diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml index 30c98070f31..7bf1d8cd43b 100644 --- a/.github/actions/install-node-dependencies/action.yml +++ b/.github/actions/install-node-dependencies/action.yml @@ -2,6 +2,10 @@ name: Install Node dependencies description: Installs the Node toolchain and repository dependencies for CI jobs, with optional Electron archive caching. inputs: + cache-pnpm-verification: + description: Restore pnpm's policy-checked lockfile verification record on Linux. + required: false + default: 'true' native-runtime: description: Native runtime to prepare after the script-free install (none, node, or electron). required: false @@ -24,6 +28,15 @@ inputs: default: 'false' outputs: + verification-cache-hit: + description: Whether pnpm's verification record was restored. + value: ${{ steps.verification-cache-restore.outputs.cache-hit }} + verification-cache-path: + description: The small pnpm-owned verification record, without registry metadata. + value: ${{ steps.verification-cache.outputs.path }} + verification-cache-key: + description: Exact verification record key, also used by the isolated PR benchmark. + value: ${{ steps.verification-cache.outputs.key }} node-version: description: Resolved Node.js version used for the install. value: ${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }} @@ -50,8 +63,9 @@ runs: uses: actions/setup-node@v6 with: node-version-file: package.json - cache: pnpm + cache: ${{ github.event_name != 'pull_request' && 'pnpm' || '' }} cache-dependency-path: ${{ inputs.cache-dependency-path }} + package-manager-cache: false - name: Setup requested Node.js id: requested-node @@ -59,8 +73,50 @@ runs: uses: actions/setup-node@v6 with: node-version: ${{ inputs.node-version }} - cache: pnpm + cache: ${{ github.event_name != 'pull_request' && 'pnpm' || '' }} cache-dependency-path: ${{ inputs.cache-dependency-path }} + package-manager-cache: false + + # PR-local stores compete with reusable build caches for the repository quota. + - name: Resolve pnpm download store + id: pnpm-store + if: github.event_name == 'pull_request' + shell: bash + env: + LOCKFILE_HASH: ${{ hashFiles(inputs.cache-dependency-path) }} + run: | + test -n "$LOCKFILE_HASH" + cache_path="$(pnpm store path --silent)" + test -n "$cache_path" + printf 'path=%s\n' "$cache_path" >> "$GITHUB_OUTPUT" + printf 'arch=%s\n' "$(node -p 'require("node:os").arch()')" >> "$GITHUB_OUTPUT" + + # Match setup-node's key and path so existing default-branch stores remain reusable. + - name: Restore pnpm download store without saving + if: github.event_name == 'pull_request' + uses: actions/cache/restore@v5 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: node-cache-${{ runner.os }}-${{ steps.pnpm-store.outputs.arch }}-pnpm-${{ hashFiles(inputs.cache-dependency-path) }} + + - name: Resolve pnpm verification cache + id: verification-cache + if: runner.os == 'Linux' && inputs.cache-pnpm-verification == 'true' + shell: bash + env: + POLICY_HASH: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc') }} + run: | + printf 'path=%s/lockfile-verified.jsonl\n' "$(pnpm cache path)" >> "$GITHUB_OUTPUT" + printf 'key=pnpm-verification-v1-%s-%s-%s-%s\n' "$RUNNER_OS" "$RUNNER_ARCH" "$(pnpm --version)" "$POLICY_HASH" >> "$GITHUB_OUTPUT" + + - name: Restore pnpm verification record + id: verification-cache-restore + if: steps.verification-cache.outputs.key != '' + continue-on-error: true + uses: actions/cache/restore@v5 + with: + path: ${{ steps.verification-cache.outputs.path }} + key: ${{ steps.verification-cache.outputs.key }} - name: Validate native runtime shell: bash @@ -96,6 +152,15 @@ runs: git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml pnpm-workspace.yaml fi + # pnpm checks the cached record's policy and validity; never bypass verification. + - name: Save pnpm verification record on main + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && steps.verification-cache.outputs.key != '' && steps.verification-cache-restore.outputs.cache-hit != 'true' + continue-on-error: true + uses: actions/cache/save@v5 + with: + path: ${{ steps.verification-cache.outputs.path }} + key: ${{ steps.verification-cache.outputs.key }} + - name: Resolve Electron package cache id: electron-package-cache if: inputs.native-runtime == 'electron' || inputs.cache-electron-package == 'true' @@ -116,12 +181,19 @@ runs: printf 'version=%s\n' "$(node -p "require('./node_modules/electron/package.json').version")" >> "$GITHUB_OUTPUT" - name: Cache Electron package archive - if: inputs.native-runtime == 'electron' || inputs.cache-electron-package == 'true' + if: (github.event_name != 'pull_request' || runner.os != 'Linux') && steps.electron-package-cache.outputs.version != '' uses: actions/cache@v5 with: path: ${{ steps.electron-package-cache.outputs.cache-root }} key: electron-package-${{ runner.os }}-${{ runner.arch }}-${{ steps.electron-package-cache.outputs.version }} + - name: Restore Electron package archive without saving + if: github.event_name == 'pull_request' && runner.os == 'Linux' && steps.electron-package-cache.outputs.version != '' + uses: actions/cache/restore@v5 + with: + path: ${{ steps.electron-package-cache.outputs.cache-root }} + key: electron-package-${{ runner.os }}-${{ runner.arch }}-${{ steps.electron-package-cache.outputs.version }} + # Why cached: `--ignore-scripts` leaves node-pty without build/Release, so # ensure-native-runtime node-gyp-compiles it in every job that asks for a runtime. # The artifacts are ABI-bound, so the key carries the target runtime, the resolved diff --git a/.github/actions/prepare-git-compatibility/action.yml b/.github/actions/prepare-git-compatibility/action.yml new file mode 100644 index 00000000000..058d31a064c --- /dev/null +++ b/.github/actions/prepare-git-compatibility/action.yml @@ -0,0 +1,32 @@ +name: Prepare baseline Git compatibility binary +description: Restore or build the pinned Linux Git binary for the compatibility contract. + +runs: + using: composite + steps: + # The default-branch warmer shares this key across PRs; a PR save is private to its merge ref. + # Cache eviction remains possible, so keep the checksum-verified cold build below. + - name: Cache baseline Git build + uses: actions/cache@v5 + with: + path: ~/.cache/orca-git-compat/git-2.25.5 + key: git-compat-baseline-${{ runner.os }}-${{ runner.arch }}-2.25.5 + + # Finish the CPU-heavy build before any timed compatibility lanes start. + - name: Build the baseline Git binary + shell: bash + run: | + archive="$RUNNER_TEMP/git-2.25.5.tar.gz" + source="$HOME/.cache/orca-git-compat/git-2.25.5" + if [ -x "$source/git" ]; then + exit 0 + fi + curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive" + echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \ + | sha256sum --check + mkdir -p "$source" + tar -xzf "$archive" -C "$source" --strip-components=1 + make -C "$source" -j"$(nproc)" \ + NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git + # Object files are no longer needed after linking the cached binary. + find "$source" -name '*.o' -delete diff --git a/.github/actions/prepare-linux-package-fixture/action.yml b/.github/actions/prepare-linux-package-fixture/action.yml new file mode 100644 index 00000000000..a84568b3e06 --- /dev/null +++ b/.github/actions/prepare-linux-package-fixture/action.yml @@ -0,0 +1,63 @@ +name: Prepare cached Linux package fixture +description: Restore Docker build layers; only the main warmer builds and saves missing images. +inputs: + fixture: + description: Directory name below config/docker. + required: true + save-cache: + description: Build and save a missing fixture on the default branch. + default: 'false' +outputs: + image: + description: Loaded image to use as a Docker build cache, or empty on a cache miss. + value: ${{ steps.image.outputs.image }} +runs: + using: composite + steps: + - name: Restore fixture image + id: cache + uses: actions/cache/restore@v5 + continue-on-error: true + with: + path: ${{ runner.temp }}/orca-package-fixtures/${{ inputs.fixture }}.tar + key: linux-package-fixture-v1-${{ runner.os }}-${{ runner.arch }}-linux-amd64-${{ inputs.fixture }}-${{ hashFiles(format('config/docker/{0}/**', inputs.fixture), '.github/actions/prepare-linux-package-fixture/action.yml') }} + + - name: Load or seed fixture build cache + id: image + shell: bash + env: + FIXTURE: ${{ inputs.fixture }} + SAVE_CACHE: ${{ inputs.save-cache }} + CACHE_HIT: ${{ steps.cache.outputs.cache-hit }} + run: | + set -euo pipefail + case "$FIXTURE" in + headless-serve-shutdown|cli-launch-contract) ;; + *) echo "Unsupported package fixture: $FIXTURE" >&2; exit 1 ;; + esac + archive="$RUNNER_TEMP/orca-package-fixtures/$FIXTURE.tar" + image="orca-package-fixture-$FIXTURE:cache" + if [[ "$CACHE_HIT" == true ]] && docker load --input "$archive"; then + if docker image inspect "$image" > /dev/null; then + echo "image=$image" >> "$GITHUB_OUTPUT" + exit 0 + fi + fi + if [[ "$SAVE_CACHE" != true ]]; then + echo 'No usable fixture cache; the package runner will build normally.' + exit 0 + fi + docker build --platform linux/amd64 --build-arg BUILDKIT_INLINE_CACHE=1 \ + --tag "$image" --file "config/docker/$FIXTURE/Dockerfile" "config/docker/$FIXTURE" + mkdir -p "$(dirname "$archive")" + docker save --output "$archive" "$image" + echo "image=$image" >> "$GITHUB_OUTPUT" + echo 'built=true' >> "$GITHUB_OUTPUT" + + - name: Save fixture image + if: inputs.save-cache == 'true' && steps.cache.outputs.cache-hit != 'true' && steps.image.outputs.built == 'true' + uses: actions/cache/save@v5 + continue-on-error: true + with: + path: ${{ runner.temp }}/orca-package-fixtures/${{ inputs.fixture }}.tar + key: ${{ steps.cache.outputs.cache-primary-key }} diff --git a/.github/actions/prepare-xterm-dependencies/action.yml b/.github/actions/prepare-xterm-dependencies/action.yml new file mode 100644 index 00000000000..8fa54bd85ae --- /dev/null +++ b/.github/actions/prepare-xterm-dependencies/action.yml @@ -0,0 +1,33 @@ +name: Restore xterm build dependencies +description: Reuse installed dependencies for the pinned upstream checkout. +outputs: + cache-key: + description: Exact dependency cache key. + value: ${{ steps.restore.outputs.cache-primary-key }} + cache-hit: + description: Whether the exact installed dependency cache was restored. + value: ${{ steps.restore.outputs.cache-hit }} +runs: + using: composite + steps: + - id: runtime + shell: bash + run: | + . /etc/os-release + echo "image=$ID-$VERSION_ID" >> "$GITHUB_OUTPUT" + echo "node=$(node --version)" >> "$GITHUB_OUTPUT" + - id: restore + uses: actions/cache/restore@v5 + with: + path: | + ${{ runner.temp }}/xterm-patch-build/upstream/.git + ${{ runner.temp }}/xterm-patch-build/upstream/node_modules + key: xterm-dependencies-v1-${{ runner.os }}-${{ steps.runtime.outputs.image }}-${{ runner.arch }}-${{ steps.runtime.outputs.node }}-${{ hashFiles('config/patches/xterm-upstream.json', 'config/scripts/regenerate-xterm-patches.mjs', 'config/scripts/xterm-patch-text.mjs', '.github/actions/prepare-xterm-dependencies/action.yml') }} + - name: Restore download cache on an installed-dependency miss + if: steps.restore.outputs.cache-hit != 'true' + uses: actions/cache/restore@v5 + with: + path: | + ~/.npm + ${{ runner.temp }}/xterm-patch-build/upstream/.git + key: xterm-upstream-${{ hashFiles('config/patches/xterm-upstream.json') }} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 9d7a73a6b88..326de25e153 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -12,7 +12,8 @@ ## Linked Issue - + + Fixes # diff --git a/.github/workflows/bun-profile-tests.yml b/.github/workflows/bun-profile-tests.yml new file mode 100644 index 00000000000..1a292292510 --- /dev/null +++ b/.github/workflows/bun-profile-tests.yml @@ -0,0 +1,171 @@ +name: Bun profile persistence + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'src/**' + - 'config/**' + - 'native/**' + - 'tests/**' + - 'resources/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'tsconfig.json' + - '.npmrc' + - '.pnpmfile.cjs' + - '.github/actions/install-node-dependencies/**' + - '.github/workflows/bun-profile-tests.yml' + # The pull request qualifies one platform for an unflavoured change; this is where all six + # are re-qualified, so a platform break surfaces minutes after merge instead of next cron. + push: + branches: [main] + paths: + - 'src/**' + - 'config/**' + - 'native/**' + - 'tests/**' + - 'resources/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'tsconfig.json' + - '.npmrc' + - '.pnpmfile.cjs' + - '.github/actions/install-node-dependencies/**' + - '.github/workflows/bun-profile-tests.yml' + workflow_dispatch: + schedule: + - cron: '30 11 * * *' + +permissions: + contents: read + +concurrency: + group: bun-profile-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + changes: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + should_run: ${{ steps.scope.outputs.should_run }} + qualification: ${{ steps.scope.outputs.qualification }} + runners: ${{ steps.scope.outputs.runners }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 2 + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + - name: Detect Bun build and test inputs + id: scope + shell: bash + run: | + # Compare the tested merge with its base, retaining both sides of renames. + if git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/bun-changes"; then + node config/scripts/bun-profile-change-scope.mjs "$RUNNER_TEMP/bun-changes" + else + echo 'should_run=true' >> "$GITHUB_OUTPUT" + fi + + persistence: + needs: changes + # Missing/failed detection runs the full matrix; manual runs remain unconditional. + # A draft carries no platform verdict; readiness re-triggers this workflow. Spelled against + # the event name so the push and schedule paths do not rest on a null property comparison. + if: >- + ${{ !cancelled() && needs.changes.outputs.should_run != 'false' && + (github.event_name != 'pull_request' || github.event.pull_request.draft != true) }} + strategy: + fail-fast: false + matrix: + os: ${{ fromJSON(needs.changes.outputs.runners || '["ubuntu-22.04","ubuntu-24.04-arm","macos-14","macos-15-intel","windows-2022","windows-11-arm"]') }} + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: ${{ runner.os == 'Windows' && 'node' || 'none' }} + # build:orcad reuses the host addon validated by native-runtime preparation. + - run: pnpm build:orcad + - run: pnpm test:bun:profile --artifact + - uses: actions/setup-node@v6 + if: runner.arch == 'X64' + with: + node-version: '18' + - name: Verify Node 18 loads and hands off to bundled Bun + if: runner.arch == 'X64' + run: | + node out/orcad/orcad.js --orcad-smoke-load-check + node out/orcad/orcad.js --orcad-profile-state-preflight 00000000-0000-4000-8000-000000000018 + + linux_glibc_floor: + needs: [changes, persistence] + # A failed smoke already blocks qualification; missing scope still selects every platform. + if: >- + ${{ !cancelled() && needs.persistence.result == 'success' && + needs.changes.outputs.should_run != 'false' && + needs.changes.outputs.qualification != 'false' }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, ubuntu-24.04-arm] + runs-on: ${{ matrix.os }} + container: ubuntu:20.04 + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - name: Install Ubuntu 20.04 prerequisites + run: apt-get update && apt-get install -y build-essential ca-certificates git python3 unzip + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Trust the checked-out workspace + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - uses: ./.github/actions/install-node-dependencies + - run: pnpm build:orcad + - run: pnpm test:bun:profile --artifact + + linux_musl: + needs: [changes, persistence] + # A failed smoke already blocks qualification; missing scope still selects every platform. + if: >- + ${{ !cancelled() && needs.persistence.result == 'success' && + needs.changes.outputs.should_run != 'false' && + needs.changes.outputs.qualification != 'false' }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, ubuntu-24.04-arm] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Verify native Alpine artifact and persistence + run: | + docker run --rm --init -i \ + -e ORCA_BACKGROUND_LAUNCH=1 \ + -v "$GITHUB_WORKSPACE:/work" -w /work \ + node:24-alpine3.23 sh -s <<'BUN_QUALIFICATION' + set -eu + apk add --no-cache bash git libstdc++ python3 make g++ + git config --global --add safe.directory /work + npm install -g "$(node -p "require('./package.json').packageManager.split('+')[0]")" + pnpm install --frozen-lockfile --ignore-scripts + pnpm build:orcad + pnpm test:bun:profile --artifact + BUN_QUALIFICATION diff --git a/.github/workflows/ci-cache-warmup.yml b/.github/workflows/ci-cache-warmup.yml new file mode 100644 index 00000000000..7309f7e3912 --- /dev/null +++ b/.github/workflows/ci-cache-warmup.yml @@ -0,0 +1,131 @@ +name: Warm shared CI caches + +on: + schedule: + - cron: '41 * * * *' + workflow_dispatch: + push: + branches: [main] + paths: + - '.github/workflows/ci-cache-warmup.yml' + - '.github/actions/install-node-dependencies/**' + - '.github/actions/prepare-git-compatibility/**' + - '.github/actions/prepare-linux-package-fixture/**' + - 'config/docker/headless-serve-shutdown/**' + - 'config/docker/cli-launch-contract/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'config/tsconfig*.json' + - 'config/scripts/ensure-native-runtime.mjs' + - 'config/scripts/rebuild-native-deps.mjs' + - 'config/patches/node-pty@1.1.0.patch' + - 'config/patches/@vscode__windows-process-tree@0.8.0.patch' + - 'native/windows-registry/**' + pull_request: + paths: + - '.github/workflows/ci-cache-warmup.yml' + - '.github/actions/prepare-git-compatibility/**' + - '.github/actions/prepare-linux-package-fixture/**' + - 'config/docker/headless-serve-shutdown/**' + - 'config/docker/cli-launch-contract/**' + - 'config/scripts/ci-cache-warmup-workflow.test.mjs' + +permissions: + contents: read + +concurrency: + group: ci-cache-warmup-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + warm: + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + # Default-branch caches can be restored by every PR; PR caches cannot. + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + node-version: '24' + cache-electron-package: 'true' + + - name: Populate shared Electron archive + run: node config/scripts/install-electron-package-binary.mjs + + - uses: ./.github/actions/prepare-git-compatibility + + - name: Cache TypeScript incremental state + id: typecheck-cache + uses: actions/cache@v5 + with: + path: config/*.tsbuildinfo + key: tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}-${{ github.sha }} + restore-keys: | + tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}- + + - name: Refresh TypeScript state for this commit + if: steps.typecheck-cache.outputs.cache-hit != 'true' + run: pnpm run typecheck + + warm-linux-arm: + runs-on: ubuntu-24.04-arm + timeout-minutes: 10 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + node-version: '24' + cache-electron-package: 'true' + - name: Populate shared Electron archive + run: node config/scripts/install-electron-package-binary.mjs + - name: Verify native cache is usable + run: node config/scripts/ensure-native-runtime.mjs --check-only + + warm-windows: + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-11-arm] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + - name: Verify native cache is usable + run: node config/scripts/ensure-native-runtime.mjs --check-only + + warm-linux-package-fixtures: + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/prepare-linux-package-fixture + with: + fixture: headless-serve-shutdown + save-cache: ${{ github.event_name != 'pull_request' }} + - uses: ./.github/actions/prepare-linux-package-fixture + with: + fixture: cli-launch-contract + save-cache: ${{ github.event_name != 'pull_request' }} diff --git a/.github/workflows/ci-closed-pr-caches.yml b/.github/workflows/ci-closed-pr-caches.yml new file mode 100644 index 00000000000..38cf5803a14 --- /dev/null +++ b/.github/workflows/ci-closed-pr-caches.yml @@ -0,0 +1,33 @@ +name: Clean closed PR caches + +on: + pull_request_target: + types: [closed] + +permissions: + actions: write + +jobs: + clean: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # No checkout: this runs trusted default-branch code, including for fork PRs. + - uses: actions/github-script@v8 + with: + script: | + const ref = `refs/pull/${context.payload.pull_request.number}/merge` + const caches = await github.paginate(github.rest.actions.getActionsCacheList, { + ...context.repo, ref, per_page: 100 + }) + for (const cache of caches) { + if (cache.ref !== ref) throw new Error(`Unexpected cache ref: ${cache.ref}`) + try { + await github.rest.actions.deleteActionsCacheById({ + ...context.repo, cache_id: cache.id + }) + } catch (error) { + if (error.status !== 404) throw error + } + } + core.info(`Removed ${caches.length} caches scoped to ${ref}`) diff --git a/.github/workflows/ci-pnpm-verification-pilot.yml b/.github/workflows/ci-pnpm-verification-pilot.yml new file mode 100644 index 00000000000..2a35a52fcd0 --- /dev/null +++ b/.github/workflows/ci-pnpm-verification-pilot.yml @@ -0,0 +1,65 @@ +name: pnpm verification cache pilot + +on: + pull_request: + paths: ['.github/workflows/ci-pnpm-verification-pilot.yml'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pnpm-verification-pilot-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + ORCA_BACKGROUND_LAUNCH: '1' + +jobs: + seed: + strategy: + matrix: + runner: [ubuntu-latest, ubuntu-24.04-arm] + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + id: deps + # PR caches are scoped to the PR merge ref; main never restores them. + - uses: actions/cache/save@v5 + if: steps.deps.outputs.verification-cache-hit != 'true' + with: + path: ${{ steps.deps.outputs.verification-cache-path }} + key: ${{ steps.deps.outputs.verification-cache-key }} + + measure: + needs: seed + name: measure ${{ matrix.runner }} sample ${{ matrix.sample }} cache ${{ matrix.cache }} + strategy: + fail-fast: false + max-parallel: 4 + matrix: + runner: [ubuntu-latest, ubuntu-24.04-arm] + sample: [1, 2, 3] + cache: ['false', 'true'] + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + id: deps + with: + cache-pnpm-verification: ${{ matrix.cache }} + - name: Validate treatment and frozen install + env: + CACHE_ENABLED: ${{ matrix.cache }} + CACHE_HIT: ${{ steps.deps.outputs.verification-cache-hit }} + run: | + if [ "$CACHE_ENABLED" = true ]; then test "$CACHE_HIT" = true; fi + git diff --exit-code -- package.json pnpm-lock.yaml pnpm-workspace.yaml + node -e "require.resolve('vitest'); require.resolve('electron')" diff --git a/.github/workflows/ci-runner-demand.yml b/.github/workflows/ci-runner-demand.yml new file mode 100644 index 00000000000..966813a65e4 --- /dev/null +++ b/.github/workflows/ci-runner-demand.yml @@ -0,0 +1,33 @@ +name: CI runner demand + +on: + schedule: + - cron: '23 4 * * *' + workflow_dispatch: + +permissions: + contents: read + actions: read + +concurrency: + group: ci-runner-demand + cancel-in-progress: false + +jobs: + report: + runs-on: ubuntu-slim + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + with: + sparse-checkout: config/scripts + persist-credentials: false + - name: Measure the previous complete 24 hours + env: + GH_TOKEN: ${{ github.token }} + run: node config/scripts/ci-runner-demand.mjs + - uses: actions/upload-artifact@v7 + with: + name: ci-runner-demand-${{ github.run_id }} + path: ci-demand/ + retention-days: 30 diff --git a/.github/workflows/ci-xterm-cache.yml b/.github/workflows/ci-xterm-cache.yml new file mode 100644 index 00000000000..fada7ea8cab --- /dev/null +++ b/.github/workflows/ci-xterm-cache.yml @@ -0,0 +1,55 @@ +name: Shared xterm dependency cache +on: + push: + branches: [main] + paths: + - '.github/workflows/ci-xterm-cache.yml' + - '.github/actions/prepare-xterm-dependencies/**' + - 'config/patches/xterm-upstream.json' + - 'config/scripts/regenerate-xterm-patches.mjs' + - 'config/scripts/xterm-patch-text.mjs' + - 'package.json' + # Refresh after Node patch releases or cache eviction without rebuilding on hits. + schedule: + - cron: '17 5 * * *' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: xterm-cache-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +env: + ORCA_BACKGROUND_LAUNCH: '1' +jobs: + seed: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04-arm + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version-file: package.json + package-manager-cache: false + - uses: ./.github/actions/prepare-xterm-dependencies + id: cache + - name: Verify and populate dependencies + if: steps.cache.outputs.cache-hit != 'true' + run: node config/scripts/regenerate-xterm-patches.mjs --check --work-dir="$RUNNER_TEMP/xterm-patch-build" + - uses: actions/cache/save@v5 + if: steps.cache.outputs.cache-hit != 'true' + with: + path: | + ${{ runner.temp }}/xterm-patch-build/upstream/.git + ${{ runner.temp }}/xterm-patch-build/upstream/node_modules + key: ${{ steps.cache.outputs.cache-key }} + - name: Preserve fallback downloads + if: steps.cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: | + ~/.npm + ${{ runner.temp }}/xterm-patch-build/upstream/.git + key: xterm-upstream-${{ hashFiles('config/patches/xterm-upstream.json') }} diff --git a/.github/workflows/cloud-verify.yml b/.github/workflows/cloud-verify.yml index 6191552dab9..6c4cb9a53a1 100644 --- a/.github/workflows/cloud-verify.yml +++ b/.github/workflows/cloud-verify.yml @@ -32,9 +32,30 @@ jobs: steps: - uses: actions/checkout@v4 with: - fetch-depth: 0 + fetch-depth: 1 + + - name: Pull Gitleaks image + id: gitleaks-image + background: true + run: docker pull zricethezav/gitleaks@sha256:cdbb7c955abce02001a9f6c9f602fb195b7fadc1e812065883f695d1eeaba854 + + - name: Pull TruffleHog image + id: trufflehog-image + background: true + run: docker pull trufflesecurity/trufflehog@sha256:5dc064868ba7933601b5cbaea6954954d524ddd5dc6222a9667acea70068bf7d + + # Scan every ancestor of HEAD without downloading unrelated branch/tag histories. + - name: Fetch complete scan history + working-directory: . + run: | + git fetch --no-tags --unshallow origin "$GITHUB_SHA" + test "$(git rev-parse --is-shallow-repository)" = false + + - wait: [gitleaks-image, trufflehog-image] - name: Scan Cloud history reachable from HEAD with Gitleaks + id: history-scan + background: true run: >- docker run --rm --volume "${GITHUB_WORKSPACE}:/repo:ro" @@ -51,6 +72,8 @@ jobs: --include-paths=/repo/cloud/.trufflehog-include-paths.txt --exclude-paths=/repo/cloud/.trufflehog-exclude-paths.txt + - wait: history-scan + # Compiles the workspace. No Postgres service: nothing here reaches a # database, and the service container costs ~13s of startup. build: diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index f42d1184ce9..4177b141d8a 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -32,9 +32,8 @@ on: required: false type: string schedule: - # Why: GitHub cron uses UTC; these slots map to 10am and 3pm - # America/Phoenix for the default-branch E2E run. - - cron: '0 17,22 * * *' + # One complete daily reference run; targeted PR coverage remains unchanged. + - cron: '0 17 * * *' jobs: build: @@ -62,11 +61,26 @@ jobs: status=0 pnpm run build:relay & relay_pid=$! - npx electron-vite build --mode e2e || status=1 - pnpm run build:web-from-renderer || status=1 + pnpm run build:electron-vite:parallel --mode e2e || status=1 wait "$relay_pid" || status=1 exit "$status" + # The CLI emits into out/main too; start only after Electron finishes clearing that tree. + - name: Build shared E2E CLI + id: e2e-cli + background: true + run: | + if node -e 'process.exit(require("./package.json").scripts["prepare:cli-output"] ? 0 : 1)'; then + pnpm run build:cli + else + echo "Older ref: let each consumer build and install its CLI." + fi + + - name: Project shared E2E web client + run: pnpm run build:web-from-renderer + + - wait: e2e-cli + - name: Upload E2E build output uses: actions/upload-artifact@v7 with: @@ -186,7 +200,14 @@ jobs: node config/scripts/ci-e2e-shard-plan.mjs --verify ci-shards/assignment.json ci-shards/selected-discovery.json - name: Run E2E tests (${{ matrix.shard_name }}) - run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --test-list=ci-shards/selected.txt + env: + PLAYWRIGHT_JSON_OUTPUT_FILE: ci-shards/results.json + run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --test-list=ci-shards/selected.txt --reporter=list,json + + - name: Summarize E2E failures + if: always() + continue-on-error: true + run: node config/scripts/ci-e2e-failure-summary.mjs ci-shards/results.json - name: Upload E2E shard assignment if: always() @@ -221,8 +242,6 @@ jobs: needs: [build, prepare-native-cache] if: inputs.test_files != '' runs-on: ubuntu-latest - # Why 45: pr.yml now maps SSH source edits onto Docker-backed specs, so this lane can - # pay a container image build plus ~22 serial SSH tests on top of the changed specs. timeout-minutes: 45 steps: @@ -256,14 +275,38 @@ jobs: # ORCA_E2E_NATIVE_IBUS_HANGUL, which this lane cannot set because it has no ibus # session. Running it here reported a green skip as coverage. mapfile -t TEST_FILES < <(jq -r '.[] | select( + . != "tests/e2e/local-ssh-browser-routing.spec.ts" and + . != "tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts" and + . != "tests/e2e/pty-input-write-queue-ssh.spec.ts" and + . != "tests/e2e/ssh-ai-vault-session-history.spec.ts" and + . != "tests/e2e/ssh-codex-display-artifacts-repro.spec.ts" and + . != "tests/e2e/ssh-cold-activation-restore.spec.ts" and + . != "tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts" and + . != "tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts" and + . != "tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts" and + . != "tests/e2e/ssh-docker-half-open-link.spec.ts" and + . != "tests/e2e/ssh-docker-quick-open-large-listing.spec.ts" and + . != "tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts" and + . != "tests/e2e/ssh-docker-relay-stall-credential.spec.ts" and + . != "tests/e2e/ssh-docker-resource-accumulation.spec.ts" and + . != "tests/e2e/ssh-docker-transport-drop-recovery.spec.ts" and + . != "tests/e2e/ssh-external-image-preview.spec.ts" and + . != "tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts" and + . != "tests/e2e/ssh-pi-compatible-agent-title.spec.ts" and + . != "tests/e2e/ssh-port-forward-lifecycle.spec.ts" and + . != "tests/e2e/ssh-reconnect-tab-destruction.spec.ts" and + . != "tests/e2e/ssh-restart-tab-accumulation.spec.ts" and + . != "tests/e2e/ssh-skill-installation.spec.ts" and + . != "tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts" and + . != "tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts" and + . != "tests/e2e/terminal-inline-images-ssh.spec.ts" and + . != "tests/e2e/ssh-docker-watcher-isolation.spec.ts" and + . != "tests/e2e/ssh-terminal-parking.spec.ts" and + . != "tests/e2e/terminal-retention-budget.spec.ts" and . != "tests/e2e/ssh-startup-exec-readiness.spec.ts" and . != "tests/e2e/paired-startup-exec-readiness.spec.ts" and - . != "tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts" and - . != "tests/e2e/local-ssh-browser-routing.spec.ts" and . != "tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts" and . != "tests/e2e/ssh-localhost.spec.ts" and - . != "tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts" and - . != "tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts" and . != "tests/e2e/terminal-ibus-hangul-native.spec.ts" )' <<<"$TEST_FILES_JSON") if [ "${#TEST_FILES[@]}" -eq 0 ]; then @@ -295,25 +338,47 @@ jobs: if-no-files-found: ignore ssh-docker-watcher-isolation: - name: ssh docker watcher isolation + name: ssh docker watcher isolation (${{ matrix.shard }}/4) needs: [build, prepare-native-cache] - # effect of one route listing a startup-readiness spec — pruning that spec would have - # silently retired the whole lane. The signal is now derived from the SSH routes directly. - # The explicit spec clauses stay for their honest purpose: changed-e2e hands these specs to this - # lane, so editing one must still run it here. + # Each excluded changed spec must trigger its dedicated owner. if: >- inputs.test_files == '' || inputs.ssh_source_changed == 'true' || - contains(inputs.test_files, 'tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts') || contains(inputs.test_files, 'tests/e2e/local-ssh-browser-routing.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-startup-exec-readiness.spec.ts') || + contains(inputs.test_files, 'tests/e2e/pty-input-write-queue-ssh.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-ai-vault-session-history.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-cold-activation-restore.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-docker-half-open-link.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-docker-quick-open-large-listing.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-docker-relay-stall-credential.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-docker-resource-accumulation.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-external-image-preview.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-pi-compatible-agent-title.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-port-forward-lifecycle.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-restart-tab-accumulation.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-skill-installation.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts') || + contains(inputs.test_files, 'tests/e2e/terminal-inline-images-ssh.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-docker-watcher-isolation.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-terminal-parking.spec.ts') || + contains(inputs.test_files, 'tests/e2e/terminal-retention-budget.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-startup-exec-readiness.spec.ts') || contains(inputs.test_files, 'tests/e2e/paired-startup-exec-readiness.spec.ts') runs-on: ubuntu-latest - # Why 60: this lane now also runs the remaining Docker-SSH specs serially. They average - # ~18s but several budget 4-10 minutes per test, so a slow run lands far above the old 35 - # — and the sharded lanes already show that a lane which times out is a lane nobody trusts. + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] timeout-minutes: 60 steps: @@ -338,13 +403,14 @@ jobs: # Why: this is the release-path proof that the deployed Linux relay keeps # its PTY and explorer live across a real watcher SIGSEGV. - name: Run Docker SSH watcher isolation E2E + if: matrix.shard == 1 run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation # Why: Playwright empties test-results/ when it starts, so each step here used to # destroy the previous step's traces. Only the last lane's failure was ever # diagnosable from the artifact; set each lane aside before the next one runs. - name: Keep watcher-isolation traces - if: always() + if: always() && matrix.shard == 1 run: | if [ -d test-results ]; then mkdir -p e2e-traces @@ -354,24 +420,21 @@ jobs: # Why always(): this lane gates SSH parking/retention plus startup-exec # readiness across live SSH, headed paired, and headless serve topologies. - name: Run Docker SSH terminal parking + startup readiness E2E - if: always() + if: always() && matrix.shard == 1 run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking - name: Keep terminal-parking traces - if: always() + if: always() && matrix.shard == 1 run: | if [ -d test-results ]; then mkdir -p e2e-traces mv test-results "e2e-traces/terminal-parking" fi - # Why here rather than the sharded lanes: the shards set no ORCA_E2E_SSH_DOCKER, so every - # spec below skipped itself while the shard still reported green. Running them on this one - # VM pays the fixture image build once instead of ten times, and keeps an SSH regression - # legible as an SSH-named failure. + # Separate VMs isolate destructive SSH fixtures while keeping one worker per shard. - name: Run remaining Docker SSH E2E if: always() - run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker + run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker --shard=${{ matrix.shard }}/4 - name: Keep remaining-ssh-docker traces if: always() @@ -385,7 +448,7 @@ jobs: if: failure() uses: actions/upload-artifact@v7 with: - name: playwright-traces-ssh-docker-watcher-isolation + name: playwright-traces-ssh-docker-watcher-isolation-${{ matrix.shard }} path: e2e-traces/ retention-days: 7 if-no-files-found: ignore diff --git a/.github/workflows/git-command-termination-runtime.yml b/.github/workflows/git-command-termination-runtime.yml index 1602bae956a..6b49177ed56 100644 --- a/.github/workflows/git-command-termination-runtime.yml +++ b/.github/workflows/git-command-termination-runtime.yml @@ -7,6 +7,9 @@ on: workflow_dispatch: permissions: contents: read +concurrency: + group: git-command-termination-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: windows-exit: runs-on: windows-latest diff --git a/.github/workflows/golden-e2e-experiment.yml b/.github/workflows/golden-e2e-experiment.yml index 11cfa866c67..0b80662d716 100644 --- a/.github/workflows/golden-e2e-experiment.yml +++ b/.github/workflows/golden-e2e-experiment.yml @@ -66,10 +66,10 @@ jobs: - name: Run golden E2E tests on Linux if: runner.os == 'Linux' run: | - xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts + xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e tests/e2e/golden-core-flows.spec.ts xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden if [ -f tests/e2e/golden-fresh-profile-terminal.spec.ts ]; then - xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts + xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts fi xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden @@ -80,10 +80,10 @@ jobs: - name: Run golden E2E tests on macOS if: runner.os == 'macOS' run: | - env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts + env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e tests/e2e/golden-core-flows.spec.ts env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden if [ -f tests/e2e/golden-fresh-profile-terminal.spec.ts ]; then - env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts + env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts fi env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden @@ -104,7 +104,7 @@ jobs: pnpm run --if-present test:e2e:tab-bar-agent-launch-golden if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } if (Test-Path tests/e2e/golden-fresh-profile-terminal.spec.ts) { - pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts + pnpm run test:e2e tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } pnpm run --if-present test:e2e:source-control-golden diff --git a/.github/workflows/homebrew-bump.yml b/.github/workflows/homebrew-bump.yml index 9b04d3205fc..a5f9b352f6e 100644 --- a/.github/workflows/homebrew-bump.yml +++ b/.github/workflows/homebrew-bump.yml @@ -132,11 +132,10 @@ jobs: print(src) PY - # Why: reuse the existing buf0-bot GitHub App (also used by - # track-community-prs.yaml) instead of minting a new PAT. The app is - # installed org-wide, so it has access to stablyai/homebrew-orca - # automatically. GITHUB_TOKEN cannot push cross-repo; a short-lived - # installation token can. + # Why: reuse the existing buf0-bot GitHub App instead of minting a new + # PAT. The app is installed org-wide, so it has access to + # stablyai/homebrew-orca automatically. GITHUB_TOKEN cannot push + # cross-repo; a short-lived installation token can. - name: Generate buf0-bot token id: app-token uses: actions/create-github-app-token@v3 diff --git a/.github/workflows/issue-os-labeler.yaml b/.github/workflows/issue-os-labeler.yaml index c437db7f21a..cf0ee59a842 100644 --- a/.github/workflows/issue-os-labeler.yaml +++ b/.github/workflows/issue-os-labeler.yaml @@ -12,7 +12,7 @@ permissions: jobs: apply-os-label: - runs-on: ubuntu-latest + runs-on: ubuntu-slim timeout-minutes: 5 steps: - name: Apply OS label from issue form diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index aae50234bf9..bc9f8241f94 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -6,7 +6,6 @@ on: - opened - synchronize - reopened - - ready_for_review paths: - 'mobile/**' # Mobile launch contracts exercise the real host dispatcher and durable receipt store. @@ -38,9 +37,12 @@ on: - '.github/workflows/mobile.yml' - '.github/actions/install-node-dependencies/**' - '.github/workflows/mobile-ios-release.yml' - # Why main too: a behaviour-change branch legitimately pins its own last fenced commit, and that - # commit only stops being reachable when the branch squash-merges. The pull_request run cannot - # see that; this one is where the pin guard finds it. + - 'config/scripts/mobile-release-check-scope*' + - 'config/scripts/mobile-test-change-scope*' + - 'config/scripts/pr-code-change-scope.mjs' + - 'config/scripts/mobile-recording-pin-checkout.test.mjs' + # Why main too: a squash is where a spliced corpus lands, and where a behaviour-change branch's + # own pinned commit leaves main's history for its pull request's head ref, which the guard follows. push: branches: - main @@ -58,7 +60,10 @@ concurrency: jobs: verify: if: github.event_name == 'pull_request' - runs-on: ubuntu-latest + # Why ARM: 209s of this job is Vitest and nothing here needs x86: no Android SDK, emulator, gradle, + # Hermes or Watchman, no docker, and no artifacts. The Gemfile.lock lists the generic `ruby` + # platform, so frozen bundler installs without an aarch64-linux entry. + runs-on: ubuntu-24.04-arm env: # Why: an unfrozen bundler silently re-resolves when Gemfile.lock drifts @@ -73,6 +78,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + fetch-depth: 2 - uses: ./.github/actions/install-node-dependencies with: @@ -80,10 +87,23 @@ jobs: pnpm-lock.yaml mobile/pnpm-lock.yaml + - name: Detect Ruby release inputs + id: ruby-scope + shell: bash + working-directory: . + run: | + # Keep deletions when release files move into an application directory. + if ! git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/mobile-release-changes"; then + echo 'should_run=true' >> "$GITHUB_OUTPUT" + elif ! node config/scripts/mobile-release-check-scope.mjs "$RUNNER_TEMP/mobile-release-changes"; then + echo 'should_run=true' >> "$GITHUB_OUTPUT" + fi + # bundler-cache installs mobile/Gemfile.lock, so this job is also what # proves the pinned fastlane the release workflow depends on still # resolves — before a release run finds out. - name: Setup Ruby and fastlane + if: steps.ruby-scope.outputs.should_run != 'false' uses: ruby/setup-ruby@v1 with: ruby-version: '3.3' @@ -93,7 +113,10 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + # Both compilers are read-only; finish them before starting the test workers. - name: Typecheck + id: production-types + background: true run: pnpm typecheck # Why a ratchet and not the raw typecheck: mobile/tsconfig.json excludes test files, so until @@ -103,19 +126,37 @@ jobs: - name: Typecheck tests (ratchet) run: pnpm run check:tests-typecheck + - wait: production-types + # This includes the bridged replay of the whole recording corpus, which used to be a second # step of its own behind RPC_FOUNDATION_BRIDGE=1. A gate nobody can forget to set is the point: # it fails when a divergence class grows, when a divergence lands in no class at all, or when # one of the 103 goldens inside the C1 page closure changes the verdict it is pinned to. It is # ~3 min of test time on its own, and Vitest runs it on a worker beside the rest of the suite, # so folding it in costs a fraction of that in wall time and one step less to skip. + - name: Detect mobile test inputs + id: test-scope + shell: bash + working-directory: . + run: | + if ! git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/mobile-test-changes"; then + echo 'should_run=true' >> "$GITHUB_OUTPUT" + elif ! node config/scripts/mobile-test-change-scope.mjs "$RUNNER_TEMP/mobile-test-changes"; then + echo 'should_run=true' >> "$GITHUB_OUTPUT" + fi + - name: Test + if: steps.test-scope.outputs.should_run != 'false' + env: + ORCA_BACKGROUND_LAUNCH: '1' run: pnpm test - name: Test iOS release version resolution + if: steps.ruby-scope.outputs.should_run != 'false' run: ruby fastlane/ios_release_version_test.rb - name: Test TestFlight lane arguments + if: steps.ruby-scope.outputs.should_run != 'false' run: ruby fastlane/fastfile_testflight_arguments_test.rb # Why: nothing else in CI loads the Fastfile, so a syntax error, a broken @@ -124,6 +165,7 @@ jobs: # loads and lists, so it needs no App Store Connect credentials and makes # no network calls to Apple. - name: Smoke-check the Fastfile + if: steps.ruby-scope.outputs.should_run != 'false' env: FASTLANE_SKIP_UPDATE_CHECK: '1' FASTLANE_OPT_OUT_USAGE: '1' @@ -137,7 +179,12 @@ jobs: recording-pin: name: RPC recording pin - runs-on: ubuntu-latest + # Why ARM: pure Node plus git; the golden comparison masks `platform`. + runs-on: ubuntu-24.04-arm + # Why pull-requests: the guard asks GitHub which pull requests hold a pin main's history lacks. + permissions: + contents: read + pull-requests: read defaults: run: @@ -147,11 +194,13 @@ jobs: - name: Checkout uses: actions/checkout@v6 with: - # The ancestry verdict is read straight off history. On a shallow checkout + # The reachability verdict is read straight off history. On a shallow checkout # `git merge-base --is-ancestor` answers from grafted parents, so the guard refuses to # answer at all rather than reporting a pass it has no evidence for -- and the pinned tree # below has to be checkable out. fetch-depth: 0 + # Ancestry needs commits; the pinned worktree fetches its historical blobs on demand. + filter: blob:none - uses: ./.github/actions/install-node-dependencies with: @@ -165,12 +214,14 @@ jobs: # Seconds. No `--ref`, so the pin is judged against the same tree it was read out of. On a # pull request that is the merge preview, which already carries main's repins; judging the # branch head instead fails every branch cut before the day's repin, and its instruction would - # tell the author to pin their own head -- creating the break this guard exists to catch. A - # branch that pins its own commit passes here and fails on the push after the squash, which is - # where the pin actually leaves the history. + # tell the author to pin their own head. A branch that pins its own commit still passes on the + # push after the squash: the guard asks GitHub which pull requests hold the pin and fetches + # their `refs/pull//head`, which GitHub keeps for good. The token lifts the API rate limit. - name: Check the recording pin is reachable shell: bash - run: pnpm exec tsx scripts/rpc-recording-pin-guard.mts ancestry + env: + GITHUB_TOKEN: ${{ github.token }} + run: pnpm exec tsx scripts/rpc-recording-pin-guard.mts reachable # ~2 min locally for the record itself, so it is gated rather than run twice over. A pull # request that moves none of the corpus, the manifest or the recorder cannot move this @@ -180,6 +231,7 @@ jobs: - name: Reproduce the corpus from the pinned tree shell: bash env: + GITHUB_TOKEN: ${{ github.token }} PIN_GUARD_BASE: ${{ github.event.pull_request.base.sha }} run: | if [ -n "$PIN_GUARD_BASE" ]; then diff --git a/.github/workflows/node-next-compat.yml b/.github/workflows/node-next-compat.yml index 43556c864dc..fc9b9cedaf9 100644 --- a/.github/workflows/node-next-compat.yml +++ b/.github/workflows/node-next-compat.yml @@ -1,8 +1,8 @@ -name: Node next compatibility +name: Scheduled x86 unit compatibility on: schedule: - # Full future-runtime coverage is useful, but not worth doubling every PR matrix. + # Keep current and future Node coverage on x86 while PR unit shards use ARM. - cron: '0 10 * * *' workflow_dispatch: @@ -14,9 +14,13 @@ permissions: contents: read jobs: - # A cold cache would otherwise make all eight Node 26 shards compile the same native addons. + # Prime each Node ABI before its shards restore native modules. test_native_cache: - name: prepare test native cache node 26 + name: prepare test native cache node ${{ matrix.node }} + strategy: + fail-fast: false + matrix: + node: ['24', '26'] runs-on: ubuntu-latest steps: @@ -28,10 +32,14 @@ jobs: - uses: ./.github/actions/install-node-dependencies with: native-runtime: node - node-version: '26' + node-version: ${{ matrix.node }} + + unit_plan: + uses: ./.github/workflows/unit-plan.yml test: - needs: [test_native_cache] + needs: [test_native_cache, unit_plan] uses: ./.github/workflows/unit-tests.yml with: - node_versions: '["26"]' + node_versions: '["24", "26"]' + shards: ${{ needs.unit_plan.outputs.shards }} diff --git a/.github/workflows/packaged-browser-e2e.yml b/.github/workflows/packaged-browser-e2e.yml index 2a23ac58988..7d36e7fd1b1 100644 --- a/.github/workflows/packaged-browser-e2e.yml +++ b/.github/workflows/packaged-browser-e2e.yml @@ -39,7 +39,11 @@ jobs: root=pathlib.Path(os.environ['RUNNER_TEMP'])/'old-orca' package=root/'orca-ide_1.4.188_amd64.deb' expected='uGONFUDfinYggxcT9ac72wnnlofLQaqasDDeP0HWOSqarBwTi1Ax3khmzKUY3vUnvuYOpSCEmsH4InzLZ2vg6g==' - assert base64.b64encode(hashlib.sha512(package.read_bytes()).digest()).decode()==expected + digest=hashlib.sha512() + with package.open('rb') as archive: + for block in iter(lambda: archive.read(1024*1024), b''): + digest.update(block) + assert base64.b64encode(digest.digest()).decode()==expected extracted=root/'extracted' subprocess.run(['dpkg-deb','-x',str(package),str(extracted)],check=True) executable=extracted/'opt'/'Orca'/'orca-ide' diff --git a/.github/workflows/pi-owner-runtime.yml b/.github/workflows/pi-owner-runtime.yml index 9f9df4f9b0b..592bc75bb5e 100644 --- a/.github/workflows/pi-owner-runtime.yml +++ b/.github/workflows/pi-owner-runtime.yml @@ -9,6 +9,9 @@ on: workflow_dispatch: permissions: contents: read +concurrency: + group: pi-owner-runtime-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: runtime: strategy: diff --git a/.github/workflows/pi-provider-runtime.yml b/.github/workflows/pi-provider-runtime.yml index 837c38baf9a..31046737499 100644 --- a/.github/workflows/pi-provider-runtime.yml +++ b/.github/workflows/pi-provider-runtime.yml @@ -7,6 +7,9 @@ on: - '.github/workflows/pi-provider-runtime.yml' permissions: contents: read +concurrency: + group: pi-provider-runtime-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: runtime: strategy: diff --git a/.github/workflows/pr-test-loc.yml b/.github/workflows/pr-test-loc.yml index a884aedc636..340c6c7b70d 100644 --- a/.github/workflows/pr-test-loc.yml +++ b/.github/workflows/pr-test-loc.yml @@ -6,7 +6,6 @@ on: - opened - synchronize - reopened - - ready_for_review concurrency: group: pr-test-loc-${{ github.event.pull_request.number }} @@ -19,7 +18,8 @@ permissions: jobs: loc: name: test vs non-test LoC - runs-on: ubuntu-latest + # API calls and a small Node script fit the free single-CPU container runner. + runs-on: ubuntu-slim timeout-minutes: 2 steps: # Why no checkout: the Files API already has per-file additions/deletions. diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 1b52aac7571..c961f525a38 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1,4 +1,5 @@ name: PR Checks +run-name: "PR ${{ github.event.pull_request.number }} | source ${{ github.sha }} | workflow ${{ github.workflow_sha }} | unit ${{ github.event.pull_request.draft && vars.ORCA_UNIT_SELECTION_MODE == 'selected' && 'selected' || 'full' }}" on: pull_request: @@ -23,25 +24,32 @@ jobs: # Per-job outputs also skip git-compat/xterm/packaging/shell when those # inputs are unchanged; empty diffs fail closed and run everything. code_paths: - name: detect code-relevant changes - runs-on: ubuntu-latest + name: detect changes and check repository guards + # Reuse one lightweight checkout for detection and the always-required guards. + runs-on: ubuntu-slim + timeout-minutes: 5 + permissions: + contents: read + actions: read outputs: should_run: ${{ steps.filter.outputs.should_run }} - native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }} + reused_run_id: ${{ steps.readiness.outputs.run_id }} + # A proven success masks required work only; advisory routing still uses the full diff. + native_cache_changed: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.native_cache_changed }} mobile_dependencies: ${{ steps.filter.outputs.mobile_dependencies }} - mobile_web_app: ${{ steps.filter.outputs.mobile_web_app }} - static_analysis: ${{ steps.filter.outputs.static_analysis }} - typecheck: ${{ steps.filter.outputs.typecheck }} - git_compatibility: ${{ steps.filter.outputs.git_compatibility }} - codex_index_heal_contract: ${{ steps.filter.outputs.codex_index_heal_contract }} - xterm_patch_sync: ${{ steps.filter.outputs.xterm_patch_sync }} - shell_contracts: ${{ steps.filter.outputs.shell_contracts }} - test: ${{ steps.filter.outputs.test }} - orcad_browser: ${{ steps.filter.outputs.orcad_browser }} - cross-version-wire: ${{ steps.filter.outputs.cross-version-wire }} - managed_hook_node18: ${{ steps.filter.outputs.managed_hook_node18 }} - package: ${{ steps.filter.outputs.package }} - package_windows: ${{ steps.filter.outputs.package_windows }} + mobile_web_app: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.mobile_web_app }} + static_analysis: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.static_analysis }} + typecheck: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.typecheck }} + git_compatibility: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.git_compatibility }} + codex_index_heal_contract: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.codex_index_heal_contract }} + xterm_patch_sync: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.xterm_patch_sync }} + shell_contracts: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.shell_contracts }} + test: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.test }} + orcad_browser: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.orcad_browser }} + cross-version-wire: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.cross-version-wire }} + managed_hook_node18: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.managed_hook_node18 }} + package: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.package }} + package_windows: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.package_windows }} e2e_should_run: ${{ steps.e2e_filter.outputs.should_run }} test_files: ${{ steps.e2e_filter.outputs.test_files }} ssh_source_changed: ${{ steps.e2e_filter.outputs.ssh_source_changed }} @@ -51,23 +59,59 @@ jobs: - name: Checkout uses: actions/checkout@v6 with: - # Why blob:none: full history is needed for the merge-base diff, but historical - # file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the - # few this job actually reads on demand. - fetch-depth: 0 + # Why depth 50 and not 0: every diff below resolves to HEAD^1, so only the merge commit + # and a little slack are needed. Fetching all 8127 refs' commit graph cost ~20s here and + # is charged to the start of all 22 jobs, since they all need this one. + # Why blob:none stays: the sparse tree below is ~7 files, so there are no blobs to + # materialize and no promisor refetch. Measured 9.5s -> 1.6s against 20.7s today. + fetch-depth: 50 filter: blob:none + sparse-checkout: | + /config/scripts/git-pull-request-diff-base.mjs + /package.json + /README.md + /docs/readme/ + /.github/scripts/check-root-directory-entries.mjs + /config/scripts/check-readme-local-links.mjs + /config/scripts/pr-code-change-scope.mjs + /config/scripts/pr-e2e-source-routing.mjs + /config/scripts/pr-ready-check-reuse.mjs + sparse-checkout-cone-mode: false persist-credentials: false + - name: Reject new root-level files and folders + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")" + node .github/scripts/check-root-directory-entries.mjs "$DIFF_BASE" HEAD + + # The full Git index retains link targets outside the sparse working tree. + - name: Check README local links + run: node config/scripts/check-readme-local-links.mjs + + # Readiness changes eligibility for advisory tests, not the already-tested source. + - name: Find identical successful required checks + id: readiness + if: github.event.action == 'ready_for_review' + env: + GH_TOKEN: ${{ github.token }} + PR_CHECK_WORKFLOW_SHA: ${{ github.workflow_sha }} + run: node config/scripts/pr-ready-check-reuse.mjs + - name: Classify changed paths id: filter env: BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | set -euo pipefail + # Why HEAD^1 and not --merge-base: HEAD is the pull request merge commit, so its first + # parent is the base side already. Computing a merge base instead would require the + # payload base SHA to be in the graph, which is what forced a full-history checkout. + DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")" # Why --no-renames: name-only rename detection can report only the destination. # A code file moved under docs/ must still expose its code-side deletion. - CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR --merge-base "$BASE_SHA" "$HEAD_SHA")" + CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR "$DIFF_BASE" HEAD)" echo "Changed paths:" printf '%s\n' "$CHANGED" printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs | tee -a "$GITHUB_OUTPUT" @@ -79,8 +123,8 @@ jobs: run: | set -euo pipefail BASE="${{ github.event.pull_request.base.sha }}" - HEAD="${{ github.event.pull_request.head.sha }}" - CHANGED="$(git diff --name-only --diff-filter=AMCR --merge-base "$BASE" "$HEAD")" + DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE")" + CHANGED="$(git diff --name-only --diff-filter=AMCR "$DIFF_BASE" HEAD)" # Source routes are executable contracts so a test can prove exact # authorities, exclusions, and sentinels without evaluating workflow shell. TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs)" @@ -94,7 +138,7 @@ jobs: # trigger on IME source rather than on a spec name in some route's list. NATIVE_IME_SOURCE_CHANGED="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --native-ime-source)" echo "native_ime_source_changed=$NATIVE_IME_SOURCE_CHANGED" >> "$GITHUB_OUTPUT" - WSL_CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR --merge-base "$BASE" "$HEAD")" + WSL_CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR "$DIFF_BASE" HEAD)" WSL_SOURCE_CHANGED="$(printf '%s\n' "$WSL_CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --wsl-source)" echo "wsl_source_changed=$WSL_SOURCE_CHANGED" >> "$GITHUB_OUTPUT" echo "Native IME source changed: $NATIVE_IME_SOURCE_CHANGED" @@ -111,38 +155,63 @@ jobs: name: static analysis needs: [code_paths] if: needs.code_paths.outputs.static_analysis == 'true' - runs-on: ubuntu-latest + # Why ARM: measured 128s against 172s on ubuntu-latest, with every compute step faster -- + # type-aware 24s->15s, anti-slop 28->19s, localization extraction 67->46s, the orcad smoke + # 39->14s. Both lint engines ship linux-arm64 and the Bun target follows process.arch, so + # the whole toolchain resolves. Free for public repositories, same as the typecheck job. + runs-on: ubuntu-24.04-arm steps: - name: Checkout uses: actions/checkout@v6 with: - # Why blob:none: full history is needed for the merge-base diff, but historical - # file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the - # few this job actually reads on demand. - fetch-depth: 0 - filter: blob:none + # Why depth 50: the gates below diff against HEAD^1, so no merge base is computed and + # the payload base SHA need not be in the graph. + # Why no blob:none here, unlike code_paths: this job checks out all 30,226 files, and + # the filter then forces a second promisor fetch of nearly every blob. Measured 23s + # blobless against ~11s without it. + fetch-depth: 50 persist-credentials: false + # Why two guarded installs: the mixed root+mobile store entry is 537 MB against + # 321 MB for root alone, and restoring it costs 8.6s against 4.6s. Most PRs skip the + # mobile install below, so they were paying 216 MB for packages they never link. The + # root-only key is also the one the hourly warmer reseeds. Only one of these runs. - uses: ./.github/actions/install-node-dependencies + if: needs.code_paths.outputs.mobile_dependencies != 'true' + with: + native-runtime: node + + - uses: ./.github/actions/install-node-dependencies + if: needs.code_paths.outputs.mobile_dependencies == 'true' with: native-runtime: node cache-dependency-path: | pnpm-lock.yaml mobile/pnpm-lock.yaml + # Keep each check in its own log while sharing this runner. - name: Lint + id: root-lint + background: true run: pnpm exec oxlint --format github - name: Reject low-evidence patterns run: pnpm run audit:anti-slop + - wait: root-lint + - name: Enforce focused code-quality plugins + id: native-code-quality + background: true run: pnpm run audit:code-quality:native - name: Enforce type-aware code-quality baseline run: pnpm run audit:code-quality:type-aware + # Mobile installation changes import resolution for the native cycle check. + - wait: native-code-quality + # Why here: the changed-code gate lints mobile files too, and its type-aware pass # resolves types from mobile/node_modules. Without the install every mobile type # degrades to an `error` type — reported as phantom findings against the changed lines. @@ -150,11 +219,15 @@ jobs: if: needs.code_paths.outputs.mobile_dependencies == 'true' - name: Enforce changed-code quality + id: changed-code-quality + background: true run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}" - name: Enforce React Doctor on changed lines run: pnpm run check:react-doctor:changed -- "${{ github.event.pull_request.base.sha }}" + - wait: changed-code-quality + - name: Check Zustand selector fan-out budget run: pnpm run check:zustand-selector-fanout @@ -167,9 +240,9 @@ jobs: - name: Check VM runtime rollback compatibility env: BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | - if git diff --quiet --merge-base "$BASE_SHA" "$HEAD_SHA" -- \ + DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")" + if git diff --quiet "$DIFF_BASE" HEAD -- \ src/shared/ephemeral-vm-runtime-store.ts \ src/shared/ephemeral-vm-runtime-feature-store.ts \ src/shared/ephemeral-vm-runtime-rollback-projection.ts \ @@ -194,11 +267,39 @@ jobs: - name: Enforce runtime Electron-import ratchet run: pnpm run check:runtime-electron-ratchet + # Why: extraction writes sorted evidence to an isolated temporary path, + # so feature PRs need one normalized AST pass rather than a three-OS matrix. + - name: Verify localization extraction + id: localization-extraction + background: true + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + # Detection failures run the full check; renames retain the removed input path. + DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")" + if git diff --name-only --no-renames -z "$DIFF_BASE" HEAD > "$RUNNER_TEMP/localization-changes" && + scope="$(node config/scripts/localization-extraction-change-scope.mjs "$RUNNER_TEMP/localization-changes")" && [ "$scope" = false ]; then + echo "Localization extraction inputs are unchanged." + else + pnpm run verify:localization-extraction + fi + # Why both: the ratchet proves nothing reachable from the runtime imports electron, # which is a property of the import graph. This proves the Node artifact it enables # actually boots, pairs, creates a worktree and round-trips a real PTY. - name: Boot orcad and round-trip a terminal - run: pnpm run smoke:orcad-terminal + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + ORCA_BACKGROUND_LAUNCH: '1' + run: | + # Detection failures run the smoke; renames retain the removed input path. + DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")" + if git diff --name-only --no-renames -z "$DIFF_BASE" HEAD > "$RUNNER_TEMP/orcad-smoke-changes" && + scope="$(node config/scripts/orcad-terminal-smoke-change-scope.mjs "$RUNNER_TEMP/orcad-smoke-changes")" && [ "$scope" = false ]; then + echo "Orcad terminal smoke inputs are unchanged." + else + pnpm run smoke:orcad-terminal + fi - name: Verify the generated RPC params catalog run: pnpm run verify:rpc-params-catalog @@ -209,23 +310,16 @@ jobs: - name: Verify skill freshness manifest run: pnpm run verify:skill-bundle-manifest - - name: Verify localization catalog - run: pnpm run verify:localization-catalog - - # Why: the renderer ships only the English entries i18next cannot rebuild - # from each call site's inline default, so the generated subset has to - # track en.json and those defaults. - - name: Verify runtime-required localization catalog - run: pnpm run verify:localization-runtime-catalog - - # Why: extraction writes sorted evidence to an isolated temporary path, - # so feature PRs need one normalized AST pass rather than a three-OS matrix. - - name: Verify localization extraction - run: pnpm run verify:localization-extraction + - name: Verify localization catalogs + id: localization-catalogs + background: true + run: pnpm run verify:localization-catalogs - name: Verify localization coverage run: pnpm run verify:localization-coverage + - wait: [localization-catalogs, localization-extraction] + # Why: project-owned type declarations must live in .ts so tsc # actually checks them. TypeScript's skipLibCheck: true (inherited # from @electron-toolkit/tsconfig) silently widens unresolved names @@ -249,37 +343,11 @@ jobs: - name: Verify macOS entitlements run: pnpm verify:macos-entitlements - root_directory_guard: - name: root directory guard - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - # Why blob:none: full history is needed for the merge-base diff, but historical - # file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the - # few this job actually reads on demand. - fetch-depth: 0 - filter: blob:none - persist-credentials: false - - - name: Reject new root-level files and folders - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: node .github/scripts/check-root-directory-entries.mjs "$BASE_SHA" "$HEAD_SHA" - - # Why here: the READMEs embed media owned by docs/site and resources/onboarding, - # and the classifier skips static_analysis for docs-only diffs. This job runs - # on every PR and needs no install. - - name: Check README local links - run: node config/scripts/check-readme-local-links.mjs - typecheck: needs: [code_paths] if: needs.code_paths.outputs.typecheck == 'true' - runs-on: ubuntu-latest + # Typechecking uses no native runtime, so it can use the free public ARM runner. + runs-on: ubuntu-24.04-arm steps: - name: Checkout @@ -317,39 +385,7 @@ jobs: - uses: ./.github/actions/install-node-dependencies - # Why: the 2.25.5 lane is a source build of a pinned tarball, so it produced the - # same binary on every PR for minutes of runner time. The key carries the version - # because that is the only input; the sha256 assertion below still guards the - # tarball on the miss path that actually builds. Only this PR's own later pushes - # can restore it — GitHub scopes a cache written from a pull_request run to that - # ref — so a first push always takes the build path below. - - name: Cache baseline Git build - uses: actions/cache@v5 - with: - path: ~/.cache/orca-git-compat/git-2.25.5 - key: git-compat-baseline-${{ runner.os }}-${{ runner.arch }}-2.25.5 - - # Why its own step: this is `make -j$(nproc)` on every core, and the lanes below - # spend their wall clock waiting on container starts, not on Git. Sharing a runner - # with the build stretched one ~1.5s boundary case past Vitest's 30s timeout, so - # the build has to finish before anything timed starts. - - name: Build the baseline Git binary - run: | - archive="$RUNNER_TEMP/git-2.25.5.tar.gz" - source="$HOME/.cache/orca-git-compat/git-2.25.5" - if [ -x "$source/git" ]; then - exit 0 - fi - curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive" - echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \ - | sha256sum --check - mkdir -p "$source" - tar -xzf "$archive" -C "$source" --strip-components=1 - make -C "$source" -j"$(nproc)" \ - NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git - # Why: the linked binaries are what the next run needs; the objects that - # produced them are most of the tree and would bloat the cache entry. - find "$source" -name '*.o' -delete + - uses: ./.github/actions/prepare-git-compatibility - name: Verify Git binary compatibility matrix run: | @@ -399,9 +435,12 @@ jobs: name: Codex index-heal contract needs: [code_paths] if: needs.code_paths.outputs.codex_index_heal_contract == 'true' - runs-on: ubuntu-latest + # Why ARM: @openai/codex ships @openai/codex-linux-arm64. + runs-on: ubuntu-24.04-arm env: CODEX_CLI_VERSION: '0.150.1' + # Why a second pin: --no-daemon only exists from 0.156, and Orca's codex wrapper relies on it. + CODEX_NO_DAEMON_CLI_VERSION: '0.158.0' steps: - name: Checkout @@ -430,11 +469,30 @@ jobs: pnpm exec vitest run --config config/vitest.config.ts \ src/main/codex/codex-index-heal-binary-contract.test.ts + - name: Install pinned no-daemon Codex CLI + run: | + set -euo pipefail + npm install --no-audit --no-fund --prefix "$RUNNER_TEMP/codex-cli-no-daemon" \ + "@openai/codex@$CODEX_NO_DAEMON_CLI_VERSION" + + - name: Verify Codex --no-daemon contract + env: + ORCA_CODEX_NO_DAEMON_CONTRACT_REQUIRED: '1' + ORCA_CODEX_NO_DAEMON_CONTRACT_VERSION: ${{ env.CODEX_NO_DAEMON_CLI_VERSION }} + run: | + set -euo pipefail + ORCA_CODEX_NO_DAEMON_CONTRACT_BINARY="$RUNNER_TEMP/codex-cli-no-daemon/node_modules/.bin/codex" \ + pnpm exec vitest run --config config/vitest.config.ts \ + src/main/pty/codex-no-daemon-binary-contract.test.ts + xterm_patch_sync: name: xterm patch sync needs: [code_paths] if: needs.code_paths.outputs.xterm_patch_sync == 'true' - runs-on: ubuntu-latest + # Why ARM: the patch check rebuilds 4 packages x 2 builds and byte-compares against the + # checked-in bundles. Those were generated on Linux x64 and reproduce byte-for-byte on + # darwin-arm64, so the output is neither host-arch nor host-OS dependent. + runs-on: ubuntu-24.04-arm steps: - name: Checkout @@ -442,21 +500,14 @@ jobs: with: persist-credentials: false - - uses: ./.github/actions/install-node-dependencies - - # Why: the check rebuilds every package in the manifest from a pinned upstream - # commit — @xterm/xterm and its three addons, each built twice (once unmodified to - # prove the toolchain still reproduces the published bundles, once patched). Caching - # the npm metadata and the shallow clone keeps the repeated cost to the builds - # themselves; the key is the manifest, so a commit, package or toolchain bump - # invalidates it. - - name: Restore upstream xterm build inputs - uses: actions/cache@v5 + # The generator uses Node built-ins and installs its own upstream toolchain. + - uses: actions/setup-node@v6 with: - path: | - ~/.npm - ${{ runner.temp }}/xterm-patch-build/upstream/.git - key: xterm-upstream-${{ hashFiles('config/patches/xterm-upstream.json') }} + node-version-file: package.json + package-manager-cache: false + + # Rebuild both pristine and patched bundles; reuse only the pinned toolchain. + - uses: ./.github/actions/prepare-xterm-dependencies - name: Verify xterm patches match the pinned upstream build env: @@ -467,7 +518,8 @@ jobs: name: shell contracts needs: [code_paths] if: needs.code_paths.outputs.shell_contracts == 'true' - runs-on: ubuntu-latest + # Why ARM: fish 4.x is published for noble/arm64 and zsh is in the arm64 archive. + runs-on: ubuntu-24.04-arm # Why: this job's cost is almost entirely package download, and a stalled mirror has # no wall-clock bound of its own. A successful run finishes in ~4.5 minutes, so this # is generous; it exists so a wedge fails the job instead of holding the whole run @@ -479,6 +531,8 @@ jobs: # and its fish lane is the only end-to-end guard for #9993, so a skip would # report green with nothing exercised. Turns those skips into failures. ORCA_REQUIRE_FISH: '1' + # Why: the runner image ships pwsh, and the codex wrapper's PowerShell lane must not skip. + ORCA_REQUIRE_PWSH: '1' steps: - name: Checkout @@ -493,6 +547,8 @@ jobs: # fish-color-scheme-child-stdin.node-pty.test.ts (#9993) needs it. Noble ships # 3.7, so the PPA is what makes that lane real. - name: Install zsh and fish + id: shells + background: true run: | # Why the update/PPA/fish steps are tolerant: a repo the runner image already # ships can lack a Release file for this suite, and a failed add-apt-repository @@ -541,6 +597,12 @@ jobs: # first install command in this step to prove the lane really installs them. timeout 300 sudo apt-get install -y zsh fish + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + + - wait: shells + # Separate from the install so the failure names the contract, not an apt error. # ORCA_REQUIRE_FISH re-checks this at test time; this step just fails in seconds # instead of after a full dependency install. @@ -556,10 +618,6 @@ jobs: exit 1 fi - - uses: ./.github/actions/install-node-dependencies - with: - native-runtime: node - - name: Test real shell contracts run: | pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 \ @@ -569,6 +627,7 @@ jobs: src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \ src/main/providers/__tests__/shell-ready-framework-example.test.ts \ src/main/pty/codex-shell-launch-preflight.test.ts \ + src/main/pty/codex-shell-no-daemon.test.ts \ src/main/pty/omp-shell-wrapper-alias-safety.test.ts \ src/main/pty/omp-shell-wrapper.node-pty.test.ts \ src/main/shell-startup-feature-channel.test.ts \ @@ -576,6 +635,7 @@ jobs: src/main/zsh-scoped-histfile.live-shell.test.ts \ src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \ src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \ + src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts \ src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ src/shared/fish-query-reply-child-stdin.node-pty.test.ts \ src/shared/pty-reply-echo-shapes.node-pty.test.ts \ @@ -588,7 +648,7 @@ jobs: name: prepare test native cache node 24 needs: [code_paths] if: needs.code_paths.outputs.native_cache_changed == 'true' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04-arm steps: - name: Checkout @@ -601,15 +661,40 @@ jobs: native-runtime: node node-version: '24' + # Why hoisted out of unit-tests.yml: a caller's `needs` gate the whole called workflow, so + # while planning lived in there it queued behind static analysis and typecheck and the shards + # then queued behind it. Planning needs none of their output, so running it against + # code_paths alone overlaps it with the gate. Measured a median 93s off the matrix's start. + unit_plan: + needs: [code_paths] + if: needs.code_paths.outputs.test == 'true' + uses: ./.github/workflows/unit-plan.yml + with: + selection_mode: ${{ vars.ORCA_UNIT_SELECTION_MODE || 'shadow' }} + test: - needs: [code_paths, test_native_cache] + needs: [code_paths, unit_plan, test_native_cache, static_analysis, typecheck] + # Honor cancellation while allowing the optional native-cache primer to skip. if: >- - always() && + !cancelled() && needs.code_paths.outputs.test == 'true' && + needs.unit_plan.result == 'success' && + needs.static_analysis.result == 'success' && + needs.typecheck.result == 'success' && (needs.test_native_cache.result == 'success' || needs.test_native_cache.result == 'skipped') uses: ./.github/workflows/unit-tests.yml with: node_versions: '["24"]' + runner: ubuntu-24.04-arm + shards: ${{ needs.unit_plan.outputs.shards }} + + # Why a sibling and not part of the test workflow: it is advisory, so it must not delay the + # gate. Inside unit-tests.yml a caller's `needs: test` waited for it, holding verify ~36s + # after the last shard. Deliberately absent from verify's needs for the same reason. + unit_selection_evidence: + needs: [test] + if: ${{ !cancelled() && needs.test.result == 'success' }} + uses: ./.github/workflows/unit-selection-evidence.yml # Why a separate job: the test needs a real Chrome, and the sharded `test` matrix # would pay for it on every shard to run one file in whichever shard it landed in. @@ -673,9 +758,22 @@ jobs: pnpm-lock.yaml mobile/pnpm-lock.yaml + # The drawer check runs on WebKit as well as Chrome, because the shell's iOS WebView is + # WebKit and the Chrome above cannot stand in for it. Downloaded rather than resolved from + # the runner: Ubuntu ships no WebKit build to point at. + - name: Install WebKit for the drawer check + id: webkit + background: true + run: pnpm exec playwright install --with-deps webkit + # The entry lives in mobile/ so one React resolves; without this every RN import is nothing. - uses: ./.github/actions/install-mobile-dependencies + - name: Prepare mobile route snapshot + id: mobile-routes + background: true + run: node config/scripts/run-mobile-web-app-checks.mjs --prepare-route-snapshot "$RUNNER_TEMP/mobile-routes.json" + # Why the runner's Google Chrome and not a downloaded chromium: same reason as the orcad # browser job -- Ubuntu 24.04 only ships an AppArmor userns profile for the Chrome .deb. # Why fail instead of skip: a silently skipped render check is the failure this job exists @@ -691,15 +789,12 @@ jobs: "$chrome" --version echo "ORCA_MOBILE_WEB_RENDER_BROWSER=$chrome" >> "$GITHUB_ENV" - # The drawer check runs on WebKit as well as Chrome, because the shell's iOS WebView is - # WebKit and the Chrome above cannot stand in for it. Downloaded rather than resolved from - # the runner: Ubuntu ships no WebKit build to point at. - - name: Install WebKit for the drawer check - run: pnpm exec playwright install --with-deps webkit - - name: Build and verify the app bundle run: pnpm run build:mobile-web + # Browser checks must observe both a finished install and the built bundle. + - wait: [webkit, mobile-routes] + # The bundling tests skip themselves where mobile dependencies are absent, which is how they # stay green in the sharded `test` job. This is the job that installs them, so here a missing # install has to fail rather than skip everything the job exists to run. @@ -709,23 +804,20 @@ jobs: # one `pr-code-change-scope.mjs` fires this job on, so naming a test into the family is all # it takes to have it run. Quoted because these are vitest filename filters, matched as # substrings against the discovered files, and the shell must not touch them. - # - # Cost: 18 files in 25-30s wall, of which the frame-budget sweep is 2.5s. That sweep encodes - # 111 noise JPEGs in Chromium, so it is the one step here whose cost grows with its viewport - # set; adding rows to that set is a decision about this job's runtime. + # Route censuses share fresh dependency lists for this invocation; scratch builds stay independent. - name: Builder, override census and render checks env: ORCA_MOBILE_WEB_APP_DEPS_REQUIRED: '1' + ORCA_MOBILE_WEB_PREPARED_ROUTE_SNAPSHOT: ${{ runner.temp }}/mobile-routes.json run: | - pnpm exec vitest run --config config/vitest.config.ts \ - 'config/scripts/mobile-web-app-' \ - 'config/scripts/build-mobile-web-app-bundle.test.mjs' + node config/scripts/run-mobile-web-app-checks.mjs cross-version-wire: name: cross-version wire compatibility needs: [code_paths] if: needs.code_paths.outputs.cross-version-wire == 'true' - runs-on: ubuntu-latest + # Why ARM: source-only: tagged checkout plus in-process vitest, no docker or browser. + runs-on: ubuntu-24.04-arm steps: # Why fetch-depth 0: the harness extracts the newest release tag to skew @@ -757,12 +849,16 @@ jobs: tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts + tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts + tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts + tests/e2e/cross-version-wire/cross-version-worktree-ps-verdict.unit.test.ts managed_hook_node18: name: managed hooks on Node 18 needs: [code_paths] if: needs.code_paths.outputs.managed_hook_node18 == 'true' - runs-on: ubuntu-latest + # Why ARM: Node 18 publishes linux-arm64; the per-platform runtime files are read as data. + runs-on: ubuntu-24.04-arm steps: - name: Checkout @@ -785,7 +881,7 @@ jobs: package: name: package - needs: [code_paths] + needs: [code_paths, static_analysis, typecheck] if: needs.code_paths.outputs.package == 'true' runs-on: ubuntu-latest # Let the serial Docker gates reach their own deadlines and report cleanup failures. @@ -798,7 +894,7 @@ jobs: persist-credentials: false - name: Cache electron-builder downloads - uses: actions/cache@v5 + uses: actions/cache/restore@v5 with: path: ~/.cache/electron-builder key: electron-builder-linux-${{ hashFiles('pnpm-lock.yaml') }} @@ -835,6 +931,24 @@ jobs: src/main/browser/browser-route-h3-egress.electron.test.ts src/main/browser/browser-route-dns-prefetch.electron.test.ts + # Restore independent fixtures during builds; keep native lifecycle probes serial. + - uses: ./.github/actions/prepare-linux-package-fixture + id: shutdown-fixture-cache + background: true + with: + fixture: headless-serve-shutdown + + - uses: ./.github/actions/prepare-linux-package-fixture + id: cli-fixture-cache + background: true + with: + fixture: cli-launch-contract + + - name: Install Linux package tooling + id: linux-package-tools + background: true + run: sudo apt-get update && sudo apt-get install -y cpio rpm + - name: Build package inputs run: | status=0 @@ -853,6 +967,8 @@ jobs: exit "$status" - name: Project web client from renderer build + id: web-client + background: true run: pnpm run build:web-from-renderer # Why here and not inside "Build package inputs": this job assembles packaging inputs step by @@ -864,17 +980,16 @@ jobs: - name: Build native components run: pnpm run build:native - - name: Install Linux package tooling - run: sudo apt-get update && sudo apt-get install -y cpio rpm + - wait: [linux-package-tools, web-client] - name: Package unpacked app env: + ORCA_BACKGROUND_LAUNCH: '1' ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1' - # PR artifacts are only inspected locally; gzip avoids release-size xz compression. - run: >- - pnpm exec electron-builder --config config/electron-builder.config.cjs - --linux AppImage deb rpm --x64 --publish never - --config.deb.compression=gz --config.rpm.compression=gzip + # Prepare once with every hook, then isolate the format-specific mutations. + run: | + pnpm exec electron-builder --config config/electron-builder.config.cjs --linux dir --x64 --publish never + node config/scripts/package-linux-formats.mjs - name: Verify root-package marker payloads run: | @@ -889,13 +1004,21 @@ jobs: [[ "$deb_marker" == deb ]] || { echo "Expected deb marker, got: $deb_marker"; exit 1; } [[ "$rpm_marker" == rpm ]] || { echo "Expected rpm marker, got: $rpm_marker"; exit 1; } + - wait: shutdown-fixture-cache + - name: Verify headless serve signal shutdown + env: + ORCA_SHUTDOWN_FIXTURE_CACHE_IMAGE: ${{ steps.shutdown-fixture-cache.outputs.image }} run: >- node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage --all-entrypoints # A default container reproduces the hostile AppImage launch environment. + - wait: cli-fixture-cache + - name: Verify Linux CLI launch contract + env: + ORCA_CLI_FIXTURE_CACHE_IMAGE: ${{ steps.cli-fixture-cache.outputs.image }} run: node config/scripts/run-linux-cli-launch-contract-docker.mjs --appimage dist/orca-linux.AppImage - name: Smoke packaged CLI @@ -906,7 +1029,7 @@ jobs: package_windows: name: package (windows) - needs: [code_paths] + needs: [code_paths, static_analysis, typecheck] if: needs.code_paths.outputs.package_windows == 'true' runs-on: windows-2022 timeout-minutes: 30 @@ -918,14 +1041,15 @@ jobs: persist-credentials: false - name: Cache electron-builder downloads - uses: actions/cache@v5 + uses: actions/cache/restore@v5 with: path: | ~\AppData\Local\electron\Cache ~\AppData\Local\electron-builder\Cache - key: electron-builder-windows-${{ hashFiles('pnpm-lock.yaml') }} + # Release builds seed this exact path set; PR-local copies cannot serve other PRs. + key: electron-builder-win-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | - electron-builder-windows- + electron-builder-win- # Why persist-native-cache false: this job later rebuilds the same path for # Electron. A post-job save would store the Electron ABI under the Node key. @@ -954,6 +1078,12 @@ jobs: # vitest runs here directly rather than through `pnpm test`, so the addon # assertions only hold once install-node-dependencies has rebuilt natives. + - name: Test Windows installer process probe + # Keep cold CIM startup out of the concurrent Electron/native process workload. + run: >- + pnpm exec vitest run --config config/vitest.config.ts + config/scripts/nsis-process-check.test.mjs + - name: Test Windows-specific boundaries run: >- pnpm exec vitest run --config config/vitest.config.ts @@ -989,6 +1119,7 @@ jobs: src/main/windows/windows-host-job.win32.test.ts src/main/windows/windows-process-tree-command-line-patch.test.ts src/main/windows/windows-process-table-native-addon.win32.test.ts + src/main/persistence/profile-state/profile-state-access-windows-native.win32.test.ts src/main/windows-live-tree-kill.win32.test.ts src/main/wsl/wsl-runner.test.ts src/main/wsl/wsl-guest-environment.test.ts @@ -1017,11 +1148,9 @@ jobs: uses: actions/cache@v5 with: path: native/windows-cli-launcher/.build - key: windows-cli-launcher-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/windows-cli-launcher/**', 'config/scripts/build-windows-cli-launcher.mjs') }} + key: windows-cli-launcher-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/windows-cli-launcher/**', 'config/scripts/build-windows-cli-launcher.mjs', 'resources/build/icon.ico', 'package.json') }} - name: Build package inputs - env: - ORCA_REUSE_WINDOWS_CLI_LAUNCHER: '1' run: pnpm run build:release:parallel - name: Restore compiled Electron native modules @@ -1088,11 +1217,10 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} verify: - if: always() + if: ${{ !cancelled() }} needs: - code_paths - static_analysis - - root_directory_guard - typecheck - git_compatibility - codex_index_heal_contract @@ -1105,7 +1233,9 @@ jobs: - managed_hook_node18 - package - package_windows - runs-on: ubuntu-latest + # Evaluating job results only needs the lightweight container runner. + runs-on: ubuntu-slim + timeout-minutes: 5 steps: # Why: e2e is deliberately absent from needs. The suite is currently red on @@ -1122,7 +1252,6 @@ jobs: SHOULD_RUN: ${{ needs.code_paths.outputs.should_run }} STATIC_ANALYSIS: ${{ needs.static_analysis.result }} STATIC_ANALYSIS_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis }} - ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }} TYPECHECK: ${{ needs.typecheck.result }} TYPECHECK_SHOULD_RUN: ${{ needs.code_paths.outputs.typecheck }} GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }} @@ -1151,9 +1280,6 @@ jobs: if [ "$CODE_PATHS" != "success" ]; then exit 1 fi - if [ "$ROOT_DIRECTORY_GUARD" != "success" ]; then - exit 1 - fi if [ "$SHOULD_RUN" != "true" ]; then echo "Docs-only change; expensive PR checks skipped." fi diff --git a/.github/workflows/pullfrog.yml b/.github/workflows/pullfrog.yml index d5030252f88..2fc81c887bb 100644 --- a/.github/workflows/pullfrog.yml +++ b/.github/workflows/pullfrog.yml @@ -1,6 +1,6 @@ -# PULLFROG ACTION — DO NOT EDIT EXCEPT WHERE INDICATED +# Explicit review identities share workflow concurrency; legacy names use ordered cancellation. name: Pullfrog -run-name: ${{ inputs.name || github.workflow }} +run-name: ${{ inputs.name || github.workflow }}${{ inputs.pull_request_number && format(' | PR {0}', inputs.pull_request_number) || '' }} on: workflow_dispatch: inputs: @@ -11,21 +11,71 @@ on: type: string description: Run name + pull_request_number: + type: string + description: Optional PR identity for cancelling superseded reviews + head_sha: + type: string + description: Optional expected PR head; stale reviews are skipped + permissions: contents: read + pull-requests: read + +concurrency: + group: ${{ inputs.pull_request_number && format('pullfrog-pr-{0}', inputs.pull_request_number) || format('pullfrog-run-{0}', github.run_id) }} + cancel-in-progress: true jobs: + review_scope: + runs-on: ubuntu-slim + permissions: + contents: read + pull-requests: read + actions: write + outputs: + current: ${{ steps.scope.outputs.current }} + number: ${{ steps.scope.outputs.number }} + head: ${{ steps.scope.outputs.head }} + steps: + - uses: actions/checkout@v6 + with: + sparse-checkout: config/scripts/pullfrog-review-scope.cjs + sparse-checkout-cone-mode: false + persist-credentials: false + - uses: actions/github-script@v8 + id: scope + with: + script: | + const { reviewScope } = require('./config/scripts/pullfrog-review-scope.cjs') + await reviewScope({ github, context, core }) + pullfrog: + needs: review_scope + if: needs.review_scope.outputs.current == 'true' runs-on: ubuntu-latest permissions: id-token: write contents: read + pull-requests: read steps: - name: Checkout code uses: actions/checkout@v6 with: fetch-depth: 1 + - name: Recheck review head before starting agent + id: freshness + if: needs.review_scope.outputs.number != '' + uses: actions/github-script@v8 + env: + REVIEW_NUMBER: ${{ needs.review_scope.outputs.number }} + REVIEW_HEAD: ${{ needs.review_scope.outputs.head }} + with: + script: | + const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: Number(process.env.REVIEW_NUMBER) }) + core.setOutput('current', pr.state === 'open' && pr.head.sha === process.env.REVIEW_HEAD) - name: Run agent + if: needs.review_scope.outputs.number == '' || steps.freshness.outputs.current == 'true' uses: pullfrog/pullfrog@v0 with: prompt: ${{ inputs.prompt }} diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index b2ac03e2b08..01f90060c5e 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1314,6 +1314,16 @@ jobs: restore-keys: | electron-builder-${{ matrix.platform }}- + # PRs cache tools separately from Electron; identical paths preserve their cache version. + - name: Seed shared Linux packaging downloads + if: matrix.platform == 'linux-x64' && github.ref == 'refs/heads/main' + uses: actions/cache@v5 + with: + path: ~/.cache/electron-builder + key: electron-builder-linux-${{ hashFiles('pnpm-lock.yaml') }} + # The release cache above restores the same tools, so this only needs to save on a miss. + lookup-only: true + # Why: pnpm install triggers electron's postinstall, which downloads the # Electron binary from GitHub release assets. GitHub's download CDN # occasionally returns 504s that fail the whole release. Retry on @@ -1530,6 +1540,7 @@ jobs: } - name: Install SignPath PowerShell module + id: install-signpath if: matrix.platform == 'win' && github.run_attempt == 1 uses: ./.github/actions/install-signpath-module @@ -1540,18 +1551,14 @@ jobs: # existing installer signing request below. The NSIS uninstaller rides # this same request (it is the MDE update cluster: old-uninstaller.exe / # Uninstall Orca.exe), captured through electron-builder's sign hook and - # swapped back in during the rebuild — no third approval wait. Every step is - # fail-open (continue-on-error + outcome gating): any failure ships the - # original installer with unsigned inner binaries, exactly like releases - # did before this chain existed. Rehearsed end to end in run 28988432001 - # (.github/workflows/windows-signing-rehearsal.yml). + # swapped back in during the rebuild — no third approval wait. Production + # releases require the entire signing chain to succeed (#23383). # Why: only unsigned PE files go to SignPath. Files that already carry a # valid signature (Microsoft's OpenConsole.exe) must keep their signer. - name: Stage unsigned inner PE files for signing id: stage-inner if: matrix.platform == 'win' && github.run_attempt == 1 - continue-on-error: true shell: pwsh run: | $root = Resolve-Path 'dist/win-unpacked' @@ -1593,34 +1600,18 @@ jobs: # out of inner-signing-list.txt: that list drives the copy-back into # dist/win-unpacked, and the uninstaller does not live there — it is # re-injected through the sign hook during the rebuild instead. - # Why this name and not "Uninstall Orca.exe": the restore loop below - # matches staged files by suffix (`-like "*$relative"`) and takes the - # first hit, so any staged path ending in "Orca.exe" is separated from - # the real Orca.exe only by Get-ChildItem's enumeration order. That - # order happens to favour the root file today, but it is not a - # documented guarantee; a name that cannot suffix-match is. - # Why the whole block is caught rather than just Test-Path'd: this - # step's outcome gates the upload of every inner binary, so a locked - # file or a full disk here would cost all of them their signatures - - # worse than shipping no uninstaller signature at all. - try { - $exportedUninstaller = Join-Path $env:RUNNER_TEMP 'uninstaller-signing\unsigned\orca-uninstaller.exe' - if (Test-Path -LiteralPath $exportedUninstaller) { - $uninstallerStagePath = Join-Path $stage.FullName 'uninstaller\orca-uninstaller.exe' - New-Item -ItemType Directory -Force -Path (Split-Path $uninstallerStagePath) -ErrorAction Stop | Out-Null - Copy-Item -LiteralPath $exportedUninstaller -Destination $uninstallerStagePath -Force -ErrorAction Stop - Write-Host 'Staged the NSIS uninstaller for signing: uninstaller\orca-uninstaller.exe' - } else { - Write-Host "::warning::No exported NSIS uninstaller at $exportedUninstaller; this release ships an unsigned uninstaller (fail-open)." - } - } catch { - Write-Host "::warning::Could not stage the NSIS uninstaller ($_); this release ships an unsigned uninstaller (fail-open)." + $exportedUninstaller = Join-Path $env:RUNNER_TEMP 'uninstaller-signing\unsigned\orca-uninstaller.exe' + if (-not (Test-Path -LiteralPath $exportedUninstaller)) { + throw "No exported NSIS uninstaller at $exportedUninstaller." } + $uninstallerStagePath = Join-Path $stage.FullName 'uninstaller\orca-uninstaller.exe' + New-Item -ItemType Directory -Force -Path (Split-Path $uninstallerStagePath) -ErrorAction Stop | Out-Null + Copy-Item -LiteralPath $exportedUninstaller -Destination $uninstallerStagePath -Force -ErrorAction Stop + Write-Host 'Staged the NSIS uninstaller for signing: uninstaller\orca-uninstaller.exe' - name: Upload unsigned inner binaries for SignPath id: upload-unsigned-inner if: matrix.platform == 'win' && github.run_attempt == 1 && steps.stage-inner.outcome == 'success' - continue-on-error: true uses: actions/upload-artifact@v7 with: name: orca-windows-inner-unsigned-${{ needs.cut.outputs.tag }} @@ -1630,7 +1621,6 @@ jobs: - name: Submit inner binaries signing request id: submit-inner-signing if: matrix.platform == 'win' && github.run_attempt == 1 && steps.upload-unsigned-inner.outcome == 'success' - continue-on-error: true uses: signpath/github-action-submit-signing-request@v2 with: api-token: ${{ secrets.SIGNPATH_API_TOKEN }} @@ -1643,8 +1633,8 @@ jobs: - name: Notify Slack that inner-binary signing is waiting for approval id: notify-inner-signing - if: matrix.platform == 'win' && github.run_attempt == 1 && steps.submit-inner-signing.outcome == 'success' continue-on-error: true + if: matrix.platform == 'win' && github.run_attempt == 1 && steps.submit-inner-signing.outcome == 'success' shell: pwsh env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} @@ -1704,15 +1694,10 @@ jobs: Invoke-RestMethod -Method Post -Uri $env:SLACK_WEBHOOK_URL -ContentType 'application/json' -Body $payload - # Why gate on the notify outcome too: if nobody was told to approve, - # don't hold the release for the approval window — fall through and - # ship like today instead. The 1h wait (vs the installer's 4h) keeps - # both waits plus the build inside the 360-minute job cap; missing it - # falls through to today's unsigned-inner flow rather than blocking. + # Approval remains required even when the notification service is unavailable. - name: Download signed inner binaries from SignPath id: download-signed-inner - if: matrix.platform == 'win' && github.run_attempt == 1 && steps.submit-inner-signing.outcome == 'success' && steps.notify-inner-signing.outcome == 'success' - continue-on-error: true + if: matrix.platform == 'win' && github.run_attempt == 1 && steps.submit-inner-signing.outcome == 'success' shell: pwsh env: SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} @@ -1730,27 +1715,31 @@ jobs: Expand-Archive -Path signed-inner.zip -DestinationPath signed-inner -Force # Why: copy back strictly by the staged list so a layout mismatch in the - # returned artifact fails loudly (into fail-open) instead of silently + # returned artifact fails before publication instead of silently # shipping a mix of signed and unsigned binaries. - name: Restore signed inner binaries into unpacked app id: restore-signed-inner if: matrix.platform == 'win' && github.run_attempt == 1 && steps.download-signed-inner.outcome == 'success' - continue-on-error: true shell: pwsh + env: + SIGNING_POLICY: release-signing run: | + $requireValid = $env:SIGNING_POLICY -ne 'test-signing' $root = Resolve-Path 'dist/win-unpacked' $failures = New-Object System.Collections.Generic.List[string] foreach ($relative in Get-Content 'inner-signing-list.txt') { - $signed = Get-ChildItem -Path signed-inner -Recurse -File | - Where-Object { [System.IO.Path]::GetRelativePath((Resolve-Path 'signed-inner'), $_.FullName).TrimStart('\', '/') -like "*$relative" } | - Select-Object -First 1 - if ($null -eq $signed) { - $failures.Add("missing from signed artifact: $relative") + $candidates = @( + (Join-Path 'signed-inner' $relative), + (Join-Path 'signed-inner/signing-stage' $relative) + ) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } + if (@($candidates).Count -ne 1) { + $failures.Add("missing or ambiguous signed artifact path: $relative") continue } + $signed = Get-Item -LiteralPath @($candidates)[0] $signature = Get-AuthenticodeSignature -FilePath $signed.FullName - if ($null -eq $signature.SignerCertificate) { - $failures.Add("returned without a signature: $relative") + if ($null -eq $signature.SignerCertificate -or ($requireValid -and ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notlike '*CN=SignPath Foundation*'))) { + $failures.Add("returned without a valid SignPath signature: $relative") continue } Copy-Item -Path $signed.FullName -Destination (Join-Path $root $relative) -Force @@ -1761,25 +1750,26 @@ jobs: throw "Signed inner artifact did not round-trip cleanly ($($failures.Count) failures)." } - # Why gated separately from the inner restore above: if SignPath's - # windows-inner-binaries-zip artifact configuration does not (yet) cover the - # uninstaller/ directory, the uninstaller comes back missing. That must cost - # only the uninstaller signature — the rebuild below still runs and still - # ships the signed inner binaries, exactly as it does today. + # The uninstaller must return signed before rebuilding the installer. - name: Restore signed uninstaller for the installer rebuild id: restore-signed-uninstaller if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success' - continue-on-error: true shell: pwsh + env: + SIGNING_POLICY: release-signing run: | - $signed = Get-ChildItem -Path signed-inner -Recurse -File -Filter 'orca-uninstaller.exe' | - Select-Object -First 1 - if ($null -eq $signed) { - throw 'SignPath did not return uninstaller/orca-uninstaller.exe; check the windows-inner-binaries-zip artifact configuration covers it.' + $requireValid = $env:SIGNING_POLICY -ne 'test-signing' + $candidates = @( + (Join-Path 'signed-inner' 'uninstaller\orca-uninstaller.exe'), + (Join-Path 'signed-inner/signing-stage' 'uninstaller\orca-uninstaller.exe') + ) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } + if (@($candidates).Count -ne 1) { + throw 'Missing or ambiguous uninstaller/orca-uninstaller.exe in the signed artifact; check the windows-inner-binaries-zip configuration.' } + $signed = Get-Item -LiteralPath @($candidates)[0] $signature = Get-AuthenticodeSignature -FilePath $signed.FullName - if ($null -eq $signature.SignerCertificate) { - throw 'The returned NSIS uninstaller carries no signature.' + if ($null -eq $signature.SignerCertificate -or ($requireValid -and ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notlike '*CN=SignPath Foundation*'))) { + throw 'The returned NSIS uninstaller carries no valid SignPath signature.' } $signedDir = Join-Path $env:RUNNER_TEMP 'uninstaller-signing\signed' New-Item -ItemType Directory -Force -Path $signedDir | Out-Null @@ -1804,13 +1794,11 @@ jobs: - name: Replace cached elevate.exe with the signed copy id: sign-elevate-cache if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success' - continue-on-error: true shell: pwsh run: | $signed = 'dist/win-unpacked/resources/elevate.exe' if (-not (Test-Path $signed)) { - Write-Host '::warning::No elevate.exe in win-unpacked resources; nothing to protect from the rebuild clobber.' - exit 0 + throw 'No elevate.exe in win-unpacked resources.' } # Why this guard stays: windows-signing-rehearsal.yml shares the # electron-builder-win- cache key with this workflow, so a @@ -1818,8 +1806,7 @@ jobs: $signature = Get-AuthenticodeSignature -FilePath $signed $subject = if ($null -eq $signature.SignerCertificate) { '' } else { $signature.SignerCertificate.Subject } if ($signature.Status -ne 'Valid' -or $subject -notlike '*CN=SignPath Foundation*') { - Write-Host "::warning::win-unpacked elevate.exe is not SignPath-signed ($($signature.Status), $subject); skipping cache swap." - exit 0 + throw "win-unpacked elevate.exe is not SignPath-signed ($($signature.Status), $subject)." } node config/scripts/replace-cached-nsis-elevate.mjs $signed if ($LASTEXITCODE -ne 0) { @@ -1837,35 +1824,16 @@ jobs: - name: Rebuild NSIS installer from signed unpacked app id: rebuild-nsis-signed if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success' - continue-on-error: true shell: pwsh env: - # Why unconditional: the sign hook keys off the file existing, which it - # only does when the restore step above succeeded. A missing file logs a - # warning and embeds the freshly built unsigned uninstaller instead. ORCA_WIN_UNINSTALLER_SIGNED_PATH: ${{ runner.temp }}\uninstaller-signing\signed\orca-uninstaller.exe run: | - # Why: keep the pre-rebuild artifacts so a failed rebuild can fall - # back to shipping them unchanged (fail-open). - New-Item -ItemType Directory -Path prepack-backup -Force | Out-Null - Copy-Item 'dist/orca-windows-setup.exe' 'prepack-backup/orca-windows-setup.exe' -Force - Copy-Item 'dist/latest.yml' 'prepack-backup/latest.yml' -Force - pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never --prepackaged "$env:GITHUB_WORKSPACE\dist\win-unpacked" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } if (-not (Test-Path 'dist/orca-windows-setup.exe')) { throw 'electron-builder --prepackaged did not produce dist/orca-windows-setup.exe' } - - name: Roll back to original installer after failed rebuild - if: matrix.platform == 'win' && github.run_attempt == 1 && steps.rebuild-nsis-signed.outcome == 'failure' - shell: pwsh - run: | - if (Test-Path 'prepack-backup/orca-windows-setup.exe') { - Copy-Item 'prepack-backup/orca-windows-setup.exe' 'dist/orca-windows-setup.exe' -Force - Copy-Item 'prepack-backup/latest.yml' 'dist/latest.yml' -Force - Write-Warning 'Restored pre-rebuild installer; this release ships with unsigned inner binaries.' - } # ── End Windows inner-binary signing ─────────────────────────────── - name: Upload unsigned Windows installer for SignPath if: matrix.platform == 'win' && github.run_attempt == 1 @@ -1919,7 +1887,7 @@ jobs: $requestUrl = "https://app.signpath.io/Web/$env:SIGNPATH_ORGANIZATION_ID/SigningRequests/$env:SIGNPATH_REQUEST_ID" } - # Why: releases where inner signing fell through have only this one request. + # Keep the two approval requests distinguishable in the notification. $stage = if ($env:INNER_SIGNING_SUBMITTED -eq 'true') { 'installer signing request (2 of 2)' } else { 'signing request' } # Why: approvers need tag + source ref/commit + who cut, not only the tag. $sourceRef = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_REF)) { $env:SOURCE_REF } else { 'unknown' } @@ -2026,24 +1994,18 @@ jobs: # Why: evidence gate for inner-binary signing (issue #7785, supersedes # PR #7170's Orca.exe-only gate — this covers every staged .exe/.dll/.node - # by extracting the shipped installer). Warn-only until the flow has been - # proven on a real release, then flip ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED - # to 'true' so unsigned inner binaries block the release. + # by extracting the shipped installer). Unsigned binaries block publication. - name: Verify Windows inner binary signatures if: matrix.platform == 'win' && github.run_attempt == 1 shell: pwsh env: - ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED: 'false' + ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED: 'true' INNER_SIGNING_COMPLETED: ${{ steps.rebuild-nsis-signed.outcome == 'success' }} UNINSTALLER_SIGNING_COMPLETED: ${{ steps.restore-signed-uninstaller.outcome == 'success' }} run: | $required = $env:ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED -eq 'true' - # Why: a fail-open gate that writes nothing is indistinguishable from a - # gate that passed. Always leave a verdict in the evidence artifact and - # the job summary so a silent degradation is visible (#6487). - # Why best-effort: while warn-only, a disk-full or permission error - # writing the verdict must not become the thing that fails the release. + # Keep diagnostics best-effort so they cannot mask a signature failure. function Add-GateEvidence([string]$line) { try { Add-Content -Path 'inner-signing-evidence.txt' -Value "`n$line" -ErrorAction Stop @@ -2071,19 +2033,14 @@ jobs: } if ($env:INNER_SIGNING_COMPLETED -ne 'true') { - $message = 'Windows inner-binary signing did not complete; this release ships unsigned inner binaries (fail-open, issue #7785).' + $message = 'Windows inner-binary signing did not complete; publication is blocked.' Write-GateVerdict "NOT VERIFIED — $message" if ($required) { throw $message } Write-Host "::warning::$message" exit 0 } - # Why try/catch: while the gate is warn-only, even an unexpected - # script error (extraction hiccup, missing file) must not block - # the release — only the flip to required makes failures fatal. - # Why tracked separately: a required-mode signature failure must not be - # rewritten as ERRORED by the catch below, which would replace the - # per-file report with an exception string and lose the diagnostics. + # Keep signature failures outside the catch to preserve the per-file report. $policyFailure = $null try { @@ -2105,14 +2062,19 @@ jobs: } New-Item -ItemType Directory -Path inner-evidence-extract -Force | Out-Null & $7za x 'dist/orca-windows-setup.exe' '-oinner-evidence-extract' -y | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Could not extract the shipped installer payload.' } $root = Resolve-Path 'inner-evidence-extract' + # Verify the CLI even when a cached signed copy bypassed staging (#23383). # Why elevate.exe is always appended: staging skips already-signed # files, and the persisted electron-builder cache can carry a # previously signed elevate.exe — so it may be absent from the list # in some runs, yet it is the file most at risk of losing its # signature in the NSIS rebuild. Verify it in every release. $targets = @(Get-Content 'inner-signing-list.txt') + foreach ($requiredTarget in @('Orca.exe', 'resources\bin\orca.exe')) { + if ($targets -notcontains $requiredTarget) { $targets += $requiredTarget } + } if ($targets -notcontains 'resources\elevate.exe') { $targets += 'resources\elevate.exe' } @@ -2147,7 +2109,7 @@ jobs: } } } else { - Write-Host '::warning::The NSIS uninstaller was not signed on this run; it is excluded from the evidence gate (fail-open).' + $failures.Add('The NSIS uninstaller signing did not complete.') } foreach ($relative in $targets) { $path = Join-Path $root $relative @@ -2197,6 +2159,38 @@ jobs: # Outside the catch so the FAILED evidence report survives intact. if ($policyFailure) { throw $policyFailure } + - name: Notify Slack when Windows signing fails + if: failure() && matrix.platform == 'win' && github.run_attempt == 1 && steps.install-signpath.outcome != '' && steps.install-signpath.outcome != 'skipped' + continue-on-error: true + shell: pwsh + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + TAG: ${{ needs.cut.outputs.tag }} + GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} + INNER_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }} + INSTALLER_REQUEST_ID: ${{ steps.submit-signing-request.outputs.signing-request-id }} + INSTALLER_SUBMISSION_OUTCOME: ${{ steps.submit-signing-request.outcome }} + run: | + if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) { + throw 'SLACK_WEBHOOK_URL is missing; cannot notify release approvers of the signing failure.' + } + $stage = 'inner-binary signing or installer rebuild' + if ($env:INSTALLER_SUBMISSION_OUTCOME -in @('success', 'failure')) { + $stage = 'installer signing or final signature verification' + } + $message = "Orca Windows release ``$($env:TAG)`` failed during $stage (including approval timeouts). Publication is blocked.`n<$($env:GITHUB_RUN_URL)|Open failed GitHub Actions run>" + foreach ($request in @( + @{ Id = $env:INNER_REQUEST_ID; Label = 'Inner-binary signing request' }, + @{ Id = $env:INSTALLER_REQUEST_ID; Label = 'Installer signing request' } + )) { + if (-not [string]::IsNullOrWhiteSpace($request.Id)) { + $message += "`n" + } + } + $message += "`nCheck the failed step before retrying. Late SignPath approval does not resume this run; missing Windows assets require a fresh release dispatch, not Re-run failed jobs." + $payload = @{ text = $message } | ConvertTo-Json + Invoke-RestMethod -Method Post -Uri $env:SLACK_WEBHOOK_URL -ContentType 'application/json' -Body $payload + - name: Upload Windows inner signing evidence if: always() && matrix.platform == 'win' && github.run_attempt == 1 uses: actions/upload-artifact@v7 diff --git a/.github/workflows/release-policy.yml b/.github/workflows/release-policy.yml index 8014145939b..53decc5e7e3 100644 --- a/.github/workflows/release-policy.yml +++ b/.github/workflows/release-policy.yml @@ -16,83 +16,21 @@ concurrency: jobs: enforce: if: github.repository == 'stablyai/orca' - runs-on: ubuntu-latest + runs-on: ubuntu-slim timeout-minutes: 5 steps: + # Why: release events run this file from the tagged commit, so load the module from the same commit. + - uses: actions/checkout@v6 + with: + sparse-checkout: config/scripts/release-policy.mjs + sparse-checkout-cone-mode: false + persist-credentials: false - name: Enforce release policy uses: actions/github-script@v8 with: script: | - const release = context.payload.release; - const tag = release.tag_name; - const author = release.author?.login; - const number = "(?:0|[1-9][0-9]*)"; - const version = `${number}\\.${number}\\.${number}`; - const stableTag = new RegExp(`^v${version}$`); - const prereleaseTag = new RegExp( - `^(?:v${version}-rc\\.${number}(?:\\.[0-9A-Za-z]+)?|mobile(?:-android)?-v${version})$`, - ); - const expectedPrerelease = prereleaseTag.test(tag); - const allowed = author === "github-actions[bot]" && - (stableTag.test(tag) || expectedPrerelease); - const { owner, repo } = context.repo; - - async function restoreLatestStable() { - const releases = await github.paginate(github.rest.repos.listReleases, { - owner, - repo, - per_page: 100, - }); - const stable = releases - .filter((candidate) => - !candidate.draft && - !candidate.prerelease && - candidate.author?.login === "github-actions[bot]" && - stableTag.test(candidate.tag_name), - ) - .sort((left, right) => { - const a = left.tag_name.slice(1).split(".").map(Number); - const b = right.tag_name.slice(1).split(".").map(Number); - return a.reduce((result, part, index) => result || part - b[index], 0); - }) - .at(-1); - if (stable) { - await github.rest.repos.updateRelease({ - owner, - repo, - release_id: stable.id, - make_latest: "true", - }); - } - } - - if (allowed) { - if (release.prerelease !== expectedPrerelease) { - await github.rest.repos.updateRelease({ - owner, - repo, - release_id: release.id, - prerelease: expectedPrerelease, - make_latest: expectedPrerelease ? "false" : "legacy", - }); - } - await restoreLatestStable(); - return; - } - - await github.rest.repos.updateRelease({ - owner, - repo, - release_id: release.id, - draft: true, - prerelease: true, - make_latest: "false", - }); - await restoreLatestStable(); - await github.rest.repos.deleteRelease({ owner, repo, release_id: release.id }); - try { - await github.rest.git.deleteRef({ owner, repo, ref: `tags/${tag}` }); - } catch (error) { - if (error.status !== 404) throw error; - } - core.warning(`Deleted unauthorized release ${tag} created by ${author || "unknown"}.`); + const { pathToFileURL } = await import("node:url") + const { enforceReleasePolicy } = await import( + pathToFileURL(`${process.env.GITHUB_WORKSPACE}/config/scripts/release-policy.mjs`).href + ) + await enforceReleasePolicy({ github, context, core }) diff --git a/.github/workflows/terminal-ime-e2e.yml b/.github/workflows/terminal-ime-e2e.yml index bd6be26bd27..168280f9ac2 100644 --- a/.github/workflows/terminal-ime-e2e.yml +++ b/.github/workflows/terminal-ime-e2e.yml @@ -49,7 +49,7 @@ jobs: run: >- xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 - pnpm run test:e2e -- + pnpm run test:e2e tests/e2e/terminal-ime-exact-byte.spec.ts --workers=1 diff --git a/.github/workflows/track-community-prs.yaml b/.github/workflows/track-community-prs.yaml deleted file mode 100644 index 5a5dcb670c2..00000000000 --- a/.github/workflows/track-community-prs.yaml +++ /dev/null @@ -1,119 +0,0 @@ -name: Track Community PRs - -on: - pull_request_target: - types: [opened, reopened, ready_for_review] - workflow_dispatch: - inputs: - pr_number: - description: 'PR number to backfill or retry' - required: true - type: number - -permissions: - contents: read - -jobs: - track-community-pr: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Generate bufo-bot token - id: app-token - uses: actions/create-github-app-token@v3 - with: - app-id: 2590194 - private-key: ${{ secrets.BUFO_BOT_PRIVATE_KEY }} - owner: stablyai - - - name: Add PR to project - uses: actions/github-script@v8 - env: - PROJECT_OWNER: stablyai - PROJECT_NUMBER: '13' - INTERNAL_TEAM_SLUG: stably-eng - with: - github-token: ${{ steps.app-token.outputs.token }} - script: | - const projectOwner = process.env.PROJECT_OWNER; - const projectNumber = Number(process.env.PROJECT_NUMBER); - const internalTeamSlug = process.env.INTERNAL_TEAM_SLUG; - const owner = context.repo.owner; - const repo = context.repo.repo; - - const prNumber = context.eventName === 'workflow_dispatch' - ? Number(context.payload.inputs.pr_number) - : context.payload.pull_request.number; - - const { data: pr } = await github.rest.pulls.get({ - owner, - repo, - pull_number: prNumber, - }); - - const author = pr.user.login; - - const skippedAuthors = new Set([ - 'github-actions[bot]', - 'dependabot[bot]', - ]); - - if (skippedAuthors.has(author)) { - core.info(`Skipping bot PR author ${author}.`); - return; - } - - let isInternalAuthor = false; - try { - const membership = await github.rest.teams.getMembershipForUserInOrg({ - org: projectOwner, - team_slug: internalTeamSlug, - username: author, - }); - isInternalAuthor = membership.data.state === 'active'; - } catch (error) { - if (error.status !== 404) { - throw error; - } - } - - if (isInternalAuthor) { - core.info(`Skipping internal PR author ${author}.`); - return; - } - - const projectResult = await github.graphql( - ` - query($owner: String!, $number: Int!) { - organization(login: $owner) { - projectV2(number: $number) { - id - } - } - } - `, - { owner: projectOwner, number: projectNumber }, - ); - - const projectId = projectResult.organization.projectV2.id; - - await github.graphql( - ` - mutation($projectId: ID!, $contentId: ID!) { - addProjectV2ItemById(input: { - projectId: $projectId, - contentId: $contentId - }) { - item { - id - } - } - } - `, - { - projectId, - contentId: pr.node_id, - }, - ); - - core.info(`Added PR #${pr.number} (${pr.html_url}) to project ${projectOwner}/${projectNumber}.`); diff --git a/.github/workflows/unit-plan.yml b/.github/workflows/unit-plan.yml new file mode 100644 index 00000000000..b427908b26d --- /dev/null +++ b/.github/workflows/unit-plan.yml @@ -0,0 +1,48 @@ +name: Unit plan + +# Why its own workflow: the caller's `needs` gate the whole called workflow, so while this lived +# inside unit-tests.yml it waited on static analysis and typecheck before it could even start — +# and the shards then waited on it. Planning needs neither (its inputs are the checkout, a git +# diff against HEAD^1, the import graph, and the checked-in timing baseline), so hoisting it out +# lets it overlap the gate instead of queueing behind it. Measured: a median 93s off the shard +# matrix's start. +on: + workflow_call: + inputs: + selection_mode: + description: Shadow validates selection; selected applies it only to draft PRs. + required: false + default: shadow + type: string + + outputs: + shards: + description: JSON array of shard assignments for the unit matrix. + value: ${{ jobs.plan.outputs.shards }} + +permissions: + contents: read + +jobs: + plan: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + shards: ${{ steps.plan.outputs.shards }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 2 + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + - name: Plan unit selection + id: plan + env: + ORCA_UNIT_SELECTION_MODE: ${{ inputs.selection_mode }} + run: node config/scripts/ci-unit-plan.mjs + - uses: actions/upload-artifact@v7 + continue-on-error: true + with: + name: unit-selection-attempt-${{ github.run_attempt }} + path: ci-shards/unit-selection.json + retention-days: 14 diff --git a/.github/workflows/unit-selection-evidence.yml b/.github/workflows/unit-selection-evidence.yml new file mode 100644 index 00000000000..4ff46be84d0 --- /dev/null +++ b/.github/workflows/unit-selection-evidence.yml @@ -0,0 +1,41 @@ +name: Unit selection evidence + +# Why its own workflow: this job is advisory -- `continue-on-error` on both the job and its +# comparison step, so it can never fail a PR. But a caller's `needs: test` waits for every job in +# the called workflow, so while it lived in unit-tests.yml it held `verify` for ~36s after the +# last shard finished. Called as a sibling instead, it still runs on every PR and still uploads +# its review artifact; it just no longer sits on the critical path. +on: + workflow_call: + +permissions: + contents: read + +jobs: + selection_evidence: + if: ${{ !cancelled() }} + continue-on-error: true + runs-on: ubuntu-slim + steps: + - uses: actions/checkout@v6 + with: + sparse-checkout: config/scripts/ci-unit-selection-review.mjs + sparse-checkout-cone-mode: false + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: '24' + - uses: actions/download-artifact@v8 + with: + pattern: unit-shard-node-*-attempt-${{ github.run_attempt }} + path: unit-evidence/ + - name: Compare selection with full results + continue-on-error: true + run: node config/scripts/ci-unit-selection-review.mjs unit-evidence + - uses: actions/upload-artifact@v7 + if: always() + continue-on-error: true + with: + name: unit-selection-review-attempt-${{ github.run_attempt }} + path: unit-evidence/selection-review.json + retention-days: 30 diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 4ed5063e318..abe9cf28a41 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -8,19 +8,29 @@ on: required: true type: string + runner: + description: Hosted runner for the unit shards; relay integration keeps its x86 host. + required: false + default: ubuntu-latest + type: string + + shards: + description: JSON array of shard assignments, produced by unit-plan.yml. + required: true + type: string + permissions: contents: read jobs: test: - name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }} - runs-on: ubuntu-latest + name: tests node ${{ matrix.node }} ${{ matrix.shard.index }}/${{ matrix.shard.count }} + runs-on: ${{ inputs.runner }} strategy: fail-fast: false matrix: node: ${{ fromJSON(inputs.node_versions) }} - shard: [1, 2, 3, 4, 5, 6, 7, 8] - shard_total: [8] + shard: ${{ fromJSON(inputs.shards) }} steps: - name: Checkout @@ -33,41 +43,26 @@ jobs: native-runtime: node node-version: ${{ matrix.node }} cache-electron-package: 'true' - cache-dependency-path: | - pnpm-lock.yaml - cloud/pnpm-lock.yaml - name: Install Electron package binary for tests run: node config/scripts/install-electron-package-binary.mjs + - uses: actions/download-artifact@v8 + continue-on-error: true + with: + name: unit-selection-attempt-${{ github.run_attempt }} + path: ci-shards/ + - name: Test shard env: ORCA_BALANCE_UNIT_SHARDS: '1' ORCA_BACKGROUND_LAUNCH: '1' run: | - export ORCA_SHARD_SOURCE_SHA="$(git rev-parse HEAD)" + ORCA_SHARD_SOURCE_SHA="$(git rev-parse HEAD)" + export ORCA_SHARD_SOURCE_SHA pnpm exec vitest run --config config/vitest.config.ts \ - --exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \ - --exclude=src/main/daemon/shell-ready.test.ts \ - --exclude=src/main/daemon/node-pty-fd-leak.test.ts \ - --exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \ - --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \ - --exclude=src/main/pty/omp-shell-wrapper-alias-safety.test.ts \ - --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \ - --exclude=src/main/shell-startup-feature-channel.test.ts \ - --exclude=src/main/terminal-history-fish-session.node-pty.test.ts \ - --exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \ - --exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \ - --exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \ - --exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ - --exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \ - --exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \ - --exclude=src/shared/startup-shell-portability.live-shell.test.ts \ - --exclude=src/shared/posix-command-path-lookup.test.ts \ - --exclude=tests/e2e/relay-region-compatibility.unit.test.ts \ - --exclude=tests/e2e/relay-region-correction.unit.test.ts \ - --exclude=tests/e2e/cross-version-wire/** \ - --shard=${{ matrix.shard }}/${{ matrix.shard_total }} + --shard=${{ matrix.shard.index }}/${{ matrix.shard.count }} \ + ${{ inputs.runner == 'ubuntu-24.04-arm' && '--maxWorkers=4' || '' }} - name: Upload unit shard assignment if: always() @@ -75,13 +70,17 @@ jobs: continue-on-error: true uses: actions/upload-artifact@v7 with: - name: unit-shard-node-${{ matrix.node }}-${{ matrix.shard }}-attempt-${{ github.run_attempt }} + name: unit-shard-node-${{ matrix.node }}-${{ matrix.shard.index }}-attempt-${{ github.run_attempt }} path: ci-shards/ retention-days: 14 if-no-files-found: warn relay_integration: - name: relay integration node ${{ fromJSON(inputs.node_versions)[0] }} + name: relay integration node ${{ matrix.node }} + strategy: + fail-fast: false + matrix: + node: ${{ fromJSON(inputs.node_versions) }} runs-on: ubuntu-latest steps: @@ -93,7 +92,7 @@ jobs: - uses: ./.github/actions/install-node-dependencies with: native-runtime: node - node-version: ${{ fromJSON(inputs.node_versions)[0] }} + node-version: ${{ matrix.node }} cache-electron-package: 'true' cache-dependency-path: | pnpm-lock.yaml diff --git a/.github/workflows/win-crash-survival-e2e.yml b/.github/workflows/win-crash-survival-e2e.yml index e0d7b80d047..53c231111db 100644 --- a/.github/workflows/win-crash-survival-e2e.yml +++ b/.github/workflows/win-crash-survival-e2e.yml @@ -35,6 +35,7 @@ jobs: timeout-minutes: 50 env: EXPECT: ${{ inputs.expect || 'survival' }} + ORCA_BACKGROUND_LAUNCH: '1' steps: - name: Checkout diff --git a/.github/workflows/win-update-survival-e2e.yml b/.github/workflows/win-update-survival-e2e.yml index 50c38f2e3ad..b709f82b788 100644 --- a/.github/workflows/win-update-survival-e2e.yml +++ b/.github/workflows/win-update-survival-e2e.yml @@ -29,6 +29,21 @@ on: options: - survival - cold-restore + trace_installer: + description: Trace installer process checks in this disposable build + type: boolean + default: false + policy: + description: Execution policy inherited by harness child processes + type: choice + default: inherited + options: + - inherited + - Restricted + from_release: + description: Optional base release tag; blank uses this branch build + type: string + default: '' permissions: contents: read @@ -39,7 +54,7 @@ concurrency: jobs: survival: - name: survival (branch build over itself) + name: update ${{ inputs.from_release || 'branch' }} to branch (${{ inputs.expect || 'survival' }}, policy=${{ inputs.policy || 'inherited' }}) runs-on: windows-2022 timeout-minutes: 50 env: @@ -62,18 +77,23 @@ jobs: with: install: false - - name: Install dependencies - run: pnpm install --frozen-lockfile - # Why: cache the built installer keyed on the inputs that affect it, so a # harness-only edit skips the ~20 min electron-builder build. The daemon # relocation code lives under src/, so changing it correctly rebuilds. + # Hash before installation creates native build artifacts under native/. - name: Cache branch installer id: cache-installer - uses: actions/cache@v4 + uses: actions/cache/restore@v4 with: path: dist/orca-windows-setup.exe - key: branch-installer-${{ hashFiles('src/**', 'config/**', 'native/**', 'resources/win32/**', 'package.json', 'pnpm-lock.yaml') }} + key: branch-installer-${{ inputs.trace_installer && format('trace-{0}-', hashFiles('tests/tools/win-update-e2e/trace-installer-branches.mjs')) || '' }}${{ hashFiles('src/**', 'config/**', 'native/**', 'resources/**', 'mobile/**', 'patches/**', 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml') }} + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Instrument disposable installer and uninstaller + if: inputs.trace_installer && steps.cache-installer.outputs.cache-hit != 'true' + run: node tests/tools/win-update-e2e/trace-installer-branches.mjs # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle # build resolves React Native and Expo from mobile/node_modules. Gated with the @@ -88,26 +108,46 @@ jobs: pnpm run build:desktop pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never - # Why: install the branch build, open a terminal, update the SAME build - # over it, and assert the relocated daemon survives. Same build on both - # sides isolates the survival mechanism (NSIS kill sweep + userData daemon - # + adoption) from cross-version staleness policy. No --install-dir: a CI - # runner has no real Orca to protect. + - name: Cache successfully built installer before survival verification + if: steps.cache-installer.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: dist/orca-windows-setup.exe + key: ${{ steps.cache-installer.outputs.cache-primary-key }} + + # A released base also exercises its old uninstaller; default runs isolate this branch. - name: Run survival harness id: harness shell: pwsh env: ORCA_E2E_DIAG_DIR: artifacts/diag + ORCA_BACKGROUND_LAUNCH: '1' + DEBUG: 'pw:browser' + ORCA_E2E_NSIS_TRACE: ${{ github.workspace }}\artifacts\nsis-process-trace.log + ORCA_E2E_PROCESS_POLICY: ${{ inputs.policy || 'inherited' }} + FROM_RELEASE: ${{ inputs.from_release }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | New-Item -ItemType Directory -Force artifacts | Out-Null $exe = "dist/orca-windows-setup.exe" if (-not (Test-Path $exe)) { throw "Installer not found at $exe" } $log = "artifacts/survival-output.log" - node tests/tools/win-update-e2e/run.mjs ` - --from "$exe" ` - --to "$exe" ` - --expect "$env:EXPECT" ` - --soak-seconds 60 2>&1 | Tee-Object -FilePath $log + $harnessArgs = @('--to', $exe, '--expect', $env:EXPECT, '--soak-seconds', '60') + if ($env:FROM_RELEASE) { + $harnessArgs += @('--from-release', $env:FROM_RELEASE) + } else { + $harnessArgs += @('--from', $exe) + } + # Only the harness and its children inherit the test policy. + $launch = @' + if (process.env.ORCA_E2E_PROCESS_POLICY === 'Restricted') { + process.env.PSExecutionPolicyPreference = 'Restricted' + } + console.log('Harness child process policy: ' + (process.env.PSExecutionPolicyPreference || 'inherited')) + process.argv.splice(1, 0, 'tests/tools/win-update-e2e/run.mjs') + await import('./tests/tools/win-update-e2e/run.mjs') + '@ + node --input-type=module -e $launch -- @harnessArgs 2>&1 | Tee-Object -FilePath $log exit $LASTEXITCODE - name: Upload survival output @@ -117,6 +157,7 @@ jobs: name: win-update-survival-output path: | artifacts/survival-output.log + artifacts/nsis-process-trace.log artifacts/diag/** retention-days: 7 if-no-files-found: warn diff --git a/.github/workflows/windows-signing-rehearsal.yml b/.github/workflows/windows-signing-rehearsal.yml index 0fa2a31cd97..c2364330164 100644 --- a/.github/workflows/windows-signing-rehearsal.yml +++ b/.github/workflows/windows-signing-rehearsal.yml @@ -210,20 +210,25 @@ jobs: # signed and unsigned binaries. - name: Restore signed inner binaries into unpacked app shell: pwsh + env: + SIGNING_POLICY: ${{ inputs.signing-policy-slug || 'test-signing' }} run: | + $requireValid = $env:SIGNING_POLICY -ne 'test-signing' $root = Resolve-Path 'dist/win-unpacked' $failures = New-Object System.Collections.Generic.List[string] foreach ($relative in Get-Content 'inner-signing-list.txt') { - $signed = Get-ChildItem -Path signed-inner -Recurse -File | - Where-Object { [System.IO.Path]::GetRelativePath((Resolve-Path 'signed-inner'), $_.FullName).TrimStart('\', '/') -like "*$relative" } | - Select-Object -First 1 - if ($null -eq $signed) { - $failures.Add("missing from signed artifact: $relative") + $candidates = @( + (Join-Path 'signed-inner' $relative), + (Join-Path 'signed-inner/signing-stage' $relative) + ) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } + if (@($candidates).Count -ne 1) { + $failures.Add("missing or ambiguous signed artifact path: $relative") continue } + $signed = Get-Item -LiteralPath @($candidates)[0] $signature = Get-AuthenticodeSignature -FilePath $signed.FullName - if ($null -eq $signature.SignerCertificate) { - $failures.Add("returned without a signature: $relative") + if ($null -eq $signature.SignerCertificate -or ($requireValid -and ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notlike '*CN=SignPath Foundation*'))) { + $failures.Add("returned without a valid SignPath signature: $relative") continue } Copy-Item -Path $signed.FullName -Destination (Join-Path $root $relative) -Force @@ -236,15 +241,21 @@ jobs: - name: Restore signed uninstaller for the installer rebuild shell: pwsh + env: + SIGNING_POLICY: ${{ inputs.signing-policy-slug || 'test-signing' }} run: | - $signed = Get-ChildItem -Path signed-inner -Recurse -File -Filter 'orca-uninstaller.exe' | - Select-Object -First 1 - if ($null -eq $signed) { - throw 'SignPath did not return uninstaller/orca-uninstaller.exe; check the inner-binaries artifact configuration covers it.' + $requireValid = $env:SIGNING_POLICY -ne 'test-signing' + $candidates = @( + (Join-Path 'signed-inner' 'uninstaller\orca-uninstaller.exe'), + (Join-Path 'signed-inner/signing-stage' 'uninstaller\orca-uninstaller.exe') + ) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } + if (@($candidates).Count -ne 1) { + throw 'Missing or ambiguous uninstaller/orca-uninstaller.exe in the signed artifact; check the windows-inner-binaries-zip configuration.' } + $signed = Get-Item -LiteralPath @($candidates)[0] $signature = Get-AuthenticodeSignature -FilePath $signed.FullName - if ($null -eq $signature.SignerCertificate) { - throw 'The returned NSIS uninstaller carries no signature.' + if ($null -eq $signature.SignerCertificate -or ($requireValid -and ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notlike '*CN=SignPath Foundation*'))) { + throw 'The returned NSIS uninstaller carries no valid SignPath signature.' } $signedDir = Join-Path $env:RUNNER_TEMP 'uninstaller-signing\signed' New-Item -ItemType Directory -Force -Path $signedDir | Out-Null @@ -487,7 +498,11 @@ jobs: Test-Signature "shipped: Uninstall Orca.exe (via $installedVia)" $installedUninstaller.FullName } - foreach ($relative in Get-Content 'inner-signing-list.txt') { + $targets = @(Get-Content 'inner-signing-list.txt') + foreach ($requiredTarget in @('Orca.exe', 'resources\bin\orca.exe')) { + if ($targets -notcontains $requiredTarget) { $targets += $requiredTarget } + } + foreach ($relative in $targets) { $path = Join-Path $root $relative if (-not (Test-Path $path)) { $failures.Add("missing from installer payload: $relative") diff --git a/.github/workflows/windows-terminal-restart-e2e.yml b/.github/workflows/windows-terminal-restart-e2e.yml index abb8dc89aa8..d576ee65049 100644 --- a/.github/workflows/windows-terminal-restart-e2e.yml +++ b/.github/workflows/windows-terminal-restart-e2e.yml @@ -22,6 +22,7 @@ jobs: timeout-minutes: 30 env: NODE_OPTIONS: --max-old-space-size=4096 + ORCA_BACKGROUND_LAUNCH: '1' steps: - name: Checkout diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 86931f1f9ec..19856d6ca84 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -6,7 +6,9 @@ "trailingComma": "none", "ignorePatterns": [ "cloud/**", + "resources/licenses/**", ".github/actions/cloud-sql-rollout-lease/**", - ".anti-slop-plugin/**" + ".anti-slop-plugin/**", + "src/main/runtime/__fixtures__/*.timing.json" ] } diff --git a/.oxlintrc.json b/.oxlintrc.json index 7de99d4461e..7a0753c06e2 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -143,7 +143,26 @@ { "files": ["src/renderer/src/**/*.{ts,tsx}"], "rules": { - "renderer-scrollbar-style/require-styled-vertical-scrollbar": "error" + "renderer-scrollbar-style/require-styled-vertical-scrollbar": "error", + "no-restricted-properties": [ + "error", + { + "property": "randomUUID", + "message": "crypto.randomUUID is missing in non-secure contexts (Remote Web over plain HTTP), which white-screens the app. Use createBrowserUuid() from '@/lib/browser-uuid'." + } + ] + } + }, + { + "files": ["src/shared/**/*.ts"], + "rules": { + "no-restricted-properties": [ + "error", + { + "property": "randomUUID", + "message": "src/shared is compiled into the web bundle, where crypto.randomUUID is missing in non-secure contexts (Remote Web over plain HTTP). Use createNonSecureContextUuid() from './non-secure-context-uuid', or import randomUUID from 'node:crypto' in main-only code." + } + ] } }, { diff --git a/Casks/orca.rb b/Casks/orca.rb index 6b97b27d52e..1abc9b3a912 100644 --- a/Casks/orca.rb +++ b/Casks/orca.rb @@ -5,8 +5,7 @@ cask "orca" do sha256 arm: "fc707f290ff3b631b7b7947bf339885b61a43d2e89475997c125b61268ed4966", intel: "5f677c13a08f7a5740442e29d388285a86488c8c1f7aa5f10a8721a2c6ede8e4" - url "https://github.com/stablyai/orca/releases/download/v#{version}/orca-macos-#{arch}.dmg", - verified: "github.com/stablyai/orca/" + url "https://github.com/stablyai/orca/releases/download/v#{version}/orca-macos-#{arch}.dmg" name "Orca" desc "IDE for orchestrating AI coding agents across terminals and worktrees" homepage "https://onorca.dev/" diff --git a/Casks/orca@rc.rb b/Casks/orca@rc.rb index 63bc843b640..08eaeeca459 100644 --- a/Casks/orca@rc.rb +++ b/Casks/orca@rc.rb @@ -5,8 +5,7 @@ cask "orca@rc" do sha256 arm: "563b6b14323fc9d5489299c82442d514bc12cabffc9d06d3964ed572af4b3955", intel: "457088c7021f07de1a419197f7b2bd00092741ad4727d4fef3d86af38a6831e7" - url "https://github.com/stablyai/orca/releases/download/v#{version}/orca-macos-#{arch}.dmg", - verified: "github.com/stablyai/orca/" + url "https://github.com/stablyai/orca/releases/download/v#{version}/orca-macos-#{arch}.dmg" name "Orca RC" desc "IDE for orchestrating AI coding agents across terminals and worktrees" homepage "https://onorca.dev/" diff --git a/README.md b/README.md index 98efc301c2f..93e09614609 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -179,6 +179,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca. Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   Muse logo Muse   + DeepSeek Harness logo DeepSeek Harness   ZCode logo ZCode   OpenCode logo OpenCode   MiMo Code logo MiMo Code   @@ -194,7 +195,9 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca. Autohand Code logo Autohand Code   Charm logo Charm   Cline logo Cline   + CodeBuddy logo CodeBuddy   Codebuff logo Codebuff   + Freebuff logo Freebuff   Command Code logo Command Code   Continue logo Continue   Droid logo Droid   @@ -231,7 +234,7 @@ yay -S stably-orca-bin Pair with your desktop app to monitor and steer your agents from your phone. -- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA) +- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) - **Android:** [Download APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk) --- diff --git a/cloud/apps/push/src/durable-push-claim.test.ts b/cloud/apps/push/src/durable-push-claim.test.ts index f43588bd2c3..c7a228a0559 100644 --- a/cloud/apps/push/src/durable-push-claim.test.ts +++ b/cloud/apps/push/src/durable-push-claim.test.ts @@ -157,7 +157,7 @@ it('keeps claim, exclusion and prune correct without the queue index', async () for (const claim of leased) await store.finish(claim) expect((await store.claim())?.notification.notificationSeq).toBe(2) advance(10 * 60_000) - expect(await store.prune()).toBe(1) + expect(await store.prune()).toEqual({ deleted: 1, saturated: false }) expect(await batchCount(db)).toBe(0) }) @@ -211,11 +211,11 @@ it('prunes a large backlog in bounded calls without touching live or leased work [JSON.stringify(notification(9)), now - 1, now - 1, now + 1000, now - 1] ) await store.accept('host', 'phone-live', notification(1)) - expect(await store.prune()).toBe(perCall) - expect(await store.prune()).toBe(500) - expect(await store.prune()).toBe(0) + expect(await store.prune()).toEqual({ deleted: perCall, saturated: true }) + expect(await store.prune()).toEqual({ deleted: 500, saturated: false }) + expect(await store.prune()).toEqual({ deleted: 0, saturated: false }) advance(1000) - expect(await store.prune()).toBe(1) + expect(await store.prune()).toEqual({ deleted: 1, saturated: false }) expect(await batchCount(db)).toBe(1) expect(await store.pendingCount('phone-live')).toBe(1) }) diff --git a/cloud/apps/push/src/durable-push-store.ts b/cloud/apps/push/src/durable-push-store.ts index 61fed235018..02d26f20b3e 100644 --- a/cloud/apps/push/src/durable-push-store.ts +++ b/cloud/apps/push/src/durable-push-store.ts @@ -12,6 +12,8 @@ const CLAIM_CANDIDATE_ATTEMPTS = 4 export const PRUNE_BATCH_ROWS = 2_000 export const PRUNE_MAX_BATCHES = 50 export const DELIVERY_LEASE_MS = 30_000 +// `saturated` means the batch budget ran out with rows still matching, so a backlog remains. +export type PushPruneSweep = { deleted: number; saturated: boolean } export type QueuedPushDelivery = { id: string registrationId: string @@ -200,10 +202,10 @@ export class DurablePushStore { } // Bounded per call and per statement, so it drains any backlog on its own without holding locks. - async prune(): Promise { + async prune(): Promise { const now = this.now() // Also clears terminal rows older revisions kept, since each carries a past expires_at. - let deleted = await this.deleteInBatches( + let { deleted, saturated } = await this.deleteInBatches( 'push_delivery_batches', 'batch_id', 'expires_at <= ? AND lease_until <= ?', @@ -215,9 +217,11 @@ export class DurablePushStore { ['push_event_recipients', 'event_id, registration_id'], ['push_events', 'event_id'] ] as const) { - deleted += await this.deleteInBatches(table, key, 'created_at < ?', [now - RETENTION_MS]) + const sweep = await this.deleteInBatches(table, key, 'created_at < ?', [now - RETENTION_MS]) + deleted += sweep.deleted + saturated ||= sweep.saturated } - return deleted + return { deleted, saturated } } private async deleteInBatches( @@ -225,7 +229,7 @@ export class DurablePushStore { key: string, where: string, params: unknown[] - ): Promise { + ): Promise { const lockRows = this.background.dialect === 'postgres' ? ' FOR UPDATE SKIP LOCKED' : '' let total = 0 for (let batch = 0; batch < PRUNE_MAX_BATCHES; batch++) { @@ -235,8 +239,9 @@ export class DurablePushStore { ) const changes = Number(result?.changes ?? 0) total += changes - if (changes < PRUNE_BATCH_ROWS) break + // A short batch drained the predicate; only a full last batch leaves rows behind. + if (changes < PRUNE_BATCH_ROWS) return { deleted: total, saturated: false } } - return total + return { deleted: total, saturated: true } } } diff --git a/cloud/apps/push/src/push-background.test.ts b/cloud/apps/push/src/push-background.test.ts new file mode 100644 index 00000000000..21598ef67a2 --- /dev/null +++ b/cloud/apps/push/src/push-background.test.ts @@ -0,0 +1,174 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { startPushBackground } from './push-background.js' +import { createPushServerHarness } from './push-server-harness.test-fixture.js' +import { reserveRequestConnection } from './push-background-database.js' +import { DurablePushStore, PRUNE_BATCH_ROWS, type PushPruneSweep } from './durable-push-store.js' +import type { PushDatabase } from './push-database.js' + +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) await cleanup() + vi.useRealTimers() + vi.restoreAllMocks() +}) + +async function fixture(mode: 'active' | 'validation' = 'active') { + const harness = await createPushServerHarness() + const runtime = harness.server + const challenges = vi.spyOn(runtime.challenges, 'pruneExpired').mockResolvedValue(0) + const sessions = vi.spyOn(runtime.sessions, 'pruneExpired').mockResolvedValue(0) + const deliveries = vi + .spyOn(runtime.deliveryStore, 'prune') + .mockResolvedValue({ deleted: 0, saturated: false }) + vi.spyOn(runtime.worker, 'start').mockImplementation(() => {}) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.useFakeTimers() + const stop = startPushBackground({ mode }, runtime) + cleanups.push(async () => { + await stop() + await harness.close() + }) + return { stop, challenges, sessions, deliveries, warn } +} + +it('keeps one slow sweep per store while other stores keep their cadence', async () => { + const h = await fixture() + let finish!: (sweep: PushPruneSweep) => void + h.deliveries.mockImplementationOnce( + () => new Promise((resolve) => (finish = resolve)) + ) + try { + await vi.advanceTimersByTimeAsync(10 * 60_000) + expect(h.deliveries).toHaveBeenCalledTimes(1) + expect(h.challenges).toHaveBeenCalledTimes(10) + expect(h.sessions).toHaveBeenCalledTimes(1) + } finally { + finish({ deleted: 100_000, saturated: false }) + } + await vi.advanceTimersByTimeAsync(60_000) + expect(h.deliveries).toHaveBeenCalledTimes(2) + await h.stop() + await vi.advanceTimersByTimeAsync(10 * 60_000) + expect(h.deliveries).toHaveBeenCalledTimes(2) + expect(h.challenges).toHaveBeenCalledTimes(11) + expect(h.sessions).toHaveBeenCalledTimes(1) +}) + +it('reports a sweep that is still running a full interval after it started', async () => { + const h = await fixture() + let finish!: (sweep: PushPruneSweep) => void + h.deliveries.mockImplementationOnce( + () => new Promise((resolve) => (finish = resolve)) + ) + try { + await vi.advanceTimersByTimeAsync(119_999) + expect(h.warn).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(h.warn).toHaveBeenCalledWith( + JSON.stringify({ event: 'orca_push_prune_overdue', target: 'deliveries' }) + ) + } finally { + finish({ deleted: 0, saturated: false }) + } + // One report per sweep: the settled sweep clears its own watchdog. + await vi.advanceTimersByTimeAsync(10 * 60_000) + expect(h.warn).toHaveBeenCalledTimes(1) +}) + +it('releases a failed sweep so the next scheduled sweep can recover', async () => { + const h = await fixture() + h.deliveries.mockRejectedValueOnce(new Error('database unavailable')) + await vi.advanceTimersByTimeAsync(60_000) + expect(h.deliveries).toHaveBeenCalledTimes(1) + expect(h.warn).toHaveBeenCalledWith( + JSON.stringify({ event: 'orca_push_prune_failed', target: 'deliveries', error: 'Error' }) + ) + await vi.advanceTimersByTimeAsync(60_000) + expect(h.deliveries).toHaveBeenCalledTimes(2) + expect(h.warn).toHaveBeenCalledTimes(1) +}) + +it('resumes a saturated sweep at once rather than waiting out the interval', async () => { + const h = await fixture() + let backlogSweeps = 3 + h.deliveries.mockImplementation(async () => { + const saturated = backlogSweeps-- > 0 + return { deleted: saturated ? PRUNE_BATCH_ROWS : 0, saturated } + }) + await vi.advanceTimersByTimeAsync(60_000) + expect(h.deliveries).toHaveBeenCalledTimes(1) + // Three saturated sweeps resume within milliseconds instead of costing an interval each. + await vi.advanceTimersByTimeAsync(10) + expect(h.deliveries).toHaveBeenCalledTimes(4) + await vi.advanceTimersByTimeAsync(59_000) + expect(h.deliveries).toHaveBeenCalledTimes(4) + await vi.advanceTimersByTimeAsync(1_000) + expect(h.deliveries).toHaveBeenCalledTimes(5) +}) + +it('keeps a delivery claim behind one statement while a backlog drains back to back', async () => { + const h = await fixture() + let backlog = true + let finishedDeletes = 0 + const database: PushDatabase = { + dialect: 'postgres', + query: async (sql) => { + if (!sql.startsWith('DELETE')) return [] + await new Promise((resolve) => setTimeout(resolve, 4_000)) + finishedDeletes++ + // Only deliveries hold a backlog, so each sweep spends its whole budget there and returns. + if (!backlog || !sql.includes('push_delivery_batches')) return [{ changes: 0 }] + return [{ changes: PRUNE_BATCH_ROWS }] + }, + transaction: (operation) => operation(database), + lockQuotaScope: async () => {}, + tryLockScope: async () => true, + tryLockSharedScope: async () => true, + close: async () => {} + } + const store = new DurablePushStore(database, Date.now, reserveRequestConnection(database, 2)) + const sweeps: Promise[] = [] + let inFlight = 0 + let concurrentSweeps = 0 + h.deliveries.mockImplementation(() => { + concurrentSweeps = Math.max(concurrentSweeps, ++inFlight) + const sweep = store.prune().finally(() => void inFlight--) + sweeps.push(sweep) + return sweep + }) + try { + await vi.advanceTimersByTimeAsync(10 * 60_000) + const queuedAt = finishedDeletes + const startedAt = Date.now() + let statementsAhead: number | undefined + let claimDelay: number | undefined + const claim = store.claim().then(() => { + statementsAhead = finishedDeletes - queuedAt + claimDelay = Date.now() - startedAt + }) + await vi.advanceTimersByTimeAsync(44_000) + await claim + // Sweeping serially parks one statement ahead of the claim no matter how long the drain runs. + expect({ statementsAhead, concurrentSweeps }).toEqual({ + statementsAhead: 1, + concurrentSweeps: 1 + }) + expect(claimDelay).toBeLessThanOrEqual(4_000) + // Each sweep exhausts its 50-batch budget, so the drain continues instead of idling out the tick. + expect(sweeps.length).toBeGreaterThan(1) + } finally { + await h.stop() + backlog = false + await vi.advanceTimersByTimeAsync(10 * 60_000) + await Promise.all(sweeps) + } +}) + +it('keeps validation mode free of sweeps and timers', async () => { + const h = await fixture('validation') + await vi.advanceTimersByTimeAsync(20 * 60_000) + expect(h.challenges).not.toHaveBeenCalled() + expect(h.sessions).not.toHaveBeenCalled() + expect(h.deliveries).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) +}) diff --git a/cloud/apps/push/src/push-background.ts b/cloud/apps/push/src/push-background.ts index 8925fd41fed..9fad4700e7b 100644 --- a/cloud/apps/push/src/push-background.ts +++ b/cloud/apps/push/src/push-background.ts @@ -1,26 +1,55 @@ import type { PushConfig } from './config.js' +import type { PushPruneSweep } from './durable-push-store.js' import type { createPushServer } from './push-server.js' const CHALLENGE_PRUNE_INTERVAL_MS = 60_000 const SESSION_PRUNE_INTERVAL_MS = 10 * 60_000 const DELIVERY_PRUNE_INTERVAL_MS = 60_000 -function prune(label: string, run: () => Promise, intervalMs: number): NodeJS.Timeout { - const timer = setInterval(() => { - void run().catch((error: unknown) => { - console.warn( - JSON.stringify({ - event: 'orca_push_prune_failed', - target: label, - error: error instanceof Error ? error.name : 'unknown' - }) - ) - }) - }, intervalMs) - timer.unref() - return timer +// Chained rather than periodic, so a sweep spanning many bounded statements never overlaps itself and +// one that exhausted its batch budget resumes at once instead of idling out the rest of the interval. +function prune(label: string, run: () => Promise, intervalMs: number): () => void { + let timer: NodeJS.Timeout | undefined + let overdue: NodeJS.Timeout | undefined + let stopped = false + function schedule(delayMs: number): void { + if (stopped) return + timer = setTimeout(tick, delayMs) + timer.unref() + } + function tick(): void { + // Admission waits are untimed, so a lost slot release would otherwise stall retention silently. + overdue = setTimeout(() => { + console.warn(JSON.stringify({ event: 'orca_push_prune_overdue', target: label })) + }, intervalMs) + overdue.unref() + void run() + .then((sweep) => schedule(sweep.saturated ? 0 : intervalMs)) + .catch((error: unknown) => { + console.warn( + JSON.stringify({ + event: 'orca_push_prune_failed', + target: label, + error: error instanceof Error ? error.name : 'unknown' + }) + ) + schedule(intervalMs) + }) + .finally(() => clearTimeout(overdue)) + } + schedule(intervalMs) + return () => { + stopped = true + clearTimeout(timer) + clearTimeout(overdue) + } } +// One DELETE under a statement timeout: there is no batch budget for it to exhaust. +const unbatchedSweep = + (run: () => Promise) => + async (): Promise => ({ deleted: await run(), saturated: false }) + export function startPushBackground( config: Pick, runtime: Pick< @@ -30,14 +59,22 @@ export function startPushBackground( ): () => Promise { if (config.mode === 'validation') return async () => {} const { challenges, sessions, deliveryStore, worker } = runtime - const timers = [ - prune('challenges', () => challenges.pruneExpired(), CHALLENGE_PRUNE_INTERVAL_MS), - prune('sessions', () => sessions.pruneExpired(), SESSION_PRUNE_INTERVAL_MS), + const stops = [ + prune( + 'challenges', + unbatchedSweep(() => challenges.pruneExpired()), + CHALLENGE_PRUNE_INTERVAL_MS + ), + prune( + 'sessions', + unbatchedSweep(() => sessions.pruneExpired()), + SESSION_PRUNE_INTERVAL_MS + ), prune('deliveries', () => deliveryStore.prune(), DELIVERY_PRUNE_INTERVAL_MS) ] worker.start() return async () => { - for (const timer of timers) clearInterval(timer) + for (const stop of stops) stop() await worker.stop() } } diff --git a/cloud/apps/push/src/push-database.ts b/cloud/apps/push/src/push-database.ts index b1a7415297b..9957a7c28b5 100644 --- a/cloud/apps/push/src/push-database.ts +++ b/cloud/apps/push/src/push-database.ts @@ -85,15 +85,17 @@ class SqliteDatabase extends SqliteTransaction { let release!: () => void this.tail = new Promise((resolve) => (release = resolve)) await previous - this.database.exec('BEGIN IMMEDIATE') - const transaction = new SqliteTransaction(this.database) try { - const result = await operation(transaction) - this.database.exec('COMMIT') - return result - } catch (error) { - this.database.exec('ROLLBACK') - throw error + this.database.exec('BEGIN IMMEDIATE') + const transaction = new SqliteTransaction(this.database) + try { + const result = await operation(transaction) + this.database.exec('COMMIT') + return result + } catch (error) { + this.database.exec('ROLLBACK') + throw error + } } finally { release() } diff --git a/cloud/apps/push/src/sqlite-transaction-queue.test.ts b/cloud/apps/push/src/sqlite-transaction-queue.test.ts new file mode 100644 index 00000000000..1ee5e8b3d52 --- /dev/null +++ b/cloud/apps/push/src/sqlite-transaction-queue.test.ts @@ -0,0 +1,101 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { setImmediate } from 'node:timers/promises' +import { expect, it, vi } from 'vitest' +import { openPushDatabase } from './push-database.js' + +it('releases queued work and close after a SQLite transaction cannot acquire its lock', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'orca-push-sqlite-queue-')) + let connection: DatabaseSync | undefined + const prepare = DatabaseSync.prototype.prepare + // Keep the native handle reachable for cleanup even if a queue regression strands close(). + const capture = vi + .spyOn(DatabaseSync.prototype, 'prepare') + .mockImplementation(function (this: DatabaseSync, sql) { + connection = this + return prepare.call(this, sql) + }) + const database = await openPushDatabase({ dataDir }) + capture.mockRestore() + const blocker = new DatabaseSync(join(dataDir, 'orca-push.sqlite')) + try { + await database.query('CREATE TABLE queue_progress (value INTEGER)') + await database.query('INSERT INTO queue_progress VALUES (0)') + blocker.exec('BEGIN IMMEDIATE') + const operation = vi.fn(async () => undefined) + await expect(database.transaction(operation)).rejects.toThrow('database is locked') + let lockFailures = 0 + const blocked = Array.from({ length: 5 }, () => + database.transaction(operation).catch(() => { + lockFailures += 1 + }) + ) + await setImmediate() + expect(lockFailures).toBe(5) + await Promise.all(blocked) + expect(operation).not.toHaveBeenCalled() + blocker.exec('ROLLBACK') + let completed = 0 + const pending = Array.from({ length: 100 }, () => + database.transaction(async (transaction) => { + await transaction.query('UPDATE queue_progress SET value = value + 1') + completed += 1 + }) + ) + for (const request of pending) void request.catch(() => undefined) + await setImmediate() + expect(completed).toBe(100) + await Promise.all(pending) + expect(await database.query('SELECT value FROM queue_progress')).toEqual([{ value: 100 }]) + let closed = false + const closing = database.close().then(() => { + closed = true + }) + await setImmediate() + expect(closed).toBe(true) + await closing + } finally { + capture.mockRestore() + blocker.close() + if (connection?.isOpen) connection.close() + rmSync(dataDir, { recursive: true, force: true }) + } +}) + +it('does not roll back a transaction when BEGIN failed before taking ownership', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'orca-push-sqlite-owner-')) + let connection: DatabaseSync | undefined + const prepare = DatabaseSync.prototype.prepare + const capture = vi + .spyOn(DatabaseSync.prototype, 'prepare') + .mockImplementation(function (this: DatabaseSync, sql) { + connection = this + return prepare.call(this, sql) + }) + const database = await openPushDatabase({ dataDir }) + capture.mockRestore() + try { + await database.query('CREATE TABLE queue_owner (value INTEGER)') + await database.query('BEGIN IMMEDIATE') + await database.query('INSERT INTO queue_owner VALUES (7)') + await expect(database.transaction(async () => undefined)).rejects.toThrow( + 'cannot start a transaction within a transaction' + ) + expect(connection?.isTransaction).toBe(true) + let rows: unknown + void database.query('SELECT value FROM queue_owner').then((result) => { + rows = result + }) + await setImmediate() + expect(rows).toEqual([{ value: 7 }]) + await database.query('ROLLBACK') + expect(await database.query('SELECT value FROM queue_owner')).toEqual([]) + await database.close() + } finally { + capture.mockRestore() + if (connection?.isOpen) connection.close() + rmSync(dataDir, { recursive: true, force: true }) + } +}) diff --git a/cloud/apps/relay/src/assignment-headroom-scope.test.ts b/cloud/apps/relay/src/assignment-headroom-scope.test.ts new file mode 100644 index 00000000000..9862849d8e0 --- /dev/null +++ b/cloud/apps/relay/src/assignment-headroom-scope.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { RelayAssignmentStore } from './assignment-store.js' +import { openInMemoryRelayDatabase, type RelayDatabase } from './database.js' + +const NOW = 100_000 +const CELL_COUNT = 32 +const identity = { userId: 'headroom-user', relayHostId: 'headroomhost0001' } + +function observeHeadroom(database: RelayDatabase, rowCounts: number[]): RelayDatabase { + return { + dialect: database.dialect, + async query(sql, params) { + const rows = await database.query(sql, params) + if (sql.includes('FROM relay_cell_connection_limits limits')) rowCounts.push(rows.length) + return rows + }, + queryLocked: (sql, params, options) => database.queryLocked(sql, params, options), + transaction: (operation, options) => + database.transaction((transaction) => operation(observeHeadroom(transaction, rowCounts)), options), + close: () => database.close() + } +} + +describe('assignment headroom query scope', () => { + let database: RelayDatabase | undefined + afterEach(async () => await database?.close()) + + async function setup() { + database = await openInMemoryRelayDatabase() + const rowCounts: number[] = [] + const store = new RelayAssignmentStore(observeHeadroom(database, rowCounts), () => NOW, { + requireLiveCells: true, + heartbeatTtlMs: 45_000 + }) + const cells = Array.from({ length: CELL_COUNT }, (_, index) => ({ + id: `cell-${String(index).padStart(2, '0')}`, + url: `https://cell-${index}.example.com`, + capacityRequests: 1_000, + connectionHardCap: 600 as const, + connectionUnobservedBound: 50 + })) + await store.reconcileCells(cells) + for (const cell of cells) { + await store.recordCellHeartbeat({ + cellId: cell.id, + cellUrl: cell.url, + cellIncarnation: '11111111-1111-4111-8111-111111111111', + startedAt: NOW - 10, + ready: true, + observedRequests: 0, + totalConnections: 0, + inFlightConnections: 0, + reservedConnectionUnits: 0, + enforcedConnectionUnits: 0, + connectionHardCap: 600, + connectionUnobservedBound: 50 + }) + } + await database.query( + `INSERT INTO relay_assignments + (user_id, relay_host_id, cell_id, assignment_epoch, lease_expires_at, + last_activity_at, reserved_controls, reserved_splices, reserved_invites, + pending_installs, pending_confirmations, migration_leases) + VALUES (?, ?, 'cell-00', 1, ?, ?, 0, 0, 0, 0, 0, 0)`, + [identity.userId, identity.relayHostId, NOW + 90_000, NOW] + ) + await store.acquireActivity(identity, { + activityId: 'existing-splice', + kind: 'splice', + cellId: 'cell-00' + }) + rowCounts.length = 0 + return { store, rowCounts, database } + } + + it('reads one cell for a sticky assignment even when the capped fleet grows', async () => { + const { store, rowCounts } = await setup() + await expect(store.assign(identity)).resolves.toMatchObject({ + ...identity, + cellId: 'cell-00', + assignmentEpoch: 1 + }) + console.info('sticky headroom rows', { fleetCells: CELL_COUNT, rowCounts }) + expect(rowCounts).toEqual([1]) + }) + + it('still considers the full fleet for a new placement', async () => { + const { store, rowCounts } = await setup() + await expect( + store.assign({ userId: 'new-user', relayHostId: 'newheadroomhost1' }) + ).resolves.toMatchObject({ cellId: 'cell-01', assignmentEpoch: 1 }) + expect(rowCounts).toEqual([CELL_COUNT]) + }) + + it.each([ + ['missing snapshot', `DELETE FROM relay_cell_connection_snapshots WHERE cell_id = 'cell-00'`], + ['expired snapshot', `UPDATE relay_cell_connection_snapshots SET snapshot_at = ${NOW - 45_000} WHERE cell_id = 'cell-00'`], + ['old incarnation', `UPDATE relay_cell_connection_snapshots SET cell_incarnation = 'old' WHERE cell_id = 'cell-00'`], + ['capacity boundary', `UPDATE relay_cell_connection_snapshots SET enforced_connection_units = 450 WHERE cell_id = 'cell-00'`] + ])('rejects the pinned active host with %s', async (_name, sql) => { + const { store, database } = await setup() + await database.query(sql) + await expect(store.assign(identity)).rejects.toThrow('relay_connection_headroom_exhausted') + }) + + it('preserves admission for cells without a connection limit', async () => { + const { store, rowCounts, database } = await setup() + await database.query(`DELETE FROM relay_cell_connection_limits WHERE cell_id = 'cell-00'`) + await expect(store.assign(identity)).resolves.toMatchObject({ cellId: 'cell-00' }) + expect(rowCounts).toEqual([0]) + }) + + it('counts outstanding reservations at the admission boundary', async () => { + const { store, database } = await setup() + await database.query( + `UPDATE relay_cell_connection_snapshots SET enforced_connection_units = 449 + WHERE cell_id = 'cell-00'` + ) + await database.query( + `INSERT INTO relay_control_connection_reservations + (reservation_id, idempotency_key, user_id, relay_host_id, assignment_epoch, + cell_id, state, created_at, timeout_at, updated_at) + VALUES ('pending', 'pending', 'other-user', 'other-host', 1, + 'cell-00', 'reserved', ?, ?, ?)`, + [NOW, NOW + 90_000, NOW] + ) + await expect(store.assign(identity)).rejects.toThrow('relay_connection_headroom_exhausted') + }) +}) diff --git a/cloud/apps/relay/src/assignment-store.ts b/cloud/apps/relay/src/assignment-store.ts index 3cb5dcfeee9..9a5cd9d4165 100644 --- a/cloud/apps/relay/src/assignment-store.ts +++ b/cloud/apps/relay/src/assignment-store.ts @@ -7466,10 +7466,16 @@ export class RelayAssignmentStore { } private async connectionHeadroomByCell( - database: RelayDatabase + database: RelayDatabase, + cellId?: string ): Promise> { const now = this.now() - const rows = await database.query(ASSIGNMENT_CONNECTION_HEADROOM_QUERY) + const rows = await database.query( + cellId === undefined + ? ASSIGNMENT_CONNECTION_HEADROOM_QUERY + : `${ASSIGNMENT_CONNECTION_HEADROOM_QUERY} WHERE limits.cell_id = ?`, + cellId === undefined ? [] : [cellId] + ) return new Map( rows.map((row) => { const heartbeat = optionalInteger(row, 'last_heartbeat_at') @@ -7503,7 +7509,7 @@ export class RelayAssignmentStore { database: RelayDatabase, cellId: string ): Promise { - return (await this.connectionHeadroomByCell(database)).get(cellId) !== false + return (await this.connectionHeadroomByCell(database, cellId)).get(cellId) !== false } private async cellIsLive( @@ -8476,7 +8482,7 @@ function isDatabaseLockUnavailable(error: unknown): boolean { return error instanceof Error && error.message === 'database_lock_unavailable' } -export function cellInventoryLockOptions(mode: CellInventoryLockMode): RelayLockOptions { +function cellInventoryLockOptions(mode: CellInventoryLockMode): RelayLockOptions { if (mode === 'nowait') return { failIfUnavailable: true, measureHoldMs: true } if (mode === 'pool-default') return { measureHoldMs: true } return { lockTimeoutMs: CELL_INVENTORY_LOCK_TIMEOUT_MS, measureHoldMs: true } diff --git a/cloud/apps/relay/src/cell-inventory-lock-census.test.ts b/cloud/apps/relay/src/cell-inventory-lock-census.test.ts deleted file mode 100644 index e26d8d0551e..00000000000 --- a/cloud/apps/relay/src/cell-inventory-lock-census.test.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' -import { cellInventoryLockOptions, type CellInventoryLockMode } from './assignment-store.js' - -// Which entry points can reach a call site. A site a sweep can enter must never -// take the bounded wait: its 55P03 becomes a terminal transaction failure, and -// the incident monitor freezes on a single one. -type Reachability = 'request' | 'sweep' | 'both' | 'orphan' - -// 'caller' is not a CellInventoryLockMode: those sites take the mode threaded -// from `assign`, which is 'request' for a client and 'pool-default' for the -// evacuateDeadCells sweep. -type CensusMode = CellInventoryLockMode | 'caller' - -type CensusEntry = { method: string; mode: CensusMode; reach: Reachability } - -// Every lockCellInventory / lockGeneralCellInventory call site in -// assignment-store.ts, in source order. A new site fails this test until it is -// classified here, which is the point. -const CENSUS: CensusEntry[] = [ - // assignStickyOnce is gone from this list: its retry now locks only the row - // the host is pinned to (lockCellRows), which is what a sticky refresh - // touches. Placement below is the one genuinely fleet-wide decision left. - { method: 'assignOnce', mode: 'caller', reach: 'both' }, - { method: 'assignOnce', mode: 'caller', reach: 'both' }, - { method: 'assignOnce', mode: 'nowait', reach: 'both' }, - { method: 'assignOnce', mode: 'nowait', reach: 'both' }, - { method: 'assignOnce', mode: 'nowait', reach: 'both' }, - { method: 'refreshDrainMigrationLeasesOnce', mode: 'request', reach: 'request' }, - // changeActivity, acquireActivity, activateControl and - // removeSupersededSameCellControls no longer take the inventory: they lock - // only the one or two cell rows they touch, in cell_id order (lockCellRows), - // so they cannot cycle with placement's ordered inventory lock, and the - // 23-row lock there had serialised every reconnect in the fleet behind every - // other one. The control accept path went one step further and takes no cell - // read lock at all: its single conditional write is the last statement before - // COMMIT. - { method: 'startEvacuation', mode: 'request', reach: 'request' }, - { method: 'completeEvacuationFromDeadSourceOnce', mode: 'request', reach: 'request' }, - { method: 'completeEvacuationFromDeadSourceOnce', mode: 'nowait', reach: 'request' }, - { method: 'supersedeRegisteredEvacuationOnce', mode: 'request', reach: 'request' }, - { method: 'supersedeRegisteredEvacuationOnce', mode: 'nowait', reach: 'request' }, - { method: 'prepareRegisteredCellSupersession', mode: 'request', reach: 'request' }, - { method: 'prepareRegisteredCellSupersession', mode: 'request', reach: 'request' }, - { method: 'completeEvacuation', mode: 'nowait', reach: 'both' }, - { method: 'completeEvacuation', mode: 'pool-default', reach: 'both' }, - { method: 'rebalanceDormant', mode: 'request', reach: 'request' }, - // startRegionalRehomeCandidate is gone: the rehome commit reads the inventory - // unlocked and locks only its target row, NOWAIT, as the statement before - // COMMIT (reserveRegionalRehomeTargetRow below). - { method: 'completeRegionalRehomeCandidate', mode: 'nowait', reach: 'sweep' }, - // Both regional-rehome abort sweeps share this rollback; only the 24-hour - // one also disables the durable switch. - { method: 'rollBackStalledRegionalRehomes', mode: 'nowait', reach: 'sweep' }, - { method: 'abortExpiredEvacuations', mode: 'nowait', reach: 'sweep' }, - { method: 'abortExpiredEvacuations', mode: 'nowait', reach: 'sweep' }, - { method: 'releaseExpiredActivityLeases', mode: 'nowait', reach: 'sweep' }, - { method: 'releaseExpiredActivity', mode: 'nowait', reach: 'sweep' } - // reconcileReservationAccounting and leastLoadedCell are gone too: the first - // repairs exactly two cells' counters and now holds only those rows, and the - // second selects from the inventory its single caller has already locked. -] - -// Every inline `FROM relay_cells ... FOR UPDATE` outside the named lock helpers, -// in source order: whole-table locks in reconciliation and sticky placement, -// and single-row locks for a cell the method is already scoped to (heartbeat, -// fence, drain generation, configuration, or a reservation adjust that runs -// under a lock its caller already holds). A new inline lock fails the census -// below until it is listed here; per-connection paths that touch more than one -// cell go through lockCellRows so the order is fixed. -const NAMED_LOCK_HELPERS = ['lockCellInventory', 'lockGeneralCellInventory', 'lockCellRows'] - -const INLINE_CELL_LOCK_SITES = [ - 'reconcileCellsWithOptions', - 'assignStickyOnce', - 'recordCellHeartbeat', - 'attestCellFence', - 'adoptLegacyCellFence', - 'commitLegacyCellFenceAdoption', - 'prepareCellFenceAttempt', - 'attestCellFenceAttempt', - 'attestCellFenceAttempt', - 'configureCell', - 'reserveRegionalRehomeTargetRow', - 'assertDrainCellGeneration', - 'adjustCellReservation' -] - -// The background sweeps, and nothing else. A method reachable from one of these -// can be entered by a sweep tick, whatever else can also enter it. Both lists are -// read from source, so a new sweep step or a new route widens the derivation here -// instead of silently widening what a bounded wait can be entered from. -const SWEEP_ENTRY_FILES = ['./assignment-cleanup-steps.ts', './regional-rehome-worker.ts'] -const REQUEST_ENTRY_FILES = [ - './app.ts', - './relay-server.ts', - './host-session-registry.ts', - './cell-admission-startup.ts' -] - -const DECLARATION = /^ {2}(?:private |public )?(?:static )?(?:async )?([A-Za-z_][\w]*)[(<]/ - -function storeSource(): string[] { - return readFileSync(new URL('./assignment-store.ts', import.meta.url), 'utf8').split('\n') -} - -function entryPoints(files: string[]): string[] { - return files.flatMap((file) => - [ - ...readFileSync(new URL(file, import.meta.url), 'utf8').matchAll( - /assignments\.([A-Za-z_][\w]*)\(/g - ) - ].map((call) => call[1]!) - ) -} - -// Same-class call graph: store methods only ever reach each other through `this.`. -function storeCallGraph(lines: string[]): Map> { - const bounds: { name: string; start: number }[] = [] - lines.forEach((line, index) => { - const declaration = DECLARATION.exec(line) - if (declaration) bounds.push({ name: declaration[1]!, start: index }) - }) - const callees = new Map>() - bounds.forEach((method, index) => { - const end = bounds[index + 1]?.start ?? lines.length - const names = callees.get(method.name) ?? new Set() - for (const call of lines - .slice(method.start, end) - .join('\n') - .matchAll(/this\.([A-Za-z_][\w]*)\s*\(/g)) { - names.add(call[1]!) - } - callees.set(method.name, names) - }) - return callees -} - -function closure(callees: Map>, roots: string[]): Set { - const reached = new Set() - const pending = [...roots] - while (pending.length > 0) { - const name = pending.pop()! - if (reached.has(name)) continue - reached.add(name) - for (const callee of callees.get(name) ?? []) if (!reached.has(callee)) pending.push(callee) - } - return reached -} - -// Why: a hand-written reachability column is a claim, not a check. Derive both -// directions, so a new sweep edge into a bounded site fails here instead of in -// production, and so 'sweep' and 'both' stop being asserted by hand. -function derivedReachability(lines: string[]): (method: string) => Reachability { - const callees = storeCallGraph(lines) - const sweep = closure(callees, entryPoints(SWEEP_ENTRY_FILES)) - const request = closure(callees, entryPoints(REQUEST_ENTRY_FILES)) - return (method) => - sweep.has(method) - ? request.has(method) - ? 'both' - : 'sweep' - : request.has(method) - ? 'request' - : 'orphan' -} - -function readCallSites(): { method: string; mode: CensusMode }[] { - const sites: { method: string; mode: CensusMode }[] = [] - let method = '' - for (const line of storeSource()) { - const declaration = DECLARATION.exec(line) - if (declaration) method = declaration[1]! - if (/private async lock(General)?CellInventory\(/.test(line)) continue - const call = /lock(?:General)?CellInventory\(\s*\w+\s*,\s*(?:'([a-z-]+)'|(\w+))\s*\)/.exec(line) - if (!call) continue - sites.push({ method, mode: (call[1] ?? 'caller') as CensusMode }) - } - return sites -} - - -// Tier 3 and tier 4 of the row lock order documented in assignment-store.ts. A -// transaction that takes relay_cells before this host's reservation rows can -// cycle with one that takes them the other way round, and PostgreSQL resolves -// that as a 40P01 during exactly the drain and rehome waves these paths exist -// to run. The cell row is the one every host on a cell shares, so it is the -// lock that must be taken last, which fixes the direction for everyone else. -const CELL_LOCK_CALL = - /this\.(?:lockCellInventory|lockGeneralCellInventory|lockCellRows|adjustCellReservationAtomically|adjustCellReservation)\(|UPDATE relay_cells/ -const RESERVATION_LOCK_CALL = - /this\.(?:lockControlConnectionReservations|insertControlConnectionReservation|claimControlConnectionReservation|releaseSupersededControlConnectionReservations)\(|(?:UPDATE|INTO|DELETE FROM)\s+relay_control_connection_reservations/ - -// The lock helpers themselves, plus the one reporting query that reads both -// tables without locking either. -const ROW_LOCK_ORDER_EXEMPT = [ - 'lockCellInventory', - 'lockGeneralCellInventory', - 'lockCellRows', - 'lockControlConnectionReservations', - 'adjustCellReservation', - 'adjustCellReservationAtomically', - 'insertControlConnectionReservation', - 'claimControlConnectionReservation', - 'releaseSupersededControlConnectionReservations', - 'cellDeploymentStatus' -] - -function methodSpans(lines: string[]): { name: string; start: number; end: number }[] { - const starts: { name: string; start: number }[] = [] - lines.forEach((line, index) => { - const declaration = DECLARATION.exec(line) - if (declaration) starts.push({ name: declaration[1]!, start: index }) - }) - return starts.map((entry, index) => ({ - ...entry, - end: starts[index + 1]?.start ?? lines.length - })) -} - -function pathsTakingCellsBeforeReservations(lines: string[]): string[] { - const offending: string[] = [] - for (const span of methodSpans(lines)) { - if (ROW_LOCK_ORDER_EXEMPT.includes(span.name)) continue - let cell = Number.POSITIVE_INFINITY - let reservation = Number.POSITIVE_INFINITY - for (let index = span.start; index < span.end; index++) { - const line = lines[index]! - if (CELL_LOCK_CALL.test(line)) cell = Math.min(cell, index) - if (RESERVATION_LOCK_CALL.test(line)) reservation = Math.min(reservation, index) - } - if (cell < reservation && reservation !== Number.POSITIVE_INFINITY) { - offending.push(span.name) - } - } - return offending -} - -describe('cell inventory lock call-site census', () => { - it('classifies every call site exactly as recorded', () => { - expect(readCallSites()).toEqual(CENSUS.map(({ method, mode }) => ({ method, mode }))) - }) - - // Why: the census only sees lockCellInventory calls, so a hand-written - // `relay_cells ... FOR UPDATE` would escape classification entirely. - it('routes every relay_cells row lock through a named lock helper', () => { - const lines = storeSource() - const rawSites: string[] = [] - // Whole statements, not a fixed window: a wide column list or a raw - // FOR UPDATE inside query() must not slip past. - const source = lines.join('\n') - const bounds: { name: string; start: number }[] = [] - lines.forEach((line, index) => { - const declaration = DECLARATION.exec(line) - if (declaration) bounds.push({ name: declaration[1]!, start: index }) - }) - const methodAt = (offset: number): string => { - const lineIndex = source.slice(0, offset).split('\n').length - 1 - let name = '' - for (const bound of bounds) if (bound.start <= lineIndex) name = bound.name - return name - } - const tick = String.fromCharCode(96) - const statementCall = new RegExp( - '\\.(queryLocked|query)\\(\\s*' + tick + '([^' + tick + ']*)' + tick, - 'g' - ) - for (const call of source.matchAll(statementCall)) { - const statement = call[2]! - if (!/\bFROM\s+relay_cells\b/.test(statement)) continue - const locks = call[1] === 'queryLocked' || /\bFOR\s+UPDATE\b/.test(statement) - if (!locks) continue - const method = methodAt(call.index) - if (NAMED_LOCK_HELPERS.includes(method)) continue - rawSites.push(method) - } - expect(rawSites).toEqual(INLINE_CELL_LOCK_SITES) - }) - - it('takes the host reservation rows before the shared cell row everywhere', () => { - expect(pathsTakingCellsBeforeReservations(storeSource())).toEqual([]) - }) - - it('leaves no call site taking the inventory without naming a mode', () => { - const source = readFileSync(new URL('./assignment-store.ts', import.meta.url), 'utf8') - const unclassified = source - .split('\n') - .filter((line) => /lock(?:General)?CellInventory\(\s*\w+\s*\)/.test(line)) - .filter((line) => !line.includes('private async')) - - expect(unclassified).toEqual([]) - }) - - it('derives the same reachability the census claims', () => { - const reachOf = derivedReachability(storeSource()) - - expect(readCallSites().map(({ method }) => reachOf(method))).toEqual( - CENSUS.map((entry) => entry.reach) - ) - }) - - // Why: this is the whole point of the classification. A shorter wait on a - // sweep-reachable site turns contention into a terminal transaction failure - // that counts against the incident gate's relayPostgresRetryExhausted bar. - // Why: the hold distribution is what the 500ms bound will be tuned against, so - // a mode that stops asking for it goes unmeasured in exactly the lane that - // matters. Nothing else in the suite reads the pool-default branch. - it('measures the hold in every lock mode', () => { - const modes: CellInventoryLockMode[] = ['request', 'nowait', 'pool-default'] - - expect(modes.map((mode) => cellInventoryLockOptions(mode).measureHoldMs)).toEqual([ - true, - true, - true - ]) - }) - - it('never puts a sweep-reachable site on the bounded wait', () => { - const reachOf = derivedReachability(storeSource()) - const bounded = readCallSites().filter( - (site) => site.mode === 'request' && ['sweep', 'both'].includes(reachOf(site.method)) - ) - - expect(bounded).toEqual([]) - }) - - it('routes every sweep-only site to NOWAIT so it can skip the tick', () => { - const reachOf = derivedReachability(storeSource()) - const queueing = readCallSites().filter( - (site) => reachOf(site.method) === 'sweep' && site.mode !== 'nowait' - ) - - expect(queueing).toEqual([]) - }) -}) diff --git a/cloud/apps/relay/src/cell-inventory-lock-contention.test.ts b/cloud/apps/relay/src/cell-inventory-lock-contention.test.ts index 44a4b990ea8..1bf3153f3e4 100644 --- a/cloud/apps/relay/src/cell-inventory-lock-contention.test.ts +++ b/cloud/apps/relay/src/cell-inventory-lock-contention.test.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { afterEach, describe, expect, it, vi } from 'vitest' const fakes = vi.hoisted(() => ({ @@ -78,7 +77,6 @@ describe('bounded cell-inventory lock wait', () => { // Why: a bound at or above the pool default would fence nothing, and one far // below the hold time would convert ordinary contention into terminal failures. it('keeps the request bound strictly inside the pool default', () => { - expect(CELL_INVENTORY_LOCK_TIMEOUT_MS).toBe(500) expect(CELL_INVENTORY_LOCK_TIMEOUT_MS).toBeLessThan(POSTGRES_LOCK_TIMEOUT_MS) }) @@ -361,15 +359,6 @@ describe('bounded cell-inventory lock wait', () => { await database.close() }) - // Why: index.ts boots a server on import, so its wiring can only be read. An - // unspread hold metric is invisible: the flush simply omits the fields. - it('spreads the hold counts into the runtime metrics flush', () => { - const source = readFileSync(new URL('./index.ts', import.meta.url), 'utf8') - const flush = /observability\.start\(\(\) => \(\{([^}]*)\}\)\)/.exec(source) - - expect(flush?.[1]).toContain('...consumeRelayCellInventoryHold(database)') - }) - // Why: 500ms is a first value, not a measurement. Tuning it needs the hold // distribution, which no runtime metric carried. it('reports how long the inventory lock was held to COMMIT', async () => { @@ -492,25 +481,6 @@ describe('background sweeps skip a contended cell inventory', () => { expect(warnings.entries).toEqual([]) await database.close() }) - - it('still aborts the expired evacuation once the inventory is free', async () => { - const database = await openInMemoryRelayDatabase() - const probe = new InventoryLockProbe(database) - let now = 1_000 - const store = new RelayAssignmentStore(probe, () => now) - await store.reconcileCells(CELLS) - const assignment = await store.assign(identity) - await store.activateControl(identity, { - cellId: assignment.cellId, - assignmentEpoch: assignment.assignmentEpoch, - generation: 1 - }) - await store.startEvacuation(identity, 'cell-b') - now += 24 * 60 * 60_000 - - expect(await store.abortExpiredEvacuations()).toBe(1) - await database.close() - }) }) // Returns each inventory lock the run took, as its bound or 'nowait'. diff --git a/cloud/apps/relay/src/database.ts b/cloud/apps/relay/src/database.ts index db1203bc45c..6a8a1fc202a 100644 --- a/cloud/apps/relay/src/database.ts +++ b/cloud/apps/relay/src/database.ts @@ -852,16 +852,18 @@ class SqliteDatabase extends SqliteTransaction { let release!: () => void this.tail = new Promise((resolve) => (release = resolve)) await previous - this.database.exec('BEGIN IMMEDIATE') - const transaction = new SqliteTransaction(this.database) try { - const result = await operation(transaction) - this.database.exec('COMMIT') - recordMeasuredHold(this.holds, transaction) - return result - } catch (error) { - this.database.exec('ROLLBACK') - throw error + this.database.exec('BEGIN IMMEDIATE') + const transaction = new SqliteTransaction(this.database) + try { + const result = await operation(transaction) + this.database.exec('COMMIT') + recordMeasuredHold(this.holds, transaction) + return result + } catch (error) { + this.database.exec('ROLLBACK') + throw error + } } finally { release() } diff --git a/cloud/apps/relay/src/host-session-registry.test.ts b/cloud/apps/relay/src/host-session-registry.test.ts index 69697cce60e..fcf73695b40 100644 --- a/cloud/apps/relay/src/host-session-registry.test.ts +++ b/cloud/apps/relay/src/host-session-registry.test.ts @@ -535,6 +535,8 @@ describe('host session cleanup races', () => { const socket = new FakeSocket() const activation = activate(socket as unknown as WebSocket, identity, null, 1, false, 1) + await vi.advanceTimersByTimeAsync(0) + expect(activateControl).toHaveBeenCalledOnce() socket.close() blocked.resolve('control:production-gce-c3:1') await activation @@ -560,6 +562,8 @@ describe('host session cleanup races', () => { const rebindSocket = new FakeSocket() const rebinding = activate(rebindSocket as unknown as WebSocket, identity, original, 1, true, 1) + await vi.advanceTimersByTimeAsync(0) + expect(activateControl).toHaveBeenCalledTimes(2) rebindSocket.close() blocked.resolve('control:production-gce-c3:1') await rebinding @@ -574,6 +578,49 @@ describe('host session cleanup races', () => { ) }) + it('skips a closed queued control so its live retry avoids abandoned database work', async () => { + const stalled = deferred() + const activateControl = vi + .fn() + .mockReturnValueOnce(stalled.promise) + .mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 4_000)) + return 'control:production-gce-c3:1' + }) + const { registry, activate, releaseActivity } = createRegistry(activateControl) + const firstSocket = new FakeSocket() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeSocket implements the registry's WebSocket event and lifecycle surface. + const first = activate(firstSocket as unknown as WebSocket, identity, null, 1, false, 1) + await vi.advanceTimersByTimeAsync(0) + expect(activateControl).toHaveBeenCalledOnce() + const abandonedSocket = new FakeSocket() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeSocket implements the registry's WebSocket event and lifecycle surface. + const abandoned = activate(abandonedSocket as unknown as WebSocket, identity, null, 1, false, 1) + const liveSocket = new FakeSocket() + const startedAt = Date.now() + let liveCompletedAt: number | undefined + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeSocket implements the registry's WebSocket event and lifecycle surface. + const live = activate(liveSocket as unknown as WebSocket, identity, null, 1, false, 1) + .then(() => { liveCompletedAt = Date.now() }) + firstSocket.close() + abandonedSocket.close() + stalled.resolve('control:production-gce-c3:1') + await vi.advanceTimersByTimeAsync(8_000) + await Promise.all([first, abandoned, live]) + + console.log(JSON.stringify({ + scenario: 'closed queued control before a live retry', + activationCalls: activateControl.mock.calls.length, + activityReleases: releaseActivity.mock.calls.length, + liveReadyMs: liveCompletedAt === undefined ? null : liveCompletedAt - startedAt + })) + expect(activateControl).toHaveBeenCalledTimes(2) + expect(releaseActivity).toHaveBeenCalledOnce() + expect(liveCompletedAt! - startedAt).toBe(4_000) + expect(registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })?.socket) + .toBe(liveSocket) + }) + it('rejects client lookup when the indexed control socket is not open', async () => { const reservation = { userId: identity.sub, diff --git a/cloud/apps/relay/src/host-session-registry.ts b/cloud/apps/relay/src/host-session-registry.ts index 380ccfbc9c6..4e3372ce692 100644 --- a/cloud/apps/relay/src/host-session-registry.ts +++ b/cloud/apps/relay/src/host-session-registry.ts @@ -1103,7 +1103,7 @@ export class HostSessionRegistry { .catch(() => undefined) .then(async () => { clearTimeout(queueWaitTimer) - if (queueWaitExpired) return + if (queueWaitExpired || socket.readyState !== socket.OPEN) return if ((this.sessions.get(key) ?? null) !== existing) { socket.close(RELAY_CLOSE_CODE.PEER_DROPPED, 'control activation superseded') return diff --git a/cloud/apps/relay/src/relay-first-frame-close.blackbox.test.ts b/cloud/apps/relay/src/relay-first-frame-close.blackbox.test.ts new file mode 100644 index 00000000000..8a3d797cdfb --- /dev/null +++ b/cloud/apps/relay/src/relay-first-frame-close.blackbox.test.ts @@ -0,0 +1,272 @@ +import { connect, type Socket } from 'node:net' +import { afterEach, expect, it, vi } from 'vitest' +import { RELAY_CLOSE_CODE, RELAY_PROTOCOL_LIMITS } from '@orca-cloud/relay-contract' +import { loadRelayConfig } from './config.js' +import type { RelayDatabase } from './database.js' +import { createRelayServer } from './relay-server.js' + +const cleanups: (() => Promise | void)[] = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup() + vi.restoreAllMocks() +}) + +const IDLE_LEDGER = { + physicalConnections: 0, + inFlightConnections: 0, + reservedConnectionUnits: 0, + enforcedConnectionUnits: 0 +} + +async function fixture( + options: { role?: 'cell' | 'director'; hardCap?: number } = {} +) { + const role = options.role ?? 'cell' + const database: RelayDatabase = { + query: vi.fn(async () => []), + queryLocked: vi.fn(async () => []), + transaction: (operation) => operation(database), + close: async () => {} + } + const config = loadRelayConfig({ + ORCA_RELAY_PUBLIC_URL: 'http://127.0.0.1', + ORCA_RELAY_CELL_URL: 'http://127.0.0.1', + ORCA_RELAY_AUTH_ISSUER: 'https://auth.example.test', + ORCA_RELAY_JWKS_URL: 'https://auth.example.test/jwks', + ORCA_RELAY_ASSIGNMENT_SIGNING_KEY: 'synthetic-assignment-key-for-test-only', + ORCA_RELAY_ROLE: role, + ORCA_RELAY_ADMIN_AUDIENCE: 'https://auth.example.test/admin', + ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT: 'deploy@example.test', + ORCA_RELAY_CELL_CONNECTION_HARD_CAP: '600', + ORCA_RELAY_CELL_CONNECTION_UNOBSERVED_BOUND: '60', + ...(role === 'director' + ? { + ORCA_RELAY_CELLS_JSON: JSON.stringify([ + { id: 'cell-1', url: 'https://cell-1.example.test', capacityRequests: 900 } + ]) + } + : {}) + }) + const relay = createRelayServer(config, database, { + connectionLedgerLimits: { hardCap: options.hardCap ?? 3, controlReserve: 1 } + }) + await new Promise((resolve) => relay.server.listen(0, '127.0.0.1', resolve)) + cleanups.push(() => new Promise((resolve) => relay.server.close(() => resolve()))) + const address = relay.server.address() + if (!address || typeof address === 'string') throw new Error('missing test port') + return { relay, port: address.port, database } +} + +type RawPeer = { + socket: Socket + received: () => Buffer + transport: () => { ended: boolean; error: string | null } +} + +async function silentUpgrade(port: number, target: string): Promise { + const socket = connect(port, '127.0.0.1') + cleanups.push(() => { + socket.destroy() + }) + await new Promise((resolve, reject) => { + let header = '' + socket.once('error', reject) + socket.once('connect', () => { + socket.write( + `GET ${target} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: Upgrade\r\n` + + 'Upgrade: websocket\r\nSec-WebSocket-Version: 13\r\n' + + // RFC 6455 example nonce, matching the existing raw-upgrade fixture. + 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n' + ) + }) + const readHeader = (chunk: Buffer): void => { + header += chunk.toString() + if (!header.includes('\r\n\r\n')) return + socket.off('data', readHeader) + if (header.startsWith('HTTP/1.1 101 ')) resolve() + else reject(new Error(header.split('\r\n')[0])) + } + socket.on('data', readHeader) + }) + socket.removeAllListeners('error') + // This raw peer reads frames without answering the server's close handshake. + const chunks: Buffer[] = [] + let ended = false + let error: string | null = null + socket.on('data', (chunk: Buffer) => chunks.push(chunk)) + socket.on('end', () => { + ended = true + }) + socket.on('error', (caught: Error) => { + error = caught.message + }) + return { + socket, + received: () => Buffer.concat(chunks), + transport: () => ({ ended, error }) + } +} + +// A 43-character base64url credential is the shortest value RelayAuthSchema accepts. +const WELL_FORMED_CREDENTIAL = 'abcdefghijklmnopqrstuvwxyzABCDEFGH012345678' + +function maskedTextFrame(payload: string): Buffer { + const body = Buffer.from(payload) + const mask = Buffer.from([1, 2, 3, 4]) + const masked = Buffer.from(body.map((byte, index) => byte ^ mask[index % 4]!)) + return Buffer.concat([Buffer.from([0x81, 0x80 | body.length]), mask, masked]) +} + +function relayAuthFrame(): Buffer { + return maskedTextFrame( + JSON.stringify({ + type: 'relay-auth', + v: 1, + mode: 'connect', + credential: WELL_FORMED_CREDENTIAL + }) + ) +} + +// The close frame is the last unmasked frame a rejected peer receives: 0x88, length, +// then a big-endian status code followed by the UTF-8 reason. +function readCloseFrame(received: Buffer): { code: number; reason: string } | null { + const start = received.lastIndexOf(0x88) + if (start < 0 || received.length < start + 4) return null + const length = received[start + 1]! + return { + code: received.readUInt16BE(start + 2), + reason: received.subarray(start + 4, start + 2 + length).toString('utf8') + } +} + +async function expectIdleLedger( + relay: Awaited>['relay'], + timeout: number +): Promise { + await vi.waitFor(() => expect(relay.connectionSnapshot()).toMatchObject(IDLE_LEDGER), { timeout }) +} + +const PHONE_TARGET = '/v1/connect/abcdefghijklmnop' + +it.each([ + { label: 'first-frame timeout', target: PHONE_TARGET, opcode: undefined }, + { label: 'binary first frame', target: PHONE_TARGET, opcode: 0x82 }, + { label: 'invalid phone auth', target: PHONE_TARGET, opcode: 0x81 }, + { label: 'invalid host-data auth', target: '/v1/host/data/connection-1', opcode: 0x81 } +])( + 'releases admission after $label even when the peer ignores close', + async ({ target, opcode }) => { + const { relay, port, database } = await fixture() + const peer = await silentUpgrade(port, target) + const firstFrameDeadline = opcode === undefined ? RELAY_PROTOCOL_LIMITS.firstFrameDeadlineMs : 0 + if (opcode !== undefined) peer.socket.write(Buffer.from([opcode, 0x80, 0, 0, 0, 0])) + await expectIdleLedger(relay, firstFrameDeadline + 2_000) + expect(relay.runtimeCounts().preAuthConnections).toBe(0) + expect(database.query).not.toHaveBeenCalled() + expect(database.queryLocked).not.toHaveBeenCalled() + await silentUpgrade(port, PHONE_TARGET) + expect(relay.connectionSnapshot()?.enforcedConnectionUnits).toBe(2) + } +) + +it('releases admission after a rejected director invite when the peer ignores close', async () => { + const { relay, port } = await fixture({ role: 'director' }) + const peer = await silentUpgrade(port, PHONE_TARGET) + peer.socket.write(relayAuthFrame()) + await expectIdleLedger(relay, 2_000) + expect(readCloseFrame(peer.received())).toEqual({ + code: RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, + reason: 'invalid invite' + }) + expect(relay.runtimeCounts().preAuthConnections).toBe(0) + await silentUpgrade(port, PHONE_TARGET) + expect(relay.connectionSnapshot()?.enforcedConnectionUnits).toBe(2) +}) + +it('releases admission after a director move redirect when the peer ignores close', async () => { + const { relay, port } = await fixture({ role: 'director' }) + const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } + vi.spyOn(relay.store, 'resolveInviteForMove').mockResolvedValue({ + userId: identity.userId, + relayDeviceId: 'device-1' + }) + vi.spyOn(relay.assignments, 'resolve').mockResolvedValue({ + ...identity, + cellId: 'cell-1', + cellUrl: 'https://cell-1.example.test', + assignmentEpoch: 1, + leaseExpiresAt: Date.now() + 60_000 + }) + const peer = await silentUpgrade(port, PHONE_TARGET) + peer.socket.write(relayAuthFrame()) + await expectIdleLedger(relay, 2_000) + expect(peer.received().toString('utf8')).toContain('"type":"relay-moved"') + expect(readCloseFrame(peer.received())).toEqual({ + code: RELAY_CLOSE_CODE.DRAINING, + reason: 'connect to assigned cell' + }) + await silentUpgrade(port, PHONE_TARGET) + expect(relay.connectionSnapshot()?.enforcedConnectionUnits).toBe(2) +}) + +// A peer that keeps draining its socket does get the rejection: the close frame is written +// before the force-close timer can fire, and TCP delivers those bytes ahead of the FIN. +// +// Scope, deliberately narrow: this peer reads every byte as it arrives, so the assertion below +// speaks only for a responsive peer. It is not evidence that delivery survives backpressure — +// `terminate()` destroys the socket a second later, and a frame still queued in the kernel or in +// `ws`'s own buffer goes unsent. Treat the rejection as best effort; the bound on the close is +// what the trade-off actually buys. +it('delivers the rejection code and a graceful FIN to a peer that keeps reading', async () => { + const { relay, port } = await fixture() + const peer = await silentUpgrade(port, PHONE_TARGET) + peer.socket.write(maskedTextFrame(JSON.stringify({ type: 'relay-auth', v: 1, mode: 'wrong' }))) + await vi.waitFor(() => expect(peer.transport().ended).toBe(true), { timeout: 3_000 }) + expect(peer.received().toString('utf8')).toContain('"code":4401') + expect(readCloseFrame(peer.received())).toEqual({ + code: RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, + reason: 'invalid relay auth' + }) + expect(peer.transport().error).toBeNull() + await expectIdleLedger(relay, 2_000) +}) + +const BINARY_FIRST_FRAME = Buffer.from([0x82, 0x80, 0, 0, 0, 0]) +// maxPreAuthPerSource caps how many unauthenticated sockets one source may hold at once. +const CONCURRENT_PEERS_PER_SOURCE = 4 + +it('returns capacity to its exact baseline after repeated bursts of rejections', async () => { + const { relay, port } = await fixture({ hardCap: 2 * CONCURRENT_PEERS_PER_SOURCE + 1 }) + for (let wave = 0; wave < 3; wave++) { + const peers = await Promise.all( + Array.from({ length: CONCURRENT_PEERS_PER_SOURCE }, () => + silentUpgrade(port, PHONE_TARGET) + ) + ) + expect(relay.connectionSnapshot()).toMatchObject({ + enforcedConnectionUnits: 2 * CONCURRENT_PEERS_PER_SOURCE + }) + for (const peer of peers) peer.socket.write(BINARY_FIRST_FRAME) + await expectIdleLedger(relay, 3_000) + expect(relay.runtimeCounts().preAuthConnections).toBe(0) + } +}) + +// A rejection racing the peer's own disconnect must release once, not twice: the ledger +// does not clamp at zero, so a double release shows up as a negative count here. +it('releases exactly once when a rejected peer disconnects at the same moment', async () => { + const { relay, port } = await fixture({ hardCap: 2 * CONCURRENT_PEERS_PER_SOURCE + 1 }) + const peers = await Promise.all( + Array.from({ length: CONCURRENT_PEERS_PER_SOURCE }, () => silentUpgrade(port, PHONE_TARGET)) + ) + for (const peer of peers) { + peer.socket.write(BINARY_FIRST_FRAME) + peer.socket.destroy() + } + await expectIdleLedger(relay, 3_000) + expect(relay.connectionSnapshot()).toMatchObject(IDLE_LEDGER) + expect(relay.runtimeCounts().preAuthConnections).toBe(0) + await silentUpgrade(port, PHONE_TARGET) + expect(relay.connectionSnapshot()?.enforcedConnectionUnits).toBe(2) +}) diff --git a/cloud/apps/relay/src/relay-readiness-coalescing.test.ts b/cloud/apps/relay/src/relay-readiness-coalescing.test.ts new file mode 100644 index 00000000000..2dc8ca0af22 --- /dev/null +++ b/cloud/apps/relay/src/relay-readiness-coalescing.test.ts @@ -0,0 +1,106 @@ +import { expect, it, vi } from 'vitest' +import type { RelayDatabase } from './database.js' +import { createRelayReadiness } from './relay-readiness.js' + +function database(query: RelayDatabase['query']): RelayDatabase { + return { + query, + queryLocked: query, + transaction: (operation) => operation(database(query)), + close: async () => {} + } +} + +function gate() { + let release!: () => void + const wait = new Promise((resolve) => (release = resolve)) + return { wait, release } +} + +it('shares both dependency probes and caches from completion for concurrent callers', async () => { + const sql = gate() + let now = 1_000 + const query = vi.fn(async () => { + await sql.wait + return [{ ready: 1 }] + }) + const fetchImpl = vi.fn(async () => new Response('{}')) + const observe = vi.fn() + const readiness = createRelayReadiness(database(query), 'https://jwks.example.test', { + fetch: fetchImpl, + now: () => now, + observe + }) + const checks = Array.from({ length: 100 }, () => readiness.check()) + try { + expect({ sql: query.mock.calls.length, jwks: fetchImpl.mock.calls.length }).toEqual({ + sql: 1, + jwks: 1 + }) + expect(observe).not.toHaveBeenCalled() + now = 5_000 + } finally { + sql.release() + } + expect(await Promise.all(checks)).toEqual(Array(100).fill(true)) + expect(observe).toHaveBeenCalledTimes(1) + now = 14_999 + expect(await readiness.check()).toBe(true) + expect(query).toHaveBeenCalledTimes(1) + now = 15_000 + expect(await Promise.all(Array.from({ length: 100 }, () => readiness.check()))).toEqual( + Array(100).fill(true) + ) + expect(query).toHaveBeenCalledTimes(2) + expect(fetchImpl).toHaveBeenCalledTimes(2) +}) + +it('shares failures, retains the failure cache, and retries after expiry', async () => { + let healthy = false + let now = 1_000 + const query = vi.fn(async () => { + if (!healthy) throw new Error('offline') + return [{ ready: 1 }] + }) + const fetchImpl = vi.fn(async () => new Response('{}', { status: healthy ? 200 : 503 })) + const observe = vi.fn() + const readiness = createRelayReadiness(database(query), 'https://jwks.example.test', { + fetch: fetchImpl, + now: () => now, + observe + }) + expect(await Promise.all(Array.from({ length: 100 }, () => readiness.check()))).toEqual( + Array(100).fill(false) + ) + expect(query).toHaveBeenCalledTimes(1) + expect(fetchImpl).toHaveBeenCalledTimes(1) + expect(observe).toHaveBeenCalledTimes(1) + expect(readiness.degradedDependencies()).toEqual([]) + healthy = true + now = 10_999 + expect(await readiness.check()).toBe(false) + expect(query).toHaveBeenCalledTimes(1) + now = 11_000 + expect(await Promise.all(Array.from({ length: 100 }, () => readiness.check()))).toEqual( + Array(100).fill(true) + ) + expect(query).toHaveBeenCalledTimes(2) + expect(fetchImpl).toHaveBeenCalledTimes(2) + expect(observe).toHaveBeenCalledTimes(2) +}) + +it('keeps separate readiness owners independent', async () => { + const query = vi.fn(async () => [{ ready: 1 }]) + const fetchImpl = vi.fn(async () => new Response('{}')) + const first = createRelayReadiness(database(query), 'https://one.example.test', { + fetch: fetchImpl + }) + const second = createRelayReadiness(database(query), 'https://two.example.test', { + fetch: fetchImpl + }) + expect(await Promise.all([first.check(), first.check(), second.check(), second.check()])).toEqual( + [true, true, true, true] + ) + expect(query).toHaveBeenCalledTimes(2) + expect(fetchImpl).toHaveBeenCalledTimes(2) +}) diff --git a/cloud/apps/relay/src/relay-readiness.ts b/cloud/apps/relay/src/relay-readiness.ts index 973a827e104..72d3cd5c471 100644 --- a/cloud/apps/relay/src/relay-readiness.ts +++ b/cloud/apps/relay/src/relay-readiness.ts @@ -131,6 +131,7 @@ export function createRelayReadiness( const settleSql = createDependencyGrace('sql', options.sqlGraceMs ?? RELAY_READINESS_SQL_GRACE_MS) let cachedAt = Number.NEGATIVE_INFINITY let cached = false + let pending: Promise | null = null let lastObservedReady: boolean | undefined let degraded: RelayReadinessDependency[] = [] @@ -153,8 +154,7 @@ export function createRelayReadiness( } } - const check = async (): Promise => { - if (now() - cachedAt < cacheMs) return cached + const probe = async (): Promise => { const startedAt = now() const [jwks, sql] = await Promise.all([timed(now, probeJwks), timed(now, probeSql)]) const completedAt = now() @@ -185,5 +185,13 @@ export function createRelayReadiness( return cached } + const check = async (): Promise => { + if (now() - cachedAt < cacheMs) return cached + pending ??= probe().finally(() => { + pending = null + }) + return pending + } + return { check, degradedDependencies: () => [...degraded] } } diff --git a/cloud/apps/relay/src/relay-server.ts b/cloud/apps/relay/src/relay-server.ts index e9a08398da9..b90c322d1c2 100644 --- a/cloud/apps/relay/src/relay-server.ts +++ b/cloud/apps/relay/src/relay-server.ts @@ -276,7 +276,7 @@ export function createRelayServer( finished = true authenticated(source) observability.recordAuth(false) - socket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'first frame timeout') + closeRelayWebSocket(socket, RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'first frame timeout') }, RELAY_PROTOCOL_LIMITS.firstFrameDeadlineMs) socket.once('message', (raw, binary) => { if (finished) return @@ -285,7 +285,11 @@ export function createRelayServer( authenticated(source) if (binary) { observability.recordAuth(false) - socket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'first frame must be text') + closeRelayWebSocket( + socket, + RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, + 'first frame must be text' + ) return } void callback(raw).catch((error: unknown) => { @@ -356,7 +360,11 @@ export function createRelayServer( webSocket.send( JSON.stringify({ type: 'relay-hello', ok: false, code: RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL }) ) - webSocket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'invalid relay auth') + closeRelayWebSocket( + webSocket, + RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, + 'invalid relay auth' + ) return } if (config.role === 'director') { @@ -376,7 +384,11 @@ export function createRelayServer( code: RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL }) ) - webSocket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'invalid invite') + closeRelayWebSocket( + webSocket, + RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, + 'invalid invite' + ) return } phoneAdmission?.hostData.release() @@ -389,7 +401,7 @@ export function createRelayServer( assignmentEpoch: assignment.assignmentEpoch }) ) - webSocket.close(RELAY_CLOSE_CODE.DRAINING, 'connect to assigned cell') + closeRelayWebSocket(webSocket, RELAY_CLOSE_CODE.DRAINING, 'connect to assigned cell') return } await sessions.acceptClient( @@ -442,7 +454,11 @@ export function createRelayServer( const auth = HostDataAuthSchema.safeParse(firstPayload(raw, 'host-data-auth')) if (!auth.success) { observability.recordAuth(false) - webSocket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'invalid host data auth') + closeRelayWebSocket( + webSocket, + RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, + 'invalid host data auth' + ) return } const accepted = await sessions.acceptHostData( @@ -566,8 +582,3 @@ export function createRelayServer( cellIncarnation } } - -export function closeWithDrain(socket: WebSocket, graceMs: number): void { - socket.send(JSON.stringify({ type: 'drain', graceMs, recovery: 'resolve-director' })) - socket.close(RELAY_CLOSE_CODE.DRAINING, 'resolve configured director') -} diff --git a/cloud/apps/relay/src/relay-sweep-schedule.test.ts b/cloud/apps/relay/src/relay-sweep-schedule.test.ts index 79f65a92582..1b72c3ab031 100644 --- a/cloud/apps/relay/src/relay-sweep-schedule.test.ts +++ b/cloud/apps/relay/src/relay-sweep-schedule.test.ts @@ -45,21 +45,6 @@ describe('sweep schedule jitter', () => { expect(timers).toEqual([6_600]) }) - // Why: index.ts boots a server on import, so its wiring can only be read. - it('jitters the director assignment cleanup tick', () => { - const source = readFileSync(new URL('./index.ts', import.meta.url), 'utf8') - const cleanup = /runAssignmentCleanup\(assignments\)\s*\},\s*([^\n]*?)\)\n/.exec(source) - - expect(cleanup?.[1]).toBe('jitteredSweepIntervalMs(30_000)') - }) - - it('jitters the credential cleanup tick', () => { - const source = readFileSync(new URL('./index.ts', import.meta.url), 'utf8') - const cleanup = /'\[orca-relay\] credential cleanup failed'\s*\),\s*([^\n]*?)\n/.exec(source) - - expect(cleanup?.[1]).toBe('jitteredSweepIntervalMs(30_000)') - }) - // A census, not a list of the timers that happen to be gated today: an ungated sweep runs in // every cell as well as the director, which multiplies one table scan by the fleet size. it('gates every periodic sweep in index.ts on the maintenance role', () => { diff --git a/cloud/apps/relay/src/relay-websocket-close.ts b/cloud/apps/relay/src/relay-websocket-close.ts index d4a7f61a3ee..4b0517c3e53 100644 --- a/cloud/apps/relay/src/relay-websocket-close.ts +++ b/cloud/apps/relay/src/relay-websocket-close.ts @@ -3,6 +3,14 @@ import type WebSocket from 'ws' const RELAY_WEBSOCKET_FORCE_CLOSE_MS = 1_000 const forceCloseTimers = new WeakMap>() +// Delivering the rejection is best effort, and deliberately so. The close frame carrying the +// code and reason is written before the timer can fire, so a peer reading normally gets its +// rejection ahead of the FIN — that much is asserted in relay-first-frame-close.blackbox.test.ts. +// It is not a delivery guarantee: `ws` writes the frame to the socket, and `terminate()` destroys +// the socket a second later, so under backpressure the frame (and any `relay-moved` message queued +// before it) can still be dropped unsent even though the peer never stopped reading. Bounding the +// close is what keeps a stalled peer from holding admission, and no finite grace makes delivery +// certain. Keep the close write ahead of any new wait added here. export function closeRelayWebSocket(socket: WebSocket, code: number, reason: string): void { if (socket.readyState === socket.CLOSED) return if (!forceCloseTimers.has(socket)) { diff --git a/cloud/apps/relay/src/sqlite-transaction-queue.test.ts b/cloud/apps/relay/src/sqlite-transaction-queue.test.ts new file mode 100644 index 00000000000..99fb311ae25 --- /dev/null +++ b/cloud/apps/relay/src/sqlite-transaction-queue.test.ts @@ -0,0 +1,101 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { setImmediate } from 'node:timers/promises' +import { expect, it, vi } from 'vitest' +import { openRelayDatabase } from './database.js' + +it('releases queued work and close after a SQLite transaction cannot acquire its lock', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'orca-relay-sqlite-queue-')) + let connection: DatabaseSync | undefined + const prepare = DatabaseSync.prototype.prepare + // Keep the native handle reachable for cleanup even if a queue regression strands close(). + const capture = vi + .spyOn(DatabaseSync.prototype, 'prepare') + .mockImplementation(function (this: DatabaseSync, sql) { + connection = this + return prepare.call(this, sql) + }) + const database = await openRelayDatabase({ dataDir }) + capture.mockRestore() + const blocker = new DatabaseSync(join(dataDir, 'orca-relay.sqlite')) + try { + await database.query('CREATE TABLE queue_progress (value INTEGER)') + await database.query('INSERT INTO queue_progress VALUES (0)') + blocker.exec('BEGIN IMMEDIATE') + const operation = vi.fn(async () => undefined) + await expect(database.transaction(operation)).rejects.toThrow('database is locked') + let lockFailures = 0 + const blocked = Array.from({ length: 5 }, () => + database.transaction(operation).catch(() => { + lockFailures += 1 + }) + ) + await setImmediate() + expect(lockFailures).toBe(5) + await Promise.all(blocked) + expect(operation).not.toHaveBeenCalled() + blocker.exec('ROLLBACK') + let completed = 0 + const pending = Array.from({ length: 100 }, () => + database.transaction(async (transaction) => { + await transaction.query('UPDATE queue_progress SET value = value + 1') + completed += 1 + }) + ) + for (const request of pending) void request.catch(() => undefined) + await setImmediate() + expect(completed).toBe(100) + await Promise.all(pending) + expect(await database.query('SELECT value FROM queue_progress')).toEqual([{ value: 100 }]) + let closed = false + const closing = database.close().then(() => { + closed = true + }) + await setImmediate() + expect(closed).toBe(true) + await closing + } finally { + capture.mockRestore() + blocker.close() + if (connection?.isOpen) connection.close() + rmSync(dataDir, { recursive: true, force: true }) + } +}) + +it('does not roll back a transaction when BEGIN failed before taking ownership', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'orca-relay-sqlite-owner-')) + let connection: DatabaseSync | undefined + const prepare = DatabaseSync.prototype.prepare + const capture = vi + .spyOn(DatabaseSync.prototype, 'prepare') + .mockImplementation(function (this: DatabaseSync, sql) { + connection = this + return prepare.call(this, sql) + }) + const database = await openRelayDatabase({ dataDir }) + capture.mockRestore() + try { + await database.query('CREATE TABLE queue_owner (value INTEGER)') + await database.query('BEGIN IMMEDIATE') + await database.query('INSERT INTO queue_owner VALUES (7)') + await expect(database.transaction(async () => undefined)).rejects.toThrow( + 'cannot start a transaction within a transaction' + ) + expect(connection?.isTransaction).toBe(true) + let rows: unknown + void database.query('SELECT value FROM queue_owner').then((result) => { + rows = result + }) + await setImmediate() + expect(rows).toEqual([{ value: 7 }]) + await database.query('ROLLBACK') + expect(await database.query('SELECT value FROM queue_owner')).toEqual([]) + await database.close() + } finally { + capture.mockRestore() + if (connection?.isOpen) connection.close() + rmSync(dataDir, { recursive: true, force: true }) + } +}) diff --git a/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs b/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs index 5588bcd27fc..ee30b6340fa 100644 --- a/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs +++ b/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs @@ -2,8 +2,7 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' import { parseProductionCapacityCellArguments, - prepareProductionCapacityCell, - PRODUCTION_CAPACITY_CELL_IDS + prepareProductionCapacityCell } from './prepare-relay-production-capacity-canary.mjs' const config = { @@ -63,24 +62,6 @@ function canaryFetch() { describe('production Relay capacity cell admission', () => { it('allows only the serving rollout cells', () => { - assert.deepEqual(PRODUCTION_CAPACITY_CELL_IDS, [ - 'production-gce-c7', - 'production-gce-c8', - 'production-gce-c9', - 'production-gce-c10', - 'production-gce-c13', - 'production-gce-c14', - 'production-gce-c15', - 'production-gce-c16', - 'production-gce-c19', - 'production-gce-c20', - 'production-gce-c21', - 'production-gce-c22', - 'production-gce-c23', - 'production-gce-c24', - 'production-gce-c25', - 'production-gce-c26' - ]) assert.deepEqual(parseProductionCapacityCellArguments([ '--director-origin', 'https://relay.onorca.dev', '--cell-origin', 'https://c7.relay.onorca.dev', diff --git a/cloud/dev/scripts/production-cloud-sql-rollout-lock.test.mjs b/cloud/dev/scripts/production-cloud-sql-rollout-lock.test.mjs index d79d4f91046..628b09176da 100644 --- a/cloud/dev/scripts/production-cloud-sql-rollout-lock.test.mjs +++ b/cloud/dev/scripts/production-cloud-sql-rollout-lock.test.mjs @@ -1,5 +1,4 @@ import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' import { test } from 'node:test' import { LEASED_WORKFLOWS, @@ -21,7 +20,6 @@ import { revisionMintingScripts, workflowFiles } from './cloud-sql-rollout-lock-census.mjs' -import { relayWorkflowFile } from './relay-repository.mjs' const expectedLease = { production: PRODUCTION_LEASE, staging: STAGING_LEASE, selectable: SELECTABLE_LEASE } const leasedFiles = Object.keys(LEASED_WORKFLOWS) @@ -182,29 +180,3 @@ test('census: no workflow rolls out against the shared instance outside the leas assert.ok(!(file in NOT_A_CLOUD_SQL_CANDIDATE), `${file} cannot be both leased and a non-candidate`) } }) - -// The API and auth deploy scripts share this contract but stay in the private repository. -const serviceCapScripts = ['dev/scripts/deploy-relay-blue-green.mjs'] - -test('budgets tagged Cloud Run candidates outside the service-wide instance cap', () => { - for (const file of serviceCapScripts) { - const script = readFileSync(new URL(`../../${file}`, import.meta.url), 'utf8') - assert.match(script, /'--no-traffic'/, file) - assert.match(script, /'--max'/, file) - } - const budget = readFileSync( - new URL('../../dev/scripts/relay-cloud-sql-connection-budget.mjs', import.meta.url), - 'utf8' - ) - assert.match(budget, /directly addressable tagged revisions outside service-level caps/) - assert.match( - budget, - /apiCandidate: retainedDirectorRollback \+ inputs\.apiInstances \* inputs\.apiPoolMax/ - ) - const director = readWorkflow(relayWorkflowFile('deploy-relay-production-director.yml')) - const capacity = readWorkflow(relayWorkflowFile('deploy-relay-production-capacity-job.yml')) - const asia = readWorkflow(relayWorkflowFile('operate-relay-asia-admission.yml')) - assert.match(director, /--max-instances "\$\{DIRECTOR_MAX_INSTANCES\}"/) - assert.match(capacity, /--max-instances 5/) - assert.match(asia, /--max-instances "\$\{DIRECTOR_MAX_INSTANCES\}"/) -}) diff --git a/cloud/dev/scripts/push-gateway-workflow.test.mjs b/cloud/dev/scripts/push-gateway-workflow.test.mjs index 3ee4fabe410..5a8349c1855 100644 --- a/cloud/dev/scripts/push-gateway-workflow.test.mjs +++ b/cloud/dev/scripts/push-gateway-workflow.test.mjs @@ -309,11 +309,6 @@ test('push credentials cannot assume the shared Relay deploy identity', () => { assert.doesNotMatch(terraform('push-gateway.tf'), /member\s*=\s*local\.relay_github_deploy_service_account_member/) }) -// A latest revision needs a successor even when validation is inert. -test('dedicated database admits three simultaneous revision pools', () => { - assert.match(terraform('push-gateway.tf'), /var\.push_max_instances \* var\.push_database_pool_max \* 3 <= 64/) -}) - test('push has only a dedicated database attachment and a narrowly scoped deployment lease', () => { const service = terraform('push-gateway.tf') const database = terraform('push-dedicated-database.tf') diff --git a/cloud/dev/scripts/push-validation-workflow.test.mjs b/cloud/dev/scripts/push-validation-workflow.test.mjs deleted file mode 100644 index e26769cfeb3..00000000000 --- a/cloud/dev/scripts/push-validation-workflow.test.mjs +++ /dev/null @@ -1,48 +0,0 @@ -import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' -import test from 'node:test' -import { readRelayWorkflow } from './relay-repository.mjs' - -const workflow = readRelayWorkflow('push-deploy.yml') -const position = (name) => { - const index = workflow.indexOf(`- name: ${name}`) - assert.notEqual(index, -1) - return index -} -const capability = position('Require image support for inert validation') -const deploy = position('Deploy the candidate revision with no traffic') -const activation = position('Retire inert validation and activate the verified image') -const shift = position('Shift all traffic to the verified candidate') - -test('the exact build digest must support validation before production boot', () => { - assert.match(workflow, /docker buildx build --push --platform linux\/amd64 --provenance=false --metadata-file/) - assert.match(workflow, /containerimage\.digest/) - assert.doesNotMatch(workflow, /gcloud artifacts docker images describe/) - assert.ok(capability < deploy) - const preflight = workflow.slice(capability, deploy) - assert.match(preflight, /docker run --rm --network none --entrypoint node "\$\{IMAGE\}"/) - assert.match(preflight, /loadPushConfig\(env\)\.mode !== "validation"/) - assert.match(preflight, /validation_mode_not_fail_closed/) -}) - -test('inert validation and credential checks precede deliberate activation of the same digest', () => { - assert.match(workflow.slice(deploy, activation), /--update-env-vars ORCA_PUSH_MODE=validation/) - assert.match(workflow.slice(deploy, activation), /\.mode == "validation"/) - assert.ok(position('Prove the runtime identity can reach FCM') < activation) - const active = workflow.slice(activation, shift) - assert.ok(active.indexOf('gcloud run deploy') < active.indexOf('gcloud run revisions delete')) - assert.match(active, /--image "\$\{IMAGE\}"/) - assert.match(active, /--remove-env-vars ORCA_PUSH_MODE/) - assert.match(active, /\.spec\.containers\[0\]\.image == \$image/) - assert.match(active, /\.spec\.serviceAccountName == \$account/) - assert.match(active, /\.mode == "active"/) - assert.ok(active.indexOf('ACTIVATION_ATTEMPTED=true') < active.indexOf('gcloud run deploy')) - assert.match(workflow, /deletion below must stop its workers/) -}) - -test('production startup connects read-only and gates all background work in validation', () => { - const entry = readFileSync(new URL('../../apps/push/src/index.ts', import.meta.url), 'utf8') - assert.match(entry, /readOnly: config\.mode === 'validation'/) - assert.match(entry, /startPushBackground\(config,/) - assert.doesNotMatch(entry, /worker\.start\(/) -}) diff --git a/cloud/dev/scripts/relay-asia-admission-workflow.test.mjs b/cloud/dev/scripts/relay-asia-admission-workflow.test.mjs deleted file mode 100644 index 8283d43ca44..00000000000 --- a/cloud/dev/scripts/relay-asia-admission-workflow.test.mjs +++ /dev/null @@ -1,375 +0,0 @@ -import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' -import { test } from 'node:test' -import { relayWorkflowUrl } from './relay-repository.mjs' - -const workflow = readFileSync( - relayWorkflowUrl('operate-relay-asia-admission.yml'), - 'utf8' -) -const iam = readFileSync(new URL('../../infra/terraform/relay-github-actions.tf', import.meta.url), 'utf8') -const stagingProof = readFileSync( - relayWorkflowUrl('prove-relay-asia-staging.yml'), - 'utf8' -) -const directorWorkflow = readFileSync( - relayWorkflowUrl('deploy-relay-production-director.yml'), - 'utf8' -) -const terraformReadme = readFileSync( - new URL('../../infra/terraform/README.md', import.meta.url), - 'utf8' -) -const proofIam = readFileSync( - new URL('../../infra/terraform/relay-asia-proof-iam.tf', import.meta.url), - 'utf8' -) -const relayTerraform = readFileSync( - new URL('../../infra/terraform/relay.tf', import.meta.url), - 'utf8' -) -const rolloutEvidence = readFileSync( - new URL('./relay-asia-rollout-evidence.mjs', import.meta.url), - 'utf8' -) -const admissionBudgets = readFileSync( - new URL('../../packages/relay-contract/src/admission-budgets.ts', import.meta.url), - 'utf8' -) - -test('offers the exact audited admission modes under the shared deployment lock', () => { - for (const mode of [ - 'inspect', 'initialize', 'verify', 'register', 'configure', 'promote', 'rollback' - ]) { - assert.match(workflow, new RegExp(`\\b${mode}\\b`)) - } - assert.match(workflow, /production-cloud-sql-rollout/) - assert.match(workflow, /relay-staging-mutation/) - assert.match(workflow, /selector-generation/) - assert.match(workflow, /selector-attempt-id/) -}) - -test('requires exact confirmations and uses the existing admin identity', () => { - assert.match(workflow, /INITIALIZE_ADMISSION_SELECTOR/) - assert.match(workflow, /REGISTER_ASIA_MIGRATION_ONLY/) - assert.match(workflow, /PROMOTE_ASIA_GENERAL/) - assert.match(workflow, /ROLLBACK_ASIA_MIGRATION_ONLY/) - assert.match(workflow, /CONFIGURE_ASIA_DIRECTOR/) - assert.match(workflow, /GCP_RELAY_DEPLOY_SERVICE_ACCOUNT/) - assert.match(workflow, /id_token_audience: \$\{\{ env\.DIRECTOR_ORIGIN \}\}\/v1\/admin\/drain/) - assert.match(iam, /"operate-relay-asia-admission\.yml"/) -}) - -test('discovers generation read-only and explicitly initializes only generation zero', () => { - assert.match(workflow, /leave empty only for inspect/) - assert.match(workflow, /test -z "\$\{EXPECTED_SELECTOR_GENERATION\}"/) - assert.match(workflow, /test "\$\{EXPECTED_SELECTOR_GENERATION\}" = 0/) - assert.match(workflow, /\^\(0\|\[1-9\]\[0-9\]\*\)\$/) - assert.match(workflow, /selector-membership-sha256/) - assert.match(workflow, /\^\[a-f0-9\]\{64\}\$/) - assert.match(workflow, /director-image-digest/) - assert.match(workflow, /\.spec\.containers\[0\]\.image == \$image/) -}) - -test('uploads one sanitized machine-readable admission result', () => { - assert.match(workflow, /sanitize-relay-asia-admission-result\.mjs/) - const upload = /- name: Upload sanitized admission result\n([\s\S]*?)(?=\n - name:)/ - .exec(workflow)?.[1] - assert.ok(upload) - assert.match( - upload, - /if: \$\{\{ inputs\.mode != 'configure' && steps\.admission-operation\.outcome == 'success' \}\}/ - ) - assert.match(upload, /uses: actions\/upload-artifact@v4/) - assert.match( - upload, - /relay-asia-admission-result-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/ - ) - assert.match(upload, /path: \$\{\{ runner\.temp \}\}\/relay-asia-admission-result\/result\.json/) - assert.match(upload, /if-no-files-found: error/) - assert.match(upload, /retention-days: 7/) - assert.ok( - workflow.indexOf('Upload sanitized admission result') > - workflow.indexOf('Upload immutable canary evidence') - ) -}) - -test('binds selector operations and director configuration to reviewed implementations', () => { - assert.match(workflow, /operate-relay-asia-admission\.mjs/) - assert.match(workflow, /prepare-relay-asia-director-cells\.mjs/) - assert.match(workflow, /deploy-relay-blue-green\.mjs/) - assert.match(workflow, /--prune-revisions false/) - assert.doesNotMatch(workflow, /gcloud secrets versions add/) - assert.match(workflow, /orca-cloud-relay-regional-placement-enabled/) - assert.match(workflow, /\.valueSource\.secretKeyRef/) - assert.match(workflow, /jq -er --arg secret "\$\{REGIONAL_PLACEMENT_SECRET\}"/) - assert.doesNotMatch(workflow, /jq -e --arg secret "\$\{REGIONAL_PLACEMENT_SECRET\}"/) - assert.doesNotMatch(workflow, /--regional-placement-enabled/) - assert.doesNotMatch(workflow, /"\$\{\{ inputs\./) - assert.doesNotMatch(workflow, /dns/i) -}) - -test('requires immutable staged evidence and a timed production canary before expansion', () => { - assert.match(workflow, /actions: read/) - assert.match(workflow, /actions\/download-artifact@v4/) - assert.match(workflow, /relay-asia-staging-\$\{EVIDENCE_RUN_ID\}-\$\{EVIDENCE_RUN_ATTEMPT\}/) - assert.match(workflow, /evidence_kind=staging/) - assert.match(workflow, /load-relay-controls\.mjs/) - assert.match(workflow, /--controls 1/) - assert.match(workflow, /--splices 1/) - assert.match(workflow, /--splice-hold-seconds 60/) - assert.match(workflow, /--relay-asia-load-principals 1/) - assert.match(workflow, /--duration-seconds 300/) - assert.match(workflow, /--required-lease-horizons 2/) - assert.match(workflow, /pnpm\/action-setup@v4/) - assert.match(workflow, /Install exact canary dependencies/) - assert.match(workflow, /pnpm install --frozen-lockfile/) - assert.match(workflow, /pnpm --filter @orca-cloud\/relay-contract build/) - assert.ok( - workflow.indexOf('Build the canary Relay contract') < - workflow.indexOf('Run a real five-minute canary control and splice') - ) - assert.match(workflow, /--load-report "\$\{RUNNER_TEMP\}\/relay-asia-canary-load\.json"/) - assert.match(workflow, /"production-gce-c28":"migration-only","production-gce-c29":"migration-only"/) - // C28/C29 promotion downloads C27's canary under exactly this name. - assert.match(workflow, /relay-asia-\$\{\{ steps\.inputs\.outputs\.canary_hostname \}\}-canary-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/) - assert.match(workflow, /echo "canary_hostname=\$\{canary_cell##\*-\}"/) - assert.match(workflow, /id: canary-evidence-upload/) - assert.match(workflow, /Return an unproven canary cell to migration-only/) - assert.match(workflow, /steps\.canary-evidence-upload\.outcome != 'success'/) - assert.match(workflow, /--mode recover-promotion[\s\S]*?--attempt-id "\$\{SELECTOR_ATTEMPT_ID\}"/) - assert.match(workflow, /--attempt-id "\$\{SELECTOR_ATTEMPT_ID\}-rollback"/) - assert.match(workflow, /evidence_kind=c27/) - assert.match(workflow, /orca_relay_runtime_metrics/) - assert.match(workflow, /relay-asia-rollout-evidence\.mjs create-canary \\\n\s+--cell-id "\$\{CANARY_CELL\}"/) - assert.match(workflow, /retention-days: 7/) - assert.match(workflow, /Require the exact director image before promotion/) - assert.match(workflow, /DIRECTOR_ORIGIN.*\/v1\/admin\/runtime-status/) - assert.match(workflow, /\.imageDigest.*IMAGE_DIGEST/) - const provenance = /- name: Verify evidence provenance and rollout binding before authentication\n([\s\S]*?)(?=\n - id: auth)/ - .exec(workflow)?.[1] - assert.ok(provenance) - assert.match(provenance, /\.head_sha \| select\(type == "string" and test\("\^\[a-f0-9\]\{40\}\$"\)\)/) - assert.match(provenance, /--commit-sha "\$\{evidence_commit_sha\}"/) - assert.doesNotMatch(provenance, /--commit-sha "\$\{GITHUB_SHA\}"/) -}) - -test('binds each production promotion wave to its exact evidence and canary', () => { - const cases = /case "\$\{TARGET_CELL_IDS\}" in\n([\s\S]*?)\n\s*esac/.exec(workflow)?.[1] - assert.ok(cases) - const waves = Object.fromEntries( - [...cases.matchAll(/^ {14}([a-z0-9,-]+)\)\n([\s\S]*?);;/gm)].map((match) => [match[1], { - evidence: /evidence_kind=([a-z0-9]+)/.exec(match[2])?.[1] ?? 'none', - canary: /canary_cell=([a-z0-9-]+)/.exec(match[2])?.[1] ?? 'none' - }]) - ) - assert.deepEqual(waves, { - 'production-gce-c27': { evidence: 'staging', canary: 'production-gce-c27' }, - 'production-gce-c28,production-gce-c29': { evidence: 'c27', canary: 'none' }, - 'production-gce-c30': { evidence: 'none', canary: 'production-gce-c30' } - }) - assert.match(cases, /\*\) echo "production promotion wave is not reviewed" >&2; exit 1 ;;/) - assert.match(workflow, /if test "\$\{evidence_kind\}" = none; then\n\s+test -z "\$\{EVIDENCE_RUN_ID\}"/) - assert.doesNotMatch(workflow, /inputs\.cell-ids == /) -}) - -test('runs the timed canary and its automatic rollback for C27 and C30 alike', () => { - const steps = workflow.split(/\n(?= - )/) - const named = (name) => steps.find((step) => step.includes(`name: ${name}`)) - for (const name of [ - 'Install exact canary dependencies', - 'Build the canary Relay contract', - 'Verify the canary cell state and start the timed canary', - 'Run a real five-minute canary control and splice', - 'Collect regional, Relay SQL, and Cloud SQL canary evidence', - 'Upload immutable canary evidence' - ]) { - assert.match(named(name), /if: \$\{\{ steps\.inputs\.outputs\.canary == 'true' \}\}/, name) - } - assert.match( - workflow, - /if: \$\{\{ inputs\.mode == 'configure' \|\| steps\.inputs\.outputs\.canary == 'true' \}\}/ - ) - const rollback = named('Return an unproven canary cell to migration-only') - assert.match( - rollback, - /if: \$\{\{ always\(\) && steps\.inputs\.outputs\.canary == 'true' && steps\.admission-operation\.outcome != 'skipped' && steps\.canary-evidence-upload\.outcome != 'success' \}\}/ - ) - assert.match(rollback, /CANARY_CELL: \$\{\{ steps\.inputs\.outputs\.canary_cell \}\}/) - assert.match(rollback, /--mode recover-promotion \\\n\s+--cell-ids "\$\{CANARY_CELL\}"/) - assert.match(rollback, /--mode rollback \\\n\s+--cell-ids "\$\{CANARY_CELL\}"/) - assert.match(rollback, /'\.states\[\$cell\]' <<< "\$\{result\}"\)" = migration-only/) - const start = named('Verify the canary cell state and start the timed canary') - assert.match(start, /production-gce-c30\)\n\s+verify_cells=production-gce-c30\n\s+expected_states='\{"production-gce-c30":"general"\}'/) - assert.match(start, /test "\$\(jq -cS '\.states' <<< "\$\{result\}"\)" = "\$\(jq -cS '\.' <<< "\$\{expected_states\}"\)"/) - assert.match(named('Run a real five-minute canary control and splice'), /--duration-seconds 300/) -}) - -test('creates staging evidence only after the bounded launch-path load and rollback', () => { - assert.match(stagingProof, /runs-on: \[self-hosted, linux, x64, relay-asia-east2-load\]/) - assert.doesNotMatch(stagingProof, /group: relay-asia-east2-load/) - assert.match(stagingProof, /pnpm\/action-setup@v4/) - assert.match(stagingProof, /pnpm install --frozen-lockfile/) - assert.match(stagingProof, /pnpm --filter @orca-cloud\/relay-contract build/) - assert.match(stagingProof, /run_phase launch 5 5/) - assert.doesNotMatch(stagingProof, /run_phase control|run_phase mixed/) - assert.match(stagingProof, /--aggregate-controls "\$\(\(controls \* 4\)\)"/) - assert.match(stagingProof, /--aggregate-splices "\$\(\(splices \* 4\)\)"/) - assert.match(stagingProof, /--required-lease-horizons 2/) - assert.match(stagingProof, /--splice-ramp-seconds 120/) - assert.match(stagingProof, /--max-generator-rss-growth-mib 512/) - assert.match(stagingProof, /--relay-asia-load-principals 32/) - assert.match(stagingProof, /ulimit -n/) - assert.match(stagingProof, /--region-behavior-probes 1/) - assert.match(stagingProof, /--capacity-cell-origin https:\/\/c4\.relay-staging\.onorca\.dev/) - assert.match(stagingProof, /--rebind-probes 2/) - assert.match(stagingProof, /--skip-rebind-overflow-check/) - assert.doesNotMatch(stagingProof, /--request-unit-invites|--regional-fallback-probes/) - assert.match(stagingProof, /--aggregate-reader-splices.*echo 5/) - assert.match(stagingProof, /--aggregate-reader-bytes.*echo 12582912/) - assert.match(stagingProof, /--phase-barrier-dir "\$\{proof_dir\}\/\$\{phase\}-barrier"/) - assert.match(stagingProof, /--duration-seconds 210/) - assert.match(stagingProof, /trap stop_shards EXIT/) - assert.match(stagingProof, /if ! wait "\$\{pid\}"; then failed=1; break; fi/) - assert.match(stagingProof, /connectionFailuresByReason/) - assert.match(stagingProof, /--launch-report "\$\{proof_dir\}\/launch\.json"/) - assert.match(stagingProof, /id-token: write/) - assert.match(stagingProof, /STAGING_GCP_RELAY_ASIA_PROOF_WORKLOAD_IDENTITY_PROVIDER/) - assert.match(stagingProof, /STAGING_GCP_RELAY_ASIA_PROOF_SERVICE_ACCOUNT/) - assert.doesNotMatch(stagingProof, /STAGING_GCP_DEPLOY_SERVICE_ACCOUNT/) - assert.doesNotMatch(stagingProof, /STAGING_RELAY_LOAD_ACCESS_TOKEN/) - assert.doesNotMatch(stagingProof, /secrets versions access|signing-key-file/) - assert.match(stagingProof, /relay-asia-rollout-evidence\.mjs create-staging/) - assert.match(stagingProof, /Require the exact staging director image before promotion/) - assert.match(stagingProof, /DIRECTOR_ORIGIN.*\/v1\/admin\/runtime-status/) - assert.match(stagingProof, /\.imageDigest.*IMAGE_DIGEST/) - assert.match(stagingProof, /Return staging C4 to migration-only/) - assert.match(stagingProof, /steps\.promote\.outcome != 'skipped'/) - assert.match(stagingProof, /--mode recover-promotion[\s\S]*?--attempt-id "\$\{PROMOTE_ATTEMPT_ID\}"/) - assert.match(stagingProof, /--mode rollback[\s\S]*?--expected-generation "\$\{promoted_generation\}"/) - assert.match(stagingProof, /if: \$\{\{ success\(\) \}\}/) - assert.match( - stagingProof, - /recover:\n if: \$\{\{ always\(\) && github\.ref == 'refs\/heads\/main' \}\}/ - ) - assert.match(stagingProof, /needs: prove/) - assert.match(stagingProof, /Recover staging C4 with a fresh identity/) - assert.equal((stagingProof.match(/google-github-actions\/auth@v2/g) ?? []).length, 2) - assert.equal((stagingProof.match(/--mode recover-promotion/g) ?? []).length, 2) - assert.equal((stagingProof.match(/--mode rollback/g) ?? []).length, 2) -}) - -test('keeps the private runner below its 64-port Cloud NAT allocation', () => { - const profile = /run_phase launch (\d+) (\d+)/.exec(stagingProof) - const controlsPerShard = Number(profile?.[1]) - const splicesPerShard = Number(profile?.[2]) - const rebindProbes = Number(/--rebind-probes (\d+)/.exec(stagingProof)?.[1]) - const runtimeStatusSockets = 1 - assert.ok( - controlsPerShard * 4 + splicesPerShard * 4 * 2 + rebindProbes + runtimeStatusSockets < 64 - ) -}) - -test('paces one-source staging upgrades below the Relay anti-abuse ceiling', () => { - const splicesPerShard = Number(/run_phase launch \d+ (\d+)/.exec(stagingProof)?.[1]) - const rebindProbes = Number(/--rebind-probes (\d+)/.exec(stagingProof)?.[1]) - const spliceRampMs = Number(/--splice-ramp-seconds (\d+)/.exec(stagingProof)?.[1]) * 1000 - const ceiling = Number( - /maxPreAuthAttemptsPerSourcePerMinute: (\d+)/.exec(admissionBudgets)?.[1] - ) - const totalSplices = splicesPerShard * 4 - const attempts = Array.from({ length: totalSplices }, (_, ordinal) => - Math.floor(ordinal * spliceRampMs / (totalSplices - 1)) - ).flatMap((startedAt) => [startedAt, startedAt]) - attempts.push(...Array.from({ length: 4 + rebindProbes }, () => 0)) - const busiestMinute = Math.max(...attempts.map((startedAt) => - attempts.filter((attempt) => attempt >= startedAt && attempt < startedAt + 60_000).length - )) - assert.ok(busiestMinute < ceiling) -}) - -test('reserves rollback time beyond the complete bounded staging proof envelope', () => { - const timeoutMinutes = Number(/timeout-minutes: (\d+)/.exec(stagingProof)?.[1]) - assert.equal(timeoutMinutes, 75) - const spliceRampSeconds = Number(/--splice-ramp-seconds (\d+)/.exec(stagingProof)?.[1]) - const launchSeconds = 180 + spliceRampSeconds + 210 + 60 - const setupEvidenceAndRollbackSeconds = 10 * 60 - const envelopeMinutes = Math.ceil((launchSeconds + setupEvidenceAndRollbackSeconds) / 60) - assert.ok(timeoutMinutes - envelopeMinutes >= 30) - assert.match(stagingProof, /--ramp-seconds 180/) - assert.match(stagingProof, /--duration-seconds 210/) -}) - -test('binds the staging proof to one least-privilege Google identity', () => { - assert.match( - proofIam, - /github_relay_asia_proof_workflow_file = "prove-relay-asia-staging\.yml"/ - ) - assert.match( - proofIam, - /assertion\.workflow_ref == '\$\{prefix\}\$\{local\.github_relay_asia_proof_workflow_file\}@refs\/heads\/main'/ - ) - assert.match(proofIam, /assertion\.environment == 'staging'/) - assert.match(proofIam, /assertion\.event_name == 'workflow_dispatch'/) - assert.match(proofIam, /roles\/logging\.viewer/) - assert.match(proofIam, /roles\/monitoring\.viewer/) - assert.match(rolloutEvidence, /readCloudSqlBackends/) - assert.match(rolloutEvidence, /cloudSql: await readCloudSqlBackends/) - assert.doesNotMatch(proofIam, /compute\.|cloudsql\.|secretmanager\.|roles\/editor|roles\/run\./) -}) - -test('keeps the production US-first switch in durable Secret Manager state', () => { - assert.match(directorWorkflow, /options: \[preserve, enable, disable\]/) - assert.match(directorWorkflow, /default: preserve/) - assert.match(directorWorkflow, /gcloud secrets versions add/) - assert.match(directorWorkflow, /preserve\) desired="\$\{current\}"/) - assert.match(directorWorkflow, /--regional-placement-secret-version "\$\{target_version\}"/) - assert.match(directorWorkflow, /test "\$\{CEILING\}" = "\$\{DIRECTOR_MAX_INSTANCES\}"/) - assert.match(directorWorkflow, /orca-cloud-relay-regional-placement-enabled/) - assert.match(directorWorkflow, /\.valueSource\.secretKeyRef \/\/ \.valueFrom\.secretKeyRef/) - assert.match(directorWorkflow, /\.version \/\/ \.key/) - assert.match(directorWorkflow, /\.secret \/\/ \.name/) - assert.match(workflow, /\.valueSource\.secretKeyRef \/\/ \.valueFrom\.secretKeyRef/) - assert.doesNotMatch(directorWorkflow, /--regional-placement-enabled/) - assert.doesNotMatch(workflow, /inputs\.regional-placement-enabled/) -}) - -test('prunes incompatible production revisions only when explicitly confirmed', () => { - assert.match( - directorWorkflow, - /prune-incompatible-revisions:[\s\S]*?default: false[\s\S]*?type: boolean/ - ) - assert.match(directorWorkflow, /PRUNE_INCOMPATIBLE_RELAY_DIRECTOR_REVISIONS/) - assert.match( - directorWorkflow, - /test "\$\{REGIONAL_PLACEMENT_MODE\}" = preserve[\s\S]*?test "\$\{CONFIRMATION\}" = PRUNE_INCOMPATIBLE_RELAY_DIRECTOR_REVISIONS/ - ) - assert.match( - directorWorkflow, - /--prune-revisions "\$\{PRUNE_INCOMPATIBLE_REVISIONS\}"/ - ) -}) - -test('documents the exact regional-placement secret bootstrap before director rollout', () => { - for (const address of [ - 'google_secret_manager_secret.relay_regional_placement_enabled', - 'google_secret_manager_secret_version.relay_regional_placement_enabled', - 'google_secret_manager_secret_iam_member.relay_regional_placement_runtime_accessor', - 'google_secret_manager_secret_iam_member.relay_regional_placement_deploy_accessor[0]', - 'google_secret_manager_secret_iam_member.relay_regional_placement_deploy_adder[0]', - 'google_secret_manager_secret_iam_member.relay_regional_placement_deploy_viewer[0]' - ]) { - assert.match(terraformReadme, new RegExp(address.replaceAll(/[.[\]]/g, '\\$&'))) - } - assert.match(terraformReadme, /Before the first director deployment/) - assert.match(terraformReadme, /Pass the exact environment tfvars/) - // The Cloudflare records left with the apps root; a -var for a variable this root no longer - // declares is a hard error, so no relay procedure may still tell an operator to pass it. - assert.doesNotMatch(terraformReadme, /manage_artifact_dns/) - assert.match(terraformReadme, /exactly these six additions/) - assert.match(terraformReadme, /version metadata/) - assert.match( - relayTerraform, - /resource "google_secret_manager_secret_iam_member" "relay_regional_placement_deploy_viewer"[\s\S]*?role\s+= "roles\/secretmanager\.viewer"/ - ) -}) diff --git a/cloud/dev/scripts/relay-load-control-peer.mjs b/cloud/dev/scripts/relay-load-control-peer.mjs index a075d6bf4e7..26d0ca6a69b 100644 --- a/cloud/dev/scripts/relay-load-control-peer.mjs +++ b/cloud/dev/scripts/relay-load-control-peer.mjs @@ -860,11 +860,12 @@ export class RelayLoadControlPeer { scheduleRefresh(delayMs) { if (this.stopped) return + const socket = this.socket + const reschedule = () => { + if (this.socket === socket) this.scheduleRefresh(this.phase.refreshIntervalMs) + } this.refreshTimer = setTimeout(() => { - void this.refresh().then( - () => this.scheduleRefresh(this.phase.refreshIntervalMs), - () => this.scheduleRefresh(this.phase.refreshIntervalMs) - ) + void this.refresh().then(reschedule, reschedule) }, delayMs) } diff --git a/cloud/dev/scripts/relay-load-control-peer.test.mjs b/cloud/dev/scripts/relay-load-control-peer.test.mjs index 95ebd89e1ef..08bb3291a11 100644 --- a/cloud/dev/scripts/relay-load-control-peer.test.mjs +++ b/cloud/dev/scripts/relay-load-control-peer.test.mjs @@ -901,3 +901,105 @@ test('shutdown closes both splice legs and waits for the in-flight splice', asyn assert.equal(observations.at(-1).type, 'shutdown') assert.equal(observations.at(-1).detail.activeSpliceSockets, 0) }) + +function trackRefreshTimers(context) { + const schedule = global.setTimeout + const cancel = global.clearTimeout + const timers = new Map() + context.mock.method(global, 'setTimeout', (callback, milliseconds, ...args) => { + const timer = schedule(() => { + timers.delete(timer) + callback(...args) + }, milliseconds) + timers.set(timer, milliseconds) + return timer + }) + context.mock.method(global, 'clearTimeout', (timer) => { + timers.delete(timer) + cancel(timer) + }) + context.after(() => { + for (const timer of timers.keys()) cancel(timer) + }) + return () => [...timers.values()].filter((milliseconds) => milliseconds >= 180_000).length +} + +function reconnectingPeer(context, observations) { + const peer = new RelayLoadControlPeer( + 0, + peerOptions({ + directorOrigin: undefined, + targetOrigin: 'https://cell.test', + accessToken: 'test-access-token' + }), + (type) => observations.push(type) + ) + peer.phase.refreshOffsetMs = 180_000 + peer.phase.refreshIntervalMs = 200_000 + peer.createSocket = () => { + const socket = fakeHandshakeSocket() + socket.send = (raw) => { + const message = JSON.parse(raw) + if (message.type === 'host-hello') { + queueMicrotask(() => socket.message(validChallenge(peer))) + } else if (message.type === 'host-challenge-ack') { + queueMicrotask(() => + socket.message({ + type: 'host-hello-ack', + generation: 1, + controlResumeSecret: 'test-secret' + }) + ) + } + } + return openOnNextTurn(socket) + } + context.after(() => peer.shutdown()) + return peer +} + +for (const outcome of ['success', 'failure']) { + for (const owner of ['current connection', 'replacement connection', 'shutdown']) { + test(`refresh ${outcome} respects its ${owner} ownership`, async (context) => { + const refreshTimers = trackRefreshTimers(context) + const observations = [] + const peer = reconnectingPeer(context, observations) + const pendingResponse = deferred() + const requestStarted = deferred() + let delayResponse = false + context.mock.method(global, 'fetch', async () => { + if (delayResponse) { + requestStarted.resolve() + return await pendingResponse.promise + } + return response({ relayToken: 'test-relay-token' }) + }) + await peer.connect() + assert.equal(refreshTimers(), 1) + clearTimeout(peer.refreshTimer) + delayResponse = true + peer.scheduleRefresh(0) + await requestStarted.promise + delayResponse = false + if (owner !== 'current connection') { + peer.socket.close(1006) + await new Promise((resolve) => setImmediate(resolve)) + await peer.connect() + assert.equal(refreshTimers(), 1) + } + const shutdown = owner === 'shutdown' ? peer.shutdown() : undefined + if (outcome === 'success') pendingResponse.resolve(response({ relayToken: 'fresh-token' })) + else pendingResponse.reject(new Error('token request failed')) + await new Promise((resolve) => setImmediate(resolve)) + const timersAfterCompletion = refreshTimers() + await (shutdown ?? peer.shutdown()) + + assert.equal(timersAfterCompletion, owner === 'shutdown' ? 0 : 1) + assert.equal(refreshTimers(), 0) + assert.equal( + observations.filter((type) => type === 'refresh').length, + owner === 'current connection' && outcome === 'success' ? 1 : 0 + ) + }) + } +} diff --git a/cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs b/cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs deleted file mode 100644 index aabf80a65f0..00000000000 --- a/cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs +++ /dev/null @@ -1,269 +0,0 @@ -import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' -import { test } from 'node:test' -import { fileURLToPath } from 'node:url' -import { relayWorkflowUrl } from './relay-repository.mjs' - -function workflow(name) { - return readFileSync( - fileURLToPath(relayWorkflowUrl(name)), - 'utf8' - ) -} - -test('same-cap wrapper is reusable, canary-bound, and sequential', () => { - const wrapper = workflow('deploy-relay-production-same-cap.yml') - const job = workflow('deploy-relay-production-same-cap-job.yml') - assert.match(wrapper, /options: \[verify, canary-apply, batch-apply, rollback\]/) - assert.match(wrapper, /relay-same-cap-canary-\$\{\{ inputs\.canary-run-id \}\}/) - assert.match(wrapper, /needs: \[gate, cell_1\]/) - assert.match(wrapper, /needs: \[gate, cell_2\]/) - assert.match(wrapper, /needs: \[gate, cell_3\]/) - assert.match(job, /on:\n workflow_call:/) - assert.match(job, /c27\|c28\|c29\|c30\)/) - assert.match(job, /EXPECTED_HARD_CAP=3000/) - assert.match(job, /EXPECTED_REGION=asia-east2/) - assert.match(job, /--hard-cap "\$\{EXPECTED_HARD_CAP\}"/) - assert.match(job, /--regional-rehome-protocol "\$\{DESIRED_REHOME_PROTOCOL\}"/) - assert.match(job, /--argjson protocol "\$\{PREDECESSOR_REHOME_PROTOCOL\}"/) - assert.match(job, /runtime predecessor mismatch fields=/) - // A rollback interrupted between apply and restore must be resumable. - assert.match(job, /ROLLBACK_RESUME=true/) - assert.match(job, /test "\$\{LIVE_IMAGE_DIGEST\}" = "\$\{DESIRED_IMAGE_DIGEST\}"/) - // Resume must skip BOTH the drain (no restart will clear the flag) and the - // apply (state already converged), and prove convergence instead. - assert.match( - job, - /Reversibly isolate and drain only the selected cell\n if: \$\{\{ inputs\.mode != 'verify' && env\.ROLLBACK_RESUME != 'true' \}\}/ - ) - assert.match( - job, - /Apply only the selected same-cap template and MIG\n if: \$\{\{ inputs\.mode != 'verify' && env\.ROLLBACK_RESUME != 'true' \}\}/ - ) - assert.match( - job, - /Require converged Terraform state and a stable MIG on resume\n if: \$\{\{ inputs\.mode != 'verify' && env\.ROLLBACK_RESUME == 'true' \}\}/ - ) - assert.match(job, /resume found unconverged resources/) - // A canary or batch cell that failed before its template apply also - // resumes here with template drift from repo changes since its last roll; - // only a plan the reviewed validator approves for the image the cell - // already serves may pass, and resume still applies nothing. - assert.match(job, /requiring reviewed rollback-image drift/) - assert.match( - job, - /--image "\$\{DESIRED_IMAGE\}" \\\n {16}--rollback-image "\$\{DESIRED_IMAGE\}"/ - ) - // The relaxation is only safe if the reviewed validator actually runs on - // the NON-converged branch, in same-cap-cell mode, with the trust config - // the validator requires, restricted to the template-and-MIG change pair. - assert.match( - job, - /if ! terraform -chdir=infra\/terraform show -json[\s\S]{0,220}\| length == 0' >\/dev\/null\n then\n/ - ) - assert.match( - job, - /requiring reviewed rollback-image drift'\n[\s\S]{0,400}?\n {16}--mode same-cap-cell --cell-id "\$\{TARGET_CELL_ID\}" \\\n/ - ) - assert.match( - job, - /Require converged Terraform state and a stable MIG on resume[\s\S]{0,300}CAPACITY_SERVICE_ACCOUNT: \$\{\{ vars\.PRODUCTION_GCP_RELAY_CAPACITY_SERVICE_ACCOUNT \}\}\n {10}DIRECTOR_RUNTIME_SERVICE_ACCOUNT: \$\{\{ vars\.PRODUCTION_GCP_RELAY_DIRECTOR_RUNTIME_SERVICE_ACCOUNT \}\}/ - ) - assert.match( - job, - /--rollback-image "\$\{DESIRED_IMAGE\}" \\\n {16}--capacity-service-account "\$\{CAPACITY_SERVICE_ACCOUNT\}" \\\n {16}--rehome-director-service-account "\$\{DIRECTOR_RUNTIME_SERVICE_ACCOUNT\}"/ - ) - assert.match( - job, - /host-drain \\\n {16}--regional-rehome-protocol "\$\{DESIRED_REHOME_PROTOCOL\}" \\\n {16}"\$\{POOL_ARGUMENTS\[@\]\}"\)"\n {12}echo "\$\{RESUME_REVIEW\}"\n {12}jq -e '\.changes == 2' <<< "\$\{RESUME_REVIEW\}" >\/dev\/null/ - ) - // A resume applies nothing at all, which is what a resume means: the only accepted - // unconverged plan is the template-and-MIG rollback-image drift, and it is left pending. - const resumeStep = job.slice( - job.indexOf('- name: Require converged Terraform state and a stable MIG on resume'), - job.indexOf('- name: Apply only the selected same-cap template and MIG') - ) - assert.equal(resumeStep.split('terraform -chdir=infra/terraform apply').length, 1) - assert.match(job, /resume requires the isolated migration-only cell/) - assert.match(job, /test "\$\{TARGET_INCARNATION\}" = "\$\{SOURCE_INCARNATION\}"/) - assert.match(job, /\(.regionalRehomeProtocol \/\/ 0\) == \$protocol/) - assert.match(job, /\(\.draining == false or \$drainingOk\)/) - // Selector expectations must follow the mutations' returned generations, - // not fixed offsets: isolate is a no-op on a cell a failed canary already - // isolated, and the restore inspect must expect post-restore membership. - assert.match(job, /SELECTOR_GENERATION_AFTER_ISOLATE=\$\{EFFECTIVE_SELECTOR_GENERATION\}/) - assert.match(job, /SELECTOR_GENERATION_AFTER_ISOLATE=\$\{ISOLATE_GENERATION\}/) - assert.match(job, /--expected-selector-generation "\$\{SELECTOR_GENERATION_AFTER_ISOLATE\}"/) - assert.match(job, /--expected-selector-generation "\$\{SELECTOR_GENERATION_AFTER_RESTORE\}"/) - assert.match(job, /--expected-migration-only-cells "\$\{RESTORED_MIGRATION_CELLS\}"/) - assert.match(job, /--expected-general-cells "\$\{RESTORED_GENERAL_CELLS\}"/) - assert.match(job, /FAILSAFE_GENERATION/) - // Later batch waves start after ~16-min predecessor rolls, so BOTH evidence - // age checks must scale by wave or cell_2+ can never pass; the bound's - // per-wave step is the cell job timeout, so the two must move together. - assert.match(job, /--required-migration-policy strict \\\n --wave-index "\$\{WAVE_INDEX\}"/) - // Wave 0 must retry freshness-only failures too: one Cloud Monitoring publish - // lag at the sample instant is not health evidence, and single-shot wave 0 - // failed a whole batch on a series that was fresh again a minute later. - assert.match( - job, - /dry-run\.state\.json" \\\n {14}--wave-index "\$\{WAVE_INDEX\}" \\\n {14}--selector-wave-delta "\$\{SELECTOR_WAVE_DELTA\}" --retry-freshness/ - ) - assert.doesNotMatch(job, /RETRY_ARGS/) - // Break-glass: the override skips the aggregate 15-minute monitor evidence and - // nothing else. The live per-wave recheck still runs on the override path, off - // the dispatch inputs the rehome inspect below verifies against the director. - assert.match( - job, - /if test -n "\$\{GATE_OVERRIDE_CONFIRMATION\}"; then[\s\S]{0,700}?--no-monitor-state \\\n {14}--expected-selector-generation "\$\{EXPECTED_SELECTOR_GENERATION\}" \\\n {14}--selector-membership-file[\s\S]{0,160}?--wave-index "\$\{WAVE_INDEX\}" \\\n {14}--selector-wave-delta "\$\{SELECTOR_WAVE_DELTA\}" --retry-freshness/ - ) - // The override is re-validated here, not trusted from the caller, and it is - // bound to the digest this wave installs. - assert.match( - job, - /test "\$\{GATE_OVERRIDE_CONFIRMATION\}" = \\\n {14}"SKIP_RELAY_MONITOR_GATE \$\{TARGET_IMAGE_DIGEST\}"/ - ) - assert.match(job, /\[\[ "\$\{GATE_OVERRIDE_REASON\}" =~ \^\[\[:print:\]\]\{12,500\}\$ \]\]/) - // Exactly the aggregate-evidence steps are skipped, and only them: every step - // that reads or spends the sealed monitor artifact carries the override guard. - const overrideSkipped = [ - 'Require fresh aggregate monitor evidence reference', - 'Download private aggregate monitor evidence', - 'Verify monitor evidence provenance', - "Download this wave's single-use safety authority", - 'Require safety evidence consumed by this workflow' - ] - for (const name of overrideSkipped) { - assert.match( - job, - new RegExp(`- name: ${name}\\n {8}if: \\$\\{\\{ inputs\\.mode != 'verify' && inputs\\.gate-override-confirmation == '' \\}\\}`) - ) - } - assert.equal( - job.match(/inputs\.gate-override-confirmation == ''/g).length, - overrideSkipped.length - ) - // The wrapper validates the override before anything runs, passes it to every - // cell, seals it into the canary artifact, and prints it in the run summary. - assert.match(wrapper, /--gate-override-reason "\$\{GATE_OVERRIDE_REASON\}" \\\n {12}--gate-override-confirmation "\$\{GATE_OVERRIDE_CONFIRMATION\}"\)/) - // One per cell job in the serial cell_1..cell_10 chain. - assert.equal( - wrapper.match(/gate-override-confirmation: \$\{\{ inputs\.gate-override-confirmation \}\}/g).length, - 10 - ) - assert.match(wrapper, /Aggregate monitor gate overridden \(break-glass\)/) - assert.match(wrapper, /ACTOR: \$\{\{ github\.actor \}\}/) - for (const name of [ - 'Reject previously consumed aggregate safety evidence', - 'Consume aggregate safety evidence for this exact wave' - ]) { - assert.match( - wrapper, - new RegExp(`- name: ${name}\\n {8}if: \\$\\{\\{ inputs\\.mode != 'verify' && inputs\\.gate-override-confirmation == '' \\}\\}`) - ) - } - assert.match(job, /timeout-minutes: 75/) - // Both age gates step by the cell job timeout above; the constant is - // duplicated across the two languages, so pin each copy to it. - for (const source of [ - '../../dev/scripts/relay-monitor-evidence.mjs', - '../../apps/relay-ops/src/incident-live-preflight-cli.ts' - ]) { - const body = readFileSync(fileURLToPath(new URL(source, import.meta.url)), 'utf8') - assert.match(body, /WAVE_PREDECESSOR_TIMEOUT_MS = 75 \* 60_000/) - assert.match(body, /\^\[0-9\]\$/) - } - // Aged-evidence replay via job re-runs is fenced: mutations are - // single-dispatch, so a failed cell needs a fresh gate and monitor run. - assert.match(job, /test "\$\{GITHUB_RUN_ATTEMPT\}" = 1/) - for (const index of [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) { - assert.match(wrapper, new RegExp(`wave-index: '${index}'`)) - } - assert.doesNotMatch(job, /EFFECTIVE_SELECTOR_GENERATION \+ 1\)/) - assert.doesNotMatch(job, /EFFECTIVE_SELECTOR_GENERATION \+ 2\)/) - assert.match(job, /\$region == "us-central1" and \$protocol == 0 and [.]region == null/) - assert.match(job, /[.]regionalRehomeProtocol \/\/ 0/) - assert.match(job, /runtime predecessor normalized legacy fields=/) - assert.match(job, /probe-relay-rehome-trust[.]mjs/) - assert.doesNotMatch(job, /service_account: \$\{\{ vars\.PRODUCTION_GCP_RELAY_(?:DIRECTOR_)?RUNTIME_SERVICE_ACCOUNT/) - assert.doesNotMatch(job, /roles\/iam\.serviceAccountTokenCreator/) -}) - -// Why: the same-cap caller defines release_lease itself, and a caller-defined job presents the -// caller as job_workflow_ref, so the pair must admit the caller alongside its reusable job. -test('shared deploy WIF admits the exact same-cap reusable workflow pair and the caller itself', () => { - const terraform = readFileSync( - fileURLToPath(new URL('../../infra/terraform/relay-github-actions.tf', import.meta.url)), - 'utf8' - ) - const providerStart = terraform.indexOf( - 'resource "google_iam_workload_identity_pool_provider" "github"' - ) - const providerEnd = terraform.indexOf('\nresource "', providerStart + 1) - const sharedProvider = terraform.slice(providerStart, providerEnd) - assert.ok(providerStart >= 0 && providerEnd > providerStart) - assert.match(sharedProvider, /local\.relay_github_workflow_conditions\["github"\]/) - // The pairing itself now lives in the clause the provider renders, once per accepted repository. - assert.match( - terraform, - /assertion\.workflow_ref == '\$\{prefix\}\$\{local\.github_production_relay_same_cap_workflow_file\}@refs\/heads\/main' && \(assertion\.job_workflow_ref == '\$\{prefix\}\$\{local\.github_production_relay_same_cap_job_workflow_file\}@refs\/heads\/main' \|\| assertion\.job_workflow_ref == '\$\{prefix\}\$\{local\.github_production_relay_same_cap_workflow_file\}@refs\/heads\/main'\)/ - ) -}) - -test('pause and disable precede optional installation and cloud diagnostics', () => { - const job = workflow('operate-relay-production-rehome-job.yml') - const emergency = job.indexOf('Apply emergency durable pause or disable before diagnostics') - const install = job.indexOf('pnpm install --frozen-lockfile') - const revision = job.indexOf('Verify exact serving and rollback director identities') - assert.ok(emergency > 0) - assert.ok(emergency < install) - assert.ok(emergency < revision) - assert.match(job, /inputs\.mode == 'pause' \|\| inputs\.mode == 'disable'/) - assert.match(job, /Seal 24-hour aggregate region observation evidence/) - assert.match(job, /--freshness=25h --limit=30000/) - assert.match(job, /relay-region-observation-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/) - assert.match(job, /test "\$\{RATE_PER_MINUTE\}" = 10/) -}) - -test('a failed enable independently restores and verifies durable disabled state', () => { - const job = workflow('operate-relay-production-rehome-job.yml') - const enable = job.indexOf('Apply exact durable regional rehome enable') - const evidence = job.indexOf('Read fresh aggregate completion and abort evidence') - const summary = job.indexOf('Publish aggregate control evidence') - const recovery = job.indexOf('Fail closed after an unsuccessful enable run') - assert.ok(enable > 0 && enable < evidence && evidence < summary && summary < recovery) - const recoveryStep = job.slice(recovery) - assert.match( - recoveryStep, - /failure\(\) && inputs\.mode == 'enable' && steps\.google-auth\.outcome == 'success'/ - ) - assert.match(recoveryStep, /--mode recover-enable/) - assert.match(recoveryStep, /--expected-control-generation "\$\{EXPECTED_CONTROL_GENERATION\}"/) - assert.match(recoveryStep, /RECOVER_FAILED_REGIONAL_REHOME_ENABLE/) - assert.match(recoveryStep, /\.control\.enabled == false/) - assert.doesNotMatch(recoveryStep, /gcloud|pnpm/) -}) - -test('director rollout has a strict one-time identity bootstrap', () => { - const workflowBody = workflow('deploy-relay-production-director.yml') - const script = readFileSync( - fileURLToPath(new URL('./deploy-relay-blue-green.mjs', import.meta.url)), - 'utf8' - ) - assert.match(workflowBody, /BOOTSTRAP_RELAY_DIRECTOR_REHOME_IDENTITY/) - assert.match(workflowBody, /--predecessor-runtime-service-account/) - assert.match(workflowBody, /--expected-rehome-generation/) - assert.match(script, /args\.push\('--service-account', config\['runtime-service-account'\]\)/) - assert.match(script, /director predecessor runtime service account does not match/) - const candidateProof = script.indexOf('await verifyRehomeDisabled(candidate.origin)') - const trafficMove = script.indexOf('operations.updateTraffic(config, [`--to-tags=') - assert.ok(candidateProof > 0 && candidateProof < trafficMove) - assert.equal(script.indexOf('verifyRehomeDisabled', trafficMove), -1) -}) - -test('rehome job pipes every control result through tee under pipefail', () => { - const job = workflow('operate-relay-production-rehome-job.yml') - // Without `shell: bash` the step exit code is tee's, so a thrown inspect/apply passes green. - assert.match(job, /defaults:\n run:\n(?: #.*\n)* shell: bash\n/) - assert.ok((job.match(/\| tee "\$\{RUNNER_TEMP\}/g) ?? []).length >= 5) -}) diff --git a/cloud/dev/scripts/relay-same-cap-script-census.test.mjs b/cloud/dev/scripts/relay-same-cap-script-census.test.mjs index 1e023ce0c9d..ff2c265ee72 100644 --- a/cloud/dev/scripts/relay-same-cap-script-census.test.mjs +++ b/cloud/dev/scripts/relay-same-cap-script-census.test.mjs @@ -13,7 +13,6 @@ import { readRelayWorkflow } from './relay-repository.mjs' import { validateCapacityPlan } from './validate-relay-capacity-plan.mjs' const workflow = readRelayWorkflow('deploy-relay-production-same-cap-job.yml') -const capacityWorkflow = readRelayWorkflow('deploy-relay-production-capacity-job.yml') const production = readFileSync( new URL('../../infra/terraform/environments/production.tfvars', import.meta.url), 'utf8' @@ -295,74 +294,10 @@ describe('same-cap roll scripts accept every same-cap cell', () => { assert.equal(resolveCellShape('production-gce-c31').status, 1) }) - it('passes the same-cap allowlist on every canary invocation the job runs', () => { - const invocations = workflow.split('prepare-relay-production-capacity-canary.mjs').slice(1) - assert.equal(invocations.length, 4) - for (const invocation of invocations) { - const lines = invocation.split('\n') - const end = lines.findIndex((line) => !line.endsWith('\\')) - const call = lines.slice(0, end + 1).join(' ') - assert.match(call, /--approved-cells same-cap/) - // The restore call picks its mode from the cell's entry admission class. - assert.match(call, /--mode (isolate|drain|activate|"\$\{RESTORE_MODE\}")/) - } - }) - it('paces the drain it sends to the selected cell', () => { - const drain = workflow.split('--mode drain')[1] ?? '' - assert.match(drain.split('\n').slice(0, 2).join(' '), /--pace-window-ms "\$\{DRAIN_PACE_WINDOW_MS\}"/) - // 5 min is the cell's DRAIN_PACE_WINDOW_MAX_MS; a 2,700-host cell at 2 min overruns the director's sticky lane. - assert.match(workflow, /DRAIN_PACE_WINDOW_MS: '300000'/) - // The transition wait has to outlast the pacing window on top of the leases it waits on. - assert.match(workflow, /--activity restart-safe[\s\S]*?--timeout-ms 1200000/) - }) - it('passes this cell\'s rehome protocol and pool on every plan validation the job runs', () => { - const invocations = workflow.split('validate-relay-capacity-plan.mjs').slice(1) - assert.equal(invocations.length, 2) - for (const invocation of invocations) { - const lines = invocation.split('\n') - const end = lines.findIndex((line) => !line.trimEnd().endsWith('\\')) - const call = lines.slice(0, end + 1).join(' ') - assert.match(call, /--mode same-cap-cell/) - assert.match(call, /--regional-rehome-protocol "\$\{DESIRED_REHOME_PROTOCOL\}"/) - assert.match(call, /"\$\{POOL_ARGUMENTS\[@\]\}"/) - } - // Each of those steps must build the flag from the resolved pool, and only when there is one. - const builders = workflow.split( - 'if test -n "${EXPECTED_DATABASE_POOL_MAX}"; then\n' + - ' POOL_ARGUMENTS=(--database-pool-max "${EXPECTED_DATABASE_POOL_MAX}")' - ) - assert.equal(builders.length, 3) - assert.equal(workflow.split('POOL_ARGUMENTS=()').length, 3) - }) - // One cell's compute path and nothing else: the template and the MIG bound to it. The cell - // backend service stays out because the capacity role has no compute.backendServices.update, - // so naming it fails the apply after the MIG has already rolled. - it('targets exactly this cell template and MIG on every plan the job runs', () => { - const plans = workflow.split('terraform -chdir=infra/terraform plan').slice(1) - assert.equal(plans.length, 2) - for (const plan of plans) { - const lines = plan.split('\n') - const end = lines.findIndex((line) => !line.trimEnd().endsWith('\\')) - const call = lines.slice(0, end + 1).join('\n') - assert.deepEqual( - [...call.matchAll(/-target=([\w.]+)\[\\"\$\{TARGET_CELL_ID\}\\"\]/g)] - .map(([, resource]) => resource), - [ - 'google_compute_instance_template.relay_gce_cell', - 'google_compute_instance_group_manager.relay_gce_cell' - ] - ) - // Any target that is not one of those two, or not scoped to this cell, fails here. - assert.equal(call.split('-target=').length, 3) - } - }) - it('never names a backend service on any plan or apply in the job', () => { - assert.equal(workflow.includes('google_compute_backend_service'), false) - }) it('validates a correct plan for every wave cell at that cell\'s rehome protocol', () => { const trusted = SAME_CAP_CELLS.filter((cell) => REHOME_SOURCE_CELLS.has(cell)) @@ -569,25 +504,6 @@ describe('same-cap roll scripts accept every same-cap cell', () => { ) }) - it('pins the capacity identity on every plan validation the job runs', () => { - const invocations = workflow.split('validate-relay-capacity-plan.mjs').slice(1) - assert.equal(invocations.length, 2) - for (const invocation of invocations) { - const lines = invocation.split('\n') - const end = lines.findIndex((line) => !line.trimEnd().endsWith('\\')) - assert.match( - lines.slice(0, end + 1).join(' '), - /--capacity-service-account "\$\{CAPACITY_SERVICE_ACCOUNT\}"/ - ) - } - // Both steps must read it from the same repository variable the job already requires. - assert.equal( - workflow.split( - 'CAPACITY_SERVICE_ACCOUNT: ${{ vars.PRODUCTION_GCP_RELAY_CAPACITY_SERVICE_ACCOUNT }}' - ).length, - 4 - ) - }) it('decides the predecessor draining rule from the real block, for both classes', () => { // A zero-host cell sheds nothing, and a failed canary's own drain leaves the flag set @@ -777,29 +693,5 @@ describe('same-cap roll scripts accept every same-cap cell', () => { assert.equal(rolls('resume', { changes: 0 }), 'no-replace') assert.equal(rolls('none', { changes: 0 }), 'no-replace') }) - - it('waits on the image a stranded cell actually serves', () => { - const isolate = workflow - .split('name: Reversibly isolate and drain only the selected cell')[1] - .split('\n - id:')[0] - assert.match(isolate, /--expected-image-digests "\$\{PREDECESSOR_IMAGE_DIGEST\}"/) - // A stranded cell has to come back on a new process, which is what clears the drain. - const after = workflow - .split('name: Verify new incarnation, exact image, protocol, and durable safety')[1] - .split('\n - name:')[0] - assert.match(after, /test "\$\{TARGET_INCARNATION\}" != "\$\{SOURCE_INCARNATION\}"/) - assert.match(after, /if test "\$\{ROLLBACK_RESUME\}" = true; then/) - }) - - it('leaves the US-only capacity job on the default allowlist', () => { - assert.doesNotMatch(capacityWorkflow, /--approved-cells/) - }) }) -// Both trusted versions must prove the same authenticated drain boundary. -it('proves rehome trust for protocol 3 on forward and rollback rolls', () => { - const step = workflow.split('name: Prove exact per-host trust and idempotent no-neighbor behavior')[1].split('\n - name:')[0] - assert.match(step, /inputs\.rollback-rehome-protocol != '0'/) - assert.match(step, /inputs\.target-rehome-protocol != '0'/) - assert.match(step, /probe-relay-rehome-trust\.mjs/) -}) diff --git a/cloud/package.json b/cloud/package.json index 545f5387bcf..c029b06d6fa 100644 --- a/cloud/package.json +++ b/cloud/package.json @@ -20,13 +20,13 @@ "load:relay:model": "node dev/scripts/run-relay-load-model.mjs", "load:relay:recovery-gate": "node dev/scripts/run-relay-recovery-wave-gate.mjs", "ops:relay": "pnpm --filter @orca-cloud/relay-ops dev", - "pretest": "node --test dev/scripts/capture-terraform-plan-baseline.test.mjs dev/scripts/operate-relay-asia-admission.test.mjs dev/scripts/prepare-relay-asia-director-cells.test.mjs dev/scripts/prepare-relay-asia-topology-input.test.mjs dev/scripts/production-cloud-sql-rollout-lock.test.mjs dev/scripts/read-relay-serving-regional-placement-version.test.mjs dev/scripts/relay-asia-admission-workflow.test.mjs dev/scripts/relay-asia-rollout-evidence.test.mjs dev/scripts/relay-asia-topology-workflow.test.mjs dev/scripts/relay-cloud-sql-connection-budget.test.mjs dev/scripts/relay-load-reader-evidence.test.mjs dev/scripts/relay-lock-contention-alerts.test.mjs dev/scripts/relay-region-hint-metrics.test.mjs dev/scripts/relay-staging-deploy-identity.test.mjs dev/scripts/sanitize-relay-asia-admission-result.test.mjs dev/scripts/terraform-root-partition.test.mjs dev/scripts/validate-relay-asia-topology-plan.test.mjs ../.github/actions/cloud-sql-rollout-lease/action-contract.test.mjs ../.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs", - "test": "pnpm -r test && node --test dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/push-gateway-workflow.test.mjs dev/scripts/push-gateway-recovery.test.mjs dev/scripts/push-validation-workflow.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs dev/scripts/relay-admin-transient-retry.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-regional-rehome-workflow.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-same-cap-script-census.test.mjs dev/scripts/relay-same-cap-shadow-gate.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs", + "pretest": "node --test dev/scripts/capture-terraform-plan-baseline.test.mjs dev/scripts/operate-relay-asia-admission.test.mjs dev/scripts/prepare-relay-asia-director-cells.test.mjs dev/scripts/prepare-relay-asia-topology-input.test.mjs dev/scripts/production-cloud-sql-rollout-lock.test.mjs dev/scripts/read-relay-serving-regional-placement-version.test.mjs dev/scripts/relay-asia-rollout-evidence.test.mjs dev/scripts/relay-asia-topology-workflow.test.mjs dev/scripts/relay-cloud-sql-connection-budget.test.mjs dev/scripts/relay-load-reader-evidence.test.mjs dev/scripts/relay-lock-contention-alerts.test.mjs dev/scripts/relay-region-hint-metrics.test.mjs dev/scripts/relay-staging-deploy-identity.test.mjs dev/scripts/sanitize-relay-asia-admission-result.test.mjs dev/scripts/terraform-root-partition.test.mjs dev/scripts/validate-relay-asia-topology-plan.test.mjs ../.github/actions/cloud-sql-rollout-lease/action-contract.test.mjs ../.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs", + "test": "pnpm -r test && node --test dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/push-gateway-workflow.test.mjs dev/scripts/push-gateway-recovery.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs dev/scripts/relay-admin-transient-retry.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-same-cap-script-census.test.mjs dev/scripts/relay-same-cap-shadow-gate.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs", "typecheck": "pnpm -r typecheck" }, "devDependencies": { "@types/node": "^24.10.0", - "tsx": "^4.21.0", + "tsx": "^4.23.15", "typescript": "^5.9.3", "vitest": "^4.1.11" } diff --git a/cloud/pnpm-lock.yaml b/cloud/pnpm-lock.yaml index 4849a4ecea6..6042ee85cd4 100644 --- a/cloud/pnpm-lock.yaml +++ b/cloud/pnpm-lock.yaml @@ -12,14 +12,14 @@ importers: specifier: ^24.10.0 version: 24.13.2 tsx: - specifier: ^4.21.0 - version: 4.22.4 + specifier: ^4.23.15 + version: 4.23.15 typescript: specifier: ^5.9.3 version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)) apps/push: dependencies: @@ -65,7 +65,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)) apps/relay: dependencies: @@ -114,7 +114,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)) apps/relay-fence-broker: dependencies: @@ -139,7 +139,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)) apps/relay-ops: dependencies: @@ -164,7 +164,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)) packages/postgres-schema: devDependencies: @@ -176,7 +176,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)) packages/push-contract: dependencies: @@ -192,7 +192,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)) packages/relay-contract: dependencies: @@ -208,7 +208,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)) packages: @@ -227,156 +227,312 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.28.1': resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.28.1': resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.28.1': resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.28.1': resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.28.1': resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.28.1': resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.28.1': resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.28.1': resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.28.1': resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.28.1': resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.28.1': resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.28.1': resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.28.1': resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.28.1': resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.28.1': resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.28.1': resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.28.1': resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.28.1': resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.28.1': resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.28.1': resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.28.1': resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.28.1': resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@hono/node-server@1.19.17': resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} engines: {node: '>=18.14.1'} @@ -593,6 +749,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -871,6 +1032,11 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tsx@4.23.15: + resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} + engines: {node: '>=18.0.0'} + hasBin: true + tweetnacl@1.0.3: resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} @@ -1015,81 +1181,159 @@ snapshots: '@esbuild/aix-ppc64@0.28.1': optional: true + '@esbuild/aix-ppc64@0.28.2': + optional: true + '@esbuild/android-arm64@0.28.1': optional: true + '@esbuild/android-arm64@0.28.2': + optional: true + '@esbuild/android-arm@0.28.1': optional: true + '@esbuild/android-arm@0.28.2': + optional: true + '@esbuild/android-x64@0.28.1': optional: true + '@esbuild/android-x64@0.28.2': + optional: true + '@esbuild/darwin-arm64@0.28.1': optional: true + '@esbuild/darwin-arm64@0.28.2': + optional: true + '@esbuild/darwin-x64@0.28.1': optional: true + '@esbuild/darwin-x64@0.28.2': + optional: true + '@esbuild/freebsd-arm64@0.28.1': optional: true + '@esbuild/freebsd-arm64@0.28.2': + optional: true + '@esbuild/freebsd-x64@0.28.1': optional: true + '@esbuild/freebsd-x64@0.28.2': + optional: true + '@esbuild/linux-arm64@0.28.1': optional: true + '@esbuild/linux-arm64@0.28.2': + optional: true + '@esbuild/linux-arm@0.28.1': optional: true + '@esbuild/linux-arm@0.28.2': + optional: true + '@esbuild/linux-ia32@0.28.1': optional: true + '@esbuild/linux-ia32@0.28.2': + optional: true + '@esbuild/linux-loong64@0.28.1': optional: true + '@esbuild/linux-loong64@0.28.2': + optional: true + '@esbuild/linux-mips64el@0.28.1': optional: true + '@esbuild/linux-mips64el@0.28.2': + optional: true + '@esbuild/linux-ppc64@0.28.1': optional: true + '@esbuild/linux-ppc64@0.28.2': + optional: true + '@esbuild/linux-riscv64@0.28.1': optional: true + '@esbuild/linux-riscv64@0.28.2': + optional: true + '@esbuild/linux-s390x@0.28.1': optional: true + '@esbuild/linux-s390x@0.28.2': + optional: true + '@esbuild/linux-x64@0.28.1': optional: true + '@esbuild/linux-x64@0.28.2': + optional: true + '@esbuild/netbsd-arm64@0.28.1': optional: true + '@esbuild/netbsd-arm64@0.28.2': + optional: true + '@esbuild/netbsd-x64@0.28.1': optional: true + '@esbuild/netbsd-x64@0.28.2': + optional: true + '@esbuild/openbsd-arm64@0.28.1': optional: true + '@esbuild/openbsd-arm64@0.28.2': + optional: true + '@esbuild/openbsd-x64@0.28.1': optional: true + '@esbuild/openbsd-x64@0.28.2': + optional: true + '@esbuild/openharmony-arm64@0.28.1': optional: true + '@esbuild/openharmony-arm64@0.28.2': + optional: true + '@esbuild/sunos-x64@0.28.1': optional: true + '@esbuild/sunos-x64@0.28.2': + optional: true + '@esbuild/win32-arm64@0.28.1': optional: true + '@esbuild/win32-arm64@0.28.2': + optional: true + '@esbuild/win32-ia32@0.28.1': optional: true + '@esbuild/win32-ia32@0.28.2': + optional: true + '@esbuild/win32-x64@0.28.1': optional: true + '@esbuild/win32-x64@0.28.2': + optional: true + '@hono/node-server@1.19.17(hono@4.13.7)': dependencies: hono: 4.13.7 @@ -1195,13 +1439,21 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))': + '@vitest/mocker@4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4) + vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4) + + '@vitest/mocker@4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15) '@vitest/pretty-format@4.1.11': dependencies: @@ -1284,6 +1536,35 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -1542,13 +1823,19 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tsx@4.23.15: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + tweetnacl@1.0.3: {} typescript@5.9.3: {} undici-types@7.18.2: {} - vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4): + vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -1557,14 +1844,27 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.13.2 - esbuild: 0.28.1 + esbuild: 0.28.2 fsevents: 2.3.3 tsx: 4.22.4 - vitest@4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)): + vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.2 + esbuild: 0.28.2 + fsevents: 2.3.3 + tsx: 4.23.15 + + vitest@4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + '@vitest/mocker': 4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -1581,7 +1881,34 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4) + vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.2 + transitivePeerDependencies: + - msw + + vitest@4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.2 diff --git a/config/build-plugins/pdfjs-viewer-assets.ts b/config/build-plugins/pdfjs-viewer-assets.ts index c212194ec71..a632a7aa2d6 100644 --- a/config/build-plugins/pdfjs-viewer-assets.ts +++ b/config/build-plugins/pdfjs-viewer-assets.ts @@ -76,6 +76,9 @@ export function createPdfjsViewerAssetsPlugin(root = pdfjsRoot()): Plugin { next() return } + if (response.destroyed) { + return + } response.statusCode = 200 response.setHeader('Content-Length', size) response.setHeader( @@ -86,7 +89,18 @@ export function createPdfjsViewerAssetsPlugin(root = pdfjsRoot()): Plugin { response.end() return } - createReadStream(filePath).pipe(response) + const stream = createReadStream(filePath) + const stopReading = (): void => { + stream.destroy() + } + response.once('close', stopReading) + response.once('error', stopReading) + stream.once('close', () => { + response.off('close', stopReading) + response.off('error', stopReading) + }) + stream.once('error', () => response.destroy()) + stream.pipe(response) }) }, writeBundle(options) { diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts index 7dc017b9c98..c4f58fa521c 100644 --- a/config/build-plugins/plain-node-entry-guard.ts +++ b/config/build-plugins/plain-node-entry-guard.ts @@ -16,15 +16,34 @@ type OutputChunk = Rollup.OutputChunk // electron, and smoke-loads daemon-entry under plain Node to prove its module // graph still resolves. -// Entries executed as plain Node (ELECTRON_RUN_AS_NODE / no electron runtime): -// forked daemon, parcel-watcher, WSL filesystem and computer sidecars, and the CLI-run -// agent-hooks entry. require("electron") throws MODULE_NOT_FOUND in all of them. +// The CLI loads these paths after electron-vite replaces out/main. +export const CLI_MAIN_ENTRY_NAMES = [ + 'agent-hooks/managed-agent-hook-controls', + 'orca-profiles/profile-index-store', + 'codex/managed-home-shell-preflight', + 'claude-accounts/keychain', + ...[ + 'access', + 'active-location', + 'storage-classification', + 'offline-settings', + 'backup-path', + 'database-recovery', + 'domain-reader', + 'recovery-command' + ].map((module) => `persistence/profile-state/profile-state-${module}`), + 'persistence/profile-state/legacy-json/profile-state-export-path', + 'persistence/profile-state/legacy-json/profile-state-recovery', + 'startup/http1-compatibility-marker' +] as const + +// Plain-Node processes and CLI modules cannot load Electron's API. const PLAIN_NODE_ENTRY_NAMES = [ 'daemon-entry', 'parcel-watcher-process-entry', 'computer-sidecar', 'wsl-transcript-fs-process-entry', - 'agent-hooks/managed-agent-hook-controls' + ...CLI_MAIN_ENTRY_NAMES ] as const // Entries executed as worker threads of the main process. Electron's module is @@ -41,7 +60,9 @@ const WORKER_THREAD_ENTRY_NAMES = [ 'session-scanner-worker-entry', 'main-thread-hang-watchdog-entry', 'port-scan-command-worker-entry', - 'usage-scan-worker-entry' + 'usage-scan-worker-entry', + 'profile-state-backup-worker-entry', + 'profile-state-writer-worker-entry' ] as const export const GUARDED_ENTRY_NAMES = [ diff --git a/config/docker/cli-launch-contract/Dockerfile b/config/docker/cli-launch-contract/Dockerfile index c90cbcd979c..bc9ba2947f4 100644 --- a/config/docker/cli-launch-contract/Dockerfile +++ b/config/docker/cli-launch-contract/Dockerfile @@ -1,4 +1,4 @@ -ARG BASE_IMAGE=ubuntu:24.04 +ARG BASE_IMAGE=ubuntu@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 FROM ${BASE_IMAGE} ARG LIBASOUND_PACKAGE=libasound2t64 diff --git a/config/e2e-failure-tracking.json b/config/e2e-failure-tracking.json new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/config/e2e-failure-tracking.json @@ -0,0 +1 @@ +[] diff --git a/config/electron-builder-pr-linux.config.cjs b/config/electron-builder-pr-linux.config.cjs new file mode 100644 index 00000000000..54056e80996 --- /dev/null +++ b/config/electron-builder-pr-linux.config.cjs @@ -0,0 +1,8 @@ +const release = require('./electron-builder.config.cjs') + +// CI discards these packages after smoke tests; keep release compression unchanged. +module.exports = { + ...release, + deb: { ...release.deb, fpm: [...(release.deb.fpm ?? []), '--deb-compression-level=1'] }, + rpm: { ...release.rpm, fpm: [...(release.rpm.fpm ?? []), '--rpm-compression-level=1'] } +} diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 84c31376833..7032e635d93 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -188,6 +188,9 @@ module.exports = { // Why: these repo-only inputs are either bundled into out/ or copied via // extraResources. Shipping them in app.asar bloats the desktop bundle. '!src{,/**/*}', + '!out/orcad{,/**/*}', + '!out/orcad-template{,/**/*}', + '!out/.orcad-*{,/**/*}', '!config{,/**/*}', '!docs{,/**/*}', '!mobile{,/**/*}', @@ -207,6 +210,8 @@ module.exports = { // it is gitignored, but exclude it defensively so a stray local capture at // package time never bloats app.asar. '!pr-evidence{,/**/*}', + // Local build logs and rollback copies are never application resources. + '!notes{,/**/*}', // Why: local agent/tooling directories may contain worktree symlink loops; // they are never runtime inputs and must not be traversed by electron-builder. '!{.claude,.grok,.agents,.codex}{,/**/*}', @@ -281,6 +286,9 @@ module.exports = { 'out/main/gemini/**', 'out/main/grok/**', 'out/main/hermes/**', + 'out/main/orca-profiles/profile-index-store.js', + 'out/main/persistence/profile-state/**', + 'out/main/startup/http1-compatibility-marker.js', 'out/main/daemon-entry.js', 'out/main/session-scanner-service-entry.js', 'out/main/wsl-transcript-fs-process-entry.js', diff --git a/config/nsis/orca-installer-hooks.nsh b/config/nsis/orca-installer-hooks.nsh index d89439073ab..a6fb31f9aec 100644 --- a/config/nsis/orca-installer-hooks.nsh +++ b/config/nsis/orca-installer-hooks.nsh @@ -3,6 +3,8 @@ ; electron-builder accepts exactly ONE `nsis.include` file, so every customInstall / ; customUnInstall hook Orca needs lives here. +!include "${__FILEDIR__}\orca-process-check.nsh" + ; --------------------------------------------------------------------------- ; Markdown "Open with Orca" (issue #10138) ; diff --git a/config/nsis/orca-process-check.nsh b/config/nsis/orca-process-check.nsh new file mode 100644 index 00000000000..4acedf5afc8 --- /dev/null +++ b/config/nsis/orca-process-check.nsh @@ -0,0 +1,17 @@ +; Defining the hook suppresses electron-builder's process-info declarations. +!include "getProcessInfo.nsh" +Var pid +Var /GLOBAL IsPowerShellAvailable + +!macro customCheckAppRunning + ; Restricted permits inline commands; test the process query rather than script-file policy. + ; Match upstream FIND/KILL's profile behavior so the probe cannot skip a failing profile. + nsExec::Exec `"$PowerShellPath" -Command "try { Get-CimInstance -ClassName Win32_Process -ErrorAction Stop | Out-Null; exit 0 } catch { exit 1 }"` + Pop $0 + ; Launch errors, timeouts, and failed queries retain upstream's image-name fallback. + StrCpy $IsPowerShellAvailable 1 + ${if} $0 == 0 + StrCpy $IsPowerShellAvailable 0 + ${endIf} + !insertmacro _CHECK_APP_RUNNING +!macroend diff --git a/config/oxlint-anti-slop.json b/config/oxlint-anti-slop.json index 4728c38a7e9..ae5bd3c217b 100644 --- a/config/oxlint-anti-slop.json +++ b/config/oxlint-anti-slop.json @@ -113,7 +113,7 @@ // `shapedSidebar` is a persisted onboarding-checklist field and a telemetry enum member; // renaming it would orphan saved state. { - "files": ["**/src/shared/constants.ts", "**/src/shared/onboarding-state-types.ts"], + "files": ["**/src/shared/onboarding-defaults.ts", "**/src/shared/onboarding-state-types.ts"], "rules": { "anti-slop/no-shape-in-symbol-names": "off" } diff --git a/config/patches/@vscode__windows-process-tree@0.8.0.patch b/config/patches/@vscode__windows-process-tree@0.8.0.patch index 7c930a5fca5..3855e187ec6 100644 --- a/config/patches/@vscode__windows-process-tree@0.8.0.patch +++ b/config/patches/@vscode__windows-process-tree@0.8.0.patch @@ -28,13 +28,14 @@ index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..0bb2af7923b6e6f1f0da40cae8067304 "/guard:cf", "/sdl", diff --git a/lib/index.js b/lib/index.js -index 9747a7402600cd252859144d32580ed45c8c93f7..001e81fa8bc89091971d06aaf9d051ba20906615 100644 +index e586cd6ead522a4ff060f5338201f45e3467d639..0ee62c35558ff40e2298c464fdf6e9485f9d181d 100644 --- a/lib/index.js +++ b/lib/index.js -@@ -7,11 +7,13 @@ Object.defineProperty(exports, "__esModule", { value: true }); +@@ -7,11 +7,14 @@ exports.getAllProcesses = exports.getProcessTree = exports.getProcessCpuUsage = exports.getProcessList = exports.filterProcessList = exports.buildProcessTree = exports.ProcessDataFlag = void 0; const util_1 = require("util"); const native = process.platform === 'win32' ? require('../build/Release/windows_process_tree.node') : undefined; ++exports.getProcessCreationTime = native === undefined ? undefined : native.getProcessCreationTime; +exports.supportedProcessDataFlags = native === undefined ? undefined : native.supportedProcessDataFlags; var ProcessDataFlag; (function (ProcessDataFlag) { @@ -45,7 +46,7 @@ index 9747a7402600cd252859144d32580ed45c8c93f7..001e81fa8bc89091971d06aaf9d051ba })(ProcessDataFlag = exports.ProcessDataFlag || (exports.ProcessDataFlag = {})); // requestInProgress is used for any function that uses CreateToolhelp32Snapshot, as multiple calls // to this cannot be done at the same time. -@@ -66,11 +68,12 @@ function buildProcessTree(rootPid, processList, maxDepth = MAX_FILTER_DEPTH) { +@@ -66,11 +69,12 @@ // • the properties are inlined/splatted // • the 'ppid' field is omitted // • the depth of the tree is limited by `maxDepth` @@ -60,13 +61,14 @@ index 9747a7402600cd252859144d32580ed45c8c93f7..001e81fa8bc89091971d06aaf9d051ba }); return buildNode(root, maxDepth); diff --git a/lib/index.ts b/lib/index.ts -index f9aa005d9ced9e42885b8a976de5eb5bd61899ee..1b509af0b9065918bcb5cb75f2d7f23821d4a56a 100644 +index 7b53aad05c3682a5c7d814d81a39c3f391ae97e3..284dae185b56241244b4d6bcab9e08f7530b8cf3 100644 --- a/lib/index.ts +++ b/lib/index.ts -@@ -6,12 +6,15 @@ +@@ -6,12 +6,16 @@ import { promisify } from 'util'; const native = process.platform === 'win32' ? require('../build/Release/windows_process_tree.node') : undefined; ++export const getProcessCreationTime: ((pid: number) => number | undefined) | undefined = native?.getProcessCreationTime; +/** The flag bits this compiled addon reports; undefined off win32. */ +export const supportedProcessDataFlags: number | undefined = native?.supportedProcessDataFlags; import { IProcessInfo, IProcessTreeNode, IProcessCpuInfo } from '@vscode/windows-process-tree'; @@ -80,7 +82,7 @@ index f9aa005d9ced9e42885b8a976de5eb5bd61899ee..1b509af0b9065918bcb5cb75f2d7f238 } type RequestCallback = (processList: IProcessInfo[]) => void; -@@ -81,11 +84,12 @@ export function buildProcessTree(rootPid: number, processList: IterableQueue(); + } + ++Napi::Value ReadProcessCreationTime(const Napi::CallbackInfo& args) { ++ Napi::Env env(args.Env()); ++ if (args.Length() != 1 || !args[0].IsNumber()) { ++ return env.Undefined(); ++ } ++ const double pid = args[0].As().DoubleValue(); ++ if (!(pid >= 1 && pid <= MAXDWORD) || pid != static_cast(pid)) { ++ return env.Undefined(); ++ } ++ ProcessInfo pinfo{}; ++ pinfo.pid = static_cast(pid); ++ GetProcessCreationTime(pinfo); ++ if (pinfo.creationTimeMs == 0) { ++ return env.Undefined(); ++ } ++ return Napi::Number::New(env, static_cast(pinfo.creationTimeMs)); ++} ++ Napi::Object Init(Napi::Env env, Napi::Object exports) { ++ exports.Set("getProcessCreationTime", Napi::Function::New(env, ReadProcessCreationTime)); exports.Set("getProcessList", Napi::Function::New(env, GetProcessList)); exports.Set("getProcessCpuUsage", Napi::Function::New(env, GetProcessCpuUsage)); + // Lets a caller prove THIS BINARY understands CREATIONTIME. The JS enum is @@ -398,10 +422,10 @@ index c9e3457a759c1acaa2644231a4917d45aed951f8..3f26a354477f062b34bd31fbd17be529 } diff --git a/typings/windows-process-tree.d.ts b/typings/windows-process-tree.d.ts -index 08bdac2fdc5ead6f0fcfb5ee5a021e2298c7d523..458981566fc45c0084badff566b1e3791ec1b629 100644 +index 70e242b123e76d43c452007be1ce92a6d224c8bb..b1d0a53c0c18cc16531b394c19bb256bdbaf782f 100644 --- a/typings/windows-process-tree.d.ts +++ b/typings/windows-process-tree.d.ts -@@ -7,9 +7,17 @@ declare module '@vscode/windows-process-tree' { +@@ -7,8 +7,17 @@ export enum ProcessDataFlag { None = 0, Memory = 1, @@ -409,18 +433,18 @@ index 08bdac2fdc5ead6f0fcfb5ee5a021e2298c7d523..458981566fc45c0084badff566b1e379 + CommandLine = 2, + CreationTime = 4 } - ++ + /** + * The flag bits the compiled addon actually understands, or undefined off + * win32. `ProcessDataFlag` above is source; this is what the binary reports, + * so it is the only way to tell a patched build from a stale prebuilt. + */ + export const supportedProcessDataFlags: number | undefined; -+ ++ export const getProcessCreationTime: ((pid: number) => number | undefined) | undefined; + export interface IProcessInfo { pid: number; - ppid: number; -@@ -24,6 +32,9 @@ declare module '@vscode/windows-process-tree' { +@@ -24,6 +33,9 @@ * The string returned is at most 512 chars, strings exceeding this length are truncated. */ commandLine?: string; @@ -430,7 +454,7 @@ index 08bdac2fdc5ead6f0fcfb5ee5a021e2298c7d523..458981566fc45c0084badff566b1e379 } export interface IProcessCpuInfo extends IProcessInfo { -@@ -35,6 +46,7 @@ declare module '@vscode/windows-process-tree' { +@@ -35,6 +47,7 @@ name: string; memory?: number; commandLine?: string; diff --git a/config/patches/@xterm__addon-image@0.10.0-beta.300.patch b/config/patches/@xterm__addon-image@0.10.0-beta.300.patch index c44cb33d2d5..44d6ac32c14 100644 --- a/config/patches/@xterm__addon-image@0.10.0-beta.300.patch +++ b/config/patches/@xterm__addon-image@0.10.0-beta.300.patch @@ -1,24 +1,24 @@ diff --git a/lib/addon-image.js b/lib/addon-image.js -index 38cf9e8708a5f314b8d587e5428ce7facb0d4076..4807d674d75a5aeaba40cf86972ce0a6158a33f4 100644 +index 38cf9e8708a5f314b8d587e5428ce7facb0d4076..4c5f44e00b0972978d762df5990de86002537649 100644 --- a/lib/addon-image.js +++ b/lib/addon-image.js @@ -1,3 +1,3 @@ /*! For license information please see addon-image.js.LICENSE.txt */ -!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.ImageAddon=t():e.ImageAddon=t()}(globalThis,()=>(()=>{"use strict";var e={939(e,t){function A(e){return 255&e}function i(e){return e>>>8&255}function s(e){return e>>>16&255}function r(e,t,A,i=255){return((255&i)<<24|(255&A)<<16|(255&t)<<8|255&e)>>>0}function o(e,t,A){return Math.max(e,Math.min(A,t))}function a(e,t,A){return A<0&&(A+=1),A>1&&(A-=1),6*A<1?t+6*(e-t)*A:2*A<1?e:3*A<2?t+(e-t)*(4-6*A):t}function n(e,t,A){return(4278190080|Math.round(A/100*255)<<16|Math.round(t/100*255)<<8|Math.round(e/100*255))>>>0}Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_FOREGROUND=t.DEFAULT_BACKGROUND=t.PALETTE_ANSI_256=t.PALETTE_VT340_GREY=t.PALETTE_VT340_COLOR=t.normalizeHLS=t.normalizeRGB=t.nearestColorIndex=t.fromRGBA8888=t.toRGBA8888=t.alpha=t.blue=t.green=t.red=t.BIG_ENDIAN=void 0,t.BIG_ENDIAN=255===new Uint8Array(new Uint32Array([4278190080]).buffer)[0],t.BIG_ENDIAN&&console.warn("BE platform detected. This version of node-sixel works only on LE properly."),t.red=A,t.green=i,t.blue=s,t.alpha=function(e){return e>>>24&255},t.toRGBA8888=r,t.fromRGBA8888=function(e){return[255&e,e>>8&255,e>>16&255,e>>>24]},t.nearestColorIndex=function(e,t){const r=A(e),o=i(e),a=s(e);let n=Number.MAX_SAFE_INTEGER,h=-1;for(let e=0;e{const e=[r(0,0,0),r(205,0,0),r(0,205,0),r(205,205,0),r(0,0,238),r(205,0,205),r(0,250,205),r(229,229,229),r(127,127,127),r(255,0,0),r(0,255,0),r(255,255,0),r(92,92,255),r(255,0,255),r(0,255,255),r(255,255,255)],t=[0,95,135,175,215,255];for(let A=0;A<6;++A)for(let i=0;i<6;++i)for(let s=0;s<6;++s)e.push(r(t[A],t[i],t[s]));for(let t=8;t<=238;t+=10)e.push(r(t,t,t));return new Uint32Array(e)})(),t.DEFAULT_BACKGROUND=r(0,0,0,255),t.DEFAULT_FOREGROUND=r(255,255,255,255)},591(e,t,A){Object.defineProperty(t,"__esModule",{value:!0}),t.decodeAsync=t.decode=t.Decoder=t.DecoderAsync=void 0;const i=A(939),s=A(199),r=function(e){if("undefined"!=typeof Buffer)return Buffer.from(e,"base64");const t=atob(e),A=new Uint8Array(t.length);for(let e=0;e1,this.modeHandler=e=>1}handle_band(e){return this.bandHandler(e)}mode_parsed(e){return this.modeHandler(e)}}const h={memoryLimit:134217728,sixelColor:i.DEFAULT_FOREGROUND,fillColor:i.DEFAULT_BACKGROUND,palette:i.PALETTE_VT340_COLOR,paletteLimit:s.LIMITS.PALETTE_SIZE,truncate:!0};function g(e){const t=new n,A={env:{handle_band:t.handle_band.bind(t),mode_parsed:t.mode_parsed.bind(t)}};return WebAssembly.instantiate(o||r,A).then(A=>(o=o||A.module,new d(e,A.instance||A,t)))}t.DecoderAsync=g;class d{constructor(e,t,A){if(this._PIXEL_OFFSET=s.LIMITS.MAX_WIDTH+4,this._canvas=a,this._bandWidths=[],this._maxWidth=0,this._minWidth=s.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0,this._opts=Object.assign({},h,e),this._opts.paletteLimit>s.LIMITS.PALETTE_SIZE)throw new Error(`DecoderOptions.paletteLimit must not exceed ${s.LIMITS.PALETTE_SIZE}`);if(t)A.bandHandler=this._handle_band.bind(this),A.modeHandler=this._initCanvas.bind(this);else{const e=o||(o=new WebAssembly.Module(r));t=new WebAssembly.Instance(e,{env:{handle_band:this._handle_band.bind(this),mode_parsed:this._initCanvas.bind(this)}})}this._instance=t,this._wasm=this._instance.exports,this._chunk=new Uint8Array(this._wasm.memory.buffer,this._wasm.get_chunk_address(),s.LIMITS.CHUNK_SIZE),this._states=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_state_address(),12),this._palette=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_palette_address(),s.LIMITS.PALETTE_SIZE),this._palette.set(this._opts.palette),this._pSrc=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_p0_address()),this._wasm.init(i.DEFAULT_FOREGROUND,0,this._opts.paletteLimit,0)}get _fillColor(){return this._states[0]}get _truncate(){return this._states[8]}get _rasterWidth(){return this._states[6]}get _rasterHeight(){return this._states[7]}get _width(){return this._states[2]?this._states[2]-4:0}get _height(){return this._states[3]}get _level(){return this._states[9]}get _mode(){return this._states[10]}get _paletteLimit(){return this._states[11]}_initCanvas(e){if(2===e){const e=this.width*this.height;if(e>this._canvas.length){if(this._opts.memoryLimit&&4*e>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(e)}this._maxWidth=this._width}else if(1===e)if(2===this._level){const e=Math.min(this._rasterWidth,s.LIMITS.MAX_WIDTH)*this._rasterHeight;if(e>this._canvas.length){if(this._opts.memoryLimit&&4*e>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(e)}}else this._canvas.length<65536&&(this._canvas=new Uint32Array(65536));return 0}_realloc(e,t){const A=e+t;if(A>this._canvas.length){if(this._opts.memoryLimit&&4*A>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");const e=new Uint32Array(65536*Math.ceil(A/65536));e.set(this._canvas),this._canvas=e}}_handle_band(e){const t=this._PIXEL_OFFSET;let A=this._lastOffset;if(2===this._mode){let i=this.height-this._currentHeight,s=0;for(;s<6&&i>0;)this._canvas.set(this._pSrc.subarray(t*s,t*s+e),A+e*s),s++,i--;this._lastOffset+=e*s,this._currentHeight+=s}else if(1===this._mode){this._realloc(A,6*e),this._maxWidth=Math.max(this._maxWidth,e),this._minWidth=Math.min(this._minWidth,e);for(let i=0;i<6;++i)this._canvas.set(this._pSrc.subarray(t*i,t*i+e),A+e*i);this._bandWidths.push(e),this._lastOffset+=6*e,this._currentHeight+=6}return 0}get width(){return 1!==this._mode?this._width:Math.max(this._maxWidth,this._wasm.current_width())}get height(){return 1!==this._mode?this._height:this._wasm.current_width()?6*this._bandWidths.length+this._wasm.current_height():6*this._bandWidths.length}get palette(){return this._palette.subarray(0,this._paletteLimit)}get memoryUsage(){return this._canvas.byteLength+this._wasm.memory.buffer.byteLength+8*this._bandWidths.length}get properties(){return{width:this.width,height:this.height,mode:this._mode,level:this._level,truncate:!!this._truncate,paletteLimit:this._paletteLimit,fillColor:this._fillColor,memUsage:this.memoryUsage,rasterAttributes:{numerator:this._states[4],denominator:this._states[5],width:this._rasterWidth,height:this._rasterHeight}}}init(e=this._opts.fillColor,t=this._opts.palette,A=this._opts.paletteLimit,i=this._opts.truncate){this._wasm.init(this._opts.sixelColor,e,A,i?1:0),t&&this._palette.set(t.subarray(0,s.LIMITS.PALETTE_SIZE)),this._bandWidths.length=0,this._maxWidth=0,this._minWidth=s.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0}decode(e,t=0,A=e.length){let i=t;for(;i0){const A=this._PIXEL_OFFSET;let i=this._lastOffset,s=0;for(;s<6&&t>0;)this._canvas.set(this._pSrc.subarray(A*s,A*s+e),i+e*s),s++,t--;t&&this._canvas.fill(this._fillColor,i+e*s)}return this._canvas.subarray(0,this.width*this.height)}if(1===this._mode){if(this._minWidth===this._maxWidth){let t=!1;if(e)if(e!==this._minWidth)t=!0;else{const t=this._PIXEL_OFFSET;let A=this._lastOffset;this._realloc(A,6*e);for(let i=0;i<6;++i)this._canvas.set(this._pSrc.subarray(t*i,t*i+e),A+e*i)}if(!t)return this._canvas.subarray(0,this.width*this.height)}const t=new Uint32Array(this.width*this.height);t.fill(this._fillColor);let A=0,i=0;for(let e=0;e{if(this._disposed)return(0,i.toDisposable)(()=>{});const s={fn:e,thisArgs:t};this._listeners=this._listeners.slice(),this._listeners.push(s);const r=(0,i.toDisposable)(()=>{const e=this._listeners.indexOf(s);-1!==e&&(this._listeners=this._listeners.slice(),this._listeners.splice(e,1))});return A&&(Array.isArray(A)?A.push(r):A.add(r)),r}),this._event}fire(e){if(this._disposed||!this._listeners.length)return;if(1===this._listeners.length)return void this._listeners[0].fn.call(this._listeners[0].thisArgs,e);const t=this._listeners;for(let A=0,i=t.length;At.fire(e))},e.map=function(e,t){return(A,i,s)=>e(e=>A.call(i,t(e)),void 0,s)},e.any=function(...e){return(t,A,s)=>{const r=new i.DisposableStore;for(const i of e)r.add(i(e=>t.call(A,e)));return s&&(Array.isArray(s)?s.push(r):s.add(r)),r}},e.runAndSubscribe=function(e,t,A){return t(A),e(e=>t(e))}}(s||(t.EventUtils=s={}))},426(e,t){function A(e){return{dispose:e}}function i(e){if(!e)return e;if(Array.isArray(e)){for(const t of e)t.dispose();return[]}return e.dispose(),e}Object.defineProperty(t,"__esModule",{value:!0}),t.MutableDisposable=t.Disposable=t.DisposableStore=void 0,t.toDisposable=A,t.dispose=i,t.combinedDisposable=function(...e){return A(()=>i(e))};class s{constructor(){this._disposables=new Set,this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(e){return this._isDisposed?e.dispose():this._disposables.add(e),e}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(const e of this._disposables)e.dispose();this._disposables.clear()}}clear(){for(const e of this._disposables)e.dispose();this._disposables.clear()}}t.DisposableStore=s;class r{constructor(){this._store=new s}dispose(){this._store.dispose()}_register(e){return this._store.add(e)}}t.Disposable=r,r.None=Object.freeze({dispose(){}}),t.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}}},795(e,t,A){var i=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0}),t.IIPHandler=void 0;const s=A(463),r=A(699),o=i(A(669)),a=i(A(61)),n=A(389),h=A(462),g={type:0,name:"Unnamed file",size:0,width:"auto",height:"auto",preserveAspectRatio:1,inline:0};t.IIPHandler=class{constructor(e,t,A,i){this._opts=e,this._renderer=t,this._storage=A,this._coreTerminal=i,this._aborted=!1,this._hp=new n.HeaderParser,this._header=g,this._isMultipart=!1,this._abortMulti=!1;const s=Math.ceil(4*this._opts.iipSizeLimit/3),r=Math.min(1048576,s);this._dec=new o.default(4194304,s,r),this._qoiDec=new a.default(4194304)}reset(){this._hp.reset(),this._dec.release(),this._qoiDec.release()}start(){this._aborted=!1,this._hp.reset()}put(e,t,A){if(!this._aborted)if(4===this._hp.state)0!==this._dec.put(e.subarray(t,A))&&(this._dec.release(),this._aborted=!0);else{const i=this._hp.parse(e,t,A);if(-1===i)return void(this._aborted=!0);if(i>0){if(1===this._hp.fields.type){if(this._isMultipart&&(this._isMultipart=!1,this._abortMulti=!1,this._dec.release()),this._header=Object.assign({},g,this._hp.fields),!this._header.inline)return void(this._aborted=!0);this._dec.init()}else if(this._abortMulti)return void(this._aborted=!0);0!==this._dec.put(e.subarray(i,A))&&(this._dec.release(),this._aborted=!0,this._isMultipart&&(this._abortMulti=!0))}}}end(e){var t,A,i;if(this._aborted)return!0;if(4!==this._hp.state&&this._hp.end())return!0;const o=this._hp.fields.type;if(3===o)return!0;if(5===o){let e=r.CELL_SIZE_DEFAULT.width,A=r.CELL_SIZE_DEFAULT.height;this._renderer.dimensions&&(e=this._renderer.dimensions.css.canvas.width/this._coreTerminal.cols,A=this._renderer.dimensions.css.canvas.height/this._coreTerminal.rows);const s=null!==(i=null===(t=this._coreTerminal._core._coreBrowserService)||void 0===t?void 0:t.dpr)&&void 0!==i?i:1,o=`]1337;ReportCellSize=${A.toFixed(3)};${e.toFixed(3)};${s.toFixed(3)}\\`;return this._coreTerminal.input(o,!1),!0}if(2===o)return this._header=Object.assign({},g,this._hp.fields),this._isMultipart=!0,this._abortMulti=!1,this._dec.release(),this._dec.init(),!0;if(4===o){if(!this._isMultipart)return!0;if(this._isMultipart=!1,this._abortMulti||2!==this._header.type)return!0}let a,n,d=0,l=0,I=h.UNSUPPORTED_TYPE;if((a=e)&&((a=!this._dec.end())?(I=(0,h.imageType)(this._dec.data8),(a="unsupported"!==I.mime)?(d=I.width,l=I.height,(a=d&&l&&d*l(this._storage.addImage(e),!0)).catch(e=>(console.warn(`IIP: decoding error ${I.mime} ${I.width}x${I.height}`,e),!0))}_resize(e,t){var A,i,s,o;const a=(null===(A=this._renderer.dimensions)||void 0===A?void 0:A.css.cell.width)||r.CELL_SIZE_DEFAULT.width,n=(null===(i=this._renderer.dimensions)||void 0===i?void 0:i.css.cell.height)||r.CELL_SIZE_DEFAULT.height,h=(null===(s=this._renderer.dimensions)||void 0===s?void 0:s.css.canvas.width)||a*this._coreTerminal.cols,g=(null===(o=this._renderer.dimensions)||void 0===o?void 0:o.css.canvas.height)||n*this._coreTerminal.rows,d=this._dim(this._header.width,h,a),l=this._dim(this._header.height,g,n);if(!d&&!l){const A=h/e,i=(g-n)/t,s=Math.min(A,i);return s<1?[e*s,t*s]:[e,t]}return d?!this._header.preserveAspectRatio&&d&&l?[d,l]:[d,t*d/e]:[e*l/t,l]}_dim(e,t,A){return"auto"===e?0:e.endsWith("%")?parseInt(e.slice(0,-1),10)*t/100:e.endsWith("px")?parseInt(e.slice(0,-2),10):parseInt(e,10)*A}}},389(e,t){function A(e){let t="";for(let A=0;A57)throw new Error("illegal char");t=10*t+e[A]-48}return t}function s(e){const t=A(e);if(!t.match(/^((auto)|(\d+?((px)|(%)){0,1}))$/))throw new Error("illegal size");return t}Object.defineProperty(t,"__esModule",{value:!0}),t.HeaderParser=void 0;const r={inline:i,size:i,name:function(e){if("undefined"!=typeof Buffer)return Buffer.from(A(e),"base64").toString();const t=atob(A(e)),i=new Uint8Array(t.length);for(let e=0;e14)return-1;for(let h=t;h=d)return this._a();r[s++]=t}break;case 58:return 3!==i||this._storeValue(s)?(this.state=4,h+1):this._a();default:if(s>=d)return this._a();r[s++]=t}}return this.state=i,this._position=s,-2}_a(){return this.fields.type=0,this.state=1,-1}_storeKey(e){const t=A(this._buffer.subarray(0,e));return!!t&&(this._key=t,this.fields[t]=null,!0)}_storeValue(e){if(this._key){try{const t=this._buffer.slice(0,e);this.fields[this._key]=r[this._key]?r[this._key](t):t}catch(e){return!1}return!0}return!1}}},649(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.IIPImageStorage=void 0,t.IIPImageStorage=class{constructor(e){this._storage=e,this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"iip"}}addImage(e){this._storage.addImage(e,this._addImageOpts)}}},462(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.UNSUPPORTED_TYPE=void 0,t.imageType=function(e){if(e.length<32)return t.UNSUPPORTED_TYPE;const A=new Uint32Array(e.buffer,e.byteOffset,8);if(1196314761===A[0]&&169478669===A[1]&&1380206665===A[3])return{mime:"image/png",width:e[16]<<24|e[17]<<16|e[18]<<8|e[19],height:e[20]<<24|e[21]<<16|e[22]<<8|e[23]};if(255===e[0]&&216===e[1]&&255===e[2]){const[t,A]=function(e){const t=e.length;let A=4,i=e[A]<<8|e[A+1];for(;;){if(A+=i,A>=t)return[0,0];if(255!==e[A])return[0,0];if(192===e[A+1]||194===e[A+1])return A+8>>14&16383)};case 32:return 157!==e[23]||1!==e[24]||42!==e[25]?t.UNSUPPORTED_TYPE:{mime:"image/webp",width:16383&(e[26]|e[27]<<8),height:16383&(e[28]|e[29]<<8)}}return t.UNSUPPORTED_TYPE}if(1887007846===A[1]&&(1718187617===A[2]||1936291425===A[2])){let A=-1;const i=Math.min(e.length-16,1024);for(let t=8;t0&&i>0)return{mime:"image/avif",width:t,height:i}}return t.UNSUPPORTED_TYPE}return t.UNSUPPORTED_TYPE},t.UNSUPPORTED_TYPE={mime:"unsupported",width:0,height:0}},463(e,t,A){Object.defineProperty(t,"__esModule",{value:!0}),t.ImageRenderer=void 0;const i=A(939),s=A(426);class r extends s.Disposable{get canvas(){var e;return null===(e=this._layers.get("top"))||void 0===e?void 0:e.canvas}static createCanvas(e,t,A){const i=(null!=e?e:document).createElement("canvas");return i.width=0|t,i.height=0|A,i}static createImageData(e,t,A,i){if("function"!=typeof ImageData){const s=e.createImageData(t,A);return i&&s.data.set(new Uint8ClampedArray(i,0,t*A*4)),s}return i?new ImageData(new Uint8ClampedArray(i,0,t*A*4),t,A):new ImageData(t,A)}static createImageBitmap(e){return"function"!=typeof createImageBitmap?Promise.resolve(void 0):createImageBitmap(e)}constructor(e){super(),this._terminal=e,this._layers=new Map,this._optionsRefresh=this._register(new s.MutableDisposable),this._oldOpen=this._terminal._core.open,this._terminal._core.open=e=>{var t;null===(t=this._oldOpen)||void 0===t||t.call(this._terminal._core,e),this._open()},this._terminal._core.screenElement&&this._open(),this._optionsRefresh.value=this._terminal._core.optionsService.onOptionChange(e=>{var t;"fontSize"===e&&(this.rescaleCanvas(),null===(t=this._renderService)||void 0===t||t.refreshRows(0,this._terminal.rows))}),this._register((0,s.toDisposable)(()=>{var e;this.removeLayerFromDom(),this.removeLayerFromDom("bottom"),this._terminal._core&&this._oldOpen&&(this._terminal._core.open=this._oldOpen,this._oldOpen=void 0),this._renderService&&this._oldSetRenderer&&(this._renderService.setRenderer=this._oldSetRenderer,this._oldSetRenderer=void 0),this._renderService=void 0,this._layers.clear(),null===(e=this._placeholderBitmap)||void 0===e||e.close(),this._placeholderBitmap=void 0,this._placeholder=void 0}))}showPlaceholder(e){var t,A;e?this._placeholder||-1===this.cellSize.height||this._createPlaceHolder(Math.max(this.cellSize.height+1,24)):(null===(t=this._placeholderBitmap)||void 0===t||t.close(),this._placeholderBitmap=void 0,this._placeholder=void 0),null===(A=this._renderService)||void 0===A||A.refreshRows(0,this._terminal.rows)}get dimensions(){return this._terminal.dimensions}get cellSize(){var e,t;return{width:(null===(e=this.dimensions)||void 0===e?void 0:e.css.cell.width)||-1,height:(null===(t=this.dimensions)||void 0===t?void 0:t.css.cell.height)||-1}}clearLines(e,t,A){var i,s,r,o,a;const n=e*((null===(i=this.dimensions)||void 0===i?void 0:i.css.cell.height)||0),h=(null===(s=this.dimensions)||void 0===s?void 0:s.css.canvas.width)||0,g=(t+1-e)*((null===(r=this.dimensions)||void 0===r?void 0:r.css.cell.height)||0);A&&"top"!==A||null===(o=this._layers.get("top"))||void 0===o||o.clearRect(0,n,h,g),A&&"bottom"!==A||null===(a=this._layers.get("bottom"))||void 0===a||a.clearRect(0,n,h,g)}clearAll(e){if(!e||"top"===e){const e=this._layers.get("top");null==e||e.clearRect(0,0,e.canvas.width,e.canvas.height)}if(!e||"bottom"===e){const e=this._layers.get("bottom");null==e||e.clearRect(0,0,e.canvas.width,e.canvas.height)}}draw(e,t,A,i,s=1){const r=this._layers.get(e.layer);if(!r)return;const{width:o,height:a}=this.cellSize;if(-1===o||-1===a)return;this._rescaleImage(e,o,a);const n=e.actual,h=Math.ceil(n.width/o),g=t%h*o,d=Math.floor(t/h)*a,l=A*o,I=i*a,c=s*o+g>n.width?n.width-g:s*o,_=d+a>n.height?n.height-d:a;r.drawImage(n,Math.floor(g),Math.floor(d),Math.ceil(c),Math.ceil(_),Math.floor(l),Math.floor(I),Math.ceil(c),Math.ceil(_))}extractTile(e,t){const{width:A,height:i}=this.cellSize;if(-1===A||-1===i)return;this._rescaleImage(e,A,i);const s=e.actual,o=Math.ceil(s.width/A),a=t%o*A,n=Math.floor(t/o)*i,h=A+a>s.width?s.width-a:A,g=n+i>s.height?s.height-n:i,d=r.createCanvas(this.document,h,g),l=d.getContext("2d");return l?(l.drawImage(s,Math.floor(a),Math.floor(n),Math.floor(h),Math.floor(g),0,0,Math.floor(h),Math.floor(g)),d):void 0}drawPlaceholder(e,t,A=1){var i;const s=this._layers.get("top");if(s){const{width:r,height:o}=this.cellSize;if(-1===r||-1===o)return;if(this._placeholder?o>=this._placeholder.height&&this._createPlaceHolder(o+1):this._createPlaceHolder(Math.max(o+1,24)),!this._placeholder)return;s.drawImage(null!==(i=this._placeholderBitmap)&&void 0!==i?i:this._placeholder,e*r,t*o%2?0:1,r*A,o,e*r,t*o,r*A,o)}}rescaleCanvas(){var e,t;const A=(null===(e=this.dimensions)||void 0===e?void 0:e.css.canvas.width)||0,i=(null===(t=this.dimensions)||void 0===t?void 0:t.css.canvas.height)||0;for(const e of this._layers.values())e.canvas.width===A&&e.canvas.height===i||(e.canvas.width=A,e.canvas.height=i)}_rescaleImage(e,t,A){if(t===e.actualCellSize.width&&A===e.actualCellSize.height)return;const{width:i,height:s}=e.origCellSize;if(t===i&&A===s)return e.actual=e.orig,e.actualCellSize.width=i,void(e.actualCellSize.height=s);const o=r.createCanvas(this.document,Math.ceil(e.orig.width*t/i),Math.ceil(e.orig.height*A/s)),a=o.getContext("2d");a&&(a.drawImage(e.orig,0,0,o.width,o.height),e.actual=o,e.actualCellSize.width=t,e.actualCellSize.height=A)}_open(){this._renderService=this._terminal._core._renderService,this._oldSetRenderer=this._renderService.setRenderer.bind(this._renderService),this._renderService.setRenderer=e=>{var t;for(const e of[...this._layers.keys()])this.removeLayerFromDom(e);null===(t=this._oldSetRenderer)||void 0===t||t.call(this._renderService,e)}}insertLayerToDom(e="top"){var t,A;if(!this.document||!this._terminal._core.screenElement)return void console.warn("image addon: cannot insert output canvas to DOM, missing document or screenElement");if(this._layers.has(e))return;const i=r.createCanvas(this.document,(null===(t=this.dimensions)||void 0===t?void 0:t.css.canvas.width)||0,(null===(A=this.dimensions)||void 0===A?void 0:A.css.canvas.height)||0);i.classList.add(`xterm-image-layer-${e}`);const s=this._terminal._core.screenElement;s.style.isolation="isolate","bottom"===e?(i.style.zIndex="-1",s.insertBefore(i,s.firstChild)):(i.style.zIndex="0",s.appendChild(i));const o=i.getContext("2d",{alpha:!0});o?(this._layers.set(e,o),this.clearAll(e)):i.remove()}removeLayerFromDom(e="top"){const t=this._layers.get(e);t&&(t.canvas.remove(),this._layers.delete(e))}hasLayer(e){return this._layers.has(e)}_createPlaceHolder(e=24){var t;null===(t=this._placeholderBitmap)||void 0===t||t.close(),this._placeholderBitmap=void 0;const A=32,s=r.createCanvas(this.document,A,e),o=s.getContext("2d",{alpha:!1});if(!o)return;const a=r.createImageData(o,A,e),n=new Uint32Array(a.data.buffer),h=(0,i.toRGBA8888)(0,0,0),g=(0,i.toRGBA8888)(255,255,255);n.fill(h);for(let t=0;tthis._placeholderBitmap=e)}else this._placeholder=void 0}get document(){var e;return null===(e=this._terminal._core._coreBrowserService)||void 0===e?void 0:e.window.document}}t.ImageRenderer=r},699(e,t,A){Object.defineProperty(t,"__esModule",{value:!0}),t.ImageStorage=t.CELL_SIZE_DEFAULT=void 0;const i=A(463);t.CELL_SIZE_DEFAULT={width:7,height:14};class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(e){this._ext&=-67108864,this._ext|=67108863&e}get underlineVariantOffset(){const e=(3758096384&this._ext)>>29;return e<0?4294967288^e:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}get urlId(){return this._urlId}set urlId(e){this._urlId=e}constructor(e=0,t=0,A=-1,i=-1){this.imageId=A,this.tileId=i,this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}clone(){return new s(this._ext,this._urlId,this.imageId,this.tileId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId&&-1===this.imageId}}const r=new s;t.ImageStorage=class{constructor(e,t,A){this._terminal=e,this._renderer=t,this._opts=A,this._images=new Map,this._lastId=0,this._lowestId=0,this._fullyCleared=!1,this._needsFullClear=!1,this._pixelLimit=25e5;try{this.setLimit(this._opts.storageLimit)}catch(e){e instanceof Error&&console.error(e.message),console.warn(`storageLimit is set to ${this.getLimit()} MB`)}this._viewportMetrics={cols:this._terminal.cols,rows:this._terminal.rows}}dispose(){this.reset()}reset(){var e;for(const t of this._images.values())null===(e=t.marker)||void 0===e||e.dispose();this._images.clear(),this._renderer.clearAll()}getLimit(){return 4*this._pixelLimit/1e6}setLimit(e){if(e<.5||e>1e3)throw RangeError("invalid storageLimit, should be at least 0.5 MB and not exceed 1G");this._pixelLimit=e/4*1e6>>>0,this._evictOldest(0)}getUsage(){return 4*this._getStoredPixels()/1e6}_getStoredPixels(){let e=0;for(const t of this._images.values())t.orig&&(e+=t.orig.width*t.orig.height,t.actual&&t.actual!==t.orig&&(e+=t.actual.width*t.actual.height));return e}_delImg(e){var t;const A=this._images.get(e);A&&(this._images.delete(e),window.ImageBitmap&&A.orig instanceof ImageBitmap&&A.orig.close(),null===(t=this.onImageDeleted)||void 0===t||t.call(this,e))}wipeAlternate(){var e;const t=[];for(const[A,i]of this._images.entries())"alternate"===i.bufferType&&(null===(e=i.marker)||void 0===e||e.dispose(),t.push(A));for(const e of t)this._delImg(e);this._needsFullClear=!0,this._fullyCleared=!1}deleteImage(e){var t;const A=this._images.get(e);A&&(null===(t=A.marker)||void 0===t||t.dispose(),this._delImg(e))}addImage(e,A){var i,s;this._evictOldest(e.width*e.height);let r=this._renderer.cellSize;-1!==r.width&&-1!==r.height||(r=t.CELL_SIZE_DEFAULT);const o=Math.ceil(e.width/r.width),a=Math.ceil(e.height/r.height),n=++this._lastId,h=this._terminal._core.buffer,g=this._terminal.cols,d=this._terminal.rows,l=h.x,I=h.y;let c=l,_=0;A.scrolling||(h.x=0,h.y=0,c=0),this._terminal._core._inputHandler._dirtyRowTracker.markDirty(h.y);for(let e=0;e=g);++A)this._writeToCell(t,c+A,n,e*o+A),_++;if(A.scrolling)e=d)break;h.x=c}this._terminal._core._inputHandler._dirtyRowTracker.markDirty(h.y),A.scrolling?"iip"===A.cursorPos?h.x=Math.min(c+o,g):h.x=c:(h.x=l,h.y=I);const C=[];for(const[e,t]of this._images.entries())t.tileCount<1&&(null===(i=t.marker)||void 0===i||i.dispose(),C.push(e));for(const e of C)this._delImg(e);const B=this._terminal.registerMarker(0);null==B||B.onDispose(()=>{this._images.get(n)&&this._delImg(n)}),"alternate"===this._terminal.buffer.active.type&&this._evictOnAlternate();const Q={orig:e,origCellSize:r,actual:e,actualCellSize:Object.assign({},r),marker:B||void 0,tileCount:_,bufferType:this._terminal.buffer.active.type,layer:A.layer,zIndex:A.zIndex};return this._images.set(n,Q),null===(s=this.onImageAdded)||void 0===s||s.call(this),n}render(e){var t,A;let i=!1,s=!1;for(const e of this._images.values())if("bottom"===e.layer?s=!0:i=!0,i&&s)break;if(i&&!this._renderer.hasLayer("top")&&(this._renderer.insertLayerToDom("top"),!this._renderer.hasLayer("top")))return;if(s&&!this._renderer.hasLayer("bottom")&&this._renderer.insertLayerToDom("bottom"),this._renderer.rescaleCanvas(),!this._images.size)return this._fullyCleared||(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1),this._renderer.hasLayer("top")&&this._renderer.removeLayerFromDom("top"),void(this._renderer.hasLayer("bottom")&&this._renderer.removeLayerFromDom("bottom"));!i&&this._renderer.hasLayer("top")&&(this._renderer.clearAll("top"),this._renderer.removeLayerFromDom("top")),!s&&this._renderer.hasLayer("bottom")&&(this._renderer.clearAll("bottom"),this._renderer.removeLayerFromDom("bottom")),this._needsFullClear&&(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1);const{start:o,end:a}=e,n=this._terminal._core.buffer,h=this._terminal._core.cols;this._renderer.clearLines(o,a);const g=[],d=[];for(let e=o;e<=a;++e){const i=n.lines.get(e+n.ydisp);if(!i)return;for(let s=0;se.imgSpec.zIndex-t.imgSpec.zIndex);for(const e of d)this._renderer.drawPlaceholder(e.col,e.row,e.count);for(const e of g)this._renderer.draw(e.imgSpec,e.tileId,e.col,e.row,e.count)}viewportResize(e){var t,A;if(!this._images.size)return void(this._viewportMetrics=e);if(this._viewportMetrics.cols>=e.cols)return void(this._viewportMetrics=e);const i=this._terminal._core.buffer,s=i.lines.length,o=this._viewportMetrics.cols-1;for(let a=0;a=h)continue;let g=!1;for(let t=o+1;t>e.cols;++t)if(4194303&s._data[3*t+0]){g=!0;break}if(g)continue;const d=Math.min(e.cols,h-i.tileId%h+o);let l=i.tileId;for(let e=o+1;e>>8&255)<<16|(e>>>16&255)<<8|e>>>24&255}o.set(i.PALETTE_VT340_COLOR),t.SixelHandler=class{constructor(e,t,A){this._opts=e,this._storage=t,this._coreTerminal=A,this._size=0,this._aborted=!1,(0,r.DecoderAsync)({memoryLimit:4*this._opts.pixelLimit,palette:o,paletteLimit:this._opts.sixelPaletteLimit}).then(e=>this._dec=e)}reset(){this._dec&&(this._dec.release(),this._dec._palette.fill(0),this._dec.init(0,o,this._opts.sixelPaletteLimit))}hook(e){var t;if(this._size=0,this._aborted=!1,this._dec){const A=1===e.params[1]?0:function(e,t){let A=0;if(!t)return A;if(e.isInverse())if(e.isFgDefault())A=a(t.foreground.rgba);else if(e.isFgRGB()){const t=e.constructor.toColorRGB(e.getFgColor());A=(0,i.toRGBA8888)(...t)}else A=a(t.ansi[e.getFgColor()].rgba);else if(e.isBgDefault())A=a(t.background.rgba);else if(e.isBgRGB()){const t=e.constructor.toColorRGB(e.getBgColor());A=(0,i.toRGBA8888)(...t)}else A=a(t.ansi[e.getBgColor()].rgba);return A}(this._coreTerminal._core._inputHandler._curAttrData,null===(t=this._coreTerminal._core._themeService)||void 0===t?void 0:t.colors);this._dec.init(A,null,this._opts.sixelPaletteLimit)}}put(e,t,A){if(!this._aborted&&this._dec){if(this._size+=A-t,this._size>this._opts.sixelSizeLimit)return console.warn("SIXEL: too much data, aborting"),this._aborted=!0,void this._dec.release();try{this._dec.decode(e,t,A)}catch(e){console.warn(`SIXEL: error while decoding image - ${e}`),this._aborted=!0,this._dec.release()}}}unhook(e){var t;if(this._aborted||!e||!this._dec)return!0;const A=this._dec.width,i=this._dec.height;if(!A||!i)return i&&this._storage.advanceCursor(i),!0;const r=s.ImageRenderer.createCanvas(void 0,A,i);return null===(t=r.getContext("2d"))||void 0===t||t.putImageData(new ImageData(this._dec.data8,A,i),0,0),this._dec.memoryUsage>4194304&&this._dec.release(),this._storage.addImage(r),!0}}},994(e,t,A){Object.defineProperty(t,"__esModule",{value:!0}),t.SixelImageStorage=void 0;const i=A(699);t.SixelImageStorage=class{constructor(e,t,A,i){this._storage=e,this._opts=t,this._renderer=A,this._terminal=i,this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"vt340"}}addImage(e){this._addImageOpts.scrolling=this._opts.sixelScrolling,this._storage.addImage(e,this._addImageOpts)}advanceCursor(e){if(this._opts.sixelScrolling){let t=this._renderer.cellSize;-1!==t.width&&-1!==t.height||(t=i.CELL_SIZE_DEFAULT);const A=Math.ceil(e/t.height);for(let e=1;e512)return void(this._aborted=!0);if(this._controlData.set(e.subarray(t,s),this._controlLength),this._controlLength+=r,!this._inControlData){if(this._parsedCommand=(0,a.parseKittyCommand)(this._parseControlDataString()),void 0!==this._parsedCommand.id&&void 0!==this._parsedCommand.imageNumber)return this._sendResponse(this._parsedCommand.id,"EINVAL:cannot specify both i and I keys",null!==(i=this._parsedCommand.quiet)&&void 0!==i?i:0),void(this._aborted=!0);if("d"===this._parsedCommand.action)return;const t=s+1;tthis._encodedSizeLimit){const e=null!==(n=this._activeDecoder)&&void 0!==n?n:null==g?void 0:g.decoder;return e&&e.release(),this._activeDecoder=null,g&&this._removePendingEntry(h),void(this._aborted=!0)}this._decodeError||((null==g?void 0:g.decoder)&&!this._activeDecoder&&(this._activeDecoder=g.decoder),this._activeDecoder||(this._activeDecoder=new o.default(4194304,this._maxEncodedBytes,this._initialEncodedBytes),this._activeDecoder.init()),0!==this._activeDecoder.put(e.subarray(t,A))&&(this._activeDecoder.release(),this._activeDecoder=null,this._decodeError=!0,g&&this._removePendingEntry(h)))}end(e){var t,A;if(this._aborted||!e)return this._activeDecoder&&(this._activeDecoder.release(),this._activeDecoder=null),!0;if(this._inControlData)return this._handleNoPayloadCommand();const i=this._parsedCommand;if("d"===i.action)return this._handleDelete(i);const s=null!==(A=null!==(t=i.id)&&void 0!==t?t:this._lastPendingKey)&&void 0!==A?A:0,r=1===i.more,o=this._pendingTransmissions.get(s);if(r)return this._activeDecoder&&(o?(o.totalEncodedSize+=this._totalEncodedSize,o.decodeError=o.decodeError||this._decodeError):this._pendingTransmissions.set(s,{cmd:Object.assign({},i),decoder:this._activeDecoder,totalEncodedSize:this._totalEncodedSize,decodeError:this._decodeError}),this._lastPendingKey=s,this._activeDecoder=null),!0;o&&(this._lastPendingKey=void 0);let a=this._decodeError,n=i,h=this._activeDecoder;o&&(n=o.cmd,h=o.decoder,a=a||o.decodeError,this._pendingTransmissions.delete(s));let g=new Uint8Array(0);h&&(0!==h.end()&&(a=!0),g=h.data8),this._activeDecoder=null;const d=this._handleCommandWithBytesAndCmd(n,g,a);return h&&h.release(),d}_parseControlDataString(){let e="";for(let t=0;t0&&this._sendResponse(e.id,"OK",null!==(o=e.quiet)&&void 0!==o?o:0)),i}case"T":return this._handleTransmitDisplay(e,t,A);case"q":return this._handleQuery(e,t,A);case"p":return this._handlePlacement(e);default:return void 0!==e.id&&this._sendResponse(e.id,"EINVAL:unsupported action",null!==(a=e.quiet)&&void 0!==a?a:0),!0}}_handlePlacement(e){var t;if(void 0===e.id)return!0;const A=e.id,i=this._kittyStorage.getImage(A);return i?this._displayImage(i,e).then(t=>{var i;return this._sendResponse(A,t?"OK":"EINVAL:image rendering failed",null!==(i=e.quiet)&&void 0!==i?i:0,e.placementId),!0}):(this._sendResponse(A,"ENOENT:image not found",null!==(t=e.quiet)&&void 0!==t?t:0,e.placementId),!0)}_handleTransmit(e,t,A){var i,s,r,o,a,n;return"d"!==(null!==(i=e.transmission)&&void 0!==i?i:"d")?(void 0!==e.id&&this._sendResponse(e.id,"EINVAL:unsupported transmission medium",null!==(s=e.quiet)&&void 0!==s?s:0),!0):(A||0===t.length||this._kittyStorage.storeImage(e.id,{data:new Blob([t]),width:null!==(r=e.width)&&void 0!==r?r:0,height:null!==(o=e.height)&&void 0!==o?o:0,format:null!==(a=e.format)&&void 0!==a?a:32,compression:null!==(n=e.compression)&&void 0!==n?n:""}),!0)}_handleTransmitDisplay(e,t,A){var i,s;if(A)return void 0!==e.id&&this._sendResponse(e.id,"EINVAL:invalid base64 data",null!==(i=e.quiet)&&void 0!==i?i:0),!0;this._handleTransmit(e,t,A);const r=null!==(s=e.id)&&void 0!==s?s:this._kittyStorage.lastImageId,o=this._kittyStorage.getImage(r);if(o){const t=this._displayImage(o,e);return void 0!==e.id?t.then(t=>{var A;return this._sendResponse(r,t?"OK":"EINVAL:image rendering failed",null!==(A=e.quiet)&&void 0!==A?A:0),!0}):t.then(()=>!0)}return!0}_handleQuery(e,t,A){var i,s,r,o,a,n;const h=null!==(i=e.id)&&void 0!==i?i:0,g=null!==(s=e.quiet)&&void 0!==s?s:0;if("d"!==(null!==(r=e.transmission)&&void 0!==r?r:"d"))return this._sendResponse(h,"EINVAL:unsupported transmission medium",g),!0;if(A)return this._sendResponse(h,"EINVAL:invalid base64 data",g),!0;if(0===t.length)return this._sendResponse(h,"OK",g),!0;const d=null!==(o=e.format)&&void 0!==o?o:32;if(100===d)this._sendResponse(h,"OK",g);else{const A=null!==(a=e.width)&&void 0!==a?a:0,i=null!==(n=e.height)&&void 0!==n?n:0;if(!A||!i)return this._sendResponse(h,"EINVAL:width and height required for raw pixel data",g),!0;const s=A*i*(32===d?4:3);if(t.length=1)return;if(!s&&A>=2)return;const r=`_Gi=${e}${i?`,p=${i}`:""};${t}\\`;this._coreTerminal._core.coreService.triggerDataEvent(r)}_displayImage(e,t){return this._decodeAndDisplay(e,t).then(()=>!0).catch(()=>!1)}async _decodeAndDisplay(e,t){var A,i,o,a,n,h,g;let d=await this._createBitmap(e);try{const l=Math.max(0,null!==(o=t.x)&&void 0!==o?o:0),I=Math.max(0,null!==(a=t.y)&&void 0!==a?a:0),c=t.sourceWidth||d.width-l,_=t.sourceHeight||d.height-I,C=Math.max(0,d.width-l),B=Math.max(0,d.height-I),Q=Math.max(0,Math.min(c,C)),m=Math.max(0,Math.min(_,B));if(0===Q||0===m)throw new Error("invalid source rectangle");if(0!==l||0!==I||Q!==d.width||m!==d.height){const e=await createImageBitmap(d,l,I,Q,m);d.close(),d=e}const E=(null===(A=this._renderer.dimensions)||void 0===A?void 0:A.css.cell.width)||r.CELL_SIZE_DEFAULT.width,u=(null===(i=this._renderer.dimensions)||void 0===i?void 0:i.css.cell.height)||r.CELL_SIZE_DEFAULT.height;let w,p;void 0!==t.columns&&void 0!==t.rows?(w=t.columns,p=t.rows):void 0!==t.columns?(w=t.columns,p=Math.max(1,Math.ceil(d.height/d.width*(w*E)/u))):void 0!==t.rows?(p=t.rows,w=Math.max(1,Math.ceil(d.width/d.height*(p*u)/E))):(w=Math.ceil(d.width/E),p=Math.ceil(d.height/u));let f=d.width,D=d.height;if(void 0===t.columns&&void 0===t.rows||(f=Math.round(w*E),D=Math.round(p*u)),f*D>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");const y=this._coreTerminal._core.buffer,v=y.x,k=y.y,M=y.ybase,b=void 0!==t.zIndex&&t.zIndex<0?"bottom":"top";if(f!==d.width||D!==d.height){const e=await createImageBitmap(d,{resizeWidth:f,resizeHeight:D});d.close(),d=e}const S=Math.min(Math.max(0,null!==(n=t.xOffset)&&void 0!==n?n:0),E-1),L=Math.min(Math.max(0,null!==(h=t.yOffset)&&void 0!==h?h:0),u-1);if(0!==S||0!==L){const e=void 0!==t.columns?Math.round(w*E):d.width+S,A=void 0!==t.rows?Math.round(p*u):d.height+L,i=s.ImageRenderer.createCanvas(window.document,e,A),r=i.getContext("2d");if(!r)throw new Error("Failed to create offset canvas context");r.drawImage(d,S,L);const o=await createImageBitmap(i);if(i.width=i.height=0,d.close(),d=o,f=d.width,D=d.height,f*D>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");void 0===t.columns&&(w=Math.ceil(d.width/E)),void 0===t.rows&&(p=Math.ceil(d.height/u))}const N=null!==(g=t.zIndex)&&void 0!==g?g:0;if(this._kittyStorage.addImage(e.id,d,!0,b,N),d=void 0,1===t.cursorMovement){const e=y.ybase-M;y.x=v,y.y=Math.max(k-e,0)}else y.x=Math.min(v+w,this._coreTerminal.cols)}catch(e){throw null==d||d.close(),e}}async _createBitmap(e){let t=new Uint8Array(await e.data.arrayBuffer());if("z"===e.compression&&(t=await this._decompressZlib(t)),100===e.format){const e=new Blob([t],{type:"image/png"});if(!window.createImageBitmap){const t=URL.createObjectURL(e),A=new Image;return new Promise((e,i)=>{A.addEventListener("load",()=>{var r;URL.revokeObjectURL(t);const o=s.ImageRenderer.createCanvas(window.document,A.width,A.height);null===(r=o.getContext("2d"))||void 0===r||r.drawImage(A,0,0),createImageBitmap(o).then(e).catch(i)}),A.addEventListener("error",()=>{URL.revokeObjectURL(t),i(new Error("Failed to load image"))}),A.src=t})}return createImageBitmap(e)}const A=e.width,i=e.height;if(!A||!i)throw new Error("Width and height required for raw pixel data");const r=A*i*(32===e.format?4:3);if(t.length>>24|t<<8,h[l++]=4278190080|t>>>16|A<<16,h[l++]=4278190080|A>>>8}let I=3*g,c=4*g;for(let e=g;ee+t.length,0),a=new Uint8Array(o);let n=0;for(const e of s)a.set(e,n),n+=e.length;return a}get images(){return this._kittyStorage.images}get _kittyIdToStorageId(){return this._kittyStorage.kittyIdToStorageId}get pendingTransmissions(){return this._pendingTransmissions}}},470(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.parseKittyCommand=function(e){const t={},A=e.split(",");for(const e of A){const A=e.indexOf("=");if(-1===A)continue;const i=e.substring(0,A),s=e.substring(A+1);if("a"===i){t.action=s;continue}if("o"===i){t.compression=s;continue}if("t"===i){t.transmission=s;continue}if("d"===i){t.deleteSelector=s;continue}const r=parseInt(s,10);switch(i){case"f":t.format=r;break;case"i":t.id=r;break;case"I":t.imageNumber=r;break;case"s":t.width=r;break;case"v":t.height=r;break;case"x":t.x=r;break;case"y":t.y=r;break;case"w":t.sourceWidth=r;break;case"h":t.sourceHeight=r;break;case"X":t.xOffset=r;break;case"Y":t.yOffset=r;break;case"c":t.columns=r;break;case"r":t.rows=r;break;case"m":t.more=r;break;case"q":t.quiet=r;break;case"C":t.cursorMovement=r;break;case"z":t.zIndex=r;break;case"p":t.placementId=r}}return t}},235(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.KittyImageStorage=void 0;class A{constructor(e){this._storage=e,this._nextImageId=1,this._images=new Map,this._kittyIdToStorageId=new Map,this._storageIdToKittyId=new Map,this._handleStorageImageDeleted=e=>{const t=this._storageIdToKittyId.get(e);void 0!==t&&(this._kittyIdToStorageId.delete(t),this._storageIdToKittyId.delete(e),this._images.delete(t))},this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"iip"},this._previousOnImageDeleted=this._storage.onImageDeleted,this._wrappedOnImageDeleted=e=>{var t;null===(t=this._previousOnImageDeleted)||void 0===t||t.call(this,e),this._handleStorageImageDeleted(e)},this._storage.onImageDeleted=this._wrappedOnImageDeleted}reset(){this._nextImageId=1,this._images.clear(),this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}dispose(){this.reset(),this._storage.onImageDeleted===this._wrappedOnImageDeleted&&(this._storage.onImageDeleted=this._previousOnImageDeleted)}storeImage(e,t){const i=null!=e?e:this._nextImageId++,s=this._kittyIdToStorageId.get(i);return void 0!==s&&(this._storage.deleteImage(s),this._kittyIdToStorageId.delete(i),this._storageIdToKittyId.delete(s)),!this._images.has(i)&&this._images.size>=A._maxStoredImages&&this._evictUndisplayedImages(),this._images.set(i,Object.assign(Object.assign({},t),{id:i})),i}addImage(e,t,A,i,s){const r=this._kittyIdToStorageId.get(e);void 0!==r&&this._storageIdToKittyId.delete(r),this._addImageOpts.scrolling=A,this._addImageOpts.layer=i,this._addImageOpts.zIndex=s;const o=this._storage.addImage(t,this._addImageOpts);this._kittyIdToStorageId.set(e,o),this._storageIdToKittyId.set(o,e)}getImage(e){return this._images.get(e)}deleteById(e){this._images.delete(e);const t=this._kittyIdToStorageId.get(e);void 0!==t&&(this._storage.deleteImage(t),this._kittyIdToStorageId.delete(e),this._storageIdToKittyId.delete(t))}deleteAll(){this._images.clear();for(const e of this._kittyIdToStorageId.values())this._storage.deleteImage(e);this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}get images(){return this._images}get kittyIdToStorageId(){return this._kittyIdToStorageId}get lastImageId(){return this._nextImageId-1}_evictUndisplayedImages(){for(const[e]of this._images){if(this._images.size<=A._maxStoredImages/2)break;this._kittyIdToStorageId.has(e)||this._images.delete(e)}}}t.KittyImageStorage=A,A._maxStoredImages=256},669(e,t,A){Object.defineProperty(t,"__esModule",{value:!0});const i=(0,A(552).InWasm)({s:1,t:0,d:"AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML"}),s=new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map(e=>e.charCodeAt(0))),r=new Uint32Array(1024);r.fill(4278190080);for(let e=0;e>4|(e<<4&255)<<8;for(let e=0;e>2<<8|(e<<6&255)<<16;for(let e=0;ethis.maxBytes||this.maxBytes>4294901760)throw new Error("invalid byte settings")}get data8(){return this._inst?this._d.subarray(0,this._m32[1282]):o}release(){this._inst&&(this._bytes>this.keepSize?this._inst=this._m32=this._d=this._mem=null:(this._m32[1280]=0,this._m32[1281]=0,this._m32[1282]=0))}init(e,t){if(this.maxBytes=null!=e?e:this.maxBytes,this._bytes=null!=t?t:Math.min(this._bytes,this.maxBytes),this._bytes>this.maxBytes||this.maxBytes>4294901760)throw Error("invalid byte settings");let A=this._m32;const s=this._bytes+5152;this._inst?this._mem.buffer.byteLengththis.maxBytes)return-3;let e=this._bytes;for(;(e*=2)this._mem.buffer.byteLength){const t=Math.ceil((e+5152-this._mem.buffer.byteLength)/65536);this._mem.grow(t),this._m32=new Uint32Array(this._mem.buffer,0),this._d=new Uint8Array(this._mem.buffer,5152)}this._bytes=e}return 0}put(e){if(!this._inst||this._ended)return-2;if(this._realloc(e.length))return-3;const t=this._m32;return this._d.set(e,t[1280]),t[1280]+=e.length,t[1280]-t[1281]>=131072?this._inst.exports.dec():0}end(){return this._ended=!0,this._inst?this._inst.exports.end():-2}get loadedBytes(){return this._inst?this._m32[1280]:0}get freeBytes(){return this._inst?this.maxBytes-this._m32[1280]:0}}},61(e,t,A){Object.defineProperty(t,"__esModule",{value:!0});const i=(0,A(552).InWasm)({s:1,t:0,d:"AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=="});t.default=class{constructor(e){this.keepSize=e,this.width=0,this.height=0}decode(e){this.width=e[4]<<24|e[5]<<16|e[6]<<8|e[7],this.height=e[8]<<24|e[9]<<16|e[10]<<8|e[11];const t=this.width*this.height,A=4*t,s=e.length,r=Math.max(A,s)+(Math.min(A,s)>>1)+4096;this._inst?this._mem.buffer.byteLengththis.keepSize&&(this._inst=this._d=this._mem=null)}}},552(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.InWasm=function(e){if(e.d){const{t,s:i,d:s}=e;let r,o;const a=WebAssembly;return 0===t?i?e=>new a.Instance(o||(o=new a.Module(r||(r=A(s)))),e):e=>o?a.instantiate(o,e):a.instantiate(r||(r=A(s)),e).then(e=>(o=e.module)&&e.instance):1===t?i?()=>o||(o=new a.Module(r||(r=A(s)))):()=>o?Promise.resolve(o):a.compile(r||(r=A(s))).then(e=>o=e):i?()=>r||(r=A(s)):()=>Promise.resolve(r||(r=A(s)))}if("undefined"==typeof _wasmCtx)throw new Error('must run "inwasm"');_wasmCtx.add(e)};let A=e=>{if(Uint8Array.fromBase64)return Uint8Array.fromBase64(e);if("undefined"!=typeof Buffer)return Buffer.from(e,"base64");const t=atob(e),A=new Uint8Array(t.length);for(let e=0;e{var e=i;Object.defineProperty(e,"__esModule",{value:!0}),e.ImageAddon=void 0;const t=A(414),s=A(795),r=A(463),o=A(699),a=A(23),n=A(235),h=A(566),g=A(994),d=A(649),l={enableSizeReports:!0,pixelLimit:16777216,sixelSupport:!0,sixelScrolling:!0,sixelPaletteLimit:4096,sixelSizeLimit:33554432,storageLimit:128,showPlaceholder:!0,iipSupport:!0,iipSizeLimit:33554432,kittySupport:!0,kittySizeLimit:33554432};e.ImageAddon=class{constructor(e){this._disposables=[],this._handlers=new Map,this._onImageAdded=new t.Emitter,this.onImageAdded=this._onImageAdded.event,this._opts=Object.assign({},l,e),this._defaultOpts=Object.assign({},l,e)}dispose(){for(const e of this._disposables)e.dispose();this._disposables.length=0,this._handlers.clear(),this._onImageAdded.dispose()}_disposeLater(...e){for(const t of e)this._disposables.push(t)}activate(e){var t;if(this._terminal=e,this._renderer=new r.ImageRenderer(e),this._storage=new o.ImageStorage(e,this._renderer,this._opts),this._storage.onImageAdded=()=>this._onImageAdded.fire(),this._opts.enableSizeReports){const A=null!==(t=e.options.windowOptions)&&void 0!==t?t:{};A.getWinSizePixels=!0,A.getCellSizePixels=!0,A.getWinSizeChars=!0,e.options.windowOptions=A}if(this._disposeLater(this._renderer,this._storage,e.parser.registerCsiHandler({prefix:"?",final:"h"},e=>this._decset(e)),e.parser.registerCsiHandler({prefix:"?",final:"l"},e=>this._decrst(e)),e.parser.registerCsiHandler({final:"c"},e=>this._da1(e)),e.parser.registerCsiHandler({prefix:"?",final:"S"},e=>this._xtermGraphicsAttributes(e)),e.onRender(e=>{var t;return null===(t=this._storage)||void 0===t?void 0:t.render(e)}),e.parser.registerCsiHandler({intermediates:"!",final:"p"},()=>this.reset()),e.parser.registerEscHandler({final:"c"},()=>this.reset()),e._core._inputHandler.onRequestReset(()=>this.reset()),e.buffer.onBufferChange(()=>{var e;return null===(e=this._storage)||void 0===e?void 0:e.wipeAlternate()}),e.onResize(e=>{var t;return null===(t=this._storage)||void 0===t?void 0:t.viewportResize(e)})),this._opts.sixelSupport){const t=new g.SixelImageStorage(this._storage,this._opts,this._renderer,e),A=new h.SixelHandler(this._opts,t,e);this._handlers.set("sixel",A),this._disposeLater(e._core._inputHandler._parser.registerDcsHandler({final:"q"},A))}if(this._opts.iipSupport){const t=new d.IIPImageStorage(this._storage),A=new s.IIPHandler(this._opts,this._renderer,t,e);this._handlers.set("iip",A),this._disposeLater(e._core._inputHandler._parser.registerOscHandler(1337,A))}if(this._opts.kittySupport){const t=new n.KittyImageStorage(this._storage),A=new a.KittyGraphicsHandler(this._opts,this._renderer,t,e);this._handlers.set("kitty",A),this._disposeLater(t,A,e._core._inputHandler._parser.registerApcHandler({final:"G"},A))}}reset(){var e;this._opts.sixelScrolling=this._defaultOpts.sixelScrolling,this._opts.sixelPaletteLimit=this._defaultOpts.sixelPaletteLimit,null===(e=this._storage)||void 0===e||e.reset();for(const e of this._handlers.values())e.reset();return!1}get storageLimit(){var e;return(null===(e=this._storage)||void 0===e?void 0:e.getLimit())||-1}set storageLimit(e){var t;null===(t=this._storage)||void 0===t||t.setLimit(e),this._opts.storageLimit=e}get storageUsage(){return this._storage?this._storage.getUsage():-1}get showPlaceholder(){return this._opts.showPlaceholder}set showPlaceholder(e){var t;this._opts.showPlaceholder=e,null===(t=this._renderer)||void 0===t||t.showPlaceholder(e)}getImageAtBufferCell(e,t){var A;return null===(A=this._storage)||void 0===A?void 0:A.getImageAtBufferCell(e,t)}extractTileAtBufferCell(e,t){var A;return null===(A=this._storage)||void 0===A?void 0:A.extractTileAtBufferCell(e,t)}_report(e){var t;null===(t=this._terminal)||void 0===t||t._core.input(e,!1)}_decset(e){for(let t=0;t2&&!(e[2]instanceof Array)&&e[2]<=4096?(this._opts.sixelPaletteLimit=e[2],this._report(`[?${e[0]};0;${this._opts.sixelPaletteLimit}S`)):this._report(`[?${e[0]};2S`),!0;case 4:return this._report(`[?${e[0]};0;4096S`),!0;default:return this._report(`[?${e[0]};2S`),!0}if(2===e[0])switch(e[1]){case 1:let n=null===(A=null===(t=this._renderer)||void 0===t?void 0:t.dimensions)||void 0===A?void 0:A.css.canvas.width,h=null===(s=null===(i=this._renderer)||void 0===i?void 0:i.dimensions)||void 0===s?void 0:s.css.canvas.height;if(!n||!h){const e=o.CELL_SIZE_DEFAULT;n=((null===(r=this._terminal)||void 0===r?void 0:r.cols)||80)*e.width,h=((null===(a=this._terminal)||void 0===a?void 0:a.rows)||24)*e.height}if(n*h(()=>{"use strict";var e={939(e,t){function i(e){return 255&e}function A(e){return e>>>8&255}function s(e){return e>>>16&255}function r(e,t,i,A=255){return((255&A)<<24|(255&i)<<16|(255&t)<<8|255&e)>>>0}function o(e,t,i){return Math.max(e,Math.min(i,t))}function a(e,t,i){return i<0&&(i+=1),i>1&&(i-=1),6*i<1?t+6*(e-t)*i:2*i<1?e:3*i<2?t+(e-t)*(4-6*i):t}function n(e,t,i){return(4278190080|Math.round(i/100*255)<<16|Math.round(t/100*255)<<8|Math.round(e/100*255))>>>0}Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_FOREGROUND=t.DEFAULT_BACKGROUND=t.PALETTE_ANSI_256=t.PALETTE_VT340_GREY=t.PALETTE_VT340_COLOR=t.normalizeHLS=t.normalizeRGB=t.nearestColorIndex=t.fromRGBA8888=t.toRGBA8888=t.alpha=t.blue=t.green=t.red=t.BIG_ENDIAN=void 0,t.BIG_ENDIAN=255===new Uint8Array(new Uint32Array([4278190080]).buffer)[0],t.BIG_ENDIAN&&console.warn("BE platform detected. This version of node-sixel works only on LE properly."),t.red=i,t.green=A,t.blue=s,t.alpha=function(e){return e>>>24&255},t.toRGBA8888=r,t.fromRGBA8888=function(e){return[255&e,e>>8&255,e>>16&255,e>>>24]},t.nearestColorIndex=function(e,t){const r=i(e),o=A(e),a=s(e);let n=Number.MAX_SAFE_INTEGER,h=-1;for(let e=0;e{const e=[r(0,0,0),r(205,0,0),r(0,205,0),r(205,205,0),r(0,0,238),r(205,0,205),r(0,250,205),r(229,229,229),r(127,127,127),r(255,0,0),r(0,255,0),r(255,255,0),r(92,92,255),r(255,0,255),r(0,255,255),r(255,255,255)],t=[0,95,135,175,215,255];for(let i=0;i<6;++i)for(let A=0;A<6;++A)for(let s=0;s<6;++s)e.push(r(t[i],t[A],t[s]));for(let t=8;t<=238;t+=10)e.push(r(t,t,t));return new Uint32Array(e)})(),t.DEFAULT_BACKGROUND=r(0,0,0,255),t.DEFAULT_FOREGROUND=r(255,255,255,255)},591(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.decodeAsync=t.decode=t.Decoder=t.DecoderAsync=void 0;const A=i(939),s=i(199),r=function(e){if("undefined"!=typeof Buffer)return Buffer.from(e,"base64");const t=atob(e),i=new Uint8Array(t.length);for(let e=0;e1,this.modeHandler=e=>1}handle_band(e){return this.bandHandler(e)}mode_parsed(e){return this.modeHandler(e)}}const h={memoryLimit:134217728,sixelColor:A.DEFAULT_FOREGROUND,fillColor:A.DEFAULT_BACKGROUND,palette:A.PALETTE_VT340_COLOR,paletteLimit:s.LIMITS.PALETTE_SIZE,truncate:!0};function g(e){const t=new n,i={env:{handle_band:t.handle_band.bind(t),mode_parsed:t.mode_parsed.bind(t)}};return WebAssembly.instantiate(o||r,i).then(i=>(o=o||i.module,new d(e,i.instance||i,t)))}t.DecoderAsync=g;class d{constructor(e,t,i){if(this._PIXEL_OFFSET=s.LIMITS.MAX_WIDTH+4,this._canvas=a,this._bandWidths=[],this._maxWidth=0,this._minWidth=s.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0,this._opts=Object.assign({},h,e),this._opts.paletteLimit>s.LIMITS.PALETTE_SIZE)throw new Error(`DecoderOptions.paletteLimit must not exceed ${s.LIMITS.PALETTE_SIZE}`);if(t)i.bandHandler=this._handle_band.bind(this),i.modeHandler=this._initCanvas.bind(this);else{const e=o||(o=new WebAssembly.Module(r));t=new WebAssembly.Instance(e,{env:{handle_band:this._handle_band.bind(this),mode_parsed:this._initCanvas.bind(this)}})}this._instance=t,this._wasm=this._instance.exports,this._chunk=new Uint8Array(this._wasm.memory.buffer,this._wasm.get_chunk_address(),s.LIMITS.CHUNK_SIZE),this._states=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_state_address(),12),this._palette=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_palette_address(),s.LIMITS.PALETTE_SIZE),this._palette.set(this._opts.palette),this._pSrc=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_p0_address()),this._wasm.init(A.DEFAULT_FOREGROUND,0,this._opts.paletteLimit,0)}get _fillColor(){return this._states[0]}get _truncate(){return this._states[8]}get _rasterWidth(){return this._states[6]}get _rasterHeight(){return this._states[7]}get _width(){return this._states[2]?this._states[2]-4:0}get _height(){return this._states[3]}get _level(){return this._states[9]}get _mode(){return this._states[10]}get _paletteLimit(){return this._states[11]}_initCanvas(e){if(2===e){const e=this.width*this.height;if(e>this._canvas.length){if(this._opts.memoryLimit&&4*e>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(e)}this._maxWidth=this._width}else if(1===e)if(2===this._level){const e=Math.min(this._rasterWidth,s.LIMITS.MAX_WIDTH)*this._rasterHeight;if(e>this._canvas.length){if(this._opts.memoryLimit&&4*e>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(e)}}else this._canvas.length<65536&&(this._canvas=new Uint32Array(65536));return 0}_realloc(e,t){const i=e+t;if(i>this._canvas.length){if(this._opts.memoryLimit&&4*i>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");const e=new Uint32Array(65536*Math.ceil(i/65536));e.set(this._canvas),this._canvas=e}}_handle_band(e){const t=this._PIXEL_OFFSET;let i=this._lastOffset;if(2===this._mode){let A=this.height-this._currentHeight,s=0;for(;s<6&&A>0;)this._canvas.set(this._pSrc.subarray(t*s,t*s+e),i+e*s),s++,A--;this._lastOffset+=e*s,this._currentHeight+=s}else if(1===this._mode){this._realloc(i,6*e),this._maxWidth=Math.max(this._maxWidth,e),this._minWidth=Math.min(this._minWidth,e);for(let A=0;A<6;++A)this._canvas.set(this._pSrc.subarray(t*A,t*A+e),i+e*A);this._bandWidths.push(e),this._lastOffset+=6*e,this._currentHeight+=6}return 0}get width(){return 1!==this._mode?this._width:Math.max(this._maxWidth,this._wasm.current_width())}get height(){return 1!==this._mode?this._height:this._wasm.current_width()?6*this._bandWidths.length+this._wasm.current_height():6*this._bandWidths.length}get palette(){return this._palette.subarray(0,this._paletteLimit)}get memoryUsage(){return this._canvas.byteLength+this._wasm.memory.buffer.byteLength+8*this._bandWidths.length}get properties(){return{width:this.width,height:this.height,mode:this._mode,level:this._level,truncate:!!this._truncate,paletteLimit:this._paletteLimit,fillColor:this._fillColor,memUsage:this.memoryUsage,rasterAttributes:{numerator:this._states[4],denominator:this._states[5],width:this._rasterWidth,height:this._rasterHeight}}}init(e=this._opts.fillColor,t=this._opts.palette,i=this._opts.paletteLimit,A=this._opts.truncate){this._wasm.init(this._opts.sixelColor,e,i,A?1:0),t&&this._palette.set(t.subarray(0,s.LIMITS.PALETTE_SIZE)),this._bandWidths.length=0,this._maxWidth=0,this._minWidth=s.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0}decode(e,t=0,i=e.length){let A=t;for(;A0){const i=this._PIXEL_OFFSET;let A=this._lastOffset,s=0;for(;s<6&&t>0;)this._canvas.set(this._pSrc.subarray(i*s,i*s+e),A+e*s),s++,t--;t&&this._canvas.fill(this._fillColor,A+e*s)}return this._canvas.subarray(0,this.width*this.height)}if(1===this._mode){if(this._minWidth===this._maxWidth){let t=!1;if(e)if(e!==this._minWidth)t=!0;else{const t=this._PIXEL_OFFSET;let i=this._lastOffset;this._realloc(i,6*e);for(let A=0;A<6;++A)this._canvas.set(this._pSrc.subarray(t*A,t*A+e),i+e*A)}if(!t)return this._canvas.subarray(0,this.width*this.height)}const t=new Uint32Array(this.width*this.height);t.fill(this._fillColor);let i=0,A=0;for(let e=0;e{if(this._disposed)return(0,A.toDisposable)(()=>{});const s={fn:e,thisArgs:t};this._listeners=this._listeners.slice(),this._listeners.push(s);const r=(0,A.toDisposable)(()=>{const e=this._listeners.indexOf(s);-1!==e&&(this._listeners=this._listeners.slice(),this._listeners.splice(e,1))});return i&&(Array.isArray(i)?i.push(r):i.add(r)),r}),this._event}fire(e){if(this._disposed||!this._listeners.length)return;if(1===this._listeners.length)return void this._listeners[0].fn.call(this._listeners[0].thisArgs,e);const t=this._listeners;for(let i=0,A=t.length;it.fire(e))},e.map=function(e,t){return(i,A,s)=>e(e=>i.call(A,t(e)),void 0,s)},e.any=function(...e){return(t,i,s)=>{const r=new A.DisposableStore;for(const A of e)r.add(A(e=>t.call(i,e)));return s&&(Array.isArray(s)?s.push(r):s.add(r)),r}},e.runAndSubscribe=function(e,t,i){return t(i),e(e=>t(e))}}(s||(t.EventUtils=s={}))},426(e,t){function i(e){return{dispose:e}}function A(e){if(!e)return e;if(Array.isArray(e)){for(const t of e)t.dispose();return[]}return e.dispose(),e}Object.defineProperty(t,"__esModule",{value:!0}),t.MutableDisposable=t.Disposable=t.DisposableStore=void 0,t.toDisposable=i,t.dispose=A,t.combinedDisposable=function(...e){return i(()=>A(e))};class s{constructor(){this._disposables=new Set,this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(e){return this._isDisposed?e.dispose():this._disposables.add(e),e}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(const e of this._disposables)e.dispose();this._disposables.clear()}}clear(){for(const e of this._disposables)e.dispose();this._disposables.clear()}}t.DisposableStore=s;class r{constructor(){this._store=new s}dispose(){this._store.dispose()}_register(e){return this._store.add(e)}}t.Disposable=r,r.None=Object.freeze({dispose(){}}),t.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}}},795(e,t,i){var A=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0}),t.IIPHandler=void 0;const s=i(463),r=i(699),o=A(i(669)),a=A(i(61)),n=i(389),h=i(462),g={type:0,name:"Unnamed file",size:0,width:"auto",height:"auto",preserveAspectRatio:1,inline:0};t.IIPHandler=class{constructor(e,t,i,A){this._opts=e,this._renderer=t,this._storage=i,this._coreTerminal=A,this._generation=0,this._aborted=!1,this._hp=new n.HeaderParser,this._header=g,this._isMultipart=!1,this._abortMulti=!1;const s=Math.ceil(4*this._opts.iipSizeLimit/3),r=Math.min(1048576,s);this._dec=new o.default(4194304,s,r),this._qoiDec=new a.default(4194304)}reset(){this._generation++,this._hp.reset(),this._dec.release(),this._qoiDec.release()}start(){this._aborted=!1,this._hp.reset()}put(e,t,i){if(!this._aborted)if(4===this._hp.state)0!==this._dec.put(e.subarray(t,i))&&(this._dec.release(),this._aborted=!0);else{const A=this._hp.parse(e,t,i);if(-1===A)return void(this._aborted=!0);if(A>0){if(1===this._hp.fields.type){if(this._isMultipart&&(this._isMultipart=!1,this._abortMulti=!1,this._dec.release()),this._header=Object.assign({},g,this._hp.fields),!this._header.inline)return void(this._aborted=!0);this._dec.init()}else if(this._abortMulti)return void(this._aborted=!0);0!==this._dec.put(e.subarray(A,i))&&(this._dec.release(),this._aborted=!0,this._isMultipart&&(this._abortMulti=!0))}}}end(e){var t,i,A;if(this._aborted)return!0;if(4!==this._hp.state&&this._hp.end())return!0;const o=this._hp.fields.type;if(3===o)return!0;if(5===o){let e=r.CELL_SIZE_DEFAULT.width,i=r.CELL_SIZE_DEFAULT.height;this._renderer.dimensions&&(e=this._renderer.dimensions.css.canvas.width/this._coreTerminal.cols,i=this._renderer.dimensions.css.canvas.height/this._coreTerminal.rows);const s=null!==(A=null===(t=this._coreTerminal._core._coreBrowserService)||void 0===t?void 0:t.dpr)&&void 0!==A?A:1,o=`]1337;ReportCellSize=${i.toFixed(3)};${e.toFixed(3)};${s.toFixed(3)}\\`;return this._coreTerminal.input(o,!1),!0}if(2===o)return this._header=Object.assign({},g,this._hp.fields),this._isMultipart=!0,this._abortMulti=!1,this._dec.release(),this._dec.init(),!0;if(4===o){if(!this._isMultipart)return!0;if(this._isMultipart=!1,this._abortMulti||2!==this._header.type)return!0}let a,n,d=0,l=0,I=h.UNSUPPORTED_TYPE;if((a=e)&&((a=!this._dec.end())?(I=(0,h.imageType)(this._dec.data8),(a="unsupported"!==I.mime)?(d=I.width,l=I.height,(a=d&&l&&d*lc!==this._generation?(e.close(),!0):(this._storage.addImage(e),!0)).catch(e=>(console.warn(`IIP: decoding error ${I.mime} ${I.width}x${I.height}`,e),!0))}_resize(e,t){var i,A,s,o;const a=(null===(i=this._renderer.dimensions)||void 0===i?void 0:i.css.cell.width)||r.CELL_SIZE_DEFAULT.width,n=(null===(A=this._renderer.dimensions)||void 0===A?void 0:A.css.cell.height)||r.CELL_SIZE_DEFAULT.height,h=(null===(s=this._renderer.dimensions)||void 0===s?void 0:s.css.canvas.width)||a*this._coreTerminal.cols,g=(null===(o=this._renderer.dimensions)||void 0===o?void 0:o.css.canvas.height)||n*this._coreTerminal.rows,d=this._dim(this._header.width,h,a),l=this._dim(this._header.height,g,n);if(!d&&!l){const i=h/e,A=(g-n)/t,s=Math.min(i,A);return s<1?[e*s,t*s]:[e,t]}return d?!this._header.preserveAspectRatio&&d&&l?[d,l]:[d,t*d/e]:[e*l/t,l]}_dim(e,t,i){return"auto"===e?0:e.endsWith("%")?parseInt(e.slice(0,-1),10)*t/100:e.endsWith("px")?parseInt(e.slice(0,-2),10):parseInt(e,10)*i}}},389(e,t){function i(e){let t="";for(let i=0;i57)throw new Error("illegal char");t=10*t+e[i]-48}return t}function s(e){const t=i(e);if(!t.match(/^((auto)|(\d+?((px)|(%)){0,1}))$/))throw new Error("illegal size");return t}Object.defineProperty(t,"__esModule",{value:!0}),t.HeaderParser=void 0;const r={inline:A,size:A,name:function(e){if("undefined"!=typeof Buffer)return Buffer.from(i(e),"base64").toString();const t=atob(i(e)),A=new Uint8Array(t.length);for(let e=0;e14)return-1;for(let h=t;h=d)return this._a();r[s++]=t}break;case 58:return 3!==A||this._storeValue(s)?(this.state=4,h+1):this._a();default:if(s>=d)return this._a();r[s++]=t}}return this.state=A,this._position=s,-2}_a(){return this.fields.type=0,this.state=1,-1}_storeKey(e){const t=i(this._buffer.subarray(0,e));return!!t&&(this._key=t,this.fields[t]=null,!0)}_storeValue(e){if(this._key){try{const t=this._buffer.slice(0,e);this.fields[this._key]=r[this._key]?r[this._key](t):t}catch(e){return!1}return!0}return!1}}},649(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.IIPImageStorage=void 0,t.IIPImageStorage=class{constructor(e){this._storage=e,this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"iip"}}addImage(e){this._storage.addImage(e,this._addImageOpts)}}},462(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.UNSUPPORTED_TYPE=void 0,t.imageType=function(e){if(e.length<32)return t.UNSUPPORTED_TYPE;const i=new Uint32Array(e.buffer,e.byteOffset,8);if(1196314761===i[0]&&169478669===i[1]&&1380206665===i[3])return{mime:"image/png",width:e[16]<<24|e[17]<<16|e[18]<<8|e[19],height:e[20]<<24|e[21]<<16|e[22]<<8|e[23]};if(255===e[0]&&216===e[1]&&255===e[2]){const[t,i]=function(e){const t=e.length;let i=4,A=e[i]<<8|e[i+1];for(;;){if(i+=A,i>=t)return[0,0];if(255!==e[i])return[0,0];if(192===e[i+1]||194===e[i+1])return i+8>>14&16383)};case 32:return 157!==e[23]||1!==e[24]||42!==e[25]?t.UNSUPPORTED_TYPE:{mime:"image/webp",width:16383&(e[26]|e[27]<<8),height:16383&(e[28]|e[29]<<8)}}return t.UNSUPPORTED_TYPE}if(1887007846===i[1]&&(1718187617===i[2]||1936291425===i[2])){let i=-1;const A=Math.min(e.length-16,1024);for(let t=8;t0&&A>0)return{mime:"image/avif",width:t,height:A}}return t.UNSUPPORTED_TYPE}return t.UNSUPPORTED_TYPE},t.UNSUPPORTED_TYPE={mime:"unsupported",width:0,height:0}},463(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.ImageRenderer=void 0;const A=i(939),s=i(426);class r extends s.Disposable{get canvas(){var e;return null===(e=this._layers.get("top"))||void 0===e?void 0:e.canvas}static createCanvas(e,t,i){const A=(null!=e?e:document).createElement("canvas");return A.width=0|t,A.height=0|i,A}static createImageData(e,t,i,A){if("function"!=typeof ImageData){const s=e.createImageData(t,i);return A&&s.data.set(new Uint8ClampedArray(A,0,t*i*4)),s}return A?new ImageData(new Uint8ClampedArray(A,0,t*i*4),t,i):new ImageData(t,i)}static createImageBitmap(e){return"function"!=typeof createImageBitmap?Promise.resolve(void 0):createImageBitmap(e)}constructor(e){super(),this._terminal=e,this._layers=new Map,this._optionsRefresh=this._register(new s.MutableDisposable),this._oldOpen=this._terminal._core.open,this._terminal._core.open=e=>{var t;null===(t=this._oldOpen)||void 0===t||t.call(this._terminal._core,e),this._open()},this._terminal._core.screenElement&&this._open(),this._optionsRefresh.value=this._terminal._core.optionsService.onOptionChange(e=>{var t;"fontSize"===e&&(this.rescaleCanvas(),null===(t=this._renderService)||void 0===t||t.refreshRows(0,this._terminal.rows))}),this._register((0,s.toDisposable)(()=>{var e;this.removeLayerFromDom(),this.removeLayerFromDom("bottom"),this._terminal._core&&this._oldOpen&&(this._terminal._core.open=this._oldOpen,this._oldOpen=void 0),this._renderService&&this._oldSetRenderer&&(this._renderService.setRenderer=this._oldSetRenderer,this._oldSetRenderer=void 0),this._renderService=void 0,this._layers.clear(),null===(e=this._placeholderBitmap)||void 0===e||e.close(),this._placeholderBitmap=void 0,this._placeholder=void 0}))}showPlaceholder(e){var t,i;e?this._placeholder||-1===this.cellSize.height||this._createPlaceHolder(Math.max(this.cellSize.height+1,24)):(null===(t=this._placeholderBitmap)||void 0===t||t.close(),this._placeholderBitmap=void 0,this._placeholder=void 0),null===(i=this._renderService)||void 0===i||i.refreshRows(0,this._terminal.rows)}get dimensions(){return this._terminal.dimensions}get cellSize(){var e,t;return{width:(null===(e=this.dimensions)||void 0===e?void 0:e.css.cell.width)||-1,height:(null===(t=this.dimensions)||void 0===t?void 0:t.css.cell.height)||-1}}clearLines(e,t,i){var A,s,r,o,a;const n=e*((null===(A=this.dimensions)||void 0===A?void 0:A.css.cell.height)||0),h=(null===(s=this.dimensions)||void 0===s?void 0:s.css.canvas.width)||0,g=(t+1-e)*((null===(r=this.dimensions)||void 0===r?void 0:r.css.cell.height)||0);i&&"top"!==i||null===(o=this._layers.get("top"))||void 0===o||o.clearRect(0,n,h,g),i&&"bottom"!==i||null===(a=this._layers.get("bottom"))||void 0===a||a.clearRect(0,n,h,g)}clearAll(e){if(!e||"top"===e){const e=this._layers.get("top");null==e||e.clearRect(0,0,e.canvas.width,e.canvas.height)}if(!e||"bottom"===e){const e=this._layers.get("bottom");null==e||e.clearRect(0,0,e.canvas.width,e.canvas.height)}}draw(e,t,i,A,s=1){const r=this._layers.get(e.layer);if(!r)return;const{width:o,height:a}=this.cellSize;if(-1===o||-1===a)return;this._rescaleImage(e,o,a);const n=e.actual,{width:h,height:g}=e.actualCellSize,d=Math.ceil(n.width/h),l=t%d*h,I=Math.floor(t/d)*g,c=i*o,_=A*a,C=s*h+l>n.width?n.width-l:s*h,B=I+g>n.height?n.height-I:g;r.drawImage(n,Math.floor(l),Math.floor(I),Math.ceil(C),Math.ceil(B),Math.floor(c),Math.floor(_),Math.ceil(C*o/h),Math.ceil(B*a/g))}extractTile(e,t){const{width:i,height:A}=this.cellSize;if(-1===i||-1===A)return;this._rescaleImage(e,i,A);const s=e.actual,{width:o,height:a}=e.actualCellSize,n=Math.ceil(s.width/o),h=t%n*o,g=Math.floor(t/n)*a,d=o+h>s.width?s.width-h:o,l=g+a>s.height?s.height-g:a,I=r.createCanvas(this.document,Math.ceil(d*i/o),Math.ceil(l*A/a)),c=I.getContext("2d");return c?(c.drawImage(s,Math.floor(h),Math.floor(g),Math.floor(d),Math.floor(l),0,0,I.width,I.height),I):void 0}drawPlaceholder(e,t,i=1){var A;const s=this._layers.get("top");if(s){const{width:r,height:o}=this.cellSize;if(-1===r||-1===o)return;if(this._placeholder?o>=this._placeholder.height&&this._createPlaceHolder(o+1):this._createPlaceHolder(Math.max(o+1,24)),!this._placeholder)return;s.drawImage(null!==(A=this._placeholderBitmap)&&void 0!==A?A:this._placeholder,e*r,t*o%2?0:1,r*i,o,e*r,t*o,r*i,o)}}rescaleCanvas(){var e,t;const i=(null===(e=this.dimensions)||void 0===e?void 0:e.css.canvas.width)||0,A=(null===(t=this.dimensions)||void 0===t?void 0:t.css.canvas.height)||0;for(const e of this._layers.values())e.canvas.width===i&&e.canvas.height===A||(e.canvas.width=i,e.canvas.height=A)}_rescaleImage(e,t,i){if(t===e.actualCellSize.width&&i===e.actualCellSize.height)return;const{width:A,height:s}=e.origCellSize;if(t===A&&i===s)return e.actual=e.orig,e.actualCellSize.width=A,void(e.actualCellSize.height=s);const o=Math.ceil(e.orig.width*t/A),a=Math.ceil(e.orig.height*i/s);if(o*a>e.orig.width*e.orig.height)return e.actual=e.orig,e.actualCellSize.width=A,void(e.actualCellSize.height=s);const n=r.createCanvas(this.document,o,a),h=n.getContext("2d");h&&(h.drawImage(e.orig,0,0,n.width,n.height),e.actual=n,e.actualCellSize.width=t,e.actualCellSize.height=i)}_open(){this._renderService=this._terminal._core._renderService,this._oldSetRenderer=this._renderService.setRenderer.bind(this._renderService),this._renderService.setRenderer=e=>{var t;for(const e of[...this._layers.keys()])this.removeLayerFromDom(e);null===(t=this._oldSetRenderer)||void 0===t||t.call(this._renderService,e)}}insertLayerToDom(e="top"){var t,i;if(!this.document||!this._terminal._core.screenElement)return void console.warn("image addon: cannot insert output canvas to DOM, missing document or screenElement");if(this._layers.has(e))return;const A=r.createCanvas(this.document,(null===(t=this.dimensions)||void 0===t?void 0:t.css.canvas.width)||0,(null===(i=this.dimensions)||void 0===i?void 0:i.css.canvas.height)||0);A.classList.add(`xterm-image-layer-${e}`);const s=this._terminal._core.screenElement;s.style.isolation="isolate","bottom"===e?(A.style.zIndex="-1",s.insertBefore(A,s.firstChild)):(A.style.zIndex="0",s.appendChild(A));const o=A.getContext("2d",{alpha:!0});o?(this._layers.set(e,o),this.clearAll(e)):A.remove()}removeLayerFromDom(e="top"){const t=this._layers.get(e);t&&(t.canvas.remove(),this._layers.delete(e))}hasLayer(e){return this._layers.has(e)}_createPlaceHolder(e=24){var t;null===(t=this._placeholderBitmap)||void 0===t||t.close(),this._placeholderBitmap=void 0;const i=32,s=r.createCanvas(this.document,i,e),o=s.getContext("2d",{alpha:!1});if(!o)return;const a=r.createImageData(o,i,e),n=new Uint32Array(a.data.buffer),h=(0,A.toRGBA8888)(0,0,0),g=(0,A.toRGBA8888)(255,255,255);n.fill(h);for(let t=0;t{this._placeholder!==I?null==e||e.close():this._placeholderBitmap=e}).catch(()=>{})}get document(){var e;return null===(e=this._terminal._core._coreBrowserService)||void 0===e?void 0:e.window.document}}t.ImageRenderer=r},699(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.ImageStorage=t.CELL_SIZE_DEFAULT=void 0;const A=i(463);t.CELL_SIZE_DEFAULT={width:7,height:14};class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(e){this._ext&=-67108864,this._ext|=67108863&e}get underlineVariantOffset(){const e=(3758096384&this._ext)>>29;return e<0?4294967288^e:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}get urlId(){return this._urlId}set urlId(e){this._urlId=e}constructor(e=0,t=0,i=-1,A=-1){this.imageId=i,this.tileId=A,this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}clone(){return new s(this._ext,this._urlId,this.imageId,this.tileId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId&&-1===this.imageId}}const r=new s;t.ImageStorage=class{constructor(e,t,i){this._terminal=e,this._renderer=t,this._opts=i,this._images=new Map,this._lastId=0,this._lowestId=0,this._fullyCleared=!1,this._needsFullClear=!1,this._pixelLimit=25e5;try{this.setLimit(this._opts.storageLimit)}catch(e){e instanceof Error&&console.error(e.message),console.warn(`storageLimit is set to ${this.getLimit()} MB`)}this._viewportMetrics={cols:this._terminal.cols,rows:this._terminal.rows}}dispose(){this.reset()}reset(){var e;for(const t of this._images.values())null===(e=t.marker)||void 0===e||e.dispose();this._images.clear(),this._renderer.clearAll()}getLimit(){return 4*this._pixelLimit/1e6}setLimit(e){if(e<.5||e>1e3)throw RangeError("invalid storageLimit, should be at least 0.5 MB and not exceed 1G");this._pixelLimit=e/4*1e6>>>0,this._evictOldest(0)}getUsage(){return 4*this._getStoredPixels()/1e6}_getStoredPixels(){let e=0;for(const t of this._images.values())t.orig&&(e+=t.orig.width*t.orig.height,t.actual&&t.actual!==t.orig&&(e+=t.actual.width*t.actual.height));return e}_delImg(e){var t;const i=this._images.get(e);i&&(this._images.delete(e),window.ImageBitmap&&i.orig instanceof ImageBitmap&&i.orig.close(),null===(t=this.onImageDeleted)||void 0===t||t.call(this,e))}wipeAlternate(){var e;const t=[];for(const[i,A]of this._images.entries())"alternate"===A.bufferType&&(null===(e=A.marker)||void 0===e||e.dispose(),t.push(i));for(const e of t)this._delImg(e);this._needsFullClear=!0,this._fullyCleared=!1}deleteImage(e){var t;const i=this._images.get(e);i&&(null===(t=i.marker)||void 0===t||t.dispose(),this._delImg(e))}addImage(e,i){var A,s;this._evictOldest(e.width*e.height);let r=this._renderer.cellSize;-1!==r.width&&-1!==r.height||(r=t.CELL_SIZE_DEFAULT);const o=Math.ceil(e.width/r.width),a=Math.ceil(e.height/r.height),n=++this._lastId,h=this._terminal._core.buffer,g=this._terminal.cols,d=this._terminal.rows,l=h.x,I=h.y;let c=l,_=0;i.scrolling||(h.x=0,h.y=0,c=0),this._terminal._core._inputHandler._dirtyRowTracker.markDirty(h.y);for(let e=0;e=g);++i)this._writeToCell(t,c+i,n,e*o+i),_++;if(i.scrolling)e=d)break;h.x=c}this._terminal._core._inputHandler._dirtyRowTracker.markDirty(h.y),i.scrolling?"iip"===i.cursorPos?h.x=Math.min(c+o,g):h.x=c:(h.x=l,h.y=I);const C=[];for(const[e,t]of this._images.entries())t.tileCount<1&&(null===(A=t.marker)||void 0===A||A.dispose(),C.push(e));for(const e of C)this._delImg(e);const B=this._terminal.registerMarker(0);null==B||B.onDispose(()=>{this._images.get(n)&&this._delImg(n)}),"alternate"===this._terminal.buffer.active.type&&this._evictOnAlternate();const Q={orig:e,origCellSize:r,actual:e,actualCellSize:Object.assign({},r),marker:B||void 0,tileCount:_,bufferType:this._terminal.buffer.active.type,layer:i.layer,zIndex:i.zIndex};return this._images.set(n,Q),null===(s=this.onImageAdded)||void 0===s||s.call(this),n}render(e){var t,i;let A=!1,s=!1;for(const e of this._images.values())if("bottom"===e.layer?s=!0:A=!0,A&&s)break;if(A&&!this._renderer.hasLayer("top")&&(this._renderer.insertLayerToDom("top"),!this._renderer.hasLayer("top")))return;if(s&&!this._renderer.hasLayer("bottom")&&this._renderer.insertLayerToDom("bottom"),this._renderer.rescaleCanvas(),!this._images.size)return this._fullyCleared||(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1),this._renderer.hasLayer("top")&&this._renderer.removeLayerFromDom("top"),void(this._renderer.hasLayer("bottom")&&this._renderer.removeLayerFromDom("bottom"));!A&&this._renderer.hasLayer("top")&&(this._renderer.clearAll("top"),this._renderer.removeLayerFromDom("top")),!s&&this._renderer.hasLayer("bottom")&&(this._renderer.clearAll("bottom"),this._renderer.removeLayerFromDom("bottom")),this._needsFullClear&&(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1);const{start:o,end:a}=e,n=this._terminal._core.buffer,h=this._terminal._core.cols;this._renderer.clearLines(o,a);const g=[],d=[];for(let e=o;e<=a;++e){const A=n.lines.get(e+n.ydisp);if(!A)return;for(let s=0;se.imgSpec.zIndex-t.imgSpec.zIndex);for(const e of d)this._renderer.drawPlaceholder(e.col,e.row,e.count);for(const e of g)this._renderer.draw(e.imgSpec,e.tileId,e.col,e.row,e.count)}viewportResize(e){var t,i;if(!this._images.size)return void(this._viewportMetrics=e);if(this._viewportMetrics.cols>=e.cols)return void(this._viewportMetrics=e);const A=this._terminal._core.buffer,s=A.lines.length,o=this._viewportMetrics.cols-1;for(let a=0;a=h)continue;let g=!1;for(let t=o+1;t>e.cols;++t)if(4194303&s._data[3*t+0]){g=!0;break}if(g)continue;const d=Math.min(e.cols,h-A.tileId%h+o);let l=A.tileId;for(let e=o+1;e>>8&255)<<16|(e>>>16&255)<<8|e>>>24&255}o.set(A.PALETTE_VT340_COLOR),t.SixelHandler=class{constructor(e,t,i){this._opts=e,this._storage=t,this._coreTerminal=i,this._size=0,this._aborted=!1,(0,r.DecoderAsync)({memoryLimit:4*this._opts.pixelLimit,palette:o,paletteLimit:this._opts.sixelPaletteLimit}).then(e=>this._dec=e)}reset(){this._dec&&(this._dec.release(),this._dec._palette.fill(0),this._dec.init(0,o,this._opts.sixelPaletteLimit))}hook(e){var t;if(this._size=0,this._aborted=!1,this._dec){const i=1===e.params[1]?0:function(e,t){let i=0;if(!t)return i;if(e.isInverse())if(e.isFgDefault())i=a(t.foreground.rgba);else if(e.isFgRGB()){const t=e.constructor.toColorRGB(e.getFgColor());i=(0,A.toRGBA8888)(...t)}else i=a(t.ansi[e.getFgColor()].rgba);else if(e.isBgDefault())i=a(t.background.rgba);else if(e.isBgRGB()){const t=e.constructor.toColorRGB(e.getBgColor());i=(0,A.toRGBA8888)(...t)}else i=a(t.ansi[e.getBgColor()].rgba);return i}(this._coreTerminal._core._inputHandler._curAttrData,null===(t=this._coreTerminal._core._themeService)||void 0===t?void 0:t.colors);this._dec.init(i,null,this._opts.sixelPaletteLimit)}}put(e,t,i){if(!this._aborted&&this._dec){if(this._size+=i-t,this._size>this._opts.sixelSizeLimit)return console.warn("SIXEL: too much data, aborting"),this._aborted=!0,void this._dec.release();try{this._dec.decode(e,t,i)}catch(e){console.warn(`SIXEL: error while decoding image - ${e}`),this._aborted=!0,this._dec.release()}}}unhook(e){var t;if(this._aborted||!e||!this._dec)return!0;const i=this._dec.width,A=this._dec.height;if(!i||!A)return A&&this._storage.advanceCursor(A),!0;const r=s.ImageRenderer.createCanvas(void 0,i,A);return null===(t=r.getContext("2d"))||void 0===t||t.putImageData(new ImageData(this._dec.data8,i,A),0,0),this._dec.memoryUsage>4194304&&this._dec.release(),this._storage.addImage(r),!0}}},994(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.SixelImageStorage=void 0;const A=i(699);t.SixelImageStorage=class{constructor(e,t,i,A){this._storage=e,this._opts=t,this._renderer=i,this._terminal=A,this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"vt340"}}addImage(e){this._addImageOpts.scrolling=this._opts.sixelScrolling,this._storage.addImage(e,this._addImageOpts)}advanceCursor(e){if(this._opts.sixelScrolling){let t=this._renderer.cellSize;-1!==t.width&&-1!==t.height||(t=A.CELL_SIZE_DEFAULT);const i=Math.ceil(e/t.height);for(let e=1;e512)return void(this._aborted=!0);if(this._controlData.set(e.subarray(t,s),this._controlLength),this._controlLength+=r,!this._inControlData){if(this._parsedCommand=(0,n.parseKittyCommand)(this._parseControlDataString()),void 0!==this._parsedCommand.id&&void 0!==this._parsedCommand.imageNumber)return this._sendResponse(this._parsedCommand.id,"EINVAL:cannot specify both i and I keys",null!==(A=this._parsedCommand.quiet)&&void 0!==A?A:0),void(this._aborted=!0);if("d"===this._parsedCommand.action)return;const t=s+1;tthis._encodedSizeLimit){const e=null!==(h=this._activeDecoder)&&void 0!==h?h:null==I?void 0:I.decoder;return e&&e.release(),this._activeDecoder=null,I&&this._removePendingEntry(l),void(this._aborted=!0)}if(!this._decodeError){if((null==I?void 0:I.decoder)&&!this._activeDecoder&&(this._activeDecoder=I.decoder),!this._activeDecoder){const e=this._maxEncodedBytes+131072;if(e>1e6*this._opts.storageLimit)return this._aborted=!0,void(void 0!==(null===(s=this._parsedCommand)||void 0===s?void 0:s.id)&&this._sendResponse(this._parsedCommand.id,"ENOMEM:pending image budget exceeded",null!==(g=this._parsedCommand.quiet)&&void 0!==g?g:0));const t=Math.max(1,Math.floor(1e6*this._opts.storageLimit/e));for(;this._pendingTransmissions.size>=t;){const e=this._pendingTransmissions.entries().next().value;if(!e)break;e[1].decoder.release(),this._removePendingEntry(e[0]),void 0!==e[1].cmd.id&&this._sendResponse(e[1].cmd.id,"ENOMEM:pending image budget exceeded",null!==(d=e[1].cmd.quiet)&&void 0!==d?d:0)}this._activeDecoder=new a.default(4194304,this._maxEncodedBytes,this._initialEncodedBytes),this._activeDecoder.init()}0!==this._activeDecoder.put(e.subarray(t,i))&&(this._activeDecoder.release(),this._activeDecoder=null,this._decodeError=!0,I&&this._removePendingEntry(l))}}end(e){var t,i;if(this._aborted||!e)return this._activeDecoder&&(this._activeDecoder.release(),this._activeDecoder=null),!0;if(this._inControlData)return this._handleNoPayloadCommand();const A=this._parsedCommand;if("d"===A.action)return this._handleDelete(A);const s=null!==(i=null!==(t=A.id)&&void 0!==t?t:this._lastPendingKey)&&void 0!==i?i:0,r=1===A.more,o=this._pendingTransmissions.get(s);if(r)return this._activeDecoder&&(o?(o.totalEncodedSize+=this._totalEncodedSize,o.decodeError=o.decodeError||this._decodeError):this._pendingTransmissions.set(s,{cmd:Object.assign({},A),decoder:this._activeDecoder,totalEncodedSize:this._totalEncodedSize,decodeError:this._decodeError}),this._lastPendingKey=s,this._activeDecoder=null),!0;o&&(this._lastPendingKey=void 0);let a=this._decodeError,n=A,h=this._activeDecoder;o&&(n=o.cmd,h=o.decoder,a=a||o.decodeError,this._pendingTransmissions.delete(s));let g=new Uint8Array(0);h&&(0!==h.end()&&(a=!0),g=h.data8),this._activeDecoder=null;const d=this._handleCommandWithBytesAndCmd(n,g,a);return h&&h.release(),d}_parseControlDataString(){let e="";for(let t=0;t0&&this._sendResponse(e.id,"OK",null!==(o=e.quiet)&&void 0!==o?o:0)),A}case"T":return this._handleTransmitDisplay(e,t,i);case"q":return this._handleQuery(e,t,i);case"p":return this._handlePlacement(e);default:return void 0!==e.id&&this._sendResponse(e.id,"EINVAL:unsupported action",null!==(a=e.quiet)&&void 0!==a?a:0),!0}}_handlePlacement(e){var t;if(void 0===e.id)return!0;const i=e.id,A=this._kittyStorage.getImage(i);return A?this._displayImage(A,e).then(t=>{var A;return this._sendResponse(i,t?"OK":"EINVAL:image rendering failed",null!==(A=e.quiet)&&void 0!==A?A:0,e.placementId),!0}):(this._sendResponse(i,"ENOENT:image not found",null!==(t=e.quiet)&&void 0!==t?t:0,e.placementId),!0)}_handleTransmit(e,t,i){var A,s,r,o,a,n;return"d"!==(null!==(A=e.transmission)&&void 0!==A?A:"d")?(void 0!==e.id&&this._sendResponse(e.id,"EINVAL:unsupported transmission medium",null!==(s=e.quiet)&&void 0!==s?s:0),!0):(i||0===t.length||this._kittyStorage.storeImage(e.id,{data:new Blob([t]),width:null!==(r=e.width)&&void 0!==r?r:0,height:null!==(o=e.height)&&void 0!==o?o:0,format:null!==(a=e.format)&&void 0!==a?a:32,compression:null!==(n=e.compression)&&void 0!==n?n:""}),!0)}_handleTransmitDisplay(e,t,i){var A,s;if(i)return void 0!==e.id&&this._sendResponse(e.id,"EINVAL:invalid base64 data",null!==(A=e.quiet)&&void 0!==A?A:0),!0;this._handleTransmit(e,t,i);const r=null!==(s=e.id)&&void 0!==s?s:this._kittyStorage.lastImageId,o=this._kittyStorage.getImage(r);if(o){const t=this._displayImage(o,e);return void 0!==e.id?t.then(t=>{var i;return this._sendResponse(r,t?"OK":"EINVAL:image rendering failed",null!==(i=e.quiet)&&void 0!==i?i:0),!0}):t.then(()=>!0)}return!0}_handleQuery(e,t,i){var A,s,r,o,a,n;const h=null!==(A=e.id)&&void 0!==A?A:0,g=null!==(s=e.quiet)&&void 0!==s?s:0;if("d"!==(null!==(r=e.transmission)&&void 0!==r?r:"d"))return this._sendResponse(h,"EINVAL:unsupported transmission medium",g),!0;if(i)return this._sendResponse(h,"EINVAL:invalid base64 data",g),!0;if(0===t.length)return this._sendResponse(h,"OK",g),!0;const d=null!==(o=e.format)&&void 0!==o?o:32;if(100===d)this._sendResponse(h,"OK",g);else{const i=null!==(a=e.width)&&void 0!==a?a:0,A=null!==(n=e.height)&&void 0!==n?n:0;if(!i||!A)return this._sendResponse(h,"EINVAL:width and height required for raw pixel data",g),!0;const s=i*A*(32===d?4:3);if(t.length=1)return;if(!s&&i>=2)return;const r=`_Gi=${e}${A?`,p=${A}`:""};${t}\\`;this._coreTerminal._core.coreService.triggerDataEvent(r)}_displayImage(e,t){return this._decodeAndDisplay(e,t).then(()=>!0).catch(()=>!1)}async _decodeAndDisplay(e,t){var i,A,o,a,n,h,g;const d=this._generation;let l=await this._createBitmap(e);try{if(d!==this._generation)throw new Error("image decode canceled");const I=Math.max(0,null!==(o=t.x)&&void 0!==o?o:0),c=Math.max(0,null!==(a=t.y)&&void 0!==a?a:0),_=t.sourceWidth||l.width-I,C=t.sourceHeight||l.height-c,B=Math.max(0,l.width-I),Q=Math.max(0,l.height-c),m=Math.max(0,Math.min(_,B)),E=Math.max(0,Math.min(C,Q));if(0===m||0===E)throw new Error("invalid source rectangle");if(0!==I||0!==c||m!==l.width||E!==l.height){const e=await createImageBitmap(l,I,c,m,E);l.close(),l=e}const u=(null===(i=this._renderer.dimensions)||void 0===i?void 0:i.css.cell.width)||r.CELL_SIZE_DEFAULT.width,p=(null===(A=this._renderer.dimensions)||void 0===A?void 0:A.css.cell.height)||r.CELL_SIZE_DEFAULT.height;let w,f;void 0!==t.columns&&void 0!==t.rows?(w=t.columns,f=t.rows):void 0!==t.columns?(w=t.columns,f=Math.max(1,Math.ceil(l.height/l.width*(w*u)/p))):void 0!==t.rows?(f=t.rows,w=Math.max(1,Math.ceil(l.width/l.height*(f*p)/u))):(w=Math.ceil(l.width/u),f=Math.ceil(l.height/p));let D=l.width,y=l.height;if(void 0===t.columns&&void 0===t.rows||(D=Math.round(w*u),y=Math.round(f*p)),D*y>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");const v=this._coreTerminal._core.buffer,k=v.x,M=v.y,b=v.ybase,S=void 0!==t.zIndex&&t.zIndex<0?"bottom":"top";if(D!==l.width||y!==l.height){const e=await createImageBitmap(l,{resizeWidth:D,resizeHeight:y});l.close(),l=e}const L=Math.min(Math.max(0,null!==(n=t.xOffset)&&void 0!==n?n:0),u-1),N=Math.min(Math.max(0,null!==(h=t.yOffset)&&void 0!==h?h:0),p-1);if(0!==L||0!==N){const e=void 0!==t.columns?Math.round(w*u):l.width+L,i=void 0!==t.rows?Math.round(f*p):l.height+N,A=s.ImageRenderer.createCanvas(window.document,e,i),r=A.getContext("2d");if(!r)throw new Error("Failed to create offset canvas context");r.drawImage(l,L,N);const o=await createImageBitmap(A);if(A.width=A.height=0,l.close(),l=o,D=l.width,y=l.height,D*y>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");void 0===t.columns&&(w=Math.ceil(l.width/u)),void 0===t.rows&&(f=Math.ceil(l.height/p))}if(d!==this._generation)throw new Error("image decode canceled");const G=null!==(g=t.zIndex)&&void 0!==g?g:0;if(this._kittyStorage.addImage(e.id,l,!0,S,G),l=void 0,1===t.cursorMovement){const e=v.ybase-b;v.x=k,v.y=Math.max(M-e,0)}else v.x=Math.min(k+w,this._coreTerminal.cols)}catch(e){throw null==l||l.close(),e}}async _createBitmap(e){let t=new Uint8Array(await e.data.arrayBuffer());if("z"===e.compression&&(t=await this._decompressZlib(t)),100===e.format){const e=(0,o.imageType)(t);if("image/png"!==e.mime||!(e.width>0)||!(e.height>0)||e.width*e.height>this._opts.pixelLimit)throw new RangeError("PNG exceeds pixel limit or has invalid dimensions");const i=new Blob([t],{type:"image/png"});if(!window.createImageBitmap){const e=URL.createObjectURL(i),t=new Image;return new Promise((i,A)=>{t.addEventListener("load",()=>{var r;URL.revokeObjectURL(e);const o=s.ImageRenderer.createCanvas(window.document,t.width,t.height);null===(r=o.getContext("2d"))||void 0===r||r.drawImage(t,0,0),createImageBitmap(o).then(i).catch(A)}),t.addEventListener("error",()=>{URL.revokeObjectURL(e),A(new Error("Failed to load image"))}),t.src=e})}return createImageBitmap(i)}const i=e.width,A=e.height;if(!i||!A)throw new Error("Width and height required for raw pixel data");const r=i*A*(32===e.format?4:3);if(t.length>>24|t<<8,g[I++]=4278190080|t>>>16|i<<16,g[I++]=4278190080|i>>>8}let c=3*d,_=4*d;for(let e=d;e=e.length)return void t.close();const i=Math.min(A+4096,e.length);t.enqueue(new Uint8Array(e.subarray(A,i))),A=i}}).pipeThrough(new DecompressionStream(t)).getReader(),r=[];let o=0;try{for(;;){const{done:e,value:t}=await s.read();if(e)break;if(o+=t.byteLength,o>i)throw await s.cancel().catch(()=>{}),new RangeError("decompressed image exceeds byte limit");r.push(t)}}finally{s.releaseLock()}const a=new Uint8Array(o);let n=0;for(const e of r)a.set(e,n),n+=e.length;return a}get images(){return this._kittyStorage.images}get _kittyIdToStorageId(){return this._kittyStorage.kittyIdToStorageId}get pendingTransmissions(){return this._pendingTransmissions}}},470(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.parseKittyCommand=function(e){const t={},i=e.split(",");for(const e of i){const i=e.indexOf("=");if(-1===i)continue;const A=e.substring(0,i),s=e.substring(i+1);if("a"===A){t.action=s;continue}if("o"===A){t.compression=s;continue}if("t"===A){t.transmission=s;continue}if("d"===A){t.deleteSelector=s;continue}const r=parseInt(s,10);switch(A){case"f":t.format=r;break;case"i":t.id=r;break;case"I":t.imageNumber=r;break;case"s":t.width=r;break;case"v":t.height=r;break;case"x":t.x=r;break;case"y":t.y=r;break;case"w":t.sourceWidth=r;break;case"h":t.sourceHeight=r;break;case"X":t.xOffset=r;break;case"Y":t.yOffset=r;break;case"c":t.columns=r;break;case"r":t.rows=r;break;case"m":t.more=r;break;case"q":t.quiet=r;break;case"C":t.cursorMovement=r;break;case"z":t.zIndex=r;break;case"p":t.placementId=r}}return t}},235(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.KittyImageStorage=void 0;class i{constructor(e){this._storage=e,this._nextImageId=1,this._images=new Map,this._kittyIdToStorageId=new Map,this._storageIdToKittyId=new Map,this._handleStorageImageDeleted=e=>{const t=this._storageIdToKittyId.get(e);void 0!==t&&(this._kittyIdToStorageId.delete(t),this._storageIdToKittyId.delete(e),this._images.delete(t))},this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"iip"},this._previousOnImageDeleted=this._storage.onImageDeleted,this._wrappedOnImageDeleted=e=>{var t;null===(t=this._previousOnImageDeleted)||void 0===t||t.call(this,e),this._handleStorageImageDeleted(e)},this._storage.onImageDeleted=this._wrappedOnImageDeleted}reset(){this._nextImageId=1,this._images.clear(),this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}dispose(){this.reset(),this._storage.onImageDeleted===this._wrappedOnImageDeleted&&(this._storage.onImageDeleted=this._previousOnImageDeleted)}storeImage(e,t){const A=null!=e?e:this._nextImageId++,s=this._kittyIdToStorageId.get(A);void 0!==s&&(this._storage.deleteImage(s),this._kittyIdToStorageId.delete(A),this._storageIdToKittyId.delete(s)),!this._images.has(A)&&this._images.size>=i._maxStoredImages&&this._evictUndisplayedImages();const r=1e6*this._storage.getLimit();this._images.delete(A);let o=0;for(const e of this._images.values())o+=e.data.size;for(const e of[!1,!0])for(const[i,A]of this._images){if(o+t.data.size<=r)break;this._kittyIdToStorageId.has(i)===e&&(o-=A.data.size,this.deleteById(i))}return this._images.set(A,Object.assign(Object.assign({},t),{id:A})),A}addImage(e,t,i,A,s){const r=this._kittyIdToStorageId.get(e);void 0!==r&&this._storageIdToKittyId.delete(r),this._addImageOpts.scrolling=i,this._addImageOpts.layer=A,this._addImageOpts.zIndex=s;const o=this._storage.addImage(t,this._addImageOpts);this._kittyIdToStorageId.set(e,o),this._storageIdToKittyId.set(o,e)}getImage(e){return this._images.get(e)}deleteById(e){this._images.delete(e);const t=this._kittyIdToStorageId.get(e);void 0!==t&&(this._storage.deleteImage(t),this._kittyIdToStorageId.delete(e),this._storageIdToKittyId.delete(t))}deleteAll(){this._images.clear();for(const e of this._kittyIdToStorageId.values())this._storage.deleteImage(e);this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}get images(){return this._images}get kittyIdToStorageId(){return this._kittyIdToStorageId}get lastImageId(){return this._nextImageId-1}_evictUndisplayedImages(){for(const[e]of this._images){if(this._images.size<=i._maxStoredImages/2)break;this._kittyIdToStorageId.has(e)||this._images.delete(e)}}}t.KittyImageStorage=i,i._maxStoredImages=256},669(e,t,i){Object.defineProperty(t,"__esModule",{value:!0});const A=(0,i(552).InWasm)({s:1,t:0,d:"AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML"}),s=new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map(e=>e.charCodeAt(0))),r=new Uint32Array(1024);r.fill(4278190080);for(let e=0;e>4|(e<<4&255)<<8;for(let e=0;e>2<<8|(e<<6&255)<<16;for(let e=0;ethis.maxBytes||this.maxBytes>4294901760)throw new Error("invalid byte settings")}get data8(){return this._inst?this._d.subarray(0,this._m32[1282]):o}release(){this._inst&&(this._bytes>this.keepSize?this._inst=this._m32=this._d=this._mem=null:(this._m32[1280]=0,this._m32[1281]=0,this._m32[1282]=0))}init(e,t){if(this.maxBytes=null!=e?e:this.maxBytes,this._bytes=null!=t?t:Math.min(this._bytes,this.maxBytes),this._bytes>this.maxBytes||this.maxBytes>4294901760)throw Error("invalid byte settings");let i=this._m32;const s=this._bytes+5152;this._inst?this._mem.buffer.byteLengththis.maxBytes)return-3;let e=this._bytes;for(;(e*=2)this._mem.buffer.byteLength){const t=Math.ceil((e+5152-this._mem.buffer.byteLength)/65536);this._mem.grow(t),this._m32=new Uint32Array(this._mem.buffer,0),this._d=new Uint8Array(this._mem.buffer,5152)}this._bytes=e}return 0}put(e){if(!this._inst||this._ended)return-2;if(this._realloc(e.length))return-3;const t=this._m32;return this._d.set(e,t[1280]),t[1280]+=e.length,t[1280]-t[1281]>=131072?this._inst.exports.dec():0}end(){return this._ended=!0,this._inst?this._inst.exports.end():-2}get loadedBytes(){return this._inst?this._m32[1280]:0}get freeBytes(){return this._inst?this.maxBytes-this._m32[1280]:0}}},61(e,t,i){Object.defineProperty(t,"__esModule",{value:!0});const A=(0,i(552).InWasm)({s:1,t:0,d:"AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=="});t.default=class{constructor(e){this.keepSize=e,this.width=0,this.height=0}decode(e){this.width=e[4]<<24|e[5]<<16|e[6]<<8|e[7],this.height=e[8]<<24|e[9]<<16|e[10]<<8|e[11];const t=this.width*this.height,i=4*t,s=e.length,r=Math.max(i,s)+(Math.min(i,s)>>1)+4096;this._inst?this._mem.buffer.byteLengththis.keepSize&&(this._inst=this._d=this._mem=null)}}},552(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.InWasm=function(e){if(e.d){const{t,s:A,d:s}=e;let r,o;const a=WebAssembly;return 0===t?A?e=>new a.Instance(o||(o=new a.Module(r||(r=i(s)))),e):e=>o?a.instantiate(o,e):a.instantiate(r||(r=i(s)),e).then(e=>(o=e.module)&&e.instance):1===t?A?()=>o||(o=new a.Module(r||(r=i(s)))):()=>o?Promise.resolve(o):a.compile(r||(r=i(s))).then(e=>o=e):A?()=>r||(r=i(s)):()=>Promise.resolve(r||(r=i(s)))}if("undefined"==typeof _wasmCtx)throw new Error('must run "inwasm"');_wasmCtx.add(e)};let i=e=>{if(Uint8Array.fromBase64)return Uint8Array.fromBase64(e);if("undefined"!=typeof Buffer)return Buffer.from(e,"base64");const t=atob(e),i=new Uint8Array(t.length);for(let e=0;e{var e=A;Object.defineProperty(e,"__esModule",{value:!0}),e.ImageAddon=void 0;const t=i(414),s=i(795),r=i(463),o=i(699),a=i(23),n=i(235),h=i(566),g=i(994),d=i(649),l={enableSizeReports:!0,pixelLimit:16777216,sixelSupport:!0,sixelScrolling:!0,sixelPaletteLimit:4096,sixelSizeLimit:33554432,storageLimit:128,showPlaceholder:!0,iipSupport:!0,iipSizeLimit:33554432,kittySupport:!0,kittySizeLimit:33554432};e.ImageAddon=class{constructor(e){this._disposables=[],this._handlers=new Map,this._onImageAdded=new t.Emitter,this.onImageAdded=this._onImageAdded.event,this._opts=Object.assign({},l,e),this._defaultOpts=Object.assign({},l,e)}dispose(){for(const e of this._handlers.values())e.reset();for(const e of this._disposables)e.dispose();this._disposables.length=0,this._handlers.clear(),this._onImageAdded.dispose()}_disposeLater(...e){for(const t of e)this._disposables.push(t)}activate(e){var t;if(this._terminal=e,this._renderer=new r.ImageRenderer(e),this._storage=new o.ImageStorage(e,this._renderer,this._opts),this._storage.onImageAdded=()=>this._onImageAdded.fire(),this._opts.enableSizeReports){const i=null!==(t=e.options.windowOptions)&&void 0!==t?t:{};i.getWinSizePixels=!0,i.getCellSizePixels=!0,i.getWinSizeChars=!0,e.options.windowOptions=i}if(this._disposeLater(this._renderer,this._storage,e.parser.registerCsiHandler({prefix:"?",final:"h"},e=>this._decset(e)),e.parser.registerCsiHandler({prefix:"?",final:"l"},e=>this._decrst(e)),e.parser.registerCsiHandler({final:"c"},e=>this._da1(e)),e.parser.registerCsiHandler({prefix:"?",final:"S"},e=>this._xtermGraphicsAttributes(e)),e.onRender(e=>{var t;return null===(t=this._storage)||void 0===t?void 0:t.render(e)}),e.parser.registerCsiHandler({intermediates:"!",final:"p"},()=>this.reset()),e.parser.registerEscHandler({final:"c"},()=>this.reset()),e._core._inputHandler.onRequestReset(()=>this.reset()),e.buffer.onBufferChange(()=>{var e;return null===(e=this._storage)||void 0===e?void 0:e.wipeAlternate()}),e.onResize(e=>{var t;return null===(t=this._storage)||void 0===t?void 0:t.viewportResize(e)})),this._opts.sixelSupport){const t=new g.SixelImageStorage(this._storage,this._opts,this._renderer,e),i=new h.SixelHandler(this._opts,t,e);this._handlers.set("sixel",i),this._disposeLater(e._core._inputHandler._parser.registerDcsHandler({final:"q"},i))}if(this._opts.iipSupport){const t=new d.IIPImageStorage(this._storage),i=new s.IIPHandler(this._opts,this._renderer,t,e);this._handlers.set("iip",i),this._disposeLater(e._core._inputHandler._parser.registerOscHandler(1337,i))}if(this._opts.kittySupport){const t=new n.KittyImageStorage(this._storage),i=new a.KittyGraphicsHandler(this._opts,this._renderer,t,e);this._handlers.set("kitty",i),this._disposeLater(t,i,e._core._inputHandler._parser.registerApcHandler({final:"G"},i))}}reset(){var e;this._opts.sixelScrolling=this._defaultOpts.sixelScrolling,this._opts.sixelPaletteLimit=this._defaultOpts.sixelPaletteLimit,null===(e=this._storage)||void 0===e||e.reset();for(const e of this._handlers.values())e.reset();return!1}get storageLimit(){var e;return(null===(e=this._storage)||void 0===e?void 0:e.getLimit())||-1}set storageLimit(e){var t;null===(t=this._storage)||void 0===t||t.setLimit(e),this._opts.storageLimit=e}get storageUsage(){return this._storage?this._storage.getUsage():-1}get showPlaceholder(){return this._opts.showPlaceholder}set showPlaceholder(e){var t;this._opts.showPlaceholder=e,null===(t=this._renderer)||void 0===t||t.showPlaceholder(e)}getImageAtBufferCell(e,t){var i;return null===(i=this._storage)||void 0===i?void 0:i.getImageAtBufferCell(e,t)}extractTileAtBufferCell(e,t){var i;return null===(i=this._storage)||void 0===i?void 0:i.extractTileAtBufferCell(e,t)}_report(e){var t;null===(t=this._terminal)||void 0===t||t._core.input(e,!1)}_decset(e){for(let t=0;t2&&!(e[2]instanceof Array)&&e[2]<=4096?(this._opts.sixelPaletteLimit=e[2],this._report(`[?${e[0]};0;${this._opts.sixelPaletteLimit}S`)):this._report(`[?${e[0]};2S`),!0;case 4:return this._report(`[?${e[0]};0;4096S`),!0;default:return this._report(`[?${e[0]};2S`),!0}if(2===e[0])switch(e[1]){case 1:let n=null===(i=null===(t=this._renderer)||void 0===t?void 0:t.dimensions)||void 0===i?void 0:i.css.canvas.width,h=null===(s=null===(A=this._renderer)||void 0===A?void 0:A.dimensions)||void 0===s?void 0:s.css.canvas.height;if(!n||!h){const e=o.CELL_SIZE_DEFAULT;n=((null===(r=this._terminal)||void 0===r?void 0:r.cols)||80)*e.width,h=((null===(a=this._terminal)||void 0===a?void 0:a.rows)||24)*e.height}if(n*h(()=>{"use strict";var e={939(e,t){function i(e){return 255&e}function A(e){return e>>>8&255}function s(e){return e>>>16&255}function r(e,t,i,A=255){return((255&A)<<24|(255&i)<<16|(255&t)<<8|255&e)>>>0}function o(e,t,i){return Math.max(e,Math.min(i,t))}function a(e,t,i){return i<0&&(i+=1),i>1&&(i-=1),6*i<1?t+6*(e-t)*i:2*i<1?e:3*i<2?t+(e-t)*(4-6*i):t}function n(e,t,i){return(4278190080|Math.round(i/100*255)<<16|Math.round(t/100*255)<<8|Math.round(e/100*255))>>>0}Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_FOREGROUND=t.DEFAULT_BACKGROUND=t.PALETTE_ANSI_256=t.PALETTE_VT340_GREY=t.PALETTE_VT340_COLOR=t.normalizeHLS=t.normalizeRGB=t.nearestColorIndex=t.fromRGBA8888=t.toRGBA8888=t.alpha=t.blue=t.green=t.red=t.BIG_ENDIAN=void 0,t.BIG_ENDIAN=255===new Uint8Array(new Uint32Array([4278190080]).buffer)[0],t.BIG_ENDIAN&&console.warn("BE platform detected. This version of node-sixel works only on LE properly."),t.red=i,t.green=A,t.blue=s,t.alpha=function(e){return e>>>24&255},t.toRGBA8888=r,t.fromRGBA8888=function(e){return[255&e,e>>8&255,e>>16&255,e>>>24]},t.nearestColorIndex=function(e,t){const r=i(e),o=A(e),a=s(e);let n=Number.MAX_SAFE_INTEGER,h=-1;for(let e=0;e{const e=[r(0,0,0),r(205,0,0),r(0,205,0),r(205,205,0),r(0,0,238),r(205,0,205),r(0,250,205),r(229,229,229),r(127,127,127),r(255,0,0),r(0,255,0),r(255,255,0),r(92,92,255),r(255,0,255),r(0,255,255),r(255,255,255)],t=[0,95,135,175,215,255];for(let i=0;i<6;++i)for(let A=0;A<6;++A)for(let s=0;s<6;++s)e.push(r(t[i],t[A],t[s]));for(let t=8;t<=238;t+=10)e.push(r(t,t,t));return new Uint32Array(e)})(),t.DEFAULT_BACKGROUND=r(0,0,0,255),t.DEFAULT_FOREGROUND=r(255,255,255,255)},591(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.decodeAsync=t.decode=t.Decoder=t.DecoderAsync=void 0;const A=i(939),s=i(199),r=function(e){if("undefined"!=typeof Buffer)return Buffer.from(e,"base64");const t=atob(e),i=new Uint8Array(t.length);for(let e=0;e1,this.modeHandler=e=>1}handle_band(e){return this.bandHandler(e)}mode_parsed(e){return this.modeHandler(e)}}const h={memoryLimit:134217728,sixelColor:A.DEFAULT_FOREGROUND,fillColor:A.DEFAULT_BACKGROUND,palette:A.PALETTE_VT340_COLOR,paletteLimit:s.LIMITS.PALETTE_SIZE,truncate:!0};function g(e){const t=new n,i={env:{handle_band:t.handle_band.bind(t),mode_parsed:t.mode_parsed.bind(t)}};return WebAssembly.instantiate(o||r,i).then(i=>(o=o||i.module,new d(e,i.instance||i,t)))}t.DecoderAsync=g;class d{constructor(e,t,i){if(this._PIXEL_OFFSET=s.LIMITS.MAX_WIDTH+4,this._canvas=a,this._bandWidths=[],this._maxWidth=0,this._minWidth=s.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0,this._opts=Object.assign({},h,e),this._opts.paletteLimit>s.LIMITS.PALETTE_SIZE)throw new Error(`DecoderOptions.paletteLimit must not exceed ${s.LIMITS.PALETTE_SIZE}`);if(t)i.bandHandler=this._handle_band.bind(this),i.modeHandler=this._initCanvas.bind(this);else{const e=o||(o=new WebAssembly.Module(r));t=new WebAssembly.Instance(e,{env:{handle_band:this._handle_band.bind(this),mode_parsed:this._initCanvas.bind(this)}})}this._instance=t,this._wasm=this._instance.exports,this._chunk=new Uint8Array(this._wasm.memory.buffer,this._wasm.get_chunk_address(),s.LIMITS.CHUNK_SIZE),this._states=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_state_address(),12),this._palette=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_palette_address(),s.LIMITS.PALETTE_SIZE),this._palette.set(this._opts.palette),this._pSrc=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_p0_address()),this._wasm.init(A.DEFAULT_FOREGROUND,0,this._opts.paletteLimit,0)}get _fillColor(){return this._states[0]}get _truncate(){return this._states[8]}get _rasterWidth(){return this._states[6]}get _rasterHeight(){return this._states[7]}get _width(){return this._states[2]?this._states[2]-4:0}get _height(){return this._states[3]}get _level(){return this._states[9]}get _mode(){return this._states[10]}get _paletteLimit(){return this._states[11]}_initCanvas(e){if(2===e){const e=this.width*this.height;if(e>this._canvas.length){if(this._opts.memoryLimit&&4*e>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(e)}this._maxWidth=this._width}else if(1===e)if(2===this._level){const e=Math.min(this._rasterWidth,s.LIMITS.MAX_WIDTH)*this._rasterHeight;if(e>this._canvas.length){if(this._opts.memoryLimit&&4*e>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(e)}}else this._canvas.length<65536&&(this._canvas=new Uint32Array(65536));return 0}_realloc(e,t){const i=e+t;if(i>this._canvas.length){if(this._opts.memoryLimit&&4*i>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");const e=new Uint32Array(65536*Math.ceil(i/65536));e.set(this._canvas),this._canvas=e}}_handle_band(e){const t=this._PIXEL_OFFSET;let i=this._lastOffset;if(2===this._mode){let A=this.height-this._currentHeight,s=0;for(;s<6&&A>0;)this._canvas.set(this._pSrc.subarray(t*s,t*s+e),i+e*s),s++,A--;this._lastOffset+=e*s,this._currentHeight+=s}else if(1===this._mode){this._realloc(i,6*e),this._maxWidth=Math.max(this._maxWidth,e),this._minWidth=Math.min(this._minWidth,e);for(let A=0;A<6;++A)this._canvas.set(this._pSrc.subarray(t*A,t*A+e),i+e*A);this._bandWidths.push(e),this._lastOffset+=6*e,this._currentHeight+=6}return 0}get width(){return 1!==this._mode?this._width:Math.max(this._maxWidth,this._wasm.current_width())}get height(){return 1!==this._mode?this._height:this._wasm.current_width()?6*this._bandWidths.length+this._wasm.current_height():6*this._bandWidths.length}get palette(){return this._palette.subarray(0,this._paletteLimit)}get memoryUsage(){return this._canvas.byteLength+this._wasm.memory.buffer.byteLength+8*this._bandWidths.length}get properties(){return{width:this.width,height:this.height,mode:this._mode,level:this._level,truncate:!!this._truncate,paletteLimit:this._paletteLimit,fillColor:this._fillColor,memUsage:this.memoryUsage,rasterAttributes:{numerator:this._states[4],denominator:this._states[5],width:this._rasterWidth,height:this._rasterHeight}}}init(e=this._opts.fillColor,t=this._opts.palette,i=this._opts.paletteLimit,A=this._opts.truncate){this._wasm.init(this._opts.sixelColor,e,i,A?1:0),t&&this._palette.set(t.subarray(0,s.LIMITS.PALETTE_SIZE)),this._bandWidths.length=0,this._maxWidth=0,this._minWidth=s.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0}decode(e,t=0,i=e.length){let A=t;for(;A0){const i=this._PIXEL_OFFSET;let A=this._lastOffset,s=0;for(;s<6&&t>0;)this._canvas.set(this._pSrc.subarray(i*s,i*s+e),A+e*s),s++,t--;t&&this._canvas.fill(this._fillColor,A+e*s)}return this._canvas.subarray(0,this.width*this.height)}if(1===this._mode){if(this._minWidth===this._maxWidth){let t=!1;if(e)if(e!==this._minWidth)t=!0;else{const t=this._PIXEL_OFFSET;let i=this._lastOffset;this._realloc(i,6*e);for(let A=0;A<6;++A)this._canvas.set(this._pSrc.subarray(t*A,t*A+e),i+e*A)}if(!t)return this._canvas.subarray(0,this.width*this.height)}const t=new Uint32Array(this.width*this.height);t.fill(this._fillColor);let i=0,A=0;for(let e=0;e{if(this._disposed)return(0,A.toDisposable)(()=>{});const s={fn:e,thisArgs:t};this._listeners=this._listeners.slice(),this._listeners.push(s);const r=(0,A.toDisposable)(()=>{const e=this._listeners.indexOf(s);-1!==e&&(this._listeners=this._listeners.slice(),this._listeners.splice(e,1))});return i&&(Array.isArray(i)?i.push(r):i.add(r)),r}),this._event}fire(e){if(this._disposed||!this._listeners.length)return;if(1===this._listeners.length)return void this._listeners[0].fn.call(this._listeners[0].thisArgs,e);const t=this._listeners;for(let i=0,A=t.length;it.fire(e))},e.map=function(e,t){return(i,A,s)=>e(e=>i.call(A,t(e)),void 0,s)},e.any=function(...e){return(t,i,s)=>{const r=new A.DisposableStore;for(const A of e)r.add(A(e=>t.call(i,e)));return s&&(Array.isArray(s)?s.push(r):s.add(r)),r}},e.runAndSubscribe=function(e,t,i){return t(i),e(e=>t(e))}}(s||(t.EventUtils=s={}))},426(e,t){function i(e){return{dispose:e}}function A(e){if(!e)return e;if(Array.isArray(e)){for(const t of e)t.dispose();return[]}return e.dispose(),e}Object.defineProperty(t,"__esModule",{value:!0}),t.MutableDisposable=t.Disposable=t.DisposableStore=void 0,t.toDisposable=i,t.dispose=A,t.combinedDisposable=function(...e){return i(()=>A(e))};class s{constructor(){this._disposables=new Set,this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(e){return this._isDisposed?e.dispose():this._disposables.add(e),e}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(const e of this._disposables)e.dispose();this._disposables.clear()}}clear(){for(const e of this._disposables)e.dispose();this._disposables.clear()}}t.DisposableStore=s;class r{constructor(){this._store=new s}dispose(){this._store.dispose()}_register(e){return this._store.add(e)}}t.Disposable=r,r.None=Object.freeze({dispose(){}}),t.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}}},795(e,t,i){var A=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0}),t.IIPHandler=void 0;const s=i(463),r=i(699),o=A(i(669)),a=A(i(61)),n=i(389),h=i(462),g={type:0,name:"Unnamed file",size:0,width:"auto",height:"auto",preserveAspectRatio:1,inline:0};t.IIPHandler=class{constructor(e,t,i,A){this._opts=e,this._renderer=t,this._storage=i,this._coreTerminal=A,this._generation=0,this._aborted=!1,this._hp=new n.HeaderParser,this._header=g,this._isMultipart=!1,this._abortMulti=!1;const s=Math.ceil(4*this._opts.iipSizeLimit/3),r=Math.min(1048576,s);this._dec=new o.default(0,s,r),this._qoiDec=new a.default(0)}reset(){this._generation++,this._hp.reset(),this._dec.release(),this._qoiDec.release()}start(){this._aborted=!1,this._hp.reset()}put(e,t,i){if(!this._aborted)if(4===this._hp.state)0!==this._dec.put(e.subarray(t,i))&&(this._dec.release(),this._aborted=!0);else{const A=this._hp.parse(e,t,i);if(-1===A)return void(this._aborted=!0);if(A>0){if(1===this._hp.fields.type){if(this._isMultipart&&(this._isMultipart=!1,this._abortMulti=!1,this._dec.release()),this._header=Object.assign({},g,this._hp.fields),!this._header.inline)return void(this._aborted=!0);if(!this._initDecoder())return void(this._aborted=!0)}else if(this._abortMulti)return void(this._aborted=!0);0!==this._dec.put(e.subarray(A,i))&&(this._dec.release(),this._aborted=!0,this._isMultipart&&(this._abortMulti=!0))}}}end(e){var t,i,A;if(this._aborted)return!0;if(4!==this._hp.state&&this._hp.end())return!0;const o=this._hp.fields.type;if(3===o)return!0;if(5===o){let e=r.CELL_SIZE_DEFAULT.width,i=r.CELL_SIZE_DEFAULT.height;this._renderer.dimensions&&(e=this._renderer.dimensions.css.canvas.width/this._coreTerminal.cols,i=this._renderer.dimensions.css.canvas.height/this._coreTerminal.rows);const s=null!==(A=null===(t=this._coreTerminal._core._coreBrowserService)||void 0===t?void 0:t.dpr)&&void 0!==A?A:1,o=`]1337;ReportCellSize=${i.toFixed(3)};${e.toFixed(3)};${s.toFixed(3)}\\`;return this._coreTerminal.input(o,!1),!0}if(2===o)return this._header=Object.assign({},g,this._hp.fields),this._isMultipart=!0,this._abortMulti=!1,this._dec.release(),this._initDecoder()||(this._abortMulti=!0),!0;if(4===o){if(!this._isMultipart)return!0;if(this._isMultipart=!1,this._abortMulti||2!==this._header.type)return!0}let a,n,d=0,l=0,I=h.UNSUPPORTED_TYPE;if((a=e)&&((a=!this._dec.end())?(I=(0,h.imageType)(this._dec.data8),(a="unsupported"!==I.mime)?(d=I.width,l=I.height,(a=d&&l&&d*lc!==this._generation?(e.close(),!0):(this._storage.addImage(e),!0)).catch(e=>(console.warn(`IIP: decoding error ${I.mime} ${I.width}x${I.height}`,e),!0))}_initDecoder(){try{return this._dec.init(),!0}catch(e){return console.warn("IIP: could not allocate decoder",e),this._dec.release(),!1}}_resize(e,t){var i,A,s,o;const a=(null===(i=this._renderer.dimensions)||void 0===i?void 0:i.css.cell.width)||r.CELL_SIZE_DEFAULT.width,n=(null===(A=this._renderer.dimensions)||void 0===A?void 0:A.css.cell.height)||r.CELL_SIZE_DEFAULT.height,h=(null===(s=this._renderer.dimensions)||void 0===s?void 0:s.css.canvas.width)||a*this._coreTerminal.cols,g=(null===(o=this._renderer.dimensions)||void 0===o?void 0:o.css.canvas.height)||n*this._coreTerminal.rows,d=this._dim(this._header.width,h,a),l=this._dim(this._header.height,g,n);if(!d&&!l){const i=h/e,A=(g-n)/t,s=Math.min(i,A);return s<1?[e*s,t*s]:[e,t]}return d?!this._header.preserveAspectRatio&&d&&l?[d,l]:[d,t*d/e]:[e*l/t,l]}_dim(e,t,i){return"auto"===e?0:e.endsWith("%")?parseInt(e.slice(0,-1),10)*t/100:e.endsWith("px")?parseInt(e.slice(0,-2),10):parseInt(e,10)*i}}},389(e,t){function i(e){let t="";for(let i=0;i57)throw new Error("illegal char");t=10*t+e[i]-48}return t}function s(e){const t=i(e);if(!t.match(/^((auto)|(\d+?((px)|(%)){0,1}))$/))throw new Error("illegal size");return t}Object.defineProperty(t,"__esModule",{value:!0}),t.HeaderParser=void 0;const r={inline:A,size:A,name:function(e){if("undefined"!=typeof Buffer)return Buffer.from(i(e),"base64").toString();const t=atob(i(e)),A=new Uint8Array(t.length);for(let e=0;e14)return-1;for(let h=t;h=d)return this._a();r[s++]=t}break;case 58:return 3!==A||this._storeValue(s)?(this.state=4,h+1):this._a();default:if(s>=d)return this._a();r[s++]=t}}return this.state=A,this._position=s,-2}_a(){return this.fields.type=0,this.state=1,-1}_storeKey(e){const t=i(this._buffer.subarray(0,e));return!!t&&(this._key=t,this.fields[t]=null,!0)}_storeValue(e){if(this._key){try{const t=this._buffer.slice(0,e);this.fields[this._key]=r[this._key]?r[this._key](t):t}catch(e){return!1}return!0}return!1}}},649(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.IIPImageStorage=void 0,t.IIPImageStorage=class{constructor(e){this._storage=e,this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"iip"}}addImage(e){this._storage.addImage(e,this._addImageOpts)}}},462(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.UNSUPPORTED_TYPE=void 0,t.imageType=function(e){if(e.length<32)return t.UNSUPPORTED_TYPE;const i=new Uint32Array(e.buffer,e.byteOffset,8);if(1196314761===i[0]&&169478669===i[1]&&1380206665===i[3])return{mime:"image/png",width:e[16]<<24|e[17]<<16|e[18]<<8|e[19],height:e[20]<<24|e[21]<<16|e[22]<<8|e[23]};if(255===e[0]&&216===e[1]&&255===e[2]){const[t,i]=function(e){const t=e.length;let i=4,A=e[i]<<8|e[i+1];for(;;){if(i+=A,i>=t)return[0,0];if(255!==e[i])return[0,0];if(192===e[i+1]||194===e[i+1])return i+8>>14&16383)};case 32:return 157!==e[23]||1!==e[24]||42!==e[25]?t.UNSUPPORTED_TYPE:{mime:"image/webp",width:16383&(e[26]|e[27]<<8),height:16383&(e[28]|e[29]<<8)}}return t.UNSUPPORTED_TYPE}if(1887007846===i[1]&&(1718187617===i[2]||1936291425===i[2])){let i=-1;const A=Math.min(e.length-16,1024);for(let t=8;t0&&A>0)return{mime:"image/avif",width:t,height:A}}return t.UNSUPPORTED_TYPE}return t.UNSUPPORTED_TYPE},t.UNSUPPORTED_TYPE={mime:"unsupported",width:0,height:0}},463(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.ImageRenderer=void 0;const A=i(939),s=i(426);class r extends s.Disposable{get canvas(){var e;return null===(e=this._layers.get("top"))||void 0===e?void 0:e.canvas}static createCanvas(e,t,i){const A=(null!=e?e:document).createElement("canvas");return A.width=0|t,A.height=0|i,A}static createImageData(e,t,i,A){if("function"!=typeof ImageData){const s=e.createImageData(t,i);return A&&s.data.set(new Uint8ClampedArray(A,0,t*i*4)),s}return A?new ImageData(new Uint8ClampedArray(A,0,t*i*4),t,i):new ImageData(t,i)}static createImageBitmap(e){return"function"!=typeof createImageBitmap?Promise.resolve(void 0):createImageBitmap(e)}constructor(e){super(),this._terminal=e,this._layers=new Map,this._optionsRefresh=this._register(new s.MutableDisposable),this._oldOpen=this._terminal._core.open,this._terminal._core.open=e=>{var t;null===(t=this._oldOpen)||void 0===t||t.call(this._terminal._core,e),this._open()},this._terminal._core.screenElement&&this._open(),this._optionsRefresh.value=this._terminal._core.optionsService.onOptionChange(e=>{var t;"fontSize"===e&&(this.rescaleCanvas(),null===(t=this._renderService)||void 0===t||t.refreshRows(0,this._terminal.rows))}),this._register((0,s.toDisposable)(()=>{var e;this.removeLayerFromDom(),this.removeLayerFromDom("bottom"),this._terminal._core&&this._oldOpen&&(this._terminal._core.open=this._oldOpen,this._oldOpen=void 0),this._renderService&&this._oldSetRenderer&&(this._renderService.setRenderer=this._oldSetRenderer,this._oldSetRenderer=void 0),this._renderService=void 0,this._layers.clear(),null===(e=this._placeholderBitmap)||void 0===e||e.close(),this._placeholderBitmap=void 0,this._placeholder=void 0}))}showPlaceholder(e){var t,i;e?this._placeholder||-1===this.cellSize.height||this._createPlaceHolder(Math.max(this.cellSize.height+1,24)):(null===(t=this._placeholderBitmap)||void 0===t||t.close(),this._placeholderBitmap=void 0,this._placeholder=void 0),null===(i=this._renderService)||void 0===i||i.refreshRows(0,this._terminal.rows)}get dimensions(){return this._terminal.dimensions}get cellSize(){var e,t;return{width:(null===(e=this.dimensions)||void 0===e?void 0:e.css.cell.width)||-1,height:(null===(t=this.dimensions)||void 0===t?void 0:t.css.cell.height)||-1}}clearLines(e,t,i){var A,s,r,o,a;const n=e*((null===(A=this.dimensions)||void 0===A?void 0:A.css.cell.height)||0),h=(null===(s=this.dimensions)||void 0===s?void 0:s.css.canvas.width)||0,g=(t+1-e)*((null===(r=this.dimensions)||void 0===r?void 0:r.css.cell.height)||0);i&&"top"!==i||null===(o=this._layers.get("top"))||void 0===o||o.clearRect(0,n,h,g),i&&"bottom"!==i||null===(a=this._layers.get("bottom"))||void 0===a||a.clearRect(0,n,h,g)}clearAll(e){if(!e||"top"===e){const e=this._layers.get("top");null==e||e.clearRect(0,0,e.canvas.width,e.canvas.height)}if(!e||"bottom"===e){const e=this._layers.get("bottom");null==e||e.clearRect(0,0,e.canvas.width,e.canvas.height)}}draw(e,t,i,A,s=1){const r=this._layers.get(e.layer);if(!r)return;const{width:o,height:a}=this.cellSize;if(-1===o||-1===a)return;this._rescaleImage(e,o,a);const n=e.actual,{width:h,height:g}=e.actualCellSize,d=Math.ceil(n.width/h),l=t%d*h,I=Math.floor(t/d)*g,c=i*o,_=A*a,C=s*h+l>n.width?n.width-l:s*h,B=I+g>n.height?n.height-I:g;r.drawImage(n,Math.floor(l),Math.floor(I),Math.ceil(C),Math.ceil(B),Math.floor(c),Math.floor(_),Math.ceil(C*o/h),Math.ceil(B*a/g))}extractTile(e,t){const{width:i,height:A}=this.cellSize;if(-1===i||-1===A)return;this._rescaleImage(e,i,A);const s=e.actual,{width:o,height:a}=e.actualCellSize,n=Math.ceil(s.width/o),h=t%n*o,g=Math.floor(t/n)*a,d=o+h>s.width?s.width-h:o,l=g+a>s.height?s.height-g:a,I=r.createCanvas(this.document,Math.ceil(d*i/o),Math.ceil(l*A/a)),c=I.getContext("2d");return c?(c.drawImage(s,Math.floor(h),Math.floor(g),Math.floor(d),Math.floor(l),0,0,I.width,I.height),I):void 0}drawPlaceholder(e,t,i=1){var A;const s=this._layers.get("top");if(s){const{width:r,height:o}=this.cellSize;if(-1===r||-1===o)return;if(this._placeholder?o>=this._placeholder.height&&this._createPlaceHolder(o+1):this._createPlaceHolder(Math.max(o+1,24)),!this._placeholder)return;s.drawImage(null!==(A=this._placeholderBitmap)&&void 0!==A?A:this._placeholder,e*r,t*o%2?0:1,r*i,o,e*r,t*o,r*i,o)}}rescaleCanvas(){var e,t;const i=(null===(e=this.dimensions)||void 0===e?void 0:e.css.canvas.width)||0,A=(null===(t=this.dimensions)||void 0===t?void 0:t.css.canvas.height)||0;for(const e of this._layers.values())e.canvas.width===i&&e.canvas.height===A||(e.canvas.width=i,e.canvas.height=A)}_rescaleImage(e,t,i){if(t===e.actualCellSize.width&&i===e.actualCellSize.height)return;const{width:A,height:s}=e.origCellSize;if(t===A&&i===s)return e.actual=e.orig,e.actualCellSize.width=A,void(e.actualCellSize.height=s);const o=Math.ceil(e.orig.width*t/A),a=Math.ceil(e.orig.height*i/s);if(o*a>e.orig.width*e.orig.height)return e.actual=e.orig,e.actualCellSize.width=A,void(e.actualCellSize.height=s);const n=r.createCanvas(this.document,o,a),h=n.getContext("2d");h&&(h.drawImage(e.orig,0,0,n.width,n.height),e.actual=n,e.actualCellSize.width=t,e.actualCellSize.height=i)}_open(){this._renderService=this._terminal._core._renderService,this._oldSetRenderer=this._renderService.setRenderer.bind(this._renderService),this._renderService.setRenderer=e=>{var t;for(const e of[...this._layers.keys()])this.removeLayerFromDom(e);null===(t=this._oldSetRenderer)||void 0===t||t.call(this._renderService,e)}}insertLayerToDom(e="top"){var t,i;if(!this.document||!this._terminal._core.screenElement)return void console.warn("image addon: cannot insert output canvas to DOM, missing document or screenElement");if(this._layers.has(e))return;const A=r.createCanvas(this.document,(null===(t=this.dimensions)||void 0===t?void 0:t.css.canvas.width)||0,(null===(i=this.dimensions)||void 0===i?void 0:i.css.canvas.height)||0);A.classList.add(`xterm-image-layer-${e}`);const s=this._terminal._core.screenElement;s.style.isolation="isolate","bottom"===e?(A.style.zIndex="-1",s.insertBefore(A,s.firstChild)):(A.style.zIndex="0",s.appendChild(A));const o=A.getContext("2d",{alpha:!0});o?(this._layers.set(e,o),this.clearAll(e)):A.remove()}removeLayerFromDom(e="top"){const t=this._layers.get(e);t&&(t.canvas.remove(),this._layers.delete(e))}hasLayer(e){return this._layers.has(e)}_createPlaceHolder(e=24){var t;null===(t=this._placeholderBitmap)||void 0===t||t.close(),this._placeholderBitmap=void 0;const i=32,s=r.createCanvas(this.document,i,e),o=s.getContext("2d",{alpha:!1});if(!o)return;const a=r.createImageData(o,i,e),n=new Uint32Array(a.data.buffer),h=(0,A.toRGBA8888)(0,0,0),g=(0,A.toRGBA8888)(255,255,255);n.fill(h);for(let t=0;t{this._placeholder!==I?null==e||e.close():this._placeholderBitmap=e}).catch(()=>{})}get document(){var e;return null===(e=this._terminal._core._coreBrowserService)||void 0===e?void 0:e.window.document}}t.ImageRenderer=r},699(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.ImageStorage=t.CELL_SIZE_DEFAULT=void 0;const A=i(463);t.CELL_SIZE_DEFAULT={width:7,height:14};class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(e){this._ext&=-67108864,this._ext|=67108863&e}get underlineVariantOffset(){const e=(3758096384&this._ext)>>29;return e<0?4294967288^e:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}get urlId(){return this._urlId}set urlId(e){this._urlId=e}constructor(e=0,t=0,i=-1,A=-1){this.imageId=i,this.tileId=A,this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}clone(){return new s(this._ext,this._urlId,this.imageId,this.tileId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId&&-1===this.imageId}}const r=new s;t.ImageStorage=class{constructor(e,t,i){this._terminal=e,this._renderer=t,this._opts=i,this._images=new Map,this._lastId=0,this._lowestId=0,this._fullyCleared=!1,this._needsFullClear=!1,this._pixelLimit=25e5;try{this.setLimit(this._opts.storageLimit)}catch(e){e instanceof Error&&console.error(e.message),console.warn(`storageLimit is set to ${this.getLimit()} MB`)}this._viewportMetrics={cols:this._terminal.cols,rows:this._terminal.rows}}dispose(){this.reset()}reset(){var e;for(const t of this._images.values())null===(e=t.marker)||void 0===e||e.dispose();this._images.clear(),this._renderer.clearAll()}getLimit(){return 4*this._pixelLimit/1e6}setLimit(e){if(e<.5||e>1e3)throw RangeError("invalid storageLimit, should be at least 0.5 MB and not exceed 1G");this._pixelLimit=e/4*1e6>>>0,this._evictOldest(0)}getUsage(){return 4*this._getStoredPixels()/1e6}_getStoredPixels(){let e=0;for(const t of this._images.values())t.orig&&(e+=t.orig.width*t.orig.height,t.actual&&t.actual!==t.orig&&(e+=t.actual.width*t.actual.height));return e}_delImg(e){var t;const i=this._images.get(e);i&&(this._images.delete(e),window.ImageBitmap&&i.orig instanceof ImageBitmap&&i.orig.close(),null===(t=this.onImageDeleted)||void 0===t||t.call(this,e))}wipeAlternate(){var e;const t=[];for(const[i,A]of this._images.entries())"alternate"===A.bufferType&&(null===(e=A.marker)||void 0===e||e.dispose(),t.push(i));for(const e of t)this._delImg(e);this._needsFullClear=!0,this._fullyCleared=!1}deleteImage(e){var t;const i=this._images.get(e);i&&(null===(t=i.marker)||void 0===t||t.dispose(),this._delImg(e))}addImage(e,i){var A,s;this._evictOldest(e.width*e.height);let r=this._renderer.cellSize;-1!==r.width&&-1!==r.height||(r=t.CELL_SIZE_DEFAULT);const o=Math.ceil(e.width/r.width),a=Math.ceil(e.height/r.height),n=++this._lastId,h=this._terminal._core.buffer,g=this._terminal.cols,d=this._terminal.rows,l=h.x,I=h.y;let c=l,_=0;i.scrolling||(h.x=0,h.y=0,c=0),this._terminal._core._inputHandler._dirtyRowTracker.markDirty(h.y);for(let e=0;e=g);++i)this._writeToCell(t,c+i,n,e*o+i),_++;if(i.scrolling)e=d)break;h.x=c}this._terminal._core._inputHandler._dirtyRowTracker.markDirty(h.y),i.scrolling?"iip"===i.cursorPos?h.x=Math.min(c+o,g):h.x=c:(h.x=l,h.y=I);const C=[];for(const[e,t]of this._images.entries())t.tileCount<1&&(null===(A=t.marker)||void 0===A||A.dispose(),C.push(e));for(const e of C)this._delImg(e);const B=this._terminal.registerMarker(0);null==B||B.onDispose(()=>{this._images.get(n)&&this._delImg(n)}),"alternate"===this._terminal.buffer.active.type&&this._evictOnAlternate();const Q={orig:e,origCellSize:r,actual:e,actualCellSize:Object.assign({},r),marker:B||void 0,tileCount:_,bufferType:this._terminal.buffer.active.type,layer:i.layer,zIndex:i.zIndex};return this._images.set(n,Q),null===(s=this.onImageAdded)||void 0===s||s.call(this),n}render(e){var t,i;let A=!1,s=!1;for(const e of this._images.values())if("bottom"===e.layer?s=!0:A=!0,A&&s)break;if(A&&!this._renderer.hasLayer("top")&&(this._renderer.insertLayerToDom("top"),!this._renderer.hasLayer("top")))return;if(s&&!this._renderer.hasLayer("bottom")&&this._renderer.insertLayerToDom("bottom"),this._renderer.rescaleCanvas(),!this._images.size)return this._fullyCleared||(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1),this._renderer.hasLayer("top")&&this._renderer.removeLayerFromDom("top"),void(this._renderer.hasLayer("bottom")&&this._renderer.removeLayerFromDom("bottom"));!A&&this._renderer.hasLayer("top")&&(this._renderer.clearAll("top"),this._renderer.removeLayerFromDom("top")),!s&&this._renderer.hasLayer("bottom")&&(this._renderer.clearAll("bottom"),this._renderer.removeLayerFromDom("bottom")),this._needsFullClear&&(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1);const{start:o,end:a}=e,n=this._terminal._core.buffer,h=this._terminal._core.cols;this._renderer.clearLines(o,a);const g=[],d=[];for(let e=o;e<=a;++e){const A=n.lines.get(e+n.ydisp);if(!A)return;for(let s=0;se.imgSpec.zIndex-t.imgSpec.zIndex);for(const e of d)this._renderer.drawPlaceholder(e.col,e.row,e.count);for(const e of g)this._renderer.draw(e.imgSpec,e.tileId,e.col,e.row,e.count)}viewportResize(e){var t,i;if(!this._images.size)return void(this._viewportMetrics=e);if(this._viewportMetrics.cols>=e.cols)return void(this._viewportMetrics=e);const A=this._terminal._core.buffer,s=A.lines.length,o=this._viewportMetrics.cols-1;for(let a=0;a=h)continue;let g=!1;for(let t=o+1;t>e.cols;++t)if(4194303&s._data[3*t+0]){g=!0;break}if(g)continue;const d=Math.min(e.cols,h-A.tileId%h+o);let l=A.tileId;for(let e=o+1;e4194304&&e.release();const A=null!==(i=n.get(t))&&void 0!==i?i:[];A.length<2&&(A.push(e),n.set(t,A))}function d(e){return A.BIG_ENDIAN?e:(255&e)<<24|(e>>>8&255)<<16|(e>>>16&255)<<8|e>>>24&255}t.SixelHandler=class{constructor(e,t,i){var A;this._opts=e,this._storage=t,this._coreTerminal=i,this._size=0,this._aborted=!1,this._decMemoryLimit=0,this._palette=new Uint32Array(o.LIMITS.PALETTE_SIZE),this._palette.set(a),A=4*this._opts.pixelLimit,h||(h=!0,(0,r.DecoderAsync)({memoryLimit:A,palette:a}).then(e=>g(e,A),()=>{h=!1}))}reset(){this._returnDecoder(),this._palette.fill(0),this._palette.set(a)}hook(e){var t;this._size=0,this._aborted=!1,this._returnDecoder();const i=4*this._opts.pixelLimit;try{this._dec=function(e){var t,i;return null!==(i=null===(t=n.get(e))||void 0===t?void 0:t.pop())&&void 0!==i?i:new r.Decoder({memoryLimit:e,palette:a})}(i)}catch(e){return console.warn(`SIXEL: could not allocate decoder - ${e}`),void(this._aborted=!0)}this._decMemoryLimit=i;const s=1===e.params[1]?0:function(e,t){let i=0;if(!t)return i;if(e.isInverse())if(e.isFgDefault())i=d(t.foreground.rgba);else if(e.isFgRGB()){const t=e.constructor.toColorRGB(e.getFgColor());i=(0,A.toRGBA8888)(...t)}else i=d(t.ansi[e.getFgColor()].rgba);else if(e.isBgDefault())i=d(t.background.rgba);else if(e.isBgRGB()){const t=e.constructor.toColorRGB(e.getBgColor());i=(0,A.toRGBA8888)(...t)}else i=d(t.ansi[e.getBgColor()].rgba);return i}(this._coreTerminal._core._inputHandler._curAttrData,null===(t=this._coreTerminal._core._themeService)||void 0===t?void 0:t.colors);this._dec.init(s,this._palette,this._opts.sixelPaletteLimit)}_returnDecoder(){const e=this._dec;e&&(this._dec=void 0,this._palette.set(e.palette),g(e,this._decMemoryLimit))}put(e,t,i){if(!this._aborted&&this._dec){if(this._size+=i-t,this._size>this._opts.sixelSizeLimit)return console.warn("SIXEL: too much data, aborting"),this._aborted=!0,void this._dec.release();try{this._dec.decode(e,t,i)}catch(e){console.warn(`SIXEL: error while decoding image - ${e}`),this._aborted=!0,this._dec.release()}}}unhook(e){try{return this._unhook(e)}finally{this._returnDecoder()}}_unhook(e){var t;if(this._aborted||!e||!this._dec)return!0;const i=this._dec.width,A=this._dec.height;if(!i||!A)return A&&this._storage.advanceCursor(A),!0;const r=s.ImageRenderer.createCanvas(void 0,i,A);return null===(t=r.getContext("2d"))||void 0===t||t.putImageData(new ImageData(this._dec.data8,i,A),0,0),this._storage.addImage(r),!0}}},994(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.SixelImageStorage=void 0;const A=i(699);t.SixelImageStorage=class{constructor(e,t,i,A){this._storage=e,this._opts=t,this._renderer=i,this._terminal=A,this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"vt340"}}addImage(e){this._addImageOpts.scrolling=this._opts.sixelScrolling,this._storage.addImage(e,this._addImageOpts)}advanceCursor(e){if(this._opts.sixelScrolling){let t=this._renderer.cellSize;-1!==t.width&&-1!==t.height||(t=A.CELL_SIZE_DEFAULT);const i=Math.ceil(e/t.height);for(let e=1;e512)return void(this._aborted=!0);if(this._controlData.set(e.subarray(t,s),this._controlLength),this._controlLength+=r,!this._inControlData){if(this._parsedCommand=(0,n.parseKittyCommand)(this._parseControlDataString()),void 0!==this._parsedCommand.id&&void 0!==this._parsedCommand.imageNumber)return this._sendResponse(this._parsedCommand.id,"EINVAL:cannot specify both i and I keys",null!==(A=this._parsedCommand.quiet)&&void 0!==A?A:0),void(this._aborted=!0);if("d"===this._parsedCommand.action)return;const t=s+1;tthis._encodedSizeLimit){const e=null!==(g=this._activeDecoder)&&void 0!==g?g:null==_?void 0:_.decoder;return e&&e.release(),this._activeDecoder=null,_&&this._removePendingEntry(c),void(this._aborted=!0)}if(!this._decodeError){if((null==_?void 0:_.decoder)&&!this._activeDecoder&&(this._activeDecoder=_.decoder),!this._activeDecoder){const e=this._maxEncodedBytes+131072;if(e>1e6*this._opts.storageLimit)return this._aborted=!0,void(void 0!==(null===(s=this._parsedCommand)||void 0===s?void 0:s.id)&&this._sendResponse(this._parsedCommand.id,"ENOMEM:pending image budget exceeded",null!==(d=this._parsedCommand.quiet)&&void 0!==d?d:0));const t=Math.max(1,Math.floor(1e6*this._opts.storageLimit/e));for(;this._pendingTransmissions.size>=t;){const e=this._pendingTransmissions.entries().next().value;if(!e)break;e[1].decoder.release(),this._removePendingEntry(e[0]),void 0!==e[1].cmd.id&&this._sendResponse(e[1].cmd.id,"ENOMEM:pending image budget exceeded",null!==(l=e[1].cmd.quiet)&&void 0!==l?l:0)}const i=new a.default(4194304,this._maxEncodedBytes,this._initialEncodedBytes);try{i.init()}catch(e){return console.warn("KITTY: could not allocate decoder",e),this._aborted=!0,void(void 0!==(null===(r=this._parsedCommand)||void 0===r?void 0:r.id)&&this._sendResponse(this._parsedCommand.id,"ENOMEM:could not allocate decoder",null!==(I=this._parsedCommand.quiet)&&void 0!==I?I:0))}this._activeDecoder=i}0!==this._activeDecoder.put(e.subarray(t,i))&&(this._activeDecoder.release(),this._activeDecoder=null,this._decodeError=!0,_&&this._removePendingEntry(c))}}end(e){var t,i;if(this._aborted||!e)return this._activeDecoder&&(this._activeDecoder.release(),this._activeDecoder=null),!0;if(this._inControlData)return this._handleNoPayloadCommand();const A=this._parsedCommand;if("d"===A.action)return this._handleDelete(A);const s=null!==(i=null!==(t=A.id)&&void 0!==t?t:this._lastPendingKey)&&void 0!==i?i:0,r=1===A.more,o=this._pendingTransmissions.get(s);if(r)return this._activeDecoder&&(o?(o.totalEncodedSize+=this._totalEncodedSize,o.decodeError=o.decodeError||this._decodeError):this._pendingTransmissions.set(s,{cmd:Object.assign({},A),decoder:this._activeDecoder,totalEncodedSize:this._totalEncodedSize,decodeError:this._decodeError}),this._lastPendingKey=s,this._activeDecoder=null),!0;o&&(this._lastPendingKey=void 0);let a=this._decodeError,n=A,h=this._activeDecoder;o&&(n=o.cmd,h=o.decoder,a=a||o.decodeError,this._pendingTransmissions.delete(s));let g=new Uint8Array(0);h&&(0!==h.end()&&(a=!0),g=h.data8),this._activeDecoder=null;const d=this._handleCommandWithBytesAndCmd(n,g,a);return h&&h.release(),d}_parseControlDataString(){let e="";for(let t=0;t0&&this._sendResponse(e.id,"OK",null!==(o=e.quiet)&&void 0!==o?o:0)),A}case"T":return this._handleTransmitDisplay(e,t,i);case"q":return this._handleQuery(e,t,i);case"p":return this._handlePlacement(e);default:return void 0!==e.id&&this._sendResponse(e.id,"EINVAL:unsupported action",null!==(a=e.quiet)&&void 0!==a?a:0),!0}}_handlePlacement(e){var t;if(void 0===e.id)return!0;const i=e.id,A=this._kittyStorage.getImage(i);return A?this._displayImage(A,e).then(t=>{var A;return this._sendResponse(i,t?"OK":"EINVAL:image rendering failed",null!==(A=e.quiet)&&void 0!==A?A:0,e.placementId),!0}):(this._sendResponse(i,"ENOENT:image not found",null!==(t=e.quiet)&&void 0!==t?t:0,e.placementId),!0)}_handleTransmit(e,t,i){var A,s,r,o,a,n;return"d"!==(null!==(A=e.transmission)&&void 0!==A?A:"d")?(void 0!==e.id&&this._sendResponse(e.id,"EINVAL:unsupported transmission medium",null!==(s=e.quiet)&&void 0!==s?s:0),!0):(i||0===t.length||this._kittyStorage.storeImage(e.id,{data:new Blob([t]),width:null!==(r=e.width)&&void 0!==r?r:0,height:null!==(o=e.height)&&void 0!==o?o:0,format:null!==(a=e.format)&&void 0!==a?a:32,compression:null!==(n=e.compression)&&void 0!==n?n:""}),!0)}_handleTransmitDisplay(e,t,i){var A,s;if(i)return void 0!==e.id&&this._sendResponse(e.id,"EINVAL:invalid base64 data",null!==(A=e.quiet)&&void 0!==A?A:0),!0;this._handleTransmit(e,t,i);const r=null!==(s=e.id)&&void 0!==s?s:this._kittyStorage.lastImageId,o=this._kittyStorage.getImage(r);if(o){const t=this._displayImage(o,e);return void 0!==e.id?t.then(t=>{var i;return this._sendResponse(r,t?"OK":"EINVAL:image rendering failed",null!==(i=e.quiet)&&void 0!==i?i:0),!0}):t.then(()=>!0)}return!0}_handleQuery(e,t,i){var A,s,r,o,a,n;const h=null!==(A=e.id)&&void 0!==A?A:0,g=null!==(s=e.quiet)&&void 0!==s?s:0;if("d"!==(null!==(r=e.transmission)&&void 0!==r?r:"d"))return this._sendResponse(h,"EINVAL:unsupported transmission medium",g),!0;if(i)return this._sendResponse(h,"EINVAL:invalid base64 data",g),!0;if(0===t.length)return this._sendResponse(h,"OK",g),!0;const d=null!==(o=e.format)&&void 0!==o?o:32;if(100===d)this._sendResponse(h,"OK",g);else{const i=null!==(a=e.width)&&void 0!==a?a:0,A=null!==(n=e.height)&&void 0!==n?n:0;if(!i||!A)return this._sendResponse(h,"EINVAL:width and height required for raw pixel data",g),!0;const s=i*A*(32===d?4:3);if(t.length=1)return;if(!s&&i>=2)return;const r=`_Gi=${e}${A?`,p=${A}`:""};${t}\\`;this._coreTerminal._core.coreService.triggerDataEvent(r)}_displayImage(e,t){return this._decodeAndDisplay(e,t).then(()=>!0).catch(()=>!1)}async _decodeAndDisplay(e,t){var i,A,o,a,n,h,g;const d=this._generation;let l=await this._createBitmap(e);try{if(d!==this._generation)throw new Error("image decode canceled");const I=Math.max(0,null!==(o=t.x)&&void 0!==o?o:0),c=Math.max(0,null!==(a=t.y)&&void 0!==a?a:0),_=t.sourceWidth||l.width-I,C=t.sourceHeight||l.height-c,B=Math.max(0,l.width-I),Q=Math.max(0,l.height-c),m=Math.max(0,Math.min(_,B)),u=Math.max(0,Math.min(C,Q));if(0===m||0===u)throw new Error("invalid source rectangle");if(0!==I||0!==c||m!==l.width||u!==l.height){const e=await createImageBitmap(l,I,c,m,u);l.close(),l=e}const E=(null===(i=this._renderer.dimensions)||void 0===i?void 0:i.css.cell.width)||r.CELL_SIZE_DEFAULT.width,p=(null===(A=this._renderer.dimensions)||void 0===A?void 0:A.css.cell.height)||r.CELL_SIZE_DEFAULT.height;let w,f;void 0!==t.columns&&void 0!==t.rows?(w=t.columns,f=t.rows):void 0!==t.columns?(w=t.columns,f=Math.max(1,Math.ceil(l.height/l.width*(w*E)/p))):void 0!==t.rows?(f=t.rows,w=Math.max(1,Math.ceil(l.width/l.height*(f*p)/E))):(w=Math.ceil(l.width/E),f=Math.ceil(l.height/p));let D=l.width,y=l.height;if(void 0===t.columns&&void 0===t.rows||(D=Math.round(w*E),y=Math.round(f*p)),D*y>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");const v=this._coreTerminal._core.buffer,M=v.x,k=v.y,b=v.ybase,S=void 0!==t.zIndex&&t.zIndex<0?"bottom":"top";if(D!==l.width||y!==l.height){const e=await createImageBitmap(l,{resizeWidth:D,resizeHeight:y});l.close(),l=e}const L=Math.min(Math.max(0,null!==(n=t.xOffset)&&void 0!==n?n:0),E-1),N=Math.min(Math.max(0,null!==(h=t.yOffset)&&void 0!==h?h:0),p-1);if(0!==L||0!==N){const e=void 0!==t.columns?Math.round(w*E):l.width+L,i=void 0!==t.rows?Math.round(f*p):l.height+N,A=s.ImageRenderer.createCanvas(window.document,e,i),r=A.getContext("2d");if(!r)throw new Error("Failed to create offset canvas context");r.drawImage(l,L,N);const o=await createImageBitmap(A);if(A.width=A.height=0,l.close(),l=o,D=l.width,y=l.height,D*y>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");void 0===t.columns&&(w=Math.ceil(l.width/E)),void 0===t.rows&&(f=Math.ceil(l.height/p))}if(d!==this._generation)throw new Error("image decode canceled");const G=null!==(g=t.zIndex)&&void 0!==g?g:0;if(this._kittyStorage.addImage(e.id,l,!0,S,G),l=void 0,1===t.cursorMovement){const e=v.ybase-b;v.x=M,v.y=Math.max(k-e,0)}else v.x=Math.min(M+w,this._coreTerminal.cols)}catch(e){throw null==l||l.close(),e}}async _createBitmap(e){let t=new Uint8Array(await e.data.arrayBuffer());if("z"===e.compression&&(t=await this._decompressZlib(t)),100===e.format){const e=(0,o.imageType)(t);if("image/png"!==e.mime||!(e.width>0)||!(e.height>0)||e.width*e.height>this._opts.pixelLimit)throw new RangeError("PNG exceeds pixel limit or has invalid dimensions");const i=new Blob([t],{type:"image/png"});if(!window.createImageBitmap){const e=URL.createObjectURL(i),t=new Image;return new Promise((i,A)=>{t.addEventListener("load",()=>{var r;URL.revokeObjectURL(e);const o=s.ImageRenderer.createCanvas(window.document,t.width,t.height);null===(r=o.getContext("2d"))||void 0===r||r.drawImage(t,0,0),createImageBitmap(o).then(i).catch(A)}),t.addEventListener("error",()=>{URL.revokeObjectURL(e),A(new Error("Failed to load image"))}),t.src=e})}return createImageBitmap(i)}const i=e.width,A=e.height;if(!i||!A)throw new Error("Width and height required for raw pixel data");const r=i*A*(32===e.format?4:3);if(t.length>>24|t<<8,g[I++]=4278190080|t>>>16|i<<16,g[I++]=4278190080|i>>>8}let c=3*d,_=4*d;for(let e=d;e=e.length)return void t.close();const i=Math.min(A+4096,e.length);t.enqueue(new Uint8Array(e.subarray(A,i))),A=i}}).pipeThrough(new DecompressionStream(t)).getReader(),r=[];let o=0;try{for(;;){const{done:e,value:t}=await s.read();if(e)break;if(o+=t.byteLength,o>i)throw await s.cancel().catch(()=>{}),new RangeError("decompressed image exceeds byte limit");r.push(t)}}finally{s.releaseLock()}const a=new Uint8Array(o);let n=0;for(const e of r)a.set(e,n),n+=e.length;return a}get images(){return this._kittyStorage.images}get _kittyIdToStorageId(){return this._kittyStorage.kittyIdToStorageId}get pendingTransmissions(){return this._pendingTransmissions}}},470(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.parseKittyCommand=function(e){const t={},i=e.split(",");for(const e of i){const i=e.indexOf("=");if(-1===i)continue;const A=e.substring(0,i),s=e.substring(i+1);if("a"===A){t.action=s;continue}if("o"===A){t.compression=s;continue}if("t"===A){t.transmission=s;continue}if("d"===A){t.deleteSelector=s;continue}const r=parseInt(s,10);switch(A){case"f":t.format=r;break;case"i":t.id=r;break;case"I":t.imageNumber=r;break;case"s":t.width=r;break;case"v":t.height=r;break;case"x":t.x=r;break;case"y":t.y=r;break;case"w":t.sourceWidth=r;break;case"h":t.sourceHeight=r;break;case"X":t.xOffset=r;break;case"Y":t.yOffset=r;break;case"c":t.columns=r;break;case"r":t.rows=r;break;case"m":t.more=r;break;case"q":t.quiet=r;break;case"C":t.cursorMovement=r;break;case"z":t.zIndex=r;break;case"p":t.placementId=r}}return t}},235(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.KittyImageStorage=void 0;class i{constructor(e){this._storage=e,this._nextImageId=1,this._images=new Map,this._kittyIdToStorageId=new Map,this._storageIdToKittyId=new Map,this._handleStorageImageDeleted=e=>{const t=this._storageIdToKittyId.get(e);void 0!==t&&(this._kittyIdToStorageId.delete(t),this._storageIdToKittyId.delete(e),this._images.delete(t))},this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"iip"},this._previousOnImageDeleted=this._storage.onImageDeleted,this._wrappedOnImageDeleted=e=>{var t;null===(t=this._previousOnImageDeleted)||void 0===t||t.call(this,e),this._handleStorageImageDeleted(e)},this._storage.onImageDeleted=this._wrappedOnImageDeleted}reset(){this._nextImageId=1,this._images.clear(),this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}dispose(){this.reset(),this._storage.onImageDeleted===this._wrappedOnImageDeleted&&(this._storage.onImageDeleted=this._previousOnImageDeleted)}storeImage(e,t){const A=null!=e?e:this._nextImageId++,s=this._kittyIdToStorageId.get(A);void 0!==s&&(this._storage.deleteImage(s),this._kittyIdToStorageId.delete(A),this._storageIdToKittyId.delete(s)),!this._images.has(A)&&this._images.size>=i._maxStoredImages&&this._evictUndisplayedImages();const r=1e6*this._storage.getLimit();this._images.delete(A);let o=0;for(const e of this._images.values())o+=e.data.size;for(const e of[!1,!0])for(const[i,A]of this._images){if(o+t.data.size<=r)break;this._kittyIdToStorageId.has(i)===e&&(o-=A.data.size,this.deleteById(i))}return this._images.set(A,Object.assign(Object.assign({},t),{id:A})),A}addImage(e,t,i,A,s){const r=this._kittyIdToStorageId.get(e);void 0!==r&&this._storageIdToKittyId.delete(r),this._addImageOpts.scrolling=i,this._addImageOpts.layer=A,this._addImageOpts.zIndex=s;const o=this._storage.addImage(t,this._addImageOpts);this._kittyIdToStorageId.set(e,o),this._storageIdToKittyId.set(o,e)}getImage(e){return this._images.get(e)}deleteById(e){this._images.delete(e);const t=this._kittyIdToStorageId.get(e);void 0!==t&&(this._storage.deleteImage(t),this._kittyIdToStorageId.delete(e),this._storageIdToKittyId.delete(t))}deleteAll(){this._images.clear();for(const e of this._kittyIdToStorageId.values())this._storage.deleteImage(e);this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}get images(){return this._images}get kittyIdToStorageId(){return this._kittyIdToStorageId}get lastImageId(){return this._nextImageId-1}_evictUndisplayedImages(){for(const[e]of this._images){if(this._images.size<=i._maxStoredImages/2)break;this._kittyIdToStorageId.has(e)||this._images.delete(e)}}}t.KittyImageStorage=i,i._maxStoredImages=256},669(e,t,i){Object.defineProperty(t,"__esModule",{value:!0});const A=(0,i(552).InWasm)({s:1,t:0,d:"AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML"}),s=new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map(e=>e.charCodeAt(0))),r=new Uint32Array(1024);r.fill(4278190080);for(let e=0;e>4|(e<<4&255)<<8;for(let e=0;e>2<<8|(e<<6&255)<<16;for(let e=0;ethis.maxBytes||this.maxBytes>4294901760)throw new Error("invalid byte settings")}get data8(){return this._inst?this._d.subarray(0,this._m32[1282]):o}release(){this._inst&&(this._bytes>this.keepSize?this._inst=this._m32=this._d=this._mem=null:(this._m32[1280]=0,this._m32[1281]=0,this._m32[1282]=0))}init(e,t){if(this.maxBytes=null!=e?e:this.maxBytes,this._bytes=null!=t?t:Math.min(this._bytes,this.maxBytes),this._bytes>this.maxBytes||this.maxBytes>4294901760)throw Error("invalid byte settings");let i=this._m32;const s=this._bytes+5152;this._inst?this._mem.buffer.byteLengththis.maxBytes)return-3;let e=this._bytes;for(;(e*=2)this._mem.buffer.byteLength){const t=Math.ceil((e+5152-this._mem.buffer.byteLength)/65536);this._mem.grow(t),this._m32=new Uint32Array(this._mem.buffer,0),this._d=new Uint8Array(this._mem.buffer,5152)}this._bytes=e}return 0}put(e){if(!this._inst||this._ended)return-2;if(this._realloc(e.length))return-3;const t=this._m32;return this._d.set(e,t[1280]),t[1280]+=e.length,t[1280]-t[1281]>=131072?this._inst.exports.dec():0}end(){return this._ended=!0,this._inst?this._inst.exports.end():-2}get loadedBytes(){return this._inst?this._m32[1280]:0}get freeBytes(){return this._inst?this.maxBytes-this._m32[1280]:0}}},61(e,t,i){Object.defineProperty(t,"__esModule",{value:!0});const A=(0,i(552).InWasm)({s:1,t:0,d:"AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=="});t.default=class{constructor(e){this.keepSize=e,this.width=0,this.height=0}decode(e){this.width=e[4]<<24|e[5]<<16|e[6]<<8|e[7],this.height=e[8]<<24|e[9]<<16|e[10]<<8|e[11];const t=this.width*this.height,i=4*t,s=e.length,r=Math.max(i,s)+(Math.min(i,s)>>1)+4096;this._inst?this._mem.buffer.byteLengththis.keepSize&&(this._inst=this._d=this._mem=null)}}},552(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.InWasm=function(e){if(e.d){const{t,s:A,d:s}=e;let r,o;const a=WebAssembly;return 0===t?A?e=>new a.Instance(o||(o=new a.Module(r||(r=i(s)))),e):e=>o?a.instantiate(o,e):a.instantiate(r||(r=i(s)),e).then(e=>(o=e.module)&&e.instance):1===t?A?()=>o||(o=new a.Module(r||(r=i(s)))):()=>o?Promise.resolve(o):a.compile(r||(r=i(s))).then(e=>o=e):A?()=>r||(r=i(s)):()=>Promise.resolve(r||(r=i(s)))}if("undefined"==typeof _wasmCtx)throw new Error('must run "inwasm"');_wasmCtx.add(e)};let i=e=>{if(Uint8Array.fromBase64)return Uint8Array.fromBase64(e);if("undefined"!=typeof Buffer)return Buffer.from(e,"base64");const t=atob(e),i=new Uint8Array(t.length);for(let e=0;e{var e=A;Object.defineProperty(e,"__esModule",{value:!0}),e.ImageAddon=void 0;const t=i(414),s=i(795),r=i(463),o=i(699),a=i(23),n=i(235),h=i(566),g=i(994),d=i(649),l={enableSizeReports:!0,pixelLimit:16777216,sixelSupport:!0,sixelScrolling:!0,sixelPaletteLimit:4096,sixelSizeLimit:33554432,storageLimit:128,showPlaceholder:!0,iipSupport:!0,iipSizeLimit:33554432,kittySupport:!0,kittySizeLimit:33554432};e.ImageAddon=class{constructor(e){this._disposables=[],this._handlers=new Map,this._onImageAdded=new t.Emitter,this.onImageAdded=this._onImageAdded.event,this._opts=Object.assign({},l,e),this._defaultOpts=Object.assign({},l,e)}dispose(){for(const e of this._handlers.values())e.reset();for(const e of this._disposables)e.dispose();this._disposables.length=0,this._handlers.clear(),this._onImageAdded.dispose()}_disposeLater(...e){for(const t of e)this._disposables.push(t)}activate(e){var t;if(this._terminal=e,this._renderer=new r.ImageRenderer(e),this._storage=new o.ImageStorage(e,this._renderer,this._opts),this._storage.onImageAdded=()=>this._onImageAdded.fire(),this._opts.enableSizeReports){const i=null!==(t=e.options.windowOptions)&&void 0!==t?t:{};i.getWinSizePixels=!0,i.getCellSizePixels=!0,i.getWinSizeChars=!0,e.options.windowOptions=i}if(this._disposeLater(this._renderer,this._storage,e.parser.registerCsiHandler({prefix:"?",final:"h"},e=>this._decset(e)),e.parser.registerCsiHandler({prefix:"?",final:"l"},e=>this._decrst(e)),e.parser.registerCsiHandler({final:"c"},e=>this._da1(e)),e.parser.registerCsiHandler({prefix:"?",final:"S"},e=>this._xtermGraphicsAttributes(e)),e.onRender(e=>{var t;return null===(t=this._storage)||void 0===t?void 0:t.render(e)}),e.parser.registerCsiHandler({intermediates:"!",final:"p"},()=>this.reset()),e.parser.registerEscHandler({final:"c"},()=>this.reset()),e._core._inputHandler.onRequestReset(()=>this.reset()),e.buffer.onBufferChange(()=>{var e;return null===(e=this._storage)||void 0===e?void 0:e.wipeAlternate()}),e.onResize(e=>{var t;return null===(t=this._storage)||void 0===t?void 0:t.viewportResize(e)})),this._opts.sixelSupport){const t=new g.SixelImageStorage(this._storage,this._opts,this._renderer,e),i=new h.SixelHandler(this._opts,t,e);this._handlers.set("sixel",i),this._disposeLater(e._core._inputHandler._parser.registerDcsHandler({final:"q"},i))}if(this._opts.iipSupport){const t=new d.IIPImageStorage(this._storage),i=new s.IIPHandler(this._opts,this._renderer,t,e);this._handlers.set("iip",i),this._disposeLater(e._core._inputHandler._parser.registerOscHandler(1337,i))}if(this._opts.kittySupport){const t=new n.KittyImageStorage(this._storage),i=new a.KittyGraphicsHandler(this._opts,this._renderer,t,e);this._handlers.set("kitty",i),this._disposeLater(t,i,e._core._inputHandler._parser.registerApcHandler({final:"G"},i))}}reset(){var e;this._opts.sixelScrolling=this._defaultOpts.sixelScrolling,this._opts.sixelPaletteLimit=this._defaultOpts.sixelPaletteLimit,null===(e=this._storage)||void 0===e||e.reset();for(const e of this._handlers.values())e.reset();return!1}get storageLimit(){var e;return(null===(e=this._storage)||void 0===e?void 0:e.getLimit())||-1}set storageLimit(e){var t;null===(t=this._storage)||void 0===t||t.setLimit(e),this._opts.storageLimit=e}get storageUsage(){return this._storage?this._storage.getUsage():-1}get showPlaceholder(){return this._opts.showPlaceholder}set showPlaceholder(e){var t;this._opts.showPlaceholder=e,null===(t=this._renderer)||void 0===t||t.showPlaceholder(e)}getImageAtBufferCell(e,t){var i;return null===(i=this._storage)||void 0===i?void 0:i.getImageAtBufferCell(e,t)}extractTileAtBufferCell(e,t){var i;return null===(i=this._storage)||void 0===i?void 0:i.extractTileAtBufferCell(e,t)}_report(e){var t;null===(t=this._terminal)||void 0===t||t._core.input(e,!1)}_decset(e){for(let t=0;t2&&!(e[2]instanceof Array)&&e[2]<=4096?(this._opts.sixelPaletteLimit=e[2],this._report(`[?${e[0]};0;${this._opts.sixelPaletteLimit}S`)):this._report(`[?${e[0]};2S`),!0;case 4:return this._report(`[?${e[0]};0;4096S`),!0;default:return this._report(`[?${e[0]};2S`),!0}if(2===e[0])switch(e[1]){case 1:let n=null===(i=null===(t=this._renderer)||void 0===t?void 0:t.dimensions)||void 0===i?void 0:i.css.canvas.width,h=null===(s=null===(A=this._renderer)||void 0===A?void 0:A.dimensions)||void 0===s?void 0:s.css.canvas.height;if(!n||!h){const e=o.CELL_SIZE_DEFAULT;n=((null===(r=this._terminal)||void 0===r?void 0:r.cols)||80)*e.width,h=((null===(a=this._terminal)||void 0===a?void 0:a.rows)||24)*e.height}if(n*h {\nreturn ","\"use strict\";\n/**\n * Copyright (c) 2019 Joerg Breitbart.\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.DEFAULT_FOREGROUND = exports.DEFAULT_BACKGROUND = exports.PALETTE_ANSI_256 = exports.PALETTE_VT340_GREY = exports.PALETTE_VT340_COLOR = exports.normalizeHLS = exports.normalizeRGB = exports.nearestColorIndex = exports.fromRGBA8888 = exports.toRGBA8888 = exports.alpha = exports.blue = exports.green = exports.red = exports.BIG_ENDIAN = void 0;\n// FIXME: cleanup this mess, move things either to decoder/encoder, keep only shared things\n// system endianess\nexports.BIG_ENDIAN = new Uint8Array(new Uint32Array([0xFF000000]).buffer)[0] === 0xFF;\nif (exports.BIG_ENDIAN) {\n console.warn('BE platform detected. This version of node-sixel works only on LE properly.');\n}\n// channel values\nfunction red(n) {\n return n & 0xFF;\n}\nexports.red = red;\nfunction green(n) {\n return (n >>> 8) & 0xFF;\n}\nexports.green = green;\nfunction blue(n) {\n return (n >>> 16) & 0xFF;\n}\nexports.blue = blue;\nfunction alpha(n) {\n return (n >>> 24) & 0xFF;\n}\nexports.alpha = alpha;\n/**\n * Convert RGB channels to native color RGBA8888.\n */\nfunction toRGBA8888(r, g, b, a = 255) {\n return ((a & 0xFF) << 24 | (b & 0xFF) << 16 | (g & 0xFF) << 8 | (r & 0xFF)) >>> 0; // ABGR32\n}\nexports.toRGBA8888 = toRGBA8888;\n/**\n * Convert native color to [r, g, b, a].\n */\nfunction fromRGBA8888(color) {\n return [color & 0xFF, (color >> 8) & 0xFF, (color >> 16) & 0xFF, color >>> 24];\n}\nexports.fromRGBA8888 = fromRGBA8888;\n/**\n * Get index of nearest color in `palette` for `color`.\n * Uses euclidean distance without any luminescence correction.\n */\nfunction nearestColorIndex(color, palette) {\n const r = red(color);\n const g = green(color);\n const b = blue(color);\n let min = Number.MAX_SAFE_INTEGER;\n let idx = -1;\n // use euclidean distance (manhattan gives very poor results)\n for (let i = 0; i < palette.length; ++i) {\n const dr = r - palette[i][0];\n const dg = g - palette[i][1];\n const db = b - palette[i][2];\n const d = dr * dr + dg * dg + db * db;\n if (!d)\n return i;\n if (d < min) {\n min = d;\n idx = i;\n }\n }\n return idx;\n}\nexports.nearestColorIndex = nearestColorIndex;\n// color conversions\n// HLS taken from: http://www.niwa.nu/2013/05/math-behind-colorspace-conversions-rgb-hsl\nfunction clamp(low, high, value) {\n return Math.max(low, Math.min(value, high));\n}\nfunction h2c(t1, t2, c) {\n if (c < 0)\n c += 1;\n if (c > 1)\n c -= 1;\n return c * 6 < 1\n ? t2 + (t1 - t2) * 6 * c\n : c * 2 < 1\n ? t1\n : c * 3 < 2\n ? t2 + (t1 - t2) * (4 - c * 6)\n : t2;\n}\nfunction HLStoRGB(h, l, s) {\n if (!s) {\n const v = Math.round(l * 255);\n return toRGBA8888(v, v, v);\n }\n const t1 = l < 0.5 ? l * (1 + s) : l + s - l * s;\n const t2 = 2 * l - t1;\n return toRGBA8888(clamp(0, 255, Math.round(h2c(t1, t2, h + 1 / 3) * 255)), clamp(0, 255, Math.round(h2c(t1, t2, h) * 255)), clamp(0, 255, Math.round(h2c(t1, t2, h - 1 / 3) * 255)));\n}\n/**\n * Normalize SIXEL RGB values (percent based, 0-100) to RGBA8888.\n */\nfunction normalizeRGB(r, g, b) {\n return (0xFF000000 | Math.round(b / 100 * 255) << 16 | Math.round(g / 100 * 255) << 8 | Math.round(r / 100 * 255)) >>> 0; // ABGR32\n}\nexports.normalizeRGB = normalizeRGB;\n/**\n * Normalize SIXEL HLS values to RGBA8888. Applies hue correction of +240°.\n */\nfunction normalizeHLS(h, l, s) {\n // Note: hue value is turned by 240° in VT340, all values given as fractions\n return HLStoRGB((h + 240 % 360) / 360, l / 100, s / 100);\n}\nexports.normalizeHLS = normalizeHLS;\n/**\n * default palettes\n */\n// FIXME: move palettes to Decoder.ts\n/**\n * 16 predefined color registers of VT340 (values in %):\n * ```\n * R G B\n * 0 Black 0 0 0\n * 1 Blue 20 20 80\n * 2 Red 80 13 13\n * 3 Green 20 80 20\n * 4 Magenta 80 20 80\n * 5 Cyan 20 80 80\n * 6 Yellow 80 80 20\n * 7 Gray 50% 53 53 53\n * 8 Gray 25% 26 26 26\n * 9 Blue* 33 33 60\n * 10 Red* 60 26 26\n * 11 Green* 33 60 33\n * 12 Magenta* 60 33 60\n * 13 Cyan* 33 60 60\n * 14 Yellow* 60 60 33\n * 15 Gray 75% 80 80 80\n * ```\n * (*) less saturated\n *\n * @see https://vt100.net/docs/vt3xx-gp/chapter2.html#S2.4\n*/\nexports.PALETTE_VT340_COLOR = new Uint32Array([\n normalizeRGB(0, 0, 0),\n normalizeRGB(20, 20, 80),\n normalizeRGB(80, 13, 13),\n normalizeRGB(20, 80, 20),\n normalizeRGB(80, 20, 80),\n normalizeRGB(20, 80, 80),\n normalizeRGB(80, 80, 20),\n normalizeRGB(53, 53, 53),\n normalizeRGB(26, 26, 26),\n normalizeRGB(33, 33, 60),\n normalizeRGB(60, 26, 26),\n normalizeRGB(33, 60, 33),\n normalizeRGB(60, 33, 60),\n normalizeRGB(33, 60, 60),\n normalizeRGB(60, 60, 33),\n normalizeRGB(80, 80, 80)\n]);\n/**\n * 16 predefined monochrome registers of VT340 (values in %):\n * ```\n * R G B\n * 0 Black 0 0 0\n * 1 Gray-2 13 13 13\n * 2 Gray-4 26 26 26\n * 3 Gray-6 40 40 40\n * 4 Gray-1 6 6 6\n * 5 Gray-3 20 20 20\n * 6 Gray-5 33 33 33\n * 7 White 7 46 46 46\n * 8 Black 0 0 0 0\n * 9 Gray-2 13 13 13\n * 10 Gray-4 26 26 26\n * 11 Gray-6 40 40 40\n * 12 Gray-1 6 6 6\n * 13 Gray-3 20 20 20\n * 14 Gray-5 33 33 33\n * 15 White 7 46 46 46\n * ```\n *\n * @see https://vt100.net/docs/vt3xx-gp/chapter2.html#S2.4\n */\nexports.PALETTE_VT340_GREY = new Uint32Array([\n normalizeRGB(0, 0, 0),\n normalizeRGB(13, 13, 13),\n normalizeRGB(26, 26, 26),\n normalizeRGB(40, 40, 40),\n normalizeRGB(6, 6, 6),\n normalizeRGB(20, 20, 20),\n normalizeRGB(33, 33, 33),\n normalizeRGB(46, 46, 46),\n normalizeRGB(0, 0, 0),\n normalizeRGB(13, 13, 13),\n normalizeRGB(26, 26, 26),\n normalizeRGB(40, 40, 40),\n normalizeRGB(6, 6, 6),\n normalizeRGB(20, 20, 20),\n normalizeRGB(33, 33, 33),\n normalizeRGB(46, 46, 46)\n]);\n/**\n * 256 predefined ANSI colors.\n *\n * @see https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit\n */\nexports.PALETTE_ANSI_256 = (() => {\n // 16 lower colors (taken from xterm)\n const p = [\n toRGBA8888(0, 0, 0),\n toRGBA8888(205, 0, 0),\n toRGBA8888(0, 205, 0),\n toRGBA8888(205, 205, 0),\n toRGBA8888(0, 0, 238),\n toRGBA8888(205, 0, 205),\n toRGBA8888(0, 250, 205),\n toRGBA8888(229, 229, 229),\n toRGBA8888(127, 127, 127),\n toRGBA8888(255, 0, 0),\n toRGBA8888(0, 255, 0),\n toRGBA8888(255, 255, 0),\n toRGBA8888(92, 92, 255),\n toRGBA8888(255, 0, 255),\n toRGBA8888(0, 255, 255),\n toRGBA8888(255, 255, 255),\n ];\n // colors up to 232\n const d = [0, 95, 135, 175, 215, 255];\n for (let r = 0; r < 6; ++r) {\n for (let g = 0; g < 6; ++g) {\n for (let b = 0; b < 6; ++b) {\n p.push(toRGBA8888(d[r], d[g], d[b]));\n }\n }\n }\n // grey scale to up 255\n for (let v = 8; v <= 238; v += 10) {\n p.push(toRGBA8888(v, v, v));\n }\n return new Uint32Array(p);\n})();\n/**\n * Background: Black by default.\n * Foreground: White by default.\n *\n * Background color is used whenever a fill color is needed and not explicitly set.\n * Foreground color is used as default initial sixel color.\n */\nexports.DEFAULT_BACKGROUND = toRGBA8888(0, 0, 0, 255);\nexports.DEFAULT_FOREGROUND = toRGBA8888(255, 255, 255, 255);\n//# sourceMappingURL=Colors.js.map","\"use strict\";\n/**\n * Copyright (c) 2021 Joerg Breitbart.\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.decodeAsync = exports.decode = exports.Decoder = exports.DecoderAsync = void 0;\nconst Colors_1 = require(\"./Colors\");\nconst wasm_1 = require(\"./wasm\");\n/* istanbul ignore next */\nfunction decodeBase64(s) {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(s, 'base64');\n }\n const bytestring = atob(s);\n const result = new Uint8Array(bytestring.length);\n for (let i = 0; i < result.length; ++i) {\n result[i] = bytestring.charCodeAt(i);\n }\n return result;\n}\nconst WASM_BYTES = decodeBase64(wasm_1.LIMITS.BYTES);\nlet WASM_MODULE;\n// empty canvas\nconst NULL_CANVAS = new Uint32Array();\n// proxy for lazy binding of decoder methods to wasm env callbacks\nclass CallbackProxy {\n constructor() {\n this.bandHandler = (width) => 1;\n this.modeHandler = (mode) => 1;\n }\n handle_band(width) {\n return this.bandHandler(width);\n }\n mode_parsed(mode) {\n return this.modeHandler(mode);\n }\n}\n// default decoder options\nconst DEFAULT_OPTIONS = {\n memoryLimit: 2048 * 65536,\n sixelColor: Colors_1.DEFAULT_FOREGROUND,\n fillColor: Colors_1.DEFAULT_BACKGROUND,\n palette: Colors_1.PALETTE_VT340_COLOR,\n paletteLimit: wasm_1.LIMITS.PALETTE_SIZE,\n truncate: true\n};\n/**\n * Create a decoder instance asynchronously.\n * To be used in the browser main thread.\n */\nfunction DecoderAsync(opts) {\n const cbProxy = new CallbackProxy();\n const importObj = {\n env: {\n handle_band: cbProxy.handle_band.bind(cbProxy),\n mode_parsed: cbProxy.mode_parsed.bind(cbProxy)\n }\n };\n return WebAssembly.instantiate(WASM_MODULE || WASM_BYTES, importObj)\n .then((inst) => {\n WASM_MODULE = WASM_MODULE || inst.module;\n return new Decoder(opts, inst.instance || inst, cbProxy);\n });\n}\nexports.DecoderAsync = DecoderAsync;\n/**\n * Decoder - web assembly based sixel stream decoder.\n *\n * Usage pattern:\n * - call `init` to initialize decoder for new image\n * - feed data chunks to `decode` or `decodeString`\n * - grab pixels from `data32`\n * - optional: call `release` to free memory (e.g. after big images)\n * - start over with next image by calling `init`\n *\n * Properties:\n * - max width of 2^14 - 4 pixels (compile time setting in wasm)\n * - no explicit height limit (only limited by memory)\n * - max 4096 colors palette (compile time setting in wasm)\n *\n * Explanation operation modes:\n * - M1 Mode chosen for level 1 images (no raster attributes),\n * or for level 2 images with `truncate=false`.\n * - M2 Mode chosen for level 2 images with `truncate=true` (default).\n * While this mode is not fully spec conform (decoder not expected to truncate),\n * it is what spec conform encoders should create (should not excess raster).\n * This mode has several advantages:\n * - ~15% faster decoding speed\n * - image dimensions can be evaluated early without processing the whole data\n * - faster pixel access in `data32` (precalulated)\n * - image height is not reported as multiple of 6 pixels\n * - M0 Undecided mode state after `init`.\n * The level of an image is determined during early decoding based on the fact,\n * whether the data contains valid raster attributes before any sixel data.\n * Until then the mode of an image is marked as M0, meaning the real operation mode\n * could not be decided yet.\n */\nclass Decoder {\n /**\n * Synchonous ctor. Can be called from nodejs or a webworker context.\n * For instantiation in the browser main thread use `WasmDecoderAsync` instead.\n */\n constructor(opts, _instance, _cbProxy) {\n this._PIXEL_OFFSET = wasm_1.LIMITS.MAX_WIDTH + 4;\n this._canvas = NULL_CANVAS;\n this._bandWidths = [];\n this._maxWidth = 0;\n this._minWidth = wasm_1.LIMITS.MAX_WIDTH;\n this._lastOffset = 0;\n this._currentHeight = 0;\n this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);\n if (this._opts.paletteLimit > wasm_1.LIMITS.PALETTE_SIZE) {\n throw new Error(`DecoderOptions.paletteLimit must not exceed ${wasm_1.LIMITS.PALETTE_SIZE}`);\n }\n if (!_instance) {\n const module = WASM_MODULE || (WASM_MODULE = new WebAssembly.Module(WASM_BYTES));\n _instance = new WebAssembly.Instance(module, {\n env: {\n handle_band: this._handle_band.bind(this),\n mode_parsed: this._initCanvas.bind(this)\n }\n });\n }\n else {\n _cbProxy.bandHandler = this._handle_band.bind(this);\n _cbProxy.modeHandler = this._initCanvas.bind(this);\n }\n this._instance = _instance;\n this._wasm = this._instance.exports;\n this._chunk = new Uint8Array(this._wasm.memory.buffer, this._wasm.get_chunk_address(), wasm_1.LIMITS.CHUNK_SIZE);\n this._states = new Uint32Array(this._wasm.memory.buffer, this._wasm.get_state_address(), 12);\n this._palette = new Uint32Array(this._wasm.memory.buffer, this._wasm.get_palette_address(), wasm_1.LIMITS.PALETTE_SIZE);\n this._palette.set(this._opts.palette);\n this._pSrc = new Uint32Array(this._wasm.memory.buffer, this._wasm.get_p0_address());\n this._wasm.init(Colors_1.DEFAULT_FOREGROUND, 0, this._opts.paletteLimit, 0);\n }\n // some readonly parser states for internal usage\n get _fillColor() { return this._states[0]; }\n get _truncate() { return this._states[8]; }\n get _rasterWidth() { return this._states[6]; }\n get _rasterHeight() { return this._states[7]; }\n get _width() { return this._states[2] ? this._states[2] - 4 : 0; }\n get _height() { return this._states[3]; }\n get _level() { return this._states[9]; }\n get _mode() { return this._states[10]; }\n get _paletteLimit() { return this._states[11]; }\n _initCanvas(mode) {\n if (mode === 2 /* M2 */) {\n const pixels = this.width * this.height;\n if (pixels > this._canvas.length) {\n if (this._opts.memoryLimit && pixels * 4 > this._opts.memoryLimit) {\n this.release();\n throw new Error('image exceeds memory limit');\n }\n this._canvas = new Uint32Array(pixels);\n }\n this._maxWidth = this._width;\n }\n else if (mode === 1 /* M1 */) {\n if (this._level === 2) {\n // got raster attributes, use them as initial size hint\n const pixels = Math.min(this._rasterWidth, wasm_1.LIMITS.MAX_WIDTH) * this._rasterHeight;\n if (pixels > this._canvas.length) {\n if (this._opts.memoryLimit && pixels * 4 > this._opts.memoryLimit) {\n this.release();\n throw new Error('image exceeds memory limit');\n }\n this._canvas = new Uint32Array(pixels);\n }\n }\n else {\n // else fallback to generic resizing, starting with 256*256 pixels\n if (this._canvas.length < 65536) {\n this._canvas = new Uint32Array(65536);\n }\n }\n }\n return 0; // 0 - continue, 1 - abort right away\n }\n _realloc(offset, additionalPixels) {\n const pixels = offset + additionalPixels;\n if (pixels > this._canvas.length) {\n if (this._opts.memoryLimit && pixels * 4 > this._opts.memoryLimit) {\n this.release();\n throw new Error('image exceeds memory limit');\n }\n // extend in 65536 pixel blocks\n const newCanvas = new Uint32Array(Math.ceil(pixels / 65536) * 65536);\n newCanvas.set(this._canvas);\n this._canvas = newCanvas;\n }\n }\n _handle_band(width) {\n const adv = this._PIXEL_OFFSET;\n let offset = this._lastOffset;\n if (this._mode === 2 /* M2 */) {\n let remaining = this.height - this._currentHeight;\n let c = 0;\n while (c < 6 && remaining > 0) {\n this._canvas.set(this._pSrc.subarray(adv * c, adv * c + width), offset + width * c);\n c++;\n remaining--;\n }\n this._lastOffset += width * c;\n this._currentHeight += c;\n }\n else if (this._mode === 1 /* M1 */) {\n this._realloc(offset, width * 6);\n this._maxWidth = Math.max(this._maxWidth, width);\n this._minWidth = Math.min(this._minWidth, width);\n for (let i = 0; i < 6; ++i) {\n this._canvas.set(this._pSrc.subarray(adv * i, adv * i + width), offset + width * i);\n }\n this._bandWidths.push(width);\n this._lastOffset += width * 6;\n this._currentHeight += 6;\n }\n return 0; // 0 - continue, 1 - abort right away\n }\n /**\n * Width of the image data.\n * Returns the rasterWidth in level2/truncating mode,\n * otherwise the max width, that has been seen so far.\n */\n get width() {\n return this._mode !== 1 /* M1 */\n ? this._width\n : Math.max(this._maxWidth, this._wasm.current_width());\n }\n /**\n * Height of the image data.\n * Returns the rasterHeight in level2/truncating mode,\n * otherwise height touched by sixels.\n */\n get height() {\n return this._mode !== 1 /* M1 */\n ? this._height\n : this._wasm.current_width()\n ? this._bandWidths.length * 6 + this._wasm.current_height()\n : this._bandWidths.length * 6;\n }\n /**\n * Get active palette colors as RGBA8888[] (borrowed).\n */\n get palette() {\n return this._palette.subarray(0, this._paletteLimit);\n }\n /**\n * Get the memory used by the decoder.\n *\n * This is a rough estimate accounting the wasm instance memory\n * and pixel buffers held on JS side (real value will be slightly\n * higher due to JS book-keeping).\n * Note that the decoder does not free ressources on its own,\n * call `release` to free excess memory.\n */\n get memoryUsage() {\n return this._canvas.byteLength + this._wasm.memory.buffer.byteLength + 8 * this._bandWidths.length;\n }\n /**\n * Get various properties of the decoder and the current image.\n */\n get properties() {\n return {\n width: this.width,\n height: this.height,\n mode: this._mode,\n level: this._level,\n truncate: !!this._truncate,\n paletteLimit: this._paletteLimit,\n fillColor: this._fillColor,\n memUsage: this.memoryUsage,\n rasterAttributes: {\n numerator: this._states[4],\n denominator: this._states[5],\n width: this._rasterWidth,\n height: this._rasterHeight,\n }\n };\n }\n /**\n * Initialize decoder for next image. Must be called before\n * any calls to `decode` or `decodeString`.\n */\n // FIXME: reorder arguments, better palette handling\n init(fillColor = this._opts.fillColor, palette = this._opts.palette, paletteLimit = this._opts.paletteLimit, truncate = this._opts.truncate) {\n this._wasm.init(this._opts.sixelColor, fillColor, paletteLimit, truncate ? 1 : 0);\n if (palette) {\n this._palette.set(palette.subarray(0, wasm_1.LIMITS.PALETTE_SIZE));\n }\n this._bandWidths.length = 0;\n this._maxWidth = 0;\n this._minWidth = wasm_1.LIMITS.MAX_WIDTH;\n this._lastOffset = 0;\n this._currentHeight = 0;\n }\n /**\n * Decode next chunk of data from start to end index (exclusive).\n * @throws Will throw if the image exceeds the memory limit.\n */\n decode(data, start = 0, end = data.length) {\n let p = start;\n while (p < end) {\n const length = Math.min(end - p, wasm_1.LIMITS.CHUNK_SIZE);\n this._chunk.set(data.subarray(p, p += length));\n this._wasm.decode(0, length);\n }\n }\n /**\n * Decode next chunk of string data from start to end index (exclusive).\n * Note: Decoding from string data is rather slow, use `decode` with byte data instead.\n * @throws Will throw if the image exceeds the memory limit.\n */\n decodeString(data, start = 0, end = data.length) {\n let p = start;\n while (p < end) {\n const length = Math.min(end - p, wasm_1.LIMITS.CHUNK_SIZE);\n for (let i = 0, j = p; i < length; ++i, ++j) {\n this._chunk[i] = data.charCodeAt(j);\n }\n p += length;\n this._wasm.decode(0, length);\n }\n }\n /**\n * Get current pixel data as 32-bit typed array (RGBA8888).\n * Also peeks into pixel data of the current band, that got not pushed yet.\n */\n get data32() {\n if (this._mode === 0 /* M0 */ || !this.width || !this.height) {\n return NULL_CANVAS;\n }\n // get width of pending band to peek into left-over data\n const currentWidth = this._wasm.current_width();\n if (this._mode === 2 /* M2 */) {\n let remaining = this.height - this._currentHeight;\n if (remaining > 0) {\n const adv = this._PIXEL_OFFSET;\n let offset = this._lastOffset;\n let c = 0;\n while (c < 6 && remaining > 0) {\n this._canvas.set(this._pSrc.subarray(adv * c, adv * c + currentWidth), offset + currentWidth * c);\n c++;\n remaining--;\n }\n if (remaining) {\n this._canvas.fill(this._fillColor, offset + currentWidth * c);\n }\n }\n return this._canvas.subarray(0, this.width * this.height);\n }\n if (this._mode === 1 /* M1 */) {\n if (this._minWidth === this._maxWidth) {\n let escape = false;\n if (currentWidth) {\n if (currentWidth !== this._minWidth) {\n escape = true;\n }\n else {\n const adv = this._PIXEL_OFFSET;\n let offset = this._lastOffset;\n this._realloc(offset, currentWidth * 6);\n for (let i = 0; i < 6; ++i) {\n this._canvas.set(this._pSrc.subarray(adv * i, adv * i + currentWidth), offset + currentWidth * i);\n }\n }\n }\n if (!escape) {\n return this._canvas.subarray(0, this.width * this.height);\n }\n }\n // worst case: re-align pixels if we have bands with different width\n // This is somewhat allocation intensive, any way to do that in-place, and just once?\n const final = new Uint32Array(this.width * this.height);\n final.fill(this._fillColor);\n let finalOffset = 0;\n let start = 0;\n for (let i = 0; i < this._bandWidths.length; ++i) {\n const bw = this._bandWidths[i];\n for (let p = 0; p < 6; ++p) {\n final.set(this._canvas.subarray(start, start += bw), finalOffset);\n finalOffset += this.width;\n }\n }\n // also handle left-over pixels of the current band\n if (currentWidth) {\n const adv = this._PIXEL_OFFSET;\n // other than finished bands, this runs only up to currentHeight\n const currentHeight = this._wasm.current_height();\n for (let i = 0; i < currentHeight; ++i) {\n final.set(this._pSrc.subarray(adv * i, adv * i + currentWidth), finalOffset + this.width * i);\n }\n }\n return final;\n }\n // fallthrough for all not handled cases\n return NULL_CANVAS;\n }\n /**\n * Same as `data32`, but returning pixel data as Uint8ClampedArray suitable\n * for direct usage with `ImageData`.\n */\n get data8() {\n return new Uint8ClampedArray(this.data32.buffer, 0, this.width * this.height * 4);\n }\n /**\n * Release image ressources on JS side held by the decoder.\n *\n * The decoder tries to re-use memory ressources of a previous image\n * to lower allocation and GC pressure. Decoding a single big image\n * will grow the memory usage of the decoder permanently.\n * Call `release` to reset the internal buffers and free the memory.\n * Note that this destroys the image data, call it when done processing\n * a rather big image, otherwise it is not needed. Use `memoryUsage`\n * to decide, whether the held memory is still within your limits.\n * This does not affect the wasm module (operates on static memory).\n */\n release() {\n this._canvas = NULL_CANVAS;\n this._bandWidths.length = 0;\n this._maxWidth = 0;\n this._minWidth = wasm_1.LIMITS.MAX_WIDTH;\n // also nullify parser states in wasm to avoid\n // width/height reporting potential out-of-bound values\n this._wasm.init(Colors_1.DEFAULT_FOREGROUND, 0, this._opts.paletteLimit, 0);\n }\n}\nexports.Decoder = Decoder;\n/**\n * Convenient decoding functions for easier usage.\n *\n * These can be used for casual decoding of sixel images,\n * that dont come in as stream chunks.\n * Note that the functions instantiate a stream decoder for every call,\n * which comes with a performance penalty of ~25%.\n */\n/**\n * Decode function with synchronous wasm loading.\n * Can be used in a web worker or in nodejs. Does not work reliable in normal browser context.\n * @throws Will throw if the image exceeds the memory limit.\n */\nfunction decode(data, opts) {\n const dec = new Decoder(opts);\n dec.init();\n typeof data === 'string' ? dec.decodeString(data) : dec.decode(data);\n return {\n width: dec.width,\n height: dec.height,\n data32: dec.data32,\n data8: dec.data8\n };\n}\nexports.decode = decode;\n/**\n * Decode function with asynchronous wasm loading.\n * Use this version in normal browser context.\n * @throws Will throw if the image exceeds the memory limit.\n */\nasync function decodeAsync(data, opts) {\n const dec = await DecoderAsync(opts);\n dec.init();\n typeof data === 'string' ? dec.decodeString(data) : dec.decode(data);\n return {\n width: dec.width,\n height: dec.height,\n data32: dec.data32,\n data8: dec.data8\n };\n}\nexports.decodeAsync = decodeAsync;\n//# sourceMappingURL=Decoder.js.map","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.LIMITS = void 0;\nexports.LIMITS = {\n CHUNK_SIZE: 16384,\n PALETTE_SIZE: 4096,\n MAX_WIDTH: 16384,\n BYTES: 'AGFzbQEAAAABJAdgAAF/YAJ/fwBgA39/fwF/YAF/AX9gAABgBH9/f38AYAF/AAIlAgNlbnYLaGFuZGxlX2JhbmQAAwNlbnYLbW9kZV9wYXJzZWQAAwMTEgQAAAAAAQQBAQUBAAACAgAGAwQFAXABBwcFBAEBBwcGCAF/AUGAihoLB9wBDgZtZW1vcnkCABFnZXRfc3RhdGVfYWRkcmVzcwADEWdldF9jaHVua19hZGRyZXNzAAQOZ2V0X3AwX2FkZHJlc3MABRNnZXRfcGFsZXR0ZV9hZGRyZXNzAAYEaW5pdAALBmRlY29kZQAMDWN1cnJlbnRfd2lkdGgADQ5jdXJyZW50X2hlaWdodAAOGV9faW5kaXJlY3RfZnVuY3Rpb25fdGFibGUBAAtfaW5pdGlhbGl6ZQACCXN0YWNrU2F2ZQARDHN0YWNrUmVzdG9yZQASCnN0YWNrQWxsb2MAEwkMAQBBAQsGCgcJDxACDAEBCq5UEgMAAQsFAEGgCAsGAEGQiQELBgBBsIkCCwUAQZAJC+okAQh/QeQIKAIAIQVB4AgoAgAhA0HoCCgCACEIIAFBkIkBaiIJQf8BOgAAIAAgAUgEQCAAQZCJAWohBgNAIAMhBCAGQQFqIQECQCAGLQAAQf8AcSIDQTBrQQlLBEAgASEGDAELQewIKAIAQQJ0QewIaiICKAIAIQADQCACIAMgAEEKbGpBMGsiADYCACABLQAAIQMgAUEBaiIGIQEgA0H/AHEiA0Ewa0EKSQ0ACwsCQAJAAkACQAJAAkACQAJ/AkACQCADQT9rIgBBP00EQCAERQ0BIARBIUYEQAJAQfAIKAIAIgFBASABGyIHIAhqIgFB1AgoAgAiA0gNACADQf//AEoNAANAIANBAnQiAkGgiQJqIgRBoAgpAwA3AwAgAkGoiQJqQaAIKQMANwMAIAJBsIkCakGgCCkDADcDACACQbiJAmpBoAgpAwA3AwAgAkHAiQJqQaAIKQMANwMAIAJByIkCakGgCCkDADcDACACQdCJAmpBoAgpAwA3AwAgAkHYiQJqQaAIKQMANwMAIAJB4IkCakGgCCkDADcDACACQeiJAmpBoAgpAwA3AwAgAkHwiQJqQaAIKQMANwMAIAJB+IkCakGgCCkDADcDACACQYCKAmpBoAgpAwA3AwAgAkGIigJqQaAIKQMANwMAIAJBkIoCakGgCCkDADcDACACQZiKAmpBoAgpAwA3AwAgAkGgigJqQaAIKQMANwMAIAJBqIoCakGgCCkDADcDACACQbCKAmpBoAgpAwA3AwAgAkG4igJqQaAIKQMANwMAIAJBwIoCakGgCCkDADcDACACQciKAmpBoAgpAwA3AwAgAkHQigJqQaAIKQMANwMAIAJB2IoCakGgCCkDADcDACACQeCKAmpBoAgpAwA3AwAgAkHoigJqQaAIKQMANwMAIAJB8IoCakGgCCkDADcDACACQfiKAmpBoAgpAwA3AwAgAkGAiwJqQaAIKQMANwMAIAJBiIsCakGgCCkDADcDACACQZCLAmpBoAgpAwA3AwAgAkGYiwJqQaAIKQMANwMAIAJBoIsCakGgCCkDADcDACACQaiLAmpBoAgpAwA3AwAgAkGwiwJqQaAIKQMANwMAIAJBuIsCakGgCCkDADcDACACQcCLAmpBoAgpAwA3AwAgAkHIiwJqQaAIKQMANwMAIAJB0IsCakGgCCkDADcDACACQdiLAmpBoAgpAwA3AwAgAkHgiwJqQaAIKQMANwMAIAJB6IsCakGgCCkDADcDACACQfCLAmpBoAgpAwA3AwAgAkH4iwJqQaAIKQMANwMAIAJBgIwCakGgCCkDADcDACACQYiMAmpBoAgpAwA3AwAgAkGQjAJqQaAIKQMANwMAIAJBmIwCakGgCCkDADcDACACQaCMAmpBoAgpAwA3AwAgAkGojAJqQaAIKQMANwMAIAJBsIwCakGgCCkDADcDACACQbiMAmpBoAgpAwA3AwAgAkHAjAJqQaAIKQMANwMAIAJByIwCakGgCCkDADcDACACQdCMAmpBoAgpAwA3AwAgAkHYjAJqQaAIKQMANwMAIAJB4IwCakGgCCkDADcDACACQeiMAmpBoAgpAwA3AwAgAkHwjAJqQaAIKQMANwMAIAJB+IwCakGgCCkDADcDACACQYCNAmpBoAgpAwA3AwAgAkGIjQJqQaAIKQMANwMAIAJBkI0CakGgCCkDADcDACACQZiNAmpBoAgpAwA3AwAgAkGwiQZqIARBgAT8CgAAQdQIKAIAQQJ0QcCJCmogBEGABPwKAABB1AgoAgBBAnRB0IkOaiAEQYAE/AoAAEHUCCgCAEECdEHgiRJqIARBgAT8CgAAQdQIKAIAQQJ0QfCJFmogBEGABPwKAABB1AhB1AgoAgAiAkGAAWoiAzYCACABIANIDQEgAkGA/wBIDQALCwJAIABFDQAgCEH//wBLDQBBgIABIAhrIAcgAUH//wBLGyECAkAgAEEBcUUNACACRQ0AIAhBAnRBoIkCaiEDIAIhBCACQQdxIgcEQANAIAMgBTYCACADQQRqIQMgBEEBayEEIAdBAWsiBw0ACwsgAkEBa0EHSQ0AA0AgAyAFNgIcIAMgBTYCGCADIAU2AhQgAyAFNgIQIAMgBTYCDCADIAU2AgggAyAFNgIEIAMgBTYCACADQSBqIQMgBEEIayIEDQALCwJAIABBAnFFDQAgAkUNACAIQQJ0QbCJBmohAyACIQQgAkEHcSIHBEADQCADIAU2AgAgA0EEaiEDIARBAWshBCAHQQFrIgcNAAsLIAJBAWtBB0kNAANAIAMgBTYCHCADIAU2AhggAyAFNgIUIAMgBTYCECADIAU2AgwgAyAFNgIIIAMgBTYCBCADIAU2AgAgA0EgaiEDIARBCGsiBA0ACwsCQCAAQQRxRQ0AIAJFDQAgCEECdEHAiQpqIQMgAiEEIAJBB3EiBwRAA0AgAyAFNgIAIANBBGohAyAEQQFrIQQgB0EBayIHDQALCyACQQFrQQdJDQADQCADIAU2AhwgAyAFNgIYIAMgBTYCFCADIAU2AhAgAyAFNgIMIAMgBTYCCCADIAU2AgQgAyAFNgIAIANBIGohAyAEQQhrIgQNAAsLAkAgAEEIcUUNACACRQ0AIAhBAnRB0IkOaiEDIAIhBCACQQdxIgcEQANAIAMgBTYCACADQQRqIQMgBEEBayEEIAdBAWsiBw0ACwsgAkEBa0EHSQ0AA0AgAyAFNgIcIAMgBTYCGCADIAU2AhQgAyAFNgIQIAMgBTYCDCADIAU2AgggAyAFNgIEIAMgBTYCACADQSBqIQMgBEEIayIEDQALCwJAIABBEHFFDQAgAkUNACAIQQJ0QeCJEmohAyACIQQgAkEHcSIHBEADQCADIAU2AgAgA0EEaiEDIARBAWshBCAHQQFrIgcNAAsLIAJBAWtBB0kNAANAIAMgBTYCHCADIAU2AhggAyAFNgIUIAMgBTYCECADIAU2AgwgAyAFNgIIIAMgBTYCBCADIAU2AgAgA0EgaiEDIARBCGsiBA0ACwsgAEEgcUUNACACRQ0AIAJBAWshByAIQQJ0QfCJFmohAyACQQdxIgQEQANAIAMgBTYCACADQQRqIQMgAkEBayECIARBAWsiBA0ACwsgB0EHSQ0AA0AgAyAFNgIcIAMgBTYCGCADIAU2AhQgAyAFNgIQIAMgBTYCDCADIAU2AgggAyAFNgIEIAMgBTYCACADQSBqIQMgAkEIayICDQALC0HcCEHcCCgCACAAcjYCACAGQQFqIgIgBi0AAEH/AHEiA0E/ayIAQT9LDQQaDAMLAkBB7AgoAgAiBEEBRgRAQfAIKAIAIgNBzAgoAgAiAUkNASADIAFwIQMMAQtB+AgoAgAhAkH0CCgCACEBAkACQCAEQQVHDQAgAUEBRw0AIAJB6QJODQQMAQsgAkHkAEoNA0H8CCgCAEHkAEoNA0GACSgCAEHkAEoNAwsCQCABRQ0AIAFBAkoNACACQfwIKAIAQYAJKAIAIAFBAnRBiAhqKAIAEQIAIQFB8AgoAgAiA0HMCCgCACICTwR/IAMgAnAFIAMLQQJ0QZAJaiABNgIAC0HwCCgCACIDQcwIKAIAIgFJDQAgAyABcCEDCyADQQJ0QZAJaigCACEFDAELIANB/QBxQSFHBEAgCCEBIAYhAgwECyAEQSNHDQQCQEHsCCgCACICQQFGBEBB8AgoAgAiAUHMCCgCACIASQ0BIAEgAHAhAQwBC0H4CCgCACEBQfQIKAIAIQACQAJAIAJBBUcNACAAQQFHDQAgAUHpAkgNAQwHCyABQeQASg0GQfwIKAIAQeQASg0GQYAJKAIAQeQASg0GCwJAIABFDQAgAEECSg0AIAFB/AgoAgBBgAkoAgAgAEECdEGICGooAgARAgAhAEHwCCgCACIBQcwIKAIAIgJPBH8gASACcAUgAQtBAnRBkAlqIAA2AgALQfAIKAIAIgFBzAgoAgAiAEkNACABIABwIQELIAFBAnRBkAlqKAIAIQUMBAsgCCEBIAYhAgtB1AgoAgAhBgNAAkAgASAGSA0AIAZB//8ASg0AIAZBAnQiBEGgiQJqIgZBoAgpAwA3AwAgBEGoiQJqQaAIKQMANwMAIARBsIkCakGgCCkDADcDACAEQbiJAmpBoAgpAwA3AwAgBEHAiQJqQaAIKQMANwMAIARByIkCakGgCCkDADcDACAEQdCJAmpBoAgpAwA3AwAgBEHYiQJqQaAIKQMANwMAIARB4IkCakGgCCkDADcDACAEQeiJAmpBoAgpAwA3AwAgBEHwiQJqQaAIKQMANwMAIARB+IkCakGgCCkDADcDACAEQYCKAmpBoAgpAwA3AwAgBEGIigJqQaAIKQMANwMAIARBkIoCakGgCCkDADcDACAEQZiKAmpBoAgpAwA3AwAgBEGgigJqQaAIKQMANwMAIARBqIoCakGgCCkDADcDACAEQbCKAmpBoAgpAwA3AwAgBEG4igJqQaAIKQMANwMAIARBwIoCakGgCCkDADcDACAEQciKAmpBoAgpAwA3AwAgBEHQigJqQaAIKQMANwMAIARB2IoCakGgCCkDADcDACAEQeCKAmpBoAgpAwA3AwAgBEHoigJqQaAIKQMANwMAIARB8IoCakGgCCkDADcDACAEQfiKAmpBoAgpAwA3AwAgBEGAiwJqQaAIKQMANwMAIARBiIsCakGgCCkDADcDACAEQZCLAmpBoAgpAwA3AwAgBEGYiwJqQaAIKQMANwMAIARBoIsCakGgCCkDADcDACAEQaiLAmpBoAgpAwA3AwAgBEGwiwJqQaAIKQMANwMAIARBuIsCakGgCCkDADcDACAEQcCLAmpBoAgpAwA3AwAgBEHIiwJqQaAIKQMANwMAIARB0IsCakGgCCkDADcDACAEQdiLAmpBoAgpAwA3AwAgBEHgiwJqQaAIKQMANwMAIARB6IsCakGgCCkDADcDACAEQfCLAmpBoAgpAwA3AwAgBEH4iwJqQaAIKQMANwMAIARBgIwCakGgCCkDADcDACAEQYiMAmpBoAgpAwA3AwAgBEGQjAJqQaAIKQMANwMAIARBmIwCakGgCCkDADcDACAEQaCMAmpBoAgpAwA3AwAgBEGojAJqQaAIKQMANwMAIARBsIwCakGgCCkDADcDACAEQbiMAmpBoAgpAwA3AwAgBEHAjAJqQaAIKQMANwMAIARByIwCakGgCCkDADcDACAEQdCMAmpBoAgpAwA3AwAgBEHYjAJqQaAIKQMANwMAIARB4IwCakGgCCkDADcDACAEQeiMAmpBoAgpAwA3AwAgBEHwjAJqQaAIKQMANwMAIARB+IwCakGgCCkDADcDACAEQYCNAmpBoAgpAwA3AwAgBEGIjQJqQaAIKQMANwMAIARBkI0CakGgCCkDADcDACAEQZiNAmpBoAgpAwA3AwAgBEGwiQZqIAZBgAT8CgAAQdQIKAIAQQJ0QcCJCmogBkGABPwKAABB1AgoAgBBAnRB0IkOaiAGQYAE/AoAAEHUCCgCAEECdEHgiRJqIAZBgAT8CgAAQdQIKAIAQQJ0QfCJFmogBkGABPwKAABB1AhB1AgoAgBBgAFqIgY2AgALIAFB//8ATQRAIABBAXEgAWxBAnRBoIkCaiAFNgIAIABBAXZBAXEgAWxBAnRBsIkGaiAFNgIAIABBAnZBAXEgAWxBAnRBwIkKaiAFNgIAIABBA3ZBAXEgAWxBAnRB0IkOaiAFNgIAIABBBHZBAXEgAWxBAnRB4IkSaiAFNgIAIABBBXYgAWxBAnRB8IkWaiAFNgIAQdQIKAIAIQYLIAFBAWohAUHcCEHcCCgCACAAcjYCACACLQAAIQAgAkEBaiIEIQIgAEH/AHEiA0E/ayIAQcAASQ0ACyAECyECQQAhBCACIQYgASEIIANB/QBxQSFGDQELIANBJGsOCgEDAwMDAwMDAwIDC0HsCEIBNwIADAQLQdgIIAFB2AgoAgAiACAAIAFIGyIAQYCAASAAQYCAAUgbNgIADAILQegIIAFB2AgoAgAiACAAIAFIGyIAQYCAASAAQYCAAUgbIgA2AgBB2AggADYCACAAQQRrEAAEQEHoCEEENgIAQdgIQQQ2AgBB0AhBATYCAA8LEAgMAQsCQCADQTtHDQBB7AgoAgAiAEEHSg0AQewIIABBAWo2AgAgAEECdEHwCGpBADYCAAsgAiEGIAQhAyABIQgMAQtBBCEIIAIhBiAEIQMLIAYgCUkNAAsLQeQIIAU2AgBB4AggAzYCAEHoCCAINgIAC9ELAgF+CH9B2AhCBDcDAEGojQJBoAgpAwAiADcDAEGgjQIgADcDAEGYjQIgADcDAEGQjQIgADcDAEGIjQIgADcDAEGAjQIgADcDAEH4jAIgADcDAEHwjAIgADcDAEHojAIgADcDAEHgjAIgADcDAEHYjAIgADcDAEHQjAIgADcDAEHIjAIgADcDAEHAjAIgADcDAEG4jAIgADcDAEGwjAIgADcDAEGojAIgADcDAEGgjAIgADcDAEGYjAIgADcDAEGQjAIgADcDAEGIjAIgADcDAEGAjAIgADcDAEH4iwIgADcDAEHwiwIgADcDAEHoiwIgADcDAEHgiwIgADcDAEHYiwIgADcDAEHQiwIgADcDAEHIiwIgADcDAEHAiwIgADcDAEG4iwIgADcDAEGwiwIgADcDAEGoiwIgADcDAEGgiwIgADcDAEGYiwIgADcDAEGQiwIgADcDAEGIiwIgADcDAEGAiwIgADcDAEH4igIgADcDAEHwigIgADcDAEHoigIgADcDAEHgigIgADcDAEHYigIgADcDAEHQigIgADcDAEHIigIgADcDAEHAigIgADcDAEG4igIgADcDAEGwigIgADcDAEGoigIgADcDAEGgigIgADcDAEGYigIgADcDAEGQigIgADcDAEGIigIgADcDAEGAigIgADcDAEH4iQIgADcDAEHwiQIgADcDAEHoiQIgADcDAEHgiQIgADcDAEHYiQIgADcDAEHQiQIgADcDAEHIiQIgADcDAEHAiQIgADcDAEG4iQIgADcDAEGwiQIgADcDAEGoCCgCACIEQf8AakGAAW0hCAJAIARBgQFIDQBBASEBIAhBAiAIQQJKG0EBayICQQFxIQMgBEGBAk4EQCACQX5xIQIDQCABQQl0IgdBEHJBoIkCakGwiQJBgAT8CgAAIAdBsI0CakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsLIANFDQAgAUEJdEEQckGgiQJqQbCJAkGABPwKAAALAkAgBEEBSA0AIAhBASAIQQFKGyIDQQFxIQUCQCADQQFrIgdFBEBBACEBDAELIANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQbCJBmpBsIkCQYAE/AoAACAGQZAEckGwiQZqQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACwsgBQRAIAFBCXRBEHJBsIkGakGwiQJBgAT8CgAACyAEQQFIDQAgA0EBcSEFIAcEfyADQf7///8HcSECQQAhAQNAIAFBCXQiBkEQckHAiQpqQbCJAkGABPwKAAAgBkGQBHJBwIkKakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsgAUEHdEEEcgVBBAshASAFBEAgAUECdEHAiQpqQbCJAkGABPwKAAALIARBAUgNACADQQFxIQUgBwR/IANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQdCJDmpBsIkCQYAE/AoAACAGQZAEckHQiQ5qQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACyABQQd0QQRyBUEECyEBIAUEQCABQQJ0QdCJDmpBsIkCQYAE/AoAAAsgBEEBSA0AIANBAXEhBSAHBH8gA0H+////B3EhAkEAIQEDQCABQQl0IgZBEHJB4IkSakGwiQJBgAT8CgAAIAZBkARyQeCJEmpBsIkCQYAE/AoAACABQQJqIQEgAkECayICDQALIAFBB3RBBHIFQQQLIQEgBQRAIAFBAnRB4IkSakGwiQJBgAT8CgAACyAEQQFIDQAgA0EBcSEEIAcEfyADQf7///8HcSECQQAhAQNAIAFBCXQiA0EQckHwiRZqQbCJAkGABPwKAAAgA0GQBHJB8IkWakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsgAUEHdEEEcgVBBAshASAERQ0AIAFBAnRB8IkWakGwiQJBgAT8CgAAC0HUCCAIQQd0QQRyNgIAC58TAgh/AX5B5AgoAgAhA0HgCCgCACECQegIKAIAIQcgAUGQiQFqIglB/wE6AAAgACABSARAIABBkIkBaiEIA0AgAiEEIAhBAWohAQJAIAgtAABB/wBxIgJBMGtBCUsEQCABIQgMAQtB7AgoAgBBAnRB7AhqIgUoAgAhAANAIAUgAiAAQQpsakEwayIANgIAIAEtAAAhAiABQQFqIgghASACQf8AcSICQTBrQQpJDQALCwJAAkACQAJAAkACQAJ/AkAgAkE/ayIAQT9NBEAgBEUNASAEQSFGBEBB8AgoAgAiAUEBIAEbIgQgB2ohAQJAIABFDQAgB0H//wBLDQBBgIABIAdrIAQgAUH//wBLGyEFAkAgAEEBcUUNACAHQQJ0QaCJAmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEECcUUNACAHQQJ0QbCJBmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEEEcUUNACAHQQJ0QcCJCmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEEIcUUNACAHQQJ0QdCJDmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEEQcUUNACAHQQJ0QeCJEmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLIABBIHFFDQAgBUEBayEEIAdBAnRB8IkWaiEAIAVBB3EiAgRAA0AgACADNgIAIABBBGohACAFQQFrIQUgAkEBayICDQALCyAEQQdJDQADQCAAIAM2AhwgACADNgIYIAAgAzYCFCAAIAM2AhAgACADNgIMIAAgAzYCCCAAIAM2AgQgACADNgIAIABBIGohACAFQQhrIgUNAAsLIAhBAWoiBSAILQAAQf8AcSICQT9rIgBBP00NAxoMBAsCQEHsCCgCACIFQQFGBEBB8AgoAgAiAUHMCCgCACIESQ0BIAEgBHAhAQwBC0H4CCgCACEEQfQIKAIAIQECQAJAIAVBBUcNACABQQFHDQAgBEHpAk4NBAwBCyAEQeQASg0DQfwIKAIAQeQASg0DQYAJKAIAQeQASg0DCwJAIAFFDQAgAUECSg0AIARB/AgoAgBBgAkoAgAgAUECdEGICGooAgARAgAhBEHwCCgCACIBQcwIKAIAIgVPBH8gASAFcAUgAQtBAnRBkAlqIAQ2AgALQfAIKAIAIgFBzAgoAgAiBEkNACABIARwIQELIAFBAnRBkAlqKAIAIQMMAQsgAkH9AHFBIUcEQCAHIQEgAiEADAQLIARBI0cNBAJAQewIKAIAIgRBAUYEQEHwCCgCACIBQcwIKAIAIgBJDQEgASAAcCEBDAELQfgIKAIAIQFB9AgoAgAhAAJAAkAgBEEFRw0AIABBAUcNACABQekCSA0BDAcLIAFB5ABKDQZB/AgoAgBB5ABKDQZBgAkoAgBB5ABKDQYLAkAgAEUNACAAQQJKDQAgAUH8CCgCAEGACSgCACAAQQJ0QYgIaigCABECACEAQfAIKAIAIgFBzAgoAgAiBE8EfyABIARwBSABC0ECdEGQCWogADYCAAtB8AgoAgAiAUHMCCgCACIASQ0AIAEgAHAhAQsgAUECdEGQCWooAgAhAwwECyAHIQEgCAshBQNAIAFB//8ATQRAIABBAXEgAWxBAnRBoIkCaiADNgIAIABBAXZBAXEgAWxBAnRBsIkGaiADNgIAIABBAnZBAXEgAWxBAnRBwIkKaiADNgIAIABBA3ZBAXEgAWxBAnRB0IkOaiADNgIAIABBBHZBAXEgAWxBAnRB4IkSaiADNgIAIABBBXYgAWxBAnRB8IkWaiADNgIACyABQQFqIQEgBS0AACEAIAVBAWoiBCEFIABB/wBxIgJBP2siAEHAAEkNAAsgBCEFC0EAIQQgBSEIIAEhByACIQAgAkH9AHFBIUYNAQtBBCEHIAQhAiAAQSRrDgoDAgICAgICAgIBAgtB7AhCATcCAAwCC0GoCCgCAEEEaxAABEBB0AhBATYCAA8LAkBBqAgoAgAiBkEFSA0AQaAIKQMAIQogBkEDa0EBdiIBQQdxIQJBACEAIAFBAWtBB08EQCABQfj///8HcSEFA0AgAEEDdCIBQbCJAmogCjcDACABQQhyQbCJAmogCjcDACABQRByQbCJAmogCjcDACABQRhyQbCJAmogCjcDACABQSByQbCJAmogCjcDACABQShyQbCJAmogCjcDACABQTByQbCJAmogCjcDACABQThyQbCJAmogCjcDACAAQQhqIQAgBUEIayIFDQALCyACRQ0AA0AgAEEDdEGwiQJqIAo3AwAgAEEBaiEAIAJBAWsiAg0ACwtBwIkGQbCJAiAGQQJ0IgD8CgAAQdCJCkGwiQIgAPwKAABB4IkOQbCJAiAA/AoAAEHwiRJBsIkCIAD8CgAAQYCKFkGwiQIgAPwKAAAgBCECDAELAkAgAEE7Rw0AQewIKAIAIgBBB0oNAEHsCCAAQQFqNgIAIABBAnRB8AhqQQA2AgALIAEhBwsgCCAJSQ0ACwtB5AggAzYCAEHgCCACNgIAQegIIAc2AgAL4gcCBX8BfgJAQdAIAn8CQAJAIAAgAU4NACABQZCJAWohBiAAQZCJAWohBQNAIAUtAAAiA0H/AHEhAgJAAkACQAJAAkACQAJAQeAIKAIAIgRBIkcEQCAEDQcgAkEiRgRAQewIQgE3AgBB4AhBIjYCAAwICyACQT9rQcAASQ0GIANBIWsiAkEMTQ0BDAULAkAgAkEwayIEQQlNBEBB7AgoAgBBAnRB7AhqIgIgBCACKAIAQQpsajYCAAwBC0HsCCgCACEEIAJBO0YEQCAEQQdKDQFB7AggBEEBajYCACAEQQJ0QfAIakEANgIADAELIARBBEYEQEHECEECNgIAQbAIQfAIKQMANwMAQbgIQfgIKAIAIgI2AgBBvAhB/AgoAgAiBDYCAEHICEECQQFBwAgoAgAiAxs2AgBBrAggBEEAIAMbNgIAQagIIAJBgIABIAJBgIABSBtBBGpBACADGzYCAEHgCEEANgIADAoLIAJBP2tBwABJDQQLIANBIWsiAkEMTQ0BDAILQQEgAnRBjSBxRQ0DDAQLQQEgAnRBjSBxDQELIANBoQFrIgJBDEsNA0EBIAJ0QY0gcUUNAwtBxAhCgYCAgBA3AgBBsAhB8AgoAgBBAEHsCCgCACICQQBKGzYCAEG0CEH0CCgCAEEAIAJBAUobNgIAQbgIQfgIKAIAQQAgAkECShs2AgBB4AhBADYCAEG8CEEANgIADAQLIANBoQFrIgJBDEsNAUEBIAJ0QY0gcUUNAQtBxAhCgYCAgBA3AgBBsAhCADcDAEG4CEIANwMADAMLIAVBAWoiBSAGSQ0ACwsCQEHICCgCAA4DAwEAAQsCQEGoCCgCACIFQQVIDQBBoAgpAwAhByAFQQNrQQF2IgNBB3EhBEEAIQIgA0EBa0EHTwRAIANB+P///wdxIQYDQCACQQN0IgNBsIkCaiAHNwMAIANBCHJBsIkCaiAHNwMAIANBEHJBsIkCaiAHNwMAIANBGHJBsIkCaiAHNwMAIANBIHJBsIkCaiAHNwMAIANBKHJBsIkCaiAHNwMAIANBMHJBsIkCaiAHNwMAIANBOHJBsIkCaiAHNwMAIAJBCGohAiAGQQhrIgYNAAsLIARFDQADQCACQQN0QbCJAmogBzcDACACQQFqIQIgBEEBayIEDQALC0HAiQZBsIkCIAVBAnQiA/wKAABB0IkKQbCJAiAD/AoAAEHgiQ5BsIkCIAP8CgAAQfCJEkGwiQIgA/wKAABBgIoWQbCJAiAD/AoAAEECDAELEAhByAgoAgALEAEiAjYCACACDQAgACABQcgIKAIAQQJ0QYAIaigCABEBAAsLdABB6AhBBDYCAEHkCCAANgIAQewIQgE3AgBBxAhCADcCAEHACCADNgIAQdwIQgA3AgBBqAhCADcDAEGwCEIANwMAQbgIQgA3AwBBzAggAkGAICACQYAgSRs2AgBBoAggAa1CgYCAgBB+NwMAQdAIQQA2AgALIwBB0AgoAgBFBEAgACABQcgIKAIAQQJ0QYAIaigCABEBAAsLWgECfwJAAkACQEHICCgCAEEBaw4CAAECC0HYCEHoCCgCACIAQdgIKAIAIgEgACABShsiAEGAgAEgAEGAgAFIGyIANgIAIABBBGsPC0GoCCgCAEEEayEACyAAC0IBAX8Cf0EGQdwIKAIAIgBBIHENABpBBSAAQRBxDQAaQQQgAEEIcQ0AGkEDIABBBHENABpBAiAAQQFxIABBAnEbCwu9BQEFfQJ/IAJFBEAgAUH/AWxBMmpB5ABtIgBBCHQgAHIgAEEQdHIMAQsgArJDAADIQpUhBiAAQfABarJDAAC0Q5UhBQJ9IAGyQwAAyEKVIgNDAAAAP10EQCADIAZDAACAP5KUDAELIAYgA0MAAIA/IAaTlJILIQcgAyADkiEGAkAgBUOrqqo+kiIEQwAAAABdBEAgBEMAAIA/kiEEDAELIARDAACAP15FDQAgBEMAAIC/kiEECyAGIAeTIQMgBUMAAAAAXSEAAn8CfSADIAcgA5NDAADAQJQgBJSSIARDq6oqPl0NABogByAEQwAAAD9dDQAaIAMgBEOrqio/XUUNABogAyAHIAOTIARDAADAwJRDAACAQJKUkgtDAAB/Q5RDAAAAP5IiBkMAAIBPXSAGQwAAAABgcQRAIAapDAELQQALIQECQCAABEAgBUMAAIA/kiEEDAELIAUiBEMAAIA/XkUNACAFQwAAgL+SIQQLIAVDq6qqvpIiBUMAAAAAXSECAn8CfSADIAcgA5NDAADAQJQgBJSSIARDq6oqPl0NABogByAEQwAAAD9dDQAaIAMgBEOrqio/XUUNABogAyAHIAOTIARDAADAwJRDAACAQJKUkgtDAAB/Q5RDAAAAP5IiBkMAAIBPXSAGQwAAAABgcQRAIAapDAELQQALIQACQCACBEAgBUMAAIA/kiEFDAELIAVDAACAP15FDQAgBUMAAIC/kiEFCwJAIAVDq6oqPl0EQCADIAcgA5NDAADAQJQgBZSSIQcMAQsgBUMAAAA/XQ0AIAVDq6oqP11FBEAgAyEHDAELIAMgByADkyAFQwAAwMCUQwAAgECSlJIhBwsgAEEIdAJ/IAdDAAB/Q5RDAAAAP5IiBkMAAIBPXSAGQwAAAABgcQRAIAapDAELQQALQRB0ciABcgtBgICAeHILNwAgAEH/AWxBMmpB5ABtIAFB/wFsQTJqQeQAbUEIdHIgAkH/AWxBMmpB5ABtQRB0ckGAgIB4cgsEACMACwYAIAAkAAsQACMAIABrQXBxIgAkACAACwsYAQBBgAgLEQEAAAACAAAAAwAAAAQAAAAF'\n};\n//# sourceMappingURL=wasm.js.map","/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal event utilities for xterm.js core.\n * Simplified from VS Code's event.ts - no leak detection/profiling.\n */\n\nimport { IDisposable, DisposableStore, toDisposable } from './Lifecycle';\n\nexport interface IEvent {\n (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable;\n}\n\nexport class Emitter {\n private _listeners: { fn: (e: T) => any, thisArgs: any }[] = [];\n private _disposed = false;\n private _event: IEvent | undefined;\n\n public get event(): IEvent {\n if (this._event) {\n return this._event;\n }\n this._event = (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n if (this._disposed) {\n return toDisposable(() => {});\n }\n\n const entry = { fn: listener, thisArgs };\n this._listeners = this._listeners.slice();\n this._listeners.push(entry);\n\n const result = toDisposable(() => {\n const idx = this._listeners.indexOf(entry);\n if (idx !== -1) {\n this._listeners = this._listeners.slice();\n this._listeners.splice(idx, 1);\n }\n });\n\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(result);\n } else {\n disposables.add(result);\n }\n }\n\n return result;\n };\n return this._event;\n }\n\n public fire(event: T): void {\n if (this._disposed || !this._listeners.length) {\n return;\n }\n if (this._listeners.length === 1) {\n this._listeners[0].fn.call(this._listeners[0].thisArgs, event);\n return;\n }\n const listeners = this._listeners;\n for (let i = 0, len = listeners.length; i < len; ++i) {\n listeners[i].fn.call(listeners[i].thisArgs, event);\n }\n }\n\n public dispose(): void {\n if (this._disposed) {\n return;\n }\n this._disposed = true;\n this._listeners.length = 0;\n }\n}\n\nexport namespace EventUtils {\n export function forward(from: IEvent, to: Emitter): IDisposable {\n return from(e => to.fire(e));\n }\n\n export function map(event: IEvent, map: (i: I) => O): IEvent {\n return (listener: (e: O) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n return event(i => listener.call(thisArgs, map(i)), undefined, disposables);\n };\n }\n\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent {\n return (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n const store = new DisposableStore();\n for (const event of events) {\n store.add(event(e => listener.call(thisArgs, e)));\n }\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(store);\n } else {\n disposables.add(store);\n }\n }\n return store;\n };\n }\n\n export function runAndSubscribe(event: IEvent, handler: (e: T) => void, initial: T): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void, initial?: T): IDisposable {\n handler(initial);\n return event(e => handler(e));\n }\n}\n","/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal lifecycle utilities for xterm.js core.\n * Simplified from VS Code's lifecycle.ts - no tracking/leak detection.\n */\n\nexport interface IDisposable {\n dispose(): void;\n}\n\nexport function toDisposable(fn: () => void): IDisposable {\n return { dispose: fn };\n}\n\nexport function dispose(disposable: T): T;\nexport function dispose(disposable: T | undefined): T | undefined;\nexport function dispose(disposables: T[]): T[];\nexport function dispose(arg: T | T[] | undefined): T | T[] | undefined {\n if (!arg) {\n return arg;\n }\n if (Array.isArray(arg)) {\n for (const d of arg) {\n d.dispose();\n }\n return [];\n }\n arg.dispose();\n return arg;\n}\n\nexport function combinedDisposable(...disposables: IDisposable[]): IDisposable {\n return toDisposable(() => dispose(disposables));\n}\n\nexport class DisposableStore implements IDisposable {\n private readonly _disposables = new Set();\n private _isDisposed = false;\n\n public get isDisposed(): boolean {\n return this._isDisposed;\n }\n\n public add(o: T): T {\n if (this._isDisposed) {\n o.dispose();\n } else {\n this._disposables.add(o);\n }\n return o;\n }\n\n public dispose(): void {\n if (this._isDisposed) {\n return;\n }\n this._isDisposed = true;\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n\n public clear(): void {\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n}\n\nexport abstract class Disposable implements IDisposable {\n public static readonly None: IDisposable = Object.freeze({ dispose() { } });\n\n protected readonly _store = new DisposableStore();\n\n public dispose(): void {\n this._store.dispose();\n }\n\n protected _register(o: T): T {\n return this._store.add(o);\n }\n}\n\nexport class MutableDisposable implements IDisposable {\n private _value: T | undefined;\n private _isDisposed = false;\n\n public get value(): T | undefined {\n return this._isDisposed ? undefined : this._value;\n }\n\n public set value(value: T | undefined) {\n if (this._isDisposed || value === this._value) {\n return;\n }\n this._value?.dispose();\n this._value = value;\n }\n\n public clear(): void {\n this.value = undefined;\n }\n\n public dispose(): void {\n this._isDisposed = true;\n this._value?.dispose();\n this._value = undefined;\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nimport { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { CELL_SIZE_DEFAULT } from './ImageStorage';\nimport Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';\nimport { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser';\nimport { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';\n\n// Local const enum mirror - esbuild can't inline const enums from external packages\nconst enum DecoderConst {\n // Limit held memory in base64 decoder (encoded bytes).\n KEEP_DATA = 4194304,\n // Initial buffer allocation for the decoder.\n INITIAL_DATA = 1048576,\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n OK = 0\n}\n\n// default IIP header values\nconst DEFAULT_HEADER: IHeaderFields = {\n type: SequenceType.INVALID,\n name: 'Unnamed file',\n size: 0,\n width: 'auto',\n height: 'auto',\n preserveAspectRatio: 1,\n inline: 0\n};\n\n\nexport class IIPHandler implements IOscHandler, IResetHandler {\n private _aborted = false;\n private _hp = new HeaderParser();\n private _header: IHeaderFields = DEFAULT_HEADER;\n private _dec: Base64Decoder;\n private _qoiDec: QoiDecoder;\n private _isMultipart = false;\n private _abortMulti = false;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _storage: IIPImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);\n const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);\n this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes);\n this._qoiDec = new QoiDecoder(DecoderConst.KEEP_DATA);\n }\n\n public reset(): void {\n this._hp.reset();\n this._dec.release();\n this._qoiDec.release();\n }\n\n public start(): void {\n this._aborted = false;\n this._hp.reset();\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (this._hp.state === HeaderState.END) {\n if ((this._dec.put(data.subarray(start, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n }\n } else {\n const dataPos = this._hp.parse(data, start, end);\n if (dataPos === -1) {\n this._aborted = true;\n return;\n }\n if (dataPos > 0) {\n const seqType = this._hp.fields.type;\n if (seqType === SequenceType.FILE) {\n if (this._isMultipart) {\n this._isMultipart = false;\n this._abortMulti = false;\n this._dec.release();\n }\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n if (!this._header.inline) {\n this._aborted = true;\n return;\n }\n this._dec.init();\n } else if (this._abortMulti) {\n this._aborted = true;\n return;\n }\n if ((this._dec.put(data.subarray(dataPos, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n if (this._isMultipart) this._abortMulti = true;\n }\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted) return true;\n\n if (this._hp.state !== HeaderState.END) {\n if (this._hp.end()) return true;\n }\n const seqType = this._hp.fields.type;\n\n if (seqType === SequenceType.FILEPART) return true;\n\n if (seqType === SequenceType.REPORTCELLSIZE) {\n // OSC 1337 ; ReportCellSize=[height];[width];[scale] ST\n let w = CELL_SIZE_DEFAULT.width;\n let h = CELL_SIZE_DEFAULT.height;\n if (this._renderer.dimensions) {\n w = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols;\n h = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows;\n }\n const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1;\n const report = `\\x1b]1337;ReportCellSize=${h.toFixed(3)};${w.toFixed(3)};${scale.toFixed(3)}\\x1b\\\\`;\n this._coreTerminal.input(report, false);\n return true;\n }\n\n if (seqType === SequenceType.MULTIPARTFILE) {\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n this._isMultipart = true;\n this._abortMulti = false;\n this._dec.release();\n this._dec.init();\n return true;\n }\n\n if (seqType === SequenceType.FILEEND) {\n if (!this._isMultipart) return true;\n this._isMultipart = false;\n if (this._abortMulti || this._header.type !== SequenceType.MULTIPARTFILE) return true;\n }\n\n // fallthrough for SequenceType.FILE & SequenceType.FILEEND\n\n let w = 0;\n let h = 0;\n\n // early exit condition chain\n let cond: number | boolean;\n let metrics = UNSUPPORTED_TYPE;\n if (cond = success) {\n if (cond = !this._dec.end()) {\n metrics = imageType(this._dec.data8);\n if (cond = metrics.mime !== 'unsupported') {\n w = metrics.width;\n h = metrics.height;\n if (cond = w && h && w * h < this._opts.pixelLimit) {\n [w, h] = this._resize(w, h).map(Math.floor);\n cond = w && h && w * h < this._opts.pixelLimit;\n } else {\n console.warn(`IIP: image dimension issue ${metrics.width}x${metrics.height}`);\n }\n } else {\n console.warn('IIP: unsupported image type');\n }\n } else {\n console.warn('IIP: error during BASE64 decoding');\n }\n }\n if (!cond) {\n this._dec.release();\n return true;\n }\n\n let blob: Blob | ImageData;\n if (metrics.mime === 'image/qoi') {\n const data = this._qoiDec.decode(this._dec.data8);\n blob = new ImageData(\n new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),\n this._qoiDec.width,\n this._qoiDec.height\n );\n this._qoiDec.release();\n if (w === this._qoiDec.width && h === this._qoiDec.height) {\n // use fast-path if we don't need to rescale\n this._dec.release();\n const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height);\n canvas.getContext('2d')?.putImageData(blob, 0, 0);\n this._storage.addImage(canvas);\n return true;\n }\n } else {\n blob = new Blob([this._dec.data8], { type: metrics.mime });\n }\n this._dec.release();\n return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })\n .then(bm => {\n this._storage.addImage(bm);\n return true;\n })\n .catch(e => {\n console.warn(`IIP: decoding error ${metrics.mime} ${metrics.width}x${metrics.height}`, e);\n return true;\n });\n }\n\n private _resize(w: number, h: number): [number, number] {\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n const width = this._renderer.dimensions?.css.canvas.width || cw * this._coreTerminal.cols;\n const height = this._renderer.dimensions?.css.canvas.height || ch * this._coreTerminal.rows;\n\n const rw = this._dim(this._header.width!, width, cw);\n const rh = this._dim(this._header.height!, height, ch);\n if (!rw && !rh) {\n const wf = width / w; // TODO: should this respect initial cursor offset?\n const hf = (height - ch) / h; // TODO: fix offset issues from float cell height\n const f = Math.min(wf, hf);\n return f < 1 ? [w * f, h * f] : [w, h];\n }\n return !rw\n ? [w * rh / h, rh]\n : this._header.preserveAspectRatio || !rw || !rh\n ? [rw, h * rw / w] : [rw, rh];\n }\n\n private _dim(s: string, total: number, cdim: number): number {\n if (s === 'auto') return 0;\n if (s.endsWith('%')) return parseInt(s.slice(0, -1), 10) * total / 100;\n if (s.endsWith('px')) return parseInt(s.slice(0, -2), 10);\n return parseInt(s, 10) * cdim;\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n// eslint-disable-next-line\ndeclare const Buffer: any;\n\nexport const enum HeaderState {\n START = 0,\n ABORT = 1,\n KEY = 2,\n VALUE = 3,\n END = 4\n}\n\nexport const enum SequenceType {\n INVALID = 0,\n FILE = 1,\n MULTIPARTFILE = 2,\n FILEPART = 3,\n FILEEND = 4,\n REPORTCELLSIZE = 5\n}\n\nexport interface IHeaderFields {\n [key: string]: number | string | Uint32Array | null | undefined;\n // sequence type\n type: SequenceType;\n // base-64 encoded filename. Defaults to \"Unnamed file\".\n name: string;\n // File size in bytes. The file transfer will be canceled if this size is exceeded.\n size: number;\n /**\n * Optional width and height to render:\n * - N: N character cells.\n * - Npx: N pixels.\n * - N%: N percent of the session's width or height.\n * - auto: The image's inherent size will be used to determine an appropriate dimension.\n */\n width?: string;\n height?: string;\n // Optional, defaults to 1 respecting aspect ratio (width takes precedence).\n preserveAspectRatio?: number;\n // Optional, defaults to 0. If set to 1, the file will be displayed inline, else downloaded\n // (download not supported).\n inline?: number;\n}\n\n// field value decoders\n\n// ASCII bytes to string\nfunction toStr(data: Uint32Array): string {\n let s = '';\n for (let i = 0; i < data.length; ++i) {\n s += String.fromCharCode(data[i]);\n }\n return s;\n}\n\n// digits to integer\nfunction toInt(data: Uint32Array): number {\n let v = 0;\n for (let i = 0; i < data.length; ++i) {\n if (data[i] < 48 || data[i] > 57) {\n throw new Error('illegal char');\n }\n v = v * 10 + data[i] - 48;\n }\n return v;\n}\n\n// check for correct size entry\nfunction toSize(data: Uint32Array): string {\n const v = toStr(data);\n if (!v.match(/^((auto)|(\\d+?((px)|(%)){0,1}))$/)) {\n throw new Error('illegal size');\n }\n return v;\n}\n\n// name is base64 encoded utf-8\nfunction toName(data: Uint32Array): string {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(toStr(data), 'base64').toString();\n }\n const bs = atob(toStr(data));\n const b = new Uint8Array(bs.length);\n for (let i = 0; i < b.length; ++i) {\n b[i] = bs.charCodeAt(i);\n }\n return new TextDecoder().decode(b);\n}\n\nconst DECODERS: {[key: string]: (v: Uint32Array) => number | string} = {\n inline: toInt,\n size: toInt,\n name: toName,\n width: toSize,\n height: toSize,\n preserveAspectRatio: toInt\n};\n\n\n// sequence type markers\n// File\nconst FILE_MARKER = [70, 105, 108, 101];\n// MultipartFile\nconst MULTIPARTFILE_MARKER = [77, 117, 108, 116, 105, 112, 97, 114, 116, 70, 105, 108, 101];\n// FilePart\nconst FILEPART_MARKER = [70, 105, 108, 101, 80, 97, 114, 116];\n// FileEnd\nconst FILEEND_MARKER = [70, 105, 108, 101, 69, 110, 100];\n// ReportCellSize\nconst REPORTCELLSIZE_MARKER = [82, 101, 112, 111, 114, 116, 67, 101, 108, 108, 83, 105, 122, 101];\n\n// max allowed chars for sequence header\nconst MAX_FIELDCHARS = 1024;\n\n\nexport class HeaderParser {\n public state: HeaderState = HeaderState.START;\n private _buffer = new Uint32Array(MAX_FIELDCHARS);\n private _position = 0;\n private _key = '';\n public fields: {[key: string]: number | string | Uint32Array | null | undefined} = {};\n\n public reset(): void {\n this._buffer.fill(0);\n this.state = HeaderState.START;\n this._position = 0;\n this.fields = {};\n this._key = '';\n }\n\n public end(): number {\n if (this.state === HeaderState.START) {\n if (this._position === FILEEND_MARKER.length) {\n for (let k = 0; k < FILEEND_MARKER.length; ++k) {\n if (this._buffer[k] !== FILEEND_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEEND;\n this.state = HeaderState.END;\n return 0;\n }\n if (this._position === REPORTCELLSIZE_MARKER.length) {\n for (let k = 0; k < REPORTCELLSIZE_MARKER.length; ++k) {\n if (this._buffer[k] !== REPORTCELLSIZE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.REPORTCELLSIZE;\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n if (this.state === HeaderState.END) return 0;\n if (this.state === HeaderState.VALUE\n && this.fields.type === SequenceType.MULTIPARTFILE\n ) {\n if (!this._storeValue(this._position)) return this._a();\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n\n public parse(data: Uint32Array, start: number, end: number): number {\n let state = this.state;\n let pos = this._position;\n const buffer = this._buffer;\n if (state === HeaderState.ABORT || state === HeaderState.END) return -1;\n if (state === HeaderState.START && pos > 14) return -1;\n for (let i = start; i < end; ++i) {\n const c = data[i];\n switch (c) {\n case 59: // ;\n if (!this._storeValue(pos)) return this._a();\n state = HeaderState.KEY;\n pos = 0;\n break;\n case 61: // =\n if (state === HeaderState.START) {\n if (buffer[0] === 70) {\n // 'File' or 'FilePart'\n let k = 0;\n for (; k < FILE_MARKER.length; ++k) {\n if (buffer[k] !== FILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILE;\n if (pos === FILEPART_MARKER.length) {\n for (; k < FILEPART_MARKER.length; ++k) {\n if (buffer[k] !== FILEPART_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEPART;\n this.state = HeaderState.END;\n return i + 1;\n }\n } else if (buffer[0] === 77) {\n // 'MultipartFile'\n for (let k = 0; k < MULTIPARTFILE_MARKER.length; ++k) {\n if (buffer[k] !== MULTIPARTFILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.MULTIPARTFILE;\n } else {\n return this._a();\n }\n state = HeaderState.KEY;\n pos = 0;\n } else if (state === HeaderState.KEY) {\n if (!this._storeKey(pos)) return this._a();\n state = HeaderState.VALUE;\n pos = 0;\n } else if (state === HeaderState.VALUE) {\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n break;\n case 58: // :\n if (state === HeaderState.VALUE) {\n if (!this._storeValue(pos)) return this._a();\n }\n this.state = HeaderState.END;\n return i + 1;\n default:\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n }\n this.state = state;\n this._position = pos;\n return -2;\n }\n\n private _a(): number {\n this.fields.type = SequenceType.INVALID;\n this.state = HeaderState.ABORT;\n return -1;\n }\n\n private _storeKey(pos: number): boolean {\n const k = toStr(this._buffer.subarray(0, pos));\n if (k) {\n this._key = k;\n this.fields[k] = null;\n return true;\n }\n return false;\n }\n\n private _storeValue(pos: number): boolean {\n if (this._key) {\n try {\n const v = this._buffer.slice(0, pos);\n this.fields[this._key] = DECODERS[this._key] ? DECODERS[this._key](v) : v;\n } catch {\n return false;\n }\n return true;\n }\n return false;\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IAddImageOpts } from './Types';\nimport { ImageStorage } from './ImageStorage';\n\n/**\n * IIP (iTerm Image Protocol) specific image storage controller.\n *\n * Wraps the shared ImageStorage with IIP protocol semantics:\n * - Always uses scrolling mode (cursor advances with image)\n */\nexport class IIPImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n constructor(\n private readonly _storage: ImageStorage\n ) {}\n\n /**\n * Add an IIP image to storage.\n * Always uses scrolling mode — cursor advances past the image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._storage.addImage(img, this._addImageOpts);\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n\nexport type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'image/webp' | 'image/avif' | 'unsupported' | '';\n\nexport interface IMetrics {\n mime: ImageType;\n width: number;\n height: number;\n}\n\nexport const UNSUPPORTED_TYPE: IMetrics = {\n mime: 'unsupported',\n width: 0,\n height: 0\n};\n\nexport function imageType(d: Uint8Array): IMetrics {\n if (d.length < 32) {\n return UNSUPPORTED_TYPE;\n }\n const d32 = new Uint32Array(d.buffer, d.byteOffset, 8);\n // PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)\n // + first chunk must be IHDR\n if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {\n return {\n mime: 'image/png',\n width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],\n height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]\n };\n }\n // JPEG: FF D8 FF\n if (d[0] === 0xFF && d[1] === 0xD8 && d[2] === 0xFF) {\n const [width, height] = jpgSize(d);\n return { mime: 'image/jpeg', width, height };\n }\n // GIF: GIF87a or GIF89a\n if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {\n return {\n mime: 'image/gif',\n width: d[7] << 8 | d[6],\n height: d[9] << 8 | d[8]\n };\n }\n // QOI: qoif\n if (d32[0] === 0x66696F71) {\n return {\n mime: 'image/qoi',\n width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7],\n height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11]\n };\n }\n // WEBP: RIFF | xxxx | WEBP | VP8x\n if (d32[0] === 0x46464952 && d32[2] === 0x50424557 && (d32[3] & 0xFFFFFF) === 0x385056) {\n switch (d[15]) {\n case 0x58: // Extended WebP VP8X --> \"X\"\n return {\n mime: 'image/webp',\n width: (d[24] | d[25] << 8 | d[26] << 16) + 1,\n height: (d[27] | d[28] << 8 | d[29] << 16) + 1\n };\n case 0x4C: // Lossless WebP VP8L --> \"L\"\n if (d[20] !== 0x2f) return UNSUPPORTED_TYPE;\n const dim = d[21] | d[22] << 8 | d[23] << 16 | d[24] << 24;\n return {\n mime: 'image/webp',\n width: (dim & 0x3FFF) + 1,\n height: (dim >>> 14 & 0x3FFF) + 1\n };\n case 0x20: // Lossy WebP VP8 --> \" \"\n if (d[23] !== 0x9d || d[24] !== 0x01 || d[25] !== 0x2a) return UNSUPPORTED_TYPE;\n return {\n mime: 'image/webp',\n width: (d[26] | d[27] << 8) & 0x3FFF,\n height: (d[28] | d[29] << 8) & 0x3FFF\n };\n }\n return UNSUPPORTED_TYPE;\n }\n // AVIF: Box size | ftyp | avif/avis\n if (d32[1] === 0x70797466 && (d32[2] === 0x66697661 || d32[2] === 0x73697661)) {\n let pos = -1;\n // search for ispe box within first 1024 bytes\n const limit = Math.min(d.length - 16, 1024);\n for (let i = 8; i < limit; i++) {\n // scan for ispe\n if (d[i] === 0x69 && d[i + 1] === 0x73 && d[i + 2] === 0x70 && d[i + 3] === 0x65) {\n pos = i;\n break;\n }\n }\n if (pos !== -1) {\n // dimensions are in BE at +8 (width) at +12 (height)\n const width =\n d[pos + 8] << 24 |\n d[pos + 9] << 16 |\n d[pos + 10] << 8 |\n d[pos + 11];\n const height =\n d[pos + 12] << 24 |\n d[pos + 13] << 16 |\n d[pos + 14] << 8 |\n d[pos + 15];\n if (width > 0 && height > 0) {\n return { mime: 'image/avif', width, height };\n }\n }\n return UNSUPPORTED_TYPE;\n }\n return UNSUPPORTED_TYPE;\n}\n\n\nfunction jpgSize(d: Uint8Array): [number, number] {\n const len = d.length;\n let i = 4;\n let blockLength = d[i] << 8 | d[i + 1];\n while (true) {\n i += blockLength;\n if (i >= len) {\n // exhausted without size info\n return [0, 0];\n }\n if (d[i] !== 0xFF) {\n return [0, 0];\n }\n if (d[i + 1] === 0xC0 || d[i + 1] === 0xC2) {\n if (i + 8 < len) {\n return [\n d[i + 7] << 8 | d[i + 8],\n d[i + 5] << 8 | d[i + 6]\n ];\n }\n return [0, 0];\n }\n i += 2;\n blockLength = d[i] << 8 | d[i + 1];\n }\n}\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { toRGBA8888 } from 'sixel/lib/Colors';\nimport { IDisposable } from '@xterm/xterm';\nimport { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';\nimport { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\n\nconst enum Constants {\n PLACEHOLDER_LENGTH = 4096,\n PLACEHOLDER_HEIGHT = 24\n}\n\n/**\n * ImageRenderer - terminal frontend extension:\n * - provide primitives for canvas, ImageData, Bitmap (static)\n * - add canvas layer to DOM (browser only for now)\n * - draw image tiles onRender\n */\nexport class ImageRenderer extends Disposable implements IDisposable {\n /** @deprecated Kept for backward compat — points to top layer canvas. */\n public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; }\n private _layers = new Map();\n private _placeholder: HTMLCanvasElement | undefined;\n private _placeholderBitmap: ImageBitmap | undefined;\n private _optionsRefresh = this._register(new MutableDisposable());\n private _oldOpen: ((parent: HTMLElement) => void) | undefined;\n private _renderService: IRenderService | undefined;\n private _oldSetRenderer: ((renderer: any) => void) | undefined;\n\n // drawing primitive - canvas\n public static createCanvas(localDocument: Document | undefined, width: number, height: number): HTMLCanvasElement {\n /**\n * NOTE: We normally dont care, from which document the canvas\n * gets created, so we can fall back to global document,\n * if the terminal has no document associated yet.\n * This way early image loads before calling .open keep working\n * (still discouraged though, as the metrics will be screwed up).\n * Only the DOM output canvas should be on the terminal's document,\n * which gets explicitly checked in `insertLayerToDom`.\n */\n const canvas = (localDocument ?? document).createElement('canvas');\n canvas.width = width | 0;\n canvas.height = height | 0;\n return canvas;\n }\n\n // drawing primitive - ImageData with optional buffer\n public static createImageData(ctx: CanvasRenderingContext2D, width: number, height: number, buffer?: ArrayBuffer): ImageData {\n if (typeof ImageData !== 'function') {\n const imgData = ctx.createImageData(width, height);\n if (buffer) {\n imgData.data.set(new Uint8ClampedArray(buffer, 0, width * height * 4));\n }\n return imgData;\n }\n return buffer\n ? new ImageData(new Uint8ClampedArray(buffer, 0, width * height * 4), width, height)\n : new ImageData(width, height);\n }\n\n // drawing primitive - ImageBitmap\n public static createImageBitmap(img: ImageBitmapSource): Promise {\n if (typeof createImageBitmap !== 'function') {\n return Promise.resolve(undefined);\n }\n return createImageBitmap(img);\n }\n\n\n constructor(private _terminal: ITerminalExt) {\n super();\n this._oldOpen = this._terminal._core.open;\n this._terminal._core.open = (parent: HTMLElement): void => {\n this._oldOpen?.call(this._terminal._core, parent);\n this._open();\n };\n if (this._terminal._core.screenElement) {\n this._open();\n }\n // hack to spot fontSize changes\n this._optionsRefresh.value = this._terminal._core.optionsService.onOptionChange(option => {\n if (option === 'fontSize') {\n this.rescaleCanvas();\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n });\n this._register(toDisposable(() => {\n this.removeLayerFromDom();\n this.removeLayerFromDom('bottom');\n if (this._terminal._core && this._oldOpen) {\n this._terminal._core.open = this._oldOpen;\n this._oldOpen = undefined;\n }\n if (this._renderService && this._oldSetRenderer) {\n this._renderService.setRenderer = this._oldSetRenderer;\n this._oldSetRenderer = undefined;\n }\n this._renderService = undefined;\n this._layers.clear();\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }));\n }\n\n /**\n * Enable the placeholder.\n */\n public showPlaceholder(value: boolean): void {\n if (value) {\n if (!this._placeholder && this.cellSize.height !== -1) {\n this._createPlaceHolder(Math.max(this.cellSize.height + 1, Constants.PLACEHOLDER_HEIGHT));\n }\n } else {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n\n /**\n * Dimensions of the terminal.\n * Forwarded from internal render service.\n */\n public get dimensions(): IRenderDimensions | undefined {\n return this._terminal.dimensions;\n }\n\n /**\n * Current cell size (float).\n */\n public get cellSize(): ICellSize {\n return {\n width: this.dimensions?.css.cell.width || -1,\n height: this.dimensions?.css.cell.height || -1\n };\n }\n\n /**\n * Clear a region of the image layer canvas.\n */\n public clearLines(start: number, end: number, layer?: ImageLayer): void {\n const y = start * (this.dimensions?.css.cell.height || 0);\n const w = this.dimensions?.css.canvas.width || 0;\n const h = (end + 1 - start) * (this.dimensions?.css.cell.height || 0);\n if (!layer || layer === 'top') {\n this._layers.get('top')?.clearRect(0, y, w, h);\n }\n if (!layer || layer === 'bottom') {\n this._layers.get('bottom')?.clearRect(0, y, w, h);\n }\n }\n\n /**\n * Clear whole image canvas.\n */\n public clearAll(layer?: ImageLayer): void {\n if (!layer || layer === 'top') {\n const ctx = this._layers.get('top');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n if (!layer || layer === 'bottom') {\n const ctx = this._layers.get('bottom');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n }\n\n /**\n * Draw neighboring tiles on the image layer canvas.\n */\n public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get(imgSpec.layer);\n if (!ctx) {\n return;\n }\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const cols = Math.ceil(img.width / width);\n\n const sx = (tileId % cols) * width;\n const sy = Math.floor(tileId / cols) * height;\n const dx = col * width;\n const dy = row * height;\n\n // safari bug: never access image source out of bounds\n const finalWidth = count * width + sx > img.width ? img.width - sx : count * width;\n const finalHeight = sy + height > img.height ? img.height - sy : height;\n\n // Floor all pixel offsets to get stable tile mapping without any overflows.\n // Note: For not pixel perfect aligned cells like in the DOM renderer\n // this will move a tile slightly to the top/left (subpixel range, thus ignore it).\n // FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),\n Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth), Math.ceil(finalHeight)\n );\n }\n\n /**\n * Extract a single tile from an image.\n */\n public extractTile(imgSpec: IImageSpec, tileId: number): HTMLCanvasElement | undefined {\n const { width, height } = this.cellSize;\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const cols = Math.ceil(img.width / width);\n const sx = (tileId % cols) * width;\n const sy = Math.floor(tileId / cols) * height;\n const finalWidth = width + sx > img.width ? img.width - sx : width;\n const finalHeight = sy + height > img.height ? img.height - sy : height;\n\n const canvas = ImageRenderer.createCanvas(this.document, finalWidth, finalHeight);\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight),\n 0, 0, Math.floor(finalWidth), Math.floor(finalHeight)\n );\n return canvas;\n }\n }\n\n /**\n * Draw a line with placeholder on the image layer canvas.\n */\n public drawPlaceholder(col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get('top');\n if (ctx) {\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n if (!this._placeholder) {\n this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT));\n } else if (height >= this._placeholder!.height) {\n this._createPlaceHolder(height + 1);\n }\n if (!this._placeholder) return;\n ctx.drawImage(\n this._placeholderBitmap ?? this._placeholder!,\n col * width,\n (row * height) % 2 ? 0 : 1, // needs %2 offset correction\n width * count,\n height,\n col * width,\n row * height,\n width * count,\n height\n );\n }\n }\n\n /**\n * Rescale image layer canvas if needed.\n * Checked once from `ImageStorage.render`.\n */\n public rescaleCanvas(): void {\n const w = this.dimensions?.css.canvas.width || 0;\n const h = this.dimensions?.css.canvas.height || 0;\n for (const ctx of this._layers.values()) {\n if (ctx.canvas.width !== w || ctx.canvas.height !== h) {\n ctx.canvas.width = w;\n ctx.canvas.height = h;\n }\n }\n }\n\n /**\n * Rescale image in storage if needed.\n */\n private _rescaleImage(spec: IImageSpec, currentWidth: number, currentHeight: number): void {\n if (currentWidth === spec.actualCellSize.width && currentHeight === spec.actualCellSize.height) {\n return;\n }\n const { width: originalWidth, height: originalHeight } = spec.origCellSize;\n if (currentWidth === originalWidth && currentHeight === originalHeight) {\n spec.actual = spec.orig;\n spec.actualCellSize.width = originalWidth;\n spec.actualCellSize.height = originalHeight;\n return;\n }\n const canvas = ImageRenderer.createCanvas(\n this.document,\n Math.ceil(spec.orig!.width * currentWidth / originalWidth),\n Math.ceil(spec.orig!.height * currentHeight / originalHeight)\n );\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(spec.orig!, 0, 0, canvas.width, canvas.height);\n spec.actual = canvas;\n spec.actualCellSize.width = currentWidth;\n spec.actualCellSize.height = currentHeight;\n }\n }\n\n /**\n * Lazy init for the renderer.\n */\n private _open(): void {\n this._renderService = this._terminal._core._renderService;\n this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);\n this._renderService.setRenderer = (renderer: any) => {\n for (const key of [...this._layers.keys()]) {\n this.removeLayerFromDom(key);\n }\n this._oldSetRenderer?.call(this._renderService, renderer);\n };\n }\n\n public insertLayerToDom(layer: ImageLayer = 'top'): void {\n // make sure that the terminal is attached to a document and to DOM\n if (!this.document || !this._terminal._core.screenElement) {\n console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');\n return;\n }\n if (this._layers.has(layer)) {\n return;\n }\n const canvas = ImageRenderer.createCanvas(\n this.document, this.dimensions?.css.canvas.width || 0,\n this.dimensions?.css.canvas.height || 0\n );\n canvas.classList.add(`xterm-image-layer-${layer}`);\n const screenElement = this._terminal._core.screenElement;\n // Use isolation to create a stacking context without overriding z-index,\n // which would conflict with integrators (e.g. VS Code) that set their\n // own z-index on the screen element.\n screenElement.style.isolation = 'isolate';\n if (layer === 'bottom') {\n // Use z-index:-1 so it paints behind non-positioned text elements.\n // The screen element needs to be a stacking context (via isolation)\n // to contain the negative z-index, otherwise it would go behind the\n // entire terminal.\n canvas.style.zIndex = '-1';\n screenElement.insertBefore(canvas, screenElement.firstChild);\n } else {\n // Explicit z-index ensures the image canvas reliably stacks above\n // the text layer (DOM renderer rows). z-index: 0 is below the\n // selection overlay (z-index: 1).\n canvas.style.zIndex = '0';\n screenElement.appendChild(canvas);\n }\n const ctx = canvas.getContext('2d', { alpha: true });\n if (!ctx) {\n canvas.remove();\n return;\n }\n this._layers.set(layer, ctx);\n this.clearAll(layer);\n }\n\n public removeLayerFromDom(layer: ImageLayer = 'top'): void {\n const ctx = this._layers.get(layer);\n if (ctx) {\n ctx.canvas.remove();\n this._layers.delete(layer);\n }\n }\n\n public hasLayer(layer: ImageLayer): boolean {\n return this._layers.has(layer);\n }\n\n private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n\n // create blueprint to fill placeholder with\n const bWidth = 32; // must be 2^n\n const blueprint = ImageRenderer.createCanvas(this.document, bWidth, height);\n const ctx = blueprint.getContext('2d', { alpha: false });\n if (!ctx) return;\n const imgData = ImageRenderer.createImageData(ctx, bWidth, height);\n const d32 = new Uint32Array(imgData.data.buffer);\n const black = toRGBA8888(0, 0, 0);\n const white = toRGBA8888(255, 255, 255);\n d32.fill(black);\n for (let y = 0; y < height; ++y) {\n const shift = y % 2;\n const offset = y * bWidth;\n for (let x = 0; x < bWidth; x += 2) {\n d32[offset + x + shift] = white;\n }\n }\n ctx.putImageData(imgData, 0, 0);\n\n // create placeholder line, width aligned to blueprint width\n const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || Constants.PLACEHOLDER_LENGTH;\n this._placeholder = ImageRenderer.createCanvas(this.document, width, height);\n const ctx2 = this._placeholder.getContext('2d', { alpha: false });\n if (!ctx2) {\n this._placeholder = undefined;\n return;\n }\n for (let i = 0; i < width; i += bWidth) {\n ctx2.drawImage(blueprint, i, 0);\n }\n ImageRenderer.createImageBitmap(this._placeholder).then(bitmap => this._placeholderBitmap = bitmap);\n }\n\n public get document(): Document | undefined {\n return this._terminal._core._coreBrowserService?.window.document;\n }\n}\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageRenderer } from './ImageRenderer';\nimport {\n ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec,\n IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes,\n UnderlineStyle, IAddImageOpts\n} from './Types';\n\n\n// fallback default cell size\nexport const CELL_SIZE_DEFAULT: ICellSize = {\n width: 7,\n height: 14\n};\n\n/**\n * Extend extended attribute to also hold image tile information.\n *\n * Object definition is copied from base repo to fully mimick its behavior.\n * Image data is added as additional public properties `imageId` and `tileId`.\n */\nclass ExtendedAttrsImage implements IExtendedAttrsImage {\n private _ext: number = 0;\n public get ext(): number {\n if (this._urlId) {\n return (\n (this._ext & ~ExtFlags.UNDERLINE_STYLE) |\n (this.underlineStyle << 26)\n );\n }\n return this._ext;\n }\n public set ext(value: number) { this._ext = value; }\n\n public get underlineStyle(): UnderlineStyle {\n // Always return the URL style if it has one\n if (this._urlId) {\n return UnderlineStyle.DASHED;\n }\n return (this._ext & ExtFlags.UNDERLINE_STYLE) >> 26;\n }\n public set underlineStyle(value: UnderlineStyle) {\n this._ext &= ~ExtFlags.UNDERLINE_STYLE;\n this._ext |= (value << 26) & ExtFlags.UNDERLINE_STYLE;\n }\n\n public get underlineColor(): number {\n return this._ext & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n public set underlineColor(value: number) {\n this._ext &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);\n this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n\n public get underlineVariantOffset(): number {\n const val = (this._ext & ExtFlags.VARIANT_OFFSET) >> 29;\n if (val < 0) {\n return val ^ 0xFFFFFFF8;\n }\n return val;\n }\n public set underlineVariantOffset(value: number) {\n this._ext &= ~ExtFlags.VARIANT_OFFSET;\n this._ext |= (value << 29) & ExtFlags.VARIANT_OFFSET;\n }\n\n private _urlId: number = 0;\n public get urlId(): number {\n return this._urlId;\n }\n public set urlId(value: number) {\n this._urlId = value;\n }\n\n constructor(\n ext: number = 0,\n urlId: number = 0,\n public imageId = -1,\n public tileId = -1\n ) {\n this._ext = ext;\n this._urlId = urlId;\n }\n\n public clone(): IExtendedAttrsImage {\n /**\n * Technically we dont need a clone variant of ExtendedAttrsImage,\n * as we never clone a cell holding image data.\n * Note: Clone is only meant to be used by the InputHandler for\n * sticky attributes, which is never the case for image data.\n * We still provide a proper clone method to reflect the full ext attr\n * state in case there are future use cases for clone.\n */\n return new ExtendedAttrsImage(this._ext, this._urlId, this.imageId, this.tileId);\n }\n\n public isEmpty(): boolean {\n return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.imageId === -1;\n }\n}\nconst EMPTY_ATTRS = new ExtendedAttrsImage();\n\n\n/**\n * ImageStorage - extension of CoreTerminal:\n * - hold image data\n * - write/read image data to/from buffer\n *\n * TODO: image composition for overwrites\n */\nexport class ImageStorage implements IDisposable {\n // storage\n private _images: Map = new Map();\n // last used id\n private _lastId = 0;\n // last evicted id\n private _lowestId = 0;\n // whether a full clear happened before\n private _fullyCleared = false;\n // whether render should do a full clear\n private _needsFullClear = false;\n // hard limit of stored pixels (fallback limit of 10 MB)\n private _pixelLimit: number = 2500000;\n\n private _viewportMetrics: { cols: number, rows: number };\n public onImageAdded: (() => void) | undefined;\n public onImageDeleted: ((storageId: number) => void) | undefined;\n\n constructor(\n private _terminal: ITerminalExt,\n private _renderer: ImageRenderer,\n private _opts: IImageAddonOptions\n ) {\n try {\n this.setLimit(this._opts.storageLimit);\n } catch (e: unknown) {\n if (e instanceof Error) {\n console.error(e.message);\n }\n console.warn(`storageLimit is set to ${this.getLimit()} MB`);\n }\n this._viewportMetrics = {\n cols: this._terminal.cols,\n rows: this._terminal.rows\n };\n }\n\n public dispose(): void {\n this.reset();\n }\n\n public reset(): void {\n for (const spec of this._images.values()) {\n spec.marker?.dispose();\n }\n // NOTE: marker.dispose above already calls ImageBitmap.close\n // therefore we can just wipe the map here\n this._images.clear();\n this._renderer.clearAll();\n }\n\n public getLimit(): number {\n return this._pixelLimit * 4 / 1000000;\n }\n\n public setLimit(value: number): void {\n if (value < 0.5 || value > 1000) {\n throw RangeError('invalid storageLimit, should be at least 0.5 MB and not exceed 1G');\n }\n this._pixelLimit = (value / 4 * 1000000) >>> 0;\n this._evictOldest(0);\n }\n\n public getUsage(): number {\n return this._getStoredPixels() * 4 / 1000000;\n }\n\n private _getStoredPixels(): number {\n let storedPixels = 0;\n for (const spec of this._images.values()) {\n if (spec.orig) {\n storedPixels += spec.orig.width * spec.orig.height;\n if (spec.actual && spec.actual !== spec.orig) {\n storedPixels += spec.actual.width * spec.actual.height;\n }\n }\n }\n return storedPixels;\n }\n\n private _delImg(id: number): void {\n const spec = this._images.get(id);\n if (!spec) return;\n this._images.delete(id);\n // FIXME: really ugly workaround to get bitmaps deallocated :(\n if (window.ImageBitmap && spec.orig instanceof ImageBitmap) {\n spec.orig.close();\n }\n this.onImageDeleted?.(id);\n }\n\n /**\n * Wipe canvas and images on alternate buffer.\n */\n public wipeAlternate(): void {\n // remove all alternate tagged images\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate') {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n // mark canvas to be wiped on next render\n this._needsFullClear = true;\n this._fullyCleared = false;\n }\n\n /**\n * Delete an image by its internal storage ID.\n * Used by protocols that support explicit deletion (e.g. Kitty a=d).\n */\n public deleteImage(id: number): void {\n const spec = this._images.get(id);\n if (spec) {\n spec.marker?.dispose();\n this._delImg(id);\n }\n }\n\n /**\n * Method to add an image to the storage.\n * @param img - The image to add (canvas or bitmap).\n * @param opts - Options for addImage:\n * - scrolling: When true, cursor advances with the image.\n * When false, image is placed at ORIGIN and cursor does not move.\n * - layer: Which canvas layer to render on ('top' or 'bottom').\n * - zIndex: Z-index for image layering within the same layer.\n * - cursorPos: 'vt340' for bottom-left, 'iip' for bottom.right.\n * @returns The internal image ID assigned to the stored image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap, opts: IAddImageOpts): number {\n // never allow storage to exceed memory limit\n this._evictOldest(img.width * img.height);\n\n // calc rows x cols needed to display the image\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const cols = Math.ceil(img.width / cellSize.width);\n const rows = Math.ceil(img.height / cellSize.height);\n\n const imageId = ++this._lastId;\n\n const buffer = this._terminal._core.buffer;\n const termCols = this._terminal.cols;\n const termRows = this._terminal.rows;\n const originX = buffer.x;\n const originY = buffer.y;\n let offset = originX;\n let tileCount = 0;\n\n if (!opts.scrolling) {\n buffer.x = 0;\n buffer.y = 0;\n offset = 0;\n }\n\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(buffer.y + buffer.ybase);\n for (let col = 0; col < cols; ++col) {\n if (offset + col >= termCols) break;\n this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);\n tileCount++;\n }\n if (opts.scrolling) {\n if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();\n } else {\n if (++buffer.y >= termRows) break;\n }\n buffer.x = offset;\n }\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n\n // cursor positioning modes\n if (opts.scrolling) {\n if (opts.cursorPos === 'iip') {\n buffer.x = Math.min(offset + cols, termCols);\n } else {\n buffer.x = offset;\n }\n } else {\n buffer.x = originX;\n buffer.y = originY;\n }\n\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.tileCount < 1) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n\n // eviction marker:\n // delete the image when the marker gets disposed\n const endMarker = this._terminal.registerMarker(0);\n endMarker?.onDispose(() => {\n const spec = this._images.get(imageId);\n if (spec) {\n this._delImg(imageId);\n }\n });\n\n // since markers do not work on alternate for some reason,\n // we evict images here manually\n if (this._terminal.buffer.active.type === 'alternate') {\n this._evictOnAlternate();\n }\n\n // create storage entry\n const imgSpec: IImageSpec = {\n orig: img,\n origCellSize: cellSize,\n actual: img,\n actualCellSize: { ...cellSize }, // clone needed, since later modified\n marker: endMarker || undefined,\n tileCount,\n bufferType: this._terminal.buffer.active.type,\n layer: opts.layer,\n zIndex: opts.zIndex\n };\n\n // finally add the image\n this._images.set(imageId, imgSpec);\n this.onImageAdded?.();\n return imageId;\n }\n\n\n /**\n * Render method. Collects buffer information and triggers\n * canvas updates.\n */\n // TODO: Should we move this to the ImageRenderer?\n public render(range: { start: number, end: number }): void {\n // Determine which layers have images\n let hasTopImages = false;\n let hasBottomImages = false;\n for (const spec of this._images.values()) {\n if (spec.layer === 'bottom') {\n hasBottomImages = true;\n } else {\n hasTopImages = true;\n }\n if (hasTopImages && hasBottomImages) break;\n }\n\n // Lazily insert layers that are needed\n if (hasTopImages && !this._renderer.hasLayer('top')) {\n this._renderer.insertLayerToDom('top');\n if (!this._renderer.hasLayer('top')) return;\n }\n if (hasBottomImages && !this._renderer.hasLayer('bottom')) {\n this._renderer.insertLayerToDom('bottom');\n }\n\n // rescale if needed\n this._renderer.rescaleCanvas();\n\n // exit early if we dont have any images to test for\n if (!this._images.size) {\n if (!this._fullyCleared) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n if (this._renderer.hasLayer('top')) {\n this._renderer.removeLayerFromDom('top');\n }\n if (this._renderer.hasLayer('bottom')) {\n this._renderer.removeLayerFromDom('bottom');\n }\n return;\n }\n\n // Remove layers no longer needed\n if (!hasTopImages && this._renderer.hasLayer('top')) {\n this._renderer.clearAll('top');\n this._renderer.removeLayerFromDom('top');\n }\n if (!hasBottomImages && this._renderer.hasLayer('bottom')) {\n this._renderer.clearAll('bottom');\n this._renderer.removeLayerFromDom('bottom');\n }\n\n // buffer switches force a full clear\n if (this._needsFullClear) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n\n const { start, end } = range;\n const buffer = this._terminal._core.buffer;\n const cols = this._terminal._core.cols;\n\n // clear drawing area\n this._renderer.clearLines(start, end);\n\n // Collect draw calls so we can sort by z-index (lower z drawn first).\n const drawCalls: { imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number }[] = [];\n const placeholderCalls: { col: number, row: number, count: number }[] = [];\n\n // walk all cells in viewport and collect tiles found\n for (let row = start; row <= end; ++row) {\n const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt;\n if (!line) return;\n for (let col = 0; col < cols; ++col) {\n if (line.getBg(col) & BgFlags.HAS_EXTENDED) {\n let e: IExtendedAttrsImage = line._extendedAttrs[col] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (e.tileId !== -1) {\n const startTile = e.tileId;\n const startCol = col;\n let count = 1;\n /**\n * merge tiles to the right into a single draw call, if:\n * - not at end of line\n * - cell has same image id\n * - cell has consecutive tile id\n */\n while (\n ++col < cols\n && (line.getBg(col) & BgFlags.HAS_EXTENDED)\n && (e = line._extendedAttrs[col] ?? EMPTY_ATTRS)\n && (e.imageId === imageId)\n && (e.tileId === startTile + count)\n ) {\n count++;\n }\n col--;\n if (imgSpec) {\n if (imgSpec.actual) {\n drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });\n }\n } else if (this._opts.showPlaceholder) {\n placeholderCalls.push({ col: startCol, row, count });\n }\n this._fullyCleared = false;\n }\n }\n }\n }\n\n // Sort by z-index so lower z draws first (higher z renders on top)\n drawCalls.sort((a, b) => a.imgSpec.zIndex - b.imgSpec.zIndex);\n\n // Draw placeholders first (lowest priority)\n for (const call of placeholderCalls) {\n this._renderer.drawPlaceholder(call.col, call.row, call.count);\n }\n\n // Draw images in z-index order\n for (const call of drawCalls) {\n this._renderer.draw(call.imgSpec, call.tileId, call.col, call.row, call.count);\n }\n }\n\n public viewportResize(metrics: { cols: number, rows: number }): void {\n // exit early if we have nothing in storage\n if (!this._images.size) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // handle only viewport width enlargements, exit all other cases\n // TODO: needs patch for tile counter\n if (this._viewportMetrics.cols >= metrics.cols) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // walk scrollbuffer at old col width to find all possible expansion matches\n const buffer = this._terminal._core.buffer;\n const rows = buffer.lines.length;\n const oldCol = this._viewportMetrics.cols - 1;\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(row) as IBufferLineExt;\n if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (!imgSpec) {\n continue;\n }\n // found an image tile at oldCol, check if it qualifies for right exapansion\n const tilesPerRow = Math.ceil((imgSpec.actual?.width || 0) / imgSpec.actualCellSize.width);\n if ((e.tileId % tilesPerRow) + 1 >= tilesPerRow) {\n continue;\n }\n // expand only if right side is empty (nothing got wrapped from below)\n let hasData = false;\n for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) {\n if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) {\n hasData = true;\n break;\n }\n }\n if (hasData) {\n continue;\n }\n // do right expansion on terminal buffer\n const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol);\n let lastTile = e.tileId;\n for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) {\n this._writeToCell(line as IBufferLineExt, expandCol, imageId, ++lastTile);\n imgSpec.tileCount++;\n }\n }\n }\n // store new viewport metrics\n this._viewportMetrics = metrics;\n }\n\n /**\n * Retrieve original canvas at buffer position.\n */\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1) {\n const orig = this._images.get(e.imageId)?.orig;\n if (window.ImageBitmap && orig instanceof ImageBitmap) {\n const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height);\n canvas.getContext('2d')?.drawImage(orig, 0, 0, orig.width, orig.height);\n return canvas;\n }\n return orig as HTMLCanvasElement;\n }\n }\n }\n\n /**\n * Extract active single tile at buffer position.\n */\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1 && e.tileId !== -1) {\n const spec = this._images.get(e.imageId);\n if (spec) {\n return this._renderer.extractTile(spec, e.tileId);\n }\n }\n }\n }\n\n // TODO: Do we need some blob offloading tricks here to avoid early eviction?\n // also see https://stackoverflow.com/questions/28307789/is-there-any-limitation-on-javascript-max-blob-size\n private _evictOldest(room: number): number {\n const used = this._getStoredPixels();\n let current = used;\n while (this._pixelLimit < current + room && this._images.size) {\n const spec = this._images.get(++this._lowestId);\n if (spec && spec.orig) {\n current -= spec.orig.width * spec.orig.height;\n if (spec.actual && spec.orig !== spec.actual) {\n current -= spec.actual.width * spec.actual.height;\n }\n spec.marker?.dispose();\n this._delImg(this._lowestId);\n }\n }\n return used - current;\n }\n\n private _writeToCell(line: IBufferLineExt, x: number, imageId: number, tileId: number): void {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const old = line._extendedAttrs[x];\n if (old) {\n if (old.imageId !== undefined) {\n // found an old ExtendedAttrsImage, since we know that\n // they are always isolated instances (single cell usage),\n // we can re-use it and just update their id entries\n const oldSpec = this._images.get(old.imageId);\n if (oldSpec) {\n // early eviction for in-viewport overwrites\n oldSpec.tileCount--;\n }\n old.imageId = imageId;\n old.tileId = tileId;\n return;\n }\n // found a plain ExtendedAttrs instance, clone it to new entry\n line._extendedAttrs[x] = new ExtendedAttrsImage(old.ext, old.urlId, imageId, tileId);\n return;\n }\n }\n // fall-through: always create new ExtendedAttrsImage entry\n line._data[x * Cell.SIZE + Cell.BG] |= BgFlags.HAS_EXTENDED;\n line._extendedAttrs[x] = new ExtendedAttrsImage(0, 0, imageId, tileId);\n }\n\n private _evictOnAlternate(): void {\n // nullify tile count of all images on alternate buffer\n for (const spec of this._images.values()) {\n if (spec.bufferType === 'alternate') {\n spec.tileCount = 0;\n }\n }\n // re-count tiles on whole buffer\n const buffer = this._terminal._core.buffer;\n for (let y = 0; y < this._terminal.rows; ++y) {\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (!line) {\n continue;\n }\n for (let x = 0; x < this._terminal.cols; ++x) {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const imgId = line._extendedAttrs[x]?.imageId;\n if (imgId) {\n const spec = this._images.get(imgId);\n if (spec) {\n spec.tileCount++;\n }\n }\n }\n }\n }\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate' && !spec.tileCount) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n }\n}\n","/**\n * Copyright (c) 2020, 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IDcsHandler, IParams, IImageAddonOptions, ITerminalExt, AttributeData, IResetHandler, ReadonlyColorSet } from './Types';\nimport { toRGBA8888, BIG_ENDIAN, PALETTE_ANSI_256, PALETTE_VT340_COLOR } from 'sixel/lib/Colors';\nimport { RGBA8888 } from 'sixel/lib/Types';\nimport { ImageRenderer } from './ImageRenderer';\n\nimport { DecoderAsync, Decoder } from 'sixel/lib/Decoder';\n\n// always free decoder ressources after decoding if it exceeds this limit\nconst MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB\n\n// custom default palette: VT340 (lower 16 colors) + ANSI256 (up to 256) + zeroed (up to 4096)\nconst DEFAULT_PALETTE = PALETTE_ANSI_256;\nDEFAULT_PALETTE.set(PALETTE_VT340_COLOR);\n\n\nexport class SixelHandler implements IDcsHandler, IResetHandler {\n private _size = 0;\n private _aborted = false;\n private _dec: Decoder | undefined;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _storage: SixelImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n DecoderAsync({\n memoryLimit: this._opts.pixelLimit * 4,\n palette: DEFAULT_PALETTE,\n paletteLimit: this._opts.sixelPaletteLimit\n }).then(d => this._dec = d);\n }\n\n public reset(): void {\n /**\n * reset sixel decoder to defaults:\n * - release all memory\n * - nullify palette (4096)\n * - apply default palette (256)\n */\n if (this._dec) {\n this._dec.release();\n // FIXME: missing interface on decoder to nullify full palette\n (this._dec as any)._palette.fill(0);\n this._dec.init(0, DEFAULT_PALETTE, this._opts.sixelPaletteLimit);\n }\n }\n\n public hook(params: IParams): void {\n this._size = 0;\n this._aborted = false;\n if (this._dec) {\n const fillColor = params.params[1] === 1 ? 0 : extractActiveBg(\n this._coreTerminal._core._inputHandler._curAttrData,\n this._coreTerminal._core._themeService?.colors);\n this._dec.init(fillColor, null, this._opts.sixelPaletteLimit);\n }\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted || !this._dec) {\n return;\n }\n this._size += end - start;\n if (this._size > this._opts.sixelSizeLimit) {\n console.warn(`SIXEL: too much data, aborting`);\n this._aborted = true;\n this._dec.release();\n return;\n }\n try {\n this._dec.decode(data, start, end);\n } catch (e) {\n console.warn(`SIXEL: error while decoding image - ${e}`);\n this._aborted = true;\n this._dec.release();\n }\n }\n\n public unhook(success: boolean): boolean | Promise {\n if (this._aborted || !success || !this._dec) {\n return true;\n }\n\n const width = this._dec.width;\n const height = this._dec.height;\n\n // partial fix for https://github.com/jerch/xterm-addon-image/issues/37\n if (!width || !height) {\n if (height) {\n this._storage.advanceCursor(height);\n }\n return true;\n }\n\n const canvas = ImageRenderer.createCanvas(undefined, width, height);\n canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray, width, height), 0, 0);\n if (this._dec.memoryUsage > MEM_PERMA_LIMIT) {\n this._dec.release();\n }\n this._storage.addImage(canvas);\n return true;\n }\n}\n\n\n/**\n * Some helpers to extract current terminal colors.\n */\n\n// get currently active background color from terminal\n// also respect INVERSE setting\nfunction extractActiveBg(attr: AttributeData, colors: ReadonlyColorSet | undefined): RGBA8888 {\n let bg = 0;\n if (!colors) {\n // FIXME: theme service is prolly not available yet,\n // happens if .open() was not called yet (bug in core?)\n return bg;\n }\n if (attr.isInverse()) {\n if (attr.isFgDefault()) {\n bg = convertLe(colors.foreground.rgba);\n } else if (attr.isFgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getFgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getFgColor()].rgba);\n }\n } else {\n if (attr.isBgDefault()) {\n bg = convertLe(colors.background.rgba);\n } else if (attr.isBgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getBgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getBgColor()].rgba);\n }\n }\n return bg;\n}\n\n// rgba values on the color managers are always in BE, thus convert to LE\nfunction convertLe(color: number): RGBA8888 {\n if (BIG_ENDIAN) return color;\n return (color & 0xFF) << 24 | (color >>> 8 & 0xFF) << 16 | (color >>> 16 & 0xFF) << 8 | color >>> 24 & 0xFF;\n}\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { IImageAddonOptions, ITerminalExt, IAddImageOpts } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\n\n/**\n * Sixel-specific image storage controller.\n *\n * Wraps the shared ImageStorage with sixel protocol semantics:\n * - Cursor behavior governed by DECSET 80 (sixelScrolling option)\n * - advanceCursor for empty sixels carrying only height\n */\nexport class SixelImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'vt340' };\n constructor(\n private readonly _storage: ImageStorage,\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _terminal: ITerminalExt\n ) {}\n\n /**\n * Add a sixel image to storage.\n * Cursor behavior depends on the sixelScrolling option (DECSET 80).\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._addImageOpts.scrolling = this._opts.sixelScrolling;\n this._storage.addImage(img, this._addImageOpts);\n }\n\n /**\n * Only advance text cursor.\n * This is an edge case from empty sixels carrying only a height but no pixels.\n * Partially fixes https://github.com/jerch/xterm-addon-image/issues/37.\n */\n public advanceCursor(height: number): void {\n if (this._opts.sixelScrolling) {\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const rows = Math.ceil(height / cellSize.height);\n for (let i = 1; i < rows; ++i) {\n this._terminal._core._inputHandler.lineFeed();\n }\n }\n }\n}\n","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';\nimport { ImageRenderer } from '../ImageRenderer';\nimport { CELL_SIZE_DEFAULT } from '../ImageStorage';\nimport { KittyImageStorage } from './KittyImageStorage';\nimport Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport {\n KittyAction,\n KittyFormat,\n KittyCompression,\n IKittyCommand,\n IPendingTransmission,\n IKittyImageData,\n KittyPixelConstants,\n parseKittyCommand\n} from './KittyGraphicsTypes';\n\nconst enum Constants {\n // Memory limit for base64 decoder (4MB, same as IIPHandler)\n DECODER_KEEP_DATA = 4194304,\n DECODER_INITIAL_DATA = 4194304, // 4MB\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n DECODER_OK = 0,\n // Maximum control data size\n MAX_CONTROL_DATA_SIZE = 512,\n // Semicolon codepoint\n SEMICOLON = 0x3B\n}\n\nconst DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK;\n\n// Kitty graphics protocol handler with streaming base64 decoding.\nexport class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {\n private _aborted = false;\n private _decodeError = false;\n\n private _activeDecoder: Base64Decoder | null = null;\n private readonly _maxEncodedBytes: number;\n private readonly _initialEncodedBytes: number;\n\n // Streaming related states\n\n // True while receiving control data (before semicolon).\n private _inControlData = true;\n\n // Buffer for control data.\n private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);\n private _controlLength = 0;\n\n // Pre-calculated encoded size limit\n private _encodedSizeLimit = 0;\n private _totalEncodedSize = 0;\n\n // Parsed command. These are the control data before semicolon.\n private _parsedCommand: IKittyCommand | null = null;\n\n // Storage related states\n\n private _pendingTransmissions: Map = new Map();\n // Tracks the pending key of the most recently started chunked upload.\n // Per spec, subsequent chunks only need m= (and optionally q=), without i=.\n // When a chunk arrives with no i=, this key is used to find the pending upload.\n private _lastPendingKey: number | undefined;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _kittyStorage: KittyImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n // Convert decoded size limit -> max encoded bytes.\n this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);\n // ensure we preallocate more than configured limit while using 4mb initial size.\n this._initialEncodedBytes = Math.min(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);\n }\n\n public reset(): void {\n this._cleanupAllPending();\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n this._kittyStorage.reset();\n }\n\n public dispose(): void {\n this.reset();\n }\n\n private _removePendingEntry(key: number): void {\n this._pendingTransmissions.delete(key);\n if (this._lastPendingKey === key) {\n this._lastPendingKey = undefined;\n }\n }\n\n private _cleanupAllPending(): void {\n for (const pending of this._pendingTransmissions.values()) {\n pending.decoder.release();\n }\n this._pendingTransmissions.clear();\n this._lastPendingKey = undefined;\n }\n\n public start(): void {\n this._aborted = false;\n this._decodeError = false;\n this._inControlData = true;\n this._controlLength = 0;\n this._parsedCommand = null;\n // Pre-calculate encoded limit once: base64 is 4 bytes encoded → 3 bytes decoded\n this._encodedSizeLimit = this._maxEncodedBytes;\n this._totalEncodedSize = 0;\n this._activeDecoder = null;\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (!this._inControlData) {\n this._streamPayload(data, start, end);\n } else {\n // Scan for semicolon\n let controlEnd = end;\n for (let i = start; i < end; i++) {\n if (data[i] === Constants.SEMICOLON) {\n this._inControlData = false;\n controlEnd = i;\n break;\n }\n }\n\n // Copy control data\n const copyLength = controlEnd - start;\n if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {\n this._aborted = true;\n return;\n }\n this._controlData.set(data.subarray(start, controlEnd), this._controlLength);\n this._controlLength += copyLength;\n\n if (!this._inControlData) {\n // Found semicolon - parse control data early for validation\n this._parsedCommand = parseKittyCommand(this._parseControlDataString());\n\n // Early validation: i+I conflict\n if (this._parsedCommand.id !== undefined && this._parsedCommand.imageNumber !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'EINVAL:cannot specify both i and I keys', this._parsedCommand.quiet ?? 0);\n this._aborted = true;\n return;\n }\n\n // Delete action doesn't need payload - skip streaming\n if (this._parsedCommand.action === KittyAction.DELETE) {\n return;\n }\n\n // Stream remaining as payload\n const payloadStart = controlEnd + 1;\n if (payloadStart < end) {\n this._streamPayload(data, payloadStart, end);\n }\n }\n }\n }\n\n // Stream payload bytes into the base64 decoder.\n private _streamPayload(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n // Check size limit (compare encoded bytes against pre-calculated limit)\n // Include cumulative size from pending transmission for multi-chunk images.\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = this._parsedCommand?.id ?? this._lastPendingKey ?? 0;\n const pending = this._pendingTransmissions.get(pendingKey);\n const previousEncodedSize = pending?.totalEncodedSize ?? 0;\n this._totalEncodedSize += end - start;\n const cumulativeEncodedSize = previousEncodedSize + this._totalEncodedSize;\n if (cumulativeEncodedSize > this._encodedSizeLimit) {\n const decoderToRelease = this._activeDecoder ?? pending?.decoder;\n if (decoderToRelease) {\n decoderToRelease.release();\n }\n this._activeDecoder = null;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n this._aborted = true;\n return;\n }\n\n if (this._decodeError) return;\n\n if (pending?.decoder && !this._activeDecoder) {\n this._activeDecoder = pending.decoder;\n }\n if (!this._activeDecoder) {\n this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);\n this._activeDecoder.init();\n }\n\n if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n this._decodeError = true;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted || !success) {\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n return true;\n }\n\n // No semicolon = no payload (delete, capability query)\n if (this._inControlData) {\n return this._handleNoPayloadCommand();\n }\n\n // Use command parsed early in put() - i+I already validated there\n const cmd = this._parsedCommand!;\n\n // Delete action was handled by skipping payload - just execute\n if (cmd.action === KittyAction.DELETE) {\n return this._handleDelete(cmd);\n }\n\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = cmd.id ?? this._lastPendingKey ?? 0;\n const isMoreComing = cmd.more === 1;\n const pending = this._pendingTransmissions.get(pendingKey);\n\n if (isMoreComing) {\n if (this._activeDecoder) {\n if (pending) {\n pending.totalEncodedSize += this._totalEncodedSize;\n pending.decodeError = pending.decodeError || this._decodeError;\n } else {\n this._pendingTransmissions.set(pendingKey, {\n cmd: { ...cmd },\n decoder: this._activeDecoder,\n totalEncodedSize: this._totalEncodedSize,\n decodeError: this._decodeError\n });\n }\n this._lastPendingKey = pendingKey;\n this._activeDecoder = null;\n }\n return true;\n }\n\n // Final chunk received — clear the last pending key\n if (pending) {\n this._lastPendingKey = undefined;\n }\n\n let decodeError = this._decodeError;\n let finalCmd = cmd;\n let decoder = this._activeDecoder;\n\n if (pending) {\n finalCmd = pending.cmd;\n decoder = pending.decoder;\n decodeError = decodeError || pending.decodeError;\n this._pendingTransmissions.delete(pendingKey);\n }\n\n let imageBytes = new Uint8Array(0);\n if (decoder) {\n if (decoder.end() !== DECODER_OK) {\n decodeError = true;\n }\n imageBytes = decoder.data8;\n }\n this._activeDecoder = null;\n\n // Handle command first — handlers create Blob/ImageData from imageBytes,\n // which copies the data. Only then is it safe to release the decoder's\n // wasm memory that imageBytes points into.\n const result = this._handleCommandWithBytesAndCmd(finalCmd, imageBytes, decodeError);\n if (decoder) {\n decoder.release();\n }\n return result;\n }\n\n // Command handling\n\n private _parseControlDataString(): string {\n let str = '';\n for (let i = 0; i < this._controlLength; i++) {\n str += String.fromCodePoint(this._controlData[i]);\n }\n return str;\n }\n\n private _handleNoPayloadCommand(): boolean | Promise {\n const cmd = parseKittyCommand(this._parseControlDataString());\n\n // Per spec: specifying both i and I is an error\n if (cmd.id !== undefined && cmd.imageNumber !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:cannot specify both i and I keys', cmd.quiet ?? 0);\n return true;\n }\n\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.DELETE:\n return this._handleDelete(cmd);\n case KittyAction.QUERY:\n this._sendResponse(cmd.id ?? 0, 'OK', cmd.quiet ?? 0);\n return true;\n case KittyAction.PLACEMENT:\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handleCommandWithBytesAndCmd(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.TRANSMIT: {\n const result = this._handleTransmit(cmd, bytes, decodeError);\n // Only send response when _handleTransmit didn't already respond\n // (it handles unsupported transmission medium responses internally)\n if ((cmd.transmission ?? 'd') === 'd' && cmd.id !== undefined) {\n if (decodeError) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n } else if (bytes.length > 0) {\n this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0);\n }\n }\n return result;\n }\n case KittyAction.TRANSMIT_DISPLAY:\n return this._handleTransmitDisplay(cmd, bytes, decodeError);\n case KittyAction.QUERY:\n return this._handleQuery(cmd, bytes, decodeError);\n case KittyAction.PLACEMENT:\n // a=p ignores any payload — image data was already transmitted\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handlePlacement(cmd: IKittyCommand): boolean | Promise {\n if (cmd.id === undefined) {\n return true;\n }\n const id = cmd.id;\n const image = this._kittyStorage.getImage(id);\n if (!image) {\n this._sendResponse(id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);\n return true;\n }\n const result = this._displayImage(image, cmd);\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);\n return true;\n });\n }\n\n private _handleTransmit(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n // TODO: Support file-based transmission modes (t=f, t=t, t=s)\n // Currently only supports direct transmission (t=d, the default).\n // - t=f (file): Payload is base64-encoded file path. Terminal reads image from that path.\n // - t=t (temp file): Payload is base64-encoded path in temp directory. Terminal reads, deletes.\n // - t=s: Payload is base64-encoded POSIX shm name. Terminal reads from shared memory.\n // These modes require filesystem/IPC access not available in browsers. For Node.js/Electron:\n // 1. Check cmd.transmission (t key) before treating bytes as image data\n // 2. For t=f/t/s: decode bytes as UTF-8 string (the path/name), then read file contents\n // 3. For t=d: treat bytes as image data (current behavior)\n // When implementing, also update _handleQuery to accept these transmission mediums.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported transmission medium', cmd.quiet ?? 0);\n }\n return true;\n }\n\n if (decodeError || bytes.length === 0) return true;\n\n this._kittyStorage.storeImage(cmd.id, {\n data: new Blob([bytes as BlobPart]),\n width: cmd.width ?? 0,\n height: cmd.height ?? 0,\n format: (cmd.format ?? KittyFormat.RGBA) as 24 | 32 | 100,\n compression: cmd.compression ?? ''\n });\n return true;\n }\n\n private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n if (decodeError) {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n }\n return true;\n }\n\n this._handleTransmit(cmd, bytes, decodeError);\n\n const id = cmd.id ?? this._kittyStorage.lastImageId;\n const image = this._kittyStorage.getImage(id);\n if (image) {\n const result = this._displayImage(image, cmd);\n if (cmd.id !== undefined) {\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0);\n return true;\n });\n }\n return result.then(() => true);\n }\n return true;\n }\n\n private _handleQuery(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n const id = cmd.id ?? 0;\n const quiet = cmd.quiet ?? 0;\n\n // Per spec: reject unsupported transmission mediums (only t=d is supported atm)\n // TODO: When filesystem support is added (Node.js/Electron), update this to accept\n // t=f (file), t=t (temp file), and t=s (shared memory) and respond OK for queries.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n this._sendResponse(id, 'EINVAL:unsupported transmission medium', quiet);\n return true;\n }\n\n // Check decode error first (invalid base64)\n if (decodeError) {\n this._sendResponse(id, 'EINVAL:invalid base64 data', quiet);\n return true;\n }\n\n // Capability query (no payload) - just respond OK\n if (bytes.length === 0) {\n this._sendResponse(id, 'OK', quiet);\n return true;\n }\n\n const format = cmd.format ?? KittyFormat.RGBA;\n\n if (format === KittyFormat.PNG) {\n this._sendResponse(id, 'OK', quiet);\n } else {\n const width = cmd.width ?? 0;\n const height = cmd.height ?? 0;\n\n if (!width || !height) {\n this._sendResponse(id, 'EINVAL:width and height required for raw pixel data', quiet);\n return true;\n }\n\n const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n this._sendResponse(id, `EINVAL:insufficient pixel data`, quiet);\n return true;\n }\n\n this._sendResponse(id, 'OK', quiet);\n }\n return true;\n }\n\n private _handleDelete(cmd: IKittyCommand): boolean {\n // Per spec: default delete selector is 'a' (delete all visible placements)\n const selector = cmd.deleteSelector ?? 'a';\n\n // TODO: Distinguish lowercase (delete placements only) from uppercase\n // (delete placements + free stored image data). Currently both variants\n // free everything since we don't separate stored data from placements.\n switch (selector) {\n case 'a':\n case 'A':\n this._cleanupAllPending();\n this._kittyStorage.deleteAll();\n break;\n case 'i':\n case 'I':\n // TODO: When placement id tracking is implemented (see TODO in\n // KittyImageStorage), d=i with p= should delete only that\n // specific placement, while d=i without p should delete all\n // placements for the image.\n if (cmd.id !== undefined) {\n const pending = this._pendingTransmissions.get(cmd.id);\n if (pending) {\n pending.decoder.release();\n }\n this._removePendingEntry(cmd.id);\n this._kittyStorage.deleteById(cmd.id);\n }\n break;\n default:\n // Unsupported selectors (c, n, p, q, r, x, y, z, f) — ignore for now\n break;\n }\n return true;\n }\n\n private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void {\n const isOk = message === 'OK';\n if (isOk && quiet >= 1) return;\n if (!isOk && quiet >= 2) return;\n\n const pPart = placementId ? `,p=${placementId}` : '';\n const response = `\\x1b_Gi=${id}${pPart};${message}\\x1b\\\\`;\n this._coreTerminal._core.coreService.triggerDataEvent(response);\n }\n\n // Image display\n\n private _displayImage(image: IKittyImageData, cmd: IKittyCommand): Promise {\n return this._decodeAndDisplay(image, cmd)\n .then(() => true)\n .catch(() => false);\n }\n\n private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise {\n let bitmap: ImageBitmap | undefined = await this._createBitmap(image);\n\n try {\n const cropX = Math.max(0, cmd.x ?? 0);\n const cropY = Math.max(0, cmd.y ?? 0);\n const cropW = cmd.sourceWidth || (bitmap.width - cropX);\n const cropH = cmd.sourceHeight || (bitmap.height - cropY);\n\n const maxCropW = Math.max(0, bitmap.width - cropX);\n const maxCropH = Math.max(0, bitmap.height - cropY);\n const finalCropW = Math.max(0, Math.min(cropW, maxCropW));\n const finalCropH = Math.max(0, Math.min(cropH, maxCropH));\n\n if (finalCropW === 0 || finalCropH === 0) {\n throw new Error('invalid source rectangle');\n }\n\n if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {\n const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);\n bitmap.close();\n bitmap = cropped;\n }\n\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n\n // Per spec: c/r default to image's natural cell dimensions.\n // If only one of c/r is specified, compute the other from image aspect ratio.\n let imgCols: number;\n let imgRows: number;\n if (cmd.columns !== undefined && cmd.rows !== undefined) {\n imgCols = cmd.columns;\n imgRows = cmd.rows;\n } else if (cmd.columns !== undefined) {\n imgCols = cmd.columns;\n imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));\n } else if (cmd.rows !== undefined) {\n imgRows = cmd.rows;\n imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));\n } else {\n imgCols = Math.ceil(bitmap.width / cw);\n imgRows = Math.ceil(bitmap.height / ch);\n }\n\n let w = bitmap.width;\n let h = bitmap.height;\n\n // Scale bitmap to fit placement rectangle when c/r are specified\n if (cmd.columns !== undefined || cmd.rows !== undefined) {\n w = Math.round(imgCols * cw);\n h = Math.round(imgRows * ch);\n }\n\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n\n // Save cursor position before addImage modifies it\n const buffer = this._coreTerminal._core.buffer;\n const savedX = buffer.x;\n const savedY = buffer.y;\n const savedYbase = buffer.ybase;\n\n // Determine layer based on z-index: negative = behind text, 0+ = on top.\n // When z<0 we always use the bottom layer even without allowTransparency —\n // the image will simply be hidden behind the opaque text background, which\n // is the correct behavior (client asked for \"behind text\").\n const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;\n const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';\n\n if (w !== bitmap.width || h !== bitmap.height) {\n const scaled = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });\n bitmap.close();\n bitmap = scaled;\n }\n\n // Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions\n const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1);\n const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1);\n if (xOffset !== 0 || yOffset !== 0) {\n // Per spec: X/Y is not added to c/r area. When c/r are explicit, the\n // total placement area remains c*cw × r*ch pixels and the offset image\n // is clipped to fit. When c/r are unset, the padded canvas determines\n // the natural cell dimensions.\n const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset;\n const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset;\n const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);\n const offsetCtx = offsetCanvas.getContext('2d');\n if (!offsetCtx) {\n throw new Error('Failed to create offset canvas context');\n }\n offsetCtx.drawImage(bitmap, xOffset, yOffset);\n\n const offsetBitmap = await createImageBitmap(offsetCanvas);\n offsetCanvas.width = offsetCanvas.height = 0;\n bitmap.close();\n bitmap = offsetBitmap;\n w = bitmap.width;\n h = bitmap.height;\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n if (cmd.columns === undefined) {\n imgCols = Math.ceil(bitmap.width / cw);\n }\n if (cmd.rows === undefined) {\n imgRows = Math.ceil(bitmap.height / ch);\n }\n }\n\n const zIndex = cmd.zIndex ?? 0;\n this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);\n bitmap = undefined; // ownership transferred to storage\n\n // Kitty cursor movement\n // Per spec: cursor placed at first column after last image column,\n // on the last row of the image. C=1 means don't move cursor.\n if (cmd.cursorMovement === 1) {\n // C=1: restore cursor to position before image was placed\n const scrolled = buffer.ybase - savedYbase;\n buffer.x = savedX;\n // Can't restore cursor to scrollback?\n buffer.y = Math.max(savedY - scrolled, 0);\n } else {\n // Default (C=0): advance cursor horizontally past the image\n // addImage already positioned cursor on the last row via lineFeeds\n buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);\n }\n } catch (e) {\n bitmap?.close();\n throw e;\n }\n }\n\n // Create ImageBitmap from already-decoded image data.\n private async _createBitmap(image: IKittyImageData): Promise {\n let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());\n\n if (image.compression === KittyCompression.ZLIB) {\n bytes = await this._decompressZlib(bytes);\n }\n\n if (image.format === KittyFormat.PNG) {\n const blob = new Blob([bytes as BlobPart], { type: 'image/png' });\n if (!window.createImageBitmap) {\n const url = URL.createObjectURL(blob);\n const img = new Image();\n return new Promise((resolve, reject) => {\n img.addEventListener('load', () => {\n URL.revokeObjectURL(url);\n const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);\n canvas.getContext('2d')?.drawImage(img, 0, 0);\n createImageBitmap(canvas).then(resolve).catch(reject);\n });\n img.addEventListener('error', () => {\n URL.revokeObjectURL(url);\n reject(new Error('Failed to load image'));\n });\n img.src = url;\n });\n }\n return createImageBitmap(blob);\n }\n\n // Raw pixel data\n const width = image.width;\n const height = image.height;\n\n if (!width || !height) {\n throw new Error('Width and height required for raw pixel data');\n }\n\n const bytesPerPixel = image.format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n throw new Error('Insufficient pixel data');\n }\n\n const pixelCount = width * height;\n\n if (image.format === KittyFormat.RGBA) {\n // RGBA: use bytes directly — no copy needed\n return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA), width, height));\n }\n\n // RGB→RGBA: interleave alpha using uint32 block processing (4 pixels per iteration).\n // 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes — ~6x faster.\n // Assumes little-endian (all modern browsers/Node.js).\n const data = new Uint8ClampedArray(pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA);\n const src32 = new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));\n const dst32 = new Uint32Array(data.buffer);\n const alignedPixels = pixelCount & ~3; // round down to multiple of 4\n\n let srcOffset = 0;\n let dstOffset = 0;\n for (let i = 0; i < alignedPixels; i += 4) {\n const b0 = src32[srcOffset++];\n const b1 = src32[srcOffset++];\n const b2 = src32[srcOffset++];\n // Little-endian: pixel bytes are [R,G,B] → uint32 ABGR layout\n dst32[dstOffset++] = 0xFF000000 | b0;\n dst32[dstOffset++] = 0xFF000000 | (b0 >>> 24) | (b1 << 8);\n dst32[dstOffset++] = 0xFF000000 | (b1 >>> 16) | (b2 << 16);\n dst32[dstOffset++] = 0xFF000000 | (b2 >>> 8);\n }\n\n // Handle remaining 1–3 pixels\n let srcByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n let dstByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n for (let i = alignedPixels; i < pixelCount; i++) {\n data[dstByte] = bytes[srcByte];\n data[dstByte + 1] = bytes[srcByte + 1];\n data[dstByte + 2] = bytes[srcByte + 2];\n data[dstByte + 3] = KittyPixelConstants.ALPHA_OPAQUE;\n srcByte += KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n dstByte += KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n }\n\n return createImageBitmap(new ImageData(data, width, height));\n }\n\n private async _decompressZlib(compressed: Uint8Array): Promise {\n try {\n return await this._decompress(compressed, 'deflate');\n } catch {\n return await this._decompress(compressed, 'deflate-raw');\n }\n }\n\n private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise {\n const ds = new DecompressionStream(format);\n const writer = ds.writable.getWriter();\n writer.write(compressed as BufferSource);\n writer.close();\n\n const chunks: Uint8Array[] = [];\n const reader = ds.readable.getReader();\n\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n chunks.push(value);\n }\n\n const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);\n const result = new Uint8Array(totalLength);\n let offset = 0;\n for (const chunk of chunks) {\n result.set(chunk, offset);\n offset += chunk.length;\n }\n return result;\n }\n\n public get images(): ReadonlyMap {\n return this._kittyStorage.images;\n }\n\n public get _kittyIdToStorageId(): ReadonlyMap {\n return this._kittyStorage.kittyIdToStorageId;\n }\n\n public get pendingTransmissions(): ReadonlyMap {\n return this._pendingTransmissions;\n }\n}\n","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Kitty graphics protocol types, constants, and parsing utilities.\n */\n\nimport type Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\n\n// Kitty graphics protocol action types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'a'.\nexport const enum KittyAction {\n TRANSMIT = 't',\n TRANSMIT_DISPLAY = 'T',\n QUERY = 'q',\n PLACEMENT = 'p',\n DELETE = 'd'\n}\n\n// Kitty graphics protocol format types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyFormat {\n RGB = 24,\n RGBA = 32,\n PNG = 100\n}\n\n// Kitty graphics protocol compression types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'o'.\nexport const enum KittyCompression {\n NONE = '',\n ZLIB = 'z'\n}\n\n// Kitty graphics protocol control data keys.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyKey {\n // Action to perform (t=transmit, T=transmit+display, q=query, p=placement, d=delete)\n ACTION = 'a',\n // Image format (24=RGB, 32=RGBA, 100=PNG)\n FORMAT = 'f',\n // Image ID for referencing stored images\n ID = 'i',\n // Image number (alternative to ID, terminal assigns ID)\n IMAGE_NUMBER = 'I',\n // Source image width in pixels\n WIDTH = 's',\n // Source image height in pixels\n HEIGHT = 'v',\n // The left edge (in pixels) of the image area to display\n X_OFFSET = 'x',\n // The top edge (in pixels) of the image area to display\n Y_OFFSET = 'y',\n // Width (in pixels) of the source rectangle to display\n SOURCE_WIDTH = 'w',\n // Height (in pixels) of the source rectangle to display\n SOURCE_HEIGHT = 'h',\n // Horizontal offset (in pixels) within the first cell\n X_PLACEMENT_OFFSET = 'X',\n // Vertical offset (in pixels) within the first cell\n Y_PLACEMENT_OFFSET = 'Y',\n // Number of terminal columns to display the image over\n COLUMNS = 'c',\n // Number of terminal rows to display the image over\n ROWS = 'r',\n // More data flag (1=more chunks coming, 0=final chunk)\n MORE = 'm',\n // Compression type (z=zlib). This is essential for chunking larger images.\n COMPRESSION = 'o',\n // Quiet mode (1=suppress OK responses, 2=suppress error responses)\n QUIET = 'q',\n // Cursor movement policy (0=move cursor after image, 1=don't move cursor)\n CURSOR_MOVEMENT = 'C',\n // Z-index for image layering (negative = behind text, 0+ = on top)\n Z_INDEX = 'z',\n // Transmission medium (d=direct, f=file, t=temp file, s=shared memory)\n TRANSMISSION = 't',\n // Delete selector (a/A=all, i/I=by id, c/C=at cursor, etc.) — only used when a=d\n DELETE_SELECTOR = 'd',\n // Placement ID for targeting specific placements\n PLACEMENT_ID = 'p'\n}\n\n// Pixel format constants\nexport const enum KittyPixelConstants {\n BYTES_PER_PIXEL_RGB = 3,\n BYTES_PER_PIXEL_RGBA = 4,\n ALPHA_OPAQUE = 255\n}\n\n// Parsed Kitty graphics command.\nexport interface IKittyCommand {\n action?: string;\n format?: number;\n id?: number;\n imageNumber?: number;\n width?: number;\n height?: number;\n x?: number;\n y?: number;\n sourceWidth?: number;\n sourceHeight?: number;\n xOffset?: number;\n yOffset?: number;\n columns?: number;\n rows?: number;\n more?: number;\n quiet?: number;\n cursorMovement?: number;\n zIndex?: number;\n transmission?: string;\n deleteSelector?: string;\n placementId?: number;\n compression?: string;\n payload?: string;\n}\n\n// Pending chunked transmission state.\n// Stores metadata from the first chunk while accumulating decoded payload data.\nexport interface IPendingTransmission {\n // The parsed command from the first chunk (contains action, format, dimensions, etc.)\n cmd: IKittyCommand;\n // Decoder used across chunked payloads\n decoder: Base64Decoder;\n // Total encoded (base64) bytes received across all chunks - for size limit enforcement\n totalEncodedSize: number;\n // Whether any chunk has failed to decode\n decodeError: boolean;\n}\n\n// Stored Kitty image data.\nexport interface IKittyImageData {\n id: number;\n // Decoded image data stored as Blob (off JS heap) to avoid 2GB heap limit\n data: Blob;\n width: number;\n height: number;\n format: 24 | 32 | 100;\n compression?: string;\n}\n\n// Parses Kitty graphics control data into a command object.\nexport function parseKittyCommand(data: string): IKittyCommand {\n const cmd: IKittyCommand = {};\n const parts = data.split(',');\n\n for (const part of parts) {\n const eqIdx = part.indexOf('=');\n if (eqIdx === -1) continue;\n\n const key = part.substring(0, eqIdx);\n const value = part.substring(eqIdx + 1);\n\n // Handle string keys first\n if (key === KittyKey.ACTION) {\n cmd.action = value;\n continue;\n }\n if (key === KittyKey.COMPRESSION) {\n cmd.compression = value;\n continue;\n }\n if (key === KittyKey.TRANSMISSION) {\n cmd.transmission = value;\n continue;\n }\n if (key === KittyKey.DELETE_SELECTOR) {\n cmd.deleteSelector = value;\n continue;\n }\n const numValue = parseInt(value, 10);\n switch (key) {\n case KittyKey.FORMAT: cmd.format = numValue; break;\n case KittyKey.ID: cmd.id = numValue; break;\n case KittyKey.IMAGE_NUMBER: cmd.imageNumber = numValue; break;\n case KittyKey.WIDTH: cmd.width = numValue; break;\n case KittyKey.HEIGHT: cmd.height = numValue; break;\n case KittyKey.X_OFFSET: cmd.x = numValue; break;\n case KittyKey.Y_OFFSET: cmd.y = numValue; break;\n case KittyKey.SOURCE_WIDTH: cmd.sourceWidth = numValue; break;\n case KittyKey.SOURCE_HEIGHT: cmd.sourceHeight = numValue; break;\n case KittyKey.X_PLACEMENT_OFFSET: cmd.xOffset = numValue; break;\n case KittyKey.Y_PLACEMENT_OFFSET: cmd.yOffset = numValue; break;\n case KittyKey.COLUMNS: cmd.columns = numValue; break;\n case KittyKey.ROWS: cmd.rows = numValue; break;\n case KittyKey.MORE: cmd.more = numValue; break;\n case KittyKey.QUIET: cmd.quiet = numValue; break;\n case KittyKey.CURSOR_MOVEMENT: cmd.cursorMovement = numValue; break;\n case KittyKey.Z_INDEX: cmd.zIndex = numValue; break;\n case KittyKey.PLACEMENT_ID: cmd.placementId = numValue; break;\n }\n }\n\n return cmd;\n}\n","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageStorage } from '../ImageStorage';\nimport { ImageLayer, IAddImageOpts } from '../Types';\nimport { IKittyImageData } from './KittyGraphicsTypes';\n\n// Kitty-specific image storage controller.\n//\n// Wraps shared ImageStorage with kitty protocol semantics:\n// - tracks transmitted image payloads by kitty image id\n// - tracks kitty image id -> shared ImageStorage id mapping for displayed images\n// - mirrors shared-storage evictions into kitty maps\n// - applies protocol-level undisplayed-image eviction policy\nexport class KittyImageStorage implements IDisposable {\n private static readonly _maxStoredImages = 256;\n\n private _nextImageId = 1;\n private readonly _images: Map = new Map();\n // TODO: Support multiple placements per image. The kitty spec identifies\n // placements by an (image id, placement id) pair — same i + different p\n // values should coexist, and same i + same p should replace the prior\n // placement. Currently we track only one storage entry per kitty image id,\n // so multiple placements of the same image overwrite each other. Fixing\n // this requires changing these maps to Map>\n // (kittyId → placementId → storageId) and updating addImage/deleteById\n // accordingly. The underlying shared ImageStorage would also need to\n // support multiple entries per logical image.\n private readonly _kittyIdToStorageId: Map = new Map();\n private readonly _storageIdToKittyId: Map = new Map();\n\n private readonly _previousOnImageDeleted: ((storageId: number) => void) | undefined;\n private readonly _wrappedOnImageDeleted: (storageId: number) => void;\n private readonly _handleStorageImageDeleted = (storageId: number): void => {\n const kittyId = this._storageIdToKittyId.get(storageId);\n if (kittyId !== undefined) {\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n this._images.delete(kittyId);\n }\n };\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n\n constructor(\n private readonly _storage: ImageStorage\n ) {\n this._previousOnImageDeleted = this._storage.onImageDeleted;\n this._wrappedOnImageDeleted = (storageId: number) => {\n this._previousOnImageDeleted?.(storageId);\n this._handleStorageImageDeleted(storageId);\n };\n this._storage.onImageDeleted = this._wrappedOnImageDeleted;\n }\n\n public reset(): void {\n this._nextImageId = 1;\n this._images.clear();\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public dispose(): void {\n this.reset();\n if (this._storage.onImageDeleted === this._wrappedOnImageDeleted) {\n this._storage.onImageDeleted = this._previousOnImageDeleted;\n }\n }\n\n public storeImage(id: number | undefined, imageData: Omit): number {\n const imageId = id ?? this._nextImageId++;\n\n const oldStorageId = this._kittyIdToStorageId.get(imageId);\n if (oldStorageId !== undefined) {\n this._storage.deleteImage(oldStorageId);\n this._kittyIdToStorageId.delete(imageId);\n this._storageIdToKittyId.delete(oldStorageId);\n }\n\n if (!this._images.has(imageId) && this._images.size >= KittyImageStorage._maxStoredImages) {\n this._evictUndisplayedImages();\n }\n\n this._images.set(imageId, {\n ...imageData,\n id: imageId\n });\n return imageId;\n }\n\n public addImage(kittyId: number, image: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer, zIndex: number): void {\n // Clean up stale reverse-mapping from a previous placement of the same\n // kitty image. The old shared-storage entry is kept (it may still be\n // visible on screen) but its reverse mapping is removed so that eviction\n // of the old entry won't incorrectly delete the kitty image data.\n const oldStorageId = this._kittyIdToStorageId.get(kittyId);\n if (oldStorageId !== undefined) {\n this._storageIdToKittyId.delete(oldStorageId);\n }\n this._addImageOpts.scrolling = scrolling;\n this._addImageOpts.layer = layer;\n this._addImageOpts.zIndex = zIndex;\n const storageId = this._storage.addImage(image, this._addImageOpts);\n this._kittyIdToStorageId.set(kittyId, storageId);\n this._storageIdToKittyId.set(storageId, kittyId);\n }\n\n public getImage(kittyId: number): IKittyImageData | undefined {\n return this._images.get(kittyId);\n }\n\n public deleteById(kittyId: number): void {\n this._images.delete(kittyId);\n const storageId = this._kittyIdToStorageId.get(kittyId);\n if (storageId !== undefined) {\n this._storage.deleteImage(storageId);\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n }\n }\n\n public deleteAll(): void {\n this._images.clear();\n for (const storageId of this._kittyIdToStorageId.values()) {\n this._storage.deleteImage(storageId);\n }\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public get images(): ReadonlyMap {\n return this._images;\n }\n\n public get kittyIdToStorageId(): ReadonlyMap {\n return this._kittyIdToStorageId;\n }\n\n public get lastImageId(): number {\n return this._nextImageId - 1;\n }\n\n private _evictUndisplayedImages(): void {\n for (const [kittyId] of this._images) {\n if (this._images.size <= KittyImageStorage._maxStoredImages / 2) {\n break;\n }\n if (!this._kittyIdToStorageId.has(kittyId)) {\n this._images.delete(kittyId);\n }\n }\n }\n}\n","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023, 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\n/**\n * wasm base64 decoder.\n */\nconst wasmDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#828e69684093b2c6:rdef-start:\"decode\"*/{s:1,t:0,d:'AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML'}/*inwasm#828e69684093b2c6:rdef-end:\"decode\"*/);\n// SIMD version (speedup ~1.4x, not covered by tests yet)\n/*\nconst wasmDecode = InWasm({\n name: 'decode',\n type: OutputType.INSTANCE,\n mode: OutputMode.SYNC,\n srctype: 'Clang-C',\n imports: {\n env: { memory: new WebAssembly.Memory({ initial: 1 }) }\n },\n exports: {\n dec: () => 0,\n end: () => 0\n },\n compile: {\n switches: ['-msimd128', '-Wl,-z,stack-size=0', '-Wl,--stack-first']\n },\n code: `\n #include \n typedef struct {\n unsigned int wp;\n unsigned int sp;\n unsigned int dp;\n unsigned int e_size;\n unsigned int dummy[4];\n unsigned char data[0];\n } State;\n\n unsigned int *D0 = (unsigned int *) ${P32.D0 * 4};\n unsigned int *D1 = (unsigned int *) ${P32.D1 * 4};\n unsigned int *D2 = (unsigned int *) ${P32.D2 * 4};\n unsigned int *D3 = (unsigned int *) ${P32.D3 * 4};\n State *state = (State *) ${P32.STATE * 4};\n\n #define packed_byte(x) wasm_i8x16_splat((char) x)\n #define packed_dword(x) wasm_i32x4_splat(x)\n #define masked(x, mask) wasm_v128_and(x, wasm_i32x4_splat(mask))\n\n __attribute__((noinline)) int dec() {\n unsigned int nsp = (state->wp - 1) & ~3;\n unsigned char *src = state->data + state->sp;\n unsigned char *end = state->data + nsp;\n unsigned char *dst = state->data + state->dp;\n unsigned int error = 0;\n\n v128_t err = wasm_i8x16_splat(0);\n unsigned char *end16 = state->data + (nsp & ~15);\n while (src < end16) {\n v128_t data = wasm_v128_load((v128_t *) src);\n\n // wasm-simd rewrite of http://0x80.pl/notesen/2016-01-17-sse-base64-decoding.html#vector-lookup-pshufb\n const v128_t higher_nibble = wasm_u32x4_shr(data, 4) & packed_byte(0x0f);\n const char linv = 1;\n const char hinv = 0;\n\n const v128_t lower_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n linv, linv, 0x2b, 0x30,\n 0x41, 0x50, 0x61, 0x70,\n linv, linv, linv, linv,\n linv, linv, linv, linv\n );\n const v128_t upper_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n hinv, hinv, 0x2b, 0x39,\n 0x4f, 0x5a, 0x6f, 0x7a,\n hinv, hinv, hinv, hinv,\n hinv, hinv, hinv, hinv\n );\n // the difference between the shift and lower bound\n const v128_t shift_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n 0x00, 0x00, 0x3e - 0x2b, 0x34 - 0x30,\n 0x00 - 0x41, 0x0f - 0x50, 0x1a - 0x61, 0x29 - 0x70,\n 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00\n );\n\n const v128_t upper_bound = wasm_i8x16_swizzle(upper_bound_LUT, higher_nibble);\n const v128_t lower_bound = wasm_i8x16_swizzle(lower_bound_LUT, higher_nibble);\n\n const v128_t below = wasm_i8x16_lt(data, lower_bound);\n const v128_t above = wasm_i8x16_gt(data, upper_bound);\n const v128_t eq_2f = wasm_i8x16_eq(data, packed_byte(0x2f));\n\n // in_range = not (below or above) or eq_2f\n // outside = not in_range = below or above and not eq_2f (from deMorgan law)\n const v128_t outside = wasm_v128_andnot(eq_2f, above | below);\n err = wasm_v128_or(err, outside);\n\n const v128_t shift = wasm_i8x16_swizzle(shift_LUT, higher_nibble);\n const v128_t t0 = wasm_i8x16_add(data, shift);\n v128_t v = wasm_i8x16_add(t0, wasm_v128_and(eq_2f, packed_byte(-3)));\n\n // pack bytes\n const v128_t ca = masked(v, 0x003f003f);\n const v128_t db = masked(v, 0x3f003f00);\n const v128_t t00 = wasm_v128_or(wasm_u32x4_shr(db, 8), wasm_i32x4_shl(ca, 6));\n v128_t res = wasm_v128_or(wasm_u32x4_shr(t00, 16), wasm_i32x4_shl(t00, 12));\n res = wasm_i8x16_swizzle(res, wasm_i8x16_const(2, 1, 0, 6, 5, 4, 10, 9, 8, 14, 13, 12, 16, 16, 16, 16));\n\n wasm_v128_store((v128_t *) dst, res);\n dst += 12;\n src += 16;\n }\n //if (wasm_i8x16_bitmask(err) != 0) return -1;\n if (wasm_v128_any_true(err)) return -1;\n\n // operate on 4-byte blocks\n while (src < end) {\n error |= *((unsigned int *) dst) = D0[src[0]] | D1[src[1]] | D2[src[2]] | D3[src[3]];\n dst += 3;\n src += 4;\n }\n if (error >> 24) return -1;\n state->sp = nsp;\n state->dp = dst - state->data;\n return 0;\n }\n\n int end() {\n int rem = state->wp - state->sp;\n if (rem > 4 && dec()) return -1;\n rem = state->wp - state->sp;\n if (rem < 2) return -1;\n\n unsigned char *src = state->data + state->sp;\n if (rem == 4) {\n if (src[3] == 61) rem--;\n if (src[2] == 61) rem--;\n }\n unsigned int accu = D0[src[0]] | D1[src[1]];\n int dp = 1;\n if (rem > 2) {\n accu |= D2[src[2]];\n dp++;\n if (rem == 4) {\n accu |= D3[src[3]];\n dp++;\n }\n }\n if (accu >> 24) return -1;\n *((unsigned int *) (state->data + state->dp)) = accu;\n state->dp += dp;\n return 0;\n }\n `\n});\n*/\n// base64 map\nconst MAP = new Uint8Array('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'\n .split('')\n .map(el => el.charCodeAt(0)));\n// init decoder maps in LE order\nconst D = new Uint32Array(1024);\nD.fill(0xFF000000);\nfor (let i = 0; i < MAP.length; ++i)\n D[MAP[i]] = i << 2;\nfor (let i = 0; i < MAP.length; ++i)\n D[256 + MAP[i]] = i >> 4 | ((i << 4) & 0xFF) << 8;\nfor (let i = 0; i < MAP.length; ++i)\n D[512 + MAP[i]] = (i >> 2) << 8 | ((i << 6) & 0xFF) << 16;\nfor (let i = 0; i < MAP.length; ++i)\n D[768 + MAP[i]] = i << 16;\nconst EMPTY = new Uint8Array(0);\n/**\n * base64 stream decoder.\n *\n * Features / assumptions:\n * - lazy chunkwise decoding\n * - errors out on any non base64 chars (no support for NL formatted base64)\n * - decodes in wasm\n * - inplace decoding to save memory\n * - supports a keepSize for lazy memory release\n */\nclass Base64Decoder {\n /**\n * @param keepSize Keep the wasm instance below this limit when calling `release()`.\n * @param maxBytes Max allowed bytes to allocate.\n * @param initialBytes Initial bytes to allocate.\n */\n constructor(keepSize, maxBytes, initialBytes) {\n this._inst = null;\n this._ended = true;\n this._bytes = 0;\n this.keepSize = keepSize !== null && keepSize !== void 0 ? keepSize : 1048576 /* Bytes.KEEP */;\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : 4294901760 /* Bytes.MAX */;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : 32768 /* Bytes.INITIAL */;\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw new Error('invalid byte settings');\n }\n }\n /**\n * Currently decoded bytes (borrowed).\n * Must be accessed before calling `release` or `init`.\n */\n get data8() {\n return this._inst ? this._d.subarray(0, this._m32[1282 /* P32.STATE_DP */]) : EMPTY;\n }\n /**\n * Release memory conditionally based on `keepSize`.\n * If memory gets released, also the wasm instance will be freed and recreated on next `init`,\n * otherwise the instance will be reused.\n */\n release() {\n if (!this._inst)\n return;\n if (this._bytes > this.keepSize) {\n this._inst = this._m32 = this._d = this._mem = null;\n }\n else {\n this._m32[1280 /* P32.STATE_WP */] = 0;\n this._m32[1281 /* P32.STATE_SP */] = 0;\n this._m32[1282 /* P32.STATE_DP */] = 0;\n }\n }\n /**\n * Initializes the decoder for new base64 data.\n * Must be called before doing any decoding attempts.\n * The method will either spawn a new wasm instance or grow\n * the needed memory of an existing instance.\n * @param maxBytes Max allowed bytes to allocate (overwrites ctor value).\n * @param initialBytes Initial bytes to allocate (overwrites ctor value).\n */\n init(maxBytes, initialBytes) {\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : this.maxBytes;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : Math.min(this._bytes, this.maxBytes);\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw Error('invalid byte settings');\n }\n let m = this._m32;\n const bytes = this._bytes + 5152 /* Bytes._DATA_OFFSET */;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmDecode({ env: { memory: this._mem } });\n m = new Uint32Array(this._mem.buffer, 0);\n m.set(D, 256 /* P32.D0 */);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n m = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n m[1280 /* P32.STATE_WP */] = 0;\n m[1281 /* P32.STATE_SP */] = 0;\n m[1282 /* P32.STATE_DP */] = 0;\n this._m32 = m;\n this._ended = false;\n }\n /**\n * Realloc memory. Realloc only happens, if the requested\n * size doesn't fit in the current memory.\n * The new size will be capped by `maxBytes`.\n * @param requested Bytes to be stored.\n */\n _realloc(requested) {\n const needed = this._m32[1280 /* P32.STATE_WP */] + requested;\n if (this._bytes < needed) {\n if (needed > this.maxBytes) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n let newSize = this._bytes;\n while ((newSize *= 2) < needed) { }\n newSize = Math.min(newSize, this.maxBytes);\n if (newSize < needed) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n if (newSize + 5152 /* Bytes._DATA_OFFSET */ > this._mem.buffer.byteLength) {\n const addPages = Math.ceil((newSize + 5152 /* Bytes._DATA_OFFSET */ - this._mem.buffer.byteLength) / 65536);\n this._mem.grow(addPages);\n this._m32 = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n this._bytes = newSize;\n }\n return 0 /* DecodeStatus.OK */;\n }\n /**\n * Put bytes in `data` into the decoder.\n * Additionally decodes the payload, if it reached 2^17 bytes.\n * The return value indicates the type of issue.\n * @param data Bytes to be loaded.\n */\n put(data) {\n if (!this._inst || this._ended) {\n return -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n if (this._realloc(data.length)) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n const m = this._m32;\n this._d.set(data, m[1280 /* P32.STATE_WP */]);\n m[1280 /* P32.STATE_WP */] += data.length;\n // max chunk in input handler is 2^17, try to run in \"tandem mode\"\n return m[1280 /* P32.STATE_WP */] - m[1281 /* P32.STATE_SP */] >= 131072\n ? this._inst.exports.dec()\n : 0 /* DecodeStatus.OK */;\n }\n /**\n * End the current decoding.\n * Also decodes leftover payload from previous put calls.\n */\n end() {\n this._ended = true;\n return this._inst\n ? this._inst.exports.end()\n : -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n /**\n * Bytes loaded into the decoder.\n */\n get loadedBytes() {\n return this._inst\n ? this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n /**\n * Free bytes to feed to the decoder.\n */\n get freeBytes() {\n return this._inst\n ? this.maxBytes - this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n}\nexports.default = Base64Decoder;\n//# sourceMappingURL=Base64Decoder.wasm.js.map","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\nconst wasmQoiDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#459f9e1bfb80b1a8:rdef-start:\"qoi_decode\"*/{s:1,t:0,d:'AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=='}/*inwasm#459f9e1bfb80b1a8:rdef-end:\"qoi_decode\"*/);\nclass QoiDecoder {\n constructor(keepSize) {\n this.keepSize = keepSize;\n this.width = 0;\n this.height = 0;\n }\n decode(d) {\n this.width = d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7];\n this.height = d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11];\n const pixels = this.width * this.height;\n const ib = pixels * 4;\n const dl = d.length;\n /**\n * byte/offset calculation:\n * To save some memory we dont reserve full memory for decoded + encoded,\n * but place encoded at the end of decoded plus 50% security distance\n * to avoid reads before writes positions:\n *\n * encoded < decoded (good compression)\n * enc ####################\n * dec #######################################\n * ^ ^\n * DST_P CHUNK_P\n *\n * encoded > decoded (degenerated compression, should not happen)\n * enc ##############################\n * dec ####################\n * ^ ^\n * DST_P CHUNK_P\n *\n * There is still a chance for overlapping r/w positions in case the compressed\n * data has very different pixel progression, yet the 50% security distance\n * should deal with that, as QOI will bloat data by 25% at max (RGB -> OP byte + RGB).\n * Since we always assume RGBA at decoding stage, the possible bloat reduces to 20% at max.\n */\n const bytes = Math.max(ib, dl) + (Math.min(ib, dl) >> 1) + 4096;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmQoiDecode({ env: { memory: this._mem } });\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n this._d = null;\n }\n if (!this._d) {\n this._d = new Uint8Array(this._mem.buffer);\n }\n // put src data at the end of memory, also align to 256\n const chunkP = (this._mem.buffer.byteLength - dl) & ~0xFF;\n this._d.set(d, chunkP);\n this._inst.exports.dec(chunkP, dl, pixels);\n return this._d.subarray(1024 /* P8.DST_P */, 1024 /* P8.DST_P */ + ib);\n }\n release() {\n if (!this._inst)\n return;\n if (this._mem.buffer.byteLength > this.keepSize) {\n this._inst = this._d = this._mem = null;\n }\n }\n}\nexports.default = QoiDecoder;\n//# sourceMappingURL=QoiDecoder.wasm.js.map","\"use strict\";\n/**\n * Copyright (c) 2022, 2026 Joerg Breitbart\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.InWasm = InWasm;\nlet z = (s) => {\n if (Uint8Array.fromBase64)\n return Uint8Array.fromBase64(s);\n if (typeof Buffer !== 'undefined')\n return Buffer.from(s, 'base64');\n const b = atob(s);\n const r = new Uint8Array(b.length);\n for (let i = 0; i < r.length; ++i)\n r[i] = b.charCodeAt(i);\n return r;\n};\nfunction InWasm(def) {\n if (def.d) {\n const { t, s, d } = def;\n let b;\n let m;\n const W = WebAssembly;\n if (t === 0 /* OutputType.INSTANCE */) {\n if (s)\n return (e) => new W.Instance(m || (m = new W.Module(b || (b = z(d)))), e);\n return (e) => m\n ? W.instantiate(m, e)\n : W.instantiate(b || (b = z(d)), e).then(r => (m = r.module) && r.instance);\n }\n if (t === 1 /* OutputType.MODULE */) {\n if (s)\n return () => m || (m = new W.Module(b || (b = z(d))));\n return () => m\n ? Promise.resolve(m)\n : W.compile(b || (b = z(d))).then(r => m = r);\n }\n if (s)\n return () => b || (b = z(d));\n return () => Promise.resolve(b || (b = z(d)));\n }\n if (typeof _wasmCtx === 'undefined')\n throw new Error('must run \"inwasm\"');\n _wasmCtx.add(def);\n}\n//# sourceMappingURL=index.js.map","// The module cache\nvar __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\t// Check if module is in cache\n\tvar cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tvar module = __webpack_module_cache__[moduleId] = {\n\t\t// no module.id needed\n\t\t// no module.loaded needed\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ITerminalAddon, IDisposable } from '@xterm/xterm';\nimport type { ImageAddon as IImageApi } from '@xterm/addon-image';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { IIPHandler } from './IIPHandler';\nimport { ImageRenderer } from './ImageRenderer';\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';\nimport { KittyImageStorage } from './kitty/KittyImageStorage';\nimport { SixelHandler } from './SixelHandler';\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';\n\n\n/**\n * Document VT features provided by this addon.\n *\n * @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL \"SIXEL Graphics\" \"DCS Ps ; Ps ; Ps ; q Pt ST\" \"Draw SIXEL image.\"\n *\n * Sixel support is provided by the addon @xterm/addon-image with these limitations:\n * - immediate coloring (no shared palette, allows high color settings of `img2sixel`)\n * - max. palette size of 4096 colors\n * - max. pixel width of 16K\n * - max. 25 MB per sixel sequence\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] OSC 1337 \"iTerm2 Commands\" \"OSC 1337 ; Pt BEL\" \"Custom iTerm2 commands.\"\n *\n * Only the inline image protocol (IIP) is supported by the addon @xterm/addon-image with\n * the following limitations:\n * - sequence:\n * - format: `OSC 1337 ; File=inline=1 ; size= ; ... : BEL`\n * - size param must be set and payload may not exceed CEIL(size * 4 / 3)\n * - strict base64 handling as of RFC4648 §4 (standard alphabet, optional padding,\n * no separator bytes allowed)\n * - supported params: size, name, width, height, preserveAspectRatio\n * - image formats: PNG, JPEG and GIF\n * - no animation support (renders first image of a GIF)\n * - no multipart support\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image)\n * and [iTerm2 IIP docs](https://iterm2.com/documentation-images.html) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] APC KITTY_GRAPHICS \"Kitty Graphics\" \"APC G Pt ST\" \"Kitty Graphics Protocol.\"\n *\n * Kitty graphics support is provided by the addon @xterm/addon-image.\n * Note that while basic image output already works, this is still work in progress.\n */\n\n// default values of addon ctor options\nconst DEFAULT_OPTIONS: IImageAddonOptions = {\n enableSizeReports: true,\n pixelLimit: 16777216, // limit to 4096 * 4096 pixels\n sixelSupport: true,\n sixelScrolling: true,\n sixelPaletteLimit: 4096,\n sixelSizeLimit: 33554432,\n storageLimit: 128,\n showPlaceholder: true,\n iipSupport: true,\n iipSizeLimit: 33554432,\n kittySupport: true,\n kittySizeLimit: 33554432\n};\n\n// max palette size supported by the sixel lib (compile time setting)\nconst MAX_SIXEL_PALETTE_SIZE = 4096;\n\n// definitions for _xtermGraphicsAttributes sequence\nconst enum GaItem {\n COLORS = 1,\n SIXEL_GEO = 2,\n REGIS_GEO = 3\n}\nconst enum GaAction {\n READ = 1,\n SET_DEFAULT = 2,\n SET = 3,\n READ_MAX = 4\n}\nconst enum GaStatus {\n SUCCESS = 0,\n ITEM_ERROR = 1,\n ACTION_ERROR = 2,\n FAILURE = 3\n}\n\n\nexport class ImageAddon implements ITerminalAddon, IImageApi {\n private _opts: IImageAddonOptions;\n private _defaultOpts: IImageAddonOptions;\n private _storage: ImageStorage | undefined;\n private _renderer: ImageRenderer | undefined;\n private _disposables: IDisposable[] = [];\n private _terminal: ITerminalExt | undefined;\n private _handlers: Map = new Map();\n private readonly _onImageAdded = new Emitter();\n public readonly onImageAdded: IEvent = this._onImageAdded.event;\n\n constructor(opts?: Partial) {\n this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);\n this._defaultOpts = Object.assign({}, DEFAULT_OPTIONS, opts);\n }\n\n public dispose(): void {\n for (const obj of this._disposables) {\n obj.dispose();\n }\n this._disposables.length = 0;\n this._handlers.clear();\n this._onImageAdded.dispose();\n }\n\n private _disposeLater(...args: IDisposable[]): void {\n for (const obj of args) {\n this._disposables.push(obj);\n }\n }\n\n public activate(terminal: ITerminalExt): void {\n this._terminal = terminal;\n\n // internal data structures\n this._renderer = new ImageRenderer(terminal);\n this._storage = new ImageStorage(terminal, this._renderer, this._opts);\n this._storage.onImageAdded = () => this._onImageAdded.fire();\n\n // enable size reports\n if (this._opts.enableSizeReports) {\n const windowOps = terminal.options.windowOptions ?? {};\n windowOps.getWinSizePixels = true;\n windowOps.getCellSizePixels = true;\n windowOps.getWinSizeChars = true;\n terminal.options.windowOptions = windowOps;\n }\n\n this._disposeLater(\n this._renderer,\n this._storage,\n\n // DECSET/DECRST/DA1/XTSMGRAPHICS handlers\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'h' }, params => this._decset(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'l' }, params => this._decrst(params)),\n terminal.parser.registerCsiHandler({ final: 'c' }, params => this._da1(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'S' }, params => this._xtermGraphicsAttributes(params)),\n\n // render hook\n terminal.onRender(range => this._storage?.render(range)),\n\n /**\n * reset handlers covered:\n * - DECSTR\n * - RIS\n * - Terminal.reset()\n */\n terminal.parser.registerCsiHandler({ intermediates: '!', final: 'p' }, () => this.reset()),\n terminal.parser.registerEscHandler({ final: 'c' }, () => this.reset()),\n terminal._core._inputHandler.onRequestReset(() => this.reset()),\n\n // wipe canvas and delete alternate images on buffer switch\n terminal.buffer.onBufferChange(() => this._storage?.wipeAlternate()),\n\n // extend images to the right on resize\n terminal.onResize(metrics => this._storage?.viewportResize(metrics))\n );\n\n // SIXEL handler\n if (this._opts.sixelSupport) {\n const sixelStorage = new SixelImageStorage(this._storage!, this._opts, this._renderer!, terminal);\n const sixelHandler = new SixelHandler(this._opts, sixelStorage, terminal);\n this._handlers.set('sixel', sixelHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler)\n );\n }\n\n // iTerm IIP handler\n if (this._opts.iipSupport) {\n const iipStorage = new IIPImageStorage(this._storage!);\n const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, terminal);\n this._handlers.set('iip', iipHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)\n );\n }\n\n // Kitty graphics handler\n if (this._opts.kittySupport) {\n const kittyStorage = new KittyImageStorage(this._storage!);\n const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, kittyStorage, terminal);\n this._handlers.set('kitty', kittyHandler);\n this._disposeLater(\n kittyStorage,\n kittyHandler,\n terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)\n );\n }\n }\n\n // Note: storageLimit is skipped here to not intoduce a surprising side effect.\n public reset(): boolean {\n // reset options customizable by sequences to defaults\n this._opts.sixelScrolling = this._defaultOpts.sixelScrolling;\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n // also clear image storage\n this._storage?.reset();\n // reset protocol handlers\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return false;\n }\n\n public get storageLimit(): number {\n return this._storage?.getLimit() || -1;\n }\n\n public set storageLimit(limit: number) {\n this._storage?.setLimit(limit);\n this._opts.storageLimit = limit;\n }\n\n public get storageUsage(): number {\n if (this._storage) {\n return this._storage.getUsage();\n }\n return -1;\n }\n\n public get showPlaceholder(): boolean {\n return this._opts.showPlaceholder;\n }\n\n public set showPlaceholder(value: boolean) {\n this._opts.showPlaceholder = value;\n this._renderer?.showPlaceholder(value);\n }\n\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.getImageAtBufferCell(x, y);\n }\n\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.extractTileAtBufferCell(x, y);\n }\n\n private _report(s: string): void {\n this._terminal?._core.input(s, false);\n }\n\n private _decset(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = false;\n break;\n }\n }\n return false;\n }\n\n private _decrst(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = true;\n break;\n }\n }\n return false;\n }\n\n // overload DA to return something more appropriate\n private _da1(params: (number | number[])[]): boolean {\n if (params[0]) {\n return true;\n }\n // reported features:\n // 62 - VT220\n // 4 - SIXEL support\n // 9 - charsets\n // 22 - ANSI colors\n if (this._opts.sixelSupport) {\n this._report(`\\x1b[?62;4;9;22c`);\n return true;\n }\n return false;\n }\n\n /**\n * Implementation of xterm's graphics attribute sequence.\n *\n * Supported features:\n * - read/change palette limits (max 4096 by sixel lib)\n * - read SIXEL canvas geometry (reports current window canvas or\n * squared pixelLimit if canvas > pixel limit)\n *\n * Everything else is deactivated.\n */\n private _xtermGraphicsAttributes(params: (number | number[])[]): boolean {\n if (params.length < 2) {\n return true;\n }\n if (params[0] === GaItem.COLORS) {\n switch (params[1]) {\n case GaAction.READ:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n return true;\n case GaAction.SET_DEFAULT:\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n // also reset protocol handlers for now\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return true;\n case GaAction.SET:\n if (params.length > 2 && !(params[2] instanceof Array) && params[2] <= MAX_SIXEL_PALETTE_SIZE) {\n this._opts.sixelPaletteLimit = params[2];\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n } else {\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n }\n return true;\n case GaAction.READ_MAX:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${MAX_SIXEL_PALETTE_SIZE}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n if (params[0] === GaItem.SIXEL_GEO) {\n switch (params[1]) {\n // we only implement read and read_max here\n case GaAction.READ:\n let width = this._renderer?.dimensions?.css.canvas.width;\n let height = this._renderer?.dimensions?.css.canvas.height;\n if (!width || !height) {\n // for some reason we have no working image renderer\n // --> fallback to default cell size\n const cellSize = CELL_SIZE_DEFAULT;\n width = (this._terminal?.cols || 80) * cellSize.width;\n height = (this._terminal?.rows || 24) * cellSize.height;\n }\n if (width * height < this._opts.pixelLimit) {\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${width.toFixed(0)};${height.toFixed(0)}S`);\n } else {\n // if we overflow pixelLimit report that squared instead\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n }\n return true;\n case GaAction.READ_MAX:\n // read_max returns pixelLimit as square area\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n // exit with error on ReGIS or any other requests\n this._report(`\\x1b[?${params[0]};${GaStatus.ITEM_ERROR}S`);\n return true;\n }\n}\n"],"names":["root","factory","exports","module","define","amd","globalThis","red","n","green","blue","toRGBA8888","r","g","b","a","clamp","low","high","value","Math","max","min","h2c","t1","t2","c","normalizeRGB","round","Object","defineProperty","DEFAULT_FOREGROUND","DEFAULT_BACKGROUND","PALETTE_ANSI_256","PALETTE_VT340_GREY","PALETTE_VT340_COLOR","normalizeHLS","nearestColorIndex","fromRGBA8888","alpha","BIG_ENDIAN","Uint8Array","Uint32Array","buffer","console","warn","color","palette","Number","MAX_SAFE_INTEGER","idx","i","length","dr","dg","db","d","h","l","s","v","HLStoRGB","p","push","decodeAsync","decode","Decoder","DecoderAsync","Colors_1","__webpack_require__","wasm_1","WASM_BYTES","Buffer","from","bytestring","atob","result","charCodeAt","decodeBase64","LIMITS","BYTES","WASM_MODULE","NULL_CANVAS","CallbackProxy","constructor","this","bandHandler","width","modeHandler","mode","handle_band","mode_parsed","DEFAULT_OPTIONS","memoryLimit","sixelColor","fillColor","paletteLimit","PALETTE_SIZE","truncate","opts","cbProxy","importObj","env","bind","WebAssembly","instantiate","then","inst","instance","_instance","_cbProxy","_PIXEL_OFFSET","MAX_WIDTH","_canvas","_bandWidths","_maxWidth","_minWidth","_lastOffset","_currentHeight","_opts","assign","Error","_handle_band","_initCanvas","Module","Instance","_wasm","_chunk","memory","get_chunk_address","CHUNK_SIZE","_states","get_state_address","_palette","get_palette_address","set","_pSrc","get_p0_address","init","_fillColor","_truncate","_rasterWidth","_rasterHeight","_width","_height","_level","_mode","_paletteLimit","pixels","height","release","_realloc","offset","additionalPixels","newCanvas","ceil","adv","remaining","subarray","current_width","current_height","memoryUsage","byteLength","properties","level","memUsage","rasterAttributes","numerator","denominator","data","start","end","decodeString","j","data32","currentWidth","fill","escape","final","finalOffset","bw","currentHeight","data8","Uint8ClampedArray","dec","async","Lifecycle_1","EventUtils","_listeners","_disposed","event","_event","listener","thisArgs","disposables","toDisposable","entry","fn","slice","indexOf","splice","Array","isArray","add","fire","call","listeners","len","dispose","forward","to","e","map","undefined","any","events","store","DisposableStore","runAndSubscribe","handler","initial","arg","_disposables","Set","_isDisposed","isDisposed","o","clear","Disposable","_store","_register","None","freeze","_value","ImageRenderer_1","ImageStorage_1","Base64Decoder_wasm_1","__importDefault","QoiDecoder_wasm_1","IIPHeaderParser_1","IIPMetrics_1","DEFAULT_HEADER","type","name","size","preserveAspectRatio","inline","_renderer","_storage","_coreTerminal","_aborted","_hp","HeaderParser","_header","_isMultipart","_abortMulti","maxEncodedBytes","iipSizeLimit","initialBytes","_dec","default","_qoiDec","reset","put","state","dataPos","parse","fields","success","seqType","w","CELL_SIZE_DEFAULT","dimensions","css","canvas","cols","rows","scale","_c","_a","_core","_coreBrowserService","dpr","report","toFixed","input","cond","blob","metrics","UNSUPPORTED_TYPE","imageType","mime","pixelLimit","_resize","floor","ImageData","byteOffset","ImageRenderer","createCanvas","_b","getContext","putImageData","addImage","Blob","createImageBitmap","resizeWidth","resizeHeight","bm","catch","cw","cell","ch","_d","rw","_dim","rh","wf","hf","f","total","cdim","endsWith","parseInt","toStr","String","fromCharCode","toInt","toSize","match","DECODERS","toString","bs","TextDecoder","FILE_MARKER","MULTIPARTFILE_MARKER","FILEPART_MARKER","FILEEND_MARKER","REPORTCELLSIZE_MARKER","MAX_FIELDCHARS","_buffer","_position","_key","k","_storeValue","pos","_storeKey","_addImageOpts","scrolling","layer","zIndex","cursorPos","img","d32","blockLength","jpgSize","dim","limit","_layers","get","localDocument","document","createElement","createImageData","ctx","imgData","Promise","resolve","_terminal","super","Map","_optionsRefresh","MutableDisposable","_oldOpen","open","parent","_open","screenElement","optionsService","onOptionChange","option","rescaleCanvas","_renderService","refreshRows","removeLayerFromDom","_oldSetRenderer","setRenderer","_placeholderBitmap","close","_placeholder","showPlaceholder","cellSize","_createPlaceHolder","clearLines","y","clearRect","_e","clearAll","draw","imgSpec","tileId","col","row","count","_rescaleImage","actual","sx","sy","dx","dy","finalWidth","finalHeight","drawImage","extractTile","drawPlaceholder","values","spec","actualCellSize","originalWidth","originalHeight","origCellSize","orig","renderer","key","keys","insertLayerToDom","has","classList","style","isolation","insertBefore","firstChild","appendChild","remove","delete","hasLayer","bWidth","blueprint","black","white","shift","x","screen","ctx2","bitmap","window","ExtendedAttrsImage","ext","_urlId","_ext","underlineStyle","underlineColor","underlineVariantOffset","val","urlId","imageId","clone","isEmpty","EMPTY_ATTRS","_images","_lastId","_lowestId","_fullyCleared","_needsFullClear","_pixelLimit","setLimit","storageLimit","error","message","getLimit","_viewportMetrics","marker","RangeError","_evictOldest","getUsage","_getStoredPixels","storedPixels","_delImg","id","ImageBitmap","onImageDeleted","wipeAlternate","zero","entries","bufferType","deleteImage","termCols","termRows","originX","originY","tileCount","_inputHandler","_dirtyRowTracker","markDirty","line","lines","ybase","_writeToCell","lineFeed","endMarker","registerMarker","onDispose","active","_evictOnAlternate","onImageAdded","render","range","hasTopImages","hasBottomImages","drawCalls","placeholderCalls","ydisp","getBg","_extendedAttrs","startTile","startCol","sort","viewportResize","oldCol","tilesPerRow","hasData","rightCol","_data","lastTile","expandCol","getImageAtBufferCell","extractTileAtBufferCell","room","used","current","old","oldSpec","imgId","Decoder_1","DEFAULT_PALETTE","convertLe","_size","sixelPaletteLimit","hook","params","attr","colors","bg","isInverse","isFgDefault","foreground","rgba","isFgRGB","t","toColorRGB","getFgColor","ansi","isBgDefault","background","isBgRGB","getBgColor","extractActiveBg","_curAttrData","_themeService","sixelSizeLimit","unhook","advanceCursor","sixelScrolling","KittyGraphicsTypes_1","_kittyStorage","_decodeError","_activeDecoder","_inControlData","_controlData","_controlLength","_encodedSizeLimit","_totalEncodedSize","_parsedCommand","_pendingTransmissions","_maxEncodedBytes","kittySizeLimit","_initialEncodedBytes","_cleanupAllPending","_removePendingEntry","_lastPendingKey","pending","decoder","controlEnd","copyLength","parseKittyCommand","_parseControlDataString","imageNumber","_sendResponse","quiet","action","payloadStart","_streamPayload","pendingKey","previousEncodedSize","totalEncodedSize","decoderToRelease","_handleNoPayloadCommand","cmd","_handleDelete","isMoreComing","more","decodeError","finalCmd","imageBytes","_handleCommandWithBytesAndCmd","str","fromCodePoint","_handlePlacement","bytes","_handleTransmit","transmission","_handleTransmitDisplay","_handleQuery","image","getImage","_displayImage","placementId","storeImage","format","compression","_f","lastImageId","expectedBytes","deleteSelector","deleteAll","deleteById","isOk","response","coreService","triggerDataEvent","_decodeAndDisplay","_createBitmap","cropX","cropY","cropW","sourceWidth","cropH","sourceHeight","maxCropW","maxCropH","finalCropW","finalCropH","cropped","imgCols","imgRows","columns","savedX","savedY","savedYbase","scaled","xOffset","yOffset","canvasW","canvasH","offsetCanvas","offsetCtx","offsetBitmap","_g","cursorMovement","scrolled","arrayBuffer","_decompressZlib","url","URL","createObjectURL","Image","reject","addEventListener","revokeObjectURL","src","pixelCount","src32","dst32","alignedPixels","srcOffset","dstOffset","b0","b1","b2","srcByte","dstByte","compressed","_decompress","ds","DecompressionStream","writer","writable","getWriter","write","chunks","reader","readable","getReader","done","read","totalLength","reduce","sum","chunk","images","_kittyIdToStorageId","kittyIdToStorageId","pendingTransmissions","parts","split","part","eqIdx","substring","numValue","KittyImageStorage","_nextImageId","_storageIdToKittyId","_handleStorageImageDeleted","storageId","kittyId","_previousOnImageDeleted","_wrappedOnImageDeleted","imageData","oldStorageId","_maxStoredImages","_evictUndisplayedImages","wasmDecode","InWasm","MAP","el","D","EMPTY","keepSize","maxBytes","_inst","_ended","_bytes","_m32","_mem","m","grow","Memory","requested","needed","newSize","addPages","loadedBytes","freeBytes","wasmQoiDecode","ib","dl","chunkP","def","W","z","compile","_wasmCtx","fromBase64","__webpack_module_cache__","moduleId","cachedModule","__webpack_modules__","Event_1","IIPHandler_1","KittyGraphicsHandler_1","KittyImageStorage_1","SixelHandler_1","SixelImageStorage_1","IIPImageStorage_1","enableSizeReports","sixelSupport","iipSupport","kittySupport","_handlers","_onImageAdded","Emitter","_defaultOpts","obj","_disposeLater","args","activate","terminal","ImageStorage","windowOps","options","windowOptions","getWinSizePixels","getCellSizePixels","getWinSizeChars","parser","registerCsiHandler","prefix","_decset","_decrst","_da1","_xtermGraphicsAttributes","onRender","intermediates","registerEscHandler","onRequestReset","onBufferChange","onResize","sixelStorage","SixelImageStorage","sixelHandler","SixelHandler","_parser","registerDcsHandler","iipStorage","IIPImageStorage","iipHandler","IIPHandler","registerOscHandler","kittyStorage","kittyHandler","KittyGraphicsHandler","registerApcHandler","storageUsage","_report","sqrt"],"sourceRoot":""} \ No newline at end of file -+{"version":3,"file":"addon-image.js","mappings":";CAAA,SAAAA,EAAAC,GACA,iBAAAC,SAAA,iBAAAC,OACAA,OAAAD,QAAAD,IACA,mBAAAG,QAAAA,OAAAC,IACAD,OAAA,GAAAH,GACA,iBAAAC,QACAA,QAAA,WAAAD,IAEAD,EAAA,WAAAC,GACC,CATD,CASCK,WAAA,uCCKD,SAAAC,EAAAC,GACA,WAAAA,CACA,CAEA,SAAAC,EAAAD,GACA,OAAAA,IAAA,KACA,CAEA,SAAAE,EAAAF,GACA,OAAAA,IAAA,MACA,CASA,SAAAG,EAAAC,EAAAC,EAAAC,EAAAC,EAAA,KACA,YAAAA,IAAA,QAAAD,IAAA,QAAAD,IAAA,MAAAD,KAAA,CACA,CAqCA,SAAAI,EAAAC,EAAAC,EAAAC,GACA,OAAAC,KAAAC,IAAAJ,EAAAG,KAAAE,IAAAH,EAAAD,GACA,CACA,SAAAK,EAAAC,EAAAC,EAAAC,GAKA,OAJAA,EAAA,IACAA,GAAA,GACAA,EAAA,IACAA,GAAA,GACA,EAAAA,EAAA,EACAD,EAAA,GAAAD,EAAAC,GAAAC,EACA,EAAAA,EAAA,EACAF,EACA,EAAAE,EAAA,EACAD,GAAAD,EAAAC,IAAA,IAAAC,GACAD,CACA,CAaA,SAAAE,EAAAf,EAAAC,EAAAC,GACA,kBAAAM,KAAAQ,MAAAd,EAAA,aAAAM,KAAAQ,MAAAf,EAAA,YAAAO,KAAAQ,MAAAhB,EAAA,aACA,CAjGAiB,OAAAC,eAAA5B,EAAA,cAA+CiB,OAAA,IAC/CjB,EAAA6B,mBAA6B7B,EAAA8B,mBAA6B9B,EAAA+B,iBAA2B/B,EAAAgC,mBAA6BhC,EAAAiC,oBAA8BjC,EAAAkC,aAAuBlC,EAAAyB,aAAuBzB,EAAAmC,kBAA4BnC,EAAAoC,aAAuBpC,EAAAS,WAAqBT,EAAAqC,MAAgBrC,EAAAQ,KAAeR,EAAAO,MAAgBP,EAAAK,IAAcL,EAAAsC,gBAAkB,EAGrVtC,EAAAsC,WAAkB,UAAAC,WAAA,IAAAC,YAAA,cAAAC,QAAA,GAClBzC,EAAAsC,YACAI,QAAAC,KAAA,+EAMA3C,EAAAK,IAAWA,EAIXL,EAAAO,MAAaA,EAIbP,EAAAQ,KAAYA,EAIZR,EAAAqC,MAHA,SAAA/B,GACA,OAAAA,IAAA,MACA,EAQAN,EAAAS,WAAkBA,EAOlBT,EAAAoC,aAHA,SAAAQ,GACA,WAAAA,EAAAA,GAAA,MAAAA,GAAA,OAAAA,IAAA,GACA,EA2BA5C,EAAAmC,kBArBA,SAAAS,EAAAC,GACA,MAAAnC,EAAAL,EAAAuC,GACAjC,EAAAJ,EAAAqC,GACAhC,EAAAJ,EAAAoC,GACA,IAAAxB,EAAA0B,OAAAC,iBACAC,GAAA,EAEA,QAAAC,EAAA,EAAoBA,EAAAJ,EAAAK,SAAoBD,EAAA,CACxC,MAAAE,EAAAzC,EAAAmC,EAAAI,GAAA,GACAG,EAAAzC,EAAAkC,EAAAI,GAAA,GACAI,EAAAzC,EAAAiC,EAAAI,GAAA,GACAK,EAAAH,EAAAA,EAAAC,EAAAA,EAAAC,EAAAA,EACA,IAAAC,EACA,OAAAL,EACAK,EAAAlC,IACAA,EAAAkC,EACAN,EAAAC,EAEA,CACA,OAAAD,CACA,EAmCAhD,EAAAyB,aAAoBA,EAQpBzB,EAAAkC,aAJA,SAAAqB,EAAAC,EAAAC,GAEA,OArBA,SAAAF,EAAAC,EAAAC,GACA,IAAAA,EAAA,CACA,MAAAC,EAAAxC,KAAAQ,MAAA,IAAA8B,GACA,OAAA/C,EAAAiD,EAAAA,EAAAA,EACA,CACA,MAAApC,EAAAkC,EAAA,GAAAA,GAAA,EAAAC,GAAAD,EAAAC,EAAAD,EAAAC,EACAlC,EAAA,EAAAiC,EAAAlC,EACA,OAAAb,EAAAK,EAAA,MAAAI,KAAAQ,MAAA,IAAAL,EAAAC,EAAAC,EAAAgC,EAAA,OAAAzC,EAAA,MAAAI,KAAAQ,MAAA,IAAAL,EAAAC,EAAAC,EAAAgC,KAAAzC,EAAA,MAAAI,KAAAQ,MAAA,IAAAL,EAAAC,EAAAC,EAAAgC,EAAA,OACA,CAaAI,EAAAJ,EAAA,SAAAC,EAAA,IAAAC,EAAA,IACA,EA+BAzD,EAAAiC,oBAA2B,IAAAO,YAAA,CAC3Bf,EAAA,OACAA,EAAA,UACAA,EAAA,UACAA,EAAA,UACAA,EAAA,UACAA,EAAA,UACAA,EAAA,UACAA,EAAA,UACAA,EAAA,UACAA,EAAA,UACAA,EAAA,UACAA,EAAA,UACAA,EAAA,UACAA,EAAA,UACAA,EAAA,UACAA,EAAA,YA0BAzB,EAAAgC,mBAA0B,IAAAQ,YAAA,CAC1Bf,EAAA,OACAA,EAAA,UACAA,EAAA,UACAA,EAAA,UACAA,EAAA,OACAA,EAAA,UACAA,EAAA,UACAA,EAAA,UACAA,EAAA,OACAA,EAAA,UACAA,EAAA,UACAA,EAAA,UACAA,EAAA,OACAA,EAAA,UACAA,EAAA,UACAA,EAAA,YAOAzB,EAAA+B,iBAAwB,MAExB,MAAA6B,EAAA,CACAnD,EAAA,OACAA,EAAA,SACAA,EAAA,SACAA,EAAA,WACAA,EAAA,SACAA,EAAA,WACAA,EAAA,WACAA,EAAA,aACAA,EAAA,aACAA,EAAA,SACAA,EAAA,SACAA,EAAA,WACAA,EAAA,WACAA,EAAA,WACAA,EAAA,WACAA,EAAA,cAGA6C,EAAA,uBACA,QAAA5C,EAAA,EAAoBA,EAAA,IAAOA,EAC3B,QAAAC,EAAA,EAAwBA,EAAA,IAAOA,EAC/B,QAAAC,EAAA,EAA4BA,EAAA,IAAOA,EACnCgD,EAAAC,KAAApD,EAAA6C,EAAA5C,GAAA4C,EAAA3C,GAAA2C,EAAA1C,KAKA,QAAA8C,EAAA,EAAoBA,GAAA,IAAUA,GAAA,GAC9BE,EAAAC,KAAApD,EAAAiD,EAAAA,EAAAA,IAEA,WAAAlB,YAAAoB,EACC,EAlCuB,GA0CxB5D,EAAA8B,mBAA0BrB,EAAA,WAC1BT,EAAA6B,mBAA0BpB,EAAA,6BCpP1BkB,OAAAC,eAAA5B,EAAA,cAA+CiB,OAAA,IAC/CjB,EAAA8D,YAAsB9D,EAAA+D,OAAiB/D,EAAAgE,QAAkBhE,EAAAiE,kBAAoB,EAC7E,MAAAC,EAAiBC,EAAQ,KACzBC,EAAeD,EAAQ,KAavBE,EAXA,SAAAZ,GACA,uBAAAa,OACA,OAAAA,OAAAC,KAAAd,EAAA,UAEA,MAAAe,EAAAC,KAAAhB,GACAiB,EAAA,IAAAnC,WAAAiC,EAAAtB,QACA,QAAAD,EAAA,EAAoBA,EAAAyB,EAAAxB,SAAmBD,EACvCyB,EAAAzB,GAAAuB,EAAAG,WAAA1B,GAEA,OAAAyB,CACA,CACAE,CAAAR,EAAAS,OAAAC,OACA,IAAAC,EAEA,MAAAC,EAAA,IAAAxC,YAEA,MAAAyC,EACA,WAAAC,GACAC,KAAAC,YAAAC,GAAA,EACAF,KAAAG,YAAAC,GAAA,CACA,CACA,WAAAC,CAAAH,GACA,OAAAF,KAAAC,YAAAC,EACA,CACA,WAAAI,CAAAF,GACA,OAAAJ,KAAAG,YAAAC,EACA,EAGA,MAAAG,EAAA,CACAC,YAAA,UACAC,WAAA1B,EAAArC,mBACAgE,UAAA3B,EAAApC,mBACAe,QAAAqB,EAAAjC,oBACA6D,aAAA1B,EAAAS,OAAAkB,aACAC,UAAA,GAMA,SAAA/B,EAAAgC,GACA,MAAAC,EAAA,IAAAjB,EACAkB,EAAA,CACAC,IAAA,CACAZ,YAAAU,EAAAV,YAAAa,KAAAH,GACAT,YAAAS,EAAAT,YAAAY,KAAAH,KAGA,OAAAI,YAAAC,YAAAxB,GAAAV,EAAA8B,GACAK,KAAAC,IACA1B,EAAAA,GAAA0B,EAAAxG,OACA,IAAA+D,EAAAiC,EAAAQ,EAAAC,UAAAD,EAAAP,IAEA,CACAlG,EAAAiE,aAAoBA,EAiCpB,MAAAD,EAKA,WAAAkB,CAAAe,EAAAU,EAAAC,GASA,GARAzB,KAAA0B,cAAAzC,EAAAS,OAAAiC,UAAA,EACA3B,KAAA4B,QAAA/B,EACAG,KAAA6B,YAAA,GACA7B,KAAA8B,UAAA,EACA9B,KAAA+B,UAAA9C,EAAAS,OAAAiC,UACA3B,KAAAgC,YAAA,EACAhC,KAAAiC,eAAA,EACAjC,KAAAkC,MAAA1F,OAAA2F,OAAA,GAAqC5B,EAAAO,GACrCd,KAAAkC,MAAAvB,aAAA1B,EAAAS,OAAAkB,aACA,UAAAwB,MAAA,+CAA2EnD,EAAAS,OAAAkB,gBAE3E,GAAAY,EAUAC,EAAAxB,YAAAD,KAAAqC,aAAAnB,KAAAlB,MACAyB,EAAAtB,YAAAH,KAAAsC,YAAApB,KAAAlB,UAXA,CACA,MAAAlF,EAAA8E,IAAAA,EAAA,IAAAuB,YAAAoB,OAAArD,IACAsC,EAAA,IAAAL,YAAAqB,SAAA1H,EAAA,CACAmG,IAAA,CACAZ,YAAAL,KAAAqC,aAAAnB,KAAAlB,MACAM,YAAAN,KAAAsC,YAAApB,KAAAlB,QAGA,CAKAA,KAAAwB,UAAAA,EACAxB,KAAAyC,MAAAzC,KAAAwB,UAAA3G,QACAmF,KAAA0C,OAAA,IAAAtF,WAAA4C,KAAAyC,MAAAE,OAAArF,OAAA0C,KAAAyC,MAAAG,oBAAA3D,EAAAS,OAAAmD,YACA7C,KAAA8C,QAAA,IAAAzF,YAAA2C,KAAAyC,MAAAE,OAAArF,OAAA0C,KAAAyC,MAAAM,oBAAA,IACA/C,KAAAgD,SAAA,IAAA3F,YAAA2C,KAAAyC,MAAAE,OAAArF,OAAA0C,KAAAyC,MAAAQ,sBAAAhE,EAAAS,OAAAkB,cACAZ,KAAAgD,SAAAE,IAAAlD,KAAAkC,MAAAxE,SACAsC,KAAAmD,MAAA,IAAA9F,YAAA2C,KAAAyC,MAAAE,OAAArF,OAAA0C,KAAAyC,MAAAW,kBACApD,KAAAyC,MAAAY,KAAAtE,EAAArC,mBAAA,EAAAsD,KAAAkC,MAAAvB,aAAA,EACA,CAEA,cAAA2C,GAAuB,OAAAtD,KAAA8C,QAAA,GACvB,aAAAS,GAAsB,OAAAvD,KAAA8C,QAAA,GACtB,gBAAAU,GAAyB,OAAAxD,KAAA8C,QAAA,GACzB,iBAAAW,GAA0B,OAAAzD,KAAA8C,QAAA,GAC1B,UAAAY,GAAmB,OAAA1D,KAAA8C,QAAA,GAAA9C,KAAA8C,QAAA,OACnB,WAAAa,GAAoB,OAAA3D,KAAA8C,QAAA,GACpB,UAAAc,GAAmB,OAAA5D,KAAA8C,QAAA,GACnB,SAAAe,GAAkB,OAAA7D,KAAA8C,QAAA,IAClB,iBAAAgB,GAA0B,OAAA9D,KAAA8C,QAAA,IAC1B,WAAAR,CAAAlC,GACA,OAAAA,EAAA,CACA,MAAA2D,EAAA/D,KAAAE,MAAAF,KAAAgE,OACA,GAAAD,EAAA/D,KAAA4B,QAAA7D,OAAA,CACA,GAAAiC,KAAAkC,MAAA1B,aAAA,EAAAuD,EAAA/D,KAAAkC,MAAA1B,YAEA,MADAR,KAAAiE,UACA,IAAA7B,MAAA,8BAEApC,KAAA4B,QAAA,IAAAvE,YAAA0G,EACA,CACA/D,KAAA8B,UAAA9B,KAAA0D,MACA,MACA,OAAAtD,EACA,OAAAJ,KAAA4D,OAAA,CAEA,MAAAG,EAAAhI,KAAAE,IAAA+D,KAAAwD,aAAAvE,EAAAS,OAAAiC,WAAA3B,KAAAyD,cACA,GAAAM,EAAA/D,KAAA4B,QAAA7D,OAAA,CACA,GAAAiC,KAAAkC,MAAA1B,aAAA,EAAAuD,EAAA/D,KAAAkC,MAAA1B,YAEA,MADAR,KAAAiE,UACA,IAAA7B,MAAA,8BAEApC,KAAA4B,QAAA,IAAAvE,YAAA0G,EACA,CACA,MAGA/D,KAAA4B,QAAA7D,OAAA,QACAiC,KAAA4B,QAAA,IAAAvE,YAAA,QAIA,QACA,CACA,QAAA6G,CAAAC,EAAAC,GACA,MAAAL,EAAAI,EAAAC,EACA,GAAAL,EAAA/D,KAAA4B,QAAA7D,OAAA,CACA,GAAAiC,KAAAkC,MAAA1B,aAAA,EAAAuD,EAAA/D,KAAAkC,MAAA1B,YAEA,MADAR,KAAAiE,UACA,IAAA7B,MAAA,8BAGA,MAAAiC,EAAA,IAAAhH,YAAA,MAAAtB,KAAAuI,KAAAP,EAAA,QACAM,EAAAnB,IAAAlD,KAAA4B,SACA5B,KAAA4B,QAAAyC,CACA,CACA,CACA,YAAAhC,CAAAnC,GACA,MAAAqE,EAAAvE,KAAA0B,cACA,IAAAyC,EAAAnE,KAAAgC,YACA,OAAAhC,KAAA6D,MAAA,CACA,IAAAW,EAAAxE,KAAAgE,OAAAhE,KAAAiC,eACA5F,EAAA,EACA,KAAAA,EAAA,GAAAmI,EAAA,GACAxE,KAAA4B,QAAAsB,IAAAlD,KAAAmD,MAAAsB,SAAAF,EAAAlI,EAAAkI,EAAAlI,EAAA6D,GAAAiE,EAAAjE,EAAA7D,GACAA,IACAmI,IAEAxE,KAAAgC,aAAA9B,EAAA7D,EACA2D,KAAAiC,gBAAA5F,CACA,MACA,OAAA2D,KAAA6D,MAAA,CACA7D,KAAAkE,SAAAC,EAAA,EAAAjE,GACAF,KAAA8B,UAAA/F,KAAAC,IAAAgE,KAAA8B,UAAA5B,GACAF,KAAA+B,UAAAhG,KAAAE,IAAA+D,KAAA+B,UAAA7B,GACA,QAAApC,EAAA,EAA4BA,EAAA,IAAOA,EACnCkC,KAAA4B,QAAAsB,IAAAlD,KAAAmD,MAAAsB,SAAAF,EAAAzG,EAAAyG,EAAAzG,EAAAoC,GAAAiE,EAAAjE,EAAApC,GAEAkC,KAAA6B,YAAAnD,KAAAwB,GACAF,KAAAgC,aAAA,EAAA9B,EACAF,KAAAiC,gBAAA,CACA,CACA,QACA,CAMA,SAAA/B,GACA,WAAAF,KAAA6D,MACA7D,KAAA0D,OACA3H,KAAAC,IAAAgE,KAAA8B,UAAA9B,KAAAyC,MAAAiC,gBACA,CAMA,UAAAV,GACA,WAAAhE,KAAA6D,MACA7D,KAAA2D,QACA3D,KAAAyC,MAAAiC,gBACA,EAAA1E,KAAA6B,YAAA9D,OAAAiC,KAAAyC,MAAAkC,iBACA,EAAA3E,KAAA6B,YAAA9D,MACA,CAIA,WAAAL,GACA,OAAAsC,KAAAgD,SAAAyB,SAAA,EAAAzE,KAAA8D,cACA,CAUA,eAAAc,GACA,OAAA5E,KAAA4B,QAAAiD,WAAA7E,KAAAyC,MAAAE,OAAArF,OAAAuH,WAAA,EAAA7E,KAAA6B,YAAA9D,MACA,CAIA,cAAA+G,GACA,OACA5E,MAAAF,KAAAE,MACA8D,OAAAhE,KAAAgE,OACA5D,KAAAJ,KAAA6D,MACAkB,MAAA/E,KAAA4D,OACA/C,WAAAb,KAAAuD,UACA5C,aAAAX,KAAA8D,cACApD,UAAAV,KAAAsD,WACA0B,SAAAhF,KAAA4E,YACAK,iBAAA,CACAC,UAAAlF,KAAA8C,QAAA,GACAqC,YAAAnF,KAAA8C,QAAA,GACA5C,MAAAF,KAAAwD,aACAQ,OAAAhE,KAAAyD,eAGA,CAMA,IAAAJ,CAAA3C,EAAAV,KAAAkC,MAAAxB,UAAAhD,EAAAsC,KAAAkC,MAAAxE,QAAAiD,EAAAX,KAAAkC,MAAAvB,aAAAE,EAAAb,KAAAkC,MAAArB,UACAb,KAAAyC,MAAAY,KAAArD,KAAAkC,MAAAzB,WAAAC,EAAAC,EAAAE,EAAA,KACAnD,GACAsC,KAAAgD,SAAAE,IAAAxF,EAAA+G,SAAA,EAAAxF,EAAAS,OAAAkB,eAEAZ,KAAA6B,YAAA9D,OAAA,EACAiC,KAAA8B,UAAA,EACA9B,KAAA+B,UAAA9C,EAAAS,OAAAiC,UACA3B,KAAAgC,YAAA,EACAhC,KAAAiC,eAAA,CACA,CAKA,MAAArD,CAAAwG,EAAAC,EAAA,EAAAC,EAAAF,EAAArH,QACA,IAAAU,EAAA4G,EACA,KAAA5G,EAAA6G,GAAA,CACA,MAAAvH,EAAAhC,KAAAE,IAAAqJ,EAAA7G,EAAAQ,EAAAS,OAAAmD,YACA7C,KAAA0C,OAAAQ,IAAAkC,EAAAX,SAAAhG,EAAAA,GAAAV,IACAiC,KAAAyC,MAAA7D,OAAA,EAAAb,EACA,CACA,CAMA,YAAAwH,CAAAH,EAAAC,EAAA,EAAAC,EAAAF,EAAArH,QACA,IAAAU,EAAA4G,EACA,KAAA5G,EAAA6G,GAAA,CACA,MAAAvH,EAAAhC,KAAAE,IAAAqJ,EAAA7G,EAAAQ,EAAAS,OAAAmD,YACA,QAAA/E,EAAA,EAAA0H,EAAA/G,EAAmCX,EAAAC,IAAYD,IAAA0H,EAC/CxF,KAAA0C,OAAA5E,GAAAsH,EAAA5F,WAAAgG,GAEA/G,GAAAV,EACAiC,KAAAyC,MAAA7D,OAAA,EAAAb,EACA,CACA,CAKA,UAAA0H,GACA,OAAAzF,KAAA6D,QAAA7D,KAAAE,QAAAF,KAAAgE,OACA,OAAAnE,EAGA,MAAA6F,EAAA1F,KAAAyC,MAAAiC,gBACA,OAAA1E,KAAA6D,MAAA,CACA,IAAAW,EAAAxE,KAAAgE,OAAAhE,KAAAiC,eACA,GAAAuC,EAAA,GACA,MAAAD,EAAAvE,KAAA0B,cACA,IAAAyC,EAAAnE,KAAAgC,YACA3F,EAAA,EACA,KAAAA,EAAA,GAAAmI,EAAA,GACAxE,KAAA4B,QAAAsB,IAAAlD,KAAAmD,MAAAsB,SAAAF,EAAAlI,EAAAkI,EAAAlI,EAAAqJ,GAAAvB,EAAAuB,EAAArJ,GACAA,IACAmI,IAEAA,GACAxE,KAAA4B,QAAA+D,KAAA3F,KAAAsD,WAAAa,EAAAuB,EAAArJ,EAEA,CACA,OAAA2D,KAAA4B,QAAA6C,SAAA,EAAAzE,KAAAE,MAAAF,KAAAgE,OACA,CACA,OAAAhE,KAAA6D,MAAA,CACA,GAAA7D,KAAA+B,YAAA/B,KAAA8B,UAAA,CACA,IAAA8D,GAAA,EACA,GAAAF,EACA,GAAAA,IAAA1F,KAAA+B,UACA6D,GAAA,MAEA,CACA,MAAArB,EAAAvE,KAAA0B,cACA,IAAAyC,EAAAnE,KAAAgC,YACAhC,KAAAkE,SAAAC,EAAA,EAAAuB,GACA,QAAA5H,EAAA,EAAwCA,EAAA,IAAOA,EAC/CkC,KAAA4B,QAAAsB,IAAAlD,KAAAmD,MAAAsB,SAAAF,EAAAzG,EAAAyG,EAAAzG,EAAA4H,GAAAvB,EAAAuB,EAAA5H,EAEA,CAEA,IAAA8H,EACA,OAAA5F,KAAA4B,QAAA6C,SAAA,EAAAzE,KAAAE,MAAAF,KAAAgE,OAEA,CAGA,MAAA6B,EAAA,IAAAxI,YAAA2C,KAAAE,MAAAF,KAAAgE,QACA6B,EAAAF,KAAA3F,KAAAsD,YACA,IAAAwC,EAAA,EACAT,EAAA,EACA,QAAAvH,EAAA,EAA4BA,EAAAkC,KAAA6B,YAAA9D,SAA6BD,EAAA,CACzD,MAAAiI,EAAA/F,KAAA6B,YAAA/D,GACA,QAAAW,EAAA,EAAgCA,EAAA,IAAOA,EACvCoH,EAAA3C,IAAAlD,KAAA4B,QAAA6C,SAAAY,EAAAA,GAAAU,GAAAD,GACAA,GAAA9F,KAAAE,KAEA,CAEA,GAAAwF,EAAA,CACA,MAAAnB,EAAAvE,KAAA0B,cAEAsE,EAAAhG,KAAAyC,MAAAkC,iBACA,QAAA7G,EAAA,EAAgCA,EAAAkI,IAAmBlI,EACnD+H,EAAA3C,IAAAlD,KAAAmD,MAAAsB,SAAAF,EAAAzG,EAAAyG,EAAAzG,EAAA4H,GAAAI,EAAA9F,KAAAE,MAAApC,EAEA,CACA,OAAA+H,CACA,CAEA,OAAAhG,CACA,CAKA,SAAAoG,GACA,WAAAC,kBAAAlG,KAAAyF,OAAAnI,OAAA,EAAA0C,KAAAE,MAAAF,KAAAgE,OAAA,EACA,CAaA,OAAAC,GACAjE,KAAA4B,QAAA/B,EACAG,KAAA6B,YAAA9D,OAAA,EACAiC,KAAA8B,UAAA,EACA9B,KAAA+B,UAAA9C,EAAAS,OAAAiC,UAGA3B,KAAAyC,MAAAY,KAAAtE,EAAArC,mBAAA,EAAAsD,KAAAkC,MAAAvB,aAAA,EACA,EAEA9F,EAAAgE,QAAeA,EAyBfhE,EAAA+D,OAXA,SAAAwG,EAAAtE,GACA,MAAAqF,EAAA,IAAAtH,EAAAiC,GAGA,OAFAqF,EAAA9C,OACA,iBAAA+B,EAAAe,EAAAZ,aAAAH,GAAAe,EAAAvH,OAAAwG,GACA,CACAlF,MAAAiG,EAAAjG,MACA8D,OAAAmC,EAAAnC,OACAyB,OAAAU,EAAAV,OACAQ,MAAAE,EAAAF,MAEA,EAkBApL,EAAA8D,YAXAyH,eAAAhB,EAAAtE,GACA,MAAAqF,QAAArH,EAAAgC,GAGA,OAFAqF,EAAA9C,OACA,iBAAA+B,EAAAe,EAAAZ,aAAAH,GAAAe,EAAAvH,OAAAwG,GACA,CACAlF,MAAAiG,EAAAjG,MACA8D,OAAAmC,EAAAnC,OACAyB,OAAAU,EAAAV,OACAQ,MAAAE,EAAAF,MAEA,YCpdAzJ,OAAAC,eAAA5B,EAAA,cAA+CiB,OAAA,IAC/CjB,EAAA6E,YAAc,EACd7E,EAAA6E,OAAc,CACdmD,WAAA,MACAjC,aAAA,KACAe,UAAA,MACAhC,MAAA,+qdCCA,MAAA0G,EAAArH,EAAA,KAoEA,IAAiBsH,YA9DjB,iBAAAvG,GACUC,KAAAuG,WAAqD,GACrDvG,KAAAwG,WAAY,CA0DtB,CAvDE,SAAWC,GACT,OAAIzG,KAAK0G,SAGT1G,KAAK0G,OAAS,CAACC,EAAyBC,EAAgBC,KACtD,GAAI7G,KAAKwG,UACP,OAAO,EAAAH,EAAAS,cAAa,QAGtB,MAAMC,EAAQ,CAAEC,GAAIL,EAAUC,YAC9B5G,KAAKuG,WAAavG,KAAKuG,WAAWU,QAClCjH,KAAKuG,WAAW7H,KAAKqI,GAErB,MAAMxH,GAAS,EAAA8G,EAAAS,cAAa,KAC1B,MAAMjJ,EAAMmC,KAAKuG,WAAWW,QAAQH,IACvB,IAATlJ,IACFmC,KAAKuG,WAAavG,KAAKuG,WAAWU,QAClCjH,KAAKuG,WAAWY,OAAOtJ,EAAK,MAYhC,OARIgJ,IACEO,MAAMC,QAAQR,GAChBA,EAAYnI,KAAKa,GAEjBsH,EAAYS,IAAI/H,IAIbA,IA3BAS,KAAK0G,MA8BhB,CAEO,IAAAa,CAAKd,GACV,GAAIzG,KAAKwG,YAAcxG,KAAKuG,WAAWxI,OACrC,OAEF,GAA+B,IAA3BiC,KAAKuG,WAAWxI,OAElB,YADAiC,KAAKuG,WAAW,GAAGS,GAAGQ,KAAKxH,KAAKuG,WAAW,GAAGK,SAAUH,GAG1D,MAAMgB,EAAYzH,KAAKuG,WACvB,IAAK,IAAIzI,EAAI,EAAG4J,EAAMD,EAAU1J,OAAQD,EAAI4J,IAAO5J,EACjD2J,EAAU3J,GAAGkJ,GAAGQ,KAAKC,EAAU3J,GAAG8I,SAAUH,EAEhD,CAEO,OAAAkB,GACD3H,KAAKwG,YAGTxG,KAAKwG,WAAY,EACjBxG,KAAKuG,WAAWxI,OAAS,EAC3B,GAGF,SAAiBuI,GACCA,EAAAsB,QAAhB,SAA2BxI,EAAiByI,GAC1C,OAAOzI,EAAK0I,GAAKD,EAAGN,KAAKO,GAC3B,EAEgBxB,EAAAyB,IAAhB,SAA0BtB,EAAkBsB,GAC1C,MAAO,CAACpB,EAAyBC,EAAgBC,IACxCJ,EAAM3I,GAAK6I,EAASa,KAAKZ,EAAUmB,EAAIjK,SAAKkK,EAAWnB,EAElE,EAIgBP,EAAA2B,IAAhB,YAA0BC,GACxB,MAAO,CAACvB,EAAyBC,EAAgBC,KAC/C,MAAMsB,EAAQ,IAAI9B,EAAA+B,gBAClB,IAAK,MAAM3B,KAASyB,EAClBC,EAAMb,IAAIb,EAAMqB,GAAKnB,EAASa,KAAKZ,EAAUkB,KAS/C,OAPIjB,IACEO,MAAMC,QAAQR,GAChBA,EAAYnI,KAAKyJ,GAEjBtB,EAAYS,IAAIa,IAGbA,EAEX,EAIgB7B,EAAA+B,gBAAhB,SAAmC5B,EAAkB6B,EAAqCC,GAExF,OADAD,EAAQC,GACD9B,EAAMqB,GAAKQ,EAAQR,GAC5B,CACD,CApCD,CAAiBxB,IAAUzL,EAAAyL,WAAVA,EAAU,eChE3B,SAAAQ,EAA6BE,GAC3B,MAAO,CAAEW,QAASX,EACpB,CAKA,SAAAW,EAA+Ca,GAC7C,IAAKA,EACH,OAAOA,EAET,GAAIpB,MAAMC,QAAQmB,GAAM,CACtB,IAAK,MAAMrK,KAAKqK,EACdrK,EAAEwJ,UAEJ,MAAO,EACT,CAEA,OADAa,EAAIb,UACGa,CACT,8JAEA,YAAsC3B,GACpC,OAAOC,EAAa,IAAMa,EAAQd,GACpC,EAEA,MAAAuB,EAAA,WAAArI,GACmBC,KAAAyI,aAAe,IAAIC,IAC5B1I,KAAA2I,aAAc,CAgCxB,CA9BE,cAAWC,GACT,OAAO5I,KAAK2I,WACd,CAEO,GAAArB,CAA2BuB,GAMhC,OALI7I,KAAK2I,YACPE,EAAElB,UAEF3H,KAAKyI,aAAanB,IAAIuB,GAEjBA,CACT,CAEO,OAAAlB,GACL,IAAI3H,KAAK2I,YAAT,CAGA3I,KAAK2I,aAAc,EACnB,IAAK,MAAMxK,KAAK6B,KAAKyI,aACnBtK,EAAEwJ,UAEJ3H,KAAKyI,aAAaK,OALlB,CAMF,CAEO,KAAAA,GACL,IAAK,MAAM3K,KAAK6B,KAAKyI,aACnBtK,EAAEwJ,UAEJ3H,KAAKyI,aAAaK,OACpB,sBAGF,MAAAC,EAAA,WAAAhJ,GAGqBC,KAAAgJ,OAAS,IAAIZ,CASlC,CAPS,OAAAT,GACL3H,KAAKgJ,OAAOrB,SACd,CAEU,SAAAsB,CAAiCJ,GACzC,OAAO7I,KAAKgJ,OAAO1B,IAAIuB,EACzB,iBAVuBE,EAAAG,KAAoB1M,OAAO2M,OAAO,CAAE,OAAAxB,GAAY,wBAazE,iBAAA5H,GAEUC,KAAA2I,aAAc,CAuBxB,CArBE,SAAW7M,GACT,OAAOkE,KAAK2I,iBAAcX,EAAYhI,KAAKoJ,MAC7C,CAEA,SAAWtN,CAAMA,GACXkE,KAAK2I,aAAe7M,IAAUkE,KAAKoJ,SAGvCpJ,KAAKoJ,QAAQzB,UACb3H,KAAKoJ,OAAStN,EAChB,CAEO,KAAAgN,GACL9I,KAAKlE,WAAQkM,CACf,CAEO,OAAAL,GACL3H,KAAK2I,aAAc,EACnB3I,KAAKoJ,QAAQzB,UACb3H,KAAKoJ,YAASpB,CAChB,wKC1GF,MAAAqB,EAAArK,EAAA,KAEAsK,EAAAtK,EAAA,KACAuK,EAAAC,EAAAxK,EAAA,MACAyK,EAAAD,EAAAxK,EAAA,KACA0K,EAAA1K,EAAA,KACA2K,EAAA3K,EAAA,KAaM4K,EAAgC,CACpCC,KAAI,EACJC,KAAM,eACNC,KAAM,EACN7J,MAAO,OACP8D,OAAQ,OACRgG,oBAAqB,EACrBC,OAAQ,gBAIV,MAUE,WAAAlK,CACmBmC,EACAgI,EACAC,EACAC,GAHApK,KAAAkC,MAAAA,EACAlC,KAAAkK,UAAAA,EACAlK,KAAAmK,SAAAA,EACAnK,KAAAoK,cAAAA,EAbXpK,KAAAqK,YAAc,EACdrK,KAAAsK,UAAW,EACXtK,KAAAuK,IAAM,IAAIb,EAAAc,aACVxK,KAAAyK,QAAyBb,EAGzB5J,KAAA0K,cAAe,EACf1K,KAAA2K,aAAc,EAQpB,MAAMC,EAAkB7O,KAAKuI,KAA+B,EAA1BtE,KAAKkC,MAAM2I,aAAmB,GAC1DC,EAAe/O,KAAKE,IAAG,QAA4B2O,GACzD5K,KAAK+K,KAAO,IAAIxB,EAAAyB,QAAa,QAAyBJ,EAAiBE,GACvE9K,KAAKiL,QAAU,IAAIxB,EAAAuB,QAAU,QAC/B,CAEO,KAAAE,GACLlL,KAAKqK,cACLrK,KAAKuK,IAAIW,QACTlL,KAAK+K,KAAK9G,UACVjE,KAAKiL,QAAQhH,SACf,CAEO,KAAAoB,GACLrF,KAAKsK,UAAW,EAChBtK,KAAKuK,IAAIW,OACX,CAEO,GAAAC,CAAI/F,EAAmBC,EAAeC,GAC3C,IAAItF,KAAKsK,SAET,GAAkB,IAAdtK,KAAKuK,IAAIa,MAC6C,IAAnDpL,KAAK+K,KAAKI,IAAI/F,EAAKX,SAASY,EAAOC,MACtCtF,KAAK+K,KAAK9G,UACVjE,KAAKsK,UAAW,OAEb,CACL,MAAMe,EAAUrL,KAAKuK,IAAIe,MAAMlG,EAAMC,EAAOC,GAC5C,IAAiB,IAAb+F,EAEF,YADArL,KAAKsK,UAAW,GAGlB,GAAIe,EAAU,EAAG,CAEf,GAAW,IADKrL,KAAKuK,IAAIgB,OAAO1B,KACG,CAOjC,GANI7J,KAAK0K,eACP1K,KAAK0K,cAAe,EACpB1K,KAAK2K,aAAc,EACnB3K,KAAK+K,KAAK9G,WAEZjE,KAAKyK,QAAUjO,OAAO2F,OAAO,GAAIyH,EAAgB5J,KAAKuK,IAAIgB,SACrDvL,KAAKyK,QAAQR,OAEhB,YADAjK,KAAKsK,UAAW,GAGlBtK,KAAK+K,KAAK1H,MACZ,MAAO,GAAIrD,KAAK2K,YAEd,YADA3K,KAAKsK,UAAW,GAGwC,IAArDtK,KAAK+K,KAAKI,IAAI/F,EAAKX,SAAS4G,EAAS/F,MACxCtF,KAAK+K,KAAK9G,UACVjE,KAAKsK,UAAW,EACZtK,KAAK0K,eAAc1K,KAAK2K,aAAc,GAE9C,CACF,CACF,CAEO,GAAArF,CAAIkG,aACT,GAAIxL,KAAKsK,SAAU,OAAO,EAE1B,GAAkB,IAAdtK,KAAKuK,IAAIa,OACPpL,KAAKuK,IAAIjF,MAAO,OAAO,EAE7B,MAAMmG,EAAUzL,KAAKuK,IAAIgB,OAAO1B,KAEhC,GAAW,IAAP4B,EAAmC,OAAO,EAE9C,GAAW,IAAPA,EAAyC,CAE3C,IAAIC,EAAIpC,EAAAqC,kBAAkBzL,MACtB9B,EAAIkL,EAAAqC,kBAAkB3H,OACtBhE,KAAKkK,UAAU0B,aACjBF,EAAI1L,KAAKkK,UAAU0B,WAAWC,IAAIC,OAAO5L,MAAQF,KAAKoK,cAAc2B,KACpE3N,EAAI4B,KAAKkK,UAAU0B,WAAWC,IAAIC,OAAO9H,OAAShE,KAAKoK,cAAc4B,MAEvE,MAAMC,EAAyD,QAApDC,EAA+C,QAA5CC,EAAAnM,KAAKoK,cAAcgC,MAAMC,2BAAmB,IAAAF,OAAA,EAAAA,EAAEG,WAAG,IAAAJ,EAAAA,EAAI,EAC7DK,EAAS,yBAA4BnO,EAAEoO,QAAQ,MAAMd,EAAEc,QAAQ,MAAMP,EAAMO,QAAQ,QAEzF,OADAxM,KAAKoK,cAAcqC,MAAMF,GAAQ,IAC1B,CACT,CAEA,GAAW,IAAPd,EAMF,OALAzL,KAAKyK,QAAUjO,OAAO2F,OAAO,GAAIyH,EAAgB5J,KAAKuK,IAAIgB,QAC1DvL,KAAK0K,cAAe,EACpB1K,KAAK2K,aAAc,EACnB3K,KAAK+K,KAAK9G,UACVjE,KAAK+K,KAAK1H,QACH,EAGT,GAAW,IAAPoI,EAAkC,CACpC,IAAKzL,KAAK0K,aAAc,OAAO,EAE/B,GADA1K,KAAK0K,cAAe,EAChB1K,KAAK2K,aAAgC,IAAjB3K,KAAKyK,QAAQZ,KAAqC,OAAO,CACnF,CAIA,IAII6C,EA0BAC,EA9BAjB,EAAI,EACJtN,EAAI,EAIJwO,EAAUjD,EAAAkD,iBAoBd,IAnBIH,EAAOlB,MACLkB,GAAQ1M,KAAK+K,KAAKzF,QACpBsH,GAAU,EAAAjD,EAAAmD,WAAU9M,KAAK+K,KAAK9E,QAC1ByG,EAAwB,gBAAjBE,EAAQG,OACjBrB,EAAIkB,EAAQ1M,MACZ9B,EAAIwO,EAAQ5I,QACR0I,EAAOhB,GAAKtN,GAAKsN,EAAItN,EAAI4B,KAAKkC,MAAM8K,cACrCtB,EAAGtN,GAAK4B,KAAKiN,QAAQvB,EAAGtN,GAAG2J,IAAIhM,KAAKmR,OACrCR,EAAOhB,GAAKtN,GAAKsN,EAAItN,EAAI4B,KAAKkC,MAAM8K,YAEpCzP,QAAQC,KAAK,8BAA8BoP,EAAQ1M,SAAS0M,EAAQ5I,WAGtEzG,QAAQC,KAAK,gCAGfD,QAAQC,KAAK,uCAGZkP,EAEH,OADA1M,KAAK+K,KAAK9G,WACH,EAIT,GAAqB,cAAjB2I,EAAQG,KAAsB,CAChC,MAAM3H,EAAOpF,KAAKiL,QAAQrM,OAAOoB,KAAK+K,KAAK9E,OAO3C,GANA0G,EAAO,IAAIQ,UACT,IAAIjH,kBAAkBd,EAAK9H,OAAQ8H,EAAKgI,WAAYhI,EAAKP,YACzD7E,KAAKiL,QAAQ/K,MACbF,KAAKiL,QAAQjH,QAEfhE,KAAKiL,QAAQhH,UACTyH,IAAM1L,KAAKiL,QAAQ/K,OAAS9B,IAAM4B,KAAKiL,QAAQjH,OAAQ,CAEzDhE,KAAK+K,KAAK9G,UACV,MAAM6H,EAASzC,EAAAgE,cAAcC,kBAAatF,EAAWhI,KAAKiL,QAAQ/K,MAAOF,KAAKiL,QAAQjH,QAGtF,OAFuB,QAAvBuJ,EAAAzB,EAAO0B,WAAW,aAAK,IAAAD,GAAAA,EAAEE,aAAad,EAAM,EAAG,GAC/C3M,KAAKmK,SAASuD,SAAS5B,IAChB,CACT,CACF,MACEa,EAAO,IAAIgB,KAAK,CAAC3N,KAAK+K,KAAK9E,OAAQ,CAAE4D,KAAM+C,EAAQG,OAErD/M,KAAK+K,KAAK9G,UACV,MAAM2J,EAAa5N,KAAKqK,YACxB,OAAOwD,kBAAkBlB,EAAM,CAAEmB,YAAapC,EAAGqC,aAAc3P,IAC5DiD,KAAK2M,GACAJ,IAAe5N,KAAKqK,aACtB2D,EAAGC,SACI,IAETjO,KAAKmK,SAASuD,SAASM,IAChB,IAERE,MAAMpG,IACLvK,QAAQC,KAAK,uBAAuBoP,EAAQG,QAAQH,EAAQ1M,SAAS0M,EAAQ5I,SAAU8D,IAChF,GAEb,CAEQ,OAAAmF,CAAQvB,EAAWtN,eACzB,MAAM+P,GAA8B,QAAzBhC,EAAAnM,KAAKkK,UAAU0B,kBAAU,IAAAO,OAAA,EAAAA,EAAEN,IAAIuC,KAAKlO,QAASoJ,EAAAqC,kBAAkBzL,MACpEmO,GAA8B,QAAzBd,EAAAvN,KAAKkK,UAAU0B,kBAAU,IAAA2B,OAAA,EAAAA,EAAE1B,IAAIuC,KAAKpK,SAAUsF,EAAAqC,kBAAkB3H,OACrE9D,GAAiC,QAAzBgM,EAAAlM,KAAKkK,UAAU0B,kBAAU,IAAAM,OAAA,EAAAA,EAAEL,IAAIC,OAAO5L,QAASiO,EAAKnO,KAAKoK,cAAc2B,KAC/E/H,GAAkC,QAAzBsK,EAAAtO,KAAKkK,UAAU0B,kBAAU,IAAA0C,OAAA,EAAAA,EAAEzC,IAAIC,OAAO9H,SAAUqK,EAAKrO,KAAKoK,cAAc4B,KAEjFuC,EAAKvO,KAAKwO,KAAKxO,KAAKyK,QAAQvK,MAAQA,EAAOiO,GAC3CM,EAAKzO,KAAKwO,KAAKxO,KAAKyK,QAAQzG,OAASA,EAAQqK,GACnD,IAAKE,IAAOE,EAAI,CACd,MAAMC,EAAKxO,EAAQwL,EACbiD,GAAM3K,EAASqK,GAAMjQ,EACrBwQ,EAAI7S,KAAKE,IAAIyS,EAAIC,GACvB,OAAOC,EAAI,EAAI,CAAClD,EAAIkD,EAAGxQ,EAAIwQ,GAAK,CAAClD,EAAGtN,EACtC,CACA,OAAQmQ,GAEJvO,KAAKyK,QAAQT,qBAAwBuE,GAAOE,EACvB,CAACF,EAAIE,GAAxB,CAACF,EAAInQ,EAAImQ,EAAK7C,GAFhB,CAACA,EAAI+C,EAAKrQ,EAAGqQ,EAGnB,CAEQ,IAAAD,CAAKlQ,EAAWuQ,EAAeC,GACrC,MAAU,SAANxQ,EAAqB,EACrBA,EAAEyQ,SAAS,KAAaC,SAAS1Q,EAAE2I,MAAM,GAAI,GAAI,IAAM4H,EAAQ,IAC/DvQ,EAAEyQ,SAAS,MAAcC,SAAS1Q,EAAE2I,MAAM,GAAI,GAAI,IAC/C+H,SAAS1Q,EAAG,IAAMwQ,CAC3B,aC/LF,SAASG,EAAM7J,GACb,IAAI9G,EAAI,GACR,IAAK,IAAIR,EAAI,EAAGA,EAAIsH,EAAKrH,SAAUD,EACjCQ,GAAK4Q,OAAOC,aAAa/J,EAAKtH,IAEhC,OAAOQ,CACT,CAGA,SAAS8Q,EAAMhK,GACb,IAAI7G,EAAI,EACR,IAAK,IAAIT,EAAI,EAAGA,EAAIsH,EAAKrH,SAAUD,EAAG,CACpC,GAAIsH,EAAKtH,GAAK,IAAMsH,EAAKtH,GAAK,GAC5B,MAAM,IAAIsE,MAAM,gBAElB7D,EAAQ,GAAJA,EAAS6G,EAAKtH,GAAK,EACzB,CACA,OAAOS,CACT,CAGA,SAAS8Q,EAAOjK,GACd,MAAM7G,EAAI0Q,EAAM7J,GAChB,IAAK7G,EAAE+Q,MAAM,oCACX,MAAM,IAAIlN,MAAM,gBAElB,OAAO7D,CACT,wEAeA,MAAMgR,EAAiE,CACrEtF,OAAQmF,EACRrF,KAAMqF,EACNtF,KAfF,SAAgB1E,GACd,GAAsB,oBAAXjG,OACT,OAAOA,OAAOC,KAAK6P,EAAM7J,GAAO,UAAUoK,WAE5C,MAAMC,EAAKnQ,KAAK2P,EAAM7J,IAChB3J,EAAI,IAAI2B,WAAWqS,EAAG1R,QAC5B,IAAK,IAAID,EAAI,EAAGA,EAAIrC,EAAEsC,SAAUD,EAC9BrC,EAAEqC,GAAK2R,EAAGjQ,WAAW1B,GAEvB,OAAO,IAAI4R,aAAc9Q,OAAOnD,EAClC,EAMEyE,MAAOmP,EACPrL,OAAQqL,EACRrF,oBAAqBoF,GAMjBO,EAAc,CAAC,GAAI,IAAK,IAAK,KAE7BC,EAAuB,CAAC,GAAI,IAAK,IAAK,IAAK,IAAK,IAAK,GAAI,IAAK,IAAK,GAAI,IAAK,IAAK,KAEjFC,EAAkB,CAAC,GAAI,IAAK,IAAK,IAAK,GAAI,GAAI,IAAK,KAEnDC,EAAiB,CAAC,GAAI,IAAK,IAAK,IAAK,GAAI,IAAK,KAE9CC,EAAwB,CAAC,GAAI,IAAK,IAAK,IAAK,IAAK,IAAK,GAAI,IAAK,IAAK,IAAK,GAAI,IAAK,IAAK,KAGvFC,EAAiB,oBAGvB,iBAAAjQ,GACSC,KAAAoL,MAAK,EACJpL,KAAAiQ,QAAU,IAAI5S,YAAY2S,GAC1BhQ,KAAAkQ,UAAY,EACZlQ,KAAAmQ,KAAO,GACRnQ,KAAAuL,OAA4E,EAwIrF,CAtIS,KAAAL,GACLlL,KAAKiQ,QAAQtK,KAAK,GAClB3F,KAAKoL,MAAK,EACVpL,KAAKkQ,UAAY,EACjBlQ,KAAKuL,OAAS,GACdvL,KAAKmQ,KAAO,EACd,CAEO,GAAA7K,GACL,GAAc,IAAVtF,KAAKoL,MAA6B,CACpC,GAAIpL,KAAKkQ,YAAcJ,EAAe/R,OAAQ,CAC5C,IAAK,IAAIqS,EAAI,EAAGA,EAAIN,EAAe/R,SAAUqS,EAC3C,GAAIpQ,KAAKiQ,QAAQG,KAAON,EAAeM,GAAI,OAAOpQ,KAAKmM,KAIzD,OAFAnM,KAAKuL,OAAa,KAAC,EACnBvL,KAAKoL,MAAK,EACH,CACT,CACA,GAAIpL,KAAKkQ,YAAcH,EAAsBhS,OAAQ,CACnD,IAAK,IAAIqS,EAAI,EAAGA,EAAIL,EAAsBhS,SAAUqS,EAClD,GAAIpQ,KAAKiQ,QAAQG,KAAOL,EAAsBK,GAAI,OAAOpQ,KAAKmM,KAIhE,OAFAnM,KAAKuL,OAAa,KAAC,EACnBvL,KAAKoL,MAAK,EACH,CACT,CACA,OAAOpL,KAAKmM,IACd,CACA,OAAc,IAAVnM,KAAKoL,MAAkC,EAC7B,IAAVpL,KAAKoL,OACY,IAAhBpL,KAAKuL,OAAO1B,MAEV7J,KAAKqQ,YAAYrQ,KAAKkQ,YAC3BlQ,KAAKoL,MAAK,EACH,GAEFpL,KAAKmM,IACd,CAEO,KAAAb,CAAMlG,EAAmBC,EAAeC,GAC7C,IAAI8F,EAAQpL,KAAKoL,MACbkF,EAAMtQ,KAAKkQ,UACf,MAAM5S,EAAS0C,KAAKiQ,QACpB,GAAS,IAAL7E,GAAoC,IAALA,EAA2B,OAAQ,EACtE,GAAS,IAALA,GAA+BkF,EAAM,GAAI,OAAQ,EACrD,IAAK,IAAIxS,EAAIuH,EAAOvH,EAAIwH,IAAOxH,EAAG,CAChC,MAAMzB,EAAI+I,EAAKtH,GACf,OAAQzB,GACN,KAAK,GACH,IAAK2D,KAAKqQ,YAAYC,GAAM,OAAOtQ,KAAKmM,KACxCf,EAAK,EACLkF,EAAM,EACN,MACF,KAAK,GACH,GAAS,IAALlF,EAA6B,CAC/B,GAAkB,KAAd9N,EAAO,GAAW,CAEpB,IAAI8S,EAAI,EACR,KAAOA,EAAIT,EAAY5R,SAAUqS,EAC/B,GAAI9S,EAAO8S,KAAOT,EAAYS,GAAI,OAAOpQ,KAAKmM,KAGhD,GADAnM,KAAKuL,OAAa,KAAC,EACf+E,IAAQT,EAAgB9R,OAAQ,CAClC,KAAOqS,EAAIP,EAAgB9R,SAAUqS,EACnC,GAAI9S,EAAO8S,KAAOP,EAAgBO,GAAI,OAAOpQ,KAAKmM,KAIpD,OAFAnM,KAAKuL,OAAa,KAAC,EACnBvL,KAAKoL,MAAK,EACHtN,EAAI,CACb,CACF,KAAO,IAAkB,KAAdR,EAAO,GAOhB,OAAO0C,KAAKmM,KALZ,IAAK,IAAIiE,EAAI,EAAGA,EAAIR,EAAqB7R,SAAUqS,EACjD,GAAI9S,EAAO8S,KAAOR,EAAqBQ,GAAI,OAAOpQ,KAAKmM,KAEzDnM,KAAKuL,OAAa,KAAC,CAGrB,CACAH,EAAK,EACLkF,EAAM,CACR,MAAO,GAAS,IAALlF,EAA2B,CACpC,IAAKpL,KAAKuQ,UAAUD,GAAM,OAAOtQ,KAAKmM,KACtCf,EAAK,EACLkF,EAAM,CACR,MAAO,GAAS,IAALlF,EAA6B,CACtC,GAAIkF,GAAON,EAAgB,OAAOhQ,KAAKmM,KACvC7O,EAAOgT,KAASjU,CAClB,CACA,MACF,KAAK,GACH,OAAS,IAAL+O,GACGpL,KAAKqQ,YAAYC,IAExBtQ,KAAKoL,MAAK,EACHtN,EAAI,GAH0BkC,KAAKmM,KAI5C,QACE,GAAImE,GAAON,EAAgB,OAAOhQ,KAAKmM,KACvC7O,EAAOgT,KAASjU,EAEtB,CAGA,OAFA2D,KAAKoL,MAAQA,EACbpL,KAAKkQ,UAAYI,GACT,CACV,CAEQ,EAAAnE,GAGN,OAFAnM,KAAKuL,OAAO1B,KAAI,EAChB7J,KAAKoL,MAAK,GACF,CACV,CAEQ,SAAAmF,CAAUD,GAChB,MAAMF,EAAInB,EAAMjP,KAAKiQ,QAAQxL,SAAS,EAAG6L,IACzC,QAAIF,IACFpQ,KAAKmQ,KAAOC,EACZpQ,KAAKuL,OAAO6E,GAAK,MACV,EAGX,CAEQ,WAAAC,CAAYC,GAClB,GAAItQ,KAAKmQ,KAAM,CACb,IACE,MAAM5R,EAAIyB,KAAKiQ,QAAQhJ,MAAM,EAAGqJ,GAChCtQ,KAAKuL,OAAOvL,KAAKmQ,MAAQZ,EAASvP,KAAKmQ,MAAQZ,EAASvP,KAAKmQ,MAAM5R,GAAKA,CAC1E,UACE,OAAO,CACT,CACA,OAAO,CACT,CACA,OAAO,CACT,yGCtPF,MAEE,WAAAwB,CACmBoK,GAAAnK,KAAAmK,SAAAA,EAFXnK,KAAAwQ,cAA+B,CAAEC,WAAW,EAAMC,MAAO,MAAOC,OAAQ,EAAGC,UAAW,MAG3F,CAMI,QAAAlD,CAASmD,GACd7Q,KAAKmK,SAASuD,SAASmD,EAAK7Q,KAAKwQ,cACnC,oGCNF,SAA0BrS,GACxB,GAAIA,EAAEJ,OAAS,GACb,OAAOlD,EAAAgS,iBAET,MAAMiE,EAAM,IAAIzT,YAAYc,EAAEb,OAAQa,EAAEiP,WAAY,GAGpD,GAAe,aAAX0D,EAAI,IAAgC,YAAXA,EAAI,IAAgC,aAAXA,EAAI,GACxD,MAAO,CACL/D,KAAM,YACN7M,MAAQ/B,EAAE,KAAO,GAAKA,EAAE,KAAO,GAAKA,EAAE,KAAO,EAAIA,EAAE,IACnD6F,OAAQ7F,EAAE,KAAO,GAAKA,EAAE,KAAO,GAAKA,EAAE,KAAO,EAAIA,EAAE,KAIvD,GAAa,MAATA,EAAE,IAAwB,MAATA,EAAE,IAAwB,MAATA,EAAE,GAAa,CACnD,MAAO+B,EAAO8D,GAgFlB,SAAiB7F,GACf,MAAMuJ,EAAMvJ,EAAEJ,OACd,IAAID,EAAI,EACJiT,EAAc5S,EAAEL,IAAM,EAAIK,EAAEL,EAAI,GACpC,OAAa,CAEX,GADAA,GAAKiT,EACDjT,GAAK4J,EAEP,MAAO,CAAC,EAAG,GAEb,GAAa,MAATvJ,EAAEL,GACJ,MAAO,CAAC,EAAG,GAEb,GAAiB,MAAbK,EAAEL,EAAI,IAA4B,MAAbK,EAAEL,EAAI,GAC7B,OAAIA,EAAI,EAAI4J,EACH,CACLvJ,EAAEL,EAAI,IAAM,EAAIK,EAAEL,EAAI,GACtBK,EAAEL,EAAI,IAAM,EAAIK,EAAEL,EAAI,IAGnB,CAAC,EAAG,GAEbA,GAAK,EACLiT,EAAc5S,EAAEL,IAAM,EAAIK,EAAEL,EAAI,EAClC,CACF,CAzG4BkT,CAAQ7S,GAChC,MAAO,CAAE4O,KAAM,aAAc7M,QAAO8D,SACtC,CAEA,GAAe,YAAX8M,EAAI,KAA+B,KAAT3S,EAAE,IAAwB,KAATA,EAAE,KAAyB,KAATA,EAAE,GACjE,MAAO,CACL4O,KAAM,YACN7M,MAAQ/B,EAAE,IAAM,EAAIA,EAAE,GACtB6F,OAAQ7F,EAAE,IAAM,EAAIA,EAAE,IAI1B,GAAe,aAAX2S,EAAI,GACN,MAAO,CACL/D,KAAM,YACN7M,MAAQ/B,EAAE,IAAM,GAAKA,EAAE,IAAM,GAAKA,EAAE,IAAM,EAAIA,EAAE,GAChD6F,OAAQ7F,EAAE,IAAM,GAAKA,EAAE,IAAM,GAAKA,EAAE,KAAO,EAAIA,EAAE,KAIrD,GAAe,aAAX2S,EAAI,IAAgC,aAAXA,EAAI,IAA6C,UAAd,SAATA,EAAI,IAA6B,CACtF,OAAQ3S,EAAE,KACR,KAAK,GACH,MAAO,CACL4O,KAAM,aACN7M,MAA6C,GAApC/B,EAAE,IAAMA,EAAE,KAAO,EAAIA,EAAE,KAAO,IACvC6F,OAA6C,GAApC7F,EAAE,IAAMA,EAAE,KAAO,EAAIA,EAAE,KAAO,KAE3C,KAAK,GACH,GAAc,KAAVA,EAAE,IAAc,OAAOtD,EAAAgS,iBAC3B,MAAMoE,EAAM9S,EAAE,IAAMA,EAAE,KAAO,EAAIA,EAAE,KAAO,GAAKA,EAAE,KAAO,GACxD,MAAO,CACL4O,KAAM,aACN7M,MAAgC,GAAV,MAAb+Q,GACTjN,OAAgC,GAAvBiN,IAAQ,GAAK,QAE1B,KAAK,GACH,OAAc,MAAV9S,EAAE,KAA0B,IAAVA,EAAE,KAA0B,KAAVA,EAAE,IAAqBtD,EAAAgS,iBACxD,CACLE,KAAM,aACN7M,MAA+B,OAAtB/B,EAAE,IAAMA,EAAE,KAAO,GAC1B6F,OAA+B,OAAtB7F,EAAE,IAAMA,EAAE,KAAO,IAGhC,OAAOtD,EAAAgS,gBACT,CAEA,GAAe,aAAXiE,EAAI,KAAiC,aAAXA,EAAI,IAAgC,aAAXA,EAAI,IAAoB,CAC7E,IAAIR,GAAO,EAEX,MAAMY,EAAQnV,KAAKE,IAAIkC,EAAEJ,OAAS,GAAI,MACtC,IAAK,IAAID,EAAI,EAAGA,EAAIoT,EAAOpT,IAEzB,GAAa,MAATK,EAAEL,IAA4B,MAAbK,EAAEL,EAAI,IAA4B,MAAbK,EAAEL,EAAI,IAA4B,MAAbK,EAAEL,EAAI,GAAa,CAChFwS,EAAMxS,EACN,KACF,CAEF,IAAa,IAATwS,EAAY,CAEd,MAAMpQ,EACJ/B,EAAEmS,EAAO,IAAM,GACfnS,EAAEmS,EAAO,IAAM,GACfnS,EAAEmS,EAAM,KAAQ,EAChBnS,EAAEmS,EAAM,IACJtM,EACJ7F,EAAEmS,EAAM,KAAO,GACfnS,EAAEmS,EAAM,KAAO,GACfnS,EAAEmS,EAAM,KAAQ,EAChBnS,EAAEmS,EAAM,IACV,GAAIpQ,EAAQ,GAAK8D,EAAS,EACxB,MAAO,CAAE+I,KAAM,aAAc7M,QAAO8D,SAExC,CACA,OAAOnJ,EAAAgS,gBACT,CACA,OAAOhS,EAAAgS,gBACT,EAnGahS,EAAAgS,iBAA6B,CACxCE,KAAM,cACN7M,MAAO,EACP8D,OAAQ,uFCZV,MAAAjF,EAAAC,EAAA,KAGAqH,EAAArH,EAAA,KAaA,MAAAqO,UAAmChH,EAAA0C,WAEjC,UAAW+C,GAAM,IAAAK,EAAoC,OAA8B,QAAvBA,EAAAnM,KAAKmR,QAAQC,IAAI,cAAM,IAAAjF,OAAA,EAAAA,EAAEL,MAAQ,CAUtF,mBAAOwB,CAAa+D,EAAqCnR,EAAe8D,GAU7E,MAAM8H,GAAUuF,QAAAA,EAAiBC,UAAUC,cAAc,UAGzD,OAFAzF,EAAO5L,MAAgB,EAARA,EACf4L,EAAO9H,OAAkB,EAATA,EACT8H,CACT,CAGO,sBAAO0F,CAAgBC,EAA+BvR,EAAe8D,EAAgB1G,GAC1F,GAAyB,mBAAd6P,UAA0B,CACnC,MAAMuE,EAAUD,EAAID,gBAAgBtR,EAAO8D,GAI3C,OAHI1G,GACFoU,EAAQtM,KAAKlC,IAAI,IAAIgD,kBAAkB5I,EAAQ,EAAG4C,EAAQ8D,EAAS,IAE9D0N,CACT,CACA,OAAOpU,EACH,IAAI6P,UAAU,IAAIjH,kBAAkB5I,EAAQ,EAAG4C,EAAQ8D,EAAS,GAAI9D,EAAO8D,GAC3E,IAAImJ,UAAUjN,EAAO8D,EAC3B,CAGO,wBAAO6J,CAAkBgD,GAC9B,MAAiC,mBAAtBhD,kBACF8D,QAAQC,aAAQ5J,GAElB6F,kBAAkBgD,EAC3B,CAGA,WAAA9Q,CAAoB8R,GAClBC,QADkB9R,KAAA6R,UAAAA,EAhDZ7R,KAAAmR,QAAU,IAAIY,IAGd/R,KAAAgS,gBAAkBhS,KAAKiJ,UAAU,IAAI5C,EAAA4L,mBA+C3CjS,KAAKkS,SAAWlS,KAAK6R,UAAUzF,MAAM+F,KACrCnS,KAAK6R,UAAUzF,MAAM+F,KAAQC,UACd,QAAbjG,EAAAnM,KAAKkS,gBAAQ,IAAA/F,GAAAA,EAAE3E,KAAKxH,KAAK6R,UAAUzF,MAAOgG,GAC1CpS,KAAKqS,SAEHrS,KAAK6R,UAAUzF,MAAMkG,eACvBtS,KAAKqS,QAGPrS,KAAKgS,gBAAgBlW,MAAQkE,KAAK6R,UAAUzF,MAAMmG,eAAeC,eAAeC,UAC/D,aAAXA,IACFzS,KAAK0S,gBACc,QAAnBvG,EAAAnM,KAAK2S,sBAAc,IAAAxG,GAAAA,EAAEyG,YAAY,EAAG5S,KAAK6R,UAAU7F,SAGvDhM,KAAKiJ,WAAU,EAAA5C,EAAAS,cAAa,WAC1B9G,KAAK6S,qBACL7S,KAAK6S,mBAAmB,UACpB7S,KAAK6R,UAAUzF,OAASpM,KAAKkS,WAC/BlS,KAAK6R,UAAUzF,MAAM+F,KAAOnS,KAAKkS,SACjClS,KAAKkS,cAAWlK,GAEdhI,KAAK2S,gBAAkB3S,KAAK8S,kBAC9B9S,KAAK2S,eAAeI,YAAc/S,KAAK8S,gBACvC9S,KAAK8S,qBAAkB9K,GAEzBhI,KAAK2S,oBAAiB3K,EACtBhI,KAAKmR,QAAQrI,QACU,QAAvBqD,EAAAnM,KAAKgT,0BAAkB,IAAA7G,GAAAA,EAAE8B,QACzBjO,KAAKgT,wBAAqBhL,EAC1BhI,KAAKiT,kBAAejL,IAExB,CAKO,eAAAkL,CAAgBpX,WACjBA,EACGkE,KAAKiT,eAA0C,IAA1BjT,KAAKmT,SAASnP,QACtChE,KAAKoT,mBAAmBrX,KAAKC,IAAIgE,KAAKmT,SAASnP,OAAS,EAAC,MAGpC,QAAvBmI,EAAAnM,KAAKgT,0BAAkB,IAAA7G,GAAAA,EAAE8B,QACzBjO,KAAKgT,wBAAqBhL,EAC1BhI,KAAKiT,kBAAejL,GAEH,QAAnBuF,EAAAvN,KAAK2S,sBAAc,IAAApF,GAAAA,EAAEqF,YAAY,EAAG5S,KAAK6R,UAAU7F,KACrD,CAMA,cAAWJ,GACT,OAAO5L,KAAK6R,UAAUjG,UACxB,CAKA,YAAWuH,WACT,MAAO,CACLjT,OAAsB,QAAfiM,EAAAnM,KAAK4L,kBAAU,IAAAO,OAAA,EAAAA,EAAEN,IAAIuC,KAAKlO,SAAU,EAC3C8D,QAAuB,QAAfuJ,EAAAvN,KAAK4L,kBAAU,IAAA2B,OAAA,EAAAA,EAAE1B,IAAIuC,KAAKpK,UAAW,EAEjD,CAKO,UAAAqP,CAAWhO,EAAeC,EAAaoL,iBAC5C,MAAM4C,EAAIjO,IAAwB,QAAf8G,EAAAnM,KAAK4L,kBAAU,IAAAO,OAAA,EAAAA,EAAEN,IAAIuC,KAAKpK,SAAU,GACjD0H,GAAmB,QAAf6B,EAAAvN,KAAK4L,kBAAU,IAAA2B,OAAA,EAAAA,EAAE1B,IAAIC,OAAO5L,QAAS,EACzC9B,GAAKkH,EAAM,EAAID,KAAyB,QAAf6G,EAAAlM,KAAK4L,kBAAU,IAAAM,OAAA,EAAAA,EAAEL,IAAIuC,KAAKpK,SAAU,GAC9D0M,GAAmB,QAAVA,GACW,QAAvBpC,EAAAtO,KAAKmR,QAAQC,IAAI,cAAM,IAAA9C,GAAAA,EAAEiF,UAAU,EAAGD,EAAG5H,EAAGtN,GAEzCsS,GAAmB,WAAVA,GACc,QAA1B8C,EAAAxT,KAAKmR,QAAQC,IAAI,iBAAS,IAAAoC,GAAAA,EAAED,UAAU,EAAGD,EAAG5H,EAAGtN,EAEnD,CAKO,QAAAqV,CAAS/C,GACd,IAAKA,GAAmB,QAAVA,EAAiB,CAC7B,MAAMe,EAAMzR,KAAKmR,QAAQC,IAAI,OAC7BK,SAAAA,EAAK8B,UAAU,EAAG,EAAG9B,EAAI3F,OAAO5L,MAAOuR,EAAI3F,OAAO9H,OACpD,CACA,IAAK0M,GAAmB,WAAVA,EAAoB,CAChC,MAAMe,EAAMzR,KAAKmR,QAAQC,IAAI,UAC7BK,SAAAA,EAAK8B,UAAU,EAAG,EAAG9B,EAAI3F,OAAO5L,MAAOuR,EAAI3F,OAAO9H,OACpD,CACF,CAKO,IAAA0P,CAAKC,EAAqBC,EAAgBC,EAAaC,EAAaC,EAAgB,GACzF,MAAMtC,EAAMzR,KAAKmR,QAAQC,IAAIuC,EAAQjD,OACrC,IAAKe,EACH,OAEF,MAAMvR,MAAEA,EAAK8D,OAAEA,GAAWhE,KAAKmT,SAG/B,IAAe,IAAXjT,IAA4B,IAAZ8D,EAClB,OAGFhE,KAAKgU,cAAcL,EAASzT,EAAO8D,GACnC,MAAM6M,EAAM8C,EAAQM,QACZ/T,MAAOgU,EAAalQ,OAAQmQ,GAAiBR,EAAQS,eACvDrI,EAAOhQ,KAAKuI,KAAKuM,EAAI3Q,MAAQgU,GAE7BG,EAAMT,EAAS7H,EAAQmI,EACvBI,EAAKvY,KAAKmR,MAAM0G,EAAS7H,GAAQoI,EACjCI,EAAKV,EAAM3T,EACXsU,EAAKV,EAAM9P,EAGXyQ,EAAaV,EAAQG,EAAcG,EAAKxD,EAAI3Q,MAAQ2Q,EAAI3Q,MAAQmU,EAAKN,EAAQG,EAC7EQ,EAAcJ,EAAKH,EAAetD,EAAI7M,OAAS6M,EAAI7M,OAASsQ,EAAKH,EAMvE1C,EAAIkD,UACF9D,EACA9U,KAAKmR,MAAMmH,GAAKtY,KAAKmR,MAAMoH,GAAKvY,KAAKuI,KAAKmQ,GAAa1Y,KAAKuI,KAAKoQ,GACjE3Y,KAAKmR,MAAMqH,GAAKxY,KAAKmR,MAAMsH,GAAKzY,KAAKuI,KAAKmQ,EAAavU,EAAQgU,GAAcnY,KAAKuI,KAAKoQ,EAAc1Q,EAASmQ,GAElH,CAKO,WAAAS,CAAYjB,EAAqBC,GACtC,MAAM1T,MAAEA,EAAK8D,OAAEA,GAAWhE,KAAKmT,SAE/B,IAAe,IAAXjT,IAA4B,IAAZ8D,EAClB,OAEFhE,KAAKgU,cAAcL,EAASzT,EAAO8D,GACnC,MAAM6M,EAAM8C,EAAQM,QACZ/T,MAAOgU,EAAalQ,OAAQmQ,GAAiBR,EAAQS,eACvDrI,EAAOhQ,KAAKuI,KAAKuM,EAAI3Q,MAAQgU,GAC7BG,EAAMT,EAAS7H,EAAQmI,EACvBI,EAAKvY,KAAKmR,MAAM0G,EAAS7H,GAAQoI,EACjCM,EAAaP,EAAcG,EAAKxD,EAAI3Q,MAAQ2Q,EAAI3Q,MAAQmU,EAAKH,EAC7DQ,EAAcJ,EAAKH,EAAetD,EAAI7M,OAAS6M,EAAI7M,OAASsQ,EAAKH,EAEjErI,EAASuB,EAAcC,aAAatN,KAAKsR,SAAUvV,KAAKuI,KAAKmQ,EAAavU,EAAQgU,GAAcnY,KAAKuI,KAAKoQ,EAAc1Q,EAASmQ,IACjI1C,EAAM3F,EAAO0B,WAAW,MAC9B,OAAIiE,GACFA,EAAIkD,UACF9D,EACA9U,KAAKmR,MAAMmH,GAAKtY,KAAKmR,MAAMoH,GAAKvY,KAAKmR,MAAMuH,GAAa1Y,KAAKmR,MAAMwH,GACnE,EAAG,EAAG5I,EAAO5L,MAAO4L,EAAO9H,QAEtB8H,QANT,CAQF,CAKO,eAAA+I,CAAgBhB,EAAaC,EAAaC,EAAgB,SAC/D,MAAMtC,EAAMzR,KAAKmR,QAAQC,IAAI,OAC7B,GAAIK,EAAK,CACP,MAAMvR,MAAEA,EAAK8D,OAAEA,GAAWhE,KAAKmT,SAG/B,IAAe,IAAXjT,IAA4B,IAAZ8D,EAClB,OAQF,GALKhE,KAAKiT,aAECjP,GAAUhE,KAAKiT,aAAcjP,QACtChE,KAAKoT,mBAAmBpP,EAAS,GAFjChE,KAAKoT,mBAAmBrX,KAAKC,IAAIgI,EAAS,EAAC,MAIxChE,KAAKiT,aAAc,OACxBxB,EAAIkD,UACqB,QADZxI,EACXnM,KAAKgT,0BAAkB,IAAA7G,EAAAA,EAAInM,KAAKiT,aAChCY,EAAM3T,EACL4T,EAAM9P,EAAU,EAAI,EAAI,EACzB9D,EAAQ6T,EACR/P,EACA6P,EAAM3T,EACN4T,EAAM9P,EACN9D,EAAQ6T,EACR/P,EAEJ,CACF,CAMO,aAAA0O,WACL,MAAMhH,GAAmB,QAAfS,EAAAnM,KAAK4L,kBAAU,IAAAO,OAAA,EAAAA,EAAEN,IAAIC,OAAO5L,QAAS,EACzC9B,GAAmB,QAAfmP,EAAAvN,KAAK4L,kBAAU,IAAA2B,OAAA,EAAAA,EAAE1B,IAAIC,OAAO9H,SAAU,EAChD,IAAK,MAAMyN,KAAOzR,KAAKmR,QAAQ2D,SACzBrD,EAAI3F,OAAO5L,QAAUwL,GAAK+F,EAAI3F,OAAO9H,SAAW5F,IAClDqT,EAAI3F,OAAO5L,MAAQwL,EACnB+F,EAAI3F,OAAO9H,OAAS5F,EAG1B,CAKQ,aAAA4V,CAAce,EAAkBrP,EAAsBM,GAC5D,GAAIN,IAAiBqP,EAAKX,eAAelU,OAAS8F,IAAkB+O,EAAKX,eAAepQ,OACtF,OAEF,MAAQ9D,MAAO8U,EAAehR,OAAQiR,GAAmBF,EAAKG,aAC9D,GAAIxP,IAAiBsP,GAAiBhP,IAAkBiP,EAItD,OAHAF,EAAKd,OAASc,EAAKI,KACnBJ,EAAKX,eAAelU,MAAQ8U,OAC5BD,EAAKX,eAAepQ,OAASiR,GAG/B,MAAMG,EAAcrZ,KAAKuI,KAAKyQ,EAAKI,KAAMjV,MAAQwF,EAAesP,GAC1DK,EAAetZ,KAAKuI,KAAKyQ,EAAKI,KAAMnR,OAASgC,EAAgBiP,GAEnE,GAAIG,EAAcC,EAAeN,EAAKI,KAAMjV,MAAQ6U,EAAKI,KAAMnR,OAI7D,OAHA+Q,EAAKd,OAASc,EAAKI,KACnBJ,EAAKX,eAAelU,MAAQ8U,OAC5BD,EAAKX,eAAepQ,OAASiR,GAG/B,MAAMnJ,EAASuB,EAAcC,aAAatN,KAAKsR,SAAU8D,EAAaC,GAChE5D,EAAM3F,EAAO0B,WAAW,MAC1BiE,IACFA,EAAIkD,UAAUI,EAAKI,KAAO,EAAG,EAAGrJ,EAAO5L,MAAO4L,EAAO9H,QACrD+Q,EAAKd,OAASnI,EACdiJ,EAAKX,eAAelU,MAAQwF,EAC5BqP,EAAKX,eAAepQ,OAASgC,EAEjC,CAKQ,KAAAqM,GACNrS,KAAK2S,eAAiB3S,KAAK6R,UAAUzF,MAAMuG,eAC3C3S,KAAK8S,gBAAkB9S,KAAK2S,eAAeI,YAAY7R,KAAKlB,KAAK2S,gBACjE3S,KAAK2S,eAAeI,YAAeuC,UACjC,IAAK,MAAMC,IAAO,IAAIvV,KAAKmR,QAAQqE,QACjCxV,KAAK6S,mBAAmB0C,GAEN,QAApBpJ,EAAAnM,KAAK8S,uBAAe,IAAA3G,GAAAA,EAAE3E,KAAKxH,KAAK2S,eAAgB2C,GAEpD,CAEO,gBAAAG,CAAiB/E,EAAoB,eAE1C,IAAK1Q,KAAKsR,WAAatR,KAAK6R,UAAUzF,MAAMkG,cAE1C,YADA/U,QAAQC,KAAK,sFAGf,GAAIwC,KAAKmR,QAAQuE,IAAIhF,GACnB,OAEF,MAAM5E,EAASuB,EAAcC,aAC3BtN,KAAKsR,UAAyB,QAAfnF,EAAAnM,KAAK4L,kBAAU,IAAAO,OAAA,EAAAA,EAAEN,IAAIC,OAAO5L,QAAS,GACrC,QAAfqN,EAAAvN,KAAK4L,kBAAU,IAAA2B,OAAA,EAAAA,EAAE1B,IAAIC,OAAO9H,SAAU,GAExC8H,EAAO6J,UAAUrO,IAAI,qBAAqBoJ,KAC1C,MAAM4B,EAAgBtS,KAAK6R,UAAUzF,MAAMkG,cAI3CA,EAAcsD,MAAMC,UAAY,UAClB,WAAVnF,GAKF5E,EAAO8J,MAAMjF,OAAS,KACtB2B,EAAcwD,aAAahK,EAAQwG,EAAcyD,cAKjDjK,EAAO8J,MAAMjF,OAAS,IACtB2B,EAAc0D,YAAYlK,IAE5B,MAAM2F,EAAM3F,EAAO0B,WAAW,KAAM,CAAEtQ,OAAO,IACxCuU,GAILzR,KAAKmR,QAAQjO,IAAIwN,EAAOe,GACxBzR,KAAKyT,SAAS/C,IAJZ5E,EAAOmK,QAKX,CAEO,kBAAApD,CAAmBnC,EAAoB,OAC5C,MAAMe,EAAMzR,KAAKmR,QAAQC,IAAIV,GACzBe,IACFA,EAAI3F,OAAOmK,SACXjW,KAAKmR,QAAQ+E,OAAOxF,GAExB,CAEO,QAAAyF,CAASzF,GACd,OAAO1Q,KAAKmR,QAAQuE,IAAIhF,EAC1B,CAEQ,kBAAA0C,CAAmBpP,EAAM,UACR,QAAvBmI,EAAAnM,KAAKgT,0BAAkB,IAAA7G,GAAAA,EAAE8B,QACzBjO,KAAKgT,wBAAqBhL,EAG1B,MAAMoO,EAAS,GACTC,EAAYhJ,EAAcC,aAAatN,KAAKsR,SAAU8E,EAAQpS,GAC9DyN,EAAM4E,EAAU7I,WAAW,KAAM,CAAEtQ,OAAO,IAChD,IAAKuU,EAAK,OACV,MAAMC,EAAUrE,EAAcmE,gBAAgBC,EAAK2E,EAAQpS,GACrD8M,EAAM,IAAIzT,YAAYqU,EAAQtM,KAAK9H,QACnCgZ,GAAQ,EAAAvX,EAAAzD,YAAW,EAAG,EAAG,GACzBib,GAAQ,EAAAxX,EAAAzD,YAAW,IAAK,IAAK,KACnCwV,EAAInL,KAAK2Q,GACT,IAAK,IAAIhD,EAAI,EAAGA,EAAItP,IAAUsP,EAAG,CAC/B,MAAMkD,EAAQlD,EAAI,EACZnP,EAASmP,EAAI8C,EACnB,IAAK,IAAIK,EAAI,EAAGA,EAAIL,EAAQK,GAAK,EAC/B3F,EAAI3M,EAASsS,EAAID,GAASD,CAE9B,CACA9E,EAAIhE,aAAaiE,EAAS,EAAG,GAG7B,MAAMxR,EAASwW,OAAOxW,MAAQkW,EAAS,GAAK,IAAa,KACzDpW,KAAKiT,aAAe5F,EAAcC,aAAatN,KAAKsR,SAAUpR,EAAO8D,GACrE,MAAM2S,EAAO3W,KAAKiT,aAAazF,WAAW,KAAM,CAAEtQ,OAAO,IACzD,IAAKyZ,EAEH,YADA3W,KAAKiT,kBAAejL,GAGtB,IAAK,IAAIlK,EAAI,EAAGA,EAAIoC,EAAOpC,GAAKsY,EAC9BO,EAAKhC,UAAU0B,EAAWvY,EAAG,GAE/B,MAAM8Y,EAAc5W,KAAKiT,aACzB5F,EAAcQ,kBAAkB+I,GAAavV,KAAKwV,IAC5C7W,KAAKiT,eAAiB2D,EAAaC,SAAAA,EAAQ5I,QAC1CjO,KAAKgT,mBAAqB6D,IAC9B3I,MAAM,OACX,CAEA,YAAWoD,SACT,OAA+C,QAAxCnF,EAAAnM,KAAK6R,UAAUzF,MAAMC,2BAAmB,IAAAF,OAAA,EAAAA,EAAE2K,OAAOxF,QAC1D,2HC3aF,MAAAjI,EAAArK,EAAA,KASanE,EAAA8Q,kBAA+B,CAC1CzL,MAAO,EACP8D,OAAQ,IASV,MAAM+S,EAEJ,OAAWC,GACT,OAAIhX,KAAKiX,QAEQ,UAAZjX,KAAKkX,KACLlX,KAAKmX,gBAAkB,GAGrBnX,KAAKkX,IACd,CACA,OAAWF,CAAIlb,GAAiBkE,KAAKkX,KAAOpb,CAAO,CAEnD,kBAAWqb,GAET,OAAInX,KAAKiX,OACP,GAEe,UAATjX,KAAKkX,OAAoC,EACnD,CACA,kBAAWC,CAAerb,GACxBkE,KAAKkX,OAAQ,UACblX,KAAKkX,MAASpb,GAAS,GAAG,SAC5B,CAEA,kBAAWsb,GACT,OAAmB,SAAZpX,KAAKkX,IACd,CACA,kBAAWE,CAAetb,GACxBkE,KAAKkX,OAAQ,SACblX,KAAKkX,MAAgB,SAARpb,CACf,CAEA,0BAAWub,GACT,MAAMC,GAAgB,WAATtX,KAAKkX,OAAmC,GACrD,OAAII,EAAM,EACK,WAANA,EAEFA,CACT,CACA,0BAAWD,CAAuBvb,GAChCkE,KAAKkX,MAAQ,UACblX,KAAKkX,MAASpb,GAAS,GAAG,UAC5B,CAGA,SAAWyb,GACT,OAAOvX,KAAKiX,MACd,CACA,SAAWM,CAAMzb,GACfkE,KAAKiX,OAASnb,CAChB,CAEA,WAAAiE,CACEiX,EAAc,EACdO,EAAgB,EACTC,GAAU,EACV5D,GAAS,GADT5T,KAAAwX,QAAAA,EACAxX,KAAA4T,OAAAA,EAxDD5T,KAAAkX,KAAe,EA4CflX,KAAAiX,OAAiB,EAcvBjX,KAAKkX,KAAOF,EACZhX,KAAKiX,OAASM,CAChB,CAEO,KAAAE,GASL,OAAO,IAAIV,EAAmB/W,KAAKkX,KAAMlX,KAAKiX,OAAQjX,KAAKwX,QAASxX,KAAK4T,OAC3E,CAEO,OAAA8D,GACL,OAA0B,IAAnB1X,KAAKmX,gBAA0D,IAAhBnX,KAAKiX,SAAkC,IAAlBjX,KAAKwX,OAClF,EAEF,MAAMG,EAAc,IAAIZ,iBAUxB,MAkBE,WAAAhX,CACU8R,EACA3H,EACAhI,GAFAlC,KAAA6R,UAAAA,EACA7R,KAAAkK,UAAAA,EACAlK,KAAAkC,MAAAA,EAnBFlC,KAAA4X,QAAmC,IAAI7F,IAEvC/R,KAAA6X,QAAU,EAEV7X,KAAA8X,UAAY,EAEZ9X,KAAA+X,eAAgB,EAEhB/X,KAAAgY,iBAAkB,EAElBhY,KAAAiY,YAAsB,KAW5B,IACEjY,KAAKkY,SAASlY,KAAKkC,MAAMiW,aAC3B,CAAE,MAAOrQ,GACHA,aAAa1F,OACf7E,QAAQ6a,MAAMtQ,EAAEuQ,SAElB9a,QAAQC,KAAK,0BAA0BwC,KAAKsY,gBAC9C,CACAtY,KAAKuY,iBAAmB,CACtBxM,KAAM/L,KAAK6R,UAAU9F,KACrBC,KAAMhM,KAAK6R,UAAU7F,KAEzB,CAEO,OAAArE,GACL3H,KAAKkL,OACP,CAEO,KAAAA,SACL,IAAK,MAAM6J,KAAQ/U,KAAK4X,QAAQ9C,SACnB,QAAX3I,EAAA4I,EAAKyD,cAAM,IAAArM,GAAAA,EAAExE,UAIf3H,KAAK4X,QAAQ9O,QACb9I,KAAKkK,UAAUuJ,UACjB,CAEO,QAAA6E,GACL,OAA0B,EAAnBtY,KAAKiY,YAAkB,GAChC,CAEO,QAAAC,CAASpc,GACd,GAAIA,EAAQ,IAAOA,EAAQ,IACzB,MAAM2c,WAAW,qEAEnBzY,KAAKiY,YAAenc,EAAQ,EAAI,MAAa,EAC7CkE,KAAK0Y,aAAa,EACpB,CAEO,QAAAC,GACL,OAAiC,EAA1B3Y,KAAK4Y,mBAAyB,GACvC,CAEQ,gBAAAA,GACN,IAAIC,EAAe,EACnB,IAAK,MAAM9D,KAAQ/U,KAAK4X,QAAQ9C,SAC1BC,EAAKI,OACP0D,GAAgB9D,EAAKI,KAAKjV,MAAQ6U,EAAKI,KAAKnR,OACxC+Q,EAAKd,QAAUc,EAAKd,SAAWc,EAAKI,OACtC0D,GAAgB9D,EAAKd,OAAO/T,MAAQ6U,EAAKd,OAAOjQ,SAItD,OAAO6U,CACT,CAEQ,OAAAC,CAAQC,SACd,MAAMhE,EAAO/U,KAAK4X,QAAQxG,IAAI2H,GACzBhE,IACL/U,KAAK4X,QAAQ1B,OAAO6C,GAEhBjC,OAAOkC,aAAejE,EAAKI,gBAAgB6D,aAC7CjE,EAAKI,KAAKlH,QAEO,QAAnB9B,EAAAnM,KAAKiZ,sBAAc,IAAA9M,GAAAA,EAAA3E,KAAnBxH,KAAsB+Y,GACxB,CAKO,aAAAG,SAEL,MAAMC,EAAO,GACb,IAAK,MAAOJ,EAAIhE,KAAS/U,KAAK4X,QAAQwB,UACZ,cAApBrE,EAAKsE,aACI,QAAXlN,EAAA4I,EAAKyD,cAAM,IAAArM,GAAAA,EAAExE,UACbwR,EAAKza,KAAKqa,IAGd,IAAK,MAAMA,KAAMI,EACfnZ,KAAK8Y,QAAQC,GAGf/Y,KAAKgY,iBAAkB,EACvBhY,KAAK+X,eAAgB,CACvB,CAMO,WAAAuB,CAAYP,SACjB,MAAMhE,EAAO/U,KAAK4X,QAAQxG,IAAI2H,GAC1BhE,IACS,QAAX5I,EAAA4I,EAAKyD,cAAM,IAAArM,GAAAA,EAAExE,UACb3H,KAAK8Y,QAAQC,GAEjB,CAaO,QAAArL,CAASmD,EAAsC/P,WAEpDd,KAAK0Y,aAAa7H,EAAI3Q,MAAQ2Q,EAAI7M,QAGlC,IAAImP,EAAWnT,KAAKkK,UAAUiJ,UACN,IAApBA,EAASjT,QAAqC,IAArBiT,EAASnP,SACpCmP,EAAWtY,EAAA8Q,mBAEb,MAAMI,EAAOhQ,KAAKuI,KAAKuM,EAAI3Q,MAAQiT,EAASjT,OACtC8L,EAAOjQ,KAAKuI,KAAKuM,EAAI7M,OAASmP,EAASnP,QAEvCwT,IAAYxX,KAAK6X,QAEjBva,EAAS0C,KAAK6R,UAAUzF,MAAM9O,OAC9Bic,EAAWvZ,KAAK6R,UAAU9F,KAC1ByN,EAAWxZ,KAAK6R,UAAU7F,KAC1ByN,EAAUnc,EAAOmZ,EACjBiD,EAAUpc,EAAOgW,EACvB,IAAInP,EAASsV,EACTE,EAAY,EAEX7Y,EAAK2P,YACRnT,EAAOmZ,EAAI,EACXnZ,EAAOgW,EAAI,EACXnP,EAAS,GAGXnE,KAAK6R,UAAUzF,MAAMwN,cAAcC,iBAAiBC,UAAUxc,EAAOgW,GACrE,IAAK,IAAIQ,EAAM,EAAGA,EAAM9H,IAAQ8H,EAAK,CACnC,MAAMiG,EAAOzc,EAAO0c,MAAM5I,IAAI9T,EAAOgW,EAAIhW,EAAO2c,OAChD,IAAK,IAAIpG,EAAM,EAAGA,EAAM9H,KAClB5H,EAAS0P,GAAO0F,KADU1F,EAE9B7T,KAAKka,aAAaH,EAAwB5V,EAAS0P,EAAK2D,EAAS1D,EAAM/H,EAAO8H,GAC9E8F,IAEF,GAAI7Y,EAAK2P,UACHqD,EAAM9H,EAAO,GAAGhM,KAAK6R,UAAUzF,MAAMwN,cAAcO,gBAEvD,KAAM7c,EAAOgW,GAAKkG,EAAU,MAE9Blc,EAAOmZ,EAAItS,CACb,CACAnE,KAAK6R,UAAUzF,MAAMwN,cAAcC,iBAAiBC,UAAUxc,EAAOgW,GAGjExS,EAAK2P,UACgB,QAAnB3P,EAAK8P,UACPtT,EAAOmZ,EAAI1a,KAAKE,IAAIkI,EAAS4H,EAAMwN,GAEnCjc,EAAOmZ,EAAItS,GAGb7G,EAAOmZ,EAAIgD,EACXnc,EAAOgW,EAAIoG,GAIb,MAAMP,EAAO,GACb,IAAK,MAAOJ,EAAIhE,KAAS/U,KAAK4X,QAAQwB,UAChCrE,EAAK4E,UAAY,IACR,QAAXxN,EAAA4I,EAAKyD,cAAM,IAAArM,GAAAA,EAAExE,UACbwR,EAAKza,KAAKqa,IAGd,IAAK,MAAMA,KAAMI,EACfnZ,KAAK8Y,QAAQC,GAKf,MAAMqB,EAAYpa,KAAK6R,UAAUwI,eAAe,GAChDD,SAAAA,EAAWE,UAAU,KACNta,KAAK4X,QAAQxG,IAAIoG,IAE5BxX,KAAK8Y,QAAQtB,KAMyB,cAAtCxX,KAAK6R,UAAUvU,OAAOid,OAAO1Q,MAC/B7J,KAAKwa,oBAIP,MAAM7G,EAAsB,CAC1BwB,KAAMtE,EACNqE,aAAc/B,EACdc,OAAQpD,EACRuD,eAAc5X,OAAA2F,OAAA,GAAOgR,GACrBqF,OAAQ4B,QAAapS,EACrB2R,YACAN,WAAYrZ,KAAK6R,UAAUvU,OAAOid,OAAO1Q,KACzC6G,MAAO5P,EAAK4P,MACZC,OAAQ7P,EAAK6P,QAMf,OAFA3Q,KAAK4X,QAAQ1U,IAAIsU,EAAS7D,GACT,QAAjBpG,EAAAvN,KAAKya,oBAAY,IAAAlN,GAAAA,EAAA/F,KAAjBxH,MACOwX,CACT,CAQO,MAAAkD,CAAOC,WAEZ,IAAIC,GAAe,EACfC,GAAkB,EACtB,IAAK,MAAM9F,KAAQ/U,KAAK4X,QAAQ9C,SAM9B,GALmB,WAAfC,EAAKrE,MACPmK,GAAkB,EAElBD,GAAe,EAEbA,GAAgBC,EAAiB,MAIvC,GAAID,IAAiB5a,KAAKkK,UAAUiM,SAAS,SAC3CnW,KAAKkK,UAAUuL,iBAAiB,QAC3BzV,KAAKkK,UAAUiM,SAAS,QAAQ,OAUvC,GARI0E,IAAoB7a,KAAKkK,UAAUiM,SAAS,WAC9CnW,KAAKkK,UAAUuL,iBAAiB,UAIlCzV,KAAKkK,UAAUwI,iBAGV1S,KAAK4X,QAAQ7N,KAYhB,OAXK/J,KAAK+X,gBACR/X,KAAKkK,UAAUuJ,WACfzT,KAAK+X,eAAgB,EACrB/X,KAAKgY,iBAAkB,GAErBhY,KAAKkK,UAAUiM,SAAS,QAC1BnW,KAAKkK,UAAU2I,mBAAmB,YAEhC7S,KAAKkK,UAAUiM,SAAS,WAC1BnW,KAAKkK,UAAU2I,mBAAmB,YAMjC+H,GAAgB5a,KAAKkK,UAAUiM,SAAS,SAC3CnW,KAAKkK,UAAUuJ,SAAS,OACxBzT,KAAKkK,UAAU2I,mBAAmB,SAE/BgI,GAAmB7a,KAAKkK,UAAUiM,SAAS,YAC9CnW,KAAKkK,UAAUuJ,SAAS,UACxBzT,KAAKkK,UAAU2I,mBAAmB,WAIhC7S,KAAKgY,kBACPhY,KAAKkK,UAAUuJ,WACfzT,KAAK+X,eAAgB,EACrB/X,KAAKgY,iBAAkB,GAGzB,MAAM3S,MAAEA,EAAKC,IAAEA,GAAQqV,EACjBrd,EAAS0C,KAAK6R,UAAUzF,MAAM9O,OAC9ByO,EAAO/L,KAAK6R,UAAUzF,MAAML,KAGlC/L,KAAKkK,UAAUmJ,WAAWhO,EAAOC,GAGjC,MAAMwV,EAAgG,GAChGC,EAAkE,GAGxE,IAAK,IAAIjH,EAAMzO,EAAOyO,GAAOxO,IAAOwO,EAAK,CACvC,MAAMiG,EAAOzc,EAAO0c,MAAM5I,IAAI0C,EAAMxW,EAAO0d,OAC3C,IAAKjB,EAAM,OACX,IAAK,IAAIlG,EAAM,EAAGA,EAAM9H,IAAQ8H,EAC9B,GAAmB,UAAfkG,EAAKkB,MAAMpH,GAA6B,CAC1C,IAAI/L,EAAiD,QAAhDqE,EAAwB4N,EAAKmB,eAAerH,UAAI,IAAA1H,EAAAA,EAAIwL,EACzD,MAAMH,EAAU1P,EAAE0P,QAClB,QAAgBxP,IAAZwP,IAAsC,IAAbA,EAC3B,SAEF,MAAM7D,EAAU3T,KAAK4X,QAAQxG,IAAIoG,GACjC,IAAkB,IAAd1P,EAAE8L,OAAe,CACnB,MAAMuH,EAAYrT,EAAE8L,OACdwH,EAAWvH,EACjB,IAAIE,EAAQ,EAOZ,OACIF,EAAM9H,GACW,UAAfgO,EAAKkB,MAAMpH,KACX/L,EAA4B,QAA3ByF,EAAGwM,EAAKmB,eAAerH,UAAI,IAAAtG,EAAAA,EAAIoK,IAChC7P,EAAE0P,UAAYA,GACd1P,EAAE8L,SAAWuH,EAAYpH,GAE7BA,IAEFF,IACIF,EACEA,EAAQM,QACV6G,EAAUpc,KAAK,CAAEiV,UAASC,OAAQuH,EAAWtH,IAAKuH,EAAUtH,MAAKC,UAE1D/T,KAAKkC,MAAMgR,iBACpB6H,EAAiBrc,KAAK,CAAEmV,IAAKuH,EAAUtH,MAAKC,UAE9C/T,KAAK+X,eAAgB,CACvB,CACF,CAEJ,CAGA+C,EAAUO,KAAK,CAAC3f,EAAGD,IAAMC,EAAEiY,QAAQhD,OAASlV,EAAEkY,QAAQhD,QAGtD,IAAK,MAAMnJ,KAAQuT,EACjB/a,KAAKkK,UAAU2K,gBAAgBrN,EAAKqM,IAAKrM,EAAKsM,IAAKtM,EAAKuM,OAI1D,IAAK,MAAMvM,KAAQsT,EACjB9a,KAAKkK,UAAUwJ,KAAKlM,EAAKmM,QAASnM,EAAKoM,OAAQpM,EAAKqM,IAAKrM,EAAKsM,IAAKtM,EAAKuM,MAE5E,CAEO,cAAAuH,CAAe1O,WAEpB,IAAK5M,KAAK4X,QAAQ7N,KAEhB,YADA/J,KAAKuY,iBAAmB3L,GAM1B,GAAI5M,KAAKuY,iBAAiBxM,MAAQa,EAAQb,KAExC,YADA/L,KAAKuY,iBAAmB3L,GAK1B,MAAMtP,EAAS0C,KAAK6R,UAAUzF,MAAM9O,OAC9B0O,EAAO1O,EAAO0c,MAAMjc,OACpBwd,EAASvb,KAAKuY,iBAAiBxM,KAAO,EAC5C,IAAK,IAAI+H,EAAM,EAAGA,EAAM9H,IAAQ8H,EAAK,CACnC,MAAMiG,EAAOzc,EAAO0c,MAAM5I,IAAI0C,GAC9B,GAAsB,UAAlBiG,EAAKkB,MAAMM,GAAgC,CAC7C,MAAMzT,EAAoD,QAAnDyF,EAAwBwM,EAAKmB,eAAeK,UAAO,IAAAhO,EAAAA,EAAIoK,EACxDH,EAAU1P,EAAE0P,QAClB,QAAgBxP,IAAZwP,IAAsC,IAAbA,EAC3B,SAEF,MAAM7D,EAAU3T,KAAK4X,QAAQxG,IAAIoG,GACjC,IAAK7D,EACH,SAGF,MAAM6H,EAAczf,KAAKuI,OAAoB,QAAd6H,EAAAwH,EAAQM,cAAM,IAAA9H,OAAA,EAAAA,EAAEjM,QAAS,GAAKyT,EAAQS,eAAelU,OACpF,GAAK4H,EAAE8L,OAAS4H,EAAe,GAAKA,EAClC,SAGF,IAAIC,GAAU,EACd,IAAK,IAAIC,EAAWH,EAAS,EAAGG,EAAW9O,EAAQb,OAAQ2P,EACzD,GAAmD,QAA/C3B,EAAK4B,MAAc,EAARD,EAAoB,GAA6C,CAC9ED,GAAU,EACV,KACF,CAEF,GAAIA,EACF,SAGF,MAAMnW,EAAMvJ,KAAKE,IAAI2Q,EAAQb,KAAMyP,EAAe1T,EAAE8L,OAAS4H,EAAeD,GAC5E,IAAIK,EAAW9T,EAAE8L,OACjB,IAAK,IAAIiI,EAAYN,EAAS,EAAGM,EAAYvW,IAAOuW,EAClD7b,KAAKka,aAAaH,EAAwB8B,EAAWrE,IAAWoE,GAChEjI,EAAQgG,WAEZ,CACF,CAEA3Z,KAAKuY,iBAAmB3L,CAC1B,CAKO,oBAAAkP,CAAqBrF,EAAWnD,aACrC,MACMyG,EADS/Z,KAAK6R,UAAUzF,MAAM9O,OAChB0c,MAAM5I,IAAIkC,GAC9B,GAAIyG,GAAqB,UAAbA,EAAKkB,MAAMxE,GAA2B,CAChD,MAAM3O,EAA+C,QAA9CoE,EAAwB6N,EAAKmB,eAAezE,UAAE,IAAAvK,EAAAA,EAAIyL,EACzD,GAAI7P,EAAE0P,UAA0B,IAAf1P,EAAE0P,QAAgB,CACjC,MAAMrC,EAAkC,QAA3BhJ,EAAAnM,KAAK4X,QAAQxG,IAAItJ,EAAE0P,gBAAQ,IAAArL,OAAA,EAAAA,EAAEgJ,KAC1C,GAAI2B,OAAOkC,aAAe7D,aAAgB6D,YAAa,CACrD,MAAMlN,EAASzC,EAAAgE,cAAcC,aAAawJ,OAAOxF,SAAU6D,EAAKjV,MAAOiV,EAAKnR,QAE5E,OADuB,QAAvBuJ,EAAAzB,EAAO0B,WAAW,aAAK,IAAAD,GAAAA,EAAEoH,UAAUQ,EAAM,EAAG,EAAGA,EAAKjV,MAAOiV,EAAKnR,QACzD8H,CACT,CACA,OAAOqJ,CACT,CACF,CACF,CAKO,uBAAA4G,CAAwBtF,EAAWnD,SACxC,MACMyG,EADS/Z,KAAK6R,UAAUzF,MAAM9O,OAChB0c,MAAM5I,IAAIkC,GAC9B,GAAIyG,GAAqB,UAAbA,EAAKkB,MAAMxE,GAA2B,CAChD,MAAM3O,EAA+C,QAA9CqE,EAAwB4N,EAAKmB,eAAezE,UAAE,IAAAtK,EAAAA,EAAIwL,EACzD,GAAI7P,EAAE0P,UAA0B,IAAf1P,EAAE0P,UAAgC,IAAd1P,EAAE8L,OAAe,CACpD,MAAMmB,EAAO/U,KAAK4X,QAAQxG,IAAItJ,EAAE0P,SAChC,GAAIzC,EACF,OAAO/U,KAAKkK,UAAU0K,YAAYG,EAAMjN,EAAE8L,OAE9C,CACF,CACF,CAIQ,YAAA8E,CAAasD,SACnB,MAAMC,EAAOjc,KAAK4Y,mBAClB,IAAIsD,EAAUD,EACd,KAAOjc,KAAKiY,YAAciE,EAAUF,GAAQhc,KAAK4X,QAAQ7N,MAAM,CAC7D,MAAMgL,EAAO/U,KAAK4X,QAAQxG,MAAMpR,KAAK8X,WACjC/C,GAAQA,EAAKI,OACf+G,GAAWnH,EAAKI,KAAKjV,MAAQ6U,EAAKI,KAAKnR,OACnC+Q,EAAKd,QAAUc,EAAKI,OAASJ,EAAKd,SACpCiI,GAAWnH,EAAKd,OAAO/T,MAAQ6U,EAAKd,OAAOjQ,QAElC,QAAXmI,EAAA4I,EAAKyD,cAAM,IAAArM,GAAAA,EAAExE,UACb3H,KAAK8Y,QAAQ9Y,KAAK8X,WAEtB,CACA,OAAOmE,EAAOC,CAChB,CAEQ,YAAAhC,CAAaH,EAAsBtD,EAAWe,EAAiB5D,GACrE,GAAuC,UAAnCmG,EAAK4B,MAAO,EAADlF,EAAa,GAAoC,CAC9D,MAAM0F,EAAMpC,EAAKmB,eAAezE,GAChC,GAAI0F,EAAK,CACP,QAAoBnU,IAAhBmU,EAAI3E,QAAuB,CAI7B,MAAM4E,EAAUpc,KAAK4X,QAAQxG,IAAI+K,EAAI3E,SAOrC,OANI4E,GAEFA,EAAQzC,YAEVwC,EAAI3E,QAAUA,OACd2E,EAAIvI,OAASA,EAEf,CAGA,YADAmG,EAAKmB,eAAezE,GAAK,IAAIM,EAAmBoF,EAAInF,IAAKmF,EAAI5E,MAAOC,EAAS5D,GAE/E,CACF,CAEAmG,EAAK4B,MAAO,EAADlF,EAAa,IAAW,UACnCsD,EAAKmB,eAAezE,GAAK,IAAIM,EAAmB,EAAG,EAAGS,EAAS5D,EACjE,CAEQ,iBAAA4G,WAEN,IAAK,MAAMzF,KAAQ/U,KAAK4X,QAAQ9C,SACN,cAApBC,EAAKsE,aACPtE,EAAK4E,UAAY,GAIrB,MAAMrc,EAAS0C,KAAK6R,UAAUzF,MAAM9O,OACpC,IAAK,IAAIgW,EAAI,EAAGA,EAAItT,KAAK6R,UAAU7F,OAAQsH,EAAG,CAC5C,MAAMyG,EAAOzc,EAAO0c,MAAM5I,IAAIkC,GAC9B,GAAKyG,EAGL,IAAK,IAAItD,EAAI,EAAGA,EAAIzW,KAAK6R,UAAU9F,OAAQ0K,EACzC,GAAuC,UAAnCsD,EAAK4B,MAAO,EAADlF,EAAa,GAAoC,CAC9D,MAAM4F,EAA8B,QAAtBlQ,EAAA4N,EAAKmB,eAAezE,UAAE,IAAAtK,OAAA,EAAAA,EAAEqL,QACtC,GAAI6E,EAAO,CACT,MAAMtH,EAAO/U,KAAK4X,QAAQxG,IAAIiL,GAC1BtH,GACFA,EAAK4E,WAET,CACF,CAEJ,CAEA,MAAMR,EAAO,GACb,IAAK,MAAOJ,EAAIhE,KAAS/U,KAAK4X,QAAQwB,UACZ,cAApBrE,EAAKsE,YAA+BtE,EAAK4E,YAChC,QAAXpM,EAAAwH,EAAKyD,cAAM,IAAAjL,GAAAA,EAAE5F,UACbwR,EAAKza,KAAKqa,IAGd,IAAK,MAAMA,KAAMI,EACfnZ,KAAK8Y,QAAQC,EAEjB,sFCnpBF,MAAAha,EAAAC,EAAA,KAEAqK,EAAArK,EAAA,KAEAsd,EAAAtd,EAAA,KAMMud,EAAkBxd,EAAAnC,iBAkIxB,SAAS4f,EAAU/e,GACjB,OAAIsB,EAAA5B,WAAmBM,GACP,IAARA,IAAiB,IAAMA,IAAU,EAAI,MAAS,IAAMA,IAAU,GAAK,MAAS,EAAIA,IAAU,GAAK,GACzG,CApIA8e,EAAgBrZ,IAAInE,EAAAjC,oCAGpB,MAKE,WAAAiD,CACmBmC,EACAiI,EACAC,GAFApK,KAAAkC,MAAAA,EACAlC,KAAAmK,SAAAA,EACAnK,KAAAoK,cAAAA,EAPXpK,KAAAyc,MAAQ,EACRzc,KAAAsK,UAAW,GAQjB,EAAAgS,EAAAxd,cAAa,CACX0B,YAAqC,EAAxBR,KAAKkC,MAAM8K,WACxBtP,QAAS6e,EACT5b,aAAcX,KAAKkC,MAAMwa,oBACxBrb,KAAKlD,GAAK6B,KAAK+K,KAAO5M,EAC3B,CAEO,KAAA+M,GAODlL,KAAK+K,OACP/K,KAAK+K,KAAK9G,UAETjE,KAAK+K,KAAa/H,SAAS2C,KAAK,GACjC3F,KAAK+K,KAAK1H,KAAK,EAAGkZ,EAAiBvc,KAAKkC,MAAMwa,mBAElD,CAEO,IAAAC,CAAKC,SAGV,GAFA5c,KAAKyc,MAAQ,EACbzc,KAAKsK,UAAW,EACZtK,KAAK+K,KAAM,CACb,MAAMrK,EAAiC,IAArBkc,EAAOA,OAAO,GAAW,EA4DjD,SAAyBC,EAAqBC,GAC5C,IAAIC,EAAK,EACT,IAAKD,EAGH,OAAOC,EAET,GAAIF,EAAKG,YACP,GAAIH,EAAKI,cACPF,EAAKP,EAAUM,EAAOI,WAAWC,WAC5B,GAAIN,EAAKO,UAAW,CACzB,MAAMC,EAAKR,EAAK9c,YAAqCud,WAAWT,EAAKU,cACrER,GAAK,EAAAhe,EAAAzD,eAAc+hB,EACrB,MACEN,EAAKP,EAAUM,EAAOU,KAAKX,EAAKU,cAAcJ,WAGhD,GAAIN,EAAKY,cACPV,EAAKP,EAAUM,EAAOY,WAAWP,WAC5B,GAAIN,EAAKc,UAAW,CACzB,MAAMN,EAAKR,EAAK9c,YAAqCud,WAAWT,EAAKe,cACrEb,GAAK,EAAAhe,EAAAzD,eAAc+hB,EACrB,MACEN,EAAKP,EAAUM,EAAOU,KAAKX,EAAKe,cAAcT,MAGlD,OAAOJ,CACT,CAvFqDc,CAC7C7d,KAAKoK,cAAcgC,MAAMwN,cAAckE,aACD,QAAtC3R,EAAAnM,KAAKoK,cAAcgC,MAAM2R,qBAAa,IAAA5R,OAAA,EAAAA,EAAE2Q,QAC1C9c,KAAK+K,KAAK1H,KAAK3C,EAAW,KAAMV,KAAKkC,MAAMwa,kBAC7C,CACF,CAEO,GAAAvR,CAAI/F,EAAmBC,EAAeC,GAC3C,IAAItF,KAAKsK,UAAatK,KAAK+K,KAA3B,CAIA,GADA/K,KAAKyc,OAASnX,EAAMD,EAChBrF,KAAKyc,MAAQzc,KAAKkC,MAAM8b,eAI1B,OAHAzgB,QAAQC,KAAK,kCACbwC,KAAKsK,UAAW,OAChBtK,KAAK+K,KAAK9G,UAGZ,IACEjE,KAAK+K,KAAKnM,OAAOwG,EAAMC,EAAOC,EAChC,CAAE,MAAOwC,GACPvK,QAAQC,KAAK,uCAAuCsK,KACpD9H,KAAKsK,UAAW,EAChBtK,KAAK+K,KAAK9G,SACZ,CAdA,CAeF,CAEO,MAAAga,CAAOzS,SACZ,GAAIxL,KAAKsK,WAAakB,IAAYxL,KAAK+K,KACrC,OAAO,EAGT,MAAM7K,EAAQF,KAAK+K,KAAK7K,MAClB8D,EAAShE,KAAK+K,KAAK/G,OAGzB,IAAK9D,IAAU8D,EAIb,OAHIA,GACFhE,KAAKmK,SAAS+T,cAAcla,IAEvB,EAGT,MAAM8H,EAASzC,EAAAgE,cAAcC,kBAAatF,EAAW9H,EAAO8D,GAM5D,OALuB,QAAvBmI,EAAAL,EAAO0B,WAAW,aAAK,IAAArB,GAAAA,EAAEsB,aAAa,IAAIN,UAAUnN,KAAK+K,KAAK9E,MAAyC/F,EAAO8D,GAAS,EAAG,GACtHhE,KAAK+K,KAAKnG,YAxFM,SAyFlB5E,KAAK+K,KAAK9G,UAEZjE,KAAKmK,SAASuD,SAAS5B,IAChB,CACT,2FCtGF,MAAAxC,EAAAtK,EAAA,yBAWA,MAEE,WAAAe,CACmBoK,EACAjI,EACAgI,EACA2H,GAHA7R,KAAAmK,SAAAA,EACAnK,KAAAkC,MAAAA,EACAlC,KAAAkK,UAAAA,EACAlK,KAAA6R,UAAAA,EALX7R,KAAAwQ,cAA+B,CAAEC,WAAW,EAAMC,MAAO,MAAOC,OAAQ,EAAGC,UAAW,QAM3F,CAMI,QAAAlD,CAASmD,GACd7Q,KAAKwQ,cAAcC,UAAYzQ,KAAKkC,MAAMic,eAC1Cne,KAAKmK,SAASuD,SAASmD,EAAK7Q,KAAKwQ,cACnC,CAOO,aAAA0N,CAAcla,GACnB,GAAIhE,KAAKkC,MAAMic,eAAgB,CAC7B,IAAIhL,EAAWnT,KAAKkK,UAAUiJ,UACN,IAApBA,EAASjT,QAAqC,IAArBiT,EAASnP,SACpCmP,EAAW7J,EAAAqC,mBAEb,MAAMK,EAAOjQ,KAAKuI,KAAKN,EAASmP,EAASnP,QACzC,IAAK,IAAIlG,EAAI,EAAGA,EAAIkO,IAAQlO,EAC1BkC,KAAK6R,UAAUzF,MAAMwN,cAAcO,UAEvC,CACF,iLC3CF,MAAA9Q,EAAArK,EAAA,KACAsK,EAAAtK,EAAA,KACA2K,EAAA3K,EAAA,KAEAuK,EAAAC,EAAAxK,EAAA,MACAof,EAAApf,EAAA,4BA0BA,MAiCE,WAAAe,CACmBmC,EACAgI,EACAmU,EACAjU,GAHApK,KAAAkC,MAAAA,EACAlC,KAAAkK,UAAAA,EACAlK,KAAAqe,cAAAA,EACAre,KAAAoK,cAAAA,EApCXpK,KAAAsK,UAAW,EACXtK,KAAAqK,YAAc,EACdrK,KAAAse,cAAe,EAEfte,KAAAue,eAAuC,KAOvCve,KAAAwe,gBAAiB,EAGjBxe,KAAAye,aAAe,IAAIphB,YAAW,KAC9B2C,KAAA0e,eAAiB,EAGjB1e,KAAA2e,kBAAoB,EACpB3e,KAAA4e,kBAAoB,EAGpB5e,KAAA6e,eAAuC,KAIvC7e,KAAA8e,sBAA2D,IAAI/M,IAarE/R,KAAK+e,iBAAmBhjB,KAAKuI,KAAiC,EAA5BtE,KAAKkC,MAAM8c,eAAqB,GAElEhf,KAAKif,qBAAuBljB,KAAKE,IAAG,QAAiC+D,KAAK+e,iBAC5E,CAEO,KAAA7T,GACLlL,KAAKqK,cACLrK,KAAKkf,qBACDlf,KAAKue,iBACPve,KAAKue,eAAeta,UACpBjE,KAAKue,eAAiB,MAExBve,KAAKqe,cAAcnT,OACrB,CAEO,OAAAvD,GACL3H,KAAKkL,OACP,CAEQ,mBAAAiU,CAAoB5J,GAC1BvV,KAAK8e,sBAAsB5I,OAAOX,GAC9BvV,KAAKof,kBAAoB7J,IAC3BvV,KAAKof,qBAAkBpX,EAE3B,CAEQ,kBAAAkX,GACN,IAAK,MAAMG,KAAWrf,KAAK8e,sBAAsBhK,SAC/CuK,EAAQC,QAAQrb,UAElBjE,KAAK8e,sBAAsBhW,QAC3B9I,KAAKof,qBAAkBpX,CACzB,CAEO,KAAA3C,GACLrF,KAAKsK,UAAW,EAChBtK,KAAKse,cAAe,EACpBte,KAAKwe,gBAAiB,EACtBxe,KAAK0e,eAAiB,EACtB1e,KAAK6e,eAAiB,KAEtB7e,KAAK2e,kBAAoB3e,KAAK+e,iBAC9B/e,KAAK4e,kBAAoB,EACzB5e,KAAKue,eAAiB,IACxB,CAEO,GAAApT,CAAI/F,EAAmBC,EAAeC,SAC3C,IAAItF,KAAKsK,SAET,GAAKtK,KAAKwe,eAEH,CAEL,IAAIe,EAAaja,EACjB,IAAK,IAAIxH,EAAIuH,EAAOvH,EAAIwH,EAAKxH,IAC3B,GAAW,KAAPsH,EAAKtH,GAA4B,CACnCkC,KAAKwe,gBAAiB,EACtBe,EAAazhB,EACb,KACF,CAIF,MAAM0hB,EAAaD,EAAala,EAChC,GAAIrF,KAAK0e,eAAiBc,EAAU,IAElC,YADAxf,KAAKsK,UAAW,GAMlB,GAHAtK,KAAKye,aAAavb,IAAIkC,EAAKX,SAASY,EAAOka,GAAavf,KAAK0e,gBAC7D1e,KAAK0e,gBAAkBc,GAElBxf,KAAKwe,eAAgB,CAKxB,GAHAxe,KAAK6e,gBAAiB,EAAAT,EAAAqB,mBAAkBzf,KAAK0f,gCAGd1X,IAA3BhI,KAAK6e,eAAe9F,SAAwD/Q,IAApChI,KAAK6e,eAAec,YAG9D,OAFA3f,KAAK4f,cAAc5f,KAAK6e,eAAe9F,GAAI,0CAAoE,QAA3B5M,EAAEnM,KAAK6e,eAAegB,aAAK,IAAA1T,EAAAA,EAAI,QACnHnM,KAAKsK,UAAW,GAKlB,GAA8B,MAA1BtK,KAAK6e,eAAeiB,OACtB,OAIF,MAAMC,EAAeR,EAAa,EAC9BQ,EAAeza,GACjBtF,KAAKggB,eAAe5a,EAAM2a,EAAcza,EAE5C,CACF,MA3CEtF,KAAKggB,eAAe5a,EAAMC,EAAOC,EA4CrC,CAGQ,cAAA0a,CAAe5a,EAAmBC,EAAeC,uBACvD,GAAItF,KAAKsK,SAAU,OAKnB,MAAM2V,EAA4D,QAAlD3R,EAA0B,QAA1BpC,EAAsB,QAAnBC,EAAAnM,KAAK6e,sBAAc,IAAA1S,OAAA,EAAAA,EAAE4M,UAAE,IAAA7M,EAAAA,EAAIlM,KAAKof,uBAAe,IAAA9Q,EAAAA,EAAI,EAChE+Q,EAAUrf,KAAK8e,sBAAsB1N,IAAI6O,GACzCC,EAA+C,QAA5B1M,EAAG6L,aAAO,EAAPA,EAASc,wBAAgB,IAAA3M,EAAAA,EAAI,EAGzD,GAFAxT,KAAK4e,mBAAqBtZ,EAAMD,EACF6a,EAAsBlgB,KAAK4e,kBAC7B5e,KAAK2e,kBAAmB,CAClD,MAAMyB,EAAsC,QAAtBC,EAAGrgB,KAAKue,sBAAc,IAAA8B,EAAAA,EAAIhB,aAAO,EAAPA,EAASC,QASzD,OARIc,GACFA,EAAiBnc,UAEnBjE,KAAKue,eAAiB,KAClBc,GACFrf,KAAKmf,oBAAoBc,QAE3BjgB,KAAKsK,UAAW,EAElB,CAEA,IAAItK,KAAKse,aAAT,CAKA,IAHIe,aAAO,EAAPA,EAASC,WAAYtf,KAAKue,iBAC5Bve,KAAKue,eAAiBc,EAAQC,UAE3Btf,KAAKue,eAAgB,CAExB,MAAM+B,EAAkBtgB,KAAK+e,iBAAmB,OAChD,GAAIuB,EAA4C,IAA1BtgB,KAAKkC,MAAMiW,aAK/B,OAJAnY,KAAKsK,UAAW,YACgBtC,KAAT,QAAnBuF,EAAAvN,KAAK6e,sBAAc,IAAAtR,OAAA,EAAAA,EAAEwL,KACvB/Y,KAAK4f,cAAc5f,KAAK6e,eAAe9F,GAAI,uCAAiE,QAA3BwH,EAAEvgB,KAAK6e,eAAegB,aAAK,IAAAU,EAAAA,EAAI,IAIpH,MAAMC,EAAazkB,KAAKC,IAAI,EAAGD,KAAKmR,MAAgC,IAA1BlN,KAAKkC,MAAMiW,aAAyBmI,IAC9E,KAAOtgB,KAAK8e,sBAAsB/U,MAAQyW,GAAY,CACpD,MAAMC,EAASzgB,KAAK8e,sBAAsB1F,UAAUsH,OAAO5kB,MAC3D,IAAK2kB,EAAQ,MACbA,EAAO,GAAGnB,QAAQrb,UAClBjE,KAAKmf,oBAAoBsB,EAAO,SACPzY,IAArByY,EAAO,GAAGE,IAAI5H,IAChB/Y,KAAK4f,cAAca,EAAO,GAAGE,IAAI5H,GAAI,uCAA2D,QAArB6H,EAAEH,EAAO,GAAGE,IAAId,aAAK,IAAAe,EAAAA,EAAI,EAExG,CACA5gB,KAAKue,eAAiB,IAAIhV,EAAAyB,QAAa,QAA8BhL,KAAK+e,iBAAkB/e,KAAKif,sBACjGjf,KAAKue,eAAelb,MACtB,CA/Le,IAiMXrD,KAAKue,eAAepT,IAAI/F,EAAKX,SAASY,EAAOC,MAC/CtF,KAAKue,eAAeta,UACpBjE,KAAKue,eAAiB,KACtBve,KAAKse,cAAe,EAChBe,GACFrf,KAAKmf,oBAAoBc,GAlCN,CAqCzB,CAEO,GAAA3a,CAAIkG,WACT,GAAIxL,KAAKsK,WAAakB,EAKpB,OAJIxL,KAAKue,iBACPve,KAAKue,eAAeta,UACpBjE,KAAKue,eAAiB,OAEjB,EAIT,GAAIve,KAAKwe,eACP,OAAOxe,KAAK6gB,0BAId,MAAMF,EAAM3gB,KAAK6e,eAGjB,GAAc,MAAV8B,EAAIb,OACN,OAAO9f,KAAK8gB,cAAcH,GAI5B,MAAMV,EAA2C,QAAjC1S,EAAS,QAATpB,EAAGwU,EAAI5H,UAAE,IAAA5M,EAAAA,EAAInM,KAAKof,uBAAe,IAAA7R,EAAAA,EAAI,EAC/CwT,EAA4B,IAAbJ,EAAIK,KACnB3B,EAAUrf,KAAK8e,sBAAsB1N,IAAI6O,GAE/C,GAAIc,EAgBF,OAfI/gB,KAAKue,iBACHc,GACFA,EAAQc,kBAAoBngB,KAAK4e,kBACjCS,EAAQ4B,YAAc5B,EAAQ4B,aAAejhB,KAAKse,cAElDte,KAAK8e,sBAAsB5b,IAAI+c,EAAY,CACzCU,IAAGnkB,OAAA2F,OAAA,GAAOwe,GACVrB,QAAStf,KAAKue,eACd4B,iBAAkBngB,KAAK4e,kBACvBqC,YAAajhB,KAAKse,eAGtBte,KAAKof,gBAAkBa,EACvBjgB,KAAKue,eAAiB,OAEjB,EAILc,IACFrf,KAAKof,qBAAkBpX,GAGzB,IAAIiZ,EAAcjhB,KAAKse,aACnB4C,EAAWP,EACXrB,EAAUtf,KAAKue,eAEfc,IACF6B,EAAW7B,EAAQsB,IACnBrB,EAAUD,EAAQC,QAClB2B,EAAcA,GAAe5B,EAAQ4B,YACrCjhB,KAAK8e,sBAAsB5I,OAAO+J,IAGpC,IAAIkB,EAAa,IAAI/jB,WAAW,GAC5BkiB,IA1QW,IA2QTA,EAAQha,QACV2b,GAAc,GAEhBE,EAAa7B,EAAQrZ,OAEvBjG,KAAKue,eAAiB,KAKtB,MAAMhf,EAASS,KAAKohB,8BAA8BF,EAAUC,EAAYF,GAIxE,OAHI3B,GACFA,EAAQrb,UAEH1E,CACT,CAIQ,uBAAAmgB,GACN,IAAI2B,EAAM,GACV,IAAK,IAAIvjB,EAAI,EAAGA,EAAIkC,KAAK0e,eAAgB5gB,IACvCujB,GAAOnS,OAAOoS,cAActhB,KAAKye,aAAa3gB,IAEhD,OAAOujB,CACT,CAEQ,uBAAAR,iBACN,MAAMF,GAAM,EAAAvC,EAAAqB,mBAAkBzf,KAAK0f,2BAGnC,QAAe1X,IAAX2Y,EAAI5H,SAAwC/Q,IAApB2Y,EAAIhB,YAE9B,OADA3f,KAAK4f,cAAce,EAAI5H,GAAI,0CAAoD,QAAX5M,EAAEwU,EAAId,aAAK,IAAA1T,EAAAA,EAAI,IAC5E,EAKT,OAFyB,QAAboB,EAAGoT,EAAIb,cAAM,IAAAvS,EAAAA,EAAI,KAG3B,QACE,OAAOvN,KAAK8gB,cAAcH,GAC5B,QAEE,OADA3gB,KAAK4f,cAAoB,QAAP1T,EAACyU,EAAI5H,UAAE,IAAA7M,EAAAA,EAAI,EAAG,KAAe,QAAXoC,EAAEqS,EAAId,aAAK,IAAAvR,EAAAA,EAAI,IAC5C,EACT,QACE,OAAOtO,KAAKuhB,iBAAiBZ,GAC/B,QAQE,YAHe3Y,IAAX2Y,EAAI5H,IACN/Y,KAAK4f,cAAce,EAAI5H,GAAI,4BAAsC,QAAXvF,EAAEmN,EAAId,aAAK,IAAArM,EAAAA,EAAI,IAEhE,EAEb,CAEQ,6BAAA4N,CAA8BT,EAAoBa,EAAmBP,iBAG3E,OAFyB,QAAb9U,EAAGwU,EAAIb,cAAM,IAAA3T,EAAAA,EAAI,KAG3B,QAA2B,CACzB,MAAM5M,EAASS,KAAKyhB,gBAAgBd,EAAKa,EAAOP,GAUhD,MAPkC,OAAb,QAAjB1T,EAACoT,EAAIe,oBAAY,IAAAnU,EAAAA,EAAI,WAA2BvF,IAAX2Y,EAAI5H,KACvCkI,EACFjhB,KAAK4f,cAAce,EAAI5H,GAAI,6BAAuC,QAAX7M,EAAEyU,EAAId,aAAK,IAAA3T,EAAAA,EAAI,GAC7DsV,EAAMzjB,OAAS,GACxBiC,KAAK4f,cAAce,EAAI5H,GAAI,KAAe,QAAXzK,EAAEqS,EAAId,aAAK,IAAAvR,EAAAA,EAAI,IAG3C/O,CACT,CACA,QACE,OAAOS,KAAK2hB,uBAAuBhB,EAAKa,EAAOP,GACjD,QACE,OAAOjhB,KAAK4hB,aAAajB,EAAKa,EAAOP,GACvC,QAEE,OAAOjhB,KAAKuhB,iBAAiBZ,GAC/B,QAQE,YAHe3Y,IAAX2Y,EAAI5H,IACN/Y,KAAK4f,cAAce,EAAI5H,GAAI,4BAAsC,QAAXvF,EAAEmN,EAAId,aAAK,IAAArM,EAAAA,EAAI,IAEhE,EAEb,CAEQ,gBAAA+N,CAAiBZ,SACvB,QAAe3Y,IAAX2Y,EAAI5H,GACN,OAAO,EAET,MAAMA,EAAK4H,EAAI5H,GACT8I,EAAQ7hB,KAAKqe,cAAcyD,SAAS/I,GAC1C,OAAK8I,EAIU7hB,KAAK+hB,cAAcF,EAAOlB,GAC3Btf,KAAKmK,UAEjB,OADAxL,KAAK4f,cAAc7G,EAAIvN,EAAU,KAAO,gCAA0C,QAAXW,EAAEwU,EAAId,aAAK,IAAA1T,EAAAA,EAAI,EAAGwU,EAAIqB,cACtF,KANPhiB,KAAK4f,cAAc7G,EAAI,yBAAmC,QAAX5M,EAAEwU,EAAId,aAAK,IAAA1T,EAAAA,EAAI,EAAGwU,EAAIqB,cAC9D,EAOX,CAEQ,eAAAP,CAAgBd,EAAoBa,EAAmBP,mBAY7D,MAAqB,OADgB,QAAnB9U,EAAGwU,EAAIe,oBAAY,IAAAvV,EAAAA,EAAI,WAExBnE,IAAX2Y,EAAI5H,IACN/Y,KAAK4f,cAAce,EAAI5H,GAAI,yCAAmD,QAAXxL,EAAEoT,EAAId,aAAK,IAAAtS,EAAAA,EAAI,IAE7E,IAGL0T,GAAgC,IAAjBO,EAAMzjB,QAEzBiC,KAAKqe,cAAc4D,WAAWtB,EAAI5H,GAAI,CACpC3T,KAAM,IAAIuI,KAAK,CAAC6T,IAChBthB,MAAgB,QAAXgM,EAAEyU,EAAIzgB,aAAK,IAAAgM,EAAAA,EAAI,EACpBlI,OAAkB,QAAZsK,EAAEqS,EAAI3c,cAAM,IAAAsK,EAAAA,EAAI,EACtB4T,OAAmB,QAAX1O,EAACmN,EAAIuB,cAAM,IAAA1O,EAAAA,EAAA,GACnB2O,YAA4B,QAAjB9B,EAAEM,EAAIwB,mBAAW,IAAA9B,EAAAA,EAAI,MAPY,EAUhD,CAEQ,sBAAAsB,CAAuBhB,EAAoBa,EAAmBP,WACpE,GAAIA,EAIF,YAHejZ,IAAX2Y,EAAI5H,IACN/Y,KAAK4f,cAAce,EAAI5H,GAAI,6BAAuC,QAAX5M,EAAEwU,EAAId,aAAK,IAAA1T,EAAAA,EAAI,IAEjE,EAGTnM,KAAKyhB,gBAAgBd,EAAKa,EAAOP,GAEjC,MAAMlI,EAAW,QAATxL,EAAGoT,EAAI5H,UAAE,IAAAxL,EAAAA,EAAIvN,KAAKqe,cAAc+D,YAClCP,EAAQ7hB,KAAKqe,cAAcyD,SAAS/I,GAC1C,GAAI8I,EAAO,CACT,MAAMtiB,EAASS,KAAK+hB,cAAcF,EAAOlB,GACzC,YAAe3Y,IAAX2Y,EAAI5H,GACCxZ,EAAO8B,KAAKmK,UAEjB,OADAxL,KAAK4f,cAAc7G,EAAIvN,EAAU,KAAO,gCAA0C,QAAXW,EAAEwU,EAAId,aAAK,IAAA1T,EAAAA,EAAI,IAC/E,IAGJ5M,EAAO8B,KAAK,KAAM,EAC3B,CACA,OAAO,CACT,CAEQ,YAAAugB,CAAajB,EAAoBa,EAAmBP,mBAC1D,MAAMlI,EAAW,QAAT5M,EAAGwU,EAAI5H,UAAE,IAAA5M,EAAAA,EAAI,EACf0T,EAAiB,QAAZtS,EAAGoT,EAAId,aAAK,IAAAtS,EAAAA,EAAI,EAM3B,GAAqB,OADgB,QAAnBrB,EAAGyU,EAAIe,oBAAY,IAAAxV,EAAAA,EAAI,KAGvC,OADAlM,KAAK4f,cAAc7G,EAAI,yCAA0C8G,IAC1D,EAIT,GAAIoB,EAEF,OADAjhB,KAAK4f,cAAc7G,EAAI,6BAA8B8G,IAC9C,EAIT,GAAqB,IAAjB2B,EAAMzjB,OAER,OADAiC,KAAK4f,cAAc7G,EAAI,KAAM8G,IACtB,EAGT,MAAMqC,EAAmB,QAAb5T,EAAGqS,EAAIuB,cAAM,IAAA5T,EAAAA,EAAA,GAEzB,GAAU,MAAN4T,EACFliB,KAAK4f,cAAc7G,EAAI,KAAM8G,OACxB,CACL,MAAM3f,EAAiB,QAAZsT,EAAGmN,EAAIzgB,aAAK,IAAAsT,EAAAA,EAAI,EACrBxP,EAAmB,QAAbqc,EAAGM,EAAI3c,cAAM,IAAAqc,EAAAA,EAAI,EAE7B,IAAKngB,IAAU8D,EAEb,OADAhE,KAAK4f,cAAc7G,EAAI,sDAAuD8G,IACvE,EAGT,MACMwC,EAAgBniB,EAAQ8D,GADF,KAANke,EAA6B,EAA2C,GAG9F,GAAIV,EAAMzjB,OAASskB,EAEjB,OADAriB,KAAK4f,cAAc7G,EAAI,iCAAkC8G,IAClD,EAGT7f,KAAK4f,cAAc7G,EAAI,KAAM8G,EAC/B,CACA,OAAO,CACT,CAEQ,aAAAiB,CAAcH,SAOpB,OALmC,QAArBxU,EAAGwU,EAAI2B,sBAAc,IAAAnW,EAAAA,EAAI,KAMrC,IAAK,IACL,IAAK,IACHnM,KAAKkf,qBACLlf,KAAKqe,cAAckE,YACnB,MACF,IAAK,IACL,IAAK,IAKH,QAAeva,IAAX2Y,EAAI5H,GAAkB,CACxB,MAAMsG,EAAUrf,KAAK8e,sBAAsB1N,IAAIuP,EAAI5H,IAC/CsG,GACFA,EAAQC,QAAQrb,UAElBjE,KAAKmf,oBAAoBwB,EAAI5H,IAC7B/Y,KAAKqe,cAAcmE,WAAW7B,EAAI5H,GACpC,EAMJ,OAAO,CACT,CAEQ,aAAA6G,CAAc7G,EAAYV,EAAiBwH,EAAemC,GAChE,MAAMS,EAAmB,OAAZpK,EACb,GAAIoK,GAAQ5C,GAAS,EAAG,OACxB,IAAK4C,GAAQ5C,GAAS,EAAG,OAEzB,MACM6C,EAAW,QAAW3J,IADdiJ,EAAc,MAAMA,IAAgB,MACR3J,OAC1CrY,KAAKoK,cAAcgC,MAAMuW,YAAYC,iBAAiBF,EACxD,CAIQ,aAAAX,CAAcF,EAAwBlB,GAC5C,OAAO3gB,KAAK6iB,kBAAkBhB,EAAOlB,GAClCtf,KAAK,KAAM,GACX6M,MAAM,KAAM,EACjB,CAEQ,uBAAM2U,CAAkBhB,EAAwBlB,qBACtD,MAAM/S,EAAa5N,KAAKqK,YACxB,IAAIwM,QAAwC7W,KAAK8iB,cAAcjB,GAE/D,IACE,GAAIjU,IAAe5N,KAAKqK,YAAa,MAAM,IAAIjI,MAAM,yBACrD,MAAM2gB,EAAQhnB,KAAKC,IAAI,EAAQ,QAAPkQ,EAAEyU,EAAIlK,SAAC,IAAAvK,EAAAA,EAAI,GAC7B8W,EAAQjnB,KAAKC,IAAI,EAAQ,QAAPsS,EAAEqS,EAAIrN,SAAC,IAAAhF,EAAAA,EAAI,GAC7B2U,EAAQtC,EAAIzM,aAAgB2C,EAAO3W,MAAQ6iB,EAC3CG,EAAQvC,EAAIxM,cAAiB0C,EAAO7S,OAASgf,EAE7CG,EAAWpnB,KAAKC,IAAI,EAAG6a,EAAO3W,MAAQ6iB,GACtCK,EAAWrnB,KAAKC,IAAI,EAAG6a,EAAO7S,OAASgf,GACvCK,EAAatnB,KAAKC,IAAI,EAAGD,KAAKE,IAAIgnB,EAAOE,IACzCG,EAAavnB,KAAKC,IAAI,EAAGD,KAAKE,IAAIinB,EAAOE,IAE/C,GAAmB,IAAfC,GAAmC,IAAfC,EACtB,MAAM,IAAIlhB,MAAM,4BAGlB,GAAc,IAAV2gB,GAAyB,IAAVC,GAAeK,IAAexM,EAAO3W,OAASojB,IAAezM,EAAO7S,OAAQ,CAC7F,MAAMuf,QAAgB1V,kBAAkBgJ,EAAQkM,EAAOC,EAAOK,EAAYC,GAC1EzM,EAAO5I,QACP4I,EAAS0M,CACX,CAEA,MAAMpV,GAA8B,QAAzBhC,EAAAnM,KAAKkK,UAAU0B,kBAAU,IAAAO,OAAA,EAAAA,EAAEN,IAAIuC,KAAKlO,QAASoJ,EAAAqC,kBAAkBzL,MACpEmO,GAA8B,QAAzBd,EAAAvN,KAAKkK,UAAU0B,kBAAU,IAAA2B,OAAA,EAAAA,EAAE1B,IAAIuC,KAAKpK,SAAUsF,EAAAqC,kBAAkB3H,OAI3E,IAAIwf,EACAC,OACgBzb,IAAhB2Y,EAAI+C,cAAsC1b,IAAb2Y,EAAI3U,MACnCwX,EAAU7C,EAAI+C,QACdD,EAAU9C,EAAI3U,WACWhE,IAAhB2Y,EAAI+C,SACbF,EAAU7C,EAAI+C,QACdD,EAAU1nB,KAAKC,IAAI,EAAGD,KAAKuI,KAAMuS,EAAO7S,OAAS6S,EAAO3W,OAAUsjB,EAAUrV,GAAME,UAC5DrG,IAAb2Y,EAAI3U,MACbyX,EAAU9C,EAAI3U,KACdwX,EAAUznB,KAAKC,IAAI,EAAGD,KAAKuI,KAAMuS,EAAO3W,MAAQ2W,EAAO7S,QAAWyf,EAAUpV,GAAMF,MAElFqV,EAAUznB,KAAKuI,KAAKuS,EAAO3W,MAAQiO,GACnCsV,EAAU1nB,KAAKuI,KAAKuS,EAAO7S,OAASqK,IAGtC,IAAI3C,EAAImL,EAAO3W,MACX9B,EAAIyY,EAAO7S,OAQf,QALoBgE,IAAhB2Y,EAAI+C,cAAsC1b,IAAb2Y,EAAI3U,OACnCN,EAAI3P,KAAKQ,MAAMinB,EAAUrV,GACzB/P,EAAIrC,KAAKQ,MAAMknB,EAAUpV,IAGvB3C,EAAItN,EAAI4B,KAAKkC,MAAM8K,WACrB,MAAM,IAAI5K,MAAM,6BAIlB,MAAM9E,EAAS0C,KAAKoK,cAAcgC,MAAM9O,OAClCqmB,EAASrmB,EAAOmZ,EAChBmN,EAAStmB,EAAOgW,EAChBuQ,EAAavmB,EAAO2c,MAOpBvJ,OAD6B1I,IAAf2Y,EAAIhQ,QAAwBgQ,EAAIhQ,OAAS,EACrB,SAAW,MAEnD,GAAIjF,IAAMmL,EAAO3W,OAAS9B,IAAMyY,EAAO7S,OAAQ,CAC7C,MAAM8f,QAAejW,kBAAkBgJ,EAAQ,CAAE/I,YAAapC,EAAGqC,aAAc3P,IAC/EyY,EAAO5I,QACP4I,EAASiN,CACX,CAGA,MAAMC,EAAUhoB,KAAKE,IAAIF,KAAKC,IAAI,EAAc,QAAbwX,EAAEmN,EAAIoD,eAAO,IAAAvQ,EAAAA,EAAI,GAAIrF,EAAK,GACvD6V,EAAUjoB,KAAKE,IAAIF,KAAKC,IAAI,EAAc,QAAbqkB,EAAEM,EAAIqD,eAAO,IAAA3D,EAAAA,EAAI,GAAIhS,EAAK,GAC7D,GAAgB,IAAZ0V,GAA6B,IAAZC,EAAe,CAKlC,MAAMC,OAA2Bjc,IAAhB2Y,EAAI+C,QAAyB3nB,KAAKQ,MAAMinB,EAAUrV,GAAM0I,EAAO3W,MAAQ6jB,EAClFG,OAAwBlc,IAAb2Y,EAAI3U,KAAsBjQ,KAAKQ,MAAMknB,EAAUpV,GAAMwI,EAAO7S,OAASggB,EAChFG,EAAe9a,EAAAgE,cAAcC,aAAawJ,OAAOxF,SAAU2S,EAASC,GACpEE,EAAYD,EAAa3W,WAAW,MAC1C,IAAK4W,EACH,MAAM,IAAIhiB,MAAM,0CAElBgiB,EAAUzP,UAAUkC,EAAQkN,EAASC,GAErC,MAAMK,QAAqBxW,kBAAkBsW,GAM7C,GALAA,EAAajkB,MAAQikB,EAAangB,OAAS,EAC3C6S,EAAO5I,QACP4I,EAASwN,EACT3Y,EAAImL,EAAO3W,MACX9B,EAAIyY,EAAO7S,OACP0H,EAAItN,EAAI4B,KAAKkC,MAAM8K,WACrB,MAAM,IAAI5K,MAAM,kCAEE4F,IAAhB2Y,EAAI+C,UACNF,EAAUznB,KAAKuI,KAAKuS,EAAO3W,MAAQiO,SAEpBnG,IAAb2Y,EAAI3U,OACNyX,EAAU1nB,KAAKuI,KAAKuS,EAAO7S,OAASqK,GAExC,CAEA,GAAIT,IAAe5N,KAAKqK,YAAa,MAAM,IAAIjI,MAAM,yBACrD,MAAMuO,EAAmB,QAAb4P,EAAGI,EAAIhQ,cAAM,IAAA4P,EAAAA,EAAI,EAO7B,GANAvgB,KAAKqe,cAAc3Q,SAASmU,EAAM9I,GAAIlC,GAAQ,EAAMnG,EAAOC,GAC3DkG,OAAS7O,EAKkB,IAAvB2Y,EAAI2D,eAAsB,CAE5B,MAAMC,EAAWjnB,EAAO2c,MAAQ4J,EAChCvmB,EAAOmZ,EAAIkN,EAEXrmB,EAAOgW,EAAIvX,KAAKC,IAAI4nB,EAASW,EAAU,EACzC,MAGEjnB,EAAOmZ,EAAI1a,KAAKE,IAAI0nB,EAASH,EAASxjB,KAAKoK,cAAc2B,KAE7D,CAAE,MAAOjE,GAEP,MADA+O,SAAAA,EAAQ5I,QACFnG,CACR,CACF,CAGQ,mBAAMgb,CAAcjB,GAC1B,IAAIL,EAAoB,IAAIpkB,iBAAiBykB,EAAMzc,KAAKof,eAMxD,GAJqB,MAAjB3C,EAAMM,cACRX,QAAcxhB,KAAKykB,gBAAgBjD,IAGrB,MAAZK,EAAMK,OAA4B,CACpC,MAAMtV,GAAU,EAAAjD,EAAAmD,WAAU0U,GAG1B,GAAqB,cAAjB5U,EAAQG,QAA0BH,EAAQ1M,MAAQ,MAAQ0M,EAAQ5I,OAAS,IAAM4I,EAAQ1M,MAAQ0M,EAAQ5I,OAAShE,KAAKkC,MAAM8K,WAC/H,MAAM,IAAIyL,WAAW,qDAEvB,MAAM9L,EAAO,IAAIgB,KAAK,CAAC6T,GAAoB,CAAE3X,KAAM,cACnD,IAAKiN,OAAOjJ,kBAAmB,CAC7B,MAAM6W,EAAMC,IAAIC,gBAAgBjY,GAC1BkE,EAAM,IAAIgU,MAChB,OAAO,IAAIlT,QAAqB,CAACC,EAASkT,KACxCjU,EAAIkU,iBAAiB,OAAQ,WAC3BJ,IAAIK,gBAAgBN,GACpB,MAAM5Y,EAASzC,EAAAgE,cAAcC,aAAawJ,OAAOxF,SAAUT,EAAI3Q,MAAO2Q,EAAI7M,QACnD,QAAvBmI,EAAAL,EAAO0B,WAAW,aAAK,IAAArB,GAAAA,EAAEwI,UAAU9D,EAAK,EAAG,GAC3ChD,kBAAkB/B,GAAQzK,KAAKuQ,GAAS1D,MAAM4W,KAEhDjU,EAAIkU,iBAAiB,QAAS,KAC5BJ,IAAIK,gBAAgBN,GACpBI,EAAO,IAAI1iB,MAAM,2BAEnByO,EAAIoU,IAAMP,GAEd,CACA,OAAO7W,kBAAkBlB,EAC3B,CAGA,MAAMzM,EAAQ2hB,EAAM3hB,MACd8D,EAAS6d,EAAM7d,OAErB,IAAK9D,IAAU8D,EACb,MAAM,IAAI5B,MAAM,gDAGlB,MACMigB,EAAgBniB,EAAQ8D,GADI,KAAZ6d,EAAMK,OAA6B,EAA2C,GAGpG,GAAIV,EAAMzjB,OAASskB,EACjB,MAAM,IAAIjgB,MAAM,2BAGlB,MAAM8iB,EAAahlB,EAAQ8D,EAE3B,GAAgB,KAAZ6d,EAAMK,OAER,OAAOrU,kBAAkB,IAAIV,UAAU,IAAIjH,kBAAkBsb,EAAMlkB,OAAuBkkB,EAAMpU,WAAsB,EAAV8X,GAAwDhlB,EAAO8D,IAM7K,MAAMoB,EAAO,IAAIc,kBAA4B,EAAVgf,GAC7BC,EAAQ,IAAI9nB,YAAYmkB,EAAMlkB,OAAQkkB,EAAMpU,WAAYrR,KAAKmR,MAAMsU,EAAM3c,WAAa,IACtFugB,EAAQ,IAAI/nB,YAAY+H,EAAK9H,QAC7B+nB,GAA6B,EAAbH,EAEtB,IAAII,EAAY,EACZC,EAAY,EAChB,IAAK,IAAIznB,EAAI,EAAGA,EAAIunB,EAAevnB,GAAK,EAAG,CACzC,MAAM0nB,EAAKL,EAAMG,KACXG,EAAKN,EAAMG,KACXI,EAAKP,EAAMG,KAEjBF,EAAMG,KAAe,WAAaC,EAClCJ,EAAMG,KAAe,WAAcC,IAAO,GAAOC,GAAM,EACvDL,EAAMG,KAAe,WAAcE,IAAO,GAAOC,GAAM,GACvDN,EAAMG,KAAe,WAAcG,IAAO,CAC5C,CAGA,IAAIC,EAAuB,EAAbN,EACVO,EAAuB,EAAbP,EACd,IAAK,IAAIvnB,EAAIunB,EAAevnB,EAAIonB,EAAYpnB,IAC1CsH,EAAKwgB,GAAepE,EAAMmE,GAC1BvgB,EAAKwgB,EAAU,GAAKpE,EAAMmE,EAAU,GACpCvgB,EAAKwgB,EAAU,GAAKpE,EAAMmE,EAAU,GACpCvgB,EAAKwgB,EAAU,GAAE,IACjBD,GAAO,EACPC,GAAO,EAGT,OAAO/X,kBAAkB,IAAIV,UAAU/H,EAAMlF,EAAO8D,GACtD,CAEQ,qBAAMygB,CAAgBoB,GAC5B,IACE,aAAa7lB,KAAK8lB,YAAYD,EAAY,UAC5C,CAAE,MAAOzN,GACP,GAAIA,aAAiBK,WAAY,MAAML,EACvC,aAAapY,KAAK8lB,YAAYD,EAAY,cAC5C,CACF,CAEQ,iBAAMC,CAAYD,EAAwB3D,GAChD,MAAMhR,EAAQnV,KAAKE,IAAI+D,KAAKkC,MAAM8c,eAAwC,EAAxBhf,KAAKkC,MAAM8K,WAA0C,IAA1BhN,KAAKkC,MAAMiW,cACxF,IAAI4N,EAAW,EAEf,MAWMC,EAXS,IAAIC,eAA6B,CAC9C,IAAAC,CAAKC,GACH,GAAIJ,GAAYF,EAAW9nB,OAEzB,YADAooB,EAAWlY,QAGb,MAAM3I,EAAMvJ,KAAKE,IAAI8pB,EAAW,KAAMF,EAAW9nB,QACjDooB,EAAWC,QAAQ,IAAIhpB,WAAWyoB,EAAWphB,SAASshB,EAAUzgB,KAChEygB,EAAWzgB,CACb,IAEoB+gB,YAAY,IAAIC,oBAAoBpE,IAASqE,YAC7DC,EAAuB,GAC7B,IAAIC,EAAc,EAClB,IACE,OAAa,CACX,MAAMC,KAAEA,EAAI5qB,MAAEA,SAAgBkqB,EAAOW,OACrC,GAAID,EAAM,MAEV,GADAD,GAAe3qB,EAAM+I,WACjB4hB,EAAcvV,EAEhB,YADM8U,EAAOY,SAAS1Y,MAAM,QACtB,IAAIuK,WAAW,yCAEvB+N,EAAO9nB,KAAK5C,EACd,CACF,SACEkqB,EAAOa,aACT,CAEA,MAAMtnB,EAAS,IAAInC,WAAWqpB,GAC9B,IAAItiB,EAAS,EACb,IAAK,MAAM2iB,KAASN,EAClBjnB,EAAO2D,IAAI4jB,EAAO3iB,GAClBA,GAAU2iB,EAAM/oB,OAElB,OAAOwB,CACT,CAEA,UAAWwnB,GACT,OAAO/mB,KAAKqe,cAAc0I,MAC5B,CAEA,uBAAWC,GACT,OAAOhnB,KAAKqe,cAAc4I,kBAC5B,CAEA,wBAAWC,GACT,OAAOlnB,KAAK8e,qBACd,kFCptBF,SAAkC1Z,GAChC,MAAMub,EAAqB,GACrBwG,EAAQ/hB,EAAKgiB,MAAM,KAEzB,IAAK,MAAMC,KAAQF,EAAO,CACxB,MAAMG,EAAQD,EAAKngB,QAAQ,KAC3B,IAAe,IAAXogB,EAAc,SAElB,MAAM/R,EAAM8R,EAAKE,UAAU,EAAGD,GACxBxrB,EAAQurB,EAAKE,UAAUD,EAAQ,GAGrC,GAAO,MAAH/R,EAAyB,CAC3BoL,EAAIb,OAAShkB,EACb,QACF,CACA,GAAO,MAAHyZ,EAA8B,CAChCoL,EAAIwB,YAAcrmB,EAClB,QACF,CACA,GAAO,MAAHyZ,EAA+B,CACjCoL,EAAIe,aAAe5lB,EACnB,QACF,CACA,GAAO,MAAHyZ,EAAkC,CACpCoL,EAAI2B,eAAiBxmB,EACrB,QACF,CACA,MAAM0rB,EAAWxY,SAASlT,EAAO,IACjC,OAAQyZ,GACN,QAAsBoL,EAAIuB,OAASsF,EAAU,MAC7C,QAAkB7G,EAAI5H,GAAKyO,EAAU,MACrC,QAA4B7G,EAAIhB,YAAc6H,EAAU,MACxD,QAAqB7G,EAAIzgB,MAAQsnB,EAAU,MAC3C,QAAsB7G,EAAI3c,OAASwjB,EAAU,MAC7C,QAAwB7G,EAAIlK,EAAI+Q,EAAU,MAC1C,QAAwB7G,EAAIrN,EAAIkU,EAAU,MAC1C,QAA4B7G,EAAIzM,YAAcsT,EAAU,MACxD,QAA6B7G,EAAIxM,aAAeqT,EAAU,MAC1D,QAAkC7G,EAAIoD,QAAUyD,EAAU,MAC1D,QAAkC7G,EAAIqD,QAAUwD,EAAU,MAC1D,QAAuB7G,EAAI+C,QAAU8D,EAAU,MAC/C,QAAoB7G,EAAI3U,KAAOwb,EAAU,MACzC,QAAoB7G,EAAIK,KAAOwG,EAAU,MACzC,QAAqB7G,EAAId,MAAQ2H,EAAU,MAC3C,QAA+B7G,EAAI2D,eAAiBkD,EAAU,MAC9D,QAAuB7G,EAAIhQ,OAAS6W,EAAU,MAC9C,QAA4B7G,EAAIqB,YAAcwF,EAElD,CAEA,OAAO7G,CACT,wFCjLA,MAAA8G,EA6BE,WAAA1nB,CACmBoK,GAAAnK,KAAAmK,SAAAA,EA3BXnK,KAAA0nB,aAAe,EACN1nB,KAAA4X,QAAwC,IAAI7F,IAU5C/R,KAAAgnB,oBAA2C,IAAIjV,IAC/C/R,KAAA2nB,oBAA2C,IAAI5V,IAI/C/R,KAAA4nB,2BAA8BC,IAC7C,MAAMC,EAAU9nB,KAAK2nB,oBAAoBvW,IAAIyW,QAC7B7f,IAAZ8f,IACF9nB,KAAKgnB,oBAAoB9Q,OAAO4R,GAChC9nB,KAAK2nB,oBAAoBzR,OAAO2R,GAChC7nB,KAAK4X,QAAQ1B,OAAO4R,KAGhB9nB,KAAAwQ,cAA+B,CAAEC,WAAW,EAAMC,MAAO,MAAOC,OAAQ,EAAGC,UAAW,OAK5F5Q,KAAK+nB,wBAA0B/nB,KAAKmK,SAAS8O,eAC7CjZ,KAAKgoB,uBAA0BH,UACD,QAA5B1b,EAAAnM,KAAK+nB,+BAAuB,IAAA5b,GAAAA,EAAA3E,KAA5BxH,KAA+B6nB,GAC/B7nB,KAAK4nB,2BAA2BC,IAElC7nB,KAAKmK,SAAS8O,eAAiBjZ,KAAKgoB,sBACtC,CAEO,KAAA9c,GACLlL,KAAK0nB,aAAe,EACpB1nB,KAAK4X,QAAQ9O,QACb9I,KAAKgnB,oBAAoBle,QACzB9I,KAAK2nB,oBAAoB7e,OAC3B,CAEO,OAAAnB,GACL3H,KAAKkL,QACDlL,KAAKmK,SAAS8O,iBAAmBjZ,KAAKgoB,yBACxChoB,KAAKmK,SAAS8O,eAAiBjZ,KAAK+nB,wBAExC,CAEO,UAAA9F,CAAWlJ,EAAwBkP,GACxC,MAAMzQ,EAAUuB,QAAAA,EAAM/Y,KAAK0nB,eAErBQ,EAAeloB,KAAKgnB,oBAAoB5V,IAAIoG,QAC7BxP,IAAjBkgB,IACFloB,KAAKmK,SAASmP,YAAY4O,GAC1BloB,KAAKgnB,oBAAoB9Q,OAAOsB,GAChCxX,KAAK2nB,oBAAoBzR,OAAOgS,KAG7BloB,KAAK4X,QAAQlC,IAAI8B,IAAYxX,KAAK4X,QAAQ7N,MAAQ0d,EAAkBU,kBACvEnoB,KAAKooB,0BASP,MAAMC,EAAuC,IAA3BroB,KAAKmK,SAASmO,WAChCtY,KAAK4X,QAAQ1B,OAAOsB,GACpB,IAAI8Q,EAAgB,EACpB,IAAK,MAAMzG,KAAS7hB,KAAK4X,QAAQ9C,SAAUwT,GAAiBzG,EAAMzc,KAAK2E,KACvE,IAAK,MAAMwe,IAAe,EAAC,GAAO,GAChC,IAAK,MAAOC,EAAU3G,KAAU7hB,KAAK4X,QAAS,CAC5C,GAAI0Q,EAAgBL,EAAU7iB,KAAK2E,MAAQse,EAAW,MAClDroB,KAAKgnB,oBAAoBtR,IAAI8S,KAAcD,IAC/CD,GAAiBzG,EAAMzc,KAAK2E,KAC5B/J,KAAKwiB,WAAWgG,GAClB,CAOF,OAJAxoB,KAAK4X,QAAQ1U,IAAIsU,EAAOhb,OAAA2F,OAAA3F,OAAA2F,OAAA,GACnB8lB,GAAS,CACZlP,GAAIvB,KAECA,CACT,CAEO,QAAA9J,CAASoa,EAAiBjG,EAAwCpR,EAAoBC,EAAmBC,GAK9G,MAAMuX,EAAeloB,KAAKgnB,oBAAoB5V,IAAI0W,QAC7B9f,IAAjBkgB,GACFloB,KAAK2nB,oBAAoBzR,OAAOgS,GAElCloB,KAAKwQ,cAAcC,UAAYA,EAC/BzQ,KAAKwQ,cAAcE,MAAQA,EAC3B1Q,KAAKwQ,cAAcG,OAASA,EAC5B,MAAMkX,EAAY7nB,KAAKmK,SAASuD,SAASmU,EAAO7hB,KAAKwQ,eACrDxQ,KAAKgnB,oBAAoB9jB,IAAI4kB,EAASD,GACtC7nB,KAAK2nB,oBAAoBzkB,IAAI2kB,EAAWC,EAC1C,CAEO,QAAAhG,CAASgG,GACd,OAAO9nB,KAAK4X,QAAQxG,IAAI0W,EAC1B,CAEO,UAAAtF,CAAWsF,GAChB9nB,KAAK4X,QAAQ1B,OAAO4R,GACpB,MAAMD,EAAY7nB,KAAKgnB,oBAAoB5V,IAAI0W,QAC7B9f,IAAd6f,IACF7nB,KAAKmK,SAASmP,YAAYuO,GAC1B7nB,KAAKgnB,oBAAoB9Q,OAAO4R,GAChC9nB,KAAK2nB,oBAAoBzR,OAAO2R,GAEpC,CAEO,SAAAtF,GACLviB,KAAK4X,QAAQ9O,QACb,IAAK,MAAM+e,KAAa7nB,KAAKgnB,oBAAoBlS,SAC/C9U,KAAKmK,SAASmP,YAAYuO,GAE5B7nB,KAAKgnB,oBAAoBle,QACzB9I,KAAK2nB,oBAAoB7e,OAC3B,CAEA,UAAWie,GACT,OAAO/mB,KAAK4X,OACd,CAEA,sBAAWqP,GACT,OAAOjnB,KAAKgnB,mBACd,CAEA,eAAW5E,GACT,OAAOpiB,KAAK0nB,aAAe,CAC7B,CAEQ,uBAAAU,GACN,IAAK,MAAON,KAAY9nB,KAAK4X,QAAS,CACpC,GAAI5X,KAAK4X,QAAQ7N,MAAQ0d,EAAkBU,iBAAmB,EAC5D,MAEGnoB,KAAKgnB,oBAAoBtR,IAAIoS,IAChC9nB,KAAK4X,QAAQ1B,OAAO4R,EAExB,CACF,wBA1JwBL,EAAAU,iBAAmB,gBCjB7C3rB,OAAAC,eAAA5B,EAAA,cAA+CiB,OAAA,IAK/C,MAIA2sB,GAAA,EAJyBzpB,EAAQ,KAIjC0pB,QAAA,CAAgGpqB,EAAA,EAAA+e,EAAA,EAAAlf,EAAA,6lCAuJhGwqB,EAAA,IAAAvrB,WAAA,mEACAgqB,MAAA,IACArf,IAAA6gB,GAAAA,EAAAppB,WAAA,KAEAqpB,EAAA,IAAAxrB,YAAA,MACAwrB,EAAAljB,KAAA,YACA,QAAA7H,EAAA,EAAgBA,EAAA6qB,EAAA5qB,SAAgBD,EAChC+qB,EAAAF,EAAA7qB,IAAAA,GAAA,EACA,QAAAA,EAAA,EAAgBA,EAAA6qB,EAAA5qB,SAAgBD,EAChC+qB,EAAA,IAAAF,EAAA7qB,IAAAA,GAAA,GAAAA,GAAA,UACA,QAAAA,EAAA,EAAgBA,EAAA6qB,EAAA5qB,SAAgBD,EAChC+qB,EAAA,IAAAF,EAAA7qB,IAAAA,GAAA,MAAAA,GAAA,WACA,QAAAA,EAAA,EAAgBA,EAAA6qB,EAAA5qB,SAAgBD,EAChC+qB,EAAA,IAAAF,EAAA7qB,IAAAA,GAAA,GACA,MAAAgrB,EAAA,IAAA1rB,WAAA,GAkKAvC,EAAA,QAvJA,MAMA,WAAAkF,CAAAgpB,EAAAC,EAAAle,GAOA,GANA9K,KAAAipB,MAAA,KACAjpB,KAAAkpB,QAAA,EACAlpB,KAAAmpB,OAAA,EACAnpB,KAAA+oB,SAAAA,QAAAA,EAAA,QACA/oB,KAAAgpB,SAAAA,QAAAA,EAAA,WACAhpB,KAAAmpB,OAAAre,QAAAA,EAAA,MACA9K,KAAAmpB,OAAAnpB,KAAAgpB,UAAAhpB,KAAAgpB,SAAA,WACA,UAAA5mB,MAAA,wBAEA,CAKA,SAAA6D,GACA,OAAAjG,KAAAipB,MAAAjpB,KAAAsO,GAAA7J,SAAA,EAAAzE,KAAAopB,KAAA,OAAAN,CACA,CAMA,OAAA7kB,GACAjE,KAAAipB,QAEAjpB,KAAAmpB,OAAAnpB,KAAA+oB,SACA/oB,KAAAipB,MAAAjpB,KAAAopB,KAAAppB,KAAAsO,GAAAtO,KAAAqpB,KAAA,MAGArpB,KAAAopB,KAAA,QACAppB,KAAAopB,KAAA,QACAppB,KAAAopB,KAAA,SAEA,CASA,IAAA/lB,CAAA2lB,EAAAle,GAGA,GAFA9K,KAAAgpB,SAAAA,QAAAA,EAAAhpB,KAAAgpB,SACAhpB,KAAAmpB,OAAAre,QAAAA,EAAA/O,KAAAE,IAAA+D,KAAAmpB,OAAAnpB,KAAAgpB,UACAhpB,KAAAmpB,OAAAnpB,KAAAgpB,UAAAhpB,KAAAgpB,SAAA,WACA,MAAA5mB,MAAA,yBAEA,IAAAknB,EAAAtpB,KAAAopB,KACA,MAAA5H,EAAAxhB,KAAAmpB,OAAA,KACAnpB,KAAAipB,MAOAjpB,KAAAqpB,KAAA/rB,OAAAuH,WAAA2c,IACAxhB,KAAAqpB,KAAAE,KAAAxtB,KAAAuI,MAAAkd,EAAAxhB,KAAAqpB,KAAA/rB,OAAAuH,YAAA,QACAykB,EAAA,IAAAjsB,YAAA2C,KAAAqpB,KAAA/rB,OAAA,GACA0C,KAAAsO,GAAA,IAAAlR,WAAA4C,KAAAqpB,KAAA/rB,OAAA,QATA0C,KAAAqpB,KAAA,IAAAloB,YAAAqoB,OAAA,CAAiDjhB,QAAAxM,KAAAuI,KAAAkd,EAAA,SACjDxhB,KAAAipB,MAAAR,EAAA,CAAsCxnB,IAAA,CAAO0B,OAAA3C,KAAAqpB,QAC7CC,EAAA,IAAAjsB,YAAA2C,KAAAqpB,KAAA/rB,OAAA,GACAgsB,EAAApmB,IAAA2lB,EAAA,KACA7oB,KAAAsO,GAAA,IAAAlR,WAAA4C,KAAAqpB,KAAA/rB,OAAA,OAOAgsB,EAAA,QACAA,EAAA,QACAA,EAAA,QACAtpB,KAAAopB,KAAAE,EACAtpB,KAAAkpB,QAAA,CACA,CAOA,QAAAhlB,CAAAulB,GACA,MAAAC,EAAA1pB,KAAAopB,KAAA,MAAAK,EACA,GAAAzpB,KAAAmpB,OAAAO,EAAA,CACA,GAAAA,EAAA1pB,KAAAgpB,SACA,SAEA,IAAAW,EAAA3pB,KAAAmpB,OACA,MAAAQ,GAAA,GAAAD,IAEA,GADAC,EAAA5tB,KAAAE,IAAA0tB,EAAA3pB,KAAAgpB,UACAW,EAAAD,EACA,SAEA,GAAAC,EAAA,KAAA3pB,KAAAqpB,KAAA/rB,OAAAuH,WAAA,CACA,MAAA+kB,EAAA7tB,KAAAuI,MAAAqlB,EAAA,KAAA3pB,KAAAqpB,KAAA/rB,OAAAuH,YAAA,OACA7E,KAAAqpB,KAAAE,KAAAK,GACA5pB,KAAAopB,KAAA,IAAA/rB,YAAA2C,KAAAqpB,KAAA/rB,OAAA,GACA0C,KAAAsO,GAAA,IAAAlR,WAAA4C,KAAAqpB,KAAA/rB,OAAA,KACA,CACA0C,KAAAmpB,OAAAQ,CACA,CACA,QACA,CAOA,GAAAxe,CAAA/F,GACA,IAAApF,KAAAipB,OAAAjpB,KAAAkpB,OACA,SAEA,GAAAlpB,KAAAkE,SAAAkB,EAAArH,QACA,SAEA,MAAAurB,EAAAtpB,KAAAopB,KAIA,OAHAppB,KAAAsO,GAAApL,IAAAkC,EAAAkkB,EAAA,OACAA,EAAA,OAAAlkB,EAAArH,OAEAurB,EAAA,MAAAA,EAAA,cACAtpB,KAAAipB,MAAApuB,QAAAsL,MACA,CACA,CAKA,GAAAb,GAEA,OADAtF,KAAAkpB,QAAA,EACAlpB,KAAAipB,MACAjpB,KAAAipB,MAAApuB,QAAAyK,OACA,CACA,CAIA,eAAAukB,GACA,OAAA7pB,KAAAipB,MACAjpB,KAAAopB,KAAA,MACA,CACA,CAIA,aAAAU,GACA,OAAA9pB,KAAAipB,MACAjpB,KAAAgpB,SAAAhpB,KAAAopB,KAAA,MACA,CACA,cC9UA5sB,OAAAC,eAAA5B,EAAA,cAA+CiB,OAAA,IAK/C,MACAiuB,GAAA,EADyB/qB,EAAQ,KACjC0pB,QAAA,CAAuGpqB,EAAA,EAAA+e,EAAA,EAAAlf,EAAA,i5BA8DvGtD,EAAA,QA7DA,MACA,WAAAkF,CAAAgpB,GACA/oB,KAAA+oB,SAAAA,EACA/oB,KAAAE,MAAA,EACAF,KAAAgE,OAAA,CACA,CACA,MAAApF,CAAAT,GACA6B,KAAAE,MAAA/B,EAAA,OAAAA,EAAA,OAAAA,EAAA,MAAAA,EAAA,GACA6B,KAAAgE,OAAA7F,EAAA,OAAAA,EAAA,OAAAA,EAAA,OAAAA,EAAA,IACA,MAAA4F,EAAA/D,KAAAE,MAAAF,KAAAgE,OACAgmB,EAAA,EAAAjmB,EACAkmB,EAAA9rB,EAAAJ,OAwBAyjB,EAAAzlB,KAAAC,IAAAguB,EAAAC,IAAAluB,KAAAE,IAAA+tB,EAAAC,IAAA,QACAjqB,KAAAipB,MAIAjpB,KAAAqpB,KAAA/rB,OAAAuH,WAAA2c,IACAxhB,KAAAqpB,KAAAE,KAAAxtB,KAAAuI,MAAAkd,EAAAxhB,KAAAqpB,KAAA/rB,OAAAuH,YAAA,QACA7E,KAAAsO,GAAA,OALAtO,KAAAqpB,KAAA,IAAAloB,YAAAqoB,OAAA,CAAiDjhB,QAAAxM,KAAAuI,KAAAkd,EAAA,SACjDxhB,KAAAipB,MAAAc,EAAA,CAAyC9oB,IAAA,CAAO0B,OAAA3C,KAAAqpB,SAMhDrpB,KAAAsO,KACAtO,KAAAsO,GAAA,IAAAlR,WAAA4C,KAAAqpB,KAAA/rB,SAGA,MAAA4sB,EAAAlqB,KAAAqpB,KAAA/rB,OAAAuH,WAAAolB,GAAA,IAGA,OAFAjqB,KAAAsO,GAAApL,IAAA/E,EAAA+rB,GACAlqB,KAAAipB,MAAApuB,QAAAsL,IAAA+jB,EAAAD,EAAAlmB,GACA/D,KAAAsO,GAAA7J,SAAA,UAAAulB,EACA,CACA,OAAA/lB,GACAjE,KAAAipB,OAEAjpB,KAAAqpB,KAAA/rB,OAAAuH,WAAA7E,KAAA+oB,WACA/oB,KAAAipB,MAAAjpB,KAAAsO,GAAAtO,KAAAqpB,KAAA,KAEA,aC9DA7sB,OAAAC,eAAA5B,EAAA,cAA+CiB,OAAA,IAC/CjB,EAAA6tB,OAYA,SAAAyB,GACA,GAAAA,EAAAhsB,EAAA,CACA,MAAAkf,EAAgB/e,EAAAA,EAAAH,EAAAA,GAAUgsB,EAC1B,IAAA1uB,EACA6tB,EACA,MAAAc,EAAAjpB,YACA,WAAAkc,EACA/e,EACAwJ,GAAA,IAAAsiB,EAAA5nB,SAAA8mB,IAAAA,EAAA,IAAAc,EAAA7nB,OAAA9G,IAAAA,EAAA4uB,EAAAlsB,MAAA2J,GACAA,GAAAwhB,EACAc,EAAAhpB,YAAAkoB,EAAAxhB,GACAsiB,EAAAhpB,YAAA3F,IAAAA,EAAA4uB,EAAAlsB,IAAA2J,GAAAzG,KAAA9F,IAAA+tB,EAAA/tB,EAAAT,SAAAS,EAAAgG,UAEA,IAAA8b,EACA/e,EACA,IAAAgrB,IAAAA,EAAA,IAAAc,EAAA7nB,OAAA9G,IAAAA,EAAA4uB,EAAAlsB,MACA,IAAAmrB,EACA3X,QAAAC,QAAA0X,GACAc,EAAAE,QAAA7uB,IAAAA,EAAA4uB,EAAAlsB,KAAAkD,KAAA9F,GAAA+tB,EAAA/tB,GAEA+C,EACA,IAAA7C,IAAAA,EAAA4uB,EAAAlsB,IACA,IAAAwT,QAAAC,QAAAnW,IAAAA,EAAA4uB,EAAAlsB,IACA,CACA,uBAAAosB,SACA,UAAAnoB,MAAA,qBACAmoB,SAAAjjB,IAAA6iB,EACA,EAtCA,IAAAE,EAAA/rB,IACA,GAAAlB,WAAAotB,WACA,OAAAptB,WAAAotB,WAAAlsB,GACA,uBAAAa,OACA,OAAAA,OAAAC,KAAAd,EAAA,UACA,MAAA7C,EAAA6D,KAAAhB,GACA/C,EAAA,IAAA6B,WAAA3B,EAAAsC,QACA,QAAAD,EAAA,EAAoBA,EAAAvC,EAAAwC,SAAcD,EAClCvC,EAAAuC,GAAArC,EAAA+D,WAAA1B,GACA,OAAAvC,KCfAkvB,EAAA,GAGA,SAAAzrB,EAAA0rB,GAEA,IAAAC,EAAAF,EAAAC,GACA,QAAA1iB,IAAA2iB,EACA,OAAAA,EAAA9vB,QAGA,IAAAC,EAAA2vB,EAAAC,GAAA,CAGA7vB,QAAA,IAOA,OAHA+vB,EAAAF,GAAAljB,KAAA1M,EAAAD,QAAAC,EAAAA,EAAAD,QAAAmE,GAGAlE,EAAAD,OACA,mGCfA,MAAAgwB,EAAA7rB,EAAA,KACA8rB,EAAA9rB,EAAA,KACAqK,EAAArK,EAAA,KACAsK,EAAAtK,EAAA,KACA+rB,EAAA/rB,EAAA,IACAgsB,EAAAhsB,EAAA,KACAisB,EAAAjsB,EAAA,KACAksB,EAAAlsB,EAAA,KACAmsB,EAAAnsB,EAAA,KA6CMuB,EAAsC,CAC1C6qB,mBAAmB,EACnBpe,WAAY,SACZqe,cAAc,EACdlN,gBAAgB,EAChBzB,kBAAmB,KACnBsB,eAAgB,SAChB7F,aAAc,IACdjF,iBAAiB,EACjBoY,YAAY,EACZzgB,aAAc,SACd0gB,cAAc,EACdvM,eAAgB,uBA0BlB,MAWE,WAAAjf,CAAYe,GANJd,KAAAyI,aAA8B,GAE9BzI,KAAAwrB,UAAwC,IAAIzZ,IACnC/R,KAAAyrB,cAAgB,IAAIZ,EAAAa,QACrB1rB,KAAAya,aAA6Bza,KAAKyrB,cAAchlB,MAG9DzG,KAAKkC,MAAQ1F,OAAO2F,OAAO,GAAI5B,EAAiBO,GAChDd,KAAK2rB,aAAenvB,OAAO2F,OAAO,GAAI5B,EAAiBO,EACzD,CAEO,OAAA6G,GACL,IAAK,MAAMW,KAAWtI,KAAKwrB,UAAU1W,SAAUxM,EAAQ4C,QACvD,IAAK,MAAM0gB,KAAO5rB,KAAKyI,aACrBmjB,EAAIjkB,UAEN3H,KAAKyI,aAAa1K,OAAS,EAC3BiC,KAAKwrB,UAAU1iB,QACf9I,KAAKyrB,cAAc9jB,SACrB,CAEQ,aAAAkkB,IAAiBC,GACvB,IAAK,MAAMF,KAAOE,EAChB9rB,KAAKyI,aAAa/J,KAAKktB,EAE3B,CAEO,QAAAG,CAASC,SASd,GARAhsB,KAAK6R,UAAYma,EAGjBhsB,KAAKkK,UAAY,IAAIb,EAAAgE,cAAc2e,GACnChsB,KAAKmK,SAAW,IAAIb,EAAA2iB,aAAaD,EAAUhsB,KAAKkK,UAAWlK,KAAKkC,OAChElC,KAAKmK,SAASsQ,aAAe,IAAMza,KAAKyrB,cAAclkB,OAGlDvH,KAAKkC,MAAMkpB,kBAAmB,CAChC,MAAMc,EAA0C,QAAjC/f,EAAG6f,EAASG,QAAQC,qBAAa,IAAAjgB,EAAAA,EAAI,GACpD+f,EAAUG,kBAAmB,EAC7BH,EAAUI,mBAAoB,EAC9BJ,EAAUK,iBAAkB,EAC5BP,EAASG,QAAQC,cAAgBF,CACnC,CAiCA,GA/BAlsB,KAAK6rB,cACH7rB,KAAKkK,UACLlK,KAAKmK,SAGL6hB,EAASQ,OAAOC,mBAAmB,CAAEC,OAAQ,IAAK7mB,MAAO,KAAO+W,GAAU5c,KAAK2sB,QAAQ/P,IACvFoP,EAASQ,OAAOC,mBAAmB,CAAEC,OAAQ,IAAK7mB,MAAO,KAAO+W,GAAU5c,KAAK4sB,QAAQhQ,IACvFoP,EAASQ,OAAOC,mBAAmB,CAAE5mB,MAAO,KAAO+W,GAAU5c,KAAK6sB,KAAKjQ,IACvEoP,EAASQ,OAAOC,mBAAmB,CAAEC,OAAQ,IAAK7mB,MAAO,KAAO+W,GAAU5c,KAAK8sB,yBAAyBlQ,IAGxGoP,EAASe,SAASpS,IAAQ,IAAAxO,EAAA,OAAc,QAAbA,EAAAnM,KAAKmK,gBAAQ,IAAAgC,OAAA,EAAAA,EAAEuO,OAAOC,KAQjDqR,EAASQ,OAAOC,mBAAmB,CAAEO,cAAe,IAAKnnB,MAAO,KAAO,IAAM7F,KAAKkL,SAClF8gB,EAASQ,OAAOS,mBAAmB,CAAEpnB,MAAO,KAAO,IAAM7F,KAAKkL,SAC9D8gB,EAAS5f,MAAMwN,cAAcsT,eAAe,IAAMltB,KAAKkL,SAGvD8gB,EAAS1uB,OAAO6vB,eAAe,KAAK,IAAAhhB,EAAA,OAAc,QAAbA,EAAAnM,KAAKmK,gBAAQ,IAAAgC,OAAA,EAAAA,EAAE+M,kBAGpD8S,EAASoB,SAASxgB,IAAU,IAAAT,EAAA,OAAc,QAAbA,EAAAnM,KAAKmK,gBAAQ,IAAAgC,OAAA,EAAAA,EAAEmP,eAAe1O,MAIzD5M,KAAKkC,MAAMmpB,aAAc,CAC3B,MAAMgC,EAAe,IAAInC,EAAAoC,kBAAkBttB,KAAKmK,SAAWnK,KAAKkC,MAAOlC,KAAKkK,UAAY8hB,GAClFuB,EAAe,IAAItC,EAAAuC,aAAaxtB,KAAKkC,MAAOmrB,EAAcrB,GAChEhsB,KAAKwrB,UAAUtoB,IAAI,QAASqqB,GAC5BvtB,KAAK6rB,cACHG,EAAS5f,MAAMwN,cAAc6T,QAAQC,mBAAmB,CAAE7nB,MAAO,KAAO0nB,GAE5E,CAGA,GAAIvtB,KAAKkC,MAAMopB,WAAY,CACzB,MAAMqC,EAAa,IAAIxC,EAAAyC,gBAAgB5tB,KAAKmK,UACtC0jB,EAAa,IAAI/C,EAAAgD,WAAW9tB,KAAKkC,MAAOlC,KAAKkK,UAAYyjB,EAAY3B,GAC3EhsB,KAAKwrB,UAAUtoB,IAAI,MAAO2qB,GAC1B7tB,KAAK6rB,cACHG,EAAS5f,MAAMwN,cAAc6T,QAAQM,mBAAmB,KAAMF,GAElE,CAGA,GAAI7tB,KAAKkC,MAAMqpB,aAAc,CAC3B,MAAMyC,EAAe,IAAIhD,EAAAvD,kBAAkBznB,KAAKmK,UAC1C8jB,EAAe,IAAIlD,EAAAmD,qBAAqBluB,KAAKkC,MAAOlC,KAAKkK,UAAY8jB,EAAchC,GACzFhsB,KAAKwrB,UAAUtoB,IAAI,QAAS+qB,GAC5BjuB,KAAK6rB,cACHmC,EACAC,EACAjC,EAAS5f,MAAMwN,cAAc6T,QAAQU,mBAAmB,CAAEtoB,MAAO,KAAOooB,GAE5E,CACF,CAGO,KAAA/iB,SAELlL,KAAKkC,MAAMic,eAAiBne,KAAK2rB,aAAaxN,eAC9Cne,KAAKkC,MAAMwa,kBAAoB1c,KAAK2rB,aAAajP,kBAEpC,QAAbvQ,EAAAnM,KAAKmK,gBAAQ,IAAAgC,GAAAA,EAAEjB,QAEf,IAAK,MAAM5C,KAAWtI,KAAKwrB,UAAU1W,SACnCxM,EAAQ4C,QAEV,OAAO,CACT,CAEA,gBAAWiN,SACT,OAAoB,QAAbhM,EAAAnM,KAAKmK,gBAAQ,IAAAgC,OAAA,EAAAA,EAAEmM,cAAe,CACvC,CAEA,gBAAWH,CAAajH,SACT,QAAb/E,EAAAnM,KAAKmK,gBAAQ,IAAAgC,GAAAA,EAAE+L,SAAShH,GACxBlR,KAAKkC,MAAMiW,aAAejH,CAC5B,CAEA,gBAAWkd,GACT,OAAIpuB,KAAKmK,SACAnK,KAAKmK,SAASwO,YAEf,CACV,CAEA,mBAAWzF,GACT,OAAOlT,KAAKkC,MAAMgR,eACpB,CAEA,mBAAWA,CAAgBpX,SACzBkE,KAAKkC,MAAMgR,gBAAkBpX,EACf,QAAdqQ,EAAAnM,KAAKkK,iBAAS,IAAAiC,GAAAA,EAAE+G,gBAAgBpX,EAClC,CAEO,oBAAAggB,CAAqBrF,EAAWnD,SACrC,OAAoB,QAAbnH,EAAAnM,KAAKmK,gBAAQ,IAAAgC,OAAA,EAAAA,EAAE2P,qBAAqBrF,EAAGnD,EAChD,CAEO,uBAAAyI,CAAwBtF,EAAWnD,SACxC,OAAoB,QAAbnH,EAAAnM,KAAKmK,gBAAQ,IAAAgC,OAAA,EAAAA,EAAE4P,wBAAwBtF,EAAGnD,EACnD,CAEQ,OAAA+a,CAAQ/vB,SACA,QAAd6N,EAAAnM,KAAK6R,iBAAS,IAAA1F,GAAAA,EAAEC,MAAMK,MAAMnO,GAAG,EACjC,CAEQ,OAAAquB,CAAQ/P,GACd,IAAK,IAAI9e,EAAI,EAAGA,EAAI8e,EAAO7e,SAAUD,EAE5B,KADC8e,EAAO9e,KAEXkC,KAAKkC,MAAMic,gBAAiB,GAIlC,OAAO,CACT,CAEQ,OAAAyO,CAAQhQ,GACd,IAAK,IAAI9e,EAAI,EAAGA,EAAI8e,EAAO7e,SAAUD,EAE5B,KADC8e,EAAO9e,KAEXkC,KAAKkC,MAAMic,gBAAiB,GAIlC,OAAO,CACT,CAGQ,IAAA0O,CAAKjQ,GACX,QAAIA,EAAO,MAQP5c,KAAKkC,MAAMmpB,eACbrrB,KAAKquB,QAAQ,kBACN,EAGX,CAYQ,wBAAAvB,CAAyBlQ,mBAC/B,GAAIA,EAAO7e,OAAS,EAClB,OAAO,EAET,GAAa,IAAT6e,EAAO,GACT,OAAQA,EAAO,IACb,OAEE,OADA5c,KAAKquB,QAAQ,MAASzR,EAAO,QAA0B5c,KAAKkC,MAAMwa,uBAC3D,EACT,OACE1c,KAAKkC,MAAMwa,kBAAoB1c,KAAK2rB,aAAajP,kBACjD1c,KAAKquB,QAAQ,MAASzR,EAAO,QAA0B5c,KAAKkC,MAAMwa,sBAElE,IAAK,MAAMpU,KAAWtI,KAAKwrB,UAAU1W,SACnCxM,EAAQ4C,QAEV,OAAO,EACT,OAOE,OANI0R,EAAO7e,OAAS,KAAO6e,EAAO,aAAcxV,QAAUwV,EAAO,IA5P5C,MA6PnB5c,KAAKkC,MAAMwa,kBAAoBE,EAAO,GACtC5c,KAAKquB,QAAQ,MAASzR,EAAO,QAA0B5c,KAAKkC,MAAMwa,uBAElE1c,KAAKquB,QAAQ,MAASzR,EAAO,UAExB,EACT,OAEE,OADA5c,KAAKquB,QAAQ,MAASzR,EAAO,eACtB,EACT,QAEE,OADA5c,KAAKquB,QAAQ,MAASzR,EAAO,UACtB,EAGb,GAAa,IAATA,EAAO,GACT,OAAQA,EAAO,IAEb,OACE,IAAI1c,EAAkC,QAA1BqN,EAAc,QAAdpB,EAAAnM,KAAKkK,iBAAS,IAAAiC,OAAA,EAAAA,EAAEP,kBAAU,IAAA2B,OAAA,EAAAA,EAAE1B,IAAIC,OAAO5L,MAC/C8D,EAAmC,QAA1BsK,EAAc,QAAdpC,EAAAlM,KAAKkK,iBAAS,IAAAgC,OAAA,EAAAA,EAAEN,kBAAU,IAAA0C,OAAA,EAAAA,EAAEzC,IAAIC,OAAO9H,OACpD,IAAK9D,IAAU8D,EAAQ,CAGrB,MAAMmP,EAAW7J,EAAAqC,kBACjBzL,IAAuB,QAAdsT,EAAAxT,KAAK6R,iBAAS,IAAA2B,OAAA,EAAAA,EAAEzH,OAAQ,IAAMoH,EAASjT,MAChD8D,IAAwB,QAAdqc,EAAArgB,KAAK6R,iBAAS,IAAAwO,OAAA,EAAAA,EAAErU,OAAQ,IAAMmH,EAASnP,MACnD,CACA,GAAI9D,EAAQ8D,EAAShE,KAAKkC,MAAM8K,WAC9BhN,KAAKquB,QAAQ,MAASzR,EAAO,QAA0B1c,EAAMsM,QAAQ,MAAMxI,EAAOwI,QAAQ,WACrF,CAEL,MAAMiK,EAAI1a,KAAKmR,MAAMnR,KAAKuyB,KAAKtuB,KAAKkC,MAAM8K,aAC1ChN,KAAKquB,QAAQ,MAASzR,EAAO,QAA0BnG,KAAKA,KAC9D,CACA,OAAO,EACT,OAEE,MAAMA,EAAI1a,KAAKmR,MAAMnR,KAAKuyB,KAAKtuB,KAAKkC,MAAM8K,aAE1C,OADAhN,KAAKquB,QAAQ,MAASzR,EAAO,QAA0BnG,KAAKA,OACrD,EACT,QAEE,OADAzW,KAAKquB,QAAQ,MAASzR,EAAO,UACtB,EAKb,OADA5c,KAAKquB,QAAQ,MAASzR,EAAO,UACtB,CACT","sources":["webpack://ImageAddon/webpack/universalModuleDefinition","webpack://ImageAddon/../node_modules/sixel/lib/Colors.js","webpack://ImageAddon/../node_modules/sixel/lib/Decoder.js","webpack://ImageAddon/../node_modules/sixel/lib/wasm.js","webpack://ImageAddon/../src/common/Event.ts","webpack://ImageAddon/../src/common/Lifecycle.ts","webpack://ImageAddon/./src/IIPHandler.ts","webpack://ImageAddon/./src/IIPHeaderParser.ts","webpack://ImageAddon/./src/IIPImageStorage.ts","webpack://ImageAddon/./src/IIPMetrics.ts","webpack://ImageAddon/./src/ImageRenderer.ts","webpack://ImageAddon/./src/ImageStorage.ts","webpack://ImageAddon/./src/SixelHandler.ts","webpack://ImageAddon/./src/SixelImageStorage.ts","webpack://ImageAddon/./src/kitty/KittyGraphicsHandler.ts","webpack://ImageAddon/./src/kitty/KittyGraphicsTypes.ts","webpack://ImageAddon/./src/kitty/KittyImageStorage.ts","webpack://ImageAddon/../node_modules/xterm-wasm-parts/lib/base64/Base64Decoder.wasm.js","webpack://ImageAddon/../node_modules/xterm-wasm-parts/lib/qoi/QoiDecoder.wasm.js","webpack://ImageAddon/javascript/node_modules/inwasm-runtime/lib/index.cjs","webpack://ImageAddon/webpack/bootstrap","webpack://ImageAddon/./src/ImageAddon.ts"],"sourcesContent":["(function webpackUniversalModuleDefinition(root, factory) {\n\tif(typeof exports === 'object' && typeof module === 'object')\n\t\tmodule.exports = factory();\n\telse if(typeof define === 'function' && define.amd)\n\t\tdefine([], factory);\n\telse if(typeof exports === 'object')\n\t\texports[\"ImageAddon\"] = factory();\n\telse\n\t\troot[\"ImageAddon\"] = factory();\n})(globalThis, () => {\nreturn ","\"use strict\";\n/**\n * Copyright (c) 2019 Joerg Breitbart.\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.DEFAULT_FOREGROUND = exports.DEFAULT_BACKGROUND = exports.PALETTE_ANSI_256 = exports.PALETTE_VT340_GREY = exports.PALETTE_VT340_COLOR = exports.normalizeHLS = exports.normalizeRGB = exports.nearestColorIndex = exports.fromRGBA8888 = exports.toRGBA8888 = exports.alpha = exports.blue = exports.green = exports.red = exports.BIG_ENDIAN = void 0;\n// FIXME: cleanup this mess, move things either to decoder/encoder, keep only shared things\n// system endianess\nexports.BIG_ENDIAN = new Uint8Array(new Uint32Array([0xFF000000]).buffer)[0] === 0xFF;\nif (exports.BIG_ENDIAN) {\n console.warn('BE platform detected. This version of node-sixel works only on LE properly.');\n}\n// channel values\nfunction red(n) {\n return n & 0xFF;\n}\nexports.red = red;\nfunction green(n) {\n return (n >>> 8) & 0xFF;\n}\nexports.green = green;\nfunction blue(n) {\n return (n >>> 16) & 0xFF;\n}\nexports.blue = blue;\nfunction alpha(n) {\n return (n >>> 24) & 0xFF;\n}\nexports.alpha = alpha;\n/**\n * Convert RGB channels to native color RGBA8888.\n */\nfunction toRGBA8888(r, g, b, a = 255) {\n return ((a & 0xFF) << 24 | (b & 0xFF) << 16 | (g & 0xFF) << 8 | (r & 0xFF)) >>> 0; // ABGR32\n}\nexports.toRGBA8888 = toRGBA8888;\n/**\n * Convert native color to [r, g, b, a].\n */\nfunction fromRGBA8888(color) {\n return [color & 0xFF, (color >> 8) & 0xFF, (color >> 16) & 0xFF, color >>> 24];\n}\nexports.fromRGBA8888 = fromRGBA8888;\n/**\n * Get index of nearest color in `palette` for `color`.\n * Uses euclidean distance without any luminescence correction.\n */\nfunction nearestColorIndex(color, palette) {\n const r = red(color);\n const g = green(color);\n const b = blue(color);\n let min = Number.MAX_SAFE_INTEGER;\n let idx = -1;\n // use euclidean distance (manhattan gives very poor results)\n for (let i = 0; i < palette.length; ++i) {\n const dr = r - palette[i][0];\n const dg = g - palette[i][1];\n const db = b - palette[i][2];\n const d = dr * dr + dg * dg + db * db;\n if (!d)\n return i;\n if (d < min) {\n min = d;\n idx = i;\n }\n }\n return idx;\n}\nexports.nearestColorIndex = nearestColorIndex;\n// color conversions\n// HLS taken from: http://www.niwa.nu/2013/05/math-behind-colorspace-conversions-rgb-hsl\nfunction clamp(low, high, value) {\n return Math.max(low, Math.min(value, high));\n}\nfunction h2c(t1, t2, c) {\n if (c < 0)\n c += 1;\n if (c > 1)\n c -= 1;\n return c * 6 < 1\n ? t2 + (t1 - t2) * 6 * c\n : c * 2 < 1\n ? t1\n : c * 3 < 2\n ? t2 + (t1 - t2) * (4 - c * 6)\n : t2;\n}\nfunction HLStoRGB(h, l, s) {\n if (!s) {\n const v = Math.round(l * 255);\n return toRGBA8888(v, v, v);\n }\n const t1 = l < 0.5 ? l * (1 + s) : l + s - l * s;\n const t2 = 2 * l - t1;\n return toRGBA8888(clamp(0, 255, Math.round(h2c(t1, t2, h + 1 / 3) * 255)), clamp(0, 255, Math.round(h2c(t1, t2, h) * 255)), clamp(0, 255, Math.round(h2c(t1, t2, h - 1 / 3) * 255)));\n}\n/**\n * Normalize SIXEL RGB values (percent based, 0-100) to RGBA8888.\n */\nfunction normalizeRGB(r, g, b) {\n return (0xFF000000 | Math.round(b / 100 * 255) << 16 | Math.round(g / 100 * 255) << 8 | Math.round(r / 100 * 255)) >>> 0; // ABGR32\n}\nexports.normalizeRGB = normalizeRGB;\n/**\n * Normalize SIXEL HLS values to RGBA8888. Applies hue correction of +240°.\n */\nfunction normalizeHLS(h, l, s) {\n // Note: hue value is turned by 240° in VT340, all values given as fractions\n return HLStoRGB((h + 240 % 360) / 360, l / 100, s / 100);\n}\nexports.normalizeHLS = normalizeHLS;\n/**\n * default palettes\n */\n// FIXME: move palettes to Decoder.ts\n/**\n * 16 predefined color registers of VT340 (values in %):\n * ```\n * R G B\n * 0 Black 0 0 0\n * 1 Blue 20 20 80\n * 2 Red 80 13 13\n * 3 Green 20 80 20\n * 4 Magenta 80 20 80\n * 5 Cyan 20 80 80\n * 6 Yellow 80 80 20\n * 7 Gray 50% 53 53 53\n * 8 Gray 25% 26 26 26\n * 9 Blue* 33 33 60\n * 10 Red* 60 26 26\n * 11 Green* 33 60 33\n * 12 Magenta* 60 33 60\n * 13 Cyan* 33 60 60\n * 14 Yellow* 60 60 33\n * 15 Gray 75% 80 80 80\n * ```\n * (*) less saturated\n *\n * @see https://vt100.net/docs/vt3xx-gp/chapter2.html#S2.4\n*/\nexports.PALETTE_VT340_COLOR = new Uint32Array([\n normalizeRGB(0, 0, 0),\n normalizeRGB(20, 20, 80),\n normalizeRGB(80, 13, 13),\n normalizeRGB(20, 80, 20),\n normalizeRGB(80, 20, 80),\n normalizeRGB(20, 80, 80),\n normalizeRGB(80, 80, 20),\n normalizeRGB(53, 53, 53),\n normalizeRGB(26, 26, 26),\n normalizeRGB(33, 33, 60),\n normalizeRGB(60, 26, 26),\n normalizeRGB(33, 60, 33),\n normalizeRGB(60, 33, 60),\n normalizeRGB(33, 60, 60),\n normalizeRGB(60, 60, 33),\n normalizeRGB(80, 80, 80)\n]);\n/**\n * 16 predefined monochrome registers of VT340 (values in %):\n * ```\n * R G B\n * 0 Black 0 0 0\n * 1 Gray-2 13 13 13\n * 2 Gray-4 26 26 26\n * 3 Gray-6 40 40 40\n * 4 Gray-1 6 6 6\n * 5 Gray-3 20 20 20\n * 6 Gray-5 33 33 33\n * 7 White 7 46 46 46\n * 8 Black 0 0 0 0\n * 9 Gray-2 13 13 13\n * 10 Gray-4 26 26 26\n * 11 Gray-6 40 40 40\n * 12 Gray-1 6 6 6\n * 13 Gray-3 20 20 20\n * 14 Gray-5 33 33 33\n * 15 White 7 46 46 46\n * ```\n *\n * @see https://vt100.net/docs/vt3xx-gp/chapter2.html#S2.4\n */\nexports.PALETTE_VT340_GREY = new Uint32Array([\n normalizeRGB(0, 0, 0),\n normalizeRGB(13, 13, 13),\n normalizeRGB(26, 26, 26),\n normalizeRGB(40, 40, 40),\n normalizeRGB(6, 6, 6),\n normalizeRGB(20, 20, 20),\n normalizeRGB(33, 33, 33),\n normalizeRGB(46, 46, 46),\n normalizeRGB(0, 0, 0),\n normalizeRGB(13, 13, 13),\n normalizeRGB(26, 26, 26),\n normalizeRGB(40, 40, 40),\n normalizeRGB(6, 6, 6),\n normalizeRGB(20, 20, 20),\n normalizeRGB(33, 33, 33),\n normalizeRGB(46, 46, 46)\n]);\n/**\n * 256 predefined ANSI colors.\n *\n * @see https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit\n */\nexports.PALETTE_ANSI_256 = (() => {\n // 16 lower colors (taken from xterm)\n const p = [\n toRGBA8888(0, 0, 0),\n toRGBA8888(205, 0, 0),\n toRGBA8888(0, 205, 0),\n toRGBA8888(205, 205, 0),\n toRGBA8888(0, 0, 238),\n toRGBA8888(205, 0, 205),\n toRGBA8888(0, 250, 205),\n toRGBA8888(229, 229, 229),\n toRGBA8888(127, 127, 127),\n toRGBA8888(255, 0, 0),\n toRGBA8888(0, 255, 0),\n toRGBA8888(255, 255, 0),\n toRGBA8888(92, 92, 255),\n toRGBA8888(255, 0, 255),\n toRGBA8888(0, 255, 255),\n toRGBA8888(255, 255, 255),\n ];\n // colors up to 232\n const d = [0, 95, 135, 175, 215, 255];\n for (let r = 0; r < 6; ++r) {\n for (let g = 0; g < 6; ++g) {\n for (let b = 0; b < 6; ++b) {\n p.push(toRGBA8888(d[r], d[g], d[b]));\n }\n }\n }\n // grey scale to up 255\n for (let v = 8; v <= 238; v += 10) {\n p.push(toRGBA8888(v, v, v));\n }\n return new Uint32Array(p);\n})();\n/**\n * Background: Black by default.\n * Foreground: White by default.\n *\n * Background color is used whenever a fill color is needed and not explicitly set.\n * Foreground color is used as default initial sixel color.\n */\nexports.DEFAULT_BACKGROUND = toRGBA8888(0, 0, 0, 255);\nexports.DEFAULT_FOREGROUND = toRGBA8888(255, 255, 255, 255);\n//# sourceMappingURL=Colors.js.map","\"use strict\";\n/**\n * Copyright (c) 2021 Joerg Breitbart.\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.decodeAsync = exports.decode = exports.Decoder = exports.DecoderAsync = void 0;\nconst Colors_1 = require(\"./Colors\");\nconst wasm_1 = require(\"./wasm\");\n/* istanbul ignore next */\nfunction decodeBase64(s) {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(s, 'base64');\n }\n const bytestring = atob(s);\n const result = new Uint8Array(bytestring.length);\n for (let i = 0; i < result.length; ++i) {\n result[i] = bytestring.charCodeAt(i);\n }\n return result;\n}\nconst WASM_BYTES = decodeBase64(wasm_1.LIMITS.BYTES);\nlet WASM_MODULE;\n// empty canvas\nconst NULL_CANVAS = new Uint32Array();\n// proxy for lazy binding of decoder methods to wasm env callbacks\nclass CallbackProxy {\n constructor() {\n this.bandHandler = (width) => 1;\n this.modeHandler = (mode) => 1;\n }\n handle_band(width) {\n return this.bandHandler(width);\n }\n mode_parsed(mode) {\n return this.modeHandler(mode);\n }\n}\n// default decoder options\nconst DEFAULT_OPTIONS = {\n memoryLimit: 2048 * 65536,\n sixelColor: Colors_1.DEFAULT_FOREGROUND,\n fillColor: Colors_1.DEFAULT_BACKGROUND,\n palette: Colors_1.PALETTE_VT340_COLOR,\n paletteLimit: wasm_1.LIMITS.PALETTE_SIZE,\n truncate: true\n};\n/**\n * Create a decoder instance asynchronously.\n * To be used in the browser main thread.\n */\nfunction DecoderAsync(opts) {\n const cbProxy = new CallbackProxy();\n const importObj = {\n env: {\n handle_band: cbProxy.handle_band.bind(cbProxy),\n mode_parsed: cbProxy.mode_parsed.bind(cbProxy)\n }\n };\n return WebAssembly.instantiate(WASM_MODULE || WASM_BYTES, importObj)\n .then((inst) => {\n WASM_MODULE = WASM_MODULE || inst.module;\n return new Decoder(opts, inst.instance || inst, cbProxy);\n });\n}\nexports.DecoderAsync = DecoderAsync;\n/**\n * Decoder - web assembly based sixel stream decoder.\n *\n * Usage pattern:\n * - call `init` to initialize decoder for new image\n * - feed data chunks to `decode` or `decodeString`\n * - grab pixels from `data32`\n * - optional: call `release` to free memory (e.g. after big images)\n * - start over with next image by calling `init`\n *\n * Properties:\n * - max width of 2^14 - 4 pixels (compile time setting in wasm)\n * - no explicit height limit (only limited by memory)\n * - max 4096 colors palette (compile time setting in wasm)\n *\n * Explanation operation modes:\n * - M1 Mode chosen for level 1 images (no raster attributes),\n * or for level 2 images with `truncate=false`.\n * - M2 Mode chosen for level 2 images with `truncate=true` (default).\n * While this mode is not fully spec conform (decoder not expected to truncate),\n * it is what spec conform encoders should create (should not excess raster).\n * This mode has several advantages:\n * - ~15% faster decoding speed\n * - image dimensions can be evaluated early without processing the whole data\n * - faster pixel access in `data32` (precalulated)\n * - image height is not reported as multiple of 6 pixels\n * - M0 Undecided mode state after `init`.\n * The level of an image is determined during early decoding based on the fact,\n * whether the data contains valid raster attributes before any sixel data.\n * Until then the mode of an image is marked as M0, meaning the real operation mode\n * could not be decided yet.\n */\nclass Decoder {\n /**\n * Synchonous ctor. Can be called from nodejs or a webworker context.\n * For instantiation in the browser main thread use `WasmDecoderAsync` instead.\n */\n constructor(opts, _instance, _cbProxy) {\n this._PIXEL_OFFSET = wasm_1.LIMITS.MAX_WIDTH + 4;\n this._canvas = NULL_CANVAS;\n this._bandWidths = [];\n this._maxWidth = 0;\n this._minWidth = wasm_1.LIMITS.MAX_WIDTH;\n this._lastOffset = 0;\n this._currentHeight = 0;\n this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);\n if (this._opts.paletteLimit > wasm_1.LIMITS.PALETTE_SIZE) {\n throw new Error(`DecoderOptions.paletteLimit must not exceed ${wasm_1.LIMITS.PALETTE_SIZE}`);\n }\n if (!_instance) {\n const module = WASM_MODULE || (WASM_MODULE = new WebAssembly.Module(WASM_BYTES));\n _instance = new WebAssembly.Instance(module, {\n env: {\n handle_band: this._handle_band.bind(this),\n mode_parsed: this._initCanvas.bind(this)\n }\n });\n }\n else {\n _cbProxy.bandHandler = this._handle_band.bind(this);\n _cbProxy.modeHandler = this._initCanvas.bind(this);\n }\n this._instance = _instance;\n this._wasm = this._instance.exports;\n this._chunk = new Uint8Array(this._wasm.memory.buffer, this._wasm.get_chunk_address(), wasm_1.LIMITS.CHUNK_SIZE);\n this._states = new Uint32Array(this._wasm.memory.buffer, this._wasm.get_state_address(), 12);\n this._palette = new Uint32Array(this._wasm.memory.buffer, this._wasm.get_palette_address(), wasm_1.LIMITS.PALETTE_SIZE);\n this._palette.set(this._opts.palette);\n this._pSrc = new Uint32Array(this._wasm.memory.buffer, this._wasm.get_p0_address());\n this._wasm.init(Colors_1.DEFAULT_FOREGROUND, 0, this._opts.paletteLimit, 0);\n }\n // some readonly parser states for internal usage\n get _fillColor() { return this._states[0]; }\n get _truncate() { return this._states[8]; }\n get _rasterWidth() { return this._states[6]; }\n get _rasterHeight() { return this._states[7]; }\n get _width() { return this._states[2] ? this._states[2] - 4 : 0; }\n get _height() { return this._states[3]; }\n get _level() { return this._states[9]; }\n get _mode() { return this._states[10]; }\n get _paletteLimit() { return this._states[11]; }\n _initCanvas(mode) {\n if (mode === 2 /* M2 */) {\n const pixels = this.width * this.height;\n if (pixels > this._canvas.length) {\n if (this._opts.memoryLimit && pixels * 4 > this._opts.memoryLimit) {\n this.release();\n throw new Error('image exceeds memory limit');\n }\n this._canvas = new Uint32Array(pixels);\n }\n this._maxWidth = this._width;\n }\n else if (mode === 1 /* M1 */) {\n if (this._level === 2) {\n // got raster attributes, use them as initial size hint\n const pixels = Math.min(this._rasterWidth, wasm_1.LIMITS.MAX_WIDTH) * this._rasterHeight;\n if (pixels > this._canvas.length) {\n if (this._opts.memoryLimit && pixels * 4 > this._opts.memoryLimit) {\n this.release();\n throw new Error('image exceeds memory limit');\n }\n this._canvas = new Uint32Array(pixels);\n }\n }\n else {\n // else fallback to generic resizing, starting with 256*256 pixels\n if (this._canvas.length < 65536) {\n this._canvas = new Uint32Array(65536);\n }\n }\n }\n return 0; // 0 - continue, 1 - abort right away\n }\n _realloc(offset, additionalPixels) {\n const pixels = offset + additionalPixels;\n if (pixels > this._canvas.length) {\n if (this._opts.memoryLimit && pixels * 4 > this._opts.memoryLimit) {\n this.release();\n throw new Error('image exceeds memory limit');\n }\n // extend in 65536 pixel blocks\n const newCanvas = new Uint32Array(Math.ceil(pixels / 65536) * 65536);\n newCanvas.set(this._canvas);\n this._canvas = newCanvas;\n }\n }\n _handle_band(width) {\n const adv = this._PIXEL_OFFSET;\n let offset = this._lastOffset;\n if (this._mode === 2 /* M2 */) {\n let remaining = this.height - this._currentHeight;\n let c = 0;\n while (c < 6 && remaining > 0) {\n this._canvas.set(this._pSrc.subarray(adv * c, adv * c + width), offset + width * c);\n c++;\n remaining--;\n }\n this._lastOffset += width * c;\n this._currentHeight += c;\n }\n else if (this._mode === 1 /* M1 */) {\n this._realloc(offset, width * 6);\n this._maxWidth = Math.max(this._maxWidth, width);\n this._minWidth = Math.min(this._minWidth, width);\n for (let i = 0; i < 6; ++i) {\n this._canvas.set(this._pSrc.subarray(adv * i, adv * i + width), offset + width * i);\n }\n this._bandWidths.push(width);\n this._lastOffset += width * 6;\n this._currentHeight += 6;\n }\n return 0; // 0 - continue, 1 - abort right away\n }\n /**\n * Width of the image data.\n * Returns the rasterWidth in level2/truncating mode,\n * otherwise the max width, that has been seen so far.\n */\n get width() {\n return this._mode !== 1 /* M1 */\n ? this._width\n : Math.max(this._maxWidth, this._wasm.current_width());\n }\n /**\n * Height of the image data.\n * Returns the rasterHeight in level2/truncating mode,\n * otherwise height touched by sixels.\n */\n get height() {\n return this._mode !== 1 /* M1 */\n ? this._height\n : this._wasm.current_width()\n ? this._bandWidths.length * 6 + this._wasm.current_height()\n : this._bandWidths.length * 6;\n }\n /**\n * Get active palette colors as RGBA8888[] (borrowed).\n */\n get palette() {\n return this._palette.subarray(0, this._paletteLimit);\n }\n /**\n * Get the memory used by the decoder.\n *\n * This is a rough estimate accounting the wasm instance memory\n * and pixel buffers held on JS side (real value will be slightly\n * higher due to JS book-keeping).\n * Note that the decoder does not free ressources on its own,\n * call `release` to free excess memory.\n */\n get memoryUsage() {\n return this._canvas.byteLength + this._wasm.memory.buffer.byteLength + 8 * this._bandWidths.length;\n }\n /**\n * Get various properties of the decoder and the current image.\n */\n get properties() {\n return {\n width: this.width,\n height: this.height,\n mode: this._mode,\n level: this._level,\n truncate: !!this._truncate,\n paletteLimit: this._paletteLimit,\n fillColor: this._fillColor,\n memUsage: this.memoryUsage,\n rasterAttributes: {\n numerator: this._states[4],\n denominator: this._states[5],\n width: this._rasterWidth,\n height: this._rasterHeight,\n }\n };\n }\n /**\n * Initialize decoder for next image. Must be called before\n * any calls to `decode` or `decodeString`.\n */\n // FIXME: reorder arguments, better palette handling\n init(fillColor = this._opts.fillColor, palette = this._opts.palette, paletteLimit = this._opts.paletteLimit, truncate = this._opts.truncate) {\n this._wasm.init(this._opts.sixelColor, fillColor, paletteLimit, truncate ? 1 : 0);\n if (palette) {\n this._palette.set(palette.subarray(0, wasm_1.LIMITS.PALETTE_SIZE));\n }\n this._bandWidths.length = 0;\n this._maxWidth = 0;\n this._minWidth = wasm_1.LIMITS.MAX_WIDTH;\n this._lastOffset = 0;\n this._currentHeight = 0;\n }\n /**\n * Decode next chunk of data from start to end index (exclusive).\n * @throws Will throw if the image exceeds the memory limit.\n */\n decode(data, start = 0, end = data.length) {\n let p = start;\n while (p < end) {\n const length = Math.min(end - p, wasm_1.LIMITS.CHUNK_SIZE);\n this._chunk.set(data.subarray(p, p += length));\n this._wasm.decode(0, length);\n }\n }\n /**\n * Decode next chunk of string data from start to end index (exclusive).\n * Note: Decoding from string data is rather slow, use `decode` with byte data instead.\n * @throws Will throw if the image exceeds the memory limit.\n */\n decodeString(data, start = 0, end = data.length) {\n let p = start;\n while (p < end) {\n const length = Math.min(end - p, wasm_1.LIMITS.CHUNK_SIZE);\n for (let i = 0, j = p; i < length; ++i, ++j) {\n this._chunk[i] = data.charCodeAt(j);\n }\n p += length;\n this._wasm.decode(0, length);\n }\n }\n /**\n * Get current pixel data as 32-bit typed array (RGBA8888).\n * Also peeks into pixel data of the current band, that got not pushed yet.\n */\n get data32() {\n if (this._mode === 0 /* M0 */ || !this.width || !this.height) {\n return NULL_CANVAS;\n }\n // get width of pending band to peek into left-over data\n const currentWidth = this._wasm.current_width();\n if (this._mode === 2 /* M2 */) {\n let remaining = this.height - this._currentHeight;\n if (remaining > 0) {\n const adv = this._PIXEL_OFFSET;\n let offset = this._lastOffset;\n let c = 0;\n while (c < 6 && remaining > 0) {\n this._canvas.set(this._pSrc.subarray(adv * c, adv * c + currentWidth), offset + currentWidth * c);\n c++;\n remaining--;\n }\n if (remaining) {\n this._canvas.fill(this._fillColor, offset + currentWidth * c);\n }\n }\n return this._canvas.subarray(0, this.width * this.height);\n }\n if (this._mode === 1 /* M1 */) {\n if (this._minWidth === this._maxWidth) {\n let escape = false;\n if (currentWidth) {\n if (currentWidth !== this._minWidth) {\n escape = true;\n }\n else {\n const adv = this._PIXEL_OFFSET;\n let offset = this._lastOffset;\n this._realloc(offset, currentWidth * 6);\n for (let i = 0; i < 6; ++i) {\n this._canvas.set(this._pSrc.subarray(adv * i, adv * i + currentWidth), offset + currentWidth * i);\n }\n }\n }\n if (!escape) {\n return this._canvas.subarray(0, this.width * this.height);\n }\n }\n // worst case: re-align pixels if we have bands with different width\n // This is somewhat allocation intensive, any way to do that in-place, and just once?\n const final = new Uint32Array(this.width * this.height);\n final.fill(this._fillColor);\n let finalOffset = 0;\n let start = 0;\n for (let i = 0; i < this._bandWidths.length; ++i) {\n const bw = this._bandWidths[i];\n for (let p = 0; p < 6; ++p) {\n final.set(this._canvas.subarray(start, start += bw), finalOffset);\n finalOffset += this.width;\n }\n }\n // also handle left-over pixels of the current band\n if (currentWidth) {\n const adv = this._PIXEL_OFFSET;\n // other than finished bands, this runs only up to currentHeight\n const currentHeight = this._wasm.current_height();\n for (let i = 0; i < currentHeight; ++i) {\n final.set(this._pSrc.subarray(adv * i, adv * i + currentWidth), finalOffset + this.width * i);\n }\n }\n return final;\n }\n // fallthrough for all not handled cases\n return NULL_CANVAS;\n }\n /**\n * Same as `data32`, but returning pixel data as Uint8ClampedArray suitable\n * for direct usage with `ImageData`.\n */\n get data8() {\n return new Uint8ClampedArray(this.data32.buffer, 0, this.width * this.height * 4);\n }\n /**\n * Release image ressources on JS side held by the decoder.\n *\n * The decoder tries to re-use memory ressources of a previous image\n * to lower allocation and GC pressure. Decoding a single big image\n * will grow the memory usage of the decoder permanently.\n * Call `release` to reset the internal buffers and free the memory.\n * Note that this destroys the image data, call it when done processing\n * a rather big image, otherwise it is not needed. Use `memoryUsage`\n * to decide, whether the held memory is still within your limits.\n * This does not affect the wasm module (operates on static memory).\n */\n release() {\n this._canvas = NULL_CANVAS;\n this._bandWidths.length = 0;\n this._maxWidth = 0;\n this._minWidth = wasm_1.LIMITS.MAX_WIDTH;\n // also nullify parser states in wasm to avoid\n // width/height reporting potential out-of-bound values\n this._wasm.init(Colors_1.DEFAULT_FOREGROUND, 0, this._opts.paletteLimit, 0);\n }\n}\nexports.Decoder = Decoder;\n/**\n * Convenient decoding functions for easier usage.\n *\n * These can be used for casual decoding of sixel images,\n * that dont come in as stream chunks.\n * Note that the functions instantiate a stream decoder for every call,\n * which comes with a performance penalty of ~25%.\n */\n/**\n * Decode function with synchronous wasm loading.\n * Can be used in a web worker or in nodejs. Does not work reliable in normal browser context.\n * @throws Will throw if the image exceeds the memory limit.\n */\nfunction decode(data, opts) {\n const dec = new Decoder(opts);\n dec.init();\n typeof data === 'string' ? dec.decodeString(data) : dec.decode(data);\n return {\n width: dec.width,\n height: dec.height,\n data32: dec.data32,\n data8: dec.data8\n };\n}\nexports.decode = decode;\n/**\n * Decode function with asynchronous wasm loading.\n * Use this version in normal browser context.\n * @throws Will throw if the image exceeds the memory limit.\n */\nasync function decodeAsync(data, opts) {\n const dec = await DecoderAsync(opts);\n dec.init();\n typeof data === 'string' ? dec.decodeString(data) : dec.decode(data);\n return {\n width: dec.width,\n height: dec.height,\n data32: dec.data32,\n data8: dec.data8\n };\n}\nexports.decodeAsync = decodeAsync;\n//# sourceMappingURL=Decoder.js.map","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.LIMITS = void 0;\nexports.LIMITS = {\n CHUNK_SIZE: 16384,\n PALETTE_SIZE: 4096,\n MAX_WIDTH: 16384,\n BYTES: 'AGFzbQEAAAABJAdgAAF/YAJ/fwBgA39/fwF/YAF/AX9gAABgBH9/f38AYAF/AAIlAgNlbnYLaGFuZGxlX2JhbmQAAwNlbnYLbW9kZV9wYXJzZWQAAwMTEgQAAAAAAQQBAQUBAAACAgAGAwQFAXABBwcFBAEBBwcGCAF/AUGAihoLB9wBDgZtZW1vcnkCABFnZXRfc3RhdGVfYWRkcmVzcwADEWdldF9jaHVua19hZGRyZXNzAAQOZ2V0X3AwX2FkZHJlc3MABRNnZXRfcGFsZXR0ZV9hZGRyZXNzAAYEaW5pdAALBmRlY29kZQAMDWN1cnJlbnRfd2lkdGgADQ5jdXJyZW50X2hlaWdodAAOGV9faW5kaXJlY3RfZnVuY3Rpb25fdGFibGUBAAtfaW5pdGlhbGl6ZQACCXN0YWNrU2F2ZQARDHN0YWNrUmVzdG9yZQASCnN0YWNrQWxsb2MAEwkMAQBBAQsGCgcJDxACDAEBCq5UEgMAAQsFAEGgCAsGAEGQiQELBgBBsIkCCwUAQZAJC+okAQh/QeQIKAIAIQVB4AgoAgAhA0HoCCgCACEIIAFBkIkBaiIJQf8BOgAAIAAgAUgEQCAAQZCJAWohBgNAIAMhBCAGQQFqIQECQCAGLQAAQf8AcSIDQTBrQQlLBEAgASEGDAELQewIKAIAQQJ0QewIaiICKAIAIQADQCACIAMgAEEKbGpBMGsiADYCACABLQAAIQMgAUEBaiIGIQEgA0H/AHEiA0Ewa0EKSQ0ACwsCQAJAAkACQAJAAkACQAJ/AkACQCADQT9rIgBBP00EQCAERQ0BIARBIUYEQAJAQfAIKAIAIgFBASABGyIHIAhqIgFB1AgoAgAiA0gNACADQf//AEoNAANAIANBAnQiAkGgiQJqIgRBoAgpAwA3AwAgAkGoiQJqQaAIKQMANwMAIAJBsIkCakGgCCkDADcDACACQbiJAmpBoAgpAwA3AwAgAkHAiQJqQaAIKQMANwMAIAJByIkCakGgCCkDADcDACACQdCJAmpBoAgpAwA3AwAgAkHYiQJqQaAIKQMANwMAIAJB4IkCakGgCCkDADcDACACQeiJAmpBoAgpAwA3AwAgAkHwiQJqQaAIKQMANwMAIAJB+IkCakGgCCkDADcDACACQYCKAmpBoAgpAwA3AwAgAkGIigJqQaAIKQMANwMAIAJBkIoCakGgCCkDADcDACACQZiKAmpBoAgpAwA3AwAgAkGgigJqQaAIKQMANwMAIAJBqIoCakGgCCkDADcDACACQbCKAmpBoAgpAwA3AwAgAkG4igJqQaAIKQMANwMAIAJBwIoCakGgCCkDADcDACACQciKAmpBoAgpAwA3AwAgAkHQigJqQaAIKQMANwMAIAJB2IoCakGgCCkDADcDACACQeCKAmpBoAgpAwA3AwAgAkHoigJqQaAIKQMANwMAIAJB8IoCakGgCCkDADcDACACQfiKAmpBoAgpAwA3AwAgAkGAiwJqQaAIKQMANwMAIAJBiIsCakGgCCkDADcDACACQZCLAmpBoAgpAwA3AwAgAkGYiwJqQaAIKQMANwMAIAJBoIsCakGgCCkDADcDACACQaiLAmpBoAgpAwA3AwAgAkGwiwJqQaAIKQMANwMAIAJBuIsCakGgCCkDADcDACACQcCLAmpBoAgpAwA3AwAgAkHIiwJqQaAIKQMANwMAIAJB0IsCakGgCCkDADcDACACQdiLAmpBoAgpAwA3AwAgAkHgiwJqQaAIKQMANwMAIAJB6IsCakGgCCkDADcDACACQfCLAmpBoAgpAwA3AwAgAkH4iwJqQaAIKQMANwMAIAJBgIwCakGgCCkDADcDACACQYiMAmpBoAgpAwA3AwAgAkGQjAJqQaAIKQMANwMAIAJBmIwCakGgCCkDADcDACACQaCMAmpBoAgpAwA3AwAgAkGojAJqQaAIKQMANwMAIAJBsIwCakGgCCkDADcDACACQbiMAmpBoAgpAwA3AwAgAkHAjAJqQaAIKQMANwMAIAJByIwCakGgCCkDADcDACACQdCMAmpBoAgpAwA3AwAgAkHYjAJqQaAIKQMANwMAIAJB4IwCakGgCCkDADcDACACQeiMAmpBoAgpAwA3AwAgAkHwjAJqQaAIKQMANwMAIAJB+IwCakGgCCkDADcDACACQYCNAmpBoAgpAwA3AwAgAkGIjQJqQaAIKQMANwMAIAJBkI0CakGgCCkDADcDACACQZiNAmpBoAgpAwA3AwAgAkGwiQZqIARBgAT8CgAAQdQIKAIAQQJ0QcCJCmogBEGABPwKAABB1AgoAgBBAnRB0IkOaiAEQYAE/AoAAEHUCCgCAEECdEHgiRJqIARBgAT8CgAAQdQIKAIAQQJ0QfCJFmogBEGABPwKAABB1AhB1AgoAgAiAkGAAWoiAzYCACABIANIDQEgAkGA/wBIDQALCwJAIABFDQAgCEH//wBLDQBBgIABIAhrIAcgAUH//wBLGyECAkAgAEEBcUUNACACRQ0AIAhBAnRBoIkCaiEDIAIhBCACQQdxIgcEQANAIAMgBTYCACADQQRqIQMgBEEBayEEIAdBAWsiBw0ACwsgAkEBa0EHSQ0AA0AgAyAFNgIcIAMgBTYCGCADIAU2AhQgAyAFNgIQIAMgBTYCDCADIAU2AgggAyAFNgIEIAMgBTYCACADQSBqIQMgBEEIayIEDQALCwJAIABBAnFFDQAgAkUNACAIQQJ0QbCJBmohAyACIQQgAkEHcSIHBEADQCADIAU2AgAgA0EEaiEDIARBAWshBCAHQQFrIgcNAAsLIAJBAWtBB0kNAANAIAMgBTYCHCADIAU2AhggAyAFNgIUIAMgBTYCECADIAU2AgwgAyAFNgIIIAMgBTYCBCADIAU2AgAgA0EgaiEDIARBCGsiBA0ACwsCQCAAQQRxRQ0AIAJFDQAgCEECdEHAiQpqIQMgAiEEIAJBB3EiBwRAA0AgAyAFNgIAIANBBGohAyAEQQFrIQQgB0EBayIHDQALCyACQQFrQQdJDQADQCADIAU2AhwgAyAFNgIYIAMgBTYCFCADIAU2AhAgAyAFNgIMIAMgBTYCCCADIAU2AgQgAyAFNgIAIANBIGohAyAEQQhrIgQNAAsLAkAgAEEIcUUNACACRQ0AIAhBAnRB0IkOaiEDIAIhBCACQQdxIgcEQANAIAMgBTYCACADQQRqIQMgBEEBayEEIAdBAWsiBw0ACwsgAkEBa0EHSQ0AA0AgAyAFNgIcIAMgBTYCGCADIAU2AhQgAyAFNgIQIAMgBTYCDCADIAU2AgggAyAFNgIEIAMgBTYCACADQSBqIQMgBEEIayIEDQALCwJAIABBEHFFDQAgAkUNACAIQQJ0QeCJEmohAyACIQQgAkEHcSIHBEADQCADIAU2AgAgA0EEaiEDIARBAWshBCAHQQFrIgcNAAsLIAJBAWtBB0kNAANAIAMgBTYCHCADIAU2AhggAyAFNgIUIAMgBTYCECADIAU2AgwgAyAFNgIIIAMgBTYCBCADIAU2AgAgA0EgaiEDIARBCGsiBA0ACwsgAEEgcUUNACACRQ0AIAJBAWshByAIQQJ0QfCJFmohAyACQQdxIgQEQANAIAMgBTYCACADQQRqIQMgAkEBayECIARBAWsiBA0ACwsgB0EHSQ0AA0AgAyAFNgIcIAMgBTYCGCADIAU2AhQgAyAFNgIQIAMgBTYCDCADIAU2AgggAyAFNgIEIAMgBTYCACADQSBqIQMgAkEIayICDQALC0HcCEHcCCgCACAAcjYCACAGQQFqIgIgBi0AAEH/AHEiA0E/ayIAQT9LDQQaDAMLAkBB7AgoAgAiBEEBRgRAQfAIKAIAIgNBzAgoAgAiAUkNASADIAFwIQMMAQtB+AgoAgAhAkH0CCgCACEBAkACQCAEQQVHDQAgAUEBRw0AIAJB6QJODQQMAQsgAkHkAEoNA0H8CCgCAEHkAEoNA0GACSgCAEHkAEoNAwsCQCABRQ0AIAFBAkoNACACQfwIKAIAQYAJKAIAIAFBAnRBiAhqKAIAEQIAIQFB8AgoAgAiA0HMCCgCACICTwR/IAMgAnAFIAMLQQJ0QZAJaiABNgIAC0HwCCgCACIDQcwIKAIAIgFJDQAgAyABcCEDCyADQQJ0QZAJaigCACEFDAELIANB/QBxQSFHBEAgCCEBIAYhAgwECyAEQSNHDQQCQEHsCCgCACICQQFGBEBB8AgoAgAiAUHMCCgCACIASQ0BIAEgAHAhAQwBC0H4CCgCACEBQfQIKAIAIQACQAJAIAJBBUcNACAAQQFHDQAgAUHpAkgNAQwHCyABQeQASg0GQfwIKAIAQeQASg0GQYAJKAIAQeQASg0GCwJAIABFDQAgAEECSg0AIAFB/AgoAgBBgAkoAgAgAEECdEGICGooAgARAgAhAEHwCCgCACIBQcwIKAIAIgJPBH8gASACcAUgAQtBAnRBkAlqIAA2AgALQfAIKAIAIgFBzAgoAgAiAEkNACABIABwIQELIAFBAnRBkAlqKAIAIQUMBAsgCCEBIAYhAgtB1AgoAgAhBgNAAkAgASAGSA0AIAZB//8ASg0AIAZBAnQiBEGgiQJqIgZBoAgpAwA3AwAgBEGoiQJqQaAIKQMANwMAIARBsIkCakGgCCkDADcDACAEQbiJAmpBoAgpAwA3AwAgBEHAiQJqQaAIKQMANwMAIARByIkCakGgCCkDADcDACAEQdCJAmpBoAgpAwA3AwAgBEHYiQJqQaAIKQMANwMAIARB4IkCakGgCCkDADcDACAEQeiJAmpBoAgpAwA3AwAgBEHwiQJqQaAIKQMANwMAIARB+IkCakGgCCkDADcDACAEQYCKAmpBoAgpAwA3AwAgBEGIigJqQaAIKQMANwMAIARBkIoCakGgCCkDADcDACAEQZiKAmpBoAgpAwA3AwAgBEGgigJqQaAIKQMANwMAIARBqIoCakGgCCkDADcDACAEQbCKAmpBoAgpAwA3AwAgBEG4igJqQaAIKQMANwMAIARBwIoCakGgCCkDADcDACAEQciKAmpBoAgpAwA3AwAgBEHQigJqQaAIKQMANwMAIARB2IoCakGgCCkDADcDACAEQeCKAmpBoAgpAwA3AwAgBEHoigJqQaAIKQMANwMAIARB8IoCakGgCCkDADcDACAEQfiKAmpBoAgpAwA3AwAgBEGAiwJqQaAIKQMANwMAIARBiIsCakGgCCkDADcDACAEQZCLAmpBoAgpAwA3AwAgBEGYiwJqQaAIKQMANwMAIARBoIsCakGgCCkDADcDACAEQaiLAmpBoAgpAwA3AwAgBEGwiwJqQaAIKQMANwMAIARBuIsCakGgCCkDADcDACAEQcCLAmpBoAgpAwA3AwAgBEHIiwJqQaAIKQMANwMAIARB0IsCakGgCCkDADcDACAEQdiLAmpBoAgpAwA3AwAgBEHgiwJqQaAIKQMANwMAIARB6IsCakGgCCkDADcDACAEQfCLAmpBoAgpAwA3AwAgBEH4iwJqQaAIKQMANwMAIARBgIwCakGgCCkDADcDACAEQYiMAmpBoAgpAwA3AwAgBEGQjAJqQaAIKQMANwMAIARBmIwCakGgCCkDADcDACAEQaCMAmpBoAgpAwA3AwAgBEGojAJqQaAIKQMANwMAIARBsIwCakGgCCkDADcDACAEQbiMAmpBoAgpAwA3AwAgBEHAjAJqQaAIKQMANwMAIARByIwCakGgCCkDADcDACAEQdCMAmpBoAgpAwA3AwAgBEHYjAJqQaAIKQMANwMAIARB4IwCakGgCCkDADcDACAEQeiMAmpBoAgpAwA3AwAgBEHwjAJqQaAIKQMANwMAIARB+IwCakGgCCkDADcDACAEQYCNAmpBoAgpAwA3AwAgBEGIjQJqQaAIKQMANwMAIARBkI0CakGgCCkDADcDACAEQZiNAmpBoAgpAwA3AwAgBEGwiQZqIAZBgAT8CgAAQdQIKAIAQQJ0QcCJCmogBkGABPwKAABB1AgoAgBBAnRB0IkOaiAGQYAE/AoAAEHUCCgCAEECdEHgiRJqIAZBgAT8CgAAQdQIKAIAQQJ0QfCJFmogBkGABPwKAABB1AhB1AgoAgBBgAFqIgY2AgALIAFB//8ATQRAIABBAXEgAWxBAnRBoIkCaiAFNgIAIABBAXZBAXEgAWxBAnRBsIkGaiAFNgIAIABBAnZBAXEgAWxBAnRBwIkKaiAFNgIAIABBA3ZBAXEgAWxBAnRB0IkOaiAFNgIAIABBBHZBAXEgAWxBAnRB4IkSaiAFNgIAIABBBXYgAWxBAnRB8IkWaiAFNgIAQdQIKAIAIQYLIAFBAWohAUHcCEHcCCgCACAAcjYCACACLQAAIQAgAkEBaiIEIQIgAEH/AHEiA0E/ayIAQcAASQ0ACyAECyECQQAhBCACIQYgASEIIANB/QBxQSFGDQELIANBJGsOCgEDAwMDAwMDAwIDC0HsCEIBNwIADAQLQdgIIAFB2AgoAgAiACAAIAFIGyIAQYCAASAAQYCAAUgbNgIADAILQegIIAFB2AgoAgAiACAAIAFIGyIAQYCAASAAQYCAAUgbIgA2AgBB2AggADYCACAAQQRrEAAEQEHoCEEENgIAQdgIQQQ2AgBB0AhBATYCAA8LEAgMAQsCQCADQTtHDQBB7AgoAgAiAEEHSg0AQewIIABBAWo2AgAgAEECdEHwCGpBADYCAAsgAiEGIAQhAyABIQgMAQtBBCEIIAIhBiAEIQMLIAYgCUkNAAsLQeQIIAU2AgBB4AggAzYCAEHoCCAINgIAC9ELAgF+CH9B2AhCBDcDAEGojQJBoAgpAwAiADcDAEGgjQIgADcDAEGYjQIgADcDAEGQjQIgADcDAEGIjQIgADcDAEGAjQIgADcDAEH4jAIgADcDAEHwjAIgADcDAEHojAIgADcDAEHgjAIgADcDAEHYjAIgADcDAEHQjAIgADcDAEHIjAIgADcDAEHAjAIgADcDAEG4jAIgADcDAEGwjAIgADcDAEGojAIgADcDAEGgjAIgADcDAEGYjAIgADcDAEGQjAIgADcDAEGIjAIgADcDAEGAjAIgADcDAEH4iwIgADcDAEHwiwIgADcDAEHoiwIgADcDAEHgiwIgADcDAEHYiwIgADcDAEHQiwIgADcDAEHIiwIgADcDAEHAiwIgADcDAEG4iwIgADcDAEGwiwIgADcDAEGoiwIgADcDAEGgiwIgADcDAEGYiwIgADcDAEGQiwIgADcDAEGIiwIgADcDAEGAiwIgADcDAEH4igIgADcDAEHwigIgADcDAEHoigIgADcDAEHgigIgADcDAEHYigIgADcDAEHQigIgADcDAEHIigIgADcDAEHAigIgADcDAEG4igIgADcDAEGwigIgADcDAEGoigIgADcDAEGgigIgADcDAEGYigIgADcDAEGQigIgADcDAEGIigIgADcDAEGAigIgADcDAEH4iQIgADcDAEHwiQIgADcDAEHoiQIgADcDAEHgiQIgADcDAEHYiQIgADcDAEHQiQIgADcDAEHIiQIgADcDAEHAiQIgADcDAEG4iQIgADcDAEGwiQIgADcDAEGoCCgCACIEQf8AakGAAW0hCAJAIARBgQFIDQBBASEBIAhBAiAIQQJKG0EBayICQQFxIQMgBEGBAk4EQCACQX5xIQIDQCABQQl0IgdBEHJBoIkCakGwiQJBgAT8CgAAIAdBsI0CakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsLIANFDQAgAUEJdEEQckGgiQJqQbCJAkGABPwKAAALAkAgBEEBSA0AIAhBASAIQQFKGyIDQQFxIQUCQCADQQFrIgdFBEBBACEBDAELIANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQbCJBmpBsIkCQYAE/AoAACAGQZAEckGwiQZqQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACwsgBQRAIAFBCXRBEHJBsIkGakGwiQJBgAT8CgAACyAEQQFIDQAgA0EBcSEFIAcEfyADQf7///8HcSECQQAhAQNAIAFBCXQiBkEQckHAiQpqQbCJAkGABPwKAAAgBkGQBHJBwIkKakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsgAUEHdEEEcgVBBAshASAFBEAgAUECdEHAiQpqQbCJAkGABPwKAAALIARBAUgNACADQQFxIQUgBwR/IANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQdCJDmpBsIkCQYAE/AoAACAGQZAEckHQiQ5qQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACyABQQd0QQRyBUEECyEBIAUEQCABQQJ0QdCJDmpBsIkCQYAE/AoAAAsgBEEBSA0AIANBAXEhBSAHBH8gA0H+////B3EhAkEAIQEDQCABQQl0IgZBEHJB4IkSakGwiQJBgAT8CgAAIAZBkARyQeCJEmpBsIkCQYAE/AoAACABQQJqIQEgAkECayICDQALIAFBB3RBBHIFQQQLIQEgBQRAIAFBAnRB4IkSakGwiQJBgAT8CgAACyAEQQFIDQAgA0EBcSEEIAcEfyADQf7///8HcSECQQAhAQNAIAFBCXQiA0EQckHwiRZqQbCJAkGABPwKAAAgA0GQBHJB8IkWakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsgAUEHdEEEcgVBBAshASAERQ0AIAFBAnRB8IkWakGwiQJBgAT8CgAAC0HUCCAIQQd0QQRyNgIAC58TAgh/AX5B5AgoAgAhA0HgCCgCACECQegIKAIAIQcgAUGQiQFqIglB/wE6AAAgACABSARAIABBkIkBaiEIA0AgAiEEIAhBAWohAQJAIAgtAABB/wBxIgJBMGtBCUsEQCABIQgMAQtB7AgoAgBBAnRB7AhqIgUoAgAhAANAIAUgAiAAQQpsakEwayIANgIAIAEtAAAhAiABQQFqIgghASACQf8AcSICQTBrQQpJDQALCwJAAkACQAJAAkACQAJ/AkAgAkE/ayIAQT9NBEAgBEUNASAEQSFGBEBB8AgoAgAiAUEBIAEbIgQgB2ohAQJAIABFDQAgB0H//wBLDQBBgIABIAdrIAQgAUH//wBLGyEFAkAgAEEBcUUNACAHQQJ0QaCJAmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEECcUUNACAHQQJ0QbCJBmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEEEcUUNACAHQQJ0QcCJCmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEEIcUUNACAHQQJ0QdCJDmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEEQcUUNACAHQQJ0QeCJEmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLIABBIHFFDQAgBUEBayEEIAdBAnRB8IkWaiEAIAVBB3EiAgRAA0AgACADNgIAIABBBGohACAFQQFrIQUgAkEBayICDQALCyAEQQdJDQADQCAAIAM2AhwgACADNgIYIAAgAzYCFCAAIAM2AhAgACADNgIMIAAgAzYCCCAAIAM2AgQgACADNgIAIABBIGohACAFQQhrIgUNAAsLIAhBAWoiBSAILQAAQf8AcSICQT9rIgBBP00NAxoMBAsCQEHsCCgCACIFQQFGBEBB8AgoAgAiAUHMCCgCACIESQ0BIAEgBHAhAQwBC0H4CCgCACEEQfQIKAIAIQECQAJAIAVBBUcNACABQQFHDQAgBEHpAk4NBAwBCyAEQeQASg0DQfwIKAIAQeQASg0DQYAJKAIAQeQASg0DCwJAIAFFDQAgAUECSg0AIARB/AgoAgBBgAkoAgAgAUECdEGICGooAgARAgAhBEHwCCgCACIBQcwIKAIAIgVPBH8gASAFcAUgAQtBAnRBkAlqIAQ2AgALQfAIKAIAIgFBzAgoAgAiBEkNACABIARwIQELIAFBAnRBkAlqKAIAIQMMAQsgAkH9AHFBIUcEQCAHIQEgAiEADAQLIARBI0cNBAJAQewIKAIAIgRBAUYEQEHwCCgCACIBQcwIKAIAIgBJDQEgASAAcCEBDAELQfgIKAIAIQFB9AgoAgAhAAJAAkAgBEEFRw0AIABBAUcNACABQekCSA0BDAcLIAFB5ABKDQZB/AgoAgBB5ABKDQZBgAkoAgBB5ABKDQYLAkAgAEUNACAAQQJKDQAgAUH8CCgCAEGACSgCACAAQQJ0QYgIaigCABECACEAQfAIKAIAIgFBzAgoAgAiBE8EfyABIARwBSABC0ECdEGQCWogADYCAAtB8AgoAgAiAUHMCCgCACIASQ0AIAEgAHAhAQsgAUECdEGQCWooAgAhAwwECyAHIQEgCAshBQNAIAFB//8ATQRAIABBAXEgAWxBAnRBoIkCaiADNgIAIABBAXZBAXEgAWxBAnRBsIkGaiADNgIAIABBAnZBAXEgAWxBAnRBwIkKaiADNgIAIABBA3ZBAXEgAWxBAnRB0IkOaiADNgIAIABBBHZBAXEgAWxBAnRB4IkSaiADNgIAIABBBXYgAWxBAnRB8IkWaiADNgIACyABQQFqIQEgBS0AACEAIAVBAWoiBCEFIABB/wBxIgJBP2siAEHAAEkNAAsgBCEFC0EAIQQgBSEIIAEhByACIQAgAkH9AHFBIUYNAQtBBCEHIAQhAiAAQSRrDgoDAgICAgICAgIBAgtB7AhCATcCAAwCC0GoCCgCAEEEaxAABEBB0AhBATYCAA8LAkBBqAgoAgAiBkEFSA0AQaAIKQMAIQogBkEDa0EBdiIBQQdxIQJBACEAIAFBAWtBB08EQCABQfj///8HcSEFA0AgAEEDdCIBQbCJAmogCjcDACABQQhyQbCJAmogCjcDACABQRByQbCJAmogCjcDACABQRhyQbCJAmogCjcDACABQSByQbCJAmogCjcDACABQShyQbCJAmogCjcDACABQTByQbCJAmogCjcDACABQThyQbCJAmogCjcDACAAQQhqIQAgBUEIayIFDQALCyACRQ0AA0AgAEEDdEGwiQJqIAo3AwAgAEEBaiEAIAJBAWsiAg0ACwtBwIkGQbCJAiAGQQJ0IgD8CgAAQdCJCkGwiQIgAPwKAABB4IkOQbCJAiAA/AoAAEHwiRJBsIkCIAD8CgAAQYCKFkGwiQIgAPwKAAAgBCECDAELAkAgAEE7Rw0AQewIKAIAIgBBB0oNAEHsCCAAQQFqNgIAIABBAnRB8AhqQQA2AgALIAEhBwsgCCAJSQ0ACwtB5AggAzYCAEHgCCACNgIAQegIIAc2AgAL4gcCBX8BfgJAQdAIAn8CQAJAIAAgAU4NACABQZCJAWohBiAAQZCJAWohBQNAIAUtAAAiA0H/AHEhAgJAAkACQAJAAkACQAJAQeAIKAIAIgRBIkcEQCAEDQcgAkEiRgRAQewIQgE3AgBB4AhBIjYCAAwICyACQT9rQcAASQ0GIANBIWsiAkEMTQ0BDAULAkAgAkEwayIEQQlNBEBB7AgoAgBBAnRB7AhqIgIgBCACKAIAQQpsajYCAAwBC0HsCCgCACEEIAJBO0YEQCAEQQdKDQFB7AggBEEBajYCACAEQQJ0QfAIakEANgIADAELIARBBEYEQEHECEECNgIAQbAIQfAIKQMANwMAQbgIQfgIKAIAIgI2AgBBvAhB/AgoAgAiBDYCAEHICEECQQFBwAgoAgAiAxs2AgBBrAggBEEAIAMbNgIAQagIIAJBgIABIAJBgIABSBtBBGpBACADGzYCAEHgCEEANgIADAoLIAJBP2tBwABJDQQLIANBIWsiAkEMTQ0BDAILQQEgAnRBjSBxRQ0DDAQLQQEgAnRBjSBxDQELIANBoQFrIgJBDEsNA0EBIAJ0QY0gcUUNAwtBxAhCgYCAgBA3AgBBsAhB8AgoAgBBAEHsCCgCACICQQBKGzYCAEG0CEH0CCgCAEEAIAJBAUobNgIAQbgIQfgIKAIAQQAgAkECShs2AgBB4AhBADYCAEG8CEEANgIADAQLIANBoQFrIgJBDEsNAUEBIAJ0QY0gcUUNAQtBxAhCgYCAgBA3AgBBsAhCADcDAEG4CEIANwMADAMLIAVBAWoiBSAGSQ0ACwsCQEHICCgCAA4DAwEAAQsCQEGoCCgCACIFQQVIDQBBoAgpAwAhByAFQQNrQQF2IgNBB3EhBEEAIQIgA0EBa0EHTwRAIANB+P///wdxIQYDQCACQQN0IgNBsIkCaiAHNwMAIANBCHJBsIkCaiAHNwMAIANBEHJBsIkCaiAHNwMAIANBGHJBsIkCaiAHNwMAIANBIHJBsIkCaiAHNwMAIANBKHJBsIkCaiAHNwMAIANBMHJBsIkCaiAHNwMAIANBOHJBsIkCaiAHNwMAIAJBCGohAiAGQQhrIgYNAAsLIARFDQADQCACQQN0QbCJAmogBzcDACACQQFqIQIgBEEBayIEDQALC0HAiQZBsIkCIAVBAnQiA/wKAABB0IkKQbCJAiAD/AoAAEHgiQ5BsIkCIAP8CgAAQfCJEkGwiQIgA/wKAABBgIoWQbCJAiAD/AoAAEECDAELEAhByAgoAgALEAEiAjYCACACDQAgACABQcgIKAIAQQJ0QYAIaigCABEBAAsLdABB6AhBBDYCAEHkCCAANgIAQewIQgE3AgBBxAhCADcCAEHACCADNgIAQdwIQgA3AgBBqAhCADcDAEGwCEIANwMAQbgIQgA3AwBBzAggAkGAICACQYAgSRs2AgBBoAggAa1CgYCAgBB+NwMAQdAIQQA2AgALIwBB0AgoAgBFBEAgACABQcgIKAIAQQJ0QYAIaigCABEBAAsLWgECfwJAAkACQEHICCgCAEEBaw4CAAECC0HYCEHoCCgCACIAQdgIKAIAIgEgACABShsiAEGAgAEgAEGAgAFIGyIANgIAIABBBGsPC0GoCCgCAEEEayEACyAAC0IBAX8Cf0EGQdwIKAIAIgBBIHENABpBBSAAQRBxDQAaQQQgAEEIcQ0AGkEDIABBBHENABpBAiAAQQFxIABBAnEbCwu9BQEFfQJ/IAJFBEAgAUH/AWxBMmpB5ABtIgBBCHQgAHIgAEEQdHIMAQsgArJDAADIQpUhBiAAQfABarJDAAC0Q5UhBQJ9IAGyQwAAyEKVIgNDAAAAP10EQCADIAZDAACAP5KUDAELIAYgA0MAAIA/IAaTlJILIQcgAyADkiEGAkAgBUOrqqo+kiIEQwAAAABdBEAgBEMAAIA/kiEEDAELIARDAACAP15FDQAgBEMAAIC/kiEECyAGIAeTIQMgBUMAAAAAXSEAAn8CfSADIAcgA5NDAADAQJQgBJSSIARDq6oqPl0NABogByAEQwAAAD9dDQAaIAMgBEOrqio/XUUNABogAyAHIAOTIARDAADAwJRDAACAQJKUkgtDAAB/Q5RDAAAAP5IiBkMAAIBPXSAGQwAAAABgcQRAIAapDAELQQALIQECQCAABEAgBUMAAIA/kiEEDAELIAUiBEMAAIA/XkUNACAFQwAAgL+SIQQLIAVDq6qqvpIiBUMAAAAAXSECAn8CfSADIAcgA5NDAADAQJQgBJSSIARDq6oqPl0NABogByAEQwAAAD9dDQAaIAMgBEOrqio/XUUNABogAyAHIAOTIARDAADAwJRDAACAQJKUkgtDAAB/Q5RDAAAAP5IiBkMAAIBPXSAGQwAAAABgcQRAIAapDAELQQALIQACQCACBEAgBUMAAIA/kiEFDAELIAVDAACAP15FDQAgBUMAAIC/kiEFCwJAIAVDq6oqPl0EQCADIAcgA5NDAADAQJQgBZSSIQcMAQsgBUMAAAA/XQ0AIAVDq6oqP11FBEAgAyEHDAELIAMgByADkyAFQwAAwMCUQwAAgECSlJIhBwsgAEEIdAJ/IAdDAAB/Q5RDAAAAP5IiBkMAAIBPXSAGQwAAAABgcQRAIAapDAELQQALQRB0ciABcgtBgICAeHILNwAgAEH/AWxBMmpB5ABtIAFB/wFsQTJqQeQAbUEIdHIgAkH/AWxBMmpB5ABtQRB0ckGAgIB4cgsEACMACwYAIAAkAAsQACMAIABrQXBxIgAkACAACwsYAQBBgAgLEQEAAAACAAAAAwAAAAQAAAAF'\n};\n//# sourceMappingURL=wasm.js.map","/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal event utilities for xterm.js core.\n * Simplified from VS Code's event.ts - no leak detection/profiling.\n */\n\nimport { IDisposable, DisposableStore, toDisposable } from './Lifecycle';\n\nexport interface IEvent {\n (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable;\n}\n\nexport class Emitter {\n private _listeners: { fn: (e: T) => any, thisArgs: any }[] = [];\n private _disposed = false;\n private _event: IEvent | undefined;\n\n public get event(): IEvent {\n if (this._event) {\n return this._event;\n }\n this._event = (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n if (this._disposed) {\n return toDisposable(() => {});\n }\n\n const entry = { fn: listener, thisArgs };\n this._listeners = this._listeners.slice();\n this._listeners.push(entry);\n\n const result = toDisposable(() => {\n const idx = this._listeners.indexOf(entry);\n if (idx !== -1) {\n this._listeners = this._listeners.slice();\n this._listeners.splice(idx, 1);\n }\n });\n\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(result);\n } else {\n disposables.add(result);\n }\n }\n\n return result;\n };\n return this._event;\n }\n\n public fire(event: T): void {\n if (this._disposed || !this._listeners.length) {\n return;\n }\n if (this._listeners.length === 1) {\n this._listeners[0].fn.call(this._listeners[0].thisArgs, event);\n return;\n }\n const listeners = this._listeners;\n for (let i = 0, len = listeners.length; i < len; ++i) {\n listeners[i].fn.call(listeners[i].thisArgs, event);\n }\n }\n\n public dispose(): void {\n if (this._disposed) {\n return;\n }\n this._disposed = true;\n this._listeners.length = 0;\n }\n}\n\nexport namespace EventUtils {\n export function forward(from: IEvent, to: Emitter): IDisposable {\n return from(e => to.fire(e));\n }\n\n export function map(event: IEvent, map: (i: I) => O): IEvent {\n return (listener: (e: O) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n return event(i => listener.call(thisArgs, map(i)), undefined, disposables);\n };\n }\n\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent {\n return (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n const store = new DisposableStore();\n for (const event of events) {\n store.add(event(e => listener.call(thisArgs, e)));\n }\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(store);\n } else {\n disposables.add(store);\n }\n }\n return store;\n };\n }\n\n export function runAndSubscribe(event: IEvent, handler: (e: T) => void, initial: T): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void, initial?: T): IDisposable {\n handler(initial);\n return event(e => handler(e));\n }\n}\n","/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal lifecycle utilities for xterm.js core.\n * Simplified from VS Code's lifecycle.ts - no tracking/leak detection.\n */\n\nexport interface IDisposable {\n dispose(): void;\n}\n\nexport function toDisposable(fn: () => void): IDisposable {\n return { dispose: fn };\n}\n\nexport function dispose(disposable: T): T;\nexport function dispose(disposable: T | undefined): T | undefined;\nexport function dispose(disposables: T[]): T[];\nexport function dispose(arg: T | T[] | undefined): T | T[] | undefined {\n if (!arg) {\n return arg;\n }\n if (Array.isArray(arg)) {\n for (const d of arg) {\n d.dispose();\n }\n return [];\n }\n arg.dispose();\n return arg;\n}\n\nexport function combinedDisposable(...disposables: IDisposable[]): IDisposable {\n return toDisposable(() => dispose(disposables));\n}\n\nexport class DisposableStore implements IDisposable {\n private readonly _disposables = new Set();\n private _isDisposed = false;\n\n public get isDisposed(): boolean {\n return this._isDisposed;\n }\n\n public add(o: T): T {\n if (this._isDisposed) {\n o.dispose();\n } else {\n this._disposables.add(o);\n }\n return o;\n }\n\n public dispose(): void {\n if (this._isDisposed) {\n return;\n }\n this._isDisposed = true;\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n\n public clear(): void {\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n}\n\nexport abstract class Disposable implements IDisposable {\n public static readonly None: IDisposable = Object.freeze({ dispose() { } });\n\n protected readonly _store = new DisposableStore();\n\n public dispose(): void {\n this._store.dispose();\n }\n\n protected _register(o: T): T {\n return this._store.add(o);\n }\n}\n\nexport class MutableDisposable implements IDisposable {\n private _value: T | undefined;\n private _isDisposed = false;\n\n public get value(): T | undefined {\n return this._isDisposed ? undefined : this._value;\n }\n\n public set value(value: T | undefined) {\n if (this._isDisposed || value === this._value) {\n return;\n }\n this._value?.dispose();\n this._value = value;\n }\n\n public clear(): void {\n this.value = undefined;\n }\n\n public dispose(): void {\n this._isDisposed = true;\n this._value?.dispose();\n this._value = undefined;\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nimport { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { CELL_SIZE_DEFAULT } from './ImageStorage';\nimport Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';\nimport { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser';\nimport { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';\n\n// Local const enum mirror - esbuild can't inline const enums from external packages\nconst enum DecoderConst {\n // Limit held memory in base64 decoder (encoded bytes).\n KEEP_DATA = 4194304,\n // Initial buffer allocation for the decoder.\n INITIAL_DATA = 1048576,\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n OK = 0\n}\n\n// default IIP header values\nconst DEFAULT_HEADER: IHeaderFields = {\n type: SequenceType.INVALID,\n name: 'Unnamed file',\n size: 0,\n width: 'auto',\n height: 'auto',\n preserveAspectRatio: 1,\n inline: 0\n};\n\n\nexport class IIPHandler implements IOscHandler, IResetHandler {\n private _generation = 0;\n private _aborted = false;\n private _hp = new HeaderParser();\n private _header: IHeaderFields = DEFAULT_HEADER;\n private _dec: Base64Decoder;\n private _qoiDec: QoiDecoder;\n private _isMultipart = false;\n private _abortMulti = false;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _storage: IIPImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);\n const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);\n this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes);\n this._qoiDec = new QoiDecoder(DecoderConst.KEEP_DATA);\n }\n\n public reset(): void {\n this._generation++;\n this._hp.reset();\n this._dec.release();\n this._qoiDec.release();\n }\n\n public start(): void {\n this._aborted = false;\n this._hp.reset();\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (this._hp.state === HeaderState.END) {\n if ((this._dec.put(data.subarray(start, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n }\n } else {\n const dataPos = this._hp.parse(data, start, end);\n if (dataPos === -1) {\n this._aborted = true;\n return;\n }\n if (dataPos > 0) {\n const seqType = this._hp.fields.type;\n if (seqType === SequenceType.FILE) {\n if (this._isMultipart) {\n this._isMultipart = false;\n this._abortMulti = false;\n this._dec.release();\n }\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n if (!this._header.inline) {\n this._aborted = true;\n return;\n }\n this._dec.init();\n } else if (this._abortMulti) {\n this._aborted = true;\n return;\n }\n if ((this._dec.put(data.subarray(dataPos, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n if (this._isMultipart) this._abortMulti = true;\n }\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted) return true;\n\n if (this._hp.state !== HeaderState.END) {\n if (this._hp.end()) return true;\n }\n const seqType = this._hp.fields.type;\n\n if (seqType === SequenceType.FILEPART) return true;\n\n if (seqType === SequenceType.REPORTCELLSIZE) {\n // OSC 1337 ; ReportCellSize=[height];[width];[scale] ST\n let w = CELL_SIZE_DEFAULT.width;\n let h = CELL_SIZE_DEFAULT.height;\n if (this._renderer.dimensions) {\n w = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols;\n h = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows;\n }\n const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1;\n const report = `\\x1b]1337;ReportCellSize=${h.toFixed(3)};${w.toFixed(3)};${scale.toFixed(3)}\\x1b\\\\`;\n this._coreTerminal.input(report, false);\n return true;\n }\n\n if (seqType === SequenceType.MULTIPARTFILE) {\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n this._isMultipart = true;\n this._abortMulti = false;\n this._dec.release();\n this._dec.init();\n return true;\n }\n\n if (seqType === SequenceType.FILEEND) {\n if (!this._isMultipart) return true;\n this._isMultipart = false;\n if (this._abortMulti || this._header.type !== SequenceType.MULTIPARTFILE) return true;\n }\n\n // fallthrough for SequenceType.FILE & SequenceType.FILEEND\n\n let w = 0;\n let h = 0;\n\n // early exit condition chain\n let cond: number | boolean;\n let metrics = UNSUPPORTED_TYPE;\n if (cond = success) {\n if (cond = !this._dec.end()) {\n metrics = imageType(this._dec.data8);\n if (cond = metrics.mime !== 'unsupported') {\n w = metrics.width;\n h = metrics.height;\n if (cond = w && h && w * h < this._opts.pixelLimit) {\n [w, h] = this._resize(w, h).map(Math.floor);\n cond = w && h && w * h < this._opts.pixelLimit;\n } else {\n console.warn(`IIP: image dimension issue ${metrics.width}x${metrics.height}`);\n }\n } else {\n console.warn('IIP: unsupported image type');\n }\n } else {\n console.warn('IIP: error during BASE64 decoding');\n }\n }\n if (!cond) {\n this._dec.release();\n return true;\n }\n\n let blob: Blob | ImageData;\n if (metrics.mime === 'image/qoi') {\n const data = this._qoiDec.decode(this._dec.data8);\n blob = new ImageData(\n new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),\n this._qoiDec.width,\n this._qoiDec.height\n );\n this._qoiDec.release();\n if (w === this._qoiDec.width && h === this._qoiDec.height) {\n // use fast-path if we don't need to rescale\n this._dec.release();\n const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height);\n canvas.getContext('2d')?.putImageData(blob, 0, 0);\n this._storage.addImage(canvas);\n return true;\n }\n } else {\n blob = new Blob([this._dec.data8], { type: metrics.mime });\n }\n this._dec.release();\n const generation = this._generation;\n return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })\n .then(bm => {\n if (generation !== this._generation) {\n bm.close();\n return true;\n }\n this._storage.addImage(bm);\n return true;\n })\n .catch(e => {\n console.warn(`IIP: decoding error ${metrics.mime} ${metrics.width}x${metrics.height}`, e);\n return true;\n });\n }\n\n private _resize(w: number, h: number): [number, number] {\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n const width = this._renderer.dimensions?.css.canvas.width || cw * this._coreTerminal.cols;\n const height = this._renderer.dimensions?.css.canvas.height || ch * this._coreTerminal.rows;\n\n const rw = this._dim(this._header.width!, width, cw);\n const rh = this._dim(this._header.height!, height, ch);\n if (!rw && !rh) {\n const wf = width / w; // TODO: should this respect initial cursor offset?\n const hf = (height - ch) / h; // TODO: fix offset issues from float cell height\n const f = Math.min(wf, hf);\n return f < 1 ? [w * f, h * f] : [w, h];\n }\n return !rw\n ? [w * rh / h, rh]\n : this._header.preserveAspectRatio || !rw || !rh\n ? [rw, h * rw / w] : [rw, rh];\n }\n\n private _dim(s: string, total: number, cdim: number): number {\n if (s === 'auto') return 0;\n if (s.endsWith('%')) return parseInt(s.slice(0, -1), 10) * total / 100;\n if (s.endsWith('px')) return parseInt(s.slice(0, -2), 10);\n return parseInt(s, 10) * cdim;\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n// eslint-disable-next-line\ndeclare const Buffer: any;\n\nexport const enum HeaderState {\n START = 0,\n ABORT = 1,\n KEY = 2,\n VALUE = 3,\n END = 4\n}\n\nexport const enum SequenceType {\n INVALID = 0,\n FILE = 1,\n MULTIPARTFILE = 2,\n FILEPART = 3,\n FILEEND = 4,\n REPORTCELLSIZE = 5\n}\n\nexport interface IHeaderFields {\n [key: string]: number | string | Uint32Array | null | undefined;\n // sequence type\n type: SequenceType;\n // base-64 encoded filename. Defaults to \"Unnamed file\".\n name: string;\n // File size in bytes. The file transfer will be canceled if this size is exceeded.\n size: number;\n /**\n * Optional width and height to render:\n * - N: N character cells.\n * - Npx: N pixels.\n * - N%: N percent of the session's width or height.\n * - auto: The image's inherent size will be used to determine an appropriate dimension.\n */\n width?: string;\n height?: string;\n // Optional, defaults to 1 respecting aspect ratio (width takes precedence).\n preserveAspectRatio?: number;\n // Optional, defaults to 0. If set to 1, the file will be displayed inline, else downloaded\n // (download not supported).\n inline?: number;\n}\n\n// field value decoders\n\n// ASCII bytes to string\nfunction toStr(data: Uint32Array): string {\n let s = '';\n for (let i = 0; i < data.length; ++i) {\n s += String.fromCharCode(data[i]);\n }\n return s;\n}\n\n// digits to integer\nfunction toInt(data: Uint32Array): number {\n let v = 0;\n for (let i = 0; i < data.length; ++i) {\n if (data[i] < 48 || data[i] > 57) {\n throw new Error('illegal char');\n }\n v = v * 10 + data[i] - 48;\n }\n return v;\n}\n\n// check for correct size entry\nfunction toSize(data: Uint32Array): string {\n const v = toStr(data);\n if (!v.match(/^((auto)|(\\d+?((px)|(%)){0,1}))$/)) {\n throw new Error('illegal size');\n }\n return v;\n}\n\n// name is base64 encoded utf-8\nfunction toName(data: Uint32Array): string {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(toStr(data), 'base64').toString();\n }\n const bs = atob(toStr(data));\n const b = new Uint8Array(bs.length);\n for (let i = 0; i < b.length; ++i) {\n b[i] = bs.charCodeAt(i);\n }\n return new TextDecoder().decode(b);\n}\n\nconst DECODERS: {[key: string]: (v: Uint32Array) => number | string} = {\n inline: toInt,\n size: toInt,\n name: toName,\n width: toSize,\n height: toSize,\n preserveAspectRatio: toInt\n};\n\n\n// sequence type markers\n// File\nconst FILE_MARKER = [70, 105, 108, 101];\n// MultipartFile\nconst MULTIPARTFILE_MARKER = [77, 117, 108, 116, 105, 112, 97, 114, 116, 70, 105, 108, 101];\n// FilePart\nconst FILEPART_MARKER = [70, 105, 108, 101, 80, 97, 114, 116];\n// FileEnd\nconst FILEEND_MARKER = [70, 105, 108, 101, 69, 110, 100];\n// ReportCellSize\nconst REPORTCELLSIZE_MARKER = [82, 101, 112, 111, 114, 116, 67, 101, 108, 108, 83, 105, 122, 101];\n\n// max allowed chars for sequence header\nconst MAX_FIELDCHARS = 1024;\n\n\nexport class HeaderParser {\n public state: HeaderState = HeaderState.START;\n private _buffer = new Uint32Array(MAX_FIELDCHARS);\n private _position = 0;\n private _key = '';\n public fields: {[key: string]: number | string | Uint32Array | null | undefined} = {};\n\n public reset(): void {\n this._buffer.fill(0);\n this.state = HeaderState.START;\n this._position = 0;\n this.fields = {};\n this._key = '';\n }\n\n public end(): number {\n if (this.state === HeaderState.START) {\n if (this._position === FILEEND_MARKER.length) {\n for (let k = 0; k < FILEEND_MARKER.length; ++k) {\n if (this._buffer[k] !== FILEEND_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEEND;\n this.state = HeaderState.END;\n return 0;\n }\n if (this._position === REPORTCELLSIZE_MARKER.length) {\n for (let k = 0; k < REPORTCELLSIZE_MARKER.length; ++k) {\n if (this._buffer[k] !== REPORTCELLSIZE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.REPORTCELLSIZE;\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n if (this.state === HeaderState.END) return 0;\n if (this.state === HeaderState.VALUE\n && this.fields.type === SequenceType.MULTIPARTFILE\n ) {\n if (!this._storeValue(this._position)) return this._a();\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n\n public parse(data: Uint32Array, start: number, end: number): number {\n let state = this.state;\n let pos = this._position;\n const buffer = this._buffer;\n if (state === HeaderState.ABORT || state === HeaderState.END) return -1;\n if (state === HeaderState.START && pos > 14) return -1;\n for (let i = start; i < end; ++i) {\n const c = data[i];\n switch (c) {\n case 59: // ;\n if (!this._storeValue(pos)) return this._a();\n state = HeaderState.KEY;\n pos = 0;\n break;\n case 61: // =\n if (state === HeaderState.START) {\n if (buffer[0] === 70) {\n // 'File' or 'FilePart'\n let k = 0;\n for (; k < FILE_MARKER.length; ++k) {\n if (buffer[k] !== FILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILE;\n if (pos === FILEPART_MARKER.length) {\n for (; k < FILEPART_MARKER.length; ++k) {\n if (buffer[k] !== FILEPART_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEPART;\n this.state = HeaderState.END;\n return i + 1;\n }\n } else if (buffer[0] === 77) {\n // 'MultipartFile'\n for (let k = 0; k < MULTIPARTFILE_MARKER.length; ++k) {\n if (buffer[k] !== MULTIPARTFILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.MULTIPARTFILE;\n } else {\n return this._a();\n }\n state = HeaderState.KEY;\n pos = 0;\n } else if (state === HeaderState.KEY) {\n if (!this._storeKey(pos)) return this._a();\n state = HeaderState.VALUE;\n pos = 0;\n } else if (state === HeaderState.VALUE) {\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n break;\n case 58: // :\n if (state === HeaderState.VALUE) {\n if (!this._storeValue(pos)) return this._a();\n }\n this.state = HeaderState.END;\n return i + 1;\n default:\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n }\n this.state = state;\n this._position = pos;\n return -2;\n }\n\n private _a(): number {\n this.fields.type = SequenceType.INVALID;\n this.state = HeaderState.ABORT;\n return -1;\n }\n\n private _storeKey(pos: number): boolean {\n const k = toStr(this._buffer.subarray(0, pos));\n if (k) {\n this._key = k;\n this.fields[k] = null;\n return true;\n }\n return false;\n }\n\n private _storeValue(pos: number): boolean {\n if (this._key) {\n try {\n const v = this._buffer.slice(0, pos);\n this.fields[this._key] = DECODERS[this._key] ? DECODERS[this._key](v) : v;\n } catch {\n return false;\n }\n return true;\n }\n return false;\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IAddImageOpts } from './Types';\nimport { ImageStorage } from './ImageStorage';\n\n/**\n * IIP (iTerm Image Protocol) specific image storage controller.\n *\n * Wraps the shared ImageStorage with IIP protocol semantics:\n * - Always uses scrolling mode (cursor advances with image)\n */\nexport class IIPImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n constructor(\n private readonly _storage: ImageStorage\n ) {}\n\n /**\n * Add an IIP image to storage.\n * Always uses scrolling mode — cursor advances past the image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._storage.addImage(img, this._addImageOpts);\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n\nexport type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'image/webp' | 'image/avif' | 'unsupported' | '';\n\nexport interface IMetrics {\n mime: ImageType;\n width: number;\n height: number;\n}\n\nexport const UNSUPPORTED_TYPE: IMetrics = {\n mime: 'unsupported',\n width: 0,\n height: 0\n};\n\nexport function imageType(d: Uint8Array): IMetrics {\n if (d.length < 32) {\n return UNSUPPORTED_TYPE;\n }\n const d32 = new Uint32Array(d.buffer, d.byteOffset, 8);\n // PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)\n // + first chunk must be IHDR\n if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {\n return {\n mime: 'image/png',\n width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],\n height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]\n };\n }\n // JPEG: FF D8 FF\n if (d[0] === 0xFF && d[1] === 0xD8 && d[2] === 0xFF) {\n const [width, height] = jpgSize(d);\n return { mime: 'image/jpeg', width, height };\n }\n // GIF: GIF87a or GIF89a\n if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {\n return {\n mime: 'image/gif',\n width: d[7] << 8 | d[6],\n height: d[9] << 8 | d[8]\n };\n }\n // QOI: qoif\n if (d32[0] === 0x66696F71) {\n return {\n mime: 'image/qoi',\n width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7],\n height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11]\n };\n }\n // WEBP: RIFF | xxxx | WEBP | VP8x\n if (d32[0] === 0x46464952 && d32[2] === 0x50424557 && (d32[3] & 0xFFFFFF) === 0x385056) {\n switch (d[15]) {\n case 0x58: // Extended WebP VP8X --> \"X\"\n return {\n mime: 'image/webp',\n width: (d[24] | d[25] << 8 | d[26] << 16) + 1,\n height: (d[27] | d[28] << 8 | d[29] << 16) + 1\n };\n case 0x4C: // Lossless WebP VP8L --> \"L\"\n if (d[20] !== 0x2f) return UNSUPPORTED_TYPE;\n const dim = d[21] | d[22] << 8 | d[23] << 16 | d[24] << 24;\n return {\n mime: 'image/webp',\n width: (dim & 0x3FFF) + 1,\n height: (dim >>> 14 & 0x3FFF) + 1\n };\n case 0x20: // Lossy WebP VP8 --> \" \"\n if (d[23] !== 0x9d || d[24] !== 0x01 || d[25] !== 0x2a) return UNSUPPORTED_TYPE;\n return {\n mime: 'image/webp',\n width: (d[26] | d[27] << 8) & 0x3FFF,\n height: (d[28] | d[29] << 8) & 0x3FFF\n };\n }\n return UNSUPPORTED_TYPE;\n }\n // AVIF: Box size | ftyp | avif/avis\n if (d32[1] === 0x70797466 && (d32[2] === 0x66697661 || d32[2] === 0x73697661)) {\n let pos = -1;\n // search for ispe box within first 1024 bytes\n const limit = Math.min(d.length - 16, 1024);\n for (let i = 8; i < limit; i++) {\n // scan for ispe\n if (d[i] === 0x69 && d[i + 1] === 0x73 && d[i + 2] === 0x70 && d[i + 3] === 0x65) {\n pos = i;\n break;\n }\n }\n if (pos !== -1) {\n // dimensions are in BE at +8 (width) at +12 (height)\n const width =\n d[pos + 8] << 24 |\n d[pos + 9] << 16 |\n d[pos + 10] << 8 |\n d[pos + 11];\n const height =\n d[pos + 12] << 24 |\n d[pos + 13] << 16 |\n d[pos + 14] << 8 |\n d[pos + 15];\n if (width > 0 && height > 0) {\n return { mime: 'image/avif', width, height };\n }\n }\n return UNSUPPORTED_TYPE;\n }\n return UNSUPPORTED_TYPE;\n}\n\n\nfunction jpgSize(d: Uint8Array): [number, number] {\n const len = d.length;\n let i = 4;\n let blockLength = d[i] << 8 | d[i + 1];\n while (true) {\n i += blockLength;\n if (i >= len) {\n // exhausted without size info\n return [0, 0];\n }\n if (d[i] !== 0xFF) {\n return [0, 0];\n }\n if (d[i + 1] === 0xC0 || d[i + 1] === 0xC2) {\n if (i + 8 < len) {\n return [\n d[i + 7] << 8 | d[i + 8],\n d[i + 5] << 8 | d[i + 6]\n ];\n }\n return [0, 0];\n }\n i += 2;\n blockLength = d[i] << 8 | d[i + 1];\n }\n}\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { toRGBA8888 } from 'sixel/lib/Colors';\nimport { IDisposable } from '@xterm/xterm';\nimport { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';\nimport { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\n\nconst enum Constants {\n PLACEHOLDER_LENGTH = 4096,\n PLACEHOLDER_HEIGHT = 24\n}\n\n/**\n * ImageRenderer - terminal frontend extension:\n * - provide primitives for canvas, ImageData, Bitmap (static)\n * - add canvas layer to DOM (browser only for now)\n * - draw image tiles onRender\n */\nexport class ImageRenderer extends Disposable implements IDisposable {\n /** @deprecated Kept for backward compat — points to top layer canvas. */\n public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; }\n private _layers = new Map();\n private _placeholder: HTMLCanvasElement | undefined;\n private _placeholderBitmap: ImageBitmap | undefined;\n private _optionsRefresh = this._register(new MutableDisposable());\n private _oldOpen: ((parent: HTMLElement) => void) | undefined;\n private _renderService: IRenderService | undefined;\n private _oldSetRenderer: ((renderer: any) => void) | undefined;\n\n // drawing primitive - canvas\n public static createCanvas(localDocument: Document | undefined, width: number, height: number): HTMLCanvasElement {\n /**\n * NOTE: We normally dont care, from which document the canvas\n * gets created, so we can fall back to global document,\n * if the terminal has no document associated yet.\n * This way early image loads before calling .open keep working\n * (still discouraged though, as the metrics will be screwed up).\n * Only the DOM output canvas should be on the terminal's document,\n * which gets explicitly checked in `insertLayerToDom`.\n */\n const canvas = (localDocument ?? document).createElement('canvas');\n canvas.width = width | 0;\n canvas.height = height | 0;\n return canvas;\n }\n\n // drawing primitive - ImageData with optional buffer\n public static createImageData(ctx: CanvasRenderingContext2D, width: number, height: number, buffer?: ArrayBuffer): ImageData {\n if (typeof ImageData !== 'function') {\n const imgData = ctx.createImageData(width, height);\n if (buffer) {\n imgData.data.set(new Uint8ClampedArray(buffer, 0, width * height * 4));\n }\n return imgData;\n }\n return buffer\n ? new ImageData(new Uint8ClampedArray(buffer, 0, width * height * 4), width, height)\n : new ImageData(width, height);\n }\n\n // drawing primitive - ImageBitmap\n public static createImageBitmap(img: ImageBitmapSource): Promise {\n if (typeof createImageBitmap !== 'function') {\n return Promise.resolve(undefined);\n }\n return createImageBitmap(img);\n }\n\n\n constructor(private _terminal: ITerminalExt) {\n super();\n this._oldOpen = this._terminal._core.open;\n this._terminal._core.open = (parent: HTMLElement): void => {\n this._oldOpen?.call(this._terminal._core, parent);\n this._open();\n };\n if (this._terminal._core.screenElement) {\n this._open();\n }\n // hack to spot fontSize changes\n this._optionsRefresh.value = this._terminal._core.optionsService.onOptionChange(option => {\n if (option === 'fontSize') {\n this.rescaleCanvas();\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n });\n this._register(toDisposable(() => {\n this.removeLayerFromDom();\n this.removeLayerFromDom('bottom');\n if (this._terminal._core && this._oldOpen) {\n this._terminal._core.open = this._oldOpen;\n this._oldOpen = undefined;\n }\n if (this._renderService && this._oldSetRenderer) {\n this._renderService.setRenderer = this._oldSetRenderer;\n this._oldSetRenderer = undefined;\n }\n this._renderService = undefined;\n this._layers.clear();\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }));\n }\n\n /**\n * Enable the placeholder.\n */\n public showPlaceholder(value: boolean): void {\n if (value) {\n if (!this._placeholder && this.cellSize.height !== -1) {\n this._createPlaceHolder(Math.max(this.cellSize.height + 1, Constants.PLACEHOLDER_HEIGHT));\n }\n } else {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n\n /**\n * Dimensions of the terminal.\n * Forwarded from internal render service.\n */\n public get dimensions(): IRenderDimensions | undefined {\n return this._terminal.dimensions;\n }\n\n /**\n * Current cell size (float).\n */\n public get cellSize(): ICellSize {\n return {\n width: this.dimensions?.css.cell.width || -1,\n height: this.dimensions?.css.cell.height || -1\n };\n }\n\n /**\n * Clear a region of the image layer canvas.\n */\n public clearLines(start: number, end: number, layer?: ImageLayer): void {\n const y = start * (this.dimensions?.css.cell.height || 0);\n const w = this.dimensions?.css.canvas.width || 0;\n const h = (end + 1 - start) * (this.dimensions?.css.cell.height || 0);\n if (!layer || layer === 'top') {\n this._layers.get('top')?.clearRect(0, y, w, h);\n }\n if (!layer || layer === 'bottom') {\n this._layers.get('bottom')?.clearRect(0, y, w, h);\n }\n }\n\n /**\n * Clear whole image canvas.\n */\n public clearAll(layer?: ImageLayer): void {\n if (!layer || layer === 'top') {\n const ctx = this._layers.get('top');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n if (!layer || layer === 'bottom') {\n const ctx = this._layers.get('bottom');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n }\n\n /**\n * Draw neighboring tiles on the image layer canvas.\n */\n public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get(imgSpec.layer);\n if (!ctx) {\n return;\n }\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const { width: sourceWidth, height: sourceHeight } = imgSpec.actualCellSize;\n const cols = Math.ceil(img.width / sourceWidth);\n\n const sx = (tileId % cols) * sourceWidth;\n const sy = Math.floor(tileId / cols) * sourceHeight;\n const dx = col * width;\n const dy = row * height;\n\n // safari bug: never access image source out of bounds\n const finalWidth = count * sourceWidth + sx > img.width ? img.width - sx : count * sourceWidth;\n const finalHeight = sy + sourceHeight > img.height ? img.height - sy : sourceHeight;\n\n // Floor all pixel offsets to get stable tile mapping without any overflows.\n // Note: For not pixel perfect aligned cells like in the DOM renderer\n // this will move a tile slightly to the top/left (subpixel range, thus ignore it).\n // FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),\n Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth * width / sourceWidth), Math.ceil(finalHeight * height / sourceHeight)\n );\n }\n\n /**\n * Extract a single tile from an image.\n */\n public extractTile(imgSpec: IImageSpec, tileId: number): HTMLCanvasElement | undefined {\n const { width, height } = this.cellSize;\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const { width: sourceWidth, height: sourceHeight } = imgSpec.actualCellSize;\n const cols = Math.ceil(img.width / sourceWidth);\n const sx = (tileId % cols) * sourceWidth;\n const sy = Math.floor(tileId / cols) * sourceHeight;\n const finalWidth = sourceWidth + sx > img.width ? img.width - sx : sourceWidth;\n const finalHeight = sy + sourceHeight > img.height ? img.height - sy : sourceHeight;\n\n const canvas = ImageRenderer.createCanvas(this.document, Math.ceil(finalWidth * width / sourceWidth), Math.ceil(finalHeight * height / sourceHeight));\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight),\n 0, 0, canvas.width, canvas.height\n );\n return canvas;\n }\n }\n\n /**\n * Draw a line with placeholder on the image layer canvas.\n */\n public drawPlaceholder(col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get('top');\n if (ctx) {\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n if (!this._placeholder) {\n this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT));\n } else if (height >= this._placeholder!.height) {\n this._createPlaceHolder(height + 1);\n }\n if (!this._placeholder) return;\n ctx.drawImage(\n this._placeholderBitmap ?? this._placeholder!,\n col * width,\n (row * height) % 2 ? 0 : 1, // needs %2 offset correction\n width * count,\n height,\n col * width,\n row * height,\n width * count,\n height\n );\n }\n }\n\n /**\n * Rescale image layer canvas if needed.\n * Checked once from `ImageStorage.render`.\n */\n public rescaleCanvas(): void {\n const w = this.dimensions?.css.canvas.width || 0;\n const h = this.dimensions?.css.canvas.height || 0;\n for (const ctx of this._layers.values()) {\n if (ctx.canvas.width !== w || ctx.canvas.height !== h) {\n ctx.canvas.width = w;\n ctx.canvas.height = h;\n }\n }\n }\n\n /**\n * Rescale image in storage if needed.\n */\n private _rescaleImage(spec: IImageSpec, currentWidth: number, currentHeight: number): void {\n if (currentWidth === spec.actualCellSize.width && currentHeight === spec.actualCellSize.height) {\n return;\n }\n const { width: originalWidth, height: originalHeight } = spec.origCellSize;\n if (currentWidth === originalWidth && currentHeight === originalHeight) {\n spec.actual = spec.orig;\n spec.actualCellSize.width = originalWidth;\n spec.actualCellSize.height = originalHeight;\n return;\n }\n const scaledWidth = Math.ceil(spec.orig!.width * currentWidth / originalWidth);\n const scaledHeight = Math.ceil(spec.orig!.height * currentHeight / originalHeight);\n // Upscale visible tiles directly; a full zoomed copy can dwarf the image budget.\n if (scaledWidth * scaledHeight > spec.orig!.width * spec.orig!.height) {\n spec.actual = spec.orig;\n spec.actualCellSize.width = originalWidth;\n spec.actualCellSize.height = originalHeight;\n return;\n }\n const canvas = ImageRenderer.createCanvas(this.document, scaledWidth, scaledHeight);\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(spec.orig!, 0, 0, canvas.width, canvas.height);\n spec.actual = canvas;\n spec.actualCellSize.width = currentWidth;\n spec.actualCellSize.height = currentHeight;\n }\n }\n\n /**\n * Lazy init for the renderer.\n */\n private _open(): void {\n this._renderService = this._terminal._core._renderService;\n this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);\n this._renderService.setRenderer = (renderer: any) => {\n for (const key of [...this._layers.keys()]) {\n this.removeLayerFromDom(key);\n }\n this._oldSetRenderer?.call(this._renderService, renderer);\n };\n }\n\n public insertLayerToDom(layer: ImageLayer = 'top'): void {\n // make sure that the terminal is attached to a document and to DOM\n if (!this.document || !this._terminal._core.screenElement) {\n console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');\n return;\n }\n if (this._layers.has(layer)) {\n return;\n }\n const canvas = ImageRenderer.createCanvas(\n this.document, this.dimensions?.css.canvas.width || 0,\n this.dimensions?.css.canvas.height || 0\n );\n canvas.classList.add(`xterm-image-layer-${layer}`);\n const screenElement = this._terminal._core.screenElement;\n // Use isolation to create a stacking context without overriding z-index,\n // which would conflict with integrators (e.g. VS Code) that set their\n // own z-index on the screen element.\n screenElement.style.isolation = 'isolate';\n if (layer === 'bottom') {\n // Use z-index:-1 so it paints behind non-positioned text elements.\n // The screen element needs to be a stacking context (via isolation)\n // to contain the negative z-index, otherwise it would go behind the\n // entire terminal.\n canvas.style.zIndex = '-1';\n screenElement.insertBefore(canvas, screenElement.firstChild);\n } else {\n // Explicit z-index ensures the image canvas reliably stacks above\n // the text layer (DOM renderer rows). z-index: 0 is below the\n // selection overlay (z-index: 1).\n canvas.style.zIndex = '0';\n screenElement.appendChild(canvas);\n }\n const ctx = canvas.getContext('2d', { alpha: true });\n if (!ctx) {\n canvas.remove();\n return;\n }\n this._layers.set(layer, ctx);\n this.clearAll(layer);\n }\n\n public removeLayerFromDom(layer: ImageLayer = 'top'): void {\n const ctx = this._layers.get(layer);\n if (ctx) {\n ctx.canvas.remove();\n this._layers.delete(layer);\n }\n }\n\n public hasLayer(layer: ImageLayer): boolean {\n return this._layers.has(layer);\n }\n\n private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n\n // create blueprint to fill placeholder with\n const bWidth = 32; // must be 2^n\n const blueprint = ImageRenderer.createCanvas(this.document, bWidth, height);\n const ctx = blueprint.getContext('2d', { alpha: false });\n if (!ctx) return;\n const imgData = ImageRenderer.createImageData(ctx, bWidth, height);\n const d32 = new Uint32Array(imgData.data.buffer);\n const black = toRGBA8888(0, 0, 0);\n const white = toRGBA8888(255, 255, 255);\n d32.fill(black);\n for (let y = 0; y < height; ++y) {\n const shift = y % 2;\n const offset = y * bWidth;\n for (let x = 0; x < bWidth; x += 2) {\n d32[offset + x + shift] = white;\n }\n }\n ctx.putImageData(imgData, 0, 0);\n\n // create placeholder line, width aligned to blueprint width\n const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || Constants.PLACEHOLDER_LENGTH;\n this._placeholder = ImageRenderer.createCanvas(this.document, width, height);\n const ctx2 = this._placeholder.getContext('2d', { alpha: false });\n if (!ctx2) {\n this._placeholder = undefined;\n return;\n }\n for (let i = 0; i < width; i += bWidth) {\n ctx2.drawImage(blueprint, i, 0);\n }\n const placeholder = this._placeholder;\n ImageRenderer.createImageBitmap(placeholder).then(bitmap => {\n if (this._placeholder !== placeholder) bitmap?.close();\n else this._placeholderBitmap = bitmap;\n }).catch(() => {});\n }\n\n public get document(): Document | undefined {\n return this._terminal._core._coreBrowserService?.window.document;\n }\n}\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageRenderer } from './ImageRenderer';\nimport {\n ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec,\n IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes,\n UnderlineStyle, IAddImageOpts\n} from './Types';\n\n\n// fallback default cell size\nexport const CELL_SIZE_DEFAULT: ICellSize = {\n width: 7,\n height: 14\n};\n\n/**\n * Extend extended attribute to also hold image tile information.\n *\n * Object definition is copied from base repo to fully mimick its behavior.\n * Image data is added as additional public properties `imageId` and `tileId`.\n */\nclass ExtendedAttrsImage implements IExtendedAttrsImage {\n private _ext: number = 0;\n public get ext(): number {\n if (this._urlId) {\n return (\n (this._ext & ~ExtFlags.UNDERLINE_STYLE) |\n (this.underlineStyle << 26)\n );\n }\n return this._ext;\n }\n public set ext(value: number) { this._ext = value; }\n\n public get underlineStyle(): UnderlineStyle {\n // Always return the URL style if it has one\n if (this._urlId) {\n return UnderlineStyle.DASHED;\n }\n return (this._ext & ExtFlags.UNDERLINE_STYLE) >> 26;\n }\n public set underlineStyle(value: UnderlineStyle) {\n this._ext &= ~ExtFlags.UNDERLINE_STYLE;\n this._ext |= (value << 26) & ExtFlags.UNDERLINE_STYLE;\n }\n\n public get underlineColor(): number {\n return this._ext & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n public set underlineColor(value: number) {\n this._ext &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);\n this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n\n public get underlineVariantOffset(): number {\n const val = (this._ext & ExtFlags.VARIANT_OFFSET) >> 29;\n if (val < 0) {\n return val ^ 0xFFFFFFF8;\n }\n return val;\n }\n public set underlineVariantOffset(value: number) {\n this._ext &= ~ExtFlags.VARIANT_OFFSET;\n this._ext |= (value << 29) & ExtFlags.VARIANT_OFFSET;\n }\n\n private _urlId: number = 0;\n public get urlId(): number {\n return this._urlId;\n }\n public set urlId(value: number) {\n this._urlId = value;\n }\n\n constructor(\n ext: number = 0,\n urlId: number = 0,\n public imageId = -1,\n public tileId = -1\n ) {\n this._ext = ext;\n this._urlId = urlId;\n }\n\n public clone(): IExtendedAttrsImage {\n /**\n * Technically we dont need a clone variant of ExtendedAttrsImage,\n * as we never clone a cell holding image data.\n * Note: Clone is only meant to be used by the InputHandler for\n * sticky attributes, which is never the case for image data.\n * We still provide a proper clone method to reflect the full ext attr\n * state in case there are future use cases for clone.\n */\n return new ExtendedAttrsImage(this._ext, this._urlId, this.imageId, this.tileId);\n }\n\n public isEmpty(): boolean {\n return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.imageId === -1;\n }\n}\nconst EMPTY_ATTRS = new ExtendedAttrsImage();\n\n\n/**\n * ImageStorage - extension of CoreTerminal:\n * - hold image data\n * - write/read image data to/from buffer\n *\n * TODO: image composition for overwrites\n */\nexport class ImageStorage implements IDisposable {\n // storage\n private _images: Map = new Map();\n // last used id\n private _lastId = 0;\n // last evicted id\n private _lowestId = 0;\n // whether a full clear happened before\n private _fullyCleared = false;\n // whether render should do a full clear\n private _needsFullClear = false;\n // hard limit of stored pixels (fallback limit of 10 MB)\n private _pixelLimit: number = 2500000;\n\n private _viewportMetrics: { cols: number, rows: number };\n public onImageAdded: (() => void) | undefined;\n public onImageDeleted: ((storageId: number) => void) | undefined;\n\n constructor(\n private _terminal: ITerminalExt,\n private _renderer: ImageRenderer,\n private _opts: IImageAddonOptions\n ) {\n try {\n this.setLimit(this._opts.storageLimit);\n } catch (e: unknown) {\n if (e instanceof Error) {\n console.error(e.message);\n }\n console.warn(`storageLimit is set to ${this.getLimit()} MB`);\n }\n this._viewportMetrics = {\n cols: this._terminal.cols,\n rows: this._terminal.rows\n };\n }\n\n public dispose(): void {\n this.reset();\n }\n\n public reset(): void {\n for (const spec of this._images.values()) {\n spec.marker?.dispose();\n }\n // NOTE: marker.dispose above already calls ImageBitmap.close\n // therefore we can just wipe the map here\n this._images.clear();\n this._renderer.clearAll();\n }\n\n public getLimit(): number {\n return this._pixelLimit * 4 / 1000000;\n }\n\n public setLimit(value: number): void {\n if (value < 0.5 || value > 1000) {\n throw RangeError('invalid storageLimit, should be at least 0.5 MB and not exceed 1G');\n }\n this._pixelLimit = (value / 4 * 1000000) >>> 0;\n this._evictOldest(0);\n }\n\n public getUsage(): number {\n return this._getStoredPixels() * 4 / 1000000;\n }\n\n private _getStoredPixels(): number {\n let storedPixels = 0;\n for (const spec of this._images.values()) {\n if (spec.orig) {\n storedPixels += spec.orig.width * spec.orig.height;\n if (spec.actual && spec.actual !== spec.orig) {\n storedPixels += spec.actual.width * spec.actual.height;\n }\n }\n }\n return storedPixels;\n }\n\n private _delImg(id: number): void {\n const spec = this._images.get(id);\n if (!spec) return;\n this._images.delete(id);\n // FIXME: really ugly workaround to get bitmaps deallocated :(\n if (window.ImageBitmap && spec.orig instanceof ImageBitmap) {\n spec.orig.close();\n }\n this.onImageDeleted?.(id);\n }\n\n /**\n * Wipe canvas and images on alternate buffer.\n */\n public wipeAlternate(): void {\n // remove all alternate tagged images\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate') {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n // mark canvas to be wiped on next render\n this._needsFullClear = true;\n this._fullyCleared = false;\n }\n\n /**\n * Delete an image by its internal storage ID.\n * Used by protocols that support explicit deletion (e.g. Kitty a=d).\n */\n public deleteImage(id: number): void {\n const spec = this._images.get(id);\n if (spec) {\n spec.marker?.dispose();\n this._delImg(id);\n }\n }\n\n /**\n * Method to add an image to the storage.\n * @param img - The image to add (canvas or bitmap).\n * @param opts - Options for addImage:\n * - scrolling: When true, cursor advances with the image.\n * When false, image is placed at ORIGIN and cursor does not move.\n * - layer: Which canvas layer to render on ('top' or 'bottom').\n * - zIndex: Z-index for image layering within the same layer.\n * - cursorPos: 'vt340' for bottom-left, 'iip' for bottom.right.\n * @returns The internal image ID assigned to the stored image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap, opts: IAddImageOpts): number {\n // never allow storage to exceed memory limit\n this._evictOldest(img.width * img.height);\n\n // calc rows x cols needed to display the image\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const cols = Math.ceil(img.width / cellSize.width);\n const rows = Math.ceil(img.height / cellSize.height);\n\n const imageId = ++this._lastId;\n\n const buffer = this._terminal._core.buffer;\n const termCols = this._terminal.cols;\n const termRows = this._terminal.rows;\n const originX = buffer.x;\n const originY = buffer.y;\n let offset = originX;\n let tileCount = 0;\n\n if (!opts.scrolling) {\n buffer.x = 0;\n buffer.y = 0;\n offset = 0;\n }\n\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(buffer.y + buffer.ybase);\n for (let col = 0; col < cols; ++col) {\n if (offset + col >= termCols) break;\n this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);\n tileCount++;\n }\n if (opts.scrolling) {\n if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();\n } else {\n if (++buffer.y >= termRows) break;\n }\n buffer.x = offset;\n }\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n\n // cursor positioning modes\n if (opts.scrolling) {\n if (opts.cursorPos === 'iip') {\n buffer.x = Math.min(offset + cols, termCols);\n } else {\n buffer.x = offset;\n }\n } else {\n buffer.x = originX;\n buffer.y = originY;\n }\n\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.tileCount < 1) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n\n // eviction marker:\n // delete the image when the marker gets disposed\n const endMarker = this._terminal.registerMarker(0);\n endMarker?.onDispose(() => {\n const spec = this._images.get(imageId);\n if (spec) {\n this._delImg(imageId);\n }\n });\n\n // since markers do not work on alternate for some reason,\n // we evict images here manually\n if (this._terminal.buffer.active.type === 'alternate') {\n this._evictOnAlternate();\n }\n\n // create storage entry\n const imgSpec: IImageSpec = {\n orig: img,\n origCellSize: cellSize,\n actual: img,\n actualCellSize: { ...cellSize }, // clone needed, since later modified\n marker: endMarker || undefined,\n tileCount,\n bufferType: this._terminal.buffer.active.type,\n layer: opts.layer,\n zIndex: opts.zIndex\n };\n\n // finally add the image\n this._images.set(imageId, imgSpec);\n this.onImageAdded?.();\n return imageId;\n }\n\n\n /**\n * Render method. Collects buffer information and triggers\n * canvas updates.\n */\n // TODO: Should we move this to the ImageRenderer?\n public render(range: { start: number, end: number }): void {\n // Determine which layers have images\n let hasTopImages = false;\n let hasBottomImages = false;\n for (const spec of this._images.values()) {\n if (spec.layer === 'bottom') {\n hasBottomImages = true;\n } else {\n hasTopImages = true;\n }\n if (hasTopImages && hasBottomImages) break;\n }\n\n // Lazily insert layers that are needed\n if (hasTopImages && !this._renderer.hasLayer('top')) {\n this._renderer.insertLayerToDom('top');\n if (!this._renderer.hasLayer('top')) return;\n }\n if (hasBottomImages && !this._renderer.hasLayer('bottom')) {\n this._renderer.insertLayerToDom('bottom');\n }\n\n // rescale if needed\n this._renderer.rescaleCanvas();\n\n // exit early if we dont have any images to test for\n if (!this._images.size) {\n if (!this._fullyCleared) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n if (this._renderer.hasLayer('top')) {\n this._renderer.removeLayerFromDom('top');\n }\n if (this._renderer.hasLayer('bottom')) {\n this._renderer.removeLayerFromDom('bottom');\n }\n return;\n }\n\n // Remove layers no longer needed\n if (!hasTopImages && this._renderer.hasLayer('top')) {\n this._renderer.clearAll('top');\n this._renderer.removeLayerFromDom('top');\n }\n if (!hasBottomImages && this._renderer.hasLayer('bottom')) {\n this._renderer.clearAll('bottom');\n this._renderer.removeLayerFromDom('bottom');\n }\n\n // buffer switches force a full clear\n if (this._needsFullClear) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n\n const { start, end } = range;\n const buffer = this._terminal._core.buffer;\n const cols = this._terminal._core.cols;\n\n // clear drawing area\n this._renderer.clearLines(start, end);\n\n // Collect draw calls so we can sort by z-index (lower z drawn first).\n const drawCalls: { imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number }[] = [];\n const placeholderCalls: { col: number, row: number, count: number }[] = [];\n\n // walk all cells in viewport and collect tiles found\n for (let row = start; row <= end; ++row) {\n const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt;\n if (!line) return;\n for (let col = 0; col < cols; ++col) {\n if (line.getBg(col) & BgFlags.HAS_EXTENDED) {\n let e: IExtendedAttrsImage = line._extendedAttrs[col] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (e.tileId !== -1) {\n const startTile = e.tileId;\n const startCol = col;\n let count = 1;\n /**\n * merge tiles to the right into a single draw call, if:\n * - not at end of line\n * - cell has same image id\n * - cell has consecutive tile id\n */\n while (\n ++col < cols\n && (line.getBg(col) & BgFlags.HAS_EXTENDED)\n && (e = line._extendedAttrs[col] ?? EMPTY_ATTRS)\n && (e.imageId === imageId)\n && (e.tileId === startTile + count)\n ) {\n count++;\n }\n col--;\n if (imgSpec) {\n if (imgSpec.actual) {\n drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });\n }\n } else if (this._opts.showPlaceholder) {\n placeholderCalls.push({ col: startCol, row, count });\n }\n this._fullyCleared = false;\n }\n }\n }\n }\n\n // Sort by z-index so lower z draws first (higher z renders on top)\n drawCalls.sort((a, b) => a.imgSpec.zIndex - b.imgSpec.zIndex);\n\n // Draw placeholders first (lowest priority)\n for (const call of placeholderCalls) {\n this._renderer.drawPlaceholder(call.col, call.row, call.count);\n }\n\n // Draw images in z-index order\n for (const call of drawCalls) {\n this._renderer.draw(call.imgSpec, call.tileId, call.col, call.row, call.count);\n }\n }\n\n public viewportResize(metrics: { cols: number, rows: number }): void {\n // exit early if we have nothing in storage\n if (!this._images.size) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // handle only viewport width enlargements, exit all other cases\n // TODO: needs patch for tile counter\n if (this._viewportMetrics.cols >= metrics.cols) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // walk scrollbuffer at old col width to find all possible expansion matches\n const buffer = this._terminal._core.buffer;\n const rows = buffer.lines.length;\n const oldCol = this._viewportMetrics.cols - 1;\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(row) as IBufferLineExt;\n if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (!imgSpec) {\n continue;\n }\n // found an image tile at oldCol, check if it qualifies for right exapansion\n const tilesPerRow = Math.ceil((imgSpec.actual?.width || 0) / imgSpec.actualCellSize.width);\n if ((e.tileId % tilesPerRow) + 1 >= tilesPerRow) {\n continue;\n }\n // expand only if right side is empty (nothing got wrapped from below)\n let hasData = false;\n for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) {\n if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) {\n hasData = true;\n break;\n }\n }\n if (hasData) {\n continue;\n }\n // do right expansion on terminal buffer\n const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol);\n let lastTile = e.tileId;\n for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) {\n this._writeToCell(line as IBufferLineExt, expandCol, imageId, ++lastTile);\n imgSpec.tileCount++;\n }\n }\n }\n // store new viewport metrics\n this._viewportMetrics = metrics;\n }\n\n /**\n * Retrieve original canvas at buffer position.\n */\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1) {\n const orig = this._images.get(e.imageId)?.orig;\n if (window.ImageBitmap && orig instanceof ImageBitmap) {\n const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height);\n canvas.getContext('2d')?.drawImage(orig, 0, 0, orig.width, orig.height);\n return canvas;\n }\n return orig as HTMLCanvasElement;\n }\n }\n }\n\n /**\n * Extract active single tile at buffer position.\n */\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1 && e.tileId !== -1) {\n const spec = this._images.get(e.imageId);\n if (spec) {\n return this._renderer.extractTile(spec, e.tileId);\n }\n }\n }\n }\n\n // TODO: Do we need some blob offloading tricks here to avoid early eviction?\n // also see https://stackoverflow.com/questions/28307789/is-there-any-limitation-on-javascript-max-blob-size\n private _evictOldest(room: number): number {\n const used = this._getStoredPixels();\n let current = used;\n while (this._pixelLimit < current + room && this._images.size) {\n const spec = this._images.get(++this._lowestId);\n if (spec && spec.orig) {\n current -= spec.orig.width * spec.orig.height;\n if (spec.actual && spec.orig !== spec.actual) {\n current -= spec.actual.width * spec.actual.height;\n }\n spec.marker?.dispose();\n this._delImg(this._lowestId);\n }\n }\n return used - current;\n }\n\n private _writeToCell(line: IBufferLineExt, x: number, imageId: number, tileId: number): void {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const old = line._extendedAttrs[x];\n if (old) {\n if (old.imageId !== undefined) {\n // found an old ExtendedAttrsImage, since we know that\n // they are always isolated instances (single cell usage),\n // we can re-use it and just update their id entries\n const oldSpec = this._images.get(old.imageId);\n if (oldSpec) {\n // early eviction for in-viewport overwrites\n oldSpec.tileCount--;\n }\n old.imageId = imageId;\n old.tileId = tileId;\n return;\n }\n // found a plain ExtendedAttrs instance, clone it to new entry\n line._extendedAttrs[x] = new ExtendedAttrsImage(old.ext, old.urlId, imageId, tileId);\n return;\n }\n }\n // fall-through: always create new ExtendedAttrsImage entry\n line._data[x * Cell.SIZE + Cell.BG] |= BgFlags.HAS_EXTENDED;\n line._extendedAttrs[x] = new ExtendedAttrsImage(0, 0, imageId, tileId);\n }\n\n private _evictOnAlternate(): void {\n // nullify tile count of all images on alternate buffer\n for (const spec of this._images.values()) {\n if (spec.bufferType === 'alternate') {\n spec.tileCount = 0;\n }\n }\n // re-count tiles on whole buffer\n const buffer = this._terminal._core.buffer;\n for (let y = 0; y < this._terminal.rows; ++y) {\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (!line) {\n continue;\n }\n for (let x = 0; x < this._terminal.cols; ++x) {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const imgId = line._extendedAttrs[x]?.imageId;\n if (imgId) {\n const spec = this._images.get(imgId);\n if (spec) {\n spec.tileCount++;\n }\n }\n }\n }\n }\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate' && !spec.tileCount) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n }\n}\n","/**\n * Copyright (c) 2020, 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IDcsHandler, IParams, IImageAddonOptions, ITerminalExt, AttributeData, IResetHandler, ReadonlyColorSet } from './Types';\nimport { toRGBA8888, BIG_ENDIAN, PALETTE_ANSI_256, PALETTE_VT340_COLOR } from 'sixel/lib/Colors';\nimport { RGBA8888 } from 'sixel/lib/Types';\nimport { ImageRenderer } from './ImageRenderer';\n\nimport { DecoderAsync, Decoder } from 'sixel/lib/Decoder';\n\n// always free decoder ressources after decoding if it exceeds this limit\nconst MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB\n\n// custom default palette: VT340 (lower 16 colors) + ANSI256 (up to 256) + zeroed (up to 4096)\nconst DEFAULT_PALETTE = PALETTE_ANSI_256;\nDEFAULT_PALETTE.set(PALETTE_VT340_COLOR);\n\n\nexport class SixelHandler implements IDcsHandler, IResetHandler {\n private _size = 0;\n private _aborted = false;\n private _dec: Decoder | undefined;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _storage: SixelImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n DecoderAsync({\n memoryLimit: this._opts.pixelLimit * 4,\n palette: DEFAULT_PALETTE,\n paletteLimit: this._opts.sixelPaletteLimit\n }).then(d => this._dec = d);\n }\n\n public reset(): void {\n /**\n * reset sixel decoder to defaults:\n * - release all memory\n * - nullify palette (4096)\n * - apply default palette (256)\n */\n if (this._dec) {\n this._dec.release();\n // FIXME: missing interface on decoder to nullify full palette\n (this._dec as any)._palette.fill(0);\n this._dec.init(0, DEFAULT_PALETTE, this._opts.sixelPaletteLimit);\n }\n }\n\n public hook(params: IParams): void {\n this._size = 0;\n this._aborted = false;\n if (this._dec) {\n const fillColor = params.params[1] === 1 ? 0 : extractActiveBg(\n this._coreTerminal._core._inputHandler._curAttrData,\n this._coreTerminal._core._themeService?.colors);\n this._dec.init(fillColor, null, this._opts.sixelPaletteLimit);\n }\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted || !this._dec) {\n return;\n }\n this._size += end - start;\n if (this._size > this._opts.sixelSizeLimit) {\n console.warn(`SIXEL: too much data, aborting`);\n this._aborted = true;\n this._dec.release();\n return;\n }\n try {\n this._dec.decode(data, start, end);\n } catch (e) {\n console.warn(`SIXEL: error while decoding image - ${e}`);\n this._aborted = true;\n this._dec.release();\n }\n }\n\n public unhook(success: boolean): boolean | Promise {\n if (this._aborted || !success || !this._dec) {\n return true;\n }\n\n const width = this._dec.width;\n const height = this._dec.height;\n\n // partial fix for https://github.com/jerch/xterm-addon-image/issues/37\n if (!width || !height) {\n if (height) {\n this._storage.advanceCursor(height);\n }\n return true;\n }\n\n const canvas = ImageRenderer.createCanvas(undefined, width, height);\n canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray, width, height), 0, 0);\n if (this._dec.memoryUsage > MEM_PERMA_LIMIT) {\n this._dec.release();\n }\n this._storage.addImage(canvas);\n return true;\n }\n}\n\n\n/**\n * Some helpers to extract current terminal colors.\n */\n\n// get currently active background color from terminal\n// also respect INVERSE setting\nfunction extractActiveBg(attr: AttributeData, colors: ReadonlyColorSet | undefined): RGBA8888 {\n let bg = 0;\n if (!colors) {\n // FIXME: theme service is prolly not available yet,\n // happens if .open() was not called yet (bug in core?)\n return bg;\n }\n if (attr.isInverse()) {\n if (attr.isFgDefault()) {\n bg = convertLe(colors.foreground.rgba);\n } else if (attr.isFgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getFgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getFgColor()].rgba);\n }\n } else {\n if (attr.isBgDefault()) {\n bg = convertLe(colors.background.rgba);\n } else if (attr.isBgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getBgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getBgColor()].rgba);\n }\n }\n return bg;\n}\n\n// rgba values on the color managers are always in BE, thus convert to LE\nfunction convertLe(color: number): RGBA8888 {\n if (BIG_ENDIAN) return color;\n return (color & 0xFF) << 24 | (color >>> 8 & 0xFF) << 16 | (color >>> 16 & 0xFF) << 8 | color >>> 24 & 0xFF;\n}\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { IImageAddonOptions, ITerminalExt, IAddImageOpts } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\n\n/**\n * Sixel-specific image storage controller.\n *\n * Wraps the shared ImageStorage with sixel protocol semantics:\n * - Cursor behavior governed by DECSET 80 (sixelScrolling option)\n * - advanceCursor for empty sixels carrying only height\n */\nexport class SixelImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'vt340' };\n constructor(\n private readonly _storage: ImageStorage,\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _terminal: ITerminalExt\n ) {}\n\n /**\n * Add a sixel image to storage.\n * Cursor behavior depends on the sixelScrolling option (DECSET 80).\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._addImageOpts.scrolling = this._opts.sixelScrolling;\n this._storage.addImage(img, this._addImageOpts);\n }\n\n /**\n * Only advance text cursor.\n * This is an edge case from empty sixels carrying only a height but no pixels.\n * Partially fixes https://github.com/jerch/xterm-addon-image/issues/37.\n */\n public advanceCursor(height: number): void {\n if (this._opts.sixelScrolling) {\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const rows = Math.ceil(height / cellSize.height);\n for (let i = 1; i < rows; ++i) {\n this._terminal._core._inputHandler.lineFeed();\n }\n }\n }\n}\n","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';\nimport { ImageRenderer } from '../ImageRenderer';\nimport { CELL_SIZE_DEFAULT } from '../ImageStorage';\nimport { imageType } from '../IIPMetrics';\nimport { KittyImageStorage } from './KittyImageStorage';\nimport Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport {\n KittyAction,\n KittyFormat,\n KittyCompression,\n IKittyCommand,\n IPendingTransmission,\n IKittyImageData,\n KittyPixelConstants,\n parseKittyCommand\n} from './KittyGraphicsTypes';\n\nconst enum Constants {\n // Memory limit for base64 decoder (4MB, same as IIPHandler)\n DECODER_KEEP_DATA = 4194304,\n DECODER_INITIAL_DATA = 4194304, // 4MB\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n DECODER_OK = 0,\n // Maximum control data size\n MAX_CONTROL_DATA_SIZE = 512,\n // Semicolon codepoint\n SEMICOLON = 0x3B\n}\n\nconst DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK;\n\n// Kitty graphics protocol handler with streaming base64 decoding.\nexport class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {\n private _aborted = false;\n private _generation = 0;\n private _decodeError = false;\n\n private _activeDecoder: Base64Decoder | null = null;\n private readonly _maxEncodedBytes: number;\n private readonly _initialEncodedBytes: number;\n\n // Streaming related states\n\n // True while receiving control data (before semicolon).\n private _inControlData = true;\n\n // Buffer for control data.\n private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);\n private _controlLength = 0;\n\n // Pre-calculated encoded size limit\n private _encodedSizeLimit = 0;\n private _totalEncodedSize = 0;\n\n // Parsed command. These are the control data before semicolon.\n private _parsedCommand: IKittyCommand | null = null;\n\n // Storage related states\n\n private _pendingTransmissions: Map = new Map();\n // Tracks the pending key of the most recently started chunked upload.\n // Per spec, subsequent chunks only need m= (and optionally q=), without i=.\n // When a chunk arrives with no i=, this key is used to find the pending upload.\n private _lastPendingKey: number | undefined;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _kittyStorage: KittyImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n // Convert decoded size limit -> max encoded bytes.\n this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);\n // ensure we preallocate more than configured limit while using 4mb initial size.\n this._initialEncodedBytes = Math.min(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);\n }\n\n public reset(): void {\n this._generation++;\n this._cleanupAllPending();\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n this._kittyStorage.reset();\n }\n\n public dispose(): void {\n this.reset();\n }\n\n private _removePendingEntry(key: number): void {\n this._pendingTransmissions.delete(key);\n if (this._lastPendingKey === key) {\n this._lastPendingKey = undefined;\n }\n }\n\n private _cleanupAllPending(): void {\n for (const pending of this._pendingTransmissions.values()) {\n pending.decoder.release();\n }\n this._pendingTransmissions.clear();\n this._lastPendingKey = undefined;\n }\n\n public start(): void {\n this._aborted = false;\n this._decodeError = false;\n this._inControlData = true;\n this._controlLength = 0;\n this._parsedCommand = null;\n // Pre-calculate encoded limit once: base64 is 4 bytes encoded → 3 bytes decoded\n this._encodedSizeLimit = this._maxEncodedBytes;\n this._totalEncodedSize = 0;\n this._activeDecoder = null;\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (!this._inControlData) {\n this._streamPayload(data, start, end);\n } else {\n // Scan for semicolon\n let controlEnd = end;\n for (let i = start; i < end; i++) {\n if (data[i] === Constants.SEMICOLON) {\n this._inControlData = false;\n controlEnd = i;\n break;\n }\n }\n\n // Copy control data\n const copyLength = controlEnd - start;\n if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {\n this._aborted = true;\n return;\n }\n this._controlData.set(data.subarray(start, controlEnd), this._controlLength);\n this._controlLength += copyLength;\n\n if (!this._inControlData) {\n // Found semicolon - parse control data early for validation\n this._parsedCommand = parseKittyCommand(this._parseControlDataString());\n\n // Early validation: i+I conflict\n if (this._parsedCommand.id !== undefined && this._parsedCommand.imageNumber !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'EINVAL:cannot specify both i and I keys', this._parsedCommand.quiet ?? 0);\n this._aborted = true;\n return;\n }\n\n // Delete action doesn't need payload - skip streaming\n if (this._parsedCommand.action === KittyAction.DELETE) {\n return;\n }\n\n // Stream remaining as payload\n const payloadStart = controlEnd + 1;\n if (payloadStart < end) {\n this._streamPayload(data, payloadStart, end);\n }\n }\n }\n }\n\n // Stream payload bytes into the base64 decoder.\n private _streamPayload(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n // Check size limit (compare encoded bytes against pre-calculated limit)\n // Include cumulative size from pending transmission for multi-chunk images.\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = this._parsedCommand?.id ?? this._lastPendingKey ?? 0;\n const pending = this._pendingTransmissions.get(pendingKey);\n const previousEncodedSize = pending?.totalEncodedSize ?? 0;\n this._totalEncodedSize += end - start;\n const cumulativeEncodedSize = previousEncodedSize + this._totalEncodedSize;\n if (cumulativeEncodedSize > this._encodedSizeLimit) {\n const decoderToRelease = this._activeDecoder ?? pending?.decoder;\n if (decoderToRelease) {\n decoderToRelease.release();\n }\n this._activeDecoder = null;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n this._aborted = true;\n return;\n }\n\n if (this._decodeError) return;\n\n if (pending?.decoder && !this._activeDecoder) {\n this._activeDecoder = pending.decoder;\n }\n if (!this._activeDecoder) {\n // Budget WASM capacity, including one page of decoder state and rounding.\n const decoderCapacity = this._maxEncodedBytes + 131072;\n if (decoderCapacity > this._opts.storageLimit * 1000000) {\n this._aborted = true;\n if (this._parsedCommand?.id !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'ENOMEM:pending image budget exceeded', this._parsedCommand.quiet ?? 0);\n }\n return;\n }\n const maxPending = Math.max(1, Math.floor(this._opts.storageLimit * 1000000 / decoderCapacity));\n while (this._pendingTransmissions.size >= maxPending) {\n const oldest = this._pendingTransmissions.entries().next().value;\n if (!oldest) break;\n oldest[1].decoder.release();\n this._removePendingEntry(oldest[0]);\n if (oldest[1].cmd.id !== undefined) {\n this._sendResponse(oldest[1].cmd.id, 'ENOMEM:pending image budget exceeded', oldest[1].cmd.quiet ?? 0);\n }\n }\n this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);\n this._activeDecoder.init();\n }\n\n if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n this._decodeError = true;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted || !success) {\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n return true;\n }\n\n // No semicolon = no payload (delete, capability query)\n if (this._inControlData) {\n return this._handleNoPayloadCommand();\n }\n\n // Use command parsed early in put() - i+I already validated there\n const cmd = this._parsedCommand!;\n\n // Delete action was handled by skipping payload - just execute\n if (cmd.action === KittyAction.DELETE) {\n return this._handleDelete(cmd);\n }\n\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = cmd.id ?? this._lastPendingKey ?? 0;\n const isMoreComing = cmd.more === 1;\n const pending = this._pendingTransmissions.get(pendingKey);\n\n if (isMoreComing) {\n if (this._activeDecoder) {\n if (pending) {\n pending.totalEncodedSize += this._totalEncodedSize;\n pending.decodeError = pending.decodeError || this._decodeError;\n } else {\n this._pendingTransmissions.set(pendingKey, {\n cmd: { ...cmd },\n decoder: this._activeDecoder,\n totalEncodedSize: this._totalEncodedSize,\n decodeError: this._decodeError\n });\n }\n this._lastPendingKey = pendingKey;\n this._activeDecoder = null;\n }\n return true;\n }\n\n // Final chunk received — clear the last pending key\n if (pending) {\n this._lastPendingKey = undefined;\n }\n\n let decodeError = this._decodeError;\n let finalCmd = cmd;\n let decoder = this._activeDecoder;\n\n if (pending) {\n finalCmd = pending.cmd;\n decoder = pending.decoder;\n decodeError = decodeError || pending.decodeError;\n this._pendingTransmissions.delete(pendingKey);\n }\n\n let imageBytes = new Uint8Array(0);\n if (decoder) {\n if (decoder.end() !== DECODER_OK) {\n decodeError = true;\n }\n imageBytes = decoder.data8;\n }\n this._activeDecoder = null;\n\n // Handle command first — handlers create Blob/ImageData from imageBytes,\n // which copies the data. Only then is it safe to release the decoder's\n // wasm memory that imageBytes points into.\n const result = this._handleCommandWithBytesAndCmd(finalCmd, imageBytes, decodeError);\n if (decoder) {\n decoder.release();\n }\n return result;\n }\n\n // Command handling\n\n private _parseControlDataString(): string {\n let str = '';\n for (let i = 0; i < this._controlLength; i++) {\n str += String.fromCodePoint(this._controlData[i]);\n }\n return str;\n }\n\n private _handleNoPayloadCommand(): boolean | Promise {\n const cmd = parseKittyCommand(this._parseControlDataString());\n\n // Per spec: specifying both i and I is an error\n if (cmd.id !== undefined && cmd.imageNumber !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:cannot specify both i and I keys', cmd.quiet ?? 0);\n return true;\n }\n\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.DELETE:\n return this._handleDelete(cmd);\n case KittyAction.QUERY:\n this._sendResponse(cmd.id ?? 0, 'OK', cmd.quiet ?? 0);\n return true;\n case KittyAction.PLACEMENT:\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handleCommandWithBytesAndCmd(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.TRANSMIT: {\n const result = this._handleTransmit(cmd, bytes, decodeError);\n // Only send response when _handleTransmit didn't already respond\n // (it handles unsupported transmission medium responses internally)\n if ((cmd.transmission ?? 'd') === 'd' && cmd.id !== undefined) {\n if (decodeError) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n } else if (bytes.length > 0) {\n this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0);\n }\n }\n return result;\n }\n case KittyAction.TRANSMIT_DISPLAY:\n return this._handleTransmitDisplay(cmd, bytes, decodeError);\n case KittyAction.QUERY:\n return this._handleQuery(cmd, bytes, decodeError);\n case KittyAction.PLACEMENT:\n // a=p ignores any payload — image data was already transmitted\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handlePlacement(cmd: IKittyCommand): boolean | Promise {\n if (cmd.id === undefined) {\n return true;\n }\n const id = cmd.id;\n const image = this._kittyStorage.getImage(id);\n if (!image) {\n this._sendResponse(id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);\n return true;\n }\n const result = this._displayImage(image, cmd);\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);\n return true;\n });\n }\n\n private _handleTransmit(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n // TODO: Support file-based transmission modes (t=f, t=t, t=s)\n // Currently only supports direct transmission (t=d, the default).\n // - t=f (file): Payload is base64-encoded file path. Terminal reads image from that path.\n // - t=t (temp file): Payload is base64-encoded path in temp directory. Terminal reads, deletes.\n // - t=s: Payload is base64-encoded POSIX shm name. Terminal reads from shared memory.\n // These modes require filesystem/IPC access not available in browsers. For Node.js/Electron:\n // 1. Check cmd.transmission (t key) before treating bytes as image data\n // 2. For t=f/t/s: decode bytes as UTF-8 string (the path/name), then read file contents\n // 3. For t=d: treat bytes as image data (current behavior)\n // When implementing, also update _handleQuery to accept these transmission mediums.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported transmission medium', cmd.quiet ?? 0);\n }\n return true;\n }\n\n if (decodeError || bytes.length === 0) return true;\n\n this._kittyStorage.storeImage(cmd.id, {\n data: new Blob([bytes as BlobPart]),\n width: cmd.width ?? 0,\n height: cmd.height ?? 0,\n format: (cmd.format ?? KittyFormat.RGBA) as 24 | 32 | 100,\n compression: cmd.compression ?? ''\n });\n return true;\n }\n\n private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n if (decodeError) {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n }\n return true;\n }\n\n this._handleTransmit(cmd, bytes, decodeError);\n\n const id = cmd.id ?? this._kittyStorage.lastImageId;\n const image = this._kittyStorage.getImage(id);\n if (image) {\n const result = this._displayImage(image, cmd);\n if (cmd.id !== undefined) {\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0);\n return true;\n });\n }\n return result.then(() => true);\n }\n return true;\n }\n\n private _handleQuery(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n const id = cmd.id ?? 0;\n const quiet = cmd.quiet ?? 0;\n\n // Per spec: reject unsupported transmission mediums (only t=d is supported atm)\n // TODO: When filesystem support is added (Node.js/Electron), update this to accept\n // t=f (file), t=t (temp file), and t=s (shared memory) and respond OK for queries.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n this._sendResponse(id, 'EINVAL:unsupported transmission medium', quiet);\n return true;\n }\n\n // Check decode error first (invalid base64)\n if (decodeError) {\n this._sendResponse(id, 'EINVAL:invalid base64 data', quiet);\n return true;\n }\n\n // Capability query (no payload) - just respond OK\n if (bytes.length === 0) {\n this._sendResponse(id, 'OK', quiet);\n return true;\n }\n\n const format = cmd.format ?? KittyFormat.RGBA;\n\n if (format === KittyFormat.PNG) {\n this._sendResponse(id, 'OK', quiet);\n } else {\n const width = cmd.width ?? 0;\n const height = cmd.height ?? 0;\n\n if (!width || !height) {\n this._sendResponse(id, 'EINVAL:width and height required for raw pixel data', quiet);\n return true;\n }\n\n const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n this._sendResponse(id, `EINVAL:insufficient pixel data`, quiet);\n return true;\n }\n\n this._sendResponse(id, 'OK', quiet);\n }\n return true;\n }\n\n private _handleDelete(cmd: IKittyCommand): boolean {\n // Per spec: default delete selector is 'a' (delete all visible placements)\n const selector = cmd.deleteSelector ?? 'a';\n\n // TODO: Distinguish lowercase (delete placements only) from uppercase\n // (delete placements + free stored image data). Currently both variants\n // free everything since we don't separate stored data from placements.\n switch (selector) {\n case 'a':\n case 'A':\n this._cleanupAllPending();\n this._kittyStorage.deleteAll();\n break;\n case 'i':\n case 'I':\n // TODO: When placement id tracking is implemented (see TODO in\n // KittyImageStorage), d=i with p= should delete only that\n // specific placement, while d=i without p should delete all\n // placements for the image.\n if (cmd.id !== undefined) {\n const pending = this._pendingTransmissions.get(cmd.id);\n if (pending) {\n pending.decoder.release();\n }\n this._removePendingEntry(cmd.id);\n this._kittyStorage.deleteById(cmd.id);\n }\n break;\n default:\n // Unsupported selectors (c, n, p, q, r, x, y, z, f) — ignore for now\n break;\n }\n return true;\n }\n\n private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void {\n const isOk = message === 'OK';\n if (isOk && quiet >= 1) return;\n if (!isOk && quiet >= 2) return;\n\n const pPart = placementId ? `,p=${placementId}` : '';\n const response = `\\x1b_Gi=${id}${pPart};${message}\\x1b\\\\`;\n this._coreTerminal._core.coreService.triggerDataEvent(response);\n }\n\n // Image display\n\n private _displayImage(image: IKittyImageData, cmd: IKittyCommand): Promise {\n return this._decodeAndDisplay(image, cmd)\n .then(() => true)\n .catch(() => false);\n }\n\n private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise {\n const generation = this._generation;\n let bitmap: ImageBitmap | undefined = await this._createBitmap(image);\n\n try {\n if (generation !== this._generation) throw new Error('image decode canceled');\n const cropX = Math.max(0, cmd.x ?? 0);\n const cropY = Math.max(0, cmd.y ?? 0);\n const cropW = cmd.sourceWidth || (bitmap.width - cropX);\n const cropH = cmd.sourceHeight || (bitmap.height - cropY);\n\n const maxCropW = Math.max(0, bitmap.width - cropX);\n const maxCropH = Math.max(0, bitmap.height - cropY);\n const finalCropW = Math.max(0, Math.min(cropW, maxCropW));\n const finalCropH = Math.max(0, Math.min(cropH, maxCropH));\n\n if (finalCropW === 0 || finalCropH === 0) {\n throw new Error('invalid source rectangle');\n }\n\n if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {\n const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);\n bitmap.close();\n bitmap = cropped;\n }\n\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n\n // Per spec: c/r default to image's natural cell dimensions.\n // If only one of c/r is specified, compute the other from image aspect ratio.\n let imgCols: number;\n let imgRows: number;\n if (cmd.columns !== undefined && cmd.rows !== undefined) {\n imgCols = cmd.columns;\n imgRows = cmd.rows;\n } else if (cmd.columns !== undefined) {\n imgCols = cmd.columns;\n imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));\n } else if (cmd.rows !== undefined) {\n imgRows = cmd.rows;\n imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));\n } else {\n imgCols = Math.ceil(bitmap.width / cw);\n imgRows = Math.ceil(bitmap.height / ch);\n }\n\n let w = bitmap.width;\n let h = bitmap.height;\n\n // Scale bitmap to fit placement rectangle when c/r are specified\n if (cmd.columns !== undefined || cmd.rows !== undefined) {\n w = Math.round(imgCols * cw);\n h = Math.round(imgRows * ch);\n }\n\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n\n // Save cursor position before addImage modifies it\n const buffer = this._coreTerminal._core.buffer;\n const savedX = buffer.x;\n const savedY = buffer.y;\n const savedYbase = buffer.ybase;\n\n // Determine layer based on z-index: negative = behind text, 0+ = on top.\n // When z<0 we always use the bottom layer even without allowTransparency —\n // the image will simply be hidden behind the opaque text background, which\n // is the correct behavior (client asked for \"behind text\").\n const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;\n const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';\n\n if (w !== bitmap.width || h !== bitmap.height) {\n const scaled = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });\n bitmap.close();\n bitmap = scaled;\n }\n\n // Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions\n const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1);\n const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1);\n if (xOffset !== 0 || yOffset !== 0) {\n // Per spec: X/Y is not added to c/r area. When c/r are explicit, the\n // total placement area remains c*cw × r*ch pixels and the offset image\n // is clipped to fit. When c/r are unset, the padded canvas determines\n // the natural cell dimensions.\n const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset;\n const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset;\n const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);\n const offsetCtx = offsetCanvas.getContext('2d');\n if (!offsetCtx) {\n throw new Error('Failed to create offset canvas context');\n }\n offsetCtx.drawImage(bitmap, xOffset, yOffset);\n\n const offsetBitmap = await createImageBitmap(offsetCanvas);\n offsetCanvas.width = offsetCanvas.height = 0;\n bitmap.close();\n bitmap = offsetBitmap;\n w = bitmap.width;\n h = bitmap.height;\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n if (cmd.columns === undefined) {\n imgCols = Math.ceil(bitmap.width / cw);\n }\n if (cmd.rows === undefined) {\n imgRows = Math.ceil(bitmap.height / ch);\n }\n }\n\n if (generation !== this._generation) throw new Error('image decode canceled');\n const zIndex = cmd.zIndex ?? 0;\n this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);\n bitmap = undefined; // ownership transferred to storage\n\n // Kitty cursor movement\n // Per spec: cursor placed at first column after last image column,\n // on the last row of the image. C=1 means don't move cursor.\n if (cmd.cursorMovement === 1) {\n // C=1: restore cursor to position before image was placed\n const scrolled = buffer.ybase - savedYbase;\n buffer.x = savedX;\n // Can't restore cursor to scrollback?\n buffer.y = Math.max(savedY - scrolled, 0);\n } else {\n // Default (C=0): advance cursor horizontally past the image\n // addImage already positioned cursor on the last row via lineFeeds\n buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);\n }\n } catch (e) {\n bitmap?.close();\n throw e;\n }\n }\n\n // Create ImageBitmap from already-decoded image data.\n private async _createBitmap(image: IKittyImageData): Promise {\n let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());\n\n if (image.compression === KittyCompression.ZLIB) {\n bytes = await this._decompressZlib(bytes);\n }\n\n if (image.format === KittyFormat.PNG) {\n const metrics = imageType(bytes);\n // IHDR dimensions are parsed with signed shifts, so a value >= 0x80000000 comes\n // back negative and a bare `>` pixel-limit test passes it; require positive.\n if (metrics.mime !== 'image/png' || !(metrics.width > 0) || !(metrics.height > 0) || metrics.width * metrics.height > this._opts.pixelLimit) {\n throw new RangeError('PNG exceeds pixel limit or has invalid dimensions');\n }\n const blob = new Blob([bytes as BlobPart], { type: 'image/png' });\n if (!window.createImageBitmap) {\n const url = URL.createObjectURL(blob);\n const img = new Image();\n return new Promise((resolve, reject) => {\n img.addEventListener('load', () => {\n URL.revokeObjectURL(url);\n const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);\n canvas.getContext('2d')?.drawImage(img, 0, 0);\n createImageBitmap(canvas).then(resolve).catch(reject);\n });\n img.addEventListener('error', () => {\n URL.revokeObjectURL(url);\n reject(new Error('Failed to load image'));\n });\n img.src = url;\n });\n }\n return createImageBitmap(blob);\n }\n\n // Raw pixel data\n const width = image.width;\n const height = image.height;\n\n if (!width || !height) {\n throw new Error('Width and height required for raw pixel data');\n }\n\n const bytesPerPixel = image.format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n throw new Error('Insufficient pixel data');\n }\n\n const pixelCount = width * height;\n\n if (image.format === KittyFormat.RGBA) {\n // RGBA: use bytes directly — no copy needed\n return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA), width, height));\n }\n\n // RGB→RGBA: interleave alpha using uint32 block processing (4 pixels per iteration).\n // 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes — ~6x faster.\n // Assumes little-endian (all modern browsers/Node.js).\n const data = new Uint8ClampedArray(pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA);\n const src32 = new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));\n const dst32 = new Uint32Array(data.buffer);\n const alignedPixels = pixelCount & ~3; // round down to multiple of 4\n\n let srcOffset = 0;\n let dstOffset = 0;\n for (let i = 0; i < alignedPixels; i += 4) {\n const b0 = src32[srcOffset++];\n const b1 = src32[srcOffset++];\n const b2 = src32[srcOffset++];\n // Little-endian: pixel bytes are [R,G,B] → uint32 ABGR layout\n dst32[dstOffset++] = 0xFF000000 | b0;\n dst32[dstOffset++] = 0xFF000000 | (b0 >>> 24) | (b1 << 8);\n dst32[dstOffset++] = 0xFF000000 | (b1 >>> 16) | (b2 << 16);\n dst32[dstOffset++] = 0xFF000000 | (b2 >>> 8);\n }\n\n // Handle remaining 1–3 pixels\n let srcByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n let dstByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n for (let i = alignedPixels; i < pixelCount; i++) {\n data[dstByte] = bytes[srcByte];\n data[dstByte + 1] = bytes[srcByte + 1];\n data[dstByte + 2] = bytes[srcByte + 2];\n data[dstByte + 3] = KittyPixelConstants.ALPHA_OPAQUE;\n srcByte += KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n dstByte += KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n }\n\n return createImageBitmap(new ImageData(data, width, height));\n }\n\n private async _decompressZlib(compressed: Uint8Array): Promise {\n try {\n return await this._decompress(compressed, 'deflate');\n } catch (error) {\n if (error instanceof RangeError) throw error;\n return await this._decompress(compressed, 'deflate-raw');\n }\n }\n\n private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise {\n const limit = Math.min(this._opts.kittySizeLimit, this._opts.pixelLimit * 4, this._opts.storageLimit * 1000000);\n let offsetIn = 0;\n // Bound inflation within one native transform before its output is budgeted.\n const source = new ReadableStream({\n pull(controller) {\n if (offsetIn >= compressed.length) {\n controller.close();\n return;\n }\n const end = Math.min(offsetIn + 4096, compressed.length);\n controller.enqueue(new Uint8Array(compressed.subarray(offsetIn, end)));\n offsetIn = end;\n }\n });\n const reader = source.pipeThrough(new DecompressionStream(format)).getReader();\n const chunks: Uint8Array[] = [];\n let totalLength = 0;\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n totalLength += value.byteLength;\n if (totalLength > limit) {\n await reader.cancel().catch(() => {});\n throw new RangeError('decompressed image exceeds byte limit');\n }\n chunks.push(value);\n }\n } finally {\n reader.releaseLock();\n }\n\n const result = new Uint8Array(totalLength);\n let offset = 0;\n for (const chunk of chunks) {\n result.set(chunk, offset);\n offset += chunk.length;\n }\n return result;\n }\n\n public get images(): ReadonlyMap {\n return this._kittyStorage.images;\n }\n\n public get _kittyIdToStorageId(): ReadonlyMap {\n return this._kittyStorage.kittyIdToStorageId;\n }\n\n public get pendingTransmissions(): ReadonlyMap {\n return this._pendingTransmissions;\n }\n}\n","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Kitty graphics protocol types, constants, and parsing utilities.\n */\n\nimport type Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\n\n// Kitty graphics protocol action types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'a'.\nexport const enum KittyAction {\n TRANSMIT = 't',\n TRANSMIT_DISPLAY = 'T',\n QUERY = 'q',\n PLACEMENT = 'p',\n DELETE = 'd'\n}\n\n// Kitty graphics protocol format types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyFormat {\n RGB = 24,\n RGBA = 32,\n PNG = 100\n}\n\n// Kitty graphics protocol compression types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'o'.\nexport const enum KittyCompression {\n NONE = '',\n ZLIB = 'z'\n}\n\n// Kitty graphics protocol control data keys.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyKey {\n // Action to perform (t=transmit, T=transmit+display, q=query, p=placement, d=delete)\n ACTION = 'a',\n // Image format (24=RGB, 32=RGBA, 100=PNG)\n FORMAT = 'f',\n // Image ID for referencing stored images\n ID = 'i',\n // Image number (alternative to ID, terminal assigns ID)\n IMAGE_NUMBER = 'I',\n // Source image width in pixels\n WIDTH = 's',\n // Source image height in pixels\n HEIGHT = 'v',\n // The left edge (in pixels) of the image area to display\n X_OFFSET = 'x',\n // The top edge (in pixels) of the image area to display\n Y_OFFSET = 'y',\n // Width (in pixels) of the source rectangle to display\n SOURCE_WIDTH = 'w',\n // Height (in pixels) of the source rectangle to display\n SOURCE_HEIGHT = 'h',\n // Horizontal offset (in pixels) within the first cell\n X_PLACEMENT_OFFSET = 'X',\n // Vertical offset (in pixels) within the first cell\n Y_PLACEMENT_OFFSET = 'Y',\n // Number of terminal columns to display the image over\n COLUMNS = 'c',\n // Number of terminal rows to display the image over\n ROWS = 'r',\n // More data flag (1=more chunks coming, 0=final chunk)\n MORE = 'm',\n // Compression type (z=zlib). This is essential for chunking larger images.\n COMPRESSION = 'o',\n // Quiet mode (1=suppress OK responses, 2=suppress error responses)\n QUIET = 'q',\n // Cursor movement policy (0=move cursor after image, 1=don't move cursor)\n CURSOR_MOVEMENT = 'C',\n // Z-index for image layering (negative = behind text, 0+ = on top)\n Z_INDEX = 'z',\n // Transmission medium (d=direct, f=file, t=temp file, s=shared memory)\n TRANSMISSION = 't',\n // Delete selector (a/A=all, i/I=by id, c/C=at cursor, etc.) — only used when a=d\n DELETE_SELECTOR = 'd',\n // Placement ID for targeting specific placements\n PLACEMENT_ID = 'p'\n}\n\n// Pixel format constants\nexport const enum KittyPixelConstants {\n BYTES_PER_PIXEL_RGB = 3,\n BYTES_PER_PIXEL_RGBA = 4,\n ALPHA_OPAQUE = 255\n}\n\n// Parsed Kitty graphics command.\nexport interface IKittyCommand {\n action?: string;\n format?: number;\n id?: number;\n imageNumber?: number;\n width?: number;\n height?: number;\n x?: number;\n y?: number;\n sourceWidth?: number;\n sourceHeight?: number;\n xOffset?: number;\n yOffset?: number;\n columns?: number;\n rows?: number;\n more?: number;\n quiet?: number;\n cursorMovement?: number;\n zIndex?: number;\n transmission?: string;\n deleteSelector?: string;\n placementId?: number;\n compression?: string;\n payload?: string;\n}\n\n// Pending chunked transmission state.\n// Stores metadata from the first chunk while accumulating decoded payload data.\nexport interface IPendingTransmission {\n // The parsed command from the first chunk (contains action, format, dimensions, etc.)\n cmd: IKittyCommand;\n // Decoder used across chunked payloads\n decoder: Base64Decoder;\n // Total encoded (base64) bytes received across all chunks - for size limit enforcement\n totalEncodedSize: number;\n // Whether any chunk has failed to decode\n decodeError: boolean;\n}\n\n// Stored Kitty image data.\nexport interface IKittyImageData {\n id: number;\n // Decoded image data stored as Blob (off JS heap) to avoid 2GB heap limit\n data: Blob;\n width: number;\n height: number;\n format: 24 | 32 | 100;\n compression?: string;\n}\n\n// Parses Kitty graphics control data into a command object.\nexport function parseKittyCommand(data: string): IKittyCommand {\n const cmd: IKittyCommand = {};\n const parts = data.split(',');\n\n for (const part of parts) {\n const eqIdx = part.indexOf('=');\n if (eqIdx === -1) continue;\n\n const key = part.substring(0, eqIdx);\n const value = part.substring(eqIdx + 1);\n\n // Handle string keys first\n if (key === KittyKey.ACTION) {\n cmd.action = value;\n continue;\n }\n if (key === KittyKey.COMPRESSION) {\n cmd.compression = value;\n continue;\n }\n if (key === KittyKey.TRANSMISSION) {\n cmd.transmission = value;\n continue;\n }\n if (key === KittyKey.DELETE_SELECTOR) {\n cmd.deleteSelector = value;\n continue;\n }\n const numValue = parseInt(value, 10);\n switch (key) {\n case KittyKey.FORMAT: cmd.format = numValue; break;\n case KittyKey.ID: cmd.id = numValue; break;\n case KittyKey.IMAGE_NUMBER: cmd.imageNumber = numValue; break;\n case KittyKey.WIDTH: cmd.width = numValue; break;\n case KittyKey.HEIGHT: cmd.height = numValue; break;\n case KittyKey.X_OFFSET: cmd.x = numValue; break;\n case KittyKey.Y_OFFSET: cmd.y = numValue; break;\n case KittyKey.SOURCE_WIDTH: cmd.sourceWidth = numValue; break;\n case KittyKey.SOURCE_HEIGHT: cmd.sourceHeight = numValue; break;\n case KittyKey.X_PLACEMENT_OFFSET: cmd.xOffset = numValue; break;\n case KittyKey.Y_PLACEMENT_OFFSET: cmd.yOffset = numValue; break;\n case KittyKey.COLUMNS: cmd.columns = numValue; break;\n case KittyKey.ROWS: cmd.rows = numValue; break;\n case KittyKey.MORE: cmd.more = numValue; break;\n case KittyKey.QUIET: cmd.quiet = numValue; break;\n case KittyKey.CURSOR_MOVEMENT: cmd.cursorMovement = numValue; break;\n case KittyKey.Z_INDEX: cmd.zIndex = numValue; break;\n case KittyKey.PLACEMENT_ID: cmd.placementId = numValue; break;\n }\n }\n\n return cmd;\n}\n","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageStorage } from '../ImageStorage';\nimport { ImageLayer, IAddImageOpts } from '../Types';\nimport { IKittyImageData } from './KittyGraphicsTypes';\n\n// Kitty-specific image storage controller.\n//\n// Wraps shared ImageStorage with kitty protocol semantics:\n// - tracks transmitted image payloads by kitty image id\n// - tracks kitty image id -> shared ImageStorage id mapping for displayed images\n// - mirrors shared-storage evictions into kitty maps\n// - applies protocol-level undisplayed-image eviction policy\nexport class KittyImageStorage implements IDisposable {\n private static readonly _maxStoredImages = 256;\n\n private _nextImageId = 1;\n private readonly _images: Map = new Map();\n // TODO: Support multiple placements per image. The kitty spec identifies\n // placements by an (image id, placement id) pair — same i + different p\n // values should coexist, and same i + same p should replace the prior\n // placement. Currently we track only one storage entry per kitty image id,\n // so multiple placements of the same image overwrite each other. Fixing\n // this requires changing these maps to Map>\n // (kittyId → placementId → storageId) and updating addImage/deleteById\n // accordingly. The underlying shared ImageStorage would also need to\n // support multiple entries per logical image.\n private readonly _kittyIdToStorageId: Map = new Map();\n private readonly _storageIdToKittyId: Map = new Map();\n\n private readonly _previousOnImageDeleted: ((storageId: number) => void) | undefined;\n private readonly _wrappedOnImageDeleted: (storageId: number) => void;\n private readonly _handleStorageImageDeleted = (storageId: number): void => {\n const kittyId = this._storageIdToKittyId.get(storageId);\n if (kittyId !== undefined) {\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n this._images.delete(kittyId);\n }\n };\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n\n constructor(\n private readonly _storage: ImageStorage\n ) {\n this._previousOnImageDeleted = this._storage.onImageDeleted;\n this._wrappedOnImageDeleted = (storageId: number) => {\n this._previousOnImageDeleted?.(storageId);\n this._handleStorageImageDeleted(storageId);\n };\n this._storage.onImageDeleted = this._wrappedOnImageDeleted;\n }\n\n public reset(): void {\n this._nextImageId = 1;\n this._images.clear();\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public dispose(): void {\n this.reset();\n if (this._storage.onImageDeleted === this._wrappedOnImageDeleted) {\n this._storage.onImageDeleted = this._previousOnImageDeleted;\n }\n }\n\n public storeImage(id: number | undefined, imageData: Omit): number {\n const imageId = id ?? this._nextImageId++;\n\n const oldStorageId = this._kittyIdToStorageId.get(imageId);\n if (oldStorageId !== undefined) {\n this._storage.deleteImage(oldStorageId);\n this._kittyIdToStorageId.delete(imageId);\n this._storageIdToKittyId.delete(oldStorageId);\n }\n\n if (!this._images.has(imageId) && this._images.size >= KittyImageStorage._maxStoredImages) {\n this._evictUndisplayedImages();\n }\n\n // Encoded images awaiting placement are outside ImageStorage's pixel budget.\n // Unplaced payloads are evicted first so a new upload cannot erase a visible\n // image while abandoned blobs still hold budget; placed ones go only when\n // that is not enough, because the byte cap is a hard bound. The new image is\n // always stored, so an oversized one overshoots by at most one payload\n // (itself bounded by kittySizeLimit) rather than being dropped after an OK ack.\n const byteLimit = this._storage.getLimit() * 1000000;\n this._images.delete(imageId);\n let retainedBytes = 0;\n for (const image of this._images.values()) retainedBytes += image.data.size;\n for (const evictPlaced of [false, true]) {\n for (const [oldestId, image] of this._images) {\n if (retainedBytes + imageData.data.size <= byteLimit) break;\n if (this._kittyIdToStorageId.has(oldestId) !== evictPlaced) continue;\n retainedBytes -= image.data.size;\n this.deleteById(oldestId);\n }\n }\n\n this._images.set(imageId, {\n ...imageData,\n id: imageId\n });\n return imageId;\n }\n\n public addImage(kittyId: number, image: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer, zIndex: number): void {\n // Clean up stale reverse-mapping from a previous placement of the same\n // kitty image. The old shared-storage entry is kept (it may still be\n // visible on screen) but its reverse mapping is removed so that eviction\n // of the old entry won't incorrectly delete the kitty image data.\n const oldStorageId = this._kittyIdToStorageId.get(kittyId);\n if (oldStorageId !== undefined) {\n this._storageIdToKittyId.delete(oldStorageId);\n }\n this._addImageOpts.scrolling = scrolling;\n this._addImageOpts.layer = layer;\n this._addImageOpts.zIndex = zIndex;\n const storageId = this._storage.addImage(image, this._addImageOpts);\n this._kittyIdToStorageId.set(kittyId, storageId);\n this._storageIdToKittyId.set(storageId, kittyId);\n }\n\n public getImage(kittyId: number): IKittyImageData | undefined {\n return this._images.get(kittyId);\n }\n\n public deleteById(kittyId: number): void {\n this._images.delete(kittyId);\n const storageId = this._kittyIdToStorageId.get(kittyId);\n if (storageId !== undefined) {\n this._storage.deleteImage(storageId);\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n }\n }\n\n public deleteAll(): void {\n this._images.clear();\n for (const storageId of this._kittyIdToStorageId.values()) {\n this._storage.deleteImage(storageId);\n }\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public get images(): ReadonlyMap {\n return this._images;\n }\n\n public get kittyIdToStorageId(): ReadonlyMap {\n return this._kittyIdToStorageId;\n }\n\n public get lastImageId(): number {\n return this._nextImageId - 1;\n }\n\n private _evictUndisplayedImages(): void {\n for (const [kittyId] of this._images) {\n if (this._images.size <= KittyImageStorage._maxStoredImages / 2) {\n break;\n }\n if (!this._kittyIdToStorageId.has(kittyId)) {\n this._images.delete(kittyId);\n }\n }\n }\n}\n","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023, 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\n/**\n * wasm base64 decoder.\n */\nconst wasmDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#828e69684093b2c6:rdef-start:\"decode\"*/{s:1,t:0,d:'AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML'}/*inwasm#828e69684093b2c6:rdef-end:\"decode\"*/);\n// SIMD version (speedup ~1.4x, not covered by tests yet)\n/*\nconst wasmDecode = InWasm({\n name: 'decode',\n type: OutputType.INSTANCE,\n mode: OutputMode.SYNC,\n srctype: 'Clang-C',\n imports: {\n env: { memory: new WebAssembly.Memory({ initial: 1 }) }\n },\n exports: {\n dec: () => 0,\n end: () => 0\n },\n compile: {\n switches: ['-msimd128', '-Wl,-z,stack-size=0', '-Wl,--stack-first']\n },\n code: `\n #include \n typedef struct {\n unsigned int wp;\n unsigned int sp;\n unsigned int dp;\n unsigned int e_size;\n unsigned int dummy[4];\n unsigned char data[0];\n } State;\n\n unsigned int *D0 = (unsigned int *) ${P32.D0 * 4};\n unsigned int *D1 = (unsigned int *) ${P32.D1 * 4};\n unsigned int *D2 = (unsigned int *) ${P32.D2 * 4};\n unsigned int *D3 = (unsigned int *) ${P32.D3 * 4};\n State *state = (State *) ${P32.STATE * 4};\n\n #define packed_byte(x) wasm_i8x16_splat((char) x)\n #define packed_dword(x) wasm_i32x4_splat(x)\n #define masked(x, mask) wasm_v128_and(x, wasm_i32x4_splat(mask))\n\n __attribute__((noinline)) int dec() {\n unsigned int nsp = (state->wp - 1) & ~3;\n unsigned char *src = state->data + state->sp;\n unsigned char *end = state->data + nsp;\n unsigned char *dst = state->data + state->dp;\n unsigned int error = 0;\n\n v128_t err = wasm_i8x16_splat(0);\n unsigned char *end16 = state->data + (nsp & ~15);\n while (src < end16) {\n v128_t data = wasm_v128_load((v128_t *) src);\n\n // wasm-simd rewrite of http://0x80.pl/notesen/2016-01-17-sse-base64-decoding.html#vector-lookup-pshufb\n const v128_t higher_nibble = wasm_u32x4_shr(data, 4) & packed_byte(0x0f);\n const char linv = 1;\n const char hinv = 0;\n\n const v128_t lower_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n linv, linv, 0x2b, 0x30,\n 0x41, 0x50, 0x61, 0x70,\n linv, linv, linv, linv,\n linv, linv, linv, linv\n );\n const v128_t upper_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n hinv, hinv, 0x2b, 0x39,\n 0x4f, 0x5a, 0x6f, 0x7a,\n hinv, hinv, hinv, hinv,\n hinv, hinv, hinv, hinv\n );\n // the difference between the shift and lower bound\n const v128_t shift_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n 0x00, 0x00, 0x3e - 0x2b, 0x34 - 0x30,\n 0x00 - 0x41, 0x0f - 0x50, 0x1a - 0x61, 0x29 - 0x70,\n 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00\n );\n\n const v128_t upper_bound = wasm_i8x16_swizzle(upper_bound_LUT, higher_nibble);\n const v128_t lower_bound = wasm_i8x16_swizzle(lower_bound_LUT, higher_nibble);\n\n const v128_t below = wasm_i8x16_lt(data, lower_bound);\n const v128_t above = wasm_i8x16_gt(data, upper_bound);\n const v128_t eq_2f = wasm_i8x16_eq(data, packed_byte(0x2f));\n\n // in_range = not (below or above) or eq_2f\n // outside = not in_range = below or above and not eq_2f (from deMorgan law)\n const v128_t outside = wasm_v128_andnot(eq_2f, above | below);\n err = wasm_v128_or(err, outside);\n\n const v128_t shift = wasm_i8x16_swizzle(shift_LUT, higher_nibble);\n const v128_t t0 = wasm_i8x16_add(data, shift);\n v128_t v = wasm_i8x16_add(t0, wasm_v128_and(eq_2f, packed_byte(-3)));\n\n // pack bytes\n const v128_t ca = masked(v, 0x003f003f);\n const v128_t db = masked(v, 0x3f003f00);\n const v128_t t00 = wasm_v128_or(wasm_u32x4_shr(db, 8), wasm_i32x4_shl(ca, 6));\n v128_t res = wasm_v128_or(wasm_u32x4_shr(t00, 16), wasm_i32x4_shl(t00, 12));\n res = wasm_i8x16_swizzle(res, wasm_i8x16_const(2, 1, 0, 6, 5, 4, 10, 9, 8, 14, 13, 12, 16, 16, 16, 16));\n\n wasm_v128_store((v128_t *) dst, res);\n dst += 12;\n src += 16;\n }\n //if (wasm_i8x16_bitmask(err) != 0) return -1;\n if (wasm_v128_any_true(err)) return -1;\n\n // operate on 4-byte blocks\n while (src < end) {\n error |= *((unsigned int *) dst) = D0[src[0]] | D1[src[1]] | D2[src[2]] | D3[src[3]];\n dst += 3;\n src += 4;\n }\n if (error >> 24) return -1;\n state->sp = nsp;\n state->dp = dst - state->data;\n return 0;\n }\n\n int end() {\n int rem = state->wp - state->sp;\n if (rem > 4 && dec()) return -1;\n rem = state->wp - state->sp;\n if (rem < 2) return -1;\n\n unsigned char *src = state->data + state->sp;\n if (rem == 4) {\n if (src[3] == 61) rem--;\n if (src[2] == 61) rem--;\n }\n unsigned int accu = D0[src[0]] | D1[src[1]];\n int dp = 1;\n if (rem > 2) {\n accu |= D2[src[2]];\n dp++;\n if (rem == 4) {\n accu |= D3[src[3]];\n dp++;\n }\n }\n if (accu >> 24) return -1;\n *((unsigned int *) (state->data + state->dp)) = accu;\n state->dp += dp;\n return 0;\n }\n `\n});\n*/\n// base64 map\nconst MAP = new Uint8Array('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'\n .split('')\n .map(el => el.charCodeAt(0)));\n// init decoder maps in LE order\nconst D = new Uint32Array(1024);\nD.fill(0xFF000000);\nfor (let i = 0; i < MAP.length; ++i)\n D[MAP[i]] = i << 2;\nfor (let i = 0; i < MAP.length; ++i)\n D[256 + MAP[i]] = i >> 4 | ((i << 4) & 0xFF) << 8;\nfor (let i = 0; i < MAP.length; ++i)\n D[512 + MAP[i]] = (i >> 2) << 8 | ((i << 6) & 0xFF) << 16;\nfor (let i = 0; i < MAP.length; ++i)\n D[768 + MAP[i]] = i << 16;\nconst EMPTY = new Uint8Array(0);\n/**\n * base64 stream decoder.\n *\n * Features / assumptions:\n * - lazy chunkwise decoding\n * - errors out on any non base64 chars (no support for NL formatted base64)\n * - decodes in wasm\n * - inplace decoding to save memory\n * - supports a keepSize for lazy memory release\n */\nclass Base64Decoder {\n /**\n * @param keepSize Keep the wasm instance below this limit when calling `release()`.\n * @param maxBytes Max allowed bytes to allocate.\n * @param initialBytes Initial bytes to allocate.\n */\n constructor(keepSize, maxBytes, initialBytes) {\n this._inst = null;\n this._ended = true;\n this._bytes = 0;\n this.keepSize = keepSize !== null && keepSize !== void 0 ? keepSize : 1048576 /* Bytes.KEEP */;\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : 4294901760 /* Bytes.MAX */;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : 32768 /* Bytes.INITIAL */;\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw new Error('invalid byte settings');\n }\n }\n /**\n * Currently decoded bytes (borrowed).\n * Must be accessed before calling `release` or `init`.\n */\n get data8() {\n return this._inst ? this._d.subarray(0, this._m32[1282 /* P32.STATE_DP */]) : EMPTY;\n }\n /**\n * Release memory conditionally based on `keepSize`.\n * If memory gets released, also the wasm instance will be freed and recreated on next `init`,\n * otherwise the instance will be reused.\n */\n release() {\n if (!this._inst)\n return;\n if (this._bytes > this.keepSize) {\n this._inst = this._m32 = this._d = this._mem = null;\n }\n else {\n this._m32[1280 /* P32.STATE_WP */] = 0;\n this._m32[1281 /* P32.STATE_SP */] = 0;\n this._m32[1282 /* P32.STATE_DP */] = 0;\n }\n }\n /**\n * Initializes the decoder for new base64 data.\n * Must be called before doing any decoding attempts.\n * The method will either spawn a new wasm instance or grow\n * the needed memory of an existing instance.\n * @param maxBytes Max allowed bytes to allocate (overwrites ctor value).\n * @param initialBytes Initial bytes to allocate (overwrites ctor value).\n */\n init(maxBytes, initialBytes) {\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : this.maxBytes;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : Math.min(this._bytes, this.maxBytes);\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw Error('invalid byte settings');\n }\n let m = this._m32;\n const bytes = this._bytes + 5152 /* Bytes._DATA_OFFSET */;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmDecode({ env: { memory: this._mem } });\n m = new Uint32Array(this._mem.buffer, 0);\n m.set(D, 256 /* P32.D0 */);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n m = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n m[1280 /* P32.STATE_WP */] = 0;\n m[1281 /* P32.STATE_SP */] = 0;\n m[1282 /* P32.STATE_DP */] = 0;\n this._m32 = m;\n this._ended = false;\n }\n /**\n * Realloc memory. Realloc only happens, if the requested\n * size doesn't fit in the current memory.\n * The new size will be capped by `maxBytes`.\n * @param requested Bytes to be stored.\n */\n _realloc(requested) {\n const needed = this._m32[1280 /* P32.STATE_WP */] + requested;\n if (this._bytes < needed) {\n if (needed > this.maxBytes) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n let newSize = this._bytes;\n while ((newSize *= 2) < needed) { }\n newSize = Math.min(newSize, this.maxBytes);\n if (newSize < needed) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n if (newSize + 5152 /* Bytes._DATA_OFFSET */ > this._mem.buffer.byteLength) {\n const addPages = Math.ceil((newSize + 5152 /* Bytes._DATA_OFFSET */ - this._mem.buffer.byteLength) / 65536);\n this._mem.grow(addPages);\n this._m32 = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n this._bytes = newSize;\n }\n return 0 /* DecodeStatus.OK */;\n }\n /**\n * Put bytes in `data` into the decoder.\n * Additionally decodes the payload, if it reached 2^17 bytes.\n * The return value indicates the type of issue.\n * @param data Bytes to be loaded.\n */\n put(data) {\n if (!this._inst || this._ended) {\n return -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n if (this._realloc(data.length)) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n const m = this._m32;\n this._d.set(data, m[1280 /* P32.STATE_WP */]);\n m[1280 /* P32.STATE_WP */] += data.length;\n // max chunk in input handler is 2^17, try to run in \"tandem mode\"\n return m[1280 /* P32.STATE_WP */] - m[1281 /* P32.STATE_SP */] >= 131072\n ? this._inst.exports.dec()\n : 0 /* DecodeStatus.OK */;\n }\n /**\n * End the current decoding.\n * Also decodes leftover payload from previous put calls.\n */\n end() {\n this._ended = true;\n return this._inst\n ? this._inst.exports.end()\n : -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n /**\n * Bytes loaded into the decoder.\n */\n get loadedBytes() {\n return this._inst\n ? this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n /**\n * Free bytes to feed to the decoder.\n */\n get freeBytes() {\n return this._inst\n ? this.maxBytes - this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n}\nexports.default = Base64Decoder;\n//# sourceMappingURL=Base64Decoder.wasm.js.map","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\nconst wasmQoiDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#459f9e1bfb80b1a8:rdef-start:\"qoi_decode\"*/{s:1,t:0,d:'AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=='}/*inwasm#459f9e1bfb80b1a8:rdef-end:\"qoi_decode\"*/);\nclass QoiDecoder {\n constructor(keepSize) {\n this.keepSize = keepSize;\n this.width = 0;\n this.height = 0;\n }\n decode(d) {\n this.width = d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7];\n this.height = d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11];\n const pixels = this.width * this.height;\n const ib = pixels * 4;\n const dl = d.length;\n /**\n * byte/offset calculation:\n * To save some memory we dont reserve full memory for decoded + encoded,\n * but place encoded at the end of decoded plus 50% security distance\n * to avoid reads before writes positions:\n *\n * encoded < decoded (good compression)\n * enc ####################\n * dec #######################################\n * ^ ^\n * DST_P CHUNK_P\n *\n * encoded > decoded (degenerated compression, should not happen)\n * enc ##############################\n * dec ####################\n * ^ ^\n * DST_P CHUNK_P\n *\n * There is still a chance for overlapping r/w positions in case the compressed\n * data has very different pixel progression, yet the 50% security distance\n * should deal with that, as QOI will bloat data by 25% at max (RGB -> OP byte + RGB).\n * Since we always assume RGBA at decoding stage, the possible bloat reduces to 20% at max.\n */\n const bytes = Math.max(ib, dl) + (Math.min(ib, dl) >> 1) + 4096;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmQoiDecode({ env: { memory: this._mem } });\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n this._d = null;\n }\n if (!this._d) {\n this._d = new Uint8Array(this._mem.buffer);\n }\n // put src data at the end of memory, also align to 256\n const chunkP = (this._mem.buffer.byteLength - dl) & ~0xFF;\n this._d.set(d, chunkP);\n this._inst.exports.dec(chunkP, dl, pixels);\n return this._d.subarray(1024 /* P8.DST_P */, 1024 /* P8.DST_P */ + ib);\n }\n release() {\n if (!this._inst)\n return;\n if (this._mem.buffer.byteLength > this.keepSize) {\n this._inst = this._d = this._mem = null;\n }\n }\n}\nexports.default = QoiDecoder;\n//# sourceMappingURL=QoiDecoder.wasm.js.map","\"use strict\";\n/**\n * Copyright (c) 2022, 2026 Joerg Breitbart\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.InWasm = InWasm;\nlet z = (s) => {\n if (Uint8Array.fromBase64)\n return Uint8Array.fromBase64(s);\n if (typeof Buffer !== 'undefined')\n return Buffer.from(s, 'base64');\n const b = atob(s);\n const r = new Uint8Array(b.length);\n for (let i = 0; i < r.length; ++i)\n r[i] = b.charCodeAt(i);\n return r;\n};\nfunction InWasm(def) {\n if (def.d) {\n const { t, s, d } = def;\n let b;\n let m;\n const W = WebAssembly;\n if (t === 0 /* OutputType.INSTANCE */) {\n if (s)\n return (e) => new W.Instance(m || (m = new W.Module(b || (b = z(d)))), e);\n return (e) => m\n ? W.instantiate(m, e)\n : W.instantiate(b || (b = z(d)), e).then(r => (m = r.module) && r.instance);\n }\n if (t === 1 /* OutputType.MODULE */) {\n if (s)\n return () => m || (m = new W.Module(b || (b = z(d))));\n return () => m\n ? Promise.resolve(m)\n : W.compile(b || (b = z(d))).then(r => m = r);\n }\n if (s)\n return () => b || (b = z(d));\n return () => Promise.resolve(b || (b = z(d)));\n }\n if (typeof _wasmCtx === 'undefined')\n throw new Error('must run \"inwasm\"');\n _wasmCtx.add(def);\n}\n//# sourceMappingURL=index.js.map","// The module cache\nvar __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\t// Check if module is in cache\n\tvar cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tvar module = __webpack_module_cache__[moduleId] = {\n\t\t// no module.id needed\n\t\t// no module.loaded needed\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ITerminalAddon, IDisposable } from '@xterm/xterm';\nimport type { ImageAddon as IImageApi } from '@xterm/addon-image';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { IIPHandler } from './IIPHandler';\nimport { ImageRenderer } from './ImageRenderer';\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';\nimport { KittyImageStorage } from './kitty/KittyImageStorage';\nimport { SixelHandler } from './SixelHandler';\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';\n\n\n/**\n * Document VT features provided by this addon.\n *\n * @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL \"SIXEL Graphics\" \"DCS Ps ; Ps ; Ps ; q Pt ST\" \"Draw SIXEL image.\"\n *\n * Sixel support is provided by the addon @xterm/addon-image with these limitations:\n * - immediate coloring (no shared palette, allows high color settings of `img2sixel`)\n * - max. palette size of 4096 colors\n * - max. pixel width of 16K\n * - max. 25 MB per sixel sequence\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] OSC 1337 \"iTerm2 Commands\" \"OSC 1337 ; Pt BEL\" \"Custom iTerm2 commands.\"\n *\n * Only the inline image protocol (IIP) is supported by the addon @xterm/addon-image with\n * the following limitations:\n * - sequence:\n * - format: `OSC 1337 ; File=inline=1 ; size= ; ... : BEL`\n * - size param must be set and payload may not exceed CEIL(size * 4 / 3)\n * - strict base64 handling as of RFC4648 §4 (standard alphabet, optional padding,\n * no separator bytes allowed)\n * - supported params: size, name, width, height, preserveAspectRatio\n * - image formats: PNG, JPEG and GIF\n * - no animation support (renders first image of a GIF)\n * - no multipart support\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image)\n * and [iTerm2 IIP docs](https://iterm2.com/documentation-images.html) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] APC KITTY_GRAPHICS \"Kitty Graphics\" \"APC G Pt ST\" \"Kitty Graphics Protocol.\"\n *\n * Kitty graphics support is provided by the addon @xterm/addon-image.\n * Note that while basic image output already works, this is still work in progress.\n */\n\n// default values of addon ctor options\nconst DEFAULT_OPTIONS: IImageAddonOptions = {\n enableSizeReports: true,\n pixelLimit: 16777216, // limit to 4096 * 4096 pixels\n sixelSupport: true,\n sixelScrolling: true,\n sixelPaletteLimit: 4096,\n sixelSizeLimit: 33554432,\n storageLimit: 128,\n showPlaceholder: true,\n iipSupport: true,\n iipSizeLimit: 33554432,\n kittySupport: true,\n kittySizeLimit: 33554432\n};\n\n// max palette size supported by the sixel lib (compile time setting)\nconst MAX_SIXEL_PALETTE_SIZE = 4096;\n\n// definitions for _xtermGraphicsAttributes sequence\nconst enum GaItem {\n COLORS = 1,\n SIXEL_GEO = 2,\n REGIS_GEO = 3\n}\nconst enum GaAction {\n READ = 1,\n SET_DEFAULT = 2,\n SET = 3,\n READ_MAX = 4\n}\nconst enum GaStatus {\n SUCCESS = 0,\n ITEM_ERROR = 1,\n ACTION_ERROR = 2,\n FAILURE = 3\n}\n\n\nexport class ImageAddon implements ITerminalAddon, IImageApi {\n private _opts: IImageAddonOptions;\n private _defaultOpts: IImageAddonOptions;\n private _storage: ImageStorage | undefined;\n private _renderer: ImageRenderer | undefined;\n private _disposables: IDisposable[] = [];\n private _terminal: ITerminalExt | undefined;\n private _handlers: Map = new Map();\n private readonly _onImageAdded = new Emitter();\n public readonly onImageAdded: IEvent = this._onImageAdded.event;\n\n constructor(opts?: Partial) {\n this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);\n this._defaultOpts = Object.assign({}, DEFAULT_OPTIONS, opts);\n }\n\n public dispose(): void {\n for (const handler of this._handlers.values()) handler.reset();\n for (const obj of this._disposables) {\n obj.dispose();\n }\n this._disposables.length = 0;\n this._handlers.clear();\n this._onImageAdded.dispose();\n }\n\n private _disposeLater(...args: IDisposable[]): void {\n for (const obj of args) {\n this._disposables.push(obj);\n }\n }\n\n public activate(terminal: ITerminalExt): void {\n this._terminal = terminal;\n\n // internal data structures\n this._renderer = new ImageRenderer(terminal);\n this._storage = new ImageStorage(terminal, this._renderer, this._opts);\n this._storage.onImageAdded = () => this._onImageAdded.fire();\n\n // enable size reports\n if (this._opts.enableSizeReports) {\n const windowOps = terminal.options.windowOptions ?? {};\n windowOps.getWinSizePixels = true;\n windowOps.getCellSizePixels = true;\n windowOps.getWinSizeChars = true;\n terminal.options.windowOptions = windowOps;\n }\n\n this._disposeLater(\n this._renderer,\n this._storage,\n\n // DECSET/DECRST/DA1/XTSMGRAPHICS handlers\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'h' }, params => this._decset(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'l' }, params => this._decrst(params)),\n terminal.parser.registerCsiHandler({ final: 'c' }, params => this._da1(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'S' }, params => this._xtermGraphicsAttributes(params)),\n\n // render hook\n terminal.onRender(range => this._storage?.render(range)),\n\n /**\n * reset handlers covered:\n * - DECSTR\n * - RIS\n * - Terminal.reset()\n */\n terminal.parser.registerCsiHandler({ intermediates: '!', final: 'p' }, () => this.reset()),\n terminal.parser.registerEscHandler({ final: 'c' }, () => this.reset()),\n terminal._core._inputHandler.onRequestReset(() => this.reset()),\n\n // wipe canvas and delete alternate images on buffer switch\n terminal.buffer.onBufferChange(() => this._storage?.wipeAlternate()),\n\n // extend images to the right on resize\n terminal.onResize(metrics => this._storage?.viewportResize(metrics))\n );\n\n // SIXEL handler\n if (this._opts.sixelSupport) {\n const sixelStorage = new SixelImageStorage(this._storage!, this._opts, this._renderer!, terminal);\n const sixelHandler = new SixelHandler(this._opts, sixelStorage, terminal);\n this._handlers.set('sixel', sixelHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler)\n );\n }\n\n // iTerm IIP handler\n if (this._opts.iipSupport) {\n const iipStorage = new IIPImageStorage(this._storage!);\n const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, terminal);\n this._handlers.set('iip', iipHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)\n );\n }\n\n // Kitty graphics handler\n if (this._opts.kittySupport) {\n const kittyStorage = new KittyImageStorage(this._storage!);\n const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, kittyStorage, terminal);\n this._handlers.set('kitty', kittyHandler);\n this._disposeLater(\n kittyStorage,\n kittyHandler,\n terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)\n );\n }\n }\n\n // Note: storageLimit is skipped here to not intoduce a surprising side effect.\n public reset(): boolean {\n // reset options customizable by sequences to defaults\n this._opts.sixelScrolling = this._defaultOpts.sixelScrolling;\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n // also clear image storage\n this._storage?.reset();\n // reset protocol handlers\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return false;\n }\n\n public get storageLimit(): number {\n return this._storage?.getLimit() || -1;\n }\n\n public set storageLimit(limit: number) {\n this._storage?.setLimit(limit);\n this._opts.storageLimit = limit;\n }\n\n public get storageUsage(): number {\n if (this._storage) {\n return this._storage.getUsage();\n }\n return -1;\n }\n\n public get showPlaceholder(): boolean {\n return this._opts.showPlaceholder;\n }\n\n public set showPlaceholder(value: boolean) {\n this._opts.showPlaceholder = value;\n this._renderer?.showPlaceholder(value);\n }\n\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.getImageAtBufferCell(x, y);\n }\n\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.extractTileAtBufferCell(x, y);\n }\n\n private _report(s: string): void {\n this._terminal?._core.input(s, false);\n }\n\n private _decset(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = false;\n break;\n }\n }\n return false;\n }\n\n private _decrst(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = true;\n break;\n }\n }\n return false;\n }\n\n // overload DA to return something more appropriate\n private _da1(params: (number | number[])[]): boolean {\n if (params[0]) {\n return true;\n }\n // reported features:\n // 62 - VT220\n // 4 - SIXEL support\n // 9 - charsets\n // 22 - ANSI colors\n if (this._opts.sixelSupport) {\n this._report(`\\x1b[?62;4;9;22c`);\n return true;\n }\n return false;\n }\n\n /**\n * Implementation of xterm's graphics attribute sequence.\n *\n * Supported features:\n * - read/change palette limits (max 4096 by sixel lib)\n * - read SIXEL canvas geometry (reports current window canvas or\n * squared pixelLimit if canvas > pixel limit)\n *\n * Everything else is deactivated.\n */\n private _xtermGraphicsAttributes(params: (number | number[])[]): boolean {\n if (params.length < 2) {\n return true;\n }\n if (params[0] === GaItem.COLORS) {\n switch (params[1]) {\n case GaAction.READ:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n return true;\n case GaAction.SET_DEFAULT:\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n // also reset protocol handlers for now\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return true;\n case GaAction.SET:\n if (params.length > 2 && !(params[2] instanceof Array) && params[2] <= MAX_SIXEL_PALETTE_SIZE) {\n this._opts.sixelPaletteLimit = params[2];\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n } else {\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n }\n return true;\n case GaAction.READ_MAX:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${MAX_SIXEL_PALETTE_SIZE}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n if (params[0] === GaItem.SIXEL_GEO) {\n switch (params[1]) {\n // we only implement read and read_max here\n case GaAction.READ:\n let width = this._renderer?.dimensions?.css.canvas.width;\n let height = this._renderer?.dimensions?.css.canvas.height;\n if (!width || !height) {\n // for some reason we have no working image renderer\n // --> fallback to default cell size\n const cellSize = CELL_SIZE_DEFAULT;\n width = (this._terminal?.cols || 80) * cellSize.width;\n height = (this._terminal?.rows || 24) * cellSize.height;\n }\n if (width * height < this._opts.pixelLimit) {\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${width.toFixed(0)};${height.toFixed(0)}S`);\n } else {\n // if we overflow pixelLimit report that squared instead\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n }\n return true;\n case GaAction.READ_MAX:\n // read_max returns pixelLimit as square area\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n // exit with error on ReGIS or any other requests\n this._report(`\\x1b[?${params[0]};${GaStatus.ITEM_ERROR}S`);\n return true;\n }\n}\n"],"names":["root","factory","exports","module","define","amd","globalThis","red","n","green","blue","toRGBA8888","r","g","b","a","clamp","low","high","value","Math","max","min","h2c","t1","t2","c","normalizeRGB","round","Object","defineProperty","DEFAULT_FOREGROUND","DEFAULT_BACKGROUND","PALETTE_ANSI_256","PALETTE_VT340_GREY","PALETTE_VT340_COLOR","normalizeHLS","nearestColorIndex","fromRGBA8888","alpha","BIG_ENDIAN","Uint8Array","Uint32Array","buffer","console","warn","color","palette","Number","MAX_SAFE_INTEGER","idx","i","length","dr","dg","db","d","h","l","s","v","HLStoRGB","p","push","decodeAsync","decode","Decoder","DecoderAsync","Colors_1","__webpack_require__","wasm_1","WASM_BYTES","Buffer","from","bytestring","atob","result","charCodeAt","decodeBase64","LIMITS","BYTES","WASM_MODULE","NULL_CANVAS","CallbackProxy","constructor","this","bandHandler","width","modeHandler","mode","handle_band","mode_parsed","DEFAULT_OPTIONS","memoryLimit","sixelColor","fillColor","paletteLimit","PALETTE_SIZE","truncate","opts","cbProxy","importObj","env","bind","WebAssembly","instantiate","then","inst","instance","_instance","_cbProxy","_PIXEL_OFFSET","MAX_WIDTH","_canvas","_bandWidths","_maxWidth","_minWidth","_lastOffset","_currentHeight","_opts","assign","Error","_handle_band","_initCanvas","Module","Instance","_wasm","_chunk","memory","get_chunk_address","CHUNK_SIZE","_states","get_state_address","_palette","get_palette_address","set","_pSrc","get_p0_address","init","_fillColor","_truncate","_rasterWidth","_rasterHeight","_width","_height","_level","_mode","_paletteLimit","pixels","height","release","_realloc","offset","additionalPixels","newCanvas","ceil","adv","remaining","subarray","current_width","current_height","memoryUsage","byteLength","properties","level","memUsage","rasterAttributes","numerator","denominator","data","start","end","decodeString","j","data32","currentWidth","fill","escape","final","finalOffset","bw","currentHeight","data8","Uint8ClampedArray","dec","async","Lifecycle_1","EventUtils","_listeners","_disposed","event","_event","listener","thisArgs","disposables","toDisposable","entry","fn","slice","indexOf","splice","Array","isArray","add","fire","call","listeners","len","dispose","forward","to","e","map","undefined","any","events","store","DisposableStore","runAndSubscribe","handler","initial","arg","_disposables","Set","_isDisposed","isDisposed","o","clear","Disposable","_store","_register","None","freeze","_value","ImageRenderer_1","ImageStorage_1","Base64Decoder_wasm_1","__importDefault","QoiDecoder_wasm_1","IIPHeaderParser_1","IIPMetrics_1","DEFAULT_HEADER","type","name","size","preserveAspectRatio","inline","_renderer","_storage","_coreTerminal","_generation","_aborted","_hp","HeaderParser","_header","_isMultipart","_abortMulti","maxEncodedBytes","iipSizeLimit","initialBytes","_dec","default","_qoiDec","reset","put","state","dataPos","parse","fields","success","seqType","w","CELL_SIZE_DEFAULT","dimensions","css","canvas","cols","rows","scale","_c","_a","_core","_coreBrowserService","dpr","report","toFixed","input","cond","blob","metrics","UNSUPPORTED_TYPE","imageType","mime","pixelLimit","_resize","floor","ImageData","byteOffset","ImageRenderer","createCanvas","_b","getContext","putImageData","addImage","Blob","generation","createImageBitmap","resizeWidth","resizeHeight","bm","close","catch","cw","cell","ch","_d","rw","_dim","rh","wf","hf","f","total","cdim","endsWith","parseInt","toStr","String","fromCharCode","toInt","toSize","match","DECODERS","toString","bs","TextDecoder","FILE_MARKER","MULTIPARTFILE_MARKER","FILEPART_MARKER","FILEEND_MARKER","REPORTCELLSIZE_MARKER","MAX_FIELDCHARS","_buffer","_position","_key","k","_storeValue","pos","_storeKey","_addImageOpts","scrolling","layer","zIndex","cursorPos","img","d32","blockLength","jpgSize","dim","limit","_layers","get","localDocument","document","createElement","createImageData","ctx","imgData","Promise","resolve","_terminal","super","Map","_optionsRefresh","MutableDisposable","_oldOpen","open","parent","_open","screenElement","optionsService","onOptionChange","option","rescaleCanvas","_renderService","refreshRows","removeLayerFromDom","_oldSetRenderer","setRenderer","_placeholderBitmap","_placeholder","showPlaceholder","cellSize","_createPlaceHolder","clearLines","y","clearRect","_e","clearAll","draw","imgSpec","tileId","col","row","count","_rescaleImage","actual","sourceWidth","sourceHeight","actualCellSize","sx","sy","dx","dy","finalWidth","finalHeight","drawImage","extractTile","drawPlaceholder","values","spec","originalWidth","originalHeight","origCellSize","orig","scaledWidth","scaledHeight","renderer","key","keys","insertLayerToDom","has","classList","style","isolation","insertBefore","firstChild","appendChild","remove","delete","hasLayer","bWidth","blueprint","black","white","shift","x","screen","ctx2","placeholder","bitmap","window","ExtendedAttrsImage","ext","_urlId","_ext","underlineStyle","underlineColor","underlineVariantOffset","val","urlId","imageId","clone","isEmpty","EMPTY_ATTRS","_images","_lastId","_lowestId","_fullyCleared","_needsFullClear","_pixelLimit","setLimit","storageLimit","error","message","getLimit","_viewportMetrics","marker","RangeError","_evictOldest","getUsage","_getStoredPixels","storedPixels","_delImg","id","ImageBitmap","onImageDeleted","wipeAlternate","zero","entries","bufferType","deleteImage","termCols","termRows","originX","originY","tileCount","_inputHandler","_dirtyRowTracker","markDirty","line","lines","ybase","_writeToCell","lineFeed","endMarker","registerMarker","onDispose","active","_evictOnAlternate","onImageAdded","render","range","hasTopImages","hasBottomImages","drawCalls","placeholderCalls","ydisp","getBg","_extendedAttrs","startTile","startCol","sort","viewportResize","oldCol","tilesPerRow","hasData","rightCol","_data","lastTile","expandCol","getImageAtBufferCell","extractTileAtBufferCell","room","used","current","old","oldSpec","imgId","Decoder_1","DEFAULT_PALETTE","convertLe","_size","sixelPaletteLimit","hook","params","attr","colors","bg","isInverse","isFgDefault","foreground","rgba","isFgRGB","t","toColorRGB","getFgColor","ansi","isBgDefault","background","isBgRGB","getBgColor","extractActiveBg","_curAttrData","_themeService","sixelSizeLimit","unhook","advanceCursor","sixelScrolling","KittyGraphicsTypes_1","_kittyStorage","_decodeError","_activeDecoder","_inControlData","_controlData","_controlLength","_encodedSizeLimit","_totalEncodedSize","_parsedCommand","_pendingTransmissions","_maxEncodedBytes","kittySizeLimit","_initialEncodedBytes","_cleanupAllPending","_removePendingEntry","_lastPendingKey","pending","decoder","controlEnd","copyLength","parseKittyCommand","_parseControlDataString","imageNumber","_sendResponse","quiet","action","payloadStart","_streamPayload","pendingKey","previousEncodedSize","totalEncodedSize","decoderToRelease","_f","decoderCapacity","_g","maxPending","oldest","next","cmd","_h","_handleNoPayloadCommand","_handleDelete","isMoreComing","more","decodeError","finalCmd","imageBytes","_handleCommandWithBytesAndCmd","str","fromCodePoint","_handlePlacement","bytes","_handleTransmit","transmission","_handleTransmitDisplay","_handleQuery","image","getImage","_displayImage","placementId","storeImage","format","compression","lastImageId","expectedBytes","deleteSelector","deleteAll","deleteById","isOk","response","coreService","triggerDataEvent","_decodeAndDisplay","_createBitmap","cropX","cropY","cropW","cropH","maxCropW","maxCropH","finalCropW","finalCropH","cropped","imgCols","imgRows","columns","savedX","savedY","savedYbase","scaled","xOffset","yOffset","canvasW","canvasH","offsetCanvas","offsetCtx","offsetBitmap","cursorMovement","scrolled","arrayBuffer","_decompressZlib","url","URL","createObjectURL","Image","reject","addEventListener","revokeObjectURL","src","pixelCount","src32","dst32","alignedPixels","srcOffset","dstOffset","b0","b1","b2","srcByte","dstByte","compressed","_decompress","offsetIn","reader","ReadableStream","pull","controller","enqueue","pipeThrough","DecompressionStream","getReader","chunks","totalLength","done","read","cancel","releaseLock","chunk","images","_kittyIdToStorageId","kittyIdToStorageId","pendingTransmissions","parts","split","part","eqIdx","substring","numValue","KittyImageStorage","_nextImageId","_storageIdToKittyId","_handleStorageImageDeleted","storageId","kittyId","_previousOnImageDeleted","_wrappedOnImageDeleted","imageData","oldStorageId","_maxStoredImages","_evictUndisplayedImages","byteLimit","retainedBytes","evictPlaced","oldestId","wasmDecode","InWasm","MAP","el","D","EMPTY","keepSize","maxBytes","_inst","_ended","_bytes","_m32","_mem","m","grow","Memory","requested","needed","newSize","addPages","loadedBytes","freeBytes","wasmQoiDecode","ib","dl","chunkP","def","W","z","compile","_wasmCtx","fromBase64","__webpack_module_cache__","moduleId","cachedModule","__webpack_modules__","Event_1","IIPHandler_1","KittyGraphicsHandler_1","KittyImageStorage_1","SixelHandler_1","SixelImageStorage_1","IIPImageStorage_1","enableSizeReports","sixelSupport","iipSupport","kittySupport","_handlers","_onImageAdded","Emitter","_defaultOpts","obj","_disposeLater","args","activate","terminal","ImageStorage","windowOps","options","windowOptions","getWinSizePixels","getCellSizePixels","getWinSizeChars","parser","registerCsiHandler","prefix","_decset","_decrst","_da1","_xtermGraphicsAttributes","onRender","intermediates","registerEscHandler","onRequestReset","onBufferChange","onResize","sixelStorage","SixelImageStorage","sixelHandler","SixelHandler","_parser","registerDcsHandler","iipStorage","IIPImageStorage","iipHandler","IIPHandler","registerOscHandler","kittyStorage","kittyHandler","KittyGraphicsHandler","registerApcHandler","storageUsage","_report","sqrt"],"sourceRoot":""} ++{"version":3,"file":"addon-image.js","mappings":";CAAA,SAAAA,EAAAC,GACA,iBAAAC,SAAA,iBAAAC,OACAA,OAAAD,QAAAD,IACA,mBAAAG,QAAAA,OAAAC,IACAD,OAAA,GAAAH,GACA,iBAAAC,QACAA,QAAA,WAAAD,IAEAD,EAAA,WAAAC,GACC,CATD,CASCK,WAAA,uCCKD,SAAAC,EAAAC,GACA,WAAAA,CACA,CAEA,SAAAC,EAAAD,GACA,OAAAA,IAAA,KACA,CAEA,SAAAE,EAAAF,GACA,OAAAA,IAAA,MACA,CASA,SAAAG,EAAAC,EAAAC,EAAAC,EAAAC,EAAA,KACA,YAAAA,IAAA,QAAAD,IAAA,QAAAD,IAAA,MAAAD,KAAA,CACA,CAqCA,SAAAI,EAAAC,EAAAC,EAAAC,GACA,OAAAC,KAAAC,IAAAJ,EAAAG,KAAAE,IAAAH,EAAAD,GACA,CACA,SAAAK,EAAAC,EAAAC,EAAAC,GAKA,OAJAA,EAAA,IACAA,GAAA,GACAA,EAAA,IACAA,GAAA,GACA,EAAAA,EAAA,EACAD,EAAA,GAAAD,EAAAC,GAAAC,EACA,EAAAA,EAAA,EACAF,EACA,EAAAE,EAAA,EACAD,GAAAD,EAAAC,IAAA,IAAAC,GACAD,CACA,CAaA,SAAAE,EAAAf,EAAAC,EAAAC,GACA,kBAAAM,KAAAQ,MAAAd,EAAA,aAAAM,KAAAQ,MAAAf,EAAA,YAAAO,KAAAQ,MAAAhB,EAAA,aACA,CAjGAiB,OAAAC,eAAA5B,EAAA,cAA+CiB,OAAA,IAC/CjB,EAAA6B,mBAA6B7B,EAAA8B,mBAA6B9B,EAAA+B,iBAA2B/B,EAAAgC,mBAA6BhC,EAAAiC,oBAA8BjC,EAAAkC,aAAuBlC,EAAAyB,aAAuBzB,EAAAmC,kBAA4BnC,EAAAoC,aAAuBpC,EAAAS,WAAqBT,EAAAqC,MAAgBrC,EAAAQ,KAAeR,EAAAO,MAAgBP,EAAAK,IAAcL,EAAAsC,gBAAkB,EAGrVtC,EAAAsC,WAAkB,UAAAC,WAAA,IAAAC,YAAA,cAAAC,QAAA,GAClBzC,EAAAsC,YACAI,QAAAC,KAAA,+EAMA3C,EAAAK,IAAWA,EAIXL,EAAAO,MAAaA,EAIbP,EAAAQ,KAAYA,EAIZR,EAAAqC,MAHA,SAAA/B,GACA,OAAAA,IAAA,MACA,EAQAN,EAAAS,WAAkBA,EAOlBT,EAAAoC,aAHA,SAAAQ,GACA,WAAAA,EAAAA,GAAA,MAAAA,GAAA,OAAAA,IAAA,GACA,EA2BA5C,EAAAmC,kBArBA,SAAAS,EAAAC,GACA,MAAAnC,EAAAL,EAAAuC,GACAjC,EAAAJ,EAAAqC,GACAhC,EAAAJ,EAAAoC,GACA,IAAAxB,EAAA0B,OAAAC,iBACAC,GAAA,EAEA,QAAAC,EAAA,EAAoBA,EAAAJ,EAAAK,SAAoBD,EAAA,CACxC,MAAAE,EAAAzC,EAAAmC,EAAAI,GAAA,GACAG,EAAAzC,EAAAkC,EAAAI,GAAA,GACAI,EAAAzC,EAAAiC,EAAAI,GAAA,GACAK,EAAAH,EAAAA,EAAAC,EAAAA,EAAAC,EAAAA,EACA,IAAAC,EACA,OAAAL,EACAK,EAAAlC,IACAA,EAAAkC,EACAN,EAAAC,EAEA,CACA,OAAAD,CACA,EAmCAhD,EAAAyB,aAAoBA,EAQpBzB,EAAAkC,aAJA,SAAAqB,EAAAC,EAAAC,GAEA,OArBA,SAAAF,EAAAC,EAAAC,GACA,IAAAA,EAAA,CACA,MAAAC,EAAAxC,KAAAQ,MAAA,IAAA8B,GACA,OAAA/C,EAAAiD,EAAAA,EAAAA,EACA,CACA,MAAApC,EAAAkC,EAAA,GAAAA,GAAA,EAAAC,GAAAD,EAAAC,EAAAD,EAAAC,EACAlC,EAAA,EAAAiC,EAAAlC,EACA,OAAAb,EAAAK,EAAA,MAAAI,KAAAQ,MAAA,IAAAL,EAAAC,EAAAC,EAAAgC,EAAA,OAAAzC,EAAA,MAAAI,KAAAQ,MAAA,IAAAL,EAAAC,EAAAC,EAAAgC,KAAAzC,EAAA,MAAAI,KAAAQ,MAAA,IAAAL,EAAAC,EAAAC,EAAAgC,EAAA,OACA,CAaAI,EAAAJ,EAAA,SAAAC,EAAA,IAAAC,EAAA,IACA,EA+BAzD,EAAAiC,oBAA2B,IAAAO,YAAA,CAC3Bf,EAAA,OACAA,EAAA,UACAA,EAAA,UACAA,EAAA,UACAA,EAAA,UACAA,EAAA,UACAA,EAAA,UACAA,EAAA,UACAA,EAAA,UACAA,EAAA,UACAA,EAAA,UACAA,EAAA,UACAA,EAAA,UACAA,EAAA,UACAA,EAAA,UACAA,EAAA,YA0BAzB,EAAAgC,mBAA0B,IAAAQ,YAAA,CAC1Bf,EAAA,OACAA,EAAA,UACAA,EAAA,UACAA,EAAA,UACAA,EAAA,OACAA,EAAA,UACAA,EAAA,UACAA,EAAA,UACAA,EAAA,OACAA,EAAA,UACAA,EAAA,UACAA,EAAA,UACAA,EAAA,OACAA,EAAA,UACAA,EAAA,UACAA,EAAA,YAOAzB,EAAA+B,iBAAwB,MAExB,MAAA6B,EAAA,CACAnD,EAAA,OACAA,EAAA,SACAA,EAAA,SACAA,EAAA,WACAA,EAAA,SACAA,EAAA,WACAA,EAAA,WACAA,EAAA,aACAA,EAAA,aACAA,EAAA,SACAA,EAAA,SACAA,EAAA,WACAA,EAAA,WACAA,EAAA,WACAA,EAAA,WACAA,EAAA,cAGA6C,EAAA,uBACA,QAAA5C,EAAA,EAAoBA,EAAA,IAAOA,EAC3B,QAAAC,EAAA,EAAwBA,EAAA,IAAOA,EAC/B,QAAAC,EAAA,EAA4BA,EAAA,IAAOA,EACnCgD,EAAAC,KAAApD,EAAA6C,EAAA5C,GAAA4C,EAAA3C,GAAA2C,EAAA1C,KAKA,QAAA8C,EAAA,EAAoBA,GAAA,IAAUA,GAAA,GAC9BE,EAAAC,KAAApD,EAAAiD,EAAAA,EAAAA,IAEA,WAAAlB,YAAAoB,EACC,EAlCuB,GA0CxB5D,EAAA8B,mBAA0BrB,EAAA,WAC1BT,EAAA6B,mBAA0BpB,EAAA,6BCpP1BkB,OAAAC,eAAA5B,EAAA,cAA+CiB,OAAA,IAC/CjB,EAAA8D,YAAsB9D,EAAA+D,OAAiB/D,EAAAgE,QAAkBhE,EAAAiE,kBAAoB,EAC7E,MAAAC,EAAiBC,EAAQ,KACzBC,EAAeD,EAAQ,KAavBE,EAXA,SAAAZ,GACA,uBAAAa,OACA,OAAAA,OAAAC,KAAAd,EAAA,UAEA,MAAAe,EAAAC,KAAAhB,GACAiB,EAAA,IAAAnC,WAAAiC,EAAAtB,QACA,QAAAD,EAAA,EAAoBA,EAAAyB,EAAAxB,SAAmBD,EACvCyB,EAAAzB,GAAAuB,EAAAG,WAAA1B,GAEA,OAAAyB,CACA,CACAE,CAAAR,EAAAS,OAAAC,OACA,IAAAC,EAEA,MAAAC,EAAA,IAAAxC,YAEA,MAAAyC,EACA,WAAAC,GACAC,KAAAC,YAAAC,GAAA,EACAF,KAAAG,YAAAC,GAAA,CACA,CACA,WAAAC,CAAAH,GACA,OAAAF,KAAAC,YAAAC,EACA,CACA,WAAAI,CAAAF,GACA,OAAAJ,KAAAG,YAAAC,EACA,EAGA,MAAAG,EAAA,CACAC,YAAA,UACAC,WAAA1B,EAAArC,mBACAgE,UAAA3B,EAAApC,mBACAe,QAAAqB,EAAAjC,oBACA6D,aAAA1B,EAAAS,OAAAkB,aACAC,UAAA,GAMA,SAAA/B,EAAAgC,GACA,MAAAC,EAAA,IAAAjB,EACAkB,EAAA,CACAC,IAAA,CACAZ,YAAAU,EAAAV,YAAAa,KAAAH,GACAT,YAAAS,EAAAT,YAAAY,KAAAH,KAGA,OAAAI,YAAAC,YAAAxB,GAAAV,EAAA8B,GACAK,KAAAC,IACA1B,EAAAA,GAAA0B,EAAAxG,OACA,IAAA+D,EAAAiC,EAAAQ,EAAAC,UAAAD,EAAAP,IAEA,CACAlG,EAAAiE,aAAoBA,EAiCpB,MAAAD,EAKA,WAAAkB,CAAAe,EAAAU,EAAAC,GASA,GARAzB,KAAA0B,cAAAzC,EAAAS,OAAAiC,UAAA,EACA3B,KAAA4B,QAAA/B,EACAG,KAAA6B,YAAA,GACA7B,KAAA8B,UAAA,EACA9B,KAAA+B,UAAA9C,EAAAS,OAAAiC,UACA3B,KAAAgC,YAAA,EACAhC,KAAAiC,eAAA,EACAjC,KAAAkC,MAAA1F,OAAA2F,OAAA,GAAqC5B,EAAAO,GACrCd,KAAAkC,MAAAvB,aAAA1B,EAAAS,OAAAkB,aACA,UAAAwB,MAAA,+CAA2EnD,EAAAS,OAAAkB,gBAE3E,GAAAY,EAUAC,EAAAxB,YAAAD,KAAAqC,aAAAnB,KAAAlB,MACAyB,EAAAtB,YAAAH,KAAAsC,YAAApB,KAAAlB,UAXA,CACA,MAAAlF,EAAA8E,IAAAA,EAAA,IAAAuB,YAAAoB,OAAArD,IACAsC,EAAA,IAAAL,YAAAqB,SAAA1H,EAAA,CACAmG,IAAA,CACAZ,YAAAL,KAAAqC,aAAAnB,KAAAlB,MACAM,YAAAN,KAAAsC,YAAApB,KAAAlB,QAGA,CAKAA,KAAAwB,UAAAA,EACAxB,KAAAyC,MAAAzC,KAAAwB,UAAA3G,QACAmF,KAAA0C,OAAA,IAAAtF,WAAA4C,KAAAyC,MAAAE,OAAArF,OAAA0C,KAAAyC,MAAAG,oBAAA3D,EAAAS,OAAAmD,YACA7C,KAAA8C,QAAA,IAAAzF,YAAA2C,KAAAyC,MAAAE,OAAArF,OAAA0C,KAAAyC,MAAAM,oBAAA,IACA/C,KAAAgD,SAAA,IAAA3F,YAAA2C,KAAAyC,MAAAE,OAAArF,OAAA0C,KAAAyC,MAAAQ,sBAAAhE,EAAAS,OAAAkB,cACAZ,KAAAgD,SAAAE,IAAAlD,KAAAkC,MAAAxE,SACAsC,KAAAmD,MAAA,IAAA9F,YAAA2C,KAAAyC,MAAAE,OAAArF,OAAA0C,KAAAyC,MAAAW,kBACApD,KAAAyC,MAAAY,KAAAtE,EAAArC,mBAAA,EAAAsD,KAAAkC,MAAAvB,aAAA,EACA,CAEA,cAAA2C,GAAuB,OAAAtD,KAAA8C,QAAA,GACvB,aAAAS,GAAsB,OAAAvD,KAAA8C,QAAA,GACtB,gBAAAU,GAAyB,OAAAxD,KAAA8C,QAAA,GACzB,iBAAAW,GAA0B,OAAAzD,KAAA8C,QAAA,GAC1B,UAAAY,GAAmB,OAAA1D,KAAA8C,QAAA,GAAA9C,KAAA8C,QAAA,OACnB,WAAAa,GAAoB,OAAA3D,KAAA8C,QAAA,GACpB,UAAAc,GAAmB,OAAA5D,KAAA8C,QAAA,GACnB,SAAAe,GAAkB,OAAA7D,KAAA8C,QAAA,IAClB,iBAAAgB,GAA0B,OAAA9D,KAAA8C,QAAA,IAC1B,WAAAR,CAAAlC,GACA,OAAAA,EAAA,CACA,MAAA2D,EAAA/D,KAAAE,MAAAF,KAAAgE,OACA,GAAAD,EAAA/D,KAAA4B,QAAA7D,OAAA,CACA,GAAAiC,KAAAkC,MAAA1B,aAAA,EAAAuD,EAAA/D,KAAAkC,MAAA1B,YAEA,MADAR,KAAAiE,UACA,IAAA7B,MAAA,8BAEApC,KAAA4B,QAAA,IAAAvE,YAAA0G,EACA,CACA/D,KAAA8B,UAAA9B,KAAA0D,MACA,MACA,OAAAtD,EACA,OAAAJ,KAAA4D,OAAA,CAEA,MAAAG,EAAAhI,KAAAE,IAAA+D,KAAAwD,aAAAvE,EAAAS,OAAAiC,WAAA3B,KAAAyD,cACA,GAAAM,EAAA/D,KAAA4B,QAAA7D,OAAA,CACA,GAAAiC,KAAAkC,MAAA1B,aAAA,EAAAuD,EAAA/D,KAAAkC,MAAA1B,YAEA,MADAR,KAAAiE,UACA,IAAA7B,MAAA,8BAEApC,KAAA4B,QAAA,IAAAvE,YAAA0G,EACA,CACA,MAGA/D,KAAA4B,QAAA7D,OAAA,QACAiC,KAAA4B,QAAA,IAAAvE,YAAA,QAIA,QACA,CACA,QAAA6G,CAAAC,EAAAC,GACA,MAAAL,EAAAI,EAAAC,EACA,GAAAL,EAAA/D,KAAA4B,QAAA7D,OAAA,CACA,GAAAiC,KAAAkC,MAAA1B,aAAA,EAAAuD,EAAA/D,KAAAkC,MAAA1B,YAEA,MADAR,KAAAiE,UACA,IAAA7B,MAAA,8BAGA,MAAAiC,EAAA,IAAAhH,YAAA,MAAAtB,KAAAuI,KAAAP,EAAA,QACAM,EAAAnB,IAAAlD,KAAA4B,SACA5B,KAAA4B,QAAAyC,CACA,CACA,CACA,YAAAhC,CAAAnC,GACA,MAAAqE,EAAAvE,KAAA0B,cACA,IAAAyC,EAAAnE,KAAAgC,YACA,OAAAhC,KAAA6D,MAAA,CACA,IAAAW,EAAAxE,KAAAgE,OAAAhE,KAAAiC,eACA5F,EAAA,EACA,KAAAA,EAAA,GAAAmI,EAAA,GACAxE,KAAA4B,QAAAsB,IAAAlD,KAAAmD,MAAAsB,SAAAF,EAAAlI,EAAAkI,EAAAlI,EAAA6D,GAAAiE,EAAAjE,EAAA7D,GACAA,IACAmI,IAEAxE,KAAAgC,aAAA9B,EAAA7D,EACA2D,KAAAiC,gBAAA5F,CACA,MACA,OAAA2D,KAAA6D,MAAA,CACA7D,KAAAkE,SAAAC,EAAA,EAAAjE,GACAF,KAAA8B,UAAA/F,KAAAC,IAAAgE,KAAA8B,UAAA5B,GACAF,KAAA+B,UAAAhG,KAAAE,IAAA+D,KAAA+B,UAAA7B,GACA,QAAApC,EAAA,EAA4BA,EAAA,IAAOA,EACnCkC,KAAA4B,QAAAsB,IAAAlD,KAAAmD,MAAAsB,SAAAF,EAAAzG,EAAAyG,EAAAzG,EAAAoC,GAAAiE,EAAAjE,EAAApC,GAEAkC,KAAA6B,YAAAnD,KAAAwB,GACAF,KAAAgC,aAAA,EAAA9B,EACAF,KAAAiC,gBAAA,CACA,CACA,QACA,CAMA,SAAA/B,GACA,WAAAF,KAAA6D,MACA7D,KAAA0D,OACA3H,KAAAC,IAAAgE,KAAA8B,UAAA9B,KAAAyC,MAAAiC,gBACA,CAMA,UAAAV,GACA,WAAAhE,KAAA6D,MACA7D,KAAA2D,QACA3D,KAAAyC,MAAAiC,gBACA,EAAA1E,KAAA6B,YAAA9D,OAAAiC,KAAAyC,MAAAkC,iBACA,EAAA3E,KAAA6B,YAAA9D,MACA,CAIA,WAAAL,GACA,OAAAsC,KAAAgD,SAAAyB,SAAA,EAAAzE,KAAA8D,cACA,CAUA,eAAAc,GACA,OAAA5E,KAAA4B,QAAAiD,WAAA7E,KAAAyC,MAAAE,OAAArF,OAAAuH,WAAA,EAAA7E,KAAA6B,YAAA9D,MACA,CAIA,cAAA+G,GACA,OACA5E,MAAAF,KAAAE,MACA8D,OAAAhE,KAAAgE,OACA5D,KAAAJ,KAAA6D,MACAkB,MAAA/E,KAAA4D,OACA/C,WAAAb,KAAAuD,UACA5C,aAAAX,KAAA8D,cACApD,UAAAV,KAAAsD,WACA0B,SAAAhF,KAAA4E,YACAK,iBAAA,CACAC,UAAAlF,KAAA8C,QAAA,GACAqC,YAAAnF,KAAA8C,QAAA,GACA5C,MAAAF,KAAAwD,aACAQ,OAAAhE,KAAAyD,eAGA,CAMA,IAAAJ,CAAA3C,EAAAV,KAAAkC,MAAAxB,UAAAhD,EAAAsC,KAAAkC,MAAAxE,QAAAiD,EAAAX,KAAAkC,MAAAvB,aAAAE,EAAAb,KAAAkC,MAAArB,UACAb,KAAAyC,MAAAY,KAAArD,KAAAkC,MAAAzB,WAAAC,EAAAC,EAAAE,EAAA,KACAnD,GACAsC,KAAAgD,SAAAE,IAAAxF,EAAA+G,SAAA,EAAAxF,EAAAS,OAAAkB,eAEAZ,KAAA6B,YAAA9D,OAAA,EACAiC,KAAA8B,UAAA,EACA9B,KAAA+B,UAAA9C,EAAAS,OAAAiC,UACA3B,KAAAgC,YAAA,EACAhC,KAAAiC,eAAA,CACA,CAKA,MAAArD,CAAAwG,EAAAC,EAAA,EAAAC,EAAAF,EAAArH,QACA,IAAAU,EAAA4G,EACA,KAAA5G,EAAA6G,GAAA,CACA,MAAAvH,EAAAhC,KAAAE,IAAAqJ,EAAA7G,EAAAQ,EAAAS,OAAAmD,YACA7C,KAAA0C,OAAAQ,IAAAkC,EAAAX,SAAAhG,EAAAA,GAAAV,IACAiC,KAAAyC,MAAA7D,OAAA,EAAAb,EACA,CACA,CAMA,YAAAwH,CAAAH,EAAAC,EAAA,EAAAC,EAAAF,EAAArH,QACA,IAAAU,EAAA4G,EACA,KAAA5G,EAAA6G,GAAA,CACA,MAAAvH,EAAAhC,KAAAE,IAAAqJ,EAAA7G,EAAAQ,EAAAS,OAAAmD,YACA,QAAA/E,EAAA,EAAA0H,EAAA/G,EAAmCX,EAAAC,IAAYD,IAAA0H,EAC/CxF,KAAA0C,OAAA5E,GAAAsH,EAAA5F,WAAAgG,GAEA/G,GAAAV,EACAiC,KAAAyC,MAAA7D,OAAA,EAAAb,EACA,CACA,CAKA,UAAA0H,GACA,OAAAzF,KAAA6D,QAAA7D,KAAAE,QAAAF,KAAAgE,OACA,OAAAnE,EAGA,MAAA6F,EAAA1F,KAAAyC,MAAAiC,gBACA,OAAA1E,KAAA6D,MAAA,CACA,IAAAW,EAAAxE,KAAAgE,OAAAhE,KAAAiC,eACA,GAAAuC,EAAA,GACA,MAAAD,EAAAvE,KAAA0B,cACA,IAAAyC,EAAAnE,KAAAgC,YACA3F,EAAA,EACA,KAAAA,EAAA,GAAAmI,EAAA,GACAxE,KAAA4B,QAAAsB,IAAAlD,KAAAmD,MAAAsB,SAAAF,EAAAlI,EAAAkI,EAAAlI,EAAAqJ,GAAAvB,EAAAuB,EAAArJ,GACAA,IACAmI,IAEAA,GACAxE,KAAA4B,QAAA+D,KAAA3F,KAAAsD,WAAAa,EAAAuB,EAAArJ,EAEA,CACA,OAAA2D,KAAA4B,QAAA6C,SAAA,EAAAzE,KAAAE,MAAAF,KAAAgE,OACA,CACA,OAAAhE,KAAA6D,MAAA,CACA,GAAA7D,KAAA+B,YAAA/B,KAAA8B,UAAA,CACA,IAAA8D,GAAA,EACA,GAAAF,EACA,GAAAA,IAAA1F,KAAA+B,UACA6D,GAAA,MAEA,CACA,MAAArB,EAAAvE,KAAA0B,cACA,IAAAyC,EAAAnE,KAAAgC,YACAhC,KAAAkE,SAAAC,EAAA,EAAAuB,GACA,QAAA5H,EAAA,EAAwCA,EAAA,IAAOA,EAC/CkC,KAAA4B,QAAAsB,IAAAlD,KAAAmD,MAAAsB,SAAAF,EAAAzG,EAAAyG,EAAAzG,EAAA4H,GAAAvB,EAAAuB,EAAA5H,EAEA,CAEA,IAAA8H,EACA,OAAA5F,KAAA4B,QAAA6C,SAAA,EAAAzE,KAAAE,MAAAF,KAAAgE,OAEA,CAGA,MAAA6B,EAAA,IAAAxI,YAAA2C,KAAAE,MAAAF,KAAAgE,QACA6B,EAAAF,KAAA3F,KAAAsD,YACA,IAAAwC,EAAA,EACAT,EAAA,EACA,QAAAvH,EAAA,EAA4BA,EAAAkC,KAAA6B,YAAA9D,SAA6BD,EAAA,CACzD,MAAAiI,EAAA/F,KAAA6B,YAAA/D,GACA,QAAAW,EAAA,EAAgCA,EAAA,IAAOA,EACvCoH,EAAA3C,IAAAlD,KAAA4B,QAAA6C,SAAAY,EAAAA,GAAAU,GAAAD,GACAA,GAAA9F,KAAAE,KAEA,CAEA,GAAAwF,EAAA,CACA,MAAAnB,EAAAvE,KAAA0B,cAEAsE,EAAAhG,KAAAyC,MAAAkC,iBACA,QAAA7G,EAAA,EAAgCA,EAAAkI,IAAmBlI,EACnD+H,EAAA3C,IAAAlD,KAAAmD,MAAAsB,SAAAF,EAAAzG,EAAAyG,EAAAzG,EAAA4H,GAAAI,EAAA9F,KAAAE,MAAApC,EAEA,CACA,OAAA+H,CACA,CAEA,OAAAhG,CACA,CAKA,SAAAoG,GACA,WAAAC,kBAAAlG,KAAAyF,OAAAnI,OAAA,EAAA0C,KAAAE,MAAAF,KAAAgE,OAAA,EACA,CAaA,OAAAC,GACAjE,KAAA4B,QAAA/B,EACAG,KAAA6B,YAAA9D,OAAA,EACAiC,KAAA8B,UAAA,EACA9B,KAAA+B,UAAA9C,EAAAS,OAAAiC,UAGA3B,KAAAyC,MAAAY,KAAAtE,EAAArC,mBAAA,EAAAsD,KAAAkC,MAAAvB,aAAA,EACA,EAEA9F,EAAAgE,QAAeA,EAyBfhE,EAAA+D,OAXA,SAAAwG,EAAAtE,GACA,MAAAqF,EAAA,IAAAtH,EAAAiC,GAGA,OAFAqF,EAAA9C,OACA,iBAAA+B,EAAAe,EAAAZ,aAAAH,GAAAe,EAAAvH,OAAAwG,GACA,CACAlF,MAAAiG,EAAAjG,MACA8D,OAAAmC,EAAAnC,OACAyB,OAAAU,EAAAV,OACAQ,MAAAE,EAAAF,MAEA,EAkBApL,EAAA8D,YAXAyH,eAAAhB,EAAAtE,GACA,MAAAqF,QAAArH,EAAAgC,GAGA,OAFAqF,EAAA9C,OACA,iBAAA+B,EAAAe,EAAAZ,aAAAH,GAAAe,EAAAvH,OAAAwG,GACA,CACAlF,MAAAiG,EAAAjG,MACA8D,OAAAmC,EAAAnC,OACAyB,OAAAU,EAAAV,OACAQ,MAAAE,EAAAF,MAEA,YCpdAzJ,OAAAC,eAAA5B,EAAA,cAA+CiB,OAAA,IAC/CjB,EAAA6E,YAAc,EACd7E,EAAA6E,OAAc,CACdmD,WAAA,MACAjC,aAAA,KACAe,UAAA,MACAhC,MAAA,+qdCCA,MAAA0G,EAAArH,EAAA,KAoEA,IAAiBsH,YA9DjB,iBAAAvG,GACUC,KAAAuG,WAAqD,GACrDvG,KAAAwG,WAAY,CA0DtB,CAvDE,SAAWC,GACT,OAAIzG,KAAK0G,SAGT1G,KAAK0G,OAAS,CAACC,EAAyBC,EAAgBC,KACtD,GAAI7G,KAAKwG,UACP,OAAO,EAAAH,EAAAS,cAAa,QAGtB,MAAMC,EAAQ,CAAEC,GAAIL,EAAUC,YAC9B5G,KAAKuG,WAAavG,KAAKuG,WAAWU,QAClCjH,KAAKuG,WAAW7H,KAAKqI,GAErB,MAAMxH,GAAS,EAAA8G,EAAAS,cAAa,KAC1B,MAAMjJ,EAAMmC,KAAKuG,WAAWW,QAAQH,IACvB,IAATlJ,IACFmC,KAAKuG,WAAavG,KAAKuG,WAAWU,QAClCjH,KAAKuG,WAAWY,OAAOtJ,EAAK,MAYhC,OARIgJ,IACEO,MAAMC,QAAQR,GAChBA,EAAYnI,KAAKa,GAEjBsH,EAAYS,IAAI/H,IAIbA,IA3BAS,KAAK0G,MA8BhB,CAEO,IAAAa,CAAKd,GACV,GAAIzG,KAAKwG,YAAcxG,KAAKuG,WAAWxI,OACrC,OAEF,GAA+B,IAA3BiC,KAAKuG,WAAWxI,OAElB,YADAiC,KAAKuG,WAAW,GAAGS,GAAGQ,KAAKxH,KAAKuG,WAAW,GAAGK,SAAUH,GAG1D,MAAMgB,EAAYzH,KAAKuG,WACvB,IAAK,IAAIzI,EAAI,EAAG4J,EAAMD,EAAU1J,OAAQD,EAAI4J,IAAO5J,EACjD2J,EAAU3J,GAAGkJ,GAAGQ,KAAKC,EAAU3J,GAAG8I,SAAUH,EAEhD,CAEO,OAAAkB,GACD3H,KAAKwG,YAGTxG,KAAKwG,WAAY,EACjBxG,KAAKuG,WAAWxI,OAAS,EAC3B,GAGF,SAAiBuI,GACCA,EAAAsB,QAAhB,SAA2BxI,EAAiByI,GAC1C,OAAOzI,EAAK0I,GAAKD,EAAGN,KAAKO,GAC3B,EAEgBxB,EAAAyB,IAAhB,SAA0BtB,EAAkBsB,GAC1C,MAAO,CAACpB,EAAyBC,EAAgBC,IACxCJ,EAAM3I,GAAK6I,EAASa,KAAKZ,EAAUmB,EAAIjK,SAAKkK,EAAWnB,EAElE,EAIgBP,EAAA2B,IAAhB,YAA0BC,GACxB,MAAO,CAACvB,EAAyBC,EAAgBC,KAC/C,MAAMsB,EAAQ,IAAI9B,EAAA+B,gBAClB,IAAK,MAAM3B,KAASyB,EAClBC,EAAMb,IAAIb,EAAMqB,GAAKnB,EAASa,KAAKZ,EAAUkB,KAS/C,OAPIjB,IACEO,MAAMC,QAAQR,GAChBA,EAAYnI,KAAKyJ,GAEjBtB,EAAYS,IAAIa,IAGbA,EAEX,EAIgB7B,EAAA+B,gBAAhB,SAAmC5B,EAAkB6B,EAAqCC,GAExF,OADAD,EAAQC,GACD9B,EAAMqB,GAAKQ,EAAQR,GAC5B,CACD,CApCD,CAAiBxB,IAAUzL,EAAAyL,WAAVA,EAAU,eChE3B,SAAAQ,EAA6BE,GAC3B,MAAO,CAAEW,QAASX,EACpB,CAKA,SAAAW,EAA+Ca,GAC7C,IAAKA,EACH,OAAOA,EAET,GAAIpB,MAAMC,QAAQmB,GAAM,CACtB,IAAK,MAAMrK,KAAKqK,EACdrK,EAAEwJ,UAEJ,MAAO,EACT,CAEA,OADAa,EAAIb,UACGa,CACT,8JAEA,YAAsC3B,GACpC,OAAOC,EAAa,IAAMa,EAAQd,GACpC,EAEA,MAAAuB,EAAA,WAAArI,GACmBC,KAAAyI,aAAe,IAAIC,IAC5B1I,KAAA2I,aAAc,CAgCxB,CA9BE,cAAWC,GACT,OAAO5I,KAAK2I,WACd,CAEO,GAAArB,CAA2BuB,GAMhC,OALI7I,KAAK2I,YACPE,EAAElB,UAEF3H,KAAKyI,aAAanB,IAAIuB,GAEjBA,CACT,CAEO,OAAAlB,GACL,IAAI3H,KAAK2I,YAAT,CAGA3I,KAAK2I,aAAc,EACnB,IAAK,MAAMxK,KAAK6B,KAAKyI,aACnBtK,EAAEwJ,UAEJ3H,KAAKyI,aAAaK,OALlB,CAMF,CAEO,KAAAA,GACL,IAAK,MAAM3K,KAAK6B,KAAKyI,aACnBtK,EAAEwJ,UAEJ3H,KAAKyI,aAAaK,OACpB,sBAGF,MAAAC,EAAA,WAAAhJ,GAGqBC,KAAAgJ,OAAS,IAAIZ,CASlC,CAPS,OAAAT,GACL3H,KAAKgJ,OAAOrB,SACd,CAEU,SAAAsB,CAAiCJ,GACzC,OAAO7I,KAAKgJ,OAAO1B,IAAIuB,EACzB,iBAVuBE,EAAAG,KAAoB1M,OAAO2M,OAAO,CAAE,OAAAxB,GAAY,wBAazE,iBAAA5H,GAEUC,KAAA2I,aAAc,CAuBxB,CArBE,SAAW7M,GACT,OAAOkE,KAAK2I,iBAAcX,EAAYhI,KAAKoJ,MAC7C,CAEA,SAAWtN,CAAMA,GACXkE,KAAK2I,aAAe7M,IAAUkE,KAAKoJ,SAGvCpJ,KAAKoJ,QAAQzB,UACb3H,KAAKoJ,OAAStN,EAChB,CAEO,KAAAgN,GACL9I,KAAKlE,WAAQkM,CACf,CAEO,OAAAL,GACL3H,KAAK2I,aAAc,EACnB3I,KAAKoJ,QAAQzB,UACb3H,KAAKoJ,YAASpB,CAChB,wKC1GF,MAAAqB,EAAArK,EAAA,KAEAsK,EAAAtK,EAAA,KACAuK,EAAAC,EAAAxK,EAAA,MACAyK,EAAAD,EAAAxK,EAAA,KACA0K,EAAA1K,EAAA,KACA2K,EAAA3K,EAAA,KAeM4K,EAAgC,CACpCC,KAAI,EACJC,KAAM,eACNC,KAAM,EACN7J,MAAO,OACP8D,OAAQ,OACRgG,oBAAqB,EACrBC,OAAQ,gBAIV,MAUE,WAAAlK,CACmBmC,EACAgI,EACAC,EACAC,GAHApK,KAAAkC,MAAAA,EACAlC,KAAAkK,UAAAA,EACAlK,KAAAmK,SAAAA,EACAnK,KAAAoK,cAAAA,EAbXpK,KAAAqK,YAAc,EACdrK,KAAAsK,UAAW,EACXtK,KAAAuK,IAAM,IAAIb,EAAAc,aACVxK,KAAAyK,QAAyBb,EAGzB5J,KAAA0K,cAAe,EACf1K,KAAA2K,aAAc,EAQpB,MAAMC,EAAkB7O,KAAKuI,KAA+B,EAA1BtE,KAAKkC,MAAM2I,aAAmB,GAC1DC,EAAe/O,KAAKE,IAAG,QAA4B2O,GACzD5K,KAAK+K,KAAO,IAAIxB,EAAAyB,QAAa,EAAyBJ,EAAiBE,GACvE9K,KAAKiL,QAAU,IAAIxB,EAAAuB,QAAU,EAC/B,CAEO,KAAAE,GACLlL,KAAKqK,cACLrK,KAAKuK,IAAIW,QACTlL,KAAK+K,KAAK9G,UACVjE,KAAKiL,QAAQhH,SACf,CAEO,KAAAoB,GACLrF,KAAKsK,UAAW,EAChBtK,KAAKuK,IAAIW,OACX,CAEO,GAAAC,CAAI/F,EAAmBC,EAAeC,GAC3C,IAAItF,KAAKsK,SAET,GAAkB,IAAdtK,KAAKuK,IAAIa,MAC6C,IAAnDpL,KAAK+K,KAAKI,IAAI/F,EAAKX,SAASY,EAAOC,MACtCtF,KAAK+K,KAAK9G,UACVjE,KAAKsK,UAAW,OAEb,CACL,MAAMe,EAAUrL,KAAKuK,IAAIe,MAAMlG,EAAMC,EAAOC,GAC5C,IAAiB,IAAb+F,EAEF,YADArL,KAAKsK,UAAW,GAGlB,GAAIe,EAAU,EAAG,CAEf,GAAW,IADKrL,KAAKuK,IAAIgB,OAAO1B,KACG,CAOjC,GANI7J,KAAK0K,eACP1K,KAAK0K,cAAe,EACpB1K,KAAK2K,aAAc,EACnB3K,KAAK+K,KAAK9G,WAEZjE,KAAKyK,QAAUjO,OAAO2F,OAAO,GAAIyH,EAAgB5J,KAAKuK,IAAIgB,SACrDvL,KAAKyK,QAAQR,OAEhB,YADAjK,KAAKsK,UAAW,GAGlB,IAAKtK,KAAKwL,eAER,YADAxL,KAAKsK,UAAW,EAGpB,MAAO,GAAItK,KAAK2K,YAEd,YADA3K,KAAKsK,UAAW,GAGwC,IAArDtK,KAAK+K,KAAKI,IAAI/F,EAAKX,SAAS4G,EAAS/F,MACxCtF,KAAK+K,KAAK9G,UACVjE,KAAKsK,UAAW,EACZtK,KAAK0K,eAAc1K,KAAK2K,aAAc,GAE9C,CACF,CACF,CAEO,GAAArF,CAAImG,aACT,GAAIzL,KAAKsK,SAAU,OAAO,EAE1B,GAAkB,IAAdtK,KAAKuK,IAAIa,OACPpL,KAAKuK,IAAIjF,MAAO,OAAO,EAE7B,MAAMoG,EAAU1L,KAAKuK,IAAIgB,OAAO1B,KAEhC,GAAW,IAAP6B,EAAmC,OAAO,EAE9C,GAAW,IAAPA,EAAyC,CAE3C,IAAIC,EAAIrC,EAAAsC,kBAAkB1L,MACtB9B,EAAIkL,EAAAsC,kBAAkB5H,OACtBhE,KAAKkK,UAAU2B,aACjBF,EAAI3L,KAAKkK,UAAU2B,WAAWC,IAAIC,OAAO7L,MAAQF,KAAKoK,cAAc4B,KACpE5N,EAAI4B,KAAKkK,UAAU2B,WAAWC,IAAIC,OAAO/H,OAAShE,KAAKoK,cAAc6B,MAEvE,MAAMC,EAAyD,QAApDC,EAA+C,QAA5CC,EAAApM,KAAKoK,cAAciC,MAAMC,2BAAmB,IAAAF,OAAA,EAAAA,EAAEG,WAAG,IAAAJ,EAAAA,EAAI,EAC7DK,EAAS,yBAA4BpO,EAAEqO,QAAQ,MAAMd,EAAEc,QAAQ,MAAMP,EAAMO,QAAQ,QAEzF,OADAzM,KAAKoK,cAAcsC,MAAMF,GAAQ,IAC1B,CACT,CAEA,GAAW,IAAPd,EAQF,OAPA1L,KAAKyK,QAAUjO,OAAO2F,OAAO,GAAIyH,EAAgB5J,KAAKuK,IAAIgB,QAC1DvL,KAAK0K,cAAe,EACpB1K,KAAK2K,aAAc,EACnB3K,KAAK+K,KAAK9G,UACLjE,KAAKwL,iBACRxL,KAAK2K,aAAc,IAEd,EAGT,GAAW,IAAPe,EAAkC,CACpC,IAAK1L,KAAK0K,aAAc,OAAO,EAE/B,GADA1K,KAAK0K,cAAe,EAChB1K,KAAK2K,aAAgC,IAAjB3K,KAAKyK,QAAQZ,KAAqC,OAAO,CACnF,CAIA,IAII8C,EA0BAC,EA9BAjB,EAAI,EACJvN,EAAI,EAIJyO,EAAUlD,EAAAmD,iBAoBd,IAnBIH,EAAOlB,MACLkB,GAAQ3M,KAAK+K,KAAKzF,QACpBuH,GAAU,EAAAlD,EAAAoD,WAAU/M,KAAK+K,KAAK9E,QAC1B0G,EAAwB,gBAAjBE,EAAQG,OACjBrB,EAAIkB,EAAQ3M,MACZ9B,EAAIyO,EAAQ7I,QACR2I,EAAOhB,GAAKvN,GAAKuN,EAAIvN,EAAI4B,KAAKkC,MAAM+K,cACrCtB,EAAGvN,GAAK4B,KAAKkN,QAAQvB,EAAGvN,GAAG2J,IAAIhM,KAAKoR,OACrCR,EAAOhB,GAAKvN,GAAKuN,EAAIvN,EAAI4B,KAAKkC,MAAM+K,YAEpC1P,QAAQC,KAAK,8BAA8BqP,EAAQ3M,SAAS2M,EAAQ7I,WAGtEzG,QAAQC,KAAK,gCAGfD,QAAQC,KAAK,uCAGZmP,EAEH,OADA3M,KAAK+K,KAAK9G,WACH,EAIT,GAAqB,cAAjB4I,EAAQG,KAAsB,CAChC,IAAI5H,EACJ,IACEA,EAAOpF,KAAKiL,QAAQrM,OAAOoB,KAAK+K,KAAK9E,MACvC,CAAE,MAAO6B,GAIP,OAHAvK,QAAQC,KAAK,kCAAmCsK,GAChD9H,KAAK+K,KAAK9G,UACVjE,KAAKiL,QAAQhH,WACN,CACT,CAOA,GANA2I,EAAO,IAAIQ,UACT,IAAIlH,kBAAkBd,EAAK9H,OAAQ8H,EAAKiI,WAAYjI,EAAKP,YACzD7E,KAAKiL,QAAQ/K,MACbF,KAAKiL,QAAQjH,QAEfhE,KAAKiL,QAAQhH,UACT0H,IAAM3L,KAAKiL,QAAQ/K,OAAS9B,IAAM4B,KAAKiL,QAAQjH,OAAQ,CAEzDhE,KAAK+K,KAAK9G,UACV,MAAM8H,EAAS1C,EAAAiE,cAAcC,kBAAavF,EAAWhI,KAAKiL,QAAQ/K,MAAOF,KAAKiL,QAAQjH,QAGtF,OAFuB,QAAvBwJ,EAAAzB,EAAO0B,WAAW,aAAK,IAAAD,GAAAA,EAAEE,aAAad,EAAM,EAAG,GAC/C5M,KAAKmK,SAASwD,SAAS5B,IAChB,CACT,CACF,MACEa,EAAO,IAAIgB,KAAK,CAAC5N,KAAK+K,KAAK9E,OAAQ,CAAE4D,KAAMgD,EAAQG,OAErDhN,KAAK+K,KAAK9G,UACV,MAAM4J,EAAa7N,KAAKqK,YACxB,OAAOyD,kBAAkBlB,EAAM,CAAEmB,YAAapC,EAAGqC,aAAc5P,IAC5DiD,KAAK4M,GACAJ,IAAe7N,KAAKqK,aACtB4D,EAAGC,SACI,IAETlO,KAAKmK,SAASwD,SAASM,IAChB,IAERE,MAAMrG,IACLvK,QAAQC,KAAK,uBAAuBqP,EAAQG,QAAQH,EAAQ3M,SAAS2M,EAAQ7I,SAAU8D,IAChF,GAEb,CAGQ,YAAA0D,GACN,IAEE,OADAxL,KAAK+K,KAAK1H,QACH,CACT,CAAE,MAAOyE,GAGP,OAFAvK,QAAQC,KAAK,kCAAmCsK,GAChD9H,KAAK+K,KAAK9G,WACH,CACT,CACF,CAEQ,OAAAiJ,CAAQvB,EAAWvN,eACzB,MAAMgQ,GAA8B,QAAzBhC,EAAApM,KAAKkK,UAAU2B,kBAAU,IAAAO,OAAA,EAAAA,EAAEN,IAAIuC,KAAKnO,QAASoJ,EAAAsC,kBAAkB1L,MACpEoO,GAA8B,QAAzBd,EAAAxN,KAAKkK,UAAU2B,kBAAU,IAAA2B,OAAA,EAAAA,EAAE1B,IAAIuC,KAAKrK,SAAUsF,EAAAsC,kBAAkB5H,OACrE9D,GAAiC,QAAzBiM,EAAAnM,KAAKkK,UAAU2B,kBAAU,IAAAM,OAAA,EAAAA,EAAEL,IAAIC,OAAO7L,QAASkO,EAAKpO,KAAKoK,cAAc4B,KAC/EhI,GAAkC,QAAzBuK,EAAAvO,KAAKkK,UAAU2B,kBAAU,IAAA0C,OAAA,EAAAA,EAAEzC,IAAIC,OAAO/H,SAAUsK,EAAKtO,KAAKoK,cAAc6B,KAEjFuC,EAAKxO,KAAKyO,KAAKzO,KAAKyK,QAAQvK,MAAQA,EAAOkO,GAC3CM,EAAK1O,KAAKyO,KAAKzO,KAAKyK,QAAQzG,OAASA,EAAQsK,GACnD,IAAKE,IAAOE,EAAI,CACd,MAAMC,EAAKzO,EAAQyL,EACbiD,GAAM5K,EAASsK,GAAMlQ,EACrByQ,EAAI9S,KAAKE,IAAI0S,EAAIC,GACvB,OAAOC,EAAI,EAAI,CAAClD,EAAIkD,EAAGzQ,EAAIyQ,GAAK,CAAClD,EAAGvN,EACtC,CACA,OAAQoQ,GAEJxO,KAAKyK,QAAQT,qBAAwBwE,GAAOE,EACvB,CAACF,EAAIE,GAAxB,CAACF,EAAIpQ,EAAIoQ,EAAK7C,GAFhB,CAACA,EAAI+C,EAAKtQ,EAAGsQ,EAGnB,CAEQ,IAAAD,CAAKnQ,EAAWwQ,EAAeC,GACrC,MAAU,SAANzQ,EAAqB,EACrBA,EAAE0Q,SAAS,KAAaC,SAAS3Q,EAAE2I,MAAM,GAAI,GAAI,IAAM6H,EAAQ,IAC/DxQ,EAAE0Q,SAAS,MAAcC,SAAS3Q,EAAE2I,MAAM,GAAI,GAAI,IAC/CgI,SAAS3Q,EAAG,IAAMyQ,CAC3B,aC1NF,SAASG,EAAM9J,GACb,IAAI9G,EAAI,GACR,IAAK,IAAIR,EAAI,EAAGA,EAAIsH,EAAKrH,SAAUD,EACjCQ,GAAK6Q,OAAOC,aAAahK,EAAKtH,IAEhC,OAAOQ,CACT,CAGA,SAAS+Q,EAAMjK,GACb,IAAI7G,EAAI,EACR,IAAK,IAAIT,EAAI,EAAGA,EAAIsH,EAAKrH,SAAUD,EAAG,CACpC,GAAIsH,EAAKtH,GAAK,IAAMsH,EAAKtH,GAAK,GAC5B,MAAM,IAAIsE,MAAM,gBAElB7D,EAAQ,GAAJA,EAAS6G,EAAKtH,GAAK,EACzB,CACA,OAAOS,CACT,CAGA,SAAS+Q,EAAOlK,GACd,MAAM7G,EAAI2Q,EAAM9J,GAChB,IAAK7G,EAAEgR,MAAM,oCACX,MAAM,IAAInN,MAAM,gBAElB,OAAO7D,CACT,wEAeA,MAAMiR,EAAiE,CACrEvF,OAAQoF,EACRtF,KAAMsF,EACNvF,KAfF,SAAgB1E,GACd,GAAsB,oBAAXjG,OACT,OAAOA,OAAOC,KAAK8P,EAAM9J,GAAO,UAAUqK,WAE5C,MAAMC,EAAKpQ,KAAK4P,EAAM9J,IAChB3J,EAAI,IAAI2B,WAAWsS,EAAG3R,QAC5B,IAAK,IAAID,EAAI,EAAGA,EAAIrC,EAAEsC,SAAUD,EAC9BrC,EAAEqC,GAAK4R,EAAGlQ,WAAW1B,GAEvB,OAAO,IAAI6R,aAAc/Q,OAAOnD,EAClC,EAMEyE,MAAOoP,EACPtL,OAAQsL,EACRtF,oBAAqBqF,GAMjBO,EAAc,CAAC,GAAI,IAAK,IAAK,KAE7BC,EAAuB,CAAC,GAAI,IAAK,IAAK,IAAK,IAAK,IAAK,GAAI,IAAK,IAAK,GAAI,IAAK,IAAK,KAEjFC,EAAkB,CAAC,GAAI,IAAK,IAAK,IAAK,GAAI,GAAI,IAAK,KAEnDC,EAAiB,CAAC,GAAI,IAAK,IAAK,IAAK,GAAI,IAAK,KAE9CC,EAAwB,CAAC,GAAI,IAAK,IAAK,IAAK,IAAK,IAAK,GAAI,IAAK,IAAK,IAAK,GAAI,IAAK,IAAK,KAGvFC,EAAiB,oBAGvB,iBAAAlQ,GACSC,KAAAoL,MAAK,EACJpL,KAAAkQ,QAAU,IAAI7S,YAAY4S,GAC1BjQ,KAAAmQ,UAAY,EACZnQ,KAAAoQ,KAAO,GACRpQ,KAAAuL,OAA4E,EAwIrF,CAtIS,KAAAL,GACLlL,KAAKkQ,QAAQvK,KAAK,GAClB3F,KAAKoL,MAAK,EACVpL,KAAKmQ,UAAY,EACjBnQ,KAAKuL,OAAS,GACdvL,KAAKoQ,KAAO,EACd,CAEO,GAAA9K,GACL,GAAc,IAAVtF,KAAKoL,MAA6B,CACpC,GAAIpL,KAAKmQ,YAAcJ,EAAehS,OAAQ,CAC5C,IAAK,IAAIsS,EAAI,EAAGA,EAAIN,EAAehS,SAAUsS,EAC3C,GAAIrQ,KAAKkQ,QAAQG,KAAON,EAAeM,GAAI,OAAOrQ,KAAKoM,KAIzD,OAFApM,KAAKuL,OAAa,KAAC,EACnBvL,KAAKoL,MAAK,EACH,CACT,CACA,GAAIpL,KAAKmQ,YAAcH,EAAsBjS,OAAQ,CACnD,IAAK,IAAIsS,EAAI,EAAGA,EAAIL,EAAsBjS,SAAUsS,EAClD,GAAIrQ,KAAKkQ,QAAQG,KAAOL,EAAsBK,GAAI,OAAOrQ,KAAKoM,KAIhE,OAFApM,KAAKuL,OAAa,KAAC,EACnBvL,KAAKoL,MAAK,EACH,CACT,CACA,OAAOpL,KAAKoM,IACd,CACA,OAAc,IAAVpM,KAAKoL,MAAkC,EAC7B,IAAVpL,KAAKoL,OACY,IAAhBpL,KAAKuL,OAAO1B,MAEV7J,KAAKsQ,YAAYtQ,KAAKmQ,YAC3BnQ,KAAKoL,MAAK,EACH,GAEFpL,KAAKoM,IACd,CAEO,KAAAd,CAAMlG,EAAmBC,EAAeC,GAC7C,IAAI8F,EAAQpL,KAAKoL,MACbmF,EAAMvQ,KAAKmQ,UACf,MAAM7S,EAAS0C,KAAKkQ,QACpB,GAAS,IAAL9E,GAAoC,IAALA,EAA2B,OAAQ,EACtE,GAAS,IAALA,GAA+BmF,EAAM,GAAI,OAAQ,EACrD,IAAK,IAAIzS,EAAIuH,EAAOvH,EAAIwH,IAAOxH,EAAG,CAChC,MAAMzB,EAAI+I,EAAKtH,GACf,OAAQzB,GACN,KAAK,GACH,IAAK2D,KAAKsQ,YAAYC,GAAM,OAAOvQ,KAAKoM,KACxChB,EAAK,EACLmF,EAAM,EACN,MACF,KAAK,GACH,GAAS,IAALnF,EAA6B,CAC/B,GAAkB,KAAd9N,EAAO,GAAW,CAEpB,IAAI+S,EAAI,EACR,KAAOA,EAAIT,EAAY7R,SAAUsS,EAC/B,GAAI/S,EAAO+S,KAAOT,EAAYS,GAAI,OAAOrQ,KAAKoM,KAGhD,GADApM,KAAKuL,OAAa,KAAC,EACfgF,IAAQT,EAAgB/R,OAAQ,CAClC,KAAOsS,EAAIP,EAAgB/R,SAAUsS,EACnC,GAAI/S,EAAO+S,KAAOP,EAAgBO,GAAI,OAAOrQ,KAAKoM,KAIpD,OAFApM,KAAKuL,OAAa,KAAC,EACnBvL,KAAKoL,MAAK,EACHtN,EAAI,CACb,CACF,KAAO,IAAkB,KAAdR,EAAO,GAOhB,OAAO0C,KAAKoM,KALZ,IAAK,IAAIiE,EAAI,EAAGA,EAAIR,EAAqB9R,SAAUsS,EACjD,GAAI/S,EAAO+S,KAAOR,EAAqBQ,GAAI,OAAOrQ,KAAKoM,KAEzDpM,KAAKuL,OAAa,KAAC,CAGrB,CACAH,EAAK,EACLmF,EAAM,CACR,MAAO,GAAS,IAALnF,EAA2B,CACpC,IAAKpL,KAAKwQ,UAAUD,GAAM,OAAOvQ,KAAKoM,KACtChB,EAAK,EACLmF,EAAM,CACR,MAAO,GAAS,IAALnF,EAA6B,CACtC,GAAImF,GAAON,EAAgB,OAAOjQ,KAAKoM,KACvC9O,EAAOiT,KAASlU,CAClB,CACA,MACF,KAAK,GACH,OAAS,IAAL+O,GACGpL,KAAKsQ,YAAYC,IAExBvQ,KAAKoL,MAAK,EACHtN,EAAI,GAH0BkC,KAAKoM,KAI5C,QACE,GAAImE,GAAON,EAAgB,OAAOjQ,KAAKoM,KACvC9O,EAAOiT,KAASlU,EAEtB,CAGA,OAFA2D,KAAKoL,MAAQA,EACbpL,KAAKmQ,UAAYI,GACT,CACV,CAEQ,EAAAnE,GAGN,OAFApM,KAAKuL,OAAO1B,KAAI,EAChB7J,KAAKoL,MAAK,GACF,CACV,CAEQ,SAAAoF,CAAUD,GAChB,MAAMF,EAAInB,EAAMlP,KAAKkQ,QAAQzL,SAAS,EAAG8L,IACzC,QAAIF,IACFrQ,KAAKoQ,KAAOC,EACZrQ,KAAKuL,OAAO8E,GAAK,MACV,EAGX,CAEQ,WAAAC,CAAYC,GAClB,GAAIvQ,KAAKoQ,KAAM,CACb,IACE,MAAM7R,EAAIyB,KAAKkQ,QAAQjJ,MAAM,EAAGsJ,GAChCvQ,KAAKuL,OAAOvL,KAAKoQ,MAAQZ,EAASxP,KAAKoQ,MAAQZ,EAASxP,KAAKoQ,MAAM7R,GAAKA,CAC1E,UACE,OAAO,CACT,CACA,OAAO,CACT,CACA,OAAO,CACT,yGCtPF,MAEE,WAAAwB,CACmBoK,GAAAnK,KAAAmK,SAAAA,EAFXnK,KAAAyQ,cAA+B,CAAEC,WAAW,EAAMC,MAAO,MAAOC,OAAQ,EAAGC,UAAW,MAG3F,CAMI,QAAAlD,CAASmD,GACd9Q,KAAKmK,SAASwD,SAASmD,EAAK9Q,KAAKyQ,cACnC,oGCNF,SAA0BtS,GACxB,GAAIA,EAAEJ,OAAS,GACb,OAAOlD,EAAAiS,iBAET,MAAMiE,EAAM,IAAI1T,YAAYc,EAAEb,OAAQa,EAAEkP,WAAY,GAGpD,GAAe,aAAX0D,EAAI,IAAgC,YAAXA,EAAI,IAAgC,aAAXA,EAAI,GACxD,MAAO,CACL/D,KAAM,YACN9M,MAAQ/B,EAAE,KAAO,GAAKA,EAAE,KAAO,GAAKA,EAAE,KAAO,EAAIA,EAAE,IACnD6F,OAAQ7F,EAAE,KAAO,GAAKA,EAAE,KAAO,GAAKA,EAAE,KAAO,EAAIA,EAAE,KAIvD,GAAa,MAATA,EAAE,IAAwB,MAATA,EAAE,IAAwB,MAATA,EAAE,GAAa,CACnD,MAAO+B,EAAO8D,GAgFlB,SAAiB7F,GACf,MAAMuJ,EAAMvJ,EAAEJ,OACd,IAAID,EAAI,EACJkT,EAAc7S,EAAEL,IAAM,EAAIK,EAAEL,EAAI,GACpC,OAAa,CAEX,GADAA,GAAKkT,EACDlT,GAAK4J,EAEP,MAAO,CAAC,EAAG,GAEb,GAAa,MAATvJ,EAAEL,GACJ,MAAO,CAAC,EAAG,GAEb,GAAiB,MAAbK,EAAEL,EAAI,IAA4B,MAAbK,EAAEL,EAAI,GAC7B,OAAIA,EAAI,EAAI4J,EACH,CACLvJ,EAAEL,EAAI,IAAM,EAAIK,EAAEL,EAAI,GACtBK,EAAEL,EAAI,IAAM,EAAIK,EAAEL,EAAI,IAGnB,CAAC,EAAG,GAEbA,GAAK,EACLkT,EAAc7S,EAAEL,IAAM,EAAIK,EAAEL,EAAI,EAClC,CACF,CAzG4BmT,CAAQ9S,GAChC,MAAO,CAAE6O,KAAM,aAAc9M,QAAO8D,SACtC,CAEA,GAAe,YAAX+M,EAAI,KAA+B,KAAT5S,EAAE,IAAwB,KAATA,EAAE,KAAyB,KAATA,EAAE,GACjE,MAAO,CACL6O,KAAM,YACN9M,MAAQ/B,EAAE,IAAM,EAAIA,EAAE,GACtB6F,OAAQ7F,EAAE,IAAM,EAAIA,EAAE,IAI1B,GAAe,aAAX4S,EAAI,GACN,MAAO,CACL/D,KAAM,YACN9M,MAAQ/B,EAAE,IAAM,GAAKA,EAAE,IAAM,GAAKA,EAAE,IAAM,EAAIA,EAAE,GAChD6F,OAAQ7F,EAAE,IAAM,GAAKA,EAAE,IAAM,GAAKA,EAAE,KAAO,EAAIA,EAAE,KAIrD,GAAe,aAAX4S,EAAI,IAAgC,aAAXA,EAAI,IAA6C,UAAd,SAATA,EAAI,IAA6B,CACtF,OAAQ5S,EAAE,KACR,KAAK,GACH,MAAO,CACL6O,KAAM,aACN9M,MAA6C,GAApC/B,EAAE,IAAMA,EAAE,KAAO,EAAIA,EAAE,KAAO,IACvC6F,OAA6C,GAApC7F,EAAE,IAAMA,EAAE,KAAO,EAAIA,EAAE,KAAO,KAE3C,KAAK,GACH,GAAc,KAAVA,EAAE,IAAc,OAAOtD,EAAAiS,iBAC3B,MAAMoE,EAAM/S,EAAE,IAAMA,EAAE,KAAO,EAAIA,EAAE,KAAO,GAAKA,EAAE,KAAO,GACxD,MAAO,CACL6O,KAAM,aACN9M,MAAgC,GAAV,MAAbgR,GACTlN,OAAgC,GAAvBkN,IAAQ,GAAK,QAE1B,KAAK,GACH,OAAc,MAAV/S,EAAE,KAA0B,IAAVA,EAAE,KAA0B,KAAVA,EAAE,IAAqBtD,EAAAiS,iBACxD,CACLE,KAAM,aACN9M,MAA+B,OAAtB/B,EAAE,IAAMA,EAAE,KAAO,GAC1B6F,OAA+B,OAAtB7F,EAAE,IAAMA,EAAE,KAAO,IAGhC,OAAOtD,EAAAiS,gBACT,CAEA,GAAe,aAAXiE,EAAI,KAAiC,aAAXA,EAAI,IAAgC,aAAXA,EAAI,IAAoB,CAC7E,IAAIR,GAAO,EAEX,MAAMY,EAAQpV,KAAKE,IAAIkC,EAAEJ,OAAS,GAAI,MACtC,IAAK,IAAID,EAAI,EAAGA,EAAIqT,EAAOrT,IAEzB,GAAa,MAATK,EAAEL,IAA4B,MAAbK,EAAEL,EAAI,IAA4B,MAAbK,EAAEL,EAAI,IAA4B,MAAbK,EAAEL,EAAI,GAAa,CAChFyS,EAAMzS,EACN,KACF,CAEF,IAAa,IAATyS,EAAY,CAEd,MAAMrQ,EACJ/B,EAAEoS,EAAO,IAAM,GACfpS,EAAEoS,EAAO,IAAM,GACfpS,EAAEoS,EAAM,KAAQ,EAChBpS,EAAEoS,EAAM,IACJvM,EACJ7F,EAAEoS,EAAM,KAAO,GACfpS,EAAEoS,EAAM,KAAO,GACfpS,EAAEoS,EAAM,KAAQ,EAChBpS,EAAEoS,EAAM,IACV,GAAIrQ,EAAQ,GAAK8D,EAAS,EACxB,MAAO,CAAEgJ,KAAM,aAAc9M,QAAO8D,SAExC,CACA,OAAOnJ,EAAAiS,gBACT,CACA,OAAOjS,EAAAiS,gBACT,EAnGajS,EAAAiS,iBAA6B,CACxCE,KAAM,cACN9M,MAAO,EACP8D,OAAQ,uFCZV,MAAAjF,EAAAC,EAAA,KAGAqH,EAAArH,EAAA,KAaA,MAAAsO,UAAmCjH,EAAA0C,WAEjC,UAAWgD,GAAM,IAAAK,EAAoC,OAA8B,QAAvBA,EAAApM,KAAKoR,QAAQC,IAAI,cAAM,IAAAjF,OAAA,EAAAA,EAAEL,MAAQ,CAUtF,mBAAOwB,CAAa+D,EAAqCpR,EAAe8D,GAU7E,MAAM+H,GAAUuF,QAAAA,EAAiBC,UAAUC,cAAc,UAGzD,OAFAzF,EAAO7L,MAAgB,EAARA,EACf6L,EAAO/H,OAAkB,EAATA,EACT+H,CACT,CAGO,sBAAO0F,CAAgBC,EAA+BxR,EAAe8D,EAAgB1G,GAC1F,GAAyB,mBAAd8P,UAA0B,CACnC,MAAMuE,EAAUD,EAAID,gBAAgBvR,EAAO8D,GAI3C,OAHI1G,GACFqU,EAAQvM,KAAKlC,IAAI,IAAIgD,kBAAkB5I,EAAQ,EAAG4C,EAAQ8D,EAAS,IAE9D2N,CACT,CACA,OAAOrU,EACH,IAAI8P,UAAU,IAAIlH,kBAAkB5I,EAAQ,EAAG4C,EAAQ8D,EAAS,GAAI9D,EAAO8D,GAC3E,IAAIoJ,UAAUlN,EAAO8D,EAC3B,CAGO,wBAAO8J,CAAkBgD,GAC9B,MAAiC,mBAAtBhD,kBACF8D,QAAQC,aAAQ7J,GAElB8F,kBAAkBgD,EAC3B,CAGA,WAAA/Q,CAAoB+R,GAClBC,QADkB/R,KAAA8R,UAAAA,EAhDZ9R,KAAAoR,QAAU,IAAIY,IAGdhS,KAAAiS,gBAAkBjS,KAAKiJ,UAAU,IAAI5C,EAAA6L,mBA+C3ClS,KAAKmS,SAAWnS,KAAK8R,UAAUzF,MAAM+F,KACrCpS,KAAK8R,UAAUzF,MAAM+F,KAAQC,UACd,QAAbjG,EAAApM,KAAKmS,gBAAQ,IAAA/F,GAAAA,EAAE5E,KAAKxH,KAAK8R,UAAUzF,MAAOgG,GAC1CrS,KAAKsS,SAEHtS,KAAK8R,UAAUzF,MAAMkG,eACvBvS,KAAKsS,QAGPtS,KAAKiS,gBAAgBnW,MAAQkE,KAAK8R,UAAUzF,MAAMmG,eAAeC,eAAeC,UAC/D,aAAXA,IACF1S,KAAK2S,gBACc,QAAnBvG,EAAApM,KAAK4S,sBAAc,IAAAxG,GAAAA,EAAEyG,YAAY,EAAG7S,KAAK8R,UAAU7F,SAGvDjM,KAAKiJ,WAAU,EAAA5C,EAAAS,cAAa,WAC1B9G,KAAK8S,qBACL9S,KAAK8S,mBAAmB,UACpB9S,KAAK8R,UAAUzF,OAASrM,KAAKmS,WAC/BnS,KAAK8R,UAAUzF,MAAM+F,KAAOpS,KAAKmS,SACjCnS,KAAKmS,cAAWnK,GAEdhI,KAAK4S,gBAAkB5S,KAAK+S,kBAC9B/S,KAAK4S,eAAeI,YAAchT,KAAK+S,gBACvC/S,KAAK+S,qBAAkB/K,GAEzBhI,KAAK4S,oBAAiB5K,EACtBhI,KAAKoR,QAAQtI,QACU,QAAvBsD,EAAApM,KAAKiT,0BAAkB,IAAA7G,GAAAA,EAAE8B,QACzBlO,KAAKiT,wBAAqBjL,EAC1BhI,KAAKkT,kBAAelL,IAExB,CAKO,eAAAmL,CAAgBrX,WACjBA,EACGkE,KAAKkT,eAA0C,IAA1BlT,KAAKoT,SAASpP,QACtChE,KAAKqT,mBAAmBtX,KAAKC,IAAIgE,KAAKoT,SAASpP,OAAS,EAAC,MAGpC,QAAvBoI,EAAApM,KAAKiT,0BAAkB,IAAA7G,GAAAA,EAAE8B,QACzBlO,KAAKiT,wBAAqBjL,EAC1BhI,KAAKkT,kBAAelL,GAEH,QAAnBwF,EAAAxN,KAAK4S,sBAAc,IAAApF,GAAAA,EAAEqF,YAAY,EAAG7S,KAAK8R,UAAU7F,KACrD,CAMA,cAAWJ,GACT,OAAO7L,KAAK8R,UAAUjG,UACxB,CAKA,YAAWuH,WACT,MAAO,CACLlT,OAAsB,QAAfkM,EAAApM,KAAK6L,kBAAU,IAAAO,OAAA,EAAAA,EAAEN,IAAIuC,KAAKnO,SAAU,EAC3C8D,QAAuB,QAAfwJ,EAAAxN,KAAK6L,kBAAU,IAAA2B,OAAA,EAAAA,EAAE1B,IAAIuC,KAAKrK,UAAW,EAEjD,CAKO,UAAAsP,CAAWjO,EAAeC,EAAaqL,iBAC5C,MAAM4C,EAAIlO,IAAwB,QAAf+G,EAAApM,KAAK6L,kBAAU,IAAAO,OAAA,EAAAA,EAAEN,IAAIuC,KAAKrK,SAAU,GACjD2H,GAAmB,QAAf6B,EAAAxN,KAAK6L,kBAAU,IAAA2B,OAAA,EAAAA,EAAE1B,IAAIC,OAAO7L,QAAS,EACzC9B,GAAKkH,EAAM,EAAID,KAAyB,QAAf8G,EAAAnM,KAAK6L,kBAAU,IAAAM,OAAA,EAAAA,EAAEL,IAAIuC,KAAKrK,SAAU,GAC9D2M,GAAmB,QAAVA,GACW,QAAvBpC,EAAAvO,KAAKoR,QAAQC,IAAI,cAAM,IAAA9C,GAAAA,EAAEiF,UAAU,EAAGD,EAAG5H,EAAGvN,GAEzCuS,GAAmB,WAAVA,GACc,QAA1B8C,EAAAzT,KAAKoR,QAAQC,IAAI,iBAAS,IAAAoC,GAAAA,EAAED,UAAU,EAAGD,EAAG5H,EAAGvN,EAEnD,CAKO,QAAAsV,CAAS/C,GACd,IAAKA,GAAmB,QAAVA,EAAiB,CAC7B,MAAMe,EAAM1R,KAAKoR,QAAQC,IAAI,OAC7BK,SAAAA,EAAK8B,UAAU,EAAG,EAAG9B,EAAI3F,OAAO7L,MAAOwR,EAAI3F,OAAO/H,OACpD,CACA,IAAK2M,GAAmB,WAAVA,EAAoB,CAChC,MAAMe,EAAM1R,KAAKoR,QAAQC,IAAI,UAC7BK,SAAAA,EAAK8B,UAAU,EAAG,EAAG9B,EAAI3F,OAAO7L,MAAOwR,EAAI3F,OAAO/H,OACpD,CACF,CAKO,IAAA2P,CAAKC,EAAqBC,EAAgBC,EAAaC,EAAaC,EAAgB,GACzF,MAAMtC,EAAM1R,KAAKoR,QAAQC,IAAIuC,EAAQjD,OACrC,IAAKe,EACH,OAEF,MAAMxR,MAAEA,EAAK8D,OAAEA,GAAWhE,KAAKoT,SAG/B,IAAe,IAAXlT,IAA4B,IAAZ8D,EAClB,OAGFhE,KAAKiU,cAAcL,EAAS1T,EAAO8D,GACnC,MAAM8M,EAAM8C,EAAQM,QACZhU,MAAOiU,EAAanQ,OAAQoQ,GAAiBR,EAAQS,eACvDrI,EAAOjQ,KAAKuI,KAAKwM,EAAI5Q,MAAQiU,GAE7BG,EAAMT,EAAS7H,EAAQmI,EACvBI,EAAKxY,KAAKoR,MAAM0G,EAAS7H,GAAQoI,EACjCI,EAAKV,EAAM5T,EACXuU,EAAKV,EAAM/P,EAGX0Q,EAAaV,EAAQG,EAAcG,EAAKxD,EAAI5Q,MAAQ4Q,EAAI5Q,MAAQoU,EAAKN,EAAQG,EAC7EQ,EAAcJ,EAAKH,EAAetD,EAAI9M,OAAS8M,EAAI9M,OAASuQ,EAAKH,EAMvE1C,EAAIkD,UACF9D,EACA/U,KAAKoR,MAAMmH,GAAKvY,KAAKoR,MAAMoH,GAAKxY,KAAKuI,KAAKoQ,GAAa3Y,KAAKuI,KAAKqQ,GACjE5Y,KAAKoR,MAAMqH,GAAKzY,KAAKoR,MAAMsH,GAAK1Y,KAAKuI,KAAKoQ,EAAaxU,EAAQiU,GAAcpY,KAAKuI,KAAKqQ,EAAc3Q,EAASoQ,GAElH,CAKO,WAAAS,CAAYjB,EAAqBC,GACtC,MAAM3T,MAAEA,EAAK8D,OAAEA,GAAWhE,KAAKoT,SAE/B,IAAe,IAAXlT,IAA4B,IAAZ8D,EAClB,OAEFhE,KAAKiU,cAAcL,EAAS1T,EAAO8D,GACnC,MAAM8M,EAAM8C,EAAQM,QACZhU,MAAOiU,EAAanQ,OAAQoQ,GAAiBR,EAAQS,eACvDrI,EAAOjQ,KAAKuI,KAAKwM,EAAI5Q,MAAQiU,GAC7BG,EAAMT,EAAS7H,EAAQmI,EACvBI,EAAKxY,KAAKoR,MAAM0G,EAAS7H,GAAQoI,EACjCM,EAAaP,EAAcG,EAAKxD,EAAI5Q,MAAQ4Q,EAAI5Q,MAAQoU,EAAKH,EAC7DQ,EAAcJ,EAAKH,EAAetD,EAAI9M,OAAS8M,EAAI9M,OAASuQ,EAAKH,EAEjErI,EAASuB,EAAcC,aAAavN,KAAKuR,SAAUxV,KAAKuI,KAAKoQ,EAAaxU,EAAQiU,GAAcpY,KAAKuI,KAAKqQ,EAAc3Q,EAASoQ,IACjI1C,EAAM3F,EAAO0B,WAAW,MAC9B,OAAIiE,GACFA,EAAIkD,UACF9D,EACA/U,KAAKoR,MAAMmH,GAAKvY,KAAKoR,MAAMoH,GAAKxY,KAAKoR,MAAMuH,GAAa3Y,KAAKoR,MAAMwH,GACnE,EAAG,EAAG5I,EAAO7L,MAAO6L,EAAO/H,QAEtB+H,QANT,CAQF,CAKO,eAAA+I,CAAgBhB,EAAaC,EAAaC,EAAgB,SAC/D,MAAMtC,EAAM1R,KAAKoR,QAAQC,IAAI,OAC7B,GAAIK,EAAK,CACP,MAAMxR,MAAEA,EAAK8D,OAAEA,GAAWhE,KAAKoT,SAG/B,IAAe,IAAXlT,IAA4B,IAAZ8D,EAClB,OAQF,GALKhE,KAAKkT,aAEClP,GAAUhE,KAAKkT,aAAclP,QACtChE,KAAKqT,mBAAmBrP,EAAS,GAFjChE,KAAKqT,mBAAmBtX,KAAKC,IAAIgI,EAAS,EAAC,MAIxChE,KAAKkT,aAAc,OACxBxB,EAAIkD,UACqB,QADZxI,EACXpM,KAAKiT,0BAAkB,IAAA7G,EAAAA,EAAIpM,KAAKkT,aAChCY,EAAM5T,EACL6T,EAAM/P,EAAU,EAAI,EAAI,EACzB9D,EAAQ8T,EACRhQ,EACA8P,EAAM5T,EACN6T,EAAM/P,EACN9D,EAAQ8T,EACRhQ,EAEJ,CACF,CAMO,aAAA2O,WACL,MAAMhH,GAAmB,QAAfS,EAAApM,KAAK6L,kBAAU,IAAAO,OAAA,EAAAA,EAAEN,IAAIC,OAAO7L,QAAS,EACzC9B,GAAmB,QAAfoP,EAAAxN,KAAK6L,kBAAU,IAAA2B,OAAA,EAAAA,EAAE1B,IAAIC,OAAO/H,SAAU,EAChD,IAAK,MAAM0N,KAAO1R,KAAKoR,QAAQ2D,SACzBrD,EAAI3F,OAAO7L,QAAUyL,GAAK+F,EAAI3F,OAAO/H,SAAW5F,IAClDsT,EAAI3F,OAAO7L,MAAQyL,EACnB+F,EAAI3F,OAAO/H,OAAS5F,EAG1B,CAKQ,aAAA6V,CAAce,EAAkBtP,EAAsBM,GAC5D,GAAIN,IAAiBsP,EAAKX,eAAenU,OAAS8F,IAAkBgP,EAAKX,eAAerQ,OACtF,OAEF,MAAQ9D,MAAO+U,EAAejR,OAAQkR,GAAmBF,EAAKG,aAC9D,GAAIzP,IAAiBuP,GAAiBjP,IAAkBkP,EAItD,OAHAF,EAAKd,OAASc,EAAKI,KACnBJ,EAAKX,eAAenU,MAAQ+U,OAC5BD,EAAKX,eAAerQ,OAASkR,GAG/B,MAAMG,EAActZ,KAAKuI,KAAK0Q,EAAKI,KAAMlV,MAAQwF,EAAeuP,GAC1DK,EAAevZ,KAAKuI,KAAK0Q,EAAKI,KAAMpR,OAASgC,EAAgBkP,GAEnE,GAAIG,EAAcC,EAAeN,EAAKI,KAAMlV,MAAQ8U,EAAKI,KAAMpR,OAI7D,OAHAgR,EAAKd,OAASc,EAAKI,KACnBJ,EAAKX,eAAenU,MAAQ+U,OAC5BD,EAAKX,eAAerQ,OAASkR,GAG/B,MAAMnJ,EAASuB,EAAcC,aAAavN,KAAKuR,SAAU8D,EAAaC,GAChE5D,EAAM3F,EAAO0B,WAAW,MAC1BiE,IACFA,EAAIkD,UAAUI,EAAKI,KAAO,EAAG,EAAGrJ,EAAO7L,MAAO6L,EAAO/H,QACrDgR,EAAKd,OAASnI,EACdiJ,EAAKX,eAAenU,MAAQwF,EAC5BsP,EAAKX,eAAerQ,OAASgC,EAEjC,CAKQ,KAAAsM,GACNtS,KAAK4S,eAAiB5S,KAAK8R,UAAUzF,MAAMuG,eAC3C5S,KAAK+S,gBAAkB/S,KAAK4S,eAAeI,YAAY9R,KAAKlB,KAAK4S,gBACjE5S,KAAK4S,eAAeI,YAAeuC,UACjC,IAAK,MAAMC,IAAO,IAAIxV,KAAKoR,QAAQqE,QACjCzV,KAAK8S,mBAAmB0C,GAEN,QAApBpJ,EAAApM,KAAK+S,uBAAe,IAAA3G,GAAAA,EAAE5E,KAAKxH,KAAK4S,eAAgB2C,GAEpD,CAEO,gBAAAG,CAAiB/E,EAAoB,eAE1C,IAAK3Q,KAAKuR,WAAavR,KAAK8R,UAAUzF,MAAMkG,cAE1C,YADAhV,QAAQC,KAAK,sFAGf,GAAIwC,KAAKoR,QAAQuE,IAAIhF,GACnB,OAEF,MAAM5E,EAASuB,EAAcC,aAC3BvN,KAAKuR,UAAyB,QAAfnF,EAAApM,KAAK6L,kBAAU,IAAAO,OAAA,EAAAA,EAAEN,IAAIC,OAAO7L,QAAS,GACrC,QAAfsN,EAAAxN,KAAK6L,kBAAU,IAAA2B,OAAA,EAAAA,EAAE1B,IAAIC,OAAO/H,SAAU,GAExC+H,EAAO6J,UAAUtO,IAAI,qBAAqBqJ,KAC1C,MAAM4B,EAAgBvS,KAAK8R,UAAUzF,MAAMkG,cAI3CA,EAAcsD,MAAMC,UAAY,UAClB,WAAVnF,GAKF5E,EAAO8J,MAAMjF,OAAS,KACtB2B,EAAcwD,aAAahK,EAAQwG,EAAcyD,cAKjDjK,EAAO8J,MAAMjF,OAAS,IACtB2B,EAAc0D,YAAYlK,IAE5B,MAAM2F,EAAM3F,EAAO0B,WAAW,KAAM,CAAEvQ,OAAO,IACxCwU,GAIL1R,KAAKoR,QAAQlO,IAAIyN,EAAOe,GACxB1R,KAAK0T,SAAS/C,IAJZ5E,EAAOmK,QAKX,CAEO,kBAAApD,CAAmBnC,EAAoB,OAC5C,MAAMe,EAAM1R,KAAKoR,QAAQC,IAAIV,GACzBe,IACFA,EAAI3F,OAAOmK,SACXlW,KAAKoR,QAAQ+E,OAAOxF,GAExB,CAEO,QAAAyF,CAASzF,GACd,OAAO3Q,KAAKoR,QAAQuE,IAAIhF,EAC1B,CAEQ,kBAAA0C,CAAmBrP,EAAM,UACR,QAAvBoI,EAAApM,KAAKiT,0BAAkB,IAAA7G,GAAAA,EAAE8B,QACzBlO,KAAKiT,wBAAqBjL,EAG1B,MAAMqO,EAAS,GACTC,EAAYhJ,EAAcC,aAAavN,KAAKuR,SAAU8E,EAAQrS,GAC9D0N,EAAM4E,EAAU7I,WAAW,KAAM,CAAEvQ,OAAO,IAChD,IAAKwU,EAAK,OACV,MAAMC,EAAUrE,EAAcmE,gBAAgBC,EAAK2E,EAAQrS,GACrD+M,EAAM,IAAI1T,YAAYsU,EAAQvM,KAAK9H,QACnCiZ,GAAQ,EAAAxX,EAAAzD,YAAW,EAAG,EAAG,GACzBkb,GAAQ,EAAAzX,EAAAzD,YAAW,IAAK,IAAK,KACnCyV,EAAIpL,KAAK4Q,GACT,IAAK,IAAIhD,EAAI,EAAGA,EAAIvP,IAAUuP,EAAG,CAC/B,MAAMkD,EAAQlD,EAAI,EACZpP,EAASoP,EAAI8C,EACnB,IAAK,IAAIK,EAAI,EAAGA,EAAIL,EAAQK,GAAK,EAC/B3F,EAAI5M,EAASuS,EAAID,GAASD,CAE9B,CACA9E,EAAIhE,aAAaiE,EAAS,EAAG,GAG7B,MAAMzR,EAASyW,OAAOzW,MAAQmW,EAAS,GAAK,IAAa,KACzDrW,KAAKkT,aAAe5F,EAAcC,aAAavN,KAAKuR,SAAUrR,EAAO8D,GACrE,MAAM4S,EAAO5W,KAAKkT,aAAazF,WAAW,KAAM,CAAEvQ,OAAO,IACzD,IAAK0Z,EAEH,YADA5W,KAAKkT,kBAAelL,GAGtB,IAAK,IAAIlK,EAAI,EAAGA,EAAIoC,EAAOpC,GAAKuY,EAC9BO,EAAKhC,UAAU0B,EAAWxY,EAAG,GAE/B,MAAM+Y,EAAc7W,KAAKkT,aACzB5F,EAAcQ,kBAAkB+I,GAAaxV,KAAKyV,IAC5C9W,KAAKkT,eAAiB2D,EAAaC,SAAAA,EAAQ5I,QAC1ClO,KAAKiT,mBAAqB6D,IAC9B3I,MAAM,OACX,CAEA,YAAWoD,SACT,OAA+C,QAAxCnF,EAAApM,KAAK8R,UAAUzF,MAAMC,2BAAmB,IAAAF,OAAA,EAAAA,EAAE2K,OAAOxF,QAC1D,2HC3aF,MAAAlI,EAAArK,EAAA,KASanE,EAAA+Q,kBAA+B,CAC1C1L,MAAO,EACP8D,OAAQ,IASV,MAAMgT,EAEJ,OAAWC,GACT,OAAIjX,KAAKkX,QAEQ,UAAZlX,KAAKmX,KACLnX,KAAKoX,gBAAkB,GAGrBpX,KAAKmX,IACd,CACA,OAAWF,CAAInb,GAAiBkE,KAAKmX,KAAOrb,CAAO,CAEnD,kBAAWsb,GAET,OAAIpX,KAAKkX,OACP,GAEe,UAATlX,KAAKmX,OAAoC,EACnD,CACA,kBAAWC,CAAetb,GACxBkE,KAAKmX,OAAQ,UACbnX,KAAKmX,MAASrb,GAAS,GAAG,SAC5B,CAEA,kBAAWub,GACT,OAAmB,SAAZrX,KAAKmX,IACd,CACA,kBAAWE,CAAevb,GACxBkE,KAAKmX,OAAQ,SACbnX,KAAKmX,MAAgB,SAARrb,CACf,CAEA,0BAAWwb,GACT,MAAMC,GAAgB,WAATvX,KAAKmX,OAAmC,GACrD,OAAII,EAAM,EACK,WAANA,EAEFA,CACT,CACA,0BAAWD,CAAuBxb,GAChCkE,KAAKmX,MAAQ,UACbnX,KAAKmX,MAASrb,GAAS,GAAG,UAC5B,CAGA,SAAW0b,GACT,OAAOxX,KAAKkX,MACd,CACA,SAAWM,CAAM1b,GACfkE,KAAKkX,OAASpb,CAChB,CAEA,WAAAiE,CACEkX,EAAc,EACdO,EAAgB,EACTC,GAAU,EACV5D,GAAS,GADT7T,KAAAyX,QAAAA,EACAzX,KAAA6T,OAAAA,EAxDD7T,KAAAmX,KAAe,EA4CfnX,KAAAkX,OAAiB,EAcvBlX,KAAKmX,KAAOF,EACZjX,KAAKkX,OAASM,CAChB,CAEO,KAAAE,GASL,OAAO,IAAIV,EAAmBhX,KAAKmX,KAAMnX,KAAKkX,OAAQlX,KAAKyX,QAASzX,KAAK6T,OAC3E,CAEO,OAAA8D,GACL,OAA0B,IAAnB3X,KAAKoX,gBAA0D,IAAhBpX,KAAKkX,SAAkC,IAAlBlX,KAAKyX,OAClF,EAEF,MAAMG,EAAc,IAAIZ,iBAUxB,MAkBE,WAAAjX,CACU+R,EACA5H,EACAhI,GAFAlC,KAAA8R,UAAAA,EACA9R,KAAAkK,UAAAA,EACAlK,KAAAkC,MAAAA,EAnBFlC,KAAA6X,QAAmC,IAAI7F,IAEvChS,KAAA8X,QAAU,EAEV9X,KAAA+X,UAAY,EAEZ/X,KAAAgY,eAAgB,EAEhBhY,KAAAiY,iBAAkB,EAElBjY,KAAAkY,YAAsB,KAW5B,IACElY,KAAKmY,SAASnY,KAAKkC,MAAMkW,aAC3B,CAAE,MAAOtQ,GACHA,aAAa1F,OACf7E,QAAQ8a,MAAMvQ,EAAEwQ,SAElB/a,QAAQC,KAAK,0BAA0BwC,KAAKuY,gBAC9C,CACAvY,KAAKwY,iBAAmB,CACtBxM,KAAMhM,KAAK8R,UAAU9F,KACrBC,KAAMjM,KAAK8R,UAAU7F,KAEzB,CAEO,OAAAtE,GACL3H,KAAKkL,OACP,CAEO,KAAAA,SACL,IAAK,MAAM8J,KAAQhV,KAAK6X,QAAQ9C,SACnB,QAAX3I,EAAA4I,EAAKyD,cAAM,IAAArM,GAAAA,EAAEzE,UAIf3H,KAAK6X,QAAQ/O,QACb9I,KAAKkK,UAAUwJ,UACjB,CAEO,QAAA6E,GACL,OAA0B,EAAnBvY,KAAKkY,YAAkB,GAChC,CAEO,QAAAC,CAASrc,GACd,GAAIA,EAAQ,IAAOA,EAAQ,IACzB,MAAM4c,WAAW,qEAEnB1Y,KAAKkY,YAAepc,EAAQ,EAAI,MAAa,EAC7CkE,KAAK2Y,aAAa,EACpB,CAEO,QAAAC,GACL,OAAiC,EAA1B5Y,KAAK6Y,mBAAyB,GACvC,CAEQ,gBAAAA,GACN,IAAIC,EAAe,EACnB,IAAK,MAAM9D,KAAQhV,KAAK6X,QAAQ9C,SAC1BC,EAAKI,OACP0D,GAAgB9D,EAAKI,KAAKlV,MAAQ8U,EAAKI,KAAKpR,OACxCgR,EAAKd,QAAUc,EAAKd,SAAWc,EAAKI,OACtC0D,GAAgB9D,EAAKd,OAAOhU,MAAQ8U,EAAKd,OAAOlQ,SAItD,OAAO8U,CACT,CAEQ,OAAAC,CAAQC,SACd,MAAMhE,EAAOhV,KAAK6X,QAAQxG,IAAI2H,GACzBhE,IACLhV,KAAK6X,QAAQ1B,OAAO6C,GAEhBjC,OAAOkC,aAAejE,EAAKI,gBAAgB6D,aAC7CjE,EAAKI,KAAKlH,QAEO,QAAnB9B,EAAApM,KAAKkZ,sBAAc,IAAA9M,GAAAA,EAAA5E,KAAnBxH,KAAsBgZ,GACxB,CAKO,aAAAG,SAEL,MAAMC,EAAO,GACb,IAAK,MAAOJ,EAAIhE,KAAShV,KAAK6X,QAAQwB,UACZ,cAApBrE,EAAKsE,aACI,QAAXlN,EAAA4I,EAAKyD,cAAM,IAAArM,GAAAA,EAAEzE,UACbyR,EAAK1a,KAAKsa,IAGd,IAAK,MAAMA,KAAMI,EACfpZ,KAAK+Y,QAAQC,GAGfhZ,KAAKiY,iBAAkB,EACvBjY,KAAKgY,eAAgB,CACvB,CAMO,WAAAuB,CAAYP,SACjB,MAAMhE,EAAOhV,KAAK6X,QAAQxG,IAAI2H,GAC1BhE,IACS,QAAX5I,EAAA4I,EAAKyD,cAAM,IAAArM,GAAAA,EAAEzE,UACb3H,KAAK+Y,QAAQC,GAEjB,CAaO,QAAArL,CAASmD,EAAsChQ,WAEpDd,KAAK2Y,aAAa7H,EAAI5Q,MAAQ4Q,EAAI9M,QAGlC,IAAIoP,EAAWpT,KAAKkK,UAAUkJ,UACN,IAApBA,EAASlT,QAAqC,IAArBkT,EAASpP,SACpCoP,EAAWvY,EAAA+Q,mBAEb,MAAMI,EAAOjQ,KAAKuI,KAAKwM,EAAI5Q,MAAQkT,EAASlT,OACtC+L,EAAOlQ,KAAKuI,KAAKwM,EAAI9M,OAASoP,EAASpP,QAEvCyT,IAAYzX,KAAK8X,QAEjBxa,EAAS0C,KAAK8R,UAAUzF,MAAM/O,OAC9Bkc,EAAWxZ,KAAK8R,UAAU9F,KAC1ByN,EAAWzZ,KAAK8R,UAAU7F,KAC1ByN,EAAUpc,EAAOoZ,EACjBiD,EAAUrc,EAAOiW,EACvB,IAAIpP,EAASuV,EACTE,EAAY,EAEX9Y,EAAK4P,YACRpT,EAAOoZ,EAAI,EACXpZ,EAAOiW,EAAI,EACXpP,EAAS,GAGXnE,KAAK8R,UAAUzF,MAAMwN,cAAcC,iBAAiBC,UAAUzc,EAAOiW,GACrE,IAAK,IAAIQ,EAAM,EAAGA,EAAM9H,IAAQ8H,EAAK,CACnC,MAAMiG,EAAO1c,EAAO2c,MAAM5I,IAAI/T,EAAOiW,EAAIjW,EAAO4c,OAChD,IAAK,IAAIpG,EAAM,EAAGA,EAAM9H,KAClB7H,EAAS2P,GAAO0F,KADU1F,EAE9B9T,KAAKma,aAAaH,EAAwB7V,EAAS2P,EAAK2D,EAAS1D,EAAM/H,EAAO8H,GAC9E8F,IAEF,GAAI9Y,EAAK4P,UACHqD,EAAM9H,EAAO,GAAGjM,KAAK8R,UAAUzF,MAAMwN,cAAcO,gBAEvD,KAAM9c,EAAOiW,GAAKkG,EAAU,MAE9Bnc,EAAOoZ,EAAIvS,CACb,CACAnE,KAAK8R,UAAUzF,MAAMwN,cAAcC,iBAAiBC,UAAUzc,EAAOiW,GAGjEzS,EAAK4P,UACgB,QAAnB5P,EAAK+P,UACPvT,EAAOoZ,EAAI3a,KAAKE,IAAIkI,EAAS6H,EAAMwN,GAEnClc,EAAOoZ,EAAIvS,GAGb7G,EAAOoZ,EAAIgD,EACXpc,EAAOiW,EAAIoG,GAIb,MAAMP,EAAO,GACb,IAAK,MAAOJ,EAAIhE,KAAShV,KAAK6X,QAAQwB,UAChCrE,EAAK4E,UAAY,IACR,QAAXxN,EAAA4I,EAAKyD,cAAM,IAAArM,GAAAA,EAAEzE,UACbyR,EAAK1a,KAAKsa,IAGd,IAAK,MAAMA,KAAMI,EACfpZ,KAAK+Y,QAAQC,GAKf,MAAMqB,EAAYra,KAAK8R,UAAUwI,eAAe,GAChDD,SAAAA,EAAWE,UAAU,KACNva,KAAK6X,QAAQxG,IAAIoG,IAE5BzX,KAAK+Y,QAAQtB,KAMyB,cAAtCzX,KAAK8R,UAAUxU,OAAOkd,OAAO3Q,MAC/B7J,KAAKya,oBAIP,MAAM7G,EAAsB,CAC1BwB,KAAMtE,EACNqE,aAAc/B,EACdc,OAAQpD,EACRuD,eAAc7X,OAAA2F,OAAA,GAAOiR,GACrBqF,OAAQ4B,QAAarS,EACrB4R,YACAN,WAAYtZ,KAAK8R,UAAUxU,OAAOkd,OAAO3Q,KACzC8G,MAAO7P,EAAK6P,MACZC,OAAQ9P,EAAK8P,QAMf,OAFA5Q,KAAK6X,QAAQ3U,IAAIuU,EAAS7D,GACT,QAAjBpG,EAAAxN,KAAK0a,oBAAY,IAAAlN,GAAAA,EAAAhG,KAAjBxH,MACOyX,CACT,CAQO,MAAAkD,CAAOC,WAEZ,IAAIC,GAAe,EACfC,GAAkB,EACtB,IAAK,MAAM9F,KAAQhV,KAAK6X,QAAQ9C,SAM9B,GALmB,WAAfC,EAAKrE,MACPmK,GAAkB,EAElBD,GAAe,EAEbA,GAAgBC,EAAiB,MAIvC,GAAID,IAAiB7a,KAAKkK,UAAUkM,SAAS,SAC3CpW,KAAKkK,UAAUwL,iBAAiB,QAC3B1V,KAAKkK,UAAUkM,SAAS,QAAQ,OAUvC,GARI0E,IAAoB9a,KAAKkK,UAAUkM,SAAS,WAC9CpW,KAAKkK,UAAUwL,iBAAiB,UAIlC1V,KAAKkK,UAAUyI,iBAGV3S,KAAK6X,QAAQ9N,KAYhB,OAXK/J,KAAKgY,gBACRhY,KAAKkK,UAAUwJ,WACf1T,KAAKgY,eAAgB,EACrBhY,KAAKiY,iBAAkB,GAErBjY,KAAKkK,UAAUkM,SAAS,QAC1BpW,KAAKkK,UAAU4I,mBAAmB,YAEhC9S,KAAKkK,UAAUkM,SAAS,WAC1BpW,KAAKkK,UAAU4I,mBAAmB,YAMjC+H,GAAgB7a,KAAKkK,UAAUkM,SAAS,SAC3CpW,KAAKkK,UAAUwJ,SAAS,OACxB1T,KAAKkK,UAAU4I,mBAAmB,SAE/BgI,GAAmB9a,KAAKkK,UAAUkM,SAAS,YAC9CpW,KAAKkK,UAAUwJ,SAAS,UACxB1T,KAAKkK,UAAU4I,mBAAmB,WAIhC9S,KAAKiY,kBACPjY,KAAKkK,UAAUwJ,WACf1T,KAAKgY,eAAgB,EACrBhY,KAAKiY,iBAAkB,GAGzB,MAAM5S,MAAEA,EAAKC,IAAEA,GAAQsV,EACjBtd,EAAS0C,KAAK8R,UAAUzF,MAAM/O,OAC9B0O,EAAOhM,KAAK8R,UAAUzF,MAAML,KAGlChM,KAAKkK,UAAUoJ,WAAWjO,EAAOC,GAGjC,MAAMyV,EAAgG,GAChGC,EAAkE,GAGxE,IAAK,IAAIjH,EAAM1O,EAAO0O,GAAOzO,IAAOyO,EAAK,CACvC,MAAMiG,EAAO1c,EAAO2c,MAAM5I,IAAI0C,EAAMzW,EAAO2d,OAC3C,IAAKjB,EAAM,OACX,IAAK,IAAIlG,EAAM,EAAGA,EAAM9H,IAAQ8H,EAC9B,GAAmB,UAAfkG,EAAKkB,MAAMpH,GAA6B,CAC1C,IAAIhM,EAAiD,QAAhDsE,EAAwB4N,EAAKmB,eAAerH,UAAI,IAAA1H,EAAAA,EAAIwL,EACzD,MAAMH,EAAU3P,EAAE2P,QAClB,QAAgBzP,IAAZyP,IAAsC,IAAbA,EAC3B,SAEF,MAAM7D,EAAU5T,KAAK6X,QAAQxG,IAAIoG,GACjC,IAAkB,IAAd3P,EAAE+L,OAAe,CACnB,MAAMuH,EAAYtT,EAAE+L,OACdwH,EAAWvH,EACjB,IAAIE,EAAQ,EAOZ,OACIF,EAAM9H,GACW,UAAfgO,EAAKkB,MAAMpH,KACXhM,EAA4B,QAA3B0F,EAAGwM,EAAKmB,eAAerH,UAAI,IAAAtG,EAAAA,EAAIoK,IAChC9P,EAAE2P,UAAYA,GACd3P,EAAE+L,SAAWuH,EAAYpH,GAE7BA,IAEFF,IACIF,EACEA,EAAQM,QACV6G,EAAUrc,KAAK,CAAEkV,UAASC,OAAQuH,EAAWtH,IAAKuH,EAAUtH,MAAKC,UAE1DhU,KAAKkC,MAAMiR,iBACpB6H,EAAiBtc,KAAK,CAAEoV,IAAKuH,EAAUtH,MAAKC,UAE9ChU,KAAKgY,eAAgB,CACvB,CACF,CAEJ,CAGA+C,EAAUO,KAAK,CAAC5f,EAAGD,IAAMC,EAAEkY,QAAQhD,OAASnV,EAAEmY,QAAQhD,QAGtD,IAAK,MAAMpJ,KAAQwT,EACjBhb,KAAKkK,UAAU4K,gBAAgBtN,EAAKsM,IAAKtM,EAAKuM,IAAKvM,EAAKwM,OAI1D,IAAK,MAAMxM,KAAQuT,EACjB/a,KAAKkK,UAAUyJ,KAAKnM,EAAKoM,QAASpM,EAAKqM,OAAQrM,EAAKsM,IAAKtM,EAAKuM,IAAKvM,EAAKwM,MAE5E,CAEO,cAAAuH,CAAe1O,WAEpB,IAAK7M,KAAK6X,QAAQ9N,KAEhB,YADA/J,KAAKwY,iBAAmB3L,GAM1B,GAAI7M,KAAKwY,iBAAiBxM,MAAQa,EAAQb,KAExC,YADAhM,KAAKwY,iBAAmB3L,GAK1B,MAAMvP,EAAS0C,KAAK8R,UAAUzF,MAAM/O,OAC9B2O,EAAO3O,EAAO2c,MAAMlc,OACpByd,EAASxb,KAAKwY,iBAAiBxM,KAAO,EAC5C,IAAK,IAAI+H,EAAM,EAAGA,EAAM9H,IAAQ8H,EAAK,CACnC,MAAMiG,EAAO1c,EAAO2c,MAAM5I,IAAI0C,GAC9B,GAAsB,UAAlBiG,EAAKkB,MAAMM,GAAgC,CAC7C,MAAM1T,EAAoD,QAAnD0F,EAAwBwM,EAAKmB,eAAeK,UAAO,IAAAhO,EAAAA,EAAIoK,EACxDH,EAAU3P,EAAE2P,QAClB,QAAgBzP,IAAZyP,IAAsC,IAAbA,EAC3B,SAEF,MAAM7D,EAAU5T,KAAK6X,QAAQxG,IAAIoG,GACjC,IAAK7D,EACH,SAGF,MAAM6H,EAAc1f,KAAKuI,OAAoB,QAAd8H,EAAAwH,EAAQM,cAAM,IAAA9H,OAAA,EAAAA,EAAElM,QAAS,GAAK0T,EAAQS,eAAenU,OACpF,GAAK4H,EAAE+L,OAAS4H,EAAe,GAAKA,EAClC,SAGF,IAAIC,GAAU,EACd,IAAK,IAAIC,EAAWH,EAAS,EAAGG,EAAW9O,EAAQb,OAAQ2P,EACzD,GAAmD,QAA/C3B,EAAK4B,MAAc,EAARD,EAAoB,GAA6C,CAC9ED,GAAU,EACV,KACF,CAEF,GAAIA,EACF,SAGF,MAAMpW,EAAMvJ,KAAKE,IAAI4Q,EAAQb,KAAMyP,EAAe3T,EAAE+L,OAAS4H,EAAeD,GAC5E,IAAIK,EAAW/T,EAAE+L,OACjB,IAAK,IAAIiI,EAAYN,EAAS,EAAGM,EAAYxW,IAAOwW,EAClD9b,KAAKma,aAAaH,EAAwB8B,EAAWrE,IAAWoE,GAChEjI,EAAQgG,WAEZ,CACF,CAEA5Z,KAAKwY,iBAAmB3L,CAC1B,CAKO,oBAAAkP,CAAqBrF,EAAWnD,aACrC,MACMyG,EADSha,KAAK8R,UAAUzF,MAAM/O,OAChB2c,MAAM5I,IAAIkC,GAC9B,GAAIyG,GAAqB,UAAbA,EAAKkB,MAAMxE,GAA2B,CAChD,MAAM5O,EAA+C,QAA9CqE,EAAwB6N,EAAKmB,eAAezE,UAAE,IAAAvK,EAAAA,EAAIyL,EACzD,GAAI9P,EAAE2P,UAA0B,IAAf3P,EAAE2P,QAAgB,CACjC,MAAMrC,EAAkC,QAA3BhJ,EAAApM,KAAK6X,QAAQxG,IAAIvJ,EAAE2P,gBAAQ,IAAArL,OAAA,EAAAA,EAAEgJ,KAC1C,GAAI2B,OAAOkC,aAAe7D,aAAgB6D,YAAa,CACrD,MAAMlN,EAAS1C,EAAAiE,cAAcC,aAAawJ,OAAOxF,SAAU6D,EAAKlV,MAAOkV,EAAKpR,QAE5E,OADuB,QAAvBwJ,EAAAzB,EAAO0B,WAAW,aAAK,IAAAD,GAAAA,EAAEoH,UAAUQ,EAAM,EAAG,EAAGA,EAAKlV,MAAOkV,EAAKpR,QACzD+H,CACT,CACA,OAAOqJ,CACT,CACF,CACF,CAKO,uBAAA4G,CAAwBtF,EAAWnD,SACxC,MACMyG,EADSha,KAAK8R,UAAUzF,MAAM/O,OAChB2c,MAAM5I,IAAIkC,GAC9B,GAAIyG,GAAqB,UAAbA,EAAKkB,MAAMxE,GAA2B,CAChD,MAAM5O,EAA+C,QAA9CsE,EAAwB4N,EAAKmB,eAAezE,UAAE,IAAAtK,EAAAA,EAAIwL,EACzD,GAAI9P,EAAE2P,UAA0B,IAAf3P,EAAE2P,UAAgC,IAAd3P,EAAE+L,OAAe,CACpD,MAAMmB,EAAOhV,KAAK6X,QAAQxG,IAAIvJ,EAAE2P,SAChC,GAAIzC,EACF,OAAOhV,KAAKkK,UAAU2K,YAAYG,EAAMlN,EAAE+L,OAE9C,CACF,CACF,CAIQ,YAAA8E,CAAasD,SACnB,MAAMC,EAAOlc,KAAK6Y,mBAClB,IAAIsD,EAAUD,EACd,KAAOlc,KAAKkY,YAAciE,EAAUF,GAAQjc,KAAK6X,QAAQ9N,MAAM,CAC7D,MAAMiL,EAAOhV,KAAK6X,QAAQxG,MAAMrR,KAAK+X,WACjC/C,GAAQA,EAAKI,OACf+G,GAAWnH,EAAKI,KAAKlV,MAAQ8U,EAAKI,KAAKpR,OACnCgR,EAAKd,QAAUc,EAAKI,OAASJ,EAAKd,SACpCiI,GAAWnH,EAAKd,OAAOhU,MAAQ8U,EAAKd,OAAOlQ,QAElC,QAAXoI,EAAA4I,EAAKyD,cAAM,IAAArM,GAAAA,EAAEzE,UACb3H,KAAK+Y,QAAQ/Y,KAAK+X,WAEtB,CACA,OAAOmE,EAAOC,CAChB,CAEQ,YAAAhC,CAAaH,EAAsBtD,EAAWe,EAAiB5D,GACrE,GAAuC,UAAnCmG,EAAK4B,MAAO,EAADlF,EAAa,GAAoC,CAC9D,MAAM0F,EAAMpC,EAAKmB,eAAezE,GAChC,GAAI0F,EAAK,CACP,QAAoBpU,IAAhBoU,EAAI3E,QAAuB,CAI7B,MAAM4E,EAAUrc,KAAK6X,QAAQxG,IAAI+K,EAAI3E,SAOrC,OANI4E,GAEFA,EAAQzC,YAEVwC,EAAI3E,QAAUA,OACd2E,EAAIvI,OAASA,EAEf,CAGA,YADAmG,EAAKmB,eAAezE,GAAK,IAAIM,EAAmBoF,EAAInF,IAAKmF,EAAI5E,MAAOC,EAAS5D,GAE/E,CACF,CAEAmG,EAAK4B,MAAO,EAADlF,EAAa,IAAW,UACnCsD,EAAKmB,eAAezE,GAAK,IAAIM,EAAmB,EAAG,EAAGS,EAAS5D,EACjE,CAEQ,iBAAA4G,WAEN,IAAK,MAAMzF,KAAQhV,KAAK6X,QAAQ9C,SACN,cAApBC,EAAKsE,aACPtE,EAAK4E,UAAY,GAIrB,MAAMtc,EAAS0C,KAAK8R,UAAUzF,MAAM/O,OACpC,IAAK,IAAIiW,EAAI,EAAGA,EAAIvT,KAAK8R,UAAU7F,OAAQsH,EAAG,CAC5C,MAAMyG,EAAO1c,EAAO2c,MAAM5I,IAAIkC,GAC9B,GAAKyG,EAGL,IAAK,IAAItD,EAAI,EAAGA,EAAI1W,KAAK8R,UAAU9F,OAAQ0K,EACzC,GAAuC,UAAnCsD,EAAK4B,MAAO,EAADlF,EAAa,GAAoC,CAC9D,MAAM4F,EAA8B,QAAtBlQ,EAAA4N,EAAKmB,eAAezE,UAAE,IAAAtK,OAAA,EAAAA,EAAEqL,QACtC,GAAI6E,EAAO,CACT,MAAMtH,EAAOhV,KAAK6X,QAAQxG,IAAIiL,GAC1BtH,GACFA,EAAK4E,WAET,CACF,CAEJ,CAEA,MAAMR,EAAO,GACb,IAAK,MAAOJ,EAAIhE,KAAShV,KAAK6X,QAAQwB,UACZ,cAApBrE,EAAKsE,YAA+BtE,EAAK4E,YAChC,QAAXpM,EAAAwH,EAAKyD,cAAM,IAAAjL,GAAAA,EAAE7F,UACbyR,EAAK1a,KAAKsa,IAGd,IAAK,MAAMA,KAAMI,EACfpZ,KAAK+Y,QAAQC,EAEjB,sFCnpBF,MAAAja,EAAAC,EAAA,KAEAqK,EAAArK,EAAA,KAEAud,EAAAvd,EAAA,KACAC,EAAAD,EAAA,KAMMwd,EAAkBzd,EAAAnC,iBACxB4f,EAAgBtZ,IAAInE,EAAAjC,qBAKpB,MACM2f,EAAe,IAAIzK,IACzB,IAAI0K,GAAa,EAgBjB,SAASC,EAAexW,EAAc3F,SAChC2F,EAAIvB,YA5Bc,SA6BpBuB,EAAIlC,UAEN,MAAM2Y,EAAoC,QAAhCxQ,EAAGqQ,EAAapL,IAAI7Q,UAAY,IAAA4L,EAAAA,EAAI,GAC1CwQ,EAAK7e,OAvBe,IAwBtB6e,EAAKle,KAAKyH,GACVsW,EAAavZ,IAAI1C,EAAaoc,GAElC,CA8IA,SAASC,EAAUpf,GACjB,OAAIsB,EAAA5B,WAAmBM,GACP,IAARA,IAAiB,IAAMA,IAAU,EAAI,MAAS,IAAMA,IAAU,GAAK,MAAS,EAAIA,IAAU,GAAK,GACzG,gBA9IA,MAQE,WAAAsC,CACmBmC,EACAiI,EACAC,GArCrB,IAA0B5J,EAmCLR,KAAAkC,MAAAA,EACAlC,KAAAmK,SAAAA,EACAnK,KAAAoK,cAAAA,EAVXpK,KAAA8c,MAAQ,EACR9c,KAAAsK,UAAW,EAEXtK,KAAA+c,gBAAkB,EAET/c,KAAAgD,SAAW,IAAI3F,YAAY4B,EAAAS,OAAOkB,cAOjDZ,KAAKgD,SAASE,IAAIsZ,GAvCIhc,EAwCmB,EAAxBR,KAAKkC,MAAM+K,WAvC1ByP,IACJA,GAAa,GAEb,EAAAH,EAAAzd,cAAa,CAAE0B,cAAa9C,QAAS8e,IAAmBnb,KACtDlD,GAAKwe,EAAexe,EAAGqC,GACvB,KAAQkc,GAAa,IAmCvB,CAEO,KAAAxR,GACLlL,KAAKgd,iBACLhd,KAAKgD,SAAS2C,KAAK,GACnB3F,KAAKgD,SAASE,IAAIsZ,EACpB,CAEO,IAAAS,CAAKC,SACVld,KAAK8c,MAAQ,EACb9c,KAAKsK,UAAW,EAChBtK,KAAKgd,iBACL,MAAMxc,EAAsC,EAAxBR,KAAKkC,MAAM+K,WAC/B,IACEjN,KAAK+K,KA7CX,SAAwBvK,WACtB,OAA2C,QAA3CgN,EAAoC,QAA7BpB,EAAAqQ,EAAapL,IAAI7Q,UAAY,IAAA4L,OAAA,EAAAA,EAAE+Q,aAAK,IAAA3P,EAAAA,EAAI,IAAI+O,EAAA1d,QAAQ,CAAE2B,cAAa9C,QAAS8e,GACrF,CA2CkBY,CAAe5c,EAC7B,CAAE,MAAOsH,GAIP,OAFAvK,QAAQC,KAAK,uCAAuCsK,UACpD9H,KAAKsK,UAAW,EAElB,CACAtK,KAAK+c,gBAAkBvc,EACvB,MAAME,EAAiC,IAArBwc,EAAOA,OAAO,GAAW,EAwE/C,SAAyBG,EAAqBC,GAC5C,IAAIC,EAAK,EACT,IAAKD,EAGH,OAAOC,EAET,GAAIF,EAAKG,YACP,GAAIH,EAAKI,cACPF,EAAKV,EAAUS,EAAOI,WAAWC,WAC5B,GAAIN,EAAKO,UAAW,CACzB,MAAMC,EAAKR,EAAKtd,YAAqC+d,WAAWT,EAAKU,cACrER,GAAK,EAAAxe,EAAAzD,eAAcuiB,EACrB,MACEN,EAAKV,EAAUS,EAAOU,KAAKX,EAAKU,cAAcJ,WAGhD,GAAIN,EAAKY,cACPV,EAAKV,EAAUS,EAAOY,WAAWP,WAC5B,GAAIN,EAAKc,UAAW,CACzB,MAAMN,EAAKR,EAAKtd,YAAqC+d,WAAWT,EAAKe,cACrEb,GAAK,EAAAxe,EAAAzD,eAAcuiB,EACrB,MACEN,EAAKV,EAAUS,EAAOU,KAAKX,EAAKe,cAAcT,MAGlD,OAAOJ,CACT,CAnGmDc,CAC7Cre,KAAKoK,cAAciC,MAAMwN,cAAcyE,aACD,QAAtClS,EAAApM,KAAKoK,cAAciC,MAAMkS,qBAAa,IAAAnS,OAAA,EAAAA,EAAEkR,QAC1Ctd,KAAK+K,KAAK1H,KAAK3C,EAAWV,KAAKgD,SAAUhD,KAAKkC,MAAMsc,kBACtD,CAEQ,cAAAxB,GACN,MAAM7W,EAAMnG,KAAK+K,KACZ5E,IACLnG,KAAK+K,UAAO/C,EACZhI,KAAKgD,SAASE,IAAIiD,EAAIzI,SACtBif,EAAexW,EAAKnG,KAAK+c,iBAC3B,CAEO,GAAA5R,CAAI/F,EAAmBC,EAAeC,GAC3C,IAAItF,KAAKsK,UAAatK,KAAK+K,KAA3B,CAIA,GADA/K,KAAK8c,OAASxX,EAAMD,EAChBrF,KAAK8c,MAAQ9c,KAAKkC,MAAMuc,eAI1B,OAHAlhB,QAAQC,KAAK,kCACbwC,KAAKsK,UAAW,OAChBtK,KAAK+K,KAAK9G,UAGZ,IACEjE,KAAK+K,KAAKnM,OAAOwG,EAAMC,EAAOC,EAChC,CAAE,MAAOwC,GACPvK,QAAQC,KAAK,uCAAuCsK,KACpD9H,KAAKsK,UAAW,EAChBtK,KAAK+K,KAAK9G,SACZ,CAdA,CAeF,CAEO,MAAAya,CAAOjT,GACZ,IACE,OAAOzL,KAAK2e,QAAQlT,EACtB,SACEzL,KAAKgd,gBACP,CACF,CAEQ,OAAA2B,CAAQlT,SACd,GAAIzL,KAAKsK,WAAamB,IAAYzL,KAAK+K,KACrC,OAAO,EAGT,MAAM7K,EAAQF,KAAK+K,KAAK7K,MAClB8D,EAAShE,KAAK+K,KAAK/G,OAGzB,IAAK9D,IAAU8D,EAIb,OAHIA,GACFhE,KAAKmK,SAASyU,cAAc5a,IAEvB,EAGT,MAAM+H,EAAS1C,EAAAiE,cAAcC,kBAAavF,EAAW9H,EAAO8D,GAG5D,OAFuB,QAAvBoI,EAAAL,EAAO0B,WAAW,aAAK,IAAArB,GAAAA,EAAEsB,aAAa,IAAIN,UAAUpN,KAAK+K,KAAK9E,MAAyC/F,EAAO8D,GAAS,EAAG,GAC1HhE,KAAKmK,SAASwD,SAAS5B,IAChB,CACT,2FCpJF,MAAAzC,EAAAtK,EAAA,yBAWA,MAEE,WAAAe,CACmBoK,EACAjI,EACAgI,EACA4H,GAHA9R,KAAAmK,SAAAA,EACAnK,KAAAkC,MAAAA,EACAlC,KAAAkK,UAAAA,EACAlK,KAAA8R,UAAAA,EALX9R,KAAAyQ,cAA+B,CAAEC,WAAW,EAAMC,MAAO,MAAOC,OAAQ,EAAGC,UAAW,QAM3F,CAMI,QAAAlD,CAASmD,GACd9Q,KAAKyQ,cAAcC,UAAY1Q,KAAKkC,MAAM2c,eAC1C7e,KAAKmK,SAASwD,SAASmD,EAAK9Q,KAAKyQ,cACnC,CAOO,aAAAmO,CAAc5a,GACnB,GAAIhE,KAAKkC,MAAM2c,eAAgB,CAC7B,IAAIzL,EAAWpT,KAAKkK,UAAUkJ,UACN,IAApBA,EAASlT,QAAqC,IAArBkT,EAASpP,SACpCoP,EAAW9J,EAAAsC,mBAEb,MAAMK,EAAOlQ,KAAKuI,KAAKN,EAASoP,EAASpP,QACzC,IAAK,IAAIlG,EAAI,EAAGA,EAAImO,IAAQnO,EAC1BkC,KAAK8R,UAAUzF,MAAMwN,cAAcO,UAEvC,CACF,iLC3CF,MAAA/Q,EAAArK,EAAA,KACAsK,EAAAtK,EAAA,KACA2K,EAAA3K,EAAA,KAEAuK,EAAAC,EAAAxK,EAAA,MACA8f,EAAA9f,EAAA,4BA0BA,MAiCE,WAAAe,CACmBmC,EACAgI,EACA6U,EACA3U,GAHApK,KAAAkC,MAAAA,EACAlC,KAAAkK,UAAAA,EACAlK,KAAA+e,cAAAA,EACA/e,KAAAoK,cAAAA,EApCXpK,KAAAsK,UAAW,EACXtK,KAAAqK,YAAc,EACdrK,KAAAgf,cAAe,EAEfhf,KAAAif,eAAuC,KAOvCjf,KAAAkf,gBAAiB,EAGjBlf,KAAAmf,aAAe,IAAI9hB,YAAW,KAC9B2C,KAAAof,eAAiB,EAGjBpf,KAAAqf,kBAAoB,EACpBrf,KAAAsf,kBAAoB,EAGpBtf,KAAAuf,eAAuC,KAIvCvf,KAAAwf,sBAA2D,IAAIxN,IAarEhS,KAAKyf,iBAAmB1jB,KAAKuI,KAAiC,EAA5BtE,KAAKkC,MAAMwd,eAAqB,GAElE1f,KAAK2f,qBAAuB5jB,KAAKE,IAAG,QAAiC+D,KAAKyf,iBAC5E,CAEO,KAAAvU,GACLlL,KAAKqK,cACLrK,KAAK4f,qBACD5f,KAAKif,iBACPjf,KAAKif,eAAehb,UACpBjE,KAAKif,eAAiB,MAExBjf,KAAK+e,cAAc7T,OACrB,CAEO,OAAAvD,GACL3H,KAAKkL,OACP,CAEQ,mBAAA2U,CAAoBrK,GAC1BxV,KAAKwf,sBAAsBrJ,OAAOX,GAC9BxV,KAAK8f,kBAAoBtK,IAC3BxV,KAAK8f,qBAAkB9X,EAE3B,CAEQ,kBAAA4X,GACN,IAAK,MAAMG,KAAW/f,KAAKwf,sBAAsBzK,SAC/CgL,EAAQC,QAAQ/b,UAElBjE,KAAKwf,sBAAsB1W,QAC3B9I,KAAK8f,qBAAkB9X,CACzB,CAEO,KAAA3C,GACLrF,KAAKsK,UAAW,EAChBtK,KAAKgf,cAAe,EACpBhf,KAAKkf,gBAAiB,EACtBlf,KAAKof,eAAiB,EACtBpf,KAAKuf,eAAiB,KAEtBvf,KAAKqf,kBAAoBrf,KAAKyf,iBAC9Bzf,KAAKsf,kBAAoB,EACzBtf,KAAKif,eAAiB,IACxB,CAEO,GAAA9T,CAAI/F,EAAmBC,EAAeC,SAC3C,IAAItF,KAAKsK,SAET,GAAKtK,KAAKkf,eAEH,CAEL,IAAIe,EAAa3a,EACjB,IAAK,IAAIxH,EAAIuH,EAAOvH,EAAIwH,EAAKxH,IAC3B,GAAW,KAAPsH,EAAKtH,GAA4B,CACnCkC,KAAKkf,gBAAiB,EACtBe,EAAaniB,EACb,KACF,CAIF,MAAMoiB,EAAaD,EAAa5a,EAChC,GAAIrF,KAAKof,eAAiBc,EAAU,IAElC,YADAlgB,KAAKsK,UAAW,GAMlB,GAHAtK,KAAKmf,aAAajc,IAAIkC,EAAKX,SAASY,EAAO4a,GAAajgB,KAAKof,gBAC7Dpf,KAAKof,gBAAkBc,GAElBlgB,KAAKkf,eAAgB,CAKxB,GAHAlf,KAAKuf,gBAAiB,EAAAT,EAAAqB,mBAAkBngB,KAAKogB,gCAGdpY,IAA3BhI,KAAKuf,eAAevG,SAAwDhR,IAApChI,KAAKuf,eAAec,YAG9D,OAFArgB,KAAKsgB,cAActgB,KAAKuf,eAAevG,GAAI,0CAAoE,QAA3B5M,EAAEpM,KAAKuf,eAAegB,aAAK,IAAAnU,EAAAA,EAAI,QACnHpM,KAAKsK,UAAW,GAKlB,GAA8B,MAA1BtK,KAAKuf,eAAeiB,OACtB,OAIF,MAAMC,EAAeR,EAAa,EAC9BQ,EAAenb,GACjBtF,KAAK0gB,eAAetb,EAAMqb,EAAcnb,EAE5C,CACF,MA3CEtF,KAAK0gB,eAAetb,EAAMC,EAAOC,EA4CrC,CAGQ,cAAAob,CAAetb,EAAmBC,EAAeC,2BACvD,GAAItF,KAAKsK,SAAU,OAKnB,MAAMqW,EAA4D,QAAlDlN,EAA0B,QAA1BlF,EAAsB,QAAnBnC,EAAApM,KAAKuf,sBAAc,IAAAnT,OAAA,EAAAA,EAAE4M,UAAE,IAAAzK,EAAAA,EAAIvO,KAAK8f,uBAAe,IAAArM,EAAAA,EAAI,EAChEsM,EAAU/f,KAAKwf,sBAAsBnO,IAAIsP,GACzCC,EAA+C,QAA5BC,EAAGd,aAAO,EAAPA,EAASe,wBAAgB,IAAAD,EAAAA,EAAI,EAGzD,GAFA7gB,KAAKsf,mBAAqBha,EAAMD,EACFub,EAAsB5gB,KAAKsf,kBAC7Btf,KAAKqf,kBAAmB,CAClD,MAAM0B,EAAsC,QAAtBC,EAAGhhB,KAAKif,sBAAc,IAAA+B,EAAAA,EAAIjB,aAAO,EAAPA,EAASC,QASzD,OARIe,GACFA,EAAiB9c,UAEnBjE,KAAKif,eAAiB,KAClBc,GACF/f,KAAK6f,oBAAoBc,QAE3B3gB,KAAKsK,UAAW,EAElB,CAEA,IAAItK,KAAKgf,aAAT,CAKA,IAHIe,aAAO,EAAPA,EAASC,WAAYhgB,KAAKif,iBAC5Bjf,KAAKif,eAAiBc,EAAQC,UAE3BhgB,KAAKif,eAAgB,CAExB,MAAMgC,EAAkBjhB,KAAKyf,iBAAmB,OAChD,GAAIwB,EAA4C,IAA1BjhB,KAAKkC,MAAMkW,aAK/B,OAJApY,KAAKsK,UAAW,YACgBtC,KAAT,QAAnBwF,EAAAxN,KAAKuf,sBAAc,IAAA/R,OAAA,EAAAA,EAAEwL,KACvBhZ,KAAKsgB,cAActgB,KAAKuf,eAAevG,GAAI,uCAAiE,QAA3BkI,EAAElhB,KAAKuf,eAAegB,aAAK,IAAAW,EAAAA,EAAI,IAIpH,MAAMC,EAAaplB,KAAKC,IAAI,EAAGD,KAAKoR,MAAgC,IAA1BnN,KAAKkC,MAAMkW,aAAyB6I,IAC9E,KAAOjhB,KAAKwf,sBAAsBzV,MAAQoX,GAAY,CACpD,MAAMC,EAASphB,KAAKwf,sBAAsBnG,UAAUgI,OAAOvlB,MAC3D,IAAKslB,EAAQ,MACbA,EAAO,GAAGpB,QAAQ/b,UAClBjE,KAAK6f,oBAAoBuB,EAAO,SACPpZ,IAArBoZ,EAAO,GAAGE,IAAItI,IAChBhZ,KAAKsgB,cAAcc,EAAO,GAAGE,IAAItI,GAAI,uCAA2D,QAArBuI,EAAEH,EAAO,GAAGE,IAAIf,aAAK,IAAAgB,EAAAA,EAAI,EAExG,CACA,MAAMvB,EAAU,IAAIzW,EAAAyB,QAAa,QAA8BhL,KAAKyf,iBAAkBzf,KAAK2f,sBAC3F,IACEK,EAAQ3c,MACV,CAAE,MAAOyE,GAOP,OALAvK,QAAQC,KAAK,oCAAqCsK,GAClD9H,KAAKsK,UAAW,YACgBtC,KAAT,QAAnBmE,EAAAnM,KAAKuf,sBAAc,IAAApT,OAAA,EAAAA,EAAE6M,KACvBhZ,KAAKsgB,cAActgB,KAAKuf,eAAevG,GAAI,oCAA8D,QAA3BwI,EAAExhB,KAAKuf,eAAegB,aAAK,IAAAiB,EAAAA,EAAI,GAGjH,CACAxhB,KAAKif,eAAiBe,CACxB,CA1Me,IA4MXhgB,KAAKif,eAAe9T,IAAI/F,EAAKX,SAASY,EAAOC,MAC/CtF,KAAKif,eAAehb,UACpBjE,KAAKif,eAAiB,KACtBjf,KAAKgf,cAAe,EAChBe,GACF/f,KAAK6f,oBAAoBc,GA7CN,CAgDzB,CAEO,GAAArb,CAAImG,WACT,GAAIzL,KAAKsK,WAAamB,EAKpB,OAJIzL,KAAKif,iBACPjf,KAAKif,eAAehb,UACpBjE,KAAKif,eAAiB,OAEjB,EAIT,GAAIjf,KAAKkf,eACP,OAAOlf,KAAKyhB,0BAId,MAAMH,EAAMthB,KAAKuf,eAGjB,GAAc,MAAV+B,EAAId,OACN,OAAOxgB,KAAK0hB,cAAcJ,GAI5B,MAAMX,EAA2C,QAAjCnT,EAAS,QAATpB,EAAGkV,EAAItI,UAAE,IAAA5M,EAAAA,EAAIpM,KAAK8f,uBAAe,IAAAtS,EAAAA,EAAI,EAC/CmU,EAA4B,IAAbL,EAAIM,KACnB7B,EAAU/f,KAAKwf,sBAAsBnO,IAAIsP,GAE/C,GAAIgB,EAgBF,OAfI3hB,KAAKif,iBACHc,GACFA,EAAQe,kBAAoB9gB,KAAKsf,kBACjCS,EAAQ8B,YAAc9B,EAAQ8B,aAAe7hB,KAAKgf,cAElDhf,KAAKwf,sBAAsBtc,IAAIyd,EAAY,CACzCW,IAAG9kB,OAAA2F,OAAA,GAAOmf,GACVtB,QAAShgB,KAAKif,eACd6B,iBAAkB9gB,KAAKsf,kBACvBuC,YAAa7hB,KAAKgf,eAGtBhf,KAAK8f,gBAAkBa,EACvB3gB,KAAKif,eAAiB,OAEjB,EAILc,IACF/f,KAAK8f,qBAAkB9X,GAGzB,IAAI6Z,EAAc7hB,KAAKgf,aACnB8C,EAAWR,EACXtB,EAAUhgB,KAAKif,eAEfc,IACF+B,EAAW/B,EAAQuB,IACnBtB,EAAUD,EAAQC,QAClB6B,EAAcA,GAAe9B,EAAQ8B,YACrC7hB,KAAKwf,sBAAsBrJ,OAAOwK,IAGpC,IAAIoB,EAAa,IAAI3kB,WAAW,GAC5B4iB,IArRW,IAsRTA,EAAQ1a,QACVuc,GAAc,GAEhBE,EAAa/B,EAAQ/Z,OAEvBjG,KAAKif,eAAiB,KAKtB,MAAM1f,EAASS,KAAKgiB,8BAA8BF,EAAUC,EAAYF,GAIxE,OAHI7B,GACFA,EAAQ/b,UAEH1E,CACT,CAIQ,uBAAA6gB,GACN,IAAI6B,EAAM,GACV,IAAK,IAAInkB,EAAI,EAAGA,EAAIkC,KAAKof,eAAgBthB,IACvCmkB,GAAO9S,OAAO+S,cAAcliB,KAAKmf,aAAarhB,IAEhD,OAAOmkB,CACT,CAEQ,uBAAAR,iBACN,MAAMH,GAAM,EAAAxC,EAAAqB,mBAAkBngB,KAAKogB,2BAGnC,QAAepY,IAAXsZ,EAAItI,SAAwChR,IAApBsZ,EAAIjB,YAE9B,OADArgB,KAAKsgB,cAAcgB,EAAItI,GAAI,0CAAoD,QAAX5M,EAAEkV,EAAIf,aAAK,IAAAnU,EAAAA,EAAI,IAC5E,EAKT,OAFyB,QAAboB,EAAG8T,EAAId,cAAM,IAAAhT,EAAAA,EAAI,KAG3B,QACE,OAAOxN,KAAK0hB,cAAcJ,GAC5B,QAEE,OADAthB,KAAKsgB,cAAoB,QAAPnU,EAACmV,EAAItI,UAAE,IAAA7M,EAAAA,EAAI,EAAG,KAAe,QAAXoC,EAAE+S,EAAIf,aAAK,IAAAhS,EAAAA,EAAI,IAC5C,EACT,QACE,OAAOvO,KAAKmiB,iBAAiBb,GAC/B,QAQE,YAHetZ,IAAXsZ,EAAItI,IACNhZ,KAAKsgB,cAAcgB,EAAItI,GAAI,4BAAsC,QAAXvF,EAAE6N,EAAIf,aAAK,IAAA9M,EAAAA,EAAI,IAEhE,EAEb,CAEQ,6BAAAuO,CAA8BV,EAAoBc,EAAmBP,iBAG3E,OAFyB,QAAbzV,EAAGkV,EAAId,cAAM,IAAApU,EAAAA,EAAI,KAG3B,QAA2B,CACzB,MAAM7M,EAASS,KAAKqiB,gBAAgBf,EAAKc,EAAOP,GAUhD,MAPkC,OAAb,QAAjBrU,EAAC8T,EAAIgB,oBAAY,IAAA9U,EAAAA,EAAI,WAA2BxF,IAAXsZ,EAAItI,KACvC6I,EACF7hB,KAAKsgB,cAAcgB,EAAItI,GAAI,6BAAuC,QAAX7M,EAAEmV,EAAIf,aAAK,IAAApU,EAAAA,EAAI,GAC7DiW,EAAMrkB,OAAS,GACxBiC,KAAKsgB,cAAcgB,EAAItI,GAAI,KAAe,QAAXzK,EAAE+S,EAAIf,aAAK,IAAAhS,EAAAA,EAAI,IAG3ChP,CACT,CACA,QACE,OAAOS,KAAKuiB,uBAAuBjB,EAAKc,EAAOP,GACjD,QACE,OAAO7hB,KAAKwiB,aAAalB,EAAKc,EAAOP,GACvC,QAEE,OAAO7hB,KAAKmiB,iBAAiBb,GAC/B,QAQE,YAHetZ,IAAXsZ,EAAItI,IACNhZ,KAAKsgB,cAAcgB,EAAItI,GAAI,4BAAsC,QAAXvF,EAAE6N,EAAIf,aAAK,IAAA9M,EAAAA,EAAI,IAEhE,EAEb,CAEQ,gBAAA0O,CAAiBb,SACvB,QAAetZ,IAAXsZ,EAAItI,GACN,OAAO,EAET,MAAMA,EAAKsI,EAAItI,GACTyJ,EAAQziB,KAAK+e,cAAc2D,SAAS1J,GAC1C,OAAKyJ,EAIUziB,KAAK2iB,cAAcF,EAAOnB,GAC3BjgB,KAAKoK,UAEjB,OADAzL,KAAKsgB,cAActH,EAAIvN,EAAU,KAAO,gCAA0C,QAAXW,EAAEkV,EAAIf,aAAK,IAAAnU,EAAAA,EAAI,EAAGkV,EAAIsB,cACtF,KANP5iB,KAAKsgB,cAActH,EAAI,yBAAmC,QAAX5M,EAAEkV,EAAIf,aAAK,IAAAnU,EAAAA,EAAI,EAAGkV,EAAIsB,cAC9D,EAOX,CAEQ,eAAAP,CAAgBf,EAAoBc,EAAmBP,mBAY7D,MAAqB,OADgB,QAAnBzV,EAAGkV,EAAIgB,oBAAY,IAAAlW,EAAAA,EAAI,WAExBpE,IAAXsZ,EAAItI,IACNhZ,KAAKsgB,cAAcgB,EAAItI,GAAI,yCAAmD,QAAXxL,EAAE8T,EAAIf,aAAK,IAAA/S,EAAAA,EAAI,IAE7E,IAGLqU,GAAgC,IAAjBO,EAAMrkB,QAEzBiC,KAAK+e,cAAc8D,WAAWvB,EAAItI,GAAI,CACpC5T,KAAM,IAAIwI,KAAK,CAACwU,IAChBliB,MAAgB,QAAXiM,EAAEmV,EAAIphB,aAAK,IAAAiM,EAAAA,EAAI,EACpBnI,OAAkB,QAAZuK,EAAE+S,EAAItd,cAAM,IAAAuK,EAAAA,EAAI,EACtBuU,OAAmB,QAAXrP,EAAC6N,EAAIwB,cAAM,IAAArP,EAAAA,EAAA,GACnBsP,YAA4B,QAAjBlC,EAAES,EAAIyB,mBAAW,IAAAlC,EAAAA,EAAI,MAPY,EAUhD,CAEQ,sBAAA0B,CAAuBjB,EAAoBc,EAAmBP,WACpE,GAAIA,EAIF,YAHe7Z,IAAXsZ,EAAItI,IACNhZ,KAAKsgB,cAAcgB,EAAItI,GAAI,6BAAuC,QAAX5M,EAAEkV,EAAIf,aAAK,IAAAnU,EAAAA,EAAI,IAEjE,EAGTpM,KAAKqiB,gBAAgBf,EAAKc,EAAOP,GAEjC,MAAM7I,EAAW,QAATxL,EAAG8T,EAAItI,UAAE,IAAAxL,EAAAA,EAAIxN,KAAK+e,cAAciE,YAClCP,EAAQziB,KAAK+e,cAAc2D,SAAS1J,GAC1C,GAAIyJ,EAAO,CACT,MAAMljB,EAASS,KAAK2iB,cAAcF,EAAOnB,GACzC,YAAetZ,IAAXsZ,EAAItI,GACCzZ,EAAO8B,KAAKoK,UAEjB,OADAzL,KAAKsgB,cAActH,EAAIvN,EAAU,KAAO,gCAA0C,QAAXW,EAAEkV,EAAIf,aAAK,IAAAnU,EAAAA,EAAI,IAC/E,IAGJ7M,EAAO8B,KAAK,KAAM,EAC3B,CACA,OAAO,CACT,CAEQ,YAAAmhB,CAAalB,EAAoBc,EAAmBP,mBAC1D,MAAM7I,EAAW,QAAT5M,EAAGkV,EAAItI,UAAE,IAAA5M,EAAAA,EAAI,EACfmU,EAAiB,QAAZ/S,EAAG8T,EAAIf,aAAK,IAAA/S,EAAAA,EAAI,EAM3B,GAAqB,OADgB,QAAnBrB,EAAGmV,EAAIgB,oBAAY,IAAAnW,EAAAA,EAAI,KAGvC,OADAnM,KAAKsgB,cAActH,EAAI,yCAA0CuH,IAC1D,EAIT,GAAIsB,EAEF,OADA7hB,KAAKsgB,cAActH,EAAI,6BAA8BuH,IAC9C,EAIT,GAAqB,IAAjB6B,EAAMrkB,OAER,OADAiC,KAAKsgB,cAActH,EAAI,KAAMuH,IACtB,EAGT,MAAMuC,EAAmB,QAAbvU,EAAG+S,EAAIwB,cAAM,IAAAvU,EAAAA,EAAA,GAEzB,GAAU,MAANuU,EACF9iB,KAAKsgB,cAActH,EAAI,KAAMuH,OACxB,CACL,MAAMrgB,EAAiB,QAAZuT,EAAG6N,EAAIphB,aAAK,IAAAuT,EAAAA,EAAI,EACrBzP,EAAmB,QAAb6c,EAAGS,EAAItd,cAAM,IAAA6c,EAAAA,EAAI,EAE7B,IAAK3gB,IAAU8D,EAEb,OADAhE,KAAKsgB,cAActH,EAAI,sDAAuDuH,IACvE,EAGT,MACM0C,EAAgB/iB,EAAQ8D,GADF,KAAN8e,EAA6B,EAA2C,GAG9F,GAAIV,EAAMrkB,OAASklB,EAEjB,OADAjjB,KAAKsgB,cAActH,EAAI,iCAAkCuH,IAClD,EAGTvgB,KAAKsgB,cAActH,EAAI,KAAMuH,EAC/B,CACA,OAAO,CACT,CAEQ,aAAAmB,CAAcJ,SAOpB,OALmC,QAArBlV,EAAGkV,EAAI4B,sBAAc,IAAA9W,EAAAA,EAAI,KAMrC,IAAK,IACL,IAAK,IACHpM,KAAK4f,qBACL5f,KAAK+e,cAAcoE,YACnB,MACF,IAAK,IACL,IAAK,IAKH,QAAenb,IAAXsZ,EAAItI,GAAkB,CACxB,MAAM+G,EAAU/f,KAAKwf,sBAAsBnO,IAAIiQ,EAAItI,IAC/C+G,GACFA,EAAQC,QAAQ/b,UAElBjE,KAAK6f,oBAAoByB,EAAItI,IAC7BhZ,KAAK+e,cAAcqE,WAAW9B,EAAItI,GACpC,EAMJ,OAAO,CACT,CAEQ,aAAAsH,CAActH,EAAYV,EAAiBiI,EAAeqC,GAChE,MAAMS,EAAmB,OAAZ/K,EACb,GAAI+K,GAAQ9C,GAAS,EAAG,OACxB,IAAK8C,GAAQ9C,GAAS,EAAG,OAEzB,MACM+C,EAAW,QAAWtK,IADd4J,EAAc,MAAMA,IAAgB,MACRtK,OAC1CtY,KAAKoK,cAAciC,MAAMkX,YAAYC,iBAAiBF,EACxD,CAIQ,aAAAX,CAAcF,EAAwBnB,GAC5C,OAAOthB,KAAKyjB,kBAAkBhB,EAAOnB,GAClCjgB,KAAK,KAAM,GACX8M,MAAM,KAAM,EACjB,CAEQ,uBAAMsV,CAAkBhB,EAAwBnB,qBACtD,MAAMzT,EAAa7N,KAAKqK,YACxB,IAAIyM,QAAwC9W,KAAK0jB,cAAcjB,GAE/D,IACE,GAAI5U,IAAe7N,KAAKqK,YAAa,MAAM,IAAIjI,MAAM,yBACrD,MAAMuhB,EAAQ5nB,KAAKC,IAAI,EAAQ,QAAPmQ,EAAEmV,EAAI5K,SAAC,IAAAvK,EAAAA,EAAI,GAC7ByX,EAAQ7nB,KAAKC,IAAI,EAAQ,QAAPuS,EAAE+S,EAAI/N,SAAC,IAAAhF,EAAAA,EAAI,GAC7BsV,EAAQvC,EAAInN,aAAgB2C,EAAO5W,MAAQyjB,EAC3CG,EAAQxC,EAAIlN,cAAiB0C,EAAO9S,OAAS4f,EAE7CG,EAAWhoB,KAAKC,IAAI,EAAG8a,EAAO5W,MAAQyjB,GACtCK,EAAWjoB,KAAKC,IAAI,EAAG8a,EAAO9S,OAAS4f,GACvCK,EAAaloB,KAAKC,IAAI,EAAGD,KAAKE,IAAI4nB,EAAOE,IACzCG,EAAanoB,KAAKC,IAAI,EAAGD,KAAKE,IAAI6nB,EAAOE,IAE/C,GAAmB,IAAfC,GAAmC,IAAfC,EACtB,MAAM,IAAI9hB,MAAM,4BAGlB,GAAc,IAAVuhB,GAAyB,IAAVC,GAAeK,IAAenN,EAAO5W,OAASgkB,IAAepN,EAAO9S,OAAQ,CAC7F,MAAMmgB,QAAgBrW,kBAAkBgJ,EAAQ6M,EAAOC,EAAOK,EAAYC,GAC1EpN,EAAO5I,QACP4I,EAASqN,CACX,CAEA,MAAM/V,GAA8B,QAAzBhC,EAAApM,KAAKkK,UAAU2B,kBAAU,IAAAO,OAAA,EAAAA,EAAEN,IAAIuC,KAAKnO,QAASoJ,EAAAsC,kBAAkB1L,MACpEoO,GAA8B,QAAzBd,EAAAxN,KAAKkK,UAAU2B,kBAAU,IAAA2B,OAAA,EAAAA,EAAE1B,IAAIuC,KAAKrK,SAAUsF,EAAAsC,kBAAkB5H,OAI3E,IAAIogB,EACAC,OACgBrc,IAAhBsZ,EAAIgD,cAAsCtc,IAAbsZ,EAAIrV,MACnCmY,EAAU9C,EAAIgD,QACdD,EAAU/C,EAAIrV,WACWjE,IAAhBsZ,EAAIgD,SACbF,EAAU9C,EAAIgD,QACdD,EAAUtoB,KAAKC,IAAI,EAAGD,KAAKuI,KAAMwS,EAAO9S,OAAS8S,EAAO5W,OAAUkkB,EAAUhW,GAAME,UAC5DtG,IAAbsZ,EAAIrV,MACboY,EAAU/C,EAAIrV,KACdmY,EAAUroB,KAAKC,IAAI,EAAGD,KAAKuI,KAAMwS,EAAO5W,MAAQ4W,EAAO9S,QAAWqgB,EAAU/V,GAAMF,MAElFgW,EAAUroB,KAAKuI,KAAKwS,EAAO5W,MAAQkO,GACnCiW,EAAUtoB,KAAKuI,KAAKwS,EAAO9S,OAASsK,IAGtC,IAAI3C,EAAImL,EAAO5W,MACX9B,EAAI0Y,EAAO9S,OAQf,QALoBgE,IAAhBsZ,EAAIgD,cAAsCtc,IAAbsZ,EAAIrV,OACnCN,EAAI5P,KAAKQ,MAAM6nB,EAAUhW,GACzBhQ,EAAIrC,KAAKQ,MAAM8nB,EAAU/V,IAGvB3C,EAAIvN,EAAI4B,KAAKkC,MAAM+K,WACrB,MAAM,IAAI7K,MAAM,6BAIlB,MAAM9E,EAAS0C,KAAKoK,cAAciC,MAAM/O,OAClCinB,EAASjnB,EAAOoZ,EAChB8N,EAASlnB,EAAOiW,EAChBkR,EAAannB,EAAO4c,MAOpBvJ,OAD6B3I,IAAfsZ,EAAI1Q,QAAwB0Q,EAAI1Q,OAAS,EACrB,SAAW,MAEnD,GAAIjF,IAAMmL,EAAO5W,OAAS9B,IAAM0Y,EAAO9S,OAAQ,CAC7C,MAAM0gB,QAAe5W,kBAAkBgJ,EAAQ,CAAE/I,YAAapC,EAAGqC,aAAc5P,IAC/E0Y,EAAO5I,QACP4I,EAAS4N,CACX,CAGA,MAAMC,EAAU5oB,KAAKE,IAAIF,KAAKC,IAAI,EAAc,QAAbyX,EAAE6N,EAAIqD,eAAO,IAAAlR,EAAAA,EAAI,GAAIrF,EAAK,GACvDwW,EAAU7oB,KAAKE,IAAIF,KAAKC,IAAI,EAAc,QAAb6kB,EAAES,EAAIsD,eAAO,IAAA/D,EAAAA,EAAI,GAAIvS,EAAK,GAC7D,GAAgB,IAAZqW,GAA6B,IAAZC,EAAe,CAKlC,MAAMC,OAA2B7c,IAAhBsZ,EAAIgD,QAAyBvoB,KAAKQ,MAAM6nB,EAAUhW,GAAM0I,EAAO5W,MAAQykB,EAClFG,OAAwB9c,IAAbsZ,EAAIrV,KAAsBlQ,KAAKQ,MAAM8nB,EAAU/V,GAAMwI,EAAO9S,OAAS4gB,EAChFG,EAAe1b,EAAAiE,cAAcC,aAAawJ,OAAOxF,SAAUsT,EAASC,GACpEE,EAAYD,EAAatX,WAAW,MAC1C,IAAKuX,EACH,MAAM,IAAI5iB,MAAM,0CAElB4iB,EAAUpQ,UAAUkC,EAAQ6N,EAASC,GAErC,MAAMK,QAAqBnX,kBAAkBiX,GAM7C,GALAA,EAAa7kB,MAAQ6kB,EAAa/gB,OAAS,EAC3C8S,EAAO5I,QACP4I,EAASmO,EACTtZ,EAAImL,EAAO5W,MACX9B,EAAI0Y,EAAO9S,OACP2H,EAAIvN,EAAI4B,KAAKkC,MAAM+K,WACrB,MAAM,IAAI7K,MAAM,kCAEE4F,IAAhBsZ,EAAIgD,UACNF,EAAUroB,KAAKuI,KAAKwS,EAAO5W,MAAQkO,SAEpBpG,IAAbsZ,EAAIrV,OACNoY,EAAUtoB,KAAKuI,KAAKwS,EAAO9S,OAASsK,GAExC,CAEA,GAAIT,IAAe7N,KAAKqK,YAAa,MAAM,IAAIjI,MAAM,yBACrD,MAAMwO,EAAmB,QAAboQ,EAAGM,EAAI1Q,cAAM,IAAAoQ,EAAAA,EAAI,EAO7B,GANAhhB,KAAK+e,cAAcpR,SAAS8U,EAAMzJ,GAAIlC,GAAQ,EAAMnG,EAAOC,GAC3DkG,OAAS9O,EAKkB,IAAvBsZ,EAAI4D,eAAsB,CAE5B,MAAMC,EAAW7nB,EAAO4c,MAAQuK,EAChCnnB,EAAOoZ,EAAI6N,EAEXjnB,EAAOiW,EAAIxX,KAAKC,IAAIwoB,EAASW,EAAU,EACzC,MAGE7nB,EAAOoZ,EAAI3a,KAAKE,IAAIsoB,EAASH,EAASpkB,KAAKoK,cAAc4B,KAE7D,CAAE,MAAOlE,GAEP,MADAgP,SAAAA,EAAQ5I,QACFpG,CACR,CACF,CAGQ,mBAAM4b,CAAcjB,GAC1B,IAAIL,EAAoB,IAAIhlB,iBAAiBqlB,EAAMrd,KAAKggB,eAMxD,GAJqB,MAAjB3C,EAAMM,cACRX,QAAcpiB,KAAKqlB,gBAAgBjD,IAGrB,MAAZK,EAAMK,OAA4B,CACpC,MAAMjW,GAAU,EAAAlD,EAAAoD,WAAUqV,GAG1B,GAAqB,cAAjBvV,EAAQG,QAA0BH,EAAQ3M,MAAQ,MAAQ2M,EAAQ7I,OAAS,IAAM6I,EAAQ3M,MAAQ2M,EAAQ7I,OAAShE,KAAKkC,MAAM+K,WAC/H,MAAM,IAAIyL,WAAW,qDAEvB,MAAM9L,EAAO,IAAIgB,KAAK,CAACwU,GAAoB,CAAEvY,KAAM,cACnD,IAAKkN,OAAOjJ,kBAAmB,CAC7B,MAAMwX,EAAMC,IAAIC,gBAAgB5Y,GAC1BkE,EAAM,IAAI2U,MAChB,OAAO,IAAI7T,QAAqB,CAACC,EAAS6T,KACxC5U,EAAI6U,iBAAiB,OAAQ,WAC3BJ,IAAIK,gBAAgBN,GACpB,MAAMvZ,EAAS1C,EAAAiE,cAAcC,aAAawJ,OAAOxF,SAAUT,EAAI5Q,MAAO4Q,EAAI9M,QACnD,QAAvBoI,EAAAL,EAAO0B,WAAW,aAAK,IAAArB,GAAAA,EAAEwI,UAAU9D,EAAK,EAAG,GAC3ChD,kBAAkB/B,GAAQ1K,KAAKwQ,GAAS1D,MAAMuX,KAEhD5U,EAAI6U,iBAAiB,QAAS,KAC5BJ,IAAIK,gBAAgBN,GACpBI,EAAO,IAAItjB,MAAM,2BAEnB0O,EAAI+U,IAAMP,GAEd,CACA,OAAOxX,kBAAkBlB,EAC3B,CAGA,MAAM1M,EAAQuiB,EAAMviB,MACd8D,EAASye,EAAMze,OAErB,IAAK9D,IAAU8D,EACb,MAAM,IAAI5B,MAAM,gDAGlB,MACM6gB,EAAgB/iB,EAAQ8D,GADI,KAAZye,EAAMK,OAA6B,EAA2C,GAGpG,GAAIV,EAAMrkB,OAASklB,EACjB,MAAM,IAAI7gB,MAAM,2BAGlB,MAAM0jB,EAAa5lB,EAAQ8D,EAE3B,GAAgB,KAAZye,EAAMK,OAER,OAAOhV,kBAAkB,IAAIV,UAAU,IAAIlH,kBAAkBkc,EAAM9kB,OAAuB8kB,EAAM/U,WAAsB,EAAVyY,GAAwD5lB,EAAO8D,IAM7K,MAAMoB,EAAO,IAAIc,kBAA4B,EAAV4f,GAC7BC,EAAQ,IAAI1oB,YAAY+kB,EAAM9kB,OAAQ8kB,EAAM/U,WAAYtR,KAAKoR,MAAMiV,EAAMvd,WAAa,IACtFmhB,EAAQ,IAAI3oB,YAAY+H,EAAK9H,QAC7B2oB,GAA6B,EAAbH,EAEtB,IAAII,EAAY,EACZC,EAAY,EAChB,IAAK,IAAIroB,EAAI,EAAGA,EAAImoB,EAAenoB,GAAK,EAAG,CACzC,MAAMsoB,EAAKL,EAAMG,KACXG,EAAKN,EAAMG,KACXI,EAAKP,EAAMG,KAEjBF,EAAMG,KAAe,WAAaC,EAClCJ,EAAMG,KAAe,WAAcC,IAAO,GAAOC,GAAM,EACvDL,EAAMG,KAAe,WAAcE,IAAO,GAAOC,GAAM,GACvDN,EAAMG,KAAe,WAAcG,IAAO,CAC5C,CAGA,IAAIC,EAAuB,EAAbN,EACVO,EAAuB,EAAbP,EACd,IAAK,IAAInoB,EAAImoB,EAAenoB,EAAIgoB,EAAYhoB,IAC1CsH,EAAKohB,GAAepE,EAAMmE,GAC1BnhB,EAAKohB,EAAU,GAAKpE,EAAMmE,EAAU,GACpCnhB,EAAKohB,EAAU,GAAKpE,EAAMmE,EAAU,GACpCnhB,EAAKohB,EAAU,GAAE,IACjBD,GAAO,EACPC,GAAO,EAGT,OAAO1Y,kBAAkB,IAAIV,UAAUhI,EAAMlF,EAAO8D,GACtD,CAEQ,qBAAMqhB,CAAgBoB,GAC5B,IACE,aAAazmB,KAAK0mB,YAAYD,EAAY,UAC5C,CAAE,MAAOpO,GACP,GAAIA,aAAiBK,WAAY,MAAML,EACvC,aAAarY,KAAK0mB,YAAYD,EAAY,cAC5C,CACF,CAEQ,iBAAMC,CAAYD,EAAwB3D,GAChD,MAAM3R,EAAQpV,KAAKE,IAAI+D,KAAKkC,MAAMwd,eAAwC,EAAxB1f,KAAKkC,MAAM+K,WAA0C,IAA1BjN,KAAKkC,MAAMkW,cACxF,IAAIuO,EAAW,EAEf,MAWMC,EAXS,IAAIC,eAA6B,CAC9C,IAAAC,CAAKC,GACH,GAAIJ,GAAYF,EAAW1oB,OAEzB,YADAgpB,EAAW7Y,QAGb,MAAM5I,EAAMvJ,KAAKE,IAAI0qB,EAAW,KAAMF,EAAW1oB,QACjDgpB,EAAWC,QAAQ,IAAI5pB,WAAWqpB,EAAWhiB,SAASkiB,EAAUrhB,KAChEqhB,EAAWrhB,CACb,IAEoB2hB,YAAY,IAAIC,oBAAoBpE,IAASqE,YAC7DC,EAAuB,GAC7B,IAAIC,EAAc,EAClB,IACE,OAAa,CACX,MAAMC,KAAEA,EAAIxrB,MAAEA,SAAgB8qB,EAAOW,OACrC,GAAID,EAAM,MAEV,GADAD,GAAevrB,EAAM+I,WACjBwiB,EAAclW,EAEhB,YADMyV,EAAOY,SAASrZ,MAAM,QACtB,IAAIuK,WAAW,yCAEvB0O,EAAO1oB,KAAK5C,EACd,CACF,SACE8qB,EAAOa,aACT,CAEA,MAAMloB,EAAS,IAAInC,WAAWiqB,GAC9B,IAAIljB,EAAS,EACb,IAAK,MAAMujB,KAASN,EAClB7nB,EAAO2D,IAAIwkB,EAAOvjB,GAClBA,GAAUujB,EAAM3pB,OAElB,OAAOwB,CACT,CAEA,UAAWooB,GACT,OAAO3nB,KAAK+e,cAAc4I,MAC5B,CAEA,uBAAWC,GACT,OAAO5nB,KAAK+e,cAAc8I,kBAC5B,CAEA,wBAAWC,GACT,OAAO9nB,KAAKwf,qBACd,kFC/tBF,SAAkCpa,GAChC,MAAMkc,EAAqB,GACrByG,EAAQ3iB,EAAK4iB,MAAM,KAEzB,IAAK,MAAMC,KAAQF,EAAO,CACxB,MAAMG,EAAQD,EAAK/gB,QAAQ,KAC3B,IAAe,IAAXghB,EAAc,SAElB,MAAM1S,EAAMyS,EAAKE,UAAU,EAAGD,GACxBpsB,EAAQmsB,EAAKE,UAAUD,EAAQ,GAGrC,GAAO,MAAH1S,EAAyB,CAC3B8L,EAAId,OAAS1kB,EACb,QACF,CACA,GAAO,MAAH0Z,EAA8B,CAChC8L,EAAIyB,YAAcjnB,EAClB,QACF,CACA,GAAO,MAAH0Z,EAA+B,CACjC8L,EAAIgB,aAAexmB,EACnB,QACF,CACA,GAAO,MAAH0Z,EAAkC,CACpC8L,EAAI4B,eAAiBpnB,EACrB,QACF,CACA,MAAMssB,EAAWnZ,SAASnT,EAAO,IACjC,OAAQ0Z,GACN,QAAsB8L,EAAIwB,OAASsF,EAAU,MAC7C,QAAkB9G,EAAItI,GAAKoP,EAAU,MACrC,QAA4B9G,EAAIjB,YAAc+H,EAAU,MACxD,QAAqB9G,EAAIphB,MAAQkoB,EAAU,MAC3C,QAAsB9G,EAAItd,OAASokB,EAAU,MAC7C,QAAwB9G,EAAI5K,EAAI0R,EAAU,MAC1C,QAAwB9G,EAAI/N,EAAI6U,EAAU,MAC1C,QAA4B9G,EAAInN,YAAciU,EAAU,MACxD,QAA6B9G,EAAIlN,aAAegU,EAAU,MAC1D,QAAkC9G,EAAIqD,QAAUyD,EAAU,MAC1D,QAAkC9G,EAAIsD,QAAUwD,EAAU,MAC1D,QAAuB9G,EAAIgD,QAAU8D,EAAU,MAC/C,QAAoB9G,EAAIrV,KAAOmc,EAAU,MACzC,QAAoB9G,EAAIM,KAAOwG,EAAU,MACzC,QAAqB9G,EAAIf,MAAQ6H,EAAU,MAC3C,QAA+B9G,EAAI4D,eAAiBkD,EAAU,MAC9D,QAAuB9G,EAAI1Q,OAASwX,EAAU,MAC9C,QAA4B9G,EAAIsB,YAAcwF,EAElD,CAEA,OAAO9G,CACT,wFCjLA,MAAA+G,EA6BE,WAAAtoB,CACmBoK,GAAAnK,KAAAmK,SAAAA,EA3BXnK,KAAAsoB,aAAe,EACNtoB,KAAA6X,QAAwC,IAAI7F,IAU5ChS,KAAA4nB,oBAA2C,IAAI5V,IAC/ChS,KAAAuoB,oBAA2C,IAAIvW,IAI/ChS,KAAAwoB,2BAA8BC,IAC7C,MAAMC,EAAU1oB,KAAKuoB,oBAAoBlX,IAAIoX,QAC7BzgB,IAAZ0gB,IACF1oB,KAAK4nB,oBAAoBzR,OAAOuS,GAChC1oB,KAAKuoB,oBAAoBpS,OAAOsS,GAChCzoB,KAAK6X,QAAQ1B,OAAOuS,KAGhB1oB,KAAAyQ,cAA+B,CAAEC,WAAW,EAAMC,MAAO,MAAOC,OAAQ,EAAGC,UAAW,OAK5F7Q,KAAK2oB,wBAA0B3oB,KAAKmK,SAAS+O,eAC7ClZ,KAAK4oB,uBAA0BH,UACD,QAA5Brc,EAAApM,KAAK2oB,+BAAuB,IAAAvc,GAAAA,EAAA5E,KAA5BxH,KAA+ByoB,GAC/BzoB,KAAKwoB,2BAA2BC,IAElCzoB,KAAKmK,SAAS+O,eAAiBlZ,KAAK4oB,sBACtC,CAEO,KAAA1d,GACLlL,KAAKsoB,aAAe,EACpBtoB,KAAK6X,QAAQ/O,QACb9I,KAAK4nB,oBAAoB9e,QACzB9I,KAAKuoB,oBAAoBzf,OAC3B,CAEO,OAAAnB,GACL3H,KAAKkL,QACDlL,KAAKmK,SAAS+O,iBAAmBlZ,KAAK4oB,yBACxC5oB,KAAKmK,SAAS+O,eAAiBlZ,KAAK2oB,wBAExC,CAEO,UAAA9F,CAAW7J,EAAwB6P,GACxC,MAAMpR,EAAUuB,QAAAA,EAAMhZ,KAAKsoB,eAErBQ,EAAe9oB,KAAK4nB,oBAAoBvW,IAAIoG,QAC7BzP,IAAjB8gB,IACF9oB,KAAKmK,SAASoP,YAAYuP,GAC1B9oB,KAAK4nB,oBAAoBzR,OAAOsB,GAChCzX,KAAKuoB,oBAAoBpS,OAAO2S,KAG7B9oB,KAAK6X,QAAQlC,IAAI8B,IAAYzX,KAAK6X,QAAQ9N,MAAQse,EAAkBU,kBACvE/oB,KAAKgpB,0BASP,MAAMC,EAAuC,IAA3BjpB,KAAKmK,SAASoO,WAChCvY,KAAK6X,QAAQ1B,OAAOsB,GACpB,IAAIyR,EAAgB,EACpB,IAAK,MAAMzG,KAASziB,KAAK6X,QAAQ9C,SAAUmU,GAAiBzG,EAAMrd,KAAK2E,KACvE,IAAK,MAAMof,IAAe,EAAC,GAAO,GAChC,IAAK,MAAOC,EAAU3G,KAAUziB,KAAK6X,QAAS,CAC5C,GAAIqR,EAAgBL,EAAUzjB,KAAK2E,MAAQkf,EAAW,MAClDjpB,KAAK4nB,oBAAoBjS,IAAIyT,KAAcD,IAC/CD,GAAiBzG,EAAMrd,KAAK2E,KAC5B/J,KAAKojB,WAAWgG,GAClB,CAOF,OAJAppB,KAAK6X,QAAQ3U,IAAIuU,EAAOjb,OAAA2F,OAAA3F,OAAA2F,OAAA,GACnB0mB,GAAS,CACZ7P,GAAIvB,KAECA,CACT,CAEO,QAAA9J,CAAS+a,EAAiBjG,EAAwC/R,EAAoBC,EAAmBC,GAK9G,MAAMkY,EAAe9oB,KAAK4nB,oBAAoBvW,IAAIqX,QAC7B1gB,IAAjB8gB,GACF9oB,KAAKuoB,oBAAoBpS,OAAO2S,GAElC9oB,KAAKyQ,cAAcC,UAAYA,EAC/B1Q,KAAKyQ,cAAcE,MAAQA,EAC3B3Q,KAAKyQ,cAAcG,OAASA,EAC5B,MAAM6X,EAAYzoB,KAAKmK,SAASwD,SAAS8U,EAAOziB,KAAKyQ,eACrDzQ,KAAK4nB,oBAAoB1kB,IAAIwlB,EAASD,GACtCzoB,KAAKuoB,oBAAoBrlB,IAAIulB,EAAWC,EAC1C,CAEO,QAAAhG,CAASgG,GACd,OAAO1oB,KAAK6X,QAAQxG,IAAIqX,EAC1B,CAEO,UAAAtF,CAAWsF,GAChB1oB,KAAK6X,QAAQ1B,OAAOuS,GACpB,MAAMD,EAAYzoB,KAAK4nB,oBAAoBvW,IAAIqX,QAC7B1gB,IAAdygB,IACFzoB,KAAKmK,SAASoP,YAAYkP,GAC1BzoB,KAAK4nB,oBAAoBzR,OAAOuS,GAChC1oB,KAAKuoB,oBAAoBpS,OAAOsS,GAEpC,CAEO,SAAAtF,GACLnjB,KAAK6X,QAAQ/O,QACb,IAAK,MAAM2f,KAAazoB,KAAK4nB,oBAAoB7S,SAC/C/U,KAAKmK,SAASoP,YAAYkP,GAE5BzoB,KAAK4nB,oBAAoB9e,QACzB9I,KAAKuoB,oBAAoBzf,OAC3B,CAEA,UAAW6e,GACT,OAAO3nB,KAAK6X,OACd,CAEA,sBAAWgQ,GACT,OAAO7nB,KAAK4nB,mBACd,CAEA,eAAW5E,GACT,OAAOhjB,KAAKsoB,aAAe,CAC7B,CAEQ,uBAAAU,GACN,IAAK,MAAON,KAAY1oB,KAAK6X,QAAS,CACpC,GAAI7X,KAAK6X,QAAQ9N,MAAQse,EAAkBU,iBAAmB,EAC5D,MAEG/oB,KAAK4nB,oBAAoBjS,IAAI+S,IAChC1oB,KAAK6X,QAAQ1B,OAAOuS,EAExB,CACF,wBA1JwBL,EAAAU,iBAAmB,gBCjB7CvsB,OAAAC,eAAA5B,EAAA,cAA+CiB,OAAA,IAK/C,MAIAutB,GAAA,EAJyBrqB,EAAQ,KAIjCsqB,QAAA,CAAgGhrB,EAAA,EAAAuf,EAAA,EAAA1f,EAAA,6lCAuJhGorB,EAAA,IAAAnsB,WAAA,mEACA4qB,MAAA,IACAjgB,IAAAyhB,GAAAA,EAAAhqB,WAAA,KAEAiqB,EAAA,IAAApsB,YAAA,MACAosB,EAAA9jB,KAAA,YACA,QAAA7H,EAAA,EAAgBA,EAAAyrB,EAAAxrB,SAAgBD,EAChC2rB,EAAAF,EAAAzrB,IAAAA,GAAA,EACA,QAAAA,EAAA,EAAgBA,EAAAyrB,EAAAxrB,SAAgBD,EAChC2rB,EAAA,IAAAF,EAAAzrB,IAAAA,GAAA,GAAAA,GAAA,UACA,QAAAA,EAAA,EAAgBA,EAAAyrB,EAAAxrB,SAAgBD,EAChC2rB,EAAA,IAAAF,EAAAzrB,IAAAA,GAAA,MAAAA,GAAA,WACA,QAAAA,EAAA,EAAgBA,EAAAyrB,EAAAxrB,SAAgBD,EAChC2rB,EAAA,IAAAF,EAAAzrB,IAAAA,GAAA,GACA,MAAA4rB,EAAA,IAAAtsB,WAAA,GAkKAvC,EAAA,QAvJA,MAMA,WAAAkF,CAAA4pB,EAAAC,EAAA9e,GAOA,GANA9K,KAAA6pB,MAAA,KACA7pB,KAAA8pB,QAAA,EACA9pB,KAAA+pB,OAAA,EACA/pB,KAAA2pB,SAAAA,QAAAA,EAAA,QACA3pB,KAAA4pB,SAAAA,QAAAA,EAAA,WACA5pB,KAAA+pB,OAAAjf,QAAAA,EAAA,MACA9K,KAAA+pB,OAAA/pB,KAAA4pB,UAAA5pB,KAAA4pB,SAAA,WACA,UAAAxnB,MAAA,wBAEA,CAKA,SAAA6D,GACA,OAAAjG,KAAA6pB,MAAA7pB,KAAAuO,GAAA9J,SAAA,EAAAzE,KAAAgqB,KAAA,OAAAN,CACA,CAMA,OAAAzlB,GACAjE,KAAA6pB,QAEA7pB,KAAA+pB,OAAA/pB,KAAA2pB,SACA3pB,KAAA6pB,MAAA7pB,KAAAgqB,KAAAhqB,KAAAuO,GAAAvO,KAAAiqB,KAAA,MAGAjqB,KAAAgqB,KAAA,QACAhqB,KAAAgqB,KAAA,QACAhqB,KAAAgqB,KAAA,SAEA,CASA,IAAA3mB,CAAAumB,EAAA9e,GAGA,GAFA9K,KAAA4pB,SAAAA,QAAAA,EAAA5pB,KAAA4pB,SACA5pB,KAAA+pB,OAAAjf,QAAAA,EAAA/O,KAAAE,IAAA+D,KAAA+pB,OAAA/pB,KAAA4pB,UACA5pB,KAAA+pB,OAAA/pB,KAAA4pB,UAAA5pB,KAAA4pB,SAAA,WACA,MAAAxnB,MAAA,yBAEA,IAAA8nB,EAAAlqB,KAAAgqB,KACA,MAAA5H,EAAApiB,KAAA+pB,OAAA,KACA/pB,KAAA6pB,MAOA7pB,KAAAiqB,KAAA3sB,OAAAuH,WAAAud,IACApiB,KAAAiqB,KAAAE,KAAApuB,KAAAuI,MAAA8d,EAAApiB,KAAAiqB,KAAA3sB,OAAAuH,YAAA,QACAqlB,EAAA,IAAA7sB,YAAA2C,KAAAiqB,KAAA3sB,OAAA,GACA0C,KAAAuO,GAAA,IAAAnR,WAAA4C,KAAAiqB,KAAA3sB,OAAA,QATA0C,KAAAiqB,KAAA,IAAA9oB,YAAAipB,OAAA,CAAiD7hB,QAAAxM,KAAAuI,KAAA8d,EAAA,SACjDpiB,KAAA6pB,MAAAR,EAAA,CAAsCpoB,IAAA,CAAO0B,OAAA3C,KAAAiqB,QAC7CC,EAAA,IAAA7sB,YAAA2C,KAAAiqB,KAAA3sB,OAAA,GACA4sB,EAAAhnB,IAAAumB,EAAA,KACAzpB,KAAAuO,GAAA,IAAAnR,WAAA4C,KAAAiqB,KAAA3sB,OAAA,OAOA4sB,EAAA,QACAA,EAAA,QACAA,EAAA,QACAlqB,KAAAgqB,KAAAE,EACAlqB,KAAA8pB,QAAA,CACA,CAOA,QAAA5lB,CAAAmmB,GACA,MAAAC,EAAAtqB,KAAAgqB,KAAA,MAAAK,EACA,GAAArqB,KAAA+pB,OAAAO,EAAA,CACA,GAAAA,EAAAtqB,KAAA4pB,SACA,SAEA,IAAAW,EAAAvqB,KAAA+pB,OACA,MAAAQ,GAAA,GAAAD,IAEA,GADAC,EAAAxuB,KAAAE,IAAAsuB,EAAAvqB,KAAA4pB,UACAW,EAAAD,EACA,SAEA,GAAAC,EAAA,KAAAvqB,KAAAiqB,KAAA3sB,OAAAuH,WAAA,CACA,MAAA2lB,EAAAzuB,KAAAuI,MAAAimB,EAAA,KAAAvqB,KAAAiqB,KAAA3sB,OAAAuH,YAAA,OACA7E,KAAAiqB,KAAAE,KAAAK,GACAxqB,KAAAgqB,KAAA,IAAA3sB,YAAA2C,KAAAiqB,KAAA3sB,OAAA,GACA0C,KAAAuO,GAAA,IAAAnR,WAAA4C,KAAAiqB,KAAA3sB,OAAA,KACA,CACA0C,KAAA+pB,OAAAQ,CACA,CACA,QACA,CAOA,GAAApf,CAAA/F,GACA,IAAApF,KAAA6pB,OAAA7pB,KAAA8pB,OACA,SAEA,GAAA9pB,KAAAkE,SAAAkB,EAAArH,QACA,SAEA,MAAAmsB,EAAAlqB,KAAAgqB,KAIA,OAHAhqB,KAAAuO,GAAArL,IAAAkC,EAAA8kB,EAAA,OACAA,EAAA,OAAA9kB,EAAArH,OAEAmsB,EAAA,MAAAA,EAAA,cACAlqB,KAAA6pB,MAAAhvB,QAAAsL,MACA,CACA,CAKA,GAAAb,GAEA,OADAtF,KAAA8pB,QAAA,EACA9pB,KAAA6pB,MACA7pB,KAAA6pB,MAAAhvB,QAAAyK,OACA,CACA,CAIA,eAAAmlB,GACA,OAAAzqB,KAAA6pB,MACA7pB,KAAAgqB,KAAA,MACA,CACA,CAIA,aAAAU,GACA,OAAA1qB,KAAA6pB,MACA7pB,KAAA4pB,SAAA5pB,KAAAgqB,KAAA,MACA,CACA,cC9UAxtB,OAAAC,eAAA5B,EAAA,cAA+CiB,OAAA,IAK/C,MACA6uB,GAAA,EADyB3rB,EAAQ,KACjCsqB,QAAA,CAAuGhrB,EAAA,EAAAuf,EAAA,EAAA1f,EAAA,i5BA8DvGtD,EAAA,QA7DA,MACA,WAAAkF,CAAA4pB,GACA3pB,KAAA2pB,SAAAA,EACA3pB,KAAAE,MAAA,EACAF,KAAAgE,OAAA,CACA,CACA,MAAApF,CAAAT,GACA6B,KAAAE,MAAA/B,EAAA,OAAAA,EAAA,OAAAA,EAAA,MAAAA,EAAA,GACA6B,KAAAgE,OAAA7F,EAAA,OAAAA,EAAA,OAAAA,EAAA,OAAAA,EAAA,IACA,MAAA4F,EAAA/D,KAAAE,MAAAF,KAAAgE,OACA4mB,EAAA,EAAA7mB,EACA8mB,EAAA1sB,EAAAJ,OAwBAqkB,EAAArmB,KAAAC,IAAA4uB,EAAAC,IAAA9uB,KAAAE,IAAA2uB,EAAAC,IAAA,QACA7qB,KAAA6pB,MAIA7pB,KAAAiqB,KAAA3sB,OAAAuH,WAAAud,IACApiB,KAAAiqB,KAAAE,KAAApuB,KAAAuI,MAAA8d,EAAApiB,KAAAiqB,KAAA3sB,OAAAuH,YAAA,QACA7E,KAAAuO,GAAA,OALAvO,KAAAiqB,KAAA,IAAA9oB,YAAAipB,OAAA,CAAiD7hB,QAAAxM,KAAAuI,KAAA8d,EAAA,SACjDpiB,KAAA6pB,MAAAc,EAAA,CAAyC1pB,IAAA,CAAO0B,OAAA3C,KAAAiqB,SAMhDjqB,KAAAuO,KACAvO,KAAAuO,GAAA,IAAAnR,WAAA4C,KAAAiqB,KAAA3sB,SAGA,MAAAwtB,EAAA9qB,KAAAiqB,KAAA3sB,OAAAuH,WAAAgmB,GAAA,IAGA,OAFA7qB,KAAAuO,GAAArL,IAAA/E,EAAA2sB,GACA9qB,KAAA6pB,MAAAhvB,QAAAsL,IAAA2kB,EAAAD,EAAA9mB,GACA/D,KAAAuO,GAAA9J,SAAA,UAAAmmB,EACA,CACA,OAAA3mB,GACAjE,KAAA6pB,OAEA7pB,KAAAiqB,KAAA3sB,OAAAuH,WAAA7E,KAAA2pB,WACA3pB,KAAA6pB,MAAA7pB,KAAAuO,GAAAvO,KAAAiqB,KAAA,KAEA,aC9DAztB,OAAAC,eAAA5B,EAAA,cAA+CiB,OAAA,IAC/CjB,EAAAyuB,OAYA,SAAAyB,GACA,GAAAA,EAAA5sB,EAAA,CACA,MAAA0f,EAAgBvf,EAAAA,EAAAH,EAAAA,GAAU4sB,EAC1B,IAAAtvB,EACAyuB,EACA,MAAAc,EAAA7pB,YACA,WAAA0c,EACAvf,EACAwJ,GAAA,IAAAkjB,EAAAxoB,SAAA0nB,IAAAA,EAAA,IAAAc,EAAAzoB,OAAA9G,IAAAA,EAAAwvB,EAAA9sB,MAAA2J,GACAA,GAAAoiB,EACAc,EAAA5pB,YAAA8oB,EAAApiB,GACAkjB,EAAA5pB,YAAA3F,IAAAA,EAAAwvB,EAAA9sB,IAAA2J,GAAAzG,KAAA9F,IAAA2uB,EAAA3uB,EAAAT,SAAAS,EAAAgG,UAEA,IAAAsc,EACAvf,EACA,IAAA4rB,IAAAA,EAAA,IAAAc,EAAAzoB,OAAA9G,IAAAA,EAAAwvB,EAAA9sB,MACA,IAAA+rB,EACAtY,QAAAC,QAAAqY,GACAc,EAAAE,QAAAzvB,IAAAA,EAAAwvB,EAAA9sB,KAAAkD,KAAA9F,GAAA2uB,EAAA3uB,GAEA+C,EACA,IAAA7C,IAAAA,EAAAwvB,EAAA9sB,IACA,IAAAyT,QAAAC,QAAApW,IAAAA,EAAAwvB,EAAA9sB,IACA,CACA,uBAAAgtB,SACA,UAAA/oB,MAAA,qBACA+oB,SAAA7jB,IAAAyjB,EACA,EAtCA,IAAAE,EAAA3sB,IACA,GAAAlB,WAAAguB,WACA,OAAAhuB,WAAAguB,WAAA9sB,GACA,uBAAAa,OACA,OAAAA,OAAAC,KAAAd,EAAA,UACA,MAAA7C,EAAA6D,KAAAhB,GACA/C,EAAA,IAAA6B,WAAA3B,EAAAsC,QACA,QAAAD,EAAA,EAAoBA,EAAAvC,EAAAwC,SAAcD,EAClCvC,EAAAuC,GAAArC,EAAA+D,WAAA1B,GACA,OAAAvC,KCfA8vB,EAAA,GAGA,SAAArsB,EAAAssB,GAEA,IAAAC,EAAAF,EAAAC,GACA,QAAAtjB,IAAAujB,EACA,OAAAA,EAAA1wB,QAGA,IAAAC,EAAAuwB,EAAAC,GAAA,CAGAzwB,QAAA,IAOA,OAHA2wB,EAAAF,GAAA9jB,KAAA1M,EAAAD,QAAAC,EAAAA,EAAAD,QAAAmE,GAGAlE,EAAAD,OACA,mGCfA,MAAA4wB,EAAAzsB,EAAA,KACA0sB,EAAA1sB,EAAA,KACAqK,EAAArK,EAAA,KACAsK,EAAAtK,EAAA,KACA2sB,EAAA3sB,EAAA,IACA4sB,EAAA5sB,EAAA,KACA6sB,EAAA7sB,EAAA,KACA8sB,EAAA9sB,EAAA,KACA+sB,EAAA/sB,EAAA,KA6CMuB,EAAsC,CAC1CyrB,mBAAmB,EACnB/e,WAAY,SACZgf,cAAc,EACdpN,gBAAgB,EAChBL,kBAAmB,KACnBC,eAAgB,SAChBrG,aAAc,IACdjF,iBAAiB,EACjB+Y,YAAY,EACZrhB,aAAc,SACdshB,cAAc,EACdzM,eAAgB,uBA0BlB,MAWE,WAAA3f,CAAYe,GANJd,KAAAyI,aAA8B,GAE9BzI,KAAAosB,UAAwC,IAAIpa,IACnChS,KAAAqsB,cAAgB,IAAIZ,EAAAa,QACrBtsB,KAAA0a,aAA6B1a,KAAKqsB,cAAc5lB,MAG9DzG,KAAKkC,MAAQ1F,OAAO2F,OAAO,GAAI5B,EAAiBO,GAChDd,KAAKusB,aAAe/vB,OAAO2F,OAAO,GAAI5B,EAAiBO,EACzD,CAEO,OAAA6G,GACL,IAAK,MAAMW,KAAWtI,KAAKosB,UAAUrX,SAAUzM,EAAQ4C,QACvD,IAAK,MAAMshB,KAAOxsB,KAAKyI,aACrB+jB,EAAI7kB,UAEN3H,KAAKyI,aAAa1K,OAAS,EAC3BiC,KAAKosB,UAAUtjB,QACf9I,KAAKqsB,cAAc1kB,SACrB,CAEQ,aAAA8kB,IAAiBC,GACvB,IAAK,MAAMF,KAAOE,EAChB1sB,KAAKyI,aAAa/J,KAAK8tB,EAE3B,CAEO,QAAAG,CAASC,SASd,GARA5sB,KAAK8R,UAAY8a,EAGjB5sB,KAAKkK,UAAY,IAAIb,EAAAiE,cAAcsf,GACnC5sB,KAAKmK,SAAW,IAAIb,EAAAujB,aAAaD,EAAU5sB,KAAKkK,UAAWlK,KAAKkC,OAChElC,KAAKmK,SAASuQ,aAAe,IAAM1a,KAAKqsB,cAAc9kB,OAGlDvH,KAAKkC,MAAM8pB,kBAAmB,CAChC,MAAMc,EAA0C,QAAjC1gB,EAAGwgB,EAASG,QAAQC,qBAAa,IAAA5gB,EAAAA,EAAI,GACpD0gB,EAAUG,kBAAmB,EAC7BH,EAAUI,mBAAoB,EAC9BJ,EAAUK,iBAAkB,EAC5BP,EAASG,QAAQC,cAAgBF,CACnC,CAiCA,GA/BA9sB,KAAKysB,cACHzsB,KAAKkK,UACLlK,KAAKmK,SAGLyiB,EAASQ,OAAOC,mBAAmB,CAAEC,OAAQ,IAAKznB,MAAO,KAAOqX,GAAUld,KAAKutB,QAAQrQ,IACvF0P,EAASQ,OAAOC,mBAAmB,CAAEC,OAAQ,IAAKznB,MAAO,KAAOqX,GAAUld,KAAKwtB,QAAQtQ,IACvF0P,EAASQ,OAAOC,mBAAmB,CAAExnB,MAAO,KAAOqX,GAAUld,KAAKytB,KAAKvQ,IACvE0P,EAASQ,OAAOC,mBAAmB,CAAEC,OAAQ,IAAKznB,MAAO,KAAOqX,GAAUld,KAAK0tB,yBAAyBxQ,IAGxG0P,EAASe,SAAS/S,IAAQ,IAAAxO,EAAA,OAAc,QAAbA,EAAApM,KAAKmK,gBAAQ,IAAAiC,OAAA,EAAAA,EAAEuO,OAAOC,KAQjDgS,EAASQ,OAAOC,mBAAmB,CAAEO,cAAe,IAAK/nB,MAAO,KAAO,IAAM7F,KAAKkL,SAClF0hB,EAASQ,OAAOS,mBAAmB,CAAEhoB,MAAO,KAAO,IAAM7F,KAAKkL,SAC9D0hB,EAASvgB,MAAMwN,cAAciU,eAAe,IAAM9tB,KAAKkL,SAGvD0hB,EAAStvB,OAAOywB,eAAe,KAAK,IAAA3hB,EAAA,OAAc,QAAbA,EAAApM,KAAKmK,gBAAQ,IAAAiC,OAAA,EAAAA,EAAE+M,kBAGpDyT,EAASoB,SAASnhB,IAAU,IAAAT,EAAA,OAAc,QAAbA,EAAApM,KAAKmK,gBAAQ,IAAAiC,OAAA,EAAAA,EAAEmP,eAAe1O,MAIzD7M,KAAKkC,MAAM+pB,aAAc,CAC3B,MAAMgC,EAAe,IAAInC,EAAAoC,kBAAkBluB,KAAKmK,SAAWnK,KAAKkC,MAAOlC,KAAKkK,UAAY0iB,GAClFuB,EAAe,IAAItC,EAAAuC,aAAapuB,KAAKkC,MAAO+rB,EAAcrB,GAChE5sB,KAAKosB,UAAUlpB,IAAI,QAASirB,GAC5BnuB,KAAKysB,cACHG,EAASvgB,MAAMwN,cAAcwU,QAAQC,mBAAmB,CAAEzoB,MAAO,KAAOsoB,GAE5E,CAGA,GAAInuB,KAAKkC,MAAMgqB,WAAY,CACzB,MAAMqC,EAAa,IAAIxC,EAAAyC,gBAAgBxuB,KAAKmK,UACtCskB,EAAa,IAAI/C,EAAAgD,WAAW1uB,KAAKkC,MAAOlC,KAAKkK,UAAYqkB,EAAY3B,GAC3E5sB,KAAKosB,UAAUlpB,IAAI,MAAOurB,GAC1BzuB,KAAKysB,cACHG,EAASvgB,MAAMwN,cAAcwU,QAAQM,mBAAmB,KAAMF,GAElE,CAGA,GAAIzuB,KAAKkC,MAAMiqB,aAAc,CAC3B,MAAMyC,EAAe,IAAIhD,EAAAvD,kBAAkBroB,KAAKmK,UAC1C0kB,EAAe,IAAIlD,EAAAmD,qBAAqB9uB,KAAKkC,MAAOlC,KAAKkK,UAAY0kB,EAAchC,GACzF5sB,KAAKosB,UAAUlpB,IAAI,QAAS2rB,GAC5B7uB,KAAKysB,cACHmC,EACAC,EACAjC,EAASvgB,MAAMwN,cAAcwU,QAAQU,mBAAmB,CAAElpB,MAAO,KAAOgpB,GAE5E,CACF,CAGO,KAAA3jB,SAELlL,KAAKkC,MAAM2c,eAAiB7e,KAAKusB,aAAa1N,eAC9C7e,KAAKkC,MAAMsc,kBAAoBxe,KAAKusB,aAAa/N,kBAEpC,QAAbpS,EAAApM,KAAKmK,gBAAQ,IAAAiC,GAAAA,EAAElB,QAEf,IAAK,MAAM5C,KAAWtI,KAAKosB,UAAUrX,SACnCzM,EAAQ4C,QAEV,OAAO,CACT,CAEA,gBAAWkN,SACT,OAAoB,QAAbhM,EAAApM,KAAKmK,gBAAQ,IAAAiC,OAAA,EAAAA,EAAEmM,cAAe,CACvC,CAEA,gBAAWH,CAAajH,SACT,QAAb/E,EAAApM,KAAKmK,gBAAQ,IAAAiC,GAAAA,EAAE+L,SAAShH,GACxBnR,KAAKkC,MAAMkW,aAAejH,CAC5B,CAEA,gBAAW6d,GACT,OAAIhvB,KAAKmK,SACAnK,KAAKmK,SAASyO,YAEf,CACV,CAEA,mBAAWzF,GACT,OAAOnT,KAAKkC,MAAMiR,eACpB,CAEA,mBAAWA,CAAgBrX,SACzBkE,KAAKkC,MAAMiR,gBAAkBrX,EACf,QAAdsQ,EAAApM,KAAKkK,iBAAS,IAAAkC,GAAAA,EAAE+G,gBAAgBrX,EAClC,CAEO,oBAAAigB,CAAqBrF,EAAWnD,SACrC,OAAoB,QAAbnH,EAAApM,KAAKmK,gBAAQ,IAAAiC,OAAA,EAAAA,EAAE2P,qBAAqBrF,EAAGnD,EAChD,CAEO,uBAAAyI,CAAwBtF,EAAWnD,SACxC,OAAoB,QAAbnH,EAAApM,KAAKmK,gBAAQ,IAAAiC,OAAA,EAAAA,EAAE4P,wBAAwBtF,EAAGnD,EACnD,CAEQ,OAAA0b,CAAQ3wB,SACA,QAAd8N,EAAApM,KAAK8R,iBAAS,IAAA1F,GAAAA,EAAEC,MAAMK,MAAMpO,GAAG,EACjC,CAEQ,OAAAivB,CAAQrQ,GACd,IAAK,IAAIpf,EAAI,EAAGA,EAAIof,EAAOnf,SAAUD,EAE5B,KADCof,EAAOpf,KAEXkC,KAAKkC,MAAM2c,gBAAiB,GAIlC,OAAO,CACT,CAEQ,OAAA2O,CAAQtQ,GACd,IAAK,IAAIpf,EAAI,EAAGA,EAAIof,EAAOnf,SAAUD,EAE5B,KADCof,EAAOpf,KAEXkC,KAAKkC,MAAM2c,gBAAiB,GAIlC,OAAO,CACT,CAGQ,IAAA4O,CAAKvQ,GACX,QAAIA,EAAO,MAQPld,KAAKkC,MAAM+pB,eACbjsB,KAAKivB,QAAQ,kBACN,EAGX,CAYQ,wBAAAvB,CAAyBxQ,mBAC/B,GAAIA,EAAOnf,OAAS,EAClB,OAAO,EAET,GAAa,IAATmf,EAAO,GACT,OAAQA,EAAO,IACb,OAEE,OADAld,KAAKivB,QAAQ,MAAS/R,EAAO,QAA0Bld,KAAKkC,MAAMsc,uBAC3D,EACT,OACExe,KAAKkC,MAAMsc,kBAAoBxe,KAAKusB,aAAa/N,kBACjDxe,KAAKivB,QAAQ,MAAS/R,EAAO,QAA0Bld,KAAKkC,MAAMsc,sBAElE,IAAK,MAAMlW,KAAWtI,KAAKosB,UAAUrX,SACnCzM,EAAQ4C,QAEV,OAAO,EACT,OAOE,OANIgS,EAAOnf,OAAS,KAAOmf,EAAO,aAAc9V,QAAU8V,EAAO,IA5P5C,MA6PnBld,KAAKkC,MAAMsc,kBAAoBtB,EAAO,GACtCld,KAAKivB,QAAQ,MAAS/R,EAAO,QAA0Bld,KAAKkC,MAAMsc,uBAElExe,KAAKivB,QAAQ,MAAS/R,EAAO,UAExB,EACT,OAEE,OADAld,KAAKivB,QAAQ,MAAS/R,EAAO,eACtB,EACT,QAEE,OADAld,KAAKivB,QAAQ,MAAS/R,EAAO,UACtB,EAGb,GAAa,IAATA,EAAO,GACT,OAAQA,EAAO,IAEb,OACE,IAAIhd,EAAkC,QAA1BsN,EAAc,QAAdpB,EAAApM,KAAKkK,iBAAS,IAAAkC,OAAA,EAAAA,EAAEP,kBAAU,IAAA2B,OAAA,EAAAA,EAAE1B,IAAIC,OAAO7L,MAC/C8D,EAAmC,QAA1BuK,EAAc,QAAdpC,EAAAnM,KAAKkK,iBAAS,IAAAiC,OAAA,EAAAA,EAAEN,kBAAU,IAAA0C,OAAA,EAAAA,EAAEzC,IAAIC,OAAO/H,OACpD,IAAK9D,IAAU8D,EAAQ,CAGrB,MAAMoP,EAAW9J,EAAAsC,kBACjB1L,IAAuB,QAAduT,EAAAzT,KAAK8R,iBAAS,IAAA2B,OAAA,EAAAA,EAAEzH,OAAQ,IAAMoH,EAASlT,MAChD8D,IAAwB,QAAd6c,EAAA7gB,KAAK8R,iBAAS,IAAA+O,OAAA,EAAAA,EAAE5U,OAAQ,IAAMmH,EAASpP,MACnD,CACA,GAAI9D,EAAQ8D,EAAShE,KAAKkC,MAAM+K,WAC9BjN,KAAKivB,QAAQ,MAAS/R,EAAO,QAA0Bhd,EAAMuM,QAAQ,MAAMzI,EAAOyI,QAAQ,WACrF,CAEL,MAAMiK,EAAI3a,KAAKoR,MAAMpR,KAAKmzB,KAAKlvB,KAAKkC,MAAM+K,aAC1CjN,KAAKivB,QAAQ,MAAS/R,EAAO,QAA0BxG,KAAKA,KAC9D,CACA,OAAO,EACT,OAEE,MAAMA,EAAI3a,KAAKoR,MAAMpR,KAAKmzB,KAAKlvB,KAAKkC,MAAM+K,aAE1C,OADAjN,KAAKivB,QAAQ,MAAS/R,EAAO,QAA0BxG,KAAKA,OACrD,EACT,QAEE,OADA1W,KAAKivB,QAAQ,MAAS/R,EAAO,UACtB,EAKb,OADAld,KAAKivB,QAAQ,MAAS/R,EAAO,UACtB,CACT","sources":["webpack://ImageAddon/webpack/universalModuleDefinition","webpack://ImageAddon/../node_modules/sixel/lib/Colors.js","webpack://ImageAddon/../node_modules/sixel/lib/Decoder.js","webpack://ImageAddon/../node_modules/sixel/lib/wasm.js","webpack://ImageAddon/../src/common/Event.ts","webpack://ImageAddon/../src/common/Lifecycle.ts","webpack://ImageAddon/./src/IIPHandler.ts","webpack://ImageAddon/./src/IIPHeaderParser.ts","webpack://ImageAddon/./src/IIPImageStorage.ts","webpack://ImageAddon/./src/IIPMetrics.ts","webpack://ImageAddon/./src/ImageRenderer.ts","webpack://ImageAddon/./src/ImageStorage.ts","webpack://ImageAddon/./src/SixelHandler.ts","webpack://ImageAddon/./src/SixelImageStorage.ts","webpack://ImageAddon/./src/kitty/KittyGraphicsHandler.ts","webpack://ImageAddon/./src/kitty/KittyGraphicsTypes.ts","webpack://ImageAddon/./src/kitty/KittyImageStorage.ts","webpack://ImageAddon/../node_modules/xterm-wasm-parts/lib/base64/Base64Decoder.wasm.js","webpack://ImageAddon/../node_modules/xterm-wasm-parts/lib/qoi/QoiDecoder.wasm.js","webpack://ImageAddon/javascript/node_modules/inwasm-runtime/lib/index.cjs","webpack://ImageAddon/webpack/bootstrap","webpack://ImageAddon/./src/ImageAddon.ts"],"sourcesContent":["(function webpackUniversalModuleDefinition(root, factory) {\n\tif(typeof exports === 'object' && typeof module === 'object')\n\t\tmodule.exports = factory();\n\telse if(typeof define === 'function' && define.amd)\n\t\tdefine([], factory);\n\telse if(typeof exports === 'object')\n\t\texports[\"ImageAddon\"] = factory();\n\telse\n\t\troot[\"ImageAddon\"] = factory();\n})(globalThis, () => {\nreturn ","\"use strict\";\n/**\n * Copyright (c) 2019 Joerg Breitbart.\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.DEFAULT_FOREGROUND = exports.DEFAULT_BACKGROUND = exports.PALETTE_ANSI_256 = exports.PALETTE_VT340_GREY = exports.PALETTE_VT340_COLOR = exports.normalizeHLS = exports.normalizeRGB = exports.nearestColorIndex = exports.fromRGBA8888 = exports.toRGBA8888 = exports.alpha = exports.blue = exports.green = exports.red = exports.BIG_ENDIAN = void 0;\n// FIXME: cleanup this mess, move things either to decoder/encoder, keep only shared things\n// system endianess\nexports.BIG_ENDIAN = new Uint8Array(new Uint32Array([0xFF000000]).buffer)[0] === 0xFF;\nif (exports.BIG_ENDIAN) {\n console.warn('BE platform detected. This version of node-sixel works only on LE properly.');\n}\n// channel values\nfunction red(n) {\n return n & 0xFF;\n}\nexports.red = red;\nfunction green(n) {\n return (n >>> 8) & 0xFF;\n}\nexports.green = green;\nfunction blue(n) {\n return (n >>> 16) & 0xFF;\n}\nexports.blue = blue;\nfunction alpha(n) {\n return (n >>> 24) & 0xFF;\n}\nexports.alpha = alpha;\n/**\n * Convert RGB channels to native color RGBA8888.\n */\nfunction toRGBA8888(r, g, b, a = 255) {\n return ((a & 0xFF) << 24 | (b & 0xFF) << 16 | (g & 0xFF) << 8 | (r & 0xFF)) >>> 0; // ABGR32\n}\nexports.toRGBA8888 = toRGBA8888;\n/**\n * Convert native color to [r, g, b, a].\n */\nfunction fromRGBA8888(color) {\n return [color & 0xFF, (color >> 8) & 0xFF, (color >> 16) & 0xFF, color >>> 24];\n}\nexports.fromRGBA8888 = fromRGBA8888;\n/**\n * Get index of nearest color in `palette` for `color`.\n * Uses euclidean distance without any luminescence correction.\n */\nfunction nearestColorIndex(color, palette) {\n const r = red(color);\n const g = green(color);\n const b = blue(color);\n let min = Number.MAX_SAFE_INTEGER;\n let idx = -1;\n // use euclidean distance (manhattan gives very poor results)\n for (let i = 0; i < palette.length; ++i) {\n const dr = r - palette[i][0];\n const dg = g - palette[i][1];\n const db = b - palette[i][2];\n const d = dr * dr + dg * dg + db * db;\n if (!d)\n return i;\n if (d < min) {\n min = d;\n idx = i;\n }\n }\n return idx;\n}\nexports.nearestColorIndex = nearestColorIndex;\n// color conversions\n// HLS taken from: http://www.niwa.nu/2013/05/math-behind-colorspace-conversions-rgb-hsl\nfunction clamp(low, high, value) {\n return Math.max(low, Math.min(value, high));\n}\nfunction h2c(t1, t2, c) {\n if (c < 0)\n c += 1;\n if (c > 1)\n c -= 1;\n return c * 6 < 1\n ? t2 + (t1 - t2) * 6 * c\n : c * 2 < 1\n ? t1\n : c * 3 < 2\n ? t2 + (t1 - t2) * (4 - c * 6)\n : t2;\n}\nfunction HLStoRGB(h, l, s) {\n if (!s) {\n const v = Math.round(l * 255);\n return toRGBA8888(v, v, v);\n }\n const t1 = l < 0.5 ? l * (1 + s) : l + s - l * s;\n const t2 = 2 * l - t1;\n return toRGBA8888(clamp(0, 255, Math.round(h2c(t1, t2, h + 1 / 3) * 255)), clamp(0, 255, Math.round(h2c(t1, t2, h) * 255)), clamp(0, 255, Math.round(h2c(t1, t2, h - 1 / 3) * 255)));\n}\n/**\n * Normalize SIXEL RGB values (percent based, 0-100) to RGBA8888.\n */\nfunction normalizeRGB(r, g, b) {\n return (0xFF000000 | Math.round(b / 100 * 255) << 16 | Math.round(g / 100 * 255) << 8 | Math.round(r / 100 * 255)) >>> 0; // ABGR32\n}\nexports.normalizeRGB = normalizeRGB;\n/**\n * Normalize SIXEL HLS values to RGBA8888. Applies hue correction of +240°.\n */\nfunction normalizeHLS(h, l, s) {\n // Note: hue value is turned by 240° in VT340, all values given as fractions\n return HLStoRGB((h + 240 % 360) / 360, l / 100, s / 100);\n}\nexports.normalizeHLS = normalizeHLS;\n/**\n * default palettes\n */\n// FIXME: move palettes to Decoder.ts\n/**\n * 16 predefined color registers of VT340 (values in %):\n * ```\n * R G B\n * 0 Black 0 0 0\n * 1 Blue 20 20 80\n * 2 Red 80 13 13\n * 3 Green 20 80 20\n * 4 Magenta 80 20 80\n * 5 Cyan 20 80 80\n * 6 Yellow 80 80 20\n * 7 Gray 50% 53 53 53\n * 8 Gray 25% 26 26 26\n * 9 Blue* 33 33 60\n * 10 Red* 60 26 26\n * 11 Green* 33 60 33\n * 12 Magenta* 60 33 60\n * 13 Cyan* 33 60 60\n * 14 Yellow* 60 60 33\n * 15 Gray 75% 80 80 80\n * ```\n * (*) less saturated\n *\n * @see https://vt100.net/docs/vt3xx-gp/chapter2.html#S2.4\n*/\nexports.PALETTE_VT340_COLOR = new Uint32Array([\n normalizeRGB(0, 0, 0),\n normalizeRGB(20, 20, 80),\n normalizeRGB(80, 13, 13),\n normalizeRGB(20, 80, 20),\n normalizeRGB(80, 20, 80),\n normalizeRGB(20, 80, 80),\n normalizeRGB(80, 80, 20),\n normalizeRGB(53, 53, 53),\n normalizeRGB(26, 26, 26),\n normalizeRGB(33, 33, 60),\n normalizeRGB(60, 26, 26),\n normalizeRGB(33, 60, 33),\n normalizeRGB(60, 33, 60),\n normalizeRGB(33, 60, 60),\n normalizeRGB(60, 60, 33),\n normalizeRGB(80, 80, 80)\n]);\n/**\n * 16 predefined monochrome registers of VT340 (values in %):\n * ```\n * R G B\n * 0 Black 0 0 0\n * 1 Gray-2 13 13 13\n * 2 Gray-4 26 26 26\n * 3 Gray-6 40 40 40\n * 4 Gray-1 6 6 6\n * 5 Gray-3 20 20 20\n * 6 Gray-5 33 33 33\n * 7 White 7 46 46 46\n * 8 Black 0 0 0 0\n * 9 Gray-2 13 13 13\n * 10 Gray-4 26 26 26\n * 11 Gray-6 40 40 40\n * 12 Gray-1 6 6 6\n * 13 Gray-3 20 20 20\n * 14 Gray-5 33 33 33\n * 15 White 7 46 46 46\n * ```\n *\n * @see https://vt100.net/docs/vt3xx-gp/chapter2.html#S2.4\n */\nexports.PALETTE_VT340_GREY = new Uint32Array([\n normalizeRGB(0, 0, 0),\n normalizeRGB(13, 13, 13),\n normalizeRGB(26, 26, 26),\n normalizeRGB(40, 40, 40),\n normalizeRGB(6, 6, 6),\n normalizeRGB(20, 20, 20),\n normalizeRGB(33, 33, 33),\n normalizeRGB(46, 46, 46),\n normalizeRGB(0, 0, 0),\n normalizeRGB(13, 13, 13),\n normalizeRGB(26, 26, 26),\n normalizeRGB(40, 40, 40),\n normalizeRGB(6, 6, 6),\n normalizeRGB(20, 20, 20),\n normalizeRGB(33, 33, 33),\n normalizeRGB(46, 46, 46)\n]);\n/**\n * 256 predefined ANSI colors.\n *\n * @see https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit\n */\nexports.PALETTE_ANSI_256 = (() => {\n // 16 lower colors (taken from xterm)\n const p = [\n toRGBA8888(0, 0, 0),\n toRGBA8888(205, 0, 0),\n toRGBA8888(0, 205, 0),\n toRGBA8888(205, 205, 0),\n toRGBA8888(0, 0, 238),\n toRGBA8888(205, 0, 205),\n toRGBA8888(0, 250, 205),\n toRGBA8888(229, 229, 229),\n toRGBA8888(127, 127, 127),\n toRGBA8888(255, 0, 0),\n toRGBA8888(0, 255, 0),\n toRGBA8888(255, 255, 0),\n toRGBA8888(92, 92, 255),\n toRGBA8888(255, 0, 255),\n toRGBA8888(0, 255, 255),\n toRGBA8888(255, 255, 255),\n ];\n // colors up to 232\n const d = [0, 95, 135, 175, 215, 255];\n for (let r = 0; r < 6; ++r) {\n for (let g = 0; g < 6; ++g) {\n for (let b = 0; b < 6; ++b) {\n p.push(toRGBA8888(d[r], d[g], d[b]));\n }\n }\n }\n // grey scale to up 255\n for (let v = 8; v <= 238; v += 10) {\n p.push(toRGBA8888(v, v, v));\n }\n return new Uint32Array(p);\n})();\n/**\n * Background: Black by default.\n * Foreground: White by default.\n *\n * Background color is used whenever a fill color is needed and not explicitly set.\n * Foreground color is used as default initial sixel color.\n */\nexports.DEFAULT_BACKGROUND = toRGBA8888(0, 0, 0, 255);\nexports.DEFAULT_FOREGROUND = toRGBA8888(255, 255, 255, 255);\n//# sourceMappingURL=Colors.js.map","\"use strict\";\n/**\n * Copyright (c) 2021 Joerg Breitbart.\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.decodeAsync = exports.decode = exports.Decoder = exports.DecoderAsync = void 0;\nconst Colors_1 = require(\"./Colors\");\nconst wasm_1 = require(\"./wasm\");\n/* istanbul ignore next */\nfunction decodeBase64(s) {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(s, 'base64');\n }\n const bytestring = atob(s);\n const result = new Uint8Array(bytestring.length);\n for (let i = 0; i < result.length; ++i) {\n result[i] = bytestring.charCodeAt(i);\n }\n return result;\n}\nconst WASM_BYTES = decodeBase64(wasm_1.LIMITS.BYTES);\nlet WASM_MODULE;\n// empty canvas\nconst NULL_CANVAS = new Uint32Array();\n// proxy for lazy binding of decoder methods to wasm env callbacks\nclass CallbackProxy {\n constructor() {\n this.bandHandler = (width) => 1;\n this.modeHandler = (mode) => 1;\n }\n handle_band(width) {\n return this.bandHandler(width);\n }\n mode_parsed(mode) {\n return this.modeHandler(mode);\n }\n}\n// default decoder options\nconst DEFAULT_OPTIONS = {\n memoryLimit: 2048 * 65536,\n sixelColor: Colors_1.DEFAULT_FOREGROUND,\n fillColor: Colors_1.DEFAULT_BACKGROUND,\n palette: Colors_1.PALETTE_VT340_COLOR,\n paletteLimit: wasm_1.LIMITS.PALETTE_SIZE,\n truncate: true\n};\n/**\n * Create a decoder instance asynchronously.\n * To be used in the browser main thread.\n */\nfunction DecoderAsync(opts) {\n const cbProxy = new CallbackProxy();\n const importObj = {\n env: {\n handle_band: cbProxy.handle_band.bind(cbProxy),\n mode_parsed: cbProxy.mode_parsed.bind(cbProxy)\n }\n };\n return WebAssembly.instantiate(WASM_MODULE || WASM_BYTES, importObj)\n .then((inst) => {\n WASM_MODULE = WASM_MODULE || inst.module;\n return new Decoder(opts, inst.instance || inst, cbProxy);\n });\n}\nexports.DecoderAsync = DecoderAsync;\n/**\n * Decoder - web assembly based sixel stream decoder.\n *\n * Usage pattern:\n * - call `init` to initialize decoder for new image\n * - feed data chunks to `decode` or `decodeString`\n * - grab pixels from `data32`\n * - optional: call `release` to free memory (e.g. after big images)\n * - start over with next image by calling `init`\n *\n * Properties:\n * - max width of 2^14 - 4 pixels (compile time setting in wasm)\n * - no explicit height limit (only limited by memory)\n * - max 4096 colors palette (compile time setting in wasm)\n *\n * Explanation operation modes:\n * - M1 Mode chosen for level 1 images (no raster attributes),\n * or for level 2 images with `truncate=false`.\n * - M2 Mode chosen for level 2 images with `truncate=true` (default).\n * While this mode is not fully spec conform (decoder not expected to truncate),\n * it is what spec conform encoders should create (should not excess raster).\n * This mode has several advantages:\n * - ~15% faster decoding speed\n * - image dimensions can be evaluated early without processing the whole data\n * - faster pixel access in `data32` (precalulated)\n * - image height is not reported as multiple of 6 pixels\n * - M0 Undecided mode state after `init`.\n * The level of an image is determined during early decoding based on the fact,\n * whether the data contains valid raster attributes before any sixel data.\n * Until then the mode of an image is marked as M0, meaning the real operation mode\n * could not be decided yet.\n */\nclass Decoder {\n /**\n * Synchonous ctor. Can be called from nodejs or a webworker context.\n * For instantiation in the browser main thread use `WasmDecoderAsync` instead.\n */\n constructor(opts, _instance, _cbProxy) {\n this._PIXEL_OFFSET = wasm_1.LIMITS.MAX_WIDTH + 4;\n this._canvas = NULL_CANVAS;\n this._bandWidths = [];\n this._maxWidth = 0;\n this._minWidth = wasm_1.LIMITS.MAX_WIDTH;\n this._lastOffset = 0;\n this._currentHeight = 0;\n this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);\n if (this._opts.paletteLimit > wasm_1.LIMITS.PALETTE_SIZE) {\n throw new Error(`DecoderOptions.paletteLimit must not exceed ${wasm_1.LIMITS.PALETTE_SIZE}`);\n }\n if (!_instance) {\n const module = WASM_MODULE || (WASM_MODULE = new WebAssembly.Module(WASM_BYTES));\n _instance = new WebAssembly.Instance(module, {\n env: {\n handle_band: this._handle_band.bind(this),\n mode_parsed: this._initCanvas.bind(this)\n }\n });\n }\n else {\n _cbProxy.bandHandler = this._handle_band.bind(this);\n _cbProxy.modeHandler = this._initCanvas.bind(this);\n }\n this._instance = _instance;\n this._wasm = this._instance.exports;\n this._chunk = new Uint8Array(this._wasm.memory.buffer, this._wasm.get_chunk_address(), wasm_1.LIMITS.CHUNK_SIZE);\n this._states = new Uint32Array(this._wasm.memory.buffer, this._wasm.get_state_address(), 12);\n this._palette = new Uint32Array(this._wasm.memory.buffer, this._wasm.get_palette_address(), wasm_1.LIMITS.PALETTE_SIZE);\n this._palette.set(this._opts.palette);\n this._pSrc = new Uint32Array(this._wasm.memory.buffer, this._wasm.get_p0_address());\n this._wasm.init(Colors_1.DEFAULT_FOREGROUND, 0, this._opts.paletteLimit, 0);\n }\n // some readonly parser states for internal usage\n get _fillColor() { return this._states[0]; }\n get _truncate() { return this._states[8]; }\n get _rasterWidth() { return this._states[6]; }\n get _rasterHeight() { return this._states[7]; }\n get _width() { return this._states[2] ? this._states[2] - 4 : 0; }\n get _height() { return this._states[3]; }\n get _level() { return this._states[9]; }\n get _mode() { return this._states[10]; }\n get _paletteLimit() { return this._states[11]; }\n _initCanvas(mode) {\n if (mode === 2 /* M2 */) {\n const pixels = this.width * this.height;\n if (pixels > this._canvas.length) {\n if (this._opts.memoryLimit && pixels * 4 > this._opts.memoryLimit) {\n this.release();\n throw new Error('image exceeds memory limit');\n }\n this._canvas = new Uint32Array(pixels);\n }\n this._maxWidth = this._width;\n }\n else if (mode === 1 /* M1 */) {\n if (this._level === 2) {\n // got raster attributes, use them as initial size hint\n const pixels = Math.min(this._rasterWidth, wasm_1.LIMITS.MAX_WIDTH) * this._rasterHeight;\n if (pixels > this._canvas.length) {\n if (this._opts.memoryLimit && pixels * 4 > this._opts.memoryLimit) {\n this.release();\n throw new Error('image exceeds memory limit');\n }\n this._canvas = new Uint32Array(pixels);\n }\n }\n else {\n // else fallback to generic resizing, starting with 256*256 pixels\n if (this._canvas.length < 65536) {\n this._canvas = new Uint32Array(65536);\n }\n }\n }\n return 0; // 0 - continue, 1 - abort right away\n }\n _realloc(offset, additionalPixels) {\n const pixels = offset + additionalPixels;\n if (pixels > this._canvas.length) {\n if (this._opts.memoryLimit && pixels * 4 > this._opts.memoryLimit) {\n this.release();\n throw new Error('image exceeds memory limit');\n }\n // extend in 65536 pixel blocks\n const newCanvas = new Uint32Array(Math.ceil(pixels / 65536) * 65536);\n newCanvas.set(this._canvas);\n this._canvas = newCanvas;\n }\n }\n _handle_band(width) {\n const adv = this._PIXEL_OFFSET;\n let offset = this._lastOffset;\n if (this._mode === 2 /* M2 */) {\n let remaining = this.height - this._currentHeight;\n let c = 0;\n while (c < 6 && remaining > 0) {\n this._canvas.set(this._pSrc.subarray(adv * c, adv * c + width), offset + width * c);\n c++;\n remaining--;\n }\n this._lastOffset += width * c;\n this._currentHeight += c;\n }\n else if (this._mode === 1 /* M1 */) {\n this._realloc(offset, width * 6);\n this._maxWidth = Math.max(this._maxWidth, width);\n this._minWidth = Math.min(this._minWidth, width);\n for (let i = 0; i < 6; ++i) {\n this._canvas.set(this._pSrc.subarray(adv * i, adv * i + width), offset + width * i);\n }\n this._bandWidths.push(width);\n this._lastOffset += width * 6;\n this._currentHeight += 6;\n }\n return 0; // 0 - continue, 1 - abort right away\n }\n /**\n * Width of the image data.\n * Returns the rasterWidth in level2/truncating mode,\n * otherwise the max width, that has been seen so far.\n */\n get width() {\n return this._mode !== 1 /* M1 */\n ? this._width\n : Math.max(this._maxWidth, this._wasm.current_width());\n }\n /**\n * Height of the image data.\n * Returns the rasterHeight in level2/truncating mode,\n * otherwise height touched by sixels.\n */\n get height() {\n return this._mode !== 1 /* M1 */\n ? this._height\n : this._wasm.current_width()\n ? this._bandWidths.length * 6 + this._wasm.current_height()\n : this._bandWidths.length * 6;\n }\n /**\n * Get active palette colors as RGBA8888[] (borrowed).\n */\n get palette() {\n return this._palette.subarray(0, this._paletteLimit);\n }\n /**\n * Get the memory used by the decoder.\n *\n * This is a rough estimate accounting the wasm instance memory\n * and pixel buffers held on JS side (real value will be slightly\n * higher due to JS book-keeping).\n * Note that the decoder does not free ressources on its own,\n * call `release` to free excess memory.\n */\n get memoryUsage() {\n return this._canvas.byteLength + this._wasm.memory.buffer.byteLength + 8 * this._bandWidths.length;\n }\n /**\n * Get various properties of the decoder and the current image.\n */\n get properties() {\n return {\n width: this.width,\n height: this.height,\n mode: this._mode,\n level: this._level,\n truncate: !!this._truncate,\n paletteLimit: this._paletteLimit,\n fillColor: this._fillColor,\n memUsage: this.memoryUsage,\n rasterAttributes: {\n numerator: this._states[4],\n denominator: this._states[5],\n width: this._rasterWidth,\n height: this._rasterHeight,\n }\n };\n }\n /**\n * Initialize decoder for next image. Must be called before\n * any calls to `decode` or `decodeString`.\n */\n // FIXME: reorder arguments, better palette handling\n init(fillColor = this._opts.fillColor, palette = this._opts.palette, paletteLimit = this._opts.paletteLimit, truncate = this._opts.truncate) {\n this._wasm.init(this._opts.sixelColor, fillColor, paletteLimit, truncate ? 1 : 0);\n if (palette) {\n this._palette.set(palette.subarray(0, wasm_1.LIMITS.PALETTE_SIZE));\n }\n this._bandWidths.length = 0;\n this._maxWidth = 0;\n this._minWidth = wasm_1.LIMITS.MAX_WIDTH;\n this._lastOffset = 0;\n this._currentHeight = 0;\n }\n /**\n * Decode next chunk of data from start to end index (exclusive).\n * @throws Will throw if the image exceeds the memory limit.\n */\n decode(data, start = 0, end = data.length) {\n let p = start;\n while (p < end) {\n const length = Math.min(end - p, wasm_1.LIMITS.CHUNK_SIZE);\n this._chunk.set(data.subarray(p, p += length));\n this._wasm.decode(0, length);\n }\n }\n /**\n * Decode next chunk of string data from start to end index (exclusive).\n * Note: Decoding from string data is rather slow, use `decode` with byte data instead.\n * @throws Will throw if the image exceeds the memory limit.\n */\n decodeString(data, start = 0, end = data.length) {\n let p = start;\n while (p < end) {\n const length = Math.min(end - p, wasm_1.LIMITS.CHUNK_SIZE);\n for (let i = 0, j = p; i < length; ++i, ++j) {\n this._chunk[i] = data.charCodeAt(j);\n }\n p += length;\n this._wasm.decode(0, length);\n }\n }\n /**\n * Get current pixel data as 32-bit typed array (RGBA8888).\n * Also peeks into pixel data of the current band, that got not pushed yet.\n */\n get data32() {\n if (this._mode === 0 /* M0 */ || !this.width || !this.height) {\n return NULL_CANVAS;\n }\n // get width of pending band to peek into left-over data\n const currentWidth = this._wasm.current_width();\n if (this._mode === 2 /* M2 */) {\n let remaining = this.height - this._currentHeight;\n if (remaining > 0) {\n const adv = this._PIXEL_OFFSET;\n let offset = this._lastOffset;\n let c = 0;\n while (c < 6 && remaining > 0) {\n this._canvas.set(this._pSrc.subarray(adv * c, adv * c + currentWidth), offset + currentWidth * c);\n c++;\n remaining--;\n }\n if (remaining) {\n this._canvas.fill(this._fillColor, offset + currentWidth * c);\n }\n }\n return this._canvas.subarray(0, this.width * this.height);\n }\n if (this._mode === 1 /* M1 */) {\n if (this._minWidth === this._maxWidth) {\n let escape = false;\n if (currentWidth) {\n if (currentWidth !== this._minWidth) {\n escape = true;\n }\n else {\n const adv = this._PIXEL_OFFSET;\n let offset = this._lastOffset;\n this._realloc(offset, currentWidth * 6);\n for (let i = 0; i < 6; ++i) {\n this._canvas.set(this._pSrc.subarray(adv * i, adv * i + currentWidth), offset + currentWidth * i);\n }\n }\n }\n if (!escape) {\n return this._canvas.subarray(0, this.width * this.height);\n }\n }\n // worst case: re-align pixels if we have bands with different width\n // This is somewhat allocation intensive, any way to do that in-place, and just once?\n const final = new Uint32Array(this.width * this.height);\n final.fill(this._fillColor);\n let finalOffset = 0;\n let start = 0;\n for (let i = 0; i < this._bandWidths.length; ++i) {\n const bw = this._bandWidths[i];\n for (let p = 0; p < 6; ++p) {\n final.set(this._canvas.subarray(start, start += bw), finalOffset);\n finalOffset += this.width;\n }\n }\n // also handle left-over pixels of the current band\n if (currentWidth) {\n const adv = this._PIXEL_OFFSET;\n // other than finished bands, this runs only up to currentHeight\n const currentHeight = this._wasm.current_height();\n for (let i = 0; i < currentHeight; ++i) {\n final.set(this._pSrc.subarray(adv * i, adv * i + currentWidth), finalOffset + this.width * i);\n }\n }\n return final;\n }\n // fallthrough for all not handled cases\n return NULL_CANVAS;\n }\n /**\n * Same as `data32`, but returning pixel data as Uint8ClampedArray suitable\n * for direct usage with `ImageData`.\n */\n get data8() {\n return new Uint8ClampedArray(this.data32.buffer, 0, this.width * this.height * 4);\n }\n /**\n * Release image ressources on JS side held by the decoder.\n *\n * The decoder tries to re-use memory ressources of a previous image\n * to lower allocation and GC pressure. Decoding a single big image\n * will grow the memory usage of the decoder permanently.\n * Call `release` to reset the internal buffers and free the memory.\n * Note that this destroys the image data, call it when done processing\n * a rather big image, otherwise it is not needed. Use `memoryUsage`\n * to decide, whether the held memory is still within your limits.\n * This does not affect the wasm module (operates on static memory).\n */\n release() {\n this._canvas = NULL_CANVAS;\n this._bandWidths.length = 0;\n this._maxWidth = 0;\n this._minWidth = wasm_1.LIMITS.MAX_WIDTH;\n // also nullify parser states in wasm to avoid\n // width/height reporting potential out-of-bound values\n this._wasm.init(Colors_1.DEFAULT_FOREGROUND, 0, this._opts.paletteLimit, 0);\n }\n}\nexports.Decoder = Decoder;\n/**\n * Convenient decoding functions for easier usage.\n *\n * These can be used for casual decoding of sixel images,\n * that dont come in as stream chunks.\n * Note that the functions instantiate a stream decoder for every call,\n * which comes with a performance penalty of ~25%.\n */\n/**\n * Decode function with synchronous wasm loading.\n * Can be used in a web worker or in nodejs. Does not work reliable in normal browser context.\n * @throws Will throw if the image exceeds the memory limit.\n */\nfunction decode(data, opts) {\n const dec = new Decoder(opts);\n dec.init();\n typeof data === 'string' ? dec.decodeString(data) : dec.decode(data);\n return {\n width: dec.width,\n height: dec.height,\n data32: dec.data32,\n data8: dec.data8\n };\n}\nexports.decode = decode;\n/**\n * Decode function with asynchronous wasm loading.\n * Use this version in normal browser context.\n * @throws Will throw if the image exceeds the memory limit.\n */\nasync function decodeAsync(data, opts) {\n const dec = await DecoderAsync(opts);\n dec.init();\n typeof data === 'string' ? dec.decodeString(data) : dec.decode(data);\n return {\n width: dec.width,\n height: dec.height,\n data32: dec.data32,\n data8: dec.data8\n };\n}\nexports.decodeAsync = decodeAsync;\n//# sourceMappingURL=Decoder.js.map","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.LIMITS = void 0;\nexports.LIMITS = {\n CHUNK_SIZE: 16384,\n PALETTE_SIZE: 4096,\n MAX_WIDTH: 16384,\n BYTES: 'AGFzbQEAAAABJAdgAAF/YAJ/fwBgA39/fwF/YAF/AX9gAABgBH9/f38AYAF/AAIlAgNlbnYLaGFuZGxlX2JhbmQAAwNlbnYLbW9kZV9wYXJzZWQAAwMTEgQAAAAAAQQBAQUBAAACAgAGAwQFAXABBwcFBAEBBwcGCAF/AUGAihoLB9wBDgZtZW1vcnkCABFnZXRfc3RhdGVfYWRkcmVzcwADEWdldF9jaHVua19hZGRyZXNzAAQOZ2V0X3AwX2FkZHJlc3MABRNnZXRfcGFsZXR0ZV9hZGRyZXNzAAYEaW5pdAALBmRlY29kZQAMDWN1cnJlbnRfd2lkdGgADQ5jdXJyZW50X2hlaWdodAAOGV9faW5kaXJlY3RfZnVuY3Rpb25fdGFibGUBAAtfaW5pdGlhbGl6ZQACCXN0YWNrU2F2ZQARDHN0YWNrUmVzdG9yZQASCnN0YWNrQWxsb2MAEwkMAQBBAQsGCgcJDxACDAEBCq5UEgMAAQsFAEGgCAsGAEGQiQELBgBBsIkCCwUAQZAJC+okAQh/QeQIKAIAIQVB4AgoAgAhA0HoCCgCACEIIAFBkIkBaiIJQf8BOgAAIAAgAUgEQCAAQZCJAWohBgNAIAMhBCAGQQFqIQECQCAGLQAAQf8AcSIDQTBrQQlLBEAgASEGDAELQewIKAIAQQJ0QewIaiICKAIAIQADQCACIAMgAEEKbGpBMGsiADYCACABLQAAIQMgAUEBaiIGIQEgA0H/AHEiA0Ewa0EKSQ0ACwsCQAJAAkACQAJAAkACQAJ/AkACQCADQT9rIgBBP00EQCAERQ0BIARBIUYEQAJAQfAIKAIAIgFBASABGyIHIAhqIgFB1AgoAgAiA0gNACADQf//AEoNAANAIANBAnQiAkGgiQJqIgRBoAgpAwA3AwAgAkGoiQJqQaAIKQMANwMAIAJBsIkCakGgCCkDADcDACACQbiJAmpBoAgpAwA3AwAgAkHAiQJqQaAIKQMANwMAIAJByIkCakGgCCkDADcDACACQdCJAmpBoAgpAwA3AwAgAkHYiQJqQaAIKQMANwMAIAJB4IkCakGgCCkDADcDACACQeiJAmpBoAgpAwA3AwAgAkHwiQJqQaAIKQMANwMAIAJB+IkCakGgCCkDADcDACACQYCKAmpBoAgpAwA3AwAgAkGIigJqQaAIKQMANwMAIAJBkIoCakGgCCkDADcDACACQZiKAmpBoAgpAwA3AwAgAkGgigJqQaAIKQMANwMAIAJBqIoCakGgCCkDADcDACACQbCKAmpBoAgpAwA3AwAgAkG4igJqQaAIKQMANwMAIAJBwIoCakGgCCkDADcDACACQciKAmpBoAgpAwA3AwAgAkHQigJqQaAIKQMANwMAIAJB2IoCakGgCCkDADcDACACQeCKAmpBoAgpAwA3AwAgAkHoigJqQaAIKQMANwMAIAJB8IoCakGgCCkDADcDACACQfiKAmpBoAgpAwA3AwAgAkGAiwJqQaAIKQMANwMAIAJBiIsCakGgCCkDADcDACACQZCLAmpBoAgpAwA3AwAgAkGYiwJqQaAIKQMANwMAIAJBoIsCakGgCCkDADcDACACQaiLAmpBoAgpAwA3AwAgAkGwiwJqQaAIKQMANwMAIAJBuIsCakGgCCkDADcDACACQcCLAmpBoAgpAwA3AwAgAkHIiwJqQaAIKQMANwMAIAJB0IsCakGgCCkDADcDACACQdiLAmpBoAgpAwA3AwAgAkHgiwJqQaAIKQMANwMAIAJB6IsCakGgCCkDADcDACACQfCLAmpBoAgpAwA3AwAgAkH4iwJqQaAIKQMANwMAIAJBgIwCakGgCCkDADcDACACQYiMAmpBoAgpAwA3AwAgAkGQjAJqQaAIKQMANwMAIAJBmIwCakGgCCkDADcDACACQaCMAmpBoAgpAwA3AwAgAkGojAJqQaAIKQMANwMAIAJBsIwCakGgCCkDADcDACACQbiMAmpBoAgpAwA3AwAgAkHAjAJqQaAIKQMANwMAIAJByIwCakGgCCkDADcDACACQdCMAmpBoAgpAwA3AwAgAkHYjAJqQaAIKQMANwMAIAJB4IwCakGgCCkDADcDACACQeiMAmpBoAgpAwA3AwAgAkHwjAJqQaAIKQMANwMAIAJB+IwCakGgCCkDADcDACACQYCNAmpBoAgpAwA3AwAgAkGIjQJqQaAIKQMANwMAIAJBkI0CakGgCCkDADcDACACQZiNAmpBoAgpAwA3AwAgAkGwiQZqIARBgAT8CgAAQdQIKAIAQQJ0QcCJCmogBEGABPwKAABB1AgoAgBBAnRB0IkOaiAEQYAE/AoAAEHUCCgCAEECdEHgiRJqIARBgAT8CgAAQdQIKAIAQQJ0QfCJFmogBEGABPwKAABB1AhB1AgoAgAiAkGAAWoiAzYCACABIANIDQEgAkGA/wBIDQALCwJAIABFDQAgCEH//wBLDQBBgIABIAhrIAcgAUH//wBLGyECAkAgAEEBcUUNACACRQ0AIAhBAnRBoIkCaiEDIAIhBCACQQdxIgcEQANAIAMgBTYCACADQQRqIQMgBEEBayEEIAdBAWsiBw0ACwsgAkEBa0EHSQ0AA0AgAyAFNgIcIAMgBTYCGCADIAU2AhQgAyAFNgIQIAMgBTYCDCADIAU2AgggAyAFNgIEIAMgBTYCACADQSBqIQMgBEEIayIEDQALCwJAIABBAnFFDQAgAkUNACAIQQJ0QbCJBmohAyACIQQgAkEHcSIHBEADQCADIAU2AgAgA0EEaiEDIARBAWshBCAHQQFrIgcNAAsLIAJBAWtBB0kNAANAIAMgBTYCHCADIAU2AhggAyAFNgIUIAMgBTYCECADIAU2AgwgAyAFNgIIIAMgBTYCBCADIAU2AgAgA0EgaiEDIARBCGsiBA0ACwsCQCAAQQRxRQ0AIAJFDQAgCEECdEHAiQpqIQMgAiEEIAJBB3EiBwRAA0AgAyAFNgIAIANBBGohAyAEQQFrIQQgB0EBayIHDQALCyACQQFrQQdJDQADQCADIAU2AhwgAyAFNgIYIAMgBTYCFCADIAU2AhAgAyAFNgIMIAMgBTYCCCADIAU2AgQgAyAFNgIAIANBIGohAyAEQQhrIgQNAAsLAkAgAEEIcUUNACACRQ0AIAhBAnRB0IkOaiEDIAIhBCACQQdxIgcEQANAIAMgBTYCACADQQRqIQMgBEEBayEEIAdBAWsiBw0ACwsgAkEBa0EHSQ0AA0AgAyAFNgIcIAMgBTYCGCADIAU2AhQgAyAFNgIQIAMgBTYCDCADIAU2AgggAyAFNgIEIAMgBTYCACADQSBqIQMgBEEIayIEDQALCwJAIABBEHFFDQAgAkUNACAIQQJ0QeCJEmohAyACIQQgAkEHcSIHBEADQCADIAU2AgAgA0EEaiEDIARBAWshBCAHQQFrIgcNAAsLIAJBAWtBB0kNAANAIAMgBTYCHCADIAU2AhggAyAFNgIUIAMgBTYCECADIAU2AgwgAyAFNgIIIAMgBTYCBCADIAU2AgAgA0EgaiEDIARBCGsiBA0ACwsgAEEgcUUNACACRQ0AIAJBAWshByAIQQJ0QfCJFmohAyACQQdxIgQEQANAIAMgBTYCACADQQRqIQMgAkEBayECIARBAWsiBA0ACwsgB0EHSQ0AA0AgAyAFNgIcIAMgBTYCGCADIAU2AhQgAyAFNgIQIAMgBTYCDCADIAU2AgggAyAFNgIEIAMgBTYCACADQSBqIQMgAkEIayICDQALC0HcCEHcCCgCACAAcjYCACAGQQFqIgIgBi0AAEH/AHEiA0E/ayIAQT9LDQQaDAMLAkBB7AgoAgAiBEEBRgRAQfAIKAIAIgNBzAgoAgAiAUkNASADIAFwIQMMAQtB+AgoAgAhAkH0CCgCACEBAkACQCAEQQVHDQAgAUEBRw0AIAJB6QJODQQMAQsgAkHkAEoNA0H8CCgCAEHkAEoNA0GACSgCAEHkAEoNAwsCQCABRQ0AIAFBAkoNACACQfwIKAIAQYAJKAIAIAFBAnRBiAhqKAIAEQIAIQFB8AgoAgAiA0HMCCgCACICTwR/IAMgAnAFIAMLQQJ0QZAJaiABNgIAC0HwCCgCACIDQcwIKAIAIgFJDQAgAyABcCEDCyADQQJ0QZAJaigCACEFDAELIANB/QBxQSFHBEAgCCEBIAYhAgwECyAEQSNHDQQCQEHsCCgCACICQQFGBEBB8AgoAgAiAUHMCCgCACIASQ0BIAEgAHAhAQwBC0H4CCgCACEBQfQIKAIAIQACQAJAIAJBBUcNACAAQQFHDQAgAUHpAkgNAQwHCyABQeQASg0GQfwIKAIAQeQASg0GQYAJKAIAQeQASg0GCwJAIABFDQAgAEECSg0AIAFB/AgoAgBBgAkoAgAgAEECdEGICGooAgARAgAhAEHwCCgCACIBQcwIKAIAIgJPBH8gASACcAUgAQtBAnRBkAlqIAA2AgALQfAIKAIAIgFBzAgoAgAiAEkNACABIABwIQELIAFBAnRBkAlqKAIAIQUMBAsgCCEBIAYhAgtB1AgoAgAhBgNAAkAgASAGSA0AIAZB//8ASg0AIAZBAnQiBEGgiQJqIgZBoAgpAwA3AwAgBEGoiQJqQaAIKQMANwMAIARBsIkCakGgCCkDADcDACAEQbiJAmpBoAgpAwA3AwAgBEHAiQJqQaAIKQMANwMAIARByIkCakGgCCkDADcDACAEQdCJAmpBoAgpAwA3AwAgBEHYiQJqQaAIKQMANwMAIARB4IkCakGgCCkDADcDACAEQeiJAmpBoAgpAwA3AwAgBEHwiQJqQaAIKQMANwMAIARB+IkCakGgCCkDADcDACAEQYCKAmpBoAgpAwA3AwAgBEGIigJqQaAIKQMANwMAIARBkIoCakGgCCkDADcDACAEQZiKAmpBoAgpAwA3AwAgBEGgigJqQaAIKQMANwMAIARBqIoCakGgCCkDADcDACAEQbCKAmpBoAgpAwA3AwAgBEG4igJqQaAIKQMANwMAIARBwIoCakGgCCkDADcDACAEQciKAmpBoAgpAwA3AwAgBEHQigJqQaAIKQMANwMAIARB2IoCakGgCCkDADcDACAEQeCKAmpBoAgpAwA3AwAgBEHoigJqQaAIKQMANwMAIARB8IoCakGgCCkDADcDACAEQfiKAmpBoAgpAwA3AwAgBEGAiwJqQaAIKQMANwMAIARBiIsCakGgCCkDADcDACAEQZCLAmpBoAgpAwA3AwAgBEGYiwJqQaAIKQMANwMAIARBoIsCakGgCCkDADcDACAEQaiLAmpBoAgpAwA3AwAgBEGwiwJqQaAIKQMANwMAIARBuIsCakGgCCkDADcDACAEQcCLAmpBoAgpAwA3AwAgBEHIiwJqQaAIKQMANwMAIARB0IsCakGgCCkDADcDACAEQdiLAmpBoAgpAwA3AwAgBEHgiwJqQaAIKQMANwMAIARB6IsCakGgCCkDADcDACAEQfCLAmpBoAgpAwA3AwAgBEH4iwJqQaAIKQMANwMAIARBgIwCakGgCCkDADcDACAEQYiMAmpBoAgpAwA3AwAgBEGQjAJqQaAIKQMANwMAIARBmIwCakGgCCkDADcDACAEQaCMAmpBoAgpAwA3AwAgBEGojAJqQaAIKQMANwMAIARBsIwCakGgCCkDADcDACAEQbiMAmpBoAgpAwA3AwAgBEHAjAJqQaAIKQMANwMAIARByIwCakGgCCkDADcDACAEQdCMAmpBoAgpAwA3AwAgBEHYjAJqQaAIKQMANwMAIARB4IwCakGgCCkDADcDACAEQeiMAmpBoAgpAwA3AwAgBEHwjAJqQaAIKQMANwMAIARB+IwCakGgCCkDADcDACAEQYCNAmpBoAgpAwA3AwAgBEGIjQJqQaAIKQMANwMAIARBkI0CakGgCCkDADcDACAEQZiNAmpBoAgpAwA3AwAgBEGwiQZqIAZBgAT8CgAAQdQIKAIAQQJ0QcCJCmogBkGABPwKAABB1AgoAgBBAnRB0IkOaiAGQYAE/AoAAEHUCCgCAEECdEHgiRJqIAZBgAT8CgAAQdQIKAIAQQJ0QfCJFmogBkGABPwKAABB1AhB1AgoAgBBgAFqIgY2AgALIAFB//8ATQRAIABBAXEgAWxBAnRBoIkCaiAFNgIAIABBAXZBAXEgAWxBAnRBsIkGaiAFNgIAIABBAnZBAXEgAWxBAnRBwIkKaiAFNgIAIABBA3ZBAXEgAWxBAnRB0IkOaiAFNgIAIABBBHZBAXEgAWxBAnRB4IkSaiAFNgIAIABBBXYgAWxBAnRB8IkWaiAFNgIAQdQIKAIAIQYLIAFBAWohAUHcCEHcCCgCACAAcjYCACACLQAAIQAgAkEBaiIEIQIgAEH/AHEiA0E/ayIAQcAASQ0ACyAECyECQQAhBCACIQYgASEIIANB/QBxQSFGDQELIANBJGsOCgEDAwMDAwMDAwIDC0HsCEIBNwIADAQLQdgIIAFB2AgoAgAiACAAIAFIGyIAQYCAASAAQYCAAUgbNgIADAILQegIIAFB2AgoAgAiACAAIAFIGyIAQYCAASAAQYCAAUgbIgA2AgBB2AggADYCACAAQQRrEAAEQEHoCEEENgIAQdgIQQQ2AgBB0AhBATYCAA8LEAgMAQsCQCADQTtHDQBB7AgoAgAiAEEHSg0AQewIIABBAWo2AgAgAEECdEHwCGpBADYCAAsgAiEGIAQhAyABIQgMAQtBBCEIIAIhBiAEIQMLIAYgCUkNAAsLQeQIIAU2AgBB4AggAzYCAEHoCCAINgIAC9ELAgF+CH9B2AhCBDcDAEGojQJBoAgpAwAiADcDAEGgjQIgADcDAEGYjQIgADcDAEGQjQIgADcDAEGIjQIgADcDAEGAjQIgADcDAEH4jAIgADcDAEHwjAIgADcDAEHojAIgADcDAEHgjAIgADcDAEHYjAIgADcDAEHQjAIgADcDAEHIjAIgADcDAEHAjAIgADcDAEG4jAIgADcDAEGwjAIgADcDAEGojAIgADcDAEGgjAIgADcDAEGYjAIgADcDAEGQjAIgADcDAEGIjAIgADcDAEGAjAIgADcDAEH4iwIgADcDAEHwiwIgADcDAEHoiwIgADcDAEHgiwIgADcDAEHYiwIgADcDAEHQiwIgADcDAEHIiwIgADcDAEHAiwIgADcDAEG4iwIgADcDAEGwiwIgADcDAEGoiwIgADcDAEGgiwIgADcDAEGYiwIgADcDAEGQiwIgADcDAEGIiwIgADcDAEGAiwIgADcDAEH4igIgADcDAEHwigIgADcDAEHoigIgADcDAEHgigIgADcDAEHYigIgADcDAEHQigIgADcDAEHIigIgADcDAEHAigIgADcDAEG4igIgADcDAEGwigIgADcDAEGoigIgADcDAEGgigIgADcDAEGYigIgADcDAEGQigIgADcDAEGIigIgADcDAEGAigIgADcDAEH4iQIgADcDAEHwiQIgADcDAEHoiQIgADcDAEHgiQIgADcDAEHYiQIgADcDAEHQiQIgADcDAEHIiQIgADcDAEHAiQIgADcDAEG4iQIgADcDAEGwiQIgADcDAEGoCCgCACIEQf8AakGAAW0hCAJAIARBgQFIDQBBASEBIAhBAiAIQQJKG0EBayICQQFxIQMgBEGBAk4EQCACQX5xIQIDQCABQQl0IgdBEHJBoIkCakGwiQJBgAT8CgAAIAdBsI0CakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsLIANFDQAgAUEJdEEQckGgiQJqQbCJAkGABPwKAAALAkAgBEEBSA0AIAhBASAIQQFKGyIDQQFxIQUCQCADQQFrIgdFBEBBACEBDAELIANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQbCJBmpBsIkCQYAE/AoAACAGQZAEckGwiQZqQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACwsgBQRAIAFBCXRBEHJBsIkGakGwiQJBgAT8CgAACyAEQQFIDQAgA0EBcSEFIAcEfyADQf7///8HcSECQQAhAQNAIAFBCXQiBkEQckHAiQpqQbCJAkGABPwKAAAgBkGQBHJBwIkKakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsgAUEHdEEEcgVBBAshASAFBEAgAUECdEHAiQpqQbCJAkGABPwKAAALIARBAUgNACADQQFxIQUgBwR/IANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQdCJDmpBsIkCQYAE/AoAACAGQZAEckHQiQ5qQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACyABQQd0QQRyBUEECyEBIAUEQCABQQJ0QdCJDmpBsIkCQYAE/AoAAAsgBEEBSA0AIANBAXEhBSAHBH8gA0H+////B3EhAkEAIQEDQCABQQl0IgZBEHJB4IkSakGwiQJBgAT8CgAAIAZBkARyQeCJEmpBsIkCQYAE/AoAACABQQJqIQEgAkECayICDQALIAFBB3RBBHIFQQQLIQEgBQRAIAFBAnRB4IkSakGwiQJBgAT8CgAACyAEQQFIDQAgA0EBcSEEIAcEfyADQf7///8HcSECQQAhAQNAIAFBCXQiA0EQckHwiRZqQbCJAkGABPwKAAAgA0GQBHJB8IkWakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsgAUEHdEEEcgVBBAshASAERQ0AIAFBAnRB8IkWakGwiQJBgAT8CgAAC0HUCCAIQQd0QQRyNgIAC58TAgh/AX5B5AgoAgAhA0HgCCgCACECQegIKAIAIQcgAUGQiQFqIglB/wE6AAAgACABSARAIABBkIkBaiEIA0AgAiEEIAhBAWohAQJAIAgtAABB/wBxIgJBMGtBCUsEQCABIQgMAQtB7AgoAgBBAnRB7AhqIgUoAgAhAANAIAUgAiAAQQpsakEwayIANgIAIAEtAAAhAiABQQFqIgghASACQf8AcSICQTBrQQpJDQALCwJAAkACQAJAAkACQAJ/AkAgAkE/ayIAQT9NBEAgBEUNASAEQSFGBEBB8AgoAgAiAUEBIAEbIgQgB2ohAQJAIABFDQAgB0H//wBLDQBBgIABIAdrIAQgAUH//wBLGyEFAkAgAEEBcUUNACAHQQJ0QaCJAmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEECcUUNACAHQQJ0QbCJBmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEEEcUUNACAHQQJ0QcCJCmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEEIcUUNACAHQQJ0QdCJDmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEEQcUUNACAHQQJ0QeCJEmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLIABBIHFFDQAgBUEBayEEIAdBAnRB8IkWaiEAIAVBB3EiAgRAA0AgACADNgIAIABBBGohACAFQQFrIQUgAkEBayICDQALCyAEQQdJDQADQCAAIAM2AhwgACADNgIYIAAgAzYCFCAAIAM2AhAgACADNgIMIAAgAzYCCCAAIAM2AgQgACADNgIAIABBIGohACAFQQhrIgUNAAsLIAhBAWoiBSAILQAAQf8AcSICQT9rIgBBP00NAxoMBAsCQEHsCCgCACIFQQFGBEBB8AgoAgAiAUHMCCgCACIESQ0BIAEgBHAhAQwBC0H4CCgCACEEQfQIKAIAIQECQAJAIAVBBUcNACABQQFHDQAgBEHpAk4NBAwBCyAEQeQASg0DQfwIKAIAQeQASg0DQYAJKAIAQeQASg0DCwJAIAFFDQAgAUECSg0AIARB/AgoAgBBgAkoAgAgAUECdEGICGooAgARAgAhBEHwCCgCACIBQcwIKAIAIgVPBH8gASAFcAUgAQtBAnRBkAlqIAQ2AgALQfAIKAIAIgFBzAgoAgAiBEkNACABIARwIQELIAFBAnRBkAlqKAIAIQMMAQsgAkH9AHFBIUcEQCAHIQEgAiEADAQLIARBI0cNBAJAQewIKAIAIgRBAUYEQEHwCCgCACIBQcwIKAIAIgBJDQEgASAAcCEBDAELQfgIKAIAIQFB9AgoAgAhAAJAAkAgBEEFRw0AIABBAUcNACABQekCSA0BDAcLIAFB5ABKDQZB/AgoAgBB5ABKDQZBgAkoAgBB5ABKDQYLAkAgAEUNACAAQQJKDQAgAUH8CCgCAEGACSgCACAAQQJ0QYgIaigCABECACEAQfAIKAIAIgFBzAgoAgAiBE8EfyABIARwBSABC0ECdEGQCWogADYCAAtB8AgoAgAiAUHMCCgCACIASQ0AIAEgAHAhAQsgAUECdEGQCWooAgAhAwwECyAHIQEgCAshBQNAIAFB//8ATQRAIABBAXEgAWxBAnRBoIkCaiADNgIAIABBAXZBAXEgAWxBAnRBsIkGaiADNgIAIABBAnZBAXEgAWxBAnRBwIkKaiADNgIAIABBA3ZBAXEgAWxBAnRB0IkOaiADNgIAIABBBHZBAXEgAWxBAnRB4IkSaiADNgIAIABBBXYgAWxBAnRB8IkWaiADNgIACyABQQFqIQEgBS0AACEAIAVBAWoiBCEFIABB/wBxIgJBP2siAEHAAEkNAAsgBCEFC0EAIQQgBSEIIAEhByACIQAgAkH9AHFBIUYNAQtBBCEHIAQhAiAAQSRrDgoDAgICAgICAgIBAgtB7AhCATcCAAwCC0GoCCgCAEEEaxAABEBB0AhBATYCAA8LAkBBqAgoAgAiBkEFSA0AQaAIKQMAIQogBkEDa0EBdiIBQQdxIQJBACEAIAFBAWtBB08EQCABQfj///8HcSEFA0AgAEEDdCIBQbCJAmogCjcDACABQQhyQbCJAmogCjcDACABQRByQbCJAmogCjcDACABQRhyQbCJAmogCjcDACABQSByQbCJAmogCjcDACABQShyQbCJAmogCjcDACABQTByQbCJAmogCjcDACABQThyQbCJAmogCjcDACAAQQhqIQAgBUEIayIFDQALCyACRQ0AA0AgAEEDdEGwiQJqIAo3AwAgAEEBaiEAIAJBAWsiAg0ACwtBwIkGQbCJAiAGQQJ0IgD8CgAAQdCJCkGwiQIgAPwKAABB4IkOQbCJAiAA/AoAAEHwiRJBsIkCIAD8CgAAQYCKFkGwiQIgAPwKAAAgBCECDAELAkAgAEE7Rw0AQewIKAIAIgBBB0oNAEHsCCAAQQFqNgIAIABBAnRB8AhqQQA2AgALIAEhBwsgCCAJSQ0ACwtB5AggAzYCAEHgCCACNgIAQegIIAc2AgAL4gcCBX8BfgJAQdAIAn8CQAJAIAAgAU4NACABQZCJAWohBiAAQZCJAWohBQNAIAUtAAAiA0H/AHEhAgJAAkACQAJAAkACQAJAQeAIKAIAIgRBIkcEQCAEDQcgAkEiRgRAQewIQgE3AgBB4AhBIjYCAAwICyACQT9rQcAASQ0GIANBIWsiAkEMTQ0BDAULAkAgAkEwayIEQQlNBEBB7AgoAgBBAnRB7AhqIgIgBCACKAIAQQpsajYCAAwBC0HsCCgCACEEIAJBO0YEQCAEQQdKDQFB7AggBEEBajYCACAEQQJ0QfAIakEANgIADAELIARBBEYEQEHECEECNgIAQbAIQfAIKQMANwMAQbgIQfgIKAIAIgI2AgBBvAhB/AgoAgAiBDYCAEHICEECQQFBwAgoAgAiAxs2AgBBrAggBEEAIAMbNgIAQagIIAJBgIABIAJBgIABSBtBBGpBACADGzYCAEHgCEEANgIADAoLIAJBP2tBwABJDQQLIANBIWsiAkEMTQ0BDAILQQEgAnRBjSBxRQ0DDAQLQQEgAnRBjSBxDQELIANBoQFrIgJBDEsNA0EBIAJ0QY0gcUUNAwtBxAhCgYCAgBA3AgBBsAhB8AgoAgBBAEHsCCgCACICQQBKGzYCAEG0CEH0CCgCAEEAIAJBAUobNgIAQbgIQfgIKAIAQQAgAkECShs2AgBB4AhBADYCAEG8CEEANgIADAQLIANBoQFrIgJBDEsNAUEBIAJ0QY0gcUUNAQtBxAhCgYCAgBA3AgBBsAhCADcDAEG4CEIANwMADAMLIAVBAWoiBSAGSQ0ACwsCQEHICCgCAA4DAwEAAQsCQEGoCCgCACIFQQVIDQBBoAgpAwAhByAFQQNrQQF2IgNBB3EhBEEAIQIgA0EBa0EHTwRAIANB+P///wdxIQYDQCACQQN0IgNBsIkCaiAHNwMAIANBCHJBsIkCaiAHNwMAIANBEHJBsIkCaiAHNwMAIANBGHJBsIkCaiAHNwMAIANBIHJBsIkCaiAHNwMAIANBKHJBsIkCaiAHNwMAIANBMHJBsIkCaiAHNwMAIANBOHJBsIkCaiAHNwMAIAJBCGohAiAGQQhrIgYNAAsLIARFDQADQCACQQN0QbCJAmogBzcDACACQQFqIQIgBEEBayIEDQALC0HAiQZBsIkCIAVBAnQiA/wKAABB0IkKQbCJAiAD/AoAAEHgiQ5BsIkCIAP8CgAAQfCJEkGwiQIgA/wKAABBgIoWQbCJAiAD/AoAAEECDAELEAhByAgoAgALEAEiAjYCACACDQAgACABQcgIKAIAQQJ0QYAIaigCABEBAAsLdABB6AhBBDYCAEHkCCAANgIAQewIQgE3AgBBxAhCADcCAEHACCADNgIAQdwIQgA3AgBBqAhCADcDAEGwCEIANwMAQbgIQgA3AwBBzAggAkGAICACQYAgSRs2AgBBoAggAa1CgYCAgBB+NwMAQdAIQQA2AgALIwBB0AgoAgBFBEAgACABQcgIKAIAQQJ0QYAIaigCABEBAAsLWgECfwJAAkACQEHICCgCAEEBaw4CAAECC0HYCEHoCCgCACIAQdgIKAIAIgEgACABShsiAEGAgAEgAEGAgAFIGyIANgIAIABBBGsPC0GoCCgCAEEEayEACyAAC0IBAX8Cf0EGQdwIKAIAIgBBIHENABpBBSAAQRBxDQAaQQQgAEEIcQ0AGkEDIABBBHENABpBAiAAQQFxIABBAnEbCwu9BQEFfQJ/IAJFBEAgAUH/AWxBMmpB5ABtIgBBCHQgAHIgAEEQdHIMAQsgArJDAADIQpUhBiAAQfABarJDAAC0Q5UhBQJ9IAGyQwAAyEKVIgNDAAAAP10EQCADIAZDAACAP5KUDAELIAYgA0MAAIA/IAaTlJILIQcgAyADkiEGAkAgBUOrqqo+kiIEQwAAAABdBEAgBEMAAIA/kiEEDAELIARDAACAP15FDQAgBEMAAIC/kiEECyAGIAeTIQMgBUMAAAAAXSEAAn8CfSADIAcgA5NDAADAQJQgBJSSIARDq6oqPl0NABogByAEQwAAAD9dDQAaIAMgBEOrqio/XUUNABogAyAHIAOTIARDAADAwJRDAACAQJKUkgtDAAB/Q5RDAAAAP5IiBkMAAIBPXSAGQwAAAABgcQRAIAapDAELQQALIQECQCAABEAgBUMAAIA/kiEEDAELIAUiBEMAAIA/XkUNACAFQwAAgL+SIQQLIAVDq6qqvpIiBUMAAAAAXSECAn8CfSADIAcgA5NDAADAQJQgBJSSIARDq6oqPl0NABogByAEQwAAAD9dDQAaIAMgBEOrqio/XUUNABogAyAHIAOTIARDAADAwJRDAACAQJKUkgtDAAB/Q5RDAAAAP5IiBkMAAIBPXSAGQwAAAABgcQRAIAapDAELQQALIQACQCACBEAgBUMAAIA/kiEFDAELIAVDAACAP15FDQAgBUMAAIC/kiEFCwJAIAVDq6oqPl0EQCADIAcgA5NDAADAQJQgBZSSIQcMAQsgBUMAAAA/XQ0AIAVDq6oqP11FBEAgAyEHDAELIAMgByADkyAFQwAAwMCUQwAAgECSlJIhBwsgAEEIdAJ/IAdDAAB/Q5RDAAAAP5IiBkMAAIBPXSAGQwAAAABgcQRAIAapDAELQQALQRB0ciABcgtBgICAeHILNwAgAEH/AWxBMmpB5ABtIAFB/wFsQTJqQeQAbUEIdHIgAkH/AWxBMmpB5ABtQRB0ckGAgIB4cgsEACMACwYAIAAkAAsQACMAIABrQXBxIgAkACAACwsYAQBBgAgLEQEAAAACAAAAAwAAAAQAAAAF'\n};\n//# sourceMappingURL=wasm.js.map","/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal event utilities for xterm.js core.\n * Simplified from VS Code's event.ts - no leak detection/profiling.\n */\n\nimport { IDisposable, DisposableStore, toDisposable } from './Lifecycle';\n\nexport interface IEvent {\n (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable;\n}\n\nexport class Emitter {\n private _listeners: { fn: (e: T) => any, thisArgs: any }[] = [];\n private _disposed = false;\n private _event: IEvent | undefined;\n\n public get event(): IEvent {\n if (this._event) {\n return this._event;\n }\n this._event = (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n if (this._disposed) {\n return toDisposable(() => {});\n }\n\n const entry = { fn: listener, thisArgs };\n this._listeners = this._listeners.slice();\n this._listeners.push(entry);\n\n const result = toDisposable(() => {\n const idx = this._listeners.indexOf(entry);\n if (idx !== -1) {\n this._listeners = this._listeners.slice();\n this._listeners.splice(idx, 1);\n }\n });\n\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(result);\n } else {\n disposables.add(result);\n }\n }\n\n return result;\n };\n return this._event;\n }\n\n public fire(event: T): void {\n if (this._disposed || !this._listeners.length) {\n return;\n }\n if (this._listeners.length === 1) {\n this._listeners[0].fn.call(this._listeners[0].thisArgs, event);\n return;\n }\n const listeners = this._listeners;\n for (let i = 0, len = listeners.length; i < len; ++i) {\n listeners[i].fn.call(listeners[i].thisArgs, event);\n }\n }\n\n public dispose(): void {\n if (this._disposed) {\n return;\n }\n this._disposed = true;\n this._listeners.length = 0;\n }\n}\n\nexport namespace EventUtils {\n export function forward(from: IEvent, to: Emitter): IDisposable {\n return from(e => to.fire(e));\n }\n\n export function map(event: IEvent, map: (i: I) => O): IEvent {\n return (listener: (e: O) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n return event(i => listener.call(thisArgs, map(i)), undefined, disposables);\n };\n }\n\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent {\n return (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n const store = new DisposableStore();\n for (const event of events) {\n store.add(event(e => listener.call(thisArgs, e)));\n }\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(store);\n } else {\n disposables.add(store);\n }\n }\n return store;\n };\n }\n\n export function runAndSubscribe(event: IEvent, handler: (e: T) => void, initial: T): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void, initial?: T): IDisposable {\n handler(initial);\n return event(e => handler(e));\n }\n}\n","/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal lifecycle utilities for xterm.js core.\n * Simplified from VS Code's lifecycle.ts - no tracking/leak detection.\n */\n\nexport interface IDisposable {\n dispose(): void;\n}\n\nexport function toDisposable(fn: () => void): IDisposable {\n return { dispose: fn };\n}\n\nexport function dispose(disposable: T): T;\nexport function dispose(disposable: T | undefined): T | undefined;\nexport function dispose(disposables: T[]): T[];\nexport function dispose(arg: T | T[] | undefined): T | T[] | undefined {\n if (!arg) {\n return arg;\n }\n if (Array.isArray(arg)) {\n for (const d of arg) {\n d.dispose();\n }\n return [];\n }\n arg.dispose();\n return arg;\n}\n\nexport function combinedDisposable(...disposables: IDisposable[]): IDisposable {\n return toDisposable(() => dispose(disposables));\n}\n\nexport class DisposableStore implements IDisposable {\n private readonly _disposables = new Set();\n private _isDisposed = false;\n\n public get isDisposed(): boolean {\n return this._isDisposed;\n }\n\n public add(o: T): T {\n if (this._isDisposed) {\n o.dispose();\n } else {\n this._disposables.add(o);\n }\n return o;\n }\n\n public dispose(): void {\n if (this._isDisposed) {\n return;\n }\n this._isDisposed = true;\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n\n public clear(): void {\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n}\n\nexport abstract class Disposable implements IDisposable {\n public static readonly None: IDisposable = Object.freeze({ dispose() { } });\n\n protected readonly _store = new DisposableStore();\n\n public dispose(): void {\n this._store.dispose();\n }\n\n protected _register(o: T): T {\n return this._store.add(o);\n }\n}\n\nexport class MutableDisposable implements IDisposable {\n private _value: T | undefined;\n private _isDisposed = false;\n\n public get value(): T | undefined {\n return this._isDisposed ? undefined : this._value;\n }\n\n public set value(value: T | undefined) {\n if (this._isDisposed || value === this._value) {\n return;\n }\n this._value?.dispose();\n this._value = value;\n }\n\n public clear(): void {\n this.value = undefined;\n }\n\n public dispose(): void {\n this._isDisposed = true;\n this._value?.dispose();\n this._value = undefined;\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nimport { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { CELL_SIZE_DEFAULT } from './ImageStorage';\nimport Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';\nimport { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser';\nimport { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';\n\n// Local const enum mirror - esbuild can't inline const enums from external packages\nconst enum DecoderConst {\n // Held memory in base64/QOI decoders between images. Zero because each kept\n // decoder pins a wasm memory, and V8 caps those per process (~124 in a\n // sandboxed renderer), so idle terminals must not hold one.\n KEEP_DATA = 0,\n // Initial buffer allocation for the decoder.\n INITIAL_DATA = 1048576,\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n OK = 0\n}\n\n// default IIP header values\nconst DEFAULT_HEADER: IHeaderFields = {\n type: SequenceType.INVALID,\n name: 'Unnamed file',\n size: 0,\n width: 'auto',\n height: 'auto',\n preserveAspectRatio: 1,\n inline: 0\n};\n\n\nexport class IIPHandler implements IOscHandler, IResetHandler {\n private _generation = 0;\n private _aborted = false;\n private _hp = new HeaderParser();\n private _header: IHeaderFields = DEFAULT_HEADER;\n private _dec: Base64Decoder;\n private _qoiDec: QoiDecoder;\n private _isMultipart = false;\n private _abortMulti = false;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _storage: IIPImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);\n const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);\n this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes);\n this._qoiDec = new QoiDecoder(DecoderConst.KEEP_DATA);\n }\n\n public reset(): void {\n this._generation++;\n this._hp.reset();\n this._dec.release();\n this._qoiDec.release();\n }\n\n public start(): void {\n this._aborted = false;\n this._hp.reset();\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (this._hp.state === HeaderState.END) {\n if ((this._dec.put(data.subarray(start, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n }\n } else {\n const dataPos = this._hp.parse(data, start, end);\n if (dataPos === -1) {\n this._aborted = true;\n return;\n }\n if (dataPos > 0) {\n const seqType = this._hp.fields.type;\n if (seqType === SequenceType.FILE) {\n if (this._isMultipart) {\n this._isMultipart = false;\n this._abortMulti = false;\n this._dec.release();\n }\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n if (!this._header.inline) {\n this._aborted = true;\n return;\n }\n if (!this._initDecoder()) {\n this._aborted = true;\n return;\n }\n } else if (this._abortMulti) {\n this._aborted = true;\n return;\n }\n if ((this._dec.put(data.subarray(dataPos, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n if (this._isMultipart) this._abortMulti = true;\n }\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted) return true;\n\n if (this._hp.state !== HeaderState.END) {\n if (this._hp.end()) return true;\n }\n const seqType = this._hp.fields.type;\n\n if (seqType === SequenceType.FILEPART) return true;\n\n if (seqType === SequenceType.REPORTCELLSIZE) {\n // OSC 1337 ; ReportCellSize=[height];[width];[scale] ST\n let w = CELL_SIZE_DEFAULT.width;\n let h = CELL_SIZE_DEFAULT.height;\n if (this._renderer.dimensions) {\n w = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols;\n h = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows;\n }\n const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1;\n const report = `\\x1b]1337;ReportCellSize=${h.toFixed(3)};${w.toFixed(3)};${scale.toFixed(3)}\\x1b\\\\`;\n this._coreTerminal.input(report, false);\n return true;\n }\n\n if (seqType === SequenceType.MULTIPARTFILE) {\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n this._isMultipart = true;\n this._abortMulti = false;\n this._dec.release();\n if (!this._initDecoder()) {\n this._abortMulti = true;\n }\n return true;\n }\n\n if (seqType === SequenceType.FILEEND) {\n if (!this._isMultipart) return true;\n this._isMultipart = false;\n if (this._abortMulti || this._header.type !== SequenceType.MULTIPARTFILE) return true;\n }\n\n // fallthrough for SequenceType.FILE & SequenceType.FILEEND\n\n let w = 0;\n let h = 0;\n\n // early exit condition chain\n let cond: number | boolean;\n let metrics = UNSUPPORTED_TYPE;\n if (cond = success) {\n if (cond = !this._dec.end()) {\n metrics = imageType(this._dec.data8);\n if (cond = metrics.mime !== 'unsupported') {\n w = metrics.width;\n h = metrics.height;\n if (cond = w && h && w * h < this._opts.pixelLimit) {\n [w, h] = this._resize(w, h).map(Math.floor);\n cond = w && h && w * h < this._opts.pixelLimit;\n } else {\n console.warn(`IIP: image dimension issue ${metrics.width}x${metrics.height}`);\n }\n } else {\n console.warn('IIP: unsupported image type');\n }\n } else {\n console.warn('IIP: error during BASE64 decoding');\n }\n }\n if (!cond) {\n this._dec.release();\n return true;\n }\n\n let blob: Blob | ImageData;\n if (metrics.mime === 'image/qoi') {\n let data: Uint8Array;\n try {\n data = this._qoiDec.decode(this._dec.data8);\n } catch (e) {\n console.warn('IIP: could not decode QOI image', e);\n this._dec.release();\n this._qoiDec.release();\n return true;\n }\n blob = new ImageData(\n new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),\n this._qoiDec.width,\n this._qoiDec.height\n );\n this._qoiDec.release();\n if (w === this._qoiDec.width && h === this._qoiDec.height) {\n // use fast-path if we don't need to rescale\n this._dec.release();\n const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height);\n canvas.getContext('2d')?.putImageData(blob, 0, 0);\n this._storage.addImage(canvas);\n return true;\n }\n } else {\n blob = new Blob([this._dec.data8], { type: metrics.mime });\n }\n this._dec.release();\n const generation = this._generation;\n return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })\n .then(bm => {\n if (generation !== this._generation) {\n bm.close();\n return true;\n }\n this._storage.addImage(bm);\n return true;\n })\n .catch(e => {\n console.warn(`IIP: decoding error ${metrics.mime} ${metrics.width}x${metrics.height}`, e);\n return true;\n });\n }\n\n // Why: wasm memory exhaustion must drop this image, not throw out of the parser and wedge the write queue.\n private _initDecoder(): boolean {\n try {\n this._dec.init();\n return true;\n } catch (e) {\n console.warn('IIP: could not allocate decoder', e);\n this._dec.release();\n return false;\n }\n }\n\n private _resize(w: number, h: number): [number, number] {\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n const width = this._renderer.dimensions?.css.canvas.width || cw * this._coreTerminal.cols;\n const height = this._renderer.dimensions?.css.canvas.height || ch * this._coreTerminal.rows;\n\n const rw = this._dim(this._header.width!, width, cw);\n const rh = this._dim(this._header.height!, height, ch);\n if (!rw && !rh) {\n const wf = width / w; // TODO: should this respect initial cursor offset?\n const hf = (height - ch) / h; // TODO: fix offset issues from float cell height\n const f = Math.min(wf, hf);\n return f < 1 ? [w * f, h * f] : [w, h];\n }\n return !rw\n ? [w * rh / h, rh]\n : this._header.preserveAspectRatio || !rw || !rh\n ? [rw, h * rw / w] : [rw, rh];\n }\n\n private _dim(s: string, total: number, cdim: number): number {\n if (s === 'auto') return 0;\n if (s.endsWith('%')) return parseInt(s.slice(0, -1), 10) * total / 100;\n if (s.endsWith('px')) return parseInt(s.slice(0, -2), 10);\n return parseInt(s, 10) * cdim;\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n// eslint-disable-next-line\ndeclare const Buffer: any;\n\nexport const enum HeaderState {\n START = 0,\n ABORT = 1,\n KEY = 2,\n VALUE = 3,\n END = 4\n}\n\nexport const enum SequenceType {\n INVALID = 0,\n FILE = 1,\n MULTIPARTFILE = 2,\n FILEPART = 3,\n FILEEND = 4,\n REPORTCELLSIZE = 5\n}\n\nexport interface IHeaderFields {\n [key: string]: number | string | Uint32Array | null | undefined;\n // sequence type\n type: SequenceType;\n // base-64 encoded filename. Defaults to \"Unnamed file\".\n name: string;\n // File size in bytes. The file transfer will be canceled if this size is exceeded.\n size: number;\n /**\n * Optional width and height to render:\n * - N: N character cells.\n * - Npx: N pixels.\n * - N%: N percent of the session's width or height.\n * - auto: The image's inherent size will be used to determine an appropriate dimension.\n */\n width?: string;\n height?: string;\n // Optional, defaults to 1 respecting aspect ratio (width takes precedence).\n preserveAspectRatio?: number;\n // Optional, defaults to 0. If set to 1, the file will be displayed inline, else downloaded\n // (download not supported).\n inline?: number;\n}\n\n// field value decoders\n\n// ASCII bytes to string\nfunction toStr(data: Uint32Array): string {\n let s = '';\n for (let i = 0; i < data.length; ++i) {\n s += String.fromCharCode(data[i]);\n }\n return s;\n}\n\n// digits to integer\nfunction toInt(data: Uint32Array): number {\n let v = 0;\n for (let i = 0; i < data.length; ++i) {\n if (data[i] < 48 || data[i] > 57) {\n throw new Error('illegal char');\n }\n v = v * 10 + data[i] - 48;\n }\n return v;\n}\n\n// check for correct size entry\nfunction toSize(data: Uint32Array): string {\n const v = toStr(data);\n if (!v.match(/^((auto)|(\\d+?((px)|(%)){0,1}))$/)) {\n throw new Error('illegal size');\n }\n return v;\n}\n\n// name is base64 encoded utf-8\nfunction toName(data: Uint32Array): string {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(toStr(data), 'base64').toString();\n }\n const bs = atob(toStr(data));\n const b = new Uint8Array(bs.length);\n for (let i = 0; i < b.length; ++i) {\n b[i] = bs.charCodeAt(i);\n }\n return new TextDecoder().decode(b);\n}\n\nconst DECODERS: {[key: string]: (v: Uint32Array) => number | string} = {\n inline: toInt,\n size: toInt,\n name: toName,\n width: toSize,\n height: toSize,\n preserveAspectRatio: toInt\n};\n\n\n// sequence type markers\n// File\nconst FILE_MARKER = [70, 105, 108, 101];\n// MultipartFile\nconst MULTIPARTFILE_MARKER = [77, 117, 108, 116, 105, 112, 97, 114, 116, 70, 105, 108, 101];\n// FilePart\nconst FILEPART_MARKER = [70, 105, 108, 101, 80, 97, 114, 116];\n// FileEnd\nconst FILEEND_MARKER = [70, 105, 108, 101, 69, 110, 100];\n// ReportCellSize\nconst REPORTCELLSIZE_MARKER = [82, 101, 112, 111, 114, 116, 67, 101, 108, 108, 83, 105, 122, 101];\n\n// max allowed chars for sequence header\nconst MAX_FIELDCHARS = 1024;\n\n\nexport class HeaderParser {\n public state: HeaderState = HeaderState.START;\n private _buffer = new Uint32Array(MAX_FIELDCHARS);\n private _position = 0;\n private _key = '';\n public fields: {[key: string]: number | string | Uint32Array | null | undefined} = {};\n\n public reset(): void {\n this._buffer.fill(0);\n this.state = HeaderState.START;\n this._position = 0;\n this.fields = {};\n this._key = '';\n }\n\n public end(): number {\n if (this.state === HeaderState.START) {\n if (this._position === FILEEND_MARKER.length) {\n for (let k = 0; k < FILEEND_MARKER.length; ++k) {\n if (this._buffer[k] !== FILEEND_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEEND;\n this.state = HeaderState.END;\n return 0;\n }\n if (this._position === REPORTCELLSIZE_MARKER.length) {\n for (let k = 0; k < REPORTCELLSIZE_MARKER.length; ++k) {\n if (this._buffer[k] !== REPORTCELLSIZE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.REPORTCELLSIZE;\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n if (this.state === HeaderState.END) return 0;\n if (this.state === HeaderState.VALUE\n && this.fields.type === SequenceType.MULTIPARTFILE\n ) {\n if (!this._storeValue(this._position)) return this._a();\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n\n public parse(data: Uint32Array, start: number, end: number): number {\n let state = this.state;\n let pos = this._position;\n const buffer = this._buffer;\n if (state === HeaderState.ABORT || state === HeaderState.END) return -1;\n if (state === HeaderState.START && pos > 14) return -1;\n for (let i = start; i < end; ++i) {\n const c = data[i];\n switch (c) {\n case 59: // ;\n if (!this._storeValue(pos)) return this._a();\n state = HeaderState.KEY;\n pos = 0;\n break;\n case 61: // =\n if (state === HeaderState.START) {\n if (buffer[0] === 70) {\n // 'File' or 'FilePart'\n let k = 0;\n for (; k < FILE_MARKER.length; ++k) {\n if (buffer[k] !== FILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILE;\n if (pos === FILEPART_MARKER.length) {\n for (; k < FILEPART_MARKER.length; ++k) {\n if (buffer[k] !== FILEPART_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEPART;\n this.state = HeaderState.END;\n return i + 1;\n }\n } else if (buffer[0] === 77) {\n // 'MultipartFile'\n for (let k = 0; k < MULTIPARTFILE_MARKER.length; ++k) {\n if (buffer[k] !== MULTIPARTFILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.MULTIPARTFILE;\n } else {\n return this._a();\n }\n state = HeaderState.KEY;\n pos = 0;\n } else if (state === HeaderState.KEY) {\n if (!this._storeKey(pos)) return this._a();\n state = HeaderState.VALUE;\n pos = 0;\n } else if (state === HeaderState.VALUE) {\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n break;\n case 58: // :\n if (state === HeaderState.VALUE) {\n if (!this._storeValue(pos)) return this._a();\n }\n this.state = HeaderState.END;\n return i + 1;\n default:\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n }\n this.state = state;\n this._position = pos;\n return -2;\n }\n\n private _a(): number {\n this.fields.type = SequenceType.INVALID;\n this.state = HeaderState.ABORT;\n return -1;\n }\n\n private _storeKey(pos: number): boolean {\n const k = toStr(this._buffer.subarray(0, pos));\n if (k) {\n this._key = k;\n this.fields[k] = null;\n return true;\n }\n return false;\n }\n\n private _storeValue(pos: number): boolean {\n if (this._key) {\n try {\n const v = this._buffer.slice(0, pos);\n this.fields[this._key] = DECODERS[this._key] ? DECODERS[this._key](v) : v;\n } catch {\n return false;\n }\n return true;\n }\n return false;\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IAddImageOpts } from './Types';\nimport { ImageStorage } from './ImageStorage';\n\n/**\n * IIP (iTerm Image Protocol) specific image storage controller.\n *\n * Wraps the shared ImageStorage with IIP protocol semantics:\n * - Always uses scrolling mode (cursor advances with image)\n */\nexport class IIPImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n constructor(\n private readonly _storage: ImageStorage\n ) {}\n\n /**\n * Add an IIP image to storage.\n * Always uses scrolling mode — cursor advances past the image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._storage.addImage(img, this._addImageOpts);\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n\nexport type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'image/webp' | 'image/avif' | 'unsupported' | '';\n\nexport interface IMetrics {\n mime: ImageType;\n width: number;\n height: number;\n}\n\nexport const UNSUPPORTED_TYPE: IMetrics = {\n mime: 'unsupported',\n width: 0,\n height: 0\n};\n\nexport function imageType(d: Uint8Array): IMetrics {\n if (d.length < 32) {\n return UNSUPPORTED_TYPE;\n }\n const d32 = new Uint32Array(d.buffer, d.byteOffset, 8);\n // PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)\n // + first chunk must be IHDR\n if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {\n return {\n mime: 'image/png',\n width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],\n height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]\n };\n }\n // JPEG: FF D8 FF\n if (d[0] === 0xFF && d[1] === 0xD8 && d[2] === 0xFF) {\n const [width, height] = jpgSize(d);\n return { mime: 'image/jpeg', width, height };\n }\n // GIF: GIF87a or GIF89a\n if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {\n return {\n mime: 'image/gif',\n width: d[7] << 8 | d[6],\n height: d[9] << 8 | d[8]\n };\n }\n // QOI: qoif\n if (d32[0] === 0x66696F71) {\n return {\n mime: 'image/qoi',\n width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7],\n height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11]\n };\n }\n // WEBP: RIFF | xxxx | WEBP | VP8x\n if (d32[0] === 0x46464952 && d32[2] === 0x50424557 && (d32[3] & 0xFFFFFF) === 0x385056) {\n switch (d[15]) {\n case 0x58: // Extended WebP VP8X --> \"X\"\n return {\n mime: 'image/webp',\n width: (d[24] | d[25] << 8 | d[26] << 16) + 1,\n height: (d[27] | d[28] << 8 | d[29] << 16) + 1\n };\n case 0x4C: // Lossless WebP VP8L --> \"L\"\n if (d[20] !== 0x2f) return UNSUPPORTED_TYPE;\n const dim = d[21] | d[22] << 8 | d[23] << 16 | d[24] << 24;\n return {\n mime: 'image/webp',\n width: (dim & 0x3FFF) + 1,\n height: (dim >>> 14 & 0x3FFF) + 1\n };\n case 0x20: // Lossy WebP VP8 --> \" \"\n if (d[23] !== 0x9d || d[24] !== 0x01 || d[25] !== 0x2a) return UNSUPPORTED_TYPE;\n return {\n mime: 'image/webp',\n width: (d[26] | d[27] << 8) & 0x3FFF,\n height: (d[28] | d[29] << 8) & 0x3FFF\n };\n }\n return UNSUPPORTED_TYPE;\n }\n // AVIF: Box size | ftyp | avif/avis\n if (d32[1] === 0x70797466 && (d32[2] === 0x66697661 || d32[2] === 0x73697661)) {\n let pos = -1;\n // search for ispe box within first 1024 bytes\n const limit = Math.min(d.length - 16, 1024);\n for (let i = 8; i < limit; i++) {\n // scan for ispe\n if (d[i] === 0x69 && d[i + 1] === 0x73 && d[i + 2] === 0x70 && d[i + 3] === 0x65) {\n pos = i;\n break;\n }\n }\n if (pos !== -1) {\n // dimensions are in BE at +8 (width) at +12 (height)\n const width =\n d[pos + 8] << 24 |\n d[pos + 9] << 16 |\n d[pos + 10] << 8 |\n d[pos + 11];\n const height =\n d[pos + 12] << 24 |\n d[pos + 13] << 16 |\n d[pos + 14] << 8 |\n d[pos + 15];\n if (width > 0 && height > 0) {\n return { mime: 'image/avif', width, height };\n }\n }\n return UNSUPPORTED_TYPE;\n }\n return UNSUPPORTED_TYPE;\n}\n\n\nfunction jpgSize(d: Uint8Array): [number, number] {\n const len = d.length;\n let i = 4;\n let blockLength = d[i] << 8 | d[i + 1];\n while (true) {\n i += blockLength;\n if (i >= len) {\n // exhausted without size info\n return [0, 0];\n }\n if (d[i] !== 0xFF) {\n return [0, 0];\n }\n if (d[i + 1] === 0xC0 || d[i + 1] === 0xC2) {\n if (i + 8 < len) {\n return [\n d[i + 7] << 8 | d[i + 8],\n d[i + 5] << 8 | d[i + 6]\n ];\n }\n return [0, 0];\n }\n i += 2;\n blockLength = d[i] << 8 | d[i + 1];\n }\n}\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { toRGBA8888 } from 'sixel/lib/Colors';\nimport { IDisposable } from '@xterm/xterm';\nimport { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';\nimport { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\n\nconst enum Constants {\n PLACEHOLDER_LENGTH = 4096,\n PLACEHOLDER_HEIGHT = 24\n}\n\n/**\n * ImageRenderer - terminal frontend extension:\n * - provide primitives for canvas, ImageData, Bitmap (static)\n * - add canvas layer to DOM (browser only for now)\n * - draw image tiles onRender\n */\nexport class ImageRenderer extends Disposable implements IDisposable {\n /** @deprecated Kept for backward compat — points to top layer canvas. */\n public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; }\n private _layers = new Map();\n private _placeholder: HTMLCanvasElement | undefined;\n private _placeholderBitmap: ImageBitmap | undefined;\n private _optionsRefresh = this._register(new MutableDisposable());\n private _oldOpen: ((parent: HTMLElement) => void) | undefined;\n private _renderService: IRenderService | undefined;\n private _oldSetRenderer: ((renderer: any) => void) | undefined;\n\n // drawing primitive - canvas\n public static createCanvas(localDocument: Document | undefined, width: number, height: number): HTMLCanvasElement {\n /**\n * NOTE: We normally dont care, from which document the canvas\n * gets created, so we can fall back to global document,\n * if the terminal has no document associated yet.\n * This way early image loads before calling .open keep working\n * (still discouraged though, as the metrics will be screwed up).\n * Only the DOM output canvas should be on the terminal's document,\n * which gets explicitly checked in `insertLayerToDom`.\n */\n const canvas = (localDocument ?? document).createElement('canvas');\n canvas.width = width | 0;\n canvas.height = height | 0;\n return canvas;\n }\n\n // drawing primitive - ImageData with optional buffer\n public static createImageData(ctx: CanvasRenderingContext2D, width: number, height: number, buffer?: ArrayBuffer): ImageData {\n if (typeof ImageData !== 'function') {\n const imgData = ctx.createImageData(width, height);\n if (buffer) {\n imgData.data.set(new Uint8ClampedArray(buffer, 0, width * height * 4));\n }\n return imgData;\n }\n return buffer\n ? new ImageData(new Uint8ClampedArray(buffer, 0, width * height * 4), width, height)\n : new ImageData(width, height);\n }\n\n // drawing primitive - ImageBitmap\n public static createImageBitmap(img: ImageBitmapSource): Promise {\n if (typeof createImageBitmap !== 'function') {\n return Promise.resolve(undefined);\n }\n return createImageBitmap(img);\n }\n\n\n constructor(private _terminal: ITerminalExt) {\n super();\n this._oldOpen = this._terminal._core.open;\n this._terminal._core.open = (parent: HTMLElement): void => {\n this._oldOpen?.call(this._terminal._core, parent);\n this._open();\n };\n if (this._terminal._core.screenElement) {\n this._open();\n }\n // hack to spot fontSize changes\n this._optionsRefresh.value = this._terminal._core.optionsService.onOptionChange(option => {\n if (option === 'fontSize') {\n this.rescaleCanvas();\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n });\n this._register(toDisposable(() => {\n this.removeLayerFromDom();\n this.removeLayerFromDom('bottom');\n if (this._terminal._core && this._oldOpen) {\n this._terminal._core.open = this._oldOpen;\n this._oldOpen = undefined;\n }\n if (this._renderService && this._oldSetRenderer) {\n this._renderService.setRenderer = this._oldSetRenderer;\n this._oldSetRenderer = undefined;\n }\n this._renderService = undefined;\n this._layers.clear();\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }));\n }\n\n /**\n * Enable the placeholder.\n */\n public showPlaceholder(value: boolean): void {\n if (value) {\n if (!this._placeholder && this.cellSize.height !== -1) {\n this._createPlaceHolder(Math.max(this.cellSize.height + 1, Constants.PLACEHOLDER_HEIGHT));\n }\n } else {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n\n /**\n * Dimensions of the terminal.\n * Forwarded from internal render service.\n */\n public get dimensions(): IRenderDimensions | undefined {\n return this._terminal.dimensions;\n }\n\n /**\n * Current cell size (float).\n */\n public get cellSize(): ICellSize {\n return {\n width: this.dimensions?.css.cell.width || -1,\n height: this.dimensions?.css.cell.height || -1\n };\n }\n\n /**\n * Clear a region of the image layer canvas.\n */\n public clearLines(start: number, end: number, layer?: ImageLayer): void {\n const y = start * (this.dimensions?.css.cell.height || 0);\n const w = this.dimensions?.css.canvas.width || 0;\n const h = (end + 1 - start) * (this.dimensions?.css.cell.height || 0);\n if (!layer || layer === 'top') {\n this._layers.get('top')?.clearRect(0, y, w, h);\n }\n if (!layer || layer === 'bottom') {\n this._layers.get('bottom')?.clearRect(0, y, w, h);\n }\n }\n\n /**\n * Clear whole image canvas.\n */\n public clearAll(layer?: ImageLayer): void {\n if (!layer || layer === 'top') {\n const ctx = this._layers.get('top');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n if (!layer || layer === 'bottom') {\n const ctx = this._layers.get('bottom');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n }\n\n /**\n * Draw neighboring tiles on the image layer canvas.\n */\n public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get(imgSpec.layer);\n if (!ctx) {\n return;\n }\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const { width: sourceWidth, height: sourceHeight } = imgSpec.actualCellSize;\n const cols = Math.ceil(img.width / sourceWidth);\n\n const sx = (tileId % cols) * sourceWidth;\n const sy = Math.floor(tileId / cols) * sourceHeight;\n const dx = col * width;\n const dy = row * height;\n\n // safari bug: never access image source out of bounds\n const finalWidth = count * sourceWidth + sx > img.width ? img.width - sx : count * sourceWidth;\n const finalHeight = sy + sourceHeight > img.height ? img.height - sy : sourceHeight;\n\n // Floor all pixel offsets to get stable tile mapping without any overflows.\n // Note: For not pixel perfect aligned cells like in the DOM renderer\n // this will move a tile slightly to the top/left (subpixel range, thus ignore it).\n // FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),\n Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth * width / sourceWidth), Math.ceil(finalHeight * height / sourceHeight)\n );\n }\n\n /**\n * Extract a single tile from an image.\n */\n public extractTile(imgSpec: IImageSpec, tileId: number): HTMLCanvasElement | undefined {\n const { width, height } = this.cellSize;\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const { width: sourceWidth, height: sourceHeight } = imgSpec.actualCellSize;\n const cols = Math.ceil(img.width / sourceWidth);\n const sx = (tileId % cols) * sourceWidth;\n const sy = Math.floor(tileId / cols) * sourceHeight;\n const finalWidth = sourceWidth + sx > img.width ? img.width - sx : sourceWidth;\n const finalHeight = sy + sourceHeight > img.height ? img.height - sy : sourceHeight;\n\n const canvas = ImageRenderer.createCanvas(this.document, Math.ceil(finalWidth * width / sourceWidth), Math.ceil(finalHeight * height / sourceHeight));\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight),\n 0, 0, canvas.width, canvas.height\n );\n return canvas;\n }\n }\n\n /**\n * Draw a line with placeholder on the image layer canvas.\n */\n public drawPlaceholder(col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get('top');\n if (ctx) {\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n if (!this._placeholder) {\n this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT));\n } else if (height >= this._placeholder!.height) {\n this._createPlaceHolder(height + 1);\n }\n if (!this._placeholder) return;\n ctx.drawImage(\n this._placeholderBitmap ?? this._placeholder!,\n col * width,\n (row * height) % 2 ? 0 : 1, // needs %2 offset correction\n width * count,\n height,\n col * width,\n row * height,\n width * count,\n height\n );\n }\n }\n\n /**\n * Rescale image layer canvas if needed.\n * Checked once from `ImageStorage.render`.\n */\n public rescaleCanvas(): void {\n const w = this.dimensions?.css.canvas.width || 0;\n const h = this.dimensions?.css.canvas.height || 0;\n for (const ctx of this._layers.values()) {\n if (ctx.canvas.width !== w || ctx.canvas.height !== h) {\n ctx.canvas.width = w;\n ctx.canvas.height = h;\n }\n }\n }\n\n /**\n * Rescale image in storage if needed.\n */\n private _rescaleImage(spec: IImageSpec, currentWidth: number, currentHeight: number): void {\n if (currentWidth === spec.actualCellSize.width && currentHeight === spec.actualCellSize.height) {\n return;\n }\n const { width: originalWidth, height: originalHeight } = spec.origCellSize;\n if (currentWidth === originalWidth && currentHeight === originalHeight) {\n spec.actual = spec.orig;\n spec.actualCellSize.width = originalWidth;\n spec.actualCellSize.height = originalHeight;\n return;\n }\n const scaledWidth = Math.ceil(spec.orig!.width * currentWidth / originalWidth);\n const scaledHeight = Math.ceil(spec.orig!.height * currentHeight / originalHeight);\n // Upscale visible tiles directly; a full zoomed copy can dwarf the image budget.\n if (scaledWidth * scaledHeight > spec.orig!.width * spec.orig!.height) {\n spec.actual = spec.orig;\n spec.actualCellSize.width = originalWidth;\n spec.actualCellSize.height = originalHeight;\n return;\n }\n const canvas = ImageRenderer.createCanvas(this.document, scaledWidth, scaledHeight);\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(spec.orig!, 0, 0, canvas.width, canvas.height);\n spec.actual = canvas;\n spec.actualCellSize.width = currentWidth;\n spec.actualCellSize.height = currentHeight;\n }\n }\n\n /**\n * Lazy init for the renderer.\n */\n private _open(): void {\n this._renderService = this._terminal._core._renderService;\n this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);\n this._renderService.setRenderer = (renderer: any) => {\n for (const key of [...this._layers.keys()]) {\n this.removeLayerFromDom(key);\n }\n this._oldSetRenderer?.call(this._renderService, renderer);\n };\n }\n\n public insertLayerToDom(layer: ImageLayer = 'top'): void {\n // make sure that the terminal is attached to a document and to DOM\n if (!this.document || !this._terminal._core.screenElement) {\n console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');\n return;\n }\n if (this._layers.has(layer)) {\n return;\n }\n const canvas = ImageRenderer.createCanvas(\n this.document, this.dimensions?.css.canvas.width || 0,\n this.dimensions?.css.canvas.height || 0\n );\n canvas.classList.add(`xterm-image-layer-${layer}`);\n const screenElement = this._terminal._core.screenElement;\n // Use isolation to create a stacking context without overriding z-index,\n // which would conflict with integrators (e.g. VS Code) that set their\n // own z-index on the screen element.\n screenElement.style.isolation = 'isolate';\n if (layer === 'bottom') {\n // Use z-index:-1 so it paints behind non-positioned text elements.\n // The screen element needs to be a stacking context (via isolation)\n // to contain the negative z-index, otherwise it would go behind the\n // entire terminal.\n canvas.style.zIndex = '-1';\n screenElement.insertBefore(canvas, screenElement.firstChild);\n } else {\n // Explicit z-index ensures the image canvas reliably stacks above\n // the text layer (DOM renderer rows). z-index: 0 is below the\n // selection overlay (z-index: 1).\n canvas.style.zIndex = '0';\n screenElement.appendChild(canvas);\n }\n const ctx = canvas.getContext('2d', { alpha: true });\n if (!ctx) {\n canvas.remove();\n return;\n }\n this._layers.set(layer, ctx);\n this.clearAll(layer);\n }\n\n public removeLayerFromDom(layer: ImageLayer = 'top'): void {\n const ctx = this._layers.get(layer);\n if (ctx) {\n ctx.canvas.remove();\n this._layers.delete(layer);\n }\n }\n\n public hasLayer(layer: ImageLayer): boolean {\n return this._layers.has(layer);\n }\n\n private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n\n // create blueprint to fill placeholder with\n const bWidth = 32; // must be 2^n\n const blueprint = ImageRenderer.createCanvas(this.document, bWidth, height);\n const ctx = blueprint.getContext('2d', { alpha: false });\n if (!ctx) return;\n const imgData = ImageRenderer.createImageData(ctx, bWidth, height);\n const d32 = new Uint32Array(imgData.data.buffer);\n const black = toRGBA8888(0, 0, 0);\n const white = toRGBA8888(255, 255, 255);\n d32.fill(black);\n for (let y = 0; y < height; ++y) {\n const shift = y % 2;\n const offset = y * bWidth;\n for (let x = 0; x < bWidth; x += 2) {\n d32[offset + x + shift] = white;\n }\n }\n ctx.putImageData(imgData, 0, 0);\n\n // create placeholder line, width aligned to blueprint width\n const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || Constants.PLACEHOLDER_LENGTH;\n this._placeholder = ImageRenderer.createCanvas(this.document, width, height);\n const ctx2 = this._placeholder.getContext('2d', { alpha: false });\n if (!ctx2) {\n this._placeholder = undefined;\n return;\n }\n for (let i = 0; i < width; i += bWidth) {\n ctx2.drawImage(blueprint, i, 0);\n }\n const placeholder = this._placeholder;\n ImageRenderer.createImageBitmap(placeholder).then(bitmap => {\n if (this._placeholder !== placeholder) bitmap?.close();\n else this._placeholderBitmap = bitmap;\n }).catch(() => {});\n }\n\n public get document(): Document | undefined {\n return this._terminal._core._coreBrowserService?.window.document;\n }\n}\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageRenderer } from './ImageRenderer';\nimport {\n ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec,\n IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes,\n UnderlineStyle, IAddImageOpts\n} from './Types';\n\n\n// fallback default cell size\nexport const CELL_SIZE_DEFAULT: ICellSize = {\n width: 7,\n height: 14\n};\n\n/**\n * Extend extended attribute to also hold image tile information.\n *\n * Object definition is copied from base repo to fully mimick its behavior.\n * Image data is added as additional public properties `imageId` and `tileId`.\n */\nclass ExtendedAttrsImage implements IExtendedAttrsImage {\n private _ext: number = 0;\n public get ext(): number {\n if (this._urlId) {\n return (\n (this._ext & ~ExtFlags.UNDERLINE_STYLE) |\n (this.underlineStyle << 26)\n );\n }\n return this._ext;\n }\n public set ext(value: number) { this._ext = value; }\n\n public get underlineStyle(): UnderlineStyle {\n // Always return the URL style if it has one\n if (this._urlId) {\n return UnderlineStyle.DASHED;\n }\n return (this._ext & ExtFlags.UNDERLINE_STYLE) >> 26;\n }\n public set underlineStyle(value: UnderlineStyle) {\n this._ext &= ~ExtFlags.UNDERLINE_STYLE;\n this._ext |= (value << 26) & ExtFlags.UNDERLINE_STYLE;\n }\n\n public get underlineColor(): number {\n return this._ext & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n public set underlineColor(value: number) {\n this._ext &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);\n this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n\n public get underlineVariantOffset(): number {\n const val = (this._ext & ExtFlags.VARIANT_OFFSET) >> 29;\n if (val < 0) {\n return val ^ 0xFFFFFFF8;\n }\n return val;\n }\n public set underlineVariantOffset(value: number) {\n this._ext &= ~ExtFlags.VARIANT_OFFSET;\n this._ext |= (value << 29) & ExtFlags.VARIANT_OFFSET;\n }\n\n private _urlId: number = 0;\n public get urlId(): number {\n return this._urlId;\n }\n public set urlId(value: number) {\n this._urlId = value;\n }\n\n constructor(\n ext: number = 0,\n urlId: number = 0,\n public imageId = -1,\n public tileId = -1\n ) {\n this._ext = ext;\n this._urlId = urlId;\n }\n\n public clone(): IExtendedAttrsImage {\n /**\n * Technically we dont need a clone variant of ExtendedAttrsImage,\n * as we never clone a cell holding image data.\n * Note: Clone is only meant to be used by the InputHandler for\n * sticky attributes, which is never the case for image data.\n * We still provide a proper clone method to reflect the full ext attr\n * state in case there are future use cases for clone.\n */\n return new ExtendedAttrsImage(this._ext, this._urlId, this.imageId, this.tileId);\n }\n\n public isEmpty(): boolean {\n return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.imageId === -1;\n }\n}\nconst EMPTY_ATTRS = new ExtendedAttrsImage();\n\n\n/**\n * ImageStorage - extension of CoreTerminal:\n * - hold image data\n * - write/read image data to/from buffer\n *\n * TODO: image composition for overwrites\n */\nexport class ImageStorage implements IDisposable {\n // storage\n private _images: Map = new Map();\n // last used id\n private _lastId = 0;\n // last evicted id\n private _lowestId = 0;\n // whether a full clear happened before\n private _fullyCleared = false;\n // whether render should do a full clear\n private _needsFullClear = false;\n // hard limit of stored pixels (fallback limit of 10 MB)\n private _pixelLimit: number = 2500000;\n\n private _viewportMetrics: { cols: number, rows: number };\n public onImageAdded: (() => void) | undefined;\n public onImageDeleted: ((storageId: number) => void) | undefined;\n\n constructor(\n private _terminal: ITerminalExt,\n private _renderer: ImageRenderer,\n private _opts: IImageAddonOptions\n ) {\n try {\n this.setLimit(this._opts.storageLimit);\n } catch (e: unknown) {\n if (e instanceof Error) {\n console.error(e.message);\n }\n console.warn(`storageLimit is set to ${this.getLimit()} MB`);\n }\n this._viewportMetrics = {\n cols: this._terminal.cols,\n rows: this._terminal.rows\n };\n }\n\n public dispose(): void {\n this.reset();\n }\n\n public reset(): void {\n for (const spec of this._images.values()) {\n spec.marker?.dispose();\n }\n // NOTE: marker.dispose above already calls ImageBitmap.close\n // therefore we can just wipe the map here\n this._images.clear();\n this._renderer.clearAll();\n }\n\n public getLimit(): number {\n return this._pixelLimit * 4 / 1000000;\n }\n\n public setLimit(value: number): void {\n if (value < 0.5 || value > 1000) {\n throw RangeError('invalid storageLimit, should be at least 0.5 MB and not exceed 1G');\n }\n this._pixelLimit = (value / 4 * 1000000) >>> 0;\n this._evictOldest(0);\n }\n\n public getUsage(): number {\n return this._getStoredPixels() * 4 / 1000000;\n }\n\n private _getStoredPixels(): number {\n let storedPixels = 0;\n for (const spec of this._images.values()) {\n if (spec.orig) {\n storedPixels += spec.orig.width * spec.orig.height;\n if (spec.actual && spec.actual !== spec.orig) {\n storedPixels += spec.actual.width * spec.actual.height;\n }\n }\n }\n return storedPixels;\n }\n\n private _delImg(id: number): void {\n const spec = this._images.get(id);\n if (!spec) return;\n this._images.delete(id);\n // FIXME: really ugly workaround to get bitmaps deallocated :(\n if (window.ImageBitmap && spec.orig instanceof ImageBitmap) {\n spec.orig.close();\n }\n this.onImageDeleted?.(id);\n }\n\n /**\n * Wipe canvas and images on alternate buffer.\n */\n public wipeAlternate(): void {\n // remove all alternate tagged images\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate') {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n // mark canvas to be wiped on next render\n this._needsFullClear = true;\n this._fullyCleared = false;\n }\n\n /**\n * Delete an image by its internal storage ID.\n * Used by protocols that support explicit deletion (e.g. Kitty a=d).\n */\n public deleteImage(id: number): void {\n const spec = this._images.get(id);\n if (spec) {\n spec.marker?.dispose();\n this._delImg(id);\n }\n }\n\n /**\n * Method to add an image to the storage.\n * @param img - The image to add (canvas or bitmap).\n * @param opts - Options for addImage:\n * - scrolling: When true, cursor advances with the image.\n * When false, image is placed at ORIGIN and cursor does not move.\n * - layer: Which canvas layer to render on ('top' or 'bottom').\n * - zIndex: Z-index for image layering within the same layer.\n * - cursorPos: 'vt340' for bottom-left, 'iip' for bottom.right.\n * @returns The internal image ID assigned to the stored image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap, opts: IAddImageOpts): number {\n // never allow storage to exceed memory limit\n this._evictOldest(img.width * img.height);\n\n // calc rows x cols needed to display the image\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const cols = Math.ceil(img.width / cellSize.width);\n const rows = Math.ceil(img.height / cellSize.height);\n\n const imageId = ++this._lastId;\n\n const buffer = this._terminal._core.buffer;\n const termCols = this._terminal.cols;\n const termRows = this._terminal.rows;\n const originX = buffer.x;\n const originY = buffer.y;\n let offset = originX;\n let tileCount = 0;\n\n if (!opts.scrolling) {\n buffer.x = 0;\n buffer.y = 0;\n offset = 0;\n }\n\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(buffer.y + buffer.ybase);\n for (let col = 0; col < cols; ++col) {\n if (offset + col >= termCols) break;\n this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);\n tileCount++;\n }\n if (opts.scrolling) {\n if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();\n } else {\n if (++buffer.y >= termRows) break;\n }\n buffer.x = offset;\n }\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n\n // cursor positioning modes\n if (opts.scrolling) {\n if (opts.cursorPos === 'iip') {\n buffer.x = Math.min(offset + cols, termCols);\n } else {\n buffer.x = offset;\n }\n } else {\n buffer.x = originX;\n buffer.y = originY;\n }\n\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.tileCount < 1) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n\n // eviction marker:\n // delete the image when the marker gets disposed\n const endMarker = this._terminal.registerMarker(0);\n endMarker?.onDispose(() => {\n const spec = this._images.get(imageId);\n if (spec) {\n this._delImg(imageId);\n }\n });\n\n // since markers do not work on alternate for some reason,\n // we evict images here manually\n if (this._terminal.buffer.active.type === 'alternate') {\n this._evictOnAlternate();\n }\n\n // create storage entry\n const imgSpec: IImageSpec = {\n orig: img,\n origCellSize: cellSize,\n actual: img,\n actualCellSize: { ...cellSize }, // clone needed, since later modified\n marker: endMarker || undefined,\n tileCount,\n bufferType: this._terminal.buffer.active.type,\n layer: opts.layer,\n zIndex: opts.zIndex\n };\n\n // finally add the image\n this._images.set(imageId, imgSpec);\n this.onImageAdded?.();\n return imageId;\n }\n\n\n /**\n * Render method. Collects buffer information and triggers\n * canvas updates.\n */\n // TODO: Should we move this to the ImageRenderer?\n public render(range: { start: number, end: number }): void {\n // Determine which layers have images\n let hasTopImages = false;\n let hasBottomImages = false;\n for (const spec of this._images.values()) {\n if (spec.layer === 'bottom') {\n hasBottomImages = true;\n } else {\n hasTopImages = true;\n }\n if (hasTopImages && hasBottomImages) break;\n }\n\n // Lazily insert layers that are needed\n if (hasTopImages && !this._renderer.hasLayer('top')) {\n this._renderer.insertLayerToDom('top');\n if (!this._renderer.hasLayer('top')) return;\n }\n if (hasBottomImages && !this._renderer.hasLayer('bottom')) {\n this._renderer.insertLayerToDom('bottom');\n }\n\n // rescale if needed\n this._renderer.rescaleCanvas();\n\n // exit early if we dont have any images to test for\n if (!this._images.size) {\n if (!this._fullyCleared) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n if (this._renderer.hasLayer('top')) {\n this._renderer.removeLayerFromDom('top');\n }\n if (this._renderer.hasLayer('bottom')) {\n this._renderer.removeLayerFromDom('bottom');\n }\n return;\n }\n\n // Remove layers no longer needed\n if (!hasTopImages && this._renderer.hasLayer('top')) {\n this._renderer.clearAll('top');\n this._renderer.removeLayerFromDom('top');\n }\n if (!hasBottomImages && this._renderer.hasLayer('bottom')) {\n this._renderer.clearAll('bottom');\n this._renderer.removeLayerFromDom('bottom');\n }\n\n // buffer switches force a full clear\n if (this._needsFullClear) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n\n const { start, end } = range;\n const buffer = this._terminal._core.buffer;\n const cols = this._terminal._core.cols;\n\n // clear drawing area\n this._renderer.clearLines(start, end);\n\n // Collect draw calls so we can sort by z-index (lower z drawn first).\n const drawCalls: { imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number }[] = [];\n const placeholderCalls: { col: number, row: number, count: number }[] = [];\n\n // walk all cells in viewport and collect tiles found\n for (let row = start; row <= end; ++row) {\n const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt;\n if (!line) return;\n for (let col = 0; col < cols; ++col) {\n if (line.getBg(col) & BgFlags.HAS_EXTENDED) {\n let e: IExtendedAttrsImage = line._extendedAttrs[col] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (e.tileId !== -1) {\n const startTile = e.tileId;\n const startCol = col;\n let count = 1;\n /**\n * merge tiles to the right into a single draw call, if:\n * - not at end of line\n * - cell has same image id\n * - cell has consecutive tile id\n */\n while (\n ++col < cols\n && (line.getBg(col) & BgFlags.HAS_EXTENDED)\n && (e = line._extendedAttrs[col] ?? EMPTY_ATTRS)\n && (e.imageId === imageId)\n && (e.tileId === startTile + count)\n ) {\n count++;\n }\n col--;\n if (imgSpec) {\n if (imgSpec.actual) {\n drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });\n }\n } else if (this._opts.showPlaceholder) {\n placeholderCalls.push({ col: startCol, row, count });\n }\n this._fullyCleared = false;\n }\n }\n }\n }\n\n // Sort by z-index so lower z draws first (higher z renders on top)\n drawCalls.sort((a, b) => a.imgSpec.zIndex - b.imgSpec.zIndex);\n\n // Draw placeholders first (lowest priority)\n for (const call of placeholderCalls) {\n this._renderer.drawPlaceholder(call.col, call.row, call.count);\n }\n\n // Draw images in z-index order\n for (const call of drawCalls) {\n this._renderer.draw(call.imgSpec, call.tileId, call.col, call.row, call.count);\n }\n }\n\n public viewportResize(metrics: { cols: number, rows: number }): void {\n // exit early if we have nothing in storage\n if (!this._images.size) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // handle only viewport width enlargements, exit all other cases\n // TODO: needs patch for tile counter\n if (this._viewportMetrics.cols >= metrics.cols) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // walk scrollbuffer at old col width to find all possible expansion matches\n const buffer = this._terminal._core.buffer;\n const rows = buffer.lines.length;\n const oldCol = this._viewportMetrics.cols - 1;\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(row) as IBufferLineExt;\n if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (!imgSpec) {\n continue;\n }\n // found an image tile at oldCol, check if it qualifies for right exapansion\n const tilesPerRow = Math.ceil((imgSpec.actual?.width || 0) / imgSpec.actualCellSize.width);\n if ((e.tileId % tilesPerRow) + 1 >= tilesPerRow) {\n continue;\n }\n // expand only if right side is empty (nothing got wrapped from below)\n let hasData = false;\n for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) {\n if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) {\n hasData = true;\n break;\n }\n }\n if (hasData) {\n continue;\n }\n // do right expansion on terminal buffer\n const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol);\n let lastTile = e.tileId;\n for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) {\n this._writeToCell(line as IBufferLineExt, expandCol, imageId, ++lastTile);\n imgSpec.tileCount++;\n }\n }\n }\n // store new viewport metrics\n this._viewportMetrics = metrics;\n }\n\n /**\n * Retrieve original canvas at buffer position.\n */\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1) {\n const orig = this._images.get(e.imageId)?.orig;\n if (window.ImageBitmap && orig instanceof ImageBitmap) {\n const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height);\n canvas.getContext('2d')?.drawImage(orig, 0, 0, orig.width, orig.height);\n return canvas;\n }\n return orig as HTMLCanvasElement;\n }\n }\n }\n\n /**\n * Extract active single tile at buffer position.\n */\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1 && e.tileId !== -1) {\n const spec = this._images.get(e.imageId);\n if (spec) {\n return this._renderer.extractTile(spec, e.tileId);\n }\n }\n }\n }\n\n // TODO: Do we need some blob offloading tricks here to avoid early eviction?\n // also see https://stackoverflow.com/questions/28307789/is-there-any-limitation-on-javascript-max-blob-size\n private _evictOldest(room: number): number {\n const used = this._getStoredPixels();\n let current = used;\n while (this._pixelLimit < current + room && this._images.size) {\n const spec = this._images.get(++this._lowestId);\n if (spec && spec.orig) {\n current -= spec.orig.width * spec.orig.height;\n if (spec.actual && spec.orig !== spec.actual) {\n current -= spec.actual.width * spec.actual.height;\n }\n spec.marker?.dispose();\n this._delImg(this._lowestId);\n }\n }\n return used - current;\n }\n\n private _writeToCell(line: IBufferLineExt, x: number, imageId: number, tileId: number): void {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const old = line._extendedAttrs[x];\n if (old) {\n if (old.imageId !== undefined) {\n // found an old ExtendedAttrsImage, since we know that\n // they are always isolated instances (single cell usage),\n // we can re-use it and just update their id entries\n const oldSpec = this._images.get(old.imageId);\n if (oldSpec) {\n // early eviction for in-viewport overwrites\n oldSpec.tileCount--;\n }\n old.imageId = imageId;\n old.tileId = tileId;\n return;\n }\n // found a plain ExtendedAttrs instance, clone it to new entry\n line._extendedAttrs[x] = new ExtendedAttrsImage(old.ext, old.urlId, imageId, tileId);\n return;\n }\n }\n // fall-through: always create new ExtendedAttrsImage entry\n line._data[x * Cell.SIZE + Cell.BG] |= BgFlags.HAS_EXTENDED;\n line._extendedAttrs[x] = new ExtendedAttrsImage(0, 0, imageId, tileId);\n }\n\n private _evictOnAlternate(): void {\n // nullify tile count of all images on alternate buffer\n for (const spec of this._images.values()) {\n if (spec.bufferType === 'alternate') {\n spec.tileCount = 0;\n }\n }\n // re-count tiles on whole buffer\n const buffer = this._terminal._core.buffer;\n for (let y = 0; y < this._terminal.rows; ++y) {\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (!line) {\n continue;\n }\n for (let x = 0; x < this._terminal.cols; ++x) {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const imgId = line._extendedAttrs[x]?.imageId;\n if (imgId) {\n const spec = this._images.get(imgId);\n if (spec) {\n spec.tileCount++;\n }\n }\n }\n }\n }\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate' && !spec.tileCount) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n }\n}\n","/**\n * Copyright (c) 2020, 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IDcsHandler, IParams, IImageAddonOptions, ITerminalExt, AttributeData, IResetHandler, ReadonlyColorSet } from './Types';\nimport { toRGBA8888, BIG_ENDIAN, PALETTE_ANSI_256, PALETTE_VT340_COLOR } from 'sixel/lib/Colors';\nimport { RGBA8888 } from 'sixel/lib/Types';\nimport { ImageRenderer } from './ImageRenderer';\n\nimport { DecoderAsync, Decoder } from 'sixel/lib/Decoder';\nimport { LIMITS } from 'sixel/lib/wasm';\n\n// always free decoder ressources after decoding if it exceeds this limit\nconst MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB\n\n// custom default palette: VT340 (lower 16 colors) + ANSI256 (up to 256) + zeroed (up to 4096)\nconst DEFAULT_PALETTE = PALETTE_ANSI_256;\nDEFAULT_PALETTE.set(PALETTE_VT340_COLOR);\n\n// Why pooled: every decoder owns a wasm memory, and V8 caps live wasm memories\n// per process (~124 in a sandboxed renderer). Terminals borrow a decoder only\n// while a SIXEL sequence is open, so idle terminals hold none.\nconst MAX_IDLE_DECODERS = 2;\nconst idleDecoders = new Map();\nlet poolPrimed = false;\n\nfunction primeDecoderPool(memoryLimit: number): void {\n if (poolPrimed) return;\n poolPrimed = true;\n // Async compile once, off the parser's hot path; later decoders reuse the cached module.\n DecoderAsync({ memoryLimit, palette: DEFAULT_PALETTE }).then(\n d => releaseDecoder(d, memoryLimit),\n () => { poolPrimed = false; }\n );\n}\n\nfunction acquireDecoder(memoryLimit: number): Decoder {\n return idleDecoders.get(memoryLimit)?.pop() ?? new Decoder({ memoryLimit, palette: DEFAULT_PALETTE });\n}\n\nfunction releaseDecoder(dec: Decoder, memoryLimit: number): void {\n if (dec.memoryUsage > MEM_PERMA_LIMIT) {\n dec.release();\n }\n const idle = idleDecoders.get(memoryLimit) ?? [];\n if (idle.length < MAX_IDLE_DECODERS) {\n idle.push(dec);\n idleDecoders.set(memoryLimit, idle);\n }\n}\n\n\nexport class SixelHandler implements IDcsHandler, IResetHandler {\n private _size = 0;\n private _aborted = false;\n private _dec: Decoder | undefined;\n private _decMemoryLimit = 0;\n // Color registers outlive a single image, so they live here rather than in a pooled decoder.\n private readonly _palette = new Uint32Array(LIMITS.PALETTE_SIZE);\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _storage: SixelImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n this._palette.set(DEFAULT_PALETTE);\n primeDecoderPool(this._opts.pixelLimit * 4);\n }\n\n public reset(): void {\n this._returnDecoder();\n this._palette.fill(0);\n this._palette.set(DEFAULT_PALETTE);\n }\n\n public hook(params: IParams): void {\n this._size = 0;\n this._aborted = false;\n this._returnDecoder();\n const memoryLimit = this._opts.pixelLimit * 4;\n try {\n this._dec = acquireDecoder(memoryLimit);\n } catch (e) {\n // Why: exhausting wasm memory must drop this image, not throw out of the parser and wedge the write queue.\n console.warn(`SIXEL: could not allocate decoder - ${e}`);\n this._aborted = true;\n return;\n }\n this._decMemoryLimit = memoryLimit;\n const fillColor = params.params[1] === 1 ? 0 : extractActiveBg(\n this._coreTerminal._core._inputHandler._curAttrData,\n this._coreTerminal._core._themeService?.colors);\n this._dec.init(fillColor, this._palette, this._opts.sixelPaletteLimit);\n }\n\n private _returnDecoder(): void {\n const dec = this._dec;\n if (!dec) return;\n this._dec = undefined;\n this._palette.set(dec.palette);\n releaseDecoder(dec, this._decMemoryLimit);\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted || !this._dec) {\n return;\n }\n this._size += end - start;\n if (this._size > this._opts.sixelSizeLimit) {\n console.warn(`SIXEL: too much data, aborting`);\n this._aborted = true;\n this._dec.release();\n return;\n }\n try {\n this._dec.decode(data, start, end);\n } catch (e) {\n console.warn(`SIXEL: error while decoding image - ${e}`);\n this._aborted = true;\n this._dec.release();\n }\n }\n\n public unhook(success: boolean): boolean | Promise {\n try {\n return this._unhook(success);\n } finally {\n this._returnDecoder();\n }\n }\n\n private _unhook(success: boolean): boolean {\n if (this._aborted || !success || !this._dec) {\n return true;\n }\n\n const width = this._dec.width;\n const height = this._dec.height;\n\n // partial fix for https://github.com/jerch/xterm-addon-image/issues/37\n if (!width || !height) {\n if (height) {\n this._storage.advanceCursor(height);\n }\n return true;\n }\n\n const canvas = ImageRenderer.createCanvas(undefined, width, height);\n canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray, width, height), 0, 0);\n this._storage.addImage(canvas);\n return true;\n }\n}\n\n\n/**\n * Some helpers to extract current terminal colors.\n */\n\n// get currently active background color from terminal\n// also respect INVERSE setting\nfunction extractActiveBg(attr: AttributeData, colors: ReadonlyColorSet | undefined): RGBA8888 {\n let bg = 0;\n if (!colors) {\n // FIXME: theme service is prolly not available yet,\n // happens if .open() was not called yet (bug in core?)\n return bg;\n }\n if (attr.isInverse()) {\n if (attr.isFgDefault()) {\n bg = convertLe(colors.foreground.rgba);\n } else if (attr.isFgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getFgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getFgColor()].rgba);\n }\n } else {\n if (attr.isBgDefault()) {\n bg = convertLe(colors.background.rgba);\n } else if (attr.isBgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getBgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getBgColor()].rgba);\n }\n }\n return bg;\n}\n\n// rgba values on the color managers are always in BE, thus convert to LE\nfunction convertLe(color: number): RGBA8888 {\n if (BIG_ENDIAN) return color;\n return (color & 0xFF) << 24 | (color >>> 8 & 0xFF) << 16 | (color >>> 16 & 0xFF) << 8 | color >>> 24 & 0xFF;\n}\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { IImageAddonOptions, ITerminalExt, IAddImageOpts } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\n\n/**\n * Sixel-specific image storage controller.\n *\n * Wraps the shared ImageStorage with sixel protocol semantics:\n * - Cursor behavior governed by DECSET 80 (sixelScrolling option)\n * - advanceCursor for empty sixels carrying only height\n */\nexport class SixelImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'vt340' };\n constructor(\n private readonly _storage: ImageStorage,\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _terminal: ITerminalExt\n ) {}\n\n /**\n * Add a sixel image to storage.\n * Cursor behavior depends on the sixelScrolling option (DECSET 80).\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._addImageOpts.scrolling = this._opts.sixelScrolling;\n this._storage.addImage(img, this._addImageOpts);\n }\n\n /**\n * Only advance text cursor.\n * This is an edge case from empty sixels carrying only a height but no pixels.\n * Partially fixes https://github.com/jerch/xterm-addon-image/issues/37.\n */\n public advanceCursor(height: number): void {\n if (this._opts.sixelScrolling) {\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const rows = Math.ceil(height / cellSize.height);\n for (let i = 1; i < rows; ++i) {\n this._terminal._core._inputHandler.lineFeed();\n }\n }\n }\n}\n","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';\nimport { ImageRenderer } from '../ImageRenderer';\nimport { CELL_SIZE_DEFAULT } from '../ImageStorage';\nimport { imageType } from '../IIPMetrics';\nimport { KittyImageStorage } from './KittyImageStorage';\nimport Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport {\n KittyAction,\n KittyFormat,\n KittyCompression,\n IKittyCommand,\n IPendingTransmission,\n IKittyImageData,\n KittyPixelConstants,\n parseKittyCommand\n} from './KittyGraphicsTypes';\n\nconst enum Constants {\n // Memory limit for base64 decoder (4MB, same as IIPHandler)\n DECODER_KEEP_DATA = 4194304,\n DECODER_INITIAL_DATA = 4194304, // 4MB\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n DECODER_OK = 0,\n // Maximum control data size\n MAX_CONTROL_DATA_SIZE = 512,\n // Semicolon codepoint\n SEMICOLON = 0x3B\n}\n\nconst DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK;\n\n// Kitty graphics protocol handler with streaming base64 decoding.\nexport class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {\n private _aborted = false;\n private _generation = 0;\n private _decodeError = false;\n\n private _activeDecoder: Base64Decoder | null = null;\n private readonly _maxEncodedBytes: number;\n private readonly _initialEncodedBytes: number;\n\n // Streaming related states\n\n // True while receiving control data (before semicolon).\n private _inControlData = true;\n\n // Buffer for control data.\n private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);\n private _controlLength = 0;\n\n // Pre-calculated encoded size limit\n private _encodedSizeLimit = 0;\n private _totalEncodedSize = 0;\n\n // Parsed command. These are the control data before semicolon.\n private _parsedCommand: IKittyCommand | null = null;\n\n // Storage related states\n\n private _pendingTransmissions: Map = new Map();\n // Tracks the pending key of the most recently started chunked upload.\n // Per spec, subsequent chunks only need m= (and optionally q=), without i=.\n // When a chunk arrives with no i=, this key is used to find the pending upload.\n private _lastPendingKey: number | undefined;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _kittyStorage: KittyImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n // Convert decoded size limit -> max encoded bytes.\n this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);\n // ensure we preallocate more than configured limit while using 4mb initial size.\n this._initialEncodedBytes = Math.min(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);\n }\n\n public reset(): void {\n this._generation++;\n this._cleanupAllPending();\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n this._kittyStorage.reset();\n }\n\n public dispose(): void {\n this.reset();\n }\n\n private _removePendingEntry(key: number): void {\n this._pendingTransmissions.delete(key);\n if (this._lastPendingKey === key) {\n this._lastPendingKey = undefined;\n }\n }\n\n private _cleanupAllPending(): void {\n for (const pending of this._pendingTransmissions.values()) {\n pending.decoder.release();\n }\n this._pendingTransmissions.clear();\n this._lastPendingKey = undefined;\n }\n\n public start(): void {\n this._aborted = false;\n this._decodeError = false;\n this._inControlData = true;\n this._controlLength = 0;\n this._parsedCommand = null;\n // Pre-calculate encoded limit once: base64 is 4 bytes encoded → 3 bytes decoded\n this._encodedSizeLimit = this._maxEncodedBytes;\n this._totalEncodedSize = 0;\n this._activeDecoder = null;\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (!this._inControlData) {\n this._streamPayload(data, start, end);\n } else {\n // Scan for semicolon\n let controlEnd = end;\n for (let i = start; i < end; i++) {\n if (data[i] === Constants.SEMICOLON) {\n this._inControlData = false;\n controlEnd = i;\n break;\n }\n }\n\n // Copy control data\n const copyLength = controlEnd - start;\n if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {\n this._aborted = true;\n return;\n }\n this._controlData.set(data.subarray(start, controlEnd), this._controlLength);\n this._controlLength += copyLength;\n\n if (!this._inControlData) {\n // Found semicolon - parse control data early for validation\n this._parsedCommand = parseKittyCommand(this._parseControlDataString());\n\n // Early validation: i+I conflict\n if (this._parsedCommand.id !== undefined && this._parsedCommand.imageNumber !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'EINVAL:cannot specify both i and I keys', this._parsedCommand.quiet ?? 0);\n this._aborted = true;\n return;\n }\n\n // Delete action doesn't need payload - skip streaming\n if (this._parsedCommand.action === KittyAction.DELETE) {\n return;\n }\n\n // Stream remaining as payload\n const payloadStart = controlEnd + 1;\n if (payloadStart < end) {\n this._streamPayload(data, payloadStart, end);\n }\n }\n }\n }\n\n // Stream payload bytes into the base64 decoder.\n private _streamPayload(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n // Check size limit (compare encoded bytes against pre-calculated limit)\n // Include cumulative size from pending transmission for multi-chunk images.\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = this._parsedCommand?.id ?? this._lastPendingKey ?? 0;\n const pending = this._pendingTransmissions.get(pendingKey);\n const previousEncodedSize = pending?.totalEncodedSize ?? 0;\n this._totalEncodedSize += end - start;\n const cumulativeEncodedSize = previousEncodedSize + this._totalEncodedSize;\n if (cumulativeEncodedSize > this._encodedSizeLimit) {\n const decoderToRelease = this._activeDecoder ?? pending?.decoder;\n if (decoderToRelease) {\n decoderToRelease.release();\n }\n this._activeDecoder = null;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n this._aborted = true;\n return;\n }\n\n if (this._decodeError) return;\n\n if (pending?.decoder && !this._activeDecoder) {\n this._activeDecoder = pending.decoder;\n }\n if (!this._activeDecoder) {\n // Budget WASM capacity, including one page of decoder state and rounding.\n const decoderCapacity = this._maxEncodedBytes + 131072;\n if (decoderCapacity > this._opts.storageLimit * 1000000) {\n this._aborted = true;\n if (this._parsedCommand?.id !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'ENOMEM:pending image budget exceeded', this._parsedCommand.quiet ?? 0);\n }\n return;\n }\n const maxPending = Math.max(1, Math.floor(this._opts.storageLimit * 1000000 / decoderCapacity));\n while (this._pendingTransmissions.size >= maxPending) {\n const oldest = this._pendingTransmissions.entries().next().value;\n if (!oldest) break;\n oldest[1].decoder.release();\n this._removePendingEntry(oldest[0]);\n if (oldest[1].cmd.id !== undefined) {\n this._sendResponse(oldest[1].cmd.id, 'ENOMEM:pending image budget exceeded', oldest[1].cmd.quiet ?? 0);\n }\n }\n const decoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);\n try {\n decoder.init();\n } catch (e) {\n // Why: wasm memory exhaustion must drop this image, not throw out of the parser and wedge the write queue.\n console.warn('KITTY: could not allocate decoder', e);\n this._aborted = true;\n if (this._parsedCommand?.id !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'ENOMEM:could not allocate decoder', this._parsedCommand.quiet ?? 0);\n }\n return;\n }\n this._activeDecoder = decoder;\n }\n\n if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n this._decodeError = true;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted || !success) {\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n return true;\n }\n\n // No semicolon = no payload (delete, capability query)\n if (this._inControlData) {\n return this._handleNoPayloadCommand();\n }\n\n // Use command parsed early in put() - i+I already validated there\n const cmd = this._parsedCommand!;\n\n // Delete action was handled by skipping payload - just execute\n if (cmd.action === KittyAction.DELETE) {\n return this._handleDelete(cmd);\n }\n\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = cmd.id ?? this._lastPendingKey ?? 0;\n const isMoreComing = cmd.more === 1;\n const pending = this._pendingTransmissions.get(pendingKey);\n\n if (isMoreComing) {\n if (this._activeDecoder) {\n if (pending) {\n pending.totalEncodedSize += this._totalEncodedSize;\n pending.decodeError = pending.decodeError || this._decodeError;\n } else {\n this._pendingTransmissions.set(pendingKey, {\n cmd: { ...cmd },\n decoder: this._activeDecoder,\n totalEncodedSize: this._totalEncodedSize,\n decodeError: this._decodeError\n });\n }\n this._lastPendingKey = pendingKey;\n this._activeDecoder = null;\n }\n return true;\n }\n\n // Final chunk received — clear the last pending key\n if (pending) {\n this._lastPendingKey = undefined;\n }\n\n let decodeError = this._decodeError;\n let finalCmd = cmd;\n let decoder = this._activeDecoder;\n\n if (pending) {\n finalCmd = pending.cmd;\n decoder = pending.decoder;\n decodeError = decodeError || pending.decodeError;\n this._pendingTransmissions.delete(pendingKey);\n }\n\n let imageBytes = new Uint8Array(0);\n if (decoder) {\n if (decoder.end() !== DECODER_OK) {\n decodeError = true;\n }\n imageBytes = decoder.data8;\n }\n this._activeDecoder = null;\n\n // Handle command first — handlers create Blob/ImageData from imageBytes,\n // which copies the data. Only then is it safe to release the decoder's\n // wasm memory that imageBytes points into.\n const result = this._handleCommandWithBytesAndCmd(finalCmd, imageBytes, decodeError);\n if (decoder) {\n decoder.release();\n }\n return result;\n }\n\n // Command handling\n\n private _parseControlDataString(): string {\n let str = '';\n for (let i = 0; i < this._controlLength; i++) {\n str += String.fromCodePoint(this._controlData[i]);\n }\n return str;\n }\n\n private _handleNoPayloadCommand(): boolean | Promise {\n const cmd = parseKittyCommand(this._parseControlDataString());\n\n // Per spec: specifying both i and I is an error\n if (cmd.id !== undefined && cmd.imageNumber !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:cannot specify both i and I keys', cmd.quiet ?? 0);\n return true;\n }\n\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.DELETE:\n return this._handleDelete(cmd);\n case KittyAction.QUERY:\n this._sendResponse(cmd.id ?? 0, 'OK', cmd.quiet ?? 0);\n return true;\n case KittyAction.PLACEMENT:\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handleCommandWithBytesAndCmd(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.TRANSMIT: {\n const result = this._handleTransmit(cmd, bytes, decodeError);\n // Only send response when _handleTransmit didn't already respond\n // (it handles unsupported transmission medium responses internally)\n if ((cmd.transmission ?? 'd') === 'd' && cmd.id !== undefined) {\n if (decodeError) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n } else if (bytes.length > 0) {\n this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0);\n }\n }\n return result;\n }\n case KittyAction.TRANSMIT_DISPLAY:\n return this._handleTransmitDisplay(cmd, bytes, decodeError);\n case KittyAction.QUERY:\n return this._handleQuery(cmd, bytes, decodeError);\n case KittyAction.PLACEMENT:\n // a=p ignores any payload — image data was already transmitted\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handlePlacement(cmd: IKittyCommand): boolean | Promise {\n if (cmd.id === undefined) {\n return true;\n }\n const id = cmd.id;\n const image = this._kittyStorage.getImage(id);\n if (!image) {\n this._sendResponse(id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);\n return true;\n }\n const result = this._displayImage(image, cmd);\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);\n return true;\n });\n }\n\n private _handleTransmit(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n // TODO: Support file-based transmission modes (t=f, t=t, t=s)\n // Currently only supports direct transmission (t=d, the default).\n // - t=f (file): Payload is base64-encoded file path. Terminal reads image from that path.\n // - t=t (temp file): Payload is base64-encoded path in temp directory. Terminal reads, deletes.\n // - t=s: Payload is base64-encoded POSIX shm name. Terminal reads from shared memory.\n // These modes require filesystem/IPC access not available in browsers. For Node.js/Electron:\n // 1. Check cmd.transmission (t key) before treating bytes as image data\n // 2. For t=f/t/s: decode bytes as UTF-8 string (the path/name), then read file contents\n // 3. For t=d: treat bytes as image data (current behavior)\n // When implementing, also update _handleQuery to accept these transmission mediums.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported transmission medium', cmd.quiet ?? 0);\n }\n return true;\n }\n\n if (decodeError || bytes.length === 0) return true;\n\n this._kittyStorage.storeImage(cmd.id, {\n data: new Blob([bytes as BlobPart]),\n width: cmd.width ?? 0,\n height: cmd.height ?? 0,\n format: (cmd.format ?? KittyFormat.RGBA) as 24 | 32 | 100,\n compression: cmd.compression ?? ''\n });\n return true;\n }\n\n private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n if (decodeError) {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n }\n return true;\n }\n\n this._handleTransmit(cmd, bytes, decodeError);\n\n const id = cmd.id ?? this._kittyStorage.lastImageId;\n const image = this._kittyStorage.getImage(id);\n if (image) {\n const result = this._displayImage(image, cmd);\n if (cmd.id !== undefined) {\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0);\n return true;\n });\n }\n return result.then(() => true);\n }\n return true;\n }\n\n private _handleQuery(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n const id = cmd.id ?? 0;\n const quiet = cmd.quiet ?? 0;\n\n // Per spec: reject unsupported transmission mediums (only t=d is supported atm)\n // TODO: When filesystem support is added (Node.js/Electron), update this to accept\n // t=f (file), t=t (temp file), and t=s (shared memory) and respond OK for queries.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n this._sendResponse(id, 'EINVAL:unsupported transmission medium', quiet);\n return true;\n }\n\n // Check decode error first (invalid base64)\n if (decodeError) {\n this._sendResponse(id, 'EINVAL:invalid base64 data', quiet);\n return true;\n }\n\n // Capability query (no payload) - just respond OK\n if (bytes.length === 0) {\n this._sendResponse(id, 'OK', quiet);\n return true;\n }\n\n const format = cmd.format ?? KittyFormat.RGBA;\n\n if (format === KittyFormat.PNG) {\n this._sendResponse(id, 'OK', quiet);\n } else {\n const width = cmd.width ?? 0;\n const height = cmd.height ?? 0;\n\n if (!width || !height) {\n this._sendResponse(id, 'EINVAL:width and height required for raw pixel data', quiet);\n return true;\n }\n\n const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n this._sendResponse(id, `EINVAL:insufficient pixel data`, quiet);\n return true;\n }\n\n this._sendResponse(id, 'OK', quiet);\n }\n return true;\n }\n\n private _handleDelete(cmd: IKittyCommand): boolean {\n // Per spec: default delete selector is 'a' (delete all visible placements)\n const selector = cmd.deleteSelector ?? 'a';\n\n // TODO: Distinguish lowercase (delete placements only) from uppercase\n // (delete placements + free stored image data). Currently both variants\n // free everything since we don't separate stored data from placements.\n switch (selector) {\n case 'a':\n case 'A':\n this._cleanupAllPending();\n this._kittyStorage.deleteAll();\n break;\n case 'i':\n case 'I':\n // TODO: When placement id tracking is implemented (see TODO in\n // KittyImageStorage), d=i with p= should delete only that\n // specific placement, while d=i without p should delete all\n // placements for the image.\n if (cmd.id !== undefined) {\n const pending = this._pendingTransmissions.get(cmd.id);\n if (pending) {\n pending.decoder.release();\n }\n this._removePendingEntry(cmd.id);\n this._kittyStorage.deleteById(cmd.id);\n }\n break;\n default:\n // Unsupported selectors (c, n, p, q, r, x, y, z, f) — ignore for now\n break;\n }\n return true;\n }\n\n private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void {\n const isOk = message === 'OK';\n if (isOk && quiet >= 1) return;\n if (!isOk && quiet >= 2) return;\n\n const pPart = placementId ? `,p=${placementId}` : '';\n const response = `\\x1b_Gi=${id}${pPart};${message}\\x1b\\\\`;\n this._coreTerminal._core.coreService.triggerDataEvent(response);\n }\n\n // Image display\n\n private _displayImage(image: IKittyImageData, cmd: IKittyCommand): Promise {\n return this._decodeAndDisplay(image, cmd)\n .then(() => true)\n .catch(() => false);\n }\n\n private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise {\n const generation = this._generation;\n let bitmap: ImageBitmap | undefined = await this._createBitmap(image);\n\n try {\n if (generation !== this._generation) throw new Error('image decode canceled');\n const cropX = Math.max(0, cmd.x ?? 0);\n const cropY = Math.max(0, cmd.y ?? 0);\n const cropW = cmd.sourceWidth || (bitmap.width - cropX);\n const cropH = cmd.sourceHeight || (bitmap.height - cropY);\n\n const maxCropW = Math.max(0, bitmap.width - cropX);\n const maxCropH = Math.max(0, bitmap.height - cropY);\n const finalCropW = Math.max(0, Math.min(cropW, maxCropW));\n const finalCropH = Math.max(0, Math.min(cropH, maxCropH));\n\n if (finalCropW === 0 || finalCropH === 0) {\n throw new Error('invalid source rectangle');\n }\n\n if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {\n const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);\n bitmap.close();\n bitmap = cropped;\n }\n\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n\n // Per spec: c/r default to image's natural cell dimensions.\n // If only one of c/r is specified, compute the other from image aspect ratio.\n let imgCols: number;\n let imgRows: number;\n if (cmd.columns !== undefined && cmd.rows !== undefined) {\n imgCols = cmd.columns;\n imgRows = cmd.rows;\n } else if (cmd.columns !== undefined) {\n imgCols = cmd.columns;\n imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));\n } else if (cmd.rows !== undefined) {\n imgRows = cmd.rows;\n imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));\n } else {\n imgCols = Math.ceil(bitmap.width / cw);\n imgRows = Math.ceil(bitmap.height / ch);\n }\n\n let w = bitmap.width;\n let h = bitmap.height;\n\n // Scale bitmap to fit placement rectangle when c/r are specified\n if (cmd.columns !== undefined || cmd.rows !== undefined) {\n w = Math.round(imgCols * cw);\n h = Math.round(imgRows * ch);\n }\n\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n\n // Save cursor position before addImage modifies it\n const buffer = this._coreTerminal._core.buffer;\n const savedX = buffer.x;\n const savedY = buffer.y;\n const savedYbase = buffer.ybase;\n\n // Determine layer based on z-index: negative = behind text, 0+ = on top.\n // When z<0 we always use the bottom layer even without allowTransparency —\n // the image will simply be hidden behind the opaque text background, which\n // is the correct behavior (client asked for \"behind text\").\n const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;\n const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';\n\n if (w !== bitmap.width || h !== bitmap.height) {\n const scaled = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });\n bitmap.close();\n bitmap = scaled;\n }\n\n // Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions\n const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1);\n const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1);\n if (xOffset !== 0 || yOffset !== 0) {\n // Per spec: X/Y is not added to c/r area. When c/r are explicit, the\n // total placement area remains c*cw × r*ch pixels and the offset image\n // is clipped to fit. When c/r are unset, the padded canvas determines\n // the natural cell dimensions.\n const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset;\n const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset;\n const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);\n const offsetCtx = offsetCanvas.getContext('2d');\n if (!offsetCtx) {\n throw new Error('Failed to create offset canvas context');\n }\n offsetCtx.drawImage(bitmap, xOffset, yOffset);\n\n const offsetBitmap = await createImageBitmap(offsetCanvas);\n offsetCanvas.width = offsetCanvas.height = 0;\n bitmap.close();\n bitmap = offsetBitmap;\n w = bitmap.width;\n h = bitmap.height;\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n if (cmd.columns === undefined) {\n imgCols = Math.ceil(bitmap.width / cw);\n }\n if (cmd.rows === undefined) {\n imgRows = Math.ceil(bitmap.height / ch);\n }\n }\n\n if (generation !== this._generation) throw new Error('image decode canceled');\n const zIndex = cmd.zIndex ?? 0;\n this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);\n bitmap = undefined; // ownership transferred to storage\n\n // Kitty cursor movement\n // Per spec: cursor placed at first column after last image column,\n // on the last row of the image. C=1 means don't move cursor.\n if (cmd.cursorMovement === 1) {\n // C=1: restore cursor to position before image was placed\n const scrolled = buffer.ybase - savedYbase;\n buffer.x = savedX;\n // Can't restore cursor to scrollback?\n buffer.y = Math.max(savedY - scrolled, 0);\n } else {\n // Default (C=0): advance cursor horizontally past the image\n // addImage already positioned cursor on the last row via lineFeeds\n buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);\n }\n } catch (e) {\n bitmap?.close();\n throw e;\n }\n }\n\n // Create ImageBitmap from already-decoded image data.\n private async _createBitmap(image: IKittyImageData): Promise {\n let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());\n\n if (image.compression === KittyCompression.ZLIB) {\n bytes = await this._decompressZlib(bytes);\n }\n\n if (image.format === KittyFormat.PNG) {\n const metrics = imageType(bytes);\n // IHDR dimensions are parsed with signed shifts, so a value >= 0x80000000 comes\n // back negative and a bare `>` pixel-limit test passes it; require positive.\n if (metrics.mime !== 'image/png' || !(metrics.width > 0) || !(metrics.height > 0) || metrics.width * metrics.height > this._opts.pixelLimit) {\n throw new RangeError('PNG exceeds pixel limit or has invalid dimensions');\n }\n const blob = new Blob([bytes as BlobPart], { type: 'image/png' });\n if (!window.createImageBitmap) {\n const url = URL.createObjectURL(blob);\n const img = new Image();\n return new Promise((resolve, reject) => {\n img.addEventListener('load', () => {\n URL.revokeObjectURL(url);\n const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);\n canvas.getContext('2d')?.drawImage(img, 0, 0);\n createImageBitmap(canvas).then(resolve).catch(reject);\n });\n img.addEventListener('error', () => {\n URL.revokeObjectURL(url);\n reject(new Error('Failed to load image'));\n });\n img.src = url;\n });\n }\n return createImageBitmap(blob);\n }\n\n // Raw pixel data\n const width = image.width;\n const height = image.height;\n\n if (!width || !height) {\n throw new Error('Width and height required for raw pixel data');\n }\n\n const bytesPerPixel = image.format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n throw new Error('Insufficient pixel data');\n }\n\n const pixelCount = width * height;\n\n if (image.format === KittyFormat.RGBA) {\n // RGBA: use bytes directly — no copy needed\n return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA), width, height));\n }\n\n // RGB→RGBA: interleave alpha using uint32 block processing (4 pixels per iteration).\n // 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes — ~6x faster.\n // Assumes little-endian (all modern browsers/Node.js).\n const data = new Uint8ClampedArray(pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA);\n const src32 = new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));\n const dst32 = new Uint32Array(data.buffer);\n const alignedPixels = pixelCount & ~3; // round down to multiple of 4\n\n let srcOffset = 0;\n let dstOffset = 0;\n for (let i = 0; i < alignedPixels; i += 4) {\n const b0 = src32[srcOffset++];\n const b1 = src32[srcOffset++];\n const b2 = src32[srcOffset++];\n // Little-endian: pixel bytes are [R,G,B] → uint32 ABGR layout\n dst32[dstOffset++] = 0xFF000000 | b0;\n dst32[dstOffset++] = 0xFF000000 | (b0 >>> 24) | (b1 << 8);\n dst32[dstOffset++] = 0xFF000000 | (b1 >>> 16) | (b2 << 16);\n dst32[dstOffset++] = 0xFF000000 | (b2 >>> 8);\n }\n\n // Handle remaining 1–3 pixels\n let srcByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n let dstByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n for (let i = alignedPixels; i < pixelCount; i++) {\n data[dstByte] = bytes[srcByte];\n data[dstByte + 1] = bytes[srcByte + 1];\n data[dstByte + 2] = bytes[srcByte + 2];\n data[dstByte + 3] = KittyPixelConstants.ALPHA_OPAQUE;\n srcByte += KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n dstByte += KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n }\n\n return createImageBitmap(new ImageData(data, width, height));\n }\n\n private async _decompressZlib(compressed: Uint8Array): Promise {\n try {\n return await this._decompress(compressed, 'deflate');\n } catch (error) {\n if (error instanceof RangeError) throw error;\n return await this._decompress(compressed, 'deflate-raw');\n }\n }\n\n private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise {\n const limit = Math.min(this._opts.kittySizeLimit, this._opts.pixelLimit * 4, this._opts.storageLimit * 1000000);\n let offsetIn = 0;\n // Bound inflation within one native transform before its output is budgeted.\n const source = new ReadableStream({\n pull(controller) {\n if (offsetIn >= compressed.length) {\n controller.close();\n return;\n }\n const end = Math.min(offsetIn + 4096, compressed.length);\n controller.enqueue(new Uint8Array(compressed.subarray(offsetIn, end)));\n offsetIn = end;\n }\n });\n const reader = source.pipeThrough(new DecompressionStream(format)).getReader();\n const chunks: Uint8Array[] = [];\n let totalLength = 0;\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n totalLength += value.byteLength;\n if (totalLength > limit) {\n await reader.cancel().catch(() => {});\n throw new RangeError('decompressed image exceeds byte limit');\n }\n chunks.push(value);\n }\n } finally {\n reader.releaseLock();\n }\n\n const result = new Uint8Array(totalLength);\n let offset = 0;\n for (const chunk of chunks) {\n result.set(chunk, offset);\n offset += chunk.length;\n }\n return result;\n }\n\n public get images(): ReadonlyMap {\n return this._kittyStorage.images;\n }\n\n public get _kittyIdToStorageId(): ReadonlyMap {\n return this._kittyStorage.kittyIdToStorageId;\n }\n\n public get pendingTransmissions(): ReadonlyMap {\n return this._pendingTransmissions;\n }\n}\n","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Kitty graphics protocol types, constants, and parsing utilities.\n */\n\nimport type Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\n\n// Kitty graphics protocol action types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'a'.\nexport const enum KittyAction {\n TRANSMIT = 't',\n TRANSMIT_DISPLAY = 'T',\n QUERY = 'q',\n PLACEMENT = 'p',\n DELETE = 'd'\n}\n\n// Kitty graphics protocol format types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyFormat {\n RGB = 24,\n RGBA = 32,\n PNG = 100\n}\n\n// Kitty graphics protocol compression types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'o'.\nexport const enum KittyCompression {\n NONE = '',\n ZLIB = 'z'\n}\n\n// Kitty graphics protocol control data keys.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyKey {\n // Action to perform (t=transmit, T=transmit+display, q=query, p=placement, d=delete)\n ACTION = 'a',\n // Image format (24=RGB, 32=RGBA, 100=PNG)\n FORMAT = 'f',\n // Image ID for referencing stored images\n ID = 'i',\n // Image number (alternative to ID, terminal assigns ID)\n IMAGE_NUMBER = 'I',\n // Source image width in pixels\n WIDTH = 's',\n // Source image height in pixels\n HEIGHT = 'v',\n // The left edge (in pixels) of the image area to display\n X_OFFSET = 'x',\n // The top edge (in pixels) of the image area to display\n Y_OFFSET = 'y',\n // Width (in pixels) of the source rectangle to display\n SOURCE_WIDTH = 'w',\n // Height (in pixels) of the source rectangle to display\n SOURCE_HEIGHT = 'h',\n // Horizontal offset (in pixels) within the first cell\n X_PLACEMENT_OFFSET = 'X',\n // Vertical offset (in pixels) within the first cell\n Y_PLACEMENT_OFFSET = 'Y',\n // Number of terminal columns to display the image over\n COLUMNS = 'c',\n // Number of terminal rows to display the image over\n ROWS = 'r',\n // More data flag (1=more chunks coming, 0=final chunk)\n MORE = 'm',\n // Compression type (z=zlib). This is essential for chunking larger images.\n COMPRESSION = 'o',\n // Quiet mode (1=suppress OK responses, 2=suppress error responses)\n QUIET = 'q',\n // Cursor movement policy (0=move cursor after image, 1=don't move cursor)\n CURSOR_MOVEMENT = 'C',\n // Z-index for image layering (negative = behind text, 0+ = on top)\n Z_INDEX = 'z',\n // Transmission medium (d=direct, f=file, t=temp file, s=shared memory)\n TRANSMISSION = 't',\n // Delete selector (a/A=all, i/I=by id, c/C=at cursor, etc.) — only used when a=d\n DELETE_SELECTOR = 'd',\n // Placement ID for targeting specific placements\n PLACEMENT_ID = 'p'\n}\n\n// Pixel format constants\nexport const enum KittyPixelConstants {\n BYTES_PER_PIXEL_RGB = 3,\n BYTES_PER_PIXEL_RGBA = 4,\n ALPHA_OPAQUE = 255\n}\n\n// Parsed Kitty graphics command.\nexport interface IKittyCommand {\n action?: string;\n format?: number;\n id?: number;\n imageNumber?: number;\n width?: number;\n height?: number;\n x?: number;\n y?: number;\n sourceWidth?: number;\n sourceHeight?: number;\n xOffset?: number;\n yOffset?: number;\n columns?: number;\n rows?: number;\n more?: number;\n quiet?: number;\n cursorMovement?: number;\n zIndex?: number;\n transmission?: string;\n deleteSelector?: string;\n placementId?: number;\n compression?: string;\n payload?: string;\n}\n\n// Pending chunked transmission state.\n// Stores metadata from the first chunk while accumulating decoded payload data.\nexport interface IPendingTransmission {\n // The parsed command from the first chunk (contains action, format, dimensions, etc.)\n cmd: IKittyCommand;\n // Decoder used across chunked payloads\n decoder: Base64Decoder;\n // Total encoded (base64) bytes received across all chunks - for size limit enforcement\n totalEncodedSize: number;\n // Whether any chunk has failed to decode\n decodeError: boolean;\n}\n\n// Stored Kitty image data.\nexport interface IKittyImageData {\n id: number;\n // Decoded image data stored as Blob (off JS heap) to avoid 2GB heap limit\n data: Blob;\n width: number;\n height: number;\n format: 24 | 32 | 100;\n compression?: string;\n}\n\n// Parses Kitty graphics control data into a command object.\nexport function parseKittyCommand(data: string): IKittyCommand {\n const cmd: IKittyCommand = {};\n const parts = data.split(',');\n\n for (const part of parts) {\n const eqIdx = part.indexOf('=');\n if (eqIdx === -1) continue;\n\n const key = part.substring(0, eqIdx);\n const value = part.substring(eqIdx + 1);\n\n // Handle string keys first\n if (key === KittyKey.ACTION) {\n cmd.action = value;\n continue;\n }\n if (key === KittyKey.COMPRESSION) {\n cmd.compression = value;\n continue;\n }\n if (key === KittyKey.TRANSMISSION) {\n cmd.transmission = value;\n continue;\n }\n if (key === KittyKey.DELETE_SELECTOR) {\n cmd.deleteSelector = value;\n continue;\n }\n const numValue = parseInt(value, 10);\n switch (key) {\n case KittyKey.FORMAT: cmd.format = numValue; break;\n case KittyKey.ID: cmd.id = numValue; break;\n case KittyKey.IMAGE_NUMBER: cmd.imageNumber = numValue; break;\n case KittyKey.WIDTH: cmd.width = numValue; break;\n case KittyKey.HEIGHT: cmd.height = numValue; break;\n case KittyKey.X_OFFSET: cmd.x = numValue; break;\n case KittyKey.Y_OFFSET: cmd.y = numValue; break;\n case KittyKey.SOURCE_WIDTH: cmd.sourceWidth = numValue; break;\n case KittyKey.SOURCE_HEIGHT: cmd.sourceHeight = numValue; break;\n case KittyKey.X_PLACEMENT_OFFSET: cmd.xOffset = numValue; break;\n case KittyKey.Y_PLACEMENT_OFFSET: cmd.yOffset = numValue; break;\n case KittyKey.COLUMNS: cmd.columns = numValue; break;\n case KittyKey.ROWS: cmd.rows = numValue; break;\n case KittyKey.MORE: cmd.more = numValue; break;\n case KittyKey.QUIET: cmd.quiet = numValue; break;\n case KittyKey.CURSOR_MOVEMENT: cmd.cursorMovement = numValue; break;\n case KittyKey.Z_INDEX: cmd.zIndex = numValue; break;\n case KittyKey.PLACEMENT_ID: cmd.placementId = numValue; break;\n }\n }\n\n return cmd;\n}\n","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageStorage } from '../ImageStorage';\nimport { ImageLayer, IAddImageOpts } from '../Types';\nimport { IKittyImageData } from './KittyGraphicsTypes';\n\n// Kitty-specific image storage controller.\n//\n// Wraps shared ImageStorage with kitty protocol semantics:\n// - tracks transmitted image payloads by kitty image id\n// - tracks kitty image id -> shared ImageStorage id mapping for displayed images\n// - mirrors shared-storage evictions into kitty maps\n// - applies protocol-level undisplayed-image eviction policy\nexport class KittyImageStorage implements IDisposable {\n private static readonly _maxStoredImages = 256;\n\n private _nextImageId = 1;\n private readonly _images: Map = new Map();\n // TODO: Support multiple placements per image. The kitty spec identifies\n // placements by an (image id, placement id) pair — same i + different p\n // values should coexist, and same i + same p should replace the prior\n // placement. Currently we track only one storage entry per kitty image id,\n // so multiple placements of the same image overwrite each other. Fixing\n // this requires changing these maps to Map>\n // (kittyId → placementId → storageId) and updating addImage/deleteById\n // accordingly. The underlying shared ImageStorage would also need to\n // support multiple entries per logical image.\n private readonly _kittyIdToStorageId: Map = new Map();\n private readonly _storageIdToKittyId: Map = new Map();\n\n private readonly _previousOnImageDeleted: ((storageId: number) => void) | undefined;\n private readonly _wrappedOnImageDeleted: (storageId: number) => void;\n private readonly _handleStorageImageDeleted = (storageId: number): void => {\n const kittyId = this._storageIdToKittyId.get(storageId);\n if (kittyId !== undefined) {\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n this._images.delete(kittyId);\n }\n };\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n\n constructor(\n private readonly _storage: ImageStorage\n ) {\n this._previousOnImageDeleted = this._storage.onImageDeleted;\n this._wrappedOnImageDeleted = (storageId: number) => {\n this._previousOnImageDeleted?.(storageId);\n this._handleStorageImageDeleted(storageId);\n };\n this._storage.onImageDeleted = this._wrappedOnImageDeleted;\n }\n\n public reset(): void {\n this._nextImageId = 1;\n this._images.clear();\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public dispose(): void {\n this.reset();\n if (this._storage.onImageDeleted === this._wrappedOnImageDeleted) {\n this._storage.onImageDeleted = this._previousOnImageDeleted;\n }\n }\n\n public storeImage(id: number | undefined, imageData: Omit): number {\n const imageId = id ?? this._nextImageId++;\n\n const oldStorageId = this._kittyIdToStorageId.get(imageId);\n if (oldStorageId !== undefined) {\n this._storage.deleteImage(oldStorageId);\n this._kittyIdToStorageId.delete(imageId);\n this._storageIdToKittyId.delete(oldStorageId);\n }\n\n if (!this._images.has(imageId) && this._images.size >= KittyImageStorage._maxStoredImages) {\n this._evictUndisplayedImages();\n }\n\n // Encoded images awaiting placement are outside ImageStorage's pixel budget.\n // Unplaced payloads are evicted first so a new upload cannot erase a visible\n // image while abandoned blobs still hold budget; placed ones go only when\n // that is not enough, because the byte cap is a hard bound. The new image is\n // always stored, so an oversized one overshoots by at most one payload\n // (itself bounded by kittySizeLimit) rather than being dropped after an OK ack.\n const byteLimit = this._storage.getLimit() * 1000000;\n this._images.delete(imageId);\n let retainedBytes = 0;\n for (const image of this._images.values()) retainedBytes += image.data.size;\n for (const evictPlaced of [false, true]) {\n for (const [oldestId, image] of this._images) {\n if (retainedBytes + imageData.data.size <= byteLimit) break;\n if (this._kittyIdToStorageId.has(oldestId) !== evictPlaced) continue;\n retainedBytes -= image.data.size;\n this.deleteById(oldestId);\n }\n }\n\n this._images.set(imageId, {\n ...imageData,\n id: imageId\n });\n return imageId;\n }\n\n public addImage(kittyId: number, image: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer, zIndex: number): void {\n // Clean up stale reverse-mapping from a previous placement of the same\n // kitty image. The old shared-storage entry is kept (it may still be\n // visible on screen) but its reverse mapping is removed so that eviction\n // of the old entry won't incorrectly delete the kitty image data.\n const oldStorageId = this._kittyIdToStorageId.get(kittyId);\n if (oldStorageId !== undefined) {\n this._storageIdToKittyId.delete(oldStorageId);\n }\n this._addImageOpts.scrolling = scrolling;\n this._addImageOpts.layer = layer;\n this._addImageOpts.zIndex = zIndex;\n const storageId = this._storage.addImage(image, this._addImageOpts);\n this._kittyIdToStorageId.set(kittyId, storageId);\n this._storageIdToKittyId.set(storageId, kittyId);\n }\n\n public getImage(kittyId: number): IKittyImageData | undefined {\n return this._images.get(kittyId);\n }\n\n public deleteById(kittyId: number): void {\n this._images.delete(kittyId);\n const storageId = this._kittyIdToStorageId.get(kittyId);\n if (storageId !== undefined) {\n this._storage.deleteImage(storageId);\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n }\n }\n\n public deleteAll(): void {\n this._images.clear();\n for (const storageId of this._kittyIdToStorageId.values()) {\n this._storage.deleteImage(storageId);\n }\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public get images(): ReadonlyMap {\n return this._images;\n }\n\n public get kittyIdToStorageId(): ReadonlyMap {\n return this._kittyIdToStorageId;\n }\n\n public get lastImageId(): number {\n return this._nextImageId - 1;\n }\n\n private _evictUndisplayedImages(): void {\n for (const [kittyId] of this._images) {\n if (this._images.size <= KittyImageStorage._maxStoredImages / 2) {\n break;\n }\n if (!this._kittyIdToStorageId.has(kittyId)) {\n this._images.delete(kittyId);\n }\n }\n }\n}\n","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023, 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\n/**\n * wasm base64 decoder.\n */\nconst wasmDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#828e69684093b2c6:rdef-start:\"decode\"*/{s:1,t:0,d:'AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML'}/*inwasm#828e69684093b2c6:rdef-end:\"decode\"*/);\n// SIMD version (speedup ~1.4x, not covered by tests yet)\n/*\nconst wasmDecode = InWasm({\n name: 'decode',\n type: OutputType.INSTANCE,\n mode: OutputMode.SYNC,\n srctype: 'Clang-C',\n imports: {\n env: { memory: new WebAssembly.Memory({ initial: 1 }) }\n },\n exports: {\n dec: () => 0,\n end: () => 0\n },\n compile: {\n switches: ['-msimd128', '-Wl,-z,stack-size=0', '-Wl,--stack-first']\n },\n code: `\n #include \n typedef struct {\n unsigned int wp;\n unsigned int sp;\n unsigned int dp;\n unsigned int e_size;\n unsigned int dummy[4];\n unsigned char data[0];\n } State;\n\n unsigned int *D0 = (unsigned int *) ${P32.D0 * 4};\n unsigned int *D1 = (unsigned int *) ${P32.D1 * 4};\n unsigned int *D2 = (unsigned int *) ${P32.D2 * 4};\n unsigned int *D3 = (unsigned int *) ${P32.D3 * 4};\n State *state = (State *) ${P32.STATE * 4};\n\n #define packed_byte(x) wasm_i8x16_splat((char) x)\n #define packed_dword(x) wasm_i32x4_splat(x)\n #define masked(x, mask) wasm_v128_and(x, wasm_i32x4_splat(mask))\n\n __attribute__((noinline)) int dec() {\n unsigned int nsp = (state->wp - 1) & ~3;\n unsigned char *src = state->data + state->sp;\n unsigned char *end = state->data + nsp;\n unsigned char *dst = state->data + state->dp;\n unsigned int error = 0;\n\n v128_t err = wasm_i8x16_splat(0);\n unsigned char *end16 = state->data + (nsp & ~15);\n while (src < end16) {\n v128_t data = wasm_v128_load((v128_t *) src);\n\n // wasm-simd rewrite of http://0x80.pl/notesen/2016-01-17-sse-base64-decoding.html#vector-lookup-pshufb\n const v128_t higher_nibble = wasm_u32x4_shr(data, 4) & packed_byte(0x0f);\n const char linv = 1;\n const char hinv = 0;\n\n const v128_t lower_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n linv, linv, 0x2b, 0x30,\n 0x41, 0x50, 0x61, 0x70,\n linv, linv, linv, linv,\n linv, linv, linv, linv\n );\n const v128_t upper_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n hinv, hinv, 0x2b, 0x39,\n 0x4f, 0x5a, 0x6f, 0x7a,\n hinv, hinv, hinv, hinv,\n hinv, hinv, hinv, hinv\n );\n // the difference between the shift and lower bound\n const v128_t shift_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n 0x00, 0x00, 0x3e - 0x2b, 0x34 - 0x30,\n 0x00 - 0x41, 0x0f - 0x50, 0x1a - 0x61, 0x29 - 0x70,\n 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00\n );\n\n const v128_t upper_bound = wasm_i8x16_swizzle(upper_bound_LUT, higher_nibble);\n const v128_t lower_bound = wasm_i8x16_swizzle(lower_bound_LUT, higher_nibble);\n\n const v128_t below = wasm_i8x16_lt(data, lower_bound);\n const v128_t above = wasm_i8x16_gt(data, upper_bound);\n const v128_t eq_2f = wasm_i8x16_eq(data, packed_byte(0x2f));\n\n // in_range = not (below or above) or eq_2f\n // outside = not in_range = below or above and not eq_2f (from deMorgan law)\n const v128_t outside = wasm_v128_andnot(eq_2f, above | below);\n err = wasm_v128_or(err, outside);\n\n const v128_t shift = wasm_i8x16_swizzle(shift_LUT, higher_nibble);\n const v128_t t0 = wasm_i8x16_add(data, shift);\n v128_t v = wasm_i8x16_add(t0, wasm_v128_and(eq_2f, packed_byte(-3)));\n\n // pack bytes\n const v128_t ca = masked(v, 0x003f003f);\n const v128_t db = masked(v, 0x3f003f00);\n const v128_t t00 = wasm_v128_or(wasm_u32x4_shr(db, 8), wasm_i32x4_shl(ca, 6));\n v128_t res = wasm_v128_or(wasm_u32x4_shr(t00, 16), wasm_i32x4_shl(t00, 12));\n res = wasm_i8x16_swizzle(res, wasm_i8x16_const(2, 1, 0, 6, 5, 4, 10, 9, 8, 14, 13, 12, 16, 16, 16, 16));\n\n wasm_v128_store((v128_t *) dst, res);\n dst += 12;\n src += 16;\n }\n //if (wasm_i8x16_bitmask(err) != 0) return -1;\n if (wasm_v128_any_true(err)) return -1;\n\n // operate on 4-byte blocks\n while (src < end) {\n error |= *((unsigned int *) dst) = D0[src[0]] | D1[src[1]] | D2[src[2]] | D3[src[3]];\n dst += 3;\n src += 4;\n }\n if (error >> 24) return -1;\n state->sp = nsp;\n state->dp = dst - state->data;\n return 0;\n }\n\n int end() {\n int rem = state->wp - state->sp;\n if (rem > 4 && dec()) return -1;\n rem = state->wp - state->sp;\n if (rem < 2) return -1;\n\n unsigned char *src = state->data + state->sp;\n if (rem == 4) {\n if (src[3] == 61) rem--;\n if (src[2] == 61) rem--;\n }\n unsigned int accu = D0[src[0]] | D1[src[1]];\n int dp = 1;\n if (rem > 2) {\n accu |= D2[src[2]];\n dp++;\n if (rem == 4) {\n accu |= D3[src[3]];\n dp++;\n }\n }\n if (accu >> 24) return -1;\n *((unsigned int *) (state->data + state->dp)) = accu;\n state->dp += dp;\n return 0;\n }\n `\n});\n*/\n// base64 map\nconst MAP = new Uint8Array('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'\n .split('')\n .map(el => el.charCodeAt(0)));\n// init decoder maps in LE order\nconst D = new Uint32Array(1024);\nD.fill(0xFF000000);\nfor (let i = 0; i < MAP.length; ++i)\n D[MAP[i]] = i << 2;\nfor (let i = 0; i < MAP.length; ++i)\n D[256 + MAP[i]] = i >> 4 | ((i << 4) & 0xFF) << 8;\nfor (let i = 0; i < MAP.length; ++i)\n D[512 + MAP[i]] = (i >> 2) << 8 | ((i << 6) & 0xFF) << 16;\nfor (let i = 0; i < MAP.length; ++i)\n D[768 + MAP[i]] = i << 16;\nconst EMPTY = new Uint8Array(0);\n/**\n * base64 stream decoder.\n *\n * Features / assumptions:\n * - lazy chunkwise decoding\n * - errors out on any non base64 chars (no support for NL formatted base64)\n * - decodes in wasm\n * - inplace decoding to save memory\n * - supports a keepSize for lazy memory release\n */\nclass Base64Decoder {\n /**\n * @param keepSize Keep the wasm instance below this limit when calling `release()`.\n * @param maxBytes Max allowed bytes to allocate.\n * @param initialBytes Initial bytes to allocate.\n */\n constructor(keepSize, maxBytes, initialBytes) {\n this._inst = null;\n this._ended = true;\n this._bytes = 0;\n this.keepSize = keepSize !== null && keepSize !== void 0 ? keepSize : 1048576 /* Bytes.KEEP */;\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : 4294901760 /* Bytes.MAX */;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : 32768 /* Bytes.INITIAL */;\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw new Error('invalid byte settings');\n }\n }\n /**\n * Currently decoded bytes (borrowed).\n * Must be accessed before calling `release` or `init`.\n */\n get data8() {\n return this._inst ? this._d.subarray(0, this._m32[1282 /* P32.STATE_DP */]) : EMPTY;\n }\n /**\n * Release memory conditionally based on `keepSize`.\n * If memory gets released, also the wasm instance will be freed and recreated on next `init`,\n * otherwise the instance will be reused.\n */\n release() {\n if (!this._inst)\n return;\n if (this._bytes > this.keepSize) {\n this._inst = this._m32 = this._d = this._mem = null;\n }\n else {\n this._m32[1280 /* P32.STATE_WP */] = 0;\n this._m32[1281 /* P32.STATE_SP */] = 0;\n this._m32[1282 /* P32.STATE_DP */] = 0;\n }\n }\n /**\n * Initializes the decoder for new base64 data.\n * Must be called before doing any decoding attempts.\n * The method will either spawn a new wasm instance or grow\n * the needed memory of an existing instance.\n * @param maxBytes Max allowed bytes to allocate (overwrites ctor value).\n * @param initialBytes Initial bytes to allocate (overwrites ctor value).\n */\n init(maxBytes, initialBytes) {\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : this.maxBytes;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : Math.min(this._bytes, this.maxBytes);\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw Error('invalid byte settings');\n }\n let m = this._m32;\n const bytes = this._bytes + 5152 /* Bytes._DATA_OFFSET */;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmDecode({ env: { memory: this._mem } });\n m = new Uint32Array(this._mem.buffer, 0);\n m.set(D, 256 /* P32.D0 */);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n m = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n m[1280 /* P32.STATE_WP */] = 0;\n m[1281 /* P32.STATE_SP */] = 0;\n m[1282 /* P32.STATE_DP */] = 0;\n this._m32 = m;\n this._ended = false;\n }\n /**\n * Realloc memory. Realloc only happens, if the requested\n * size doesn't fit in the current memory.\n * The new size will be capped by `maxBytes`.\n * @param requested Bytes to be stored.\n */\n _realloc(requested) {\n const needed = this._m32[1280 /* P32.STATE_WP */] + requested;\n if (this._bytes < needed) {\n if (needed > this.maxBytes) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n let newSize = this._bytes;\n while ((newSize *= 2) < needed) { }\n newSize = Math.min(newSize, this.maxBytes);\n if (newSize < needed) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n if (newSize + 5152 /* Bytes._DATA_OFFSET */ > this._mem.buffer.byteLength) {\n const addPages = Math.ceil((newSize + 5152 /* Bytes._DATA_OFFSET */ - this._mem.buffer.byteLength) / 65536);\n this._mem.grow(addPages);\n this._m32 = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n this._bytes = newSize;\n }\n return 0 /* DecodeStatus.OK */;\n }\n /**\n * Put bytes in `data` into the decoder.\n * Additionally decodes the payload, if it reached 2^17 bytes.\n * The return value indicates the type of issue.\n * @param data Bytes to be loaded.\n */\n put(data) {\n if (!this._inst || this._ended) {\n return -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n if (this._realloc(data.length)) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n const m = this._m32;\n this._d.set(data, m[1280 /* P32.STATE_WP */]);\n m[1280 /* P32.STATE_WP */] += data.length;\n // max chunk in input handler is 2^17, try to run in \"tandem mode\"\n return m[1280 /* P32.STATE_WP */] - m[1281 /* P32.STATE_SP */] >= 131072\n ? this._inst.exports.dec()\n : 0 /* DecodeStatus.OK */;\n }\n /**\n * End the current decoding.\n * Also decodes leftover payload from previous put calls.\n */\n end() {\n this._ended = true;\n return this._inst\n ? this._inst.exports.end()\n : -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n /**\n * Bytes loaded into the decoder.\n */\n get loadedBytes() {\n return this._inst\n ? this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n /**\n * Free bytes to feed to the decoder.\n */\n get freeBytes() {\n return this._inst\n ? this.maxBytes - this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n}\nexports.default = Base64Decoder;\n//# sourceMappingURL=Base64Decoder.wasm.js.map","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\nconst wasmQoiDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#459f9e1bfb80b1a8:rdef-start:\"qoi_decode\"*/{s:1,t:0,d:'AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=='}/*inwasm#459f9e1bfb80b1a8:rdef-end:\"qoi_decode\"*/);\nclass QoiDecoder {\n constructor(keepSize) {\n this.keepSize = keepSize;\n this.width = 0;\n this.height = 0;\n }\n decode(d) {\n this.width = d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7];\n this.height = d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11];\n const pixels = this.width * this.height;\n const ib = pixels * 4;\n const dl = d.length;\n /**\n * byte/offset calculation:\n * To save some memory we dont reserve full memory for decoded + encoded,\n * but place encoded at the end of decoded plus 50% security distance\n * to avoid reads before writes positions:\n *\n * encoded < decoded (good compression)\n * enc ####################\n * dec #######################################\n * ^ ^\n * DST_P CHUNK_P\n *\n * encoded > decoded (degenerated compression, should not happen)\n * enc ##############################\n * dec ####################\n * ^ ^\n * DST_P CHUNK_P\n *\n * There is still a chance for overlapping r/w positions in case the compressed\n * data has very different pixel progression, yet the 50% security distance\n * should deal with that, as QOI will bloat data by 25% at max (RGB -> OP byte + RGB).\n * Since we always assume RGBA at decoding stage, the possible bloat reduces to 20% at max.\n */\n const bytes = Math.max(ib, dl) + (Math.min(ib, dl) >> 1) + 4096;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmQoiDecode({ env: { memory: this._mem } });\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n this._d = null;\n }\n if (!this._d) {\n this._d = new Uint8Array(this._mem.buffer);\n }\n // put src data at the end of memory, also align to 256\n const chunkP = (this._mem.buffer.byteLength - dl) & ~0xFF;\n this._d.set(d, chunkP);\n this._inst.exports.dec(chunkP, dl, pixels);\n return this._d.subarray(1024 /* P8.DST_P */, 1024 /* P8.DST_P */ + ib);\n }\n release() {\n if (!this._inst)\n return;\n if (this._mem.buffer.byteLength > this.keepSize) {\n this._inst = this._d = this._mem = null;\n }\n }\n}\nexports.default = QoiDecoder;\n//# sourceMappingURL=QoiDecoder.wasm.js.map","\"use strict\";\n/**\n * Copyright (c) 2022, 2026 Joerg Breitbart\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.InWasm = InWasm;\nlet z = (s) => {\n if (Uint8Array.fromBase64)\n return Uint8Array.fromBase64(s);\n if (typeof Buffer !== 'undefined')\n return Buffer.from(s, 'base64');\n const b = atob(s);\n const r = new Uint8Array(b.length);\n for (let i = 0; i < r.length; ++i)\n r[i] = b.charCodeAt(i);\n return r;\n};\nfunction InWasm(def) {\n if (def.d) {\n const { t, s, d } = def;\n let b;\n let m;\n const W = WebAssembly;\n if (t === 0 /* OutputType.INSTANCE */) {\n if (s)\n return (e) => new W.Instance(m || (m = new W.Module(b || (b = z(d)))), e);\n return (e) => m\n ? W.instantiate(m, e)\n : W.instantiate(b || (b = z(d)), e).then(r => (m = r.module) && r.instance);\n }\n if (t === 1 /* OutputType.MODULE */) {\n if (s)\n return () => m || (m = new W.Module(b || (b = z(d))));\n return () => m\n ? Promise.resolve(m)\n : W.compile(b || (b = z(d))).then(r => m = r);\n }\n if (s)\n return () => b || (b = z(d));\n return () => Promise.resolve(b || (b = z(d)));\n }\n if (typeof _wasmCtx === 'undefined')\n throw new Error('must run \"inwasm\"');\n _wasmCtx.add(def);\n}\n//# sourceMappingURL=index.js.map","// The module cache\nvar __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\t// Check if module is in cache\n\tvar cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tvar module = __webpack_module_cache__[moduleId] = {\n\t\t// no module.id needed\n\t\t// no module.loaded needed\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ITerminalAddon, IDisposable } from '@xterm/xterm';\nimport type { ImageAddon as IImageApi } from '@xterm/addon-image';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { IIPHandler } from './IIPHandler';\nimport { ImageRenderer } from './ImageRenderer';\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';\nimport { KittyImageStorage } from './kitty/KittyImageStorage';\nimport { SixelHandler } from './SixelHandler';\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';\n\n\n/**\n * Document VT features provided by this addon.\n *\n * @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL \"SIXEL Graphics\" \"DCS Ps ; Ps ; Ps ; q Pt ST\" \"Draw SIXEL image.\"\n *\n * Sixel support is provided by the addon @xterm/addon-image with these limitations:\n * - immediate coloring (no shared palette, allows high color settings of `img2sixel`)\n * - max. palette size of 4096 colors\n * - max. pixel width of 16K\n * - max. 25 MB per sixel sequence\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] OSC 1337 \"iTerm2 Commands\" \"OSC 1337 ; Pt BEL\" \"Custom iTerm2 commands.\"\n *\n * Only the inline image protocol (IIP) is supported by the addon @xterm/addon-image with\n * the following limitations:\n * - sequence:\n * - format: `OSC 1337 ; File=inline=1 ; size= ; ... : BEL`\n * - size param must be set and payload may not exceed CEIL(size * 4 / 3)\n * - strict base64 handling as of RFC4648 §4 (standard alphabet, optional padding,\n * no separator bytes allowed)\n * - supported params: size, name, width, height, preserveAspectRatio\n * - image formats: PNG, JPEG and GIF\n * - no animation support (renders first image of a GIF)\n * - no multipart support\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image)\n * and [iTerm2 IIP docs](https://iterm2.com/documentation-images.html) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] APC KITTY_GRAPHICS \"Kitty Graphics\" \"APC G Pt ST\" \"Kitty Graphics Protocol.\"\n *\n * Kitty graphics support is provided by the addon @xterm/addon-image.\n * Note that while basic image output already works, this is still work in progress.\n */\n\n// default values of addon ctor options\nconst DEFAULT_OPTIONS: IImageAddonOptions = {\n enableSizeReports: true,\n pixelLimit: 16777216, // limit to 4096 * 4096 pixels\n sixelSupport: true,\n sixelScrolling: true,\n sixelPaletteLimit: 4096,\n sixelSizeLimit: 33554432,\n storageLimit: 128,\n showPlaceholder: true,\n iipSupport: true,\n iipSizeLimit: 33554432,\n kittySupport: true,\n kittySizeLimit: 33554432\n};\n\n// max palette size supported by the sixel lib (compile time setting)\nconst MAX_SIXEL_PALETTE_SIZE = 4096;\n\n// definitions for _xtermGraphicsAttributes sequence\nconst enum GaItem {\n COLORS = 1,\n SIXEL_GEO = 2,\n REGIS_GEO = 3\n}\nconst enum GaAction {\n READ = 1,\n SET_DEFAULT = 2,\n SET = 3,\n READ_MAX = 4\n}\nconst enum GaStatus {\n SUCCESS = 0,\n ITEM_ERROR = 1,\n ACTION_ERROR = 2,\n FAILURE = 3\n}\n\n\nexport class ImageAddon implements ITerminalAddon, IImageApi {\n private _opts: IImageAddonOptions;\n private _defaultOpts: IImageAddonOptions;\n private _storage: ImageStorage | undefined;\n private _renderer: ImageRenderer | undefined;\n private _disposables: IDisposable[] = [];\n private _terminal: ITerminalExt | undefined;\n private _handlers: Map = new Map();\n private readonly _onImageAdded = new Emitter();\n public readonly onImageAdded: IEvent = this._onImageAdded.event;\n\n constructor(opts?: Partial) {\n this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);\n this._defaultOpts = Object.assign({}, DEFAULT_OPTIONS, opts);\n }\n\n public dispose(): void {\n for (const handler of this._handlers.values()) handler.reset();\n for (const obj of this._disposables) {\n obj.dispose();\n }\n this._disposables.length = 0;\n this._handlers.clear();\n this._onImageAdded.dispose();\n }\n\n private _disposeLater(...args: IDisposable[]): void {\n for (const obj of args) {\n this._disposables.push(obj);\n }\n }\n\n public activate(terminal: ITerminalExt): void {\n this._terminal = terminal;\n\n // internal data structures\n this._renderer = new ImageRenderer(terminal);\n this._storage = new ImageStorage(terminal, this._renderer, this._opts);\n this._storage.onImageAdded = () => this._onImageAdded.fire();\n\n // enable size reports\n if (this._opts.enableSizeReports) {\n const windowOps = terminal.options.windowOptions ?? {};\n windowOps.getWinSizePixels = true;\n windowOps.getCellSizePixels = true;\n windowOps.getWinSizeChars = true;\n terminal.options.windowOptions = windowOps;\n }\n\n this._disposeLater(\n this._renderer,\n this._storage,\n\n // DECSET/DECRST/DA1/XTSMGRAPHICS handlers\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'h' }, params => this._decset(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'l' }, params => this._decrst(params)),\n terminal.parser.registerCsiHandler({ final: 'c' }, params => this._da1(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'S' }, params => this._xtermGraphicsAttributes(params)),\n\n // render hook\n terminal.onRender(range => this._storage?.render(range)),\n\n /**\n * reset handlers covered:\n * - DECSTR\n * - RIS\n * - Terminal.reset()\n */\n terminal.parser.registerCsiHandler({ intermediates: '!', final: 'p' }, () => this.reset()),\n terminal.parser.registerEscHandler({ final: 'c' }, () => this.reset()),\n terminal._core._inputHandler.onRequestReset(() => this.reset()),\n\n // wipe canvas and delete alternate images on buffer switch\n terminal.buffer.onBufferChange(() => this._storage?.wipeAlternate()),\n\n // extend images to the right on resize\n terminal.onResize(metrics => this._storage?.viewportResize(metrics))\n );\n\n // SIXEL handler\n if (this._opts.sixelSupport) {\n const sixelStorage = new SixelImageStorage(this._storage!, this._opts, this._renderer!, terminal);\n const sixelHandler = new SixelHandler(this._opts, sixelStorage, terminal);\n this._handlers.set('sixel', sixelHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler)\n );\n }\n\n // iTerm IIP handler\n if (this._opts.iipSupport) {\n const iipStorage = new IIPImageStorage(this._storage!);\n const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, terminal);\n this._handlers.set('iip', iipHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)\n );\n }\n\n // Kitty graphics handler\n if (this._opts.kittySupport) {\n const kittyStorage = new KittyImageStorage(this._storage!);\n const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, kittyStorage, terminal);\n this._handlers.set('kitty', kittyHandler);\n this._disposeLater(\n kittyStorage,\n kittyHandler,\n terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)\n );\n }\n }\n\n // Note: storageLimit is skipped here to not intoduce a surprising side effect.\n public reset(): boolean {\n // reset options customizable by sequences to defaults\n this._opts.sixelScrolling = this._defaultOpts.sixelScrolling;\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n // also clear image storage\n this._storage?.reset();\n // reset protocol handlers\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return false;\n }\n\n public get storageLimit(): number {\n return this._storage?.getLimit() || -1;\n }\n\n public set storageLimit(limit: number) {\n this._storage?.setLimit(limit);\n this._opts.storageLimit = limit;\n }\n\n public get storageUsage(): number {\n if (this._storage) {\n return this._storage.getUsage();\n }\n return -1;\n }\n\n public get showPlaceholder(): boolean {\n return this._opts.showPlaceholder;\n }\n\n public set showPlaceholder(value: boolean) {\n this._opts.showPlaceholder = value;\n this._renderer?.showPlaceholder(value);\n }\n\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.getImageAtBufferCell(x, y);\n }\n\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.extractTileAtBufferCell(x, y);\n }\n\n private _report(s: string): void {\n this._terminal?._core.input(s, false);\n }\n\n private _decset(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = false;\n break;\n }\n }\n return false;\n }\n\n private _decrst(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = true;\n break;\n }\n }\n return false;\n }\n\n // overload DA to return something more appropriate\n private _da1(params: (number | number[])[]): boolean {\n if (params[0]) {\n return true;\n }\n // reported features:\n // 62 - VT220\n // 4 - SIXEL support\n // 9 - charsets\n // 22 - ANSI colors\n if (this._opts.sixelSupport) {\n this._report(`\\x1b[?62;4;9;22c`);\n return true;\n }\n return false;\n }\n\n /**\n * Implementation of xterm's graphics attribute sequence.\n *\n * Supported features:\n * - read/change palette limits (max 4096 by sixel lib)\n * - read SIXEL canvas geometry (reports current window canvas or\n * squared pixelLimit if canvas > pixel limit)\n *\n * Everything else is deactivated.\n */\n private _xtermGraphicsAttributes(params: (number | number[])[]): boolean {\n if (params.length < 2) {\n return true;\n }\n if (params[0] === GaItem.COLORS) {\n switch (params[1]) {\n case GaAction.READ:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n return true;\n case GaAction.SET_DEFAULT:\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n // also reset protocol handlers for now\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return true;\n case GaAction.SET:\n if (params.length > 2 && !(params[2] instanceof Array) && params[2] <= MAX_SIXEL_PALETTE_SIZE) {\n this._opts.sixelPaletteLimit = params[2];\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n } else {\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n }\n return true;\n case GaAction.READ_MAX:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${MAX_SIXEL_PALETTE_SIZE}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n if (params[0] === GaItem.SIXEL_GEO) {\n switch (params[1]) {\n // we only implement read and read_max here\n case GaAction.READ:\n let width = this._renderer?.dimensions?.css.canvas.width;\n let height = this._renderer?.dimensions?.css.canvas.height;\n if (!width || !height) {\n // for some reason we have no working image renderer\n // --> fallback to default cell size\n const cellSize = CELL_SIZE_DEFAULT;\n width = (this._terminal?.cols || 80) * cellSize.width;\n height = (this._terminal?.rows || 24) * cellSize.height;\n }\n if (width * height < this._opts.pixelLimit) {\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${width.toFixed(0)};${height.toFixed(0)}S`);\n } else {\n // if we overflow pixelLimit report that squared instead\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n }\n return true;\n case GaAction.READ_MAX:\n // read_max returns pixelLimit as square area\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n // exit with error on ReGIS or any other requests\n this._report(`\\x1b[?${params[0]};${GaStatus.ITEM_ERROR}S`);\n return true;\n }\n}\n"],"names":["root","factory","exports","module","define","amd","globalThis","red","n","green","blue","toRGBA8888","r","g","b","a","clamp","low","high","value","Math","max","min","h2c","t1","t2","c","normalizeRGB","round","Object","defineProperty","DEFAULT_FOREGROUND","DEFAULT_BACKGROUND","PALETTE_ANSI_256","PALETTE_VT340_GREY","PALETTE_VT340_COLOR","normalizeHLS","nearestColorIndex","fromRGBA8888","alpha","BIG_ENDIAN","Uint8Array","Uint32Array","buffer","console","warn","color","palette","Number","MAX_SAFE_INTEGER","idx","i","length","dr","dg","db","d","h","l","s","v","HLStoRGB","p","push","decodeAsync","decode","Decoder","DecoderAsync","Colors_1","__webpack_require__","wasm_1","WASM_BYTES","Buffer","from","bytestring","atob","result","charCodeAt","decodeBase64","LIMITS","BYTES","WASM_MODULE","NULL_CANVAS","CallbackProxy","constructor","this","bandHandler","width","modeHandler","mode","handle_band","mode_parsed","DEFAULT_OPTIONS","memoryLimit","sixelColor","fillColor","paletteLimit","PALETTE_SIZE","truncate","opts","cbProxy","importObj","env","bind","WebAssembly","instantiate","then","inst","instance","_instance","_cbProxy","_PIXEL_OFFSET","MAX_WIDTH","_canvas","_bandWidths","_maxWidth","_minWidth","_lastOffset","_currentHeight","_opts","assign","Error","_handle_band","_initCanvas","Module","Instance","_wasm","_chunk","memory","get_chunk_address","CHUNK_SIZE","_states","get_state_address","_palette","get_palette_address","set","_pSrc","get_p0_address","init","_fillColor","_truncate","_rasterWidth","_rasterHeight","_width","_height","_level","_mode","_paletteLimit","pixels","height","release","_realloc","offset","additionalPixels","newCanvas","ceil","adv","remaining","subarray","current_width","current_height","memoryUsage","byteLength","properties","level","memUsage","rasterAttributes","numerator","denominator","data","start","end","decodeString","j","data32","currentWidth","fill","escape","final","finalOffset","bw","currentHeight","data8","Uint8ClampedArray","dec","async","Lifecycle_1","EventUtils","_listeners","_disposed","event","_event","listener","thisArgs","disposables","toDisposable","entry","fn","slice","indexOf","splice","Array","isArray","add","fire","call","listeners","len","dispose","forward","to","e","map","undefined","any","events","store","DisposableStore","runAndSubscribe","handler","initial","arg","_disposables","Set","_isDisposed","isDisposed","o","clear","Disposable","_store","_register","None","freeze","_value","ImageRenderer_1","ImageStorage_1","Base64Decoder_wasm_1","__importDefault","QoiDecoder_wasm_1","IIPHeaderParser_1","IIPMetrics_1","DEFAULT_HEADER","type","name","size","preserveAspectRatio","inline","_renderer","_storage","_coreTerminal","_generation","_aborted","_hp","HeaderParser","_header","_isMultipart","_abortMulti","maxEncodedBytes","iipSizeLimit","initialBytes","_dec","default","_qoiDec","reset","put","state","dataPos","parse","fields","_initDecoder","success","seqType","w","CELL_SIZE_DEFAULT","dimensions","css","canvas","cols","rows","scale","_c","_a","_core","_coreBrowserService","dpr","report","toFixed","input","cond","blob","metrics","UNSUPPORTED_TYPE","imageType","mime","pixelLimit","_resize","floor","ImageData","byteOffset","ImageRenderer","createCanvas","_b","getContext","putImageData","addImage","Blob","generation","createImageBitmap","resizeWidth","resizeHeight","bm","close","catch","cw","cell","ch","_d","rw","_dim","rh","wf","hf","f","total","cdim","endsWith","parseInt","toStr","String","fromCharCode","toInt","toSize","match","DECODERS","toString","bs","TextDecoder","FILE_MARKER","MULTIPARTFILE_MARKER","FILEPART_MARKER","FILEEND_MARKER","REPORTCELLSIZE_MARKER","MAX_FIELDCHARS","_buffer","_position","_key","k","_storeValue","pos","_storeKey","_addImageOpts","scrolling","layer","zIndex","cursorPos","img","d32","blockLength","jpgSize","dim","limit","_layers","get","localDocument","document","createElement","createImageData","ctx","imgData","Promise","resolve","_terminal","super","Map","_optionsRefresh","MutableDisposable","_oldOpen","open","parent","_open","screenElement","optionsService","onOptionChange","option","rescaleCanvas","_renderService","refreshRows","removeLayerFromDom","_oldSetRenderer","setRenderer","_placeholderBitmap","_placeholder","showPlaceholder","cellSize","_createPlaceHolder","clearLines","y","clearRect","_e","clearAll","draw","imgSpec","tileId","col","row","count","_rescaleImage","actual","sourceWidth","sourceHeight","actualCellSize","sx","sy","dx","dy","finalWidth","finalHeight","drawImage","extractTile","drawPlaceholder","values","spec","originalWidth","originalHeight","origCellSize","orig","scaledWidth","scaledHeight","renderer","key","keys","insertLayerToDom","has","classList","style","isolation","insertBefore","firstChild","appendChild","remove","delete","hasLayer","bWidth","blueprint","black","white","shift","x","screen","ctx2","placeholder","bitmap","window","ExtendedAttrsImage","ext","_urlId","_ext","underlineStyle","underlineColor","underlineVariantOffset","val","urlId","imageId","clone","isEmpty","EMPTY_ATTRS","_images","_lastId","_lowestId","_fullyCleared","_needsFullClear","_pixelLimit","setLimit","storageLimit","error","message","getLimit","_viewportMetrics","marker","RangeError","_evictOldest","getUsage","_getStoredPixels","storedPixels","_delImg","id","ImageBitmap","onImageDeleted","wipeAlternate","zero","entries","bufferType","deleteImage","termCols","termRows","originX","originY","tileCount","_inputHandler","_dirtyRowTracker","markDirty","line","lines","ybase","_writeToCell","lineFeed","endMarker","registerMarker","onDispose","active","_evictOnAlternate","onImageAdded","render","range","hasTopImages","hasBottomImages","drawCalls","placeholderCalls","ydisp","getBg","_extendedAttrs","startTile","startCol","sort","viewportResize","oldCol","tilesPerRow","hasData","rightCol","_data","lastTile","expandCol","getImageAtBufferCell","extractTileAtBufferCell","room","used","current","old","oldSpec","imgId","Decoder_1","DEFAULT_PALETTE","idleDecoders","poolPrimed","releaseDecoder","idle","convertLe","_size","_decMemoryLimit","_returnDecoder","hook","params","pop","acquireDecoder","attr","colors","bg","isInverse","isFgDefault","foreground","rgba","isFgRGB","t","toColorRGB","getFgColor","ansi","isBgDefault","background","isBgRGB","getBgColor","extractActiveBg","_curAttrData","_themeService","sixelPaletteLimit","sixelSizeLimit","unhook","_unhook","advanceCursor","sixelScrolling","KittyGraphicsTypes_1","_kittyStorage","_decodeError","_activeDecoder","_inControlData","_controlData","_controlLength","_encodedSizeLimit","_totalEncodedSize","_parsedCommand","_pendingTransmissions","_maxEncodedBytes","kittySizeLimit","_initialEncodedBytes","_cleanupAllPending","_removePendingEntry","_lastPendingKey","pending","decoder","controlEnd","copyLength","parseKittyCommand","_parseControlDataString","imageNumber","_sendResponse","quiet","action","payloadStart","_streamPayload","pendingKey","previousEncodedSize","_f","totalEncodedSize","decoderToRelease","_g","decoderCapacity","_h","maxPending","oldest","next","cmd","_j","_k","_handleNoPayloadCommand","_handleDelete","isMoreComing","more","decodeError","finalCmd","imageBytes","_handleCommandWithBytesAndCmd","str","fromCodePoint","_handlePlacement","bytes","_handleTransmit","transmission","_handleTransmitDisplay","_handleQuery","image","getImage","_displayImage","placementId","storeImage","format","compression","lastImageId","expectedBytes","deleteSelector","deleteAll","deleteById","isOk","response","coreService","triggerDataEvent","_decodeAndDisplay","_createBitmap","cropX","cropY","cropW","cropH","maxCropW","maxCropH","finalCropW","finalCropH","cropped","imgCols","imgRows","columns","savedX","savedY","savedYbase","scaled","xOffset","yOffset","canvasW","canvasH","offsetCanvas","offsetCtx","offsetBitmap","cursorMovement","scrolled","arrayBuffer","_decompressZlib","url","URL","createObjectURL","Image","reject","addEventListener","revokeObjectURL","src","pixelCount","src32","dst32","alignedPixels","srcOffset","dstOffset","b0","b1","b2","srcByte","dstByte","compressed","_decompress","offsetIn","reader","ReadableStream","pull","controller","enqueue","pipeThrough","DecompressionStream","getReader","chunks","totalLength","done","read","cancel","releaseLock","chunk","images","_kittyIdToStorageId","kittyIdToStorageId","pendingTransmissions","parts","split","part","eqIdx","substring","numValue","KittyImageStorage","_nextImageId","_storageIdToKittyId","_handleStorageImageDeleted","storageId","kittyId","_previousOnImageDeleted","_wrappedOnImageDeleted","imageData","oldStorageId","_maxStoredImages","_evictUndisplayedImages","byteLimit","retainedBytes","evictPlaced","oldestId","wasmDecode","InWasm","MAP","el","D","EMPTY","keepSize","maxBytes","_inst","_ended","_bytes","_m32","_mem","m","grow","Memory","requested","needed","newSize","addPages","loadedBytes","freeBytes","wasmQoiDecode","ib","dl","chunkP","def","W","z","compile","_wasmCtx","fromBase64","__webpack_module_cache__","moduleId","cachedModule","__webpack_modules__","Event_1","IIPHandler_1","KittyGraphicsHandler_1","KittyImageStorage_1","SixelHandler_1","SixelImageStorage_1","IIPImageStorage_1","enableSizeReports","sixelSupport","iipSupport","kittySupport","_handlers","_onImageAdded","Emitter","_defaultOpts","obj","_disposeLater","args","activate","terminal","ImageStorage","windowOps","options","windowOptions","getWinSizePixels","getCellSizePixels","getWinSizeChars","parser","registerCsiHandler","prefix","_decset","_decrst","_da1","_xtermGraphicsAttributes","onRender","intermediates","registerEscHandler","onRequestReset","onBufferChange","onResize","sixelStorage","SixelImageStorage","sixelHandler","SixelHandler","_parser","registerDcsHandler","iipStorage","IIPImageStorage","iipHandler","IIPHandler","registerOscHandler","kittyStorage","kittyHandler","KittyGraphicsHandler","registerApcHandler","storageUsage","_report","sqrt"],"sourceRoot":""} \ No newline at end of file diff --git a/lib/addon-image.mjs b/lib/addon-image.mjs -index 753a4f833c038a3f45802377dcb16a93695f2161..29c3984db57b888a058165c8aa02ccbcf8efcd2f 100644 +index 753a4f833c038a3f45802377dcb16a93695f2161..aca74ad0c769e8525bc5e4ab8cc6dda03959f760 100644 --- a/lib/addon-image.mjs +++ b/lib/addon-image.mjs @@ -14,5 +14,5 @@ @@ -26,10 +26,10 @@ index 753a4f833c038a3f45802377dcb16a93695f2161..29c3984db57b888a058165c8aa02ccbc * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -var it=Object.create;var Te=Object.defineProperty;var At=Object.getOwnPropertyDescriptor;var rt=Object.getOwnPropertyNames;var st=Object.getPrototypeOf,nt=Object.prototype.hasOwnProperty;var x=(r,e)=>()=>{try{return e||r((e={exports:{}}).exports,e),e.exports}catch(t){throw e=0,t}};var at=(r,e,t,i)=>{if(e&&typeof e=="object"||typeof e=="function")for(let A of rt(e))!nt.call(r,A)&&A!==t&&Te(r,A,{get:()=>e[A],enumerable:!(i=At(e,A))||i.enumerable});return r};var M=(r,e,t)=>(t=r!=null?it(st(r)):{},at(e||!r||!r.__esModule?Te(t,"default",{value:r,enumerable:!0}):t,r));var X=x(_=>{"use strict";Object.defineProperty(_,"__esModule",{value:!0});_.DEFAULT_FOREGROUND=_.DEFAULT_BACKGROUND=_.PALETTE_ANSI_256=_.PALETTE_VT340_GREY=_.PALETTE_VT340_COLOR=_.normalizeHLS=_.normalizeRGB=_.nearestColorIndex=_.fromRGBA8888=_.toRGBA8888=_.alpha=_.blue=_.green=_.red=_.BIG_ENDIAN=void 0;_.BIG_ENDIAN=new Uint8Array(new Uint32Array([4278190080]).buffer)[0]===255;_.BIG_ENDIAN&&console.warn("BE platform detected. This version of node-sixel works only on LE properly.");function xe(r){return r&255}_.red=xe;function Me(r){return r>>>8&255}_.green=Me;function ve(r){return r>>>16&255}_.blue=ve;function ht(r){return r>>>24&255}_.alpha=ht;function B(r,e,t,i=255){return((i&255)<<24|(t&255)<<16|(e&255)<<8|r&255)>>>0}_.toRGBA8888=B;function gt(r){return[r&255,r>>8&255,r>>16&255,r>>>24]}_.fromRGBA8888=gt;function It(r,e){let t=xe(r),i=Me(r),A=ve(r),s=Number.MAX_SAFE_INTEGER,n=-1;for(let a=0;a1&&(t-=1),t*6<1?e+(r-e)*6*t:t*2<1?r:t*3<2?e+(r-e)*(4-t*6):e}function dt(r,e,t){if(!t){let s=Math.round(e*255);return B(s,s,s)}let i=e<.5?e*(1+t):e+t-e*t,A=2*e-i;return B(ge(0,255,Math.round(Ie(i,A,r+1/3)*255)),ge(0,255,Math.round(Ie(i,A,r)*255)),ge(0,255,Math.round(Ie(i,A,r-1/3)*255)))}function m(r,e,t){return(4278190080|Math.round(t/100*255)<<16|Math.round(e/100*255)<<8|Math.round(r/100*255))>>>0}_.normalizeRGB=m;function lt(r,e,t){return dt((r+240%360)/360,e/100,t/100)}_.normalizeHLS=lt;_.PALETTE_VT340_COLOR=new Uint32Array([m(0,0,0),m(20,20,80),m(80,13,13),m(20,80,20),m(80,20,80),m(20,80,80),m(80,80,20),m(53,53,53),m(26,26,26),m(33,33,60),m(60,26,26),m(33,60,33),m(60,33,60),m(33,60,60),m(60,60,33),m(80,80,80)]);_.PALETTE_VT340_GREY=new Uint32Array([m(0,0,0),m(13,13,13),m(26,26,26),m(40,40,40),m(6,6,6),m(20,20,20),m(33,33,33),m(46,46,46),m(0,0,0),m(13,13,13),m(26,26,26),m(40,40,40),m(6,6,6),m(20,20,20),m(33,33,33),m(46,46,46)]);_.PALETTE_ANSI_256=(()=>{let r=[B(0,0,0),B(205,0,0),B(0,205,0),B(205,205,0),B(0,0,238),B(205,0,205),B(0,250,205),B(229,229,229),B(127,127,127),B(255,0,0),B(0,255,0),B(255,255,0),B(92,92,255),B(255,0,255),B(0,255,255),B(255,255,255)],e=[0,95,135,175,215,255];for(let t=0;t<6;++t)for(let i=0;i<6;++i)for(let A=0;A<6;++A)r.push(B(e[t],e[i],e[A]));for(let t=8;t<=238;t+=10)r.push(B(t,t,t));return new Uint32Array(r)})();_.DEFAULT_BACKGROUND=B(0,0,0,255);_.DEFAULT_FOREGROUND=B(255,255,255,255)});var ce=x(le=>{"use strict";Object.defineProperty(le,"__esModule",{value:!0});le.InWasm=Ct;var v=r=>{if(Uint8Array.fromBase64)return Uint8Array.fromBase64(r);if(typeof Buffer<"u")return Buffer.from(r,"base64");let e=atob(r),t=new Uint8Array(e.length);for(let i=0;inew n.Instance(s||(s=new n.Module(A||(A=v(i)))),a):a=>s?n.instantiate(s,a):n.instantiate(A||(A=v(i)),a).then(o=>(s=o.module)&&o.instance):e===1?t?()=>s||(s=new n.Module(A||(A=v(i)))):()=>s?Promise.resolve(s):n.compile(A||(A=v(i))).then(a=>s=a):t?()=>A||(A=v(i)):()=>Promise.resolve(A||(A=v(i)))}if(typeof _wasmCtx>"u")throw new Error('must run "inwasm"');_wasmCtx.add(r)}});var ue=x(_e=>{"use strict";Object.defineProperty(_e,"__esModule",{value:!0});var pt=ce(),Bt=(0,pt.InWasm)({s:1,t:0,d:"AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML"}),S=new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map(r=>r.charCodeAt(0))),R=new Uint32Array(1024);R.fill(4278190080);for(let r=0;r>4|(r<<4&255)<<8;for(let r=0;r>2<<8|(r<<6&255)<<16;for(let r=0;rthis.maxBytes||this.maxBytes>4294901760)throw new Error("invalid byte settings")}get data8(){return this._inst?this._d.subarray(0,this._m32[1282]):ft}release(){this._inst&&(this._bytes>this.keepSize?this._inst=this._m32=this._d=this._mem=null:(this._m32[1280]=0,this._m32[1281]=0,this._m32[1282]=0))}init(e,t){if(this.maxBytes=e??this.maxBytes,this._bytes=t??Math.min(this._bytes,this.maxBytes),this._bytes>this.maxBytes||this.maxBytes>4294901760)throw Error("invalid byte settings");let i=this._m32,A=this._bytes+5152;this._inst?this._mem.buffer.byteLengththis.maxBytes)return-3;let i=this._bytes;for(;(i*=2)this._mem.buffer.byteLength){let A=Math.ceil((i+5152-this._mem.buffer.byteLength)/65536);this._mem.grow(A),this._m32=new Uint32Array(this._mem.buffer,0),this._d=new Uint8Array(this._mem.buffer,5152)}this._bytes=i}return 0}put(e){if(!this._inst||this._ended)return-2;if(this._realloc(e.length))return-3;let t=this._m32;return this._d.set(e,t[1280]),t[1280]+=e.length,t[1280]-t[1281]>=131072?this._inst.exports.dec():0}end(){return this._ended=!0,this._inst?this._inst.exports.end():-2}get loadedBytes(){return this._inst?this._m32[1280]:0}get freeBytes(){return this._inst?this.maxBytes-this._m32[1280]:0}};_e.default=me});var Re=x(Ce=>{"use strict";Object.defineProperty(Ce,"__esModule",{value:!0});var Qt=ce(),Dt=(0,Qt.InWasm)({s:1,t:0,d:"AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=="}),Ee=class{constructor(e){this.keepSize=e,this.width=0,this.height=0}decode(e){this.width=e[4]<<24|e[5]<<16|e[6]<<8|e[7],this.height=e[8]<<24|e[9]<<16|e[10]<<8|e[11];let t=this.width*this.height,i=t*4,A=e.length,s=Math.max(i,A)+(Math.min(i,A)>>1)+4096;this._inst?this._mem.buffer.byteLengththis.keepSize&&(this._inst=this._d=this._mem=null)}};Ce.default=Ee});var Ke=x(ie=>{"use strict";Object.defineProperty(ie,"__esModule",{value:!0});ie.LIMITS=void 0;ie.LIMITS={CHUNK_SIZE:16384,PALETTE_SIZE:4096,MAX_WIDTH:16384,BYTES:"AGFzbQEAAAABJAdgAAF/YAJ/fwBgA39/fwF/YAF/AX9gAABgBH9/f38AYAF/AAIlAgNlbnYLaGFuZGxlX2JhbmQAAwNlbnYLbW9kZV9wYXJzZWQAAwMTEgQAAAAAAQQBAQUBAAACAgAGAwQFAXABBwcFBAEBBwcGCAF/AUGAihoLB9wBDgZtZW1vcnkCABFnZXRfc3RhdGVfYWRkcmVzcwADEWdldF9jaHVua19hZGRyZXNzAAQOZ2V0X3AwX2FkZHJlc3MABRNnZXRfcGFsZXR0ZV9hZGRyZXNzAAYEaW5pdAALBmRlY29kZQAMDWN1cnJlbnRfd2lkdGgADQ5jdXJyZW50X2hlaWdodAAOGV9faW5kaXJlY3RfZnVuY3Rpb25fdGFibGUBAAtfaW5pdGlhbGl6ZQACCXN0YWNrU2F2ZQARDHN0YWNrUmVzdG9yZQASCnN0YWNrQWxsb2MAEwkMAQBBAQsGCgcJDxACDAEBCq5UEgMAAQsFAEGgCAsGAEGQiQELBgBBsIkCCwUAQZAJC+okAQh/QeQIKAIAIQVB4AgoAgAhA0HoCCgCACEIIAFBkIkBaiIJQf8BOgAAIAAgAUgEQCAAQZCJAWohBgNAIAMhBCAGQQFqIQECQCAGLQAAQf8AcSIDQTBrQQlLBEAgASEGDAELQewIKAIAQQJ0QewIaiICKAIAIQADQCACIAMgAEEKbGpBMGsiADYCACABLQAAIQMgAUEBaiIGIQEgA0H/AHEiA0Ewa0EKSQ0ACwsCQAJAAkACQAJAAkACQAJ/AkACQCADQT9rIgBBP00EQCAERQ0BIARBIUYEQAJAQfAIKAIAIgFBASABGyIHIAhqIgFB1AgoAgAiA0gNACADQf//AEoNAANAIANBAnQiAkGgiQJqIgRBoAgpAwA3AwAgAkGoiQJqQaAIKQMANwMAIAJBsIkCakGgCCkDADcDACACQbiJAmpBoAgpAwA3AwAgAkHAiQJqQaAIKQMANwMAIAJByIkCakGgCCkDADcDACACQdCJAmpBoAgpAwA3AwAgAkHYiQJqQaAIKQMANwMAIAJB4IkCakGgCCkDADcDACACQeiJAmpBoAgpAwA3AwAgAkHwiQJqQaAIKQMANwMAIAJB+IkCakGgCCkDADcDACACQYCKAmpBoAgpAwA3AwAgAkGIigJqQaAIKQMANwMAIAJBkIoCakGgCCkDADcDACACQZiKAmpBoAgpAwA3AwAgAkGgigJqQaAIKQMANwMAIAJBqIoCakGgCCkDADcDACACQbCKAmpBoAgpAwA3AwAgAkG4igJqQaAIKQMANwMAIAJBwIoCakGgCCkDADcDACACQciKAmpBoAgpAwA3AwAgAkHQigJqQaAIKQMANwMAIAJB2IoCakGgCCkDADcDACACQeCKAmpBoAgpAwA3AwAgAkHoigJqQaAIKQMANwMAIAJB8IoCakGgCCkDADcDACACQfiKAmpBoAgpAwA3AwAgAkGAiwJqQaAIKQMANwMAIAJBiIsCakGgCCkDADcDACACQZCLAmpBoAgpAwA3AwAgAkGYiwJqQaAIKQMANwMAIAJBoIsCakGgCCkDADcDACACQaiLAmpBoAgpAwA3AwAgAkGwiwJqQaAIKQMANwMAIAJBuIsCakGgCCkDADcDACACQcCLAmpBoAgpAwA3AwAgAkHIiwJqQaAIKQMANwMAIAJB0IsCakGgCCkDADcDACACQdiLAmpBoAgpAwA3AwAgAkHgiwJqQaAIKQMANwMAIAJB6IsCakGgCCkDADcDACACQfCLAmpBoAgpAwA3AwAgAkH4iwJqQaAIKQMANwMAIAJBgIwCakGgCCkDADcDACACQYiMAmpBoAgpAwA3AwAgAkGQjAJqQaAIKQMANwMAIAJBmIwCakGgCCkDADcDACACQaCMAmpBoAgpAwA3AwAgAkGojAJqQaAIKQMANwMAIAJBsIwCakGgCCkDADcDACACQbiMAmpBoAgpAwA3AwAgAkHAjAJqQaAIKQMANwMAIAJByIwCakGgCCkDADcDACACQdCMAmpBoAgpAwA3AwAgAkHYjAJqQaAIKQMANwMAIAJB4IwCakGgCCkDADcDACACQeiMAmpBoAgpAwA3AwAgAkHwjAJqQaAIKQMANwMAIAJB+IwCakGgCCkDADcDACACQYCNAmpBoAgpAwA3AwAgAkGIjQJqQaAIKQMANwMAIAJBkI0CakGgCCkDADcDACACQZiNAmpBoAgpAwA3AwAgAkGwiQZqIARBgAT8CgAAQdQIKAIAQQJ0QcCJCmogBEGABPwKAABB1AgoAgBBAnRB0IkOaiAEQYAE/AoAAEHUCCgCAEECdEHgiRJqIARBgAT8CgAAQdQIKAIAQQJ0QfCJFmogBEGABPwKAABB1AhB1AgoAgAiAkGAAWoiAzYCACABIANIDQEgAkGA/wBIDQALCwJAIABFDQAgCEH//wBLDQBBgIABIAhrIAcgAUH//wBLGyECAkAgAEEBcUUNACACRQ0AIAhBAnRBoIkCaiEDIAIhBCACQQdxIgcEQANAIAMgBTYCACADQQRqIQMgBEEBayEEIAdBAWsiBw0ACwsgAkEBa0EHSQ0AA0AgAyAFNgIcIAMgBTYCGCADIAU2AhQgAyAFNgIQIAMgBTYCDCADIAU2AgggAyAFNgIEIAMgBTYCACADQSBqIQMgBEEIayIEDQALCwJAIABBAnFFDQAgAkUNACAIQQJ0QbCJBmohAyACIQQgAkEHcSIHBEADQCADIAU2AgAgA0EEaiEDIARBAWshBCAHQQFrIgcNAAsLIAJBAWtBB0kNAANAIAMgBTYCHCADIAU2AhggAyAFNgIUIAMgBTYCECADIAU2AgwgAyAFNgIIIAMgBTYCBCADIAU2AgAgA0EgaiEDIARBCGsiBA0ACwsCQCAAQQRxRQ0AIAJFDQAgCEECdEHAiQpqIQMgAiEEIAJBB3EiBwRAA0AgAyAFNgIAIANBBGohAyAEQQFrIQQgB0EBayIHDQALCyACQQFrQQdJDQADQCADIAU2AhwgAyAFNgIYIAMgBTYCFCADIAU2AhAgAyAFNgIMIAMgBTYCCCADIAU2AgQgAyAFNgIAIANBIGohAyAEQQhrIgQNAAsLAkAgAEEIcUUNACACRQ0AIAhBAnRB0IkOaiEDIAIhBCACQQdxIgcEQANAIAMgBTYCACADQQRqIQMgBEEBayEEIAdBAWsiBw0ACwsgAkEBa0EHSQ0AA0AgAyAFNgIcIAMgBTYCGCADIAU2AhQgAyAFNgIQIAMgBTYCDCADIAU2AgggAyAFNgIEIAMgBTYCACADQSBqIQMgBEEIayIEDQALCwJAIABBEHFFDQAgAkUNACAIQQJ0QeCJEmohAyACIQQgAkEHcSIHBEADQCADIAU2AgAgA0EEaiEDIARBAWshBCAHQQFrIgcNAAsLIAJBAWtBB0kNAANAIAMgBTYCHCADIAU2AhggAyAFNgIUIAMgBTYCECADIAU2AgwgAyAFNgIIIAMgBTYCBCADIAU2AgAgA0EgaiEDIARBCGsiBA0ACwsgAEEgcUUNACACRQ0AIAJBAWshByAIQQJ0QfCJFmohAyACQQdxIgQEQANAIAMgBTYCACADQQRqIQMgAkEBayECIARBAWsiBA0ACwsgB0EHSQ0AA0AgAyAFNgIcIAMgBTYCGCADIAU2AhQgAyAFNgIQIAMgBTYCDCADIAU2AgggAyAFNgIEIAMgBTYCACADQSBqIQMgAkEIayICDQALC0HcCEHcCCgCACAAcjYCACAGQQFqIgIgBi0AAEH/AHEiA0E/ayIAQT9LDQQaDAMLAkBB7AgoAgAiBEEBRgRAQfAIKAIAIgNBzAgoAgAiAUkNASADIAFwIQMMAQtB+AgoAgAhAkH0CCgCACEBAkACQCAEQQVHDQAgAUEBRw0AIAJB6QJODQQMAQsgAkHkAEoNA0H8CCgCAEHkAEoNA0GACSgCAEHkAEoNAwsCQCABRQ0AIAFBAkoNACACQfwIKAIAQYAJKAIAIAFBAnRBiAhqKAIAEQIAIQFB8AgoAgAiA0HMCCgCACICTwR/IAMgAnAFIAMLQQJ0QZAJaiABNgIAC0HwCCgCACIDQcwIKAIAIgFJDQAgAyABcCEDCyADQQJ0QZAJaigCACEFDAELIANB/QBxQSFHBEAgCCEBIAYhAgwECyAEQSNHDQQCQEHsCCgCACICQQFGBEBB8AgoAgAiAUHMCCgCACIASQ0BIAEgAHAhAQwBC0H4CCgCACEBQfQIKAIAIQACQAJAIAJBBUcNACAAQQFHDQAgAUHpAkgNAQwHCyABQeQASg0GQfwIKAIAQeQASg0GQYAJKAIAQeQASg0GCwJAIABFDQAgAEECSg0AIAFB/AgoAgBBgAkoAgAgAEECdEGICGooAgARAgAhAEHwCCgCACIBQcwIKAIAIgJPBH8gASACcAUgAQtBAnRBkAlqIAA2AgALQfAIKAIAIgFBzAgoAgAiAEkNACABIABwIQELIAFBAnRBkAlqKAIAIQUMBAsgCCEBIAYhAgtB1AgoAgAhBgNAAkAgASAGSA0AIAZB//8ASg0AIAZBAnQiBEGgiQJqIgZBoAgpAwA3AwAgBEGoiQJqQaAIKQMANwMAIARBsIkCakGgCCkDADcDACAEQbiJAmpBoAgpAwA3AwAgBEHAiQJqQaAIKQMANwMAIARByIkCakGgCCkDADcDACAEQdCJAmpBoAgpAwA3AwAgBEHYiQJqQaAIKQMANwMAIARB4IkCakGgCCkDADcDACAEQeiJAmpBoAgpAwA3AwAgBEHwiQJqQaAIKQMANwMAIARB+IkCakGgCCkDADcDACAEQYCKAmpBoAgpAwA3AwAgBEGIigJqQaAIKQMANwMAIARBkIoCakGgCCkDADcDACAEQZiKAmpBoAgpAwA3AwAgBEGgigJqQaAIKQMANwMAIARBqIoCakGgCCkDADcDACAEQbCKAmpBoAgpAwA3AwAgBEG4igJqQaAIKQMANwMAIARBwIoCakGgCCkDADcDACAEQciKAmpBoAgpAwA3AwAgBEHQigJqQaAIKQMANwMAIARB2IoCakGgCCkDADcDACAEQeCKAmpBoAgpAwA3AwAgBEHoigJqQaAIKQMANwMAIARB8IoCakGgCCkDADcDACAEQfiKAmpBoAgpAwA3AwAgBEGAiwJqQaAIKQMANwMAIARBiIsCakGgCCkDADcDACAEQZCLAmpBoAgpAwA3AwAgBEGYiwJqQaAIKQMANwMAIARBoIsCakGgCCkDADcDACAEQaiLAmpBoAgpAwA3AwAgBEGwiwJqQaAIKQMANwMAIARBuIsCakGgCCkDADcDACAEQcCLAmpBoAgpAwA3AwAgBEHIiwJqQaAIKQMANwMAIARB0IsCakGgCCkDADcDACAEQdiLAmpBoAgpAwA3AwAgBEHgiwJqQaAIKQMANwMAIARB6IsCakGgCCkDADcDACAEQfCLAmpBoAgpAwA3AwAgBEH4iwJqQaAIKQMANwMAIARBgIwCakGgCCkDADcDACAEQYiMAmpBoAgpAwA3AwAgBEGQjAJqQaAIKQMANwMAIARBmIwCakGgCCkDADcDACAEQaCMAmpBoAgpAwA3AwAgBEGojAJqQaAIKQMANwMAIARBsIwCakGgCCkDADcDACAEQbiMAmpBoAgpAwA3AwAgBEHAjAJqQaAIKQMANwMAIARByIwCakGgCCkDADcDACAEQdCMAmpBoAgpAwA3AwAgBEHYjAJqQaAIKQMANwMAIARB4IwCakGgCCkDADcDACAEQeiMAmpBoAgpAwA3AwAgBEHwjAJqQaAIKQMANwMAIARB+IwCakGgCCkDADcDACAEQYCNAmpBoAgpAwA3AwAgBEGIjQJqQaAIKQMANwMAIARBkI0CakGgCCkDADcDACAEQZiNAmpBoAgpAwA3AwAgBEGwiQZqIAZBgAT8CgAAQdQIKAIAQQJ0QcCJCmogBkGABPwKAABB1AgoAgBBAnRB0IkOaiAGQYAE/AoAAEHUCCgCAEECdEHgiRJqIAZBgAT8CgAAQdQIKAIAQQJ0QfCJFmogBkGABPwKAABB1AhB1AgoAgBBgAFqIgY2AgALIAFB//8ATQRAIABBAXEgAWxBAnRBoIkCaiAFNgIAIABBAXZBAXEgAWxBAnRBsIkGaiAFNgIAIABBAnZBAXEgAWxBAnRBwIkKaiAFNgIAIABBA3ZBAXEgAWxBAnRB0IkOaiAFNgIAIABBBHZBAXEgAWxBAnRB4IkSaiAFNgIAIABBBXYgAWxBAnRB8IkWaiAFNgIAQdQIKAIAIQYLIAFBAWohAUHcCEHcCCgCACAAcjYCACACLQAAIQAgAkEBaiIEIQIgAEH/AHEiA0E/ayIAQcAASQ0ACyAECyECQQAhBCACIQYgASEIIANB/QBxQSFGDQELIANBJGsOCgEDAwMDAwMDAwIDC0HsCEIBNwIADAQLQdgIIAFB2AgoAgAiACAAIAFIGyIAQYCAASAAQYCAAUgbNgIADAILQegIIAFB2AgoAgAiACAAIAFIGyIAQYCAASAAQYCAAUgbIgA2AgBB2AggADYCACAAQQRrEAAEQEHoCEEENgIAQdgIQQQ2AgBB0AhBATYCAA8LEAgMAQsCQCADQTtHDQBB7AgoAgAiAEEHSg0AQewIIABBAWo2AgAgAEECdEHwCGpBADYCAAsgAiEGIAQhAyABIQgMAQtBBCEIIAIhBiAEIQMLIAYgCUkNAAsLQeQIIAU2AgBB4AggAzYCAEHoCCAINgIAC9ELAgF+CH9B2AhCBDcDAEGojQJBoAgpAwAiADcDAEGgjQIgADcDAEGYjQIgADcDAEGQjQIgADcDAEGIjQIgADcDAEGAjQIgADcDAEH4jAIgADcDAEHwjAIgADcDAEHojAIgADcDAEHgjAIgADcDAEHYjAIgADcDAEHQjAIgADcDAEHIjAIgADcDAEHAjAIgADcDAEG4jAIgADcDAEGwjAIgADcDAEGojAIgADcDAEGgjAIgADcDAEGYjAIgADcDAEGQjAIgADcDAEGIjAIgADcDAEGAjAIgADcDAEH4iwIgADcDAEHwiwIgADcDAEHoiwIgADcDAEHgiwIgADcDAEHYiwIgADcDAEHQiwIgADcDAEHIiwIgADcDAEHAiwIgADcDAEG4iwIgADcDAEGwiwIgADcDAEGoiwIgADcDAEGgiwIgADcDAEGYiwIgADcDAEGQiwIgADcDAEGIiwIgADcDAEGAiwIgADcDAEH4igIgADcDAEHwigIgADcDAEHoigIgADcDAEHgigIgADcDAEHYigIgADcDAEHQigIgADcDAEHIigIgADcDAEHAigIgADcDAEG4igIgADcDAEGwigIgADcDAEGoigIgADcDAEGgigIgADcDAEGYigIgADcDAEGQigIgADcDAEGIigIgADcDAEGAigIgADcDAEH4iQIgADcDAEHwiQIgADcDAEHoiQIgADcDAEHgiQIgADcDAEHYiQIgADcDAEHQiQIgADcDAEHIiQIgADcDAEHAiQIgADcDAEG4iQIgADcDAEGwiQIgADcDAEGoCCgCACIEQf8AakGAAW0hCAJAIARBgQFIDQBBASEBIAhBAiAIQQJKG0EBayICQQFxIQMgBEGBAk4EQCACQX5xIQIDQCABQQl0IgdBEHJBoIkCakGwiQJBgAT8CgAAIAdBsI0CakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsLIANFDQAgAUEJdEEQckGgiQJqQbCJAkGABPwKAAALAkAgBEEBSA0AIAhBASAIQQFKGyIDQQFxIQUCQCADQQFrIgdFBEBBACEBDAELIANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQbCJBmpBsIkCQYAE/AoAACAGQZAEckGwiQZqQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACwsgBQRAIAFBCXRBEHJBsIkGakGwiQJBgAT8CgAACyAEQQFIDQAgA0EBcSEFIAcEfyADQf7///8HcSECQQAhAQNAIAFBCXQiBkEQckHAiQpqQbCJAkGABPwKAAAgBkGQBHJBwIkKakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsgAUEHdEEEcgVBBAshASAFBEAgAUECdEHAiQpqQbCJAkGABPwKAAALIARBAUgNACADQQFxIQUgBwR/IANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQdCJDmpBsIkCQYAE/AoAACAGQZAEckHQiQ5qQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACyABQQd0QQRyBUEECyEBIAUEQCABQQJ0QdCJDmpBsIkCQYAE/AoAAAsgBEEBSA0AIANBAXEhBSAHBH8gA0H+////B3EhAkEAIQEDQCABQQl0IgZBEHJB4IkSakGwiQJBgAT8CgAAIAZBkARyQeCJEmpBsIkCQYAE/AoAACABQQJqIQEgAkECayICDQALIAFBB3RBBHIFQQQLIQEgBQRAIAFBAnRB4IkSakGwiQJBgAT8CgAACyAEQQFIDQAgA0EBcSEEIAcEfyADQf7///8HcSECQQAhAQNAIAFBCXQiA0EQckHwiRZqQbCJAkGABPwKAAAgA0GQBHJB8IkWakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsgAUEHdEEEcgVBBAshASAERQ0AIAFBAnRB8IkWakGwiQJBgAT8CgAAC0HUCCAIQQd0QQRyNgIAC58TAgh/AX5B5AgoAgAhA0HgCCgCACECQegIKAIAIQcgAUGQiQFqIglB/wE6AAAgACABSARAIABBkIkBaiEIA0AgAiEEIAhBAWohAQJAIAgtAABB/wBxIgJBMGtBCUsEQCABIQgMAQtB7AgoAgBBAnRB7AhqIgUoAgAhAANAIAUgAiAAQQpsakEwayIANgIAIAEtAAAhAiABQQFqIgghASACQf8AcSICQTBrQQpJDQALCwJAAkACQAJAAkACQAJ/AkAgAkE/ayIAQT9NBEAgBEUNASAEQSFGBEBB8AgoAgAiAUEBIAEbIgQgB2ohAQJAIABFDQAgB0H//wBLDQBBgIABIAdrIAQgAUH//wBLGyEFAkAgAEEBcUUNACAHQQJ0QaCJAmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEECcUUNACAHQQJ0QbCJBmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEEEcUUNACAHQQJ0QcCJCmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEEIcUUNACAHQQJ0QdCJDmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEEQcUUNACAHQQJ0QeCJEmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLIABBIHFFDQAgBUEBayEEIAdBAnRB8IkWaiEAIAVBB3EiAgRAA0AgACADNgIAIABBBGohACAFQQFrIQUgAkEBayICDQALCyAEQQdJDQADQCAAIAM2AhwgACADNgIYIAAgAzYCFCAAIAM2AhAgACADNgIMIAAgAzYCCCAAIAM2AgQgACADNgIAIABBIGohACAFQQhrIgUNAAsLIAhBAWoiBSAILQAAQf8AcSICQT9rIgBBP00NAxoMBAsCQEHsCCgCACIFQQFGBEBB8AgoAgAiAUHMCCgCACIESQ0BIAEgBHAhAQwBC0H4CCgCACEEQfQIKAIAIQECQAJAIAVBBUcNACABQQFHDQAgBEHpAk4NBAwBCyAEQeQASg0DQfwIKAIAQeQASg0DQYAJKAIAQeQASg0DCwJAIAFFDQAgAUECSg0AIARB/AgoAgBBgAkoAgAgAUECdEGICGooAgARAgAhBEHwCCgCACIBQcwIKAIAIgVPBH8gASAFcAUgAQtBAnRBkAlqIAQ2AgALQfAIKAIAIgFBzAgoAgAiBEkNACABIARwIQELIAFBAnRBkAlqKAIAIQMMAQsgAkH9AHFBIUcEQCAHIQEgAiEADAQLIARBI0cNBAJAQewIKAIAIgRBAUYEQEHwCCgCACIBQcwIKAIAIgBJDQEgASAAcCEBDAELQfgIKAIAIQFB9AgoAgAhAAJAAkAgBEEFRw0AIABBAUcNACABQekCSA0BDAcLIAFB5ABKDQZB/AgoAgBB5ABKDQZBgAkoAgBB5ABKDQYLAkAgAEUNACAAQQJKDQAgAUH8CCgCAEGACSgCACAAQQJ0QYgIaigCABECACEAQfAIKAIAIgFBzAgoAgAiBE8EfyABIARwBSABC0ECdEGQCWogADYCAAtB8AgoAgAiAUHMCCgCACIASQ0AIAEgAHAhAQsgAUECdEGQCWooAgAhAwwECyAHIQEgCAshBQNAIAFB//8ATQRAIABBAXEgAWxBAnRBoIkCaiADNgIAIABBAXZBAXEgAWxBAnRBsIkGaiADNgIAIABBAnZBAXEgAWxBAnRBwIkKaiADNgIAIABBA3ZBAXEgAWxBAnRB0IkOaiADNgIAIABBBHZBAXEgAWxBAnRB4IkSaiADNgIAIABBBXYgAWxBAnRB8IkWaiADNgIACyABQQFqIQEgBS0AACEAIAVBAWoiBCEFIABB/wBxIgJBP2siAEHAAEkNAAsgBCEFC0EAIQQgBSEIIAEhByACIQAgAkH9AHFBIUYNAQtBBCEHIAQhAiAAQSRrDgoDAgICAgICAgIBAgtB7AhCATcCAAwCC0GoCCgCAEEEaxAABEBB0AhBATYCAA8LAkBBqAgoAgAiBkEFSA0AQaAIKQMAIQogBkEDa0EBdiIBQQdxIQJBACEAIAFBAWtBB08EQCABQfj///8HcSEFA0AgAEEDdCIBQbCJAmogCjcDACABQQhyQbCJAmogCjcDACABQRByQbCJAmogCjcDACABQRhyQbCJAmogCjcDACABQSByQbCJAmogCjcDACABQShyQbCJAmogCjcDACABQTByQbCJAmogCjcDACABQThyQbCJAmogCjcDACAAQQhqIQAgBUEIayIFDQALCyACRQ0AA0AgAEEDdEGwiQJqIAo3AwAgAEEBaiEAIAJBAWsiAg0ACwtBwIkGQbCJAiAGQQJ0IgD8CgAAQdCJCkGwiQIgAPwKAABB4IkOQbCJAiAA/AoAAEHwiRJBsIkCIAD8CgAAQYCKFkGwiQIgAPwKAAAgBCECDAELAkAgAEE7Rw0AQewIKAIAIgBBB0oNAEHsCCAAQQFqNgIAIABBAnRB8AhqQQA2AgALIAEhBwsgCCAJSQ0ACwtB5AggAzYCAEHgCCACNgIAQegIIAc2AgAL4gcCBX8BfgJAQdAIAn8CQAJAIAAgAU4NACABQZCJAWohBiAAQZCJAWohBQNAIAUtAAAiA0H/AHEhAgJAAkACQAJAAkACQAJAQeAIKAIAIgRBIkcEQCAEDQcgAkEiRgRAQewIQgE3AgBB4AhBIjYCAAwICyACQT9rQcAASQ0GIANBIWsiAkEMTQ0BDAULAkAgAkEwayIEQQlNBEBB7AgoAgBBAnRB7AhqIgIgBCACKAIAQQpsajYCAAwBC0HsCCgCACEEIAJBO0YEQCAEQQdKDQFB7AggBEEBajYCACAEQQJ0QfAIakEANgIADAELIARBBEYEQEHECEECNgIAQbAIQfAIKQMANwMAQbgIQfgIKAIAIgI2AgBBvAhB/AgoAgAiBDYCAEHICEECQQFBwAgoAgAiAxs2AgBBrAggBEEAIAMbNgIAQagIIAJBgIABIAJBgIABSBtBBGpBACADGzYCAEHgCEEANgIADAoLIAJBP2tBwABJDQQLIANBIWsiAkEMTQ0BDAILQQEgAnRBjSBxRQ0DDAQLQQEgAnRBjSBxDQELIANBoQFrIgJBDEsNA0EBIAJ0QY0gcUUNAwtBxAhCgYCAgBA3AgBBsAhB8AgoAgBBAEHsCCgCACICQQBKGzYCAEG0CEH0CCgCAEEAIAJBAUobNgIAQbgIQfgIKAIAQQAgAkECShs2AgBB4AhBADYCAEG8CEEANgIADAQLIANBoQFrIgJBDEsNAUEBIAJ0QY0gcUUNAQtBxAhCgYCAgBA3AgBBsAhCADcDAEG4CEIANwMADAMLIAVBAWoiBSAGSQ0ACwsCQEHICCgCAA4DAwEAAQsCQEGoCCgCACIFQQVIDQBBoAgpAwAhByAFQQNrQQF2IgNBB3EhBEEAIQIgA0EBa0EHTwRAIANB+P///wdxIQYDQCACQQN0IgNBsIkCaiAHNwMAIANBCHJBsIkCaiAHNwMAIANBEHJBsIkCaiAHNwMAIANBGHJBsIkCaiAHNwMAIANBIHJBsIkCaiAHNwMAIANBKHJBsIkCaiAHNwMAIANBMHJBsIkCaiAHNwMAIANBOHJBsIkCaiAHNwMAIAJBCGohAiAGQQhrIgYNAAsLIARFDQADQCACQQN0QbCJAmogBzcDACACQQFqIQIgBEEBayIEDQALC0HAiQZBsIkCIAVBAnQiA/wKAABB0IkKQbCJAiAD/AoAAEHgiQ5BsIkCIAP8CgAAQfCJEkGwiQIgA/wKAABBgIoWQbCJAiAD/AoAAEECDAELEAhByAgoAgALEAEiAjYCACACDQAgACABQcgIKAIAQQJ0QYAIaigCABEBAAsLdABB6AhBBDYCAEHkCCAANgIAQewIQgE3AgBBxAhCADcCAEHACCADNgIAQdwIQgA3AgBBqAhCADcDAEGwCEIANwMAQbgIQgA3AwBBzAggAkGAICACQYAgSRs2AgBBoAggAa1CgYCAgBB+NwMAQdAIQQA2AgALIwBB0AgoAgBFBEAgACABQcgIKAIAQQJ0QYAIaigCABEBAAsLWgECfwJAAkACQEHICCgCAEEBaw4CAAECC0HYCEHoCCgCACIAQdgIKAIAIgEgACABShsiAEGAgAEgAEGAgAFIGyIANgIAIABBBGsPC0GoCCgCAEEEayEACyAAC0IBAX8Cf0EGQdwIKAIAIgBBIHENABpBBSAAQRBxDQAaQQQgAEEIcQ0AGkEDIABBBHENABpBAiAAQQFxIABBAnEbCwu9BQEFfQJ/IAJFBEAgAUH/AWxBMmpB5ABtIgBBCHQgAHIgAEEQdHIMAQsgArJDAADIQpUhBiAAQfABarJDAAC0Q5UhBQJ9IAGyQwAAyEKVIgNDAAAAP10EQCADIAZDAACAP5KUDAELIAYgA0MAAIA/IAaTlJILIQcgAyADkiEGAkAgBUOrqqo+kiIEQwAAAABdBEAgBEMAAIA/kiEEDAELIARDAACAP15FDQAgBEMAAIC/kiEECyAGIAeTIQMgBUMAAAAAXSEAAn8CfSADIAcgA5NDAADAQJQgBJSSIARDq6oqPl0NABogByAEQwAAAD9dDQAaIAMgBEOrqio/XUUNABogAyAHIAOTIARDAADAwJRDAACAQJKUkgtDAAB/Q5RDAAAAP5IiBkMAAIBPXSAGQwAAAABgcQRAIAapDAELQQALIQECQCAABEAgBUMAAIA/kiEEDAELIAUiBEMAAIA/XkUNACAFQwAAgL+SIQQLIAVDq6qqvpIiBUMAAAAAXSECAn8CfSADIAcgA5NDAADAQJQgBJSSIARDq6oqPl0NABogByAEQwAAAD9dDQAaIAMgBEOrqio/XUUNABogAyAHIAOTIARDAADAwJRDAACAQJKUkgtDAAB/Q5RDAAAAP5IiBkMAAIBPXSAGQwAAAABgcQRAIAapDAELQQALIQACQCACBEAgBUMAAIA/kiEFDAELIAVDAACAP15FDQAgBUMAAIC/kiEFCwJAIAVDq6oqPl0EQCADIAcgA5NDAADAQJQgBZSSIQcMAQsgBUMAAAA/XQ0AIAVDq6oqP11FBEAgAyEHDAELIAMgByADkyAFQwAAwMCUQwAAgECSlJIhBwsgAEEIdAJ/IAdDAAB/Q5RDAAAAP5IiBkMAAIBPXSAGQwAAAABgcQRAIAapDAELQQALQRB0ciABcgtBgICAeHILNwAgAEH/AWxBMmpB5ABtIAFB/wFsQTJqQeQAbUEIdHIgAkH/AWxBMmpB5ABtQRB0ckGAgIB4cgsEACMACwYAIAAkAAsQACMAIABrQXBxIgAkACAACwsYAQBBgAgLEQEAAAACAAAAAwAAAAQAAAAF"}});var We=x(b=>{"use strict";Object.defineProperty(b,"__esModule",{value:!0});b.decodeAsync=b.decode=b.Decoder=b.DecoderAsync=void 0;var O=X(),f=Ke();function yt(r){if(typeof Buffer<"u")return Buffer.from(r,"base64");let e=atob(r),t=new Uint8Array(e.length);for(let i=0;i1,this.modeHandler=e=>1}handle_band(e){return this.bandHandler(e)}mode_parsed(e){return this.modeHandler(e)}},St={memoryLimit:2048*65536,sixelColor:O.DEFAULT_FOREGROUND,fillColor:O.DEFAULT_BACKGROUND,palette:O.PALETTE_VT340_COLOR,paletteLimit:f.LIMITS.PALETTE_SIZE,truncate:!0};function qe(r){let e=new ye,t={env:{handle_band:e.handle_band.bind(e),mode_parsed:e.mode_parsed.bind(e)}};return WebAssembly.instantiate(J||Ye,t).then(i=>(J=J||i.module,new P(r,i.instance||i,e)))}b.DecoderAsync=qe;var P=class{constructor(e,t,i){if(this._PIXEL_OFFSET=f.LIMITS.MAX_WIDTH+4,this._canvas=Ae,this._bandWidths=[],this._maxWidth=0,this._minWidth=f.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0,this._opts=Object.assign({},St,e),this._opts.paletteLimit>f.LIMITS.PALETTE_SIZE)throw new Error(`DecoderOptions.paletteLimit must not exceed ${f.LIMITS.PALETTE_SIZE}`);if(t)i.bandHandler=this._handle_band.bind(this),i.modeHandler=this._initCanvas.bind(this);else{let A=J||(J=new WebAssembly.Module(Ye));t=new WebAssembly.Instance(A,{env:{handle_band:this._handle_band.bind(this),mode_parsed:this._initCanvas.bind(this)}})}this._instance=t,this._wasm=this._instance.exports,this._chunk=new Uint8Array(this._wasm.memory.buffer,this._wasm.get_chunk_address(),f.LIMITS.CHUNK_SIZE),this._states=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_state_address(),12),this._palette=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_palette_address(),f.LIMITS.PALETTE_SIZE),this._palette.set(this._opts.palette),this._pSrc=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_p0_address()),this._wasm.init(O.DEFAULT_FOREGROUND,0,this._opts.paletteLimit,0)}get _fillColor(){return this._states[0]}get _truncate(){return this._states[8]}get _rasterWidth(){return this._states[6]}get _rasterHeight(){return this._states[7]}get _width(){return this._states[2]?this._states[2]-4:0}get _height(){return this._states[3]}get _level(){return this._states[9]}get _mode(){return this._states[10]}get _paletteLimit(){return this._states[11]}_initCanvas(e){if(e===2){let t=this.width*this.height;if(t>this._canvas.length){if(this._opts.memoryLimit&&t*4>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(t)}this._maxWidth=this._width}else if(e===1)if(this._level===2){let t=Math.min(this._rasterWidth,f.LIMITS.MAX_WIDTH)*this._rasterHeight;if(t>this._canvas.length){if(this._opts.memoryLimit&&t*4>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(t)}}else this._canvas.length<65536&&(this._canvas=new Uint32Array(65536));return 0}_realloc(e,t){let i=e+t;if(i>this._canvas.length){if(this._opts.memoryLimit&&i*4>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");let A=new Uint32Array(Math.ceil(i/65536)*65536);A.set(this._canvas),this._canvas=A}}_handle_band(e){let t=this._PIXEL_OFFSET,i=this._lastOffset;if(this._mode===2){let A=this.height-this._currentHeight,s=0;for(;s<6&&A>0;)this._canvas.set(this._pSrc.subarray(t*s,t*s+e),i+e*s),s++,A--;this._lastOffset+=e*s,this._currentHeight+=s}else if(this._mode===1){this._realloc(i,e*6),this._maxWidth=Math.max(this._maxWidth,e),this._minWidth=Math.min(this._minWidth,e);for(let A=0;A<6;++A)this._canvas.set(this._pSrc.subarray(t*A,t*A+e),i+e*A);this._bandWidths.push(e),this._lastOffset+=e*6,this._currentHeight+=6}return 0}get width(){return this._mode!==1?this._width:Math.max(this._maxWidth,this._wasm.current_width())}get height(){return this._mode!==1?this._height:this._wasm.current_width()?this._bandWidths.length*6+this._wasm.current_height():this._bandWidths.length*6}get palette(){return this._palette.subarray(0,this._paletteLimit)}get memoryUsage(){return this._canvas.byteLength+this._wasm.memory.buffer.byteLength+8*this._bandWidths.length}get properties(){return{width:this.width,height:this.height,mode:this._mode,level:this._level,truncate:!!this._truncate,paletteLimit:this._paletteLimit,fillColor:this._fillColor,memUsage:this.memoryUsage,rasterAttributes:{numerator:this._states[4],denominator:this._states[5],width:this._rasterWidth,height:this._rasterHeight}}}init(e=this._opts.fillColor,t=this._opts.palette,i=this._opts.paletteLimit,A=this._opts.truncate){this._wasm.init(this._opts.sixelColor,e,i,A?1:0),t&&this._palette.set(t.subarray(0,f.LIMITS.PALETTE_SIZE)),this._bandWidths.length=0,this._maxWidth=0,this._minWidth=f.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0}decode(e,t=0,i=e.length){let A=t;for(;A0){let i=this._PIXEL_OFFSET,A=this._lastOffset,s=0;for(;s<6&&t>0;)this._canvas.set(this._pSrc.subarray(i*s,i*s+e),A+e*s),s++,t--;t&&this._canvas.fill(this._fillColor,A+e*s)}return this._canvas.subarray(0,this.width*this.height)}if(this._mode===1){if(this._minWidth===this._maxWidth){let s=!1;if(e)if(e!==this._minWidth)s=!0;else{let n=this._PIXEL_OFFSET,a=this._lastOffset;this._realloc(a,e*6);for(let o=0;o<6;++o)this._canvas.set(this._pSrc.subarray(n*o,n*o+e),a+e*o)}if(!s)return this._canvas.subarray(0,this.width*this.height)}let t=new Uint32Array(this.width*this.height);t.fill(this._fillColor);let i=0,A=0;for(let s=0;s{if(this._disposed)return H(()=>{});let A={fn:e,thisArgs:t};this._listeners=this._listeners.slice(),this._listeners.push(A);let s=H(()=>{let n=this._listeners.indexOf(A);n!==-1&&(this._listeners=this._listeners.slice(),this._listeners.splice(n,1))});return i&&(Array.isArray(i)?i.push(s):i.add(s)),s},this._event)}fire(e){if(this._disposed||!this._listeners.length)return;if(this._listeners.length===1){this._listeners[0].fn.call(this._listeners[0].thisArgs,e);return}let t=this._listeners;for(let i=0,A=t.length;i{function r(s,n){return s(a=>n.fire(a))}A.forward=r;function e(s,n){return(a,o,g)=>s(h=>a.call(o,n(h)),void 0,g)}A.map=e;function t(...s){return(n,a,o)=>{let g=new N;for(let h of s)g.add(h(I=>n.call(a,I)));return o&&(Array.isArray(o)?o.push(g):o.add(g)),g}}A.any=t;function i(s,n,a){return n(a),s(o=>n(o))}A.runAndSubscribe=i})(ot||={});var de=M(X());var D=class r extends k{constructor(t){super();this._terminal=t;this._layers=new Map;this._optionsRefresh=this._register(new W);this._oldOpen=this._terminal._core.open,this._terminal._core.open=i=>{this._oldOpen?.call(this._terminal._core,i),this._open()},this._terminal._core.screenElement&&this._open(),this._optionsRefresh.value=this._terminal._core.optionsService.onOptionChange(i=>{i==="fontSize"&&(this.rescaleCanvas(),this._renderService?.refreshRows(0,this._terminal.rows))}),this._register(H(()=>{this.removeLayerFromDom(),this.removeLayerFromDom("bottom"),this._terminal._core&&this._oldOpen&&(this._terminal._core.open=this._oldOpen,this._oldOpen=void 0),this._renderService&&this._oldSetRenderer&&(this._renderService.setRenderer=this._oldSetRenderer,this._oldSetRenderer=void 0),this._renderService=void 0,this._layers.clear(),this._placeholderBitmap?.close(),this._placeholderBitmap=void 0,this._placeholder=void 0}))}get canvas(){return this._layers.get("top")?.canvas}static createCanvas(t,i,A){let s=(t??document).createElement("canvas");return s.width=i|0,s.height=A|0,s}static createImageData(t,i,A,s){if(typeof ImageData!="function"){let n=t.createImageData(i,A);return s&&n.data.set(new Uint8ClampedArray(s,0,i*A*4)),n}return s?new ImageData(new Uint8ClampedArray(s,0,i*A*4),i,A):new ImageData(i,A)}static createImageBitmap(t){return typeof createImageBitmap!="function"?Promise.resolve(void 0):createImageBitmap(t)}showPlaceholder(t){t?!this._placeholder&&this.cellSize.height!==-1&&this._createPlaceHolder(Math.max(this.cellSize.height+1,24)):(this._placeholderBitmap?.close(),this._placeholderBitmap=void 0,this._placeholder=void 0),this._renderService?.refreshRows(0,this._terminal.rows)}get dimensions(){return this._terminal.dimensions}get cellSize(){return{width:this.dimensions?.css.cell.width||-1,height:this.dimensions?.css.cell.height||-1}}clearLines(t,i,A){let s=t*(this.dimensions?.css.cell.height||0),n=this.dimensions?.css.canvas.width||0,a=(i+1-t)*(this.dimensions?.css.cell.height||0);(!A||A==="top")&&this._layers.get("top")?.clearRect(0,s,n,a),(!A||A==="bottom")&&this._layers.get("bottom")?.clearRect(0,s,n,a)}clearAll(t){if(!t||t==="top"){let i=this._layers.get("top");i?.clearRect(0,0,i.canvas.width,i.canvas.height)}if(!t||t==="bottom"){let i=this._layers.get("bottom");i?.clearRect(0,0,i.canvas.width,i.canvas.height)}}draw(t,i,A,s,n=1){let a=this._layers.get(t.layer);if(!a)return;let{width:o,height:g}=this.cellSize;if(o===-1||g===-1)return;this._rescaleImage(t,o,g);let h=t.actual,I=Math.ceil(h.width/o),d=i%I*o,c=Math.floor(i/I)*g,l=A*o,E=s*g,p=n*o+d>h.width?h.width-d:n*o,u=c+g>h.height?h.height-c:g;a.drawImage(h,Math.floor(d),Math.floor(c),Math.ceil(p),Math.ceil(u),Math.floor(l),Math.floor(E),Math.ceil(p),Math.ceil(u))}extractTile(t,i){let{width:A,height:s}=this.cellSize;if(A===-1||s===-1)return;this._rescaleImage(t,A,s);let n=t.actual,a=Math.ceil(n.width/A),o=i%a*A,g=Math.floor(i/a)*s,h=A+o>n.width?n.width-o:A,I=g+s>n.height?n.height-g:s,d=r.createCanvas(this.document,h,I),c=d.getContext("2d");if(c)return c.drawImage(n,Math.floor(o),Math.floor(g),Math.floor(h),Math.floor(I),0,0,Math.floor(h),Math.floor(I)),d}drawPlaceholder(t,i,A=1){let s=this._layers.get("top");if(s){let{width:n,height:a}=this.cellSize;if(n===-1||a===-1||(this._placeholder?a>=this._placeholder.height&&this._createPlaceHolder(a+1):this._createPlaceHolder(Math.max(a+1,24)),!this._placeholder))return;s.drawImage(this._placeholderBitmap??this._placeholder,t*n,i*a%2?0:1,n*A,a,t*n,i*a,n*A,a)}}rescaleCanvas(){let t=this.dimensions?.css.canvas.width||0,i=this.dimensions?.css.canvas.height||0;for(let A of this._layers.values())(A.canvas.width!==t||A.canvas.height!==i)&&(A.canvas.width=t,A.canvas.height=i)}_rescaleImage(t,i,A){if(i===t.actualCellSize.width&&A===t.actualCellSize.height)return;let{width:s,height:n}=t.origCellSize;if(i===s&&A===n){t.actual=t.orig,t.actualCellSize.width=s,t.actualCellSize.height=n;return}let a=r.createCanvas(this.document,Math.ceil(t.orig.width*i/s),Math.ceil(t.orig.height*A/n)),o=a.getContext("2d");o&&(o.drawImage(t.orig,0,0,a.width,a.height),t.actual=a,t.actualCellSize.width=i,t.actualCellSize.height=A)}_open(){this._renderService=this._terminal._core._renderService,this._oldSetRenderer=this._renderService.setRenderer.bind(this._renderService),this._renderService.setRenderer=t=>{for(let i of[...this._layers.keys()])this.removeLayerFromDom(i);this._oldSetRenderer?.call(this._renderService,t)}}insertLayerToDom(t="top"){if(!this.document||!this._terminal._core.screenElement){console.warn("image addon: cannot insert output canvas to DOM, missing document or screenElement");return}if(this._layers.has(t))return;let i=r.createCanvas(this.document,this.dimensions?.css.canvas.width||0,this.dimensions?.css.canvas.height||0);i.classList.add(`xterm-image-layer-${t}`);let A=this._terminal._core.screenElement;A.style.isolation="isolate",t==="bottom"?(i.style.zIndex="-1",A.insertBefore(i,A.firstChild)):(i.style.zIndex="0",A.appendChild(i));let s=i.getContext("2d",{alpha:!0});if(!s){i.remove();return}this._layers.set(t,s),this.clearAll(t)}removeLayerFromDom(t="top"){let i=this._layers.get(t);i&&(i.canvas.remove(),this._layers.delete(t))}hasLayer(t){return this._layers.has(t)}_createPlaceHolder(t=24){this._placeholderBitmap?.close(),this._placeholderBitmap=void 0;let i=32,A=r.createCanvas(this.document,i,t),s=A.getContext("2d",{alpha:!1});if(!s)return;let n=r.createImageData(s,i,t),a=new Uint32Array(n.data.buffer),o=(0,de.toRGBA8888)(0,0,0),g=(0,de.toRGBA8888)(255,255,255);a.fill(o);for(let d=0;dthis._placeholderBitmap=d)}get document(){return this._terminal._core._coreBrowserService?.window.document}};var w={width:7,height:14},G=class r{constructor(e=0,t=0,i=-1,A=-1){this.imageId=i;this.tileId=A;this._ext=0;this._urlId=0;this._ext=e,this._urlId=t}get ext(){return this._urlId?this._ext&-469762049|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(this._ext&469762048)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return this._ext&67108863}set underlineColor(e){this._ext&=-67108864,this._ext|=e&67108863}get underlineVariantOffset(){let e=(this._ext&3758096384)>>29;return e<0?e^4294967288:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}get urlId(){return this._urlId}set urlId(e){this._urlId=e}clone(){return new r(this._ext,this._urlId,this.imageId,this.tileId)}isEmpty(){return this.underlineStyle===0&&this._urlId===0&&this.imageId===-1}},F=new G,Z=class{constructor(e,t,i){this._terminal=e;this._renderer=t;this._opts=i;this._images=new Map;this._lastId=0;this._lowestId=0;this._fullyCleared=!1;this._needsFullClear=!1;this._pixelLimit=25e5;try{this.setLimit(this._opts.storageLimit)}catch(A){A instanceof Error&&console.error(A.message),console.warn(`storageLimit is set to ${this.getLimit()} MB`)}this._viewportMetrics={cols:this._terminal.cols,rows:this._terminal.rows}}dispose(){this.reset()}reset(){for(let e of this._images.values())e.marker?.dispose();this._images.clear(),this._renderer.clearAll()}getLimit(){return this._pixelLimit*4/1e6}setLimit(e){if(e<.5||e>1e3)throw RangeError("invalid storageLimit, should be at least 0.5 MB and not exceed 1G");this._pixelLimit=e/4*1e6>>>0,this._evictOldest(0)}getUsage(){return this._getStoredPixels()*4/1e6}_getStoredPixels(){let e=0;for(let t of this._images.values())t.orig&&(e+=t.orig.width*t.orig.height,t.actual&&t.actual!==t.orig&&(e+=t.actual.width*t.actual.height));return e}_delImg(e){let t=this._images.get(e);t&&(this._images.delete(e),window.ImageBitmap&&t.orig instanceof ImageBitmap&&t.orig.close(),this.onImageDeleted?.(e))}wipeAlternate(){let e=[];for(let[t,i]of this._images.entries())i.bufferType==="alternate"&&(i.marker?.dispose(),e.push(t));for(let t of e)this._delImg(t);this._needsFullClear=!0,this._fullyCleared=!1}deleteImage(e){let t=this._images.get(e);t&&(t.marker?.dispose(),this._delImg(e))}addImage(e,t){this._evictOldest(e.width*e.height);let i=this._renderer.cellSize;(i.width===-1||i.height===-1)&&(i=w);let A=Math.ceil(e.width/i.width),s=Math.ceil(e.height/i.height),n=++this._lastId,a=this._terminal._core.buffer,o=this._terminal.cols,g=this._terminal.rows,h=a.x,I=a.y,d=h,c=0;t.scrolling||(a.x=0,a.y=0,d=0),this._terminal._core._inputHandler._dirtyRowTracker.markDirty(a.y);for(let u=0;u=o);++Q)this._writeToCell(C,d+Q,n,u*A+Q),c++;if(t.scrolling)u=g)break;a.x=d}this._terminal._core._inputHandler._dirtyRowTracker.markDirty(a.y),t.scrolling?t.cursorPos==="iip"?a.x=Math.min(d+A,o):a.x=d:(a.x=h,a.y=I);let l=[];for(let[u,C]of this._images.entries())C.tileCount<1&&(C.marker?.dispose(),l.push(u));for(let u of l)this._delImg(u);let E=this._terminal.registerMarker(0);E?.onDispose(()=>{this._images.get(n)&&this._delImg(n)}),this._terminal.buffer.active.type==="alternate"&&this._evictOnAlternate();let p={orig:e,origCellSize:i,actual:e,actualCellSize:{...i},marker:E||void 0,tileCount:c,bufferType:this._terminal.buffer.active.type,layer:t.layer,zIndex:t.zIndex};return this._images.set(n,p),this.onImageAdded?.(),n}render(e){let t=!1,i=!1;for(let h of this._images.values())if(h.layer==="bottom"?i=!0:t=!0,t&&i)break;if(t&&!this._renderer.hasLayer("top")&&(this._renderer.insertLayerToDom("top"),!this._renderer.hasLayer("top")))return;if(i&&!this._renderer.hasLayer("bottom")&&this._renderer.insertLayerToDom("bottom"),this._renderer.rescaleCanvas(),!this._images.size){this._fullyCleared||(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1),this._renderer.hasLayer("top")&&this._renderer.removeLayerFromDom("top"),this._renderer.hasLayer("bottom")&&this._renderer.removeLayerFromDom("bottom");return}!t&&this._renderer.hasLayer("top")&&(this._renderer.clearAll("top"),this._renderer.removeLayerFromDom("top")),!i&&this._renderer.hasLayer("bottom")&&(this._renderer.clearAll("bottom"),this._renderer.removeLayerFromDom("bottom")),this._needsFullClear&&(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1);let{start:A,end:s}=e,n=this._terminal._core.buffer,a=this._terminal._core.cols;this._renderer.clearLines(A,s);let o=[],g=[];for(let h=A;h<=s;++h){let I=n.lines.get(h+n.ydisp);if(!I)return;for(let d=0;dh.imgSpec.zIndex-I.imgSpec.zIndex);for(let h of g)this._renderer.drawPlaceholder(h.col,h.row,h.count);for(let h of o)this._renderer.draw(h.imgSpec,h.tileId,h.col,h.row,h.count)}viewportResize(e){if(!this._images.size){this._viewportMetrics=e;return}if(this._viewportMetrics.cols>=e.cols){this._viewportMetrics=e;return}let t=this._terminal._core.buffer,i=t.lines.length,A=this._viewportMetrics.cols-1;for(let s=0;s=h)continue;let I=!1;for(let l=A+1;l>e.cols;++l)if(n._data[l*3+0]&4194303){I=!0;break}if(I)continue;let d=Math.min(e.cols,h-a.tileId%h+A),c=a.tileId;for(let l=A+1;l57)throw new Error("illegal char");e=e*10+r[t]-48}return e}function ke(r){let e=j(r);if(!e.match(/^((auto)|(\d+?((px)|(%)){0,1}))$/))throw new Error("illegal size");return e}function wt(r){if(typeof Buffer<"u")return Buffer.from(j(r),"base64").toString();let e=atob(j(r)),t=new Uint8Array(e.length);for(let i=0;i14)return-1;for(let a=t;a=De)return this._a();n[s++]=o}break;case 58:return A===3&&!this._storeValue(s)?this._a():(this.state=4,a+1);default:if(s>=De)return this._a();n[s++]=o}}return this.state=A,this._position=s,-2}_a(){return this.fields.type=0,this.state=1,-1}_storeKey(e){let t=j(this._buffer.subarray(0,e));return t?(this._key=t,this.fields[t]=null,!0):!1}_storeValue(e){if(this._key){try{let t=this._buffer.slice(0,e);this.fields[this._key]=Ne[this._key]?Ne[this._key](t):t}catch{return!1}return!0}return!1}};var L={mime:"unsupported",width:0,height:0};function Ge(r){if(r.length<32)return L;let e=new Uint32Array(r.buffer,r.byteOffset,8);if(e[0]===1196314761&&e[1]===169478669&&e[3]===1380206665)return{mime:"image/png",width:r[16]<<24|r[17]<<16|r[18]<<8|r[19],height:r[20]<<24|r[21]<<16|r[22]<<8|r[23]};if(r[0]===255&&r[1]===216&&r[2]===255){let[t,i]=bt(r);return{mime:"image/jpeg",width:t,height:i}}if(e[0]===944130375&&(r[4]===55||r[4]===57)&&r[5]===97)return{mime:"image/gif",width:r[7]<<8|r[6],height:r[9]<<8|r[8]};if(e[0]===1718185841)return{mime:"image/qoi",width:r[4]<<24|r[5]<<16|r[6]<<8|r[7],height:r[8]<<24|r[9]<<16|r[10]<<8|r[11]};if(e[0]===1179011410&&e[2]===1346520407&&(e[3]&16777215)===3690582){switch(r[15]){case 88:return{mime:"image/webp",width:(r[24]|r[25]<<8|r[26]<<16)+1,height:(r[27]|r[28]<<8|r[29]<<16)+1};case 76:if(r[20]!==47)return L;let t=r[21]|r[22]<<8|r[23]<<16|r[24]<<24;return{mime:"image/webp",width:(t&16383)+1,height:(t>>>14&16383)+1};case 32:return r[23]!==157||r[24]!==1||r[25]!==42?L:{mime:"image/webp",width:(r[26]|r[27]<<8)&16383,height:(r[28]|r[29]<<8)&16383}}return L}if(e[1]===1887007846&&(e[2]===1718187617||e[2]===1936291425)){let t=-1,i=Math.min(r.length-16,1024);for(let A=8;A0&&s>0)return{mime:"image/avif",width:A,height:s}}return L}return L}function bt(r){let e=r.length,t=4,i=r[t]<<8|r[t+1];for(;;){if(t+=i,t>=e)return[0,0];if(r[t]!==255)return[0,0];if(r[t+1]===192||r[t+1]===194)return t+80){if(this._hp.fields.type===1){if(this._isMultipart&&(this._isMultipart=!1,this._abortMulti=!1,this._dec.release()),this._header=Object.assign({},we,this._hp.fields),!this._header.inline){this._aborted=!0;return}this._dec.init()}else if(this._abortMulti){this._aborted=!0;return}this._dec.put(e.subarray(A,i))!==0&&(this._dec.release(),this._aborted=!0,this._isMultipart&&(this._abortMulti=!0))}}}end(e){if(this._aborted||this._hp.state!==4&&this._hp.end())return!0;let t=this._hp.fields.type;if(t===3)return!0;if(t===5){let o=w.width,g=w.height;this._renderer.dimensions&&(o=this._renderer.dimensions.css.canvas.width/this._coreTerminal.cols,g=this._renderer.dimensions.css.canvas.height/this._coreTerminal.rows);let h=this._coreTerminal._core._coreBrowserService?.dpr??1,I=`\x1B]1337;ReportCellSize=${g.toFixed(3)};${o.toFixed(3)};${h.toFixed(3)}\x1B\\`;return this._coreTerminal.input(I,!1),!0}if(t===2)return this._header=Object.assign({},we,this._hp.fields),this._isMultipart=!0,this._abortMulti=!1,this._dec.release(),this._dec.init(),!0;if(t===4&&(!this._isMultipart||(this._isMultipart=!1,this._abortMulti||this._header.type!==2)))return!0;let i=0,A=0,s,n=L;if((s=e)&&((s=!this._dec.end())?(n=Ge(this._dec.data8),(s=n.mime!=="unsupported")?(i=n.width,A=n.height,(s=i&&A&&i*A(this._storage.addImage(o),!0)).catch(o=>(console.warn(`IIP: decoding error ${n.mime} ${n.width}x${n.height}`,o),!0))}_resize(e,t){let i=this._renderer.dimensions?.css.cell.width||w.width,A=this._renderer.dimensions?.css.cell.height||w.height,s=this._renderer.dimensions?.css.canvas.width||i*this._coreTerminal.cols,n=this._renderer.dimensions?.css.canvas.height||A*this._coreTerminal.rows,a=this._dim(this._header.width,s,i),o=this._dim(this._header.height,n,A);if(!a&&!o){let g=s/e,h=(n-A)/t,I=Math.min(g,h);return I<1?[e*I,t*I]:[e,t]}return a?this._header.preserveAspectRatio||!a||!o?[a,t*a/e]:[a,o]:[e*o/t,o]}_dim(e,t,i){return e==="auto"?0:e.endsWith("%")?parseInt(e.slice(0,-1),10)*t/100:e.endsWith("px")?parseInt(e.slice(0,-2),10):parseInt(e,10)*i}};var Pe=M(ue());function be(r){let e={},t=r.split(",");for(let i of t){let A=i.indexOf("=");if(A===-1)continue;let s=i.substring(0,A),n=i.substring(A+1);if(s==="a"){e.action=n;continue}if(s==="o"){e.compression=n;continue}if(s==="t"){e.transmission=n;continue}if(s==="d"){e.deleteSelector=n;continue}let a=parseInt(n,10);switch(s){case"f":e.format=a;break;case"i":e.id=a;break;case"I":e.imageNumber=a;break;case"s":e.width=a;break;case"v":e.height=a;break;case"x":e.x=a;break;case"y":e.y=a;break;case"w":e.sourceWidth=a;break;case"h":e.sourceHeight=a;break;case"X":e.xOffset=a;break;case"Y":e.yOffset=a;break;case"c":e.columns=a;break;case"r":e.rows=a;break;case"m":e.more=a;break;case"q":e.quiet=a;break;case"C":e.cursorMovement=a;break;case"z":e.zIndex=a;break;case"p":e.placementId=a;break}}return e}var Je=0,ee=class{constructor(e,t,i,A){this._opts=e;this._renderer=t;this._kittyStorage=i;this._coreTerminal=A;this._aborted=!1;this._decodeError=!1;this._activeDecoder=null;this._inControlData=!0;this._controlData=new Uint32Array(512);this._controlLength=0;this._encodedSizeLimit=0;this._totalEncodedSize=0;this._parsedCommand=null;this._pendingTransmissions=new Map;this._maxEncodedBytes=Math.ceil(this._opts.kittySizeLimit*4/3),this._initialEncodedBytes=Math.min(4194304,this._maxEncodedBytes)}reset(){this._cleanupAllPending(),this._activeDecoder&&(this._activeDecoder.release(),this._activeDecoder=null),this._kittyStorage.reset()}dispose(){this.reset()}_removePendingEntry(e){this._pendingTransmissions.delete(e),this._lastPendingKey===e&&(this._lastPendingKey=void 0)}_cleanupAllPending(){for(let e of this._pendingTransmissions.values())e.decoder.release();this._pendingTransmissions.clear(),this._lastPendingKey=void 0}start(){this._aborted=!1,this._decodeError=!1,this._inControlData=!0,this._controlLength=0,this._parsedCommand=null,this._encodedSizeLimit=this._maxEncodedBytes,this._totalEncodedSize=0,this._activeDecoder=null}put(e,t,i){if(!this._aborted)if(!this._inControlData)this._streamPayload(e,t,i);else{let A=i;for(let n=t;n512){this._aborted=!0;return}if(this._controlData.set(e.subarray(t,A),this._controlLength),this._controlLength+=s,!this._inControlData){if(this._parsedCommand=be(this._parseControlDataString()),this._parsedCommand.id!==void 0&&this._parsedCommand.imageNumber!==void 0){this._sendResponse(this._parsedCommand.id,"EINVAL:cannot specify both i and I keys",this._parsedCommand.quiet??0),this._aborted=!0;return}if(this._parsedCommand.action==="d")return;let n=A+1;nthis._encodedSizeLimit){let o=this._activeDecoder??s?.decoder;o&&o.release(),this._activeDecoder=null,s&&this._removePendingEntry(A),this._aborted=!0;return}this._decodeError||(s?.decoder&&!this._activeDecoder&&(this._activeDecoder=s.decoder),this._activeDecoder||(this._activeDecoder=new Pe.default(4194304,this._maxEncodedBytes,this._initialEncodedBytes),this._activeDecoder.init()),this._activeDecoder.put(e.subarray(t,i))!==Je&&(this._activeDecoder.release(),this._activeDecoder=null,this._decodeError=!0,s&&this._removePendingEntry(A)))}end(e){if(this._aborted||!e)return this._activeDecoder&&(this._activeDecoder.release(),this._activeDecoder=null),!0;if(this._inControlData)return this._handleNoPayloadCommand();let t=this._parsedCommand;if(t.action==="d")return this._handleDelete(t);let i=t.id??this._lastPendingKey??0,A=t.more===1,s=this._pendingTransmissions.get(i);if(A)return this._activeDecoder&&(s?(s.totalEncodedSize+=this._totalEncodedSize,s.decodeError=s.decodeError||this._decodeError):this._pendingTransmissions.set(i,{cmd:{...t},decoder:this._activeDecoder,totalEncodedSize:this._totalEncodedSize,decodeError:this._decodeError}),this._lastPendingKey=i,this._activeDecoder=null),!0;s&&(this._lastPendingKey=void 0);let n=this._decodeError,a=t,o=this._activeDecoder;s&&(a=s.cmd,o=s.decoder,n=n||s.decodeError,this._pendingTransmissions.delete(i));let g=new Uint8Array(0);o&&(o.end()!==Je&&(n=!0),g=o.data8),this._activeDecoder=null;let h=this._handleCommandWithBytesAndCmd(a,g,n);return o&&o.release(),h}_parseControlDataString(){let e="";for(let t=0;t0&&this._sendResponse(e.id,"OK",e.quiet??0)),s}case"T":return this._handleTransmitDisplay(e,t,i);case"q":return this._handleQuery(e,t,i);case"p":return this._handlePlacement(e);default:return e.id!==void 0&&this._sendResponse(e.id,"EINVAL:unsupported action",e.quiet??0),!0}}_handlePlacement(e){if(e.id===void 0)return!0;let t=e.id,i=this._kittyStorage.getImage(t);return i?this._displayImage(i,e).then(s=>(this._sendResponse(t,s?"OK":"EINVAL:image rendering failed",e.quiet??0,e.placementId),!0)):(this._sendResponse(t,"ENOENT:image not found",e.quiet??0,e.placementId),!0)}_handleTransmit(e,t,i){return(e.transmission??"d")!=="d"?(e.id!==void 0&&this._sendResponse(e.id,"EINVAL:unsupported transmission medium",e.quiet??0),!0):(i||t.length===0||this._kittyStorage.storeImage(e.id,{data:new Blob([t]),width:e.width??0,height:e.height??0,format:e.format??32,compression:e.compression??""}),!0)}_handleTransmitDisplay(e,t,i){if(i)return e.id!==void 0&&this._sendResponse(e.id,"EINVAL:invalid base64 data",e.quiet??0),!0;this._handleTransmit(e,t,i);let A=e.id??this._kittyStorage.lastImageId,s=this._kittyStorage.getImage(A);if(s){let n=this._displayImage(s,e);return e.id!==void 0?n.then(a=>(this._sendResponse(A,a?"OK":"EINVAL:image rendering failed",e.quiet??0),!0)):n.then(()=>!0)}return!0}_handleQuery(e,t,i){let A=e.id??0,s=e.quiet??0;if((e.transmission??"d")!=="d")return this._sendResponse(A,"EINVAL:unsupported transmission medium",s),!0;if(i)return this._sendResponse(A,"EINVAL:invalid base64 data",s),!0;if(t.length===0)return this._sendResponse(A,"OK",s),!0;let a=e.format??32;if(a===100)this._sendResponse(A,"OK",s);else{let o=e.width??0,g=e.height??0;if(!o||!g)return this._sendResponse(A,"EINVAL:width and height required for raw pixel data",s),!0;let h=a===32?4:3,I=o*g*h;if(t.length=1||!s&&i>=2)return;let n=A?`,p=${A}`:"",a=`\x1B_Gi=${e}${n};${t}\x1B\\`;this._coreTerminal._core.coreService.triggerDataEvent(a)}_displayImage(e,t){return this._decodeAndDisplay(e,t).then(()=>!0).catch(()=>!1)}async _decodeAndDisplay(e,t){let i=await this._createBitmap(e);try{let A=Math.max(0,t.x??0),s=Math.max(0,t.y??0),n=t.sourceWidth||i.width-A,a=t.sourceHeight||i.height-s,o=Math.max(0,i.width-A),g=Math.max(0,i.height-s),h=Math.max(0,Math.min(n,o)),I=Math.max(0,Math.min(a,g));if(h===0||I===0)throw new Error("invalid source rectangle");if(A!==0||s!==0||h!==i.width||I!==i.height){let T=await createImageBitmap(i,A,s,h,I);i.close(),i=T}let d=this._renderer.dimensions?.css.cell.width||w.width,c=this._renderer.dimensions?.css.cell.height||w.height,l,E;t.columns!==void 0&&t.rows!==void 0?(l=t.columns,E=t.rows):t.columns!==void 0?(l=t.columns,E=Math.max(1,Math.ceil(i.height/i.width*(l*d)/c))):t.rows!==void 0?(E=t.rows,l=Math.max(1,Math.ceil(i.width/i.height*(E*c)/d))):(l=Math.ceil(i.width/d),E=Math.ceil(i.height/c));let p=i.width,u=i.height;if((t.columns!==void 0||t.rows!==void 0)&&(p=Math.round(l*d),u=Math.round(E*c)),p*u>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");let C=this._coreTerminal._core.buffer,Q=C.x,K=C.y,Y=C.ybase,Ve=t.zIndex!==void 0&&t.zIndex<0?"bottom":"top";if(p!==i.width||u!==i.height){let T=await createImageBitmap(i,{resizeWidth:p,resizeHeight:u});i.close(),i=T}let oe=Math.min(Math.max(0,t.xOffset??0),d-1),he=Math.min(Math.max(0,t.yOffset??0),c-1);if(oe!==0||he!==0){let T=t.columns!==void 0?Math.round(l*d):i.width+oe,et=t.rows!==void 0?Math.round(E*c):i.height+he,q=D.createCanvas(window.document,T,et),Le=q.getContext("2d");if(!Le)throw new Error("Failed to create offset canvas context");Le.drawImage(i,oe,he);let tt=await createImageBitmap(q);if(q.width=q.height=0,i.close(),i=tt,p=i.width,u=i.height,p*u>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");t.columns===void 0&&(l=Math.ceil(i.width/d)),t.rows===void 0&&(E=Math.ceil(i.height/c))}let $e=t.zIndex??0;if(this._kittyStorage.addImage(e.id,i,!0,Ve,$e),i=void 0,t.cursorMovement===1){let T=C.ybase-Y;C.x=Q,C.y=Math.max(K-T,0)}else C.x=Math.min(Q+l,this._coreTerminal.cols)}catch(A){throw i?.close(),A}}async _createBitmap(e){let t=new Uint8Array(await e.data.arrayBuffer());if(e.compression==="z"&&(t=await this._decompressZlib(t)),e.format===100){let p=new Blob([t],{type:"image/png"});if(!window.createImageBitmap){let u=URL.createObjectURL(p),C=new Image;return new Promise((Q,K)=>{C.addEventListener("load",()=>{URL.revokeObjectURL(u);let Y=D.createCanvas(window.document,C.width,C.height);Y.getContext("2d")?.drawImage(C,0,0),createImageBitmap(Y).then(Q).catch(K)}),C.addEventListener("error",()=>{URL.revokeObjectURL(u),K(new Error("Failed to load image"))}),C.src=u})}return createImageBitmap(p)}let i=e.width,A=e.height;if(!i||!A)throw new Error("Width and height required for raw pixel data");let s=e.format===32?4:3,n=i*A*s;if(t.length>>24|C<<8,h[c++]=4278190080|C>>>16|Q<<16,h[c++]=4278190080|Q>>>8}let l=I*3,E=I*4;for(let p=I;ph+I.length,0),o=new Uint8Array(a),g=0;for(let h of s)o.set(h,g),g+=h.length;return o}get images(){return this._kittyStorage.images}get _kittyIdToStorageId(){return this._kittyStorage.kittyIdToStorageId}get pendingTransmissions(){return this._pendingTransmissions}};var U=class U{constructor(e){this._storage=e;this._nextImageId=1;this._images=new Map;this._kittyIdToStorageId=new Map;this._storageIdToKittyId=new Map;this._handleStorageImageDeleted=e=>{let t=this._storageIdToKittyId.get(e);t!==void 0&&(this._kittyIdToStorageId.delete(t),this._storageIdToKittyId.delete(e),this._images.delete(t))};this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"iip"};this._previousOnImageDeleted=this._storage.onImageDeleted,this._wrappedOnImageDeleted=t=>{this._previousOnImageDeleted?.(t),this._handleStorageImageDeleted(t)},this._storage.onImageDeleted=this._wrappedOnImageDeleted}reset(){this._nextImageId=1,this._images.clear(),this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}dispose(){this.reset(),this._storage.onImageDeleted===this._wrappedOnImageDeleted&&(this._storage.onImageDeleted=this._previousOnImageDeleted)}storeImage(e,t){let i=e??this._nextImageId++,A=this._kittyIdToStorageId.get(i);return A!==void 0&&(this._storage.deleteImage(A),this._kittyIdToStorageId.delete(i),this._storageIdToKittyId.delete(A)),!this._images.has(i)&&this._images.size>=U._maxStoredImages&&this._evictUndisplayedImages(),this._images.set(i,{...t,id:i}),i}addImage(e,t,i,A,s){let n=this._kittyIdToStorageId.get(e);n!==void 0&&this._storageIdToKittyId.delete(n),this._addImageOpts.scrolling=i,this._addImageOpts.layer=A,this._addImageOpts.zIndex=s;let a=this._storage.addImage(t,this._addImageOpts);this._kittyIdToStorageId.set(e,a),this._storageIdToKittyId.set(a,e)}getImage(e){return this._images.get(e)}deleteById(e){this._images.delete(e);let t=this._kittyIdToStorageId.get(e);t!==void 0&&(this._storage.deleteImage(t),this._kittyIdToStorageId.delete(e),this._storageIdToKittyId.delete(t))}deleteAll(){this._images.clear();for(let e of this._kittyIdToStorageId.values())this._storage.deleteImage(e);this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}get images(){return this._images}get kittyIdToStorageId(){return this._kittyIdToStorageId}get lastImageId(){return this._nextImageId-1}_evictUndisplayedImages(){for(let[e]of this._images){if(this._images.size<=U._maxStoredImages/2)break;this._kittyIdToStorageId.has(e)||this._images.delete(e)}}};U._maxStoredImages=256;var te=U;var y=M(X());var ze=M(We());var xt=4194304,Se=y.PALETTE_ANSI_256;Se.set(y.PALETTE_VT340_COLOR);var se=class{constructor(e,t,i){this._opts=e;this._storage=t;this._coreTerminal=i;this._size=0;this._aborted=!1;(0,ze.DecoderAsync)({memoryLimit:this._opts.pixelLimit*4,palette:Se,paletteLimit:this._opts.sixelPaletteLimit}).then(A=>this._dec=A)}reset(){this._dec&&(this._dec.release(),this._dec._palette.fill(0),this._dec.init(0,Se,this._opts.sixelPaletteLimit))}hook(e){if(this._size=0,this._aborted=!1,this._dec){let t=e.params[1]===1?0:Mt(this._coreTerminal._core._inputHandler._curAttrData,this._coreTerminal._core._themeService?.colors);this._dec.init(t,null,this._opts.sixelPaletteLimit)}}put(e,t,i){if(!(this._aborted||!this._dec)){if(this._size+=i-t,this._size>this._opts.sixelSizeLimit){console.warn("SIXEL: too much data, aborting"),this._aborted=!0,this._dec.release();return}try{this._dec.decode(e,t,i)}catch(A){console.warn(`SIXEL: error while decoding image - ${A}`),this._aborted=!0,this._dec.release()}}}unhook(e){if(this._aborted||!e||!this._dec)return!0;let t=this._dec.width,i=this._dec.height;if(!t||!i)return i&&this._storage.advanceCursor(i),!0;let A=D.createCanvas(void 0,t,i);return A.getContext("2d")?.putImageData(new ImageData(this._dec.data8,t,i),0,0),this._dec.memoryUsage>xt&&this._dec.release(),this._storage.addImage(A),!0}};function Mt(r,e){let t=0;if(!e)return t;if(r.isInverse())if(r.isFgDefault())t=re(e.foreground.rgba);else if(r.isFgRGB()){let i=r.constructor.toColorRGB(r.getFgColor());t=(0,y.toRGBA8888)(...i)}else t=re(e.ansi[r.getFgColor()].rgba);else if(r.isBgDefault())t=re(e.background.rgba);else if(r.isBgRGB()){let i=r.constructor.toColorRGB(r.getBgColor());t=(0,y.toRGBA8888)(...i)}else t=re(e.ansi[r.getBgColor()].rgba);return t}function re(r){return y.BIG_ENDIAN?r:(r&255)<<24|(r>>>8&255)<<16|(r>>>16&255)<<8|r>>>24&255}var ne=class{constructor(e,t,i,A){this._storage=e;this._opts=t;this._renderer=i;this._terminal=A;this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"vt340"}}addImage(e){this._addImageOpts.scrolling=this._opts.sixelScrolling,this._storage.addImage(e,this._addImageOpts)}advanceCursor(e){if(this._opts.sixelScrolling){let t=this._renderer.cellSize;(t.width===-1||t.height===-1)&&(t=w);let i=Math.ceil(e/t.height);for(let A=1;Athis._onImageAdded.fire(),this._opts.enableSizeReports){let t=e.options.windowOptions??{};t.getWinSizePixels=!0,t.getCellSizePixels=!0,t.getWinSizeChars=!0,e.options.windowOptions=t}if(this._disposeLater(this._renderer,this._storage,e.parser.registerCsiHandler({prefix:"?",final:"h"},t=>this._decset(t)),e.parser.registerCsiHandler({prefix:"?",final:"l"},t=>this._decrst(t)),e.parser.registerCsiHandler({final:"c"},t=>this._da1(t)),e.parser.registerCsiHandler({prefix:"?",final:"S"},t=>this._xtermGraphicsAttributes(t)),e.onRender(t=>this._storage?.render(t)),e.parser.registerCsiHandler({intermediates:"!",final:"p"},()=>this.reset()),e.parser.registerEscHandler({final:"c"},()=>this.reset()),e._core._inputHandler.onRequestReset(()=>this.reset()),e.buffer.onBufferChange(()=>this._storage?.wipeAlternate()),e.onResize(t=>this._storage?.viewportResize(t))),this._opts.sixelSupport){let t=new ne(this._storage,this._opts,this._renderer,e),i=new se(this._opts,t,e);this._handlers.set("sixel",i),this._disposeLater(e._core._inputHandler._parser.registerDcsHandler({final:"q"},i))}if(this._opts.iipSupport){let t=new ae(this._storage),i=new $(this._opts,this._renderer,t,e);this._handlers.set("iip",i),this._disposeLater(e._core._inputHandler._parser.registerOscHandler(1337,i))}if(this._opts.kittySupport){let t=new te(this._storage),i=new ee(this._opts,this._renderer,t,e);this._handlers.set("kitty",i),this._disposeLater(t,i,e._core._inputHandler._parser.registerApcHandler({final:"G"},i))}}reset(){this._opts.sixelScrolling=this._defaultOpts.sixelScrolling,this._opts.sixelPaletteLimit=this._defaultOpts.sixelPaletteLimit,this._storage?.reset();for(let e of this._handlers.values())e.reset();return!1}get storageLimit(){return this._storage?.getLimit()||-1}set storageLimit(e){this._storage?.setLimit(e),this._opts.storageLimit=e}get storageUsage(){return this._storage?this._storage.getUsage():-1}get showPlaceholder(){return this._opts.showPlaceholder}set showPlaceholder(e){this._opts.showPlaceholder=e,this._renderer?.showPlaceholder(e)}getImageAtBufferCell(e,t){return this._storage?.getImageAtBufferCell(e,t)}extractTileAtBufferCell(e,t){return this._storage?.extractTileAtBufferCell(e,t)}_report(e){this._terminal?._core.input(e,!1)}_decset(e){for(let t=0;t2&&!(e[2]instanceof Array)&&e[2]<=Ze?(this._opts.sixelPaletteLimit=e[2],this._report(`\x1B[?${e[0]};0;${this._opts.sixelPaletteLimit}S`)):this._report(`\x1B[?${e[0]};2S`),!0;case 4:return this._report(`\x1B[?${e[0]};0;${Ze}S`),!0;default:return this._report(`\x1B[?${e[0]};2S`),!0}if(e[0]===2)switch(e[1]){case 1:let t=this._renderer?.dimensions?.css.canvas.width,i=this._renderer?.dimensions?.css.canvas.height;if(!t||!i){let s=w;t=(this._terminal?.cols||80)*s.width,i=(this._terminal?.rows||24)*s.height}if(t*i()=>{try{return e||s((e={exports:{}}).exports,e),e.exports}catch(t){throw e=0,t}};var ot=(s,e,t,i)=>{if(e&&typeof e=="object"||typeof e=="function")for(let A of rt(e))!at.call(s,A)&&A!==t&&Me(s,A,{get:()=>e[A],enumerable:!(i=st(e,A))||i.enumerable});return s};var M=(s,e,t)=>(t=s!=null?At(nt(s)):{},ot(e||!s||!s.__esModule?Me(t,"default",{value:s,enumerable:!0}):t,s));var Z=x(_=>{"use strict";Object.defineProperty(_,"__esModule",{value:!0});_.DEFAULT_FOREGROUND=_.DEFAULT_BACKGROUND=_.PALETTE_ANSI_256=_.PALETTE_VT340_GREY=_.PALETTE_VT340_COLOR=_.normalizeHLS=_.normalizeRGB=_.nearestColorIndex=_.fromRGBA8888=_.toRGBA8888=_.alpha=_.blue=_.green=_.red=_.BIG_ENDIAN=void 0;_.BIG_ENDIAN=new Uint8Array(new Uint32Array([4278190080]).buffer)[0]===255;_.BIG_ENDIAN&&console.warn("BE platform detected. This version of node-sixel works only on LE properly.");function ve(s){return s&255}_.red=ve;function Re(s){return s>>>8&255}_.green=Re;function ke(s){return s>>>16&255}_.blue=ke;function gt(s){return s>>>24&255}_.alpha=gt;function f(s,e,t,i=255){return((i&255)<<24|(t&255)<<16|(e&255)<<8|s&255)>>>0}_.toRGBA8888=f;function dt(s){return[s&255,s>>8&255,s>>16&255,s>>>24]}_.fromRGBA8888=dt;function It(s,e){let t=ve(s),i=Re(s),A=ke(s),r=Number.MAX_SAFE_INTEGER,n=-1;for(let a=0;a1&&(t-=1),t*6<1?e+(s-e)*6*t:t*2<1?s:t*3<2?e+(s-e)*(4-t*6):e}function lt(s,e,t){if(!t){let r=Math.round(e*255);return f(r,r,r)}let i=e<.5?e*(1+t):e+t-e*t,A=2*e-i;return f(Ie(0,255,Math.round(le(i,A,s+1/3)*255)),Ie(0,255,Math.round(le(i,A,s)*255)),Ie(0,255,Math.round(le(i,A,s-1/3)*255)))}function m(s,e,t){return(4278190080|Math.round(t/100*255)<<16|Math.round(e/100*255)<<8|Math.round(s/100*255))>>>0}_.normalizeRGB=m;function ct(s,e,t){return lt((s+240%360)/360,e/100,t/100)}_.normalizeHLS=ct;_.PALETTE_VT340_COLOR=new Uint32Array([m(0,0,0),m(20,20,80),m(80,13,13),m(20,80,20),m(80,20,80),m(20,80,80),m(80,80,20),m(53,53,53),m(26,26,26),m(33,33,60),m(60,26,26),m(33,60,33),m(60,33,60),m(33,60,60),m(60,60,33),m(80,80,80)]);_.PALETTE_VT340_GREY=new Uint32Array([m(0,0,0),m(13,13,13),m(26,26,26),m(40,40,40),m(6,6,6),m(20,20,20),m(33,33,33),m(46,46,46),m(0,0,0),m(13,13,13),m(26,26,26),m(40,40,40),m(6,6,6),m(20,20,20),m(33,33,33),m(46,46,46)]);_.PALETTE_ANSI_256=(()=>{let s=[f(0,0,0),f(205,0,0),f(0,205,0),f(205,205,0),f(0,0,238),f(205,0,205),f(0,250,205),f(229,229,229),f(127,127,127),f(255,0,0),f(0,255,0),f(255,255,0),f(92,92,255),f(255,0,255),f(0,255,255),f(255,255,255)],e=[0,95,135,175,215,255];for(let t=0;t<6;++t)for(let i=0;i<6;++i)for(let A=0;A<6;++A)s.push(f(e[t],e[i],e[A]));for(let t=8;t<=238;t+=10)s.push(f(t,t,t));return new Uint32Array(s)})();_.DEFAULT_BACKGROUND=f(0,0,0,255);_.DEFAULT_FOREGROUND=f(255,255,255,255)});var _e=x(me=>{"use strict";Object.defineProperty(me,"__esModule",{value:!0});me.InWasm=pt;var v=s=>{if(Uint8Array.fromBase64)return Uint8Array.fromBase64(s);if(typeof Buffer<"u")return Buffer.from(s,"base64");let e=atob(s),t=new Uint8Array(e.length);for(let i=0;inew n.Instance(r||(r=new n.Module(A||(A=v(i)))),a):a=>r?n.instantiate(r,a):n.instantiate(A||(A=v(i)),a).then(o=>(r=o.module)&&o.instance):e===1?t?()=>r||(r=new n.Module(A||(A=v(i)))):()=>r?Promise.resolve(r):n.compile(A||(A=v(i))).then(a=>r=a):t?()=>A||(A=v(i)):()=>Promise.resolve(A||(A=v(i)))}if(typeof _wasmCtx>"u")throw new Error('must run "inwasm"');_wasmCtx.add(s)}});var Ce=x(Ee=>{"use strict";Object.defineProperty(Ee,"__esModule",{value:!0});var Bt=_e(),ft=(0,Bt.InWasm)({s:1,t:0,d:"AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML"}),S=new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map(s=>s.charCodeAt(0))),R=new Uint32Array(1024);R.fill(4278190080);for(let s=0;s>4|(s<<4&255)<<8;for(let s=0;s>2<<8|(s<<6&255)<<16;for(let s=0;sthis.maxBytes||this.maxBytes>4294901760)throw new Error("invalid byte settings")}get data8(){return this._inst?this._d.subarray(0,this._m32[1282]):Qt}release(){this._inst&&(this._bytes>this.keepSize?this._inst=this._m32=this._d=this._mem=null:(this._m32[1280]=0,this._m32[1281]=0,this._m32[1282]=0))}init(e,t){if(this.maxBytes=e??this.maxBytes,this._bytes=t??Math.min(this._bytes,this.maxBytes),this._bytes>this.maxBytes||this.maxBytes>4294901760)throw Error("invalid byte settings");let i=this._m32,A=this._bytes+5152;this._inst?this._mem.buffer.byteLengththis.maxBytes)return-3;let i=this._bytes;for(;(i*=2)this._mem.buffer.byteLength){let A=Math.ceil((i+5152-this._mem.buffer.byteLength)/65536);this._mem.grow(A),this._m32=new Uint32Array(this._mem.buffer,0),this._d=new Uint8Array(this._mem.buffer,5152)}this._bytes=i}return 0}put(e){if(!this._inst||this._ended)return-2;if(this._realloc(e.length))return-3;let t=this._m32;return this._d.set(e,t[1280]),t[1280]+=e.length,t[1280]-t[1281]>=131072?this._inst.exports.dec():0}end(){return this._ended=!0,this._inst?this._inst.exports.end():-2}get loadedBytes(){return this._inst?this._m32[1280]:0}get freeBytes(){return this._inst?this.maxBytes-this._m32[1280]:0}};Ee.default=ue});var Ne=x(Be=>{"use strict";Object.defineProperty(Be,"__esModule",{value:!0});var Dt=_e(),wt=(0,Dt.InWasm)({s:1,t:0,d:"AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=="}),pe=class{constructor(e){this.keepSize=e,this.width=0,this.height=0}decode(e){this.width=e[4]<<24|e[5]<<16|e[6]<<8|e[7],this.height=e[8]<<24|e[9]<<16|e[10]<<8|e[11];let t=this.width*this.height,i=t*4,A=e.length,r=Math.max(i,A)+(Math.min(i,A)>>1)+4096;this._inst?this._mem.buffer.byteLengththis.keepSize&&(this._inst=this._d=this._mem=null)}};Be.default=pe});var Ye=x(se=>{"use strict";Object.defineProperty(se,"__esModule",{value:!0});se.LIMITS=void 0;se.LIMITS={CHUNK_SIZE:16384,PALETTE_SIZE:4096,MAX_WIDTH:16384,BYTES:"AGFzbQEAAAABJAdgAAF/YAJ/fwBgA39/fwF/YAF/AX9gAABgBH9/f38AYAF/AAIlAgNlbnYLaGFuZGxlX2JhbmQAAwNlbnYLbW9kZV9wYXJzZWQAAwMTEgQAAAAAAQQBAQUBAAACAgAGAwQFAXABBwcFBAEBBwcGCAF/AUGAihoLB9wBDgZtZW1vcnkCABFnZXRfc3RhdGVfYWRkcmVzcwADEWdldF9jaHVua19hZGRyZXNzAAQOZ2V0X3AwX2FkZHJlc3MABRNnZXRfcGFsZXR0ZV9hZGRyZXNzAAYEaW5pdAALBmRlY29kZQAMDWN1cnJlbnRfd2lkdGgADQ5jdXJyZW50X2hlaWdodAAOGV9faW5kaXJlY3RfZnVuY3Rpb25fdGFibGUBAAtfaW5pdGlhbGl6ZQACCXN0YWNrU2F2ZQARDHN0YWNrUmVzdG9yZQASCnN0YWNrQWxsb2MAEwkMAQBBAQsGCgcJDxACDAEBCq5UEgMAAQsFAEGgCAsGAEGQiQELBgBBsIkCCwUAQZAJC+okAQh/QeQIKAIAIQVB4AgoAgAhA0HoCCgCACEIIAFBkIkBaiIJQf8BOgAAIAAgAUgEQCAAQZCJAWohBgNAIAMhBCAGQQFqIQECQCAGLQAAQf8AcSIDQTBrQQlLBEAgASEGDAELQewIKAIAQQJ0QewIaiICKAIAIQADQCACIAMgAEEKbGpBMGsiADYCACABLQAAIQMgAUEBaiIGIQEgA0H/AHEiA0Ewa0EKSQ0ACwsCQAJAAkACQAJAAkACQAJ/AkACQCADQT9rIgBBP00EQCAERQ0BIARBIUYEQAJAQfAIKAIAIgFBASABGyIHIAhqIgFB1AgoAgAiA0gNACADQf//AEoNAANAIANBAnQiAkGgiQJqIgRBoAgpAwA3AwAgAkGoiQJqQaAIKQMANwMAIAJBsIkCakGgCCkDADcDACACQbiJAmpBoAgpAwA3AwAgAkHAiQJqQaAIKQMANwMAIAJByIkCakGgCCkDADcDACACQdCJAmpBoAgpAwA3AwAgAkHYiQJqQaAIKQMANwMAIAJB4IkCakGgCCkDADcDACACQeiJAmpBoAgpAwA3AwAgAkHwiQJqQaAIKQMANwMAIAJB+IkCakGgCCkDADcDACACQYCKAmpBoAgpAwA3AwAgAkGIigJqQaAIKQMANwMAIAJBkIoCakGgCCkDADcDACACQZiKAmpBoAgpAwA3AwAgAkGgigJqQaAIKQMANwMAIAJBqIoCakGgCCkDADcDACACQbCKAmpBoAgpAwA3AwAgAkG4igJqQaAIKQMANwMAIAJBwIoCakGgCCkDADcDACACQciKAmpBoAgpAwA3AwAgAkHQigJqQaAIKQMANwMAIAJB2IoCakGgCCkDADcDACACQeCKAmpBoAgpAwA3AwAgAkHoigJqQaAIKQMANwMAIAJB8IoCakGgCCkDADcDACACQfiKAmpBoAgpAwA3AwAgAkGAiwJqQaAIKQMANwMAIAJBiIsCakGgCCkDADcDACACQZCLAmpBoAgpAwA3AwAgAkGYiwJqQaAIKQMANwMAIAJBoIsCakGgCCkDADcDACACQaiLAmpBoAgpAwA3AwAgAkGwiwJqQaAIKQMANwMAIAJBuIsCakGgCCkDADcDACACQcCLAmpBoAgpAwA3AwAgAkHIiwJqQaAIKQMANwMAIAJB0IsCakGgCCkDADcDACACQdiLAmpBoAgpAwA3AwAgAkHgiwJqQaAIKQMANwMAIAJB6IsCakGgCCkDADcDACACQfCLAmpBoAgpAwA3AwAgAkH4iwJqQaAIKQMANwMAIAJBgIwCakGgCCkDADcDACACQYiMAmpBoAgpAwA3AwAgAkGQjAJqQaAIKQMANwMAIAJBmIwCakGgCCkDADcDACACQaCMAmpBoAgpAwA3AwAgAkGojAJqQaAIKQMANwMAIAJBsIwCakGgCCkDADcDACACQbiMAmpBoAgpAwA3AwAgAkHAjAJqQaAIKQMANwMAIAJByIwCakGgCCkDADcDACACQdCMAmpBoAgpAwA3AwAgAkHYjAJqQaAIKQMANwMAIAJB4IwCakGgCCkDADcDACACQeiMAmpBoAgpAwA3AwAgAkHwjAJqQaAIKQMANwMAIAJB+IwCakGgCCkDADcDACACQYCNAmpBoAgpAwA3AwAgAkGIjQJqQaAIKQMANwMAIAJBkI0CakGgCCkDADcDACACQZiNAmpBoAgpAwA3AwAgAkGwiQZqIARBgAT8CgAAQdQIKAIAQQJ0QcCJCmogBEGABPwKAABB1AgoAgBBAnRB0IkOaiAEQYAE/AoAAEHUCCgCAEECdEHgiRJqIARBgAT8CgAAQdQIKAIAQQJ0QfCJFmogBEGABPwKAABB1AhB1AgoAgAiAkGAAWoiAzYCACABIANIDQEgAkGA/wBIDQALCwJAIABFDQAgCEH//wBLDQBBgIABIAhrIAcgAUH//wBLGyECAkAgAEEBcUUNACACRQ0AIAhBAnRBoIkCaiEDIAIhBCACQQdxIgcEQANAIAMgBTYCACADQQRqIQMgBEEBayEEIAdBAWsiBw0ACwsgAkEBa0EHSQ0AA0AgAyAFNgIcIAMgBTYCGCADIAU2AhQgAyAFNgIQIAMgBTYCDCADIAU2AgggAyAFNgIEIAMgBTYCACADQSBqIQMgBEEIayIEDQALCwJAIABBAnFFDQAgAkUNACAIQQJ0QbCJBmohAyACIQQgAkEHcSIHBEADQCADIAU2AgAgA0EEaiEDIARBAWshBCAHQQFrIgcNAAsLIAJBAWtBB0kNAANAIAMgBTYCHCADIAU2AhggAyAFNgIUIAMgBTYCECADIAU2AgwgAyAFNgIIIAMgBTYCBCADIAU2AgAgA0EgaiEDIARBCGsiBA0ACwsCQCAAQQRxRQ0AIAJFDQAgCEECdEHAiQpqIQMgAiEEIAJBB3EiBwRAA0AgAyAFNgIAIANBBGohAyAEQQFrIQQgB0EBayIHDQALCyACQQFrQQdJDQADQCADIAU2AhwgAyAFNgIYIAMgBTYCFCADIAU2AhAgAyAFNgIMIAMgBTYCCCADIAU2AgQgAyAFNgIAIANBIGohAyAEQQhrIgQNAAsLAkAgAEEIcUUNACACRQ0AIAhBAnRB0IkOaiEDIAIhBCACQQdxIgcEQANAIAMgBTYCACADQQRqIQMgBEEBayEEIAdBAWsiBw0ACwsgAkEBa0EHSQ0AA0AgAyAFNgIcIAMgBTYCGCADIAU2AhQgAyAFNgIQIAMgBTYCDCADIAU2AgggAyAFNgIEIAMgBTYCACADQSBqIQMgBEEIayIEDQALCwJAIABBEHFFDQAgAkUNACAIQQJ0QeCJEmohAyACIQQgAkEHcSIHBEADQCADIAU2AgAgA0EEaiEDIARBAWshBCAHQQFrIgcNAAsLIAJBAWtBB0kNAANAIAMgBTYCHCADIAU2AhggAyAFNgIUIAMgBTYCECADIAU2AgwgAyAFNgIIIAMgBTYCBCADIAU2AgAgA0EgaiEDIARBCGsiBA0ACwsgAEEgcUUNACACRQ0AIAJBAWshByAIQQJ0QfCJFmohAyACQQdxIgQEQANAIAMgBTYCACADQQRqIQMgAkEBayECIARBAWsiBA0ACwsgB0EHSQ0AA0AgAyAFNgIcIAMgBTYCGCADIAU2AhQgAyAFNgIQIAMgBTYCDCADIAU2AgggAyAFNgIEIAMgBTYCACADQSBqIQMgAkEIayICDQALC0HcCEHcCCgCACAAcjYCACAGQQFqIgIgBi0AAEH/AHEiA0E/ayIAQT9LDQQaDAMLAkBB7AgoAgAiBEEBRgRAQfAIKAIAIgNBzAgoAgAiAUkNASADIAFwIQMMAQtB+AgoAgAhAkH0CCgCACEBAkACQCAEQQVHDQAgAUEBRw0AIAJB6QJODQQMAQsgAkHkAEoNA0H8CCgCAEHkAEoNA0GACSgCAEHkAEoNAwsCQCABRQ0AIAFBAkoNACACQfwIKAIAQYAJKAIAIAFBAnRBiAhqKAIAEQIAIQFB8AgoAgAiA0HMCCgCACICTwR/IAMgAnAFIAMLQQJ0QZAJaiABNgIAC0HwCCgCACIDQcwIKAIAIgFJDQAgAyABcCEDCyADQQJ0QZAJaigCACEFDAELIANB/QBxQSFHBEAgCCEBIAYhAgwECyAEQSNHDQQCQEHsCCgCACICQQFGBEBB8AgoAgAiAUHMCCgCACIASQ0BIAEgAHAhAQwBC0H4CCgCACEBQfQIKAIAIQACQAJAIAJBBUcNACAAQQFHDQAgAUHpAkgNAQwHCyABQeQASg0GQfwIKAIAQeQASg0GQYAJKAIAQeQASg0GCwJAIABFDQAgAEECSg0AIAFB/AgoAgBBgAkoAgAgAEECdEGICGooAgARAgAhAEHwCCgCACIBQcwIKAIAIgJPBH8gASACcAUgAQtBAnRBkAlqIAA2AgALQfAIKAIAIgFBzAgoAgAiAEkNACABIABwIQELIAFBAnRBkAlqKAIAIQUMBAsgCCEBIAYhAgtB1AgoAgAhBgNAAkAgASAGSA0AIAZB//8ASg0AIAZBAnQiBEGgiQJqIgZBoAgpAwA3AwAgBEGoiQJqQaAIKQMANwMAIARBsIkCakGgCCkDADcDACAEQbiJAmpBoAgpAwA3AwAgBEHAiQJqQaAIKQMANwMAIARByIkCakGgCCkDADcDACAEQdCJAmpBoAgpAwA3AwAgBEHYiQJqQaAIKQMANwMAIARB4IkCakGgCCkDADcDACAEQeiJAmpBoAgpAwA3AwAgBEHwiQJqQaAIKQMANwMAIARB+IkCakGgCCkDADcDACAEQYCKAmpBoAgpAwA3AwAgBEGIigJqQaAIKQMANwMAIARBkIoCakGgCCkDADcDACAEQZiKAmpBoAgpAwA3AwAgBEGgigJqQaAIKQMANwMAIARBqIoCakGgCCkDADcDACAEQbCKAmpBoAgpAwA3AwAgBEG4igJqQaAIKQMANwMAIARBwIoCakGgCCkDADcDACAEQciKAmpBoAgpAwA3AwAgBEHQigJqQaAIKQMANwMAIARB2IoCakGgCCkDADcDACAEQeCKAmpBoAgpAwA3AwAgBEHoigJqQaAIKQMANwMAIARB8IoCakGgCCkDADcDACAEQfiKAmpBoAgpAwA3AwAgBEGAiwJqQaAIKQMANwMAIARBiIsCakGgCCkDADcDACAEQZCLAmpBoAgpAwA3AwAgBEGYiwJqQaAIKQMANwMAIARBoIsCakGgCCkDADcDACAEQaiLAmpBoAgpAwA3AwAgBEGwiwJqQaAIKQMANwMAIARBuIsCakGgCCkDADcDACAEQcCLAmpBoAgpAwA3AwAgBEHIiwJqQaAIKQMANwMAIARB0IsCakGgCCkDADcDACAEQdiLAmpBoAgpAwA3AwAgBEHgiwJqQaAIKQMANwMAIARB6IsCakGgCCkDADcDACAEQfCLAmpBoAgpAwA3AwAgBEH4iwJqQaAIKQMANwMAIARBgIwCakGgCCkDADcDACAEQYiMAmpBoAgpAwA3AwAgBEGQjAJqQaAIKQMANwMAIARBmIwCakGgCCkDADcDACAEQaCMAmpBoAgpAwA3AwAgBEGojAJqQaAIKQMANwMAIARBsIwCakGgCCkDADcDACAEQbiMAmpBoAgpAwA3AwAgBEHAjAJqQaAIKQMANwMAIARByIwCakGgCCkDADcDACAEQdCMAmpBoAgpAwA3AwAgBEHYjAJqQaAIKQMANwMAIARB4IwCakGgCCkDADcDACAEQeiMAmpBoAgpAwA3AwAgBEHwjAJqQaAIKQMANwMAIARB+IwCakGgCCkDADcDACAEQYCNAmpBoAgpAwA3AwAgBEGIjQJqQaAIKQMANwMAIARBkI0CakGgCCkDADcDACAEQZiNAmpBoAgpAwA3AwAgBEGwiQZqIAZBgAT8CgAAQdQIKAIAQQJ0QcCJCmogBkGABPwKAABB1AgoAgBBAnRB0IkOaiAGQYAE/AoAAEHUCCgCAEECdEHgiRJqIAZBgAT8CgAAQdQIKAIAQQJ0QfCJFmogBkGABPwKAABB1AhB1AgoAgBBgAFqIgY2AgALIAFB//8ATQRAIABBAXEgAWxBAnRBoIkCaiAFNgIAIABBAXZBAXEgAWxBAnRBsIkGaiAFNgIAIABBAnZBAXEgAWxBAnRBwIkKaiAFNgIAIABBA3ZBAXEgAWxBAnRB0IkOaiAFNgIAIABBBHZBAXEgAWxBAnRB4IkSaiAFNgIAIABBBXYgAWxBAnRB8IkWaiAFNgIAQdQIKAIAIQYLIAFBAWohAUHcCEHcCCgCACAAcjYCACACLQAAIQAgAkEBaiIEIQIgAEH/AHEiA0E/ayIAQcAASQ0ACyAECyECQQAhBCACIQYgASEIIANB/QBxQSFGDQELIANBJGsOCgEDAwMDAwMDAwIDC0HsCEIBNwIADAQLQdgIIAFB2AgoAgAiACAAIAFIGyIAQYCAASAAQYCAAUgbNgIADAILQegIIAFB2AgoAgAiACAAIAFIGyIAQYCAASAAQYCAAUgbIgA2AgBB2AggADYCACAAQQRrEAAEQEHoCEEENgIAQdgIQQQ2AgBB0AhBATYCAA8LEAgMAQsCQCADQTtHDQBB7AgoAgAiAEEHSg0AQewIIABBAWo2AgAgAEECdEHwCGpBADYCAAsgAiEGIAQhAyABIQgMAQtBBCEIIAIhBiAEIQMLIAYgCUkNAAsLQeQIIAU2AgBB4AggAzYCAEHoCCAINgIAC9ELAgF+CH9B2AhCBDcDAEGojQJBoAgpAwAiADcDAEGgjQIgADcDAEGYjQIgADcDAEGQjQIgADcDAEGIjQIgADcDAEGAjQIgADcDAEH4jAIgADcDAEHwjAIgADcDAEHojAIgADcDAEHgjAIgADcDAEHYjAIgADcDAEHQjAIgADcDAEHIjAIgADcDAEHAjAIgADcDAEG4jAIgADcDAEGwjAIgADcDAEGojAIgADcDAEGgjAIgADcDAEGYjAIgADcDAEGQjAIgADcDAEGIjAIgADcDAEGAjAIgADcDAEH4iwIgADcDAEHwiwIgADcDAEHoiwIgADcDAEHgiwIgADcDAEHYiwIgADcDAEHQiwIgADcDAEHIiwIgADcDAEHAiwIgADcDAEG4iwIgADcDAEGwiwIgADcDAEGoiwIgADcDAEGgiwIgADcDAEGYiwIgADcDAEGQiwIgADcDAEGIiwIgADcDAEGAiwIgADcDAEH4igIgADcDAEHwigIgADcDAEHoigIgADcDAEHgigIgADcDAEHYigIgADcDAEHQigIgADcDAEHIigIgADcDAEHAigIgADcDAEG4igIgADcDAEGwigIgADcDAEGoigIgADcDAEGgigIgADcDAEGYigIgADcDAEGQigIgADcDAEGIigIgADcDAEGAigIgADcDAEH4iQIgADcDAEHwiQIgADcDAEHoiQIgADcDAEHgiQIgADcDAEHYiQIgADcDAEHQiQIgADcDAEHIiQIgADcDAEHAiQIgADcDAEG4iQIgADcDAEGwiQIgADcDAEGoCCgCACIEQf8AakGAAW0hCAJAIARBgQFIDQBBASEBIAhBAiAIQQJKG0EBayICQQFxIQMgBEGBAk4EQCACQX5xIQIDQCABQQl0IgdBEHJBoIkCakGwiQJBgAT8CgAAIAdBsI0CakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsLIANFDQAgAUEJdEEQckGgiQJqQbCJAkGABPwKAAALAkAgBEEBSA0AIAhBASAIQQFKGyIDQQFxIQUCQCADQQFrIgdFBEBBACEBDAELIANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQbCJBmpBsIkCQYAE/AoAACAGQZAEckGwiQZqQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACwsgBQRAIAFBCXRBEHJBsIkGakGwiQJBgAT8CgAACyAEQQFIDQAgA0EBcSEFIAcEfyADQf7///8HcSECQQAhAQNAIAFBCXQiBkEQckHAiQpqQbCJAkGABPwKAAAgBkGQBHJBwIkKakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsgAUEHdEEEcgVBBAshASAFBEAgAUECdEHAiQpqQbCJAkGABPwKAAALIARBAUgNACADQQFxIQUgBwR/IANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQdCJDmpBsIkCQYAE/AoAACAGQZAEckHQiQ5qQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACyABQQd0QQRyBUEECyEBIAUEQCABQQJ0QdCJDmpBsIkCQYAE/AoAAAsgBEEBSA0AIANBAXEhBSAHBH8gA0H+////B3EhAkEAIQEDQCABQQl0IgZBEHJB4IkSakGwiQJBgAT8CgAAIAZBkARyQeCJEmpBsIkCQYAE/AoAACABQQJqIQEgAkECayICDQALIAFBB3RBBHIFQQQLIQEgBQRAIAFBAnRB4IkSakGwiQJBgAT8CgAACyAEQQFIDQAgA0EBcSEEIAcEfyADQf7///8HcSECQQAhAQNAIAFBCXQiA0EQckHwiRZqQbCJAkGABPwKAAAgA0GQBHJB8IkWakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsgAUEHdEEEcgVBBAshASAERQ0AIAFBAnRB8IkWakGwiQJBgAT8CgAAC0HUCCAIQQd0QQRyNgIAC58TAgh/AX5B5AgoAgAhA0HgCCgCACECQegIKAIAIQcgAUGQiQFqIglB/wE6AAAgACABSARAIABBkIkBaiEIA0AgAiEEIAhBAWohAQJAIAgtAABB/wBxIgJBMGtBCUsEQCABIQgMAQtB7AgoAgBBAnRB7AhqIgUoAgAhAANAIAUgAiAAQQpsakEwayIANgIAIAEtAAAhAiABQQFqIgghASACQf8AcSICQTBrQQpJDQALCwJAAkACQAJAAkACQAJ/AkAgAkE/ayIAQT9NBEAgBEUNASAEQSFGBEBB8AgoAgAiAUEBIAEbIgQgB2ohAQJAIABFDQAgB0H//wBLDQBBgIABIAdrIAQgAUH//wBLGyEFAkAgAEEBcUUNACAHQQJ0QaCJAmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEECcUUNACAHQQJ0QbCJBmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEEEcUUNACAHQQJ0QcCJCmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEEIcUUNACAHQQJ0QdCJDmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEEQcUUNACAHQQJ0QeCJEmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLIABBIHFFDQAgBUEBayEEIAdBAnRB8IkWaiEAIAVBB3EiAgRAA0AgACADNgIAIABBBGohACAFQQFrIQUgAkEBayICDQALCyAEQQdJDQADQCAAIAM2AhwgACADNgIYIAAgAzYCFCAAIAM2AhAgACADNgIMIAAgAzYCCCAAIAM2AgQgACADNgIAIABBIGohACAFQQhrIgUNAAsLIAhBAWoiBSAILQAAQf8AcSICQT9rIgBBP00NAxoMBAsCQEHsCCgCACIFQQFGBEBB8AgoAgAiAUHMCCgCACIESQ0BIAEgBHAhAQwBC0H4CCgCACEEQfQIKAIAIQECQAJAIAVBBUcNACABQQFHDQAgBEHpAk4NBAwBCyAEQeQASg0DQfwIKAIAQeQASg0DQYAJKAIAQeQASg0DCwJAIAFFDQAgAUECSg0AIARB/AgoAgBBgAkoAgAgAUECdEGICGooAgARAgAhBEHwCCgCACIBQcwIKAIAIgVPBH8gASAFcAUgAQtBAnRBkAlqIAQ2AgALQfAIKAIAIgFBzAgoAgAiBEkNACABIARwIQELIAFBAnRBkAlqKAIAIQMMAQsgAkH9AHFBIUcEQCAHIQEgAiEADAQLIARBI0cNBAJAQewIKAIAIgRBAUYEQEHwCCgCACIBQcwIKAIAIgBJDQEgASAAcCEBDAELQfgIKAIAIQFB9AgoAgAhAAJAAkAgBEEFRw0AIABBAUcNACABQekCSA0BDAcLIAFB5ABKDQZB/AgoAgBB5ABKDQZBgAkoAgBB5ABKDQYLAkAgAEUNACAAQQJKDQAgAUH8CCgCAEGACSgCACAAQQJ0QYgIaigCABECACEAQfAIKAIAIgFBzAgoAgAiBE8EfyABIARwBSABC0ECdEGQCWogADYCAAtB8AgoAgAiAUHMCCgCACIASQ0AIAEgAHAhAQsgAUECdEGQCWooAgAhAwwECyAHIQEgCAshBQNAIAFB//8ATQRAIABBAXEgAWxBAnRBoIkCaiADNgIAIABBAXZBAXEgAWxBAnRBsIkGaiADNgIAIABBAnZBAXEgAWxBAnRBwIkKaiADNgIAIABBA3ZBAXEgAWxBAnRB0IkOaiADNgIAIABBBHZBAXEgAWxBAnRB4IkSaiADNgIAIABBBXYgAWxBAnRB8IkWaiADNgIACyABQQFqIQEgBS0AACEAIAVBAWoiBCEFIABB/wBxIgJBP2siAEHAAEkNAAsgBCEFC0EAIQQgBSEIIAEhByACIQAgAkH9AHFBIUYNAQtBBCEHIAQhAiAAQSRrDgoDAgICAgICAgIBAgtB7AhCATcCAAwCC0GoCCgCAEEEaxAABEBB0AhBATYCAA8LAkBBqAgoAgAiBkEFSA0AQaAIKQMAIQogBkEDa0EBdiIBQQdxIQJBACEAIAFBAWtBB08EQCABQfj///8HcSEFA0AgAEEDdCIBQbCJAmogCjcDACABQQhyQbCJAmogCjcDACABQRByQbCJAmogCjcDACABQRhyQbCJAmogCjcDACABQSByQbCJAmogCjcDACABQShyQbCJAmogCjcDACABQTByQbCJAmogCjcDACABQThyQbCJAmogCjcDACAAQQhqIQAgBUEIayIFDQALCyACRQ0AA0AgAEEDdEGwiQJqIAo3AwAgAEEBaiEAIAJBAWsiAg0ACwtBwIkGQbCJAiAGQQJ0IgD8CgAAQdCJCkGwiQIgAPwKAABB4IkOQbCJAiAA/AoAAEHwiRJBsIkCIAD8CgAAQYCKFkGwiQIgAPwKAAAgBCECDAELAkAgAEE7Rw0AQewIKAIAIgBBB0oNAEHsCCAAQQFqNgIAIABBAnRB8AhqQQA2AgALIAEhBwsgCCAJSQ0ACwtB5AggAzYCAEHgCCACNgIAQegIIAc2AgAL4gcCBX8BfgJAQdAIAn8CQAJAIAAgAU4NACABQZCJAWohBiAAQZCJAWohBQNAIAUtAAAiA0H/AHEhAgJAAkACQAJAAkACQAJAQeAIKAIAIgRBIkcEQCAEDQcgAkEiRgRAQewIQgE3AgBB4AhBIjYCAAwICyACQT9rQcAASQ0GIANBIWsiAkEMTQ0BDAULAkAgAkEwayIEQQlNBEBB7AgoAgBBAnRB7AhqIgIgBCACKAIAQQpsajYCAAwBC0HsCCgCACEEIAJBO0YEQCAEQQdKDQFB7AggBEEBajYCACAEQQJ0QfAIakEANgIADAELIARBBEYEQEHECEECNgIAQbAIQfAIKQMANwMAQbgIQfgIKAIAIgI2AgBBvAhB/AgoAgAiBDYCAEHICEECQQFBwAgoAgAiAxs2AgBBrAggBEEAIAMbNgIAQagIIAJBgIABIAJBgIABSBtBBGpBACADGzYCAEHgCEEANgIADAoLIAJBP2tBwABJDQQLIANBIWsiAkEMTQ0BDAILQQEgAnRBjSBxRQ0DDAQLQQEgAnRBjSBxDQELIANBoQFrIgJBDEsNA0EBIAJ0QY0gcUUNAwtBxAhCgYCAgBA3AgBBsAhB8AgoAgBBAEHsCCgCACICQQBKGzYCAEG0CEH0CCgCAEEAIAJBAUobNgIAQbgIQfgIKAIAQQAgAkECShs2AgBB4AhBADYCAEG8CEEANgIADAQLIANBoQFrIgJBDEsNAUEBIAJ0QY0gcUUNAQtBxAhCgYCAgBA3AgBBsAhCADcDAEG4CEIANwMADAMLIAVBAWoiBSAGSQ0ACwsCQEHICCgCAA4DAwEAAQsCQEGoCCgCACIFQQVIDQBBoAgpAwAhByAFQQNrQQF2IgNBB3EhBEEAIQIgA0EBa0EHTwRAIANB+P///wdxIQYDQCACQQN0IgNBsIkCaiAHNwMAIANBCHJBsIkCaiAHNwMAIANBEHJBsIkCaiAHNwMAIANBGHJBsIkCaiAHNwMAIANBIHJBsIkCaiAHNwMAIANBKHJBsIkCaiAHNwMAIANBMHJBsIkCaiAHNwMAIANBOHJBsIkCaiAHNwMAIAJBCGohAiAGQQhrIgYNAAsLIARFDQADQCACQQN0QbCJAmogBzcDACACQQFqIQIgBEEBayIEDQALC0HAiQZBsIkCIAVBAnQiA/wKAABB0IkKQbCJAiAD/AoAAEHgiQ5BsIkCIAP8CgAAQfCJEkGwiQIgA/wKAABBgIoWQbCJAiAD/AoAAEECDAELEAhByAgoAgALEAEiAjYCACACDQAgACABQcgIKAIAQQJ0QYAIaigCABEBAAsLdABB6AhBBDYCAEHkCCAANgIAQewIQgE3AgBBxAhCADcCAEHACCADNgIAQdwIQgA3AgBBqAhCADcDAEGwCEIANwMAQbgIQgA3AwBBzAggAkGAICACQYAgSRs2AgBBoAggAa1CgYCAgBB+NwMAQdAIQQA2AgALIwBB0AgoAgBFBEAgACABQcgIKAIAQQJ0QYAIaigCABEBAAsLWgECfwJAAkACQEHICCgCAEEBaw4CAAECC0HYCEHoCCgCACIAQdgIKAIAIgEgACABShsiAEGAgAEgAEGAgAFIGyIANgIAIABBBGsPC0GoCCgCAEEEayEACyAAC0IBAX8Cf0EGQdwIKAIAIgBBIHENABpBBSAAQRBxDQAaQQQgAEEIcQ0AGkEDIABBBHENABpBAiAAQQFxIABBAnEbCwu9BQEFfQJ/IAJFBEAgAUH/AWxBMmpB5ABtIgBBCHQgAHIgAEEQdHIMAQsgArJDAADIQpUhBiAAQfABarJDAAC0Q5UhBQJ9IAGyQwAAyEKVIgNDAAAAP10EQCADIAZDAACAP5KUDAELIAYgA0MAAIA/IAaTlJILIQcgAyADkiEGAkAgBUOrqqo+kiIEQwAAAABdBEAgBEMAAIA/kiEEDAELIARDAACAP15FDQAgBEMAAIC/kiEECyAGIAeTIQMgBUMAAAAAXSEAAn8CfSADIAcgA5NDAADAQJQgBJSSIARDq6oqPl0NABogByAEQwAAAD9dDQAaIAMgBEOrqio/XUUNABogAyAHIAOTIARDAADAwJRDAACAQJKUkgtDAAB/Q5RDAAAAP5IiBkMAAIBPXSAGQwAAAABgcQRAIAapDAELQQALIQECQCAABEAgBUMAAIA/kiEEDAELIAUiBEMAAIA/XkUNACAFQwAAgL+SIQQLIAVDq6qqvpIiBUMAAAAAXSECAn8CfSADIAcgA5NDAADAQJQgBJSSIARDq6oqPl0NABogByAEQwAAAD9dDQAaIAMgBEOrqio/XUUNABogAyAHIAOTIARDAADAwJRDAACAQJKUkgtDAAB/Q5RDAAAAP5IiBkMAAIBPXSAGQwAAAABgcQRAIAapDAELQQALIQACQCACBEAgBUMAAIA/kiEFDAELIAVDAACAP15FDQAgBUMAAIC/kiEFCwJAIAVDq6oqPl0EQCADIAcgA5NDAADAQJQgBZSSIQcMAQsgBUMAAAA/XQ0AIAVDq6oqP11FBEAgAyEHDAELIAMgByADkyAFQwAAwMCUQwAAgECSlJIhBwsgAEEIdAJ/IAdDAAB/Q5RDAAAAP5IiBkMAAIBPXSAGQwAAAABgcQRAIAapDAELQQALQRB0ciABcgtBgICAeHILNwAgAEH/AWxBMmpB5ABtIAFB/wFsQTJqQeQAbUEIdHIgAkH/AWxBMmpB5ABtQRB0ckGAgIB4cgsEACMACwYAIAAkAAsQACMAIABrQXBxIgAkACAACwsYAQBBgAgLEQEAAAACAAAAAwAAAAQAAAAF"}});var We=x(b=>{"use strict";Object.defineProperty(b,"__esModule",{value:!0});b.decodeAsync=b.decode=b.Decoder=b.DecoderAsync=void 0;var O=Z(),Q=Ye();function St(s){if(typeof Buffer<"u")return Buffer.from(s,"base64");let e=atob(s),t=new Uint8Array(e.length);for(let i=0;i1,this.modeHandler=e=>1}handle_band(e){return this.bandHandler(e)}mode_parsed(e){return this.modeHandler(e)}},Lt={memoryLimit:2048*65536,sixelColor:O.DEFAULT_FOREGROUND,fillColor:O.DEFAULT_BACKGROUND,palette:O.PALETTE_VT340_COLOR,paletteLimit:Q.LIMITS.PALETTE_SIZE,truncate:!0};function ze(s){let e=new Le,t={env:{handle_band:e.handle_band.bind(e),mode_parsed:e.mode_parsed.bind(e)}};return WebAssembly.instantiate(J||qe,t).then(i=>(J=J||i.module,new P(s,i.instance||i,e)))}b.DecoderAsync=ze;var P=class{constructor(e,t,i){if(this._PIXEL_OFFSET=Q.LIMITS.MAX_WIDTH+4,this._canvas=re,this._bandWidths=[],this._maxWidth=0,this._minWidth=Q.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0,this._opts=Object.assign({},Lt,e),this._opts.paletteLimit>Q.LIMITS.PALETTE_SIZE)throw new Error(`DecoderOptions.paletteLimit must not exceed ${Q.LIMITS.PALETTE_SIZE}`);if(t)i.bandHandler=this._handle_band.bind(this),i.modeHandler=this._initCanvas.bind(this);else{let A=J||(J=new WebAssembly.Module(qe));t=new WebAssembly.Instance(A,{env:{handle_band:this._handle_band.bind(this),mode_parsed:this._initCanvas.bind(this)}})}this._instance=t,this._wasm=this._instance.exports,this._chunk=new Uint8Array(this._wasm.memory.buffer,this._wasm.get_chunk_address(),Q.LIMITS.CHUNK_SIZE),this._states=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_state_address(),12),this._palette=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_palette_address(),Q.LIMITS.PALETTE_SIZE),this._palette.set(this._opts.palette),this._pSrc=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_p0_address()),this._wasm.init(O.DEFAULT_FOREGROUND,0,this._opts.paletteLimit,0)}get _fillColor(){return this._states[0]}get _truncate(){return this._states[8]}get _rasterWidth(){return this._states[6]}get _rasterHeight(){return this._states[7]}get _width(){return this._states[2]?this._states[2]-4:0}get _height(){return this._states[3]}get _level(){return this._states[9]}get _mode(){return this._states[10]}get _paletteLimit(){return this._states[11]}_initCanvas(e){if(e===2){let t=this.width*this.height;if(t>this._canvas.length){if(this._opts.memoryLimit&&t*4>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(t)}this._maxWidth=this._width}else if(e===1)if(this._level===2){let t=Math.min(this._rasterWidth,Q.LIMITS.MAX_WIDTH)*this._rasterHeight;if(t>this._canvas.length){if(this._opts.memoryLimit&&t*4>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(t)}}else this._canvas.length<65536&&(this._canvas=new Uint32Array(65536));return 0}_realloc(e,t){let i=e+t;if(i>this._canvas.length){if(this._opts.memoryLimit&&i*4>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");let A=new Uint32Array(Math.ceil(i/65536)*65536);A.set(this._canvas),this._canvas=A}}_handle_band(e){let t=this._PIXEL_OFFSET,i=this._lastOffset;if(this._mode===2){let A=this.height-this._currentHeight,r=0;for(;r<6&&A>0;)this._canvas.set(this._pSrc.subarray(t*r,t*r+e),i+e*r),r++,A--;this._lastOffset+=e*r,this._currentHeight+=r}else if(this._mode===1){this._realloc(i,e*6),this._maxWidth=Math.max(this._maxWidth,e),this._minWidth=Math.min(this._minWidth,e);for(let A=0;A<6;++A)this._canvas.set(this._pSrc.subarray(t*A,t*A+e),i+e*A);this._bandWidths.push(e),this._lastOffset+=e*6,this._currentHeight+=6}return 0}get width(){return this._mode!==1?this._width:Math.max(this._maxWidth,this._wasm.current_width())}get height(){return this._mode!==1?this._height:this._wasm.current_width()?this._bandWidths.length*6+this._wasm.current_height():this._bandWidths.length*6}get palette(){return this._palette.subarray(0,this._paletteLimit)}get memoryUsage(){return this._canvas.byteLength+this._wasm.memory.buffer.byteLength+8*this._bandWidths.length}get properties(){return{width:this.width,height:this.height,mode:this._mode,level:this._level,truncate:!!this._truncate,paletteLimit:this._paletteLimit,fillColor:this._fillColor,memUsage:this.memoryUsage,rasterAttributes:{numerator:this._states[4],denominator:this._states[5],width:this._rasterWidth,height:this._rasterHeight}}}init(e=this._opts.fillColor,t=this._opts.palette,i=this._opts.paletteLimit,A=this._opts.truncate){this._wasm.init(this._opts.sixelColor,e,i,A?1:0),t&&this._palette.set(t.subarray(0,Q.LIMITS.PALETTE_SIZE)),this._bandWidths.length=0,this._maxWidth=0,this._minWidth=Q.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0}decode(e,t=0,i=e.length){let A=t;for(;A0){let i=this._PIXEL_OFFSET,A=this._lastOffset,r=0;for(;r<6&&t>0;)this._canvas.set(this._pSrc.subarray(i*r,i*r+e),A+e*r),r++,t--;t&&this._canvas.fill(this._fillColor,A+e*r)}return this._canvas.subarray(0,this.width*this.height)}if(this._mode===1){if(this._minWidth===this._maxWidth){let r=!1;if(e)if(e!==this._minWidth)r=!0;else{let n=this._PIXEL_OFFSET,a=this._lastOffset;this._realloc(a,e*6);for(let o=0;o<6;++o)this._canvas.set(this._pSrc.subarray(n*o,n*o+e),a+e*o)}if(!r)return this._canvas.subarray(0,this.width*this.height)}let t=new Uint32Array(this.width*this.height);t.fill(this._fillColor);let i=0,A=0;for(let r=0;r{if(this._disposed)return H(()=>{});let A={fn:e,thisArgs:t};this._listeners=this._listeners.slice(),this._listeners.push(A);let r=H(()=>{let n=this._listeners.indexOf(A);n!==-1&&(this._listeners=this._listeners.slice(),this._listeners.splice(n,1))});return i&&(Array.isArray(i)?i.push(r):i.add(r)),r},this._event)}fire(e){if(this._disposed||!this._listeners.length)return;if(this._listeners.length===1){this._listeners[0].fn.call(this._listeners[0].thisArgs,e);return}let t=this._listeners;for(let i=0,A=t.length;i{function s(r,n){return r(a=>n.fire(a))}A.forward=s;function e(r,n){return(a,o,h)=>r(g=>a.call(o,n(g)),void 0,h)}A.map=e;function t(...r){return(n,a,o)=>{let h=new N;for(let g of r)h.add(g(d=>n.call(a,d)));return o&&(Array.isArray(o)?o.push(h):o.add(h)),h}}A.any=t;function i(r,n,a){return n(a),r(o=>n(o))}A.runAndSubscribe=i})(ht||={});var ce=M(Z());var D=class s extends k{constructor(t){super();this._terminal=t;this._layers=new Map;this._optionsRefresh=this._register(new W);this._oldOpen=this._terminal._core.open,this._terminal._core.open=i=>{this._oldOpen?.call(this._terminal._core,i),this._open()},this._terminal._core.screenElement&&this._open(),this._optionsRefresh.value=this._terminal._core.optionsService.onOptionChange(i=>{i==="fontSize"&&(this.rescaleCanvas(),this._renderService?.refreshRows(0,this._terminal.rows))}),this._register(H(()=>{this.removeLayerFromDom(),this.removeLayerFromDom("bottom"),this._terminal._core&&this._oldOpen&&(this._terminal._core.open=this._oldOpen,this._oldOpen=void 0),this._renderService&&this._oldSetRenderer&&(this._renderService.setRenderer=this._oldSetRenderer,this._oldSetRenderer=void 0),this._renderService=void 0,this._layers.clear(),this._placeholderBitmap?.close(),this._placeholderBitmap=void 0,this._placeholder=void 0}))}get canvas(){return this._layers.get("top")?.canvas}static createCanvas(t,i,A){let r=(t??document).createElement("canvas");return r.width=i|0,r.height=A|0,r}static createImageData(t,i,A,r){if(typeof ImageData!="function"){let n=t.createImageData(i,A);return r&&n.data.set(new Uint8ClampedArray(r,0,i*A*4)),n}return r?new ImageData(new Uint8ClampedArray(r,0,i*A*4),i,A):new ImageData(i,A)}static createImageBitmap(t){return typeof createImageBitmap!="function"?Promise.resolve(void 0):createImageBitmap(t)}showPlaceholder(t){t?!this._placeholder&&this.cellSize.height!==-1&&this._createPlaceHolder(Math.max(this.cellSize.height+1,24)):(this._placeholderBitmap?.close(),this._placeholderBitmap=void 0,this._placeholder=void 0),this._renderService?.refreshRows(0,this._terminal.rows)}get dimensions(){return this._terminal.dimensions}get cellSize(){return{width:this.dimensions?.css.cell.width||-1,height:this.dimensions?.css.cell.height||-1}}clearLines(t,i,A){let r=t*(this.dimensions?.css.cell.height||0),n=this.dimensions?.css.canvas.width||0,a=(i+1-t)*(this.dimensions?.css.cell.height||0);(!A||A==="top")&&this._layers.get("top")?.clearRect(0,r,n,a),(!A||A==="bottom")&&this._layers.get("bottom")?.clearRect(0,r,n,a)}clearAll(t){if(!t||t==="top"){let i=this._layers.get("top");i?.clearRect(0,0,i.canvas.width,i.canvas.height)}if(!t||t==="bottom"){let i=this._layers.get("bottom");i?.clearRect(0,0,i.canvas.width,i.canvas.height)}}draw(t,i,A,r,n=1){let a=this._layers.get(t.layer);if(!a)return;let{width:o,height:h}=this.cellSize;if(o===-1||h===-1)return;this._rescaleImage(t,o,h);let g=t.actual,{width:d,height:I}=t.actualCellSize,l=Math.ceil(g.width/d),c=i%l*d,u=Math.floor(i/l)*I,E=A*o,C=r*h,p=n*d+c>g.width?g.width-c:n*d,B=u+I>g.height?g.height-u:I;a.drawImage(g,Math.floor(c),Math.floor(u),Math.ceil(p),Math.ceil(B),Math.floor(E),Math.floor(C),Math.ceil(p*o/d),Math.ceil(B*h/I))}extractTile(t,i){let{width:A,height:r}=this.cellSize;if(A===-1||r===-1)return;this._rescaleImage(t,A,r);let n=t.actual,{width:a,height:o}=t.actualCellSize,h=Math.ceil(n.width/a),g=i%h*a,d=Math.floor(i/h)*o,I=a+g>n.width?n.width-g:a,l=d+o>n.height?n.height-d:o,c=s.createCanvas(this.document,Math.ceil(I*A/a),Math.ceil(l*r/o)),u=c.getContext("2d");if(u)return u.drawImage(n,Math.floor(g),Math.floor(d),Math.floor(I),Math.floor(l),0,0,c.width,c.height),c}drawPlaceholder(t,i,A=1){let r=this._layers.get("top");if(r){let{width:n,height:a}=this.cellSize;if(n===-1||a===-1||(this._placeholder?a>=this._placeholder.height&&this._createPlaceHolder(a+1):this._createPlaceHolder(Math.max(a+1,24)),!this._placeholder))return;r.drawImage(this._placeholderBitmap??this._placeholder,t*n,i*a%2?0:1,n*A,a,t*n,i*a,n*A,a)}}rescaleCanvas(){let t=this.dimensions?.css.canvas.width||0,i=this.dimensions?.css.canvas.height||0;for(let A of this._layers.values())(A.canvas.width!==t||A.canvas.height!==i)&&(A.canvas.width=t,A.canvas.height=i)}_rescaleImage(t,i,A){if(i===t.actualCellSize.width&&A===t.actualCellSize.height)return;let{width:r,height:n}=t.origCellSize;if(i===r&&A===n){t.actual=t.orig,t.actualCellSize.width=r,t.actualCellSize.height=n;return}let a=Math.ceil(t.orig.width*i/r),o=Math.ceil(t.orig.height*A/n);if(a*o>t.orig.width*t.orig.height){t.actual=t.orig,t.actualCellSize.width=r,t.actualCellSize.height=n;return}let h=s.createCanvas(this.document,a,o),g=h.getContext("2d");g&&(g.drawImage(t.orig,0,0,h.width,h.height),t.actual=h,t.actualCellSize.width=i,t.actualCellSize.height=A)}_open(){this._renderService=this._terminal._core._renderService,this._oldSetRenderer=this._renderService.setRenderer.bind(this._renderService),this._renderService.setRenderer=t=>{for(let i of[...this._layers.keys()])this.removeLayerFromDom(i);this._oldSetRenderer?.call(this._renderService,t)}}insertLayerToDom(t="top"){if(!this.document||!this._terminal._core.screenElement){console.warn("image addon: cannot insert output canvas to DOM, missing document or screenElement");return}if(this._layers.has(t))return;let i=s.createCanvas(this.document,this.dimensions?.css.canvas.width||0,this.dimensions?.css.canvas.height||0);i.classList.add(`xterm-image-layer-${t}`);let A=this._terminal._core.screenElement;A.style.isolation="isolate",t==="bottom"?(i.style.zIndex="-1",A.insertBefore(i,A.firstChild)):(i.style.zIndex="0",A.appendChild(i));let r=i.getContext("2d",{alpha:!0});if(!r){i.remove();return}this._layers.set(t,r),this.clearAll(t)}removeLayerFromDom(t="top"){let i=this._layers.get(t);i&&(i.canvas.remove(),this._layers.delete(t))}hasLayer(t){return this._layers.has(t)}_createPlaceHolder(t=24){this._placeholderBitmap?.close(),this._placeholderBitmap=void 0;let i=32,A=s.createCanvas(this.document,i,t),r=A.getContext("2d",{alpha:!1});if(!r)return;let n=s.createImageData(r,i,t),a=new Uint32Array(n.data.buffer),o=(0,ce.toRGBA8888)(0,0,0),h=(0,ce.toRGBA8888)(255,255,255);a.fill(o);for(let l=0;l{this._placeholder!==I?l?.close():this._placeholderBitmap=l}).catch(()=>{})}get document(){return this._terminal._core._coreBrowserService?.window.document}};var w={width:7,height:14},G=class s{constructor(e=0,t=0,i=-1,A=-1){this.imageId=i;this.tileId=A;this._ext=0;this._urlId=0;this._ext=e,this._urlId=t}get ext(){return this._urlId?this._ext&-469762049|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(this._ext&469762048)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return this._ext&67108863}set underlineColor(e){this._ext&=-67108864,this._ext|=e&67108863}get underlineVariantOffset(){let e=(this._ext&3758096384)>>29;return e<0?e^4294967288:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}get urlId(){return this._urlId}set urlId(e){this._urlId=e}clone(){return new s(this._ext,this._urlId,this.imageId,this.tileId)}isEmpty(){return this.underlineStyle===0&&this._urlId===0&&this.imageId===-1}},F=new G,j=class{constructor(e,t,i){this._terminal=e;this._renderer=t;this._opts=i;this._images=new Map;this._lastId=0;this._lowestId=0;this._fullyCleared=!1;this._needsFullClear=!1;this._pixelLimit=25e5;try{this.setLimit(this._opts.storageLimit)}catch(A){A instanceof Error&&console.error(A.message),console.warn(`storageLimit is set to ${this.getLimit()} MB`)}this._viewportMetrics={cols:this._terminal.cols,rows:this._terminal.rows}}dispose(){this.reset()}reset(){for(let e of this._images.values())e.marker?.dispose();this._images.clear(),this._renderer.clearAll()}getLimit(){return this._pixelLimit*4/1e6}setLimit(e){if(e<.5||e>1e3)throw RangeError("invalid storageLimit, should be at least 0.5 MB and not exceed 1G");this._pixelLimit=e/4*1e6>>>0,this._evictOldest(0)}getUsage(){return this._getStoredPixels()*4/1e6}_getStoredPixels(){let e=0;for(let t of this._images.values())t.orig&&(e+=t.orig.width*t.orig.height,t.actual&&t.actual!==t.orig&&(e+=t.actual.width*t.actual.height));return e}_delImg(e){let t=this._images.get(e);t&&(this._images.delete(e),window.ImageBitmap&&t.orig instanceof ImageBitmap&&t.orig.close(),this.onImageDeleted?.(e))}wipeAlternate(){let e=[];for(let[t,i]of this._images.entries())i.bufferType==="alternate"&&(i.marker?.dispose(),e.push(t));for(let t of e)this._delImg(t);this._needsFullClear=!0,this._fullyCleared=!1}deleteImage(e){let t=this._images.get(e);t&&(t.marker?.dispose(),this._delImg(e))}addImage(e,t){this._evictOldest(e.width*e.height);let i=this._renderer.cellSize;(i.width===-1||i.height===-1)&&(i=w);let A=Math.ceil(e.width/i.width),r=Math.ceil(e.height/i.height),n=++this._lastId,a=this._terminal._core.buffer,o=this._terminal.cols,h=this._terminal.rows,g=a.x,d=a.y,I=g,l=0;t.scrolling||(a.x=0,a.y=0,I=0),this._terminal._core._inputHandler._dirtyRowTracker.markDirty(a.y);for(let C=0;C=o);++B)this._writeToCell(p,I+B,n,C*A+B),l++;if(t.scrolling)C=h)break;a.x=I}this._terminal._core._inputHandler._dirtyRowTracker.markDirty(a.y),t.scrolling?t.cursorPos==="iip"?a.x=Math.min(I+A,o):a.x=I:(a.x=g,a.y=d);let c=[];for(let[C,p]of this._images.entries())p.tileCount<1&&(p.marker?.dispose(),c.push(C));for(let C of c)this._delImg(C);let u=this._terminal.registerMarker(0);u?.onDispose(()=>{this._images.get(n)&&this._delImg(n)}),this._terminal.buffer.active.type==="alternate"&&this._evictOnAlternate();let E={orig:e,origCellSize:i,actual:e,actualCellSize:{...i},marker:u||void 0,tileCount:l,bufferType:this._terminal.buffer.active.type,layer:t.layer,zIndex:t.zIndex};return this._images.set(n,E),this.onImageAdded?.(),n}render(e){let t=!1,i=!1;for(let g of this._images.values())if(g.layer==="bottom"?i=!0:t=!0,t&&i)break;if(t&&!this._renderer.hasLayer("top")&&(this._renderer.insertLayerToDom("top"),!this._renderer.hasLayer("top")))return;if(i&&!this._renderer.hasLayer("bottom")&&this._renderer.insertLayerToDom("bottom"),this._renderer.rescaleCanvas(),!this._images.size){this._fullyCleared||(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1),this._renderer.hasLayer("top")&&this._renderer.removeLayerFromDom("top"),this._renderer.hasLayer("bottom")&&this._renderer.removeLayerFromDom("bottom");return}!t&&this._renderer.hasLayer("top")&&(this._renderer.clearAll("top"),this._renderer.removeLayerFromDom("top")),!i&&this._renderer.hasLayer("bottom")&&(this._renderer.clearAll("bottom"),this._renderer.removeLayerFromDom("bottom")),this._needsFullClear&&(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1);let{start:A,end:r}=e,n=this._terminal._core.buffer,a=this._terminal._core.cols;this._renderer.clearLines(A,r);let o=[],h=[];for(let g=A;g<=r;++g){let d=n.lines.get(g+n.ydisp);if(!d)return;for(let I=0;Ig.imgSpec.zIndex-d.imgSpec.zIndex);for(let g of h)this._renderer.drawPlaceholder(g.col,g.row,g.count);for(let g of o)this._renderer.draw(g.imgSpec,g.tileId,g.col,g.row,g.count)}viewportResize(e){if(!this._images.size){this._viewportMetrics=e;return}if(this._viewportMetrics.cols>=e.cols){this._viewportMetrics=e;return}let t=this._terminal._core.buffer,i=t.lines.length,A=this._viewportMetrics.cols-1;for(let r=0;r=g)continue;let d=!1;for(let c=A+1;c>e.cols;++c)if(n._data[c*3+0]&4194303){d=!0;break}if(d)continue;let I=Math.min(e.cols,g-a.tileId%g+A),l=a.tileId;for(let c=A+1;c57)throw new Error("illegal char");e=e*10+s[t]-48}return e}function He(s){let e=V(s);if(!e.match(/^((auto)|(\d+?((px)|(%)){0,1}))$/))throw new Error("illegal size");return e}function bt(s){if(typeof Buffer<"u")return Buffer.from(V(s),"base64").toString();let e=atob(V(s)),t=new Uint8Array(e.length);for(let i=0;i14)return-1;for(let a=t;a=be)return this._a();n[r++]=o}break;case 58:return A===3&&!this._storeValue(r)?this._a():(this.state=4,a+1);default:if(r>=be)return this._a();n[r++]=o}}return this.state=A,this._position=r,-2}_a(){return this.fields.type=0,this.state=1,-1}_storeKey(e){let t=V(this._buffer.subarray(0,e));return t?(this._key=t,this.fields[t]=null,!0):!1}_storeValue(e){if(this._key){try{let t=this._buffer.slice(0,e);this.fields[this._key]=Fe[this._key]?Fe[this._key](t):t}catch{return!1}return!0}return!1}};var L={mime:"unsupported",width:0,height:0};function ee(s){if(s.length<32)return L;let e=new Uint32Array(s.buffer,s.byteOffset,8);if(e[0]===1196314761&&e[1]===169478669&&e[3]===1380206665)return{mime:"image/png",width:s[16]<<24|s[17]<<16|s[18]<<8|s[19],height:s[20]<<24|s[21]<<16|s[22]<<8|s[23]};if(s[0]===255&&s[1]===216&&s[2]===255){let[t,i]=yt(s);return{mime:"image/jpeg",width:t,height:i}}if(e[0]===944130375&&(s[4]===55||s[4]===57)&&s[5]===97)return{mime:"image/gif",width:s[7]<<8|s[6],height:s[9]<<8|s[8]};if(e[0]===1718185841)return{mime:"image/qoi",width:s[4]<<24|s[5]<<16|s[6]<<8|s[7],height:s[8]<<24|s[9]<<16|s[10]<<8|s[11]};if(e[0]===1179011410&&e[2]===1346520407&&(e[3]&16777215)===3690582){switch(s[15]){case 88:return{mime:"image/webp",width:(s[24]|s[25]<<8|s[26]<<16)+1,height:(s[27]|s[28]<<8|s[29]<<16)+1};case 76:if(s[20]!==47)return L;let t=s[21]|s[22]<<8|s[23]<<16|s[24]<<24;return{mime:"image/webp",width:(t&16383)+1,height:(t>>>14&16383)+1};case 32:return s[23]!==157||s[24]!==1||s[25]!==42?L:{mime:"image/webp",width:(s[26]|s[27]<<8)&16383,height:(s[28]|s[29]<<8)&16383}}return L}if(e[1]===1887007846&&(e[2]===1718187617||e[2]===1936291425)){let t=-1,i=Math.min(s.length-16,1024);for(let A=8;A0&&r>0)return{mime:"image/avif",width:A,height:r}}return L}return L}function yt(s){let e=s.length,t=4,i=s[t]<<8|s[t+1];for(;;){if(t+=i,t>=e)return[0,0];if(s[t]!==255)return[0,0];if(s[t+1]===192||s[t+1]===194)return t+80){if(this._hp.fields.type===1){if(this._isMultipart&&(this._isMultipart=!1,this._abortMulti=!1,this._dec.release()),this._header=Object.assign({},ye,this._hp.fields),!this._header.inline){this._aborted=!0;return}this._dec.init()}else if(this._abortMulti){this._aborted=!0;return}this._dec.put(e.subarray(A,i))!==0&&(this._dec.release(),this._aborted=!0,this._isMultipart&&(this._abortMulti=!0))}}}end(e){if(this._aborted||this._hp.state!==4&&this._hp.end())return!0;let t=this._hp.fields.type;if(t===3)return!0;if(t===5){let h=w.width,g=w.height;this._renderer.dimensions&&(h=this._renderer.dimensions.css.canvas.width/this._coreTerminal.cols,g=this._renderer.dimensions.css.canvas.height/this._coreTerminal.rows);let d=this._coreTerminal._core._coreBrowserService?.dpr??1,I=`\x1B]1337;ReportCellSize=${g.toFixed(3)};${h.toFixed(3)};${d.toFixed(3)}\x1B\\`;return this._coreTerminal.input(I,!1),!0}if(t===2)return this._header=Object.assign({},ye,this._hp.fields),this._isMultipart=!0,this._abortMulti=!1,this._dec.release(),this._dec.init(),!0;if(t===4&&(!this._isMultipart||(this._isMultipart=!1,this._abortMulti||this._header.type!==2)))return!0;let i=0,A=0,r,n=L;if((r=e)&&((r=!this._dec.end())?(n=ee(this._dec.data8),(r=n.mime!=="unsupported")?(i=n.width,A=n.height,(r=i&&A&&i*Ao!==this._generation?(h.close(),!0):(this._storage.addImage(h),!0)).catch(h=>(console.warn(`IIP: decoding error ${n.mime} ${n.width}x${n.height}`,h),!0))}_resize(e,t){let i=this._renderer.dimensions?.css.cell.width||w.width,A=this._renderer.dimensions?.css.cell.height||w.height,r=this._renderer.dimensions?.css.canvas.width||i*this._coreTerminal.cols,n=this._renderer.dimensions?.css.canvas.height||A*this._coreTerminal.rows,a=this._dim(this._header.width,r,i),o=this._dim(this._header.height,n,A);if(!a&&!o){let h=r/e,g=(n-A)/t,d=Math.min(h,g);return d<1?[e*d,t*d]:[e,t]}return a?this._header.preserveAspectRatio||!a||!o?[a,t*a/e]:[a,o]:[e*o/t,o]}_dim(e,t,i){return e==="auto"?0:e.endsWith("%")?parseInt(e.slice(0,-1),10)*t/100:e.endsWith("px")?parseInt(e.slice(0,-2),10):parseInt(e,10)*i}};var Ke=M(Ce());function Se(s){let e={},t=s.split(",");for(let i of t){let A=i.indexOf("=");if(A===-1)continue;let r=i.substring(0,A),n=i.substring(A+1);if(r==="a"){e.action=n;continue}if(r==="o"){e.compression=n;continue}if(r==="t"){e.transmission=n;continue}if(r==="d"){e.deleteSelector=n;continue}let a=parseInt(n,10);switch(r){case"f":e.format=a;break;case"i":e.id=a;break;case"I":e.imageNumber=a;break;case"s":e.width=a;break;case"v":e.height=a;break;case"x":e.x=a;break;case"y":e.y=a;break;case"w":e.sourceWidth=a;break;case"h":e.sourceHeight=a;break;case"X":e.xOffset=a;break;case"Y":e.yOffset=a;break;case"c":e.columns=a;break;case"r":e.rows=a;break;case"m":e.more=a;break;case"q":e.quiet=a;break;case"C":e.cursorMovement=a;break;case"z":e.zIndex=a;break;case"p":e.placementId=a;break}}return e}var Pe=0,ie=class{constructor(e,t,i,A){this._opts=e;this._renderer=t;this._kittyStorage=i;this._coreTerminal=A;this._aborted=!1;this._generation=0;this._decodeError=!1;this._activeDecoder=null;this._inControlData=!0;this._controlData=new Uint32Array(512);this._controlLength=0;this._encodedSizeLimit=0;this._totalEncodedSize=0;this._parsedCommand=null;this._pendingTransmissions=new Map;this._maxEncodedBytes=Math.ceil(this._opts.kittySizeLimit*4/3),this._initialEncodedBytes=Math.min(4194304,this._maxEncodedBytes)}reset(){this._generation++,this._cleanupAllPending(),this._activeDecoder&&(this._activeDecoder.release(),this._activeDecoder=null),this._kittyStorage.reset()}dispose(){this.reset()}_removePendingEntry(e){this._pendingTransmissions.delete(e),this._lastPendingKey===e&&(this._lastPendingKey=void 0)}_cleanupAllPending(){for(let e of this._pendingTransmissions.values())e.decoder.release();this._pendingTransmissions.clear(),this._lastPendingKey=void 0}start(){this._aborted=!1,this._decodeError=!1,this._inControlData=!0,this._controlLength=0,this._parsedCommand=null,this._encodedSizeLimit=this._maxEncodedBytes,this._totalEncodedSize=0,this._activeDecoder=null}put(e,t,i){if(!this._aborted)if(!this._inControlData)this._streamPayload(e,t,i);else{let A=i;for(let n=t;n512){this._aborted=!0;return}if(this._controlData.set(e.subarray(t,A),this._controlLength),this._controlLength+=r,!this._inControlData){if(this._parsedCommand=Se(this._parseControlDataString()),this._parsedCommand.id!==void 0&&this._parsedCommand.imageNumber!==void 0){this._sendResponse(this._parsedCommand.id,"EINVAL:cannot specify both i and I keys",this._parsedCommand.quiet??0),this._aborted=!0;return}if(this._parsedCommand.action==="d")return;let n=A+1;nthis._encodedSizeLimit){let o=this._activeDecoder??r?.decoder;o&&o.release(),this._activeDecoder=null,r&&this._removePendingEntry(A),this._aborted=!0;return}if(!this._decodeError){if(r?.decoder&&!this._activeDecoder&&(this._activeDecoder=r.decoder),!this._activeDecoder){let o=this._maxEncodedBytes+131072;if(o>this._opts.storageLimit*1e6){this._aborted=!0,this._parsedCommand?.id!==void 0&&this._sendResponse(this._parsedCommand.id,"ENOMEM:pending image budget exceeded",this._parsedCommand.quiet??0);return}let h=Math.max(1,Math.floor(this._opts.storageLimit*1e6/o));for(;this._pendingTransmissions.size>=h;){let g=this._pendingTransmissions.entries().next().value;if(!g)break;g[1].decoder.release(),this._removePendingEntry(g[0]),g[1].cmd.id!==void 0&&this._sendResponse(g[1].cmd.id,"ENOMEM:pending image budget exceeded",g[1].cmd.quiet??0)}this._activeDecoder=new Ke.default(4194304,this._maxEncodedBytes,this._initialEncodedBytes),this._activeDecoder.init()}this._activeDecoder.put(e.subarray(t,i))!==Pe&&(this._activeDecoder.release(),this._activeDecoder=null,this._decodeError=!0,r&&this._removePendingEntry(A))}}end(e){if(this._aborted||!e)return this._activeDecoder&&(this._activeDecoder.release(),this._activeDecoder=null),!0;if(this._inControlData)return this._handleNoPayloadCommand();let t=this._parsedCommand;if(t.action==="d")return this._handleDelete(t);let i=t.id??this._lastPendingKey??0,A=t.more===1,r=this._pendingTransmissions.get(i);if(A)return this._activeDecoder&&(r?(r.totalEncodedSize+=this._totalEncodedSize,r.decodeError=r.decodeError||this._decodeError):this._pendingTransmissions.set(i,{cmd:{...t},decoder:this._activeDecoder,totalEncodedSize:this._totalEncodedSize,decodeError:this._decodeError}),this._lastPendingKey=i,this._activeDecoder=null),!0;r&&(this._lastPendingKey=void 0);let n=this._decodeError,a=t,o=this._activeDecoder;r&&(a=r.cmd,o=r.decoder,n=n||r.decodeError,this._pendingTransmissions.delete(i));let h=new Uint8Array(0);o&&(o.end()!==Pe&&(n=!0),h=o.data8),this._activeDecoder=null;let g=this._handleCommandWithBytesAndCmd(a,h,n);return o&&o.release(),g}_parseControlDataString(){let e="";for(let t=0;t0&&this._sendResponse(e.id,"OK",e.quiet??0)),r}case"T":return this._handleTransmitDisplay(e,t,i);case"q":return this._handleQuery(e,t,i);case"p":return this._handlePlacement(e);default:return e.id!==void 0&&this._sendResponse(e.id,"EINVAL:unsupported action",e.quiet??0),!0}}_handlePlacement(e){if(e.id===void 0)return!0;let t=e.id,i=this._kittyStorage.getImage(t);return i?this._displayImage(i,e).then(r=>(this._sendResponse(t,r?"OK":"EINVAL:image rendering failed",e.quiet??0,e.placementId),!0)):(this._sendResponse(t,"ENOENT:image not found",e.quiet??0,e.placementId),!0)}_handleTransmit(e,t,i){return(e.transmission??"d")!=="d"?(e.id!==void 0&&this._sendResponse(e.id,"EINVAL:unsupported transmission medium",e.quiet??0),!0):(i||t.length===0||this._kittyStorage.storeImage(e.id,{data:new Blob([t]),width:e.width??0,height:e.height??0,format:e.format??32,compression:e.compression??""}),!0)}_handleTransmitDisplay(e,t,i){if(i)return e.id!==void 0&&this._sendResponse(e.id,"EINVAL:invalid base64 data",e.quiet??0),!0;this._handleTransmit(e,t,i);let A=e.id??this._kittyStorage.lastImageId,r=this._kittyStorage.getImage(A);if(r){let n=this._displayImage(r,e);return e.id!==void 0?n.then(a=>(this._sendResponse(A,a?"OK":"EINVAL:image rendering failed",e.quiet??0),!0)):n.then(()=>!0)}return!0}_handleQuery(e,t,i){let A=e.id??0,r=e.quiet??0;if((e.transmission??"d")!=="d")return this._sendResponse(A,"EINVAL:unsupported transmission medium",r),!0;if(i)return this._sendResponse(A,"EINVAL:invalid base64 data",r),!0;if(t.length===0)return this._sendResponse(A,"OK",r),!0;let a=e.format??32;if(a===100)this._sendResponse(A,"OK",r);else{let o=e.width??0,h=e.height??0;if(!o||!h)return this._sendResponse(A,"EINVAL:width and height required for raw pixel data",r),!0;let g=a===32?4:3,d=o*h*g;if(t.length=1||!r&&i>=2)return;let n=A?`,p=${A}`:"",a=`\x1B_Gi=${e}${n};${t}\x1B\\`;this._coreTerminal._core.coreService.triggerDataEvent(a)}_displayImage(e,t){return this._decodeAndDisplay(e,t).then(()=>!0).catch(()=>!1)}async _decodeAndDisplay(e,t){let i=this._generation,A=await this._createBitmap(e);try{if(i!==this._generation)throw new Error("image decode canceled");let r=Math.max(0,t.x??0),n=Math.max(0,t.y??0),a=t.sourceWidth||A.width-r,o=t.sourceHeight||A.height-n,h=Math.max(0,A.width-r),g=Math.max(0,A.height-n),d=Math.max(0,Math.min(a,h)),I=Math.max(0,Math.min(o,g));if(d===0||I===0)throw new Error("invalid source rectangle");if(r!==0||n!==0||d!==A.width||I!==A.height){let T=await createImageBitmap(A,r,n,d,I);A.close(),A=T}let l=this._renderer.dimensions?.css.cell.width||w.width,c=this._renderer.dimensions?.css.cell.height||w.height,u,E;t.columns!==void 0&&t.rows!==void 0?(u=t.columns,E=t.rows):t.columns!==void 0?(u=t.columns,E=Math.max(1,Math.ceil(A.height/A.width*(u*l)/c))):t.rows!==void 0?(E=t.rows,u=Math.max(1,Math.ceil(A.width/A.height*(E*c)/l))):(u=Math.ceil(A.width/l),E=Math.ceil(A.height/c));let C=A.width,p=A.height;if((t.columns!==void 0||t.rows!==void 0)&&(C=Math.round(u*l),p=Math.round(E*c)),C*p>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");let B=this._coreTerminal._core.buffer,K=B.x,Y=B.y,q=B.ybase,$e=t.zIndex!==void 0&&t.zIndex<0?"bottom":"top";if(C!==A.width||p!==A.height){let T=await createImageBitmap(A,{resizeWidth:C,resizeHeight:p});A.close(),A=T}let ge=Math.min(Math.max(0,t.xOffset??0),l-1),de=Math.min(Math.max(0,t.yOffset??0),c-1);if(ge!==0||de!==0){let T=t.columns!==void 0?Math.round(u*l):A.width+ge,tt=t.rows!==void 0?Math.round(E*c):A.height+de,z=D.createCanvas(window.document,T,tt),xe=z.getContext("2d");if(!xe)throw new Error("Failed to create offset canvas context");xe.drawImage(A,ge,de);let it=await createImageBitmap(z);if(z.width=z.height=0,A.close(),A=it,C=A.width,p=A.height,C*p>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");t.columns===void 0&&(u=Math.ceil(A.width/l)),t.rows===void 0&&(E=Math.ceil(A.height/c))}if(i!==this._generation)throw new Error("image decode canceled");let et=t.zIndex??0;if(this._kittyStorage.addImage(e.id,A,!0,$e,et),A=void 0,t.cursorMovement===1){let T=B.ybase-q;B.x=K,B.y=Math.max(Y-T,0)}else B.x=Math.min(K+u,this._coreTerminal.cols)}catch(r){throw A?.close(),r}}async _createBitmap(e){let t=new Uint8Array(await e.data.arrayBuffer());if(e.compression==="z"&&(t=await this._decompressZlib(t)),e.format===100){let E=ee(t);if(E.mime!=="image/png"||!(E.width>0)||!(E.height>0)||E.width*E.height>this._opts.pixelLimit)throw new RangeError("PNG exceeds pixel limit or has invalid dimensions");let C=new Blob([t],{type:"image/png"});if(!window.createImageBitmap){let p=URL.createObjectURL(C),B=new Image;return new Promise((K,Y)=>{B.addEventListener("load",()=>{URL.revokeObjectURL(p);let q=D.createCanvas(window.document,B.width,B.height);q.getContext("2d")?.drawImage(B,0,0),createImageBitmap(q).then(K).catch(Y)}),B.addEventListener("error",()=>{URL.revokeObjectURL(p),Y(new Error("Failed to load image"))}),B.src=p})}return createImageBitmap(C)}let i=e.width,A=e.height;if(!i||!A)throw new Error("Width and height required for raw pixel data");let r=e.format===32?4:3,n=i*A*r;if(t.length>>24|p<<8,g[l++]=4278190080|p>>>16|B<<16,g[l++]=4278190080|B>>>8}let c=d*3,u=d*4;for(let E=d;E=e.length){d.close();return}let I=Math.min(A+4096,e.length);d.enqueue(new Uint8Array(e.subarray(A,I))),A=I}}).pipeThrough(new DecompressionStream(t)).getReader(),a=[],o=0;try{for(;;){let{done:d,value:I}=await n.read();if(d)break;if(o+=I.byteLength,o>i)throw await n.cancel().catch(()=>{}),new RangeError("decompressed image exceeds byte limit");a.push(I)}}finally{n.releaseLock()}let h=new Uint8Array(o),g=0;for(let d of a)h.set(d,g),g+=d.length;return h}get images(){return this._kittyStorage.images}get _kittyIdToStorageId(){return this._kittyStorage.kittyIdToStorageId}get pendingTransmissions(){return this._pendingTransmissions}};var U=class U{constructor(e){this._storage=e;this._nextImageId=1;this._images=new Map;this._kittyIdToStorageId=new Map;this._storageIdToKittyId=new Map;this._handleStorageImageDeleted=e=>{let t=this._storageIdToKittyId.get(e);t!==void 0&&(this._kittyIdToStorageId.delete(t),this._storageIdToKittyId.delete(e),this._images.delete(t))};this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"iip"};this._previousOnImageDeleted=this._storage.onImageDeleted,this._wrappedOnImageDeleted=t=>{this._previousOnImageDeleted?.(t),this._handleStorageImageDeleted(t)},this._storage.onImageDeleted=this._wrappedOnImageDeleted}reset(){this._nextImageId=1,this._images.clear(),this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}dispose(){this.reset(),this._storage.onImageDeleted===this._wrappedOnImageDeleted&&(this._storage.onImageDeleted=this._previousOnImageDeleted)}storeImage(e,t){let i=e??this._nextImageId++,A=this._kittyIdToStorageId.get(i);A!==void 0&&(this._storage.deleteImage(A),this._kittyIdToStorageId.delete(i),this._storageIdToKittyId.delete(A)),!this._images.has(i)&&this._images.size>=U._maxStoredImages&&this._evictUndisplayedImages();let r=this._storage.getLimit()*1e6;this._images.delete(i);let n=0;for(let a of this._images.values())n+=a.data.size;for(let a of[!1,!0])for(let[o,h]of this._images){if(n+t.data.size<=r)break;this._kittyIdToStorageId.has(o)===a&&(n-=h.data.size,this.deleteById(o))}return this._images.set(i,{...t,id:i}),i}addImage(e,t,i,A,r){let n=this._kittyIdToStorageId.get(e);n!==void 0&&this._storageIdToKittyId.delete(n),this._addImageOpts.scrolling=i,this._addImageOpts.layer=A,this._addImageOpts.zIndex=r;let a=this._storage.addImage(t,this._addImageOpts);this._kittyIdToStorageId.set(e,a),this._storageIdToKittyId.set(a,e)}getImage(e){return this._images.get(e)}deleteById(e){this._images.delete(e);let t=this._kittyIdToStorageId.get(e);t!==void 0&&(this._storage.deleteImage(t),this._kittyIdToStorageId.delete(e),this._storageIdToKittyId.delete(t))}deleteAll(){this._images.clear();for(let e of this._kittyIdToStorageId.values())this._storage.deleteImage(e);this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}get images(){return this._images}get kittyIdToStorageId(){return this._kittyIdToStorageId}get lastImageId(){return this._nextImageId-1}_evictUndisplayedImages(){for(let[e]of this._images){if(this._images.size<=U._maxStoredImages/2)break;this._kittyIdToStorageId.has(e)||this._images.delete(e)}}};U._maxStoredImages=256;var Ae=U;var y=M(Z());var Xe=M(We());var Mt=4194304,Te=y.PALETTE_ANSI_256;Te.set(y.PALETTE_VT340_COLOR);var ae=class{constructor(e,t,i){this._opts=e;this._storage=t;this._coreTerminal=i;this._size=0;this._aborted=!1;(0,Xe.DecoderAsync)({memoryLimit:this._opts.pixelLimit*4,palette:Te,paletteLimit:this._opts.sixelPaletteLimit}).then(A=>this._dec=A)}reset(){this._dec&&(this._dec.release(),this._dec._palette.fill(0),this._dec.init(0,Te,this._opts.sixelPaletteLimit))}hook(e){if(this._size=0,this._aborted=!1,this._dec){let t=e.params[1]===1?0:vt(this._coreTerminal._core._inputHandler._curAttrData,this._coreTerminal._core._themeService?.colors);this._dec.init(t,null,this._opts.sixelPaletteLimit)}}put(e,t,i){if(!(this._aborted||!this._dec)){if(this._size+=i-t,this._size>this._opts.sixelSizeLimit){console.warn("SIXEL: too much data, aborting"),this._aborted=!0,this._dec.release();return}try{this._dec.decode(e,t,i)}catch(A){console.warn(`SIXEL: error while decoding image - ${A}`),this._aborted=!0,this._dec.release()}}}unhook(e){if(this._aborted||!e||!this._dec)return!0;let t=this._dec.width,i=this._dec.height;if(!t||!i)return i&&this._storage.advanceCursor(i),!0;let A=D.createCanvas(void 0,t,i);return A.getContext("2d")?.putImageData(new ImageData(this._dec.data8,t,i),0,0),this._dec.memoryUsage>Mt&&this._dec.release(),this._storage.addImage(A),!0}};function vt(s,e){let t=0;if(!e)return t;if(s.isInverse())if(s.isFgDefault())t=ne(e.foreground.rgba);else if(s.isFgRGB()){let i=s.constructor.toColorRGB(s.getFgColor());t=(0,y.toRGBA8888)(...i)}else t=ne(e.ansi[s.getFgColor()].rgba);else if(s.isBgDefault())t=ne(e.background.rgba);else if(s.isBgRGB()){let i=s.constructor.toColorRGB(s.getBgColor());t=(0,y.toRGBA8888)(...i)}else t=ne(e.ansi[s.getBgColor()].rgba);return t}function ne(s){return y.BIG_ENDIAN?s:(s&255)<<24|(s>>>8&255)<<16|(s>>>16&255)<<8|s>>>24&255}var oe=class{constructor(e,t,i,A){this._storage=e;this._opts=t;this._renderer=i;this._terminal=A;this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"vt340"}}addImage(e){this._addImageOpts.scrolling=this._opts.sixelScrolling,this._storage.addImage(e,this._addImageOpts)}advanceCursor(e){if(this._opts.sixelScrolling){let t=this._renderer.cellSize;(t.width===-1||t.height===-1)&&(t=w);let i=Math.ceil(e/t.height);for(let A=1;Athis._onImageAdded.fire(),this._opts.enableSizeReports){let t=e.options.windowOptions??{};t.getWinSizePixels=!0,t.getCellSizePixels=!0,t.getWinSizeChars=!0,e.options.windowOptions=t}if(this._disposeLater(this._renderer,this._storage,e.parser.registerCsiHandler({prefix:"?",final:"h"},t=>this._decset(t)),e.parser.registerCsiHandler({prefix:"?",final:"l"},t=>this._decrst(t)),e.parser.registerCsiHandler({final:"c"},t=>this._da1(t)),e.parser.registerCsiHandler({prefix:"?",final:"S"},t=>this._xtermGraphicsAttributes(t)),e.onRender(t=>this._storage?.render(t)),e.parser.registerCsiHandler({intermediates:"!",final:"p"},()=>this.reset()),e.parser.registerEscHandler({final:"c"},()=>this.reset()),e._core._inputHandler.onRequestReset(()=>this.reset()),e.buffer.onBufferChange(()=>this._storage?.wipeAlternate()),e.onResize(t=>this._storage?.viewportResize(t))),this._opts.sixelSupport){let t=new oe(this._storage,this._opts,this._renderer,e),i=new ae(this._opts,t,e);this._handlers.set("sixel",i),this._disposeLater(e._core._inputHandler._parser.registerDcsHandler({final:"q"},i))}if(this._opts.iipSupport){let t=new he(this._storage),i=new te(this._opts,this._renderer,t,e);this._handlers.set("iip",i),this._disposeLater(e._core._inputHandler._parser.registerOscHandler(1337,i))}if(this._opts.kittySupport){let t=new Ae(this._storage),i=new ie(this._opts,this._renderer,t,e);this._handlers.set("kitty",i),this._disposeLater(t,i,e._core._inputHandler._parser.registerApcHandler({final:"G"},i))}}reset(){this._opts.sixelScrolling=this._defaultOpts.sixelScrolling,this._opts.sixelPaletteLimit=this._defaultOpts.sixelPaletteLimit,this._storage?.reset();for(let e of this._handlers.values())e.reset();return!1}get storageLimit(){return this._storage?.getLimit()||-1}set storageLimit(e){this._storage?.setLimit(e),this._opts.storageLimit=e}get storageUsage(){return this._storage?this._storage.getUsage():-1}get showPlaceholder(){return this._opts.showPlaceholder}set showPlaceholder(e){this._opts.showPlaceholder=e,this._renderer?.showPlaceholder(e)}getImageAtBufferCell(e,t){return this._storage?.getImageAtBufferCell(e,t)}extractTileAtBufferCell(e,t){return this._storage?.extractTileAtBufferCell(e,t)}_report(e){this._terminal?._core.input(e,!1)}_decset(e){for(let t=0;t2&&!(e[2]instanceof Array)&&e[2]<=je?(this._opts.sixelPaletteLimit=e[2],this._report(`\x1B[?${e[0]};0;${this._opts.sixelPaletteLimit}S`)):this._report(`\x1B[?${e[0]};2S`),!0;case 4:return this._report(`\x1B[?${e[0]};0;${je}S`),!0;default:return this._report(`\x1B[?${e[0]};2S`),!0}if(e[0]===2)switch(e[1]){case 1:let t=this._renderer?.dimensions?.css.canvas.width,i=this._renderer?.dimensions?.css.canvas.height;if(!t||!i){let r=w;t=(this._terminal?.cols||80)*r.width,i=(this._terminal?.rows||24)*r.height}if(t*i()=>{try{return e||r((e={exports:{}}).exports,e),e.exports}catch(t){throw e=0,t}};var It=(r,e,t,i)=>{if(e&&typeof e=="object"||typeof e=="function")for(let A of ht(e))!gt.call(r,A)&&A!==t&&Ne(r,A,{get:()=>e[A],enumerable:!(i=ot(e,A))||i.enumerable});return r};var x=(r,e,t)=>(t=r!=null?at(dt(r)):{},It(e||!r||!r.__esModule?Ne(t,"default",{value:r,enumerable:!0}):t,r));var j=M(_=>{"use strict";Object.defineProperty(_,"__esModule",{value:!0});_.DEFAULT_FOREGROUND=_.DEFAULT_BACKGROUND=_.PALETTE_ANSI_256=_.PALETTE_VT340_GREY=_.PALETTE_VT340_COLOR=_.normalizeHLS=_.normalizeRGB=_.nearestColorIndex=_.fromRGBA8888=_.toRGBA8888=_.alpha=_.blue=_.green=_.red=_.BIG_ENDIAN=void 0;_.BIG_ENDIAN=new Uint8Array(new Uint32Array([4278190080]).buffer)[0]===255;_.BIG_ENDIAN&&console.warn("BE platform detected. This version of node-sixel works only on LE properly.");function He(r){return r&255}_.red=He;function Fe(r){return r>>>8&255}_.green=Fe;function Ge(r){return r>>>16&255}_.blue=Ge;function ct(r){return r>>>24&255}_.alpha=ct;function f(r,e,t,i=255){return((i&255)<<24|(t&255)<<16|(e&255)<<8|r&255)>>>0}_.toRGBA8888=f;function mt(r){return[r&255,r>>8&255,r>>16&255,r>>>24]}_.fromRGBA8888=mt;function _t(r,e){let t=He(r),i=Fe(r),A=Ge(r),s=Number.MAX_SAFE_INTEGER,n=-1;for(let a=0;a1&&(t-=1),t*6<1?e+(r-e)*6*t:t*2<1?r:t*3<2?e+(r-e)*(4-t*6):e}function ut(r,e,t){if(!t){let s=Math.round(e*255);return f(s,s,s)}let i=e<.5?e*(1+t):e+t-e*t,A=2*e-i;return f(ce(0,255,Math.round(me(i,A,r+1/3)*255)),ce(0,255,Math.round(me(i,A,r)*255)),ce(0,255,Math.round(me(i,A,r-1/3)*255)))}function m(r,e,t){return(4278190080|Math.round(t/100*255)<<16|Math.round(e/100*255)<<8|Math.round(r/100*255))>>>0}_.normalizeRGB=m;function Et(r,e,t){return ut((r+240%360)/360,e/100,t/100)}_.normalizeHLS=Et;_.PALETTE_VT340_COLOR=new Uint32Array([m(0,0,0),m(20,20,80),m(80,13,13),m(20,80,20),m(80,20,80),m(20,80,80),m(80,80,20),m(53,53,53),m(26,26,26),m(33,33,60),m(60,26,26),m(33,60,33),m(60,33,60),m(33,60,60),m(60,60,33),m(80,80,80)]);_.PALETTE_VT340_GREY=new Uint32Array([m(0,0,0),m(13,13,13),m(26,26,26),m(40,40,40),m(6,6,6),m(20,20,20),m(33,33,33),m(46,46,46),m(0,0,0),m(13,13,13),m(26,26,26),m(40,40,40),m(6,6,6),m(20,20,20),m(33,33,33),m(46,46,46)]);_.PALETTE_ANSI_256=(()=>{let r=[f(0,0,0),f(205,0,0),f(0,205,0),f(205,205,0),f(0,0,238),f(205,0,205),f(0,250,205),f(229,229,229),f(127,127,127),f(255,0,0),f(0,255,0),f(255,255,0),f(92,92,255),f(255,0,255),f(0,255,255),f(255,255,255)],e=[0,95,135,175,215,255];for(let t=0;t<6;++t)for(let i=0;i<6;++i)for(let A=0;A<6;++A)r.push(f(e[t],e[i],e[A]));for(let t=8;t<=238;t+=10)r.push(f(t,t,t));return new Uint32Array(r)})();_.DEFAULT_BACKGROUND=f(0,0,0,255);_.DEFAULT_FOREGROUND=f(255,255,255,255)});var Ee=M(ue=>{"use strict";Object.defineProperty(ue,"__esModule",{value:!0});ue.InWasm=Dt;var v=r=>{if(Uint8Array.fromBase64)return Uint8Array.fromBase64(r);if(typeof Buffer<"u")return Buffer.from(r,"base64");let e=atob(r),t=new Uint8Array(e.length);for(let i=0;inew n.Instance(s||(s=new n.Module(A||(A=v(i)))),a):a=>s?n.instantiate(s,a):n.instantiate(A||(A=v(i)),a).then(o=>(s=o.module)&&o.instance):e===1?t?()=>s||(s=new n.Module(A||(A=v(i)))):()=>s?Promise.resolve(s):n.compile(A||(A=v(i))).then(a=>s=a):t?()=>A||(A=v(i)):()=>Promise.resolve(A||(A=v(i)))}if(typeof _wasmCtx>"u")throw new Error('must run "inwasm"');_wasmCtx.add(r)}});var Be=M(pe=>{"use strict";Object.defineProperty(pe,"__esModule",{value:!0});var wt=Ee(),bt=(0,wt.InWasm)({s:1,t:0,d:"AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML"}),S=new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map(r=>r.charCodeAt(0))),R=new Uint32Array(1024);R.fill(4278190080);for(let r=0;r>4|(r<<4&255)<<8;for(let r=0;r>2<<8|(r<<6&255)<<16;for(let r=0;rthis.maxBytes||this.maxBytes>4294901760)throw new Error("invalid byte settings")}get data8(){return this._inst?this._d.subarray(0,this._m32[1282]):yt}release(){this._inst&&(this._bytes>this.keepSize?this._inst=this._m32=this._d=this._mem=null:(this._m32[1280]=0,this._m32[1281]=0,this._m32[1282]=0))}init(e,t){if(this.maxBytes=e??this.maxBytes,this._bytes=t??Math.min(this._bytes,this.maxBytes),this._bytes>this.maxBytes||this.maxBytes>4294901760)throw Error("invalid byte settings");let i=this._m32,A=this._bytes+5152;this._inst?this._mem.buffer.byteLengththis.maxBytes)return-3;let i=this._bytes;for(;(i*=2)this._mem.buffer.byteLength){let A=Math.ceil((i+5152-this._mem.buffer.byteLength)/65536);this._mem.grow(A),this._m32=new Uint32Array(this._mem.buffer,0),this._d=new Uint8Array(this._mem.buffer,5152)}this._bytes=i}return 0}put(e){if(!this._inst||this._ended)return-2;if(this._realloc(e.length))return-3;let t=this._m32;return this._d.set(e,t[1280]),t[1280]+=e.length,t[1280]-t[1281]>=131072?this._inst.exports.dec():0}end(){return this._ended=!0,this._inst?this._inst.exports.end():-2}get loadedBytes(){return this._inst?this._m32[1280]:0}get freeBytes(){return this._inst?this.maxBytes-this._m32[1280]:0}};pe.default=Ce});var Ue=M(Qe=>{"use strict";Object.defineProperty(Qe,"__esModule",{value:!0});var St=Ee(),Lt=(0,St.InWasm)({s:1,t:0,d:"AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=="}),fe=class{constructor(e){this.keepSize=e,this.width=0,this.height=0}decode(e){this.width=e[4]<<24|e[5]<<16|e[6]<<8|e[7],this.height=e[8]<<24|e[9]<<16|e[10]<<8|e[11];let t=this.width*this.height,i=t*4,A=e.length,s=Math.max(i,A)+(Math.min(i,A)>>1)+4096;this._inst?this._mem.buffer.byteLengththis.keepSize&&(this._inst=this._d=this._mem=null)}};Qe.default=fe});var xe=M(se=>{"use strict";Object.defineProperty(se,"__esModule",{value:!0});se.LIMITS=void 0;se.LIMITS={CHUNK_SIZE:16384,PALETTE_SIZE:4096,MAX_WIDTH:16384,BYTES:"AGFzbQEAAAABJAdgAAF/YAJ/fwBgA39/fwF/YAF/AX9gAABgBH9/f38AYAF/AAIlAgNlbnYLaGFuZGxlX2JhbmQAAwNlbnYLbW9kZV9wYXJzZWQAAwMTEgQAAAAAAQQBAQUBAAACAgAGAwQFAXABBwcFBAEBBwcGCAF/AUGAihoLB9wBDgZtZW1vcnkCABFnZXRfc3RhdGVfYWRkcmVzcwADEWdldF9jaHVua19hZGRyZXNzAAQOZ2V0X3AwX2FkZHJlc3MABRNnZXRfcGFsZXR0ZV9hZGRyZXNzAAYEaW5pdAALBmRlY29kZQAMDWN1cnJlbnRfd2lkdGgADQ5jdXJyZW50X2hlaWdodAAOGV9faW5kaXJlY3RfZnVuY3Rpb25fdGFibGUBAAtfaW5pdGlhbGl6ZQACCXN0YWNrU2F2ZQARDHN0YWNrUmVzdG9yZQASCnN0YWNrQWxsb2MAEwkMAQBBAQsGCgcJDxACDAEBCq5UEgMAAQsFAEGgCAsGAEGQiQELBgBBsIkCCwUAQZAJC+okAQh/QeQIKAIAIQVB4AgoAgAhA0HoCCgCACEIIAFBkIkBaiIJQf8BOgAAIAAgAUgEQCAAQZCJAWohBgNAIAMhBCAGQQFqIQECQCAGLQAAQf8AcSIDQTBrQQlLBEAgASEGDAELQewIKAIAQQJ0QewIaiICKAIAIQADQCACIAMgAEEKbGpBMGsiADYCACABLQAAIQMgAUEBaiIGIQEgA0H/AHEiA0Ewa0EKSQ0ACwsCQAJAAkACQAJAAkACQAJ/AkACQCADQT9rIgBBP00EQCAERQ0BIARBIUYEQAJAQfAIKAIAIgFBASABGyIHIAhqIgFB1AgoAgAiA0gNACADQf//AEoNAANAIANBAnQiAkGgiQJqIgRBoAgpAwA3AwAgAkGoiQJqQaAIKQMANwMAIAJBsIkCakGgCCkDADcDACACQbiJAmpBoAgpAwA3AwAgAkHAiQJqQaAIKQMANwMAIAJByIkCakGgCCkDADcDACACQdCJAmpBoAgpAwA3AwAgAkHYiQJqQaAIKQMANwMAIAJB4IkCakGgCCkDADcDACACQeiJAmpBoAgpAwA3AwAgAkHwiQJqQaAIKQMANwMAIAJB+IkCakGgCCkDADcDACACQYCKAmpBoAgpAwA3AwAgAkGIigJqQaAIKQMANwMAIAJBkIoCakGgCCkDADcDACACQZiKAmpBoAgpAwA3AwAgAkGgigJqQaAIKQMANwMAIAJBqIoCakGgCCkDADcDACACQbCKAmpBoAgpAwA3AwAgAkG4igJqQaAIKQMANwMAIAJBwIoCakGgCCkDADcDACACQciKAmpBoAgpAwA3AwAgAkHQigJqQaAIKQMANwMAIAJB2IoCakGgCCkDADcDACACQeCKAmpBoAgpAwA3AwAgAkHoigJqQaAIKQMANwMAIAJB8IoCakGgCCkDADcDACACQfiKAmpBoAgpAwA3AwAgAkGAiwJqQaAIKQMANwMAIAJBiIsCakGgCCkDADcDACACQZCLAmpBoAgpAwA3AwAgAkGYiwJqQaAIKQMANwMAIAJBoIsCakGgCCkDADcDACACQaiLAmpBoAgpAwA3AwAgAkGwiwJqQaAIKQMANwMAIAJBuIsCakGgCCkDADcDACACQcCLAmpBoAgpAwA3AwAgAkHIiwJqQaAIKQMANwMAIAJB0IsCakGgCCkDADcDACACQdiLAmpBoAgpAwA3AwAgAkHgiwJqQaAIKQMANwMAIAJB6IsCakGgCCkDADcDACACQfCLAmpBoAgpAwA3AwAgAkH4iwJqQaAIKQMANwMAIAJBgIwCakGgCCkDADcDACACQYiMAmpBoAgpAwA3AwAgAkGQjAJqQaAIKQMANwMAIAJBmIwCakGgCCkDADcDACACQaCMAmpBoAgpAwA3AwAgAkGojAJqQaAIKQMANwMAIAJBsIwCakGgCCkDADcDACACQbiMAmpBoAgpAwA3AwAgAkHAjAJqQaAIKQMANwMAIAJByIwCakGgCCkDADcDACACQdCMAmpBoAgpAwA3AwAgAkHYjAJqQaAIKQMANwMAIAJB4IwCakGgCCkDADcDACACQeiMAmpBoAgpAwA3AwAgAkHwjAJqQaAIKQMANwMAIAJB+IwCakGgCCkDADcDACACQYCNAmpBoAgpAwA3AwAgAkGIjQJqQaAIKQMANwMAIAJBkI0CakGgCCkDADcDACACQZiNAmpBoAgpAwA3AwAgAkGwiQZqIARBgAT8CgAAQdQIKAIAQQJ0QcCJCmogBEGABPwKAABB1AgoAgBBAnRB0IkOaiAEQYAE/AoAAEHUCCgCAEECdEHgiRJqIARBgAT8CgAAQdQIKAIAQQJ0QfCJFmogBEGABPwKAABB1AhB1AgoAgAiAkGAAWoiAzYCACABIANIDQEgAkGA/wBIDQALCwJAIABFDQAgCEH//wBLDQBBgIABIAhrIAcgAUH//wBLGyECAkAgAEEBcUUNACACRQ0AIAhBAnRBoIkCaiEDIAIhBCACQQdxIgcEQANAIAMgBTYCACADQQRqIQMgBEEBayEEIAdBAWsiBw0ACwsgAkEBa0EHSQ0AA0AgAyAFNgIcIAMgBTYCGCADIAU2AhQgAyAFNgIQIAMgBTYCDCADIAU2AgggAyAFNgIEIAMgBTYCACADQSBqIQMgBEEIayIEDQALCwJAIABBAnFFDQAgAkUNACAIQQJ0QbCJBmohAyACIQQgAkEHcSIHBEADQCADIAU2AgAgA0EEaiEDIARBAWshBCAHQQFrIgcNAAsLIAJBAWtBB0kNAANAIAMgBTYCHCADIAU2AhggAyAFNgIUIAMgBTYCECADIAU2AgwgAyAFNgIIIAMgBTYCBCADIAU2AgAgA0EgaiEDIARBCGsiBA0ACwsCQCAAQQRxRQ0AIAJFDQAgCEECdEHAiQpqIQMgAiEEIAJBB3EiBwRAA0AgAyAFNgIAIANBBGohAyAEQQFrIQQgB0EBayIHDQALCyACQQFrQQdJDQADQCADIAU2AhwgAyAFNgIYIAMgBTYCFCADIAU2AhAgAyAFNgIMIAMgBTYCCCADIAU2AgQgAyAFNgIAIANBIGohAyAEQQhrIgQNAAsLAkAgAEEIcUUNACACRQ0AIAhBAnRB0IkOaiEDIAIhBCACQQdxIgcEQANAIAMgBTYCACADQQRqIQMgBEEBayEEIAdBAWsiBw0ACwsgAkEBa0EHSQ0AA0AgAyAFNgIcIAMgBTYCGCADIAU2AhQgAyAFNgIQIAMgBTYCDCADIAU2AgggAyAFNgIEIAMgBTYCACADQSBqIQMgBEEIayIEDQALCwJAIABBEHFFDQAgAkUNACAIQQJ0QeCJEmohAyACIQQgAkEHcSIHBEADQCADIAU2AgAgA0EEaiEDIARBAWshBCAHQQFrIgcNAAsLIAJBAWtBB0kNAANAIAMgBTYCHCADIAU2AhggAyAFNgIUIAMgBTYCECADIAU2AgwgAyAFNgIIIAMgBTYCBCADIAU2AgAgA0EgaiEDIARBCGsiBA0ACwsgAEEgcUUNACACRQ0AIAJBAWshByAIQQJ0QfCJFmohAyACQQdxIgQEQANAIAMgBTYCACADQQRqIQMgAkEBayECIARBAWsiBA0ACwsgB0EHSQ0AA0AgAyAFNgIcIAMgBTYCGCADIAU2AhQgAyAFNgIQIAMgBTYCDCADIAU2AgggAyAFNgIEIAMgBTYCACADQSBqIQMgAkEIayICDQALC0HcCEHcCCgCACAAcjYCACAGQQFqIgIgBi0AAEH/AHEiA0E/ayIAQT9LDQQaDAMLAkBB7AgoAgAiBEEBRgRAQfAIKAIAIgNBzAgoAgAiAUkNASADIAFwIQMMAQtB+AgoAgAhAkH0CCgCACEBAkACQCAEQQVHDQAgAUEBRw0AIAJB6QJODQQMAQsgAkHkAEoNA0H8CCgCAEHkAEoNA0GACSgCAEHkAEoNAwsCQCABRQ0AIAFBAkoNACACQfwIKAIAQYAJKAIAIAFBAnRBiAhqKAIAEQIAIQFB8AgoAgAiA0HMCCgCACICTwR/IAMgAnAFIAMLQQJ0QZAJaiABNgIAC0HwCCgCACIDQcwIKAIAIgFJDQAgAyABcCEDCyADQQJ0QZAJaigCACEFDAELIANB/QBxQSFHBEAgCCEBIAYhAgwECyAEQSNHDQQCQEHsCCgCACICQQFGBEBB8AgoAgAiAUHMCCgCACIASQ0BIAEgAHAhAQwBC0H4CCgCACEBQfQIKAIAIQACQAJAIAJBBUcNACAAQQFHDQAgAUHpAkgNAQwHCyABQeQASg0GQfwIKAIAQeQASg0GQYAJKAIAQeQASg0GCwJAIABFDQAgAEECSg0AIAFB/AgoAgBBgAkoAgAgAEECdEGICGooAgARAgAhAEHwCCgCACIBQcwIKAIAIgJPBH8gASACcAUgAQtBAnRBkAlqIAA2AgALQfAIKAIAIgFBzAgoAgAiAEkNACABIABwIQELIAFBAnRBkAlqKAIAIQUMBAsgCCEBIAYhAgtB1AgoAgAhBgNAAkAgASAGSA0AIAZB//8ASg0AIAZBAnQiBEGgiQJqIgZBoAgpAwA3AwAgBEGoiQJqQaAIKQMANwMAIARBsIkCakGgCCkDADcDACAEQbiJAmpBoAgpAwA3AwAgBEHAiQJqQaAIKQMANwMAIARByIkCakGgCCkDADcDACAEQdCJAmpBoAgpAwA3AwAgBEHYiQJqQaAIKQMANwMAIARB4IkCakGgCCkDADcDACAEQeiJAmpBoAgpAwA3AwAgBEHwiQJqQaAIKQMANwMAIARB+IkCakGgCCkDADcDACAEQYCKAmpBoAgpAwA3AwAgBEGIigJqQaAIKQMANwMAIARBkIoCakGgCCkDADcDACAEQZiKAmpBoAgpAwA3AwAgBEGgigJqQaAIKQMANwMAIARBqIoCakGgCCkDADcDACAEQbCKAmpBoAgpAwA3AwAgBEG4igJqQaAIKQMANwMAIARBwIoCakGgCCkDADcDACAEQciKAmpBoAgpAwA3AwAgBEHQigJqQaAIKQMANwMAIARB2IoCakGgCCkDADcDACAEQeCKAmpBoAgpAwA3AwAgBEHoigJqQaAIKQMANwMAIARB8IoCakGgCCkDADcDACAEQfiKAmpBoAgpAwA3AwAgBEGAiwJqQaAIKQMANwMAIARBiIsCakGgCCkDADcDACAEQZCLAmpBoAgpAwA3AwAgBEGYiwJqQaAIKQMANwMAIARBoIsCakGgCCkDADcDACAEQaiLAmpBoAgpAwA3AwAgBEGwiwJqQaAIKQMANwMAIARBuIsCakGgCCkDADcDACAEQcCLAmpBoAgpAwA3AwAgBEHIiwJqQaAIKQMANwMAIARB0IsCakGgCCkDADcDACAEQdiLAmpBoAgpAwA3AwAgBEHgiwJqQaAIKQMANwMAIARB6IsCakGgCCkDADcDACAEQfCLAmpBoAgpAwA3AwAgBEH4iwJqQaAIKQMANwMAIARBgIwCakGgCCkDADcDACAEQYiMAmpBoAgpAwA3AwAgBEGQjAJqQaAIKQMANwMAIARBmIwCakGgCCkDADcDACAEQaCMAmpBoAgpAwA3AwAgBEGojAJqQaAIKQMANwMAIARBsIwCakGgCCkDADcDACAEQbiMAmpBoAgpAwA3AwAgBEHAjAJqQaAIKQMANwMAIARByIwCakGgCCkDADcDACAEQdCMAmpBoAgpAwA3AwAgBEHYjAJqQaAIKQMANwMAIARB4IwCakGgCCkDADcDACAEQeiMAmpBoAgpAwA3AwAgBEHwjAJqQaAIKQMANwMAIARB+IwCakGgCCkDADcDACAEQYCNAmpBoAgpAwA3AwAgBEGIjQJqQaAIKQMANwMAIARBkI0CakGgCCkDADcDACAEQZiNAmpBoAgpAwA3AwAgBEGwiQZqIAZBgAT8CgAAQdQIKAIAQQJ0QcCJCmogBkGABPwKAABB1AgoAgBBAnRB0IkOaiAGQYAE/AoAAEHUCCgCAEECdEHgiRJqIAZBgAT8CgAAQdQIKAIAQQJ0QfCJFmogBkGABPwKAABB1AhB1AgoAgBBgAFqIgY2AgALIAFB//8ATQRAIABBAXEgAWxBAnRBoIkCaiAFNgIAIABBAXZBAXEgAWxBAnRBsIkGaiAFNgIAIABBAnZBAXEgAWxBAnRBwIkKaiAFNgIAIABBA3ZBAXEgAWxBAnRB0IkOaiAFNgIAIABBBHZBAXEgAWxBAnRB4IkSaiAFNgIAIABBBXYgAWxBAnRB8IkWaiAFNgIAQdQIKAIAIQYLIAFBAWohAUHcCEHcCCgCACAAcjYCACACLQAAIQAgAkEBaiIEIQIgAEH/AHEiA0E/ayIAQcAASQ0ACyAECyECQQAhBCACIQYgASEIIANB/QBxQSFGDQELIANBJGsOCgEDAwMDAwMDAwIDC0HsCEIBNwIADAQLQdgIIAFB2AgoAgAiACAAIAFIGyIAQYCAASAAQYCAAUgbNgIADAILQegIIAFB2AgoAgAiACAAIAFIGyIAQYCAASAAQYCAAUgbIgA2AgBB2AggADYCACAAQQRrEAAEQEHoCEEENgIAQdgIQQQ2AgBB0AhBATYCAA8LEAgMAQsCQCADQTtHDQBB7AgoAgAiAEEHSg0AQewIIABBAWo2AgAgAEECdEHwCGpBADYCAAsgAiEGIAQhAyABIQgMAQtBBCEIIAIhBiAEIQMLIAYgCUkNAAsLQeQIIAU2AgBB4AggAzYCAEHoCCAINgIAC9ELAgF+CH9B2AhCBDcDAEGojQJBoAgpAwAiADcDAEGgjQIgADcDAEGYjQIgADcDAEGQjQIgADcDAEGIjQIgADcDAEGAjQIgADcDAEH4jAIgADcDAEHwjAIgADcDAEHojAIgADcDAEHgjAIgADcDAEHYjAIgADcDAEHQjAIgADcDAEHIjAIgADcDAEHAjAIgADcDAEG4jAIgADcDAEGwjAIgADcDAEGojAIgADcDAEGgjAIgADcDAEGYjAIgADcDAEGQjAIgADcDAEGIjAIgADcDAEGAjAIgADcDAEH4iwIgADcDAEHwiwIgADcDAEHoiwIgADcDAEHgiwIgADcDAEHYiwIgADcDAEHQiwIgADcDAEHIiwIgADcDAEHAiwIgADcDAEG4iwIgADcDAEGwiwIgADcDAEGoiwIgADcDAEGgiwIgADcDAEGYiwIgADcDAEGQiwIgADcDAEGIiwIgADcDAEGAiwIgADcDAEH4igIgADcDAEHwigIgADcDAEHoigIgADcDAEHgigIgADcDAEHYigIgADcDAEHQigIgADcDAEHIigIgADcDAEHAigIgADcDAEG4igIgADcDAEGwigIgADcDAEGoigIgADcDAEGgigIgADcDAEGYigIgADcDAEGQigIgADcDAEGIigIgADcDAEGAigIgADcDAEH4iQIgADcDAEHwiQIgADcDAEHoiQIgADcDAEHgiQIgADcDAEHYiQIgADcDAEHQiQIgADcDAEHIiQIgADcDAEHAiQIgADcDAEG4iQIgADcDAEGwiQIgADcDAEGoCCgCACIEQf8AakGAAW0hCAJAIARBgQFIDQBBASEBIAhBAiAIQQJKG0EBayICQQFxIQMgBEGBAk4EQCACQX5xIQIDQCABQQl0IgdBEHJBoIkCakGwiQJBgAT8CgAAIAdBsI0CakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsLIANFDQAgAUEJdEEQckGgiQJqQbCJAkGABPwKAAALAkAgBEEBSA0AIAhBASAIQQFKGyIDQQFxIQUCQCADQQFrIgdFBEBBACEBDAELIANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQbCJBmpBsIkCQYAE/AoAACAGQZAEckGwiQZqQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACwsgBQRAIAFBCXRBEHJBsIkGakGwiQJBgAT8CgAACyAEQQFIDQAgA0EBcSEFIAcEfyADQf7///8HcSECQQAhAQNAIAFBCXQiBkEQckHAiQpqQbCJAkGABPwKAAAgBkGQBHJBwIkKakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsgAUEHdEEEcgVBBAshASAFBEAgAUECdEHAiQpqQbCJAkGABPwKAAALIARBAUgNACADQQFxIQUgBwR/IANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQdCJDmpBsIkCQYAE/AoAACAGQZAEckHQiQ5qQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACyABQQd0QQRyBUEECyEBIAUEQCABQQJ0QdCJDmpBsIkCQYAE/AoAAAsgBEEBSA0AIANBAXEhBSAHBH8gA0H+////B3EhAkEAIQEDQCABQQl0IgZBEHJB4IkSakGwiQJBgAT8CgAAIAZBkARyQeCJEmpBsIkCQYAE/AoAACABQQJqIQEgAkECayICDQALIAFBB3RBBHIFQQQLIQEgBQRAIAFBAnRB4IkSakGwiQJBgAT8CgAACyAEQQFIDQAgA0EBcSEEIAcEfyADQf7///8HcSECQQAhAQNAIAFBCXQiA0EQckHwiRZqQbCJAkGABPwKAAAgA0GQBHJB8IkWakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsgAUEHdEEEcgVBBAshASAERQ0AIAFBAnRB8IkWakGwiQJBgAT8CgAAC0HUCCAIQQd0QQRyNgIAC58TAgh/AX5B5AgoAgAhA0HgCCgCACECQegIKAIAIQcgAUGQiQFqIglB/wE6AAAgACABSARAIABBkIkBaiEIA0AgAiEEIAhBAWohAQJAIAgtAABB/wBxIgJBMGtBCUsEQCABIQgMAQtB7AgoAgBBAnRB7AhqIgUoAgAhAANAIAUgAiAAQQpsakEwayIANgIAIAEtAAAhAiABQQFqIgghASACQf8AcSICQTBrQQpJDQALCwJAAkACQAJAAkACQAJ/AkAgAkE/ayIAQT9NBEAgBEUNASAEQSFGBEBB8AgoAgAiAUEBIAEbIgQgB2ohAQJAIABFDQAgB0H//wBLDQBBgIABIAdrIAQgAUH//wBLGyEFAkAgAEEBcUUNACAHQQJ0QaCJAmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEECcUUNACAHQQJ0QbCJBmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEEEcUUNACAHQQJ0QcCJCmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEEIcUUNACAHQQJ0QdCJDmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEEQcUUNACAHQQJ0QeCJEmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLIABBIHFFDQAgBUEBayEEIAdBAnRB8IkWaiEAIAVBB3EiAgRAA0AgACADNgIAIABBBGohACAFQQFrIQUgAkEBayICDQALCyAEQQdJDQADQCAAIAM2AhwgACADNgIYIAAgAzYCFCAAIAM2AhAgACADNgIMIAAgAzYCCCAAIAM2AgQgACADNgIAIABBIGohACAFQQhrIgUNAAsLIAhBAWoiBSAILQAAQf8AcSICQT9rIgBBP00NAxoMBAsCQEHsCCgCACIFQQFGBEBB8AgoAgAiAUHMCCgCACIESQ0BIAEgBHAhAQwBC0H4CCgCACEEQfQIKAIAIQECQAJAIAVBBUcNACABQQFHDQAgBEHpAk4NBAwBCyAEQeQASg0DQfwIKAIAQeQASg0DQYAJKAIAQeQASg0DCwJAIAFFDQAgAUECSg0AIARB/AgoAgBBgAkoAgAgAUECdEGICGooAgARAgAhBEHwCCgCACIBQcwIKAIAIgVPBH8gASAFcAUgAQtBAnRBkAlqIAQ2AgALQfAIKAIAIgFBzAgoAgAiBEkNACABIARwIQELIAFBAnRBkAlqKAIAIQMMAQsgAkH9AHFBIUcEQCAHIQEgAiEADAQLIARBI0cNBAJAQewIKAIAIgRBAUYEQEHwCCgCACIBQcwIKAIAIgBJDQEgASAAcCEBDAELQfgIKAIAIQFB9AgoAgAhAAJAAkAgBEEFRw0AIABBAUcNACABQekCSA0BDAcLIAFB5ABKDQZB/AgoAgBB5ABKDQZBgAkoAgBB5ABKDQYLAkAgAEUNACAAQQJKDQAgAUH8CCgCAEGACSgCACAAQQJ0QYgIaigCABECACEAQfAIKAIAIgFBzAgoAgAiBE8EfyABIARwBSABC0ECdEGQCWogADYCAAtB8AgoAgAiAUHMCCgCACIASQ0AIAEgAHAhAQsgAUECdEGQCWooAgAhAwwECyAHIQEgCAshBQNAIAFB//8ATQRAIABBAXEgAWxBAnRBoIkCaiADNgIAIABBAXZBAXEgAWxBAnRBsIkGaiADNgIAIABBAnZBAXEgAWxBAnRBwIkKaiADNgIAIABBA3ZBAXEgAWxBAnRB0IkOaiADNgIAIABBBHZBAXEgAWxBAnRB4IkSaiADNgIAIABBBXYgAWxBAnRB8IkWaiADNgIACyABQQFqIQEgBS0AACEAIAVBAWoiBCEFIABB/wBxIgJBP2siAEHAAEkNAAsgBCEFC0EAIQQgBSEIIAEhByACIQAgAkH9AHFBIUYNAQtBBCEHIAQhAiAAQSRrDgoDAgICAgICAgIBAgtB7AhCATcCAAwCC0GoCCgCAEEEaxAABEBB0AhBATYCAA8LAkBBqAgoAgAiBkEFSA0AQaAIKQMAIQogBkEDa0EBdiIBQQdxIQJBACEAIAFBAWtBB08EQCABQfj///8HcSEFA0AgAEEDdCIBQbCJAmogCjcDACABQQhyQbCJAmogCjcDACABQRByQbCJAmogCjcDACABQRhyQbCJAmogCjcDACABQSByQbCJAmogCjcDACABQShyQbCJAmogCjcDACABQTByQbCJAmogCjcDACABQThyQbCJAmogCjcDACAAQQhqIQAgBUEIayIFDQALCyACRQ0AA0AgAEEDdEGwiQJqIAo3AwAgAEEBaiEAIAJBAWsiAg0ACwtBwIkGQbCJAiAGQQJ0IgD8CgAAQdCJCkGwiQIgAPwKAABB4IkOQbCJAiAA/AoAAEHwiRJBsIkCIAD8CgAAQYCKFkGwiQIgAPwKAAAgBCECDAELAkAgAEE7Rw0AQewIKAIAIgBBB0oNAEHsCCAAQQFqNgIAIABBAnRB8AhqQQA2AgALIAEhBwsgCCAJSQ0ACwtB5AggAzYCAEHgCCACNgIAQegIIAc2AgAL4gcCBX8BfgJAQdAIAn8CQAJAIAAgAU4NACABQZCJAWohBiAAQZCJAWohBQNAIAUtAAAiA0H/AHEhAgJAAkACQAJAAkACQAJAQeAIKAIAIgRBIkcEQCAEDQcgAkEiRgRAQewIQgE3AgBB4AhBIjYCAAwICyACQT9rQcAASQ0GIANBIWsiAkEMTQ0BDAULAkAgAkEwayIEQQlNBEBB7AgoAgBBAnRB7AhqIgIgBCACKAIAQQpsajYCAAwBC0HsCCgCACEEIAJBO0YEQCAEQQdKDQFB7AggBEEBajYCACAEQQJ0QfAIakEANgIADAELIARBBEYEQEHECEECNgIAQbAIQfAIKQMANwMAQbgIQfgIKAIAIgI2AgBBvAhB/AgoAgAiBDYCAEHICEECQQFBwAgoAgAiAxs2AgBBrAggBEEAIAMbNgIAQagIIAJBgIABIAJBgIABSBtBBGpBACADGzYCAEHgCEEANgIADAoLIAJBP2tBwABJDQQLIANBIWsiAkEMTQ0BDAILQQEgAnRBjSBxRQ0DDAQLQQEgAnRBjSBxDQELIANBoQFrIgJBDEsNA0EBIAJ0QY0gcUUNAwtBxAhCgYCAgBA3AgBBsAhB8AgoAgBBAEHsCCgCACICQQBKGzYCAEG0CEH0CCgCAEEAIAJBAUobNgIAQbgIQfgIKAIAQQAgAkECShs2AgBB4AhBADYCAEG8CEEANgIADAQLIANBoQFrIgJBDEsNAUEBIAJ0QY0gcUUNAQtBxAhCgYCAgBA3AgBBsAhCADcDAEG4CEIANwMADAMLIAVBAWoiBSAGSQ0ACwsCQEHICCgCAA4DAwEAAQsCQEGoCCgCACIFQQVIDQBBoAgpAwAhByAFQQNrQQF2IgNBB3EhBEEAIQIgA0EBa0EHTwRAIANB+P///wdxIQYDQCACQQN0IgNBsIkCaiAHNwMAIANBCHJBsIkCaiAHNwMAIANBEHJBsIkCaiAHNwMAIANBGHJBsIkCaiAHNwMAIANBIHJBsIkCaiAHNwMAIANBKHJBsIkCaiAHNwMAIANBMHJBsIkCaiAHNwMAIANBOHJBsIkCaiAHNwMAIAJBCGohAiAGQQhrIgYNAAsLIARFDQADQCACQQN0QbCJAmogBzcDACACQQFqIQIgBEEBayIEDQALC0HAiQZBsIkCIAVBAnQiA/wKAABB0IkKQbCJAiAD/AoAAEHgiQ5BsIkCIAP8CgAAQfCJEkGwiQIgA/wKAABBgIoWQbCJAiAD/AoAAEECDAELEAhByAgoAgALEAEiAjYCACACDQAgACABQcgIKAIAQQJ0QYAIaigCABEBAAsLdABB6AhBBDYCAEHkCCAANgIAQewIQgE3AgBBxAhCADcCAEHACCADNgIAQdwIQgA3AgBBqAhCADcDAEGwCEIANwMAQbgIQgA3AwBBzAggAkGAICACQYAgSRs2AgBBoAggAa1CgYCAgBB+NwMAQdAIQQA2AgALIwBB0AgoAgBFBEAgACABQcgIKAIAQQJ0QYAIaigCABEBAAsLWgECfwJAAkACQEHICCgCAEEBaw4CAAECC0HYCEHoCCgCACIAQdgIKAIAIgEgACABShsiAEGAgAEgAEGAgAFIGyIANgIAIABBBGsPC0GoCCgCAEEEayEACyAAC0IBAX8Cf0EGQdwIKAIAIgBBIHENABpBBSAAQRBxDQAaQQQgAEEIcQ0AGkEDIABBBHENABpBAiAAQQFxIABBAnEbCwu9BQEFfQJ/IAJFBEAgAUH/AWxBMmpB5ABtIgBBCHQgAHIgAEEQdHIMAQsgArJDAADIQpUhBiAAQfABarJDAAC0Q5UhBQJ9IAGyQwAAyEKVIgNDAAAAP10EQCADIAZDAACAP5KUDAELIAYgA0MAAIA/IAaTlJILIQcgAyADkiEGAkAgBUOrqqo+kiIEQwAAAABdBEAgBEMAAIA/kiEEDAELIARDAACAP15FDQAgBEMAAIC/kiEECyAGIAeTIQMgBUMAAAAAXSEAAn8CfSADIAcgA5NDAADAQJQgBJSSIARDq6oqPl0NABogByAEQwAAAD9dDQAaIAMgBEOrqio/XUUNABogAyAHIAOTIARDAADAwJRDAACAQJKUkgtDAAB/Q5RDAAAAP5IiBkMAAIBPXSAGQwAAAABgcQRAIAapDAELQQALIQECQCAABEAgBUMAAIA/kiEEDAELIAUiBEMAAIA/XkUNACAFQwAAgL+SIQQLIAVDq6qqvpIiBUMAAAAAXSECAn8CfSADIAcgA5NDAADAQJQgBJSSIARDq6oqPl0NABogByAEQwAAAD9dDQAaIAMgBEOrqio/XUUNABogAyAHIAOTIARDAADAwJRDAACAQJKUkgtDAAB/Q5RDAAAAP5IiBkMAAIBPXSAGQwAAAABgcQRAIAapDAELQQALIQACQCACBEAgBUMAAIA/kiEFDAELIAVDAACAP15FDQAgBUMAAIC/kiEFCwJAIAVDq6oqPl0EQCADIAcgA5NDAADAQJQgBZSSIQcMAQsgBUMAAAA/XQ0AIAVDq6oqP11FBEAgAyEHDAELIAMgByADkyAFQwAAwMCUQwAAgECSlJIhBwsgAEEIdAJ/IAdDAAB/Q5RDAAAAP5IiBkMAAIBPXSAGQwAAAABgcQRAIAapDAELQQALQRB0ciABcgtBgICAeHILNwAgAEH/AWxBMmpB5ABtIAFB/wFsQTJqQeQAbUEIdHIgAkH/AWxBMmpB5ABtQRB0ckGAgIB4cgsEACMACwYAIAAkAAsQACMAIABrQXBxIgAkACAACwsYAQBBgAgLEQEAAAACAAAAAwAAAAQAAAAF"}});var je=M(b=>{"use strict";Object.defineProperty(b,"__esModule",{value:!0});b.decodeAsync=b.decode=b.Decoder=b.DecoderAsync=void 0;var O=j(),Q=xe();function Mt(r){if(typeof Buffer<"u")return Buffer.from(r,"base64");let e=atob(r),t=new Uint8Array(e.length);for(let i=0;i1,this.modeHandler=e=>1}handle_band(e){return this.bandHandler(e)}mode_parsed(e){return this.modeHandler(e)}},vt={memoryLimit:2048*65536,sixelColor:O.DEFAULT_FOREGROUND,fillColor:O.DEFAULT_BACKGROUND,palette:O.PALETTE_VT340_COLOR,paletteLimit:Q.LIMITS.PALETTE_SIZE,truncate:!0};function Ze(r){let e=new Me,t={env:{handle_band:e.handle_band.bind(e),mode_parsed:e.mode_parsed.bind(e)}};return WebAssembly.instantiate(J||Xe,t).then(i=>(J=J||i.module,new P(r,i.instance||i,e)))}b.DecoderAsync=Ze;var P=class{constructor(e,t,i){if(this._PIXEL_OFFSET=Q.LIMITS.MAX_WIDTH+4,this._canvas=ne,this._bandWidths=[],this._maxWidth=0,this._minWidth=Q.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0,this._opts=Object.assign({},vt,e),this._opts.paletteLimit>Q.LIMITS.PALETTE_SIZE)throw new Error(`DecoderOptions.paletteLimit must not exceed ${Q.LIMITS.PALETTE_SIZE}`);if(t)i.bandHandler=this._handle_band.bind(this),i.modeHandler=this._initCanvas.bind(this);else{let A=J||(J=new WebAssembly.Module(Xe));t=new WebAssembly.Instance(A,{env:{handle_band:this._handle_band.bind(this),mode_parsed:this._initCanvas.bind(this)}})}this._instance=t,this._wasm=this._instance.exports,this._chunk=new Uint8Array(this._wasm.memory.buffer,this._wasm.get_chunk_address(),Q.LIMITS.CHUNK_SIZE),this._states=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_state_address(),12),this._palette=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_palette_address(),Q.LIMITS.PALETTE_SIZE),this._palette.set(this._opts.palette),this._pSrc=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_p0_address()),this._wasm.init(O.DEFAULT_FOREGROUND,0,this._opts.paletteLimit,0)}get _fillColor(){return this._states[0]}get _truncate(){return this._states[8]}get _rasterWidth(){return this._states[6]}get _rasterHeight(){return this._states[7]}get _width(){return this._states[2]?this._states[2]-4:0}get _height(){return this._states[3]}get _level(){return this._states[9]}get _mode(){return this._states[10]}get _paletteLimit(){return this._states[11]}_initCanvas(e){if(e===2){let t=this.width*this.height;if(t>this._canvas.length){if(this._opts.memoryLimit&&t*4>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(t)}this._maxWidth=this._width}else if(e===1)if(this._level===2){let t=Math.min(this._rasterWidth,Q.LIMITS.MAX_WIDTH)*this._rasterHeight;if(t>this._canvas.length){if(this._opts.memoryLimit&&t*4>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(t)}}else this._canvas.length<65536&&(this._canvas=new Uint32Array(65536));return 0}_realloc(e,t){let i=e+t;if(i>this._canvas.length){if(this._opts.memoryLimit&&i*4>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");let A=new Uint32Array(Math.ceil(i/65536)*65536);A.set(this._canvas),this._canvas=A}}_handle_band(e){let t=this._PIXEL_OFFSET,i=this._lastOffset;if(this._mode===2){let A=this.height-this._currentHeight,s=0;for(;s<6&&A>0;)this._canvas.set(this._pSrc.subarray(t*s,t*s+e),i+e*s),s++,A--;this._lastOffset+=e*s,this._currentHeight+=s}else if(this._mode===1){this._realloc(i,e*6),this._maxWidth=Math.max(this._maxWidth,e),this._minWidth=Math.min(this._minWidth,e);for(let A=0;A<6;++A)this._canvas.set(this._pSrc.subarray(t*A,t*A+e),i+e*A);this._bandWidths.push(e),this._lastOffset+=e*6,this._currentHeight+=6}return 0}get width(){return this._mode!==1?this._width:Math.max(this._maxWidth,this._wasm.current_width())}get height(){return this._mode!==1?this._height:this._wasm.current_width()?this._bandWidths.length*6+this._wasm.current_height():this._bandWidths.length*6}get palette(){return this._palette.subarray(0,this._paletteLimit)}get memoryUsage(){return this._canvas.byteLength+this._wasm.memory.buffer.byteLength+8*this._bandWidths.length}get properties(){return{width:this.width,height:this.height,mode:this._mode,level:this._level,truncate:!!this._truncate,paletteLimit:this._paletteLimit,fillColor:this._fillColor,memUsage:this.memoryUsage,rasterAttributes:{numerator:this._states[4],denominator:this._states[5],width:this._rasterWidth,height:this._rasterHeight}}}init(e=this._opts.fillColor,t=this._opts.palette,i=this._opts.paletteLimit,A=this._opts.truncate){this._wasm.init(this._opts.sixelColor,e,i,A?1:0),t&&this._palette.set(t.subarray(0,Q.LIMITS.PALETTE_SIZE)),this._bandWidths.length=0,this._maxWidth=0,this._minWidth=Q.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0}decode(e,t=0,i=e.length){let A=t;for(;A0){let i=this._PIXEL_OFFSET,A=this._lastOffset,s=0;for(;s<6&&t>0;)this._canvas.set(this._pSrc.subarray(i*s,i*s+e),A+e*s),s++,t--;t&&this._canvas.fill(this._fillColor,A+e*s)}return this._canvas.subarray(0,this.width*this.height)}if(this._mode===1){if(this._minWidth===this._maxWidth){let s=!1;if(e)if(e!==this._minWidth)s=!0;else{let n=this._PIXEL_OFFSET,a=this._lastOffset;this._realloc(a,e*6);for(let o=0;o<6;++o)this._canvas.set(this._pSrc.subarray(n*o,n*o+e),a+e*o)}if(!s)return this._canvas.subarray(0,this.width*this.height)}let t=new Uint32Array(this.width*this.height);t.fill(this._fillColor);let i=0,A=0;for(let s=0;s{if(this._disposed)return H(()=>{});let A={fn:e,thisArgs:t};this._listeners=this._listeners.slice(),this._listeners.push(A);let s=H(()=>{let n=this._listeners.indexOf(A);n!==-1&&(this._listeners=this._listeners.slice(),this._listeners.splice(n,1))});return i&&(Array.isArray(i)?i.push(s):i.add(s)),s},this._event)}fire(e){if(this._disposed||!this._listeners.length)return;if(this._listeners.length===1){this._listeners[0].fn.call(this._listeners[0].thisArgs,e);return}let t=this._listeners;for(let i=0,A=t.length;i{function r(s,n){return s(a=>n.fire(a))}A.forward=r;function e(s,n){return(a,o,h)=>s(d=>a.call(o,n(d)),void 0,h)}A.map=e;function t(...s){return(n,a,o)=>{let h=new N;for(let d of s)h.add(d(g=>n.call(a,g)));return o&&(Array.isArray(o)?o.push(h):o.add(h)),h}}A.any=t;function i(s,n,a){return n(a),s(o=>n(o))}A.runAndSubscribe=i})(lt||={});var _e=x(j());var D=class r extends k{constructor(t){super();this._terminal=t;this._layers=new Map;this._optionsRefresh=this._register(new X);this._oldOpen=this._terminal._core.open,this._terminal._core.open=i=>{this._oldOpen?.call(this._terminal._core,i),this._open()},this._terminal._core.screenElement&&this._open(),this._optionsRefresh.value=this._terminal._core.optionsService.onOptionChange(i=>{i==="fontSize"&&(this.rescaleCanvas(),this._renderService?.refreshRows(0,this._terminal.rows))}),this._register(H(()=>{this.removeLayerFromDom(),this.removeLayerFromDom("bottom"),this._terminal._core&&this._oldOpen&&(this._terminal._core.open=this._oldOpen,this._oldOpen=void 0),this._renderService&&this._oldSetRenderer&&(this._renderService.setRenderer=this._oldSetRenderer,this._oldSetRenderer=void 0),this._renderService=void 0,this._layers.clear(),this._placeholderBitmap?.close(),this._placeholderBitmap=void 0,this._placeholder=void 0}))}get canvas(){return this._layers.get("top")?.canvas}static createCanvas(t,i,A){let s=(t??document).createElement("canvas");return s.width=i|0,s.height=A|0,s}static createImageData(t,i,A,s){if(typeof ImageData!="function"){let n=t.createImageData(i,A);return s&&n.data.set(new Uint8ClampedArray(s,0,i*A*4)),n}return s?new ImageData(new Uint8ClampedArray(s,0,i*A*4),i,A):new ImageData(i,A)}static createImageBitmap(t){return typeof createImageBitmap!="function"?Promise.resolve(void 0):createImageBitmap(t)}showPlaceholder(t){t?!this._placeholder&&this.cellSize.height!==-1&&this._createPlaceHolder(Math.max(this.cellSize.height+1,24)):(this._placeholderBitmap?.close(),this._placeholderBitmap=void 0,this._placeholder=void 0),this._renderService?.refreshRows(0,this._terminal.rows)}get dimensions(){return this._terminal.dimensions}get cellSize(){return{width:this.dimensions?.css.cell.width||-1,height:this.dimensions?.css.cell.height||-1}}clearLines(t,i,A){let s=t*(this.dimensions?.css.cell.height||0),n=this.dimensions?.css.canvas.width||0,a=(i+1-t)*(this.dimensions?.css.cell.height||0);(!A||A==="top")&&this._layers.get("top")?.clearRect(0,s,n,a),(!A||A==="bottom")&&this._layers.get("bottom")?.clearRect(0,s,n,a)}clearAll(t){if(!t||t==="top"){let i=this._layers.get("top");i?.clearRect(0,0,i.canvas.width,i.canvas.height)}if(!t||t==="bottom"){let i=this._layers.get("bottom");i?.clearRect(0,0,i.canvas.width,i.canvas.height)}}draw(t,i,A,s,n=1){let a=this._layers.get(t.layer);if(!a)return;let{width:o,height:h}=this.cellSize;if(o===-1||h===-1)return;this._rescaleImage(t,o,h);let d=t.actual,{width:g,height:I}=t.actualCellSize,l=Math.ceil(d.width/g),c=i%l*g,u=Math.floor(i/l)*I,E=A*o,C=s*h,p=n*g+c>d.width?d.width-c:n*g,B=u+I>d.height?d.height-u:I;a.drawImage(d,Math.floor(c),Math.floor(u),Math.ceil(p),Math.ceil(B),Math.floor(E),Math.floor(C),Math.ceil(p*o/g),Math.ceil(B*h/I))}extractTile(t,i){let{width:A,height:s}=this.cellSize;if(A===-1||s===-1)return;this._rescaleImage(t,A,s);let n=t.actual,{width:a,height:o}=t.actualCellSize,h=Math.ceil(n.width/a),d=i%h*a,g=Math.floor(i/h)*o,I=a+d>n.width?n.width-d:a,l=g+o>n.height?n.height-g:o,c=r.createCanvas(this.document,Math.ceil(I*A/a),Math.ceil(l*s/o)),u=c.getContext("2d");if(u)return u.drawImage(n,Math.floor(d),Math.floor(g),Math.floor(I),Math.floor(l),0,0,c.width,c.height),c}drawPlaceholder(t,i,A=1){let s=this._layers.get("top");if(s){let{width:n,height:a}=this.cellSize;if(n===-1||a===-1||(this._placeholder?a>=this._placeholder.height&&this._createPlaceHolder(a+1):this._createPlaceHolder(Math.max(a+1,24)),!this._placeholder))return;s.drawImage(this._placeholderBitmap??this._placeholder,t*n,i*a%2?0:1,n*A,a,t*n,i*a,n*A,a)}}rescaleCanvas(){let t=this.dimensions?.css.canvas.width||0,i=this.dimensions?.css.canvas.height||0;for(let A of this._layers.values())(A.canvas.width!==t||A.canvas.height!==i)&&(A.canvas.width=t,A.canvas.height=i)}_rescaleImage(t,i,A){if(i===t.actualCellSize.width&&A===t.actualCellSize.height)return;let{width:s,height:n}=t.origCellSize;if(i===s&&A===n){t.actual=t.orig,t.actualCellSize.width=s,t.actualCellSize.height=n;return}let a=Math.ceil(t.orig.width*i/s),o=Math.ceil(t.orig.height*A/n);if(a*o>t.orig.width*t.orig.height){t.actual=t.orig,t.actualCellSize.width=s,t.actualCellSize.height=n;return}let h=r.createCanvas(this.document,a,o),d=h.getContext("2d");d&&(d.drawImage(t.orig,0,0,h.width,h.height),t.actual=h,t.actualCellSize.width=i,t.actualCellSize.height=A)}_open(){this._renderService=this._terminal._core._renderService,this._oldSetRenderer=this._renderService.setRenderer.bind(this._renderService),this._renderService.setRenderer=t=>{for(let i of[...this._layers.keys()])this.removeLayerFromDom(i);this._oldSetRenderer?.call(this._renderService,t)}}insertLayerToDom(t="top"){if(!this.document||!this._terminal._core.screenElement){console.warn("image addon: cannot insert output canvas to DOM, missing document or screenElement");return}if(this._layers.has(t))return;let i=r.createCanvas(this.document,this.dimensions?.css.canvas.width||0,this.dimensions?.css.canvas.height||0);i.classList.add(`xterm-image-layer-${t}`);let A=this._terminal._core.screenElement;A.style.isolation="isolate",t==="bottom"?(i.style.zIndex="-1",A.insertBefore(i,A.firstChild)):(i.style.zIndex="0",A.appendChild(i));let s=i.getContext("2d",{alpha:!0});if(!s){i.remove();return}this._layers.set(t,s),this.clearAll(t)}removeLayerFromDom(t="top"){let i=this._layers.get(t);i&&(i.canvas.remove(),this._layers.delete(t))}hasLayer(t){return this._layers.has(t)}_createPlaceHolder(t=24){this._placeholderBitmap?.close(),this._placeholderBitmap=void 0;let i=32,A=r.createCanvas(this.document,i,t),s=A.getContext("2d",{alpha:!1});if(!s)return;let n=r.createImageData(s,i,t),a=new Uint32Array(n.data.buffer),o=(0,_e.toRGBA8888)(0,0,0),h=(0,_e.toRGBA8888)(255,255,255);a.fill(o);for(let l=0;l{this._placeholder!==I?l?.close():this._placeholderBitmap=l}).catch(()=>{})}get document(){return this._terminal._core._coreBrowserService?.window.document}};var w={width:7,height:14},G=class r{constructor(e=0,t=0,i=-1,A=-1){this.imageId=i;this.tileId=A;this._ext=0;this._urlId=0;this._ext=e,this._urlId=t}get ext(){return this._urlId?this._ext&-469762049|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(this._ext&469762048)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return this._ext&67108863}set underlineColor(e){this._ext&=-67108864,this._ext|=e&67108863}get underlineVariantOffset(){let e=(this._ext&3758096384)>>29;return e<0?e^4294967288:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}get urlId(){return this._urlId}set urlId(e){this._urlId=e}clone(){return new r(this._ext,this._urlId,this.imageId,this.tileId)}isEmpty(){return this.underlineStyle===0&&this._urlId===0&&this.imageId===-1}},F=new G,V=class{constructor(e,t,i){this._terminal=e;this._renderer=t;this._opts=i;this._images=new Map;this._lastId=0;this._lowestId=0;this._fullyCleared=!1;this._needsFullClear=!1;this._pixelLimit=25e5;try{this.setLimit(this._opts.storageLimit)}catch(A){A instanceof Error&&console.error(A.message),console.warn(`storageLimit is set to ${this.getLimit()} MB`)}this._viewportMetrics={cols:this._terminal.cols,rows:this._terminal.rows}}dispose(){this.reset()}reset(){for(let e of this._images.values())e.marker?.dispose();this._images.clear(),this._renderer.clearAll()}getLimit(){return this._pixelLimit*4/1e6}setLimit(e){if(e<.5||e>1e3)throw RangeError("invalid storageLimit, should be at least 0.5 MB and not exceed 1G");this._pixelLimit=e/4*1e6>>>0,this._evictOldest(0)}getUsage(){return this._getStoredPixels()*4/1e6}_getStoredPixels(){let e=0;for(let t of this._images.values())t.orig&&(e+=t.orig.width*t.orig.height,t.actual&&t.actual!==t.orig&&(e+=t.actual.width*t.actual.height));return e}_delImg(e){let t=this._images.get(e);t&&(this._images.delete(e),window.ImageBitmap&&t.orig instanceof ImageBitmap&&t.orig.close(),this.onImageDeleted?.(e))}wipeAlternate(){let e=[];for(let[t,i]of this._images.entries())i.bufferType==="alternate"&&(i.marker?.dispose(),e.push(t));for(let t of e)this._delImg(t);this._needsFullClear=!0,this._fullyCleared=!1}deleteImage(e){let t=this._images.get(e);t&&(t.marker?.dispose(),this._delImg(e))}addImage(e,t){this._evictOldest(e.width*e.height);let i=this._renderer.cellSize;(i.width===-1||i.height===-1)&&(i=w);let A=Math.ceil(e.width/i.width),s=Math.ceil(e.height/i.height),n=++this._lastId,a=this._terminal._core.buffer,o=this._terminal.cols,h=this._terminal.rows,d=a.x,g=a.y,I=d,l=0;t.scrolling||(a.x=0,a.y=0,I=0),this._terminal._core._inputHandler._dirtyRowTracker.markDirty(a.y);for(let C=0;C=o);++B)this._writeToCell(p,I+B,n,C*A+B),l++;if(t.scrolling)C=h)break;a.x=I}this._terminal._core._inputHandler._dirtyRowTracker.markDirty(a.y),t.scrolling?t.cursorPos==="iip"?a.x=Math.min(I+A,o):a.x=I:(a.x=d,a.y=g);let c=[];for(let[C,p]of this._images.entries())p.tileCount<1&&(p.marker?.dispose(),c.push(C));for(let C of c)this._delImg(C);let u=this._terminal.registerMarker(0);u?.onDispose(()=>{this._images.get(n)&&this._delImg(n)}),this._terminal.buffer.active.type==="alternate"&&this._evictOnAlternate();let E={orig:e,origCellSize:i,actual:e,actualCellSize:{...i},marker:u||void 0,tileCount:l,bufferType:this._terminal.buffer.active.type,layer:t.layer,zIndex:t.zIndex};return this._images.set(n,E),this.onImageAdded?.(),n}render(e){let t=!1,i=!1;for(let d of this._images.values())if(d.layer==="bottom"?i=!0:t=!0,t&&i)break;if(t&&!this._renderer.hasLayer("top")&&(this._renderer.insertLayerToDom("top"),!this._renderer.hasLayer("top")))return;if(i&&!this._renderer.hasLayer("bottom")&&this._renderer.insertLayerToDom("bottom"),this._renderer.rescaleCanvas(),!this._images.size){this._fullyCleared||(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1),this._renderer.hasLayer("top")&&this._renderer.removeLayerFromDom("top"),this._renderer.hasLayer("bottom")&&this._renderer.removeLayerFromDom("bottom");return}!t&&this._renderer.hasLayer("top")&&(this._renderer.clearAll("top"),this._renderer.removeLayerFromDom("top")),!i&&this._renderer.hasLayer("bottom")&&(this._renderer.clearAll("bottom"),this._renderer.removeLayerFromDom("bottom")),this._needsFullClear&&(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1);let{start:A,end:s}=e,n=this._terminal._core.buffer,a=this._terminal._core.cols;this._renderer.clearLines(A,s);let o=[],h=[];for(let d=A;d<=s;++d){let g=n.lines.get(d+n.ydisp);if(!g)return;for(let I=0;Id.imgSpec.zIndex-g.imgSpec.zIndex);for(let d of h)this._renderer.drawPlaceholder(d.col,d.row,d.count);for(let d of o)this._renderer.draw(d.imgSpec,d.tileId,d.col,d.row,d.count)}viewportResize(e){if(!this._images.size){this._viewportMetrics=e;return}if(this._viewportMetrics.cols>=e.cols){this._viewportMetrics=e;return}let t=this._terminal._core.buffer,i=t.lines.length,A=this._viewportMetrics.cols-1;for(let s=0;s=d)continue;let g=!1;for(let c=A+1;c>e.cols;++c)if(n._data[c*3+0]&4194303){g=!0;break}if(g)continue;let I=Math.min(e.cols,d-a.tileId%d+A),l=a.tileId;for(let c=A+1;c57)throw new Error("illegal char");e=e*10+r[t]-48}return e}function Oe(r){let e=$(r);if(!e.match(/^((auto)|(\d+?((px)|(%)){0,1}))$/))throw new Error("illegal size");return e}function Tt(r){if(typeof Buffer<"u")return Buffer.from($(r),"base64").toString();let e=atob($(r)),t=new Uint8Array(e.length);for(let i=0;i14)return-1;for(let a=t;a=Se)return this._a();n[s++]=o}break;case 58:return A===3&&!this._storeValue(s)?this._a():(this.state=4,a+1);default:if(s>=Se)return this._a();n[s++]=o}}return this.state=A,this._position=s,-2}_a(){return this.fields.type=0,this.state=1,-1}_storeKey(e){let t=$(this._buffer.subarray(0,e));return t?(this._key=t,this.fields[t]=null,!0):!1}_storeValue(e){if(this._key){try{let t=this._buffer.slice(0,e);this.fields[this._key]=Je[this._key]?Je[this._key](t):t}catch{return!1}return!0}return!1}};var L={mime:"unsupported",width:0,height:0};function te(r){if(r.length<32)return L;let e=new Uint32Array(r.buffer,r.byteOffset,8);if(e[0]===1196314761&&e[1]===169478669&&e[3]===1380206665)return{mime:"image/png",width:r[16]<<24|r[17]<<16|r[18]<<8|r[19],height:r[20]<<24|r[21]<<16|r[22]<<8|r[23]};if(r[0]===255&&r[1]===216&&r[2]===255){let[t,i]=xt(r);return{mime:"image/jpeg",width:t,height:i}}if(e[0]===944130375&&(r[4]===55||r[4]===57)&&r[5]===97)return{mime:"image/gif",width:r[7]<<8|r[6],height:r[9]<<8|r[8]};if(e[0]===1718185841)return{mime:"image/qoi",width:r[4]<<24|r[5]<<16|r[6]<<8|r[7],height:r[8]<<24|r[9]<<16|r[10]<<8|r[11]};if(e[0]===1179011410&&e[2]===1346520407&&(e[3]&16777215)===3690582){switch(r[15]){case 88:return{mime:"image/webp",width:(r[24]|r[25]<<8|r[26]<<16)+1,height:(r[27]|r[28]<<8|r[29]<<16)+1};case 76:if(r[20]!==47)return L;let t=r[21]|r[22]<<8|r[23]<<16|r[24]<<24;return{mime:"image/webp",width:(t&16383)+1,height:(t>>>14&16383)+1};case 32:return r[23]!==157||r[24]!==1||r[25]!==42?L:{mime:"image/webp",width:(r[26]|r[27]<<8)&16383,height:(r[28]|r[29]<<8)&16383}}return L}if(e[1]===1887007846&&(e[2]===1718187617||e[2]===1936291425)){let t=-1,i=Math.min(r.length-16,1024);for(let A=8;A0&&s>0)return{mime:"image/avif",width:A,height:s}}return L}return L}function xt(r){let e=r.length,t=4,i=r[t]<<8|r[t+1];for(;;){if(t+=i,t>=e)return[0,0];if(r[t]!==255)return[0,0];if(r[t+1]===192||r[t+1]===194)return t+80){if(this._hp.fields.type===1){if(this._isMultipart&&(this._isMultipart=!1,this._abortMulti=!1,this._dec.release()),this._header=Object.assign({},Le,this._hp.fields),!this._header.inline){this._aborted=!0;return}if(!this._initDecoder()){this._aborted=!0;return}}else if(this._abortMulti){this._aborted=!0;return}this._dec.put(e.subarray(A,i))!==0&&(this._dec.release(),this._aborted=!0,this._isMultipart&&(this._abortMulti=!0))}}}end(e){if(this._aborted||this._hp.state!==4&&this._hp.end())return!0;let t=this._hp.fields.type;if(t===3)return!0;if(t===5){let h=w.width,d=w.height;this._renderer.dimensions&&(h=this._renderer.dimensions.css.canvas.width/this._coreTerminal.cols,d=this._renderer.dimensions.css.canvas.height/this._coreTerminal.rows);let g=this._coreTerminal._core._coreBrowserService?.dpr??1,I=`\x1B]1337;ReportCellSize=${d.toFixed(3)};${h.toFixed(3)};${g.toFixed(3)}\x1B\\`;return this._coreTerminal.input(I,!1),!0}if(t===2)return this._header=Object.assign({},Le,this._hp.fields),this._isMultipart=!0,this._abortMulti=!1,this._dec.release(),this._initDecoder()||(this._abortMulti=!0),!0;if(t===4&&(!this._isMultipart||(this._isMultipart=!1,this._abortMulti||this._header.type!==2)))return!0;let i=0,A=0,s,n=L;if((s=e)&&((s=!this._dec.end())?(n=te(this._dec.data8),(s=n.mime!=="unsupported")?(i=n.width,A=n.height,(s=i&&A&&i*Ao!==this._generation?(h.close(),!0):(this._storage.addImage(h),!0)).catch(h=>(console.warn(`IIP: decoding error ${n.mime} ${n.width}x${n.height}`,h),!0))}_initDecoder(){try{return this._dec.init(),!0}catch(e){return console.warn("IIP: could not allocate decoder",e),this._dec.release(),!1}}_resize(e,t){let i=this._renderer.dimensions?.css.cell.width||w.width,A=this._renderer.dimensions?.css.cell.height||w.height,s=this._renderer.dimensions?.css.canvas.width||i*this._coreTerminal.cols,n=this._renderer.dimensions?.css.canvas.height||A*this._coreTerminal.rows,a=this._dim(this._header.width,s,i),o=this._dim(this._header.height,n,A);if(!a&&!o){let h=s/e,d=(n-A)/t,g=Math.min(h,d);return g<1?[e*g,t*g]:[e,t]}return a?this._header.preserveAspectRatio||!a||!o?[a,t*a/e]:[a,o]:[e*o/t,o]}_dim(e,t,i){return e==="auto"?0:e.endsWith("%")?parseInt(e.slice(0,-1),10)*t/100:e.endsWith("px")?parseInt(e.slice(0,-2),10):parseInt(e,10)*i}};var We=x(Be());function Te(r){let e={},t=r.split(",");for(let i of t){let A=i.indexOf("=");if(A===-1)continue;let s=i.substring(0,A),n=i.substring(A+1);if(s==="a"){e.action=n;continue}if(s==="o"){e.compression=n;continue}if(s==="t"){e.transmission=n;continue}if(s==="d"){e.deleteSelector=n;continue}let a=parseInt(n,10);switch(s){case"f":e.format=a;break;case"i":e.id=a;break;case"I":e.imageNumber=a;break;case"s":e.width=a;break;case"v":e.height=a;break;case"x":e.x=a;break;case"y":e.y=a;break;case"w":e.sourceWidth=a;break;case"h":e.sourceHeight=a;break;case"X":e.xOffset=a;break;case"Y":e.yOffset=a;break;case"c":e.columns=a;break;case"r":e.rows=a;break;case"m":e.more=a;break;case"q":e.quiet=a;break;case"C":e.cursorMovement=a;break;case"z":e.zIndex=a;break;case"p":e.placementId=a;break}}return e}var ze=0,Ae=class{constructor(e,t,i,A){this._opts=e;this._renderer=t;this._kittyStorage=i;this._coreTerminal=A;this._aborted=!1;this._generation=0;this._decodeError=!1;this._activeDecoder=null;this._inControlData=!0;this._controlData=new Uint32Array(512);this._controlLength=0;this._encodedSizeLimit=0;this._totalEncodedSize=0;this._parsedCommand=null;this._pendingTransmissions=new Map;this._maxEncodedBytes=Math.ceil(this._opts.kittySizeLimit*4/3),this._initialEncodedBytes=Math.min(4194304,this._maxEncodedBytes)}reset(){this._generation++,this._cleanupAllPending(),this._activeDecoder&&(this._activeDecoder.release(),this._activeDecoder=null),this._kittyStorage.reset()}dispose(){this.reset()}_removePendingEntry(e){this._pendingTransmissions.delete(e),this._lastPendingKey===e&&(this._lastPendingKey=void 0)}_cleanupAllPending(){for(let e of this._pendingTransmissions.values())e.decoder.release();this._pendingTransmissions.clear(),this._lastPendingKey=void 0}start(){this._aborted=!1,this._decodeError=!1,this._inControlData=!0,this._controlLength=0,this._parsedCommand=null,this._encodedSizeLimit=this._maxEncodedBytes,this._totalEncodedSize=0,this._activeDecoder=null}put(e,t,i){if(!this._aborted)if(!this._inControlData)this._streamPayload(e,t,i);else{let A=i;for(let n=t;n512){this._aborted=!0;return}if(this._controlData.set(e.subarray(t,A),this._controlLength),this._controlLength+=s,!this._inControlData){if(this._parsedCommand=Te(this._parseControlDataString()),this._parsedCommand.id!==void 0&&this._parsedCommand.imageNumber!==void 0){this._sendResponse(this._parsedCommand.id,"EINVAL:cannot specify both i and I keys",this._parsedCommand.quiet??0),this._aborted=!0;return}if(this._parsedCommand.action==="d")return;let n=A+1;nthis._encodedSizeLimit){let o=this._activeDecoder??s?.decoder;o&&o.release(),this._activeDecoder=null,s&&this._removePendingEntry(A),this._aborted=!0;return}if(!this._decodeError){if(s?.decoder&&!this._activeDecoder&&(this._activeDecoder=s.decoder),!this._activeDecoder){let o=this._maxEncodedBytes+131072;if(o>this._opts.storageLimit*1e6){this._aborted=!0,this._parsedCommand?.id!==void 0&&this._sendResponse(this._parsedCommand.id,"ENOMEM:pending image budget exceeded",this._parsedCommand.quiet??0);return}let h=Math.max(1,Math.floor(this._opts.storageLimit*1e6/o));for(;this._pendingTransmissions.size>=h;){let g=this._pendingTransmissions.entries().next().value;if(!g)break;g[1].decoder.release(),this._removePendingEntry(g[0]),g[1].cmd.id!==void 0&&this._sendResponse(g[1].cmd.id,"ENOMEM:pending image budget exceeded",g[1].cmd.quiet??0)}let d=new We.default(4194304,this._maxEncodedBytes,this._initialEncodedBytes);try{d.init()}catch(g){console.warn("KITTY: could not allocate decoder",g),this._aborted=!0,this._parsedCommand?.id!==void 0&&this._sendResponse(this._parsedCommand.id,"ENOMEM:could not allocate decoder",this._parsedCommand.quiet??0);return}this._activeDecoder=d}this._activeDecoder.put(e.subarray(t,i))!==ze&&(this._activeDecoder.release(),this._activeDecoder=null,this._decodeError=!0,s&&this._removePendingEntry(A))}}end(e){if(this._aborted||!e)return this._activeDecoder&&(this._activeDecoder.release(),this._activeDecoder=null),!0;if(this._inControlData)return this._handleNoPayloadCommand();let t=this._parsedCommand;if(t.action==="d")return this._handleDelete(t);let i=t.id??this._lastPendingKey??0,A=t.more===1,s=this._pendingTransmissions.get(i);if(A)return this._activeDecoder&&(s?(s.totalEncodedSize+=this._totalEncodedSize,s.decodeError=s.decodeError||this._decodeError):this._pendingTransmissions.set(i,{cmd:{...t},decoder:this._activeDecoder,totalEncodedSize:this._totalEncodedSize,decodeError:this._decodeError}),this._lastPendingKey=i,this._activeDecoder=null),!0;s&&(this._lastPendingKey=void 0);let n=this._decodeError,a=t,o=this._activeDecoder;s&&(a=s.cmd,o=s.decoder,n=n||s.decodeError,this._pendingTransmissions.delete(i));let h=new Uint8Array(0);o&&(o.end()!==ze&&(n=!0),h=o.data8),this._activeDecoder=null;let d=this._handleCommandWithBytesAndCmd(a,h,n);return o&&o.release(),d}_parseControlDataString(){let e="";for(let t=0;t0&&this._sendResponse(e.id,"OK",e.quiet??0)),s}case"T":return this._handleTransmitDisplay(e,t,i);case"q":return this._handleQuery(e,t,i);case"p":return this._handlePlacement(e);default:return e.id!==void 0&&this._sendResponse(e.id,"EINVAL:unsupported action",e.quiet??0),!0}}_handlePlacement(e){if(e.id===void 0)return!0;let t=e.id,i=this._kittyStorage.getImage(t);return i?this._displayImage(i,e).then(s=>(this._sendResponse(t,s?"OK":"EINVAL:image rendering failed",e.quiet??0,e.placementId),!0)):(this._sendResponse(t,"ENOENT:image not found",e.quiet??0,e.placementId),!0)}_handleTransmit(e,t,i){return(e.transmission??"d")!=="d"?(e.id!==void 0&&this._sendResponse(e.id,"EINVAL:unsupported transmission medium",e.quiet??0),!0):(i||t.length===0||this._kittyStorage.storeImage(e.id,{data:new Blob([t]),width:e.width??0,height:e.height??0,format:e.format??32,compression:e.compression??""}),!0)}_handleTransmitDisplay(e,t,i){if(i)return e.id!==void 0&&this._sendResponse(e.id,"EINVAL:invalid base64 data",e.quiet??0),!0;this._handleTransmit(e,t,i);let A=e.id??this._kittyStorage.lastImageId,s=this._kittyStorage.getImage(A);if(s){let n=this._displayImage(s,e);return e.id!==void 0?n.then(a=>(this._sendResponse(A,a?"OK":"EINVAL:image rendering failed",e.quiet??0),!0)):n.then(()=>!0)}return!0}_handleQuery(e,t,i){let A=e.id??0,s=e.quiet??0;if((e.transmission??"d")!=="d")return this._sendResponse(A,"EINVAL:unsupported transmission medium",s),!0;if(i)return this._sendResponse(A,"EINVAL:invalid base64 data",s),!0;if(t.length===0)return this._sendResponse(A,"OK",s),!0;let a=e.format??32;if(a===100)this._sendResponse(A,"OK",s);else{let o=e.width??0,h=e.height??0;if(!o||!h)return this._sendResponse(A,"EINVAL:width and height required for raw pixel data",s),!0;let d=a===32?4:3,g=o*h*d;if(t.length=1||!s&&i>=2)return;let n=A?`,p=${A}`:"",a=`\x1B_Gi=${e}${n};${t}\x1B\\`;this._coreTerminal._core.coreService.triggerDataEvent(a)}_displayImage(e,t){return this._decodeAndDisplay(e,t).then(()=>!0).catch(()=>!1)}async _decodeAndDisplay(e,t){let i=this._generation,A=await this._createBitmap(e);try{if(i!==this._generation)throw new Error("image decode canceled");let s=Math.max(0,t.x??0),n=Math.max(0,t.y??0),a=t.sourceWidth||A.width-s,o=t.sourceHeight||A.height-n,h=Math.max(0,A.width-s),d=Math.max(0,A.height-n),g=Math.max(0,Math.min(a,h)),I=Math.max(0,Math.min(o,d));if(g===0||I===0)throw new Error("invalid source rectangle");if(s!==0||n!==0||g!==A.width||I!==A.height){let T=await createImageBitmap(A,s,n,g,I);A.close(),A=T}let l=this._renderer.dimensions?.css.cell.width||w.width,c=this._renderer.dimensions?.css.cell.height||w.height,u,E;t.columns!==void 0&&t.rows!==void 0?(u=t.columns,E=t.rows):t.columns!==void 0?(u=t.columns,E=Math.max(1,Math.ceil(A.height/A.width*(u*l)/c))):t.rows!==void 0?(E=t.rows,u=Math.max(1,Math.ceil(A.width/A.height*(E*c)/l))):(u=Math.ceil(A.width/l),E=Math.ceil(A.height/c));let C=A.width,p=A.height;if((t.columns!==void 0||t.rows!==void 0)&&(C=Math.round(u*l),p=Math.round(E*c)),C*p>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");let B=this._coreTerminal._core.buffer,Y=B.x,q=B.y,z=B.ybase,At=t.zIndex!==void 0&&t.zIndex<0?"bottom":"top";if(C!==A.width||p!==A.height){let T=await createImageBitmap(A,{resizeWidth:C,resizeHeight:p});A.close(),A=T}let Ie=Math.min(Math.max(0,t.xOffset??0),l-1),le=Math.min(Math.max(0,t.yOffset??0),c-1);if(Ie!==0||le!==0){let T=t.columns!==void 0?Math.round(u*l):A.width+Ie,st=t.rows!==void 0?Math.round(E*c):A.height+le,W=D.createCanvas(window.document,T,st),ke=W.getContext("2d");if(!ke)throw new Error("Failed to create offset canvas context");ke.drawImage(A,Ie,le);let nt=await createImageBitmap(W);if(W.width=W.height=0,A.close(),A=nt,C=A.width,p=A.height,C*p>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");t.columns===void 0&&(u=Math.ceil(A.width/l)),t.rows===void 0&&(E=Math.ceil(A.height/c))}if(i!==this._generation)throw new Error("image decode canceled");let rt=t.zIndex??0;if(this._kittyStorage.addImage(e.id,A,!0,At,rt),A=void 0,t.cursorMovement===1){let T=B.ybase-z;B.x=Y,B.y=Math.max(q-T,0)}else B.x=Math.min(Y+u,this._coreTerminal.cols)}catch(s){throw A?.close(),s}}async _createBitmap(e){let t=new Uint8Array(await e.data.arrayBuffer());if(e.compression==="z"&&(t=await this._decompressZlib(t)),e.format===100){let E=te(t);if(E.mime!=="image/png"||!(E.width>0)||!(E.height>0)||E.width*E.height>this._opts.pixelLimit)throw new RangeError("PNG exceeds pixel limit or has invalid dimensions");let C=new Blob([t],{type:"image/png"});if(!window.createImageBitmap){let p=URL.createObjectURL(C),B=new Image;return new Promise((Y,q)=>{B.addEventListener("load",()=>{URL.revokeObjectURL(p);let z=D.createCanvas(window.document,B.width,B.height);z.getContext("2d")?.drawImage(B,0,0),createImageBitmap(z).then(Y).catch(q)}),B.addEventListener("error",()=>{URL.revokeObjectURL(p),q(new Error("Failed to load image"))}),B.src=p})}return createImageBitmap(C)}let i=e.width,A=e.height;if(!i||!A)throw new Error("Width and height required for raw pixel data");let s=e.format===32?4:3,n=i*A*s;if(t.length>>24|p<<8,d[l++]=4278190080|p>>>16|B<<16,d[l++]=4278190080|B>>>8}let c=g*3,u=g*4;for(let E=g;E=e.length){g.close();return}let I=Math.min(A+4096,e.length);g.enqueue(new Uint8Array(e.subarray(A,I))),A=I}}).pipeThrough(new DecompressionStream(t)).getReader(),a=[],o=0;try{for(;;){let{done:g,value:I}=await n.read();if(g)break;if(o+=I.byteLength,o>i)throw await n.cancel().catch(()=>{}),new RangeError("decompressed image exceeds byte limit");a.push(I)}}finally{n.releaseLock()}let h=new Uint8Array(o),d=0;for(let g of a)h.set(g,d),d+=g.length;return h}get images(){return this._kittyStorage.images}get _kittyIdToStorageId(){return this._kittyStorage.kittyIdToStorageId}get pendingTransmissions(){return this._pendingTransmissions}};var U=class U{constructor(e){this._storage=e;this._nextImageId=1;this._images=new Map;this._kittyIdToStorageId=new Map;this._storageIdToKittyId=new Map;this._handleStorageImageDeleted=e=>{let t=this._storageIdToKittyId.get(e);t!==void 0&&(this._kittyIdToStorageId.delete(t),this._storageIdToKittyId.delete(e),this._images.delete(t))};this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"iip"};this._previousOnImageDeleted=this._storage.onImageDeleted,this._wrappedOnImageDeleted=t=>{this._previousOnImageDeleted?.(t),this._handleStorageImageDeleted(t)},this._storage.onImageDeleted=this._wrappedOnImageDeleted}reset(){this._nextImageId=1,this._images.clear(),this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}dispose(){this.reset(),this._storage.onImageDeleted===this._wrappedOnImageDeleted&&(this._storage.onImageDeleted=this._previousOnImageDeleted)}storeImage(e,t){let i=e??this._nextImageId++,A=this._kittyIdToStorageId.get(i);A!==void 0&&(this._storage.deleteImage(A),this._kittyIdToStorageId.delete(i),this._storageIdToKittyId.delete(A)),!this._images.has(i)&&this._images.size>=U._maxStoredImages&&this._evictUndisplayedImages();let s=this._storage.getLimit()*1e6;this._images.delete(i);let n=0;for(let a of this._images.values())n+=a.data.size;for(let a of[!1,!0])for(let[o,h]of this._images){if(n+t.data.size<=s)break;this._kittyIdToStorageId.has(o)===a&&(n-=h.data.size,this.deleteById(o))}return this._images.set(i,{...t,id:i}),i}addImage(e,t,i,A,s){let n=this._kittyIdToStorageId.get(e);n!==void 0&&this._storageIdToKittyId.delete(n),this._addImageOpts.scrolling=i,this._addImageOpts.layer=A,this._addImageOpts.zIndex=s;let a=this._storage.addImage(t,this._addImageOpts);this._kittyIdToStorageId.set(e,a),this._storageIdToKittyId.set(a,e)}getImage(e){return this._images.get(e)}deleteById(e){this._images.delete(e);let t=this._kittyIdToStorageId.get(e);t!==void 0&&(this._storage.deleteImage(t),this._kittyIdToStorageId.delete(e),this._storageIdToKittyId.delete(t))}deleteAll(){this._images.clear();for(let e of this._kittyIdToStorageId.values())this._storage.deleteImage(e);this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}get images(){return this._images}get kittyIdToStorageId(){return this._kittyIdToStorageId}get lastImageId(){return this._nextImageId-1}_evictUndisplayedImages(){for(let[e]of this._images){if(this._images.size<=U._maxStoredImages/2)break;this._kittyIdToStorageId.has(e)||this._images.delete(e)}}};U._maxStoredImages=256;var re=U;var y=x(j());var he=x(je()),Ve=x(xe());var Nt=4194304,K=y.PALETTE_ANSI_256;K.set(y.PALETTE_VT340_COLOR);var Ht=2,Re=new Map,ve=!1;function Ft(r){ve||(ve=!0,(0,he.DecoderAsync)({memoryLimit:r,palette:K}).then(e=>$e(e,r),()=>{ve=!1}))}function Gt(r){return Re.get(r)?.pop()??new he.Decoder({memoryLimit:r,palette:K})}function $e(r,e){r.memoryUsage>Nt&&r.release();let t=Re.get(e)??[];t.lengththis._opts.sixelSizeLimit){console.warn("SIXEL: too much data, aborting"),this._aborted=!0,this._dec.release();return}try{this._dec.decode(e,t,i)}catch(A){console.warn(`SIXEL: error while decoding image - ${A}`),this._aborted=!0,this._dec.release()}}}unhook(e){try{return this._unhook(e)}finally{this._returnDecoder()}}_unhook(e){if(this._aborted||!e||!this._dec)return!0;let t=this._dec.width,i=this._dec.height;if(!t||!i)return i&&this._storage.advanceCursor(i),!0;let A=D.createCanvas(void 0,t,i);return A.getContext("2d")?.putImageData(new ImageData(this._dec.data8,t,i),0,0),this._storage.addImage(A),!0}};function Ut(r,e){let t=0;if(!e)return t;if(r.isInverse())if(r.isFgDefault())t=ae(e.foreground.rgba);else if(r.isFgRGB()){let i=r.constructor.toColorRGB(r.getFgColor());t=(0,y.toRGBA8888)(...i)}else t=ae(e.ansi[r.getFgColor()].rgba);else if(r.isBgDefault())t=ae(e.background.rgba);else if(r.isBgRGB()){let i=r.constructor.toColorRGB(r.getBgColor());t=(0,y.toRGBA8888)(...i)}else t=ae(e.ansi[r.getBgColor()].rgba);return t}function ae(r){return y.BIG_ENDIAN?r:(r&255)<<24|(r>>>8&255)<<16|(r>>>16&255)<<8|r>>>24&255}var de=class{constructor(e,t,i,A){this._storage=e;this._opts=t;this._renderer=i;this._terminal=A;this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"vt340"}}addImage(e){this._addImageOpts.scrolling=this._opts.sixelScrolling,this._storage.addImage(e,this._addImageOpts)}advanceCursor(e){if(this._opts.sixelScrolling){let t=this._renderer.cellSize;(t.width===-1||t.height===-1)&&(t=w);let i=Math.ceil(e/t.height);for(let A=1;Athis._onImageAdded.fire(),this._opts.enableSizeReports){let t=e.options.windowOptions??{};t.getWinSizePixels=!0,t.getCellSizePixels=!0,t.getWinSizeChars=!0,e.options.windowOptions=t}if(this._disposeLater(this._renderer,this._storage,e.parser.registerCsiHandler({prefix:"?",final:"h"},t=>this._decset(t)),e.parser.registerCsiHandler({prefix:"?",final:"l"},t=>this._decrst(t)),e.parser.registerCsiHandler({final:"c"},t=>this._da1(t)),e.parser.registerCsiHandler({prefix:"?",final:"S"},t=>this._xtermGraphicsAttributes(t)),e.onRender(t=>this._storage?.render(t)),e.parser.registerCsiHandler({intermediates:"!",final:"p"},()=>this.reset()),e.parser.registerEscHandler({final:"c"},()=>this.reset()),e._core._inputHandler.onRequestReset(()=>this.reset()),e.buffer.onBufferChange(()=>this._storage?.wipeAlternate()),e.onResize(t=>this._storage?.viewportResize(t))),this._opts.sixelSupport){let t=new de(this._storage,this._opts,this._renderer,e),i=new oe(this._opts,t,e);this._handlers.set("sixel",i),this._disposeLater(e._core._inputHandler._parser.registerDcsHandler({final:"q"},i))}if(this._opts.iipSupport){let t=new ge(this._storage),i=new ie(this._opts,this._renderer,t,e);this._handlers.set("iip",i),this._disposeLater(e._core._inputHandler._parser.registerOscHandler(1337,i))}if(this._opts.kittySupport){let t=new re(this._storage),i=new Ae(this._opts,this._renderer,t,e);this._handlers.set("kitty",i),this._disposeLater(t,i,e._core._inputHandler._parser.registerApcHandler({final:"G"},i))}}reset(){this._opts.sixelScrolling=this._defaultOpts.sixelScrolling,this._opts.sixelPaletteLimit=this._defaultOpts.sixelPaletteLimit,this._storage?.reset();for(let e of this._handlers.values())e.reset();return!1}get storageLimit(){return this._storage?.getLimit()||-1}set storageLimit(e){this._storage?.setLimit(e),this._opts.storageLimit=e}get storageUsage(){return this._storage?this._storage.getUsage():-1}get showPlaceholder(){return this._opts.showPlaceholder}set showPlaceholder(e){this._opts.showPlaceholder=e,this._renderer?.showPlaceholder(e)}getImageAtBufferCell(e,t){return this._storage?.getImageAtBufferCell(e,t)}extractTileAtBufferCell(e,t){return this._storage?.extractTileAtBufferCell(e,t)}_report(e){this._terminal?._core.input(e,!1)}_decset(e){for(let t=0;t2&&!(e[2]instanceof Array)&&e[2]<=tt?(this._opts.sixelPaletteLimit=e[2],this._report(`\x1B[?${e[0]};0;${this._opts.sixelPaletteLimit}S`)):this._report(`\x1B[?${e[0]};2S`),!0;case 4:return this._report(`\x1B[?${e[0]};0;${tt}S`),!0;default:return this._report(`\x1B[?${e[0]};2S`),!0}if(e[0]===2)switch(e[1]){case 1:let t=this._renderer?.dimensions?.css.canvas.width,i=this._renderer?.dimensions?.css.canvas.height;if(!t||!i){let s=w;t=(this._terminal?.cols||80)*s.width,i=(this._terminal?.rows||24)*s.height}if(t*i {\n if (Uint8Array.fromBase64)\n return Uint8Array.fromBase64(s);\n if (typeof Buffer !== 'undefined')\n return Buffer.from(s, 'base64');\n const b = atob(s);\n const r = new Uint8Array(b.length);\n for (let i = 0; i < r.length; ++i)\n r[i] = b.charCodeAt(i);\n return r;\n};\nfunction InWasm(def) {\n if (def.d) {\n const { t, s, d } = def;\n let b;\n let m;\n const W = WebAssembly;\n if (t === 0 /* OutputType.INSTANCE */) {\n if (s)\n return (e) => new W.Instance(m || (m = new W.Module(b || (b = z(d)))), e);\n return (e) => m\n ? W.instantiate(m, e)\n : W.instantiate(b || (b = z(d)), e).then(r => (m = r.module) && r.instance);\n }\n if (t === 1 /* OutputType.MODULE */) {\n if (s)\n return () => m || (m = new W.Module(b || (b = z(d))));\n return () => m\n ? Promise.resolve(m)\n : W.compile(b || (b = z(d))).then(r => m = r);\n }\n if (s)\n return () => b || (b = z(d));\n return () => Promise.resolve(b || (b = z(d)));\n }\n if (typeof _wasmCtx === 'undefined')\n throw new Error('must run \"inwasm\"');\n _wasmCtx.add(def);\n}\n//# sourceMappingURL=index.js.map", "\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023, 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\n/**\n * wasm base64 decoder.\n */\nconst wasmDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#828e69684093b2c6:rdef-start:\"decode\"*/{s:1,t:0,d:'AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML'}/*inwasm#828e69684093b2c6:rdef-end:\"decode\"*/);\n// SIMD version (speedup ~1.4x, not covered by tests yet)\n/*\nconst wasmDecode = InWasm({\n name: 'decode',\n type: OutputType.INSTANCE,\n mode: OutputMode.SYNC,\n srctype: 'Clang-C',\n imports: {\n env: { memory: new WebAssembly.Memory({ initial: 1 }) }\n },\n exports: {\n dec: () => 0,\n end: () => 0\n },\n compile: {\n switches: ['-msimd128', '-Wl,-z,stack-size=0', '-Wl,--stack-first']\n },\n code: `\n #include \n typedef struct {\n unsigned int wp;\n unsigned int sp;\n unsigned int dp;\n unsigned int e_size;\n unsigned int dummy[4];\n unsigned char data[0];\n } State;\n\n unsigned int *D0 = (unsigned int *) ${P32.D0 * 4};\n unsigned int *D1 = (unsigned int *) ${P32.D1 * 4};\n unsigned int *D2 = (unsigned int *) ${P32.D2 * 4};\n unsigned int *D3 = (unsigned int *) ${P32.D3 * 4};\n State *state = (State *) ${P32.STATE * 4};\n\n #define packed_byte(x) wasm_i8x16_splat((char) x)\n #define packed_dword(x) wasm_i32x4_splat(x)\n #define masked(x, mask) wasm_v128_and(x, wasm_i32x4_splat(mask))\n\n __attribute__((noinline)) int dec() {\n unsigned int nsp = (state->wp - 1) & ~3;\n unsigned char *src = state->data + state->sp;\n unsigned char *end = state->data + nsp;\n unsigned char *dst = state->data + state->dp;\n unsigned int error = 0;\n\n v128_t err = wasm_i8x16_splat(0);\n unsigned char *end16 = state->data + (nsp & ~15);\n while (src < end16) {\n v128_t data = wasm_v128_load((v128_t *) src);\n\n // wasm-simd rewrite of http://0x80.pl/notesen/2016-01-17-sse-base64-decoding.html#vector-lookup-pshufb\n const v128_t higher_nibble = wasm_u32x4_shr(data, 4) & packed_byte(0x0f);\n const char linv = 1;\n const char hinv = 0;\n\n const v128_t lower_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n linv, linv, 0x2b, 0x30,\n 0x41, 0x50, 0x61, 0x70,\n linv, linv, linv, linv,\n linv, linv, linv, linv\n );\n const v128_t upper_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n hinv, hinv, 0x2b, 0x39,\n 0x4f, 0x5a, 0x6f, 0x7a,\n hinv, hinv, hinv, hinv,\n hinv, hinv, hinv, hinv\n );\n // the difference between the shift and lower bound\n const v128_t shift_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n 0x00, 0x00, 0x3e - 0x2b, 0x34 - 0x30,\n 0x00 - 0x41, 0x0f - 0x50, 0x1a - 0x61, 0x29 - 0x70,\n 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00\n );\n\n const v128_t upper_bound = wasm_i8x16_swizzle(upper_bound_LUT, higher_nibble);\n const v128_t lower_bound = wasm_i8x16_swizzle(lower_bound_LUT, higher_nibble);\n\n const v128_t below = wasm_i8x16_lt(data, lower_bound);\n const v128_t above = wasm_i8x16_gt(data, upper_bound);\n const v128_t eq_2f = wasm_i8x16_eq(data, packed_byte(0x2f));\n\n // in_range = not (below or above) or eq_2f\n // outside = not in_range = below or above and not eq_2f (from deMorgan law)\n const v128_t outside = wasm_v128_andnot(eq_2f, above | below);\n err = wasm_v128_or(err, outside);\n\n const v128_t shift = wasm_i8x16_swizzle(shift_LUT, higher_nibble);\n const v128_t t0 = wasm_i8x16_add(data, shift);\n v128_t v = wasm_i8x16_add(t0, wasm_v128_and(eq_2f, packed_byte(-3)));\n\n // pack bytes\n const v128_t ca = masked(v, 0x003f003f);\n const v128_t db = masked(v, 0x3f003f00);\n const v128_t t00 = wasm_v128_or(wasm_u32x4_shr(db, 8), wasm_i32x4_shl(ca, 6));\n v128_t res = wasm_v128_or(wasm_u32x4_shr(t00, 16), wasm_i32x4_shl(t00, 12));\n res = wasm_i8x16_swizzle(res, wasm_i8x16_const(2, 1, 0, 6, 5, 4, 10, 9, 8, 14, 13, 12, 16, 16, 16, 16));\n\n wasm_v128_store((v128_t *) dst, res);\n dst += 12;\n src += 16;\n }\n //if (wasm_i8x16_bitmask(err) != 0) return -1;\n if (wasm_v128_any_true(err)) return -1;\n\n // operate on 4-byte blocks\n while (src < end) {\n error |= *((unsigned int *) dst) = D0[src[0]] | D1[src[1]] | D2[src[2]] | D3[src[3]];\n dst += 3;\n src += 4;\n }\n if (error >> 24) return -1;\n state->sp = nsp;\n state->dp = dst - state->data;\n return 0;\n }\n\n int end() {\n int rem = state->wp - state->sp;\n if (rem > 4 && dec()) return -1;\n rem = state->wp - state->sp;\n if (rem < 2) return -1;\n\n unsigned char *src = state->data + state->sp;\n if (rem == 4) {\n if (src[3] == 61) rem--;\n if (src[2] == 61) rem--;\n }\n unsigned int accu = D0[src[0]] | D1[src[1]];\n int dp = 1;\n if (rem > 2) {\n accu |= D2[src[2]];\n dp++;\n if (rem == 4) {\n accu |= D3[src[3]];\n dp++;\n }\n }\n if (accu >> 24) return -1;\n *((unsigned int *) (state->data + state->dp)) = accu;\n state->dp += dp;\n return 0;\n }\n `\n});\n*/\n// base64 map\nconst MAP = new Uint8Array('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'\n .split('')\n .map(el => el.charCodeAt(0)));\n// init decoder maps in LE order\nconst D = new Uint32Array(1024);\nD.fill(0xFF000000);\nfor (let i = 0; i < MAP.length; ++i)\n D[MAP[i]] = i << 2;\nfor (let i = 0; i < MAP.length; ++i)\n D[256 + MAP[i]] = i >> 4 | ((i << 4) & 0xFF) << 8;\nfor (let i = 0; i < MAP.length; ++i)\n D[512 + MAP[i]] = (i >> 2) << 8 | ((i << 6) & 0xFF) << 16;\nfor (let i = 0; i < MAP.length; ++i)\n D[768 + MAP[i]] = i << 16;\nconst EMPTY = new Uint8Array(0);\n/**\n * base64 stream decoder.\n *\n * Features / assumptions:\n * - lazy chunkwise decoding\n * - errors out on any non base64 chars (no support for NL formatted base64)\n * - decodes in wasm\n * - inplace decoding to save memory\n * - supports a keepSize for lazy memory release\n */\nclass Base64Decoder {\n /**\n * @param keepSize Keep the wasm instance below this limit when calling `release()`.\n * @param maxBytes Max allowed bytes to allocate.\n * @param initialBytes Initial bytes to allocate.\n */\n constructor(keepSize, maxBytes, initialBytes) {\n this._inst = null;\n this._ended = true;\n this._bytes = 0;\n this.keepSize = keepSize !== null && keepSize !== void 0 ? keepSize : 1048576 /* Bytes.KEEP */;\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : 4294901760 /* Bytes.MAX */;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : 32768 /* Bytes.INITIAL */;\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw new Error('invalid byte settings');\n }\n }\n /**\n * Currently decoded bytes (borrowed).\n * Must be accessed before calling `release` or `init`.\n */\n get data8() {\n return this._inst ? this._d.subarray(0, this._m32[1282 /* P32.STATE_DP */]) : EMPTY;\n }\n /**\n * Release memory conditionally based on `keepSize`.\n * If memory gets released, also the wasm instance will be freed and recreated on next `init`,\n * otherwise the instance will be reused.\n */\n release() {\n if (!this._inst)\n return;\n if (this._bytes > this.keepSize) {\n this._inst = this._m32 = this._d = this._mem = null;\n }\n else {\n this._m32[1280 /* P32.STATE_WP */] = 0;\n this._m32[1281 /* P32.STATE_SP */] = 0;\n this._m32[1282 /* P32.STATE_DP */] = 0;\n }\n }\n /**\n * Initializes the decoder for new base64 data.\n * Must be called before doing any decoding attempts.\n * The method will either spawn a new wasm instance or grow\n * the needed memory of an existing instance.\n * @param maxBytes Max allowed bytes to allocate (overwrites ctor value).\n * @param initialBytes Initial bytes to allocate (overwrites ctor value).\n */\n init(maxBytes, initialBytes) {\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : this.maxBytes;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : Math.min(this._bytes, this.maxBytes);\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw Error('invalid byte settings');\n }\n let m = this._m32;\n const bytes = this._bytes + 5152 /* Bytes._DATA_OFFSET */;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmDecode({ env: { memory: this._mem } });\n m = new Uint32Array(this._mem.buffer, 0);\n m.set(D, 256 /* P32.D0 */);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n m = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n m[1280 /* P32.STATE_WP */] = 0;\n m[1281 /* P32.STATE_SP */] = 0;\n m[1282 /* P32.STATE_DP */] = 0;\n this._m32 = m;\n this._ended = false;\n }\n /**\n * Realloc memory. Realloc only happens, if the requested\n * size doesn't fit in the current memory.\n * The new size will be capped by `maxBytes`.\n * @param requested Bytes to be stored.\n */\n _realloc(requested) {\n const needed = this._m32[1280 /* P32.STATE_WP */] + requested;\n if (this._bytes < needed) {\n if (needed > this.maxBytes) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n let newSize = this._bytes;\n while ((newSize *= 2) < needed) { }\n newSize = Math.min(newSize, this.maxBytes);\n if (newSize < needed) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n if (newSize + 5152 /* Bytes._DATA_OFFSET */ > this._mem.buffer.byteLength) {\n const addPages = Math.ceil((newSize + 5152 /* Bytes._DATA_OFFSET */ - this._mem.buffer.byteLength) / 65536);\n this._mem.grow(addPages);\n this._m32 = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n this._bytes = newSize;\n }\n return 0 /* DecodeStatus.OK */;\n }\n /**\n * Put bytes in `data` into the decoder.\n * Additionally decodes the payload, if it reached 2^17 bytes.\n * The return value indicates the type of issue.\n * @param data Bytes to be loaded.\n */\n put(data) {\n if (!this._inst || this._ended) {\n return -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n if (this._realloc(data.length)) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n const m = this._m32;\n this._d.set(data, m[1280 /* P32.STATE_WP */]);\n m[1280 /* P32.STATE_WP */] += data.length;\n // max chunk in input handler is 2^17, try to run in \"tandem mode\"\n return m[1280 /* P32.STATE_WP */] - m[1281 /* P32.STATE_SP */] >= 131072\n ? this._inst.exports.dec()\n : 0 /* DecodeStatus.OK */;\n }\n /**\n * End the current decoding.\n * Also decodes leftover payload from previous put calls.\n */\n end() {\n this._ended = true;\n return this._inst\n ? this._inst.exports.end()\n : -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n /**\n * Bytes loaded into the decoder.\n */\n get loadedBytes() {\n return this._inst\n ? this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n /**\n * Free bytes to feed to the decoder.\n */\n get freeBytes() {\n return this._inst\n ? this.maxBytes - this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n}\nexports.default = Base64Decoder;\n//# sourceMappingURL=Base64Decoder.wasm.js.map", "\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\nconst wasmQoiDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#459f9e1bfb80b1a8:rdef-start:\"qoi_decode\"*/{s:1,t:0,d:'AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=='}/*inwasm#459f9e1bfb80b1a8:rdef-end:\"qoi_decode\"*/);\nclass QoiDecoder {\n constructor(keepSize) {\n this.keepSize = keepSize;\n this.width = 0;\n this.height = 0;\n }\n decode(d) {\n this.width = d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7];\n this.height = d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11];\n const pixels = this.width * this.height;\n const ib = pixels * 4;\n const dl = d.length;\n /**\n * byte/offset calculation:\n * To save some memory we dont reserve full memory for decoded + encoded,\n * but place encoded at the end of decoded plus 50% security distance\n * to avoid reads before writes positions:\n *\n * encoded < decoded (good compression)\n * enc ####################\n * dec #######################################\n * ^ ^\n * DST_P CHUNK_P\n *\n * encoded > decoded (degenerated compression, should not happen)\n * enc ##############################\n * dec ####################\n * ^ ^\n * DST_P CHUNK_P\n *\n * There is still a chance for overlapping r/w positions in case the compressed\n * data has very different pixel progression, yet the 50% security distance\n * should deal with that, as QOI will bloat data by 25% at max (RGB -> OP byte + RGB).\n * Since we always assume RGBA at decoding stage, the possible bloat reduces to 20% at max.\n */\n const bytes = Math.max(ib, dl) + (Math.min(ib, dl) >> 1) + 4096;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmQoiDecode({ env: { memory: this._mem } });\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n this._d = null;\n }\n if (!this._d) {\n this._d = new Uint8Array(this._mem.buffer);\n }\n // put src data at the end of memory, also align to 256\n const chunkP = (this._mem.buffer.byteLength - dl) & ~0xFF;\n this._d.set(d, chunkP);\n this._inst.exports.dec(chunkP, dl, pixels);\n return this._d.subarray(1024 /* P8.DST_P */, 1024 /* P8.DST_P */ + ib);\n }\n release() {\n if (!this._inst)\n return;\n if (this._mem.buffer.byteLength > this.keepSize) {\n this._inst = this._d = this._mem = null;\n }\n }\n}\nexports.default = QoiDecoder;\n//# sourceMappingURL=QoiDecoder.wasm.js.map", null, null, "/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal lifecycle utilities for xterm.js core.\n * Simplified from VS Code's lifecycle.ts - no tracking/leak detection.\n */\n\nexport interface IDisposable {\n dispose(): void;\n}\n\nexport function toDisposable(fn: () => void): IDisposable {\n return { dispose: fn };\n}\n\nexport function dispose(disposable: T): T;\nexport function dispose(disposable: T | undefined): T | undefined;\nexport function dispose(disposables: T[]): T[];\nexport function dispose(arg: T | T[] | undefined): T | T[] | undefined {\n if (!arg) {\n return arg;\n }\n if (Array.isArray(arg)) {\n for (const d of arg) {\n d.dispose();\n }\n return [];\n }\n arg.dispose();\n return arg;\n}\n\nexport function combinedDisposable(...disposables: IDisposable[]): IDisposable {\n return toDisposable(() => dispose(disposables));\n}\n\nexport class DisposableStore implements IDisposable {\n private readonly _disposables = new Set();\n private _isDisposed = false;\n\n public get isDisposed(): boolean {\n return this._isDisposed;\n }\n\n public add(o: T): T {\n if (this._isDisposed) {\n o.dispose();\n } else {\n this._disposables.add(o);\n }\n return o;\n }\n\n public dispose(): void {\n if (this._isDisposed) {\n return;\n }\n this._isDisposed = true;\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n\n public clear(): void {\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n}\n\nexport abstract class Disposable implements IDisposable {\n public static readonly None: IDisposable = Object.freeze({ dispose() { } });\n\n protected readonly _store = new DisposableStore();\n\n public dispose(): void {\n this._store.dispose();\n }\n\n protected _register(o: T): T {\n return this._store.add(o);\n }\n}\n\nexport class MutableDisposable implements IDisposable {\n private _value: T | undefined;\n private _isDisposed = false;\n\n public get value(): T | undefined {\n return this._isDisposed ? undefined : this._value;\n }\n\n public set value(value: T | undefined) {\n if (this._isDisposed || value === this._value) {\n return;\n }\n this._value?.dispose();\n this._value = value;\n }\n\n public clear(): void {\n this.value = undefined;\n }\n\n public dispose(): void {\n this._isDisposed = true;\n this._value?.dispose();\n this._value = undefined;\n }\n}\n", "/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal event utilities for xterm.js core.\n * Simplified from VS Code's event.ts - no leak detection/profiling.\n */\n\nimport { IDisposable, DisposableStore, toDisposable } from './Lifecycle';\n\nexport interface IEvent {\n (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable;\n}\n\nexport class Emitter {\n private _listeners: { fn: (e: T) => any, thisArgs: any }[] = [];\n private _disposed = false;\n private _event: IEvent | undefined;\n\n public get event(): IEvent {\n if (this._event) {\n return this._event;\n }\n this._event = (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n if (this._disposed) {\n return toDisposable(() => {});\n }\n\n const entry = { fn: listener, thisArgs };\n this._listeners = this._listeners.slice();\n this._listeners.push(entry);\n\n const result = toDisposable(() => {\n const idx = this._listeners.indexOf(entry);\n if (idx !== -1) {\n this._listeners = this._listeners.slice();\n this._listeners.splice(idx, 1);\n }\n });\n\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(result);\n } else {\n disposables.add(result);\n }\n }\n\n return result;\n };\n return this._event;\n }\n\n public fire(event: T): void {\n if (this._disposed || !this._listeners.length) {\n return;\n }\n if (this._listeners.length === 1) {\n this._listeners[0].fn.call(this._listeners[0].thisArgs, event);\n return;\n }\n const listeners = this._listeners;\n for (let i = 0, len = listeners.length; i < len; ++i) {\n listeners[i].fn.call(listeners[i].thisArgs, event);\n }\n }\n\n public dispose(): void {\n if (this._disposed) {\n return;\n }\n this._disposed = true;\n this._listeners.length = 0;\n }\n}\n\nexport namespace EventUtils {\n export function forward(from: IEvent, to: Emitter): IDisposable {\n return from(e => to.fire(e));\n }\n\n export function map(event: IEvent, map: (i: I) => O): IEvent {\n return (listener: (e: O) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n return event(i => listener.call(thisArgs, map(i)), undefined, disposables);\n };\n }\n\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent {\n return (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n const store = new DisposableStore();\n for (const event of events) {\n store.add(event(e => listener.call(thisArgs, e)));\n }\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(store);\n } else {\n disposables.add(store);\n }\n }\n return store;\n };\n }\n\n export function runAndSubscribe(event: IEvent, handler: (e: T) => void, initial: T): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void, initial?: T): IDisposable {\n handler(initial);\n return event(e => handler(e));\n }\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { toRGBA8888 } from 'sixel/lib/Colors';\nimport { IDisposable } from '@xterm/xterm';\nimport { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';\nimport { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\n\nconst enum Constants {\n PLACEHOLDER_LENGTH = 4096,\n PLACEHOLDER_HEIGHT = 24\n}\n\n/**\n * ImageRenderer - terminal frontend extension:\n * - provide primitives for canvas, ImageData, Bitmap (static)\n * - add canvas layer to DOM (browser only for now)\n * - draw image tiles onRender\n */\nexport class ImageRenderer extends Disposable implements IDisposable {\n /** @deprecated Kept for backward compat \u2014 points to top layer canvas. */\n public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; }\n private _layers = new Map();\n private _placeholder: HTMLCanvasElement | undefined;\n private _placeholderBitmap: ImageBitmap | undefined;\n private _optionsRefresh = this._register(new MutableDisposable());\n private _oldOpen: ((parent: HTMLElement) => void) | undefined;\n private _renderService: IRenderService | undefined;\n private _oldSetRenderer: ((renderer: any) => void) | undefined;\n\n // drawing primitive - canvas\n public static createCanvas(localDocument: Document | undefined, width: number, height: number): HTMLCanvasElement {\n /**\n * NOTE: We normally dont care, from which document the canvas\n * gets created, so we can fall back to global document,\n * if the terminal has no document associated yet.\n * This way early image loads before calling .open keep working\n * (still discouraged though, as the metrics will be screwed up).\n * Only the DOM output canvas should be on the terminal's document,\n * which gets explicitly checked in `insertLayerToDom`.\n */\n const canvas = (localDocument ?? document).createElement('canvas');\n canvas.width = width | 0;\n canvas.height = height | 0;\n return canvas;\n }\n\n // drawing primitive - ImageData with optional buffer\n public static createImageData(ctx: CanvasRenderingContext2D, width: number, height: number, buffer?: ArrayBuffer): ImageData {\n if (typeof ImageData !== 'function') {\n const imgData = ctx.createImageData(width, height);\n if (buffer) {\n imgData.data.set(new Uint8ClampedArray(buffer, 0, width * height * 4));\n }\n return imgData;\n }\n return buffer\n ? new ImageData(new Uint8ClampedArray(buffer, 0, width * height * 4), width, height)\n : new ImageData(width, height);\n }\n\n // drawing primitive - ImageBitmap\n public static createImageBitmap(img: ImageBitmapSource): Promise {\n if (typeof createImageBitmap !== 'function') {\n return Promise.resolve(undefined);\n }\n return createImageBitmap(img);\n }\n\n\n constructor(private _terminal: ITerminalExt) {\n super();\n this._oldOpen = this._terminal._core.open;\n this._terminal._core.open = (parent: HTMLElement): void => {\n this._oldOpen?.call(this._terminal._core, parent);\n this._open();\n };\n if (this._terminal._core.screenElement) {\n this._open();\n }\n // hack to spot fontSize changes\n this._optionsRefresh.value = this._terminal._core.optionsService.onOptionChange(option => {\n if (option === 'fontSize') {\n this.rescaleCanvas();\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n });\n this._register(toDisposable(() => {\n this.removeLayerFromDom();\n this.removeLayerFromDom('bottom');\n if (this._terminal._core && this._oldOpen) {\n this._terminal._core.open = this._oldOpen;\n this._oldOpen = undefined;\n }\n if (this._renderService && this._oldSetRenderer) {\n this._renderService.setRenderer = this._oldSetRenderer;\n this._oldSetRenderer = undefined;\n }\n this._renderService = undefined;\n this._layers.clear();\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }));\n }\n\n /**\n * Enable the placeholder.\n */\n public showPlaceholder(value: boolean): void {\n if (value) {\n if (!this._placeholder && this.cellSize.height !== -1) {\n this._createPlaceHolder(Math.max(this.cellSize.height + 1, Constants.PLACEHOLDER_HEIGHT));\n }\n } else {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n\n /**\n * Dimensions of the terminal.\n * Forwarded from internal render service.\n */\n public get dimensions(): IRenderDimensions | undefined {\n return this._terminal.dimensions;\n }\n\n /**\n * Current cell size (float).\n */\n public get cellSize(): ICellSize {\n return {\n width: this.dimensions?.css.cell.width || -1,\n height: this.dimensions?.css.cell.height || -1\n };\n }\n\n /**\n * Clear a region of the image layer canvas.\n */\n public clearLines(start: number, end: number, layer?: ImageLayer): void {\n const y = start * (this.dimensions?.css.cell.height || 0);\n const w = this.dimensions?.css.canvas.width || 0;\n const h = (end + 1 - start) * (this.dimensions?.css.cell.height || 0);\n if (!layer || layer === 'top') {\n this._layers.get('top')?.clearRect(0, y, w, h);\n }\n if (!layer || layer === 'bottom') {\n this._layers.get('bottom')?.clearRect(0, y, w, h);\n }\n }\n\n /**\n * Clear whole image canvas.\n */\n public clearAll(layer?: ImageLayer): void {\n if (!layer || layer === 'top') {\n const ctx = this._layers.get('top');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n if (!layer || layer === 'bottom') {\n const ctx = this._layers.get('bottom');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n }\n\n /**\n * Draw neighboring tiles on the image layer canvas.\n */\n public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get(imgSpec.layer);\n if (!ctx) {\n return;\n }\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const cols = Math.ceil(img.width / width);\n\n const sx = (tileId % cols) * width;\n const sy = Math.floor(tileId / cols) * height;\n const dx = col * width;\n const dy = row * height;\n\n // safari bug: never access image source out of bounds\n const finalWidth = count * width + sx > img.width ? img.width - sx : count * width;\n const finalHeight = sy + height > img.height ? img.height - sy : height;\n\n // Floor all pixel offsets to get stable tile mapping without any overflows.\n // Note: For not pixel perfect aligned cells like in the DOM renderer\n // this will move a tile slightly to the top/left (subpixel range, thus ignore it).\n // FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),\n Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth), Math.ceil(finalHeight)\n );\n }\n\n /**\n * Extract a single tile from an image.\n */\n public extractTile(imgSpec: IImageSpec, tileId: number): HTMLCanvasElement | undefined {\n const { width, height } = this.cellSize;\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const cols = Math.ceil(img.width / width);\n const sx = (tileId % cols) * width;\n const sy = Math.floor(tileId / cols) * height;\n const finalWidth = width + sx > img.width ? img.width - sx : width;\n const finalHeight = sy + height > img.height ? img.height - sy : height;\n\n const canvas = ImageRenderer.createCanvas(this.document, finalWidth, finalHeight);\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight),\n 0, 0, Math.floor(finalWidth), Math.floor(finalHeight)\n );\n return canvas;\n }\n }\n\n /**\n * Draw a line with placeholder on the image layer canvas.\n */\n public drawPlaceholder(col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get('top');\n if (ctx) {\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n if (!this._placeholder) {\n this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT));\n } else if (height >= this._placeholder!.height) {\n this._createPlaceHolder(height + 1);\n }\n if (!this._placeholder) return;\n ctx.drawImage(\n this._placeholderBitmap ?? this._placeholder!,\n col * width,\n (row * height) % 2 ? 0 : 1, // needs %2 offset correction\n width * count,\n height,\n col * width,\n row * height,\n width * count,\n height\n );\n }\n }\n\n /**\n * Rescale image layer canvas if needed.\n * Checked once from `ImageStorage.render`.\n */\n public rescaleCanvas(): void {\n const w = this.dimensions?.css.canvas.width || 0;\n const h = this.dimensions?.css.canvas.height || 0;\n for (const ctx of this._layers.values()) {\n if (ctx.canvas.width !== w || ctx.canvas.height !== h) {\n ctx.canvas.width = w;\n ctx.canvas.height = h;\n }\n }\n }\n\n /**\n * Rescale image in storage if needed.\n */\n private _rescaleImage(spec: IImageSpec, currentWidth: number, currentHeight: number): void {\n if (currentWidth === spec.actualCellSize.width && currentHeight === spec.actualCellSize.height) {\n return;\n }\n const { width: originalWidth, height: originalHeight } = spec.origCellSize;\n if (currentWidth === originalWidth && currentHeight === originalHeight) {\n spec.actual = spec.orig;\n spec.actualCellSize.width = originalWidth;\n spec.actualCellSize.height = originalHeight;\n return;\n }\n const canvas = ImageRenderer.createCanvas(\n this.document,\n Math.ceil(spec.orig!.width * currentWidth / originalWidth),\n Math.ceil(spec.orig!.height * currentHeight / originalHeight)\n );\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(spec.orig!, 0, 0, canvas.width, canvas.height);\n spec.actual = canvas;\n spec.actualCellSize.width = currentWidth;\n spec.actualCellSize.height = currentHeight;\n }\n }\n\n /**\n * Lazy init for the renderer.\n */\n private _open(): void {\n this._renderService = this._terminal._core._renderService;\n this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);\n this._renderService.setRenderer = (renderer: any) => {\n for (const key of [...this._layers.keys()]) {\n this.removeLayerFromDom(key);\n }\n this._oldSetRenderer?.call(this._renderService, renderer);\n };\n }\n\n public insertLayerToDom(layer: ImageLayer = 'top'): void {\n // make sure that the terminal is attached to a document and to DOM\n if (!this.document || !this._terminal._core.screenElement) {\n console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');\n return;\n }\n if (this._layers.has(layer)) {\n return;\n }\n const canvas = ImageRenderer.createCanvas(\n this.document, this.dimensions?.css.canvas.width || 0,\n this.dimensions?.css.canvas.height || 0\n );\n canvas.classList.add(`xterm-image-layer-${layer}`);\n const screenElement = this._terminal._core.screenElement;\n // Use isolation to create a stacking context without overriding z-index,\n // which would conflict with integrators (e.g. VS Code) that set their\n // own z-index on the screen element.\n screenElement.style.isolation = 'isolate';\n if (layer === 'bottom') {\n // Use z-index:-1 so it paints behind non-positioned text elements.\n // The screen element needs to be a stacking context (via isolation)\n // to contain the negative z-index, otherwise it would go behind the\n // entire terminal.\n canvas.style.zIndex = '-1';\n screenElement.insertBefore(canvas, screenElement.firstChild);\n } else {\n // Explicit z-index ensures the image canvas reliably stacks above\n // the text layer (DOM renderer rows). z-index: 0 is below the\n // selection overlay (z-index: 1).\n canvas.style.zIndex = '0';\n screenElement.appendChild(canvas);\n }\n const ctx = canvas.getContext('2d', { alpha: true });\n if (!ctx) {\n canvas.remove();\n return;\n }\n this._layers.set(layer, ctx);\n this.clearAll(layer);\n }\n\n public removeLayerFromDom(layer: ImageLayer = 'top'): void {\n const ctx = this._layers.get(layer);\n if (ctx) {\n ctx.canvas.remove();\n this._layers.delete(layer);\n }\n }\n\n public hasLayer(layer: ImageLayer): boolean {\n return this._layers.has(layer);\n }\n\n private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n\n // create blueprint to fill placeholder with\n const bWidth = 32; // must be 2^n\n const blueprint = ImageRenderer.createCanvas(this.document, bWidth, height);\n const ctx = blueprint.getContext('2d', { alpha: false });\n if (!ctx) return;\n const imgData = ImageRenderer.createImageData(ctx, bWidth, height);\n const d32 = new Uint32Array(imgData.data.buffer);\n const black = toRGBA8888(0, 0, 0);\n const white = toRGBA8888(255, 255, 255);\n d32.fill(black);\n for (let y = 0; y < height; ++y) {\n const shift = y % 2;\n const offset = y * bWidth;\n for (let x = 0; x < bWidth; x += 2) {\n d32[offset + x + shift] = white;\n }\n }\n ctx.putImageData(imgData, 0, 0);\n\n // create placeholder line, width aligned to blueprint width\n const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || Constants.PLACEHOLDER_LENGTH;\n this._placeholder = ImageRenderer.createCanvas(this.document, width, height);\n const ctx2 = this._placeholder.getContext('2d', { alpha: false });\n if (!ctx2) {\n this._placeholder = undefined;\n return;\n }\n for (let i = 0; i < width; i += bWidth) {\n ctx2.drawImage(blueprint, i, 0);\n }\n ImageRenderer.createImageBitmap(this._placeholder).then(bitmap => this._placeholderBitmap = bitmap);\n }\n\n public get document(): Document | undefined {\n return this._terminal._core._coreBrowserService?.window.document;\n }\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageRenderer } from './ImageRenderer';\nimport {\n ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec,\n IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes,\n UnderlineStyle, IAddImageOpts\n} from './Types';\n\n\n// fallback default cell size\nexport const CELL_SIZE_DEFAULT: ICellSize = {\n width: 7,\n height: 14\n};\n\n/**\n * Extend extended attribute to also hold image tile information.\n *\n * Object definition is copied from base repo to fully mimick its behavior.\n * Image data is added as additional public properties `imageId` and `tileId`.\n */\nclass ExtendedAttrsImage implements IExtendedAttrsImage {\n private _ext: number = 0;\n public get ext(): number {\n if (this._urlId) {\n return (\n (this._ext & ~ExtFlags.UNDERLINE_STYLE) |\n (this.underlineStyle << 26)\n );\n }\n return this._ext;\n }\n public set ext(value: number) { this._ext = value; }\n\n public get underlineStyle(): UnderlineStyle {\n // Always return the URL style if it has one\n if (this._urlId) {\n return UnderlineStyle.DASHED;\n }\n return (this._ext & ExtFlags.UNDERLINE_STYLE) >> 26;\n }\n public set underlineStyle(value: UnderlineStyle) {\n this._ext &= ~ExtFlags.UNDERLINE_STYLE;\n this._ext |= (value << 26) & ExtFlags.UNDERLINE_STYLE;\n }\n\n public get underlineColor(): number {\n return this._ext & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n public set underlineColor(value: number) {\n this._ext &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);\n this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n\n public get underlineVariantOffset(): number {\n const val = (this._ext & ExtFlags.VARIANT_OFFSET) >> 29;\n if (val < 0) {\n return val ^ 0xFFFFFFF8;\n }\n return val;\n }\n public set underlineVariantOffset(value: number) {\n this._ext &= ~ExtFlags.VARIANT_OFFSET;\n this._ext |= (value << 29) & ExtFlags.VARIANT_OFFSET;\n }\n\n private _urlId: number = 0;\n public get urlId(): number {\n return this._urlId;\n }\n public set urlId(value: number) {\n this._urlId = value;\n }\n\n constructor(\n ext: number = 0,\n urlId: number = 0,\n public imageId = -1,\n public tileId = -1\n ) {\n this._ext = ext;\n this._urlId = urlId;\n }\n\n public clone(): IExtendedAttrsImage {\n /**\n * Technically we dont need a clone variant of ExtendedAttrsImage,\n * as we never clone a cell holding image data.\n * Note: Clone is only meant to be used by the InputHandler for\n * sticky attributes, which is never the case for image data.\n * We still provide a proper clone method to reflect the full ext attr\n * state in case there are future use cases for clone.\n */\n return new ExtendedAttrsImage(this._ext, this._urlId, this.imageId, this.tileId);\n }\n\n public isEmpty(): boolean {\n return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.imageId === -1;\n }\n}\nconst EMPTY_ATTRS = new ExtendedAttrsImage();\n\n\n/**\n * ImageStorage - extension of CoreTerminal:\n * - hold image data\n * - write/read image data to/from buffer\n *\n * TODO: image composition for overwrites\n */\nexport class ImageStorage implements IDisposable {\n // storage\n private _images: Map = new Map();\n // last used id\n private _lastId = 0;\n // last evicted id\n private _lowestId = 0;\n // whether a full clear happened before\n private _fullyCleared = false;\n // whether render should do a full clear\n private _needsFullClear = false;\n // hard limit of stored pixels (fallback limit of 10 MB)\n private _pixelLimit: number = 2500000;\n\n private _viewportMetrics: { cols: number, rows: number };\n public onImageAdded: (() => void) | undefined;\n public onImageDeleted: ((storageId: number) => void) | undefined;\n\n constructor(\n private _terminal: ITerminalExt,\n private _renderer: ImageRenderer,\n private _opts: IImageAddonOptions\n ) {\n try {\n this.setLimit(this._opts.storageLimit);\n } catch (e: unknown) {\n if (e instanceof Error) {\n console.error(e.message);\n }\n console.warn(`storageLimit is set to ${this.getLimit()} MB`);\n }\n this._viewportMetrics = {\n cols: this._terminal.cols,\n rows: this._terminal.rows\n };\n }\n\n public dispose(): void {\n this.reset();\n }\n\n public reset(): void {\n for (const spec of this._images.values()) {\n spec.marker?.dispose();\n }\n // NOTE: marker.dispose above already calls ImageBitmap.close\n // therefore we can just wipe the map here\n this._images.clear();\n this._renderer.clearAll();\n }\n\n public getLimit(): number {\n return this._pixelLimit * 4 / 1000000;\n }\n\n public setLimit(value: number): void {\n if (value < 0.5 || value > 1000) {\n throw RangeError('invalid storageLimit, should be at least 0.5 MB and not exceed 1G');\n }\n this._pixelLimit = (value / 4 * 1000000) >>> 0;\n this._evictOldest(0);\n }\n\n public getUsage(): number {\n return this._getStoredPixels() * 4 / 1000000;\n }\n\n private _getStoredPixels(): number {\n let storedPixels = 0;\n for (const spec of this._images.values()) {\n if (spec.orig) {\n storedPixels += spec.orig.width * spec.orig.height;\n if (spec.actual && spec.actual !== spec.orig) {\n storedPixels += spec.actual.width * spec.actual.height;\n }\n }\n }\n return storedPixels;\n }\n\n private _delImg(id: number): void {\n const spec = this._images.get(id);\n if (!spec) return;\n this._images.delete(id);\n // FIXME: really ugly workaround to get bitmaps deallocated :(\n if (window.ImageBitmap && spec.orig instanceof ImageBitmap) {\n spec.orig.close();\n }\n this.onImageDeleted?.(id);\n }\n\n /**\n * Wipe canvas and images on alternate buffer.\n */\n public wipeAlternate(): void {\n // remove all alternate tagged images\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate') {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n // mark canvas to be wiped on next render\n this._needsFullClear = true;\n this._fullyCleared = false;\n }\n\n /**\n * Delete an image by its internal storage ID.\n * Used by protocols that support explicit deletion (e.g. Kitty a=d).\n */\n public deleteImage(id: number): void {\n const spec = this._images.get(id);\n if (spec) {\n spec.marker?.dispose();\n this._delImg(id);\n }\n }\n\n /**\n * Method to add an image to the storage.\n * @param img - The image to add (canvas or bitmap).\n * @param opts - Options for addImage:\n * - scrolling: When true, cursor advances with the image.\n * When false, image is placed at ORIGIN and cursor does not move.\n * - layer: Which canvas layer to render on ('top' or 'bottom').\n * - zIndex: Z-index for image layering within the same layer.\n * - cursorPos: 'vt340' for bottom-left, 'iip' for bottom.right.\n * @returns The internal image ID assigned to the stored image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap, opts: IAddImageOpts): number {\n // never allow storage to exceed memory limit\n this._evictOldest(img.width * img.height);\n\n // calc rows x cols needed to display the image\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const cols = Math.ceil(img.width / cellSize.width);\n const rows = Math.ceil(img.height / cellSize.height);\n\n const imageId = ++this._lastId;\n\n const buffer = this._terminal._core.buffer;\n const termCols = this._terminal.cols;\n const termRows = this._terminal.rows;\n const originX = buffer.x;\n const originY = buffer.y;\n let offset = originX;\n let tileCount = 0;\n\n if (!opts.scrolling) {\n buffer.x = 0;\n buffer.y = 0;\n offset = 0;\n }\n\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(buffer.y + buffer.ybase);\n for (let col = 0; col < cols; ++col) {\n if (offset + col >= termCols) break;\n this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);\n tileCount++;\n }\n if (opts.scrolling) {\n if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();\n } else {\n if (++buffer.y >= termRows) break;\n }\n buffer.x = offset;\n }\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n\n // cursor positioning modes\n if (opts.scrolling) {\n if (opts.cursorPos === 'iip') {\n buffer.x = Math.min(offset + cols, termCols);\n } else {\n buffer.x = offset;\n }\n } else {\n buffer.x = originX;\n buffer.y = originY;\n }\n\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.tileCount < 1) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n\n // eviction marker:\n // delete the image when the marker gets disposed\n const endMarker = this._terminal.registerMarker(0);\n endMarker?.onDispose(() => {\n const spec = this._images.get(imageId);\n if (spec) {\n this._delImg(imageId);\n }\n });\n\n // since markers do not work on alternate for some reason,\n // we evict images here manually\n if (this._terminal.buffer.active.type === 'alternate') {\n this._evictOnAlternate();\n }\n\n // create storage entry\n const imgSpec: IImageSpec = {\n orig: img,\n origCellSize: cellSize,\n actual: img,\n actualCellSize: { ...cellSize }, // clone needed, since later modified\n marker: endMarker || undefined,\n tileCount,\n bufferType: this._terminal.buffer.active.type,\n layer: opts.layer,\n zIndex: opts.zIndex\n };\n\n // finally add the image\n this._images.set(imageId, imgSpec);\n this.onImageAdded?.();\n return imageId;\n }\n\n\n /**\n * Render method. Collects buffer information and triggers\n * canvas updates.\n */\n // TODO: Should we move this to the ImageRenderer?\n public render(range: { start: number, end: number }): void {\n // Determine which layers have images\n let hasTopImages = false;\n let hasBottomImages = false;\n for (const spec of this._images.values()) {\n if (spec.layer === 'bottom') {\n hasBottomImages = true;\n } else {\n hasTopImages = true;\n }\n if (hasTopImages && hasBottomImages) break;\n }\n\n // Lazily insert layers that are needed\n if (hasTopImages && !this._renderer.hasLayer('top')) {\n this._renderer.insertLayerToDom('top');\n if (!this._renderer.hasLayer('top')) return;\n }\n if (hasBottomImages && !this._renderer.hasLayer('bottom')) {\n this._renderer.insertLayerToDom('bottom');\n }\n\n // rescale if needed\n this._renderer.rescaleCanvas();\n\n // exit early if we dont have any images to test for\n if (!this._images.size) {\n if (!this._fullyCleared) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n if (this._renderer.hasLayer('top')) {\n this._renderer.removeLayerFromDom('top');\n }\n if (this._renderer.hasLayer('bottom')) {\n this._renderer.removeLayerFromDom('bottom');\n }\n return;\n }\n\n // Remove layers no longer needed\n if (!hasTopImages && this._renderer.hasLayer('top')) {\n this._renderer.clearAll('top');\n this._renderer.removeLayerFromDom('top');\n }\n if (!hasBottomImages && this._renderer.hasLayer('bottom')) {\n this._renderer.clearAll('bottom');\n this._renderer.removeLayerFromDom('bottom');\n }\n\n // buffer switches force a full clear\n if (this._needsFullClear) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n\n const { start, end } = range;\n const buffer = this._terminal._core.buffer;\n const cols = this._terminal._core.cols;\n\n // clear drawing area\n this._renderer.clearLines(start, end);\n\n // Collect draw calls so we can sort by z-index (lower z drawn first).\n const drawCalls: { imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number }[] = [];\n const placeholderCalls: { col: number, row: number, count: number }[] = [];\n\n // walk all cells in viewport and collect tiles found\n for (let row = start; row <= end; ++row) {\n const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt;\n if (!line) return;\n for (let col = 0; col < cols; ++col) {\n if (line.getBg(col) & BgFlags.HAS_EXTENDED) {\n let e: IExtendedAttrsImage = line._extendedAttrs[col] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (e.tileId !== -1) {\n const startTile = e.tileId;\n const startCol = col;\n let count = 1;\n /**\n * merge tiles to the right into a single draw call, if:\n * - not at end of line\n * - cell has same image id\n * - cell has consecutive tile id\n */\n while (\n ++col < cols\n && (line.getBg(col) & BgFlags.HAS_EXTENDED)\n && (e = line._extendedAttrs[col] ?? EMPTY_ATTRS)\n && (e.imageId === imageId)\n && (e.tileId === startTile + count)\n ) {\n count++;\n }\n col--;\n if (imgSpec) {\n if (imgSpec.actual) {\n drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });\n }\n } else if (this._opts.showPlaceholder) {\n placeholderCalls.push({ col: startCol, row, count });\n }\n this._fullyCleared = false;\n }\n }\n }\n }\n\n // Sort by z-index so lower z draws first (higher z renders on top)\n drawCalls.sort((a, b) => a.imgSpec.zIndex - b.imgSpec.zIndex);\n\n // Draw placeholders first (lowest priority)\n for (const call of placeholderCalls) {\n this._renderer.drawPlaceholder(call.col, call.row, call.count);\n }\n\n // Draw images in z-index order\n for (const call of drawCalls) {\n this._renderer.draw(call.imgSpec, call.tileId, call.col, call.row, call.count);\n }\n }\n\n public viewportResize(metrics: { cols: number, rows: number }): void {\n // exit early if we have nothing in storage\n if (!this._images.size) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // handle only viewport width enlargements, exit all other cases\n // TODO: needs patch for tile counter\n if (this._viewportMetrics.cols >= metrics.cols) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // walk scrollbuffer at old col width to find all possible expansion matches\n const buffer = this._terminal._core.buffer;\n const rows = buffer.lines.length;\n const oldCol = this._viewportMetrics.cols - 1;\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(row) as IBufferLineExt;\n if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (!imgSpec) {\n continue;\n }\n // found an image tile at oldCol, check if it qualifies for right exapansion\n const tilesPerRow = Math.ceil((imgSpec.actual?.width || 0) / imgSpec.actualCellSize.width);\n if ((e.tileId % tilesPerRow) + 1 >= tilesPerRow) {\n continue;\n }\n // expand only if right side is empty (nothing got wrapped from below)\n let hasData = false;\n for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) {\n if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) {\n hasData = true;\n break;\n }\n }\n if (hasData) {\n continue;\n }\n // do right expansion on terminal buffer\n const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol);\n let lastTile = e.tileId;\n for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) {\n this._writeToCell(line as IBufferLineExt, expandCol, imageId, ++lastTile);\n imgSpec.tileCount++;\n }\n }\n }\n // store new viewport metrics\n this._viewportMetrics = metrics;\n }\n\n /**\n * Retrieve original canvas at buffer position.\n */\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1) {\n const orig = this._images.get(e.imageId)?.orig;\n if (window.ImageBitmap && orig instanceof ImageBitmap) {\n const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height);\n canvas.getContext('2d')?.drawImage(orig, 0, 0, orig.width, orig.height);\n return canvas;\n }\n return orig as HTMLCanvasElement;\n }\n }\n }\n\n /**\n * Extract active single tile at buffer position.\n */\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1 && e.tileId !== -1) {\n const spec = this._images.get(e.imageId);\n if (spec) {\n return this._renderer.extractTile(spec, e.tileId);\n }\n }\n }\n }\n\n // TODO: Do we need some blob offloading tricks here to avoid early eviction?\n // also see https://stackoverflow.com/questions/28307789/is-there-any-limitation-on-javascript-max-blob-size\n private _evictOldest(room: number): number {\n const used = this._getStoredPixels();\n let current = used;\n while (this._pixelLimit < current + room && this._images.size) {\n const spec = this._images.get(++this._lowestId);\n if (spec && spec.orig) {\n current -= spec.orig.width * spec.orig.height;\n if (spec.actual && spec.orig !== spec.actual) {\n current -= spec.actual.width * spec.actual.height;\n }\n spec.marker?.dispose();\n this._delImg(this._lowestId);\n }\n }\n return used - current;\n }\n\n private _writeToCell(line: IBufferLineExt, x: number, imageId: number, tileId: number): void {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const old = line._extendedAttrs[x];\n if (old) {\n if (old.imageId !== undefined) {\n // found an old ExtendedAttrsImage, since we know that\n // they are always isolated instances (single cell usage),\n // we can re-use it and just update their id entries\n const oldSpec = this._images.get(old.imageId);\n if (oldSpec) {\n // early eviction for in-viewport overwrites\n oldSpec.tileCount--;\n }\n old.imageId = imageId;\n old.tileId = tileId;\n return;\n }\n // found a plain ExtendedAttrs instance, clone it to new entry\n line._extendedAttrs[x] = new ExtendedAttrsImage(old.ext, old.urlId, imageId, tileId);\n return;\n }\n }\n // fall-through: always create new ExtendedAttrsImage entry\n line._data[x * Cell.SIZE + Cell.BG] |= BgFlags.HAS_EXTENDED;\n line._extendedAttrs[x] = new ExtendedAttrsImage(0, 0, imageId, tileId);\n }\n\n private _evictOnAlternate(): void {\n // nullify tile count of all images on alternate buffer\n for (const spec of this._images.values()) {\n if (spec.bufferType === 'alternate') {\n spec.tileCount = 0;\n }\n }\n // re-count tiles on whole buffer\n const buffer = this._terminal._core.buffer;\n for (let y = 0; y < this._terminal.rows; ++y) {\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (!line) {\n continue;\n }\n for (let x = 0; x < this._terminal.cols; ++x) {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const imgId = line._extendedAttrs[x]?.imageId;\n if (imgId) {\n const spec = this._images.get(imgId);\n if (spec) {\n spec.tileCount++;\n }\n }\n }\n }\n }\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate' && !spec.tileCount) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nimport { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { CELL_SIZE_DEFAULT } from './ImageStorage';\nimport Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';\nimport { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser';\nimport { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';\n\n// Local const enum mirror - esbuild can't inline const enums from external packages\nconst enum DecoderConst {\n // Limit held memory in base64 decoder (encoded bytes).\n KEEP_DATA = 4194304,\n // Initial buffer allocation for the decoder.\n INITIAL_DATA = 1048576,\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n OK = 0\n}\n\n// default IIP header values\nconst DEFAULT_HEADER: IHeaderFields = {\n type: SequenceType.INVALID,\n name: 'Unnamed file',\n size: 0,\n width: 'auto',\n height: 'auto',\n preserveAspectRatio: 1,\n inline: 0\n};\n\n\nexport class IIPHandler implements IOscHandler, IResetHandler {\n private _aborted = false;\n private _hp = new HeaderParser();\n private _header: IHeaderFields = DEFAULT_HEADER;\n private _dec: Base64Decoder;\n private _qoiDec: QoiDecoder;\n private _isMultipart = false;\n private _abortMulti = false;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _storage: IIPImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);\n const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);\n this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes);\n this._qoiDec = new QoiDecoder(DecoderConst.KEEP_DATA);\n }\n\n public reset(): void {\n this._hp.reset();\n this._dec.release();\n this._qoiDec.release();\n }\n\n public start(): void {\n this._aborted = false;\n this._hp.reset();\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (this._hp.state === HeaderState.END) {\n if ((this._dec.put(data.subarray(start, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n }\n } else {\n const dataPos = this._hp.parse(data, start, end);\n if (dataPos === -1) {\n this._aborted = true;\n return;\n }\n if (dataPos > 0) {\n const seqType = this._hp.fields.type;\n if (seqType === SequenceType.FILE) {\n if (this._isMultipart) {\n this._isMultipart = false;\n this._abortMulti = false;\n this._dec.release();\n }\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n if (!this._header.inline) {\n this._aborted = true;\n return;\n }\n this._dec.init();\n } else if (this._abortMulti) {\n this._aborted = true;\n return;\n }\n if ((this._dec.put(data.subarray(dataPos, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n if (this._isMultipart) this._abortMulti = true;\n }\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted) return true;\n\n if (this._hp.state !== HeaderState.END) {\n if (this._hp.end()) return true;\n }\n const seqType = this._hp.fields.type;\n\n if (seqType === SequenceType.FILEPART) return true;\n\n if (seqType === SequenceType.REPORTCELLSIZE) {\n // OSC 1337 ; ReportCellSize=[height];[width];[scale] ST\n let w = CELL_SIZE_DEFAULT.width;\n let h = CELL_SIZE_DEFAULT.height;\n if (this._renderer.dimensions) {\n w = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols;\n h = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows;\n }\n const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1;\n const report = `\\x1b]1337;ReportCellSize=${h.toFixed(3)};${w.toFixed(3)};${scale.toFixed(3)}\\x1b\\\\`;\n this._coreTerminal.input(report, false);\n return true;\n }\n\n if (seqType === SequenceType.MULTIPARTFILE) {\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n this._isMultipart = true;\n this._abortMulti = false;\n this._dec.release();\n this._dec.init();\n return true;\n }\n\n if (seqType === SequenceType.FILEEND) {\n if (!this._isMultipart) return true;\n this._isMultipart = false;\n if (this._abortMulti || this._header.type !== SequenceType.MULTIPARTFILE) return true;\n }\n\n // fallthrough for SequenceType.FILE & SequenceType.FILEEND\n\n let w = 0;\n let h = 0;\n\n // early exit condition chain\n let cond: number | boolean;\n let metrics = UNSUPPORTED_TYPE;\n if (cond = success) {\n if (cond = !this._dec.end()) {\n metrics = imageType(this._dec.data8);\n if (cond = metrics.mime !== 'unsupported') {\n w = metrics.width;\n h = metrics.height;\n if (cond = w && h && w * h < this._opts.pixelLimit) {\n [w, h] = this._resize(w, h).map(Math.floor);\n cond = w && h && w * h < this._opts.pixelLimit;\n } else {\n console.warn(`IIP: image dimension issue ${metrics.width}x${metrics.height}`);\n }\n } else {\n console.warn('IIP: unsupported image type');\n }\n } else {\n console.warn('IIP: error during BASE64 decoding');\n }\n }\n if (!cond) {\n this._dec.release();\n return true;\n }\n\n let blob: Blob | ImageData;\n if (metrics.mime === 'image/qoi') {\n const data = this._qoiDec.decode(this._dec.data8);\n blob = new ImageData(\n new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),\n this._qoiDec.width,\n this._qoiDec.height\n );\n this._qoiDec.release();\n if (w === this._qoiDec.width && h === this._qoiDec.height) {\n // use fast-path if we don't need to rescale\n this._dec.release();\n const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height);\n canvas.getContext('2d')?.putImageData(blob, 0, 0);\n this._storage.addImage(canvas);\n return true;\n }\n } else {\n blob = new Blob([this._dec.data8], { type: metrics.mime });\n }\n this._dec.release();\n return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })\n .then(bm => {\n this._storage.addImage(bm);\n return true;\n })\n .catch(e => {\n console.warn(`IIP: decoding error ${metrics.mime} ${metrics.width}x${metrics.height}`, e);\n return true;\n });\n }\n\n private _resize(w: number, h: number): [number, number] {\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n const width = this._renderer.dimensions?.css.canvas.width || cw * this._coreTerminal.cols;\n const height = this._renderer.dimensions?.css.canvas.height || ch * this._coreTerminal.rows;\n\n const rw = this._dim(this._header.width!, width, cw);\n const rh = this._dim(this._header.height!, height, ch);\n if (!rw && !rh) {\n const wf = width / w; // TODO: should this respect initial cursor offset?\n const hf = (height - ch) / h; // TODO: fix offset issues from float cell height\n const f = Math.min(wf, hf);\n return f < 1 ? [w * f, h * f] : [w, h];\n }\n return !rw\n ? [w * rh / h, rh]\n : this._header.preserveAspectRatio || !rw || !rh\n ? [rw, h * rw / w] : [rw, rh];\n }\n\n private _dim(s: string, total: number, cdim: number): number {\n if (s === 'auto') return 0;\n if (s.endsWith('%')) return parseInt(s.slice(0, -1), 10) * total / 100;\n if (s.endsWith('px')) return parseInt(s.slice(0, -2), 10);\n return parseInt(s, 10) * cdim;\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n// eslint-disable-next-line\ndeclare const Buffer: any;\n\nexport const enum HeaderState {\n START = 0,\n ABORT = 1,\n KEY = 2,\n VALUE = 3,\n END = 4\n}\n\nexport const enum SequenceType {\n INVALID = 0,\n FILE = 1,\n MULTIPARTFILE = 2,\n FILEPART = 3,\n FILEEND = 4,\n REPORTCELLSIZE = 5\n}\n\nexport interface IHeaderFields {\n [key: string]: number | string | Uint32Array | null | undefined;\n // sequence type\n type: SequenceType;\n // base-64 encoded filename. Defaults to \"Unnamed file\".\n name: string;\n // File size in bytes. The file transfer will be canceled if this size is exceeded.\n size: number;\n /**\n * Optional width and height to render:\n * - N: N character cells.\n * - Npx: N pixels.\n * - N%: N percent of the session's width or height.\n * - auto: The image's inherent size will be used to determine an appropriate dimension.\n */\n width?: string;\n height?: string;\n // Optional, defaults to 1 respecting aspect ratio (width takes precedence).\n preserveAspectRatio?: number;\n // Optional, defaults to 0. If set to 1, the file will be displayed inline, else downloaded\n // (download not supported).\n inline?: number;\n}\n\n// field value decoders\n\n// ASCII bytes to string\nfunction toStr(data: Uint32Array): string {\n let s = '';\n for (let i = 0; i < data.length; ++i) {\n s += String.fromCharCode(data[i]);\n }\n return s;\n}\n\n// digits to integer\nfunction toInt(data: Uint32Array): number {\n let v = 0;\n for (let i = 0; i < data.length; ++i) {\n if (data[i] < 48 || data[i] > 57) {\n throw new Error('illegal char');\n }\n v = v * 10 + data[i] - 48;\n }\n return v;\n}\n\n// check for correct size entry\nfunction toSize(data: Uint32Array): string {\n const v = toStr(data);\n if (!v.match(/^((auto)|(\\d+?((px)|(%)){0,1}))$/)) {\n throw new Error('illegal size');\n }\n return v;\n}\n\n// name is base64 encoded utf-8\nfunction toName(data: Uint32Array): string {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(toStr(data), 'base64').toString();\n }\n const bs = atob(toStr(data));\n const b = new Uint8Array(bs.length);\n for (let i = 0; i < b.length; ++i) {\n b[i] = bs.charCodeAt(i);\n }\n return new TextDecoder().decode(b);\n}\n\nconst DECODERS: {[key: string]: (v: Uint32Array) => number | string} = {\n inline: toInt,\n size: toInt,\n name: toName,\n width: toSize,\n height: toSize,\n preserveAspectRatio: toInt\n};\n\n\n// sequence type markers\n// File\nconst FILE_MARKER = [70, 105, 108, 101];\n// MultipartFile\nconst MULTIPARTFILE_MARKER = [77, 117, 108, 116, 105, 112, 97, 114, 116, 70, 105, 108, 101];\n// FilePart\nconst FILEPART_MARKER = [70, 105, 108, 101, 80, 97, 114, 116];\n// FileEnd\nconst FILEEND_MARKER = [70, 105, 108, 101, 69, 110, 100];\n// ReportCellSize\nconst REPORTCELLSIZE_MARKER = [82, 101, 112, 111, 114, 116, 67, 101, 108, 108, 83, 105, 122, 101];\n\n// max allowed chars for sequence header\nconst MAX_FIELDCHARS = 1024;\n\n\nexport class HeaderParser {\n public state: HeaderState = HeaderState.START;\n private _buffer = new Uint32Array(MAX_FIELDCHARS);\n private _position = 0;\n private _key = '';\n public fields: {[key: string]: number | string | Uint32Array | null | undefined} = {};\n\n public reset(): void {\n this._buffer.fill(0);\n this.state = HeaderState.START;\n this._position = 0;\n this.fields = {};\n this._key = '';\n }\n\n public end(): number {\n if (this.state === HeaderState.START) {\n if (this._position === FILEEND_MARKER.length) {\n for (let k = 0; k < FILEEND_MARKER.length; ++k) {\n if (this._buffer[k] !== FILEEND_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEEND;\n this.state = HeaderState.END;\n return 0;\n }\n if (this._position === REPORTCELLSIZE_MARKER.length) {\n for (let k = 0; k < REPORTCELLSIZE_MARKER.length; ++k) {\n if (this._buffer[k] !== REPORTCELLSIZE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.REPORTCELLSIZE;\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n if (this.state === HeaderState.END) return 0;\n if (this.state === HeaderState.VALUE\n && this.fields.type === SequenceType.MULTIPARTFILE\n ) {\n if (!this._storeValue(this._position)) return this._a();\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n\n public parse(data: Uint32Array, start: number, end: number): number {\n let state = this.state;\n let pos = this._position;\n const buffer = this._buffer;\n if (state === HeaderState.ABORT || state === HeaderState.END) return -1;\n if (state === HeaderState.START && pos > 14) return -1;\n for (let i = start; i < end; ++i) {\n const c = data[i];\n switch (c) {\n case 59: // ;\n if (!this._storeValue(pos)) return this._a();\n state = HeaderState.KEY;\n pos = 0;\n break;\n case 61: // =\n if (state === HeaderState.START) {\n if (buffer[0] === 70) {\n // 'File' or 'FilePart'\n let k = 0;\n for (; k < FILE_MARKER.length; ++k) {\n if (buffer[k] !== FILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILE;\n if (pos === FILEPART_MARKER.length) {\n for (; k < FILEPART_MARKER.length; ++k) {\n if (buffer[k] !== FILEPART_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEPART;\n this.state = HeaderState.END;\n return i + 1;\n }\n } else if (buffer[0] === 77) {\n // 'MultipartFile'\n for (let k = 0; k < MULTIPARTFILE_MARKER.length; ++k) {\n if (buffer[k] !== MULTIPARTFILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.MULTIPARTFILE;\n } else {\n return this._a();\n }\n state = HeaderState.KEY;\n pos = 0;\n } else if (state === HeaderState.KEY) {\n if (!this._storeKey(pos)) return this._a();\n state = HeaderState.VALUE;\n pos = 0;\n } else if (state === HeaderState.VALUE) {\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n break;\n case 58: // :\n if (state === HeaderState.VALUE) {\n if (!this._storeValue(pos)) return this._a();\n }\n this.state = HeaderState.END;\n return i + 1;\n default:\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n }\n this.state = state;\n this._position = pos;\n return -2;\n }\n\n private _a(): number {\n this.fields.type = SequenceType.INVALID;\n this.state = HeaderState.ABORT;\n return -1;\n }\n\n private _storeKey(pos: number): boolean {\n const k = toStr(this._buffer.subarray(0, pos));\n if (k) {\n this._key = k;\n this.fields[k] = null;\n return true;\n }\n return false;\n }\n\n private _storeValue(pos: number): boolean {\n if (this._key) {\n try {\n const v = this._buffer.slice(0, pos);\n this.fields[this._key] = DECODERS[this._key] ? DECODERS[this._key](v) : v;\n } catch {\n return false;\n }\n return true;\n }\n return false;\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n\nexport type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'image/webp' | 'image/avif' | 'unsupported' | '';\n\nexport interface IMetrics {\n mime: ImageType;\n width: number;\n height: number;\n}\n\nexport const UNSUPPORTED_TYPE: IMetrics = {\n mime: 'unsupported',\n width: 0,\n height: 0\n};\n\nexport function imageType(d: Uint8Array): IMetrics {\n if (d.length < 32) {\n return UNSUPPORTED_TYPE;\n }\n const d32 = new Uint32Array(d.buffer, d.byteOffset, 8);\n // PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)\n // + first chunk must be IHDR\n if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {\n return {\n mime: 'image/png',\n width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],\n height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]\n };\n }\n // JPEG: FF D8 FF\n if (d[0] === 0xFF && d[1] === 0xD8 && d[2] === 0xFF) {\n const [width, height] = jpgSize(d);\n return { mime: 'image/jpeg', width, height };\n }\n // GIF: GIF87a or GIF89a\n if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {\n return {\n mime: 'image/gif',\n width: d[7] << 8 | d[6],\n height: d[9] << 8 | d[8]\n };\n }\n // QOI: qoif\n if (d32[0] === 0x66696F71) {\n return {\n mime: 'image/qoi',\n width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7],\n height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11]\n };\n }\n // WEBP: RIFF | xxxx | WEBP | VP8x\n if (d32[0] === 0x46464952 && d32[2] === 0x50424557 && (d32[3] & 0xFFFFFF) === 0x385056) {\n switch (d[15]) {\n case 0x58: // Extended WebP VP8X --> \"X\"\n return {\n mime: 'image/webp',\n width: (d[24] | d[25] << 8 | d[26] << 16) + 1,\n height: (d[27] | d[28] << 8 | d[29] << 16) + 1\n };\n case 0x4C: // Lossless WebP VP8L --> \"L\"\n if (d[20] !== 0x2f) return UNSUPPORTED_TYPE;\n const dim = d[21] | d[22] << 8 | d[23] << 16 | d[24] << 24;\n return {\n mime: 'image/webp',\n width: (dim & 0x3FFF) + 1,\n height: (dim >>> 14 & 0x3FFF) + 1\n };\n case 0x20: // Lossy WebP VP8 --> \" \"\n if (d[23] !== 0x9d || d[24] !== 0x01 || d[25] !== 0x2a) return UNSUPPORTED_TYPE;\n return {\n mime: 'image/webp',\n width: (d[26] | d[27] << 8) & 0x3FFF,\n height: (d[28] | d[29] << 8) & 0x3FFF\n };\n }\n return UNSUPPORTED_TYPE;\n }\n // AVIF: Box size | ftyp | avif/avis\n if (d32[1] === 0x70797466 && (d32[2] === 0x66697661 || d32[2] === 0x73697661)) {\n let pos = -1;\n // search for ispe box within first 1024 bytes\n const limit = Math.min(d.length - 16, 1024);\n for (let i = 8; i < limit; i++) {\n // scan for ispe\n if (d[i] === 0x69 && d[i + 1] === 0x73 && d[i + 2] === 0x70 && d[i + 3] === 0x65) {\n pos = i;\n break;\n }\n }\n if (pos !== -1) {\n // dimensions are in BE at +8 (width) at +12 (height)\n const width =\n d[pos + 8] << 24 |\n d[pos + 9] << 16 |\n d[pos + 10] << 8 |\n d[pos + 11];\n const height =\n d[pos + 12] << 24 |\n d[pos + 13] << 16 |\n d[pos + 14] << 8 |\n d[pos + 15];\n if (width > 0 && height > 0) {\n return { mime: 'image/avif', width, height };\n }\n }\n return UNSUPPORTED_TYPE;\n }\n return UNSUPPORTED_TYPE;\n}\n\n\nfunction jpgSize(d: Uint8Array): [number, number] {\n const len = d.length;\n let i = 4;\n let blockLength = d[i] << 8 | d[i + 1];\n while (true) {\n i += blockLength;\n if (i >= len) {\n // exhausted without size info\n return [0, 0];\n }\n if (d[i] !== 0xFF) {\n return [0, 0];\n }\n if (d[i + 1] === 0xC0 || d[i + 1] === 0xC2) {\n if (i + 8 < len) {\n return [\n d[i + 7] << 8 | d[i + 8],\n d[i + 5] << 8 | d[i + 6]\n ];\n }\n return [0, 0];\n }\n i += 2;\n blockLength = d[i] << 8 | d[i + 1];\n }\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';\nimport { ImageRenderer } from '../ImageRenderer';\nimport { CELL_SIZE_DEFAULT } from '../ImageStorage';\nimport { KittyImageStorage } from './KittyImageStorage';\nimport Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport {\n KittyAction,\n KittyFormat,\n KittyCompression,\n IKittyCommand,\n IPendingTransmission,\n IKittyImageData,\n KittyPixelConstants,\n parseKittyCommand\n} from './KittyGraphicsTypes';\n\nconst enum Constants {\n // Memory limit for base64 decoder (4MB, same as IIPHandler)\n DECODER_KEEP_DATA = 4194304,\n DECODER_INITIAL_DATA = 4194304, // 4MB\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n DECODER_OK = 0,\n // Maximum control data size\n MAX_CONTROL_DATA_SIZE = 512,\n // Semicolon codepoint\n SEMICOLON = 0x3B\n}\n\nconst DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK;\n\n// Kitty graphics protocol handler with streaming base64 decoding.\nexport class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {\n private _aborted = false;\n private _decodeError = false;\n\n private _activeDecoder: Base64Decoder | null = null;\n private readonly _maxEncodedBytes: number;\n private readonly _initialEncodedBytes: number;\n\n // Streaming related states\n\n // True while receiving control data (before semicolon).\n private _inControlData = true;\n\n // Buffer for control data.\n private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);\n private _controlLength = 0;\n\n // Pre-calculated encoded size limit\n private _encodedSizeLimit = 0;\n private _totalEncodedSize = 0;\n\n // Parsed command. These are the control data before semicolon.\n private _parsedCommand: IKittyCommand | null = null;\n\n // Storage related states\n\n private _pendingTransmissions: Map = new Map();\n // Tracks the pending key of the most recently started chunked upload.\n // Per spec, subsequent chunks only need m= (and optionally q=), without i=.\n // When a chunk arrives with no i=, this key is used to find the pending upload.\n private _lastPendingKey: number | undefined;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _kittyStorage: KittyImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n // Convert decoded size limit -> max encoded bytes.\n this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);\n // ensure we preallocate more than configured limit while using 4mb initial size.\n this._initialEncodedBytes = Math.min(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);\n }\n\n public reset(): void {\n this._cleanupAllPending();\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n this._kittyStorage.reset();\n }\n\n public dispose(): void {\n this.reset();\n }\n\n private _removePendingEntry(key: number): void {\n this._pendingTransmissions.delete(key);\n if (this._lastPendingKey === key) {\n this._lastPendingKey = undefined;\n }\n }\n\n private _cleanupAllPending(): void {\n for (const pending of this._pendingTransmissions.values()) {\n pending.decoder.release();\n }\n this._pendingTransmissions.clear();\n this._lastPendingKey = undefined;\n }\n\n public start(): void {\n this._aborted = false;\n this._decodeError = false;\n this._inControlData = true;\n this._controlLength = 0;\n this._parsedCommand = null;\n // Pre-calculate encoded limit once: base64 is 4 bytes encoded \u2192 3 bytes decoded\n this._encodedSizeLimit = this._maxEncodedBytes;\n this._totalEncodedSize = 0;\n this._activeDecoder = null;\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (!this._inControlData) {\n this._streamPayload(data, start, end);\n } else {\n // Scan for semicolon\n let controlEnd = end;\n for (let i = start; i < end; i++) {\n if (data[i] === Constants.SEMICOLON) {\n this._inControlData = false;\n controlEnd = i;\n break;\n }\n }\n\n // Copy control data\n const copyLength = controlEnd - start;\n if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {\n this._aborted = true;\n return;\n }\n this._controlData.set(data.subarray(start, controlEnd), this._controlLength);\n this._controlLength += copyLength;\n\n if (!this._inControlData) {\n // Found semicolon - parse control data early for validation\n this._parsedCommand = parseKittyCommand(this._parseControlDataString());\n\n // Early validation: i+I conflict\n if (this._parsedCommand.id !== undefined && this._parsedCommand.imageNumber !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'EINVAL:cannot specify both i and I keys', this._parsedCommand.quiet ?? 0);\n this._aborted = true;\n return;\n }\n\n // Delete action doesn't need payload - skip streaming\n if (this._parsedCommand.action === KittyAction.DELETE) {\n return;\n }\n\n // Stream remaining as payload\n const payloadStart = controlEnd + 1;\n if (payloadStart < end) {\n this._streamPayload(data, payloadStart, end);\n }\n }\n }\n }\n\n // Stream payload bytes into the base64 decoder.\n private _streamPayload(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n // Check size limit (compare encoded bytes against pre-calculated limit)\n // Include cumulative size from pending transmission for multi-chunk images.\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = this._parsedCommand?.id ?? this._lastPendingKey ?? 0;\n const pending = this._pendingTransmissions.get(pendingKey);\n const previousEncodedSize = pending?.totalEncodedSize ?? 0;\n this._totalEncodedSize += end - start;\n const cumulativeEncodedSize = previousEncodedSize + this._totalEncodedSize;\n if (cumulativeEncodedSize > this._encodedSizeLimit) {\n const decoderToRelease = this._activeDecoder ?? pending?.decoder;\n if (decoderToRelease) {\n decoderToRelease.release();\n }\n this._activeDecoder = null;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n this._aborted = true;\n return;\n }\n\n if (this._decodeError) return;\n\n if (pending?.decoder && !this._activeDecoder) {\n this._activeDecoder = pending.decoder;\n }\n if (!this._activeDecoder) {\n this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);\n this._activeDecoder.init();\n }\n\n if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n this._decodeError = true;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted || !success) {\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n return true;\n }\n\n // No semicolon = no payload (delete, capability query)\n if (this._inControlData) {\n return this._handleNoPayloadCommand();\n }\n\n // Use command parsed early in put() - i+I already validated there\n const cmd = this._parsedCommand!;\n\n // Delete action was handled by skipping payload - just execute\n if (cmd.action === KittyAction.DELETE) {\n return this._handleDelete(cmd);\n }\n\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = cmd.id ?? this._lastPendingKey ?? 0;\n const isMoreComing = cmd.more === 1;\n const pending = this._pendingTransmissions.get(pendingKey);\n\n if (isMoreComing) {\n if (this._activeDecoder) {\n if (pending) {\n pending.totalEncodedSize += this._totalEncodedSize;\n pending.decodeError = pending.decodeError || this._decodeError;\n } else {\n this._pendingTransmissions.set(pendingKey, {\n cmd: { ...cmd },\n decoder: this._activeDecoder,\n totalEncodedSize: this._totalEncodedSize,\n decodeError: this._decodeError\n });\n }\n this._lastPendingKey = pendingKey;\n this._activeDecoder = null;\n }\n return true;\n }\n\n // Final chunk received \u2014 clear the last pending key\n if (pending) {\n this._lastPendingKey = undefined;\n }\n\n let decodeError = this._decodeError;\n let finalCmd = cmd;\n let decoder = this._activeDecoder;\n\n if (pending) {\n finalCmd = pending.cmd;\n decoder = pending.decoder;\n decodeError = decodeError || pending.decodeError;\n this._pendingTransmissions.delete(pendingKey);\n }\n\n let imageBytes = new Uint8Array(0);\n if (decoder) {\n if (decoder.end() !== DECODER_OK) {\n decodeError = true;\n }\n imageBytes = decoder.data8;\n }\n this._activeDecoder = null;\n\n // Handle command first \u2014 handlers create Blob/ImageData from imageBytes,\n // which copies the data. Only then is it safe to release the decoder's\n // wasm memory that imageBytes points into.\n const result = this._handleCommandWithBytesAndCmd(finalCmd, imageBytes, decodeError);\n if (decoder) {\n decoder.release();\n }\n return result;\n }\n\n // Command handling\n\n private _parseControlDataString(): string {\n let str = '';\n for (let i = 0; i < this._controlLength; i++) {\n str += String.fromCodePoint(this._controlData[i]);\n }\n return str;\n }\n\n private _handleNoPayloadCommand(): boolean | Promise {\n const cmd = parseKittyCommand(this._parseControlDataString());\n\n // Per spec: specifying both i and I is an error\n if (cmd.id !== undefined && cmd.imageNumber !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:cannot specify both i and I keys', cmd.quiet ?? 0);\n return true;\n }\n\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.DELETE:\n return this._handleDelete(cmd);\n case KittyAction.QUERY:\n this._sendResponse(cmd.id ?? 0, 'OK', cmd.quiet ?? 0);\n return true;\n case KittyAction.PLACEMENT:\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handleCommandWithBytesAndCmd(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.TRANSMIT: {\n const result = this._handleTransmit(cmd, bytes, decodeError);\n // Only send response when _handleTransmit didn't already respond\n // (it handles unsupported transmission medium responses internally)\n if ((cmd.transmission ?? 'd') === 'd' && cmd.id !== undefined) {\n if (decodeError) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n } else if (bytes.length > 0) {\n this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0);\n }\n }\n return result;\n }\n case KittyAction.TRANSMIT_DISPLAY:\n return this._handleTransmitDisplay(cmd, bytes, decodeError);\n case KittyAction.QUERY:\n return this._handleQuery(cmd, bytes, decodeError);\n case KittyAction.PLACEMENT:\n // a=p ignores any payload \u2014 image data was already transmitted\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handlePlacement(cmd: IKittyCommand): boolean | Promise {\n if (cmd.id === undefined) {\n return true;\n }\n const id = cmd.id;\n const image = this._kittyStorage.getImage(id);\n if (!image) {\n this._sendResponse(id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);\n return true;\n }\n const result = this._displayImage(image, cmd);\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);\n return true;\n });\n }\n\n private _handleTransmit(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n // TODO: Support file-based transmission modes (t=f, t=t, t=s)\n // Currently only supports direct transmission (t=d, the default).\n // - t=f (file): Payload is base64-encoded file path. Terminal reads image from that path.\n // - t=t (temp file): Payload is base64-encoded path in temp directory. Terminal reads, deletes.\n // - t=s: Payload is base64-encoded POSIX shm name. Terminal reads from shared memory.\n // These modes require filesystem/IPC access not available in browsers. For Node.js/Electron:\n // 1. Check cmd.transmission (t key) before treating bytes as image data\n // 2. For t=f/t/s: decode bytes as UTF-8 string (the path/name), then read file contents\n // 3. For t=d: treat bytes as image data (current behavior)\n // When implementing, also update _handleQuery to accept these transmission mediums.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported transmission medium', cmd.quiet ?? 0);\n }\n return true;\n }\n\n if (decodeError || bytes.length === 0) return true;\n\n this._kittyStorage.storeImage(cmd.id, {\n data: new Blob([bytes as BlobPart]),\n width: cmd.width ?? 0,\n height: cmd.height ?? 0,\n format: (cmd.format ?? KittyFormat.RGBA) as 24 | 32 | 100,\n compression: cmd.compression ?? ''\n });\n return true;\n }\n\n private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n if (decodeError) {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n }\n return true;\n }\n\n this._handleTransmit(cmd, bytes, decodeError);\n\n const id = cmd.id ?? this._kittyStorage.lastImageId;\n const image = this._kittyStorage.getImage(id);\n if (image) {\n const result = this._displayImage(image, cmd);\n if (cmd.id !== undefined) {\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0);\n return true;\n });\n }\n return result.then(() => true);\n }\n return true;\n }\n\n private _handleQuery(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n const id = cmd.id ?? 0;\n const quiet = cmd.quiet ?? 0;\n\n // Per spec: reject unsupported transmission mediums (only t=d is supported atm)\n // TODO: When filesystem support is added (Node.js/Electron), update this to accept\n // t=f (file), t=t (temp file), and t=s (shared memory) and respond OK for queries.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n this._sendResponse(id, 'EINVAL:unsupported transmission medium', quiet);\n return true;\n }\n\n // Check decode error first (invalid base64)\n if (decodeError) {\n this._sendResponse(id, 'EINVAL:invalid base64 data', quiet);\n return true;\n }\n\n // Capability query (no payload) - just respond OK\n if (bytes.length === 0) {\n this._sendResponse(id, 'OK', quiet);\n return true;\n }\n\n const format = cmd.format ?? KittyFormat.RGBA;\n\n if (format === KittyFormat.PNG) {\n this._sendResponse(id, 'OK', quiet);\n } else {\n const width = cmd.width ?? 0;\n const height = cmd.height ?? 0;\n\n if (!width || !height) {\n this._sendResponse(id, 'EINVAL:width and height required for raw pixel data', quiet);\n return true;\n }\n\n const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n this._sendResponse(id, `EINVAL:insufficient pixel data`, quiet);\n return true;\n }\n\n this._sendResponse(id, 'OK', quiet);\n }\n return true;\n }\n\n private _handleDelete(cmd: IKittyCommand): boolean {\n // Per spec: default delete selector is 'a' (delete all visible placements)\n const selector = cmd.deleteSelector ?? 'a';\n\n // TODO: Distinguish lowercase (delete placements only) from uppercase\n // (delete placements + free stored image data). Currently both variants\n // free everything since we don't separate stored data from placements.\n switch (selector) {\n case 'a':\n case 'A':\n this._cleanupAllPending();\n this._kittyStorage.deleteAll();\n break;\n case 'i':\n case 'I':\n // TODO: When placement id tracking is implemented (see TODO in\n // KittyImageStorage), d=i with p= should delete only that\n // specific placement, while d=i without p should delete all\n // placements for the image.\n if (cmd.id !== undefined) {\n const pending = this._pendingTransmissions.get(cmd.id);\n if (pending) {\n pending.decoder.release();\n }\n this._removePendingEntry(cmd.id);\n this._kittyStorage.deleteById(cmd.id);\n }\n break;\n default:\n // Unsupported selectors (c, n, p, q, r, x, y, z, f) \u2014 ignore for now\n break;\n }\n return true;\n }\n\n private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void {\n const isOk = message === 'OK';\n if (isOk && quiet >= 1) return;\n if (!isOk && quiet >= 2) return;\n\n const pPart = placementId ? `,p=${placementId}` : '';\n const response = `\\x1b_Gi=${id}${pPart};${message}\\x1b\\\\`;\n this._coreTerminal._core.coreService.triggerDataEvent(response);\n }\n\n // Image display\n\n private _displayImage(image: IKittyImageData, cmd: IKittyCommand): Promise {\n return this._decodeAndDisplay(image, cmd)\n .then(() => true)\n .catch(() => false);\n }\n\n private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise {\n let bitmap: ImageBitmap | undefined = await this._createBitmap(image);\n\n try {\n const cropX = Math.max(0, cmd.x ?? 0);\n const cropY = Math.max(0, cmd.y ?? 0);\n const cropW = cmd.sourceWidth || (bitmap.width - cropX);\n const cropH = cmd.sourceHeight || (bitmap.height - cropY);\n\n const maxCropW = Math.max(0, bitmap.width - cropX);\n const maxCropH = Math.max(0, bitmap.height - cropY);\n const finalCropW = Math.max(0, Math.min(cropW, maxCropW));\n const finalCropH = Math.max(0, Math.min(cropH, maxCropH));\n\n if (finalCropW === 0 || finalCropH === 0) {\n throw new Error('invalid source rectangle');\n }\n\n if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {\n const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);\n bitmap.close();\n bitmap = cropped;\n }\n\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n\n // Per spec: c/r default to image's natural cell dimensions.\n // If only one of c/r is specified, compute the other from image aspect ratio.\n let imgCols: number;\n let imgRows: number;\n if (cmd.columns !== undefined && cmd.rows !== undefined) {\n imgCols = cmd.columns;\n imgRows = cmd.rows;\n } else if (cmd.columns !== undefined) {\n imgCols = cmd.columns;\n imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));\n } else if (cmd.rows !== undefined) {\n imgRows = cmd.rows;\n imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));\n } else {\n imgCols = Math.ceil(bitmap.width / cw);\n imgRows = Math.ceil(bitmap.height / ch);\n }\n\n let w = bitmap.width;\n let h = bitmap.height;\n\n // Scale bitmap to fit placement rectangle when c/r are specified\n if (cmd.columns !== undefined || cmd.rows !== undefined) {\n w = Math.round(imgCols * cw);\n h = Math.round(imgRows * ch);\n }\n\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n\n // Save cursor position before addImage modifies it\n const buffer = this._coreTerminal._core.buffer;\n const savedX = buffer.x;\n const savedY = buffer.y;\n const savedYbase = buffer.ybase;\n\n // Determine layer based on z-index: negative = behind text, 0+ = on top.\n // When z<0 we always use the bottom layer even without allowTransparency \u2014\n // the image will simply be hidden behind the opaque text background, which\n // is the correct behavior (client asked for \"behind text\").\n const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;\n const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';\n\n if (w !== bitmap.width || h !== bitmap.height) {\n const scaled = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });\n bitmap.close();\n bitmap = scaled;\n }\n\n // Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions\n const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1);\n const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1);\n if (xOffset !== 0 || yOffset !== 0) {\n // Per spec: X/Y is not added to c/r area. When c/r are explicit, the\n // total placement area remains c*cw \u00D7 r*ch pixels and the offset image\n // is clipped to fit. When c/r are unset, the padded canvas determines\n // the natural cell dimensions.\n const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset;\n const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset;\n const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);\n const offsetCtx = offsetCanvas.getContext('2d');\n if (!offsetCtx) {\n throw new Error('Failed to create offset canvas context');\n }\n offsetCtx.drawImage(bitmap, xOffset, yOffset);\n\n const offsetBitmap = await createImageBitmap(offsetCanvas);\n offsetCanvas.width = offsetCanvas.height = 0;\n bitmap.close();\n bitmap = offsetBitmap;\n w = bitmap.width;\n h = bitmap.height;\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n if (cmd.columns === undefined) {\n imgCols = Math.ceil(bitmap.width / cw);\n }\n if (cmd.rows === undefined) {\n imgRows = Math.ceil(bitmap.height / ch);\n }\n }\n\n const zIndex = cmd.zIndex ?? 0;\n this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);\n bitmap = undefined; // ownership transferred to storage\n\n // Kitty cursor movement\n // Per spec: cursor placed at first column after last image column,\n // on the last row of the image. C=1 means don't move cursor.\n if (cmd.cursorMovement === 1) {\n // C=1: restore cursor to position before image was placed\n const scrolled = buffer.ybase - savedYbase;\n buffer.x = savedX;\n // Can't restore cursor to scrollback?\n buffer.y = Math.max(savedY - scrolled, 0);\n } else {\n // Default (C=0): advance cursor horizontally past the image\n // addImage already positioned cursor on the last row via lineFeeds\n buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);\n }\n } catch (e) {\n bitmap?.close();\n throw e;\n }\n }\n\n // Create ImageBitmap from already-decoded image data.\n private async _createBitmap(image: IKittyImageData): Promise {\n let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());\n\n if (image.compression === KittyCompression.ZLIB) {\n bytes = await this._decompressZlib(bytes);\n }\n\n if (image.format === KittyFormat.PNG) {\n const blob = new Blob([bytes as BlobPart], { type: 'image/png' });\n if (!window.createImageBitmap) {\n const url = URL.createObjectURL(blob);\n const img = new Image();\n return new Promise((resolve, reject) => {\n img.addEventListener('load', () => {\n URL.revokeObjectURL(url);\n const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);\n canvas.getContext('2d')?.drawImage(img, 0, 0);\n createImageBitmap(canvas).then(resolve).catch(reject);\n });\n img.addEventListener('error', () => {\n URL.revokeObjectURL(url);\n reject(new Error('Failed to load image'));\n });\n img.src = url;\n });\n }\n return createImageBitmap(blob);\n }\n\n // Raw pixel data\n const width = image.width;\n const height = image.height;\n\n if (!width || !height) {\n throw new Error('Width and height required for raw pixel data');\n }\n\n const bytesPerPixel = image.format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n throw new Error('Insufficient pixel data');\n }\n\n const pixelCount = width * height;\n\n if (image.format === KittyFormat.RGBA) {\n // RGBA: use bytes directly \u2014 no copy needed\n return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA), width, height));\n }\n\n // RGB\u2192RGBA: interleave alpha using uint32 block processing (4 pixels per iteration).\n // 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes \u2014 ~6x faster.\n // Assumes little-endian (all modern browsers/Node.js).\n const data = new Uint8ClampedArray(pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA);\n const src32 = new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));\n const dst32 = new Uint32Array(data.buffer);\n const alignedPixels = pixelCount & ~3; // round down to multiple of 4\n\n let srcOffset = 0;\n let dstOffset = 0;\n for (let i = 0; i < alignedPixels; i += 4) {\n const b0 = src32[srcOffset++];\n const b1 = src32[srcOffset++];\n const b2 = src32[srcOffset++];\n // Little-endian: pixel bytes are [R,G,B] \u2192 uint32 ABGR layout\n dst32[dstOffset++] = 0xFF000000 | b0;\n dst32[dstOffset++] = 0xFF000000 | (b0 >>> 24) | (b1 << 8);\n dst32[dstOffset++] = 0xFF000000 | (b1 >>> 16) | (b2 << 16);\n dst32[dstOffset++] = 0xFF000000 | (b2 >>> 8);\n }\n\n // Handle remaining 1\u20133 pixels\n let srcByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n let dstByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n for (let i = alignedPixels; i < pixelCount; i++) {\n data[dstByte] = bytes[srcByte];\n data[dstByte + 1] = bytes[srcByte + 1];\n data[dstByte + 2] = bytes[srcByte + 2];\n data[dstByte + 3] = KittyPixelConstants.ALPHA_OPAQUE;\n srcByte += KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n dstByte += KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n }\n\n return createImageBitmap(new ImageData(data, width, height));\n }\n\n private async _decompressZlib(compressed: Uint8Array): Promise {\n try {\n return await this._decompress(compressed, 'deflate');\n } catch {\n return await this._decompress(compressed, 'deflate-raw');\n }\n }\n\n private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise {\n const ds = new DecompressionStream(format);\n const writer = ds.writable.getWriter();\n writer.write(compressed as BufferSource);\n writer.close();\n\n const chunks: Uint8Array[] = [];\n const reader = ds.readable.getReader();\n\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n chunks.push(value);\n }\n\n const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);\n const result = new Uint8Array(totalLength);\n let offset = 0;\n for (const chunk of chunks) {\n result.set(chunk, offset);\n offset += chunk.length;\n }\n return result;\n }\n\n public get images(): ReadonlyMap {\n return this._kittyStorage.images;\n }\n\n public get _kittyIdToStorageId(): ReadonlyMap {\n return this._kittyStorage.kittyIdToStorageId;\n }\n\n public get pendingTransmissions(): ReadonlyMap {\n return this._pendingTransmissions;\n }\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Kitty graphics protocol types, constants, and parsing utilities.\n */\n\nimport type Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\n\n// Kitty graphics protocol action types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'a'.\nexport const enum KittyAction {\n TRANSMIT = 't',\n TRANSMIT_DISPLAY = 'T',\n QUERY = 'q',\n PLACEMENT = 'p',\n DELETE = 'd'\n}\n\n// Kitty graphics protocol format types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyFormat {\n RGB = 24,\n RGBA = 32,\n PNG = 100\n}\n\n// Kitty graphics protocol compression types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'o'.\nexport const enum KittyCompression {\n NONE = '',\n ZLIB = 'z'\n}\n\n// Kitty graphics protocol control data keys.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyKey {\n // Action to perform (t=transmit, T=transmit+display, q=query, p=placement, d=delete)\n ACTION = 'a',\n // Image format (24=RGB, 32=RGBA, 100=PNG)\n FORMAT = 'f',\n // Image ID for referencing stored images\n ID = 'i',\n // Image number (alternative to ID, terminal assigns ID)\n IMAGE_NUMBER = 'I',\n // Source image width in pixels\n WIDTH = 's',\n // Source image height in pixels\n HEIGHT = 'v',\n // The left edge (in pixels) of the image area to display\n X_OFFSET = 'x',\n // The top edge (in pixels) of the image area to display\n Y_OFFSET = 'y',\n // Width (in pixels) of the source rectangle to display\n SOURCE_WIDTH = 'w',\n // Height (in pixels) of the source rectangle to display\n SOURCE_HEIGHT = 'h',\n // Horizontal offset (in pixels) within the first cell\n X_PLACEMENT_OFFSET = 'X',\n // Vertical offset (in pixels) within the first cell\n Y_PLACEMENT_OFFSET = 'Y',\n // Number of terminal columns to display the image over\n COLUMNS = 'c',\n // Number of terminal rows to display the image over\n ROWS = 'r',\n // More data flag (1=more chunks coming, 0=final chunk)\n MORE = 'm',\n // Compression type (z=zlib). This is essential for chunking larger images.\n COMPRESSION = 'o',\n // Quiet mode (1=suppress OK responses, 2=suppress error responses)\n QUIET = 'q',\n // Cursor movement policy (0=move cursor after image, 1=don't move cursor)\n CURSOR_MOVEMENT = 'C',\n // Z-index for image layering (negative = behind text, 0+ = on top)\n Z_INDEX = 'z',\n // Transmission medium (d=direct, f=file, t=temp file, s=shared memory)\n TRANSMISSION = 't',\n // Delete selector (a/A=all, i/I=by id, c/C=at cursor, etc.) \u2014 only used when a=d\n DELETE_SELECTOR = 'd',\n // Placement ID for targeting specific placements\n PLACEMENT_ID = 'p'\n}\n\n// Pixel format constants\nexport const enum KittyPixelConstants {\n BYTES_PER_PIXEL_RGB = 3,\n BYTES_PER_PIXEL_RGBA = 4,\n ALPHA_OPAQUE = 255\n}\n\n// Parsed Kitty graphics command.\nexport interface IKittyCommand {\n action?: string;\n format?: number;\n id?: number;\n imageNumber?: number;\n width?: number;\n height?: number;\n x?: number;\n y?: number;\n sourceWidth?: number;\n sourceHeight?: number;\n xOffset?: number;\n yOffset?: number;\n columns?: number;\n rows?: number;\n more?: number;\n quiet?: number;\n cursorMovement?: number;\n zIndex?: number;\n transmission?: string;\n deleteSelector?: string;\n placementId?: number;\n compression?: string;\n payload?: string;\n}\n\n// Pending chunked transmission state.\n// Stores metadata from the first chunk while accumulating decoded payload data.\nexport interface IPendingTransmission {\n // The parsed command from the first chunk (contains action, format, dimensions, etc.)\n cmd: IKittyCommand;\n // Decoder used across chunked payloads\n decoder: Base64Decoder;\n // Total encoded (base64) bytes received across all chunks - for size limit enforcement\n totalEncodedSize: number;\n // Whether any chunk has failed to decode\n decodeError: boolean;\n}\n\n// Stored Kitty image data.\nexport interface IKittyImageData {\n id: number;\n // Decoded image data stored as Blob (off JS heap) to avoid 2GB heap limit\n data: Blob;\n width: number;\n height: number;\n format: 24 | 32 | 100;\n compression?: string;\n}\n\n// Parses Kitty graphics control data into a command object.\nexport function parseKittyCommand(data: string): IKittyCommand {\n const cmd: IKittyCommand = {};\n const parts = data.split(',');\n\n for (const part of parts) {\n const eqIdx = part.indexOf('=');\n if (eqIdx === -1) continue;\n\n const key = part.substring(0, eqIdx);\n const value = part.substring(eqIdx + 1);\n\n // Handle string keys first\n if (key === KittyKey.ACTION) {\n cmd.action = value;\n continue;\n }\n if (key === KittyKey.COMPRESSION) {\n cmd.compression = value;\n continue;\n }\n if (key === KittyKey.TRANSMISSION) {\n cmd.transmission = value;\n continue;\n }\n if (key === KittyKey.DELETE_SELECTOR) {\n cmd.deleteSelector = value;\n continue;\n }\n const numValue = parseInt(value, 10);\n switch (key) {\n case KittyKey.FORMAT: cmd.format = numValue; break;\n case KittyKey.ID: cmd.id = numValue; break;\n case KittyKey.IMAGE_NUMBER: cmd.imageNumber = numValue; break;\n case KittyKey.WIDTH: cmd.width = numValue; break;\n case KittyKey.HEIGHT: cmd.height = numValue; break;\n case KittyKey.X_OFFSET: cmd.x = numValue; break;\n case KittyKey.Y_OFFSET: cmd.y = numValue; break;\n case KittyKey.SOURCE_WIDTH: cmd.sourceWidth = numValue; break;\n case KittyKey.SOURCE_HEIGHT: cmd.sourceHeight = numValue; break;\n case KittyKey.X_PLACEMENT_OFFSET: cmd.xOffset = numValue; break;\n case KittyKey.Y_PLACEMENT_OFFSET: cmd.yOffset = numValue; break;\n case KittyKey.COLUMNS: cmd.columns = numValue; break;\n case KittyKey.ROWS: cmd.rows = numValue; break;\n case KittyKey.MORE: cmd.more = numValue; break;\n case KittyKey.QUIET: cmd.quiet = numValue; break;\n case KittyKey.CURSOR_MOVEMENT: cmd.cursorMovement = numValue; break;\n case KittyKey.Z_INDEX: cmd.zIndex = numValue; break;\n case KittyKey.PLACEMENT_ID: cmd.placementId = numValue; break;\n }\n }\n\n return cmd;\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageStorage } from '../ImageStorage';\nimport { ImageLayer, IAddImageOpts } from '../Types';\nimport { IKittyImageData } from './KittyGraphicsTypes';\n\n// Kitty-specific image storage controller.\n//\n// Wraps shared ImageStorage with kitty protocol semantics:\n// - tracks transmitted image payloads by kitty image id\n// - tracks kitty image id -> shared ImageStorage id mapping for displayed images\n// - mirrors shared-storage evictions into kitty maps\n// - applies protocol-level undisplayed-image eviction policy\nexport class KittyImageStorage implements IDisposable {\n private static readonly _maxStoredImages = 256;\n\n private _nextImageId = 1;\n private readonly _images: Map = new Map();\n // TODO: Support multiple placements per image. The kitty spec identifies\n // placements by an (image id, placement id) pair \u2014 same i + different p\n // values should coexist, and same i + same p should replace the prior\n // placement. Currently we track only one storage entry per kitty image id,\n // so multiple placements of the same image overwrite each other. Fixing\n // this requires changing these maps to Map>\n // (kittyId \u2192 placementId \u2192 storageId) and updating addImage/deleteById\n // accordingly. The underlying shared ImageStorage would also need to\n // support multiple entries per logical image.\n private readonly _kittyIdToStorageId: Map = new Map();\n private readonly _storageIdToKittyId: Map = new Map();\n\n private readonly _previousOnImageDeleted: ((storageId: number) => void) | undefined;\n private readonly _wrappedOnImageDeleted: (storageId: number) => void;\n private readonly _handleStorageImageDeleted = (storageId: number): void => {\n const kittyId = this._storageIdToKittyId.get(storageId);\n if (kittyId !== undefined) {\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n this._images.delete(kittyId);\n }\n };\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n\n constructor(\n private readonly _storage: ImageStorage\n ) {\n this._previousOnImageDeleted = this._storage.onImageDeleted;\n this._wrappedOnImageDeleted = (storageId: number) => {\n this._previousOnImageDeleted?.(storageId);\n this._handleStorageImageDeleted(storageId);\n };\n this._storage.onImageDeleted = this._wrappedOnImageDeleted;\n }\n\n public reset(): void {\n this._nextImageId = 1;\n this._images.clear();\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public dispose(): void {\n this.reset();\n if (this._storage.onImageDeleted === this._wrappedOnImageDeleted) {\n this._storage.onImageDeleted = this._previousOnImageDeleted;\n }\n }\n\n public storeImage(id: number | undefined, imageData: Omit): number {\n const imageId = id ?? this._nextImageId++;\n\n const oldStorageId = this._kittyIdToStorageId.get(imageId);\n if (oldStorageId !== undefined) {\n this._storage.deleteImage(oldStorageId);\n this._kittyIdToStorageId.delete(imageId);\n this._storageIdToKittyId.delete(oldStorageId);\n }\n\n if (!this._images.has(imageId) && this._images.size >= KittyImageStorage._maxStoredImages) {\n this._evictUndisplayedImages();\n }\n\n this._images.set(imageId, {\n ...imageData,\n id: imageId\n });\n return imageId;\n }\n\n public addImage(kittyId: number, image: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer, zIndex: number): void {\n // Clean up stale reverse-mapping from a previous placement of the same\n // kitty image. The old shared-storage entry is kept (it may still be\n // visible on screen) but its reverse mapping is removed so that eviction\n // of the old entry won't incorrectly delete the kitty image data.\n const oldStorageId = this._kittyIdToStorageId.get(kittyId);\n if (oldStorageId !== undefined) {\n this._storageIdToKittyId.delete(oldStorageId);\n }\n this._addImageOpts.scrolling = scrolling;\n this._addImageOpts.layer = layer;\n this._addImageOpts.zIndex = zIndex;\n const storageId = this._storage.addImage(image, this._addImageOpts);\n this._kittyIdToStorageId.set(kittyId, storageId);\n this._storageIdToKittyId.set(storageId, kittyId);\n }\n\n public getImage(kittyId: number): IKittyImageData | undefined {\n return this._images.get(kittyId);\n }\n\n public deleteById(kittyId: number): void {\n this._images.delete(kittyId);\n const storageId = this._kittyIdToStorageId.get(kittyId);\n if (storageId !== undefined) {\n this._storage.deleteImage(storageId);\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n }\n }\n\n public deleteAll(): void {\n this._images.clear();\n for (const storageId of this._kittyIdToStorageId.values()) {\n this._storage.deleteImage(storageId);\n }\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public get images(): ReadonlyMap {\n return this._images;\n }\n\n public get kittyIdToStorageId(): ReadonlyMap {\n return this._kittyIdToStorageId;\n }\n\n public get lastImageId(): number {\n return this._nextImageId - 1;\n }\n\n private _evictUndisplayedImages(): void {\n for (const [kittyId] of this._images) {\n if (this._images.size <= KittyImageStorage._maxStoredImages / 2) {\n break;\n }\n if (!this._kittyIdToStorageId.has(kittyId)) {\n this._images.delete(kittyId);\n }\n }\n }\n}\n", "/**\n * Copyright (c) 2020, 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IDcsHandler, IParams, IImageAddonOptions, ITerminalExt, AttributeData, IResetHandler, ReadonlyColorSet } from './Types';\nimport { toRGBA8888, BIG_ENDIAN, PALETTE_ANSI_256, PALETTE_VT340_COLOR } from 'sixel/lib/Colors';\nimport { RGBA8888 } from 'sixel/lib/Types';\nimport { ImageRenderer } from './ImageRenderer';\n\nimport { DecoderAsync, Decoder } from 'sixel/lib/Decoder';\n\n// always free decoder ressources after decoding if it exceeds this limit\nconst MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB\n\n// custom default palette: VT340 (lower 16 colors) + ANSI256 (up to 256) + zeroed (up to 4096)\nconst DEFAULT_PALETTE = PALETTE_ANSI_256;\nDEFAULT_PALETTE.set(PALETTE_VT340_COLOR);\n\n\nexport class SixelHandler implements IDcsHandler, IResetHandler {\n private _size = 0;\n private _aborted = false;\n private _dec: Decoder | undefined;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _storage: SixelImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n DecoderAsync({\n memoryLimit: this._opts.pixelLimit * 4,\n palette: DEFAULT_PALETTE,\n paletteLimit: this._opts.sixelPaletteLimit\n }).then(d => this._dec = d);\n }\n\n public reset(): void {\n /**\n * reset sixel decoder to defaults:\n * - release all memory\n * - nullify palette (4096)\n * - apply default palette (256)\n */\n if (this._dec) {\n this._dec.release();\n // FIXME: missing interface on decoder to nullify full palette\n (this._dec as any)._palette.fill(0);\n this._dec.init(0, DEFAULT_PALETTE, this._opts.sixelPaletteLimit);\n }\n }\n\n public hook(params: IParams): void {\n this._size = 0;\n this._aborted = false;\n if (this._dec) {\n const fillColor = params.params[1] === 1 ? 0 : extractActiveBg(\n this._coreTerminal._core._inputHandler._curAttrData,\n this._coreTerminal._core._themeService?.colors);\n this._dec.init(fillColor, null, this._opts.sixelPaletteLimit);\n }\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted || !this._dec) {\n return;\n }\n this._size += end - start;\n if (this._size > this._opts.sixelSizeLimit) {\n console.warn(`SIXEL: too much data, aborting`);\n this._aborted = true;\n this._dec.release();\n return;\n }\n try {\n this._dec.decode(data, start, end);\n } catch (e) {\n console.warn(`SIXEL: error while decoding image - ${e}`);\n this._aborted = true;\n this._dec.release();\n }\n }\n\n public unhook(success: boolean): boolean | Promise {\n if (this._aborted || !success || !this._dec) {\n return true;\n }\n\n const width = this._dec.width;\n const height = this._dec.height;\n\n // partial fix for https://github.com/jerch/xterm-addon-image/issues/37\n if (!width || !height) {\n if (height) {\n this._storage.advanceCursor(height);\n }\n return true;\n }\n\n const canvas = ImageRenderer.createCanvas(undefined, width, height);\n canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray, width, height), 0, 0);\n if (this._dec.memoryUsage > MEM_PERMA_LIMIT) {\n this._dec.release();\n }\n this._storage.addImage(canvas);\n return true;\n }\n}\n\n\n/**\n * Some helpers to extract current terminal colors.\n */\n\n// get currently active background color from terminal\n// also respect INVERSE setting\nfunction extractActiveBg(attr: AttributeData, colors: ReadonlyColorSet | undefined): RGBA8888 {\n let bg = 0;\n if (!colors) {\n // FIXME: theme service is prolly not available yet,\n // happens if .open() was not called yet (bug in core?)\n return bg;\n }\n if (attr.isInverse()) {\n if (attr.isFgDefault()) {\n bg = convertLe(colors.foreground.rgba);\n } else if (attr.isFgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getFgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getFgColor()].rgba);\n }\n } else {\n if (attr.isBgDefault()) {\n bg = convertLe(colors.background.rgba);\n } else if (attr.isBgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getBgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getBgColor()].rgba);\n }\n }\n return bg;\n}\n\n// rgba values on the color managers are always in BE, thus convert to LE\nfunction convertLe(color: number): RGBA8888 {\n if (BIG_ENDIAN) return color;\n return (color & 0xFF) << 24 | (color >>> 8 & 0xFF) << 16 | (color >>> 16 & 0xFF) << 8 | color >>> 24 & 0xFF;\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { IImageAddonOptions, ITerminalExt, IAddImageOpts } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\n\n/**\n * Sixel-specific image storage controller.\n *\n * Wraps the shared ImageStorage with sixel protocol semantics:\n * - Cursor behavior governed by DECSET 80 (sixelScrolling option)\n * - advanceCursor for empty sixels carrying only height\n */\nexport class SixelImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'vt340' };\n constructor(\n private readonly _storage: ImageStorage,\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _terminal: ITerminalExt\n ) {}\n\n /**\n * Add a sixel image to storage.\n * Cursor behavior depends on the sixelScrolling option (DECSET 80).\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._addImageOpts.scrolling = this._opts.sixelScrolling;\n this._storage.addImage(img, this._addImageOpts);\n }\n\n /**\n * Only advance text cursor.\n * This is an edge case from empty sixels carrying only a height but no pixels.\n * Partially fixes https://github.com/jerch/xterm-addon-image/issues/37.\n */\n public advanceCursor(height: number): void {\n if (this._opts.sixelScrolling) {\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const rows = Math.ceil(height / cellSize.height);\n for (let i = 1; i < rows; ++i) {\n this._terminal._core._inputHandler.lineFeed();\n }\n }\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IAddImageOpts } from './Types';\nimport { ImageStorage } from './ImageStorage';\n\n/**\n * IIP (iTerm Image Protocol) specific image storage controller.\n *\n * Wraps the shared ImageStorage with IIP protocol semantics:\n * - Always uses scrolling mode (cursor advances with image)\n */\nexport class IIPImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n constructor(\n private readonly _storage: ImageStorage\n ) {}\n\n /**\n * Add an IIP image to storage.\n * Always uses scrolling mode \u2014 cursor advances past the image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._storage.addImage(img, this._addImageOpts);\n }\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ITerminalAddon, IDisposable } from '@xterm/xterm';\nimport type { ImageAddon as IImageApi } from '@xterm/addon-image';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { IIPHandler } from './IIPHandler';\nimport { ImageRenderer } from './ImageRenderer';\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';\nimport { KittyImageStorage } from './kitty/KittyImageStorage';\nimport { SixelHandler } from './SixelHandler';\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';\n\n\n/**\n * Document VT features provided by this addon.\n *\n * @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL \"SIXEL Graphics\" \"DCS Ps ; Ps ; Ps ; q Pt ST\" \"Draw SIXEL image.\"\n *\n * Sixel support is provided by the addon @xterm/addon-image with these limitations:\n * - immediate coloring (no shared palette, allows high color settings of `img2sixel`)\n * - max. palette size of 4096 colors\n * - max. pixel width of 16K\n * - max. 25 MB per sixel sequence\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] OSC 1337 \"iTerm2 Commands\" \"OSC 1337 ; Pt BEL\" \"Custom iTerm2 commands.\"\n *\n * Only the inline image protocol (IIP) is supported by the addon @xterm/addon-image with\n * the following limitations:\n * - sequence:\n * - format: `OSC 1337 ; File=inline=1 ; size= ; ... : BEL`\n * - size param must be set and payload may not exceed CEIL(size * 4 / 3)\n * - strict base64 handling as of RFC4648 \u00A74 (standard alphabet, optional padding,\n * no separator bytes allowed)\n * - supported params: size, name, width, height, preserveAspectRatio\n * - image formats: PNG, JPEG and GIF\n * - no animation support (renders first image of a GIF)\n * - no multipart support\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image)\n * and [iTerm2 IIP docs](https://iterm2.com/documentation-images.html) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] APC KITTY_GRAPHICS \"Kitty Graphics\" \"APC G Pt ST\" \"Kitty Graphics Protocol.\"\n *\n * Kitty graphics support is provided by the addon @xterm/addon-image.\n * Note that while basic image output already works, this is still work in progress.\n */\n\n// default values of addon ctor options\nconst DEFAULT_OPTIONS: IImageAddonOptions = {\n enableSizeReports: true,\n pixelLimit: 16777216, // limit to 4096 * 4096 pixels\n sixelSupport: true,\n sixelScrolling: true,\n sixelPaletteLimit: 4096,\n sixelSizeLimit: 33554432,\n storageLimit: 128,\n showPlaceholder: true,\n iipSupport: true,\n iipSizeLimit: 33554432,\n kittySupport: true,\n kittySizeLimit: 33554432\n};\n\n// max palette size supported by the sixel lib (compile time setting)\nconst MAX_SIXEL_PALETTE_SIZE = 4096;\n\n// definitions for _xtermGraphicsAttributes sequence\nconst enum GaItem {\n COLORS = 1,\n SIXEL_GEO = 2,\n REGIS_GEO = 3\n}\nconst enum GaAction {\n READ = 1,\n SET_DEFAULT = 2,\n SET = 3,\n READ_MAX = 4\n}\nconst enum GaStatus {\n SUCCESS = 0,\n ITEM_ERROR = 1,\n ACTION_ERROR = 2,\n FAILURE = 3\n}\n\n\nexport class ImageAddon implements ITerminalAddon, IImageApi {\n private _opts: IImageAddonOptions;\n private _defaultOpts: IImageAddonOptions;\n private _storage: ImageStorage | undefined;\n private _renderer: ImageRenderer | undefined;\n private _disposables: IDisposable[] = [];\n private _terminal: ITerminalExt | undefined;\n private _handlers: Map = new Map();\n private readonly _onImageAdded = new Emitter();\n public readonly onImageAdded: IEvent = this._onImageAdded.event;\n\n constructor(opts?: Partial) {\n this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);\n this._defaultOpts = Object.assign({}, DEFAULT_OPTIONS, opts);\n }\n\n public dispose(): void {\n for (const obj of this._disposables) {\n obj.dispose();\n }\n this._disposables.length = 0;\n this._handlers.clear();\n this._onImageAdded.dispose();\n }\n\n private _disposeLater(...args: IDisposable[]): void {\n for (const obj of args) {\n this._disposables.push(obj);\n }\n }\n\n public activate(terminal: ITerminalExt): void {\n this._terminal = terminal;\n\n // internal data structures\n this._renderer = new ImageRenderer(terminal);\n this._storage = new ImageStorage(terminal, this._renderer, this._opts);\n this._storage.onImageAdded = () => this._onImageAdded.fire();\n\n // enable size reports\n if (this._opts.enableSizeReports) {\n const windowOps = terminal.options.windowOptions ?? {};\n windowOps.getWinSizePixels = true;\n windowOps.getCellSizePixels = true;\n windowOps.getWinSizeChars = true;\n terminal.options.windowOptions = windowOps;\n }\n\n this._disposeLater(\n this._renderer,\n this._storage,\n\n // DECSET/DECRST/DA1/XTSMGRAPHICS handlers\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'h' }, params => this._decset(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'l' }, params => this._decrst(params)),\n terminal.parser.registerCsiHandler({ final: 'c' }, params => this._da1(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'S' }, params => this._xtermGraphicsAttributes(params)),\n\n // render hook\n terminal.onRender(range => this._storage?.render(range)),\n\n /**\n * reset handlers covered:\n * - DECSTR\n * - RIS\n * - Terminal.reset()\n */\n terminal.parser.registerCsiHandler({ intermediates: '!', final: 'p' }, () => this.reset()),\n terminal.parser.registerEscHandler({ final: 'c' }, () => this.reset()),\n terminal._core._inputHandler.onRequestReset(() => this.reset()),\n\n // wipe canvas and delete alternate images on buffer switch\n terminal.buffer.onBufferChange(() => this._storage?.wipeAlternate()),\n\n // extend images to the right on resize\n terminal.onResize(metrics => this._storage?.viewportResize(metrics))\n );\n\n // SIXEL handler\n if (this._opts.sixelSupport) {\n const sixelStorage = new SixelImageStorage(this._storage!, this._opts, this._renderer!, terminal);\n const sixelHandler = new SixelHandler(this._opts, sixelStorage, terminal);\n this._handlers.set('sixel', sixelHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler)\n );\n }\n\n // iTerm IIP handler\n if (this._opts.iipSupport) {\n const iipStorage = new IIPImageStorage(this._storage!);\n const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, terminal);\n this._handlers.set('iip', iipHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)\n );\n }\n\n // Kitty graphics handler\n if (this._opts.kittySupport) {\n const kittyStorage = new KittyImageStorage(this._storage!);\n const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, kittyStorage, terminal);\n this._handlers.set('kitty', kittyHandler);\n this._disposeLater(\n kittyStorage,\n kittyHandler,\n terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)\n );\n }\n }\n\n // Note: storageLimit is skipped here to not intoduce a surprising side effect.\n public reset(): boolean {\n // reset options customizable by sequences to defaults\n this._opts.sixelScrolling = this._defaultOpts.sixelScrolling;\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n // also clear image storage\n this._storage?.reset();\n // reset protocol handlers\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return false;\n }\n\n public get storageLimit(): number {\n return this._storage?.getLimit() || -1;\n }\n\n public set storageLimit(limit: number) {\n this._storage?.setLimit(limit);\n this._opts.storageLimit = limit;\n }\n\n public get storageUsage(): number {\n if (this._storage) {\n return this._storage.getUsage();\n }\n return -1;\n }\n\n public get showPlaceholder(): boolean {\n return this._opts.showPlaceholder;\n }\n\n public set showPlaceholder(value: boolean) {\n this._opts.showPlaceholder = value;\n this._renderer?.showPlaceholder(value);\n }\n\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.getImageAtBufferCell(x, y);\n }\n\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.extractTileAtBufferCell(x, y);\n }\n\n private _report(s: string): void {\n this._terminal?._core.input(s, false);\n }\n\n private _decset(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = false;\n break;\n }\n }\n return false;\n }\n\n private _decrst(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = true;\n break;\n }\n }\n return false;\n }\n\n // overload DA to return something more appropriate\n private _da1(params: (number | number[])[]): boolean {\n if (params[0]) {\n return true;\n }\n // reported features:\n // 62 - VT220\n // 4 - SIXEL support\n // 9 - charsets\n // 22 - ANSI colors\n if (this._opts.sixelSupport) {\n this._report(`\\x1b[?62;4;9;22c`);\n return true;\n }\n return false;\n }\n\n /**\n * Implementation of xterm's graphics attribute sequence.\n *\n * Supported features:\n * - read/change palette limits (max 4096 by sixel lib)\n * - read SIXEL canvas geometry (reports current window canvas or\n * squared pixelLimit if canvas > pixel limit)\n *\n * Everything else is deactivated.\n */\n private _xtermGraphicsAttributes(params: (number | number[])[]): boolean {\n if (params.length < 2) {\n return true;\n }\n if (params[0] === GaItem.COLORS) {\n switch (params[1]) {\n case GaAction.READ:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n return true;\n case GaAction.SET_DEFAULT:\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n // also reset protocol handlers for now\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return true;\n case GaAction.SET:\n if (params.length > 2 && !(params[2] instanceof Array) && params[2] <= MAX_SIXEL_PALETTE_SIZE) {\n this._opts.sixelPaletteLimit = params[2];\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n } else {\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n }\n return true;\n case GaAction.READ_MAX:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${MAX_SIXEL_PALETTE_SIZE}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n if (params[0] === GaItem.SIXEL_GEO) {\n switch (params[1]) {\n // we only implement read and read_max here\n case GaAction.READ:\n let width = this._renderer?.dimensions?.css.canvas.width;\n let height = this._renderer?.dimensions?.css.canvas.height;\n if (!width || !height) {\n // for some reason we have no working image renderer\n // --> fallback to default cell size\n const cellSize = CELL_SIZE_DEFAULT;\n width = (this._terminal?.cols || 80) * cellSize.width;\n height = (this._terminal?.rows || 24) * cellSize.height;\n }\n if (width * height < this._opts.pixelLimit) {\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${width.toFixed(0)};${height.toFixed(0)}S`);\n } else {\n // if we overflow pixelLimit report that squared instead\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n }\n return true;\n case GaAction.READ_MAX:\n // read_max returns pixelLimit as square area\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n // exit with error on ReGIS or any other requests\n this._report(`\\x1b[?${params[0]};${GaStatus.ITEM_ERROR}S`);\n return true;\n }\n}\n"], - "mappings": ";;;;;;;;;;;;;;;;02BAYaA,EAAA,WAAa,IAAI,WAAW,IAAI,YAAY,CAAC,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC,IAAM,IAClFA,EAAA,YACF,QAAQ,KAAK,6EAA6E,EAI5F,SAAgBC,GAAIC,EAAW,CAC7B,OAAOA,EAAI,GACb,CAFAF,EAAA,IAAAC,GAIA,SAAgBE,GAAMD,EAAW,CAC/B,OAAQA,IAAM,EAAK,GACrB,CAFAF,EAAA,MAAAG,GAIA,SAAgBC,GAAKF,EAAW,CAC9B,OAAQA,IAAM,GAAM,GACtB,CAFAF,EAAA,KAAAI,GAIA,SAAgBC,GAAMH,EAAW,CAC/B,OAAQA,IAAM,GAAM,GACtB,CAFAF,EAAA,MAAAK,GAQA,SAAgBC,EAAW,EAAWC,EAAWC,EAAWC,EAAY,IAAG,CACzE,QAASA,EAAI,MAAS,IAAMD,EAAI,MAAS,IAAMD,EAAI,MAAS,EAAK,EAAI,OAAW,CAClF,CAFAP,EAAA,WAAAM,EAQA,SAAgBI,GAAaC,EAAe,CAC1C,MAAO,CAACA,EAAQ,IAAOA,GAAS,EAAK,IAAOA,GAAS,GAAM,IAAMA,IAAU,EAAE,CAC/E,CAFAX,EAAA,aAAAU,GASA,SAAgBE,GAAkBD,EAAiBE,EAAmB,CACpE,IAAMC,EAAIb,GAAIU,CAAK,EACbJ,EAAIJ,GAAMQ,CAAK,EACfH,EAAIJ,GAAKO,CAAK,EAEhBI,EAAM,OAAO,iBACbC,EAAM,GAGV,QAASC,EAAI,EAAGA,EAAIJ,EAAQ,OAAQ,EAAEI,EAAG,CACvC,IAAMC,EAAKJ,EAAID,EAAQI,CAAC,EAAE,CAAC,EACrBE,EAAKZ,EAAIM,EAAQI,CAAC,EAAE,CAAC,EACrBG,EAAKZ,EAAIK,EAAQI,CAAC,EAAE,CAAC,EACrBI,EAAIH,EAAKA,EAAKC,EAAKA,EAAKC,EAAKA,EACnC,GAAI,CAACC,EAAG,OAAOJ,EACXI,EAAIN,IACNA,EAAMM,EACNL,EAAMC,GAIV,OAAOD,CACT,CAtBAhB,EAAA,kBAAAY,GA4BA,SAASU,GAAMC,EAAaC,EAAcC,EAAa,CACrD,OAAO,KAAK,IAAIF,EAAK,KAAK,IAAIE,EAAOD,CAAI,CAAC,CAC5C,CAEA,SAASE,GAAIC,EAAYC,EAAYC,EAAS,CAC5C,OAAIA,EAAI,IAAGA,GAAK,GACZA,EAAI,IAAGA,GAAK,GACTA,EAAI,EAAI,EACXD,GAAMD,EAAKC,GAAM,EAAIC,EACrBA,EAAI,EAAI,EACNF,EACAE,EAAI,EAAI,EACND,GAAMD,EAAKC,IAAO,EAAIC,EAAI,GAC1BD,CACV,CAEA,SAASE,GAASC,EAAWC,EAAWC,EAAS,CAC/C,GAAI,CAACA,EAAG,CACN,IAAMC,EAAI,KAAK,MAAMF,EAAI,GAAG,EAC5B,OAAO1B,EAAW4B,EAAGA,EAAGA,CAAC,EAE3B,IAAMP,EAAKK,EAAI,GAAMA,GAAK,EAAIC,GAAKD,EAAIC,EAAID,EAAIC,EACzCL,EAAK,EAAII,EAAIL,EACnB,OAAOrB,EACLgB,GAAM,EAAG,IAAK,KAAK,MAAMI,GAAIC,EAAIC,EAAIG,EAAI,EAAI,CAAC,EAAI,GAAG,CAAC,EACtDT,GAAM,EAAG,IAAK,KAAK,MAAMI,GAAIC,EAAIC,EAAIG,CAAC,EAAI,GAAG,CAAC,EAC9CT,GAAM,EAAG,IAAK,KAAK,MAAMI,GAAIC,EAAIC,EAAIG,EAAI,EAAI,CAAC,EAAI,GAAG,CAAC,CAAC,CAE3D,CAKA,SAAgBI,EAAa,EAAW5B,EAAWC,EAAS,CAC1D,OAAQ,WAAa,KAAK,MAAMA,EAAI,IAAM,GAAG,GAAK,GAAK,KAAK,MAAMD,EAAI,IAAM,GAAG,GAAK,EAAI,KAAK,MAAM,EAAI,IAAM,GAAG,KAAO,CACzH,CAFAP,EAAA,aAAAmC,EAQA,SAAgBC,GAAaL,EAAWC,EAAWC,EAAS,CAE1D,OAAOH,IAAUC,EAAI,IAAM,KAAO,IAAKC,EAAI,IAAKC,EAAI,GAAG,CACzD,CAHAjC,EAAA,aAAAoC,GAqCapC,EAAA,oBAAsB,IAAI,YAAY,CACjDmC,EAAc,EAAI,EAAI,CAAC,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACxB,EA0BYnC,EAAA,mBAAqB,IAAI,YAAY,CAChDmC,EAAc,EAAI,EAAI,CAAC,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAc,EAAI,EAAI,CAAC,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAc,EAAI,EAAI,CAAC,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAc,EAAI,EAAI,CAAC,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACxB,EAOYnC,EAAA,kBAAoB,IAAK,CAEpC,IAAMqC,EAAgB,CACpB/B,EAAW,EAAG,EAAG,CAAC,EAClBA,EAAW,IAAK,EAAG,CAAC,EACpBA,EAAW,EAAG,IAAK,CAAC,EACpBA,EAAW,IAAK,IAAK,CAAC,EACtBA,EAAW,EAAG,EAAG,GAAG,EACpBA,EAAW,IAAK,EAAG,GAAG,EACtBA,EAAW,EAAG,IAAK,GAAG,EACtBA,EAAW,IAAK,IAAK,GAAG,EACxBA,EAAW,IAAK,IAAK,GAAG,EACxBA,EAAW,IAAK,EAAG,CAAC,EACpBA,EAAW,EAAG,IAAK,CAAC,EACpBA,EAAW,IAAK,IAAK,CAAC,EACtBA,EAAW,GAAI,GAAI,GAAG,EACtBA,EAAW,IAAK,EAAG,GAAG,EACtBA,EAAW,EAAG,IAAK,GAAG,EACtBA,EAAW,IAAK,IAAK,GAAG,GAGpBe,EAAI,CAAC,EAAG,GAAI,IAAK,IAAK,IAAK,GAAG,EACpC,QAASP,EAAI,EAAGA,EAAI,EAAG,EAAEA,EACvB,QAASP,EAAI,EAAGA,EAAI,EAAG,EAAEA,EACvB,QAASC,EAAI,EAAGA,EAAI,EAAG,EAAEA,EACvB6B,EAAE,KAAK/B,EAAWe,EAAEP,CAAC,EAAGO,EAAEd,CAAC,EAAGc,EAAEb,CAAC,CAAC,CAAC,EAKzC,QAAS0B,EAAI,EAAGA,GAAK,IAAKA,GAAK,GAC7BG,EAAE,KAAK/B,EAAW4B,EAAGA,EAAGA,CAAC,CAAC,EAE5B,OAAO,IAAI,YAAYG,CAAC,CAC1B,GAAE,EASWrC,EAAA,mBAA+BM,EAAW,EAAG,EAAG,EAAG,GAAG,EACtDN,EAAA,mBAA+BM,EAAW,IAAK,IAAK,IAAK,GAAG,IChRzE,IAAAgC,GAAAC,EAAAC,IAAA,cAKA,OAAO,eAAeA,GAAS,aAAc,CAAE,MAAO,EAAK,CAAC,EAC5DA,GAAQ,OAASC,GACjB,IAAIC,EAAKC,GAAM,CACX,GAAI,WAAW,WACX,OAAO,WAAW,WAAWA,CAAC,EAClC,GAAI,OAAO,OAAW,IAClB,OAAO,OAAO,KAAKA,EAAG,QAAQ,EAClC,IAAMC,EAAI,KAAKD,CAAC,EACVE,EAAI,IAAI,WAAWD,EAAE,MAAM,EACjC,QAAS,EAAI,EAAG,EAAIC,EAAE,OAAQ,EAAE,EAC5BA,EAAE,CAAC,EAAID,EAAE,WAAW,CAAC,EACzB,OAAOC,CACX,EACA,SAASJ,GAAOK,EAAK,CACjB,GAAIA,EAAI,EAAG,CACP,GAAM,CAAE,EAAAC,EAAG,EAAAJ,EAAG,EAAAK,CAAE,EAAIF,EAChBF,EACAK,EACEC,EAAI,YACV,OAAIH,IAAM,EACFJ,EACQQ,GAAM,IAAID,EAAE,SAASD,IAAMA,EAAI,IAAIC,EAAE,OAAON,IAAMA,EAAIF,EAAEM,CAAC,EAAE,GAAIG,CAAC,EACpEA,GAAMF,EACRC,EAAE,YAAYD,EAAGE,CAAC,EAClBD,EAAE,YAAYN,IAAMA,EAAIF,EAAEM,CAAC,GAAIG,CAAC,EAAE,KAAKN,IAAMI,EAAIJ,EAAE,SAAWA,EAAE,QAAQ,EAE9EE,IAAM,EACFJ,EACO,IAAMM,IAAMA,EAAI,IAAIC,EAAE,OAAON,IAAMA,EAAIF,EAAEM,CAAC,EAAE,GAChD,IAAMC,EACP,QAAQ,QAAQA,CAAC,EACjBC,EAAE,QAAQN,IAAMA,EAAIF,EAAEM,CAAC,EAAE,EAAE,KAAKH,GAAKI,EAAIJ,CAAC,EAEhDF,EACO,IAAMC,IAAMA,EAAIF,EAAEM,CAAC,GACvB,IAAM,QAAQ,QAAQJ,IAAMA,EAAIF,EAAEM,CAAC,EAAE,CAChD,CACA,GAAI,OAAO,SAAa,IACpB,MAAM,IAAI,MAAM,mBAAmB,EACvC,SAAS,IAAIF,CAAG,CACpB,IC7CA,IAAAM,GAAAC,EAAAC,IAAA,cACA,OAAO,eAAeA,GAAS,aAAc,CAAE,MAAO,EAAK,CAAC,EAK5D,IAAMC,GAAmB,KAInBC,MAAiBD,GAAiB,QAAuD,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,0lCAA0lC,CAA8C,EAuJ5uCE,EAAM,IAAI,WAAW,mEACtB,MAAM,EAAE,EACR,IAAIC,GAAMA,EAAG,WAAW,CAAC,CAAC,CAAC,EAE1BC,EAAI,IAAI,YAAY,IAAI,EAC9BA,EAAE,KAAK,UAAU,EACjB,QAASC,EAAI,EAAGA,EAAIH,EAAI,OAAQ,EAAEG,EAC9BD,EAAEF,EAAIG,CAAC,CAAC,EAAIA,GAAK,EACrB,QAASA,EAAI,EAAGA,EAAIH,EAAI,OAAQ,EAAEG,EAC9BD,EAAE,IAAMF,EAAIG,CAAC,CAAC,EAAIA,GAAK,GAAMA,GAAK,EAAK,MAAS,EACpD,QAASA,EAAI,EAAGA,EAAIH,EAAI,OAAQ,EAAEG,EAC9BD,EAAE,IAAMF,EAAIG,CAAC,CAAC,EAAKA,GAAK,GAAM,GAAMA,GAAK,EAAK,MAAS,GAC3D,QAASA,EAAI,EAAGA,EAAIH,EAAI,OAAQ,EAAEG,EAC9BD,EAAE,IAAMF,EAAIG,CAAC,CAAC,EAAIA,GAAK,GAC3B,IAAMC,GAAQ,IAAI,WAAW,CAAC,EAWxBC,GAAN,KAAoB,CAMhB,YAAYC,EAAUC,EAAUC,EAAc,CAO1C,GANA,KAAK,MAAQ,KACb,KAAK,OAAS,GACd,KAAK,OAAS,EACd,KAAK,SAAWF,GAAsD,QACtE,KAAK,SAAWC,GAAsD,WACtE,KAAK,OAASC,GAAkE,MAC5E,KAAK,OAAS,KAAK,UAAY,KAAK,SAAW,WAC/C,MAAM,IAAI,MAAM,uBAAuB,CAE/C,CAKA,IAAI,OAAQ,CACR,OAAO,KAAK,MAAQ,KAAK,GAAG,SAAS,EAAG,KAAK,KAAK,IAAuB,CAAC,EAAIJ,EAClF,CAMA,SAAU,CACD,KAAK,QAEN,KAAK,OAAS,KAAK,SACnB,KAAK,MAAQ,KAAK,KAAO,KAAK,GAAK,KAAK,KAAO,MAG/C,KAAK,KAAK,IAAuB,EAAI,EACrC,KAAK,KAAK,IAAuB,EAAI,EACrC,KAAK,KAAK,IAAuB,EAAI,GAE7C,CASA,KAAKG,EAAUC,EAAc,CAGzB,GAFA,KAAK,SAAWD,GAAsD,KAAK,SAC3E,KAAK,OAASC,GAAkE,KAAK,IAAI,KAAK,OAAQ,KAAK,QAAQ,EAC/G,KAAK,OAAS,KAAK,UAAY,KAAK,SAAW,WAC/C,MAAM,MAAM,uBAAuB,EAEvC,IAAIC,EAAI,KAAK,KACPC,EAAQ,KAAK,OAAS,KACvB,KAAK,MAOD,KAAK,KAAK,OAAO,WAAaA,IACnC,KAAK,KAAK,KAAK,KAAK,MAAMA,EAAQ,KAAK,KAAK,OAAO,YAAc,KAAK,CAAC,EACvED,EAAI,IAAI,YAAY,KAAK,KAAK,OAAQ,CAAC,EACvC,KAAK,GAAK,IAAI,WAAW,KAAK,KAAK,OAAQ,IAA6B,IATxE,KAAK,KAAO,IAAI,YAAY,OAAO,CAAE,QAAS,KAAK,KAAKC,EAAQ,KAAK,CAAE,CAAC,EACxE,KAAK,MAAQX,GAAW,CAAE,IAAK,CAAE,OAAQ,KAAK,IAAK,CAAE,CAAC,EACtDU,EAAI,IAAI,YAAY,KAAK,KAAK,OAAQ,CAAC,EACvCA,EAAE,IAAIP,EAAG,GAAgB,EACzB,KAAK,GAAK,IAAI,WAAW,KAAK,KAAK,OAAQ,IAA6B,GAO5EO,EAAE,IAAuB,EAAI,EAC7BA,EAAE,IAAuB,EAAI,EAC7BA,EAAE,IAAuB,EAAI,EAC7B,KAAK,KAAOA,EACZ,KAAK,OAAS,EAClB,CAOA,SAASE,EAAW,CAChB,IAAMC,EAAS,KAAK,KAAK,IAAuB,EAAID,EACpD,GAAI,KAAK,OAASC,EAAQ,CACtB,GAAIA,EAAS,KAAK,SACd,MAAO,GAEX,IAAIC,EAAU,KAAK,OACnB,MAAQA,GAAW,GAAKD,GAAQ,CAEhC,GADAC,EAAU,KAAK,IAAIA,EAAS,KAAK,QAAQ,EACrCA,EAAUD,EACV,MAAO,GAEX,GAAIC,EAAU,KAAgC,KAAK,KAAK,OAAO,WAAY,CACvE,IAAMC,EAAW,KAAK,MAAMD,EAAU,KAAgC,KAAK,KAAK,OAAO,YAAc,KAAK,EAC1G,KAAK,KAAK,KAAKC,CAAQ,EACvB,KAAK,KAAO,IAAI,YAAY,KAAK,KAAK,OAAQ,CAAC,EAC/C,KAAK,GAAK,IAAI,WAAW,KAAK,KAAK,OAAQ,IAA6B,CAC5E,CACA,KAAK,OAASD,CAClB,CACA,MAAO,EACX,CAOA,IAAIE,EAAM,CACN,GAAI,CAAC,KAAK,OAAS,KAAK,OACpB,MAAO,GAEX,GAAI,KAAK,SAASA,EAAK,MAAM,EACzB,MAAO,GAEX,IAAMN,EAAI,KAAK,KACf,YAAK,GAAG,IAAIM,EAAMN,EAAE,IAAuB,CAAC,EAC5CA,EAAE,IAAuB,GAAKM,EAAK,OAE5BN,EAAE,IAAuB,EAAIA,EAAE,IAAuB,GAAK,OAC5D,KAAK,MAAM,QAAQ,IAAI,EACvB,CACV,CAKA,KAAM,CACF,YAAK,OAAS,GACP,KAAK,MACN,KAAK,MAAM,QAAQ,IAAI,EACvB,EACV,CAIA,IAAI,aAAc,CACd,OAAO,KAAK,MACN,KAAK,KAAK,IAAuB,EACjC,CACV,CAIA,IAAI,WAAY,CACZ,OAAO,KAAK,MACN,KAAK,SAAW,KAAK,KAAK,IAAuB,EACjD,CACV,CACJ,EACAZ,GAAQ,QAAUQ,KCjVlB,IAAAW,GAAAC,EAAAC,IAAA,cACA,OAAO,eAAeA,GAAS,aAAc,CAAE,MAAO,EAAK,CAAC,EAK5D,IAAMC,GAAmB,KACnBC,MAAoBD,GAAiB,QAA2D,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,84BAA84B,CAAkD,EAC3iCE,GAAN,KAAiB,CACb,YAAYC,EAAU,CAClB,KAAK,SAAWA,EAChB,KAAK,MAAQ,EACb,KAAK,OAAS,CAClB,CACA,OAAOC,EAAG,CACN,KAAK,MAAQA,EAAE,CAAC,GAAK,GAAKA,EAAE,CAAC,GAAK,GAAKA,EAAE,CAAC,GAAK,EAAIA,EAAE,CAAC,EACtD,KAAK,OAASA,EAAE,CAAC,GAAK,GAAKA,EAAE,CAAC,GAAK,GAAKA,EAAE,EAAE,GAAK,EAAIA,EAAE,EAAE,EACzD,IAAMC,EAAS,KAAK,MAAQ,KAAK,OAC3BC,EAAKD,EAAS,EACdE,EAAKH,EAAE,OAwBPI,EAAQ,KAAK,IAAIF,EAAIC,CAAE,GAAK,KAAK,IAAID,EAAIC,CAAE,GAAK,GAAK,KACtD,KAAK,MAID,KAAK,KAAK,OAAO,WAAaC,IACnC,KAAK,KAAK,KAAK,KAAK,MAAMA,EAAQ,KAAK,KAAK,OAAO,YAAc,KAAK,CAAC,EACvE,KAAK,GAAK,OALV,KAAK,KAAO,IAAI,YAAY,OAAO,CAAE,QAAS,KAAK,KAAKA,EAAQ,KAAK,CAAE,CAAC,EACxE,KAAK,MAAQP,GAAc,CAAE,IAAK,CAAE,OAAQ,KAAK,IAAK,CAAE,CAAC,GAMxD,KAAK,KACN,KAAK,GAAK,IAAI,WAAW,KAAK,KAAK,MAAM,GAG7C,IAAMQ,EAAU,KAAK,KAAK,OAAO,WAAaF,EAAM,KACpD,YAAK,GAAG,IAAIH,EAAGK,CAAM,EACrB,KAAK,MAAM,QAAQ,IAAIA,EAAQF,EAAIF,CAAM,EAClC,KAAK,GAAG,SAAS,KAAqB,KAAsBC,CAAE,CACzE,CACA,SAAU,CACD,KAAK,OAEN,KAAK,KAAK,OAAO,WAAa,KAAK,WACnC,KAAK,MAAQ,KAAK,GAAK,KAAK,KAAO,KAE3C,CACJ,EACAP,GAAQ,QAAUG,mGCpELQ,GAAA,OAAS,CACpB,WAAY,MACZ,aAAc,KACd,UAAW,MACX,MAAO,wtdCCT,IAAAC,EAAA,IACAC,EAAA,KAIA,SAASC,GAAaC,EAAS,CAC7B,GAAI,OAAO,OAAW,IACpB,OAAO,OAAO,KAAKA,EAAG,QAAQ,EAEhC,IAAMC,EAAa,KAAKD,CAAC,EACnBE,EAAS,IAAI,WAAWD,EAAW,MAAM,EAC/C,QAAS,EAAI,EAAG,EAAIC,EAAO,OAAQ,EAAE,EACnCA,EAAO,CAAC,EAAID,EAAW,WAAW,CAAC,EAErC,OAAOC,CACT,CAEA,IAAMC,GAAaJ,GAAaD,EAAA,OAAO,KAAK,EACxCM,EAGEC,GAAc,IAAI,YAIlBC,GAAN,KAAmB,CAAnB,aAAA,CACS,KAAA,YAAeC,GAAkB,EACjC,KAAA,YAAeC,GAAoB,CAO5C,CANS,YAAYD,EAAa,CAC9B,OAAO,KAAK,YAAYA,CAAK,CAC/B,CACO,YAAYC,EAAY,CAC7B,OAAO,KAAK,YAAYA,CAAI,CAC9B,GAKIC,GAA2C,CAC/C,YAAa,KAAO,MACpB,WAAYZ,EAAA,mBACZ,UAAWA,EAAA,mBACX,QAASA,EAAA,oBACT,aAAcC,EAAA,OAAO,aACrB,SAAU,IAQZ,SAAgBY,GAAaC,EAAsB,CACjD,IAAMC,EAAU,IAAIN,GACdO,EAAY,CAChB,IAAK,CACH,YAAaD,EAAQ,YAAY,KAAKA,CAAO,EAC7C,YAAaA,EAAQ,YAAY,KAAKA,CAAO,IAGjD,OAAO,YAAY,YAAYR,GAAeD,GAAYU,CAAS,EAChE,KAAMC,IACLV,EAAcA,GAAeU,EAAK,OAC3B,IAAIC,EAAQJ,EAAMG,EAAK,UAAYA,EAAMF,CAAO,EACxD,CACL,CAbAI,EAAA,aAAAN,GAgDA,IAAaK,EAAb,KAAoB,CAwGlB,YACEJ,EACAM,EACAC,EAAwB,CAGxB,GAvGM,KAAA,cAAgBpB,EAAA,OAAO,UAAY,EAEnC,KAAA,QAAuBO,GACvB,KAAA,YAAwB,CAAA,EACxB,KAAA,UAAY,EACZ,KAAA,UAAYP,EAAA,OAAO,UACnB,KAAA,YAAc,EACd,KAAA,eAAiB,EA+FvB,KAAK,MAAQ,OAAO,OAAO,CAAA,EAAIW,GAAiBE,CAAI,EAChD,KAAK,MAAM,aAAeb,EAAA,OAAO,aACnC,MAAM,IAAI,MAAM,+CAA+CA,EAAA,OAAO,YAAY,EAAE,EAEtF,GAAKmB,EASHC,EAAU,YAAc,KAAK,aAAa,KAAK,IAAI,EACnDA,EAAU,YAAc,KAAK,YAAY,KAAK,IAAI,MAVpC,CACd,IAAMC,EAASf,IAAgBA,EAAc,IAAI,YAAY,OAAOD,EAAU,GAC9Ec,EAAY,IAAI,YAAY,SAASE,EAAQ,CAC3C,IAAK,CACH,YAAa,KAAK,aAAa,KAAK,IAAI,EACxC,YAAa,KAAK,YAAY,KAAK,IAAI,GAE1C,EAKH,KAAK,UAAYF,EACjB,KAAK,MAAQ,KAAK,UAAU,QAC5B,KAAK,OAAS,IAAI,WAAW,KAAK,MAAM,OAAO,OAAQ,KAAK,MAAM,kBAAiB,EAAInB,EAAA,OAAO,UAAU,EACxG,KAAK,QAAU,IAAI,YAAY,KAAK,MAAM,OAAO,OAAQ,KAAK,MAAM,kBAAiB,EAAI,EAAE,EAC3F,KAAK,SAAW,IAAI,YAAY,KAAK,MAAM,OAAO,OAAQ,KAAK,MAAM,oBAAmB,EAAIA,EAAA,OAAO,YAAY,EAC/G,KAAK,SAAS,IAAI,KAAK,MAAM,OAAO,EACpC,KAAK,MAAQ,IAAI,YAAY,KAAK,MAAM,OAAO,OAAQ,KAAK,MAAM,eAAc,CAAE,EAClF,KAAK,MAAM,KAAKD,EAAA,mBAAoB,EAAG,KAAK,MAAM,aAAc,CAAC,CACnE,CApHA,IAAY,YAAU,CAAe,OAAO,KAAK,QAAQ,CAAC,CAAG,CAC7D,IAAY,WAAS,CAAa,OAAO,KAAK,QAAQ,CAAC,CAAG,CAC1D,IAAY,cAAY,CAAa,OAAO,KAAK,QAAQ,CAAC,CAAG,CAC7D,IAAY,eAAa,CAAa,OAAO,KAAK,QAAQ,CAAC,CAAG,CAC9D,IAAY,QAAM,CAAa,OAAO,KAAK,QAAQ,CAAC,EAAI,KAAK,QAAQ,CAAC,EAAI,EAAI,CAAG,CACjF,IAAY,SAAO,CAAa,OAAO,KAAK,QAAQ,CAAC,CAAG,CACxD,IAAY,QAAM,CAAa,OAAO,KAAK,QAAQ,CAAC,CAAG,CACvD,IAAY,OAAK,CAAgB,OAAO,KAAK,QAAQ,EAAE,CAAG,CAC1D,IAAY,eAAa,CAAa,OAAO,KAAK,QAAQ,EAAE,CAAG,CAEvD,YAAYW,EAAe,CACjC,GAAIA,IAAI,EAAmB,CACzB,IAAMY,EAAS,KAAK,MAAQ,KAAK,OACjC,GAAIA,EAAS,KAAK,QAAQ,OAAQ,CAChC,GAAI,KAAK,MAAM,aAAeA,EAAS,EAAI,KAAK,MAAM,YACpD,WAAK,QAAO,EACN,IAAI,MAAM,4BAA4B,EAE9C,KAAK,QAAU,IAAI,YAAYA,CAAM,EAEvC,KAAK,UAAY,KAAK,eACbZ,IAAI,EACb,GAAI,KAAK,SAAW,EAAG,CAErB,IAAMY,EAAS,KAAK,IAAI,KAAK,aAActB,EAAA,OAAO,SAAS,EAAI,KAAK,cACpE,GAAIsB,EAAS,KAAK,QAAQ,OAAQ,CAChC,GAAI,KAAK,MAAM,aAAeA,EAAS,EAAI,KAAK,MAAM,YACpD,WAAK,QAAO,EACN,IAAI,MAAM,4BAA4B,EAE9C,KAAK,QAAU,IAAI,YAAYA,CAAM,QAInC,KAAK,QAAQ,OAAS,QACxB,KAAK,QAAU,IAAI,YAAY,KAAK,GAI1C,MAAO,EACT,CAEQ,SAASC,EAAgBC,EAAwB,CACvD,IAAMF,EAASC,EAASC,EACxB,GAAIF,EAAS,KAAK,QAAQ,OAAQ,CAChC,GAAI,KAAK,MAAM,aAAeA,EAAS,EAAI,KAAK,MAAM,YACpD,WAAK,QAAO,EACN,IAAI,MAAM,4BAA4B,EAG9C,IAAMG,EAAY,IAAI,YAAY,KAAK,KAAKH,EAAS,KAAK,EAAI,KAAK,EACnEG,EAAU,IAAI,KAAK,OAAO,EAC1B,KAAK,QAAUA,EAEnB,CAEQ,aAAahB,EAAa,CAChC,IAAMiB,EAAM,KAAK,cACbH,EAAS,KAAK,YAClB,GAAI,KAAK,QAAK,EAAmB,CAC/B,IAAII,EAAY,KAAK,OAAS,KAAK,eAC/BC,EAAI,EACR,KAAOA,EAAI,GAAKD,EAAY,GAC1B,KAAK,QAAQ,IAAI,KAAK,MAAM,SAASD,EAAME,EAAGF,EAAME,EAAInB,CAAK,EAAGc,EAASd,EAAQmB,CAAC,EAClFA,IACAD,IAEF,KAAK,aAAelB,EAAQmB,EAC5B,KAAK,gBAAkBA,UACd,KAAK,QAAK,EAAmB,CACtC,KAAK,SAASL,EAAQd,EAAQ,CAAC,EAC/B,KAAK,UAAY,KAAK,IAAI,KAAK,UAAWA,CAAK,EAC/C,KAAK,UAAY,KAAK,IAAI,KAAK,UAAWA,CAAK,EAC/C,QAASoB,EAAI,EAAGA,EAAI,EAAG,EAAEA,EACvB,KAAK,QAAQ,IAAI,KAAK,MAAM,SAASH,EAAMG,EAAGH,EAAMG,EAAIpB,CAAK,EAAGc,EAASd,EAAQoB,CAAC,EAEpF,KAAK,YAAY,KAAKpB,CAAK,EAC3B,KAAK,aAAeA,EAAQ,EAC5B,KAAK,gBAAkB,EAEzB,MAAO,EACT,CA0CA,IAAW,OAAK,CACd,OAAO,KAAK,QAAK,EACb,KAAK,OACL,KAAK,IAAI,KAAK,UAAW,KAAK,MAAM,cAAa,CAAE,CACzD,CAOA,IAAW,QAAM,CACf,OAAO,KAAK,QAAK,EACb,KAAK,QACL,KAAK,MAAM,cAAa,EACtB,KAAK,YAAY,OAAS,EAAI,KAAK,MAAM,eAAc,EACvD,KAAK,YAAY,OAAS,CAClC,CAKA,IAAW,SAAO,CAChB,OAAO,KAAK,SAAS,SAAS,EAAG,KAAK,aAAa,CACrD,CAWA,IAAW,aAAW,CACpB,OAAO,KAAK,QAAQ,WAAa,KAAK,MAAM,OAAO,OAAO,WAAa,EAAI,KAAK,YAAY,MAC9F,CAKA,IAAW,YAAU,CACnB,MAAO,CACL,MAAO,KAAK,MACZ,OAAQ,KAAK,OACb,KAAM,KAAK,MACX,MAAO,KAAK,OACZ,SAAU,CAAC,CAAC,KAAK,UACjB,aAAc,KAAK,cACnB,UAAW,KAAK,WAChB,SAAU,KAAK,YACf,iBAAkB,CAChB,UAAW,KAAK,QAAQ,CAAC,EACzB,YAAa,KAAK,QAAQ,CAAC,EAC3B,MAAO,KAAK,aACZ,OAAQ,KAAK,eAGnB,CAOO,KACLqB,EAAsB,KAAK,MAAM,UACjCC,EAA8B,KAAK,MAAM,QACzCC,EAAuB,KAAK,MAAM,aAClCC,EAAoB,KAAK,MAAM,SAAQ,CAEvC,KAAK,MAAM,KAAK,KAAK,MAAM,WAAYH,EAAWE,EAAcC,EAAW,EAAI,CAAC,EAC5EF,GACF,KAAK,SAAS,IAAIA,EAAQ,SAAS,EAAG/B,EAAA,OAAO,YAAY,CAAC,EAE5D,KAAK,YAAY,OAAS,EAC1B,KAAK,UAAY,EACjB,KAAK,UAAYA,EAAA,OAAO,UACxB,KAAK,YAAc,EACnB,KAAK,eAAiB,CACxB,CAMO,OAAOkC,EAAsBC,EAAgB,EAAGC,EAAcF,EAAK,OAAM,CAC9E,IAAIG,EAAIF,EACR,KAAOE,EAAID,GAAK,CACd,IAAME,EAAS,KAAK,IAAIF,EAAMC,EAAGrC,EAAA,OAAO,UAAU,EAClD,KAAK,OAAO,IAAIkC,EAAK,SAASG,EAAGA,GAAKC,CAAM,CAAC,EAC7C,KAAK,MAAM,OAAO,EAAGA,CAAM,EAE/B,CAOO,aAAaJ,EAAcC,EAAgB,EAAGC,EAAcF,EAAK,OAAM,CAC5E,IAAIG,EAAIF,EACR,KAAOE,EAAID,GAAK,CACd,IAAME,EAAS,KAAK,IAAIF,EAAMC,EAAGrC,EAAA,OAAO,UAAU,EAClD,QAAS6B,EAAI,EAAGU,EAAIF,EAAGR,EAAIS,EAAQ,EAAET,EAAG,EAAEU,EACxC,KAAK,OAAOV,CAAC,EAAIK,EAAK,WAAWK,CAAC,EAEpCF,GAAKC,EACL,KAAK,MAAM,OAAO,EAAGA,CAAM,EAE/B,CAMA,IAAW,QAAM,CACf,GAAI,KAAK,QAAK,GAAqB,CAAC,KAAK,OAAS,CAAC,KAAK,OACtD,OAAO/B,GAIT,IAAMiC,EAAe,KAAK,MAAM,cAAa,EAE7C,GAAI,KAAK,QAAK,EAAmB,CAC/B,IAAIb,EAAY,KAAK,OAAS,KAAK,eACnC,GAAIA,EAAY,EAAG,CACjB,IAAMD,EAAM,KAAK,cACbH,EAAS,KAAK,YACdK,EAAI,EACR,KAAOA,EAAI,GAAKD,EAAY,GAC1B,KAAK,QAAQ,IAAI,KAAK,MAAM,SAASD,EAAME,EAAGF,EAAME,EAAIY,CAAY,EAAGjB,EAASiB,EAAeZ,CAAC,EAChGA,IACAD,IAEEA,GACF,KAAK,QAAQ,KAAK,KAAK,WAAYJ,EAASiB,EAAeZ,CAAC,EAGhE,OAAO,KAAK,QAAQ,SAAS,EAAG,KAAK,MAAQ,KAAK,MAAM,EAG1D,GAAI,KAAK,QAAK,EAAmB,CAC/B,GAAI,KAAK,YAAc,KAAK,UAAW,CACrC,IAAIa,EAAS,GACb,GAAID,EACF,GAAIA,IAAiB,KAAK,UACxBC,EAAS,OACJ,CACL,IAAMf,EAAM,KAAK,cACbH,EAAS,KAAK,YAClB,KAAK,SAASA,EAAQiB,EAAe,CAAC,EACtC,QAASX,EAAI,EAAGA,EAAI,EAAG,EAAEA,EACvB,KAAK,QAAQ,IAAI,KAAK,MAAM,SAASH,EAAMG,EAAGH,EAAMG,EAAIW,CAAY,EAAGjB,EAASiB,EAAeX,CAAC,EAItG,GAAI,CAACY,EACH,OAAO,KAAK,QAAQ,SAAS,EAAG,KAAK,MAAQ,KAAK,MAAM,EAM5D,IAAMC,EAAQ,IAAI,YAAY,KAAK,MAAQ,KAAK,MAAM,EACtDA,EAAM,KAAK,KAAK,UAAU,EAC1B,IAAIC,EAAc,EACdR,EAAQ,EACZ,QAASN,EAAI,EAAGA,EAAI,KAAK,YAAY,OAAQ,EAAEA,EAAG,CAChD,IAAMe,EAAK,KAAK,YAAYf,CAAC,EAC7B,QAASQ,EAAI,EAAGA,EAAI,EAAG,EAAEA,EACvBK,EAAM,IAAI,KAAK,QAAQ,SAASP,EAAOA,GAASS,CAAE,EAAGD,CAAW,EAChEA,GAAe,KAAK,MAIxB,GAAIH,EAAc,CAChB,IAAMd,EAAM,KAAK,cAEXmB,EAAgB,KAAK,MAAM,eAAc,EAC/C,QAAShB,EAAI,EAAGA,EAAIgB,EAAe,EAAEhB,EACnCa,EAAM,IAAI,KAAK,MAAM,SAAShB,EAAMG,EAAGH,EAAMG,EAAIW,CAAY,EAAGG,EAAc,KAAK,MAAQd,CAAC,EAGhG,OAAOa,EAIT,OAAOnC,EACT,CAMA,IAAW,OAAK,CACd,OAAO,IAAI,kBAAkB,KAAK,OAAO,OAAQ,EAAG,KAAK,MAAQ,KAAK,OAAS,CAAC,CAClF,CAcO,SAAO,CACZ,KAAK,QAAUA,GACf,KAAK,YAAY,OAAS,EAC1B,KAAK,UAAY,EACjB,KAAK,UAAYP,EAAA,OAAO,UAGxB,KAAK,MAAM,KAAKD,EAAA,mBAAoB,EAAG,KAAK,MAAM,aAAc,CAAC,CACnE,GAxWFmB,EAAA,QAAAD,EA2XC,SAAgB6B,GACfZ,EACArB,EAAsB,CAEtB,IAAMkC,EAAM,IAAI9B,EAAQJ,CAAI,EAC5B,OAAAkC,EAAI,KAAI,EACR,OAAOb,GAAS,SAAWa,EAAI,aAAab,CAAI,EAAIa,EAAI,OAAOb,CAAI,EAC5D,CACL,MAAOa,EAAI,MACX,OAAQA,EAAI,OACZ,OAAQA,EAAI,OACZ,MAAOA,EAAI,MAEf,CAbC7B,EAAA,OAAA4B,GAoBM,eAAeE,GACpBd,EACArB,EAAsB,CAEtB,IAAMkC,EAAM,MAAMnC,GAAaC,CAAI,EACnC,OAAAkC,EAAI,KAAI,EACR,OAAOb,GAAS,SAAWa,EAAI,aAAab,CAAI,EAAIa,EAAI,OAAOb,CAAI,EAC5D,CACL,MAAOa,EAAI,MACX,OAAQA,EAAI,OACZ,OAAQA,EAAI,OACZ,MAAOA,EAAI,MAEf,CAbA7B,EAAA,YAAA8B,KC7eO,SAASC,EAAaC,EAA6B,CACxD,MAAO,CAAE,QAASA,CAAG,CACvB,CAuBO,IAAMC,EAAN,KAA6C,CAA7C,cACL,KAAiB,aAAe,IAAI,IACpC,KAAQ,YAAc,GAEtB,IAAW,YAAsB,CAC/B,OAAO,KAAK,WACd,CAEO,IAA2BC,EAAS,CACzC,OAAI,KAAK,YACPA,EAAE,QAAQ,EAEV,KAAK,aAAa,IAAIA,CAAC,EAElBA,CACT,CAEO,SAAgB,CACrB,GAAI,MAAK,YAGT,MAAK,YAAc,GACnB,QAAWC,KAAK,KAAK,aACnBA,EAAE,QAAQ,EAEZ,KAAK,aAAa,MAAM,EAC1B,CAEO,OAAc,CACnB,QAAWA,KAAK,KAAK,aACnBA,EAAE,QAAQ,EAEZ,KAAK,aAAa,MAAM,CAC1B,CACF,EAEsBC,EAAf,KAAiD,CAAjD,cAGL,KAAmB,OAAS,IAAIH,EAEzB,SAAgB,CACrB,KAAK,OAAO,QAAQ,CACtB,CAEU,UAAiCC,EAAS,CAClD,OAAO,KAAK,OAAO,IAAIA,CAAC,CAC1B,CACF,EAZsBE,EACG,KAAoB,OAAO,OAAO,CAAE,SAAU,CAAE,CAAE,CAAC,EAarE,IAAMC,EAAN,KAAsE,CAAtE,cAEL,KAAQ,YAAc,GAEtB,IAAW,OAAuB,CAChC,OAAO,KAAK,YAAc,OAAY,KAAK,MAC7C,CAEA,IAAW,MAAMC,EAAsB,CACjC,KAAK,aAAeA,IAAU,KAAK,SAGvC,KAAK,QAAQ,QAAQ,EACrB,KAAK,OAASA,EAChB,CAEO,OAAc,CACnB,KAAK,MAAQ,MACf,CAEO,SAAgB,CACrB,KAAK,YAAc,GACnB,KAAK,QAAQ,QAAQ,EACrB,KAAK,OAAS,MAChB,CACF,EClGO,IAAMC,EAAN,KAAiB,CAAjB,cACL,KAAQ,WAAqD,CAAC,EAC9D,KAAQ,UAAY,GAGpB,IAAW,OAAmB,CAC5B,OAAI,KAAK,OACA,KAAK,QAEd,KAAK,OAAS,CAACC,EAAyBC,EAAgBC,IAAkD,CACxG,GAAI,KAAK,UACP,OAAOC,EAAa,IAAM,CAAC,CAAC,EAG9B,IAAMC,EAAQ,CAAE,GAAIJ,EAAU,SAAAC,CAAS,EACvC,KAAK,WAAa,KAAK,WAAW,MAAM,EACxC,KAAK,WAAW,KAAKG,CAAK,EAE1B,IAAMC,EAASF,EAAa,IAAM,CAChC,IAAMG,EAAM,KAAK,WAAW,QAAQF,CAAK,EACrCE,IAAQ,KACV,KAAK,WAAa,KAAK,WAAW,MAAM,EACxC,KAAK,WAAW,OAAOA,EAAK,CAAC,EAEjC,CAAC,EAED,OAAIJ,IACE,MAAM,QAAQA,CAAW,EAC3BA,EAAY,KAAKG,CAAM,EAEvBH,EAAY,IAAIG,CAAM,GAInBA,CACT,EACO,KAAK,OACd,CAEO,KAAKE,EAAgB,CAC1B,GAAI,KAAK,WAAa,CAAC,KAAK,WAAW,OACrC,OAEF,GAAI,KAAK,WAAW,SAAW,EAAG,CAChC,KAAK,WAAW,CAAC,EAAE,GAAG,KAAK,KAAK,WAAW,CAAC,EAAE,SAAUA,CAAK,EAC7D,MACF,CACA,IAAMC,EAAY,KAAK,WACvB,QAAS,EAAI,EAAGC,EAAMD,EAAU,OAAQ,EAAIC,EAAK,EAAE,EACjDD,EAAU,CAAC,EAAE,GAAG,KAAKA,EAAU,CAAC,EAAE,SAAUD,CAAK,CAErD,CAEO,SAAgB,CACjB,KAAK,YAGT,KAAK,UAAY,GACjB,KAAK,WAAW,OAAS,EAC3B,CACF,EAEiBG,OAAV,CACE,SAASC,EAAWC,EAAiBC,EAA6B,CACvE,OAAOD,EAAKE,GAAKD,EAAG,KAAKC,CAAC,CAAC,CAC7B,CAFOJ,EAAS,QAAAC,EAIT,SAASI,EAAUR,EAAkBQ,EAA6B,CACvE,MAAO,CAACf,EAAyBC,EAAgBC,IACxCK,EAAMS,GAAKhB,EAAS,KAAKC,EAAUc,EAAIC,CAAC,CAAC,EAAG,OAAWd,CAAW,CAE7E,CAJOQ,EAAS,IAAAK,EAQT,SAASE,KAAUC,EAAgC,CACxD,MAAO,CAAClB,EAAyBC,EAAgBC,IAAkD,CACjG,IAAMiB,EAAQ,IAAIC,EAClB,QAAWb,KAASW,EAClBC,EAAM,IAAIZ,EAAMO,GAAKd,EAAS,KAAKC,EAAUa,CAAC,CAAC,CAAC,EAElD,OAAIZ,IACE,MAAM,QAAQA,CAAW,EAC3BA,EAAY,KAAKiB,CAAK,EAEtBjB,EAAY,IAAIiB,CAAK,GAGlBA,CACT,CACF,CAfOT,EAAS,IAAAO,EAmBT,SAASI,EAAmBd,EAAkBe,EAAqCC,EAA0B,CAClH,OAAAD,EAAQC,CAAO,EACRhB,EAAMO,GAAKQ,EAAQR,CAAC,CAAC,CAC9B,CAHOJ,EAAS,gBAAAW,IAhCDX,KAAA,ICvEjB,IAAAc,GAA2B,OAgBpB,IAAMC,EAAN,MAAMC,UAAsBC,CAAkC,CAmDnE,YAAoBC,EAAyB,CAC3C,MAAM,EADY,eAAAA,EAhDpB,KAAQ,QAAU,IAAI,IAGtB,KAAQ,gBAAkB,KAAK,UAAU,IAAIC,CAAmB,EA+C9D,KAAK,SAAW,KAAK,UAAU,MAAM,KACrC,KAAK,UAAU,MAAM,KAAQC,GAA8B,CACzD,KAAK,UAAU,KAAK,KAAK,UAAU,MAAOA,CAAM,EAChD,KAAK,MAAM,CACb,EACI,KAAK,UAAU,MAAM,eACvB,KAAK,MAAM,EAGb,KAAK,gBAAgB,MAAQ,KAAK,UAAU,MAAM,eAAe,eAAeC,GAAU,CACpFA,IAAW,aACb,KAAK,cAAc,EACnB,KAAK,gBAAgB,YAAY,EAAG,KAAK,UAAU,IAAI,EAE3D,CAAC,EACD,KAAK,UAAUC,EAAa,IAAM,CAChC,KAAK,mBAAmB,EACxB,KAAK,mBAAmB,QAAQ,EAC5B,KAAK,UAAU,OAAS,KAAK,WAC/B,KAAK,UAAU,MAAM,KAAO,KAAK,SACjC,KAAK,SAAW,QAEd,KAAK,gBAAkB,KAAK,kBAC9B,KAAK,eAAe,YAAc,KAAK,gBACvC,KAAK,gBAAkB,QAEzB,KAAK,eAAiB,OACtB,KAAK,QAAQ,MAAM,EACnB,KAAK,oBAAoB,MAAM,EAC/B,KAAK,mBAAqB,OAC1B,KAAK,aAAe,MACtB,CAAC,CAAC,CACJ,CAnFA,IAAW,QAAwC,CAAE,OAAO,KAAK,QAAQ,IAAI,KAAK,GAAG,MAAQ,CAU7F,OAAc,aAAaC,EAAqCC,EAAeC,EAAmC,CAUhH,IAAMC,GAAUH,GAAiB,UAAU,cAAc,QAAQ,EACjE,OAAAG,EAAO,MAAQF,EAAQ,EACvBE,EAAO,OAASD,EAAS,EAClBC,CACT,CAGA,OAAc,gBAAgBC,EAA+BH,EAAeC,EAAgBG,EAAiC,CAC3H,GAAI,OAAO,WAAc,WAAY,CACnC,IAAMC,EAAUF,EAAI,gBAAgBH,EAAOC,CAAM,EACjD,OAAIG,GACFC,EAAQ,KAAK,IAAI,IAAI,kBAAkBD,EAAQ,EAAGJ,EAAQC,EAAS,CAAC,CAAC,EAEhEI,CACT,CACA,OAAOD,EACH,IAAI,UAAU,IAAI,kBAAkBA,EAAQ,EAAGJ,EAAQC,EAAS,CAAC,EAAGD,EAAOC,CAAM,EACjF,IAAI,UAAUD,EAAOC,CAAM,CACjC,CAGA,OAAc,kBAAkBK,EAA0D,CACxF,OAAI,OAAO,mBAAsB,WACxB,QAAQ,QAAQ,MAAS,EAE3B,kBAAkBA,CAAG,CAC9B,CA0CO,gBAAgBC,EAAsB,CACvCA,EACE,CAAC,KAAK,cAAgB,KAAK,SAAS,SAAW,IACjD,KAAK,mBAAmB,KAAK,IAAI,KAAK,SAAS,OAAS,EAAG,EAA4B,CAAC,GAG1F,KAAK,oBAAoB,MAAM,EAC/B,KAAK,mBAAqB,OAC1B,KAAK,aAAe,QAEtB,KAAK,gBAAgB,YAAY,EAAG,KAAK,UAAU,IAAI,CACzD,CAMA,IAAW,YAA4C,CACrD,OAAO,KAAK,UAAU,UACxB,CAKA,IAAW,UAAsB,CAC/B,MAAO,CACL,MAAO,KAAK,YAAY,IAAI,KAAK,OAAS,GAC1C,OAAQ,KAAK,YAAY,IAAI,KAAK,QAAU,EAC9C,CACF,CAKO,WAAWC,EAAeC,EAAaC,EAA0B,CACtE,IAAMC,EAAIH,GAAS,KAAK,YAAY,IAAI,KAAK,QAAU,GACjDI,EAAI,KAAK,YAAY,IAAI,OAAO,OAAS,EACzCC,GAAKJ,EAAM,EAAID,IAAU,KAAK,YAAY,IAAI,KAAK,QAAU,IAC/D,CAACE,GAASA,IAAU,QACtB,KAAK,QAAQ,IAAI,KAAK,GAAG,UAAU,EAAGC,EAAGC,EAAGC,CAAC,GAE3C,CAACH,GAASA,IAAU,WACtB,KAAK,QAAQ,IAAI,QAAQ,GAAG,UAAU,EAAGC,EAAGC,EAAGC,CAAC,CAEpD,CAKO,SAASH,EAA0B,CACxC,GAAI,CAACA,GAASA,IAAU,MAAO,CAC7B,IAAMP,EAAM,KAAK,QAAQ,IAAI,KAAK,EAClCA,GAAK,UAAU,EAAG,EAAGA,EAAI,OAAO,MAAOA,EAAI,OAAO,MAAM,CAC1D,CACA,GAAI,CAACO,GAASA,IAAU,SAAU,CAChC,IAAMP,EAAM,KAAK,QAAQ,IAAI,QAAQ,EACrCA,GAAK,UAAU,EAAG,EAAGA,EAAI,OAAO,MAAOA,EAAI,OAAO,MAAM,CAC1D,CACF,CAKO,KAAKW,EAAqBC,EAAgBC,EAAaC,EAAaC,EAAgB,EAAS,CAClG,IAAMf,EAAM,KAAK,QAAQ,IAAIW,EAAQ,KAAK,EAC1C,GAAI,CAACX,EACH,OAEF,GAAM,CAAE,MAAAH,EAAO,OAAAC,CAAO,EAAI,KAAK,SAG/B,GAAID,IAAU,IAAMC,IAAW,GAC7B,OAGF,KAAK,cAAca,EAASd,EAAOC,CAAM,EACzC,IAAMK,EAAMQ,EAAQ,OACdK,EAAO,KAAK,KAAKb,EAAI,MAAQN,CAAK,EAElCoB,EAAML,EAASI,EAAQnB,EACvBqB,EAAK,KAAK,MAAMN,EAASI,CAAI,EAAIlB,EACjCqB,EAAKN,EAAMhB,EACXuB,EAAKN,EAAMhB,EAGXuB,EAAaN,EAAQlB,EAAQoB,EAAKd,EAAI,MAAQA,EAAI,MAAQc,EAAKF,EAAQlB,EACvEyB,EAAcJ,EAAKpB,EAASK,EAAI,OAASA,EAAI,OAASe,EAAKpB,EAMjEE,EAAI,UACFG,EACA,KAAK,MAAMc,CAAE,EAAG,KAAK,MAAMC,CAAE,EAAG,KAAK,KAAKG,CAAU,EAAG,KAAK,KAAKC,CAAW,EAC5E,KAAK,MAAMH,CAAE,EAAG,KAAK,MAAMC,CAAE,EAAG,KAAK,KAAKC,CAAU,EAAG,KAAK,KAAKC,CAAW,CAC9E,CACF,CAKO,YAAYX,EAAqBC,EAA+C,CACrF,GAAM,CAAE,MAAAf,EAAO,OAAAC,CAAO,EAAI,KAAK,SAE/B,GAAID,IAAU,IAAMC,IAAW,GAC7B,OAEF,KAAK,cAAca,EAASd,EAAOC,CAAM,EACzC,IAAMK,EAAMQ,EAAQ,OACdK,EAAO,KAAK,KAAKb,EAAI,MAAQN,CAAK,EAClCoB,EAAML,EAASI,EAAQnB,EACvBqB,EAAK,KAAK,MAAMN,EAASI,CAAI,EAAIlB,EACjCuB,EAAaxB,EAAQoB,EAAKd,EAAI,MAAQA,EAAI,MAAQc,EAAKpB,EACvDyB,EAAcJ,EAAKpB,EAASK,EAAI,OAASA,EAAI,OAASe,EAAKpB,EAE3DC,EAASV,EAAc,aAAa,KAAK,SAAUgC,EAAYC,CAAW,EAC1EtB,EAAMD,EAAO,WAAW,IAAI,EAClC,GAAIC,EACF,OAAAA,EAAI,UACFG,EACA,KAAK,MAAMc,CAAE,EAAG,KAAK,MAAMC,CAAE,EAAG,KAAK,MAAMG,CAAU,EAAG,KAAK,MAAMC,CAAW,EAC9E,EAAG,EAAG,KAAK,MAAMD,CAAU,EAAG,KAAK,MAAMC,CAAW,CACtD,EACOvB,CAEX,CAKO,gBAAgBc,EAAaC,EAAaC,EAAgB,EAAS,CACxE,IAAMf,EAAM,KAAK,QAAQ,IAAI,KAAK,EAClC,GAAIA,EAAK,CACP,GAAM,CAAE,MAAAH,EAAO,OAAAC,CAAO,EAAI,KAAK,SAY/B,GATID,IAAU,IAAMC,IAAW,KAI1B,KAAK,aAECA,GAAU,KAAK,aAAc,QACtC,KAAK,mBAAmBA,EAAS,CAAC,EAFlC,KAAK,mBAAmB,KAAK,IAAIA,EAAS,EAAG,EAA4B,CAAC,EAIxE,CAAC,KAAK,cAAc,OACxBE,EAAI,UACF,KAAK,oBAAsB,KAAK,aAChCa,EAAMhB,EACLiB,EAAMhB,EAAU,EAAI,EAAI,EACzBD,EAAQkB,EACRjB,EACAe,EAAMhB,EACNiB,EAAMhB,EACND,EAAQkB,EACRjB,CACF,CACF,CACF,CAMO,eAAsB,CAC3B,IAAMW,EAAI,KAAK,YAAY,IAAI,OAAO,OAAS,EACzCC,EAAI,KAAK,YAAY,IAAI,OAAO,QAAU,EAChD,QAAWV,KAAO,KAAK,QAAQ,OAAO,GAChCA,EAAI,OAAO,QAAUS,GAAKT,EAAI,OAAO,SAAWU,KAClDV,EAAI,OAAO,MAAQS,EACnBT,EAAI,OAAO,OAASU,EAG1B,CAKQ,cAAca,EAAkBC,EAAsBC,EAA6B,CACzF,GAAID,IAAiBD,EAAK,eAAe,OAASE,IAAkBF,EAAK,eAAe,OACtF,OAEF,GAAM,CAAE,MAAOG,EAAe,OAAQC,CAAe,EAAIJ,EAAK,aAC9D,GAAIC,IAAiBE,GAAiBD,IAAkBE,EAAgB,CACtEJ,EAAK,OAASA,EAAK,KACnBA,EAAK,eAAe,MAAQG,EAC5BH,EAAK,eAAe,OAASI,EAC7B,MACF,CACA,IAAM5B,EAASV,EAAc,aAC3B,KAAK,SACL,KAAK,KAAKkC,EAAK,KAAM,MAAQC,EAAeE,CAAa,EACzD,KAAK,KAAKH,EAAK,KAAM,OAASE,EAAgBE,CAAc,CAC9D,EACM3B,EAAMD,EAAO,WAAW,IAAI,EAC9BC,IACFA,EAAI,UAAUuB,EAAK,KAAO,EAAG,EAAGxB,EAAO,MAAOA,EAAO,MAAM,EAC3DwB,EAAK,OAASxB,EACdwB,EAAK,eAAe,MAAQC,EAC5BD,EAAK,eAAe,OAASE,EAEjC,CAKQ,OAAc,CACpB,KAAK,eAAiB,KAAK,UAAU,MAAM,eAC3C,KAAK,gBAAkB,KAAK,eAAe,YAAY,KAAK,KAAK,cAAc,EAC/E,KAAK,eAAe,YAAeG,GAAkB,CACnD,QAAWC,IAAO,CAAC,GAAG,KAAK,QAAQ,KAAK,CAAC,EACvC,KAAK,mBAAmBA,CAAG,EAE7B,KAAK,iBAAiB,KAAK,KAAK,eAAgBD,CAAQ,CAC1D,CACF,CAEO,iBAAiBrB,EAAoB,MAAa,CAEvD,GAAI,CAAC,KAAK,UAAY,CAAC,KAAK,UAAU,MAAM,cAAe,CACzD,QAAQ,KAAK,oFAAoF,EACjG,MACF,CACA,GAAI,KAAK,QAAQ,IAAIA,CAAK,EACxB,OAEF,IAAMR,EAASV,EAAc,aAC3B,KAAK,SAAU,KAAK,YAAY,IAAI,OAAO,OAAS,EACpD,KAAK,YAAY,IAAI,OAAO,QAAU,CACxC,EACAU,EAAO,UAAU,IAAI,qBAAqBQ,CAAK,EAAE,EACjD,IAAMuB,EAAgB,KAAK,UAAU,MAAM,cAI3CA,EAAc,MAAM,UAAY,UAC5BvB,IAAU,UAKZR,EAAO,MAAM,OAAS,KACtB+B,EAAc,aAAa/B,EAAQ+B,EAAc,UAAU,IAK3D/B,EAAO,MAAM,OAAS,IACtB+B,EAAc,YAAY/B,CAAM,GAElC,IAAMC,EAAMD,EAAO,WAAW,KAAM,CAAE,MAAO,EAAK,CAAC,EACnD,GAAI,CAACC,EAAK,CACRD,EAAO,OAAO,EACd,MACF,CACA,KAAK,QAAQ,IAAIQ,EAAOP,CAAG,EAC3B,KAAK,SAASO,CAAK,CACrB,CAEO,mBAAmBA,EAAoB,MAAa,CACzD,IAAMP,EAAM,KAAK,QAAQ,IAAIO,CAAK,EAC9BP,IACFA,EAAI,OAAO,OAAO,EAClB,KAAK,QAAQ,OAAOO,CAAK,EAE7B,CAEO,SAASA,EAA4B,CAC1C,OAAO,KAAK,QAAQ,IAAIA,CAAK,CAC/B,CAEQ,mBAAmBT,EAAiB,GAAoC,CAC9E,KAAK,oBAAoB,MAAM,EAC/B,KAAK,mBAAqB,OAG1B,IAAMiC,EAAS,GACTC,EAAY3C,EAAc,aAAa,KAAK,SAAU0C,EAAQjC,CAAM,EACpEE,EAAMgC,EAAU,WAAW,KAAM,CAAE,MAAO,EAAM,CAAC,EACvD,GAAI,CAAChC,EAAK,OACV,IAAME,EAAUb,EAAc,gBAAgBW,EAAK+B,EAAQjC,CAAM,EAC3DmC,EAAM,IAAI,YAAY/B,EAAQ,KAAK,MAAM,EACzCgC,KAAQ,eAAW,EAAG,EAAG,CAAC,EAC1BC,KAAQ,eAAW,IAAK,IAAK,GAAG,EACtCF,EAAI,KAAKC,CAAK,EACd,QAAS1B,EAAI,EAAGA,EAAIV,EAAQ,EAAEU,EAAG,CAC/B,IAAM4B,EAAQ5B,EAAI,EACZ6B,EAAS7B,EAAIuB,EACnB,QAASO,EAAI,EAAGA,EAAIP,EAAQO,GAAK,EAC/BL,EAAII,EAASC,EAAIF,CAAK,EAAID,CAE9B,CACAnC,EAAI,aAAaE,EAAS,EAAG,CAAC,EAG9B,IAAML,EAAS,OAAO,MAAQkC,EAAS,EAAK,EAAEA,EAAS,IAAM,KAC7D,KAAK,aAAe1C,EAAc,aAAa,KAAK,SAAUQ,EAAOC,CAAM,EAC3E,IAAMyC,EAAO,KAAK,aAAa,WAAW,KAAM,CAAE,MAAO,EAAM,CAAC,EAChE,GAAI,CAACA,EAAM,CACT,KAAK,aAAe,OACpB,MACF,CACA,QAASC,EAAI,EAAGA,EAAI3C,EAAO2C,GAAKT,EAC9BQ,EAAK,UAAUP,EAAWQ,EAAG,CAAC,EAEhCnD,EAAc,kBAAkB,KAAK,YAAY,EAAE,KAAKoD,GAAU,KAAK,mBAAqBA,CAAM,CACpG,CAEA,IAAW,UAAiC,CAC1C,OAAO,KAAK,UAAU,MAAM,qBAAqB,OAAO,QAC1D,CACF,ECxZO,IAAMC,EAA+B,CAC1C,MAAO,EACP,OAAQ,EACV,EAQMC,EAAN,MAAMC,CAAkD,CAqDtD,YACEC,EAAc,EACdC,EAAgB,EACTC,EAAU,GACVC,EAAS,GAChB,CAFO,aAAAD,EACA,YAAAC,EAxDT,KAAQ,KAAe,EA4CvB,KAAQ,OAAiB,EAcvB,KAAK,KAAOH,EACZ,KAAK,OAASC,CAChB,CA3DA,IAAW,KAAc,CACvB,OAAI,KAAK,OAEJ,KAAK,KAAO,WACZ,KAAK,gBAAkB,GAGrB,KAAK,IACd,CACA,IAAW,IAAIG,EAAe,CAAE,KAAK,KAAOA,CAAO,CAEnD,IAAW,gBAAiC,CAE1C,OAAI,KAAK,UAGD,KAAK,KAAO,YAA6B,EACnD,CACA,IAAW,eAAeA,EAAuB,CAC/C,KAAK,MAAQ,WACb,KAAK,MAASA,GAAS,GAAM,SAC/B,CAEA,IAAW,gBAAyB,CAClC,OAAO,KAAK,KAAQ,QACtB,CACA,IAAW,eAAeA,EAAe,CACvC,KAAK,MAAQ,UACb,KAAK,MAAQA,EAAS,QACxB,CAEA,IAAW,wBAAiC,CAC1C,IAAMC,GAAO,KAAK,KAAO,aAA4B,GACrD,OAAIA,EAAM,EACDA,EAAM,WAERA,CACT,CACA,IAAW,uBAAuBD,EAAe,CAC/C,KAAK,MAAQ,UACb,KAAK,MAASA,GAAS,GAAM,UAC/B,CAGA,IAAW,OAAgB,CACzB,OAAO,KAAK,MACd,CACA,IAAW,MAAMA,EAAe,CAC9B,KAAK,OAASA,CAChB,CAYO,OAA6B,CASlC,OAAO,IAAIL,EAAmB,KAAK,KAAM,KAAK,OAAQ,KAAK,QAAS,KAAK,MAAM,CACjF,CAEO,SAAmB,CACxB,OAAO,KAAK,iBAAmB,GAAuB,KAAK,SAAW,GAAK,KAAK,UAAY,EAC9F,CACF,EACMO,EAAc,IAAIR,EAUXS,EAAN,KAA0C,CAkB/C,YACUC,EACAC,EACAC,EACR,CAHQ,eAAAF,EACA,eAAAC,EACA,WAAAC,EAnBV,KAAQ,QAAmC,IAAI,IAE/C,KAAQ,QAAU,EAElB,KAAQ,UAAY,EAEpB,KAAQ,cAAgB,GAExB,KAAQ,gBAAkB,GAE1B,KAAQ,YAAsB,KAW5B,GAAI,CACF,KAAK,SAAS,KAAK,MAAM,YAAY,CACvC,OAASC,EAAY,CACfA,aAAa,OACf,QAAQ,MAAMA,EAAE,OAAO,EAEzB,QAAQ,KAAK,0BAA0B,KAAK,SAAS,CAAC,KAAK,CAC7D,CACA,KAAK,iBAAmB,CACtB,KAAM,KAAK,UAAU,KACrB,KAAM,KAAK,UAAU,IACvB,CACF,CAEO,SAAgB,CACrB,KAAK,MAAM,CACb,CAEO,OAAc,CACnB,QAAWC,KAAQ,KAAK,QAAQ,OAAO,EACrCA,EAAK,QAAQ,QAAQ,EAIvB,KAAK,QAAQ,MAAM,EACnB,KAAK,UAAU,SAAS,CAC1B,CAEO,UAAmB,CACxB,OAAO,KAAK,YAAc,EAAI,GAChC,CAEO,SAASR,EAAqB,CACnC,GAAIA,EAAQ,IAAOA,EAAQ,IACzB,MAAM,WAAW,mEAAmE,EAEtF,KAAK,YAAeA,EAAQ,EAAI,MAAa,EAC7C,KAAK,aAAa,CAAC,CACrB,CAEO,UAAmB,CACxB,OAAO,KAAK,iBAAiB,EAAI,EAAI,GACvC,CAEQ,kBAA2B,CACjC,IAAIS,EAAe,EACnB,QAAWD,KAAQ,KAAK,QAAQ,OAAO,EACjCA,EAAK,OACPC,GAAgBD,EAAK,KAAK,MAAQA,EAAK,KAAK,OACxCA,EAAK,QAAUA,EAAK,SAAWA,EAAK,OACtCC,GAAgBD,EAAK,OAAO,MAAQA,EAAK,OAAO,SAItD,OAAOC,CACT,CAEQ,QAAQC,EAAkB,CAChC,IAAMF,EAAO,KAAK,QAAQ,IAAIE,CAAE,EAC3BF,IACL,KAAK,QAAQ,OAAOE,CAAE,EAElB,OAAO,aAAeF,EAAK,gBAAgB,aAC7CA,EAAK,KAAK,MAAM,EAElB,KAAK,iBAAiBE,CAAE,EAC1B,CAKO,eAAsB,CAE3B,IAAMC,EAAO,CAAC,EACd,OAAW,CAACD,EAAIF,CAAI,IAAK,KAAK,QAAQ,QAAQ,EACxCA,EAAK,aAAe,cACtBA,EAAK,QAAQ,QAAQ,EACrBG,EAAK,KAAKD,CAAE,GAGhB,QAAWA,KAAMC,EACf,KAAK,QAAQD,CAAE,EAGjB,KAAK,gBAAkB,GACvB,KAAK,cAAgB,EACvB,CAMO,YAAYA,EAAkB,CACnC,IAAMF,EAAO,KAAK,QAAQ,IAAIE,CAAE,EAC5BF,IACFA,EAAK,QAAQ,QAAQ,EACrB,KAAK,QAAQE,CAAE,EAEnB,CAaO,SAASE,EAAsCC,EAA6B,CAEjF,KAAK,aAAaD,EAAI,MAAQA,EAAI,MAAM,EAGxC,IAAIE,EAAW,KAAK,UAAU,UAC1BA,EAAS,QAAU,IAAMA,EAAS,SAAW,MAC/CA,EAAWrB,GAEb,IAAMsB,EAAO,KAAK,KAAKH,EAAI,MAAQE,EAAS,KAAK,EAC3CE,EAAO,KAAK,KAAKJ,EAAI,OAASE,EAAS,MAAM,EAE7ChB,EAAU,EAAE,KAAK,QAEjBmB,EAAS,KAAK,UAAU,MAAM,OAC9BC,EAAW,KAAK,UAAU,KAC1BC,EAAW,KAAK,UAAU,KAC1BC,EAAUH,EAAO,EACjBI,EAAUJ,EAAO,EACnBK,EAASF,EACTG,EAAY,EAEXV,EAAK,YACRI,EAAO,EAAI,EACXA,EAAO,EAAI,EACXK,EAAS,GAGX,KAAK,UAAU,MAAM,cAAc,iBAAiB,UAAUL,EAAO,CAAC,EACtE,QAASO,EAAM,EAAGA,EAAMR,EAAM,EAAEQ,EAAK,CACnC,IAAMC,EAAOR,EAAO,MAAM,IAAIA,EAAO,EAAIA,EAAO,KAAK,EACrD,QAASS,EAAM,EAAGA,EAAMX,GAClB,EAAAO,EAASI,GAAOR,GADQ,EAAEQ,EAE9B,KAAK,aAAaD,EAAwBH,EAASI,EAAK5B,EAAS0B,EAAMT,EAAOW,CAAG,EACjFH,IAEF,GAAIV,EAAK,UACHW,EAAMR,EAAO,GAAG,KAAK,UAAU,MAAM,cAAc,SAAS,UAE5D,EAAEC,EAAO,GAAKE,EAAU,MAE9BF,EAAO,EAAIK,CACb,CACA,KAAK,UAAU,MAAM,cAAc,iBAAiB,UAAUL,EAAO,CAAC,EAGlEJ,EAAK,UACHA,EAAK,YAAc,MACrBI,EAAO,EAAI,KAAK,IAAIK,EAASP,EAAMG,CAAQ,EAE3CD,EAAO,EAAIK,GAGbL,EAAO,EAAIG,EACXH,EAAO,EAAII,GAIb,IAAMV,EAAO,CAAC,EACd,OAAW,CAACD,EAAIF,CAAI,IAAK,KAAK,QAAQ,QAAQ,EACxCA,EAAK,UAAY,IACnBA,EAAK,QAAQ,QAAQ,EACrBG,EAAK,KAAKD,CAAE,GAGhB,QAAWA,KAAMC,EACf,KAAK,QAAQD,CAAE,EAKjB,IAAMiB,EAAY,KAAK,UAAU,eAAe,CAAC,EACjDA,GAAW,UAAU,IAAM,CACZ,KAAK,QAAQ,IAAI7B,CAAO,GAEnC,KAAK,QAAQA,CAAO,CAExB,CAAC,EAIG,KAAK,UAAU,OAAO,OAAO,OAAS,aACxC,KAAK,kBAAkB,EAIzB,IAAM8B,EAAsB,CAC1B,KAAMhB,EACN,aAAcE,EACd,OAAQF,EACR,eAAgB,CAAE,GAAGE,CAAS,EAC9B,OAAQa,GAAa,OACrB,UAAAJ,EACA,WAAY,KAAK,UAAU,OAAO,OAAO,KACzC,MAAOV,EAAK,MACZ,OAAQA,EAAK,MACf,EAGA,YAAK,QAAQ,IAAIf,EAAS8B,CAAO,EACjC,KAAK,eAAe,EACb9B,CACT,CAQO,OAAO+B,EAA6C,CAEzD,IAAIC,EAAe,GACfC,EAAkB,GACtB,QAAWvB,KAAQ,KAAK,QAAQ,OAAO,EAMrC,GALIA,EAAK,QAAU,SACjBuB,EAAkB,GAElBD,EAAe,GAEbA,GAAgBC,EAAiB,MAIvC,GAAID,GAAgB,CAAC,KAAK,UAAU,SAAS,KAAK,IAChD,KAAK,UAAU,iBAAiB,KAAK,EACjC,CAAC,KAAK,UAAU,SAAS,KAAK,GAAG,OAUvC,GARIC,GAAmB,CAAC,KAAK,UAAU,SAAS,QAAQ,GACtD,KAAK,UAAU,iBAAiB,QAAQ,EAI1C,KAAK,UAAU,cAAc,EAGzB,CAAC,KAAK,QAAQ,KAAM,CACjB,KAAK,gBACR,KAAK,UAAU,SAAS,EACxB,KAAK,cAAgB,GACrB,KAAK,gBAAkB,IAErB,KAAK,UAAU,SAAS,KAAK,GAC/B,KAAK,UAAU,mBAAmB,KAAK,EAErC,KAAK,UAAU,SAAS,QAAQ,GAClC,KAAK,UAAU,mBAAmB,QAAQ,EAE5C,MACF,CAGI,CAACD,GAAgB,KAAK,UAAU,SAAS,KAAK,IAChD,KAAK,UAAU,SAAS,KAAK,EAC7B,KAAK,UAAU,mBAAmB,KAAK,GAErC,CAACC,GAAmB,KAAK,UAAU,SAAS,QAAQ,IACtD,KAAK,UAAU,SAAS,QAAQ,EAChC,KAAK,UAAU,mBAAmB,QAAQ,GAIxC,KAAK,kBACP,KAAK,UAAU,SAAS,EACxB,KAAK,cAAgB,GACrB,KAAK,gBAAkB,IAGzB,GAAM,CAAE,MAAAC,EAAO,IAAAC,CAAI,EAAIJ,EACjBZ,EAAS,KAAK,UAAU,MAAM,OAC9BF,EAAO,KAAK,UAAU,MAAM,KAGlC,KAAK,UAAU,WAAWiB,EAAOC,CAAG,EAGpC,IAAMC,EAAgG,CAAC,EACjGC,EAAkE,CAAC,EAGzE,QAASX,EAAMQ,EAAOR,GAAOS,EAAK,EAAET,EAAK,CACvC,IAAMC,EAAOR,EAAO,MAAM,IAAIO,EAAMP,EAAO,KAAK,EAChD,GAAI,CAACQ,EAAM,OACX,QAASC,EAAM,EAAGA,EAAMX,EAAM,EAAEW,EAC9B,GAAID,EAAK,MAAMC,CAAG,EAAI,UAAsB,CAC1C,IAAInB,EAAyBkB,EAAK,eAAeC,CAAG,GAAKxB,EACnDJ,EAAUS,EAAE,QAClB,GAAIT,IAAY,QAAaA,IAAY,GACvC,SAEF,IAAM8B,EAAU,KAAK,QAAQ,IAAI9B,CAAO,EACxC,GAAIS,EAAE,SAAW,GAAI,CACnB,IAAM6B,EAAY7B,EAAE,OACd8B,EAAWX,EACbY,EAAQ,EAOZ,KACE,EAAEZ,EAAMX,GACJU,EAAK,MAAMC,CAAG,EAAI,YAClBnB,EAAIkB,EAAK,eAAeC,CAAG,GAAKxB,IAChCK,EAAE,UAAYT,GACdS,EAAE,SAAW6B,EAAYE,GAE7BA,IAEFZ,IACIE,EACEA,EAAQ,QACVM,EAAU,KAAK,CAAE,QAAAN,EAAS,OAAQQ,EAAW,IAAKC,EAAU,IAAAb,EAAK,MAAAc,CAAM,CAAC,EAEjE,KAAK,MAAM,iBACpBH,EAAiB,KAAK,CAAE,IAAKE,EAAU,IAAAb,EAAK,MAAAc,CAAM,CAAC,EAErD,KAAK,cAAgB,EACvB,CACF,CAEJ,CAGAJ,EAAU,KAAK,CAACK,EAAGC,IAAMD,EAAE,QAAQ,OAASC,EAAE,QAAQ,MAAM,EAG5D,QAAWC,KAAQN,EACjB,KAAK,UAAU,gBAAgBM,EAAK,IAAKA,EAAK,IAAKA,EAAK,KAAK,EAI/D,QAAWA,KAAQP,EACjB,KAAK,UAAU,KAAKO,EAAK,QAASA,EAAK,OAAQA,EAAK,IAAKA,EAAK,IAAKA,EAAK,KAAK,CAEjF,CAEO,eAAeC,EAA+C,CAEnE,GAAI,CAAC,KAAK,QAAQ,KAAM,CACtB,KAAK,iBAAmBA,EACxB,MACF,CAIA,GAAI,KAAK,iBAAiB,MAAQA,EAAQ,KAAM,CAC9C,KAAK,iBAAmBA,EACxB,MACF,CAGA,IAAMzB,EAAS,KAAK,UAAU,MAAM,OAC9BD,EAAOC,EAAO,MAAM,OACpB0B,EAAS,KAAK,iBAAiB,KAAO,EAC5C,QAASnB,EAAM,EAAGA,EAAMR,EAAM,EAAEQ,EAAK,CACnC,IAAMC,EAAOR,EAAO,MAAM,IAAIO,CAAG,EACjC,GAAIC,EAAK,MAAMkB,CAAM,EAAI,UAAsB,CAC7C,IAAMpC,EAAyBkB,EAAK,eAAekB,CAAM,GAAKzC,EACxDJ,EAAUS,EAAE,QAClB,GAAIT,IAAY,QAAaA,IAAY,GACvC,SAEF,IAAM8B,EAAU,KAAK,QAAQ,IAAI9B,CAAO,EACxC,GAAI,CAAC8B,EACH,SAGF,IAAMgB,EAAc,KAAK,MAAMhB,EAAQ,QAAQ,OAAS,GAAKA,EAAQ,eAAe,KAAK,EACzF,GAAKrB,EAAE,OAASqC,EAAe,GAAKA,EAClC,SAGF,IAAIC,EAAU,GACd,QAASC,EAAWH,EAAS,EAAGG,EAAWJ,EAAQ,KAAM,EAAEI,EACzD,GAAIrB,EAAK,MAAMqB,EAAW,EAAY,CAAY,EAAI,QAA0B,CAC9ED,EAAU,GACV,KACF,CAEF,GAAIA,EACF,SAGF,IAAMZ,EAAM,KAAK,IAAIS,EAAQ,KAAME,EAAerC,EAAE,OAASqC,EAAeD,CAAM,EAC9EI,EAAWxC,EAAE,OACjB,QAASyC,EAAYL,EAAS,EAAGK,EAAYf,EAAK,EAAEe,EAClD,KAAK,aAAavB,EAAwBuB,EAAWlD,EAAS,EAAEiD,CAAQ,EACxEnB,EAAQ,WAEZ,CACF,CAEA,KAAK,iBAAmBc,CAC1B,CAKO,qBAAqBO,EAAWC,EAA0C,CAE/E,IAAMzB,EADS,KAAK,UAAU,MAAM,OAChB,MAAM,IAAIyB,CAAC,EAC/B,GAAIzB,GAAQA,EAAK,MAAMwB,CAAC,EAAI,UAAsB,CAChD,IAAM1C,EAAyBkB,EAAK,eAAewB,CAAC,GAAK/C,EACzD,GAAIK,EAAE,SAAWA,EAAE,UAAY,GAAI,CACjC,IAAM4C,EAAO,KAAK,QAAQ,IAAI5C,EAAE,OAAO,GAAG,KAC1C,GAAI,OAAO,aAAe4C,aAAgB,YAAa,CACrD,IAAMC,EAASC,EAAc,aAAa,OAAO,SAAUF,EAAK,MAAOA,EAAK,MAAM,EAClF,OAAAC,EAAO,WAAW,IAAI,GAAG,UAAUD,EAAM,EAAG,EAAGA,EAAK,MAAOA,EAAK,MAAM,EAC/DC,CACT,CACA,OAAOD,CACT,CACF,CACF,CAKO,wBAAwBF,EAAWC,EAA0C,CAElF,IAAMzB,EADS,KAAK,UAAU,MAAM,OAChB,MAAM,IAAIyB,CAAC,EAC/B,GAAIzB,GAAQA,EAAK,MAAMwB,CAAC,EAAI,UAAsB,CAChD,IAAM1C,EAAyBkB,EAAK,eAAewB,CAAC,GAAK/C,EACzD,GAAIK,EAAE,SAAWA,EAAE,UAAY,IAAMA,EAAE,SAAW,GAAI,CACpD,IAAMC,EAAO,KAAK,QAAQ,IAAID,EAAE,OAAO,EACvC,GAAIC,EACF,OAAO,KAAK,UAAU,YAAYA,EAAMD,EAAE,MAAM,CAEpD,CACF,CACF,CAIQ,aAAa+C,EAAsB,CACzC,IAAMC,EAAO,KAAK,iBAAiB,EAC/BC,EAAUD,EACd,KAAO,KAAK,YAAcC,EAAUF,GAAQ,KAAK,QAAQ,MAAM,CAC7D,IAAM9C,EAAO,KAAK,QAAQ,IAAI,EAAE,KAAK,SAAS,EAC1CA,GAAQA,EAAK,OACfgD,GAAWhD,EAAK,KAAK,MAAQA,EAAK,KAAK,OACnCA,EAAK,QAAUA,EAAK,OAASA,EAAK,SACpCgD,GAAWhD,EAAK,OAAO,MAAQA,EAAK,OAAO,QAE7CA,EAAK,QAAQ,QAAQ,EACrB,KAAK,QAAQ,KAAK,SAAS,EAE/B,CACA,OAAO+C,EAAOC,CAChB,CAEQ,aAAa/B,EAAsBwB,EAAWnD,EAAiBC,EAAsB,CAC3F,GAAI0B,EAAK,MAAMwB,EAAI,EAAY,CAAO,EAAI,UAAsB,CAC9D,IAAMQ,EAAMhC,EAAK,eAAewB,CAAC,EACjC,GAAIQ,EAAK,CACP,GAAIA,EAAI,UAAY,OAAW,CAI7B,IAAMC,EAAU,KAAK,QAAQ,IAAID,EAAI,OAAO,EACxCC,GAEFA,EAAQ,YAEVD,EAAI,QAAU3D,EACd2D,EAAI,OAAS1D,EACb,MACF,CAEA0B,EAAK,eAAewB,CAAC,EAAI,IAAIvD,EAAmB+D,EAAI,IAAKA,EAAI,MAAO3D,EAASC,CAAM,EACnF,MACF,CACF,CAEA0B,EAAK,MAAMwB,EAAI,EAAY,CAAO,GAAK,UACvCxB,EAAK,eAAewB,CAAC,EAAI,IAAIvD,EAAmB,EAAG,EAAGI,EAASC,CAAM,CACvE,CAEQ,mBAA0B,CAEhC,QAAWS,KAAQ,KAAK,QAAQ,OAAO,EACjCA,EAAK,aAAe,cACtBA,EAAK,UAAY,GAIrB,IAAMS,EAAS,KAAK,UAAU,MAAM,OACpC,QAASiC,EAAI,EAAGA,EAAI,KAAK,UAAU,KAAM,EAAEA,EAAG,CAC5C,IAAMzB,EAAOR,EAAO,MAAM,IAAIiC,CAAC,EAC/B,GAAKzB,GAGL,QAASwB,EAAI,EAAGA,EAAI,KAAK,UAAU,KAAM,EAAEA,EACzC,GAAIxB,EAAK,MAAMwB,EAAI,EAAY,CAAO,EAAI,UAAsB,CAC9D,IAAMU,EAAQlC,EAAK,eAAewB,CAAC,GAAG,QACtC,GAAIU,EAAO,CACT,IAAMnD,EAAO,KAAK,QAAQ,IAAImD,CAAK,EAC/BnD,GACFA,EAAK,WAET,CACF,EAEJ,CAEA,IAAMG,EAAO,CAAC,EACd,OAAW,CAACD,EAAIF,CAAI,IAAK,KAAK,QAAQ,QAAQ,EACxCA,EAAK,aAAe,aAAe,CAACA,EAAK,YAC3CA,EAAK,QAAQ,QAAQ,EACrBG,EAAK,KAAKD,CAAE,GAGhB,QAAWA,KAAMC,EACf,KAAK,QAAQD,CAAE,CAEnB,CACF,ECnpBA,IAAAkD,GAA0B,QAC1BC,GAAuB,QC2CvB,SAASC,EAAMC,EAA2B,CACxC,IAAIC,EAAI,GACR,QAASC,EAAI,EAAGA,EAAIF,EAAK,OAAQ,EAAEE,EACjCD,GAAK,OAAO,aAAaD,EAAKE,CAAC,CAAC,EAElC,OAAOD,CACT,CAGA,SAASE,GAAMH,EAA2B,CACxC,IAAII,EAAI,EACR,QAASF,EAAI,EAAGA,EAAIF,EAAK,OAAQ,EAAEE,EAAG,CACpC,GAAIF,EAAKE,CAAC,EAAI,IAAMF,EAAKE,CAAC,EAAI,GAC5B,MAAM,IAAI,MAAM,cAAc,EAEhCE,EAAIA,EAAI,GAAKJ,EAAKE,CAAC,EAAI,EACzB,CACA,OAAOE,CACT,CAGA,SAASC,GAAOL,EAA2B,CACzC,IAAMI,EAAIL,EAAMC,CAAI,EACpB,GAAI,CAACI,EAAE,MAAM,kCAAkC,EAC7C,MAAM,IAAI,MAAM,cAAc,EAEhC,OAAOA,CACT,CAGA,SAASE,GAAON,EAA2B,CACzC,GAAI,OAAO,OAAW,IACpB,OAAO,OAAO,KAAKD,EAAMC,CAAI,EAAG,QAAQ,EAAE,SAAS,EAErD,IAAMO,EAAK,KAAKR,EAAMC,CAAI,CAAC,EACrBQ,EAAI,IAAI,WAAWD,EAAG,MAAM,EAClC,QAAS,EAAI,EAAG,EAAIC,EAAE,OAAQ,EAAE,EAC9BA,EAAE,CAAC,EAAID,EAAG,WAAW,CAAC,EAExB,OAAO,IAAI,YAAY,EAAE,OAAOC,CAAC,CACnC,CAEA,IAAMC,GAAiE,CACrE,OAAQN,GACR,KAAMA,GACN,KAAMG,GACN,MAAOD,GACP,OAAQA,GACR,oBAAqBF,EACvB,EAKMO,GAAc,CAAC,GAAI,IAAK,IAAK,GAAG,EAEhCC,GAAuB,CAAC,GAAI,IAAK,IAAK,IAAK,IAAK,IAAK,GAAI,IAAK,IAAK,GAAI,IAAK,IAAK,GAAG,EAEpFC,GAAkB,CAAC,GAAI,IAAK,IAAK,IAAK,GAAI,GAAI,IAAK,GAAG,EAEtDC,GAAiB,CAAC,GAAI,IAAK,IAAK,IAAK,GAAI,IAAK,GAAG,EAEjDC,GAAwB,CAAC,GAAI,IAAK,IAAK,IAAK,IAAK,IAAK,GAAI,IAAK,IAAK,IAAK,GAAI,IAAK,IAAK,GAAG,EAG1FC,GAAiB,KAGVC,EAAN,KAAmB,CAAnB,cACL,KAAO,MAAqB,EAC5B,KAAQ,QAAU,IAAI,YAAYD,EAAc,EAChD,KAAQ,UAAY,EACpB,KAAQ,KAAO,GACf,KAAO,OAA4E,CAAC,EAE7E,OAAc,CACnB,KAAK,QAAQ,KAAK,CAAC,EACnB,KAAK,MAAQ,EACb,KAAK,UAAY,EACjB,KAAK,OAAS,CAAC,EACf,KAAK,KAAO,EACd,CAEO,KAAc,CACnB,GAAI,KAAK,QAAU,EAAmB,CACpC,GAAI,KAAK,YAAcF,GAAe,OAAQ,CAC5C,QAASI,EAAI,EAAGA,EAAIJ,GAAe,OAAQ,EAAEI,EAC3C,GAAI,KAAK,QAAQA,CAAC,IAAMJ,GAAeI,CAAC,EAAG,OAAO,KAAK,GAAG,EAE5D,YAAK,OAAO,KAAU,EACtB,KAAK,MAAQ,EACN,CACT,CACA,GAAI,KAAK,YAAcH,GAAsB,OAAQ,CACnD,QAASG,EAAI,EAAGA,EAAIH,GAAsB,OAAQ,EAAEG,EAClD,GAAI,KAAK,QAAQA,CAAC,IAAMH,GAAsBG,CAAC,EAAG,OAAO,KAAK,GAAG,EAEnE,YAAK,OAAO,KAAU,EACtB,KAAK,MAAQ,EACN,CACT,CACA,OAAO,KAAK,GAAG,CACjB,CACA,OAAI,KAAK,QAAU,EAAwB,EACvC,KAAK,QAAU,GACd,KAAK,OAAO,OAAS,EAEnB,KAAK,YAAY,KAAK,SAAS,GACpC,KAAK,MAAQ,EACN,GAFuC,KAAK,GAAG,EAIjD,KAAK,GAAG,CACjB,CAEO,MAAMjB,EAAmBkB,EAAeC,EAAqB,CAClE,IAAIC,EAAQ,KAAK,MACbC,EAAM,KAAK,UACTC,EAAS,KAAK,QAEpB,GADIF,IAAU,GAAqBA,IAAU,GACzCA,IAAU,GAAqBC,EAAM,GAAI,MAAO,GACpD,QAASnB,EAAIgB,EAAOhB,EAAIiB,EAAK,EAAEjB,EAAG,CAChC,IAAMqB,EAAIvB,EAAKE,CAAC,EAChB,OAAQqB,EAAG,CACT,IAAK,IACH,GAAI,CAAC,KAAK,YAAYF,CAAG,EAAG,OAAO,KAAK,GAAG,EAC3CD,EAAQ,EACRC,EAAM,EACN,MACF,IAAK,IACH,GAAID,IAAU,EAAmB,CAC/B,GAAIE,EAAO,CAAC,IAAM,GAAI,CAEpB,IAAIL,EAAI,EACR,KAAOA,EAAIP,GAAY,OAAQ,EAAEO,EAC/B,GAAIK,EAAOL,CAAC,IAAMP,GAAYO,CAAC,EAAG,OAAO,KAAK,GAAG,EAGnD,GADA,KAAK,OAAO,KAAU,EAClBI,IAAQT,GAAgB,OAAQ,CAClC,KAAOK,EAAIL,GAAgB,OAAQ,EAAEK,EACnC,GAAIK,EAAOL,CAAC,IAAML,GAAgBK,CAAC,EAAG,OAAO,KAAK,GAAG,EAEvD,YAAK,OAAO,KAAU,EACtB,KAAK,MAAQ,EACNf,EAAI,CACb,CACF,SAAWoB,EAAO,CAAC,IAAM,GAAI,CAE3B,QAASL,EAAI,EAAGA,EAAIN,GAAqB,OAAQ,EAAEM,EACjD,GAAIK,EAAOL,CAAC,IAAMN,GAAqBM,CAAC,EAAG,OAAO,KAAK,GAAG,EAE5D,KAAK,OAAO,KAAU,CACxB,KACE,QAAO,KAAK,GAAG,EAEjBG,EAAQ,EACRC,EAAM,CACR,SAAWD,IAAU,EAAiB,CACpC,GAAI,CAAC,KAAK,UAAUC,CAAG,EAAG,OAAO,KAAK,GAAG,EACzCD,EAAQ,EACRC,EAAM,CACR,SAAWD,IAAU,EAAmB,CACtC,GAAIC,GAAON,GAAgB,OAAO,KAAK,GAAG,EAC1CO,EAAOD,GAAK,EAAIE,CAClB,CACA,MACF,IAAK,IACH,OAAIH,IAAU,GACR,CAAC,KAAK,YAAYC,CAAG,EAAU,KAAK,GAAG,GAE7C,KAAK,MAAQ,EACNnB,EAAI,GACb,QACE,GAAImB,GAAON,GAAgB,OAAO,KAAK,GAAG,EAC1CO,EAAOD,GAAK,EAAIE,CACpB,CACF,CACA,YAAK,MAAQH,EACb,KAAK,UAAYC,EACV,EACT,CAEQ,IAAa,CACnB,YAAK,OAAO,KAAO,EACnB,KAAK,MAAQ,EACN,EACT,CAEQ,UAAUA,EAAsB,CACtC,IAAMJ,EAAIlB,EAAM,KAAK,QAAQ,SAAS,EAAGsB,CAAG,CAAC,EAC7C,OAAIJ,GACF,KAAK,KAAOA,EACZ,KAAK,OAAOA,CAAC,EAAI,KACV,IAEF,EACT,CAEQ,YAAYI,EAAsB,CACxC,GAAI,KAAK,KAAM,CACb,GAAI,CACF,IAAMjB,EAAI,KAAK,QAAQ,MAAM,EAAGiB,CAAG,EACnC,KAAK,OAAO,KAAK,IAAI,EAAIZ,GAAS,KAAK,IAAI,EAAIA,GAAS,KAAK,IAAI,EAAEL,CAAC,EAAIA,CAC1E,MAAQ,CACN,MAAO,EACT,CACA,MAAO,EACT,CACA,MAAO,EACT,CACF,ECvPO,IAAMoB,EAA6B,CACxC,KAAM,cACN,MAAO,EACP,OAAQ,CACV,EAEO,SAASC,GAAUC,EAAyB,CACjD,GAAIA,EAAE,OAAS,GACb,OAAOF,EAET,IAAMG,EAAM,IAAI,YAAYD,EAAE,OAAQA,EAAE,WAAY,CAAC,EAGrD,GAAIC,EAAI,CAAC,IAAM,YAAcA,EAAI,CAAC,IAAM,WAAcA,EAAI,CAAC,IAAM,WAC/D,MAAO,CACL,KAAM,YACN,MAAQD,EAAE,EAAE,GAAK,GAAKA,EAAE,EAAE,GAAK,GAAKA,EAAE,EAAE,GAAK,EAAIA,EAAE,EAAE,EACrD,OAAQA,EAAE,EAAE,GAAK,GAAKA,EAAE,EAAE,GAAK,GAAKA,EAAE,EAAE,GAAK,EAAIA,EAAE,EAAE,CACvD,EAGF,GAAIA,EAAE,CAAC,IAAM,KAAQA,EAAE,CAAC,IAAM,KAAQA,EAAE,CAAC,IAAM,IAAM,CACnD,GAAM,CAACE,EAAOC,CAAM,EAAIC,GAAQJ,CAAC,EACjC,MAAO,CAAE,KAAM,aAAc,MAAAE,EAAO,OAAAC,CAAO,CAC7C,CAEA,GAAIF,EAAI,CAAC,IAAM,YAAeD,EAAE,CAAC,IAAM,IAAQA,EAAE,CAAC,IAAM,KAASA,EAAE,CAAC,IAAM,GACxE,MAAO,CACL,KAAM,YACN,MAAQA,EAAE,CAAC,GAAK,EAAIA,EAAE,CAAC,EACvB,OAAQA,EAAE,CAAC,GAAK,EAAIA,EAAE,CAAC,CACzB,EAGF,GAAIC,EAAI,CAAC,IAAM,WACb,MAAO,CACL,KAAM,YACN,MAAQD,EAAE,CAAC,GAAK,GAAKA,EAAE,CAAC,GAAK,GAAKA,EAAE,CAAC,GAAK,EAAIA,EAAE,CAAC,EACjD,OAAQA,EAAE,CAAC,GAAK,GAAKA,EAAE,CAAC,GAAK,GAAKA,EAAE,EAAE,GAAK,EAAIA,EAAE,EAAE,CACrD,EAGF,GAAIC,EAAI,CAAC,IAAM,YAAcA,EAAI,CAAC,IAAM,aAAeA,EAAI,CAAC,EAAI,YAAc,QAAU,CACtF,OAAQD,EAAE,EAAE,EAAG,CACb,IAAK,IACH,MAAO,CACL,KAAM,aACN,OAASA,EAAE,EAAE,EAAIA,EAAE,EAAE,GAAK,EAAIA,EAAE,EAAE,GAAK,IAAM,EAC7C,QAASA,EAAE,EAAE,EAAIA,EAAE,EAAE,GAAK,EAAIA,EAAE,EAAE,GAAK,IAAM,CAC/C,EACF,IAAK,IACH,GAAIA,EAAE,EAAE,IAAM,GAAM,OAAOF,EAC3B,IAAMO,EAAML,EAAE,EAAE,EAAIA,EAAE,EAAE,GAAK,EAAIA,EAAE,EAAE,GAAK,GAAKA,EAAE,EAAE,GAAK,GACxD,MAAO,CACL,KAAM,aACN,OAASK,EAAa,OAAU,EAChC,QAASA,IAAQ,GAAK,OAAU,CAClC,EACF,IAAK,IACH,OAAIL,EAAE,EAAE,IAAM,KAAQA,EAAE,EAAE,IAAM,GAAQA,EAAE,EAAE,IAAM,GAAaF,EACxD,CACL,KAAM,aACN,OAASE,EAAE,EAAE,EAAIA,EAAE,EAAE,GAAK,GAAK,MAC/B,QAASA,EAAE,EAAE,EAAIA,EAAE,EAAE,GAAK,GAAK,KACjC,CACJ,CACA,OAAOF,CACT,CAEA,GAAIG,EAAI,CAAC,IAAM,aAAeA,EAAI,CAAC,IAAM,YAAcA,EAAI,CAAC,IAAM,YAAa,CAC7E,IAAIK,EAAM,GAEJC,EAAQ,KAAK,IAAIP,EAAE,OAAS,GAAI,IAAI,EAC1C,QAASQ,EAAI,EAAGA,EAAID,EAAOC,IAEzB,GAAIR,EAAEQ,CAAC,IAAM,KAAQR,EAAEQ,EAAI,CAAC,IAAM,KAAQR,EAAEQ,EAAI,CAAC,IAAM,KAAQR,EAAEQ,EAAI,CAAC,IAAM,IAAM,CAChFF,EAAME,EACN,KACF,CAEF,GAAIF,IAAQ,GAAI,CAEd,IAAMJ,EACJF,EAAEM,EAAO,CAAC,GAAK,GACfN,EAAEM,EAAO,CAAC,GAAK,GACfN,EAAEM,EAAM,EAAE,GAAM,EAChBN,EAAEM,EAAM,EAAE,EACNH,EACJH,EAAEM,EAAM,EAAE,GAAK,GACfN,EAAEM,EAAM,EAAE,GAAK,GACfN,EAAEM,EAAM,EAAE,GAAM,EAChBN,EAAEM,EAAM,EAAE,EACZ,GAAIJ,EAAQ,GAAKC,EAAS,EACxB,MAAO,CAAE,KAAM,aAAc,MAAAD,EAAO,OAAAC,CAAO,CAE/C,CACA,OAAOL,CACT,CACA,OAAOA,CACT,CAGA,SAASM,GAAQJ,EAAiC,CAChD,IAAMS,EAAMT,EAAE,OACVQ,EAAI,EACJE,EAAcV,EAAEQ,CAAC,GAAK,EAAIR,EAAEQ,EAAI,CAAC,EACrC,OAAa,CAEX,GADAA,GAAKE,EACDF,GAAKC,EAEP,MAAO,CAAC,EAAG,CAAC,EAEd,GAAIT,EAAEQ,CAAC,IAAM,IACX,MAAO,CAAC,EAAG,CAAC,EAEd,GAAIR,EAAEQ,EAAI,CAAC,IAAM,KAAQR,EAAEQ,EAAI,CAAC,IAAM,IACpC,OAAIA,EAAI,EAAIC,EACH,CACLT,EAAEQ,EAAI,CAAC,GAAK,EAAIR,EAAEQ,EAAI,CAAC,EACvBR,EAAEQ,EAAI,CAAC,GAAK,EAAIR,EAAEQ,EAAI,CAAC,CACzB,EAEK,CAAC,EAAG,CAAC,EAEdA,GAAK,EACLE,EAAcV,EAAEQ,CAAC,GAAK,EAAIR,EAAEQ,EAAI,CAAC,CACnC,CACF,CFrHA,IAAMG,GAAgC,CACpC,OACA,KAAM,eACN,KAAM,EACN,MAAO,OACP,OAAQ,OACR,oBAAqB,EACrB,OAAQ,CACV,EAGaC,EAAN,KAAuD,CAS5D,YACmBC,EACAC,EACAC,EACAC,EACjB,CAJiB,WAAAH,EACA,eAAAC,EACA,cAAAC,EACA,mBAAAC,EAZnB,KAAQ,SAAW,GACnB,KAAQ,IAAM,IAAIC,EAClB,KAAQ,QAAyBN,GAGjC,KAAQ,aAAe,GACvB,KAAQ,YAAc,GAQpB,IAAMO,EAAkB,KAAK,KAAK,KAAK,MAAM,aAAe,EAAI,CAAC,EAC3DC,EAAe,KAAK,IAAI,QAA2BD,CAAe,EACxE,KAAK,KAAO,IAAI,GAAAE,QAAc,QAAwBF,EAAiBC,CAAY,EACnF,KAAK,QAAU,IAAI,GAAAE,QAAW,OAAsB,CACtD,CAEO,OAAc,CACnB,KAAK,IAAI,MAAM,EACf,KAAK,KAAK,QAAQ,EAClB,KAAK,QAAQ,QAAQ,CACvB,CAEO,OAAc,CACnB,KAAK,SAAW,GAChB,KAAK,IAAI,MAAM,CACjB,CAEO,IAAIC,EAAmBC,EAAeC,EAAmB,CAC9D,GAAI,MAAK,SAET,GAAI,KAAK,IAAI,QAAU,EAChB,KAAK,KAAK,IAAIF,EAAK,SAASC,EAAOC,CAAG,CAAC,IAAiB,IAC3D,KAAK,KAAK,QAAQ,EAClB,KAAK,SAAW,QAEb,CACL,IAAMC,EAAU,KAAK,IAAI,MAAMH,EAAMC,EAAOC,CAAG,EAC/C,GAAIC,IAAY,GAAI,CAClB,KAAK,SAAW,GAChB,MACF,CACA,GAAIA,EAAU,EAAG,CAEf,GADgB,KAAK,IAAI,OAAO,OAChB,EAAmB,CAOjC,GANI,KAAK,eACP,KAAK,aAAe,GACpB,KAAK,YAAc,GACnB,KAAK,KAAK,QAAQ,GAEpB,KAAK,QAAU,OAAO,OAAO,CAAC,EAAGd,GAAgB,KAAK,IAAI,MAAM,EAC5D,CAAC,KAAK,QAAQ,OAAQ,CACxB,KAAK,SAAW,GAChB,MACF,CACA,KAAK,KAAK,KAAK,CACjB,SAAW,KAAK,YAAa,CAC3B,KAAK,SAAW,GAChB,MACF,CACK,KAAK,KAAK,IAAIW,EAAK,SAASG,EAASD,CAAG,CAAC,IAAiB,IAC7D,KAAK,KAAK,QAAQ,EAClB,KAAK,SAAW,GACZ,KAAK,eAAc,KAAK,YAAc,IAE9C,CACF,CACF,CAEO,IAAIE,EAA8C,CAGvD,GAFI,KAAK,UAEL,KAAK,IAAI,QAAU,GACjB,KAAK,IAAI,IAAI,EAAG,MAAO,GAE7B,IAAMC,EAAU,KAAK,IAAI,OAAO,KAEhC,GAAIA,IAAY,EAAuB,MAAO,GAE9C,GAAIA,IAAY,EAA6B,CAE3C,IAAIC,EAAIC,EAAkB,MACtBC,EAAID,EAAkB,OACtB,KAAK,UAAU,aACjBD,EAAI,KAAK,UAAU,WAAW,IAAI,OAAO,MAAQ,KAAK,cAAc,KACpEE,EAAI,KAAK,UAAU,WAAW,IAAI,OAAO,OAAS,KAAK,cAAc,MAEvE,IAAMC,EAAQ,KAAK,cAAc,MAAM,qBAAqB,KAAO,EAC7DC,EAAS,4BAA4BF,EAAE,QAAQ,CAAC,CAAC,IAAIF,EAAE,QAAQ,CAAC,CAAC,IAAIG,EAAM,QAAQ,CAAC,CAAC,SAC3F,YAAK,cAAc,MAAMC,EAAQ,EAAK,EAC/B,EACT,CAEA,GAAIL,IAAY,EACd,YAAK,QAAU,OAAO,OAAO,CAAC,EAAGhB,GAAgB,KAAK,IAAI,MAAM,EAChE,KAAK,aAAe,GACpB,KAAK,YAAc,GACnB,KAAK,KAAK,QAAQ,EAClB,KAAK,KAAK,KAAK,EACR,GAGT,GAAIgB,IAAY,IACV,CAAC,KAAK,eACV,KAAK,aAAe,GAChB,KAAK,aAAe,KAAK,QAAQ,OAAS,IAA4B,MAAO,GAKnF,IAAIC,EAAI,EACJE,EAAI,EAGJG,EACAC,EAAUC,EAoBd,IAnBIF,EAAOP,MACLO,EAAO,CAAC,KAAK,KAAK,IAAI,IACxBC,EAAUE,GAAU,KAAK,KAAK,KAAK,GAC/BH,EAAOC,EAAQ,OAAS,gBAC1BN,EAAIM,EAAQ,MACZJ,EAAII,EAAQ,QACRD,EAAOL,GAAKE,GAAKF,EAAIE,EAAI,KAAK,MAAM,aACtC,CAACF,EAAGE,CAAC,EAAI,KAAK,QAAQF,EAAGE,CAAC,EAAE,IAAI,KAAK,KAAK,EAC1CG,EAAOL,GAAKE,GAAKF,EAAIE,EAAI,KAAK,MAAM,YAEpC,QAAQ,KAAK,8BAA8BI,EAAQ,KAAK,IAAIA,EAAQ,MAAM,EAAE,GAG9E,QAAQ,KAAK,6BAA6B,GAG5C,QAAQ,KAAK,mCAAmC,GAGhD,CAACD,EACH,YAAK,KAAK,QAAQ,EACX,GAGT,IAAII,EACJ,GAAIH,EAAQ,OAAS,YAAa,CAChC,IAAMZ,EAAO,KAAK,QAAQ,OAAO,KAAK,KAAK,KAAK,EAOhD,GANAe,EAAO,IAAI,UACT,IAAI,kBAAkBf,EAAK,OAAQA,EAAK,WAAYA,EAAK,UAAU,EACnE,KAAK,QAAQ,MACb,KAAK,QAAQ,MACf,EACA,KAAK,QAAQ,QAAQ,EACjBM,IAAM,KAAK,QAAQ,OAASE,IAAM,KAAK,QAAQ,OAAQ,CAEzD,KAAK,KAAK,QAAQ,EAClB,IAAMQ,EAASC,EAAc,aAAa,OAAW,KAAK,QAAQ,MAAO,KAAK,QAAQ,MAAM,EAC5F,OAAAD,EAAO,WAAW,IAAI,GAAG,aAAaD,EAAM,EAAG,CAAC,EAChD,KAAK,SAAS,SAASC,CAAM,EACtB,EACT,CACF,MACED,EAAO,IAAI,KAAK,CAAC,KAAK,KAAK,KAAK,EAAG,CAAE,KAAMH,EAAQ,IAAK,CAAC,EAE3D,YAAK,KAAK,QAAQ,EACX,kBAAkBG,EAAM,CAAE,YAAaT,EAAG,aAAcE,CAAE,CAAC,EAC/D,KAAKU,IACJ,KAAK,SAAS,SAASA,CAAE,EAClB,GACR,EACA,MAAMC,IACL,QAAQ,KAAK,uBAAuBP,EAAQ,IAAI,IAAIA,EAAQ,KAAK,IAAIA,EAAQ,MAAM,GAAIO,CAAC,EACjF,GACR,CACL,CAEQ,QAAQb,EAAWE,EAA6B,CACtD,IAAMY,EAAK,KAAK,UAAU,YAAY,IAAI,KAAK,OAASb,EAAkB,MACpEc,EAAK,KAAK,UAAU,YAAY,IAAI,KAAK,QAAUd,EAAkB,OACrEe,EAAQ,KAAK,UAAU,YAAY,IAAI,OAAO,OAASF,EAAK,KAAK,cAAc,KAC/EG,EAAS,KAAK,UAAU,YAAY,IAAI,OAAO,QAAUF,EAAK,KAAK,cAAc,KAEjFG,EAAK,KAAK,KAAK,KAAK,QAAQ,MAAQF,EAAOF,CAAE,EAC7CK,EAAK,KAAK,KAAK,KAAK,QAAQ,OAASF,EAAQF,CAAE,EACrD,GAAI,CAACG,GAAM,CAACC,EAAI,CACd,IAAMC,EAAKJ,EAAQhB,EACbqB,GAAMJ,EAASF,GAAMb,EACrBoB,EAAI,KAAK,IAAIF,EAAIC,CAAE,EACzB,OAAOC,EAAI,EAAI,CAACtB,EAAIsB,EAAGpB,EAAIoB,CAAC,EAAI,CAACtB,EAAGE,CAAC,CACvC,CACA,OAAQgB,EAEJ,KAAK,QAAQ,qBAAuB,CAACA,GAAM,CAACC,EAC1C,CAACD,EAAIhB,EAAIgB,EAAKlB,CAAC,EAAI,CAACkB,EAAIC,CAAE,EAF5B,CAACnB,EAAImB,EAAKjB,EAAGiB,CAAE,CAGrB,CAEQ,KAAKI,EAAWC,EAAeC,EAAsB,CAC3D,OAAIF,IAAM,OAAe,EACrBA,EAAE,SAAS,GAAG,EAAU,SAASA,EAAE,MAAM,EAAG,EAAE,EAAG,EAAE,EAAIC,EAAQ,IAC/DD,EAAE,SAAS,IAAI,EAAU,SAASA,EAAE,MAAM,EAAG,EAAE,EAAG,EAAE,EACjD,SAASA,EAAG,EAAE,EAAIE,CAC3B,CACF,EGnOA,IAAAC,GAAiD,QCoI1C,SAASC,GAAkBC,EAA6B,CAC7D,IAAMC,EAAqB,CAAC,EACtBC,EAAQF,EAAK,MAAM,GAAG,EAE5B,QAAWG,KAAQD,EAAO,CACxB,IAAME,EAAQD,EAAK,QAAQ,GAAG,EAC9B,GAAIC,IAAU,GAAI,SAElB,IAAMC,EAAMF,EAAK,UAAU,EAAGC,CAAK,EAC7BE,EAAQH,EAAK,UAAUC,EAAQ,CAAC,EAGtC,GAAIC,IAAQ,IAAiB,CAC3BJ,EAAI,OAASK,EACb,QACF,CACA,GAAID,IAAQ,IAAsB,CAChCJ,EAAI,YAAcK,EAClB,QACF,CACA,GAAID,IAAQ,IAAuB,CACjCJ,EAAI,aAAeK,EACnB,QACF,CACA,GAAID,IAAQ,IAA0B,CACpCJ,EAAI,eAAiBK,EACrB,QACF,CACA,IAAMC,EAAW,SAASD,EAAO,EAAE,EACnC,OAAQD,EAAK,CACX,IAAK,IAAiBJ,EAAI,OAASM,EAAU,MAC7C,IAAK,IAAaN,EAAI,GAAKM,EAAU,MACrC,IAAK,IAAuBN,EAAI,YAAcM,EAAU,MACxD,IAAK,IAAgBN,EAAI,MAAQM,EAAU,MAC3C,IAAK,IAAiBN,EAAI,OAASM,EAAU,MAC7C,IAAK,IAAmBN,EAAI,EAAIM,EAAU,MAC1C,IAAK,IAAmBN,EAAI,EAAIM,EAAU,MAC1C,IAAK,IAAuBN,EAAI,YAAcM,EAAU,MACxD,IAAK,IAAwBN,EAAI,aAAeM,EAAU,MAC1D,IAAK,IAA6BN,EAAI,QAAUM,EAAU,MAC1D,IAAK,IAA6BN,EAAI,QAAUM,EAAU,MAC1D,IAAK,IAAkBN,EAAI,QAAUM,EAAU,MAC/C,IAAK,IAAeN,EAAI,KAAOM,EAAU,MACzC,IAAK,IAAeN,EAAI,KAAOM,EAAU,MACzC,IAAK,IAAgBN,EAAI,MAAQM,EAAU,MAC3C,IAAK,IAA0BN,EAAI,eAAiBM,EAAU,MAC9D,IAAK,IAAkBN,EAAI,OAASM,EAAU,MAC9C,IAAK,IAAuBN,EAAI,YAAcM,EAAU,KAC1D,CACF,CAEA,OAAON,CACT,CDhKA,IAAMO,GAAa,EAGNC,GAAN,KAA8E,CAgCnF,YACmBC,EACAC,EACAC,EACAC,EACjB,CAJiB,WAAAH,EACA,eAAAC,EACA,mBAAAC,EACA,mBAAAC,EAnCnB,KAAQ,SAAW,GACnB,KAAQ,aAAe,GAEvB,KAAQ,eAAuC,KAO/C,KAAQ,eAAiB,GAGzB,KAAQ,aAAe,IAAI,YAAY,GAA+B,EACtE,KAAQ,eAAiB,EAGzB,KAAQ,kBAAoB,EAC5B,KAAQ,kBAAoB,EAG5B,KAAQ,eAAuC,KAI/C,KAAQ,sBAA2D,IAAI,IAarE,KAAK,iBAAmB,KAAK,KAAK,KAAK,MAAM,eAAiB,EAAI,CAAC,EAEnE,KAAK,qBAAuB,KAAK,IAAI,QAAgC,KAAK,gBAAgB,CAC5F,CAEO,OAAc,CACnB,KAAK,mBAAmB,EACpB,KAAK,iBACP,KAAK,eAAe,QAAQ,EAC5B,KAAK,eAAiB,MAExB,KAAK,cAAc,MAAM,CAC3B,CAEO,SAAgB,CACrB,KAAK,MAAM,CACb,CAEQ,oBAAoBC,EAAmB,CAC7C,KAAK,sBAAsB,OAAOA,CAAG,EACjC,KAAK,kBAAoBA,IAC3B,KAAK,gBAAkB,OAE3B,CAEQ,oBAA2B,CACjC,QAAWC,KAAW,KAAK,sBAAsB,OAAO,EACtDA,EAAQ,QAAQ,QAAQ,EAE1B,KAAK,sBAAsB,MAAM,EACjC,KAAK,gBAAkB,MACzB,CAEO,OAAc,CACnB,KAAK,SAAW,GAChB,KAAK,aAAe,GACpB,KAAK,eAAiB,GACtB,KAAK,eAAiB,EACtB,KAAK,eAAiB,KAEtB,KAAK,kBAAoB,KAAK,iBAC9B,KAAK,kBAAoB,EACzB,KAAK,eAAiB,IACxB,CAEO,IAAIC,EAAmBC,EAAeC,EAAmB,CAC9D,GAAI,MAAK,SAET,GAAI,CAAC,KAAK,eACR,KAAK,eAAeF,EAAMC,EAAOC,CAAG,MAC/B,CAEL,IAAIC,EAAaD,EACjB,QAASE,EAAIH,EAAOG,EAAIF,EAAKE,IAC3B,GAAIJ,EAAKI,CAAC,IAAM,GAAqB,CACnC,KAAK,eAAiB,GACtBD,EAAaC,EACb,KACF,CAIF,IAAMC,EAAaF,EAAaF,EAChC,GAAI,KAAK,eAAiBI,EAAa,IAAiC,CACtE,KAAK,SAAW,GAChB,MACF,CAIA,GAHA,KAAK,aAAa,IAAIL,EAAK,SAASC,EAAOE,CAAU,EAAG,KAAK,cAAc,EAC3E,KAAK,gBAAkBE,EAEnB,CAAC,KAAK,eAAgB,CAKxB,GAHA,KAAK,eAAiBC,GAAkB,KAAK,wBAAwB,CAAC,EAGlE,KAAK,eAAe,KAAO,QAAa,KAAK,eAAe,cAAgB,OAAW,CACzF,KAAK,cAAc,KAAK,eAAe,GAAI,0CAA2C,KAAK,eAAe,OAAS,CAAC,EACpH,KAAK,SAAW,GAChB,MACF,CAGA,GAAI,KAAK,eAAe,SAAW,IACjC,OAIF,IAAMC,EAAeJ,EAAa,EAC9BI,EAAeL,GACjB,KAAK,eAAeF,EAAMO,EAAcL,CAAG,CAE/C,CACF,CACF,CAGQ,eAAeF,EAAmBC,EAAeC,EAAmB,CAC1E,GAAI,KAAK,SAAU,OAKnB,IAAMM,EAAa,KAAK,gBAAgB,IAAM,KAAK,iBAAmB,EAChET,EAAU,KAAK,sBAAsB,IAAIS,CAAU,EACnDC,EAAsBV,GAAS,kBAAoB,EAGzD,GAFA,KAAK,mBAAqBG,EAAMD,EACFQ,EAAsB,KAAK,kBAC7B,KAAK,kBAAmB,CAClD,IAAMC,EAAmB,KAAK,gBAAkBX,GAAS,QACrDW,GACFA,EAAiB,QAAQ,EAE3B,KAAK,eAAiB,KAClBX,GACF,KAAK,oBAAoBS,CAAU,EAErC,KAAK,SAAW,GAChB,MACF,CAEI,KAAK,eAELT,GAAS,SAAW,CAAC,KAAK,iBAC5B,KAAK,eAAiBA,EAAQ,SAE3B,KAAK,iBACR,KAAK,eAAiB,IAAI,GAAAY,QAAc,QAA6B,KAAK,iBAAkB,KAAK,oBAAoB,EACrH,KAAK,eAAe,KAAK,GAGvB,KAAK,eAAe,IAAIX,EAAK,SAASC,EAAOC,CAAG,CAAC,IAAMV,KACzD,KAAK,eAAe,QAAQ,EAC5B,KAAK,eAAiB,KACtB,KAAK,aAAe,GAChBO,GACF,KAAK,oBAAoBS,CAAU,GAGzC,CAEO,IAAII,EAA8C,CACvD,GAAI,KAAK,UAAY,CAACA,EACpB,OAAI,KAAK,iBACP,KAAK,eAAe,QAAQ,EAC5B,KAAK,eAAiB,MAEjB,GAIT,GAAI,KAAK,eACP,OAAO,KAAK,wBAAwB,EAItC,IAAMC,EAAM,KAAK,eAGjB,GAAIA,EAAI,SAAW,IACjB,OAAO,KAAK,cAAcA,CAAG,EAI/B,IAAML,EAAaK,EAAI,IAAM,KAAK,iBAAmB,EAC/CC,EAAeD,EAAI,OAAS,EAC5Bd,EAAU,KAAK,sBAAsB,IAAIS,CAAU,EAEzD,GAAIM,EACF,OAAI,KAAK,iBACHf,GACFA,EAAQ,kBAAoB,KAAK,kBACjCA,EAAQ,YAAcA,EAAQ,aAAe,KAAK,cAElD,KAAK,sBAAsB,IAAIS,EAAY,CACzC,IAAK,CAAE,GAAGK,CAAI,EACd,QAAS,KAAK,eACd,iBAAkB,KAAK,kBACvB,YAAa,KAAK,YACpB,CAAC,EAEH,KAAK,gBAAkBL,EACvB,KAAK,eAAiB,MAEjB,GAILT,IACF,KAAK,gBAAkB,QAGzB,IAAIgB,EAAc,KAAK,aACnBC,EAAWH,EACXI,EAAU,KAAK,eAEflB,IACFiB,EAAWjB,EAAQ,IACnBkB,EAAUlB,EAAQ,QAClBgB,EAAcA,GAAehB,EAAQ,YACrC,KAAK,sBAAsB,OAAOS,CAAU,GAG9C,IAAIU,EAAa,IAAI,WAAW,CAAC,EAC7BD,IACEA,EAAQ,IAAI,IAAMzB,KACpBuB,EAAc,IAEhBG,EAAaD,EAAQ,OAEvB,KAAK,eAAiB,KAKtB,IAAME,EAAS,KAAK,8BAA8BH,EAAUE,EAAYH,CAAW,EACnF,OAAIE,GACFA,EAAQ,QAAQ,EAEXE,CACT,CAIQ,yBAAkC,CACxC,IAAIC,EAAM,GACV,QAAShB,EAAI,EAAGA,EAAI,KAAK,eAAgBA,IACvCgB,GAAO,OAAO,cAAc,KAAK,aAAahB,CAAC,CAAC,EAElD,OAAOgB,CACT,CAEQ,yBAAsD,CAC5D,IAAMP,EAAMP,GAAkB,KAAK,wBAAwB,CAAC,EAG5D,GAAIO,EAAI,KAAO,QAAaA,EAAI,cAAgB,OAC9C,YAAK,cAAcA,EAAI,GAAI,0CAA2CA,EAAI,OAAS,CAAC,EAC7E,GAKT,OAFeA,EAAI,QAAU,IAEb,CACd,QACE,OAAO,KAAK,cAAcA,CAAG,EAC/B,QACE,YAAK,cAAcA,EAAI,IAAM,EAAG,KAAMA,EAAI,OAAS,CAAC,EAC7C,GACT,QACE,OAAO,KAAK,iBAAiBA,CAAG,EAClC,QAKE,OAAIA,EAAI,KAAO,QACb,KAAK,cAAcA,EAAI,GAAI,4BAA6BA,EAAI,OAAS,CAAC,EAEjE,EACX,CACF,CAEQ,8BAA8BA,EAAoBQ,EAAmBN,EAAkD,CAG7H,OAFeF,EAAI,QAAU,IAEb,CACd,QAA2B,CACzB,IAAMM,EAAS,KAAK,gBAAgBN,EAAKQ,EAAON,CAAW,EAG3D,OAAKF,EAAI,cAAgB,OAAS,KAAOA,EAAI,KAAO,SAC9CE,EACF,KAAK,cAAcF,EAAI,GAAI,6BAA8BA,EAAI,OAAS,CAAC,EAC9DQ,EAAM,OAAS,GACxB,KAAK,cAAcR,EAAI,GAAI,KAAMA,EAAI,OAAS,CAAC,GAG5CM,CACT,CACA,QACE,OAAO,KAAK,uBAAuBN,EAAKQ,EAAON,CAAW,EAC5D,QACE,OAAO,KAAK,aAAaF,EAAKQ,EAAON,CAAW,EAClD,QAEE,OAAO,KAAK,iBAAiBF,CAAG,EAClC,QAKE,OAAIA,EAAI,KAAO,QACb,KAAK,cAAcA,EAAI,GAAI,4BAA6BA,EAAI,OAAS,CAAC,EAEjE,EACX,CACF,CAEQ,iBAAiBA,EAAgD,CACvE,GAAIA,EAAI,KAAO,OACb,MAAO,GAET,IAAMS,EAAKT,EAAI,GACTU,EAAQ,KAAK,cAAc,SAASD,CAAE,EAC5C,OAAKC,EAIU,KAAK,cAAcA,EAAOV,CAAG,EAC9B,KAAKD,IACjB,KAAK,cAAcU,EAAIV,EAAU,KAAO,gCAAiCC,EAAI,OAAS,EAAGA,EAAI,WAAW,EACjG,GACR,GAPC,KAAK,cAAcS,EAAI,yBAA0BT,EAAI,OAAS,EAAGA,EAAI,WAAW,EACzE,GAOX,CAEQ,gBAAgBA,EAAoBQ,EAAmBN,EAA+B,CAY5F,OADqBF,EAAI,cAAgB,OACpB,KACfA,EAAI,KAAO,QACb,KAAK,cAAcA,EAAI,GAAI,yCAA0CA,EAAI,OAAS,CAAC,EAE9E,KAGLE,GAAeM,EAAM,SAAW,GAEpC,KAAK,cAAc,WAAWR,EAAI,GAAI,CACpC,KAAM,IAAI,KAAK,CAACQ,CAAiB,CAAC,EAClC,MAAOR,EAAI,OAAS,EACpB,OAAQA,EAAI,QAAU,EACtB,OAASA,EAAI,QAAU,GACvB,YAAaA,EAAI,aAAe,EAClC,CAAC,EACM,GACT,CAEQ,uBAAuBA,EAAoBQ,EAAmBN,EAAkD,CACtH,GAAIA,EACF,OAAIF,EAAI,KAAO,QACb,KAAK,cAAcA,EAAI,GAAI,6BAA8BA,EAAI,OAAS,CAAC,EAElE,GAGT,KAAK,gBAAgBA,EAAKQ,EAAON,CAAW,EAE5C,IAAMO,EAAKT,EAAI,IAAM,KAAK,cAAc,YAClCU,EAAQ,KAAK,cAAc,SAASD,CAAE,EAC5C,GAAIC,EAAO,CACT,IAAMJ,EAAS,KAAK,cAAcI,EAAOV,CAAG,EAC5C,OAAIA,EAAI,KAAO,OACNM,EAAO,KAAKP,IACjB,KAAK,cAAcU,EAAIV,EAAU,KAAO,gCAAiCC,EAAI,OAAS,CAAC,EAChF,GACR,EAEIM,EAAO,KAAK,IAAM,EAAI,CAC/B,CACA,MAAO,EACT,CAEQ,aAAaN,EAAoBQ,EAAmBN,EAA+B,CACzF,IAAMO,EAAKT,EAAI,IAAM,EACfW,EAAQX,EAAI,OAAS,EAM3B,IADqBA,EAAI,cAAgB,OACpB,IACnB,YAAK,cAAcS,EAAI,yCAA0CE,CAAK,EAC/D,GAIT,GAAIT,EACF,YAAK,cAAcO,EAAI,6BAA8BE,CAAK,EACnD,GAIT,GAAIH,EAAM,SAAW,EACnB,YAAK,cAAcC,EAAI,KAAME,CAAK,EAC3B,GAGT,IAAMC,EAASZ,EAAI,QAAU,GAE7B,GAAIY,IAAW,IACb,KAAK,cAAcH,EAAI,KAAME,CAAK,MAC7B,CACL,IAAME,EAAQb,EAAI,OAAS,EACrBc,EAASd,EAAI,QAAU,EAE7B,GAAI,CAACa,GAAS,CAACC,EACb,YAAK,cAAcL,EAAI,sDAAuDE,CAAK,EAC5E,GAGT,IAAMI,EAAgBH,IAAW,OAC3BI,EAAgBH,EAAQC,EAASC,EAEvC,GAAIP,EAAM,OAASQ,EACjB,YAAK,cAAcP,EAAI,iCAAkCE,CAAK,EACvD,GAGT,KAAK,cAAcF,EAAI,KAAME,CAAK,CACpC,CACA,MAAO,EACT,CAEQ,cAAcX,EAA6B,CAOjD,OALiBA,EAAI,gBAAkB,IAKrB,CAChB,IAAK,IACL,IAAK,IACH,KAAK,mBAAmB,EACxB,KAAK,cAAc,UAAU,EAC7B,MACF,IAAK,IACL,IAAK,IAKH,GAAIA,EAAI,KAAO,OAAW,CACxB,IAAMd,EAAU,KAAK,sBAAsB,IAAIc,EAAI,EAAE,EACjDd,GACFA,EAAQ,QAAQ,QAAQ,EAE1B,KAAK,oBAAoBc,EAAI,EAAE,EAC/B,KAAK,cAAc,WAAWA,EAAI,EAAE,CACtC,CACA,MACF,QAEE,KACJ,CACA,MAAO,EACT,CAEQ,cAAcS,EAAYQ,EAAiBN,EAAeO,EAA4B,CAC5F,IAAMC,EAAOF,IAAY,KAEzB,GADIE,GAAQR,GAAS,GACjB,CAACQ,GAAQR,GAAS,EAAG,OAEzB,IAAMS,EAAQF,EAAc,MAAMA,CAAW,GAAK,GAC5CG,EAAW,WAAWZ,CAAE,GAAGW,CAAK,IAAIH,CAAO,SACjD,KAAK,cAAc,MAAM,YAAY,iBAAiBI,CAAQ,CAChE,CAIQ,cAAcX,EAAwBV,EAAsC,CAClF,OAAO,KAAK,kBAAkBU,EAAOV,CAAG,EACrC,KAAK,IAAM,EAAI,EACf,MAAM,IAAM,EAAK,CACtB,CAEA,MAAc,kBAAkBU,EAAwBV,EAAmC,CACzF,IAAIsB,EAAkC,MAAM,KAAK,cAAcZ,CAAK,EAEpE,GAAI,CACF,IAAMa,EAAQ,KAAK,IAAI,EAAGvB,EAAI,GAAK,CAAC,EAC9BwB,EAAQ,KAAK,IAAI,EAAGxB,EAAI,GAAK,CAAC,EAC9ByB,EAAQzB,EAAI,aAAgBsB,EAAO,MAAQC,EAC3CG,EAAQ1B,EAAI,cAAiBsB,EAAO,OAASE,EAE7CG,EAAW,KAAK,IAAI,EAAGL,EAAO,MAAQC,CAAK,EAC3CK,EAAW,KAAK,IAAI,EAAGN,EAAO,OAASE,CAAK,EAC5CK,EAAa,KAAK,IAAI,EAAG,KAAK,IAAIJ,EAAOE,CAAQ,CAAC,EAClDG,EAAa,KAAK,IAAI,EAAG,KAAK,IAAIJ,EAAOE,CAAQ,CAAC,EAExD,GAAIC,IAAe,GAAKC,IAAe,EACrC,MAAM,IAAI,MAAM,0BAA0B,EAG5C,GAAIP,IAAU,GAAKC,IAAU,GAAKK,IAAeP,EAAO,OAASQ,IAAeR,EAAO,OAAQ,CAC7F,IAAMS,EAAU,MAAM,kBAAkBT,EAAQC,EAAOC,EAAOK,EAAYC,CAAU,EACpFR,EAAO,MAAM,EACbA,EAASS,CACX,CAEA,IAAMC,EAAK,KAAK,UAAU,YAAY,IAAI,KAAK,OAASC,EAAkB,MACpEC,EAAK,KAAK,UAAU,YAAY,IAAI,KAAK,QAAUD,EAAkB,OAIvEE,EACAC,EACApC,EAAI,UAAY,QAAaA,EAAI,OAAS,QAC5CmC,EAAUnC,EAAI,QACdoC,EAAUpC,EAAI,MACLA,EAAI,UAAY,QACzBmC,EAAUnC,EAAI,QACdoC,EAAU,KAAK,IAAI,EAAG,KAAK,KAAMd,EAAO,OAASA,EAAO,OAAUa,EAAUH,GAAME,CAAE,CAAC,GAC5ElC,EAAI,OAAS,QACtBoC,EAAUpC,EAAI,KACdmC,EAAU,KAAK,IAAI,EAAG,KAAK,KAAMb,EAAO,MAAQA,EAAO,QAAWc,EAAUF,GAAMF,CAAE,CAAC,IAErFG,EAAU,KAAK,KAAKb,EAAO,MAAQU,CAAE,EACrCI,EAAU,KAAK,KAAKd,EAAO,OAASY,CAAE,GAGxC,IAAIG,EAAIf,EAAO,MACXgB,EAAIhB,EAAO,OAQf,IALItB,EAAI,UAAY,QAAaA,EAAI,OAAS,UAC5CqC,EAAI,KAAK,MAAMF,EAAUH,CAAE,EAC3BM,EAAI,KAAK,MAAMF,EAAUF,CAAE,GAGzBG,EAAIC,EAAI,KAAK,MAAM,WACrB,MAAM,IAAI,MAAM,2BAA2B,EAI7C,IAAMC,EAAS,KAAK,cAAc,MAAM,OAClCC,EAASD,EAAO,EAChBE,EAASF,EAAO,EAChBG,EAAaH,EAAO,MAOpBI,GADc3C,EAAI,SAAW,QAAaA,EAAI,OAAS,EACrB,SAAW,MAEnD,GAAIqC,IAAMf,EAAO,OAASgB,IAAMhB,EAAO,OAAQ,CAC7C,IAAMsB,EAAS,MAAM,kBAAkBtB,EAAQ,CAAE,YAAae,EAAG,aAAcC,CAAE,CAAC,EAClFhB,EAAO,MAAM,EACbA,EAASsB,CACX,CAGA,IAAMC,GAAU,KAAK,IAAI,KAAK,IAAI,EAAG7C,EAAI,SAAW,CAAC,EAAGgC,EAAK,CAAC,EACxDc,GAAU,KAAK,IAAI,KAAK,IAAI,EAAG9C,EAAI,SAAW,CAAC,EAAGkC,EAAK,CAAC,EAC9D,GAAIW,KAAY,GAAKC,KAAY,EAAG,CAKlC,IAAMC,EAAW/C,EAAI,UAAY,OAAa,KAAK,MAAMmC,EAAUH,CAAE,EAAIV,EAAO,MAAQuB,GAClFG,GAAWhD,EAAI,OAAS,OAAa,KAAK,MAAMoC,EAAUF,CAAE,EAAIZ,EAAO,OAASwB,GAChFG,EAAeC,EAAc,aAAa,OAAO,SAAUH,EAASC,EAAO,EAC3EG,GAAYF,EAAa,WAAW,IAAI,EAC9C,GAAI,CAACE,GACH,MAAM,IAAI,MAAM,wCAAwC,EAE1DA,GAAU,UAAU7B,EAAQuB,GAASC,EAAO,EAE5C,IAAMM,GAAe,MAAM,kBAAkBH,CAAY,EAMzD,GALAA,EAAa,MAAQA,EAAa,OAAS,EAC3C3B,EAAO,MAAM,EACbA,EAAS8B,GACTf,EAAIf,EAAO,MACXgB,EAAIhB,EAAO,OACPe,EAAIC,EAAI,KAAK,MAAM,WACrB,MAAM,IAAI,MAAM,2BAA2B,EAEzCtC,EAAI,UAAY,SAClBmC,EAAU,KAAK,KAAKb,EAAO,MAAQU,CAAE,GAEnChC,EAAI,OAAS,SACfoC,EAAU,KAAK,KAAKd,EAAO,OAASY,CAAE,EAE1C,CAEA,IAAMmB,GAASrD,EAAI,QAAU,EAO7B,GANA,KAAK,cAAc,SAASU,EAAM,GAAIY,EAAQ,GAAMqB,GAAOU,EAAM,EACjE/B,EAAS,OAKLtB,EAAI,iBAAmB,EAAG,CAE5B,IAAMsD,EAAWf,EAAO,MAAQG,EAChCH,EAAO,EAAIC,EAEXD,EAAO,EAAI,KAAK,IAAIE,EAASa,EAAU,CAAC,CAC1C,MAGEf,EAAO,EAAI,KAAK,IAAIC,EAASL,EAAS,KAAK,cAAc,IAAI,CAEjE,OAASoB,EAAG,CACV,MAAAjC,GAAQ,MAAM,EACRiC,CACR,CACF,CAGA,MAAc,cAAc7C,EAA8C,CACxE,IAAIF,EAAoB,IAAI,WAAW,MAAME,EAAM,KAAK,YAAY,CAAC,EAMrE,GAJIA,EAAM,cAAgB,MACxBF,EAAQ,MAAM,KAAK,gBAAgBA,CAAK,GAGtCE,EAAM,SAAW,IAAiB,CACpC,IAAM8C,EAAO,IAAI,KAAK,CAAChD,CAAiB,EAAG,CAAE,KAAM,WAAY,CAAC,EAChE,GAAI,CAAC,OAAO,kBAAmB,CAC7B,IAAMiD,EAAM,IAAI,gBAAgBD,CAAI,EAC9BE,EAAM,IAAI,MAChB,OAAO,IAAI,QAAqB,CAACC,EAASC,IAAW,CACnDF,EAAI,iBAAiB,OAAQ,IAAM,CACjC,IAAI,gBAAgBD,CAAG,EACvB,IAAMI,EAASX,EAAc,aAAa,OAAO,SAAUQ,EAAI,MAAOA,EAAI,MAAM,EAChFG,EAAO,WAAW,IAAI,GAAG,UAAUH,EAAK,EAAG,CAAC,EAC5C,kBAAkBG,CAAM,EAAE,KAAKF,CAAO,EAAE,MAAMC,CAAM,CACtD,CAAC,EACDF,EAAI,iBAAiB,QAAS,IAAM,CAClC,IAAI,gBAAgBD,CAAG,EACvBG,EAAO,IAAI,MAAM,sBAAsB,CAAC,CAC1C,CAAC,EACDF,EAAI,IAAMD,CACZ,CAAC,CACH,CACA,OAAO,kBAAkBD,CAAI,CAC/B,CAGA,IAAM3C,EAAQH,EAAM,MACdI,EAASJ,EAAM,OAErB,GAAI,CAACG,GAAS,CAACC,EACb,MAAM,IAAI,MAAM,8CAA8C,EAGhE,IAAMC,EAAgBL,EAAM,SAAW,OACjCM,EAAgBH,EAAQC,EAASC,EAEvC,GAAIP,EAAM,OAASQ,EACjB,MAAM,IAAI,MAAM,yBAAyB,EAG3C,IAAM8C,EAAajD,EAAQC,EAE3B,GAAIJ,EAAM,SAAW,GAEnB,OAAO,kBAAkB,IAAI,UAAU,IAAI,kBAAkBF,EAAM,OAAuBA,EAAM,WAAYsD,EAAa,CAAwC,EAAGjD,EAAOC,CAAM,CAAC,EAMpL,IAAM3B,EAAO,IAAI,kBAAkB2E,EAAa,CAAwC,EAClFC,EAAQ,IAAI,YAAYvD,EAAM,OAAQA,EAAM,WAAY,KAAK,MAAMA,EAAM,WAAa,CAAC,CAAC,EACxFwD,EAAQ,IAAI,YAAY7E,EAAK,MAAM,EACnC8E,EAAgBH,EAAa,GAE/BI,EAAY,EACZC,EAAY,EAChB,QAAS5E,EAAI,EAAGA,EAAI0E,EAAe1E,GAAK,EAAG,CACzC,IAAM6E,EAAKL,EAAMG,GAAW,EACtBG,EAAKN,EAAMG,GAAW,EACtBI,EAAKP,EAAMG,GAAW,EAE5BF,EAAMG,GAAW,EAAI,WAAaC,EAClCJ,EAAMG,GAAW,EAAI,WAAcC,IAAO,GAAOC,GAAM,EACvDL,EAAMG,GAAW,EAAI,WAAcE,IAAO,GAAOC,GAAM,GACvDN,EAAMG,GAAW,EAAI,WAAcG,IAAO,CAC5C,CAGA,IAAIC,EAAUN,EAAgB,EAC1BO,EAAUP,EAAgB,EAC9B,QAAS1E,EAAI0E,EAAe1E,EAAIuE,EAAYvE,IAC1CJ,EAAKqF,CAAO,EAAQhE,EAAM+D,CAAO,EACjCpF,EAAKqF,EAAU,CAAC,EAAIhE,EAAM+D,EAAU,CAAC,EACrCpF,EAAKqF,EAAU,CAAC,EAAIhE,EAAM+D,EAAU,CAAC,EACrCpF,EAAKqF,EAAU,CAAC,EAAI,IACpBD,GAAW,EACXC,GAAW,EAGb,OAAO,kBAAkB,IAAI,UAAUrF,EAAM0B,EAAOC,CAAM,CAAC,CAC7D,CAEA,MAAc,gBAAgB2D,EAA6C,CACzE,GAAI,CACF,OAAO,MAAM,KAAK,YAAYA,EAAY,SAAS,CACrD,MAAQ,CACN,OAAO,MAAM,KAAK,YAAYA,EAAY,aAAa,CACzD,CACF,CAEA,MAAc,YAAYA,EAAwB7D,EAAwD,CACxG,IAAM8D,EAAK,IAAI,oBAAoB9D,CAAM,EACnC+D,EAASD,EAAG,SAAS,UAAU,EACrCC,EAAO,MAAMF,CAA0B,EACvCE,EAAO,MAAM,EAEb,IAAMC,EAAuB,CAAC,EACxBC,EAASH,EAAG,SAAS,UAAU,EAErC,OAAa,CACX,GAAM,CAAE,KAAAI,EAAM,MAAAC,CAAM,EAAI,MAAMF,EAAO,KAAK,EAC1C,GAAIC,EAAM,MACVF,EAAO,KAAKG,CAAK,CACnB,CAEA,IAAMC,EAAcJ,EAAO,OAAO,CAACK,EAAKC,IAAUD,EAAMC,EAAM,OAAQ,CAAC,EACjE5E,EAAS,IAAI,WAAW0E,CAAW,EACrCG,EAAS,EACb,QAAWD,KAASN,EAClBtE,EAAO,IAAI4E,EAAOC,CAAM,EACxBA,GAAUD,EAAM,OAElB,OAAO5E,CACT,CAEA,IAAW,QAA+C,CACxD,OAAO,KAAK,cAAc,MAC5B,CAEA,IAAW,qBAAmD,CAC5D,OAAO,KAAK,cAAc,kBAC5B,CAEA,IAAW,sBAAkE,CAC3E,OAAO,KAAK,qBACd,CACF,EEjyBO,IAAM8E,EAAN,MAAMA,CAAyC,CA6BpD,YACmBC,EACjB,CADiB,cAAAA,EA3BnB,KAAQ,aAAe,EACvB,KAAiB,QAAwC,IAAI,IAU7D,KAAiB,oBAA2C,IAAI,IAChE,KAAiB,oBAA2C,IAAI,IAIhE,KAAiB,2BAA8BC,GAA4B,CACzE,IAAMC,EAAU,KAAK,oBAAoB,IAAID,CAAS,EAClDC,IAAY,SACd,KAAK,oBAAoB,OAAOA,CAAO,EACvC,KAAK,oBAAoB,OAAOD,CAAS,EACzC,KAAK,QAAQ,OAAOC,CAAO,EAE/B,EACA,KAAQ,cAA+B,CAAE,UAAW,GAAM,MAAO,MAAO,OAAQ,EAAG,UAAW,KAAM,EAKlG,KAAK,wBAA0B,KAAK,SAAS,eAC7C,KAAK,uBAA0BD,GAAsB,CACnD,KAAK,0BAA0BA,CAAS,EACxC,KAAK,2BAA2BA,CAAS,CAC3C,EACA,KAAK,SAAS,eAAiB,KAAK,sBACtC,CAEO,OAAc,CACnB,KAAK,aAAe,EACpB,KAAK,QAAQ,MAAM,EACnB,KAAK,oBAAoB,MAAM,EAC/B,KAAK,oBAAoB,MAAM,CACjC,CAEO,SAAgB,CACrB,KAAK,MAAM,EACP,KAAK,SAAS,iBAAmB,KAAK,yBACxC,KAAK,SAAS,eAAiB,KAAK,wBAExC,CAEO,WAAWE,EAAwBC,EAAgD,CACxF,IAAMC,EAAUF,GAAM,KAAK,eAErBG,EAAe,KAAK,oBAAoB,IAAID,CAAO,EACzD,OAAIC,IAAiB,SACnB,KAAK,SAAS,YAAYA,CAAY,EACtC,KAAK,oBAAoB,OAAOD,CAAO,EACvC,KAAK,oBAAoB,OAAOC,CAAY,GAG1C,CAAC,KAAK,QAAQ,IAAID,CAAO,GAAK,KAAK,QAAQ,MAAQN,EAAkB,kBACvE,KAAK,wBAAwB,EAG/B,KAAK,QAAQ,IAAIM,EAAS,CACxB,GAAGD,EACH,GAAIC,CACN,CAAC,EACMA,CACT,CAEO,SAASH,EAAiBK,EAAwCC,EAAoBC,EAAmBC,EAAsB,CAKpI,IAAMJ,EAAe,KAAK,oBAAoB,IAAIJ,CAAO,EACrDI,IAAiB,QACnB,KAAK,oBAAoB,OAAOA,CAAY,EAE9C,KAAK,cAAc,UAAYE,EAC/B,KAAK,cAAc,MAAQC,EAC3B,KAAK,cAAc,OAASC,EAC5B,IAAMT,EAAY,KAAK,SAAS,SAASM,EAAO,KAAK,aAAa,EAClE,KAAK,oBAAoB,IAAIL,EAASD,CAAS,EAC/C,KAAK,oBAAoB,IAAIA,EAAWC,CAAO,CACjD,CAEO,SAASA,EAA8C,CAC5D,OAAO,KAAK,QAAQ,IAAIA,CAAO,CACjC,CAEO,WAAWA,EAAuB,CACvC,KAAK,QAAQ,OAAOA,CAAO,EAC3B,IAAMD,EAAY,KAAK,oBAAoB,IAAIC,CAAO,EAClDD,IAAc,SAChB,KAAK,SAAS,YAAYA,CAAS,EACnC,KAAK,oBAAoB,OAAOC,CAAO,EACvC,KAAK,oBAAoB,OAAOD,CAAS,EAE7C,CAEO,WAAkB,CACvB,KAAK,QAAQ,MAAM,EACnB,QAAWA,KAAa,KAAK,oBAAoB,OAAO,EACtD,KAAK,SAAS,YAAYA,CAAS,EAErC,KAAK,oBAAoB,MAAM,EAC/B,KAAK,oBAAoB,MAAM,CACjC,CAEA,IAAW,QAA+C,CACxD,OAAO,KAAK,OACd,CAEA,IAAW,oBAAkD,CAC3D,OAAO,KAAK,mBACd,CAEA,IAAW,aAAsB,CAC/B,OAAO,KAAK,aAAe,CAC7B,CAEQ,yBAAgC,CACtC,OAAW,CAACC,CAAO,IAAK,KAAK,QAAS,CACpC,GAAI,KAAK,QAAQ,MAAQH,EAAkB,iBAAmB,EAC5D,MAEG,KAAK,oBAAoB,IAAIG,CAAO,GACvC,KAAK,QAAQ,OAAOA,CAAO,CAE/B,CACF,CACF,EAzIaH,EACa,iBAAmB,IADtC,IAAMY,GAANZ,ECVP,IAAAa,EAA8E,OAI9E,IAAAC,GAAsC,QAGtC,IAAMC,GAAkB,QAGlBC,GAAkB,mBACxBA,GAAgB,IAAI,qBAAmB,EAGhC,IAAMC,GAAN,KAAyD,CAK9D,YACmBC,EACAC,EACAC,EACjB,CAHiB,WAAAF,EACA,cAAAC,EACA,mBAAAC,EAPnB,KAAQ,MAAQ,EAChB,KAAQ,SAAW,MAQjB,iBAAa,CACX,YAAa,KAAK,MAAM,WAAa,EACrC,QAASJ,GACT,aAAc,KAAK,MAAM,iBAC3B,CAAC,EAAE,KAAKK,GAAK,KAAK,KAAOA,CAAC,CAC5B,CAEO,OAAc,CAOf,KAAK,OACP,KAAK,KAAK,QAAQ,EAEjB,KAAK,KAAa,SAAS,KAAK,CAAC,EAClC,KAAK,KAAK,KAAK,EAAGL,GAAiB,KAAK,MAAM,iBAAiB,EAEnE,CAEO,KAAKM,EAAuB,CAGjC,GAFA,KAAK,MAAQ,EACb,KAAK,SAAW,GACZ,KAAK,KAAM,CACb,IAAMC,EAAYD,EAAO,OAAO,CAAC,IAAM,EAAI,EAAIE,GAC7C,KAAK,cAAc,MAAM,cAAc,aACvC,KAAK,cAAc,MAAM,eAAe,MAAM,EAChD,KAAK,KAAK,KAAKD,EAAW,KAAM,KAAK,MAAM,iBAAiB,CAC9D,CACF,CAEO,IAAIE,EAAmBC,EAAeC,EAAmB,CAC9D,GAAI,OAAK,UAAY,CAAC,KAAK,MAI3B,IADA,KAAK,OAASA,EAAMD,EAChB,KAAK,MAAQ,KAAK,MAAM,eAAgB,CAC1C,QAAQ,KAAK,gCAAgC,EAC7C,KAAK,SAAW,GAChB,KAAK,KAAK,QAAQ,EAClB,MACF,CACA,GAAI,CACF,KAAK,KAAK,OAAOD,EAAMC,EAAOC,CAAG,CACnC,OAASC,EAAG,CACV,QAAQ,KAAK,uCAAuCA,CAAC,EAAE,EACvD,KAAK,SAAW,GAChB,KAAK,KAAK,QAAQ,CACpB,EACF,CAEO,OAAOC,EAA8C,CAC1D,GAAI,KAAK,UAAY,CAACA,GAAW,CAAC,KAAK,KACrC,MAAO,GAGT,IAAMC,EAAQ,KAAK,KAAK,MAClBC,EAAS,KAAK,KAAK,OAGzB,GAAI,CAACD,GAAS,CAACC,EACb,OAAIA,GACF,KAAK,SAAS,cAAcA,CAAM,EAE7B,GAGT,IAAMC,EAASC,EAAc,aAAa,OAAWH,EAAOC,CAAM,EAClE,OAAAC,EAAO,WAAW,IAAI,GAAG,aAAa,IAAI,UAAU,KAAK,KAAK,MAAyCF,EAAOC,CAAM,EAAG,EAAG,CAAC,EACvH,KAAK,KAAK,YAAchB,IAC1B,KAAK,KAAK,QAAQ,EAEpB,KAAK,SAAS,SAASiB,CAAM,EACtB,EACT,CACF,EASA,SAASR,GAAgBU,EAAqBC,EAAgD,CAC5F,IAAIC,EAAK,EACT,GAAI,CAACD,EAGH,OAAOC,EAET,GAAIF,EAAK,UAAU,EACjB,GAAIA,EAAK,YAAY,EACnBE,EAAKC,GAAUF,EAAO,WAAW,IAAI,UAC5BD,EAAK,QAAQ,EAAG,CACzB,IAAMI,EAAKJ,EAAK,YAAqC,WAAWA,EAAK,WAAW,CAAC,EACjFE,KAAK,cAAW,GAAGE,CAAC,CACtB,MACEF,EAAKC,GAAUF,EAAO,KAAKD,EAAK,WAAW,CAAC,EAAE,IAAI,UAGhDA,EAAK,YAAY,EACnBE,EAAKC,GAAUF,EAAO,WAAW,IAAI,UAC5BD,EAAK,QAAQ,EAAG,CACzB,IAAMI,EAAKJ,EAAK,YAAqC,WAAWA,EAAK,WAAW,CAAC,EACjFE,KAAK,cAAW,GAAGE,CAAC,CACtB,MACEF,EAAKC,GAAUF,EAAO,KAAKD,EAAK,WAAW,CAAC,EAAE,IAAI,EAGtD,OAAOE,CACT,CAGA,SAASC,GAAUE,EAAyB,CAC1C,OAAI,aAAmBA,GACfA,EAAQ,MAAS,IAAMA,IAAU,EAAI,MAAS,IAAMA,IAAU,GAAK,MAAS,EAAIA,IAAU,GAAK,GACzG,CCtIO,IAAMC,GAAN,KAAwB,CAE7B,YACmBC,EACAC,EACAC,EACAC,EACjB,CAJiB,cAAAH,EACA,WAAAC,EACA,eAAAC,EACA,eAAAC,EALnB,KAAQ,cAA+B,CAAE,UAAW,GAAM,MAAO,MAAO,OAAQ,EAAG,UAAW,OAAQ,CAMnG,CAMI,SAASC,EAA4C,CAC1D,KAAK,cAAc,UAAY,KAAK,MAAM,eAC1C,KAAK,SAAS,SAASA,EAAK,KAAK,aAAa,CAChD,CAOO,cAAcC,EAAsB,CACzC,GAAI,KAAK,MAAM,eAAgB,CAC7B,IAAIC,EAAW,KAAK,UAAU,UAC1BA,EAAS,QAAU,IAAMA,EAAS,SAAW,MAC/CA,EAAWC,GAEb,IAAMC,EAAO,KAAK,KAAKH,EAASC,EAAS,MAAM,EAC/C,QAASG,EAAI,EAAGA,EAAID,EAAM,EAAEC,EAC1B,KAAK,UAAU,MAAM,cAAc,SAAS,CAEhD,CACF,CACF,ECrCO,IAAMC,GAAN,KAAsB,CAE3B,YACmBC,EACjB,CADiB,cAAAA,EAFnB,KAAQ,cAA+B,CAAE,UAAW,GAAM,MAAO,MAAO,OAAQ,EAAG,UAAW,KAAM,CAGjG,CAMI,SAASC,EAA4C,CAC1D,KAAK,SAAS,SAASA,EAAK,KAAK,aAAa,CAChD,CACF,ECiCA,IAAMC,GAAsC,CAC1C,kBAAmB,GACnB,WAAY,SACZ,aAAc,GACd,eAAgB,GAChB,kBAAmB,KACnB,eAAgB,SAChB,aAAc,IACd,gBAAiB,GACjB,WAAY,GACZ,aAAc,SACd,aAAc,GACd,eAAgB,QAClB,EAGMC,GAAyB,KAsBxB,IAAMC,GAAN,KAAsD,CAW3D,YAAYC,EAAoC,CANhD,KAAQ,aAA8B,CAAC,EAEvC,KAAQ,UAAwC,IAAI,IACpD,KAAiB,cAAgB,IAAIC,EACrC,KAAgB,aAA6B,KAAK,cAAc,MAG9D,KAAK,MAAQ,OAAO,OAAO,CAAC,EAAGC,GAAiBF,CAAI,EACpD,KAAK,aAAe,OAAO,OAAO,CAAC,EAAGE,GAAiBF,CAAI,CAC7D,CAEO,SAAgB,CACrB,QAAWG,KAAO,KAAK,aACrBA,EAAI,QAAQ,EAEd,KAAK,aAAa,OAAS,EAC3B,KAAK,UAAU,MAAM,EACrB,KAAK,cAAc,QAAQ,CAC7B,CAEQ,iBAAiBC,EAA2B,CAClD,QAAWD,KAAOC,EAChB,KAAK,aAAa,KAAKD,CAAG,CAE9B,CAEO,SAASE,EAA8B,CAS5C,GARA,KAAK,UAAYA,EAGjB,KAAK,UAAY,IAAIC,EAAcD,CAAQ,EAC3C,KAAK,SAAW,IAAIE,EAAaF,EAAU,KAAK,UAAW,KAAK,KAAK,EACrE,KAAK,SAAS,aAAe,IAAM,KAAK,cAAc,KAAK,EAGvD,KAAK,MAAM,kBAAmB,CAChC,IAAMG,EAAYH,EAAS,QAAQ,eAAiB,CAAC,EACrDG,EAAU,iBAAmB,GAC7BA,EAAU,kBAAoB,GAC9BA,EAAU,gBAAkB,GAC5BH,EAAS,QAAQ,cAAgBG,CACnC,CAiCA,GA/BA,KAAK,cACH,KAAK,UACL,KAAK,SAGLH,EAAS,OAAO,mBAAmB,CAAE,OAAQ,IAAK,MAAO,GAAI,EAAGI,GAAU,KAAK,QAAQA,CAAM,CAAC,EAC9FJ,EAAS,OAAO,mBAAmB,CAAE,OAAQ,IAAK,MAAO,GAAI,EAAGI,GAAU,KAAK,QAAQA,CAAM,CAAC,EAC9FJ,EAAS,OAAO,mBAAmB,CAAE,MAAO,GAAI,EAAGI,GAAU,KAAK,KAAKA,CAAM,CAAC,EAC9EJ,EAAS,OAAO,mBAAmB,CAAE,OAAQ,IAAK,MAAO,GAAI,EAAGI,GAAU,KAAK,yBAAyBA,CAAM,CAAC,EAG/GJ,EAAS,SAASK,GAAS,KAAK,UAAU,OAAOA,CAAK,CAAC,EAQvDL,EAAS,OAAO,mBAAmB,CAAE,cAAe,IAAK,MAAO,GAAI,EAAG,IAAM,KAAK,MAAM,CAAC,EACzFA,EAAS,OAAO,mBAAmB,CAAE,MAAO,GAAI,EAAG,IAAM,KAAK,MAAM,CAAC,EACrEA,EAAS,MAAM,cAAc,eAAe,IAAM,KAAK,MAAM,CAAC,EAG9DA,EAAS,OAAO,eAAe,IAAM,KAAK,UAAU,cAAc,CAAC,EAGnEA,EAAS,SAASM,GAAW,KAAK,UAAU,eAAeA,CAAO,CAAC,CACrE,EAGI,KAAK,MAAM,aAAc,CAC3B,IAAMC,EAAe,IAAIC,GAAkB,KAAK,SAAW,KAAK,MAAO,KAAK,UAAYR,CAAQ,EAC1FS,EAAe,IAAIC,GAAa,KAAK,MAAOH,EAAcP,CAAQ,EACxE,KAAK,UAAU,IAAI,QAASS,CAAY,EACxC,KAAK,cACHT,EAAS,MAAM,cAAc,QAAQ,mBAAmB,CAAE,MAAO,GAAI,EAAGS,CAAY,CACtF,CACF,CAGA,GAAI,KAAK,MAAM,WAAY,CACzB,IAAME,EAAa,IAAIC,GAAgB,KAAK,QAAS,EAC/CC,EAAa,IAAIC,EAAW,KAAK,MAAO,KAAK,UAAYH,EAAYX,CAAQ,EACnF,KAAK,UAAU,IAAI,MAAOa,CAAU,EACpC,KAAK,cACHb,EAAS,MAAM,cAAc,QAAQ,mBAAmB,KAAMa,CAAU,CAC1E,CACF,CAGA,GAAI,KAAK,MAAM,aAAc,CAC3B,IAAME,EAAe,IAAIC,GAAkB,KAAK,QAAS,EACnDC,EAAe,IAAIC,GAAqB,KAAK,MAAO,KAAK,UAAYH,EAAcf,CAAQ,EACjG,KAAK,UAAU,IAAI,QAASiB,CAAY,EACxC,KAAK,cACHF,EACAE,EACAjB,EAAS,MAAM,cAAc,QAAQ,mBAAmB,CAAE,MAAO,GAAI,EAAGiB,CAAY,CACtF,CACF,CACF,CAGO,OAAiB,CAEtB,KAAK,MAAM,eAAiB,KAAK,aAAa,eAC9C,KAAK,MAAM,kBAAoB,KAAK,aAAa,kBAEjD,KAAK,UAAU,MAAM,EAErB,QAAWE,KAAW,KAAK,UAAU,OAAO,EAC1CA,EAAQ,MAAM,EAEhB,MAAO,EACT,CAEA,IAAW,cAAuB,CAChC,OAAO,KAAK,UAAU,SAAS,GAAK,EACtC,CAEA,IAAW,aAAaC,EAAe,CACrC,KAAK,UAAU,SAASA,CAAK,EAC7B,KAAK,MAAM,aAAeA,CAC5B,CAEA,IAAW,cAAuB,CAChC,OAAI,KAAK,SACA,KAAK,SAAS,SAAS,EAEzB,EACT,CAEA,IAAW,iBAA2B,CACpC,OAAO,KAAK,MAAM,eACpB,CAEA,IAAW,gBAAgBC,EAAgB,CACzC,KAAK,MAAM,gBAAkBA,EAC7B,KAAK,WAAW,gBAAgBA,CAAK,CACvC,CAEO,qBAAqBC,EAAWC,EAA0C,CAC/E,OAAO,KAAK,UAAU,qBAAqBD,EAAGC,CAAC,CACjD,CAEO,wBAAwBD,EAAWC,EAA0C,CAClF,OAAO,KAAK,UAAU,wBAAwBD,EAAGC,CAAC,CACpD,CAEQ,QAAQC,EAAiB,CAC/B,KAAK,WAAW,MAAM,MAAMA,EAAG,EAAK,CACtC,CAEQ,QAAQpB,EAAwC,CACtD,QAASqB,EAAI,EAAGA,EAAIrB,EAAO,OAAQ,EAAEqB,EAC3BrB,EAAOqB,CAAC,IACT,KACH,KAAK,MAAM,eAAiB,IAIlC,MAAO,EACT,CAEQ,QAAQrB,EAAwC,CACtD,QAASqB,EAAI,EAAGA,EAAIrB,EAAO,OAAQ,EAAEqB,EAC3BrB,EAAOqB,CAAC,IACT,KACH,KAAK,MAAM,eAAiB,IAIlC,MAAO,EACT,CAGQ,KAAKrB,EAAwC,CACnD,OAAIA,EAAO,CAAC,EACH,GAOL,KAAK,MAAM,cACb,KAAK,QAAQ,kBAAkB,EACxB,IAEF,EACT,CAYQ,yBAAyBA,EAAwC,CACvE,GAAIA,EAAO,OAAS,EAClB,MAAO,GAET,GAAIA,EAAO,CAAC,IAAM,EAChB,OAAQA,EAAO,CAAC,EAAG,CACjB,IAAK,GACH,YAAK,QAAQ,SAASA,EAAO,CAAC,CAAC,MAAwB,KAAK,MAAM,iBAAiB,GAAG,EAC/E,GACT,IAAK,GACH,KAAK,MAAM,kBAAoB,KAAK,aAAa,kBACjD,KAAK,QAAQ,SAASA,EAAO,CAAC,CAAC,MAAwB,KAAK,MAAM,iBAAiB,GAAG,EAEtF,QAAWe,KAAW,KAAK,UAAU,OAAO,EAC1CA,EAAQ,MAAM,EAEhB,MAAO,GACT,IAAK,GACH,OAAIf,EAAO,OAAS,GAAK,EAAEA,EAAO,CAAC,YAAa,QAAUA,EAAO,CAAC,GAAKsB,IACrE,KAAK,MAAM,kBAAoBtB,EAAO,CAAC,EACvC,KAAK,QAAQ,SAASA,EAAO,CAAC,CAAC,MAAwB,KAAK,MAAM,iBAAiB,GAAG,GAEtF,KAAK,QAAQ,SAASA,EAAO,CAAC,CAAC,KAA4B,EAEtD,GACT,IAAK,GACH,YAAK,QAAQ,SAASA,EAAO,CAAC,CAAC,MAAwBsB,EAAsB,GAAG,EACzE,GACT,QACE,YAAK,QAAQ,SAAStB,EAAO,CAAC,CAAC,KAA4B,EACpD,EACX,CAEF,GAAIA,EAAO,CAAC,IAAM,EAChB,OAAQA,EAAO,CAAC,EAAG,CAEjB,IAAK,GACH,IAAIuB,EAAQ,KAAK,WAAW,YAAY,IAAI,OAAO,MAC/CC,EAAS,KAAK,WAAW,YAAY,IAAI,OAAO,OACpD,GAAI,CAACD,GAAS,CAACC,EAAQ,CAGrB,IAAMC,EAAWC,EACjBH,GAAS,KAAK,WAAW,MAAQ,IAAME,EAAS,MAChDD,GAAU,KAAK,WAAW,MAAQ,IAAMC,EAAS,MACnD,CACA,GAAIF,EAAQC,EAAS,KAAK,MAAM,WAC9B,KAAK,QAAQ,SAASxB,EAAO,CAAC,CAAC,MAAwBuB,EAAM,QAAQ,CAAC,CAAC,IAAIC,EAAO,QAAQ,CAAC,CAAC,GAAG,MAC1F,CAEL,IAAMN,EAAI,KAAK,MAAM,KAAK,KAAK,KAAK,MAAM,UAAU,CAAC,EACrD,KAAK,QAAQ,SAASlB,EAAO,CAAC,CAAC,MAAwBkB,CAAC,IAAIA,CAAC,GAAG,CAClE,CACA,MAAO,GACT,IAAK,GAEH,IAAMA,EAAI,KAAK,MAAM,KAAK,KAAK,KAAK,MAAM,UAAU,CAAC,EACrD,YAAK,QAAQ,SAASlB,EAAO,CAAC,CAAC,MAAwBkB,CAAC,IAAIA,CAAC,GAAG,EACzD,GACT,QACE,YAAK,QAAQ,SAASlB,EAAO,CAAC,CAAC,KAA4B,EACpD,EACX,CAGF,YAAK,QAAQ,SAASA,EAAO,CAAC,CAAC,KAA0B,EAClD,EACT,CACF", - "names": ["exports", "red", "n", "green", "blue", "alpha", "toRGBA8888", "g", "b", "a", "fromRGBA8888", "color", "nearestColorIndex", "palette", "r", "min", "idx", "i", "dr", "dg", "db", "d", "clamp", "low", "high", "value", "h2c", "t1", "t2", "c", "HLStoRGB", "h", "l", "s", "v", "normalizeRGB", "normalizeHLS", "p", "require_lib", "__commonJSMin", "exports", "InWasm", "z", "s", "b", "r", "def", "t", "d", "m", "W", "e", "require_Base64Decoder_wasm", "__commonJSMin", "exports", "inwasm_runtime_1", "wasmDecode", "MAP", "el", "D", "i", "EMPTY", "Base64Decoder", "keepSize", "maxBytes", "initialBytes", "m", "bytes", "requested", "needed", "newSize", "addPages", "data", "require_QoiDecoder_wasm", "__commonJSMin", "exports", "inwasm_runtime_1", "wasmQoiDecode", "QoiDecoder", "keepSize", "d", "pixels", "ib", "dl", "bytes", "chunkP", "exports", "Colors_1", "wasm_1", "decodeBase64", "s", "bytestring", "result", "WASM_BYTES", "WASM_MODULE", "NULL_CANVAS", "CallbackProxy", "width", "mode", "DEFAULT_OPTIONS", "DecoderAsync", "opts", "cbProxy", "importObj", "inst", "Decoder", "exports", "_instance", "_cbProxy", "module", "pixels", "offset", "additionalPixels", "newCanvas", "adv", "remaining", "c", "i", "fillColor", "palette", "paletteLimit", "truncate", "data", "start", "end", "p", "length", "j", "currentWidth", "escape", "final", "finalOffset", "bw", "currentHeight", "decode", "dec", "decodeAsync", "toDisposable", "fn", "DisposableStore", "o", "d", "Disposable", "MutableDisposable", "value", "Emitter", "listener", "thisArgs", "disposables", "toDisposable", "entry", "result", "idx", "event", "listeners", "len", "EventUtils", "forward", "from", "to", "e", "map", "i", "any", "events", "store", "DisposableStore", "runAndSubscribe", "handler", "initial", "import_Colors", "ImageRenderer", "_ImageRenderer", "Disposable", "_terminal", "MutableDisposable", "parent", "option", "toDisposable", "localDocument", "width", "height", "canvas", "ctx", "buffer", "imgData", "img", "value", "start", "end", "layer", "y", "w", "h", "imgSpec", "tileId", "col", "row", "count", "cols", "sx", "sy", "dx", "dy", "finalWidth", "finalHeight", "spec", "currentWidth", "currentHeight", "originalWidth", "originalHeight", "renderer", "key", "screenElement", "bWidth", "blueprint", "d32", "black", "white", "shift", "offset", "x", "ctx2", "i", "bitmap", "CELL_SIZE_DEFAULT", "ExtendedAttrsImage", "_ExtendedAttrsImage", "ext", "urlId", "imageId", "tileId", "value", "val", "EMPTY_ATTRS", "ImageStorage", "_terminal", "_renderer", "_opts", "e", "spec", "storedPixels", "id", "zero", "img", "opts", "cellSize", "cols", "rows", "buffer", "termCols", "termRows", "originX", "originY", "offset", "tileCount", "row", "line", "col", "endMarker", "imgSpec", "range", "hasTopImages", "hasBottomImages", "start", "end", "drawCalls", "placeholderCalls", "startTile", "startCol", "count", "a", "b", "call", "metrics", "oldCol", "tilesPerRow", "hasData", "rightCol", "lastTile", "expandCol", "x", "y", "orig", "canvas", "ImageRenderer", "room", "used", "current", "old", "oldSpec", "imgId", "import_Base64Decoder", "import_QoiDecoder", "toStr", "data", "s", "i", "toInt", "v", "toSize", "toName", "bs", "b", "DECODERS", "FILE_MARKER", "MULTIPARTFILE_MARKER", "FILEPART_MARKER", "FILEEND_MARKER", "REPORTCELLSIZE_MARKER", "MAX_FIELDCHARS", "HeaderParser", "k", "start", "end", "state", "pos", "buffer", "c", "UNSUPPORTED_TYPE", "imageType", "d", "d32", "width", "height", "jpgSize", "dim", "pos", "limit", "i", "len", "blockLength", "DEFAULT_HEADER", "IIPHandler", "_opts", "_renderer", "_storage", "_coreTerminal", "HeaderParser", "maxEncodedBytes", "initialBytes", "Base64Decoder", "QoiDecoder", "data", "start", "end", "dataPos", "success", "seqType", "w", "CELL_SIZE_DEFAULT", "h", "scale", "report", "cond", "metrics", "UNSUPPORTED_TYPE", "imageType", "blob", "canvas", "ImageRenderer", "bm", "e", "cw", "ch", "width", "height", "rw", "rh", "wf", "hf", "f", "s", "total", "cdim", "import_Base64Decoder", "parseKittyCommand", "data", "cmd", "parts", "part", "eqIdx", "key", "value", "numValue", "DECODER_OK", "KittyGraphicsHandler", "_opts", "_renderer", "_kittyStorage", "_coreTerminal", "key", "pending", "data", "start", "end", "controlEnd", "i", "copyLength", "parseKittyCommand", "payloadStart", "pendingKey", "previousEncodedSize", "decoderToRelease", "Base64Decoder", "success", "cmd", "isMoreComing", "decodeError", "finalCmd", "decoder", "imageBytes", "result", "str", "bytes", "id", "image", "quiet", "format", "width", "height", "bytesPerPixel", "expectedBytes", "message", "placementId", "isOk", "pPart", "response", "bitmap", "cropX", "cropY", "cropW", "cropH", "maxCropW", "maxCropH", "finalCropW", "finalCropH", "cropped", "cw", "CELL_SIZE_DEFAULT", "ch", "imgCols", "imgRows", "w", "h", "buffer", "savedX", "savedY", "savedYbase", "layer", "scaled", "xOffset", "yOffset", "canvasW", "canvasH", "offsetCanvas", "ImageRenderer", "offsetCtx", "offsetBitmap", "zIndex", "scrolled", "e", "blob", "url", "img", "resolve", "reject", "canvas", "pixelCount", "src32", "dst32", "alignedPixels", "srcOffset", "dstOffset", "b0", "b1", "b2", "srcByte", "dstByte", "compressed", "ds", "writer", "chunks", "reader", "done", "value", "totalLength", "sum", "chunk", "offset", "_KittyImageStorage", "_storage", "storageId", "kittyId", "id", "imageData", "imageId", "oldStorageId", "image", "scrolling", "layer", "zIndex", "KittyImageStorage", "import_Colors", "import_Decoder", "MEM_PERMA_LIMIT", "DEFAULT_PALETTE", "SixelHandler", "_opts", "_storage", "_coreTerminal", "d", "params", "fillColor", "extractActiveBg", "data", "start", "end", "e", "success", "width", "height", "canvas", "ImageRenderer", "attr", "colors", "bg", "convertLe", "t", "color", "SixelImageStorage", "_storage", "_opts", "_renderer", "_terminal", "img", "height", "cellSize", "CELL_SIZE_DEFAULT", "rows", "i", "IIPImageStorage", "_storage", "img", "DEFAULT_OPTIONS", "MAX_SIXEL_PALETTE_SIZE", "ImageAddon", "opts", "Emitter", "DEFAULT_OPTIONS", "obj", "args", "terminal", "ImageRenderer", "ImageStorage", "windowOps", "params", "range", "metrics", "sixelStorage", "SixelImageStorage", "sixelHandler", "SixelHandler", "iipStorage", "IIPImageStorage", "iipHandler", "IIPHandler", "kittyStorage", "KittyImageStorage", "kittyHandler", "KittyGraphicsHandler", "handler", "limit", "value", "x", "y", "s", "i", "MAX_SIXEL_PALETTE_SIZE", "width", "height", "cellSize", "CELL_SIZE_DEFAULT"] -+ "sourcesContent": [null, "\"use strict\";\n/**\n * Copyright (c) 2022, 2026 Joerg Breitbart\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.InWasm = InWasm;\nlet z = (s) => {\n if (Uint8Array.fromBase64)\n return Uint8Array.fromBase64(s);\n if (typeof Buffer !== 'undefined')\n return Buffer.from(s, 'base64');\n const b = atob(s);\n const r = new Uint8Array(b.length);\n for (let i = 0; i < r.length; ++i)\n r[i] = b.charCodeAt(i);\n return r;\n};\nfunction InWasm(def) {\n if (def.d) {\n const { t, s, d } = def;\n let b;\n let m;\n const W = WebAssembly;\n if (t === 0 /* OutputType.INSTANCE */) {\n if (s)\n return (e) => new W.Instance(m || (m = new W.Module(b || (b = z(d)))), e);\n return (e) => m\n ? W.instantiate(m, e)\n : W.instantiate(b || (b = z(d)), e).then(r => (m = r.module) && r.instance);\n }\n if (t === 1 /* OutputType.MODULE */) {\n if (s)\n return () => m || (m = new W.Module(b || (b = z(d))));\n return () => m\n ? Promise.resolve(m)\n : W.compile(b || (b = z(d))).then(r => m = r);\n }\n if (s)\n return () => b || (b = z(d));\n return () => Promise.resolve(b || (b = z(d)));\n }\n if (typeof _wasmCtx === 'undefined')\n throw new Error('must run \"inwasm\"');\n _wasmCtx.add(def);\n}\n//# sourceMappingURL=index.js.map", "\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023, 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\n/**\n * wasm base64 decoder.\n */\nconst wasmDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#828e69684093b2c6:rdef-start:\"decode\"*/{s:1,t:0,d:'AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML'}/*inwasm#828e69684093b2c6:rdef-end:\"decode\"*/);\n// SIMD version (speedup ~1.4x, not covered by tests yet)\n/*\nconst wasmDecode = InWasm({\n name: 'decode',\n type: OutputType.INSTANCE,\n mode: OutputMode.SYNC,\n srctype: 'Clang-C',\n imports: {\n env: { memory: new WebAssembly.Memory({ initial: 1 }) }\n },\n exports: {\n dec: () => 0,\n end: () => 0\n },\n compile: {\n switches: ['-msimd128', '-Wl,-z,stack-size=0', '-Wl,--stack-first']\n },\n code: `\n #include \n typedef struct {\n unsigned int wp;\n unsigned int sp;\n unsigned int dp;\n unsigned int e_size;\n unsigned int dummy[4];\n unsigned char data[0];\n } State;\n\n unsigned int *D0 = (unsigned int *) ${P32.D0 * 4};\n unsigned int *D1 = (unsigned int *) ${P32.D1 * 4};\n unsigned int *D2 = (unsigned int *) ${P32.D2 * 4};\n unsigned int *D3 = (unsigned int *) ${P32.D3 * 4};\n State *state = (State *) ${P32.STATE * 4};\n\n #define packed_byte(x) wasm_i8x16_splat((char) x)\n #define packed_dword(x) wasm_i32x4_splat(x)\n #define masked(x, mask) wasm_v128_and(x, wasm_i32x4_splat(mask))\n\n __attribute__((noinline)) int dec() {\n unsigned int nsp = (state->wp - 1) & ~3;\n unsigned char *src = state->data + state->sp;\n unsigned char *end = state->data + nsp;\n unsigned char *dst = state->data + state->dp;\n unsigned int error = 0;\n\n v128_t err = wasm_i8x16_splat(0);\n unsigned char *end16 = state->data + (nsp & ~15);\n while (src < end16) {\n v128_t data = wasm_v128_load((v128_t *) src);\n\n // wasm-simd rewrite of http://0x80.pl/notesen/2016-01-17-sse-base64-decoding.html#vector-lookup-pshufb\n const v128_t higher_nibble = wasm_u32x4_shr(data, 4) & packed_byte(0x0f);\n const char linv = 1;\n const char hinv = 0;\n\n const v128_t lower_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n linv, linv, 0x2b, 0x30,\n 0x41, 0x50, 0x61, 0x70,\n linv, linv, linv, linv,\n linv, linv, linv, linv\n );\n const v128_t upper_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n hinv, hinv, 0x2b, 0x39,\n 0x4f, 0x5a, 0x6f, 0x7a,\n hinv, hinv, hinv, hinv,\n hinv, hinv, hinv, hinv\n );\n // the difference between the shift and lower bound\n const v128_t shift_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n 0x00, 0x00, 0x3e - 0x2b, 0x34 - 0x30,\n 0x00 - 0x41, 0x0f - 0x50, 0x1a - 0x61, 0x29 - 0x70,\n 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00\n );\n\n const v128_t upper_bound = wasm_i8x16_swizzle(upper_bound_LUT, higher_nibble);\n const v128_t lower_bound = wasm_i8x16_swizzle(lower_bound_LUT, higher_nibble);\n\n const v128_t below = wasm_i8x16_lt(data, lower_bound);\n const v128_t above = wasm_i8x16_gt(data, upper_bound);\n const v128_t eq_2f = wasm_i8x16_eq(data, packed_byte(0x2f));\n\n // in_range = not (below or above) or eq_2f\n // outside = not in_range = below or above and not eq_2f (from deMorgan law)\n const v128_t outside = wasm_v128_andnot(eq_2f, above | below);\n err = wasm_v128_or(err, outside);\n\n const v128_t shift = wasm_i8x16_swizzle(shift_LUT, higher_nibble);\n const v128_t t0 = wasm_i8x16_add(data, shift);\n v128_t v = wasm_i8x16_add(t0, wasm_v128_and(eq_2f, packed_byte(-3)));\n\n // pack bytes\n const v128_t ca = masked(v, 0x003f003f);\n const v128_t db = masked(v, 0x3f003f00);\n const v128_t t00 = wasm_v128_or(wasm_u32x4_shr(db, 8), wasm_i32x4_shl(ca, 6));\n v128_t res = wasm_v128_or(wasm_u32x4_shr(t00, 16), wasm_i32x4_shl(t00, 12));\n res = wasm_i8x16_swizzle(res, wasm_i8x16_const(2, 1, 0, 6, 5, 4, 10, 9, 8, 14, 13, 12, 16, 16, 16, 16));\n\n wasm_v128_store((v128_t *) dst, res);\n dst += 12;\n src += 16;\n }\n //if (wasm_i8x16_bitmask(err) != 0) return -1;\n if (wasm_v128_any_true(err)) return -1;\n\n // operate on 4-byte blocks\n while (src < end) {\n error |= *((unsigned int *) dst) = D0[src[0]] | D1[src[1]] | D2[src[2]] | D3[src[3]];\n dst += 3;\n src += 4;\n }\n if (error >> 24) return -1;\n state->sp = nsp;\n state->dp = dst - state->data;\n return 0;\n }\n\n int end() {\n int rem = state->wp - state->sp;\n if (rem > 4 && dec()) return -1;\n rem = state->wp - state->sp;\n if (rem < 2) return -1;\n\n unsigned char *src = state->data + state->sp;\n if (rem == 4) {\n if (src[3] == 61) rem--;\n if (src[2] == 61) rem--;\n }\n unsigned int accu = D0[src[0]] | D1[src[1]];\n int dp = 1;\n if (rem > 2) {\n accu |= D2[src[2]];\n dp++;\n if (rem == 4) {\n accu |= D3[src[3]];\n dp++;\n }\n }\n if (accu >> 24) return -1;\n *((unsigned int *) (state->data + state->dp)) = accu;\n state->dp += dp;\n return 0;\n }\n `\n});\n*/\n// base64 map\nconst MAP = new Uint8Array('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'\n .split('')\n .map(el => el.charCodeAt(0)));\n// init decoder maps in LE order\nconst D = new Uint32Array(1024);\nD.fill(0xFF000000);\nfor (let i = 0; i < MAP.length; ++i)\n D[MAP[i]] = i << 2;\nfor (let i = 0; i < MAP.length; ++i)\n D[256 + MAP[i]] = i >> 4 | ((i << 4) & 0xFF) << 8;\nfor (let i = 0; i < MAP.length; ++i)\n D[512 + MAP[i]] = (i >> 2) << 8 | ((i << 6) & 0xFF) << 16;\nfor (let i = 0; i < MAP.length; ++i)\n D[768 + MAP[i]] = i << 16;\nconst EMPTY = new Uint8Array(0);\n/**\n * base64 stream decoder.\n *\n * Features / assumptions:\n * - lazy chunkwise decoding\n * - errors out on any non base64 chars (no support for NL formatted base64)\n * - decodes in wasm\n * - inplace decoding to save memory\n * - supports a keepSize for lazy memory release\n */\nclass Base64Decoder {\n /**\n * @param keepSize Keep the wasm instance below this limit when calling `release()`.\n * @param maxBytes Max allowed bytes to allocate.\n * @param initialBytes Initial bytes to allocate.\n */\n constructor(keepSize, maxBytes, initialBytes) {\n this._inst = null;\n this._ended = true;\n this._bytes = 0;\n this.keepSize = keepSize !== null && keepSize !== void 0 ? keepSize : 1048576 /* Bytes.KEEP */;\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : 4294901760 /* Bytes.MAX */;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : 32768 /* Bytes.INITIAL */;\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw new Error('invalid byte settings');\n }\n }\n /**\n * Currently decoded bytes (borrowed).\n * Must be accessed before calling `release` or `init`.\n */\n get data8() {\n return this._inst ? this._d.subarray(0, this._m32[1282 /* P32.STATE_DP */]) : EMPTY;\n }\n /**\n * Release memory conditionally based on `keepSize`.\n * If memory gets released, also the wasm instance will be freed and recreated on next `init`,\n * otherwise the instance will be reused.\n */\n release() {\n if (!this._inst)\n return;\n if (this._bytes > this.keepSize) {\n this._inst = this._m32 = this._d = this._mem = null;\n }\n else {\n this._m32[1280 /* P32.STATE_WP */] = 0;\n this._m32[1281 /* P32.STATE_SP */] = 0;\n this._m32[1282 /* P32.STATE_DP */] = 0;\n }\n }\n /**\n * Initializes the decoder for new base64 data.\n * Must be called before doing any decoding attempts.\n * The method will either spawn a new wasm instance or grow\n * the needed memory of an existing instance.\n * @param maxBytes Max allowed bytes to allocate (overwrites ctor value).\n * @param initialBytes Initial bytes to allocate (overwrites ctor value).\n */\n init(maxBytes, initialBytes) {\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : this.maxBytes;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : Math.min(this._bytes, this.maxBytes);\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw Error('invalid byte settings');\n }\n let m = this._m32;\n const bytes = this._bytes + 5152 /* Bytes._DATA_OFFSET */;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmDecode({ env: { memory: this._mem } });\n m = new Uint32Array(this._mem.buffer, 0);\n m.set(D, 256 /* P32.D0 */);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n m = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n m[1280 /* P32.STATE_WP */] = 0;\n m[1281 /* P32.STATE_SP */] = 0;\n m[1282 /* P32.STATE_DP */] = 0;\n this._m32 = m;\n this._ended = false;\n }\n /**\n * Realloc memory. Realloc only happens, if the requested\n * size doesn't fit in the current memory.\n * The new size will be capped by `maxBytes`.\n * @param requested Bytes to be stored.\n */\n _realloc(requested) {\n const needed = this._m32[1280 /* P32.STATE_WP */] + requested;\n if (this._bytes < needed) {\n if (needed > this.maxBytes) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n let newSize = this._bytes;\n while ((newSize *= 2) < needed) { }\n newSize = Math.min(newSize, this.maxBytes);\n if (newSize < needed) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n if (newSize + 5152 /* Bytes._DATA_OFFSET */ > this._mem.buffer.byteLength) {\n const addPages = Math.ceil((newSize + 5152 /* Bytes._DATA_OFFSET */ - this._mem.buffer.byteLength) / 65536);\n this._mem.grow(addPages);\n this._m32 = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n this._bytes = newSize;\n }\n return 0 /* DecodeStatus.OK */;\n }\n /**\n * Put bytes in `data` into the decoder.\n * Additionally decodes the payload, if it reached 2^17 bytes.\n * The return value indicates the type of issue.\n * @param data Bytes to be loaded.\n */\n put(data) {\n if (!this._inst || this._ended) {\n return -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n if (this._realloc(data.length)) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n const m = this._m32;\n this._d.set(data, m[1280 /* P32.STATE_WP */]);\n m[1280 /* P32.STATE_WP */] += data.length;\n // max chunk in input handler is 2^17, try to run in \"tandem mode\"\n return m[1280 /* P32.STATE_WP */] - m[1281 /* P32.STATE_SP */] >= 131072\n ? this._inst.exports.dec()\n : 0 /* DecodeStatus.OK */;\n }\n /**\n * End the current decoding.\n * Also decodes leftover payload from previous put calls.\n */\n end() {\n this._ended = true;\n return this._inst\n ? this._inst.exports.end()\n : -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n /**\n * Bytes loaded into the decoder.\n */\n get loadedBytes() {\n return this._inst\n ? this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n /**\n * Free bytes to feed to the decoder.\n */\n get freeBytes() {\n return this._inst\n ? this.maxBytes - this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n}\nexports.default = Base64Decoder;\n//# sourceMappingURL=Base64Decoder.wasm.js.map", "\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\nconst wasmQoiDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#459f9e1bfb80b1a8:rdef-start:\"qoi_decode\"*/{s:1,t:0,d:'AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=='}/*inwasm#459f9e1bfb80b1a8:rdef-end:\"qoi_decode\"*/);\nclass QoiDecoder {\n constructor(keepSize) {\n this.keepSize = keepSize;\n this.width = 0;\n this.height = 0;\n }\n decode(d) {\n this.width = d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7];\n this.height = d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11];\n const pixels = this.width * this.height;\n const ib = pixels * 4;\n const dl = d.length;\n /**\n * byte/offset calculation:\n * To save some memory we dont reserve full memory for decoded + encoded,\n * but place encoded at the end of decoded plus 50% security distance\n * to avoid reads before writes positions:\n *\n * encoded < decoded (good compression)\n * enc ####################\n * dec #######################################\n * ^ ^\n * DST_P CHUNK_P\n *\n * encoded > decoded (degenerated compression, should not happen)\n * enc ##############################\n * dec ####################\n * ^ ^\n * DST_P CHUNK_P\n *\n * There is still a chance for overlapping r/w positions in case the compressed\n * data has very different pixel progression, yet the 50% security distance\n * should deal with that, as QOI will bloat data by 25% at max (RGB -> OP byte + RGB).\n * Since we always assume RGBA at decoding stage, the possible bloat reduces to 20% at max.\n */\n const bytes = Math.max(ib, dl) + (Math.min(ib, dl) >> 1) + 4096;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmQoiDecode({ env: { memory: this._mem } });\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n this._d = null;\n }\n if (!this._d) {\n this._d = new Uint8Array(this._mem.buffer);\n }\n // put src data at the end of memory, also align to 256\n const chunkP = (this._mem.buffer.byteLength - dl) & ~0xFF;\n this._d.set(d, chunkP);\n this._inst.exports.dec(chunkP, dl, pixels);\n return this._d.subarray(1024 /* P8.DST_P */, 1024 /* P8.DST_P */ + ib);\n }\n release() {\n if (!this._inst)\n return;\n if (this._mem.buffer.byteLength > this.keepSize) {\n this._inst = this._d = this._mem = null;\n }\n }\n}\nexports.default = QoiDecoder;\n//# sourceMappingURL=QoiDecoder.wasm.js.map", null, null, "/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal lifecycle utilities for xterm.js core.\n * Simplified from VS Code's lifecycle.ts - no tracking/leak detection.\n */\n\nexport interface IDisposable {\n dispose(): void;\n}\n\nexport function toDisposable(fn: () => void): IDisposable {\n return { dispose: fn };\n}\n\nexport function dispose(disposable: T): T;\nexport function dispose(disposable: T | undefined): T | undefined;\nexport function dispose(disposables: T[]): T[];\nexport function dispose(arg: T | T[] | undefined): T | T[] | undefined {\n if (!arg) {\n return arg;\n }\n if (Array.isArray(arg)) {\n for (const d of arg) {\n d.dispose();\n }\n return [];\n }\n arg.dispose();\n return arg;\n}\n\nexport function combinedDisposable(...disposables: IDisposable[]): IDisposable {\n return toDisposable(() => dispose(disposables));\n}\n\nexport class DisposableStore implements IDisposable {\n private readonly _disposables = new Set();\n private _isDisposed = false;\n\n public get isDisposed(): boolean {\n return this._isDisposed;\n }\n\n public add(o: T): T {\n if (this._isDisposed) {\n o.dispose();\n } else {\n this._disposables.add(o);\n }\n return o;\n }\n\n public dispose(): void {\n if (this._isDisposed) {\n return;\n }\n this._isDisposed = true;\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n\n public clear(): void {\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n}\n\nexport abstract class Disposable implements IDisposable {\n public static readonly None: IDisposable = Object.freeze({ dispose() { } });\n\n protected readonly _store = new DisposableStore();\n\n public dispose(): void {\n this._store.dispose();\n }\n\n protected _register(o: T): T {\n return this._store.add(o);\n }\n}\n\nexport class MutableDisposable implements IDisposable {\n private _value: T | undefined;\n private _isDisposed = false;\n\n public get value(): T | undefined {\n return this._isDisposed ? undefined : this._value;\n }\n\n public set value(value: T | undefined) {\n if (this._isDisposed || value === this._value) {\n return;\n }\n this._value?.dispose();\n this._value = value;\n }\n\n public clear(): void {\n this.value = undefined;\n }\n\n public dispose(): void {\n this._isDisposed = true;\n this._value?.dispose();\n this._value = undefined;\n }\n}\n", "/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal event utilities for xterm.js core.\n * Simplified from VS Code's event.ts - no leak detection/profiling.\n */\n\nimport { IDisposable, DisposableStore, toDisposable } from './Lifecycle';\n\nexport interface IEvent {\n (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable;\n}\n\nexport class Emitter {\n private _listeners: { fn: (e: T) => any, thisArgs: any }[] = [];\n private _disposed = false;\n private _event: IEvent | undefined;\n\n public get event(): IEvent {\n if (this._event) {\n return this._event;\n }\n this._event = (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n if (this._disposed) {\n return toDisposable(() => {});\n }\n\n const entry = { fn: listener, thisArgs };\n this._listeners = this._listeners.slice();\n this._listeners.push(entry);\n\n const result = toDisposable(() => {\n const idx = this._listeners.indexOf(entry);\n if (idx !== -1) {\n this._listeners = this._listeners.slice();\n this._listeners.splice(idx, 1);\n }\n });\n\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(result);\n } else {\n disposables.add(result);\n }\n }\n\n return result;\n };\n return this._event;\n }\n\n public fire(event: T): void {\n if (this._disposed || !this._listeners.length) {\n return;\n }\n if (this._listeners.length === 1) {\n this._listeners[0].fn.call(this._listeners[0].thisArgs, event);\n return;\n }\n const listeners = this._listeners;\n for (let i = 0, len = listeners.length; i < len; ++i) {\n listeners[i].fn.call(listeners[i].thisArgs, event);\n }\n }\n\n public dispose(): void {\n if (this._disposed) {\n return;\n }\n this._disposed = true;\n this._listeners.length = 0;\n }\n}\n\nexport namespace EventUtils {\n export function forward(from: IEvent, to: Emitter): IDisposable {\n return from(e => to.fire(e));\n }\n\n export function map(event: IEvent, map: (i: I) => O): IEvent {\n return (listener: (e: O) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n return event(i => listener.call(thisArgs, map(i)), undefined, disposables);\n };\n }\n\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent {\n return (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n const store = new DisposableStore();\n for (const event of events) {\n store.add(event(e => listener.call(thisArgs, e)));\n }\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(store);\n } else {\n disposables.add(store);\n }\n }\n return store;\n };\n }\n\n export function runAndSubscribe(event: IEvent, handler: (e: T) => void, initial: T): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void, initial?: T): IDisposable {\n handler(initial);\n return event(e => handler(e));\n }\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { toRGBA8888 } from 'sixel/lib/Colors';\nimport { IDisposable } from '@xterm/xterm';\nimport { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';\nimport { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\n\nconst enum Constants {\n PLACEHOLDER_LENGTH = 4096,\n PLACEHOLDER_HEIGHT = 24\n}\n\n/**\n * ImageRenderer - terminal frontend extension:\n * - provide primitives for canvas, ImageData, Bitmap (static)\n * - add canvas layer to DOM (browser only for now)\n * - draw image tiles onRender\n */\nexport class ImageRenderer extends Disposable implements IDisposable {\n /** @deprecated Kept for backward compat \u2014 points to top layer canvas. */\n public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; }\n private _layers = new Map();\n private _placeholder: HTMLCanvasElement | undefined;\n private _placeholderBitmap: ImageBitmap | undefined;\n private _optionsRefresh = this._register(new MutableDisposable());\n private _oldOpen: ((parent: HTMLElement) => void) | undefined;\n private _renderService: IRenderService | undefined;\n private _oldSetRenderer: ((renderer: any) => void) | undefined;\n\n // drawing primitive - canvas\n public static createCanvas(localDocument: Document | undefined, width: number, height: number): HTMLCanvasElement {\n /**\n * NOTE: We normally dont care, from which document the canvas\n * gets created, so we can fall back to global document,\n * if the terminal has no document associated yet.\n * This way early image loads before calling .open keep working\n * (still discouraged though, as the metrics will be screwed up).\n * Only the DOM output canvas should be on the terminal's document,\n * which gets explicitly checked in `insertLayerToDom`.\n */\n const canvas = (localDocument ?? document).createElement('canvas');\n canvas.width = width | 0;\n canvas.height = height | 0;\n return canvas;\n }\n\n // drawing primitive - ImageData with optional buffer\n public static createImageData(ctx: CanvasRenderingContext2D, width: number, height: number, buffer?: ArrayBuffer): ImageData {\n if (typeof ImageData !== 'function') {\n const imgData = ctx.createImageData(width, height);\n if (buffer) {\n imgData.data.set(new Uint8ClampedArray(buffer, 0, width * height * 4));\n }\n return imgData;\n }\n return buffer\n ? new ImageData(new Uint8ClampedArray(buffer, 0, width * height * 4), width, height)\n : new ImageData(width, height);\n }\n\n // drawing primitive - ImageBitmap\n public static createImageBitmap(img: ImageBitmapSource): Promise {\n if (typeof createImageBitmap !== 'function') {\n return Promise.resolve(undefined);\n }\n return createImageBitmap(img);\n }\n\n\n constructor(private _terminal: ITerminalExt) {\n super();\n this._oldOpen = this._terminal._core.open;\n this._terminal._core.open = (parent: HTMLElement): void => {\n this._oldOpen?.call(this._terminal._core, parent);\n this._open();\n };\n if (this._terminal._core.screenElement) {\n this._open();\n }\n // hack to spot fontSize changes\n this._optionsRefresh.value = this._terminal._core.optionsService.onOptionChange(option => {\n if (option === 'fontSize') {\n this.rescaleCanvas();\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n });\n this._register(toDisposable(() => {\n this.removeLayerFromDom();\n this.removeLayerFromDom('bottom');\n if (this._terminal._core && this._oldOpen) {\n this._terminal._core.open = this._oldOpen;\n this._oldOpen = undefined;\n }\n if (this._renderService && this._oldSetRenderer) {\n this._renderService.setRenderer = this._oldSetRenderer;\n this._oldSetRenderer = undefined;\n }\n this._renderService = undefined;\n this._layers.clear();\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }));\n }\n\n /**\n * Enable the placeholder.\n */\n public showPlaceholder(value: boolean): void {\n if (value) {\n if (!this._placeholder && this.cellSize.height !== -1) {\n this._createPlaceHolder(Math.max(this.cellSize.height + 1, Constants.PLACEHOLDER_HEIGHT));\n }\n } else {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n\n /**\n * Dimensions of the terminal.\n * Forwarded from internal render service.\n */\n public get dimensions(): IRenderDimensions | undefined {\n return this._terminal.dimensions;\n }\n\n /**\n * Current cell size (float).\n */\n public get cellSize(): ICellSize {\n return {\n width: this.dimensions?.css.cell.width || -1,\n height: this.dimensions?.css.cell.height || -1\n };\n }\n\n /**\n * Clear a region of the image layer canvas.\n */\n public clearLines(start: number, end: number, layer?: ImageLayer): void {\n const y = start * (this.dimensions?.css.cell.height || 0);\n const w = this.dimensions?.css.canvas.width || 0;\n const h = (end + 1 - start) * (this.dimensions?.css.cell.height || 0);\n if (!layer || layer === 'top') {\n this._layers.get('top')?.clearRect(0, y, w, h);\n }\n if (!layer || layer === 'bottom') {\n this._layers.get('bottom')?.clearRect(0, y, w, h);\n }\n }\n\n /**\n * Clear whole image canvas.\n */\n public clearAll(layer?: ImageLayer): void {\n if (!layer || layer === 'top') {\n const ctx = this._layers.get('top');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n if (!layer || layer === 'bottom') {\n const ctx = this._layers.get('bottom');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n }\n\n /**\n * Draw neighboring tiles on the image layer canvas.\n */\n public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get(imgSpec.layer);\n if (!ctx) {\n return;\n }\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const { width: sourceWidth, height: sourceHeight } = imgSpec.actualCellSize;\n const cols = Math.ceil(img.width / sourceWidth);\n\n const sx = (tileId % cols) * sourceWidth;\n const sy = Math.floor(tileId / cols) * sourceHeight;\n const dx = col * width;\n const dy = row * height;\n\n // safari bug: never access image source out of bounds\n const finalWidth = count * sourceWidth + sx > img.width ? img.width - sx : count * sourceWidth;\n const finalHeight = sy + sourceHeight > img.height ? img.height - sy : sourceHeight;\n\n // Floor all pixel offsets to get stable tile mapping without any overflows.\n // Note: For not pixel perfect aligned cells like in the DOM renderer\n // this will move a tile slightly to the top/left (subpixel range, thus ignore it).\n // FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),\n Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth * width / sourceWidth), Math.ceil(finalHeight * height / sourceHeight)\n );\n }\n\n /**\n * Extract a single tile from an image.\n */\n public extractTile(imgSpec: IImageSpec, tileId: number): HTMLCanvasElement | undefined {\n const { width, height } = this.cellSize;\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const { width: sourceWidth, height: sourceHeight } = imgSpec.actualCellSize;\n const cols = Math.ceil(img.width / sourceWidth);\n const sx = (tileId % cols) * sourceWidth;\n const sy = Math.floor(tileId / cols) * sourceHeight;\n const finalWidth = sourceWidth + sx > img.width ? img.width - sx : sourceWidth;\n const finalHeight = sy + sourceHeight > img.height ? img.height - sy : sourceHeight;\n\n const canvas = ImageRenderer.createCanvas(this.document, Math.ceil(finalWidth * width / sourceWidth), Math.ceil(finalHeight * height / sourceHeight));\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight),\n 0, 0, canvas.width, canvas.height\n );\n return canvas;\n }\n }\n\n /**\n * Draw a line with placeholder on the image layer canvas.\n */\n public drawPlaceholder(col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get('top');\n if (ctx) {\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n if (!this._placeholder) {\n this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT));\n } else if (height >= this._placeholder!.height) {\n this._createPlaceHolder(height + 1);\n }\n if (!this._placeholder) return;\n ctx.drawImage(\n this._placeholderBitmap ?? this._placeholder!,\n col * width,\n (row * height) % 2 ? 0 : 1, // needs %2 offset correction\n width * count,\n height,\n col * width,\n row * height,\n width * count,\n height\n );\n }\n }\n\n /**\n * Rescale image layer canvas if needed.\n * Checked once from `ImageStorage.render`.\n */\n public rescaleCanvas(): void {\n const w = this.dimensions?.css.canvas.width || 0;\n const h = this.dimensions?.css.canvas.height || 0;\n for (const ctx of this._layers.values()) {\n if (ctx.canvas.width !== w || ctx.canvas.height !== h) {\n ctx.canvas.width = w;\n ctx.canvas.height = h;\n }\n }\n }\n\n /**\n * Rescale image in storage if needed.\n */\n private _rescaleImage(spec: IImageSpec, currentWidth: number, currentHeight: number): void {\n if (currentWidth === spec.actualCellSize.width && currentHeight === spec.actualCellSize.height) {\n return;\n }\n const { width: originalWidth, height: originalHeight } = spec.origCellSize;\n if (currentWidth === originalWidth && currentHeight === originalHeight) {\n spec.actual = spec.orig;\n spec.actualCellSize.width = originalWidth;\n spec.actualCellSize.height = originalHeight;\n return;\n }\n const scaledWidth = Math.ceil(spec.orig!.width * currentWidth / originalWidth);\n const scaledHeight = Math.ceil(spec.orig!.height * currentHeight / originalHeight);\n // Upscale visible tiles directly; a full zoomed copy can dwarf the image budget.\n if (scaledWidth * scaledHeight > spec.orig!.width * spec.orig!.height) {\n spec.actual = spec.orig;\n spec.actualCellSize.width = originalWidth;\n spec.actualCellSize.height = originalHeight;\n return;\n }\n const canvas = ImageRenderer.createCanvas(this.document, scaledWidth, scaledHeight);\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(spec.orig!, 0, 0, canvas.width, canvas.height);\n spec.actual = canvas;\n spec.actualCellSize.width = currentWidth;\n spec.actualCellSize.height = currentHeight;\n }\n }\n\n /**\n * Lazy init for the renderer.\n */\n private _open(): void {\n this._renderService = this._terminal._core._renderService;\n this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);\n this._renderService.setRenderer = (renderer: any) => {\n for (const key of [...this._layers.keys()]) {\n this.removeLayerFromDom(key);\n }\n this._oldSetRenderer?.call(this._renderService, renderer);\n };\n }\n\n public insertLayerToDom(layer: ImageLayer = 'top'): void {\n // make sure that the terminal is attached to a document and to DOM\n if (!this.document || !this._terminal._core.screenElement) {\n console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');\n return;\n }\n if (this._layers.has(layer)) {\n return;\n }\n const canvas = ImageRenderer.createCanvas(\n this.document, this.dimensions?.css.canvas.width || 0,\n this.dimensions?.css.canvas.height || 0\n );\n canvas.classList.add(`xterm-image-layer-${layer}`);\n const screenElement = this._terminal._core.screenElement;\n // Use isolation to create a stacking context without overriding z-index,\n // which would conflict with integrators (e.g. VS Code) that set their\n // own z-index on the screen element.\n screenElement.style.isolation = 'isolate';\n if (layer === 'bottom') {\n // Use z-index:-1 so it paints behind non-positioned text elements.\n // The screen element needs to be a stacking context (via isolation)\n // to contain the negative z-index, otherwise it would go behind the\n // entire terminal.\n canvas.style.zIndex = '-1';\n screenElement.insertBefore(canvas, screenElement.firstChild);\n } else {\n // Explicit z-index ensures the image canvas reliably stacks above\n // the text layer (DOM renderer rows). z-index: 0 is below the\n // selection overlay (z-index: 1).\n canvas.style.zIndex = '0';\n screenElement.appendChild(canvas);\n }\n const ctx = canvas.getContext('2d', { alpha: true });\n if (!ctx) {\n canvas.remove();\n return;\n }\n this._layers.set(layer, ctx);\n this.clearAll(layer);\n }\n\n public removeLayerFromDom(layer: ImageLayer = 'top'): void {\n const ctx = this._layers.get(layer);\n if (ctx) {\n ctx.canvas.remove();\n this._layers.delete(layer);\n }\n }\n\n public hasLayer(layer: ImageLayer): boolean {\n return this._layers.has(layer);\n }\n\n private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n\n // create blueprint to fill placeholder with\n const bWidth = 32; // must be 2^n\n const blueprint = ImageRenderer.createCanvas(this.document, bWidth, height);\n const ctx = blueprint.getContext('2d', { alpha: false });\n if (!ctx) return;\n const imgData = ImageRenderer.createImageData(ctx, bWidth, height);\n const d32 = new Uint32Array(imgData.data.buffer);\n const black = toRGBA8888(0, 0, 0);\n const white = toRGBA8888(255, 255, 255);\n d32.fill(black);\n for (let y = 0; y < height; ++y) {\n const shift = y % 2;\n const offset = y * bWidth;\n for (let x = 0; x < bWidth; x += 2) {\n d32[offset + x + shift] = white;\n }\n }\n ctx.putImageData(imgData, 0, 0);\n\n // create placeholder line, width aligned to blueprint width\n const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || Constants.PLACEHOLDER_LENGTH;\n this._placeholder = ImageRenderer.createCanvas(this.document, width, height);\n const ctx2 = this._placeholder.getContext('2d', { alpha: false });\n if (!ctx2) {\n this._placeholder = undefined;\n return;\n }\n for (let i = 0; i < width; i += bWidth) {\n ctx2.drawImage(blueprint, i, 0);\n }\n const placeholder = this._placeholder;\n ImageRenderer.createImageBitmap(placeholder).then(bitmap => {\n if (this._placeholder !== placeholder) bitmap?.close();\n else this._placeholderBitmap = bitmap;\n }).catch(() => {});\n }\n\n public get document(): Document | undefined {\n return this._terminal._core._coreBrowserService?.window.document;\n }\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageRenderer } from './ImageRenderer';\nimport {\n ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec,\n IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes,\n UnderlineStyle, IAddImageOpts\n} from './Types';\n\n\n// fallback default cell size\nexport const CELL_SIZE_DEFAULT: ICellSize = {\n width: 7,\n height: 14\n};\n\n/**\n * Extend extended attribute to also hold image tile information.\n *\n * Object definition is copied from base repo to fully mimick its behavior.\n * Image data is added as additional public properties `imageId` and `tileId`.\n */\nclass ExtendedAttrsImage implements IExtendedAttrsImage {\n private _ext: number = 0;\n public get ext(): number {\n if (this._urlId) {\n return (\n (this._ext & ~ExtFlags.UNDERLINE_STYLE) |\n (this.underlineStyle << 26)\n );\n }\n return this._ext;\n }\n public set ext(value: number) { this._ext = value; }\n\n public get underlineStyle(): UnderlineStyle {\n // Always return the URL style if it has one\n if (this._urlId) {\n return UnderlineStyle.DASHED;\n }\n return (this._ext & ExtFlags.UNDERLINE_STYLE) >> 26;\n }\n public set underlineStyle(value: UnderlineStyle) {\n this._ext &= ~ExtFlags.UNDERLINE_STYLE;\n this._ext |= (value << 26) & ExtFlags.UNDERLINE_STYLE;\n }\n\n public get underlineColor(): number {\n return this._ext & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n public set underlineColor(value: number) {\n this._ext &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);\n this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n\n public get underlineVariantOffset(): number {\n const val = (this._ext & ExtFlags.VARIANT_OFFSET) >> 29;\n if (val < 0) {\n return val ^ 0xFFFFFFF8;\n }\n return val;\n }\n public set underlineVariantOffset(value: number) {\n this._ext &= ~ExtFlags.VARIANT_OFFSET;\n this._ext |= (value << 29) & ExtFlags.VARIANT_OFFSET;\n }\n\n private _urlId: number = 0;\n public get urlId(): number {\n return this._urlId;\n }\n public set urlId(value: number) {\n this._urlId = value;\n }\n\n constructor(\n ext: number = 0,\n urlId: number = 0,\n public imageId = -1,\n public tileId = -1\n ) {\n this._ext = ext;\n this._urlId = urlId;\n }\n\n public clone(): IExtendedAttrsImage {\n /**\n * Technically we dont need a clone variant of ExtendedAttrsImage,\n * as we never clone a cell holding image data.\n * Note: Clone is only meant to be used by the InputHandler for\n * sticky attributes, which is never the case for image data.\n * We still provide a proper clone method to reflect the full ext attr\n * state in case there are future use cases for clone.\n */\n return new ExtendedAttrsImage(this._ext, this._urlId, this.imageId, this.tileId);\n }\n\n public isEmpty(): boolean {\n return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.imageId === -1;\n }\n}\nconst EMPTY_ATTRS = new ExtendedAttrsImage();\n\n\n/**\n * ImageStorage - extension of CoreTerminal:\n * - hold image data\n * - write/read image data to/from buffer\n *\n * TODO: image composition for overwrites\n */\nexport class ImageStorage implements IDisposable {\n // storage\n private _images: Map = new Map();\n // last used id\n private _lastId = 0;\n // last evicted id\n private _lowestId = 0;\n // whether a full clear happened before\n private _fullyCleared = false;\n // whether render should do a full clear\n private _needsFullClear = false;\n // hard limit of stored pixels (fallback limit of 10 MB)\n private _pixelLimit: number = 2500000;\n\n private _viewportMetrics: { cols: number, rows: number };\n public onImageAdded: (() => void) | undefined;\n public onImageDeleted: ((storageId: number) => void) | undefined;\n\n constructor(\n private _terminal: ITerminalExt,\n private _renderer: ImageRenderer,\n private _opts: IImageAddonOptions\n ) {\n try {\n this.setLimit(this._opts.storageLimit);\n } catch (e: unknown) {\n if (e instanceof Error) {\n console.error(e.message);\n }\n console.warn(`storageLimit is set to ${this.getLimit()} MB`);\n }\n this._viewportMetrics = {\n cols: this._terminal.cols,\n rows: this._terminal.rows\n };\n }\n\n public dispose(): void {\n this.reset();\n }\n\n public reset(): void {\n for (const spec of this._images.values()) {\n spec.marker?.dispose();\n }\n // NOTE: marker.dispose above already calls ImageBitmap.close\n // therefore we can just wipe the map here\n this._images.clear();\n this._renderer.clearAll();\n }\n\n public getLimit(): number {\n return this._pixelLimit * 4 / 1000000;\n }\n\n public setLimit(value: number): void {\n if (value < 0.5 || value > 1000) {\n throw RangeError('invalid storageLimit, should be at least 0.5 MB and not exceed 1G');\n }\n this._pixelLimit = (value / 4 * 1000000) >>> 0;\n this._evictOldest(0);\n }\n\n public getUsage(): number {\n return this._getStoredPixels() * 4 / 1000000;\n }\n\n private _getStoredPixels(): number {\n let storedPixels = 0;\n for (const spec of this._images.values()) {\n if (spec.orig) {\n storedPixels += spec.orig.width * spec.orig.height;\n if (spec.actual && spec.actual !== spec.orig) {\n storedPixels += spec.actual.width * spec.actual.height;\n }\n }\n }\n return storedPixels;\n }\n\n private _delImg(id: number): void {\n const spec = this._images.get(id);\n if (!spec) return;\n this._images.delete(id);\n // FIXME: really ugly workaround to get bitmaps deallocated :(\n if (window.ImageBitmap && spec.orig instanceof ImageBitmap) {\n spec.orig.close();\n }\n this.onImageDeleted?.(id);\n }\n\n /**\n * Wipe canvas and images on alternate buffer.\n */\n public wipeAlternate(): void {\n // remove all alternate tagged images\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate') {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n // mark canvas to be wiped on next render\n this._needsFullClear = true;\n this._fullyCleared = false;\n }\n\n /**\n * Delete an image by its internal storage ID.\n * Used by protocols that support explicit deletion (e.g. Kitty a=d).\n */\n public deleteImage(id: number): void {\n const spec = this._images.get(id);\n if (spec) {\n spec.marker?.dispose();\n this._delImg(id);\n }\n }\n\n /**\n * Method to add an image to the storage.\n * @param img - The image to add (canvas or bitmap).\n * @param opts - Options for addImage:\n * - scrolling: When true, cursor advances with the image.\n * When false, image is placed at ORIGIN and cursor does not move.\n * - layer: Which canvas layer to render on ('top' or 'bottom').\n * - zIndex: Z-index for image layering within the same layer.\n * - cursorPos: 'vt340' for bottom-left, 'iip' for bottom.right.\n * @returns The internal image ID assigned to the stored image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap, opts: IAddImageOpts): number {\n // never allow storage to exceed memory limit\n this._evictOldest(img.width * img.height);\n\n // calc rows x cols needed to display the image\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const cols = Math.ceil(img.width / cellSize.width);\n const rows = Math.ceil(img.height / cellSize.height);\n\n const imageId = ++this._lastId;\n\n const buffer = this._terminal._core.buffer;\n const termCols = this._terminal.cols;\n const termRows = this._terminal.rows;\n const originX = buffer.x;\n const originY = buffer.y;\n let offset = originX;\n let tileCount = 0;\n\n if (!opts.scrolling) {\n buffer.x = 0;\n buffer.y = 0;\n offset = 0;\n }\n\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(buffer.y + buffer.ybase);\n for (let col = 0; col < cols; ++col) {\n if (offset + col >= termCols) break;\n this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);\n tileCount++;\n }\n if (opts.scrolling) {\n if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();\n } else {\n if (++buffer.y >= termRows) break;\n }\n buffer.x = offset;\n }\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n\n // cursor positioning modes\n if (opts.scrolling) {\n if (opts.cursorPos === 'iip') {\n buffer.x = Math.min(offset + cols, termCols);\n } else {\n buffer.x = offset;\n }\n } else {\n buffer.x = originX;\n buffer.y = originY;\n }\n\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.tileCount < 1) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n\n // eviction marker:\n // delete the image when the marker gets disposed\n const endMarker = this._terminal.registerMarker(0);\n endMarker?.onDispose(() => {\n const spec = this._images.get(imageId);\n if (spec) {\n this._delImg(imageId);\n }\n });\n\n // since markers do not work on alternate for some reason,\n // we evict images here manually\n if (this._terminal.buffer.active.type === 'alternate') {\n this._evictOnAlternate();\n }\n\n // create storage entry\n const imgSpec: IImageSpec = {\n orig: img,\n origCellSize: cellSize,\n actual: img,\n actualCellSize: { ...cellSize }, // clone needed, since later modified\n marker: endMarker || undefined,\n tileCount,\n bufferType: this._terminal.buffer.active.type,\n layer: opts.layer,\n zIndex: opts.zIndex\n };\n\n // finally add the image\n this._images.set(imageId, imgSpec);\n this.onImageAdded?.();\n return imageId;\n }\n\n\n /**\n * Render method. Collects buffer information and triggers\n * canvas updates.\n */\n // TODO: Should we move this to the ImageRenderer?\n public render(range: { start: number, end: number }): void {\n // Determine which layers have images\n let hasTopImages = false;\n let hasBottomImages = false;\n for (const spec of this._images.values()) {\n if (spec.layer === 'bottom') {\n hasBottomImages = true;\n } else {\n hasTopImages = true;\n }\n if (hasTopImages && hasBottomImages) break;\n }\n\n // Lazily insert layers that are needed\n if (hasTopImages && !this._renderer.hasLayer('top')) {\n this._renderer.insertLayerToDom('top');\n if (!this._renderer.hasLayer('top')) return;\n }\n if (hasBottomImages && !this._renderer.hasLayer('bottom')) {\n this._renderer.insertLayerToDom('bottom');\n }\n\n // rescale if needed\n this._renderer.rescaleCanvas();\n\n // exit early if we dont have any images to test for\n if (!this._images.size) {\n if (!this._fullyCleared) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n if (this._renderer.hasLayer('top')) {\n this._renderer.removeLayerFromDom('top');\n }\n if (this._renderer.hasLayer('bottom')) {\n this._renderer.removeLayerFromDom('bottom');\n }\n return;\n }\n\n // Remove layers no longer needed\n if (!hasTopImages && this._renderer.hasLayer('top')) {\n this._renderer.clearAll('top');\n this._renderer.removeLayerFromDom('top');\n }\n if (!hasBottomImages && this._renderer.hasLayer('bottom')) {\n this._renderer.clearAll('bottom');\n this._renderer.removeLayerFromDom('bottom');\n }\n\n // buffer switches force a full clear\n if (this._needsFullClear) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n\n const { start, end } = range;\n const buffer = this._terminal._core.buffer;\n const cols = this._terminal._core.cols;\n\n // clear drawing area\n this._renderer.clearLines(start, end);\n\n // Collect draw calls so we can sort by z-index (lower z drawn first).\n const drawCalls: { imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number }[] = [];\n const placeholderCalls: { col: number, row: number, count: number }[] = [];\n\n // walk all cells in viewport and collect tiles found\n for (let row = start; row <= end; ++row) {\n const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt;\n if (!line) return;\n for (let col = 0; col < cols; ++col) {\n if (line.getBg(col) & BgFlags.HAS_EXTENDED) {\n let e: IExtendedAttrsImage = line._extendedAttrs[col] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (e.tileId !== -1) {\n const startTile = e.tileId;\n const startCol = col;\n let count = 1;\n /**\n * merge tiles to the right into a single draw call, if:\n * - not at end of line\n * - cell has same image id\n * - cell has consecutive tile id\n */\n while (\n ++col < cols\n && (line.getBg(col) & BgFlags.HAS_EXTENDED)\n && (e = line._extendedAttrs[col] ?? EMPTY_ATTRS)\n && (e.imageId === imageId)\n && (e.tileId === startTile + count)\n ) {\n count++;\n }\n col--;\n if (imgSpec) {\n if (imgSpec.actual) {\n drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });\n }\n } else if (this._opts.showPlaceholder) {\n placeholderCalls.push({ col: startCol, row, count });\n }\n this._fullyCleared = false;\n }\n }\n }\n }\n\n // Sort by z-index so lower z draws first (higher z renders on top)\n drawCalls.sort((a, b) => a.imgSpec.zIndex - b.imgSpec.zIndex);\n\n // Draw placeholders first (lowest priority)\n for (const call of placeholderCalls) {\n this._renderer.drawPlaceholder(call.col, call.row, call.count);\n }\n\n // Draw images in z-index order\n for (const call of drawCalls) {\n this._renderer.draw(call.imgSpec, call.tileId, call.col, call.row, call.count);\n }\n }\n\n public viewportResize(metrics: { cols: number, rows: number }): void {\n // exit early if we have nothing in storage\n if (!this._images.size) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // handle only viewport width enlargements, exit all other cases\n // TODO: needs patch for tile counter\n if (this._viewportMetrics.cols >= metrics.cols) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // walk scrollbuffer at old col width to find all possible expansion matches\n const buffer = this._terminal._core.buffer;\n const rows = buffer.lines.length;\n const oldCol = this._viewportMetrics.cols - 1;\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(row) as IBufferLineExt;\n if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (!imgSpec) {\n continue;\n }\n // found an image tile at oldCol, check if it qualifies for right exapansion\n const tilesPerRow = Math.ceil((imgSpec.actual?.width || 0) / imgSpec.actualCellSize.width);\n if ((e.tileId % tilesPerRow) + 1 >= tilesPerRow) {\n continue;\n }\n // expand only if right side is empty (nothing got wrapped from below)\n let hasData = false;\n for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) {\n if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) {\n hasData = true;\n break;\n }\n }\n if (hasData) {\n continue;\n }\n // do right expansion on terminal buffer\n const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol);\n let lastTile = e.tileId;\n for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) {\n this._writeToCell(line as IBufferLineExt, expandCol, imageId, ++lastTile);\n imgSpec.tileCount++;\n }\n }\n }\n // store new viewport metrics\n this._viewportMetrics = metrics;\n }\n\n /**\n * Retrieve original canvas at buffer position.\n */\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1) {\n const orig = this._images.get(e.imageId)?.orig;\n if (window.ImageBitmap && orig instanceof ImageBitmap) {\n const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height);\n canvas.getContext('2d')?.drawImage(orig, 0, 0, orig.width, orig.height);\n return canvas;\n }\n return orig as HTMLCanvasElement;\n }\n }\n }\n\n /**\n * Extract active single tile at buffer position.\n */\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1 && e.tileId !== -1) {\n const spec = this._images.get(e.imageId);\n if (spec) {\n return this._renderer.extractTile(spec, e.tileId);\n }\n }\n }\n }\n\n // TODO: Do we need some blob offloading tricks here to avoid early eviction?\n // also see https://stackoverflow.com/questions/28307789/is-there-any-limitation-on-javascript-max-blob-size\n private _evictOldest(room: number): number {\n const used = this._getStoredPixels();\n let current = used;\n while (this._pixelLimit < current + room && this._images.size) {\n const spec = this._images.get(++this._lowestId);\n if (spec && spec.orig) {\n current -= spec.orig.width * spec.orig.height;\n if (spec.actual && spec.orig !== spec.actual) {\n current -= spec.actual.width * spec.actual.height;\n }\n spec.marker?.dispose();\n this._delImg(this._lowestId);\n }\n }\n return used - current;\n }\n\n private _writeToCell(line: IBufferLineExt, x: number, imageId: number, tileId: number): void {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const old = line._extendedAttrs[x];\n if (old) {\n if (old.imageId !== undefined) {\n // found an old ExtendedAttrsImage, since we know that\n // they are always isolated instances (single cell usage),\n // we can re-use it and just update their id entries\n const oldSpec = this._images.get(old.imageId);\n if (oldSpec) {\n // early eviction for in-viewport overwrites\n oldSpec.tileCount--;\n }\n old.imageId = imageId;\n old.tileId = tileId;\n return;\n }\n // found a plain ExtendedAttrs instance, clone it to new entry\n line._extendedAttrs[x] = new ExtendedAttrsImage(old.ext, old.urlId, imageId, tileId);\n return;\n }\n }\n // fall-through: always create new ExtendedAttrsImage entry\n line._data[x * Cell.SIZE + Cell.BG] |= BgFlags.HAS_EXTENDED;\n line._extendedAttrs[x] = new ExtendedAttrsImage(0, 0, imageId, tileId);\n }\n\n private _evictOnAlternate(): void {\n // nullify tile count of all images on alternate buffer\n for (const spec of this._images.values()) {\n if (spec.bufferType === 'alternate') {\n spec.tileCount = 0;\n }\n }\n // re-count tiles on whole buffer\n const buffer = this._terminal._core.buffer;\n for (let y = 0; y < this._terminal.rows; ++y) {\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (!line) {\n continue;\n }\n for (let x = 0; x < this._terminal.cols; ++x) {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const imgId = line._extendedAttrs[x]?.imageId;\n if (imgId) {\n const spec = this._images.get(imgId);\n if (spec) {\n spec.tileCount++;\n }\n }\n }\n }\n }\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate' && !spec.tileCount) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nimport { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { CELL_SIZE_DEFAULT } from './ImageStorage';\nimport Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';\nimport { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser';\nimport { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';\n\n// Local const enum mirror - esbuild can't inline const enums from external packages\nconst enum DecoderConst {\n // Limit held memory in base64 decoder (encoded bytes).\n KEEP_DATA = 4194304,\n // Initial buffer allocation for the decoder.\n INITIAL_DATA = 1048576,\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n OK = 0\n}\n\n// default IIP header values\nconst DEFAULT_HEADER: IHeaderFields = {\n type: SequenceType.INVALID,\n name: 'Unnamed file',\n size: 0,\n width: 'auto',\n height: 'auto',\n preserveAspectRatio: 1,\n inline: 0\n};\n\n\nexport class IIPHandler implements IOscHandler, IResetHandler {\n private _generation = 0;\n private _aborted = false;\n private _hp = new HeaderParser();\n private _header: IHeaderFields = DEFAULT_HEADER;\n private _dec: Base64Decoder;\n private _qoiDec: QoiDecoder;\n private _isMultipart = false;\n private _abortMulti = false;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _storage: IIPImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);\n const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);\n this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes);\n this._qoiDec = new QoiDecoder(DecoderConst.KEEP_DATA);\n }\n\n public reset(): void {\n this._generation++;\n this._hp.reset();\n this._dec.release();\n this._qoiDec.release();\n }\n\n public start(): void {\n this._aborted = false;\n this._hp.reset();\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (this._hp.state === HeaderState.END) {\n if ((this._dec.put(data.subarray(start, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n }\n } else {\n const dataPos = this._hp.parse(data, start, end);\n if (dataPos === -1) {\n this._aborted = true;\n return;\n }\n if (dataPos > 0) {\n const seqType = this._hp.fields.type;\n if (seqType === SequenceType.FILE) {\n if (this._isMultipart) {\n this._isMultipart = false;\n this._abortMulti = false;\n this._dec.release();\n }\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n if (!this._header.inline) {\n this._aborted = true;\n return;\n }\n this._dec.init();\n } else if (this._abortMulti) {\n this._aborted = true;\n return;\n }\n if ((this._dec.put(data.subarray(dataPos, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n if (this._isMultipart) this._abortMulti = true;\n }\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted) return true;\n\n if (this._hp.state !== HeaderState.END) {\n if (this._hp.end()) return true;\n }\n const seqType = this._hp.fields.type;\n\n if (seqType === SequenceType.FILEPART) return true;\n\n if (seqType === SequenceType.REPORTCELLSIZE) {\n // OSC 1337 ; ReportCellSize=[height];[width];[scale] ST\n let w = CELL_SIZE_DEFAULT.width;\n let h = CELL_SIZE_DEFAULT.height;\n if (this._renderer.dimensions) {\n w = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols;\n h = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows;\n }\n const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1;\n const report = `\\x1b]1337;ReportCellSize=${h.toFixed(3)};${w.toFixed(3)};${scale.toFixed(3)}\\x1b\\\\`;\n this._coreTerminal.input(report, false);\n return true;\n }\n\n if (seqType === SequenceType.MULTIPARTFILE) {\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n this._isMultipart = true;\n this._abortMulti = false;\n this._dec.release();\n this._dec.init();\n return true;\n }\n\n if (seqType === SequenceType.FILEEND) {\n if (!this._isMultipart) return true;\n this._isMultipart = false;\n if (this._abortMulti || this._header.type !== SequenceType.MULTIPARTFILE) return true;\n }\n\n // fallthrough for SequenceType.FILE & SequenceType.FILEEND\n\n let w = 0;\n let h = 0;\n\n // early exit condition chain\n let cond: number | boolean;\n let metrics = UNSUPPORTED_TYPE;\n if (cond = success) {\n if (cond = !this._dec.end()) {\n metrics = imageType(this._dec.data8);\n if (cond = metrics.mime !== 'unsupported') {\n w = metrics.width;\n h = metrics.height;\n if (cond = w && h && w * h < this._opts.pixelLimit) {\n [w, h] = this._resize(w, h).map(Math.floor);\n cond = w && h && w * h < this._opts.pixelLimit;\n } else {\n console.warn(`IIP: image dimension issue ${metrics.width}x${metrics.height}`);\n }\n } else {\n console.warn('IIP: unsupported image type');\n }\n } else {\n console.warn('IIP: error during BASE64 decoding');\n }\n }\n if (!cond) {\n this._dec.release();\n return true;\n }\n\n let blob: Blob | ImageData;\n if (metrics.mime === 'image/qoi') {\n const data = this._qoiDec.decode(this._dec.data8);\n blob = new ImageData(\n new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),\n this._qoiDec.width,\n this._qoiDec.height\n );\n this._qoiDec.release();\n if (w === this._qoiDec.width && h === this._qoiDec.height) {\n // use fast-path if we don't need to rescale\n this._dec.release();\n const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height);\n canvas.getContext('2d')?.putImageData(blob, 0, 0);\n this._storage.addImage(canvas);\n return true;\n }\n } else {\n blob = new Blob([this._dec.data8], { type: metrics.mime });\n }\n this._dec.release();\n const generation = this._generation;\n return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })\n .then(bm => {\n if (generation !== this._generation) {\n bm.close();\n return true;\n }\n this._storage.addImage(bm);\n return true;\n })\n .catch(e => {\n console.warn(`IIP: decoding error ${metrics.mime} ${metrics.width}x${metrics.height}`, e);\n return true;\n });\n }\n\n private _resize(w: number, h: number): [number, number] {\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n const width = this._renderer.dimensions?.css.canvas.width || cw * this._coreTerminal.cols;\n const height = this._renderer.dimensions?.css.canvas.height || ch * this._coreTerminal.rows;\n\n const rw = this._dim(this._header.width!, width, cw);\n const rh = this._dim(this._header.height!, height, ch);\n if (!rw && !rh) {\n const wf = width / w; // TODO: should this respect initial cursor offset?\n const hf = (height - ch) / h; // TODO: fix offset issues from float cell height\n const f = Math.min(wf, hf);\n return f < 1 ? [w * f, h * f] : [w, h];\n }\n return !rw\n ? [w * rh / h, rh]\n : this._header.preserveAspectRatio || !rw || !rh\n ? [rw, h * rw / w] : [rw, rh];\n }\n\n private _dim(s: string, total: number, cdim: number): number {\n if (s === 'auto') return 0;\n if (s.endsWith('%')) return parseInt(s.slice(0, -1), 10) * total / 100;\n if (s.endsWith('px')) return parseInt(s.slice(0, -2), 10);\n return parseInt(s, 10) * cdim;\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n// eslint-disable-next-line\ndeclare const Buffer: any;\n\nexport const enum HeaderState {\n START = 0,\n ABORT = 1,\n KEY = 2,\n VALUE = 3,\n END = 4\n}\n\nexport const enum SequenceType {\n INVALID = 0,\n FILE = 1,\n MULTIPARTFILE = 2,\n FILEPART = 3,\n FILEEND = 4,\n REPORTCELLSIZE = 5\n}\n\nexport interface IHeaderFields {\n [key: string]: number | string | Uint32Array | null | undefined;\n // sequence type\n type: SequenceType;\n // base-64 encoded filename. Defaults to \"Unnamed file\".\n name: string;\n // File size in bytes. The file transfer will be canceled if this size is exceeded.\n size: number;\n /**\n * Optional width and height to render:\n * - N: N character cells.\n * - Npx: N pixels.\n * - N%: N percent of the session's width or height.\n * - auto: The image's inherent size will be used to determine an appropriate dimension.\n */\n width?: string;\n height?: string;\n // Optional, defaults to 1 respecting aspect ratio (width takes precedence).\n preserveAspectRatio?: number;\n // Optional, defaults to 0. If set to 1, the file will be displayed inline, else downloaded\n // (download not supported).\n inline?: number;\n}\n\n// field value decoders\n\n// ASCII bytes to string\nfunction toStr(data: Uint32Array): string {\n let s = '';\n for (let i = 0; i < data.length; ++i) {\n s += String.fromCharCode(data[i]);\n }\n return s;\n}\n\n// digits to integer\nfunction toInt(data: Uint32Array): number {\n let v = 0;\n for (let i = 0; i < data.length; ++i) {\n if (data[i] < 48 || data[i] > 57) {\n throw new Error('illegal char');\n }\n v = v * 10 + data[i] - 48;\n }\n return v;\n}\n\n// check for correct size entry\nfunction toSize(data: Uint32Array): string {\n const v = toStr(data);\n if (!v.match(/^((auto)|(\\d+?((px)|(%)){0,1}))$/)) {\n throw new Error('illegal size');\n }\n return v;\n}\n\n// name is base64 encoded utf-8\nfunction toName(data: Uint32Array): string {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(toStr(data), 'base64').toString();\n }\n const bs = atob(toStr(data));\n const b = new Uint8Array(bs.length);\n for (let i = 0; i < b.length; ++i) {\n b[i] = bs.charCodeAt(i);\n }\n return new TextDecoder().decode(b);\n}\n\nconst DECODERS: {[key: string]: (v: Uint32Array) => number | string} = {\n inline: toInt,\n size: toInt,\n name: toName,\n width: toSize,\n height: toSize,\n preserveAspectRatio: toInt\n};\n\n\n// sequence type markers\n// File\nconst FILE_MARKER = [70, 105, 108, 101];\n// MultipartFile\nconst MULTIPARTFILE_MARKER = [77, 117, 108, 116, 105, 112, 97, 114, 116, 70, 105, 108, 101];\n// FilePart\nconst FILEPART_MARKER = [70, 105, 108, 101, 80, 97, 114, 116];\n// FileEnd\nconst FILEEND_MARKER = [70, 105, 108, 101, 69, 110, 100];\n// ReportCellSize\nconst REPORTCELLSIZE_MARKER = [82, 101, 112, 111, 114, 116, 67, 101, 108, 108, 83, 105, 122, 101];\n\n// max allowed chars for sequence header\nconst MAX_FIELDCHARS = 1024;\n\n\nexport class HeaderParser {\n public state: HeaderState = HeaderState.START;\n private _buffer = new Uint32Array(MAX_FIELDCHARS);\n private _position = 0;\n private _key = '';\n public fields: {[key: string]: number | string | Uint32Array | null | undefined} = {};\n\n public reset(): void {\n this._buffer.fill(0);\n this.state = HeaderState.START;\n this._position = 0;\n this.fields = {};\n this._key = '';\n }\n\n public end(): number {\n if (this.state === HeaderState.START) {\n if (this._position === FILEEND_MARKER.length) {\n for (let k = 0; k < FILEEND_MARKER.length; ++k) {\n if (this._buffer[k] !== FILEEND_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEEND;\n this.state = HeaderState.END;\n return 0;\n }\n if (this._position === REPORTCELLSIZE_MARKER.length) {\n for (let k = 0; k < REPORTCELLSIZE_MARKER.length; ++k) {\n if (this._buffer[k] !== REPORTCELLSIZE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.REPORTCELLSIZE;\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n if (this.state === HeaderState.END) return 0;\n if (this.state === HeaderState.VALUE\n && this.fields.type === SequenceType.MULTIPARTFILE\n ) {\n if (!this._storeValue(this._position)) return this._a();\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n\n public parse(data: Uint32Array, start: number, end: number): number {\n let state = this.state;\n let pos = this._position;\n const buffer = this._buffer;\n if (state === HeaderState.ABORT || state === HeaderState.END) return -1;\n if (state === HeaderState.START && pos > 14) return -1;\n for (let i = start; i < end; ++i) {\n const c = data[i];\n switch (c) {\n case 59: // ;\n if (!this._storeValue(pos)) return this._a();\n state = HeaderState.KEY;\n pos = 0;\n break;\n case 61: // =\n if (state === HeaderState.START) {\n if (buffer[0] === 70) {\n // 'File' or 'FilePart'\n let k = 0;\n for (; k < FILE_MARKER.length; ++k) {\n if (buffer[k] !== FILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILE;\n if (pos === FILEPART_MARKER.length) {\n for (; k < FILEPART_MARKER.length; ++k) {\n if (buffer[k] !== FILEPART_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEPART;\n this.state = HeaderState.END;\n return i + 1;\n }\n } else if (buffer[0] === 77) {\n // 'MultipartFile'\n for (let k = 0; k < MULTIPARTFILE_MARKER.length; ++k) {\n if (buffer[k] !== MULTIPARTFILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.MULTIPARTFILE;\n } else {\n return this._a();\n }\n state = HeaderState.KEY;\n pos = 0;\n } else if (state === HeaderState.KEY) {\n if (!this._storeKey(pos)) return this._a();\n state = HeaderState.VALUE;\n pos = 0;\n } else if (state === HeaderState.VALUE) {\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n break;\n case 58: // :\n if (state === HeaderState.VALUE) {\n if (!this._storeValue(pos)) return this._a();\n }\n this.state = HeaderState.END;\n return i + 1;\n default:\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n }\n this.state = state;\n this._position = pos;\n return -2;\n }\n\n private _a(): number {\n this.fields.type = SequenceType.INVALID;\n this.state = HeaderState.ABORT;\n return -1;\n }\n\n private _storeKey(pos: number): boolean {\n const k = toStr(this._buffer.subarray(0, pos));\n if (k) {\n this._key = k;\n this.fields[k] = null;\n return true;\n }\n return false;\n }\n\n private _storeValue(pos: number): boolean {\n if (this._key) {\n try {\n const v = this._buffer.slice(0, pos);\n this.fields[this._key] = DECODERS[this._key] ? DECODERS[this._key](v) : v;\n } catch {\n return false;\n }\n return true;\n }\n return false;\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n\nexport type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'image/webp' | 'image/avif' | 'unsupported' | '';\n\nexport interface IMetrics {\n mime: ImageType;\n width: number;\n height: number;\n}\n\nexport const UNSUPPORTED_TYPE: IMetrics = {\n mime: 'unsupported',\n width: 0,\n height: 0\n};\n\nexport function imageType(d: Uint8Array): IMetrics {\n if (d.length < 32) {\n return UNSUPPORTED_TYPE;\n }\n const d32 = new Uint32Array(d.buffer, d.byteOffset, 8);\n // PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)\n // + first chunk must be IHDR\n if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {\n return {\n mime: 'image/png',\n width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],\n height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]\n };\n }\n // JPEG: FF D8 FF\n if (d[0] === 0xFF && d[1] === 0xD8 && d[2] === 0xFF) {\n const [width, height] = jpgSize(d);\n return { mime: 'image/jpeg', width, height };\n }\n // GIF: GIF87a or GIF89a\n if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {\n return {\n mime: 'image/gif',\n width: d[7] << 8 | d[6],\n height: d[9] << 8 | d[8]\n };\n }\n // QOI: qoif\n if (d32[0] === 0x66696F71) {\n return {\n mime: 'image/qoi',\n width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7],\n height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11]\n };\n }\n // WEBP: RIFF | xxxx | WEBP | VP8x\n if (d32[0] === 0x46464952 && d32[2] === 0x50424557 && (d32[3] & 0xFFFFFF) === 0x385056) {\n switch (d[15]) {\n case 0x58: // Extended WebP VP8X --> \"X\"\n return {\n mime: 'image/webp',\n width: (d[24] | d[25] << 8 | d[26] << 16) + 1,\n height: (d[27] | d[28] << 8 | d[29] << 16) + 1\n };\n case 0x4C: // Lossless WebP VP8L --> \"L\"\n if (d[20] !== 0x2f) return UNSUPPORTED_TYPE;\n const dim = d[21] | d[22] << 8 | d[23] << 16 | d[24] << 24;\n return {\n mime: 'image/webp',\n width: (dim & 0x3FFF) + 1,\n height: (dim >>> 14 & 0x3FFF) + 1\n };\n case 0x20: // Lossy WebP VP8 --> \" \"\n if (d[23] !== 0x9d || d[24] !== 0x01 || d[25] !== 0x2a) return UNSUPPORTED_TYPE;\n return {\n mime: 'image/webp',\n width: (d[26] | d[27] << 8) & 0x3FFF,\n height: (d[28] | d[29] << 8) & 0x3FFF\n };\n }\n return UNSUPPORTED_TYPE;\n }\n // AVIF: Box size | ftyp | avif/avis\n if (d32[1] === 0x70797466 && (d32[2] === 0x66697661 || d32[2] === 0x73697661)) {\n let pos = -1;\n // search for ispe box within first 1024 bytes\n const limit = Math.min(d.length - 16, 1024);\n for (let i = 8; i < limit; i++) {\n // scan for ispe\n if (d[i] === 0x69 && d[i + 1] === 0x73 && d[i + 2] === 0x70 && d[i + 3] === 0x65) {\n pos = i;\n break;\n }\n }\n if (pos !== -1) {\n // dimensions are in BE at +8 (width) at +12 (height)\n const width =\n d[pos + 8] << 24 |\n d[pos + 9] << 16 |\n d[pos + 10] << 8 |\n d[pos + 11];\n const height =\n d[pos + 12] << 24 |\n d[pos + 13] << 16 |\n d[pos + 14] << 8 |\n d[pos + 15];\n if (width > 0 && height > 0) {\n return { mime: 'image/avif', width, height };\n }\n }\n return UNSUPPORTED_TYPE;\n }\n return UNSUPPORTED_TYPE;\n}\n\n\nfunction jpgSize(d: Uint8Array): [number, number] {\n const len = d.length;\n let i = 4;\n let blockLength = d[i] << 8 | d[i + 1];\n while (true) {\n i += blockLength;\n if (i >= len) {\n // exhausted without size info\n return [0, 0];\n }\n if (d[i] !== 0xFF) {\n return [0, 0];\n }\n if (d[i + 1] === 0xC0 || d[i + 1] === 0xC2) {\n if (i + 8 < len) {\n return [\n d[i + 7] << 8 | d[i + 8],\n d[i + 5] << 8 | d[i + 6]\n ];\n }\n return [0, 0];\n }\n i += 2;\n blockLength = d[i] << 8 | d[i + 1];\n }\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';\nimport { ImageRenderer } from '../ImageRenderer';\nimport { CELL_SIZE_DEFAULT } from '../ImageStorage';\nimport { imageType } from '../IIPMetrics';\nimport { KittyImageStorage } from './KittyImageStorage';\nimport Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport {\n KittyAction,\n KittyFormat,\n KittyCompression,\n IKittyCommand,\n IPendingTransmission,\n IKittyImageData,\n KittyPixelConstants,\n parseKittyCommand\n} from './KittyGraphicsTypes';\n\nconst enum Constants {\n // Memory limit for base64 decoder (4MB, same as IIPHandler)\n DECODER_KEEP_DATA = 4194304,\n DECODER_INITIAL_DATA = 4194304, // 4MB\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n DECODER_OK = 0,\n // Maximum control data size\n MAX_CONTROL_DATA_SIZE = 512,\n // Semicolon codepoint\n SEMICOLON = 0x3B\n}\n\nconst DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK;\n\n// Kitty graphics protocol handler with streaming base64 decoding.\nexport class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {\n private _aborted = false;\n private _generation = 0;\n private _decodeError = false;\n\n private _activeDecoder: Base64Decoder | null = null;\n private readonly _maxEncodedBytes: number;\n private readonly _initialEncodedBytes: number;\n\n // Streaming related states\n\n // True while receiving control data (before semicolon).\n private _inControlData = true;\n\n // Buffer for control data.\n private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);\n private _controlLength = 0;\n\n // Pre-calculated encoded size limit\n private _encodedSizeLimit = 0;\n private _totalEncodedSize = 0;\n\n // Parsed command. These are the control data before semicolon.\n private _parsedCommand: IKittyCommand | null = null;\n\n // Storage related states\n\n private _pendingTransmissions: Map = new Map();\n // Tracks the pending key of the most recently started chunked upload.\n // Per spec, subsequent chunks only need m= (and optionally q=), without i=.\n // When a chunk arrives with no i=, this key is used to find the pending upload.\n private _lastPendingKey: number | undefined;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _kittyStorage: KittyImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n // Convert decoded size limit -> max encoded bytes.\n this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);\n // ensure we preallocate more than configured limit while using 4mb initial size.\n this._initialEncodedBytes = Math.min(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);\n }\n\n public reset(): void {\n this._generation++;\n this._cleanupAllPending();\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n this._kittyStorage.reset();\n }\n\n public dispose(): void {\n this.reset();\n }\n\n private _removePendingEntry(key: number): void {\n this._pendingTransmissions.delete(key);\n if (this._lastPendingKey === key) {\n this._lastPendingKey = undefined;\n }\n }\n\n private _cleanupAllPending(): void {\n for (const pending of this._pendingTransmissions.values()) {\n pending.decoder.release();\n }\n this._pendingTransmissions.clear();\n this._lastPendingKey = undefined;\n }\n\n public start(): void {\n this._aborted = false;\n this._decodeError = false;\n this._inControlData = true;\n this._controlLength = 0;\n this._parsedCommand = null;\n // Pre-calculate encoded limit once: base64 is 4 bytes encoded \u2192 3 bytes decoded\n this._encodedSizeLimit = this._maxEncodedBytes;\n this._totalEncodedSize = 0;\n this._activeDecoder = null;\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (!this._inControlData) {\n this._streamPayload(data, start, end);\n } else {\n // Scan for semicolon\n let controlEnd = end;\n for (let i = start; i < end; i++) {\n if (data[i] === Constants.SEMICOLON) {\n this._inControlData = false;\n controlEnd = i;\n break;\n }\n }\n\n // Copy control data\n const copyLength = controlEnd - start;\n if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {\n this._aborted = true;\n return;\n }\n this._controlData.set(data.subarray(start, controlEnd), this._controlLength);\n this._controlLength += copyLength;\n\n if (!this._inControlData) {\n // Found semicolon - parse control data early for validation\n this._parsedCommand = parseKittyCommand(this._parseControlDataString());\n\n // Early validation: i+I conflict\n if (this._parsedCommand.id !== undefined && this._parsedCommand.imageNumber !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'EINVAL:cannot specify both i and I keys', this._parsedCommand.quiet ?? 0);\n this._aborted = true;\n return;\n }\n\n // Delete action doesn't need payload - skip streaming\n if (this._parsedCommand.action === KittyAction.DELETE) {\n return;\n }\n\n // Stream remaining as payload\n const payloadStart = controlEnd + 1;\n if (payloadStart < end) {\n this._streamPayload(data, payloadStart, end);\n }\n }\n }\n }\n\n // Stream payload bytes into the base64 decoder.\n private _streamPayload(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n // Check size limit (compare encoded bytes against pre-calculated limit)\n // Include cumulative size from pending transmission for multi-chunk images.\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = this._parsedCommand?.id ?? this._lastPendingKey ?? 0;\n const pending = this._pendingTransmissions.get(pendingKey);\n const previousEncodedSize = pending?.totalEncodedSize ?? 0;\n this._totalEncodedSize += end - start;\n const cumulativeEncodedSize = previousEncodedSize + this._totalEncodedSize;\n if (cumulativeEncodedSize > this._encodedSizeLimit) {\n const decoderToRelease = this._activeDecoder ?? pending?.decoder;\n if (decoderToRelease) {\n decoderToRelease.release();\n }\n this._activeDecoder = null;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n this._aborted = true;\n return;\n }\n\n if (this._decodeError) return;\n\n if (pending?.decoder && !this._activeDecoder) {\n this._activeDecoder = pending.decoder;\n }\n if (!this._activeDecoder) {\n // Budget WASM capacity, including one page of decoder state and rounding.\n const decoderCapacity = this._maxEncodedBytes + 131072;\n if (decoderCapacity > this._opts.storageLimit * 1000000) {\n this._aborted = true;\n if (this._parsedCommand?.id !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'ENOMEM:pending image budget exceeded', this._parsedCommand.quiet ?? 0);\n }\n return;\n }\n const maxPending = Math.max(1, Math.floor(this._opts.storageLimit * 1000000 / decoderCapacity));\n while (this._pendingTransmissions.size >= maxPending) {\n const oldest = this._pendingTransmissions.entries().next().value;\n if (!oldest) break;\n oldest[1].decoder.release();\n this._removePendingEntry(oldest[0]);\n if (oldest[1].cmd.id !== undefined) {\n this._sendResponse(oldest[1].cmd.id, 'ENOMEM:pending image budget exceeded', oldest[1].cmd.quiet ?? 0);\n }\n }\n this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);\n this._activeDecoder.init();\n }\n\n if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n this._decodeError = true;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted || !success) {\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n return true;\n }\n\n // No semicolon = no payload (delete, capability query)\n if (this._inControlData) {\n return this._handleNoPayloadCommand();\n }\n\n // Use command parsed early in put() - i+I already validated there\n const cmd = this._parsedCommand!;\n\n // Delete action was handled by skipping payload - just execute\n if (cmd.action === KittyAction.DELETE) {\n return this._handleDelete(cmd);\n }\n\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = cmd.id ?? this._lastPendingKey ?? 0;\n const isMoreComing = cmd.more === 1;\n const pending = this._pendingTransmissions.get(pendingKey);\n\n if (isMoreComing) {\n if (this._activeDecoder) {\n if (pending) {\n pending.totalEncodedSize += this._totalEncodedSize;\n pending.decodeError = pending.decodeError || this._decodeError;\n } else {\n this._pendingTransmissions.set(pendingKey, {\n cmd: { ...cmd },\n decoder: this._activeDecoder,\n totalEncodedSize: this._totalEncodedSize,\n decodeError: this._decodeError\n });\n }\n this._lastPendingKey = pendingKey;\n this._activeDecoder = null;\n }\n return true;\n }\n\n // Final chunk received \u2014 clear the last pending key\n if (pending) {\n this._lastPendingKey = undefined;\n }\n\n let decodeError = this._decodeError;\n let finalCmd = cmd;\n let decoder = this._activeDecoder;\n\n if (pending) {\n finalCmd = pending.cmd;\n decoder = pending.decoder;\n decodeError = decodeError || pending.decodeError;\n this._pendingTransmissions.delete(pendingKey);\n }\n\n let imageBytes = new Uint8Array(0);\n if (decoder) {\n if (decoder.end() !== DECODER_OK) {\n decodeError = true;\n }\n imageBytes = decoder.data8;\n }\n this._activeDecoder = null;\n\n // Handle command first \u2014 handlers create Blob/ImageData from imageBytes,\n // which copies the data. Only then is it safe to release the decoder's\n // wasm memory that imageBytes points into.\n const result = this._handleCommandWithBytesAndCmd(finalCmd, imageBytes, decodeError);\n if (decoder) {\n decoder.release();\n }\n return result;\n }\n\n // Command handling\n\n private _parseControlDataString(): string {\n let str = '';\n for (let i = 0; i < this._controlLength; i++) {\n str += String.fromCodePoint(this._controlData[i]);\n }\n return str;\n }\n\n private _handleNoPayloadCommand(): boolean | Promise {\n const cmd = parseKittyCommand(this._parseControlDataString());\n\n // Per spec: specifying both i and I is an error\n if (cmd.id !== undefined && cmd.imageNumber !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:cannot specify both i and I keys', cmd.quiet ?? 0);\n return true;\n }\n\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.DELETE:\n return this._handleDelete(cmd);\n case KittyAction.QUERY:\n this._sendResponse(cmd.id ?? 0, 'OK', cmd.quiet ?? 0);\n return true;\n case KittyAction.PLACEMENT:\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handleCommandWithBytesAndCmd(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.TRANSMIT: {\n const result = this._handleTransmit(cmd, bytes, decodeError);\n // Only send response when _handleTransmit didn't already respond\n // (it handles unsupported transmission medium responses internally)\n if ((cmd.transmission ?? 'd') === 'd' && cmd.id !== undefined) {\n if (decodeError) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n } else if (bytes.length > 0) {\n this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0);\n }\n }\n return result;\n }\n case KittyAction.TRANSMIT_DISPLAY:\n return this._handleTransmitDisplay(cmd, bytes, decodeError);\n case KittyAction.QUERY:\n return this._handleQuery(cmd, bytes, decodeError);\n case KittyAction.PLACEMENT:\n // a=p ignores any payload \u2014 image data was already transmitted\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handlePlacement(cmd: IKittyCommand): boolean | Promise {\n if (cmd.id === undefined) {\n return true;\n }\n const id = cmd.id;\n const image = this._kittyStorage.getImage(id);\n if (!image) {\n this._sendResponse(id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);\n return true;\n }\n const result = this._displayImage(image, cmd);\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);\n return true;\n });\n }\n\n private _handleTransmit(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n // TODO: Support file-based transmission modes (t=f, t=t, t=s)\n // Currently only supports direct transmission (t=d, the default).\n // - t=f (file): Payload is base64-encoded file path. Terminal reads image from that path.\n // - t=t (temp file): Payload is base64-encoded path in temp directory. Terminal reads, deletes.\n // - t=s: Payload is base64-encoded POSIX shm name. Terminal reads from shared memory.\n // These modes require filesystem/IPC access not available in browsers. For Node.js/Electron:\n // 1. Check cmd.transmission (t key) before treating bytes as image data\n // 2. For t=f/t/s: decode bytes as UTF-8 string (the path/name), then read file contents\n // 3. For t=d: treat bytes as image data (current behavior)\n // When implementing, also update _handleQuery to accept these transmission mediums.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported transmission medium', cmd.quiet ?? 0);\n }\n return true;\n }\n\n if (decodeError || bytes.length === 0) return true;\n\n this._kittyStorage.storeImage(cmd.id, {\n data: new Blob([bytes as BlobPart]),\n width: cmd.width ?? 0,\n height: cmd.height ?? 0,\n format: (cmd.format ?? KittyFormat.RGBA) as 24 | 32 | 100,\n compression: cmd.compression ?? ''\n });\n return true;\n }\n\n private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n if (decodeError) {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n }\n return true;\n }\n\n this._handleTransmit(cmd, bytes, decodeError);\n\n const id = cmd.id ?? this._kittyStorage.lastImageId;\n const image = this._kittyStorage.getImage(id);\n if (image) {\n const result = this._displayImage(image, cmd);\n if (cmd.id !== undefined) {\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0);\n return true;\n });\n }\n return result.then(() => true);\n }\n return true;\n }\n\n private _handleQuery(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n const id = cmd.id ?? 0;\n const quiet = cmd.quiet ?? 0;\n\n // Per spec: reject unsupported transmission mediums (only t=d is supported atm)\n // TODO: When filesystem support is added (Node.js/Electron), update this to accept\n // t=f (file), t=t (temp file), and t=s (shared memory) and respond OK for queries.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n this._sendResponse(id, 'EINVAL:unsupported transmission medium', quiet);\n return true;\n }\n\n // Check decode error first (invalid base64)\n if (decodeError) {\n this._sendResponse(id, 'EINVAL:invalid base64 data', quiet);\n return true;\n }\n\n // Capability query (no payload) - just respond OK\n if (bytes.length === 0) {\n this._sendResponse(id, 'OK', quiet);\n return true;\n }\n\n const format = cmd.format ?? KittyFormat.RGBA;\n\n if (format === KittyFormat.PNG) {\n this._sendResponse(id, 'OK', quiet);\n } else {\n const width = cmd.width ?? 0;\n const height = cmd.height ?? 0;\n\n if (!width || !height) {\n this._sendResponse(id, 'EINVAL:width and height required for raw pixel data', quiet);\n return true;\n }\n\n const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n this._sendResponse(id, `EINVAL:insufficient pixel data`, quiet);\n return true;\n }\n\n this._sendResponse(id, 'OK', quiet);\n }\n return true;\n }\n\n private _handleDelete(cmd: IKittyCommand): boolean {\n // Per spec: default delete selector is 'a' (delete all visible placements)\n const selector = cmd.deleteSelector ?? 'a';\n\n // TODO: Distinguish lowercase (delete placements only) from uppercase\n // (delete placements + free stored image data). Currently both variants\n // free everything since we don't separate stored data from placements.\n switch (selector) {\n case 'a':\n case 'A':\n this._cleanupAllPending();\n this._kittyStorage.deleteAll();\n break;\n case 'i':\n case 'I':\n // TODO: When placement id tracking is implemented (see TODO in\n // KittyImageStorage), d=i with p= should delete only that\n // specific placement, while d=i without p should delete all\n // placements for the image.\n if (cmd.id !== undefined) {\n const pending = this._pendingTransmissions.get(cmd.id);\n if (pending) {\n pending.decoder.release();\n }\n this._removePendingEntry(cmd.id);\n this._kittyStorage.deleteById(cmd.id);\n }\n break;\n default:\n // Unsupported selectors (c, n, p, q, r, x, y, z, f) \u2014 ignore for now\n break;\n }\n return true;\n }\n\n private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void {\n const isOk = message === 'OK';\n if (isOk && quiet >= 1) return;\n if (!isOk && quiet >= 2) return;\n\n const pPart = placementId ? `,p=${placementId}` : '';\n const response = `\\x1b_Gi=${id}${pPart};${message}\\x1b\\\\`;\n this._coreTerminal._core.coreService.triggerDataEvent(response);\n }\n\n // Image display\n\n private _displayImage(image: IKittyImageData, cmd: IKittyCommand): Promise {\n return this._decodeAndDisplay(image, cmd)\n .then(() => true)\n .catch(() => false);\n }\n\n private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise {\n const generation = this._generation;\n let bitmap: ImageBitmap | undefined = await this._createBitmap(image);\n\n try {\n if (generation !== this._generation) throw new Error('image decode canceled');\n const cropX = Math.max(0, cmd.x ?? 0);\n const cropY = Math.max(0, cmd.y ?? 0);\n const cropW = cmd.sourceWidth || (bitmap.width - cropX);\n const cropH = cmd.sourceHeight || (bitmap.height - cropY);\n\n const maxCropW = Math.max(0, bitmap.width - cropX);\n const maxCropH = Math.max(0, bitmap.height - cropY);\n const finalCropW = Math.max(0, Math.min(cropW, maxCropW));\n const finalCropH = Math.max(0, Math.min(cropH, maxCropH));\n\n if (finalCropW === 0 || finalCropH === 0) {\n throw new Error('invalid source rectangle');\n }\n\n if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {\n const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);\n bitmap.close();\n bitmap = cropped;\n }\n\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n\n // Per spec: c/r default to image's natural cell dimensions.\n // If only one of c/r is specified, compute the other from image aspect ratio.\n let imgCols: number;\n let imgRows: number;\n if (cmd.columns !== undefined && cmd.rows !== undefined) {\n imgCols = cmd.columns;\n imgRows = cmd.rows;\n } else if (cmd.columns !== undefined) {\n imgCols = cmd.columns;\n imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));\n } else if (cmd.rows !== undefined) {\n imgRows = cmd.rows;\n imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));\n } else {\n imgCols = Math.ceil(bitmap.width / cw);\n imgRows = Math.ceil(bitmap.height / ch);\n }\n\n let w = bitmap.width;\n let h = bitmap.height;\n\n // Scale bitmap to fit placement rectangle when c/r are specified\n if (cmd.columns !== undefined || cmd.rows !== undefined) {\n w = Math.round(imgCols * cw);\n h = Math.round(imgRows * ch);\n }\n\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n\n // Save cursor position before addImage modifies it\n const buffer = this._coreTerminal._core.buffer;\n const savedX = buffer.x;\n const savedY = buffer.y;\n const savedYbase = buffer.ybase;\n\n // Determine layer based on z-index: negative = behind text, 0+ = on top.\n // When z<0 we always use the bottom layer even without allowTransparency \u2014\n // the image will simply be hidden behind the opaque text background, which\n // is the correct behavior (client asked for \"behind text\").\n const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;\n const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';\n\n if (w !== bitmap.width || h !== bitmap.height) {\n const scaled = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });\n bitmap.close();\n bitmap = scaled;\n }\n\n // Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions\n const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1);\n const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1);\n if (xOffset !== 0 || yOffset !== 0) {\n // Per spec: X/Y is not added to c/r area. When c/r are explicit, the\n // total placement area remains c*cw \u00D7 r*ch pixels and the offset image\n // is clipped to fit. When c/r are unset, the padded canvas determines\n // the natural cell dimensions.\n const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset;\n const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset;\n const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);\n const offsetCtx = offsetCanvas.getContext('2d');\n if (!offsetCtx) {\n throw new Error('Failed to create offset canvas context');\n }\n offsetCtx.drawImage(bitmap, xOffset, yOffset);\n\n const offsetBitmap = await createImageBitmap(offsetCanvas);\n offsetCanvas.width = offsetCanvas.height = 0;\n bitmap.close();\n bitmap = offsetBitmap;\n w = bitmap.width;\n h = bitmap.height;\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n if (cmd.columns === undefined) {\n imgCols = Math.ceil(bitmap.width / cw);\n }\n if (cmd.rows === undefined) {\n imgRows = Math.ceil(bitmap.height / ch);\n }\n }\n\n if (generation !== this._generation) throw new Error('image decode canceled');\n const zIndex = cmd.zIndex ?? 0;\n this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);\n bitmap = undefined; // ownership transferred to storage\n\n // Kitty cursor movement\n // Per spec: cursor placed at first column after last image column,\n // on the last row of the image. C=1 means don't move cursor.\n if (cmd.cursorMovement === 1) {\n // C=1: restore cursor to position before image was placed\n const scrolled = buffer.ybase - savedYbase;\n buffer.x = savedX;\n // Can't restore cursor to scrollback?\n buffer.y = Math.max(savedY - scrolled, 0);\n } else {\n // Default (C=0): advance cursor horizontally past the image\n // addImage already positioned cursor on the last row via lineFeeds\n buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);\n }\n } catch (e) {\n bitmap?.close();\n throw e;\n }\n }\n\n // Create ImageBitmap from already-decoded image data.\n private async _createBitmap(image: IKittyImageData): Promise {\n let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());\n\n if (image.compression === KittyCompression.ZLIB) {\n bytes = await this._decompressZlib(bytes);\n }\n\n if (image.format === KittyFormat.PNG) {\n const metrics = imageType(bytes);\n // IHDR dimensions are parsed with signed shifts, so a value >= 0x80000000 comes\n // back negative and a bare `>` pixel-limit test passes it; require positive.\n if (metrics.mime !== 'image/png' || !(metrics.width > 0) || !(metrics.height > 0) || metrics.width * metrics.height > this._opts.pixelLimit) {\n throw new RangeError('PNG exceeds pixel limit or has invalid dimensions');\n }\n const blob = new Blob([bytes as BlobPart], { type: 'image/png' });\n if (!window.createImageBitmap) {\n const url = URL.createObjectURL(blob);\n const img = new Image();\n return new Promise((resolve, reject) => {\n img.addEventListener('load', () => {\n URL.revokeObjectURL(url);\n const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);\n canvas.getContext('2d')?.drawImage(img, 0, 0);\n createImageBitmap(canvas).then(resolve).catch(reject);\n });\n img.addEventListener('error', () => {\n URL.revokeObjectURL(url);\n reject(new Error('Failed to load image'));\n });\n img.src = url;\n });\n }\n return createImageBitmap(blob);\n }\n\n // Raw pixel data\n const width = image.width;\n const height = image.height;\n\n if (!width || !height) {\n throw new Error('Width and height required for raw pixel data');\n }\n\n const bytesPerPixel = image.format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n throw new Error('Insufficient pixel data');\n }\n\n const pixelCount = width * height;\n\n if (image.format === KittyFormat.RGBA) {\n // RGBA: use bytes directly \u2014 no copy needed\n return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA), width, height));\n }\n\n // RGB\u2192RGBA: interleave alpha using uint32 block processing (4 pixels per iteration).\n // 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes \u2014 ~6x faster.\n // Assumes little-endian (all modern browsers/Node.js).\n const data = new Uint8ClampedArray(pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA);\n const src32 = new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));\n const dst32 = new Uint32Array(data.buffer);\n const alignedPixels = pixelCount & ~3; // round down to multiple of 4\n\n let srcOffset = 0;\n let dstOffset = 0;\n for (let i = 0; i < alignedPixels; i += 4) {\n const b0 = src32[srcOffset++];\n const b1 = src32[srcOffset++];\n const b2 = src32[srcOffset++];\n // Little-endian: pixel bytes are [R,G,B] \u2192 uint32 ABGR layout\n dst32[dstOffset++] = 0xFF000000 | b0;\n dst32[dstOffset++] = 0xFF000000 | (b0 >>> 24) | (b1 << 8);\n dst32[dstOffset++] = 0xFF000000 | (b1 >>> 16) | (b2 << 16);\n dst32[dstOffset++] = 0xFF000000 | (b2 >>> 8);\n }\n\n // Handle remaining 1\u20133 pixels\n let srcByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n let dstByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n for (let i = alignedPixels; i < pixelCount; i++) {\n data[dstByte] = bytes[srcByte];\n data[dstByte + 1] = bytes[srcByte + 1];\n data[dstByte + 2] = bytes[srcByte + 2];\n data[dstByte + 3] = KittyPixelConstants.ALPHA_OPAQUE;\n srcByte += KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n dstByte += KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n }\n\n return createImageBitmap(new ImageData(data, width, height));\n }\n\n private async _decompressZlib(compressed: Uint8Array): Promise {\n try {\n return await this._decompress(compressed, 'deflate');\n } catch (error) {\n if (error instanceof RangeError) throw error;\n return await this._decompress(compressed, 'deflate-raw');\n }\n }\n\n private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise {\n const limit = Math.min(this._opts.kittySizeLimit, this._opts.pixelLimit * 4, this._opts.storageLimit * 1000000);\n let offsetIn = 0;\n // Bound inflation within one native transform before its output is budgeted.\n const source = new ReadableStream({\n pull(controller) {\n if (offsetIn >= compressed.length) {\n controller.close();\n return;\n }\n const end = Math.min(offsetIn + 4096, compressed.length);\n controller.enqueue(new Uint8Array(compressed.subarray(offsetIn, end)));\n offsetIn = end;\n }\n });\n const reader = source.pipeThrough(new DecompressionStream(format)).getReader();\n const chunks: Uint8Array[] = [];\n let totalLength = 0;\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n totalLength += value.byteLength;\n if (totalLength > limit) {\n await reader.cancel().catch(() => {});\n throw new RangeError('decompressed image exceeds byte limit');\n }\n chunks.push(value);\n }\n } finally {\n reader.releaseLock();\n }\n\n const result = new Uint8Array(totalLength);\n let offset = 0;\n for (const chunk of chunks) {\n result.set(chunk, offset);\n offset += chunk.length;\n }\n return result;\n }\n\n public get images(): ReadonlyMap {\n return this._kittyStorage.images;\n }\n\n public get _kittyIdToStorageId(): ReadonlyMap {\n return this._kittyStorage.kittyIdToStorageId;\n }\n\n public get pendingTransmissions(): ReadonlyMap {\n return this._pendingTransmissions;\n }\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Kitty graphics protocol types, constants, and parsing utilities.\n */\n\nimport type Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\n\n// Kitty graphics protocol action types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'a'.\nexport const enum KittyAction {\n TRANSMIT = 't',\n TRANSMIT_DISPLAY = 'T',\n QUERY = 'q',\n PLACEMENT = 'p',\n DELETE = 'd'\n}\n\n// Kitty graphics protocol format types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyFormat {\n RGB = 24,\n RGBA = 32,\n PNG = 100\n}\n\n// Kitty graphics protocol compression types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'o'.\nexport const enum KittyCompression {\n NONE = '',\n ZLIB = 'z'\n}\n\n// Kitty graphics protocol control data keys.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyKey {\n // Action to perform (t=transmit, T=transmit+display, q=query, p=placement, d=delete)\n ACTION = 'a',\n // Image format (24=RGB, 32=RGBA, 100=PNG)\n FORMAT = 'f',\n // Image ID for referencing stored images\n ID = 'i',\n // Image number (alternative to ID, terminal assigns ID)\n IMAGE_NUMBER = 'I',\n // Source image width in pixels\n WIDTH = 's',\n // Source image height in pixels\n HEIGHT = 'v',\n // The left edge (in pixels) of the image area to display\n X_OFFSET = 'x',\n // The top edge (in pixels) of the image area to display\n Y_OFFSET = 'y',\n // Width (in pixels) of the source rectangle to display\n SOURCE_WIDTH = 'w',\n // Height (in pixels) of the source rectangle to display\n SOURCE_HEIGHT = 'h',\n // Horizontal offset (in pixels) within the first cell\n X_PLACEMENT_OFFSET = 'X',\n // Vertical offset (in pixels) within the first cell\n Y_PLACEMENT_OFFSET = 'Y',\n // Number of terminal columns to display the image over\n COLUMNS = 'c',\n // Number of terminal rows to display the image over\n ROWS = 'r',\n // More data flag (1=more chunks coming, 0=final chunk)\n MORE = 'm',\n // Compression type (z=zlib). This is essential for chunking larger images.\n COMPRESSION = 'o',\n // Quiet mode (1=suppress OK responses, 2=suppress error responses)\n QUIET = 'q',\n // Cursor movement policy (0=move cursor after image, 1=don't move cursor)\n CURSOR_MOVEMENT = 'C',\n // Z-index for image layering (negative = behind text, 0+ = on top)\n Z_INDEX = 'z',\n // Transmission medium (d=direct, f=file, t=temp file, s=shared memory)\n TRANSMISSION = 't',\n // Delete selector (a/A=all, i/I=by id, c/C=at cursor, etc.) \u2014 only used when a=d\n DELETE_SELECTOR = 'd',\n // Placement ID for targeting specific placements\n PLACEMENT_ID = 'p'\n}\n\n// Pixel format constants\nexport const enum KittyPixelConstants {\n BYTES_PER_PIXEL_RGB = 3,\n BYTES_PER_PIXEL_RGBA = 4,\n ALPHA_OPAQUE = 255\n}\n\n// Parsed Kitty graphics command.\nexport interface IKittyCommand {\n action?: string;\n format?: number;\n id?: number;\n imageNumber?: number;\n width?: number;\n height?: number;\n x?: number;\n y?: number;\n sourceWidth?: number;\n sourceHeight?: number;\n xOffset?: number;\n yOffset?: number;\n columns?: number;\n rows?: number;\n more?: number;\n quiet?: number;\n cursorMovement?: number;\n zIndex?: number;\n transmission?: string;\n deleteSelector?: string;\n placementId?: number;\n compression?: string;\n payload?: string;\n}\n\n// Pending chunked transmission state.\n// Stores metadata from the first chunk while accumulating decoded payload data.\nexport interface IPendingTransmission {\n // The parsed command from the first chunk (contains action, format, dimensions, etc.)\n cmd: IKittyCommand;\n // Decoder used across chunked payloads\n decoder: Base64Decoder;\n // Total encoded (base64) bytes received across all chunks - for size limit enforcement\n totalEncodedSize: number;\n // Whether any chunk has failed to decode\n decodeError: boolean;\n}\n\n// Stored Kitty image data.\nexport interface IKittyImageData {\n id: number;\n // Decoded image data stored as Blob (off JS heap) to avoid 2GB heap limit\n data: Blob;\n width: number;\n height: number;\n format: 24 | 32 | 100;\n compression?: string;\n}\n\n// Parses Kitty graphics control data into a command object.\nexport function parseKittyCommand(data: string): IKittyCommand {\n const cmd: IKittyCommand = {};\n const parts = data.split(',');\n\n for (const part of parts) {\n const eqIdx = part.indexOf('=');\n if (eqIdx === -1) continue;\n\n const key = part.substring(0, eqIdx);\n const value = part.substring(eqIdx + 1);\n\n // Handle string keys first\n if (key === KittyKey.ACTION) {\n cmd.action = value;\n continue;\n }\n if (key === KittyKey.COMPRESSION) {\n cmd.compression = value;\n continue;\n }\n if (key === KittyKey.TRANSMISSION) {\n cmd.transmission = value;\n continue;\n }\n if (key === KittyKey.DELETE_SELECTOR) {\n cmd.deleteSelector = value;\n continue;\n }\n const numValue = parseInt(value, 10);\n switch (key) {\n case KittyKey.FORMAT: cmd.format = numValue; break;\n case KittyKey.ID: cmd.id = numValue; break;\n case KittyKey.IMAGE_NUMBER: cmd.imageNumber = numValue; break;\n case KittyKey.WIDTH: cmd.width = numValue; break;\n case KittyKey.HEIGHT: cmd.height = numValue; break;\n case KittyKey.X_OFFSET: cmd.x = numValue; break;\n case KittyKey.Y_OFFSET: cmd.y = numValue; break;\n case KittyKey.SOURCE_WIDTH: cmd.sourceWidth = numValue; break;\n case KittyKey.SOURCE_HEIGHT: cmd.sourceHeight = numValue; break;\n case KittyKey.X_PLACEMENT_OFFSET: cmd.xOffset = numValue; break;\n case KittyKey.Y_PLACEMENT_OFFSET: cmd.yOffset = numValue; break;\n case KittyKey.COLUMNS: cmd.columns = numValue; break;\n case KittyKey.ROWS: cmd.rows = numValue; break;\n case KittyKey.MORE: cmd.more = numValue; break;\n case KittyKey.QUIET: cmd.quiet = numValue; break;\n case KittyKey.CURSOR_MOVEMENT: cmd.cursorMovement = numValue; break;\n case KittyKey.Z_INDEX: cmd.zIndex = numValue; break;\n case KittyKey.PLACEMENT_ID: cmd.placementId = numValue; break;\n }\n }\n\n return cmd;\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageStorage } from '../ImageStorage';\nimport { ImageLayer, IAddImageOpts } from '../Types';\nimport { IKittyImageData } from './KittyGraphicsTypes';\n\n// Kitty-specific image storage controller.\n//\n// Wraps shared ImageStorage with kitty protocol semantics:\n// - tracks transmitted image payloads by kitty image id\n// - tracks kitty image id -> shared ImageStorage id mapping for displayed images\n// - mirrors shared-storage evictions into kitty maps\n// - applies protocol-level undisplayed-image eviction policy\nexport class KittyImageStorage implements IDisposable {\n private static readonly _maxStoredImages = 256;\n\n private _nextImageId = 1;\n private readonly _images: Map = new Map();\n // TODO: Support multiple placements per image. The kitty spec identifies\n // placements by an (image id, placement id) pair \u2014 same i + different p\n // values should coexist, and same i + same p should replace the prior\n // placement. Currently we track only one storage entry per kitty image id,\n // so multiple placements of the same image overwrite each other. Fixing\n // this requires changing these maps to Map>\n // (kittyId \u2192 placementId \u2192 storageId) and updating addImage/deleteById\n // accordingly. The underlying shared ImageStorage would also need to\n // support multiple entries per logical image.\n private readonly _kittyIdToStorageId: Map = new Map();\n private readonly _storageIdToKittyId: Map = new Map();\n\n private readonly _previousOnImageDeleted: ((storageId: number) => void) | undefined;\n private readonly _wrappedOnImageDeleted: (storageId: number) => void;\n private readonly _handleStorageImageDeleted = (storageId: number): void => {\n const kittyId = this._storageIdToKittyId.get(storageId);\n if (kittyId !== undefined) {\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n this._images.delete(kittyId);\n }\n };\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n\n constructor(\n private readonly _storage: ImageStorage\n ) {\n this._previousOnImageDeleted = this._storage.onImageDeleted;\n this._wrappedOnImageDeleted = (storageId: number) => {\n this._previousOnImageDeleted?.(storageId);\n this._handleStorageImageDeleted(storageId);\n };\n this._storage.onImageDeleted = this._wrappedOnImageDeleted;\n }\n\n public reset(): void {\n this._nextImageId = 1;\n this._images.clear();\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public dispose(): void {\n this.reset();\n if (this._storage.onImageDeleted === this._wrappedOnImageDeleted) {\n this._storage.onImageDeleted = this._previousOnImageDeleted;\n }\n }\n\n public storeImage(id: number | undefined, imageData: Omit): number {\n const imageId = id ?? this._nextImageId++;\n\n const oldStorageId = this._kittyIdToStorageId.get(imageId);\n if (oldStorageId !== undefined) {\n this._storage.deleteImage(oldStorageId);\n this._kittyIdToStorageId.delete(imageId);\n this._storageIdToKittyId.delete(oldStorageId);\n }\n\n if (!this._images.has(imageId) && this._images.size >= KittyImageStorage._maxStoredImages) {\n this._evictUndisplayedImages();\n }\n\n // Encoded images awaiting placement are outside ImageStorage's pixel budget.\n // Unplaced payloads are evicted first so a new upload cannot erase a visible\n // image while abandoned blobs still hold budget; placed ones go only when\n // that is not enough, because the byte cap is a hard bound. The new image is\n // always stored, so an oversized one overshoots by at most one payload\n // (itself bounded by kittySizeLimit) rather than being dropped after an OK ack.\n const byteLimit = this._storage.getLimit() * 1000000;\n this._images.delete(imageId);\n let retainedBytes = 0;\n for (const image of this._images.values()) retainedBytes += image.data.size;\n for (const evictPlaced of [false, true]) {\n for (const [oldestId, image] of this._images) {\n if (retainedBytes + imageData.data.size <= byteLimit) break;\n if (this._kittyIdToStorageId.has(oldestId) !== evictPlaced) continue;\n retainedBytes -= image.data.size;\n this.deleteById(oldestId);\n }\n }\n\n this._images.set(imageId, {\n ...imageData,\n id: imageId\n });\n return imageId;\n }\n\n public addImage(kittyId: number, image: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer, zIndex: number): void {\n // Clean up stale reverse-mapping from a previous placement of the same\n // kitty image. The old shared-storage entry is kept (it may still be\n // visible on screen) but its reverse mapping is removed so that eviction\n // of the old entry won't incorrectly delete the kitty image data.\n const oldStorageId = this._kittyIdToStorageId.get(kittyId);\n if (oldStorageId !== undefined) {\n this._storageIdToKittyId.delete(oldStorageId);\n }\n this._addImageOpts.scrolling = scrolling;\n this._addImageOpts.layer = layer;\n this._addImageOpts.zIndex = zIndex;\n const storageId = this._storage.addImage(image, this._addImageOpts);\n this._kittyIdToStorageId.set(kittyId, storageId);\n this._storageIdToKittyId.set(storageId, kittyId);\n }\n\n public getImage(kittyId: number): IKittyImageData | undefined {\n return this._images.get(kittyId);\n }\n\n public deleteById(kittyId: number): void {\n this._images.delete(kittyId);\n const storageId = this._kittyIdToStorageId.get(kittyId);\n if (storageId !== undefined) {\n this._storage.deleteImage(storageId);\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n }\n }\n\n public deleteAll(): void {\n this._images.clear();\n for (const storageId of this._kittyIdToStorageId.values()) {\n this._storage.deleteImage(storageId);\n }\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public get images(): ReadonlyMap {\n return this._images;\n }\n\n public get kittyIdToStorageId(): ReadonlyMap {\n return this._kittyIdToStorageId;\n }\n\n public get lastImageId(): number {\n return this._nextImageId - 1;\n }\n\n private _evictUndisplayedImages(): void {\n for (const [kittyId] of this._images) {\n if (this._images.size <= KittyImageStorage._maxStoredImages / 2) {\n break;\n }\n if (!this._kittyIdToStorageId.has(kittyId)) {\n this._images.delete(kittyId);\n }\n }\n }\n}\n", "/**\n * Copyright (c) 2020, 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IDcsHandler, IParams, IImageAddonOptions, ITerminalExt, AttributeData, IResetHandler, ReadonlyColorSet } from './Types';\nimport { toRGBA8888, BIG_ENDIAN, PALETTE_ANSI_256, PALETTE_VT340_COLOR } from 'sixel/lib/Colors';\nimport { RGBA8888 } from 'sixel/lib/Types';\nimport { ImageRenderer } from './ImageRenderer';\n\nimport { DecoderAsync, Decoder } from 'sixel/lib/Decoder';\n\n// always free decoder ressources after decoding if it exceeds this limit\nconst MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB\n\n// custom default palette: VT340 (lower 16 colors) + ANSI256 (up to 256) + zeroed (up to 4096)\nconst DEFAULT_PALETTE = PALETTE_ANSI_256;\nDEFAULT_PALETTE.set(PALETTE_VT340_COLOR);\n\n\nexport class SixelHandler implements IDcsHandler, IResetHandler {\n private _size = 0;\n private _aborted = false;\n private _dec: Decoder | undefined;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _storage: SixelImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n DecoderAsync({\n memoryLimit: this._opts.pixelLimit * 4,\n palette: DEFAULT_PALETTE,\n paletteLimit: this._opts.sixelPaletteLimit\n }).then(d => this._dec = d);\n }\n\n public reset(): void {\n /**\n * reset sixel decoder to defaults:\n * - release all memory\n * - nullify palette (4096)\n * - apply default palette (256)\n */\n if (this._dec) {\n this._dec.release();\n // FIXME: missing interface on decoder to nullify full palette\n (this._dec as any)._palette.fill(0);\n this._dec.init(0, DEFAULT_PALETTE, this._opts.sixelPaletteLimit);\n }\n }\n\n public hook(params: IParams): void {\n this._size = 0;\n this._aborted = false;\n if (this._dec) {\n const fillColor = params.params[1] === 1 ? 0 : extractActiveBg(\n this._coreTerminal._core._inputHandler._curAttrData,\n this._coreTerminal._core._themeService?.colors);\n this._dec.init(fillColor, null, this._opts.sixelPaletteLimit);\n }\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted || !this._dec) {\n return;\n }\n this._size += end - start;\n if (this._size > this._opts.sixelSizeLimit) {\n console.warn(`SIXEL: too much data, aborting`);\n this._aborted = true;\n this._dec.release();\n return;\n }\n try {\n this._dec.decode(data, start, end);\n } catch (e) {\n console.warn(`SIXEL: error while decoding image - ${e}`);\n this._aborted = true;\n this._dec.release();\n }\n }\n\n public unhook(success: boolean): boolean | Promise {\n if (this._aborted || !success || !this._dec) {\n return true;\n }\n\n const width = this._dec.width;\n const height = this._dec.height;\n\n // partial fix for https://github.com/jerch/xterm-addon-image/issues/37\n if (!width || !height) {\n if (height) {\n this._storage.advanceCursor(height);\n }\n return true;\n }\n\n const canvas = ImageRenderer.createCanvas(undefined, width, height);\n canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray, width, height), 0, 0);\n if (this._dec.memoryUsage > MEM_PERMA_LIMIT) {\n this._dec.release();\n }\n this._storage.addImage(canvas);\n return true;\n }\n}\n\n\n/**\n * Some helpers to extract current terminal colors.\n */\n\n// get currently active background color from terminal\n// also respect INVERSE setting\nfunction extractActiveBg(attr: AttributeData, colors: ReadonlyColorSet | undefined): RGBA8888 {\n let bg = 0;\n if (!colors) {\n // FIXME: theme service is prolly not available yet,\n // happens if .open() was not called yet (bug in core?)\n return bg;\n }\n if (attr.isInverse()) {\n if (attr.isFgDefault()) {\n bg = convertLe(colors.foreground.rgba);\n } else if (attr.isFgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getFgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getFgColor()].rgba);\n }\n } else {\n if (attr.isBgDefault()) {\n bg = convertLe(colors.background.rgba);\n } else if (attr.isBgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getBgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getBgColor()].rgba);\n }\n }\n return bg;\n}\n\n// rgba values on the color managers are always in BE, thus convert to LE\nfunction convertLe(color: number): RGBA8888 {\n if (BIG_ENDIAN) return color;\n return (color & 0xFF) << 24 | (color >>> 8 & 0xFF) << 16 | (color >>> 16 & 0xFF) << 8 | color >>> 24 & 0xFF;\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { IImageAddonOptions, ITerminalExt, IAddImageOpts } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\n\n/**\n * Sixel-specific image storage controller.\n *\n * Wraps the shared ImageStorage with sixel protocol semantics:\n * - Cursor behavior governed by DECSET 80 (sixelScrolling option)\n * - advanceCursor for empty sixels carrying only height\n */\nexport class SixelImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'vt340' };\n constructor(\n private readonly _storage: ImageStorage,\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _terminal: ITerminalExt\n ) {}\n\n /**\n * Add a sixel image to storage.\n * Cursor behavior depends on the sixelScrolling option (DECSET 80).\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._addImageOpts.scrolling = this._opts.sixelScrolling;\n this._storage.addImage(img, this._addImageOpts);\n }\n\n /**\n * Only advance text cursor.\n * This is an edge case from empty sixels carrying only a height but no pixels.\n * Partially fixes https://github.com/jerch/xterm-addon-image/issues/37.\n */\n public advanceCursor(height: number): void {\n if (this._opts.sixelScrolling) {\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const rows = Math.ceil(height / cellSize.height);\n for (let i = 1; i < rows; ++i) {\n this._terminal._core._inputHandler.lineFeed();\n }\n }\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IAddImageOpts } from './Types';\nimport { ImageStorage } from './ImageStorage';\n\n/**\n * IIP (iTerm Image Protocol) specific image storage controller.\n *\n * Wraps the shared ImageStorage with IIP protocol semantics:\n * - Always uses scrolling mode (cursor advances with image)\n */\nexport class IIPImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n constructor(\n private readonly _storage: ImageStorage\n ) {}\n\n /**\n * Add an IIP image to storage.\n * Always uses scrolling mode \u2014 cursor advances past the image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._storage.addImage(img, this._addImageOpts);\n }\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ITerminalAddon, IDisposable } from '@xterm/xterm';\nimport type { ImageAddon as IImageApi } from '@xterm/addon-image';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { IIPHandler } from './IIPHandler';\nimport { ImageRenderer } from './ImageRenderer';\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';\nimport { KittyImageStorage } from './kitty/KittyImageStorage';\nimport { SixelHandler } from './SixelHandler';\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';\n\n\n/**\n * Document VT features provided by this addon.\n *\n * @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL \"SIXEL Graphics\" \"DCS Ps ; Ps ; Ps ; q Pt ST\" \"Draw SIXEL image.\"\n *\n * Sixel support is provided by the addon @xterm/addon-image with these limitations:\n * - immediate coloring (no shared palette, allows high color settings of `img2sixel`)\n * - max. palette size of 4096 colors\n * - max. pixel width of 16K\n * - max. 25 MB per sixel sequence\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] OSC 1337 \"iTerm2 Commands\" \"OSC 1337 ; Pt BEL\" \"Custom iTerm2 commands.\"\n *\n * Only the inline image protocol (IIP) is supported by the addon @xterm/addon-image with\n * the following limitations:\n * - sequence:\n * - format: `OSC 1337 ; File=inline=1 ; size= ; ... : BEL`\n * - size param must be set and payload may not exceed CEIL(size * 4 / 3)\n * - strict base64 handling as of RFC4648 \u00A74 (standard alphabet, optional padding,\n * no separator bytes allowed)\n * - supported params: size, name, width, height, preserveAspectRatio\n * - image formats: PNG, JPEG and GIF\n * - no animation support (renders first image of a GIF)\n * - no multipart support\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image)\n * and [iTerm2 IIP docs](https://iterm2.com/documentation-images.html) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] APC KITTY_GRAPHICS \"Kitty Graphics\" \"APC G Pt ST\" \"Kitty Graphics Protocol.\"\n *\n * Kitty graphics support is provided by the addon @xterm/addon-image.\n * Note that while basic image output already works, this is still work in progress.\n */\n\n// default values of addon ctor options\nconst DEFAULT_OPTIONS: IImageAddonOptions = {\n enableSizeReports: true,\n pixelLimit: 16777216, // limit to 4096 * 4096 pixels\n sixelSupport: true,\n sixelScrolling: true,\n sixelPaletteLimit: 4096,\n sixelSizeLimit: 33554432,\n storageLimit: 128,\n showPlaceholder: true,\n iipSupport: true,\n iipSizeLimit: 33554432,\n kittySupport: true,\n kittySizeLimit: 33554432\n};\n\n// max palette size supported by the sixel lib (compile time setting)\nconst MAX_SIXEL_PALETTE_SIZE = 4096;\n\n// definitions for _xtermGraphicsAttributes sequence\nconst enum GaItem {\n COLORS = 1,\n SIXEL_GEO = 2,\n REGIS_GEO = 3\n}\nconst enum GaAction {\n READ = 1,\n SET_DEFAULT = 2,\n SET = 3,\n READ_MAX = 4\n}\nconst enum GaStatus {\n SUCCESS = 0,\n ITEM_ERROR = 1,\n ACTION_ERROR = 2,\n FAILURE = 3\n}\n\n\nexport class ImageAddon implements ITerminalAddon, IImageApi {\n private _opts: IImageAddonOptions;\n private _defaultOpts: IImageAddonOptions;\n private _storage: ImageStorage | undefined;\n private _renderer: ImageRenderer | undefined;\n private _disposables: IDisposable[] = [];\n private _terminal: ITerminalExt | undefined;\n private _handlers: Map = new Map();\n private readonly _onImageAdded = new Emitter();\n public readonly onImageAdded: IEvent = this._onImageAdded.event;\n\n constructor(opts?: Partial) {\n this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);\n this._defaultOpts = Object.assign({}, DEFAULT_OPTIONS, opts);\n }\n\n public dispose(): void {\n for (const handler of this._handlers.values()) handler.reset();\n for (const obj of this._disposables) {\n obj.dispose();\n }\n this._disposables.length = 0;\n this._handlers.clear();\n this._onImageAdded.dispose();\n }\n\n private _disposeLater(...args: IDisposable[]): void {\n for (const obj of args) {\n this._disposables.push(obj);\n }\n }\n\n public activate(terminal: ITerminalExt): void {\n this._terminal = terminal;\n\n // internal data structures\n this._renderer = new ImageRenderer(terminal);\n this._storage = new ImageStorage(terminal, this._renderer, this._opts);\n this._storage.onImageAdded = () => this._onImageAdded.fire();\n\n // enable size reports\n if (this._opts.enableSizeReports) {\n const windowOps = terminal.options.windowOptions ?? {};\n windowOps.getWinSizePixels = true;\n windowOps.getCellSizePixels = true;\n windowOps.getWinSizeChars = true;\n terminal.options.windowOptions = windowOps;\n }\n\n this._disposeLater(\n this._renderer,\n this._storage,\n\n // DECSET/DECRST/DA1/XTSMGRAPHICS handlers\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'h' }, params => this._decset(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'l' }, params => this._decrst(params)),\n terminal.parser.registerCsiHandler({ final: 'c' }, params => this._da1(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'S' }, params => this._xtermGraphicsAttributes(params)),\n\n // render hook\n terminal.onRender(range => this._storage?.render(range)),\n\n /**\n * reset handlers covered:\n * - DECSTR\n * - RIS\n * - Terminal.reset()\n */\n terminal.parser.registerCsiHandler({ intermediates: '!', final: 'p' }, () => this.reset()),\n terminal.parser.registerEscHandler({ final: 'c' }, () => this.reset()),\n terminal._core._inputHandler.onRequestReset(() => this.reset()),\n\n // wipe canvas and delete alternate images on buffer switch\n terminal.buffer.onBufferChange(() => this._storage?.wipeAlternate()),\n\n // extend images to the right on resize\n terminal.onResize(metrics => this._storage?.viewportResize(metrics))\n );\n\n // SIXEL handler\n if (this._opts.sixelSupport) {\n const sixelStorage = new SixelImageStorage(this._storage!, this._opts, this._renderer!, terminal);\n const sixelHandler = new SixelHandler(this._opts, sixelStorage, terminal);\n this._handlers.set('sixel', sixelHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler)\n );\n }\n\n // iTerm IIP handler\n if (this._opts.iipSupport) {\n const iipStorage = new IIPImageStorage(this._storage!);\n const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, terminal);\n this._handlers.set('iip', iipHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)\n );\n }\n\n // Kitty graphics handler\n if (this._opts.kittySupport) {\n const kittyStorage = new KittyImageStorage(this._storage!);\n const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, kittyStorage, terminal);\n this._handlers.set('kitty', kittyHandler);\n this._disposeLater(\n kittyStorage,\n kittyHandler,\n terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)\n );\n }\n }\n\n // Note: storageLimit is skipped here to not intoduce a surprising side effect.\n public reset(): boolean {\n // reset options customizable by sequences to defaults\n this._opts.sixelScrolling = this._defaultOpts.sixelScrolling;\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n // also clear image storage\n this._storage?.reset();\n // reset protocol handlers\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return false;\n }\n\n public get storageLimit(): number {\n return this._storage?.getLimit() || -1;\n }\n\n public set storageLimit(limit: number) {\n this._storage?.setLimit(limit);\n this._opts.storageLimit = limit;\n }\n\n public get storageUsage(): number {\n if (this._storage) {\n return this._storage.getUsage();\n }\n return -1;\n }\n\n public get showPlaceholder(): boolean {\n return this._opts.showPlaceholder;\n }\n\n public set showPlaceholder(value: boolean) {\n this._opts.showPlaceholder = value;\n this._renderer?.showPlaceholder(value);\n }\n\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.getImageAtBufferCell(x, y);\n }\n\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.extractTileAtBufferCell(x, y);\n }\n\n private _report(s: string): void {\n this._terminal?._core.input(s, false);\n }\n\n private _decset(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = false;\n break;\n }\n }\n return false;\n }\n\n private _decrst(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = true;\n break;\n }\n }\n return false;\n }\n\n // overload DA to return something more appropriate\n private _da1(params: (number | number[])[]): boolean {\n if (params[0]) {\n return true;\n }\n // reported features:\n // 62 - VT220\n // 4 - SIXEL support\n // 9 - charsets\n // 22 - ANSI colors\n if (this._opts.sixelSupport) {\n this._report(`\\x1b[?62;4;9;22c`);\n return true;\n }\n return false;\n }\n\n /**\n * Implementation of xterm's graphics attribute sequence.\n *\n * Supported features:\n * - read/change palette limits (max 4096 by sixel lib)\n * - read SIXEL canvas geometry (reports current window canvas or\n * squared pixelLimit if canvas > pixel limit)\n *\n * Everything else is deactivated.\n */\n private _xtermGraphicsAttributes(params: (number | number[])[]): boolean {\n if (params.length < 2) {\n return true;\n }\n if (params[0] === GaItem.COLORS) {\n switch (params[1]) {\n case GaAction.READ:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n return true;\n case GaAction.SET_DEFAULT:\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n // also reset protocol handlers for now\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return true;\n case GaAction.SET:\n if (params.length > 2 && !(params[2] instanceof Array) && params[2] <= MAX_SIXEL_PALETTE_SIZE) {\n this._opts.sixelPaletteLimit = params[2];\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n } else {\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n }\n return true;\n case GaAction.READ_MAX:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${MAX_SIXEL_PALETTE_SIZE}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n if (params[0] === GaItem.SIXEL_GEO) {\n switch (params[1]) {\n // we only implement read and read_max here\n case GaAction.READ:\n let width = this._renderer?.dimensions?.css.canvas.width;\n let height = this._renderer?.dimensions?.css.canvas.height;\n if (!width || !height) {\n // for some reason we have no working image renderer\n // --> fallback to default cell size\n const cellSize = CELL_SIZE_DEFAULT;\n width = (this._terminal?.cols || 80) * cellSize.width;\n height = (this._terminal?.rows || 24) * cellSize.height;\n }\n if (width * height < this._opts.pixelLimit) {\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${width.toFixed(0)};${height.toFixed(0)}S`);\n } else {\n // if we overflow pixelLimit report that squared instead\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n }\n return true;\n case GaAction.READ_MAX:\n // read_max returns pixelLimit as square area\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n // exit with error on ReGIS or any other requests\n this._report(`\\x1b[?${params[0]};${GaStatus.ITEM_ERROR}S`);\n return true;\n }\n}\n"], -+ "mappings": ";;;;;;;;;;;;;;;;02BAYaA,EAAA,WAAa,IAAI,WAAW,IAAI,YAAY,CAAC,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC,IAAM,IAClFA,EAAA,YACF,QAAQ,KAAK,6EAA6E,EAI5F,SAAgBC,GAAIC,EAAW,CAC7B,OAAOA,EAAI,GACb,CAFAF,EAAA,IAAAC,GAIA,SAAgBE,GAAMD,EAAW,CAC/B,OAAQA,IAAM,EAAK,GACrB,CAFAF,EAAA,MAAAG,GAIA,SAAgBC,GAAKF,EAAW,CAC9B,OAAQA,IAAM,GAAM,GACtB,CAFAF,EAAA,KAAAI,GAIA,SAAgBC,GAAMH,EAAW,CAC/B,OAAQA,IAAM,GAAM,GACtB,CAFAF,EAAA,MAAAK,GAQA,SAAgBC,EAAWC,EAAWC,EAAWC,EAAWC,EAAY,IAAG,CACzE,QAASA,EAAI,MAAS,IAAMD,EAAI,MAAS,IAAMD,EAAI,MAAS,EAAKD,EAAI,OAAW,CAClF,CAFAP,EAAA,WAAAM,EAQA,SAAgBK,GAAaC,EAAe,CAC1C,MAAO,CAACA,EAAQ,IAAOA,GAAS,EAAK,IAAOA,GAAS,GAAM,IAAMA,IAAU,EAAE,CAC/E,CAFAZ,EAAA,aAAAW,GASA,SAAgBE,GAAkBD,EAAiBE,EAAmB,CACpE,IAAMP,EAAIN,GAAIW,CAAK,EACbJ,EAAIL,GAAMS,CAAK,EACfH,EAAIL,GAAKQ,CAAK,EAEhBG,EAAM,OAAO,iBACbC,EAAM,GAGV,QAASC,EAAI,EAAGA,EAAIH,EAAQ,OAAQ,EAAEG,EAAG,CACvC,IAAMC,EAAKX,EAAIO,EAAQG,CAAC,EAAE,CAAC,EACrBE,EAAKX,EAAIM,EAAQG,CAAC,EAAE,CAAC,EACrBG,EAAKX,EAAIK,EAAQG,CAAC,EAAE,CAAC,EACrB,EAAIC,EAAKA,EAAKC,EAAKA,EAAKC,EAAKA,EACnC,GAAI,CAAC,EAAG,OAAOH,EACX,EAAIF,IACNA,EAAM,EACNC,EAAMC,GAIV,OAAOD,CACT,CAtBAhB,EAAA,kBAAAa,GA4BA,SAASQ,GAAMC,EAAaC,EAAcC,EAAa,CACrD,OAAO,KAAK,IAAIF,EAAK,KAAK,IAAIE,EAAOD,CAAI,CAAC,CAC5C,CAEA,SAASE,GAAIC,EAAYC,EAAYC,EAAS,CAC5C,OAAIA,EAAI,IAAGA,GAAK,GACZA,EAAI,IAAGA,GAAK,GACTA,EAAI,EAAI,EACXD,GAAMD,EAAKC,GAAM,EAAIC,EACrBA,EAAI,EAAI,EACNF,EACAE,EAAI,EAAI,EACND,GAAMD,EAAKC,IAAO,EAAIC,EAAI,GAC1BD,CACV,CAEA,SAASE,GAASC,EAAWC,EAAWC,EAAS,CAC/C,GAAI,CAACA,EAAG,CACN,IAAMC,EAAI,KAAK,MAAMF,EAAI,GAAG,EAC5B,OAAOzB,EAAW2B,EAAGA,EAAGA,CAAC,EAE3B,IAAMP,EAAKK,EAAI,GAAMA,GAAK,EAAIC,GAAKD,EAAIC,EAAID,EAAIC,EACzCL,EAAK,EAAII,EAAIL,EACnB,OAAOpB,EACLe,GAAM,EAAG,IAAK,KAAK,MAAMI,GAAIC,EAAIC,EAAIG,EAAI,EAAI,CAAC,EAAI,GAAG,CAAC,EACtDT,GAAM,EAAG,IAAK,KAAK,MAAMI,GAAIC,EAAIC,EAAIG,CAAC,EAAI,GAAG,CAAC,EAC9CT,GAAM,EAAG,IAAK,KAAK,MAAMI,GAAIC,EAAIC,EAAIG,EAAI,EAAI,CAAC,EAAI,GAAG,CAAC,CAAC,CAE3D,CAKA,SAAgBI,EAAa3B,EAAWC,EAAWC,EAAS,CAC1D,OAAQ,WAAa,KAAK,MAAMA,EAAI,IAAM,GAAG,GAAK,GAAK,KAAK,MAAMD,EAAI,IAAM,GAAG,GAAK,EAAI,KAAK,MAAMD,EAAI,IAAM,GAAG,KAAO,CACzH,CAFAP,EAAA,aAAAkC,EAQA,SAAgBC,GAAaL,EAAWC,EAAWC,EAAS,CAE1D,OAAOH,IAAUC,EAAI,IAAM,KAAO,IAAKC,EAAI,IAAKC,EAAI,GAAG,CACzD,CAHAhC,EAAA,aAAAmC,GAqCanC,EAAA,oBAAsB,IAAI,YAAY,CACjDkC,EAAc,EAAI,EAAI,CAAC,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACxB,EA0BYlC,EAAA,mBAAqB,IAAI,YAAY,CAChDkC,EAAc,EAAI,EAAI,CAAC,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAc,EAAI,EAAI,CAAC,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAc,EAAI,EAAI,CAAC,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAc,EAAI,EAAI,CAAC,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACxB,EAOYlC,EAAA,kBAAoB,IAAK,CAEpC,IAAMoC,EAAgB,CACpB9B,EAAW,EAAG,EAAG,CAAC,EAClBA,EAAW,IAAK,EAAG,CAAC,EACpBA,EAAW,EAAG,IAAK,CAAC,EACpBA,EAAW,IAAK,IAAK,CAAC,EACtBA,EAAW,EAAG,EAAG,GAAG,EACpBA,EAAW,IAAK,EAAG,GAAG,EACtBA,EAAW,EAAG,IAAK,GAAG,EACtBA,EAAW,IAAK,IAAK,GAAG,EACxBA,EAAW,IAAK,IAAK,GAAG,EACxBA,EAAW,IAAK,EAAG,CAAC,EACpBA,EAAW,EAAG,IAAK,CAAC,EACpBA,EAAW,IAAK,IAAK,CAAC,EACtBA,EAAW,GAAI,GAAI,GAAG,EACtBA,EAAW,IAAK,EAAG,GAAG,EACtBA,EAAW,EAAG,IAAK,GAAG,EACtBA,EAAW,IAAK,IAAK,GAAG,GAGpB+B,EAAI,CAAC,EAAG,GAAI,IAAK,IAAK,IAAK,GAAG,EACpC,QAAS9B,EAAI,EAAGA,EAAI,EAAG,EAAEA,EACvB,QAASC,EAAI,EAAGA,EAAI,EAAG,EAAEA,EACvB,QAASC,EAAI,EAAGA,EAAI,EAAG,EAAEA,EACvB2B,EAAE,KAAK9B,EAAW+B,EAAE9B,CAAC,EAAG8B,EAAE7B,CAAC,EAAG6B,EAAE5B,CAAC,CAAC,CAAC,EAKzC,QAASwB,EAAI,EAAGA,GAAK,IAAKA,GAAK,GAC7BG,EAAE,KAAK9B,EAAW2B,EAAGA,EAAGA,CAAC,CAAC,EAE5B,OAAO,IAAI,YAAYG,CAAC,CAC1B,GAAE,EASWpC,EAAA,mBAA+BM,EAAW,EAAG,EAAG,EAAG,GAAG,EACtDN,EAAA,mBAA+BM,EAAW,IAAK,IAAK,IAAK,GAAG,IChRzE,IAAAgC,GAAAC,EAAAC,IAAA,cAKA,OAAO,eAAeA,GAAS,aAAc,CAAE,MAAO,EAAK,CAAC,EAC5DA,GAAQ,OAASC,GACjB,IAAIC,EAAK,GAAM,CACX,GAAI,WAAW,WACX,OAAO,WAAW,WAAW,CAAC,EAClC,GAAI,OAAO,OAAW,IAClB,OAAO,OAAO,KAAK,EAAG,QAAQ,EAClC,IAAMC,EAAI,KAAK,CAAC,EACVC,EAAI,IAAI,WAAWD,EAAE,MAAM,EACjC,QAAS,EAAI,EAAG,EAAIC,EAAE,OAAQ,EAAE,EAC5BA,EAAE,CAAC,EAAID,EAAE,WAAW,CAAC,EACzB,OAAOC,CACX,EACA,SAASH,GAAOI,EAAK,CACjB,GAAIA,EAAI,EAAG,CACP,GAAM,CAAE,EAAAC,EAAG,EAAAC,EAAG,EAAAC,CAAE,EAAIH,EAChBF,EACAM,EACEC,EAAI,YACV,OAAIJ,IAAM,EACFC,EACQI,GAAM,IAAID,EAAE,SAASD,IAAMA,EAAI,IAAIC,EAAE,OAAOP,IAAMA,EAAID,EAAEM,CAAC,EAAE,GAAIG,CAAC,EACpEA,GAAMF,EACRC,EAAE,YAAYD,EAAGE,CAAC,EAClBD,EAAE,YAAYP,IAAMA,EAAID,EAAEM,CAAC,GAAIG,CAAC,EAAE,KAAKP,IAAMK,EAAIL,EAAE,SAAWA,EAAE,QAAQ,EAE9EE,IAAM,EACFC,EACO,IAAME,IAAMA,EAAI,IAAIC,EAAE,OAAOP,IAAMA,EAAID,EAAEM,CAAC,EAAE,GAChD,IAAMC,EACP,QAAQ,QAAQA,CAAC,EACjBC,EAAE,QAAQP,IAAMA,EAAID,EAAEM,CAAC,EAAE,EAAE,KAAKJ,GAAKK,EAAIL,CAAC,EAEhDG,EACO,IAAMJ,IAAMA,EAAID,EAAEM,CAAC,GACvB,IAAM,QAAQ,QAAQL,IAAMA,EAAID,EAAEM,CAAC,EAAE,CAChD,CACA,GAAI,OAAO,SAAa,IACpB,MAAM,IAAI,MAAM,mBAAmB,EACvC,SAAS,IAAIH,CAAG,CACpB,IC7CA,IAAAO,GAAAC,EAAAC,IAAA,cACA,OAAO,eAAeA,GAAS,aAAc,CAAE,MAAO,EAAK,CAAC,EAK5D,IAAMC,GAAmB,KAInBC,MAAiBD,GAAiB,QAAuD,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,0lCAA0lC,CAA8C,EAuJ5uCE,EAAM,IAAI,WAAW,mEACtB,MAAM,EAAE,EACR,IAAIC,GAAMA,EAAG,WAAW,CAAC,CAAC,CAAC,EAE1BC,EAAI,IAAI,YAAY,IAAI,EAC9BA,EAAE,KAAK,UAAU,EACjB,QAASC,EAAI,EAAGA,EAAIH,EAAI,OAAQ,EAAEG,EAC9BD,EAAEF,EAAIG,CAAC,CAAC,EAAIA,GAAK,EACrB,QAASA,EAAI,EAAGA,EAAIH,EAAI,OAAQ,EAAEG,EAC9BD,EAAE,IAAMF,EAAIG,CAAC,CAAC,EAAIA,GAAK,GAAMA,GAAK,EAAK,MAAS,EACpD,QAASA,EAAI,EAAGA,EAAIH,EAAI,OAAQ,EAAEG,EAC9BD,EAAE,IAAMF,EAAIG,CAAC,CAAC,EAAKA,GAAK,GAAM,GAAMA,GAAK,EAAK,MAAS,GAC3D,QAASA,EAAI,EAAGA,EAAIH,EAAI,OAAQ,EAAEG,EAC9BD,EAAE,IAAMF,EAAIG,CAAC,CAAC,EAAIA,GAAK,GAC3B,IAAMC,GAAQ,IAAI,WAAW,CAAC,EAWxBC,GAAN,KAAoB,CAMhB,YAAYC,EAAUC,EAAUC,EAAc,CAO1C,GANA,KAAK,MAAQ,KACb,KAAK,OAAS,GACd,KAAK,OAAS,EACd,KAAK,SAAWF,GAAsD,QACtE,KAAK,SAAWC,GAAsD,WACtE,KAAK,OAASC,GAAkE,MAC5E,KAAK,OAAS,KAAK,UAAY,KAAK,SAAW,WAC/C,MAAM,IAAI,MAAM,uBAAuB,CAE/C,CAKA,IAAI,OAAQ,CACR,OAAO,KAAK,MAAQ,KAAK,GAAG,SAAS,EAAG,KAAK,KAAK,IAAuB,CAAC,EAAIJ,EAClF,CAMA,SAAU,CACD,KAAK,QAEN,KAAK,OAAS,KAAK,SACnB,KAAK,MAAQ,KAAK,KAAO,KAAK,GAAK,KAAK,KAAO,MAG/C,KAAK,KAAK,IAAuB,EAAI,EACrC,KAAK,KAAK,IAAuB,EAAI,EACrC,KAAK,KAAK,IAAuB,EAAI,GAE7C,CASA,KAAKG,EAAUC,EAAc,CAGzB,GAFA,KAAK,SAAWD,GAAsD,KAAK,SAC3E,KAAK,OAASC,GAAkE,KAAK,IAAI,KAAK,OAAQ,KAAK,QAAQ,EAC/G,KAAK,OAAS,KAAK,UAAY,KAAK,SAAW,WAC/C,MAAM,MAAM,uBAAuB,EAEvC,IAAIC,EAAI,KAAK,KACPC,EAAQ,KAAK,OAAS,KACvB,KAAK,MAOD,KAAK,KAAK,OAAO,WAAaA,IACnC,KAAK,KAAK,KAAK,KAAK,MAAMA,EAAQ,KAAK,KAAK,OAAO,YAAc,KAAK,CAAC,EACvED,EAAI,IAAI,YAAY,KAAK,KAAK,OAAQ,CAAC,EACvC,KAAK,GAAK,IAAI,WAAW,KAAK,KAAK,OAAQ,IAA6B,IATxE,KAAK,KAAO,IAAI,YAAY,OAAO,CAAE,QAAS,KAAK,KAAKC,EAAQ,KAAK,CAAE,CAAC,EACxE,KAAK,MAAQX,GAAW,CAAE,IAAK,CAAE,OAAQ,KAAK,IAAK,CAAE,CAAC,EACtDU,EAAI,IAAI,YAAY,KAAK,KAAK,OAAQ,CAAC,EACvCA,EAAE,IAAIP,EAAG,GAAgB,EACzB,KAAK,GAAK,IAAI,WAAW,KAAK,KAAK,OAAQ,IAA6B,GAO5EO,EAAE,IAAuB,EAAI,EAC7BA,EAAE,IAAuB,EAAI,EAC7BA,EAAE,IAAuB,EAAI,EAC7B,KAAK,KAAOA,EACZ,KAAK,OAAS,EAClB,CAOA,SAASE,EAAW,CAChB,IAAMC,EAAS,KAAK,KAAK,IAAuB,EAAID,EACpD,GAAI,KAAK,OAASC,EAAQ,CACtB,GAAIA,EAAS,KAAK,SACd,MAAO,GAEX,IAAIC,EAAU,KAAK,OACnB,MAAQA,GAAW,GAAKD,GAAQ,CAEhC,GADAC,EAAU,KAAK,IAAIA,EAAS,KAAK,QAAQ,EACrCA,EAAUD,EACV,MAAO,GAEX,GAAIC,EAAU,KAAgC,KAAK,KAAK,OAAO,WAAY,CACvE,IAAMC,EAAW,KAAK,MAAMD,EAAU,KAAgC,KAAK,KAAK,OAAO,YAAc,KAAK,EAC1G,KAAK,KAAK,KAAKC,CAAQ,EACvB,KAAK,KAAO,IAAI,YAAY,KAAK,KAAK,OAAQ,CAAC,EAC/C,KAAK,GAAK,IAAI,WAAW,KAAK,KAAK,OAAQ,IAA6B,CAC5E,CACA,KAAK,OAASD,CAClB,CACA,MAAO,EACX,CAOA,IAAIE,EAAM,CACN,GAAI,CAAC,KAAK,OAAS,KAAK,OACpB,MAAO,GAEX,GAAI,KAAK,SAASA,EAAK,MAAM,EACzB,MAAO,GAEX,IAAMN,EAAI,KAAK,KACf,YAAK,GAAG,IAAIM,EAAMN,EAAE,IAAuB,CAAC,EAC5CA,EAAE,IAAuB,GAAKM,EAAK,OAE5BN,EAAE,IAAuB,EAAIA,EAAE,IAAuB,GAAK,OAC5D,KAAK,MAAM,QAAQ,IAAI,EACvB,CACV,CAKA,KAAM,CACF,YAAK,OAAS,GACP,KAAK,MACN,KAAK,MAAM,QAAQ,IAAI,EACvB,EACV,CAIA,IAAI,aAAc,CACd,OAAO,KAAK,MACN,KAAK,KAAK,IAAuB,EACjC,CACV,CAIA,IAAI,WAAY,CACZ,OAAO,KAAK,MACN,KAAK,SAAW,KAAK,KAAK,IAAuB,EACjD,CACV,CACJ,EACAZ,GAAQ,QAAUQ,KCjVlB,IAAAW,GAAAC,EAAAC,IAAA,cACA,OAAO,eAAeA,GAAS,aAAc,CAAE,MAAO,EAAK,CAAC,EAK5D,IAAMC,GAAmB,KACnBC,MAAoBD,GAAiB,QAA2D,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,84BAA84B,CAAkD,EAC3iCE,GAAN,KAAiB,CACb,YAAYC,EAAU,CAClB,KAAK,SAAWA,EAChB,KAAK,MAAQ,EACb,KAAK,OAAS,CAClB,CACA,OAAOC,EAAG,CACN,KAAK,MAAQA,EAAE,CAAC,GAAK,GAAKA,EAAE,CAAC,GAAK,GAAKA,EAAE,CAAC,GAAK,EAAIA,EAAE,CAAC,EACtD,KAAK,OAASA,EAAE,CAAC,GAAK,GAAKA,EAAE,CAAC,GAAK,GAAKA,EAAE,EAAE,GAAK,EAAIA,EAAE,EAAE,EACzD,IAAMC,EAAS,KAAK,MAAQ,KAAK,OAC3BC,EAAKD,EAAS,EACdE,EAAKH,EAAE,OAwBPI,EAAQ,KAAK,IAAIF,EAAIC,CAAE,GAAK,KAAK,IAAID,EAAIC,CAAE,GAAK,GAAK,KACtD,KAAK,MAID,KAAK,KAAK,OAAO,WAAaC,IACnC,KAAK,KAAK,KAAK,KAAK,MAAMA,EAAQ,KAAK,KAAK,OAAO,YAAc,KAAK,CAAC,EACvE,KAAK,GAAK,OALV,KAAK,KAAO,IAAI,YAAY,OAAO,CAAE,QAAS,KAAK,KAAKA,EAAQ,KAAK,CAAE,CAAC,EACxE,KAAK,MAAQP,GAAc,CAAE,IAAK,CAAE,OAAQ,KAAK,IAAK,CAAE,CAAC,GAMxD,KAAK,KACN,KAAK,GAAK,IAAI,WAAW,KAAK,KAAK,MAAM,GAG7C,IAAMQ,EAAU,KAAK,KAAK,OAAO,WAAaF,EAAM,KACpD,YAAK,GAAG,IAAIH,EAAGK,CAAM,EACrB,KAAK,MAAM,QAAQ,IAAIA,EAAQF,EAAIF,CAAM,EAClC,KAAK,GAAG,SAAS,KAAqB,KAAsBC,CAAE,CACzE,CACA,SAAU,CACD,KAAK,OAEN,KAAK,KAAK,OAAO,WAAa,KAAK,WACnC,KAAK,MAAQ,KAAK,GAAK,KAAK,KAAO,KAE3C,CACJ,EACAP,GAAQ,QAAUG,mGCpELQ,GAAA,OAAS,CACpB,WAAY,MACZ,aAAc,KACd,UAAW,MACX,MAAO,wtdCCT,IAAAC,EAAA,IACAC,EAAA,KAIA,SAASC,GAAa,EAAS,CAC7B,GAAI,OAAO,OAAW,IACpB,OAAO,OAAO,KAAK,EAAG,QAAQ,EAEhC,IAAMC,EAAa,KAAK,CAAC,EACnBC,EAAS,IAAI,WAAWD,EAAW,MAAM,EAC/C,QAAS,EAAI,EAAG,EAAIC,EAAO,OAAQ,EAAE,EACnCA,EAAO,CAAC,EAAID,EAAW,WAAW,CAAC,EAErC,OAAOC,CACT,CAEA,IAAMC,GAAaH,GAAaD,EAAA,OAAO,KAAK,EACxCK,EAGEC,GAAc,IAAI,YAIlBC,GAAN,KAAmB,CAAnB,aAAA,CACS,KAAA,YAAeC,GAAkB,EACjC,KAAA,YAAeC,GAAoB,CAO5C,CANS,YAAYD,EAAa,CAC9B,OAAO,KAAK,YAAYA,CAAK,CAC/B,CACO,YAAYC,EAAY,CAC7B,OAAO,KAAK,YAAYA,CAAI,CAC9B,GAKIC,GAA2C,CAC/C,YAAa,KAAO,MACpB,WAAYX,EAAA,mBACZ,UAAWA,EAAA,mBACX,QAASA,EAAA,oBACT,aAAcC,EAAA,OAAO,aACrB,SAAU,IAQZ,SAAgBW,GAAaC,EAAsB,CACjD,IAAMC,EAAU,IAAIN,GACdO,EAAY,CAChB,IAAK,CACH,YAAaD,EAAQ,YAAY,KAAKA,CAAO,EAC7C,YAAaA,EAAQ,YAAY,KAAKA,CAAO,IAGjD,OAAO,YAAY,YAAYR,GAAeD,GAAYU,CAAS,EAChE,KAAMC,IACLV,EAAcA,GAAeU,EAAK,OAC3B,IAAIC,EAAQJ,EAAMG,EAAK,UAAYA,EAAMF,CAAO,EACxD,CACL,CAbAI,EAAA,aAAAN,GAgDA,IAAaK,EAAb,KAAoB,CAwGlB,YACEJ,EACAM,EACAC,EAAwB,CAGxB,GAvGM,KAAA,cAAgBnB,EAAA,OAAO,UAAY,EAEnC,KAAA,QAAuBM,GACvB,KAAA,YAAwB,CAAA,EACxB,KAAA,UAAY,EACZ,KAAA,UAAYN,EAAA,OAAO,UACnB,KAAA,YAAc,EACd,KAAA,eAAiB,EA+FvB,KAAK,MAAQ,OAAO,OAAO,CAAA,EAAIU,GAAiBE,CAAI,EAChD,KAAK,MAAM,aAAeZ,EAAA,OAAO,aACnC,MAAM,IAAI,MAAM,+CAA+CA,EAAA,OAAO,YAAY,EAAE,EAEtF,GAAKkB,EASHC,EAAU,YAAc,KAAK,aAAa,KAAK,IAAI,EACnDA,EAAU,YAAc,KAAK,YAAY,KAAK,IAAI,MAVpC,CACd,IAAMC,EAASf,IAAgBA,EAAc,IAAI,YAAY,OAAOD,EAAU,GAC9Ec,EAAY,IAAI,YAAY,SAASE,EAAQ,CAC3C,IAAK,CACH,YAAa,KAAK,aAAa,KAAK,IAAI,EACxC,YAAa,KAAK,YAAY,KAAK,IAAI,GAE1C,EAKH,KAAK,UAAYF,EACjB,KAAK,MAAQ,KAAK,UAAU,QAC5B,KAAK,OAAS,IAAI,WAAW,KAAK,MAAM,OAAO,OAAQ,KAAK,MAAM,kBAAiB,EAAIlB,EAAA,OAAO,UAAU,EACxG,KAAK,QAAU,IAAI,YAAY,KAAK,MAAM,OAAO,OAAQ,KAAK,MAAM,kBAAiB,EAAI,EAAE,EAC3F,KAAK,SAAW,IAAI,YAAY,KAAK,MAAM,OAAO,OAAQ,KAAK,MAAM,oBAAmB,EAAIA,EAAA,OAAO,YAAY,EAC/G,KAAK,SAAS,IAAI,KAAK,MAAM,OAAO,EACpC,KAAK,MAAQ,IAAI,YAAY,KAAK,MAAM,OAAO,OAAQ,KAAK,MAAM,eAAc,CAAE,EAClF,KAAK,MAAM,KAAKD,EAAA,mBAAoB,EAAG,KAAK,MAAM,aAAc,CAAC,CACnE,CApHA,IAAY,YAAU,CAAe,OAAO,KAAK,QAAQ,CAAC,CAAG,CAC7D,IAAY,WAAS,CAAa,OAAO,KAAK,QAAQ,CAAC,CAAG,CAC1D,IAAY,cAAY,CAAa,OAAO,KAAK,QAAQ,CAAC,CAAG,CAC7D,IAAY,eAAa,CAAa,OAAO,KAAK,QAAQ,CAAC,CAAG,CAC9D,IAAY,QAAM,CAAa,OAAO,KAAK,QAAQ,CAAC,EAAI,KAAK,QAAQ,CAAC,EAAI,EAAI,CAAG,CACjF,IAAY,SAAO,CAAa,OAAO,KAAK,QAAQ,CAAC,CAAG,CACxD,IAAY,QAAM,CAAa,OAAO,KAAK,QAAQ,CAAC,CAAG,CACvD,IAAY,OAAK,CAAgB,OAAO,KAAK,QAAQ,EAAE,CAAG,CAC1D,IAAY,eAAa,CAAa,OAAO,KAAK,QAAQ,EAAE,CAAG,CAEvD,YAAYU,EAAe,CACjC,GAAIA,IAAI,EAAmB,CACzB,IAAMY,EAAS,KAAK,MAAQ,KAAK,OACjC,GAAIA,EAAS,KAAK,QAAQ,OAAQ,CAChC,GAAI,KAAK,MAAM,aAAeA,EAAS,EAAI,KAAK,MAAM,YACpD,WAAK,QAAO,EACN,IAAI,MAAM,4BAA4B,EAE9C,KAAK,QAAU,IAAI,YAAYA,CAAM,EAEvC,KAAK,UAAY,KAAK,eACbZ,IAAI,EACb,GAAI,KAAK,SAAW,EAAG,CAErB,IAAMY,EAAS,KAAK,IAAI,KAAK,aAAcrB,EAAA,OAAO,SAAS,EAAI,KAAK,cACpE,GAAIqB,EAAS,KAAK,QAAQ,OAAQ,CAChC,GAAI,KAAK,MAAM,aAAeA,EAAS,EAAI,KAAK,MAAM,YACpD,WAAK,QAAO,EACN,IAAI,MAAM,4BAA4B,EAE9C,KAAK,QAAU,IAAI,YAAYA,CAAM,QAInC,KAAK,QAAQ,OAAS,QACxB,KAAK,QAAU,IAAI,YAAY,KAAK,GAI1C,MAAO,EACT,CAEQ,SAASC,EAAgBC,EAAwB,CACvD,IAAMF,EAASC,EAASC,EACxB,GAAIF,EAAS,KAAK,QAAQ,OAAQ,CAChC,GAAI,KAAK,MAAM,aAAeA,EAAS,EAAI,KAAK,MAAM,YACpD,WAAK,QAAO,EACN,IAAI,MAAM,4BAA4B,EAG9C,IAAMG,EAAY,IAAI,YAAY,KAAK,KAAKH,EAAS,KAAK,EAAI,KAAK,EACnEG,EAAU,IAAI,KAAK,OAAO,EAC1B,KAAK,QAAUA,EAEnB,CAEQ,aAAahB,EAAa,CAChC,IAAMiB,EAAM,KAAK,cACbH,EAAS,KAAK,YAClB,GAAI,KAAK,QAAK,EAAmB,CAC/B,IAAII,EAAY,KAAK,OAAS,KAAK,eAC/BC,EAAI,EACR,KAAOA,EAAI,GAAKD,EAAY,GAC1B,KAAK,QAAQ,IAAI,KAAK,MAAM,SAASD,EAAME,EAAGF,EAAME,EAAInB,CAAK,EAAGc,EAASd,EAAQmB,CAAC,EAClFA,IACAD,IAEF,KAAK,aAAelB,EAAQmB,EAC5B,KAAK,gBAAkBA,UACd,KAAK,QAAK,EAAmB,CACtC,KAAK,SAASL,EAAQd,EAAQ,CAAC,EAC/B,KAAK,UAAY,KAAK,IAAI,KAAK,UAAWA,CAAK,EAC/C,KAAK,UAAY,KAAK,IAAI,KAAK,UAAWA,CAAK,EAC/C,QAASoB,EAAI,EAAGA,EAAI,EAAG,EAAEA,EACvB,KAAK,QAAQ,IAAI,KAAK,MAAM,SAASH,EAAMG,EAAGH,EAAMG,EAAIpB,CAAK,EAAGc,EAASd,EAAQoB,CAAC,EAEpF,KAAK,YAAY,KAAKpB,CAAK,EAC3B,KAAK,aAAeA,EAAQ,EAC5B,KAAK,gBAAkB,EAEzB,MAAO,EACT,CA0CA,IAAW,OAAK,CACd,OAAO,KAAK,QAAK,EACb,KAAK,OACL,KAAK,IAAI,KAAK,UAAW,KAAK,MAAM,cAAa,CAAE,CACzD,CAOA,IAAW,QAAM,CACf,OAAO,KAAK,QAAK,EACb,KAAK,QACL,KAAK,MAAM,cAAa,EACtB,KAAK,YAAY,OAAS,EAAI,KAAK,MAAM,eAAc,EACvD,KAAK,YAAY,OAAS,CAClC,CAKA,IAAW,SAAO,CAChB,OAAO,KAAK,SAAS,SAAS,EAAG,KAAK,aAAa,CACrD,CAWA,IAAW,aAAW,CACpB,OAAO,KAAK,QAAQ,WAAa,KAAK,MAAM,OAAO,OAAO,WAAa,EAAI,KAAK,YAAY,MAC9F,CAKA,IAAW,YAAU,CACnB,MAAO,CACL,MAAO,KAAK,MACZ,OAAQ,KAAK,OACb,KAAM,KAAK,MACX,MAAO,KAAK,OACZ,SAAU,CAAC,CAAC,KAAK,UACjB,aAAc,KAAK,cACnB,UAAW,KAAK,WAChB,SAAU,KAAK,YACf,iBAAkB,CAChB,UAAW,KAAK,QAAQ,CAAC,EACzB,YAAa,KAAK,QAAQ,CAAC,EAC3B,MAAO,KAAK,aACZ,OAAQ,KAAK,eAGnB,CAOO,KACLqB,EAAsB,KAAK,MAAM,UACjCC,EAA8B,KAAK,MAAM,QACzCC,EAAuB,KAAK,MAAM,aAClCC,EAAoB,KAAK,MAAM,SAAQ,CAEvC,KAAK,MAAM,KAAK,KAAK,MAAM,WAAYH,EAAWE,EAAcC,EAAW,EAAI,CAAC,EAC5EF,GACF,KAAK,SAAS,IAAIA,EAAQ,SAAS,EAAG9B,EAAA,OAAO,YAAY,CAAC,EAE5D,KAAK,YAAY,OAAS,EAC1B,KAAK,UAAY,EACjB,KAAK,UAAYA,EAAA,OAAO,UACxB,KAAK,YAAc,EACnB,KAAK,eAAiB,CACxB,CAMO,OAAOiC,EAAsBC,EAAgB,EAAGC,EAAcF,EAAK,OAAM,CAC9E,IAAIG,EAAIF,EACR,KAAOE,EAAID,GAAK,CACd,IAAME,EAAS,KAAK,IAAIF,EAAMC,EAAGpC,EAAA,OAAO,UAAU,EAClD,KAAK,OAAO,IAAIiC,EAAK,SAASG,EAAGA,GAAKC,CAAM,CAAC,EAC7C,KAAK,MAAM,OAAO,EAAGA,CAAM,EAE/B,CAOO,aAAaJ,EAAcC,EAAgB,EAAGC,EAAcF,EAAK,OAAM,CAC5E,IAAIG,EAAIF,EACR,KAAOE,EAAID,GAAK,CACd,IAAME,EAAS,KAAK,IAAIF,EAAMC,EAAGpC,EAAA,OAAO,UAAU,EAClD,QAAS4B,EAAI,EAAGU,EAAIF,EAAGR,EAAIS,EAAQ,EAAET,EAAG,EAAEU,EACxC,KAAK,OAAOV,CAAC,EAAIK,EAAK,WAAWK,CAAC,EAEpCF,GAAKC,EACL,KAAK,MAAM,OAAO,EAAGA,CAAM,EAE/B,CAMA,IAAW,QAAM,CACf,GAAI,KAAK,QAAK,GAAqB,CAAC,KAAK,OAAS,CAAC,KAAK,OACtD,OAAO/B,GAIT,IAAMiC,EAAe,KAAK,MAAM,cAAa,EAE7C,GAAI,KAAK,QAAK,EAAmB,CAC/B,IAAIb,EAAY,KAAK,OAAS,KAAK,eACnC,GAAIA,EAAY,EAAG,CACjB,IAAMD,EAAM,KAAK,cACbH,EAAS,KAAK,YACdK,EAAI,EACR,KAAOA,EAAI,GAAKD,EAAY,GAC1B,KAAK,QAAQ,IAAI,KAAK,MAAM,SAASD,EAAME,EAAGF,EAAME,EAAIY,CAAY,EAAGjB,EAASiB,EAAeZ,CAAC,EAChGA,IACAD,IAEEA,GACF,KAAK,QAAQ,KAAK,KAAK,WAAYJ,EAASiB,EAAeZ,CAAC,EAGhE,OAAO,KAAK,QAAQ,SAAS,EAAG,KAAK,MAAQ,KAAK,MAAM,EAG1D,GAAI,KAAK,QAAK,EAAmB,CAC/B,GAAI,KAAK,YAAc,KAAK,UAAW,CACrC,IAAIa,EAAS,GACb,GAAID,EACF,GAAIA,IAAiB,KAAK,UACxBC,EAAS,OACJ,CACL,IAAMf,EAAM,KAAK,cACbH,EAAS,KAAK,YAClB,KAAK,SAASA,EAAQiB,EAAe,CAAC,EACtC,QAASX,EAAI,EAAGA,EAAI,EAAG,EAAEA,EACvB,KAAK,QAAQ,IAAI,KAAK,MAAM,SAASH,EAAMG,EAAGH,EAAMG,EAAIW,CAAY,EAAGjB,EAASiB,EAAeX,CAAC,EAItG,GAAI,CAACY,EACH,OAAO,KAAK,QAAQ,SAAS,EAAG,KAAK,MAAQ,KAAK,MAAM,EAM5D,IAAMC,EAAQ,IAAI,YAAY,KAAK,MAAQ,KAAK,MAAM,EACtDA,EAAM,KAAK,KAAK,UAAU,EAC1B,IAAIC,EAAc,EACdR,EAAQ,EACZ,QAASN,EAAI,EAAGA,EAAI,KAAK,YAAY,OAAQ,EAAEA,EAAG,CAChD,IAAMe,EAAK,KAAK,YAAYf,CAAC,EAC7B,QAASQ,EAAI,EAAGA,EAAI,EAAG,EAAEA,EACvBK,EAAM,IAAI,KAAK,QAAQ,SAASP,EAAOA,GAASS,CAAE,EAAGD,CAAW,EAChEA,GAAe,KAAK,MAIxB,GAAIH,EAAc,CAChB,IAAMd,EAAM,KAAK,cAEXmB,EAAgB,KAAK,MAAM,eAAc,EAC/C,QAAShB,EAAI,EAAGA,EAAIgB,EAAe,EAAEhB,EACnCa,EAAM,IAAI,KAAK,MAAM,SAAShB,EAAMG,EAAGH,EAAMG,EAAIW,CAAY,EAAGG,EAAc,KAAK,MAAQd,CAAC,EAGhG,OAAOa,EAIT,OAAOnC,EACT,CAMA,IAAW,OAAK,CACd,OAAO,IAAI,kBAAkB,KAAK,OAAO,OAAQ,EAAG,KAAK,MAAQ,KAAK,OAAS,CAAC,CAClF,CAcO,SAAO,CACZ,KAAK,QAAUA,GACf,KAAK,YAAY,OAAS,EAC1B,KAAK,UAAY,EACjB,KAAK,UAAYN,EAAA,OAAO,UAGxB,KAAK,MAAM,KAAKD,EAAA,mBAAoB,EAAG,KAAK,MAAM,aAAc,CAAC,CACnE,GAxWFkB,EAAA,QAAAD,EA2XC,SAAgB6B,GACfZ,EACArB,EAAsB,CAEtB,IAAMkC,EAAM,IAAI9B,EAAQJ,CAAI,EAC5B,OAAAkC,EAAI,KAAI,EACR,OAAOb,GAAS,SAAWa,EAAI,aAAab,CAAI,EAAIa,EAAI,OAAOb,CAAI,EAC5D,CACL,MAAOa,EAAI,MACX,OAAQA,EAAI,OACZ,OAAQA,EAAI,OACZ,MAAOA,EAAI,MAEf,CAbC7B,EAAA,OAAA4B,GAoBM,eAAeE,GACpBd,EACArB,EAAsB,CAEtB,IAAMkC,EAAM,MAAMnC,GAAaC,CAAI,EACnC,OAAAkC,EAAI,KAAI,EACR,OAAOb,GAAS,SAAWa,EAAI,aAAab,CAAI,EAAIa,EAAI,OAAOb,CAAI,EAC5D,CACL,MAAOa,EAAI,MACX,OAAQA,EAAI,OACZ,OAAQA,EAAI,OACZ,MAAOA,EAAI,MAEf,CAbA7B,EAAA,YAAA8B,KC7eO,SAASC,EAAaC,EAA6B,CACxD,MAAO,CAAE,QAASA,CAAG,CACvB,CAuBO,IAAMC,EAAN,KAA6C,CAA7C,cACL,KAAiB,aAAe,IAAI,IACpC,KAAQ,YAAc,GAEtB,IAAW,YAAsB,CAC/B,OAAO,KAAK,WACd,CAEO,IAA2BC,EAAS,CACzC,OAAI,KAAK,YACPA,EAAE,QAAQ,EAEV,KAAK,aAAa,IAAIA,CAAC,EAElBA,CACT,CAEO,SAAgB,CACrB,GAAI,MAAK,YAGT,MAAK,YAAc,GACnB,QAAWC,KAAK,KAAK,aACnBA,EAAE,QAAQ,EAEZ,KAAK,aAAa,MAAM,EAC1B,CAEO,OAAc,CACnB,QAAWA,KAAK,KAAK,aACnBA,EAAE,QAAQ,EAEZ,KAAK,aAAa,MAAM,CAC1B,CACF,EAEsBC,EAAf,KAAiD,CAAjD,cAGL,KAAmB,OAAS,IAAIH,EAEzB,SAAgB,CACrB,KAAK,OAAO,QAAQ,CACtB,CAEU,UAAiCC,EAAS,CAClD,OAAO,KAAK,OAAO,IAAIA,CAAC,CAC1B,CACF,EAZsBE,EACG,KAAoB,OAAO,OAAO,CAAE,SAAU,CAAE,CAAE,CAAC,EAarE,IAAMC,EAAN,KAAsE,CAAtE,cAEL,KAAQ,YAAc,GAEtB,IAAW,OAAuB,CAChC,OAAO,KAAK,YAAc,OAAY,KAAK,MAC7C,CAEA,IAAW,MAAMC,EAAsB,CACjC,KAAK,aAAeA,IAAU,KAAK,SAGvC,KAAK,QAAQ,QAAQ,EACrB,KAAK,OAASA,EAChB,CAEO,OAAc,CACnB,KAAK,MAAQ,MACf,CAEO,SAAgB,CACrB,KAAK,YAAc,GACnB,KAAK,QAAQ,QAAQ,EACrB,KAAK,OAAS,MAChB,CACF,EClGO,IAAMC,EAAN,KAAiB,CAAjB,cACL,KAAQ,WAAqD,CAAC,EAC9D,KAAQ,UAAY,GAGpB,IAAW,OAAmB,CAC5B,OAAI,KAAK,OACA,KAAK,QAEd,KAAK,OAAS,CAACC,EAAyBC,EAAgBC,IAAkD,CACxG,GAAI,KAAK,UACP,OAAOC,EAAa,IAAM,CAAC,CAAC,EAG9B,IAAMC,EAAQ,CAAE,GAAIJ,EAAU,SAAAC,CAAS,EACvC,KAAK,WAAa,KAAK,WAAW,MAAM,EACxC,KAAK,WAAW,KAAKG,CAAK,EAE1B,IAAMC,EAASF,EAAa,IAAM,CAChC,IAAMG,EAAM,KAAK,WAAW,QAAQF,CAAK,EACrCE,IAAQ,KACV,KAAK,WAAa,KAAK,WAAW,MAAM,EACxC,KAAK,WAAW,OAAOA,EAAK,CAAC,EAEjC,CAAC,EAED,OAAIJ,IACE,MAAM,QAAQA,CAAW,EAC3BA,EAAY,KAAKG,CAAM,EAEvBH,EAAY,IAAIG,CAAM,GAInBA,CACT,EACO,KAAK,OACd,CAEO,KAAKE,EAAgB,CAC1B,GAAI,KAAK,WAAa,CAAC,KAAK,WAAW,OACrC,OAEF,GAAI,KAAK,WAAW,SAAW,EAAG,CAChC,KAAK,WAAW,CAAC,EAAE,GAAG,KAAK,KAAK,WAAW,CAAC,EAAE,SAAUA,CAAK,EAC7D,MACF,CACA,IAAMC,EAAY,KAAK,WACvB,QAAS,EAAI,EAAGC,EAAMD,EAAU,OAAQ,EAAIC,EAAK,EAAE,EACjDD,EAAU,CAAC,EAAE,GAAG,KAAKA,EAAU,CAAC,EAAE,SAAUD,CAAK,CAErD,CAEO,SAAgB,CACjB,KAAK,YAGT,KAAK,UAAY,GACjB,KAAK,WAAW,OAAS,EAC3B,CACF,EAEiBG,OAAV,CACE,SAASC,EAAWC,EAAiBC,EAA6B,CACvE,OAAOD,EAAKE,GAAKD,EAAG,KAAKC,CAAC,CAAC,CAC7B,CAFOJ,EAAS,QAAAC,EAIT,SAASI,EAAUR,EAAkBQ,EAA6B,CACvE,MAAO,CAACf,EAAyBC,EAAgBC,IACxCK,EAAMS,GAAKhB,EAAS,KAAKC,EAAUc,EAAIC,CAAC,CAAC,EAAG,OAAWd,CAAW,CAE7E,CAJOQ,EAAS,IAAAK,EAQT,SAASE,KAAUC,EAAgC,CACxD,MAAO,CAAClB,EAAyBC,EAAgBC,IAAkD,CACjG,IAAMiB,EAAQ,IAAIC,EAClB,QAAWb,KAASW,EAClBC,EAAM,IAAIZ,EAAMO,GAAKd,EAAS,KAAKC,EAAUa,CAAC,CAAC,CAAC,EAElD,OAAIZ,IACE,MAAM,QAAQA,CAAW,EAC3BA,EAAY,KAAKiB,CAAK,EAEtBjB,EAAY,IAAIiB,CAAK,GAGlBA,CACT,CACF,CAfOT,EAAS,IAAAO,EAmBT,SAASI,EAAmBd,EAAkBe,EAAqCC,EAA0B,CAClH,OAAAD,EAAQC,CAAO,EACRhB,EAAMO,GAAKQ,EAAQR,CAAC,CAAC,CAC9B,CAHOJ,EAAS,gBAAAW,IAhCDX,KAAA,ICvEjB,IAAAc,GAA2B,OAgBpB,IAAMC,EAAN,MAAMC,UAAsBC,CAAkC,CAmDnE,YAAoBC,EAAyB,CAC3C,MAAM,EADY,eAAAA,EAhDpB,KAAQ,QAAU,IAAI,IAGtB,KAAQ,gBAAkB,KAAK,UAAU,IAAIC,CAAmB,EA+C9D,KAAK,SAAW,KAAK,UAAU,MAAM,KACrC,KAAK,UAAU,MAAM,KAAQC,GAA8B,CACzD,KAAK,UAAU,KAAK,KAAK,UAAU,MAAOA,CAAM,EAChD,KAAK,MAAM,CACb,EACI,KAAK,UAAU,MAAM,eACvB,KAAK,MAAM,EAGb,KAAK,gBAAgB,MAAQ,KAAK,UAAU,MAAM,eAAe,eAAeC,GAAU,CACpFA,IAAW,aACb,KAAK,cAAc,EACnB,KAAK,gBAAgB,YAAY,EAAG,KAAK,UAAU,IAAI,EAE3D,CAAC,EACD,KAAK,UAAUC,EAAa,IAAM,CAChC,KAAK,mBAAmB,EACxB,KAAK,mBAAmB,QAAQ,EAC5B,KAAK,UAAU,OAAS,KAAK,WAC/B,KAAK,UAAU,MAAM,KAAO,KAAK,SACjC,KAAK,SAAW,QAEd,KAAK,gBAAkB,KAAK,kBAC9B,KAAK,eAAe,YAAc,KAAK,gBACvC,KAAK,gBAAkB,QAEzB,KAAK,eAAiB,OACtB,KAAK,QAAQ,MAAM,EACnB,KAAK,oBAAoB,MAAM,EAC/B,KAAK,mBAAqB,OAC1B,KAAK,aAAe,MACtB,CAAC,CAAC,CACJ,CAnFA,IAAW,QAAwC,CAAE,OAAO,KAAK,QAAQ,IAAI,KAAK,GAAG,MAAQ,CAU7F,OAAc,aAAaC,EAAqCC,EAAeC,EAAmC,CAUhH,IAAMC,GAAUH,GAAiB,UAAU,cAAc,QAAQ,EACjE,OAAAG,EAAO,MAAQF,EAAQ,EACvBE,EAAO,OAASD,EAAS,EAClBC,CACT,CAGA,OAAc,gBAAgBC,EAA+BH,EAAeC,EAAgBG,EAAiC,CAC3H,GAAI,OAAO,WAAc,WAAY,CACnC,IAAMC,EAAUF,EAAI,gBAAgBH,EAAOC,CAAM,EACjD,OAAIG,GACFC,EAAQ,KAAK,IAAI,IAAI,kBAAkBD,EAAQ,EAAGJ,EAAQC,EAAS,CAAC,CAAC,EAEhEI,CACT,CACA,OAAOD,EACH,IAAI,UAAU,IAAI,kBAAkBA,EAAQ,EAAGJ,EAAQC,EAAS,CAAC,EAAGD,EAAOC,CAAM,EACjF,IAAI,UAAUD,EAAOC,CAAM,CACjC,CAGA,OAAc,kBAAkBK,EAA0D,CACxF,OAAI,OAAO,mBAAsB,WACxB,QAAQ,QAAQ,MAAS,EAE3B,kBAAkBA,CAAG,CAC9B,CA0CO,gBAAgBC,EAAsB,CACvCA,EACE,CAAC,KAAK,cAAgB,KAAK,SAAS,SAAW,IACjD,KAAK,mBAAmB,KAAK,IAAI,KAAK,SAAS,OAAS,EAAG,EAA4B,CAAC,GAG1F,KAAK,oBAAoB,MAAM,EAC/B,KAAK,mBAAqB,OAC1B,KAAK,aAAe,QAEtB,KAAK,gBAAgB,YAAY,EAAG,KAAK,UAAU,IAAI,CACzD,CAMA,IAAW,YAA4C,CACrD,OAAO,KAAK,UAAU,UACxB,CAKA,IAAW,UAAsB,CAC/B,MAAO,CACL,MAAO,KAAK,YAAY,IAAI,KAAK,OAAS,GAC1C,OAAQ,KAAK,YAAY,IAAI,KAAK,QAAU,EAC9C,CACF,CAKO,WAAWC,EAAeC,EAAaC,EAA0B,CACtE,IAAMC,EAAIH,GAAS,KAAK,YAAY,IAAI,KAAK,QAAU,GACjDI,EAAI,KAAK,YAAY,IAAI,OAAO,OAAS,EACzCC,GAAKJ,EAAM,EAAID,IAAU,KAAK,YAAY,IAAI,KAAK,QAAU,IAC/D,CAACE,GAASA,IAAU,QACtB,KAAK,QAAQ,IAAI,KAAK,GAAG,UAAU,EAAGC,EAAGC,EAAGC,CAAC,GAE3C,CAACH,GAASA,IAAU,WACtB,KAAK,QAAQ,IAAI,QAAQ,GAAG,UAAU,EAAGC,EAAGC,EAAGC,CAAC,CAEpD,CAKO,SAASH,EAA0B,CACxC,GAAI,CAACA,GAASA,IAAU,MAAO,CAC7B,IAAMP,EAAM,KAAK,QAAQ,IAAI,KAAK,EAClCA,GAAK,UAAU,EAAG,EAAGA,EAAI,OAAO,MAAOA,EAAI,OAAO,MAAM,CAC1D,CACA,GAAI,CAACO,GAASA,IAAU,SAAU,CAChC,IAAMP,EAAM,KAAK,QAAQ,IAAI,QAAQ,EACrCA,GAAK,UAAU,EAAG,EAAGA,EAAI,OAAO,MAAOA,EAAI,OAAO,MAAM,CAC1D,CACF,CAKO,KAAKW,EAAqBC,EAAgBC,EAAaC,EAAaC,EAAgB,EAAS,CAClG,IAAMf,EAAM,KAAK,QAAQ,IAAIW,EAAQ,KAAK,EAC1C,GAAI,CAACX,EACH,OAEF,GAAM,CAAE,MAAAH,EAAO,OAAAC,CAAO,EAAI,KAAK,SAG/B,GAAID,IAAU,IAAMC,IAAW,GAC7B,OAGF,KAAK,cAAca,EAASd,EAAOC,CAAM,EACzC,IAAMK,EAAMQ,EAAQ,OACd,CAAE,MAAOK,EAAa,OAAQC,CAAa,EAAIN,EAAQ,eACvDO,EAAO,KAAK,KAAKf,EAAI,MAAQa,CAAW,EAExCG,EAAMP,EAASM,EAAQF,EACvBI,EAAK,KAAK,MAAMR,EAASM,CAAI,EAAID,EACjCI,EAAKR,EAAMhB,EACXyB,EAAKR,EAAMhB,EAGXyB,EAAaR,EAAQC,EAAcG,EAAKhB,EAAI,MAAQA,EAAI,MAAQgB,EAAKJ,EAAQC,EAC7EQ,EAAcJ,EAAKH,EAAed,EAAI,OAASA,EAAI,OAASiB,EAAKH,EAMvEjB,EAAI,UACFG,EACA,KAAK,MAAMgB,CAAE,EAAG,KAAK,MAAMC,CAAE,EAAG,KAAK,KAAKG,CAAU,EAAG,KAAK,KAAKC,CAAW,EAC5E,KAAK,MAAMH,CAAE,EAAG,KAAK,MAAMC,CAAE,EAAG,KAAK,KAAKC,EAAa1B,EAAQmB,CAAW,EAAG,KAAK,KAAKQ,EAAc1B,EAASmB,CAAY,CAC5H,CACF,CAKO,YAAYN,EAAqBC,EAA+C,CACrF,GAAM,CAAE,MAAAf,EAAO,OAAAC,CAAO,EAAI,KAAK,SAE/B,GAAID,IAAU,IAAMC,IAAW,GAC7B,OAEF,KAAK,cAAca,EAASd,EAAOC,CAAM,EACzC,IAAMK,EAAMQ,EAAQ,OACd,CAAE,MAAOK,EAAa,OAAQC,CAAa,EAAIN,EAAQ,eACvDO,EAAO,KAAK,KAAKf,EAAI,MAAQa,CAAW,EACxCG,EAAMP,EAASM,EAAQF,EACvBI,EAAK,KAAK,MAAMR,EAASM,CAAI,EAAID,EACjCM,EAAaP,EAAcG,EAAKhB,EAAI,MAAQA,EAAI,MAAQgB,EAAKH,EAC7DQ,EAAcJ,EAAKH,EAAed,EAAI,OAASA,EAAI,OAASiB,EAAKH,EAEjElB,EAASV,EAAc,aAAa,KAAK,SAAU,KAAK,KAAKkC,EAAa1B,EAAQmB,CAAW,EAAG,KAAK,KAAKQ,EAAc1B,EAASmB,CAAY,CAAC,EAC9IjB,EAAMD,EAAO,WAAW,IAAI,EAClC,GAAIC,EACF,OAAAA,EAAI,UACFG,EACA,KAAK,MAAMgB,CAAE,EAAG,KAAK,MAAMC,CAAE,EAAG,KAAK,MAAMG,CAAU,EAAG,KAAK,MAAMC,CAAW,EAC9E,EAAG,EAAGzB,EAAO,MAAOA,EAAO,MAC7B,EACOA,CAEX,CAKO,gBAAgBc,EAAaC,EAAaC,EAAgB,EAAS,CACxE,IAAMf,EAAM,KAAK,QAAQ,IAAI,KAAK,EAClC,GAAIA,EAAK,CACP,GAAM,CAAE,MAAAH,EAAO,OAAAC,CAAO,EAAI,KAAK,SAY/B,GATID,IAAU,IAAMC,IAAW,KAI1B,KAAK,aAECA,GAAU,KAAK,aAAc,QACtC,KAAK,mBAAmBA,EAAS,CAAC,EAFlC,KAAK,mBAAmB,KAAK,IAAIA,EAAS,EAAG,EAA4B,CAAC,EAIxE,CAAC,KAAK,cAAc,OACxBE,EAAI,UACF,KAAK,oBAAsB,KAAK,aAChCa,EAAMhB,EACLiB,EAAMhB,EAAU,EAAI,EAAI,EACzBD,EAAQkB,EACRjB,EACAe,EAAMhB,EACNiB,EAAMhB,EACND,EAAQkB,EACRjB,CACF,CACF,CACF,CAMO,eAAsB,CAC3B,IAAMW,EAAI,KAAK,YAAY,IAAI,OAAO,OAAS,EACzCC,EAAI,KAAK,YAAY,IAAI,OAAO,QAAU,EAChD,QAAWV,KAAO,KAAK,QAAQ,OAAO,GAChCA,EAAI,OAAO,QAAUS,GAAKT,EAAI,OAAO,SAAWU,KAClDV,EAAI,OAAO,MAAQS,EACnBT,EAAI,OAAO,OAASU,EAG1B,CAKQ,cAAce,EAAkBC,EAAsBC,EAA6B,CACzF,GAAID,IAAiBD,EAAK,eAAe,OAASE,IAAkBF,EAAK,eAAe,OACtF,OAEF,GAAM,CAAE,MAAOG,EAAe,OAAQC,CAAe,EAAIJ,EAAK,aAC9D,GAAIC,IAAiBE,GAAiBD,IAAkBE,EAAgB,CACtEJ,EAAK,OAASA,EAAK,KACnBA,EAAK,eAAe,MAAQG,EAC5BH,EAAK,eAAe,OAASI,EAC7B,MACF,CACA,IAAMC,EAAc,KAAK,KAAKL,EAAK,KAAM,MAAQC,EAAeE,CAAa,EACvEG,EAAe,KAAK,KAAKN,EAAK,KAAM,OAASE,EAAgBE,CAAc,EAEjF,GAAIC,EAAcC,EAAeN,EAAK,KAAM,MAAQA,EAAK,KAAM,OAAQ,CACrEA,EAAK,OAASA,EAAK,KACnBA,EAAK,eAAe,MAAQG,EAC5BH,EAAK,eAAe,OAASI,EAC7B,MACF,CACA,IAAM9B,EAASV,EAAc,aAAa,KAAK,SAAUyC,EAAaC,CAAY,EAC5E/B,EAAMD,EAAO,WAAW,IAAI,EAC9BC,IACFA,EAAI,UAAUyB,EAAK,KAAO,EAAG,EAAG1B,EAAO,MAAOA,EAAO,MAAM,EAC3D0B,EAAK,OAAS1B,EACd0B,EAAK,eAAe,MAAQC,EAC5BD,EAAK,eAAe,OAASE,EAEjC,CAKQ,OAAc,CACpB,KAAK,eAAiB,KAAK,UAAU,MAAM,eAC3C,KAAK,gBAAkB,KAAK,eAAe,YAAY,KAAK,KAAK,cAAc,EAC/E,KAAK,eAAe,YAAeK,GAAkB,CACnD,QAAWC,IAAO,CAAC,GAAG,KAAK,QAAQ,KAAK,CAAC,EACvC,KAAK,mBAAmBA,CAAG,EAE7B,KAAK,iBAAiB,KAAK,KAAK,eAAgBD,CAAQ,CAC1D,CACF,CAEO,iBAAiBzB,EAAoB,MAAa,CAEvD,GAAI,CAAC,KAAK,UAAY,CAAC,KAAK,UAAU,MAAM,cAAe,CACzD,QAAQ,KAAK,oFAAoF,EACjG,MACF,CACA,GAAI,KAAK,QAAQ,IAAIA,CAAK,EACxB,OAEF,IAAMR,EAASV,EAAc,aAC3B,KAAK,SAAU,KAAK,YAAY,IAAI,OAAO,OAAS,EACpD,KAAK,YAAY,IAAI,OAAO,QAAU,CACxC,EACAU,EAAO,UAAU,IAAI,qBAAqBQ,CAAK,EAAE,EACjD,IAAM2B,EAAgB,KAAK,UAAU,MAAM,cAI3CA,EAAc,MAAM,UAAY,UAC5B3B,IAAU,UAKZR,EAAO,MAAM,OAAS,KACtBmC,EAAc,aAAanC,EAAQmC,EAAc,UAAU,IAK3DnC,EAAO,MAAM,OAAS,IACtBmC,EAAc,YAAYnC,CAAM,GAElC,IAAMC,EAAMD,EAAO,WAAW,KAAM,CAAE,MAAO,EAAK,CAAC,EACnD,GAAI,CAACC,EAAK,CACRD,EAAO,OAAO,EACd,MACF,CACA,KAAK,QAAQ,IAAIQ,EAAOP,CAAG,EAC3B,KAAK,SAASO,CAAK,CACrB,CAEO,mBAAmBA,EAAoB,MAAa,CACzD,IAAMP,EAAM,KAAK,QAAQ,IAAIO,CAAK,EAC9BP,IACFA,EAAI,OAAO,OAAO,EAClB,KAAK,QAAQ,OAAOO,CAAK,EAE7B,CAEO,SAASA,EAA4B,CAC1C,OAAO,KAAK,QAAQ,IAAIA,CAAK,CAC/B,CAEQ,mBAAmBT,EAAiB,GAAoC,CAC9E,KAAK,oBAAoB,MAAM,EAC/B,KAAK,mBAAqB,OAG1B,IAAMqC,EAAS,GACTC,EAAY/C,EAAc,aAAa,KAAK,SAAU8C,EAAQrC,CAAM,EACpEE,EAAMoC,EAAU,WAAW,KAAM,CAAE,MAAO,EAAM,CAAC,EACvD,GAAI,CAACpC,EAAK,OACV,IAAME,EAAUb,EAAc,gBAAgBW,EAAKmC,EAAQrC,CAAM,EAC3DuC,EAAM,IAAI,YAAYnC,EAAQ,KAAK,MAAM,EACzCoC,KAAQ,eAAW,EAAG,EAAG,CAAC,EAC1BC,KAAQ,eAAW,IAAK,IAAK,GAAG,EACtCF,EAAI,KAAKC,CAAK,EACd,QAAS9B,EAAI,EAAGA,EAAIV,EAAQ,EAAEU,EAAG,CAC/B,IAAMgC,EAAQhC,EAAI,EACZiC,EAASjC,EAAI2B,EACnB,QAASO,EAAI,EAAGA,EAAIP,EAAQO,GAAK,EAC/BL,EAAII,EAASC,EAAIF,CAAK,EAAID,CAE9B,CACAvC,EAAI,aAAaE,EAAS,EAAG,CAAC,EAG9B,IAAML,EAAS,OAAO,MAAQsC,EAAS,EAAK,EAAEA,EAAS,IAAM,KAC7D,KAAK,aAAe9C,EAAc,aAAa,KAAK,SAAUQ,EAAOC,CAAM,EAC3E,IAAM6C,EAAO,KAAK,aAAa,WAAW,KAAM,CAAE,MAAO,EAAM,CAAC,EAChE,GAAI,CAACA,EAAM,CACT,KAAK,aAAe,OACpB,MACF,CACA,QAASC,EAAI,EAAGA,EAAI/C,EAAO+C,GAAKT,EAC9BQ,EAAK,UAAUP,EAAWQ,EAAG,CAAC,EAEhC,IAAMC,EAAc,KAAK,aACzBxD,EAAc,kBAAkBwD,CAAW,EAAE,KAAKC,GAAU,CACtD,KAAK,eAAiBD,EAAaC,GAAQ,MAAM,EAChD,KAAK,mBAAqBA,CACjC,CAAC,EAAE,MAAM,IAAM,CAAC,CAAC,CACnB,CAEA,IAAW,UAAiC,CAC1C,OAAO,KAAK,UAAU,MAAM,qBAAqB,OAAO,QAC1D,CACF,ECnaO,IAAMC,EAA+B,CAC1C,MAAO,EACP,OAAQ,EACV,EAQMC,EAAN,MAAMC,CAAkD,CAqDtD,YACEC,EAAc,EACdC,EAAgB,EACTC,EAAU,GACVC,EAAS,GAChB,CAFO,aAAAD,EACA,YAAAC,EAxDT,KAAQ,KAAe,EA4CvB,KAAQ,OAAiB,EAcvB,KAAK,KAAOH,EACZ,KAAK,OAASC,CAChB,CA3DA,IAAW,KAAc,CACvB,OAAI,KAAK,OAEJ,KAAK,KAAO,WACZ,KAAK,gBAAkB,GAGrB,KAAK,IACd,CACA,IAAW,IAAIG,EAAe,CAAE,KAAK,KAAOA,CAAO,CAEnD,IAAW,gBAAiC,CAE1C,OAAI,KAAK,UAGD,KAAK,KAAO,YAA6B,EACnD,CACA,IAAW,eAAeA,EAAuB,CAC/C,KAAK,MAAQ,WACb,KAAK,MAASA,GAAS,GAAM,SAC/B,CAEA,IAAW,gBAAyB,CAClC,OAAO,KAAK,KAAQ,QACtB,CACA,IAAW,eAAeA,EAAe,CACvC,KAAK,MAAQ,UACb,KAAK,MAAQA,EAAS,QACxB,CAEA,IAAW,wBAAiC,CAC1C,IAAMC,GAAO,KAAK,KAAO,aAA4B,GACrD,OAAIA,EAAM,EACDA,EAAM,WAERA,CACT,CACA,IAAW,uBAAuBD,EAAe,CAC/C,KAAK,MAAQ,UACb,KAAK,MAASA,GAAS,GAAM,UAC/B,CAGA,IAAW,OAAgB,CACzB,OAAO,KAAK,MACd,CACA,IAAW,MAAMA,EAAe,CAC9B,KAAK,OAASA,CAChB,CAYO,OAA6B,CASlC,OAAO,IAAIL,EAAmB,KAAK,KAAM,KAAK,OAAQ,KAAK,QAAS,KAAK,MAAM,CACjF,CAEO,SAAmB,CACxB,OAAO,KAAK,iBAAmB,GAAuB,KAAK,SAAW,GAAK,KAAK,UAAY,EAC9F,CACF,EACMO,EAAc,IAAIR,EAUXS,EAAN,KAA0C,CAkB/C,YACUC,EACAC,EACAC,EACR,CAHQ,eAAAF,EACA,eAAAC,EACA,WAAAC,EAnBV,KAAQ,QAAmC,IAAI,IAE/C,KAAQ,QAAU,EAElB,KAAQ,UAAY,EAEpB,KAAQ,cAAgB,GAExB,KAAQ,gBAAkB,GAE1B,KAAQ,YAAsB,KAW5B,GAAI,CACF,KAAK,SAAS,KAAK,MAAM,YAAY,CACvC,OAASC,EAAY,CACfA,aAAa,OACf,QAAQ,MAAMA,EAAE,OAAO,EAEzB,QAAQ,KAAK,0BAA0B,KAAK,SAAS,CAAC,KAAK,CAC7D,CACA,KAAK,iBAAmB,CACtB,KAAM,KAAK,UAAU,KACrB,KAAM,KAAK,UAAU,IACvB,CACF,CAEO,SAAgB,CACrB,KAAK,MAAM,CACb,CAEO,OAAc,CACnB,QAAWC,KAAQ,KAAK,QAAQ,OAAO,EACrCA,EAAK,QAAQ,QAAQ,EAIvB,KAAK,QAAQ,MAAM,EACnB,KAAK,UAAU,SAAS,CAC1B,CAEO,UAAmB,CACxB,OAAO,KAAK,YAAc,EAAI,GAChC,CAEO,SAASR,EAAqB,CACnC,GAAIA,EAAQ,IAAOA,EAAQ,IACzB,MAAM,WAAW,mEAAmE,EAEtF,KAAK,YAAeA,EAAQ,EAAI,MAAa,EAC7C,KAAK,aAAa,CAAC,CACrB,CAEO,UAAmB,CACxB,OAAO,KAAK,iBAAiB,EAAI,EAAI,GACvC,CAEQ,kBAA2B,CACjC,IAAIS,EAAe,EACnB,QAAWD,KAAQ,KAAK,QAAQ,OAAO,EACjCA,EAAK,OACPC,GAAgBD,EAAK,KAAK,MAAQA,EAAK,KAAK,OACxCA,EAAK,QAAUA,EAAK,SAAWA,EAAK,OACtCC,GAAgBD,EAAK,OAAO,MAAQA,EAAK,OAAO,SAItD,OAAOC,CACT,CAEQ,QAAQC,EAAkB,CAChC,IAAMF,EAAO,KAAK,QAAQ,IAAIE,CAAE,EAC3BF,IACL,KAAK,QAAQ,OAAOE,CAAE,EAElB,OAAO,aAAeF,EAAK,gBAAgB,aAC7CA,EAAK,KAAK,MAAM,EAElB,KAAK,iBAAiBE,CAAE,EAC1B,CAKO,eAAsB,CAE3B,IAAMC,EAAO,CAAC,EACd,OAAW,CAACD,EAAIF,CAAI,IAAK,KAAK,QAAQ,QAAQ,EACxCA,EAAK,aAAe,cACtBA,EAAK,QAAQ,QAAQ,EACrBG,EAAK,KAAKD,CAAE,GAGhB,QAAWA,KAAMC,EACf,KAAK,QAAQD,CAAE,EAGjB,KAAK,gBAAkB,GACvB,KAAK,cAAgB,EACvB,CAMO,YAAYA,EAAkB,CACnC,IAAMF,EAAO,KAAK,QAAQ,IAAIE,CAAE,EAC5BF,IACFA,EAAK,QAAQ,QAAQ,EACrB,KAAK,QAAQE,CAAE,EAEnB,CAaO,SAASE,EAAsCC,EAA6B,CAEjF,KAAK,aAAaD,EAAI,MAAQA,EAAI,MAAM,EAGxC,IAAIE,EAAW,KAAK,UAAU,UAC1BA,EAAS,QAAU,IAAMA,EAAS,SAAW,MAC/CA,EAAWrB,GAEb,IAAMsB,EAAO,KAAK,KAAKH,EAAI,MAAQE,EAAS,KAAK,EAC3CE,EAAO,KAAK,KAAKJ,EAAI,OAASE,EAAS,MAAM,EAE7ChB,EAAU,EAAE,KAAK,QAEjBmB,EAAS,KAAK,UAAU,MAAM,OAC9BC,EAAW,KAAK,UAAU,KAC1BC,EAAW,KAAK,UAAU,KAC1BC,EAAUH,EAAO,EACjBI,EAAUJ,EAAO,EACnBK,EAASF,EACTG,EAAY,EAEXV,EAAK,YACRI,EAAO,EAAI,EACXA,EAAO,EAAI,EACXK,EAAS,GAGX,KAAK,UAAU,MAAM,cAAc,iBAAiB,UAAUL,EAAO,CAAC,EACtE,QAASO,EAAM,EAAGA,EAAMR,EAAM,EAAEQ,EAAK,CACnC,IAAMC,EAAOR,EAAO,MAAM,IAAIA,EAAO,EAAIA,EAAO,KAAK,EACrD,QAASS,EAAM,EAAGA,EAAMX,GAClB,EAAAO,EAASI,GAAOR,GADQ,EAAEQ,EAE9B,KAAK,aAAaD,EAAwBH,EAASI,EAAK5B,EAAS0B,EAAMT,EAAOW,CAAG,EACjFH,IAEF,GAAIV,EAAK,UACHW,EAAMR,EAAO,GAAG,KAAK,UAAU,MAAM,cAAc,SAAS,UAE5D,EAAEC,EAAO,GAAKE,EAAU,MAE9BF,EAAO,EAAIK,CACb,CACA,KAAK,UAAU,MAAM,cAAc,iBAAiB,UAAUL,EAAO,CAAC,EAGlEJ,EAAK,UACHA,EAAK,YAAc,MACrBI,EAAO,EAAI,KAAK,IAAIK,EAASP,EAAMG,CAAQ,EAE3CD,EAAO,EAAIK,GAGbL,EAAO,EAAIG,EACXH,EAAO,EAAII,GAIb,IAAMV,EAAO,CAAC,EACd,OAAW,CAACD,EAAIF,CAAI,IAAK,KAAK,QAAQ,QAAQ,EACxCA,EAAK,UAAY,IACnBA,EAAK,QAAQ,QAAQ,EACrBG,EAAK,KAAKD,CAAE,GAGhB,QAAWA,KAAMC,EACf,KAAK,QAAQD,CAAE,EAKjB,IAAMiB,EAAY,KAAK,UAAU,eAAe,CAAC,EACjDA,GAAW,UAAU,IAAM,CACZ,KAAK,QAAQ,IAAI7B,CAAO,GAEnC,KAAK,QAAQA,CAAO,CAExB,CAAC,EAIG,KAAK,UAAU,OAAO,OAAO,OAAS,aACxC,KAAK,kBAAkB,EAIzB,IAAM8B,EAAsB,CAC1B,KAAMhB,EACN,aAAcE,EACd,OAAQF,EACR,eAAgB,CAAE,GAAGE,CAAS,EAC9B,OAAQa,GAAa,OACrB,UAAAJ,EACA,WAAY,KAAK,UAAU,OAAO,OAAO,KACzC,MAAOV,EAAK,MACZ,OAAQA,EAAK,MACf,EAGA,YAAK,QAAQ,IAAIf,EAAS8B,CAAO,EACjC,KAAK,eAAe,EACb9B,CACT,CAQO,OAAO+B,EAA6C,CAEzD,IAAIC,EAAe,GACfC,EAAkB,GACtB,QAAWvB,KAAQ,KAAK,QAAQ,OAAO,EAMrC,GALIA,EAAK,QAAU,SACjBuB,EAAkB,GAElBD,EAAe,GAEbA,GAAgBC,EAAiB,MAIvC,GAAID,GAAgB,CAAC,KAAK,UAAU,SAAS,KAAK,IAChD,KAAK,UAAU,iBAAiB,KAAK,EACjC,CAAC,KAAK,UAAU,SAAS,KAAK,GAAG,OAUvC,GARIC,GAAmB,CAAC,KAAK,UAAU,SAAS,QAAQ,GACtD,KAAK,UAAU,iBAAiB,QAAQ,EAI1C,KAAK,UAAU,cAAc,EAGzB,CAAC,KAAK,QAAQ,KAAM,CACjB,KAAK,gBACR,KAAK,UAAU,SAAS,EACxB,KAAK,cAAgB,GACrB,KAAK,gBAAkB,IAErB,KAAK,UAAU,SAAS,KAAK,GAC/B,KAAK,UAAU,mBAAmB,KAAK,EAErC,KAAK,UAAU,SAAS,QAAQ,GAClC,KAAK,UAAU,mBAAmB,QAAQ,EAE5C,MACF,CAGI,CAACD,GAAgB,KAAK,UAAU,SAAS,KAAK,IAChD,KAAK,UAAU,SAAS,KAAK,EAC7B,KAAK,UAAU,mBAAmB,KAAK,GAErC,CAACC,GAAmB,KAAK,UAAU,SAAS,QAAQ,IACtD,KAAK,UAAU,SAAS,QAAQ,EAChC,KAAK,UAAU,mBAAmB,QAAQ,GAIxC,KAAK,kBACP,KAAK,UAAU,SAAS,EACxB,KAAK,cAAgB,GACrB,KAAK,gBAAkB,IAGzB,GAAM,CAAE,MAAAC,EAAO,IAAAC,CAAI,EAAIJ,EACjBZ,EAAS,KAAK,UAAU,MAAM,OAC9BF,EAAO,KAAK,UAAU,MAAM,KAGlC,KAAK,UAAU,WAAWiB,EAAOC,CAAG,EAGpC,IAAMC,EAAgG,CAAC,EACjGC,EAAkE,CAAC,EAGzE,QAASX,EAAMQ,EAAOR,GAAOS,EAAK,EAAET,EAAK,CACvC,IAAMC,EAAOR,EAAO,MAAM,IAAIO,EAAMP,EAAO,KAAK,EAChD,GAAI,CAACQ,EAAM,OACX,QAASC,EAAM,EAAGA,EAAMX,EAAM,EAAEW,EAC9B,GAAID,EAAK,MAAMC,CAAG,EAAI,UAAsB,CAC1C,IAAInB,EAAyBkB,EAAK,eAAeC,CAAG,GAAKxB,EACnDJ,EAAUS,EAAE,QAClB,GAAIT,IAAY,QAAaA,IAAY,GACvC,SAEF,IAAM8B,EAAU,KAAK,QAAQ,IAAI9B,CAAO,EACxC,GAAIS,EAAE,SAAW,GAAI,CACnB,IAAM6B,EAAY7B,EAAE,OACd8B,EAAWX,EACbY,EAAQ,EAOZ,KACE,EAAEZ,EAAMX,GACJU,EAAK,MAAMC,CAAG,EAAI,YAClBnB,EAAIkB,EAAK,eAAeC,CAAG,GAAKxB,IAChCK,EAAE,UAAYT,GACdS,EAAE,SAAW6B,EAAYE,GAE7BA,IAEFZ,IACIE,EACEA,EAAQ,QACVM,EAAU,KAAK,CAAE,QAAAN,EAAS,OAAQQ,EAAW,IAAKC,EAAU,IAAAb,EAAK,MAAAc,CAAM,CAAC,EAEjE,KAAK,MAAM,iBACpBH,EAAiB,KAAK,CAAE,IAAKE,EAAU,IAAAb,EAAK,MAAAc,CAAM,CAAC,EAErD,KAAK,cAAgB,EACvB,CACF,CAEJ,CAGAJ,EAAU,KAAK,CAACK,EAAGC,IAAMD,EAAE,QAAQ,OAASC,EAAE,QAAQ,MAAM,EAG5D,QAAWC,KAAQN,EACjB,KAAK,UAAU,gBAAgBM,EAAK,IAAKA,EAAK,IAAKA,EAAK,KAAK,EAI/D,QAAWA,KAAQP,EACjB,KAAK,UAAU,KAAKO,EAAK,QAASA,EAAK,OAAQA,EAAK,IAAKA,EAAK,IAAKA,EAAK,KAAK,CAEjF,CAEO,eAAeC,EAA+C,CAEnE,GAAI,CAAC,KAAK,QAAQ,KAAM,CACtB,KAAK,iBAAmBA,EACxB,MACF,CAIA,GAAI,KAAK,iBAAiB,MAAQA,EAAQ,KAAM,CAC9C,KAAK,iBAAmBA,EACxB,MACF,CAGA,IAAMzB,EAAS,KAAK,UAAU,MAAM,OAC9BD,EAAOC,EAAO,MAAM,OACpB0B,EAAS,KAAK,iBAAiB,KAAO,EAC5C,QAASnB,EAAM,EAAGA,EAAMR,EAAM,EAAEQ,EAAK,CACnC,IAAMC,EAAOR,EAAO,MAAM,IAAIO,CAAG,EACjC,GAAIC,EAAK,MAAMkB,CAAM,EAAI,UAAsB,CAC7C,IAAMpC,EAAyBkB,EAAK,eAAekB,CAAM,GAAKzC,EACxDJ,EAAUS,EAAE,QAClB,GAAIT,IAAY,QAAaA,IAAY,GACvC,SAEF,IAAM8B,EAAU,KAAK,QAAQ,IAAI9B,CAAO,EACxC,GAAI,CAAC8B,EACH,SAGF,IAAMgB,EAAc,KAAK,MAAMhB,EAAQ,QAAQ,OAAS,GAAKA,EAAQ,eAAe,KAAK,EACzF,GAAKrB,EAAE,OAASqC,EAAe,GAAKA,EAClC,SAGF,IAAIC,EAAU,GACd,QAASC,EAAWH,EAAS,EAAGG,EAAWJ,EAAQ,KAAM,EAAEI,EACzD,GAAIrB,EAAK,MAAMqB,EAAW,EAAY,CAAY,EAAI,QAA0B,CAC9ED,EAAU,GACV,KACF,CAEF,GAAIA,EACF,SAGF,IAAMZ,EAAM,KAAK,IAAIS,EAAQ,KAAME,EAAerC,EAAE,OAASqC,EAAeD,CAAM,EAC9EI,EAAWxC,EAAE,OACjB,QAASyC,EAAYL,EAAS,EAAGK,EAAYf,EAAK,EAAEe,EAClD,KAAK,aAAavB,EAAwBuB,EAAWlD,EAAS,EAAEiD,CAAQ,EACxEnB,EAAQ,WAEZ,CACF,CAEA,KAAK,iBAAmBc,CAC1B,CAKO,qBAAqBO,EAAWC,EAA0C,CAE/E,IAAMzB,EADS,KAAK,UAAU,MAAM,OAChB,MAAM,IAAIyB,CAAC,EAC/B,GAAIzB,GAAQA,EAAK,MAAMwB,CAAC,EAAI,UAAsB,CAChD,IAAM1C,EAAyBkB,EAAK,eAAewB,CAAC,GAAK/C,EACzD,GAAIK,EAAE,SAAWA,EAAE,UAAY,GAAI,CACjC,IAAM4C,EAAO,KAAK,QAAQ,IAAI5C,EAAE,OAAO,GAAG,KAC1C,GAAI,OAAO,aAAe4C,aAAgB,YAAa,CACrD,IAAMC,EAASC,EAAc,aAAa,OAAO,SAAUF,EAAK,MAAOA,EAAK,MAAM,EAClF,OAAAC,EAAO,WAAW,IAAI,GAAG,UAAUD,EAAM,EAAG,EAAGA,EAAK,MAAOA,EAAK,MAAM,EAC/DC,CACT,CACA,OAAOD,CACT,CACF,CACF,CAKO,wBAAwBF,EAAWC,EAA0C,CAElF,IAAMzB,EADS,KAAK,UAAU,MAAM,OAChB,MAAM,IAAIyB,CAAC,EAC/B,GAAIzB,GAAQA,EAAK,MAAMwB,CAAC,EAAI,UAAsB,CAChD,IAAM1C,EAAyBkB,EAAK,eAAewB,CAAC,GAAK/C,EACzD,GAAIK,EAAE,SAAWA,EAAE,UAAY,IAAMA,EAAE,SAAW,GAAI,CACpD,IAAMC,EAAO,KAAK,QAAQ,IAAID,EAAE,OAAO,EACvC,GAAIC,EACF,OAAO,KAAK,UAAU,YAAYA,EAAMD,EAAE,MAAM,CAEpD,CACF,CACF,CAIQ,aAAa+C,EAAsB,CACzC,IAAMC,EAAO,KAAK,iBAAiB,EAC/BC,EAAUD,EACd,KAAO,KAAK,YAAcC,EAAUF,GAAQ,KAAK,QAAQ,MAAM,CAC7D,IAAM9C,EAAO,KAAK,QAAQ,IAAI,EAAE,KAAK,SAAS,EAC1CA,GAAQA,EAAK,OACfgD,GAAWhD,EAAK,KAAK,MAAQA,EAAK,KAAK,OACnCA,EAAK,QAAUA,EAAK,OAASA,EAAK,SACpCgD,GAAWhD,EAAK,OAAO,MAAQA,EAAK,OAAO,QAE7CA,EAAK,QAAQ,QAAQ,EACrB,KAAK,QAAQ,KAAK,SAAS,EAE/B,CACA,OAAO+C,EAAOC,CAChB,CAEQ,aAAa/B,EAAsBwB,EAAWnD,EAAiBC,EAAsB,CAC3F,GAAI0B,EAAK,MAAMwB,EAAI,EAAY,CAAO,EAAI,UAAsB,CAC9D,IAAMQ,EAAMhC,EAAK,eAAewB,CAAC,EACjC,GAAIQ,EAAK,CACP,GAAIA,EAAI,UAAY,OAAW,CAI7B,IAAMC,EAAU,KAAK,QAAQ,IAAID,EAAI,OAAO,EACxCC,GAEFA,EAAQ,YAEVD,EAAI,QAAU3D,EACd2D,EAAI,OAAS1D,EACb,MACF,CAEA0B,EAAK,eAAewB,CAAC,EAAI,IAAIvD,EAAmB+D,EAAI,IAAKA,EAAI,MAAO3D,EAASC,CAAM,EACnF,MACF,CACF,CAEA0B,EAAK,MAAMwB,EAAI,EAAY,CAAO,GAAK,UACvCxB,EAAK,eAAewB,CAAC,EAAI,IAAIvD,EAAmB,EAAG,EAAGI,EAASC,CAAM,CACvE,CAEQ,mBAA0B,CAEhC,QAAWS,KAAQ,KAAK,QAAQ,OAAO,EACjCA,EAAK,aAAe,cACtBA,EAAK,UAAY,GAIrB,IAAMS,EAAS,KAAK,UAAU,MAAM,OACpC,QAASiC,EAAI,EAAGA,EAAI,KAAK,UAAU,KAAM,EAAEA,EAAG,CAC5C,IAAMzB,EAAOR,EAAO,MAAM,IAAIiC,CAAC,EAC/B,GAAKzB,GAGL,QAASwB,EAAI,EAAGA,EAAI,KAAK,UAAU,KAAM,EAAEA,EACzC,GAAIxB,EAAK,MAAMwB,EAAI,EAAY,CAAO,EAAI,UAAsB,CAC9D,IAAMU,EAAQlC,EAAK,eAAewB,CAAC,GAAG,QACtC,GAAIU,EAAO,CACT,IAAMnD,EAAO,KAAK,QAAQ,IAAImD,CAAK,EAC/BnD,GACFA,EAAK,WAET,CACF,EAEJ,CAEA,IAAMG,EAAO,CAAC,EACd,OAAW,CAACD,EAAIF,CAAI,IAAK,KAAK,QAAQ,QAAQ,EACxCA,EAAK,aAAe,aAAe,CAACA,EAAK,YAC3CA,EAAK,QAAQ,QAAQ,EACrBG,EAAK,KAAKD,CAAE,GAGhB,QAAWA,KAAMC,EACf,KAAK,QAAQD,CAAE,CAEnB,CACF,ECnpBA,IAAAkD,GAA0B,QAC1BC,GAAuB,QC2CvB,SAASC,EAAMC,EAA2B,CACxC,IAAIC,EAAI,GACR,QAASC,EAAI,EAAGA,EAAIF,EAAK,OAAQ,EAAEE,EACjCD,GAAK,OAAO,aAAaD,EAAKE,CAAC,CAAC,EAElC,OAAOD,CACT,CAGA,SAASE,GAAMH,EAA2B,CACxC,IAAII,EAAI,EACR,QAASF,EAAI,EAAGA,EAAIF,EAAK,OAAQ,EAAEE,EAAG,CACpC,GAAIF,EAAKE,CAAC,EAAI,IAAMF,EAAKE,CAAC,EAAI,GAC5B,MAAM,IAAI,MAAM,cAAc,EAEhCE,EAAIA,EAAI,GAAKJ,EAAKE,CAAC,EAAI,EACzB,CACA,OAAOE,CACT,CAGA,SAASC,GAAOL,EAA2B,CACzC,IAAMI,EAAIL,EAAMC,CAAI,EACpB,GAAI,CAACI,EAAE,MAAM,kCAAkC,EAC7C,MAAM,IAAI,MAAM,cAAc,EAEhC,OAAOA,CACT,CAGA,SAASE,GAAON,EAA2B,CACzC,GAAI,OAAO,OAAW,IACpB,OAAO,OAAO,KAAKD,EAAMC,CAAI,EAAG,QAAQ,EAAE,SAAS,EAErD,IAAMO,EAAK,KAAKR,EAAMC,CAAI,CAAC,EACrBQ,EAAI,IAAI,WAAWD,EAAG,MAAM,EAClC,QAAS,EAAI,EAAG,EAAIC,EAAE,OAAQ,EAAE,EAC9BA,EAAE,CAAC,EAAID,EAAG,WAAW,CAAC,EAExB,OAAO,IAAI,YAAY,EAAE,OAAOC,CAAC,CACnC,CAEA,IAAMC,GAAiE,CACrE,OAAQN,GACR,KAAMA,GACN,KAAMG,GACN,MAAOD,GACP,OAAQA,GACR,oBAAqBF,EACvB,EAKMO,GAAc,CAAC,GAAI,IAAK,IAAK,GAAG,EAEhCC,GAAuB,CAAC,GAAI,IAAK,IAAK,IAAK,IAAK,IAAK,GAAI,IAAK,IAAK,GAAI,IAAK,IAAK,GAAG,EAEpFC,GAAkB,CAAC,GAAI,IAAK,IAAK,IAAK,GAAI,GAAI,IAAK,GAAG,EAEtDC,GAAiB,CAAC,GAAI,IAAK,IAAK,IAAK,GAAI,IAAK,GAAG,EAEjDC,GAAwB,CAAC,GAAI,IAAK,IAAK,IAAK,IAAK,IAAK,GAAI,IAAK,IAAK,IAAK,GAAI,IAAK,IAAK,GAAG,EAG1FC,GAAiB,KAGVC,EAAN,KAAmB,CAAnB,cACL,KAAO,MAAqB,EAC5B,KAAQ,QAAU,IAAI,YAAYD,EAAc,EAChD,KAAQ,UAAY,EACpB,KAAQ,KAAO,GACf,KAAO,OAA4E,CAAC,EAE7E,OAAc,CACnB,KAAK,QAAQ,KAAK,CAAC,EACnB,KAAK,MAAQ,EACb,KAAK,UAAY,EACjB,KAAK,OAAS,CAAC,EACf,KAAK,KAAO,EACd,CAEO,KAAc,CACnB,GAAI,KAAK,QAAU,EAAmB,CACpC,GAAI,KAAK,YAAcF,GAAe,OAAQ,CAC5C,QAASI,EAAI,EAAGA,EAAIJ,GAAe,OAAQ,EAAEI,EAC3C,GAAI,KAAK,QAAQA,CAAC,IAAMJ,GAAeI,CAAC,EAAG,OAAO,KAAK,GAAG,EAE5D,YAAK,OAAO,KAAU,EACtB,KAAK,MAAQ,EACN,CACT,CACA,GAAI,KAAK,YAAcH,GAAsB,OAAQ,CACnD,QAASG,EAAI,EAAGA,EAAIH,GAAsB,OAAQ,EAAEG,EAClD,GAAI,KAAK,QAAQA,CAAC,IAAMH,GAAsBG,CAAC,EAAG,OAAO,KAAK,GAAG,EAEnE,YAAK,OAAO,KAAU,EACtB,KAAK,MAAQ,EACN,CACT,CACA,OAAO,KAAK,GAAG,CACjB,CACA,OAAI,KAAK,QAAU,EAAwB,EACvC,KAAK,QAAU,GACd,KAAK,OAAO,OAAS,EAEnB,KAAK,YAAY,KAAK,SAAS,GACpC,KAAK,MAAQ,EACN,GAFuC,KAAK,GAAG,EAIjD,KAAK,GAAG,CACjB,CAEO,MAAMjB,EAAmBkB,EAAeC,EAAqB,CAClE,IAAIC,EAAQ,KAAK,MACbC,EAAM,KAAK,UACTC,EAAS,KAAK,QAEpB,GADIF,IAAU,GAAqBA,IAAU,GACzCA,IAAU,GAAqBC,EAAM,GAAI,MAAO,GACpD,QAASnB,EAAIgB,EAAOhB,EAAIiB,EAAK,EAAEjB,EAAG,CAChC,IAAMqB,EAAIvB,EAAKE,CAAC,EAChB,OAAQqB,EAAG,CACT,IAAK,IACH,GAAI,CAAC,KAAK,YAAYF,CAAG,EAAG,OAAO,KAAK,GAAG,EAC3CD,EAAQ,EACRC,EAAM,EACN,MACF,IAAK,IACH,GAAID,IAAU,EAAmB,CAC/B,GAAIE,EAAO,CAAC,IAAM,GAAI,CAEpB,IAAIL,EAAI,EACR,KAAOA,EAAIP,GAAY,OAAQ,EAAEO,EAC/B,GAAIK,EAAOL,CAAC,IAAMP,GAAYO,CAAC,EAAG,OAAO,KAAK,GAAG,EAGnD,GADA,KAAK,OAAO,KAAU,EAClBI,IAAQT,GAAgB,OAAQ,CAClC,KAAOK,EAAIL,GAAgB,OAAQ,EAAEK,EACnC,GAAIK,EAAOL,CAAC,IAAML,GAAgBK,CAAC,EAAG,OAAO,KAAK,GAAG,EAEvD,YAAK,OAAO,KAAU,EACtB,KAAK,MAAQ,EACNf,EAAI,CACb,CACF,SAAWoB,EAAO,CAAC,IAAM,GAAI,CAE3B,QAASL,EAAI,EAAGA,EAAIN,GAAqB,OAAQ,EAAEM,EACjD,GAAIK,EAAOL,CAAC,IAAMN,GAAqBM,CAAC,EAAG,OAAO,KAAK,GAAG,EAE5D,KAAK,OAAO,KAAU,CACxB,KACE,QAAO,KAAK,GAAG,EAEjBG,EAAQ,EACRC,EAAM,CACR,SAAWD,IAAU,EAAiB,CACpC,GAAI,CAAC,KAAK,UAAUC,CAAG,EAAG,OAAO,KAAK,GAAG,EACzCD,EAAQ,EACRC,EAAM,CACR,SAAWD,IAAU,EAAmB,CACtC,GAAIC,GAAON,GAAgB,OAAO,KAAK,GAAG,EAC1CO,EAAOD,GAAK,EAAIE,CAClB,CACA,MACF,IAAK,IACH,OAAIH,IAAU,GACR,CAAC,KAAK,YAAYC,CAAG,EAAU,KAAK,GAAG,GAE7C,KAAK,MAAQ,EACNnB,EAAI,GACb,QACE,GAAImB,GAAON,GAAgB,OAAO,KAAK,GAAG,EAC1CO,EAAOD,GAAK,EAAIE,CACpB,CACF,CACA,YAAK,MAAQH,EACb,KAAK,UAAYC,EACV,EACT,CAEQ,IAAa,CACnB,YAAK,OAAO,KAAO,EACnB,KAAK,MAAQ,EACN,EACT,CAEQ,UAAUA,EAAsB,CACtC,IAAMJ,EAAIlB,EAAM,KAAK,QAAQ,SAAS,EAAGsB,CAAG,CAAC,EAC7C,OAAIJ,GACF,KAAK,KAAOA,EACZ,KAAK,OAAOA,CAAC,EAAI,KACV,IAEF,EACT,CAEQ,YAAYI,EAAsB,CACxC,GAAI,KAAK,KAAM,CACb,GAAI,CACF,IAAMjB,EAAI,KAAK,QAAQ,MAAM,EAAGiB,CAAG,EACnC,KAAK,OAAO,KAAK,IAAI,EAAIZ,GAAS,KAAK,IAAI,EAAIA,GAAS,KAAK,IAAI,EAAEL,CAAC,EAAIA,CAC1E,MAAQ,CACN,MAAO,EACT,CACA,MAAO,EACT,CACA,MAAO,EACT,CACF,ECvPO,IAAMoB,EAA6B,CACxC,KAAM,cACN,MAAO,EACP,OAAQ,CACV,EAEO,SAASC,GAAUC,EAAyB,CACjD,GAAIA,EAAE,OAAS,GACb,OAAOF,EAET,IAAMG,EAAM,IAAI,YAAYD,EAAE,OAAQA,EAAE,WAAY,CAAC,EAGrD,GAAIC,EAAI,CAAC,IAAM,YAAcA,EAAI,CAAC,IAAM,WAAcA,EAAI,CAAC,IAAM,WAC/D,MAAO,CACL,KAAM,YACN,MAAQD,EAAE,EAAE,GAAK,GAAKA,EAAE,EAAE,GAAK,GAAKA,EAAE,EAAE,GAAK,EAAIA,EAAE,EAAE,EACrD,OAAQA,EAAE,EAAE,GAAK,GAAKA,EAAE,EAAE,GAAK,GAAKA,EAAE,EAAE,GAAK,EAAIA,EAAE,EAAE,CACvD,EAGF,GAAIA,EAAE,CAAC,IAAM,KAAQA,EAAE,CAAC,IAAM,KAAQA,EAAE,CAAC,IAAM,IAAM,CACnD,GAAM,CAACE,EAAOC,CAAM,EAAIC,GAAQJ,CAAC,EACjC,MAAO,CAAE,KAAM,aAAc,MAAAE,EAAO,OAAAC,CAAO,CAC7C,CAEA,GAAIF,EAAI,CAAC,IAAM,YAAeD,EAAE,CAAC,IAAM,IAAQA,EAAE,CAAC,IAAM,KAASA,EAAE,CAAC,IAAM,GACxE,MAAO,CACL,KAAM,YACN,MAAQA,EAAE,CAAC,GAAK,EAAIA,EAAE,CAAC,EACvB,OAAQA,EAAE,CAAC,GAAK,EAAIA,EAAE,CAAC,CACzB,EAGF,GAAIC,EAAI,CAAC,IAAM,WACb,MAAO,CACL,KAAM,YACN,MAAQD,EAAE,CAAC,GAAK,GAAKA,EAAE,CAAC,GAAK,GAAKA,EAAE,CAAC,GAAK,EAAIA,EAAE,CAAC,EACjD,OAAQA,EAAE,CAAC,GAAK,GAAKA,EAAE,CAAC,GAAK,GAAKA,EAAE,EAAE,GAAK,EAAIA,EAAE,EAAE,CACrD,EAGF,GAAIC,EAAI,CAAC,IAAM,YAAcA,EAAI,CAAC,IAAM,aAAeA,EAAI,CAAC,EAAI,YAAc,QAAU,CACtF,OAAQD,EAAE,EAAE,EAAG,CACb,IAAK,IACH,MAAO,CACL,KAAM,aACN,OAASA,EAAE,EAAE,EAAIA,EAAE,EAAE,GAAK,EAAIA,EAAE,EAAE,GAAK,IAAM,EAC7C,QAASA,EAAE,EAAE,EAAIA,EAAE,EAAE,GAAK,EAAIA,EAAE,EAAE,GAAK,IAAM,CAC/C,EACF,IAAK,IACH,GAAIA,EAAE,EAAE,IAAM,GAAM,OAAOF,EAC3B,IAAMO,EAAML,EAAE,EAAE,EAAIA,EAAE,EAAE,GAAK,EAAIA,EAAE,EAAE,GAAK,GAAKA,EAAE,EAAE,GAAK,GACxD,MAAO,CACL,KAAM,aACN,OAASK,EAAa,OAAU,EAChC,QAASA,IAAQ,GAAK,OAAU,CAClC,EACF,IAAK,IACH,OAAIL,EAAE,EAAE,IAAM,KAAQA,EAAE,EAAE,IAAM,GAAQA,EAAE,EAAE,IAAM,GAAaF,EACxD,CACL,KAAM,aACN,OAASE,EAAE,EAAE,EAAIA,EAAE,EAAE,GAAK,GAAK,MAC/B,QAASA,EAAE,EAAE,EAAIA,EAAE,EAAE,GAAK,GAAK,KACjC,CACJ,CACA,OAAOF,CACT,CAEA,GAAIG,EAAI,CAAC,IAAM,aAAeA,EAAI,CAAC,IAAM,YAAcA,EAAI,CAAC,IAAM,YAAa,CAC7E,IAAIK,EAAM,GAEJC,EAAQ,KAAK,IAAIP,EAAE,OAAS,GAAI,IAAI,EAC1C,QAASQ,EAAI,EAAGA,EAAID,EAAOC,IAEzB,GAAIR,EAAEQ,CAAC,IAAM,KAAQR,EAAEQ,EAAI,CAAC,IAAM,KAAQR,EAAEQ,EAAI,CAAC,IAAM,KAAQR,EAAEQ,EAAI,CAAC,IAAM,IAAM,CAChFF,EAAME,EACN,KACF,CAEF,GAAIF,IAAQ,GAAI,CAEd,IAAMJ,EACJF,EAAEM,EAAO,CAAC,GAAK,GACfN,EAAEM,EAAO,CAAC,GAAK,GACfN,EAAEM,EAAM,EAAE,GAAM,EAChBN,EAAEM,EAAM,EAAE,EACNH,EACJH,EAAEM,EAAM,EAAE,GAAK,GACfN,EAAEM,EAAM,EAAE,GAAK,GACfN,EAAEM,EAAM,EAAE,GAAM,EAChBN,EAAEM,EAAM,EAAE,EACZ,GAAIJ,EAAQ,GAAKC,EAAS,EACxB,MAAO,CAAE,KAAM,aAAc,MAAAD,EAAO,OAAAC,CAAO,CAE/C,CACA,OAAOL,CACT,CACA,OAAOA,CACT,CAGA,SAASM,GAAQJ,EAAiC,CAChD,IAAMS,EAAMT,EAAE,OACVQ,EAAI,EACJE,EAAcV,EAAEQ,CAAC,GAAK,EAAIR,EAAEQ,EAAI,CAAC,EACrC,OAAa,CAEX,GADAA,GAAKE,EACDF,GAAKC,EAEP,MAAO,CAAC,EAAG,CAAC,EAEd,GAAIT,EAAEQ,CAAC,IAAM,IACX,MAAO,CAAC,EAAG,CAAC,EAEd,GAAIR,EAAEQ,EAAI,CAAC,IAAM,KAAQR,EAAEQ,EAAI,CAAC,IAAM,IACpC,OAAIA,EAAI,EAAIC,EACH,CACLT,EAAEQ,EAAI,CAAC,GAAK,EAAIR,EAAEQ,EAAI,CAAC,EACvBR,EAAEQ,EAAI,CAAC,GAAK,EAAIR,EAAEQ,EAAI,CAAC,CACzB,EAEK,CAAC,EAAG,CAAC,EAEdA,GAAK,EACLE,EAAcV,EAAEQ,CAAC,GAAK,EAAIR,EAAEQ,EAAI,CAAC,CACnC,CACF,CFrHA,IAAMG,GAAgC,CACpC,OACA,KAAM,eACN,KAAM,EACN,MAAO,OACP,OAAQ,OACR,oBAAqB,EACrB,OAAQ,CACV,EAGaC,GAAN,KAAuD,CAU5D,YACmBC,EACAC,EACAC,EACAC,EACjB,CAJiB,WAAAH,EACA,eAAAC,EACA,cAAAC,EACA,mBAAAC,EAbnB,KAAQ,YAAc,EACtB,KAAQ,SAAW,GACnB,KAAQ,IAAM,IAAIC,EAClB,KAAQ,QAAyBN,GAGjC,KAAQ,aAAe,GACvB,KAAQ,YAAc,GAQpB,IAAMO,EAAkB,KAAK,KAAK,KAAK,MAAM,aAAe,EAAI,CAAC,EAC3DC,EAAe,KAAK,IAAI,QAA2BD,CAAe,EACxE,KAAK,KAAO,IAAI,GAAAE,QAAc,QAAwBF,EAAiBC,CAAY,EACnF,KAAK,QAAU,IAAI,GAAAE,QAAW,OAAsB,CACtD,CAEO,OAAc,CACnB,KAAK,cACL,KAAK,IAAI,MAAM,EACf,KAAK,KAAK,QAAQ,EAClB,KAAK,QAAQ,QAAQ,CACvB,CAEO,OAAc,CACnB,KAAK,SAAW,GAChB,KAAK,IAAI,MAAM,CACjB,CAEO,IAAIC,EAAmBC,EAAeC,EAAmB,CAC9D,GAAI,MAAK,SAET,GAAI,KAAK,IAAI,QAAU,EAChB,KAAK,KAAK,IAAIF,EAAK,SAASC,EAAOC,CAAG,CAAC,IAAiB,IAC3D,KAAK,KAAK,QAAQ,EAClB,KAAK,SAAW,QAEb,CACL,IAAMC,EAAU,KAAK,IAAI,MAAMH,EAAMC,EAAOC,CAAG,EAC/C,GAAIC,IAAY,GAAI,CAClB,KAAK,SAAW,GAChB,MACF,CACA,GAAIA,EAAU,EAAG,CAEf,GADgB,KAAK,IAAI,OAAO,OAChB,EAAmB,CAOjC,GANI,KAAK,eACP,KAAK,aAAe,GACpB,KAAK,YAAc,GACnB,KAAK,KAAK,QAAQ,GAEpB,KAAK,QAAU,OAAO,OAAO,CAAC,EAAGd,GAAgB,KAAK,IAAI,MAAM,EAC5D,CAAC,KAAK,QAAQ,OAAQ,CACxB,KAAK,SAAW,GAChB,MACF,CACA,KAAK,KAAK,KAAK,CACjB,SAAW,KAAK,YAAa,CAC3B,KAAK,SAAW,GAChB,MACF,CACK,KAAK,KAAK,IAAIW,EAAK,SAASG,EAASD,CAAG,CAAC,IAAiB,IAC7D,KAAK,KAAK,QAAQ,EAClB,KAAK,SAAW,GACZ,KAAK,eAAc,KAAK,YAAc,IAE9C,CACF,CACF,CAEO,IAAIE,EAA8C,CAGvD,GAFI,KAAK,UAEL,KAAK,IAAI,QAAU,GACjB,KAAK,IAAI,IAAI,EAAG,MAAO,GAE7B,IAAMC,EAAU,KAAK,IAAI,OAAO,KAEhC,GAAIA,IAAY,EAAuB,MAAO,GAE9C,GAAIA,IAAY,EAA6B,CAE3C,IAAIC,EAAIC,EAAkB,MACtBC,EAAID,EAAkB,OACtB,KAAK,UAAU,aACjBD,EAAI,KAAK,UAAU,WAAW,IAAI,OAAO,MAAQ,KAAK,cAAc,KACpEE,EAAI,KAAK,UAAU,WAAW,IAAI,OAAO,OAAS,KAAK,cAAc,MAEvE,IAAMC,EAAQ,KAAK,cAAc,MAAM,qBAAqB,KAAO,EAC7DC,EAAS,4BAA4BF,EAAE,QAAQ,CAAC,CAAC,IAAIF,EAAE,QAAQ,CAAC,CAAC,IAAIG,EAAM,QAAQ,CAAC,CAAC,SAC3F,YAAK,cAAc,MAAMC,EAAQ,EAAK,EAC/B,EACT,CAEA,GAAIL,IAAY,EACd,YAAK,QAAU,OAAO,OAAO,CAAC,EAAGhB,GAAgB,KAAK,IAAI,MAAM,EAChE,KAAK,aAAe,GACpB,KAAK,YAAc,GACnB,KAAK,KAAK,QAAQ,EAClB,KAAK,KAAK,KAAK,EACR,GAGT,GAAIgB,IAAY,IACV,CAAC,KAAK,eACV,KAAK,aAAe,GAChB,KAAK,aAAe,KAAK,QAAQ,OAAS,IAA4B,MAAO,GAKnF,IAAIC,EAAI,EACJE,EAAI,EAGJG,EACAC,EAAUC,EAoBd,IAnBIF,EAAOP,MACLO,EAAO,CAAC,KAAK,KAAK,IAAI,IACxBC,EAAUE,GAAU,KAAK,KAAK,KAAK,GAC/BH,EAAOC,EAAQ,OAAS,gBAC1BN,EAAIM,EAAQ,MACZJ,EAAII,EAAQ,QACRD,EAAOL,GAAKE,GAAKF,EAAIE,EAAI,KAAK,MAAM,aACtC,CAACF,EAAGE,CAAC,EAAI,KAAK,QAAQF,EAAGE,CAAC,EAAE,IAAI,KAAK,KAAK,EAC1CG,EAAOL,GAAKE,GAAKF,EAAIE,EAAI,KAAK,MAAM,YAEpC,QAAQ,KAAK,8BAA8BI,EAAQ,KAAK,IAAIA,EAAQ,MAAM,EAAE,GAG9E,QAAQ,KAAK,6BAA6B,GAG5C,QAAQ,KAAK,mCAAmC,GAGhD,CAACD,EACH,YAAK,KAAK,QAAQ,EACX,GAGT,IAAII,EACJ,GAAIH,EAAQ,OAAS,YAAa,CAChC,IAAMZ,EAAO,KAAK,QAAQ,OAAO,KAAK,KAAK,KAAK,EAOhD,GANAe,EAAO,IAAI,UACT,IAAI,kBAAkBf,EAAK,OAAQA,EAAK,WAAYA,EAAK,UAAU,EACnE,KAAK,QAAQ,MACb,KAAK,QAAQ,MACf,EACA,KAAK,QAAQ,QAAQ,EACjBM,IAAM,KAAK,QAAQ,OAASE,IAAM,KAAK,QAAQ,OAAQ,CAEzD,KAAK,KAAK,QAAQ,EAClB,IAAMQ,EAASC,EAAc,aAAa,OAAW,KAAK,QAAQ,MAAO,KAAK,QAAQ,MAAM,EAC5F,OAAAD,EAAO,WAAW,IAAI,GAAG,aAAaD,EAAM,EAAG,CAAC,EAChD,KAAK,SAAS,SAASC,CAAM,EACtB,EACT,CACF,MACED,EAAO,IAAI,KAAK,CAAC,KAAK,KAAK,KAAK,EAAG,CAAE,KAAMH,EAAQ,IAAK,CAAC,EAE3D,KAAK,KAAK,QAAQ,EAClB,IAAMM,EAAa,KAAK,YACxB,OAAO,kBAAkBH,EAAM,CAAE,YAAaT,EAAG,aAAcE,CAAE,CAAC,EAC/D,KAAKW,GACAD,IAAe,KAAK,aACtBC,EAAG,MAAM,EACF,KAET,KAAK,SAAS,SAASA,CAAE,EAClB,GACR,EACA,MAAMC,IACL,QAAQ,KAAK,uBAAuBR,EAAQ,IAAI,IAAIA,EAAQ,KAAK,IAAIA,EAAQ,MAAM,GAAIQ,CAAC,EACjF,GACR,CACL,CAEQ,QAAQd,EAAWE,EAA6B,CACtD,IAAMa,EAAK,KAAK,UAAU,YAAY,IAAI,KAAK,OAASd,EAAkB,MACpEe,EAAK,KAAK,UAAU,YAAY,IAAI,KAAK,QAAUf,EAAkB,OACrEgB,EAAQ,KAAK,UAAU,YAAY,IAAI,OAAO,OAASF,EAAK,KAAK,cAAc,KAC/EG,EAAS,KAAK,UAAU,YAAY,IAAI,OAAO,QAAUF,EAAK,KAAK,cAAc,KAEjFG,EAAK,KAAK,KAAK,KAAK,QAAQ,MAAQF,EAAOF,CAAE,EAC7CK,EAAK,KAAK,KAAK,KAAK,QAAQ,OAASF,EAAQF,CAAE,EACrD,GAAI,CAACG,GAAM,CAACC,EAAI,CACd,IAAMC,EAAKJ,EAAQjB,EACbsB,GAAMJ,EAASF,GAAMd,EACrBqB,EAAI,KAAK,IAAIF,EAAIC,CAAE,EACzB,OAAOC,EAAI,EAAI,CAACvB,EAAIuB,EAAGrB,EAAIqB,CAAC,EAAI,CAACvB,EAAGE,CAAC,CACvC,CACA,OAAQiB,EAEJ,KAAK,QAAQ,qBAAuB,CAACA,GAAM,CAACC,EAC1C,CAACD,EAAIjB,EAAIiB,EAAKnB,CAAC,EAAI,CAACmB,EAAIC,CAAE,EAF5B,CAACpB,EAAIoB,EAAKlB,EAAGkB,CAAE,CAGrB,CAEQ,KAAKI,EAAWC,EAAeC,EAAsB,CAC3D,OAAIF,IAAM,OAAe,EACrBA,EAAE,SAAS,GAAG,EAAU,SAASA,EAAE,MAAM,EAAG,EAAE,EAAG,EAAE,EAAIC,EAAQ,IAC/DD,EAAE,SAAS,IAAI,EAAU,SAASA,EAAE,MAAM,EAAG,EAAE,EAAG,EAAE,EACjD,SAASA,EAAG,EAAE,EAAIE,CAC3B,CACF,EGzOA,IAAAC,GAAiD,QCmI1C,SAASC,GAAkBC,EAA6B,CAC7D,IAAMC,EAAqB,CAAC,EACtBC,EAAQF,EAAK,MAAM,GAAG,EAE5B,QAAWG,KAAQD,EAAO,CACxB,IAAME,EAAQD,EAAK,QAAQ,GAAG,EAC9B,GAAIC,IAAU,GAAI,SAElB,IAAMC,EAAMF,EAAK,UAAU,EAAGC,CAAK,EAC7BE,EAAQH,EAAK,UAAUC,EAAQ,CAAC,EAGtC,GAAIC,IAAQ,IAAiB,CAC3BJ,EAAI,OAASK,EACb,QACF,CACA,GAAID,IAAQ,IAAsB,CAChCJ,EAAI,YAAcK,EAClB,QACF,CACA,GAAID,IAAQ,IAAuB,CACjCJ,EAAI,aAAeK,EACnB,QACF,CACA,GAAID,IAAQ,IAA0B,CACpCJ,EAAI,eAAiBK,EACrB,QACF,CACA,IAAMC,EAAW,SAASD,EAAO,EAAE,EACnC,OAAQD,EAAK,CACX,IAAK,IAAiBJ,EAAI,OAASM,EAAU,MAC7C,IAAK,IAAaN,EAAI,GAAKM,EAAU,MACrC,IAAK,IAAuBN,EAAI,YAAcM,EAAU,MACxD,IAAK,IAAgBN,EAAI,MAAQM,EAAU,MAC3C,IAAK,IAAiBN,EAAI,OAASM,EAAU,MAC7C,IAAK,IAAmBN,EAAI,EAAIM,EAAU,MAC1C,IAAK,IAAmBN,EAAI,EAAIM,EAAU,MAC1C,IAAK,IAAuBN,EAAI,YAAcM,EAAU,MACxD,IAAK,IAAwBN,EAAI,aAAeM,EAAU,MAC1D,IAAK,IAA6BN,EAAI,QAAUM,EAAU,MAC1D,IAAK,IAA6BN,EAAI,QAAUM,EAAU,MAC1D,IAAK,IAAkBN,EAAI,QAAUM,EAAU,MAC/C,IAAK,IAAeN,EAAI,KAAOM,EAAU,MACzC,IAAK,IAAeN,EAAI,KAAOM,EAAU,MACzC,IAAK,IAAgBN,EAAI,MAAQM,EAAU,MAC3C,IAAK,IAA0BN,EAAI,eAAiBM,EAAU,MAC9D,IAAK,IAAkBN,EAAI,OAASM,EAAU,MAC9C,IAAK,IAAuBN,EAAI,YAAcM,EAAU,KAC1D,CACF,CAEA,OAAON,CACT,CD/JA,IAAMO,GAAa,EAGNC,GAAN,KAA8E,CAiCnF,YACmBC,EACAC,EACAC,EACAC,EACjB,CAJiB,WAAAH,EACA,eAAAC,EACA,mBAAAC,EACA,mBAAAC,EApCnB,KAAQ,SAAW,GACnB,KAAQ,YAAc,EACtB,KAAQ,aAAe,GAEvB,KAAQ,eAAuC,KAO/C,KAAQ,eAAiB,GAGzB,KAAQ,aAAe,IAAI,YAAY,GAA+B,EACtE,KAAQ,eAAiB,EAGzB,KAAQ,kBAAoB,EAC5B,KAAQ,kBAAoB,EAG5B,KAAQ,eAAuC,KAI/C,KAAQ,sBAA2D,IAAI,IAarE,KAAK,iBAAmB,KAAK,KAAK,KAAK,MAAM,eAAiB,EAAI,CAAC,EAEnE,KAAK,qBAAuB,KAAK,IAAI,QAAgC,KAAK,gBAAgB,CAC5F,CAEO,OAAc,CACnB,KAAK,cACL,KAAK,mBAAmB,EACpB,KAAK,iBACP,KAAK,eAAe,QAAQ,EAC5B,KAAK,eAAiB,MAExB,KAAK,cAAc,MAAM,CAC3B,CAEO,SAAgB,CACrB,KAAK,MAAM,CACb,CAEQ,oBAAoBC,EAAmB,CAC7C,KAAK,sBAAsB,OAAOA,CAAG,EACjC,KAAK,kBAAoBA,IAC3B,KAAK,gBAAkB,OAE3B,CAEQ,oBAA2B,CACjC,QAAWC,KAAW,KAAK,sBAAsB,OAAO,EACtDA,EAAQ,QAAQ,QAAQ,EAE1B,KAAK,sBAAsB,MAAM,EACjC,KAAK,gBAAkB,MACzB,CAEO,OAAc,CACnB,KAAK,SAAW,GAChB,KAAK,aAAe,GACpB,KAAK,eAAiB,GACtB,KAAK,eAAiB,EACtB,KAAK,eAAiB,KAEtB,KAAK,kBAAoB,KAAK,iBAC9B,KAAK,kBAAoB,EACzB,KAAK,eAAiB,IACxB,CAEO,IAAIC,EAAmBC,EAAeC,EAAmB,CAC9D,GAAI,MAAK,SAET,GAAI,CAAC,KAAK,eACR,KAAK,eAAeF,EAAMC,EAAOC,CAAG,MAC/B,CAEL,IAAIC,EAAaD,EACjB,QAASE,EAAIH,EAAOG,EAAIF,EAAKE,IAC3B,GAAIJ,EAAKI,CAAC,IAAM,GAAqB,CACnC,KAAK,eAAiB,GACtBD,EAAaC,EACb,KACF,CAIF,IAAMC,EAAaF,EAAaF,EAChC,GAAI,KAAK,eAAiBI,EAAa,IAAiC,CACtE,KAAK,SAAW,GAChB,MACF,CAIA,GAHA,KAAK,aAAa,IAAIL,EAAK,SAASC,EAAOE,CAAU,EAAG,KAAK,cAAc,EAC3E,KAAK,gBAAkBE,EAEnB,CAAC,KAAK,eAAgB,CAKxB,GAHA,KAAK,eAAiBC,GAAkB,KAAK,wBAAwB,CAAC,EAGlE,KAAK,eAAe,KAAO,QAAa,KAAK,eAAe,cAAgB,OAAW,CACzF,KAAK,cAAc,KAAK,eAAe,GAAI,0CAA2C,KAAK,eAAe,OAAS,CAAC,EACpH,KAAK,SAAW,GAChB,MACF,CAGA,GAAI,KAAK,eAAe,SAAW,IACjC,OAIF,IAAMC,EAAeJ,EAAa,EAC9BI,EAAeL,GACjB,KAAK,eAAeF,EAAMO,EAAcL,CAAG,CAE/C,CACF,CACF,CAGQ,eAAeF,EAAmBC,EAAeC,EAAmB,CAC1E,GAAI,KAAK,SAAU,OAKnB,IAAMM,EAAa,KAAK,gBAAgB,IAAM,KAAK,iBAAmB,EAChET,EAAU,KAAK,sBAAsB,IAAIS,CAAU,EACnDC,EAAsBV,GAAS,kBAAoB,EAGzD,GAFA,KAAK,mBAAqBG,EAAMD,EACFQ,EAAsB,KAAK,kBAC7B,KAAK,kBAAmB,CAClD,IAAMC,EAAmB,KAAK,gBAAkBX,GAAS,QACrDW,GACFA,EAAiB,QAAQ,EAE3B,KAAK,eAAiB,KAClBX,GACF,KAAK,oBAAoBS,CAAU,EAErC,KAAK,SAAW,GAChB,MACF,CAEA,GAAI,MAAK,aAKT,IAHIT,GAAS,SAAW,CAAC,KAAK,iBAC5B,KAAK,eAAiBA,EAAQ,SAE5B,CAAC,KAAK,eAAgB,CAExB,IAAMY,EAAkB,KAAK,iBAAmB,OAChD,GAAIA,EAAkB,KAAK,MAAM,aAAe,IAAS,CACvD,KAAK,SAAW,GACZ,KAAK,gBAAgB,KAAO,QAC9B,KAAK,cAAc,KAAK,eAAe,GAAI,uCAAwC,KAAK,eAAe,OAAS,CAAC,EAEnH,MACF,CACA,IAAMC,EAAa,KAAK,IAAI,EAAG,KAAK,MAAM,KAAK,MAAM,aAAe,IAAUD,CAAe,CAAC,EAC9F,KAAO,KAAK,sBAAsB,MAAQC,GAAY,CACpD,IAAMC,EAAS,KAAK,sBAAsB,QAAQ,EAAE,KAAK,EAAE,MAC3D,GAAI,CAACA,EAAQ,MACbA,EAAO,CAAC,EAAE,QAAQ,QAAQ,EAC1B,KAAK,oBAAoBA,EAAO,CAAC,CAAC,EAC9BA,EAAO,CAAC,EAAE,IAAI,KAAO,QACvB,KAAK,cAAcA,EAAO,CAAC,EAAE,IAAI,GAAI,uCAAwCA,EAAO,CAAC,EAAE,IAAI,OAAS,CAAC,CAEzG,CACA,KAAK,eAAiB,IAAI,GAAAC,QAAc,QAA6B,KAAK,iBAAkB,KAAK,oBAAoB,EACrH,KAAK,eAAe,KAAK,CAC3B,CAEI,KAAK,eAAe,IAAId,EAAK,SAASC,EAAOC,CAAG,CAAC,IAAMV,KACzD,KAAK,eAAe,QAAQ,EAC5B,KAAK,eAAiB,KACtB,KAAK,aAAe,GAChBO,GACF,KAAK,oBAAoBS,CAAU,GAGzC,CAEO,IAAIO,EAA8C,CACvD,GAAI,KAAK,UAAY,CAACA,EACpB,OAAI,KAAK,iBACP,KAAK,eAAe,QAAQ,EAC5B,KAAK,eAAiB,MAEjB,GAIT,GAAI,KAAK,eACP,OAAO,KAAK,wBAAwB,EAItC,IAAMC,EAAM,KAAK,eAGjB,GAAIA,EAAI,SAAW,IACjB,OAAO,KAAK,cAAcA,CAAG,EAI/B,IAAMR,EAAaQ,EAAI,IAAM,KAAK,iBAAmB,EAC/CC,EAAeD,EAAI,OAAS,EAC5BjB,EAAU,KAAK,sBAAsB,IAAIS,CAAU,EAEzD,GAAIS,EACF,OAAI,KAAK,iBACHlB,GACFA,EAAQ,kBAAoB,KAAK,kBACjCA,EAAQ,YAAcA,EAAQ,aAAe,KAAK,cAElD,KAAK,sBAAsB,IAAIS,EAAY,CACzC,IAAK,CAAE,GAAGQ,CAAI,EACd,QAAS,KAAK,eACd,iBAAkB,KAAK,kBACvB,YAAa,KAAK,YACpB,CAAC,EAEH,KAAK,gBAAkBR,EACvB,KAAK,eAAiB,MAEjB,GAILT,IACF,KAAK,gBAAkB,QAGzB,IAAImB,EAAc,KAAK,aACnBC,EAAWH,EACXI,EAAU,KAAK,eAEfrB,IACFoB,EAAWpB,EAAQ,IACnBqB,EAAUrB,EAAQ,QAClBmB,EAAcA,GAAenB,EAAQ,YACrC,KAAK,sBAAsB,OAAOS,CAAU,GAG9C,IAAIa,EAAa,IAAI,WAAW,CAAC,EAC7BD,IACEA,EAAQ,IAAI,IAAM5B,KACpB0B,EAAc,IAEhBG,EAAaD,EAAQ,OAEvB,KAAK,eAAiB,KAKtB,IAAME,EAAS,KAAK,8BAA8BH,EAAUE,EAAYH,CAAW,EACnF,OAAIE,GACFA,EAAQ,QAAQ,EAEXE,CACT,CAIQ,yBAAkC,CACxC,IAAIC,EAAM,GACV,QAASnB,EAAI,EAAGA,EAAI,KAAK,eAAgBA,IACvCmB,GAAO,OAAO,cAAc,KAAK,aAAanB,CAAC,CAAC,EAElD,OAAOmB,CACT,CAEQ,yBAAsD,CAC5D,IAAMP,EAAMV,GAAkB,KAAK,wBAAwB,CAAC,EAG5D,GAAIU,EAAI,KAAO,QAAaA,EAAI,cAAgB,OAC9C,YAAK,cAAcA,EAAI,GAAI,0CAA2CA,EAAI,OAAS,CAAC,EAC7E,GAKT,OAFeA,EAAI,QAAU,IAEb,CACd,QACE,OAAO,KAAK,cAAcA,CAAG,EAC/B,QACE,YAAK,cAAcA,EAAI,IAAM,EAAG,KAAMA,EAAI,OAAS,CAAC,EAC7C,GACT,QACE,OAAO,KAAK,iBAAiBA,CAAG,EAClC,QAKE,OAAIA,EAAI,KAAO,QACb,KAAK,cAAcA,EAAI,GAAI,4BAA6BA,EAAI,OAAS,CAAC,EAEjE,EACX,CACF,CAEQ,8BAA8BA,EAAoBQ,EAAmBN,EAAkD,CAG7H,OAFeF,EAAI,QAAU,IAEb,CACd,QAA2B,CACzB,IAAMM,EAAS,KAAK,gBAAgBN,EAAKQ,EAAON,CAAW,EAG3D,OAAKF,EAAI,cAAgB,OAAS,KAAOA,EAAI,KAAO,SAC9CE,EACF,KAAK,cAAcF,EAAI,GAAI,6BAA8BA,EAAI,OAAS,CAAC,EAC9DQ,EAAM,OAAS,GACxB,KAAK,cAAcR,EAAI,GAAI,KAAMA,EAAI,OAAS,CAAC,GAG5CM,CACT,CACA,QACE,OAAO,KAAK,uBAAuBN,EAAKQ,EAAON,CAAW,EAC5D,QACE,OAAO,KAAK,aAAaF,EAAKQ,EAAON,CAAW,EAClD,QAEE,OAAO,KAAK,iBAAiBF,CAAG,EAClC,QAKE,OAAIA,EAAI,KAAO,QACb,KAAK,cAAcA,EAAI,GAAI,4BAA6BA,EAAI,OAAS,CAAC,EAEjE,EACX,CACF,CAEQ,iBAAiBA,EAAgD,CACvE,GAAIA,EAAI,KAAO,OACb,MAAO,GAET,IAAMS,EAAKT,EAAI,GACTU,EAAQ,KAAK,cAAc,SAASD,CAAE,EAC5C,OAAKC,EAIU,KAAK,cAAcA,EAAOV,CAAG,EAC9B,KAAKD,IACjB,KAAK,cAAcU,EAAIV,EAAU,KAAO,gCAAiCC,EAAI,OAAS,EAAGA,EAAI,WAAW,EACjG,GACR,GAPC,KAAK,cAAcS,EAAI,yBAA0BT,EAAI,OAAS,EAAGA,EAAI,WAAW,EACzE,GAOX,CAEQ,gBAAgBA,EAAoBQ,EAAmBN,EAA+B,CAY5F,OADqBF,EAAI,cAAgB,OACpB,KACfA,EAAI,KAAO,QACb,KAAK,cAAcA,EAAI,GAAI,yCAA0CA,EAAI,OAAS,CAAC,EAE9E,KAGLE,GAAeM,EAAM,SAAW,GAEpC,KAAK,cAAc,WAAWR,EAAI,GAAI,CACpC,KAAM,IAAI,KAAK,CAACQ,CAAiB,CAAC,EAClC,MAAOR,EAAI,OAAS,EACpB,OAAQA,EAAI,QAAU,EACtB,OAASA,EAAI,QAAU,GACvB,YAAaA,EAAI,aAAe,EAClC,CAAC,EACM,GACT,CAEQ,uBAAuBA,EAAoBQ,EAAmBN,EAAkD,CACtH,GAAIA,EACF,OAAIF,EAAI,KAAO,QACb,KAAK,cAAcA,EAAI,GAAI,6BAA8BA,EAAI,OAAS,CAAC,EAElE,GAGT,KAAK,gBAAgBA,EAAKQ,EAAON,CAAW,EAE5C,IAAMO,EAAKT,EAAI,IAAM,KAAK,cAAc,YAClCU,EAAQ,KAAK,cAAc,SAASD,CAAE,EAC5C,GAAIC,EAAO,CACT,IAAMJ,EAAS,KAAK,cAAcI,EAAOV,CAAG,EAC5C,OAAIA,EAAI,KAAO,OACNM,EAAO,KAAKP,IACjB,KAAK,cAAcU,EAAIV,EAAU,KAAO,gCAAiCC,EAAI,OAAS,CAAC,EAChF,GACR,EAEIM,EAAO,KAAK,IAAM,EAAI,CAC/B,CACA,MAAO,EACT,CAEQ,aAAaN,EAAoBQ,EAAmBN,EAA+B,CACzF,IAAMO,EAAKT,EAAI,IAAM,EACfW,EAAQX,EAAI,OAAS,EAM3B,IADqBA,EAAI,cAAgB,OACpB,IACnB,YAAK,cAAcS,EAAI,yCAA0CE,CAAK,EAC/D,GAIT,GAAIT,EACF,YAAK,cAAcO,EAAI,6BAA8BE,CAAK,EACnD,GAIT,GAAIH,EAAM,SAAW,EACnB,YAAK,cAAcC,EAAI,KAAME,CAAK,EAC3B,GAGT,IAAMC,EAASZ,EAAI,QAAU,GAE7B,GAAIY,IAAW,IACb,KAAK,cAAcH,EAAI,KAAME,CAAK,MAC7B,CACL,IAAME,EAAQb,EAAI,OAAS,EACrBc,EAASd,EAAI,QAAU,EAE7B,GAAI,CAACa,GAAS,CAACC,EACb,YAAK,cAAcL,EAAI,sDAAuDE,CAAK,EAC5E,GAGT,IAAMI,EAAgBH,IAAW,OAC3BI,EAAgBH,EAAQC,EAASC,EAEvC,GAAIP,EAAM,OAASQ,EACjB,YAAK,cAAcP,EAAI,iCAAkCE,CAAK,EACvD,GAGT,KAAK,cAAcF,EAAI,KAAME,CAAK,CACpC,CACA,MAAO,EACT,CAEQ,cAAcX,EAA6B,CAOjD,OALiBA,EAAI,gBAAkB,IAKrB,CAChB,IAAK,IACL,IAAK,IACH,KAAK,mBAAmB,EACxB,KAAK,cAAc,UAAU,EAC7B,MACF,IAAK,IACL,IAAK,IAKH,GAAIA,EAAI,KAAO,OAAW,CACxB,IAAMjB,EAAU,KAAK,sBAAsB,IAAIiB,EAAI,EAAE,EACjDjB,GACFA,EAAQ,QAAQ,QAAQ,EAE1B,KAAK,oBAAoBiB,EAAI,EAAE,EAC/B,KAAK,cAAc,WAAWA,EAAI,EAAE,CACtC,CACA,MACF,QAEE,KACJ,CACA,MAAO,EACT,CAEQ,cAAcS,EAAYQ,EAAiBN,EAAeO,EAA4B,CAC5F,IAAMC,EAAOF,IAAY,KAEzB,GADIE,GAAQR,GAAS,GACjB,CAACQ,GAAQR,GAAS,EAAG,OAEzB,IAAMS,EAAQF,EAAc,MAAMA,CAAW,GAAK,GAC5CG,EAAW,WAAWZ,CAAE,GAAGW,CAAK,IAAIH,CAAO,SACjD,KAAK,cAAc,MAAM,YAAY,iBAAiBI,CAAQ,CAChE,CAIQ,cAAcX,EAAwBV,EAAsC,CAClF,OAAO,KAAK,kBAAkBU,EAAOV,CAAG,EACrC,KAAK,IAAM,EAAI,EACf,MAAM,IAAM,EAAK,CACtB,CAEA,MAAc,kBAAkBU,EAAwBV,EAAmC,CACzF,IAAMsB,EAAa,KAAK,YACpBC,EAAkC,MAAM,KAAK,cAAcb,CAAK,EAEpE,GAAI,CACF,GAAIY,IAAe,KAAK,YAAa,MAAM,IAAI,MAAM,uBAAuB,EAC5E,IAAME,EAAQ,KAAK,IAAI,EAAGxB,EAAI,GAAK,CAAC,EAC9ByB,EAAQ,KAAK,IAAI,EAAGzB,EAAI,GAAK,CAAC,EAC9B0B,EAAQ1B,EAAI,aAAgBuB,EAAO,MAAQC,EAC3CG,EAAQ3B,EAAI,cAAiBuB,EAAO,OAASE,EAE7CG,EAAW,KAAK,IAAI,EAAGL,EAAO,MAAQC,CAAK,EAC3CK,EAAW,KAAK,IAAI,EAAGN,EAAO,OAASE,CAAK,EAC5CK,EAAa,KAAK,IAAI,EAAG,KAAK,IAAIJ,EAAOE,CAAQ,CAAC,EAClDG,EAAa,KAAK,IAAI,EAAG,KAAK,IAAIJ,EAAOE,CAAQ,CAAC,EAExD,GAAIC,IAAe,GAAKC,IAAe,EACrC,MAAM,IAAI,MAAM,0BAA0B,EAG5C,GAAIP,IAAU,GAAKC,IAAU,GAAKK,IAAeP,EAAO,OAASQ,IAAeR,EAAO,OAAQ,CAC7F,IAAMS,EAAU,MAAM,kBAAkBT,EAAQC,EAAOC,EAAOK,EAAYC,CAAU,EACpFR,EAAO,MAAM,EACbA,EAASS,CACX,CAEA,IAAMC,EAAK,KAAK,UAAU,YAAY,IAAI,KAAK,OAASC,EAAkB,MACpEC,EAAK,KAAK,UAAU,YAAY,IAAI,KAAK,QAAUD,EAAkB,OAIvEE,EACAC,EACArC,EAAI,UAAY,QAAaA,EAAI,OAAS,QAC5CoC,EAAUpC,EAAI,QACdqC,EAAUrC,EAAI,MACLA,EAAI,UAAY,QACzBoC,EAAUpC,EAAI,QACdqC,EAAU,KAAK,IAAI,EAAG,KAAK,KAAMd,EAAO,OAASA,EAAO,OAAUa,EAAUH,GAAME,CAAE,CAAC,GAC5EnC,EAAI,OAAS,QACtBqC,EAAUrC,EAAI,KACdoC,EAAU,KAAK,IAAI,EAAG,KAAK,KAAMb,EAAO,MAAQA,EAAO,QAAWc,EAAUF,GAAMF,CAAE,CAAC,IAErFG,EAAU,KAAK,KAAKb,EAAO,MAAQU,CAAE,EACrCI,EAAU,KAAK,KAAKd,EAAO,OAASY,CAAE,GAGxC,IAAIG,EAAIf,EAAO,MACXgB,EAAIhB,EAAO,OAQf,IALIvB,EAAI,UAAY,QAAaA,EAAI,OAAS,UAC5CsC,EAAI,KAAK,MAAMF,EAAUH,CAAE,EAC3BM,EAAI,KAAK,MAAMF,EAAUF,CAAE,GAGzBG,EAAIC,EAAI,KAAK,MAAM,WACrB,MAAM,IAAI,MAAM,2BAA2B,EAI7C,IAAMC,EAAS,KAAK,cAAc,MAAM,OAClCC,EAASD,EAAO,EAChBE,EAASF,EAAO,EAChBG,EAAaH,EAAO,MAOpBI,GADc5C,EAAI,SAAW,QAAaA,EAAI,OAAS,EACrB,SAAW,MAEnD,GAAIsC,IAAMf,EAAO,OAASgB,IAAMhB,EAAO,OAAQ,CAC7C,IAAMsB,EAAS,MAAM,kBAAkBtB,EAAQ,CAAE,YAAae,EAAG,aAAcC,CAAE,CAAC,EAClFhB,EAAO,MAAM,EACbA,EAASsB,CACX,CAGA,IAAMC,GAAU,KAAK,IAAI,KAAK,IAAI,EAAG9C,EAAI,SAAW,CAAC,EAAGiC,EAAK,CAAC,EACxDc,GAAU,KAAK,IAAI,KAAK,IAAI,EAAG/C,EAAI,SAAW,CAAC,EAAGmC,EAAK,CAAC,EAC9D,GAAIW,KAAY,GAAKC,KAAY,EAAG,CAKlC,IAAMC,EAAWhD,EAAI,UAAY,OAAa,KAAK,MAAMoC,EAAUH,CAAE,EAAIV,EAAO,MAAQuB,GAClFG,GAAWjD,EAAI,OAAS,OAAa,KAAK,MAAMqC,EAAUF,CAAE,EAAIZ,EAAO,OAASwB,GAChFG,EAAeC,EAAc,aAAa,OAAO,SAAUH,EAASC,EAAO,EAC3EG,GAAYF,EAAa,WAAW,IAAI,EAC9C,GAAI,CAACE,GACH,MAAM,IAAI,MAAM,wCAAwC,EAE1DA,GAAU,UAAU7B,EAAQuB,GAASC,EAAO,EAE5C,IAAMM,GAAe,MAAM,kBAAkBH,CAAY,EAMzD,GALAA,EAAa,MAAQA,EAAa,OAAS,EAC3C3B,EAAO,MAAM,EACbA,EAAS8B,GACTf,EAAIf,EAAO,MACXgB,EAAIhB,EAAO,OACPe,EAAIC,EAAI,KAAK,MAAM,WACrB,MAAM,IAAI,MAAM,2BAA2B,EAEzCvC,EAAI,UAAY,SAClBoC,EAAU,KAAK,KAAKb,EAAO,MAAQU,CAAE,GAEnCjC,EAAI,OAAS,SACfqC,EAAU,KAAK,KAAKd,EAAO,OAASY,CAAE,EAE1C,CAEA,GAAIb,IAAe,KAAK,YAAa,MAAM,IAAI,MAAM,uBAAuB,EAC5E,IAAMgC,GAAStD,EAAI,QAAU,EAO7B,GANA,KAAK,cAAc,SAASU,EAAM,GAAIa,EAAQ,GAAMqB,GAAOU,EAAM,EACjE/B,EAAS,OAKLvB,EAAI,iBAAmB,EAAG,CAE5B,IAAMuD,EAAWf,EAAO,MAAQG,EAChCH,EAAO,EAAIC,EAEXD,EAAO,EAAI,KAAK,IAAIE,EAASa,EAAU,CAAC,CAC1C,MAGEf,EAAO,EAAI,KAAK,IAAIC,EAASL,EAAS,KAAK,cAAc,IAAI,CAEjE,OAASoB,EAAG,CACV,MAAAjC,GAAQ,MAAM,EACRiC,CACR,CACF,CAGA,MAAc,cAAc9C,EAA8C,CACxE,IAAIF,EAAoB,IAAI,WAAW,MAAME,EAAM,KAAK,YAAY,CAAC,EAMrE,GAJIA,EAAM,cAAgB,MACxBF,EAAQ,MAAM,KAAK,gBAAgBA,CAAK,GAGtCE,EAAM,SAAW,IAAiB,CACpC,IAAM+C,EAAUC,GAAUlD,CAAK,EAG/B,GAAIiD,EAAQ,OAAS,aAAe,EAAEA,EAAQ,MAAQ,IAAM,EAAEA,EAAQ,OAAS,IAAMA,EAAQ,MAAQA,EAAQ,OAAS,KAAK,MAAM,WAC/H,MAAM,IAAI,WAAW,mDAAmD,EAE1E,IAAME,EAAO,IAAI,KAAK,CAACnD,CAAiB,EAAG,CAAE,KAAM,WAAY,CAAC,EAChE,GAAI,CAAC,OAAO,kBAAmB,CAC7B,IAAMoD,EAAM,IAAI,gBAAgBD,CAAI,EAC9BE,EAAM,IAAI,MAChB,OAAO,IAAI,QAAqB,CAACC,EAASC,IAAW,CACnDF,EAAI,iBAAiB,OAAQ,IAAM,CACjC,IAAI,gBAAgBD,CAAG,EACvB,IAAMI,EAASb,EAAc,aAAa,OAAO,SAAUU,EAAI,MAAOA,EAAI,MAAM,EAChFG,EAAO,WAAW,IAAI,GAAG,UAAUH,EAAK,EAAG,CAAC,EAC5C,kBAAkBG,CAAM,EAAE,KAAKF,CAAO,EAAE,MAAMC,CAAM,CACtD,CAAC,EACDF,EAAI,iBAAiB,QAAS,IAAM,CAClC,IAAI,gBAAgBD,CAAG,EACvBG,EAAO,IAAI,MAAM,sBAAsB,CAAC,CAC1C,CAAC,EACDF,EAAI,IAAMD,CACZ,CAAC,CACH,CACA,OAAO,kBAAkBD,CAAI,CAC/B,CAGA,IAAM9C,EAAQH,EAAM,MACdI,EAASJ,EAAM,OAErB,GAAI,CAACG,GAAS,CAACC,EACb,MAAM,IAAI,MAAM,8CAA8C,EAGhE,IAAMC,EAAgBL,EAAM,SAAW,OACjCM,EAAgBH,EAAQC,EAASC,EAEvC,GAAIP,EAAM,OAASQ,EACjB,MAAM,IAAI,MAAM,yBAAyB,EAG3C,IAAMiD,EAAapD,EAAQC,EAE3B,GAAIJ,EAAM,SAAW,GAEnB,OAAO,kBAAkB,IAAI,UAAU,IAAI,kBAAkBF,EAAM,OAAuBA,EAAM,WAAYyD,EAAa,CAAwC,EAAGpD,EAAOC,CAAM,CAAC,EAMpL,IAAM9B,EAAO,IAAI,kBAAkBiF,EAAa,CAAwC,EAClFC,EAAQ,IAAI,YAAY1D,EAAM,OAAQA,EAAM,WAAY,KAAK,MAAMA,EAAM,WAAa,CAAC,CAAC,EACxF2D,EAAQ,IAAI,YAAYnF,EAAK,MAAM,EACnCoF,EAAgBH,EAAa,GAE/BI,EAAY,EACZC,EAAY,EAChB,QAASlF,EAAI,EAAGA,EAAIgF,EAAehF,GAAK,EAAG,CACzC,IAAMmF,EAAKL,EAAMG,GAAW,EACtBG,EAAKN,EAAMG,GAAW,EACtBI,EAAKP,EAAMG,GAAW,EAE5BF,EAAMG,GAAW,EAAI,WAAaC,EAClCJ,EAAMG,GAAW,EAAI,WAAcC,IAAO,GAAOC,GAAM,EACvDL,EAAMG,GAAW,EAAI,WAAcE,IAAO,GAAOC,GAAM,GACvDN,EAAMG,GAAW,EAAI,WAAcG,IAAO,CAC5C,CAGA,IAAIC,EAAUN,EAAgB,EAC1BO,EAAUP,EAAgB,EAC9B,QAAShF,EAAIgF,EAAehF,EAAI6E,EAAY7E,IAC1CJ,EAAK2F,CAAO,EAAQnE,EAAMkE,CAAO,EACjC1F,EAAK2F,EAAU,CAAC,EAAInE,EAAMkE,EAAU,CAAC,EACrC1F,EAAK2F,EAAU,CAAC,EAAInE,EAAMkE,EAAU,CAAC,EACrC1F,EAAK2F,EAAU,CAAC,EAAI,IACpBD,GAAW,EACXC,GAAW,EAGb,OAAO,kBAAkB,IAAI,UAAU3F,EAAM6B,EAAOC,CAAM,CAAC,CAC7D,CAEA,MAAc,gBAAgB8D,EAA6C,CACzE,GAAI,CACF,OAAO,MAAM,KAAK,YAAYA,EAAY,SAAS,CACrD,OAASC,EAAO,CACd,GAAIA,aAAiB,WAAY,MAAMA,EACvC,OAAO,MAAM,KAAK,YAAYD,EAAY,aAAa,CACzD,CACF,CAEA,MAAc,YAAYA,EAAwBhE,EAAwD,CACxG,IAAMkE,EAAQ,KAAK,IAAI,KAAK,MAAM,eAAgB,KAAK,MAAM,WAAa,EAAG,KAAK,MAAM,aAAe,GAAO,EAC1GC,EAAW,EAaTC,EAXS,IAAI,eAA6B,CAC9C,KAAKC,EAAY,CACf,GAAIF,GAAYH,EAAW,OAAQ,CACjCK,EAAW,MAAM,EACjB,MACF,CACA,IAAM/F,EAAM,KAAK,IAAI6F,EAAW,KAAMH,EAAW,MAAM,EACvDK,EAAW,QAAQ,IAAI,WAAWL,EAAW,SAASG,EAAU7F,CAAG,CAAC,CAAC,EACrE6F,EAAW7F,CACb,CACF,CAAC,EACqB,YAAY,IAAI,oBAAoB0B,CAAM,CAAC,EAAE,UAAU,EACvEsE,EAAuB,CAAC,EAC1BC,EAAc,EAClB,GAAI,CACF,OAAa,CACX,GAAM,CAAE,KAAAC,EAAM,MAAAC,CAAM,EAAI,MAAML,EAAO,KAAK,EAC1C,GAAII,EAAM,MAEV,GADAD,GAAeE,EAAM,WACjBF,EAAcL,EAChB,YAAME,EAAO,OAAO,EAAE,MAAM,IAAM,CAAC,CAAC,EAC9B,IAAI,WAAW,uCAAuC,EAE9DE,EAAO,KAAKG,CAAK,CACnB,CACF,QAAE,CACAL,EAAO,YAAY,CACrB,CAEA,IAAM1E,EAAS,IAAI,WAAW6E,CAAW,EACrCG,EAAS,EACb,QAAWC,KAASL,EAClB5E,EAAO,IAAIiF,EAAOD,CAAM,EACxBA,GAAUC,EAAM,OAElB,OAAOjF,CACT,CAEA,IAAW,QAA+C,CACxD,OAAO,KAAK,cAAc,MAC5B,CAEA,IAAW,qBAAmD,CAC5D,OAAO,KAAK,cAAc,kBAC5B,CAEA,IAAW,sBAAkE,CAC3E,OAAO,KAAK,qBACd,CACF,EEl1BO,IAAMkF,EAAN,MAAMA,CAAyC,CA6BpD,YACmBC,EACjB,CADiB,cAAAA,EA3BnB,KAAQ,aAAe,EACvB,KAAiB,QAAwC,IAAI,IAU7D,KAAiB,oBAA2C,IAAI,IAChE,KAAiB,oBAA2C,IAAI,IAIhE,KAAiB,2BAA8BC,GAA4B,CACzE,IAAMC,EAAU,KAAK,oBAAoB,IAAID,CAAS,EAClDC,IAAY,SACd,KAAK,oBAAoB,OAAOA,CAAO,EACvC,KAAK,oBAAoB,OAAOD,CAAS,EACzC,KAAK,QAAQ,OAAOC,CAAO,EAE/B,EACA,KAAQ,cAA+B,CAAE,UAAW,GAAM,MAAO,MAAO,OAAQ,EAAG,UAAW,KAAM,EAKlG,KAAK,wBAA0B,KAAK,SAAS,eAC7C,KAAK,uBAA0BD,GAAsB,CACnD,KAAK,0BAA0BA,CAAS,EACxC,KAAK,2BAA2BA,CAAS,CAC3C,EACA,KAAK,SAAS,eAAiB,KAAK,sBACtC,CAEO,OAAc,CACnB,KAAK,aAAe,EACpB,KAAK,QAAQ,MAAM,EACnB,KAAK,oBAAoB,MAAM,EAC/B,KAAK,oBAAoB,MAAM,CACjC,CAEO,SAAgB,CACrB,KAAK,MAAM,EACP,KAAK,SAAS,iBAAmB,KAAK,yBACxC,KAAK,SAAS,eAAiB,KAAK,wBAExC,CAEO,WAAWE,EAAwBC,EAAgD,CACxF,IAAMC,EAAUF,GAAM,KAAK,eAErBG,EAAe,KAAK,oBAAoB,IAAID,CAAO,EACrDC,IAAiB,SACnB,KAAK,SAAS,YAAYA,CAAY,EACtC,KAAK,oBAAoB,OAAOD,CAAO,EACvC,KAAK,oBAAoB,OAAOC,CAAY,GAG1C,CAAC,KAAK,QAAQ,IAAID,CAAO,GAAK,KAAK,QAAQ,MAAQN,EAAkB,kBACvE,KAAK,wBAAwB,EAS/B,IAAMQ,EAAY,KAAK,SAAS,SAAS,EAAI,IAC7C,KAAK,QAAQ,OAAOF,CAAO,EAC3B,IAAIG,EAAgB,EACpB,QAAWC,KAAS,KAAK,QAAQ,OAAO,EAAGD,GAAiBC,EAAM,KAAK,KACvE,QAAWC,IAAe,CAAC,GAAO,EAAI,EACpC,OAAW,CAACC,EAAUF,CAAK,IAAK,KAAK,QAAS,CAC5C,GAAID,EAAgBJ,EAAU,KAAK,MAAQG,EAAW,MAClD,KAAK,oBAAoB,IAAII,CAAQ,IAAMD,IAC/CF,GAAiBC,EAAM,KAAK,KAC5B,KAAK,WAAWE,CAAQ,EAC1B,CAGF,YAAK,QAAQ,IAAIN,EAAS,CACxB,GAAGD,EACH,GAAIC,CACN,CAAC,EACMA,CACT,CAEO,SAASH,EAAiBO,EAAwCG,EAAoBC,EAAmBC,EAAsB,CAKpI,IAAMR,EAAe,KAAK,oBAAoB,IAAIJ,CAAO,EACrDI,IAAiB,QACnB,KAAK,oBAAoB,OAAOA,CAAY,EAE9C,KAAK,cAAc,UAAYM,EAC/B,KAAK,cAAc,MAAQC,EAC3B,KAAK,cAAc,OAASC,EAC5B,IAAMb,EAAY,KAAK,SAAS,SAASQ,EAAO,KAAK,aAAa,EAClE,KAAK,oBAAoB,IAAIP,EAASD,CAAS,EAC/C,KAAK,oBAAoB,IAAIA,EAAWC,CAAO,CACjD,CAEO,SAASA,EAA8C,CAC5D,OAAO,KAAK,QAAQ,IAAIA,CAAO,CACjC,CAEO,WAAWA,EAAuB,CACvC,KAAK,QAAQ,OAAOA,CAAO,EAC3B,IAAMD,EAAY,KAAK,oBAAoB,IAAIC,CAAO,EAClDD,IAAc,SAChB,KAAK,SAAS,YAAYA,CAAS,EACnC,KAAK,oBAAoB,OAAOC,CAAO,EACvC,KAAK,oBAAoB,OAAOD,CAAS,EAE7C,CAEO,WAAkB,CACvB,KAAK,QAAQ,MAAM,EACnB,QAAWA,KAAa,KAAK,oBAAoB,OAAO,EACtD,KAAK,SAAS,YAAYA,CAAS,EAErC,KAAK,oBAAoB,MAAM,EAC/B,KAAK,oBAAoB,MAAM,CACjC,CAEA,IAAW,QAA+C,CACxD,OAAO,KAAK,OACd,CAEA,IAAW,oBAAkD,CAC3D,OAAO,KAAK,mBACd,CAEA,IAAW,aAAsB,CAC/B,OAAO,KAAK,aAAe,CAC7B,CAEQ,yBAAgC,CACtC,OAAW,CAACC,CAAO,IAAK,KAAK,QAAS,CACpC,GAAI,KAAK,QAAQ,MAAQH,EAAkB,iBAAmB,EAC5D,MAEG,KAAK,oBAAoB,IAAIG,CAAO,GACvC,KAAK,QAAQ,OAAOA,CAAO,CAE/B,CACF,CACF,EA5JaH,EACa,iBAAmB,IADtC,IAAMgB,GAANhB,ECVP,IAAAiB,EAA8E,OAI9E,IAAAC,GAAsC,QAGtC,IAAMC,GAAkB,QAGlBC,GAAkB,mBACxBA,GAAgB,IAAI,qBAAmB,EAGhC,IAAMC,GAAN,KAAyD,CAK9D,YACmBC,EACAC,EACAC,EACjB,CAHiB,WAAAF,EACA,cAAAC,EACA,mBAAAC,EAPnB,KAAQ,MAAQ,EAChB,KAAQ,SAAW,MAQjB,iBAAa,CACX,YAAa,KAAK,MAAM,WAAa,EACrC,QAASJ,GACT,aAAc,KAAK,MAAM,iBAC3B,CAAC,EAAE,KAAKK,GAAK,KAAK,KAAOA,CAAC,CAC5B,CAEO,OAAc,CAOf,KAAK,OACP,KAAK,KAAK,QAAQ,EAEjB,KAAK,KAAa,SAAS,KAAK,CAAC,EAClC,KAAK,KAAK,KAAK,EAAGL,GAAiB,KAAK,MAAM,iBAAiB,EAEnE,CAEO,KAAKM,EAAuB,CAGjC,GAFA,KAAK,MAAQ,EACb,KAAK,SAAW,GACZ,KAAK,KAAM,CACb,IAAMC,EAAYD,EAAO,OAAO,CAAC,IAAM,EAAI,EAAIE,GAC7C,KAAK,cAAc,MAAM,cAAc,aACvC,KAAK,cAAc,MAAM,eAAe,MAAM,EAChD,KAAK,KAAK,KAAKD,EAAW,KAAM,KAAK,MAAM,iBAAiB,CAC9D,CACF,CAEO,IAAIE,EAAmBC,EAAeC,EAAmB,CAC9D,GAAI,OAAK,UAAY,CAAC,KAAK,MAI3B,IADA,KAAK,OAASA,EAAMD,EAChB,KAAK,MAAQ,KAAK,MAAM,eAAgB,CAC1C,QAAQ,KAAK,gCAAgC,EAC7C,KAAK,SAAW,GAChB,KAAK,KAAK,QAAQ,EAClB,MACF,CACA,GAAI,CACF,KAAK,KAAK,OAAOD,EAAMC,EAAOC,CAAG,CACnC,OAASC,EAAG,CACV,QAAQ,KAAK,uCAAuCA,CAAC,EAAE,EACvD,KAAK,SAAW,GAChB,KAAK,KAAK,QAAQ,CACpB,EACF,CAEO,OAAOC,EAA8C,CAC1D,GAAI,KAAK,UAAY,CAACA,GAAW,CAAC,KAAK,KACrC,MAAO,GAGT,IAAMC,EAAQ,KAAK,KAAK,MAClBC,EAAS,KAAK,KAAK,OAGzB,GAAI,CAACD,GAAS,CAACC,EACb,OAAIA,GACF,KAAK,SAAS,cAAcA,CAAM,EAE7B,GAGT,IAAMC,EAASC,EAAc,aAAa,OAAWH,EAAOC,CAAM,EAClE,OAAAC,EAAO,WAAW,IAAI,GAAG,aAAa,IAAI,UAAU,KAAK,KAAK,MAAyCF,EAAOC,CAAM,EAAG,EAAG,CAAC,EACvH,KAAK,KAAK,YAAchB,IAC1B,KAAK,KAAK,QAAQ,EAEpB,KAAK,SAAS,SAASiB,CAAM,EACtB,EACT,CACF,EASA,SAASR,GAAgBU,EAAqBC,EAAgD,CAC5F,IAAIC,EAAK,EACT,GAAI,CAACD,EAGH,OAAOC,EAET,GAAIF,EAAK,UAAU,EACjB,GAAIA,EAAK,YAAY,EACnBE,EAAKC,GAAUF,EAAO,WAAW,IAAI,UAC5BD,EAAK,QAAQ,EAAG,CACzB,IAAMI,EAAKJ,EAAK,YAAqC,WAAWA,EAAK,WAAW,CAAC,EACjFE,KAAK,cAAW,GAAGE,CAAC,CACtB,MACEF,EAAKC,GAAUF,EAAO,KAAKD,EAAK,WAAW,CAAC,EAAE,IAAI,UAGhDA,EAAK,YAAY,EACnBE,EAAKC,GAAUF,EAAO,WAAW,IAAI,UAC5BD,EAAK,QAAQ,EAAG,CACzB,IAAMI,EAAKJ,EAAK,YAAqC,WAAWA,EAAK,WAAW,CAAC,EACjFE,KAAK,cAAW,GAAGE,CAAC,CACtB,MACEF,EAAKC,GAAUF,EAAO,KAAKD,EAAK,WAAW,CAAC,EAAE,IAAI,EAGtD,OAAOE,CACT,CAGA,SAASC,GAAUE,EAAyB,CAC1C,OAAI,aAAmBA,GACfA,EAAQ,MAAS,IAAMA,IAAU,EAAI,MAAS,IAAMA,IAAU,GAAK,MAAS,EAAIA,IAAU,GAAK,GACzG,CCtIO,IAAMC,GAAN,KAAwB,CAE7B,YACmBC,EACAC,EACAC,EACAC,EACjB,CAJiB,cAAAH,EACA,WAAAC,EACA,eAAAC,EACA,eAAAC,EALnB,KAAQ,cAA+B,CAAE,UAAW,GAAM,MAAO,MAAO,OAAQ,EAAG,UAAW,OAAQ,CAMnG,CAMI,SAASC,EAA4C,CAC1D,KAAK,cAAc,UAAY,KAAK,MAAM,eAC1C,KAAK,SAAS,SAASA,EAAK,KAAK,aAAa,CAChD,CAOO,cAAcC,EAAsB,CACzC,GAAI,KAAK,MAAM,eAAgB,CAC7B,IAAIC,EAAW,KAAK,UAAU,UAC1BA,EAAS,QAAU,IAAMA,EAAS,SAAW,MAC/CA,EAAWC,GAEb,IAAMC,EAAO,KAAK,KAAKH,EAASC,EAAS,MAAM,EAC/C,QAASG,EAAI,EAAGA,EAAID,EAAM,EAAEC,EAC1B,KAAK,UAAU,MAAM,cAAc,SAAS,CAEhD,CACF,CACF,ECrCO,IAAMC,GAAN,KAAsB,CAE3B,YACmBC,EACjB,CADiB,cAAAA,EAFnB,KAAQ,cAA+B,CAAE,UAAW,GAAM,MAAO,MAAO,OAAQ,EAAG,UAAW,KAAM,CAGjG,CAMI,SAASC,EAA4C,CAC1D,KAAK,SAAS,SAASA,EAAK,KAAK,aAAa,CAChD,CACF,ECiCA,IAAMC,GAAsC,CAC1C,kBAAmB,GACnB,WAAY,SACZ,aAAc,GACd,eAAgB,GAChB,kBAAmB,KACnB,eAAgB,SAChB,aAAc,IACd,gBAAiB,GACjB,WAAY,GACZ,aAAc,SACd,aAAc,GACd,eAAgB,QAClB,EAGMC,GAAyB,KAsBxB,IAAMC,GAAN,KAAsD,CAW3D,YAAYC,EAAoC,CANhD,KAAQ,aAA8B,CAAC,EAEvC,KAAQ,UAAwC,IAAI,IACpD,KAAiB,cAAgB,IAAIC,EACrC,KAAgB,aAA6B,KAAK,cAAc,MAG9D,KAAK,MAAQ,OAAO,OAAO,CAAC,EAAGC,GAAiBF,CAAI,EACpD,KAAK,aAAe,OAAO,OAAO,CAAC,EAAGE,GAAiBF,CAAI,CAC7D,CAEO,SAAgB,CACrB,QAAWG,KAAW,KAAK,UAAU,OAAO,EAAGA,EAAQ,MAAM,EAC7D,QAAWC,KAAO,KAAK,aACrBA,EAAI,QAAQ,EAEd,KAAK,aAAa,OAAS,EAC3B,KAAK,UAAU,MAAM,EACrB,KAAK,cAAc,QAAQ,CAC7B,CAEQ,iBAAiBC,EAA2B,CAClD,QAAWD,KAAOC,EAChB,KAAK,aAAa,KAAKD,CAAG,CAE9B,CAEO,SAASE,EAA8B,CAS5C,GARA,KAAK,UAAYA,EAGjB,KAAK,UAAY,IAAIC,EAAcD,CAAQ,EAC3C,KAAK,SAAW,IAAIE,EAAaF,EAAU,KAAK,UAAW,KAAK,KAAK,EACrE,KAAK,SAAS,aAAe,IAAM,KAAK,cAAc,KAAK,EAGvD,KAAK,MAAM,kBAAmB,CAChC,IAAMG,EAAYH,EAAS,QAAQ,eAAiB,CAAC,EACrDG,EAAU,iBAAmB,GAC7BA,EAAU,kBAAoB,GAC9BA,EAAU,gBAAkB,GAC5BH,EAAS,QAAQ,cAAgBG,CACnC,CAiCA,GA/BA,KAAK,cACH,KAAK,UACL,KAAK,SAGLH,EAAS,OAAO,mBAAmB,CAAE,OAAQ,IAAK,MAAO,GAAI,EAAGI,GAAU,KAAK,QAAQA,CAAM,CAAC,EAC9FJ,EAAS,OAAO,mBAAmB,CAAE,OAAQ,IAAK,MAAO,GAAI,EAAGI,GAAU,KAAK,QAAQA,CAAM,CAAC,EAC9FJ,EAAS,OAAO,mBAAmB,CAAE,MAAO,GAAI,EAAGI,GAAU,KAAK,KAAKA,CAAM,CAAC,EAC9EJ,EAAS,OAAO,mBAAmB,CAAE,OAAQ,IAAK,MAAO,GAAI,EAAGI,GAAU,KAAK,yBAAyBA,CAAM,CAAC,EAG/GJ,EAAS,SAASK,GAAS,KAAK,UAAU,OAAOA,CAAK,CAAC,EAQvDL,EAAS,OAAO,mBAAmB,CAAE,cAAe,IAAK,MAAO,GAAI,EAAG,IAAM,KAAK,MAAM,CAAC,EACzFA,EAAS,OAAO,mBAAmB,CAAE,MAAO,GAAI,EAAG,IAAM,KAAK,MAAM,CAAC,EACrEA,EAAS,MAAM,cAAc,eAAe,IAAM,KAAK,MAAM,CAAC,EAG9DA,EAAS,OAAO,eAAe,IAAM,KAAK,UAAU,cAAc,CAAC,EAGnEA,EAAS,SAASM,GAAW,KAAK,UAAU,eAAeA,CAAO,CAAC,CACrE,EAGI,KAAK,MAAM,aAAc,CAC3B,IAAMC,EAAe,IAAIC,GAAkB,KAAK,SAAW,KAAK,MAAO,KAAK,UAAYR,CAAQ,EAC1FS,EAAe,IAAIC,GAAa,KAAK,MAAOH,EAAcP,CAAQ,EACxE,KAAK,UAAU,IAAI,QAASS,CAAY,EACxC,KAAK,cACHT,EAAS,MAAM,cAAc,QAAQ,mBAAmB,CAAE,MAAO,GAAI,EAAGS,CAAY,CACtF,CACF,CAGA,GAAI,KAAK,MAAM,WAAY,CACzB,IAAME,EAAa,IAAIC,GAAgB,KAAK,QAAS,EAC/CC,EAAa,IAAIC,GAAW,KAAK,MAAO,KAAK,UAAYH,EAAYX,CAAQ,EACnF,KAAK,UAAU,IAAI,MAAOa,CAAU,EACpC,KAAK,cACHb,EAAS,MAAM,cAAc,QAAQ,mBAAmB,KAAMa,CAAU,CAC1E,CACF,CAGA,GAAI,KAAK,MAAM,aAAc,CAC3B,IAAME,EAAe,IAAIC,GAAkB,KAAK,QAAS,EACnDC,EAAe,IAAIC,GAAqB,KAAK,MAAO,KAAK,UAAYH,EAAcf,CAAQ,EACjG,KAAK,UAAU,IAAI,QAASiB,CAAY,EACxC,KAAK,cACHF,EACAE,EACAjB,EAAS,MAAM,cAAc,QAAQ,mBAAmB,CAAE,MAAO,GAAI,EAAGiB,CAAY,CACtF,CACF,CACF,CAGO,OAAiB,CAEtB,KAAK,MAAM,eAAiB,KAAK,aAAa,eAC9C,KAAK,MAAM,kBAAoB,KAAK,aAAa,kBAEjD,KAAK,UAAU,MAAM,EAErB,QAAWpB,KAAW,KAAK,UAAU,OAAO,EAC1CA,EAAQ,MAAM,EAEhB,MAAO,EACT,CAEA,IAAW,cAAuB,CAChC,OAAO,KAAK,UAAU,SAAS,GAAK,EACtC,CAEA,IAAW,aAAasB,EAAe,CACrC,KAAK,UAAU,SAASA,CAAK,EAC7B,KAAK,MAAM,aAAeA,CAC5B,CAEA,IAAW,cAAuB,CAChC,OAAI,KAAK,SACA,KAAK,SAAS,SAAS,EAEzB,EACT,CAEA,IAAW,iBAA2B,CACpC,OAAO,KAAK,MAAM,eACpB,CAEA,IAAW,gBAAgBC,EAAgB,CACzC,KAAK,MAAM,gBAAkBA,EAC7B,KAAK,WAAW,gBAAgBA,CAAK,CACvC,CAEO,qBAAqBC,EAAWC,EAA0C,CAC/E,OAAO,KAAK,UAAU,qBAAqBD,EAAGC,CAAC,CACjD,CAEO,wBAAwBD,EAAWC,EAA0C,CAClF,OAAO,KAAK,UAAU,wBAAwBD,EAAGC,CAAC,CACpD,CAEQ,QAAQC,EAAiB,CAC/B,KAAK,WAAW,MAAM,MAAMA,EAAG,EAAK,CACtC,CAEQ,QAAQnB,EAAwC,CACtD,QAASoB,EAAI,EAAGA,EAAIpB,EAAO,OAAQ,EAAEoB,EAC3BpB,EAAOoB,CAAC,IACT,KACH,KAAK,MAAM,eAAiB,IAIlC,MAAO,EACT,CAEQ,QAAQpB,EAAwC,CACtD,QAASoB,EAAI,EAAGA,EAAIpB,EAAO,OAAQ,EAAEoB,EAC3BpB,EAAOoB,CAAC,IACT,KACH,KAAK,MAAM,eAAiB,IAIlC,MAAO,EACT,CAGQ,KAAKpB,EAAwC,CACnD,OAAIA,EAAO,CAAC,EACH,GAOL,KAAK,MAAM,cACb,KAAK,QAAQ,kBAAkB,EACxB,IAEF,EACT,CAYQ,yBAAyBA,EAAwC,CACvE,GAAIA,EAAO,OAAS,EAClB,MAAO,GAET,GAAIA,EAAO,CAAC,IAAM,EAChB,OAAQA,EAAO,CAAC,EAAG,CACjB,IAAK,GACH,YAAK,QAAQ,SAASA,EAAO,CAAC,CAAC,MAAwB,KAAK,MAAM,iBAAiB,GAAG,EAC/E,GACT,IAAK,GACH,KAAK,MAAM,kBAAoB,KAAK,aAAa,kBACjD,KAAK,QAAQ,SAASA,EAAO,CAAC,CAAC,MAAwB,KAAK,MAAM,iBAAiB,GAAG,EAEtF,QAAWP,KAAW,KAAK,UAAU,OAAO,EAC1CA,EAAQ,MAAM,EAEhB,MAAO,GACT,IAAK,GACH,OAAIO,EAAO,OAAS,GAAK,EAAEA,EAAO,CAAC,YAAa,QAAUA,EAAO,CAAC,GAAKqB,IACrE,KAAK,MAAM,kBAAoBrB,EAAO,CAAC,EACvC,KAAK,QAAQ,SAASA,EAAO,CAAC,CAAC,MAAwB,KAAK,MAAM,iBAAiB,GAAG,GAEtF,KAAK,QAAQ,SAASA,EAAO,CAAC,CAAC,KAA4B,EAEtD,GACT,IAAK,GACH,YAAK,QAAQ,SAASA,EAAO,CAAC,CAAC,MAAwBqB,EAAsB,GAAG,EACzE,GACT,QACE,YAAK,QAAQ,SAASrB,EAAO,CAAC,CAAC,KAA4B,EACpD,EACX,CAEF,GAAIA,EAAO,CAAC,IAAM,EAChB,OAAQA,EAAO,CAAC,EAAG,CAEjB,IAAK,GACH,IAAIsB,EAAQ,KAAK,WAAW,YAAY,IAAI,OAAO,MAC/CC,EAAS,KAAK,WAAW,YAAY,IAAI,OAAO,OACpD,GAAI,CAACD,GAAS,CAACC,EAAQ,CAGrB,IAAMC,EAAWC,EACjBH,GAAS,KAAK,WAAW,MAAQ,IAAME,EAAS,MAChDD,GAAU,KAAK,WAAW,MAAQ,IAAMC,EAAS,MACnD,CACA,GAAIF,EAAQC,EAAS,KAAK,MAAM,WAC9B,KAAK,QAAQ,SAASvB,EAAO,CAAC,CAAC,MAAwBsB,EAAM,QAAQ,CAAC,CAAC,IAAIC,EAAO,QAAQ,CAAC,CAAC,GAAG,MAC1F,CAEL,IAAMN,EAAI,KAAK,MAAM,KAAK,KAAK,KAAK,MAAM,UAAU,CAAC,EACrD,KAAK,QAAQ,SAASjB,EAAO,CAAC,CAAC,MAAwBiB,CAAC,IAAIA,CAAC,GAAG,CAClE,CACA,MAAO,GACT,IAAK,GAEH,IAAMA,EAAI,KAAK,MAAM,KAAK,KAAK,KAAK,MAAM,UAAU,CAAC,EACrD,YAAK,QAAQ,SAASjB,EAAO,CAAC,CAAC,MAAwBiB,CAAC,IAAIA,CAAC,GAAG,EACzD,GACT,QACE,YAAK,QAAQ,SAASjB,EAAO,CAAC,CAAC,KAA4B,EACpD,EACX,CAGF,YAAK,QAAQ,SAASA,EAAO,CAAC,CAAC,KAA0B,EAClD,EACT,CACF", -+ "names": ["exports", "red", "n", "green", "blue", "alpha", "toRGBA8888", "r", "g", "b", "a", "fromRGBA8888", "color", "nearestColorIndex", "palette", "min", "idx", "i", "dr", "dg", "db", "clamp", "low", "high", "value", "h2c", "t1", "t2", "c", "HLStoRGB", "h", "l", "s", "v", "normalizeRGB", "normalizeHLS", "p", "d", "require_lib", "__commonJSMin", "exports", "InWasm", "z", "b", "r", "def", "t", "s", "d", "m", "W", "e", "require_Base64Decoder_wasm", "__commonJSMin", "exports", "inwasm_runtime_1", "wasmDecode", "MAP", "el", "D", "i", "EMPTY", "Base64Decoder", "keepSize", "maxBytes", "initialBytes", "m", "bytes", "requested", "needed", "newSize", "addPages", "data", "require_QoiDecoder_wasm", "__commonJSMin", "exports", "inwasm_runtime_1", "wasmQoiDecode", "QoiDecoder", "keepSize", "d", "pixels", "ib", "dl", "bytes", "chunkP", "exports", "Colors_1", "wasm_1", "decodeBase64", "bytestring", "result", "WASM_BYTES", "WASM_MODULE", "NULL_CANVAS", "CallbackProxy", "width", "mode", "DEFAULT_OPTIONS", "DecoderAsync", "opts", "cbProxy", "importObj", "inst", "Decoder", "exports", "_instance", "_cbProxy", "module", "pixels", "offset", "additionalPixels", "newCanvas", "adv", "remaining", "c", "i", "fillColor", "palette", "paletteLimit", "truncate", "data", "start", "end", "p", "length", "j", "currentWidth", "escape", "final", "finalOffset", "bw", "currentHeight", "decode", "dec", "decodeAsync", "toDisposable", "fn", "DisposableStore", "o", "d", "Disposable", "MutableDisposable", "value", "Emitter", "listener", "thisArgs", "disposables", "toDisposable", "entry", "result", "idx", "event", "listeners", "len", "EventUtils", "forward", "from", "to", "e", "map", "i", "any", "events", "store", "DisposableStore", "runAndSubscribe", "handler", "initial", "import_Colors", "ImageRenderer", "_ImageRenderer", "Disposable", "_terminal", "MutableDisposable", "parent", "option", "toDisposable", "localDocument", "width", "height", "canvas", "ctx", "buffer", "imgData", "img", "value", "start", "end", "layer", "y", "w", "h", "imgSpec", "tileId", "col", "row", "count", "sourceWidth", "sourceHeight", "cols", "sx", "sy", "dx", "dy", "finalWidth", "finalHeight", "spec", "currentWidth", "currentHeight", "originalWidth", "originalHeight", "scaledWidth", "scaledHeight", "renderer", "key", "screenElement", "bWidth", "blueprint", "d32", "black", "white", "shift", "offset", "x", "ctx2", "i", "placeholder", "bitmap", "CELL_SIZE_DEFAULT", "ExtendedAttrsImage", "_ExtendedAttrsImage", "ext", "urlId", "imageId", "tileId", "value", "val", "EMPTY_ATTRS", "ImageStorage", "_terminal", "_renderer", "_opts", "e", "spec", "storedPixels", "id", "zero", "img", "opts", "cellSize", "cols", "rows", "buffer", "termCols", "termRows", "originX", "originY", "offset", "tileCount", "row", "line", "col", "endMarker", "imgSpec", "range", "hasTopImages", "hasBottomImages", "start", "end", "drawCalls", "placeholderCalls", "startTile", "startCol", "count", "a", "b", "call", "metrics", "oldCol", "tilesPerRow", "hasData", "rightCol", "lastTile", "expandCol", "x", "y", "orig", "canvas", "ImageRenderer", "room", "used", "current", "old", "oldSpec", "imgId", "import_Base64Decoder", "import_QoiDecoder", "toStr", "data", "s", "i", "toInt", "v", "toSize", "toName", "bs", "b", "DECODERS", "FILE_MARKER", "MULTIPARTFILE_MARKER", "FILEPART_MARKER", "FILEEND_MARKER", "REPORTCELLSIZE_MARKER", "MAX_FIELDCHARS", "HeaderParser", "k", "start", "end", "state", "pos", "buffer", "c", "UNSUPPORTED_TYPE", "imageType", "d", "d32", "width", "height", "jpgSize", "dim", "pos", "limit", "i", "len", "blockLength", "DEFAULT_HEADER", "IIPHandler", "_opts", "_renderer", "_storage", "_coreTerminal", "HeaderParser", "maxEncodedBytes", "initialBytes", "Base64Decoder", "QoiDecoder", "data", "start", "end", "dataPos", "success", "seqType", "w", "CELL_SIZE_DEFAULT", "h", "scale", "report", "cond", "metrics", "UNSUPPORTED_TYPE", "imageType", "blob", "canvas", "ImageRenderer", "generation", "bm", "e", "cw", "ch", "width", "height", "rw", "rh", "wf", "hf", "f", "s", "total", "cdim", "import_Base64Decoder", "parseKittyCommand", "data", "cmd", "parts", "part", "eqIdx", "key", "value", "numValue", "DECODER_OK", "KittyGraphicsHandler", "_opts", "_renderer", "_kittyStorage", "_coreTerminal", "key", "pending", "data", "start", "end", "controlEnd", "i", "copyLength", "parseKittyCommand", "payloadStart", "pendingKey", "previousEncodedSize", "decoderToRelease", "decoderCapacity", "maxPending", "oldest", "Base64Decoder", "success", "cmd", "isMoreComing", "decodeError", "finalCmd", "decoder", "imageBytes", "result", "str", "bytes", "id", "image", "quiet", "format", "width", "height", "bytesPerPixel", "expectedBytes", "message", "placementId", "isOk", "pPart", "response", "generation", "bitmap", "cropX", "cropY", "cropW", "cropH", "maxCropW", "maxCropH", "finalCropW", "finalCropH", "cropped", "cw", "CELL_SIZE_DEFAULT", "ch", "imgCols", "imgRows", "w", "h", "buffer", "savedX", "savedY", "savedYbase", "layer", "scaled", "xOffset", "yOffset", "canvasW", "canvasH", "offsetCanvas", "ImageRenderer", "offsetCtx", "offsetBitmap", "zIndex", "scrolled", "e", "metrics", "imageType", "blob", "url", "img", "resolve", "reject", "canvas", "pixelCount", "src32", "dst32", "alignedPixels", "srcOffset", "dstOffset", "b0", "b1", "b2", "srcByte", "dstByte", "compressed", "error", "limit", "offsetIn", "reader", "controller", "chunks", "totalLength", "done", "value", "offset", "chunk", "_KittyImageStorage", "_storage", "storageId", "kittyId", "id", "imageData", "imageId", "oldStorageId", "byteLimit", "retainedBytes", "image", "evictPlaced", "oldestId", "scrolling", "layer", "zIndex", "KittyImageStorage", "import_Colors", "import_Decoder", "MEM_PERMA_LIMIT", "DEFAULT_PALETTE", "SixelHandler", "_opts", "_storage", "_coreTerminal", "d", "params", "fillColor", "extractActiveBg", "data", "start", "end", "e", "success", "width", "height", "canvas", "ImageRenderer", "attr", "colors", "bg", "convertLe", "t", "color", "SixelImageStorage", "_storage", "_opts", "_renderer", "_terminal", "img", "height", "cellSize", "CELL_SIZE_DEFAULT", "rows", "i", "IIPImageStorage", "_storage", "img", "DEFAULT_OPTIONS", "MAX_SIXEL_PALETTE_SIZE", "ImageAddon", "opts", "Emitter", "DEFAULT_OPTIONS", "handler", "obj", "args", "terminal", "ImageRenderer", "ImageStorage", "windowOps", "params", "range", "metrics", "sixelStorage", "SixelImageStorage", "sixelHandler", "SixelHandler", "iipStorage", "IIPImageStorage", "iipHandler", "IIPHandler", "kittyStorage", "KittyImageStorage", "kittyHandler", "KittyGraphicsHandler", "limit", "value", "x", "y", "s", "i", "MAX_SIXEL_PALETTE_SIZE", "width", "height", "cellSize", "CELL_SIZE_DEFAULT"] ++ "sourcesContent": [null, "\"use strict\";\n/**\n * Copyright (c) 2022, 2026 Joerg Breitbart\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.InWasm = InWasm;\nlet z = (s) => {\n if (Uint8Array.fromBase64)\n return Uint8Array.fromBase64(s);\n if (typeof Buffer !== 'undefined')\n return Buffer.from(s, 'base64');\n const b = atob(s);\n const r = new Uint8Array(b.length);\n for (let i = 0; i < r.length; ++i)\n r[i] = b.charCodeAt(i);\n return r;\n};\nfunction InWasm(def) {\n if (def.d) {\n const { t, s, d } = def;\n let b;\n let m;\n const W = WebAssembly;\n if (t === 0 /* OutputType.INSTANCE */) {\n if (s)\n return (e) => new W.Instance(m || (m = new W.Module(b || (b = z(d)))), e);\n return (e) => m\n ? W.instantiate(m, e)\n : W.instantiate(b || (b = z(d)), e).then(r => (m = r.module) && r.instance);\n }\n if (t === 1 /* OutputType.MODULE */) {\n if (s)\n return () => m || (m = new W.Module(b || (b = z(d))));\n return () => m\n ? Promise.resolve(m)\n : W.compile(b || (b = z(d))).then(r => m = r);\n }\n if (s)\n return () => b || (b = z(d));\n return () => Promise.resolve(b || (b = z(d)));\n }\n if (typeof _wasmCtx === 'undefined')\n throw new Error('must run \"inwasm\"');\n _wasmCtx.add(def);\n}\n//# sourceMappingURL=index.js.map", "\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023, 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\n/**\n * wasm base64 decoder.\n */\nconst wasmDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#828e69684093b2c6:rdef-start:\"decode\"*/{s:1,t:0,d:'AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML'}/*inwasm#828e69684093b2c6:rdef-end:\"decode\"*/);\n// SIMD version (speedup ~1.4x, not covered by tests yet)\n/*\nconst wasmDecode = InWasm({\n name: 'decode',\n type: OutputType.INSTANCE,\n mode: OutputMode.SYNC,\n srctype: 'Clang-C',\n imports: {\n env: { memory: new WebAssembly.Memory({ initial: 1 }) }\n },\n exports: {\n dec: () => 0,\n end: () => 0\n },\n compile: {\n switches: ['-msimd128', '-Wl,-z,stack-size=0', '-Wl,--stack-first']\n },\n code: `\n #include \n typedef struct {\n unsigned int wp;\n unsigned int sp;\n unsigned int dp;\n unsigned int e_size;\n unsigned int dummy[4];\n unsigned char data[0];\n } State;\n\n unsigned int *D0 = (unsigned int *) ${P32.D0 * 4};\n unsigned int *D1 = (unsigned int *) ${P32.D1 * 4};\n unsigned int *D2 = (unsigned int *) ${P32.D2 * 4};\n unsigned int *D3 = (unsigned int *) ${P32.D3 * 4};\n State *state = (State *) ${P32.STATE * 4};\n\n #define packed_byte(x) wasm_i8x16_splat((char) x)\n #define packed_dword(x) wasm_i32x4_splat(x)\n #define masked(x, mask) wasm_v128_and(x, wasm_i32x4_splat(mask))\n\n __attribute__((noinline)) int dec() {\n unsigned int nsp = (state->wp - 1) & ~3;\n unsigned char *src = state->data + state->sp;\n unsigned char *end = state->data + nsp;\n unsigned char *dst = state->data + state->dp;\n unsigned int error = 0;\n\n v128_t err = wasm_i8x16_splat(0);\n unsigned char *end16 = state->data + (nsp & ~15);\n while (src < end16) {\n v128_t data = wasm_v128_load((v128_t *) src);\n\n // wasm-simd rewrite of http://0x80.pl/notesen/2016-01-17-sse-base64-decoding.html#vector-lookup-pshufb\n const v128_t higher_nibble = wasm_u32x4_shr(data, 4) & packed_byte(0x0f);\n const char linv = 1;\n const char hinv = 0;\n\n const v128_t lower_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n linv, linv, 0x2b, 0x30,\n 0x41, 0x50, 0x61, 0x70,\n linv, linv, linv, linv,\n linv, linv, linv, linv\n );\n const v128_t upper_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n hinv, hinv, 0x2b, 0x39,\n 0x4f, 0x5a, 0x6f, 0x7a,\n hinv, hinv, hinv, hinv,\n hinv, hinv, hinv, hinv\n );\n // the difference between the shift and lower bound\n const v128_t shift_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n 0x00, 0x00, 0x3e - 0x2b, 0x34 - 0x30,\n 0x00 - 0x41, 0x0f - 0x50, 0x1a - 0x61, 0x29 - 0x70,\n 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00\n );\n\n const v128_t upper_bound = wasm_i8x16_swizzle(upper_bound_LUT, higher_nibble);\n const v128_t lower_bound = wasm_i8x16_swizzle(lower_bound_LUT, higher_nibble);\n\n const v128_t below = wasm_i8x16_lt(data, lower_bound);\n const v128_t above = wasm_i8x16_gt(data, upper_bound);\n const v128_t eq_2f = wasm_i8x16_eq(data, packed_byte(0x2f));\n\n // in_range = not (below or above) or eq_2f\n // outside = not in_range = below or above and not eq_2f (from deMorgan law)\n const v128_t outside = wasm_v128_andnot(eq_2f, above | below);\n err = wasm_v128_or(err, outside);\n\n const v128_t shift = wasm_i8x16_swizzle(shift_LUT, higher_nibble);\n const v128_t t0 = wasm_i8x16_add(data, shift);\n v128_t v = wasm_i8x16_add(t0, wasm_v128_and(eq_2f, packed_byte(-3)));\n\n // pack bytes\n const v128_t ca = masked(v, 0x003f003f);\n const v128_t db = masked(v, 0x3f003f00);\n const v128_t t00 = wasm_v128_or(wasm_u32x4_shr(db, 8), wasm_i32x4_shl(ca, 6));\n v128_t res = wasm_v128_or(wasm_u32x4_shr(t00, 16), wasm_i32x4_shl(t00, 12));\n res = wasm_i8x16_swizzle(res, wasm_i8x16_const(2, 1, 0, 6, 5, 4, 10, 9, 8, 14, 13, 12, 16, 16, 16, 16));\n\n wasm_v128_store((v128_t *) dst, res);\n dst += 12;\n src += 16;\n }\n //if (wasm_i8x16_bitmask(err) != 0) return -1;\n if (wasm_v128_any_true(err)) return -1;\n\n // operate on 4-byte blocks\n while (src < end) {\n error |= *((unsigned int *) dst) = D0[src[0]] | D1[src[1]] | D2[src[2]] | D3[src[3]];\n dst += 3;\n src += 4;\n }\n if (error >> 24) return -1;\n state->sp = nsp;\n state->dp = dst - state->data;\n return 0;\n }\n\n int end() {\n int rem = state->wp - state->sp;\n if (rem > 4 && dec()) return -1;\n rem = state->wp - state->sp;\n if (rem < 2) return -1;\n\n unsigned char *src = state->data + state->sp;\n if (rem == 4) {\n if (src[3] == 61) rem--;\n if (src[2] == 61) rem--;\n }\n unsigned int accu = D0[src[0]] | D1[src[1]];\n int dp = 1;\n if (rem > 2) {\n accu |= D2[src[2]];\n dp++;\n if (rem == 4) {\n accu |= D3[src[3]];\n dp++;\n }\n }\n if (accu >> 24) return -1;\n *((unsigned int *) (state->data + state->dp)) = accu;\n state->dp += dp;\n return 0;\n }\n `\n});\n*/\n// base64 map\nconst MAP = new Uint8Array('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'\n .split('')\n .map(el => el.charCodeAt(0)));\n// init decoder maps in LE order\nconst D = new Uint32Array(1024);\nD.fill(0xFF000000);\nfor (let i = 0; i < MAP.length; ++i)\n D[MAP[i]] = i << 2;\nfor (let i = 0; i < MAP.length; ++i)\n D[256 + MAP[i]] = i >> 4 | ((i << 4) & 0xFF) << 8;\nfor (let i = 0; i < MAP.length; ++i)\n D[512 + MAP[i]] = (i >> 2) << 8 | ((i << 6) & 0xFF) << 16;\nfor (let i = 0; i < MAP.length; ++i)\n D[768 + MAP[i]] = i << 16;\nconst EMPTY = new Uint8Array(0);\n/**\n * base64 stream decoder.\n *\n * Features / assumptions:\n * - lazy chunkwise decoding\n * - errors out on any non base64 chars (no support for NL formatted base64)\n * - decodes in wasm\n * - inplace decoding to save memory\n * - supports a keepSize for lazy memory release\n */\nclass Base64Decoder {\n /**\n * @param keepSize Keep the wasm instance below this limit when calling `release()`.\n * @param maxBytes Max allowed bytes to allocate.\n * @param initialBytes Initial bytes to allocate.\n */\n constructor(keepSize, maxBytes, initialBytes) {\n this._inst = null;\n this._ended = true;\n this._bytes = 0;\n this.keepSize = keepSize !== null && keepSize !== void 0 ? keepSize : 1048576 /* Bytes.KEEP */;\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : 4294901760 /* Bytes.MAX */;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : 32768 /* Bytes.INITIAL */;\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw new Error('invalid byte settings');\n }\n }\n /**\n * Currently decoded bytes (borrowed).\n * Must be accessed before calling `release` or `init`.\n */\n get data8() {\n return this._inst ? this._d.subarray(0, this._m32[1282 /* P32.STATE_DP */]) : EMPTY;\n }\n /**\n * Release memory conditionally based on `keepSize`.\n * If memory gets released, also the wasm instance will be freed and recreated on next `init`,\n * otherwise the instance will be reused.\n */\n release() {\n if (!this._inst)\n return;\n if (this._bytes > this.keepSize) {\n this._inst = this._m32 = this._d = this._mem = null;\n }\n else {\n this._m32[1280 /* P32.STATE_WP */] = 0;\n this._m32[1281 /* P32.STATE_SP */] = 0;\n this._m32[1282 /* P32.STATE_DP */] = 0;\n }\n }\n /**\n * Initializes the decoder for new base64 data.\n * Must be called before doing any decoding attempts.\n * The method will either spawn a new wasm instance or grow\n * the needed memory of an existing instance.\n * @param maxBytes Max allowed bytes to allocate (overwrites ctor value).\n * @param initialBytes Initial bytes to allocate (overwrites ctor value).\n */\n init(maxBytes, initialBytes) {\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : this.maxBytes;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : Math.min(this._bytes, this.maxBytes);\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw Error('invalid byte settings');\n }\n let m = this._m32;\n const bytes = this._bytes + 5152 /* Bytes._DATA_OFFSET */;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmDecode({ env: { memory: this._mem } });\n m = new Uint32Array(this._mem.buffer, 0);\n m.set(D, 256 /* P32.D0 */);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n m = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n m[1280 /* P32.STATE_WP */] = 0;\n m[1281 /* P32.STATE_SP */] = 0;\n m[1282 /* P32.STATE_DP */] = 0;\n this._m32 = m;\n this._ended = false;\n }\n /**\n * Realloc memory. Realloc only happens, if the requested\n * size doesn't fit in the current memory.\n * The new size will be capped by `maxBytes`.\n * @param requested Bytes to be stored.\n */\n _realloc(requested) {\n const needed = this._m32[1280 /* P32.STATE_WP */] + requested;\n if (this._bytes < needed) {\n if (needed > this.maxBytes) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n let newSize = this._bytes;\n while ((newSize *= 2) < needed) { }\n newSize = Math.min(newSize, this.maxBytes);\n if (newSize < needed) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n if (newSize + 5152 /* Bytes._DATA_OFFSET */ > this._mem.buffer.byteLength) {\n const addPages = Math.ceil((newSize + 5152 /* Bytes._DATA_OFFSET */ - this._mem.buffer.byteLength) / 65536);\n this._mem.grow(addPages);\n this._m32 = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n this._bytes = newSize;\n }\n return 0 /* DecodeStatus.OK */;\n }\n /**\n * Put bytes in `data` into the decoder.\n * Additionally decodes the payload, if it reached 2^17 bytes.\n * The return value indicates the type of issue.\n * @param data Bytes to be loaded.\n */\n put(data) {\n if (!this._inst || this._ended) {\n return -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n if (this._realloc(data.length)) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n const m = this._m32;\n this._d.set(data, m[1280 /* P32.STATE_WP */]);\n m[1280 /* P32.STATE_WP */] += data.length;\n // max chunk in input handler is 2^17, try to run in \"tandem mode\"\n return m[1280 /* P32.STATE_WP */] - m[1281 /* P32.STATE_SP */] >= 131072\n ? this._inst.exports.dec()\n : 0 /* DecodeStatus.OK */;\n }\n /**\n * End the current decoding.\n * Also decodes leftover payload from previous put calls.\n */\n end() {\n this._ended = true;\n return this._inst\n ? this._inst.exports.end()\n : -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n /**\n * Bytes loaded into the decoder.\n */\n get loadedBytes() {\n return this._inst\n ? this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n /**\n * Free bytes to feed to the decoder.\n */\n get freeBytes() {\n return this._inst\n ? this.maxBytes - this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n}\nexports.default = Base64Decoder;\n//# sourceMappingURL=Base64Decoder.wasm.js.map", "\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\nconst wasmQoiDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#459f9e1bfb80b1a8:rdef-start:\"qoi_decode\"*/{s:1,t:0,d:'AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=='}/*inwasm#459f9e1bfb80b1a8:rdef-end:\"qoi_decode\"*/);\nclass QoiDecoder {\n constructor(keepSize) {\n this.keepSize = keepSize;\n this.width = 0;\n this.height = 0;\n }\n decode(d) {\n this.width = d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7];\n this.height = d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11];\n const pixels = this.width * this.height;\n const ib = pixels * 4;\n const dl = d.length;\n /**\n * byte/offset calculation:\n * To save some memory we dont reserve full memory for decoded + encoded,\n * but place encoded at the end of decoded plus 50% security distance\n * to avoid reads before writes positions:\n *\n * encoded < decoded (good compression)\n * enc ####################\n * dec #######################################\n * ^ ^\n * DST_P CHUNK_P\n *\n * encoded > decoded (degenerated compression, should not happen)\n * enc ##############################\n * dec ####################\n * ^ ^\n * DST_P CHUNK_P\n *\n * There is still a chance for overlapping r/w positions in case the compressed\n * data has very different pixel progression, yet the 50% security distance\n * should deal with that, as QOI will bloat data by 25% at max (RGB -> OP byte + RGB).\n * Since we always assume RGBA at decoding stage, the possible bloat reduces to 20% at max.\n */\n const bytes = Math.max(ib, dl) + (Math.min(ib, dl) >> 1) + 4096;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmQoiDecode({ env: { memory: this._mem } });\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n this._d = null;\n }\n if (!this._d) {\n this._d = new Uint8Array(this._mem.buffer);\n }\n // put src data at the end of memory, also align to 256\n const chunkP = (this._mem.buffer.byteLength - dl) & ~0xFF;\n this._d.set(d, chunkP);\n this._inst.exports.dec(chunkP, dl, pixels);\n return this._d.subarray(1024 /* P8.DST_P */, 1024 /* P8.DST_P */ + ib);\n }\n release() {\n if (!this._inst)\n return;\n if (this._mem.buffer.byteLength > this.keepSize) {\n this._inst = this._d = this._mem = null;\n }\n }\n}\nexports.default = QoiDecoder;\n//# sourceMappingURL=QoiDecoder.wasm.js.map", null, null, "/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal lifecycle utilities for xterm.js core.\n * Simplified from VS Code's lifecycle.ts - no tracking/leak detection.\n */\n\nexport interface IDisposable {\n dispose(): void;\n}\n\nexport function toDisposable(fn: () => void): IDisposable {\n return { dispose: fn };\n}\n\nexport function dispose(disposable: T): T;\nexport function dispose(disposable: T | undefined): T | undefined;\nexport function dispose(disposables: T[]): T[];\nexport function dispose(arg: T | T[] | undefined): T | T[] | undefined {\n if (!arg) {\n return arg;\n }\n if (Array.isArray(arg)) {\n for (const d of arg) {\n d.dispose();\n }\n return [];\n }\n arg.dispose();\n return arg;\n}\n\nexport function combinedDisposable(...disposables: IDisposable[]): IDisposable {\n return toDisposable(() => dispose(disposables));\n}\n\nexport class DisposableStore implements IDisposable {\n private readonly _disposables = new Set();\n private _isDisposed = false;\n\n public get isDisposed(): boolean {\n return this._isDisposed;\n }\n\n public add(o: T): T {\n if (this._isDisposed) {\n o.dispose();\n } else {\n this._disposables.add(o);\n }\n return o;\n }\n\n public dispose(): void {\n if (this._isDisposed) {\n return;\n }\n this._isDisposed = true;\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n\n public clear(): void {\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n}\n\nexport abstract class Disposable implements IDisposable {\n public static readonly None: IDisposable = Object.freeze({ dispose() { } });\n\n protected readonly _store = new DisposableStore();\n\n public dispose(): void {\n this._store.dispose();\n }\n\n protected _register(o: T): T {\n return this._store.add(o);\n }\n}\n\nexport class MutableDisposable implements IDisposable {\n private _value: T | undefined;\n private _isDisposed = false;\n\n public get value(): T | undefined {\n return this._isDisposed ? undefined : this._value;\n }\n\n public set value(value: T | undefined) {\n if (this._isDisposed || value === this._value) {\n return;\n }\n this._value?.dispose();\n this._value = value;\n }\n\n public clear(): void {\n this.value = undefined;\n }\n\n public dispose(): void {\n this._isDisposed = true;\n this._value?.dispose();\n this._value = undefined;\n }\n}\n", "/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal event utilities for xterm.js core.\n * Simplified from VS Code's event.ts - no leak detection/profiling.\n */\n\nimport { IDisposable, DisposableStore, toDisposable } from './Lifecycle';\n\nexport interface IEvent {\n (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable;\n}\n\nexport class Emitter {\n private _listeners: { fn: (e: T) => any, thisArgs: any }[] = [];\n private _disposed = false;\n private _event: IEvent | undefined;\n\n public get event(): IEvent {\n if (this._event) {\n return this._event;\n }\n this._event = (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n if (this._disposed) {\n return toDisposable(() => {});\n }\n\n const entry = { fn: listener, thisArgs };\n this._listeners = this._listeners.slice();\n this._listeners.push(entry);\n\n const result = toDisposable(() => {\n const idx = this._listeners.indexOf(entry);\n if (idx !== -1) {\n this._listeners = this._listeners.slice();\n this._listeners.splice(idx, 1);\n }\n });\n\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(result);\n } else {\n disposables.add(result);\n }\n }\n\n return result;\n };\n return this._event;\n }\n\n public fire(event: T): void {\n if (this._disposed || !this._listeners.length) {\n return;\n }\n if (this._listeners.length === 1) {\n this._listeners[0].fn.call(this._listeners[0].thisArgs, event);\n return;\n }\n const listeners = this._listeners;\n for (let i = 0, len = listeners.length; i < len; ++i) {\n listeners[i].fn.call(listeners[i].thisArgs, event);\n }\n }\n\n public dispose(): void {\n if (this._disposed) {\n return;\n }\n this._disposed = true;\n this._listeners.length = 0;\n }\n}\n\nexport namespace EventUtils {\n export function forward(from: IEvent, to: Emitter): IDisposable {\n return from(e => to.fire(e));\n }\n\n export function map(event: IEvent, map: (i: I) => O): IEvent {\n return (listener: (e: O) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n return event(i => listener.call(thisArgs, map(i)), undefined, disposables);\n };\n }\n\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent {\n return (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n const store = new DisposableStore();\n for (const event of events) {\n store.add(event(e => listener.call(thisArgs, e)));\n }\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(store);\n } else {\n disposables.add(store);\n }\n }\n return store;\n };\n }\n\n export function runAndSubscribe(event: IEvent, handler: (e: T) => void, initial: T): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void, initial?: T): IDisposable {\n handler(initial);\n return event(e => handler(e));\n }\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { toRGBA8888 } from 'sixel/lib/Colors';\nimport { IDisposable } from '@xterm/xterm';\nimport { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';\nimport { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\n\nconst enum Constants {\n PLACEHOLDER_LENGTH = 4096,\n PLACEHOLDER_HEIGHT = 24\n}\n\n/**\n * ImageRenderer - terminal frontend extension:\n * - provide primitives for canvas, ImageData, Bitmap (static)\n * - add canvas layer to DOM (browser only for now)\n * - draw image tiles onRender\n */\nexport class ImageRenderer extends Disposable implements IDisposable {\n /** @deprecated Kept for backward compat \u2014 points to top layer canvas. */\n public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; }\n private _layers = new Map();\n private _placeholder: HTMLCanvasElement | undefined;\n private _placeholderBitmap: ImageBitmap | undefined;\n private _optionsRefresh = this._register(new MutableDisposable());\n private _oldOpen: ((parent: HTMLElement) => void) | undefined;\n private _renderService: IRenderService | undefined;\n private _oldSetRenderer: ((renderer: any) => void) | undefined;\n\n // drawing primitive - canvas\n public static createCanvas(localDocument: Document | undefined, width: number, height: number): HTMLCanvasElement {\n /**\n * NOTE: We normally dont care, from which document the canvas\n * gets created, so we can fall back to global document,\n * if the terminal has no document associated yet.\n * This way early image loads before calling .open keep working\n * (still discouraged though, as the metrics will be screwed up).\n * Only the DOM output canvas should be on the terminal's document,\n * which gets explicitly checked in `insertLayerToDom`.\n */\n const canvas = (localDocument ?? document).createElement('canvas');\n canvas.width = width | 0;\n canvas.height = height | 0;\n return canvas;\n }\n\n // drawing primitive - ImageData with optional buffer\n public static createImageData(ctx: CanvasRenderingContext2D, width: number, height: number, buffer?: ArrayBuffer): ImageData {\n if (typeof ImageData !== 'function') {\n const imgData = ctx.createImageData(width, height);\n if (buffer) {\n imgData.data.set(new Uint8ClampedArray(buffer, 0, width * height * 4));\n }\n return imgData;\n }\n return buffer\n ? new ImageData(new Uint8ClampedArray(buffer, 0, width * height * 4), width, height)\n : new ImageData(width, height);\n }\n\n // drawing primitive - ImageBitmap\n public static createImageBitmap(img: ImageBitmapSource): Promise {\n if (typeof createImageBitmap !== 'function') {\n return Promise.resolve(undefined);\n }\n return createImageBitmap(img);\n }\n\n\n constructor(private _terminal: ITerminalExt) {\n super();\n this._oldOpen = this._terminal._core.open;\n this._terminal._core.open = (parent: HTMLElement): void => {\n this._oldOpen?.call(this._terminal._core, parent);\n this._open();\n };\n if (this._terminal._core.screenElement) {\n this._open();\n }\n // hack to spot fontSize changes\n this._optionsRefresh.value = this._terminal._core.optionsService.onOptionChange(option => {\n if (option === 'fontSize') {\n this.rescaleCanvas();\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n });\n this._register(toDisposable(() => {\n this.removeLayerFromDom();\n this.removeLayerFromDom('bottom');\n if (this._terminal._core && this._oldOpen) {\n this._terminal._core.open = this._oldOpen;\n this._oldOpen = undefined;\n }\n if (this._renderService && this._oldSetRenderer) {\n this._renderService.setRenderer = this._oldSetRenderer;\n this._oldSetRenderer = undefined;\n }\n this._renderService = undefined;\n this._layers.clear();\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }));\n }\n\n /**\n * Enable the placeholder.\n */\n public showPlaceholder(value: boolean): void {\n if (value) {\n if (!this._placeholder && this.cellSize.height !== -1) {\n this._createPlaceHolder(Math.max(this.cellSize.height + 1, Constants.PLACEHOLDER_HEIGHT));\n }\n } else {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n\n /**\n * Dimensions of the terminal.\n * Forwarded from internal render service.\n */\n public get dimensions(): IRenderDimensions | undefined {\n return this._terminal.dimensions;\n }\n\n /**\n * Current cell size (float).\n */\n public get cellSize(): ICellSize {\n return {\n width: this.dimensions?.css.cell.width || -1,\n height: this.dimensions?.css.cell.height || -1\n };\n }\n\n /**\n * Clear a region of the image layer canvas.\n */\n public clearLines(start: number, end: number, layer?: ImageLayer): void {\n const y = start * (this.dimensions?.css.cell.height || 0);\n const w = this.dimensions?.css.canvas.width || 0;\n const h = (end + 1 - start) * (this.dimensions?.css.cell.height || 0);\n if (!layer || layer === 'top') {\n this._layers.get('top')?.clearRect(0, y, w, h);\n }\n if (!layer || layer === 'bottom') {\n this._layers.get('bottom')?.clearRect(0, y, w, h);\n }\n }\n\n /**\n * Clear whole image canvas.\n */\n public clearAll(layer?: ImageLayer): void {\n if (!layer || layer === 'top') {\n const ctx = this._layers.get('top');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n if (!layer || layer === 'bottom') {\n const ctx = this._layers.get('bottom');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n }\n\n /**\n * Draw neighboring tiles on the image layer canvas.\n */\n public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get(imgSpec.layer);\n if (!ctx) {\n return;\n }\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const { width: sourceWidth, height: sourceHeight } = imgSpec.actualCellSize;\n const cols = Math.ceil(img.width / sourceWidth);\n\n const sx = (tileId % cols) * sourceWidth;\n const sy = Math.floor(tileId / cols) * sourceHeight;\n const dx = col * width;\n const dy = row * height;\n\n // safari bug: never access image source out of bounds\n const finalWidth = count * sourceWidth + sx > img.width ? img.width - sx : count * sourceWidth;\n const finalHeight = sy + sourceHeight > img.height ? img.height - sy : sourceHeight;\n\n // Floor all pixel offsets to get stable tile mapping without any overflows.\n // Note: For not pixel perfect aligned cells like in the DOM renderer\n // this will move a tile slightly to the top/left (subpixel range, thus ignore it).\n // FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),\n Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth * width / sourceWidth), Math.ceil(finalHeight * height / sourceHeight)\n );\n }\n\n /**\n * Extract a single tile from an image.\n */\n public extractTile(imgSpec: IImageSpec, tileId: number): HTMLCanvasElement | undefined {\n const { width, height } = this.cellSize;\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const { width: sourceWidth, height: sourceHeight } = imgSpec.actualCellSize;\n const cols = Math.ceil(img.width / sourceWidth);\n const sx = (tileId % cols) * sourceWidth;\n const sy = Math.floor(tileId / cols) * sourceHeight;\n const finalWidth = sourceWidth + sx > img.width ? img.width - sx : sourceWidth;\n const finalHeight = sy + sourceHeight > img.height ? img.height - sy : sourceHeight;\n\n const canvas = ImageRenderer.createCanvas(this.document, Math.ceil(finalWidth * width / sourceWidth), Math.ceil(finalHeight * height / sourceHeight));\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight),\n 0, 0, canvas.width, canvas.height\n );\n return canvas;\n }\n }\n\n /**\n * Draw a line with placeholder on the image layer canvas.\n */\n public drawPlaceholder(col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get('top');\n if (ctx) {\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n if (!this._placeholder) {\n this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT));\n } else if (height >= this._placeholder!.height) {\n this._createPlaceHolder(height + 1);\n }\n if (!this._placeholder) return;\n ctx.drawImage(\n this._placeholderBitmap ?? this._placeholder!,\n col * width,\n (row * height) % 2 ? 0 : 1, // needs %2 offset correction\n width * count,\n height,\n col * width,\n row * height,\n width * count,\n height\n );\n }\n }\n\n /**\n * Rescale image layer canvas if needed.\n * Checked once from `ImageStorage.render`.\n */\n public rescaleCanvas(): void {\n const w = this.dimensions?.css.canvas.width || 0;\n const h = this.dimensions?.css.canvas.height || 0;\n for (const ctx of this._layers.values()) {\n if (ctx.canvas.width !== w || ctx.canvas.height !== h) {\n ctx.canvas.width = w;\n ctx.canvas.height = h;\n }\n }\n }\n\n /**\n * Rescale image in storage if needed.\n */\n private _rescaleImage(spec: IImageSpec, currentWidth: number, currentHeight: number): void {\n if (currentWidth === spec.actualCellSize.width && currentHeight === spec.actualCellSize.height) {\n return;\n }\n const { width: originalWidth, height: originalHeight } = spec.origCellSize;\n if (currentWidth === originalWidth && currentHeight === originalHeight) {\n spec.actual = spec.orig;\n spec.actualCellSize.width = originalWidth;\n spec.actualCellSize.height = originalHeight;\n return;\n }\n const scaledWidth = Math.ceil(spec.orig!.width * currentWidth / originalWidth);\n const scaledHeight = Math.ceil(spec.orig!.height * currentHeight / originalHeight);\n // Upscale visible tiles directly; a full zoomed copy can dwarf the image budget.\n if (scaledWidth * scaledHeight > spec.orig!.width * spec.orig!.height) {\n spec.actual = spec.orig;\n spec.actualCellSize.width = originalWidth;\n spec.actualCellSize.height = originalHeight;\n return;\n }\n const canvas = ImageRenderer.createCanvas(this.document, scaledWidth, scaledHeight);\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(spec.orig!, 0, 0, canvas.width, canvas.height);\n spec.actual = canvas;\n spec.actualCellSize.width = currentWidth;\n spec.actualCellSize.height = currentHeight;\n }\n }\n\n /**\n * Lazy init for the renderer.\n */\n private _open(): void {\n this._renderService = this._terminal._core._renderService;\n this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);\n this._renderService.setRenderer = (renderer: any) => {\n for (const key of [...this._layers.keys()]) {\n this.removeLayerFromDom(key);\n }\n this._oldSetRenderer?.call(this._renderService, renderer);\n };\n }\n\n public insertLayerToDom(layer: ImageLayer = 'top'): void {\n // make sure that the terminal is attached to a document and to DOM\n if (!this.document || !this._terminal._core.screenElement) {\n console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');\n return;\n }\n if (this._layers.has(layer)) {\n return;\n }\n const canvas = ImageRenderer.createCanvas(\n this.document, this.dimensions?.css.canvas.width || 0,\n this.dimensions?.css.canvas.height || 0\n );\n canvas.classList.add(`xterm-image-layer-${layer}`);\n const screenElement = this._terminal._core.screenElement;\n // Use isolation to create a stacking context without overriding z-index,\n // which would conflict with integrators (e.g. VS Code) that set their\n // own z-index on the screen element.\n screenElement.style.isolation = 'isolate';\n if (layer === 'bottom') {\n // Use z-index:-1 so it paints behind non-positioned text elements.\n // The screen element needs to be a stacking context (via isolation)\n // to contain the negative z-index, otherwise it would go behind the\n // entire terminal.\n canvas.style.zIndex = '-1';\n screenElement.insertBefore(canvas, screenElement.firstChild);\n } else {\n // Explicit z-index ensures the image canvas reliably stacks above\n // the text layer (DOM renderer rows). z-index: 0 is below the\n // selection overlay (z-index: 1).\n canvas.style.zIndex = '0';\n screenElement.appendChild(canvas);\n }\n const ctx = canvas.getContext('2d', { alpha: true });\n if (!ctx) {\n canvas.remove();\n return;\n }\n this._layers.set(layer, ctx);\n this.clearAll(layer);\n }\n\n public removeLayerFromDom(layer: ImageLayer = 'top'): void {\n const ctx = this._layers.get(layer);\n if (ctx) {\n ctx.canvas.remove();\n this._layers.delete(layer);\n }\n }\n\n public hasLayer(layer: ImageLayer): boolean {\n return this._layers.has(layer);\n }\n\n private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n\n // create blueprint to fill placeholder with\n const bWidth = 32; // must be 2^n\n const blueprint = ImageRenderer.createCanvas(this.document, bWidth, height);\n const ctx = blueprint.getContext('2d', { alpha: false });\n if (!ctx) return;\n const imgData = ImageRenderer.createImageData(ctx, bWidth, height);\n const d32 = new Uint32Array(imgData.data.buffer);\n const black = toRGBA8888(0, 0, 0);\n const white = toRGBA8888(255, 255, 255);\n d32.fill(black);\n for (let y = 0; y < height; ++y) {\n const shift = y % 2;\n const offset = y * bWidth;\n for (let x = 0; x < bWidth; x += 2) {\n d32[offset + x + shift] = white;\n }\n }\n ctx.putImageData(imgData, 0, 0);\n\n // create placeholder line, width aligned to blueprint width\n const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || Constants.PLACEHOLDER_LENGTH;\n this._placeholder = ImageRenderer.createCanvas(this.document, width, height);\n const ctx2 = this._placeholder.getContext('2d', { alpha: false });\n if (!ctx2) {\n this._placeholder = undefined;\n return;\n }\n for (let i = 0; i < width; i += bWidth) {\n ctx2.drawImage(blueprint, i, 0);\n }\n const placeholder = this._placeholder;\n ImageRenderer.createImageBitmap(placeholder).then(bitmap => {\n if (this._placeholder !== placeholder) bitmap?.close();\n else this._placeholderBitmap = bitmap;\n }).catch(() => {});\n }\n\n public get document(): Document | undefined {\n return this._terminal._core._coreBrowserService?.window.document;\n }\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageRenderer } from './ImageRenderer';\nimport {\n ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec,\n IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes,\n UnderlineStyle, IAddImageOpts\n} from './Types';\n\n\n// fallback default cell size\nexport const CELL_SIZE_DEFAULT: ICellSize = {\n width: 7,\n height: 14\n};\n\n/**\n * Extend extended attribute to also hold image tile information.\n *\n * Object definition is copied from base repo to fully mimick its behavior.\n * Image data is added as additional public properties `imageId` and `tileId`.\n */\nclass ExtendedAttrsImage implements IExtendedAttrsImage {\n private _ext: number = 0;\n public get ext(): number {\n if (this._urlId) {\n return (\n (this._ext & ~ExtFlags.UNDERLINE_STYLE) |\n (this.underlineStyle << 26)\n );\n }\n return this._ext;\n }\n public set ext(value: number) { this._ext = value; }\n\n public get underlineStyle(): UnderlineStyle {\n // Always return the URL style if it has one\n if (this._urlId) {\n return UnderlineStyle.DASHED;\n }\n return (this._ext & ExtFlags.UNDERLINE_STYLE) >> 26;\n }\n public set underlineStyle(value: UnderlineStyle) {\n this._ext &= ~ExtFlags.UNDERLINE_STYLE;\n this._ext |= (value << 26) & ExtFlags.UNDERLINE_STYLE;\n }\n\n public get underlineColor(): number {\n return this._ext & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n public set underlineColor(value: number) {\n this._ext &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);\n this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n\n public get underlineVariantOffset(): number {\n const val = (this._ext & ExtFlags.VARIANT_OFFSET) >> 29;\n if (val < 0) {\n return val ^ 0xFFFFFFF8;\n }\n return val;\n }\n public set underlineVariantOffset(value: number) {\n this._ext &= ~ExtFlags.VARIANT_OFFSET;\n this._ext |= (value << 29) & ExtFlags.VARIANT_OFFSET;\n }\n\n private _urlId: number = 0;\n public get urlId(): number {\n return this._urlId;\n }\n public set urlId(value: number) {\n this._urlId = value;\n }\n\n constructor(\n ext: number = 0,\n urlId: number = 0,\n public imageId = -1,\n public tileId = -1\n ) {\n this._ext = ext;\n this._urlId = urlId;\n }\n\n public clone(): IExtendedAttrsImage {\n /**\n * Technically we dont need a clone variant of ExtendedAttrsImage,\n * as we never clone a cell holding image data.\n * Note: Clone is only meant to be used by the InputHandler for\n * sticky attributes, which is never the case for image data.\n * We still provide a proper clone method to reflect the full ext attr\n * state in case there are future use cases for clone.\n */\n return new ExtendedAttrsImage(this._ext, this._urlId, this.imageId, this.tileId);\n }\n\n public isEmpty(): boolean {\n return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.imageId === -1;\n }\n}\nconst EMPTY_ATTRS = new ExtendedAttrsImage();\n\n\n/**\n * ImageStorage - extension of CoreTerminal:\n * - hold image data\n * - write/read image data to/from buffer\n *\n * TODO: image composition for overwrites\n */\nexport class ImageStorage implements IDisposable {\n // storage\n private _images: Map = new Map();\n // last used id\n private _lastId = 0;\n // last evicted id\n private _lowestId = 0;\n // whether a full clear happened before\n private _fullyCleared = false;\n // whether render should do a full clear\n private _needsFullClear = false;\n // hard limit of stored pixels (fallback limit of 10 MB)\n private _pixelLimit: number = 2500000;\n\n private _viewportMetrics: { cols: number, rows: number };\n public onImageAdded: (() => void) | undefined;\n public onImageDeleted: ((storageId: number) => void) | undefined;\n\n constructor(\n private _terminal: ITerminalExt,\n private _renderer: ImageRenderer,\n private _opts: IImageAddonOptions\n ) {\n try {\n this.setLimit(this._opts.storageLimit);\n } catch (e: unknown) {\n if (e instanceof Error) {\n console.error(e.message);\n }\n console.warn(`storageLimit is set to ${this.getLimit()} MB`);\n }\n this._viewportMetrics = {\n cols: this._terminal.cols,\n rows: this._terminal.rows\n };\n }\n\n public dispose(): void {\n this.reset();\n }\n\n public reset(): void {\n for (const spec of this._images.values()) {\n spec.marker?.dispose();\n }\n // NOTE: marker.dispose above already calls ImageBitmap.close\n // therefore we can just wipe the map here\n this._images.clear();\n this._renderer.clearAll();\n }\n\n public getLimit(): number {\n return this._pixelLimit * 4 / 1000000;\n }\n\n public setLimit(value: number): void {\n if (value < 0.5 || value > 1000) {\n throw RangeError('invalid storageLimit, should be at least 0.5 MB and not exceed 1G');\n }\n this._pixelLimit = (value / 4 * 1000000) >>> 0;\n this._evictOldest(0);\n }\n\n public getUsage(): number {\n return this._getStoredPixels() * 4 / 1000000;\n }\n\n private _getStoredPixels(): number {\n let storedPixels = 0;\n for (const spec of this._images.values()) {\n if (spec.orig) {\n storedPixels += spec.orig.width * spec.orig.height;\n if (spec.actual && spec.actual !== spec.orig) {\n storedPixels += spec.actual.width * spec.actual.height;\n }\n }\n }\n return storedPixels;\n }\n\n private _delImg(id: number): void {\n const spec = this._images.get(id);\n if (!spec) return;\n this._images.delete(id);\n // FIXME: really ugly workaround to get bitmaps deallocated :(\n if (window.ImageBitmap && spec.orig instanceof ImageBitmap) {\n spec.orig.close();\n }\n this.onImageDeleted?.(id);\n }\n\n /**\n * Wipe canvas and images on alternate buffer.\n */\n public wipeAlternate(): void {\n // remove all alternate tagged images\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate') {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n // mark canvas to be wiped on next render\n this._needsFullClear = true;\n this._fullyCleared = false;\n }\n\n /**\n * Delete an image by its internal storage ID.\n * Used by protocols that support explicit deletion (e.g. Kitty a=d).\n */\n public deleteImage(id: number): void {\n const spec = this._images.get(id);\n if (spec) {\n spec.marker?.dispose();\n this._delImg(id);\n }\n }\n\n /**\n * Method to add an image to the storage.\n * @param img - The image to add (canvas or bitmap).\n * @param opts - Options for addImage:\n * - scrolling: When true, cursor advances with the image.\n * When false, image is placed at ORIGIN and cursor does not move.\n * - layer: Which canvas layer to render on ('top' or 'bottom').\n * - zIndex: Z-index for image layering within the same layer.\n * - cursorPos: 'vt340' for bottom-left, 'iip' for bottom.right.\n * @returns The internal image ID assigned to the stored image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap, opts: IAddImageOpts): number {\n // never allow storage to exceed memory limit\n this._evictOldest(img.width * img.height);\n\n // calc rows x cols needed to display the image\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const cols = Math.ceil(img.width / cellSize.width);\n const rows = Math.ceil(img.height / cellSize.height);\n\n const imageId = ++this._lastId;\n\n const buffer = this._terminal._core.buffer;\n const termCols = this._terminal.cols;\n const termRows = this._terminal.rows;\n const originX = buffer.x;\n const originY = buffer.y;\n let offset = originX;\n let tileCount = 0;\n\n if (!opts.scrolling) {\n buffer.x = 0;\n buffer.y = 0;\n offset = 0;\n }\n\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(buffer.y + buffer.ybase);\n for (let col = 0; col < cols; ++col) {\n if (offset + col >= termCols) break;\n this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);\n tileCount++;\n }\n if (opts.scrolling) {\n if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();\n } else {\n if (++buffer.y >= termRows) break;\n }\n buffer.x = offset;\n }\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n\n // cursor positioning modes\n if (opts.scrolling) {\n if (opts.cursorPos === 'iip') {\n buffer.x = Math.min(offset + cols, termCols);\n } else {\n buffer.x = offset;\n }\n } else {\n buffer.x = originX;\n buffer.y = originY;\n }\n\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.tileCount < 1) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n\n // eviction marker:\n // delete the image when the marker gets disposed\n const endMarker = this._terminal.registerMarker(0);\n endMarker?.onDispose(() => {\n const spec = this._images.get(imageId);\n if (spec) {\n this._delImg(imageId);\n }\n });\n\n // since markers do not work on alternate for some reason,\n // we evict images here manually\n if (this._terminal.buffer.active.type === 'alternate') {\n this._evictOnAlternate();\n }\n\n // create storage entry\n const imgSpec: IImageSpec = {\n orig: img,\n origCellSize: cellSize,\n actual: img,\n actualCellSize: { ...cellSize }, // clone needed, since later modified\n marker: endMarker || undefined,\n tileCount,\n bufferType: this._terminal.buffer.active.type,\n layer: opts.layer,\n zIndex: opts.zIndex\n };\n\n // finally add the image\n this._images.set(imageId, imgSpec);\n this.onImageAdded?.();\n return imageId;\n }\n\n\n /**\n * Render method. Collects buffer information and triggers\n * canvas updates.\n */\n // TODO: Should we move this to the ImageRenderer?\n public render(range: { start: number, end: number }): void {\n // Determine which layers have images\n let hasTopImages = false;\n let hasBottomImages = false;\n for (const spec of this._images.values()) {\n if (spec.layer === 'bottom') {\n hasBottomImages = true;\n } else {\n hasTopImages = true;\n }\n if (hasTopImages && hasBottomImages) break;\n }\n\n // Lazily insert layers that are needed\n if (hasTopImages && !this._renderer.hasLayer('top')) {\n this._renderer.insertLayerToDom('top');\n if (!this._renderer.hasLayer('top')) return;\n }\n if (hasBottomImages && !this._renderer.hasLayer('bottom')) {\n this._renderer.insertLayerToDom('bottom');\n }\n\n // rescale if needed\n this._renderer.rescaleCanvas();\n\n // exit early if we dont have any images to test for\n if (!this._images.size) {\n if (!this._fullyCleared) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n if (this._renderer.hasLayer('top')) {\n this._renderer.removeLayerFromDom('top');\n }\n if (this._renderer.hasLayer('bottom')) {\n this._renderer.removeLayerFromDom('bottom');\n }\n return;\n }\n\n // Remove layers no longer needed\n if (!hasTopImages && this._renderer.hasLayer('top')) {\n this._renderer.clearAll('top');\n this._renderer.removeLayerFromDom('top');\n }\n if (!hasBottomImages && this._renderer.hasLayer('bottom')) {\n this._renderer.clearAll('bottom');\n this._renderer.removeLayerFromDom('bottom');\n }\n\n // buffer switches force a full clear\n if (this._needsFullClear) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n\n const { start, end } = range;\n const buffer = this._terminal._core.buffer;\n const cols = this._terminal._core.cols;\n\n // clear drawing area\n this._renderer.clearLines(start, end);\n\n // Collect draw calls so we can sort by z-index (lower z drawn first).\n const drawCalls: { imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number }[] = [];\n const placeholderCalls: { col: number, row: number, count: number }[] = [];\n\n // walk all cells in viewport and collect tiles found\n for (let row = start; row <= end; ++row) {\n const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt;\n if (!line) return;\n for (let col = 0; col < cols; ++col) {\n if (line.getBg(col) & BgFlags.HAS_EXTENDED) {\n let e: IExtendedAttrsImage = line._extendedAttrs[col] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (e.tileId !== -1) {\n const startTile = e.tileId;\n const startCol = col;\n let count = 1;\n /**\n * merge tiles to the right into a single draw call, if:\n * - not at end of line\n * - cell has same image id\n * - cell has consecutive tile id\n */\n while (\n ++col < cols\n && (line.getBg(col) & BgFlags.HAS_EXTENDED)\n && (e = line._extendedAttrs[col] ?? EMPTY_ATTRS)\n && (e.imageId === imageId)\n && (e.tileId === startTile + count)\n ) {\n count++;\n }\n col--;\n if (imgSpec) {\n if (imgSpec.actual) {\n drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });\n }\n } else if (this._opts.showPlaceholder) {\n placeholderCalls.push({ col: startCol, row, count });\n }\n this._fullyCleared = false;\n }\n }\n }\n }\n\n // Sort by z-index so lower z draws first (higher z renders on top)\n drawCalls.sort((a, b) => a.imgSpec.zIndex - b.imgSpec.zIndex);\n\n // Draw placeholders first (lowest priority)\n for (const call of placeholderCalls) {\n this._renderer.drawPlaceholder(call.col, call.row, call.count);\n }\n\n // Draw images in z-index order\n for (const call of drawCalls) {\n this._renderer.draw(call.imgSpec, call.tileId, call.col, call.row, call.count);\n }\n }\n\n public viewportResize(metrics: { cols: number, rows: number }): void {\n // exit early if we have nothing in storage\n if (!this._images.size) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // handle only viewport width enlargements, exit all other cases\n // TODO: needs patch for tile counter\n if (this._viewportMetrics.cols >= metrics.cols) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // walk scrollbuffer at old col width to find all possible expansion matches\n const buffer = this._terminal._core.buffer;\n const rows = buffer.lines.length;\n const oldCol = this._viewportMetrics.cols - 1;\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(row) as IBufferLineExt;\n if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (!imgSpec) {\n continue;\n }\n // found an image tile at oldCol, check if it qualifies for right exapansion\n const tilesPerRow = Math.ceil((imgSpec.actual?.width || 0) / imgSpec.actualCellSize.width);\n if ((e.tileId % tilesPerRow) + 1 >= tilesPerRow) {\n continue;\n }\n // expand only if right side is empty (nothing got wrapped from below)\n let hasData = false;\n for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) {\n if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) {\n hasData = true;\n break;\n }\n }\n if (hasData) {\n continue;\n }\n // do right expansion on terminal buffer\n const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol);\n let lastTile = e.tileId;\n for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) {\n this._writeToCell(line as IBufferLineExt, expandCol, imageId, ++lastTile);\n imgSpec.tileCount++;\n }\n }\n }\n // store new viewport metrics\n this._viewportMetrics = metrics;\n }\n\n /**\n * Retrieve original canvas at buffer position.\n */\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1) {\n const orig = this._images.get(e.imageId)?.orig;\n if (window.ImageBitmap && orig instanceof ImageBitmap) {\n const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height);\n canvas.getContext('2d')?.drawImage(orig, 0, 0, orig.width, orig.height);\n return canvas;\n }\n return orig as HTMLCanvasElement;\n }\n }\n }\n\n /**\n * Extract active single tile at buffer position.\n */\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1 && e.tileId !== -1) {\n const spec = this._images.get(e.imageId);\n if (spec) {\n return this._renderer.extractTile(spec, e.tileId);\n }\n }\n }\n }\n\n // TODO: Do we need some blob offloading tricks here to avoid early eviction?\n // also see https://stackoverflow.com/questions/28307789/is-there-any-limitation-on-javascript-max-blob-size\n private _evictOldest(room: number): number {\n const used = this._getStoredPixels();\n let current = used;\n while (this._pixelLimit < current + room && this._images.size) {\n const spec = this._images.get(++this._lowestId);\n if (spec && spec.orig) {\n current -= spec.orig.width * spec.orig.height;\n if (spec.actual && spec.orig !== spec.actual) {\n current -= spec.actual.width * spec.actual.height;\n }\n spec.marker?.dispose();\n this._delImg(this._lowestId);\n }\n }\n return used - current;\n }\n\n private _writeToCell(line: IBufferLineExt, x: number, imageId: number, tileId: number): void {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const old = line._extendedAttrs[x];\n if (old) {\n if (old.imageId !== undefined) {\n // found an old ExtendedAttrsImage, since we know that\n // they are always isolated instances (single cell usage),\n // we can re-use it and just update their id entries\n const oldSpec = this._images.get(old.imageId);\n if (oldSpec) {\n // early eviction for in-viewport overwrites\n oldSpec.tileCount--;\n }\n old.imageId = imageId;\n old.tileId = tileId;\n return;\n }\n // found a plain ExtendedAttrs instance, clone it to new entry\n line._extendedAttrs[x] = new ExtendedAttrsImage(old.ext, old.urlId, imageId, tileId);\n return;\n }\n }\n // fall-through: always create new ExtendedAttrsImage entry\n line._data[x * Cell.SIZE + Cell.BG] |= BgFlags.HAS_EXTENDED;\n line._extendedAttrs[x] = new ExtendedAttrsImage(0, 0, imageId, tileId);\n }\n\n private _evictOnAlternate(): void {\n // nullify tile count of all images on alternate buffer\n for (const spec of this._images.values()) {\n if (spec.bufferType === 'alternate') {\n spec.tileCount = 0;\n }\n }\n // re-count tiles on whole buffer\n const buffer = this._terminal._core.buffer;\n for (let y = 0; y < this._terminal.rows; ++y) {\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (!line) {\n continue;\n }\n for (let x = 0; x < this._terminal.cols; ++x) {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const imgId = line._extendedAttrs[x]?.imageId;\n if (imgId) {\n const spec = this._images.get(imgId);\n if (spec) {\n spec.tileCount++;\n }\n }\n }\n }\n }\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate' && !spec.tileCount) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nimport { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { CELL_SIZE_DEFAULT } from './ImageStorage';\nimport Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';\nimport { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser';\nimport { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';\n\n// Local const enum mirror - esbuild can't inline const enums from external packages\nconst enum DecoderConst {\n // Held memory in base64/QOI decoders between images. Zero because each kept\n // decoder pins a wasm memory, and V8 caps those per process (~124 in a\n // sandboxed renderer), so idle terminals must not hold one.\n KEEP_DATA = 0,\n // Initial buffer allocation for the decoder.\n INITIAL_DATA = 1048576,\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n OK = 0\n}\n\n// default IIP header values\nconst DEFAULT_HEADER: IHeaderFields = {\n type: SequenceType.INVALID,\n name: 'Unnamed file',\n size: 0,\n width: 'auto',\n height: 'auto',\n preserveAspectRatio: 1,\n inline: 0\n};\n\n\nexport class IIPHandler implements IOscHandler, IResetHandler {\n private _generation = 0;\n private _aborted = false;\n private _hp = new HeaderParser();\n private _header: IHeaderFields = DEFAULT_HEADER;\n private _dec: Base64Decoder;\n private _qoiDec: QoiDecoder;\n private _isMultipart = false;\n private _abortMulti = false;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _storage: IIPImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);\n const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);\n this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes);\n this._qoiDec = new QoiDecoder(DecoderConst.KEEP_DATA);\n }\n\n public reset(): void {\n this._generation++;\n this._hp.reset();\n this._dec.release();\n this._qoiDec.release();\n }\n\n public start(): void {\n this._aborted = false;\n this._hp.reset();\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (this._hp.state === HeaderState.END) {\n if ((this._dec.put(data.subarray(start, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n }\n } else {\n const dataPos = this._hp.parse(data, start, end);\n if (dataPos === -1) {\n this._aborted = true;\n return;\n }\n if (dataPos > 0) {\n const seqType = this._hp.fields.type;\n if (seqType === SequenceType.FILE) {\n if (this._isMultipart) {\n this._isMultipart = false;\n this._abortMulti = false;\n this._dec.release();\n }\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n if (!this._header.inline) {\n this._aborted = true;\n return;\n }\n if (!this._initDecoder()) {\n this._aborted = true;\n return;\n }\n } else if (this._abortMulti) {\n this._aborted = true;\n return;\n }\n if ((this._dec.put(data.subarray(dataPos, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n if (this._isMultipart) this._abortMulti = true;\n }\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted) return true;\n\n if (this._hp.state !== HeaderState.END) {\n if (this._hp.end()) return true;\n }\n const seqType = this._hp.fields.type;\n\n if (seqType === SequenceType.FILEPART) return true;\n\n if (seqType === SequenceType.REPORTCELLSIZE) {\n // OSC 1337 ; ReportCellSize=[height];[width];[scale] ST\n let w = CELL_SIZE_DEFAULT.width;\n let h = CELL_SIZE_DEFAULT.height;\n if (this._renderer.dimensions) {\n w = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols;\n h = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows;\n }\n const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1;\n const report = `\\x1b]1337;ReportCellSize=${h.toFixed(3)};${w.toFixed(3)};${scale.toFixed(3)}\\x1b\\\\`;\n this._coreTerminal.input(report, false);\n return true;\n }\n\n if (seqType === SequenceType.MULTIPARTFILE) {\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n this._isMultipart = true;\n this._abortMulti = false;\n this._dec.release();\n if (!this._initDecoder()) {\n this._abortMulti = true;\n }\n return true;\n }\n\n if (seqType === SequenceType.FILEEND) {\n if (!this._isMultipart) return true;\n this._isMultipart = false;\n if (this._abortMulti || this._header.type !== SequenceType.MULTIPARTFILE) return true;\n }\n\n // fallthrough for SequenceType.FILE & SequenceType.FILEEND\n\n let w = 0;\n let h = 0;\n\n // early exit condition chain\n let cond: number | boolean;\n let metrics = UNSUPPORTED_TYPE;\n if (cond = success) {\n if (cond = !this._dec.end()) {\n metrics = imageType(this._dec.data8);\n if (cond = metrics.mime !== 'unsupported') {\n w = metrics.width;\n h = metrics.height;\n if (cond = w && h && w * h < this._opts.pixelLimit) {\n [w, h] = this._resize(w, h).map(Math.floor);\n cond = w && h && w * h < this._opts.pixelLimit;\n } else {\n console.warn(`IIP: image dimension issue ${metrics.width}x${metrics.height}`);\n }\n } else {\n console.warn('IIP: unsupported image type');\n }\n } else {\n console.warn('IIP: error during BASE64 decoding');\n }\n }\n if (!cond) {\n this._dec.release();\n return true;\n }\n\n let blob: Blob | ImageData;\n if (metrics.mime === 'image/qoi') {\n let data: Uint8Array;\n try {\n data = this._qoiDec.decode(this._dec.data8);\n } catch (e) {\n console.warn('IIP: could not decode QOI image', e);\n this._dec.release();\n this._qoiDec.release();\n return true;\n }\n blob = new ImageData(\n new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),\n this._qoiDec.width,\n this._qoiDec.height\n );\n this._qoiDec.release();\n if (w === this._qoiDec.width && h === this._qoiDec.height) {\n // use fast-path if we don't need to rescale\n this._dec.release();\n const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height);\n canvas.getContext('2d')?.putImageData(blob, 0, 0);\n this._storage.addImage(canvas);\n return true;\n }\n } else {\n blob = new Blob([this._dec.data8], { type: metrics.mime });\n }\n this._dec.release();\n const generation = this._generation;\n return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })\n .then(bm => {\n if (generation !== this._generation) {\n bm.close();\n return true;\n }\n this._storage.addImage(bm);\n return true;\n })\n .catch(e => {\n console.warn(`IIP: decoding error ${metrics.mime} ${metrics.width}x${metrics.height}`, e);\n return true;\n });\n }\n\n // Why: wasm memory exhaustion must drop this image, not throw out of the parser and wedge the write queue.\n private _initDecoder(): boolean {\n try {\n this._dec.init();\n return true;\n } catch (e) {\n console.warn('IIP: could not allocate decoder', e);\n this._dec.release();\n return false;\n }\n }\n\n private _resize(w: number, h: number): [number, number] {\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n const width = this._renderer.dimensions?.css.canvas.width || cw * this._coreTerminal.cols;\n const height = this._renderer.dimensions?.css.canvas.height || ch * this._coreTerminal.rows;\n\n const rw = this._dim(this._header.width!, width, cw);\n const rh = this._dim(this._header.height!, height, ch);\n if (!rw && !rh) {\n const wf = width / w; // TODO: should this respect initial cursor offset?\n const hf = (height - ch) / h; // TODO: fix offset issues from float cell height\n const f = Math.min(wf, hf);\n return f < 1 ? [w * f, h * f] : [w, h];\n }\n return !rw\n ? [w * rh / h, rh]\n : this._header.preserveAspectRatio || !rw || !rh\n ? [rw, h * rw / w] : [rw, rh];\n }\n\n private _dim(s: string, total: number, cdim: number): number {\n if (s === 'auto') return 0;\n if (s.endsWith('%')) return parseInt(s.slice(0, -1), 10) * total / 100;\n if (s.endsWith('px')) return parseInt(s.slice(0, -2), 10);\n return parseInt(s, 10) * cdim;\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n// eslint-disable-next-line\ndeclare const Buffer: any;\n\nexport const enum HeaderState {\n START = 0,\n ABORT = 1,\n KEY = 2,\n VALUE = 3,\n END = 4\n}\n\nexport const enum SequenceType {\n INVALID = 0,\n FILE = 1,\n MULTIPARTFILE = 2,\n FILEPART = 3,\n FILEEND = 4,\n REPORTCELLSIZE = 5\n}\n\nexport interface IHeaderFields {\n [key: string]: number | string | Uint32Array | null | undefined;\n // sequence type\n type: SequenceType;\n // base-64 encoded filename. Defaults to \"Unnamed file\".\n name: string;\n // File size in bytes. The file transfer will be canceled if this size is exceeded.\n size: number;\n /**\n * Optional width and height to render:\n * - N: N character cells.\n * - Npx: N pixels.\n * - N%: N percent of the session's width or height.\n * - auto: The image's inherent size will be used to determine an appropriate dimension.\n */\n width?: string;\n height?: string;\n // Optional, defaults to 1 respecting aspect ratio (width takes precedence).\n preserveAspectRatio?: number;\n // Optional, defaults to 0. If set to 1, the file will be displayed inline, else downloaded\n // (download not supported).\n inline?: number;\n}\n\n// field value decoders\n\n// ASCII bytes to string\nfunction toStr(data: Uint32Array): string {\n let s = '';\n for (let i = 0; i < data.length; ++i) {\n s += String.fromCharCode(data[i]);\n }\n return s;\n}\n\n// digits to integer\nfunction toInt(data: Uint32Array): number {\n let v = 0;\n for (let i = 0; i < data.length; ++i) {\n if (data[i] < 48 || data[i] > 57) {\n throw new Error('illegal char');\n }\n v = v * 10 + data[i] - 48;\n }\n return v;\n}\n\n// check for correct size entry\nfunction toSize(data: Uint32Array): string {\n const v = toStr(data);\n if (!v.match(/^((auto)|(\\d+?((px)|(%)){0,1}))$/)) {\n throw new Error('illegal size');\n }\n return v;\n}\n\n// name is base64 encoded utf-8\nfunction toName(data: Uint32Array): string {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(toStr(data), 'base64').toString();\n }\n const bs = atob(toStr(data));\n const b = new Uint8Array(bs.length);\n for (let i = 0; i < b.length; ++i) {\n b[i] = bs.charCodeAt(i);\n }\n return new TextDecoder().decode(b);\n}\n\nconst DECODERS: {[key: string]: (v: Uint32Array) => number | string} = {\n inline: toInt,\n size: toInt,\n name: toName,\n width: toSize,\n height: toSize,\n preserveAspectRatio: toInt\n};\n\n\n// sequence type markers\n// File\nconst FILE_MARKER = [70, 105, 108, 101];\n// MultipartFile\nconst MULTIPARTFILE_MARKER = [77, 117, 108, 116, 105, 112, 97, 114, 116, 70, 105, 108, 101];\n// FilePart\nconst FILEPART_MARKER = [70, 105, 108, 101, 80, 97, 114, 116];\n// FileEnd\nconst FILEEND_MARKER = [70, 105, 108, 101, 69, 110, 100];\n// ReportCellSize\nconst REPORTCELLSIZE_MARKER = [82, 101, 112, 111, 114, 116, 67, 101, 108, 108, 83, 105, 122, 101];\n\n// max allowed chars for sequence header\nconst MAX_FIELDCHARS = 1024;\n\n\nexport class HeaderParser {\n public state: HeaderState = HeaderState.START;\n private _buffer = new Uint32Array(MAX_FIELDCHARS);\n private _position = 0;\n private _key = '';\n public fields: {[key: string]: number | string | Uint32Array | null | undefined} = {};\n\n public reset(): void {\n this._buffer.fill(0);\n this.state = HeaderState.START;\n this._position = 0;\n this.fields = {};\n this._key = '';\n }\n\n public end(): number {\n if (this.state === HeaderState.START) {\n if (this._position === FILEEND_MARKER.length) {\n for (let k = 0; k < FILEEND_MARKER.length; ++k) {\n if (this._buffer[k] !== FILEEND_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEEND;\n this.state = HeaderState.END;\n return 0;\n }\n if (this._position === REPORTCELLSIZE_MARKER.length) {\n for (let k = 0; k < REPORTCELLSIZE_MARKER.length; ++k) {\n if (this._buffer[k] !== REPORTCELLSIZE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.REPORTCELLSIZE;\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n if (this.state === HeaderState.END) return 0;\n if (this.state === HeaderState.VALUE\n && this.fields.type === SequenceType.MULTIPARTFILE\n ) {\n if (!this._storeValue(this._position)) return this._a();\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n\n public parse(data: Uint32Array, start: number, end: number): number {\n let state = this.state;\n let pos = this._position;\n const buffer = this._buffer;\n if (state === HeaderState.ABORT || state === HeaderState.END) return -1;\n if (state === HeaderState.START && pos > 14) return -1;\n for (let i = start; i < end; ++i) {\n const c = data[i];\n switch (c) {\n case 59: // ;\n if (!this._storeValue(pos)) return this._a();\n state = HeaderState.KEY;\n pos = 0;\n break;\n case 61: // =\n if (state === HeaderState.START) {\n if (buffer[0] === 70) {\n // 'File' or 'FilePart'\n let k = 0;\n for (; k < FILE_MARKER.length; ++k) {\n if (buffer[k] !== FILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILE;\n if (pos === FILEPART_MARKER.length) {\n for (; k < FILEPART_MARKER.length; ++k) {\n if (buffer[k] !== FILEPART_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEPART;\n this.state = HeaderState.END;\n return i + 1;\n }\n } else if (buffer[0] === 77) {\n // 'MultipartFile'\n for (let k = 0; k < MULTIPARTFILE_MARKER.length; ++k) {\n if (buffer[k] !== MULTIPARTFILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.MULTIPARTFILE;\n } else {\n return this._a();\n }\n state = HeaderState.KEY;\n pos = 0;\n } else if (state === HeaderState.KEY) {\n if (!this._storeKey(pos)) return this._a();\n state = HeaderState.VALUE;\n pos = 0;\n } else if (state === HeaderState.VALUE) {\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n break;\n case 58: // :\n if (state === HeaderState.VALUE) {\n if (!this._storeValue(pos)) return this._a();\n }\n this.state = HeaderState.END;\n return i + 1;\n default:\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n }\n this.state = state;\n this._position = pos;\n return -2;\n }\n\n private _a(): number {\n this.fields.type = SequenceType.INVALID;\n this.state = HeaderState.ABORT;\n return -1;\n }\n\n private _storeKey(pos: number): boolean {\n const k = toStr(this._buffer.subarray(0, pos));\n if (k) {\n this._key = k;\n this.fields[k] = null;\n return true;\n }\n return false;\n }\n\n private _storeValue(pos: number): boolean {\n if (this._key) {\n try {\n const v = this._buffer.slice(0, pos);\n this.fields[this._key] = DECODERS[this._key] ? DECODERS[this._key](v) : v;\n } catch {\n return false;\n }\n return true;\n }\n return false;\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n\nexport type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'image/webp' | 'image/avif' | 'unsupported' | '';\n\nexport interface IMetrics {\n mime: ImageType;\n width: number;\n height: number;\n}\n\nexport const UNSUPPORTED_TYPE: IMetrics = {\n mime: 'unsupported',\n width: 0,\n height: 0\n};\n\nexport function imageType(d: Uint8Array): IMetrics {\n if (d.length < 32) {\n return UNSUPPORTED_TYPE;\n }\n const d32 = new Uint32Array(d.buffer, d.byteOffset, 8);\n // PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)\n // + first chunk must be IHDR\n if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {\n return {\n mime: 'image/png',\n width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],\n height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]\n };\n }\n // JPEG: FF D8 FF\n if (d[0] === 0xFF && d[1] === 0xD8 && d[2] === 0xFF) {\n const [width, height] = jpgSize(d);\n return { mime: 'image/jpeg', width, height };\n }\n // GIF: GIF87a or GIF89a\n if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {\n return {\n mime: 'image/gif',\n width: d[7] << 8 | d[6],\n height: d[9] << 8 | d[8]\n };\n }\n // QOI: qoif\n if (d32[0] === 0x66696F71) {\n return {\n mime: 'image/qoi',\n width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7],\n height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11]\n };\n }\n // WEBP: RIFF | xxxx | WEBP | VP8x\n if (d32[0] === 0x46464952 && d32[2] === 0x50424557 && (d32[3] & 0xFFFFFF) === 0x385056) {\n switch (d[15]) {\n case 0x58: // Extended WebP VP8X --> \"X\"\n return {\n mime: 'image/webp',\n width: (d[24] | d[25] << 8 | d[26] << 16) + 1,\n height: (d[27] | d[28] << 8 | d[29] << 16) + 1\n };\n case 0x4C: // Lossless WebP VP8L --> \"L\"\n if (d[20] !== 0x2f) return UNSUPPORTED_TYPE;\n const dim = d[21] | d[22] << 8 | d[23] << 16 | d[24] << 24;\n return {\n mime: 'image/webp',\n width: (dim & 0x3FFF) + 1,\n height: (dim >>> 14 & 0x3FFF) + 1\n };\n case 0x20: // Lossy WebP VP8 --> \" \"\n if (d[23] !== 0x9d || d[24] !== 0x01 || d[25] !== 0x2a) return UNSUPPORTED_TYPE;\n return {\n mime: 'image/webp',\n width: (d[26] | d[27] << 8) & 0x3FFF,\n height: (d[28] | d[29] << 8) & 0x3FFF\n };\n }\n return UNSUPPORTED_TYPE;\n }\n // AVIF: Box size | ftyp | avif/avis\n if (d32[1] === 0x70797466 && (d32[2] === 0x66697661 || d32[2] === 0x73697661)) {\n let pos = -1;\n // search for ispe box within first 1024 bytes\n const limit = Math.min(d.length - 16, 1024);\n for (let i = 8; i < limit; i++) {\n // scan for ispe\n if (d[i] === 0x69 && d[i + 1] === 0x73 && d[i + 2] === 0x70 && d[i + 3] === 0x65) {\n pos = i;\n break;\n }\n }\n if (pos !== -1) {\n // dimensions are in BE at +8 (width) at +12 (height)\n const width =\n d[pos + 8] << 24 |\n d[pos + 9] << 16 |\n d[pos + 10] << 8 |\n d[pos + 11];\n const height =\n d[pos + 12] << 24 |\n d[pos + 13] << 16 |\n d[pos + 14] << 8 |\n d[pos + 15];\n if (width > 0 && height > 0) {\n return { mime: 'image/avif', width, height };\n }\n }\n return UNSUPPORTED_TYPE;\n }\n return UNSUPPORTED_TYPE;\n}\n\n\nfunction jpgSize(d: Uint8Array): [number, number] {\n const len = d.length;\n let i = 4;\n let blockLength = d[i] << 8 | d[i + 1];\n while (true) {\n i += blockLength;\n if (i >= len) {\n // exhausted without size info\n return [0, 0];\n }\n if (d[i] !== 0xFF) {\n return [0, 0];\n }\n if (d[i + 1] === 0xC0 || d[i + 1] === 0xC2) {\n if (i + 8 < len) {\n return [\n d[i + 7] << 8 | d[i + 8],\n d[i + 5] << 8 | d[i + 6]\n ];\n }\n return [0, 0];\n }\n i += 2;\n blockLength = d[i] << 8 | d[i + 1];\n }\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';\nimport { ImageRenderer } from '../ImageRenderer';\nimport { CELL_SIZE_DEFAULT } from '../ImageStorage';\nimport { imageType } from '../IIPMetrics';\nimport { KittyImageStorage } from './KittyImageStorage';\nimport Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport {\n KittyAction,\n KittyFormat,\n KittyCompression,\n IKittyCommand,\n IPendingTransmission,\n IKittyImageData,\n KittyPixelConstants,\n parseKittyCommand\n} from './KittyGraphicsTypes';\n\nconst enum Constants {\n // Memory limit for base64 decoder (4MB, same as IIPHandler)\n DECODER_KEEP_DATA = 4194304,\n DECODER_INITIAL_DATA = 4194304, // 4MB\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n DECODER_OK = 0,\n // Maximum control data size\n MAX_CONTROL_DATA_SIZE = 512,\n // Semicolon codepoint\n SEMICOLON = 0x3B\n}\n\nconst DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK;\n\n// Kitty graphics protocol handler with streaming base64 decoding.\nexport class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {\n private _aborted = false;\n private _generation = 0;\n private _decodeError = false;\n\n private _activeDecoder: Base64Decoder | null = null;\n private readonly _maxEncodedBytes: number;\n private readonly _initialEncodedBytes: number;\n\n // Streaming related states\n\n // True while receiving control data (before semicolon).\n private _inControlData = true;\n\n // Buffer for control data.\n private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);\n private _controlLength = 0;\n\n // Pre-calculated encoded size limit\n private _encodedSizeLimit = 0;\n private _totalEncodedSize = 0;\n\n // Parsed command. These are the control data before semicolon.\n private _parsedCommand: IKittyCommand | null = null;\n\n // Storage related states\n\n private _pendingTransmissions: Map = new Map();\n // Tracks the pending key of the most recently started chunked upload.\n // Per spec, subsequent chunks only need m= (and optionally q=), without i=.\n // When a chunk arrives with no i=, this key is used to find the pending upload.\n private _lastPendingKey: number | undefined;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _kittyStorage: KittyImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n // Convert decoded size limit -> max encoded bytes.\n this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);\n // ensure we preallocate more than configured limit while using 4mb initial size.\n this._initialEncodedBytes = Math.min(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);\n }\n\n public reset(): void {\n this._generation++;\n this._cleanupAllPending();\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n this._kittyStorage.reset();\n }\n\n public dispose(): void {\n this.reset();\n }\n\n private _removePendingEntry(key: number): void {\n this._pendingTransmissions.delete(key);\n if (this._lastPendingKey === key) {\n this._lastPendingKey = undefined;\n }\n }\n\n private _cleanupAllPending(): void {\n for (const pending of this._pendingTransmissions.values()) {\n pending.decoder.release();\n }\n this._pendingTransmissions.clear();\n this._lastPendingKey = undefined;\n }\n\n public start(): void {\n this._aborted = false;\n this._decodeError = false;\n this._inControlData = true;\n this._controlLength = 0;\n this._parsedCommand = null;\n // Pre-calculate encoded limit once: base64 is 4 bytes encoded \u2192 3 bytes decoded\n this._encodedSizeLimit = this._maxEncodedBytes;\n this._totalEncodedSize = 0;\n this._activeDecoder = null;\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (!this._inControlData) {\n this._streamPayload(data, start, end);\n } else {\n // Scan for semicolon\n let controlEnd = end;\n for (let i = start; i < end; i++) {\n if (data[i] === Constants.SEMICOLON) {\n this._inControlData = false;\n controlEnd = i;\n break;\n }\n }\n\n // Copy control data\n const copyLength = controlEnd - start;\n if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {\n this._aborted = true;\n return;\n }\n this._controlData.set(data.subarray(start, controlEnd), this._controlLength);\n this._controlLength += copyLength;\n\n if (!this._inControlData) {\n // Found semicolon - parse control data early for validation\n this._parsedCommand = parseKittyCommand(this._parseControlDataString());\n\n // Early validation: i+I conflict\n if (this._parsedCommand.id !== undefined && this._parsedCommand.imageNumber !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'EINVAL:cannot specify both i and I keys', this._parsedCommand.quiet ?? 0);\n this._aborted = true;\n return;\n }\n\n // Delete action doesn't need payload - skip streaming\n if (this._parsedCommand.action === KittyAction.DELETE) {\n return;\n }\n\n // Stream remaining as payload\n const payloadStart = controlEnd + 1;\n if (payloadStart < end) {\n this._streamPayload(data, payloadStart, end);\n }\n }\n }\n }\n\n // Stream payload bytes into the base64 decoder.\n private _streamPayload(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n // Check size limit (compare encoded bytes against pre-calculated limit)\n // Include cumulative size from pending transmission for multi-chunk images.\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = this._parsedCommand?.id ?? this._lastPendingKey ?? 0;\n const pending = this._pendingTransmissions.get(pendingKey);\n const previousEncodedSize = pending?.totalEncodedSize ?? 0;\n this._totalEncodedSize += end - start;\n const cumulativeEncodedSize = previousEncodedSize + this._totalEncodedSize;\n if (cumulativeEncodedSize > this._encodedSizeLimit) {\n const decoderToRelease = this._activeDecoder ?? pending?.decoder;\n if (decoderToRelease) {\n decoderToRelease.release();\n }\n this._activeDecoder = null;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n this._aborted = true;\n return;\n }\n\n if (this._decodeError) return;\n\n if (pending?.decoder && !this._activeDecoder) {\n this._activeDecoder = pending.decoder;\n }\n if (!this._activeDecoder) {\n // Budget WASM capacity, including one page of decoder state and rounding.\n const decoderCapacity = this._maxEncodedBytes + 131072;\n if (decoderCapacity > this._opts.storageLimit * 1000000) {\n this._aborted = true;\n if (this._parsedCommand?.id !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'ENOMEM:pending image budget exceeded', this._parsedCommand.quiet ?? 0);\n }\n return;\n }\n const maxPending = Math.max(1, Math.floor(this._opts.storageLimit * 1000000 / decoderCapacity));\n while (this._pendingTransmissions.size >= maxPending) {\n const oldest = this._pendingTransmissions.entries().next().value;\n if (!oldest) break;\n oldest[1].decoder.release();\n this._removePendingEntry(oldest[0]);\n if (oldest[1].cmd.id !== undefined) {\n this._sendResponse(oldest[1].cmd.id, 'ENOMEM:pending image budget exceeded', oldest[1].cmd.quiet ?? 0);\n }\n }\n const decoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);\n try {\n decoder.init();\n } catch (e) {\n // Why: wasm memory exhaustion must drop this image, not throw out of the parser and wedge the write queue.\n console.warn('KITTY: could not allocate decoder', e);\n this._aborted = true;\n if (this._parsedCommand?.id !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'ENOMEM:could not allocate decoder', this._parsedCommand.quiet ?? 0);\n }\n return;\n }\n this._activeDecoder = decoder;\n }\n\n if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n this._decodeError = true;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted || !success) {\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n return true;\n }\n\n // No semicolon = no payload (delete, capability query)\n if (this._inControlData) {\n return this._handleNoPayloadCommand();\n }\n\n // Use command parsed early in put() - i+I already validated there\n const cmd = this._parsedCommand!;\n\n // Delete action was handled by skipping payload - just execute\n if (cmd.action === KittyAction.DELETE) {\n return this._handleDelete(cmd);\n }\n\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = cmd.id ?? this._lastPendingKey ?? 0;\n const isMoreComing = cmd.more === 1;\n const pending = this._pendingTransmissions.get(pendingKey);\n\n if (isMoreComing) {\n if (this._activeDecoder) {\n if (pending) {\n pending.totalEncodedSize += this._totalEncodedSize;\n pending.decodeError = pending.decodeError || this._decodeError;\n } else {\n this._pendingTransmissions.set(pendingKey, {\n cmd: { ...cmd },\n decoder: this._activeDecoder,\n totalEncodedSize: this._totalEncodedSize,\n decodeError: this._decodeError\n });\n }\n this._lastPendingKey = pendingKey;\n this._activeDecoder = null;\n }\n return true;\n }\n\n // Final chunk received \u2014 clear the last pending key\n if (pending) {\n this._lastPendingKey = undefined;\n }\n\n let decodeError = this._decodeError;\n let finalCmd = cmd;\n let decoder = this._activeDecoder;\n\n if (pending) {\n finalCmd = pending.cmd;\n decoder = pending.decoder;\n decodeError = decodeError || pending.decodeError;\n this._pendingTransmissions.delete(pendingKey);\n }\n\n let imageBytes = new Uint8Array(0);\n if (decoder) {\n if (decoder.end() !== DECODER_OK) {\n decodeError = true;\n }\n imageBytes = decoder.data8;\n }\n this._activeDecoder = null;\n\n // Handle command first \u2014 handlers create Blob/ImageData from imageBytes,\n // which copies the data. Only then is it safe to release the decoder's\n // wasm memory that imageBytes points into.\n const result = this._handleCommandWithBytesAndCmd(finalCmd, imageBytes, decodeError);\n if (decoder) {\n decoder.release();\n }\n return result;\n }\n\n // Command handling\n\n private _parseControlDataString(): string {\n let str = '';\n for (let i = 0; i < this._controlLength; i++) {\n str += String.fromCodePoint(this._controlData[i]);\n }\n return str;\n }\n\n private _handleNoPayloadCommand(): boolean | Promise {\n const cmd = parseKittyCommand(this._parseControlDataString());\n\n // Per spec: specifying both i and I is an error\n if (cmd.id !== undefined && cmd.imageNumber !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:cannot specify both i and I keys', cmd.quiet ?? 0);\n return true;\n }\n\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.DELETE:\n return this._handleDelete(cmd);\n case KittyAction.QUERY:\n this._sendResponse(cmd.id ?? 0, 'OK', cmd.quiet ?? 0);\n return true;\n case KittyAction.PLACEMENT:\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handleCommandWithBytesAndCmd(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.TRANSMIT: {\n const result = this._handleTransmit(cmd, bytes, decodeError);\n // Only send response when _handleTransmit didn't already respond\n // (it handles unsupported transmission medium responses internally)\n if ((cmd.transmission ?? 'd') === 'd' && cmd.id !== undefined) {\n if (decodeError) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n } else if (bytes.length > 0) {\n this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0);\n }\n }\n return result;\n }\n case KittyAction.TRANSMIT_DISPLAY:\n return this._handleTransmitDisplay(cmd, bytes, decodeError);\n case KittyAction.QUERY:\n return this._handleQuery(cmd, bytes, decodeError);\n case KittyAction.PLACEMENT:\n // a=p ignores any payload \u2014 image data was already transmitted\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handlePlacement(cmd: IKittyCommand): boolean | Promise {\n if (cmd.id === undefined) {\n return true;\n }\n const id = cmd.id;\n const image = this._kittyStorage.getImage(id);\n if (!image) {\n this._sendResponse(id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);\n return true;\n }\n const result = this._displayImage(image, cmd);\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);\n return true;\n });\n }\n\n private _handleTransmit(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n // TODO: Support file-based transmission modes (t=f, t=t, t=s)\n // Currently only supports direct transmission (t=d, the default).\n // - t=f (file): Payload is base64-encoded file path. Terminal reads image from that path.\n // - t=t (temp file): Payload is base64-encoded path in temp directory. Terminal reads, deletes.\n // - t=s: Payload is base64-encoded POSIX shm name. Terminal reads from shared memory.\n // These modes require filesystem/IPC access not available in browsers. For Node.js/Electron:\n // 1. Check cmd.transmission (t key) before treating bytes as image data\n // 2. For t=f/t/s: decode bytes as UTF-8 string (the path/name), then read file contents\n // 3. For t=d: treat bytes as image data (current behavior)\n // When implementing, also update _handleQuery to accept these transmission mediums.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported transmission medium', cmd.quiet ?? 0);\n }\n return true;\n }\n\n if (decodeError || bytes.length === 0) return true;\n\n this._kittyStorage.storeImage(cmd.id, {\n data: new Blob([bytes as BlobPart]),\n width: cmd.width ?? 0,\n height: cmd.height ?? 0,\n format: (cmd.format ?? KittyFormat.RGBA) as 24 | 32 | 100,\n compression: cmd.compression ?? ''\n });\n return true;\n }\n\n private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n if (decodeError) {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n }\n return true;\n }\n\n this._handleTransmit(cmd, bytes, decodeError);\n\n const id = cmd.id ?? this._kittyStorage.lastImageId;\n const image = this._kittyStorage.getImage(id);\n if (image) {\n const result = this._displayImage(image, cmd);\n if (cmd.id !== undefined) {\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0);\n return true;\n });\n }\n return result.then(() => true);\n }\n return true;\n }\n\n private _handleQuery(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n const id = cmd.id ?? 0;\n const quiet = cmd.quiet ?? 0;\n\n // Per spec: reject unsupported transmission mediums (only t=d is supported atm)\n // TODO: When filesystem support is added (Node.js/Electron), update this to accept\n // t=f (file), t=t (temp file), and t=s (shared memory) and respond OK for queries.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n this._sendResponse(id, 'EINVAL:unsupported transmission medium', quiet);\n return true;\n }\n\n // Check decode error first (invalid base64)\n if (decodeError) {\n this._sendResponse(id, 'EINVAL:invalid base64 data', quiet);\n return true;\n }\n\n // Capability query (no payload) - just respond OK\n if (bytes.length === 0) {\n this._sendResponse(id, 'OK', quiet);\n return true;\n }\n\n const format = cmd.format ?? KittyFormat.RGBA;\n\n if (format === KittyFormat.PNG) {\n this._sendResponse(id, 'OK', quiet);\n } else {\n const width = cmd.width ?? 0;\n const height = cmd.height ?? 0;\n\n if (!width || !height) {\n this._sendResponse(id, 'EINVAL:width and height required for raw pixel data', quiet);\n return true;\n }\n\n const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n this._sendResponse(id, `EINVAL:insufficient pixel data`, quiet);\n return true;\n }\n\n this._sendResponse(id, 'OK', quiet);\n }\n return true;\n }\n\n private _handleDelete(cmd: IKittyCommand): boolean {\n // Per spec: default delete selector is 'a' (delete all visible placements)\n const selector = cmd.deleteSelector ?? 'a';\n\n // TODO: Distinguish lowercase (delete placements only) from uppercase\n // (delete placements + free stored image data). Currently both variants\n // free everything since we don't separate stored data from placements.\n switch (selector) {\n case 'a':\n case 'A':\n this._cleanupAllPending();\n this._kittyStorage.deleteAll();\n break;\n case 'i':\n case 'I':\n // TODO: When placement id tracking is implemented (see TODO in\n // KittyImageStorage), d=i with p= should delete only that\n // specific placement, while d=i without p should delete all\n // placements for the image.\n if (cmd.id !== undefined) {\n const pending = this._pendingTransmissions.get(cmd.id);\n if (pending) {\n pending.decoder.release();\n }\n this._removePendingEntry(cmd.id);\n this._kittyStorage.deleteById(cmd.id);\n }\n break;\n default:\n // Unsupported selectors (c, n, p, q, r, x, y, z, f) \u2014 ignore for now\n break;\n }\n return true;\n }\n\n private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void {\n const isOk = message === 'OK';\n if (isOk && quiet >= 1) return;\n if (!isOk && quiet >= 2) return;\n\n const pPart = placementId ? `,p=${placementId}` : '';\n const response = `\\x1b_Gi=${id}${pPart};${message}\\x1b\\\\`;\n this._coreTerminal._core.coreService.triggerDataEvent(response);\n }\n\n // Image display\n\n private _displayImage(image: IKittyImageData, cmd: IKittyCommand): Promise {\n return this._decodeAndDisplay(image, cmd)\n .then(() => true)\n .catch(() => false);\n }\n\n private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise {\n const generation = this._generation;\n let bitmap: ImageBitmap | undefined = await this._createBitmap(image);\n\n try {\n if (generation !== this._generation) throw new Error('image decode canceled');\n const cropX = Math.max(0, cmd.x ?? 0);\n const cropY = Math.max(0, cmd.y ?? 0);\n const cropW = cmd.sourceWidth || (bitmap.width - cropX);\n const cropH = cmd.sourceHeight || (bitmap.height - cropY);\n\n const maxCropW = Math.max(0, bitmap.width - cropX);\n const maxCropH = Math.max(0, bitmap.height - cropY);\n const finalCropW = Math.max(0, Math.min(cropW, maxCropW));\n const finalCropH = Math.max(0, Math.min(cropH, maxCropH));\n\n if (finalCropW === 0 || finalCropH === 0) {\n throw new Error('invalid source rectangle');\n }\n\n if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {\n const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);\n bitmap.close();\n bitmap = cropped;\n }\n\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n\n // Per spec: c/r default to image's natural cell dimensions.\n // If only one of c/r is specified, compute the other from image aspect ratio.\n let imgCols: number;\n let imgRows: number;\n if (cmd.columns !== undefined && cmd.rows !== undefined) {\n imgCols = cmd.columns;\n imgRows = cmd.rows;\n } else if (cmd.columns !== undefined) {\n imgCols = cmd.columns;\n imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));\n } else if (cmd.rows !== undefined) {\n imgRows = cmd.rows;\n imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));\n } else {\n imgCols = Math.ceil(bitmap.width / cw);\n imgRows = Math.ceil(bitmap.height / ch);\n }\n\n let w = bitmap.width;\n let h = bitmap.height;\n\n // Scale bitmap to fit placement rectangle when c/r are specified\n if (cmd.columns !== undefined || cmd.rows !== undefined) {\n w = Math.round(imgCols * cw);\n h = Math.round(imgRows * ch);\n }\n\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n\n // Save cursor position before addImage modifies it\n const buffer = this._coreTerminal._core.buffer;\n const savedX = buffer.x;\n const savedY = buffer.y;\n const savedYbase = buffer.ybase;\n\n // Determine layer based on z-index: negative = behind text, 0+ = on top.\n // When z<0 we always use the bottom layer even without allowTransparency \u2014\n // the image will simply be hidden behind the opaque text background, which\n // is the correct behavior (client asked for \"behind text\").\n const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;\n const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';\n\n if (w !== bitmap.width || h !== bitmap.height) {\n const scaled = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });\n bitmap.close();\n bitmap = scaled;\n }\n\n // Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions\n const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1);\n const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1);\n if (xOffset !== 0 || yOffset !== 0) {\n // Per spec: X/Y is not added to c/r area. When c/r are explicit, the\n // total placement area remains c*cw \u00D7 r*ch pixels and the offset image\n // is clipped to fit. When c/r are unset, the padded canvas determines\n // the natural cell dimensions.\n const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset;\n const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset;\n const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);\n const offsetCtx = offsetCanvas.getContext('2d');\n if (!offsetCtx) {\n throw new Error('Failed to create offset canvas context');\n }\n offsetCtx.drawImage(bitmap, xOffset, yOffset);\n\n const offsetBitmap = await createImageBitmap(offsetCanvas);\n offsetCanvas.width = offsetCanvas.height = 0;\n bitmap.close();\n bitmap = offsetBitmap;\n w = bitmap.width;\n h = bitmap.height;\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n if (cmd.columns === undefined) {\n imgCols = Math.ceil(bitmap.width / cw);\n }\n if (cmd.rows === undefined) {\n imgRows = Math.ceil(bitmap.height / ch);\n }\n }\n\n if (generation !== this._generation) throw new Error('image decode canceled');\n const zIndex = cmd.zIndex ?? 0;\n this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);\n bitmap = undefined; // ownership transferred to storage\n\n // Kitty cursor movement\n // Per spec: cursor placed at first column after last image column,\n // on the last row of the image. C=1 means don't move cursor.\n if (cmd.cursorMovement === 1) {\n // C=1: restore cursor to position before image was placed\n const scrolled = buffer.ybase - savedYbase;\n buffer.x = savedX;\n // Can't restore cursor to scrollback?\n buffer.y = Math.max(savedY - scrolled, 0);\n } else {\n // Default (C=0): advance cursor horizontally past the image\n // addImage already positioned cursor on the last row via lineFeeds\n buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);\n }\n } catch (e) {\n bitmap?.close();\n throw e;\n }\n }\n\n // Create ImageBitmap from already-decoded image data.\n private async _createBitmap(image: IKittyImageData): Promise {\n let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());\n\n if (image.compression === KittyCompression.ZLIB) {\n bytes = await this._decompressZlib(bytes);\n }\n\n if (image.format === KittyFormat.PNG) {\n const metrics = imageType(bytes);\n // IHDR dimensions are parsed with signed shifts, so a value >= 0x80000000 comes\n // back negative and a bare `>` pixel-limit test passes it; require positive.\n if (metrics.mime !== 'image/png' || !(metrics.width > 0) || !(metrics.height > 0) || metrics.width * metrics.height > this._opts.pixelLimit) {\n throw new RangeError('PNG exceeds pixel limit or has invalid dimensions');\n }\n const blob = new Blob([bytes as BlobPart], { type: 'image/png' });\n if (!window.createImageBitmap) {\n const url = URL.createObjectURL(blob);\n const img = new Image();\n return new Promise((resolve, reject) => {\n img.addEventListener('load', () => {\n URL.revokeObjectURL(url);\n const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);\n canvas.getContext('2d')?.drawImage(img, 0, 0);\n createImageBitmap(canvas).then(resolve).catch(reject);\n });\n img.addEventListener('error', () => {\n URL.revokeObjectURL(url);\n reject(new Error('Failed to load image'));\n });\n img.src = url;\n });\n }\n return createImageBitmap(blob);\n }\n\n // Raw pixel data\n const width = image.width;\n const height = image.height;\n\n if (!width || !height) {\n throw new Error('Width and height required for raw pixel data');\n }\n\n const bytesPerPixel = image.format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n throw new Error('Insufficient pixel data');\n }\n\n const pixelCount = width * height;\n\n if (image.format === KittyFormat.RGBA) {\n // RGBA: use bytes directly \u2014 no copy needed\n return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA), width, height));\n }\n\n // RGB\u2192RGBA: interleave alpha using uint32 block processing (4 pixels per iteration).\n // 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes \u2014 ~6x faster.\n // Assumes little-endian (all modern browsers/Node.js).\n const data = new Uint8ClampedArray(pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA);\n const src32 = new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));\n const dst32 = new Uint32Array(data.buffer);\n const alignedPixels = pixelCount & ~3; // round down to multiple of 4\n\n let srcOffset = 0;\n let dstOffset = 0;\n for (let i = 0; i < alignedPixels; i += 4) {\n const b0 = src32[srcOffset++];\n const b1 = src32[srcOffset++];\n const b2 = src32[srcOffset++];\n // Little-endian: pixel bytes are [R,G,B] \u2192 uint32 ABGR layout\n dst32[dstOffset++] = 0xFF000000 | b0;\n dst32[dstOffset++] = 0xFF000000 | (b0 >>> 24) | (b1 << 8);\n dst32[dstOffset++] = 0xFF000000 | (b1 >>> 16) | (b2 << 16);\n dst32[dstOffset++] = 0xFF000000 | (b2 >>> 8);\n }\n\n // Handle remaining 1\u20133 pixels\n let srcByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n let dstByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n for (let i = alignedPixels; i < pixelCount; i++) {\n data[dstByte] = bytes[srcByte];\n data[dstByte + 1] = bytes[srcByte + 1];\n data[dstByte + 2] = bytes[srcByte + 2];\n data[dstByte + 3] = KittyPixelConstants.ALPHA_OPAQUE;\n srcByte += KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n dstByte += KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n }\n\n return createImageBitmap(new ImageData(data, width, height));\n }\n\n private async _decompressZlib(compressed: Uint8Array): Promise {\n try {\n return await this._decompress(compressed, 'deflate');\n } catch (error) {\n if (error instanceof RangeError) throw error;\n return await this._decompress(compressed, 'deflate-raw');\n }\n }\n\n private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise {\n const limit = Math.min(this._opts.kittySizeLimit, this._opts.pixelLimit * 4, this._opts.storageLimit * 1000000);\n let offsetIn = 0;\n // Bound inflation within one native transform before its output is budgeted.\n const source = new ReadableStream({\n pull(controller) {\n if (offsetIn >= compressed.length) {\n controller.close();\n return;\n }\n const end = Math.min(offsetIn + 4096, compressed.length);\n controller.enqueue(new Uint8Array(compressed.subarray(offsetIn, end)));\n offsetIn = end;\n }\n });\n const reader = source.pipeThrough(new DecompressionStream(format)).getReader();\n const chunks: Uint8Array[] = [];\n let totalLength = 0;\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n totalLength += value.byteLength;\n if (totalLength > limit) {\n await reader.cancel().catch(() => {});\n throw new RangeError('decompressed image exceeds byte limit');\n }\n chunks.push(value);\n }\n } finally {\n reader.releaseLock();\n }\n\n const result = new Uint8Array(totalLength);\n let offset = 0;\n for (const chunk of chunks) {\n result.set(chunk, offset);\n offset += chunk.length;\n }\n return result;\n }\n\n public get images(): ReadonlyMap {\n return this._kittyStorage.images;\n }\n\n public get _kittyIdToStorageId(): ReadonlyMap {\n return this._kittyStorage.kittyIdToStorageId;\n }\n\n public get pendingTransmissions(): ReadonlyMap {\n return this._pendingTransmissions;\n }\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Kitty graphics protocol types, constants, and parsing utilities.\n */\n\nimport type Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\n\n// Kitty graphics protocol action types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'a'.\nexport const enum KittyAction {\n TRANSMIT = 't',\n TRANSMIT_DISPLAY = 'T',\n QUERY = 'q',\n PLACEMENT = 'p',\n DELETE = 'd'\n}\n\n// Kitty graphics protocol format types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyFormat {\n RGB = 24,\n RGBA = 32,\n PNG = 100\n}\n\n// Kitty graphics protocol compression types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'o'.\nexport const enum KittyCompression {\n NONE = '',\n ZLIB = 'z'\n}\n\n// Kitty graphics protocol control data keys.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyKey {\n // Action to perform (t=transmit, T=transmit+display, q=query, p=placement, d=delete)\n ACTION = 'a',\n // Image format (24=RGB, 32=RGBA, 100=PNG)\n FORMAT = 'f',\n // Image ID for referencing stored images\n ID = 'i',\n // Image number (alternative to ID, terminal assigns ID)\n IMAGE_NUMBER = 'I',\n // Source image width in pixels\n WIDTH = 's',\n // Source image height in pixels\n HEIGHT = 'v',\n // The left edge (in pixels) of the image area to display\n X_OFFSET = 'x',\n // The top edge (in pixels) of the image area to display\n Y_OFFSET = 'y',\n // Width (in pixels) of the source rectangle to display\n SOURCE_WIDTH = 'w',\n // Height (in pixels) of the source rectangle to display\n SOURCE_HEIGHT = 'h',\n // Horizontal offset (in pixels) within the first cell\n X_PLACEMENT_OFFSET = 'X',\n // Vertical offset (in pixels) within the first cell\n Y_PLACEMENT_OFFSET = 'Y',\n // Number of terminal columns to display the image over\n COLUMNS = 'c',\n // Number of terminal rows to display the image over\n ROWS = 'r',\n // More data flag (1=more chunks coming, 0=final chunk)\n MORE = 'm',\n // Compression type (z=zlib). This is essential for chunking larger images.\n COMPRESSION = 'o',\n // Quiet mode (1=suppress OK responses, 2=suppress error responses)\n QUIET = 'q',\n // Cursor movement policy (0=move cursor after image, 1=don't move cursor)\n CURSOR_MOVEMENT = 'C',\n // Z-index for image layering (negative = behind text, 0+ = on top)\n Z_INDEX = 'z',\n // Transmission medium (d=direct, f=file, t=temp file, s=shared memory)\n TRANSMISSION = 't',\n // Delete selector (a/A=all, i/I=by id, c/C=at cursor, etc.) \u2014 only used when a=d\n DELETE_SELECTOR = 'd',\n // Placement ID for targeting specific placements\n PLACEMENT_ID = 'p'\n}\n\n// Pixel format constants\nexport const enum KittyPixelConstants {\n BYTES_PER_PIXEL_RGB = 3,\n BYTES_PER_PIXEL_RGBA = 4,\n ALPHA_OPAQUE = 255\n}\n\n// Parsed Kitty graphics command.\nexport interface IKittyCommand {\n action?: string;\n format?: number;\n id?: number;\n imageNumber?: number;\n width?: number;\n height?: number;\n x?: number;\n y?: number;\n sourceWidth?: number;\n sourceHeight?: number;\n xOffset?: number;\n yOffset?: number;\n columns?: number;\n rows?: number;\n more?: number;\n quiet?: number;\n cursorMovement?: number;\n zIndex?: number;\n transmission?: string;\n deleteSelector?: string;\n placementId?: number;\n compression?: string;\n payload?: string;\n}\n\n// Pending chunked transmission state.\n// Stores metadata from the first chunk while accumulating decoded payload data.\nexport interface IPendingTransmission {\n // The parsed command from the first chunk (contains action, format, dimensions, etc.)\n cmd: IKittyCommand;\n // Decoder used across chunked payloads\n decoder: Base64Decoder;\n // Total encoded (base64) bytes received across all chunks - for size limit enforcement\n totalEncodedSize: number;\n // Whether any chunk has failed to decode\n decodeError: boolean;\n}\n\n// Stored Kitty image data.\nexport interface IKittyImageData {\n id: number;\n // Decoded image data stored as Blob (off JS heap) to avoid 2GB heap limit\n data: Blob;\n width: number;\n height: number;\n format: 24 | 32 | 100;\n compression?: string;\n}\n\n// Parses Kitty graphics control data into a command object.\nexport function parseKittyCommand(data: string): IKittyCommand {\n const cmd: IKittyCommand = {};\n const parts = data.split(',');\n\n for (const part of parts) {\n const eqIdx = part.indexOf('=');\n if (eqIdx === -1) continue;\n\n const key = part.substring(0, eqIdx);\n const value = part.substring(eqIdx + 1);\n\n // Handle string keys first\n if (key === KittyKey.ACTION) {\n cmd.action = value;\n continue;\n }\n if (key === KittyKey.COMPRESSION) {\n cmd.compression = value;\n continue;\n }\n if (key === KittyKey.TRANSMISSION) {\n cmd.transmission = value;\n continue;\n }\n if (key === KittyKey.DELETE_SELECTOR) {\n cmd.deleteSelector = value;\n continue;\n }\n const numValue = parseInt(value, 10);\n switch (key) {\n case KittyKey.FORMAT: cmd.format = numValue; break;\n case KittyKey.ID: cmd.id = numValue; break;\n case KittyKey.IMAGE_NUMBER: cmd.imageNumber = numValue; break;\n case KittyKey.WIDTH: cmd.width = numValue; break;\n case KittyKey.HEIGHT: cmd.height = numValue; break;\n case KittyKey.X_OFFSET: cmd.x = numValue; break;\n case KittyKey.Y_OFFSET: cmd.y = numValue; break;\n case KittyKey.SOURCE_WIDTH: cmd.sourceWidth = numValue; break;\n case KittyKey.SOURCE_HEIGHT: cmd.sourceHeight = numValue; break;\n case KittyKey.X_PLACEMENT_OFFSET: cmd.xOffset = numValue; break;\n case KittyKey.Y_PLACEMENT_OFFSET: cmd.yOffset = numValue; break;\n case KittyKey.COLUMNS: cmd.columns = numValue; break;\n case KittyKey.ROWS: cmd.rows = numValue; break;\n case KittyKey.MORE: cmd.more = numValue; break;\n case KittyKey.QUIET: cmd.quiet = numValue; break;\n case KittyKey.CURSOR_MOVEMENT: cmd.cursorMovement = numValue; break;\n case KittyKey.Z_INDEX: cmd.zIndex = numValue; break;\n case KittyKey.PLACEMENT_ID: cmd.placementId = numValue; break;\n }\n }\n\n return cmd;\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageStorage } from '../ImageStorage';\nimport { ImageLayer, IAddImageOpts } from '../Types';\nimport { IKittyImageData } from './KittyGraphicsTypes';\n\n// Kitty-specific image storage controller.\n//\n// Wraps shared ImageStorage with kitty protocol semantics:\n// - tracks transmitted image payloads by kitty image id\n// - tracks kitty image id -> shared ImageStorage id mapping for displayed images\n// - mirrors shared-storage evictions into kitty maps\n// - applies protocol-level undisplayed-image eviction policy\nexport class KittyImageStorage implements IDisposable {\n private static readonly _maxStoredImages = 256;\n\n private _nextImageId = 1;\n private readonly _images: Map = new Map();\n // TODO: Support multiple placements per image. The kitty spec identifies\n // placements by an (image id, placement id) pair \u2014 same i + different p\n // values should coexist, and same i + same p should replace the prior\n // placement. Currently we track only one storage entry per kitty image id,\n // so multiple placements of the same image overwrite each other. Fixing\n // this requires changing these maps to Map>\n // (kittyId \u2192 placementId \u2192 storageId) and updating addImage/deleteById\n // accordingly. The underlying shared ImageStorage would also need to\n // support multiple entries per logical image.\n private readonly _kittyIdToStorageId: Map = new Map();\n private readonly _storageIdToKittyId: Map = new Map();\n\n private readonly _previousOnImageDeleted: ((storageId: number) => void) | undefined;\n private readonly _wrappedOnImageDeleted: (storageId: number) => void;\n private readonly _handleStorageImageDeleted = (storageId: number): void => {\n const kittyId = this._storageIdToKittyId.get(storageId);\n if (kittyId !== undefined) {\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n this._images.delete(kittyId);\n }\n };\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n\n constructor(\n private readonly _storage: ImageStorage\n ) {\n this._previousOnImageDeleted = this._storage.onImageDeleted;\n this._wrappedOnImageDeleted = (storageId: number) => {\n this._previousOnImageDeleted?.(storageId);\n this._handleStorageImageDeleted(storageId);\n };\n this._storage.onImageDeleted = this._wrappedOnImageDeleted;\n }\n\n public reset(): void {\n this._nextImageId = 1;\n this._images.clear();\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public dispose(): void {\n this.reset();\n if (this._storage.onImageDeleted === this._wrappedOnImageDeleted) {\n this._storage.onImageDeleted = this._previousOnImageDeleted;\n }\n }\n\n public storeImage(id: number | undefined, imageData: Omit): number {\n const imageId = id ?? this._nextImageId++;\n\n const oldStorageId = this._kittyIdToStorageId.get(imageId);\n if (oldStorageId !== undefined) {\n this._storage.deleteImage(oldStorageId);\n this._kittyIdToStorageId.delete(imageId);\n this._storageIdToKittyId.delete(oldStorageId);\n }\n\n if (!this._images.has(imageId) && this._images.size >= KittyImageStorage._maxStoredImages) {\n this._evictUndisplayedImages();\n }\n\n // Encoded images awaiting placement are outside ImageStorage's pixel budget.\n // Unplaced payloads are evicted first so a new upload cannot erase a visible\n // image while abandoned blobs still hold budget; placed ones go only when\n // that is not enough, because the byte cap is a hard bound. The new image is\n // always stored, so an oversized one overshoots by at most one payload\n // (itself bounded by kittySizeLimit) rather than being dropped after an OK ack.\n const byteLimit = this._storage.getLimit() * 1000000;\n this._images.delete(imageId);\n let retainedBytes = 0;\n for (const image of this._images.values()) retainedBytes += image.data.size;\n for (const evictPlaced of [false, true]) {\n for (const [oldestId, image] of this._images) {\n if (retainedBytes + imageData.data.size <= byteLimit) break;\n if (this._kittyIdToStorageId.has(oldestId) !== evictPlaced) continue;\n retainedBytes -= image.data.size;\n this.deleteById(oldestId);\n }\n }\n\n this._images.set(imageId, {\n ...imageData,\n id: imageId\n });\n return imageId;\n }\n\n public addImage(kittyId: number, image: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer, zIndex: number): void {\n // Clean up stale reverse-mapping from a previous placement of the same\n // kitty image. The old shared-storage entry is kept (it may still be\n // visible on screen) but its reverse mapping is removed so that eviction\n // of the old entry won't incorrectly delete the kitty image data.\n const oldStorageId = this._kittyIdToStorageId.get(kittyId);\n if (oldStorageId !== undefined) {\n this._storageIdToKittyId.delete(oldStorageId);\n }\n this._addImageOpts.scrolling = scrolling;\n this._addImageOpts.layer = layer;\n this._addImageOpts.zIndex = zIndex;\n const storageId = this._storage.addImage(image, this._addImageOpts);\n this._kittyIdToStorageId.set(kittyId, storageId);\n this._storageIdToKittyId.set(storageId, kittyId);\n }\n\n public getImage(kittyId: number): IKittyImageData | undefined {\n return this._images.get(kittyId);\n }\n\n public deleteById(kittyId: number): void {\n this._images.delete(kittyId);\n const storageId = this._kittyIdToStorageId.get(kittyId);\n if (storageId !== undefined) {\n this._storage.deleteImage(storageId);\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n }\n }\n\n public deleteAll(): void {\n this._images.clear();\n for (const storageId of this._kittyIdToStorageId.values()) {\n this._storage.deleteImage(storageId);\n }\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public get images(): ReadonlyMap {\n return this._images;\n }\n\n public get kittyIdToStorageId(): ReadonlyMap {\n return this._kittyIdToStorageId;\n }\n\n public get lastImageId(): number {\n return this._nextImageId - 1;\n }\n\n private _evictUndisplayedImages(): void {\n for (const [kittyId] of this._images) {\n if (this._images.size <= KittyImageStorage._maxStoredImages / 2) {\n break;\n }\n if (!this._kittyIdToStorageId.has(kittyId)) {\n this._images.delete(kittyId);\n }\n }\n }\n}\n", "/**\n * Copyright (c) 2020, 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IDcsHandler, IParams, IImageAddonOptions, ITerminalExt, AttributeData, IResetHandler, ReadonlyColorSet } from './Types';\nimport { toRGBA8888, BIG_ENDIAN, PALETTE_ANSI_256, PALETTE_VT340_COLOR } from 'sixel/lib/Colors';\nimport { RGBA8888 } from 'sixel/lib/Types';\nimport { ImageRenderer } from './ImageRenderer';\n\nimport { DecoderAsync, Decoder } from 'sixel/lib/Decoder';\nimport { LIMITS } from 'sixel/lib/wasm';\n\n// always free decoder ressources after decoding if it exceeds this limit\nconst MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB\n\n// custom default palette: VT340 (lower 16 colors) + ANSI256 (up to 256) + zeroed (up to 4096)\nconst DEFAULT_PALETTE = PALETTE_ANSI_256;\nDEFAULT_PALETTE.set(PALETTE_VT340_COLOR);\n\n// Why pooled: every decoder owns a wasm memory, and V8 caps live wasm memories\n// per process (~124 in a sandboxed renderer). Terminals borrow a decoder only\n// while a SIXEL sequence is open, so idle terminals hold none.\nconst MAX_IDLE_DECODERS = 2;\nconst idleDecoders = new Map();\nlet poolPrimed = false;\n\nfunction primeDecoderPool(memoryLimit: number): void {\n if (poolPrimed) return;\n poolPrimed = true;\n // Async compile once, off the parser's hot path; later decoders reuse the cached module.\n DecoderAsync({ memoryLimit, palette: DEFAULT_PALETTE }).then(\n d => releaseDecoder(d, memoryLimit),\n () => { poolPrimed = false; }\n );\n}\n\nfunction acquireDecoder(memoryLimit: number): Decoder {\n return idleDecoders.get(memoryLimit)?.pop() ?? new Decoder({ memoryLimit, palette: DEFAULT_PALETTE });\n}\n\nfunction releaseDecoder(dec: Decoder, memoryLimit: number): void {\n if (dec.memoryUsage > MEM_PERMA_LIMIT) {\n dec.release();\n }\n const idle = idleDecoders.get(memoryLimit) ?? [];\n if (idle.length < MAX_IDLE_DECODERS) {\n idle.push(dec);\n idleDecoders.set(memoryLimit, idle);\n }\n}\n\n\nexport class SixelHandler implements IDcsHandler, IResetHandler {\n private _size = 0;\n private _aborted = false;\n private _dec: Decoder | undefined;\n private _decMemoryLimit = 0;\n // Color registers outlive a single image, so they live here rather than in a pooled decoder.\n private readonly _palette = new Uint32Array(LIMITS.PALETTE_SIZE);\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _storage: SixelImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n this._palette.set(DEFAULT_PALETTE);\n primeDecoderPool(this._opts.pixelLimit * 4);\n }\n\n public reset(): void {\n this._returnDecoder();\n this._palette.fill(0);\n this._palette.set(DEFAULT_PALETTE);\n }\n\n public hook(params: IParams): void {\n this._size = 0;\n this._aborted = false;\n this._returnDecoder();\n const memoryLimit = this._opts.pixelLimit * 4;\n try {\n this._dec = acquireDecoder(memoryLimit);\n } catch (e) {\n // Why: exhausting wasm memory must drop this image, not throw out of the parser and wedge the write queue.\n console.warn(`SIXEL: could not allocate decoder - ${e}`);\n this._aborted = true;\n return;\n }\n this._decMemoryLimit = memoryLimit;\n const fillColor = params.params[1] === 1 ? 0 : extractActiveBg(\n this._coreTerminal._core._inputHandler._curAttrData,\n this._coreTerminal._core._themeService?.colors);\n this._dec.init(fillColor, this._palette, this._opts.sixelPaletteLimit);\n }\n\n private _returnDecoder(): void {\n const dec = this._dec;\n if (!dec) return;\n this._dec = undefined;\n this._palette.set(dec.palette);\n releaseDecoder(dec, this._decMemoryLimit);\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted || !this._dec) {\n return;\n }\n this._size += end - start;\n if (this._size > this._opts.sixelSizeLimit) {\n console.warn(`SIXEL: too much data, aborting`);\n this._aborted = true;\n this._dec.release();\n return;\n }\n try {\n this._dec.decode(data, start, end);\n } catch (e) {\n console.warn(`SIXEL: error while decoding image - ${e}`);\n this._aborted = true;\n this._dec.release();\n }\n }\n\n public unhook(success: boolean): boolean | Promise {\n try {\n return this._unhook(success);\n } finally {\n this._returnDecoder();\n }\n }\n\n private _unhook(success: boolean): boolean {\n if (this._aborted || !success || !this._dec) {\n return true;\n }\n\n const width = this._dec.width;\n const height = this._dec.height;\n\n // partial fix for https://github.com/jerch/xterm-addon-image/issues/37\n if (!width || !height) {\n if (height) {\n this._storage.advanceCursor(height);\n }\n return true;\n }\n\n const canvas = ImageRenderer.createCanvas(undefined, width, height);\n canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray, width, height), 0, 0);\n this._storage.addImage(canvas);\n return true;\n }\n}\n\n\n/**\n * Some helpers to extract current terminal colors.\n */\n\n// get currently active background color from terminal\n// also respect INVERSE setting\nfunction extractActiveBg(attr: AttributeData, colors: ReadonlyColorSet | undefined): RGBA8888 {\n let bg = 0;\n if (!colors) {\n // FIXME: theme service is prolly not available yet,\n // happens if .open() was not called yet (bug in core?)\n return bg;\n }\n if (attr.isInverse()) {\n if (attr.isFgDefault()) {\n bg = convertLe(colors.foreground.rgba);\n } else if (attr.isFgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getFgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getFgColor()].rgba);\n }\n } else {\n if (attr.isBgDefault()) {\n bg = convertLe(colors.background.rgba);\n } else if (attr.isBgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getBgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getBgColor()].rgba);\n }\n }\n return bg;\n}\n\n// rgba values on the color managers are always in BE, thus convert to LE\nfunction convertLe(color: number): RGBA8888 {\n if (BIG_ENDIAN) return color;\n return (color & 0xFF) << 24 | (color >>> 8 & 0xFF) << 16 | (color >>> 16 & 0xFF) << 8 | color >>> 24 & 0xFF;\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { IImageAddonOptions, ITerminalExt, IAddImageOpts } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\n\n/**\n * Sixel-specific image storage controller.\n *\n * Wraps the shared ImageStorage with sixel protocol semantics:\n * - Cursor behavior governed by DECSET 80 (sixelScrolling option)\n * - advanceCursor for empty sixels carrying only height\n */\nexport class SixelImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'vt340' };\n constructor(\n private readonly _storage: ImageStorage,\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _terminal: ITerminalExt\n ) {}\n\n /**\n * Add a sixel image to storage.\n * Cursor behavior depends on the sixelScrolling option (DECSET 80).\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._addImageOpts.scrolling = this._opts.sixelScrolling;\n this._storage.addImage(img, this._addImageOpts);\n }\n\n /**\n * Only advance text cursor.\n * This is an edge case from empty sixels carrying only a height but no pixels.\n * Partially fixes https://github.com/jerch/xterm-addon-image/issues/37.\n */\n public advanceCursor(height: number): void {\n if (this._opts.sixelScrolling) {\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const rows = Math.ceil(height / cellSize.height);\n for (let i = 1; i < rows; ++i) {\n this._terminal._core._inputHandler.lineFeed();\n }\n }\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IAddImageOpts } from './Types';\nimport { ImageStorage } from './ImageStorage';\n\n/**\n * IIP (iTerm Image Protocol) specific image storage controller.\n *\n * Wraps the shared ImageStorage with IIP protocol semantics:\n * - Always uses scrolling mode (cursor advances with image)\n */\nexport class IIPImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n constructor(\n private readonly _storage: ImageStorage\n ) {}\n\n /**\n * Add an IIP image to storage.\n * Always uses scrolling mode \u2014 cursor advances past the image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._storage.addImage(img, this._addImageOpts);\n }\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ITerminalAddon, IDisposable } from '@xterm/xterm';\nimport type { ImageAddon as IImageApi } from '@xterm/addon-image';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { IIPHandler } from './IIPHandler';\nimport { ImageRenderer } from './ImageRenderer';\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';\nimport { KittyImageStorage } from './kitty/KittyImageStorage';\nimport { SixelHandler } from './SixelHandler';\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';\n\n\n/**\n * Document VT features provided by this addon.\n *\n * @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL \"SIXEL Graphics\" \"DCS Ps ; Ps ; Ps ; q Pt ST\" \"Draw SIXEL image.\"\n *\n * Sixel support is provided by the addon @xterm/addon-image with these limitations:\n * - immediate coloring (no shared palette, allows high color settings of `img2sixel`)\n * - max. palette size of 4096 colors\n * - max. pixel width of 16K\n * - max. 25 MB per sixel sequence\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] OSC 1337 \"iTerm2 Commands\" \"OSC 1337 ; Pt BEL\" \"Custom iTerm2 commands.\"\n *\n * Only the inline image protocol (IIP) is supported by the addon @xterm/addon-image with\n * the following limitations:\n * - sequence:\n * - format: `OSC 1337 ; File=inline=1 ; size= ; ... : BEL`\n * - size param must be set and payload may not exceed CEIL(size * 4 / 3)\n * - strict base64 handling as of RFC4648 \u00A74 (standard alphabet, optional padding,\n * no separator bytes allowed)\n * - supported params: size, name, width, height, preserveAspectRatio\n * - image formats: PNG, JPEG and GIF\n * - no animation support (renders first image of a GIF)\n * - no multipart support\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image)\n * and [iTerm2 IIP docs](https://iterm2.com/documentation-images.html) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] APC KITTY_GRAPHICS \"Kitty Graphics\" \"APC G Pt ST\" \"Kitty Graphics Protocol.\"\n *\n * Kitty graphics support is provided by the addon @xterm/addon-image.\n * Note that while basic image output already works, this is still work in progress.\n */\n\n// default values of addon ctor options\nconst DEFAULT_OPTIONS: IImageAddonOptions = {\n enableSizeReports: true,\n pixelLimit: 16777216, // limit to 4096 * 4096 pixels\n sixelSupport: true,\n sixelScrolling: true,\n sixelPaletteLimit: 4096,\n sixelSizeLimit: 33554432,\n storageLimit: 128,\n showPlaceholder: true,\n iipSupport: true,\n iipSizeLimit: 33554432,\n kittySupport: true,\n kittySizeLimit: 33554432\n};\n\n// max palette size supported by the sixel lib (compile time setting)\nconst MAX_SIXEL_PALETTE_SIZE = 4096;\n\n// definitions for _xtermGraphicsAttributes sequence\nconst enum GaItem {\n COLORS = 1,\n SIXEL_GEO = 2,\n REGIS_GEO = 3\n}\nconst enum GaAction {\n READ = 1,\n SET_DEFAULT = 2,\n SET = 3,\n READ_MAX = 4\n}\nconst enum GaStatus {\n SUCCESS = 0,\n ITEM_ERROR = 1,\n ACTION_ERROR = 2,\n FAILURE = 3\n}\n\n\nexport class ImageAddon implements ITerminalAddon, IImageApi {\n private _opts: IImageAddonOptions;\n private _defaultOpts: IImageAddonOptions;\n private _storage: ImageStorage | undefined;\n private _renderer: ImageRenderer | undefined;\n private _disposables: IDisposable[] = [];\n private _terminal: ITerminalExt | undefined;\n private _handlers: Map = new Map();\n private readonly _onImageAdded = new Emitter();\n public readonly onImageAdded: IEvent = this._onImageAdded.event;\n\n constructor(opts?: Partial) {\n this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);\n this._defaultOpts = Object.assign({}, DEFAULT_OPTIONS, opts);\n }\n\n public dispose(): void {\n for (const handler of this._handlers.values()) handler.reset();\n for (const obj of this._disposables) {\n obj.dispose();\n }\n this._disposables.length = 0;\n this._handlers.clear();\n this._onImageAdded.dispose();\n }\n\n private _disposeLater(...args: IDisposable[]): void {\n for (const obj of args) {\n this._disposables.push(obj);\n }\n }\n\n public activate(terminal: ITerminalExt): void {\n this._terminal = terminal;\n\n // internal data structures\n this._renderer = new ImageRenderer(terminal);\n this._storage = new ImageStorage(terminal, this._renderer, this._opts);\n this._storage.onImageAdded = () => this._onImageAdded.fire();\n\n // enable size reports\n if (this._opts.enableSizeReports) {\n const windowOps = terminal.options.windowOptions ?? {};\n windowOps.getWinSizePixels = true;\n windowOps.getCellSizePixels = true;\n windowOps.getWinSizeChars = true;\n terminal.options.windowOptions = windowOps;\n }\n\n this._disposeLater(\n this._renderer,\n this._storage,\n\n // DECSET/DECRST/DA1/XTSMGRAPHICS handlers\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'h' }, params => this._decset(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'l' }, params => this._decrst(params)),\n terminal.parser.registerCsiHandler({ final: 'c' }, params => this._da1(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'S' }, params => this._xtermGraphicsAttributes(params)),\n\n // render hook\n terminal.onRender(range => this._storage?.render(range)),\n\n /**\n * reset handlers covered:\n * - DECSTR\n * - RIS\n * - Terminal.reset()\n */\n terminal.parser.registerCsiHandler({ intermediates: '!', final: 'p' }, () => this.reset()),\n terminal.parser.registerEscHandler({ final: 'c' }, () => this.reset()),\n terminal._core._inputHandler.onRequestReset(() => this.reset()),\n\n // wipe canvas and delete alternate images on buffer switch\n terminal.buffer.onBufferChange(() => this._storage?.wipeAlternate()),\n\n // extend images to the right on resize\n terminal.onResize(metrics => this._storage?.viewportResize(metrics))\n );\n\n // SIXEL handler\n if (this._opts.sixelSupport) {\n const sixelStorage = new SixelImageStorage(this._storage!, this._opts, this._renderer!, terminal);\n const sixelHandler = new SixelHandler(this._opts, sixelStorage, terminal);\n this._handlers.set('sixel', sixelHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler)\n );\n }\n\n // iTerm IIP handler\n if (this._opts.iipSupport) {\n const iipStorage = new IIPImageStorage(this._storage!);\n const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, terminal);\n this._handlers.set('iip', iipHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)\n );\n }\n\n // Kitty graphics handler\n if (this._opts.kittySupport) {\n const kittyStorage = new KittyImageStorage(this._storage!);\n const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, kittyStorage, terminal);\n this._handlers.set('kitty', kittyHandler);\n this._disposeLater(\n kittyStorage,\n kittyHandler,\n terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)\n );\n }\n }\n\n // Note: storageLimit is skipped here to not intoduce a surprising side effect.\n public reset(): boolean {\n // reset options customizable by sequences to defaults\n this._opts.sixelScrolling = this._defaultOpts.sixelScrolling;\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n // also clear image storage\n this._storage?.reset();\n // reset protocol handlers\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return false;\n }\n\n public get storageLimit(): number {\n return this._storage?.getLimit() || -1;\n }\n\n public set storageLimit(limit: number) {\n this._storage?.setLimit(limit);\n this._opts.storageLimit = limit;\n }\n\n public get storageUsage(): number {\n if (this._storage) {\n return this._storage.getUsage();\n }\n return -1;\n }\n\n public get showPlaceholder(): boolean {\n return this._opts.showPlaceholder;\n }\n\n public set showPlaceholder(value: boolean) {\n this._opts.showPlaceholder = value;\n this._renderer?.showPlaceholder(value);\n }\n\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.getImageAtBufferCell(x, y);\n }\n\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.extractTileAtBufferCell(x, y);\n }\n\n private _report(s: string): void {\n this._terminal?._core.input(s, false);\n }\n\n private _decset(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = false;\n break;\n }\n }\n return false;\n }\n\n private _decrst(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = true;\n break;\n }\n }\n return false;\n }\n\n // overload DA to return something more appropriate\n private _da1(params: (number | number[])[]): boolean {\n if (params[0]) {\n return true;\n }\n // reported features:\n // 62 - VT220\n // 4 - SIXEL support\n // 9 - charsets\n // 22 - ANSI colors\n if (this._opts.sixelSupport) {\n this._report(`\\x1b[?62;4;9;22c`);\n return true;\n }\n return false;\n }\n\n /**\n * Implementation of xterm's graphics attribute sequence.\n *\n * Supported features:\n * - read/change palette limits (max 4096 by sixel lib)\n * - read SIXEL canvas geometry (reports current window canvas or\n * squared pixelLimit if canvas > pixel limit)\n *\n * Everything else is deactivated.\n */\n private _xtermGraphicsAttributes(params: (number | number[])[]): boolean {\n if (params.length < 2) {\n return true;\n }\n if (params[0] === GaItem.COLORS) {\n switch (params[1]) {\n case GaAction.READ:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n return true;\n case GaAction.SET_DEFAULT:\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n // also reset protocol handlers for now\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return true;\n case GaAction.SET:\n if (params.length > 2 && !(params[2] instanceof Array) && params[2] <= MAX_SIXEL_PALETTE_SIZE) {\n this._opts.sixelPaletteLimit = params[2];\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n } else {\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n }\n return true;\n case GaAction.READ_MAX:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${MAX_SIXEL_PALETTE_SIZE}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n if (params[0] === GaItem.SIXEL_GEO) {\n switch (params[1]) {\n // we only implement read and read_max here\n case GaAction.READ:\n let width = this._renderer?.dimensions?.css.canvas.width;\n let height = this._renderer?.dimensions?.css.canvas.height;\n if (!width || !height) {\n // for some reason we have no working image renderer\n // --> fallback to default cell size\n const cellSize = CELL_SIZE_DEFAULT;\n width = (this._terminal?.cols || 80) * cellSize.width;\n height = (this._terminal?.rows || 24) * cellSize.height;\n }\n if (width * height < this._opts.pixelLimit) {\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${width.toFixed(0)};${height.toFixed(0)}S`);\n } else {\n // if we overflow pixelLimit report that squared instead\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n }\n return true;\n case GaAction.READ_MAX:\n // read_max returns pixelLimit as square area\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n // exit with error on ReGIS or any other requests\n this._report(`\\x1b[?${params[0]};${GaStatus.ITEM_ERROR}S`);\n return true;\n }\n}\n"], ++ "mappings": ";;;;;;;;;;;;;;;;02BAYaA,EAAA,WAAa,IAAI,WAAW,IAAI,YAAY,CAAC,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC,IAAM,IAClFA,EAAA,YACF,QAAQ,KAAK,6EAA6E,EAI5F,SAAgBC,GAAIC,EAAW,CAC7B,OAAOA,EAAI,GACb,CAFAF,EAAA,IAAAC,GAIA,SAAgBE,GAAMD,EAAW,CAC/B,OAAQA,IAAM,EAAK,GACrB,CAFAF,EAAA,MAAAG,GAIA,SAAgBC,GAAKF,EAAW,CAC9B,OAAQA,IAAM,GAAM,GACtB,CAFAF,EAAA,KAAAI,GAIA,SAAgBC,GAAMH,EAAW,CAC/B,OAAQA,IAAM,GAAM,GACtB,CAFAF,EAAA,MAAAK,GAQA,SAAgBC,EAAW,EAAWC,EAAWC,EAAWC,EAAY,IAAG,CACzE,QAASA,EAAI,MAAS,IAAMD,EAAI,MAAS,IAAMD,EAAI,MAAS,EAAK,EAAI,OAAW,CAClF,CAFAP,EAAA,WAAAM,EAQA,SAAgBI,GAAaC,EAAe,CAC1C,MAAO,CAACA,EAAQ,IAAOA,GAAS,EAAK,IAAOA,GAAS,GAAM,IAAMA,IAAU,EAAE,CAC/E,CAFAX,EAAA,aAAAU,GASA,SAAgBE,GAAkBD,EAAiBE,EAAmB,CACpE,IAAMC,EAAIb,GAAIU,CAAK,EACbJ,EAAIJ,GAAMQ,CAAK,EACfH,EAAIJ,GAAKO,CAAK,EAEhBI,EAAM,OAAO,iBACbC,EAAM,GAGV,QAASC,EAAI,EAAGA,EAAIJ,EAAQ,OAAQ,EAAEI,EAAG,CACvC,IAAMC,EAAKJ,EAAID,EAAQI,CAAC,EAAE,CAAC,EACrBE,EAAKZ,EAAIM,EAAQI,CAAC,EAAE,CAAC,EACrBG,EAAKZ,EAAIK,EAAQI,CAAC,EAAE,CAAC,EACrBI,EAAIH,EAAKA,EAAKC,EAAKA,EAAKC,EAAKA,EACnC,GAAI,CAACC,EAAG,OAAOJ,EACXI,EAAIN,IACNA,EAAMM,EACNL,EAAMC,GAIV,OAAOD,CACT,CAtBAhB,EAAA,kBAAAY,GA4BA,SAASU,GAAMC,EAAaC,EAAcC,EAAa,CACrD,OAAO,KAAK,IAAIF,EAAK,KAAK,IAAIE,EAAOD,CAAI,CAAC,CAC5C,CAEA,SAASE,GAAIC,EAAYC,EAAYC,EAAS,CAC5C,OAAIA,EAAI,IAAGA,GAAK,GACZA,EAAI,IAAGA,GAAK,GACTA,EAAI,EAAI,EACXD,GAAMD,EAAKC,GAAM,EAAIC,EACrBA,EAAI,EAAI,EACNF,EACAE,EAAI,EAAI,EACND,GAAMD,EAAKC,IAAO,EAAIC,EAAI,GAC1BD,CACV,CAEA,SAASE,GAASC,EAAWC,EAAWC,EAAS,CAC/C,GAAI,CAACA,EAAG,CACN,IAAMC,EAAI,KAAK,MAAMF,EAAI,GAAG,EAC5B,OAAO1B,EAAW4B,EAAGA,EAAGA,CAAC,EAE3B,IAAMP,EAAKK,EAAI,GAAMA,GAAK,EAAIC,GAAKD,EAAIC,EAAID,EAAIC,EACzCL,EAAK,EAAII,EAAIL,EACnB,OAAOrB,EACLgB,GAAM,EAAG,IAAK,KAAK,MAAMI,GAAIC,EAAIC,EAAIG,EAAI,EAAI,CAAC,EAAI,GAAG,CAAC,EACtDT,GAAM,EAAG,IAAK,KAAK,MAAMI,GAAIC,EAAIC,EAAIG,CAAC,EAAI,GAAG,CAAC,EAC9CT,GAAM,EAAG,IAAK,KAAK,MAAMI,GAAIC,EAAIC,EAAIG,EAAI,EAAI,CAAC,EAAI,GAAG,CAAC,CAAC,CAE3D,CAKA,SAAgBI,EAAa,EAAW5B,EAAWC,EAAS,CAC1D,OAAQ,WAAa,KAAK,MAAMA,EAAI,IAAM,GAAG,GAAK,GAAK,KAAK,MAAMD,EAAI,IAAM,GAAG,GAAK,EAAI,KAAK,MAAM,EAAI,IAAM,GAAG,KAAO,CACzH,CAFAP,EAAA,aAAAmC,EAQA,SAAgBC,GAAaL,EAAWC,EAAWC,EAAS,CAE1D,OAAOH,IAAUC,EAAI,IAAM,KAAO,IAAKC,EAAI,IAAKC,EAAI,GAAG,CACzD,CAHAjC,EAAA,aAAAoC,GAqCapC,EAAA,oBAAsB,IAAI,YAAY,CACjDmC,EAAc,EAAI,EAAI,CAAC,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACxB,EA0BYnC,EAAA,mBAAqB,IAAI,YAAY,CAChDmC,EAAc,EAAI,EAAI,CAAC,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAc,EAAI,EAAI,CAAC,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAc,EAAI,EAAI,CAAC,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAc,EAAI,EAAI,CAAC,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACvBA,EAAa,GAAI,GAAI,EAAE,EACxB,EAOYnC,EAAA,kBAAoB,IAAK,CAEpC,IAAMqC,EAAgB,CACpB/B,EAAW,EAAG,EAAG,CAAC,EAClBA,EAAW,IAAK,EAAG,CAAC,EACpBA,EAAW,EAAG,IAAK,CAAC,EACpBA,EAAW,IAAK,IAAK,CAAC,EACtBA,EAAW,EAAG,EAAG,GAAG,EACpBA,EAAW,IAAK,EAAG,GAAG,EACtBA,EAAW,EAAG,IAAK,GAAG,EACtBA,EAAW,IAAK,IAAK,GAAG,EACxBA,EAAW,IAAK,IAAK,GAAG,EACxBA,EAAW,IAAK,EAAG,CAAC,EACpBA,EAAW,EAAG,IAAK,CAAC,EACpBA,EAAW,IAAK,IAAK,CAAC,EACtBA,EAAW,GAAI,GAAI,GAAG,EACtBA,EAAW,IAAK,EAAG,GAAG,EACtBA,EAAW,EAAG,IAAK,GAAG,EACtBA,EAAW,IAAK,IAAK,GAAG,GAGpBe,EAAI,CAAC,EAAG,GAAI,IAAK,IAAK,IAAK,GAAG,EACpC,QAASP,EAAI,EAAGA,EAAI,EAAG,EAAEA,EACvB,QAASP,EAAI,EAAGA,EAAI,EAAG,EAAEA,EACvB,QAASC,EAAI,EAAGA,EAAI,EAAG,EAAEA,EACvB6B,EAAE,KAAK/B,EAAWe,EAAEP,CAAC,EAAGO,EAAEd,CAAC,EAAGc,EAAEb,CAAC,CAAC,CAAC,EAKzC,QAAS0B,EAAI,EAAGA,GAAK,IAAKA,GAAK,GAC7BG,EAAE,KAAK/B,EAAW4B,EAAGA,EAAGA,CAAC,CAAC,EAE5B,OAAO,IAAI,YAAYG,CAAC,CAC1B,GAAE,EASWrC,EAAA,mBAA+BM,EAAW,EAAG,EAAG,EAAG,GAAG,EACtDN,EAAA,mBAA+BM,EAAW,IAAK,IAAK,IAAK,GAAG,IChRzE,IAAAgC,GAAAC,EAAAC,IAAA,cAKA,OAAO,eAAeA,GAAS,aAAc,CAAE,MAAO,EAAK,CAAC,EAC5DA,GAAQ,OAASC,GACjB,IAAIC,EAAKC,GAAM,CACX,GAAI,WAAW,WACX,OAAO,WAAW,WAAWA,CAAC,EAClC,GAAI,OAAO,OAAW,IAClB,OAAO,OAAO,KAAKA,EAAG,QAAQ,EAClC,IAAMC,EAAI,KAAKD,CAAC,EACVE,EAAI,IAAI,WAAWD,EAAE,MAAM,EACjC,QAAS,EAAI,EAAG,EAAIC,EAAE,OAAQ,EAAE,EAC5BA,EAAE,CAAC,EAAID,EAAE,WAAW,CAAC,EACzB,OAAOC,CACX,EACA,SAASJ,GAAOK,EAAK,CACjB,GAAIA,EAAI,EAAG,CACP,GAAM,CAAE,EAAAC,EAAG,EAAAJ,EAAG,EAAAK,CAAE,EAAIF,EAChBF,EACAK,EACEC,EAAI,YACV,OAAIH,IAAM,EACFJ,EACQQ,GAAM,IAAID,EAAE,SAASD,IAAMA,EAAI,IAAIC,EAAE,OAAON,IAAMA,EAAIF,EAAEM,CAAC,EAAE,GAAIG,CAAC,EACpEA,GAAMF,EACRC,EAAE,YAAYD,EAAGE,CAAC,EAClBD,EAAE,YAAYN,IAAMA,EAAIF,EAAEM,CAAC,GAAIG,CAAC,EAAE,KAAKN,IAAMI,EAAIJ,EAAE,SAAWA,EAAE,QAAQ,EAE9EE,IAAM,EACFJ,EACO,IAAMM,IAAMA,EAAI,IAAIC,EAAE,OAAON,IAAMA,EAAIF,EAAEM,CAAC,EAAE,GAChD,IAAMC,EACP,QAAQ,QAAQA,CAAC,EACjBC,EAAE,QAAQN,IAAMA,EAAIF,EAAEM,CAAC,EAAE,EAAE,KAAKH,GAAKI,EAAIJ,CAAC,EAEhDF,EACO,IAAMC,IAAMA,EAAIF,EAAEM,CAAC,GACvB,IAAM,QAAQ,QAAQJ,IAAMA,EAAIF,EAAEM,CAAC,EAAE,CAChD,CACA,GAAI,OAAO,SAAa,IACpB,MAAM,IAAI,MAAM,mBAAmB,EACvC,SAAS,IAAIF,CAAG,CACpB,IC7CA,IAAAM,GAAAC,EAAAC,IAAA,cACA,OAAO,eAAeA,GAAS,aAAc,CAAE,MAAO,EAAK,CAAC,EAK5D,IAAMC,GAAmB,KAInBC,MAAiBD,GAAiB,QAAuD,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,0lCAA0lC,CAA8C,EAuJ5uCE,EAAM,IAAI,WAAW,mEACtB,MAAM,EAAE,EACR,IAAIC,GAAMA,EAAG,WAAW,CAAC,CAAC,CAAC,EAE1BC,EAAI,IAAI,YAAY,IAAI,EAC9BA,EAAE,KAAK,UAAU,EACjB,QAASC,EAAI,EAAGA,EAAIH,EAAI,OAAQ,EAAEG,EAC9BD,EAAEF,EAAIG,CAAC,CAAC,EAAIA,GAAK,EACrB,QAASA,EAAI,EAAGA,EAAIH,EAAI,OAAQ,EAAEG,EAC9BD,EAAE,IAAMF,EAAIG,CAAC,CAAC,EAAIA,GAAK,GAAMA,GAAK,EAAK,MAAS,EACpD,QAASA,EAAI,EAAGA,EAAIH,EAAI,OAAQ,EAAEG,EAC9BD,EAAE,IAAMF,EAAIG,CAAC,CAAC,EAAKA,GAAK,GAAM,GAAMA,GAAK,EAAK,MAAS,GAC3D,QAASA,EAAI,EAAGA,EAAIH,EAAI,OAAQ,EAAEG,EAC9BD,EAAE,IAAMF,EAAIG,CAAC,CAAC,EAAIA,GAAK,GAC3B,IAAMC,GAAQ,IAAI,WAAW,CAAC,EAWxBC,GAAN,KAAoB,CAMhB,YAAYC,EAAUC,EAAUC,EAAc,CAO1C,GANA,KAAK,MAAQ,KACb,KAAK,OAAS,GACd,KAAK,OAAS,EACd,KAAK,SAAWF,GAAsD,QACtE,KAAK,SAAWC,GAAsD,WACtE,KAAK,OAASC,GAAkE,MAC5E,KAAK,OAAS,KAAK,UAAY,KAAK,SAAW,WAC/C,MAAM,IAAI,MAAM,uBAAuB,CAE/C,CAKA,IAAI,OAAQ,CACR,OAAO,KAAK,MAAQ,KAAK,GAAG,SAAS,EAAG,KAAK,KAAK,IAAuB,CAAC,EAAIJ,EAClF,CAMA,SAAU,CACD,KAAK,QAEN,KAAK,OAAS,KAAK,SACnB,KAAK,MAAQ,KAAK,KAAO,KAAK,GAAK,KAAK,KAAO,MAG/C,KAAK,KAAK,IAAuB,EAAI,EACrC,KAAK,KAAK,IAAuB,EAAI,EACrC,KAAK,KAAK,IAAuB,EAAI,GAE7C,CASA,KAAKG,EAAUC,EAAc,CAGzB,GAFA,KAAK,SAAWD,GAAsD,KAAK,SAC3E,KAAK,OAASC,GAAkE,KAAK,IAAI,KAAK,OAAQ,KAAK,QAAQ,EAC/G,KAAK,OAAS,KAAK,UAAY,KAAK,SAAW,WAC/C,MAAM,MAAM,uBAAuB,EAEvC,IAAIC,EAAI,KAAK,KACPC,EAAQ,KAAK,OAAS,KACvB,KAAK,MAOD,KAAK,KAAK,OAAO,WAAaA,IACnC,KAAK,KAAK,KAAK,KAAK,MAAMA,EAAQ,KAAK,KAAK,OAAO,YAAc,KAAK,CAAC,EACvED,EAAI,IAAI,YAAY,KAAK,KAAK,OAAQ,CAAC,EACvC,KAAK,GAAK,IAAI,WAAW,KAAK,KAAK,OAAQ,IAA6B,IATxE,KAAK,KAAO,IAAI,YAAY,OAAO,CAAE,QAAS,KAAK,KAAKC,EAAQ,KAAK,CAAE,CAAC,EACxE,KAAK,MAAQX,GAAW,CAAE,IAAK,CAAE,OAAQ,KAAK,IAAK,CAAE,CAAC,EACtDU,EAAI,IAAI,YAAY,KAAK,KAAK,OAAQ,CAAC,EACvCA,EAAE,IAAIP,EAAG,GAAgB,EACzB,KAAK,GAAK,IAAI,WAAW,KAAK,KAAK,OAAQ,IAA6B,GAO5EO,EAAE,IAAuB,EAAI,EAC7BA,EAAE,IAAuB,EAAI,EAC7BA,EAAE,IAAuB,EAAI,EAC7B,KAAK,KAAOA,EACZ,KAAK,OAAS,EAClB,CAOA,SAASE,EAAW,CAChB,IAAMC,EAAS,KAAK,KAAK,IAAuB,EAAID,EACpD,GAAI,KAAK,OAASC,EAAQ,CACtB,GAAIA,EAAS,KAAK,SACd,MAAO,GAEX,IAAIC,EAAU,KAAK,OACnB,MAAQA,GAAW,GAAKD,GAAQ,CAEhC,GADAC,EAAU,KAAK,IAAIA,EAAS,KAAK,QAAQ,EACrCA,EAAUD,EACV,MAAO,GAEX,GAAIC,EAAU,KAAgC,KAAK,KAAK,OAAO,WAAY,CACvE,IAAMC,EAAW,KAAK,MAAMD,EAAU,KAAgC,KAAK,KAAK,OAAO,YAAc,KAAK,EAC1G,KAAK,KAAK,KAAKC,CAAQ,EACvB,KAAK,KAAO,IAAI,YAAY,KAAK,KAAK,OAAQ,CAAC,EAC/C,KAAK,GAAK,IAAI,WAAW,KAAK,KAAK,OAAQ,IAA6B,CAC5E,CACA,KAAK,OAASD,CAClB,CACA,MAAO,EACX,CAOA,IAAIE,EAAM,CACN,GAAI,CAAC,KAAK,OAAS,KAAK,OACpB,MAAO,GAEX,GAAI,KAAK,SAASA,EAAK,MAAM,EACzB,MAAO,GAEX,IAAMN,EAAI,KAAK,KACf,YAAK,GAAG,IAAIM,EAAMN,EAAE,IAAuB,CAAC,EAC5CA,EAAE,IAAuB,GAAKM,EAAK,OAE5BN,EAAE,IAAuB,EAAIA,EAAE,IAAuB,GAAK,OAC5D,KAAK,MAAM,QAAQ,IAAI,EACvB,CACV,CAKA,KAAM,CACF,YAAK,OAAS,GACP,KAAK,MACN,KAAK,MAAM,QAAQ,IAAI,EACvB,EACV,CAIA,IAAI,aAAc,CACd,OAAO,KAAK,MACN,KAAK,KAAK,IAAuB,EACjC,CACV,CAIA,IAAI,WAAY,CACZ,OAAO,KAAK,MACN,KAAK,SAAW,KAAK,KAAK,IAAuB,EACjD,CACV,CACJ,EACAZ,GAAQ,QAAUQ,KCjVlB,IAAAW,GAAAC,EAAAC,IAAA,cACA,OAAO,eAAeA,GAAS,aAAc,CAAE,MAAO,EAAK,CAAC,EAK5D,IAAMC,GAAmB,KACnBC,MAAoBD,GAAiB,QAA2D,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,84BAA84B,CAAkD,EAC3iCE,GAAN,KAAiB,CACb,YAAYC,EAAU,CAClB,KAAK,SAAWA,EAChB,KAAK,MAAQ,EACb,KAAK,OAAS,CAClB,CACA,OAAOC,EAAG,CACN,KAAK,MAAQA,EAAE,CAAC,GAAK,GAAKA,EAAE,CAAC,GAAK,GAAKA,EAAE,CAAC,GAAK,EAAIA,EAAE,CAAC,EACtD,KAAK,OAASA,EAAE,CAAC,GAAK,GAAKA,EAAE,CAAC,GAAK,GAAKA,EAAE,EAAE,GAAK,EAAIA,EAAE,EAAE,EACzD,IAAMC,EAAS,KAAK,MAAQ,KAAK,OAC3BC,EAAKD,EAAS,EACdE,EAAKH,EAAE,OAwBPI,EAAQ,KAAK,IAAIF,EAAIC,CAAE,GAAK,KAAK,IAAID,EAAIC,CAAE,GAAK,GAAK,KACtD,KAAK,MAID,KAAK,KAAK,OAAO,WAAaC,IACnC,KAAK,KAAK,KAAK,KAAK,MAAMA,EAAQ,KAAK,KAAK,OAAO,YAAc,KAAK,CAAC,EACvE,KAAK,GAAK,OALV,KAAK,KAAO,IAAI,YAAY,OAAO,CAAE,QAAS,KAAK,KAAKA,EAAQ,KAAK,CAAE,CAAC,EACxE,KAAK,MAAQP,GAAc,CAAE,IAAK,CAAE,OAAQ,KAAK,IAAK,CAAE,CAAC,GAMxD,KAAK,KACN,KAAK,GAAK,IAAI,WAAW,KAAK,KAAK,MAAM,GAG7C,IAAMQ,EAAU,KAAK,KAAK,OAAO,WAAaF,EAAM,KACpD,YAAK,GAAG,IAAIH,EAAGK,CAAM,EACrB,KAAK,MAAM,QAAQ,IAAIA,EAAQF,EAAIF,CAAM,EAClC,KAAK,GAAG,SAAS,KAAqB,KAAsBC,CAAE,CACzE,CACA,SAAU,CACD,KAAK,OAEN,KAAK,KAAK,OAAO,WAAa,KAAK,WACnC,KAAK,MAAQ,KAAK,GAAK,KAAK,KAAO,KAE3C,CACJ,EACAP,GAAQ,QAAUG,mGCpELQ,GAAA,OAAS,CACpB,WAAY,MACZ,aAAc,KACd,UAAW,MACX,MAAO,wtdCCT,IAAAC,EAAA,IACAC,EAAA,KAIA,SAASC,GAAaC,EAAS,CAC7B,GAAI,OAAO,OAAW,IACpB,OAAO,OAAO,KAAKA,EAAG,QAAQ,EAEhC,IAAMC,EAAa,KAAKD,CAAC,EACnBE,EAAS,IAAI,WAAWD,EAAW,MAAM,EAC/C,QAAS,EAAI,EAAG,EAAIC,EAAO,OAAQ,EAAE,EACnCA,EAAO,CAAC,EAAID,EAAW,WAAW,CAAC,EAErC,OAAOC,CACT,CAEA,IAAMC,GAAaJ,GAAaD,EAAA,OAAO,KAAK,EACxCM,EAGEC,GAAc,IAAI,YAIlBC,GAAN,KAAmB,CAAnB,aAAA,CACS,KAAA,YAAeC,GAAkB,EACjC,KAAA,YAAeC,GAAoB,CAO5C,CANS,YAAYD,EAAa,CAC9B,OAAO,KAAK,YAAYA,CAAK,CAC/B,CACO,YAAYC,EAAY,CAC7B,OAAO,KAAK,YAAYA,CAAI,CAC9B,GAKIC,GAA2C,CAC/C,YAAa,KAAO,MACpB,WAAYZ,EAAA,mBACZ,UAAWA,EAAA,mBACX,QAASA,EAAA,oBACT,aAAcC,EAAA,OAAO,aACrB,SAAU,IAQZ,SAAgBY,GAAaC,EAAsB,CACjD,IAAMC,EAAU,IAAIN,GACdO,EAAY,CAChB,IAAK,CACH,YAAaD,EAAQ,YAAY,KAAKA,CAAO,EAC7C,YAAaA,EAAQ,YAAY,KAAKA,CAAO,IAGjD,OAAO,YAAY,YAAYR,GAAeD,GAAYU,CAAS,EAChE,KAAMC,IACLV,EAAcA,GAAeU,EAAK,OAC3B,IAAIC,EAAQJ,EAAMG,EAAK,UAAYA,EAAMF,CAAO,EACxD,CACL,CAbAI,EAAA,aAAAN,GAgDA,IAAaK,EAAb,KAAoB,CAwGlB,YACEJ,EACAM,EACAC,EAAwB,CAGxB,GAvGM,KAAA,cAAgBpB,EAAA,OAAO,UAAY,EAEnC,KAAA,QAAuBO,GACvB,KAAA,YAAwB,CAAA,EACxB,KAAA,UAAY,EACZ,KAAA,UAAYP,EAAA,OAAO,UACnB,KAAA,YAAc,EACd,KAAA,eAAiB,EA+FvB,KAAK,MAAQ,OAAO,OAAO,CAAA,EAAIW,GAAiBE,CAAI,EAChD,KAAK,MAAM,aAAeb,EAAA,OAAO,aACnC,MAAM,IAAI,MAAM,+CAA+CA,EAAA,OAAO,YAAY,EAAE,EAEtF,GAAKmB,EASHC,EAAU,YAAc,KAAK,aAAa,KAAK,IAAI,EACnDA,EAAU,YAAc,KAAK,YAAY,KAAK,IAAI,MAVpC,CACd,IAAMC,EAASf,IAAgBA,EAAc,IAAI,YAAY,OAAOD,EAAU,GAC9Ec,EAAY,IAAI,YAAY,SAASE,EAAQ,CAC3C,IAAK,CACH,YAAa,KAAK,aAAa,KAAK,IAAI,EACxC,YAAa,KAAK,YAAY,KAAK,IAAI,GAE1C,EAKH,KAAK,UAAYF,EACjB,KAAK,MAAQ,KAAK,UAAU,QAC5B,KAAK,OAAS,IAAI,WAAW,KAAK,MAAM,OAAO,OAAQ,KAAK,MAAM,kBAAiB,EAAInB,EAAA,OAAO,UAAU,EACxG,KAAK,QAAU,IAAI,YAAY,KAAK,MAAM,OAAO,OAAQ,KAAK,MAAM,kBAAiB,EAAI,EAAE,EAC3F,KAAK,SAAW,IAAI,YAAY,KAAK,MAAM,OAAO,OAAQ,KAAK,MAAM,oBAAmB,EAAIA,EAAA,OAAO,YAAY,EAC/G,KAAK,SAAS,IAAI,KAAK,MAAM,OAAO,EACpC,KAAK,MAAQ,IAAI,YAAY,KAAK,MAAM,OAAO,OAAQ,KAAK,MAAM,eAAc,CAAE,EAClF,KAAK,MAAM,KAAKD,EAAA,mBAAoB,EAAG,KAAK,MAAM,aAAc,CAAC,CACnE,CApHA,IAAY,YAAU,CAAe,OAAO,KAAK,QAAQ,CAAC,CAAG,CAC7D,IAAY,WAAS,CAAa,OAAO,KAAK,QAAQ,CAAC,CAAG,CAC1D,IAAY,cAAY,CAAa,OAAO,KAAK,QAAQ,CAAC,CAAG,CAC7D,IAAY,eAAa,CAAa,OAAO,KAAK,QAAQ,CAAC,CAAG,CAC9D,IAAY,QAAM,CAAa,OAAO,KAAK,QAAQ,CAAC,EAAI,KAAK,QAAQ,CAAC,EAAI,EAAI,CAAG,CACjF,IAAY,SAAO,CAAa,OAAO,KAAK,QAAQ,CAAC,CAAG,CACxD,IAAY,QAAM,CAAa,OAAO,KAAK,QAAQ,CAAC,CAAG,CACvD,IAAY,OAAK,CAAgB,OAAO,KAAK,QAAQ,EAAE,CAAG,CAC1D,IAAY,eAAa,CAAa,OAAO,KAAK,QAAQ,EAAE,CAAG,CAEvD,YAAYW,EAAe,CACjC,GAAIA,IAAI,EAAmB,CACzB,IAAMY,EAAS,KAAK,MAAQ,KAAK,OACjC,GAAIA,EAAS,KAAK,QAAQ,OAAQ,CAChC,GAAI,KAAK,MAAM,aAAeA,EAAS,EAAI,KAAK,MAAM,YACpD,WAAK,QAAO,EACN,IAAI,MAAM,4BAA4B,EAE9C,KAAK,QAAU,IAAI,YAAYA,CAAM,EAEvC,KAAK,UAAY,KAAK,eACbZ,IAAI,EACb,GAAI,KAAK,SAAW,EAAG,CAErB,IAAMY,EAAS,KAAK,IAAI,KAAK,aAActB,EAAA,OAAO,SAAS,EAAI,KAAK,cACpE,GAAIsB,EAAS,KAAK,QAAQ,OAAQ,CAChC,GAAI,KAAK,MAAM,aAAeA,EAAS,EAAI,KAAK,MAAM,YACpD,WAAK,QAAO,EACN,IAAI,MAAM,4BAA4B,EAE9C,KAAK,QAAU,IAAI,YAAYA,CAAM,QAInC,KAAK,QAAQ,OAAS,QACxB,KAAK,QAAU,IAAI,YAAY,KAAK,GAI1C,MAAO,EACT,CAEQ,SAASC,EAAgBC,EAAwB,CACvD,IAAMF,EAASC,EAASC,EACxB,GAAIF,EAAS,KAAK,QAAQ,OAAQ,CAChC,GAAI,KAAK,MAAM,aAAeA,EAAS,EAAI,KAAK,MAAM,YACpD,WAAK,QAAO,EACN,IAAI,MAAM,4BAA4B,EAG9C,IAAMG,EAAY,IAAI,YAAY,KAAK,KAAKH,EAAS,KAAK,EAAI,KAAK,EACnEG,EAAU,IAAI,KAAK,OAAO,EAC1B,KAAK,QAAUA,EAEnB,CAEQ,aAAahB,EAAa,CAChC,IAAMiB,EAAM,KAAK,cACbH,EAAS,KAAK,YAClB,GAAI,KAAK,QAAK,EAAmB,CAC/B,IAAII,EAAY,KAAK,OAAS,KAAK,eAC/BC,EAAI,EACR,KAAOA,EAAI,GAAKD,EAAY,GAC1B,KAAK,QAAQ,IAAI,KAAK,MAAM,SAASD,EAAME,EAAGF,EAAME,EAAInB,CAAK,EAAGc,EAASd,EAAQmB,CAAC,EAClFA,IACAD,IAEF,KAAK,aAAelB,EAAQmB,EAC5B,KAAK,gBAAkBA,UACd,KAAK,QAAK,EAAmB,CACtC,KAAK,SAASL,EAAQd,EAAQ,CAAC,EAC/B,KAAK,UAAY,KAAK,IAAI,KAAK,UAAWA,CAAK,EAC/C,KAAK,UAAY,KAAK,IAAI,KAAK,UAAWA,CAAK,EAC/C,QAASoB,EAAI,EAAGA,EAAI,EAAG,EAAEA,EACvB,KAAK,QAAQ,IAAI,KAAK,MAAM,SAASH,EAAMG,EAAGH,EAAMG,EAAIpB,CAAK,EAAGc,EAASd,EAAQoB,CAAC,EAEpF,KAAK,YAAY,KAAKpB,CAAK,EAC3B,KAAK,aAAeA,EAAQ,EAC5B,KAAK,gBAAkB,EAEzB,MAAO,EACT,CA0CA,IAAW,OAAK,CACd,OAAO,KAAK,QAAK,EACb,KAAK,OACL,KAAK,IAAI,KAAK,UAAW,KAAK,MAAM,cAAa,CAAE,CACzD,CAOA,IAAW,QAAM,CACf,OAAO,KAAK,QAAK,EACb,KAAK,QACL,KAAK,MAAM,cAAa,EACtB,KAAK,YAAY,OAAS,EAAI,KAAK,MAAM,eAAc,EACvD,KAAK,YAAY,OAAS,CAClC,CAKA,IAAW,SAAO,CAChB,OAAO,KAAK,SAAS,SAAS,EAAG,KAAK,aAAa,CACrD,CAWA,IAAW,aAAW,CACpB,OAAO,KAAK,QAAQ,WAAa,KAAK,MAAM,OAAO,OAAO,WAAa,EAAI,KAAK,YAAY,MAC9F,CAKA,IAAW,YAAU,CACnB,MAAO,CACL,MAAO,KAAK,MACZ,OAAQ,KAAK,OACb,KAAM,KAAK,MACX,MAAO,KAAK,OACZ,SAAU,CAAC,CAAC,KAAK,UACjB,aAAc,KAAK,cACnB,UAAW,KAAK,WAChB,SAAU,KAAK,YACf,iBAAkB,CAChB,UAAW,KAAK,QAAQ,CAAC,EACzB,YAAa,KAAK,QAAQ,CAAC,EAC3B,MAAO,KAAK,aACZ,OAAQ,KAAK,eAGnB,CAOO,KACLqB,EAAsB,KAAK,MAAM,UACjCC,EAA8B,KAAK,MAAM,QACzCC,EAAuB,KAAK,MAAM,aAClCC,EAAoB,KAAK,MAAM,SAAQ,CAEvC,KAAK,MAAM,KAAK,KAAK,MAAM,WAAYH,EAAWE,EAAcC,EAAW,EAAI,CAAC,EAC5EF,GACF,KAAK,SAAS,IAAIA,EAAQ,SAAS,EAAG/B,EAAA,OAAO,YAAY,CAAC,EAE5D,KAAK,YAAY,OAAS,EAC1B,KAAK,UAAY,EACjB,KAAK,UAAYA,EAAA,OAAO,UACxB,KAAK,YAAc,EACnB,KAAK,eAAiB,CACxB,CAMO,OAAOkC,EAAsBC,EAAgB,EAAGC,EAAcF,EAAK,OAAM,CAC9E,IAAIG,EAAIF,EACR,KAAOE,EAAID,GAAK,CACd,IAAME,EAAS,KAAK,IAAIF,EAAMC,EAAGrC,EAAA,OAAO,UAAU,EAClD,KAAK,OAAO,IAAIkC,EAAK,SAASG,EAAGA,GAAKC,CAAM,CAAC,EAC7C,KAAK,MAAM,OAAO,EAAGA,CAAM,EAE/B,CAOO,aAAaJ,EAAcC,EAAgB,EAAGC,EAAcF,EAAK,OAAM,CAC5E,IAAIG,EAAIF,EACR,KAAOE,EAAID,GAAK,CACd,IAAME,EAAS,KAAK,IAAIF,EAAMC,EAAGrC,EAAA,OAAO,UAAU,EAClD,QAAS6B,EAAI,EAAGU,EAAIF,EAAGR,EAAIS,EAAQ,EAAET,EAAG,EAAEU,EACxC,KAAK,OAAOV,CAAC,EAAIK,EAAK,WAAWK,CAAC,EAEpCF,GAAKC,EACL,KAAK,MAAM,OAAO,EAAGA,CAAM,EAE/B,CAMA,IAAW,QAAM,CACf,GAAI,KAAK,QAAK,GAAqB,CAAC,KAAK,OAAS,CAAC,KAAK,OACtD,OAAO/B,GAIT,IAAMiC,EAAe,KAAK,MAAM,cAAa,EAE7C,GAAI,KAAK,QAAK,EAAmB,CAC/B,IAAIb,EAAY,KAAK,OAAS,KAAK,eACnC,GAAIA,EAAY,EAAG,CACjB,IAAMD,EAAM,KAAK,cACbH,EAAS,KAAK,YACdK,EAAI,EACR,KAAOA,EAAI,GAAKD,EAAY,GAC1B,KAAK,QAAQ,IAAI,KAAK,MAAM,SAASD,EAAME,EAAGF,EAAME,EAAIY,CAAY,EAAGjB,EAASiB,EAAeZ,CAAC,EAChGA,IACAD,IAEEA,GACF,KAAK,QAAQ,KAAK,KAAK,WAAYJ,EAASiB,EAAeZ,CAAC,EAGhE,OAAO,KAAK,QAAQ,SAAS,EAAG,KAAK,MAAQ,KAAK,MAAM,EAG1D,GAAI,KAAK,QAAK,EAAmB,CAC/B,GAAI,KAAK,YAAc,KAAK,UAAW,CACrC,IAAIa,EAAS,GACb,GAAID,EACF,GAAIA,IAAiB,KAAK,UACxBC,EAAS,OACJ,CACL,IAAMf,EAAM,KAAK,cACbH,EAAS,KAAK,YAClB,KAAK,SAASA,EAAQiB,EAAe,CAAC,EACtC,QAASX,EAAI,EAAGA,EAAI,EAAG,EAAEA,EACvB,KAAK,QAAQ,IAAI,KAAK,MAAM,SAASH,EAAMG,EAAGH,EAAMG,EAAIW,CAAY,EAAGjB,EAASiB,EAAeX,CAAC,EAItG,GAAI,CAACY,EACH,OAAO,KAAK,QAAQ,SAAS,EAAG,KAAK,MAAQ,KAAK,MAAM,EAM5D,IAAMC,EAAQ,IAAI,YAAY,KAAK,MAAQ,KAAK,MAAM,EACtDA,EAAM,KAAK,KAAK,UAAU,EAC1B,IAAIC,EAAc,EACdR,EAAQ,EACZ,QAASN,EAAI,EAAGA,EAAI,KAAK,YAAY,OAAQ,EAAEA,EAAG,CAChD,IAAMe,EAAK,KAAK,YAAYf,CAAC,EAC7B,QAASQ,EAAI,EAAGA,EAAI,EAAG,EAAEA,EACvBK,EAAM,IAAI,KAAK,QAAQ,SAASP,EAAOA,GAASS,CAAE,EAAGD,CAAW,EAChEA,GAAe,KAAK,MAIxB,GAAIH,EAAc,CAChB,IAAMd,EAAM,KAAK,cAEXmB,EAAgB,KAAK,MAAM,eAAc,EAC/C,QAAShB,EAAI,EAAGA,EAAIgB,EAAe,EAAEhB,EACnCa,EAAM,IAAI,KAAK,MAAM,SAAShB,EAAMG,EAAGH,EAAMG,EAAIW,CAAY,EAAGG,EAAc,KAAK,MAAQd,CAAC,EAGhG,OAAOa,EAIT,OAAOnC,EACT,CAMA,IAAW,OAAK,CACd,OAAO,IAAI,kBAAkB,KAAK,OAAO,OAAQ,EAAG,KAAK,MAAQ,KAAK,OAAS,CAAC,CAClF,CAcO,SAAO,CACZ,KAAK,QAAUA,GACf,KAAK,YAAY,OAAS,EAC1B,KAAK,UAAY,EACjB,KAAK,UAAYP,EAAA,OAAO,UAGxB,KAAK,MAAM,KAAKD,EAAA,mBAAoB,EAAG,KAAK,MAAM,aAAc,CAAC,CACnE,GAxWFmB,EAAA,QAAAD,EA2XC,SAAgB6B,GACfZ,EACArB,EAAsB,CAEtB,IAAMkC,EAAM,IAAI9B,EAAQJ,CAAI,EAC5B,OAAAkC,EAAI,KAAI,EACR,OAAOb,GAAS,SAAWa,EAAI,aAAab,CAAI,EAAIa,EAAI,OAAOb,CAAI,EAC5D,CACL,MAAOa,EAAI,MACX,OAAQA,EAAI,OACZ,OAAQA,EAAI,OACZ,MAAOA,EAAI,MAEf,CAbC7B,EAAA,OAAA4B,GAoBM,eAAeE,GACpBd,EACArB,EAAsB,CAEtB,IAAMkC,EAAM,MAAMnC,GAAaC,CAAI,EACnC,OAAAkC,EAAI,KAAI,EACR,OAAOb,GAAS,SAAWa,EAAI,aAAab,CAAI,EAAIa,EAAI,OAAOb,CAAI,EAC5D,CACL,MAAOa,EAAI,MACX,OAAQA,EAAI,OACZ,OAAQA,EAAI,OACZ,MAAOA,EAAI,MAEf,CAbA7B,EAAA,YAAA8B,KC7eO,SAASC,EAAaC,EAA6B,CACxD,MAAO,CAAE,QAASA,CAAG,CACvB,CAuBO,IAAMC,EAAN,KAA6C,CAA7C,cACL,KAAiB,aAAe,IAAI,IACpC,KAAQ,YAAc,GAEtB,IAAW,YAAsB,CAC/B,OAAO,KAAK,WACd,CAEO,IAA2BC,EAAS,CACzC,OAAI,KAAK,YACPA,EAAE,QAAQ,EAEV,KAAK,aAAa,IAAIA,CAAC,EAElBA,CACT,CAEO,SAAgB,CACrB,GAAI,MAAK,YAGT,MAAK,YAAc,GACnB,QAAWC,KAAK,KAAK,aACnBA,EAAE,QAAQ,EAEZ,KAAK,aAAa,MAAM,EAC1B,CAEO,OAAc,CACnB,QAAWA,KAAK,KAAK,aACnBA,EAAE,QAAQ,EAEZ,KAAK,aAAa,MAAM,CAC1B,CACF,EAEsBC,EAAf,KAAiD,CAAjD,cAGL,KAAmB,OAAS,IAAIH,EAEzB,SAAgB,CACrB,KAAK,OAAO,QAAQ,CACtB,CAEU,UAAiCC,EAAS,CAClD,OAAO,KAAK,OAAO,IAAIA,CAAC,CAC1B,CACF,EAZsBE,EACG,KAAoB,OAAO,OAAO,CAAE,SAAU,CAAE,CAAE,CAAC,EAarE,IAAMC,EAAN,KAAsE,CAAtE,cAEL,KAAQ,YAAc,GAEtB,IAAW,OAAuB,CAChC,OAAO,KAAK,YAAc,OAAY,KAAK,MAC7C,CAEA,IAAW,MAAMC,EAAsB,CACjC,KAAK,aAAeA,IAAU,KAAK,SAGvC,KAAK,QAAQ,QAAQ,EACrB,KAAK,OAASA,EAChB,CAEO,OAAc,CACnB,KAAK,MAAQ,MACf,CAEO,SAAgB,CACrB,KAAK,YAAc,GACnB,KAAK,QAAQ,QAAQ,EACrB,KAAK,OAAS,MAChB,CACF,EClGO,IAAMC,EAAN,KAAiB,CAAjB,cACL,KAAQ,WAAqD,CAAC,EAC9D,KAAQ,UAAY,GAGpB,IAAW,OAAmB,CAC5B,OAAI,KAAK,OACA,KAAK,QAEd,KAAK,OAAS,CAACC,EAAyBC,EAAgBC,IAAkD,CACxG,GAAI,KAAK,UACP,OAAOC,EAAa,IAAM,CAAC,CAAC,EAG9B,IAAMC,EAAQ,CAAE,GAAIJ,EAAU,SAAAC,CAAS,EACvC,KAAK,WAAa,KAAK,WAAW,MAAM,EACxC,KAAK,WAAW,KAAKG,CAAK,EAE1B,IAAMC,EAASF,EAAa,IAAM,CAChC,IAAMG,EAAM,KAAK,WAAW,QAAQF,CAAK,EACrCE,IAAQ,KACV,KAAK,WAAa,KAAK,WAAW,MAAM,EACxC,KAAK,WAAW,OAAOA,EAAK,CAAC,EAEjC,CAAC,EAED,OAAIJ,IACE,MAAM,QAAQA,CAAW,EAC3BA,EAAY,KAAKG,CAAM,EAEvBH,EAAY,IAAIG,CAAM,GAInBA,CACT,EACO,KAAK,OACd,CAEO,KAAKE,EAAgB,CAC1B,GAAI,KAAK,WAAa,CAAC,KAAK,WAAW,OACrC,OAEF,GAAI,KAAK,WAAW,SAAW,EAAG,CAChC,KAAK,WAAW,CAAC,EAAE,GAAG,KAAK,KAAK,WAAW,CAAC,EAAE,SAAUA,CAAK,EAC7D,MACF,CACA,IAAMC,EAAY,KAAK,WACvB,QAAS,EAAI,EAAGC,EAAMD,EAAU,OAAQ,EAAIC,EAAK,EAAE,EACjDD,EAAU,CAAC,EAAE,GAAG,KAAKA,EAAU,CAAC,EAAE,SAAUD,CAAK,CAErD,CAEO,SAAgB,CACjB,KAAK,YAGT,KAAK,UAAY,GACjB,KAAK,WAAW,OAAS,EAC3B,CACF,EAEiBG,OAAV,CACE,SAASC,EAAWC,EAAiBC,EAA6B,CACvE,OAAOD,EAAKE,GAAKD,EAAG,KAAKC,CAAC,CAAC,CAC7B,CAFOJ,EAAS,QAAAC,EAIT,SAASI,EAAUR,EAAkBQ,EAA6B,CACvE,MAAO,CAACf,EAAyBC,EAAgBC,IACxCK,EAAMS,GAAKhB,EAAS,KAAKC,EAAUc,EAAIC,CAAC,CAAC,EAAG,OAAWd,CAAW,CAE7E,CAJOQ,EAAS,IAAAK,EAQT,SAASE,KAAUC,EAAgC,CACxD,MAAO,CAAClB,EAAyBC,EAAgBC,IAAkD,CACjG,IAAMiB,EAAQ,IAAIC,EAClB,QAAWb,KAASW,EAClBC,EAAM,IAAIZ,EAAMO,GAAKd,EAAS,KAAKC,EAAUa,CAAC,CAAC,CAAC,EAElD,OAAIZ,IACE,MAAM,QAAQA,CAAW,EAC3BA,EAAY,KAAKiB,CAAK,EAEtBjB,EAAY,IAAIiB,CAAK,GAGlBA,CACT,CACF,CAfOT,EAAS,IAAAO,EAmBT,SAASI,EAAmBd,EAAkBe,EAAqCC,EAA0B,CAClH,OAAAD,EAAQC,CAAO,EACRhB,EAAMO,GAAKQ,EAAQR,CAAC,CAAC,CAC9B,CAHOJ,EAAS,gBAAAW,IAhCDX,KAAA,ICvEjB,IAAAc,GAA2B,OAgBpB,IAAMC,EAAN,MAAMC,UAAsBC,CAAkC,CAmDnE,YAAoBC,EAAyB,CAC3C,MAAM,EADY,eAAAA,EAhDpB,KAAQ,QAAU,IAAI,IAGtB,KAAQ,gBAAkB,KAAK,UAAU,IAAIC,CAAmB,EA+C9D,KAAK,SAAW,KAAK,UAAU,MAAM,KACrC,KAAK,UAAU,MAAM,KAAQC,GAA8B,CACzD,KAAK,UAAU,KAAK,KAAK,UAAU,MAAOA,CAAM,EAChD,KAAK,MAAM,CACb,EACI,KAAK,UAAU,MAAM,eACvB,KAAK,MAAM,EAGb,KAAK,gBAAgB,MAAQ,KAAK,UAAU,MAAM,eAAe,eAAeC,GAAU,CACpFA,IAAW,aACb,KAAK,cAAc,EACnB,KAAK,gBAAgB,YAAY,EAAG,KAAK,UAAU,IAAI,EAE3D,CAAC,EACD,KAAK,UAAUC,EAAa,IAAM,CAChC,KAAK,mBAAmB,EACxB,KAAK,mBAAmB,QAAQ,EAC5B,KAAK,UAAU,OAAS,KAAK,WAC/B,KAAK,UAAU,MAAM,KAAO,KAAK,SACjC,KAAK,SAAW,QAEd,KAAK,gBAAkB,KAAK,kBAC9B,KAAK,eAAe,YAAc,KAAK,gBACvC,KAAK,gBAAkB,QAEzB,KAAK,eAAiB,OACtB,KAAK,QAAQ,MAAM,EACnB,KAAK,oBAAoB,MAAM,EAC/B,KAAK,mBAAqB,OAC1B,KAAK,aAAe,MACtB,CAAC,CAAC,CACJ,CAnFA,IAAW,QAAwC,CAAE,OAAO,KAAK,QAAQ,IAAI,KAAK,GAAG,MAAQ,CAU7F,OAAc,aAAaC,EAAqCC,EAAeC,EAAmC,CAUhH,IAAMC,GAAUH,GAAiB,UAAU,cAAc,QAAQ,EACjE,OAAAG,EAAO,MAAQF,EAAQ,EACvBE,EAAO,OAASD,EAAS,EAClBC,CACT,CAGA,OAAc,gBAAgBC,EAA+BH,EAAeC,EAAgBG,EAAiC,CAC3H,GAAI,OAAO,WAAc,WAAY,CACnC,IAAMC,EAAUF,EAAI,gBAAgBH,EAAOC,CAAM,EACjD,OAAIG,GACFC,EAAQ,KAAK,IAAI,IAAI,kBAAkBD,EAAQ,EAAGJ,EAAQC,EAAS,CAAC,CAAC,EAEhEI,CACT,CACA,OAAOD,EACH,IAAI,UAAU,IAAI,kBAAkBA,EAAQ,EAAGJ,EAAQC,EAAS,CAAC,EAAGD,EAAOC,CAAM,EACjF,IAAI,UAAUD,EAAOC,CAAM,CACjC,CAGA,OAAc,kBAAkBK,EAA0D,CACxF,OAAI,OAAO,mBAAsB,WACxB,QAAQ,QAAQ,MAAS,EAE3B,kBAAkBA,CAAG,CAC9B,CA0CO,gBAAgBC,EAAsB,CACvCA,EACE,CAAC,KAAK,cAAgB,KAAK,SAAS,SAAW,IACjD,KAAK,mBAAmB,KAAK,IAAI,KAAK,SAAS,OAAS,EAAG,EAA4B,CAAC,GAG1F,KAAK,oBAAoB,MAAM,EAC/B,KAAK,mBAAqB,OAC1B,KAAK,aAAe,QAEtB,KAAK,gBAAgB,YAAY,EAAG,KAAK,UAAU,IAAI,CACzD,CAMA,IAAW,YAA4C,CACrD,OAAO,KAAK,UAAU,UACxB,CAKA,IAAW,UAAsB,CAC/B,MAAO,CACL,MAAO,KAAK,YAAY,IAAI,KAAK,OAAS,GAC1C,OAAQ,KAAK,YAAY,IAAI,KAAK,QAAU,EAC9C,CACF,CAKO,WAAWC,EAAeC,EAAaC,EAA0B,CACtE,IAAMC,EAAIH,GAAS,KAAK,YAAY,IAAI,KAAK,QAAU,GACjDI,EAAI,KAAK,YAAY,IAAI,OAAO,OAAS,EACzCC,GAAKJ,EAAM,EAAID,IAAU,KAAK,YAAY,IAAI,KAAK,QAAU,IAC/D,CAACE,GAASA,IAAU,QACtB,KAAK,QAAQ,IAAI,KAAK,GAAG,UAAU,EAAGC,EAAGC,EAAGC,CAAC,GAE3C,CAACH,GAASA,IAAU,WACtB,KAAK,QAAQ,IAAI,QAAQ,GAAG,UAAU,EAAGC,EAAGC,EAAGC,CAAC,CAEpD,CAKO,SAASH,EAA0B,CACxC,GAAI,CAACA,GAASA,IAAU,MAAO,CAC7B,IAAMP,EAAM,KAAK,QAAQ,IAAI,KAAK,EAClCA,GAAK,UAAU,EAAG,EAAGA,EAAI,OAAO,MAAOA,EAAI,OAAO,MAAM,CAC1D,CACA,GAAI,CAACO,GAASA,IAAU,SAAU,CAChC,IAAMP,EAAM,KAAK,QAAQ,IAAI,QAAQ,EACrCA,GAAK,UAAU,EAAG,EAAGA,EAAI,OAAO,MAAOA,EAAI,OAAO,MAAM,CAC1D,CACF,CAKO,KAAKW,EAAqBC,EAAgBC,EAAaC,EAAaC,EAAgB,EAAS,CAClG,IAAMf,EAAM,KAAK,QAAQ,IAAIW,EAAQ,KAAK,EAC1C,GAAI,CAACX,EACH,OAEF,GAAM,CAAE,MAAAH,EAAO,OAAAC,CAAO,EAAI,KAAK,SAG/B,GAAID,IAAU,IAAMC,IAAW,GAC7B,OAGF,KAAK,cAAca,EAASd,EAAOC,CAAM,EACzC,IAAMK,EAAMQ,EAAQ,OACd,CAAE,MAAOK,EAAa,OAAQC,CAAa,EAAIN,EAAQ,eACvDO,EAAO,KAAK,KAAKf,EAAI,MAAQa,CAAW,EAExCG,EAAMP,EAASM,EAAQF,EACvBI,EAAK,KAAK,MAAMR,EAASM,CAAI,EAAID,EACjCI,EAAKR,EAAMhB,EACXyB,EAAKR,EAAMhB,EAGXyB,EAAaR,EAAQC,EAAcG,EAAKhB,EAAI,MAAQA,EAAI,MAAQgB,EAAKJ,EAAQC,EAC7EQ,EAAcJ,EAAKH,EAAed,EAAI,OAASA,EAAI,OAASiB,EAAKH,EAMvEjB,EAAI,UACFG,EACA,KAAK,MAAMgB,CAAE,EAAG,KAAK,MAAMC,CAAE,EAAG,KAAK,KAAKG,CAAU,EAAG,KAAK,KAAKC,CAAW,EAC5E,KAAK,MAAMH,CAAE,EAAG,KAAK,MAAMC,CAAE,EAAG,KAAK,KAAKC,EAAa1B,EAAQmB,CAAW,EAAG,KAAK,KAAKQ,EAAc1B,EAASmB,CAAY,CAC5H,CACF,CAKO,YAAYN,EAAqBC,EAA+C,CACrF,GAAM,CAAE,MAAAf,EAAO,OAAAC,CAAO,EAAI,KAAK,SAE/B,GAAID,IAAU,IAAMC,IAAW,GAC7B,OAEF,KAAK,cAAca,EAASd,EAAOC,CAAM,EACzC,IAAMK,EAAMQ,EAAQ,OACd,CAAE,MAAOK,EAAa,OAAQC,CAAa,EAAIN,EAAQ,eACvDO,EAAO,KAAK,KAAKf,EAAI,MAAQa,CAAW,EACxCG,EAAMP,EAASM,EAAQF,EACvBI,EAAK,KAAK,MAAMR,EAASM,CAAI,EAAID,EACjCM,EAAaP,EAAcG,EAAKhB,EAAI,MAAQA,EAAI,MAAQgB,EAAKH,EAC7DQ,EAAcJ,EAAKH,EAAed,EAAI,OAASA,EAAI,OAASiB,EAAKH,EAEjElB,EAASV,EAAc,aAAa,KAAK,SAAU,KAAK,KAAKkC,EAAa1B,EAAQmB,CAAW,EAAG,KAAK,KAAKQ,EAAc1B,EAASmB,CAAY,CAAC,EAC9IjB,EAAMD,EAAO,WAAW,IAAI,EAClC,GAAIC,EACF,OAAAA,EAAI,UACFG,EACA,KAAK,MAAMgB,CAAE,EAAG,KAAK,MAAMC,CAAE,EAAG,KAAK,MAAMG,CAAU,EAAG,KAAK,MAAMC,CAAW,EAC9E,EAAG,EAAGzB,EAAO,MAAOA,EAAO,MAC7B,EACOA,CAEX,CAKO,gBAAgBc,EAAaC,EAAaC,EAAgB,EAAS,CACxE,IAAMf,EAAM,KAAK,QAAQ,IAAI,KAAK,EAClC,GAAIA,EAAK,CACP,GAAM,CAAE,MAAAH,EAAO,OAAAC,CAAO,EAAI,KAAK,SAY/B,GATID,IAAU,IAAMC,IAAW,KAI1B,KAAK,aAECA,GAAU,KAAK,aAAc,QACtC,KAAK,mBAAmBA,EAAS,CAAC,EAFlC,KAAK,mBAAmB,KAAK,IAAIA,EAAS,EAAG,EAA4B,CAAC,EAIxE,CAAC,KAAK,cAAc,OACxBE,EAAI,UACF,KAAK,oBAAsB,KAAK,aAChCa,EAAMhB,EACLiB,EAAMhB,EAAU,EAAI,EAAI,EACzBD,EAAQkB,EACRjB,EACAe,EAAMhB,EACNiB,EAAMhB,EACND,EAAQkB,EACRjB,CACF,CACF,CACF,CAMO,eAAsB,CAC3B,IAAMW,EAAI,KAAK,YAAY,IAAI,OAAO,OAAS,EACzCC,EAAI,KAAK,YAAY,IAAI,OAAO,QAAU,EAChD,QAAWV,KAAO,KAAK,QAAQ,OAAO,GAChCA,EAAI,OAAO,QAAUS,GAAKT,EAAI,OAAO,SAAWU,KAClDV,EAAI,OAAO,MAAQS,EACnBT,EAAI,OAAO,OAASU,EAG1B,CAKQ,cAAce,EAAkBC,EAAsBC,EAA6B,CACzF,GAAID,IAAiBD,EAAK,eAAe,OAASE,IAAkBF,EAAK,eAAe,OACtF,OAEF,GAAM,CAAE,MAAOG,EAAe,OAAQC,CAAe,EAAIJ,EAAK,aAC9D,GAAIC,IAAiBE,GAAiBD,IAAkBE,EAAgB,CACtEJ,EAAK,OAASA,EAAK,KACnBA,EAAK,eAAe,MAAQG,EAC5BH,EAAK,eAAe,OAASI,EAC7B,MACF,CACA,IAAMC,EAAc,KAAK,KAAKL,EAAK,KAAM,MAAQC,EAAeE,CAAa,EACvEG,EAAe,KAAK,KAAKN,EAAK,KAAM,OAASE,EAAgBE,CAAc,EAEjF,GAAIC,EAAcC,EAAeN,EAAK,KAAM,MAAQA,EAAK,KAAM,OAAQ,CACrEA,EAAK,OAASA,EAAK,KACnBA,EAAK,eAAe,MAAQG,EAC5BH,EAAK,eAAe,OAASI,EAC7B,MACF,CACA,IAAM9B,EAASV,EAAc,aAAa,KAAK,SAAUyC,EAAaC,CAAY,EAC5E/B,EAAMD,EAAO,WAAW,IAAI,EAC9BC,IACFA,EAAI,UAAUyB,EAAK,KAAO,EAAG,EAAG1B,EAAO,MAAOA,EAAO,MAAM,EAC3D0B,EAAK,OAAS1B,EACd0B,EAAK,eAAe,MAAQC,EAC5BD,EAAK,eAAe,OAASE,EAEjC,CAKQ,OAAc,CACpB,KAAK,eAAiB,KAAK,UAAU,MAAM,eAC3C,KAAK,gBAAkB,KAAK,eAAe,YAAY,KAAK,KAAK,cAAc,EAC/E,KAAK,eAAe,YAAeK,GAAkB,CACnD,QAAWC,IAAO,CAAC,GAAG,KAAK,QAAQ,KAAK,CAAC,EACvC,KAAK,mBAAmBA,CAAG,EAE7B,KAAK,iBAAiB,KAAK,KAAK,eAAgBD,CAAQ,CAC1D,CACF,CAEO,iBAAiBzB,EAAoB,MAAa,CAEvD,GAAI,CAAC,KAAK,UAAY,CAAC,KAAK,UAAU,MAAM,cAAe,CACzD,QAAQ,KAAK,oFAAoF,EACjG,MACF,CACA,GAAI,KAAK,QAAQ,IAAIA,CAAK,EACxB,OAEF,IAAMR,EAASV,EAAc,aAC3B,KAAK,SAAU,KAAK,YAAY,IAAI,OAAO,OAAS,EACpD,KAAK,YAAY,IAAI,OAAO,QAAU,CACxC,EACAU,EAAO,UAAU,IAAI,qBAAqBQ,CAAK,EAAE,EACjD,IAAM2B,EAAgB,KAAK,UAAU,MAAM,cAI3CA,EAAc,MAAM,UAAY,UAC5B3B,IAAU,UAKZR,EAAO,MAAM,OAAS,KACtBmC,EAAc,aAAanC,EAAQmC,EAAc,UAAU,IAK3DnC,EAAO,MAAM,OAAS,IACtBmC,EAAc,YAAYnC,CAAM,GAElC,IAAMC,EAAMD,EAAO,WAAW,KAAM,CAAE,MAAO,EAAK,CAAC,EACnD,GAAI,CAACC,EAAK,CACRD,EAAO,OAAO,EACd,MACF,CACA,KAAK,QAAQ,IAAIQ,EAAOP,CAAG,EAC3B,KAAK,SAASO,CAAK,CACrB,CAEO,mBAAmBA,EAAoB,MAAa,CACzD,IAAMP,EAAM,KAAK,QAAQ,IAAIO,CAAK,EAC9BP,IACFA,EAAI,OAAO,OAAO,EAClB,KAAK,QAAQ,OAAOO,CAAK,EAE7B,CAEO,SAASA,EAA4B,CAC1C,OAAO,KAAK,QAAQ,IAAIA,CAAK,CAC/B,CAEQ,mBAAmBT,EAAiB,GAAoC,CAC9E,KAAK,oBAAoB,MAAM,EAC/B,KAAK,mBAAqB,OAG1B,IAAMqC,EAAS,GACTC,EAAY/C,EAAc,aAAa,KAAK,SAAU8C,EAAQrC,CAAM,EACpEE,EAAMoC,EAAU,WAAW,KAAM,CAAE,MAAO,EAAM,CAAC,EACvD,GAAI,CAACpC,EAAK,OACV,IAAME,EAAUb,EAAc,gBAAgBW,EAAKmC,EAAQrC,CAAM,EAC3DuC,EAAM,IAAI,YAAYnC,EAAQ,KAAK,MAAM,EACzCoC,KAAQ,eAAW,EAAG,EAAG,CAAC,EAC1BC,KAAQ,eAAW,IAAK,IAAK,GAAG,EACtCF,EAAI,KAAKC,CAAK,EACd,QAAS9B,EAAI,EAAGA,EAAIV,EAAQ,EAAEU,EAAG,CAC/B,IAAMgC,EAAQhC,EAAI,EACZiC,EAASjC,EAAI2B,EACnB,QAASO,EAAI,EAAGA,EAAIP,EAAQO,GAAK,EAC/BL,EAAII,EAASC,EAAIF,CAAK,EAAID,CAE9B,CACAvC,EAAI,aAAaE,EAAS,EAAG,CAAC,EAG9B,IAAML,EAAS,OAAO,MAAQsC,EAAS,EAAK,EAAEA,EAAS,IAAM,KAC7D,KAAK,aAAe9C,EAAc,aAAa,KAAK,SAAUQ,EAAOC,CAAM,EAC3E,IAAM6C,EAAO,KAAK,aAAa,WAAW,KAAM,CAAE,MAAO,EAAM,CAAC,EAChE,GAAI,CAACA,EAAM,CACT,KAAK,aAAe,OACpB,MACF,CACA,QAASC,EAAI,EAAGA,EAAI/C,EAAO+C,GAAKT,EAC9BQ,EAAK,UAAUP,EAAWQ,EAAG,CAAC,EAEhC,IAAMC,EAAc,KAAK,aACzBxD,EAAc,kBAAkBwD,CAAW,EAAE,KAAKC,GAAU,CACtD,KAAK,eAAiBD,EAAaC,GAAQ,MAAM,EAChD,KAAK,mBAAqBA,CACjC,CAAC,EAAE,MAAM,IAAM,CAAC,CAAC,CACnB,CAEA,IAAW,UAAiC,CAC1C,OAAO,KAAK,UAAU,MAAM,qBAAqB,OAAO,QAC1D,CACF,ECnaO,IAAMC,EAA+B,CAC1C,MAAO,EACP,OAAQ,EACV,EAQMC,EAAN,MAAMC,CAAkD,CAqDtD,YACEC,EAAc,EACdC,EAAgB,EACTC,EAAU,GACVC,EAAS,GAChB,CAFO,aAAAD,EACA,YAAAC,EAxDT,KAAQ,KAAe,EA4CvB,KAAQ,OAAiB,EAcvB,KAAK,KAAOH,EACZ,KAAK,OAASC,CAChB,CA3DA,IAAW,KAAc,CACvB,OAAI,KAAK,OAEJ,KAAK,KAAO,WACZ,KAAK,gBAAkB,GAGrB,KAAK,IACd,CACA,IAAW,IAAIG,EAAe,CAAE,KAAK,KAAOA,CAAO,CAEnD,IAAW,gBAAiC,CAE1C,OAAI,KAAK,UAGD,KAAK,KAAO,YAA6B,EACnD,CACA,IAAW,eAAeA,EAAuB,CAC/C,KAAK,MAAQ,WACb,KAAK,MAASA,GAAS,GAAM,SAC/B,CAEA,IAAW,gBAAyB,CAClC,OAAO,KAAK,KAAQ,QACtB,CACA,IAAW,eAAeA,EAAe,CACvC,KAAK,MAAQ,UACb,KAAK,MAAQA,EAAS,QACxB,CAEA,IAAW,wBAAiC,CAC1C,IAAMC,GAAO,KAAK,KAAO,aAA4B,GACrD,OAAIA,EAAM,EACDA,EAAM,WAERA,CACT,CACA,IAAW,uBAAuBD,EAAe,CAC/C,KAAK,MAAQ,UACb,KAAK,MAASA,GAAS,GAAM,UAC/B,CAGA,IAAW,OAAgB,CACzB,OAAO,KAAK,MACd,CACA,IAAW,MAAMA,EAAe,CAC9B,KAAK,OAASA,CAChB,CAYO,OAA6B,CASlC,OAAO,IAAIL,EAAmB,KAAK,KAAM,KAAK,OAAQ,KAAK,QAAS,KAAK,MAAM,CACjF,CAEO,SAAmB,CACxB,OAAO,KAAK,iBAAmB,GAAuB,KAAK,SAAW,GAAK,KAAK,UAAY,EAC9F,CACF,EACMO,EAAc,IAAIR,EAUXS,EAAN,KAA0C,CAkB/C,YACUC,EACAC,EACAC,EACR,CAHQ,eAAAF,EACA,eAAAC,EACA,WAAAC,EAnBV,KAAQ,QAAmC,IAAI,IAE/C,KAAQ,QAAU,EAElB,KAAQ,UAAY,EAEpB,KAAQ,cAAgB,GAExB,KAAQ,gBAAkB,GAE1B,KAAQ,YAAsB,KAW5B,GAAI,CACF,KAAK,SAAS,KAAK,MAAM,YAAY,CACvC,OAASC,EAAY,CACfA,aAAa,OACf,QAAQ,MAAMA,EAAE,OAAO,EAEzB,QAAQ,KAAK,0BAA0B,KAAK,SAAS,CAAC,KAAK,CAC7D,CACA,KAAK,iBAAmB,CACtB,KAAM,KAAK,UAAU,KACrB,KAAM,KAAK,UAAU,IACvB,CACF,CAEO,SAAgB,CACrB,KAAK,MAAM,CACb,CAEO,OAAc,CACnB,QAAWC,KAAQ,KAAK,QAAQ,OAAO,EACrCA,EAAK,QAAQ,QAAQ,EAIvB,KAAK,QAAQ,MAAM,EACnB,KAAK,UAAU,SAAS,CAC1B,CAEO,UAAmB,CACxB,OAAO,KAAK,YAAc,EAAI,GAChC,CAEO,SAASR,EAAqB,CACnC,GAAIA,EAAQ,IAAOA,EAAQ,IACzB,MAAM,WAAW,mEAAmE,EAEtF,KAAK,YAAeA,EAAQ,EAAI,MAAa,EAC7C,KAAK,aAAa,CAAC,CACrB,CAEO,UAAmB,CACxB,OAAO,KAAK,iBAAiB,EAAI,EAAI,GACvC,CAEQ,kBAA2B,CACjC,IAAIS,EAAe,EACnB,QAAWD,KAAQ,KAAK,QAAQ,OAAO,EACjCA,EAAK,OACPC,GAAgBD,EAAK,KAAK,MAAQA,EAAK,KAAK,OACxCA,EAAK,QAAUA,EAAK,SAAWA,EAAK,OACtCC,GAAgBD,EAAK,OAAO,MAAQA,EAAK,OAAO,SAItD,OAAOC,CACT,CAEQ,QAAQC,EAAkB,CAChC,IAAMF,EAAO,KAAK,QAAQ,IAAIE,CAAE,EAC3BF,IACL,KAAK,QAAQ,OAAOE,CAAE,EAElB,OAAO,aAAeF,EAAK,gBAAgB,aAC7CA,EAAK,KAAK,MAAM,EAElB,KAAK,iBAAiBE,CAAE,EAC1B,CAKO,eAAsB,CAE3B,IAAMC,EAAO,CAAC,EACd,OAAW,CAACD,EAAIF,CAAI,IAAK,KAAK,QAAQ,QAAQ,EACxCA,EAAK,aAAe,cACtBA,EAAK,QAAQ,QAAQ,EACrBG,EAAK,KAAKD,CAAE,GAGhB,QAAWA,KAAMC,EACf,KAAK,QAAQD,CAAE,EAGjB,KAAK,gBAAkB,GACvB,KAAK,cAAgB,EACvB,CAMO,YAAYA,EAAkB,CACnC,IAAMF,EAAO,KAAK,QAAQ,IAAIE,CAAE,EAC5BF,IACFA,EAAK,QAAQ,QAAQ,EACrB,KAAK,QAAQE,CAAE,EAEnB,CAaO,SAASE,EAAsCC,EAA6B,CAEjF,KAAK,aAAaD,EAAI,MAAQA,EAAI,MAAM,EAGxC,IAAIE,EAAW,KAAK,UAAU,UAC1BA,EAAS,QAAU,IAAMA,EAAS,SAAW,MAC/CA,EAAWrB,GAEb,IAAMsB,EAAO,KAAK,KAAKH,EAAI,MAAQE,EAAS,KAAK,EAC3CE,EAAO,KAAK,KAAKJ,EAAI,OAASE,EAAS,MAAM,EAE7ChB,EAAU,EAAE,KAAK,QAEjBmB,EAAS,KAAK,UAAU,MAAM,OAC9BC,EAAW,KAAK,UAAU,KAC1BC,EAAW,KAAK,UAAU,KAC1BC,EAAUH,EAAO,EACjBI,EAAUJ,EAAO,EACnBK,EAASF,EACTG,EAAY,EAEXV,EAAK,YACRI,EAAO,EAAI,EACXA,EAAO,EAAI,EACXK,EAAS,GAGX,KAAK,UAAU,MAAM,cAAc,iBAAiB,UAAUL,EAAO,CAAC,EACtE,QAASO,EAAM,EAAGA,EAAMR,EAAM,EAAEQ,EAAK,CACnC,IAAMC,EAAOR,EAAO,MAAM,IAAIA,EAAO,EAAIA,EAAO,KAAK,EACrD,QAASS,EAAM,EAAGA,EAAMX,GAClB,EAAAO,EAASI,GAAOR,GADQ,EAAEQ,EAE9B,KAAK,aAAaD,EAAwBH,EAASI,EAAK5B,EAAS0B,EAAMT,EAAOW,CAAG,EACjFH,IAEF,GAAIV,EAAK,UACHW,EAAMR,EAAO,GAAG,KAAK,UAAU,MAAM,cAAc,SAAS,UAE5D,EAAEC,EAAO,GAAKE,EAAU,MAE9BF,EAAO,EAAIK,CACb,CACA,KAAK,UAAU,MAAM,cAAc,iBAAiB,UAAUL,EAAO,CAAC,EAGlEJ,EAAK,UACHA,EAAK,YAAc,MACrBI,EAAO,EAAI,KAAK,IAAIK,EAASP,EAAMG,CAAQ,EAE3CD,EAAO,EAAIK,GAGbL,EAAO,EAAIG,EACXH,EAAO,EAAII,GAIb,IAAMV,EAAO,CAAC,EACd,OAAW,CAACD,EAAIF,CAAI,IAAK,KAAK,QAAQ,QAAQ,EACxCA,EAAK,UAAY,IACnBA,EAAK,QAAQ,QAAQ,EACrBG,EAAK,KAAKD,CAAE,GAGhB,QAAWA,KAAMC,EACf,KAAK,QAAQD,CAAE,EAKjB,IAAMiB,EAAY,KAAK,UAAU,eAAe,CAAC,EACjDA,GAAW,UAAU,IAAM,CACZ,KAAK,QAAQ,IAAI7B,CAAO,GAEnC,KAAK,QAAQA,CAAO,CAExB,CAAC,EAIG,KAAK,UAAU,OAAO,OAAO,OAAS,aACxC,KAAK,kBAAkB,EAIzB,IAAM8B,EAAsB,CAC1B,KAAMhB,EACN,aAAcE,EACd,OAAQF,EACR,eAAgB,CAAE,GAAGE,CAAS,EAC9B,OAAQa,GAAa,OACrB,UAAAJ,EACA,WAAY,KAAK,UAAU,OAAO,OAAO,KACzC,MAAOV,EAAK,MACZ,OAAQA,EAAK,MACf,EAGA,YAAK,QAAQ,IAAIf,EAAS8B,CAAO,EACjC,KAAK,eAAe,EACb9B,CACT,CAQO,OAAO+B,EAA6C,CAEzD,IAAIC,EAAe,GACfC,EAAkB,GACtB,QAAWvB,KAAQ,KAAK,QAAQ,OAAO,EAMrC,GALIA,EAAK,QAAU,SACjBuB,EAAkB,GAElBD,EAAe,GAEbA,GAAgBC,EAAiB,MAIvC,GAAID,GAAgB,CAAC,KAAK,UAAU,SAAS,KAAK,IAChD,KAAK,UAAU,iBAAiB,KAAK,EACjC,CAAC,KAAK,UAAU,SAAS,KAAK,GAAG,OAUvC,GARIC,GAAmB,CAAC,KAAK,UAAU,SAAS,QAAQ,GACtD,KAAK,UAAU,iBAAiB,QAAQ,EAI1C,KAAK,UAAU,cAAc,EAGzB,CAAC,KAAK,QAAQ,KAAM,CACjB,KAAK,gBACR,KAAK,UAAU,SAAS,EACxB,KAAK,cAAgB,GACrB,KAAK,gBAAkB,IAErB,KAAK,UAAU,SAAS,KAAK,GAC/B,KAAK,UAAU,mBAAmB,KAAK,EAErC,KAAK,UAAU,SAAS,QAAQ,GAClC,KAAK,UAAU,mBAAmB,QAAQ,EAE5C,MACF,CAGI,CAACD,GAAgB,KAAK,UAAU,SAAS,KAAK,IAChD,KAAK,UAAU,SAAS,KAAK,EAC7B,KAAK,UAAU,mBAAmB,KAAK,GAErC,CAACC,GAAmB,KAAK,UAAU,SAAS,QAAQ,IACtD,KAAK,UAAU,SAAS,QAAQ,EAChC,KAAK,UAAU,mBAAmB,QAAQ,GAIxC,KAAK,kBACP,KAAK,UAAU,SAAS,EACxB,KAAK,cAAgB,GACrB,KAAK,gBAAkB,IAGzB,GAAM,CAAE,MAAAC,EAAO,IAAAC,CAAI,EAAIJ,EACjBZ,EAAS,KAAK,UAAU,MAAM,OAC9BF,EAAO,KAAK,UAAU,MAAM,KAGlC,KAAK,UAAU,WAAWiB,EAAOC,CAAG,EAGpC,IAAMC,EAAgG,CAAC,EACjGC,EAAkE,CAAC,EAGzE,QAASX,EAAMQ,EAAOR,GAAOS,EAAK,EAAET,EAAK,CACvC,IAAMC,EAAOR,EAAO,MAAM,IAAIO,EAAMP,EAAO,KAAK,EAChD,GAAI,CAACQ,EAAM,OACX,QAASC,EAAM,EAAGA,EAAMX,EAAM,EAAEW,EAC9B,GAAID,EAAK,MAAMC,CAAG,EAAI,UAAsB,CAC1C,IAAInB,EAAyBkB,EAAK,eAAeC,CAAG,GAAKxB,EACnDJ,EAAUS,EAAE,QAClB,GAAIT,IAAY,QAAaA,IAAY,GACvC,SAEF,IAAM8B,EAAU,KAAK,QAAQ,IAAI9B,CAAO,EACxC,GAAIS,EAAE,SAAW,GAAI,CACnB,IAAM6B,EAAY7B,EAAE,OACd8B,EAAWX,EACbY,EAAQ,EAOZ,KACE,EAAEZ,EAAMX,GACJU,EAAK,MAAMC,CAAG,EAAI,YAClBnB,EAAIkB,EAAK,eAAeC,CAAG,GAAKxB,IAChCK,EAAE,UAAYT,GACdS,EAAE,SAAW6B,EAAYE,GAE7BA,IAEFZ,IACIE,EACEA,EAAQ,QACVM,EAAU,KAAK,CAAE,QAAAN,EAAS,OAAQQ,EAAW,IAAKC,EAAU,IAAAb,EAAK,MAAAc,CAAM,CAAC,EAEjE,KAAK,MAAM,iBACpBH,EAAiB,KAAK,CAAE,IAAKE,EAAU,IAAAb,EAAK,MAAAc,CAAM,CAAC,EAErD,KAAK,cAAgB,EACvB,CACF,CAEJ,CAGAJ,EAAU,KAAK,CAACK,EAAGC,IAAMD,EAAE,QAAQ,OAASC,EAAE,QAAQ,MAAM,EAG5D,QAAWC,KAAQN,EACjB,KAAK,UAAU,gBAAgBM,EAAK,IAAKA,EAAK,IAAKA,EAAK,KAAK,EAI/D,QAAWA,KAAQP,EACjB,KAAK,UAAU,KAAKO,EAAK,QAASA,EAAK,OAAQA,EAAK,IAAKA,EAAK,IAAKA,EAAK,KAAK,CAEjF,CAEO,eAAeC,EAA+C,CAEnE,GAAI,CAAC,KAAK,QAAQ,KAAM,CACtB,KAAK,iBAAmBA,EACxB,MACF,CAIA,GAAI,KAAK,iBAAiB,MAAQA,EAAQ,KAAM,CAC9C,KAAK,iBAAmBA,EACxB,MACF,CAGA,IAAMzB,EAAS,KAAK,UAAU,MAAM,OAC9BD,EAAOC,EAAO,MAAM,OACpB0B,EAAS,KAAK,iBAAiB,KAAO,EAC5C,QAASnB,EAAM,EAAGA,EAAMR,EAAM,EAAEQ,EAAK,CACnC,IAAMC,EAAOR,EAAO,MAAM,IAAIO,CAAG,EACjC,GAAIC,EAAK,MAAMkB,CAAM,EAAI,UAAsB,CAC7C,IAAMpC,EAAyBkB,EAAK,eAAekB,CAAM,GAAKzC,EACxDJ,EAAUS,EAAE,QAClB,GAAIT,IAAY,QAAaA,IAAY,GACvC,SAEF,IAAM8B,EAAU,KAAK,QAAQ,IAAI9B,CAAO,EACxC,GAAI,CAAC8B,EACH,SAGF,IAAMgB,EAAc,KAAK,MAAMhB,EAAQ,QAAQ,OAAS,GAAKA,EAAQ,eAAe,KAAK,EACzF,GAAKrB,EAAE,OAASqC,EAAe,GAAKA,EAClC,SAGF,IAAIC,EAAU,GACd,QAASC,EAAWH,EAAS,EAAGG,EAAWJ,EAAQ,KAAM,EAAEI,EACzD,GAAIrB,EAAK,MAAMqB,EAAW,EAAY,CAAY,EAAI,QAA0B,CAC9ED,EAAU,GACV,KACF,CAEF,GAAIA,EACF,SAGF,IAAMZ,EAAM,KAAK,IAAIS,EAAQ,KAAME,EAAerC,EAAE,OAASqC,EAAeD,CAAM,EAC9EI,EAAWxC,EAAE,OACjB,QAASyC,EAAYL,EAAS,EAAGK,EAAYf,EAAK,EAAEe,EAClD,KAAK,aAAavB,EAAwBuB,EAAWlD,EAAS,EAAEiD,CAAQ,EACxEnB,EAAQ,WAEZ,CACF,CAEA,KAAK,iBAAmBc,CAC1B,CAKO,qBAAqBO,EAAWC,EAA0C,CAE/E,IAAMzB,EADS,KAAK,UAAU,MAAM,OAChB,MAAM,IAAIyB,CAAC,EAC/B,GAAIzB,GAAQA,EAAK,MAAMwB,CAAC,EAAI,UAAsB,CAChD,IAAM1C,EAAyBkB,EAAK,eAAewB,CAAC,GAAK/C,EACzD,GAAIK,EAAE,SAAWA,EAAE,UAAY,GAAI,CACjC,IAAM4C,EAAO,KAAK,QAAQ,IAAI5C,EAAE,OAAO,GAAG,KAC1C,GAAI,OAAO,aAAe4C,aAAgB,YAAa,CACrD,IAAMC,EAASC,EAAc,aAAa,OAAO,SAAUF,EAAK,MAAOA,EAAK,MAAM,EAClF,OAAAC,EAAO,WAAW,IAAI,GAAG,UAAUD,EAAM,EAAG,EAAGA,EAAK,MAAOA,EAAK,MAAM,EAC/DC,CACT,CACA,OAAOD,CACT,CACF,CACF,CAKO,wBAAwBF,EAAWC,EAA0C,CAElF,IAAMzB,EADS,KAAK,UAAU,MAAM,OAChB,MAAM,IAAIyB,CAAC,EAC/B,GAAIzB,GAAQA,EAAK,MAAMwB,CAAC,EAAI,UAAsB,CAChD,IAAM1C,EAAyBkB,EAAK,eAAewB,CAAC,GAAK/C,EACzD,GAAIK,EAAE,SAAWA,EAAE,UAAY,IAAMA,EAAE,SAAW,GAAI,CACpD,IAAMC,EAAO,KAAK,QAAQ,IAAID,EAAE,OAAO,EACvC,GAAIC,EACF,OAAO,KAAK,UAAU,YAAYA,EAAMD,EAAE,MAAM,CAEpD,CACF,CACF,CAIQ,aAAa+C,EAAsB,CACzC,IAAMC,EAAO,KAAK,iBAAiB,EAC/BC,EAAUD,EACd,KAAO,KAAK,YAAcC,EAAUF,GAAQ,KAAK,QAAQ,MAAM,CAC7D,IAAM9C,EAAO,KAAK,QAAQ,IAAI,EAAE,KAAK,SAAS,EAC1CA,GAAQA,EAAK,OACfgD,GAAWhD,EAAK,KAAK,MAAQA,EAAK,KAAK,OACnCA,EAAK,QAAUA,EAAK,OAASA,EAAK,SACpCgD,GAAWhD,EAAK,OAAO,MAAQA,EAAK,OAAO,QAE7CA,EAAK,QAAQ,QAAQ,EACrB,KAAK,QAAQ,KAAK,SAAS,EAE/B,CACA,OAAO+C,EAAOC,CAChB,CAEQ,aAAa/B,EAAsBwB,EAAWnD,EAAiBC,EAAsB,CAC3F,GAAI0B,EAAK,MAAMwB,EAAI,EAAY,CAAO,EAAI,UAAsB,CAC9D,IAAMQ,EAAMhC,EAAK,eAAewB,CAAC,EACjC,GAAIQ,EAAK,CACP,GAAIA,EAAI,UAAY,OAAW,CAI7B,IAAMC,EAAU,KAAK,QAAQ,IAAID,EAAI,OAAO,EACxCC,GAEFA,EAAQ,YAEVD,EAAI,QAAU3D,EACd2D,EAAI,OAAS1D,EACb,MACF,CAEA0B,EAAK,eAAewB,CAAC,EAAI,IAAIvD,EAAmB+D,EAAI,IAAKA,EAAI,MAAO3D,EAASC,CAAM,EACnF,MACF,CACF,CAEA0B,EAAK,MAAMwB,EAAI,EAAY,CAAO,GAAK,UACvCxB,EAAK,eAAewB,CAAC,EAAI,IAAIvD,EAAmB,EAAG,EAAGI,EAASC,CAAM,CACvE,CAEQ,mBAA0B,CAEhC,QAAWS,KAAQ,KAAK,QAAQ,OAAO,EACjCA,EAAK,aAAe,cACtBA,EAAK,UAAY,GAIrB,IAAMS,EAAS,KAAK,UAAU,MAAM,OACpC,QAASiC,EAAI,EAAGA,EAAI,KAAK,UAAU,KAAM,EAAEA,EAAG,CAC5C,IAAMzB,EAAOR,EAAO,MAAM,IAAIiC,CAAC,EAC/B,GAAKzB,GAGL,QAASwB,EAAI,EAAGA,EAAI,KAAK,UAAU,KAAM,EAAEA,EACzC,GAAIxB,EAAK,MAAMwB,EAAI,EAAY,CAAO,EAAI,UAAsB,CAC9D,IAAMU,EAAQlC,EAAK,eAAewB,CAAC,GAAG,QACtC,GAAIU,EAAO,CACT,IAAMnD,EAAO,KAAK,QAAQ,IAAImD,CAAK,EAC/BnD,GACFA,EAAK,WAET,CACF,EAEJ,CAEA,IAAMG,EAAO,CAAC,EACd,OAAW,CAACD,EAAIF,CAAI,IAAK,KAAK,QAAQ,QAAQ,EACxCA,EAAK,aAAe,aAAe,CAACA,EAAK,YAC3CA,EAAK,QAAQ,QAAQ,EACrBG,EAAK,KAAKD,CAAE,GAGhB,QAAWA,KAAMC,EACf,KAAK,QAAQD,CAAE,CAEnB,CACF,ECnpBA,IAAAkD,GAA0B,QAC1BC,GAAuB,QC2CvB,SAASC,EAAMC,EAA2B,CACxC,IAAIC,EAAI,GACR,QAASC,EAAI,EAAGA,EAAIF,EAAK,OAAQ,EAAEE,EACjCD,GAAK,OAAO,aAAaD,EAAKE,CAAC,CAAC,EAElC,OAAOD,CACT,CAGA,SAASE,GAAMH,EAA2B,CACxC,IAAII,EAAI,EACR,QAASF,EAAI,EAAGA,EAAIF,EAAK,OAAQ,EAAEE,EAAG,CACpC,GAAIF,EAAKE,CAAC,EAAI,IAAMF,EAAKE,CAAC,EAAI,GAC5B,MAAM,IAAI,MAAM,cAAc,EAEhCE,EAAIA,EAAI,GAAKJ,EAAKE,CAAC,EAAI,EACzB,CACA,OAAOE,CACT,CAGA,SAASC,GAAOL,EAA2B,CACzC,IAAMI,EAAIL,EAAMC,CAAI,EACpB,GAAI,CAACI,EAAE,MAAM,kCAAkC,EAC7C,MAAM,IAAI,MAAM,cAAc,EAEhC,OAAOA,CACT,CAGA,SAASE,GAAON,EAA2B,CACzC,GAAI,OAAO,OAAW,IACpB,OAAO,OAAO,KAAKD,EAAMC,CAAI,EAAG,QAAQ,EAAE,SAAS,EAErD,IAAMO,EAAK,KAAKR,EAAMC,CAAI,CAAC,EACrBQ,EAAI,IAAI,WAAWD,EAAG,MAAM,EAClC,QAAS,EAAI,EAAG,EAAIC,EAAE,OAAQ,EAAE,EAC9BA,EAAE,CAAC,EAAID,EAAG,WAAW,CAAC,EAExB,OAAO,IAAI,YAAY,EAAE,OAAOC,CAAC,CACnC,CAEA,IAAMC,GAAiE,CACrE,OAAQN,GACR,KAAMA,GACN,KAAMG,GACN,MAAOD,GACP,OAAQA,GACR,oBAAqBF,EACvB,EAKMO,GAAc,CAAC,GAAI,IAAK,IAAK,GAAG,EAEhCC,GAAuB,CAAC,GAAI,IAAK,IAAK,IAAK,IAAK,IAAK,GAAI,IAAK,IAAK,GAAI,IAAK,IAAK,GAAG,EAEpFC,GAAkB,CAAC,GAAI,IAAK,IAAK,IAAK,GAAI,GAAI,IAAK,GAAG,EAEtDC,GAAiB,CAAC,GAAI,IAAK,IAAK,IAAK,GAAI,IAAK,GAAG,EAEjDC,GAAwB,CAAC,GAAI,IAAK,IAAK,IAAK,IAAK,IAAK,GAAI,IAAK,IAAK,IAAK,GAAI,IAAK,IAAK,GAAG,EAG1FC,GAAiB,KAGVC,GAAN,KAAmB,CAAnB,cACL,KAAO,MAAqB,EAC5B,KAAQ,QAAU,IAAI,YAAYD,EAAc,EAChD,KAAQ,UAAY,EACpB,KAAQ,KAAO,GACf,KAAO,OAA4E,CAAC,EAE7E,OAAc,CACnB,KAAK,QAAQ,KAAK,CAAC,EACnB,KAAK,MAAQ,EACb,KAAK,UAAY,EACjB,KAAK,OAAS,CAAC,EACf,KAAK,KAAO,EACd,CAEO,KAAc,CACnB,GAAI,KAAK,QAAU,EAAmB,CACpC,GAAI,KAAK,YAAcF,GAAe,OAAQ,CAC5C,QAASI,EAAI,EAAGA,EAAIJ,GAAe,OAAQ,EAAEI,EAC3C,GAAI,KAAK,QAAQA,CAAC,IAAMJ,GAAeI,CAAC,EAAG,OAAO,KAAK,GAAG,EAE5D,YAAK,OAAO,KAAU,EACtB,KAAK,MAAQ,EACN,CACT,CACA,GAAI,KAAK,YAAcH,GAAsB,OAAQ,CACnD,QAASG,EAAI,EAAGA,EAAIH,GAAsB,OAAQ,EAAEG,EAClD,GAAI,KAAK,QAAQA,CAAC,IAAMH,GAAsBG,CAAC,EAAG,OAAO,KAAK,GAAG,EAEnE,YAAK,OAAO,KAAU,EACtB,KAAK,MAAQ,EACN,CACT,CACA,OAAO,KAAK,GAAG,CACjB,CACA,OAAI,KAAK,QAAU,EAAwB,EACvC,KAAK,QAAU,GACd,KAAK,OAAO,OAAS,EAEnB,KAAK,YAAY,KAAK,SAAS,GACpC,KAAK,MAAQ,EACN,GAFuC,KAAK,GAAG,EAIjD,KAAK,GAAG,CACjB,CAEO,MAAMjB,EAAmBkB,EAAeC,EAAqB,CAClE,IAAIC,EAAQ,KAAK,MACbC,EAAM,KAAK,UACTC,EAAS,KAAK,QAEpB,GADIF,IAAU,GAAqBA,IAAU,GACzCA,IAAU,GAAqBC,EAAM,GAAI,MAAO,GACpD,QAASnB,EAAIgB,EAAOhB,EAAIiB,EAAK,EAAEjB,EAAG,CAChC,IAAMqB,EAAIvB,EAAKE,CAAC,EAChB,OAAQqB,EAAG,CACT,IAAK,IACH,GAAI,CAAC,KAAK,YAAYF,CAAG,EAAG,OAAO,KAAK,GAAG,EAC3CD,EAAQ,EACRC,EAAM,EACN,MACF,IAAK,IACH,GAAID,IAAU,EAAmB,CAC/B,GAAIE,EAAO,CAAC,IAAM,GAAI,CAEpB,IAAIL,EAAI,EACR,KAAOA,EAAIP,GAAY,OAAQ,EAAEO,EAC/B,GAAIK,EAAOL,CAAC,IAAMP,GAAYO,CAAC,EAAG,OAAO,KAAK,GAAG,EAGnD,GADA,KAAK,OAAO,KAAU,EAClBI,IAAQT,GAAgB,OAAQ,CAClC,KAAOK,EAAIL,GAAgB,OAAQ,EAAEK,EACnC,GAAIK,EAAOL,CAAC,IAAML,GAAgBK,CAAC,EAAG,OAAO,KAAK,GAAG,EAEvD,YAAK,OAAO,KAAU,EACtB,KAAK,MAAQ,EACNf,EAAI,CACb,CACF,SAAWoB,EAAO,CAAC,IAAM,GAAI,CAE3B,QAASL,EAAI,EAAGA,EAAIN,GAAqB,OAAQ,EAAEM,EACjD,GAAIK,EAAOL,CAAC,IAAMN,GAAqBM,CAAC,EAAG,OAAO,KAAK,GAAG,EAE5D,KAAK,OAAO,KAAU,CACxB,KACE,QAAO,KAAK,GAAG,EAEjBG,EAAQ,EACRC,EAAM,CACR,SAAWD,IAAU,EAAiB,CACpC,GAAI,CAAC,KAAK,UAAUC,CAAG,EAAG,OAAO,KAAK,GAAG,EACzCD,EAAQ,EACRC,EAAM,CACR,SAAWD,IAAU,EAAmB,CACtC,GAAIC,GAAON,GAAgB,OAAO,KAAK,GAAG,EAC1CO,EAAOD,GAAK,EAAIE,CAClB,CACA,MACF,IAAK,IACH,OAAIH,IAAU,GACR,CAAC,KAAK,YAAYC,CAAG,EAAU,KAAK,GAAG,GAE7C,KAAK,MAAQ,EACNnB,EAAI,GACb,QACE,GAAImB,GAAON,GAAgB,OAAO,KAAK,GAAG,EAC1CO,EAAOD,GAAK,EAAIE,CACpB,CACF,CACA,YAAK,MAAQH,EACb,KAAK,UAAYC,EACV,EACT,CAEQ,IAAa,CACnB,YAAK,OAAO,KAAO,EACnB,KAAK,MAAQ,EACN,EACT,CAEQ,UAAUA,EAAsB,CACtC,IAAMJ,EAAIlB,EAAM,KAAK,QAAQ,SAAS,EAAGsB,CAAG,CAAC,EAC7C,OAAIJ,GACF,KAAK,KAAOA,EACZ,KAAK,OAAOA,CAAC,EAAI,KACV,IAEF,EACT,CAEQ,YAAYI,EAAsB,CACxC,GAAI,KAAK,KAAM,CACb,GAAI,CACF,IAAMjB,EAAI,KAAK,QAAQ,MAAM,EAAGiB,CAAG,EACnC,KAAK,OAAO,KAAK,IAAI,EAAIZ,GAAS,KAAK,IAAI,EAAIA,GAAS,KAAK,IAAI,EAAEL,CAAC,EAAIA,CAC1E,MAAQ,CACN,MAAO,EACT,CACA,MAAO,EACT,CACA,MAAO,EACT,CACF,ECvPO,IAAMoB,EAA6B,CACxC,KAAM,cACN,MAAO,EACP,OAAQ,CACV,EAEO,SAASC,GAAUC,EAAyB,CACjD,GAAIA,EAAE,OAAS,GACb,OAAOF,EAET,IAAMG,EAAM,IAAI,YAAYD,EAAE,OAAQA,EAAE,WAAY,CAAC,EAGrD,GAAIC,EAAI,CAAC,IAAM,YAAcA,EAAI,CAAC,IAAM,WAAcA,EAAI,CAAC,IAAM,WAC/D,MAAO,CACL,KAAM,YACN,MAAQD,EAAE,EAAE,GAAK,GAAKA,EAAE,EAAE,GAAK,GAAKA,EAAE,EAAE,GAAK,EAAIA,EAAE,EAAE,EACrD,OAAQA,EAAE,EAAE,GAAK,GAAKA,EAAE,EAAE,GAAK,GAAKA,EAAE,EAAE,GAAK,EAAIA,EAAE,EAAE,CACvD,EAGF,GAAIA,EAAE,CAAC,IAAM,KAAQA,EAAE,CAAC,IAAM,KAAQA,EAAE,CAAC,IAAM,IAAM,CACnD,GAAM,CAACE,EAAOC,CAAM,EAAIC,GAAQJ,CAAC,EACjC,MAAO,CAAE,KAAM,aAAc,MAAAE,EAAO,OAAAC,CAAO,CAC7C,CAEA,GAAIF,EAAI,CAAC,IAAM,YAAeD,EAAE,CAAC,IAAM,IAAQA,EAAE,CAAC,IAAM,KAASA,EAAE,CAAC,IAAM,GACxE,MAAO,CACL,KAAM,YACN,MAAQA,EAAE,CAAC,GAAK,EAAIA,EAAE,CAAC,EACvB,OAAQA,EAAE,CAAC,GAAK,EAAIA,EAAE,CAAC,CACzB,EAGF,GAAIC,EAAI,CAAC,IAAM,WACb,MAAO,CACL,KAAM,YACN,MAAQD,EAAE,CAAC,GAAK,GAAKA,EAAE,CAAC,GAAK,GAAKA,EAAE,CAAC,GAAK,EAAIA,EAAE,CAAC,EACjD,OAAQA,EAAE,CAAC,GAAK,GAAKA,EAAE,CAAC,GAAK,GAAKA,EAAE,EAAE,GAAK,EAAIA,EAAE,EAAE,CACrD,EAGF,GAAIC,EAAI,CAAC,IAAM,YAAcA,EAAI,CAAC,IAAM,aAAeA,EAAI,CAAC,EAAI,YAAc,QAAU,CACtF,OAAQD,EAAE,EAAE,EAAG,CACb,IAAK,IACH,MAAO,CACL,KAAM,aACN,OAASA,EAAE,EAAE,EAAIA,EAAE,EAAE,GAAK,EAAIA,EAAE,EAAE,GAAK,IAAM,EAC7C,QAASA,EAAE,EAAE,EAAIA,EAAE,EAAE,GAAK,EAAIA,EAAE,EAAE,GAAK,IAAM,CAC/C,EACF,IAAK,IACH,GAAIA,EAAE,EAAE,IAAM,GAAM,OAAOF,EAC3B,IAAMO,EAAML,EAAE,EAAE,EAAIA,EAAE,EAAE,GAAK,EAAIA,EAAE,EAAE,GAAK,GAAKA,EAAE,EAAE,GAAK,GACxD,MAAO,CACL,KAAM,aACN,OAASK,EAAa,OAAU,EAChC,QAASA,IAAQ,GAAK,OAAU,CAClC,EACF,IAAK,IACH,OAAIL,EAAE,EAAE,IAAM,KAAQA,EAAE,EAAE,IAAM,GAAQA,EAAE,EAAE,IAAM,GAAaF,EACxD,CACL,KAAM,aACN,OAASE,EAAE,EAAE,EAAIA,EAAE,EAAE,GAAK,GAAK,MAC/B,QAASA,EAAE,EAAE,EAAIA,EAAE,EAAE,GAAK,GAAK,KACjC,CACJ,CACA,OAAOF,CACT,CAEA,GAAIG,EAAI,CAAC,IAAM,aAAeA,EAAI,CAAC,IAAM,YAAcA,EAAI,CAAC,IAAM,YAAa,CAC7E,IAAIK,EAAM,GAEJC,EAAQ,KAAK,IAAIP,EAAE,OAAS,GAAI,IAAI,EAC1C,QAASQ,EAAI,EAAGA,EAAID,EAAOC,IAEzB,GAAIR,EAAEQ,CAAC,IAAM,KAAQR,EAAEQ,EAAI,CAAC,IAAM,KAAQR,EAAEQ,EAAI,CAAC,IAAM,KAAQR,EAAEQ,EAAI,CAAC,IAAM,IAAM,CAChFF,EAAME,EACN,KACF,CAEF,GAAIF,IAAQ,GAAI,CAEd,IAAMJ,EACJF,EAAEM,EAAO,CAAC,GAAK,GACfN,EAAEM,EAAO,CAAC,GAAK,GACfN,EAAEM,EAAM,EAAE,GAAM,EAChBN,EAAEM,EAAM,EAAE,EACNH,EACJH,EAAEM,EAAM,EAAE,GAAK,GACfN,EAAEM,EAAM,EAAE,GAAK,GACfN,EAAEM,EAAM,EAAE,GAAM,EAChBN,EAAEM,EAAM,EAAE,EACZ,GAAIJ,EAAQ,GAAKC,EAAS,EACxB,MAAO,CAAE,KAAM,aAAc,MAAAD,EAAO,OAAAC,CAAO,CAE/C,CACA,OAAOL,CACT,CACA,OAAOA,CACT,CAGA,SAASM,GAAQJ,EAAiC,CAChD,IAAMS,EAAMT,EAAE,OACVQ,EAAI,EACJE,EAAcV,EAAEQ,CAAC,GAAK,EAAIR,EAAEQ,EAAI,CAAC,EACrC,OAAa,CAEX,GADAA,GAAKE,EACDF,GAAKC,EAEP,MAAO,CAAC,EAAG,CAAC,EAEd,GAAIT,EAAEQ,CAAC,IAAM,IACX,MAAO,CAAC,EAAG,CAAC,EAEd,GAAIR,EAAEQ,EAAI,CAAC,IAAM,KAAQR,EAAEQ,EAAI,CAAC,IAAM,IACpC,OAAIA,EAAI,EAAIC,EACH,CACLT,EAAEQ,EAAI,CAAC,GAAK,EAAIR,EAAEQ,EAAI,CAAC,EACvBR,EAAEQ,EAAI,CAAC,GAAK,EAAIR,EAAEQ,EAAI,CAAC,CACzB,EAEK,CAAC,EAAG,CAAC,EAEdA,GAAK,EACLE,EAAcV,EAAEQ,CAAC,GAAK,EAAIR,EAAEQ,EAAI,CAAC,CACnC,CACF,CFnHA,IAAMG,GAAgC,CACpC,OACA,KAAM,eACN,KAAM,EACN,MAAO,OACP,OAAQ,OACR,oBAAqB,EACrB,OAAQ,CACV,EAGaC,GAAN,KAAuD,CAU5D,YACmBC,EACAC,EACAC,EACAC,EACjB,CAJiB,WAAAH,EACA,eAAAC,EACA,cAAAC,EACA,mBAAAC,EAbnB,KAAQ,YAAc,EACtB,KAAQ,SAAW,GACnB,KAAQ,IAAM,IAAIC,GAClB,KAAQ,QAAyBN,GAGjC,KAAQ,aAAe,GACvB,KAAQ,YAAc,GAQpB,IAAMO,EAAkB,KAAK,KAAK,KAAK,MAAM,aAAe,EAAI,CAAC,EAC3DC,EAAe,KAAK,IAAI,QAA2BD,CAAe,EACxE,KAAK,KAAO,IAAI,GAAAE,QAAc,EAAwBF,EAAiBC,CAAY,EACnF,KAAK,QAAU,IAAI,GAAAE,QAAW,CAAsB,CACtD,CAEO,OAAc,CACnB,KAAK,cACL,KAAK,IAAI,MAAM,EACf,KAAK,KAAK,QAAQ,EAClB,KAAK,QAAQ,QAAQ,CACvB,CAEO,OAAc,CACnB,KAAK,SAAW,GAChB,KAAK,IAAI,MAAM,CACjB,CAEO,IAAIC,EAAmBC,EAAeC,EAAmB,CAC9D,GAAI,MAAK,SAET,GAAI,KAAK,IAAI,QAAU,EAChB,KAAK,KAAK,IAAIF,EAAK,SAASC,EAAOC,CAAG,CAAC,IAAiB,IAC3D,KAAK,KAAK,QAAQ,EAClB,KAAK,SAAW,QAEb,CACL,IAAMC,EAAU,KAAK,IAAI,MAAMH,EAAMC,EAAOC,CAAG,EAC/C,GAAIC,IAAY,GAAI,CAClB,KAAK,SAAW,GAChB,MACF,CACA,GAAIA,EAAU,EAAG,CAEf,GADgB,KAAK,IAAI,OAAO,OAChB,EAAmB,CAOjC,GANI,KAAK,eACP,KAAK,aAAe,GACpB,KAAK,YAAc,GACnB,KAAK,KAAK,QAAQ,GAEpB,KAAK,QAAU,OAAO,OAAO,CAAC,EAAGd,GAAgB,KAAK,IAAI,MAAM,EAC5D,CAAC,KAAK,QAAQ,OAAQ,CACxB,KAAK,SAAW,GAChB,MACF,CACA,GAAI,CAAC,KAAK,aAAa,EAAG,CACxB,KAAK,SAAW,GAChB,MACF,CACF,SAAW,KAAK,YAAa,CAC3B,KAAK,SAAW,GAChB,MACF,CACK,KAAK,KAAK,IAAIW,EAAK,SAASG,EAASD,CAAG,CAAC,IAAiB,IAC7D,KAAK,KAAK,QAAQ,EAClB,KAAK,SAAW,GACZ,KAAK,eAAc,KAAK,YAAc,IAE9C,CACF,CACF,CAEO,IAAIE,EAA8C,CAGvD,GAFI,KAAK,UAEL,KAAK,IAAI,QAAU,GACjB,KAAK,IAAI,IAAI,EAAG,MAAO,GAE7B,IAAMC,EAAU,KAAK,IAAI,OAAO,KAEhC,GAAIA,IAAY,EAAuB,MAAO,GAE9C,GAAIA,IAAY,EAA6B,CAE3C,IAAIC,EAAIC,EAAkB,MACtBC,EAAID,EAAkB,OACtB,KAAK,UAAU,aACjBD,EAAI,KAAK,UAAU,WAAW,IAAI,OAAO,MAAQ,KAAK,cAAc,KACpEE,EAAI,KAAK,UAAU,WAAW,IAAI,OAAO,OAAS,KAAK,cAAc,MAEvE,IAAMC,EAAQ,KAAK,cAAc,MAAM,qBAAqB,KAAO,EAC7DC,EAAS,4BAA4BF,EAAE,QAAQ,CAAC,CAAC,IAAIF,EAAE,QAAQ,CAAC,CAAC,IAAIG,EAAM,QAAQ,CAAC,CAAC,SAC3F,YAAK,cAAc,MAAMC,EAAQ,EAAK,EAC/B,EACT,CAEA,GAAIL,IAAY,EACd,YAAK,QAAU,OAAO,OAAO,CAAC,EAAGhB,GAAgB,KAAK,IAAI,MAAM,EAChE,KAAK,aAAe,GACpB,KAAK,YAAc,GACnB,KAAK,KAAK,QAAQ,EACb,KAAK,aAAa,IACrB,KAAK,YAAc,IAEd,GAGT,GAAIgB,IAAY,IACV,CAAC,KAAK,eACV,KAAK,aAAe,GAChB,KAAK,aAAe,KAAK,QAAQ,OAAS,IAA4B,MAAO,GAKnF,IAAIC,EAAI,EACJE,EAAI,EAGJG,EACAC,EAAUC,EAoBd,IAnBIF,EAAOP,MACLO,EAAO,CAAC,KAAK,KAAK,IAAI,IACxBC,EAAUE,GAAU,KAAK,KAAK,KAAK,GAC/BH,EAAOC,EAAQ,OAAS,gBAC1BN,EAAIM,EAAQ,MACZJ,EAAII,EAAQ,QACRD,EAAOL,GAAKE,GAAKF,EAAIE,EAAI,KAAK,MAAM,aACtC,CAACF,EAAGE,CAAC,EAAI,KAAK,QAAQF,EAAGE,CAAC,EAAE,IAAI,KAAK,KAAK,EAC1CG,EAAOL,GAAKE,GAAKF,EAAIE,EAAI,KAAK,MAAM,YAEpC,QAAQ,KAAK,8BAA8BI,EAAQ,KAAK,IAAIA,EAAQ,MAAM,EAAE,GAG9E,QAAQ,KAAK,6BAA6B,GAG5C,QAAQ,KAAK,mCAAmC,GAGhD,CAACD,EACH,YAAK,KAAK,QAAQ,EACX,GAGT,IAAII,EACJ,GAAIH,EAAQ,OAAS,YAAa,CAChC,IAAIZ,EACJ,GAAI,CACFA,EAAO,KAAK,QAAQ,OAAO,KAAK,KAAK,KAAK,CAC5C,OAASgB,EAAG,CACV,eAAQ,KAAK,kCAAmCA,CAAC,EACjD,KAAK,KAAK,QAAQ,EAClB,KAAK,QAAQ,QAAQ,EACd,EACT,CAOA,GANAD,EAAO,IAAI,UACT,IAAI,kBAAkBf,EAAK,OAAQA,EAAK,WAAYA,EAAK,UAAU,EACnE,KAAK,QAAQ,MACb,KAAK,QAAQ,MACf,EACA,KAAK,QAAQ,QAAQ,EACjBM,IAAM,KAAK,QAAQ,OAASE,IAAM,KAAK,QAAQ,OAAQ,CAEzD,KAAK,KAAK,QAAQ,EAClB,IAAMS,EAASC,EAAc,aAAa,OAAW,KAAK,QAAQ,MAAO,KAAK,QAAQ,MAAM,EAC5F,OAAAD,EAAO,WAAW,IAAI,GAAG,aAAaF,EAAM,EAAG,CAAC,EAChD,KAAK,SAAS,SAASE,CAAM,EACtB,EACT,CACF,MACEF,EAAO,IAAI,KAAK,CAAC,KAAK,KAAK,KAAK,EAAG,CAAE,KAAMH,EAAQ,IAAK,CAAC,EAE3D,KAAK,KAAK,QAAQ,EAClB,IAAMO,EAAa,KAAK,YACxB,OAAO,kBAAkBJ,EAAM,CAAE,YAAaT,EAAG,aAAcE,CAAE,CAAC,EAC/D,KAAKY,GACAD,IAAe,KAAK,aACtBC,EAAG,MAAM,EACF,KAET,KAAK,SAAS,SAASA,CAAE,EAClB,GACR,EACA,MAAMJ,IACL,QAAQ,KAAK,uBAAuBJ,EAAQ,IAAI,IAAIA,EAAQ,KAAK,IAAIA,EAAQ,MAAM,GAAII,CAAC,EACjF,GACR,CACL,CAGQ,cAAwB,CAC9B,GAAI,CACF,YAAK,KAAK,KAAK,EACR,EACT,OAAS,EAAG,CACV,eAAQ,KAAK,kCAAmC,CAAC,EACjD,KAAK,KAAK,QAAQ,EACX,EACT,CACF,CAEQ,QAAQV,EAAWE,EAA6B,CACtD,IAAMa,EAAK,KAAK,UAAU,YAAY,IAAI,KAAK,OAASd,EAAkB,MACpEe,EAAK,KAAK,UAAU,YAAY,IAAI,KAAK,QAAUf,EAAkB,OACrEgB,EAAQ,KAAK,UAAU,YAAY,IAAI,OAAO,OAASF,EAAK,KAAK,cAAc,KAC/EG,EAAS,KAAK,UAAU,YAAY,IAAI,OAAO,QAAUF,EAAK,KAAK,cAAc,KAEjFG,EAAK,KAAK,KAAK,KAAK,QAAQ,MAAQF,EAAOF,CAAE,EAC7CK,EAAK,KAAK,KAAK,KAAK,QAAQ,OAASF,EAAQF,CAAE,EACrD,GAAI,CAACG,GAAM,CAACC,EAAI,CACd,IAAMC,EAAKJ,EAAQjB,EACbsB,GAAMJ,EAASF,GAAMd,EACrBqB,EAAI,KAAK,IAAIF,EAAIC,CAAE,EACzB,OAAOC,EAAI,EAAI,CAACvB,EAAIuB,EAAGrB,EAAIqB,CAAC,EAAI,CAACvB,EAAGE,CAAC,CACvC,CACA,OAAQiB,EAEJ,KAAK,QAAQ,qBAAuB,CAACA,GAAM,CAACC,EAC1C,CAACD,EAAIjB,EAAIiB,EAAKnB,CAAC,EAAI,CAACmB,EAAIC,CAAE,EAF5B,CAACpB,EAAIoB,EAAKlB,EAAGkB,CAAE,CAGrB,CAEQ,KAAKI,EAAWC,EAAeC,EAAsB,CAC3D,OAAIF,IAAM,OAAe,EACrBA,EAAE,SAAS,GAAG,EAAU,SAASA,EAAE,MAAM,EAAG,EAAE,EAAG,EAAE,EAAIC,EAAQ,IAC/DD,EAAE,SAAS,IAAI,EAAU,SAASA,EAAE,MAAM,EAAG,EAAE,EAAG,EAAE,EACjD,SAASA,EAAG,EAAE,EAAIE,CAC3B,CACF,EGpQA,IAAAC,GAAiD,QCmI1C,SAASC,GAAkBC,EAA6B,CAC7D,IAAMC,EAAqB,CAAC,EACtBC,EAAQF,EAAK,MAAM,GAAG,EAE5B,QAAWG,KAAQD,EAAO,CACxB,IAAME,EAAQD,EAAK,QAAQ,GAAG,EAC9B,GAAIC,IAAU,GAAI,SAElB,IAAMC,EAAMF,EAAK,UAAU,EAAGC,CAAK,EAC7BE,EAAQH,EAAK,UAAUC,EAAQ,CAAC,EAGtC,GAAIC,IAAQ,IAAiB,CAC3BJ,EAAI,OAASK,EACb,QACF,CACA,GAAID,IAAQ,IAAsB,CAChCJ,EAAI,YAAcK,EAClB,QACF,CACA,GAAID,IAAQ,IAAuB,CACjCJ,EAAI,aAAeK,EACnB,QACF,CACA,GAAID,IAAQ,IAA0B,CACpCJ,EAAI,eAAiBK,EACrB,QACF,CACA,IAAMC,EAAW,SAASD,EAAO,EAAE,EACnC,OAAQD,EAAK,CACX,IAAK,IAAiBJ,EAAI,OAASM,EAAU,MAC7C,IAAK,IAAaN,EAAI,GAAKM,EAAU,MACrC,IAAK,IAAuBN,EAAI,YAAcM,EAAU,MACxD,IAAK,IAAgBN,EAAI,MAAQM,EAAU,MAC3C,IAAK,IAAiBN,EAAI,OAASM,EAAU,MAC7C,IAAK,IAAmBN,EAAI,EAAIM,EAAU,MAC1C,IAAK,IAAmBN,EAAI,EAAIM,EAAU,MAC1C,IAAK,IAAuBN,EAAI,YAAcM,EAAU,MACxD,IAAK,IAAwBN,EAAI,aAAeM,EAAU,MAC1D,IAAK,IAA6BN,EAAI,QAAUM,EAAU,MAC1D,IAAK,IAA6BN,EAAI,QAAUM,EAAU,MAC1D,IAAK,IAAkBN,EAAI,QAAUM,EAAU,MAC/C,IAAK,IAAeN,EAAI,KAAOM,EAAU,MACzC,IAAK,IAAeN,EAAI,KAAOM,EAAU,MACzC,IAAK,IAAgBN,EAAI,MAAQM,EAAU,MAC3C,IAAK,IAA0BN,EAAI,eAAiBM,EAAU,MAC9D,IAAK,IAAkBN,EAAI,OAASM,EAAU,MAC9C,IAAK,IAAuBN,EAAI,YAAcM,EAAU,KAC1D,CACF,CAEA,OAAON,CACT,CD/JA,IAAMO,GAAa,EAGNC,GAAN,KAA8E,CAiCnF,YACmBC,EACAC,EACAC,EACAC,EACjB,CAJiB,WAAAH,EACA,eAAAC,EACA,mBAAAC,EACA,mBAAAC,EApCnB,KAAQ,SAAW,GACnB,KAAQ,YAAc,EACtB,KAAQ,aAAe,GAEvB,KAAQ,eAAuC,KAO/C,KAAQ,eAAiB,GAGzB,KAAQ,aAAe,IAAI,YAAY,GAA+B,EACtE,KAAQ,eAAiB,EAGzB,KAAQ,kBAAoB,EAC5B,KAAQ,kBAAoB,EAG5B,KAAQ,eAAuC,KAI/C,KAAQ,sBAA2D,IAAI,IAarE,KAAK,iBAAmB,KAAK,KAAK,KAAK,MAAM,eAAiB,EAAI,CAAC,EAEnE,KAAK,qBAAuB,KAAK,IAAI,QAAgC,KAAK,gBAAgB,CAC5F,CAEO,OAAc,CACnB,KAAK,cACL,KAAK,mBAAmB,EACpB,KAAK,iBACP,KAAK,eAAe,QAAQ,EAC5B,KAAK,eAAiB,MAExB,KAAK,cAAc,MAAM,CAC3B,CAEO,SAAgB,CACrB,KAAK,MAAM,CACb,CAEQ,oBAAoBC,EAAmB,CAC7C,KAAK,sBAAsB,OAAOA,CAAG,EACjC,KAAK,kBAAoBA,IAC3B,KAAK,gBAAkB,OAE3B,CAEQ,oBAA2B,CACjC,QAAWC,KAAW,KAAK,sBAAsB,OAAO,EACtDA,EAAQ,QAAQ,QAAQ,EAE1B,KAAK,sBAAsB,MAAM,EACjC,KAAK,gBAAkB,MACzB,CAEO,OAAc,CACnB,KAAK,SAAW,GAChB,KAAK,aAAe,GACpB,KAAK,eAAiB,GACtB,KAAK,eAAiB,EACtB,KAAK,eAAiB,KAEtB,KAAK,kBAAoB,KAAK,iBAC9B,KAAK,kBAAoB,EACzB,KAAK,eAAiB,IACxB,CAEO,IAAIC,EAAmBC,EAAeC,EAAmB,CAC9D,GAAI,MAAK,SAET,GAAI,CAAC,KAAK,eACR,KAAK,eAAeF,EAAMC,EAAOC,CAAG,MAC/B,CAEL,IAAIC,EAAaD,EACjB,QAASE,EAAIH,EAAOG,EAAIF,EAAKE,IAC3B,GAAIJ,EAAKI,CAAC,IAAM,GAAqB,CACnC,KAAK,eAAiB,GACtBD,EAAaC,EACb,KACF,CAIF,IAAMC,EAAaF,EAAaF,EAChC,GAAI,KAAK,eAAiBI,EAAa,IAAiC,CACtE,KAAK,SAAW,GAChB,MACF,CAIA,GAHA,KAAK,aAAa,IAAIL,EAAK,SAASC,EAAOE,CAAU,EAAG,KAAK,cAAc,EAC3E,KAAK,gBAAkBE,EAEnB,CAAC,KAAK,eAAgB,CAKxB,GAHA,KAAK,eAAiBC,GAAkB,KAAK,wBAAwB,CAAC,EAGlE,KAAK,eAAe,KAAO,QAAa,KAAK,eAAe,cAAgB,OAAW,CACzF,KAAK,cAAc,KAAK,eAAe,GAAI,0CAA2C,KAAK,eAAe,OAAS,CAAC,EACpH,KAAK,SAAW,GAChB,MACF,CAGA,GAAI,KAAK,eAAe,SAAW,IACjC,OAIF,IAAMC,EAAeJ,EAAa,EAC9BI,EAAeL,GACjB,KAAK,eAAeF,EAAMO,EAAcL,CAAG,CAE/C,CACF,CACF,CAGQ,eAAeF,EAAmBC,EAAeC,EAAmB,CAC1E,GAAI,KAAK,SAAU,OAKnB,IAAMM,EAAa,KAAK,gBAAgB,IAAM,KAAK,iBAAmB,EAChET,EAAU,KAAK,sBAAsB,IAAIS,CAAU,EACnDC,EAAsBV,GAAS,kBAAoB,EAGzD,GAFA,KAAK,mBAAqBG,EAAMD,EACFQ,EAAsB,KAAK,kBAC7B,KAAK,kBAAmB,CAClD,IAAMC,EAAmB,KAAK,gBAAkBX,GAAS,QACrDW,GACFA,EAAiB,QAAQ,EAE3B,KAAK,eAAiB,KAClBX,GACF,KAAK,oBAAoBS,CAAU,EAErC,KAAK,SAAW,GAChB,MACF,CAEA,GAAI,MAAK,aAKT,IAHIT,GAAS,SAAW,CAAC,KAAK,iBAC5B,KAAK,eAAiBA,EAAQ,SAE5B,CAAC,KAAK,eAAgB,CAExB,IAAMY,EAAkB,KAAK,iBAAmB,OAChD,GAAIA,EAAkB,KAAK,MAAM,aAAe,IAAS,CACvD,KAAK,SAAW,GACZ,KAAK,gBAAgB,KAAO,QAC9B,KAAK,cAAc,KAAK,eAAe,GAAI,uCAAwC,KAAK,eAAe,OAAS,CAAC,EAEnH,MACF,CACA,IAAMC,EAAa,KAAK,IAAI,EAAG,KAAK,MAAM,KAAK,MAAM,aAAe,IAAUD,CAAe,CAAC,EAC9F,KAAO,KAAK,sBAAsB,MAAQC,GAAY,CACpD,IAAMC,EAAS,KAAK,sBAAsB,QAAQ,EAAE,KAAK,EAAE,MAC3D,GAAI,CAACA,EAAQ,MACbA,EAAO,CAAC,EAAE,QAAQ,QAAQ,EAC1B,KAAK,oBAAoBA,EAAO,CAAC,CAAC,EAC9BA,EAAO,CAAC,EAAE,IAAI,KAAO,QACvB,KAAK,cAAcA,EAAO,CAAC,EAAE,IAAI,GAAI,uCAAwCA,EAAO,CAAC,EAAE,IAAI,OAAS,CAAC,CAEzG,CACA,IAAMC,EAAU,IAAI,GAAAC,QAAc,QAA6B,KAAK,iBAAkB,KAAK,oBAAoB,EAC/G,GAAI,CACFD,EAAQ,KAAK,CACf,OAASE,EAAG,CAEV,QAAQ,KAAK,oCAAqCA,CAAC,EACnD,KAAK,SAAW,GACZ,KAAK,gBAAgB,KAAO,QAC9B,KAAK,cAAc,KAAK,eAAe,GAAI,oCAAqC,KAAK,eAAe,OAAS,CAAC,EAEhH,MACF,CACA,KAAK,eAAiBF,CACxB,CAEI,KAAK,eAAe,IAAId,EAAK,SAASC,EAAOC,CAAG,CAAC,IAAMV,KACzD,KAAK,eAAe,QAAQ,EAC5B,KAAK,eAAiB,KACtB,KAAK,aAAe,GAChBO,GACF,KAAK,oBAAoBS,CAAU,GAGzC,CAEO,IAAIS,EAA8C,CACvD,GAAI,KAAK,UAAY,CAACA,EACpB,OAAI,KAAK,iBACP,KAAK,eAAe,QAAQ,EAC5B,KAAK,eAAiB,MAEjB,GAIT,GAAI,KAAK,eACP,OAAO,KAAK,wBAAwB,EAItC,IAAMC,EAAM,KAAK,eAGjB,GAAIA,EAAI,SAAW,IACjB,OAAO,KAAK,cAAcA,CAAG,EAI/B,IAAMV,EAAaU,EAAI,IAAM,KAAK,iBAAmB,EAC/CC,EAAeD,EAAI,OAAS,EAC5BnB,EAAU,KAAK,sBAAsB,IAAIS,CAAU,EAEzD,GAAIW,EACF,OAAI,KAAK,iBACHpB,GACFA,EAAQ,kBAAoB,KAAK,kBACjCA,EAAQ,YAAcA,EAAQ,aAAe,KAAK,cAElD,KAAK,sBAAsB,IAAIS,EAAY,CACzC,IAAK,CAAE,GAAGU,CAAI,EACd,QAAS,KAAK,eACd,iBAAkB,KAAK,kBACvB,YAAa,KAAK,YACpB,CAAC,EAEH,KAAK,gBAAkBV,EACvB,KAAK,eAAiB,MAEjB,GAILT,IACF,KAAK,gBAAkB,QAGzB,IAAIqB,EAAc,KAAK,aACnBC,EAAWH,EACXJ,EAAU,KAAK,eAEff,IACFsB,EAAWtB,EAAQ,IACnBe,EAAUf,EAAQ,QAClBqB,EAAcA,GAAerB,EAAQ,YACrC,KAAK,sBAAsB,OAAOS,CAAU,GAG9C,IAAIc,EAAa,IAAI,WAAW,CAAC,EAC7BR,IACEA,EAAQ,IAAI,IAAMtB,KACpB4B,EAAc,IAEhBE,EAAaR,EAAQ,OAEvB,KAAK,eAAiB,KAKtB,IAAMS,EAAS,KAAK,8BAA8BF,EAAUC,EAAYF,CAAW,EACnF,OAAIN,GACFA,EAAQ,QAAQ,EAEXS,CACT,CAIQ,yBAAkC,CACxC,IAAIC,EAAM,GACV,QAASpB,EAAI,EAAGA,EAAI,KAAK,eAAgBA,IACvCoB,GAAO,OAAO,cAAc,KAAK,aAAapB,CAAC,CAAC,EAElD,OAAOoB,CACT,CAEQ,yBAAsD,CAC5D,IAAMN,EAAMZ,GAAkB,KAAK,wBAAwB,CAAC,EAG5D,GAAIY,EAAI,KAAO,QAAaA,EAAI,cAAgB,OAC9C,YAAK,cAAcA,EAAI,GAAI,0CAA2CA,EAAI,OAAS,CAAC,EAC7E,GAKT,OAFeA,EAAI,QAAU,IAEb,CACd,QACE,OAAO,KAAK,cAAcA,CAAG,EAC/B,QACE,YAAK,cAAcA,EAAI,IAAM,EAAG,KAAMA,EAAI,OAAS,CAAC,EAC7C,GACT,QACE,OAAO,KAAK,iBAAiBA,CAAG,EAClC,QAKE,OAAIA,EAAI,KAAO,QACb,KAAK,cAAcA,EAAI,GAAI,4BAA6BA,EAAI,OAAS,CAAC,EAEjE,EACX,CACF,CAEQ,8BAA8BA,EAAoBO,EAAmBL,EAAkD,CAG7H,OAFeF,EAAI,QAAU,IAEb,CACd,QAA2B,CACzB,IAAMK,EAAS,KAAK,gBAAgBL,EAAKO,EAAOL,CAAW,EAG3D,OAAKF,EAAI,cAAgB,OAAS,KAAOA,EAAI,KAAO,SAC9CE,EACF,KAAK,cAAcF,EAAI,GAAI,6BAA8BA,EAAI,OAAS,CAAC,EAC9DO,EAAM,OAAS,GACxB,KAAK,cAAcP,EAAI,GAAI,KAAMA,EAAI,OAAS,CAAC,GAG5CK,CACT,CACA,QACE,OAAO,KAAK,uBAAuBL,EAAKO,EAAOL,CAAW,EAC5D,QACE,OAAO,KAAK,aAAaF,EAAKO,EAAOL,CAAW,EAClD,QAEE,OAAO,KAAK,iBAAiBF,CAAG,EAClC,QAKE,OAAIA,EAAI,KAAO,QACb,KAAK,cAAcA,EAAI,GAAI,4BAA6BA,EAAI,OAAS,CAAC,EAEjE,EACX,CACF,CAEQ,iBAAiBA,EAAgD,CACvE,GAAIA,EAAI,KAAO,OACb,MAAO,GAET,IAAMQ,EAAKR,EAAI,GACTS,EAAQ,KAAK,cAAc,SAASD,CAAE,EAC5C,OAAKC,EAIU,KAAK,cAAcA,EAAOT,CAAG,EAC9B,KAAKD,IACjB,KAAK,cAAcS,EAAIT,EAAU,KAAO,gCAAiCC,EAAI,OAAS,EAAGA,EAAI,WAAW,EACjG,GACR,GAPC,KAAK,cAAcQ,EAAI,yBAA0BR,EAAI,OAAS,EAAGA,EAAI,WAAW,EACzE,GAOX,CAEQ,gBAAgBA,EAAoBO,EAAmBL,EAA+B,CAY5F,OADqBF,EAAI,cAAgB,OACpB,KACfA,EAAI,KAAO,QACb,KAAK,cAAcA,EAAI,GAAI,yCAA0CA,EAAI,OAAS,CAAC,EAE9E,KAGLE,GAAeK,EAAM,SAAW,GAEpC,KAAK,cAAc,WAAWP,EAAI,GAAI,CACpC,KAAM,IAAI,KAAK,CAACO,CAAiB,CAAC,EAClC,MAAOP,EAAI,OAAS,EACpB,OAAQA,EAAI,QAAU,EACtB,OAASA,EAAI,QAAU,GACvB,YAAaA,EAAI,aAAe,EAClC,CAAC,EACM,GACT,CAEQ,uBAAuBA,EAAoBO,EAAmBL,EAAkD,CACtH,GAAIA,EACF,OAAIF,EAAI,KAAO,QACb,KAAK,cAAcA,EAAI,GAAI,6BAA8BA,EAAI,OAAS,CAAC,EAElE,GAGT,KAAK,gBAAgBA,EAAKO,EAAOL,CAAW,EAE5C,IAAMM,EAAKR,EAAI,IAAM,KAAK,cAAc,YAClCS,EAAQ,KAAK,cAAc,SAASD,CAAE,EAC5C,GAAIC,EAAO,CACT,IAAMJ,EAAS,KAAK,cAAcI,EAAOT,CAAG,EAC5C,OAAIA,EAAI,KAAO,OACNK,EAAO,KAAKN,IACjB,KAAK,cAAcS,EAAIT,EAAU,KAAO,gCAAiCC,EAAI,OAAS,CAAC,EAChF,GACR,EAEIK,EAAO,KAAK,IAAM,EAAI,CAC/B,CACA,MAAO,EACT,CAEQ,aAAaL,EAAoBO,EAAmBL,EAA+B,CACzF,IAAMM,EAAKR,EAAI,IAAM,EACfU,EAAQV,EAAI,OAAS,EAM3B,IADqBA,EAAI,cAAgB,OACpB,IACnB,YAAK,cAAcQ,EAAI,yCAA0CE,CAAK,EAC/D,GAIT,GAAIR,EACF,YAAK,cAAcM,EAAI,6BAA8BE,CAAK,EACnD,GAIT,GAAIH,EAAM,SAAW,EACnB,YAAK,cAAcC,EAAI,KAAME,CAAK,EAC3B,GAGT,IAAMC,EAASX,EAAI,QAAU,GAE7B,GAAIW,IAAW,IACb,KAAK,cAAcH,EAAI,KAAME,CAAK,MAC7B,CACL,IAAME,EAAQZ,EAAI,OAAS,EACrBa,EAASb,EAAI,QAAU,EAE7B,GAAI,CAACY,GAAS,CAACC,EACb,YAAK,cAAcL,EAAI,sDAAuDE,CAAK,EAC5E,GAGT,IAAMI,EAAgBH,IAAW,OAC3BI,EAAgBH,EAAQC,EAASC,EAEvC,GAAIP,EAAM,OAASQ,EACjB,YAAK,cAAcP,EAAI,iCAAkCE,CAAK,EACvD,GAGT,KAAK,cAAcF,EAAI,KAAME,CAAK,CACpC,CACA,MAAO,EACT,CAEQ,cAAcV,EAA6B,CAOjD,OALiBA,EAAI,gBAAkB,IAKrB,CAChB,IAAK,IACL,IAAK,IACH,KAAK,mBAAmB,EACxB,KAAK,cAAc,UAAU,EAC7B,MACF,IAAK,IACL,IAAK,IAKH,GAAIA,EAAI,KAAO,OAAW,CACxB,IAAMnB,EAAU,KAAK,sBAAsB,IAAImB,EAAI,EAAE,EACjDnB,GACFA,EAAQ,QAAQ,QAAQ,EAE1B,KAAK,oBAAoBmB,EAAI,EAAE,EAC/B,KAAK,cAAc,WAAWA,EAAI,EAAE,CACtC,CACA,MACF,QAEE,KACJ,CACA,MAAO,EACT,CAEQ,cAAcQ,EAAYQ,EAAiBN,EAAeO,EAA4B,CAC5F,IAAMC,EAAOF,IAAY,KAEzB,GADIE,GAAQR,GAAS,GACjB,CAACQ,GAAQR,GAAS,EAAG,OAEzB,IAAMS,EAAQF,EAAc,MAAMA,CAAW,GAAK,GAC5CG,EAAW,WAAWZ,CAAE,GAAGW,CAAK,IAAIH,CAAO,SACjD,KAAK,cAAc,MAAM,YAAY,iBAAiBI,CAAQ,CAChE,CAIQ,cAAcX,EAAwBT,EAAsC,CAClF,OAAO,KAAK,kBAAkBS,EAAOT,CAAG,EACrC,KAAK,IAAM,EAAI,EACf,MAAM,IAAM,EAAK,CACtB,CAEA,MAAc,kBAAkBS,EAAwBT,EAAmC,CACzF,IAAMqB,EAAa,KAAK,YACpBC,EAAkC,MAAM,KAAK,cAAcb,CAAK,EAEpE,GAAI,CACF,GAAIY,IAAe,KAAK,YAAa,MAAM,IAAI,MAAM,uBAAuB,EAC5E,IAAME,EAAQ,KAAK,IAAI,EAAGvB,EAAI,GAAK,CAAC,EAC9BwB,EAAQ,KAAK,IAAI,EAAGxB,EAAI,GAAK,CAAC,EAC9ByB,EAAQzB,EAAI,aAAgBsB,EAAO,MAAQC,EAC3CG,EAAQ1B,EAAI,cAAiBsB,EAAO,OAASE,EAE7CG,EAAW,KAAK,IAAI,EAAGL,EAAO,MAAQC,CAAK,EAC3CK,EAAW,KAAK,IAAI,EAAGN,EAAO,OAASE,CAAK,EAC5CK,EAAa,KAAK,IAAI,EAAG,KAAK,IAAIJ,EAAOE,CAAQ,CAAC,EAClDG,EAAa,KAAK,IAAI,EAAG,KAAK,IAAIJ,EAAOE,CAAQ,CAAC,EAExD,GAAIC,IAAe,GAAKC,IAAe,EACrC,MAAM,IAAI,MAAM,0BAA0B,EAG5C,GAAIP,IAAU,GAAKC,IAAU,GAAKK,IAAeP,EAAO,OAASQ,IAAeR,EAAO,OAAQ,CAC7F,IAAMS,EAAU,MAAM,kBAAkBT,EAAQC,EAAOC,EAAOK,EAAYC,CAAU,EACpFR,EAAO,MAAM,EACbA,EAASS,CACX,CAEA,IAAMC,EAAK,KAAK,UAAU,YAAY,IAAI,KAAK,OAASC,EAAkB,MACpEC,EAAK,KAAK,UAAU,YAAY,IAAI,KAAK,QAAUD,EAAkB,OAIvEE,EACAC,EACApC,EAAI,UAAY,QAAaA,EAAI,OAAS,QAC5CmC,EAAUnC,EAAI,QACdoC,EAAUpC,EAAI,MACLA,EAAI,UAAY,QACzBmC,EAAUnC,EAAI,QACdoC,EAAU,KAAK,IAAI,EAAG,KAAK,KAAMd,EAAO,OAASA,EAAO,OAAUa,EAAUH,GAAME,CAAE,CAAC,GAC5ElC,EAAI,OAAS,QACtBoC,EAAUpC,EAAI,KACdmC,EAAU,KAAK,IAAI,EAAG,KAAK,KAAMb,EAAO,MAAQA,EAAO,QAAWc,EAAUF,GAAMF,CAAE,CAAC,IAErFG,EAAU,KAAK,KAAKb,EAAO,MAAQU,CAAE,EACrCI,EAAU,KAAK,KAAKd,EAAO,OAASY,CAAE,GAGxC,IAAIG,EAAIf,EAAO,MACXgB,EAAIhB,EAAO,OAQf,IALItB,EAAI,UAAY,QAAaA,EAAI,OAAS,UAC5CqC,EAAI,KAAK,MAAMF,EAAUH,CAAE,EAC3BM,EAAI,KAAK,MAAMF,EAAUF,CAAE,GAGzBG,EAAIC,EAAI,KAAK,MAAM,WACrB,MAAM,IAAI,MAAM,2BAA2B,EAI7C,IAAMC,EAAS,KAAK,cAAc,MAAM,OAClCC,EAASD,EAAO,EAChBE,EAASF,EAAO,EAChBG,EAAaH,EAAO,MAOpBI,GADc3C,EAAI,SAAW,QAAaA,EAAI,OAAS,EACrB,SAAW,MAEnD,GAAIqC,IAAMf,EAAO,OAASgB,IAAMhB,EAAO,OAAQ,CAC7C,IAAMsB,EAAS,MAAM,kBAAkBtB,EAAQ,CAAE,YAAae,EAAG,aAAcC,CAAE,CAAC,EAClFhB,EAAO,MAAM,EACbA,EAASsB,CACX,CAGA,IAAMC,GAAU,KAAK,IAAI,KAAK,IAAI,EAAG7C,EAAI,SAAW,CAAC,EAAGgC,EAAK,CAAC,EACxDc,GAAU,KAAK,IAAI,KAAK,IAAI,EAAG9C,EAAI,SAAW,CAAC,EAAGkC,EAAK,CAAC,EAC9D,GAAIW,KAAY,GAAKC,KAAY,EAAG,CAKlC,IAAMC,EAAW/C,EAAI,UAAY,OAAa,KAAK,MAAMmC,EAAUH,CAAE,EAAIV,EAAO,MAAQuB,GAClFG,GAAWhD,EAAI,OAAS,OAAa,KAAK,MAAMoC,EAAUF,CAAE,EAAIZ,EAAO,OAASwB,GAChFG,EAAeC,EAAc,aAAa,OAAO,SAAUH,EAASC,EAAO,EAC3EG,GAAYF,EAAa,WAAW,IAAI,EAC9C,GAAI,CAACE,GACH,MAAM,IAAI,MAAM,wCAAwC,EAE1DA,GAAU,UAAU7B,EAAQuB,GAASC,EAAO,EAE5C,IAAMM,GAAe,MAAM,kBAAkBH,CAAY,EAMzD,GALAA,EAAa,MAAQA,EAAa,OAAS,EAC3C3B,EAAO,MAAM,EACbA,EAAS8B,GACTf,EAAIf,EAAO,MACXgB,EAAIhB,EAAO,OACPe,EAAIC,EAAI,KAAK,MAAM,WACrB,MAAM,IAAI,MAAM,2BAA2B,EAEzCtC,EAAI,UAAY,SAClBmC,EAAU,KAAK,KAAKb,EAAO,MAAQU,CAAE,GAEnChC,EAAI,OAAS,SACfoC,EAAU,KAAK,KAAKd,EAAO,OAASY,CAAE,EAE1C,CAEA,GAAIb,IAAe,KAAK,YAAa,MAAM,IAAI,MAAM,uBAAuB,EAC5E,IAAMgC,GAASrD,EAAI,QAAU,EAO7B,GANA,KAAK,cAAc,SAASS,EAAM,GAAIa,EAAQ,GAAMqB,GAAOU,EAAM,EACjE/B,EAAS,OAKLtB,EAAI,iBAAmB,EAAG,CAE5B,IAAMsD,EAAWf,EAAO,MAAQG,EAChCH,EAAO,EAAIC,EAEXD,EAAO,EAAI,KAAK,IAAIE,EAASa,EAAU,CAAC,CAC1C,MAGEf,EAAO,EAAI,KAAK,IAAIC,EAASL,EAAS,KAAK,cAAc,IAAI,CAEjE,OAASrC,EAAG,CACV,MAAAwB,GAAQ,MAAM,EACRxB,CACR,CACF,CAGA,MAAc,cAAcW,EAA8C,CACxE,IAAIF,EAAoB,IAAI,WAAW,MAAME,EAAM,KAAK,YAAY,CAAC,EAMrE,GAJIA,EAAM,cAAgB,MACxBF,EAAQ,MAAM,KAAK,gBAAgBA,CAAK,GAGtCE,EAAM,SAAW,IAAiB,CACpC,IAAM8C,EAAUC,GAAUjD,CAAK,EAG/B,GAAIgD,EAAQ,OAAS,aAAe,EAAEA,EAAQ,MAAQ,IAAM,EAAEA,EAAQ,OAAS,IAAMA,EAAQ,MAAQA,EAAQ,OAAS,KAAK,MAAM,WAC/H,MAAM,IAAI,WAAW,mDAAmD,EAE1E,IAAME,EAAO,IAAI,KAAK,CAAClD,CAAiB,EAAG,CAAE,KAAM,WAAY,CAAC,EAChE,GAAI,CAAC,OAAO,kBAAmB,CAC7B,IAAMmD,EAAM,IAAI,gBAAgBD,CAAI,EAC9BE,EAAM,IAAI,MAChB,OAAO,IAAI,QAAqB,CAACC,EAASC,IAAW,CACnDF,EAAI,iBAAiB,OAAQ,IAAM,CACjC,IAAI,gBAAgBD,CAAG,EACvB,IAAMI,EAASZ,EAAc,aAAa,OAAO,SAAUS,EAAI,MAAOA,EAAI,MAAM,EAChFG,EAAO,WAAW,IAAI,GAAG,UAAUH,EAAK,EAAG,CAAC,EAC5C,kBAAkBG,CAAM,EAAE,KAAKF,CAAO,EAAE,MAAMC,CAAM,CACtD,CAAC,EACDF,EAAI,iBAAiB,QAAS,IAAM,CAClC,IAAI,gBAAgBD,CAAG,EACvBG,EAAO,IAAI,MAAM,sBAAsB,CAAC,CAC1C,CAAC,EACDF,EAAI,IAAMD,CACZ,CAAC,CACH,CACA,OAAO,kBAAkBD,CAAI,CAC/B,CAGA,IAAM7C,EAAQH,EAAM,MACdI,EAASJ,EAAM,OAErB,GAAI,CAACG,GAAS,CAACC,EACb,MAAM,IAAI,MAAM,8CAA8C,EAGhE,IAAMC,EAAgBL,EAAM,SAAW,OACjCM,EAAgBH,EAAQC,EAASC,EAEvC,GAAIP,EAAM,OAASQ,EACjB,MAAM,IAAI,MAAM,yBAAyB,EAG3C,IAAMgD,EAAanD,EAAQC,EAE3B,GAAIJ,EAAM,SAAW,GAEnB,OAAO,kBAAkB,IAAI,UAAU,IAAI,kBAAkBF,EAAM,OAAuBA,EAAM,WAAYwD,EAAa,CAAwC,EAAGnD,EAAOC,CAAM,CAAC,EAMpL,IAAM/B,EAAO,IAAI,kBAAkBiF,EAAa,CAAwC,EAClFC,EAAQ,IAAI,YAAYzD,EAAM,OAAQA,EAAM,WAAY,KAAK,MAAMA,EAAM,WAAa,CAAC,CAAC,EACxF0D,EAAQ,IAAI,YAAYnF,EAAK,MAAM,EACnCoF,EAAgBH,EAAa,GAE/BI,EAAY,EACZC,EAAY,EAChB,QAASlF,EAAI,EAAGA,EAAIgF,EAAehF,GAAK,EAAG,CACzC,IAAMmF,EAAKL,EAAMG,GAAW,EACtBG,EAAKN,EAAMG,GAAW,EACtBI,EAAKP,EAAMG,GAAW,EAE5BF,EAAMG,GAAW,EAAI,WAAaC,EAClCJ,EAAMG,GAAW,EAAI,WAAcC,IAAO,GAAOC,GAAM,EACvDL,EAAMG,GAAW,EAAI,WAAcE,IAAO,GAAOC,GAAM,GACvDN,EAAMG,GAAW,EAAI,WAAcG,IAAO,CAC5C,CAGA,IAAIC,EAAUN,EAAgB,EAC1BO,EAAUP,EAAgB,EAC9B,QAAShF,EAAIgF,EAAehF,EAAI6E,EAAY7E,IAC1CJ,EAAK2F,CAAO,EAAQlE,EAAMiE,CAAO,EACjC1F,EAAK2F,EAAU,CAAC,EAAIlE,EAAMiE,EAAU,CAAC,EACrC1F,EAAK2F,EAAU,CAAC,EAAIlE,EAAMiE,EAAU,CAAC,EACrC1F,EAAK2F,EAAU,CAAC,EAAI,IACpBD,GAAW,EACXC,GAAW,EAGb,OAAO,kBAAkB,IAAI,UAAU3F,EAAM8B,EAAOC,CAAM,CAAC,CAC7D,CAEA,MAAc,gBAAgB6D,EAA6C,CACzE,GAAI,CACF,OAAO,MAAM,KAAK,YAAYA,EAAY,SAAS,CACrD,OAASC,EAAO,CACd,GAAIA,aAAiB,WAAY,MAAMA,EACvC,OAAO,MAAM,KAAK,YAAYD,EAAY,aAAa,CACzD,CACF,CAEA,MAAc,YAAYA,EAAwB/D,EAAwD,CACxG,IAAMiE,EAAQ,KAAK,IAAI,KAAK,MAAM,eAAgB,KAAK,MAAM,WAAa,EAAG,KAAK,MAAM,aAAe,GAAO,EAC1GC,EAAW,EAaTC,EAXS,IAAI,eAA6B,CAC9C,KAAKC,EAAY,CACf,GAAIF,GAAYH,EAAW,OAAQ,CACjCK,EAAW,MAAM,EACjB,MACF,CACA,IAAM/F,EAAM,KAAK,IAAI6F,EAAW,KAAMH,EAAW,MAAM,EACvDK,EAAW,QAAQ,IAAI,WAAWL,EAAW,SAASG,EAAU7F,CAAG,CAAC,CAAC,EACrE6F,EAAW7F,CACb,CACF,CAAC,EACqB,YAAY,IAAI,oBAAoB2B,CAAM,CAAC,EAAE,UAAU,EACvEqE,EAAuB,CAAC,EAC1BC,EAAc,EAClB,GAAI,CACF,OAAa,CACX,GAAM,CAAE,KAAAC,EAAM,MAAAC,CAAM,EAAI,MAAML,EAAO,KAAK,EAC1C,GAAII,EAAM,MAEV,GADAD,GAAeE,EAAM,WACjBF,EAAcL,EAChB,YAAME,EAAO,OAAO,EAAE,MAAM,IAAM,CAAC,CAAC,EAC9B,IAAI,WAAW,uCAAuC,EAE9DE,EAAO,KAAKG,CAAK,CACnB,CACF,QAAE,CACAL,EAAO,YAAY,CACrB,CAEA,IAAMzE,EAAS,IAAI,WAAW4E,CAAW,EACrCG,EAAS,EACb,QAAWC,KAASL,EAClB3E,EAAO,IAAIgF,EAAOD,CAAM,EACxBA,GAAUC,EAAM,OAElB,OAAOhF,CACT,CAEA,IAAW,QAA+C,CACxD,OAAO,KAAK,cAAc,MAC5B,CAEA,IAAW,qBAAmD,CAC5D,OAAO,KAAK,cAAc,kBAC5B,CAEA,IAAW,sBAAkE,CAC3E,OAAO,KAAK,qBACd,CACF,EE71BO,IAAMiF,EAAN,MAAMA,CAAyC,CA6BpD,YACmBC,EACjB,CADiB,cAAAA,EA3BnB,KAAQ,aAAe,EACvB,KAAiB,QAAwC,IAAI,IAU7D,KAAiB,oBAA2C,IAAI,IAChE,KAAiB,oBAA2C,IAAI,IAIhE,KAAiB,2BAA8BC,GAA4B,CACzE,IAAMC,EAAU,KAAK,oBAAoB,IAAID,CAAS,EAClDC,IAAY,SACd,KAAK,oBAAoB,OAAOA,CAAO,EACvC,KAAK,oBAAoB,OAAOD,CAAS,EACzC,KAAK,QAAQ,OAAOC,CAAO,EAE/B,EACA,KAAQ,cAA+B,CAAE,UAAW,GAAM,MAAO,MAAO,OAAQ,EAAG,UAAW,KAAM,EAKlG,KAAK,wBAA0B,KAAK,SAAS,eAC7C,KAAK,uBAA0BD,GAAsB,CACnD,KAAK,0BAA0BA,CAAS,EACxC,KAAK,2BAA2BA,CAAS,CAC3C,EACA,KAAK,SAAS,eAAiB,KAAK,sBACtC,CAEO,OAAc,CACnB,KAAK,aAAe,EACpB,KAAK,QAAQ,MAAM,EACnB,KAAK,oBAAoB,MAAM,EAC/B,KAAK,oBAAoB,MAAM,CACjC,CAEO,SAAgB,CACrB,KAAK,MAAM,EACP,KAAK,SAAS,iBAAmB,KAAK,yBACxC,KAAK,SAAS,eAAiB,KAAK,wBAExC,CAEO,WAAWE,EAAwBC,EAAgD,CACxF,IAAMC,EAAUF,GAAM,KAAK,eAErBG,EAAe,KAAK,oBAAoB,IAAID,CAAO,EACrDC,IAAiB,SACnB,KAAK,SAAS,YAAYA,CAAY,EACtC,KAAK,oBAAoB,OAAOD,CAAO,EACvC,KAAK,oBAAoB,OAAOC,CAAY,GAG1C,CAAC,KAAK,QAAQ,IAAID,CAAO,GAAK,KAAK,QAAQ,MAAQN,EAAkB,kBACvE,KAAK,wBAAwB,EAS/B,IAAMQ,EAAY,KAAK,SAAS,SAAS,EAAI,IAC7C,KAAK,QAAQ,OAAOF,CAAO,EAC3B,IAAIG,EAAgB,EACpB,QAAWC,KAAS,KAAK,QAAQ,OAAO,EAAGD,GAAiBC,EAAM,KAAK,KACvE,QAAWC,IAAe,CAAC,GAAO,EAAI,EACpC,OAAW,CAACC,EAAUF,CAAK,IAAK,KAAK,QAAS,CAC5C,GAAID,EAAgBJ,EAAU,KAAK,MAAQG,EAAW,MAClD,KAAK,oBAAoB,IAAII,CAAQ,IAAMD,IAC/CF,GAAiBC,EAAM,KAAK,KAC5B,KAAK,WAAWE,CAAQ,EAC1B,CAGF,YAAK,QAAQ,IAAIN,EAAS,CACxB,GAAGD,EACH,GAAIC,CACN,CAAC,EACMA,CACT,CAEO,SAASH,EAAiBO,EAAwCG,EAAoBC,EAAmBC,EAAsB,CAKpI,IAAMR,EAAe,KAAK,oBAAoB,IAAIJ,CAAO,EACrDI,IAAiB,QACnB,KAAK,oBAAoB,OAAOA,CAAY,EAE9C,KAAK,cAAc,UAAYM,EAC/B,KAAK,cAAc,MAAQC,EAC3B,KAAK,cAAc,OAASC,EAC5B,IAAMb,EAAY,KAAK,SAAS,SAASQ,EAAO,KAAK,aAAa,EAClE,KAAK,oBAAoB,IAAIP,EAASD,CAAS,EAC/C,KAAK,oBAAoB,IAAIA,EAAWC,CAAO,CACjD,CAEO,SAASA,EAA8C,CAC5D,OAAO,KAAK,QAAQ,IAAIA,CAAO,CACjC,CAEO,WAAWA,EAAuB,CACvC,KAAK,QAAQ,OAAOA,CAAO,EAC3B,IAAMD,EAAY,KAAK,oBAAoB,IAAIC,CAAO,EAClDD,IAAc,SAChB,KAAK,SAAS,YAAYA,CAAS,EACnC,KAAK,oBAAoB,OAAOC,CAAO,EACvC,KAAK,oBAAoB,OAAOD,CAAS,EAE7C,CAEO,WAAkB,CACvB,KAAK,QAAQ,MAAM,EACnB,QAAWA,KAAa,KAAK,oBAAoB,OAAO,EACtD,KAAK,SAAS,YAAYA,CAAS,EAErC,KAAK,oBAAoB,MAAM,EAC/B,KAAK,oBAAoB,MAAM,CACjC,CAEA,IAAW,QAA+C,CACxD,OAAO,KAAK,OACd,CAEA,IAAW,oBAAkD,CAC3D,OAAO,KAAK,mBACd,CAEA,IAAW,aAAsB,CAC/B,OAAO,KAAK,aAAe,CAC7B,CAEQ,yBAAgC,CACtC,OAAW,CAACC,CAAO,IAAK,KAAK,QAAS,CACpC,GAAI,KAAK,QAAQ,MAAQH,EAAkB,iBAAmB,EAC5D,MAEG,KAAK,oBAAoB,IAAIG,CAAO,GACvC,KAAK,QAAQ,OAAOA,CAAO,CAE/B,CACF,CACF,EA5JaH,EACa,iBAAmB,IADtC,IAAMgB,GAANhB,ECVP,IAAAiB,EAA8E,OAI9E,IAAAC,GAAsC,QACtCC,GAAuB,QAGvB,IAAMC,GAAkB,QAGlBC,EAAkB,mBACxBA,EAAgB,IAAI,qBAAmB,EAKvC,IAAMC,GAAoB,EACpBC,GAAe,IAAI,IACrBC,GAAa,GAEjB,SAASC,GAAiBC,EAA2B,CAC/CF,KACJA,GAAa,MAEb,iBAAa,CAAE,YAAAE,EAAa,QAASL,CAAgB,CAAC,EAAE,KACtDM,GAAKC,GAAeD,EAAGD,CAAW,EAClC,IAAM,CAAEF,GAAa,EAAO,CAC9B,EACF,CAEA,SAASK,GAAeH,EAA8B,CACpD,OAAOH,GAAa,IAAIG,CAAW,GAAG,IAAI,GAAK,IAAI,WAAQ,CAAE,YAAAA,EAAa,QAASL,CAAgB,CAAC,CACtG,CAEA,SAASO,GAAeE,EAAcJ,EAA2B,CAC3DI,EAAI,YAAcV,IACpBU,EAAI,QAAQ,EAEd,IAAMC,EAAOR,GAAa,IAAIG,CAAW,GAAK,CAAC,EAC3CK,EAAK,OAAST,KAChBS,EAAK,KAAKD,CAAG,EACbP,GAAa,IAAIG,EAAaK,CAAI,EAEtC,CAGO,IAAMC,GAAN,KAAyD,CAQ9D,YACmBC,EACAC,EACAC,EACjB,CAHiB,WAAAF,EACA,cAAAC,EACA,mBAAAC,EAVnB,KAAQ,MAAQ,EAChB,KAAQ,SAAW,GAEnB,KAAQ,gBAAkB,EAE1B,KAAiB,SAAW,IAAI,YAAY,UAAO,YAAY,EAO7D,KAAK,SAAS,IAAId,CAAe,EACjCI,GAAiB,KAAK,MAAM,WAAa,CAAC,CAC5C,CAEO,OAAc,CACnB,KAAK,eAAe,EACpB,KAAK,SAAS,KAAK,CAAC,EACpB,KAAK,SAAS,IAAIJ,CAAe,CACnC,CAEO,KAAKe,EAAuB,CACjC,KAAK,MAAQ,EACb,KAAK,SAAW,GAChB,KAAK,eAAe,EACpB,IAAMV,EAAc,KAAK,MAAM,WAAa,EAC5C,GAAI,CACF,KAAK,KAAOG,GAAeH,CAAW,CACxC,OAASW,EAAG,CAEV,QAAQ,KAAK,uCAAuCA,CAAC,EAAE,EACvD,KAAK,SAAW,GAChB,MACF,CACA,KAAK,gBAAkBX,EACvB,IAAMY,EAAYF,EAAO,OAAO,CAAC,IAAM,EAAI,EAAIG,GAC7C,KAAK,cAAc,MAAM,cAAc,aACvC,KAAK,cAAc,MAAM,eAAe,MAAM,EAChD,KAAK,KAAK,KAAKD,EAAW,KAAK,SAAU,KAAK,MAAM,iBAAiB,CACvE,CAEQ,gBAAuB,CAC7B,IAAMR,EAAM,KAAK,KACZA,IACL,KAAK,KAAO,OACZ,KAAK,SAAS,IAAIA,EAAI,OAAO,EAC7BF,GAAeE,EAAK,KAAK,eAAe,EAC1C,CAEO,IAAIU,EAAmBC,EAAeC,EAAmB,CAC9D,GAAI,OAAK,UAAY,CAAC,KAAK,MAI3B,IADA,KAAK,OAASA,EAAMD,EAChB,KAAK,MAAQ,KAAK,MAAM,eAAgB,CAC1C,QAAQ,KAAK,gCAAgC,EAC7C,KAAK,SAAW,GAChB,KAAK,KAAK,QAAQ,EAClB,MACF,CACA,GAAI,CACF,KAAK,KAAK,OAAOD,EAAMC,EAAOC,CAAG,CACnC,OAASL,EAAG,CACV,QAAQ,KAAK,uCAAuCA,CAAC,EAAE,EACvD,KAAK,SAAW,GAChB,KAAK,KAAK,QAAQ,CACpB,EACF,CAEO,OAAOM,EAA8C,CAC1D,GAAI,CACF,OAAO,KAAK,QAAQA,CAAO,CAC7B,QAAE,CACA,KAAK,eAAe,CACtB,CACF,CAEQ,QAAQA,EAA2B,CACzC,GAAI,KAAK,UAAY,CAACA,GAAW,CAAC,KAAK,KACrC,MAAO,GAGT,IAAMC,EAAQ,KAAK,KAAK,MAClBC,EAAS,KAAK,KAAK,OAGzB,GAAI,CAACD,GAAS,CAACC,EACb,OAAIA,GACF,KAAK,SAAS,cAAcA,CAAM,EAE7B,GAGT,IAAMC,EAASC,EAAc,aAAa,OAAWH,EAAOC,CAAM,EAClE,OAAAC,EAAO,WAAW,IAAI,GAAG,aAAa,IAAI,UAAU,KAAK,KAAK,MAAyCF,EAAOC,CAAM,EAAG,EAAG,CAAC,EAC3H,KAAK,SAAS,SAASC,CAAM,EACtB,EACT,CACF,EASA,SAASP,GAAgBS,EAAqBC,EAAgD,CAC5F,IAAIC,EAAK,EACT,GAAI,CAACD,EAGH,OAAOC,EAET,GAAIF,EAAK,UAAU,EACjB,GAAIA,EAAK,YAAY,EACnBE,EAAKC,GAAUF,EAAO,WAAW,IAAI,UAC5BD,EAAK,QAAQ,EAAG,CACzB,IAAMI,EAAKJ,EAAK,YAAqC,WAAWA,EAAK,WAAW,CAAC,EACjFE,KAAK,cAAW,GAAGE,CAAC,CACtB,MACEF,EAAKC,GAAUF,EAAO,KAAKD,EAAK,WAAW,CAAC,EAAE,IAAI,UAGhDA,EAAK,YAAY,EACnBE,EAAKC,GAAUF,EAAO,WAAW,IAAI,UAC5BD,EAAK,QAAQ,EAAG,CACzB,IAAMI,EAAKJ,EAAK,YAAqC,WAAWA,EAAK,WAAW,CAAC,EACjFE,KAAK,cAAW,GAAGE,CAAC,CACtB,MACEF,EAAKC,GAAUF,EAAO,KAAKD,EAAK,WAAW,CAAC,EAAE,IAAI,EAGtD,OAAOE,CACT,CAGA,SAASC,GAAUE,EAAyB,CAC1C,OAAI,aAAmBA,GACfA,EAAQ,MAAS,IAAMA,IAAU,EAAI,MAAS,IAAMA,IAAU,GAAK,MAAS,EAAIA,IAAU,GAAK,GACzG,CCpLO,IAAMC,GAAN,KAAwB,CAE7B,YACmBC,EACAC,EACAC,EACAC,EACjB,CAJiB,cAAAH,EACA,WAAAC,EACA,eAAAC,EACA,eAAAC,EALnB,KAAQ,cAA+B,CAAE,UAAW,GAAM,MAAO,MAAO,OAAQ,EAAG,UAAW,OAAQ,CAMnG,CAMI,SAASC,EAA4C,CAC1D,KAAK,cAAc,UAAY,KAAK,MAAM,eAC1C,KAAK,SAAS,SAASA,EAAK,KAAK,aAAa,CAChD,CAOO,cAAcC,EAAsB,CACzC,GAAI,KAAK,MAAM,eAAgB,CAC7B,IAAIC,EAAW,KAAK,UAAU,UAC1BA,EAAS,QAAU,IAAMA,EAAS,SAAW,MAC/CA,EAAWC,GAEb,IAAMC,EAAO,KAAK,KAAKH,EAASC,EAAS,MAAM,EAC/C,QAASG,EAAI,EAAGA,EAAID,EAAM,EAAEC,EAC1B,KAAK,UAAU,MAAM,cAAc,SAAS,CAEhD,CACF,CACF,ECrCO,IAAMC,GAAN,KAAsB,CAE3B,YACmBC,EACjB,CADiB,cAAAA,EAFnB,KAAQ,cAA+B,CAAE,UAAW,GAAM,MAAO,MAAO,OAAQ,EAAG,UAAW,KAAM,CAGjG,CAMI,SAASC,EAA4C,CAC1D,KAAK,SAAS,SAASA,EAAK,KAAK,aAAa,CAChD,CACF,ECiCA,IAAMC,GAAsC,CAC1C,kBAAmB,GACnB,WAAY,SACZ,aAAc,GACd,eAAgB,GAChB,kBAAmB,KACnB,eAAgB,SAChB,aAAc,IACd,gBAAiB,GACjB,WAAY,GACZ,aAAc,SACd,aAAc,GACd,eAAgB,QAClB,EAGMC,GAAyB,KAsBxB,IAAMC,GAAN,KAAsD,CAW3D,YAAYC,EAAoC,CANhD,KAAQ,aAA8B,CAAC,EAEvC,KAAQ,UAAwC,IAAI,IACpD,KAAiB,cAAgB,IAAIC,EACrC,KAAgB,aAA6B,KAAK,cAAc,MAG9D,KAAK,MAAQ,OAAO,OAAO,CAAC,EAAGC,GAAiBF,CAAI,EACpD,KAAK,aAAe,OAAO,OAAO,CAAC,EAAGE,GAAiBF,CAAI,CAC7D,CAEO,SAAgB,CACrB,QAAWG,KAAW,KAAK,UAAU,OAAO,EAAGA,EAAQ,MAAM,EAC7D,QAAWC,KAAO,KAAK,aACrBA,EAAI,QAAQ,EAEd,KAAK,aAAa,OAAS,EAC3B,KAAK,UAAU,MAAM,EACrB,KAAK,cAAc,QAAQ,CAC7B,CAEQ,iBAAiBC,EAA2B,CAClD,QAAWD,KAAOC,EAChB,KAAK,aAAa,KAAKD,CAAG,CAE9B,CAEO,SAASE,EAA8B,CAS5C,GARA,KAAK,UAAYA,EAGjB,KAAK,UAAY,IAAIC,EAAcD,CAAQ,EAC3C,KAAK,SAAW,IAAIE,EAAaF,EAAU,KAAK,UAAW,KAAK,KAAK,EACrE,KAAK,SAAS,aAAe,IAAM,KAAK,cAAc,KAAK,EAGvD,KAAK,MAAM,kBAAmB,CAChC,IAAMG,EAAYH,EAAS,QAAQ,eAAiB,CAAC,EACrDG,EAAU,iBAAmB,GAC7BA,EAAU,kBAAoB,GAC9BA,EAAU,gBAAkB,GAC5BH,EAAS,QAAQ,cAAgBG,CACnC,CAiCA,GA/BA,KAAK,cACH,KAAK,UACL,KAAK,SAGLH,EAAS,OAAO,mBAAmB,CAAE,OAAQ,IAAK,MAAO,GAAI,EAAGI,GAAU,KAAK,QAAQA,CAAM,CAAC,EAC9FJ,EAAS,OAAO,mBAAmB,CAAE,OAAQ,IAAK,MAAO,GAAI,EAAGI,GAAU,KAAK,QAAQA,CAAM,CAAC,EAC9FJ,EAAS,OAAO,mBAAmB,CAAE,MAAO,GAAI,EAAGI,GAAU,KAAK,KAAKA,CAAM,CAAC,EAC9EJ,EAAS,OAAO,mBAAmB,CAAE,OAAQ,IAAK,MAAO,GAAI,EAAGI,GAAU,KAAK,yBAAyBA,CAAM,CAAC,EAG/GJ,EAAS,SAASK,GAAS,KAAK,UAAU,OAAOA,CAAK,CAAC,EAQvDL,EAAS,OAAO,mBAAmB,CAAE,cAAe,IAAK,MAAO,GAAI,EAAG,IAAM,KAAK,MAAM,CAAC,EACzFA,EAAS,OAAO,mBAAmB,CAAE,MAAO,GAAI,EAAG,IAAM,KAAK,MAAM,CAAC,EACrEA,EAAS,MAAM,cAAc,eAAe,IAAM,KAAK,MAAM,CAAC,EAG9DA,EAAS,OAAO,eAAe,IAAM,KAAK,UAAU,cAAc,CAAC,EAGnEA,EAAS,SAASM,GAAW,KAAK,UAAU,eAAeA,CAAO,CAAC,CACrE,EAGI,KAAK,MAAM,aAAc,CAC3B,IAAMC,EAAe,IAAIC,GAAkB,KAAK,SAAW,KAAK,MAAO,KAAK,UAAYR,CAAQ,EAC1FS,EAAe,IAAIC,GAAa,KAAK,MAAOH,EAAcP,CAAQ,EACxE,KAAK,UAAU,IAAI,QAASS,CAAY,EACxC,KAAK,cACHT,EAAS,MAAM,cAAc,QAAQ,mBAAmB,CAAE,MAAO,GAAI,EAAGS,CAAY,CACtF,CACF,CAGA,GAAI,KAAK,MAAM,WAAY,CACzB,IAAME,EAAa,IAAIC,GAAgB,KAAK,QAAS,EAC/CC,EAAa,IAAIC,GAAW,KAAK,MAAO,KAAK,UAAYH,EAAYX,CAAQ,EACnF,KAAK,UAAU,IAAI,MAAOa,CAAU,EACpC,KAAK,cACHb,EAAS,MAAM,cAAc,QAAQ,mBAAmB,KAAMa,CAAU,CAC1E,CACF,CAGA,GAAI,KAAK,MAAM,aAAc,CAC3B,IAAME,EAAe,IAAIC,GAAkB,KAAK,QAAS,EACnDC,EAAe,IAAIC,GAAqB,KAAK,MAAO,KAAK,UAAYH,EAAcf,CAAQ,EACjG,KAAK,UAAU,IAAI,QAASiB,CAAY,EACxC,KAAK,cACHF,EACAE,EACAjB,EAAS,MAAM,cAAc,QAAQ,mBAAmB,CAAE,MAAO,GAAI,EAAGiB,CAAY,CACtF,CACF,CACF,CAGO,OAAiB,CAEtB,KAAK,MAAM,eAAiB,KAAK,aAAa,eAC9C,KAAK,MAAM,kBAAoB,KAAK,aAAa,kBAEjD,KAAK,UAAU,MAAM,EAErB,QAAWpB,KAAW,KAAK,UAAU,OAAO,EAC1CA,EAAQ,MAAM,EAEhB,MAAO,EACT,CAEA,IAAW,cAAuB,CAChC,OAAO,KAAK,UAAU,SAAS,GAAK,EACtC,CAEA,IAAW,aAAasB,EAAe,CACrC,KAAK,UAAU,SAASA,CAAK,EAC7B,KAAK,MAAM,aAAeA,CAC5B,CAEA,IAAW,cAAuB,CAChC,OAAI,KAAK,SACA,KAAK,SAAS,SAAS,EAEzB,EACT,CAEA,IAAW,iBAA2B,CACpC,OAAO,KAAK,MAAM,eACpB,CAEA,IAAW,gBAAgBC,EAAgB,CACzC,KAAK,MAAM,gBAAkBA,EAC7B,KAAK,WAAW,gBAAgBA,CAAK,CACvC,CAEO,qBAAqBC,EAAWC,EAA0C,CAC/E,OAAO,KAAK,UAAU,qBAAqBD,EAAGC,CAAC,CACjD,CAEO,wBAAwBD,EAAWC,EAA0C,CAClF,OAAO,KAAK,UAAU,wBAAwBD,EAAGC,CAAC,CACpD,CAEQ,QAAQC,EAAiB,CAC/B,KAAK,WAAW,MAAM,MAAMA,EAAG,EAAK,CACtC,CAEQ,QAAQnB,EAAwC,CACtD,QAASoB,EAAI,EAAGA,EAAIpB,EAAO,OAAQ,EAAEoB,EAC3BpB,EAAOoB,CAAC,IACT,KACH,KAAK,MAAM,eAAiB,IAIlC,MAAO,EACT,CAEQ,QAAQpB,EAAwC,CACtD,QAASoB,EAAI,EAAGA,EAAIpB,EAAO,OAAQ,EAAEoB,EAC3BpB,EAAOoB,CAAC,IACT,KACH,KAAK,MAAM,eAAiB,IAIlC,MAAO,EACT,CAGQ,KAAKpB,EAAwC,CACnD,OAAIA,EAAO,CAAC,EACH,GAOL,KAAK,MAAM,cACb,KAAK,QAAQ,kBAAkB,EACxB,IAEF,EACT,CAYQ,yBAAyBA,EAAwC,CACvE,GAAIA,EAAO,OAAS,EAClB,MAAO,GAET,GAAIA,EAAO,CAAC,IAAM,EAChB,OAAQA,EAAO,CAAC,EAAG,CACjB,IAAK,GACH,YAAK,QAAQ,SAASA,EAAO,CAAC,CAAC,MAAwB,KAAK,MAAM,iBAAiB,GAAG,EAC/E,GACT,IAAK,GACH,KAAK,MAAM,kBAAoB,KAAK,aAAa,kBACjD,KAAK,QAAQ,SAASA,EAAO,CAAC,CAAC,MAAwB,KAAK,MAAM,iBAAiB,GAAG,EAEtF,QAAWP,KAAW,KAAK,UAAU,OAAO,EAC1CA,EAAQ,MAAM,EAEhB,MAAO,GACT,IAAK,GACH,OAAIO,EAAO,OAAS,GAAK,EAAEA,EAAO,CAAC,YAAa,QAAUA,EAAO,CAAC,GAAKqB,IACrE,KAAK,MAAM,kBAAoBrB,EAAO,CAAC,EACvC,KAAK,QAAQ,SAASA,EAAO,CAAC,CAAC,MAAwB,KAAK,MAAM,iBAAiB,GAAG,GAEtF,KAAK,QAAQ,SAASA,EAAO,CAAC,CAAC,KAA4B,EAEtD,GACT,IAAK,GACH,YAAK,QAAQ,SAASA,EAAO,CAAC,CAAC,MAAwBqB,EAAsB,GAAG,EACzE,GACT,QACE,YAAK,QAAQ,SAASrB,EAAO,CAAC,CAAC,KAA4B,EACpD,EACX,CAEF,GAAIA,EAAO,CAAC,IAAM,EAChB,OAAQA,EAAO,CAAC,EAAG,CAEjB,IAAK,GACH,IAAIsB,EAAQ,KAAK,WAAW,YAAY,IAAI,OAAO,MAC/CC,EAAS,KAAK,WAAW,YAAY,IAAI,OAAO,OACpD,GAAI,CAACD,GAAS,CAACC,EAAQ,CAGrB,IAAMC,EAAWC,EACjBH,GAAS,KAAK,WAAW,MAAQ,IAAME,EAAS,MAChDD,GAAU,KAAK,WAAW,MAAQ,IAAMC,EAAS,MACnD,CACA,GAAIF,EAAQC,EAAS,KAAK,MAAM,WAC9B,KAAK,QAAQ,SAASvB,EAAO,CAAC,CAAC,MAAwBsB,EAAM,QAAQ,CAAC,CAAC,IAAIC,EAAO,QAAQ,CAAC,CAAC,GAAG,MAC1F,CAEL,IAAMN,EAAI,KAAK,MAAM,KAAK,KAAK,KAAK,MAAM,UAAU,CAAC,EACrD,KAAK,QAAQ,SAASjB,EAAO,CAAC,CAAC,MAAwBiB,CAAC,IAAIA,CAAC,GAAG,CAClE,CACA,MAAO,GACT,IAAK,GAEH,IAAMA,EAAI,KAAK,MAAM,KAAK,KAAK,KAAK,MAAM,UAAU,CAAC,EACrD,YAAK,QAAQ,SAASjB,EAAO,CAAC,CAAC,MAAwBiB,CAAC,IAAIA,CAAC,GAAG,EACzD,GACT,QACE,YAAK,QAAQ,SAASjB,EAAO,CAAC,CAAC,KAA4B,EACpD,EACX,CAGF,YAAK,QAAQ,SAASA,EAAO,CAAC,CAAC,KAA0B,EAClD,EACT,CACF", ++ "names": ["exports", "red", "n", "green", "blue", "alpha", "toRGBA8888", "g", "b", "a", "fromRGBA8888", "color", "nearestColorIndex", "palette", "r", "min", "idx", "i", "dr", "dg", "db", "d", "clamp", "low", "high", "value", "h2c", "t1", "t2", "c", "HLStoRGB", "h", "l", "s", "v", "normalizeRGB", "normalizeHLS", "p", "require_lib", "__commonJSMin", "exports", "InWasm", "z", "s", "b", "r", "def", "t", "d", "m", "W", "e", "require_Base64Decoder_wasm", "__commonJSMin", "exports", "inwasm_runtime_1", "wasmDecode", "MAP", "el", "D", "i", "EMPTY", "Base64Decoder", "keepSize", "maxBytes", "initialBytes", "m", "bytes", "requested", "needed", "newSize", "addPages", "data", "require_QoiDecoder_wasm", "__commonJSMin", "exports", "inwasm_runtime_1", "wasmQoiDecode", "QoiDecoder", "keepSize", "d", "pixels", "ib", "dl", "bytes", "chunkP", "exports", "Colors_1", "wasm_1", "decodeBase64", "s", "bytestring", "result", "WASM_BYTES", "WASM_MODULE", "NULL_CANVAS", "CallbackProxy", "width", "mode", "DEFAULT_OPTIONS", "DecoderAsync", "opts", "cbProxy", "importObj", "inst", "Decoder", "exports", "_instance", "_cbProxy", "module", "pixels", "offset", "additionalPixels", "newCanvas", "adv", "remaining", "c", "i", "fillColor", "palette", "paletteLimit", "truncate", "data", "start", "end", "p", "length", "j", "currentWidth", "escape", "final", "finalOffset", "bw", "currentHeight", "decode", "dec", "decodeAsync", "toDisposable", "fn", "DisposableStore", "o", "d", "Disposable", "MutableDisposable", "value", "Emitter", "listener", "thisArgs", "disposables", "toDisposable", "entry", "result", "idx", "event", "listeners", "len", "EventUtils", "forward", "from", "to", "e", "map", "i", "any", "events", "store", "DisposableStore", "runAndSubscribe", "handler", "initial", "import_Colors", "ImageRenderer", "_ImageRenderer", "Disposable", "_terminal", "MutableDisposable", "parent", "option", "toDisposable", "localDocument", "width", "height", "canvas", "ctx", "buffer", "imgData", "img", "value", "start", "end", "layer", "y", "w", "h", "imgSpec", "tileId", "col", "row", "count", "sourceWidth", "sourceHeight", "cols", "sx", "sy", "dx", "dy", "finalWidth", "finalHeight", "spec", "currentWidth", "currentHeight", "originalWidth", "originalHeight", "scaledWidth", "scaledHeight", "renderer", "key", "screenElement", "bWidth", "blueprint", "d32", "black", "white", "shift", "offset", "x", "ctx2", "i", "placeholder", "bitmap", "CELL_SIZE_DEFAULT", "ExtendedAttrsImage", "_ExtendedAttrsImage", "ext", "urlId", "imageId", "tileId", "value", "val", "EMPTY_ATTRS", "ImageStorage", "_terminal", "_renderer", "_opts", "e", "spec", "storedPixels", "id", "zero", "img", "opts", "cellSize", "cols", "rows", "buffer", "termCols", "termRows", "originX", "originY", "offset", "tileCount", "row", "line", "col", "endMarker", "imgSpec", "range", "hasTopImages", "hasBottomImages", "start", "end", "drawCalls", "placeholderCalls", "startTile", "startCol", "count", "a", "b", "call", "metrics", "oldCol", "tilesPerRow", "hasData", "rightCol", "lastTile", "expandCol", "x", "y", "orig", "canvas", "ImageRenderer", "room", "used", "current", "old", "oldSpec", "imgId", "import_Base64Decoder", "import_QoiDecoder", "toStr", "data", "s", "i", "toInt", "v", "toSize", "toName", "bs", "b", "DECODERS", "FILE_MARKER", "MULTIPARTFILE_MARKER", "FILEPART_MARKER", "FILEEND_MARKER", "REPORTCELLSIZE_MARKER", "MAX_FIELDCHARS", "HeaderParser", "k", "start", "end", "state", "pos", "buffer", "c", "UNSUPPORTED_TYPE", "imageType", "d", "d32", "width", "height", "jpgSize", "dim", "pos", "limit", "i", "len", "blockLength", "DEFAULT_HEADER", "IIPHandler", "_opts", "_renderer", "_storage", "_coreTerminal", "HeaderParser", "maxEncodedBytes", "initialBytes", "Base64Decoder", "QoiDecoder", "data", "start", "end", "dataPos", "success", "seqType", "w", "CELL_SIZE_DEFAULT", "h", "scale", "report", "cond", "metrics", "UNSUPPORTED_TYPE", "imageType", "blob", "e", "canvas", "ImageRenderer", "generation", "bm", "cw", "ch", "width", "height", "rw", "rh", "wf", "hf", "f", "s", "total", "cdim", "import_Base64Decoder", "parseKittyCommand", "data", "cmd", "parts", "part", "eqIdx", "key", "value", "numValue", "DECODER_OK", "KittyGraphicsHandler", "_opts", "_renderer", "_kittyStorage", "_coreTerminal", "key", "pending", "data", "start", "end", "controlEnd", "i", "copyLength", "parseKittyCommand", "payloadStart", "pendingKey", "previousEncodedSize", "decoderToRelease", "decoderCapacity", "maxPending", "oldest", "decoder", "Base64Decoder", "e", "success", "cmd", "isMoreComing", "decodeError", "finalCmd", "imageBytes", "result", "str", "bytes", "id", "image", "quiet", "format", "width", "height", "bytesPerPixel", "expectedBytes", "message", "placementId", "isOk", "pPart", "response", "generation", "bitmap", "cropX", "cropY", "cropW", "cropH", "maxCropW", "maxCropH", "finalCropW", "finalCropH", "cropped", "cw", "CELL_SIZE_DEFAULT", "ch", "imgCols", "imgRows", "w", "h", "buffer", "savedX", "savedY", "savedYbase", "layer", "scaled", "xOffset", "yOffset", "canvasW", "canvasH", "offsetCanvas", "ImageRenderer", "offsetCtx", "offsetBitmap", "zIndex", "scrolled", "metrics", "imageType", "blob", "url", "img", "resolve", "reject", "canvas", "pixelCount", "src32", "dst32", "alignedPixels", "srcOffset", "dstOffset", "b0", "b1", "b2", "srcByte", "dstByte", "compressed", "error", "limit", "offsetIn", "reader", "controller", "chunks", "totalLength", "done", "value", "offset", "chunk", "_KittyImageStorage", "_storage", "storageId", "kittyId", "id", "imageData", "imageId", "oldStorageId", "byteLimit", "retainedBytes", "image", "evictPlaced", "oldestId", "scrolling", "layer", "zIndex", "KittyImageStorage", "import_Colors", "import_Decoder", "import_wasm", "MEM_PERMA_LIMIT", "DEFAULT_PALETTE", "MAX_IDLE_DECODERS", "idleDecoders", "poolPrimed", "primeDecoderPool", "memoryLimit", "d", "releaseDecoder", "acquireDecoder", "dec", "idle", "SixelHandler", "_opts", "_storage", "_coreTerminal", "params", "e", "fillColor", "extractActiveBg", "data", "start", "end", "success", "width", "height", "canvas", "ImageRenderer", "attr", "colors", "bg", "convertLe", "t", "color", "SixelImageStorage", "_storage", "_opts", "_renderer", "_terminal", "img", "height", "cellSize", "CELL_SIZE_DEFAULT", "rows", "i", "IIPImageStorage", "_storage", "img", "DEFAULT_OPTIONS", "MAX_SIXEL_PALETTE_SIZE", "ImageAddon", "opts", "Emitter", "DEFAULT_OPTIONS", "handler", "obj", "args", "terminal", "ImageRenderer", "ImageStorage", "windowOps", "params", "range", "metrics", "sixelStorage", "SixelImageStorage", "sixelHandler", "SixelHandler", "iipStorage", "IIPImageStorage", "iipHandler", "IIPHandler", "kittyStorage", "KittyImageStorage", "kittyHandler", "KittyGraphicsHandler", "limit", "value", "x", "y", "s", "i", "MAX_SIXEL_PALETTE_SIZE", "width", "height", "cellSize", "CELL_SIZE_DEFAULT"] } diff --git a/src/IIPHandler.ts b/src/IIPHandler.ts -index 559b907416eb38318f439d060d7f89311ed34c7e..8541b4b0ea0d6b451aaae49007d69df64c5bd088 100644 +index 559b907416eb38318f439d060d7f89311ed34c7e..73cedbe99f831bffd202697cc8ba80a46a39cb99 100644 --- a/src/IIPHandler.ts +++ b/src/IIPHandler.ts -@@ -34,6 +34,7 @@ const DEFAULT_HEADER: IHeaderFields = { +@@ -13,8 +13,10 @@ import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics'; + + // Local const enum mirror - esbuild can't inline const enums from external packages + const enum DecoderConst { +- // Limit held memory in base64 decoder (encoded bytes). +- KEEP_DATA = 4194304, ++ // Held memory in base64/QOI decoders between images. Zero because each kept ++ // decoder pins a wasm memory, and V8 caps those per process (~124 in a ++ // sandboxed renderer), so idle terminals must not hold one. ++ KEEP_DATA = 0, + // Initial buffer allocation for the decoder. + INITIAL_DATA = 1048576, + // Local mirror of const enum (esbuild can't inline const enums from external packages) +@@ -34,6 +36,7 @@ const DEFAULT_HEADER: IHeaderFields = { export class IIPHandler implements IOscHandler, IResetHandler { @@ -55,7 +68,7 @@ index 559b907416eb38318f439d060d7f89311ed34c7e..8541b4b0ea0d6b451aaae49007d69df6 private _aborted = false; private _hp = new HeaderParser(); private _header: IHeaderFields = DEFAULT_HEADER; -@@ -55,6 +56,7 @@ export class IIPHandler implements IOscHandler, IResetHandler { +@@ -55,6 +58,7 @@ export class IIPHandler implements IOscHandler, IResetHandler { } public reset(): void { @@ -63,7 +76,47 @@ index 559b907416eb38318f439d060d7f89311ed34c7e..8541b4b0ea0d6b451aaae49007d69df6 this._hp.reset(); this._dec.release(); this._qoiDec.release(); -@@ -198,8 +200,13 @@ export class IIPHandler implements IOscHandler, IResetHandler { +@@ -92,7 +96,10 @@ export class IIPHandler implements IOscHandler, IResetHandler { + this._aborted = true; + return; + } +- this._dec.init(); ++ if (!this._initDecoder()) { ++ this._aborted = true; ++ return; ++ } + } else if (this._abortMulti) { + this._aborted = true; + return; +@@ -135,7 +142,9 @@ export class IIPHandler implements IOscHandler, IResetHandler { + this._isMultipart = true; + this._abortMulti = false; + this._dec.release(); +- this._dec.init(); ++ if (!this._initDecoder()) { ++ this._abortMulti = true; ++ } + return true; + } + +@@ -179,7 +188,15 @@ export class IIPHandler implements IOscHandler, IResetHandler { + + let blob: Blob | ImageData; + if (metrics.mime === 'image/qoi') { +- const data = this._qoiDec.decode(this._dec.data8); ++ let data: Uint8Array; ++ try { ++ data = this._qoiDec.decode(this._dec.data8); ++ } catch (e) { ++ console.warn('IIP: could not decode QOI image', e); ++ this._dec.release(); ++ this._qoiDec.release(); ++ return true; ++ } + blob = new ImageData( + new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength), + this._qoiDec.width, +@@ -198,8 +215,13 @@ export class IIPHandler implements IOscHandler, IResetHandler { blob = new Blob([this._dec.data8], { type: metrics.mime }); } this._dec.release(); @@ -77,6 +130,25 @@ index 559b907416eb38318f439d060d7f89311ed34c7e..8541b4b0ea0d6b451aaae49007d69df6 this._storage.addImage(bm); return true; }) +@@ -209,6 +231,18 @@ export class IIPHandler implements IOscHandler, IResetHandler { + }); + } + ++ // Why: wasm memory exhaustion must drop this image, not throw out of the parser and wedge the write queue. ++ private _initDecoder(): boolean { ++ try { ++ this._dec.init(); ++ return true; ++ } catch (e) { ++ console.warn('IIP: could not allocate decoder', e); ++ this._dec.release(); ++ return false; ++ } ++ } ++ + private _resize(w: number, h: number): [number, number] { + const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width; + const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height; diff --git a/src/ImageAddon.ts b/src/ImageAddon.ts index 8fd39543118cd420e36c1614c1af370b6c7bbfbb..0c44d2a81642113417bf8dc10a4faa76d7cc5864 100644 --- a/src/ImageAddon.ts @@ -189,8 +261,156 @@ index 5854efaec1fdf9dfcb886023542998a563b6d2f2..3afaf9bd63ffd7a4cdf32bf0ac24cf33 } public get document(): Document | undefined { +diff --git a/src/SixelHandler.ts b/src/SixelHandler.ts +index 1af2d85bcdd541ed60b1e707f6186a91f1bbf1b2..0711a122ea43d5212a2851add9e744b65550ec85 100644 +--- a/src/SixelHandler.ts ++++ b/src/SixelHandler.ts +@@ -10,6 +10,7 @@ import { RGBA8888 } from 'sixel/lib/Types'; + import { ImageRenderer } from './ImageRenderer'; + + import { DecoderAsync, Decoder } from 'sixel/lib/Decoder'; ++import { LIMITS } from 'sixel/lib/wasm'; + + // always free decoder ressources after decoding if it exceeds this limit + const MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB +@@ -18,48 +19,88 @@ const MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB + const DEFAULT_PALETTE = PALETTE_ANSI_256; + DEFAULT_PALETTE.set(PALETTE_VT340_COLOR); + ++// Why pooled: every decoder owns a wasm memory, and V8 caps live wasm memories ++// per process (~124 in a sandboxed renderer). Terminals borrow a decoder only ++// while a SIXEL sequence is open, so idle terminals hold none. ++const MAX_IDLE_DECODERS = 2; ++const idleDecoders = new Map(); ++let poolPrimed = false; ++ ++function primeDecoderPool(memoryLimit: number): void { ++ if (poolPrimed) return; ++ poolPrimed = true; ++ // Async compile once, off the parser's hot path; later decoders reuse the cached module. ++ DecoderAsync({ memoryLimit, palette: DEFAULT_PALETTE }).then( ++ d => releaseDecoder(d, memoryLimit), ++ () => { poolPrimed = false; } ++ ); ++} ++ ++function acquireDecoder(memoryLimit: number): Decoder { ++ return idleDecoders.get(memoryLimit)?.pop() ?? new Decoder({ memoryLimit, palette: DEFAULT_PALETTE }); ++} ++ ++function releaseDecoder(dec: Decoder, memoryLimit: number): void { ++ if (dec.memoryUsage > MEM_PERMA_LIMIT) { ++ dec.release(); ++ } ++ const idle = idleDecoders.get(memoryLimit) ?? []; ++ if (idle.length < MAX_IDLE_DECODERS) { ++ idle.push(dec); ++ idleDecoders.set(memoryLimit, idle); ++ } ++} ++ + + export class SixelHandler implements IDcsHandler, IResetHandler { + private _size = 0; + private _aborted = false; + private _dec: Decoder | undefined; ++ private _decMemoryLimit = 0; ++ // Color registers outlive a single image, so they live here rather than in a pooled decoder. ++ private readonly _palette = new Uint32Array(LIMITS.PALETTE_SIZE); + + constructor( + private readonly _opts: IImageAddonOptions, + private readonly _storage: SixelImageStorage, + private readonly _coreTerminal: ITerminalExt + ) { +- DecoderAsync({ +- memoryLimit: this._opts.pixelLimit * 4, +- palette: DEFAULT_PALETTE, +- paletteLimit: this._opts.sixelPaletteLimit +- }).then(d => this._dec = d); ++ this._palette.set(DEFAULT_PALETTE); ++ primeDecoderPool(this._opts.pixelLimit * 4); + } + + public reset(): void { +- /** +- * reset sixel decoder to defaults: +- * - release all memory +- * - nullify palette (4096) +- * - apply default palette (256) +- */ +- if (this._dec) { +- this._dec.release(); +- // FIXME: missing interface on decoder to nullify full palette +- (this._dec as any)._palette.fill(0); +- this._dec.init(0, DEFAULT_PALETTE, this._opts.sixelPaletteLimit); +- } ++ this._returnDecoder(); ++ this._palette.fill(0); ++ this._palette.set(DEFAULT_PALETTE); + } + + public hook(params: IParams): void { + this._size = 0; + this._aborted = false; +- if (this._dec) { +- const fillColor = params.params[1] === 1 ? 0 : extractActiveBg( +- this._coreTerminal._core._inputHandler._curAttrData, +- this._coreTerminal._core._themeService?.colors); +- this._dec.init(fillColor, null, this._opts.sixelPaletteLimit); ++ this._returnDecoder(); ++ const memoryLimit = this._opts.pixelLimit * 4; ++ try { ++ this._dec = acquireDecoder(memoryLimit); ++ } catch (e) { ++ // Why: exhausting wasm memory must drop this image, not throw out of the parser and wedge the write queue. ++ console.warn(`SIXEL: could not allocate decoder - ${e}`); ++ this._aborted = true; ++ return; + } ++ this._decMemoryLimit = memoryLimit; ++ const fillColor = params.params[1] === 1 ? 0 : extractActiveBg( ++ this._coreTerminal._core._inputHandler._curAttrData, ++ this._coreTerminal._core._themeService?.colors); ++ this._dec.init(fillColor, this._palette, this._opts.sixelPaletteLimit); ++ } ++ ++ private _returnDecoder(): void { ++ const dec = this._dec; ++ if (!dec) return; ++ this._dec = undefined; ++ this._palette.set(dec.palette); ++ releaseDecoder(dec, this._decMemoryLimit); + } + + public put(data: Uint32Array, start: number, end: number): void { +@@ -83,6 +124,14 @@ export class SixelHandler implements IDcsHandler, IResetHandler { + } + + public unhook(success: boolean): boolean | Promise { ++ try { ++ return this._unhook(success); ++ } finally { ++ this._returnDecoder(); ++ } ++ } ++ ++ private _unhook(success: boolean): boolean { + if (this._aborted || !success || !this._dec) { + return true; + } +@@ -100,9 +149,6 @@ export class SixelHandler implements IDcsHandler, IResetHandler { + + const canvas = ImageRenderer.createCanvas(undefined, width, height); + canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray, width, height), 0, 0); +- if (this._dec.memoryUsage > MEM_PERMA_LIMIT) { +- this._dec.release(); +- } + this._storage.addImage(canvas); + return true; + } diff --git a/src/kitty/KittyGraphicsHandler.ts b/src/kitty/KittyGraphicsHandler.ts -index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1fa320c4f8 100644 +index de889dfff75d9ecc8ab47a025e6989ffe75bb202..cf66e5b75c78645b1641e53d1425d88201134f78 100644 --- a/src/kitty/KittyGraphicsHandler.ts +++ b/src/kitty/KittyGraphicsHandler.ts @@ -7,6 +7,7 @@ import { IDisposable } from '@xterm/xterm'; @@ -217,10 +437,12 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f this._cleanupAllPending(); if (this._activeDecoder) { this._activeDecoder.release(); -@@ -200,6 +203,25 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos +@@ -200,8 +203,38 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos this._activeDecoder = pending.decoder; } if (!this._activeDecoder) { +- this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes); +- this._activeDecoder.init(); + // Budget WASM capacity, including one page of decoder state and rounding. + const decoderCapacity = this._maxEncodedBytes + 131072; + if (decoderCapacity > this._opts.storageLimit * 1000000) { @@ -240,10 +462,23 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f + this._sendResponse(oldest[1].cmd.id, 'ENOMEM:pending image budget exceeded', oldest[1].cmd.quiet ?? 0); + } + } - this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes); - this._activeDecoder.init(); ++ const decoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes); ++ try { ++ decoder.init(); ++ } catch (e) { ++ // Why: wasm memory exhaustion must drop this image, not throw out of the parser and wedge the write queue. ++ console.warn('KITTY: could not allocate decoder', e); ++ this._aborted = true; ++ if (this._parsedCommand?.id !== undefined) { ++ this._sendResponse(this._parsedCommand.id, 'ENOMEM:could not allocate decoder', this._parsedCommand.quiet ?? 0); ++ } ++ return; ++ } ++ this._activeDecoder = decoder; } -@@ -550,9 +572,11 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos + + if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) { +@@ -550,9 +583,11 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos } private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise { @@ -255,7 +490,7 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f const cropX = Math.max(0, cmd.x ?? 0); const cropY = Math.max(0, cmd.y ?? 0); const cropW = cmd.sourceWidth || (bitmap.width - cropX); -@@ -660,6 +684,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos +@@ -660,6 +695,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos } } @@ -263,7 +498,7 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f const zIndex = cmd.zIndex ?? 0; this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex); bitmap = undefined; // ownership transferred to storage -@@ -693,6 +718,12 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos +@@ -693,6 +729,12 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos } if (image.format === KittyFormat.PNG) { @@ -276,7 +511,7 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f const blob = new Blob([bytes as BlobPart], { type: 'image/png' }); if (!window.createImageBitmap) { const url = URL.createObjectURL(blob); -@@ -775,27 +806,45 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos +@@ -775,27 +817,45 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos private async _decompressZlib(compressed: Uint8Array): Promise { try { return await this._decompress(compressed, 'deflate'); diff --git a/config/patches/i18next-cli@1.74.2.patch b/config/patches/i18next-cli@1.74.2.patch new file mode 100644 index 00000000000..e4eb976d1ec --- /dev/null +++ b/config/patches/i18next-cli@1.74.2.patch @@ -0,0 +1,44 @@ +diff --git a/dist/cjs/extractor/core/extractor.js b/dist/cjs/extractor/core/extractor.js +index 6d61ade2471c20bf1a253e3784bfadcb01440545..c1b001e78a215f89a8d4da1756a87562a5a6e723 100644 +--- a/dist/cjs/extractor/core/extractor.js ++++ b/dist/cjs/extractor/core/extractor.js +@@ -143,6 +143,8 @@ const extractionSiteRegexes = new WeakMap(); + * @internal + */ + function mayContainExtractionSite(code, config) { ++ // Escaped identifiers need the parser; comments can separate a name from its call. ++ if (code.includes('\\u')) return true; + let re = extractionSiteRegexes.get(config); + if (!re) { + const calls = new Set(['t']); +@@ -160,7 +162,7 @@ function mayContainExtractionSite(code, config) { + for (const component of config.extract.transComponents || ['Trans']) + names.add(component.split('.').pop()); + const alt = (set) => [...set].map(s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'); +- re = new RegExp(`(? [...set].map(s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'); +- re = new RegExp(`(? #include #include -@@ -44,12 +45,40 @@ struct pty_baton { +@@ -44,15 +45,37 @@ struct pty_baton { HANDLE hOut; HPCON hpc; @@ -749,18 +749,25 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c }; static std::vector> ptyHandles; -+// Orca: guards the job accessors below, and PtyKill, against the exit watcher -+// thread. It does NOT make the whole table safe -- PtyResize and PtyClear still -+// read it unlocked, as they always have -- but it closes the window this patch -+// opened, where the watcher can close hShell/hJob and free the baton between a -+// lookup and its use. -+// Handle VALUES are recycled aggressively, so an unguarded read could pass the -+// shell-pid check against an unrelated process and terminate the wrong job. ++// Orca: every table access shares the exit watcher's lock; handles can be recycled. +static std::mutex ptyJobMutex; static volatile LONG ptyCounter; - static pty_baton* get_pty_baton(int id) { -@@ -102,8 +131,31 @@ void SetupExitCallback(Napi::Env env, Napi::Function cb, pty_baton* baton) { +-static pty_baton* get_pty_baton(int id) { ++static pty_baton* get_pty_baton_locked(int id) { + auto it = std::find_if(ptyHandles.begin(), ptyHandles.end(), [id](const auto& ptyHandle) { + return ptyHandle->id == id; + }); +@@ -62,7 +85,7 @@ static pty_baton* get_pty_baton(int id) { + return nullptr; + } + +-static bool remove_pty_baton(int id) { ++static bool remove_pty_baton_locked(int id) { + auto it = std::remove_if(ptyHandles.begin(), ptyHandles.end(), [id](const auto& ptyHandle) { + return ptyHandle->id == id; + }); +@@ -102,8 +125,31 @@ void SetupExitCallback(Napi::Env env, Napi::Function cb, pty_baton* baton) { // Get process exit code. GetExitCodeProcess(baton->hShell, (LPDWORD)(&exit_event->exit_code)); // Clean up handles @@ -782,7 +789,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c + // NDEBUG would compile the call away and leak every baton. + baton->shellExited = true; + if (baton->consoleClosed) { -+ const bool removed = remove_pty_baton(baton->id); ++ const bool removed = remove_pty_baton_locked(baton->id); + assert(removed); + (void)removed; + } @@ -794,7 +801,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c auto status = tsfn.BlockingCall(exit_event, callback); // In main thread switch (status) { -@@ -242,6 +294,20 @@ +@@ -242,6 +288,20 @@ HRESULT CreateNamedPipesAndPseudoConsole(const Napi::CallbackInfo& info, return HRESULT_FROM_WIN32(GetLastError()); } @@ -815,15 +822,40 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c static Napi::Value PtyStartProcess(const Napi::CallbackInfo& info) { Napi::Env env(info.Env()); Napi::HandleScope scope(env); -@@ -303,6 +369,7 @@ +@@ -301,8 +361,10 @@ static Napi::Value PtyStartProcess(const Napi::CallbackInfo& info) { + // We were able to instantiate a conpty + const int ptyId = InterlockedIncrement(&ptyCounter); marshal.Set("pty", Napi::Number::New(env, ptyId)); - ptyHandles.emplace_back( - std::make_unique(ptyId, hIn, hOut, hpc)); -+ ptyHandles.back()->allowJobBreakaway = !usesCygwinRuntime(shellpath); +- ptyHandles.emplace_back( +- std::make_unique(ptyId, hIn, hOut, hpc)); ++ auto baton = std::make_unique(ptyId, hIn, hOut, hpc); ++ baton->allowJobBreakaway = !usesCygwinRuntime(shellpath); ++ std::lock_guard guard(ptyJobMutex); ++ ptyHandles.emplace_back(std::move(baton)); } else { throw Napi::Error::New(env, "Cannot launch conpty"); } -@@ -409,6 +476,15 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { +@@ -350,11 +412,15 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { + const bool useConptyDll = info[4].As().Value(); + Napi::Function exitCallback = info[5].As(); + +- // Fetch pty handle from ID and start process +- pty_baton* handle = get_pty_baton(id); +- if (!handle) { +- throw Napi::Error::New(env, "Invalid pty handle"); ++ pty_baton* handle; ++ { ++ std::lock_guard guard(ptyJobMutex); ++ handle = get_pty_baton_locked(id); ++ if (!handle || handle->consoleClosed) { ++ throw Napi::Error::New(env, "Invalid pty handle"); ++ } + } ++ // No watcher exists for this baton until SetupExitCallback below; pipe waits stay unlocked. + + // Prepare command line + std::unique_ptr mutableCommandline = std::make_unique(cmdline.length() + 1); +@@ -409,6 +475,15 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { throw errorWithCode(info, "UpdateProcThreadAttribute failed"); } @@ -839,7 +871,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c PROCESS_INFORMATION piClient{}; fSuccess = !!CreateProcessW( nullptr, -@@ -416,7 +492,10 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { +@@ -416,7 +491,10 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { nullptr, // lpProcessAttributes nullptr, // lpThreadAttributes false, // bInheritHandles VERY IMPORTANT that this is false @@ -851,7 +883,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c envArg, // lpEnvironment mutableCwd.get(), // lpCurrentDirectory &siEx.StartupInfo, // lpStartupInfo -@@ -426,8 +505,48 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { +@@ -426,8 +504,48 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { throw errorWithCode(info, "Cannot create process"); } @@ -902,25 +934,52 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c if (useConptyDll && fLoadedDll) { PFNRELEASEPSEUDOCONSOLE const pfnReleasePseudoConsole = (PFNRELEASEPSEUDOCONSOLE)GetProcAddress( -@@ -440,6 +559,8 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { +@@ -438,8 +556,12 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { + } + } - // Update handle - handle->hShell = piClient.hProcess; -+ handle->shellPid = piClient.dwProcessId; -+ handle->hJob = hJob; +- // Update handle +- handle->hShell = piClient.hProcess; ++ { ++ std::lock_guard guard(ptyJobMutex); ++ handle->hShell = piClient.hProcess; ++ handle->shellPid = piClient.dwProcessId; ++ handle->hJob = hJob; ++ } // Close the thread handle to avoid resource leak CloseHandle(piClient.hThread); -@@ -544,27 +665,213 @@ static Napi::Value PtyKill(const Napi::CallbackInfo& info) { +@@ -472,9 +594,10 @@ static Napi::Value PtyResize(const Napi::CallbackInfo& info) { + SHORT rows = static_cast(info[2].As().Uint32Value()); + const bool useConptyDll = info[3].As().Value(); + +- const pty_baton* handle = get_pty_baton(id); ++ std::lock_guard guard(ptyJobMutex); ++ const pty_baton* handle = get_pty_baton_locked(id); + +- if (handle != nullptr) { ++ if (handle != nullptr && !handle->consoleClosed) { + HANDLE hLibrary = LoadConptyDll(info, useConptyDll); + bool fLoadedDll = hLibrary != nullptr; + if (fLoadedDll) +@@ -513,9 +636,10 @@ static Napi::Value PtyClear(const Napi::CallbackInfo& info) { + return env.Undefined(); + } + +- const pty_baton* handle = get_pty_baton(id); ++ std::lock_guard guard(ptyJobMutex); ++ const pty_baton* handle = get_pty_baton_locked(id); + +- if (handle != nullptr) { ++ if (handle != nullptr && !handle->consoleClosed) { + HANDLE hLibrary = LoadConptyDll(info, useConptyDll); + bool fLoadedDll = hLibrary != nullptr; + if (fLoadedDll) +@@ -544,29 +668,215 @@ static Napi::Value PtyKill(const Napi::CallbackInfo& info) { int id = info[0].As().Int32Value(); const bool useConptyDll = info[1].As().Value(); - const pty_baton* handle = get_pty_baton(id); -- -- if (handle != nullptr) { -- HANDLE hLibrary = LoadConptyDll(info, useConptyDll); -- bool fLoadedDll = hLibrary != nullptr; -- if (fLoadedDll) + // Orca: resolve the DLL BEFORE touching any baton state, for the same reason + // PtyConnect does it before creating anything. LoadConptyDll throws when + // conpty.dll is missing, and a throw after consoleClosed was set would strand @@ -934,7 +993,18 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c + (HMODULE)hLibrary, + useConptyDll ? "ConptyClosePseudoConsole" : "ClosePseudoConsole"); + } -+ + +- if (handle != nullptr) { +- HANDLE hLibrary = LoadConptyDll(info, useConptyDll); +- bool fLoadedDll = hLibrary != nullptr; +- if (fLoadedDll) +- { +- PFNCLOSEPSEUDOCONSOLE const pfnClosePseudoConsole = (PFNCLOSEPSEUDOCONSOLE)GetProcAddress( +- (HMODULE)hLibrary, +- useConptyDll ? "ConptyClosePseudoConsole" : "ClosePseudoConsole"); +- if (pfnClosePseudoConsole) +- { +- pfnClosePseudoConsole(handle->hpc); + // Orca: the baton now outlives the shell, so this runs on a self-exited pty + // too -- that is the whole point. Take what we need under the lock: the + // watcher thread nulls hShell the moment the shell dies, and TerminateProcess @@ -945,7 +1015,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c + bool owed = false; + { + std::lock_guard guard(ptyJobMutex); -+ pty_baton* handle = get_pty_baton(id); ++ pty_baton* handle = get_pty_baton_locked(id); + // Why the consoleClosed check: a second kill() would otherwise close the + // same pseudoconsole twice. Upstream relied on the baton being gone. + if (handle != nullptr && !handle->consoleClosed) { @@ -965,9 +1035,9 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c + hShellDup = nullptr; + TerminateProcess(handle->hShell, 1); + } -+ } + } + if (handle->shellExited) { -+ const bool removed = remove_pty_baton(id); ++ const bool removed = remove_pty_baton_locked(id); + assert(removed); + (void)removed; + } @@ -979,19 +1049,11 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c + // drained, and the watcher must be able to take the lock while it does. + if (owed) { + if (pfnClosePseudoConsole) - { -- PFNCLOSEPSEUDOCONSOLE const pfnClosePseudoConsole = (PFNCLOSEPSEUDOCONSOLE)GetProcAddress( -- (HMODULE)hLibrary, -- useConptyDll ? "ConptyClosePseudoConsole" : "ClosePseudoConsole"); -- if (pfnClosePseudoConsole) -- { -- pfnClosePseudoConsole(handle->hpc); -- } -- } ++ { ++ pfnClosePseudoConsole(hpc); + } - if (useConptyDll) { - TerminateProcess(handle->hShell, 1); -+ pfnClosePseudoConsole(hpc); -+ } + if (hShellDup != nullptr) { + TerminateProcess(hShellDup, 1); + CloseHandle(hShellDup); @@ -999,8 +1061,8 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c } return env.Undefined(); -+} -+ + } + +/** + * Orca: confirm a baton really is the pty the caller means. + * @@ -1032,7 +1094,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c + // Held across the lookup AND the Win32 call: the watcher thread can otherwise + // close these handles and free the baton in between. + std::lock_guard guard(ptyJobMutex); -+ const pty_baton* handle = get_pty_baton(info[0].As().Int32Value()); ++ const pty_baton* handle = get_pty_baton_locked(info[0].As().Int32Value()); + if (!ownsShell(handle, info[1].As().Uint32Value())) { + return Napi::Boolean::New(env, false); + } @@ -1064,7 +1126,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c + // Held across the lookup AND the Win32 call: the watcher thread can otherwise + // close these handles and free the baton in between. + std::lock_guard guard(ptyJobMutex); -+ const pty_baton* handle = get_pty_baton(info[0].As().Int32Value()); ++ const pty_baton* handle = get_pty_baton_locked(info[0].As().Int32Value()); + if (!ownsShell(handle, info[1].As().Uint32Value())) { + return env.Null(); + } @@ -1138,10 +1200,12 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c + } + hHostJob = job; + return Napi::Boolean::New(env, true); - } - ++} ++ /** -@@ -577,6 +884,9 @@ Napi::Object init(Napi::Env env, Napi::Object exports) { + * Init + */ +@@ -577,6 +887,9 @@ Napi::Object init(Napi::Env env, Napi::Object exports) { exports.Set("resize", Napi::Function::New(env, PtyResize)); exports.Set("clear", Napi::Function::New(env, PtyClear)); exports.Set("kill", Napi::Function::New(env, PtyKill)); diff --git a/config/patches/xterm-src/@xterm__addon-image@0.10.0-beta.300.src.patch b/config/patches/xterm-src/@xterm__addon-image@0.10.0-beta.300.src.patch index 2c5aa15fac1..3b581c30477 100644 --- a/config/patches/xterm-src/@xterm__addon-image@0.10.0-beta.300.src.patch +++ b/config/patches/xterm-src/@xterm__addon-image@0.10.0-beta.300.src.patch @@ -1,8 +1,21 @@ diff --git a/src/IIPHandler.ts b/src/IIPHandler.ts -index 559b907416eb38318f439d060d7f89311ed34c7e..8541b4b0ea0d6b451aaae49007d69df64c5bd088 100644 +index 559b907416eb38318f439d060d7f89311ed34c7e..73cedbe99f831bffd202697cc8ba80a46a39cb99 100644 --- a/src/IIPHandler.ts +++ b/src/IIPHandler.ts -@@ -34,6 +34,7 @@ const DEFAULT_HEADER: IHeaderFields = { +@@ -13,8 +13,10 @@ import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics'; + + // Local const enum mirror - esbuild can't inline const enums from external packages + const enum DecoderConst { +- // Limit held memory in base64 decoder (encoded bytes). +- KEEP_DATA = 4194304, ++ // Held memory in base64/QOI decoders between images. Zero because each kept ++ // decoder pins a wasm memory, and V8 caps those per process (~124 in a ++ // sandboxed renderer), so idle terminals must not hold one. ++ KEEP_DATA = 0, + // Initial buffer allocation for the decoder. + INITIAL_DATA = 1048576, + // Local mirror of const enum (esbuild can't inline const enums from external packages) +@@ -34,6 +36,7 @@ const DEFAULT_HEADER: IHeaderFields = { export class IIPHandler implements IOscHandler, IResetHandler { @@ -10,7 +23,7 @@ index 559b907416eb38318f439d060d7f89311ed34c7e..8541b4b0ea0d6b451aaae49007d69df6 private _aborted = false; private _hp = new HeaderParser(); private _header: IHeaderFields = DEFAULT_HEADER; -@@ -55,6 +56,7 @@ export class IIPHandler implements IOscHandler, IResetHandler { +@@ -55,6 +58,7 @@ export class IIPHandler implements IOscHandler, IResetHandler { } public reset(): void { @@ -18,7 +31,47 @@ index 559b907416eb38318f439d060d7f89311ed34c7e..8541b4b0ea0d6b451aaae49007d69df6 this._hp.reset(); this._dec.release(); this._qoiDec.release(); -@@ -198,8 +200,13 @@ export class IIPHandler implements IOscHandler, IResetHandler { +@@ -92,7 +96,10 @@ export class IIPHandler implements IOscHandler, IResetHandler { + this._aborted = true; + return; + } +- this._dec.init(); ++ if (!this._initDecoder()) { ++ this._aborted = true; ++ return; ++ } + } else if (this._abortMulti) { + this._aborted = true; + return; +@@ -135,7 +142,9 @@ export class IIPHandler implements IOscHandler, IResetHandler { + this._isMultipart = true; + this._abortMulti = false; + this._dec.release(); +- this._dec.init(); ++ if (!this._initDecoder()) { ++ this._abortMulti = true; ++ } + return true; + } + +@@ -179,7 +188,15 @@ export class IIPHandler implements IOscHandler, IResetHandler { + + let blob: Blob | ImageData; + if (metrics.mime === 'image/qoi') { +- const data = this._qoiDec.decode(this._dec.data8); ++ let data: Uint8Array; ++ try { ++ data = this._qoiDec.decode(this._dec.data8); ++ } catch (e) { ++ console.warn('IIP: could not decode QOI image', e); ++ this._dec.release(); ++ this._qoiDec.release(); ++ return true; ++ } + blob = new ImageData( + new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength), + this._qoiDec.width, +@@ -198,8 +215,13 @@ export class IIPHandler implements IOscHandler, IResetHandler { blob = new Blob([this._dec.data8], { type: metrics.mime }); } this._dec.release(); @@ -32,6 +85,25 @@ index 559b907416eb38318f439d060d7f89311ed34c7e..8541b4b0ea0d6b451aaae49007d69df6 this._storage.addImage(bm); return true; }) +@@ -209,6 +231,18 @@ export class IIPHandler implements IOscHandler, IResetHandler { + }); + } + ++ // Why: wasm memory exhaustion must drop this image, not throw out of the parser and wedge the write queue. ++ private _initDecoder(): boolean { ++ try { ++ this._dec.init(); ++ return true; ++ } catch (e) { ++ console.warn('IIP: could not allocate decoder', e); ++ this._dec.release(); ++ return false; ++ } ++ } ++ + private _resize(w: number, h: number): [number, number] { + const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width; + const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height; diff --git a/src/ImageAddon.ts b/src/ImageAddon.ts index 8fd39543118cd420e36c1614c1af370b6c7bbfbb..0c44d2a81642113417bf8dc10a4faa76d7cc5864 100644 --- a/src/ImageAddon.ts @@ -144,8 +216,156 @@ index 5854efaec1fdf9dfcb886023542998a563b6d2f2..3afaf9bd63ffd7a4cdf32bf0ac24cf33 } public get document(): Document | undefined { +diff --git a/src/SixelHandler.ts b/src/SixelHandler.ts +index 1af2d85bcdd541ed60b1e707f6186a91f1bbf1b2..0711a122ea43d5212a2851add9e744b65550ec85 100644 +--- a/src/SixelHandler.ts ++++ b/src/SixelHandler.ts +@@ -10,6 +10,7 @@ import { RGBA8888 } from 'sixel/lib/Types'; + import { ImageRenderer } from './ImageRenderer'; + + import { DecoderAsync, Decoder } from 'sixel/lib/Decoder'; ++import { LIMITS } from 'sixel/lib/wasm'; + + // always free decoder ressources after decoding if it exceeds this limit + const MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB +@@ -18,48 +19,88 @@ const MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB + const DEFAULT_PALETTE = PALETTE_ANSI_256; + DEFAULT_PALETTE.set(PALETTE_VT340_COLOR); + ++// Why pooled: every decoder owns a wasm memory, and V8 caps live wasm memories ++// per process (~124 in a sandboxed renderer). Terminals borrow a decoder only ++// while a SIXEL sequence is open, so idle terminals hold none. ++const MAX_IDLE_DECODERS = 2; ++const idleDecoders = new Map(); ++let poolPrimed = false; ++ ++function primeDecoderPool(memoryLimit: number): void { ++ if (poolPrimed) return; ++ poolPrimed = true; ++ // Async compile once, off the parser's hot path; later decoders reuse the cached module. ++ DecoderAsync({ memoryLimit, palette: DEFAULT_PALETTE }).then( ++ d => releaseDecoder(d, memoryLimit), ++ () => { poolPrimed = false; } ++ ); ++} ++ ++function acquireDecoder(memoryLimit: number): Decoder { ++ return idleDecoders.get(memoryLimit)?.pop() ?? new Decoder({ memoryLimit, palette: DEFAULT_PALETTE }); ++} ++ ++function releaseDecoder(dec: Decoder, memoryLimit: number): void { ++ if (dec.memoryUsage > MEM_PERMA_LIMIT) { ++ dec.release(); ++ } ++ const idle = idleDecoders.get(memoryLimit) ?? []; ++ if (idle.length < MAX_IDLE_DECODERS) { ++ idle.push(dec); ++ idleDecoders.set(memoryLimit, idle); ++ } ++} ++ + + export class SixelHandler implements IDcsHandler, IResetHandler { + private _size = 0; + private _aborted = false; + private _dec: Decoder | undefined; ++ private _decMemoryLimit = 0; ++ // Color registers outlive a single image, so they live here rather than in a pooled decoder. ++ private readonly _palette = new Uint32Array(LIMITS.PALETTE_SIZE); + + constructor( + private readonly _opts: IImageAddonOptions, + private readonly _storage: SixelImageStorage, + private readonly _coreTerminal: ITerminalExt + ) { +- DecoderAsync({ +- memoryLimit: this._opts.pixelLimit * 4, +- palette: DEFAULT_PALETTE, +- paletteLimit: this._opts.sixelPaletteLimit +- }).then(d => this._dec = d); ++ this._palette.set(DEFAULT_PALETTE); ++ primeDecoderPool(this._opts.pixelLimit * 4); + } + + public reset(): void { +- /** +- * reset sixel decoder to defaults: +- * - release all memory +- * - nullify palette (4096) +- * - apply default palette (256) +- */ +- if (this._dec) { +- this._dec.release(); +- // FIXME: missing interface on decoder to nullify full palette +- (this._dec as any)._palette.fill(0); +- this._dec.init(0, DEFAULT_PALETTE, this._opts.sixelPaletteLimit); +- } ++ this._returnDecoder(); ++ this._palette.fill(0); ++ this._palette.set(DEFAULT_PALETTE); + } + + public hook(params: IParams): void { + this._size = 0; + this._aborted = false; +- if (this._dec) { +- const fillColor = params.params[1] === 1 ? 0 : extractActiveBg( +- this._coreTerminal._core._inputHandler._curAttrData, +- this._coreTerminal._core._themeService?.colors); +- this._dec.init(fillColor, null, this._opts.sixelPaletteLimit); ++ this._returnDecoder(); ++ const memoryLimit = this._opts.pixelLimit * 4; ++ try { ++ this._dec = acquireDecoder(memoryLimit); ++ } catch (e) { ++ // Why: exhausting wasm memory must drop this image, not throw out of the parser and wedge the write queue. ++ console.warn(`SIXEL: could not allocate decoder - ${e}`); ++ this._aborted = true; ++ return; + } ++ this._decMemoryLimit = memoryLimit; ++ const fillColor = params.params[1] === 1 ? 0 : extractActiveBg( ++ this._coreTerminal._core._inputHandler._curAttrData, ++ this._coreTerminal._core._themeService?.colors); ++ this._dec.init(fillColor, this._palette, this._opts.sixelPaletteLimit); ++ } ++ ++ private _returnDecoder(): void { ++ const dec = this._dec; ++ if (!dec) return; ++ this._dec = undefined; ++ this._palette.set(dec.palette); ++ releaseDecoder(dec, this._decMemoryLimit); + } + + public put(data: Uint32Array, start: number, end: number): void { +@@ -83,6 +124,14 @@ export class SixelHandler implements IDcsHandler, IResetHandler { + } + + public unhook(success: boolean): boolean | Promise { ++ try { ++ return this._unhook(success); ++ } finally { ++ this._returnDecoder(); ++ } ++ } ++ ++ private _unhook(success: boolean): boolean { + if (this._aborted || !success || !this._dec) { + return true; + } +@@ -100,9 +149,6 @@ export class SixelHandler implements IDcsHandler, IResetHandler { + + const canvas = ImageRenderer.createCanvas(undefined, width, height); + canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray, width, height), 0, 0); +- if (this._dec.memoryUsage > MEM_PERMA_LIMIT) { +- this._dec.release(); +- } + this._storage.addImage(canvas); + return true; + } diff --git a/src/kitty/KittyGraphicsHandler.ts b/src/kitty/KittyGraphicsHandler.ts -index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1fa320c4f8 100644 +index de889dfff75d9ecc8ab47a025e6989ffe75bb202..cf66e5b75c78645b1641e53d1425d88201134f78 100644 --- a/src/kitty/KittyGraphicsHandler.ts +++ b/src/kitty/KittyGraphicsHandler.ts @@ -7,6 +7,7 @@ import { IDisposable } from '@xterm/xterm'; @@ -172,10 +392,12 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f this._cleanupAllPending(); if (this._activeDecoder) { this._activeDecoder.release(); -@@ -200,6 +203,25 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos +@@ -200,8 +203,38 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos this._activeDecoder = pending.decoder; } if (!this._activeDecoder) { +- this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes); +- this._activeDecoder.init(); + // Budget WASM capacity, including one page of decoder state and rounding. + const decoderCapacity = this._maxEncodedBytes + 131072; + if (decoderCapacity > this._opts.storageLimit * 1000000) { @@ -195,10 +417,23 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f + this._sendResponse(oldest[1].cmd.id, 'ENOMEM:pending image budget exceeded', oldest[1].cmd.quiet ?? 0); + } + } - this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes); - this._activeDecoder.init(); ++ const decoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes); ++ try { ++ decoder.init(); ++ } catch (e) { ++ // Why: wasm memory exhaustion must drop this image, not throw out of the parser and wedge the write queue. ++ console.warn('KITTY: could not allocate decoder', e); ++ this._aborted = true; ++ if (this._parsedCommand?.id !== undefined) { ++ this._sendResponse(this._parsedCommand.id, 'ENOMEM:could not allocate decoder', this._parsedCommand.quiet ?? 0); ++ } ++ return; ++ } ++ this._activeDecoder = decoder; } -@@ -550,9 +572,11 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos + + if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) { +@@ -550,9 +583,11 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos } private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise { @@ -210,7 +445,7 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f const cropX = Math.max(0, cmd.x ?? 0); const cropY = Math.max(0, cmd.y ?? 0); const cropW = cmd.sourceWidth || (bitmap.width - cropX); -@@ -660,6 +684,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos +@@ -660,6 +695,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos } } @@ -218,7 +453,7 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f const zIndex = cmd.zIndex ?? 0; this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex); bitmap = undefined; // ownership transferred to storage -@@ -693,6 +718,12 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos +@@ -693,6 +729,12 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos } if (image.format === KittyFormat.PNG) { @@ -231,7 +466,7 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f const blob = new Blob([bytes as BlobPart], { type: 'image/png' }); if (!window.createImageBitmap) { const url = URL.createObjectURL(blob); -@@ -775,27 +806,45 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos +@@ -775,27 +817,45 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos private async _decompressZlib(compressed: Uint8Array): Promise { try { return await this._decompress(compressed, 'deflate'); diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index a236145dd94..3f210c23823 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -10,6 +10,628 @@ } }, "gates": [ + { + "id": "terminal-preview.connection-settings-ownership", + "title": "Terminal preview connections survive unrelated settings updates", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "renderer-unit", + "surfaces": [ + "dashboard terminal preview connection", + "preview settings updates", + "preview box fit and grid claim" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "remote-runtime"], + "coverageNotes": "Mounted production component and real settings action with cloned IPC replies, inert xterm/input adapters, and simulated metrics. Local/remote-qualified renderer identities covered; no physical provider, native geometry, focus, IME or rendered app claim. Subsequent hidden/offscreen Electron component proof uses real xterm and synthetic snapshots, preserving the rendered terminal across unrelated updates and measuring equivalent fallback scale/fit requests after a font-size change.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/blob/main/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx", + "https://github.com/stablyai/orca/pull/23099" + ], + "invariant": "Equal composed theme with unchanged PTY, mode, contrast and font/shaping settings keeps the current preview owner. Relevant changes replace it once, preserve explicit fit/grid negotiation, and reject retired pending completions.", + "oracle": "Ten unrelated settings replies produce one connect and xterm construction, no pre-unmount teardown, then one final cleanup. Changed colors/mode/contrast/font/shaping replace owners; pending retired connections never construct and obsolete grid claims are canceled.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-geometry.test.tsx src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx src/renderer/src/components/dashboard-popout/AgentTerminalPreview.option-dead-key.test.tsx src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx" + ], + "testFiles": [ + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx", + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-geometry.test.tsx", + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx", + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.option-dead-key.test.tsx", + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx", + "assertions": [ + "keeps one preview connection across ten unrelated settings snapshots", + "ignores an old pending connection after a relevant theme change", + "does not install a terminal after unmounting a pending connection" + ] + }, + { + "file": "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-geometry.test.tsx", + "assertions": [ + "ignores a pending connection retired by a metric change", + "cancels an obsolete metric owner grid claim before the next change" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-25", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-geometry.test.tsx src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx src/renderer/src/components/dashboard-popout/AgentTerminalPreview.option-dead-key.test.tsx src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx", + "result": "passed", + "durationSeconds": 2.23, + "summary": "63 tests /5 suites pass. Lifetime regressions: original 7 fail/7 pass, candidate14 pass; all8 metric/shaping cases pass both original and corrected candidate." + } + ], + "runtimeBudget": { + "p95Seconds": 15, + "scope": "Focused deterministic suites; local elapsed recorded, p95 not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Local checks and independent adverse review only; no CI soak." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Original reconnects/reconstructs11 times for10 unrelated updates; candidate1. Seven of14 lifetime cases fail original. Original and corrected candidate both pass all8 geometry preservation cases." + }, + "performanceBudget": { + "required": true, + "evidence": "Ten unrelated settings replies: connect/constructor11 to1, pre-unmount unsubscribe/dispose10 to0. One previous-theme ref and shallow comparison added; CPU/heap overhead unmeasured. No timer/polling/subprocess/subscriber added; broad settings render/composition work remains." + }, + "knownGaps": [ + "No full-dashboard interaction, native focus/input/IME, ligature shaping or physical PTY validation. Hidden Electron component rendering and one font-size transition were checked; fit endpoint was inert.", + "No live SSH/WSL/relay or Linux/Windows run. Provider/wire ownership and folder/git workspace paths unchanged.", + "Selection/buffer/input state now persists across unrelated and live cursor/scroll updates instead of incidental resets. Small comparison/ref overhead unmeasured.", + "No CI soak, heap, main snapshot serialization or frame latency claim." + ], + "promotionCriteria": [ + "Retain count, stale-owner and explicit metric fit/grid oracles; add rendered native and provider/platform evidence plus CI soak." + ], + "demotionRule": "Keep experimental until soak; investigate failures without weakening owner counts, changed-theme behavior, fit/grid claims or late-disposal assertions." + }, + { + "id": "workspace-hooks.execution-host-ownership", + "title": "Hook reads and writes use the stored repository execution owner", + "maturity": "experimental", + "protection": "partial", + "owner": "workspace-runtime", + "layer": "cross-layer-unit", + "surfaces": [ + "desktop worktree hook IPC", + "runtime repository hooks", + "issue-command read and write", + "hosted-review own-store host resolution" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "ssh", "wsl", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": [], + "coverageNotes": "Actual hydration and authoritative desktop owner selection with mocked filesystem, Git, inspection and provider boundaries; synthetic local/SSH/runtime rows. No physical provider or native platform certification.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/blob/main/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts" + ], + "invariant": "Hook file operations follow the authoritative repository row in this process Store: canonical SSH selects its target, explicit local and self-addressed runtime rows use registered local files, and missing SSH providers never substitute local I/O.", + "oracle": "Nine desktop/runtime operations never use local I/O for canonical SSH or dial a stale legacy target; legacy SSH/local/runtime controls retain behavior, folder skips remain, missing providers do not gain local fallback.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/hooks/worktree-hook-execution-host.test.ts" + ], + "testFiles": ["src/main/ipc/hooks/worktree-hook-execution-host.test.ts"], + "assertionRefs": [ + { + "file": "src/main/ipc/hooks/worktree-hook-execution-host.test.ts", + "assertions": [ + "routes %s to the stored owner", + "refuses an unreachable canonical SSH write without touching local files" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-26", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/hooks/worktree-hook-execution-host.test.ts", + "result": "passed", + "durationSeconds": 0.739, + "summary": "Independent68pass/original37fail31controls. Eleven additional authority/unavailable-provider/encoded-ID/folder controls pass; author102unique permanent tests across four suites." + } + ], + "runtimeBudget": { + "p95Seconds": 10, + "scope": "Focused renderer-unit suites, p95 not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Author and independent local evidence only; no CI soak." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Frozen permanent suite68pass; original sources37fail31controls; independently reproduced with eleven extra controls." + }, + "performanceBudget": { + "required": true, + "evidence": "Incorrect local/stale-target routes9to0 for each of four changed identity shapes. This is correctness evidence only, no latency/CPU/heap gain. Existing host resolver reused, no polling, cache or inventory/subprocess fanout added." + }, + "knownGaps": [ + "CI soak pending.", + "Physical SSH/WSL/remote-runtime, native Linux/Windows, mobile and mixed-version integration untested. Local/daemon PTY data, identity and liveness unchanged.", + "Runtime absent-provider issue-command write still returns ok:true without a write; this preexisting convention is not a completion claim.", + "Only own-store rows use the generalized helper; generic runtime dispatch still rejects hosts this process does not execute.", + "No actual filesystem writes, provider operations, native UI or packaged app validation in routing fixtures." + ], + "promotionCriteria": [ + "Retain exact route, no-local-fallback and authority oracles; obtain CI soak and physical host evidence before promotion." + ], + "demotionRule": "Keep experimental until soak; investigate authority/routing failures without replacing exact-target or no-local-I/O assertions." + }, + { + "id": "runtime-files.renderer-document-watch-ownership", + "title": "Filesystem watches stay owned by their renderer document", + "maturity": "experimental", + "protection": "partial", + "owner": "runtime-platform", + "layer": "ipc-contract", + "surfaces": [ + "desktop file explorer watchers", + "desktop editor file watchers", + "direct SSH file watchers" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "ssh", "wsl"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "ssh", "wsl"], + "coverageNotes": "Actual desktop local/WSL subscription and SSH watcher controller logic under mocked filesystem/provider handles on macOS. Document events use an EventEmitter sender. Live remote/native Windows/Linux/WSL and rendered reload reattachment are unproved; PTY/process liveness and wire formats are unaffected.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/blob/main/src/main/ipc/filesystem-watcher-listener-lifecycle.ts" + ], + "invariant": "A replaced or crashed renderer document retains no file watches or pending ownership and cannot recreate them from an old asynchronous continuation. Shared roots retain live sibling owners; blocked and same-document navigation retain the current document. Shutdown removes registered lifecycle listeners.", + "oracle": "Run repeated reloads/crash, pending local/SSH installation cancellation with late success, aborted-install joiners, reconnect retry snapshots, failed-removal restoration and shutdown/reopen. Require zero obsolete roots/intent/listeners, no stale setup/retry, physical handle disposal and preservation of sibling notifications and same-tick remote handoff.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher-document-lifetime.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-remote-cancellation.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts --reporter=dot" + ], + "testFiles": [ + "src/main/ipc/filesystem-watcher-document-lifetime.test.ts", + "src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts", + "src/main/ipc/filesystem-watcher-remote-cancellation.test.ts", + "src/main/ipc/filesystem-watcher-native-capacity.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/ipc/filesystem-watcher-document-lifetime.test.ts", + "assertions": [ + "retains zero roots and listeners across 15 reloads and a renderer crash", + "does not revive a cancelled %s setup for a joiner whose document reloaded", + "does not re-arm an old provider snapshot into the same WebContents after replacement", + "does not restore a replaced sibling document after an awaited %s removal recovery", + "keeps same-document and blocked navigations alive, and removes lifecycle listeners at shutdown" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-25", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher-document-lifetime.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-remote-cancellation.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 1.08, + "summary": "45 tests across four focused suites pass. Broader watcher suite passes 146 plus one existing skipped; isolated publication without sibling #22995 passes 145 plus one existing skipped." + } + ], + "runtimeBudget": { + "p95Seconds": 15, + "scope": "Focused deterministic IPC-contract suites; p95 not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Local validation only; no CI soak." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Seven permanent regressions fail with HEAD source substituted: cancelled joiner resurrection for local and SSH, restoration into replaced local/SSH documents, stale provider retry, shutdown listener retention, and root retention. All 13 document ownership cases pass after the change." + }, + "performanceBudget": { + "required": true, + "evidence": "15 reloads plus crash: retained local/SSH/desired roots 16→0 each; pending setup aborted; sender lifecycle listeners 1→0. Actual ownership modules with synthetic handles; no native process/heap/latency claim. No new timer, polling, process scan or provider fanout; existing root cleanup runs at document end." + }, + "knownGaps": [ + "No native Windows/Linux/WSL or live SSH execution.", + "No rendered Electron reload-reattachment/paint run or CI soak; broader watcher process isolation is a separate gate." + ], + "promotionCriteria": [ + "Retain lifecycle, count and sibling-delivery assertions and add cross-platform and rendered reload evidence before promotion." + ], + "demotionRule": "Keep experimental until soak evidence; investigate lifecycle failures without suppressing assertions or retrying unexplained failures." + }, + { + "id": "ephemeral-vm-recipe.output-tail-parity", + "title": "Recipe output capture preserves UTF-8 tails and releases settled storage", + "maturity": "experimental", + "protection": "partial", + "owner": "ephemeral-vm-runtime-store", + "layer": "shared-node-process-contract", + "surfaces": ["VM recipe stdout and stderr", "recipe cancellation and process errors"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local"], + "coverageNotes": "Actual recipe capture with synthetic child streams and real forced GC, plus real macOS shell output and descendant cancellation. Linux and Windows native execution, live VM providers and remote lifecycle journeys remain untested. Local/daemon PTY, SSH ownership, WSL launch, mobile and relay wire behavior are unaffected.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/blob/01ce5edf1aeec8a4b5cf06f531434be7d7c0cbcd/src/shared/ephemeral-vm-recipe-process.ts" + ], + "invariant": "Decoded stdout and stderr callback contents and order remain unchanged, final tails are the last capped run of raw output bytes with any leading partial UTF-8 sequence dropped, and success or failure releases capture storage without recapturing late output.", + "oracle": "Compare byte-bounded tails against the old per-chunk model and an independent code-point model across compaction, growth, oversized chunks and mid-character splits. Feed split and malformed UTF-8 through actual stream decoding. Require at most 5 MiB moved through the capture for 4 MiB of output with zero encoding, collectible backing buffers after close and error, zero late capture work with preserved callbacks, one decode per stream, unchanged tails on forced cancellation or synchronous kill-close, and every capture limit clamped to a bounded byte count.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/ephemeral-vm-recipe-process.test.ts src/shared/ephemeral-vm-recipe-output-capture.test.ts src/shared/growing-byte-buffer.test.ts src/main/ephemeral-vm-recipe-runner.test.ts" + ], + "testFiles": [ + "src/shared/ephemeral-vm-recipe-process.test.ts", + "src/shared/ephemeral-vm-recipe-output-capture.test.ts", + "src/shared/growing-byte-buffer.test.ts", + "src/main/ephemeral-vm-recipe-runner.test.ts" + ], + "assertionRefs": [ + { + "file": "src/shared/ephemeral-vm-recipe-output-capture.test.ts", + "assertions": [ + "matches the old per-chunk UTF-8 tail for limit %s across varied chunk boundaries", + "retains the same code-point tail for limit %s wherever byte boundaries fall", + "preserves stream decoding, callback boundaries and independent tails", + "moves at most 5 MiB through the capture for 4 MiB of output", + "releases capture storage after %s while preserving late callbacks", + "decodes once per stream when force-kill closes synchronously", + "clamps a $limit capture limit to a bounded byte count" + ] + }, + { + "file": "src/shared/growing-byte-buffer.test.ts", + "assertions": [ + "reads and writes correctly after the head offset has advanced", + "keeps a bounded suffix stable across many small appends without unbounded growth" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-26", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/ephemeral-vm-recipe-process.test.ts src/shared/ephemeral-vm-recipe-output-capture.test.ts src/shared/growing-byte-buffer.test.ts src/main/ephemeral-vm-recipe-runner.test.ts", + "result": "passed", + "durationSeconds": 0.48, + "summary": "60 tests across four suites passed, including forced-GC lifetime, synchronous SIGKILL-close and the shared byte buffer's head-offset cases." + } + ], + "runtimeBudget": { + "p95Seconds": 15, + "scope": "focused Node process and capture contracts with forced GC" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Author and independent local runs pass; no CI or soak history collected." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Baseline moves 943,194,112 bytes for 4 MiB output and fails the permanent 5 MiB budget. An earlier ring prototype retained two backing buffers after error and encoded late output after close; both permanent lifetime cases fail there and pass after cleanup correction. Dropping the head offset from GrowingByteBuffer keeps the byte budget but takes 12.36 ms instead of 0.25 ms." + }, + "performanceBudget": { + "required": true, + "evidence": "4 MiB in 4 KiB chunks, seven paired samples, macOS: the old per-chunk re-encode moves 943,194,112 bytes in 265.78 ms median; raw-byte capture into GrowingByteBuffer moves 4,194,304 appended plus 1,048,576 decoded bytes, encodes nothing, and takes 0.25 ms. End to end through runRecipeCommand is 0.26 ms with no output callback and 0.78 ms with one (the callback decoder is the work setEncoding already did). A 64-byte one-shot is 0.075 to 0.206 microseconds; retained storage reserves spare capacity and can exceed a string. No new polling, timers, subprocesses, wire fields or deadline changes." + }, + "promotionCriteria": [ + "Collect 100 consecutive CI passes or 14 days of soak history.", + "Exercise native Windows and Linux recipe output and cancellation." + ], + "knownGaps": [ + "Synthetic output timings do not establish typical recipe prevalence, end-to-end provisioning gains or UI latency.", + "Retained storage reserves spare capacity and can use more memory than a string; CJK bytes can exceed UTF-16 storage. Tiny output is slower, and an oversized single chunk still copies its capped tail.", + "Malformed UTF-8 now yields a different tail than the old policy: replacement characters no longer inflate the byte count, so a malformed tail keeps more real output. No production override of the cap was found, and odd caps are clamped rather than coerced per chunk.", + "Live VM providers, SSH lifecycle journeys, and native Windows or Linux termination were not run." + ], + "demotionRule": "Keep experimental or demote if output differs, discarded bytes return, settled capture remains reachable, cancellation loses its tail, or the deterministic encoding budget regresses. Never weaken the oracle or increase retries to obtain a pass." + }, + { + "id": "agent-session.spawn-workspace-trust", + "title": "Agent PTY spawns wait for a bounded workspace trust write", + "maturity": "experimental", + "protection": "partial", + "owner": "agent-session-runtime", + "layer": "cross-layer-unit", + "surfaces": [ + "renderer PTY spawn builder", + "runtime PTY spawn builder", + "structured Codex chat create intent", + "agent workspace trust dispatcher", + "execution-host workspace trust writer (main and SSH relay)", + "SSH relay agent workspace trust" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "ssh"], + "coverageNotes": "The actual renderer and runtime spawn-option builders with a mocked dispatcher, the spawn hook with a mocked dispatcher, the structured Codex create intent with a mocked dispatcher, the dispatcher with mocked preset writers and the real Claude config writer on a temp file, the execution-host writer and the relay spawn function with every real preset writer under a throwaway home, and the relay pty.spawn handler with a mocked trust function. Proves the builders await trust before returning, the fresh-launch and setting gates, the per-preset deadlines, that SSH launches hand every preset to the relay, and, for the agents that inherit trust from a home (Claude, Copilot, Qoder), the refusal of any stored path that is a root, a home or a folder above one, while Codex, Cursor and Antigravity still trust a home. No native PTY, agent CLI, live SSH host or WSL guest.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/blob/main/src/main/agent-workspace-trust.ts" + ], + "invariant": "A fresh PTY launch of an agent with a trust preset pre-trusts its workspace root (for Codex, whose lookup keys on its start folder, the folder it starts in, including a floating terminal's) before the provider spawn, in both spawn builders, on the host that runs the agent: main for local and WSL launches, the SSH relay on its own disk for SSH launches. It never waits past the preset's deadline: 20 s for local Codex, the short budget for every other write, local or on the relay. A failed or abandoned write lets the launch proceed untrusted, so the agent asks. Restored or reattached panes, spawns with no launch command and the setting turned off are never trusted; for an agent whose trust on a home covers the folders below it (Claude, Copilot, Qoder), no writer stores a path that is a root, a home or a folder above one, and an unknown home writes nothing; Codex, Cursor and Antigravity, whose trust on a home covers only the home, trust it as they did before; and a WSL launch never writes the Windows home. A structured Codex chat, which has no PTY, pre-trusts its folder the same way when it is created, under the same setting.", + "oracle": "A trust write held pending keeps each builder from returning until it settles. A never-settling local Codex write resolves the dispatcher only after the long deadline; a never-settling local Claude write resolves after the short budget with a named warning. Rejected and throwing writes, and a throwing guard, resolve with a warning. Restored panes, spawns without a command and the setting off make no dispatcher call; Codex in a floating terminal or a workspace subfolder is trusted at that folder, and every other preset at the workspace root. Creating a structured Codex chat trusts its folder before launch preparation, and nothing with the setting off. An SSH launch returns the relay field for every preset and calls no writer, and the relay's pty.spawn starts no process until that launch's trust call settles. A home, a folder above one, a root, a symlink to a home and a missing path that climbs back to the home through `..` write no Claude, Copilot or Qoder trust file, on this machine and on the relay, and the home writes Codex, Cursor and Antigravity trust.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts src/main/agent-workspace-trust-spawn.test.ts src/main/agent-workspace-trust.test.ts src/main/execution-host-workspace-trust.test.ts src/relay/agent-workspace-trust-spawn.test.ts src/relay/agent-workspace-trust-spawn-guard-failure.test.ts src/relay/pty-handler-agent-workspace-trust.test.ts src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts" + ], + "testFiles": [ + "src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts", + "src/main/agent-workspace-trust-spawn.test.ts", + "src/main/agent-workspace-trust.test.ts", + "src/main/execution-host-workspace-trust.test.ts", + "src/relay/agent-workspace-trust-spawn.test.ts", + "src/relay/agent-workspace-trust-spawn-guard-failure.test.ts", + "src/relay/pty-handler-agent-workspace-trust.test.ts", + "src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts", + "assertions": [ + "trusts Codex in a floating terminal at the resolved folder it starts in", + "holds the spawn until the trust write settles", + "never re-runs trust for a restored pane or a spawn with no launch command" + ] + }, + { + "file": "src/main/agent-workspace-trust-spawn.test.ts", + "assertions": [ + "does nothing with the setting off", + "does nothing for a restored or reattached pane", + "trusts Codex in a floating terminal at the folder it starts in", + "trusts Codex at a subfolder it starts in, which its lookup keys on" + ] + }, + { + "file": "src/main/agent-workspace-trust.test.ts", + "assertions": [ + "contains a rejected or throwing write so the launch proceeds", + "gives only Codex the long deadline its shared config lane needs", + "gives a local Claude write a short budget, after which Claude asks", + "never pre-trusts %s for an agent that inherits trust from it", + "trusts the home folder for Codex, Cursor and Antigravity, whose trust there stays there", + "hands an SSH %s launch to the relay instead of writing anything here" + ] + }, + { + "file": "src/main/execution-host-workspace-trust.test.ts", + "assertions": [ + "writes no %s trust", + "guards exactly the agents that inherit trust from a home", + "trusts a home folder workspace for %s, whose trust there covers only the home", + "writes no %s trust when the host knows no home" + ] + }, + { + "file": "src/relay/agent-workspace-trust-spawn.test.ts", + "assertions": [ + "writes %s trust on the relay host's own disk", + "trusts the relay home for %s, whose trust there covers only the home", + "leaves Antigravity to ask, since its writer is unverified on SSH hosts" + ] + }, + { + "file": "src/relay/agent-workspace-trust-spawn-guard-failure.test.ts", + "assertions": ["skips %s trust and never fails the spawn"] + }, + { + "file": "src/relay/pty-handler-agent-workspace-trust.test.ts", + "assertions": [ + "writes the launch's trust with its final env before the agent's process starts" + ] + }, + { + "file": "src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts", + "assertions": [ + "pre-trusts the chat folder before launch preparation, as a Codex terminal launch does", + "writes nothing with the setting off, and still prepares the launch" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-29", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts src/main/agent-workspace-trust-spawn.test.ts src/main/agent-workspace-trust.test.ts src/main/execution-host-workspace-trust.test.ts src/relay/agent-workspace-trust-spawn.test.ts src/relay/agent-workspace-trust-spawn-guard-failure.test.ts src/relay/pty-handler-agent-workspace-trust.test.ts src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts", + "result": "passed", + "durationSeconds": 4.16, + "summary": "201 tests across eight suites pass, run through node_modules/.bin/vitest with a throwaway HOME. No native launch, PTY or live SSH host." + } + ], + "runtimeBudget": { + "p95Seconds": 10, + "scope": "Focused spawn-builder, hook, dispatcher, execution-host writer and relay suites; p95 not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Local mocked evidence only; CI soak pending." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Each named case failed with its fix removed and passed restored: the builder's await turned into a fire-and-forget call fails the hold case; guarding every preset fails each Codex, Cursor and Antigravity home case, and guarding none fails each Claude, Copilot and Qoder home, root, symlink and `..` case; dropping the unknown-home rule fails every no-home case; the relay deriving only Claude from launchAgent fails each non-Claude relay case; the dispatcher handing only Claude to the relay fails each SSH preset case; dropping the relay's Antigravity return fails its case; letting the host writer's catch rethrow fails every guard-failure case; dropping resolve() from the guard's forms fails the missing `..` path case for every guarded preset; marking Codex as trusted at the workspace root, or a builder dropping the start folder, fails the Codex floating-terminal and subfolder cases; removing the relay handler's trust call, or not awaiting it, fails the relay handler case; removing the structured Codex create intent's trust call fails its pre-trust case, and dropping its setting check fails its setting-off case." + }, + "performanceBudget": { + "required": true, + "evidence": "Plain shells and agents without a preset add no trust I/O; the hook returns after the preset check. A local Codex write is capped at 20 s and every other write, local or on the relay, at the short budget, each by one timer cleared on settlement. An SSH launch adds no round trip: the field rides the existing pty.spawn request. The deadline bounds the wait, not the write: an abandoned write still lands later. The local Claude writer reads and parses Claude's config synchronously on the main thread before its lock." + }, + "knownGaps": [ + "CI soak pending.", + "No live agent CLI, PTY, trust prompt, native Electron app, SSH host or WSL guest was exercised.", + "Abandoning a write at the deadline does not cancel it; a slow write can land after the agent already asked.", + "The deadline values are reasoned caps, not measured p95s.", + "Antigravity over SSH is not written on the relay (its writer is unverified there), so it still asks." + ], + "promotionCriteria": [ + "Retain the hold, deadline and home or root red/green evidence; obtain native platform and SSH evidence and a CI soak before promotion." + ], + "demotionRule": "Remain experimental until repeated independent or CI evidence; investigate ordering or unbounded-wait regressions without weakening the hold or deadline assertions." + }, + { + "id": "settings.general-section-lifetime", + "title": "General settings search preserves matching section state", + "maturity": "experimental", + "protection": "partial", + "owner": "renderer-settings", + "layer": "renderer-unit", + "surfaces": [ + "General settings search", + "app version and remote update display", + "autosave settings draft" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "remote-runtime", "ssh", "wsl"], + "coveredPlatforms": ["macos"], + "coveredProviders": [], + "coverageNotes": "Actual GeneralPane, update/version and remote-status components with mocked store/API; actual autosave form. Windows runtime on/off simulated. No physical updater, paired host, settings write or native input.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/blob/main/src/renderer/src/components/settings/GeneralPane.tsx" + ], + "invariant": "A matching General settings section retains its owner and state as preceding search results disappear; an actually removed section releases its owner and reloads when shown again.", + "oracle": "Across 14 matching searches the Updates section keeps the same DOM node and the version-read and remote-refresh counts do not grow, instead of six each. Store-published remote update state still reaches the retained section. Explicit refresh and genuine hide/reopen still read; drafts yield to externally changed saved settings; a removed owner cannot publish its late version.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/GeneralPane.section-lifetime.test.tsx" + ], + "testFiles": ["src/renderer/src/components/settings/GeneralPane.section-lifetime.test.tsx"], + "assertionRefs": [ + { + "file": "src/renderer/src/components/settings/GeneralPane.section-lifetime.test.tsx", + "assertions": [ + "keeps showing store-published remote update state without a remount", + "keeps explicit remote refresh and true hide/reopen reads", + "preserves an autosave draft until external settings change or the section hides", + "does not publish a version result from a genuinely unmounted section" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-26", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/GeneralPane.section-lifetime.test.tsx", + "result": "passed", + "durationSeconds": 1.89, + "summary": "7 tests/1 suite pass after the shared SettingsSectionStack adoption. Read-count oracles restated as node identity plus non-growing counts; original main fails both retained-section cases, the draft, the release-picker reveal and the late-version publish." + } + ], + "runtimeBudget": { + "p95Seconds": 10, + "scope": "Focused renderer-unit suites, p95 not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Author and independent local evidence only; no CI soak." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Candidate permanent6pass versus original4fail2controls. Additional independent pending-version/error-context/store-refresh3pass versus original2fail1control." + }, + "performanceBudget": { + "required": true, + "evidence": "Actual version and remote-refresh admissions 6 to 1 across 14 matching queries, which also stops one paired-host update probe per keystroke. Section map now lives in the shared SettingsSectionStack; no added poll, cache, listener or subprocess. No packaged CPU/frame/heap claim." + }, + "knownGaps": [ + "CI soak pending.", + "Physical updater/install, local/daemon PTYs and mobile are unaffected; no execution-host routing, process authority or wire change.", + "Native Linux/Windows, SSH/WSL and paired remote-runtime behavior not exercised; synthetic runtime flags cover renderer branches only.", + "Matching search no longer incidentally refreshes version/remote-status/CLI discovery; explicit checks and real hide/reopen remain. The installed version cannot change in-process and remote update entries are store-published, so neither goes stale without a signal.", + "Retained drafts, release reveal and pending/error context last until a real hide or their own transition; programmatic-query draft test does not reproduce ordinary mouse-search blur/commit.", + "Hidden Electron actual-form proof is separate; no packaged latency, heap or native focus evidence." + ], + "promotionCriteria": [ + "Retain count, draft, owner cleanup and refresh controls; collect CI soak and relevant full-app/provider evidence before promotion." + ], + "demotionRule": "Keep experimental until soak; investigate state or cleanup regressions without weakening owner, read-count or draft assertions." + }, + { + "id": "settings.accounts-section-lifetime", + "title": "Accounts search preserves matching account section owners", + "maturity": "experimental", + "protection": "partial", + "owner": "renderer-settings", + "layer": "renderer-unit", + "surfaces": [ + "Accounts settings search", + "Grok and Cursor account status", + "Codex pending sign-in link" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "remote-runtime", "ssh", "wsl"], + "coveredPlatforms": ["macos"], + "coveredProviders": [], + "coverageNotes": "Actual AccountsPane, Grok, Cursor and Codex login components; synthetic APIs/store with Windows account support enabled/disabled. No real account, keychain or provider action.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/blob/main/src/renderer/src/components/settings/AccountsPane.tsx" + ], + "invariant": "Matching sections retain their owners as earlier search results disappear; genuine removal releases the Codex subscription and reopens fresh status reads.", + "oracle": "Across nine matching Grok, ten matching Cursor and eleven matching Codex queries each matched section keeps the same DOM node and its status-read, watcher and subscription counts do not grow, against four to six reads each before. Explicit refresh, store-published usage updates, pushed login URLs and genuine removal all still reach the retained section.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx" + ], + "testFiles": ["src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx"], + "assertionRefs": [ + { + "file": "src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx", + "assertions": [ + "preserves explicit Grok refresh and real hide/reopen status reads", + "keeps the login subscription until Codex genuinely hides", + "keeps Cursor refresh driven by usage updates and genuine reopen" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-26", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx", + "result": "passed", + "durationSeconds": 1.84, + "summary": "10 tests/1 suite pass after the shared SettingsSectionStack adoption. Read-count oracles restated as node identity plus non-growing counts; original main fails the three retained-section cases and the retained Codex subscription, and passes the explicit-refresh, usage-driven and genuine-removal controls." + } + ], + "runtimeBudget": { + "p95Seconds": 10, + "scope": "Focused renderer-unit suites, p95 not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Author and independent local evidence only; no CI soak." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Candidate ten pass; current-main original seven fail and three controls pass." + }, + "performanceBudget": { + "required": true, + "evidence": "Actual renderer status-read/subscription admissions counted; the section map now lives in the shared SettingsSectionStack and no cache, poll, listener or process is added. No CPU, latency or heap claim. Matching sections retain local state and lose only the incidental search-driven refresh that duplicated an existing subscription." + }, + "knownGaps": [ + "CI soak pending.", + "No native Linux/Windows or live SSH/WSL/paired provider behavior; platform flags are simulated.", + "Hidden Electron Grok proof uses the original audit baseline and synthetic status; current-main Cursor addition has unit evidence only.", + "Matching search no longer incidentally refreshes Grok/Cursor status or pending Codex URLs. Every one of those surfaces is store-subscribed or push-subscribed, so the retained section still tracks usage updates, pushed login URLs, explicit refresh and true hide/reopen; there is no bounded automatic freshness deadline and none of them relied on one before.", + "Child drafts, errors, copied feedback and timers remain until their own transition or real removal." + ], + "promotionCriteria": [ + "Retain read/subscription counts and genuine-removal controls; collect CI soak and relevant full-app provider evidence." + ], + "demotionRule": "Keep experimental until soak; investigate owner and refresh regressions without weakening assertions or adding retries." + }, { "id": "agent-session.journal-streaming-replay", "title": "Journal replay bounds obsolete revision memory without changing recovery", @@ -82,6 +704,81 @@ "promotionCriteria": ["Retain red/green heap and value assertions and complete CI soak."], "demotionRule": "Keep experimental until cross-platform and soak evidence; investigate failures without weakening content or memory assertions." }, + { + "id": "relay-performance.ai-vault-ready-ownership", + "title": "AI Vault readiness releases canceled request owners", + "maturity": "experimental", + "protection": "partial", + "owner": "desktop-runtime", + "layer": "relay-service-client-contract", + "surfaces": ["AI Vault sidecar startup", "SSH title lookup cancellation"], + "platforms": ["macos", "linux", "windows"], + "providers": ["ssh"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["ssh"], + "coverageNotes": "Production relay client with synthetic child events and real forced GC. Native remote process execution and live SSH remain untested. Local/daemon PTY, WSL shell selection and mobile terminal stream are unaffected.", + "motivatingLinks": ["https://github.com/stablyai/orca/issues/23024"], + "invariant": "Canceled unsent calls release request payloads before sidecar readiness, disposal releases all pending readiness owners, and live calls still start exactly once through cancellation and startup failures.", + "oracle": "Hold ready and cancel 1,000 title calls; require zero retained request arrays after GC and one child. Dispose before ready and require uncanceled payload collection and zero timers. Race ready with abort and spawn failure with a second lane; require only live calls delivered and successful results. Preserve existing restart and cancel-ack behavior.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/relay/ai-vault-service-ready-retention.test.ts src/relay/ai-vault-service-client.test.ts src/relay/ai-vault-service-restart-policy.test.ts src/relay/ai-vault-service-spawn.test.ts src/relay/ai-vault-handler.test.ts src/shared/promise-settlement-waiters.test.ts" + ], + "testFiles": [ + "src/relay/ai-vault-service-ready-retention.test.ts", + "src/relay/ai-vault-service-client.test.ts", + "src/relay/ai-vault-service-restart-policy.test.ts", + "src/relay/ai-vault-service-spawn.test.ts", + "src/relay/ai-vault-handler.test.ts", + "src/shared/promise-settlement-waiters.test.ts" + ], + "assertionRefs": [ + { + "file": "src/relay/ai-vault-service-ready-retention.test.ts", + "assertions": [ + "releases canceled request payloads while readiness remains pending", + "releases uncanceled readiness payloads when the client is disposed", + "does not send a call canceled in the turn that readiness arrives", + "retries an unsent lane after spawn fails while the other lane starts a child" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-25", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/relay/ai-vault-service-ready-retention.test.ts src/relay/ai-vault-service-client.test.ts src/relay/ai-vault-service-restart-policy.test.ts src/relay/ai-vault-service-spawn.test.ts src/relay/ai-vault-handler.test.ts src/shared/promise-settlement-waiters.test.ts", + "result": "passed", + "durationSeconds": 0.616, + "summary": "38 tests across six suites passed, including real forced-GC retention and startup/cancel races." + } + ], + "runtimeBudget": { + "p95Seconds": 10, + "scope": "focused Node contract tests with forced GC" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Local runs passed; CI/soak history not collected." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Baseline retains all 1,000 canceled arrays and the disposed uncanceled payload; both permanent regressions fail there and pass after the fix. Independent spawn-failure race failed on the initial prototype, passed on baseline and passes after correction." + }, + "performanceBudget": { + "required": true, + "evidence": "Synthetic cold-start churn retains 1,000 to zero canceled arrays; incremental heap 34,771,464 to 492,504 bytes. One shared startup reaction, no new polling, timers, process starts, timeout changes or wire fields." + }, + "promotionCriteria": [ + "Collect 100 consecutive CI passes or 14 days of soak history.", + "Exercise real SSH sidecar cancellation and restart on Linux and Windows." + ], + "knownGaps": [ + "Synthetic churn does not measure typical user frequency or UI latency.", + "Physical sidecar/native Windows/Linux and live SSH paths were not exercised." + ], + "demotionRule": "Keep experimental or demote if canceled payloads survive GC, disposal retains startup owners, or live lanes stall/duplicate after startup failure." + }, { "id": "runtime.connection-owned-host-status", "title": "Host status recovers with its owning connection", @@ -251,7 +948,7 @@ "invariant": "One structured-send operation id causes at most one provider dispatch. A recorded or transport-ambiguous send reuses that id across retry, caller reconnect, client remount, and journal recovery; only a terminal rejection may rotate to a first delivery.", "oracle": "Inject adapter acknowledgement loss, RPC response loss, caller replacement, logical-client close after response, auth recovery with a written request, missing journal submissions, legacy pending rows, stale fences, operation expiry, mobile remount, and durable-journal capacity. Assert one provider dispatch or one operation id for every ambiguous retry, fresh identity only after rejection, and no eviction of ambiguous mobile ids.", "commands": [ - "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", "ORCA_BACKGROUND_LAUNCH=1 pnpm --dir mobile test ../mobile/src/session/mobile-native-chat-image-attachment.test.ts ../mobile/src/session/use-mobile-native-chat-image-attachments.test.ts ../mobile/src/session/mobile-structured-send-operation-journal.test.ts ../mobile/src/session/mobile-structured-session-operation-retention.test.ts ../mobile/src/session/mobile-structured-send-delivery.test.ts ../mobile/src/session/use-mobile-structured-agent-session-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session.test.tsx ../mobile/src/transport/mobile-relay-rpc-session.test.ts ../mobile/src/transport/rpc-client-delivery-ambiguity.test.ts ../mobile/src/transport/stable-logical-rpc-client.test.ts" ], "testFiles": [ @@ -265,7 +962,6 @@ "src/main/runtime/orchestration/structured-pointer-operation-id.test.ts", "src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx", "src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx", - "src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx", "src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx", "src/renderer/src/lib/launch-structured-agent-session.test.ts", "mobile/src/session/mobile-native-chat-image-attachment.test.ts", @@ -341,7 +1037,7 @@ "date": "2026-09-12", "runner": "local", "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", "result": "passed", "durationSeconds": 22.1, "summary": "Thirteen focused host, shared, renderer, and orchestration files passed 148 tests." @@ -8164,7 +8860,7 @@ "Windows ConPTY, SSH-hosted PTYs, app-quit killAll, and daemon dispose retain foreground-tree-only teardown.", "A process born in the capture second is SIGTERMed but not SIGKILLed because ps cannot prove its recycled-PID identity.", "A descendant orphaned before or during root ownership loss requires the separate crash-orphan sweep and is not recovered from a stale kill-time snapshot.", - "recordTerminalSurfaceRetirement advances the INCOMING session's terminal topology revision, so a tombstoned close whose write carries a revision at or below the store's own is rebased away instead of persisting. Pre-existing for every repo already past revision 0 and unchanged by the host-admitted membership fix, which only makes more repos reach that state sooner; the authoritative close path (persistTerminalSurfaceRetirements) computes from the store's session and is unaffected. Known, unaddressed, and deliberately out of scope here: the safe correction is per-tab rather than per-worktree rebase arbitration, because raising the incoming revision wholesale would re-open the host-tab loss whenever a close coincides with a host create.", + "recordTerminalSurfaceRetirement advances the INCOMING session's terminal topology revision, so a tombstoned close whose write carries a revision at or below the store's own is rebased away instead of persisting. Pre-existing for every repo already past revision 0 and unchanged by the host-admitted membership fix, which only makes more repos reach that state sooner; the authoritative close path (stageTerminalSurfaceRetirements) computes from the store's session and is unaffected. Known, unaddressed, and deliberately out of scope here: the safe correction is per-tab rather than per-worktree rebase arbitration, because raising the incoming revision wholesale would re-open the host-tab loss whenever a close coincides with a host create.", "The SSH relay reattach binding (src/main/ssh/ssh-relay-session.ts persistPtyBinding) is NOT flagged host-admitted, so a lease rebind that has to mint a missing tab raises no fence. It rebinds an existing lease rather than admitting a new surface, so it is believed not to need one, but that was not established either way. Known, unaddressed, and deliberately out of scope here.", "The host-created retention journey is macOS-local; the reported incident was on a physical Windows host, which we did not run against." ], @@ -8925,6 +9621,77 @@ ], "demotionRule": "Demote if discovery and launch can resolve different authorities, a scoped Floating probe overwrites the active-project inventory, or local context selection adds recurring scans or retained in-flight entries." }, + { + "id": "agent-session.opencode2-hook-cleanup", + "title": "OpenCode 2 setup releases acquired hook owners after failure", + "maturity": "experimental", + "protection": "partial", + "owner": "agent-integrations", + "layer": "main-unit", + "surfaces": ["OpenCode 2 generated plugin setup and unload"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "ssh", "wsl", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": [], + "coverageNotes": "Actual generated wrapper with synthetic factories, prompt registrations and event iterators. Supporting generated-module tests cover both OpenCode variants; no native provider execution.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/blob/main/src/main/opencode2/status-plugin-setup-source.ts" + ], + "invariant": "After a hook factory is acquired, prompt registration failure or a throwing/rejecting prompt disposer cannot skip hook disposal. Unload waits for in-flight event delivery before disposing the hook factory and remains fail-open.", + "oracle": "Fault-injected setup and unload each call hook disposal once. The ordered control completes delivery and subscription before hook cleanup. Normal unload disposes both owners once.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/opencode2/status-plugin-setup-lifetime.test.ts" + ], + "testFiles": ["src/main/opencode2/status-plugin-setup-lifetime.test.ts"], + "assertionRefs": [ + { + "file": "src/main/opencode2/status-plugin-setup-lifetime.test.ts", + "assertions": [ + "disposes the factory after prompt registration %s", + "disposes the factory after prompt cleanup %s", + "waits for in-flight delivery before factory cleanup after a prompt cleanup failure" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-26", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/opencode2/status-plugin-setup-lifetime.test.ts", + "result": "passed", + "durationSeconds": 0.125, + "summary": "10 permanent cases pass; baseline seven fail and three controls pass. Fresh-main run including existing setup/lineage suites: 59 pass." + } + ], + "runtimeBudget": { + "p95Seconds": 10, + "scope": "Synthetic wrapper unit suite; p95 not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Local deterministic validation only; no CI soak." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Baseline seven failures and three controls; candidate ten passed. Hook disposal after prompt registration/cleanup failure changes from zero to one." + }, + "performanceBudget": { + "required": true, + "evidence": "No new polling, scans, subprocesses or successful-startup awaits. Failure paths now await the existing hook disposer; teardown retains event-delivery ordering. No latency or heap improvement claimed." + }, + "knownGaps": [ + "Native Linux/Windows, SSH/WSL and remote-runtime plugin execution not run.", + "Local/daemon PTY and mobile/relay transport behavior is unaffected.", + "Pending non-abortable registration, event delivery or disposal promises can still delay cleanup; no timeout or cancellation redesign.", + "Malformed form normalization remains a separate defect.", + "CI soak pending." + ], + "promotionCriteria": [ + "Retain cleanup-count and delivery-order oracles; add provider/platform execution and CI soak." + ], + "demotionRule": "Keep experimental until soak; investigate failures without weakening cleanup or delivery-order assertions." + }, { "id": "agent-session.provider-ownership", "title": "Provider sessions are resumed once per workspace ownership claim", @@ -9937,6 +10704,83 @@ ], "demotionRule": "Cannot promote without deterministic oracle and runtime history." }, + { + "id": "worktree-listing.execution-host-ownership", + "title": "Worktree listings keep canonical SSH ownership and mutation fencing", + "maturity": "experimental", + "protection": "partial", + "owner": "worktree-listing", + "layer": "main-unit", + "surfaces": [ + "detected worktree listing", + "known worktree catalog", + "worktree list and listAll" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "ssh", "wsl", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": [], + "coverageNotes": "Actual handlers, hydration, ownership, scan cache and provider authority with mocked providers/Git/store/IPC. Independent controls use real lineage pruning and an in-memory map. No physical SSH or native Git.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/blob/main/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts" + ], + "invariant": "Canonical SSH listings use the owning provider and cannot register remote roots locally or reuse local cache authority. A listing overtaken by a mutation or root revision cannot prune newer lineage.", + "oracle": "Both SSH owner spellings reach five read forms without local Git/root registration; disconnection and authority rotation invalidate detected answers. Both catalog channels suppress side effects after mutation/root revision. Two direct canonical scans require two provider listings instead of one cached listing.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/worktrees-canonical-ssh-listing.test.ts" + ], + "testFiles": ["src/main/ipc/worktrees-canonical-ssh-listing.test.ts"], + "assertionRefs": [ + { + "file": "src/main/ipc/worktrees-canonical-ssh-listing.test.ts", + "assertions": [ + "keeps %s on the direct SSH boundary", + "reports disconnection without replaying an authoritative local-cache answer", + "preserves lineage when %s is overtaken by a %s" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-26", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/worktrees-canonical-ssh-listing.test.ts", + "result": "passed", + "durationSeconds": 0.884, + "summary": "26 permanent cases pass on isolated current main. Separate independent run40pass;8current-main related suites61pass. Author64scoped pass on shared checkout. Original permanent13fail/13controls. Initial candidate lineage regression repaired and revalidated." + } + ], + "runtimeBudget": { + "p95Seconds": 10, + "scope": "Focused mocked-provider unit suites; p95 not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Local author/independent/current-main validation only; no CI soak." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Original permanent13fail13controls; repaired26pass. Initial candidate lost actual lineage in2independent races; repaired40pass includes these controls." + }, + "performanceBudget": { + "required": true, + "evidence": "No added polling or concurrency; listAll remains bounded at8. Canonical SSH roots avoid local cache/registration. Trade-off:2connected direct scans use2provider listings instead of1cached result. No CPU/latency/heap improvement claim." + }, + "knownGaps": [ + "CI soak pending.", + "Local and daemon PTY, terminal output and mobile wire are unaffected.", + "Native Linux/Windows, live SSH/WSL and paired runtime-host integrations not executed. Generic runtime rows retain refusal.", + "Catalog list/listAll may still return a superseded plain-array snapshot; this gate prevents stale lineage side effects, not all response staleness.", + "Canonical forgetRemovedForExecutionHost remains outside scope; no new destructive authorization.", + "Filesystem classifier and registry defects are separate repairs; this gate only prevents listing-induced local-root admission.", + "Canonical SSH bypasses the improper5second local cache, increasing provider calls. No replacement cache or physical cancellation claim." + ], + "promotionCriteria": [ + "Retain exact-owner, no-local-I/O, cache-admission and overtaken-lineage controls; collect CI soak and real provider coverage." + ], + "demotionRule": "Keep experimental until soak; investigate host/lineage regressions without weakening ownership assertions or adding blind retries." + }, { "id": "xterm-addon.boundary-containment", "title": "xterm addon failures stay pane-scoped and input survives", @@ -11513,7 +12357,7 @@ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty-pending-projection-admissions.test.ts src/main/ipc/ssh-pty-legacy-projection.test.ts src/main/ipc/ssh-pty-model-admission.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-path-candidate-history.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/orca-runtime-tail-wait-memo.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/ssh/ssh-pty-retired-source-deliveries.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-session-data-delivery.test.ts src/main/ssh/ssh-relay-session-recovery-races.test.ts src/main/ssh/ssh-relay-session-incarnation.test.ts src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts src/main/ssh/ssh-relay-session-terminal-error.test.ts src/main/ssh/ssh-pty-recovery-retention-budget.test.ts src/main/ssh/relay-protocol-backpressure.test.ts src/relay/protocol-backpressure.test.ts src/relay/pty-source-credit-ledger.test.ts src/relay/pty-source-credit-scheduler.test.ts src/relay/relay-pty-source-publication.test.ts src/relay/ssh-pty-source-credit-adapter.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/ssh/ssh-pty-retired-source-deliveries.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-session-data-delivery.test.ts src/main/ssh/ssh-relay-session-recovery-races.test.ts src/main/ssh/ssh-relay-session-incarnation.test.ts src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts src/main/ssh/ssh-relay-session-terminal-error.test.ts src/main/ssh/ssh-pty-recovery-retention-budget.test.ts src/relay/protocol-backpressure.test.ts src/relay/pty-source-credit-ledger.test.ts src/relay/pty-source-credit-scheduler.test.ts src/relay/relay-pty-source-publication.test.ts src/relay/ssh-pty-source-credit-adapter.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-pty-model-admission.test.ts src/main/ipc/ssh-pty-output-model-migration.test.ts src/main/ssh/ssh-relay-session-model-migration.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/relay/git-response-stream-ownership.test.ts src/relay/pty-handler-output-streaming.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot", @@ -11560,7 +12404,6 @@ "src/main/ssh/ssh-relay-session-terminal-error.test.ts", "src/main/ssh/ssh-pty-recovery-retention-budget.test.ts", "src/main/ssh/ssh-pty-retired-source-deliveries.test.ts", - "src/main/ssh/relay-protocol-backpressure.test.ts", "src/relay/protocol-backpressure.test.ts", "src/relay/git-response-stream-ownership.test.ts", "src/relay/pty-handler-output-streaming.test.ts", @@ -11777,13 +12620,6 @@ "invalid-checkpoint cancellation retains the exact delivery until restore response settlement, then retry mints a fresh activation and emits one live source frame" ] }, - { - "file": "src/main/ssh/relay-protocol-backpressure.test.ts", - "assertions": [ - "main SSH decoder accepts one maximum frame plus 1 MiB partial input and rejects one extra byte", - "a throwing continuation clears retained input, releases one pause epoch, and publishes one typed ownership error" - ] - }, { "file": "src/relay/protocol-backpressure.test.ts", "assertions": [ @@ -11972,7 +12808,7 @@ "date": "2026-07-29", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/ssh/ssh-pty-retired-source-deliveries.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-session-data-delivery.test.ts src/main/ssh/ssh-relay-session-recovery-races.test.ts src/main/ssh/ssh-relay-session-incarnation.test.ts src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts src/main/ssh/ssh-relay-session-terminal-error.test.ts src/main/ssh/ssh-pty-recovery-retention-budget.test.ts src/main/ssh/relay-protocol-backpressure.test.ts src/relay/protocol-backpressure.test.ts src/relay/pty-source-credit-ledger.test.ts src/relay/pty-source-credit-scheduler.test.ts src/relay/relay-pty-source-publication.test.ts src/relay/ssh-pty-source-credit-adapter.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/ssh/ssh-pty-retired-source-deliveries.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-session-data-delivery.test.ts src/main/ssh/ssh-relay-session-recovery-races.test.ts src/main/ssh/ssh-relay-session-incarnation.test.ts src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts src/main/ssh/ssh-relay-session-terminal-error.test.ts src/main/ssh/ssh-pty-recovery-retention-budget.test.ts src/relay/protocol-backpressure.test.ts src/relay/pty-source-credit-ledger.test.ts src/relay/pty-source-credit-scheduler.test.ts src/relay/relay-pty-source-publication.test.ts src/relay/ssh-pty-source-credit-adapter.test.ts --reporter=dot", "result": "passed", "durationSeconds": 2.24, "summary": "Sixteen deterministic SSH relay/session/source/decoder files passed 192 tests, including exit-sealed private recovery, retained stale-transfer cancellation authority, exact private-frame proof watermarks, one retirement record across 10,000 same-PTY token rotations, stale-owner fallback, and scheduler rejection isolation; no live topology was exercised." @@ -14661,7 +15497,7 @@ "oracle": "Seed status, dispatch, and worker_done rows across direct-handle and canonical Run recipients in an isolated DB. Compare pointer count, RPC and built-CLI check output, direct SQLite rows, unread/peek/all/type filters, concurrent pollers, fixed Delivery IDs, explicit acknowledgment, restart, filtered check --wait, and coordinator remint. Route a 125-row old-handle backlog, inject a commit without notification, and require startup repair. Exercise duplicate Run/Dispatch owners, stale panes, 50-row pages, cancellation, lifecycle fencing, and absent PTYs. Drop a federation ACK, reconnect/restart v1/v2 peers, and require stable import plus no duplicate read-row wake. Hold a healthy SSH write past five seconds but below the 60-second settlement deadline, then distinguish the three settlement outcomes end to end: only a proven refusal releases the reservation and drains a delivery parked behind the watermark; a dropped in-flight settlement must surface as unverifiable with bytes handed to the transport, preserve the durable write-attempted reservation, and emit no duplicate pointer after restart; a settled write that throws mid-pointer is unverifiable, not a refusal; and an Enter whose settlement is lost stays at enter-attempted so restart emits no second Enter. Install the production PTY controller and verify that it routes settled writes through the owning provider and refuses before any byte when the routed provider cannot settle. Census every production PTY provider class and reject a settlement synthesized from the fire-and-forget write.", "commands": [ "pnpm run build:cli && pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-message-delivery-identity.test.ts --reporter=dot --testTimeout=5000", - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/providers/settled-pty-writer-census.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/terminal-send-stale-leaf-liveness.test.ts src/main/runtime/rpc/methods/orchestration/runs/runs.test.ts src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/federation-sync.test.ts src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts --reporter=dot" ], @@ -14678,7 +15514,6 @@ "src/main/runtime/orchestration/formatter.test.ts", "src/main/providers/ssh-pty-provider.test.ts", "src/main/providers/ssh-pty-write.test.ts", - "src/main/providers/settled-pty-writer-census.test.ts", "src/main/runtime/orchestration/mailbox-pointer-stage.test.ts", "src/main/daemon/client.test.ts", "src/main/daemon/daemon-pty-router.test.ts", @@ -14780,13 +15615,6 @@ "file": "src/main/runtime/orchestration/mailbox-pointer-stage.test.ts", "assertions": ["a refused pointer write drains a delivery parked behind its watermark"] }, - { - "file": "src/main/providers/settled-pty-writer-census.test.ts", - "assertions": [ - "every production IPtyProvider class exposes a settled writer", - "no settled writer synthesizes its settlement from the fire-and-forget write" - ] - }, { "file": "src/main/ipc/pty-controller-ownership-routing.test.ts", "assertions": [ @@ -14826,7 +15654,7 @@ "date": "2026-09-05", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/providers/settled-pty-writer-census.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts", "result": "passed", "durationSeconds": 4.73, "summary": "267 tests passed after the pointer-write path moved to the three-valued WriteSettlement union. New coverage: a dropped in-flight SSH settlement reaches the stager as unverifiable with bytes handed to the transport, a settled write that throws mid-pointer preserves the write-attempted reservation, an Enter whose settlement is lost stays at enter-attempted with no second Enter after restart, a refusal releases the reservation and drains a delivery parked behind its watermark, the production controller refuses before any byte when the routed provider cannot settle, and a census pins the five production IPtyProvider classes and rejects a settlement synthesized from the fire-and-forget write. Each new assertion was verified red against the pre-fix shape." @@ -14835,7 +15663,7 @@ "date": "2026-08-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/providers/settled-pty-writer-census.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts", "result": "passed", "durationSeconds": 8.22, "summary": "245 tests passed across mailbox identity, durable coordinator-handle migration, insertion-time canonicalization, duplicate-free 51-row ownership branch caps, unrestricted reservation merging, direct and Dispatch pointer suppression, persisted reconciliation, 50-row paging and filtered waits, cross-PTY serialization, lifecycle fencing, bounded daemon and SSH transport settlement, outstanding Deliveries, reminted Dispatch ownership, acknowledgment, cancellation, and bounded pane lookup." @@ -17477,6 +18305,86 @@ ], "demotionRule": "Demote or quarantine if the gate flakes once without a product bug or harness bug filed to the owner." }, + { + "id": "runtime-events.renderer-subscription-ownership", + "title": "Runtime events stay owned by their renderer subscription", + "maturity": "experimental", + "protection": "partial", + "owner": "runtime-platform", + "layer": "ipc-contract", + "surfaces": [ + "renderer runtime events", + "remote runtime reconnect replay", + "renderer Linear refresh" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["remote-runtime"], + "coverageNotes": "Actual renderer manager, bridge, store actions, adapter and preload dispatcher with synthetic IPC boundaries on macOS. No rendered/native lifecycle, live peer or real provider network claim.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/blob/main/src/renderer/src/hooks/runtime-client-events-sync.ts" + ], + "invariant": "Only the current subscription attempt can cause renderer event work or reconnect recovery. Current early frames and other hosts survive; canceled late setup handles dispose.", + "oracle": "Hold setup, clean up the bridge, deliver100 events and a replay, and require zero provider read dispatches/cache publications/recovery/re-subscriptions. Require initial/live delivery, same-ID replacement, rekey, stop during synchronous setup, sibling host survival and late disposal.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/runtime-client-events-sync-ownership.test.ts src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge-ownership.test.ts src/renderer/src/hooks/runtime-client-events-sync.test.ts src/renderer/src/hooks/ipc-events/runtime-reconnect-host-status.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/preload/runtime-environment-subscriptions.test.ts" + ], + "testFiles": [ + "src/renderer/src/hooks/runtime-client-events-sync-ownership.test.ts", + "src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge-ownership.test.ts", + "src/renderer/src/hooks/runtime-client-events-sync.test.ts", + "src/renderer/src/hooks/ipc-events/runtime-reconnect-host-status.test.ts", + "src/renderer/src/runtime/runtime-client-events.test.ts", + "src/preload/runtime-environment-subscriptions.test.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge-ownership.test.ts", + "assertions": [ + "does no Linear read dispatch or cache publication after cleanup while setup is pending", + "does no replay recovery or resubscription after cleanup", + "accepts a fresh bridge early frame while rejecting the previous bridge frame" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-25", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/runtime-client-events-sync-ownership.test.ts src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge-ownership.test.ts src/renderer/src/hooks/runtime-client-events-sync.test.ts src/renderer/src/hooks/ipc-events/runtime-reconnect-host-status.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/preload/runtime-environment-subscriptions.test.ts", + "result": "passed", + "durationSeconds": 2.12, + "summary": "41 tests /6 suites pass;13 new regressions have12 failures /1 pass against original production source." + } + ], + "runtimeBudget": { + "p95Seconds": 15, + "scope": "Focused deterministic renderer/preload suites; p95 not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Local validation only; no CI soak." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "12/13 new tests fail original source; candidate41 tests pass.100 stale dispatches/publications become0 each." + }, + "performanceBudget": { + "required": true, + "evidence": "No new timers, scans or IPC. Per-event O(1) owner guard adds paired median0.026ms/10k pending events and0.062ms/10k settled events. Each benchmark delivers2.2million events with no per-event desired/key reads. Synthetic timing, no rendered latency claim." + }, + "knownGaps": [ + "No native Windows/Linux/WSL, live SSH/remote or rendered app lifecycle test.", + "Already-admitted asynchronous handlers are not canceled; physical listener cleanup still waits for setup settlement.", + "No CI soak or mounted render/network/heap measurement." + ], + "promotionCriteria": [ + "Retain actual composed dispatch/publication and late-disposal oracles; add CI soak and cross-platform lifecycle evidence before promotion." + ], + "demotionRule": "Keep experimental until soak; investigate failures without suppressing ownership assertions or unexplained retries." + }, { "id": "runtime-files.watcher-process-isolation", "title": "Runtime and SSH relay watcher faults stay process-isolated without disrupting host services", @@ -20275,6 +21183,236 @@ "Default config paths are shared with GNOME on a disposable hosted CI runner; nested mode refuses non-GitHub-Actions execution." ], "demotionRule": "Keep experimental on unexplained failures; retain exact bytes and participation checks without retries, skips, or longer deadlines." + }, + { + "id": "relay.control-activation-ownership", + "title": "Closed queued controls leave persistence for the live retry", + "maturity": "experimental", + "protection": "partial", + "owner": "relay", + "layer": "registry-unit-and-local-websocket", + "surfaces": ["relay host control activation"], + "platforms": ["macos", "linux", "windows"], + "providers": ["relay"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["relay"], + "coverageNotes": "Actual registry queue with controlled store latency plus local WebSocket proof and connection-capacity suites on macOS. No PostgreSQL or live remote host run. PTY, daemon, SSH process state, WSL execution and folder/git workspace behavior are unaffected.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/blob/main/cloud/apps/relay/src/host-session-registry.ts" + ], + "invariant": "A closed queued transport starts no durable activation; an already-started activation retains cleanup, and a live successor becomes the indexed control without concurrent same-host activation.", + "oracle": "Queue a closed control before a live retry behind stalled persistence. With controlled 4s store calls, require two total activations, one release for the already-started closed control, and live readiness at 4s. Existing tests retain cleanup when closure happens during I/O.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm --dir cloud/apps/relay exec vitest run ../../../cloud/apps/relay/src/host-session-registry.test.ts ../../../cloud/apps/relay/src/host-session-client-accept.test.ts ../../../cloud/apps/relay/src/relay-host-proof-failure.blackbox.test.ts ../../../cloud/apps/relay/src/relay-connection-hard-cap.blackbox.test.ts --silent=false" + ], + "testFiles": [ + "cloud/apps/relay/src/host-session-registry.test.ts", + "cloud/apps/relay/src/host-session-client-accept.test.ts", + "cloud/apps/relay/src/relay-host-proof-failure.blackbox.test.ts", + "cloud/apps/relay/src/relay-connection-hard-cap.blackbox.test.ts" + ], + "assertionRefs": [ + { + "file": "cloud/apps/relay/src/host-session-registry.test.ts", + "assertions": [ + "skips a closed queued control so its live retry avoids abandoned database work", + "does not publish a control that closes during activation", + "does not rebind a control that closes during activation", + "fails a control waiting behind a stalled activation without breaking serialization" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-25", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm --dir cloud/apps/relay exec vitest run ../../../cloud/apps/relay/src/host-session-registry.test.ts ../../../cloud/apps/relay/src/host-session-client-accept.test.ts ../../../cloud/apps/relay/src/relay-host-proof-failure.blackbox.test.ts ../../../cloud/apps/relay/src/relay-connection-hard-cap.blackbox.test.ts --silent=false", + "result": "passed", + "durationSeconds": 0.407, + "summary": "88 tests across four files passed; relay typecheck passed separately." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "Focused unit and local socket suites; p95 not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Local validation only; no CI soak." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Baseline regression failed: three activation calls, two releases, live ready at 8s. Guarded registry passed with two calls, one release, live ready at 4s under controlled store latency." + }, + "performanceBudget": { + "required": true, + "evidence": "Deletes abandoned persistence before it starts. No new timer, polling, queue, provider call or wire change; existing FIFO and post-I/O cleanup remain." + }, + "knownGaps": [ + "No real PostgreSQL contention benchmark or live remote host/physical mobile run.", + "Windows/Linux execution and CI soak are not recorded; queue cleanup after a never-settling predecessor remains unchanged." + ], + "promotionCriteria": [ + "Retain red/green count and latency assertions; collect cross-platform and CI soak evidence." + ], + "demotionRule": "Keep experimental until soak evidence; investigate lifecycle failures without retries or weakening count, identity or cleanup assertions." + }, + { + "id": "relay.assignment-headroom-scope", + "title": "Single-cell admission preserves capacity with bounded headroom reads", + "maturity": "experimental", + "protection": "partial", + "owner": "relay", + "layer": "sqlite-store-integration", + "surfaces": ["relay assignment admission", "relay migration target capacity"], + "platforms": ["macos", "linux", "windows"], + "providers": ["relay"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["relay"], + "coverageNotes": "Actual assignment store and SQLite tests on macOS. No real PostgreSQL or live remote host/physical phone run. Local/daemon PTY, SSH execution ownership, WSL, folder/git workspace behavior and transport framing are unaffected.", + "motivatingLinks": ["https://github.com/stablyai/orca/issues/23035"], + "invariant": "A single-cell capacity check reads only that cell while retaining snapshot freshness, incarnation, outstanding reservation and missing-limit policy; new placement retains the full fleet inventory.", + "oracle": "With 32 capped cells, an existing active host stays on its pinned cell and reads one headroom row; new placement reads all 32. Missing, expired or prior-incarnation snapshots, capacity exhaustion and outstanding debt still refuse the active pinned host; an uncapped cell remains admissible.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm --dir cloud/apps/relay exec vitest run ../../../cloud/apps/relay/src/assignment-headroom-scope.test.ts ../../../cloud/apps/relay/src/assignment-connection-headroom.test.ts ../../../cloud/apps/relay/src/assignment-store.test.ts ../../../cloud/apps/relay/src/assignment-store-lock-order.test.ts ../../../cloud/apps/relay/src/cell-inventory-lock-contention.test.ts ../../../cloud/apps/relay/src/assignment-isolated-cell-replacement.test.ts ../../../cloud/apps/relay/src/regional-rehome-target-selection.test.ts --silent=false" + ], + "testFiles": [ + "cloud/apps/relay/src/assignment-headroom-scope.test.ts", + "cloud/apps/relay/src/assignment-connection-headroom.test.ts", + "cloud/apps/relay/src/assignment-store.test.ts", + "cloud/apps/relay/src/assignment-store-lock-order.test.ts", + "cloud/apps/relay/src/cell-inventory-lock-contention.test.ts", + "cloud/apps/relay/src/assignment-isolated-cell-replacement.test.ts", + "cloud/apps/relay/src/regional-rehome-target-selection.test.ts" + ], + "assertionRefs": [ + { + "file": "cloud/apps/relay/src/assignment-headroom-scope.test.ts", + "assertions": [ + "reads one cell for a sticky assignment even when the capped fleet grows", + "still considers the full fleet for a new placement", + "rejects the pinned active host with %s", + "preserves admission for cells without a connection limit", + "counts outstanding reservations at the admission boundary" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-25", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm --dir cloud/apps/relay exec vitest run ../../../cloud/apps/relay/src/assignment-headroom-scope.test.ts ../../../cloud/apps/relay/src/assignment-connection-headroom.test.ts ../../../cloud/apps/relay/src/assignment-store.test.ts ../../../cloud/apps/relay/src/assignment-store-lock-order.test.ts ../../../cloud/apps/relay/src/cell-inventory-lock-contention.test.ts ../../../cloud/apps/relay/src/assignment-isolated-cell-replacement.test.ts ../../../cloud/apps/relay/src/regional-rehome-target-selection.test.ts --silent=false", + "result": "passed", + "durationSeconds": 0.513, + "summary": "189 tests across seven files passed; independent root rerun also passed189 tests. Relay typecheck passed separately." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "Focused SQLite store/capacity/lock-order suites; p95 not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Local validation only; no CI soak." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Baseline two count regressions failed: sticky headroom 32 rows instead of 1 and uncapped target 31 unrelated rows instead of 0. Narrowed query passes all eight regression cases." + }, + "performanceBudget": { + "required": true, + "evidence": "Actual sticky path result rows 32 to 1; no new timer, polling, queue, cache or provider call. Supplemental SQLite query benchmark with 32 cells and 400 active reservations each measured median per-query 0.285ms to 0.022ms; not a production PostgreSQL latency claim." + }, + "knownGaps": [ + "Real PostgreSQL query plan and concurrent admission not rerun for the scoped variant.", + "Windows/Linux execution, live remote host/mobile flows and CI soak are not recorded." + ], + "promotionCriteria": [ + "Retain red/green row budget and semantic admission cases; collect PostgreSQL, cross-platform and CI soak evidence." + ], + "demotionRule": "Keep experimental until soak evidence; investigate admission/ownership regressions without weakening row or capacity assertions." + }, + { + "id": "terminal-history.tombstone-rescan-ownership", + "title": "Shell-history cleanup reuses one directory listing while preserving deletion ownership", + "maturity": "experimental", + "protection": "partial", + "owner": "desktop-runtime", + "layer": "main-filesystem-contract", + "surfaces": ["shell-history tombstone cleanup", "native and WSL history deletion"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "wsl"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "wsl"], + "coverageNotes": "Actual macOS temp-filesystem deletion and fake WSL cleanup ownership. Live Windows, Linux and WSL execution untested; SSH and daemon replay unaffected because every path here is host-local.", + "motivatingLinks": ["https://github.com/stablyai/orca/issues/23030"], + "invariant": "Only renamed tombstones are recursively removed, active plus retry admissions stay at 64, retries retain their delays and exhaustion behavior, a root displaced at the cap is admitted from another root's freed slot, and no tombstone is handed to removal twice per listing.", + "oracle": "Drain 1,024 real directory entries in controlled batches using at most 8 directory listings and 64 active removals. Exercise late arrivals discovered after the listing, both roots draining under one shared cap, a failing listing mid-drain, delayed failure retries, exhausted attempt budgets, and cancellation of an in-flight listing.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/terminal-history-tombstone-scan-cost.test.ts src/main/terminal-history-async-delete.test.ts src/main/terminal-history-tombstone-retry.test.ts src/main/terminal-history-gc-fs-call-count.test.ts src/main/terminal-history-gc.test.ts src/main/terminal-history.test.ts" + ], + "testFiles": [ + "src/main/terminal-history-tombstone-scan-cost.test.ts", + "src/main/terminal-history-async-delete.test.ts", + "src/main/terminal-history-tombstone-retry.test.ts", + "src/main/terminal-history-gc-fs-call-count.test.ts", + "src/main/terminal-history-gc.test.ts", + "src/main/terminal-history.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/terminal-history-tombstone-scan-cost.test.ts", + "assertions": [ + "drains 1024 tombstones without re-reading the directory per completion", + "picks up a tombstone created after the queued names were read", + "drains both roots within the shared admission cap without per-completion reads", + "keeps draining after a directory read fails mid-drain", + "drops an in-flight refill during fixture cleanup", + "preserves delayed failure retries while successful removals replenish the queue", + "does not retry a tombstone past its attempt budget when another root frees slots" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-26", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/terminal-history-tombstone-scan-cost.test.ts src/main/terminal-history-async-delete.test.ts src/main/terminal-history-tombstone-retry.test.ts src/main/terminal-history-gc-fs-call-count.test.ts src/main/terminal-history-gc.test.ts src/main/terminal-history.test.ts", + "result": "passed", + "durationSeconds": 1.95, + "summary": "86 tests across six suites passed, repeated three times with no flakes." + } + ], + "runtimeBudget": { + "p95Seconds": 15, + "scope": "focused temp-filesystem history tests" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Local author and independent runs pass; CI/soak history not collected." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Pre-change main lists the tombstone directory 1,026 times for 1,024 tombstones and fails the bounded-listing oracle. Reusing one listing reduces it to 6 while still removing every entry." + }, + "performanceBudget": { + "required": true, + "evidence": "Five real-filesystem samples per version, 1,024 tombstone directories each holding a meta.json, macOS arm64 / Node 24, identical harness. Median directory listings 1,026 to 6, names enumerated 494,348 to 1,077, blocking main-thread time in the rescan path 820 to 7 ms (the listings themselves 266 to 2.7 ms, and now off-thread), total drain 886 to 75 ms. The remaining ~70 ms is the recursive rm itself and is unchanged. Average concurrent removals 62 of 64, restored from the 46 an event-loop-deferred refill left idle. No polling, no new timers, and no work deferred past process exit." + }, + "promotionCriteria": [ + "Collect 100 consecutive CI passes or 14 days of soak history.", + "Run native Windows and WSL history cleanup on real filesystems." + ], + "knownGaps": [ + "No rendered UI latency measurement or physical Windows/Linux/WSL run.", + "One listing is retained per root while it drains, about 160 KB per 1,024 tombstones, shrinking as names are consumed.", + "A small backlog still costs roughly one listing per completion batch, of a directory that is nearly empty by then.", + "A tombstone that exhausts its retries stays on disk until the next startup, unchanged from before." + ], + "demotionRule": "Keep experimental or demote if the reused listing strands a displaced root, crosses the admission cap, rearms an exhausted retry through another root, hands one tombstone to removal twice, or touches a recreated live history path." } ] } diff --git a/config/scripts/audit-localization-coverage.mjs b/config/scripts/audit-localization-coverage.mjs index e25a7bc222e..c5f27a66422 100644 --- a/config/scripts/audit-localization-coverage.mjs +++ b/config/scripts/audit-localization-coverage.mjs @@ -7,9 +7,9 @@ import process from 'node:process' import ts from 'typescript-api' const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts']) -// Why: test-only modules live beside their spec as `*-test-harness.ts` / `*-fixtures.ts` here, not under `__tests__/`. +// Why: test-only modules live beside their spec as `*-test-harness.ts` / `*-test-rig.ts` / `*-fixtures.ts` here, not under `__tests__/`. const TEST_SUPPORT_FILE_PATTERN = - /[.-](?:test-harness|test-fixtures?|test-state|test-support|fixtures?)\.[cm]?[jt]sx?$/ + /[.-](?:test-harness|test-rig|test-fixtures?|test-state|test-support|fixtures?)\.[cm]?[jt]sx?$/ const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets']) const LOCALIZATION_CALL_NAMES = new Set(['t', 'translate']) const USER_VISIBLE_JSX_ATTRIBUTES = new Set([ diff --git a/config/scripts/audit-localization-coverage.test.mjs b/config/scripts/audit-localization-coverage.test.mjs index 477054f2e8f..6460dc9f36d 100644 --- a/config/scripts/audit-localization-coverage.test.mjs +++ b/config/scripts/audit-localization-coverage.test.mjs @@ -50,6 +50,7 @@ describe('localization coverage file skipping', () => { it('skips test-only modules that sit beside their spec', () => { expect(skipped('src/renderer/src/components/browser-pane/stream-test-harness.ts')).toBe(true) + expect(skipped('src/renderer/src/runtime/browser-tab-creation-test-rig.ts')).toBe(true) expect(skipped('src/renderer/src/hooks/ipc-events-test-fixtures.ts')).toBe(true) expect(skipped('src/renderer/src/lib/session-test-state.ts')).toBe(true) expect(skipped('src/renderer/src/store/slices/routing-fixture.ts')).toBe(true) diff --git a/config/scripts/build-mobile-web-app-bundle.mjs b/config/scripts/build-mobile-web-app-bundle.mjs index 51178e9bed5..1bfad89b6e7 100644 --- a/config/scripts/build-mobile-web-app-bundle.mjs +++ b/config/scripts/build-mobile-web-app-bundle.mjs @@ -1,4 +1,5 @@ import { readFile } from 'node:fs/promises' +import { readRouteSnapshot } from './mobile-web-app-route-snapshot.mjs' import { realpathSync } from 'node:fs' import { basename, extname, join, resolve } from 'node:path' import { createRequire } from 'node:module' @@ -466,7 +467,10 @@ const isScriptOutput = (path) => path.endsWith('.js') * wrap every route, and their imports are part of the page as surely as the route module's. */ export async function mobileWebAppRouteClosure(routeModule) { - return await mobileWebAppModuleClosure(['app/_layout', 'app/h/_layout', routeModule]) + return ( + readRouteSnapshot(routeModule) ?? + (await mobileWebAppModuleClosure(['app/_layout', 'app/h/_layout', routeModule])) + ) } /** diff --git a/config/scripts/build-mobile-web-app-bundle.test.mjs b/config/scripts/build-mobile-web-app-bundle.test.mjs index cd56aa98c0a..baa18b6b39d 100644 --- a/config/scripts/build-mobile-web-app-bundle.test.mjs +++ b/config/scripts/build-mobile-web-app-bundle.test.mjs @@ -1,8 +1,13 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' +import { mkdir, readFile, writeFile } from 'node:fs/promises' import { join, relative } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' +import { + withScratch, + readAppBundle, + readWrittenBundle, + copyWrittenBundle +} from './mobile-web-app-bundle-test-fixture.mjs' import { MOBILE_WEB_APP_NATIVE_PARITY_STYLE, MOBILE_WEB_APP_ROOT_RESET, @@ -65,15 +70,6 @@ function allScriptSource({ script, chunks }) { return [script, ...chunks.map((chunk) => chunk.bytes)].map((bytes) => bytes.toString('utf8')) } -async function withScratch(run) { - const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-test-')) - try { - return await run(scratch) - } finally { - await rm(scratch, { recursive: true, force: true }) - } -} - describe('the CRLF pin', () => { it('exempts the same extensions in .gitattributes as the CRLF scan skips', async () => { const attributes = await readFile(join(projectDir, '.gitattributes'), 'utf8') @@ -91,8 +87,36 @@ describe('the CRLF pin', () => { }) describeBundling('the app bundle', () => { + it('isolates read-only fixture consumers from mutations in another assertion', async () => { + const first = await readAppBundle() + const original = first.script[0] + first.script[0] ^= 255 + first.chunks.length = 0 + first.routeKeys.length = 0 + const next = await readAppBundle() + expect(next.script[0]).toBe(original) + expect(next.chunks.length).toBeGreaterThan(0) + expect(next.routeKeys.length).toBeGreaterThan(0) + const written = await readWrittenBundle() + written.manifest.assets.length = 0 + expect((await readWrittenBundle()).manifest.assets.length).toBeGreaterThan(0) + const originalByte = written.files[0].bytes[0] + written.files[0].bytes[0] ^= 255 + expect((await readWrittenBundle()).files[0].bytes[0]).toBe(originalByte) + await withScratch(async (scratch) => { + const firstDir = join(scratch, 'first') + await copyWrittenBundle(firstDir) + await writeFile(join(firstDir, 'manifest.json'), 'corrupted') + const secondDir = join(scratch, 'second') + const second = await copyWrittenBundle(secondDir) + for (const { file, bytes } of second.files) { + expect((await readFile(join(secondDir, file))).equals(bytes), file).toBe(true) + } + }) + }, 120_000) + it('resolves react-native to react-native-web and leaves no require.context', async () => { - const sources = allScriptSource(await bundleMobileWebApp()) + const sources = allScriptSource(await readAppBundle()) for (const source of sources) { expect(source).not.toContain('require.context') } @@ -101,7 +125,7 @@ describeBundling('the app bundle', () => { }, 120_000) it('cuts the routes into chunks the entry does not load', async () => { - const { script, chunks, entryStaticBytes } = await bundleMobileWebApp() + const { script, chunks, entryStaticBytes } = await readAppBundle() expect(chunks.length).toBeGreaterThan(1) // The entry's own bytes plus the chunks it imports statically, which is what the browser // parses before any route paints. Every route chunk is outside it. @@ -112,7 +136,7 @@ describeBundling('the app bundle', () => { }, 120_000) it('names the chunk each route lands in', async () => { - const { chunks, routeChunks, routeKeys } = await bundleMobileWebApp() + const { chunks, routeChunks, routeKeys } = await readAppBundle() expect(Object.keys(routeChunks).sort()).toEqual([...routeKeys].sort()) const emitted = new Set(chunks.map((chunk) => chunk.name)) for (const [key, name] of Object.entries(routeChunks)) { @@ -202,7 +226,7 @@ describeBundling('the app bundle', () => { ) it('bundles every route module', async () => { - const { routeKeys } = await bundleMobileWebApp() + const { routeKeys } = await readAppBundle() expect(routeKeys).toEqual(await collectMobileWebAppRouteKeys(appDir)) }, 120_000) @@ -274,7 +298,7 @@ describeBundling('the app bundle', () => { }, 240_000) it("names an output the same way the manifest's own asset hash does", async () => { - const { script, chunks } = await bundleMobileWebApp() + const { script, chunks } = await readAppBundle() // The name is embedded in the importer, so it cannot be recomputed later; this is what says // the name inside the bytes and the manifest's sha256 of those bytes are the same string. expect(hashedAsset(script, 'js').path).toBe(`assets/${sha256Hex(script)}.js`) @@ -330,7 +354,7 @@ describeBundling('the app bundle', () => { // once per call. The file explorer calls triggerSelection on every row tap, and C1.9 already // traced a swallowed long press on the worktree list to that stray click. `haptics.web.ts` is // what keeps the whole shim out of the bundle, so this reads the bytes rather than the import. - for (const source of allScriptSource(await bundleMobileWebApp())) { + for (const source of allScriptSource(await readAppBundle())) { // The shim's own fingerprint, not `navigator.vibrate`: react-native-web's Vibration export // calls that too, and it touches no DOM until something invokes it. expect(source).not.toContain('ariaHidden') @@ -340,7 +364,7 @@ describeBundling('the app bundle', () => { }, 120_000) it("ships react-native-web's hairline at one device pixel, whichever of its builds resolves", async () => { - const sources = allScriptSource(await bundleMobileWebApp()) + const sources = allScriptSource(await readAppBundle()) // Minified, so the assignment reads `.hairlineWidth=`; RNW's own value is the literal 1. const assignments = sources.flatMap( (source) => source.match(/\.hairlineWidth=[^;]{0,120}/g) ?? [] @@ -354,7 +378,7 @@ describeBundling('the app bundle', () => { it('embeds no absolute path from this checkout', async () => { // Every chunk, not only the entry: the route manifest names each route by absolute path, and // the chunk that import resolves to is where such a path would survive. - for (const source of allScriptSource(await bundleMobileWebApp())) { + for (const source of allScriptSource(await readAppBundle())) { expect(source).not.toContain(projectDir) } }, 120_000) @@ -370,76 +394,56 @@ describeBundling('the app bundle', () => { }, 120_000) it('loads the entry as a module, so its route imports resolve', async () => { - await withScratch(async (scratch) => { - const outDir = join(scratch, 'module-tag') - const { manifest } = await buildMobileWebAppBundle({ outDir }) - const html = await readFile(join(outDir, 'index.html'), 'utf8') - // import() in a classic script is a syntax error, so the tag and the format are one fact. - expect(html).toContain('` + ` } function childPage( context: string, diff --git a/src/main/browser/browser-session-ua.ts b/src/main/browser/browser-session-ua.ts index 5b4d4735dcd..fc05fe357cc 100644 --- a/src/main/browser/browser-session-ua.ts +++ b/src/main/browser/browser-session-ua.ts @@ -1,5 +1,5 @@ import type { Session } from 'electron' -import type { ViewportUserAgentOverride } from './browser-viewport-user-agent' +import type { BrowserTabIdentity, UserAgentMetadata } from './browser-tab-identity' export { cleanElectronUserAgent } from './browser-process-user-agent' import { getBrowserProcessUserAgentIdentity } from './browser-process-user-agent' @@ -19,7 +19,7 @@ export type BrowserSessionRequestUserAgentResolver = (args: { webContentsId?: number currentUserAgent?: string effectiveUserAgent?: string -}) => ViewportUserAgentOverride | undefined +}) => BrowserTabIdentity | undefined function quoteClientHint(value: string): string { return `"${value.replace(/["\\]/g, '\\$&')}"` @@ -33,7 +33,7 @@ function formatClientHintBrands(brands: { brand: string; version: string }[]): s function applyUserAgentMetadataHeaders( headers: Record, - metadata: NonNullable + metadata: UserAgentMetadata ): void { const values: Record = { 'sec-ch-ua': formatClientHintBrands(metadata.brands), @@ -101,15 +101,10 @@ export function installBrowserSessionUserAgentPolicy( callback({ requestHeaders: headers }) return } - if (identity.userAgent) { - setUserAgentHeader(headers, identity.userAgent) - } - if (identity.userAgent === firefoxUa) { + setUserAgentHeader(headers, identity.userAgent) + if (identity.kind === 'google-auth') { stripClientHints(headers) - callback({ requestHeaders: headers }) - return - } - if (identity.userAgentMetadata) { + } else if (identity.kind === 'mobile') { applyUserAgentMetadataHeaders(headers, identity.userAgentMetadata) } callback({ requestHeaders: headers }) diff --git a/src/main/browser/browser-tab-identity.test.ts b/src/main/browser/browser-tab-identity.test.ts new file mode 100644 index 00000000000..520eae50819 --- /dev/null +++ b/src/main/browser/browser-tab-identity.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' + +import { googleAuthUserAgent } from './browser-google-auth-ua' +import { resolveBrowserTabIdentity } from './browser-tab-identity' + +const CHROME_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36' +const NATIVE_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Orca/1.0.0 Chrome/134.0.0.0 Electron/43.0.0 Safari/537.36' +const clean = { mode: 'clean', userAgent: CHROME_UA } as const +const native = { mode: 'native', userAgent: NATIVE_UA } as const +const AUTH_URL = 'https://accounts.google.com/v3/signin/identifier' + +describe('resolveBrowserTabIdentity', () => { + it('presents Firefox on Google auth hosts in clean mode regardless of the preset', () => { + for (const mobile of [false, true]) { + expect(resolveBrowserTabIdentity({ url: AUTH_URL, mobile, processIdentity: clean })).toEqual({ + kind: 'google-auth', + userAgent: googleAuthUserAgent() + }) + } + }) + + it('leaves Google auth hosts on the native identity in native mode', () => { + expect( + resolveBrowserTabIdentity({ url: AUTH_URL, mobile: false, processIdentity: native }) + ).toEqual({ kind: 'process', userAgent: NATIVE_UA }) + expect( + resolveBrowserTabIdentity({ url: AUTH_URL, mobile: true, processIdentity: native }).kind + ).toBe('mobile') + }) + + it('presents the process identity itself off the auth hosts without a mobile preset', () => { + for (const processIdentity of [clean, native]) { + expect( + resolveBrowserTabIdentity({ + url: 'https://myaccount.google.com/', + mobile: false, + processIdentity + }) + ).toEqual({ kind: 'process', userAgent: processIdentity.userAgent }) + } + }) + + it('splices the real Chrome major into the mobile UA and its client hints', () => { + const identity = resolveBrowserTabIdentity({ + url: 'https://example.com/', + mobile: true, + processIdentity: clean + }) + expect(identity.kind).toBe('mobile') + expect(identity.userAgent).toContain('iPhone') + expect(identity.userAgent).toContain('CriOS/134.0.0.0') + if (identity.kind === 'mobile') { + expect(identity.userAgentMetadata.mobile).toBe(true) + expect(identity.userAgentMetadata.platform).toBe('iOS') + expect(identity.userAgentMetadata.brands).toContainEqual({ + brand: 'Google Chrome', + version: '134' + }) + } + }) + + it('falls back to a known Chrome major when the process UA carries none', () => { + const identity = resolveBrowserTabIdentity({ + url: 'https://example.com/', + mobile: true, + processIdentity: { mode: 'clean', userAgent: googleAuthUserAgent() } + }) + expect(identity.userAgent).toContain('CriOS/134.0.0.0') + }) + + it('treats an unparseable URL as a non-auth host', () => { + expect( + resolveBrowserTabIdentity({ url: 'not a url', mobile: false, processIdentity: clean }) + ).toEqual({ kind: 'process', userAgent: CHROME_UA }) + }) +}) diff --git a/src/main/browser/browser-tab-identity.ts b/src/main/browser/browser-tab-identity.ts new file mode 100644 index 00000000000..713b0310a4a --- /dev/null +++ b/src/main/browser/browser-tab-identity.ts @@ -0,0 +1,90 @@ +// Why one owner: a tab's identity is read by the session request hook and written to the guest over +// two layers (the WebContents UA and a CDP override that outranks it). When each layer derived it on +// its own, a desktop viewport preset installed a CDP override with no userAgentMetadata, and Chromium +// then drops navigator.userAgentData and every sec-ch-ua header for that tab — a Chrome UA with no +// client hints, which bot checks read as a spoof. Every layer now asks this module instead. + +import type { BrowserProcessUserAgentIdentity } from './browser-process-user-agent' +import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua' + +type UserAgentBrand = { brand: string; version: string } + +export type UserAgentMetadata = { + brands: UserAgentBrand[] + fullVersionList: UserAgentBrand[] + fullVersion: string + platform: string + platformVersion: string + architecture: string + model: string + mobile: boolean +} + +export type BrowserTabIdentity = + /** The process identity, with its client hints left to Chromium. */ + | { kind: 'process'; userAgent: string } + /** Firefox on Google's auth hosts; real Firefox sends no client hints. */ + | { kind: 'google-auth'; userAgent: string } + | { kind: 'mobile'; userAgent: string; userAgentMetadata: UserAgentMetadata } + +export function googleAuthTabIdentity(): BrowserTabIdentity { + return { kind: 'google-auth', userAgent: googleAuthUserAgent() } +} + +/** + * The identity a tab presents at `url`. Desktop presets and "no preset" are deliberately the same + * input: only a mobile preset changes who the tab claims to be. + */ +export function resolveBrowserTabIdentity(args: { + url: string + mobile: boolean + processIdentity: BrowserProcessUserAgentIdentity +}): BrowserTabIdentity { + // Firefox is delivered per-target and cannot reach workers; keep it clean-only to preserve one + // coherent identity per mode instead of pairing a Firefox document with native workers. + if (args.processIdentity.mode === 'clean' && isGoogleAuthUrl(args.url)) { + return googleAuthTabIdentity() + } + if (args.mobile) { + return buildMobileTabIdentity(args.processIdentity.userAgent) + } + return { kind: 'process', userAgent: args.processIdentity.userAgent } +} + +// Why: responsive sites UA-sniff; this is Chrome DevTools' default iPhone UA template with the real +// Chrome major spliced in so the userAgentMetadata brands below agree with it. +function buildMobileUserAgent(chromeMajor: string): string { + return `Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/${chromeMajor}.0.0.0 Mobile/15E148 Safari/604.1` +} + +function extractChromeMajor(ua: string): string { + const match = ua.match(/Chrome\/(\d+)/) + return match ? match[1] : '134' +} + +function buildMobileTabIdentity(processUserAgent: string): BrowserTabIdentity { + const chromeMajor = extractChromeMajor(processUserAgent) + // Why: userAgentMetadata must accompany the mobile UA so client hints match, or bot-detection flags the desktop-hint leak. + return { + kind: 'mobile', + userAgent: buildMobileUserAgent(chromeMajor), + userAgentMetadata: { + brands: [ + { brand: 'Google Chrome', version: chromeMajor }, + { brand: 'Chromium', version: chromeMajor }, + { brand: 'Not/A)Brand', version: '24' } + ], + fullVersionList: [ + { brand: 'Google Chrome', version: `${chromeMajor}.0.0.0` }, + { brand: 'Chromium', version: `${chromeMajor}.0.0.0` }, + { brand: 'Not/A)Brand', version: '24.0.0.0' } + ], + fullVersion: `${chromeMajor}.0.0.0`, + platform: 'iOS', + platformVersion: '17.0', + architecture: '', + model: 'iPhone', + mobile: true + } + } +} diff --git a/src/main/browser/browser-viewport-user-agent.test.ts b/src/main/browser/browser-viewport-user-agent.test.ts deleted file mode 100644 index 1d069a209ad..00000000000 --- a/src/main/browser/browser-viewport-user-agent.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { googleAuthUserAgent } from './browser-google-auth-ua' -import { buildViewportUserAgentOverride } from './browser-viewport-user-agent' - -const CHROME_UA = - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36' - -describe('buildViewportUserAgentOverride', () => { - it('presents the Firefox UA on Google auth hosts regardless of the preset', () => { - for (const mobile of [false, true]) { - const override = buildViewportUserAgentOverride({ - url: 'https://accounts.google.com/v3/signin/identifier', - mobile, - baseUserAgent: CHROME_UA - }) - expect(override.userAgent).toBe(googleAuthUserAgent()) - // Real Firefox emits no client hints, so Chrome brands would contradict the stripped headers. - expect(override.userAgentMetadata).toBeUndefined() - } - }) - - it('keeps the clean desktop UA off the auth hosts', () => { - const override = buildViewportUserAgentOverride({ - url: 'https://myaccount.google.com/', - mobile: false, - baseUserAgent: CHROME_UA - }) - expect(override.userAgent).toBe(CHROME_UA) - expect(override.userAgentMetadata).toBeUndefined() - }) - - it('splices the real Chrome major into the mobile UA and its client hints', () => { - const override = buildViewportUserAgentOverride({ - url: 'https://example.com/', - mobile: true, - baseUserAgent: CHROME_UA - }) - expect(override.userAgent).toContain('iPhone') - expect(override.userAgent).toContain('CriOS/134.0.0.0') - expect(override.userAgentMetadata?.mobile).toBe(true) - expect(override.userAgentMetadata?.platform).toBe('iOS') - expect(override.userAgentMetadata?.brands).toContainEqual({ - brand: 'Google Chrome', - version: '134' - }) - }) - - it('falls back to a known Chrome major when the base UA carries none', () => { - const override = buildViewportUserAgentOverride({ - url: 'https://example.com/', - mobile: true, - baseUserAgent: googleAuthUserAgent() - }) - expect(override.userAgent).toContain('CriOS/134.0.0.0') - }) - - it('treats an unparseable URL as a non-auth host', () => { - const override = buildViewportUserAgentOverride({ - url: 'not a url', - mobile: false, - baseUserAgent: CHROME_UA - }) - expect(override.userAgent).toBe(CHROME_UA) - }) -}) diff --git a/src/main/browser/browser-viewport-user-agent.ts b/src/main/browser/browser-viewport-user-agent.ts deleted file mode 100644 index 31c06307b46..00000000000 --- a/src/main/browser/browser-viewport-user-agent.ts +++ /dev/null @@ -1,75 +0,0 @@ -// Why: a CDP Emulation.setUserAgentOverride outranks WebContents.setUserAgent for both -// navigator.userAgent and the outgoing request header, and it stands across every later -// navigation until explicitly cleared. So the viewport preset's UA is a third identity layer -// that must agree with the auth-host Firefox switch, or applying a preset silently reintroduces -// the exact UA mismatch this scope exists to remove. - -import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua' - -type UserAgentBrand = { brand: string; version: string } - -export type ViewportUserAgentOverride = { - userAgent: string - userAgentMetadata?: { - brands: UserAgentBrand[] - fullVersionList: UserAgentBrand[] - fullVersion: string - platform: string - platformVersion: string - architecture: string - model: string - mobile: boolean - } -} - -// Why: responsive sites UA-sniff; this is Chrome DevTools' default iPhone UA template with the real -// Chrome major spliced in so the userAgentMetadata brands below agree with it. -function buildMobileUserAgent(chromeMajor: string): string { - return `Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/${chromeMajor}.0.0.0 Mobile/15E148 Safari/604.1` -} - -function extractChromeMajor(ua: string): string { - const match = ua.match(/Chrome\/(\d+)/) - return match ? match[1] : '134' -} - -export function buildViewportUserAgentOverride(args: { - url: string - mobile: boolean - baseUserAgent: string - googleAuthEnabled?: boolean -}): ViewportUserAgentOverride { - if (args.googleAuthEnabled !== false && isGoogleAuthUrl(args.url)) { - // Why: match the header-level Firefox switch exactly, and send no userAgentMetadata — real - // Firefox emits no client hints, so Chrome brands here would contradict the stripped headers. - return { userAgent: googleAuthUserAgent() } - } - if (!args.mobile) { - // Why: desktop presets republish the session's clean identity, or a preset would put the - // Electron/app tokens back on the wire and a transplanted session gets revoked (STA-7147). - return { userAgent: args.baseUserAgent } - } - const chromeMajor = extractChromeMajor(args.baseUserAgent) - // Why: userAgentMetadata must accompany the mobile UA so client hints match, or bot-detection flags the desktop-hint leak. - return { - userAgent: buildMobileUserAgent(chromeMajor), - userAgentMetadata: { - brands: [ - { brand: 'Google Chrome', version: chromeMajor }, - { brand: 'Chromium', version: chromeMajor }, - { brand: 'Not/A)Brand', version: '24' } - ], - fullVersionList: [ - { brand: 'Google Chrome', version: `${chromeMajor}.0.0.0` }, - { brand: 'Chromium', version: `${chromeMajor}.0.0.0` }, - { brand: 'Not/A)Brand', version: '24.0.0.0' } - ], - fullVersion: `${chromeMajor}.0.0.0`, - platform: 'iOS', - platformVersion: '17.0', - architecture: '', - model: 'iPhone', - mobile: true - } - } -} diff --git a/src/main/browser/cdp-debugger-channel.ts b/src/main/browser/cdp-debugger-channel.ts index f1cddbe20f1..b0c790138e8 100644 --- a/src/main/browser/cdp-debugger-channel.ts +++ b/src/main/browser/cdp-debugger-channel.ts @@ -3,6 +3,7 @@ import type { WebContents } from 'electron' import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron-debugger-lease' import type { CdpClientResponseWriter } from './cdp-client-response-writer' import type { CdpSyntheticSessionRegistry } from './cdp-synthetic-session-registry' +import { sendGuestCdpCommand } from './guest-cdp-command' /** * The IO boundary with webContents.debugger: lease-based attach, event fan-out to @@ -86,10 +87,9 @@ export class CdpDebuggerChannel { params: Record, sessionId?: string ): Promise { - const command = sessionId - ? this.webContents.debugger.sendCommand(method, params, sessionId) - : this.webContents.debugger.sendCommand(method, params) - return Promise.resolve(command) + return sessionId + ? sendGuestCdpCommand(this.webContents, method, params, sessionId) + : sendGuestCdpCommand(this.webContents, method, params) } forwardCommand( diff --git a/src/main/browser/cdp-debugger-lifecycle.ts b/src/main/browser/cdp-debugger-lifecycle.ts index 225eb689dba..0880f9926a4 100644 --- a/src/main/browser/cdp-debugger-lifecycle.ts +++ b/src/main/browser/cdp-debugger-lifecycle.ts @@ -4,6 +4,7 @@ import type { CdpTabState } from './cdp-auxiliary-commands' import type { CdpCommandSender } from './snapshot-engine' import type { CdpBridgeState } from './cdp-bridge-state' import { createCdpDebuggerMessageListener } from './cdp-debugger-events' +import { sendGuestCdpCommand } from './guest-cdp-command' export class CdpDebuggerLifecycle { constructor(private readonly bridgeState: CdpBridgeState) {} @@ -83,7 +84,7 @@ export class CdpDebuggerLifecycle { makeCdpSender(guest: WebContents, sessionId?: string): CdpCommandSender { return (method: string, params?: Record) => { - const command = guest.debugger.sendCommand(method, params, sessionId) as Promise + const command = sendGuestCdpCommand(guest, method, params, sessionId) // Why: Electron's CDP sendCommand can hang on a stale debugger session, so a 10s timeout bounds the RPC. let timer: ReturnType return Promise.race([ diff --git a/src/main/browser/cdp-ws-proxy-test-harness.ts b/src/main/browser/cdp-ws-proxy-test-harness.ts index c66a3cbf6b1..3f5765c18ab 100644 --- a/src/main/browser/cdp-ws-proxy-test-harness.ts +++ b/src/main/browser/cdp-ws-proxy-test-harness.ts @@ -25,6 +25,7 @@ export type MockWebContents = { webContents: { debugger: MockDebugger isDestroyed: () => boolean + isCrashed: () => boolean focus: Mock<() => void> printToPDF: Mock<() => Promise> reload: Mock<() => void> @@ -72,6 +73,7 @@ export function createMockWebContents(): MockWebContents { webContents: { debugger: debuggerObj, isDestroyed: () => destroyed, + isCrashed: () => false, focus: vi.fn(), printToPDF: vi.fn(async () => Buffer.from('%PDF-test')), reload: vi.fn(), diff --git a/src/main/browser/cdp-ws-proxy.test.ts b/src/main/browser/cdp-ws-proxy.test.ts index f744998ab24..db8eb82a1f8 100644 --- a/src/main/browser/cdp-ws-proxy.test.ts +++ b/src/main/browser/cdp-ws-proxy.test.ts @@ -803,23 +803,4 @@ describe('CdpWsProxy', () => { expect(removedEvents).toEqual(expect.arrayContaining(['message', 'close'])) offSpy.mockRestore() }) - - it('rejects inflight requests on stop', async () => { - let resolveCommand: (v: unknown) => void - mock.webContents.debugger.sendCommand.mockImplementation( - () => - new Promise((r) => { - resolveCommand = r as (v: unknown) => void - }) - ) - - const client = await connect(endpoint) - client.send(JSON.stringify({ id: 1, method: 'Page.enable', params: {} })) - - await new Promise((r) => setTimeout(r, 10)) - await proxy.stop() - - resolveCommand!({}) - client.close() - }) }) diff --git a/src/main/browser/guest-cdp-command.test.ts b/src/main/browser/guest-cdp-command.test.ts new file mode 100644 index 00000000000..3ad71959681 --- /dev/null +++ b/src/main/browser/guest-cdp-command.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest' +import { BrowserError } from './browser-error' +import { sendGuestCdpCommand } from './guest-cdp-command' + +function makeGuest(state: { crashed?: boolean; destroyed?: boolean } = {}) { + const sendCommand = vi.fn(async () => ({ ok: true })) + const guest = { + isDestroyed: vi.fn(() => state.destroyed ?? false), + isCrashed: vi.fn(() => { + if (state.destroyed) { + throw new Error('Object has been destroyed') + } + return state.crashed ?? false + }), + debugger: { sendCommand } + } + return { guest, sendCommand } +} + +describe('sendGuestCdpCommand', () => { + it.each(['Emulation.setDeviceMetricsOverride', 'Emulation.setVisibleSize'])( + 'refuses %s while the renderer is gone instead of letting Chromium crash the app', + async (method) => { + const { guest, sendCommand } = makeGuest({ crashed: true }) + const sent = sendGuestCdpCommand(guest, method, { width: 375, height: 667 }) + await expect(sent).rejects.toBeInstanceOf(BrowserError) + await expect(sent).rejects.toMatchObject({ code: 'browser_cdp_error' }) + expect(sendCommand).not.toHaveBeenCalled() + } + ) + + it('refuses a resize on a destroyed guest without asking it whether it crashed', async () => { + const { guest, sendCommand } = makeGuest({ destroyed: true }) + await expect( + sendGuestCdpCommand(guest, 'Emulation.setDeviceMetricsOverride', { width: 1, height: 1 }) + ).rejects.toBeInstanceOf(BrowserError) + expect(sendCommand).not.toHaveBeenCalled() + }) + + it('still sends commands that do not resize the view to a crashed guest', async () => { + const { guest, sendCommand } = makeGuest({ crashed: true }) + await expect( + sendGuestCdpCommand(guest, 'Emulation.setUserAgentOverride', { userAgent: 'x' }) + ).resolves.toEqual({ ok: true }) + expect(sendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', { userAgent: 'x' }) + }) + + it('sends a resize to a live guest, and passes a session id only when one is given', async () => { + const { guest, sendCommand } = makeGuest() + await sendGuestCdpCommand(guest, 'Emulation.setVisibleSize', { width: 2, height: 3 }) + await sendGuestCdpCommand(guest, 'DOM.enable', {}, 'iframe-session') + expect(sendCommand.mock.calls).toEqual([ + ['Emulation.setVisibleSize', { width: 2, height: 3 }], + ['DOM.enable', {}, 'iframe-session'] + ]) + }) +}) diff --git a/src/main/browser/guest-cdp-command.ts b/src/main/browser/guest-cdp-command.ts new file mode 100644 index 00000000000..ed7735df2d8 --- /dev/null +++ b/src/main/browser/guest-cdp-command.ts @@ -0,0 +1,42 @@ +import type { WebContents } from 'electron' +import { BrowserError } from './browser-error' + +// Why: Chromium resizes the page's view for these without checking the view still exists +// (WebContentsImpl::SetDeviceEmulationSize). A crashed renderer takes its view with it until the +// reload builds a new one, so one of these sent in that gap segfaults Orca's main process. +const VIEW_RESIZING_CDP_METHODS: ReadonlySet = new Set([ + 'Emulation.setDeviceMetricsOverride', + 'Emulation.setVisibleSize' +]) + +type GuestCdpTarget = Pick & { + debugger: Pick +} + +/** + * The gate for guest CDP commands: every viewport writer and every sender that forwards a caller's + * method (agent bridge, CDP proxy) goes through here, so none can hand Chromium a command that + * crashes the app while the guest's renderer is dead. + */ +export function sendGuestCdpCommand( + guest: GuestCdpTarget, + method: string, + params?: Record, + sessionId?: string +): Promise { + // Why same task as the send: isCrashed() flips in the same Chromium task that drops the view, + // so checking right before sendCommand leaves no gap for the renderer to die in between. + if (VIEW_RESIZING_CDP_METHODS.has(method) && (guest.isDestroyed() || guest.isCrashed())) { + return Promise.reject( + new BrowserError( + 'browser_cdp_error', + 'The page crashed; its viewport can be changed again once it reloads.' + ) + ) + } + return Promise.resolve( + sessionId === undefined + ? guest.debugger.sendCommand(method, params) + : guest.debugger.sendCommand(method, params, sessionId) + ) +} diff --git a/src/main/browser/offscreen-browser-backend-lifecycle.test.ts b/src/main/browser/offscreen-browser-backend-lifecycle.test.ts index a3758d70516..381489963a3 100644 --- a/src/main/browser/offscreen-browser-backend-lifecycle.test.ts +++ b/src/main/browser/offscreen-browser-backend-lifecycle.test.ts @@ -90,18 +90,16 @@ describe('OffscreenBrowserBackend lifecycle', () => { beforeEach(() => { mocks.windows.length = 0 mocks.finishLoads = true - mocks.BrowserWindow.mockImplementation( - function BrowserWindowMock(this: { - webContents: MockWebContents - isDestroyed: () => boolean - destroy: () => void - }) { - const window = new MockBrowserWindow() - this.webContents = window.webContents - this.isDestroyed = window.isDestroyed.bind(window) - this.destroy = window.destroy.bind(window) - } - ) + mocks.BrowserWindow.mockImplementation(function BrowserWindowMock(this: { + webContents: MockWebContents + isDestroyed: () => boolean + destroy: () => void + }) { + const window = new MockBrowserWindow() + this.webContents = window.webContents + this.isDestroyed = window.isDestroyed.bind(window) + this.destroy = window.destroy.bind(window) + }) }) it('settles a pending load and removes its waiters when the page is destroyed', async () => { diff --git a/src/main/claude-accounts/claude-account-service-api-parity.test.ts b/src/main/claude-accounts/claude-account-service-api-parity.test.ts index 206ccbd82af..09ec7813ffe 100644 --- a/src/main/claude-accounts/claude-account-service-api-parity.test.ts +++ b/src/main/claude-accounts/claude-account-service-api-parity.test.ts @@ -1,28 +1,10 @@ import { describe, expect, it, vi } from 'vitest' import type { ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' import { ClaudeAccountService } from './service' -import type { ClaudeAccountAddTarget, ClaudeAccountImportOptions } from './service' +import type { ClaudeAccountAddTarget } from './service' vi.mock('electron', () => ({ app: { getPath: () => '/tmp/orca-claude-api-parity' } })) -type PublicClaudeAccountService = { - listAccounts(): ClaudeRateLimitAccountsState - addAccount(target?: ClaudeAccountAddTarget): Promise - addAccountFromConfigDir( - configDir: string, - options?: ClaudeAccountImportOptions - ): Promise - reauthenticateAccount(accountId: string): Promise - removeAccount(accountId: string): Promise - selectAccount(accountId: string | null): Promise - selectAccountForTarget( - accountId: string | null, - target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null } - ): Promise - cancelPendingLogin(): boolean - getRuntimeConfigDir(target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): string -} - function createService(): ClaudeAccountService { const settings = { claudeManagedAccounts: [], @@ -41,16 +23,7 @@ function deferred(): { promise: Promise; resolve: () => void } { return { promise: new Promise((done) => (resolve = done)), resolve } } -describe('ClaudeAccountService API parity', () => { - it('keeps the exact runtime export and assignable public surface', async () => { - const runtimeExports = await import('./service') - const service: PublicClaudeAccountService = createService() - - expect(Object.keys(runtimeExports)).toEqual(['ClaudeAccountService']) - expect(service.cancelPendingLogin()).toBe(false) - expect(service.getRuntimeConfigDir()).toBe('/tmp/claude') - }) - +describe('ClaudeAccountService mutation serialization', () => { it('serializes mutations within one service', async () => { const service = createService() const first = deferred() diff --git a/src/main/claude-accounts/keychain.ts b/src/main/claude-accounts/keychain.ts index eca0e9af44d..aa4dcfa64db 100644 --- a/src/main/claude-accounts/keychain.ts +++ b/src/main/claude-accounts/keychain.ts @@ -1,18 +1,15 @@ -import { execFile } from 'node:child_process' import { createHash } from 'node:crypto' import { lstatSync, realpathSync } from 'node:fs' import { userInfo } from 'node:os' import { basename, dirname, join } from 'node:path' +import { + deleteKeychainPassword, + readKeychainPassword, + writeKeychainPassword +} from '../macos-keychain/generic-password' const ACTIVE_CLAUDE_SERVICE = 'Claude Code-credentials' const ORCA_CLAUDE_SERVICE = 'Orca Claude Code Managed Credentials' -const KEYCHAIN_COMMAND_TIMEOUT_MS = 3_000 - -type SecurityCommandResult = { - stdout: string - stderr: string -} - export async function readActiveClaudeKeychainCredentials( configDir?: string ): Promise { @@ -183,136 +180,3 @@ function getActiveClaudeServices(configDir?: string): string[] { const scoped = claudeConfigDirKeychainAliases(configDir).map((dir) => getActiveClaudeService(dir)) return [...new Set([...scoped, ACTIVE_CLAUDE_SERVICE])] } - -async function readKeychainPassword(service: string, account: string): Promise { - if (process.platform !== 'darwin') { - return null - } - try { - const { stdout } = await execSecurityCommand([ - 'find-generic-password', - '-s', - service, - '-a', - account, - '-w' - ]) - if (stdout.trim()) { - return stdout.trim() - } - throw new Error(`Could not read macOS Keychain item ${service}/${account}.`) - } catch (error) { - if (isKeychainNotFoundError(error)) { - return null - } - throw error - } -} - -async function writeKeychainPassword( - service: string, - account: string, - contents: string -): Promise { - if (process.platform !== 'darwin') { - return - } - await execSecurity(['add-generic-password', '-U', '-s', service, '-a', account, '-w', contents]) -} - -async function deleteKeychainPassword( - service: string, - account: string, - options?: { failOnAccessError?: boolean } -): Promise { - if (process.platform !== 'darwin') { - return - } - await execSecurity(['delete-generic-password', '-s', service, '-a', account], { - ignoreNotFound: true, - ignoreFailure: !options?.failOnAccessError - }) -} - -function execSecurity( - args: string[], - options?: { ignoreFailure?: boolean; ignoreNotFound?: boolean } -): Promise { - return execSecurityCommand(args).then(undefined, (error: unknown) => { - if (options?.ignoreNotFound && isKeychainNotFoundError(error)) { - return - } - if (!options?.ignoreFailure) { - throw error - } - }) -} - -function isKeychainNotFoundError(error: unknown): boolean { - const code = - error && typeof error === 'object' && 'code' in error - ? (error as { code?: unknown }).code - : undefined - const message = - error && typeof error === 'object' - ? `${String((error as { stderr?: unknown }).stderr ?? '')} ${String( - (error as { message?: unknown }).message ?? '' - )}`.toLowerCase() - : String(error).toLowerCase() - return code === 44 || message.includes('could not be found') || message.includes('not be found') -} - -function execSecurityCommand(args: string[]): Promise { - return new Promise((resolve, reject) => { - let settled = false - let child: ReturnType | undefined - const timer = setTimeout(() => { - if (settled) { - return - } - settled = true - child?.kill() - reject( - Object.assign(new Error(`security timed out after ${KEYCHAIN_COMMAND_TIMEOUT_MS}ms`), { - code: 'ETIMEDOUT', - stderr: '' - }) - ) - }, KEYCHAIN_COMMAND_TIMEOUT_MS) - - const settle = (callback: () => void): void => { - if (settled) { - return - } - settled = true - clearTimeout(timer) - callback() - } - - // Why: Node's execFile timeout only signals the `security` process; a - // stuck callback would otherwise leave auth/keychain operations pending. - try { - child = execFile( - 'security', - args, - { timeout: KEYCHAIN_COMMAND_TIMEOUT_MS }, - (error, stdout, stderr) => { - if (error) { - settle(() => - reject( - Object.assign(error, { - stdout: String(stdout), - stderr: String(stderr) - }) - ) - ) - return - } - settle(() => resolve({ stdout: String(stdout), stderr: String(stderr) })) - } - ) - } catch (error) { - settle(() => reject(error)) - } - }) -} diff --git a/src/main/claude-usage/store.ts b/src/main/claude-usage/store.ts index f5fd8ca3ce3..686b32ab827 100644 --- a/src/main/claude-usage/store.ts +++ b/src/main/claude-usage/store.ts @@ -1,3 +1,4 @@ +import { claudeTokenSessions } from '../usage/agent-token-usage' import { app } from 'electron' import { join } from 'node:path' import type { @@ -79,6 +80,10 @@ export class ClaudeUsageStore extends UsageProviderStoreLifecycle< > { constructor(store: Pick) { super(store, { + tokenUsage: { + provider: 'claude', + selectSessions: (state) => claudeTokenSessions(state.sessions) + }, logTag: '[claude-usage]', resolveCacheFile: getClaudeUsageFile, createDefaultState: getDefaultState, diff --git a/src/main/claude/__fixtures__/claude-adapter-capture-early-steer.jsonl b/src/main/claude/__fixtures__/claude-adapter-capture-early-steer.jsonl new file mode 100644 index 00000000000..bff60192d74 --- /dev/null +++ b/src/main/claude/__fixtures__/claude-adapter-capture-early-steer.jsonl @@ -0,0 +1,68 @@ +{"at": 0, "kind": "meta", "scenario": "early-steer", "providerSessionId": "00000000-0000-4000-8000-00000000c1a0", "note": "Recorded through Orca adapter vs Claude CLI 2.1.280; scrubbed: session id, home/tmp paths."} +{"at": 219, "kind": "frame", "frame": {"type": "system", "subtype": "hook_started", "hook_id": "e13780eb-2f2b-45e5-a9fd-e7868ef6d9e2", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "uuid": "308e42c2-3222-452b-bb5f-f8e41cf485a4", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 229, "kind": "frame", "frame": {"type": "system", "subtype": "hook_response", "hook_id": "e13780eb-2f2b-45e5-a9fd-e7868ef6d9e2", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "output": "\n", "stdout": "\n", "stderr": "", "exit_code": 0, "outcome": "success", "uuid": "8c37d862-0ba3-4e14-a746-68a29d137384", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 242, "kind": "dispatch", "clientMessageId": "client-A", "sentUuid": "c64cdf14-1349-4be1-b0a4-5613890d57bd", "text": "Use the Bash tool to run `sleep 8` two separate times, one call at a time, waiting for each. Then reply exactly: FIRST DONE", "outcome": {"state": "admitted"}} +{"at": 244, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "running", "uuid": "052ca984-de90-4289-ac97-587d361f35dc", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 244, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "c64cdf14-1349-4be1-b0a4-5613890d57bd", "state": "queued", "uuid": "e32cf6db-6c39-49ad-87a6-ad8f392e00b1", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 244, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "c64cdf14-1349-4be1-b0a4-5613890d57bd", "state": "started", "uuid": "0c7a74e6-8f5e-43c4-9cf0-af6e3fac6e0f", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 259, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/tmp/scrubbed-cwd", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": ["Task", "AskUserQuestion", "Bash", "CronCreate", "CronDelete", "CronList", "DesignSync", "Edit", "EnterPlanMode", "EnterWorktree", "ExitPlanMode", "ExitWorktree", "ListAgents", "NotebookEdit", "Read", "ReportFindings", "ScheduleWakeup", "SendMessage", "Skill", "TaskCreate", "TaskGet", "TaskList", "TaskStop", "TaskUpdate", "ToolSearch", "WebFetch", "WebSearch", "Workflow", "Write"], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": ["deep-research", "design", "design-sync", "dataviz", "update-config", "verify", "debug", "code-review", "simplify", "batch", "fewer-permission-prompts", "doctor", "loop", "claude-api", "workflow-authoring", "run", "run-skill-generator", "agents", "auto-mode-setup", "autocompact", "clear", "color", "compact", "config", "output-style", "context", "effort", "fast", "heapdump", "init", "mcp", "model", "__remote-workflow", "workflow-launch-exec", "reload-plugins", "reload-skills", "rename", "security-review", "usage", "insights", "recap", "goal", "design-consent", "design-revoke", "list-agents", "team-onboarding"], "terminal_slash_commands": ["doctor", "color", "reload-plugins"], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": ["claude", "Explore", "general-purpose", "Plan", "statusline-setup"], "skills": ["deep-research", "design", "design-sync", "dataviz", "update-config", "verify", "debug", "code-review", "simplify", "batch", "fewer-permission-prompts", "doctor", "loop", "claude-api", "workflow-authoring", "run", "run-skill-generator"], "plugins": [{"name": "telemetry", "path": "builtin", "source": "telemetry@builtin"}], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "ff6bb8ec-afcb-4bb8-88a8-d75625377ab9", "memory_paths": {"auto": "/Users/user/scrubbed"}, "messaging_socket_path": "/tmp/scrubbed.sock", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 261, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "9900a74e-78ff-48a6-b005-8b748a8f7a1d"}} +{"at": 492, "kind": "dispatch", "clientMessageId": "client-B", "sentUuid": "3a4cf7ae-5121-45d4-8c99-76625c847c4e", "text": "Also say the word banana at the end of your reply.", "outcome": {"state": "admitted"}} +{"at": 493, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "3a4cf7ae-5121-45d4-8c99-76625c847c4e", "state": "queued", "uuid": "2244a4b7-a832-418a-b6e9-be4657618f60", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 1342, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Use the Bash tool to run `sleep 8` two separate times, one call at a time, waiting for each. Then reply exactly: FIRST DONE"}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "c64cdf14-1349-4be1-b0a4-5613890d57bd", "timestamp": "2026-09-28T12:50:50.211Z", "isReplay": true}} +{"at": 1342, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_start", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsEi9RgiS61CeGuiyaP", "type": "message", "role": "assistant", "content": [], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 5242, "cache_read_input_tokens": 11514, "cache_creation": {"ephemeral_5m_input_tokens": 5242, "ephemeral_1h_input_tokens": 0}, "output_tokens": 16, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "25b0f48f-8de3-4e58-a53f-76cedc10c4e8", "ttft_ms": 1075, "user_message_uuid": "c64cdf14-1349-4be1-b0a4-5613890d57bd", "user_message_uuids": ["c64cdf14-1349-4be1-b0a4-5613890d57bd"]}} +{"at": 1342, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "tool_use", "id": "toolu_01RL5fbYfvXaRva9PBe2C7Qn", "name": "Bash", "input": {}, "caller": {"type": "direct"}}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "54809bd1-bb5a-4fe7-9a04-1dc60e5a7163"}} +{"at": 1342, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "02a681bb-01c9-4dd3-b890-040e17bf4132"}} +{"at": 2307, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "{\"com"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "0a1147d0-c168-405a-a4e4-79ad5266d3c2"}} +{"at": 2307, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "mand\": \"s"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "1eabfcb6-b0c4-403a-9804-c55b013391ae"}} +{"at": 2307, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "lee"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "20f34e40-6392-4e4a-9991-ad35ad6b1aaa"}} +{"at": 2309, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "p 8\""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "ec8ed822-0e8f-46c1-b7a0-c8ab4467baa9"}} +{"at": 2330, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ", \"descri"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "87881476-c659-4e82-945d-be15eaa45501"}} +{"at": 2332, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "ption\": \""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "e2502f3a-3cca-4741-9df5-dade6f4e4abb"}} +{"at": 2332, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "Sl"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "dc28f0f9-22f5-47fc-a374-1369c6fda079"}} +{"at": 2335, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "eep f"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "85345f83-b80d-4142-aa66-66b8640dca3b"}} +{"at": 2340, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "or 8 "}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "5ce61d4a-abd9-47bd-9c7b-e01a6d566387"}} +{"at": 2341, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "seconds\"}"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "21de6d37-199b-44d7-bfaa-4af1471a7235"}} +{"at": 2343, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsEi9RgiS61CeGuiyaP", "type": "message", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_01RL5fbYfvXaRva9PBe2C7Qn", "name": "Bash", "input": {"command": "sleep 8", "description": "Sleep for 8 seconds"}, "caller": {"type": "direct"}}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 5242, "cache_read_input_tokens": 11514, "cache_creation": {"ephemeral_5m_input_tokens": 5242, "ephemeral_1h_input_tokens": 0}, "output_tokens": 16, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "3f3c0fff-9ca6-49f9-8b73-36e569af5a38", "timestamp": "2026-09-28T12:50:52.296Z", "request_id": "req_011CfVsEhBPEiAFbbAE18svX", "user_message_uuid": "c64cdf14-1349-4be1-b0a4-5613890d57bd", "user_message_uuids": ["c64cdf14-1349-4be1-b0a4-5613890d57bd"]}} +{"at": 2347, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_stop", "index": 0}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "fa196eb6-011b-4fa7-831d-4ffe2582a3c7"}} +{"at": 2375, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_delta", "delta": {"stop_reason": "tool_use", "stop_sequence": null, "stop_details": null, "container": null}, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 5242, "cache_read_input_tokens": 11514, "output_tokens": 82, "output_tokens_details": {"thinking_tokens": 0}}, "context_management": {"applied_edits": []}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "7b300bd2-eaa1-4cb7-a557-6908cbb6f2a6"}} +{"at": 2380, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_stop"}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "c1573618-e6fb-4f22-8939-e7959440f18a"}} +{"at": 5482, "kind": "frame", "frame": {"type": "system", "subtype": "task_started", "task_id": "bys65yjga", "tool_use_id": "toolu_01RL5fbYfvXaRva9PBe2C7Qn", "description": "Sleep for 8 seconds", "is_backgrounded": false, "task_type": "local_bash", "uuid": "6b741fb3-6edc-4c9e-a69a-81fc87ef1502", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 10493, "kind": "frame", "frame": {"type": "system", "subtype": "task_notification", "task_id": "bys65yjga", "tool_use_id": "toolu_01RL5fbYfvXaRva9PBe2C7Qn", "status": "completed", "output_file": "", "summary": "Sleep for 8 seconds", "uuid": "9b0edc57-cd0c-4f60-a72c-e359e1a7fabc", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 10496, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_01RL5fbYfvXaRva9PBe2C7Qn", "type": "tool_result", "content": "(Bash completed with no output)", "is_error": false}]}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "2f7a468f-55f1-4434-8f68-70a077f74f6e", "timestamp": "2026-09-28T12:51:00.450Z", "tool_use_result": {"stdout": "", "stderr": "", "interrupted": false, "isImage": false, "noOutputExpected": false}}} +{"at": 10499, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Also say the word banana at the end of your reply."}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "3a4cf7ae-5121-45d4-8c99-76625c847c4e", "timestamp": "2026-09-28T12:50:50.448Z", "isReplay": true}} +{"at": 10499, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "3a4cf7ae-5121-45d4-8c99-76625c847c4e", "state": "started", "uuid": "aca666d1-c3b3-451e-81d7-5aadfa246b42", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 10499, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "ee6f2da9-2514-4086-8bc4-1a5c01e5207b"}} +{"at": 11452, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_start", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsFTNDFHDiwBZc8cKWk", "type": "message", "role": "assistant", "content": [], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 217, "cache_read_input_tokens": 16756, "cache_creation": {"ephemeral_5m_input_tokens": 217, "ephemeral_1h_input_tokens": 0}, "output_tokens": 16, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "571285ce-eda5-44b5-818c-db549ee9aef9", "ttft_ms": 949}} +{"at": 11452, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "tool_use", "id": "toolu_01HG7iwC2HgvoxndbuytmJCM", "name": "Bash", "input": {}, "caller": {"type": "direct"}}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "6d6d5ecd-0889-4592-b514-d72c0a964cc9"}} +{"at": 11453, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "58339280-14b3-4f61-bd2b-726418d4b324"}} +{"at": 11853, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "{\"comma"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "37c5f88b-3ef0-471c-89b1-a8dc6f3f61ad"}} +{"at": 11866, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "nd\": \"s"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "f31aa8f4-903d-4a7d-aeff-41c16f4b487d"}} +{"at": 11866, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "leep 8\""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "b1b59a9c-cec2-4398-9e8e-e49f16ad712d"}} +{"at": 11882, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ", \"desc"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "25d130db-a0f9-43d2-b639-6a9064f1e00d"}} +{"at": 11883, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "ription"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "e0b776a6-7cb3-4512-bacc-07fc84a1737e"}} +{"at": 11883, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "\": \"Sle"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "54ec71a5-84ad-4f45-849f-cdbeae44d781"}} +{"at": 11883, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "ep for "}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "667d1090-ceca-4c88-9864-447d6c95784e"}} +{"at": 11883, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "8 secon"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "2047e8e3-feb9-484d-8a1a-c19f0afebd59"}} +{"at": 11883, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "ds\"}"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "7d4f9da1-d974-4acf-84a5-d1f22068beec"}} +{"at": 11884, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsFTNDFHDiwBZc8cKWk", "type": "message", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_01HG7iwC2HgvoxndbuytmJCM", "name": "Bash", "input": {"command": "sleep 8", "description": "Sleep for 8 seconds"}, "caller": {"type": "direct"}}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 217, "cache_read_input_tokens": 16756, "cache_creation": {"ephemeral_5m_input_tokens": 217, "ephemeral_1h_input_tokens": 0}, "output_tokens": 16, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "6c4c5b57-e219-4c0d-a90f-26cbae8a33df", "timestamp": "2026-09-28T12:51:01.838Z", "request_id": "req_011CfVsFSwuBKQ87EU42vew5"}} +{"at": 11885, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_stop", "index": 0}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "df9289e5-af2a-4b85-8dc8-47df67725383"}} +{"at": 11885, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_delta", "delta": {"stop_reason": "tool_use", "stop_sequence": null, "stop_details": null, "container": null}, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 217, "cache_read_input_tokens": 16756, "output_tokens": 82, "output_tokens_details": {"thinking_tokens": 0}}, "context_management": {"applied_edits": []}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "7ac75599-4149-466f-ab0b-4d4ef74a9c86"}} +{"at": 11885, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_stop"}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "ea092344-1e27-4b3f-9145-e388a268a4a1"}} +{"at": 14890, "kind": "frame", "frame": {"type": "system", "subtype": "task_started", "task_id": "bu917pf4q", "tool_use_id": "toolu_01HG7iwC2HgvoxndbuytmJCM", "description": "Sleep for 8 seconds", "is_backgrounded": false, "task_type": "local_bash", "uuid": "408f8301-dbd6-43cb-8f09-7f7d5f4325da", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 19903, "kind": "frame", "frame": {"type": "system", "subtype": "task_notification", "task_id": "bu917pf4q", "tool_use_id": "toolu_01HG7iwC2HgvoxndbuytmJCM", "status": "completed", "output_file": "", "summary": "Sleep for 8 seconds", "uuid": "55ee40ae-c83e-40de-ae7c-fb0659950b39", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 19906, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_01HG7iwC2HgvoxndbuytmJCM", "type": "tool_result", "content": "(Bash completed with no output)", "is_error": false}]}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "ee783948-1a20-4797-b0ef-b191babd6a68", "timestamp": "2026-09-28T12:51:09.859Z", "tool_use_result": {"stdout": "", "stderr": "", "interrupted": false, "isImage": false, "noOutputExpected": false}}} +{"at": 19908, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "dec2fc2c-f27f-44c2-bbda-01b112f803d6"}} +{"at": 21540, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_start", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsGBuJTPMNVKzDFTw9k", "type": "message", "role": "assistant", "content": [], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 121, "cache_read_input_tokens": 16973, "cache_creation": {"ephemeral_5m_input_tokens": 121, "ephemeral_1h_input_tokens": 0}, "output_tokens": 11, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "14cb8df8-de6e-43e8-a0be-46c883c88501", "ttft_ms": 1627}} +{"at": 21540, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "f346ec9e-fe33-4c2e-a611-b2da26f43f53"}} +{"at": 21540, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "FIRST DONE ban"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "6440a3c0-de10-4e9e-a898-7ef0ec79673c"}} +{"at": 21540, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "ana"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "7096cb35-c640-4698-b755-8aad289d6cef"}} +{"at": 21556, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsGBuJTPMNVKzDFTw9k", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "FIRST DONE banana"}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 121, "cache_read_input_tokens": 16973, "cache_creation": {"ephemeral_5m_input_tokens": 121, "ephemeral_1h_input_tokens": 0}, "output_tokens": 11, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "5c9408ee-fcd2-46e8-aca0-1e2e8563420c", "timestamp": "2026-09-28T12:51:11.495Z", "request_id": "req_011CfVsG9F4VXdbez6Yd6wis"}} +{"at": 21556, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_stop", "index": 0}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "12aa2a40-1da6-45bc-bd9e-a1f999c144d2"}} +{"at": 21556, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": null, "stop_details": null, "container": null}, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 121, "cache_read_input_tokens": 16973, "output_tokens": 11, "output_tokens_details": {"thinking_tokens": 0}}, "context_management": {"applied_edits": []}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "fee3cade-9b96-4f0e-8657-b23a6c6f83ca"}} +{"at": 21557, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_stop"}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "b39aedff-1723-46d2-8fbf-d62b2fe8f2ac"}} +{"at": 21559, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "3a4cf7ae-5121-45d4-8c99-76625c847c4e", "state": "completed", "uuid": "5b6a0e7a-361d-42ae-983a-5ecc00d6ff8c", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 21562, "kind": "frame", "frame": {"duration_api_ms": 6682, "stop_reason": "end_turn", "session_id": "00000000-0000-4000-8000-00000000c1a0", "total_cost_usd": 0.045744599999999996, "usage": {"input_tokens": 6, "cache_creation_input_tokens": 5580, "cache_read_input_tokens": 45243, "output_tokens": 175, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 5580}, "inference_geo": "not_available", "iterations": [], "speed": "standard"}, "modelUsage": {"claude-opus-5-5[1m]": {"inputTokens": 1219, "outputTokens": 196, "cacheReadInputTokens": 45243, "cacheCreationInputTokens": 5580, "webSearchRequests": 0, "costUSD": 0.045744599999999996, "contextWindow": 1000000, "maxOutputTokens": 128000, "thinkingTokens": 0, "canonicalModel": "claude-opus-5-5", "provider": "firstParty", "costBasis": "list"}}, "permission_denials": [], "terminal_reason": "completed", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": false, "num_turns": 3, "subtype": "success", "api_error_status": null, "result": "FIRST DONE banana", "ttft_ms": 2096, "type": "result", "duration_ms": 21314, "uuid": "02543cfd-4942-47f9-82d7-0bd79a2ed93d", "ttft_stream_ms": 1096, "time_to_request_ms": 21, "first_content_frame_ms": 1096, "user_message_uuid": "c64cdf14-1349-4be1-b0a4-5613890d57bd", "user_message_uuids": ["c64cdf14-1349-4be1-b0a4-5613890d57bd", "3a4cf7ae-5121-45d4-8c99-76625c847c4e"], "request_sent_wall_ms": 1790599850221, "queued_turn_count": 0, "result_index": 0}} +{"at": 21563, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "c64cdf14-1349-4be1-b0a4-5613890d57bd", "state": "completed", "uuid": "ef4b1bcb-e9cf-42f0-a0f9-b25a59a56efe", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 21563, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "idle", "uuid": "fce05c2c-6c43-4c7d-a347-ec3e378e3afc", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 24574, "kind": "end", "results": 1} diff --git a/src/main/claude/__fixtures__/claude-adapter-capture-fold.jsonl b/src/main/claude/__fixtures__/claude-adapter-capture-fold.jsonl new file mode 100644 index 00000000000..5bf4b2f863c --- /dev/null +++ b/src/main/claude/__fixtures__/claude-adapter-capture-fold.jsonl @@ -0,0 +1,65 @@ +{"at": 1, "kind": "meta", "scenario": "fold", "providerSessionId": "00000000-0000-4000-8000-00000000c1a0", "note": "Recorded through Orca adapter vs Claude CLI 2.1.280; scrubbed: session id, home/tmp paths."} +{"at": 242, "kind": "frame", "frame": {"type": "system", "subtype": "hook_started", "hook_id": "e8d2b9c8-d0d0-442f-9b86-580619257967", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "uuid": "fedf8819-07f0-41f5-bc48-24a71fa9940d", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 254, "kind": "frame", "frame": {"type": "system", "subtype": "hook_response", "hook_id": "e8d2b9c8-d0d0-442f-9b86-580619257967", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "output": "\n", "stdout": "\n", "stderr": "", "exit_code": 0, "outcome": "success", "uuid": "fed65947-fa85-4c2d-9bd6-1c01159922b4", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 269, "kind": "dispatch", "clientMessageId": "client-A", "sentUuid": "c1a5941b-1bd4-4729-8289-5a25b22ff5b2", "text": "Use the Bash tool to run `sleep 8` two separate times, one call at a time, waiting for each. Then reply exactly: FIRST DONE", "outcome": {"state": "admitted"}} +{"at": 272, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "running", "uuid": "c36c2f2e-5c24-426d-819b-ea5f89862dae", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 272, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "c1a5941b-1bd4-4729-8289-5a25b22ff5b2", "state": "queued", "uuid": "65530cf1-9a29-4668-9a9f-91616c315809", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 272, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "c1a5941b-1bd4-4729-8289-5a25b22ff5b2", "state": "started", "uuid": "69d4df37-8379-4fce-bbf7-2a3a6854fbf2", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 287, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/tmp/scrubbed-cwd", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": ["Task", "AskUserQuestion", "Bash", "CronCreate", "CronDelete", "CronList", "DesignSync", "Edit", "EnterPlanMode", "EnterWorktree", "ExitPlanMode", "ExitWorktree", "ListAgents", "NotebookEdit", "Read", "ReportFindings", "ScheduleWakeup", "SendMessage", "Skill", "TaskCreate", "TaskGet", "TaskList", "TaskStop", "TaskUpdate", "ToolSearch", "WebFetch", "WebSearch", "Workflow", "Write"], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": ["deep-research", "design", "design-sync", "dataviz", "update-config", "verify", "debug", "code-review", "simplify", "batch", "fewer-permission-prompts", "doctor", "loop", "claude-api", "workflow-authoring", "run", "run-skill-generator", "agents", "auto-mode-setup", "autocompact", "clear", "color", "compact", "config", "output-style", "context", "effort", "fast", "heapdump", "init", "mcp", "model", "__remote-workflow", "workflow-launch-exec", "reload-plugins", "reload-skills", "rename", "security-review", "usage", "insights", "recap", "goal", "design-consent", "design-revoke", "list-agents", "team-onboarding"], "terminal_slash_commands": ["doctor", "color", "reload-plugins"], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": ["claude", "Explore", "general-purpose", "Plan", "statusline-setup"], "skills": ["deep-research", "design", "design-sync", "dataviz", "update-config", "verify", "debug", "code-review", "simplify", "batch", "fewer-permission-prompts", "doctor", "loop", "claude-api", "workflow-authoring", "run", "run-skill-generator"], "plugins": [{"name": "telemetry", "path": "builtin", "source": "telemetry@builtin"}], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "facd6d73-8d9e-4886-a6ab-f0873938128e", "memory_paths": {"auto": "/Users/user/scrubbed"}, "messaging_socket_path": "/tmp/scrubbed.sock", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 289, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "d34f89ba-f2ef-4cbf-99a5-7d4d85c8d02a"}} +{"at": 1527, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Use the Bash tool to run `sleep 8` two separate times, one call at a time, waiting for each. Then reply exactly: FIRST DONE"}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "c1a5941b-1bd4-4729-8289-5a25b22ff5b2", "timestamp": "2026-09-28T12:50:24.512Z", "isReplay": true}} +{"at": 1528, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_start", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsCqF1QKvUNMHjZNCWW", "type": "message", "role": "assistant", "content": [], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 16754, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 16754, "ephemeral_1h_input_tokens": 0}, "output_tokens": 16, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "ac67ce6c-da49-4c1d-bf6a-70e2474ab35f", "ttft_ms": 1230, "user_message_uuid": "c1a5941b-1bd4-4729-8289-5a25b22ff5b2", "user_message_uuids": ["c1a5941b-1bd4-4729-8289-5a25b22ff5b2"]}} +{"at": 1528, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "tool_use", "id": "toolu_01GoyahVsaJSkqYZzb3ci9KQ", "name": "Bash", "input": {}, "caller": {"type": "direct"}}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "9e8e16c5-d002-4034-b955-3a8a00523ba4"}} +{"at": 1528, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "b21cf048-3dbe-4d71-9bb0-9c139be8fb12"}} +{"at": 2612, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "{\"comma"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "2aa7968c-7244-47f1-8e0c-e660514037ee"}} +{"at": 2613, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "nd\": \"s"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "588330dc-afca-4aa7-b90c-73df15fee7c0"}} +{"at": 2613, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "leep 8\""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "75c0ecc9-4a50-4d6f-9731-3fed7e81d265"}} +{"at": 2613, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ", \"desc"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "a773fa58-791c-414f-94f1-a4a04534bdd7"}} +{"at": 2613, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "ription"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "9de26ee7-6320-467a-b226-83ec143378d7"}} +{"at": 2613, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "\": \"Sle"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "e3871642-e9b2-4e35-890e-d8d3a7a81e1e"}} +{"at": 2613, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "ep 8 se"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "7c1891c7-d7b2-48df-a26e-a7265389c8c7"}} +{"at": 2613, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "conds\"}"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "9d9be75c-c078-4bda-8a3f-dea3967d5a06"}} +{"at": 2615, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsCqF1QKvUNMHjZNCWW", "type": "message", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_01GoyahVsaJSkqYZzb3ci9KQ", "name": "Bash", "input": {"command": "sleep 8", "description": "Sleep 8 seconds"}, "caller": {"type": "direct"}}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 16754, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 16754, "ephemeral_1h_input_tokens": 0}, "output_tokens": 16, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "0788da30-2441-4b32-803e-9ff172725739", "timestamp": "2026-09-28T12:50:26.842Z", "request_id": "req_011CfVsCppDCm7ZivTRsAYEu", "user_message_uuid": "c1a5941b-1bd4-4729-8289-5a25b22ff5b2", "user_message_uuids": ["c1a5941b-1bd4-4729-8289-5a25b22ff5b2"]}} +{"at": 2619, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_stop", "index": 0}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "40dac8c9-408f-4079-8d63-57e0f3b43ca1"}} +{"at": 2620, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_delta", "delta": {"stop_reason": "tool_use", "stop_sequence": null, "stop_details": null, "container": null}, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 16754, "cache_read_input_tokens": 0, "output_tokens": 81, "output_tokens_details": {"thinking_tokens": 0}}, "context_management": {"applied_edits": []}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "6d1c0ce1-f7ad-463a-ab3d-f7745bce63f9"}} +{"at": 2621, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_stop"}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "b79333a8-7abc-4a32-a31f-249b5fea5f10"}} +{"at": 3640, "kind": "dispatch", "clientMessageId": "client-B", "sentUuid": "7ee6ab25-eafb-4869-85fd-97d058045906", "text": "Also say the word banana at the end of your reply.", "outcome": {"state": "admitted"}} +{"at": 3640, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "7ee6ab25-eafb-4869-85fd-97d058045906", "state": "queued", "uuid": "6395adb0-b318-4284-a298-7b3d89d6b7d7", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 5732, "kind": "frame", "frame": {"type": "system", "subtype": "task_started", "task_id": "beu0xkswp", "tool_use_id": "toolu_01GoyahVsaJSkqYZzb3ci9KQ", "description": "Sleep 8 seconds", "is_backgrounded": false, "task_type": "local_bash", "uuid": "a35b1b34-5810-47dd-bc2c-a38d19077a74", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 10740, "kind": "frame", "frame": {"type": "system", "subtype": "task_notification", "task_id": "beu0xkswp", "tool_use_id": "toolu_01GoyahVsaJSkqYZzb3ci9KQ", "status": "completed", "output_file": "", "summary": "Sleep 8 seconds", "uuid": "3f757c35-2f6c-4b21-bdf4-f8b523809abb", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 10743, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_01GoyahVsaJSkqYZzb3ci9KQ", "type": "tool_result", "content": "(Bash completed with no output)", "is_error": false}]}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "abbb0dbf-e102-4c66-ba3c-7808d634f675", "timestamp": "2026-09-28T12:50:34.971Z", "tool_use_result": {"stdout": "", "stderr": "", "interrupted": false, "isImage": false, "noOutputExpected": false}}} +{"at": 10744, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Also say the word banana at the end of your reply."}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "7ee6ab25-eafb-4869-85fd-97d058045906", "timestamp": "2026-09-28T12:50:27.869Z", "isReplay": true}} +{"at": 10745, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "7ee6ab25-eafb-4869-85fd-97d058045906", "state": "started", "uuid": "30f94896-494d-4002-a1ce-680620e03d54", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 10745, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "bf42e496-1f7f-4095-8d94-19390bd59e50"}} +{"at": 11584, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_start", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsDaaVReUJ6r3tdEQhr", "type": "message", "role": "assistant", "content": [], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 216, "cache_read_input_tokens": 16754, "cache_creation": {"ephemeral_5m_input_tokens": 216, "ephemeral_1h_input_tokens": 0}, "output_tokens": 16, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "79159e21-d876-467f-abac-86bfe7bf2457", "ttft_ms": 836}} +{"at": 11584, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "tool_use", "id": "toolu_01MxJ1cYsSb1chARZ735hbRk", "name": "Bash", "input": {}, "caller": {"type": "direct"}}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "469a075b-ff35-42c8-b20e-fe4fd3ccf250"}} +{"at": 11585, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "1a0aeaf4-6b9e-441e-86ac-eac3c32204c4"}} +{"at": 12071, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "{\"comma"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "6bb65599-1e9a-40d6-a769-e2b119fee09a"}} +{"at": 12072, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "nd\": \"s"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "04387767-2ce4-42ec-b869-11406432effc"}} +{"at": 12072, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "leep 8\""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "ccc9d7c3-19a3-4691-a104-ae6b9c1b4dfa"}} +{"at": 12072, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ", \"desc"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "90f5078e-ff14-45a6-9377-257c4545bb5f"}} +{"at": 12072, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "ription"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "8f04f554-0875-450a-bc0b-89cb339c864b"}} +{"at": 12072, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "\": \"Sle"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "6610a44a-019f-4136-a214-e0fc8e06d67f"}} +{"at": 12073, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "ep 8 se"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "5caedf82-9c9e-4e13-ac26-d869ce84e863"}} +{"at": 12073, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "conds\"}"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "8151a90d-7bae-4805-b709-124001edee1d"}} +{"at": 12073, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsDaaVReUJ6r3tdEQhr", "type": "message", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_01MxJ1cYsSb1chARZ735hbRk", "name": "Bash", "input": {"command": "sleep 8", "description": "Sleep 8 seconds"}, "caller": {"type": "direct"}}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 216, "cache_read_input_tokens": 16754, "cache_creation": {"ephemeral_5m_input_tokens": 216, "ephemeral_1h_input_tokens": 0}, "output_tokens": 16, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "6aaa7086-2892-4c73-a6b6-caef6cf1a2f5", "timestamp": "2026-09-28T12:50:36.299Z", "request_id": "req_011CfVsDa8gykAqKH3jsZE4P"}} +{"at": 12074, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_stop", "index": 0}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "3a4aa6b1-647e-4228-94c5-decf6d567a2f"}} +{"at": 12074, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_delta", "delta": {"stop_reason": "tool_use", "stop_sequence": null, "stop_details": null, "container": null}, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 216, "cache_read_input_tokens": 16754, "output_tokens": 81, "output_tokens_details": {"thinking_tokens": 0}}, "context_management": {"applied_edits": []}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "a09a8560-21b5-410c-9337-1abfb30caff1"}} +{"at": 12074, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_stop"}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "90522211-133a-4a50-8ae9-21bdef5590d1"}} +{"at": 15093, "kind": "frame", "frame": {"type": "system", "subtype": "task_started", "task_id": "btowzyjp1", "tool_use_id": "toolu_01MxJ1cYsSb1chARZ735hbRk", "description": "Sleep 8 seconds", "is_backgrounded": false, "task_type": "local_bash", "uuid": "c5aa424b-840a-43a2-9586-94feff2f1b97", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 20107, "kind": "frame", "frame": {"type": "system", "subtype": "task_notification", "task_id": "btowzyjp1", "tool_use_id": "toolu_01MxJ1cYsSb1chARZ735hbRk", "status": "completed", "output_file": "", "summary": "Sleep 8 seconds", "uuid": "9ad2b377-9339-4fe9-a2aa-ee9acdb41d08", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 20109, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_01MxJ1cYsSb1chARZ735hbRk", "type": "tool_result", "content": "(Bash completed with no output)", "is_error": false}]}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "e3b1fa1e-6a3d-4156-a1fe-2146aeff2b1e", "timestamp": "2026-09-28T12:50:44.337Z", "tool_use_result": {"stdout": "", "stderr": "", "interrupted": false, "isImage": false, "noOutputExpected": false}}} +{"at": 20110, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "990bcabe-2776-4b87-9ba9-788d3fff81cf"}} +{"at": 21105, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_start", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsEGxMrQeN7Y4miT6gp", "type": "message", "role": "assistant", "content": [], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 120, "cache_read_input_tokens": 16970, "cache_creation": {"ephemeral_5m_input_tokens": 120, "ephemeral_1h_input_tokens": 0}, "output_tokens": 11, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "2d867ea9-894a-4dfa-a589-7a689402c6b2", "ttft_ms": 992}} +{"at": 21105, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "ed2beb08-68a1-4159-953c-7a0b85729f4d"}} +{"at": 21106, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "FIRST DONE ban"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "581dc001-4ef8-4f1b-a741-0534f316d810"}} +{"at": 21106, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "ana"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "99a38535-8448-4953-b98e-f0740fc8e13a"}} +{"at": 21117, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsEGxMrQeN7Y4miT6gp", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "FIRST DONE banana"}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 120, "cache_read_input_tokens": 16970, "cache_creation": {"ephemeral_5m_input_tokens": 120, "ephemeral_1h_input_tokens": 0}, "output_tokens": 11, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "64e7945b-f3a9-409b-a58d-923a9c4a1f6a", "timestamp": "2026-09-28T12:50:45.334Z", "request_id": "req_011CfVsEGYJNniXhLXGpV5vr"}} +{"at": 21117, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_stop", "index": 0}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "68da798f-ddd8-4af5-b012-ce4a10811bda"}} +{"at": 21117, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": null, "stop_details": null, "container": null}, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 120, "cache_read_input_tokens": 16970, "output_tokens": 11, "output_tokens_details": {"thinking_tokens": 0}}, "context_management": {"applied_edits": []}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "29e16ff5-ab3b-4406-843f-049ee48650cd"}} +{"at": 21117, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_stop"}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "5b7d6bae-6827-48c9-8fab-e43e55949198"}} +{"at": 21119, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "7ee6ab25-eafb-4869-85fd-97d058045906", "state": "completed", "uuid": "0e958675-6553-46b7-9d98-5b667248666d", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 21120, "kind": "frame", "frame": {"duration_api_ms": 6661, "stop_reason": "end_turn", "session_id": "00000000-0000-4000-8000-00000000c1a0", "total_cost_usd": 0.10095080000000002, "usage": {"input_tokens": 6, "cache_creation_input_tokens": 17090, "cache_read_input_tokens": 33724, "output_tokens": 173, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 17090}, "inference_geo": "not_available", "iterations": [], "speed": "standard"}, "modelUsage": {"claude-opus-5-5[1m]": {"inputTokens": 1219, "outputTokens": 194, "cacheReadInputTokens": 33724, "cacheCreationInputTokens": 17090, "webSearchRequests": 0, "costUSD": 0.10095080000000002, "contextWindow": 1000000, "maxOutputTokens": 128000, "thinkingTokens": 0, "canonicalModel": "claude-opus-5-5", "provider": "firstParty", "costBasis": "list"}}, "permission_denials": [], "terminal_reason": "completed", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": false, "num_turns": 3, "subtype": "success", "api_error_status": null, "result": "FIRST DONE banana", "ttft_ms": 2340, "type": "result", "duration_ms": 20846, "uuid": "408f97b8-b880-47f8-b778-f94e4e15cd92", "ttft_stream_ms": 1253, "time_to_request_ms": 24, "first_content_frame_ms": 1253, "user_message_uuid": "c1a5941b-1bd4-4729-8289-5a25b22ff5b2", "user_message_uuids": ["c1a5941b-1bd4-4729-8289-5a25b22ff5b2", "7ee6ab25-eafb-4869-85fd-97d058045906"], "request_sent_wall_ms": 1790599824526, "queued_turn_count": 0, "result_index": 0}} +{"at": 21121, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "c1a5941b-1bd4-4729-8289-5a25b22ff5b2", "state": "completed", "uuid": "3b91d2ee-409f-45f5-a7be-aa7a45fb6ac5", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 21121, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "idle", "uuid": "4de40f8a-4b6c-4333-b1e6-ff528d249af9", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 24243, "kind": "end", "results": 1} diff --git a/src/main/claude/__fixtures__/claude-adapter-capture-miss.jsonl b/src/main/claude/__fixtures__/claude-adapter-capture-miss.jsonl new file mode 100644 index 00000000000..211f6e0eb8d --- /dev/null +++ b/src/main/claude/__fixtures__/claude-adapter-capture-miss.jsonl @@ -0,0 +1,85 @@ +{"at": 1, "kind": "meta", "scenario": "miss", "providerSessionId": "00000000-0000-4000-8000-00000000c1a0", "note": "Recorded through Orca adapter vs Claude CLI 2.1.280; scrubbed: session id, home/tmp paths."} +{"at": 228, "kind": "frame", "frame": {"type": "system", "subtype": "hook_started", "hook_id": "06af4bab-bef3-426b-96b7-733316a3b251", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "uuid": "9eb2ab97-dce5-430e-9db3-e7d9aa0e7f8d", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 239, "kind": "frame", "frame": {"type": "system", "subtype": "hook_response", "hook_id": "06af4bab-bef3-426b-96b7-733316a3b251", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "output": "\n", "stdout": "\n", "stderr": "", "exit_code": 0, "outcome": "success", "uuid": "e2266c8c-8e91-4893-9806-1286769a12fd", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 255, "kind": "dispatch", "clientMessageId": "client-A", "sentUuid": "712482eb-4c05-4f67-8888-a11843c2729f", "text": "Use the Bash tool to run `sleep 8` two separate times, one call at a time, waiting for each. Then reply exactly: FIRST DONE", "outcome": {"state": "admitted"}} +{"at": 257, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "running", "uuid": "a6e277fb-d7fc-418a-88f2-2d893feea0d0", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 257, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "712482eb-4c05-4f67-8888-a11843c2729f", "state": "queued", "uuid": "57d845c0-8945-4528-82a5-8fbe83381714", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 258, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "712482eb-4c05-4f67-8888-a11843c2729f", "state": "started", "uuid": "a2a8821a-7b4a-4332-95da-765eb95c2745", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 273, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/tmp/scrubbed-cwd", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": ["Task", "AskUserQuestion", "Bash", "CronCreate", "CronDelete", "CronList", "DesignSync", "Edit", "EnterPlanMode", "EnterWorktree", "ExitPlanMode", "ExitWorktree", "ListAgents", "NotebookEdit", "Read", "ReportFindings", "ScheduleWakeup", "SendMessage", "Skill", "TaskCreate", "TaskGet", "TaskList", "TaskStop", "TaskUpdate", "ToolSearch", "WebFetch", "WebSearch", "Workflow", "Write"], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": ["deep-research", "design", "design-sync", "dataviz", "update-config", "verify", "debug", "code-review", "simplify", "batch", "fewer-permission-prompts", "doctor", "loop", "claude-api", "workflow-authoring", "run", "run-skill-generator", "agents", "auto-mode-setup", "autocompact", "clear", "color", "compact", "config", "output-style", "context", "effort", "fast", "heapdump", "init", "mcp", "model", "__remote-workflow", "workflow-launch-exec", "reload-plugins", "reload-skills", "rename", "security-review", "usage", "insights", "recap", "goal", "design-consent", "design-revoke", "list-agents", "team-onboarding"], "terminal_slash_commands": ["doctor", "color", "reload-plugins"], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": ["claude", "Explore", "general-purpose", "Plan", "statusline-setup"], "skills": ["deep-research", "design", "design-sync", "dataviz", "update-config", "verify", "debug", "code-review", "simplify", "batch", "fewer-permission-prompts", "doctor", "loop", "claude-api", "workflow-authoring", "run", "run-skill-generator"], "plugins": [{"name": "telemetry", "path": "builtin", "source": "telemetry@builtin"}], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "ea0c12f1-7e48-4001-a577-5e4abb2e9c5f", "memory_paths": {"auto": "/Users/user/scrubbed"}, "messaging_socket_path": "/tmp/scrubbed.sock", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 275, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "85ed497a-5e78-4ac6-aa9c-2ebe37063403"}} +{"at": 1303, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Use the Bash tool to run `sleep 8` two separate times, one call at a time, waiting for each. Then reply exactly: FIRST DONE"}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "712482eb-4c05-4f67-8888-a11843c2729f", "timestamp": "2026-09-28T12:51:16.373Z", "isReplay": true}} +{"at": 1303, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_start", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsGeUUTEuaYMdT2HtPQ", "type": "message", "role": "assistant", "content": [], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 5244, "cache_read_input_tokens": 11514, "cache_creation": {"ephemeral_5m_input_tokens": 5244, "ephemeral_1h_input_tokens": 0}, "output_tokens": 16, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "61853b4b-5722-4b7b-94d8-15304f575d2c", "ttft_ms": 1022, "user_message_uuid": "712482eb-4c05-4f67-8888-a11843c2729f", "user_message_uuids": ["712482eb-4c05-4f67-8888-a11843c2729f"]}} +{"at": 1304, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "tool_use", "id": "toolu_016sNyp5R1LwYkaBUj9UKWYs", "name": "Bash", "input": {}, "caller": {"type": "direct"}}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "df0f12a2-35bb-424c-8f28-40e6fcc07960"}} +{"at": 1304, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "5bcd51ce-460b-4891-8978-332c44f66114"}} +{"at": 2559, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "{\"comma"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "9744edc1-59cc-4c22-959a-8bfba7abc2ef"}} +{"at": 2559, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "nd\": \"s"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "4b7645f6-f050-4271-91f0-544df1ff0931"}} +{"at": 2559, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "leep 8\""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "11acbd0f-fc8e-4642-8897-8815951dd4ee"}} +{"at": 2637, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ", \"desc"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "4d140c8b-47f3-44e4-aade-212f63e2b40a"}} +{"at": 2637, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "ription"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "a51d0b53-c118-4de5-a848-aa4bdf7d86c7"}} +{"at": 2649, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "\": \"Sle"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "a128c776-0a6a-4923-952f-3633f4b91239"}} +{"at": 2649, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "ep for "}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "ddce72e4-cbcc-45f1-966d-dd873f59d687"}} +{"at": 2650, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "8 secon"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "44392f6b-e3ac-4caa-8abe-ee1b99ba6783"}} +{"at": 2650, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "ds\"}"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "a5bc170f-84ba-4ac7-b104-b36d95c69049"}} +{"at": 2652, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsGeUUTEuaYMdT2HtPQ", "type": "message", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_016sNyp5R1LwYkaBUj9UKWYs", "name": "Bash", "input": {"command": "sleep 8", "description": "Sleep for 8 seconds"}, "caller": {"type": "direct"}}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 5244, "cache_read_input_tokens": 11514, "cache_creation": {"ephemeral_5m_input_tokens": 5244, "ephemeral_1h_input_tokens": 0}, "output_tokens": 16, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "4a86f704-4cdc-4561-840d-dee6e913ebf9", "timestamp": "2026-09-28T12:51:18.753Z", "request_id": "req_011CfVsGe1gx1SqtHypyjG4P", "user_message_uuid": "712482eb-4c05-4f67-8888-a11843c2729f", "user_message_uuids": ["712482eb-4c05-4f67-8888-a11843c2729f"]}} +{"at": 2655, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_stop", "index": 0}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "f79e14ed-a3e2-4ebf-88bb-a45ed5ee6a78"}} +{"at": 2657, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_delta", "delta": {"stop_reason": "tool_use", "stop_sequence": null, "stop_details": null, "container": null}, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 5244, "cache_read_input_tokens": 11514, "output_tokens": 82, "output_tokens_details": {"thinking_tokens": 0}}, "context_management": {"applied_edits": []}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "6b586bec-0544-4cdb-9fc5-fadae3c7d6ed"}} +{"at": 2657, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_stop"}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "3c602652-b8a2-4b38-b54d-ecb1f99b1201"}} +{"at": 5768, "kind": "frame", "frame": {"type": "system", "subtype": "task_started", "task_id": "bnnik2zr1", "tool_use_id": "toolu_016sNyp5R1LwYkaBUj9UKWYs", "description": "Sleep for 8 seconds", "is_backgrounded": false, "task_type": "local_bash", "uuid": "5213fad9-0ff9-4f01-b934-7f7155ba396b", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 10782, "kind": "frame", "frame": {"type": "system", "subtype": "task_notification", "task_id": "bnnik2zr1", "tool_use_id": "toolu_016sNyp5R1LwYkaBUj9UKWYs", "status": "completed", "output_file": "", "summary": "Sleep for 8 seconds", "uuid": "f37669e5-c402-43ac-823f-1a7f9005a834", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 10785, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_016sNyp5R1LwYkaBUj9UKWYs", "type": "tool_result", "content": "(Bash completed with no output)", "is_error": false}]}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "12161028-12c4-470e-a82d-1f6f4ee64aba", "timestamp": "2026-09-28T12:51:26.887Z", "tool_use_result": {"stdout": "", "stderr": "", "interrupted": false, "isImage": false, "noOutputExpected": false}}} +{"at": 10787, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "e0454575-2f1d-47c0-b9f3-4ecadbaaa122"}} +{"at": 11596, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_start", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsHQJiVw1uJvCEPhMGa", "type": "message", "role": "assistant", "content": [], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 121, "cache_read_input_tokens": 16758, "cache_creation": {"ephemeral_5m_input_tokens": 121, "ephemeral_1h_input_tokens": 0}, "output_tokens": 16, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "834585ab-8f03-423d-ae43-825a12b9f45e", "ttft_ms": 805}} +{"at": 11597, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "tool_use", "id": "toolu_01BiKvSfSEAwNM1DwshGhsT6", "name": "Bash", "input": {}, "caller": {"type": "direct"}}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "273975d6-06bf-4eff-87aa-dca1a6d39b5c"}} +{"at": 11597, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "2c9121cf-a472-476e-9626-05d4577ff349"}} +{"at": 11950, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "{\"comma"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "c2cb4769-ee86-4eda-9af7-f75a89493101"}} +{"at": 11950, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "nd\": \"s"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "8e70eae8-653c-40ad-8a12-f37068234009"}} +{"at": 11950, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "leep 8\""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "0135507b-0b6e-41fe-bd1c-17becb10430f"}} +{"at": 12026, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ", \"desc"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "94802121-d71b-4781-856c-f40f78498ebe"}} +{"at": 12026, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "ription"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "ec03999d-3810-405e-b36a-58d8d0f4e4ac"}} +{"at": 12026, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "\": \"Sle"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "2183a548-e1b7-48c5-a4d8-58ab6ae4acc9"}} +{"at": 12026, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "ep for "}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "d8ffd37e-3d9d-46a4-b6e1-d52cb96a334d"}} +{"at": 12026, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "8 secon"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "2c51be8c-ef09-42eb-a07b-c9083d3e349c"}} +{"at": 12026, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "ds\"}"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "e4957303-3d3f-47b4-aaed-0ec6210e1e81"}} +{"at": 12027, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsHQJiVw1uJvCEPhMGa", "type": "message", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_01BiKvSfSEAwNM1DwshGhsT6", "name": "Bash", "input": {"command": "sleep 8", "description": "Sleep for 8 seconds"}, "caller": {"type": "direct"}}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 121, "cache_read_input_tokens": 16758, "cache_creation": {"ephemeral_5m_input_tokens": 121, "ephemeral_1h_input_tokens": 0}, "output_tokens": 16, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "03e0a7fa-a83c-4688-9504-8b7fa5c9f6ff", "timestamp": "2026-09-28T12:51:28.129Z", "request_id": "req_011CfVsHPsQjwKL4424e8EfN"}} +{"at": 12028, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_stop", "index": 0}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "56ad793f-dcd0-4f9d-9dcc-49c2054d18c7"}} +{"at": 12036, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_delta", "delta": {"stop_reason": "tool_use", "stop_sequence": null, "stop_details": null, "container": null}, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 121, "cache_read_input_tokens": 16758, "output_tokens": 82, "output_tokens_details": {"thinking_tokens": 0}}, "context_management": {"applied_edits": []}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "98e98f29-79a1-4db9-9cd4-c4fe810eca7e"}} +{"at": 12036, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_stop"}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "3629fbc7-6420-45ae-88be-cd9b779e721a"}} +{"at": 15034, "kind": "frame", "frame": {"type": "system", "subtype": "task_started", "task_id": "buewgpxis", "tool_use_id": "toolu_01BiKvSfSEAwNM1DwshGhsT6", "description": "Sleep for 8 seconds", "is_backgrounded": false, "task_type": "local_bash", "uuid": "fa9ac561-8bf7-4035-9aa8-221e4cd91dfb", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 20045, "kind": "frame", "frame": {"type": "system", "subtype": "task_notification", "task_id": "buewgpxis", "tool_use_id": "toolu_01BiKvSfSEAwNM1DwshGhsT6", "status": "completed", "output_file": "", "summary": "Sleep for 8 seconds", "uuid": "230550ea-a7f7-4171-9080-901cfdd0d571", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 20047, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_01BiKvSfSEAwNM1DwshGhsT6", "type": "tool_result", "content": "(Bash completed with no output)", "is_error": false}]}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "68545b22-4c1b-44df-a0ec-5876d2c19aee", "timestamp": "2026-09-28T12:51:36.149Z", "tool_use_result": {"stdout": "", "stderr": "", "interrupted": false, "isImage": false, "noOutputExpected": false}}} +{"at": 20048, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "e14bc8d3-a5bf-4d52-b841-9b5632247863"}} +{"at": 20123, "kind": "dispatch", "clientMessageId": "client-B", "sentUuid": "6f0204a5-a21a-47a4-9517-034cc4467b52", "text": "Also say the word banana at the end of your reply.", "outcome": {"state": "admitted"}} +{"at": 20124, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "6f0204a5-a21a-47a4-9517-034cc4467b52", "state": "queued", "uuid": "87c94a31-75e5-400a-9551-ca24c36b9be5", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 21070, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_start", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsJ6bNbeN2V5Wbst25o", "type": "message", "role": "assistant", "content": [], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 17000, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 17000, "ephemeral_1h_input_tokens": 0}, "output_tokens": 9, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "02c04bc0-68be-4721-a24d-27e7fa4a93db", "ttft_ms": 1019}} +{"at": 21071, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "c90f97b4-2cd4-482d-af20-cd921f5915ef"}} +{"at": 21071, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "FIRST DONE"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "059a395d-c0a0-4a06-b483-240c3aece7ff"}} +{"at": 21082, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsJ6bNbeN2V5Wbst25o", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "FIRST DONE"}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 17000, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 17000, "ephemeral_1h_input_tokens": 0}, "output_tokens": 9, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "fc532a8b-2d4d-4cb9-92a5-ea1c509a8019", "timestamp": "2026-09-28T12:51:37.173Z", "request_id": "req_011CfVsJ5eaEpxJgCRB1U4zr"}} +{"at": 21082, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_stop", "index": 0}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "81e56773-385a-4016-a0ec-ce01d277b9d1"}} +{"at": 21082, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": null, "stop_details": null, "container": null}, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 17000, "cache_read_input_tokens": 0, "output_tokens": 9, "output_tokens_details": {"thinking_tokens": 0}}, "context_management": {"applied_edits": []}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "ea179db4-6487-4447-927e-ae75d31097a2"}} +{"at": 21082, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_stop"}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "6de38f87-568d-4573-960c-a6143bc8ecda"}} +{"at": 21085, "kind": "frame", "frame": {"duration_api_ms": 6803, "stop_reason": "end_turn", "session_id": "00000000-0000-4000-8000-00000000c1a0", "total_cost_usd": 0.1262354, "usage": {"input_tokens": 6, "cache_creation_input_tokens": 22365, "cache_read_input_tokens": 28272, "output_tokens": 173, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 22365}, "inference_geo": "not_available", "iterations": [], "speed": "standard"}, "modelUsage": {"claude-opus-5-5[1m]": {"inputTokens": 1219, "outputTokens": 194, "cacheReadInputTokens": 28272, "cacheCreationInputTokens": 22365, "webSearchRequests": 0, "costUSD": 0.1262354, "contextWindow": 1000000, "maxOutputTokens": 128000, "thinkingTokens": 0, "canonicalModel": "claude-opus-5-5", "provider": "firstParty", "costBasis": "list"}}, "permission_denials": [], "terminal_reason": "completed", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": false, "num_turns": 3, "subtype": "success", "api_error_status": null, "result": "FIRST DONE", "ttft_ms": 2391, "type": "result", "duration_ms": 20825, "uuid": "b82a7983-d7ea-496d-9cb7-735f1ffb3cda", "ttft_stream_ms": 1043, "time_to_request_ms": 22, "first_content_frame_ms": 1044, "user_message_uuid": "712482eb-4c05-4f67-8888-a11843c2729f", "user_message_uuids": ["712482eb-4c05-4f67-8888-a11843c2729f"], "request_sent_wall_ms": 1790599876383, "queued_turn_count": 0, "result_index": 0}} +{"at": 21085, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "712482eb-4c05-4f67-8888-a11843c2729f", "state": "completed", "uuid": "acb703de-6b64-4496-b552-74ddb042d768", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 21085, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "6f0204a5-a21a-47a4-9517-034cc4467b52", "state": "started", "uuid": "77edaf35-52e5-42f1-a84a-f86dc68c96a4", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 21088, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/tmp/scrubbed-cwd", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": ["Task", "AskUserQuestion", "Bash", "CronCreate", "CronDelete", "CronList", "DesignSync", "Edit", "EnterPlanMode", "EnterWorktree", "ExitPlanMode", "ExitWorktree", "ListAgents", "NotebookEdit", "Read", "ReportFindings", "ScheduleWakeup", "SendMessage", "Skill", "TaskCreate", "TaskGet", "TaskList", "TaskStop", "TaskUpdate", "ToolSearch", "WebFetch", "WebSearch", "Workflow", "Write"], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": ["deep-research", "design", "design-sync", "dataviz", "update-config", "verify", "debug", "code-review", "simplify", "batch", "fewer-permission-prompts", "doctor", "loop", "claude-api", "workflow-authoring", "run", "run-skill-generator", "agents", "auto-mode-setup", "autocompact", "clear", "color", "compact", "config", "output-style", "context", "effort", "fast", "heapdump", "init", "mcp", "model", "__remote-workflow", "workflow-launch-exec", "reload-plugins", "reload-skills", "rename", "security-review", "usage", "insights", "recap", "goal", "design-consent", "design-revoke", "list-agents", "team-onboarding"], "terminal_slash_commands": ["doctor", "color", "reload-plugins"], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": ["claude", "Explore", "general-purpose", "Plan", "statusline-setup"], "skills": ["deep-research", "design", "design-sync", "dataviz", "update-config", "verify", "debug", "code-review", "simplify", "batch", "fewer-permission-prompts", "doctor", "loop", "claude-api", "workflow-authoring", "run", "run-skill-generator"], "plugins": [{"name": "telemetry", "path": "builtin", "source": "telemetry@builtin"}], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "f6af6108-710e-4208-8c3c-79b2a9fc14e4", "memory_paths": {"auto": "/Users/user/scrubbed"}, "messaging_socket_path": "/tmp/scrubbed.sock", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 21088, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "7ff5bb60-79be-49cb-bd26-950fa9584ffe"}} +{"at": 22022, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Also say the word banana at the end of your reply."}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "6f0204a5-a21a-47a4-9517-034cc4467b52", "timestamp": "2026-09-28T12:51:37.189Z", "isReplay": true}} +{"at": 22022, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_start", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsJAVHoszB7NSfdEtk4", "type": "message", "role": "assistant", "content": [], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 51, "cache_read_input_tokens": 17000, "cache_creation": {"ephemeral_5m_input_tokens": 51, "ephemeral_1h_input_tokens": 0}, "output_tokens": 7, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "30e634ea-7725-4e57-bba9-5b6ae1f44c1b", "ttft_ms": 927, "user_message_uuid": "6f0204a5-a21a-47a4-9517-034cc4467b52", "user_message_uuids": ["6f0204a5-a21a-47a4-9517-034cc4467b52"]}} +{"at": 22023, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "thinking", "thinking": "", "signature": ""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "8c52275c-f862-4c49-a1b5-4cb5dde934c8"}} +{"at": 22469, "kind": "frame", "frame": {"type": "system", "subtype": "thinking_tokens", "estimated_tokens": 50, "estimated_tokens_delta": 50, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "91410a95-652d-4e88-8661-19ef21e01a22", "user_message_uuid": "6f0204a5-a21a-47a4-9517-034cc4467b52"}} +{"at": 22469, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "", "estimated_tokens": 50}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "ea150b67-dad7-4436-84c5-82d73a3ee3ac"}} +{"at": 23097, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "", "estimated_tokens": null}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "dcf3a66c-0505-4b70-985f-d5d431de68c5"}} +{"at": 23097, "kind": "frame", "frame": {"type": "system", "subtype": "thinking_tokens", "estimated_tokens": 192, "estimated_tokens_delta": 142, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "411efdb6-1b14-40d5-b126-6251292c2898", "user_message_uuid": "6f0204a5-a21a-47a4-9517-034cc4467b52"}} +{"at": 23097, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "signature_delta", "signature": "CAQS9wUKEAgSGAI4AUIIdGhpbmtpbmcSDB/yUaKTcw1uKvgE2xoM//KByDA4fixole7KIjDLAtdDHZ4gV5AjYQGHGc1vQ+EwvB7yHUpWkhn/fsoZ0XTaERvUz6ZoVYhDH7TV/3EqlAW89rxYwkeD/V2vxPnz1iDOQvnSOBk08h5h6cd7CUNMyyM5UU/VtJ84BJDllyBTZLw8RCS/tuodap4P8NmO7I6wK6ZQ8d0hTfpyHsr7J4UfnD9RXhy2x5YdTX11OI8sQ7hxj9HkzhEJpJwRSytLRpclM3ATKwIv/ApihNhfYtbKApQSeIOlFArXz17Xm3RYMs3WjRzL8NMPmV0d5sirOCJ1xtAf3vLSi7awlvG45tpmkW3xaEZsVczOBSNhLPdzwEuHQrAZqx+5WE/b5cC9A2jfW3HgOSdmlmp1z0vYhdbvfu+ALKLUFmAz49QY8YINKNXLbZMUxY5IEDHrRUNjQIY2tJvJWi1SMNNaAvRh1dVBwRDdCXRkWt1Yt8U3iQ2g0dZnfeEGCrIETtzGnRZ5nY0ekGcVrU8QQCCiibceC0dSOmbpNOnDGjxpu3PdoBBoxjZB/Ys1I2td+KDsGwWi0KygWOH7xoa11fsLHvoBu+d0RaTCH64uX6s3bLkajeeCpX0nEt0PR0GT69Jst6J4JwiDTDjjK4LF87Sxm4tSnTzymEYXhk2M5EtdkOUmD5yeI4Qf0liMutVlLWISHzDsAj/t1i7dQtr76wHzrq0/1qdf4OJlFH0qLEM98ekpLOsESLLGryp0UIRIrMaJtpk8e/o9+uHgCP2IwxxEFaWb6PUvNvuI00K/C3dxTD9JQQrNZRdadsnj74/TR2qePDAXzb8vhDSm2b8umySGmHCPrTjBdC0O6riijl1pKqdjRS9yzFVInnZ00zUHpmT9ojVT3dKennDIfdN4ShxGTh5be81hF1pfm9GDC7HJGpG+Eth3qS8JTRQhDx43J4DKNA4XQYQ7CHIxOLET/s8e+qGLnlGCJl6K0h4YAQ=="}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "3b70c219-37e8-485d-a00c-48e3680cc14e"}} +{"at": 23097, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsJAVHoszB7NSfdEtk4", "type": "message", "role": "assistant", "content": [{"type": "thinking", "thinking": "", "signature": "CAQS9wUKEAgSGAI4AUIIdGhpbmtpbmcSDB/yUaKTcw1uKvgE2xoM//KByDA4fixole7KIjDLAtdDHZ4gV5AjYQGHGc1vQ+EwvB7yHUpWkhn/fsoZ0XTaERvUz6ZoVYhDH7TV/3EqlAW89rxYwkeD/V2vxPnz1iDOQvnSOBk08h5h6cd7CUNMyyM5UU/VtJ84BJDllyBTZLw8RCS/tuodap4P8NmO7I6wK6ZQ8d0hTfpyHsr7J4UfnD9RXhy2x5YdTX11OI8sQ7hxj9HkzhEJpJwRSytLRpclM3ATKwIv/ApihNhfYtbKApQSeIOlFArXz17Xm3RYMs3WjRzL8NMPmV0d5sirOCJ1xtAf3vLSi7awlvG45tpmkW3xaEZsVczOBSNhLPdzwEuHQrAZqx+5WE/b5cC9A2jfW3HgOSdmlmp1z0vYhdbvfu+ALKLUFmAz49QY8YINKNXLbZMUxY5IEDHrRUNjQIY2tJvJWi1SMNNaAvRh1dVBwRDdCXRkWt1Yt8U3iQ2g0dZnfeEGCrIETtzGnRZ5nY0ekGcVrU8QQCCiibceC0dSOmbpNOnDGjxpu3PdoBBoxjZB/Ys1I2td+KDsGwWi0KygWOH7xoa11fsLHvoBu+d0RaTCH64uX6s3bLkajeeCpX0nEt0PR0GT69Jst6J4JwiDTDjjK4LF87Sxm4tSnTzymEYXhk2M5EtdkOUmD5yeI4Qf0liMutVlLWISHzDsAj/t1i7dQtr76wHzrq0/1qdf4OJlFH0qLEM98ekpLOsESLLGryp0UIRIrMaJtpk8e/o9+uHgCP2IwxxEFaWb6PUvNvuI00K/C3dxTD9JQQrNZRdadsnj74/TR2qePDAXzb8vhDSm2b8umySGmHCPrTjBdC0O6riijl1pKqdjRS9yzFVInnZ00zUHpmT9ojVT3dKennDIfdN4ShxGTh5be81hF1pfm9GDC7HJGpG+Eth3qS8JTRQhDx43J4DKNA4XQYQ7CHIxOLET/s8e+qGLnlGCJl6K0h4YAQ=="}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 51, "cache_read_input_tokens": 17000, "cache_creation": {"ephemeral_5m_input_tokens": 51, "ephemeral_1h_input_tokens": 0}, "output_tokens": 7, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "f59a0f60-bc42-4d2f-be93-2d89991bf482", "timestamp": "2026-09-28T12:51:39.200Z", "request_id": "req_011CfVsJA4Uy2C2Rx8uyY7H1", "user_message_uuid": "6f0204a5-a21a-47a4-9517-034cc4467b52", "user_message_uuids": ["6f0204a5-a21a-47a4-9517-034cc4467b52"]}} +{"at": 23097, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_stop", "index": 0}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "0d8eff25-307b-4247-81e1-9a4575c7e07d"}} +{"at": 23097, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_start", "index": 1, "content_block": {"type": "text", "text": ""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "7328034f-41e4-4823-9bde-8df483de1ce5"}} +{"at": 23098, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "FIRST DONE ban"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "e8f3de55-f311-4eac-baab-9db3fe5ec197"}} +{"at": 23098, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "ana"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "b293546a-d0c5-4cdd-9884-5b155b845e96"}} +{"at": 23098, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsJAVHoszB7NSfdEtk4", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "FIRST DONE banana"}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 51, "cache_read_input_tokens": 17000, "cache_creation": {"ephemeral_5m_input_tokens": 51, "ephemeral_1h_input_tokens": 0}, "output_tokens": 7, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "31583f02-16b9-418d-89b3-269afb1dc2c0", "timestamp": "2026-09-28T12:51:39.201Z", "request_id": "req_011CfVsJA4Uy2C2Rx8uyY7H1"}} +{"at": 23098, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_stop", "index": 1}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "f8047b07-9ca2-436f-a8e2-f05a17f14970"}} +{"at": 23105, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": null, "stop_details": null, "container": null}, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 51, "cache_read_input_tokens": 17000, "output_tokens": 65, "output_tokens_details": {"thinking_tokens": 54}}, "context_management": {"applied_edits": []}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "372ebe0e-8f36-4567-8563-4aff3e059a3f"}} +{"at": 23105, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_stop"}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "b11714ab-fe3d-44fb-991d-c62ae8a60507"}} +{"at": 23107, "kind": "frame", "frame": {"duration_api_ms": 8819, "stop_reason": "end_turn", "session_id": "00000000-0000-4000-8000-00000000c1a0", "total_cost_usd": 0.1311984, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 51, "cache_read_input_tokens": 17000, "output_tokens": 65, "output_tokens_details": {"thinking_tokens": 54}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 51}, "inference_geo": "not_available", "iterations": [], "speed": "standard"}, "modelUsage": {"claude-opus-5-5[1m]": {"inputTokens": 1221, "outputTokens": 259, "cacheReadInputTokens": 45272, "cacheCreationInputTokens": 22416, "webSearchRequests": 0, "costUSD": 0.1311984, "contextWindow": 1000000, "maxOutputTokens": 128000, "thinkingTokens": 54, "canonicalModel": "claude-opus-5-5", "provider": "firstParty", "costBasis": "list"}}, "permission_denials": [], "terminal_reason": "completed", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": false, "num_turns": 1, "subtype": "success", "api_error_status": null, "result": "FIRST DONE banana", "ttft_ms": 2012, "type": "result", "duration_ms": 2021, "uuid": "572df995-0b87-436c-9966-5f3740c22820", "ttft_stream_ms": 937, "time_to_request_ms": 10, "first_content_frame_ms": 937, "user_message_uuid": "6f0204a5-a21a-47a4-9517-034cc4467b52", "user_message_uuids": ["6f0204a5-a21a-47a4-9517-034cc4467b52"], "request_sent_wall_ms": 1790599897198, "queued_turn_count": 0, "result_index": 1}} +{"at": 23107, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "6f0204a5-a21a-47a4-9517-034cc4467b52", "state": "completed", "uuid": "bd6b5dbd-002f-4d5e-8311-7d747745ea00", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 23108, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "idle", "uuid": "c7642956-8764-404f-8b89-8eac3a91cb1e", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 26162, "kind": "end", "results": 2} diff --git a/src/main/claude/__fixtures__/claude-hook-event-enums.json b/src/main/claude/__fixtures__/claude-hook-event-enums.json new file mode 100644 index 00000000000..f4bedfc31df --- /dev/null +++ b/src/main/claude/__fixtures__/claude-hook-event-enums.json @@ -0,0 +1,960 @@ +{ + "source": "Read from cli.js of each @anthropic-ai/claude-code npm release. enums: the hook event enum; hooks keys outside it make 1.0.23-2.1.100 discard the whole user settings file. topLevelSettings: the settings schema's top-level keys, and whether it is strict, i.e. discards the whole file over any other key.", + "previousPublishedVersion": { + "1.0.23": "1.0.22", + "1.0.31": "1.0.30", + "1.0.41": "1.0.40", + "1.0.53": "1.0.52", + "1.0.62": "1.0.61", + "1.0.64": "1.0.63", + "1.0.85": "1.0.84", + "2.0.43": "2.0.42", + "2.0.45": "2.0.44", + "2.0.56": "2.0.55", + "2.1.33": "2.1.32", + "2.1.76": "2.1.75", + "2.1.78": "2.1.77" + }, + "topLevelSettings": { + "1.0.48": { + "strict": false, + "keys": [ + "apiKeyHelper", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "learnMode", + "forceLoginMethod" + ] + }, + "1.0.49": { + "strict": true, + "keys": [ + "apiKeyHelper", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "learnMode", + "forceLoginMethod" + ] + }, + "1.0.50": { + "strict": true, + "keys": [ + "apiKeyHelper", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "learnMode", + "forceLoginMethod" + ] + }, + "1.0.51": { + "strict": true, + "keys": [ + "apiKeyHelper", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "learnMode", + "forceLoginMethod" + ] + }, + "1.0.52": { + "strict": true, + "keys": [ + "apiKeyHelper", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "learnMode", + "forceLoginMethod" + ] + }, + "1.0.53": { + "strict": true, + "keys": [ + "apiKeyHelper", + "awsCredentialExport", + "awsAuthRefresh", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "learnMode", + "forceLoginMethod", + "otelHeadersHelper" + ] + }, + "1.0.54": { + "strict": true, + "keys": [ + "apiKeyHelper", + "awsCredentialExport", + "awsAuthRefresh", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "learnMode", + "forceLoginMethod", + "otelHeadersHelper" + ] + }, + "1.0.58": { + "strict": true, + "keys": [ + "apiKeyHelper", + "awsCredentialExport", + "awsAuthRefresh", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "learnMode", + "forceLoginMethod", + "otelHeadersHelper" + ] + }, + "1.0.59": { + "strict": true, + "keys": [ + "apiKeyHelper", + "awsCredentialExport", + "awsAuthRefresh", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "learnMode", + "forceLoginMethod", + "otelHeadersHelper" + ] + }, + "1.0.60": { + "strict": true, + "keys": [ + "apiKeyHelper", + "awsCredentialExport", + "awsAuthRefresh", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "learnMode", + "forceLoginMethod", + "otelHeadersHelper" + ] + }, + "1.0.61": { + "strict": true, + "keys": [ + "apiKeyHelper", + "awsCredentialExport", + "awsAuthRefresh", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "learnMode", + "forceLoginMethod", + "otelHeadersHelper" + ] + }, + "1.0.62": { + "strict": true, + "keys": [ + "apiKeyHelper", + "awsCredentialExport", + "awsAuthRefresh", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "learnMode", + "forceLoginMethod", + "otelHeadersHelper" + ] + }, + "1.0.63": { + "strict": true, + "keys": [ + "apiKeyHelper", + "awsCredentialExport", + "awsAuthRefresh", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "learnMode", + "forceLoginMethod", + "otelHeadersHelper", + "outputMode" + ] + }, + "1.0.64": { + "strict": true, + "keys": [ + "apiKeyHelper", + "awsCredentialExport", + "awsAuthRefresh", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "statusLine", + "learnMode", + "forceLoginMethod", + "otelHeadersHelper", + "outputMode", + "feedbackSurveyState" + ] + }, + "1.0.65": { + "strict": true, + "keys": [ + "$schema", + "apiKeyHelper", + "awsCredentialExport", + "awsAuthRefresh", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "statusLine", + "learnMode", + "forceLoginMethod", + "otelHeadersHelper", + "outputMode", + "feedbackSurveyState" + ] + }, + "1.0.66": { + "strict": true, + "keys": [ + "$schema", + "apiKeyHelper", + "awsCredentialExport", + "awsAuthRefresh", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "statusLine", + "learnMode", + "forceLoginMethod", + "otelHeadersHelper", + "outputMode" + ] + }, + "1.0.67": { + "strict": false, + "keys": [ + "$schema", + "apiKeyHelper", + "awsCredentialExport", + "awsAuthRefresh", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "statusLine", + "learnMode", + "forceLoginMethod", + "otelHeadersHelper", + "outputMode" + ] + } + }, + "enums": { + "1.0.22": [ + "PreTask", + "PreBash", + "PreGlob", + "PreGrep", + "PreRead", + "PreEdit", + "PreMultiEdit", + "PreWrite", + "PreNotebookRead", + "PreNotebookEdit", + "PreWebFetch", + "PreWebSearch" + ], + "1.0.23": ["PreToolUse", "PostToolUse", "Notification"], + "1.0.30": ["PreToolUse", "PostToolUse", "Notification"], + "1.0.31": ["PreToolUse", "PostToolUse", "Notification", "Stop"], + "1.0.40": ["PreToolUse", "PostToolUse", "Notification", "Stop"], + "1.0.41": ["PreToolUse", "PostToolUse", "Notification", "Stop", "SubagentStop"], + "1.0.48": ["PreToolUse", "PostToolUse", "Notification", "Stop", "SubagentStop", "PreCompact"], + "1.0.49": ["PreToolUse", "PostToolUse", "Notification", "Stop", "SubagentStop", "PreCompact"], + "1.0.50": ["PreToolUse", "PostToolUse", "Notification", "Stop", "SubagentStop", "PreCompact"], + "1.0.51": ["PreToolUse", "PostToolUse", "Notification", "Stop", "SubagentStop", "PreCompact"], + "1.0.52": ["PreToolUse", "PostToolUse", "Notification", "Stop", "SubagentStop", "PreCompact"], + "1.0.53": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "Stop", + "SubagentStop", + "PreCompact" + ], + "1.0.54": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "Stop", + "SubagentStop", + "PreCompact" + ], + "1.0.58": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "Stop", + "SubagentStop", + "PreCompact" + ], + "1.0.59": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "Stop", + "SubagentStop", + "PreCompact" + ], + "1.0.60": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "Stop", + "SubagentStop", + "PreCompact" + ], + "1.0.61": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "Stop", + "SubagentStop", + "PreCompact" + ], + "1.0.62": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "Stop", + "SubagentStop", + "PreCompact" + ], + "1.0.63": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "Stop", + "SubagentStop", + "PreCompact" + ], + "1.0.64": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "Stop", + "SubagentStop", + "PreCompact" + ], + "1.0.65": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "Stop", + "SubagentStop", + "PreCompact" + ], + "1.0.66": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "Stop", + "SubagentStop", + "PreCompact" + ], + "1.0.67": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "Stop", + "SubagentStop", + "PreCompact" + ], + "1.0.81": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "Stop", + "SubagentStop", + "PreCompact" + ], + "1.0.84": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "Stop", + "SubagentStop", + "PreCompact" + ], + "1.0.85": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStop", + "PreCompact" + ], + "2.0.42": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStop", + "PreCompact" + ], + "2.0.43": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStart", + "SubagentStop", + "PreCompact" + ], + "2.0.44": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStart", + "SubagentStop", + "PreCompact" + ], + "2.0.45": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PermissionRequest" + ], + "2.0.52": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PermissionRequest" + ], + "2.0.54": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PermissionRequest" + ], + "2.0.55": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PermissionRequest" + ], + "2.0.56": [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PermissionRequest" + ], + "2.0.60": [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PermissionRequest" + ], + "2.0.76": [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PermissionRequest" + ], + "2.1.32": [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PermissionRequest", + "Setup" + ], + "2.1.33": [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PermissionRequest", + "Setup", + "TeammateIdle", + "TaskCompleted" + ], + "2.1.75": [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PermissionRequest", + "Setup", + "TeammateIdle", + "TaskCompleted", + "Elicitation", + "ElicitationResult", + "ConfigChange", + "WorktreeCreate", + "WorktreeRemove", + "InstructionsLoaded" + ], + "2.1.76": [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "PermissionRequest", + "Setup", + "TeammateIdle", + "TaskCompleted", + "Elicitation", + "ElicitationResult", + "ConfigChange", + "WorktreeCreate", + "WorktreeRemove", + "InstructionsLoaded" + ], + "2.1.77": [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "PermissionRequest", + "Setup", + "TeammateIdle", + "TaskCompleted", + "Elicitation", + "ElicitationResult", + "ConfigChange", + "WorktreeCreate", + "WorktreeRemove", + "InstructionsLoaded" + ], + "2.1.78": [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "StopFailure", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "PermissionRequest", + "Setup", + "TeammateIdle", + "TaskCompleted", + "Elicitation", + "ElicitationResult", + "ConfigChange", + "WorktreeCreate", + "WorktreeRemove", + "InstructionsLoaded" + ], + "2.1.84": [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "StopFailure", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "PermissionRequest", + "Setup", + "TeammateIdle", + "TaskCreated", + "TaskCompleted", + "Elicitation", + "ElicitationResult", + "ConfigChange", + "WorktreeCreate", + "WorktreeRemove", + "InstructionsLoaded", + "CwdChanged", + "FileChanged" + ], + "2.1.90": [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "StopFailure", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "PermissionRequest", + "PermissionDenied", + "Setup", + "TeammateIdle", + "TaskCreated", + "TaskCompleted", + "Elicitation", + "ElicitationResult", + "ConfigChange", + "WorktreeCreate", + "WorktreeRemove", + "InstructionsLoaded", + "CwdChanged", + "FileChanged" + ], + "2.1.97": [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "StopFailure", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "PermissionRequest", + "PermissionDenied", + "Setup", + "TeammateIdle", + "TaskCreated", + "TaskCompleted", + "Elicitation", + "ElicitationResult", + "ConfigChange", + "WorktreeCreate", + "WorktreeRemove", + "InstructionsLoaded", + "CwdChanged", + "FileChanged" + ], + "2.1.100": [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "StopFailure", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "PermissionRequest", + "PermissionDenied", + "Setup", + "TeammateIdle", + "TaskCreated", + "TaskCompleted", + "Elicitation", + "ElicitationResult", + "ConfigChange", + "WorktreeCreate", + "WorktreeRemove", + "InstructionsLoaded", + "CwdChanged", + "FileChanged" + ], + "2.1.101": [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "StopFailure", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "PermissionRequest", + "PermissionDenied", + "Setup", + "TeammateIdle", + "TaskCreated", + "TaskCompleted", + "Elicitation", + "ElicitationResult", + "ConfigChange", + "WorktreeCreate", + "WorktreeRemove", + "InstructionsLoaded", + "CwdChanged", + "FileChanged" + ], + "2.1.110": [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "StopFailure", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "PermissionRequest", + "PermissionDenied", + "Setup", + "TeammateIdle", + "TaskCreated", + "TaskCompleted", + "Elicitation", + "ElicitationResult", + "ConfigChange", + "WorktreeCreate", + "WorktreeRemove", + "InstructionsLoaded", + "CwdChanged", + "FileChanged" + ] + } +} diff --git a/src/main/claude/__fixtures__/claude-lifecycle-capture-auth-failed.jsonl b/src/main/claude/__fixtures__/claude-lifecycle-capture-auth-failed.jsonl new file mode 100644 index 00000000000..38dd3136218 --- /dev/null +++ b/src/main/claude/__fixtures__/claude-lifecycle-capture-auth-failed.jsonl @@ -0,0 +1,12 @@ +{"at": 1, "kind": "meta", "scenario": "auth-failed", "providerSessionId": "00000000-0000-4000-8000-00000000c1a0", "note": "Recorded against Claude CLI 2.1.280 through the Agent SDK transport; scrubbed: session id, home paths, API request and message ids, initialize answer; stream_event frames dropped."} +{"at": 288, "kind": "dispatch", "clientMessageId": "client-A", "sentUuid": "39446645-6ec0-4807-9e18-95ac04c13143", "text": "Reply with text only; do not use any tools. Reply with exactly: CONTROL-DONE"} +{"at": 302, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "running", "uuid": "b8e8b60c-1592-4f05-a21a-853f6a1a5040", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 302, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "state": "queued", "uuid": "17b4b010-4615-4bf0-9b63-7936336cd7c4", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 306, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "state": "started", "uuid": "b1ed2141-c5a2-4c7e-8403-dbeb53d15be2", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 319, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "61043fb2-ec0d-4652-9ab8-00483ac5f026", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 321, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "ad41aad3-ab9d-4b3c-a9f2-8610044d9482"}} +{"at": 342, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Reply with text only; do not use any tools. Reply with exactly: CONTROL-DONE"}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "timestamp": "2026-09-29T10:33:13.793Z", "isReplay": true}} +{"at": 342, "kind": "frame", "frame": {"type": "assistant", "message": {"diagnostics": null, "id": "58f67b61-a560-4d83-b099-4f1b2e920de8", "container": null, "model": "", "role": "assistant", "stop_details": null, "stop_reason": "stop_sequence", "stop_sequence": "", "type": "message", "usage": {"output_tokens_details": null, "input_tokens": 0, "output_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": null, "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}, "inference_geo": null, "iterations": null, "speed": null}, "content": [{"type": "text", "text": "Not logged in \u00b7 Please run /login"}], "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "6c5de4d1-e149-4201-b18b-b0021d4650f2", "timestamp": "2026-09-29T10:33:13.809Z", "error": "authentication_failed", "is_api_error_message": true, "user_message_uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "user_message_uuids": ["39446645-6ec0-4807-9e18-95ac04c13143"]}} +{"at": 346, "kind": "frame", "frame": {"duration_api_ms": 0, "stop_reason": "stop_sequence", "session_id": "00000000-0000-4000-8000-00000000c1a0", "total_cost_usd": 0, "usage": {"output_tokens_details": {"thinking_tokens": 0}, "input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 0, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}, "inference_geo": "", "iterations": [], "speed": "standard"}, "modelUsage": {}, "permission_denials": [], "terminal_reason": "api_error", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": true, "num_turns": 1, "subtype": "success", "api_error_status": null, "result": "Not logged in \u00b7 Please run /login", "type": "result", "duration_ms": 36, "uuid": "9736f712-8292-41be-bd62-305633c7e152", "user_message_uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "user_message_uuids": ["39446645-6ec0-4807-9e18-95ac04c13143"], "queued_turn_count": 0, "result_index": 0}} +{"at": 347, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "state": "cancelled", "uuid": "64028711-21fb-4a47-99c9-8ae448e4c577", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 347, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "idle", "uuid": "081649ae-6232-43d6-b31b-6d1da6167020", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} diff --git a/src/main/claude/__fixtures__/claude-lifecycle-capture-batch-lead.jsonl b/src/main/claude/__fixtures__/claude-lifecycle-capture-batch-lead.jsonl new file mode 100644 index 00000000000..50cab7abe66 --- /dev/null +++ b/src/main/claude/__fixtures__/claude-lifecycle-capture-batch-lead.jsonl @@ -0,0 +1,28 @@ +{"at": 15, "kind": "meta", "scenario": "batch-lead", "providerSessionId": "00000000-0000-4000-8000-00000000c1a0", "note": "Recorded against Claude CLI 2.1.280 through the Agent SDK transport; scrubbed: session id, home paths, API request and message ids, initialize answer; stream_event frames dropped."} +{"at": 334, "kind": "frame", "frame": {"type": "system", "subtype": "hook_started", "hook_id": "adafe449-4ded-495b-a4db-b82589e901e6", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "uuid": "9d3d1c58-6d69-408a-ae73-73d5c9656e9f", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 376, "kind": "frame", "frame": {"type": "system", "subtype": "hook_response", "hook_id": "adafe449-4ded-495b-a4db-b82589e901e6", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "exit_code": 0, "outcome": "success", "uuid": "30ed6f57-a819-4c93-b4d3-7d76838c1950", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 410, "kind": "dispatch", "clientMessageId": "client-A", "sentUuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "text": "Reply with text only; do not use any tools. Write the numbers from one to two hundred in English words, one per line, lowercase, nothing else."} +{"at": 413, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "running", "uuid": "2dfef2b1-c08e-4a66-94b9-c3bbbe8d6adb", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 413, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "state": "queued", "uuid": "378fac6f-7ffb-417f-b9d3-4311c3d99d64", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 414, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "state": "started", "uuid": "4bdc9a15-e613-42f3-a912-f6bcf8712999", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 474, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "b3869f1d-27b1-488a-a8a1-bde1a52fbf84", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 477, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "1e0b56e7-60ab-430d-910f-06c0337fb209"}} +{"at": 1478, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Reply with text only; do not use any tools. Write the numbers from one to two hundred in English words, one per line, lowercase, nothing else."}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "timestamp": "2026-09-29T10:36:14.355Z", "isReplay": true}} +{"at": 1478, "kind": "dispatch", "clientMessageId": "client-B", "sentUuid": "ff373ed9-cde2-4a49-b83f-09157a686ee8", "text": "Reply with text only; do not use any tools. Reply with exactly: B-DONE"} +{"at": 1479, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "ff373ed9-cde2-4a49-b83f-09157a686ee8", "state": "queued", "uuid": "9438512a-5380-48da-8ddf-6adffc28cfbb", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 1479, "kind": "dispatch", "clientMessageId": "client-C", "sentUuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "text": "Reply with text only; do not use any tools. Reply with exactly: C-DONE"} +{"at": 1479, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "state": "queued", "uuid": "4f1b992a-26e8-43c5-af40-c3e5ef5707c8", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 1479, "kind": "control", "request": {"subtype": "cancel_async_message", "message_uuid": "ff373ed9-cde2-4a49-b83f-09157a686ee8"}} +{"at": 1479, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "ff373ed9-cde2-4a49-b83f-09157a686ee8", "state": "cancelled", "uuid": "f52f03d1-df02-432c-8bb6-c94062877e6f", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 1479, "kind": "control-answer", "response": {"cancelled": true}} +{"at": 10848, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_scrubbed02", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven\ntwelve\nthirteen\nfourteen\nfifteen\nsixteen\nseventeen\neighteen\nnineteen\ntwenty\ntwenty-one\ntwenty-two\ntwenty-three\ntwenty-four\ntwenty-five\ntwenty-six\ntwenty-seven\ntwenty-eight\ntwenty-nine\nthirty\nthirty-one\nthirty-two\nthirty-three\nthirty-four\nthirty-five\nthirty-six\nthirty-seven\nthirty-eight\nthirty-nine\nforty\nforty-one\nforty-two\nforty-three\nforty-four\nforty-five\nforty-six\nforty-seven\nforty-eight\nforty-nine\nfifty\nfifty-one\nfifty-two\nfifty-three\nfifty-four\nfifty-five\nfifty-six\nfifty-seven\nfifty-eight\nfifty-nine\nsixty\nsixty-one\nsixty-two\nsixty-three\nsixty-four\nsixty-five\nsixty-six\nsixty-seven\nsixty-eight\nsixty-nine\nseventy\nseventy-one\nseventy-two\nseventy-three\nseventy-four\nseventy-five\nseventy-six\nseventy-seven\nseventy-eight\nseventy-nine\neighty\neighty-one\neighty-two\neighty-three\neighty-four\neighty-five\neighty-six\neighty-seven\neighty-eight\neighty-nine\nninety\nninety-one\nninety-two\nninety-three\nninety-four\nninety-five\nninety-six\nninety-seven\nninety-eight\nninety-nine\none hundred\none hundred one\none hundred two\none hundred three\none hundred four\none hundred five\none hundred six\none hundred seven\none hundred eight\none hundred nine\none hundred ten\none hundred eleven\none hundred twelve\none hundred thirteen\none hundred fourteen\none hundred fifteen\none hundred sixteen\none hundred seventeen\none hundred eighteen\none hundred nineteen\none hundred twenty\none hundred twenty-one\none hundred twenty-two\none hundred twenty-three\none hundred twenty-four\none hundred twenty-five\none hundred twenty-six\none hundred twenty-seven\none hundred twenty-eight\none hundred twenty-nine\none hundred thirty\none hundred thirty-one\none hundred thirty-two\none hundred thirty-three\none hundred thirty-four\none hundred thirty-five\none hundred thirty-six\none hundred thirty-seven\none hundred thirty-eight\none hundred thirty-nine\none hundred forty\none hundred forty-one\none hundred forty-two\none hundred forty-three\none hundred forty-four\none hundred forty-five\none hundred forty-six\none hundred forty-seven\none hundred forty-eight\none hundred forty-nine\none hundred fifty\none hundred fifty-one\none hundred fifty-two\none hundred fifty-three\none hundred fifty-four\none hundred fifty-five\none hundred fifty-six\none hundred fifty-seven\none hundred fifty-eight\none hundred fifty-nine\none hundred sixty\none hundred sixty-one\none hundred sixty-two\none hundred sixty-three\none hundred sixty-four\none hundred sixty-five\none hundred sixty-six\none hundred sixty-seven\none hundred sixty-eight\none hundred sixty-nine\none hundred seventy\none hundred seventy-one\none hundred seventy-two\none hundred seventy-three\none hundred seventy-four\none hundred seventy-five\none hundred seventy-six\none hundred seventy-seven\none hundred seventy-eight\none hundred seventy-nine\none hundred eighty\none hundred eighty-one\none hundred eighty-two\none hundred eighty-three\none hundred eighty-four\none hundred eighty-five\none hundred eighty-six\none hundred eighty-seven\none hundred eighty-eight\none hundred eighty-nine\none hundred ninety\none hundred ninety-one\none hundred ninety-two\none hundred ninety-three\none hundred ninety-four\none hundred ninety-five\none hundred ninety-six\none hundred ninety-seven\none hundred ninety-eight\none hundred ninety-nine\ntwo hundred"}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 23004, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "output_tokens": 8, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "89d623dc-6a78-46fc-9123-ee06e2d61a6b", "timestamp": "2026-09-29T10:36:24.740Z", "request_id": "req_scrubbed01", "user_message_uuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "user_message_uuids": ["0e1f8935-c61a-480b-8877-72c10dd1adda"]}} +{"at": 10885, "kind": "frame", "frame": {"duration_api_ms": 11864, "stop_reason": "end_turn", "session_id": "00000000-0000-4000-8000-00000000c1a0", "total_cost_usd": 0.0398048, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 23004, "output_tokens": 1497, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}, "inference_geo": "not_available", "iterations": [{"input_tokens": 2, "output_tokens": 1497, "cache_read_input_tokens": 23004, "cache_creation_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "type": "message"}], "speed": "standard"}, "modelUsage": {"claude-opus-5-5[1m]": {"inputTokens": 1211, "outputTokens": 1518, "cacheReadInputTokens": 23004, "cacheCreationInputTokens": 0, "webSearchRequests": 0, "costUSD": 0.0398048, "contextWindow": 1000000, "maxOutputTokens": 128000, "thinkingTokens": 0, "canonicalModel": "claude-opus-5-5", "provider": "firstParty", "costBasis": "list"}}, "permission_denials": [], "terminal_reason": "completed", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": false, "num_turns": 1, "subtype": "success", "api_error_status": null, "result": "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven\ntwelve\nthirteen\nfourteen\nfifteen\nsixteen\nseventeen\neighteen\nnineteen\ntwenty\ntwenty-one\ntwenty-two\ntwenty-three\ntwenty-four\ntwenty-five\ntwenty-six\ntwenty-seven\ntwenty-eight\ntwenty-nine\nthirty\nthirty-one\nthirty-two\nthirty-three\nthirty-four\nthirty-five\nthirty-six\nthirty-seven\nthirty-eight\nthirty-nine\nforty\nforty-one\nforty-two\nforty-three\nforty-four\nforty-five\nforty-six\nforty-seven\nforty-eight\nforty-nine\nfifty\nfifty-one\nfifty-two\nfifty-three\nfifty-four\nfifty-five\nfifty-six\nfifty-seven\nfifty-eight\nfifty-nine\nsixty\nsixty-one\nsixty-two\nsixty-three\nsixty-four\nsixty-five\nsixty-six\nsixty-seven\nsixty-eight\nsixty-nine\nseventy\nseventy-one\nseventy-two\nseventy-three\nseventy-four\nseventy-five\nseventy-six\nseventy-seven\nseventy-eight\nseventy-nine\neighty\neighty-one\neighty-two\neighty-three\neighty-four\neighty-five\neighty-six\neighty-seven\neighty-eight\neighty-nine\nninety\nninety-one\nninety-two\nninety-three\nninety-four\nninety-five\nninety-six\nninety-seven\nninety-eight\nninety-nine\none hundred\none hundred one\none hundred two\none hundred three\none hundred four\none hundred five\none hundred six\none hundred seven\none hundred eight\none hundred nine\none hundred ten\none hundred eleven\none hundred twelve\none hundred thirteen\none hundred fourteen\none hundred fifteen\none hundred sixteen\none hundred seventeen\none hundred eighteen\none hundred nineteen\none hundred twenty\none hundred twenty-one\none hundred twenty-two\none hundred twenty-three\none hundred twenty-four\none hundred twenty-five\none hundred twenty-six\none hundred twenty-seven\none hundred twenty-eight\none hundred twenty-nine\none hundred thirty\none hundred thirty-one\none hundred thirty-two\none hundred thirty-three\none hundred thirty-four\none hundred thirty-five\none hundred thirty-six\none hundred thirty-seven\none hundred thirty-eight\none hundred thirty-nine\none hundred forty\none hundred forty-one\none hundred forty-two\none hundred forty-three\none hundred forty-four\none hundred forty-five\none hundred forty-six\none hundred forty-seven\none hundred forty-eight\none hundred forty-nine\none hundred fifty\none hundred fifty-one\none hundred fifty-two\none hundred fifty-three\none hundred fifty-four\none hundred fifty-five\none hundred fifty-six\none hundred fifty-seven\none hundred fifty-eight\none hundred fifty-nine\none hundred sixty\none hundred sixty-one\none hundred sixty-two\none hundred sixty-three\none hundred sixty-four\none hundred sixty-five\none hundred sixty-six\none hundred sixty-seven\none hundred sixty-eight\none hundred sixty-nine\none hundred seventy\none hundred seventy-one\none hundred seventy-two\none hundred seventy-three\none hundred seventy-four\none hundred seventy-five\none hundred seventy-six\none hundred seventy-seven\none hundred seventy-eight\none hundred seventy-nine\none hundred eighty\none hundred eighty-one\none hundred eighty-two\none hundred eighty-three\none hundred eighty-four\none hundred eighty-five\none hundred eighty-six\none hundred eighty-seven\none hundred eighty-eight\none hundred eighty-nine\none hundred ninety\none hundred ninety-one\none hundred ninety-two\none hundred ninety-three\none hundred ninety-four\none hundred ninety-five\none hundred ninety-six\none hundred ninety-seven\none hundred ninety-eight\none hundred ninety-nine\ntwo hundred", "ttft_ms": 10419, "type": "result", "duration_ms": 10453, "uuid": "922da184-ba96-4a9e-a133-27dd7f45818f", "ttft_stream_ms": 1063, "time_to_request_ms": 71, "first_content_frame_ms": 1063, "user_message_uuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "user_message_uuids": ["0e1f8935-c61a-480b-8877-72c10dd1adda"], "request_sent_wall_ms": 1790678174393, "queued_turn_count": 0, "result_index": 0}} +{"at": 10886, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "state": "completed", "uuid": "3df6d400-c72e-4521-92f4-cc286ab007b7", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 10886, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "state": "started", "uuid": "e7087639-8080-4803-b8e4-638cc31888e5", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 10904, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "6fce32e9-e653-477f-b6b6-4d10267b2991", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 10904, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "e546544b-76be-4d78-9094-9a8329a6a756"}} +{"at": 12309, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Reply with text only; do not use any tools. Reply with exactly: C-DONE"}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "timestamp": "2026-09-29T10:36:24.795Z", "isReplay": true}} +{"at": 12310, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_scrubbed03", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "C-DONE"}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 4, "cache_creation_input_tokens": 9027, "cache_read_input_tokens": 15502, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 9027}, "output_tokens": 7, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "63316a0d-face-40ab-b7c5-a2f3c7aef73b", "timestamp": "2026-09-29T10:36:26.216Z", "request_id": "req_scrubbed02", "user_message_uuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "user_message_uuids": ["19ec4a51-3c43-4197-9305-f7e6e5ae96c5"]}} +{"at": 12331, "kind": "frame", "frame": {"duration_api_ms": 13267, "stop_reason": "end_turn", "session_id": "00000000-0000-4000-8000-00000000c1a0", "total_cost_usd": 0.11527720000000001, "usage": {"input_tokens": 4, "cache_creation_input_tokens": 9027, "cache_read_input_tokens": 15502, "output_tokens": 7, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 9027, "ephemeral_5m_input_tokens": 0}, "inference_geo": "not_available", "iterations": [{"input_tokens": 4, "output_tokens": 7, "cache_read_input_tokens": 15502, "cache_creation_input_tokens": 9027, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 9027}, "type": "message"}], "speed": "standard"}, "modelUsage": {"claude-opus-5-5[1m]": {"inputTokens": 1215, "outputTokens": 1525, "cacheReadInputTokens": 38506, "cacheCreationInputTokens": 9027, "webSearchRequests": 0, "costUSD": 0.11527720000000001, "contextWindow": 1000000, "maxOutputTokens": 128000, "thinkingTokens": 0, "canonicalModel": "claude-opus-5-5", "provider": "firstParty", "costBasis": "list"}}, "permission_denials": [], "terminal_reason": "completed", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": false, "num_turns": 1, "subtype": "success", "api_error_status": null, "result": "C-DONE", "ttft_ms": 1424, "type": "result", "duration_ms": 1438, "uuid": "fb897d43-901b-4f47-a701-d4d5f4cf8562", "ttft_stream_ms": 1423, "time_to_request_ms": 23, "first_content_frame_ms": 1423, "user_message_uuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "user_message_uuids": ["19ec4a51-3c43-4197-9305-f7e6e5ae96c5"], "request_sent_wall_ms": 1790678184816, "queued_turn_count": 0, "result_index": 1}} +{"at": 12331, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "state": "completed", "uuid": "bccf83ae-642e-42f8-8a33-1448870fb137", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 12332, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "idle", "uuid": "5277a376-53d0-4a5d-b6f6-7669baccb91f", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} diff --git a/src/main/claude/__fixtures__/claude-lifecycle-capture-cancel-async.jsonl b/src/main/claude/__fixtures__/claude-lifecycle-capture-cancel-async.jsonl new file mode 100644 index 00000000000..69284b8119d --- /dev/null +++ b/src/main/claude/__fixtures__/claude-lifecycle-capture-cancel-async.jsonl @@ -0,0 +1,19 @@ +{"at": 0, "kind": "meta", "scenario": "cancel-async", "providerSessionId": "00000000-0000-4000-8000-00000000c1a0", "note": "Recorded against Claude CLI 2.1.280 through the Agent SDK transport; scrubbed: session id, home paths, API request and message ids, initialize answer; stream_event frames dropped."} +{"at": 207, "kind": "frame", "frame": {"type": "system", "subtype": "hook_started", "hook_id": "6c03b0d7-8119-4015-bd43-9c83fa47119b", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "uuid": "ce5037b8-9e84-4b39-a598-cc4cea7123b4", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 225, "kind": "frame", "frame": {"type": "system", "subtype": "hook_response", "hook_id": "6c03b0d7-8119-4015-bd43-9c83fa47119b", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "exit_code": 0, "outcome": "success", "uuid": "93e4cdab-4976-4646-a5ce-9544936bfe6e", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 256, "kind": "dispatch", "clientMessageId": "client-A", "sentUuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "text": "Reply with text only; do not use any tools. Write the numbers from one to two hundred in English words, one per line, lowercase, nothing else."} +{"at": 261, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "running", "uuid": "3746879b-3bb8-47f8-a371-9544bc5812a5", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 261, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "state": "queued", "uuid": "a75f748a-f917-4f8a-afea-28f022859753", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 261, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "state": "started", "uuid": "e204ce9a-ee8a-4b64-ba12-eff1d33c52d1", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 301, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "dad169fc-bced-4e8f-a40f-f3ea0537c763", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 302, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "f33c5acc-1608-453c-86a0-2568002a67d0"}} +{"at": 1096, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Reply with text only; do not use any tools. Write the numbers from one to two hundred in English words, one per line, lowercase, nothing else."}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "timestamp": "2026-09-29T10:35:47.486Z", "isReplay": true}} +{"at": 1097, "kind": "dispatch", "clientMessageId": "client-B", "sentUuid": "1ebf4091-6507-4b8b-838b-93c95875eded", "text": "Reply with text only; do not use any tools. Reply with exactly: B-DONE"} +{"at": 1097, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "1ebf4091-6507-4b8b-838b-93c95875eded", "state": "queued", "uuid": "a021a2ec-59b0-4470-ab40-5c3af8b9dcb0", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 1097, "kind": "control", "request": {"subtype": "cancel_async_message", "message_uuid": "1ebf4091-6507-4b8b-838b-93c95875eded"}} +{"at": 1098, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "1ebf4091-6507-4b8b-838b-93c95875eded", "state": "cancelled", "uuid": "75187135-edfc-4738-8890-3c5204d319f1", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 1098, "kind": "control-answer", "response": {"cancelled": true}} +{"at": 10491, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_scrubbed02", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven\ntwelve\nthirteen\nfourteen\nfifteen\nsixteen\nseventeen\neighteen\nnineteen\ntwenty\ntwenty-one\ntwenty-two\ntwenty-three\ntwenty-four\ntwenty-five\ntwenty-six\ntwenty-seven\ntwenty-eight\ntwenty-nine\nthirty\nthirty-one\nthirty-two\nthirty-three\nthirty-four\nthirty-five\nthirty-six\nthirty-seven\nthirty-eight\nthirty-nine\nforty\nforty-one\nforty-two\nforty-three\nforty-four\nforty-five\nforty-six\nforty-seven\nforty-eight\nforty-nine\nfifty\nfifty-one\nfifty-two\nfifty-three\nfifty-four\nfifty-five\nfifty-six\nfifty-seven\nfifty-eight\nfifty-nine\nsixty\nsixty-one\nsixty-two\nsixty-three\nsixty-four\nsixty-five\nsixty-six\nsixty-seven\nsixty-eight\nsixty-nine\nseventy\nseventy-one\nseventy-two\nseventy-three\nseventy-four\nseventy-five\nseventy-six\nseventy-seven\nseventy-eight\nseventy-nine\neighty\neighty-one\neighty-two\neighty-three\neighty-four\neighty-five\neighty-six\neighty-seven\neighty-eight\neighty-nine\nninety\nninety-one\nninety-two\nninety-three\nninety-four\nninety-five\nninety-six\nninety-seven\nninety-eight\nninety-nine\none hundred\none hundred one\none hundred two\none hundred three\none hundred four\none hundred five\none hundred six\none hundred seven\none hundred eight\none hundred nine\none hundred ten\none hundred eleven\none hundred twelve\none hundred thirteen\none hundred fourteen\none hundred fifteen\none hundred sixteen\none hundred seventeen\none hundred eighteen\none hundred nineteen\none hundred twenty\none hundred twenty-one\none hundred twenty-two\none hundred twenty-three\none hundred twenty-four\none hundred twenty-five\none hundred twenty-six\none hundred twenty-seven\none hundred twenty-eight\none hundred twenty-nine\none hundred thirty\none hundred thirty-one\none hundred thirty-two\none hundred thirty-three\none hundred thirty-four\none hundred thirty-five\none hundred thirty-six\none hundred thirty-seven\none hundred thirty-eight\none hundred thirty-nine\none hundred forty\none hundred forty-one\none hundred forty-two\none hundred forty-three\none hundred forty-four\none hundred forty-five\none hundred forty-six\none hundred forty-seven\none hundred forty-eight\none hundred forty-nine\none hundred fifty\none hundred fifty-one\none hundred fifty-two\none hundred fifty-three\none hundred fifty-four\none hundred fifty-five\none hundred fifty-six\none hundred fifty-seven\none hundred fifty-eight\none hundred fifty-nine\none hundred sixty\none hundred sixty-one\none hundred sixty-two\none hundred sixty-three\none hundred sixty-four\none hundred sixty-five\none hundred sixty-six\none hundred sixty-seven\none hundred sixty-eight\none hundred sixty-nine\none hundred seventy\none hundred seventy-one\none hundred seventy-two\none hundred seventy-three\none hundred seventy-four\none hundred seventy-five\none hundred seventy-six\none hundred seventy-seven\none hundred seventy-eight\none hundred seventy-nine\none hundred eighty\none hundred eighty-one\none hundred eighty-two\none hundred eighty-three\none hundred eighty-four\none hundred eighty-five\none hundred eighty-six\none hundred eighty-seven\none hundred eighty-eight\none hundred eighty-nine\none hundred ninety\none hundred ninety-one\none hundred ninety-two\none hundred ninety-three\none hundred ninety-four\none hundred ninety-five\none hundred ninety-six\none hundred ninety-seven\none hundred ninety-eight\none hundred ninety-nine\ntwo hundred"}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 23004, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "output_tokens": 8, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "16c7fded-8293-4480-a708-bae2361dc27c", "timestamp": "2026-09-29T10:35:57.675Z", "request_id": "req_scrubbed01", "user_message_uuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "user_message_uuids": ["f3cd976f-dc3f-4eca-8622-ebf4e5b1d679"]}} +{"at": 10532, "kind": "frame", "frame": {"duration_api_ms": 12016, "stop_reason": "end_turn", "session_id": "00000000-0000-4000-8000-00000000c1a0", "total_cost_usd": 0.0398048, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 23004, "output_tokens": 1497, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}, "inference_geo": "not_available", "iterations": [{"input_tokens": 2, "output_tokens": 1497, "cache_read_input_tokens": 23004, "cache_creation_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "type": "message"}], "speed": "standard"}, "modelUsage": {"claude-opus-5-5[1m]": {"inputTokens": 1211, "outputTokens": 1518, "cacheReadInputTokens": 23004, "cacheCreationInputTokens": 0, "webSearchRequests": 0, "costUSD": 0.0398048, "contextWindow": 1000000, "maxOutputTokens": 128000, "thinkingTokens": 0, "canonicalModel": "claude-opus-5-5", "provider": "firstParty", "costBasis": "list"}}, "permission_denials": [], "terminal_reason": "completed", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": false, "num_turns": 1, "subtype": "success", "api_error_status": null, "result": "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven\ntwelve\nthirteen\nfourteen\nfifteen\nsixteen\nseventeen\neighteen\nnineteen\ntwenty\ntwenty-one\ntwenty-two\ntwenty-three\ntwenty-four\ntwenty-five\ntwenty-six\ntwenty-seven\ntwenty-eight\ntwenty-nine\nthirty\nthirty-one\nthirty-two\nthirty-three\nthirty-four\nthirty-five\nthirty-six\nthirty-seven\nthirty-eight\nthirty-nine\nforty\nforty-one\nforty-two\nforty-three\nforty-four\nforty-five\nforty-six\nforty-seven\nforty-eight\nforty-nine\nfifty\nfifty-one\nfifty-two\nfifty-three\nfifty-four\nfifty-five\nfifty-six\nfifty-seven\nfifty-eight\nfifty-nine\nsixty\nsixty-one\nsixty-two\nsixty-three\nsixty-four\nsixty-five\nsixty-six\nsixty-seven\nsixty-eight\nsixty-nine\nseventy\nseventy-one\nseventy-two\nseventy-three\nseventy-four\nseventy-five\nseventy-six\nseventy-seven\nseventy-eight\nseventy-nine\neighty\neighty-one\neighty-two\neighty-three\neighty-four\neighty-five\neighty-six\neighty-seven\neighty-eight\neighty-nine\nninety\nninety-one\nninety-two\nninety-three\nninety-four\nninety-five\nninety-six\nninety-seven\nninety-eight\nninety-nine\none hundred\none hundred one\none hundred two\none hundred three\none hundred four\none hundred five\none hundred six\none hundred seven\none hundred eight\none hundred nine\none hundred ten\none hundred eleven\none hundred twelve\none hundred thirteen\none hundred fourteen\none hundred fifteen\none hundred sixteen\none hundred seventeen\none hundred eighteen\none hundred nineteen\none hundred twenty\none hundred twenty-one\none hundred twenty-two\none hundred twenty-three\none hundred twenty-four\none hundred twenty-five\none hundred twenty-six\none hundred twenty-seven\none hundred twenty-eight\none hundred twenty-nine\none hundred thirty\none hundred thirty-one\none hundred thirty-two\none hundred thirty-three\none hundred thirty-four\none hundred thirty-five\none hundred thirty-six\none hundred thirty-seven\none hundred thirty-eight\none hundred thirty-nine\none hundred forty\none hundred forty-one\none hundred forty-two\none hundred forty-three\none hundred forty-four\none hundred forty-five\none hundred forty-six\none hundred forty-seven\none hundred forty-eight\none hundred forty-nine\none hundred fifty\none hundred fifty-one\none hundred fifty-two\none hundred fifty-three\none hundred fifty-four\none hundred fifty-five\none hundred fifty-six\none hundred fifty-seven\none hundred fifty-eight\none hundred fifty-nine\none hundred sixty\none hundred sixty-one\none hundred sixty-two\none hundred sixty-three\none hundred sixty-four\none hundred sixty-five\none hundred sixty-six\none hundred sixty-seven\none hundred sixty-eight\none hundred sixty-nine\none hundred seventy\none hundred seventy-one\none hundred seventy-two\none hundred seventy-three\none hundred seventy-four\none hundred seventy-five\none hundred seventy-six\none hundred seventy-seven\none hundred seventy-eight\none hundred seventy-nine\none hundred eighty\none hundred eighty-one\none hundred eighty-two\none hundred eighty-three\none hundred eighty-four\none hundred eighty-five\none hundred eighty-six\none hundred eighty-seven\none hundred eighty-eight\none hundred eighty-nine\none hundred ninety\none hundred ninety-one\none hundred ninety-two\none hundred ninety-three\none hundred ninety-four\none hundred ninety-five\none hundred ninety-six\none hundred ninety-seven\none hundred ninety-eight\none hundred ninety-nine\ntwo hundred", "ttft_ms": 10214, "type": "result", "duration_ms": 10247, "uuid": "7aa2d6f4-d1dd-4749-ae09-7b0235246b51", "ttft_stream_ms": 834, "time_to_request_ms": 47, "first_content_frame_ms": 834, "user_message_uuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "user_message_uuids": ["f3cd976f-dc3f-4eca-8622-ebf4e5b1d679"], "request_sent_wall_ms": 1790678147508, "queued_turn_count": 0, "result_index": 0}} +{"at": 10532, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "state": "completed", "uuid": "28426f29-739b-48dc-94e8-63e85e197192", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 10533, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "idle", "uuid": "b8e0b928-4ded-4ba4-8a88-5a0c5517657a", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} diff --git a/src/main/claude/__fixtures__/claude-lifecycle-capture-interrupt-lost.jsonl b/src/main/claude/__fixtures__/claude-lifecycle-capture-interrupt-lost.jsonl new file mode 100644 index 00000000000..35230188f88 --- /dev/null +++ b/src/main/claude/__fixtures__/claude-lifecycle-capture-interrupt-lost.jsonl @@ -0,0 +1,20 @@ +{"at": 3, "kind": "meta", "scenario": "interrupt-lost", "providerSessionId": "00000000-0000-4000-8000-00000000c1a0", "note": "Recorded against Claude CLI 2.1.280 through the Agent SDK transport; scrubbed: session id, home paths, API request and message ids, initialize answer; stream_event frames dropped."} +{"at": 333, "kind": "frame", "frame": {"type": "system", "subtype": "hook_started", "hook_id": "1c957d67-f23b-4d51-969d-187cd2a57e59", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "uuid": "a8c39655-a598-42a7-943e-899b511643d9", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 362, "kind": "frame", "frame": {"type": "system", "subtype": "hook_response", "hook_id": "1c957d67-f23b-4d51-969d-187cd2a57e59", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "exit_code": 0, "outcome": "success", "uuid": "1bc17c32-c0d8-4c1d-ace0-03327bbcf13b", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 416, "kind": "dispatch", "clientMessageId": "client-A", "sentUuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "text": "Reply with text only; do not use any tools. Write the numbers from one to two hundred in English words, one per line, lowercase, nothing else."} +{"at": 421, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "running", "uuid": "ef60bf56-2954-4f1a-bfaf-502f4cf7e5f9", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 421, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "state": "queued", "uuid": "8f4de258-0da7-4ff7-a972-24c5d2b9e593", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 422, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "state": "started", "uuid": "e70550c4-18ff-4795-9ddf-9dd267e82b9e", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 488, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "31b93145-d113-4141-adb1-2174b07845cd", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 490, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "38344e0c-d00c-40dd-9da2-6cd73d7ad800"}} +{"at": 2520, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Reply with text only; do not use any tools. Write the numbers from one to two hundred in English words, one per line, lowercase, nothing else."}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "timestamp": "2026-09-29T10:35:25.741Z", "isReplay": true}} +{"at": 3136, "kind": "frame", "frame": {"type": "system", "subtype": "thinking_tokens", "estimated_tokens": 50, "estimated_tokens_delta": 50, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "d15945e1-6168-458e-9b2d-49f427327af2", "user_message_uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda"}} +{"at": 3137, "kind": "dispatch", "clientMessageId": "client-B", "sentUuid": "0ca6fc96-94c3-4462-9524-a8341729ab91", "text": "Reply with text only; do not use any tools. Reply with exactly: B-DONE"} +{"at": 3137, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "0ca6fc96-94c3-4462-9524-a8341729ab91", "state": "queued", "uuid": "f6865348-9b42-410c-96e8-5bf01e1d68de", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 3137, "kind": "control", "request": {"subtype": "interrupt", "cancel_queued": true}} +{"at": 3138, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "0ca6fc96-94c3-4462-9524-a8341729ab91", "state": "cancelled", "uuid": "d4cf2dfc-2530-4fa6-9f47-efab0874177a", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 3138, "kind": "control-answer", "response": {"still_queued": [], "cancelled": ["0ca6fc96-94c3-4462-9524-a8341729ab91"]}} +{"at": 3139, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "[Request interrupted by user]"}]}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "ce4fe796-391f-4acc-b37c-49b668682592", "timestamp": "2026-09-29T10:35:28.422Z"}} +{"at": 3149, "kind": "frame", "frame": {"duration_api_ms": 2176, "stop_reason": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "total_cost_usd": 0.005256, "usage": {"output_tokens_details": {"thinking_tokens": 0}, "input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 0, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}, "inference_geo": "", "iterations": [], "speed": "standard"}, "modelUsage": {"claude-opus-5-5[1m]": {"inputTokens": 1209, "outputTokens": 21, "cacheReadInputTokens": 0, "cacheCreationInputTokens": 0, "webSearchRequests": 0, "costUSD": 0.005256, "contextWindow": 1000000, "maxOutputTokens": 128000, "thinkingTokens": 0, "canonicalModel": "claude-opus-5-5", "provider": "firstParty", "costBasis": "list"}}, "permission_denials": [], "terminal_reason": "aborted_streaming", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": true, "num_turns": 2, "subtype": "error_during_execution", "errors": ["[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=null"], "user_message_uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "type": "result", "duration_ms": 2717, "uuid": "9551c994-3a32-4f9a-b205-be27ae648b93", "user_message_uuids": ["d0649b29-11d5-45e7-9034-6fb789e78eda"], "queued_turn_count": 0, "result_index": 0}} +{"at": 3150, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "state": "cancelled", "uuid": "24526d3c-00cc-488a-b899-239756190772", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 3150, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "idle", "uuid": "5de7c47b-2d4f-4f66-b301-d5e4de20e787", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} diff --git a/src/main/claude/claude-adapter-capture-replay.test.ts b/src/main/claude/claude-adapter-capture-replay.test.ts new file mode 100644 index 00000000000..8e877a135a8 --- /dev/null +++ b/src/main/claude/claude-adapter-capture-replay.test.ts @@ -0,0 +1,233 @@ +// Verbatim replay of FULL captures recorded through Orca's own adapter against +// the real CLI (`__fixtures__/claude-adapter-capture-*.jsonl`): every frame the +// adapter saw — hook proof, per-cycle init, command_lifecycle, session-state, +// stream events — in the recorded order, with the recorded dispatch points. +// The oracle is the provider's own membership fact: each result's +// `user_message_uuids` must equal the sends that settled into the turn that +// result closed. Nothing here asserts design internals, so a rule change or a +// CLI drift that breaks membership fails these tests whatever the mechanism. + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it, vi, type Mock } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalMessageItem +} from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' +import type { + ClaudeLateDispatchOutcome, + ClaudeStructuredSessionAdapterDeps +} from './claude-structured-session-state' +import { + fakeClaude, + identityFor, + PROVIDER_SESSION_ID +} from './claude-structured-session-test-support' + +type CapturedEvent = + | { at: number; kind: 'meta'; providerSessionId: string } + | { at: number; kind: 'frame'; frame: Record } + | { at: number; kind: 'dispatch'; clientMessageId: string; sentUuid: string; text: string } + | { at: number; kind: 'end' } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function decodeCapturedEvent(value: unknown): CapturedEvent { + if (!isRecord(value) || typeof value.at !== 'number' || typeof value.kind !== 'string') { + throw new Error('capture line is not a recorded event') + } + if (value.kind === 'meta' && typeof value.providerSessionId === 'string') { + return { at: value.at, kind: 'meta', providerSessionId: value.providerSessionId } + } + if (value.kind === 'frame' && isRecord(value.frame)) { + return { at: value.at, kind: 'frame', frame: value.frame } + } + if ( + value.kind === 'dispatch' && + typeof value.clientMessageId === 'string' && + typeof value.sentUuid === 'string' && + typeof value.text === 'string' + ) { + return { + at: value.at, + kind: 'dispatch', + clientMessageId: value.clientMessageId, + sentUuid: value.sentUuid, + text: value.text + } + } + if (value.kind === 'end') { + return { at: value.at, kind: 'end' } + } + throw new Error(`capture line has unknown kind: ${String(value.kind)}`) +} + +function loadCapture(name: string): CapturedEvent[] { + const path = join(__dirname, '__fixtures__', `claude-adapter-capture-${name}.jsonl`) + return readFileSync(path, 'utf8') + .trim() + .split('\n') + .map((line) => decodeCapturedEvent(JSON.parse(line))) +} + +type Replay = { + settled: Mock + /** Final revision per turn id, in first-open order. */ + finalTurns: Map>> + everInterrupted: boolean + /** Each result frame's `user_message_uuids`, mapped to live dispatch uuids. */ + resultMemberships: string[][] + /** clientMessageId -> provider uuid its delivery settled under. */ + settledUuids: Map + /** Live dispatch uuid -> turn id its send landed in (turn open at settlement). */ + liveUuidByClient: Map +} + +async function replayCapture(name: string): Promise { + const capture = loadCapture(name) + let nowMs = 1_700_000_200_000 + const finalTurns = new Map>>() + let everInterrupted = false + const sink: StructuredAgentSessionEventSink = { + appendItem: (_identity, body: AgentJournalItemBody) => { + const turn = readAgentJournalTurn(body) + if (turn) { + finalTurns.set(turn.turnId, turn) + everInterrupted ||= turn.state === 'interrupted' + } + }, + appendTombstone: () => {}, + publish: () => {} + } + const settled = vi.fn<(input: { sessionId: string } & ClaudeLateDispatchOutcome) => void>() + // The capture supplies every frame, startup proof included. + const claude = fakeClaude({ initProof: 'none', replayUuid: null }) + const deps: ClaudeStructuredSessionAdapterDeps = { + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: '/work/repo', + claudeConfigDir: '/accounts/claude', + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: false, + continuesChain: false + }), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1, + now: () => nowMs, + persistHandle: async () => {}, + onDispatchSettledLate: settled + } + const adapter = new ClaudeStructuredSessionAdapter(deps) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', events: sink }) + + // Captured uuids -> the uuids the live dispatches mint during this replay. + const uuidMap = new Map() + const capturedSessionId = capture.flatMap((event) => + event.kind === 'meta' ? [event.providerSessionId] : [] + )[0] + const mapFrame = (frame: Record): Record => { + let text = JSON.stringify(frame) + for (const [captured, live] of uuidMap) { + text = text.replaceAll(captured, live) + } + if (capturedSessionId) { + text = text.replaceAll(capturedSessionId, PROVIDER_SESSION_ID) + } + const mapped: unknown = JSON.parse(text) + if (!isRecord(mapped)) { + throw new Error('mapped frame is not a record') + } + return mapped + } + const resultMemberships: string[][] = [] + let startedAwaited = false + for (const event of capture) { + nowMs = 1_700_000_200_000 + event.at + if (event.kind === 'dispatch') { + if (!startedAwaited) { + // The proof frames have been delivered by now; startup can settle. + await adapter.awaitStarted('session-1') + startedAwaited = true + } + const body: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: event.text }] + } + const before = new Set(uuidMap.values()) + const outcome = await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: event.clientMessageId, + body, + requestedAt: nowMs, + fence: 7 + }) + expect(outcome).toEqual({ state: 'admitted' }) + const sent = claude.connections[0]!.sent.at(-1)?.uuid + if (typeof sent !== 'string' || before.has(sent)) { + throw new Error('replay could not read the dispatched uuid') + } + uuidMap.set(event.sentUuid, sent) + } else if (event.kind === 'frame') { + const frame = mapFrame(event.frame) + if (frame.type === 'result') { + const members = Array.isArray(frame.user_message_uuids) ? frame.user_message_uuids : [] + resultMemberships.push(members.filter((m): m is string => typeof m === 'string')) + } + claude.connections[0]!.handlers.onMessage?.(frame) + } + } + const settledUuids = new Map() + for (const [outcome] of settled.mock.calls) { + if ('providerIdentity' in outcome && outcome.providerIdentity.provider === 'claude') { + settledUuids.set(outcome.clientMessageId, outcome.providerIdentity.uuid) + } + } + const liveUuidByClient = new Map() + for (const event of capture) { + if (event.kind === 'dispatch') { + liveUuidByClient.set(event.clientMessageId, uuidMap.get(event.sentUuid)!) + } + } + return { settled, finalTurns, everInterrupted, resultMemberships, settledUuids, liveUuidByClient } +} + +describe.each(['fold', 'early-steer', 'miss'])('adapter capture replay: %s', (name) => { + it('keeps turn membership equal to each result’s user_message_uuids', async () => { + const replay = await replayCapture(name) + + // One turn per result, each opened by that result's first member, and no + // turn ever marked interrupted (no capture interrupts one). + expect([...replay.finalTurns.keys()]).toEqual( + replay.resultMemberships.map((members) => members[0]) + ) + expect(replay.everInterrupted).toBe(false) + for (const turn of replay.finalTurns.values()) { + expect(turn.state).toBe('completed') + } + + // Every send settled accepted under a provider uuid named by exactly the + // result whose turn it belongs to — the provider's membership fact. + const allMembers = new Set(replay.resultMemberships.flat()) + for (const [clientMessageId, liveUuid] of replay.liveUuidByClient) { + expect(replay.settledUuids.get(clientMessageId)).toBe(liveUuid) + expect(allMembers.has(liveUuid)).toBe(true) + } + for (const [index, members] of replay.resultMemberships.entries()) { + for (const member of members) { + // A member of result i must not have opened any OTHER turn. + const owner = [...replay.finalTurns.keys()].indexOf(member) + if (owner !== -1) { + expect(owner).toBe(index) + } + } + } + }) +}) diff --git a/src/main/claude/claude-agent-sdk-control-requests.test.ts b/src/main/claude/claude-agent-sdk-control-requests.test.ts index f76650d8151..01e822063e2 100644 --- a/src/main/claude/claude-agent-sdk-control-requests.test.ts +++ b/src/main/claude/claude-agent-sdk-control-requests.test.ts @@ -24,3 +24,29 @@ describe('createClaudeControlSurface stopTask', () => { expect(stopTask).toHaveBeenCalledTimes(2) }) }) + +/** A query exposing only the cancel method, as the surface reads nothing else for it. */ +function queryWithCancel(cancelAsyncMessage?: (uuid: string) => Promise): Query { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: cancelAsyncMessage reads only this member. + return (cancelAsyncMessage ? { cancelAsyncMessage } : {}) as unknown as Query +} + +describe('createClaudeControlSurface cancelAsyncMessage', () => { + it('reports a withdrawal only when the CLI answers cancelled: true', async () => { + // An older CLI answers with an empty success, which carries no `cancelled`. + const cancelAsyncMessage = vi + .fn<(uuid: string) => Promise>() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(undefined) + const controls = createClaudeControlSurface(queryWithCancel(cancelAsyncMessage)) + + await expect(controls.cancelAsyncMessage('queued-1')).resolves.toBe(true) + await expect(controls.cancelAsyncMessage('queued-2')).resolves.toBe(false) + await expect(controls.cancelAsyncMessage('queued-3')).resolves.toBe(false) + expect(cancelAsyncMessage.mock.calls).toEqual([['queued-1'], ['queued-2'], ['queued-3']]) + await expect( + createClaudeControlSurface(queryWithCancel()).cancelAsyncMessage('queued-4') + ).resolves.toBe(false) + }) +}) diff --git a/src/main/claude/claude-agent-sdk-control-requests.ts b/src/main/claude/claude-agent-sdk-control-requests.ts index f165822c937..d3313147102 100644 --- a/src/main/claude/claude-agent-sdk-control-requests.ts +++ b/src/main/claude/claude-agent-sdk-control-requests.ts @@ -94,7 +94,8 @@ export type ClaudeControlSurface = { interrupt: ( options?: ClaudeControlOptions & { cancelQueued?: boolean } ) => Promise - cancelAsyncMessage: (uuid: string, options?: ClaudeControlOptions) => Promise + /** True only when the CLI confirms it withdrew that message before it ran. */ + cancelAsyncMessage: (uuid: string, options?: ClaudeControlOptions) => Promise setModel: (model: string | undefined, options?: ClaudeControlOptions) => Promise setPermissionMode: (mode: PermissionMode, options?: ClaudeControlOptions) => Promise applyFlagSettings: ( @@ -131,9 +132,9 @@ export function createClaudeControlSurface(query: Query): ClaudeControlSurface { const cancel = claudeQueryAsyncCanceller(query) return cancel ? runClaudeControl('cancel_async_message', () => cancel(uuid), options?.timeoutMs).then( - () => {} + (cancelled) => cancelled === true ) - : Promise.resolve() + : Promise.resolve(false) }, setModel: (model, options) => runClaudeControl('set_model', () => query.setModel(model), options?.timeoutMs).then(() => {}), diff --git a/src/main/claude/claude-agent-sdk-exit-proof.test.ts b/src/main/claude/claude-agent-sdk-exit-proof.test.ts index 3f15f8e8fca..f9412a10488 100644 --- a/src/main/claude/claude-agent-sdk-exit-proof.test.ts +++ b/src/main/claude/claude-agent-sdk-exit-proof.test.ts @@ -432,8 +432,8 @@ describe('claude child tree reaper', () => { const child = mockChild() // Reap #1 completed and saw a descendant alive at its deadline; the root then // left on its own and the re-verification on a loaded host could not read the - // table. "Could not look" must not erase "was seen alive": the lease release - // gate is exactly the pair this distinguishes. + // table. "Could not look" must not erase "was seen alive": the report never + // calls a survivor gone. const terminateDescendants = vi .fn() .mockResolvedValueOnce('live') diff --git a/src/main/claude/claude-agent-sdk-exit-proof.ts b/src/main/claude/claude-agent-sdk-exit-proof.ts index 17533f87a70..883bf8efd6a 100644 --- a/src/main/claude/claude-agent-sdk-exit-proof.ts +++ b/src/main/claude/claude-agent-sdk-exit-proof.ts @@ -26,7 +26,7 @@ import { /** * A later reap may only raise the latched verdict. An observed exit is final, and * a descendant seen alive at a deadline is never forgotten by a later look that - * could not read the table: the lease gate discriminates on exactly that pair. + * could not read the table, so the report never calls a survivor gone. */ const TREE_VERDICT_TRUST: Record = { unverifiable: 0, @@ -89,8 +89,8 @@ export type ClaudeChildTreeReaper = { reap(): Promise /** * `unverifiable` until a reap observes otherwise. `exited` is the only verdict - * that lets a close release the lease; `live` names a descendant that was seen - * still running, which no later caller may collapse into "unknown". + * that proves a close; `live` names a descendant that was seen still running, + * which no later caller may collapse into "unknown". */ readonly treeVerdict: DescendantTreeVerdict } diff --git a/src/main/claude/claude-agent-sdk-import-boundary.test.ts b/src/main/claude/claude-agent-sdk-import-boundary.test.ts index f1a38466d41..421e4a9c719 100644 --- a/src/main/claude/claude-agent-sdk-import-boundary.test.ts +++ b/src/main/claude/claude-agent-sdk-import-boundary.test.ts @@ -1,5 +1,4 @@ -import { existsSync, readFileSync, statSync } from 'node:fs' -import { dirname, join, relative, resolve } from 'node:path' +import { resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { spawnProcess } from '../../shared/child-process/run-process' @@ -9,115 +8,14 @@ import { spawnProcess } from '../../shared/child-process/run-process' * A user who never leaves the terminal/TUI Claude path must not pay for the SDK: * importing it evaluates a package that rewrites * `process.env.NoDefaultCurrentDirectoryInExePath`, changing how Windows resolves - * executables for every later subprocess, and a missing or incompatible install - * would take normal runtime startup down with it. The ordinary - * `OrcaRuntimeService` graph reaches the Claude transport module, so only a - * deferred import keeps that boundary — and only a walk of the real import graph - * keeps the next static import from quietly restoring it. + * executables for every later subprocess. Loading the structured runtime must not + * trip that rewrite, which is only true while the SDK stays behind a deferred + * import inside the session path. */ const SDK_PACKAGE = '@anthropic-ai/claude-agent-sdk' const REPO_ROOT = resolve(__dirname, '..', '..', '..') -/** The Electron main entry: everything the app loads before any session exists. */ -const ROOT = 'src/main/index.ts' -/** Proof the walk goes all the way into the Claude transport rather than stopping short. */ -const TRANSPORT_MODULE = 'src/main/claude/claude-stream-json-connection.ts' - -/** - * Static, value-carrying specifiers only, read statement by statement so a - * multi-line `import { ... } from '...'` counts. `import type` is erased before - * the module ever loads and a bare `import(...)` is the deferral this guards, so - * neither is an edge the runtime traverses at load time. - */ -const STATEMENT_START = /^\s*(?:import|export)\b/ -const TYPE_ONLY = /^\s*(?:import|export)\s+type\b/ -const FROM_SPECIFIER = /(?:^|\s)from\s*['"]([^'"]+)['"]/ -const SIDE_EFFECT_IMPORT = /^\s*import\s*['"]([^'"]+)['"]/ -/** An import statement never spans more lines than its longest specifier list. */ -const MAX_STATEMENT_LINES = 60 - -function readSpecifiers(source: string): string[] { - const lines = source.split('\n') - const found: string[] = [] - for (let index = 0; index < lines.length; index += 1) { - const first = lines[index] as string - if (!STATEMENT_START.test(first) || TYPE_ONLY.test(first)) { - continue - } - const sideEffect = SIDE_EFFECT_IMPORT.exec(first) - if (sideEffect) { - found.push(sideEffect[1] as string) - continue - } - for (let scan = index; scan < Math.min(lines.length, index + MAX_STATEMENT_LINES); scan += 1) { - if (scan > index && STATEMENT_START.test(lines[scan] as string)) { - break - } - const specifier = FROM_SPECIFIER.exec(lines[scan] as string) - if (specifier) { - found.push(specifier[1] as string) - break - } - } - } - return found -} - -/** Resolve a relative specifier the way the bundler does; unresolvable means not a module. */ -function resolveRelative(fromFile: string, specifier: string): string | null { - const base = join(dirname(fromFile), specifier) - for (const candidate of [base, `${base}.ts`, `${base}.tsx`, join(base, 'index.ts')]) { - if (existsSync(candidate) && statSync(candidate).isFile()) { - return candidate - } - } - return null -} - -function walkStaticImports(rootFile: string): { visited: Set; sdkImporters: string[] } { - const visited = new Set() - const sdkImporters: string[] = [] - const queue = [resolve(REPO_ROOT, rootFile)] - while (queue.length > 0) { - const file = queue.pop() as string - const key = relative(REPO_ROOT, file).split('\\').join('/') - if (visited.has(key)) { - continue - } - visited.add(key) - for (const specifier of readSpecifiers(readFileSync(file, 'utf8'))) { - if (specifier === SDK_PACKAGE || specifier.startsWith(`${SDK_PACKAGE}/`)) { - sdkImporters.push(key) - continue - } - if (!specifier.startsWith('.')) { - continue - } - const target = resolveRelative(file, specifier) - if (target) { - queue.push(target) - } - } - } - return { visited, sdkImporters } -} - describe('claude agent SDK import boundary', () => { - const walk = walkStaticImports(ROOT) - - it('walks a graph deep enough to reach the Claude transport', () => { - // Without this the guard passes for the wrong reason the moment the walk breaks. - expect(walk.visited.size).toBeGreaterThan(500) - expect([...walk.visited]).toContain(TRANSPORT_MODULE) - }) - - it('never reaches the SDK through a static import from the main entry', () => { - expect( - walk.sdkImporters, - `${SDK_PACKAGE} must stay behind the structured-Claude boundary. Load it with a deferred import inside the session path instead.` - ).toEqual([]) - }) - it('leaves the Windows executable-search environment alone when the runtime loads', async () => { // A vitest file runs in its own fork, so this is a clean process; the ambient // value is cleared first because the developer's own shell may carry one. diff --git a/src/main/claude/claude-agent-sdk-process-spawn.test.ts b/src/main/claude/claude-agent-sdk-process-spawn.test.ts index cd3520cf6d5..1b6c192d433 100644 --- a/src/main/claude/claude-agent-sdk-process-spawn.test.ts +++ b/src/main/claude/claude-agent-sdk-process-spawn.test.ts @@ -4,14 +4,22 @@ import { describe, expect, it, vi } from 'vitest' import type { SpawnOptions as SdkSpawnOptions } from '@anthropic-ai/claude-agent-sdk' import { resolveSpawn, type spawnProcess } from '../../shared/child-process/run-process' import type { ProcessSpec } from '../../shared/child-process/process-spec' +import type * as ProviderSupervisor from '../codex/codex-app-server-posix-supervisor' +import { createProviderSpawnSpec } from '../codex/codex-app-server-posix-supervisor' import { createClaudeCodeProcessSpawn } from './claude-agent-sdk-process-spawn' +import { proveClaudeChildExitWithReaper } from './claude-child-exit-proof-ladder' + +vi.mock('../codex/codex-app-server-posix-supervisor', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, createProviderSpawnSpec: vi.fn(actual.createProviderSpawnSpec) } +}) type FakeChild = EventEmitter & { pid: number stdin: PassThrough stdout: PassThrough stderr: PassThrough - kill: ReturnType + kill: ReturnType boolean>> } function fakeSpawn() { @@ -20,7 +28,7 @@ function fakeSpawn() { child.stdin = new PassThrough() child.stdout = new PassThrough() child.stderr = new PassThrough() - child.kill = vi.fn(() => true) + child.kill = vi.fn((_signal?: NodeJS.Signals | number) => true) const specs: ProcessSpec[] = [] const spawnImpl = ((spec: ProcessSpec) => { specs.push(spec) @@ -43,7 +51,7 @@ function sdkOptions(overrides: Partial = {}): SdkSpawnOptions { describe('claude agent SDK process spawn', () => { it('routes the SDK spawn through Orca and retains the pid the lease adjudicates on', () => { const process = fakeSpawn() - const spawn = createClaudeCodeProcessSpawn(process.spawnImpl) + const spawn = createClaudeCodeProcessSpawn(process.spawnImpl, 'win32') expect(spawn.pid).toBeUndefined() expect(spawn.child).toBeNull() @@ -52,15 +60,103 @@ describe('claude agent SDK process spawn', () => { expect(child).toBe(process.child) expect(spawn.child).toBe(process.child) expect(spawn.pid).toBe(4321) + // Windows has no supervisor: Claude itself is the child. + expect(spawn.supervised).toBe(false) expect(process.specs[0]).toEqual({ program: '/usr/local/bin/claude', args: ['--output-format', 'stream-json'], cwd: '/work/repo', env: { PATH: '/usr/bin', CLAUDE_CONFIG_DIR: '/accounts/one' }, + detached: false, stdio: ['pipe', 'pipe', 'pipe'] }) }) + it.each(['darwin', 'linux'] as const)( + 'starts Claude under the provider supervisor on %s, which is then the pid the lease records', + (platform) => { + const process = fakeSpawn() + const spawn = createClaudeCodeProcessSpawn(process.spawnImpl, platform) + spawn.spawn(sdkOptions()) + + const [spec] = process.specs + if (!spec) { + throw new Error('the spawner never built a spec') + } + expect(spawn.supervised).toBe(true) + expect(spawn.pid).toBe(4321) + expect(spec.program).toBe(globalThis.process.execPath) + expect(spec.args?.[0]).toBe('-e') + expect(spec.detached).toBe(true) + expect(spec.cwd).toBe('/work/repo') + const supervisorSpec = JSON.parse( + Buffer.from(String(spec.env?.ORCA_PROVIDER_SUPERVISOR_SPEC), 'base64').toString() + ) + expect(supervisorSpec).toMatchObject({ + command: '/usr/local/bin/claude', + args: ['--output-format', 'stream-json'], + cwd: '/work/repo', + ownerPid: globalThis.process.pid + }) + // The supervisor passes its env to Claude minus its own two keys. + expect(spec.env).toMatchObject({ PATH: '/usr/bin', CLAUDE_CONFIG_DIR: '/accounts/one' }) + } + ) + + it.each([ + { platform: 'darwin', specSupervised: false }, + { platform: 'win32', specSupervised: true } + ] as const)( + 'stops Claude by the spawn spec\u2019s supervision on $platform, never the platform', + async ({ platform, specSupervised }) => { + const actual = await vi.importActual( + '../codex/codex-app-server-posix-supervisor' + ) + vi.mocked(createProviderSpawnSpec).mockImplementationOnce((...args) => ({ + ...actual.createProviderSpawnSpec(...args), + supervised: specSupervised + })) + const process = fakeSpawn() + const spawn = createClaudeCodeProcessSpawn(process.spawnImpl, platform) + spawn.spawn(sdkOptions()) + expect(spawn.supervised).toBe(specSupervised) + + let exited = false + let settle = (): void => {} + const exitPromise = new Promise((resolve) => { + settle = resolve + }) + // Claude leaves shortly after stdin ends, so the ladder never needs its forced rung. + process.child.stdin.on('finish', () => + setTimeout(() => { + exited = true + settle() + }, 10) + ) + const tree = { + capture: vi.fn(async () => {}), + reap: vi.fn(async () => 'exited' as const), + treeVerdict: 'exited' as const + } + await proveClaudeChildExitWithReaper( + { + child: process.child, + exitPromise, + exited: () => exited, + tree, + supervised: spawn.supervised + }, + () => tree + ) + // SIGTERM to an unsupervised Claude on Windows is TerminateProcess; a skipped one leaves it running. + if (specSupervised) { + expect(process.child.kill).toHaveBeenCalledWith('SIGTERM') + } else { + expect(process.child.kill).not.toHaveBeenCalled() + } + } + ) + it('keeps the child out of the SDK abort path so exit proof stays Orca-owned', () => { const process = fakeSpawn() const controller = new AbortController() @@ -86,7 +182,7 @@ describe('claude agent SDK process spawn', () => { it('hands a Windows .cmd shim to Orca\u2019s argument encoder', () => { const process = fakeSpawn() - createClaudeCodeProcessSpawn(process.spawnImpl).spawn( + createClaudeCodeProcessSpawn(process.spawnImpl, 'win32').spawn( sdkOptions({ command: 'C:\\Users\\dev\\AppData\\npm\\claude.cmd', args: ['--setting-sources=user,project,local', '--session-id', 'a b&c'] diff --git a/src/main/claude/claude-agent-sdk-process-spawn.ts b/src/main/claude/claude-agent-sdk-process-spawn.ts index a2b1ad7f158..bbb4b295aad 100644 --- a/src/main/claude/claude-agent-sdk-process-spawn.ts +++ b/src/main/claude/claude-agent-sdk-process-spawn.ts @@ -1,5 +1,6 @@ import type { SpawnOptions as ClaudeAgentSdkSpawnOptions } from '@anthropic-ai/claude-agent-sdk' import { spawnProcess } from '../../shared/child-process/run-process' +import { createProviderSpawnSpec } from '../codex/codex-app-server-posix-supervisor' /** Derived rather than imported: only src/shared/child-process may name node:child_process. */ type ClaudeCodeChild = ReturnType @@ -11,8 +12,13 @@ export type ClaudeCodeProcessSpawn = { spawn: (options: ClaudeAgentSdkSpawnOptions) => ClaudeCodeChild /** The retained child, so Orca keeps its own tree-kill and exit-proof ladder. Null until the SDK spawns. */ readonly child: ClaudeCodeChild | null - /** Ownership proof: the durable lease adjudicates on this pid plus start time plus the spawn token. */ + /** + * Ownership proof: the durable lease adjudicates on this pid plus start time plus the spawn + * token. On POSIX it is the provider supervisor's, which outlives Claude by construction. + */ readonly pid: number | undefined + /** The spawn spec's verdict, so the close ladder never re-decides it. False until the SDK spawns. */ + readonly supervised: boolean readonly stderrTail: string } @@ -31,24 +37,41 @@ function definedEnv(env: Record): Record { + const spec = createProviderSpawnSpec( + { + command: options.command, + args: [...options.args], + ...(options.cwd === undefined ? {} : { cwd: options.cwd }) + }, + definedEnv(options.env), + platform + ) // Why `options.signal` is dropped: it would let the SDK kill the child outside // Orca's ladder, and close() may never report an exit it did not observe. const spawned = spawnImpl({ - program: options.command, - args: [...options.args], - ...(options.cwd === undefined ? {} : { cwd: options.cwd }), - env: definedEnv(options.env), + program: spec.program, + args: spec.args, + cwd: spec.cwd, + env: spec.env, + detached: spec.detached, stdio: ['pipe', 'pipe', 'pipe'] }) child = spawned + supervised = spec.supervised // The SDK drains stderr only for its own local spawn, so a custom spawner must: // otherwise the child blocks on a full pipe and exit errors lose their tail. spawned.stderr.setEncoding('utf8').on('data', (chunk: string) => { @@ -62,6 +85,9 @@ export function createClaudeCodeProcessSpawn( get pid() { return child?.pid }, + get supervised() { + return supervised + }, get stderrTail() { return stderrTail } diff --git a/src/main/claude/claude-api-retry-idle-sweep.test.ts b/src/main/claude/claude-api-retry-idle-sweep.test.ts new file mode 100644 index 00000000000..fc5d234ec87 --- /dev/null +++ b/src/main/claude/claude-api-retry-idle-sweep.test.ts @@ -0,0 +1,132 @@ +// A Claude that keeps retrying a refused request is working, so the idle sweep must not stop it. +// Every retry frame's publish is the activity the sweep reads, even though a run writes one row. +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { STRUCTURED_AGENT_SESSION_IDLE_MS } from '../native-chat/agent-session-wire/structured-agent-session-idle-sweep' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + resetHostTestOperationIds +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +const SWEEP_MS = 5 +const RETRY_GAP_MS = 10 * 60_000 + +let root: string +let host: StructuredAgentSessionHost +let sink: StructuredAgentSessionEventSink | null +let closeSession: Mock> +let clock: number + +function apiRetry(attempt: number): Record { + return { + type: 'system', + subtype: 'api_retry', + attempt, + max_retries: 10, + retry_delay_ms: 600_000, + error_status: 429, + error: 'rate_limit', + session_id: 'provider-1', + uuid: `9b2f6a1e-0c4d-4e7a-8f3b-00000000000${attempt}` + } +} + +/** Long enough for many sweep ticks, so "still open" means the sweep declined. */ +function waitOutSeveralSweeps(): Promise { + return new Promise((resolve) => setTimeout(resolve, SWEEP_MS * 20)) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-retry-sweep-')) + resetHostTestOperationIds() + sink = null + clock = NOW + closeSession = vi.fn(async () => true) + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const adapter: StructuredAgentSessionAdapter = { + acquire: async ({ fence, spawnToken, events }) => { + sink = events ?? null + return { + process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW - 1_000, spawnToken }, + acquisitionGeneration: 'generation-1', + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + } + }, + closeSession, + releaseAcquisition: async () => true, + dispatch: async () => ({ state: 'admitted' }), + cancelTurn: async () => ({ cancelled: false }), + answerPrompt: async () => undefined, + setOption: async () => undefined + } + host = new StructuredAgentSessionHost({ + store, + adapter, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-1', + idleSweep: { intervalMs: SWEEP_MS, idleMs: STRUCTURED_AGENT_SESSION_IDLE_MS }, + now: () => clock + }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('a Claude retrying a refused request', () => { + it('keeps the conversation open past the idle window while retry frames arrive', async () => { + expect(await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({ + ok: true + }) + if (!sink) { + throw new Error('the host never handed the provider its event sink') + } + const translator = createClaudeJournalTranslator({ sink, fallbackIdPrefix: '1' }) + + // Five frames ten minutes apart: fifty minutes, well past the thirty-minute idle window. + for (let attempt = 1; attempt <= 5; attempt += 1) { + clock += RETRY_GAP_MS + translator.handle({ type: 'message', sessionId: SESSION, message: apiRetry(attempt) }) + await waitOutSeveralSweeps() + expect(closeSession).not.toHaveBeenCalled() + expect(host.hasSession(SESSION)).toBe(true) + } + + const items = host['sessions'].get(SESSION)?.journal.snapshot().items ?? [] + const retryRows = items.filter( + (item) => item.body.kind === 'status' && item.body.failure?.kind === 'providerRetrying' + ) + expect(retryRows).toHaveLength(1) + expect(retryRows[0]?.body).toMatchObject({ + failure: { detail: { text: expect.stringContaining('"attempt":5') } } + }) + + // Once the frames stop, the same clock does let the sweep close it. + clock += STRUCTURED_AGENT_SESSION_IDLE_MS + await vi.waitFor(() => { + expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(host.hasSession(SESSION)).toBe(false) + }) + }) +}) diff --git a/src/main/claude/claude-api-retry-row.test.ts b/src/main/claude/claude-api-retry-row.test.ts new file mode 100644 index 00000000000..becadff90d3 --- /dev/null +++ b/src/main/claude/claude-api-retry-row.test.ts @@ -0,0 +1,133 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' +import { MAX_PROVIDER_DIAGNOSTIC_CHARS } from '../../shared/agent-session-failure' +import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory' +import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'provider-1', leafUuid: 'leaf-1' } +} + +/** A frame as Claude Code sends it while it retries a refused request. */ +function apiRetry(attempt: number, fields: Record = {}): Record { + return { + type: 'system', + subtype: 'api_retry', + attempt, + max_retries: 10, + retry_delay_ms: 622, + error_status: 429, + error: 'rate_limit', + session_id: 'provider-1', + uuid: `9b2f6a1e-0c4d-4e7a-8f3b-00000000000${attempt}`, + ...fields + } +} + +let root = '' + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-api-retry-')) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +async function statusRowsFor(frames: Record[]) { + const journal = await openAgentSessionJournal({ + identity: IDENTITY, + journalDir: root, + now: () => 1_700_000_000_000, + mintEpoch: () => 'epoch-1' + }) + const deferred = createDeferredStructuredAgentSessionEventSink() + deferred.bind({ journal, fence: 1, publish: vi.fn() }) + const translator = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: '1' }) + for (const frame of frames) { + translator.handle({ type: 'message', sessionId: 'orca-session', message: frame }) + await deferred.drained() + } + return journal + .snapshot() + .items.flatMap((item): Extract[] => + item.body.kind === 'status' ? [item.body] : [] + ) +} + +describe('a Claude api_retry frame', () => { + it('writes one sentence row per retry run, revised by each attempt, not the frame', async () => { + const rows = await statusRowsFor([apiRetry(1), apiRetry(2), apiRetry(3)]) + + expect(rows).toHaveLength(1) + const [row] = rows + expect(row).toMatchObject({ + text: 'Claude is rate-limited and retrying.', + tone: 'warning', + failure: { + kind: 'providerRetrying', + retry: { error: 'rate_limit', status: 429 }, + detail: { audience: 'log' } + } + }) + expect(row).not.toHaveProperty('providerFrame') + // The latest attempt's frame, as the log detail. + expect(row.failure?.detail?.text).toContain('"attempt":3') + }) + + it('starts a new row when a later run starts over', async () => { + const rows = await statusRowsFor([apiRetry(1), apiRetry(2), apiRetry(1)]) + expect(rows.map((row) => row.text)).toEqual([ + 'Claude is rate-limited and retrying.', + 'Claude is rate-limited and retrying.' + ]) + }) + + it('does not call a retry a rate limit unless the frame says so', async () => { + const rows = await statusRowsFor([ + apiRetry(1, { error: 'overloaded', error_status: 529 }), + apiRetry(1, { error: 'server_error', error_status: 500 }), + apiRetry(1, { error: undefined, error_status: undefined }), + apiRetry(1, { error: 'something_new', error_status: 429 }) + ]) + expect(rows.map((row) => row.text)).toEqual([ + 'Claude hit a temporary problem and is retrying.', + 'Claude hit a temporary problem and is retrying.', + 'Claude hit a temporary problem and is retrying.', + 'Claude is rate-limited and retrying.' + ]) + expect(rows[0]?.failure).toMatchObject({ retry: { error: 'overloaded', status: 529 } }) + }) + + it('caps the frame it keeps as the detail', async () => { + const [row] = await statusRowsFor([apiRetry(1, { padding: 'x'.repeat(5_000) })]) + expect(row?.failure?.detail?.text.length).toBe(MAX_PROVIDER_DIAGNOSTIC_CHARS) + }) + + it('leaves a frame kind no one catalogued to the provider fallback, as before', async () => { + const unknown = { + type: 'system', + subtype: 'future_notice', + error: 'rate_limit', + session_id: 'provider-1', + uuid: 'future-1' + } + const rows = await statusRowsFor([unknown]) + expect(rows).toEqual([ + unhandledProviderFrameJournalItem('claude', 'message:system:future_notice', unknown)?.body + ]) + expect(rows[0]).toMatchObject({ text: 'rate_limit', tone: 'error' }) + }) +}) diff --git a/src/main/claude/claude-api-retry-row.ts b/src/main/claude/claude-api-retry-row.ts new file mode 100644 index 00000000000..195e30461a7 --- /dev/null +++ b/src/main/claude/claude-api-retry-row.ts @@ -0,0 +1,39 @@ +// The one row a Claude `system/api_retry` frame writes: a sentence and a `providerRetrying` fact, +// revised in place for every attempt of the same retry run. + +import { + agentSessionFailureFact, + providerDiagnostic, + readProviderRetry +} from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import type { AgentJournalStatusItem } from '../../shared/agent-session-journal-types' +import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' + +export const CLAUDE_API_RETRY_FRAME_KIND = 'message:system:api_retry' + +export function claudeApiRetryRowBody(message: Record): AgentJournalStatusItem { + const retry = readProviderRetry({ error: message.error, status: message.error_status }) + const words = agentSessionFailureWords( + agentSessionFailureFact('providerRetrying', { + detail: providerDiagnostic(JSON.stringify(message), 'log'), + ...(retry ? { retry } : {}) + }), + { surface: 'row', agentName: TUI_AGENT_DISPLAY_NAMES.claude } + ) + return { kind: 'status', tone: 'warning', ...words } +} + +/** Numbers retry runs: a frame whose attempt does not follow the last one starts a new run. */ +export function createClaudeApiRetryRuns(): (message: Record) => number { + let run = 0 + let lastAttempt: number | null = null + return (message) => { + const attempt = typeof message.attempt === 'number' ? message.attempt : null + if (attempt === null || lastAttempt === null || attempt <= lastAttempt) { + run += 1 + } + lastAttempt = attempt + return run + } +} diff --git a/src/main/claude/claude-background-task-row-journal.test.ts b/src/main/claude/claude-background-task-row-journal.test.ts index ecd23480605..736583d0324 100644 --- a/src/main/claude/claude-background-task-row-journal.test.ts +++ b/src/main/claude/claude-background-task-row-journal.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import type { StructuredAgentSessionEventSink, @@ -94,12 +95,16 @@ describe('Claude background task row journal', () => { } const resolver = new ClaudeBackgroundTaskIdentityResolver() - expect(writeClaudeBackgroundTaskRow(sink, resolver, 'task-1', task)).toEqual({ + expect( + writeClaudeBackgroundTaskRow(sink, resolver, 'task-1', task, () => AGENT_JOURNAL_THREAD_SCOPE) + ).toEqual({ accepted: false, reason: 'backpressure' }) expect(task.lastSerialized).toBeNull() - expect(writeClaudeBackgroundTaskRow(sink, resolver, 'task-1', task)).toEqual({ + expect( + writeClaudeBackgroundTaskRow(sink, resolver, 'task-1', task, () => AGENT_JOURNAL_THREAD_SCOPE) + ).toEqual({ accepted: true }) expect(task.lastSerialized).not.toBeNull() @@ -130,7 +135,13 @@ describe('Claude background task row journal', () => { } expect( - writeClaudeBackgroundTaskRow(sink, new ClaudeBackgroundTaskIdentityResolver(), 'task-1', task) + writeClaudeBackgroundTaskRow( + sink, + new ClaudeBackgroundTaskIdentityResolver(), + 'task-1', + task, + () => AGENT_JOURNAL_THREAD_SCOPE + ) ).toEqual({ accepted: true }) expect(calls).toEqual([ { @@ -153,7 +164,8 @@ describe('Claude background task row journal', () => { publish: vi.fn(), tryAppendResolvedItemAndPublish: appendAndPublish }, - isForwardedParentTool: () => true + isForwardedParentTool: () => true, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) rows.observe(START_BASH) @@ -175,7 +187,8 @@ describe('Claude background task row journal', () => { publish: vi.fn(), tryAppendResolvedItemAndPublish: appendAndPublish }, - isForwardedParentTool: () => true + isForwardedParentTool: () => true, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) rows.observe({ @@ -203,7 +216,8 @@ describe('Claude background task row journal', () => { publish: vi.fn(), tryAppendResolvedItemAndPublish: appendAndPublish }, - isForwardedParentTool: () => true + isForwardedParentTool: () => true, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) expect(rows.observe(START_BASH)).toBe(true) @@ -227,7 +241,8 @@ describe('Claude background task row journal', () => { tryAppendResolvedItemAndPublish: appendAndPublish }, isForwardedParentTool: () => true, - onPersistenceFailure + onPersistenceFailure, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) rows.observe(START_BASH) @@ -249,7 +264,8 @@ describe('Claude background task row journal', () => { })) }, isForwardedParentTool: () => true, - onPersistenceFailure + onPersistenceFailure, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) for (let index = 0; index < 512; index += 1) { @@ -287,7 +303,13 @@ describe('Claude background task row journal', () => { } expect( - writeClaudeBackgroundTaskRow(sink, new ClaudeBackgroundTaskIdentityResolver(), 'task-1', task) + writeClaudeBackgroundTaskRow( + sink, + new ClaudeBackgroundTaskIdentityResolver(), + 'task-1', + task, + () => AGENT_JOURNAL_THREAD_SCOPE + ) ).toEqual({ accepted: false, reason }) expect(task.lastSerialized).toBeNull() }) diff --git a/src/main/claude/claude-background-task-row-journal.ts b/src/main/claude/claude-background-task-row-journal.ts index a4779898204..956bc770f74 100644 --- a/src/main/claude/claude-background-task-row-journal.ts +++ b/src/main/claude/claude-background-task-row-journal.ts @@ -1,6 +1,7 @@ import type { AgentJournalItemBody, - AgentJournalItemIdentity + AgentJournalItemIdentity, + AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import { backgroundTaskFallbackText } from '../../shared/native-chat-background-task-row' import { @@ -141,6 +142,8 @@ export function writeClaudeBackgroundTaskRow( identities: ClaudeBackgroundTaskIdentityResolver, id: string, row: ClaudeBackgroundTaskRow, + /** The turn the row belongs to, read once `beforeAppend` has opened it. */ + turnScope: () => AgentJournalTurnScope, /** Runs before admission to preserve turn-before-row ordering; duplicate * delivery skips it, and a retry reuses the turn the first attempt opened. */ beforeAppend?: () => void, @@ -157,7 +160,11 @@ export function writeClaudeBackgroundTaskRow( // recreated. Keep unresolved writes from distinct provider runs queued side // by using the provider's parent tool identity as the coalescing discriminator. const coalescingKey = JSON.stringify(['claude-background-task', id, row.toolUseId ?? null]) - const appendOptions = { coalescingKey, ...(lifecycle ? { lifecycle: true } : {}) } + const appendOptions = { + coalescingKey, + turnScope: turnScope(), + ...(lifecycle ? { lifecycle: true } : {}) + } const publishOptions = lifecycle ? { lifecycle: true } : {} const resolveIdentity = (journal: StructuredAgentSessionLifecycleJournal) => identities.resolve(journal, id, row.toolUseId) diff --git a/src/main/claude/claude-background-task-row-test-support.ts b/src/main/claude/claude-background-task-row-test-support.ts index 450f4ebe292..af03378331b 100644 --- a/src/main/claude/claude-background-task-row-test-support.ts +++ b/src/main/claude/claude-background-task-row-test-support.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' // Shared fixtures and the sink harness the background-task row suites drive. // // Moved out of claude-background-task-rows.test.ts verbatim when that suite @@ -85,7 +86,8 @@ export function harness( sink, isForwardedParentTool: (toolUseId) => forwardedTools.has(toolUseId), openOutputTurn: () => turnOpens.push(1), - now: () => (clock += 10) + now: () => (clock += 10), + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) const keys = (): string[] => items.map((item) => diff --git a/src/main/claude/claude-background-task-row-writer.ts b/src/main/claude/claude-background-task-row-writer.ts index bb973a1ae70..0415503b9c2 100644 --- a/src/main/claude/claude-background-task-row-writer.ts +++ b/src/main/claude/claude-background-task-row-writer.ts @@ -1,3 +1,4 @@ +import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink, StructuredAgentSessionSinkAdmission @@ -19,6 +20,7 @@ export class ClaudeBackgroundTaskRowWriter { constructor( private readonly sink: StructuredAgentSessionEventSink, + private readonly turnScope: () => AgentJournalTurnScope, private readonly onPersistenceFailure?: (error: Error) => void ) {} @@ -33,6 +35,7 @@ export class ClaudeBackgroundTaskRowWriter { this.identities, id, row, + this.turnScope, beforeAppend, lifecycle ) @@ -61,6 +64,7 @@ export class ClaudeBackgroundTaskRowWriter { this.identities, pending.id, pending.row, + this.turnScope, undefined, pending.lifecycle ) diff --git a/src/main/claude/claude-background-task-rows.ts b/src/main/claude/claude-background-task-rows.ts index 64829d0493a..d6f50f3cfc0 100644 --- a/src/main/claude/claude-background-task-rows.ts +++ b/src/main/claude/claude-background-task-rows.ts @@ -1,6 +1,7 @@ // One durable row per Claude background `task_id`, revised in place from the // lifecycle frames so a failed command prints once with the provider sentence. +import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import { isSettledBackgroundTaskState } from '../../shared/native-chat-background-task-row' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { record, taskAliasId } from './claude-background-task-frames' @@ -46,6 +47,8 @@ export type ClaudeBackgroundTaskRowsDeps = { * output, so writing one must reopen a turn the provider resumed itself — * otherwise the session renders the row while reporting idle. */ openOutputTurn?: (frame: Record, observedAt: number) => void + /** The turn a row written now belongs to: the open one, else the conversation. */ + turnScope: () => AgentJournalTurnScope onPersistenceFailure?: (error: Error) => void now?: () => number } @@ -60,7 +63,11 @@ export class ClaudeBackgroundTaskRows { constructor(private readonly deps: ClaudeBackgroundTaskRowsDeps) { this.now = deps.now ?? (() => Date.now()) - this.writer = new ClaudeBackgroundTaskRowWriter(deps.sink, deps.onPersistenceFailure) + this.writer = new ClaudeBackgroundTaskRowWriter( + deps.sink, + deps.turnScope, + deps.onPersistenceFailure + ) this.overflowTerminalRows = new ClaudeOverflowTerminalRows( this.ledgers, this.now, diff --git a/src/main/claude/claude-can-use-tool-test-support.ts b/src/main/claude/claude-can-use-tool-test-support.ts new file mode 100644 index 00000000000..cbf064a0933 --- /dev/null +++ b/src/main/claude/claude-can-use-tool-test-support.ts @@ -0,0 +1,30 @@ +// Invoking the fake connection's canUseTool permission callback, as the CLI would. + +import type { ClaudeStreamJsonConnectionHandlers } from './claude-stream-json-connection' +import type { FakeConnection } from './claude-structured-session-test-support' + +export function invokeCanUseTool( + connection: FakeConnection, + toolName: string, + requestId: string, + toolUseID: string, + extra: { + input?: Record + suggestions?: unknown[] + signal?: AbortSignal + } = {} +): { promise: Promise; settled: () => boolean } { + const options = { + requestId, + toolUseID, + signal: extra.signal ?? new AbortController().signal, + ...(extra.suggestions ? { suggestions: extra.suggestions } : {}) + } as unknown as Parameters>[2] + let done = false + const promise = Promise.resolve( + connection.handlers.canUseTool?.(toolName, extra.input ?? {}, options) + ).finally(() => { + done = true + }) + return { promise, settled: () => done } +} diff --git a/src/main/claude/claude-captured-compact-frames.test-fixture.ts b/src/main/claude/claude-captured-compact-frames.test-fixture.ts new file mode 100644 index 00000000000..059ed560d6c --- /dev/null +++ b/src/main/claude/claude-captured-compact-frames.test-fixture.ts @@ -0,0 +1,219 @@ +// Claude Code 2.1.283 (SDK 0.3.251) stream-json captures of `/compact`, driven through `query()` with +// streaming input and cut to the protocol fields Orca reads. The session id and the summary text +// are replaced, and every frame carried `session_id: CAPTURED_COMPACT_SESSION_ID`; frame order, +// uuids and the relative clock (`at`, ms) are the captured ones. +// +// Verified from these frames: a finished and a stopped `/compact` both end in a `success` result +// with no `terminal_reason`, naming the command's input in `user_message_uuid`. Only the finished +// one carries a `compact_boundary`; the stopped one answers with a synthetic "Compaction canceled." +// instead. `command_lifecycle` frames bracket every input. + +export type CapturedCompactEvent = + | { at: number; sent: { text: string; uuid: string } } + | { at: number; interrupt: true } + | { at: number; frame: Record } + +export const CAPTURED_COMPACT_SESSION_ID = '00000000-0000-4000-8000-000000000001' + +const lifecycle = (commandUuid: string, state: string) => ({ + type: 'command_lifecycle', + command_uuid: commandUuid, + state +}) + +/** A `/compact` left to finish, from its send to its result. */ +export const CAPTURED_COMPACT_SUCCEEDS: CapturedCompactEvent[] = [ + { at: 1750, sent: { text: '/compact', uuid: '563c9bb2-241a-4946-a35b-0f9088fd05de' } }, + { at: 1751, frame: lifecycle('968b34fc-76cc-41bc-adfc-34f8840637a0', 'completed') }, + { at: 1753, frame: lifecycle('563c9bb2-241a-4946-a35b-0f9088fd05de', 'queued') }, + { at: 1753, frame: lifecycle('563c9bb2-241a-4946-a35b-0f9088fd05de', 'started') }, + { + at: 1757, + frame: { + type: 'system', + subtype: 'status', + status: 'compacting', + uuid: 'de45cbf4-0f29-4ad7-a2a4-f9d9d8847f88' + } + }, + { + at: 6602, + frame: { + type: 'system', + subtype: 'status', + status: null, + compact_result: 'success', + uuid: 'c9cd62b1-02d9-48fc-bb07-1ccb4889c0af' + } + }, + { + at: 6608, + frame: { + type: 'system', + subtype: 'init', + uuid: '9cebf016-94d0-43d5-a23b-acbd606efeae', + model: 'claude-opus-5-5[1m]' + } + }, + { + at: 6609, + frame: { + type: 'system', + subtype: 'compact_boundary', + compact_metadata: { + trigger: 'manual', + pre_tokens: 13576, + post_tokens: 1011, + cumulative_dropped_tokens: 12565, + duration_ms: 4711 + }, + uuid: '1ed30bd1-8113-4c3d-b4ef-8020a2f80bfe' + } + }, + { + at: 6609, + frame: { + type: 'user', + parent_tool_use_id: null, + isReplay: false, + isSynthetic: true, + uuid: '4fa68de9-8e28-40d1-a332-da8dba6edd14', + message: { + role: 'user', + content: + 'This session is being continued from a previous conversation that ran out of context. [summary scrubbed]' + } + } + }, + { + at: 6609, + frame: { + type: 'user', + parent_tool_use_id: null, + isReplay: true, + uuid: '302b225c-05fd-45a5-ac2d-0435f9b50845', + message: { role: 'user', content: 'Compacted' } + } + }, + { + at: 6609, + frame: { + type: 'result', + subtype: 'success', + is_error: false, + result: '', + user_message_uuid: '563c9bb2-241a-4946-a35b-0f9088fd05de', + uuid: 'd4c86a55-621b-40ae-9f26-612c842d3ca2' + } + } +] + +/** A `/compact` interrupted 1.5 s in, then the next send and its own answer. */ +export const CAPTURED_COMPACT_STOPPED_THEN_SEND: CapturedCompactEvent[] = [ + { at: 9663, sent: { text: '/compact', uuid: 'a4e04ce1-37ad-4d40-aaf8-e03a1bbbbc7d' } }, + { at: 9664, frame: lifecycle('0be27e6d-49b7-4ee8-a784-744b3fb3b55c', 'completed') }, + { at: 9664, frame: lifecycle('a4e04ce1-37ad-4d40-aaf8-e03a1bbbbc7d', 'queued') }, + { at: 9665, frame: lifecycle('a4e04ce1-37ad-4d40-aaf8-e03a1bbbbc7d', 'started') }, + { + at: 9666, + frame: { + type: 'system', + subtype: 'status', + status: 'compacting', + uuid: 'c85f1131-b4a5-424e-af6a-85e8bd98b2fc' + } + }, + { at: 11166, interrupt: true }, + { + at: 11168, + frame: { + type: 'system', + subtype: 'status', + status: null, + compact_result: 'failed', + compact_error: 'API Error: Request was aborted.', + uuid: '7fede6b2-afdf-4455-8c69-096ae3fa6f55' + } + }, + { + at: 11170, + frame: { + type: 'system', + subtype: 'init', + uuid: '067d1daa-f0da-4ca0-a9de-bedaa72c606a', + model: 'claude-opus-5-5[1m]' + } + }, + { + at: 11170, + frame: { + type: 'assistant', + parent_tool_use_id: null, + uuid: '0d252793-d95f-4c80-b4a1-7309614b7eeb', + message: { + id: '9c14e2f0-6480-4760-92f8-aa73e461bc47', + model: '', + role: 'assistant', + content: [{ type: 'text', text: 'Compaction canceled.' }] + } + } + }, + { + at: 11170, + frame: { + type: 'result', + subtype: 'success', + is_error: false, + result: '', + user_message_uuid: 'a4e04ce1-37ad-4d40-aaf8-e03a1bbbbc7d', + uuid: '523a8c1d-5054-422e-b81e-f5f350d992fd' + } + }, + { + at: 11170, + sent: { + text: 'Reply with the single word AFTERSTOP.', + uuid: '4178c081-0d9e-414f-a861-ea156efa1e7e' + } + }, + { at: 11170, frame: lifecycle('a4e04ce1-37ad-4d40-aaf8-e03a1bbbbc7d', 'cancelled') }, + { at: 11171, frame: lifecycle('4178c081-0d9e-414f-a861-ea156efa1e7e', 'queued') }, + { at: 11171, frame: lifecycle('4178c081-0d9e-414f-a861-ea156efa1e7e', 'started') }, + { + at: 11200, + frame: { + type: 'system', + subtype: 'init', + uuid: '1eedc4e5-d48c-4682-b593-144466641d13', + model: 'claude-opus-5-5[1m]' + } + }, + { + at: 12778, + frame: { + type: 'assistant', + user_message_uuid: '4178c081-0d9e-414f-a861-ea156efa1e7e', + parent_tool_use_id: null, + uuid: '39969651-5abd-4dba-ae80-ab1299c07a47', + message: { + id: 'msg_011CfUXeXFfvAtj1PLLjB1H4', + model: 'claude-opus-5-5', + role: 'assistant', + content: [{ type: 'text', text: 'AFTERSTOP' }] + } + } + }, + { + at: 12844, + frame: { + type: 'result', + subtype: 'success', + is_error: false, + terminal_reason: 'completed', + result: 'AFTERSTOP', + user_message_uuid: '4178c081-0d9e-414f-a861-ea156efa1e7e', + uuid: 'd9b4faaa-f9be-4d7b-9f81-a5106bd003f3' + } + }, + { at: 12844, frame: lifecycle('4178c081-0d9e-414f-a861-ea156efa1e7e', 'completed') } +] diff --git a/src/main/claude/claude-captured-fold-steer-frames.test-fixture.ts b/src/main/claude/claude-captured-fold-steer-frames.test-fixture.ts new file mode 100644 index 00000000000..8213b4bfa45 --- /dev/null +++ b/src/main/claude/claude-captured-fold-steer-frames.test-fixture.ts @@ -0,0 +1,275 @@ +// Claude CLI 2.1.280 stream-json captures of sends made while a turn is running +// (`--replay-user-messages`), cut to the frames and fields the dispatch/turn path +// reads. Ids, paths and prompts are replaced; the frame order and the relative +// clock (`at`, ms after the first send) are the captured ones. +// +// The captured shape under test: a mid-turn send the CLI folds into the running +// turn is replayed as a root user frame (`isReplay: true`, the client uuid) while +// that turn is still open, and the turn's ONE result names every folded send in +// `user_message_uuids`. A send the CLI runs later (miss) is replayed only when its +// own turn starts, after the first result. A cancelled mid-turn send is never +// replayed at all. + +import { + assistant, + assistantText, + assistantToolUse, + initFrame, + resultFrame, + sessionIdle, + taskFrame, + toolResult, + userReplay, + type CapturedFoldFrame, + type FoldCaptureIds +} from './claude-fold-steer-frame-builders.test-fixture' + +export type { CapturedFoldFrame, FoldCaptureIds } + +export const FIRST_PROMPT = 'Run the sleep command three times, then reply: FIRST DONE' +export const STEER_PROMPT = 'Also say the word banana at the end of your reply.' +export const SECOND_STEER_PROMPT = 'And also say the word mango at the very end.' + +/** p2-fold-fresh: first send at 12, steer at 3878; one result names both sends. */ +export const FOLD_FRESH_SEND_AT = { first: 12, steer: 3_878 } +export function foldFreshCapture({ sessionId, first, steer }: FoldCaptureIds): CapturedFoldFrame[] { + return [ + initFrame(222, sessionId), + userReplay(2_094, sessionId, first, FIRST_PROMPT), + assistantToolUse(2_676, sessionId, 'reply-tool-1', 'toolu_sleep_1', [first]), + toolResult(7_890, sessionId, 'tool-result-1', 'toolu_sleep_1'), + userReplay(7_892, sessionId, steer, STEER_PROMPT), + assistantToolUse(9_725, sessionId, 'reply-tool-2', 'toolu_sleep_2'), + toolResult(14_740, sessionId, 'tool-result-2', 'toolu_sleep_2'), + assistantText(23_039, sessionId, 'reply-text-1', 'FIRST DONE banana'), + resultFrame(23_048, sessionId, 'result-1', { + userMessageUuids: [first, steer], + durationMs: 22_845, + numTurns: 4 + }), + sessionIdle(23_049, sessionId) + ] +} + +/** p2-fold-resumed: the same fold on a `--resume` session; steer at 4458. */ +export const FOLD_RESUMED_SEND_AT = { first: 12, steer: 4_458 } +export function foldResumedCapture({ + sessionId, + first, + steer +}: FoldCaptureIds): CapturedFoldFrame[] { + return [ + initFrame(218, sessionId), + userReplay(2_057, sessionId, first, FIRST_PROMPT), + assistant( + 3_229, + sessionId, + 'reply-thinking-1', + [{ type: 'thinking', thinking: 'plan' }], + [first] + ), + assistantToolUse(3_258, sessionId, 'reply-tool-1', 'toolu_sleep_1'), + toolResult(8_464, sessionId, 'tool-result-1', 'toolu_sleep_1'), + userReplay(8_467, sessionId, steer, STEER_PROMPT), + assistantToolUse(10_318, sessionId, 'reply-tool-2', 'toolu_sleep_2'), + toolResult(15_373, sessionId, 'tool-result-2', 'toolu_sleep_2'), + assistantText(23_587, sessionId, 'reply-text-1', 'FIRST DONE banana'), + resultFrame(23_628, sessionId, 'result-1', { + userMessageUuids: [first, steer], + durationMs: 23_367, + numTurns: 4 + }), + sessionIdle(23_640, sessionId) + ] +} + +/** p2-two-steers: steers at 3630 and 5031, both replayed back to back mid-turn. */ +export const TWO_STEERS_SEND_AT = { first: 31, steer: 3_630, secondSteer: 5_031 } +export function twoSteersCapture({ + sessionId, + first, + steer, + secondSteer +}: Required): CapturedFoldFrame[] { + return [ + initFrame(351, sessionId), + userReplay(1_169, sessionId, first, FIRST_PROMPT), + assistantToolUse(2_412, sessionId, 'reply-tool-1', 'toolu_sleep_1', [first]), + toolResult(7_949, sessionId, 'tool-result-1', 'toolu_sleep_1'), + userReplay(7_963, sessionId, steer, STEER_PROMPT), + userReplay(7_967, sessionId, secondSteer, SECOND_STEER_PROMPT), + assistantToolUse(9_806, sessionId, 'reply-tool-2', 'toolu_sleep_2'), + toolResult(14_835, sessionId, 'tool-result-2', 'toolu_sleep_2'), + assistantText(22_210, sessionId, 'reply-text-1', 'FIRST DONE banana mango'), + resultFrame(22_317, sessionId, 'result-1', { + userMessageUuids: [first, steer, secondSteer], + durationMs: 21_859, + numTurns: 4 + }), + sessionIdle(22_341, sessionId) + ] +} + +/** p2-miss: the steer lands too late to fold — its replay trails the first + * result, and it runs as its own turn with its own result. */ +export const MISS_SEND_AT = { first: 30, steer: 23_384 } +export function missCapture({ sessionId, first, steer }: FoldCaptureIds): { + beforeSteerSend: CapturedFoldFrame[] + afterSteerSend: CapturedFoldFrame[] +} { + return { + beforeSteerSend: [ + initFrame(333, sessionId), + userReplay(2_118, sessionId, first, FIRST_PROMPT), + assistantToolUse(2_793, sessionId, 'reply-tool-1', 'toolu_sleep_1', [first]), + toolResult(8_258, sessionId, 'tool-result-1', 'toolu_sleep_1'), + assistantToolUse(11_019, sessionId, 'reply-tool-2', 'toolu_sleep_2'), + toolResult(15_961, sessionId, 'tool-result-2', 'toolu_sleep_2'), + assistantToolUse(18_362, sessionId, 'reply-tool-3', 'toolu_sleep_3'), + toolResult(23_375, sessionId, 'tool-result-3', 'toolu_sleep_3') + ], + afterSteerSend: [ + assistantText(25_550, sessionId, 'reply-text-1', 'FIRST DONE'), + resultFrame(25_556, sessionId, 'result-1', { + userMessageUuids: [first], + durationMs: 25_241, + numTurns: 4 + }), + initFrame(25_584, sessionId), + userReplay(28_703, sessionId, steer, STEER_PROMPT), + assistantText(29_239, sessionId, 'reply-text-2', 'banana'), + resultFrame(29_275, sessionId, 'result-2', { + userMessageUuids: [steer], + durationMs: 3_711, + numTurns: 1 + }), + sessionIdle(29_306, sessionId) + ] + } +} + +/** p2-cancel: the steer is cancelled while queued — no replay ever arrives, the + * interrupt injects a synthetic user text with NO `isReplay`, and the error + * result names only the first send. */ +export const CANCEL_SEND_AT = { first: 18, steer: 3_578 } +export function cancelCapture({ sessionId, first, steer: _steer }: FoldCaptureIds): { + beforeSteerSend: CapturedFoldFrame[] + afterInterrupt: CapturedFoldFrame[] +} { + return { + beforeSteerSend: [ + initFrame(354, sessionId), + userReplay(1_329, sessionId, first, FIRST_PROMPT), + assistantToolUse(2_366, sessionId, 'reply-tool-1', 'toolu_sleep_1', [first]) + ], + afterInterrupt: [ + toolResult( + 3_889, + sessionId, + 'tool-result-1', + 'toolu_sleep_1', + "The user doesn't want to proceed with this tool use.", + true + ), + { + at: 3_900, + frame: { + type: 'user', + session_id: sessionId, + parent_tool_use_id: null, + uuid: 'interrupt-notice-1', + message: { + role: 'user', + content: [{ type: 'text', text: '[Request interrupted by user for tool use]' }] + } + } + }, + resultFrame(3_926, sessionId, 'result-1', { + userMessageUuids: [first], + durationMs: 3_573, + numTurns: 3, + subtype: 'error_during_execution', + isError: true, + terminalReason: 'aborted_tools' + }), + sessionIdle(3_941, sessionId) + ] + } +} + +/** p3-early-steer: the steer is written BEFORE the first send's replay arrives + * (send at 312, first replay at 1208) and the CLI still folds it — one init, + * one turn, one result naming both sends. */ +export const EARLY_STEER_SEND_AT = { first: 11, steer: 312 } +export function earlySteerCapture({ + sessionId, + first, + steer +}: FoldCaptureIds): CapturedFoldFrame[] { + return [ + initFrame(205, sessionId), + userReplay(1_208, sessionId, first, FIRST_PROMPT), + assistantToolUse(2_190, sessionId, 'reply-tool-1', 'toolu_sleep_1', [first]), + toolResult(6_316, sessionId, 'tool-result-1', 'toolu_sleep_1'), + userReplay(6_318, sessionId, steer, STEER_PROMPT), + assistantText(7_336, sessionId, 'reply-text-1', 'FIRST DONE banana'), + resultFrame(7_340, sessionId, 'result-1', { + userMessageUuids: [first, steer], + durationMs: 7_155, + numTurns: 2 + }), + sessionIdle(7_341, sessionId) + ] +} + +/** p3-background-wake: after the turn's result, the finished background task + * wakes the CLI — a NEW cycle: its own init, output with no user replay, and a + * result that names no send at all (`user_message_uuids` absent). The task's + * completion frames arrive BEFORE the wake's init. */ +export function backgroundWakeCapture({ sessionId, first }: FoldCaptureIds): { + firstTurn: CapturedFoldFrame[] + wake: CapturedFoldFrame[] +} { + return { + firstTurn: [ + initFrame(252, sessionId), + userReplay(1_193, sessionId, first, FIRST_PROMPT), + assistantToolUse(2_372, sessionId, 'reply-tool-1', 'toolu_bg_1', [first]), + taskFrame(2_530, sessionId, 'task_started', { + task_id: 'task_bg_1', + tool_use_id: 'toolu_bg_1', + description: 'Sleep then print marker', + is_backgrounded: true, + task_type: 'local_bash' + }), + toolResult(2_532, sessionId, 'tool-result-1', 'toolu_bg_1'), + assistantText(3_403, sessionId, 'reply-text-1', 'STARTED'), + resultFrame(3_406, sessionId, 'result-1', { + userMessageUuids: [first], + durationMs: 3_177, + numTurns: 2 + }), + sessionIdle(3_407, sessionId) + ], + wake: [ + taskFrame(17_547, sessionId, 'task_updated', { + task_id: 'task_bg_1', + patch: { status: 'completed' } + }), + taskFrame(17_547, sessionId, 'task_notification', { + task_id: 'task_bg_1', + tool_use_id: 'toolu_bg_1', + status: 'completed', + summary: 'Background command completed (exit code 0)' + }), + initFrame(17_626, sessionId), + assistantText(19_721, sessionId, 'wake-text-1', 'The background command finished.'), + resultFrame(19_729, sessionId, 'result-2', { + userMessageUuids: [], + durationMs: 2_105, + numTurns: 1 + }), + sessionIdle(19_730, sessionId) + ] + } +} diff --git a/src/main/claude/claude-child-exit-proof-ladder.test.ts b/src/main/claude/claude-child-exit-proof-ladder.test.ts new file mode 100644 index 00000000000..2d8da7f629d --- /dev/null +++ b/src/main/claude/claude-child-exit-proof-ladder.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest' +import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../codex/codex-app-server-posix-supervisor' +import type { ClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof' +import { proveClaudeChildExitWithReaper } from './claude-child-exit-proof-ladder' + +function fakeTree(): ClaudeChildTreeReaper & { reap: ReturnType } { + return { + capture: vi.fn(async () => {}), + refresh: vi.fn(async () => {}), + reap: vi.fn(async () => 'exited' as const), + treeVerdict: 'exited' + } +} + +/** A root that leaves only once a SIGTERM has had `stopMs` to act, the way a supervisor does. */ +function rootStoppedBySigterm(stopMs: number) { + let exited = false + let settle = (): void => {} + const exitPromise = new Promise((resolve) => { + settle = resolve + }) + const kill = vi.fn((signal?: NodeJS.Signals | number) => { + if (signal === 'SIGTERM') { + setTimeout(() => { + exited = true + settle() + }, stopMs) + } + return true + }) + const stdin = { end: vi.fn() } + return { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The ladder reads only pid, kill and stdin.end from its child. + child: { pid: 4321, kill, stdin } as unknown as Parameters< + typeof proveClaudeChildExitWithReaper + >[0]['child'], + kill, + stdin, + exitPromise, + exited: () => exited + } +} + +describe('Claude child exit proof ladder', () => { + it('stops a supervised child with SIGTERM and waits out the supervisor stop before forcing', async () => { + // Slower than the unsupervised 1.5 s grace, still inside the supervisor's own bound. + const root = rootStoppedBySigterm(PROVIDER_SUPERVISOR_MAX_STOP_MS - 500) + const tree = fakeTree() + + await expect( + proveClaudeChildExitWithReaper({ ...root, supervised: true, tree }, () => tree) + ).resolves.toBe(true) + + expect(root.stdin.end).toHaveBeenCalled() + expect(root.kill).toHaveBeenCalledWith('SIGTERM') + // Forcing here would SIGKILL the supervisor mid-stop and orphan Claude in its own group. + expect(root.kill).not.toHaveBeenCalledWith('SIGKILL') + expect(tree.reap).not.toHaveBeenCalled() + }, 10_000) + + it('never signals an unsupervised child for the graceful stop', async () => { + const root = rootStoppedBySigterm(0) + const tree = fakeTree() + + await proveClaudeChildExitWithReaper({ ...root, tree }, () => tree) + + // On Windows a direct SIGTERM is TerminateProcess: stdin end stays the only graceful rung. + expect(root.stdin.end).toHaveBeenCalled() + expect(root.kill).not.toHaveBeenCalledWith('SIGTERM') + expect(tree.reap).toHaveBeenCalled() + }, 10_000) +}) diff --git a/src/main/claude/claude-child-exit-proof-ladder.ts b/src/main/claude/claude-child-exit-proof-ladder.ts index 85ed629f1b9..37d4e47508d 100644 --- a/src/main/claude/claude-child-exit-proof-ladder.ts +++ b/src/main/claude/claude-child-exit-proof-ladder.ts @@ -1,8 +1,11 @@ import type { SpawnedProcess } from '../../shared/child-process/run-process' import { waitForProcessExitUntil } from '../codex/codex-process-exit-deadline' +import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../codex/codex-app-server-posix-supervisor' import type { ClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof' -const GRACEFUL_EXIT_MS = 1_500 +export const GRACEFUL_EXIT_MS = 1_500 +// A signalled supervisor escalates on its own; forcing it sooner kills it and orphans Claude. +export const SUPERVISED_GRACEFUL_EXIT_MS = PROVIDER_SUPERVISOR_MAX_STOP_MS + 500 const FORCED_EXIT_MS = 1_000 export type ClaudeChildExitProofInput = { @@ -10,6 +13,8 @@ export type ClaudeChildExitProofInput = { exitPromise: Promise exited: () => boolean tree?: ClaudeChildTreeReaper + /** The child is the POSIX provider supervisor: SIGTERM stops Claude, which reaps its tools. */ + supervised?: boolean } export async function proveClaudeChildExitWithReaper( @@ -17,16 +22,24 @@ export async function proveClaudeChildExitWithReaper( createTree: () => ClaudeChildTreeReaper ): Promise { const tree = input.tree ?? createTree() - // Arm before stdin closes: only a live root can identify its descendants. + // Arm before the stop: only a live root can identify its descendants. await tree.capture() try { input.child.stdin?.end() } catch { // The reap below still owns the process. } + // Stdin end alone lets Claude finish its turn, tools and edits included; a close is a stop. + // Windows has no supervisor, and a direct SIGTERM there is TerminateProcess. + if (input.supervised && !input.exited()) { + input.child.kill('SIGTERM') + } let reaped = false if (!input.exited()) { - await waitForProcessExitUntil(input.exitPromise, GRACEFUL_EXIT_MS) + await waitForProcessExitUntil( + input.exitPromise, + input.supervised ? SUPERVISED_GRACEFUL_EXIT_MS : GRACEFUL_EXIT_MS + ) if (!input.exited()) { reaped = true await tree.refresh?.() diff --git a/src/main/claude/claude-child-root-termination.ts b/src/main/claude/claude-child-root-termination.ts index bed422532e1..bba29919578 100644 --- a/src/main/claude/claude-child-root-termination.ts +++ b/src/main/claude/claude-child-root-termination.ts @@ -19,6 +19,9 @@ type RootTerminationInput = { * nothing. A probe here could only let an unreadable process table cost the tree * the one fallback that still works once every table read has failed. * + * On POSIX the root is the provider supervisor, killed only after its own stop had + * its whole bound; Claude, in its own group, is reached by the descendant kill. + * * False means no signal was sent, because the root had already left. */ export function terminateClaudeRoot(input: RootTerminationInput): boolean { diff --git a/src/main/claude/claude-command-lifecycle.test.ts b/src/main/claude/claude-command-lifecycle.test.ts new file mode 100644 index 00000000000..c5570e476fd --- /dev/null +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -0,0 +1,424 @@ +// Replays of real Claude CLI 2.1.280 sessions (`__fixtures__/claude-lifecycle-capture-*.jsonl`) +// in which the CLI withdrew a queued send, interrupted a turn, or failed one. The recorded +// frames are the script; at each recorded control request the test decides how Orca's side of +// it went — the answer arriving, lost, or failing — and the CLI's own frames from while that +// request was outstanding are delivered either way. + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { DISPATCH_REJECTED_CANCELLED } from '../../shared/structured-agent-session-dispatch-rejection' +import { ClaudeControlRequestError } from './claude-agent-sdk-control-requests' +import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' +import type { ClaudeLateDispatchOutcome } from './claude-structured-session-state' +import { + fakeClaude, + identityFor, + PROVIDER_SESSION_ID, + type FakeConnection +} from './claude-structured-session-test-support' + +type CapturedEvent = + | { kind: 'meta'; providerSessionId: string } + | { kind: 'frame'; frame: Record } + | { kind: 'dispatch'; clientMessageId: string; sentUuid: string; text: string } + | { kind: 'control'; request: Record } + | { kind: 'control-answer'; response: Record } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function decodeCapturedEvent(value: unknown): CapturedEvent { + if (!isRecord(value)) { + throw new Error('capture line is not a recorded event') + } + if (value.kind === 'meta' && typeof value.providerSessionId === 'string') { + return { kind: 'meta', providerSessionId: value.providerSessionId } + } + if (value.kind === 'frame' && isRecord(value.frame)) { + return { kind: 'frame', frame: value.frame } + } + if ( + value.kind === 'dispatch' && + typeof value.clientMessageId === 'string' && + typeof value.sentUuid === 'string' && + typeof value.text === 'string' + ) { + return { + kind: 'dispatch', + clientMessageId: value.clientMessageId, + sentUuid: value.sentUuid, + text: value.text + } + } + if (value.kind === 'control' && isRecord(value.request)) { + return { kind: 'control', request: value.request } + } + if (value.kind === 'control-answer' && isRecord(value.response)) { + return { kind: 'control-answer', response: value.response } + } + throw new Error(`capture line has unknown kind: ${String(value.kind)}`) +} + +function loadCapture(name: string): CapturedEvent[] { + const path = join(__dirname, '__fixtures__', `claude-lifecycle-capture-${name}.jsonl`) + return readFileSync(path, 'utf8') + .trim() + .split('\n') + .map((line) => decodeCapturedEvent(JSON.parse(line))) +} + +type Settlement = { sessionId: string } & ClaudeLateDispatchOutcome + +type ControlPoint = { + request: Record + /** The CLI's answer as recorded, mapped to this replay's uuids. */ + answer: Record + /** Delivers the frames the CLI emitted while the request was outstanding. */ + deliverInFlight: () => void + adapter: ClaudeStructuredSessionAdapter + connection: FakeConnection + routes: ReturnType['routes'] + liveUuid: (clientMessageId: string) => string +} + +async function replayCapture( + name: string, + options: { + /** What Orca did at the recorded control request; by default nothing (the CLI acted alone). */ + atControl?: (point: ControlPoint) => Promise + /** Drop these from the capture's init frames, as an older CLI would not advertise them. */ + withoutCapabilities?: string[] + /** Leave out captured frames, to model a sequence the capture brackets. */ + omitFrame?: (frame: Record) => boolean + /** Frames to deliver right after a captured one, in the capture's own uuids. */ + afterFrame?: (frame: Record) => Record[] + /** Stop after the control request settles; its tail answers the CLI's own control path. */ + stopAfterControl?: boolean + } = {} +) { + const capture = loadCapture(name) + const settlements: Settlement[] = [] + const idles: string[] = [] + const turnStates = new Map() + // The capture supplies every frame, startup proof included. + const claude = fakeClaude({ initProof: 'none', replayUuid: null }) + const adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: '/work/repo', + claudeConfigDir: '/accounts/claude', + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: false, + continuesChain: false + }), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1, + now: () => 1_700_000_200_000, + persistHandle: async () => {}, + onDispatchSettledLate: (settlement) => settlements.push(settlement), + onSessionIdle: ({ sessionId }) => idles.push(sessionId) + }) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: { + appendItem: (_identity, body: AgentJournalItemBody) => { + const turn = readAgentJournalTurn(body) + if (turn) { + turnStates.set(turn.turnId, turn.state) + } + }, + appendTombstone: () => {}, + publish: () => {} + } + }) + const connection = claude.connections[0]! + + // Captured uuids -> the uuids the live dispatches mint during this replay. + const uuidMap = new Map() + const capturedSessionId = capture.flatMap((event) => + event.kind === 'meta' ? [event.providerSessionId] : [] + )[0]! + const mapUuids = (value: Record): Record => { + let text = JSON.stringify(value).replaceAll(capturedSessionId, PROVIDER_SESSION_ID) + for (const [captured, live] of uuidMap) { + text = text.replaceAll(captured, live) + } + const mapped: unknown = JSON.parse(text) + if (!isRecord(mapped)) { + throw new Error('mapped frame is not a record') + } + return mapped + } + const deliver = (frame: Record): void => { + if (options.omitFrame?.(frame)) { + return + } + const mapped = mapUuids(frame) + if (Array.isArray(mapped.capabilities) && options.withoutCapabilities) { + mapped.capabilities = mapped.capabilities.filter( + (capability) => !options.withoutCapabilities!.includes(String(capability)) + ) + } + connection.handlers.onMessage?.(mapped) + for (const extra of options.afterFrame?.(frame) ?? []) { + connection.handlers.onMessage?.(mapUuids(extra)) + } + } + const liveUuid = (clientMessageId: string): string => { + const dispatch = capture.find( + (event) => event.kind === 'dispatch' && event.clientMessageId === clientMessageId + ) + return dispatch?.kind === 'dispatch' ? (uuidMap.get(dispatch.sentUuid) ?? '') : '' + } + + let proofDelivered = false + for (let index = 0; index < capture.length; index++) { + const event = capture[index]! + if (event.kind === 'frame') { + deliver(event.frame) + proofDelivered = true + } else if (event.kind === 'dispatch') { + if (proofDelivered) { + await adapter.awaitStarted('session-1') + } + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: event.clientMessageId, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: event.text }] }, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + uuidMap.set(event.sentUuid, String(connection.sent.at(-1)!.uuid)) + } else if (event.kind === 'control') { + const answerAt = capture.findIndex( + (candidate, at) => at > index && candidate.kind === 'control-answer' + ) + if (answerAt === -1) { + throw new Error('capture recorded no answer for its control request') + } + const inFlight = capture.slice(index + 1, answerAt) + const answer = capture[answerAt] + let delivered = false + const deliverInFlight = (): void => { + if (!delivered) { + delivered = true + for (const frameEvent of inFlight) { + if (frameEvent.kind === 'frame') { + deliver(frameEvent.frame) + } + } + } + } + const atControl = options.atControl ?? (async (point) => point.deliverInFlight()) + await atControl({ + request: mapUuids(event.request), + answer: answer?.kind === 'control-answer' ? mapUuids(answer.response) : {}, + deliverInFlight, + adapter, + connection, + routes: claude.routes, + liveUuid + }) + deliverInFlight() + if (options.stopAfterControl) { + break + } + index = answerAt + } + } + const settlementsFor = (clientMessageId: string) => + settlements + .filter((settlement) => settlement.clientMessageId === clientMessageId) + .map(({ sessionId: _sessionId, clientMessageId: _id, ...outcome }) => outcome) + return { settlementsFor, idles, turnStates, liveUuid, connection, adapter } +} + +const WITHDRAWN = { + state: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED, + rejection: { kind: 'cancelled' } +} + +function acceptedAs(uuid: string) { + return { providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid } } +} + +/** Orca's Stop with no turn named, as the chat sends it; the interrupt answers per `answer`. */ +function stopWithInterruptAnswer(answer: 'recorded' | 'lost') { + return async (point: ControlPoint): Promise => { + point.routes.interrupt = () => { + point.deliverInFlight() + if (answer === 'lost') { + throw new ClaudeControlRequestError('interrupt', 'Control request timed out') + } + return point.answer + } + await point.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) + } +} + +describe('a Stop whose interrupt cancelled a queued follow-up (cancel_queued)', () => { + it.each(['recorded', 'lost'] as const)( + 'withdraws the follow-up once when the interrupt answer is %s, and leaves the stopped turn’s message accepted', + async (answer) => { + const replay = await replayCapture('interrupt-lost', { + atControl: stopWithInterruptAnswer(answer) + }) + + expect(replay.connection.calls.filter((call) => call.subtype === 'interrupt')).toEqual([ + { subtype: 'interrupt', params: { cancelQueued: true } } + ]) + expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN]) + // The interrupted turn's own message was echoed first; its later `cancelled` changes nothing. + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) + } + ) + + it('ignores a cancelled frame for a send it no longer holds, or never sent', async () => { + const replay = await replayCapture('interrupt-lost', { + atControl: stopWithInterruptAnswer('lost') + }) + for (const commandUuid of [ + replay.liveUuid('client-A'), + replay.liveUuid('client-B'), + '5a1b0c63-0000-4000-8000-000000000000' + ]) { + replay.connection.handlers.onMessage?.({ + type: 'command_lifecycle', + command_uuid: commandUuid, + state: 'cancelled', + uuid: `lifecycle-${commandUuid}`, + session_id: PROVIDER_SESSION_ID + }) + } + + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) + expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN]) + }) +}) + +describe('a Stop that withdraws the follow-up one at a time (no cancel_queued)', () => { + it.each([ + [ + 'times out', + () => { + throw new ClaudeControlRequestError('cancel_async_message', 'Control request timed out') + } + ], + [ + 'errors', + () => { + throw new Error('Query closed before response received') + } + ], + ['answers false', () => false] + ] as const)( + 'withdraws it from the CLI’s cancelled frame when cancel_async_message %s', + async (_label, answer) => { + const replay = await replayCapture('cancel-async', { + withoutCapabilities: ['interrupt_cancel_queued_v1'], + stopAfterControl: true, + atControl: async (point) => { + const queued = point.liveUuid('client-B') + point.routes.interrupt = () => ({ still_queued: [queued] }) + point.routes.cancel_async_message = () => { + point.deliverInFlight() + return answer() + } + await point.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) + } + }) + + expect(replay.connection.calls.map((call) => call.subtype)).toContain('cancel_async_message') + expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN]) + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) + } + ) +}) + +describe('a queued send the CLI withdraws without Orca hearing why', () => { + it('settles the withdrawn send from its own cancelled frame', async () => { + const replay = await replayCapture('cancel-async') + + expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN]) + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) + }) + + it('withdraws only the batch lead; the send behind it runs as its own turn and is accepted', async () => { + const replay = await replayCapture('batch-lead') + + expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN]) + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) + expect(replay.settlementsFor('client-C')).toEqual([acceptedAs(replay.liveUuid('client-C'))]) + expect([...replay.turnStates.entries()]).toEqual([ + [replay.liveUuid('client-A'), 'completed'], + [replay.liveUuid('client-C'), 'completed'] + ]) + }) +}) + +describe('a send the CLI started, then cancelled', () => { + it('stays accepted when its turn failed after the echo', async () => { + const replay = await replayCapture('auth-failed') + + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) + }) + + it('is not read as withdrawn when the cancelled frame came before any echo', async () => { + // The auth-failed capture with the turn's output removed: started, then cancelled. + const replay = await replayCapture('auth-failed', { + omitFrame: (frame) => + frame.type === 'user' || frame.type === 'assistant' || frame.type === 'result' + }) + + expect(replay.settlementsFor('client-A')).toEqual([]) + }) + + it('stays started when a redelivered command re-emits queued before its cancelled frame', async () => { + const replay = await replayCapture('auth-failed', { + omitFrame: (frame) => + frame.type === 'user' || frame.type === 'assistant' || frame.type === 'result', + afterFrame: (frame) => + frame.type === 'command_lifecycle' && frame.state === 'started' + ? [{ ...frame, state: 'queued' }] + : [] + }) + + expect(replay.settlementsFor('client-A')).toEqual([]) + }) +}) + +describe('the CLI reporting its session idle', () => { + it.each(['interrupt-lost', 'cancel-async', 'batch-lead', 'auth-failed'])( + 'is reported once per idle frame (%s), never for running', + async (name) => { + const replay = await replayCapture(name) + + expect(replay.idles).toEqual(['session-1']) + } + ) + + it('is not reported from a child the session no longer holds', async () => { + const replay = await replayCapture('auth-failed') + await replay.adapter.closeSession('session-1') + + replay.connection.handlers.onMessage?.({ + type: 'system', + subtype: 'session_state_changed', + state: 'idle', + uuid: 'late-idle', + session_id: PROVIDER_SESSION_ID + }) + + expect(replay.idles).toEqual(['session-1']) + }) +}) diff --git a/src/main/claude/claude-command-lifecycle.ts b/src/main/claude/claude-command-lifecycle.ts new file mode 100644 index 00000000000..652a431d538 --- /dev/null +++ b/src/main/claude/claude-command-lifecycle.ts @@ -0,0 +1,33 @@ +// What Claude's per-command `command_lifecycle` frames (msg_lifecycle_v1) settle. +// +// `cancelled` is not by itself a withdrawal: a command the CLI already started also ends +// `cancelled` when its turn is interrupted or fails (measured on 2.1.280). Only a command +// cancelled before it started was withdrawn. That frame lands ahead of the control answer, so +// it settles the send even when the interrupt or cancel_async_message answer is lost. + +import { settleCancelledClaudeDispatchWaiters } from './claude-structured-dispatch' +import { readClaudeFrameString } from './claude-structured-init-proof' +import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution' +import type { ClaudeSession } from './claude-structured-session-state' + +export function observeClaudeCommandLifecycle( + session: ClaudeSession, + message: Record, + onSettledLate?: ClaudeLateDispatchSettlement +): void { + const commandUuid = readClaudeFrameString(message, 'command_uuid') + // An echoed send has left both lists, so nothing here can reach a delivered message. + const waiter = [...session.dispatchWaiters, ...session.retiredDispatchWaiters].find( + (candidate) => candidate.sentUuid === commandUuid + ) + if (!waiter) { + return + } + const state = message.state + if (state === 'started' || (state === 'queued' && waiter.commandLifecycle !== 'started')) { + // Forward only: a redelivered command re-emits `queued`, but it has still started. + waiter.commandLifecycle = state + } else if (state === 'cancelled' && waiter.commandLifecycle !== 'started') { + settleCancelledClaudeDispatchWaiters(session, [waiter.sentUuid], onSettledLate) + } +} diff --git a/src/main/claude/claude-command-turn.test.ts b/src/main/claude/claude-command-turn.test.ts new file mode 100644 index 00000000000..d777e8a423d --- /dev/null +++ b/src/main/claude/claude-command-turn.test.ts @@ -0,0 +1,367 @@ +// A `/compact` as the Claude translator's open turn, driven by real captured frames: which of them +// end the command, how, and what the timeline draws. + +import { describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionRevisionJournal +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + CAPTURED_COMPACT_SESSION_ID, + CAPTURED_COMPACT_STOPPED_THEN_SEND, + CAPTURED_COMPACT_SUCCEEDS, + type CapturedCompactEvent +} from './claude-captured-compact-frames.test-fixture' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +const COMMAND_IDENTITY: AgentJournalItemIdentity = { + provider: 'orca', + clientMessageId: 'command-turn:cmd-1' +} +const COMMAND_KEY = agentJournalItemKey(COMMAND_IDENTITY) +const IN_COMMAND: AgentJournalTurnScope = { kind: 'turn', turnItemId: COMMAND_KEY } +const RUNNING = { + kind: 'turn' as const, + turnId: 'compact:cmd-1', + state: 'running' as const, + userItemId: 'orca:submission:cmd-1', + requestedAt: 900, + startedAt: 1_000 +} + +type Row = { key: string; body: AgentJournalItemBody; turnScope?: AgentJournalTurnScope } + +/** A sink over a journal that holds the host's running command turn, revising rows in place. */ +function harness() { + const rows = new Map([[COMMAND_KEY, { key: COMMAND_KEY, body: RUNNING }]]) + const writes: Row[] = [] + const write = ( + identity: AgentJournalItemIdentity, + body: AgentJournalItemBody, + turnScope?: AgentJournalTurnScope + ) => { + const row = { key: agentJournalItemKey(identity), body, ...(turnScope ? { turnScope } : {}) } + rows.set(row.key, row) + writes.push(row) + } + const journal: StructuredAgentSessionRevisionJournal = { + epoch: 'epoch-1', + itemBody: (itemId) => rows.get(itemId)?.body ?? null, + visitItems: (visit) => { + let sequence = 0 + for (const row of rows.values()) { + visit(row.key, sequence++, row.body) + } + } + } + const revise: NonNullable = ( + _bytes, + resolve, + options + ) => { + const resolved = resolve(journal) + if (resolved) { + write(resolved.identity, resolved.body, options.turnScope) + } + return { accepted: true } + } + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body, options) => write(identity, body, options.turnScope), + appendTombstone: vi.fn(), + publish: vi.fn(), + tryReviseResolvedItem: revise, + tryReviseResolvedItemAndPublish: revise + } + const translator = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'test' }) + const frame = (message: Record, extra: Record = {}) => + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { session_id: CAPTURED_COMPACT_SESSION_ID, ...message }, + observedAt: 5_000, + ...extra + }) + const begin = (sentUuid: string) => + translator.beginCommand({ + clientMessageId: 'cmd-1', + turnId: RUNNING.turnId, + identity: COMMAND_IDENTITY, + resultIdentity: { provider: 'orca', clientMessageId: 'command-result:cmd-1' }, + running: RUNNING, + providerSessionId: CAPTURED_COMPACT_SESSION_ID, + sentUuid + }) + /** Replays a capture as the adapter delivers it: `/compact` goes through `beginCommand`, and a + * Stop marks the interrupt the way the cancel path does. */ + const replay = (events: readonly CapturedCompactEvent[]) => { + for (const event of events) { + if ('sent' in event) { + if (event.sent.text === '/compact') { + begin(event.sent.uuid) + } + } else if ('interrupt' in event) { + translator.commandInterruptRequested(RUNNING.turnId) + } else { + frame(event.frame) + } + } + } + const commandTurn = () => readAgentJournalTurn(rows.get(COMMAND_KEY)?.body) + const drawn = () => + [...rows.values()].filter((row) => row.key !== COMMAND_KEY && row.body.kind !== 'turn') + return { translator, rows, writes, frame, begin, replay, commandTurn, drawn } +} + +describe('a captured /compact as the open Claude turn', () => { + it('ends a finished compaction as a success, drawing only the compaction separator', () => { + const { replay, commandTurn, drawn, translator } = harness() + replay(CAPTURED_COMPACT_SUCCEEDS) + + expect(commandTurn()).toMatchObject({ + turnId: RUNNING.turnId, + state: 'completed', + outcome: 'success', + userItemId: RUNNING.userItemId, + requestedAt: RUNNING.requestedAt, + startedAt: RUNNING.startedAt + }) + // Not the continuation summary, not the command's echo, not a lifecycle opcode row. + expect(drawn()).toEqual([ + { + key: agentJournalItemKey({ provider: 'orca', clientMessageId: 'command-result:cmd-1' }), + body: { kind: 'status', text: 'Context compacted', presentation: 'compaction' }, + turnScope: IN_COMMAND + } + ]) + expect(translator.commandTurnId).toBeNull() + }) + + it("ends a stopped compaction as the user's cancellation, then answers the next send in its own turn", () => { + const { replay, commandTurn, drawn, rows } = harness() + replay(CAPTURED_COMPACT_STOPPED_THEN_SEND) + + // Claude's result for the stopped command is `success` like a finished one's. + expect(commandTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + const answer = drawn().find((row) => row.body.kind === 'message') + expect(answer?.body).toMatchObject({ role: 'assistant' }) + expect(answer?.turnScope).not.toEqual(IN_COMMAND) + const answered = readAgentJournalTurn( + answer?.turnScope?.kind === 'turn' ? rows.get(answer.turnScope.turnItemId)?.body : undefined + ) + expect(answered).toMatchObject({ state: 'completed', outcome: 'success' }) + // "Compaction canceled." is the command's own output, and a stop earns no failure row. + expect(drawn().filter((row) => row.body.kind === 'status')).toEqual([]) + }) + + it('reads a compaction with no boundary and no stop as a failure, with Claude’s reason', () => { + const { replay, commandTurn, drawn } = harness() + // The stopped capture without Orca's stop: Claude reported the compaction failed. + replay(CAPTURED_COMPACT_STOPPED_THEN_SEND.filter((event) => !('interrupt' in event))) + + expect(commandTurn()).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect( + drawn().filter( + (row) => row.turnScope?.kind === 'turn' && row.turnScope.turnItemId === COMMAND_KEY + ) + ).toEqual([ + expect.objectContaining({ + body: { + kind: 'status', + text: 'Compaction failed: API Error: Request was aborted.', + failure: { + kind: 'compactionFailed', + detail: { text: 'API Error: Request was aborted.', audience: 'person' } + }, + tone: 'error' + } + }) + ]) + }) + + it('reads a stop that lands after the summary but before the boundary as a cancellation', () => { + const { replay, commandTurn, drawn } = harness() + // Only the boundary says the conversation was replaced; the status that precedes it does not. + replay( + CAPTURED_COMPACT_SUCCEEDS.flatMap((event): CapturedCompactEvent[] => + 'frame' in event && event.frame.subtype === 'compact_boundary' + ? [] + : 'frame' in event && event.frame.compact_result === 'success' + ? [event, { at: event.at, interrupt: true }] + : [event] + ) + ) + + expect(commandTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(drawn().filter((row) => row.body.kind === 'status')).toEqual([]) + }) + + it('leaves the command running at a result that names another input', () => { + const { begin, frame, commandTurn, translator } = harness() + begin('compact-input') + frame({ type: 'system', subtype: 'compact_boundary', uuid: 'boundary' }) + frame({ type: 'result', subtype: 'success', is_error: false, user_message_uuid: 'earlier' }) + expect(commandTurn()?.state).toBe('running') + expect(translator.commandTurnId).toBe(RUNNING.turnId) + + frame({ + type: 'result', + subtype: 'success', + is_error: false, + user_message_uuid: 'compact-input' + }) + expect(commandTurn()).toMatchObject({ state: 'completed', outcome: 'success' }) + }) +}) + +describe('the command turn at each point the ordinary result path threads through', () => { + it('suppresses a provider reopen after a command that failed, as after any failed turn', () => { + const failed = harness() + failed.begin('compact-input') + failed.frame({ type: 'result', subtype: 'error_during_execution', is_error: true }) + failed.frame({ + type: 'assistant', + uuid: 'stray', + parent_tool_use_id: null, + message: { id: 'msg-stray', role: 'assistant', content: [{ type: 'text', text: 'hi' }] } + }) + expect(failed.translator.currentTurnId).toBeNull() + + const finished = harness() + finished.begin('compact-input') + finished.frame({ type: 'system', subtype: 'compact_boundary', uuid: 'boundary' }) + finished.frame({ type: 'result', subtype: 'success', is_error: false }) + finished.frame({ + type: 'assistant', + uuid: 'resumed', + parent_tool_use_id: null, + message: { id: 'msg-resumed', role: 'assistant', content: [{ type: 'text', text: 'hi' }] } + }) + expect(finished.translator.currentTurnId).not.toBeNull() + }) + + it("settles a child still working in the command's turn when the command ends", () => { + const { begin, frame, rows } = harness() + begin('compact-input') + frame({ + type: 'system', + subtype: 'task_started', + task_id: 'task-1', + task_type: 'local_agent', + description: 'Map the lane' + }) + frame({ type: 'result', subtype: 'success', is_error: false }) + const group = rows.get( + agentJournalItemKey({ + provider: 'orca', + clientMessageId: `claude-subagents:${CAPTURED_COMPACT_SESSION_ID}:${RUNNING.turnId}` + }) + ) + const agents = + group?.body.kind === 'message' + ? group.body.blocks.flatMap((block) => + block.type === 'subagent-group' ? block.agents : [] + ) + : [] + expect(agents).toEqual([expect.objectContaining({ state: 'unverifiable' })]) + }) + + it("records the context window the command's result reports on the command's turn", () => { + const { begin, frame, commandTurn } = harness() + begin('compact-input') + frame({ type: 'system', subtype: 'init', model: 'claude-opus-5-5[1m]', uuid: 'init' }) + frame({ type: 'system', subtype: 'compact_boundary', uuid: 'boundary' }) + frame({ + type: 'result', + subtype: 'success', + is_error: false, + modelUsage: { 'claude-opus-5-5[1m]': { contextWindow: 1_000_000 } } + }) + expect(commandTurn()).toMatchObject({ + state: 'completed', + contextUsage: { window: { tokens: 1_000_000 } } + }) + }) + + it("reports a compaction Claude refused in Claude's own words, and one with none plainly", () => { + const worded = harness() + worded.begin('compact-input') + worded.frame({ + type: 'system', + subtype: 'status', + compact_result: 'failed', + compact_error: 'Not enough messages to compact.' + }) + worded.frame({ type: 'result', subtype: 'success', is_error: false }) + expect(worded.commandTurn()).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(worded.drawn().map((row) => row.body)).toEqual([ + { + kind: 'status', + text: 'Compaction failed: Not enough messages to compact.', + failure: { + kind: 'compactionFailed', + detail: { text: 'Not enough messages to compact.', audience: 'person' } + }, + tone: 'error' + } + ]) + + const unworded = harness() + unworded.begin('compact-input') + unworded.frame({ type: 'system', subtype: 'status', compact_result: 'failed' }) + unworded.frame({ type: 'result', subtype: 'success', is_error: false }) + expect(unworded.drawn().map((row) => row.body)).toEqual([ + { + kind: 'status', + text: 'Compaction failed.', + failure: { kind: 'compactionFailed' }, + tone: 'error' + } + ]) + }) + + it('reports a result with no compaction and no failure as a compaction Claude never confirmed', () => { + const { begin, frame, commandTurn, drawn } = harness() + begin('compact-input') + frame({ type: 'result', subtype: 'success', is_error: false }) + expect(commandTurn()).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(drawn().map((row) => row.body)).toEqual([ + { + kind: 'status', + text: 'Compaction completion is unconfirmed.', + failure: { kind: 'compactionUnconfirmed' }, + tone: 'error' + } + ]) + }) + + it('fails a command whose result ended in error', () => { + const { begin, frame, commandTurn } = harness() + begin('compact-input') + frame({ type: 'result', subtype: 'error_during_execution', is_error: true }) + expect(commandTurn()).toMatchObject({ state: 'completed', outcome: 'failure' }) + }) + + it("draws one error row, the provider's, for a command whose result is an error", () => { + const { begin, frame, drawn } = harness() + begin('compact-input') + frame({ + type: 'result', + subtype: 'success', + is_error: true, + result: 'API Error: 529 upstream overloaded' + }) + expect(drawn().filter((row) => row.body.kind === 'status')).toEqual([ + expect.objectContaining({ + body: expect.objectContaining({ text: 'API Error: 529 upstream overloaded' }), + turnScope: IN_COMMAND + }) + ]) + }) +}) diff --git a/src/main/claude/claude-command-turn.ts b/src/main/claude/claude-command-turn.ts new file mode 100644 index 00000000000..be846dc7858 --- /dev/null +++ b/src/main/claude/claude-command-turn.ts @@ -0,0 +1,153 @@ +// A conversation command Claude runs as the session's open turn. The turn is the host's record, and +// the command's own result ends it. Everything Claude writes for the command meanwhile — the +// summary it continues from, the command's echo, a "Compaction canceled." — is the command's +// output, never a reply, so none of it draws. + +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { providerDiagnostic, type ProviderDiagnostic } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' +import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { structuredCompactionOutcome } from '../native-chat/agent-session-wire/structured-conversation-command-outcome' +import { claudeText } from './claude-structured-item-translation' +import { claudeResultFailure } from './claude-structured-provider-fallback' +import type { ClaudeCurrentTurn, ClaudeTurnEnd } from './claude-turn-lifecycle-item' +import { isRootClaudeFrame } from './claude-turn-opening' +import type { ClaudeOpenTurn } from './claude-open-turn' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' + +export type ClaudeCommandTurn = { + identity: AgentJournalItemIdentity + resultIdentity: AgentJournalItemIdentity + /** The command input's uuid, which the result answering it names. */ + sentUuid: string + compacted: boolean + /** Orca asked Claude to stop the command. */ + interruptRequested: boolean + /** Claude reported the compaction failed, with its words for a person when it gave any. */ + failed: { detail?: ProviderDiagnostic } | null +} + +export type ClaudeCommandStart = StructuredAgentSessionCommandRun & { + providerSessionId: string + sentUuid: string +} + +export function claudeCommandCurrentTurn(start: ClaudeCommandStart): ClaudeCurrentTurn { + const { running } = start + return { + sessionId: start.providerSessionId, + turnId: start.turnId, + startedAt: running.startedAt ?? Date.now(), + ...(running.requestedAt === undefined ? {} : { requestedAt: running.requestedAt }), + userItemId: running.userItemId ?? agentJournalItemKey(start.identity), + command: { + identity: start.identity, + resultIdentity: start.resultIdentity, + sentUuid: start.sentUuid, + compacted: false, + interruptRequested: false, + failed: null + } + } +} + +/** Reads a running command's evidence off a frame. True for the command's own output, which + * draws nothing. */ +export function observeClaudeCommandFrame( + command: ClaudeCommandTurn | null, + message: Record +): boolean { + if (!command) { + return false + } + if (message.type === 'system') { + // Only the boundary says the history was replaced; `compact_result: 'success'` precedes it. + if (message.subtype === 'compact_boundary') { + command.compacted = true + } else if (message.compact_result === 'failed') { + const words = claudeText(message.compact_error) + const detail = words === null ? undefined : providerDiagnostic(words, 'person') + command.failed = detail ? { detail } : {} + } + return false + } + return ( + isRootClaudeFrame(message) && + (message.type === 'user' || message.type === 'assistant' || message.type === 'stream_event') + ) +} + +/** The command's end from a root result, and the one row that reports it; null when the result + * names another input. */ +export function claudeCommandEnd( + command: ClaudeCommandTurn, + message: Record, + completedAt: number +): { end: ClaudeTurnEnd; row: AgentJournalItemBody | null } | null { + const answers = claudeText(message.user_message_uuid) + if (answers !== null && answers !== command.sentUuid) { + return null + } + // A stopped `/compact` ends in the same success result as a finished one: only the provider's + // report of the compaction tells them apart. + const shown = claudeResultFailure(message) + const verdict = structuredCompactionOutcome({ + compacted: command.compacted, + interruptRequested: command.interruptRequested, + failed: command.failed ?? (shown ? {} : null) + }) + const durationMs = message.duration_ms + const end: ClaudeTurnEnd = { + state: verdict.outcome === 'cancellation' ? 'interrupted' : 'completed', + completedAt, + outcome: verdict.outcome, + ...(typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0 + ? { durationMs } + : {}) + } + if (verdict.outcome === 'success') { + return { end, row: { kind: 'status', text: 'Context compacted', presentation: 'compaction' } } + } + // An error result already draws its own row through the provider fallback. + return verdict.failure && !shown + ? { + end, + row: { + kind: 'status', + ...agentSessionFailureWords(verdict.failure, { + surface: 'row', + agentName: TUI_AGENT_DISPLAY_NAMES.claude + }), + tone: 'error' + } + } + : { end, row: null } +} + +/** A root result, when a command is the open turn: the command's end, with its one result row + * already written; `another-input` for a result that answers something else; null when no + * command is open. */ +export function claudeCommandResultEnd( + turn: Pick, + sink: Pick, + message: Record, + completedAt: number +): ClaudeTurnEnd | 'another-input' | null { + const { command } = turn + if (!command) { + return null + } + const ended = claudeCommandEnd(command, message, completedAt) + if (!ended) { + return 'another-input' + } + if (ended.row) { + sink.appendItem(command.resultIdentity, ended.row, { turnScope: turn.turnScope }) + } + return ended.end +} diff --git a/src/main/claude/claude-context-usage-restart.test.ts b/src/main/claude/claude-context-usage-restart.test.ts index 58f883ab06d..cb0dff927c5 100644 --- a/src/main/claude/claude-context-usage-restart.test.ts +++ b/src/main/claude/claude-context-usage-restart.test.ts @@ -6,7 +6,7 @@ import { selectStructuredAgentContextUsage } from '../../shared/structured-agent import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { settleStaleSessionStateOnAcquire } from '../native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict' +import { settleStaleStructuredAgentSessionState } from '../native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { bindClaudeContextUsageCapture } from './claude-context-usage' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' @@ -212,11 +212,12 @@ describe('context usage across a restart', () => { // The child dies with turn-b running; nothing ends it from the provider side. crashed.release() - await settleStaleSessionStateOnAcquire({ + await settleStaleStructuredAgentSessionState({ journal, sessionId: 'orca-session', fence: 2, - acquisitionGeneration: 'next' + acquisitionGeneration: 'next', + deathEvidence: null }) const turns = journal.snapshot().items.map((item) => readAgentJournalTurn(item.body)?.state) expect(turns).toContain('unverifiable') @@ -233,11 +234,12 @@ describe('context usage across a restart', () => { // The write is queued while the host settles the turn behind the sink's back. live.detach() live.translator.handle(assistantFrame('reply-a2', 3_000, 30_000)) - await settleStaleSessionStateOnAcquire({ + await settleStaleStructuredAgentSessionState({ journal, sessionId: 'orca-session', fence: 1, - acquisitionGeneration: 'next' + acquisitionGeneration: 'next', + deathEvidence: null }) live.reattach() await live.settle() diff --git a/src/main/claude/claude-context-usage-unloaded-turn.test.ts b/src/main/claude/claude-context-usage-unloaded-turn.test.ts index 83d9248f648..b562c839efd 100644 --- a/src/main/claude/claude-context-usage-unloaded-turn.test.ts +++ b/src/main/claude/claude-context-usage-unloaded-turn.test.ts @@ -18,10 +18,8 @@ import { createTrackedJournalOpener } from '../native-chat/agent-session-journal import { readAgentSessionHistory } from '../native-chat/agent-session-wire/agent-session-history-page' import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { - readStructuredAgentSessionOptions, - type StructuredAgentSessionMutationContext -} from '../native-chat/agent-session-wire/structured-agent-session-host-mutations' +import type { StructuredAgentSessionMutationContext } from '../native-chat/agent-session-wire/structured-agent-session-host-mutations' +import { readStructuredAgentSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-options-read' import { assistantFrame, initFrame, @@ -85,6 +83,11 @@ function readOptions( journal: AgentSessionJournal, adapter: Partial ) { + const running = { + journal, + child: { fence: 1, generation: 'generation-1' }, + params: { provider: 'claude' } + } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the options read touches only these members. const context = { deps: { @@ -95,7 +98,8 @@ function readOptions( store: { getRecord: () => undefined } }, serialize: (_sessionId: string, task: () => Promise) => task(), - requireSession: () => ({ journal, fence: 1 }) + openConversation: async () => running, + conversation: async () => running } as unknown as StructuredAgentSessionMutationContext return readStructuredAgentSessionOptions(context, SESSION) } diff --git a/src/main/claude/claude-context-usage.ts b/src/main/claude/claude-context-usage.ts index 010620cf09d..43e1ef5c31e 100644 --- a/src/main/claude/claude-context-usage.ts +++ b/src/main/claude/claude-context-usage.ts @@ -10,7 +10,7 @@ import { type AgentSessionTokenUsage } from '../../shared/agent-session-context-usage' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' -import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' +import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' /** A report later than this describes a context the user has likely moved past. */ export const CLAUDE_CONTEXT_USAGE_TIMEOUT_MS = 5_000 diff --git a/src/main/claude/claude-fold-steer-frame-builders.test-fixture.ts b/src/main/claude/claude-fold-steer-frame-builders.test-fixture.ts new file mode 100644 index 00000000000..efb5781bad8 --- /dev/null +++ b/src/main/claude/claude-fold-steer-frame-builders.test-fixture.ts @@ -0,0 +1,184 @@ +// The frame shapes the fold-steer captures are rebuilt from, keeping only the +// fields the dispatch/turn path reads. + +export type CapturedFoldFrame = { at: number; frame: Record } + +export type FoldCaptureIds = { + /** The provider session every frame names. */ + sessionId: string + /** Client uuid of the turn-opening send; the CLI adopts it on the replay. */ + first: string + /** Client uuid of the mid-turn send. */ + steer: string + /** Client uuid of the second mid-turn send (two-steers only). */ + secondSteer?: string +} + +export function userReplay( + at: number, + sessionId: string, + uuid: string, + text: string +): CapturedFoldFrame { + return { + at, + frame: { + type: 'user', + session_id: sessionId, + parent_tool_use_id: null, + uuid, + isReplay: true, + message: { role: 'user', content: [{ type: 'text', text }] } + } + } +} + +export function assistant( + at: number, + sessionId: string, + uuid: string, + content: unknown[], + userMessageUuids?: string[] +): CapturedFoldFrame { + return { + at, + frame: { + type: 'assistant', + session_id: sessionId, + parent_tool_use_id: null, + uuid, + message: { id: `msg-${uuid}`, role: 'assistant', content }, + // Only the reply that directly answers a send carries the correlation. + ...(userMessageUuids && userMessageUuids.length > 0 + ? { user_message_uuid: userMessageUuids[0], user_message_uuids: userMessageUuids } + : {}) + } + } +} + +export function assistantToolUse( + at: number, + sessionId: string, + uuid: string, + toolUseId: string, + userMessageUuids?: string[] +): CapturedFoldFrame { + return assistant( + at, + sessionId, + uuid, + [{ type: 'tool_use', id: toolUseId, name: 'Bash', input: { command: 'sleep 5' } }], + userMessageUuids + ) +} + +export function assistantText( + at: number, + sessionId: string, + uuid: string, + text: string, + userMessageUuids?: string[] +): CapturedFoldFrame { + return assistant(at, sessionId, uuid, [{ type: 'text', text }], userMessageUuids) +} + +export function toolResult( + at: number, + sessionId: string, + uuid: string, + toolUseId: string, + content = '(Bash completed with no output)', + isError = false +): CapturedFoldFrame { + return { + at, + frame: { + type: 'user', + session_id: sessionId, + parent_tool_use_id: null, + uuid, + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: toolUseId, content, is_error: isError }] + } + } + } +} + +export function resultFrame( + at: number, + sessionId: string, + uuid: string, + fields: { + userMessageUuids: string[] + durationMs: number + numTurns: number + subtype?: string + isError?: boolean + terminalReason?: string + } +): CapturedFoldFrame { + return { + at, + frame: { + type: 'result', + subtype: fields.subtype ?? 'success', + session_id: sessionId, + uuid, + is_error: fields.isError ?? false, + terminal_reason: fields.terminalReason ?? 'completed', + duration_ms: fields.durationMs, + num_turns: fields.numTurns, + result: 'FIRST DONE', + ...(fields.userMessageUuids.length > 0 + ? { + user_message_uuid: fields.userMessageUuids[0], + user_message_uuids: fields.userMessageUuids + } + : {}) + } + } +} + +export function sessionIdle(at: number, sessionId: string): CapturedFoldFrame { + return { + at, + frame: { + type: 'system', + subtype: 'session_state_changed', + state: 'idle', + session_id: sessionId, + uuid: `ssc-idle-${at}` + } + } +} + +/** Root `system/init`: the CLI starting a request cycle. Emitted at startup and + * again for every later cycle — sequential turn, queued turn, background wake, + * /compact (p3 captures). */ +export function initFrame(at: number, sessionId: string): CapturedFoldFrame { + return { + at, + frame: { + type: 'system', + subtype: 'init', + session_id: sessionId, + uuid: `init-${at}`, + model: 'claude-sonnet-5', + apiKeySource: 'none' + } + } +} + +/** A root background-task system frame (`task_started`, `task_updated`, ...). */ +export function taskFrame( + at: number, + sessionId: string, + subtype: string, + fields: Record +): CapturedFoldFrame { + return { + at, + frame: { type: 'system', subtype, session_id: sessionId, uuid: `${subtype}-${at}`, ...fields } + } +} diff --git a/src/main/claude/claude-fold-steer-receipt.test.ts b/src/main/claude/claude-fold-steer-receipt.test.ts new file mode 100644 index 00000000000..4726dc55d82 --- /dev/null +++ b/src/main/claude/claude-fold-steer-receipt.test.ts @@ -0,0 +1,622 @@ +// A send the CLI folds into the running request cycle: its adopted replay is a +// delivery receipt, never a new turn boundary. The provider's own cycle state +// decides — a root init announces each new cycle, and a result's +// `user_message_uuids` names every send the cycle ran. The measured miss, a +// lost result followed by a new cycle, and fresh replay uuids all keep the +// replay-driven opener path. Captured orders +// from Claude CLI 2.1.280 (`claude-captured-fold-steer-frames.test-fixture.ts`). + +import { describe, expect, it, vi, type Mock } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalMessageItem +} from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' +import type { + ClaudeStructuredSessionAdapterDeps, + ClaudeStructuredSessionEvent +} from './claude-structured-session-state' +import type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' +import { + fakeClaude, + identityFor, + PROVIDER_SESSION_ID +} from './claude-structured-session-test-support' +import { + assistantText, + initFrame, + resultFrame, + userReplay, + type CapturedFoldFrame +} from './claude-fold-steer-frame-builders.test-fixture' +import { + backgroundWakeCapture, + cancelCapture, + CANCEL_SEND_AT, + earlySteerCapture, + EARLY_STEER_SEND_AT, + FIRST_PROMPT, + foldFreshCapture, + FOLD_FRESH_SEND_AT, + foldResumedCapture, + FOLD_RESUMED_SEND_AT, + missCapture, + MISS_SEND_AT, + SECOND_STEER_PROMPT, + STEER_PROMPT, + twoSteersCapture, + TWO_STEERS_SEND_AT +} from './claude-captured-fold-steer-frames.test-fixture' + +const T0 = 1_700_000_100_000 + +type Rig = { + adapter: ClaudeStructuredSessionAdapter + claude: ReturnType + events: ClaudeStructuredSessionEvent[] + settled: Mock + /** Every revision of every turn lifecycle row, in append order. */ + turns: () => NonNullable>[] + deliver: (captured: CapturedFoldFrame) => void + /** Dispatches at the captured send offset and returns the client uuid the CLI adopts. */ + dispatchAt: (at: number, clientMessageId: string, text: string) => Promise +} + +async function riggedAdapter( + launch: Partial = {}, + claudeOptions: Parameters[0] = {} +): Promise { + let nowMs = T0 + const appended: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body) => appended.push({ identity, body }), + appendTombstone: () => {}, + publish: () => {} + } + const events: ClaudeStructuredSessionEvent[] = [] + const settled = vi.fn() + const claude = fakeClaude({ replayUuid: null, ...claudeOptions }) + const deps: ClaudeStructuredSessionAdapterDeps = { + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: '/work/repo', + claudeConfigDir: '/accounts/claude', + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: false, + continuesChain: false, + ...launch + }), + onEvent: (event) => events.push(event), + openConnection: claude.openConnection, + readProcessStartTime: async () => T0 - 1_000, + now: () => nowMs, + persistHandle: async () => {}, + onDispatchSettledLate: settled + } + const adapter = new ClaudeStructuredSessionAdapter(deps) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', events: sink }) + await adapter.awaitStarted('session-1') + return { + adapter, + claude, + events, + settled, + turns: () => + appended.flatMap((item) => { + const turn = readAgentJournalTurn(item.body) + return turn ? [turn] : [] + }), + deliver: (captured) => { + nowMs = T0 + captured.at + claude.connections[0]!.handlers.onMessage?.(captured.frame) + }, + dispatchAt: async (at, clientMessageId, text) => { + nowMs = T0 + at + const body: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text }] + } + const outcome = await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId, + body, + requestedAt: nowMs, + fence: 7 + }) + expect(outcome).toEqual({ state: 'admitted' }) + const sentUuid = claude.connections[0]!.sent.at(-1)?.uuid + if (typeof sentUuid !== 'string') { + throw new Error('the fake connection recorded no sent uuid') + } + return sentUuid + } + } +} + +function replayEventFor(events: ClaudeStructuredSessionEvent[], uuid: string) { + return events.find( + (event) => + event.type === 'message' && event.message.type === 'user' && event.message.uuid === uuid + ) +} + +describe('Claude fold receipt for a mid-turn send (captured orders)', () => { + it('fold-fresh: the folded steer settles as a receipt inside the one turn it was sent during', async () => { + const rig = await riggedAdapter() + const first = await rig.dispatchAt(FOLD_FRESH_SEND_AT.first, 'client-first', FIRST_PROMPT) + const framesFor = (steerUuid: string) => + foldFreshCapture({ sessionId: PROVIDER_SESSION_ID, first, steer: steerUuid }) + for (const captured of framesFor('pending')) { + if (captured.at < FOLD_FRESH_SEND_AT.steer) { + rig.deliver(captured) + } + } + const steer = await rig.dispatchAt(FOLD_FRESH_SEND_AT.steer, 'client-steer', STEER_PROMPT) + for (const captured of framesFor(steer)) { + if (captured.at > FOLD_FRESH_SEND_AT.steer) { + rig.deliver(captured) + } + } + + // ONE turn record for the whole run, never marked interrupted. + const turnIds = [...new Set(rig.turns().map((turn) => turn.turnId))] + expect(turnIds).toEqual([first]) + expect(rig.turns().every((turn) => turn.state !== 'interrupted')).toBe(true) + // The settled duration is the single turn's, spanning both sends' work. + expect(rig.turns().at(-1)).toMatchObject({ + turnId: first, + state: 'completed', + outcome: 'success', + startedAt: T0 + 2_094, + completedAt: T0 + 23_048, + durationMs: 22_845 + }) + // The steer's replay is a receipt: delivery settles under the steer's own + // provider identity, which is what attributes the user item inside the turn. + expect(rig.settled).toHaveBeenCalledWith({ + sessionId: 'session-1', + clientMessageId: 'client-steer', + providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: steer } + }) + // And it opens no boundary: the replay event carries no startsTurn. + expect(replayEventFor(rig.events, steer)).not.toHaveProperty('startsTurn') + expect(replayEventFor(rig.events, first)).toMatchObject({ startsTurn: true }) + }) + + it('session-start proof: a hook-frame startup proof with init only at the first cycle still folds', async () => { + // Live sessions prove the session from a SessionStart hook frame BEFORE + // system/init arrives (measured against the real CLI); nothing about the + // fold may depend on fields of the startup proof frame. + const rig = await riggedAdapter({}, { initProof: 'session-start' }) + const first = await rig.dispatchAt(FOLD_FRESH_SEND_AT.first, 'client-first', FIRST_PROMPT) + const framesFor = (steerUuid: string) => + foldFreshCapture({ sessionId: PROVIDER_SESSION_ID, first, steer: steerUuid }) + for (const captured of framesFor('pending')) { + if (captured.at < FOLD_FRESH_SEND_AT.steer) { + rig.deliver(captured) + } + } + const steer = await rig.dispatchAt(FOLD_FRESH_SEND_AT.steer, 'client-steer', STEER_PROMPT) + for (const captured of framesFor(steer)) { + if (captured.at > FOLD_FRESH_SEND_AT.steer) { + rig.deliver(captured) + } + } + + expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([first]) + expect(rig.turns().every((turn) => turn.state !== 'interrupted')).toBe(true) + expect(replayEventFor(rig.events, steer)).not.toHaveProperty('startsTurn') + }) + + it('fold-resumed: the receipt holds on a resumed provider session', async () => { + const rig = await riggedAdapter({ + resumeLeafUuid: 'leaf-1', + resumesTranscript: true, + continuesChain: true + }) + const first = await rig.dispatchAt(FOLD_RESUMED_SEND_AT.first, 'client-first', FIRST_PROMPT) + const framesFor = (steerUuid: string) => + foldResumedCapture({ sessionId: PROVIDER_SESSION_ID, first, steer: steerUuid }) + for (const captured of framesFor('pending')) { + if (captured.at < FOLD_RESUMED_SEND_AT.steer) { + rig.deliver(captured) + } + } + const steer = await rig.dispatchAt(FOLD_RESUMED_SEND_AT.steer, 'client-steer', STEER_PROMPT) + for (const captured of framesFor(steer)) { + if (captured.at > FOLD_RESUMED_SEND_AT.steer) { + rig.deliver(captured) + } + } + expect(rig.settled).toHaveBeenCalledWith({ + sessionId: 'session-1', + clientMessageId: 'client-steer', + providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: steer } + }) + + expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([first]) + expect(rig.turns().every((turn) => turn.state !== 'interrupted')).toBe(true) + expect(rig.turns().at(-1)).toMatchObject({ + turnId: first, + state: 'completed', + durationMs: 23_367 + }) + }) + + it('two-steers: both folded sends settle individually inside the one turn', async () => { + const rig = await riggedAdapter() + const first = await rig.dispatchAt(TWO_STEERS_SEND_AT.first, 'client-first', FIRST_PROMPT) + const framesFor = (steerUuid: string, secondSteerUuid: string) => + twoSteersCapture({ + sessionId: PROVIDER_SESSION_ID, + first, + steer: steerUuid, + secondSteer: secondSteerUuid + }) + for (const captured of framesFor('pending', 'pending-2')) { + if (captured.at < TWO_STEERS_SEND_AT.steer) { + rig.deliver(captured) + } + } + // Both steers go out mid-turn, before the next captured frame at 7949. + const steer = await rig.dispatchAt(TWO_STEERS_SEND_AT.steer, 'client-steer', STEER_PROMPT) + const secondSteer = await rig.dispatchAt( + TWO_STEERS_SEND_AT.secondSteer, + 'client-steer-2', + SECOND_STEER_PROMPT + ) + for (const captured of framesFor(steer, secondSteer)) { + if (captured.at > TWO_STEERS_SEND_AT.secondSteer) { + rig.deliver(captured) + } + } + + expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([first]) + expect(rig.turns().every((turn) => turn.state !== 'interrupted')).toBe(true) + expect(rig.settled).toHaveBeenCalledWith({ + sessionId: 'session-1', + clientMessageId: 'client-steer', + providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: steer } + }) + expect(rig.settled).toHaveBeenCalledWith({ + sessionId: 'session-1', + clientMessageId: 'client-steer-2', + providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: secondSteer } + }) + }) + + it('early-steer: a steer written before the first replay arrives still folds into the one turn', async () => { + const rig = await riggedAdapter() + const first = await rig.dispatchAt(EARLY_STEER_SEND_AT.first, 'client-first', FIRST_PROMPT) + const framesFor = (steerUuid: string) => + earlySteerCapture({ sessionId: PROVIDER_SESSION_ID, first, steer: steerUuid }) + for (const captured of framesFor('pending')) { + if (captured.at < EARLY_STEER_SEND_AT.steer) { + rig.deliver(captured) + } + } + const steer = await rig.dispatchAt(EARLY_STEER_SEND_AT.steer, 'client-steer', STEER_PROMPT) + for (const captured of framesFor(steer)) { + if (captured.at > EARLY_STEER_SEND_AT.steer) { + rig.deliver(captured) + } + } + + // No turn was open when the steer was written, and it still folded. + expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([first]) + expect(rig.turns().every((turn) => turn.state !== 'interrupted')).toBe(true) + expect(rig.turns().at(-1)).toMatchObject({ + turnId: first, + state: 'completed', + durationMs: 7_155 + }) + expect(replayEventFor(rig.events, steer)).not.toHaveProperty('startsTurn') + expect(rig.settled).toHaveBeenCalledWith({ + sessionId: 'session-1', + clientMessageId: 'client-steer', + providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: steer } + }) + }) + + it('background-wake: the wake cycle opens from its output and its result settles no waiter', async () => { + const rig = await riggedAdapter() + const first = await rig.dispatchAt(13, 'client-first', FIRST_PROMPT) + const { firstTurn, wake } = backgroundWakeCapture({ + sessionId: PROVIDER_SESSION_ID, + first, + steer: 'unused' + }) + for (const captured of [...firstTurn, ...wake]) { + rig.deliver(captured) + } + + // The task's completion revises a live row ahead of the wake's init, and + // that provider output is what opens the wake turn. + const finalByTurn = new Map(rig.turns().map((turn) => [turn.turnId, turn])) + expect([...finalByTurn.keys()]).toEqual([first, 'task_updated-17547']) + expect(finalByTurn.get('task_updated-17547')).toMatchObject({ state: 'completed' }) + // The wake result names no send; only the first send ever settled. + expect(rig.settled).toHaveBeenCalledTimes(1) + }) + + it('background-wake: a steer replayed after the wake cycle began work folds into the wake turn', async () => { + const rig = await riggedAdapter() + const first = await rig.dispatchAt(13, 'client-first', FIRST_PROMPT) + const { firstTurn, wake } = backgroundWakeCapture({ + sessionId: PROVIDER_SESSION_ID, + first, + steer: 'pending' + }) + const wakeResult = wake.findIndex((captured) => captured.frame.type === 'result') + for (const captured of [...firstTurn, ...wake.slice(0, wakeResult)]) { + rig.deliver(captured) + } + // Captured order up to the wake's output; the steer's replay is placed + // mid-cycle, where the CLI replays a send it folded (p3-early-steer). + const steer = await rig.dispatchAt(19_725, 'client-steer', STEER_PROMPT) + rig.deliver(userReplay(19_726, PROVIDER_SESSION_ID, steer, STEER_PROMPT)) + for (const captured of wake.slice(wakeResult)) { + rig.deliver(captured) + } + + expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([ + first, + 'task_updated-17547' + ]) + expect(rig.turns().every((turn) => turn.state !== 'interrupted')).toBe(true) + expect(replayEventFor(rig.events, steer)).not.toHaveProperty('startsTurn') + expect(rig.settled).toHaveBeenCalledWith({ + sessionId: 'session-1', + clientMessageId: 'client-steer', + providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: steer } + }) + }) + + it('mid-turn auto-compaction emits no root init, so a steer after the boundary still folds', async () => { + // Measured (p4-autocompact, CLAUDE_CODE_AUTO_COMPACT_WINDOW forced): the + // boundary is `status compacting` + `compact_boundary` + a synthetic + // continuation user frame — and NO root init, so cycle work survives it. + const rig = await riggedAdapter() + const first = await rig.dispatchAt(14, 'client-first', FIRST_PROMPT) + rig.deliver(initFrame(234, PROVIDER_SESSION_ID)) + rig.deliver(userReplay(2_437, PROVIDER_SESSION_ID, first, FIRST_PROMPT)) + rig.deliver(assistantText(2_864, PROVIDER_SESSION_ID, 'reply-1', 'reading files')) + rig.deliver({ + at: 11_518, + frame: { + type: 'system', + subtype: 'status', + status: 'compacting', + session_id: PROVIDER_SESSION_ID, + uuid: 'status-compacting-1' + } + }) + rig.deliver({ + at: 26_584, + frame: { + type: 'system', + subtype: 'compact_boundary', + session_id: PROVIDER_SESSION_ID, + uuid: 'compact-boundary-1', + compact_metadata: { trigger: 'auto', pre_tokens: 69_960, post_tokens: 9_311 } + } + }) + rig.deliver({ + at: 26_585, + frame: { + type: 'user', + session_id: PROVIDER_SESSION_ID, + parent_tool_use_id: null, + uuid: 'continuation-1', + isSynthetic: true, + message: { + role: 'user', + content: [{ type: 'text', text: 'This session is being continued.' }] + } + } + }) + const steer = await rig.dispatchAt(26_987, 'client-steer', STEER_PROMPT) + rig.deliver(userReplay(34_601, PROVIDER_SESSION_ID, steer, STEER_PROMPT)) + rig.deliver(assistantText(37_824, PROVIDER_SESSION_ID, 'reply-2', 'FIRST DONE banana')) + rig.deliver( + resultFrame(37_828, PROVIDER_SESSION_ID, 'result-1', { + userMessageUuids: [first, steer], + durationMs: 37_616, + numTurns: 6 + }) + ) + + expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([first]) + expect(rig.turns().every((turn) => turn.state !== 'interrupted')).toBe(true) + expect(replayEventFor(rig.events, steer)).not.toHaveProperty('startsTurn') + expect(rig.settled).toHaveBeenCalledWith({ + sessionId: 'session-1', + clientMessageId: 'client-steer', + providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: steer } + }) + }) + + it('miss: a steer whose replay trails the result keeps the opener path and its own turn', async () => { + const rig = await riggedAdapter() + const first = await rig.dispatchAt(MISS_SEND_AT.first, 'client-first', FIRST_PROMPT) + const { beforeSteerSend } = missCapture({ + sessionId: PROVIDER_SESSION_ID, + first, + steer: 'pending' + }) + for (const captured of beforeSteerSend) { + rig.deliver(captured) + } + const steer = await rig.dispatchAt(MISS_SEND_AT.steer, 'client-steer', STEER_PROMPT) + for (const captured of missCapture({ sessionId: PROVIDER_SESSION_ID, first, steer }) + .afterSteerSend) { + rig.deliver(captured) + } + + // Two real turns: the first completed by its result — not interrupted — + // and the late steer's own turn opened by its replay. + expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([first, steer]) + expect(rig.turns().every((turn) => turn.state !== 'interrupted')).toBe(true) + const finalByTurn = new Map(rig.turns().map((turn) => [turn.turnId, turn])) + expect(finalByTurn.get(first)).toMatchObject({ + state: 'completed', + completedAt: T0 + 25_556, + durationMs: 25_241 + }) + expect(finalByTurn.get(steer)).toMatchObject({ + state: 'completed', + startedAt: T0 + 28_703, + completedAt: T0 + 29_275, + durationMs: 3_711 + }) + expect(replayEventFor(rig.events, steer)).toMatchObject({ startsTurn: true }) + }) + + it('cancel: a cancelled steer settles nothing and the receipt path changes none of it', async () => { + const rig = await riggedAdapter() + const first = await rig.dispatchAt(CANCEL_SEND_AT.first, 'client-first', FIRST_PROMPT) + const { beforeSteerSend, afterInterrupt } = cancelCapture({ + sessionId: PROVIDER_SESSION_ID, + first, + steer: 'never-replayed' + }) + for (const captured of beforeSteerSend) { + rig.deliver(captured) + } + await rig.dispatchAt(CANCEL_SEND_AT.steer, 'client-steer', STEER_PROMPT) + for (const captured of afterInterrupt) { + rig.deliver(captured) + } + + // One turn, ended by the interrupt's error result exactly as before. + expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([first]) + expect(rig.turns().at(-1)).toMatchObject({ + turnId: first, + state: 'interrupted', + outcome: 'cancellation' + }) + // The steer was never replayed and its result never named it: no settlement. + expect(rig.settled).not.toHaveBeenCalledWith( + expect.objectContaining({ clientMessageId: 'client-steer' }) + ) + // The interrupt's synthetic user text opens no turn either. + expect(replayEventFor(rig.events, 'interrupt-notice-1')).not.toHaveProperty('startsTurn') + }) +}) + +describe('Claude fold receipt boundaries (synthetic orders)', () => { + it('lost result: a root init starts a cycle with no work yet, so the next adopted replay is a boundary', async () => { + const rig = await riggedAdapter() + const uuidA = await rig.dispatchAt(10, 'client-a', 'first prompt') + rig.deliver(userReplay(1_000, PROVIDER_SESSION_ID, uuidA, 'first prompt')) + const uuidB = await rig.dispatchAt(1_500, 'client-b', STEER_PROMPT) + // A's result never arrives, but the CLI announces its next request cycle: + // the open turn stops folding, so B's replay opens its own turn. + rig.deliver(initFrame(2_000, PROVIDER_SESSION_ID)) + rig.deliver(userReplay(3_000, PROVIDER_SESSION_ID, uuidB, STEER_PROMPT)) + + expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([uuidA, uuidB]) + expect(replayEventFor(rig.events, uuidB)).toMatchObject({ startsTurn: true }) + }) + + it('provider wake: an adopted replay during the live wake cycle folds into the wake turn', async () => { + // Cycle semantics: a wake is its own init-led cycle (p3-background-wake) and + // a replay the CLI emits mid-cycle was folded into that cycle (p3-early-steer). + const rig = await riggedAdapter() + const uuidA = await rig.dispatchAt(10, 'client-a', 'first prompt') + rig.deliver(userReplay(1_000, PROVIDER_SESSION_ID, uuidA, 'first prompt')) + rig.deliver( + resultFrame(2_000, PROVIDER_SESSION_ID, 'result-1', { + userMessageUuids: [uuidA], + durationMs: 1_990, + numTurns: 1 + }) + ) + const uuidB = await rig.dispatchAt(2_500, 'client-b', STEER_PROMPT) + rig.deliver(initFrame(3_000, PROVIDER_SESSION_ID)) + rig.deliver(assistantText(3_500, PROVIDER_SESSION_ID, 'provider-resumed-1', 'background done')) + rig.deliver(userReplay(4_000, PROVIDER_SESSION_ID, uuidB, STEER_PROMPT)) + + expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([ + uuidA, + 'provider-resumed-1' + ]) + expect(replayEventFor(rig.events, uuidB)).not.toHaveProperty('startsTurn') + expect(rig.settled).toHaveBeenCalledWith({ + sessionId: 'session-1', + clientMessageId: 'client-b', + providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: uuidB } + }) + }) + + it('a fresh replay uuid with user_message_uuid correlation keeps the opener path, not a fold receipt', async () => { + const rig = await riggedAdapter() + const uuidA = await rig.dispatchAt(10, 'client-a', 'first prompt') + rig.deliver(userReplay(1_000, PROVIDER_SESSION_ID, uuidA, 'first prompt')) + const uuidB = await rig.dispatchAt(1_500, 'client-b', STEER_PROMPT) + // A replay that did not adopt the client uuid is not the measured fold + // shape, so it keeps the opener path it had before the receipt existed. + const fresh = userReplay(2_000, PROVIDER_SESSION_ID, 'fresh-turn-2', STEER_PROMPT) + rig.deliver({ ...fresh, frame: { ...fresh.frame, user_message_uuid: uuidB } }) + + expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([uuidA, 'fresh-turn-2']) + expect(replayEventFor(rig.events, 'fresh-turn-2')).toMatchObject({ startsTurn: true }) + expect(rig.settled).toHaveBeenCalledWith({ + sessionId: 'session-1', + clientMessageId: 'client-b', + providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: 'fresh-turn-2' } + }) + }) + + it('plural result uuids: an unsettled folded waiter settles under its own uuid, never a shared result alias', async () => { + const rig = await riggedAdapter() + const uuidA = await rig.dispatchAt(10, 'client-a', 'first prompt') + rig.deliver(userReplay(1_000, PROVIDER_SESSION_ID, uuidA, 'first prompt')) + const uuidB = await rig.dispatchAt(1_500, 'client-b', STEER_PROMPT) + // B's replay never arrives; the folded turn's one result still names it. + rig.deliver( + resultFrame(5_000, PROVIDER_SESSION_ID, 'result-1', { + userMessageUuids: [uuidA, uuidB], + durationMs: 4_990, + numTurns: 2 + }) + ) + + expect(rig.settled).toHaveBeenCalledWith({ + sessionId: 'session-1', + clientMessageId: 'client-b', + providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: uuidB } + }) + expect(rig.settled).not.toHaveBeenCalledWith( + expect.objectContaining({ providerIdentity: expect.objectContaining({ uuid: 'result-1' }) }) + ) + // The correlated result opened nothing and the one turn completed normally. + expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([uuidA]) + expect(rig.turns().at(-1)).toMatchObject({ turnId: uuidA, state: 'completed' }) + }) + + it('plural result uuids: a result naming only settled sends does not claim an unnamed live waiter by queue order', async () => { + const rig = await riggedAdapter() + const uuidA = await rig.dispatchAt(10, 'client-a', 'first prompt') + rig.deliver(userReplay(1_000, PROVIDER_SESSION_ID, uuidA, 'first prompt')) + await rig.dispatchAt(1_500, 'client-b', STEER_PROMPT) + // A result that names only sends already settled must not fall back to + // queue order and claim B, the still-live waiter it did not name. + rig.deliver( + resultFrame(5_000, PROVIDER_SESSION_ID, 'result-1', { + userMessageUuids: [uuidA], + durationMs: 4_990, + numTurns: 1 + }) + ) + expect(rig.settled).not.toHaveBeenCalledWith( + expect.objectContaining({ clientMessageId: 'client-b' }) + ) + }) +}) diff --git a/src/main/claude/claude-folder-trust-file.test.ts b/src/main/claude/claude-folder-trust-file.test.ts new file mode 100644 index 00000000000..1c876be1457 --- /dev/null +++ b/src/main/claude/claude-folder-trust-file.test.ts @@ -0,0 +1,314 @@ +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + statSync, + symlinkSync, + utimesSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + applyClaudeFolderTrust, + grantClaudeFolderTrust, + grantClaudeWorkspaceTrust, + resolveClaudeGlobalConfigFile, + resolveLocalClaudeTrustConfig, + toClaudeTrustKey +} from './claude-folder-trust-file' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth/runtime-auth-types' + +let root: string + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-claude-trust-'))) +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +function writeConfig(file: string, value: unknown, mode = 0o600): void { + writeFileSync(file, JSON.stringify(value), { mode }) + chmodSync(file, mode) +} + +function readConfig(file: string): Record { + return JSON.parse(readFileSync(file, 'utf-8')) +} + +describe('toClaudeTrustKey', () => { + it('NFC-normalises so a decomposed path matches the key Claude looks up', () => { + const decomposed = '/tmp/cafe\u0301' + expect(toClaudeTrustKey(decomposed, 'posix')).toBe('/tmp/caf\u00e9') + }) + + it('uses forward slashes on Windows, as Claude does', () => { + expect(toClaudeTrustKey('C:\\Users\\me\\wt\\', 'win32')).toBe('C:/Users/me/wt/') + expect(toClaudeTrustKey('C:\\Users\\me\\.\\wt', 'win32')).toBe('C:/Users/me/wt') + }) +}) + +describe('resolveClaudeGlobalConfigFile', () => { + const none = (): boolean => false + + it('defaults to ~/.claude.json, never ~/.claude/.claude.json', () => { + expect( + resolveClaudeGlobalConfigFile({ env: {}, homeDir: '/home/u', style: 'posix', exists: none }) + ).toBe('/home/u/.claude.json') + }) + + it('uses CLAUDE_CONFIG_DIR/.claude.json when set', () => { + expect( + resolveClaudeGlobalConfigFile({ + env: { CLAUDE_CONFIG_DIR: '/cfg' }, + homeDir: '/home/u', + style: 'posix', + exists: none + }) + ).toBe('/cfg/.claude.json') + }) + + it('prefers the legacy .config.json in the config dir', () => { + expect( + resolveClaudeGlobalConfigFile({ + env: {}, + homeDir: '/home/u', + style: 'posix', + exists: (p) => p === '/home/u/.claude/.config.json' + }) + ).toBe('/home/u/.claude/.config.json') + }) + + it('follows the custom-OAuth file suffix', () => { + expect( + resolveClaudeGlobalConfigFile({ + env: { CLAUDE_CODE_CUSTOM_OAUTH_URL: 'https://x' }, + homeDir: '/home/u', + style: 'posix', + exists: none + }) + ).toBe('/home/u/.claude-custom-oauth.json') + }) +}) + +describe('applyClaudeFolderTrust', () => { + it('is a no-op when the folder is already trusted', () => { + expect( + applyClaudeFolderTrust({ projects: { '/wt': { hasTrustDialogAccepted: true } } }, ['/wt']) + ).toEqual({ kind: 'unchanged' }) + }) + + it('refuses a non-object projects map instead of replacing it', () => { + expect(applyClaudeFolderTrust({ projects: [] }, ['/wt'])).toEqual({ kind: 'refuse' }) + }) + + it("keeps Claude's own fields on an existing untrusted entry", () => { + expect( + applyClaudeFolderTrust({ projects: { '/wt': { allowedTools: ['x'] } } }, ['/wt']) + ).toEqual({ + kind: 'changed', + config: { projects: { '/wt': { allowedTools: ['x'], hasTrustDialogAccepted: true } } } + }) + }) +}) + +describe('grantClaudeFolderTrust', () => { + it('merges the key and keeps every other field', async () => { + const file = join(root, '.claude.json') + writeConfig(file, { + oauthAccount: { emailAddress: 'x' }, + mcpServers: { a: {} }, + projects: { '/elsewhere': { hasTrustDialogAccepted: true, allowedTools: [] } } + }) + await expect(grantClaudeFolderTrust({ configFile: file, folderKeys: ['/wt'] })).resolves.toBe( + 'granted' + ) + expect(readConfig(file)).toEqual({ + oauthAccount: { emailAddress: 'x' }, + mcpServers: { a: {} }, + projects: { + '/elsewhere': { hasTrustDialogAccepted: true, allowedTools: [] }, + '/wt': { hasTrustDialogAccepted: true } + } + }) + }) + + it('keeps an owner-only mode through the rewrite', async () => { + const file = join(root, '.claude.json') + writeConfig(file, {}, 0o600) + await grantClaudeFolderTrust({ configFile: file, folderKeys: ['/wt'] }) + expect(statSync(file).mode & 0o777).toBe(0o600) + }) + + it('never creates a missing config file', async () => { + const file = join(root, '.claude.json') + await expect(grantClaudeFolderTrust({ configFile: file, folderKeys: ['/wt'] })).resolves.toBe( + 'missing-config' + ) + expect(existsSync(file)).toBe(false) + }) + + it('leaves a corrupt file byte-for-byte untouched', async () => { + const file = join(root, '.claude.json') + writeFileSync(file, '{"oauthAccount": ', { mode: 0o600 }) + await expect(grantClaudeFolderTrust({ configFile: file, folderKeys: ['/wt'] })).resolves.toBe( + 'unreadable' + ) + expect(readFileSync(file, 'utf-8')).toBe('{"oauthAccount": ') + }) + + it('does nothing while Claude holds its lock, and leaves the lock in place', async () => { + const file = join(root, '.claude.json') + writeConfig(file, {}) + const lockDir = `${file}.lock` + mkdirSync(lockDir) + const oldTime = new Date(Date.now() - 60_000) + // Why: a lock older than Claude's 10 s stale window must still not be broken by Orca. + utimesSync(lockDir, oldTime, oldTime) + await expect(grantClaudeFolderTrust({ configFile: file, folderKeys: ['/wt'] })).resolves.toBe( + 'locked' + ) + expect(readConfig(file)).toEqual({}) + expect(existsSync(lockDir)).toBe(true) + }) + + it('updates a symlinked config through its target and keeps the link', async () => { + const target = join(root, 'dotfiles-claude.json') + const link = join(root, '.claude.json') + writeConfig(target, { theme: 'dark' }) + symlinkSync(target, link) + await grantClaudeFolderTrust({ configFile: link, folderKeys: ['/wt'] }) + expect(lstatSync(link).isSymbolicLink()).toBe(true) + expect(readConfig(target)).toEqual({ + theme: 'dark', + projects: { '/wt': { hasTrustDialogAccepted: true } } + }) + }) + + it('leaves the parent directory mode alone', async () => { + const home = join(root, 'home') + mkdirSync(home, { mode: 0o755 }) + chmodSync(home, 0o755) + const file = join(home, '.claude.json') + writeConfig(file, {}) + await grantClaudeFolderTrust({ configFile: file, folderKeys: ['/wt'] }) + expect(statSync(home).mode & 0o777).toBe(0o755) + }) + + it('grants every folder of a launch burst instead of losing some to its own lock', async () => { + const file = join(root, '.claude.json') + writeConfig(file, {}) + const keys = Array.from({ length: 12 }, (_, index) => `/wt-${index}`) + const outcomes = await Promise.all( + keys.map((key) => grantClaudeFolderTrust({ configFile: file, folderKeys: [key] })) + ) + expect(outcomes).toEqual(keys.map(() => 'granted')) + expect(readConfig(file).projects).toEqual( + Object.fromEntries(keys.map((key) => [key, { hasTrustDialogAccepted: true }])) + ) + }) +}) + +describe('grantClaudeWorkspaceTrust', () => { + it('writes the given folder and its realpath form, whatever kind of folder it is', async () => { + const real = join(root, 'real-folder') + mkdirSync(real) + const link = join(root, 'linked-folder') + symlinkSync(real, link) + const file = join(root, '.claude.json') + writeConfig(file, {}) + await expect( + grantClaudeWorkspaceTrust({ configFile: file, keyStyle: 'posix' }, link) + ).resolves.toBe('granted') + expect(readConfig(file)).toEqual({ + projects: { + [link]: { hasTrustDialogAccepted: true }, + [real]: { hasTrustDialogAccepted: true } + } + }) + }) + + it('does not take the lock when the folder is already trusted', async () => { + const file = join(root, '.claude.json') + writeConfig(file, { projects: { [root]: { hasTrustDialogAccepted: true } } }) + mkdirSync(`${file}.lock`) + await expect( + grantClaudeWorkspaceTrust({ configFile: file, keyStyle: 'posix' }, root) + ).resolves.toBe('unchanged') + }) + + it('maps a host path to the path Claude sees and skips one it cannot map', async () => { + const file = join(root, '.claude.json') + writeConfig(file, {}) + await grantClaudeWorkspaceTrust( + { configFile: file, keyStyle: 'posix', toClaudePath: () => '/home/u/wt' }, + join(root, 'missing') + ) + expect(readConfig(file)).toEqual({ + projects: { '/home/u/wt': { hasTrustDialogAccepted: true } } + }) + await expect( + grantClaudeWorkspaceTrust( + { configFile: file, keyStyle: 'posix', toClaudePath: () => null }, + join(root, 'other') + ) + ).resolves.toBe('unchanged') + }) +}) + +describe('resolveLocalClaudeTrustConfig', () => { + const wslAuth: ClaudeRuntimeAuthPreparation = { + configDir: '\\\\wsl.localhost\\Ubuntu\\home\\u\\.claude', + runtime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxConfigDir: '/home/u/.claude', + envPatch: {}, + stripAuthEnv: true, + provenance: 'wsl:Ubuntu:system' + } + + it('reads the final spawn env for a host launch', () => { + const target = resolveLocalClaudeTrustConfig({ + workspacePath: '/repo/wt', + env: { CLAUDE_CONFIG_DIR: '/cfg', HOME: '/home/u' }, + claudeAuth: null, + wslDistro: null + }) + expect(target?.configFile).toBe(join('/cfg', '.claude.json')) + }) + + it("never writes the Windows host's file for a WSL launch it cannot map to the guest", () => { + for (const args of [ + { claudeAuth: null, wslDistro: 'Ubuntu' }, + { claudeAuth: { ...wslAuth, wslLinuxConfigDir: null }, wslDistro: 'Ubuntu' }, + { claudeAuth: wslAuth, wslDistro: 'Ubuntu', workspacePath: 'C:\\repo\\wt' } + ]) { + expect( + resolveLocalClaudeTrustConfig({ + workspacePath: '\\\\wsl.localhost\\Ubuntu\\home\\u\\wt', + env: { HOME: '/home/win' }, + ...args + }) + ).toBeNull() + } + }) + + it("targets the guest's own file and Linux keys for a WSL launch", () => { + const target = resolveLocalClaudeTrustConfig({ + workspacePath: '\\\\wsl.localhost\\Ubuntu\\home\\u\\wt', + env: {}, + claudeAuth: wslAuth, + wslDistro: 'Ubuntu' + }) + expect(target?.keyStyle).toBe('posix') + expect(target?.toClaudePath?.('\\\\wsl.localhost\\Ubuntu\\home\\u\\wt')).toBe('/home/u/wt') + }) +}) diff --git a/src/main/claude/claude-folder-trust-file.ts b/src/main/claude/claude-folder-trust-file.ts new file mode 100644 index 00000000000..ca35351eeb0 --- /dev/null +++ b/src/main/claude/claude-folder-trust-file.ts @@ -0,0 +1,306 @@ +import { randomUUID } from 'node:crypto' +import { + chmodSync, + existsSync, + lstatSync, + readFileSync, + realpathSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { dirname, join, posix, resolve, win32 } from 'node:path' +import { homedir } from 'node:os' +import { lock } from 'proper-lockfile' +import { renameFileWithWindowsRetry } from '../codex-accounts/fs-utils' +import { runKeyedSerializedOperation } from '../cli/keyed-promise-queue' +import { parseWslUncPath } from '../../shared/wsl-paths' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth/runtime-auth-types' + +export type ClaudeTrustPathStyle = 'posix' | 'win32' + +export type ClaudeFolderTrustOutcome = + | 'granted' + | 'unchanged' + | 'missing-config' + | 'locked' + | 'unreadable' + +type ClaudeConfigEnv = { + CLAUDE_CONFIG_DIR?: string + CLAUDE_CODE_CUSTOM_OAUTH_URL?: string +} + +// Why: Orca must never break a lock — a held one means "skip and let Claude ask". +// Large enough that proper-lockfile never judges it stale, small enough that its +// half-stale refresh timer stays inside setTimeout's 32-bit range. +const NEVER_STALE_MS = 2 ** 30 +const LOCK_RETRIES = { retries: 4, factor: 2, minTimeout: 50, maxTimeout: 250 } +// Why: concurrent grants in one process retry the file lock in lockstep, so a launch burst +// would lose most of them to `locked`; queue them so only Claude itself contends for the lock. +const grantQueueByConfigFile = new Map>() + +function pathApi(style: ClaudeTrustPathStyle): typeof posix { + return style === 'win32' ? win32 : posix +} + +/** Claude looks up NFC `path.normalize` output, with `/` separators on Windows. */ +export function toClaudeTrustKey(folderPath: string, style: ClaudeTrustPathStyle): string { + const normalized = pathApi(style).normalize(folderPath.normalize('NFC')) + return style === 'win32' ? normalized.replaceAll('\\', '/') : normalized +} + +/** + * Mirrors Claude Code's global config lookup: a legacy `/.config.json` + * wins, otherwise `.claude.json` sits in `CLAUDE_CONFIG_DIR` or the home directory. + */ +export function resolveClaudeGlobalConfigFile(args: { + env: ClaudeConfigEnv + homeDir: string + style: ClaudeTrustPathStyle + exists: (filePath: string) => boolean +}): string { + const { join } = pathApi(args.style) + const legacyDir = (args.env.CLAUDE_CONFIG_DIR ?? join(args.homeDir, '.claude')).normalize('NFC') + const legacyFile = join(legacyDir, '.config.json') + if (args.exists(legacyFile)) { + return legacyFile + } + const suffix = args.env.CLAUDE_CODE_CUSTOM_OAUTH_URL ? '-custom-oauth' : '' + return join(args.env.CLAUDE_CONFIG_DIR || args.homeDir, `.claude${suffix}.json`) +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export type ClaudeFolderTrustChange = + | { kind: 'unchanged' } + | { kind: 'refuse' } + | { kind: 'changed'; config: Record } + +export function applyClaudeFolderTrust( + config: Record, + folderKeys: readonly string[] +): ClaudeFolderTrustChange { + if (config.projects !== undefined && !isPlainObject(config.projects)) { + return { kind: 'refuse' } + } + const projects: Record = { ...config.projects } + const alreadyTrusted = folderKeys.some((key) => { + const entry = projects[key] + return isPlainObject(entry) && entry.hasTrustDialogAccepted === true + }) + if (alreadyTrusted) { + return { kind: 'unchanged' } + } + for (const key of folderKeys) { + const entry = projects[key] + projects[key] = isPlainObject(entry) + ? { ...entry, hasTrustDialogAccepted: true } + : { hasTrustDialogAccepted: true } + } + return { kind: 'changed', config: { ...config, projects } } +} + +function isMissingFileError(error: unknown): boolean { + const code = error instanceof Error && 'code' in error ? error.code : undefined + return code === 'ENOENT' || code === 'ENOTDIR' +} + +type ConfigTarget = { kind: 'file'; path: string } | { kind: 'missing' } | { kind: 'unreadable' } + +/** Resolves a symlinked config to its target so the rename keeps the link intact. */ +function resolveConfigTarget(configFile: string): ConfigTarget { + try { + const entry = lstatSync(configFile) + const path = entry.isSymbolicLink() ? realpathSync(configFile) : configFile + return statSync(path).isFile() ? { kind: 'file', path } : { kind: 'unreadable' } + } catch (error) { + return { kind: isMissingFileError(error) ? 'missing' : 'unreadable' } + } +} + +/** Reads the config behind `target`, or says why it cannot be rewritten. */ +function readConfigAt( + target: ConfigTarget +): { path: string; config: Record } | 'missing-config' | 'unreadable' { + if (target.kind === 'missing') { + return 'missing-config' + } + if (target.kind === 'unreadable') { + return 'unreadable' + } + const config = readConfigObject(target.path) + return config ? { path: target.path, config } : 'unreadable' +} + +function readConfigObject(target: string): Record | null { + try { + const parsed: unknown = JSON.parse(readFileSync(target, 'utf-8')) + return isPlainObject(parsed) ? parsed : null + } catch { + return null + } +} + +function writeConfigAtomically(target: string, config: Record): void { + const mode = statSync(target).mode & 0o777 + const tmpPath = `${target}.orca-trust-${randomUUID()}.tmp` + try { + writeFileSync(tmpPath, `${JSON.stringify(config, null, 2)}\n`, { encoding: 'utf-8', mode }) + if (process.platform !== 'win32') { + // Why: umask may narrow the requested mode; the replacement must match the original exactly. + chmodSync(tmpPath, mode) + } + renameFileWithWindowsRetry(tmpPath, target) + } catch (error) { + rmSync(tmpPath, { force: true }) + throw error + } +} + +/** + * Sets `projects[].hasTrustDialogAccepted` in Claude's global config. Never + * creates the file, never breaks Claude's lock, and never rewrites a file it could + * not read and parse. + */ +export function grantClaudeFolderTrust(args: { + configFile: string + folderKeys: readonly string[] +}): Promise { + return runKeyedSerializedOperation(grantQueueByConfigFile, args.configFile, () => + grantClaudeFolderTrustNow(args) + ) +} + +async function grantClaudeFolderTrustNow(args: { + configFile: string + folderKeys: readonly string[] +}): Promise { + const probe = readConfigAt(resolveConfigTarget(args.configFile)) + if (typeof probe === 'string') { + return probe + } + // Why: most launches need nothing, so skip Claude's lock unless a write is due. + const planned = applyClaudeFolderTrust(probe.config, args.folderKeys).kind + if (planned !== 'changed') { + return planned === 'refuse' ? 'unreadable' : 'unchanged' + } + + let release: () => Promise + try { + release = await lock(args.configFile, { + // Why: Claude locks the literal `.lock`, not a realpath'd one. + lockfilePath: `${args.configFile}.lock`, + realpath: false, + stale: NEVER_STALE_MS, + retries: LOCK_RETRIES, + onCompromised: () => {} + }) + } catch { + return 'locked' + } + try { + // Why: read → rename stays synchronous so Orca's own synchronous auth writer to + // this file cannot interleave and lose an update. + const current = readConfigAt(resolveConfigTarget(args.configFile)) + if (typeof current === 'string') { + return current + } + const change = applyClaudeFolderTrust(current.config, args.folderKeys) + if (change.kind === 'refuse') { + return 'unreadable' + } + if (change.kind === 'unchanged') { + return 'unchanged' + } + writeConfigAtomically(current.path, change.config) + return 'granted' + } finally { + await release().catch(() => {}) + } +} + +/** Keys for `workspacePath` as Claude will see it: the given and realpath'd forms. */ +export function claudeTrustKeysForHostPath( + workspacePath: string, + keyStyle: ClaudeTrustPathStyle, + toClaudePath: (hostPath: string) => string | null = (hostPath) => hostPath +): string[] { + const forms = [resolve(workspacePath)] + try { + forms.push(realpathSync.native(workspacePath)) + } catch { + // The resolved form alone still matches an unsymlinked path. + } + const keys = new Set() + for (const form of forms) { + const claudePath = toClaudePath(form) + if (claudePath) { + keys.add(toClaudeTrustKey(claudePath, keyStyle)) + } + } + return [...keys] +} + +export type ClaudeTrustConfigTarget = { + configFile: string + keyStyle: ClaudeTrustPathStyle + /** Maps a host-native path to the path the Claude process sees (WSL UNC → Linux). */ + toClaudePath?: (hostPath: string) => string | null +} + +/** The config file a local or WSL-guest Claude will read, or null when Orca cannot tell. */ +export function resolveLocalClaudeTrustConfig(args: { + workspacePath: string + /** The final spawn env layered over this process's env. */ + env: Record + claudeAuth: ClaudeRuntimeAuthPreparation | null + wslDistro: string | null +}): ClaudeTrustConfigTarget | null { + const { claudeAuth } = args + if (claudeAuth?.runtime === 'wsl' || args.wslDistro || parseWslUncPath(args.workspacePath)) { + // Why: a WSL guest reads its own config, reachable only through the auth prep's UNC dir; + // without a guest config dir, the prep's `configDir` is the Windows host's own file. + if ( + claudeAuth?.runtime !== 'wsl' || + !claudeAuth.wslLinuxConfigDir || + !parseWslUncPath(args.workspacePath) + ) { + return null + } + const legacyFile = join(claudeAuth.configDir, '.config.json') + return { + configFile: existsSync(legacyFile) + ? legacyFile + : claudeAuth.envPatch.CLAUDE_CONFIG_DIR + ? join(claudeAuth.configDir, '.claude.json') + : join(dirname(claudeAuth.configDir), '.claude.json'), + keyStyle: 'posix', + toClaudePath: (hostPath) => parseWslUncPath(hostPath)?.linuxPath ?? null + } + } + const style = process.platform === 'win32' ? 'win32' : 'posix' + const homeDir = (style === 'win32' ? args.env.USERPROFILE : args.env.HOME) || homedir() + return { + configFile: resolveClaudeGlobalConfigFile({ + env: args.env, + homeDir, + style, + exists: existsSync + }), + keyStyle: style + } +} + +/** Grants trust for `workspacePath` in the config `target` names. */ +export async function grantClaudeWorkspaceTrust( + target: ClaudeTrustConfigTarget, + workspacePath: string +): Promise { + const folderKeys = claudeTrustKeysForHostPath(workspacePath, target.keyStyle, target.toClaudePath) + return folderKeys.length === 0 + ? 'unchanged' + : grantClaudeFolderTrust({ configFile: target.configFile, folderKeys }) +} diff --git a/src/main/claude/claude-hook-event-install.test.ts b/src/main/claude/claude-hook-event-install.test.ts new file mode 100644 index 00000000000..99af9a3fa53 --- /dev/null +++ b/src/main/claude/claude-hook-event-install.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest' +import type { HooksConfig } from '../agent-hooks/installer-utils' +import fixture from './__fixtures__/claude-hook-event-enums.json' +import { CLAUDE_HOOK_EVENT_FIRST_VERSIONS } from './claude-hook-event-versions' +import { getClaudeManagedHookPlan } from './claude-managed-hook-events' +import { applyManagedHooks } from './hook-settings' + +const SCRIPT_FILE_NAME = 'claude-hook.sh' +const MANAGED_COMMAND = '/home/dev/.orca/agent-hooks/claude-hook.sh' +const managedHook = { type: 'command' as const, command: MANAGED_COMMAND } +const enums: Record = fixture.enums +const topLevelSettings: Record = fixture.topLevelSettings + +function install(config: HooksConfig, claudeVersion: string | undefined): HooksConfig { + return applyManagedHooks( + config, + managedHook, + SCRIPT_FILE_NAME, + getClaudeManagedHookPlan(claudeVersion) + ) +} + +function managedEvents(config: HooksConfig): string[] { + return Object.entries(config.hooks ?? {}) + .filter(([, definitions]) => + (definitions ?? []).some((definition) => + (definition.hooks ?? []).some((hook) => hook.command === MANAGED_COMMAND) + ) + ) + .map(([event]) => event) + .sort() +} + +const userHook = (event: string) => ({ + hooks: [{ type: 'command' as const, command: `echo user-${event}` }] +}) + +// Why: the user owns every event, managed or not, plus the keys an invalid file would discard. +function userOwnedSettings(): HooksConfig { + const events = [...Object.keys(CLAUDE_HOOK_EVENT_FIRST_VERSIONS), 'Notification'] + return { + env: { CLAUDE_CODE_USE_BEDROCK: '1' }, + permissions: { allow: ['Bash(git status)'] }, + hooks: Object.fromEntries(events.map((event) => [event, [userHook(event)]])) + } +} + +function expectUserSettingsKept(config: HooksConfig): void { + expect(config.env).toEqual({ CLAUDE_CODE_USE_BEDROCK: '1' }) + expect(config.permissions).toEqual({ allow: ['Bash(git status)'] }) + for (const [event, definitions] of Object.entries(userOwnedSettings().hooks ?? {})) { + expect(config.hooks?.[event], event).toContainEqual(definitions?.[0]) + } +} + +describe('Claude managed hook events by resolved version', () => { + it.each(Object.keys(enums))('writes only events Claude %s knows', (version) => { + const written = install({ hooks: {} }, version) + const unknown = Object.keys(written.hooks ?? {}).filter( + (event) => !enums[version].includes(event) + ) + expect(unknown).toEqual([]) + }) + + it.each(Object.keys(topLevelSettings))( + 'writes statusLine only if Claude %s knows it', + (version) => { + const plan = getClaudeManagedHookPlan(version) + expect(plan.statusLine === 'install').toBe( + topLevelSettings[version].keys.includes('statusLine') + ) + } + ) + + it('omits every event newer than an old Claude', () => { + expect(managedEvents(install({ hooks: {} }, '2.1.32 (Claude Code)'))).toEqual( + [ + 'PermissionRequest', + 'PostToolUse', + 'PostToolUseFailure', + 'PreToolUse', + 'SessionStart', + 'Stop', + 'SubagentStart', + 'SubagentStop', + 'UserPromptSubmit' + ].sort() + ) + }) + + it.each([undefined, 'unknown'])( + 'writes only the core lifecycle events when the version is %s', + (version) => { + expect(managedEvents(install({ hooks: {} }, version))).toEqual( + [ + 'PostToolUse', + 'PreToolUse', + 'SessionStart', + 'Stop', + 'SubagentStop', + 'UserPromptSubmit' + ].sort() + ) + } + ) + + it.each([undefined, 'unknown'])( + 'leaves Orca entries a version-aware install wrote untouched when the version is %s', + (version) => { + const current = install(userOwnedSettings(), '2.1.261') + expect(JSON.stringify(install(current, version))).toBe(JSON.stringify(current)) + + // Why: even an entry from an older hook command stays byte-identical; only a known version may rewrite it. + const staleHook = { + type: 'command' as const, + command: '/old/.orca/agent-hooks/claude-hook.sh' + } + const stale = { hooks: { StopFailure: [{ hooks: [staleHook] }] } } + const written = install(stale, version) + expect(JSON.stringify(written.hooks?.StopFailure)).toBe( + JSON.stringify(stale.hooks.StopFailure) + ) + expect(managedEvents(written)).toEqual( + [ + 'PostToolUse', + 'PreToolUse', + 'SessionStart', + 'Stop', + 'SubagentStop', + 'UserPromptSubmit' + ].sort() + ) + } + ) + + it('adds the newer events once the resolved Claude is upgraded', () => { + const old = install({ hooks: {} }, '2.1.77') + expect(old.hooks?.StopFailure).toBeUndefined() + + const upgraded = install(old, '2.1.78') + + expect(managedEvents(upgraded)).toContain('StopFailure') + expect(managedEvents(upgraded)).toContain('PostCompact') + expect(upgraded.hooks?.StopFailure).toEqual([{ hooks: [managedHook] }]) + }) + + it('removes only Orca entries for events a downgraded Claude does not know', () => { + const current = install({ hooks: {} }, '2.1.261') + expect(managedEvents(current)).toContain('SessionEnd') + + const downgraded = install(current, '2.1.32') + + for (const event of ['StopFailure', 'PostCompact', 'TeammateIdle', 'SessionEnd']) { + expect(downgraded.hooks?.[event], event).toBeUndefined() + } + }) + + it('gates a known Claude older than the unresolved-version set by its own enum', () => { + expect(managedEvents(install({ hooks: {} }, '1.0.52'))).toEqual( + ['PostToolUse', 'PreToolUse', 'Stop', 'SubagentStop'].sort() + ) + const downgraded = install(install({ hooks: {} }, '2.1.261'), '1.0.22') + expect(managedEvents(downgraded)).toEqual([]) + }) + + it.each(['1.0.22', '1.0.52', '1.0.81', '2.1.32', '2.1.77', '2.1.78', '2.1.261', undefined])( + 'keeps every user-written entry and setting when installing for %s', + (version) => { + const fresh = install(userOwnedSettings(), version) + expectUserSettingsKept(fresh) + // Why: a downgrade from the newest set is the path that removes Orca entries. + expectUserSettingsKept(install(install(userOwnedSettings(), '2.1.261'), version)) + } + ) + + it('leaves a user value it cannot parse under an unknown event untouched', () => { + const settings: HooksConfig = JSON.parse('{"hooks":{"StopFailure":[],"PostCompact":"mine"}}') + const written = install(settings, '2.1.32') + expect(written.hooks?.StopFailure).toEqual([]) + expect(written.hooks?.PostCompact).toBe('mine') + }) + + it('keeps SessionEnd behind its measured 2.1.261 floor', () => { + expect(install({ hooks: {} }, '2.1.260').hooks?.SessionEnd).toBeUndefined() + expect(install({ hooks: {} }, '2.1.261').hooks?.SessionEnd).toEqual([{ hooks: [managedHook] }]) + }) +}) diff --git a/src/main/claude/claude-hook-event-versions.test.ts b/src/main/claude/claude-hook-event-versions.test.ts new file mode 100644 index 00000000000..d27705a5f54 --- /dev/null +++ b/src/main/claude/claude-hook-event-versions.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import { compareAppVersions } from '../../shared/app-version' +import fixture from './__fixtures__/claude-hook-event-enums.json' +import { + CLAUDE_HOOK_EVENT_FIRST_VERSIONS, + CLAUDE_STATUS_LINE_FIRST_VERSION, + claudeKnowsHookEvent, + claudeKnowsStatusLine, + parseClaudeCliVersion, + UNRESOLVED_CLAUDE_VERSION, + type ClaudeHookEventName +} from './claude-hook-event-versions' + +const enums: Record = fixture.enums +const previousPublished: Record = fixture.previousPublishedVersion +const topLevelSettings: Record = + fixture.topLevelSettings +const versions = Object.keys(enums).sort(compareAppVersions) +const isTableEvent = (name: string): name is ClaudeHookEventName => + name in CLAUDE_HOOK_EVENT_FIRST_VERSIONS +const tableEntries = Object.keys(CLAUDE_HOOK_EVENT_FIRST_VERSIONS) + .filter(isTableEvent) + .map((event): [ClaudeHookEventName, string] => [event, CLAUDE_HOOK_EVENT_FIRST_VERSIONS[event]]) + +describe('Claude hook event version table', () => { + it.each(tableEntries)('pins %s to the first release whose enum knows it', (event, first) => { + expect(versions.find((version) => enums[version].includes(event))).toBe(first) + for (const version of versions) { + expect(enums[version].includes(event), `${event} in ${version}`).toBe( + compareAppVersions(version, first) >= 0 + ) + } + // Why: the release published just before `first` lacks the event, so the table is exact, not just safe. + const before = previousPublished[first] + expect(enums[before], `enum for ${before}`).toBeDefined() + expect(enums[before]).not.toContain(event) + }) +}) + +describe('Claude statusLine version floor', () => { + it('pins statusLine to the first release whose settings schema knows it', () => { + for (const [version, schema] of Object.entries(topLevelSettings)) { + expect(schema.keys.includes('statusLine'), version).toBe( + compareAppVersions(version, CLAUDE_STATUS_LINE_FIRST_VERSION) >= 0 + ) + } + // Why: the release just before rejects the unknown key, so the floor is exact, not just safe. + const before = previousPublished[CLAUDE_STATUS_LINE_FIRST_VERSION] + expect(topLevelSettings[before].strict).toBe(true) + expect(topLevelSettings[before].keys).not.toContain('statusLine') + }) + + it.each([ + ['1.0.63', false], + ['1.0.64 (Claude Code)', true], + [undefined, true] + ] as const)('%s knows statusLine: %s', (version, expected) => { + expect(claudeKnowsStatusLine(version)).toBe(expected) + }) +}) + +describe('claudeKnowsHookEvent', () => { + it.each([ + ['2.1.77', 'StopFailure', false], + ['2.1.78 (Claude Code)', 'StopFailure', true], + ['2.1.75', 'PostCompact', false], + ['2.1.76', 'PostCompact', true], + ['2.1.32', 'TeammateIdle', false], + ['2.0.55', 'PostToolUseFailure', false], + ['2.0.44', 'PermissionRequest', false], + ['2.0.42', 'SubagentStart', false], + ['1.0.84', 'SessionEnd', false], + ['1.0.61', 'SessionStart', false], + ['1.0.62', 'SessionStart', true], + ['1.0.52', 'UserPromptSubmit', false], + ['1.0.40', 'SubagentStop', false], + ['1.0.30', 'Stop', false], + ['1.0.22', 'PreToolUse', false] + ] as const)('%s knows %s: %s', (version, event, expected) => { + expect(claudeKnowsHookEvent(version, event)).toBe(expected) + }) + + it.each([undefined, null, 'unknown'])( + 'grants an unresolved version (%s) only the events its assumed release knows', + (version) => { + const known = tableEntries + .filter(([event]) => claudeKnowsHookEvent(version, event)) + .map(([event]) => event) + expect(known.sort()).toEqual( + [ + 'PostToolUse', + 'PreToolUse', + 'SessionStart', + 'Stop', + 'SubagentStop', + 'UserPromptSubmit' + ].sort() + ) + for (const event of known) { + expect(enums[UNRESOLVED_CLAUDE_VERSION]).toContain(event) + } + } + ) +}) + +describe('parseClaudeCliVersion', () => { + it('extracts Claude Code version output', () => { + expect(parseClaudeCliVersion('2.1.261 (Claude Code)')).toBe('2.1.261') + expect(parseClaudeCliVersion('unknown')).toBeNull() + }) +}) diff --git a/src/main/claude/claude-hook-event-versions.ts b/src/main/claude/claude-hook-event-versions.ts new file mode 100644 index 00000000000..5c9c28de66c --- /dev/null +++ b/src/main/claude/claude-hook-event-versions.ts @@ -0,0 +1,86 @@ +import { hasReachedAppVersion, isValidAppVersion } from '../../shared/app-version' +import { runProcess } from '../../shared/child-process/run-process' +import path from 'node:path' + +// Why: Claude 1.0.23 through 2.1.100 validate `hooks` against a closed event enum and discard the +// WHOLE settings.json (env, permissions, every user hook) on one unknown name, so an event may only +// be written for a Claude that knows it. Values come from each release's packed enum, pinned by +// __fixtures__/claude-hook-event-enums.json. +export const CLAUDE_HOOK_EVENT_FIRST_VERSIONS = { + PreToolUse: '1.0.23', + PostToolUse: '1.0.23', + Stop: '1.0.31', + SubagentStop: '1.0.41', + UserPromptSubmit: '1.0.53', + SessionStart: '1.0.62', + SessionEnd: '1.0.85', + SubagentStart: '2.0.43', + PermissionRequest: '2.0.45', + PostToolUseFailure: '2.0.56', + TeammateIdle: '2.1.33', + PostCompact: '2.1.76', + StopFailure: '2.1.78' +} as const + +// Why: 1.0.49 through 1.0.66 also discard the whole file over an unknown top-level key, and +// `statusLine` joined their schema only in 1.0.64. Pinned by the same fixture's topLevelSettings. +export const CLAUDE_STATUS_LINE_FIRST_VERSION = '1.0.64' + +// Why: an unresolved version gets what the first release accepting Orca's core lifecycle events and +// statusLine knows, and nothing newer; a Claude older than that is gated only once its version resolves. +export const UNRESOLVED_CLAUDE_VERSION = CLAUDE_STATUS_LINE_FIRST_VERSION + +export type ClaudeHookEventName = keyof typeof CLAUDE_HOOK_EVENT_FIRST_VERSIONS + +export function parseClaudeCliVersion(output: string | null | undefined): string | null { + const version = output?.match(/\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\b/)?.[0] + return version && isValidAppVersion(version) ? version : null +} + +export function claudeVersionReaches(version: string | null | undefined, floor: string): boolean { + const parsed = parseClaudeCliVersion(version) + return parsed !== null && hasReachedAppVersion(parsed, floor) +} + +function claudeKnowsSince(version: string | null | undefined, firstVersion: string): boolean { + return hasReachedAppVersion( + parseClaudeCliVersion(version) ?? UNRESOLVED_CLAUDE_VERSION, + firstVersion + ) +} + +export function claudeKnowsHookEvent( + version: string | null | undefined, + eventName: ClaudeHookEventName +): boolean { + return claudeKnowsSince(version, CLAUDE_HOOK_EVENT_FIRST_VERSIONS[eventName]) +} + +export function claudeKnowsStatusLine(version: string | null | undefined): boolean { + return claudeKnowsSince(version, CLAUDE_STATUS_LINE_FIRST_VERSION) +} + +export async function probeClaudeCliVersion(executablePath: string): Promise { + try { + const pathKey = process.platform === 'win32' && process.env.Path !== undefined ? 'Path' : 'PATH' + const executableDir = path.dirname(executablePath) + const inheritedPath = process.env[pathKey] + const result = await runProcess({ + program: executablePath, + args: ['--version'], + // Why: version-manager launchers often use `#!/usr/bin/env node`; the resolved CLI's sibling + // runtime must remain reachable even when Electron started with a thinner PATH. + env: { + ...process.env, + [pathKey]: inheritedPath + ? `${executableDir}${path.delimiter}${inheritedPath}` + : executableDir + }, + timeoutMs: 5_000, + maxOutputBytes: 4_096 + }) + return result.code === 0 ? parseClaudeCliVersion(`${result.stdout}\n${result.stderr}`) : null + } catch { + return null + } +} diff --git a/src/main/claude/claude-journal-translator-contract.ts b/src/main/claude/claude-journal-translator-contract.ts index 7a7fc4291c7..45feeb84364 100644 --- a/src/main/claude/claude-journal-translator-contract.ts +++ b/src/main/claude/claude-journal-translator-contract.ts @@ -6,6 +6,7 @@ import type { ClaudeChildToolQueries } from './claude-child-tool-queries' import type { ClaudeContextReportPart, ClaudeContextReportTarget } from './claude-context-facts' import type { ClaudeJournalPrompts } from './claude-structured-journal-prompts' import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import type { ClaudeCommandStart } from './claude-command-turn' export type ClaudeJournalTranslator = { handle: (event: ClaudeStructuredSessionEvent) => void @@ -13,6 +14,18 @@ export type ClaudeJournalTranslator = { /** The open turn's provider id — the same id its journal row carries, and the one * a client's Stop names. Sole owner: no reader keeps a copy to disagree with. */ readonly currentTurnId: string | null + /** The open turn's id while it is a conversation command's. */ + readonly commandTurnId: string | null + /** Makes the host's command turn the open one until the command's result ends it. */ + beginCommand: (start: ClaudeCommandStart) => void + /** The command was never sent. */ + forgetCommand: (turnId: string) => void + /** Orca asked Claude to stop the command `turnId` names. */ + commandInterruptRequested: (turnId: string) => void + /** True while a turn is open and the provider's current request cycle has + * done root work since its init — the state in which the CLI folds an + * arriving send into the turn. */ + readonly openTurnInLiveProviderCycle: boolean flush: () => void childToolOwner?: ClaudeChildToolQueries['childToolOwner'] childActivity?: ClaudeChildToolQueries['childActivity'] diff --git a/src/main/claude/claude-journal-turn-scope.test.ts b/src/main/claude/claude-journal-turn-scope.test.ts new file mode 100644 index 00000000000..fef833ced57 --- /dev/null +++ b/src/main/claude/claude-journal-turn-scope.test.ts @@ -0,0 +1,78 @@ +// Which turn a Claude row belongs to (B8): the root turn open when the row is written, whoever +// produced it, and the conversation once that turn has ended. + +import { expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionItemAppendOptions +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +function recorder() { + const writes: { + identity: AgentJournalItemIdentity + body: AgentJournalItemBody + options: StructuredAgentSessionItemAppendOptions + }[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body, options) => writes.push({ identity, body, options }), + appendTombstone: vi.fn(), + publish: vi.fn() + } + const scopeOfProse = (text: string) => + writes.findLast( + (write) => + write.body.kind === 'message' && + write.body.blocks.some((block) => block.type === 'text' && block.text === text) + )?.options.turnScope + return { sink, writes, scopeOfProse } +} + +function frame(message: Record, startsTurn = false) { + return { + type: 'message' as const, + sessionId: 'orca-session', + ...(startsTurn ? { startsTurn: true as const } : {}), + message: { session_id: 'claude-session', ...message } + } +} + +function prose(uuid: string, text: string, parentToolUseId: string | null = null) { + return frame({ + type: 'assistant', + uuid, + parent_tool_use_id: parentToolUseId, + message: { role: 'assistant', content: [{ type: 'text', text }] } + }) +} + +it("scopes the session's and a subagent's rows to the open turn, and a late one to none", () => { + const { sink, writes, scopeOfProse } = recorder() + const translator = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'test' }) + translator.handle( + frame( + { + type: 'user', + uuid: 'user-1', + parent_tool_use_id: null, + message: { role: 'user', content: [{ type: 'text', text: 'go' }] } + }, + true + ) + ) + translator.handle(prose('assistant-1', 'Working on it')) + translator.handle(prose('child-1', 'Child mid-turn', 'toolu_1')) + translator.handle(frame({ type: 'result', subtype: 'success', uuid: 'result-1', result: 'ok' })) + translator.handle(prose('child-2', 'Child after the turn', 'toolu_1')) + + const turnRecord = writes.find((write) => write.body.kind === 'turn') + expect(turnRecord?.options.turnScope).toEqual({ kind: 'thread' }) + const turnKey = expect.stringContaining('turn-lifecycle') + expect(scopeOfProse('Working on it')).toEqual({ kind: 'turn', turnItemId: turnKey }) + expect(scopeOfProse('Child mid-turn')).toEqual({ kind: 'turn', turnItemId: turnKey }) + expect(scopeOfProse('Child after the turn')).toEqual({ kind: 'thread' }) +}) diff --git a/src/main/claude/claude-managed-hook-events.ts b/src/main/claude/claude-managed-hook-events.ts new file mode 100644 index 00000000000..dbbb8ae15a2 --- /dev/null +++ b/src/main/claude/claude-managed-hook-events.ts @@ -0,0 +1,124 @@ +import type { HookDefinition } from '../agent-hooks/installer-utils' +import { + claudeKnowsHookEvent, + claudeKnowsStatusLine, + claudeVersionReaches, + parseClaudeCliVersion +} from './claude-hook-event-versions' + +export const CLAUDE_EVENTS = [ + // Why: SessionStart is the only event a resumed/idle session emits before the + // first prompt; without it the sidebar row can't exist until the user types (STA-3386). + { + eventName: 'SessionStart', + definition: { hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'UserPromptSubmit', + definition: { hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'Stop', + definition: { hooks: [{ type: 'command', command: '' }] } + }, + // Why: OpenClaude skips normal Stop hooks after API/model errors and emits + // StopFailure instead; without this hook Orca leaves the turn spinning. + { + eventName: 'StopFailure', + definition: { hooks: [{ type: 'command', command: '' }] } + }, + // Why: subagent/teammate lifecycle feeds the sidebar's child rows and keeps + // a pane 'working' while background children outlive the lead's turn. + // TeammateIdle parks turn-based teammates without trusting their permanently + // "running" background_tasks entry to gate the pane. + { + eventName: 'SubagentStart', + definition: { hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'SubagentStop', + definition: { hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'TeammateIdle', + definition: { hooks: [{ type: 'command', command: '' }] } + }, + // Why: PreToolUse gives the dashboard a live readout of the in-flight tool + // (name + input preview) before it completes. + { + eventName: 'PreToolUse', + definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'PostToolUse', + definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'PostToolUseFailure', + definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'PermissionRequest', + definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + }, + // Why: a manual /compact ends at an idle prompt without emitting Stop, so PostCompact is the only + // signal that can clear the pane (STA-2915). PreCompact is deliberately NOT registered: it fires + // before the compact is validated, and an aborted compact emits it alone — mapping it to 'working' + // would strand the pane exactly as this registration is meant to prevent (STA-4613). + { + eventName: 'PostCompact', + definition: { hooks: [{ type: 'command', command: '' }] } + } +] as const + +const CLAUDE_SESSION_END_EVENT = { + eventName: 'SessionEnd', + // Why: Claude knows SessionEnd from 1.0.85, but 2.1.261 is the only version its delivery was measured on. + installFrom: '2.1.261', + definition: { hooks: [{ type: 'command', command: '' }] } +} as const + +export const CLAUDE_MANAGED_EVENTS = [...CLAUDE_EVENTS, CLAUDE_SESSION_END_EVENT] as const + +export type ManagedHookEvent = { + eventName: string + definition: Omit +} + +export type ClaudeManagedHookPlan = { + install: readonly ManagedHookEvent[] + /** Events whose Orca entry must go; user entries under them always stay. */ + retire: readonly ManagedHookEvent[] + /** Orca's statusLine usage feed; `retire` removes only Orca's own, `leave` never touches the slot. */ + statusLine: 'install' | 'retire' | 'leave' +} + +/** What to write for a Claude of this version; see claude-hook-event-versions.ts. */ +export function getClaudeManagedHookPlan( + claudeVersion: string | null | undefined +): ClaudeManagedHookPlan { + const install = CLAUDE_MANAGED_EVENTS.filter( + (event) => + claudeKnowsHookEvent(claudeVersion, event.eventName) && + (!('installFrom' in event) || claudeVersionReaches(claudeVersion, event.installFrom)) + ) + // Why: an unresolved version (e.g. a probe timeout) is no evidence of an old Claude, so it must + // not strip entries an install that knew the version wrote; only a known version retires events. + const retire = + parseClaudeCliVersion(claudeVersion) === null + ? [] + : CLAUDE_MANAGED_EVENTS.filter((event) => !install.includes(event)) + return { + install, + retire, + statusLine: claudeKnowsStatusLine(claudeVersion) ? 'install' : 'retire' + } +} + +// Why: OpenClaude reads its own settings file and accepts every event Orca writes. The statusline +// usage feed is Claude-only — OpenClaude data would be misattributed to the Claude provider. +export const OPENCLAUDE_MANAGED_HOOK_PLAN: ClaudeManagedHookPlan = { + install: CLAUDE_EVENTS, + retire: [CLAUDE_SESSION_END_EVENT], + statusLine: 'leave' +} diff --git a/src/main/claude/claude-model-catalog-probe.ts b/src/main/claude/claude-model-catalog-probe.ts index 882f716c3c9..f3a2646107c 100644 --- a/src/main/claude/claude-model-catalog-probe.ts +++ b/src/main/claude/claude-model-catalog-probe.ts @@ -60,7 +60,7 @@ export function createClaudeModelCatalogProbe( ...(model.description ? { description: model.description } : {}), isDefault: model.isDefault === true, // No defaultEffort: the listing's thinking default is the commit-message generator's - // choice, not the effort Claude runs, and a live session's listing never names one. + // choice, not the effort Claude runs; the store keeps the one a live child reported. efforts: (model.thinkingLevels ?? []).map((level) => ({ value: level.id, label: level.label diff --git a/src/main/claude/claude-open-turn.ts b/src/main/claude/claude-open-turn.ts index 08a7916a97b..864624fe59e 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -5,15 +5,21 @@ // keeping a copy, so there is nothing to disagree with. import type { AgentSessionContextUsage } from '../../shared/agent-session-context-usage' -import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { - claudeTurnLifecycleIdentity, + claudeCurrentTurnIdentity, claudeTurnLifecycleItem, type ClaudeCurrentTurn, type ClaudeTurnEnd } from './claude-turn-lifecycle-item' import { writeClaudeTurnRow } from './claude-turn-row-revision' +import type { ClaudeCommandTurn } from './claude-command-turn' import { createClaudeTurnOpener, type ClaudeTurnSource } from './claude-turn-opening' export type ClaudeOpenTurnDeps = { @@ -30,6 +36,11 @@ export class ClaudeOpenTurn { * failed: nothing would ever close the turn it opened, and the row would read * working for the life of the session. Only an accepted send lifts it. */ private reopenSuppressed = false + /** Whether the provider's current request cycle has done root work — a send + * echo or model output — since its init. Output can open a turn ahead of its + * cycle's init (a background task finishing), so membership is read from the + * cycle's work, not from when the turn opened. */ + private cycleWorkObserved = false private readonly opener: ( frame: Record, source: ClaudeTurnSource | null, @@ -50,9 +61,21 @@ export class ClaudeOpenTurn { /** The open turn's row, where a fact about the running turn lands. */ get identity(): AgentJournalItemIdentity | null { - return this.current - ? claudeTurnLifecycleIdentity(this.current.sessionId, this.current.turnId) - : null + return this.current ? claudeCurrentTurnIdentity(this.current) : null + } + + /** The conversation command the open turn is, if it is one. */ + get command(): ClaudeCommandTurn | null { + return this.current?.command ?? null + } + + /** Which turn a row written now belongs to: the open one, or none. A subagent's rows too — + * its work is its parent turn's. */ + get turnScope(): AgentJournalTurnScope { + const identity = this.identity + return identity + ? { kind: 'turn', turnItemId: agentJournalItemKey(identity) } + : AGENT_JOURNAL_THREAD_SCOPE } get groupKey(): string | null { @@ -63,6 +86,23 @@ export class ClaudeOpenTurn { return this.current !== null } + /** Whether a turn is open inside a provider request cycle that has already + * done work — the state in which the CLI folds an arriving send into it. A + * cycle's first send is its opener, never a fold. */ + get openedInLiveProviderCycle(): boolean { + return this.current !== null && this.cycleWorkObserved + } + + /** A root init frame: the CLI is starting a new request cycle. */ + observeProviderCycleStart(): void { + this.cycleWorkObserved = false + } + + /** A root send echo or model output inside the current request cycle. */ + observeProviderCycleWork(): void { + this.cycleWorkObserved = true + } + /** Open a turn, ending whichever one was still open. A new turn starting is the * only end the previous one gets when its result never arrives; settling it * later would sweep THIS turn. */ @@ -77,6 +117,32 @@ export class ClaudeOpenTurn { this.deps.sink.setActivity?.(null) } + /** A conversation command the host opened a turn for. Its row is the host's, already written, + * so only an end is published; the command's result is what ends it. */ + beginCommand(turn: ClaudeCurrentTurn): void { + this.deps.onOpen?.() + if (this.current) { + this.deps.settleChildren(this.groupKey) + this.publish(this.current, { state: 'interrupted', completedAt: turn.startedAt }) + } + this.current = turn + this.deps.sink.setActivity?.(null) + } + + /** Orca asked the provider to stop the command `turnId` names. */ + commandInterruptRequested(turnId: string): void { + if (this.current?.command && this.current.turnId === turnId) { + this.current.command.interruptRequested = true + } + } + + /** The command was never sent; its turn is the host's to settle. */ + forgetCommand(turnId: string): void { + if (this.current?.command && this.current.turnId === turnId) { + this.current = null + } + } + /** The provider produced, so a turn is running. Idempotent: every frame of one * reply stays inside the turn its first frame opened. A subagent's output is * its parent turn's work and never a turn of its own. */ @@ -91,6 +157,8 @@ export class ClaudeOpenTurn { /** End the open turn, if one is open, and clear the live activity line. The * context facts the end brings ride the same revision. */ settle(end: ClaudeTurnEnd, contextUsage?: AgentSessionContextUsage): void { + // Every settle is a provider cycle ending (result, idle, child exit). + this.cycleWorkObserved = false if (this.current) { this.publish(this.current, end, contextUsage) this.current = null diff --git a/src/main/claude/claude-prompt-journaling.ts b/src/main/claude/claude-prompt-journaling.ts deleted file mode 100644 index 99b163fe197..00000000000 --- a/src/main/claude/claude-prompt-journaling.ts +++ /dev/null @@ -1,46 +0,0 @@ -// Journaling an approval or question prompt, and remembering the rows it wrote -// so a cancellation can tombstone exactly those. - -import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' -import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' -import { - claudeApprovalItem, - claudePromptIdentity, - claudeQuestionItems -} from './claude-structured-prompt-items' - -export type ClaudePromptJournalDeps = { - sink: StructuredAgentSessionEventSink - bindPromptItemId?: (journalItemId: string, promptKey: string, questionId?: string) => void - /** Prompt key → the rows it wrote, owned by the translator so a cancel can sweep them. */ - promptItems: Map -} - -export function journalClaudePrompt( - deps: ClaudePromptJournalDeps, - event: Extract -): void { - const identities: AgentJournalItemIdentity[] = [] - if (event.prompt.kind === 'question') { - for (const question of claudeQuestionItems({ - sessionId: event.sessionId, - prompt: event.prompt - })) { - identities.push(question.identity) - deps.sink.appendItem(question.identity, question.body) - deps.bindPromptItemId?.(agentJournalItemKey(question.identity), event.prompt.promptKey) - } - } else { - const identity = claudePromptIdentity({ - sessionId: event.sessionId, - promptKey: event.prompt.promptKey - }) - identities.push(identity) - deps.sink.appendItem(identity, claudeApprovalItem(event.prompt)) - deps.bindPromptItemId?.(agentJournalItemKey(identity), event.prompt.promptKey) - } - deps.promptItems.set(event.prompt.promptKey, identities) - deps.sink.publish() -} diff --git a/src/main/claude/claude-prompt-registry.ts b/src/main/claude/claude-prompt-registry.ts index 3437dba93dc..5cd2f6b1a7b 100644 --- a/src/main/claude/claude-prompt-registry.ts +++ b/src/main/claude/claude-prompt-registry.ts @@ -26,7 +26,6 @@ export type ClaudePendingPrompt = ClaudePromptPresentation & { input: Record suggestions: PermissionUpdate[] questionIds: readonly string[] - answers: Map settle: ClaudePromptSettle turnId?: string | null } @@ -43,13 +42,12 @@ export type ClaudePromptRegistration = ClaudePromptPresentation & { type PromptBinding = { address: string - questionId?: string turnId: string | null } export type ClaudePromptClaim = { readonly itemId: string - readonly found: { prompt: ClaudePendingPrompt; questionId?: string } + readonly found: { prompt: ClaudePendingPrompt } } type ClaudePromptCancellationObservation = { @@ -111,7 +109,6 @@ export class ClaudePromptRegistry { ...(registration.matchedAskRule ? { matchedAskRule: registration.matchedAskRule } : {}), ...(registration.subject ? { subject: registration.subject } : {}), questionIds: questions.map(questionId), - answers: new Map(), settle: registration.settle, turnId: registration.turnId ?? null } @@ -130,26 +127,18 @@ export class ClaudePromptRegistry { return true } - bindJournalItemId( - journalItemId: string, - promptKey: string, - questionIdForItem?: string, - turnId: string | null = null - ): void { + bindJournalItemId(journalItemId: string, promptKey: string, turnId: string | null = null): void { const prompt = this.prompts.get(promptKey) this.journalBindings.set(journalItemId, { address: promptKey, - ...(questionIdForItem ? { questionId: questionIdForItem } : {}), turnId: turnId ?? prompt?.turnId ?? null }) } - find(itemId: string): { prompt: ClaudePendingPrompt; questionId?: string } | null { + find(itemId: string): { prompt: ClaudePendingPrompt } | null { const binding = this.journalBindings.get(itemId) const prompt = this.prompts.get(binding?.address ?? itemId) - return prompt - ? { prompt, ...(binding?.questionId ? { questionId: binding.questionId } : {}) } - : null + return prompt ? { prompt } : null } claim(itemId: string, kind?: 'approval' | 'question'): ClaudePromptClaim | null { @@ -168,8 +157,7 @@ export class ClaudePromptRegistry { if (!binding || !prompt || binding.turnId !== turnId || this.claims.has(prompt)) { return null } - const found = { prompt, ...(binding.questionId ? { questionId: binding.questionId } : {}) } - const claim = { itemId, found } + const claim = { itemId, found: { prompt } } this.claims.set(prompt, claim) return claim } diff --git a/src/main/claude/claude-provisional-row-corrections.test.ts b/src/main/claude/claude-provisional-row-corrections.test.ts index d18a71a17fe..cb317c5ca86 100644 --- a/src/main/claude/claude-provisional-row-corrections.test.ts +++ b/src/main/claude/claude-provisional-row-corrections.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it } from 'vitest' import type { AgentJournalItemBody, @@ -38,6 +39,7 @@ function ledger(initial: Record = {}) { : { kind: 'linked', linkage: { agentId: ref, providerParentRef: ref, producerKind: 'agent' } } } const corrections = new ClaudeProvisionalRowCorrections({ + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE, linkageFor: (ref) => verdicts.get(ref) ?? { kind: 'pending' }, settledLinkageFor: (ref) => { const verdict = settledFor(ref) @@ -71,9 +73,11 @@ function ledger(initial: Record = {}) { } describe('ClaudeProvisionalRowCorrections', () => { - it("stamps the session's own rows with nothing and owes them nothing", () => { + it("stamps the session's own rows with no producer and owes them nothing", () => { const { corrections, rewrites } = ledger() - expect(corrections.stampFor(null)(identityOf('toolu_2'), RUNNING)).toEqual({}) + expect(corrections.stampFor(null)(identityOf('toolu_2'), RUNNING)).toEqual({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) expect(corrections.pending).toBe(0) corrections.retry() expect(rewrites).toEqual([]) diff --git a/src/main/claude/claude-provisional-row-corrections.ts b/src/main/claude/claude-provisional-row-corrections.ts index a4b7d039902..92064e23f02 100644 --- a/src/main/claude/claude-provisional-row-corrections.ts +++ b/src/main/claude/claude-provisional-row-corrections.ts @@ -21,9 +21,11 @@ import { agentJournalLinkageFields } from '../../shared/agent-session-journal-pr import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { AgentJournalItemBody, - AgentJournalItemIdentity + AgentJournalItemIdentity, + AgentJournalProducerLinkage, + AgentJournalTurnScope } from '../../shared/agent-session-journal-types' -import type { StructuredAgentSessionAppendOptions } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { StructuredAgentSessionItemAppendOptions } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { ClaudeSubagentLinkageSource } from './claude-subagent-linkage' /** @@ -36,12 +38,7 @@ import type { ClaudeSubagentLinkageSource } from './claude-subagent-linkage' export type ClaudeRowStamp = ( identity: AgentJournalItemIdentity, body: AgentJournalItemBody -) => StructuredAgentSessionAppendOptions - -/** The session's own agent wrote this row: nothing is stamped, nothing is owed. - * Only for a site with no ledger to consult; a ledger-backed root write goes - * through `stampFor(null)`, which also supersedes anything owed to the row. */ -export const rootClaudeRowStamp: ClaudeRowStamp = () => ({}) +) => StructuredAgentSessionItemAppendOptions /** Corrections outstanding at once, across every producer. */ const MAX_OUTSTANDING_CORRECTIONS = 256 @@ -57,17 +54,19 @@ type OutstandingCorrection = { * a tool call and its result do — and a correction carrying the older body * would revert the row it is only meant to re-attribute. */ body: AgentJournalItemBody - stamped: StructuredAgentSessionAppendOptions + stamped: StructuredAgentSessionItemAppendOptions } export type ClaudeProvisionalRowCorrectionsDeps = ClaudeSubagentLinkageSource & { + /** The turn a row written now belongs to: the open root turn, whoever produced the row. */ + turnScope: () => AgentJournalTurnScope /** Re-appends a row under its own identity, which revises it in place. * Returns whether the write was ADMITTED: a sink under backpressure refuses, * and a correction dropped on a refusal is an obligation nothing re-derives. */ rewrite: ( identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - options: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ) => boolean publish: () => void } @@ -92,12 +91,12 @@ export class ClaudeProvisionalRowCorrections { if (parentToolUseId === null) { return (identity) => { this.supersede(identity) - return {} + return { turnScope: this.deps.turnScope() } } } return (identity, body) => { const provisional = this.deps.linkageFor(parentToolUseId).kind === 'pending' - const options = this.stamp(parentToolUseId) + const options = { ...this.stamp(parentToolUseId), turnScope: this.deps.turnScope() } if (provisional) { this.remember(parentToolUseId, identity, body, options) } else { @@ -160,7 +159,7 @@ export class ClaudeProvisionalRowCorrections { this.outstanding.delete(agentJournalItemKey(identity)) } - private stamp(parentToolUseId: string): StructuredAgentSessionAppendOptions { + private stamp(parentToolUseId: string): ReturnType { return agentJournalLinkageFields(this.deps.settledLinkageFor(parentToolUseId).linkage) } @@ -171,14 +170,16 @@ export class ClaudeProvisionalRowCorrections { if (sameLinkage(options, correction.stamped)) { return 'unchanged' } - return this.deps.rewrite(correction.identity, correction.body, options) ? 'wrote' : 'refused' + // A re-stamp revises the row, so its scope stays the one it was created with. + const restamped = { ...options, turnScope: correction.stamped.turnScope } + return this.deps.rewrite(correction.identity, correction.body, restamped) ? 'wrote' : 'refused' } private remember( ref: string, identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - stamped: StructuredAgentSessionAppendOptions + stamped: StructuredAgentSessionItemAppendOptions ): void { if (this.givenUp.has(ref)) { return @@ -233,8 +234,8 @@ export class ClaudeProvisionalRowCorrections { } function sameLinkage( - left: StructuredAgentSessionAppendOptions, - right: StructuredAgentSessionAppendOptions + left: AgentJournalProducerLinkage, + right: AgentJournalProducerLinkage ): boolean { return ( left.agentId === right.agentId && diff --git a/src/main/claude/claude-real-cli-availability-test-support.ts b/src/main/claude/claude-real-cli-availability-test-support.ts new file mode 100644 index 00000000000..faa322c456b --- /dev/null +++ b/src/main/claude/claude-real-cli-availability-test-support.ts @@ -0,0 +1,64 @@ +// Whether a real, signed-in Claude CLI is present — the gate every real-CLI +// suite skips on. Probed once per test process. + +import { homedir } from 'node:os' +import { join } from 'node:path' +import { runProcessSync, type ProcessResult } from '../../shared/child-process/run-process' +import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' +import { CLAUDE_AUTH_ENV_VARS } from '../claude-accounts/environment' +import { resolveClaudeCommand } from '../codex-cli/command' + +export const realClaudeCommand = resolveClaudeCommand() + +// Why paired: the probe must run the CLI under the same node the structured launch gives it. +function probeRealClaude(args: string[]): ProcessResult | null { + try { + return runProcessSync({ + program: realClaudeCommand, + args, + env: withCliRuntimeOnPath(realClaudeCommand, process.env), + stdio: ['ignore', 'pipe', 'pipe'], + timeoutMs: 5_000 + }) + } catch { + return null + } +} + +export const realClaudeAvailable = probeRealClaude(['--version'])?.code === 0 + +/** The CLI's own account report — the only source of truth for where it writes that + * is not derived from Orca's own path expressions. */ +export const realClaudeAuthStatus = (() => { + if (!realClaudeAvailable) { + return null + } + const result = probeRealClaude(['auth', 'status', '--json']) + if (!result || result.code !== 0) { + return null + } + try { + return JSON.parse(result.stdout) as { loggedIn?: boolean; projectsDirectory?: string } + } catch { + return null + } +})() + +export const realClaudeAuthenticated = realClaudeAuthStatus?.loggedIn === true + +/** The config dir and env auth the availability probe above saw, for a real-CLI launch. + * The connection strips an inherited CLAUDE_CONFIG_DIR and inherited auth vars, so + * without these the child silently runs against ~/.claude whatever the probe checked. */ +export function realClaudeLaunchHome(): { claudeConfigDir: string; env: Record } { + const env: Record = {} + for (const key of CLAUDE_AUTH_ENV_VARS) { + const value = process.env[key] + if (value) { + env[key] = value + } + } + return { + claudeConfigDir: process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude'), + env + } +} diff --git a/src/main/claude/claude-replay-turn-resolution.ts b/src/main/claude/claude-replay-turn-resolution.ts new file mode 100644 index 00000000000..9db40feac49 --- /dev/null +++ b/src/main/claude/claude-replay-turn-resolution.ts @@ -0,0 +1,227 @@ +// Which dispatch a provider frame settles, and whether it opens a turn. +// +// A replay or result is joined to its waiter by the client uuid Claude echoes. +// A send Claude FOLDS into the running request cycle is replayed mid-cycle with +// the client uuid adopted: once that cycle has done work, that replay is a +// delivery receipt and opens no boundary. A `command_lifecycle` frame opens no turn; its +// `cancelled` can settle a send the CLI withdrew (`claude-command-lifecycle.ts`). + +import { observeClaudeCommandLifecycle } from './claude-command-lifecycle' +import { forgetRetiredWaiter } from './claude-structured-dispatch-waiters' +import { + claudeHasReplayContent, + readClaudeMessageEnvelope +} from './claude-structured-item-translation' +import type { + ClaudeDispatchWaiter, + ClaudeLateDispatchOutcome, + ClaudeSession +} from './claude-structured-session-state' +import { readClaudeFrameString } from './claude-structured-init-proof' +import { claudeDispatchContentKey } from './claude-structured-dispatch-content' + +/** Settles a provider-proven late outcome; replay rows independently reconcile acceptance. */ +export type ClaudeLateDispatchSettlement = (input: ClaudeLateDispatchOutcome) => void + +export type ClaudeReplayTurnOrigin = { requestedAt: number | null } + +export function resolveClaudeReplayTurn( + session: ClaudeSession, + message: Record, + onSettledLate?: ClaudeLateDispatchSettlement +): ClaudeReplayTurnOrigin | null { + if (message.type === 'command_lifecycle') { + observeClaudeCommandLifecycle(session, message, onSettledLate) + return null + } + const envelope = readClaudeMessageEnvelope(message) + const isUserReplay = + envelope?.role === 'user' && + message.parent_tool_use_id === null && + claudeHasReplayContent(envelope) + const isCompletedCommand = message.type === 'result' + if ( + (!isUserReplay && !isCompletedCommand) || + readClaudeFrameString(message, 'session_id') !== session.providerSessionId + ) { + return null + } + const uuid = readClaudeFrameString(message, 'uuid') + if (!uuid) { + return null + } + + // Newer SDK frames carry the client uuid that caused a turn. A correlation + // value is authoritative: never fall back to queue order or content, since + // identical prompts may be in flight across a timeout boundary. + const userMessageUuid = readClaudeFrameString(message, 'user_message_uuid') + // A folded turn's result names every send it ran under `user_message_uuids`. + // Each member settles its own waiter under its own uuid — the result frame's + // uuid is never adopted as a shared alias for multiple submissions. + const resultUserMessageUuids = isCompletedCommand ? claudeResultUserMessageUuids(message) : [] + for (const member of resultUserMessageUuids) { + if (member === userMessageUuid) { + continue + } + const live = session.dispatchWaiters.find((candidate) => candidate.sentUuid === member) + if (live) { + settleWaiter(session, live, member, onSettledLate) + continue + } + const late = session.retiredDispatchWaiters.find((candidate) => candidate.sentUuid === member) + if (late) { + forgetRetiredWaiter(session, late) + recoverLateIdentity(session, late, member, false, onSettledLate) + } + } + if (userMessageUuid) { + const exact = session.dispatchWaiters.find( + (candidate) => candidate.sentUuid === userMessageUuid + ) + if (exact) { + const foldReceipt = isUserReplay && claudeReplayIsFoldReceipt(session, exact, uuid) + settleWaiter(session, exact, uuid, onSettledLate) + return isUserReplay && !foldReceipt ? { requestedAt: exact.requestedAt } : null + } + const retired = session.retiredDispatchWaiters.find( + (candidate) => candidate.sentUuid === userMessageUuid + ) + if (retired) { + forgetRetiredWaiter(session, retired) + recoverLateIdentity(session, retired, uuid, isUserReplay, onSettledLate) + return null + } + return null + } + if (resultUserMessageUuids.length > 0) { + // The plural list is this result's complete correlation; queue order must + // not join anyone it did not name. + return null + } + + const exact = session.dispatchWaiters.find((candidate) => candidate.sentUuid === uuid) + if (exact) { + const foldReceipt = isUserReplay && claudeReplayIsFoldReceipt(session, exact, uuid) + settleWaiter(session, exact, uuid, onSettledLate) + return isUserReplay && !foldReceipt ? { requestedAt: exact.requestedAt } : null + } + const retired = session.retiredDispatchWaiters.find((candidate) => candidate.sentUuid === uuid) + if (retired) { + forgetRetiredWaiter(session, retired) + recoverLateIdentity(session, retired, uuid, isUserReplay, onSettledLate) + return null + } + + if (isUserReplay) { + // Compatibility CLIs may mint a new replay uuid instead of echoing the + // client uuid. Content is an acceptable join only when it is the sole + // candidate on one side of the timeout boundary; with active and retired + // candidates present, identical prompts are intentionally left unknown. + const replayContentKey = claudeDispatchContentKey(envelope.content) + if (!session.replayContentFallbackBlocked && session.retiredDispatchWaiters.length === 0) { + const compatible = session.dispatchWaiters.filter( + (candidate) => candidate.replayContentKey === replayContentKey + ) + if (compatible.length === 1) { + const [candidate] = compatible + settleWaiter(session, candidate!, uuid, onSettledLate) + return { requestedAt: candidate!.requestedAt } + } + } else if (!session.replayContentFallbackBlocked && session.dispatchWaiters.length === 0) { + const lateCompatible = session.retiredDispatchWaiters.filter( + (candidate) => candidate.replayContentKey === replayContentKey + ) + if (lateCompatible.length === 1) { + const [candidate] = lateCompatible + forgetRetiredWaiter(session, candidate!) + recoverLateIdentity(session, candidate!, uuid, true, onSettledLate) + return null + } + } + return null + } + const current = session.dispatchWaiters[0] + if (isCompletedCommand && !current?.acceptsResult) { + return null + } + // A legacy result has no dispatch correlation. Any retired waiter makes queue order ambiguous, + // even when the retired dispatch was an ordinary turn rather than a slash command. + if (isCompletedCommand && session.retiredDispatchWaiters.length > 0) { + return null + } + // Once an eviction occurred, a fresh result uuid cannot be joined to a waiter by queue order. + if (isCompletedCommand && session.replayContentFallbackBlocked) { + return null + } + const waiter = uuid ? session.dispatchWaiters.shift() : undefined + if (waiter && uuid) { + settleWaiter(session, waiter, uuid, onSettledLate) + return isUserReplay ? { requestedAt: waiter.requestedAt } : null + } + return null +} + +/** The provider's own cycle state decides a fold: the CLI folds a send into the + * request cycle that is running when the send arrives, and it replays a folded + * send mid-cycle with the client uuid ADOPTED (measured: fold-fresh/-resumed, + * two-steers, early-steer). So an adopted replay after the running cycle has + * done work is a delivery receipt, not a turn boundary; a cycle's first send is + * its opener. Adoption is the capability check, read off the frame itself: a + * CLI that mints fresh replay uuids never qualifies. A new cycle announces + * itself with a root init (per-turn, measured), so a lost result cannot leave a + * stale turn swallowing the next turn's replay. */ +function claudeReplayIsFoldReceipt( + session: ClaudeSession, + waiter: ClaudeDispatchWaiter, + replayUuid: string +): boolean { + return replayUuid === waiter.sentUuid && session.translator?.openTurnInLiveProviderCycle === true +} + +function claudeResultUserMessageUuids(message: Record): string[] { + const uuids = message.user_message_uuids + return Array.isArray(uuids) + ? uuids.filter((member): member is string => typeof member === 'string' && member.length > 0) + : [] +} + +function settleWaiter( + session: ClaudeSession, + waiter: ClaudeDispatchWaiter, + uuid: string, + onSettledLate?: ClaudeLateDispatchSettlement +): void { + const index = session.dispatchWaiters.indexOf(waiter) + if (index !== -1) { + session.dispatchWaiters.splice(index, 1) + } + waiter.settledUuid = uuid + waiter.resolve(uuid) + // Dispatch returned on admission, so the replay is what settles delivery. + if (waiter.clientMessageId) { + onSettledLate?.({ + clientMessageId: waiter.clientMessageId, + providerIdentity: { provider: 'claude', sessionId: session.providerSessionId, uuid } + }) + } +} + +function recoverLateIdentity( + session: ClaudeSession, + waiter: ClaudeDispatchWaiter, + uuid: string, + isUserReplay: boolean, + onSettledLate?: ClaudeLateDispatchSettlement +): void { + if (!isUserReplay && !waiter.acceptsResult) { + return + } + // The provider acted on this dispatch, so the send it came from is delivered. + // A retired replay settles delivery only; it cannot reopen a turn. + if (waiter.clientMessageId) { + onSettledLate?.({ + clientMessageId: waiter.clientMessageId, + providerIdentity: { provider: 'claude', sessionId: session.providerSessionId, uuid } + }) + } +} diff --git a/src/main/claude/claude-result-journaling.ts b/src/main/claude/claude-result-journaling.ts new file mode 100644 index 00000000000..14fd2e94eb6 --- /dev/null +++ b/src/main/claude/claude-result-journaling.ts @@ -0,0 +1,88 @@ +// Journaling ONE Claude `result` frame: the end of the root turn it settles, and the diagnostic +// row a failed or unrecognised result leaves. +// +// Split out of the translator when that file reached its line budget; the translator still owns +// the open turn and every collaborator this writes through. + +import type { ClaudeContextFacts } from './claude-context-facts' +import { claudeCommandResultEnd } from './claude-command-turn' +import type { ClaudeMessageJournalContext } from './claude-message-journaling' +import type { ClaudeJournalPrompts } from './claude-structured-journal-prompts' +import { + claudeProviderFrameKind, + claudeResultFailure, + isSettledClaudeResultKind +} from './claude-structured-provider-fallback' +import { claudeTurnEndForResult } from './claude-turn-lifecycle-item' +import { claudeFrameParentRef, isRootClaudeFrame } from './claude-turn-opening' + +export type ClaudeResultJournalContext = Pick< + ClaudeMessageJournalContext, + | 'sink' + | 'streamedBlocks' + | 'streamedText' + | 'subagents' + | 'providerFallback' + | 'corrections' + | 'turn' +> & { + prompts: ClaudeJournalPrompts + context: ClaudeContextFacts +} + +export function journalClaudeResult( + { + sink, + streamedBlocks, + streamedText, + subagents, + providerFallback, + corrections, + turn, + prompts, + context + }: ClaudeResultJournalContext, + message: Record, + observedAt: number +): void { + // Every turn this translator opens is root by construction, so a nested + // result settles the child that produced it and never the turn. The + // diagnostic below still runs: a child's failure is reportable even when + // it ends no turn. + const settlesTurn = isRootClaudeFrame(message) + const commandEnd = settlesTurn ? claudeCommandResultEnd(turn, sink, message, observedAt) : null + if (commandEnd === 'another-input') { + return + } + // Read before the settle below closes it: the result reports that turn's end. + const endedTurnScope = turn.turnScope + if (settlesTurn) { + prompts.retryPendingCancellations() + turn.suppressReopenOnFailure(message.is_error === true) + // The turn is over however it ended, so a foreground child still + // reported as working will never be settled by an event. + subagents.settleTurn(turn.groupKey) + context.settle(message, commandEnd ?? claudeTurnEndForResult(message, observedAt)) + // The turn is over. A block still awaiting its final keeps the text the + // flush above journaled, but its live state goes: an interrupted turn + // would otherwise retain that text for the life of the session. + streamedBlocks.clear() + streamedText.settle() + } + const kind = claudeProviderFrameKind(message) + const failure = claudeResultFailure(message) + if (failure || !isSettledClaudeResultKind(kind)) { + providerFallback.append( + kind, + message, + failure?.text, + undefined, + undefined, + // A result that settles no turn is a CHILD's result: this + // translator only ever opens root turns. + settlesTurn + ? () => ({ turnScope: endedTurnScope }) + : corrections.stampFor(claudeFrameParentRef(message)) + ) + } +} diff --git a/src/main/claude/claude-session-end-hook-capability.test.ts b/src/main/claude/claude-session-end-hook-capability.test.ts deleted file mode 100644 index 755cdcbe498..00000000000 --- a/src/main/claude/claude-session-end-hook-capability.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - CLAUDE_SESSION_END_CAPABILITY_FLOOR, - claudeVersionSupportsSessionEnd, - parseClaudeCliVersion -} from './claude-session-end-hook-capability' - -describe('Claude SessionEnd hook version capability', () => { - it('records 2.1.261 as the measured floor', () => { - expect(CLAUDE_SESSION_END_CAPABILITY_FLOOR).toBe('2.1.261') - }) - - it('extracts Claude Code version output', () => { - expect(parseClaudeCliVersion('2.1.261 (Claude Code)')).toBe('2.1.261') - }) - - it.each([ - ['2.1.260', false], - ['2.1.261', true], - ['2.2.0', true], - ['unknown', false], - [undefined, false] - ])('classifies %s as SessionEnd-capable: %s', (version, expected) => { - expect(claudeVersionSupportsSessionEnd(version)).toBe(expected) - }) -}) diff --git a/src/main/claude/claude-session-end-hook-capability.ts b/src/main/claude/claude-session-end-hook-capability.ts deleted file mode 100644 index 1587c154e7b..00000000000 --- a/src/main/claude/claude-session-end-hook-capability.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { hasReachedAppVersion, isValidAppVersion } from '../../shared/app-version' -import { runProcess } from '../../shared/child-process/run-process' -import path from 'node:path' - -// 2.1.261 is the only version measured, not an established minimum. -export const CLAUDE_SESSION_END_CAPABILITY_FLOOR = '2.1.261' - -export function parseClaudeCliVersion(output: string | null | undefined): string | null { - const version = output?.match(/\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\b/)?.[0] - return version && isValidAppVersion(version) ? version : null -} - -export function claudeVersionSupportsSessionEnd(version: string | null | undefined): boolean { - const parsed = parseClaudeCliVersion(version) - return parsed !== null && hasReachedAppVersion(parsed, CLAUDE_SESSION_END_CAPABILITY_FLOOR) -} - -export async function probeClaudeCliVersion(executablePath: string): Promise { - try { - const pathKey = process.platform === 'win32' && process.env.Path !== undefined ? 'Path' : 'PATH' - const executableDir = path.dirname(executablePath) - const inheritedPath = process.env[pathKey] - const result = await runProcess({ - program: executablePath, - args: ['--version'], - // Why: version-manager launchers often use `#!/usr/bin/env node`; the resolved CLI's sibling - // runtime must remain reachable even when Electron started with a thinner PATH. - env: { - ...process.env, - [pathKey]: inheritedPath - ? `${executableDir}${path.delimiter}${inheritedPath}` - : executableDir - }, - timeoutMs: 5_000, - maxOutputBytes: 4_096 - }) - return result.code === 0 ? parseClaudeCliVersion(`${result.stdout}\n${result.stderr}`) : null - } catch { - return null - } -} diff --git a/src/main/claude/claude-session-end-install.test.ts b/src/main/claude/claude-session-end-install.test.ts deleted file mode 100644 index 46f374ca7ae..00000000000 --- a/src/main/claude/claude-session-end-install.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { applyManagedHooks } from './hook-settings' - -const SCRIPT_FILE_NAME = 'claude-hook.sh' -const MANAGED_COMMAND = '/home/dev/.orca/agent-hooks/claude-hook.sh' -const managedHook = { type: 'command' as const, command: MANAGED_COMMAND } - -describe('Claude SessionEnd managed hook capability', () => { - it('installs SessionEnd beside SessionStart for the measured capable version', () => { - const written = applyManagedHooks({ hooks: {} }, managedHook, SCRIPT_FILE_NAME, { - claudeVersion: '2.1.261 (Claude Code)' - }) - - expect(written.hooks?.SessionEnd?.[0]?.hooks?.[0]?.command).toBe(MANAGED_COMMAND) - expect(written.hooks?.SessionStart?.[0]?.hooks?.[0]?.command).toBe(MANAGED_COMMAND) - }) - - it.each(['2.1.260', 'unknown', undefined])( - 'retains the legacy event set for an incapable or unverified host (%s)', - (claudeVersion) => { - const written = applyManagedHooks({ hooks: {} }, managedHook, SCRIPT_FILE_NAME, { - claudeVersion - }) - - expect(written.hooks?.SessionEnd).toBeUndefined() - expect(written.hooks?.SessionStart).toBeDefined() - } - ) - - it('removes only Orca SessionEnd during a capability downgrade', () => { - const capable = applyManagedHooks( - { - hooks: { - SessionEnd: [{ hooks: [{ type: 'command', command: 'echo user-session-end' }] }] - } - }, - managedHook, - SCRIPT_FILE_NAME, - { claudeVersion: '2.1.261' } - ) - const downgraded = applyManagedHooks(capable, managedHook, SCRIPT_FILE_NAME, { - claudeVersion: '2.1.260' - }) - - expect(downgraded.hooks?.SessionEnd).toEqual([ - { hooks: [{ type: 'command', command: 'echo user-session-end' }] } - ]) - }) -}) diff --git a/src/main/claude/claude-settings-schema-install.test.ts b/src/main/claude/claude-settings-schema-install.test.ts new file mode 100644 index 00000000000..577e455b6a3 --- /dev/null +++ b/src/main/claude/claude-settings-schema-install.test.ts @@ -0,0 +1,59 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import fixture from './__fixtures__/claude-hook-event-enums.json' + +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/userData' + } +})) + +import { ClaudeHookService } from './hook-service' + +const enums: Record = fixture.enums +const topLevelSettings: Record = + fixture.topLevelSettings +const strictReleases = Object.keys(topLevelSettings).filter( + (version) => topLevelSettings[version].strict +) + +// Why: checks the whole written file, so any new key Orca writes must pass each strict schema. +describe('Claude settings written for a strict settings schema', () => { + let tmpHome: string + let settingsPath: string + + beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-settings-schema-')) + settingsPath = join(tmpHome, '.claude', 'settings.json') + vi.stubEnv('HOME', tmpHome) + vi.stubEnv('USERPROFILE', tmpHome) + }) + + afterEach(() => { + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + }) + + function expectAcceptedBy(version: string): void { + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')) + const { keys } = topLevelSettings[version] + expect(Object.keys(written).filter((key) => !keys.includes(key))).toEqual([]) + expect( + Object.keys(written.hooks ?? {}).filter((event) => !enums[version].includes(event)) + ).toEqual([]) + } + + it.each(strictReleases)('a fresh install writes only what Claude %s accepts', (version) => { + new ClaudeHookService().install({ claudeVersion: version }) + expectAcceptedBy(version) + }) + + it.each(strictReleases)('a downgrade to Claude %s leaves only what it accepts', (version) => { + const service = new ClaudeHookService() + service.install({ claudeVersion: '2.1.261' }) + service.install({ claudeVersion: version }) + expectAcceptedBy(version) + }) +}) diff --git a/src/main/claude/claude-status-line-install.test.ts b/src/main/claude/claude-status-line-install.test.ts new file mode 100644 index 00000000000..3c7c9c6f83c --- /dev/null +++ b/src/main/claude/claude-status-line-install.test.ts @@ -0,0 +1,75 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/userData' + } +})) + +import { ClaudeHookService } from './hook-service' + +const USER_STATUS_LINE = { type: 'command', command: '/usr/local/bin/my-statusline' } + +describe('Claude statusLine by resolved version', () => { + let tmpHome: string + let settingsPath: string + + beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-statusline-version-')) + settingsPath = join(tmpHome, '.claude', 'settings.json') + vi.stubEnv('HOME', tmpHome) + vi.stubEnv('USERPROFILE', tmpHome) + }) + + afterEach(() => { + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + }) + + const readSettings = () => JSON.parse(readFileSync(settingsPath, 'utf-8')) + + it('writes no statusLine for a Claude whose settings schema rejects it', () => { + new ClaudeHookService().install({ claudeVersion: '1.0.63' }) + expect(readSettings().statusLine).toBeUndefined() + expect(readSettings().hooks.SessionStart).toBeDefined() + }) + + it('removes Orca statusLine on a downgrade and re-adds it after the upgrade', () => { + const service = new ClaudeHookService() + service.install({ claudeVersion: '2.1.261' }) + expect(readSettings().statusLine?.command).toContain('claude-statusline') + + service.install({ claudeVersion: '1.0.63' }) + expect(readSettings().statusLine).toBeUndefined() + + // Why: Orca's own removal must not read as the user's opt-out on the next capable install. + service.install({ claudeVersion: '1.0.64' }) + expect(readSettings().statusLine?.command).toContain('claude-statusline') + }) + + it('keeps the user opt-out across a downgrade and upgrade', () => { + const service = new ClaudeHookService() + service.install({ claudeVersion: '2.1.261' }) + const { statusLine: _optedOut, ...rest } = readSettings() + writeFileSync(settingsPath, JSON.stringify(rest)) + + service.install({ claudeVersion: '1.0.63' }) + service.install({ claudeVersion: '2.1.261' }) + expect(readSettings().statusLine).toBeUndefined() + }) + + it('never removes a user statusLine for an old Claude', () => { + mkdirSync(join(tmpHome, '.claude'), { recursive: true }) + writeFileSync(settingsPath, JSON.stringify({ statusLine: USER_STATUS_LINE })) + new ClaudeHookService().install({ claudeVersion: '1.0.63' }) + expect(readSettings().statusLine).toEqual(USER_STATUS_LINE) + }) + + it('keeps installing the statusLine when the version is unresolved', () => { + new ClaudeHookService().install() + expect(readSettings().statusLine?.command).toContain('claude-statusline') + }) +}) diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index 51ffa085163..e3682f03306 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process' import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { providerDiagnosticOf } from '../../shared/agent-session-failure' import { afterEach, describe, expect, it, vi } from 'vitest' import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process' import { hasLiveClaudePtys } from '../claude-accounts/live-pty-gate' @@ -106,6 +107,18 @@ async function open( return connection } +function launchedArgv(spec: ProcessSpec | undefined): string[] { + const supervised = spec?.env?.ORCA_PROVIDER_SUPERVISOR_SPEC + if (!supervised) { + return [spec?.program ?? '', ...(spec?.args ?? [])] + } + const launch: unknown = JSON.parse(Buffer.from(supervised, 'base64').toString()) + if (!launch || typeof launch !== 'object' || !('command' in launch) || !('args' in launch)) { + return [] + } + return [String(launch.command), ...(Array.isArray(launch.args) ? launch.args.map(String) : [])] +} + function childEnv(): Record { return (spawned.at(-1)?.env ?? {}) as Record } @@ -163,9 +176,12 @@ describe('Claude stream-json connection', () => { // An inherited value wins over the SDK's default, so clear it to pin the default. vi.stubEnv('CLAUDE_CODE_ENTRYPOINT', undefined) vi.stubEnv('ORCA_CONNECTION_MARKER', 'inherited') + // An Orca launched inside another structured session inherits that session's id. + vi.stubEnv('ORCA_AGENT_SESSION_ID', 'a0b1c2d3-0000-4000-8000-00000000abcd') const scenario = scriptScenario([HOLD_OPEN]) const connection = await open( launchFor(scenario, { + ORCA_AGENT_SESSION_ID: 'f7a1c0de-1111-4222-8333-444455556666', CLAUDE_CONFIG_DIR: '/accounts/managed/home', ANTHROPIC_AUTH_TOKEN: 'configured-token', ORCA_AGENT_SESSION_SPAWN_TOKEN: 'spawn-9', @@ -184,6 +200,8 @@ describe('Claude stream-json connection', () => { expect(env.ANTHROPIC_AUTH_TOKEN).toBe('configured-token') expect(env.ORCA_AGENT_SESSION_SPAWN_TOKEN).toBe('spawn-9') expect(env.ORCA_CONNECTION_MARKER).toBe('inherited') + // The session's own id reaches the spawned child over the inherited one. + expect(env.ORCA_AGENT_SESSION_ID).toBe('f7a1c0de-1111-4222-8333-444455556666') expect(env.ANTHROPIC_API_KEY).toBeUndefined() expect(env.CLAUDE_CODE_CHILD_SESSION).toBeUndefined() expect(env.CLAUDE_CODE_SESSION_ID).toBeUndefined() @@ -195,8 +213,9 @@ describe('Claude stream-json connection', () => { const report = await until(() => readReportSafely(scenario), 'the scripted CLI report') expect(report.argv[0]).toBe(FAKE_CLI) // The .mjs fixture makes the SDK run it under node; a real CLI path is the program - // itself. Either way the resolved path is what Orca's spawner is asked to execute. - expect([spawned.at(-1)?.program, ...(spawned.at(-1)?.args ?? [])]).toContain(FAKE_CLI) + // itself. Either way the resolved path is what Orca's spawner is asked to execute, + // through the POSIX supervisor's spec where there is one. + expect(launchedArgv(spawned.at(-1))).toContain(FAKE_CLI) expect(report.argv).toContain('--replay-user-messages') expect(report.argv).toContain(`--session-id=${SESSION_ID}`) }) @@ -592,6 +611,11 @@ describe('Claude stream-json connection', () => { await until(() => exit, 'the exit error') // The status and stderr are the only diagnostic a refused start leaves behind. expect((exit as unknown as Error).message).toMatch(/exited \(code 1\): claude: not signed in/) + // Kept apart from Orca's wording where it is composed, and marked as log text. + expect(providerDiagnosticOf(exit)).toEqual({ + text: expect.stringMatching(/^\(code 1\)\n.*claude: not signed in/s), + audience: 'log' + }) expect(connection.closed).toBe(true) // Stderr-triggered capture can win or lose the race with this real child's exit. const closed = await connection.close() @@ -636,37 +660,63 @@ describe('Claude stream-json connection', () => { 20_000 ) - it('settles a spawn error followed by close as processless and closes idempotently', async () => { - const scenario = scriptScenario([HOLD_OPEN]) - const missingCli = join(scenario.cwd, 'claude-that-does-not-exist') - let fault: Error | null = null - let exit: Error | null = null - const connection = await open( - { ...launchFor(scenario), pathToClaudeCodeExecutable: missingCli }, - { - onFault: (error) => { - fault = error - }, - onExit: (error) => { - exit = error + it.skipIf(process.platform === 'win32')( + 'reports a missing CLI under the supervisor as a root exit that names the spawn error', + async () => { + const scenario = scriptScenario([HOLD_OPEN]) + const missingCli = join(scenario.cwd, 'claude-that-does-not-exist') + let exit: Error | null = null + const connection = await open( + { ...launchFor(scenario), pathToClaudeCodeExecutable: missingCli }, + { + onExit: (error) => { + exit = error + } } - } - ) + ) - await until( - () => (connection.exitVerdict.root === 'processless' ? connection.exitVerdict : null), - 'the processless spawn settlement' - ) - expect(connection.pid).toBeUndefined() - expect(fault).toBeInstanceOf(Error) - expect(exit).toBeNull() - await expect(Promise.all([connection.close(), connection.close()])).resolves.toEqual([ - true, - true - ]) - await expect(connection.close()).resolves.toBe(true) - expect(connection.exitVerdict).toEqual({ root: 'processless', tree: 'exited' }) - }) + const reported = await until(() => exit, 'the supervised spawn failure') + // The supervisor spawned, so this is its exit; only its stderr can say why. + expect(reported.message).toMatch(/\(code 127\).*ENOENT/s) + // A first-hand root exit, which releases the lease like a processless start did. + expect(connection.exitVerdict.root).toBe('exited') + } + ) + + it.skipIf(process.platform !== 'win32')( + 'settles a spawn error followed by close as processless and closes idempotently', + async () => { + const scenario = scriptScenario([HOLD_OPEN]) + const missingCli = join(scenario.cwd, 'claude-that-does-not-exist') + let fault: Error | null = null + let exit: Error | null = null + const connection = await open( + { ...launchFor(scenario), pathToClaudeCodeExecutable: missingCli }, + { + onFault: (error) => { + fault = error + }, + onExit: (error) => { + exit = error + } + } + ) + + await until( + () => (connection.exitVerdict.root === 'processless' ? connection.exitVerdict : null), + 'the processless spawn settlement' + ) + expect(connection.pid).toBeUndefined() + expect(fault).toBeInstanceOf(Error) + expect(exit).toBeNull() + await expect(Promise.all([connection.close(), connection.close()])).resolves.toEqual([ + true, + true + ]) + await expect(connection.close()).resolves.toBe(true) + expect(connection.exitVerdict).toEqual({ root: 'processless', tree: 'exited' }) + } + ) it('does not treat a child error event as first-hand root exit proof', async () => { const scenario = scriptScenario([HOLD_OPEN]) diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index 1a2a65bca34..6f67e04b36e 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto' +import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' import type * as ClaudeAgentSdk from '@anthropic-ai/claude-agent-sdk' import type { CanUseTool, OnUserDialog, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk' import { spawnProcess } from '../../shared/child-process/run-process' @@ -103,7 +104,12 @@ function exitError(stderrTail: string, status: ExitStatus | null, cause?: Error) ? ` (code ${status.code})` : '' const message = `claude stream-json exited${how}${detail ? `: ${detail}` : ''}` - return cause ? new Error(message, { cause }) : new Error(message) + // Written for a log, not a person: the chat keeps it behind Details. + const diagnostic = providerDiagnostic([how.trim(), detail].filter(Boolean).join('\n'), 'log') + return withProviderDiagnostic( + cause ? new Error(message, { cause }) : new Error(message), + diagnostic + ) } export async function openClaudeStreamJsonConnection( @@ -303,18 +309,24 @@ export async function openClaudeStreamJsonConnection( closePromise ??= (async () => { closing = true resumeReading() - // Arm the descendant proof before ending stdin. The SDK may exit the root - // immediately; a post-exit walk cannot recover descendants that reparented. + // Arm the descendant proof before the stop. The root may exit immediately; + // a post-exit walk cannot recover descendants that reparented. await (tree.refresh?.() ?? tree.capture()) inbox.end() const proven = await proveClaudeChildExit({ child, exitPromise, exited: rootSettled, - tree + tree, + supervised: spawner.supervised }) inbox.fail(new Error('claude stream-json connection closed')) if (!proven) { + if (exited && tree.treeVerdict === 'live') { + console.warn('[claude-stream-json] root exited but a descendant survived the close:', { + pid: spawner.pid + }) + } closePromise = null return false } diff --git a/src/main/claude/claude-structured-acquisition-release.ts b/src/main/claude/claude-structured-acquisition-release.ts index 6be06c86e93..3df76971df1 100644 --- a/src/main/claude/claude-structured-acquisition-release.ts +++ b/src/main/claude/claude-structured-acquisition-release.ts @@ -35,8 +35,8 @@ export async function releaseClaudeAcquisition(input: { const retriedProof = firstProof || (await exit.connection.close()) if (retriedProof) { await input.onExitProven?.(input.sessionId, exit) - // Keep the first-hand exit evidence indexed until the tree proof succeeds; - // a failed close must be retryable and cannot look like an absent session. + // Only a proven close drops the exit here. An unknown one stays indexed so it is retryable and + // cannot look like an absent session; an observed root exit is dropped by its own settlement. input.exits.delete(input.sessionId) return true } diff --git a/src/main/claude/claude-structured-auth-parity.test.ts b/src/main/claude/claude-structured-auth-parity.test.ts index ddc69366aad..002240e4dd7 100644 --- a/src/main/claude/claude-structured-auth-parity.test.ts +++ b/src/main/claude/claude-structured-auth-parity.test.ts @@ -105,7 +105,10 @@ describe('claude structured auth parity with the terminal preflight', () => { resolverFor({ stripAuthEnv: true, overlay: { ANTHROPIC_API_KEY: 'sk-ant-CONFIGURED' } })({ identity: IDENTITY }) - ).rejects.toThrow(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) + ).rejects.toMatchObject({ + message: CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, + reason: 'managedAccountEnvOverride' + }) }) it('refuses an auth-like ANTHROPIC_CUSTOM_HEADERS override while a managed account is pinned', async () => { @@ -114,7 +117,10 @@ describe('claude structured auth parity with the terminal preflight', () => { stripAuthEnv: true, overlay: { ANTHROPIC_CUSTOM_HEADERS: 'Authorization: Bearer sk-ant-CONFIGURED' } })({ identity: IDENTITY }) - ).rejects.toThrow(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) + ).rejects.toMatchObject({ + message: CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, + reason: 'managedAccountEnvOverride' + }) }) it('still admits a non-auth env overlay under a managed account', async () => { @@ -161,7 +167,10 @@ describe('claude structured auth parity with the terminal preflight', () => { await expect( resolverFor({ stripAuthEnv: true, authSwitchSettleTimeoutMs: 20 })({ identity: IDENTITY }) - ).rejects.toThrow(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) + ).rejects.toMatchObject({ + message: CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, + reason: 'accountSwitchInProgress' + }) }) it('waits a settling account switch out rather than refusing a resolved launch', async () => { @@ -183,7 +192,10 @@ describe('claude structured auth parity with the terminal preflight', () => { await expect( adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - ).rejects.toThrow(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) + ).rejects.toMatchObject({ + message: CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, + reason: 'accountSwitchInProgress' + }) // Nothing was spawned, so the refusal must not have opened a connection. expect(claude.connections).toHaveLength(0) }) @@ -227,7 +239,10 @@ describe('claude structured auth parity with the terminal preflight', () => { await expect( adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) - ).rejects.toThrow(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) + ).rejects.toMatchObject({ + message: CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, + reason: 'accountSwitchInProgress' + }) // No replacement child was opened, so nothing is left running unowned. expect(claude.connections).toHaveLength(1) await adapter.closeAll() diff --git a/src/main/claude/claude-structured-command-dispatch.ts b/src/main/claude/claude-structured-command-dispatch.ts new file mode 100644 index 00000000000..3d03c18178c --- /dev/null +++ b/src/main/claude/claude-structured-command-dispatch.ts @@ -0,0 +1,38 @@ +import { randomUUID } from 'node:crypto' +import type { + AgentSessionCommandAdmission, + StructuredAgentSessionCommandRun +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { dispatchClaudeTurn } from './claude-structured-dispatch' +import type { ClaudeSession } from './claude-structured-session-state' + +/** Sent like any message, so the slash-command waiter settles it on its result. The translator + * makes the command's turn the open one before the send, and that same result ends it. */ +export async function dispatchClaudeCommand( + session: ClaudeSession, + command: StructuredAgentSessionCommandRun +): Promise { + const sentUuid = randomUUID() + session.translator?.beginCommand({ + ...command, + providerSessionId: session.providerSessionId, + sentUuid + }) + try { + const admission = await dispatchClaudeTurn(session, { + clientMessageId: command.clientMessageId, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: '/compact' }] }, + ...(command.running.requestedAt === undefined + ? {} + : { requestedAt: command.running.requestedAt }), + sentUuid + }) + if (admission.state === 'rejected') { + session.translator?.forgetCommand(command.turnId) + } + return admission + } catch (error) { + session.translator?.forgetCommand(command.turnId) + throw error + } +} diff --git a/src/main/claude/claude-structured-compaction.test.ts b/src/main/claude/claude-structured-compaction.test.ts deleted file mode 100644 index 5255ce37ea1..00000000000 --- a/src/main/claude/claude-structured-compaction.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' -import { claudeUnwrittenUserMessageError } from './claude-agent-sdk-user-message-queue' -import { compactClaudeSession, isClaudeCompactionContent } from './claude-structured-compaction' -import { sessionFor } from './claude-structured-dispatch-test-support' - -afterEach(() => { - vi.useRealTimers() -}) - -describe('Claude compaction transcript content', () => { - it('keeps generated summaries and command echoes out of the transcript only during explicit compaction', async () => { - const tracker = new StructuredSessionCompaction() - const event = { - type: 'message' as const, - sessionId: 'orca-session', - message: { - type: 'user', - session_id: 'provider', - uuid: 'summary', - message: { role: 'user', content: 'generated compaction summary' } - } - } - expect(isClaudeCompactionContent(tracker, event)).toBe(false) - const completion = tracker.run('orca-session', 'provider', async () => ({})) - expect(isClaudeCompactionContent(tracker, event)).toBe(true) - expect(isClaudeCompactionContent(tracker, { ...event, sessionId: 'other' })).toBe(false) - expect(isClaudeCompactionContent(tracker, { ...event, message: { type: 'result' } })).toBe( - false - ) - tracker.ended('orca-session') - await completion - expect(isClaudeCompactionContent(tracker, event)).toBe(false) - }) - - it('fails a provably unwritten command without waiting for the completion deadline', async () => { - vi.useFakeTimers() - const session = sessionFor( - vi.fn().mockRejectedValue(claudeUnwrittenUserMessageError(new Error('input closed'))) - ) - const pending = compactClaudeSession(session, new StructuredSessionCompaction(60_000), { - sessionId: 'orca-session', - fence: 1, - turnId: 'compact-1' - }) - - await vi.advanceTimersByTimeAsync(1) - - await expect(pending).resolves.toEqual({ error: 'provider_write_failed: input closed' }) - }) - - it('keeps waiting when the command write outcome is ambiguous', async () => { - vi.useFakeTimers() - const session = sessionFor(vi.fn().mockRejectedValue(new Error('input pump stopped'))) - const pending = compactClaudeSession(session, new StructuredSessionCompaction(10), { - sessionId: 'orca-session', - fence: 1, - turnId: 'compact-1' - }) - const rejection = expect(pending).rejects.toThrow('Compaction completion is unconfirmed.') - - await vi.advanceTimersByTimeAsync(10) - - await rejection - }) -}) diff --git a/src/main/claude/claude-structured-compaction.ts b/src/main/claude/claude-structured-compaction.ts deleted file mode 100644 index 6c3155739c2..00000000000 --- a/src/main/claude/claude-structured-compaction.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { ClaudeSession, ClaudeStructuredSessionEvent } from './claude-structured-session-state' -import type { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' -import { dispatchClaudeTurn } from './claude-structured-dispatch' -import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -/** Compaction needs no ack deadline of its own: `compactions.run` keeps its own - * 180s completion window and settles on Claude's terminal `result` frame, so - * the dispatch here only has to report a refusal to send. */ -export function compactClaudeSession( - session: ClaudeSession, - compactions: StructuredSessionCompaction, - input: Parameters>[0] -): Promise<{ error?: string }> { - return compactions.run( - input.sessionId, - session.providerSessionId, - async () => { - const result = await dispatchClaudeTurn(session, { - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: '/compact' }] } - }) - if (result.state === 'rejected') { - return { error: result.reason } - } - return undefined - }, - input.onLateResult, - input.turnId - ) -} - -export function observeClaudeCompaction( - compactions: StructuredSessionCompaction, - event: ClaudeStructuredSessionEvent, - translator: ClaudeSession['translator'] | undefined -): void { - if (!isClaudeCompactionContent(compactions, event)) { - translator?.handle(event) - } - if (event.type === 'message') { - compactions.claude(event.sessionId, event.message) - } - if (event.type === 'ended') { - compactions.ended(event.sessionId) - } -} - -export function isClaudeCompactionContent( - compactions: StructuredSessionCompaction, - event: ClaudeStructuredSessionEvent -): boolean { - return ( - event.type === 'message' && - compactions.hasPending(event.sessionId) && - ['user', 'assistant', 'stream_event'].includes(String(event.message.type)) - ) -} diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts index f92a04a8003..4ba08d3b642 100644 --- a/src/main/claude/claude-structured-control-actions.test.ts +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -6,7 +6,7 @@ import { } from './claude-structured-control-actions' import { dispatchClaudeTurn } from './claude-structured-dispatch' import { ClaudeControlRequestError } from './claude-stream-json-connection' -import { ClaudePromptRegistry } from './claude-structured-prompt-replies' +import { buildClaudePromptReply, ClaudePromptRegistry } from './claude-structured-prompt-replies' import type { ClaudeDispatchWaiter, ClaudeSession } from './claude-structured-session-state' import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' import { sessionFor, userMessage } from './claude-structured-dispatch-test-support' @@ -16,7 +16,7 @@ type InterruptResult = Awaited Promise - cancelAsyncMessage?: (uuid: string) => Promise + cancelAsyncMessage?: (uuid: string) => Promise prompts?: ClaudePromptRegistry }): { session: ClaudeSession @@ -24,7 +24,7 @@ function sessionWith(input: { cancelAsyncMessage: ReturnType } { const interrupt = vi.fn(input.interrupt) - const cancelAsyncMessage = vi.fn(input.cancelAsyncMessage ?? (async () => {})) + const cancelAsyncMessage = vi.fn(input.cancelAsyncMessage ?? (async () => false)) const session = sessionFor() session.capabilities = input.capabilities ?? [] session.prompts = input.prompts ?? new ClaudePromptRegistry() @@ -56,6 +56,53 @@ describe('cancelClaudeTurn', () => { expect(cancelAsyncMessage.mock.calls.map((call) => call[0])).toEqual(['queued-1', 'queued-2']) }) + it('settles each still-queued send the CLI confirms it withdrew, and only those', async () => { + const { session, cancelAsyncMessage } = sessionWith({ + capabilities: ['interrupt_receipt_v1'], + interrupt: async () => ({ still_queued: ['queued-1', 'queued-2', 'queued-3'] }), + // queued-2 already ran; queued-3's answer never arrived. + cancelAsyncMessage: async (uuid) => { + if (uuid === 'queued-3') { + throw new ClaudeControlRequestError('cancel_async_message', 'timed out') + } + return uuid === 'queued-1' + } + }) + const resolutions = [vi.fn(), vi.fn(), vi.fn()] + session.dispatchWaiters = ['queued-1', 'queued-2', 'queued-3'].map( + (sentUuid, index): ClaudeDispatchWaiter => ({ + acceptsResult: false, + clientMessageId: `client-${index + 1}`, + sentUuid, + dispatchSequence: index + 1, + requestedAt: null, + replayContentKey: `content-${index}`, + resolve: resolutions[index]! + }) + ) + const settled = vi.fn() + + await expect(cancelClaudeTurn(session, 5_000, () => true, settled)).resolves.toEqual({ + cancelled: true + }) + expect(cancelAsyncMessage).toHaveBeenCalledTimes(3) + expect(settled.mock.calls).toEqual([ + [ + { + clientMessageId: 'client-1', + state: 'rejected', + reason: 'provider_cancelled_before_start', + rejection: { kind: 'cancelled' } + } + ] + ]) + expect(resolutions[0]).toHaveBeenCalledWith(null) + expect(session.dispatchWaiters.map((waiter) => waiter.sentUuid)).toEqual([ + 'queued-2', + 'queued-3' + ]) + }) + it('settles every cancelled queued waiter when the CLI advertises the capability', async () => { const cancelled = Array.from({ length: 64 }, (_, index) => `queued-${index}`) const { session, interrupt, cancelAsyncMessage } = sessionWith({ @@ -85,7 +132,8 @@ describe('cancelClaudeTurn', () => { expect(settled).toHaveBeenNthCalledWith(1, { clientMessageId: 'client-0', state: 'rejected', - reason: 'provider_cancelled_before_start' + reason: 'provider_cancelled_before_start', + rejection: { kind: 'cancelled' } }) }) @@ -118,7 +166,8 @@ describe('cancelClaudeTurn', () => { expect(settled).toHaveBeenCalledWith({ clientMessageId: 'client-ambiguous', state: 'rejected', - reason: 'provider_cancelled_before_start' + reason: 'provider_cancelled_before_start', + rejection: { kind: 'cancelled' } }) }) @@ -194,11 +243,16 @@ describe('answerClaudePrompt', () => { const resolvePrompt = vi.fn() session.translator = { handle: vi.fn(), + openTurnInLiveProviderCycle: false, journalPrompts: { cancel: vi.fn(() => ({ accepted: true as const })), resolve: resolvePrompt }, currentTurnId: null, + commandTurnId: null, + beginCommand: vi.fn(), + forgetCommand: vi.fn(), + commandInterruptRequested: vi.fn(), flush: vi.fn(), contextActivity: 0, markContextActivity: vi.fn(), @@ -214,7 +268,11 @@ describe('answerClaudePrompt', () => { if (!claim) { throw new Error('expected prompt claim') } - await answerClaudePrompt(session, claim, 'allow') + await answerClaudePrompt( + session, + claim, + buildClaudePromptReply(prompt, { kind: 'option', optionId: 'allow' }) + ) expect(settle).toHaveBeenCalledWith( expect.objectContaining({ behavior: 'allow', toolUseID: 'tool-1' }) diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts index 961b9aee7ba..8ec1aa2e9c4 100644 --- a/src/main/claude/claude-structured-control-actions.ts +++ b/src/main/claude/claude-structured-control-actions.ts @@ -1,9 +1,8 @@ -import { applyClaudePromptAnswer, type ClaudePromptClaim } from './claude-structured-prompt-replies' +import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk' +import type { ClaudePromptClaim } from './claude-structured-prompt-replies' import { ClaudeControlRequestError } from './claude-stream-json-connection' -import { - settleCancelledClaudeDispatchWaiters, - type ClaudeLateDispatchSettlement -} from './claude-structured-dispatch' +import { settleCancelledClaudeDispatchWaiters } from './claude-structured-dispatch' +import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution' import type { ClaudeSession } from './claude-structured-session-state' const INTERRUPT_CANCEL_QUEUED_CAPABILITY = 'interrupt_cancel_queued_v1' @@ -18,8 +17,9 @@ export type ClaudeTurnCancellationGuard = () => boolean * Interrupt the running turn, then make sure no queued async user message survives to spawn a * later unexpected turn. On a CLI advertising `interrupt_cancel_queued_v1` one round trip * cancels the queue alongside the abort; otherwise the interrupt receipt lists `still_queued` - * uuids, and each is withdrawn best-effort with `cancel_async_message`. Older CLIs resolve no - * receipt, so there is nothing to sweep. + * uuids, and each is withdrawn best-effort with `cancel_async_message`. Either way, every send + * the CLI confirms it withdrew settles as cancelled. Older CLIs resolve no receipt, so there is + * nothing to sweep. */ export async function cancelClaudeTurn( session: ClaudeSession, @@ -41,9 +41,13 @@ export async function cancelClaudeTurn( if (cancelQueued) { settleCancelledClaudeDispatchWaiters(session, receipt?.cancelled ?? [], onDispatchSettledLate) } else { + const withdrawn: string[] = [] for (const uuid of receipt?.still_queued ?? []) { - await session.connection.cancelAsyncMessage(uuid, { timeoutMs }).catch(() => {}) + if (await session.connection.cancelAsyncMessage(uuid, { timeoutMs }).catch(() => false)) { + withdrawn.push(uuid) + } } + settleCancelledClaudeDispatchWaiters(session, withdrawn, onDispatchSettledLate) } return { cancelled: true } } catch (error) { @@ -83,17 +87,12 @@ export async function stopClaudeBackgroundTasks( export async function answerClaudePrompt( session: ClaudeSession, claim: ClaudePromptClaim, - optionId: string + reply: PermissionResult ): Promise { if (!session.prompts.ownsClaim(claim)) { throw new Error(`claude is no longer waiting on ${claim.itemId}`) } - const response = applyClaudePromptAnswer(claim.found, optionId) - if (response === null) { - session.prompts.releaseClaim(claim) - return - } session.prompts.forget(claim.found.prompt) - claim.found.prompt.settle(response) + claim.found.prompt.settle(reply) session.translator?.journalPrompts.resolve(claim.found.prompt.promptKey) } diff --git a/src/main/claude/claude-structured-conversation-stop.test.ts b/src/main/claude/claude-structured-conversation-stop.test.ts new file mode 100644 index 00000000000..840125853ba --- /dev/null +++ b/src/main/claude/claude-structured-conversation-stop.test.ts @@ -0,0 +1,101 @@ +// A Stop that names no turn, against the Claude adapter: the gap between writing a message and +// Claude echoing it back is exactly where no turn id exists yet, and the interrupt must still land. + +import { describe, expect, it } from 'vitest' +import { acquired, fakeClaude, USER_MESSAGE } from './claude-structured-session-test-support' + +function interrupts(claude: ReturnType): number { + return claude.connections[0]!.calls.filter((call) => call.subtype === 'interrupt').length +} + +async function written(claude: ReturnType) { + const adapter = await acquired(claude) + // No replay: the message is written and Claude has not opened its turn. + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + return adapter +} + +describe('Claude Stop that names no turn', () => { + it('interrupts a written message before its turn opens', async () => { + const claude = fakeClaude({ replayUuid: null }) + const adapter = await written(claude) + + await expect(adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ + cancelled: true + }) + expect(interrupts(claude)).toBe(1) + }) + + it('still refuses the placeholder a client used to name for that gap', async () => { + const claude = fakeClaude({ replayUuid: null }) + const adapter = await written(claude) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-none', fence: 7 }) + ).resolves.toEqual({ cancelled: false }) + expect(interrupts(claude)).toBe(0) + }) + + it('interrupts a turn Claude opened on its own echo', async () => { + const claude = fakeClaude({ replayUuid: 'turn-T' }) + const adapter = await written(claude) + + await expect(adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ + cancelled: true + }) + expect(interrupts(claude)).toBe(1) + }) + + it('does nothing for another fence, or with nothing in flight', async () => { + const claude = fakeClaude({ replayUuid: null }) + const adapter = await acquired(claude) + + await expect(adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ + cancelled: false + }) + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + await expect(adapter.cancelTurn({ sessionId: 'session-1', fence: 6 })).resolves.toEqual({ + cancelled: false + }) + expect(interrupts(claude)).toBe(0) + }) + + it('ends a turn that opens while the interrupt is on its way once, and the next send lands', async () => { + const claude = fakeClaude({ replayUuid: null }) + const adapter = await written(claude) + const connection = claude.connections[0]! + // The echo that opens the turn arrives after Orca wrote the interrupt, before Claude answers it. + claude.routes.interrupt = () => { + connection.handlers.onMessage?.({ ...connection.sent[0]!, uuid: 'turn-late' }) + return undefined + } + + await expect(adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ + cancelled: true + }) + expect(interrupts(claude)).toBe(1) + + delete claude.routes.interrupt + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-2', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + expect(connection.sent).toHaveLength(2) + }) +}) diff --git a/src/main/claude/claude-structured-dispatch-admission.test.ts b/src/main/claude/claude-structured-dispatch-admission.test.ts index 9a4d60a824c..c4578ca38d8 100644 --- a/src/main/claude/claude-structured-dispatch-admission.test.ts +++ b/src/main/claude/claude-structured-dispatch-admission.test.ts @@ -2,7 +2,8 @@ // completes, and nothing about elapsed time ever puts a message in doubt. import { describe, expect, it, vi } from 'vitest' -import { dispatchClaudeTurn, resolveClaudeReplayTurn } from './claude-structured-dispatch' +import { dispatchClaudeTurn } from './claude-structured-dispatch' +import { resolveClaudeReplayTurn } from './claude-replay-turn-resolution' import { childExited, sessionFor, @@ -61,8 +62,9 @@ describe('Claude structured dispatch admission', () => { true ) - // Queued while turn one is still running: Claude cannot echo it until that - // turn ends, so nothing about the wait is evidence of a delivery problem. + // Queued while turn one is still running: a fold is echoed mid-turn, a + // queued send only when its own turn starts — either way elapsed time is + // not evidence of a delivery problem. const queued = await dispatchClaudeTurn(session, { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'two' }]) @@ -139,7 +141,11 @@ describe('Claude structured dispatch admission', () => { clientMessageId: 'client-over-capacity', body: userMessage([{ type: 'text', text: 'one too many' }]) }) - ).resolves.toEqual({ state: 'rejected', reason: 'claude structured dispatch queue is full' }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'claude structured dispatch queue is full', + rejection: { kind: 'queueFull' } + }) expect(session.dispatchWaiters).toHaveLength(64) expect(session.connection.send).toHaveBeenCalledTimes(64) }) diff --git a/src/main/claude/claude-structured-dispatch-attachment-rejection.test.ts b/src/main/claude/claude-structured-dispatch-attachment-rejection.test.ts new file mode 100644 index 00000000000..a23767c0968 --- /dev/null +++ b/src/main/claude/claude-structured-dispatch-attachment-rejection.test.ts @@ -0,0 +1,177 @@ +// What a person reads when Orca refuses a message's content before sending it. + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { dispatchClaudeTurn } from './claude-structured-dispatch' +import { sessionFor, userMessage } from './claude-structured-dispatch-test-support' + +describe('Claude structured dispatch attachment rejections', () => { + it('rejects more than twenty URL images before sending', async () => { + const session = sessionFor() + const body = userMessage( + Array.from({ length: 21 }, (_, index) => ({ + type: 'image-ref' as const, + url: `https://example.test/${index}.png` + })) + ) + + await expect( + dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'Claude accepts at most 20 images in one message, so this message was not sent.', + rejection: { kind: 'attachmentInvalid', attachment: { reason: 'tooMany', limit: 20 } } + }) + expect(session.connection.send).not.toHaveBeenCalled() + }) + + it('rejects local images whose aggregate size exceeds twenty MiB', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-claude-images-')) + try { + const paths = await Promise.all( + Array.from({ length: 5 }, async (_, index) => { + const path = join(directory, `${index}.png`) + await writeFile(path, Buffer.alloc(5 * 1024 * 1024)) + return path + }) + ) + const session = sessionFor() + const body = userMessage(paths.map((path) => ({ type: 'image-ref' as const, path }))) + + await expect( + dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) + ).resolves.toEqual({ + state: 'rejected', + reason: + 'The images on this message add up to more than 20 MB, so the message was not sent.', + rejection: { + kind: 'attachmentInvalid', + attachment: { reason: 'totalTooLarge', limit: 20 * 1024 * 1024 } + } + }) + expect(session.connection.send).not.toHaveBeenCalled() + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('rejects a local image by actual bytes read beyond the per-image cap', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-')) + try { + const path = join(directory, 'oversized.png') + await writeFile(path, Buffer.alloc(5 * 1024 * 1024 + 1)) + const session = sessionFor() + const body = userMessage([{ type: 'image-ref', path }]) + + await expect( + dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'An image on this message is larger than 5 MB, so the message was not sent.', + rejection: { + kind: 'attachmentInvalid', + attachment: { reason: 'tooLarge', limit: 5 * 1024 * 1024 } + } + }) + expect(session.connection.send).not.toHaveBeenCalled() + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('names an empty image as empty, not as too large', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-')) + try { + const path = join(directory, 'empty.png') + await writeFile(path, Buffer.alloc(0)) + const session = sessionFor() + + await expect( + dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'image-ref', path }]) + }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'An image on this message is empty, so the message was not sent.', + rejection: { kind: 'attachmentInvalid', attachment: { reason: 'empty' } } + }) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('names an unsupported image type in words a person can act on', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-')) + try { + const path = join(directory, 'picture.bmp') + await writeFile(path, Buffer.alloc(64)) + const session = sessionFor() + + await expect( + dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'image-ref', path }]) + }) + ).resolves.toEqual({ + state: 'rejected', + reason: + 'Claude accepts only PNG, JPEG, GIF, and WebP images, so this message was not sent.', + rejection: { kind: 'attachmentInvalid', attachment: { reason: 'unsupportedType' } } + }) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('does not blame an attachment for a message no Orca client sends', async () => { + const session = sessionFor() + await expect( + dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'text', text: '' }]) + }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'This message is empty, so it was not sent.', + rejection: { kind: 'emptyMessage' } + }) + await expect( + dispatchClaudeTurn(session, { + clientMessageId: 'client-2', + body: { ...userMessage([{ type: 'text', text: 'hi' }]), role: 'assistant' } + }) + ).resolves.toEqual({ + state: 'rejected', + reason: "Orca ran into a problem, so this didn't go through. Try again.", + rejection: { kind: 'hostFault' } + }) + expect(session.connection.send).not.toHaveBeenCalled() + }) + + it('rejects an attachment it cannot read with the generic sentence and no path', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const session = sessionFor() + const path = join(tmpdir(), 'orca-claude-image-missing', 'gone.png') + + await expect( + dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'image-ref', path }]) + }) + ).resolves.toEqual({ + state: 'rejected', + reason: "An attachment on this message couldn't be read, so the message was not sent.", + rejection: { kind: 'attachmentUnreadable' } + }) + expect(session.connection.send).not.toHaveBeenCalled() + // The row drops the error, so the log is the only place left to find why. + expect(warn).toHaveBeenCalledWith( + '[claude-dispatch] attachment could not be read:', + expect.objectContaining({ code: 'ENOENT' }) + ) + warn.mockRestore() + }) +}) diff --git a/src/main/claude/claude-structured-dispatch-content.ts b/src/main/claude/claude-structured-dispatch-content.ts index 7df84091b7c..035b0a59492 100644 --- a/src/main/claude/claude-structured-dispatch-content.ts +++ b/src/main/claude/claude-structured-dispatch-content.ts @@ -3,11 +3,63 @@ import { open } from 'node:fs/promises' import { extname } from 'node:path' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { NativeChatBlock } from '../../shared/native-chat-types' +import { + agentSessionFailureFact, + type AgentSessionAttachmentProblem, + type SubmissionRejectionFact +} from '../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentJournalDispatchRejection +} from '../../shared/agent-session-failure-words' +import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' import { claudeRecord } from './claude-structured-item-translation' -const MAX_IMAGE_BYTES = 5 * 1024 * 1024 +/** Orca refused the message's content, as opposed to failing to read an attachment. */ +export class ClaudeDispatchContentError extends Error { + /** What was wrong, typed where the check saw it; `message` stays for logs. */ + readonly failure: SubmissionRejectionFact + + constructor(message: string, failure: SubmissionRejectionFact) { + super(message) + this.name = 'ClaudeDispatchContentError' + this.failure = failure + } +} + +function attachmentError(message: string, attachment: AgentSessionAttachmentProblem): Error { + return new ClaudeDispatchContentError( + message, + agentSessionFailureFact('attachmentInvalid', { attachment }) + ) +} + +/** A message Claude rejected, in the words that name Claude and its legacy markers. */ +export function claudeDispatchRejection( + failure: SubmissionRejectionFact +): AgentJournalDispatchRejection { + return agentSessionFailureWords(failure, { + surface: 'rejection', + agentName: TUI_AGENT_DISPLAY_NAMES.claude, + provider: 'claude' + }) +} + +/** Why a message whose content could not be built was not sent: Orca's own refusal of it, or an + * attachment it could not read. Never the provider. */ +export function claudeDispatchContentRejection(error: unknown): AgentJournalDispatchRejection { + if (error instanceof ClaudeDispatchContentError) { + return claudeDispatchRejection(error.failure) + } + // The row says only that it could not be read; why belongs in the log. + console.warn('[claude-dispatch] attachment could not be read:', error) + return claudeDispatchRejection(agentSessionFailureFact('attachmentUnreadable')) +} + +const BYTES_PER_MB = 1024 * 1024 +const MAX_IMAGE_BYTES = 5 * BYTES_PER_MB const MAX_IMAGE_COUNT = 20 -const MAX_TOTAL_IMAGE_BYTES = 20 * 1024 * 1024 +const MAX_TOTAL_IMAGE_BYTES = 20 * BYTES_PER_MB const MAX_REPLAY_CONTENT_KEY_BYTES = 256 type ImageBudget = { @@ -18,14 +70,17 @@ type ImageBudget = { export async function readClaudeImage(path: string, openImpl: typeof open = open): Promise { const file = await openImpl(path, 'r') try { - const invalidImage = (): Error => - new Error(`Claude image must be a non-empty file no larger than ${MAX_IMAGE_BYTES} bytes`) + const tooLarge = (): Error => + attachmentError(`Claude image must be no larger than ${MAX_IMAGE_BYTES} bytes`, { + reason: 'tooLarge', + limit: MAX_IMAGE_BYTES + }) const info = await file.stat() if (!info.isFile()) { - throw new Error('Claude image must be a file') + throw attachmentError('Claude image must be a file', { reason: 'notAFile' }) } if (info.size > MAX_IMAGE_BYTES) { - throw invalidImage() + throw tooLarge() } const buffer = Buffer.allocUnsafe(info.size + 1) let bytesRead = 0 @@ -39,8 +94,15 @@ export async function readClaudeImage(path: string, openImpl: typeof open = open // A file can grow after the initial stat and after the final read returns // zero. Prove the descriptor's size matches what was copied before sending. const finalInfo = await file.stat() - if (bytesRead === 0 || bytesRead > MAX_IMAGE_BYTES || finalInfo.size !== bytesRead) { - throw invalidImage() + if (bytesRead > MAX_IMAGE_BYTES) { + throw tooLarge() + } + if (finalInfo.size !== bytesRead) { + // Not the image's fault: it changed while Orca read it, so it reads as unreadable. + throw new Error('Claude image changed while it was read') + } + if (bytesRead === 0) { + throw attachmentError('Claude image must be a non-empty file', { reason: 'empty' }) } return buffer.subarray(0, bytesRead) } finally { @@ -62,22 +124,30 @@ async function imageContent( ): Promise { budget.count += 1 if (budget.count > MAX_IMAGE_COUNT) { - throw new Error(`Claude messages support at most ${MAX_IMAGE_COUNT} images`) + throw attachmentError(`Claude messages support at most ${MAX_IMAGE_COUNT} images`, { + reason: 'tooMany', + limit: MAX_IMAGE_COUNT + }) } if (block.url) { return { type: 'image', source: { type: 'url', url: block.url } } } if (!block.path) { - throw new Error('image reference has neither a path nor a URL') + throw attachmentError('image reference has neither a path nor a URL', { reason: 'noSource' }) } const data = await readClaudeImage(block.path) budget.localBytes += data.byteLength if (budget.localBytes > MAX_TOTAL_IMAGE_BYTES) { - throw new Error(`Claude images must total no more than ${MAX_TOTAL_IMAGE_BYTES} bytes`) + throw attachmentError(`Claude images must total no more than ${MAX_TOTAL_IMAGE_BYTES} bytes`, { + reason: 'totalTooLarge', + limit: MAX_TOTAL_IMAGE_BYTES + }) } const mediaType = IMAGE_MIME_BY_EXTENSION[extname(block.path).toLowerCase()] if (!mediaType) { - throw new Error(`Claude does not support the image type ${extname(block.path)}`) + throw attachmentError(`Claude does not support the image type ${extname(block.path)}`, { + reason: 'unsupportedType' + }) } return { type: 'image', @@ -99,7 +169,11 @@ export async function claudeDispatchMessageContent( body: AgentJournalMessageItem ): Promise { if (body.role !== 'user') { - throw new Error('Claude dispatch accepts only user messages') + // No Orca client sends one, so the fault is Orca's. + throw new ClaudeDispatchContentError( + 'Claude dispatch accepts only user messages', + agentSessionFailureFact('hostFault') + ) } const images: unknown[] = [] const texts: string[] = [] @@ -115,7 +189,10 @@ export async function claudeDispatchMessageContent( // text blocks would silently discard every one but the last. const content = texts.length > 0 ? [...images, { type: 'text', text: texts.join('\n') }] : images if (content.length === 0) { - throw new Error('Claude dispatch requires text or an image') + throw new ClaudeDispatchContentError( + 'Claude dispatch requires text or an image', + agentSessionFailureFact('emptyMessage') + ) } return content } diff --git a/src/main/claude/claude-structured-dispatch-test-support.ts b/src/main/claude/claude-structured-dispatch-test-support.ts index a309bd9d0b9..5af0f17243b 100644 --- a/src/main/claude/claude-structured-dispatch-test-support.ts +++ b/src/main/claude/claude-structured-dispatch-test-support.ts @@ -5,7 +5,7 @@ import type { ClaudeSession } from './claude-structured-session-state' import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' -import { createClaudeSessionStartupGate } from './claude-structured-session-startup-gate' +import { createClaudeSessionStartup } from './claude-structured-session-startup-state' export function sessionFor(send: Mock = vi.fn().mockResolvedValue(undefined)): ClaudeSession { return { @@ -32,7 +32,7 @@ export function sessionFor(send: Mock = vi.fn().mockResolvedValue(undefined)): C capabilities: [], events: undefined, translator: null, - startup: { ...createClaudeSessionStartupGate(), state: 'proven' } + startup: { ...createClaudeSessionStartup(), state: 'proven' } } } diff --git a/src/main/claude/claude-structured-dispatch-waiters.ts b/src/main/claude/claude-structured-dispatch-waiters.ts index f3a3501ec8a..92b6a5048b2 100644 --- a/src/main/claude/claude-structured-dispatch-waiters.ts +++ b/src/main/claude/claude-structured-dispatch-waiters.ts @@ -10,11 +10,11 @@ export function forgetRetiredWaiter(session: ClaudeSession, waiter: ClaudeDispat } /** - * A waiter with no deadline. The echo Claude sends is emitted when the provider - * STARTS the turn, so a message queued behind a running turn cannot be echoed - * until that turn ends — an interval bounded only by the previous turn. Elapsed - * time is therefore not evidence about delivery, and nothing here expires. - * Waiters are retired by process facts instead: a failed write, or child exit. + * A waiter with no deadline. A mid-turn send Claude folds into the running turn + * is replayed mid-turn; one it runs later is replayed only when its own turn + * starts — an interval bounded only by the previous turn. Elapsed time is + * therefore not evidence about delivery, and nothing here expires. Waiters are + * retired by process facts instead: a failed write, or child exit. */ export function waitForReplay( session: ClaudeSession, diff --git a/src/main/claude/claude-structured-dispatch.test.ts b/src/main/claude/claude-structured-dispatch.test.ts index 1945ac3531d..92ae0253711 100644 --- a/src/main/claude/claude-structured-dispatch.test.ts +++ b/src/main/claude/claude-structured-dispatch.test.ts @@ -2,8 +2,9 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { dispatchClaudeTurn, resolveClaudeReplayTurn } from './claude-structured-dispatch' -import { readClaudeImage } from './claude-structured-dispatch-content' +import { dispatchClaudeTurn } from './claude-structured-dispatch' +import { resolveClaudeReplayTurn } from './claude-replay-turn-resolution' +import { ClaudeDispatchContentError, readClaudeImage } from './claude-structured-dispatch-content' import { claudeUnwrittenUserMessageError } from './claude-agent-sdk-user-message-queue' import type { ClaudeSession } from './claude-structured-session-state' import { @@ -379,7 +380,11 @@ describe('Claude structured dispatch image limits', () => { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'two' }]) }) - ).resolves.toEqual({ state: 'rejected', reason: 'provider_write_failed: broken pipe' }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'provider_write_failed', + rejection: { kind: 'writeFailed' } + }) expect(session.dispatchWaiters).toEqual([firstWaiter]) const firstUuid = (firstWaiter as { sentUuid?: string }).sentUuid @@ -392,6 +397,7 @@ describe('Claude structured dispatch image limits', () => { }) it('does not let a provably unwritten attempt block retry correlation', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const send = vi .fn() .mockRejectedValueOnce(claudeUnwrittenUserMessageError(new Error('broken pipe'))) @@ -401,7 +407,17 @@ describe('Claude structured dispatch image limits', () => { await expect( dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) - ).resolves.toEqual({ state: 'rejected', reason: 'provider_write_failed: broken pipe' }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'provider_write_failed', + rejection: { kind: 'writeFailed' } + }) + // The row keeps only the marker; why the write failed goes to the log. + expect(warn).toHaveBeenCalledWith( + '[claude-dispatch] message could not be handed to Claude:', + expect.objectContaining({ message: expect.stringContaining('broken pipe') }) + ) + warn.mockRestore() expect(session.dispatchWaiters).toHaveLength(0) expect(session.retiredDispatchWaiters).toHaveLength(0) @@ -684,66 +700,6 @@ describe('Claude structured dispatch image limits', () => { }) }) - it('rejects more than twenty URL images before sending', async () => { - const session = sessionFor() - const body = userMessage( - Array.from({ length: 21 }, (_, index) => ({ - type: 'image-ref' as const, - url: `https://example.test/${index}.png` - })) - ) - - await expect( - dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) - ).resolves.toEqual({ state: 'rejected', reason: 'Claude messages support at most 20 images' }) - expect(session.connection.send).not.toHaveBeenCalled() - }) - - it('rejects local images whose aggregate size exceeds twenty MiB', async () => { - const directory = await mkdtemp(join(tmpdir(), 'orca-claude-images-')) - try { - const paths = await Promise.all( - Array.from({ length: 5 }, async (_, index) => { - const path = join(directory, `${index}.png`) - await writeFile(path, Buffer.alloc(5 * 1024 * 1024)) - return path - }) - ) - const session = sessionFor() - const body = userMessage(paths.map((path) => ({ type: 'image-ref' as const, path }))) - - await expect( - dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) - ).resolves.toEqual({ - state: 'rejected', - reason: `Claude images must total no more than ${20 * 1024 * 1024} bytes` - }) - expect(session.connection.send).not.toHaveBeenCalled() - } finally { - await rm(directory, { recursive: true, force: true }) - } - }) - - it('rejects a local image by actual bytes read beyond the per-image cap', async () => { - const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-')) - try { - const path = join(directory, 'oversized.png') - await writeFile(path, Buffer.alloc(5 * 1024 * 1024 + 1)) - const session = sessionFor() - const body = userMessage([{ type: 'image-ref', path }]) - - await expect( - dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) - ).resolves.toEqual({ - state: 'rejected', - reason: `Claude image must be a non-empty file no larger than ${5 * 1024 * 1024} bytes` - }) - expect(session.connection.send).not.toHaveBeenCalled() - } finally { - await rm(directory, { recursive: true, force: true }) - } - }) - it('allocates local image reads from the file size, not the maximum cap', async () => { const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-')) const allocUnsafe = vi.spyOn(Buffer, 'allocUnsafe') @@ -821,8 +777,11 @@ describe('Claude structured dispatch image limits', () => { read, close: vi.fn().mockResolvedValue(undefined) } as never) - await expect(readClaudeImage('/controlled/growing.png', open)).rejects.toThrow( - `Claude image must be a non-empty file no larger than ${5 * 1024 * 1024} bytes` + // It changed while Orca read it: unreadable, never a limit the image did not break. + const rejected = await readClaudeImage('/controlled/growing.png', open).catch( + (error: unknown) => error ) + expect(rejected).not.toBeInstanceOf(ClaudeDispatchContentError) + expect(rejected).toMatchObject({ message: 'Claude image changed while it was read' }) }) }) diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index d93a8ecab0a..c208cceccdb 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -7,193 +7,31 @@ import { } from './claude-structured-dispatch-waiters' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import { - claudeHasReplayContent, - readClaudeMessageEnvelope -} from './claude-structured-item-translation' -import type { - ClaudeDispatchWaiter, - ClaudeLateDispatchOutcome, - ClaudeSession -} from './claude-structured-session-state' -import { readClaudeFrameString } from './claude-structured-init-proof' +import type { ClaudeSession } from './claude-structured-session-state' +import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution' import { claudeDispatchContentKey, + claudeDispatchContentRejection, claudeDispatchInvokesSlashCommand, - claudeDispatchMessageContent + claudeDispatchMessageContent, + claudeDispatchRejection } from './claude-structured-dispatch-content' import { dispatchWriteOutcomeUnknownReason } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' -import { - DISPATCH_REJECTED_CANCELLED, - DISPATCH_REJECTED_QUEUE_FULL, - dispatchWriteFailureReason -} from '../../shared/structured-agent-session-dispatch-rejection' +import { DISPATCH_REJECTED_QUEUE_FULL } from '../../shared/structured-agent-session-dispatch-rejection' +import { agentSessionFailureFact } from '../../shared/agent-session-failure' +import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' import { claudeUnwrittenUserMessageError, claudeUserMessageWasProvablyUnwritten } from './claude-agent-sdk-user-message-queue' import { AgentSessionPreDispatchError } from '../native-chat/agent-session-wire/structured-agent-session-operation-settlement' import { - claudeStartupFailureReason, - claudeStartupHoldsWrites, - failClaudeStartupGate, - holdClaudeStartupWrite -} from './claude-structured-session-startup-gate' + claudeStartupFailureFact, + failClaudeStartup +} from './claude-structured-session-startup-state' const MAX_ACTIVE_DISPATCH_WAITERS = 64 -/** Settles a provider-proven late outcome; replay rows independently reconcile acceptance. */ -export type ClaudeLateDispatchSettlement = (input: ClaudeLateDispatchOutcome) => void - -export type ClaudeReplayTurnOrigin = { requestedAt: number | null } - -export function resolveClaudeReplayTurn( - session: ClaudeSession, - message: Record, - onSettledLate?: ClaudeLateDispatchSettlement -): ClaudeReplayTurnOrigin | null { - const envelope = readClaudeMessageEnvelope(message) - const isUserReplay = - envelope?.role === 'user' && - message.parent_tool_use_id === null && - claudeHasReplayContent(envelope) - const isCompletedCommand = message.type === 'result' - if ( - (!isUserReplay && !isCompletedCommand) || - readClaudeFrameString(message, 'session_id') !== session.providerSessionId - ) { - return null - } - const uuid = readClaudeFrameString(message, 'uuid') - if (!uuid) { - return null - } - - // Newer SDK frames carry the client uuid that caused a turn. A correlation - // value is authoritative: never fall back to queue order or content, since - // identical prompts may be in flight across a timeout boundary. - const userMessageUuid = readClaudeFrameString(message, 'user_message_uuid') - if (userMessageUuid) { - const exact = session.dispatchWaiters.find( - (candidate) => candidate.sentUuid === userMessageUuid - ) - if (exact) { - settleWaiter(session, exact, uuid, onSettledLate) - return isUserReplay ? { requestedAt: exact.requestedAt } : null - } - const retired = session.retiredDispatchWaiters.find( - (candidate) => candidate.sentUuid === userMessageUuid - ) - if (retired) { - forgetRetiredWaiter(session, retired) - recoverLateIdentity(session, retired, uuid, isUserReplay, onSettledLate) - return null - } - return null - } - - const exact = session.dispatchWaiters.find((candidate) => candidate.sentUuid === uuid) - if (exact) { - settleWaiter(session, exact, uuid, onSettledLate) - return isUserReplay ? { requestedAt: exact.requestedAt } : null - } - const retired = session.retiredDispatchWaiters.find((candidate) => candidate.sentUuid === uuid) - if (retired) { - forgetRetiredWaiter(session, retired) - recoverLateIdentity(session, retired, uuid, isUserReplay, onSettledLate) - return null - } - - if (isUserReplay) { - // Compatibility CLIs may mint a new replay uuid instead of echoing the - // client uuid. Content is an acceptable join only when it is the sole - // candidate on one side of the timeout boundary; with active and retired - // candidates present, identical prompts are intentionally left unknown. - const replayContentKey = claudeDispatchContentKey(envelope.content) - if (!session.replayContentFallbackBlocked && session.retiredDispatchWaiters.length === 0) { - const compatible = session.dispatchWaiters.filter( - (candidate) => candidate.replayContentKey === replayContentKey - ) - if (compatible.length === 1) { - const [candidate] = compatible - settleWaiter(session, candidate!, uuid, onSettledLate) - return { requestedAt: candidate!.requestedAt } - } - } else if (!session.replayContentFallbackBlocked && session.dispatchWaiters.length === 0) { - const lateCompatible = session.retiredDispatchWaiters.filter( - (candidate) => candidate.replayContentKey === replayContentKey - ) - if (lateCompatible.length === 1) { - const [candidate] = lateCompatible - forgetRetiredWaiter(session, candidate!) - recoverLateIdentity(session, candidate!, uuid, true, onSettledLate) - return null - } - } - return null - } - const current = session.dispatchWaiters[0] - if (isCompletedCommand && !current?.acceptsResult) { - return null - } - // A legacy result has no dispatch correlation. Any retired waiter makes queue order ambiguous, - // even when the retired dispatch was an ordinary turn rather than a slash command. - if (isCompletedCommand && session.retiredDispatchWaiters.length > 0) { - return null - } - // Once an eviction occurred, a fresh result uuid cannot be joined to a waiter by queue order. - if (isCompletedCommand && session.replayContentFallbackBlocked) { - return null - } - const waiter = uuid ? session.dispatchWaiters.shift() : undefined - if (waiter && uuid) { - settleWaiter(session, waiter, uuid, onSettledLate) - return isUserReplay ? { requestedAt: waiter.requestedAt } : null - } - return null -} - -function settleWaiter( - session: ClaudeSession, - waiter: ClaudeDispatchWaiter, - uuid: string, - onSettledLate?: ClaudeLateDispatchSettlement -): void { - const index = session.dispatchWaiters.indexOf(waiter) - if (index !== -1) { - session.dispatchWaiters.splice(index, 1) - } - waiter.settledUuid = uuid - waiter.resolve(uuid) - // Dispatch returned on admission, so the replay is what settles delivery. - if (waiter.clientMessageId) { - onSettledLate?.({ - clientMessageId: waiter.clientMessageId, - providerIdentity: { provider: 'claude', sessionId: session.providerSessionId, uuid } - }) - } -} - -function recoverLateIdentity( - session: ClaudeSession, - waiter: ClaudeDispatchWaiter, - uuid: string, - isUserReplay: boolean, - onSettledLate?: ClaudeLateDispatchSettlement -): void { - if (!isUserReplay && !waiter.acceptsResult) { - return - } - // The provider acted on this dispatch, so the send it came from is delivered. - // A retired replay settles delivery only; it cannot reopen a turn. - if (waiter.clientMessageId) { - onSettledLate?.({ - clientMessageId: waiter.clientMessageId, - providerIdentity: { provider: 'claude', sessionId: session.providerSessionId, uuid } - }) - } -} - export function settleCancelledClaudeDispatchWaiters( session: ClaudeSession, cancelledUuids: readonly string[], @@ -216,7 +54,7 @@ export function settleCancelledClaudeDispatchWaiters( onSettledLate?.({ clientMessageId: waiter.clientMessageId, state: 'rejected', - reason: DISPATCH_REJECTED_CANCELLED + ...claudeDispatchRejection(agentSessionFailureFact('cancelled')) }) } } @@ -226,36 +64,47 @@ export function settleCancelledClaudeDispatchWaiters( * Retired rather than dropped: their identities stay joinable, bounded by * `MAX_RETIRED_DISPATCH_WAITERS`. */ export function retireClaudeDispatchWaiters(session: ClaudeSession): void { - failClaudeStartupGate(session, new Error('claude stream-json ended before startup completed')) + failClaudeStartup(session, new Error('claude stream-json ended before startup completed')) for (const waiter of session.dispatchWaiters.splice(0)) { retireWaiter(session, waiter) waiter.resolve(null) } } +/** The row keeps only the marker released clients hide; why the write failed belongs in the log. */ +function claudeWriteFailureRejection(error: unknown): AgentJournalDispatchRejection { + console.warn('[claude-dispatch] message could not be handed to Claude:', error) + return claudeDispatchRejection(agentSessionFailureFact('writeFailed')) +} + export async function dispatchClaudeTurn( session: ClaudeSession, - input: { clientMessageId?: string; body: AgentJournalMessageItem; requestedAt?: number }, - beforeDispatch?: () => Promise, - onSettledLate?: ClaudeLateDispatchSettlement + input: { + clientMessageId?: string + body: AgentJournalMessageItem + requestedAt?: number + /** The frame's uuid, for a caller that correlates the provider's answer to it. */ + sentUuid?: string + }, + beforeDispatch?: () => Promise ): Promise { let content: unknown[] try { content = await claudeDispatchMessageContent(input.body) } catch (error) { - return { state: 'rejected', reason: (error as Error).message } + return { state: 'rejected', ...claudeDispatchContentRejection(error) } } if (session.dispatchWaiters.length >= MAX_ACTIVE_DISPATCH_WAITERS) { - return { state: 'rejected', reason: DISPATCH_REJECTED_QUEUE_FULL } + return { state: 'rejected', ...claudeDispatchRejection(agentSessionFailureFact('queueFull')) } } - const startupFailure = claudeStartupFailureReason(session) + const startupFailure = claudeStartupFailureFact(session) if (startupFailure) { - return { state: 'rejected', reason: startupFailure } + return { state: 'rejected', ...claudeDispatchRejection(startupFailure) } } // Read the sent content, not the journal blocks: only the mapped trailing prompt decides // whether Claude runs a command, so the two cannot disagree about which frame settles this. const acceptsResult = claudeDispatchInvokesSlashCommand(content) - const sentUuid = randomUUID() + const sentUuid = input.sentUuid ?? randomUUID() const arm = () => { ++session.dispatchSequence // A context report asked for before this send may land after it and misstate the context. @@ -276,14 +125,6 @@ export async function dispatchClaudeTurn( parent_tool_use_id: null, session_id: session.providerSessionId } - if (claudeStartupHoldsWrites(session)) { - return holdClaudeStartupWrite(session, { - message, - arm, - ...(beforeDispatch ? { beforeDispatch } : {}), - ...(onSettledLate ? { settleLate: onSettledLate } : {}) - }) - } const pending = { replay: beforeDispatch ? undefined : arm() } const authorize = beforeDispatch ? async () => { @@ -304,7 +145,7 @@ export async function dispatchClaudeTurn( if (error instanceof AgentSessionPreDispatchError) { throw error } - return { state: 'rejected', reason: dispatchWriteFailureReason(error) } + return { state: 'rejected', ...claudeWriteFailureRejection(error) } } const waiter = replay.waiter if (waiter.settledUuid) { @@ -322,7 +163,7 @@ export async function dispatchClaudeTurn( waiter.resolve(null) // The frame was never handed to the SDK's input pump, so this is not doubt: // the message provably did not happen, which is what `rejected` means. - return { state: 'rejected', reason: dispatchWriteFailureReason(error) } + return { state: 'rejected', ...claudeWriteFailureRejection(error) } } if (!waiter.retired) { retireWaiter(session, waiter) @@ -332,6 +173,7 @@ export async function dispatchClaudeTurn( } // The write is the admission signal. Awaiting the echo here would block on the // turn already running, which is why the deadline this replaces kept declaring - // doubt about messages that were delivered. `settleWaiter` finishes the job. + // doubt about messages that were delivered. The replay resolution + // (`claude-replay-turn-resolution.ts`) finishes the job. return { state: 'admitted' } } diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts new file mode 100644 index 00000000000..2175ef49e43 --- /dev/null +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -0,0 +1,256 @@ +// A Claude chat at rest shows the effort its next start will run: a live child teaches the host +// catalog what the CLI runs for each model when no effort is sent, and the resting read answers it. + +import { describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { getAgentSessionOptionCatalog } from '../../shared/agent-session-option-catalog' +import { + applyStructuredAgentSessionOptions, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from '../../shared/structured-agent-session-options' +import { createAgentModelCatalogService } from '../native-chat/agent-model-catalog/agent-model-catalog-service' +import { agentModelCatalogFingerprintForRecord } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' +import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import type { StructuredAgentSessionMutationContext } from '../native-chat/agent-session-wire/structured-agent-session-host-mutations' +import { readStructuredAgentSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-options-read' +import { nativeSessionOptionsFromReport } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' +import { + ClaudeStructuredSessionAdapter, + type ClaudeStructuredSessionEvent +} from './claude-structured-session-adapter' +import { + PROVIDER_SESSION_ID, + fakeClaude, + identityFor, + recordingJournalSink +} from './claude-structured-session-test-support' + +const SESSION = 'session-1' +const ACCOUNT_HOME = '/accounts/claude' +const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] + +/** Claude Code 2.1.280's list_models rows: `default` resolves to the opus row. */ +const CATALOG = [ + { + value: 'default', + resolvedModel: 'claude-opus-5-5[1m]', + displayName: 'Default (recommended)', + supportsEffort: true, + supportedEffortLevels: EFFORTS + }, + { + value: 'opus[1m]', + resolvedModel: 'claude-opus-5-5[1m]', + displayName: 'Opus (1M context)', + supportsEffort: true, + supportedEffortLevels: EFFORTS + }, + { + value: 'sonnet', + resolvedModel: 'claude-sonnet-5', + displayName: 'Sonnet', + supportsEffort: true, + supportedEffortLevels: EFFORTS + } +] + +/** get_settings with nothing overriding the CLI's own default; an effort write moves both views. */ +function settingsWithNoOverride() { + const effective: { effortLevel?: string } = {} + const settings = { + effective, + sources: [], + applied: { model: 'claude-opus-5-5[1m]', effort: 'medium', advisor: null, ultracode: false } + } + const claude = fakeClaude({ + initProof: 'session-start', + initModels: CATALOG, + settings, + routes: { + list_models: () => CATALOG, + apply_flag_settings: (params) => { + const written = params?.settings + const effort = + typeof written === 'object' && written !== null && 'effortLevel' in written + ? written.effortLevel + : undefined + if (typeof effort === 'string') { + effective.effortLevel = effort + settings.applied.effort = effort + } + } + } + }) + return claude +} + +async function startChild( + store: AgentModelCatalogStore, + options?: Record, + events: ClaudeStructuredSessionEvent[] = [] +): Promise { + const adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: '/work/repo', + claudeConfigDir: ACCOUNT_HOME, + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: false, + continuesChain: false + }), + onEvent: (event) => events.push(event), + openConnection: settingsWithNoOverride().openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => 1_700_000_000_500, + persistHandle: async () => {}, + modelCatalog: store + }) + await adapter.acquire({ + identity: identityFor(SESSION), + fence: 7, + spawnToken: 'spawn-9', + events: recordingJournalSink(), + ...(options ? { options } : {}) + }) + await adapter.awaitStarted(SESSION) + return adapter +} + +function restingRecord(options: Record): AgentSessionRecord { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read and the catalog key touch only these fields. + return { + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }, + location: { wslDistro: null }, + options + } as unknown as AgentSessionRecord +} + +function catalogDefault(store: AgentModelCatalogStore, model: string): string | undefined { + const entry = store.get(agentModelCatalogFingerprintForRecord(restingRecord({}))) + return entry?.models.find((row) => row.id === model)?.defaultEffort +} + +/** The options a client reads for the chat once its child is gone. */ +function readAtRest(store: AgentModelCatalogStore, record: AgentSessionRecord) { + const modelCatalog = createAgentModelCatalogService({ + store, + getRecord: () => record, + resolveAccountHome: async () => ({ variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }) + }) + const resting = { child: null, params: { provider: 'claude' } } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members. + const context = { + deps: { adapter: {}, store: { getRecord: () => record }, modelCatalog }, + serialize: (_sessionId: string, task: () => Promise) => task(), + openConversation: async () => resting, + conversation: async () => resting + } as unknown as StructuredAgentSessionMutationContext + return readStructuredAgentSessionOptions(context, SESSION) +} + +function pickerEffort(result: Awaited>) { + const seed = getAgentSessionOptionCatalog('claude')! + const state = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('claude', seed), + seed, + result + ) + const effort = structuredAgentSessionOptionSnapshot(state).find((row) => row.id === 'effort') + return effort?.kind.type === 'select' ? effort.kind.currentValue : undefined +} + +describe('Claude effort default at rest', () => { + it("learns the model's default from a live child's applied effort", async () => { + const store = new AgentModelCatalogStore() + await startChild(store) + + // Both rows run claude-opus-5-5[1m]; sonnet was not applied, so nothing is known of it. + expect(catalogDefault(store, 'opus[1m]')).toBe('medium') + expect(catalogDefault(store, 'sonnet')).toBeUndefined() + }) + + it('shows that default in the picker of a chat at rest', async () => { + const store = new AgentModelCatalogStore() + await startChild(store) + + const result = await readAtRest(store, restingRecord({ model: 'opus[1m]' })) + + expect(result.current).toMatchObject({ model: 'opus[1m]', effort: 'medium' }) + expect(pickerEffort(result)).toBe('medium') + }) + + it("shows the user's pick at rest over the default", async () => { + const store = new AgentModelCatalogStore() + await startChild(store) + + const result = await readAtRest(store, restingRecord({ model: 'opus[1m]', effort: 'max' })) + + expect(result.current.effort).toBe('max') + expect(pickerEffort(result)).toBe('max') + }) + + it("never learns a user's pick as the default", async () => { + const store = new AgentModelCatalogStore() + const restored = await startChild(store, { model: 'opus[1m]', effort: 'high' }) + await restored.readOptions({ sessionId: SESSION, fence: 7 }) + expect(catalogDefault(store, 'opus[1m]')).toBeUndefined() + + const other = new AgentModelCatalogStore() + const live = await startChild(other) + await live.setOption({ sessionId: SESSION, fence: 7, key: 'effort', value: 'xhigh' }) + expect((await live.readOptions({ sessionId: SESSION, fence: 7 })).current.effort).toBe('xhigh') + expect(catalogDefault(other, 'opus[1m]')).toBe('medium') + }) + + it("leaves a Codex chat's unsaved effort blank at rest, as its live child does", async () => { + const store = new AgentModelCatalogStore() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read and the catalog key touch only these fields. + const record = { + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' }, + location: { wslDistro: null }, + options: { model: 'gpt-5.5' } + } as unknown as AgentSessionRecord + store.recordSuccess(agentModelCatalogFingerprintForRecord(record), 'codex', { + models: [ + { + id: 'gpt-5.5', + label: 'GPT-5.5', + isDefault: true, + defaultEffort: 'medium', + efforts: [ + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ] + } + ], + fastModeTierByModel: new Map(), + origin: 'live-session' + }) + + const result = await readAtRest(store, record) + + expect(result.current).toEqual({ model: 'gpt-5.5' }) + }) + + it("never saves the applied effort as the chat's pick, yet shows it at rest", async () => { + const store = new AgentModelCatalogStore() + const events: ClaudeStructuredSessionEvent[] = [] + await startChild(store, undefined, events) + + const started = events.find((event) => event.type === 'started') + const reported = started?.type === 'started' ? started.reportedOptions : null + expect(reported).not.toHaveProperty('effort') + // The record the start persists names the listed row the catalog learned under. + const record = restingRecord( + nativeSessionOptionsFromReport({ reported: reported!, restoreSkipped: [] }) + ) + expect(record.options).toEqual({ model: 'opus[1m]' }) + expect((await readAtRest(store, record)).current.effort).toBe('medium') + expect(record.options).toEqual({ model: 'opus[1m]' }) + }) +}) diff --git a/src/main/claude/claude-structured-effort-reporting.test.ts b/src/main/claude/claude-structured-effort-reporting.test.ts index 7cf59ee555b..2c319c86ee7 100644 --- a/src/main/claude/claude-structured-effort-reporting.test.ts +++ b/src/main/claude/claude-structured-effort-reporting.test.ts @@ -7,7 +7,7 @@ import { import { readClaudeSettingsEffort } from './claude-structured-session-options' import type { ClaudeSession } from './claude-structured-session-state' import type { ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' -import { acquired, fakeClaude } from './claude-structured-session-test-support' +import { USER_MESSAGE, acquired, fakeClaude } from './claude-structured-session-test-support' /** Verbatim from Claude Code 2.1.258's get_settings response. */ const REAL_SETTINGS = { @@ -93,7 +93,14 @@ describe('Claude effort reporting', () => { it('keeps the init fixture free of an effort the real frame never sends', async () => { const events: ClaudeStructuredSessionEvent[] = [] - await acquired(fakeClaude(), {}, events) + const adapter = await acquired(fakeClaude(), {}, events) + // Live: init arrives when the first command starts a cycle, not at startup. + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'seed-cycle', + body: USER_MESSAGE, + fence: 7 + }) const init = events.flatMap((event) => event.type === 'message' && event.message.subtype === 'init' ? [event.message] : [] ) diff --git a/src/main/claude/claude-structured-inbound-control.test.ts b/src/main/claude/claude-structured-inbound-control.test.ts index 20d0b9dd06a..43c96ee8bea 100644 --- a/src/main/claude/claude-structured-inbound-control.test.ts +++ b/src/main/claude/claude-structured-inbound-control.test.ts @@ -41,10 +41,12 @@ function callbacksFor() { describe('Claude permission callbacks', () => { it('registers a decodable can_use_tool as a durable prompt and settles it from the registry', async () => { const control = callbacksFor() + const controller = new AbortController() + const removeListener = vi.spyOn(controller.signal, 'removeEventListener') const answered = control.canUseTool( 'Bash', { command: 'git status' }, - permissionOptions('perm-1', 'tool-1', new AbortController().signal, [ + permissionOptions('perm-1', 'tool-1', controller.signal, [ { type: 'addRules', rules: [], behavior: 'allow', destination: 'session' } ]) ) @@ -63,6 +65,7 @@ describe('Claude permission callbacks', () => { // The prompt's settle is the SDK callback's own resolve — answering resolves this promise. found?.prompt.settle({ behavior: 'allow', toolUseID: 'tool-1' }) await expect(answered).resolves.toEqual({ behavior: 'allow', toolUseID: 'tool-1' }) + expect(removeListener).toHaveBeenCalledTimes(1) }) it('keeps the SDK permission presentation and strips terminal escapes', async () => { diff --git a/src/main/claude/claude-structured-inbound-control.ts b/src/main/claude/claude-structured-inbound-control.ts index 8d2954f028f..b2bed91f1f4 100644 --- a/src/main/claude/claude-structured-inbound-control.ts +++ b/src/main/claude/claude-structured-inbound-control.ts @@ -56,6 +56,11 @@ export function buildClaudePermissionCallbacks(deps: ClaudePermissionCallbackDep } { const canUseTool: CanUseTool = (toolName, input, options) => new Promise((resolve) => { + let cancel = (): void => {} + const settle = (response: PermissionResult | null): void => { + options.signal.removeEventListener('abort', cancel) + resolve(response) + } // Classify first so later permission-mode policy cannot swallow a plan proposal. const subject = claudePermissionSubject(toolName, input) const prompt = deps.prompts.register({ @@ -66,14 +71,14 @@ export function buildClaudePermissionCallbacks(deps: ClaudePermissionCallbackDep toolUseId: options.toolUseID, input, suggestions: options.suggestions ?? [], - settle: resolve, + settle, turnId: deps.currentTurnId?.() ?? null }) if (!prompt) { - resolve(denySafeResult(options.toolUseID)) + settle(denySafeResult(options.toolUseID)) return } - const cancel = (): void => { + cancel = (): void => { if (deps.prompts.forgetIfPending(prompt)) { deps.emit({ type: 'prompt-cancelled', @@ -82,7 +87,7 @@ export function buildClaudePermissionCallbacks(deps: ClaudePermissionCallbackDep }) // Null is the SDK's "no response written" sentinel: a cancelled request must not // be answered, only forgotten. - resolve(null) + settle(null) } } if (options.signal.aborted) { diff --git a/src/main/claude/claude-structured-init-proof.ts b/src/main/claude/claude-structured-init-proof.ts index c29cb2d4715..ae2ee5896a8 100644 --- a/src/main/claude/claude-structured-init-proof.ts +++ b/src/main/claude/claude-structured-init-proof.ts @@ -46,15 +46,25 @@ export function readClaudeModels(initialization: unknown): unknown[] { : [] } -/** CLI capabilities advertised on the initialize result or the yielded system/init frame. */ +/** + * The capabilities this CLI advertises: the first report given that names any, else `observed`. + * A SessionStart proof and the 2.1.280 initialize result name none, so only a turn's system/init + * may say what the binary supports, and a report naming none never retracts that. + */ export function readClaudeCapabilities( - init: ClaudeInitObservation, - initialization: unknown -): string[] { - const fromResult = isRecord(initialization) ? initialization.capabilities : undefined - const fromFrame = init.message.capabilities - const source = Array.isArray(fromResult) ? fromResult : Array.isArray(fromFrame) ? fromFrame : [] - return source.filter((value): value is string => typeof value === 'string') + observed: readonly string[], + ...reports: unknown[] +): readonly string[] { + for (const report of reports) { + const advertised = isRecord(report) ? report.capabilities : undefined + const capabilities = Array.isArray(advertised) + ? advertised.filter((value): value is string => typeof value === 'string') + : [] + if (capabilities.length > 0) { + return capabilities + } + } + return observed } export function claudeInitializationAuthError( @@ -64,7 +74,8 @@ export function claudeInitializationAuthError( isRecord(initialization) && isRecord(initialization.account) ? initialization.account : null return readClaudeFrameString(account ?? {}, 'tokenSource') === 'none' ? new AgentSessionAcquisitionRefusal( - 'Claude is not signed in for the selected account. Sign in with the Claude CLI for this CLAUDE_CONFIG_DIR, then retry.' + 'Claude is not signed in for the selected account. Sign in with the Claude CLI for this CLAUDE_CONFIG_DIR, then retry.', + 'notSignedIn' ) : null } diff --git a/src/main/claude/claude-structured-journal-prompt-retry.test.ts b/src/main/claude/claude-structured-journal-prompt-retry.test.ts index 491152668ed..d22362467e9 100644 --- a/src/main/claude/claude-structured-journal-prompt-retry.test.ts +++ b/src/main/claude/claude-structured-journal-prompt-retry.test.ts @@ -18,7 +18,6 @@ function approval(promptKey: string): ClaudePendingPrompt { input: { command: 'git status' }, suggestions: [], questionIds: [], - answers: new Map(), settle: () => {} } } diff --git a/src/main/claude/claude-structured-journal-prompts.ts b/src/main/claude/claude-structured-journal-prompts.ts index 0d2509723f6..92301af106c 100644 --- a/src/main/claude/claude-structured-journal-prompts.ts +++ b/src/main/claude/claude-structured-journal-prompts.ts @@ -1,7 +1,8 @@ import type { AgentJournalApprovalItem, AgentJournalItemIdentity, - AgentJournalQuestionItem + AgentJournalQuestionItem, + AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { cancelledJournalPromptBody } from '../native-chat/agent-session-journal/journal-prompt-body-bounds' @@ -22,6 +23,7 @@ const ADMITTED = { accepted: true } as const type ClaudeJournalPrompt = { identity: AgentJournalItemIdentity body: AgentJournalApprovalItem | AgentJournalQuestionItem + turnScope: AgentJournalTurnScope } type ClaudeJournalPromptEntry = { @@ -54,7 +56,9 @@ export class ClaudeJournalPrompts { constructor( private readonly deps: { sink: StructuredAgentSessionEventSink - bindPromptItemId?: (journalItemId: string, promptKey: string, questionId?: string) => void + /** The turn that raised the prompt: the open one, else the conversation. */ + turnScope: () => AgentJournalTurnScope + bindPromptItemId?: (journalItemId: string, promptKey: string) => void questionItems?: (input: { sessionId: string prompt: Extract['prompt'] @@ -75,13 +79,14 @@ export class ClaudeJournalPrompts { */ handle(event: Extract): void { const items: ClaudeJournalPrompt[] = [] + const turnScope = this.deps.turnScope() if (event.prompt.kind === 'question') { for (const question of (this.deps.questionItems ?? claudeQuestionItems)({ sessionId: event.sessionId, prompt: event.prompt })) { - items.push(question) - this.deps.sink.appendItem(question.identity, question.body) + items.push({ ...question, turnScope }) + this.deps.sink.appendItem(question.identity, question.body, { turnScope }) this.deps.bindPromptItemId?.(agentJournalItemKey(question.identity), event.prompt.promptKey) } } else { @@ -90,8 +95,8 @@ export class ClaudeJournalPrompts { promptKey: event.prompt.promptKey }) const body = claudeApprovalItem(event.prompt) - items.push({ identity, body }) - this.deps.sink.appendItem(identity, body) + items.push({ identity, body, turnScope }) + this.deps.sink.appendItem(identity, body, { turnScope }) this.deps.bindPromptItemId?.(agentJournalItemKey(identity), event.prompt.promptKey) } this.deletePrompt(event.prompt.promptKey) @@ -104,10 +109,11 @@ export class ClaudeJournalPrompts { if (items.length === 0) { return ADMITTED } - const mutations = items.map(({ identity, body }) => ({ + const mutations = items.map(({ identity, body, turnScope }) => ({ kind: 'item' as const, identity, - body: cancelledPromptBody(body) + body: cancelledPromptBody(body), + turnScope })) let admission: StructuredAgentSessionSinkAdmission if (this.deps.sink.tryAppendLifecycleBatch) { @@ -129,9 +135,10 @@ export class ClaudeJournalPrompts { return ADMITTED } const body = cancelledPromptBody(item.body) + const options = { lifecycle: true, turnScope: item.turnScope } admission = this.deps.sink.tryAppendItem - ? this.deps.sink.tryAppendItem(item.identity, body, { lifecycle: true }) - : (this.deps.sink.appendItem(item.identity, body, { lifecycle: true }), ADMITTED) + ? this.deps.sink.tryAppendItem(item.identity, body, options) + : (this.deps.sink.appendItem(item.identity, body, options), ADMITTED) } else { return { accepted: false, reason: 'failed' } } diff --git a/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts b/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts index 170f520e9ec..7b224e8c51a 100644 --- a/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts +++ b/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { @@ -201,7 +202,8 @@ describe('claude journal translation — background task rows', () => { deferred.bind(target) deferred.sink.appendItem( { provider: 'orca', clientMessageId: 'blocked-prefill' }, - { kind: 'message', role: 'system', blocks: [{ type: 'text', text: 'prefill' }] } + { kind: 'message', role: 'system', blocks: [{ type: 'text', text: 'prefill' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await appendEntered.promise const notification = systemFrame({ diff --git a/src/main/claude/claude-structured-journal-translation-subagents.test.ts b/src/main/claude/claude-structured-journal-translation-subagents.test.ts index d122d0af7a2..89df86122b8 100644 --- a/src/main/claude/claude-structured-journal-translation-subagents.test.ts +++ b/src/main/claude/claude-structured-journal-translation-subagents.test.ts @@ -529,7 +529,9 @@ describe('claude journal translation — which agent produced a row', () => { (entry) => orcaClientMessageId(entry.identity) === 'claude-tool:claude-session:toolu_bash' ) expect(row?.body).toMatchObject({ kind: 'tool-call', state: 'completed' }) - expect(row?.options).toEqual({}) + expect(row?.options).toEqual({ + turnScope: { kind: 'turn', turnItemId: expect.any(String) } + }) }) /** One streamed text block, as the SDK sends it: a message start, then deltas. diff --git a/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts b/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts index ad76cd2792b..ee0f4970f4d 100644 --- a/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts +++ b/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts @@ -161,7 +161,7 @@ describe('Claude structured turn timing', () => { state: 'running', startedAt: 1_000, userItemId: USER_1_KEY, - options: { observedAt: 1_000 } + options: { observedAt: 1_000, turnScope: { kind: 'thread' } } } ]) }) @@ -194,7 +194,7 @@ describe('Claude structured turn timing', () => { startedAt: 1_000, completedAt: 4_500, userItemId: USER_1_KEY, - options: {} + options: { turnScope: { kind: 'thread' } } }) expect(state.items.at(-1)?.identity).toEqual(state.items[0]?.identity) }) diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts index 574547ee731..d868b3816c4 100644 --- a/src/main/claude/claude-structured-journal-translation.test.ts +++ b/src/main/claude/claude-structured-journal-translation.test.ts @@ -869,7 +869,6 @@ function prompt( return { ...input, suggestions: [], - answers: new Map(), settle: () => {} } } diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index c18392e1aa9..8c8ff1470f4 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -9,9 +9,7 @@ import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' import { claudeProviderFrameActivity } from '../native-chat/agent-session-wire/provider-frame-activity' import { claudeProviderFrameKind, - claudeResultFailure, - createClaudeProviderFrameFallback, - isSettledClaudeResultKind + createClaudeProviderFrameFallback } from './claude-structured-provider-fallback' import { taskFrameSentence } from './claude-background-task-frames' import { ClaudeBackgroundTaskRows } from './claude-background-task-rows' @@ -26,19 +24,20 @@ import { claudeStreamTurnSource, isRootClaudeFrame } from './claude-turn-opening' -import { claudeTurnEndForResult } from './claude-turn-lifecycle-item' import { ClaudeOpenTurn } from './claude-open-turn' +import { claudeCommandCurrentTurn, observeClaudeCommandFrame } from './claude-command-turn' import { ClaudeContextFacts } from './claude-context-facts' import { claudeSessionStateEndsTurn } from './claude-session-state-turn-over' import { ClaudeJournalPrompts } from './claude-structured-journal-prompts' import { claudeChildToolQueries } from './claude-child-tool-queries' import { journalClaudeMessage, type ClaudeMessageJournalContext } from './claude-message-journaling' +import { journalClaudeResult, type ClaudeResultJournalContext } from './claude-result-journaling' export type { ClaudeJournalTranslator } from './claude-journal-translator-contract' export type ClaudeJournalTranslatorDeps = { sink: StructuredAgentSessionEventSink - bindPromptItemId?: (journalItemId: string, promptKey: string, questionId?: string) => void + bindPromptItemId?: (journalItemId: string, promptKey: string) => void coalesceMs?: number schedule?: AgentSessionDeltaCoalescerDeps['schedule'] fallbackIdPrefix?: string @@ -56,8 +55,7 @@ export function createClaudeSessionJournalTranslator( sink, fallbackIdPrefix, ...(onBackgroundTaskJournalFailure ? { onBackgroundTaskJournalFailure } : {}), - bindPromptItemId: (itemId, promptKey, questionId) => - prompts.bindJournalItemId(itemId, promptKey, questionId) + bindPromptItemId: (itemId, promptKey) => prompts.bindJournalItemId(itemId, promptKey) }) : null } @@ -66,7 +64,9 @@ export function createClaudeJournalTranslator( deps: ClaudeJournalTranslatorDeps ): ClaudeJournalTranslator { const tools = new Map() - const prompts = new ClaudeJournalPrompts(deps) + // Every row joins the root turn open when it is written, whoever produced it. + const turnScope = () => turn.turnScope + const prompts = new ClaudeJournalPrompts({ ...deps, turnScope }) const streamedBlocks = createClaudeStreamedBlockRegistry() const turn = new ClaudeOpenTurn({ sink: deps.sink, @@ -74,14 +74,13 @@ export function createClaudeJournalTranslator( onOpen: () => context.markActivity() }) const context = new ClaudeContextFacts(turn, deps.sink) - const providerFallback = createClaudeProviderFrameFallback( - deps.sink, - deps.fallbackIdPrefix ?? 'acquisition' - ) + const fallbackId = deps.fallbackIdPrefix ?? 'acquisition' + const providerFallback = createClaudeProviderFrameFallback(deps.sink, fallbackId, turnScope) const toolOrigins = new ClaudeToolOriginRegistry() const subagents = new ClaudeSubagentRoster({ sink: deps.sink, currentGroupKey: () => turn.groupKey, + currentTurnScope: turnScope, isForwardedParentTool: (toolUseId) => toolOrigins.has(toolUseId), childOwnerRefOf: (toolUseId) => toolOrigins.childOwnerRef(toolUseId), // A settled group can receive no further announcement, so a correction @@ -91,6 +90,7 @@ export function createClaudeJournalTranslator( const childQueries = claudeChildToolQueries({ tools, toolOrigins, linkage: subagents.linkage }) const corrections = new ClaudeProvisionalRowCorrections({ ...subagents.linkage, + turnScope, rewrite: (identity, body, options) => { // The admission-returning path, so a correction the sink refuses under // backpressure stays owed instead of vanishing. Sinks without it accept @@ -111,6 +111,7 @@ export function createClaudeJournalTranslator( // turn, or the session shows the row while reading idle. openOutputTurn: (frame, observedAt) => turn.ensureOpen(frame, claudeStreamTurnSource(frame), observedAt), + turnScope, ...(deps.onBackgroundTaskJournalFailure ? { onPersistenceFailure: deps.onBackgroundTaskJournalFailure } : {}) @@ -120,7 +121,8 @@ export function createClaudeJournalTranslator( ...(deps.schedule ? { schedule: deps.schedule } : {}), producer: subagents.linkage, persist: (identity, text, options) => { - deps.sink.appendItem(identity, claudeStreamingMessageBody(text), options) + const body = claudeStreamingMessageBody(text) + deps.sink.appendItem(identity, body, { ...options, turnScope: turnScope() }) deps.sink.publish() } }) @@ -162,6 +164,8 @@ export function createClaudeJournalTranslator( turn } + const resultContext: ClaudeResultJournalContext = { ...messageContext, prompts, context } + const handleMessage = ( message: Record, startsTurn: boolean, @@ -185,6 +189,24 @@ export function createClaudeJournalTranslator( } if (event.type === 'message') { context.observe(event.message, event.observedAt ?? Date.now()) + // A root init is the CLI starting a new request cycle (measured per turn, + // per queued turn, per background wake, per /compact); a send replayed + // after that cycle's first root work was folded into it. Task frames are + // not cycle work: they arrive between cycles too. + if (isRootClaudeFrame(event.message)) { + if (event.message.type === 'system' && event.message.subtype === 'init') { + turn.observeProviderCycleStart() + } else if ( + event.startsTurn === true || + event.message.type === 'assistant' || + event.message.type === 'stream_event' + ) { + turn.observeProviderCycleWork() + } + } + } + if (event.type === 'message' && observeClaudeCommandFrame(turn.command, event.message)) { + return } if (event.type === 'message' && handleStream(event.message, event.observedAt ?? Date.now())) { return @@ -204,41 +226,7 @@ export function createClaudeJournalTranslator( prompts.retryPendingCancellations() prompts.cancel(event.promptKey) } else if (event.type === 'message' && event.message.type === 'result') { - // Every turn this translator opens is root by construction, so a nested - // result settles the child that produced it and never the turn. The - // diagnostic below still runs: a child's failure is reportable even when - // it ends no turn. - const settlesTurn = isRootClaudeFrame(event.message) - if (settlesTurn) { - prompts.retryPendingCancellations() - turn.suppressReopenOnFailure(event.message.is_error === true) - // The turn is over however it ended, so a foreground child still - // reported as working will never be settled by an event. - subagents.settleTurn(turn.groupKey) - context.settle( - event.message, - claudeTurnEndForResult(event.message, event.observedAt ?? Date.now()) - ) - // The turn is over. A block still awaiting its final keeps the text the - // flush above journaled, but its live state goes: an interrupted turn - // would otherwise retain that text for the life of the session. - streamedBlocks.clear() - streamedText.settle() - } - const kind = claudeProviderFrameKind(event.message) - const failure = claudeResultFailure(event.message) - if (failure || !isSettledClaudeResultKind(kind)) { - providerFallback.append( - kind, - event.message, - failure?.text, - undefined, - undefined, - // A result that settles no turn is a CHILD's result: this - // translator only ever opens root turns. - settlesTurn ? undefined : corrections.stampFor(claudeFrameParentRef(event.message)) - ) - } + journalClaudeResult(resultContext, event.message, event.observedAt ?? Date.now()) } else if (event.type === 'message') { const backgroundTaskCovered = backgroundTasks.observe( event.message, @@ -282,6 +270,15 @@ export function createClaudeJournalTranslator( get currentTurnId() { return turn.id }, + get commandTurnId() { + return turn.command ? turn.id : null + }, + beginCommand: (start) => turn.beginCommand(claudeCommandCurrentTurn(start)), + forgetCommand: (turnId) => turn.forgetCommand(turnId), + commandInterruptRequested: (turnId) => turn.commandInterruptRequested(turnId), + get openTurnInLiveProviderCycle() { + return turn.openedInLiveProviderCycle + }, flush: streamedText.flush, childToolOwner: childQueries.childToolOwner, childActivity: childQueries.childActivity, diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index c54d1a5179f..66fed64c323 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -161,6 +161,19 @@ describe('claude structured launch resolution', () => { expect(launch.options.sessionId).toBeUndefined() }) + it('names the child by the Orca session id, over any id the configured overlay carries', async () => { + // The Orca-minted id, never the provider's: the provider id rotates on /clear. + const launch = await resolverFor(record(), () => ({ + ORCA_AGENT_SESSION_ID: 'a0b1c2d3-0000-4000-8000-00000000abcd' + }))({ identity: IDENTITY }) + + expect(launch.env).toMatchObject({ + ORCA_AGENT_SESSION_ID: SESSION_ID, + ORCA_CLI_COMMAND: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/) + }) + expect(launch.env?.ORCA_AGENT_SESSION_ID).not.toBe(launch.providerSessionId) + }) + it('forces session-state events on when the inherited overlay disables them', async () => { const launch = await resolverFor(record(), () => ({ [CLAUDE_SESSION_STATE_EVENTS_ENV]: '0' @@ -492,16 +505,17 @@ describe('claude structured launch resolution', () => { gate = WSL_ONLY_NORMALIZED - // Reacquire after the account state changed: refused before anything spawns. - await expect(resolve({ identity: identityAt('leaf-current') })).rejects.toBeInstanceOf( - AgentSessionPreSpawnError - ) + // Reacquire after the account state changed: refused before anything spawns, naming the + // account shape a person can change. + const refused = resolve({ identity: identityAt('leaf-current') }) + await expect(refused).rejects.toBeInstanceOf(AgentSessionPreSpawnError) + await expect(refused).rejects.toMatchObject({ reason: 'managedAccountUnsupported' }) }) - it('fails closed when the account state cannot be read', async () => { - await expect( - resolverWithGate(() => null)({ identity: identityAt('leaf-current') }) - ).rejects.toBeInstanceOf(AgentSessionPreSpawnError) + it('fails closed when the account state cannot be read, naming no situation', async () => { + const refused = resolverWithGate(() => null)({ identity: identityAt('leaf-current') }) + await expect(refused).rejects.toBeInstanceOf(AgentSessionPreSpawnError) + await expect(refused).rejects.toMatchObject({ reason: undefined }) }) it('keeps resolving when no gate is wired, so other embedders are unaffected', async () => { diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index a2b3d41bbfd..29703dfd8eb 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -8,7 +8,7 @@ import type { AgentSessionJournalIdentity } from '../../shared/agent-session-jou import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' -import { structuredWorkerChildIdentityEnv } from '../runtime/structured-worker-child-identity-env' +import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env' import { CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, @@ -22,6 +22,7 @@ import { } from '../claude-accounts/live-pty-gate' import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { + hasWslBoundClaudeAccount, structuredClaudeMatchesActiveManagedAccount, type ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' @@ -172,7 +173,9 @@ export async function resolveClaudeStructuredInvocation( // Under a managed account the pinned credential is the only auth this launch may // use, so an explicit override is refused rather than silently beating the pin. if (auth.stripAuthEnv && hasClaudeAuthEnvConflict(overlay)) { - throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) + throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE), { + reason: 'managedAccountEnvOverride' + }) } // Why the overlay merges onto the inherited env rather than replacing it: the child // still needs PATH and the rest of the shell environment, and withCliRuntimeOnPath @@ -209,7 +212,9 @@ export async function assertClaudeAuthSwitchSettled( timeoutMs = CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS ): Promise { if (!(await whenClaudeAuthSwitchSettles(timeoutMs))) { - throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) + throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE), { + reason: 'accountSwitchInProgress' + }) } } @@ -247,12 +252,12 @@ export function createClaudeStructuredLaunchResolver( // Every acquisition, not just the first: the account state can change under a live session, and // a reacquire after an unexpected exit would otherwise spawn under whatever it has become. // Codex has no gate here — it resolves its account on a different path. - if ( - deps.readManagedAccountGate && - !structuredClaudeMatchesActiveManagedAccount(deps.readManagedAccountGate()) - ) { + const gate = deps.readManagedAccountGate?.() + if (gate !== undefined && !structuredClaudeMatchesActiveManagedAccount(gate)) { + // Unreadable account state names no situation a person can act on, so only the log reads it. throw new AgentSessionPreSpawnError( - 'structured Claude is not offered under the active managed Claude account' + 'structured Claude is not offered under the active managed Claude account', + gate && hasWslBoundClaudeAccount(gate) ? { reason: 'managedAccountUnsupported' } : {} ) } const head = agentSessionProviderHandleChainHead(record.providerHandleChain) @@ -283,9 +288,8 @@ export function createClaudeStructuredLaunchResolver( (await deps.resolvePermissionMode?.()) ?? 'default' ) const { command, env } = await resolveClaudeStructuredInvocation(deps, (base) => - // Only a dispatched structured worker gets the orchestration identity and the Orca CLI on - // PATH; an ordinary chat session's env passes through untouched. - structuredWorkerChildIdentityEnv(record.sessionId, { + // Every structured session speaks orchestration as itself: its injected id and the Orca CLI. + structuredSessionChildIdentityEnv(record.sessionId, { ...base, // The turn translator relies on Claude's authoritative idle frame when no result arrives. [CLAUDE_SESSION_STATE_EVENTS_ENV]: '1' diff --git a/src/main/claude/claude-structured-model-confirmation.test.ts b/src/main/claude/claude-structured-model-confirmation.test.ts index 96d7e9daa39..f4034400cbf 100644 --- a/src/main/claude/claude-structured-model-confirmation.test.ts +++ b/src/main/claude/claude-structured-model-confirmation.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from 'vitest' import { setClaudeStructuredOption } from './claude-structured-options' import type { ClaudeSession } from './claude-structured-session-state' -import { PROVIDER_SESSION_ID, acquired, fakeClaude } from './claude-structured-session-test-support' +import { + PROVIDER_SESSION_ID, + USER_MESSAGE, + acquired, + fakeClaude +} from './claude-structured-session-test-support' /** Verbatim rows from Claude Code 2.1.258's list_models response. */ const CATALOG = [ @@ -147,6 +152,13 @@ describe('Claude model confirmation', () => { routes: { list_models: () => CATALOG } }) const adapter = await acquired(claude) + // The model is confirmed by a cycle's init frame, so start one. + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'seed-cycle', + body: USER_MESSAGE, + fence: 7 + }) await adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 }) await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index e02647f3625..a92149a2cdd 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -11,7 +11,7 @@ import { import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' -import { createClaudeSessionStartupGate } from './claude-structured-session-startup-gate' +import { createClaudeSessionStartup } from './claude-structured-session-startup-state' import { claudeStructuredSessionOptionsFrom, observeClaudeFastModeFacts, @@ -48,7 +48,7 @@ function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSe capabilities: [], events: undefined, translator: null, - startup: { ...createClaudeSessionStartupGate(), state: 'proven' } + startup: { ...createClaudeSessionStartup(), state: 'proven' } } } diff --git a/src/main/claude/claude-structured-options.ts b/src/main/claude/claude-structured-options.ts index 0090cd2f039..2f0b7ac4b70 100644 --- a/src/main/claude/claude-structured-options.ts +++ b/src/main/claude/claude-structured-options.ts @@ -41,6 +41,13 @@ export function restoredClaudeStructuredSessionOptions( ) } +/** The keys `setClaudeStructuredOption` writes; a pick made at rest is checked against these. */ +const CLAUDE_STRUCTURED_OPTION_KEYS = new Set(['model', 'permissionMode', 'effort', 'fastMode']) + +export function isClaudeStructuredOptionKey(key: string): boolean { + return CLAUDE_STRUCTURED_OPTION_KEYS.has(key) +} + /** A client's write; the startup restore writes through `setClaudeStructuredOption` directly. */ export function setClaudeStructuredSessionOption( session: ClaudeSession, @@ -51,7 +58,8 @@ export function setClaudeStructuredSessionOption( if (session.startup.state !== 'proven') { return Promise.reject( new AgentSessionOptionRejectedError( - 'Claude is still starting; options can be changed once it is ready.' + 'Claude is still starting; options can be changed once it is ready.', + 'providerStarting' ) ) } diff --git a/src/main/claude/claude-structured-prompt-items.test.ts b/src/main/claude/claude-structured-prompt-items.test.ts index 297bf405435..f032d4bcf8d 100644 --- a/src/main/claude/claude-structured-prompt-items.test.ts +++ b/src/main/claude/claude-structured-prompt-items.test.ts @@ -1,13 +1,11 @@ import { describe, expect, it } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import { encodeAgentSessionQuestionAnswers } from '../../shared/agent-session-question-answer' import { cancelledJournalPromptBody } from '../native-chat/agent-session-journal/journal-prompt-body-bounds' import { MAX_JOURNAL_LIFECYCLE_BATCH_BYTES } from '../native-chat/agent-session-journal/journal-row-schema' import { MAX_TOOL_DETAIL_LENGTH } from '../../shared/native-chat-tool-summary' import { claudeApprovalItem, claudeQuestionItems } from './claude-structured-prompt-items' import { - applyClaudePromptAnswer, - encodeClaudeQuestionOptionId, + buildClaudePromptReply, type ClaudePendingPrompt } from './claude-structured-prompt-replies' @@ -24,7 +22,6 @@ function approvalPrompt( input, suggestions: [], questionIds: [], - answers: new Map(), settle: () => {}, ...presentation } @@ -121,19 +118,21 @@ describe('Claude structured approval presentation', () => { } ) - expect(applyClaudePromptAnswer({ prompt }, 'deny')).toEqual({ + expect(buildClaudePromptReply(prompt, { kind: 'option', optionId: 'deny' })).toEqual({ behavior: 'deny', message: 'User denied this action.', toolUseID: 'tool-approval' }) - expect(applyClaudePromptAnswer({ prompt }, 'allowForSession')).toEqual({ - behavior: 'allow', - updatedInput: { command: 'rm output.txt' }, - updatedPermissions: [ - { type: 'addRules', rules: [], behavior: 'allow', destination: 'session' } - ], - toolUseID: 'tool-approval' - }) + expect(buildClaudePromptReply(prompt, { kind: 'option', optionId: 'allowForSession' })).toEqual( + { + behavior: 'allow', + updatedInput: { command: 'rm output.txt' }, + updatedPermissions: [ + { type: 'addRules', rules: [], behavior: 'allow', destination: 'session' } + ], + toolUseID: 'tool-approval' + } + ) }) it('asks Claude to revise a rejected plan while accepting legacy session replies', () => { @@ -145,16 +144,18 @@ describe('Claude structured approval presentation', () => { } ) - expect(applyClaudePromptAnswer({ prompt }, 'deny')).toEqual({ + expect(buildClaudePromptReply(prompt, { kind: 'option', optionId: 'deny' })).toEqual({ behavior: 'deny', message: 'The user asked you to keep planning. Revise the plan and call ExitPlanMode again.', toolUseID: 'tool-approval' }) - expect(applyClaudePromptAnswer({ prompt }, 'allowForSession')).toEqual({ - behavior: 'allow', - updatedInput: { plan: '# Release' }, - toolUseID: 'tool-approval' - }) + expect(buildClaudePromptReply(prompt, { kind: 'option', optionId: 'allowForSession' })).toEqual( + { + behavior: 'allow', + updatedInput: { plan: '# Release' }, + toolUseID: 'tool-approval' + } + ) }) }) @@ -178,7 +179,6 @@ describe('Claude structured question addressing', () => { input: { questions }, suggestions: [], questionIds: questions.map((question) => question.question), - answers: new Map(), settle: () => {} } @@ -211,7 +211,6 @@ describe('Claude structured question addressing', () => { input: { questions: [{ question: questionId, options: [{ label }] }] }, suggestions: [], questionIds: [questionId], - answers: new Map(), settle: () => {} } @@ -219,7 +218,12 @@ describe('Claude structured question addressing', () => { expect(agentJournalItemKey(item.identity).length).toBeLessThan(512) expect(item.body.options[0]!.id.length).toBeLessThan(512) expect(item.body.freeTextQuestionId).toBe('q1') - expect(applyClaudePromptAnswer({ prompt }, item.body.options[0]!.id)).toMatchObject({ + expect( + buildClaudePromptReply(prompt, { + kind: 'answers', + answers: [{ questionId: 'q1', optionIds: [item.body.options[0]!.id] }] + }) + ).toMatchObject({ updatedInput: { answers: { [questionId]: label } } }) }) @@ -235,13 +239,15 @@ describe('Claude structured question addressing', () => { input: { questions: [{ question: questionId }] }, suggestions: [], questionIds: [questionId], - answers: new Map(), settle: () => {} } const answer = 'https://example.test:8443/path' expect( - applyClaudePromptAnswer({ prompt }, encodeClaudeQuestionOptionId('q1', answer)) + buildClaudePromptReply(prompt, { + kind: 'answers', + answers: [{ questionId: 'q1', optionIds: [], other: answer }] + }) ).toMatchObject({ updatedInput: { answers: { [questionId]: answer } } }) @@ -273,21 +279,20 @@ describe('Claude structured question addressing', () => { }, suggestions: [], questionIds: [multiQuestion, singleQuestion, otherQuestion], - answers: new Map(), settle: () => {} } const item = claudeQuestionItems({ sessionId: 'session-1', prompt })[0]! const questions = item.body.questions! - const encoded = encodeAgentSessionQuestionAnswers([ + const answers = [ { questionId: 'q1', optionIds: [questions[0]!.options[0]!.id, questions[0]!.options[1]!.id] }, { questionId: 'q2', optionIds: [questions[1]!.options[1]!.id] }, { questionId: 'q3', optionIds: [], other: 'remote host' } - ]) + ] - expect(applyClaudePromptAnswer({ prompt }, encoded)).toMatchObject({ + expect(buildClaudePromptReply(prompt, { kind: 'answers', answers })).toMatchObject({ updatedInput: { answers: { [multiQuestion]: ['frontend', 'backend'], diff --git a/src/main/claude/claude-structured-prompt-ownership.test.ts b/src/main/claude/claude-structured-prompt-ownership.test.ts index 7a06a58e881..31fd703088f 100644 --- a/src/main/claude/claude-structured-prompt-ownership.test.ts +++ b/src/main/claude/claude-structured-prompt-ownership.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' @@ -17,9 +18,9 @@ import { acquired, adapterFor, fakeClaude, - identityFor, - invokeCanUseTool + identityFor } from './claude-structured-session-test-support' +import { invokeCanUseTool } from './claude-can-use-tool-test-support' function deferred(): { promise: Promise; resolve: () => void } { let resolve = (): void => {} @@ -120,7 +121,7 @@ describe('Claude live prompt ownership', () => { sessionId: 'session-1', itemId: 'journal-prompt', kind: 'approval', - optionId: 'allow', + response: { kind: 'option', optionId: 'allow' }, fence: 7, commit: async () => { expect(answered.settled()).toBe(false) @@ -185,7 +186,7 @@ describe('Claude live prompt ownership', () => { sessionId: 'session-1', itemId: 'journal-prompt', kind: 'approval', - optionId: 'allow', + response: { kind: 'option', optionId: 'allow' }, fence: 7, commit }) @@ -204,7 +205,7 @@ describe('Claude live prompt ownership', () => { sessionId: 'session-1', itemId: 'journal-prompt', kind: 'approval', - optionId: 'allow', + response: { kind: 'option', optionId: 'allow' }, fence: 7, commit }) @@ -269,7 +270,8 @@ describe('Claude live prompt ownership', () => { sessionId: 'session-1', clientMessageId: 'queued-message', state: 'rejected', - reason: 'provider_cancelled_before_start' + reason: 'provider_cancelled_before_start', + rejection: { kind: 'cancelled' } }) }) @@ -340,7 +342,10 @@ describe('Claude live prompt ownership', () => { }) it('drops resolved prompt bodies instead of retaining them for the session lifetime', () => { - const prompts = new ClaudeJournalPrompts({ sink: lifecycleRecorder().sink }) + const prompts = new ClaudeJournalPrompts({ + sink: lifecycleRecorder().sink, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE + }) for (let index = 0; index < 128; index += 1) { const promptKey = `resolved-${index}` @@ -356,7 +361,6 @@ describe('Claude live prompt ownership', () => { input: { command: 'git status' }, suggestions: [], questionIds: [], - answers: new Map(), settle: vi.fn() } }) @@ -398,7 +402,7 @@ describe('Claude live prompt ownership', () => { sessionId: 'session-1', itemId: 'journal-prompt', kind: 'approval', - optionId: 'allow', + response: { kind: 'option', optionId: 'allow' }, fence: 7, commit: async () => undefined }) @@ -557,7 +561,7 @@ describe('Claude live prompt ownership', () => { sessionId: 'session-1', itemId: promptItemId, kind: 'approval', - optionId: 'allow', + response: { kind: 'option', optionId: 'allow' }, fence: 7, commit }) @@ -610,7 +614,7 @@ describe('Claude live prompt ownership', () => { sessionId: 'session-1', itemId: 'journal-prompt', kind: 'approval', - optionId: 'allow', + response: { kind: 'option', optionId: 'allow' }, fence: 8, commit }) @@ -673,7 +677,8 @@ describe('Claude live prompt ownership', () => { } ] : [] - } + }, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) const prompt: ClaudePendingPrompt = { requestId: 'grouped-request', @@ -689,7 +694,6 @@ describe('Claude live prompt ownership', () => { }, suggestions: [], questionIds: ['First?', 'Second?'], - answers: new Map(), settle: vi.fn() } prompts.handle({ type: 'prompt', sessionId: 'session-1', prompt }) @@ -715,7 +719,8 @@ describe('Claude live prompt ownership', () => { lifecycleAttempts += 1 return backpressured ? { accepted: false, reason: 'backpressure' } : { accepted: true } } - } + }, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) const registerCancellation = (index: number): void => { const promptKey = `permission-${index}` @@ -728,7 +733,6 @@ describe('Claude live prompt ownership', () => { input: { command: 'git status' }, suggestions: [], questionIds: [], - answers: new Map(), settle: vi.fn() } prompts.handle({ type: 'prompt', sessionId: 'session-1', prompt }) diff --git a/src/main/claude/claude-structured-prompt-ownership.ts b/src/main/claude/claude-structured-prompt-ownership.ts index e4c9de982d0..6d45e77bbbb 100644 --- a/src/main/claude/claude-structured-prompt-ownership.ts +++ b/src/main/claude/claude-structured-prompt-ownership.ts @@ -1,21 +1,19 @@ import { + AgentSessionPromptAnswerRejectedError, AgentSessionPromptUnavailableError, type StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import type { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' import { CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS } from './claude-agent-sdk-control-requests' import { answerClaudePrompt, cancelClaudeTurn, supportsClaudeQueuedInterruptCancellation } from './claude-structured-control-actions' -import type { ClaudeLateDispatchSettlement } from './claude-structured-dispatch' +import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution' +import { buildClaudePromptReply } from './claude-structured-prompt-replies' import type { ClaudeSession } from './claude-structured-session-state' -import { - claudeStartupHoldsWrites, - rejectClaudeStartupWrites -} from './claude-structured-session-startup-gate' -import { DISPATCH_REJECTED_CANCELLED } from '../../shared/structured-agent-session-dispatch-rejection' +import type { ClaudePendingPrompt } from './claude-prompt-registry' +import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk' /** Conservative user-facing window: below the 30s control deadline, trading * residual slow-pump risk for ensuring delivery bookkeeping cannot block Stop indefinitely. */ @@ -91,31 +89,54 @@ function waitForClaudeDispatchAdmission( }) } +/** + * A Stop that names no turn: the conversation asked to stop whatever this child has in flight. + * Claude's interrupt is session-scoped, so there is no turn identity to check — only that this is + * still the child the host judged, and that it has a turn open or a written message whose turn has + * not opened yet (the gap before its echo, which no client can name). + */ +function cancelClaudeConversation( + session: ClaudeSession, + sessions: Map, + request: CancelInput, + timeoutMs: number | undefined, + onDispatchSettledLate: ClaudeLateDispatchSettlement | undefined +): Promise<{ cancelled: boolean }> { + const acquisitionGeneration = session.acquisitionGeneration + const isCurrent = (): boolean => + sessions.get(request.sessionId) === session && + session.fence === request.fence && + session.acquisitionGeneration === acquisitionGeneration && + ((request.resolveLiveTurnId?.() ?? session.translator?.currentTurnId ?? null) !== null || + session.dispatchWaiters.length > 0) + return cancelClaudeTurn(session, timeoutMs, isCurrent, onDispatchSettledLate) +} + export async function cancelClaudeStructuredTurn(input: { request: CancelInput sessions: Map - compactions: StructuredSessionCompaction timeoutMs?: number admitPromptCancellation: (session: ClaudeSession, promptKey: string) => boolean onDispatchSettledLate?: ClaudeLateDispatchSettlement }): Promise<{ cancelled: boolean }> { - const { request, sessions, compactions, timeoutMs } = input + const { request, sessions, timeoutMs } = input const session = requireSession(sessions, request.sessionId) const acquisitionGeneration = session.acquisitionGeneration const prompt = request.prompt - // A held prompt was never written, so Stop withdraws it; the drain only writes what it still - // holds. Before startup lands nothing was written, so there is nothing to interrupt either. - let withdrewHeld = false - if (!prompt && claudeStartupHoldsWrites(session) && session.fence === request.fence) { - withdrewHeld = rejectClaudeStartupWrites(session, DISPATCH_REJECTED_CANCELLED) - if (session.startup.state === 'pending') { - return { cancelled: withdrewHeld } - } + // Before startup lands nothing was written, so there is nothing to interrupt. + if (!prompt && session.startup.state === 'pending') { + return { cancelled: false } + } + const requestedTurnId = request.turnId + if (requestedTurnId === undefined) { + return prompt + ? { cancelled: false } + : cancelClaudeConversation(session, sessions, request, timeoutMs, input.onDispatchSettledLate) } if (prompt && session.fence !== request.fence) { return { cancelled: false } } - const claim = prompt ? session.prompts.claimBound(prompt.itemId, request.turnId) : null + const claim = prompt ? session.prompts.claimBound(prompt.itemId, requestedTurnId) : null if (prompt && !claim) { return { cancelled: false } } @@ -131,7 +152,7 @@ export async function cancelClaudeStructuredTurn(input: { // means nothing has published an identity this request can contradict. const ownsRequestedTurn = (): boolean => { const liveTurnId = request.resolveLiveTurnId?.() ?? session.translator?.currentTurnId ?? null - return liveTurnId === null ? session.dispatchSequence === 0 : liveTurnId === request.turnId + return liveTurnId === null ? session.dispatchSequence === 0 : liveTurnId === requestedTurnId } // The host supplies the durable latest submission; direct adapter callers fall back to // the current in-memory waiter so an unknown dispatch remains fenced without a latch. @@ -149,7 +170,8 @@ export async function cancelClaudeStructuredTurn(input: { const dispatchAdmissionAllowsCancellation = (): boolean => dispatchAdmissionIsCurrent() || (Boolean(prompt) && supportsClaudeQueuedInterruptCancellation(session)) - const compactionOwnsTurn = (): boolean => compactions.ownsTurn(request.sessionId, request.turnId) + const compactionOwnsTurn = (): boolean => + session.translator !== null && session.translator.commandTurnId === requestedTurnId const currentDispatchHasRetiredWaiter = (): boolean => session.retiredDispatchWaiters.some( (waiter) => waiter.dispatchSequence === session.dispatchSequence @@ -171,7 +193,7 @@ export async function cancelClaudeStructuredTurn(input: { session.acquisitionGeneration === acquisitionGeneration && (claim && prompt ? ownsRequestedTurn() && - session.prompts.ownsBoundClaim(claim, prompt.itemId, request.turnId) && + session.prompts.ownsBoundClaim(claim, prompt.itemId, requestedTurnId) && (dispatchAdmissionAllowsCancellation() || dispatchAdmissionExpired) : compactionOwnsTurn() || (ownsRequestedTurn() && @@ -181,7 +203,14 @@ export async function cancelClaudeStructuredTurn(input: { const result = await cancelClaudeTurn( session, timeoutMs, - isCurrent, + () => { + const current = isCurrent() + // Read with the result that ends it: a stopped command reports no compaction. + if (current && compactionOwnsTurn()) { + session.translator?.commandInterruptRequested(requestedTurnId) + } + return current + }, input.onDispatchSettledLate ) if (result.cancelled && claim && cancellationObserved) { @@ -193,7 +222,7 @@ export async function cancelClaudeStructuredTurn(input: { } else if (claim) { session.prompts.releaseClaim(claim) } - return withdrewHeld ? { ...result, cancelled: true } : result + return result } catch (error) { if (claim && !interruptConfirmed) { session.prompts.releaseClaim(claim) @@ -202,6 +231,19 @@ export async function cancelClaudeStructuredTurn(input: { } } +function prepareClaudePromptReply( + prompt: ClaudePendingPrompt, + response: AnswerInput['response'] +): PermissionResult { + try { + return buildClaudePromptReply(prompt, response) + } catch (error) { + throw new AgentSessionPromptAnswerRejectedError( + error instanceof Error ? error.message : String(error) + ) + } +} + export async function answerClaudeStructuredPrompt(input: { request: AnswerInput sessions: Map @@ -217,6 +259,7 @@ export async function answerClaudeStructuredPrompt(input: { throw new AgentSessionPromptUnavailableError(request.itemId) } try { + const reply = prepareClaudePromptReply(claim.found.prompt, request.response) await request.commit() if ( sessions.get(request.sessionId) !== session || @@ -226,7 +269,7 @@ export async function answerClaudeStructuredPrompt(input: { ) { throw new AgentSessionPromptUnavailableError(request.itemId) } - await answerClaudePrompt(session, claim, request.optionId) + await answerClaudePrompt(session, claim, reply) } catch (error) { session.prompts.releaseClaim(claim) throw error diff --git a/src/main/claude/claude-structured-prompt-replies.ts b/src/main/claude/claude-structured-prompt-replies.ts index 96bc83d0876..3bb99e3638a 100644 --- a/src/main/claude/claude-structured-prompt-replies.ts +++ b/src/main/claude/claude-structured-prompt-replies.ts @@ -1,5 +1,8 @@ import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk' -import { decodeAgentSessionQuestionAnswers } from '../../shared/agent-session-question-answer' +import type { + AgentSessionPromptResponse, + AgentSessionQuestionAnswer +} from '../../shared/agent-session-question-answer' import { claudePromptQuestions, isClaudePromptRecord, @@ -22,12 +25,6 @@ function isClaudeApprovalDecision(optionId: string): optionId is ClaudeApprovalD return CLAUDE_APPROVAL_DECISIONS.some((decision) => decision === optionId) } -function questionIdFromAddress(prompt: ClaudePendingPrompt, address: string): string | null { - const match = /^q([1-9]\d*)$/.exec(address) - const index = match ? Number(match[1]) - 1 : -1 - return index >= 0 ? (prompt.questionIds[index] ?? null) : null -} - function questionAnswer(prompt: ClaudePendingPrompt, questionId: string, optionId: string): string { const decoded = decodeClaudeQuestionOptionId(optionId) if (!decoded) { @@ -111,49 +108,8 @@ function approvalResponse(prompt: ClaudePendingPrompt, optionId: string): Permis function questionResponse( prompt: ClaudePendingPrompt, - optionId: string, - boundQuestionId?: string -): PermissionResult | null { - const decoded = decodeClaudeQuestionOptionId(optionId) - const decodedQuestionId = decoded - ? (questionIdFromAddress(prompt, decoded.questionId) ?? - (prompt.questionIds.includes(decoded.questionId) ? decoded.questionId : null)) - : null - const selectedQuestionId = - boundQuestionId ?? - decodedQuestionId ?? - (prompt.questionIds.length === 1 ? prompt.questionIds[0] : null) - if (!selectedQuestionId || !prompt.questionIds.includes(selectedQuestionId)) { - throw new Error(`${optionId} does not name a question on Claude prompt ${prompt.promptKey}`) - } - const answer = questionAnswer(prompt, selectedQuestionId, optionId) - prompt.answers.set(selectedQuestionId, answer) - if (prompt.questionIds.some((id) => !prompt.answers.has(id))) { - return null - } - const answers: Record = {} - for (const id of prompt.questionIds) { - const answer = prompt.answers.get(id) - if (answer === undefined) { - return null - } - answers[id] = answer - } - return { - behavior: 'allow', - updatedInput: { ...prompt.input, answers }, - toolUseID: prompt.toolUseId - } -} - -function groupedQuestionResponse( - prompt: ClaudePendingPrompt, - optionId: string -): PermissionResult | null { - const grouped = decodeAgentSessionQuestionAnswers(optionId) - if (!grouped) { - return null - } + grouped: readonly AgentSessionQuestionAnswer[] +): PermissionResult { const questions = claudePromptQuestions(prompt.input) if (grouped.length !== prompt.questionIds.length) { throw new Error(`Grouped answer does not match Claude prompt ${prompt.promptKey}`) @@ -191,15 +147,20 @@ function groupedQuestionResponse( } } -export function applyClaudePromptAnswer( - found: { prompt: ClaudePendingPrompt; questionId?: string }, - optionId: string -): PermissionResult | null { - if (found.prompt.kind === 'approval') { - return approvalResponse(found.prompt, optionId) +/** Builds Claude's reply without touching the prompt, so a reply that cannot be built refuses the + * answer before anything is recorded. */ +export function buildClaudePromptReply( + prompt: ClaudePendingPrompt, + response: AgentSessionPromptResponse +): PermissionResult { + if (prompt.kind === 'approval') { + if (response.kind !== 'option') { + throw new Error(`Claude prompt ${prompt.promptKey} takes a decision, not answers`) + } + return approvalResponse(prompt, response.optionId) } - return ( - groupedQuestionResponse(found.prompt, optionId) ?? - questionResponse(found.prompt, optionId, found.questionId) - ) + if (response.kind !== 'answers') { + throw new Error(`Claude prompt ${prompt.promptKey} takes answers, not a decision`) + } + return questionResponse(prompt, response.answers) } diff --git a/src/main/claude/claude-structured-provider-fallback.ts b/src/main/claude/claude-structured-provider-fallback.ts index baa6cb83a52..cd5441d7ec1 100644 --- a/src/main/claude/claude-structured-provider-fallback.ts +++ b/src/main/claude/claude-structured-provider-fallback.ts @@ -1,3 +1,4 @@ +import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { boundInlineText, @@ -15,7 +16,12 @@ import { type ClaudeMessageEnvelope } from './claude-structured-item-translation' import { claudeResultOutcome } from './claude-result-outcome' -import { rootClaudeRowStamp, type ClaudeRowStamp } from './claude-provisional-row-corrections' +import type { ClaudeRowStamp } from './claude-provisional-row-corrections' +import { + CLAUDE_API_RETRY_FRAME_KIND, + claudeApiRetryRowBody, + createClaudeApiRetryRuns +} from './claude-api-retry-row' export function claudeProviderFrameKind(message: Record): string { const type = claudeText(message.type) ?? 'unknown' @@ -104,7 +110,9 @@ export function isModeledClaudeContent(value: unknown): boolean { export function createClaudeProviderFrameFallback( sink: StructuredAgentSessionEventSink, - acquisitionId: string + acquisitionId: string, + /** The frame's turn — the open one once `beforeAppend` ran — or the conversation. */ + turnScope: () => AgentJournalTurnScope ): { /** `displayText` leads the row when Claude knows the sentence the frame itself does not name. */ append: ( @@ -121,9 +129,23 @@ export function createClaudeProviderFrameFallback( ) => boolean } { let sequence = 0 + const retryRun = createClaudeApiRetryRuns() return { append: (kind, payload, displayText, beforeAppend, options, stamp) => { sequence += 1 + const retrying = kind === CLAUDE_API_RETRY_FRAME_KIND ? claudeRecord(payload) : null + if (retrying) { + beforeAppend?.() + // One row per retry run, revised by each attempt, never the frame as a row. + const identity = { + provider: 'orca', + clientMessageId: `provider-retry:claude:${acquisitionId}:${retryRun(retrying)}` + } as const + const body = claudeApiRetryRowBody(retrying) + sink.appendItem(identity, body, stamp?.(identity, body) ?? { turnScope: turnScope() }) + sink.publish() + return true + } const translated = unhandledProviderFrameJournalItem( 'claude', kind, @@ -143,7 +165,7 @@ export function createClaudeProviderFrameFallback( clientMessageId: `provider-frame:claude:${acquisitionId}:${sequence}` } as const const body = bounded ? { ...translated.body, text: bounded } : translated.body - sink.appendItem(identity, body, (stamp ?? rootClaudeRowStamp)(identity, body)) + sink.appendItem(identity, body, stamp?.(identity, body) ?? { turnScope: turnScope() }) sink.publish() return true } diff --git a/src/main/claude/claude-structured-queued-stop.test.ts b/src/main/claude/claude-structured-queued-stop.test.ts new file mode 100644 index 00000000000..2dbc99bb82b --- /dev/null +++ b/src/main/claude/claude-structured-queued-stop.test.ts @@ -0,0 +1,205 @@ +// A send Claude queued behind the running turn is dropped by Stop, so it must settle as withdrawn. +// Orca's SessionStart hook proves most starts before the turn's system/init, which is the only +// frame that says whether the CLI can cancel its queue. + +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { DISPATCH_REJECTED_CANCELLED } from '../../shared/structured-agent-session-dispatch-rejection' +import type { ClaudeStructuredSessionAdapterDeps } from './claude-structured-session-state' +import { CLAUDE_DISPATCH_ADMISSION_TIMEOUT_MS } from './claude-structured-prompt-ownership' +import { + PROVIDER_SESSION_ID, + USER_MESSAGE, + adapterFor, + fakeClaude, + identityFor, + type FakeConnection +} from './claude-structured-session-test-support' + +// As Claude Code 2.1.280 advertises them on a turn's system/init frame. +const CAPABILITIES = ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1', 'msg_lifecycle_v1'] + +type Settlement = Parameters< + NonNullable +>[0] + +/** Answers the way the real CLI does: cancel_queued cancels the queue, a plain interrupt keeps it. */ +function claudeCli(options: Parameters[0] = {}) { + let queued: string[] = [] + const claude = fakeClaude({ + replayUuid: null, + ...options, + routes: { + interrupt: (params) => + params?.cancelQueued + ? { still_queued: [], cancelled: queued.splice(0) } + : { still_queued: [...queued] }, + cancel_async_message: (params) => { + const before = queued.length + queued = queued.filter((uuid) => uuid !== params?.uuid) + return queued.length < before + } + } + }) + return { claude, queue: (uuid: string) => queued.push(uuid) } +} + +function turnInit(capabilities?: string[]): Record { + return { + type: 'system', + subtype: 'init', + session_id: PROVIDER_SESSION_ID, + uuid: 'turn-init', + model: 'claude-sonnet-5', + ...(capabilities ? { capabilities } : {}) + } +} + +async function acquired( + claude: ReturnType, + settlements: Settlement[] = [] +): Promise<{ + adapter: ReturnType + bodies: Map + connection: FakeConnection +}> { + const bodies = new Map() + const adapter = adapterFor(claude, {}, [], [], undefined, undefined, undefined, (settlement) => + settlements.push(settlement) + ) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: { + appendItem: (identity, body) => bodies.set(agentJournalItemKey(identity), body), + appendTombstone: (identity) => bodies.delete(agentJournalItemKey(identity)), + publish: vi.fn() + } + }) + return { adapter, bodies, connection: claude.connections[0]! } +} + +function runningTurnId(bodies: Map): string { + for (const body of bodies.values()) { + const turn = readAgentJournalTurn(body) + if (turn?.state === 'running') { + return turn.turnId + } + } + throw new Error('expected a running turn') +} + +/** Sends A and opens its turn, queues B behind it, then Stops A as the host does. */ +async function stopWithQueuedFollowUp( + cli: ReturnType, + openTurn: (connection: FakeConnection) => void +): Promise<{ interrupt: unknown; settlements: Settlement[] }> { + const settlements: Settlement[] = [] + const { adapter, bodies, connection } = await acquired(cli.claude, settlements) + const send = (clientMessageId: string) => + adapter.dispatch({ sessionId: 'session-1', clientMessageId, body: USER_MESSAGE, fence: 7 }) + await send('client-a') + openTurn(connection) + // Claude adopts the client uuid for the echo that opens the turn. + connection.handlers.onMessage?.({ + ...connection.sent.at(-1)!, + uuid: connection.sent.at(-1)!.uuid + }) + await send('client-b') + cli.queue(String(connection.sent.at(-1)!.uuid)) + + vi.useFakeTimers() + try { + const stopped = adapter.cancelTurn({ + sessionId: 'session-1', + turnId: runningTurnId(bodies), + fence: 7, + // The host reads B's handover from the journal, and it is still pending. + dispatchStatus: { state: 'pending', recovered: false } + }) + await vi.advanceTimersByTimeAsync(CLAUDE_DISPATCH_ADMISSION_TIMEOUT_MS) + await expect(stopped).resolves.toEqual({ cancelled: true }) + } finally { + vi.useRealTimers() + } + return { + interrupt: connection.calls.find((call) => call.subtype === 'interrupt')?.params, + settlements: settlements.filter((settlement) => settlement.clientMessageId === 'client-b') + } +} + +const WITHDRAWN = [ + { + sessionId: 'session-1', + clientMessageId: 'client-b', + state: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED, + rejection: { kind: 'cancelled' } + } +] + +describe('Stop with a follow-up Claude queued behind the running turn', () => { + it('withdraws it the same way whether system/init or a SessionStart hook proved the start', async () => { + const byInit = await stopWithQueuedFollowUp( + claudeCli({ initProof: 'init', capabilities: CAPABILITIES }), + () => {} + ) + const bySessionStart = await stopWithQueuedFollowUp( + claudeCli({ initProof: 'session-start' }), + (connection) => connection.handlers.onMessage?.(turnInit(CAPABILITIES)) + ) + + expect(byInit).toEqual({ interrupt: { cancelQueued: true }, settlements: WITHDRAWN }) + expect(bySessionStart).toEqual(byInit) + }) + + it('keeps what a turn advertised when a later frame names nothing', async () => { + const result = await stopWithQueuedFollowUp( + claudeCli({ initProof: 'session-start' }), + (connection) => { + connection.handlers.onMessage?.(turnInit(CAPABILITIES)) + connection.handlers.onMessage?.(turnInit()) + connection.handlers.onMessage?.({ + type: 'system', + subtype: 'hook_response', + hook_name: 'SessionStart:resume', + session_id: PROVIDER_SESSION_ID + }) + } + ) + + expect(result).toEqual({ interrupt: { cancelQueued: true }, settlements: WITHDRAWN }) + }) + + it('keeps what a turn advertised when the start is read after it', async () => { + const cli = claudeCli({ initProof: 'session-start' }) + const open = cli.claude.openConnection + // The turn's init lands while startup still waits on get_settings, as a send that starts the + // agent allows. + cli.claude.openConnection = async (launch, handlers) => { + const connection = await open(launch, handlers) + const getSettings = connection.getSettings + connection.getSettings = async (options) => { + handlers?.onMessage?.(turnInit(CAPABILITIES)) + return getSettings(options) + } + return connection + } + + const result = await stopWithQueuedFollowUp(cli, () => {}) + + expect(result).toEqual({ interrupt: { cancelQueued: true }, settlements: WITHDRAWN }) + }) + + it('settles a follow-up withdrawn one at a time on a CLI without cancel_queued', async () => { + const result = await stopWithQueuedFollowUp( + claudeCli({ initProof: 'init', capabilities: ['interrupt_receipt_v1'] }), + () => {} + ) + + expect(result).toEqual({ interrupt: {}, settlements: WITHDRAWN }) + }) +}) diff --git a/src/main/claude/claude-structured-rate-limit-retry.test.ts b/src/main/claude/claude-structured-rate-limit-retry.test.ts new file mode 100644 index 00000000000..d7b0d5277fc --- /dev/null +++ b/src/main/claude/claude-structured-rate-limit-retry.test.ts @@ -0,0 +1,50 @@ +// Claude retrying a rate-limited request: the frames below follow Claude Code 2.1.280 against an +// HTTP 429 stub, captured with `--replay-user-messages --include-partial-messages`. The CLI writes +// only `api_retry` frames, and echoes the message only once a request gets through or is +// interrupted, so no turn opens: the send itself is all that says the session is working. + +import { describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +function apiRetry(attempt: number, observedAt: number) { + return { + type: 'message' as const, + sessionId: 'orca-session', + observedAt, + message: { + type: 'system', + subtype: 'api_retry', + attempt, + max_retries: 10, + retry_delay_ms: 1_000 * 2 ** (attempt - 1), + error_status: 429, + error: 'rate_limit', + uuid: `retry-${attempt}`, + session_id: 'claude-session' + } + } +} + +describe('Claude retrying a rate-limited request', () => { + it('opens no turn while it retries', () => { + const appended: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body) => appended.push({ identity, body }), + appendTombstone: vi.fn(), + publish: vi.fn() + } + const translator = createClaudeJournalTranslator({ sink }) + + for (let attempt = 1; attempt <= 6; attempt += 1) { + translator.handle(apiRetry(attempt, 1_000 * attempt)) + } + + expect(appended.filter(({ body }) => readAgentJournalTurn(body) !== null)).toEqual([]) + }) +}) diff --git a/src/main/claude/claude-structured-real-cli-fold.test.ts b/src/main/claude/claude-structured-real-cli-fold.test.ts new file mode 100644 index 00000000000..5ce21d83f17 --- /dev/null +++ b/src/main/claude/claude-structured-real-cli-fold.test.ts @@ -0,0 +1,194 @@ +// The fold receipt against the real CLI: a message sent while a turn runs is +// folded by the CLI into the running turn, and Orca must keep ONE turn row — +// no interrupted marking, no second bar. Runs only where a signed-in Claude CLI +// exists, like the rest of the real-CLI suite. Live sessions prove the session +// from a SessionStart hook frame BEFORE system/init arrives, which is exactly +// the path the fixture harness cannot fake end to end. + +import { randomUUID } from 'node:crypto' +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution' +import { + ClaudeStructuredSessionAdapter, + type ClaudeStructuredSessionEvent +} from './claude-structured-session-adapter' +import { + realClaudeAuthenticated, + realClaudeAvailable, + realClaudeCommand, + realClaudeLaunchHome +} from './claude-real-cli-availability-test-support' + +const SESSION_ID = 'real-cli-fold' +// Pins the live proof order (SessionStart hook frame before system/init) and lets the +// Bash steps run unprompted, whatever the config dir under test configures. +const FOLD_SESSION_SETTINGS = JSON.stringify({ + hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'echo' }] }] }, + permissions: { allow: ['Bash(sleep:*)'] } +}) + +function identity(providerSessionId: string): AgentSessionJournalIdentity { + return { + sessionId: SESSION_ID, + workspaceId: 'real-cli-fold-workspace', + hostId: 'local', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: providerSessionId, leafUuid: null } + } +} + +async function until(check: () => boolean, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (check()) { + return true + } + await new Promise((resolve) => setTimeout(resolve, 200)) + } + return check() +} + +describe.skipIf(!realClaudeAvailable)('Claude structured real CLI fold', () => { + it.skipIf(!realClaudeAuthenticated)( + 'keeps one turn row when the CLI folds a mid-turn send', + async () => { + const providerSessionId = randomUUID() + const { claudeConfigDir, env } = realClaudeLaunchHome() + const cwd = await mkdtemp(join(tmpdir(), 'orca-real-fold-')) + const events: ClaudeStructuredSessionEvent[] = [] + const turnRows: NonNullable>[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (_identity, body: AgentJournalItemBody) => { + const turn = readAgentJournalTurn(body) + if (turn) { + turnRows.push(turn) + } + }, + appendTombstone: () => {}, + publish: () => {} + } + const settled = vi.fn() + const adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: realClaudeCommand, + options: { + ...CLAUDE_STRUCTURED_BASE_OPTIONS, + extraArgs: { + ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, + settings: FOLD_SESSION_SETTINGS + }, + sessionId: providerSessionId + }, + cwd, + env, + claudeConfigDir, + providerSessionId, + resumeLeafUuid: null, + resumesTranscript: false, + continuesChain: false + }), + onEvent: (event) => events.push(event), + onDispatchSettledLate: settled, + readProcessStartTime: async () => 1 + }) + try { + await adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'real-cli-fold', + events: sink + }) + await adapter.awaitStarted(SESSION_ID) + // Startup proved from the SessionStart hook frame, with no init yet. + expect( + events.some( + (event) => + event.type === 'message' && + event.message.type === 'system' && + event.message.subtype === 'init' + ) + ).toBe(false) + + await expect( + adapter.dispatch({ + sessionId: SESSION_ID, + clientMessageId: 'client-A', + body: { + kind: 'message', + role: 'user', + blocks: [ + { + type: 'text', + text: 'Use the Bash tool to run `sleep 8` two separate times, one call at a time, waiting for each. Then reply exactly: FIRST DONE' + } + ] + }, + requestedAt: Date.now(), + fence: 1 + }) + ).resolves.toEqual({ state: 'admitted' }) + + // Wait for A's replay to open the turn, then send B mid-turn: the first + // `sleep 8` guarantees the CLI is still inside A's request cycle. + expect( + await until( + () => events.some((event) => event.type === 'message' && event.startsTurn === true), + 30_000 + ) + ).toBe(true) + await new Promise((resolve) => setTimeout(resolve, 2_000)) + await expect( + adapter.dispatch({ + sessionId: SESSION_ID, + clientMessageId: 'client-B', + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Also say the word banana at the end of your reply.' }] + }, + requestedAt: Date.now(), + fence: 1 + }) + ).resolves.toEqual({ state: 'admitted' }) + + const results = () => + events.flatMap((event) => + event.type === 'message' && event.message.type === 'result' ? [event.message] : [] + ) + expect(await until(() => results().length > 0, 90_000)).toBe(true) + + // The CLI folded: one result names both sends. (If this ever reports a + // lone send, the steer raced past the fold window — a miss, not a fold.) + const named = results().flatMap((message) => + Array.isArray(message.user_message_uuids) ? [message.user_message_uuids] : [] + ) + expect(named[0]).toHaveLength(2) + + // ONE turn for the whole run: never interrupted, and B settled accepted + // into it under its own adopted uuid. + expect(turnRows.every((turn) => turn.state !== 'interrupted')).toBe(true) + expect([...new Set(turnRows.map((turn) => turn.turnId))]).toHaveLength(1) + expect(turnRows.at(-1)).toMatchObject({ state: 'completed' }) + expect(settled).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: SESSION_ID, + clientMessageId: 'client-B', + providerIdentity: expect.objectContaining({ provider: 'claude' }) + }) + ) + } finally { + await adapter.closeAll() + } + }, + 150_000 + ) +}) diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts index 4181dab43c7..73bec7986c8 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -1,55 +1,31 @@ -import { spawnSync } from 'node:child_process' import { randomUUID } from 'node:crypto' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { basename, join, relative } from 'node:path' import { describe, expect, it } from 'vitest' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' -import { resolveClaudeCommand } from '../codex-cli/command' import { resolveSessionFilePath } from '../native-chat/session-file-resolver' -import { getSpawnArgsForWindows } from '../win32-utils' import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution' +import { + realClaudeAuthenticated, + realClaudeAuthStatus, + realClaudeAvailable, + realClaudeCommand +} from './claude-real-cli-availability-test-support' import { ClaudeStructuredSessionAdapter, type ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' +import type { ClaudeStructuredSessionAdapterDeps } from './claude-structured-session-state' -const command = resolveClaudeCommand() -const versionLaunch = getSpawnArgsForWindows(command, ['--version']) -const realClaudeAvailable = - spawnSync(versionLaunch.spawnCmd, versionLaunch.spawnArgs, { - stdio: 'ignore', - windowsHide: true, - timeout: 5_000 - }).status === 0 -const authStatusLaunch = getSpawnArgsForWindows(command, ['auth', 'status', '--json']) -/** The CLI's own account report — the only source of truth for where it writes that - * is not derived from Orca's own path expressions. */ -const realClaudeAuthStatus = (() => { - if (!realClaudeAvailable) { - return null - } - const result = spawnSync(authStatusLaunch.spawnCmd, authStatusLaunch.spawnArgs, { - encoding: 'utf8', - windowsHide: true, - timeout: 5_000 - }) - if (result.status !== 0) { - return null - } - try { - return JSON.parse(result.stdout) as { loggedIn?: boolean; projectsDirectory?: string } - } catch { - return null - } -})() -const realClaudeAuthenticated = realClaudeAuthStatus?.loggedIn === true +const command = realClaudeCommand function realAdapter( providerSessionId: string, claudeConfigDir: string, events: ClaudeStructuredSessionEvent[] = [], - cwd = process.cwd() + cwd = process.cwd(), + onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate'] ): ClaudeStructuredSessionAdapter { const adapter = new ClaudeStructuredSessionAdapter({ resolveLaunch: async () => ({ @@ -63,6 +39,7 @@ function realAdapter( continuesChain: false }), onEvent: (event) => events.push(event), + ...(onDispatchSettledLate ? { onDispatchSettledLate } : {}), readProcessStartTime: async () => 1, now: () => 2 }) @@ -70,7 +47,7 @@ function realAdapter( const acquire = adapter.acquire adapter.acquire = async (input) => { const acquisition = await acquire(input) - await adapter.drainStartup(input.identity.sessionId) + await adapter.awaitStarted(input.identity.sessionId) return acquisition } return adapter @@ -302,6 +279,161 @@ describe.skipIf(!realClaudeAvailable)('Claude structured real CLI handshake', () 90_000 ) + // The window a Stop naming no turn exists for: Orca has written the message, and Claude has not + // started its reply, so no turn id exists. Only the live binary can say the interrupt lands there. + it.skipIf(!realClaudeAuthenticated)( + 'stops a message interrupted before its reply starts, and takes the next one', + async () => { + const providerSessionId = randomUUID() + const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = realAdapter(providerSessionId, claudeConfigDir, events) + const frames = (from: number) => + events.slice(from).flatMap((event) => (event.type === 'message' ? [event.message] : [])) + const replyStarted = (from: number) => + frames(from).some( + (frame) => + frame.type === 'stream_event' && + typeof frame.event === 'object' && + frame.event !== null && + 'type' in frame.event && + frame.event.type === 'message_start' + ) + const result = async (from: number) => { + const deadline = Date.now() + 60_000 + for (;;) { + const found = frames(from).find((frame) => frame.type === 'result') + if (found || Date.now() >= deadline) { + return found + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + } + const send = (clientMessageId: string, text: string) => + adapter.dispatch({ + sessionId: 'real-cli-handshake', + clientMessageId, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }, + fence: 1 + }) + + try { + await adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'real-cli-stop' + }) + const first = events.length + await expect( + send('real-cli-stop-1', 'Count from 1 to 300, one number per line, and nothing else.') + ).resolves.toEqual({ state: 'admitted' }) + const startedBeforeStop = replyStarted(first) + + await expect( + adapter.cancelTurn({ sessionId: 'real-cli-handshake', fence: 1 }) + ).resolves.toEqual({ + cancelled: true + }) + const stopped = await result(first) + + expect(startedBeforeStop).toBe(false) + // Claude 2.1.280 echoes the message, then ends that turn as interrupted without replying. + expect(stopped).toMatchObject({ subtype: 'error_during_execution' }) + expect(replyStarted(first)).toBe(false) + + const second = events.length + await expect(send('real-cli-stop-2', 'Reply with the single word ok.')).resolves.toEqual({ + state: 'admitted' + }) + await expect(result(second)).resolves.toMatchObject({ subtype: 'success' }) + } finally { + await adapter.closeAll() + } + }, + 150_000 + ) + + // Orca installs a SessionStart hook, so its frame proves most real starts before the turn's + // system/init, the only frame that says this CLI can cancel what it queued. + it.skipIf(!realClaudeAuthenticated)( + 'withdraws a follow-up queued behind a turn that is stopped, behind a SessionStart hook', + async () => { + const providerSessionId = randomUUID() + const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') + const cwd = await mkdtemp(join(tmpdir(), 'orca-queued-stop-')) + await mkdir(join(cwd, '.claude'), { recursive: true }) + await writeFile( + join(cwd, '.claude', 'settings.json'), + JSON.stringify({ + hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'true' }] }] } + }) + ) + const events: ClaudeStructuredSessionEvent[] = [] + const settlements: unknown[] = [] + const adapter = realAdapter(providerSessionId, claudeConfigDir, events, cwd, (settlement) => + settlements.push(settlement) + ) + const send = (clientMessageId: string, text: string) => + adapter.dispatch({ + sessionId: 'real-cli-handshake', + clientMessageId, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }, + fence: 1 + }) + const waitFor = async (found: () => boolean): Promise => { + const deadline = Date.now() + 60_000 + while (!found() && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 100)) + } + return found() + } + + try { + await adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'real-cli-queued-stop' + }) + await send('real-cli-queued-stop-a', 'Count from 1 to 400, one number per line.') + // A's reply is streaming, so the next send queues behind its turn. + await expect( + waitFor(() => + events.some( + (event) => + event.type === 'message' && + event.message.type === 'stream_event' && + JSON.stringify(event.message).includes('text_delta') + ) + ) + ).resolves.toBe(true) + await send('real-cli-queued-stop-b', 'Say the word banana.') + + await expect( + adapter.cancelTurn({ + sessionId: 'real-cli-handshake', + turnId: 'turn-a', + fence: 1, + resolveLiveTurnId: () => 'turn-a', + // What the host reads for B: handed over, not yet answered. + dispatchStatus: { state: 'pending', recovered: false } + }) + ).resolves.toEqual({ cancelled: true }) + + expect(settlements).toContainEqual({ + sessionId: 'real-cli-handshake', + clientMessageId: 'real-cli-queued-stop-b', + state: 'rejected', + reason: 'provider_cancelled_before_start', + rejection: { kind: 'cancelled' } + }) + } finally { + await adapter.closeAll() + await rm(cwd, { recursive: true, force: true }) + } + }, + 150_000 + ) + it('turns a real silent unauthenticated startup into sign-in guidance', async () => { const claudeConfigDir = await mkdtemp(join(tmpdir(), 'orca-claude-no-auth-')) const providerSessionId = randomUUID() diff --git a/src/main/claude/claude-structured-requested-stop.test.ts b/src/main/claude/claude-structured-requested-stop.test.ts new file mode 100644 index 00000000000..993109f4a37 --- /dev/null +++ b/src/main/claude/claude-structured-requested-stop.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import { + PROVIDER_SESSION_ID, + USER_MESSAGE, + adapterFor, + fakeClaude, + identityFor +} from './claude-structured-session-test-support' + +function journalSink() { + const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body) => items.push({ identity, body }), + appendTombstone: () => {}, + publish: () => {} + } + return { sink, items } +} + +function frame(type: 'assistant' | 'user', uuid: string, content: unknown[]) { + return { + type, + uuid, + session_id: PROVIDER_SESSION_ID, + parent_tool_use_id: null, + message: { role: type, content } + } +} + +describe('a requested stop of a structured Claude chat', () => { + it('reads interrupted, not failed or crashed, through the frames the stop makes Claude emit', async () => { + const claude = fakeClaude({ replayUuid: 'turn-1' }) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) + const journal = journalSink() + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: journal.sink + }) + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + const connection = claude.connections[0]! + connection.handlers.onMessage?.( + frame('assistant', 'assistant-1', [ + { type: 'tool_use', id: 'tool-1', name: 'Bash', input: { command: 'sleep 120' } } + ]) + ) + // What Claude 2.1.283 emitted within 35 ms of the supervised SIGTERM mid-tool, recorded + // against the real CLI: the killed tool's result, a status frame, and no `result` frame. + connection.close = async () => { + connection.handlers.onMessage?.( + frame('user', 'user-2', [ + { type: 'tool_result', tool_use_id: 'tool-1', is_error: true, content: 'Exit code 137' } + ]) + ) + connection.handlers.onMessage?.({ + type: 'system', + subtype: 'status', + uuid: 'status-1', + session_id: PROVIDER_SESSION_ID + }) + connection.closed = true + return true + } + + await expect(adapter.closeSession('session-1')).resolves.toBe(true) + + const turns = journal.items.flatMap((item) => { + const turn = readAgentJournalTurn(item.body) + return turn ? [turn] : [] + }) + expect(turns.at(-1)).toMatchObject({ turnId: 'turn-1', state: 'interrupted' }) + const ended = events.filter((event) => event.type === 'ended') + expect(ended).toEqual([expect.objectContaining({ reason: 'claude session closed' })]) + expect(ended[0]).not.toHaveProperty('cause') + }) +}) diff --git a/src/main/claude/claude-structured-session-acquisition-processless.test.ts b/src/main/claude/claude-structured-session-acquisition-processless.test.ts index 85de77bbbd8..eb4babdb9ac 100644 --- a/src/main/claude/claude-structured-session-acquisition-processless.test.ts +++ b/src/main/claude/claude-structured-session-acquisition-processless.test.ts @@ -37,7 +37,7 @@ describe('Claude structured processless acquisition', () => { getContextUsage: async () => ({}), supportedModels: async () => [], interrupt: async () => undefined, - cancelAsyncMessage: async () => {}, + cancelAsyncMessage: async () => false, setModel: async () => {}, setPermissionMode: async () => {}, applyFlagSettings: async () => {}, diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index b2efed691ee..b4adbfccfa5 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -7,8 +7,13 @@ import { CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE } from '../claude-accounts/envir import { isClaudeAuthSwitchInProgress } from '../claude-accounts/live-pty-gate' import { openClaudeStreamJsonConnection } from './claude-stream-json-connection' import { buildClaudePermissionCallbacks } from './claude-structured-inbound-control' -import { resolveClaudeReplayTurn } from './claude-structured-dispatch' -import { readClaudeFrameString, readClaudeInit } from './claude-structured-init-proof' +import { resolveClaudeReplayTurn } from './claude-replay-turn-resolution' +import { claudeSessionStateEndsTurn } from './claude-session-state-turn-over' +import { + readClaudeCapabilities, + readClaudeFrameString, + readClaudeInit +} from './claude-structured-init-proof' import { claudeConfigDirEnvPatch } from './claude-config-dir-pin' import { CLAUDE_SPAWN_TOKEN_ENV, claudeProcessIdentity } from './claude-structured-owner-identity' import { ClaudePromptRegistry } from './claude-structured-prompt-replies' @@ -30,6 +35,7 @@ import { type ClaudeAcquireCallbacks } from './claude-structured-session-state' import { resolveClaudeAcquisitionError } from './claude-structured-session-close' +import { withObservedProviderExit } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import { readClaudeTranscriptEntryUuid } from './claude-transcript-entry-uuid' import { persistClaudeTurnResumePoint } from './claude-structured-resume-point' import { withAgentSessionCreatePhase } from '../observability/agent-session-instrumentation' @@ -58,7 +64,9 @@ export async function acquireClaudeSession({ // A managed-account switch is mid-swap of the pinned credential home; refuse here, // before this acquisition cancels the previous attempt and closes the live session. if (isClaudeAuthSwitchInProgress()) { - throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE)) + throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE), { + reason: 'accountSwitchInProgress' + }) } const sessionId = input.identity.sessionId const prompts = new ClaudePromptRegistry() @@ -98,6 +106,9 @@ export async function acquireClaudeSession({ liveSession.reportedOptions.model = init.model liveSession.reportedModelMutation = liveSession.optionMutationSequence } + if (liveSession) { + liveSession.capabilities = readClaudeCapabilities(liveSession.capabilities, init.message) + } } observedLeafUuid = readClaudeTranscriptEntryUuid(message) ?? observedLeafUuid if (liveSession) { @@ -107,10 +118,17 @@ export async function acquireClaudeSession({ if (message.type === 'result' && sessions.get(sessionId) === liveSession) { persistClaudeTurnResumePoint(sessionId, liveSession, deps) } + // The CLI idles only once its queue drains, so it holds none of this session's sends. + if (claudeSessionStateEndsTurn(message) && sessions.get(sessionId) === liveSession) { + deps.onSessionIdle?.({ sessionId }) + } } + // Settled after the turn this echo opens is emitted: a send read as answered before its turn + // lands reads as nothing running, and Stop and Working blink off in between. + const settlements: (() => void)[] = [] const turnOrigin = liveSession ? resolveClaudeReplayTurn(liveSession, message, (settlement) => - deps.onDispatchSettledLate?.({ sessionId, ...settlement }) + settlements.push(() => deps.onDispatchSettledLate?.({ sessionId, ...settlement })) ) : null const startsTurn = turnOrigin !== null @@ -128,6 +146,9 @@ export async function acquireClaudeSession({ ...observedAt }) ) + for (const settle of settlements) { + settle() + } } const { canUseTool, onUserDialog } = buildClaudePermissionCallbacks({ sessionId, @@ -178,6 +199,8 @@ export async function acquireClaudeSession({ initProof.reject(error) }, onExit: (error) => { + // The child exited on its own; marked in place, as the fault report may hold this error. + withObservedProviderExit(error) childEnded ??= error initProof.reject(error) callbacks.handleExit(sessionId, attempt, error) @@ -248,30 +271,34 @@ export async function acquireClaudeSession({ event() } }) - session.startup.settled = settleClaudeSessionStartup({ - session, - facts: readClaudeStartupFacts({ - connection, - initProof, - sessionId, - providerSessionId: launch.providerSessionId, - resumesTranscript: launch.resumesTranscript, - inputOptions: input.options, - requestTimeoutMs: deps.requestTimeoutMs, - emit - }), - isCurrent: () => sessions.get(sessionId) === session, - requestTimeoutMs: deps.requestTimeoutMs, - fault: (error) => callbacks.handleExit(sessionId, attempt, error), - onStarted: (options) => - emit({ - type: 'started', + // Whichever comes first: the start landing or faulting, or the child being ended. + session.startup.settled = Promise.race([ + session.startup.settled, + settleClaudeSessionStartup({ + session, + facts: readClaudeStartupFacts({ + connection, + initProof, sessionId, - fence: input.fence, - acquisitionGeneration: session.acquisitionGeneration, - ...options - }) - }) + providerSessionId: launch.providerSessionId, + resumesTranscript: launch.resumesTranscript, + inputOptions: input.options, + requestTimeoutMs: deps.requestTimeoutMs, + emit + }), + isCurrent: () => sessions.get(sessionId) === session, + requestTimeoutMs: deps.requestTimeoutMs, + fault: (error) => callbacks.handleExit(sessionId, attempt, error), + onStarted: (options) => + emit({ + type: 'started', + sessionId, + fence: input.fence, + acquisitionGeneration: session.acquisitionGeneration, + ...options + }) + }) + ]) // A child whose exit already reached `handleExit` is not handed over as live: the create // fails with the CLI's own diagnostic, as one that died before publish does. if (sessions.get(sessionId) !== session) { diff --git a/src/main/claude/claude-structured-session-adapter-turns.test.ts b/src/main/claude/claude-structured-session-adapter-turns.test.ts index caafee6a94b..d410861464a 100644 --- a/src/main/claude/claude-structured-session-adapter-turns.test.ts +++ b/src/main/claude/claude-structured-session-adapter-turns.test.ts @@ -68,7 +68,11 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => { body: USER_MESSAGE, fence: 7 }) - ).resolves.toEqual({ state: 'rejected', reason: 'provider_write_failed: broken pipe' }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'provider_write_failed', + rejection: { kind: 'writeFailed' } + }) }) it('requires an acknowledged interrupt and supports controlled options', async () => { @@ -219,6 +223,13 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => { } }) const adapter = await acquired(claude) + // The model is reported by a cycle's init frame, so start one. + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'seed-cycle', + body: USER_MESSAGE, + fence: 7 + }) await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ models: [ @@ -247,6 +258,13 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => { } }) const adapter = await acquired(claude) + // The custom model only reports on the first cycle's init frame. + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'seed-cycle', + body: USER_MESSAGE, + fence: 7 + }) const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) expect(result.models.map((model) => model.id)).toEqual([ diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index a879878436a..ee14b6b525a 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -3,12 +3,12 @@ import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { AgentSessionAcquisitionExitUnprovenError, - AgentSessionAcquisitionRootExitObservedError + AgentSessionAcquisitionRootExitObservedError, + AgentSessionPromptAnswerRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' import { ClaudeControlRequestError } from './claude-stream-json-connection' import { CLAUDE_SPAWN_TOKEN_ENV } from './claude-structured-owner-identity' -import { encodeClaudeQuestionOptionId } from './claude-structured-prompt-replies' import type { ClaudeStructuredSessionAdapter, ClaudeStructuredSessionEvent @@ -18,12 +18,12 @@ import { adapterFor, fakeClaude, identityFor, - invokeCanUseTool, PROVIDER_SESSION_ID, tick, USER_MESSAGE, type FakeConnection } from './claude-structured-session-test-support' +import { invokeCanUseTool } from './claude-can-use-tool-test-support' describe('ClaudeStructuredSessionAdapter.acquire', () => { it('pins the account and proves init without treating the system-frame uuid as a chain leaf', async () => { @@ -62,7 +62,8 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { mintedAtFence: 7, observedAt: 1_700_000_000_500 }) - expect(events[0]).toMatchObject({ type: 'message', message: { subtype: 'init' } }) + // Live proof order: the SessionStart hook frame arrives before any init. + expect(events[0]).toMatchObject({ type: 'message', message: { subtype: 'hook_started' } }) }) it('restores persisted model and effort before publishing a reacquired session', async () => { @@ -344,7 +345,10 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { session_id: 'foreign-provider-session' }) await Promise.resolve() - expect(events.filter((event) => event.type === 'message')).toHaveLength(1) + // Startup hook proof + the first cycle's init are admitted; nothing foreign is. + expect( + events.flatMap((event) => (event.type === 'message' ? [event.message.subtype] : [])) + ).toEqual(['hook_started', 'hook_response', 'init']) expect(settled).not.toHaveBeenCalled() connection.handlers.onMessage?.({ @@ -556,21 +560,18 @@ describe('ClaudeStructuredSessionAdapter acquisition cleanup', () => { .catch((error: unknown) => error) } - it('releases on a first-hand root exit while still carrying the CLI diagnostic', async () => { - // The root's pid and start time are the lease's identity, and they are - // provably dead: latching the session would strand a signed-out user. - const error = await failedStart({ root: 'exited', tree: 'unverifiable' }) + // The root's pid and start time are the lease's identity, and they are provably dead: latching + // the session would strand a signed-out user. The lease follows the root, so a descendant seen + // alive does not hold the session either. + it.each(['unverifiable', 'live'] as const)( + 'releases on a first-hand root exit with its tree %s while still carrying the CLI diagnostic', + async (tree) => { + const error = await failedStart({ root: 'exited', tree }) - expect(error).toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) - expect((error as Error).message).toBe('claude stream-json exited (code 1): not logged in') - }) - - it('never releases while a descendant was observed alive', async () => { - const error = await failedStart({ root: 'exited', tree: 'live' }) - - expect(error).toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) - expect(error).not.toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) - }) + expect(error).toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) + expect((error as Error).message).toBe('claude stream-json exited (code 1): not logged in') + } + ) it('never releases for a root Orca never saw leave', async () => { const error = await failedStart({ root: 'live', tree: 'unverifiable' }) @@ -590,27 +591,19 @@ describe('ClaudeStructuredSessionAdapter acquisition cleanup', () => { return { adapter, connection } } - it('classifies cleanup after a first-hand exit removed the session as a root exit, never as proven', async () => { - // The host may still be committing or proving the lease when the child dies; - // its cleanup must find the exit the ladder observed, not an absence. - const { adapter, connection } = await exitedAfterPublish({ - root: 'exited', - tree: 'unverifiable' - }) - const error = await adapter.releaseAcquisition({ sessionId: 'session-1' }).catch((e) => e) + // The host may still be committing or proving the lease when the child dies; its cleanup must + // find the exit the ladder observed, not an absence. + it.each(['unverifiable', 'live'] as const)( + 'classifies cleanup after a first-hand exit with its tree %s as a root exit, never as proven', + async (tree) => { + const { adapter, connection } = await exitedAfterPublish({ root: 'exited', tree }) + const error = await adapter.releaseAcquisition({ sessionId: 'session-1' }).catch((e) => e) - expect(error).toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) - expect((error as Error).message).toBe('claude stream-json exited (code 1): crashed') - expect(connection.closeCount).toBe(2) - }) - - it('never releases after an exit that left a descendant observed alive', async () => { - const { adapter } = await exitedAfterPublish({ root: 'exited', tree: 'live' }) - const error = await adapter.releaseAcquisition({ sessionId: 'session-1' }).catch((e) => e) - - expect(error).toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) - expect(error).not.toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) - }) + expect(error).toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) + expect((error as Error).message).toBe('claude stream-json exited (code 1): crashed') + expect(connection.closeCount).toBe(2) + } + ) it('forgets a retained exit once the session is acquired again', async () => { const options: Parameters[0] = {} @@ -780,7 +773,7 @@ describe('ClaudeStructuredSessionAdapter prompts', () => { sessionId: 'session-1', itemId: 'journal-approval', kind: 'approval', - optionId: 'allowForSession', + response: { kind: 'option', optionId: 'allowForSession' }, fence: 7, commit: async () => undefined }) @@ -793,7 +786,7 @@ describe('ClaudeStructuredSessionAdapter prompts', () => { }) }) - it('collects every AskUserQuestion card before settling the one callback', async () => { + it('settles the one AskUserQuestion callback from structured answers, including a long typed answer', async () => { const claude = fakeClaude() const adapter = await acquired(claude) const answered = invokeCanUseTool( @@ -810,34 +803,68 @@ describe('ClaudeStructuredSessionAdapter prompts', () => { } } ) - adapter.bindPromptItemId('session-1', 'journal-q1', 'question-1', 'Library?') - adapter.bindPromptItemId('session-1', 'journal-q2', 'question-1', 'Ship now?') + adapter.bindPromptItemId('session-1', 'journal-question', 'question-1') + const typed = 'Wait for the capture to finish first. '.repeat(60) await adapter.answerPrompt({ sessionId: 'session-1', - itemId: 'journal-q1', + itemId: 'journal-question', kind: 'question', - optionId: encodeClaudeQuestionOptionId('Library?', 'Luxon'), - fence: 7, - commit: async () => undefined - }) - await tick() - expect(answered.settled()).toBe(false) - await adapter.answerPrompt({ - sessionId: 'session-1', - itemId: 'journal-q2', - kind: 'question', - optionId: encodeClaudeQuestionOptionId('Ship now?', 'Yes'), + response: { + kind: 'answers', + answers: [ + { questionId: 'q1', optionIds: ['q1:choice-1'] }, + { questionId: 'q2', optionIds: [], other: typed } + ] + }, fence: 7, commit: async () => undefined }) await expect(answered.promise).resolves.toMatchObject({ behavior: 'allow', - updatedInput: { answers: { 'Library?': 'Luxon', 'Ship now?': 'Yes' } }, + updatedInput: { answers: { 'Library?': 'Luxon', 'Ship now?': typed.trim() } }, toolUseID: 'tool-question' }) }) + it('refuses answers Claude cannot take before the journal commits them', async () => { + const claude = fakeClaude() + const adapter = await acquired(claude) + const answered = invokeCanUseTool( + claude.connections[0], + 'AskUserQuestion', + 'question-1', + 'tool-question', + { input: { questions: [{ question: 'Library?', options: [{ label: 'Luxon' }] }] } } + ) + adapter.bindPromptItemId('session-1', 'journal-question', 'question-1') + const commit = vi.fn(async () => undefined) + + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-question', + kind: 'question', + response: { kind: 'option', optionId: 'allow' }, + fence: 7, + commit + }) + ).rejects.toBeInstanceOf(AgentSessionPromptAnswerRejectedError) + expect(commit).not.toHaveBeenCalled() + + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-question', + kind: 'question', + response: { kind: 'answers', answers: [{ questionId: 'q1', optionIds: ['q1:choice-1'] }] }, + fence: 7, + commit + }) + await expect(answered.promise).resolves.toMatchObject({ + updatedInput: { answers: { 'Library?': 'Luxon' } } + }) + }) + it('leaves a prompt cancelled and unanswerable once the SDK abort signal fires', async () => { const claude = fakeClaude() const events: ClaudeStructuredSessionEvent[] = [] @@ -859,7 +886,7 @@ describe('ClaudeStructuredSessionAdapter prompts', () => { sessionId: 'session-1', itemId: 'journal-9', kind: 'approval', - optionId: 'allow', + response: { kind: 'option', optionId: 'allow' }, fence: 7, commit: async () => undefined }) diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 835757dc730..64d3c67c227 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -1,4 +1,5 @@ -import { compactClaudeSession, observeClaudeCompaction } from './claude-structured-compaction' +import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' +import { dispatchClaudeCommand } from './claude-structured-command-dispatch' import type { AgentSessionAcquisition, StructuredAgentSessionAcquireInput, @@ -6,13 +7,15 @@ import type { } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { stopClaudeBackgroundTasks } from './claude-structured-control-actions' import { dispatchClaudeTurn } from './claude-structured-dispatch' -import { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' import { releaseClaudeAcquisition } from './claude-structured-acquisition-release' import { acquireClaudeSession } from './claude-structured-session-acquisition' import { supportsClaudeStructuredLocation } from './claude-structured-location-support' import { setClaudeStructuredSessionOption } from './claude-structured-options' import { readClaudeStructuredSessionOptions } from './claude-structured-session-options' -import { claudeStartupSettledWithin } from './claude-structured-session-startup-gate' +import { + claudeStartupFailureFact, + claudeStartupSettledWithin +} from './claude-structured-session-startup-state' import { CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS } from './claude-agent-sdk-control-requests' import { ClaudeAcquisitionRegistry, @@ -51,7 +54,6 @@ function backgroundTaskState(session: ClaudeSession): AgentSessionBackgroundTask } export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAdapter { - private readonly compactions = new StructuredSessionCompaction() private readonly sessions = new Map() private readonly acquisitions = new ClaudeAcquisitionRegistry() private readonly exits = new Map() @@ -109,15 +111,22 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda } /** Resolves once every first-hand exit observed so far has published its - * lifecycle event — or has failed its tree proof and stayed indexed for a - * retry. Publication trails observation by the close ladder and the + * lifecycle event — or, with neither its tree proven gone nor its root's exit + * observed, stayed indexed for a retry. Publication trails observation by the close ladder and the * transcript cursor write, so nothing outside can otherwise tell the two * apart without guessing at wall-clock. */ drainObservedExits = (): Promise => drainClaudeObservedExits(this.exits) - /** Resolves once a published session's startup has landed or faulted it. */ - drainStartup = (sessionId: string): Promise => - this.sessions.get(sessionId)?.startup.settled ?? Promise.resolve() + /** Resolves once a published session's startup has landed, faulted, or been ended by a close; + * with the reason when it did not land. */ + awaitStarted = async (sessionId: string): Promise => { + const session = this.sessions.get(sessionId) + if (!session) { + return + } + await session.startup.settled + return claudeStartupFailureFact(session) ?? undefined + } /** Restart reconciliation reads the transcript a resume replays; these maps track liveness. */ providerHistoryWindow: NonNullable = ( @@ -145,7 +154,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda if (event.type === 'message' && session?.commands.observe(event.message)) { session.events?.publish() } - observeClaudeCompaction(this.compactions, event, session?.translator) + session?.translator?.handle(event) this.deps.onEvent?.(event) if (backgroundTasksChanged) { this.deps.onBackgroundTasksChanged?.( @@ -169,34 +178,25 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda } } - bindPromptItemId( - sessionId: string, - journalItemId: string, - promptKey: string, - questionId?: string - ): void { + bindPromptItemId(sessionId: string, journalItemId: string, promptKey: string): void { const session = this.sessions.get(sessionId) session?.prompts.bindJournalItemId( journalItemId, promptKey, - questionId, session.translator?.currentTurnId ?? null ) } dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => - dispatchClaudeTurn(this.session(input.sessionId), input, input.beforeDispatch, (settlement) => - this.deps.onDispatchSettledLate?.({ sessionId: input.sessionId, ...settlement }) - ) + dispatchClaudeTurn(this.session(input.sessionId), input, input.beforeDispatch) compact: NonNullable = (input) => - compactClaudeSession(this.session(input.sessionId), this.compactions, input) + dispatchClaudeCommand(this.session(input.sessionId), input.command) cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = (request) => cancelClaudeStructuredTurn({ request, sessions: this.sessions, - compactions: this.compactions, admitPromptCancellation: (session, promptKey) => admitClaudePromptCancellation(session, promptKey), onDispatchSettledLate: (settlement) => @@ -242,7 +242,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda ) readOptions = (input: { sessionId: string; fence: number }) => readClaudeStructuredSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) - recordsContextUsage = (sessionId: string): boolean => this.sessions.has(sessionId) + // Provider-level: a session at rest still reports the usage its journal recorded. + recordsContextUsage = (): boolean => true readOptionRestoreFailures = (sessionId: string): readonly string[] => [ ...(this.sessions.get(sessionId)?.restoreSkippedOptions ?? []) diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index f0bba7ae2b0..f49b6a8d88f 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -12,19 +12,19 @@ import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' -import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' +import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import { closeProcessRegistry } from '../../shared/child-process/close-process-registry' import { retireClaudeDispatchWaiters } from './claude-structured-dispatch' import { settledClaudeTurnEndLeaf } from './claude-structured-resume-point' -/** The root's own exit was seen first-hand; only its descendants went unverified. */ +/** The root's own exit was seen first-hand. The lease follows the root, so a descendant + * left unverified or seen alive does not hold it. */ export function claudeRootExitObserved( connection: ClaudeStreamJsonConnection | null | undefined ): boolean { - const verdict = connection?.exitVerdict - return verdict?.root === 'exited' && verdict.tree === 'unverifiable' + return connection?.exitVerdict.root === 'exited' } export function claudeAcquisitionCleanupError( diff --git a/src/main/claude/claude-structured-session-exit-blame.test.ts b/src/main/claude/claude-structured-session-exit-blame.test.ts new file mode 100644 index 00000000000..79430131e15 --- /dev/null +++ b/src/main/claude/claude-structured-session-exit-blame.test.ts @@ -0,0 +1,66 @@ +// After a Claude start lands, only the child's own exit says Claude stopped; a fault on Orca's +// side that makes Orca close the child is Orca's. + +import { describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' +import { + adapterFor, + fakeClaude, + identityFor, + PROVIDER_SESSION_ID +} from './claude-structured-session-test-support' + +async function startedClaude(sink: StructuredAgentSessionEventSink) { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', events: sink }) + const ended = async () => { + await vi.waitFor(() => expect(events.some((event) => event.type === 'ended')).toBe(true)) + return events.find((event) => event.type === 'ended') + } + return { connection: claude.connections[0], ended } +} + +describe('what a started Claude session says ended it', () => { + it('says Claude stopped when the child exits on its own', async () => { + const { connection, ended } = await startedClaude({ + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {} + }) + + connection.handlers.onExit?.(new Error('claude stream-json exited (code 1): killed')) + + expect(await ended()).toMatchObject({ + cause: 'unexpected-exit', + failure: { kind: 'providerExited' } + }) + }) + + it('blames Orca when a journal fault makes Orca close the child', async () => { + const { connection, ended } = await startedClaude({ + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {}, + tryAppendResolvedItemAndPublish: () => ({ accepted: false, reason: 'failed' }) + }) + + connection.handlers.onMessage?.({ + type: 'system', + subtype: 'task_notification', + session_id: PROVIDER_SESSION_ID, + task_id: 'task-1', + status: 'failed', + summary: 'failed' + }) + + expect(await ended()).toMatchObject({ + reason: 'claude background task journal sink failed', + cause: 'unexpected-exit', + failure: { kind: 'hostFault' } + }) + expect(connection.closeCount).toBeGreaterThanOrEqual(1) + }) +}) diff --git a/src/main/claude/claude-structured-session-exit-lifecycle.ts b/src/main/claude/claude-structured-session-exit-lifecycle.ts index fdcfed379c5..854646490a8 100644 --- a/src/main/claude/claude-structured-session-exit-lifecycle.ts +++ b/src/main/claude/claude-structured-session-exit-lifecycle.ts @@ -1,9 +1,14 @@ +import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' +import { + providerExitObserved, + providerStartupFailureFact +} from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import { settledClaudeTurnEndLeaf } from './claude-structured-resume-point' import { claudeRootExitObserved, settleClaudeExitedSession } from './claude-structured-session-close' -import { failClaudeStartupGate } from './claude-structured-session-startup-gate' +import { failClaudeStartup } from './claude-structured-session-startup-state' import type { ClaudeAcquisitionAttempt, ClaudeSession, @@ -32,7 +37,9 @@ export function observeClaudeSessionExit( return } lifecycle.sessions.delete(sessionId) - failClaudeStartupGate(session, error) + // Not marked here: startup and journal faults end the session this way too. The child's own + // exit arrives already marked by the connection's exit callback. + failClaudeStartup(session, error) // Re-enter the provider's close ladder before publishing lifecycle recovery. // An exit callback is root evidence only; the retained tree proof must run // before the host releases and reacquires this exact child. @@ -55,7 +62,8 @@ export function observeClaudeSessionExit( .catch(() => undefined) } -/** Lifecycle recovery is published only after the child tree proof is true. */ +/** Lifecycle recovery is published only after the close ladder ran and proved the tree gone or + * observed the root's own exit. */ export function settleClaudeUnexpectedExit( lifecycle: ClaudeExitLifecycle, sessionId: string, @@ -84,6 +92,16 @@ export function settleClaudeUnexpectedExit( type: 'ended', sessionId, reason: exit.error.message, + // A start that never landed says why it failed. After it landed, only the child's own exit + // blames the provider; an Orca fault that closed it is Orca's. + failure: + exit.session.startup.state !== 'proven' + ? providerStartupFailureFact(exit.session.startup.failure ?? exit.error) + : providerExitObserved(exit.error) + ? agentSessionFailureFact('providerExited', { + detail: providerDiagnosticOf(exit.error) + }) + : agentSessionFailureFact('hostFault'), cause: 'unexpected-exit', fence: exit.session.fence, acquisitionGeneration: exit.session.acquisitionGeneration, diff --git a/src/main/claude/claude-structured-session-journal-control.ts b/src/main/claude/claude-structured-session-journal-control.ts index 9a4d3611435..9e3ee0289cf 100644 --- a/src/main/claude/claude-structured-session-journal-control.ts +++ b/src/main/claude/claude-structured-session-journal-control.ts @@ -4,7 +4,7 @@ import { } from './claude-context-usage' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' -import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' +import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' import type { ClaudeInitProof } from './claude-structured-session-startup' import type { ClaudeAcquisitionAttempt, diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts index f50923162e5..f4c59148241 100644 --- a/src/main/claude/claude-structured-session-options.ts +++ b/src/main/claude/claude-structured-session-options.ts @@ -223,15 +223,37 @@ export function claudeCatalogAdmitsModel(models: readonly ListedModel[], modelId ) } -function wireClaudeModels(models: readonly ListedModel[]): AgentSessionOptionsResult['models'] { - return models.map((entry) => ({ +type WireClaudeModel = AgentSessionOptionsResult['models'][number] + +function wireClaudeModel(entry: ListedModel): WireClaudeModel { + return { id: entry.id, label: entry.label, ...(entry.description ? { description: entry.description } : {}), isDefault: entry.isDefault, efforts: entry.efforts, ...(entry.supportsFastMode !== undefined ? { supportsFastMode: entry.supportsFastMode } : {}) - })) + } +} + +function wireClaudeModels(models: readonly ListedModel[]): WireClaudeModel[] { + return models.map(wireClaudeModel) +} + +/** The listing, with what the CLI runs when no effort is sent on each model the child applies — + * a default only a running child knows, and only while this session has no effort pick. */ +function catalogClaudeModels(session: ClaudeSession, discovered: ListedModel[]): WireClaudeModel[] { + const applied = session.options.has('effort') ? undefined : session.appliedOptions + return discovered.map((listed) => { + const model = wireClaudeModel(listed) + const effort = applied?.effort + const runsApplied = + applied?.model !== undefined && + (listed.id === applied.model || listed.resolvedModel === applied.model) + return effort && runsApplied && model.efforts.some((choice) => choice.value === effort) + ? { ...model, defaultEffort: effort } + : model + }) } /** Write a provider-listed catalog through to the host store. Account-level @@ -243,7 +265,7 @@ function writeClaudeCatalogThrough(session: ClaudeSession, discovered: ListedMod } const support = claudeFastModeSupport(discovered, undefined) session.catalogAccess.store.recordSuccess(session.catalogAccess.fingerprint, 'claude', { - models: wireClaudeModels(discovered), + models: catalogClaudeModels(session, discovered), ...(support ? { fastModeSupport: support } : {}), fastModeTierByModel: new Map(), origin: 'live-session' diff --git a/src/main/claude/claude-structured-session-publication.ts b/src/main/claude/claude-structured-session-publication.ts index d64273f9b8b..c685c2cd2ae 100644 --- a/src/main/claude/claude-structured-session-publication.ts +++ b/src/main/claude/claude-structured-session-publication.ts @@ -1,12 +1,12 @@ import type { AgentSessionAcquisition } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { claudeProviderHandleLink } from './claude-structured-owner-identity' import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' -import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' +import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' import type { ClaudeSession } from './claude-structured-session-state' import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' -import { createClaudeSessionStartupGate } from './claude-structured-session-startup-gate' +import { createClaudeSessionStartup } from './claude-structured-session-startup-state' /** The session as published at spawn: nothing the CLI reports at init is assumed yet. */ export function createClaudeSessionPublication(input: { @@ -67,7 +67,7 @@ export function createClaudeSessionPublication(input: { translator: input.translator, events: input.events, ...(input.unbindReadingControl ? { unbindReadingControl: input.unbindReadingControl } : {}), - startup: createClaudeSessionStartupGate() + startup: createClaudeSessionStartup() } } } diff --git a/src/main/claude/claude-structured-session-reading-control.test.ts b/src/main/claude/claude-structured-session-reading-control.test.ts index 1084c2c8626..7677462303e 100644 --- a/src/main/claude/claude-structured-session-reading-control.test.ts +++ b/src/main/claude/claude-structured-session-reading-control.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import { createDeferredStructuredAgentSessionEventSink, @@ -236,7 +237,8 @@ describe('Claude structured reading control', () => { const resumeReading = vi.spyOn(claude.connections[0], 'resumeReading') deferred.sink.appendItem( { provider: 'orca', clientMessageId: 'blocked-prefill' }, - { kind: 'message', role: 'system', blocks: [{ type: 'text', text: 'prefill' }] } + { kind: 'message', role: 'system', blocks: [{ type: 'text', text: 'prefill' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await appendEntered.promise const notification = { diff --git a/src/main/claude/claude-structured-session-recovery.test.ts b/src/main/claude/claude-structured-session-recovery.test.ts index ef8e0c65469..71a7ac9ce31 100644 --- a/src/main/claude/claude-structured-session-recovery.test.ts +++ b/src/main/claude/claude-structured-session-recovery.test.ts @@ -9,10 +9,10 @@ import { adapterFor, fakeClaude, identityFor, - invokeCanUseTool, PROVIDER_SESSION_ID, tick } from './claude-structured-session-test-support' +import { invokeCanUseTool } from './claude-can-use-tool-test-support' describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { it('shares concurrent close finalization and emits lifecycle once', async () => { @@ -338,7 +338,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { spawnToken: 'spawn-9', events: journalSink }) - await adapter.drainStartup('session-1') + await adapter.awaitStarted('session-1') const first = claude.connections[0] const oldPrompt = invokeCanUseTool(first, 'Bash', 'permission-retained', 'tool-retained') const oldSession = ( @@ -417,6 +417,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { type: 'ended', sessionId: 'session-1', reason: 'crashed before replacement', + failure: { kind: 'providerExited' }, cause: 'unexpected-exit', fence: 7, acquisitionGeneration: firstAcquisition.acquisitionGeneration, diff --git a/src/main/claude/claude-structured-session-startup-gate.ts b/src/main/claude/claude-structured-session-startup-gate.ts deleted file mode 100644 index 0df78936191..00000000000 --- a/src/main/claude/claude-structured-session-startup-gate.ts +++ /dev/null @@ -1,172 +0,0 @@ -// A Claude session is published once its child is spawned, before the CLI has answered -// initialize. Prompts sent in that window are held here and written, in order, once startup -// lands (init facts read and saved options restored), so a first turn never runs under -// defaults the restore was about to replace. A held prompt was never written, so a startup -// that fails rejects it rather than leaving its delivery in doubt. - -import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import { AgentSessionPreDispatchError } from '../native-chat/agent-session-wire/structured-agent-session-operation-settlement' -import { dispatchWriteFailureReason } from '../../shared/structured-agent-session-dispatch-rejection' -import { providerStartupFailureRejection } from '../native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement' -import { claudeUserMessageWasProvablyUnwritten } from './claude-agent-sdk-user-message-queue' -import { - forgetRetiredWaiter, - forgetWaiter, - retireWaiter -} from './claude-structured-dispatch-waiters' -import type { - ClaudeDispatchWaiter, - ClaudeLateDispatchOutcome, - ClaudeSession -} from './claude-structured-session-state' - -type ClaudeStartupHeldWrite = { - waiter: ClaudeDispatchWaiter - message: Record - settleLate?: (outcome: ClaudeLateDispatchOutcome) => void -} - -export type ClaudeSessionStartupGate = { - state: 'pending' | 'proven' | 'failed' - held: ClaudeStartupHeldWrite[] - /** Held prompts are still being written; later prompts must queue behind them. */ - draining: boolean - failure: Error | null - /** Resolves once startup has landed or faulted the session; never rejects. */ - settled: Promise -} - -export function createClaudeSessionStartupGate(): ClaudeSessionStartupGate { - return { state: 'pending', held: [], draining: false, failure: null, settled: Promise.resolve() } -} - -export function claudeStartupFailureReason(session: ClaudeSession): string | null { - return session.startup.state === 'failed' - ? providerStartupFailureRejection(session.startup.failure ?? undefined) - : null -} - -/** Resolves when startup lands or `timeoutMs` passes; a stuck start then refuses the write as before. */ -export function claudeStartupSettledWithin( - session: ClaudeSession | undefined, - timeoutMs: number -): Promise { - if (session?.startup.state !== 'pending') { - return Promise.resolve() - } - let timer: ReturnType | undefined - return Promise.race([ - session.startup.settled, - new Promise((resolve) => { - timer = setTimeout(resolve, timeoutMs) - }) - ]).finally(() => clearTimeout(timer)) -} - -export function claudeStartupHoldsWrites(session: ClaudeSession): boolean { - return session.startup.state === 'pending' || session.startup.draining -} - -/** Admits a prompt while startup is pending; it is written when `openClaudeStartupGate` runs. */ -export async function holdClaudeStartupWrite( - session: ClaudeSession, - input: { - message: Record - arm: () => { waiter: ClaudeDispatchWaiter } - beforeDispatch?: () => Promise - settleLate?: (outcome: ClaudeLateDispatchOutcome) => void - } -): Promise { - if (input.beforeDispatch) { - try { - await input.beforeDispatch() - } catch (error) { - if (error instanceof AgentSessionPreDispatchError) { - throw error - } - return { state: 'rejected', reason: dispatchWriteFailureReason(error) } - } - } - // Startup may have failed while the admission barrier ran. - const failed = claudeStartupFailureReason(session) - if (failed) { - return { state: 'rejected', reason: failed } - } - const { waiter } = input.arm() - session.startup.held.push({ - waiter, - message: input.message, - ...(input.settleLate ? { settleLate: input.settleLate } : {}) - }) - // Startup finished writing what it held while the barrier ran; nothing else would drain this. - if (!claudeStartupHoldsWrites(session)) { - void drainClaudeStartupWrites(session) - } - return { state: 'admitted' } -} - -export async function openClaudeStartupGate(session: ClaudeSession): Promise { - const gate = session.startup - if (gate.state !== 'pending') { - return - } - gate.state = 'proven' - await drainClaudeStartupWrites(session) -} - -async function drainClaudeStartupWrites(session: ClaudeSession): Promise { - const gate = session.startup - gate.draining = true - try { - for (let held = gate.held.shift(); held; held = gate.held.shift()) { - await writeHeld(session, held) - } - } finally { - gate.draining = false - } -} - -async function writeHeld(session: ClaudeSession, held: ClaudeStartupHeldWrite): Promise { - try { - await session.connection.send(held.message) - } catch (error) { - if (held.waiter.settledUuid) { - return - } - if (claudeUserMessageWasProvablyUnwritten(error)) { - rejectHeld(session, held, dispatchWriteFailureReason(error)) - return - } - // Possibly written: only a replay or the child's exit can settle it now. - retireWaiter(session, held.waiter) - held.waiter.resolve(null) - } -} - -function rejectHeld(session: ClaudeSession, held: ClaudeStartupHeldWrite, reason: string): void { - forgetWaiter(session, held.waiter) - forgetRetiredWaiter(session, held.waiter) - held.waiter.resolve(null) - if (held.waiter.clientMessageId) { - held.settleLate?.({ clientMessageId: held.waiter.clientMessageId, state: 'rejected', reason }) - } -} - -/** Rejects every held prompt with `reason`; true when any was held. */ -export function rejectClaudeStartupWrites(session: ClaudeSession, reason: string): boolean { - const held = session.startup.held.splice(0) - for (const entry of held) { - rejectHeld(session, entry, reason) - } - return held.length > 0 -} - -/** Startup cannot land any more; nothing held was written, so all of it is rejected. */ -export function failClaudeStartupGate(session: ClaudeSession, error: Error): void { - const gate = session.startup - if (gate.state === 'pending') { - gate.state = 'failed' - gate.failure = error - } - rejectClaudeStartupWrites(session, providerStartupFailureRejection(error)) -} diff --git a/src/main/claude/claude-structured-session-startup-state.ts b/src/main/claude/claude-structured-session-startup-state.ts new file mode 100644 index 00000000000..cf44bd984ab --- /dev/null +++ b/src/main/claude/claude-structured-session-startup-state.ts @@ -0,0 +1,59 @@ +// Where a Claude start stands. A session is published once its child is spawned, before the CLI +// has answered initialize. Nothing is written to it until startup lands (init facts read and saved +// options restored): the host's delivery loop waits on `settled` before it hands a message over, +// so a first turn never runs under defaults the restore was about to replace. + +import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' +import { providerStartupFailureFact } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' +import type { ClaudeSession } from './claude-structured-session-state' + +export type ClaudeSessionStartup = { + state: 'pending' | 'proven' | 'failed' + failure: Error | null + /** Resolves once startup has landed or faulted, or the child exited or was closed; never + * rejects. A close must end it: the delivery loop waits here, and a start Stop cut short + * would otherwise hold that loop forever. */ + settled: Promise + end: () => void +} + +export function createClaudeSessionStartup(): ClaudeSessionStartup { + let end: () => void = () => undefined + const ended = new Promise((resolve) => { + end = resolve + }) + return { state: 'pending', failure: null, settled: ended, end } +} + +export function claudeStartupFailureFact(session: ClaudeSession): SubmissionRejectionFact | null { + return session.startup.state === 'failed' + ? providerStartupFailureFact(session.startup.failure ?? undefined) + : null +} + +/** Resolves when startup lands or `timeoutMs` passes; a stuck start then refuses the write as before. */ +export function claudeStartupSettledWithin( + session: ClaudeSession | undefined, + timeoutMs: number +): Promise { + if (session?.startup.state !== 'pending') { + return Promise.resolve() + } + let timer: ReturnType | undefined + return Promise.race([ + session.startup.settled, + new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs) + }) + ]).finally(() => clearTimeout(timer)) +} + +/** Startup cannot land any more: the child exited, was closed, or its start faulted. */ +export function failClaudeStartup(session: ClaudeSession, error: Error): void { + const startup = session.startup + if (startup.state === 'pending') { + startup.state = 'failed' + startup.failure = error + } + startup.end() +} diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index a106c172c45..ceb9e9cdf6d 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -55,7 +55,7 @@ describe('Claude structured session publishes before the CLI answers initialize' expect(adapter.readCommands('session-1')).toBeUndefined() await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await adapter.drainStartup('session-1') + await adapter.awaitStarted('session-1') expect(events.find((event) => event.type === 'options')).toMatchObject({ models: [{ value: 'claude-sonnet' }] @@ -65,7 +65,7 @@ describe('Claude structured session publishes before the CLI answers initialize' await adapter.closeAll() }) - it('reports `started` once saved options are restored, before any held prompt is written', async () => { + it('reports `started` once saved options are restored, having written no prompt of its own', async () => { const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS, initModel: 'claude-opus-9' }) const { adapter, events } = startingAdapter(claude) const order: string[] = [] @@ -74,11 +74,10 @@ describe('Claude structured session publishes before the CLI answers initialize' return undefined } await adapter.acquire({ ...ACQUIRE, options: { model: 'opus' } }) - await adapter.dispatch(PROMPT) expect(events.some((event) => event.type === 'started')).toBe(false) await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await adapter.drainStartup('session-1') + await adapter.awaitStarted('session-1') const startedAt = events.findIndex((event) => event.type === 'started') expect(events[startedAt]).toEqual({ @@ -90,9 +89,9 @@ describe('Claude structured session publishes before the CLI answers initialize' reportedOptions: expect.objectContaining({ model: 'opus' }), restoreSkippedOptions: [] }) - // The restore wrote the saved model before `started`, and the held prompt only after it. + // The restore wrote the saved model before `started`; no message waits inside the adapter. expect(order).toEqual(['set_model']) - expect(claude.connections[0].sent).toHaveLength(1) + expect(claude.connections[0].sent).toEqual([]) expect(events.slice(0, startedAt).some((event) => event.type === 'options')).toBe(true) await adapter.closeAll() }) @@ -133,52 +132,37 @@ describe('Claude structured session publishes before the CLI answers initialize' await adapter.closeAll() }) - it('holds a prompt sent before init and writes it once startup lands', async () => { + // The host's delivery loop waits here before it hands a message over, so the adapter no longer + // holds prompts of its own: nothing is written until startup lands because nothing is sent. + it('resolves awaitStarted only once startup lands', async () => { const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS }) const { adapter } = startingAdapter(claude) await adapter.acquire(ACQUIRE) - - await expect(adapter.dispatch(PROMPT)).resolves.toEqual({ state: 'admitted' }) - expect(claude.connections[0].sent).toEqual([]) - - await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await adapter.drainStartup('session-1') - - expect(claude.connections[0].sent).toHaveLength(1) - expect(claude.connections[0].sent[0]).toMatchObject({ type: 'user' }) - await adapter.closeAll() - }) - - it('writes a prompt whose admission barrier was still running when startup landed', async () => { - const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS }) - const { adapter } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - let passBarrier = (): void => {} - const barrier = new Promise((resolve) => { - passBarrier = resolve + let started = false + const waited = adapter.awaitStarted('session-1').then(() => { + started = true }) - const dispatched = adapter.dispatch({ ...PROMPT, beforeDispatch: () => barrier }) - await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await adapter.drainStartup('session-1') - passBarrier() + await vi.advanceTimersByTimeAsync(SLOW_INIT_MS - 1) + expect(started).toBe(false) + await vi.advanceTimersByTimeAsync(1) + await waited - await expect(dispatched).resolves.toEqual({ state: 'admitted' }) - expect(claude.connections[0].sent.filter((message) => message.type === 'user')).toHaveLength(1) + await expect(adapter.dispatch(PROMPT)).resolves.toEqual({ state: 'admitted' }) + expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]) await adapter.closeAll() }) - it('ends the session with the exit reason when the CLI dies before init, and rejects held prompts', async () => { + it('ends the session with the exit reason when the CLI dies before init', async () => { const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS, exitBeforeInit: 'claude stream-json exited (code 1): stderr says no' }) - const { adapter, events, late } = startingAdapter(claude) + const { adapter, events } = startingAdapter(claude) await adapter.acquire(ACQUIRE) - await adapter.dispatch(PROMPT) await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await adapter.drainStartup('session-1') + await adapter.awaitStarted('session-1') await adapter.drainObservedExits() expect(events.find((event) => event.type === 'ended')).toMatchObject({ @@ -186,9 +170,6 @@ describe('Claude structured session publishes before the CLI answers initialize' cause: 'unexpected-exit', startupUnproven: true }) - expect(late).toEqual([ - expect.objectContaining({ clientMessageId: 'client-1', state: 'rejected' }) - ]) expect(claude.connections[0].sent).toEqual([]) expect(claude.connections[0].closeCount).toBe(1) }) @@ -203,11 +184,10 @@ describe('Claude structured session publishes before the CLI answers initialize' await adapter.acquire(ACQUIRE) await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await adapter.drainStartup('session-1') + await adapter.awaitStarted('session-1') await adapter.drainObservedExits() - // A failed start is released on the same evidence a failed create is; a proven-live - // descendant would have answered `tree: 'live'` instead. + // A failed start is released on the same evidence a failed create is. expect(events.find((event) => event.type === 'ended')).toMatchObject({ cause: 'unexpected-exit', startupUnproven: true @@ -218,7 +198,7 @@ describe('Claude structured session publishes before the CLI answers initialize' const claude = fakeClaude({ initAccount: { apiProvider: 'firstParty', tokenSource: 'none' } }) const { adapter, events } = startingAdapter(claude) await adapter.acquire(ACQUIRE) - await adapter.drainStartup('session-1') + await adapter.awaitStarted('session-1') await adapter.drainObservedExits() expect(events.find((event) => event.type === 'ended')).toMatchObject({ @@ -227,82 +207,41 @@ describe('Claude structured session publishes before the CLI answers initialize' }) }) - it('closes a session stopped before init without faulting it or writing held prompts', async () => { - const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS }) - const { adapter, events, late } = startingAdapter(claude) + // A Stop that closes a child still starting must end the wait the host's delivery loop is in, + // though initialize never answers; otherwise every later send joins a loop that never moves. + it('ends the wait on a start closed before init, without faulting it', async () => { + const claude = fakeClaude({ initDelayMs: 10 * SLOW_INIT_MS }) + const { adapter, events } = startingAdapter(claude) await adapter.acquire(ACQUIRE) - await adapter.dispatch(PROMPT) + let ended = false + const waited = adapter.awaitStarted('session-1').then(() => { + ended = true + }) await expect(adapter.closeSession('session-1')).resolves.toBe(true) - await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await adapter.drainStartup('session-1') + await vi.advanceTimersByTimeAsync(0) + await waited + expect(ended).toBe(true) const connection = claude.connections[0] expect(connection.closeCount).toBe(1) expect(connection.sent).toEqual([]) expect(connection.calls.map(({ subtype }) => subtype)).not.toContain('get_settings') - expect(late).toEqual([ - expect.objectContaining({ clientMessageId: 'client-1', state: 'rejected' }) - ]) expect(events.some((event) => event.type === 'ended' && event.startupUnproven)).toBe(false) expect(events.some((event) => event.type === 'started')).toBe(false) }) - it('withdraws a held prompt when the turn is cancelled before init', async () => { + it('interrupts nothing when Stop lands before init: nothing was written', async () => { const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS }) - const { adapter, late } = startingAdapter(claude) + const { adapter } = startingAdapter(claude) await adapter.acquire(ACQUIRE) - await adapter.dispatch(PROMPT) await expect( adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) - ).resolves.toEqual({ cancelled: true }) - await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await adapter.drainStartup('session-1') + ).resolves.toEqual({ cancelled: false }) + expect(claude.connections[0].calls.map(({ subtype }) => subtype)).not.toContain('interrupt') expect(claude.connections[0].sent).toEqual([]) - expect(late).toEqual([ - expect.objectContaining({ clientMessageId: 'client-1', state: 'rejected' }) - ]) - await adapter.closeAll() - }) - - it('withdraws the prompts still held when Stop lands while startup is writing them', async () => { - const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS }) - const { adapter, late } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - const connection = claude.connections[0] - const send = connection.send - let landFirstWrite = (): void => {} - const firstWrite = new Promise((resolve) => { - landFirstWrite = resolve - }) - let writes = 0 - connection.send = async (message, beforeDispatch) => { - writes += 1 - if (writes === 1) { - await firstWrite - } - return send(message, beforeDispatch) - } - await adapter.dispatch(PROMPT) - await adapter.dispatch({ ...PROMPT, clientMessageId: 'client-2' }) - - await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - // Startup has landed and is writing the first held prompt. - expect(writes).toBe(1) - const cancelled = adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) - await vi.advanceTimersByTimeAsync(0) - landFirstWrite() - await vi.advanceTimersByTimeAsync(5_000) - await adapter.drainStartup('session-1') - - expect(connection.sent.filter((message) => message.type === 'user')).toHaveLength(1) - expect(late).toContainEqual( - expect.objectContaining({ clientMessageId: 'client-2', state: 'rejected' }) - ) - // Stop withdrew something, so it answers as a cancel whatever the interrupt made of the turn. - await expect(cancelled).resolves.toEqual({ cancelled: true }) await adapter.closeAll() }) }) diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index aa1f9a02ecc..ce14a64dd2c 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -26,10 +26,7 @@ import { observeClaudeSettingsApplied, readClaudeSettingsEffort } from './claude-structured-session-options' -import { - failClaudeStartupGate, - openClaudeStartupGate -} from './claude-structured-session-startup-gate' +import { failClaudeStartup } from './claude-structured-session-startup-state' import type { ClaudeSession, ClaudeStructuredSessionEvent } from './claude-structured-session-state' export type ClaudeInitProof = { @@ -141,7 +138,7 @@ function applyClaudeStartupFacts(session: ClaudeSession, facts: ClaudeStartupFac session.fastModeState ??= published.fastModeState session.fastModeDisabledReason ??= published.fastModeDisabledReason session.options = prepared.options - session.capabilities = readClaudeCapabilities(init, initialization) + session.capabilities = readClaudeCapabilities(session.capabilities, initialization, init.message) // A catalog frame that streamed in after publish is newer than the initialize answer. if (session.commands.commands === undefined) { session.commands = new ClaudeSlashCommandCatalog(init.message, initialization) @@ -163,8 +160,8 @@ function claudeStartedReportedOptions( return persisted } -/** Applies startup facts to the published session, restores saved options, then releases - * held prompts. Any failure faults the session so the user sees why it never started. */ +/** Applies startup facts to the published session and restores saved options; only then does the + * session take input. Any failure faults the session so the user sees why it never started. */ export async function settleClaudeSessionStartup(input: { session: ClaudeSession facts: Promise @@ -179,7 +176,7 @@ export async function settleClaudeSessionStartup(input: { if (input.isCurrent()) { return false } - failClaudeStartupGate(session, new Error('claude session closed before startup completed')) + failClaudeStartup(session, new Error('claude session closed before startup completed')) return true } try { @@ -198,13 +195,15 @@ export async function settleClaudeSessionStartup(input: { ), restoreSkippedOptions: [...session.restoreSkippedOptions] }) - await openClaudeStartupGate(session) + if (session.startup.state === 'pending') { + session.startup.state = 'proven' + } } } catch (caught) { const error = caught instanceof Error ? caught : new Error(String(caught)) // A close or exit that already ended startup owns how the session ends. const endedElsewhere = session.startup.state !== 'pending' - failClaudeStartupGate(session, error) + failClaudeStartup(session, error) if (!endedElsewhere && input.isCurrent()) { input.fault(error) } diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 7d99aff577b..b40aaba81c4 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -1,3 +1,5 @@ +import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' +import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' import type { AgentJournalItemIdentity, AgentSessionJournalIdentity @@ -9,7 +11,7 @@ import type { openClaudeStreamJsonConnection } from './claude-stream-json-connection' import type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' -import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' +import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' import type { ClaudePendingPrompt, ClaudePromptRegistry } from './claude-structured-prompt-replies' import { cancelProcessAcquisition } from '../../shared/child-process/cancel-process-acquisition' import { randomUUID } from 'node:crypto' @@ -25,7 +27,7 @@ import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-wor import type { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' import type { ClaudeChildWorkDecoder } from './claude-child-work-decoder' import type { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' -import type { ClaudeSessionStartupGate } from './claude-structured-session-startup-gate' +import type { ClaudeSessionStartup } from './claude-structured-session-startup-state' export type ClaudeAuthDiagnostic = { apiKeySourceConfigured: boolean @@ -40,7 +42,9 @@ export type ClaudeStructuredSessionEvent = type: 'message' sessionId: string message: Record - /** Present only when this replay acknowledged Orca's in-flight dispatch. */ + /** Present only when this replay acknowledged Orca's in-flight dispatch + * AND opens a turn; a replay folded into the running turn settles + * delivery without one. */ startsTurn?: true /** Submission instant of the dispatch this replay acknowledged; the origin * of the turn it opens. Absent when the host cannot name a send. */ @@ -66,11 +70,11 @@ export type ClaudeStructuredSessionEvent = type: 'ended' sessionId: string reason: string + failure?: SubmissionRejectionFact /** Present for first-hand child exits so the host can fence recovery. */ cause?: 'unexpected-exit' | 'requested-close' fence?: number acquisitionGeneration?: string - settlementRetryRequired?: boolean /** Host clock when the end was observed. */ observedAt?: number /** The child ended before proving startup, so reacquiring would repeat the same start. */ @@ -82,7 +86,7 @@ export type ClaudeLateDispatchOutcome = clientMessageId: string providerIdentity: AgentJournalItemIdentity } - | { clientMessageId: string; state: 'rejected'; reason: string } + | ({ clientMessageId: string; state: 'rejected' } & AgentJournalDispatchRejection) export type ClaudeStructuredSessionAdapterDeps = { resolveLaunch: (input: { @@ -91,6 +95,8 @@ export type ClaudeStructuredSessionAdapterDeps = { onEvent?: (event: ClaudeStructuredSessionEvent) => void /** Direct settlement path for provider-proven late dispatch outcomes. */ onDispatchSettledLate?: (input: { sessionId: string } & ClaudeLateDispatchOutcome) => void + /** The CLI reported `session_state_changed idle`, which it sends only once its queue drains. */ + onSessionIdle?: (input: { sessionId: string }) => void onBackgroundTasksChanged?: ( sessionId: string, state: AgentSessionBackgroundTaskState | null @@ -135,6 +141,8 @@ export type ClaudeDispatchWaiter = { settledUuid?: string /** The write failed or the child died, but a replay may still name it. */ retired?: boolean + /** The CLI's last non-terminal `command_lifecycle` state for this send; in memory only. */ + commandLifecycle?: 'queued' | 'started' /** Bounded digest/summary for compatibility CLIs that mint UUIDs. */ replayContentKey: string } @@ -196,7 +204,7 @@ export type ClaudeSession = { events: StructuredAgentSessionEventSink | undefined unbindReadingControl?: () => void /** Published at spawn; init facts, option restore and queued prompts land when startup does. */ - startup: ClaudeSessionStartupGate + startup: ClaudeSessionStartup } export function mintClaudeAcquisitionGeneration(deps: ClaudeStructuredSessionAdapterDeps): string { @@ -210,7 +218,7 @@ export function mintClaudeAcquisitionGeneration(deps: ClaudeStructuredSessionAda */ export type ClaudeSessionExit = { connection: ClaudeStreamJsonConnection - /** Full session identity retained until its child tree is proven gone. */ + /** Full session identity retained until the exit settles. */ session: ClaudeSession error: Error /** The exit path's first proof attempt; retries must observe this result. */ diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts index 023e0ba46b7..0380d5ac12f 100644 --- a/src/main/claude/claude-structured-session-test-support.ts +++ b/src/main/claude/claude-structured-session-test-support.ts @@ -51,6 +51,9 @@ export function fakeClaude( initSessionId?: string initUuid?: string initModel?: string + /** 'session-start' (default) mirrors live: a SessionStart hook frame proves the + * session and system/init arrives only when the first command starts a cycle. + * 'init' emits init at startup — an UNMEASURED shape, opt-in only. */ initProof?: 'init' | 'session-start' | 'none' initAccount?: unknown initCommands?: unknown @@ -81,6 +84,22 @@ export function fakeClaude( return route ? route(params) : undefined } const openConnection: typeof openClaudeStreamJsonConnection = async (launch, handlers = {}) => { + let cycleInitEmitted = false + // Keys mirror the real system/init frame, which carries `model` but no + // effort of any kind: the current effort only comes back from get_settings. + // Never add a field the CLI does not send. + const emitCycleInit = (): void => { + cycleInitEmitted = true + handlers.onMessage?.({ + type: 'system', + subtype: 'init', + session_id: options.initSessionId ?? PROVIDER_SESSION_ID, + uuid: options.initUuid ?? 'init-uuid', + model: options.initModel ?? 'claude-sonnet-5', + apiKeySource: 'none', + ...(options.capabilities ? { capabilities: options.capabilities } : {}) + }) + } const connection: FakeConnection = { launch, handlers, @@ -102,7 +121,13 @@ export function fakeClaude( // The SDK rejects pending control requests once the transport ends. throw new Error('Query closed before response received') } - if (options.initProof === 'session-start') { + if (options.initProof === 'init') { + // UNMEASURED startup shape, kept only as an explicit opt-in: live + // sessions prove startup with a SessionStart hook frame instead. + emitCycleInit() + } else if (options.initProof !== 'none') { + // The live proof order (measured through Orca's adapter): SessionStart + // hook frames arrive first; system/init only when a cycle starts. handlers.onMessage?.({ type: 'system', subtype: 'hook_started', @@ -110,22 +135,19 @@ export function fakeClaude( session_id: options.initSessionId ?? PROVIDER_SESSION_ID, uuid: options.initUuid ?? 'init-uuid' }) - } else if (options.initProof !== 'none') { - // Keys mirror the real system/init frame, which carries `model` but no - // effort of any kind: the current effort only comes back from - // get_settings. Never add a field the CLI does not send. handlers.onMessage?.({ type: 'system', - subtype: 'init', + subtype: 'hook_response', + hook_name: 'SessionStart:startup', session_id: options.initSessionId ?? PROVIDER_SESSION_ID, - uuid: options.initUuid ?? 'init-uuid', - model: options.initModel ?? 'claude-sonnet-5', - apiKeySource: 'none', - ...(options.capabilities ? { capabilities: options.capabilities } : {}) + uuid: 'hook-response-uuid' }) } return { models: options.initModels ?? [{ value: 'claude-sonnet', displayName: 'Sonnet' }], + // Capabilities ride the initialize result, where startup facts read + // them regardless of when the first init frame arrives. + ...(options.capabilities ? { capabilities: options.capabilities } : {}), ...(options.initCommands === undefined ? {} : { commands: options.initCommands }), ...(options.initAccount === undefined ? {} : { account: options.initAccount }) } @@ -173,7 +195,7 @@ export function fakeClaude( }, cancelAsyncMessage: async (uuid) => { connection.calls.push({ subtype: 'cancel_async_message', params: { uuid } }) - routed('cancel_async_message', { uuid }) + return routed('cancel_async_message', { uuid }) === true }, stopTask: async (taskId) => { connection.calls.push({ subtype: 'stop_task', params: { taskId } }) @@ -184,6 +206,11 @@ export function fakeClaude( await beforeDispatch() } connection.sent.push(message) + // Live: the first command starts a request cycle, whose init precedes + // the replay. Later cycles are the test's own frames. + if (message.type === 'user' && !cycleInitEmitted && options.initProof !== 'none') { + emitCycleInit() + } if (message.type === 'user' && options.replayUuid !== null) { const configuredReplayUuid = options.replayUuids ? options.replayUuids[replayIndex++] @@ -220,7 +247,7 @@ export function adapterFor( const acquire = adapter.acquire adapter.acquire = async (input) => { const acquisition = await acquire(input) - await adapter.drainStartup(input.identity.sessionId) + await adapter.awaitStarted(input.identity.sessionId) return acquisition } return adapter @@ -297,29 +324,3 @@ export function recordingJournalSink(): StructuredAgentSessionEventSink { export function tick(): Promise { return new Promise((resolve) => setImmediate(resolve)) } - -export function invokeCanUseTool( - connection: FakeConnection, - toolName: string, - requestId: string, - toolUseID: string, - extra: { - input?: Record - suggestions?: unknown[] - signal?: AbortSignal - } = {} -): { promise: Promise; settled: () => boolean } { - const options = { - requestId, - toolUseID, - signal: extra.signal ?? new AbortController().signal, - ...(extra.suggestions ? { suggestions: extra.suggestions } : {}) - } as unknown as Parameters>[2] - let done = false - const promise = Promise.resolve( - connection.handlers.canUseTool?.(toolName, extra.input ?? {}, options) - ).finally(() => { - done = true - }) - return { promise, settled: () => done } -} diff --git a/src/main/claude/claude-subagent-group-row.ts b/src/main/claude/claude-subagent-group-row.ts index ab44350606e..8be89d15e19 100644 --- a/src/main/claude/claude-subagent-group-row.ts +++ b/src/main/claude/claude-subagent-group-row.ts @@ -46,7 +46,7 @@ export function writeClaudeSubagentGroupRow( group: RosterGroup ): void { const agents = [...group.entries.values()].map((tracked) => tracked.entry) - const options = { coalescingKey: `claude-subagents:${group.groupId}` } + const options = { coalescingKey: `claude-subagents:${group.groupId}`, turnScope: group.turnScope } if (agents.length === 0) { // The row's last child turned out not to be a subagent. An empty roster is // not a roster of nothing, so the row goes rather than reading "Ran 0". diff --git a/src/main/claude/claude-subagent-roster-state.ts b/src/main/claude/claude-subagent-roster-state.ts index f7fc3374661..dcaa72c3000 100644 --- a/src/main/claude/claude-subagent-roster-state.ts +++ b/src/main/claude/claude-subagent-roster-state.ts @@ -1,4 +1,7 @@ -import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import type { + AgentJournalItemIdentity, + AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' import type { ClaudeSubagentTaskFrame } from './claude-subagent-task-frames' @@ -22,6 +25,8 @@ export type TrackedEntry = { export type RosterGroup = { groupId: string identity: AgentJournalItemIdentity + /** The spawning turn's scope: the row reports its children beside that turn's work. */ + turnScope: AgentJournalTurnScope /** Insertion order is the display order; the map holds the state. */ entries: Map /** Lifetime admissions bound retained labels even when entries are removed. */ diff --git a/src/main/claude/claude-subagent-roster.test.ts b/src/main/claude/claude-subagent-roster.test.ts index da1f16d0a0e..0d5747d2f4f 100644 --- a/src/main/claude/claude-subagent-roster.test.ts +++ b/src/main/claude/claude-subagent-roster.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody, @@ -43,6 +44,7 @@ function harness(groupKey: string | null = TURN_1) { const roster = new ClaudeSubagentRoster({ sink, currentGroupKey: () => key, + currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE, now: () => (clock += 1) }) const roles = (): NativeChatSubagentEntry[] => agentsOf(items.at(-1)?.body) @@ -464,7 +466,11 @@ describe('ClaudeSubagentRoster — through the real sink queue', () => { published += 1 } }) - const roster = new ClaudeSubagentRoster({ sink: deferred.sink, currentGroupKey: () => TURN_1 }) + const roster = new ClaudeSubagentRoster({ + sink: deferred.sink, + currentGroupKey: () => TURN_1, + currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE + }) // The first append is in flight while the rest are submitted, so a publish // sharing the row's coalescing key would evict them. diff --git a/src/main/claude/claude-subagent-roster.ts b/src/main/claude/claude-subagent-roster.ts index d45a17e15e9..be6274ce054 100644 --- a/src/main/claude/claude-subagent-roster.ts +++ b/src/main/claude/claude-subagent-roster.ts @@ -11,6 +11,7 @@ // child on every resume. Outcomes latch within an invocation; a new spawn // alias can reopen it, and authoritative evidence can correct lost contact. +import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import { canReplaceSubagentState, isTerminalSubagentState @@ -46,6 +47,8 @@ export type ClaudeSubagentRosterDeps = { sink: StructuredAgentSessionEventSink /** The turn that owns children spawned right now; null outside any turn. */ currentGroupKey: () => string | null + /** That turn's scope, which the roster row it spawns belongs to. */ + currentTurnScope: () => AgentJournalTurnScope /** Whether a tool id was forwarded at the TOP level. A child parented to one * was spawned by a call the transcript shows, so its announcement is still * expected; a child parented to anything else names an id that only ever @@ -364,6 +367,7 @@ export class ClaudeSubagentRoster { const group: RosterGroup = { groupId, identity: claudeSubagentGroupIdentity(groupId), + turnScope: this.deps.currentTurnScope(), entries: new Map(), admittedEntries: 0, claimedLabels: new Set(), diff --git a/src/main/claude/claude-supervised-stop-real-cli.test.ts b/src/main/claude/claude-supervised-stop-real-cli.test.ts new file mode 100644 index 00000000000..82432e3fd6e --- /dev/null +++ b/src/main/claude/claude-supervised-stop-real-cli.test.ts @@ -0,0 +1,244 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' +import { randomUUID } from 'node:crypto' +import { afterEach, describe, expect, it } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { resolveSessionFilePath } from '../native-chat/session-file-resolver' +import { SUPERVISED_GRACEFUL_EXIT_MS } from './claude-child-exit-proof-ladder' +import { + openClaudeStreamJsonConnection, + type ClaudeStreamJsonConnection +} from './claude-stream-json-connection' +import { + CLAUDE_STRUCTURED_BASE_OPTIONS, + claudeStructuredPermissionOptions +} from './claude-structured-launch-resolution' + +// Opt-in only: spends a real (haiku) turn per case. Run it in an isolated HOME with +// ORCA_REAL_CLAUDE_BIN set, and ORCA_REAL_CLAUDE_SETTINGS when auth lives in a settings file. +const CLAUDE_BIN = process.env.ORCA_REAL_CLAUDE_BIN ?? '' +const enabled = + process.env.ORCA_REAL_CLAUDE_SUPERVISED_STOP === '1' && + process.platform !== 'win32' && + CLAUDE_BIN.length > 0 +const FRAMES_OUT = process.env.ORCA_REAL_CLAUDE_FRAMES_OUT +const TOOL_MARKER = 'orca_supervised_stop_probe' +const TOOL_PROMPT = `Use the Bash tool to run exactly this command, with no timeout argument, and nothing else: python3 -c "import time; time.sleep(120) # ${TOOL_MARKER}"` + +type Frame = { at: number; message: Record } +type Row = { pid: number; ppid: number; command: string } + +const recordedPids = new Set() +const tempDirs: string[] = [] +const connections: ClaudeStreamJsonConnection[] = [] + +function alive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return !(error instanceof Error && 'code' in error && error.code === 'ESRCH') + } +} + +async function processTable(): Promise { + const result = await runProcess({ program: 'ps', args: ['-axo', 'pid=,ppid=,command='] }) + return result.stdout.split('\n').flatMap((line) => { + const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line) + return match ? [{ pid: Number(match[1]), ppid: Number(match[2]), command: match[3] }] : [] + }) +} + +async function descendantsOf(rootPid: number): Promise { + const rows = await processTable() + const found: Row[] = [] + const frontier = [rootPid] + while (frontier.length > 0) { + const parent = frontier.pop() + for (const row of rows.filter((candidate) => candidate.ppid === parent)) { + found.push(row) + frontier.push(row.pid) + } + } + return found +} + +async function until(read: () => Promise | T | null, what: string, ms = 90_000) { + const deadline = Date.now() + ms + for (;;) { + const value = await read() + if (value !== null) { + return value + } + if (Date.now() >= deadline) { + throw new Error(`timed out waiting for ${what}`) + } + await new Promise((resolve) => setTimeout(resolve, 250)) + } +} + +async function open(sessionId: string, cwd: string, resume: boolean, frames: Frame[]) { + const settings = process.env.ORCA_REAL_CLAUDE_SETTINGS + const permission = claudeStructuredPermissionOptions('bypassPermissions') + const connection = await openClaudeStreamJsonConnection( + { + pathToClaudeCodeExecutable: CLAUDE_BIN, + options: { + ...CLAUDE_STRUCTURED_BASE_OPTIONS, + model: 'haiku', + extraArgs: { + ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, + ...permission.extraArgs, + ...(settings ? { settings } : {}) + }, + ...(resume ? { resume: sessionId } : { sessionId }) + }, + cwd + }, + { onMessage: (message) => frames.push({ at: Date.now(), message }) } + ) + connections.push(connection) + recordedPids.add(connection.pid!) + return connection +} + +function userMessage(text: string): Record { + return { + type: 'user', + message: { role: 'user', content: [{ type: 'text', text }] }, + parent_tool_use_id: null, + session_id: '' + } +} + +async function transcriptLines(sessionId: string): Promise { + const configDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') + const path = await until( + () => + resolveSessionFilePath('claude', sessionId, { + claudeProjectsDir: join(configDir, 'projects') + }), + 'the transcript', + 15_000 + ) + return readFileSync(path, 'utf8') +} + +function expectLineAtomic(contents: string): void { + expect(contents.length).toBeGreaterThan(0) + expect(contents.endsWith('\n')).toBe(true) + for (const line of contents.split('\n').filter((entry) => entry.trim())) { + expect(() => JSON.parse(line)).not.toThrow() + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function summarize(frames: Frame[], since: number): string[] { + return frames + .filter((frame) => frame.at >= since) + .map(({ at, message }) => { + const inner = isRecord(message.message) ? message.message.content : undefined + const blocks = (Array.isArray(inner) ? inner : []) + .filter(isRecord) + .map((block) => + block.type === 'tool_result' + ? `tool_result(is_error=${String(block.is_error)}:${JSON.stringify(block.content).slice(0, 80)})` + : String(block.type) + ) + return `+${at - since}ms ${String(message.type)}/${String(message.subtype ?? '')}${ + message.type === 'result' ? `(is_error=${String(message.is_error)})` : '' + } ${blocks.join(',')}`.trim() + }) +} + +async function resumeAndAsk(sessionId: string, cwd: string): Promise { + const frames: Frame[] = [] + const resumed = await open(sessionId, cwd, true, frames) + await resumed.send(userMessage('In one short line: what command did I ask you to run?')) + const result = await until( + () => frames.find((frame) => frame.message.type === 'result')?.message ?? null, + 'the resumed turn result' + ) + expect(result.subtype).toBe('success') + await expect(resumed.close()).resolves.toBe(true) +} + +type StopCase = 'close mid-tool' | 'SIGTERM to the supervisor mid-tool' | 'close while idle' + +afterEach(async () => { + for (const pid of recordedPids) { + if (alive(pid)) { + process.kill(pid, 'SIGKILL') + } + } + recordedPids.clear() + connections.splice(0) + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe.runIf(enabled)('real Claude stopped through the POSIX supervisor', () => { + it.each(['close mid-tool', 'SIGTERM to the supervisor mid-tool', 'close while idle'])( + '%s: stops Claude and its tool, keeps the transcript line-atomic, and resumes', + async (stopCase) => { + const cwd = mkdtempSync(join(tmpdir(), 'orca-real-claude-stop-')) + tempDirs.push(cwd) + const sessionId = randomUUID() + const frames: Frame[] = [] + const connection = await open(sessionId, cwd, false, frames) + const supervisor = connection.pid! + const midTool = stopCase !== 'close while idle' + await connection.send(userMessage(midTool ? TOOL_PROMPT : 'Reply with the single word: ok')) + const descendants = midTool + ? await until(async () => { + const rows = await descendantsOf(supervisor) + return rows.some((row) => row.command.includes(TOOL_MARKER)) ? rows : null + }, 'the Bash tool').catch((error: unknown) => { + console.log('[no tool] frames so far:', summarize(frames, 0)) + throw error + }) + : await until( + async () => + frames.some((frame) => frame.message.type === 'result') + ? await descendantsOf(supervisor) + : null, + 'the idle turn' + ) + for (const row of descendants) { + recordedPids.add(row.pid) + } + + const signalledAt = Date.now() + if (stopCase === 'SIGTERM to the supervisor mid-tool') { + process.kill(supervisor, 'SIGTERM') + } else { + await expect(connection.close()).resolves.toBe(true) + } + const gone = await until( + () => ([supervisor, ...descendants.map((row) => row.pid)].some(alive) ? null : true), + 'the supervisor, Claude and its tools to exit', + SUPERVISED_GRACEFUL_EXIT_MS + 2_000 + ) + const stoppedMs = Date.now() - signalledAt + const after = summarize(frames, signalledAt) + console.log(`[${stopCase}] stopped in ${stoppedMs} ms; frames after the stop:`, after) + if (FRAMES_OUT) { + writeFileSync( + `${FRAMES_OUT}.${stopCase.replaceAll(' ', '-')}.json`, + JSON.stringify({ stoppedMs, after }, null, 2) + ) + } + expect(gone).toBe(true) + + expectLineAtomic(await transcriptLines(sessionId)) + await resumeAndAsk(sessionId, cwd) + expectLineAtomic(await transcriptLines(sessionId)) + }, + 180_000 + ) +}) diff --git a/src/main/claude/claude-supervised-stop.integration.test.ts b/src/main/claude/claude-supervised-stop.integration.test.ts new file mode 100644 index 00000000000..d0a5f9ea0cc --- /dev/null +++ b/src/main/claude/claude-supervised-stop.integration.test.ts @@ -0,0 +1,225 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { SpawnOptions as SdkSpawnOptions } from '@anthropic-ai/claude-agent-sdk' +import { spawnProcess } from '../../shared/child-process/run-process' +import type { ProcessSpec } from '../../shared/child-process/process-spec' +import { + PROVIDER_SIGTERM_GRACE_MS, + PROVIDER_STDIN_END_GRACE_MS, + PROVIDER_SUPERVISOR_MAX_STOP_MS +} from '../codex/codex-app-server-posix-supervisor' +import { proveClaudeChildExit } from './claude-agent-sdk-exit-proof' +import { createClaudeCodeProcessSpawn } from './claude-agent-sdk-process-spawn' + +// Stands in for Claude mid-turn: stdin end does not stop it, the way EOF lets the real CLI finish +// its turn. Its tool leads its own group, as the CLI's Bash tool shells do, and only Claude's own +// SIGTERM handler reaps it. The daemon double-forks out of the tree before anything looks. +const FAKE_CLAUDE = String.raw` + const { spawn } = require('node:child_process') + const { writeFileSync } = require('node:fs') + process.stdin.resume() + process.stdout.on('error', () => {}) + const tool = spawn(process.execPath, ['-e', 'setInterval(() => {}, 60000)'], { + detached: true, + stdio: 'ignore' + }) + const forker = spawn( + process.execPath, + ['-e', "const d = require('node:child_process').spawn(process.execPath, ['-e', 'setInterval(() => {}, 60000)'], { detached: true, stdio: 'ignore' }); d.unref(); process.stdout.write(String(d.pid))"], + { stdio: ['ignore', 'pipe', 'ignore'] } + ) + let daemon = '' + forker.stdout.on('data', (chunk) => { daemon += chunk }) + forker.once('exit', () => { + process.stdout.write(JSON.stringify({ claude: process.pid, tool: tool.pid, daemon: Number(daemon) }) + '\n') + }) + process.on('SIGTERM', () => { + if (process.env.ORCA_TEST_CLAUDE_IGNORES_SIGTERM) return + try { process.kill(-tool.pid, 'SIGKILL') } catch {} + writeFileSync(process.env.ORCA_TEST_SIGTERM_MARKER, 'reaped') + process.exit(143) + }) + setInterval(() => {}, 60000) +` + +// Stands in for Orca's main: starts exactly the spec Claude's spawner built, then can be SIGKILLed. +const OWNER = String.raw` + const { spawn } = require('node:child_process') + const spec = JSON.parse(process.env.ORCA_TEST_SPAWN_SPEC) + const env = { ...spec.env } + if (env.ORCA_PROVIDER_SUPERVISOR_SPEC) { + const supervisor = JSON.parse(Buffer.from(env.ORCA_PROVIDER_SUPERVISOR_SPEC, 'base64').toString()) + supervisor.ownerPid = process.pid + env.ORCA_PROVIDER_SUPERVISOR_SPEC = Buffer.from(JSON.stringify(supervisor)).toString('base64') + } + const child = spawn(spec.program, spec.args, { + cwd: spec.cwd, + env, + detached: spec.detached, + stdio: ['pipe', 'pipe', 'ignore'] + }) + process.stdout.write(JSON.stringify({ root: child.pid }) + '\n') + child.stdout.pipe(process.stdout) + setInterval(() => {}, 60000) +` + +const recordedPids = new Set() +const tempDirs: string[] = [] + +function alive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return !(error instanceof Error && 'code' in error && error.code === 'ESRCH') + } +} + +async function waitFor(predicate: () => boolean, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (Date.now() >= deadline) { + return false + } + await new Promise((resolve) => setTimeout(resolve, 25)) + } + return true +} + +function readPids(child: ChildProcess, keys: readonly string[]): Promise> { + return new Promise((resolve, reject) => { + const pids: Record = {} + let buffered = '' + const timeout = setTimeout(() => reject(new Error(`no ${keys.join('/')} pids`)), 10_000) + const onData = (chunk: Buffer): void => { + buffered += chunk.toString() + const lines = buffered.split('\n') + buffered = lines.pop() ?? '' + for (const line of lines) { + const parsed: unknown = JSON.parse(line) + for (const [key, pid] of Object.entries(parsed ?? {})) { + if (typeof pid === 'number' && pid > 0) { + pids[key] = pid + recordedPids.add(pid) + } + } + } + if (keys.every((key) => key in pids)) { + clearTimeout(timeout) + child.stdout!.off('data', onData) + child.stdout!.resume() + resolve(pids) + } + } + child.stdout!.on('data', onData) + }) +} + +function sdkOptions(env: Record): SdkSpawnOptions { + const dir = mkdtempSync(join(tmpdir(), 'orca-claude-supervised-')) + tempDirs.push(dir) + return { + command: process.execPath, + args: ['-e', FAKE_CLAUDE], + cwd: dir, + env: { + ...process.env, + ORCA_TEST_SIGTERM_MARKER: join(dir, 'sigterm-reap'), + ...env + }, + signal: new AbortController().signal + } +} + +/** Claude spawned through Orca's own spawner and stopped by Orca's own close ladder. */ +async function spawnClaude(env: Record = {}) { + const spawner = createClaudeCodeProcessSpawn(spawnProcess) + const options = sdkOptions(env) + const child = spawner.spawn(options) + recordedPids.add(child.pid!) + let exited = false + const exit = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => + child.once('exit', (code, signal) => { + exited = true + resolve({ code, signal }) + }) + ) + const pids = await readPids(child, ['claude', 'tool', 'daemon']) + const close = (): Promise => + proveClaudeChildExit({ + child, + exitPromise: exit.then(() => undefined), + exited: () => exited, + supervised: spawner.supervised + }) + return { child, exit, pids, close, marker: String(options.env.ORCA_TEST_SIGTERM_MARKER) } +} + +afterEach(() => { + for (const pid of recordedPids) { + if (alive(pid)) { + process.kill(pid, 'SIGKILL') + } + } + recordedPids.clear() + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe.runIf(process.platform !== 'win32')('Claude under the POSIX provider supervisor', () => { + it('stops a mid-turn Claude on close instead of letting stdin end finish its turn', async () => { + const { child, exit, pids, close, marker } = await spawnClaude() + expect(child.pid).not.toBe(pids.claude) + + const startedAt = Date.now() + await expect(close()).resolves.toBe(true) + + // Claude's own SIGTERM reap ran at once, not after the supervisor's stdin-end grace. + expect(Date.now() - startedAt).toBeLessThan(PROVIDER_STDIN_END_GRACE_MS) + expect(existsSync(marker)).toBe(true) + await expect(exit).resolves.toEqual({ code: null, signal: 'SIGTERM' }) + expect(alive(pids.claude)).toBe(false) + expect(alive(pids.tool)).toBe(false) + }) + + it('lets the supervisor escalate a Claude that ignores SIGTERM, and exits only after it', async () => { + const { exit, pids, close } = await spawnClaude({ ORCA_TEST_CLAUDE_IGNORES_SIGTERM: '1' }) + + const startedAt = Date.now() + await expect(close()).resolves.toBe(true) + + const elapsed = Date.now() - startedAt + expect(elapsed).toBeGreaterThanOrEqual(PROVIDER_SIGTERM_GRACE_MS) + expect(elapsed).toBeLessThan(PROVIDER_SUPERVISOR_MAX_STOP_MS + 1_000) + // The supervisor's own SIGTERM stop finished the job; nothing forced the supervisor itself. + await expect(exit).resolves.toEqual({ code: null, signal: 'SIGTERM' }) + expect(alive(pids.claude)).toBe(false) + }) + + it("stops Claude and its tool when Orca's main dies, and leaves a daemon that left its tree alone", async () => { + const specs: ProcessSpec[] = [] + createClaudeCodeProcessSpawn((spec) => { + specs.push(spec) + return spawnProcess({ program: 'true' }) + }).spawn(sdkOptions({})) + const owner = spawn(process.execPath, ['-e', OWNER], { + env: { ...process.env, ORCA_TEST_SPAWN_SPEC: JSON.stringify(specs[0]) }, + stdio: ['ignore', 'pipe', 'ignore'] + }) + recordedPids.add(owner.pid!) + const pids = await readPids(owner, ['root', 'claude', 'tool', 'daemon']) + + owner.kill('SIGKILL') + + expect(await waitFor(() => !alive(pids.claude), PROVIDER_SUPERVISOR_MAX_STOP_MS)).toBe(true) + expect(await waitFor(() => !alive(pids.root), PROVIDER_SUPERVISOR_MAX_STOP_MS)).toBe(true) + // In its own group, so only Claude's SIGTERM handler could have reaped it. + expect(alive(pids.tool)).toBe(false) + // Not the conversation's writer: nothing proves it orphaned, so the stop never reaches it. + expect(alive(pids.daemon)).toBe(true) + }) +}) diff --git a/src/main/claude/claude-turn-lifecycle-item.ts b/src/main/claude/claude-turn-lifecycle-item.ts index 2a31b49afdb..be7150531fe 100644 --- a/src/main/claude/claude-turn-lifecycle-item.ts +++ b/src/main/claude/claude-turn-lifecycle-item.ts @@ -7,6 +7,7 @@ import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' import type { StructuredAgentSessionAppendOptions } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { claudeResultOutcome } from './claude-result-outcome' +import type { ClaudeCommandTurn } from './claude-command-turn' export type ClaudeCurrentTurn = { sessionId: string @@ -18,6 +19,13 @@ export type ClaudeCurrentTurn = { /** Provider key of the user echo, or the lifecycle row itself when provider * output opened a turn with no user row to receive its timing. */ userItemId: string + /** Present when the turn is the host's record of a conversation command. */ + command?: ClaudeCommandTurn +} + +/** The row a turn's lifecycle lives on: the host's record for a command, else the lane's own. */ +export function claudeCurrentTurnIdentity(turn: ClaudeCurrentTurn): AgentJournalItemIdentity { + return turn.command?.identity ?? claudeTurnLifecycleIdentity(turn.sessionId, turn.turnId) } export type ClaudeTurnEnd = { @@ -84,7 +92,7 @@ export function claudeTurnLifecycleItem( // already carried, because both are built from the same open turn. const requested = requestedAt === undefined ? {} : { requestedAt } return { - identity: claudeTurnLifecycleIdentity(sessionId, turnId), + identity: claudeCurrentTurnIdentity(turn), body: agentJournalTurnBody( end ? { diff --git a/src/main/claude/claude-turn-ownership.test.ts b/src/main/claude/claude-turn-ownership.test.ts index 338a6937716..c1915d9bf94 100644 --- a/src/main/claude/claude-turn-ownership.test.ts +++ b/src/main/claude/claude-turn-ownership.test.ts @@ -6,7 +6,6 @@ import type { AgentJournalItemBody } from '../../shared/agent-session-journal-ty import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' import { CLAUDE_DISPATCH_ADMISSION_TIMEOUT_MS, cancelClaudeStructuredTurn @@ -96,8 +95,13 @@ function sessionHoldingTurn(turnId: string | null): ReturnType true }) } @@ -277,8 +280,13 @@ describe('Claude turn ownership', () => { session.dispatchSequence = 1 session.translator = { handle: vi.fn(), + openTurnInLiveProviderCycle: false, journalPrompts: { cancel: vi.fn(), resolve: vi.fn() }, currentTurnId: 'turn-1', + commandTurnId: null, + beginCommand: vi.fn(), + forgetCommand: vi.fn(), + commandInterruptRequested: vi.fn(), flush: vi.fn(), contextActivity: 0, markContextActivity: vi.fn(), @@ -306,7 +314,6 @@ describe('Claude turn ownership', () => { const cancellation = cancelClaudeStructuredTurn({ request: { sessionId: 'session-1', turnId: 'turn-1', fence: 1 }, sessions: new Map([['session-1', session]]), - compactions: new StructuredSessionCompaction(), admitPromptCancellation: () => true }) await vi.advanceTimersByTimeAsync(100) diff --git a/src/main/claude/claude-turn-row-revision.ts b/src/main/claude/claude-turn-row-revision.ts index 1bee6bd0162..1b684ba89aa 100644 --- a/src/main/claude/claude-turn-row-revision.ts +++ b/src/main/claude/claude-turn-row-revision.ts @@ -14,9 +14,10 @@ import { agentJournalItemKey, parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import type { - AgentJournalItemIdentity, - AgentJournalTurnItem +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalTurnItem } from '../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { estimateStructuredAgentSessionItemBytes } from '../native-chat/agent-session-wire/structured-agent-session-event-sink-estimate' @@ -149,7 +150,7 @@ function findTurnRow( export type ClaudeTurnRowDelivery = { /** False only for a writer that publishes each write itself right after queueing it. */ publish: boolean - options?: StructuredAgentSessionRevisionOptions + options?: Omit } /** @@ -161,8 +162,10 @@ export function writeClaudeTurnRow( sink: StructuredAgentSessionEventSink, target: ClaudeTurnRowTarget, write: ClaudeTurnRowWrite, - { publish, options = {} }: ClaudeTurnRowDelivery + { publish, options: delivery = {} }: ClaudeTurnRowDelivery ): void { + // A turn record belongs to no turn. + const options = { ...delivery, turnScope: AGENT_JOURNAL_THREAD_SCOPE } const revise = publish ? sink.tryReviseResolvedItemAndPublish : sink.tryReviseResolvedItem if (!revise) { if (write.lifecycle && 'identity' in target) { diff --git a/src/main/claude/compact-status-registration.test.ts b/src/main/claude/compact-status-registration.test.ts index 2c54cc8db62..225a52423c3 100644 --- a/src/main/claude/compact-status-registration.test.ts +++ b/src/main/claude/compact-status-registration.test.ts @@ -12,7 +12,9 @@ import { import { seedClaudeSubagentRosterFromSnapshots } from '../../shared/agent-hook-listener/providers/claude-roster-state' import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' import { makePaneKey } from '../../shared/stable-pane-id' -import { applyManagedHooks, CLAUDE_EVENTS } from './hook-settings' +import { CLAUDE_HOOK_EVENT_FIRST_VERSIONS } from './claude-hook-event-versions' +import { CLAUDE_EVENTS, getClaudeManagedHookPlan } from './claude-managed-hook-events' +import { applyManagedHooks } from './hook-settings' const PANE_KEY = makePaneKey('compact-registration', '11111111-1111-4111-8111-111111111111') const TURN_PROMPT_ID = '22222222-2222-4222-8222-222222222222' @@ -108,7 +110,8 @@ describe('Claude compact hook registration', () => { const written = applyManagedHooks( { hooks: {} }, { type: 'command', command: 'orca-claude-hook' }, - 'claude-hook.sh' + 'claude-hook.sh', + getClaudeManagedHookPlan(CLAUDE_HOOK_EVENT_FIRST_VERSIONS.PostCompact) ) const postCompact = written.hooks?.PostCompact ?? [] expect( diff --git a/src/main/claude/hook-script.ts b/src/main/claude/hook-script.ts index efbe71fc9e3..e02fe374701 100644 --- a/src/main/claude/hook-script.ts +++ b/src/main/claude/hook-script.ts @@ -1,6 +1,7 @@ /** The managed Claude-compatible hook script, built for local, POSIX-remote and Windows targets. * Split from hook-service.ts so the service owns install/status and this owns script text, * mirroring the same split under src/main/cursor/. */ +import type { AgentHookSource } from '../../shared/agent-hook-relay' import { buildWindowsAgentHookCurlPostCommand } from '../agent-hooks/installer-utils' import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command' import { @@ -18,10 +19,12 @@ import { export function getManagedScript( target: 'local' | 'posix' = 'local', options: { + source?: AgentHookSource skipWhenDevinImportsClaude?: boolean skipWhenGrokImportsClaude?: boolean } = {} ): string { + const source = options.source ?? 'claude' if (target === 'local' && process.platform === 'win32') { return [ '@echo off', @@ -46,7 +49,7 @@ export function getManagedScript( ] : []), // Why: use curl.exe to avoid an extra PowerShell startup per hook. - buildWindowsAgentHookCurlPostCommand('claude'), + buildWindowsAgentHookCurlPostCommand(source), 'exit /b 0', ...buildWindowsHookStdinDrainEpilogue(), '' @@ -59,7 +62,7 @@ export function getManagedScript( 'printf "{}\\n"', ...buildPosixHookPayloadCapture(), ...(options.skipWhenGrokImportsClaude ? buildPosixGrokReplayGuardLines() : []), - ...buildPosixHookSpoolLines('claude'), + ...buildPosixHookSpoolLines(source), ...(options.skipWhenDevinImportsClaude ? [ // Why: Devin imports .claude hooks by default; skip Orca's managed hook there so status posts stay attributed to Devin. @@ -84,7 +87,7 @@ export function getManagedScript( ' exit 0', 'fi', // Why: keep full hook JSON off the command line and avoid IDS-friendly URL-encoded paths. - ...buildPosixAgentHookPostCommand('claude').map((line, index, lines) => + ...buildPosixAgentHookPostCommand(source).map((line, index, lines) => index === lines.length - 1 ? `${line} >/dev/null 2>&1 || spool_hook_event` : line ), 'exit 0', diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index 2a07aff4c8a..c9191d42dbd 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -27,11 +27,8 @@ import type { SFTPWrapper } from 'ssh2' import { createManagedCommandMatcher, WINDOWS_CMD_SAFE_PATH } from '../agent-hooks/installer-utils' import { WINDOWS_HOOK_STDIN_DRAIN_LABEL } from '../agent-hooks/hook-stdin-contract' import { ClaudeHookService } from './hook-service' -import { - CLAUDE_EVENTS, - getWindowsManagedLifecycleHook, - OPENCLAUDE_HOOK_SETTINGS -} from './hook-settings' +import { CLAUDE_EVENTS } from './claude-managed-hook-events' +import { getWindowsManagedLifecycleHook, OPENCLAUDE_HOOK_SETTINGS } from './hook-settings' const CLAUDE_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh' const STATUSLINE_SCRIPT_FILE_NAME = @@ -43,6 +40,9 @@ const isOpenClaudeManagedCommand = createManagedCommandMatcher(OPENCLAUDE_SCRIPT type TestHook = { command: string; args?: string[] } +// Why: the managed event set follows the resolved Claude; this one knows every event Orca writes. +const CURRENT_CLAUDE = { claudeVersion: '2.1.261 (Claude Code)' } + function hasManagedCommand(hook: TestHook, matcher: (command: string | undefined) => boolean) { return matcher(hook.command) || hook.args?.some(matcher) === true } @@ -217,7 +217,7 @@ describe('ClaudeHookService.install', () => { }) ) - const status = new ClaudeHookService().install() + const status = new ClaudeHookService().install(CURRENT_CLAUDE) expect(status.state).toBe('installed') const legacy = JSON.parse(readFileSync(legacyPath, 'utf-8')) @@ -275,6 +275,48 @@ describe('ClaudeHookService.install', () => { } }) + it('writes only the events an old resolved Claude knows and reports that as installed', () => { + const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-old-version-')) + vi.stubEnv('HOME', tmpHome) + vi.stubEnv('USERPROFILE', tmpHome) + try { + const service = new ClaudeHookService() + expect(service.install({ claudeVersion: '2.1.32' }).state).toBe('installed') + + const settings = JSON.parse(readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8')) + for (const event of ['StopFailure', 'PostCompact', 'TeammateIdle', 'SessionEnd']) { + expect(settings.hooks[event], event).toBeUndefined() + } + expect(settings.hooks.SubagentStart).toBeDefined() + // Why: a reader without the version still sees a complete install of the universal set. + expect(service.getStatus().state).toBe('installed') + } finally { + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + } + }) + + it('keeps newer Orca events and reports installed when a later probe cannot resolve the version', () => { + const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-unknown-version-')) + vi.stubEnv('HOME', tmpHome) + vi.stubEnv('USERPROFILE', tmpHome) + try { + const service = new ClaudeHookService() + const settingsPath = join(tmpHome, '.claude', 'settings.json') + expect(service.install(CURRENT_CLAUDE).state).toBe('installed') + const known = JSON.parse(readFileSync(settingsPath, 'utf-8')) + + expect(service.install().state).toBe('installed') + + const unknown = JSON.parse(readFileSync(settingsPath, 'utf-8')) + expect(unknown.hooks).toEqual(known.hooks) + expect(service.getStatus().state).toBe('installed') + } finally { + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + } + }) + it('installs the managed statusLine command and forwards rate_limits posts', () => { const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-statusline-')) vi.stubEnv('HOME', tmpHome) @@ -458,7 +500,7 @@ describe('ClaudeHookService.install', () => { return } try { - expect(new ClaudeHookService().install().state).toBe('installed') + expect(new ClaudeHookService().install(CURRENT_CLAUDE).state).toBe('installed') const settings = JSON.parse( readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8') @@ -472,7 +514,7 @@ describe('ClaudeHookService.install', () => { } // Why: the whole point of #18875 — no interpreter is started to reach the script. expect(JSON.stringify(settings.hooks)).not.toMatch(/powershell|EncodedCommand/i) - expect(new ClaudeHookService().getStatus().state).toBe('installed') + expect(new ClaudeHookService().getStatus(CURRENT_CLAUDE).state).toBe('installed') } finally { vi.unstubAllEnvs() rmSync(tmpHome, { recursive: true, force: true }) @@ -681,7 +723,7 @@ describe('ClaudeHookService.installRemote', () => { it('writes Claude settings + managed script under the remote $HOME', async () => { const svc = new ClaudeHookService() const { sftp, fs } = createFakeSftp() - const status = await svc.installRemote(sftp, '/home/dev') + const status = await svc.installRemote(sftp, '/home/dev', CURRENT_CLAUDE) expect(status.state).toBe('installed') expect(status.configPath).toBe('/home/dev/.claude/settings.json') const settings = fs.files.get('/home/dev/.claude/settings.json') @@ -732,6 +774,25 @@ describe('ClaudeHookService.installRemote', () => { expect(fs.files.get('/home/dev/.orca/agent-hooks/claude-statusline.sh')).toBeUndefined() }) + it('writes only the events the remote Claude knows, adding newer ones once it upgrades', async () => { + const svc = new ClaudeHookService() + const { sftp, fs } = createFakeSftp() + const settingsPath = '/home/dev/.claude/settings.json' + const userStop = { hooks: [{ type: 'command', command: '/usr/local/bin/my-user-hook' }] } + fs.files.set(settingsPath, JSON.stringify({ env: { A: '1' }, hooks: { Stop: [userStop] } })) + + await svc.installRemote(sftp, '/home/dev', { claudeVersion: '2.1.77' }) + const old = JSON.parse(fs.files.get(settingsPath)!) + expect(old.hooks.StopFailure).toBeUndefined() + expect(old.hooks.PostCompact).toBeDefined() + + await svc.installRemote(sftp, '/home/dev', { claudeVersion: '2.1.78' }) + const upgraded = JSON.parse(fs.files.get(settingsPath)!) + expect(upgraded.hooks.StopFailure[0].hooks[0].command).toContain('claude-hook.sh') + expect(upgraded.hooks.Stop[0]).toEqual(userStop) + expect(upgraded.env).toEqual({ A: '1' }) + }) + it('reports parse error when remote settings.json cannot be parsed', async () => { const svc = new ClaudeHookService() const { sftp, fs } = createFakeSftp() diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 73e7add40dc..bc27b56075e 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -1,4 +1,5 @@ import { existsSync, rmSync, writeFileSync } from 'node:fs' +import type { AgentHookSource } from '../../shared/agent-hook-relay' import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' import { @@ -21,7 +22,6 @@ import { getManagedStatusLineScript } from './statusline-script' import { applyManagedHooks, applyManagedStatusLine, - CLAUDE_EVENTS, CLAUDE_HOOK_SETTINGS, getManagedScriptFileName, getConfigPath, @@ -40,11 +40,19 @@ import { removeManagedStatusLine, type ClaudeCompatibleHookSettings } from './hook-settings' +import { + getClaudeManagedHookPlan, + OPENCLAUDE_MANAGED_HOOK_PLAN, + type ClaudeManagedHookPlan +} from './claude-managed-hook-events' type ClaudeHookServiceOptions = { agent: AgentHookInstallStatus['agent'] displayName: string settings: ClaudeCompatibleHookSettings + source?: AgentHookSource + /** A Claude-compatible CLI with its own settings file writes a fixed plan, not Claude's version table. */ + hookPlan?: ClaudeManagedHookPlan } type ClaudeHookInstallOptions = { @@ -64,7 +72,15 @@ export class ClaudeHookService { this.options = options } - getStatus(): AgentHookInstallStatus { + // Why: Claude's settings loader rejects events newer than the running CLI, so its plan follows the + // resolved version; OpenClaude and Qoder read their own settings files. + private managedHookPlan(options: ClaudeHookInstallOptions): ClaudeManagedHookPlan { + return this.options.agent === 'claude' + ? getClaudeManagedHookPlan(options.claudeVersion) + : (this.options.hookPlan ?? OPENCLAUDE_MANAGED_HOOK_PLAN) + } + + getStatus(options: ClaudeHookInstallOptions = {}): AgentHookInstallStatus { const configPath = getConfigPath(this.options.settings) const scriptPath = getManagedScriptPath(this.options.settings) const config = readHooksJson(configPath) @@ -82,7 +98,7 @@ export class ClaudeHookService { const expectedHook = getManagedLifecycleHook(scriptPath, this.options.settings) const missing: string[] = [] let presentCount = 0 - for (const event of CLAUDE_EVENTS) { + for (const event of this.managedHookPlan(options).install) { const definitions = Array.isArray(config.hooks?.[event.eventName]) ? config.hooks![event.eventName]! : [] @@ -115,6 +131,7 @@ export class ClaudeHookService { await refreshManagedScriptIfPresent( getManagedScriptPath(this.options.settings), getManagedScript('local', { + source: this.options.source, skipWhenDevinImportsClaude: this.options.agent === 'claude', skipWhenGrokImportsClaude: this.options.agent === 'claude' }) @@ -141,25 +158,28 @@ export class ClaudeHookService { } const hook = getManagedLifecycleHook(scriptPath, this.options.settings) + const plan = this.managedHookPlan(options) let nextConfig = applyManagedHooks( config, hook, getManagedScriptFileName(this.options.settings), - this.options.agent === 'claude' ? options : undefined + plan ) writeManagedScript( scriptPath, getManagedScript('local', { + source: this.options.source, skipWhenDevinImportsClaude: this.options.agent === 'claude', skipWhenGrokImportsClaude: this.options.agent === 'claude' }) ) - // Why: the statusline usage feed is Claude-only — OpenClaude data would be misattributed to the Claude provider. - if (this.options.agent === 'claude') { + if (plan.statusLine === 'install') { nextConfig = this.installManagedStatusLine(nextConfig) + } else if (plan.statusLine === 'retire') { + nextConfig = this.retireManagedStatusLine(nextConfig) } writeHooksJson(configPath, nextConfig) - return this.getStatus() + return this.getStatus(options) } // Why: the statusline feed is opportunistic (usage display, not agent status); a user who deleted the @@ -186,6 +206,23 @@ export class ClaudeHookService { return next } + // Why: a Claude that predates statusLine discards the whole settings file over Orca's; dropping the + // marker with it keeps an upgrade from reading the removal as the user's opt-out. + private retireManagedStatusLine(config: HooksConfig): HooksConfig { + const { config: next, changed } = removeManagedStatusLine( + config, + getStatusLineScriptFileName(this.options.settings) + ) + if (changed) { + try { + rmSync(getStatusLineInstallMarkerPath(this.options.settings), { force: true }) + } catch { + // Best-effort: a stale marker only means one upgrade skips re-adding the statusline. + } + } + return next + } + // Why: install the Claude hook on the remote box (via SFTP); POSIX-only by design (Windows-remote deferred). async installRemote( sftp: SFTPWrapper, @@ -215,7 +252,7 @@ export class ClaudeHookService { config, hook, remoteScriptFileName, - this.options.agent === 'claude' ? options : undefined + this.managedHookPlan(options) ) // Why: write scripts before settings to avoid settings pointing to missing scripts. @@ -224,6 +261,7 @@ export class ClaudeHookService { sftp, remoteScriptPath, getManagedScript('posix', { + source: this.options.source, skipWhenDevinImportsClaude: this.options.agent === 'claude', skipWhenGrokImportsClaude: this.options.agent === 'claude' }) diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index 92ffa9606a2..381e4d26b6f 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -6,22 +6,23 @@ import { getSharedManagedScriptPath, isPlainObject, MANAGED_HOOK_TIMEOUT_SECONDS, - quotePowerShellString, removeManagedCommands, wrapWindowsPowerShellEncodedCommand, type HookCommandConfig, type HookDefinition, type HooksConfig } from '../agent-hooks/installer-utils' +import { quotePowerShellLiteral } from '../../shared/powershell-native-argument' import { wrapRuntimeHomeHookCommand } from '../agent-hooks/runtime-home-hook-command' import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' import { isGitBashAvailable } from '../git-bash' -import { claudeVersionSupportsSessionEnd } from './claude-session-end-hook-capability' +import type { ClaudeManagedHookPlan } from './claude-managed-hook-events' export type ClaudeCompatibleHookSettings = { - configDirName: '.claude' | '.openclaude' - scriptBaseName: 'claude-hook' | 'openclaude-hook' + configDirName: '.claude' | '.openclaude' | '.qoder' | '.codebuddy' + scriptBaseName: 'claude-hook' | 'openclaude-hook' | 'qoder-hook' | 'codebuddy-hook' usesWindowsCompatLauncher: boolean + windowsHookShell?: 'powershell' } export const CLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = { @@ -36,81 +37,6 @@ export const OPENCLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = { usesWindowsCompatLauncher: false } -export const CLAUDE_EVENTS = [ - // Why: SessionStart is the only event a resumed/idle session emits before the - // first prompt; without it the sidebar row can't exist until the user types (STA-3386). - { - eventName: 'SessionStart', - definition: { hooks: [{ type: 'command', command: '' }] } - }, - { - eventName: 'UserPromptSubmit', - definition: { hooks: [{ type: 'command', command: '' }] } - }, - { - eventName: 'Stop', - definition: { hooks: [{ type: 'command', command: '' }] } - }, - // Why: OpenClaude skips normal Stop hooks after API/model errors and emits - // StopFailure instead; without this hook Orca leaves the turn spinning. - { - eventName: 'StopFailure', - definition: { hooks: [{ type: 'command', command: '' }] } - }, - // Why: subagent/teammate lifecycle feeds the sidebar's child rows and keeps - // a pane 'working' while background children outlive the lead's turn. - // TeammateIdle parks turn-based teammates without trusting their permanently - // "running" background_tasks entry to gate the pane. - // Older Claude builds ignore unregistered event names (StopFailure precedent). - { - eventName: 'SubagentStart', - definition: { hooks: [{ type: 'command', command: '' }] } - }, - { - eventName: 'SubagentStop', - definition: { hooks: [{ type: 'command', command: '' }] } - }, - { - eventName: 'TeammateIdle', - definition: { hooks: [{ type: 'command', command: '' }] } - }, - // Why: PreToolUse gives the dashboard a live readout of the in-flight tool - // (name + input preview) before it completes. - { - eventName: 'PreToolUse', - definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } - }, - { - eventName: 'PostToolUse', - definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } - }, - { - eventName: 'PostToolUseFailure', - definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } - }, - { - eventName: 'PermissionRequest', - definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } - }, - // Why: a manual /compact ends at an idle prompt without emitting Stop, so PostCompact is the only - // signal that can clear the pane (STA-2915). PreCompact is deliberately NOT registered: it fires - // before the compact is validated, and an aborted compact emits it alone — mapping it to 'working' - // would strand the pane exactly as this registration is meant to prevent (STA-4613). - { - eventName: 'PostCompact', - definition: { hooks: [{ type: 'command', command: '' }] } - } -] as const - -const CLAUDE_SESSION_END_EVENT = { - eventName: 'SessionEnd', - definition: { hooks: [{ type: 'command', command: '' }] } -} as const - -export type ApplyManagedClaudeHooksOptions = { - claudeVersion?: string -} - export function getConfigPath(settings = CLAUDE_HOOK_SETTINGS): string { return join(homedir(), settings.configDirName, 'settings.json') } @@ -171,6 +97,14 @@ export function getManagedLifecycleHook( if (process.platform !== 'win32' || !settings.usesWindowsCompatLauncher) { return buildManagedCommandHook(getManagedCommand(scriptPath, { neutralJsonWhenMissing: true })) } + if (settings.windowsHookShell === 'powershell') { + return { + type: 'command', + command: getWindowsPowerShellLifecycleCommand(scriptPath), + shell: 'powershell', + timeout: MANAGED_HOOK_TIMEOUT_SECONDS + } + } return getWindowsManagedLifecycleHook(scriptPath, options) } @@ -190,19 +124,21 @@ export function getWindowsManagedLifecycleHook( if (directCommand) { return { type: 'command', command: directCommand, timeout: MANAGED_HOOK_TIMEOUT_SECONDS } } + return { + type: 'command', + command: wrapWindowsPowerShellEncodedCommand(getWindowsPowerShellLifecycleCommand(scriptPath)), + timeout: MANAGED_HOOK_TIMEOUT_SECONDS + } +} + +function getWindowsPowerShellLifecycleCommand(scriptPath: string): string { const scriptFileName = win32.basename(scriptPath) - // Why: runtime profile resolution keeps the managed entry portable across users (STA-3348). - const quotedRelativePath = quotePowerShellString(`.orca\\agent-hooks\\${scriptFileName}`) - // Why: compat consumers require neutral JSON even when the managed script is missing (#14818). - const innerCommand = + const quotedRelativePath = quotePowerShellLiteral(`.orca\\agent-hooks\\${scriptFileName}`) + return ( `$scriptPath = Join-Path $env:USERPROFILE ${quotedRelativePath}; ` + 'if (Test-Path -LiteralPath $scriptPath -PathType Leaf) { & $scriptPath; exit $LASTEXITCODE }; ' + "[Console]::In.ReadToEnd() | Out-Null; Write-Output '{}'; exit 0" - return { - type: 'command', - command: wrapWindowsPowerShellEncodedCommand(innerCommand), - timeout: MANAGED_HOOK_TIMEOUT_SECONDS - } + ) } export function hasSameManagedHookInvocation( @@ -211,6 +147,7 @@ export function hasSameManagedHookInvocation( ): boolean { return ( actual.command === expected.command && + actual.shell === expected.shell && JSON.stringify(actual.args ?? []) === JSON.stringify(expected.args ?? []) ) } @@ -222,31 +159,29 @@ export function getRemoteManagedCommand(scriptPath: string): string { export function applyManagedHooks( config: HooksConfig, hook: HookCommandConfig, - scriptFileName = getManagedScriptFileName(), - options: ApplyManagedClaudeHooksOptions = {} + scriptFileName: string, + plan: ClaudeManagedHookPlan ): HooksConfig { const nextHooks = { ...config.hooks } const isManagedCommand = createManagedCommandMatcher(scriptFileName) - const sessionEndCapable = claudeVersionSupportsSessionEnd(options.claudeVersion) - const events = sessionEndCapable ? [...CLAUDE_EVENTS, CLAUDE_SESSION_END_EVENT] : CLAUDE_EVENTS - for (const event of events) { - const current = Array.isArray(nextHooks[event.eventName]) ? nextHooks[event.eventName] : [] - const cleaned = removeManagedCommands(current, isManagedCommand) - const definition: HookDefinition = { - ...event.definition, - hooks: [hook] - } + for (const event of plan.install) { + const current = nextHooks[event.eventName] + const cleaned = Array.isArray(current) ? removeManagedCommands(current, isManagedCommand) : [] + const definition: HookDefinition = { ...event.definition, hooks: [hook] } nextHooks[event.eventName] = [...cleaned, definition] } - if (!sessionEndCapable) { - const current = Array.isArray(nextHooks.SessionEnd) ? nextHooks.SessionEnd : [] + for (const event of plan.retire) { + const current = nextHooks[event.eventName] + if (!Array.isArray(current) || current.length === 0) { + continue + } const cleaned = removeManagedCommands(current, isManagedCommand) if (cleaned.length === 0) { - delete nextHooks.SessionEnd + delete nextHooks[event.eventName] } else { - nextHooks.SessionEnd = cleaned + nextHooks[event.eventName] = cleaned } } diff --git a/src/main/cli/cli-privileged-processes.test.ts b/src/main/cli/cli-privileged-processes.test.ts index e391df0bc6b..16d59af2df9 100644 --- a/src/main/cli/cli-privileged-processes.test.ts +++ b/src/main/cli/cli-privileged-processes.test.ts @@ -4,7 +4,11 @@ const runProcessMock = vi.hoisted(() => vi.fn()) vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) -import { runMacPrivilegedCommand, runWindowsPathCommand } from './cli-privileged-processes' +import { + runMacPrivilegedCommand, + runWindowsPathCommand, + writeWindowsUserPath +} from './cli-privileged-processes' describe('Windows CLI PATH process boundary', () => { beforeEach(() => runProcessMock.mockReset()) @@ -55,6 +59,21 @@ describe('Windows CLI PATH process boundary', () => { stderr: 'UnauthorizedAccessException' }) }) + + it('doubles typographic single quotes in the PATH literal', async () => { + runProcessMock.mockResolvedValue({ + code: 0, + signal: null, + stdout: '', + stderr: '', + timedOut: false + }) + + await writeWindowsUserPath('C:\\O\u2019Brien\\bin') + expect(runProcessMock.mock.calls[0][0].args.at(-1)).toBe( + "[Environment]::SetEnvironmentVariable('Path', 'C:\\O\u2019\u2019Brien\\bin', 'User')" + ) + }) }) describe('macOS CLI privileged process boundary', () => { diff --git a/src/main/cli/cli-privileged-processes.ts b/src/main/cli/cli-privileged-processes.ts index 70e9e3cfb70..89acc2542e0 100644 --- a/src/main/cli/cli-privileged-processes.ts +++ b/src/main/cli/cli-privileged-processes.ts @@ -1,4 +1,5 @@ import { runProcess } from '../../shared/child-process/run-process' +import { quotePowerShellLiteral } from '../../shared/powershell-native-argument' import { WINDOWS_PATH_WRITE_TIMEOUT_MS } from './cli-install-constants' export async function runMacPrivilegedCommand(command: string): Promise { @@ -21,7 +22,7 @@ export async function writeWindowsUserPath(value: string): Promise { await runWindowsPathCommand([ '-NoProfile', '-Command', - `[Environment]::SetEnvironmentVariable('Path', ${quotePowerShell(value)}, 'User')` + `[Environment]::SetEnvironmentVariable('Path', ${quotePowerShellLiteral(value)}, 'User')` ]) } @@ -49,7 +50,3 @@ function processFailure( Object.assign(error, { code: result.code, stderr: result.stderr }) return error } - -function quotePowerShell(value: string): string { - return `'${value.replaceAll("'", "''")}'` -} diff --git a/src/main/cli/orca-cli-child-path.ts b/src/main/cli/orca-cli-child-path.ts index f053e54c3d5..2a8136c367f 100644 --- a/src/main/cli/orca-cli-child-path.ts +++ b/src/main/cli/orca-cli-child-path.ts @@ -17,6 +17,8 @@ import { delimiter, join } from 'node:path' import { readInheritedPath } from '../ipc/pty/host-env/path' import { resolvePathEnvKey } from '../pty/windows-environment-path' import { ensureLinuxTerminalOrcaCliShimDir } from './linux-terminal-orca-cli-shim' +import { getBundledLauncherPath } from './bundled-cli-launcher-path' +import { DEV_COMMAND_NAME } from './cli-install-constants' export type OrcaCliChildPathOptions = { isPackaged: boolean @@ -26,11 +28,16 @@ export type OrcaCliChildPathOptions = { platform?: NodeJS.Platform } -/** Mutates `env` in place, prepending the directory that makes bare `orca` this app's CLI. */ +/** + * Mutates `env` in place, prepending the directory that makes bare `orca` this app's CLI. Returns + * the absolute launcher in that directory, or null when none was prepended: a child whose shell + * rebuilds PATH (a login shell reordering it behind a global install) can still name this app's + * CLI by path. + */ export function prependOrcaCliDirToChildPath( env: Record, opts: OrcaCliChildPathOptions -): void { +): string | null { const platform = opts.platform ?? process.platform // Why: matches node:path's `delimiter` for the running platform, but stays correct when a test // drives a foreign platform through the seam. @@ -43,6 +50,7 @@ export function prependOrcaCliDirToChildPath( env[resolvePathEnvKey(env, platform)] = inheritedPath ? `${devCliBin}${pathDelimiter}${inheritedPath}` : devCliBin + return join(devCliBin, platform === 'win32' ? `${DEV_COMMAND_NAME}.cmd` : DEV_COMMAND_NAME) } else if (platform === 'linux') { // Why: bare-`orca` shim scoped to Orca PTYs — Linux CLI installs as `orca-ide` to avoid shadowing GNOME's /usr/bin/orca screen reader (stablyai/orca#7904). const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath: opts.userDataPath }) @@ -51,6 +59,7 @@ export function prependOrcaCliDirToChildPath( .split(pathDelimiter) .filter((entry) => entry.length > 0 && entry !== shimDir) env.PATH = [shimDir, ...inheritedEntries].join(pathDelimiter) + return join(shimDir, 'orca') } } else if (opts.resourcesPath && (platform === 'darwin' || platform === 'win32')) { // Why: global CLI registration is optional, but agents in Orca-managed PTYs must always reach this app's bundled CLI. @@ -59,5 +68,8 @@ export function prependOrcaCliDirToChildPath( env[resolvePathEnvKey(env, platform)] = inheritedPath ? `${bundledCliBin}${pathDelimiter}${inheritedPath}` : bundledCliBin + // Why the native launcher on Windows: `orca.cmd` refuses message bodies cmd.exe would mangle. + return getBundledLauncherPath(platform, opts.resourcesPath) } + return null } diff --git a/src/main/cli/wsl-cli-powershell-boundary.test.ts b/src/main/cli/wsl-cli-powershell-boundary.test.ts index ddfdabe9ad3..500d1a29f8f 100644 --- a/src/main/cli/wsl-cli-powershell-boundary.test.ts +++ b/src/main/cli/wsl-cli-powershell-boundary.test.ts @@ -1,9 +1,10 @@ import { spawnSync } from 'node:child_process' -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, realpath, writeFile } from 'node:fs/promises' import { removeTree } from '../../shared/windows-transient-lock-removal' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' +import { runProcessSync } from '../../shared/child-process/run-process' import { buildWslBridgeScript, buildWslLauncher } from './wsl-cli-scripts' const FORWARDED_ARGS = [ @@ -32,6 +33,58 @@ const FORWARDED_ARGS = [ ] describe('WSL CLI PowerShell boundary', () => { + it.skipIf(process.platform === 'win32')( + 'forwards the distro as one argument and omits it when absent', + async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-wsl-launcher-argv-')) + try { + const launcherPath = join(root, 'launcher.sh') + const expectedCwd = await realpath(root) + await writeFile(launcherPath, buildWslLauncher('C:\\Orca\\orca.exe', '/bridge.ps1')) + await writeFile(join(root, 'wslpath'), '#!/bin/bash\nprintf "%s" "$2"\n', { + mode: 0o700 + }) + await writeFile(join(root, 'powershell.exe'), '#!/bin/bash\nprintf "%s\\0" "$@"\n', { + mode: 0o700 + }) + for (const distro of [undefined, '', 'Ubuntu Work']) { + const env: NodeJS.ProcessEnv = { + ...process.env, + PATH: `${root}:${process.env.PATH ?? ''}` + } + delete env.WSL_DISTRO_NAME + if (distro !== undefined) { + env.WSL_DISTRO_NAME = distro + } + const result = runProcessSync({ + program: '/bin/bash', + args: [launcherPath, 'account', 'add', '--agent', 'codex'], + env, + cwd: root + }) + expect(result.code).toBe(0) + expect(result.stdout.split('\0').slice(0, -1)).toEqual([ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + '/bridge.ps1', + 'C:\\Orca\\orca.exe', + '-WslCwd', + expectedCwd, + ...(distro ? ['-WslDistro', distro] : []), + 'account', + 'add', + '--agent', + 'codex' + ]) + } + } finally { + await removeTree(root) + } + } + ) + it('keeps forwarded argv outside PowerShell parsing', () => { const launcher = buildWslLauncher('C:\\Program Files\\Orca\\orca.exe') const bridge = buildWslBridgeScript() @@ -58,21 +111,33 @@ describe('WSL CLI PowerShell boundary', () => { await writeFile(bridgePath, buildWslBridgeScript(), 'utf8') await writeFile( targetPath, - 'process.stdout.write(JSON.stringify({ argv: process.argv.slice(2), cwd: process.env.ORCA_CLI_CWD ?? null }))\n', + 'process.stdout.write(JSON.stringify({ argv: process.argv.slice(2), cwd: process.env.ORCA_CLI_CWD ?? null, distro: process.env.ORCA_CLI_WSL_DISTRO ?? null }))\n', 'utf8' ) const invocations = [ + { + bridgeArgs: [ + process.execPath, + '-WslCwd', + wslCwd, + '-WslDistro', + 'Ubuntu Work', + targetPath, + ...FORWARDED_ARGS + ], + expected: { argv: FORWARDED_ARGS, cwd: wslCwd, distro: 'Ubuntu Work' } + }, { bridgeArgs: [process.execPath, '-WslCwd', wslCwd, targetPath, ...FORWARDED_ARGS], - expected: { argv: FORWARDED_ARGS, cwd: wslCwd } + expected: { argv: FORWARDED_ARGS, cwd: wslCwd, distro: null } }, { bridgeArgs: [process.execPath, '-WslCwd', wslCwd, targetPath], - expected: { argv: [], cwd: wslCwd } + expected: { argv: [], cwd: wslCwd, distro: null } }, { bridgeArgs: [process.execPath, targetPath, ...FORWARDED_ARGS], - expected: { argv: FORWARDED_ARGS, cwd: null } + expected: { argv: FORWARDED_ARGS, cwd: null, distro: null } } ] for (const { bridgeArgs, expected } of invocations) { @@ -87,7 +152,16 @@ describe('WSL CLI PowerShell boundary', () => { bridgePath, ...bridgeArgs ], - { encoding: 'utf8', env: { ...process.env, ORCA_CLI_CWD: 'stale' } } + { + encoding: 'utf8', + windowsHide: true, + env: { + ...process.env, + ORCA_CLI_CWD: 'stale', + ORCA_CLI_WSL_DISTRO: 'stale-distro', + WSL_DISTRO_NAME: 'wrong-distro' + } + } ) expect(result.error).toBeUndefined() @@ -109,7 +183,7 @@ describe('WSL CLI PowerShell boundary', () => { '-e', 'process.exit(23)' ], - { encoding: 'utf8' } + { encoding: 'utf8', windowsHide: true } ) expect(exitResult.error).toBeUndefined() expect(exitResult.status).toBe(23) @@ -118,4 +192,61 @@ describe('WSL CLI PowerShell boundary', () => { } } ) + + it.skipIf(process.platform !== 'win32')( + 'pins a non-ASCII app identity and the dev launcher env through Windows PowerShell 5.1', + async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-wsl-managed-bridge-')) + const userDataPath = join(root, "张三's O\u2019Brien Orca") + const cliEntryPath = join(root, 'cli \u2018entry\u2019.cjs') + const bridgePath = join(root, 'orca-wsl-bridge.ps1') + try { + await writeFile(bridgePath, buildWslBridgeScript({ userDataPath, cliEntryPath }), 'utf8') + await writeFile( + cliEntryPath, + 'console.error("to stderr"); const e = process.env; console.log(JSON.stringify({ argv: process.argv.slice(2), owner: e.ORCA_USER_DATA_PATH, app: e.ORCA_APP_EXECUTABLE, nodeOptions: e.NODE_OPTIONS ?? null, stashed: e.ORCA_NODE_OPTIONS, cliDir: e.ORCA_WSL_CLI_DIR ?? null }))\n', + 'utf8' + ) + const result = spawnSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-File', + bridgePath, + process.execPath, + '-WslCwd', + root, + ...FORWARDED_ARGS + ], + { + encoding: 'utf8', + windowsHide: true, + env: { + ...process.env, + ORCA_APP_EXECUTABLE: '', + NODE_OPTIONS: '--max-old-space-size=4096', + ORCA_WSL_CLI_DIR: 'C:\\guest-only' + } + } + ) + + expect(result.error).toBeUndefined() + expect(result.status, result.stderr).toBe(0) + expect(result.stderr).toContain('to stderr') + expect(JSON.parse(result.stdout.trim())).toEqual({ + argv: FORWARDED_ARGS, + owner: userDataPath, + app: process.execPath, + nodeOptions: null, + stashed: '--max-old-space-size=4096', + cliDir: null + }) + } finally { + await removeTree(root) + } + } + ) }) diff --git a/src/main/cli/wsl-cli-scripts.ts b/src/main/cli/wsl-cli-scripts.ts index 8875a81d18e..3f4e4156433 100644 --- a/src/main/cli/wsl-cli-scripts.ts +++ b/src/main/cli/wsl-cli-scripts.ts @@ -1,9 +1,44 @@ +import { quotePowerShellLiteral } from '../../shared/powershell-native-argument' + const MANAGED_MARKER = '# Orca managed WSL CLI launcher' const BRIDGE_MANAGED_MARKER = '# Orca managed WSL CLI PowerShell bridge' +const FIND_INTEROP_POWERSHELL = `if command -v powershell.exe >/dev/null 2>&1; then + ORCA_POWERSHELL=powershell.exe +elif [ -x /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe ]; then + ORCA_POWERSHELL=/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe +else + echo "Orca WSL CLI requires Windows interop and could not find powershell.exe." >&2 + exit 1 +fi` + export function buildWslLauncher( windowsLauncherPath: string, bridgePath = '${XDG_DATA_HOME:-$HOME/.local/share}/orca/orca-wsl-bridge.ps1' +): string { + return buildLauncher(windowsLauncherPath, quoteShell(bridgePath), FIND_INTEROP_POWERSHELL) +} + +/** Launcher that finds its bridge beside itself and PowerShell by Windows path, independent of guest PATH. */ +export function buildColocatedWslLauncher( + windowsLauncherPath: string, + windowsPowerShellPath: string +): string { + return buildLauncher( + windowsLauncherPath, + '"$(dirname -- "$0")/orca-wsl-bridge.ps1"', + `ORCA_POWERSHELL=$(wslpath -u ${quoteShell(windowsPowerShellPath)}) +if [ ! -x "$ORCA_POWERSHELL" ]; then + echo "Orca WSL CLI requires Windows interop and access to $ORCA_POWERSHELL." >&2 + exit 1 +fi` + ) +} + +function buildLauncher( + windowsLauncherPath: string, + bridgePathExpression: string, + resolvePowerShell: string ): string { const encodedTarget = Buffer.from(windowsLauncherPath, 'utf8').toString('base64') return `#!/usr/bin/env bash @@ -11,15 +46,8 @@ set -euo pipefail ${MANAGED_MARKER} # ORCA_WIN_LAUNCHER_B64=${encodedTarget} ORCA_WIN_LAUNCHER=${quoteShell(windowsLauncherPath)} -ORCA_BRIDGE_PS1=${quoteShell(bridgePath)} -if command -v powershell.exe >/dev/null 2>&1; then - ORCA_POWERSHELL=powershell.exe -elif [ -x /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe ]; then - ORCA_POWERSHELL=/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -else - echo "Orca WSL CLI requires Windows interop and could not find powershell.exe." >&2 - exit 1 -fi +ORCA_BRIDGE_PS1=${bridgePathExpression} +${resolvePowerShell} # Why: a shell can outlive a deleted worktree; keep explicit CLI selectors and # help usable, and repair cwd before any WSL interop tool tries to resolve it. ORCA_WSL_CWD=$(pwd -P 2>/dev/null) || { @@ -28,12 +56,28 @@ ORCA_WSL_CWD=$(pwd -P 2>/dev/null) || { } ORCA_BRIDGE_PS1_WIN=$(wslpath -w "$ORCA_BRIDGE_PS1") ORCA_WSL_CWD_WIN=$(wslpath -w "$ORCA_WSL_CWD") +if [ -n "\${WSL_DISTRO_NAME:-}" ]; then + set -- -WslDistro "$WSL_DISTRO_NAME" "$@" +fi exec "$ORCA_POWERSHELL" -NoProfile -ExecutionPolicy Bypass -File "$ORCA_BRIDGE_PS1_WIN" "$ORCA_WIN_LAUNCHER" -WslCwd "$ORCA_WSL_CWD_WIN" "$@" ` } -export function buildWslBridgeScript(): string { - return `${BRIDGE_MANAGED_MARKER} +/** `app` pins the bridge to one Orca instance; the guest-registered bridge omits it. */ +export function buildWslBridgeScript(app?: { + userDataPath: string + cliEntryPath?: string +}): string { + const setAppEnv = app + ? [ + `$env:ORCA_USER_DATA_PATH = ${quotePowerShellLiteral(app.userDataPath)}`, + // Why: WSLENV /p maps this guest-only dir back; an app the CLI starts must not inherit it. + 'Remove-Item Env:ORCA_WSL_CLI_DIR -ErrorAction SilentlyContinue', + ...(app.cliEntryPath ? buildDevCliEnv(app.cliEntryPath) : []) + ] + : [] + // Why the BOM: PowerShell 5.1 reads BOM-less scripts as ANSI, garbling non-ASCII embedded paths. + return `${app ? '\uFEFF' : ''}${BRIDGE_MANAGED_MARKER} function ConvertTo-NativeCommandLineArgument { param([AllowEmptyString()][string]$Value) @@ -71,6 +115,7 @@ try { } [string]$OrcaLauncher = $args[0] [string]$WslCwd = '' + [string]$WslDistro = '' [int]$ForwardArgStart = 1 if ($args.Count -ge 2 -and $args[1] -eq '-WslCwd') { if ($args.Count -lt 3) { @@ -79,6 +124,13 @@ try { $WslCwd = $args[2] $ForwardArgStart = 3 } + if ($ForwardArgStart -eq 3 -and $args.Count -ge 4 -and $args[3] -eq '-WslDistro') { + if ($args.Count -lt 5) { + throw 'Invalid Orca WSL CLI bridge invocation.' + } + $WslDistro = $args[4] + $ForwardArgStart = 5 + } [string[]]$ForwardArgs = @() if ($args.Count -gt $ForwardArgStart) { $ForwardArgs = @($args[$ForwardArgStart..($args.Count - 1)]) @@ -88,12 +140,18 @@ try { } else { $env:ORCA_CLI_CWD = $WslCwd } + # Do not let an inherited Windows environment choose the caller's account location. + if ([string]::IsNullOrEmpty($WslDistro)) { + Remove-Item Env:ORCA_CLI_WSL_DISTRO -ErrorAction SilentlyContinue + } else { + $env:ORCA_CLI_WSL_DISTRO = $WslDistro + } $LauncherDirectory = Split-Path -Parent $OrcaLauncher Push-Location -LiteralPath $LauncherDirectory # Why: Windows PowerShell 5.1 cannot losslessly splat strings to native argv. $StartInfo = [System.Diagnostics.ProcessStartInfo]::new() $StartInfo.FileName = $OrcaLauncher - $StartInfo.Arguments = (($ForwardArgs | ForEach-Object { +${bridgeLines(setAppEnv)} $StartInfo.Arguments = (($ForwardArgs | ForEach-Object { ConvertTo-NativeCommandLineArgument $_ }) -join ' ') $StartInfo.UseShellExecute = $false @@ -119,6 +177,22 @@ exit $exitCode ` } +/** Runs the dev CLI directly (its .cmd launcher adds a cmd.exe quoting boundary) with that launcher's env. */ +function buildDevCliEnv(cliEntryPath: string): string[] { + return [ + "$env:ELECTRON_RUN_AS_NODE = '1'", + "if (-not $env:ORCA_APP_EXECUTABLE) { $env:ORCA_APP_EXECUTABLE = $OrcaLauncher; $env:ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT = '1' }", + '$env:ORCA_NODE_OPTIONS = $env:NODE_OPTIONS', + '$env:ORCA_NODE_REPL_EXTERNAL_MODULE = $env:NODE_REPL_EXTERNAL_MODULE', + 'Remove-Item Env:NODE_OPTIONS, Env:NODE_REPL_EXTERNAL_MODULE -ErrorAction SilentlyContinue', + `$ForwardArgs = @(${quotePowerShellLiteral(cliEntryPath)}) + $ForwardArgs` + ] +} + +function bridgeLines(lines: readonly string[]): string { + return lines.map((line) => ` ${line}\n`).join('') +} + export function getBridgePathFromCommandPath(commandPath: string): string { // Why: both the current Linux command and the legacy pre-rename command // share one WSL bridge under ~/.local/share/orca. diff --git a/src/main/cli/wsl-managed-cli.test.ts b/src/main/cli/wsl-managed-cli.test.ts new file mode 100644 index 00000000000..23e764f5a5b --- /dev/null +++ b/src/main/cli/wsl-managed-cli.test.ts @@ -0,0 +1,68 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' +import { getManagedWslCliDir } from './wsl-managed-cli' + +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'orca-managed-wsl-')) + roots.push(root) + const resourcesPath = join(root, 'resources with spaces') + mkdirSync(join(resourcesPath, 'bin'), { recursive: true }) + writeFileSync(join(resourcesPath, 'bin', 'orca.exe'), 'fixture') + return { isPackaged: true, resourcesPath, userDataPath: join(root, 'user data') } +} + +describe('managed WSL CLI provisioning', () => { + it('reuses a complete tree, repairs missing files, and isolates app identities and updates', () => { + const host = fixture() + const directory = getManagedWslCliDir(host) ?? '' + const launcher = join(directory, 'orca-ide') + const modified = statSync(launcher).mtimeMs + expect(getManagedWslCliDir(host)).toBe(directory) + expect(statSync(launcher).mtimeMs).toBe(modified) + rmSync(launcher) + expect(getManagedWslCliDir(host)).toBe(directory) + expect(readFileSync(launcher, 'utf8')).toContain('resources with spaces') + const second = { ...host, userDataPath: join(host.userDataPath, 'second') } + expect(getManagedWslCliDir(second)).not.toBe(directory) + const update = fixture() + expect(getManagedWslCliDir({ ...update, userDataPath: host.userDataPath })).not.toBe(directory) + }) + + it('provides nothing when the packaged CLI runtime is missing', () => { + const host = fixture() + rmSync(join(host.resourcesPath, 'bin', 'orca.exe')) + expect(getManagedWslCliDir(host)).toBeNull() + }) + + it('provides nothing when user data cannot hold the CLI', () => { + const host = fixture() + writeFileSync(host.userDataPath, 'a file, not a directory') + expect(getManagedWslCliDir(host)).toBeNull() + }) + + it('runs the development CLI directly with the dev launcher env', () => { + const host = fixture() + const appPath = host.resourcesPath + const cliEntryPath = join(appPath, 'out', 'cli', 'index.js') + mkdirSync(join(appPath, 'out', 'cli'), { recursive: true }) + writeFileSync(cliEntryPath, 'fixture') + installFakeAppEnvironment({ getPath: () => host.userDataPath, getAppPath: () => appPath }) + const directory = getManagedWslCliDir({ ...host, isPackaged: false }) ?? '' + expect(readFileSync(join(directory, 'orca-dev'), 'utf8')).toContain(process.execPath) + const bridge = readFileSync(join(directory, 'orca-wsl-bridge.ps1'), 'utf8') + expect(bridge.startsWith('\uFEFF')).toBe(true) + expect(bridge).toContain(host.userDataPath) + expect(bridge).toContain(cliEntryPath) + expect(bridge).toContain('$env:ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT') + expect(bridge).toContain('Remove-Item Env:NODE_OPTIONS') + }) +}) diff --git a/src/main/cli/wsl-managed-cli.ts b/src/main/cli/wsl-managed-cli.ts new file mode 100644 index 00000000000..2e3c478b9a9 --- /dev/null +++ b/src/main/cli/wsl-managed-cli.ts @@ -0,0 +1,53 @@ +import { createHash } from 'node:crypto' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { getAppEnvironment } from '../../shared/app-environment' +import { windowsPowerShellPath } from '../../shared/child-process/windows-system-binary' +import { writeShellWrapperFiles } from '../shell-wrapper-file-writer' +import { getBundledLauncherPath, LINUX_CLI_COMMAND_NAME } from './bundled-cli-launcher-path' +import { DEV_COMMAND_NAME } from './cli-install-constants' +import { buildColocatedWslLauncher, buildWslBridgeScript } from './wsl-cli-scripts' + +/** Packaged builds share `orca-ide` with guest registration; dev builds get their own name. */ +export function getWslCliCommandName(isPackaged: boolean): string { + return isPackaged ? LINUX_CLI_COMMAND_NAME : DEV_COMMAND_NAME +} + +let warnedMissingRuntime = false + +/** + * Directory holding this app's WSL launcher and bridge, or null when the CLI runtime is + * missing or unwritable. Content-addressed like shell-ready wrappers: builds sharing userData + * never overwrite each other, and a present file is complete because each one lands by rename. + */ +export function getManagedWslCliDir(opts: { + isPackaged: boolean + userDataPath: string + resourcesPath?: string +}): string | null { + const cliEntryPath = opts.isPackaged + ? undefined + : join(getAppEnvironment().getAppPath(), 'out', 'cli', 'index.js') + const launcherPath = opts.isPackaged + ? opts.resourcesPath && getBundledLauncherPath('win32', opts.resourcesPath) + : process.execPath + if (!launcherPath || !existsSync(cliEntryPath ?? launcherPath)) { + if (!warnedMissingRuntime) { + warnedMissingRuntime = true + console.warn('[WSL CLI] Orca CLI runtime is missing; WSL terminals will not provide it.') + } + return null + } + const launcher = buildColocatedWslLauncher(launcherPath, windowsPowerShellPath()) + const bridge = buildWslBridgeScript({ userDataPath: opts.userDataPath, cliEntryPath }) + const digest = createHash('sha256').update(launcher).update(bridge).digest('hex').slice(0, 20) + const directory = join(opts.userDataPath, 'wsl-managed-cli', digest) + const files = [ + [join(directory, getWslCliCommandName(opts.isPackaged)), launcher], + [join(directory, 'orca-wsl-bridge.ps1'), bridge] + ] as const + const ready = + files.every(([path]) => existsSync(path)) || + writeShellWrapperFiles(files, '[WSL CLI]', 'WSL terminals will start without the Orca CLI') + return ready ? directory : null +} diff --git a/src/main/cli/wsl-managed-cli.wsl.test.ts b/src/main/cli/wsl-managed-cli.wsl.test.ts new file mode 100644 index 00000000000..c7738397d77 --- /dev/null +++ b/src/main/cli/wsl-managed-cli.wsl.test.ts @@ -0,0 +1,142 @@ +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { expect, it } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { removeTree } from '../../shared/windows-transient-lock-removal' +import { buildWslExecArgs } from '../../shared/wsl-login-shell-command' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' +import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env' +import { getManagedWslCliDir } from './wsl-managed-cli' +import { buildLocalShellReadyWrapperFiles } from '../providers/local-pty-shell-ready-wrapper-fileset' + +// Explicit opt-in: never require a developer's WSL installation for unit tests. +const enabled = process.platform === 'win32' && process.env.ORCA_TEST_MANAGED_WSL === '1' +const FIXTURE_CLI = + 'if(process.argv.includes("--exit"))process.exit(23); console.error("bridge stderr"); console.log(JSON.stringify({argv:process.argv.slice(2),owner:process.env.ORCA_USER_DATA_PATH,handle:process.env.ORCA_TERMINAL_HANDLE}))' + +async function withManagedCli( + run: (fixture: { + env: Record + guestRoot: string + root: string + userDataPath: string + wsl: (args: string[], input?: string) => ReturnType + }) => Promise +): Promise { + const root = mkdtempSync(join(tmpdir(), "orca WSL's managed CLI ")) + const distro = process.env.ORCA_TEST_WSL_DISTRO || undefined + const userDataPath = join(root, 'user data 张三 O\u2019Brien') + const env: Record = { ORCA_BACKGROUND_LAUNCH: '1' } + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) { + env[key] = value + } + } + const wsl = (args: string[], input?: string) => + runProcess({ + program: 'wsl.exe', + args: buildWslExecArgs(distro, args), + env, + cwd: root, + input, + timeoutMs: 30_000 + }) + const snapshot = () => + wsl([ + 'sh', + '-c', + 'for file in "$HOME/.profile" "$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.zshrc" "$HOME/.zprofile" "$HOME/.zshenv" "$HOME/.local/bin/orca" "$HOME/.local/bin/orca-ide" "$HOME/.local/bin/orca-dev" "$HOME/.local/share/orca/orca-wsl-bridge.ps1"; do if [ -f "$file" ]; then sha256sum "$file"; fi; done; printf "PATH=%s\\n" "$PATH"' + ]) + try { + const before = await snapshot() + expect(before.code, before.stderr).toBe(0) + mkdirSync(join(root, 'out', 'cli'), { recursive: true }) + writeFileSync(join(root, 'out', 'cli', 'index.js'), FIXTURE_CLI) + for (const [path, content] of buildLocalShellReadyWrapperFiles(join(root, 'wrapper'))) { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content) + } + const translated = await wsl(['wslpath', '-u', root]) + expect(translated.code, translated.stderr).toBe(0) + installFakeAppEnvironment({ getPath: () => userDataPath, getAppPath: () => root }) + const directory = getManagedWslCliDir({ isPackaged: false, userDataPath }) + expect(directory).not.toBeNull() + Object.assign(env, { + ORCA_WSL_CLI_DIR: directory ?? '', + ORCA_CLI_COMMAND: 'orca-dev', + ORCA_TERMINAL_HANDLE: 'term_managed_fixture' + }) + addOrcaWslInteropEnv(env) + await run({ env, guestRoot: translated.stdout.trim(), root, userDataPath, wsl }) + expect((await snapshot()).stdout).toBe(before.stdout) + } finally { + await removeTree(root) + } +} + +it.skipIf(!enabled)( + 'executes the managed bridge after bash startup resets PATH, without guest registration', + () => + withManagedCli(async ({ env, guestRoot, root, userDataPath, wsl }) => { + writeFileSync(join(root, '.bash_profile'), 'export PATH=/usr/bin:/bin\n') + const shell = (command: string) => + wsl([ + 'env', + `HOME=${guestRoot}`, + 'PATH=/usr/bin:/bin', + 'bash', + '--rcfile', + `${guestRoot}/wrapper/bash/rcfile`, + '-ic', + command + ]) + + const result = await shell('orca-dev "two words" "literal $" | cat') + expect(result.code, result.stderr).toBe(0) + expect(result.stderr).toContain('bridge stderr') + expect(result.stdout).toContain('"argv":["two words","literal $"]') + expect(result.stdout).toContain(JSON.stringify(userDataPath)) + expect(result.stdout).toContain('"handle":"term_managed_fixture"') + expect((await shell('orca-dev --exit')).code).toBe(23) + + env.ORCA_WSL_CLI_DIR = join(root, 'missing-cli') + // An unusable CLI warns but never blocks the shell. + const missing = await shell('echo SHELL_CONTINUED') + expect(missing.code).toBe(0) + expect(missing.stdout).toContain('SHELL_CONTINUED') + expect(missing.stderr).toContain('Check WSL Windows-drive mount options') + }), + 90_000 +) + +// Why a PTY: the zsh restore runs from the first-prompt hook, which `zsh -c` never reaches. +it.skipIf(!enabled)( + 'executes the managed bridge from an interactive zsh after .zshrc resets PATH', + (ctx) => + withManagedCli(async ({ guestRoot, root, userDataPath, wsl }) => { + if ((await wsl(['sh', '-c', 'command -v zsh && command -v script'])).code !== 0) { + ctx.skip() + } + writeFileSync(join(root, '.zshrc'), 'export PATH=/usr/bin:/bin\n') + const result = await wsl( + [ + 'env', + `HOME=${guestRoot}`, + 'PATH=/usr/bin:/bin', + `ZDOTDIR=${guestRoot}/wrapper/zsh`, + 'script', + '-qec', + 'zsh -l', + '/dev/null' + ], + `orca-dev "two words" > "$HOME/zsh-out"; print -r -- "path=$PATH" >> "$HOME/zsh-out"; exit\n` + ) + expect(result.code, result.stderr).toBe(0) + const output = await wsl(['cat', `${guestRoot}/zsh-out`]) + expect(output.stdout).toContain('"argv":["two words"]') + expect(output.stdout).toContain(JSON.stringify(userDataPath)) + expect(output.stdout).toMatch(/path=\/mnt\/.*wsl-managed-cli\/[0-9a-f]{20}:\/usr\/bin:\/bin/) + }), + 90_000 +) diff --git a/src/main/codebuddy/hook-service.test.ts b/src/main/codebuddy/hook-service.test.ts new file mode 100644 index 00000000000..e20ae46e39c --- /dev/null +++ b/src/main/codebuddy/hook-service.test.ts @@ -0,0 +1,47 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import type * as OsModule from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +const sandbox = vi.hoisted(() => ({ home: '' })) +vi.mock('node:os', async (importOriginal) => ({ + ...(await importOriginal()), + homedir: () => sandbox.home +})) +vi.mock('electron', () => ({ app: { getPath: () => sandbox.home } })) +import { codebuddyHookService, CODEBUDDY_HOOK_EVENTS } from './hook-service' + +beforeAll(() => { + sandbox.home = mkdtempSync(join(tmpdir(), 'orca-codebuddy-test-')) + mkdirSync(join(sandbox.home, '.codebuddy')) +}) +afterAll(() => rmSync(sandbox.home, { recursive: true, force: true })) + +describe('CodeBuddy managed hooks', () => { + it('preserves user configuration through repeated install and removal', () => { + const path = join(sandbox.home, '.codebuddy', 'settings.json') + const userHook = { hooks: [{ type: 'command', command: 'echo user-hook' }] } + const settings = { + model: 'custom', + hooks: { Stop: [userHook] }, + statusLine: { command: 'user-status' } + } + writeFileSync(path, JSON.stringify(settings)) + expect(codebuddyHookService.install().state).toBe('installed') + expect(codebuddyHookService.install().state).toBe('installed') + const installed = JSON.parse(readFileSync(path, 'utf8')) + for (const event of CODEBUDDY_HOOK_EVENTS) { + expect(installed.hooks[event]).toHaveLength(event === 'Stop' ? 2 : 1) + } + expect(installed.statusLine).toEqual(settings.statusLine) + expect(codebuddyHookService.remove().state).toBe('not_installed') + expect(JSON.parse(readFileSync(path, 'utf8'))).toMatchObject(settings) + }) + + it('leaves malformed user settings untouched', () => { + const path = join(sandbox.home, '.codebuddy', 'settings.json') + writeFileSync(path, '{broken') + expect(codebuddyHookService.install().state).toBe('error') + expect(readFileSync(path, 'utf8')).toBe('{broken') + }) +}) diff --git a/src/main/codebuddy/hook-service.ts b/src/main/codebuddy/hook-service.ts new file mode 100644 index 00000000000..1a7c9619a34 --- /dev/null +++ b/src/main/codebuddy/hook-service.ts @@ -0,0 +1,34 @@ +import type { ClaudeManagedHookPlan } from '../claude/claude-managed-hook-events' +import { ClaudeHookService } from '../claude/hook-service' + +export const CODEBUDDY_HOOK_EVENTS = [ + 'SessionStart', + 'SessionEnd', + 'UserPromptSubmit', + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'PermissionRequest', + 'Stop', + 'StopFailure', + 'Notification' +] as const + +export const CODEBUDDY_MANAGED_HOOK_PLAN: ClaudeManagedHookPlan = { + install: CODEBUDDY_HOOK_EVENTS.map((eventName) => ({ eventName, definition: {} })), + retire: [], + statusLine: 'leave' +} + +export const codebuddyHookService = new ClaudeHookService({ + agent: 'codebuddy', + source: 'codebuddy', + displayName: 'CodeBuddy', + settings: { + configDirName: '.codebuddy', + scriptBaseName: 'codebuddy-hook', + usesWindowsCompatLauncher: true, + windowsHookShell: 'powershell' + }, + hookPlan: CODEBUDDY_MANAGED_HOOK_PLAN +}) diff --git a/src/main/codex-accounts/async-file-rename.test.ts b/src/main/codex-accounts/async-file-rename.test.ts new file mode 100644 index 00000000000..a453378a78d --- /dev/null +++ b/src/main/codex-accounts/async-file-rename.test.ts @@ -0,0 +1,87 @@ +import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { writeFileDurable, writeFileDurableIfCurrent } from '../durable-file-write' + +const rename = vi.hoisted(() => vi.fn()) +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + rename.mockImplementation(actual.rename) + return { ...actual, rename } +}) + +const platform = process.platform +const roots: string[] = [] +afterEach(() => { + Object.defineProperty(process, 'platform', { value: platform }) + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } + rename.mockClear() + vi.restoreAllMocks() +}) + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'orca-async-rename-')) + roots.push(root) + const target = join(root, 'state.json') + const temporary = join(root, 'temporary.json') + writeFileSync(target, 'old') + return { root, target, temporary } +} + +it.each(['EPERM', 'EACCES', 'EBUSY'])( + 'retries a transient Windows %s without blocking the event loop', + async (code) => { + const { target, temporary } = fixture() + Object.defineProperty(process, 'platform', { value: 'win32' }) + rename.mockRejectedValueOnce(Object.assign(new Error('file busy'), { code })) + let ticked = false + const timer = setTimeout(() => { + ticked = true + }, 0) + try { + await writeFileDurable(temporary, target, 'new') + expect(readFileSync(target, 'utf8')).toBe('new') + expect(rename).toHaveBeenCalledTimes(2) + expect(ticked).toBe(true) + } finally { + clearTimeout(timer) + } + } +) + +it.each([ + ['win32', 'EPERM', 6], + ['win32', 'ENOSPC', 1], + ['linux', 'EBUSY', 1] +] as const)('bounds %s %s failures and preserves the old file', async (host, code, attempts) => { + const { root, target, temporary } = fixture() + Object.defineProperty(process, 'platform', { value: host }) + for (let attempt = 0; attempt < attempts; attempt++) { + rename.mockRejectedValueOnce(Object.assign(new Error('injected file failure'), { code })) + } + await expect(writeFileDurable(temporary, target, 'new')).rejects.toThrow('injected file failure') + expect(rename).toHaveBeenCalledTimes(attempts) + expect(readFileSync(target, 'utf8')).toBe('old') + expect(readdirSync(root)).toEqual(['state.json']) +}) + +it('does not publish a superseded snapshot after a Windows retry delay', async () => { + const { root, target, temporary } = fixture() + Object.defineProperty(process, 'platform', { value: 'win32' }) + let current = true + rename.mockImplementationOnce(async () => { + current = false + writeFileSync(target, 'newer snapshot') + throw Object.assign(new Error('busy'), { code: 'EBUSY' }) + }) + await expect( + writeFileDurableIfCurrent(temporary, target, 'stale snapshot', () => current) + ).resolves.toBe(false) + expect(rename).toHaveBeenCalledOnce() + expect(readFileSync(target, 'utf8')).toBe('newer snapshot') + expect(readdirSync(root)).toEqual(['state.json']) +}) diff --git a/src/main/codex-accounts/codex-account-selection.ts b/src/main/codex-accounts/codex-account-selection.ts index ad97d80696b..24785e79dc1 100644 --- a/src/main/codex-accounts/codex-account-selection.ts +++ b/src/main/codex-accounts/codex-account-selection.ts @@ -28,7 +28,7 @@ type CodexAccountSelectionDependencies = { lifecycle: CodexAccountServiceLifecycle resolveSystemDefault: () => CodexSystemDefaultIdentity removeManagedHome: (candidatePath: string, expectedAccountId: string) => void - discardResetAttempts: (accountId: string) => void + discardResetAttempts: (accountId: string) => Promise } export class CodexAccountSelection { @@ -83,10 +83,13 @@ export class CodexAccountSelection { } this.dependencies.removeManagedHome(account.managedHomePath, account.id) - // Why: a removed account can no longer appear in the switcher dropdown, - // so purge its cached usage to avoid stale entries. this.dependencies.rateLimits.evictInactiveCodexCache(accountId) - this.dependencies.discardResetAttempts(accountId) + try { + await this.dependencies.discardResetAttempts(accountId) + } catch (error) { + // Removal already succeeded; retain the ledger's safety guards if cleanup fails. + console.warn('[codex-accounts] Removed account, but credit ledger cleanup failed:', error) + } const accountTarget = getCodexSelectionTargetForAccount(account) this.startQuotaRefresh( getSelectedCodexAccountIdForTarget(settings, accountTarget) === accountId diff --git a/src/main/codex-accounts/codex-reset-credit-coordinator.ts b/src/main/codex-accounts/codex-reset-credit-coordinator.ts index 5d6233d46eb..cd0c8ad8569 100644 --- a/src/main/codex-accounts/codex-reset-credit-coordinator.ts +++ b/src/main/codex-accounts/codex-reset-credit-coordinator.ts @@ -171,8 +171,8 @@ export class CodexResetCreditCoordinator { }) } - discardForRemovedAccount(accountId: string): void { - this.ledger.discardForRemovedAccount(accountId) + discardForRemovedAccount(accountId: string): Promise { + return this.ledger.discardForRemovedAccount(accountId) } private startAttempt( @@ -182,6 +182,9 @@ export class CodexResetCreditCoordinator { ): Promise { const promise = this.dependencies.serializeMutation( async (): Promise => { + if (this.ledger.error) { + throw this.ledger.error + } const isFresh = attempt.state === 'fresh' let validation: { managedHomePath: string; rateLimits: RateLimitState } try { @@ -204,7 +207,7 @@ export class CodexResetCreditCoordinator { throw error } if (isFresh) { - this.ledger.markProviderPending(idempotencyKey, attempt) + await this.ledger.markProviderPending(idempotencyKey, attempt) } const { outcome, state } = await this.dependencies.rateLimits.consumeCodexRateLimitResetCredit({ @@ -220,7 +223,7 @@ export class CodexResetCreditCoordinator { codex: this.dependencies.getSnapshot(), rateLimits: state } - this.ledger.markSettled(idempotencyKey, attempt, outcome) + await this.ledger.markSettled(idempotencyKey, attempt, outcome) return result } ) diff --git a/src/main/codex-accounts/codex-reset-credit-ledger.test.ts b/src/main/codex-accounts/codex-reset-credit-ledger.test.ts new file mode 100644 index 00000000000..32f13e33d78 --- /dev/null +++ b/src/main/codex-accounts/codex-reset-credit-ledger.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from 'vitest' +import type { CodexResetCreditAttemptLedger } from '../../shared/codex-reset-credit-attempt-ledger' +import type { CodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope' +import { ProfileStateWriterError } from '../persistence/profile-state/profile-state-writer-errors' +import { CodexResetCreditLedger } from './codex-reset-credit-ledger' + +function scope(accountId: string): CodexResetCreditExpectedScope { + return { + target: { runtime: 'host', wslDistro: null }, + accountId, + accountRevision: 1, + offerRevision: 'offer-1' + } +} + +function setup() { + let durable: CodexResetCreditAttemptLedger = { version: 1, attempts: [] } + const barrier = vi.fn(async () => {}) + const store = { + getCodexResetCreditAttemptLedger: () => structuredClone(durable), + replaceCodexResetCreditAttemptLedgerAndFlush: vi.fn( + async (next: CodexResetCreditAttemptLedger) => { + await barrier() + durable = structuredClone(next) + } + ) + } + return { ledger: new CodexResetCreditLedger(store), store, barrier } +} + +describe('async reset-credit ledger', () => { + it('serializes replacement construction so concurrent account writes survive', async () => { + const { ledger, store, barrier } = setup() + const gate = Promise.withResolvers() + barrier.mockImplementationOnce(() => gate.promise) + const first = ledger.createFresh('first', scope('account-1')) + const second = ledger.createFresh('second', scope('account-2')) + const pendingFirst = ledger.markProviderPending('first', first) + const pendingSecond = ledger.markProviderPending('second', second) + await vi.waitFor(() => expect(barrier).toHaveBeenCalledOnce()) + expect(first.state).toBe('fresh') + expect(second.state).toBe('fresh') + expect(store.getCodexResetCreditAttemptLedger().attempts).toEqual([]) + + gate.resolve() + await Promise.all([pendingFirst, pendingSecond]) + expect(store.getCodexResetCreditAttemptLedger().attempts).toMatchObject([ + { idempotencyKey: 'first', state: 'providerPending' }, + { idempotencyKey: 'second', state: 'providerPending' } + ]) + expect(ledger.getUnresolvedKey(first.accountScopeKey)).toBe('first') + expect(ledger.getUnresolvedKey(second.accountScopeKey)).toBe('second') + }) + + it('keeps pending guards until settlement commits and can retry a known failure', async () => { + const { ledger, store, barrier } = setup() + const attempt = ledger.createFresh('first', scope('account-1')) + await ledger.markProviderPending('first', attempt) + const gate = Promise.withResolvers() + barrier.mockImplementationOnce(() => gate.promise) + const settled = ledger.markSettled('first', attempt, 'reset') + const rejected = expect(settled).rejects.toThrow('disk full') + await vi.waitFor(() => expect(barrier).toHaveBeenCalledTimes(2)) + expect(attempt.state).toBe('providerPending') + expect(ledger.getUnresolvedKey(attempt.accountScopeKey)).toBe('first') + + gate.reject(new Error('disk full')) + await rejected + expect(attempt.state).toBe('providerPending') + expect(store.getCodexResetCreditAttemptLedger().attempts[0]?.state).toBe('providerPending') + expect(ledger.error).toBeNull() + await ledger.markSettled('first', attempt, 'alreadyRedeemed') + expect(attempt.settledOutcome).toBe('alreadyRedeemed') + expect(ledger.getUnresolvedKey(attempt.accountScopeKey)).toBeUndefined() + }) + + it('waits for queued writes before removing an account and retains other accounts', async () => { + const { ledger, store, barrier } = setup() + const first = ledger.createFresh('first', scope('account-1')) + const second = ledger.createFresh('second', scope('account-2')) + await ledger.markProviderPending('first', first) + const gate = Promise.withResolvers() + barrier.mockImplementationOnce(() => gate.promise) + const pendingSecond = ledger.markProviderPending('second', second) + const removed = ledger.discardForRemovedAccount('account-1') + await vi.waitFor(() => expect(barrier).toHaveBeenCalledTimes(2)) + expect(ledger.get('first')).toBe(first) + + gate.resolve() + await Promise.all([pendingSecond, removed]) + expect(store.getCodexResetCreditAttemptLedger().attempts).toMatchObject([ + { idempotencyKey: 'second', state: 'providerPending' } + ]) + expect(ledger.get('first')).toBeUndefined() + expect(ledger.getUnresolvedKey(first.accountScopeKey)).toBeUndefined() + expect(ledger.get('second')).toBe(second) + }) + + it('retains the removed account guard when its async durability barrier fails', async () => { + const { ledger, barrier } = setup() + const attempt = ledger.createFresh('first', scope('account-1')) + await ledger.markProviderPending('first', attempt) + barrier.mockRejectedValueOnce(new Error('disk full')) + await expect(ledger.discardForRemovedAccount('account-1')).rejects.toThrow('disk full') + expect(ledger.get('first')).toBe(attempt) + expect(ledger.getUnresolvedKey(attempt.accountScopeKey)).toBe('first') + }) + + it('fails queued and future mutations closed when a commit outcome is unknown', async () => { + const { ledger, store, barrier } = setup() + const attempt = ledger.createFresh('first', scope('account-1')) + const second = ledger.createFresh('second', scope('account-2')) + const gate = Promise.withResolvers() + barrier.mockImplementationOnce(() => gate.promise) + const pending = ledger.markProviderPending('first', attempt) + const queued = ledger.markProviderPending('second', second) + const rejected = expect(pending).rejects.toThrow('worker stopped') + const blocked = expect(queued).rejects.toThrow('durability is unknown') + await vi.waitFor(() => expect(barrier).toHaveBeenCalledOnce()) + gate.reject(new ProfileStateWriterError('worker-exit', 'worker stopped', 'indeterminate')) + await Promise.all([rejected, blocked]) + + ledger.releaseFresh('first', attempt) + expect(ledger.get('first')).toBe(attempt) + expect(ledger.getClaimedKey(attempt.scopeKey)).toBe('first') + expect(store.replaceCodexResetCreditAttemptLedgerAndFlush).toHaveBeenCalledOnce() + await expect(ledger.discardForRemovedAccount('account-1')).rejects.toThrow( + 'durability is unknown' + ) + }) +}) diff --git a/src/main/codex-accounts/codex-reset-credit-ledger.ts b/src/main/codex-accounts/codex-reset-credit-ledger.ts index 625731f7d38..77590bcfe36 100644 --- a/src/main/codex-accounts/codex-reset-credit-ledger.ts +++ b/src/main/codex-accounts/codex-reset-credit-ledger.ts @@ -9,6 +9,7 @@ import type { RateLimitRuntimeTarget } from '../../shared/rate-limit-types' import type { Store } from '../persistence' +import { profileStateWriterFailureOutcome } from '../persistence/profile-state/profile-state-writer-errors' export type CodexResetCreditAttempt = { expectedScope: CodexResetCreditExpectedScope @@ -45,14 +46,20 @@ export class CodexResetCreditLedger { private readonly attemptKeyByOffer = new Map() private readonly unresolvedKeyByAccountScope = new Map() private durableLedger: CodexResetCreditAttemptLedger | null = null - private loadError: Error | null = null + private stateError: Error | null = null + private mutationQueue: Promise = Promise.resolve() - constructor(private readonly store: Store) { + constructor( + private readonly store: Pick< + Store, + 'getCodexResetCreditAttemptLedger' | 'replaceCodexResetCreditAttemptLedgerAndFlush' + > + ) { this.hydrate() } get error(): Error | null { - return this.loadError + return this.stateError } get(idempotencyKey: string): CodexResetCreditAttempt | undefined { @@ -114,32 +121,40 @@ export class CodexResetCreditLedger { ) } - markProviderPending(idempotencyKey: string, attempt: CodexResetCreditAttempt): void { - this.persist({ idempotencyKey, expectedScope: attempt.expectedScope, state: 'providerPending' }) - attempt.state = 'providerPending' - this.unresolvedKeyByAccountScope.set(attempt.accountScopeKey, idempotencyKey) + markProviderPending(idempotencyKey: string, attempt: CodexResetCreditAttempt): Promise { + return this.serializeMutation(async () => { + await this.persist({ + idempotencyKey, + expectedScope: attempt.expectedScope, + state: 'providerPending' + }) + attempt.state = 'providerPending' + this.unresolvedKeyByAccountScope.set(attempt.accountScopeKey, idempotencyKey) + }) } markSettled( idempotencyKey: string, attempt: CodexResetCreditAttempt, outcome: CodexRateLimitResetOutcome - ): void { - this.persist({ - idempotencyKey, - expectedScope: attempt.expectedScope, - state: 'settled', - outcome + ): Promise { + return this.serializeMutation(async () => { + await this.persist({ + idempotencyKey, + expectedScope: attempt.expectedScope, + state: 'settled', + outcome + }) + attempt.state = 'settled' + attempt.settledOutcome = outcome + if (this.unresolvedKeyByAccountScope.get(attempt.accountScopeKey) === idempotencyKey) { + this.unresolvedKeyByAccountScope.delete(attempt.accountScopeKey) + } }) - attempt.state = 'settled' - attempt.settledOutcome = outcome - if (this.unresolvedKeyByAccountScope.get(attempt.accountScopeKey) === idempotencyKey) { - this.unresolvedKeyByAccountScope.delete(attempt.accountScopeKey) - } } releaseFresh(idempotencyKey: string, attempt: CodexResetCreditAttempt): void { - if (attempt.state !== 'fresh') { + if (attempt.state !== 'fresh' || this.stateError) { return } this.attemptsByKey.delete(idempotencyKey) @@ -151,7 +166,11 @@ export class CodexResetCreditLedger { // Why: a removed account's managed home is gone, so its unresolved providerPending // attempt can never validate or be replayed; drop it so a target-scoped default reset // is not wedged forever by hasPendingResetForTarget matching the orphan. - discardForRemovedAccount(accountId: string): void { + discardForRemovedAccount(accountId: string): Promise { + return this.serializeMutation(() => this.discardAccountAttempts(accountId)) + } + + private async discardAccountAttempts(accountId: string): Promise { const staleAttempts = [...this.attemptsByKey].filter( ([, attempt]) => attempt.expectedScope.accountId === accountId ) @@ -167,7 +186,7 @@ export class CodexResetCreditLedger { const nextLedger: CodexResetCreditAttemptLedger = { version: 1, attempts } // Persist first so a failed durability barrier leaves the in-memory // fail-closed guards aligned with the ledger that will reload. - this.store.replaceCodexResetCreditAttemptLedgerAndFlush(nextLedger) + await this.store.replaceCodexResetCreditAttemptLedgerAndFlush(nextLedger) this.durableLedger = structuredClone(nextLedger) } } @@ -202,14 +221,34 @@ export class CodexResetCreditLedger { } } } catch (error) { - this.loadError = + this.stateError = error instanceof Error ? error : new Error('Codex reset-credit attempt ledger is corrupt') } } - private persist(nextAttempt: DurableCodexResetCreditAttempt): void { + private serializeMutation(operation: () => Promise): Promise { + const next = this.mutationQueue.then(async () => { + if (this.stateError) { + throw this.stateError + } + try { + await operation() + } catch (error) { + if (profileStateWriterFailureOutcome(error) === 'indeterminate') { + this.stateError = new Error('Codex reset-credit attempt durability is unknown', { + cause: error + }) + } + throw error + } + }) + this.mutationQueue = next.catch(() => {}) + return next + } + + private async persist(nextAttempt: DurableCodexResetCreditAttempt): Promise { if (!this.durableLedger) { - throw this.loadError ?? new Error('Codex reset-credit attempt ledger is unavailable') + throw this.stateError ?? new Error('Codex reset-credit attempt ledger is unavailable') } const index = this.durableLedger.attempts.findIndex( (attempt) => attempt.idempotencyKey === nextAttempt.idempotencyKey @@ -221,7 +260,7 @@ export class CodexResetCreditLedger { attempts[index] = nextAttempt } const nextLedger: CodexResetCreditAttemptLedger = { version: 1, attempts } - this.store.replaceCodexResetCreditAttemptLedgerAndFlush(nextLedger) + await this.store.replaceCodexResetCreditAttemptLedgerAndFlush(nextLedger) this.durableLedger = structuredClone(nextLedger) } } diff --git a/src/main/codex-accounts/fs-utils.ts b/src/main/codex-accounts/fs-utils.ts index e0610cc79c7..295dfac9b66 100644 --- a/src/main/codex-accounts/fs-utils.ts +++ b/src/main/codex-accounts/fs-utils.ts @@ -1,6 +1,8 @@ import { randomUUID } from 'node:crypto' import { copyFileSync, existsSync, linkSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { rename } from 'node:fs/promises' import { dirname } from 'node:path' +import { setTimeout } from 'node:timers/promises' import { grantDirAcl, isPermissionError } from '../win32-utils' import { nodeFileContentsEqualSync } from '../../shared/node-file-content-equality' @@ -168,7 +170,7 @@ function assertHardLinkPublicationSupported(sourcePath: string, targetPath: stri } } -function publishFileWithoutOverwrite(sourcePath: string, targetPath: string): boolean { +export function publishFileWithoutOverwrite(sourcePath: string, targetPath: string): boolean { try { linkSync(sourcePath, targetPath) return true @@ -201,19 +203,38 @@ export function renameFileWithWindowsRetry(source: string, target: string): void runFileOperationWithWindowsRetry(() => renameSync(source, target)) } +export async function renameFileWithWindowsRetryAsync( + source: string, + target: string, + isCurrent: () => boolean = () => true +): Promise { + for (let attempt = 1; ; attempt++) { + if (!isCurrent()) { + return false + } + try { + await rename(source, target) + return true + } catch (error) { + if (!shouldRetryFileOperation(error, attempt)) { + throw error + } + await setTimeout(attempt * 50) + } + } +} + export function copyFileWithWindowsRetry(source: string, target: string): void { runFileOperationWithWindowsRetry(() => copyFileSync(source, target)) } function runFileOperationWithWindowsRetry(operation: () => void): void { - const maxAttempts = process.platform === 'win32' ? 6 : 1 - for (let attempt = 1; attempt <= maxAttempts; attempt++) { + for (let attempt = 1; ; attempt++) { try { operation() return } catch (error) { - const code = (error as NodeJS.ErrnoException).code - if (attempt < maxAttempts && (code === 'EPERM' || code === 'EACCES' || code === 'EBUSY')) { + if (shouldRetryFileOperation(error, attempt)) { sleepSync(attempt * 50) continue } @@ -222,6 +243,16 @@ function runFileOperationWithWindowsRetry(operation: () => void): void { } } +function shouldRetryFileOperation(error: unknown, attempt: number): boolean { + return ( + process.platform === 'win32' && + attempt < 6 && + error instanceof Error && + 'code' in error && + (error.code === 'EPERM' || error.code === 'EACCES' || error.code === 'EBUSY') + ) +} + // Why: writeFileAtomically is a sync API called from sync paths, so the retry // backoff must park the thread instead of burning CPU in a Date.now() loop. const sleepBuffer = new Int32Array(new SharedArrayBuffer(4)) diff --git a/src/main/codex-accounts/managed-codex-auth-readiness.test.ts b/src/main/codex-accounts/managed-codex-auth-readiness.test.ts index e6354826366..0fbf93adadb 100644 --- a/src/main/codex-accounts/managed-codex-auth-readiness.test.ts +++ b/src/main/codex-accounts/managed-codex-auth-readiness.test.ts @@ -220,22 +220,6 @@ describe('waitForManagedCodexAuthReady', () => { chmodSync(join(fixture.home, 'auth.json'), 0o000) expect(readStoredCodexCredentialState(join(fixture.home, 'auth.json'))).toBe('unreadable') }) - - it('does not gate system, WSL, or unmanaged custom homes', async () => { - const fixture = createFixture() - await waitForManagedCodexAuthReady({ - ...fixture.args, - codexHomePath: join(fixture.root, 'custom-home') - }) - await waitForManagedCodexAuthReady({ - ...fixture.args, - target: { runtime: 'wsl', wslDistro: 'Ubuntu' } - }) - await waitForManagedCodexAuthReady({ - ...fixture.args, - codexHomePath: null - }) - }) }) function createFixture(): { diff --git a/src/main/codex-accounts/runtime-home-read-only-launch-resolution.test.ts b/src/main/codex-accounts/runtime-home-read-only-launch-resolution.test.ts index 05deffd4fc2..7a86af57f04 100644 --- a/src/main/codex-accounts/runtime-home-read-only-launch-resolution.test.ts +++ b/src/main/codex-accounts/runtime-home-read-only-launch-resolution.test.ts @@ -289,8 +289,7 @@ describe('resolveHostCodexHomePathForLaunchReadOnly', () => { resolveStructuredCodexAccountHomePath({ launchEnv: {}, resolveLaunchHome: (input) => - service.resolveHostCodexHomePathForLaunchReadOnly(input.launchEnv), - workspacePath: '' + service.resolveHostCodexHomePathForLaunchReadOnly(input.launchEnv) }) ).resolves.toBe(getSystemCodexHomePath()) }) diff --git a/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts b/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts index 53736a11beb..4a7ad01e1b5 100644 --- a/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts +++ b/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts @@ -6,6 +6,7 @@ import type { GlobalSettings } from '../../shared/global-settings-types' import type { CodexManagedAccount } from '../../shared/managed-account-types' import type * as NodeOs from 'node:os' import { readHookTrustEntries } from '../codex/config-toml-trust' +import { writeCodexStateDbBackfillStatus } from '../codex/codex-state-db-test-fixture' const testState = { userData: '', home: '' } const previousEnv: Record = {} @@ -15,6 +16,17 @@ vi.mock('node:os', async () => { const actual = await vi.importActual('node:os') return { ...actual, homedir: () => testState.home } }) +// Why: selecting an account starts the history bridge, which would otherwise +// spawn the real `codex app-server` on these fixture homes. +vi.mock('../codex/codex-account-session-index-heal', () => ({ + createCodexAccountStateDb: async () => false, + healCodexAccountSessionIndex: async () => ({ + outcome: 'up-to-date', + healedThreads: 0, + missingThreads: 0, + failedThreads: 0 + }) +})) beforeEach(() => { vi.resetModules() @@ -212,6 +224,8 @@ describe('CodexRuntimeHomeService per-account takeover composition', () => { const siblingRollout = join('2026', '07', '21', 'rollout-2026-07-21T10-00-00-bbbb.jsonl') writeRollout(systemHome(), systemRollout, '{"session":"real-home"}\n') writeRollout(accountOne.managedHomePath, siblingRollout, '{"session":"account-one"}\n') + // Why: history is linked only once Codex has indexed the new home (#20669). + writeCodexStateDbBackfillStatus(accountTwo.managedHomePath, 'complete') const { settings, store } = createStore([accountOne, accountTwo], accountOne.id) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const bridge = await import('../codex/codex-account-session-bridge') diff --git a/src/main/codex-accounts/service-account-selection-and-removal.test.ts b/src/main/codex-accounts/service-account-selection-and-removal.test.ts index a2d7d9a4bb7..861859223ea 100644 --- a/src/main/codex-accounts/service-account-selection-and-removal.test.ts +++ b/src/main/codex-accounts/service-account-selection-and-removal.test.ts @@ -215,47 +215,67 @@ describe('CodexAccountService config sync', () => { }) }) - it('removes an account and cleans up managed home', async () => { - const managedHomePath = createManagedHome( - testState.userDataDir, - 'account-1', - '', - '{"account":"managed"}\n' - ) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const rateLimits = createRateLimits() - const runtimeHome = createRuntimeHome() + it.each(['healthy', 'unreadable'])( + 'removes an account with a %s credit ledger', + async (ledger) => { + const managedHomePath = createManagedHome( + testState.userDataDir, + 'account-1', + '', + '{"account":"managed"}\n' + ) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + const rateLimits = createRateLimits() + const runtimeHome = createRuntimeHome() - const { CodexAccountService } = await import('./service') - const service = new CodexAccountService( - store as never, - rateLimits as never, - runtimeHome as never - ) + const ledgerError = new Error('credit ledger unreadable') + if (ledger === 'unreadable') { + store.getCodexResetCreditAttemptLedger.mockImplementation(() => { + throw ledgerError + }) + } + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const result = await service.removeAccount('account-1') + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) - expect(result.accounts).toHaveLength(0) - expect(result.activeAccountId).toBe(null) - expect(existsSync(managedHomePath)).toBe(false) - expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalled() - }) + const result = await service.removeAccount('account-1') + + expect(result.accounts).toHaveLength(0) + expect(result.activeAccountId).toBe(null) + expect(existsSync(managedHomePath)).toBe(false) + expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalled() + expect(rateLimits.evictInactiveCodexCache).toHaveBeenCalledWith('account-1') + if (ledger === 'unreadable') { + expect(warn).toHaveBeenCalledWith( + '[codex-accounts] Removed account, but credit ledger cleanup failed:', + expect.any(Error) + ) + expect(store.replaceCodexResetCreditAttemptLedgerAndFlush).not.toHaveBeenCalled() + } + warn.mockRestore() + } + ) it('refuses to remove a managed home owned by a different account', async () => { const otherAccountHome = createManagedHome( diff --git a/src/main/codex-accounts/service-reset-credit-durability.test.ts b/src/main/codex-accounts/service-reset-credit-durability.test.ts index ac6cd8f2ef7..e524bda2aeb 100644 --- a/src/main/codex-accounts/service-reset-credit-durability.test.ts +++ b/src/main/codex-accounts/service-reset-credit-durability.test.ts @@ -146,9 +146,22 @@ describe('CodexAccountService config sync', () => { account, limits })! + const store = createStore(settings) + const persist = store.replaceCodexResetCreditAttemptLedgerAndFlush.getMockImplementation()! + const pendingCommit = Promise.withResolvers() + const settledCommit = Promise.withResolvers() + store.replaceCodexResetCreditAttemptLedgerAndFlush + .mockImplementationOnce(async (ledger) => { + await pendingCommit.promise + await persist(ledger) + }) + .mockImplementationOnce(async (ledger) => { + await settledCommit.promise + await persist(ledger) + }) const { CodexAccountService } = await import('./service') const service = new CodexAccountService( - createStore(settings) as never, + store as never, rateLimits as never, createRuntimeHome() as never ) @@ -157,9 +170,20 @@ describe('CodexAccountService config sync', () => { const first = service.consumeRateLimitResetCredit(idempotencyKey, expectedScope) const second = service.consumeRateLimitResetCredit(idempotencyKey, expectedScope) expect(second).toBe(first) + await vi.waitFor(() => + expect(store.replaceCodexResetCreditAttemptLedgerAndFlush).toHaveBeenCalledOnce() + ) + expect(consume).not.toHaveBeenCalled() + pendingCommit.resolve() await vi.waitFor(() => expect(consume).toHaveBeenCalledOnce()) const selectingNextAccount = service.selectAccount(nextAccount.id) finishConsume?.({ outcome: 'reset', state }) + await vi.waitFor(() => + expect(store.replaceCodexResetCreditAttemptLedgerAndFlush).toHaveBeenCalledTimes(2) + ) + expect(service.listAccounts().activeAccountId).toBe(account.id) + expect(store.getCodexResetCreditAttemptLedger().attempts[0]?.state).toBe('providerPending') + settledCommit.resolve() const resetResults = await Promise.all([first, second]) expect(resetResults).toMatchObject([ @@ -406,9 +430,10 @@ describe('CodexAccountService config sync', () => { const limits = createResetCreditLimits() const state = createResetRateLimitState(limits) const store = createStore(settings) - store.replaceCodexResetCreditAttemptLedgerAndFlush.mockImplementationOnce(() => { - throw new Error('disk full') - }) + const pendingCommit = Promise.withResolvers() + store.replaceCodexResetCreditAttemptLedgerAndFlush.mockImplementationOnce( + () => pendingCommit.promise + ) const consume = vi.fn() const expectedScope = buildCodexResetCreditExpectedScope({ target: state.codexTarget, @@ -426,9 +451,15 @@ describe('CodexAccountService config sync', () => { createRuntimeHome() as never ) - await expect( + const rejected = expect( service.consumeRateLimitResetCredit('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', expectedScope) ).rejects.toThrow('disk full') + await vi.waitFor(() => + expect(store.replaceCodexResetCreditAttemptLedgerAndFlush).toHaveBeenCalledOnce() + ) + expect(consume).not.toHaveBeenCalled() + pendingCommit.reject(new Error('disk full')) + await rejected expect(consume).not.toHaveBeenCalled() expect(store.getCodexResetCreditAttemptLedger().attempts).toEqual([]) }) @@ -453,11 +484,11 @@ describe('CodexAccountService config sync', () => { const state = createResetRateLimitState(limits) const store = createStore(settings) const persist = store.replaceCodexResetCreditAttemptLedgerAndFlush.getMockImplementation()! - store.replaceCodexResetCreditAttemptLedgerAndFlush.mockImplementation((ledger) => { + store.replaceCodexResetCreditAttemptLedgerAndFlush.mockImplementation(async (ledger) => { if (ledger.attempts[0]?.state === 'settled') { throw new Error('settle disk full') } - persist(ledger) + return persist(ledger) }) const expectedScope = buildCodexResetCreditExpectedScope({ target: state.codexTarget, diff --git a/src/main/codex-accounts/service-reset-credit-home-ownership.test.ts b/src/main/codex-accounts/service-reset-credit-home-ownership.test.ts index b5c534be417..eebc256a8c6 100644 --- a/src/main/codex-accounts/service-reset-credit-home-ownership.test.ts +++ b/src/main/codex-accounts/service-reset-credit-home-ownership.test.ts @@ -145,7 +145,7 @@ describe('Codex reset-credit managed-home ownership', () => { } ] } - fixture.store.replaceCodexResetCreditAttemptLedgerAndFlush(pendingLedger) + await fixture.store.replaceCodexResetCreditAttemptLedgerAndFlush(pendingLedger) makeHomeUnsafe(fixture.managedHomePath) const settingsBefore = structuredClone(fixture.store.getSettings()) diff --git a/src/main/codex-accounts/service-reset-credit-target-routing.test.ts b/src/main/codex-accounts/service-reset-credit-target-routing.test.ts index ebfe4e3ac1c..1485794c5f9 100644 --- a/src/main/codex-accounts/service-reset-credit-target-routing.test.ts +++ b/src/main/codex-accounts/service-reset-credit-target-routing.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { existsSync } from 'node:fs' import { buildCodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope' import { createManagedHome, @@ -329,7 +330,7 @@ describe('CodexAccountService config sync', () => { limits })! const store = createStore(settings) - store.replaceCodexResetCreditAttemptLedgerAndFlush({ + await store.replaceCodexResetCreditAttemptLedgerAndFlush({ version: 1, attempts: [ { @@ -379,7 +380,7 @@ describe('CodexAccountService config sync', () => { limits })! const store = createStore(settings) - store.replaceCodexResetCreditAttemptLedgerAndFlush({ + await store.replaceCodexResetCreditAttemptLedgerAndFlush({ version: 1, attempts: [ { @@ -414,7 +415,7 @@ describe('CodexAccountService config sync', () => { expect(store.getCodexResetCreditAttemptLedger().attempts).toEqual([]) }) - it('keeps reset attempts fail-closed when removal cannot persist their purge', async () => { + it('reports account removal while keeping reset attempts guarded after a failed purge', async () => { const managedHomePath = createManagedHome(testState.userDataDir, 'account-1') const account = { id: 'account-1', @@ -438,7 +439,7 @@ describe('CodexAccountService config sync', () => { limits })! const store = createStore(settings) - store.replaceCodexResetCreditAttemptLedgerAndFlush({ + await store.replaceCodexResetCreditAttemptLedgerAndFlush({ version: 1, attempts: [ { @@ -459,11 +460,17 @@ describe('CodexAccountService config sync', () => { } as never, createRuntimeHome() as never ) - vi.spyOn(store, 'replaceCodexResetCreditAttemptLedgerAndFlush').mockImplementationOnce(() => { - throw new Error('disk full') - }) + const failure = new Error('disk full') + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(store, 'replaceCodexResetCreditAttemptLedgerAndFlush').mockRejectedValueOnce(failure) - await expect(service.removeAccount('account-1')).rejects.toThrow('disk full') + await expect(service.removeAccount('account-1')).resolves.toMatchObject({ accounts: [] }) + expect(store.getSettings().codexManagedAccounts).toEqual([]) + expect(existsSync(managedHomePath)).toBe(false) + expect(warning).toHaveBeenCalledWith( + '[codex-accounts] Removed account, but credit ledger cleanup failed:', + failure + ) await expect(service.consumeCurrentRateLimitResetCredit()).rejects.toThrow('unknown outcome') expect(consume).not.toHaveBeenCalled() }) diff --git a/src/main/codex-accounts/service-test-harness.ts b/src/main/codex-accounts/service-test-harness.ts index 4587788ec67..8b81cb2125f 100644 --- a/src/main/codex-accounts/service-test-harness.ts +++ b/src/main/codex-accounts/service-test-harness.ts @@ -56,9 +56,11 @@ export function createStore(settings: GlobalSettings) { return settings }), getCodexResetCreditAttemptLedger: vi.fn(() => structuredClone(resetLedger)), - replaceCodexResetCreditAttemptLedgerAndFlush: vi.fn((next: CodexResetCreditAttemptLedger) => { - resetLedger = structuredClone(next) - }) + replaceCodexResetCreditAttemptLedgerAndFlush: vi.fn( + async (next: CodexResetCreditAttemptLedger) => { + resetLedger = structuredClone(next) + } + ) } } diff --git a/src/main/codex-cli/codex-home-process-lock.ts b/src/main/codex-cli/codex-home-process-lock.ts index ef91a5705d9..63b64deaf8e 100644 --- a/src/main/codex-cli/codex-home-process-lock.ts +++ b/src/main/codex-cli/codex-home-process-lock.ts @@ -17,12 +17,15 @@ export function resolveCodexHomeProcessLockKey(codexHomePath?: string | null): s export function resolveCodexHomeProcessLockKeyForSpawnEnv( env: NodeJS.ProcessEnv | undefined, - wslDistro?: string | null + wslDistro?: string | null, + commandEnv?: Record ): string { if (wslDistro) { // buildWslLauncherEnv forwards only explicit values that differ from the // host process; all other cases use the distro user's default home. - const codexHome = env?.CODEX_HOME !== process.env.CODEX_HOME ? (env?.CODEX_HOME ?? null) : null + const codexHome = + commandEnv?.CODEX_HOME ?? + (env?.CODEX_HOME !== process.env.CODEX_HOME ? (env?.CODEX_HOME ?? null) : null) // Why: WSL spawns carry a Linux CODEX_HOME; key it through the same UNC // normalization the probe's \\wsl$ home path uses so both lanes collide. // Without an explicit home the distro default is unknowable from the host; diff --git a/src/main/codex-usage/codex-session-file-discovery.ts b/src/main/codex-usage/codex-session-file-discovery.ts index 946f9f1838c..bf2595fc340 100644 --- a/src/main/codex-usage/codex-session-file-discovery.ts +++ b/src/main/codex-usage/codex-session-file-discovery.ts @@ -138,8 +138,9 @@ async function getCodexSessionFileAliasKey(filePath: string): Promise { async function getPhysicalFileAliasKey(filePath: string): Promise { try { - const fileStat = await stat(filePath) - if (fileStat.ino !== 0) { + // Windows file IDs can exceed the precision of JavaScript numbers. + const fileStat = await stat(filePath, { bigint: true }) + if (fileStat.ino !== 0n) { return `${fileStat.dev}:${fileStat.ino}` } } catch {} diff --git a/src/main/codex-usage/scanner-file-identity.test.ts b/src/main/codex-usage/scanner-file-identity.test.ts new file mode 100644 index 00000000000..f0d85076457 --- /dev/null +++ b/src/main/codex-usage/scanner-file-identity.test.ts @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { linkSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import type { PathLike } from 'node:fs' +import type * as FsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const fixture = vi.hoisted(() => ({ + root: '', + identities: new Map(), + statErrors: new Set(), + realpathErrors: new Set() +})) + +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises') + return { + ...actual, + stat: async (filePath: PathLike, options?: { bigint?: boolean }) => { + const path = String(filePath) + if (fixture.statErrors.has(path)) { + throw new Error('Fixture stat unavailable') + } + const identity = fixture.identities.get(path) + if (options?.bigint) { + const stats = await actual.stat(filePath, { bigint: true }) + return identity ? Object.assign(stats, identity) : stats + } + const stats = await actual.stat(filePath) + return identity + ? Object.assign(stats, { dev: Number(identity.dev), ino: Number(identity.ino) }) + : stats + }, + realpath: async (filePath: PathLike) => { + if (fixture.realpathErrors.has(String(filePath))) { + throw new Error('Fixture realpath unavailable') + } + return actual.realpath(filePath) + } + } +}) + +vi.mock('../codex/codex-home-paths', () => ({ + getOrcaManagedCodexHomePath: () => join(fixture.root, 'runtime'), + getSystemCodexHomePath: () => join(fixture.root, 'system') +})) + +vi.mock('../codex/codex-account-home-discovery', () => ({ + getCodexAccountHomeSessionDirectories: () => [] +})) + +vi.mock('../codex/codex-session-bridge', () => ({ + getLegacyCopiedCodexSessionBridgeScanPreference: () => null +})) + +import { listCodexSessionFiles } from './codex-session-file-discovery' +import { scanCodexUsageFiles } from './scanner' + +function writeSession(name: string, tokens = 10): string { + const filePath = join(fixture.root, 'runtime', 'sessions', `${name}.jsonl`) + const records = [ + { type: 'session_meta', payload: { id: name, cwd: fixture.root } }, + { + timestamp: '2026-09-20T12:00:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + model: 'gpt-5-codex', + last_token_usage: { input_tokens: tokens, total_tokens: tokens } + } + } + } + ] + writeFileSync(filePath, `${records.map((record) => JSON.stringify(record)).join('\n')}\n`) + return filePath +} + +beforeEach(() => { + fixture.root = mkdtempSync(join(tmpdir(), 'orca-codex-file-identity-')) + mkdirSync(join(fixture.root, 'runtime', 'sessions'), { recursive: true }) + fixture.identities.clear() + fixture.statErrors.clear() + fixture.realpathErrors.clear() +}) + +afterEach(() => { + rmSync(fixture.root, { recursive: true, force: true }) +}) + +describe('Codex session physical file identity', () => { + it('keeps adjacent large inode IDs distinct when numeric stats would round them together', async () => { + const first = writeSession('first') + const second = writeSession('second') + const firstIno = 2n ** 53n + const secondIno = firstIno + 1n + expect(Number(firstIno)).toBe(Number(secondIno)) + fixture.identities.set(first, { dev: 1n, ino: firstIno }) + fixture.identities.set(second, { dev: 1n, ino: secondIno }) + + expect(await listCodexSessionFiles()).toEqual([first, second]) + }) + + it('keeps the device part of the identity exact too', async () => { + const first = writeSession('first') + const second = writeSession('second') + fixture.identities.set(first, { dev: 2n ** 53n, ino: 7n }) + fixture.identities.set(second, { dev: 2n ** 53n + 1n, ino: 7n }) + + expect(await listCodexSessionFiles()).toEqual([first, second]) + }) + + it('counts both distinct sessions and preserves numeric timestamps, sizes and cache reuse', async () => { + const first = writeSession('first', 10) + const second = writeSession('second', 20) + fixture.identities.set(first, { dev: 1n, ino: 2n ** 53n }) + fixture.identities.set(second, { dev: 1n, ino: 2n ** 53n + 1n }) + + const result = await scanCodexUsageFiles([], []) + expect(result.dailyAggregates.reduce((total, row) => total + row.totalTokens, 0)).toBe(30) + expect(result.sessions).toHaveLength(2) + const actual = await vi.importActual('node:fs/promises') + for (const file of result.processedFiles) { + const stats = await actual.stat(file.path) + expect(file.mtimeMs).toBe(stats.mtimeMs) + expect(file.size).toBe(stats.size) + } + const cached = await scanCodexUsageFiles([], result.processedFiles) + expect(cached.dailyAggregates).toEqual(result.dailyAggregates) + expect(cached.processedFiles[0]).toBe(result.processedFiles[0]) + expect(cached.processedFiles[1]).toBe(result.processedFiles[1]) + }) + + it('deduplicates real hardlink aliases across session homes', async () => { + const original = writeSession('original') + mkdirSync(join(fixture.root, 'system', 'sessions'), { recursive: true }) + const alias = join(fixture.root, 'system', 'sessions', 'alias.jsonl') + linkSync(original, alias) + + expect(await listCodexSessionFiles()).toEqual([original, alias].sort().slice(0, 1)) + const result = await scanCodexUsageFiles([], []) + expect(result.dailyAggregates.reduce((total, row) => total + row.totalTokens, 0)).toBe(10) + }) + + it('does not collapse distinct zero-inode files', async () => { + const first = writeSession('first') + const second = writeSession('second') + fixture.identities.set(first, { dev: 1n, ino: 0n }) + fixture.identities.set(second, { dev: 1n, ino: 0n }) + + expect(await listCodexSessionFiles()).toEqual([first, second]) + }) + + it.each(['zero inode', 'stat failure'])('deduplicates canonical paths after %s', async (mode) => { + const original = writeSession('original') + symlinkSync( + join(fixture.root, 'runtime'), + join(fixture.root, 'system'), + process.platform === 'win32' ? 'junction' : 'dir' + ) + const alias = join(fixture.root, 'system', 'sessions', 'original.jsonl') + for (const path of [original, alias]) { + if (mode === 'zero inode') { + fixture.identities.set(path, { dev: 1n, ino: 0n }) + } else { + fixture.statErrors.add(path) + } + } + + expect(await listCodexSessionFiles()).toEqual([original, alias].sort().slice(0, 1)) + }) + + it('preserves path spelling when both stat and realpath are unavailable', async () => { + const first = writeSession('MiXeD-first') + const second = writeSession('MiXeD-second') + for (const path of [first, second]) { + fixture.statErrors.add(path) + fixture.realpathErrors.add(path) + } + + expect(await listCodexSessionFiles()).toEqual([first, second]) + }) +}) diff --git a/src/main/codex-usage/store.ts b/src/main/codex-usage/store.ts index f11121f5d0d..7e91fb3945a 100644 --- a/src/main/codex-usage/store.ts +++ b/src/main/codex-usage/store.ts @@ -1,3 +1,4 @@ +import { codexOpenCodeTokenSessions } from '../usage/agent-token-usage' import { app } from 'electron' import { join } from 'node:path' import type { @@ -84,6 +85,10 @@ export class CodexUsageStore extends UsageProviderStoreLifecycle< > { constructor(store: Pick) { super(store, { + tokenUsage: { + provider: 'codex', + selectSessions: (state) => codexOpenCodeTokenSessions(state.sessions) + }, logTag: '[codex-usage]', resolveCacheFile: getCodexUsageFile, createDefaultState: getDefaultState, diff --git a/src/main/codex/__fixtures__/codex-app-server-turn-endings.jsonl b/src/main/codex/__fixtures__/codex-app-server-turn-endings.jsonl new file mode 100644 index 00000000000..7889b24d113 --- /dev/null +++ b/src/main/codex/__fixtures__/codex-app-server-turn-endings.jsonl @@ -0,0 +1,172 @@ +{"case":"0.141.0-bad-model","t":371,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000001","status":{"type":"active"}}} +{"case":"0.141.0-bad-model","t":371,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000001","turn":{"id":"00000000-0000-7000-8000-000000000002","status":"inProgress"}}} +{"case":"0.141.0-bad-model","t":1745,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000001","turnId":"00000000-0000-7000-8000-000000000002","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000003","clientId":null,"content":[]}}} +{"case":"0.141.0-bad-model","t":1745,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000001","turnId":"00000000-0000-7000-8000-000000000002","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000003","clientId":null,"content":[]}}} +{"case":"0.141.0-bad-model","t":2831,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000001","status":{"type":"systemError"}}} +{"case":"0.141.0-bad-model","t":2831,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000001","turnId":"00000000-0000-7000-8000-000000000002","willRetry":false,"error":{"message":"{\"type\":\"error\",\"status\":400,\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The 'gpt-qa-nonexistent' model is not supported when using Codex with a ChatGPT account.\"}}","codexErrorInfo":"other"}}} +{"case":"0.141.0-bad-model","t":2831,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000001","turn":{"id":"00000000-0000-7000-8000-000000000002","status":"failed","error":{"message":"{\"type\":\"error\",\"status\":400,\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The 'gpt-qa-nonexistent' model is not supported when using Codex with a ChatGPT account.\"}}","codexErrorInfo":"other"},"durationMs":2488}}} +{"case":"0.141.0-bad-turn-cwd","t":1458,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000004","status":{"type":"active"}}} +{"case":"0.141.0-bad-turn-cwd","t":1458,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000004","turn":{"id":"00000000-0000-7000-8000-000000000005","status":"inProgress"}}} +{"case":"0.141.0-bad-turn-cwd","t":5792,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000004","turnId":"00000000-0000-7000-8000-000000000005","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000006","clientId":null,"content":[]}}} +{"case":"0.141.0-bad-turn-cwd","t":5792,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000004","turnId":"00000000-0000-7000-8000-000000000005","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000006","clientId":null,"content":[]}}} +{"case":"0.141.0-bad-turn-cwd","t":7645,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000004","turnId":"00000000-0000-7000-8000-000000000005","item":{"type":"agentMessage","id":"msg_1","text":""}}} +{"case":"0.141.0-bad-turn-cwd","t":7938,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000004","turnId":"00000000-0000-7000-8000-000000000005","item":{"type":"agentMessage","id":"msg_1","text":"pong"}}} +{"case":"0.141.0-bad-turn-cwd","t":7959,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000004","status":{"type":"idle"}}} +{"case":"0.141.0-bad-turn-cwd","t":7959,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000004","turn":{"id":"00000000-0000-7000-8000-000000000005","status":"completed","durationMs":6532}}} +{"case":"0.141.0-compact-overloaded","t":582,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000007","status":{"type":"active"}}} +{"case":"0.141.0-compact-overloaded","t":582,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000007","turn":{"id":"00000000-0000-7000-8000-000000000008","status":"inProgress"}}} +{"case":"0.141.0-compact-overloaded","t":2940,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000007","turnId":"00000000-0000-7000-8000-000000000008","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000009","clientId":null,"content":[]}}} +{"case":"0.141.0-compact-overloaded","t":2940,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000007","turnId":"00000000-0000-7000-8000-000000000008","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000009","clientId":null,"content":[]}}} +{"case":"0.141.0-compact-overloaded","t":3011,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000007","turnId":"00000000-0000-7000-8000-000000000008","item":{"type":"agentMessage","id":"msg_ok","text":"pong"}}} +{"case":"0.141.0-compact-overloaded","t":3011,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000007","turnId":"00000000-0000-7000-8000-000000000008","item":{"type":"agentMessage","id":"msg_ok","text":"pong"}}} +{"case":"0.141.0-compact-overloaded","t":3017,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000007","status":{"type":"idle"}}} +{"case":"0.141.0-compact-overloaded","t":3017,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000007","turn":{"id":"00000000-0000-7000-8000-000000000008","status":"completed","durationMs":2474}}} +{"case":"0.141.0-compact-overloaded","t":3076,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000007","status":{"type":"active"}}} +{"case":"0.141.0-compact-overloaded","t":3076,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000007","turn":{"id":"00000000-0000-7000-8000-000000000010","status":"inProgress"}}} +{"case":"0.141.0-compact-overloaded","t":3081,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000007","turnId":"00000000-0000-7000-8000-000000000010","item":{"type":"contextCompaction","id":"00000000-0000-7000-8000-000000000011"}}} +{"case":"0.141.0-compact-overloaded","t":3684,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000007","turnId":"00000000-0000-7000-8000-000000000010","willRetry":true,"error":{"message":"Reconnecting... 1/2","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"Selected model is at capacity. Please try a different model."}}} +{"case":"0.141.0-compact-overloaded","t":4508,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000007","turnId":"00000000-0000-7000-8000-000000000010","willRetry":true,"error":{"message":"Reconnecting... 2/2","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"Selected model is at capacity. Please try a different model."}}} +{"case":"0.141.0-compact-overloaded","t":5511,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000007","status":{"type":"systemError"}}} +{"case":"0.141.0-compact-overloaded","t":5511,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000007","turnId":"00000000-0000-7000-8000-000000000010","willRetry":false,"error":{"message":"Selected model is at capacity. Please try a different model.","codexErrorInfo":"serverOverloaded"}}} +{"case":"0.141.0-compact-overloaded","t":5511,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000007","turn":{"id":"00000000-0000-7000-8000-000000000010","status":"failed","error":{"message":"Selected model is at capacity. Please try a different model.","codexErrorInfo":"serverOverloaded"},"durationMs":2435}}} +{"case":"0.141.0-conn-refused","t":588,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000012","status":{"type":"active"}}} +{"case":"0.141.0-conn-refused","t":588,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000012","turn":{"id":"00000000-0000-7000-8000-000000000013","status":"inProgress"}}} +{"case":"0.141.0-conn-refused","t":700,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000012","turnId":"00000000-0000-7000-8000-000000000013","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000014","clientId":null,"content":[]}}} +{"case":"0.141.0-conn-refused","t":700,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000012","turnId":"00000000-0000-7000-8000-000000000013","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000014","clientId":null,"content":[]}}} +{"case":"0.141.0-conn-refused","t":1354,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000012","turnId":"00000000-0000-7000-8000-000000000013","willRetry":true,"error":{"message":"Reconnecting... 1/2","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"stream disconnected before completion: error sending request for url (http://127.0.0.1:9/v1/responses)"}}} +{"case":"0.141.0-conn-refused","t":2134,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000012","turnId":"00000000-0000-7000-8000-000000000013","willRetry":true,"error":{"message":"Reconnecting... 2/2","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"stream disconnected before completion: error sending request for url (http://127.0.0.1:9/v1/responses)"}}} +{"case":"0.141.0-conn-refused","t":3116,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000012","status":{"type":"systemError"}}} +{"case":"0.141.0-conn-refused","t":3116,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000012","turnId":"00000000-0000-7000-8000-000000000013","willRetry":false,"error":{"message":"stream disconnected before completion: error sending request for url (http://127.0.0.1:9/v1/responses)","codexErrorInfo":"other"}}} +{"case":"0.141.0-conn-refused","t":3116,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000012","turn":{"id":"00000000-0000-7000-8000-000000000013","status":"failed","error":{"message":"stream disconnected before completion: error sending request for url (http://127.0.0.1:9/v1/responses)","codexErrorInfo":"other"},"durationMs":2556}}} +{"case":"0.141.0-control","t":487,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000015","status":{"type":"active"}}} +{"case":"0.141.0-control","t":487,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000015","turn":{"id":"00000000-0000-7000-8000-000000000016","status":"inProgress"}}} +{"case":"0.141.0-control","t":2657,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000015","turnId":"00000000-0000-7000-8000-000000000016","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000017","clientId":null,"content":[]}}} +{"case":"0.141.0-control","t":2657,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000015","turnId":"00000000-0000-7000-8000-000000000016","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000017","clientId":null,"content":[]}}} +{"case":"0.141.0-control","t":4651,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000015","turnId":"00000000-0000-7000-8000-000000000016","item":{"type":"agentMessage","id":"msg_2","text":""}}} +{"case":"0.141.0-control","t":4661,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000015","turnId":"00000000-0000-7000-8000-000000000016","item":{"type":"agentMessage","id":"msg_2","text":"pong"}}} +{"case":"0.141.0-control","t":4811,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000015","status":{"type":"idle"}}} +{"case":"0.141.0-control","t":4811,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000015","turn":{"id":"00000000-0000-7000-8000-000000000016","status":"completed","durationMs":4351}}} +{"case":"0.141.0-interrupt","t":670,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000018","status":{"type":"active"}}} +{"case":"0.141.0-interrupt","t":670,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000018","turn":{"id":"00000000-0000-7000-8000-000000000019","status":"inProgress"}}} +{"case":"0.141.0-interrupt","t":2618,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000018","turnId":"00000000-0000-7000-8000-000000000019","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000020","clientId":null,"content":[]}}} +{"case":"0.141.0-interrupt","t":2618,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000018","turnId":"00000000-0000-7000-8000-000000000019","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000020","clientId":null,"content":[]}}} +{"case":"0.141.0-interrupt","t":4153,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000018","turnId":"00000000-0000-7000-8000-000000000019","item":{"type":"agentMessage","id":"msg_3","text":""}}} +{"case":"0.141.0-interrupt","t":5669,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000018","status":{"type":"idle"}}} +{"case":"0.141.0-interrupt","t":5669,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000018","turn":{"id":"00000000-0000-7000-8000-000000000019","status":"interrupted","durationMs":5027}}} +{"case":"0.141.0-model-too-new-a","t":1276,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000021","status":{"type":"active"}}} +{"case":"0.141.0-model-too-new-a","t":1276,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000021","turn":{"id":"00000000-0000-7000-8000-000000000022","status":"inProgress"}}} +{"case":"0.141.0-model-too-new-a","t":6105,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000021","turnId":"00000000-0000-7000-8000-000000000022","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000023","clientId":null,"content":[]}}} +{"case":"0.141.0-model-too-new-a","t":6105,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000021","turnId":"00000000-0000-7000-8000-000000000022","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000023","clientId":null,"content":[]}}} +{"case":"0.141.0-model-too-new-a","t":7424,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000021","status":{"type":"systemError"}}} +{"case":"0.141.0-model-too-new-a","t":7424,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000021","turnId":"00000000-0000-7000-8000-000000000022","willRetry":false,"error":{"message":"{\"type\":\"error\",\"status\":400,\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The 'gpt-6-astra' model requires a newer version of Codex. Please upgrade to the latest app or CLI and try again.\"}}","codexErrorInfo":"other"}}} +{"case":"0.141.0-model-too-new-a","t":7424,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000021","turn":{"id":"00000000-0000-7000-8000-000000000022","status":"failed","error":{"message":"{\"type\":\"error\",\"status\":400,\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The 'gpt-6-astra' model requires a newer version of Codex. Please upgrade to the latest app or CLI and try again.\"}}","codexErrorInfo":"other"},"durationMs":6195}}} +{"case":"0.141.0-model-too-new-b","t":387,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000024","status":{"type":"active"}}} +{"case":"0.141.0-model-too-new-b","t":387,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000024","turn":{"id":"00000000-0000-7000-8000-000000000025","status":"inProgress"}}} +{"case":"0.141.0-model-too-new-b","t":1162,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000024","turnId":"00000000-0000-7000-8000-000000000025","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000026","clientId":null,"content":[]}}} +{"case":"0.141.0-model-too-new-b","t":1162,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000024","turnId":"00000000-0000-7000-8000-000000000025","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000026","clientId":null,"content":[]}}} +{"case":"0.141.0-model-too-new-b","t":1993,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000024","status":{"type":"systemError"}}} +{"case":"0.141.0-model-too-new-b","t":1993,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000024","turnId":"00000000-0000-7000-8000-000000000025","willRetry":false,"error":{"message":"{\"type\":\"error\",\"status\":400,\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The 'gpt-6-astra' model requires a newer version of Codex. Please upgrade to the latest app or CLI and try again.\"}}","codexErrorInfo":"other"}}} +{"case":"0.141.0-model-too-new-b","t":1993,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000024","turn":{"id":"00000000-0000-7000-8000-000000000025","status":"failed","error":{"message":"{\"type\":\"error\",\"status\":400,\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The 'gpt-6-astra' model requires a newer version of Codex. Please upgrade to the latest app or CLI and try again.\"}}","codexErrorInfo":"other"},"durationMs":1633}}} +{"case":"0.141.0-overloaded-503","t":407,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000027","status":{"type":"active"}}} +{"case":"0.141.0-overloaded-503","t":407,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000027","turn":{"id":"00000000-0000-7000-8000-000000000028","status":"inProgress"}}} +{"case":"0.141.0-overloaded-503","t":574,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000027","turnId":"00000000-0000-7000-8000-000000000028","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000029","clientId":null,"content":[]}}} +{"case":"0.141.0-overloaded-503","t":574,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000027","turnId":"00000000-0000-7000-8000-000000000028","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000029","clientId":null,"content":[]}}} +{"case":"0.141.0-overloaded-503","t":1296,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000027","status":{"type":"systemError"}}} +{"case":"0.141.0-overloaded-503","t":1296,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000027","turnId":"00000000-0000-7000-8000-000000000028","willRetry":false,"error":{"message":"Selected model is at capacity. Please try a different model.","codexErrorInfo":"serverOverloaded"}}} +{"case":"0.141.0-overloaded-503","t":1296,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000027","turn":{"id":"00000000-0000-7000-8000-000000000028","status":"failed","error":{"message":"Selected model is at capacity. Please try a different model.","codexErrorInfo":"serverOverloaded"},"durationMs":921}}} +{"case":"0.141.0-overloaded-sse","t":510,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000030","status":{"type":"active"}}} +{"case":"0.141.0-overloaded-sse","t":510,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000030","turn":{"id":"00000000-0000-7000-8000-000000000031","status":"inProgress"}}} +{"case":"0.141.0-overloaded-sse","t":611,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000030","turnId":"00000000-0000-7000-8000-000000000031","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000032","clientId":null,"content":[]}}} +{"case":"0.141.0-overloaded-sse","t":611,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000030","turnId":"00000000-0000-7000-8000-000000000031","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000032","clientId":null,"content":[]}}} +{"case":"0.141.0-overloaded-sse","t":695,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000030","status":{"type":"systemError"}}} +{"case":"0.141.0-overloaded-sse","t":695,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000030","turnId":"00000000-0000-7000-8000-000000000031","willRetry":false,"error":{"message":"Selected model is at capacity. Please try a different model.","codexErrorInfo":"serverOverloaded"}}} +{"case":"0.141.0-overloaded-sse","t":695,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000030","turn":{"id":"00000000-0000-7000-8000-000000000031","status":"failed","error":{"message":"Selected model is at capacity. Please try a different model.","codexErrorInfo":"serverOverloaded"},"durationMs":213}}} +{"case":"0.141.0-stream-drop","t":510,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000033","status":{"type":"active"}}} +{"case":"0.141.0-stream-drop","t":510,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000033","turn":{"id":"00000000-0000-7000-8000-000000000034","status":"inProgress"}}} +{"case":"0.141.0-stream-drop","t":621,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000033","turnId":"00000000-0000-7000-8000-000000000034","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000035","clientId":null,"content":[]}}} +{"case":"0.141.0-stream-drop","t":621,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000033","turnId":"00000000-0000-7000-8000-000000000034","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000035","clientId":null,"content":[]}}} +{"case":"0.141.0-stream-drop","t":740,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000033","turnId":"00000000-0000-7000-8000-000000000034","willRetry":true,"error":{"message":"Reconnecting... 1/2","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"stream disconnected before completion: stream closed before response.completed"}}} +{"case":"0.141.0-stream-drop","t":942,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000033","turnId":"00000000-0000-7000-8000-000000000034","willRetry":true,"error":{"message":"Reconnecting... 2/2","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"stream disconnected before completion: stream closed before response.completed"}}} +{"case":"0.141.0-stream-drop","t":1320,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000033","status":{"type":"systemError"}}} +{"case":"0.141.0-stream-drop","t":1320,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000033","turnId":"00000000-0000-7000-8000-000000000034","willRetry":false,"error":{"message":"stream disconnected before completion: stream closed before response.completed","codexErrorInfo":"other"}}} +{"case":"0.141.0-stream-drop","t":1320,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000033","turn":{"id":"00000000-0000-7000-8000-000000000034","status":"failed","error":{"message":"stream disconnected before completion: stream closed before response.completed","codexErrorInfo":"other"},"durationMs":838}}} +{"case":"0.158.0-bad-model","t":1232,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000036","status":{"type":"active"}}} +{"case":"0.158.0-bad-model","t":1232,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000036","turn":{"id":"00000000-0000-7000-8000-000000000037","status":"inProgress"}}} +{"case":"0.158.0-bad-model","t":7177,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000036","turnId":"00000000-0000-7000-8000-000000000037","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000038","clientId":null,"content":[]}}} +{"case":"0.158.0-bad-model","t":7215,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000036","turnId":"00000000-0000-7000-8000-000000000037","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000038","clientId":null,"content":[]}}} +{"case":"0.158.0-bad-model","t":8236,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000036","status":{"type":"systemError"}}} +{"case":"0.158.0-bad-model","t":8236,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000036","turnId":"00000000-0000-7000-8000-000000000037","willRetry":false,"error":{"message":"{\"type\":\"error\",\"status\":400,\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The 'gpt-qa-nonexistent' model is not supported when using Codex with a ChatGPT account.\"}}","codexErrorInfo":"other"}}} +{"case":"0.158.0-bad-model","t":8257,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000036","turn":{"id":"00000000-0000-7000-8000-000000000037","status":"failed","error":{"message":"{\"type\":\"error\",\"status\":400,\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The 'gpt-qa-nonexistent' model is not supported when using Codex with a ChatGPT account.\"}}","codexErrorInfo":"other"},"durationMs":7576}}} +{"case":"0.158.0-bad-turn-cwd","t":4437,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000039","status":{"type":"active"}}} +{"case":"0.158.0-bad-turn-cwd","t":4438,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000039","turn":{"id":"00000000-0000-7000-8000-000000000040","status":"inProgress"}}} +{"case":"0.158.0-bad-turn-cwd","t":7840,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000039","turnId":"00000000-0000-7000-8000-000000000040","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000041","clientId":null,"content":[]}}} +{"case":"0.158.0-bad-turn-cwd","t":7851,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000039","turnId":"00000000-0000-7000-8000-000000000040","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000041","clientId":null,"content":[]}}} +{"case":"0.158.0-bad-turn-cwd","t":9220,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000039","turnId":"00000000-0000-7000-8000-000000000040","item":{"type":"agentMessage","id":"msg_4","text":""}}} +{"case":"0.158.0-bad-turn-cwd","t":9421,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000039","turnId":"00000000-0000-7000-8000-000000000040","item":{"type":"agentMessage","id":"msg_4","text":"pong"}}} +{"case":"0.158.0-bad-turn-cwd","t":9566,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000039","status":{"type":"idle"}}} +{"case":"0.158.0-bad-turn-cwd","t":9566,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000039","turn":{"id":"00000000-0000-7000-8000-000000000040","status":"completed","durationMs":5172}}} +{"case":"0.158.0-compact-overloaded","t":943,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000042","status":{"type":"active"}}} +{"case":"0.158.0-compact-overloaded","t":944,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000042","turn":{"id":"00000000-0000-7000-8000-000000000043","status":"inProgress"}}} +{"case":"0.158.0-compact-overloaded","t":5760,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000042","turnId":"00000000-0000-7000-8000-000000000043","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000044","clientId":null,"content":[]}}} +{"case":"0.158.0-compact-overloaded","t":5765,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000042","turnId":"00000000-0000-7000-8000-000000000043","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000044","clientId":null,"content":[]}}} +{"case":"0.158.0-compact-overloaded","t":5770,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000042","turnId":"00000000-0000-7000-8000-000000000043","item":{"type":"agentMessage","id":"msg_ok","text":"pong"}}} +{"case":"0.158.0-compact-overloaded","t":5771,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000042","turnId":"00000000-0000-7000-8000-000000000043","item":{"type":"agentMessage","id":"msg_ok","text":"pong"}}} +{"case":"0.158.0-compact-overloaded","t":5795,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000042","status":{"type":"idle"}}} +{"case":"0.158.0-compact-overloaded","t":5795,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000042","turn":{"id":"00000000-0000-7000-8000-000000000043","status":"completed","durationMs":4899}}} +{"case":"0.158.0-compact-overloaded","t":5820,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000042","status":{"type":"active"}}} +{"case":"0.158.0-compact-overloaded","t":5820,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000042","turn":{"id":"00000000-0000-7000-8000-000000000045","status":"inProgress"}}} +{"case":"0.158.0-compact-overloaded","t":5821,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000042","turnId":"00000000-0000-7000-8000-000000000045","item":{"type":"contextCompaction","id":"00000000-0000-7000-8000-000000000046"}}} +{"case":"0.158.0-compact-overloaded","t":6421,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000042","turnId":"00000000-0000-7000-8000-000000000045","willRetry":true,"error":{"message":"Reconnecting... 1/2","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"Selected model is at capacity. Please try a different model."}}} +{"case":"0.158.0-compact-overloaded","t":7232,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000042","turnId":"00000000-0000-7000-8000-000000000045","willRetry":true,"error":{"message":"Reconnecting... 2/2","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"Selected model is at capacity. Please try a different model."}}} +{"case":"0.158.0-compact-overloaded","t":8204,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000042","status":{"type":"systemError"}}} +{"case":"0.158.0-compact-overloaded","t":8204,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000042","turnId":"00000000-0000-7000-8000-000000000045","willRetry":false,"error":{"message":"Selected model is at capacity. Please try a different model.","codexErrorInfo":"serverOverloaded"}}} +{"case":"0.158.0-compact-overloaded","t":8207,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000042","turn":{"id":"00000000-0000-7000-8000-000000000045","status":"failed","error":{"message":"Selected model is at capacity. Please try a different model.","codexErrorInfo":"serverOverloaded"},"durationMs":2388}}} +{"case":"0.158.0-conn-refused","t":1343,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000047","status":{"type":"active"}}} +{"case":"0.158.0-conn-refused","t":1343,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000047","turn":{"id":"00000000-0000-7000-8000-000000000048","status":"inProgress"}}} +{"case":"0.158.0-conn-refused","t":2002,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000047","turnId":"00000000-0000-7000-8000-000000000048","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000049","clientId":null,"content":[]}}} +{"case":"0.158.0-conn-refused","t":2013,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000047","turnId":"00000000-0000-7000-8000-000000000048","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000049","clientId":null,"content":[]}}} +{"case":"0.158.0-conn-refused","t":2616,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000047","turnId":"00000000-0000-7000-8000-000000000048","willRetry":true,"error":{"message":"Reconnecting... waiting for network","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"Connection failed: error sending request"}}} +{"case":"0.158.0-conn-refused","t":8185,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000047","turnId":"00000000-0000-7000-8000-000000000048","willRetry":true,"error":{"message":"Reconnecting... waiting for network","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"Connection failed: error sending request"}}} +{"case":"0.158.0-conn-refused","t":18778,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000047","turnId":"00000000-0000-7000-8000-000000000048","willRetry":true,"error":{"message":"Reconnecting... waiting for network","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"Connection failed: error sending request"}}} +{"case":"0.158.0-conn-refused","t":39406,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000047","turnId":"00000000-0000-7000-8000-000000000048","willRetry":true,"error":{"message":"Reconnecting... waiting for network","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"Connection failed: error sending request"}}} +{"case":"0.158.0-conn-refused","t":79975,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000047","turnId":"00000000-0000-7000-8000-000000000048","willRetry":true,"error":{"message":"Reconnecting... waiting for network","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"Connection failed: error sending request"}}} +{"case":"0.158.0-conn-refused","t":140587,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000047","turnId":"00000000-0000-7000-8000-000000000048","willRetry":true,"error":{"message":"Reconnecting... waiting for network","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"Connection failed: error sending request"}}} +{"case":"0.158.0-conn-refused","t":201225,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000047","turnId":"00000000-0000-7000-8000-000000000048","willRetry":true,"error":{"message":"Reconnecting... waiting for network","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"Connection failed: error sending request"}}} +{"case":"0.158.0-control","t":3620,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000050","status":{"type":"active"}}} +{"case":"0.158.0-control","t":3621,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000050","turn":{"id":"00000000-0000-7000-8000-000000000051","status":"inProgress"}}} +{"case":"0.158.0-control","t":5019,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000050","turnId":"00000000-0000-7000-8000-000000000051","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000052","clientId":null,"content":[]}}} +{"case":"0.158.0-control","t":5254,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000050","turnId":"00000000-0000-7000-8000-000000000051","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000052","clientId":null,"content":[]}}} +{"case":"0.158.0-control","t":7276,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000050","turnId":"00000000-0000-7000-8000-000000000051","item":{"type":"agentMessage","id":"msg_5","text":""}}} +{"case":"0.158.0-control","t":7480,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000050","turnId":"00000000-0000-7000-8000-000000000051","item":{"type":"agentMessage","id":"msg_5","text":"pong"}}} +{"case":"0.158.0-control","t":7659,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000050","status":{"type":"idle"}}} +{"case":"0.158.0-control","t":7659,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000050","turn":{"id":"00000000-0000-7000-8000-000000000051","status":"completed","durationMs":4520}}} +{"case":"0.158.0-interrupt","t":929,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000053","status":{"type":"active"}}} +{"case":"0.158.0-interrupt","t":929,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000053","turn":{"id":"00000000-0000-7000-8000-000000000054","status":"inProgress"}}} +{"case":"0.158.0-interrupt","t":2320,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000053","turnId":"00000000-0000-7000-8000-000000000054","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000055","clientId":null,"content":[]}}} +{"case":"0.158.0-interrupt","t":2325,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000053","turnId":"00000000-0000-7000-8000-000000000054","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000055","clientId":null,"content":[]}}} +{"case":"0.158.0-interrupt","t":4063,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000053","turnId":"00000000-0000-7000-8000-000000000054","item":{"type":"agentMessage","id":"msg_6","text":""}}} +{"case":"0.158.0-interrupt","t":5582,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000053","status":{"type":"idle"}}} +{"case":"0.158.0-interrupt","t":5582,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000053","turn":{"id":"00000000-0000-7000-8000-000000000054","status":"interrupted","durationMs":4711}}} +{"case":"0.158.0-overloaded-503","t":455,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000056","status":{"type":"active"}}} +{"case":"0.158.0-overloaded-503","t":455,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000056","turn":{"id":"00000000-0000-7000-8000-000000000057","status":"inProgress"}}} +{"case":"0.158.0-overloaded-503","t":1114,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000056","turnId":"00000000-0000-7000-8000-000000000057","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000058","clientId":null,"content":[]}}} +{"case":"0.158.0-overloaded-503","t":1131,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000056","turnId":"00000000-0000-7000-8000-000000000057","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000058","clientId":null,"content":[]}}} +{"case":"0.158.0-overloaded-503","t":1722,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000056","status":{"type":"systemError"}}} +{"case":"0.158.0-overloaded-503","t":1722,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000056","turnId":"00000000-0000-7000-8000-000000000057","willRetry":false,"error":{"message":"Selected model is at capacity. Please try a different model.","codexErrorInfo":"serverOverloaded"}}} +{"case":"0.158.0-overloaded-503","t":1753,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000056","turn":{"id":"00000000-0000-7000-8000-000000000057","status":"failed","error":{"message":"Selected model is at capacity. Please try a different model.","codexErrorInfo":"serverOverloaded"},"durationMs":1302}}} +{"case":"0.158.0-overloaded-sse","t":944,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000059","status":{"type":"active"}}} +{"case":"0.158.0-overloaded-sse","t":944,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000059","turn":{"id":"00000000-0000-7000-8000-000000000060","status":"inProgress"}}} +{"case":"0.158.0-overloaded-sse","t":1196,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000059","turnId":"00000000-0000-7000-8000-000000000060","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000061","clientId":null,"content":[]}}} +{"case":"0.158.0-overloaded-sse","t":1199,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000059","turnId":"00000000-0000-7000-8000-000000000060","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000061","clientId":null,"content":[]}}} +{"case":"0.158.0-overloaded-sse","t":1204,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000059","status":{"type":"systemError"}}} +{"case":"0.158.0-overloaded-sse","t":1204,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000059","turnId":"00000000-0000-7000-8000-000000000060","willRetry":false,"error":{"message":"Selected model is at capacity. Please try a different model.","codexErrorInfo":"serverOverloaded"}}} +{"case":"0.158.0-overloaded-sse","t":1206,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000059","turn":{"id":"00000000-0000-7000-8000-000000000060","status":"failed","error":{"message":"Selected model is at capacity. Please try a different model.","codexErrorInfo":"serverOverloaded"},"durationMs":481}}} +{"case":"0.158.0-stream-drop","t":1136,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000062","status":{"type":"active"}}} +{"case":"0.158.0-stream-drop","t":1136,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000062","turn":{"id":"00000000-0000-7000-8000-000000000063","status":"inProgress"}}} +{"case":"0.158.0-stream-drop","t":1721,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000062","turnId":"00000000-0000-7000-8000-000000000063","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000064","clientId":null,"content":[]}}} +{"case":"0.158.0-stream-drop","t":1733,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000062","turnId":"00000000-0000-7000-8000-000000000063","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000064","clientId":null,"content":[]}}} +{"case":"0.158.0-stream-drop","t":1738,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000062","turnId":"00000000-0000-7000-8000-000000000063","willRetry":true,"error":{"message":"Reconnecting... 1/2","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"stream disconnected before completion: stream closed before response.completed"}}} +{"case":"0.158.0-stream-drop","t":1959,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000062","turnId":"00000000-0000-7000-8000-000000000063","willRetry":true,"error":{"message":"Reconnecting... 2/2","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"stream disconnected before completion: stream closed before response.completed"}}} +{"case":"0.158.0-stream-drop","t":2514,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000062","status":{"type":"systemError"}}} +{"case":"0.158.0-stream-drop","t":2514,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000062","turnId":"00000000-0000-7000-8000-000000000063","willRetry":false,"error":{"message":"stream disconnected before completion: stream closed before response.completed","codexErrorInfo":"other"}}} +{"case":"0.158.0-stream-drop","t":2546,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000062","turn":{"id":"00000000-0000-7000-8000-000000000063","status":"failed","error":{"message":"stream disconnected before completion: stream closed before response.completed","codexErrorInfo":"other"},"durationMs":1761}}} diff --git a/src/main/codex/codex-account-session-bridge.test.ts b/src/main/codex/codex-account-session-bridge.test.ts index 7cebd37c1f4..3befa013473 100644 --- a/src/main/codex/codex-account-session-bridge.test.ts +++ b/src/main/codex/codex-account-session-bridge.test.ts @@ -1,14 +1,31 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { _internals, bridgeCodexSessionsIntoAccountHome, - startCodexAccountSessionBridgeInBackground + startCodexAccountSessionBridgeInBackground, + stopCodexAccountSessionBridges } from './codex-account-session-bridge' +import { writeCodexStateDbBackfillStatus } from './codex-state-db-test-fixture' +import SyncDatabase from '../sqlite/sync-database' let workspaceRoot: string +const healIndexStub = vi.fn(async () => ({ + outcome: 'up-to-date' as const, + healedThreads: 0, + missingThreads: 0, + failedThreads: 0 +})) function writeRollout(homePath: string, relativePath: string, contents: string): string { const filePath = join(homePath, 'sessions', relativePath) @@ -21,12 +38,25 @@ function rolloutPath(homePath: string, relativePath: string): string { return join(homePath, 'sessions', relativePath) } -const ROLLOUT_A = join('2026', '07', '20', 'rollout-2026-07-20T10-00-00-aaaa.jsonl') -const ROLLOUT_B = join('2026', '07', '21', 'rollout-2026-07-21T10-00-00-bbbb.jsonl') +const THREAD_A = '019a0000-0000-7000-8000-00000000000a' +const THREAD_B = '019a0000-0000-7000-8000-00000000000b' +const ROLLOUT_A = join( + '2026', + '07', + '20', + 'rollout-2026-07-20T10-00-00-019a0000-0000-7000-8000-00000000000a.jsonl' +) +const ROLLOUT_B = join( + '2026', + '07', + '21', + 'rollout-2026-07-21T10-00-00-019a0000-0000-7000-8000-00000000000b.jsonl' +) beforeEach(() => { workspaceRoot = mkdtempSync(join(tmpdir(), 'codex-account-session-bridge-')) _internals.resetBackgroundBridgeTasks() + healIndexStub.mockClear() }) afterEach(() => { @@ -47,7 +77,14 @@ describe('bridgeCodexSessionsIntoAccountHome', () => { options: { batchSize: 1, yieldMs: 0 } }) - expect(summary).toEqual({ scannedFiles: 2, linkedFiles: 2 }) + expect(summary).toEqual({ + scannedFiles: 2, + linkedFiles: 2, + bridgedThreads: new Map([ + [THREAD_A, '2026-07-20T10-00-00'], + [THREAD_B, '2026-07-21T10-00-00'] + ]) + }) expect(readFileSync(rolloutPath(targetHome, ROLLOUT_A), 'utf-8')).toBe('system session\n') expect(readFileSync(rolloutPath(targetHome, ROLLOUT_B), 'utf-8')).toBe('account a session\n') }) @@ -97,7 +134,11 @@ describe('bridgeCodexSessionsIntoAccountHome', () => { sourceCodexHomePaths: [systemHome] }) - expect(second).toEqual({ scannedFiles: 1, linkedFiles: 0 }) + expect(second).toEqual({ + scannedFiles: 1, + linkedFiles: 0, + bridgedThreads: new Map([[THREAD_A, '2026-07-20T10-00-00']]) + }) }) it('never links a home into itself or scans a duplicate source twice', async () => { @@ -109,7 +150,27 @@ describe('bridgeCodexSessionsIntoAccountHome', () => { sourceCodexHomePaths: [targetHome, targetHome] }) - expect(summary).toEqual({ scannedFiles: 0, linkedFiles: 0 }) + expect(summary).toEqual({ scannedFiles: 0, linkedFiles: 0, bridgedThreads: new Map() }) + }) + + it('does not hand a rollout it failed to link to the index heal', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + writeRollout(systemHome, ROLLOUT_A, 'session\n') + writeRollout(systemHome, ROLLOUT_B, 'session\n') + // A file where the target's day directory belongs makes that one link fail. + mkdirSync(join(targetHome, 'sessions', '2026', '07'), { recursive: true }) + writeFileSync(join(targetHome, 'sessions', '2026', '07', '20'), 'not a directory') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const summary = await bridgeCodexSessionsIntoAccountHome({ + targetCodexHomePath: targetHome, + sourceCodexHomePaths: [systemHome] + }) + + warn.mockRestore() + expect(summary.linkedFiles).toBe(1) + expect(summary.bridgedThreads).toEqual(new Map([[THREAD_B, '2026-07-21T10-00-00']])) }) it('skips a source home that has no sessions tree', async () => { @@ -119,24 +180,35 @@ describe('bridgeCodexSessionsIntoAccountHome', () => { sourceCodexHomePaths: [join(workspaceRoot, 'missing')] }) - expect(summary).toEqual({ scannedFiles: 0, linkedFiles: 0 }) + expect(summary).toEqual({ scannedFiles: 0, linkedFiles: 0, bridgedThreads: new Map() }) }) }) +type BridgeDependencies = NonNullable< + Parameters[1] +> + +// Why: the real steps spawn `codex app-server`; their behavior is covered separately. +function startBridge( + targetCodexHomePath: string, + sourceCodexHomePaths: string[], + dependencies: BridgeDependencies = {} +): Promise { + return startCodexAccountSessionBridgeInBackground( + { targetCodexHomePath, sourceCodexHomePaths }, + { createStateDb: async () => false, healIndex: healIndexStub, ...dependencies } + ) +} + describe('startCodexAccountSessionBridgeInBackground', () => { it('shares one in-flight task per target home', () => { const systemHome = join(workspaceRoot, 'system') const targetHome = join(workspaceRoot, 'account') writeRollout(systemHome, ROLLOUT_A, 'session\n') + writeCodexStateDbBackfillStatus(targetHome, 'complete') - const first = startCodexAccountSessionBridgeInBackground({ - targetCodexHomePath: targetHome, - sourceCodexHomePaths: [systemHome] - }) - const second = startCodexAccountSessionBridgeInBackground({ - targetCodexHomePath: targetHome, - sourceCodexHomePaths: [systemHome] - }) + const first = startBridge(targetHome, [systemHome]) + const second = startBridge(targetHome, [systemHome]) expect(second).toBe(first) return first @@ -147,19 +219,203 @@ describe('startCodexAccountSessionBridgeInBackground', () => { const firstTarget = join(workspaceRoot, 'account-a') const secondTarget = join(workspaceRoot, 'account-b') writeRollout(systemHome, ROLLOUT_A, 'session\n') + writeCodexStateDbBackfillStatus(firstTarget, 'complete') + writeCodexStateDbBackfillStatus(secondTarget, 'complete') await Promise.all([ - startCodexAccountSessionBridgeInBackground({ - targetCodexHomePath: firstTarget, - sourceCodexHomePaths: [systemHome] - }), - startCodexAccountSessionBridgeInBackground({ - targetCodexHomePath: secondTarget, - sourceCodexHomePaths: [systemHome] - }) + startBridge(firstTarget, [systemHome]), + startBridge(secondTarget, [systemHome]) ]) expect(readFileSync(rolloutPath(firstTarget, ROLLOUT_A), 'utf-8')).toBe('session\n') expect(readFileSync(rolloutPath(secondTarget, ROLLOUT_A), 'utf-8')).toBe('session\n') }) + + // #20669: rollouts present when Codex first creates its state DB force a + // blocking backfill that times out the first TUI launch. + it('lets Codex index a new home before linking history into it', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + writeRollout(systemHome, ROLLOUT_A, 'session\n') + const createStateDb = vi.fn(async (home: string) => { + expect(existsSync(rolloutPath(home, ROLLOUT_A))).toBe(false) + writeCodexStateDbBackfillStatus(home, 'complete') + return true + }) + + await startBridge(targetHome, [systemHome], { createStateDb }) + + expect(createStateDb).toHaveBeenCalledWith(targetHome) + expect(readFileSync(rolloutPath(targetHome, ROLLOUT_A), 'utf-8')).toBe('session\n') + }) + + // Why: an older Codex keeps no state DB, and `sqlite_home` keeps it outside + // the home; either way every launch must keep linking new history. + it('keeps linking new history on later launches when no state DB lives in the home', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + writeRollout(systemHome, ROLLOUT_A, 'first\n') + const createStateDb = vi.fn(async () => true) + + await startBridge(targetHome, [systemHome], { createStateDb }) + writeRollout(systemHome, ROLLOUT_B, 'second\n') + await startBridge(targetHome, [systemHome], { createStateDb }) + + expect(readFileSync(rolloutPath(targetHome, ROLLOUT_A), 'utf-8')).toBe('first\n') + expect(readFileSync(rolloutPath(targetHome, ROLLOUT_B), 'utf-8')).toBe('second\n') + // Only the empty home needed Codex to run first. + expect(createStateDb).toHaveBeenCalledTimes(1) + }) + + it('does not start Codex on an empty home when no source has history', async () => { + const createStateDb = vi.fn(async () => true) + + await startBridge(join(workspaceRoot, 'account'), [join(workspaceRoot, 'system')], { + createStateDb + }) + + expect(createStateDb).not.toHaveBeenCalled() + }) + + it('does not link history when Codex could not create the state DB', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + writeRollout(systemHome, ROLLOUT_A, 'session\n') + + await startBridge(targetHome, [systemHome], { createStateDb: async () => false }) + + expect(existsSync(rolloutPath(targetHome, ROLLOUT_A))).toBe(false) + expect(healIndexStub).not.toHaveBeenCalled() + }) + + it('bridges compressed-only history into a new home and indexes it', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + const compressed = `${ROLLOUT_A}.zst` + writeRollout(systemHome, compressed, 'compressed\n') + const createStateDb = vi.fn(async (home: string) => { + writeCodexStateDbBackfillStatus(home, 'complete') + return true + }) + + await startBridge(targetHome, [systemHome], { createStateDb }) + + expect(readFileSync(rolloutPath(targetHome, compressed), 'utf-8')).toBe('compressed\n') + expect(healIndexStub).toHaveBeenCalledWith( + targetHome, + new Map([[THREAD_A, '2026-07-20T10-00-00']]), + { + shouldStop: expect.any(Function) + } + ) + }) + + it('skips linking into a state DB that predates backfill tracking', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + writeRollout(systemHome, ROLLOUT_A, 'session\n') + mkdirSync(targetHome, { recursive: true }) + new SyncDatabase(join(targetHome, 'state_5.sqlite')).close() + + await startBridge(targetHome, [systemHome]) + + expect(existsSync(rolloutPath(targetHome, ROLLOUT_A))).toBe(false) + }) + + it('skips linking while Codex is still indexing the home', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + writeRollout(systemHome, ROLLOUT_A, 'session\n') + writeCodexStateDbBackfillStatus(targetHome, 'running') + + await startBridge(targetHome, [systemHome]) + + expect(existsSync(rolloutPath(targetHome, ROLLOUT_A))).toBe(false) + expect(healIndexStub).not.toHaveBeenCalled() + }) + + it('reports why it skips a home whose state DB cannot be read', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + writeRollout(systemHome, ROLLOUT_A, 'session\n') + mkdirSync(targetHome, { recursive: true }) + writeFileSync(join(targetHome, 'state_5.sqlite'), 'not a sqlite database') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await startBridge(targetHome, [systemHome]) + + expect(existsSync(rolloutPath(targetHome, ROLLOUT_A))).toBe(false) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Codex state DB is unreadable'), + expect.any(String) + ) + warn.mockRestore() + }) + + it('indexes the bridged threads once linking finishes', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + writeRollout(systemHome, ROLLOUT_A, 'session\n') + writeCodexStateDbBackfillStatus(targetHome, 'complete') + + await startBridge(targetHome, [systemHome]) + + expect(healIndexStub).toHaveBeenCalledWith( + targetHome, + new Map([[THREAD_A, '2026-07-20T10-00-00']]), + { + shouldStop: expect.any(Function) + } + ) + }) + + it('links nothing when the app quits while Codex creates the state DB', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + writeRollout(systemHome, ROLLOUT_A, 'session\n') + + await startBridge(targetHome, [systemHome], { + createStateDb: async (home) => { + writeCodexStateDbBackfillStatus(home, 'complete') + stopCodexAccountSessionBridges() + return true + } + }) + + expect(existsSync(rolloutPath(targetHome, ROLLOUT_A))).toBe(false) + expect(healIndexStub).not.toHaveBeenCalled() + }) + + it('does not start Codex on a new home after the app quits', async () => { + const systemHome = join(workspaceRoot, 'system') + writeRollout(systemHome, ROLLOUT_A, 'session\n') + const createStateDb = vi.fn(async () => true) + stopCodexAccountSessionBridges() + + await startBridge(join(workspaceRoot, 'account'), [systemHome], { createStateDb }) + + expect(createStateDb).not.toHaveBeenCalled() + }) + + it('stops indexing and starts no new bridge once the app quits', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + const laterTarget = join(workspaceRoot, 'later-account') + writeRollout(systemHome, ROLLOUT_A, 'session\n') + writeCodexStateDbBackfillStatus(targetHome, 'complete') + writeCodexStateDbBackfillStatus(laterTarget, 'complete') + let shouldStop: (() => boolean) | undefined + + await startBridge(targetHome, [systemHome], { + healIndex: async (_home, _threads, options) => { + shouldStop = options?.shouldStop + stopCodexAccountSessionBridges() + return { outcome: 'stopped', healedThreads: 0, missingThreads: 0, failedThreads: 0 } + } + }) + await startBridge(laterTarget, [systemHome]) + + expect(shouldStop?.()).toBe(true) + expect(existsSync(rolloutPath(laterTarget, ROLLOUT_A))).toBe(false) + }) }) diff --git a/src/main/codex/codex-account-session-bridge.ts b/src/main/codex/codex-account-session-bridge.ts index 72096103aa1..bb6110a8651 100644 --- a/src/main/codex/codex-account-session-bridge.ts +++ b/src/main/codex/codex-account-session-bridge.ts @@ -3,7 +3,17 @@ import { dirname, join, relative } from 'node:path' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { listCodexSessionRolloutFilesIncrementally } from './codex-session-file-listing' import type { CodexSessionBridgeIncrementalOptions } from './codex-session-file-listing' +import { + createCodexAccountStateDb, + healCodexAccountSessionIndex +} from './codex-account-session-index-heal' +import { parseCodexRolloutThreadId } from './codex-session-index-heal-state' import { linkCodexSessionFile } from './codex-session-link' +import { + countCodexSessionFilesUpTo, + findNewestCodexStateDbPath, + readCodexStateDbBackfillStatus +} from './codex-state-db' /** * Bridges Codex history between Orca-managed Codex homes. @@ -19,24 +29,99 @@ import { linkCodexSessionFile } from './codex-session-link' export type CodexAccountSessionBridgeSummary = { scannedFiles: number linkedFiles: number + /** Thread id -> rollout timestamp for every rollout present in the target home via this bridge. */ + bridgedThreads: Map } const backgroundBridgeTasksByTargetHome = new Map>() +let stopping = false + +type BackgroundBridgeDependencies = { + createStateDb: typeof createCodexAccountStateDb + healIndex: typeof healCodexAccountSessionIndex +} + +const defaultBackgroundBridgeDependencies: BackgroundBridgeDependencies = { + createStateDb: createCodexAccountStateDb, + healIndex: healCodexAccountSessionIndex +} + +/** + * Why: Codex indexes every rollout present when it first creates a home's state + * DB, and the TUI gives up waiting after 30s. Linking history into a fresh home + * first turns its first launch into a minutes-long backfill (#20669), so an + * empty home gets its state DB before any history; the heal indexes it afterwards. + */ +async function isReadyForBridgedHistory( + targetCodexHomePath: string, + sourceCodexHomePaths: readonly string[], + dependencies: BackgroundBridgeDependencies +): Promise { + if ( + !findNewestCodexStateDbPath(targetCodexHomePath) && + !hasSessionRollouts(targetCodexHomePath) + ) { + if ( + !sourceCodexHomePaths.some(hasSessionRollouts) || + !(await dependencies.createStateDb(targetCodexHomePath)) + ) { + return false + } + } + const status = readCodexStateDbBackfillStatus(targetCodexHomePath) + if (status.kind === 'unreadable') { + // Why: contention clears by the next launch; corruption would skip every launch, so surface it. + console.warn( + '[codex-account-session-bridge] Skipping history bridge; Codex state DB is unreadable:', + status.error + ) + return false + } + // Why: no DB in a home that has history means Codex keeps it elsewhere + // (`sqlite_home`) or keeps none, so linking cannot stall a launch here. + // `not-tracked` is a pre-backfill schema that Codex migrates to `pending`. + return status.kind === 'complete' || status.kind === 'missing' +} + +function hasSessionRollouts(codexHomePath: string): boolean { + return countCodexSessionFilesUpTo(join(codexHomePath, 'sessions'), 1) > 0 +} /** * Starts one background bridge per target home, sharing in-flight work. */ -export function startCodexAccountSessionBridgeInBackground(args: { - targetCodexHomePath: string - sourceCodexHomePaths: readonly string[] - options?: CodexSessionBridgeIncrementalOptions -}): Promise { +export function startCodexAccountSessionBridgeInBackground( + args: { + targetCodexHomePath: string + sourceCodexHomePaths: readonly string[] + options?: CodexSessionBridgeIncrementalOptions + }, + dependenciesOverride: Partial = {} +): Promise { + if (stopping) { + return Promise.resolve() + } const key = normalizeRuntimePathForComparison(args.targetCodexHomePath) const inFlight = backgroundBridgeTasksByTargetHome.get(key) if (inFlight) { return inFlight } - const task = bridgeCodexSessionsIntoAccountHome(args) + const dependencies = { ...defaultBackgroundBridgeDependencies, ...dependenciesOverride } + const task = isReadyForBridgedHistory( + args.targetCodexHomePath, + args.sourceCodexHomePaths, + dependencies + ) + .then(async (ready) => { + // Why: skip rather than feed a running backfill; the next launch retries. + if (!ready || stopping) { + return + } + const summary = await bridgeCodexSessionsIntoAccountHome(args) + await dependencies.healIndex(args.targetCodexHomePath, summary.bridgedThreads, { + shouldStop: () => stopping + }) + }) .catch((error: unknown) => { console.warn('[codex-account-session-bridge] Background session bridge failed:', error) }) @@ -50,6 +135,11 @@ export function startCodexAccountSessionBridgeInBackground(args: { return task } +/** Stops background bridges at quit; links and index reads made so far are kept. */ +export function stopCodexAccountSessionBridges(): void { + stopping = true +} + /** * Mirrors every source home's rollouts into the target home's sessions tree. */ @@ -58,7 +148,11 @@ export async function bridgeCodexSessionsIntoAccountHome(args: { sourceCodexHomePaths: readonly string[] options?: CodexSessionBridgeIncrementalOptions }): Promise { - const summary: CodexAccountSessionBridgeSummary = { scannedFiles: 0, linkedFiles: 0 } + const summary: CodexAccountSessionBridgeSummary = { + scannedFiles: 0, + linkedFiles: 0, + bridgedThreads: new Map() + } const targetSessionsRoot = join(args.targetCodexHomePath, 'sessions') for (const sourceHomePath of dedupeSourceHomes( args.sourceCodexHomePaths, @@ -73,9 +167,18 @@ export async function bridgeCodexSessionsIntoAccountHome(args: { args.options ?? {} )) { summary.scannedFiles += 1 - if (bridgeRolloutIntoAccountHome(sourceSessionsRoot, targetSessionsRoot, sourceFilePath)) { + const result = bridgeRolloutIntoAccountHome( + sourceSessionsRoot, + targetSessionsRoot, + sourceFilePath + ) + if (result === 'linked') { summary.linkedFiles += 1 } + const rollout = parseCodexRolloutThreadId(sourceFilePath) + if (result !== 'failed' && rollout) { + summary.bridgedThreads.set(rollout.threadId, rollout.rolloutStamp) + } } } return summary @@ -88,20 +191,20 @@ function bridgeRolloutIntoAccountHome( sourceSessionsRoot: string, targetSessionsRoot: string, sourceFilePath: string -): boolean { +): 'linked' | 'existing' | 'failed' { const targetFilePath = join(targetSessionsRoot, relative(sourceSessionsRoot, sourceFilePath)) // Why: rollout names carry the session UUID, so an existing target path is the // same conversation already bridged (often the same inode) — never a conflict. if (existsSync(targetFilePath)) { - return false + return 'existing' } try { mkdirSync(dirname(targetFilePath), { recursive: true }) } catch (error) { console.warn('[codex-account-session-bridge] Failed to create session directory:', error) - return false + return 'failed' } - return linkCodexSessionFile(sourceFilePath, targetFilePath) + return linkCodexSessionFile(sourceFilePath, targetFilePath) ? 'linked' : 'failed' } /** @@ -127,5 +230,6 @@ function dedupeSourceHomes( export const _internals = { resetBackgroundBridgeTasks: (): void => { backgroundBridgeTasksByTargetHome.clear() + stopping = false } } diff --git a/src/main/codex/codex-account-session-index-heal.test.ts b/src/main/codex/codex-account-session-index-heal.test.ts new file mode 100644 index 00000000000..fbb5fae404c --- /dev/null +++ b/src/main/codex/codex-account-session-index-heal.test.ts @@ -0,0 +1,306 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + _internals, + createCodexAccountStateDb, + healCodexAccountSessionIndex +} from './codex-account-session-index-heal' +import { + CodexAppServerUnsupportedError, + type CodexAppServerInvocation, + type CodexAppServerRpc +} from './codex-app-server-session' + +const HOME = '/codex-accounts/account-1/home' + +// Why: fails closed if a regression bypasses the fake session — never the real codex or ~/.codex. +function buildInvocation(): CodexAppServerInvocation { + return { + command: '/nonexistent/orca-test-codex', + args: ['app-server'], + cliPath: null, + env: { CODEX_HOME: join(tmpdir(), 'orca-test-nonexistent-codex-home') }, + timeoutMs: 1_000 + } +} + +/** Runs the heal body against a fake app-server that answers thread/read. */ +function fakeAppServer(onRead: (threadId: string) => void = () => {}) { + const readThreadIds: string[] = [] + const runSession = vi.fn( + async ( + _invocation: CodexAppServerInvocation, + body: (rpc: CodexAppServerRpc) => Promise + ): Promise => { + await body({ + request: async (method, params) => { + expect(method).toBe('thread/read') + const threadId = String(params?.threadId) + readThreadIds.push(threadId) + onRead(threadId) + return {} + }, + notify: () => {} + }) + } + ) + return { runSession, readThreadIds } +} + +/** Bridged threads keyed by id, all from the same rollout timestamp. */ +function bridged(...threadIds: string[]): Map { + return new Map(threadIds.map((threadId) => [threadId, '2026-07-20T10-00-00'])) +} + +beforeEach(() => { + _internals.resetFailedThreads() +}) + +describe('healCodexAccountSessionIndex', () => { + it('reads only bridged threads missing from the Codex index', async () => { + const { runSession, readThreadIds } = fakeAppServer() + + const summary = await healCodexAccountSessionIndex(HOME, bridged('a', 'b', 'c'), { + readIndexedThreadIds: () => new Set(['b']), + buildInvocation, + runSession + }) + + expect(readThreadIds.sort()).toEqual(['a', 'c']) + expect(summary).toEqual({ + outcome: 'completed', + healedThreads: 2, + missingThreads: 0, + failedThreads: 0 + }) + }) + + it('reads the most recent bridged rollouts first', async () => { + const { runSession, readThreadIds } = fakeAppServer() + + await healCodexAccountSessionIndex( + HOME, + new Map([ + ['middle', '2026-08-01T09-00-00'], + ['oldest', '2025-12-31T23-59-59'], + ['newest', '2026-09-28T01-46-16'] + ]), + { readIndexedThreadIds: () => new Set(), buildInvocation, runSession, readConcurrency: 1 } + ) + + expect(readThreadIds).toEqual(['newest', 'middle', 'oldest']) + }) + + it('does not start Codex when every bridged thread is already indexed', async () => { + const { runSession } = fakeAppServer() + + const summary = await healCodexAccountSessionIndex(HOME, bridged('a'), { + readIndexedThreadIds: () => new Set(['a']), + buildInvocation, + runSession + }) + + expect(summary.outcome).toBe('up-to-date') + expect(runSession).not.toHaveBeenCalled() + }) + + it('does not start Codex when its index cannot be read', async () => { + const { runSession } = fakeAppServer() + + const summary = await healCodexAccountSessionIndex(HOME, bridged('a'), { + readIndexedThreadIds: () => null, + buildInvocation, + runSession + }) + + expect(summary.outcome).toBe('no-index') + expect(runSession).not.toHaveBeenCalled() + }) + + it('does not start Codex once the app is quitting', async () => { + const { runSession } = fakeAppServer() + + const summary = await healCodexAccountSessionIndex(HOME, bridged('a'), { + readIndexedThreadIds: () => new Set(), + buildInvocation, + runSession, + shouldStop: () => true + }) + + expect(summary.outcome).toBe('stopped') + expect(runSession).not.toHaveBeenCalled() + }) + + it('stops retrying a thread Codex refuses to index until Orca restarts', async () => { + const { runSession, readThreadIds } = fakeAppServer((threadId) => { + if (threadId === 'broken') { + throw new Error('codex app-server thread/read failed: invalid rollout') + } + }) + const dependencies = { + readIndexedThreadIds: () => new Set(), + buildInvocation, + runSession + } + + const first = await healCodexAccountSessionIndex(HOME, bridged('broken'), dependencies) + const second = await healCodexAccountSessionIndex(HOME, bridged('broken'), dependencies) + + expect(first).toEqual({ + outcome: 'completed', + healedThreads: 0, + missingThreads: 0, + failedThreads: 1 + }) + expect(second.outcome).toBe('up-to-date') + expect(readThreadIds).toEqual(['broken']) + }) + + it('remembers a refused thread only for the home that refused it', async () => { + const { runSession, readThreadIds } = fakeAppServer((threadId) => { + if (readThreadIds.length === 1) { + throw new Error(`codex app-server thread/read failed: invalid rollout ${threadId}`) + } + }) + const dependencies = { + readIndexedThreadIds: () => new Set(), + buildInvocation, + runSession + } + + await healCodexAccountSessionIndex(HOME, bridged('shared'), dependencies) + const other = await healCodexAccountSessionIndex( + '/codex-accounts/account-2/home', + bridged('shared'), + dependencies + ) + + expect(other.healedThreads).toBe(1) + expect(readThreadIds).toEqual(['shared', 'shared']) + }) + + it('counts a rollout Codex cannot find as missing and does not reread it', async () => { + const { runSession, readThreadIds } = fakeAppServer(() => { + throw new Error('codex app-server thread/read failed: no rollout found for thread id gone') + }) + const options = { readIndexedThreadIds: () => new Set(), buildInvocation, runSession } + + const first = await healCodexAccountSessionIndex(HOME, bridged('gone'), options) + const second = await healCodexAccountSessionIndex(HOME, bridged('gone'), options) + + expect(first).toEqual({ + outcome: 'completed', + healedThreads: 0, + missingThreads: 1, + failedThreads: 0 + }) + expect(second.outcome).toBe('up-to-date') + expect(readThreadIds).toEqual(['gone']) + }) + + it('retries a thread on the next pass when the app-server session fails', async () => { + const failing = vi.fn(async () => { + throw new Error('codex app-server exited before responding') + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const readIndexedThreadIds = (): Set => new Set() + + const first = await healCodexAccountSessionIndex(HOME, bridged('a'), { + readIndexedThreadIds, + buildInvocation, + runSession: failing + }) + const { runSession, readThreadIds } = fakeAppServer() + const second = await healCodexAccountSessionIndex(HOME, bridged('a'), { + readIndexedThreadIds, + buildInvocation, + runSession + }) + + expect(first.outcome).toBe('aborted') + expect(second.outcome).toBe('completed') + expect(readThreadIds).toEqual(['a']) + warn.mockRestore() + }) + + it('reports a session that fails during quit as stopped, not unsupported', async () => { + let stopping = false + const summary = await healCodexAccountSessionIndex(HOME, bridged('a'), { + readIndexedThreadIds: () => new Set(), + buildInvocation, + runSession: async () => { + stopping = true + throw new CodexAppServerUnsupportedError('app-server killed during quit') + }, + shouldStop: () => stopping + }) + + expect(summary.outcome).toBe('stopped') + }) + + it('aborts rather than writing off a thread while a live Codex holds the database', async () => { + const { runSession } = fakeAppServer(() => { + throw new Error('codex app-server thread/read failed: database is locked') + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const summary = await healCodexAccountSessionIndex(HOME, bridged('a'), { + readIndexedThreadIds: () => new Set(), + buildInvocation, + runSession + }) + + expect(summary).toEqual({ + outcome: 'aborted', + healedThreads: 0, + missingThreads: 0, + failedThreads: 0 + }) + warn.mockRestore() + }) +}) + +describe('createCodexAccountStateDb', () => { + it('starts Codex on the home without sending any request', async () => { + const { runSession, readThreadIds } = fakeAppServer() + const invocations: string[] = [] + + const created = await createCodexAccountStateDb(HOME, { + buildInvocation: (home, timeoutMs) => { + invocations.push(home) + return { ...buildInvocation(), timeoutMs } + }, + runSession + }) + + expect(created).toBe(true) + expect(invocations).toEqual([HOME]) + expect(readThreadIds).toEqual([]) + }) + + it('treats a Codex without app-server as having no state DB to stall', async () => { + const created = await createCodexAccountStateDb(HOME, { + buildInvocation, + runSession: async () => { + throw new CodexAppServerUnsupportedError('unknown subcommand app-server') + } + }) + + expect(created).toBe(true) + }) + + it('reports failure when Codex could not start', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const created = await createCodexAccountStateDb(HOME, { + buildInvocation, + runSession: async () => { + throw new Error('spawn codex ENOENT') + } + }) + + expect(created).toBe(false) + warn.mockRestore() + }) +}) diff --git a/src/main/codex/codex-account-session-index-heal.ts b/src/main/codex/codex-account-session-index-heal.ts new file mode 100644 index 00000000000..1af87e34407 --- /dev/null +++ b/src/main/codex/codex-account-session-index-heal.ts @@ -0,0 +1,122 @@ +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { + isCodexAppServerUnsupportedError, + runCodexAppServerSession +} from './codex-app-server-session' +import { + buildNativeHealInvocation, + readCodexThreadsForIndexHeal, + type CodexSessionIndexHealOptions, + type CodexThreadReadPassOutcome +} from './codex-session-index-heal' +import { readIndexedCodexThreadIds } from './codex-state-db' + +// Why: Codex indexes a home's rollouts only once, when it first creates the +// state DB, and the /resume picker's "All" view lists only indexed threads. +// History bridged in afterwards needs `thread/read`, Codex's lazy-indexing +// path, to become visible there (#20669). + +export type CodexAccountSessionIndexHealSummary = { + outcome: CodexThreadReadPassOutcome | 'up-to-date' | 'no-index' + healedThreads: number + missingThreads: number + failedThreads: number +} + +export type CodexAccountSessionIndexHealOptions = CodexSessionIndexHealOptions & { + readIndexedThreadIds?: (codexHomePath: string) => Set | null +} + +// Why: app-server finishes state DB startup before it answers `initialize`, +// which takes ~100ms on an empty home. +const STATE_DB_CREATE_TIMEOUT_MS = 15_000 + +// Why: a just-linked rollout Codex refuses or cannot find fails the same way on +// every read; skip it until Orca restarts instead of respawning app-server each launch. +const failedThreadIdsByHome = new Map>() + +/** + * Starts Codex once on an empty home so it creates and indexes its state DB + * before any history is bridged in. False when Codex could not start. + */ +export async function createCodexAccountStateDb( + codexHomePath: string, + options: Pick = {} +): Promise { + const buildInvocation = options.buildInvocation ?? buildNativeHealInvocation + const runSession = options.runSession ?? runCodexAppServerSession + try { + await runSession(buildInvocation(codexHomePath, STATE_DB_CREATE_TIMEOUT_MS), async () => {}) + return true + } catch (error) { + // Why: a Codex without app-server predates the state DB, so linking cannot stall it. + if (isCodexAppServerUnsupportedError(error)) { + return true + } + console.warn('[codex-account-session-index-heal] Failed to create Codex state DB:', error) + return false + } +} + +/** + * Indexes the bridged threads Codex has not indexed yet, newest rollout first. + * Diffing against the state DB on every pass makes an interrupted heal resume + * on the next launch. + */ +export async function healCodexAccountSessionIndex( + codexHomePath: string, + bridgedThreads: ReadonlyMap, + options: CodexAccountSessionIndexHealOptions = {} +): Promise { + const summary: CodexAccountSessionIndexHealSummary = { + outcome: 'up-to-date', + healedThreads: 0, + missingThreads: 0, + failedThreads: 0 + } + if (bridgedThreads.size === 0) { + return summary + } + // Why: with no DB in the home, Codex keeps none (older CLI) or uses a + // `sqlite_home` shared by every Orca home, which already indexes these threads. + const indexed = (options.readIndexedThreadIds ?? readIndexedCodexThreadIds)(codexHomePath) + if (!indexed) { + return { ...summary, outcome: 'no-index' } + } + const homeKey = normalizeRuntimePathForComparison(codexHomePath) + const failed = failedThreadIdsByHome.get(homeKey) ?? new Set() + failedThreadIdsByHome.set(homeKey, failed) + // Why: a large history takes minutes to index, and /resume hides unindexed + // threads once a directory has any indexed one, so recent work goes first. + const pending = [...bridgedThreads] + .filter(([threadId]) => !indexed.has(threadId) && !failed.has(threadId)) + .sort(([, left], [, right]) => (left < right ? 1 : left > right ? -1 : 0)) + .map(([threadId]) => ({ threadId })) + if (pending.length === 0) { + return summary + } + summary.outcome = await readCodexThreadsForIndexHeal( + codexHomePath, + pending, + ({ threadId }, outcome) => { + if (outcome === 'healed') { + summary.healedThreads += 1 + return + } + failed.add(threadId) + if (outcome === 'missing') { + summary.missingThreads += 1 + } else { + summary.failedThreads += 1 + } + }, + options + ) + return summary +} + +export const _internals = { + resetFailedThreads: (): void => { + failedThreadIdsByHome.clear() + } +} diff --git a/src/main/codex/codex-app-server-connection-types.ts b/src/main/codex/codex-app-server-connection-types.ts index 5c3c2f425a2..df3f40ac39a 100644 --- a/src/main/codex/codex-app-server-connection-types.ts +++ b/src/main/codex/codex-app-server-connection-types.ts @@ -9,6 +9,8 @@ export type CodexAppServerConnectionHandlers = { onServerRequest?: (request: CodexAppServerServerRequest) => void onUnhandledFrame?: (kind: string, payload: unknown) => void onExit?: (error: Error) => void + /** Awaited once the child has a pid and before the handshake; a rejection reaps the child. */ + onSpawned?: (pid: number) => Promise } export type CodexAppServerConnection = { diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index d67f2ebecc6..3d95e7ce380 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events' import { realpathSync } from 'node:fs' import { tmpdir } from 'node:os' import { PassThrough } from 'node:stream' +import { providerDiagnosticOf } from '../../shared/agent-session-failure' import { afterEach, describe, expect, it, vi } from 'vitest' import type { spawnProcess } from '../../shared/child-process/run-process' import { @@ -10,12 +11,17 @@ import { type CodexAppServerConnection, type CodexAppServerConnectionHandlers } from './codex-app-server-connection' +import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from './codex-app-server-posix-supervisor' import { isCodexAppServerUnsupportedError } from './codex-app-server-session' +// close() waits out the supervisor's own stop before forcing the tree. +const GRACEFUL_EXIT_MS = process.platform === 'win32' ? 1_500 : PROVIDER_SUPERVISOR_MAX_STOP_MS + const originalCodexHome = process.env.CODEX_HOME afterEach(() => { vi.useRealTimers() + vi.restoreAllMocks() if (originalCodexHome === undefined) { delete process.env.CODEX_HOME } else { @@ -182,6 +188,45 @@ describe('openCodexAppServerConnection', () => { await connection.close() }) + it('reports the spawned pid before it sends the handshake', async () => { + const { child, spawnImpl, written } = stubChild() + answerInitialize(child) + const writtenAtSpawn: number[] = [] + + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { + onSpawned: async (pid) => { + writtenAtSpawn.push(written.length) + expect(pid).toBe(child.pid) + } + }, + spawnImpl + ) + + // The owner is durable before initialize, so a crash mid-handshake leaves it stoppable. + expect(writtenAtSpawn).toEqual([0]) + expect(written[0]).toMatchObject({ method: 'initialize' }) + await connection.close() + }) + + it('reaps the child and never handshakes when its spawn cannot be recorded', async () => { + const { spawnImpl, written } = stubChild() + + await expect( + openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { + onSpawned: async () => { + throw new Error('agent_session_checkpoint_stale') + } + }, + spawnImpl + ) + ).rejects.toThrow('agent_session_checkpoint_stale') + expect(written).toEqual([]) + }) + it('completes the handshake and keeps the child alive across calls', async () => { const notifications: { method: string; params: unknown }[] = [] const connection = await openFakeServer({ @@ -254,6 +299,9 @@ describe('openCodexAppServerConnection', () => { expect(isCodexAppServerRequestError(refusal)).toBe(true) expect((refusal as Error).message).toContain('bad params') + // Codex's own words, apart from Orca's prefix, for a person to read. + expect(providerDiagnosticOf(refusal)).toEqual({ text: 'bad params', audience: 'person' }) + expect(providerDiagnosticOf(missing)).toBeUndefined() expect(isCodexAppServerUnsupportedError(missing)).toBe(true) expect(isCodexAppServerRequestError(missing)).toBe(false) await connection.close() @@ -293,13 +341,42 @@ describe('openCodexAppServerConnection', () => { ) child.stdout.write(`${JSON.stringify({ id: 'late-string-id', result: { value: 1 } })}\n`) - child.stdout.write(`${JSON.stringify({ id: 999, result: { value: 2 } })}\n`) + child.stdout.write(`${JSON.stringify({ id: null, error: { message: 'parse error' } })}\n`) await vi.waitFor(() => expect(frames).toHaveLength(2)) - expect(frames.map((frame) => frame.kind)).toEqual(['frame:unclassified', 'response:unmatched']) + expect(frames.map((frame) => frame.kind)).toEqual(['frame:unclassified', 'frame:unclassified']) await connection.close() }) + it('logs a reply to a timed-out request instead of surfacing it as a frame', async () => { + vi.useFakeTimers() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { child, spawnImpl, written } = stubChild() + answerInitialize(child) + const frames: string[] = [] + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { onUnhandledFrame: (kind) => frames.push(kind) }, + spawnImpl + ) + + const slow = rejection(connection.request('turn/interrupt', undefined, { timeoutMs: 50 })) + await vi.advanceTimersByTimeAsync(60) + expect((await slow).name).toBe('CodexAppServerTimeoutError') + const id = Number(written.find((frame) => frame.method === 'turn/interrupt')?.id) + child.stdout.write(`${JSON.stringify({ id, result: {} })}\n`) + child.stdout.write(`${JSON.stringify({ id: 999, error: { message: 'no such request' } })}\n`) + await vi.waitFor(() => expect(warn).toHaveBeenCalledTimes(2)) + + expect(frames).toEqual([]) + expect(warn.mock.calls.map((call) => call[0])).toEqual([ + `[codex-app-server] late reply to turn/interrupt after timeout (id ${id})`, + '[codex-app-server] reply with no waiting request (id 999)' + ]) + expect(warn.mock.calls[1][1]).toBe('no such request') + await vi.advanceTimersByTimeAsync(0) + }) + it('fails in-flight requests and reports an unexpected exit once', async () => { const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) answerInitialize(child) @@ -349,7 +426,7 @@ describe('openCodexAppServerConnection', () => { openCodexAppServerConnection({ command: 'codex', args: ['app-server'] }, {}, spawnImpl) ) - await vi.advanceTimersByTimeAsync(5_000) + await vi.advanceTimersByTimeAsync(GRACEFUL_EXIT_MS + 3_500) const error = (await opening) as Error & { connection?: CodexAppServerConnection } expect(error.name).toBe('CodexAppServerHandshakeExitUnprovenError') @@ -391,7 +468,7 @@ describe('openCodexAppServerConnection', () => { }) const closing = connection.close() - await vi.advanceTimersByTimeAsync(2_000) + await vi.advanceTimersByTimeAsync(GRACEFUL_EXIT_MS + 500) await closing await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGKILL')) @@ -425,7 +502,7 @@ describe('openCodexAppServerConnection', () => { const first = connection.close() const second = connection.close() - await vi.advanceTimersByTimeAsync(4_100) + await vi.advanceTimersByTimeAsync(GRACEFUL_EXIT_MS + 2_600) await expect(Promise.all([first, second])).resolves.toEqual([true, true]) expect(child.kill.mock.calls.map(([signal]) => signal)).toEqual(['SIGSTOP', 'SIGKILL']) @@ -442,7 +519,7 @@ describe('openCodexAppServerConnection', () => { ) const first = connection.close() - await vi.advanceTimersByTimeAsync(5_000) + await vi.advanceTimersByTimeAsync(GRACEFUL_EXIT_MS + 3_500) await expect(first).resolves.toBe(false) child.emit('exit', 0, null) @@ -536,6 +613,36 @@ describe('openCodexAppServerConnection', () => { await connection.close() }) + it('delivers a notification beyond the daemon wire limit whole, never as an oversized frame', async () => { + const { child, spawnImpl } = stubChild() + answerInitialize(child) + const frames: string[] = [] + const deltas: unknown[] = [] + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { + onUnhandledFrame: (kind) => frames.push(kind), + onNotification: (method, params) => { + if (method === 'item/commandExecution/outputDelta') { + deltas.push(params) + } + } + }, + spawnImpl + ) + + const params = { threadId: 'thread-1', itemId: 'exec-1', delta: '' } + params.delta = 'x'.repeat(16 * 1024 * 1024 + 1) + child.stdout.write( + `${JSON.stringify({ method: 'item/commandExecution/outputDelta', params })}\n` + ) + + await vi.waitFor(() => expect(deltas).toEqual([params])) + expect(frames).toEqual([]) + expect(connection.closed).toBe(false) + await connection.close() + }) + it('keeps malformed and non-object JSON non-fatal and processes the next record', async () => { const { child, spawnImpl } = stubChild() answerInitialize(child) diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index 4bfd9a15169..3e7d16ea1bb 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -1,11 +1,13 @@ import { spawnProcess } from '../../shared/child-process/run-process' import { RetryableProcessExitProof } from '../../shared/child-process/retryable-process-exit-proof' -import { createProviderSpawnSpec } from './codex-app-server-posix-supervisor' +import { + createProviderSpawnSpec, + PROVIDER_SUPERVISOR_MAX_STOP_MS +} from './codex-app-server-posix-supervisor' import { buildCodexAppServerExitError } from './codex-app-server-exit-error' import { initializeCodexAppServerConnection } from './codex-app-server-handshake' import { CodexAppServerHandshakeExitUnprovenError } from './codex-app-server-handshake-exit-proof' import { terminateCodexAppServerProcessTree } from './codex-app-server-process-teardown' -import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity' import { waitForProcessExitUntil } from './codex-process-exit-deadline' import { CodexAppServerTimeoutError, @@ -44,7 +46,7 @@ export type CodexAppServerLaunch = { } const DEFAULT_REQUEST_TIMEOUT_MS = 30_000 -const GRACEFUL_EXIT_MS = 1_500 +export const GRACEFUL_EXIT_MS = 1_500 const FORCED_EXIT_MS = 1_000 const STDERR_TAIL_MAX_BYTES = 8192 @@ -64,12 +66,11 @@ export async function openCodexAppServerConnection( } const spawnSpec = createProviderSpawnSpec(launch, childEnv, process.platform) const child = spawnImpl(spawnSpec) - const spawnToken = launch.env?.[CODEX_SPAWN_TOKEN_ENV] function terminateProcessTree(): Promise { // The supervisor and provider own separate POSIX groups so the supervisor can prove the // provider group empty before relaying its exit. Forced wrapper teardown uses descendant proof. - return terminateCodexAppServerProcessTree(child, spawnToken) + return terminateCodexAppServerProcessTree(child) } let stderrTail = '' @@ -212,7 +213,7 @@ export async function openCodexAppServerConnection( // Why: per request, not per session — a chat session outlives every call, // so only the individual call can carry a deadline. const timer = setTimeout(() => { - dispatcher.deletePending(id) + dispatcher.timeOutPending(id) reject(new CodexAppServerTimeoutError(`codex app-server ${method} exceeded ${timeoutMs}ms`)) }, timeoutMs) dispatcher.addPending(id, { method, resolve, reject, timer }) @@ -249,7 +250,11 @@ export async function openCodexAppServerConnection( // Already destroyed; the reap below still runs. } if (!exited) { - await waitForProcessExitUntil(exitPromise, GRACEFUL_EXIT_MS) + // The POSIX supervisor stops its own provider group; forcing it any sooner can orphan it. + await waitForProcessExitUntil( + exitPromise, + process.platform === 'win32' ? GRACEFUL_EXIT_MS : PROVIDER_SUPERVISOR_MAX_STOP_MS + ) if (!exited) { const treeExited = await terminateProcessTree() if (!treeExited) { @@ -280,13 +285,20 @@ export async function openCodexAppServerConnection( close } + let handshaking = false try { + // A spawn that failed has no pid; the handshake below reports why. + if (child.pid !== undefined) { + await handlers.onSpawned?.(child.pid) + } + handshaking = true await initializeCodexAppServerConnection(connection) } catch (error) { if ((await close()) !== true) { throw new CodexAppServerHandshakeExitUnprovenError(connection, error) } - throw error instanceof CodexAppServerUnsupportedError || + throw !handshaking || + error instanceof CodexAppServerUnsupportedError || error instanceof CodexAppServerTimeoutError ? error : buildExitError(error instanceof Error ? error : new Error(String(error))) diff --git a/src/main/codex/codex-app-server-exit-error.test.ts b/src/main/codex/codex-app-server-exit-error.test.ts new file mode 100644 index 00000000000..63ec9a63743 --- /dev/null +++ b/src/main/codex/codex-app-server-exit-error.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { providerDiagnosticOf } from '../../shared/agent-session-failure' +import { buildCodexAppServerExitError } from './codex-app-server-exit-error' + +describe('buildCodexAppServerExitError', () => { + it("keeps the stderr tail apart from Orca's wording, as log text", () => { + const error = buildCodexAppServerExitError(' thread panicked at main.rs:4 ') + expect(error.message).toBe('codex app-server connection ended: thread panicked at main.rs:4') + expect(providerDiagnosticOf(error)).toEqual({ + text: 'thread panicked at main.rs:4', + audience: 'log' + }) + }) + + it('carries no diagnostic when a cause, not the stderr, explains the end', () => { + const error = buildCodexAppServerExitError('ignored', new Error('spawn codex ENOENT')) + expect(providerDiagnosticOf(error)).toBeUndefined() + expect(providerDiagnosticOf(buildCodexAppServerExitError(''))).toBeUndefined() + }) +}) diff --git a/src/main/codex/codex-app-server-exit-error.ts b/src/main/codex/codex-app-server-exit-error.ts index 85d8bc7dd6e..8812e6698a0 100644 --- a/src/main/codex/codex-app-server-exit-error.ts +++ b/src/main/codex/codex-app-server-exit-error.ts @@ -2,6 +2,7 @@ // stderr tail is the only evidence: a CLI without the subcommand is a durable // capability fact, and anything else is this run's crash. +import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' import { stderrIndicatesMissingAppServer } from './codex-app-server-capability-signal' import { CodexAppServerUnsupportedError } from './codex-app-server-session' @@ -9,11 +10,17 @@ const EXIT_DETAIL_MAX_CHARS = 400 export function buildCodexAppServerExitError(stderrTail: string, cause?: Error): Error { const tail = stderrTail.trim().slice(0, EXIT_DETAIL_MAX_CHARS) + // The tail is Codex's own stderr: a log, kept behind Details. + const diagnostic = providerDiagnostic(tail, 'log') if (stderrIndicatesMissingAppServer(stderrTail)) { - return new CodexAppServerUnsupportedError( - `codex CLI does not support the app-server subcommand: ${tail}` + return withProviderDiagnostic( + new CodexAppServerUnsupportedError( + `codex CLI does not support the app-server subcommand: ${tail}` + ), + diagnostic ) } const detail = cause ? `: ${cause.message}` : tail ? `: ${tail}` : '' - return new Error(`codex app-server connection ended${detail}`) + const error = new Error(`codex app-server connection ended${detail}`) + return cause ? error : withProviderDiagnostic(error, diagnostic) } diff --git a/src/main/codex/codex-app-server-posix-supervisor.integration.test.ts b/src/main/codex/codex-app-server-posix-supervisor.integration.test.ts new file mode 100644 index 00000000000..846afaa973f --- /dev/null +++ b/src/main/codex/codex-app-server-posix-supervisor.integration.test.ts @@ -0,0 +1,355 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + POSIX_PROVIDER_SUPERVISOR_SCRIPT, + PROVIDER_SIGTERM_GRACE_MS, + PROVIDER_STDIN_END_GRACE_MS, + PROVIDER_SUPERVISOR_MAX_STOP_MS, + supervisedPosixLaunch, + type ProviderSupervisorOptions +} from './codex-app-server-posix-supervisor' + +// The provider leads its own group; its grandchild shares that group and ignores SIGTERM. +const PROVIDER = String.raw` + const { spawn } = require('node:child_process') + if (process.env.ORCA_TEST_PROVIDER_IGNORES_SIGTERM) process.on('SIGTERM', () => {}) + if (process.env.ORCA_TEST_PROVIDER_SIGNAL_FILE) { + process.on('SIGTERM', () => { + require('node:fs').writeFileSync(process.env.ORCA_TEST_PROVIDER_SIGNAL_FILE, 'SIGTERM') + process.exit(0) + }) + } + process.stdout.on('error', () => {}) + const grandchild = spawn( + process.execPath, + ['-e', "process.on('SIGTERM', () => {}); process.stdout.write('armed'); setInterval(() => {}, 60000)"], + { stdio: ['ignore', 'pipe', 'ignore'] } + ) + grandchild.stdout.once('data', () => { + process.stdout.write(JSON.stringify({ provider: process.pid, grandchild: grandchild.pid }) + '\n') + if (process.env.ORCA_TEST_PROVIDER_STREAMS_OUTPUT) setInterval(() => process.stdout.write('.'), 2) + }) + setInterval(() => {}, 60000) +` + +// Exits the moment its stdin ends, as Codex does on a normal close. +const EXITS_ON_STDIN_END_PROVIDER = String.raw` + process.stdin.on('end', () => process.exit(0)).resume() + process.stdout.write(JSON.stringify({ provider: process.pid }) + '\n') +` + +// Ignores stdin end and SIGTERM, recording when SIGTERM arrived, so only SIGKILL ends it. +const RECORDS_SIGTERM_PROVIDER = String.raw` + process.on('SIGTERM', () => { + require('node:fs').writeFileSync(process.env.ORCA_TEST_PROVIDER_SIGNAL_FILE, String(Date.now())) + }) + process.stdout.write(JSON.stringify({ provider: process.pid }) + '\n') + setInterval(() => {}, 60000) +` + +// Stands in for Orca: launches the supervisor as its own child, then can be killed outright. A +// second child holds the supervisor's stdin open, so only the parent-death watch can notice. +// A clean-quit owner has no holder and exits normally on SIGUSR2, the way Orca quits. +const OWNER = String.raw` + const { spawn } = require('node:child_process') + const quitsCleanly = Boolean(process.env.ORCA_TEST_OWNER_QUITS_CLEANLY) + if (quitsCleanly) process.on('SIGUSR2', () => process.exit(0)) + const spec = JSON.parse(Buffer.from(process.env.ORCA_PROVIDER_SUPERVISOR_SPEC, 'base64').toString()) + spec.ownerPid = process.pid + const supervisor = spawn(process.execPath, ['-e', process.env.ORCA_TEST_SUPERVISOR_SCRIPT], { + env: { ...process.env, ORCA_PROVIDER_SUPERVISOR_SPEC: Buffer.from(JSON.stringify(spec)).toString('base64') }, + stdio: ['pipe', 'pipe', 'ignore'], + detached: true + }) + const holder = quitsCleanly + ? null + : spawn(process.execPath, ['-e', 'setInterval(() => {}, 60000)'], { + stdio: ['ignore', supervisor.stdin, 'ignore'] + }) + process.stdout.write(JSON.stringify({ supervisor: supervisor.pid, ...(holder && { holder: holder.pid }) }) + '\n') + supervisor.stdout.pipe(process.stdout) + setInterval(() => {}, 60000) +` + +// Preloaded into the supervisor: signals it the instant its provider exists, the spawn window. +const SIGNAL_AFTER_SPAWN_PRELOAD = String.raw` + const childProcess = require('node:child_process') + const spawn = childProcess.spawn + childProcess.spawn = (...args) => { + const child = spawn(...args) + require('node:fs').writeFileSync(process.env.ORCA_TEST_PROVIDER_PID_FILE, String(child.pid)) + process.kill(process.pid, 'SIGTERM') + return child + } +` + +const recordedPids = new Set() +const tempDirs: string[] = [] + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-supervisor-')) + tempDirs.push(dir) + return dir +} + +function alive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return !(error instanceof Error && 'code' in error && error.code === 'ESRCH') + } +} + +async function waitFor(predicate: () => boolean, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (Date.now() >= deadline) { + return false + } + await new Promise((resolve) => setTimeout(resolve, 25)) + } + return true +} + +function readPids(child: ChildProcess, keys: readonly string[]): Promise> { + return new Promise((resolve, reject) => { + const pids: Record = {} + let buffered = '' + const timeout = setTimeout(() => reject(new Error(`no ${keys.join('/')} pids`)), 10_000) + const onData = (chunk: Buffer): void => { + buffered += chunk.toString() + const lines = buffered.split('\n') + buffered = lines.pop() ?? '' + for (const line of lines) { + const parsed: unknown = JSON.parse(line) + for (const [key, pid] of Object.entries(parsed ?? {})) { + if (typeof pid === 'number') { + pids[key] = pid + recordedPids.add(pid) + } + } + } + if (keys.every((key) => key in pids)) { + clearTimeout(timeout) + // Later output is not pids; the stream keeps flowing without a listener. + child.stdout!.off('data', onData) + resolve(pids) + } + } + child.stdout!.on('data', onData) + }) +} + +function launchSupervisor( + options: ProviderSupervisorOptions, + env: Record = {}, + provider: { command: string; args: string[] } = { + command: process.execPath, + args: ['-e', PROVIDER] + }, + nodeArgs: string[] = [] +): { supervisor: ChildProcess; exit: Promise<{ code: number | null; signal: string | null }> } { + const launch = supervisedPosixLaunch(provider, { ...process.env, ...env }, options) + const supervisor = spawn(launch.command, [...nodeArgs, ...launch.args], { + env: launch.env, + stdio: ['pipe', 'pipe', 'ignore'], + detached: true + }) + recordedPids.add(supervisor.pid!) + const exit = new Promise<{ code: number | null; signal: string | null }>((resolve) => + supervisor.once('exit', (code, signal) => resolve({ code, signal })) + ) + return { supervisor, exit } +} + +async function launchUnderOwner( + options: ProviderSupervisorOptions, + env: Record = {} +): Promise<{ owner: ChildProcess; pids: Record }> { + const launch = supervisedPosixLaunch( + { command: process.execPath, args: ['-e', PROVIDER] }, + { ...process.env, ...env }, + options + ) + const owner = spawn(process.execPath, ['-e', OWNER], { + env: { ...launch.env, ORCA_TEST_SUPERVISOR_SCRIPT: POSIX_PROVIDER_SUPERVISOR_SCRIPT }, + stdio: ['ignore', 'pipe', 'ignore'] + }) + recordedPids.add(owner.pid!) + const pids = await readPids(owner, ['supervisor', 'provider', 'grandchild']) + return { owner, pids } +} + +afterEach(() => { + for (const pid of recordedPids) { + if (alive(pid)) { + process.kill(pid, 'SIGKILL') + } + } + recordedPids.clear() + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe.runIf(process.platform !== 'win32')('POSIX provider supervisor processes', () => { + it('reaps the provider group on SIGTERM and exits only after the group is gone', async () => { + const { supervisor, exit } = launchSupervisor({ sigtermGraceMs: 300 }) + const { provider, grandchild } = await readPids(supervisor, ['provider', 'grandchild']) + + let groupAliveAtExit: boolean | null = null + void exit.then(() => { + groupAliveAtExit = alive(-provider) + }) + supervisor.kill('SIGTERM') + + await expect(exit).resolves.toEqual({ code: null, signal: 'SIGTERM' }) + expect(groupAliveAtExit).toBe(false) + expect(alive(provider)).toBe(false) + expect(alive(grandchild)).toBe(false) + }) + + it('escalates a SIGTERM-ignoring provider to SIGKILL after the grace from the spec', async () => { + const graceMs = 200 + const { supervisor, exit } = launchSupervisor( + { sigtermGraceMs: graceMs }, + { ORCA_TEST_PROVIDER_IGNORES_SIGTERM: '1' } + ) + const { provider, grandchild } = await readPids(supervisor, ['provider', 'grandchild']) + + const signalledAt = Date.now() + supervisor.kill('SIGTERM') + const exited = await Promise.race([exit, new Promise((resolve) => setTimeout(resolve, 5_000))]) + + expect(exited).toEqual({ code: null, signal: 'SIGTERM' }) + expect(Date.now() - signalledAt).toBeGreaterThanOrEqual(graceMs) + expect(Date.now() - signalledAt).toBeLessThan(PROVIDER_SIGTERM_GRACE_MS) + expect(alive(provider)).toBe(false) + expect(alive(grandchild)).toBe(false) + }) + + it('reaps a provider spawned in the instant before a stop arrives', async () => { + const dir = tempDir() + const preload = join(dir, 'signal-after-spawn.js') + const pidFile = join(dir, 'provider-pid') + writeFileSync(preload, SIGNAL_AFTER_SPAWN_PRELOAD) + const { exit } = launchSupervisor( + { sigtermGraceMs: 300 }, + { ORCA_TEST_PROVIDER_PID_FILE: pidFile }, + { command: process.execPath, args: ['-e', 'setInterval(() => {}, 60000)'] }, + ['--require', preload] + ) + + await expect(exit).resolves.toEqual({ code: null, signal: 'SIGTERM' }) + const provider = Number(readFileSync(pidFile, 'utf8')) + recordedPids.add(provider) + expect(await waitFor(() => !alive(provider), 3_000)).toBe(true) + }) + + it('never spawns the provider when its owner is not its parent at start', async () => { + const marker = join(tempDir(), 'provider-started') + const { exit } = launchSupervisor( + { ownerPid: process.pid === 1 ? 2 : 1 }, + {}, + { command: 'touch', args: [marker] } + ) + + await expect(exit).resolves.toEqual({ code: 1, signal: null }) + await new Promise((resolve) => setTimeout(resolve, 200)) + expect(existsSync(marker)).toBe(false) + }) + + it.each([ + ['', {}], + // Output after the owner's death meets a closed pipe, which must not end the supervisor first. + [' while the provider is writing output', { ORCA_TEST_PROVIDER_STREAMS_OUTPUT: '1' }] + ])('reaps the provider group when its owner dies%s', async (_, env) => { + const graceMs = 300 + const { owner, pids } = await launchUnderOwner({ sigtermGraceMs: graceMs }, env) + + const killedAt = Date.now() + owner.kill('SIGKILL') + + expect(await waitFor(() => !alive(-pids.provider), 3_000)).toBe(true) + // The grandchild ignores SIGTERM, so the group lasts until the grace ends in SIGKILL. + expect(Date.now() - killedAt).toBeGreaterThanOrEqual(graceMs) + expect(await waitFor(() => !alive(pids.supervisor), 3_000)).toBe(true) + expect(alive(pids.grandchild)).toBe(false) + }) + + it('closes a provider that exits on stdin end without waiting out any grace', async () => { + const { supervisor, exit } = launchSupervisor( + {}, + {}, + { + command: process.execPath, + args: ['-e', EXITS_ON_STDIN_END_PROVIDER] + } + ) + const { provider } = await readPids(supervisor, ['provider']) + + const endedAt = Date.now() + supervisor.stdin!.end() + + await expect(exit).resolves.toEqual({ code: 0, signal: null }) + expect(Date.now() - endedAt).toBeLessThan(PROVIDER_STDIN_END_GRACE_MS) + expect(alive(provider)).toBe(false) + }) + + it('gives a provider 1 s after stdin end, then 3 s after SIGTERM before SIGKILL', async () => { + const signalFile = join(tempDir(), 'provider-sigterm-at') + const { supervisor, exit } = launchSupervisor( + {}, + { ORCA_TEST_PROVIDER_SIGNAL_FILE: signalFile }, + { command: process.execPath, args: ['-e', RECORDS_SIGTERM_PROVIDER] } + ) + const { provider } = await readPids(supervisor, ['provider']) + + const endedAt = Date.now() + supervisor.stdin!.end() + const exited = await exit + const exitedAt = Date.now() + const signalledAt = Number(readFileSync(signalFile, 'utf8')) + + expect(exited).toEqual({ code: 137, signal: null }) + // Timers may fire a tick early against another process's clock. + expect(signalledAt - endedAt).toBeGreaterThanOrEqual(1_000 - 20) + expect(exitedAt - signalledAt).toBeGreaterThanOrEqual(3_000 - 20) + expect(exitedAt - endedAt).toBeLessThan(PROVIDER_SUPERVISOR_MAX_STOP_MS + 1_000) + expect(alive(provider)).toBe(false) + }) + + it('reaps the provider group and exits when its owner quits cleanly', async () => { + const { owner, pids } = await launchUnderOwner( + { sigtermGraceMs: 300 }, + { ORCA_TEST_OWNER_QUITS_CLEANLY: '1' } + ) + const ownerExit = new Promise((resolve) => + owner.once('exit', (code, signal) => resolve({ code, signal })) + ) + + owner.kill('SIGUSR2') + + await expect(ownerExit).resolves.toEqual({ code: 0, signal: null }) + expect(await waitFor(() => !alive(-pids.provider), 3_000)).toBe(true) + expect(await waitFor(() => !alive(pids.supervisor), 3_000)).toBe(true) + expect(alive(pids.grandchild)).toBe(false) + }) + + it('asks the provider to stop with SIGTERM when its owner dies', async () => { + const signalFile = join(tempDir(), 'provider-signal') + const { owner, pids } = await launchUnderOwner( + { sigtermGraceMs: 300 }, + { ORCA_TEST_PROVIDER_SIGNAL_FILE: signalFile } + ) + + owner.kill('SIGKILL') + + expect(await waitFor(() => !alive(-pids.provider), 3_000)).toBe(true) + expect(existsSync(signalFile) && readFileSync(signalFile, 'utf8')).toBe('SIGTERM') + }) +}) diff --git a/src/main/codex/codex-app-server-posix-supervisor.test.ts b/src/main/codex/codex-app-server-posix-supervisor.test.ts index f51157a443c..6dbe03e5b2a 100644 --- a/src/main/codex/codex-app-server-posix-supervisor.test.ts +++ b/src/main/codex/codex-app-server-posix-supervisor.test.ts @@ -3,6 +3,8 @@ import type { CodexAppServerLaunch } from './codex-app-server-connection' import { createProviderSpawnSpec, POSIX_PROVIDER_SUPERVISOR_SCRIPT, + PROVIDER_SIGTERM_GRACE_MS, + PROVIDER_STDIN_END_GRACE_MS, supervisedPosixLaunch } from './codex-app-server-posix-supervisor' @@ -27,7 +29,10 @@ describe('structured provider supervision', () => { expect.objectContaining({ command: '/opt/codex', args: ['app-server', '--flag'], - cwd: '/work/repo' + cwd: '/work/repo', + ownerPid: process.pid, + stdinEndGraceMs: PROVIDER_STDIN_END_GRACE_MS, + sigtermGraceMs: PROVIDER_SIGTERM_GRACE_MS }) ) expect( @@ -38,7 +43,7 @@ describe('structured provider supervision', () => { ) expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain('delete childEnv.ELECTRON_RUN_AS_NODE') expect(spec.env.ELECTRON_RUN_AS_NODE).toBe('1') - expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain('process.ppid !== originalParent') + expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain('process.ppid !== spec.ownerPid') expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain( "process.stdin.once('close', scheduleOwnerShutdown)" ) @@ -49,13 +54,26 @@ describe('structured provider supervision', () => { expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).not.toContain('process.ppid === 1') }) + it('refuses a grace longer than recovery waits before SIGKILL', () => { + const stdinEnd = (stdinEndGraceMs: number) => () => + supervisedPosixLaunch(launch, {}, { stdinEndGraceMs }) + const sigterm = (sigtermGraceMs: number) => () => + supervisedPosixLaunch(launch, {}, { sigtermGraceMs }) + + expect(stdinEnd(PROVIDER_STDIN_END_GRACE_MS)).not.toThrow() + expect(stdinEnd(PROVIDER_STDIN_END_GRACE_MS + 1)).toThrow(RangeError) + expect(sigterm(PROVIDER_SIGTERM_GRACE_MS)).not.toThrow() + expect(sigterm(PROVIDER_SIGTERM_GRACE_MS + 1)).toThrow(RangeError) + }) + it('uses direct provider spawning on Windows because the job owns the tree', () => { expect(createProviderSpawnSpec(launch, { PATH: '/bin' }, 'win32')).toEqual({ program: '/opt/codex', args: ['app-server', '--flag'], env: { PATH: '/bin' }, cwd: '/work/repo', - detached: false + detached: false, + supervised: false }) }) }) diff --git a/src/main/codex/codex-app-server-posix-supervisor.ts b/src/main/codex/codex-app-server-posix-supervisor.ts index f83ba6f374b..21dd56aeb5b 100644 --- a/src/main/codex/codex-app-server-posix-supervisor.ts +++ b/src/main/codex/codex-app-server-posix-supervisor.ts @@ -1,9 +1,33 @@ import type { CodexAppServerLaunch } from './codex-app-server-connection' +/** Time the provider gets to exit on its own after its stdin ends, before SIGTERM. */ +export const PROVIDER_STDIN_END_GRACE_MS = 1_000 +/** Time the provider group gets to flush and exit after SIGTERM, before SIGKILL. */ +export const PROVIDER_SIGTERM_GRACE_MS = 3_000 +/** + * How long the supervisor waits for a SIGKILLed group to disappear. A killed process never runs + * again, so this only covers the kernel finishing the kill; waiting forever could hang close or + * recovery on a process stuck in the kernel, such as one blocked on a hung network drive. + */ +export const PROVIDER_GROUP_REAP_TIMEOUT_MS = 1_500 +/** + * Longest a supervisor can take to stop once asked (stdin end, grace, SIGTERM, grace, SIGKILL, + * reap); a SIGKILL sooner can orphan its group. The graces are also the largest a spec may carry. + */ +export const PROVIDER_SUPERVISOR_MAX_STOP_MS = + PROVIDER_STDIN_END_GRACE_MS + PROVIDER_SIGTERM_GRACE_MS + PROVIDER_GROUP_REAP_TIMEOUT_MS + /** Inline supervisor source kept dependency-free for the spawned Node child. */ export const POSIX_PROVIDER_SUPERVISOR_SCRIPT = ` const { spawn } = require('node:child_process') const spec = JSON.parse(Buffer.from(process.env.ORCA_PROVIDER_SUPERVISOR_SPEC, 'base64').toString()) +// A detached supervisor is reparented when its owner exits. The new parent may +// be PID 1 or a platform subreaper, so any other parent means no live owner. +const ownerGone = () => process.ppid !== spec.ownerPid +// Registered before the spawn, so a stop that lands while the provider starts still reaps it. +for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, () => stopProviderGroup(signal)) +// Orca can die before this runs; spawning then would start a provider nothing watches. +if (ownerGone()) process.exit(1) const childEnv = { ...process.env } delete childEnv.ORCA_PROVIDER_SUPERVISOR_SPEC delete childEnv.ELECTRON_RUN_AS_NODE @@ -13,7 +37,6 @@ const child = spawn(spec.command, spec.args, { stdio: ['pipe', 'pipe', 'pipe'], detached: true }) -const originalParent = process.ppid let timer let ownerShutdownTimer let settling = false @@ -26,29 +49,47 @@ const providerGroupExists = () => { return Boolean(error && error.code !== 'ESRCH') } } -const reapOwnedProviderGroup = async () => { - if (!child.pid) return false - try { process.kill(-child.pid, 'SIGKILL') } catch (error) { - if (error && error.code !== 'ESRCH') return false - } - const deadline = Date.now() + 1500 +const waitForProviderGroupExit = async (timeoutMs) => { + const deadline = Date.now() + timeoutMs while (providerGroupExists()) { if (Date.now() >= deadline) return false await new Promise((resolve) => setTimeout(resolve, 25)) } return true } -const terminateOwnedGroup = () => { +const reapOwnedProviderGroup = async () => { + if (!child.pid) return false + try { process.kill(-child.pid, 'SIGKILL') } catch (error) { + if (error && error.code !== 'ESRCH') return false + } + return waitForProviderGroupExit(${PROVIDER_GROUP_REAP_TIMEOUT_MS}) +} +const finishWithProviderOutcome = (code, signal) => { + if (!signal) return process.exit(code ?? 1) + // Re-raise with the default action; this supervisor's own handler would swallow it. + process.removeAllListeners(signal) + process.kill(process.pid, signal) +} +// Every stop is the same: SIGTERM the group, SIGKILL it after its grace, and exit only once it is +// gone. Whoever stops this pid judges the provider by it, so a dead supervisor means a dead group. +const stopProviderGroup = (receivedSignal) => { if (settling) return settling = true clearInterval(timer) - void reapOwnedProviderGroup().then((reaped) => process.exit(reaped ? 137 : 1)) + if (ownerShutdownTimer) clearTimeout(ownerShutdownTimer) + try { process.kill(-child.pid, 'SIGTERM') } catch {} + void waitForProviderGroupExit(spec.sigtermGraceMs) + .then((exited) => exited || reapOwnedProviderGroup()) + .then((reaped) => { + if (!reaped) return process.exit(1) + finishWithProviderOutcome(137, receivedSignal) + }) } const scheduleOwnerShutdown = () => { if (settling || ownerShutdownTimer) return // A normal close ends the provider's stdin first; allow it to flush and // exit before forcing the group, while still bounding an orphaned child. - ownerShutdownTimer = setTimeout(terminateOwnedGroup, 1250) + ownerShutdownTimer = setTimeout(() => stopProviderGroup(null), spec.stdinEndGraceMs) ownerShutdownTimer.unref() } process.stdin.once('end', scheduleOwnerShutdown) @@ -56,10 +97,9 @@ process.stdin.once('close', scheduleOwnerShutdown) process.stdin.pipe(child.stdin) child.stdout.pipe(process.stdout) child.stderr.pipe(process.stderr) -for (const stream of [process.stdin, child.stdin, child.stdout, child.stderr]) stream.on('error', () => {}) -const finishWithProviderOutcome = (code, signal) => { - if (!signal) return process.exit(code ?? 1) - process.kill(process.pid, signal) +// A dead owner's stdout pipe raises EPIPE; unhandled, it would end this pid before the group. +for (const stream of [process.stdin, process.stdout, process.stderr, child.stdin, child.stdout, child.stderr]) { + stream.on('error', () => {}) } const reapProviderExit = async (code, signal) => { if (settling) return @@ -70,33 +110,54 @@ const reapProviderExit = async (code, signal) => { finishWithProviderOutcome(code, signal) } timer = setInterval(() => { - // A detached supervisor is reparented when its owner exits. The new parent - // may be PID 1 or a platform subreaper, so any parent change is proof that - // this process group no longer has a live Orca owner. - if (process.ppid !== originalParent) { - terminateOwnedGroup() - } + if (ownerGone()) stopProviderGroup(null) }, 100) timer.unref() -child.once('error', () => { +child.once('error', (error) => { clearInterval(timer) - process.exit(127) + // The owner sees only this pid's exit; stderr is where a missing provider binary can say so. + process.stderr.write(String(error && error.message) + '\\n', () => process.exit(127)) }) child.once('exit', (code, signal) => { void reapProviderExit(code, signal) }) ` +export type ProviderSupervisorOptions = { + cwd?: string + /** The process the supervisor serves; it must be the supervisor's parent. */ + ownerPid?: number + stdinEndGraceMs?: number + sigtermGraceMs?: number +} + +// A longer grace than the max stop allows would let recovery or close SIGKILL mid-stop. +function assertGraceWithin(name: string, graceMs: number, maxMs: number): void { + if (!(graceMs >= 0 && graceMs <= maxMs)) { + throw new RangeError(`Provider supervisor ${name} grace ${graceMs} ms is outside 0-${maxMs} ms`) + } +} + export function supervisedPosixLaunch( launch: CodexAppServerLaunch, childEnv: NodeJS.ProcessEnv, - cwd = launch.cwd ?? process.cwd() + { + cwd = launch.cwd ?? process.cwd(), + ownerPid = process.pid, + stdinEndGraceMs = PROVIDER_STDIN_END_GRACE_MS, + sigtermGraceMs = PROVIDER_SIGTERM_GRACE_MS + }: ProviderSupervisorOptions = {} ): { command: string; args: string[]; env: NodeJS.ProcessEnv } { + assertGraceWithin('stdin-end', stdinEndGraceMs, PROVIDER_STDIN_END_GRACE_MS) + assertGraceWithin('SIGTERM', sigtermGraceMs, PROVIDER_SIGTERM_GRACE_MS) const supervisorSpec = Buffer.from( JSON.stringify({ command: launch.command, args: launch.args, - cwd + cwd, + ownerPid, + stdinEndGraceMs, + sigtermGraceMs }) ).toString('base64') return { @@ -116,13 +177,22 @@ export function createProviderSpawnSpec( launch: CodexAppServerLaunch, childEnv: NodeJS.ProcessEnv, platform: NodeJS.Platform -): { program: string; args: string[]; env: NodeJS.ProcessEnv; cwd: string; detached: boolean } { - const supervised = platform === 'win32' ? null : supervisedPosixLaunch(launch, childEnv) +): { + program: string + args: string[] + env: NodeJS.ProcessEnv + cwd: string + detached: boolean + /** The child is the supervisor, whose SIGTERM stops the provider and then itself. */ + supervised: boolean +} { + const supervisor = platform === 'win32' ? null : supervisedPosixLaunch(launch, childEnv) return { - program: supervised?.command ?? launch.command, - args: supervised?.args ?? launch.args, - env: supervised?.env ?? childEnv, + program: supervisor?.command ?? launch.command, + args: supervisor?.args ?? launch.args, + env: supervisor?.env ?? childEnv, cwd: launch.cwd ?? process.cwd(), - detached: platform !== 'win32' + detached: platform !== 'win32', + supervised: supervisor !== null } } diff --git a/src/main/codex/codex-app-server-process-teardown.test.ts b/src/main/codex/codex-app-server-process-teardown.test.ts index cec8f91d081..295d780718d 100644 --- a/src/main/codex/codex-app-server-process-teardown.test.ts +++ b/src/main/codex/codex-app-server-process-teardown.test.ts @@ -26,7 +26,7 @@ describe('terminateCodexAppServerProcessTree', () => { const release = Promise.withResolvers() const terminateWindowsTree = vi.fn(() => release.promise) - const teardown = terminateCodexAppServerProcessTree(target, undefined, { + const teardown = terminateCodexAppServerProcessTree(target, { platform: 'win32', terminateWindowsTree }) @@ -38,52 +38,12 @@ describe('terminateCodexAppServerProcessTree', () => { expect(target.kill).toHaveBeenCalledWith('SIGKILL') }) - it('kills exact Linux spawn-token PIDs before the recorded wrapper', async () => { - const target = child() - const findSpawnTokenProcesses = vi - .fn<() => Promise>() - .mockResolvedValueOnce([1234, 2345, 3456]) - .mockResolvedValueOnce([1234]) - .mockResolvedValueOnce([1234]) - const signalPid = vi.fn() - - await expect( - terminateCodexAppServerProcessTree(target, 'spawn-1', { - platform: 'linux', - findSpawnTokenProcesses, - signalPid, - isPidPresent: () => false, - wait: async () => undefined - }) - ).resolves.toBe(true) - - expect(signalPid.mock.calls).toEqual([ - [2345, 'SIGKILL'], - [3456, 'SIGKILL'] - ]) - expect(target.kill).toHaveBeenCalledTimes(1) - expect(target.kill).toHaveBeenCalledWith('SIGKILL') - }) - - it('keeps the wrapper reachable when Linux cannot prove descendant exit', async () => { - const target = child() - - await expect( - terminateCodexAppServerProcessTree(target, 'spawn-1', { - platform: 'linux', - findSpawnTokenProcesses: async () => null - }) - ).resolves.toBe(false) - - expect(target.kill).not.toHaveBeenCalled() - }) - it('waits for an owned POSIX snapshot before killing the wrapper', async () => { const target = child() const snapshot = { rootPgid: 1234, descendants: [], capturedAtMs: 1 } const release = Promise.withResolvers() - const teardown = terminateCodexAppServerProcessTree(target, undefined, { + const teardown = terminateCodexAppServerProcessTree(target, { platform: 'darwin', captureDescendants: async () => snapshot, terminateDescendants: () => release.promise @@ -102,7 +62,7 @@ describe('terminateCodexAppServerProcessTree', () => { const signalProcessGroup = vi.fn() await expect( - terminateCodexAppServerProcessTree(target, undefined, { + terminateCodexAppServerProcessTree(target, { platform: 'darwin', dedicatedProcessGroup: true, captureDescendants, @@ -119,7 +79,7 @@ describe('terminateCodexAppServerProcessTree', () => { const target = child() await expect( - terminateCodexAppServerProcessTree(target, undefined, { + terminateCodexAppServerProcessTree(target, { platform: 'linux', dedicatedProcessGroup: true, signalProcessGroup: () => { @@ -142,7 +102,7 @@ describe('terminateCodexAppServerProcessTree', () => { const target = { pid: UNREACHABLE_PGID, kill: vi.fn(() => true) as ChildProcess['kill'] } await expect( - terminateCodexAppServerProcessTree(target, undefined, { + terminateCodexAppServerProcessTree(target, { platform: 'darwin', captureDescendants: async () => ({ rootPgid: UNREACHABLE_PGID, @@ -162,7 +122,7 @@ describe('terminateCodexAppServerProcessTree', () => { const signalProcessGroup = vi.fn() await expect( - terminateCodexAppServerProcessTree(target, undefined, { + terminateCodexAppServerProcessTree(target, { platform: 'darwin', captureDescendants: async () => ({ rootPgid: 1234, descendants: [], capturedAtMs: 1 }), terminateDescendants: async () => true, @@ -191,7 +151,7 @@ describe('terminateCodexAppServerProcessTree', () => { const results = await Promise.all( targets.map((target) => - terminateCodexAppServerProcessTree(target, undefined, { + terminateCodexAppServerProcessTree(target, { platform: 'linux', dedicatedProcessGroup: true, captureDescendants, diff --git a/src/main/codex/codex-app-server-process-teardown.ts b/src/main/codex/codex-app-server-process-teardown.ts index 5a9c6e3574b..10e625bef7e 100644 --- a/src/main/codex/codex-app-server-process-teardown.ts +++ b/src/main/codex/codex-app-server-process-teardown.ts @@ -2,11 +2,8 @@ import type { ChildProcessHandle } from '../../shared/child-process/run-process' import { captureDescendantSnapshot, type DescendantSnapshot } from '../pty-descendant-termination' import { terminateDescendantSnapshotAndWait } from '../pty-descendant-exit-verification' import { terminateWindowsProcessTree } from '../windows-process-tree-kill' -import { findAgentSessionSpawnTokenProcesses } from '../runtime/agent-session-spawn-token-process-scan' import { recordSelfInitiatedTreeKill } from '../crash-reporting/self-initiated-tree-kill-log' -const TOKEN_PROCESS_EXIT_TIMEOUT_MS = 3_500 -const TOKEN_PROCESS_POLL_MS = 25 const activeTeardowns = new WeakMap>() type TeardownChild = Pick @@ -14,16 +11,10 @@ type TeardownChild = Pick export type CodexAppServerProcessTeardownDeps = { platform?: NodeJS.Platform dedicatedProcessGroup?: boolean - /** Diagnostic/recovery injection only; never used by the primary teardown. */ - findSpawnTokenProcesses?: (spawnToken: string) => Promise captureDescendants?: (rootPid: number) => Promise terminateDescendants?: (snapshot: DescendantSnapshot) => Promise terminateWindowsTree?: (rootPid: number, deps?: { site?: string }) => Promise - signalPid?: (pid: number, signal: NodeJS.Signals) => void signalProcessGroup?: (pgid: number, signal: NodeJS.Signals) => void - isPidPresent?: (pid: number) => boolean - wait?: (ms: number) => Promise - now?: () => number } function terminateDedicatedPosixGroup( @@ -47,70 +38,11 @@ function terminateDedicatedPosixGroup( return true } -function sendSignal(pid: number, signal: NodeJS.Signals): void { - try { - process.kill(pid, signal) - } catch { - // An already-gone exact PID is the desired outcome. - } -} - -function isPidPresent(pid: number): boolean { - try { - process.kill(pid, 0) - return true - } catch (error) { - return (error as NodeJS.ErrnoException).code !== 'ESRCH' - } -} - -async function diagnosticTokenFallback( - rootPid: number, - spawnToken: string, - deps: CodexAppServerProcessTeardownDeps -): Promise { - const find = deps.findSpawnTokenProcesses ?? findAgentSessionSpawnTokenProcesses - const signal = deps.signalPid ?? sendSignal - const pidPresent = deps.isPidPresent ?? isPidPresent - const delay = - deps.wait ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) - const now = deps.now ?? Date.now - const deadline = now() + TOKEN_PROCESS_EXIT_TIMEOUT_MS - const signalled = new Set() - while (now() < deadline) { - const pids = await find(spawnToken).catch(() => null) - if (pids === null) { - return false - } - for (const pid of pids.filter((candidate) => candidate !== rootPid)) { - signalled.add(pid) - signal(pid, 'SIGKILL') - } - if ([...signalled].every((pid) => !pidPresent(pid))) { - return true - } - await delay(TOKEN_PROCESS_POLL_MS) - } - return false -} - async function terminatePosixTree( child: TeardownChild, rootPid: number, - _spawnToken: string | undefined, deps: CodexAppServerProcessTeardownDeps ): Promise { - // Kept only for explicit recovery callers/tests. Production always follows - // the dedicated process-group path below; token enumeration is evidence, - // never the owner of orphan-reaping decisions. - if (_spawnToken && deps.findSpawnTokenProcesses) { - const reaped = await diagnosticTokenFallback(rootPid, _spawnToken, deps) - if (reaped) { - child.kill('SIGKILL') - return true - } - return false - } child.kill('SIGSTOP') const capture = deps.captureDescendants ?? captureDescendantSnapshot const snapshot = await capture(rootPid).catch(() => null) @@ -158,7 +90,6 @@ async function terminatePosixTree( /** Stops every process owned by one app-server launch before releasing its wrapper. */ async function terminateOnce( child: TeardownChild, - spawnToken: string | undefined, deps: CodexAppServerProcessTeardownDeps ): Promise { const rootPid = child.pid @@ -176,12 +107,11 @@ async function terminateOnce( if (deps.dedicatedProcessGroup) { return terminateDedicatedPosixGroup(rootPid, deps) } - return terminatePosixTree(child, rootPid, spawnToken, deps) + return terminatePosixTree(child, rootPid, deps) } export function terminateCodexAppServerProcessTree( child: TeardownChild, - spawnToken?: string, deps: CodexAppServerProcessTeardownDeps = {} ): Promise { const key = child as object @@ -189,7 +119,7 @@ export function terminateCodexAppServerProcessTree( if (active) { return active } - const attempt = terminateOnce(child, spawnToken, deps).catch(() => false) + const attempt = terminateOnce(child, deps).catch(() => false) activeTeardowns.set(key, attempt) void attempt.then(() => { if (activeTeardowns.get(key) === attempt) { diff --git a/src/main/codex/codex-app-server-record-dispatch.ts b/src/main/codex/codex-app-server-record-dispatch.ts index e76f2509399..cbac80f3643 100644 --- a/src/main/codex/codex-app-server-record-dispatch.ts +++ b/src/main/codex/codex-app-server-record-dispatch.ts @@ -10,6 +10,7 @@ import { import { classifyJsonRpcPrefix } from './codex-app-server-record-prefix' const OVERSIZED_REQUEST_ERROR_CODE = -32001 +const MAX_REMEMBERED_TIMEOUTS = 64 export type CodexPendingRequest = { method: string @@ -25,11 +26,26 @@ export function createCodexAppServerRecordDispatcher(input: { }): { addPending: (id: number, waiter: CodexPendingRequest) => void deletePending: (id: number) => void + timeOutPending: (id: number) => void failPending: (error: Error) => void dispatch: (message: Record) => void rejectOversized: (rejected: NdjsonRejectedRecord & { kind: 'line-too-long' }) => void } { const pending = new Map() + const timedOutMethods = new Map() + + const timeOutPending = (id: number): void => { + const waiter = pending.get(id) + if (!waiter) { + return + } + pending.delete(id) + timedOutMethods.set(id, waiter.method) + const oldest = timedOutMethods.keys().next() + if (timedOutMethods.size > MAX_REMEMBERED_TIMEOUTS && !oldest.done) { + timedOutMethods.delete(oldest.value) + } + } const failPending = (error: Error): void => { for (const waiter of pending.values()) { @@ -72,7 +88,17 @@ export function createCodexAppServerRecordDispatcher(input: { } const waiter = pending.get(message.id) if (!waiter) { - input.handlers.onUnhandledFrame?.('response:unmatched', message) + // Transport diagnostics, not conversation: only the request that gave up could have + // interpreted this reply, and it already reported its own outcome. + const timedOutMethod = timedOutMethods.get(message.id) + timedOutMethods.delete(message.id) + const error = isAppServerRecord(message.error) ? message.error.message : undefined + console.warn( + timedOutMethod + ? `[codex-app-server] late reply to ${timedOutMethod} after timeout (id ${message.id})` + : `[codex-app-server] reply with no waiting request (id ${message.id})`, + ...(typeof error === 'string' ? [error] : []) + ) return } pending.delete(message.id) @@ -88,7 +114,8 @@ export function createCodexAppServerRecordDispatcher(input: { : new CodexAppServerRequestError( waiter.method, typeof error.code === 'number' ? error.code : null, - `codex app-server ${waiter.method} failed: ${detail}` + `codex app-server ${waiter.method} failed: ${detail}`, + typeof error.message === 'string' ? error.message : undefined ) ) return @@ -159,6 +186,7 @@ export function createCodexAppServerRecordDispatcher(input: { return { addPending: (id, waiter) => pending.set(id, waiter), deletePending: (id) => pending.delete(id), + timeOutPending, failPending, dispatch, rejectOversized diff --git a/src/main/codex/codex-app-server-request-error.ts b/src/main/codex/codex-app-server-request-error.ts index 0dbefaca965..0e7cc719017 100644 --- a/src/main/codex/codex-app-server-request-error.ts +++ b/src/main/codex/codex-app-server-request-error.ts @@ -1,12 +1,23 @@ +import { providerDiagnostic, type ProviderDiagnostic } from '../../shared/agent-session-failure' + /** Codex answered the call and refused it, rather than timing out or exiting. */ export class CodexAppServerRequestError extends Error { + /** Codex's own `error.message`, kept apart from the Orca text around it. */ + readonly providerDiagnostic?: ProviderDiagnostic + constructor( readonly method: string, readonly code: number | null, - message: string + message: string, + providerMessage?: string ) { super(message) this.name = 'CodexAppServerRequestError' + const diagnostic = + providerMessage === undefined ? undefined : providerDiagnostic(providerMessage, 'person') + if (diagnostic) { + this.providerDiagnostic = diagnostic + } } } diff --git a/src/main/codex/codex-background-command-tracker.ts b/src/main/codex/codex-background-command-tracker.ts index 844134881ad..922743f1a83 100644 --- a/src/main/codex/codex-background-command-tracker.ts +++ b/src/main/codex/codex-background-command-tracker.ts @@ -1,15 +1,20 @@ import type { AgentSessionBackgroundTask } from '../../shared/agent-session-wire' import type { CodexBackgroundTaskEvent } from './codex-background-task-frames' -import { codexCommandOutlivesTurn } from './codex-command-lifecycle' import { readRecord, readString } from './codex-item-field-readers' import { readCodexThreadItem } from './codex-structured-item-translation' import { MAX_CODEX_ITEM_STREAM_METADATA_BYTES } from './codex-item-stream-retention' +import type { CodexAbandonedCommand } from './codex-prompt-registry' const MAX_SETTLED_COMMANDS = 128 const MAX_DESCRIPTION_CHARS = 512 type Command = { threadId: string; task: AgentSessionBackgroundTask; bytes: number } +/** A command process starting, or ending: it exited, its thread closed, or the session ended. */ +export type CodexBackgroundCommandChange = + | { type: 'started'; threadId: string; task: AgentSessionBackgroundTask } + | { type: 'ended'; threadId: string; taskId: string } + /** The label's reserved share of the description. Reserved, not merely capped: * a label free to spend the whole budget clips away the command it qualifies, * leaving a command row naming an agent and no command — the failure this @@ -65,28 +70,17 @@ export class CodexBackgroundCommandTracker { ) } - observe(event: CodexBackgroundTaskEvent): void { + observe(event: CodexBackgroundTaskEvent): CodexBackgroundCommandChange | null { const parsed = this.parse(event) if (!parsed || this.settled.has(parsed.key)) { - return + return null } const { key, command, completed } = parsed - const existing = this.commands.get(key) if (completed) { - if (existing) { - this.liveBytes -= existing.bytes - this.commands.delete(key) - } - const bytes = Buffer.byteLength(key, 'utf8') + 256 - if (this.liveBytes + bytes <= this.maxMetadataBytes) { - this.settled.set(key, bytes) - this.settledBytes += bytes - } - this.trimSettled() - return + return this.end(key) } - if (existing) { - return + if (this.commands.has(key)) { + return null } if (this.liveBytes + command.bytes > this.maxMetadataBytes) { throw new Error('Codex command metadata was not admitted before observation') @@ -94,6 +88,19 @@ export class CodexBackgroundCommandTracker { this.commands.set(key, command) this.liveBytes += command.bytes this.trimSettled() + return { type: 'started', threadId: command.threadId, task: command.task } + } + + /** The thread closed: Codex stops its processes first, so none of them can report an exit. */ + endThread(threadId: string): CodexBackgroundCommandChange[] { + return [...this.commands] + .filter(([, command]) => command.threadId === threadId) + .flatMap(([key]) => this.end(key) ?? []) + } + + /** Its approval went unanswered until its turn ended, so its process never started. */ + endUnapproved(command: CodexAbandonedCommand): CodexBackgroundCommandChange | null { + return this.end(JSON.stringify([command.threadId, command.itemId])) } tasks( @@ -113,11 +120,45 @@ export class CodexBackgroundCommandTracker { }) } - clear(): void { + /** The live commands one thread launched, as the strip would publish them. */ + threadTasks(threadId: string): AgentSessionBackgroundTask[] { + return [...this.commands.values()] + .filter((command) => command.threadId === threadId) + .map((command) => command.task) + } + + /** The session ended, and every command with it. */ + clear(): CodexBackgroundCommandChange[] { + const ended = [...this.commands.values()].map( + ({ threadId, task }): CodexBackgroundCommandChange => ({ + type: 'ended', + threadId, + taskId: task.id + }) + ) this.commands.clear() this.settled.clear() this.liveBytes = 0 this.settledBytes = 0 + return ended + } + + /** Retires the key so a replayed frame cannot start the command again. */ + private end(key: string): CodexBackgroundCommandChange | null { + const existing = this.commands.get(key) + if (existing) { + this.liveBytes -= existing.bytes + this.commands.delete(key) + } + const bytes = Buffer.byteLength(key, 'utf8') + 256 + if (this.liveBytes + bytes <= this.maxMetadataBytes) { + this.settled.set(key, bytes) + this.settledBytes += bytes + } + this.trimSettled() + return existing + ? { type: 'ended', threadId: existing.threadId, taskId: existing.task.id } + : null } private trimSettled(): void { @@ -140,8 +181,10 @@ export class CodexBackgroundCommandTracker { if (event.method !== 'item/started' && event.method !== 'item/completed') { return null } + // Any command may outlive its turn; `source` says only how Codex launched it. A stdin write + // starts no process: it reaches one already tracked. const item = readCodexThreadItem(readRecord(event.params).item) - if (!item || !codexCommandOutlivesTurn(item)) { + if (item?.type !== 'commandExecution' || item.source === 'unifiedExecInteraction') { return null } const key = JSON.stringify([event.threadId, item.id]) diff --git a/src/main/codex/codex-background-task-frames.ts b/src/main/codex/codex-background-task-frames.ts index a2e7a7e2151..f01863956b6 100644 --- a/src/main/codex/codex-background-task-frames.ts +++ b/src/main/codex/codex-background-task-frames.ts @@ -15,6 +15,8 @@ export type CodexBackgroundTaskFrame = agentThreadId: string label: string | null parentTurnId: string | null | undefined + /** The reporting thread, for a `started` activity: the agent that spawned the child. */ + spawnerThreadId: string | undefined } | { kind: 'turn' @@ -22,6 +24,11 @@ export type CodexBackgroundTaskFrame = turnId: string state: NativeChatSubagentState } + | { + /** A child thread that closed: it ran its last turn, and Codex never said how it went. */ + kind: 'thread-closed' + threadId: string + } export type CodexBackgroundTaskEvent = { method: string @@ -29,10 +36,29 @@ export type CodexBackgroundTaskEvent = { params: unknown } +/** + * The one way Codex ends a child's turn without `turn/completed`: a closed thread ran its last + * turn and Codex never said how it went. A turn-ending `error` is not one — Codex follows it with + * a failed `turn/completed` for the same turn, which is that turn's end and carries the duration + * and receipt time the error does not. + */ +function readCodexChildThreadClosed( + event: CodexBackgroundTaskEvent +): CodexBackgroundTaskFrame | null { + return event.method === 'thread/closed' + ? { kind: 'thread-closed', threadId: event.threadId } + : null +} + export function readCodexBackgroundTaskFrame( event: CodexBackgroundTaskEvent, primaryThreadId: string ): CodexBackgroundTaskFrame | null { + // The session's own turn ends through the journal's turn boundaries, never here. + const ending = event.threadId === primaryThreadId ? null : readCodexChildThreadClosed(event) + if (ending) { + return ending + } if (event.method === 'turn/started' || event.method === 'turn/completed') { const turnId = readCodexTurnId(event.params) if (turnId === null) { @@ -67,6 +93,8 @@ export function readCodexBackgroundTaskFrame( parentTurnId: activity.kind === 'started' || activity.kind === 'interacted' ? readCodexTurnId(event.params) - : undefined + : undefined, + // Only `started` names the spawner: other kinds ride whichever agent acted. + spawnerThreadId: activity.kind === 'started' ? event.threadId : undefined } } diff --git a/src/main/codex/codex-background-task-tracker.ts b/src/main/codex/codex-background-task-tracker.ts index a972b7bb4c1..6429a84d4fd 100644 --- a/src/main/codex/codex-background-task-tracker.ts +++ b/src/main/codex/codex-background-task-tracker.ts @@ -2,25 +2,50 @@ import type { AgentSessionBackgroundTask, AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' +import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import { readCodexBackgroundTaskFrame, type CodexBackgroundTaskEvent } from './codex-background-task-frames' import { CodexSubagentExecutions } from './codex-subagent-executions' import { CodexBackgroundCommandTracker } from './codex-background-command-tracker' +import { CodexChildWorkEvidence } from './codex-child-work-evidence' +import type { CodexAbandonedCommand } from './codex-prompt-registry' +import type { CodexStructuredSessionAdapterDeps } from './codex-structured-session-state' import { boundSubagentField } from './codex-subagent-group-body' +/** Where a session's child-work evidence goes, and the host clock that stamps it. */ +export type CodexChildWorkSink = { + deliver: (evidence: AgentChildWorkEvidence[]) => void + now: () => number +} + +export function codexChildWorkSink( + sessionId: string, + deps: Pick +): CodexChildWorkSink { + return { + deliver: (evidence) => deps.onChildWorkEvidence?.(sessionId, evidence), + now: () => deps.now?.() ?? Date.now() + } +} + /** Projects the same child execution facts the durable roster consumes. */ export class CodexBackgroundTaskTracker { private publishedFingerprint = '[]' private publishedState: AgentSessionBackgroundTaskState | null = null private readonly commands: CodexBackgroundCommandTracker + private readonly childWork: CodexChildWorkEvidence constructor( private readonly primaryThreadId: string, - private readonly executions = new CodexSubagentExecutions() + private readonly executions = new CodexSubagentExecutions(), + private readonly childWorkSink?: CodexChildWorkSink ) { this.commands = new CodexBackgroundCommandTracker(primaryThreadId) + this.childWork = new CodexChildWorkEvidence(primaryThreadId, executions, (threadId) => + this.commands.threadTasks(threadId) + ) } get state(): AgentSessionBackgroundTaskState | null { @@ -32,20 +57,38 @@ export class CodexBackgroundTaskTracker { return this.commands.canObserve(event) } - observe(event: CodexBackgroundTaskEvent): boolean { + /** `unapproved`: commands whose approval the journal dropped with this frame's turn ending. */ + observe( + event: CodexBackgroundTaskEvent, + unapproved: readonly CodexAbandonedCommand[] = [] + ): boolean { const itemEvent = event.method === 'item/started' || event.method === 'item/completed' - if (itemEvent) { - this.commands.observe(event) - } + const command = itemEvent ? this.commands.observe(event) : null + const commands = [ + ...unapproved.flatMap((abandoned) => this.commands.endUnapproved(abandoned) ?? []), + ...(event.method === 'thread/closed' + ? this.commands.endThread(event.threadId) + : command + ? [command] + : []) + ] const frame = readCodexBackgroundTaskFrame(event, this.primaryThreadId) - if (!frame) { - return itemEvent ? this.refresh() : false - } - if (frame.kind === 'subagent') { - this.executions.register(frame.agentThreadId, frame.label, frame.parentTurnId) - } else if (frame.threadId !== this.primaryThreadId) { + if (frame?.kind === 'subagent') { + this.executions.register( + frame.agentThreadId, + frame.label, + frame.parentTurnId, + frame.spawnerThreadId + ) + } else if (frame?.kind === 'thread-closed') { + this.executions.closeThread(frame.threadId) + } else if (frame && frame.threadId !== this.primaryThreadId) { this.executions.observeTurn(frame.threadId, frame.turnId, frame.state) } + this.childWork.observe(event, frame, commands) + if (!frame) { + return itemEvent || commands.length > 0 ? this.refresh() : false + } // A primary-turn frame only prompts a republish: turn end reveals children, // it never settles them. Codex `spawn_agent` children keep reporting well // past their parent turn, so nothing here may sweep the roster. @@ -54,10 +97,25 @@ export class CodexBackgroundTaskTracker { clear(): boolean { this.executions.clear() - this.commands.clear() + this.childWork.clear(this.commands.clear()) return this.refresh() } + /** Everything the frames observed since the last drain said about the session's child work. */ + drainChildWorkEvidence(observedAt: number): AgentChildWorkEvidence[] { + return this.childWork.drain(observedAt) + } + + /** Hand the pending evidence to the host. Callers run this after the journal wrote the frame + * and the parent's own row republished, so a child record never lands ahead of either. */ + publishChildWork(): void { + // Drained even with no sink, so undelivered evidence never accumulates. + const evidence = this.drainChildWorkEvidence(this.childWorkSink?.now() ?? Date.now()) + if (evidence.length > 0) { + this.childWorkSink?.deliver(evidence) + } + } + private tasks(): AgentSessionBackgroundTask[] { const children = this.executions.workingChildren() const agents: AgentSessionBackgroundTask[] = children.map((child, index) => ({ diff --git a/src/main/codex/codex-child-work-evidence.test.ts b/src/main/codex/codex-child-work-evidence.test.ts new file mode 100644 index 00000000000..5b1b1bc29b4 --- /dev/null +++ b/src/main/codex/codex-child-work-evidence.test.ts @@ -0,0 +1,617 @@ +import { describe, expect, it } from 'vitest' +import { createAgentChildWorkAdmission } from '../../shared/agent-status-child-work-admission' +import type { AgentChildWorkRecord } from '../../shared/agent-status-child-work' +import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' +import { reconcileAgentChildWorkEvidence } from '../../shared/agent-status-child-work-reconciliation' +import { + agentChildWorkOwnedLiveness, + deriveAgentChildDisplayState +} from '../../shared/agent-status-child-work-display' +import { projectAgentChildWorkViews } from '../../shared/agent-status-child-work-view' +import { createAgentStatusStore } from '../../shared/agent-status-store' +import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' +import type { CodexBackgroundTaskEvent } from './codex-background-task-frames' +import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' + +const PRIMARY = 'thread-parent' +const PARENT_TURN = 'turn-parent' +const CHILD = 'thread-child' +const parent = makeStructuredAgentStatusSubject( + { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + 'session-1' +) + +function turn( + method: 'turn/started' | 'turn/completed', + threadId: string, + turnId: string, + status = 'completed' +): CodexBackgroundTaskEvent { + return { method, threadId, params: { threadId, turn: { id: turnId, status } } } +} + +function spawned( + child = CHILD, + reporter = PRIMARY, + name = 'audit_build' +): CodexBackgroundTaskEvent { + return { + method: 'item/started', + threadId: reporter, + params: { + threadId: reporter, + turnId: PARENT_TURN, + item: { + type: 'subAgentActivity', + id: `activity-${child}`, + kind: 'started', + agentThreadId: child, + agentPath: `/root/${name}` + } + } + } +} + +function item( + method: 'item/started' | 'item/completed', + threadId: string, + turnId: string, + fields: Record +): CodexBackgroundTaskEvent { + return { method, threadId, params: { threadId, turnId, item: fields } } +} + +function shell(id: string, command: string, status = 'inProgress', source = 'agent') { + return { type: 'commandExecution', id, command, source, status } +} + +function harness() { + const tracker = new CodexBackgroundTaskTracker(PRIMARY) + const store = createAgentStatusStore({ epoch: 'epoch-1', mode: 'authority' }) + expect(store.applyMutation({ parent: { subject: parent } })).not.toBeNull() + let minted = 0 + const admission = createAgentChildWorkAdmission(store, { + mintChildWorkId: () => `child-${++minted}` + }) + let clock = 1_000 + const log: AgentChildWorkEvidence[][] = [] + const send = (...events: CodexBackgroundTaskEvent[]): void => { + for (const event of events) { + tracker.observe(event) + clock += 10 + const evidence = tracker.drainChildWorkEvidence(clock) + log.push(evidence) + reconcileAgentChildWorkEvidence({ store, admission, parent, provider: 'codex', evidence }) + } + } + const records = (): AgentChildWorkRecord[] => store.getChildren(parent) + const byKind = (kind: AgentChildWorkRecord['kind']) => + records().filter((record) => record.kind === kind) + const display = (childWorkId: string) => { + const children = records() + const views = projectAgentChildWorkViews( + children, + children.flatMap((child) => store.getAliasesForChild(child.childWorkId)) + ) + const view = views.find((candidate) => candidate.id === childWorkId) + return view && deriveAgentChildDisplayState(view, agentChildWorkOwnedLiveness(views, view.id)) + } + return { tracker, store, send, records, byKind, display, log } +} + +/** A child spawned by the parent turn and running its first turn. */ +function runningChild() { + const run = harness() + run.send(turn('turn/started', PRIMARY, PARENT_TURN), spawned(), turn('turn/started', CHILD, 'c1')) + return run +} + +describe('Codex child-work evidence', () => { + it('records a spawned child by its thread, with its turn as the run', () => { + const { records, store, log, send } = runningChild() + // Codex delivers the announcement a second time, on `item/completed`. + send({ ...spawned(), method: 'item/completed' }) + expect(records()).toEqual([ + expect.objectContaining({ + kind: 'agent', + membership: 'live', + state: 'working', + residency: 'background', + description: 'audit_build', + invocation: { invocationId: 'c1', generation: 1 }, + stoppable: false + }) + ]) + const aliases = store.getAliasesForChild(records()[0]!.childWorkId) + expect(aliases.map(({ aliasKind, alias }) => [aliasKind, alias])).toEqual([ + ['thread_id', CHILD], + ['turn_id', 'c1'] + ]) + // The host hears the child once. + expect(log.flat().filter((edge) => edge.type === 'live')).toHaveLength(1) + }) + + it('makes no record for a child whose turn began before its announcement, until it lands', () => { + const { send, records } = harness() + send(turn('turn/started', PRIMARY, PARENT_TURN), turn('turn/started', CHILD, 'c1')) + expect(records()).toEqual([]) + send(spawned()) + expect(records()).toEqual([expect.objectContaining({ membership: 'live', state: 'working' })]) + }) + + it.each([ + ['completed', 'succeeded'], + ['interrupted', 'cancelled'], + ['failed', 'failed'], + ['somethingNew', 'unknown'] + ])('settles a child whose own turn ended %s as %s', (status, outcome) => { + const { send, tracker, records } = runningChild() + send(turn('turn/completed', CHILD, 'c1', status)) + expect(records()).toEqual([ + expect.objectContaining({ membership: 'settled', state: 'done', outcome }) + ]) + // Today's strip drops the child the moment its turn ends; only the record keeps its ending. + expect(tracker.state).toBeNull() + }) + + const childError = (turnId: string, willRetry: boolean): CodexBackgroundTaskEvent => ({ + method: 'error', + threadId: CHILD, + params: { threadId: CHILD, turnId, willRetry, error: { message: 'boom' } } + }) + const childClosed: CodexBackgroundTaskEvent = { + method: 'thread/closed', + threadId: CHILD, + params: { threadId: CHILD } + } + + it('keeps a child working through an error Codex will not retry, and settles it on the failed turn/completed that follows', () => { + const { send, tracker, records } = runningChild() + send(childError('c1', false)) + expect(records()).toEqual([expect.objectContaining({ membership: 'live' })]) + expect(tracker.state?.tasks).toHaveLength(1) + send(turn('turn/completed', CHILD, 'c1', 'failed')) + expect(records()).toEqual([ + expect.objectContaining({ membership: 'settled', state: 'done', outcome: 'failed' }) + ]) + expect(tracker.state).toBeNull() + }) + + it('settles a working child whose thread closed with no turn/completed, in the strip and the record together', () => { + const { send, tracker, records } = runningChild() + expect(tracker.state?.tasks).toHaveLength(1) + send(childClosed) + expect(records()).toEqual([ + expect.objectContaining({ membership: 'settled', state: 'done', outcome: 'unknown' }) + ]) + expect(tracker.state).toBeNull() + // The first ending a turn gets stands. + send(turn('turn/completed', CHILD, 'c1', 'completed')) + expect(records()).toEqual([expect.objectContaining({ outcome: 'unknown' })]) + }) + + it('keeps a child working through a retried error and a systemError status: its turn runs on', () => { + const { send, tracker, records } = runningChild() + send(childError('c1', true), { + method: 'thread/status/changed', + threadId: CHILD, + params: { threadId: CHILD, status: { type: 'systemError' } } + }) + expect(records()).toEqual([expect.objectContaining({ membership: 'live', state: 'working' })]) + expect(tracker.state?.tasks).toHaveLength(1) + }) + + it('never settles a child on its PARENT turn ending: children outlive the turn', () => { + const { send, records } = runningChild() + send(turn('turn/completed', PRIMARY, PARENT_TURN)) + expect(records()).toEqual([expect.objectContaining({ membership: 'live', state: 'working' })]) + }) + + it('reopens the same record for a follow-up turn on a finished child, as a new run', () => { + const { send, records } = runningChild() + send(turn('turn/completed', CHILD, 'c1')) + const [finished] = records() + send(turn('turn/started', CHILD, 'c2')) + expect(records()).toEqual([ + expect.objectContaining({ + childWorkId: finished!.childWorkId, + membership: 'live', + state: 'working', + invocation: { invocationId: 'c2', generation: 2 }, + previousInvocations: [ + expect.objectContaining({ + fence: { invocationId: 'c1', generation: 1 }, + outcome: 'succeeded' + }) + ] + }) + ]) + // A late ending of the first run neither ends nor restarts the second. + send(turn('turn/completed', CHILD, 'c1', 'failed')) + expect(records()).toEqual([ + expect.objectContaining({ + membership: 'live', + invocation: { invocationId: 'c2', generation: 2 } + }) + ]) + send(turn('turn/completed', CHILD, 'c2', 'interrupted')) + expect(records()).toEqual([ + expect.objectContaining({ + childWorkId: finished!.childWorkId, + membership: 'settled', + outcome: 'cancelled' + }) + ]) + }) + + it('says which tool the child has open, the way a CLI row names a Codex shell', () => { + const { send, byKind } = runningChild() + send(item('item/started', CHILD, 'c1', shell('cmd-1', 'npm test'))) + expect(byKind('agent')[0]?.operation).toEqual({ + toolName: 'Bash', + input: 'npm test', + basis: 'open', + observedAt: 1_040 + }) + send( + item('item/started', CHILD, 'c1', { + type: 'mcpToolCall', + id: 'mcp-1', + server: 'github', + tool: 'search_issues', + arguments: { query: 'flaky' }, + status: 'inProgress' + }) + ) + expect(byKind('agent')[0]?.operation).toMatchObject({ + toolName: 'mcp__github__search_issues', + input: 'flaky' + }) + // The newer call ends first: the child is still running the older one, since it opened. + send( + item('item/completed', CHILD, 'c1', { type: 'mcpToolCall', id: 'mcp-1', status: 'completed' }) + ) + expect(byKind('agent')[0]?.operation).toEqual({ + toolName: 'Bash', + input: 'npm test', + basis: 'open', + observedAt: 1_040 + }) + send(item('item/completed', CHILD, 'c1', shell('cmd-1', 'npm test', 'completed'))) + expect(byKind('agent')[0]?.operation).toBeUndefined() + }) + + it('names a unified-exec shell as the open call until its process exits', () => { + const { send, byKind } = runningChild() + // Codex runs every agent shell through unified exec, not only the ones that outlive a turn. + send( + item( + 'item/started', + CHILD, + 'c1', + shell('exec-1', 'npm test', 'inProgress', 'unifiedExecStartup') + ) + ) + expect(byKind('agent')[0]?.operation).toMatchObject({ toolName: 'Bash', input: 'npm test' }) + send( + item( + 'item/completed', + CHILD, + 'c1', + shell('exec-1', 'npm test', 'completed', 'unifiedExecStartup') + ) + ) + expect(byKind('agent')[0]?.operation).toBeUndefined() + // An approved command starts on the approval path and completes from unified exec. + send(item('item/started', CHILD, 'c1', shell('exec-2', 'touch ~/marker'))) + expect(byKind('agent')[0]?.operation).toMatchObject({ + toolName: 'Bash', + input: 'touch ~/marker' + }) + send( + item( + 'item/completed', + CHILD, + 'c1', + shell('exec-2', 'touch ~/marker', 'completed', 'unifiedExecStartup') + ) + ) + expect(byKind('agent')[0]?.operation).toBeUndefined() + }) + + it("never carries a run's open call into the next run when its ending was lost", () => { + const { send, byKind } = runningChild() + send(item('item/started', CHILD, 'c1', shell('cmd-1', 'npm test'))) + send(turn('turn/started', CHILD, 'c2')) + expect(byKind('agent')[0]).toMatchObject({ invocation: { invocationId: 'c2', generation: 2 } }) + expect(byKind('agent')[0]?.operation).toBeUndefined() + }) + + it('keeps what the child said last, and its usage, through to how it ended', () => { + const { send, byKind } = runningChild() + send( + item('item/completed', CHILD, 'c1', { + type: 'agentMessage', + id: 'msg-1', + text: 'Two tests\nflake on CI' + }), + { + method: 'thread/tokenUsage/updated', + threadId: CHILD, + params: { threadId: CHILD, tokenUsage: { total: { totalTokens: 4_200 } } } + } + ) + expect(byKind('agent')[0]).toMatchObject({ + lastMessage: 'Two tests flake on CI', + totalTokens: 4_200 + }) + send(turn('turn/completed', CHILD, 'c1')) + expect(byKind('agent')[0]).toMatchObject({ + membership: 'settled', + outcome: 'succeeded', + lastMessage: 'Two tests flake on CI', + totalTokens: 4_200 + }) + // A new run has said nothing yet. + send(turn('turn/started', CHILD, 'c2')) + expect(byKind('agent')[0]).not.toHaveProperty('lastMessage') + }) + + it('files a message whose frame names no turn under the run that said it, never the next', () => { + const { send, byKind } = runningChild() + send({ + method: 'item/completed', + threadId: CHILD, + params: { threadId: CHILD, item: { type: 'agentMessage', id: 'msg-1', text: 'Done' } } + }) + expect(byKind('agent')[0]?.lastMessage).toBe('Done') + send(turn('turn/completed', CHILD, 'c1'), turn('turn/started', CHILD, 'c2')) + send(turn('turn/completed', CHILD, 'c2')) + expect(byKind('agent')[0]).toMatchObject({ outcome: 'succeeded' }) + expect(byKind('agent')[0]).not.toHaveProperty('lastMessage') + }) + + it('reads a child waiting on the user from its own thread status', () => { + const { send, byKind } = runningChild() + const status = (status: unknown): CodexBackgroundTaskEvent => ({ + method: 'thread/status/changed', + threadId: CHILD, + params: { threadId: CHILD, status } + }) + send(status({ type: 'active', activeFlags: ['waitingOnApproval'] })) + expect(byKind('agent')[0]?.state).toBe('waiting') + send(status({ type: 'active', activeFlags: [] })) + expect(byKind('agent')[0]?.state).toBe('working') + send(status({ type: 'active', activeFlags: ['waitingOnUserInput'] })) + send(turn('turn/completed', CHILD, 'c1')) + send(turn('turn/started', CHILD, 'c2')) + // The wait ended with the turn that asked. + expect(byKind('agent')[0]?.state).toBe('working') + }) + + it('records a command from its start until its process exits, then removes it', () => { + const { send, byKind, display } = runningChild() + send( + item( + 'item/started', + CHILD, + 'c1', + shell('exec-1', 'npm run dev', 'inProgress', 'unifiedExecStartup') + ) + ) + const [agent] = byKind('agent') + // While the child's turn runs, the command is also the tool it has open. + expect(agent?.operation).toMatchObject({ toolName: 'Bash', input: 'npm run dev' }) + expect(byKind('command')).toEqual([ + expect.objectContaining({ + membership: 'live', + description: 'npm run dev', + residency: 'background', + parentChildWorkId: agent!.childWorkId, + firstObservedAt: 1_040 + }) + ]) + send(turn('turn/completed', CHILD, 'c1')) + expect(byKind('agent')[0]).toMatchObject({ membership: 'settled', outcome: 'succeeded' }) + expect(byKind('command')).toEqual([expect.objectContaining({ membership: 'live' })]) + expect(display(agent!.childWorkId)).toBe('monitoring') + send( + item('item/completed', CHILD, 'c1', { + ...shell('exec-1', 'npm run dev', 'completed', 'unifiedExecStartup'), + exitCode: 1 + }) + ) + expect(byKind('command')).toEqual([]) + expect(display(agent!.childWorkId)).toBe('done') + }) + + it('records an approved command while it runs, whatever source Codex starts it with', () => { + const { send, tracker, byKind, display } = runningChild() + // The approval path starts the item as `agent`; unified exec reports its exit. + send(item('item/started', CHILD, 'c1', shell('exec-2', 'npm run dev'))) + const [agent] = byKind('agent') + expect(byKind('command')).toEqual([ + expect.objectContaining({ membership: 'live', parentChildWorkId: agent!.childWorkId }) + ]) + send(turn('turn/completed', CHILD, 'c1')) + expect(display(agent!.childWorkId)).toBe('monitoring') + expect(tracker.state?.tasks).toEqual([expect.objectContaining({ kind: 'command' })]) + send( + item( + 'item/completed', + CHILD, + 'c1', + shell('exec-2', 'npm run dev', 'completed', 'unifiedExecStartup') + ) + ) + expect(byKind('command')).toEqual([]) + expect(display(agent!.childWorkId)).toBe('done') + expect(tracker.state).toBeNull() + }) + + it("removes a closed thread's running commands: Codex stops them and never reports their exit", () => { + const { send, tracker, byKind, display } = runningChild() + send( + item( + 'item/started', + CHILD, + 'c1', + shell('exec-1', 'npm run dev', 'inProgress', 'unifiedExecStartup') + ), + turn('turn/completed', CHILD, 'c1'), + turn('turn/completed', PRIMARY, PARENT_TURN) + ) + const [agent] = byKind('agent') + expect(display(agent!.childWorkId)).toBe('monitoring') + send({ method: 'thread/closed', threadId: CHILD, params: { threadId: CHILD } }) + expect(byKind('command')).toEqual([]) + expect(display(agent!.childWorkId)).toBe('done') + expect(tracker.state).toBeNull() + // The exit Codex could not deliver starts nothing if it ever arrives. + send( + item( + 'item/completed', + CHILD, + 'c1', + shell('exec-1', 'npm run dev', 'completed', 'unifiedExecStartup') + ) + ) + expect(byKind('command')).toEqual([]) + }) + + it("names the owner of a command launched before the host held its child's record", () => { + const { send, byKind } = harness() + send( + turn('turn/started', PRIMARY, PARENT_TURN), + turn('turn/started', CHILD, 'c1'), + item( + 'item/started', + CHILD, + 'c1', + shell('exec-1', 'tail -f log', 'inProgress', 'unifiedExecStartup') + ) + ) + expect(byKind('command')).toEqual([ + expect.not.objectContaining({ parentChildWorkId: expect.anything() }) + ]) + send(spawned()) + expect(byKind('command')).toEqual([ + expect.objectContaining({ + description: 'tail -f log', + parentChildWorkId: byKind('agent')[0]?.childWorkId + }) + ]) + }) + + it("records the session's own command with no owner from its start", () => { + const { send, byKind } = harness() + send( + turn('turn/started', PRIMARY, PARENT_TURN), + item( + 'item/started', + PRIMARY, + PARENT_TURN, + shell('exec-9', 'sleep 90', 'inProgress', 'unifiedExecStartup') + ) + ) + expect(byKind('command')).toEqual([ + expect.objectContaining({ membership: 'live', description: 'sleep 90' }) + ]) + expect(byKind('command')[0]).not.toHaveProperty('parentChildWorkId') + send(turn('turn/completed', PRIMARY, PARENT_TURN)) + expect(byKind('command')).toEqual([expect.objectContaining({ membership: 'live' })]) + }) + + it('leaves no record behind a finished shell, so none displaces a finished child', () => { + const { send, byKind, records, log } = runningChild() + send(turn('turn/completed', CHILD, 'c1')) + const before = log.length + // Codex runs every shell, however short, the way it runs one left running. + for (let index = 0; index < 40; index += 1) { + const id = `exec-${index}` + send( + item('item/started', PRIMARY, PARENT_TURN, { + ...shell(id, 'rg foo', 'inProgress', 'unifiedExecStartup'), + durationMs: 0 + }) + ) + expect(byKind('command')).toEqual([expect.objectContaining({ description: 'rg foo' })]) + send( + item('item/completed', PRIMARY, PARENT_TURN, { + ...shell(id, 'rg foo', 'completed', 'unifiedExecStartup'), + exitCode: 0 + }) + ) + expect(byKind('command')).toEqual([]) + } + send(turn('turn/completed', PRIMARY, PARENT_TURN)) + expect(records()).toEqual([ + expect.objectContaining({ kind: 'agent', membership: 'settled', outcome: 'succeeded' }) + ]) + // One edge when each shell starts and one when it exits, as the strip republishes today. + expect( + log + .slice(before) + .flat() + .map((edge) => edge.type) + ).toEqual(Array.from({ length: 40 }, () => ['live', 'removed']).flat()) + }) + + it('names the child that spawned a nested child as its owner', () => { + const { send, byKind } = runningChild() + send( + spawned('thread-grandchild', CHILD, 'lint'), + turn('turn/started', 'thread-grandchild', 'g1') + ) + const nested = byKind('agent').find((record) => record.description === 'lint') + const owner = byKind('agent').find((record) => record.description === 'audit_build') + expect(nested?.parentChildWorkId).toBe(owner?.childWorkId) + }) + + it('holds no evidence for a session with nowhere to deliver it', () => { + const tracker = new CodexBackgroundTaskTracker(PRIMARY) + tracker.observe(spawned()) + tracker.observe(turn('turn/started', CHILD, 'c1')) + tracker.publishChildWork() + expect(tracker.drainChildWorkEvidence(1)).toEqual([]) + }) + + it('settles every live child with no reported outcome, and removes every command, when the provider session ends', () => { + const { send, tracker, records, store } = runningChild() + send( + item( + 'item/started', + PRIMARY, + PARENT_TURN, + shell('exec-1', 'npm run dev', 'inProgress', 'unifiedExecStartup') + ), + turn('turn/completed', PRIMARY, PARENT_TURN) + ) + expect(records()).toHaveLength(2) + tracker.clear() + const evidence = tracker.drainChildWorkEvidence(9_000) + expect(evidence).toEqual([ + { + type: 'removed', + observedAt: 9_000, + handle: { idKind: 'task_id', id: 'codex-command:primary:exec-1' } + }, + { type: 'session-ended', observedAt: 9_000 } + ]) + reconcileAgentChildWorkEvidence({ + store, + admission: createAgentChildWorkAdmission(store, { mintChildWorkId: () => 'unused' }), + parent, + provider: 'codex', + evidence + }) + expect(records()).toEqual([ + expect.objectContaining({ kind: 'agent', membership: 'settled', outcome: 'unknown' }) + ]) + }) +}) diff --git a/src/main/codex/codex-child-work-evidence.ts b/src/main/codex/codex-child-work-evidence.ts new file mode 100644 index 00000000000..93bda7a54a1 --- /dev/null +++ b/src/main/codex/codex-child-work-evidence.ts @@ -0,0 +1,312 @@ +// Codex child threads, and the commands they leave running, decoded into child-work evidence for +// the host's records. +// +// The background-task tracker already follows which child exists, which turn it runs and how that +// turn ended (the executions), and which command process is still running (the command tracker). +// A command is a record from its process start until it stops, and then its record goes: the +// tracker says when, and this module only mirrors it. This module keeps what only the records +// read — the tool a child has open, what it said last, its usage, whether it waits on the user — +// and after each frame re-derives the whole observation of the child that frame was about. Edges +// are stamped with the host clock when drained, after the journal handled the frame, so the host +// never holds a record ahead of the frame's own rows. A parent turn ending is never evidence here: +// Codex children outlive the turn that spawned them, so only a child's own turn, or the session, +// ends it. + +import type { AgentSessionBackgroundTask } from '../../shared/agent-session-wire' +import type { + AgentChildWorkEvidence, + AgentChildWorkLiveObservation +} from '../../shared/agent-status-child-work-evidence' +import type { CodexBackgroundCommandChange } from './codex-background-command-tracker' +import type { + CodexBackgroundTaskEvent, + CodexBackgroundTaskFrame +} from './codex-background-task-frames' +import { + codexChildMessageText, + codexChildToolCall, + codexChildTurnOutcome, + codexToolCallEnded, + type CodexChildToolCall +} from './codex-child-work-translation' +import { readRecord } from './codex-item-field-readers' +import { readCodexThreadItem } from './codex-structured-item-translation' +import { codexThreadWaitsOnUser, readCodexTurnId } from './codex-structured-thread-facts' +import { CODEX_TOKEN_USAGE_METHOD, readCodexThreadTokenTotal } from './codex-subagent-activity' +import type { CodexExecutionChild, CodexSubagentExecutions } from './codex-subagent-executions' + +/** The executions' own child bound. */ +const MAX_CHILD_FACTS = 128 +const MAX_OPEN_CALLS_PER_CHILD = 16 +const CHILD_FRAME_METHODS: ReadonlySet = new Set([ + 'item/started', + 'item/completed', + 'thread/status/changed', + CODEX_TOKEN_USAGE_METHOD +]) + +/** A tool call a child has started and not finished. `openedAt` is the host clock of the first + * drain that carried it, so a later edge keeps the time the call opened. */ +type OpenCall = CodexChildToolCall & { turnId: string | null; openedAt?: number } + +type ChildFacts = { + openCalls: Map + lastMessage?: { turnId: string | null; text: string } + totalTokens?: number + waiting: boolean + /** The last observation handed to the host, so an unchanged re-derivation sends nothing. */ + published?: string +} + +export type CodexPendingChildWork = (observedAt: number) => AgentChildWorkEvidence + +/** Evidence from a run only counts for that run: a fact recorded under another turn is stale. */ +function ofTurn(fact: T | undefined, turnId: string) { + return fact?.turnId === turnId ? fact : undefined +} + +function commandLive(task: AgentSessionBackgroundTask, ownerId: string | null) { + return (observedAt: number): AgentChildWorkEvidence => ({ + type: 'live', + observedAt, + child: { + handle: { idKind: 'task_id', id: task.id }, + kind: 'command', + // Its own process, not a turn's: no turn ending may settle it. + residency: 'background', + state: 'working', + ...(task.description ? { description: task.description } : {}), + ...(ownerId !== null ? { ownerId } : {}), + stoppable: false + } + }) +} + +export class CodexChildWorkEvidence { + private readonly facts = new Map() + private pending: CodexPendingChildWork[] = [] + + constructor( + private readonly primaryThreadId: string, + private readonly executions: CodexSubagentExecutions, + private readonly liveCommands: (threadId: string) => readonly AgentSessionBackgroundTask[] + ) {} + + /** After the tracker applied the frame: which command processes it saw start or stop, and the + * child the frame is about. */ + observe( + event: CodexBackgroundTaskEvent, + frame: CodexBackgroundTaskFrame | null, + commands: readonly CodexBackgroundCommandChange[] + ): void { + this.queueCommands(commands) + const threadId = this.childThread(event, frame) + if (threadId === null) { + return + } + const facts = this.factsFor(threadId) + if (facts && event.threadId === threadId) { + this.record(facts, event) + } + this.queueChild(threadId) + } + + /** The provider session is gone, with the commands it ended: no child it still ran can report + * its own ending. */ + clear(commands: readonly CodexBackgroundCommandChange[]): void { + this.facts.clear() + this.queueCommands(commands) + this.pending.push((observedAt) => ({ type: 'session-ended', observedAt })) + } + + drain(observedAt: number): AgentChildWorkEvidence[] { + const pending = this.pending + this.pending = [] + return pending.map((edge) => edge(observedAt)) + } + + private childThread( + event: CodexBackgroundTaskEvent, + frame: CodexBackgroundTaskFrame | null + ): string | null { + const threadId = + frame?.kind === 'subagent' + ? frame.agentThreadId + : frame || CHILD_FRAME_METHODS.has(event.method) + ? event.threadId + : null + return threadId === this.primaryThreadId ? null : threadId + } + + /** A command belongs to the child thread that launched it; the session's own agent is no owner. + * A stopped command leaves no record: it has nothing left to report. */ + private queueCommands(commands: readonly CodexBackgroundCommandChange[]): void { + for (const command of commands) { + if (command.type === 'started') { + const ownerId = command.threadId === this.primaryThreadId ? null : command.threadId + this.pending.push(commandLive(command.task, ownerId)) + continue + } + const { taskId } = command + this.pending.push((observedAt) => ({ + type: 'removed', + observedAt, + handle: { idKind: 'task_id', id: taskId } + })) + } + } + + private record(facts: ChildFacts, event: CodexBackgroundTaskEvent): void { + if (event.method === CODEX_TOKEN_USAGE_METHOD) { + facts.totalTokens = readCodexThreadTokenTotal(event.params)?.totalTokens ?? facts.totalTokens + return + } + if (event.method === 'thread/status/changed') { + facts.waiting = codexThreadWaitsOnUser(event.params) + return + } + // Every Codex agent shell is unified exec: it is the open call until its process exits. + const item = readCodexThreadItem(readRecord(event.params).item) + if (!item) { + return + } + // A frame that names no turn belongs to the one the child is running. + const turnId = + readCodexTurnId(event.params) ?? + this.executions.find(event.threadId)?.execution?.turnId ?? + null + const text = event.method === 'item/completed' ? codexChildMessageText(item) : undefined + if (text) { + facts.lastMessage = { turnId, text } + } + // An end closes the call by id alone: its closing frame need not restate what it ran. + if (codexToolCallEnded(event.method, item)) { + facts.openCalls.delete(item.id) + return + } + const call = codexChildToolCall(item) + if (call && !facts.openCalls.has(item.id)) { + facts.openCalls.set(item.id, { ...call, turnId }) + for (const stale of [...facts.openCalls.keys()].slice(0, -MAX_OPEN_CALLS_PER_CHILD)) { + facts.openCalls.delete(stale) + } + } + } + + /** Re-derive the child's observation and hand it on when it changed. A child the provider has + * not announced, or that never ran a turn, is no record. */ + private queueChild(threadId: string): void { + const child = this.executions.find(threadId) + const facts = this.facts.get(threadId) + if (!child?.registered || !child.execution || !facts) { + return + } + const { turnId, state } = child.execution + if (state !== 'working') { + // The turn is over, and so is every call it had open. + facts.openCalls.clear() + facts.waiting = false + const lastMessage = ofTurn(facts.lastMessage, turnId)?.text + const { totalTokens } = facts + const outcome = codexChildTurnOutcome(state) + this.publish(facts, JSON.stringify(['ended', turnId, state]), (observedAt) => ({ + type: 'ended', + observedAt, + handle: { idKind: 'thread_id', id: threadId, runId: turnId }, + outcome, + ...(lastMessage ? { lastMessage } : {}), + ...(totalTokens !== undefined ? { totalTokens } : {}) + })) + return + } + for (const [itemId, call] of facts.openCalls) { + if (!ofTurn(call, turnId)) { + facts.openCalls.delete(itemId) + } + } + const observation = this.liveAgent(threadId, child, facts, turnId) + const openCall = [...facts.openCalls].at(-1) + const announced = facts.published !== undefined + this.publish(facts, JSON.stringify(['live', observation, openCall?.[0]]), (observedAt) => { + if (!openCall) { + return { type: 'live', observedAt, child: { ...observation, operation: null } } + } + const [, call] = openCall + call.openedAt ??= observedAt + const operation = { + toolName: call.toolName, + ...(call.input ? { input: call.input } : {}), + basis: 'open' as const, + observedAt: call.openedAt + } + return { type: 'live', observedAt, child: { ...observation, operation } } + }) + if (!announced) { + this.requeueOwnedBy(threadId) + } + } + + private liveAgent( + threadId: string, + child: Readonly, + facts: ChildFacts, + turnId: string + ): AgentChildWorkLiveObservation { + const lastMessage = ofTurn(facts.lastMessage, turnId)?.text + const spawner = child.spawnerThreadId + return { + handle: { idKind: 'thread_id', id: threadId, runId: turnId }, + kind: 'agent', + // A spawned child may outlive the turn that spawned it. + residency: 'background', + state: facts.waiting ? 'waiting' : 'working', + // The agent path's last segment is the child's only label; today's row shows it there. + ...(child.label ? { description: child.label } : {}), + ...(facts.totalTokens !== undefined ? { totalTokens: facts.totalTokens } : {}), + ...(lastMessage ? { lastMessage } : {}), + ...(spawner && spawner !== this.primaryThreadId ? { ownerId: spawner } : {}), + stoppable: false + } + } + + private publish(facts: ChildFacts, fingerprint: string, edge: CodexPendingChildWork): void { + if (facts.published !== fingerprint) { + facts.published = fingerprint + this.pending.push(edge) + } + } + + /** Work a child launched before the host held its record was admitted with no owner; now that + * the owner is recorded, say again whose it is. */ + private requeueOwnedBy(threadId: string): void { + for (const task of this.liveCommands(threadId)) { + this.pending.push(commandLive(task, threadId)) + } + for (const spawned of this.executions.workingChildren()) { + const facts = this.facts.get(spawned.agentThreadId) + if (spawned.spawnerThreadId === threadId && facts?.published !== undefined) { + facts.published = undefined + this.queueChild(spawned.agentThreadId) + } + } + } + + private factsFor(threadId: string): ChildFacts | undefined { + const existing = this.facts.get(threadId) + if (existing) { + return existing + } + if (this.facts.size >= MAX_CHILD_FACTS) { + const idle = [...this.facts.keys()].find( + (id) => this.executions.find(id)?.execution?.state !== 'working' + ) + if (idle === undefined) { + return undefined + } + this.facts.delete(idle) + } + const facts: ChildFacts = { openCalls: new Map(), waiting: false } + this.facts.set(threadId, facts) + return facts + } +} diff --git a/src/main/codex/codex-child-work-translation.ts b/src/main/codex/codex-child-work-translation.ts new file mode 100644 index 00000000000..2763ac53b1c --- /dev/null +++ b/src/main/codex/codex-child-work-translation.ts @@ -0,0 +1,88 @@ +// Codex items and statuses, read in the child-work vocabulary. +// +// A tool is named the way Codex names it to its own hooks (`Bash`, `apply_patch`, +// `mcp__server__tool`), so a structured Codex child running a shell reads exactly as a Codex +// CLI agent running one does. + +import type { AgentChildWorkOutcome } from '../../shared/agent-status-child-work' +import type { NativeChatSubagentState } from '../../shared/native-chat-types' +import { + deriveFallbackToolInputPreview, + deriveToolInputPreview +} from '../../shared/agent-hook-listener/tool-input-preview' +import { readRecord, readString, readTextContent } from './codex-item-field-readers' +import type { CodexThreadItem } from './codex-structured-item-translation' + +/** Raw provider text kept for a record; admission folds it to its own one-line bound. */ +export const CODEX_CHILD_WORK_TEXT_MAX_CHARS = 2_048 + +export type CodexChildToolCall = { toolName: string; input?: string } + +function bounded(text: string | null | undefined): string | undefined { + return text ? text.slice(0, CODEX_CHILD_WORK_TEXT_MAX_CHARS) : undefined +} + +function withInput(toolName: string, input: string | undefined): CodexChildToolCall { + const preview = bounded(input) + return preview ? { toolName, input: preview } : { toolName } +} + +function firstChangePath(changes: unknown): string | undefined { + const [first] = Array.isArray(changes) ? changes : [] + return readString(readRecord(first), 'path') ?? undefined +} + +/** The tool a thread item runs, or null for an item that is not a tool call (a message, a + * thought, a plan). */ +export function codexChildToolCall(item: CodexThreadItem): CodexChildToolCall | null { + switch (item.type) { + case 'commandExecution': + return withInput('Bash', deriveToolInputPreview('Bash', { command: item.command })) + case 'fileChange': + return withInput('apply_patch', firstChangePath(item.changes)) + case 'mcpToolCall': { + const server = readString(item, 'server') + const tool = readString(item, 'tool') + if (!tool) { + return null + } + return withInput( + server ? `mcp__${server}__${tool}` : tool, + deriveFallbackToolInputPreview(item.arguments) + ) + } + case 'webSearch': + return withInput('web_search', readString(item, 'query') ?? undefined) + default: + return null + } +} + +/** Whether an item frame says the call is over, whatever frame carried it. */ +export function codexToolCallEnded(method: string, item: CodexThreadItem): boolean { + const status = readString(item, 'status') + return method === 'item/completed' || (status !== null && status !== 'inProgress') +} + +/** What a child said: an assistant message's text. */ +export function codexChildMessageText(item: CodexThreadItem): string | undefined { + return item.type === 'agentMessage' + ? bounded(readString(item, 'text') ?? readTextContent(item, 'content')) + : undefined +} + +/** A child turn's ending. Codex states three; anything else is an ending nobody classified. */ +export function codexChildTurnOutcome(state: NativeChatSubagentState): AgentChildWorkOutcome { + switch (state) { + case 'completed': + return 'succeeded' + case 'failed': + return 'failed' + case 'stopped': + return 'cancelled' + case 'unverifiable': + case 'working': + case 'idle': + return 'unknown' + } +} diff --git a/src/main/codex/codex-command-turn-claim.test.ts b/src/main/codex/codex-command-turn-claim.test.ts new file mode 100644 index 00000000000..078060497f2 --- /dev/null +++ b/src/main/codex/codex-command-turn-claim.test.ts @@ -0,0 +1,375 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionRevisionJournal, + StructuredAgentSessionSinkAdmission +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' + +const SESSION = 'session-1' +const THREAD = 'thread-1' +const PROVIDER_TURN = 'compact-turn' +const COMMAND_TURN_IDENTITY: AgentJournalItemIdentity = { + provider: 'orca', + clientMessageId: 'command-turn:cmd-1' +} +const COMMAND_TURN_KEY = agentJournalItemKey(COMMAND_TURN_IDENTITY) +const RUNNING = { + kind: 'turn' as const, + turnId: 'compact:cmd-1', + state: 'running' as const, + userItemId: 'orca:submission:cmd-1', + requestedAt: 1, + startedAt: 1 +} +const COMMAND = { + clientMessageId: 'cmd-1', + turnId: RUNNING.turnId, + identity: COMMAND_TURN_IDENTITY, + resultIdentity: { provider: 'orca' as const, clientMessageId: 'command-result:cmd-1' }, + running: RUNNING +} +const ACCEPTED: StructuredAgentSessionSinkAdmission = { accepted: true } + +type Written = { key: string; body: AgentJournalItemBody; turnScope?: AgentJournalTurnScope } + +/** Records every write with the scope it states; the journal holds the running command turn. */ +function recorder(refuseRevisions = 0) { + const writes: Written[] = [] + const record = ( + identity: AgentJournalItemIdentity, + body: AgentJournalItemBody, + turnScope?: AgentJournalTurnScope + ) => writes.push({ key: agentJournalItemKey(identity), body, turnScope }) + const commandTurn: AgentJournalItemBody = RUNNING + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the claim resolver reads only `itemBody`. + const journal = { + itemBody: (itemId: string) => (itemId === COMMAND_TURN_KEY ? commandTurn : null) + } as unknown as StructuredAgentSessionRevisionJournal + let refusals = refuseRevisions + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body, options) => record(identity, body, options.turnScope), + appendTombstone: () => {}, + publish: () => {}, + tryPublish: () => ACCEPTED, + tryAppendItem: (identity, body, options) => { + record(identity, body, options.turnScope) + return ACCEPTED + }, + appendLifecycleBatch: (_settlementId, mutations) => { + for (const mutation of mutations) { + if (mutation.kind === 'item') { + record(mutation.identity, mutation.body, mutation.turnScope) + } + } + }, + tryReviseResolvedItemAndPublish: (_bytes, resolve, options) => { + if (refusals > 0) { + refusals -= 1 + return { accepted: false, reason: 'backpressure' } + } + const resolved = resolve(journal) + if (resolved) { + record(resolved.identity, resolved.body, options.turnScope) + } + return ACCEPTED + } + } + return { sink, writes } +} + +/** Codex names the thread inside every notification's params too. */ +function notification( + method: string, + params: Record +): CodexStructuredSessionEvent { + return { + type: 'notification', + sessionId: SESSION, + threadId: THREAD, + method, + params: { threadId: THREAD, ...params } + } +} + +function harness(refuseRevisions = 0) { + const tap = recorder(refuseRevisions) + const translator = createCodexJournalTranslator({ + sink: tap.sink, + sessionId: SESSION, + primaryThreadId: () => THREAD + }) + return { + ...tap, + translator, + emit: (event: CodexStructuredSessionEvent) => translator.handle(event) + } +} + +const codexTurnRecords = (writes: readonly Written[]) => + writes.filter((write) => write.key !== COMMAND_TURN_KEY && readAgentJournalTurn(write.body)) + +/** The command turn's newest body, as the journal would hold it. */ +const commandTurn = (writes: readonly Written[]) => + readAgentJournalTurn(writes.findLast((write) => write.key === COMMAND_TURN_KEY)?.body) + +const resultRows = (writes: readonly Written[]) => + writes.filter((write) => write.body.kind === 'status') + +describe('a Codex turn a conversation command claims', () => { + it('is the command turn: writes no root turn, scopes its content there, and ends it', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + // The claim is persisted on the command turn: nothing else re-derives it later. + expect(commandTurn(writes)).toMatchObject({ state: 'running', providerTurnId: PROVIDER_TURN }) + emit( + notification('item/completed', { + turnId: PROVIDER_TURN, + item: { type: 'agentMessage', id: 'summary', text: 'Summary of the conversation.' } + }) + ) + emit( + notification('item/completed', { + turnId: PROVIDER_TURN, + item: { type: 'contextCompaction', id: 'compaction' } + }) + ) + emit(notification('turn/completed', { turn: { id: PROVIDER_TURN, status: 'completed' } })) + + expect(codexTurnRecords(writes)).toEqual([]) + const scope = { kind: 'turn', turnItemId: COMMAND_TURN_KEY } + const summary = writes.find( + (write) => write.body.kind === 'message' && write.body.role === 'assistant' + ) + expect(summary?.turnScope).toEqual(scope) + // Codex's own marker is the command's result row. + expect(resultRows(writes)).toEqual([ + expect.objectContaining({ + body: { kind: 'status', text: 'Context compacted', presentation: 'compaction' }, + turnScope: scope + }) + ]) + expect(commandTurn(writes)).toMatchObject({ + state: 'completed', + outcome: 'success', + providerTurnId: PROVIDER_TURN, + userItemId: RUNNING.userItemId, + requestedAt: RUNNING.requestedAt + }) + }) + + it('reads an interrupted turn as the command cancelled, with no result row', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + emit(notification('turn/completed', { turn: { id: PROVIDER_TURN, status: 'interrupted' } })) + + expect(commandTurn(writes)).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(resultRows(writes)).toEqual([]) + }) + + it('reads a turn that completed without compacting as a failure, with the reason', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + emit( + notification('turn/completed', { + turn: { id: PROVIDER_TURN, status: 'failed', error: { message: 'Unavailable' } } + }) + ) + + expect(commandTurn(writes)).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(resultRows(writes).map((write) => write.body)).toEqual([ + { + kind: 'status', + text: 'Compaction failed: Unavailable.', + failure: { + kind: 'compactionFailed', + detail: { text: 'Unavailable', audience: 'person' } + }, + tone: 'error' + } + ]) + }) + + it('says only that the compaction failed when a turn that did not complete gave no words', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + emit(notification('turn/completed', { turn: { id: PROVIDER_TURN, status: 'failed' } })) + + expect(commandTurn(writes)).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(resultRows(writes).map((write) => write.body)).toEqual([ + { + kind: 'status', + text: 'Compaction failed.', + failure: { kind: 'compactionFailed' }, + tone: 'error' + } + ]) + }) + + it('reports a turn that completed without Codex reporting a compaction as unconfirmed', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + emit(notification('turn/completed', { turn: { id: PROVIDER_TURN, status: 'completed' } })) + + expect(commandTurn(writes)).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(resultRows(writes).map((write) => write.body)).toEqual([ + { + kind: 'status', + text: 'Compaction completion is unconfirmed.', + failure: { kind: 'compactionUnconfirmed' }, + tone: 'error' + } + ]) + }) + + it('ends a compaction Codex failed with an error once, however late its completion', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + emit( + notification('error', { + turnId: PROVIDER_TURN, + willRetry: false, + error: { message: 'Unavailable' } + }) + ) + // The error is a row; only the completion ends the turn. + expect(commandTurn(writes)).toMatchObject({ state: 'running' }) + // Codex still completes the turn it failed, as status failed. + emit( + notification('turn/completed', { + turn: { id: PROVIDER_TURN, status: 'failed', error: { message: 'Unavailable' } } + }) + ) + + expect(codexTurnRecords(writes)).toEqual([]) + expect(commandTurn(writes)).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect( + writes.filter( + (write) => write.key === COMMAND_TURN_KEY && readAgentJournalTurn(write.body)?.completedAt + ) + ).toHaveLength(1) + // Codex's own error row, in the command's turn; the completion adds none. + expect(resultRows(writes)).toEqual([ + expect.objectContaining({ + body: expect.objectContaining({ kind: 'status', text: 'Unavailable', tone: 'error' }), + turnScope: { kind: 'turn', turnItemId: COMMAND_TURN_KEY } + }) + ]) + }) + + it("ends a captured failed compaction on its completion, below one row of Codex's own", () => { + // A real app-server's compaction turn: two retried stream errors, the turn-ending error, then + // the failed completion. + const captured: { case: string; t: number; method: string; params: object }[] = readFileSync( + join(__dirname, '__fixtures__', 'codex-app-server-turn-endings.jsonl'), + 'utf8' + ) + .split('\n') + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line)) + .filter((frame) => frame.case === '0.158.0-compact-overloaded') + const compactionTurn = captured.findLastIndex((frame) => frame.method === 'turn/started') + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + for (const frame of captured.slice(compactionTurn)) { + emit({ + type: 'notification', + sessionId: SESSION, + threadId: THREAD, + method: frame.method, + params: { ...frame.params, threadId: THREAD }, + observedAt: frame.t + }) + } + + expect(codexTurnRecords(writes)).toEqual([]) + const completion = captured.at(-1)! + expect(completion.method).toBe('turn/completed') + expect(commandTurn(writes)).toMatchObject({ + state: 'completed', + outcome: 'failure', + completedAt: completion.t + }) + // Every row is one of Codex's frames, inside the command's turn; the completion adds none. + const scope = { kind: 'turn', turnItemId: COMMAND_TURN_KEY } + const rows = resultRows(writes) + expect(rows.map((write) => write.key)).not.toContain( + agentJournalItemKey(COMMAND.resultIdentity) + ) + expect(rows.map((write) => write.turnScope)).toEqual(rows.map(() => scope)) + expect( + rows.some( + (write) => + write.body.kind === 'status' && + write.body.tone === 'error' && + write.body.text.includes('Selected model is at capacity') + ) + ).toBe(true) + }) + + it('leaves the next turn to write its own record after a failed compaction', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + emit( + notification('error', { turnId: PROVIDER_TURN, willRetry: false, error: { message: 'x' } }) + ) + emit(notification('turn/completed', { turn: { id: PROVIDER_TURN, status: 'failed' } })) + emit(notification('turn/started', { turn: { id: 'ordinary' } })) + emit(notification('turn/completed', { turn: { id: 'ordinary', status: 'completed' } })) + + expect( + codexTurnRecords(writes).map((write) => readAgentJournalTurn(write.body)?.turnId) + ).toEqual(['ordinary', 'ordinary']) + }) + + it('names no provider turn for a Stop until Codex opens one, then the one it opened', () => { + const { translator, emit } = harness() + translator.beginCommand(COMMAND) + expect(translator.commandProviderTurnId(COMMAND.turnId)).toBeUndefined() + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + expect(translator.commandProviderTurnId(COMMAND.turnId)).toBe(PROVIDER_TURN) + expect(translator.commandProviderTurnId('ordinary')).toBe('ordinary') + }) + + it('claims the same provider turn when the refused start is retried', () => { + const { writes, translator, emit } = harness(1) + translator.beginCommand(COMMAND) + const started = notification('turn/started', { turn: { id: PROVIDER_TURN } }) + + expect(emit(started)).toEqual({ accepted: false, reason: 'backpressure' }) + expect(emit(started)).toEqual(ACCEPTED) + + expect(codexTurnRecords(writes)).toEqual([]) + expect(commandTurn(writes)).toMatchObject({ providerTurnId: PROVIDER_TURN }) + expect(translator.commandProviderTurnId(COMMAND.turnId)).toBe(PROVIDER_TURN) + }) + + it('leaves a primary turn no command claims to write its own record', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + translator.forgetCommand(COMMAND.turnId) + emit(notification('turn/started', { turn: { id: 'ordinary' } })) + emit(notification('turn/completed', { turn: { id: 'ordinary', status: 'completed' } })) + expect( + codexTurnRecords(writes).map((write) => readAgentJournalTurn(write.body)?.state) + ).toEqual(['running', 'completed']) + }) +}) diff --git a/src/main/codex/codex-command-turn-claim.ts b/src/main/codex/codex-command-turn-claim.ts new file mode 100644 index 00000000000..e1278a8b5c4 --- /dev/null +++ b/src/main/codex/codex-command-turn-claim.ts @@ -0,0 +1,32 @@ +import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionSinkAdmission +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' + +const CLAIM_BYTES = 4_096 + +/** Records on the command's turn which provider turn carried it out. Persisted because nothing + * else re-derives it once the command settles, and a rewind has to place that turn's rows. */ +export function recordCodexCommandTurnClaim( + sink: StructuredAgentSessionEventSink, + commandTurnItemId: string, + providerTurnId: string +): StructuredAgentSessionSinkAdmission { + const identity = parseAgentJournalItemKey(commandTurnItemId) + if (!identity || !sink.tryReviseResolvedItemAndPublish) { + return { accepted: true } + } + return sink.tryReviseResolvedItemAndPublish( + CLAIM_BYTES, + (journal) => { + const body = journal.itemBody(commandTurnItemId) + // Settled already — by the host, or by a child's death — leaves nothing to annotate. + return body?.kind === 'turn' && body.state === 'running' + ? { identity, body: { ...body, providerTurnId } } + : null + }, + { lifecycle: true, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} diff --git a/src/main/codex/codex-config-mirror-mcp-ownership.test.ts b/src/main/codex/codex-config-mirror-mcp-ownership.test.ts new file mode 100644 index 00000000000..85121996a06 --- /dev/null +++ b/src/main/codex/codex-config-mirror-mcp-ownership.test.ts @@ -0,0 +1,159 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + syncSystemConfigIntoManagedCodexHome, + syncSystemConfigIntoLegacySharedCodexHome +} from './codex-config-mirror' + +let root: string +let runtimeHomePath: string +let systemHomePath: string + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-mcp-ownership-')) + runtimeHomePath = join(root, 'runtime') + systemHomePath = join(root, 'system') + mkdirSync(runtimeHomePath) + mkdirSync(systemHomePath) +}) + +afterEach(() => rmSync(root, { recursive: true, force: true })) + +describe('canonical MCP ownership during config mirroring', () => { + it('does not duplicate a server defined inline in the canonical MCP table', () => { + writeFileSync( + join(runtimeHomePath, 'config.toml'), + '[mcp_servers.shared]\ncommand = "runtime"\n' + ) + writeFileSync( + join(systemHomePath, 'config.toml'), + '[mcp_servers]\nshared = { command = "system" }\n' + ) + + syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) + + const runtimeConfig = readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8') + expect(runtimeConfig).toContain('shared = { command = "system" }') + expect(runtimeConfig).not.toContain('[mcp_servers.shared]') + }) + + it('preserves deletion after a canonical inline server is rewritten by the runtime', () => { + writeFileSync( + join(runtimeHomePath, 'config.toml'), + '[mcp_servers.shared]\ncommand = "runtime"\n' + ) + writeFileSync( + join(systemHomePath, 'config.toml'), + '[mcp_servers]\nshared = { command = "system" }\n' + ) + syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) + writeFileSync( + join(runtimeHomePath, 'config.toml'), + '[mcp_servers.shared]\ncommand = "system"\n[mcp_servers.runtime_only]\ncommand = "runtime"\n' + ) + writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n') + + syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) + + const runtimeConfig = readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8') + expect(runtimeConfig).not.toContain('[mcp_servers.shared]') + expect(runtimeConfig).toContain('[mcp_servers.runtime_only]') + }) + + it('honors a closed canonical MCP root and its later removal', () => { + writeFileSync( + join(runtimeHomePath, 'config.toml'), + '[mcp_servers.runtime_only]\ncommand = "runtime"\n' + ) + writeFileSync( + join(systemHomePath, 'config.toml'), + 'mcp_servers = { shared = { enabled = false } }\n' + ) + syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) + expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).not.toContain( + '[mcp_servers.' + ) + expect( + JSON.parse( + readFileSync(join(runtimeHomePath, '.orca-config-settings-baseline.json'), 'utf-8') + ) + ).toMatchObject({ mcpServerRoot: true }) + writeFileSync(join(runtimeHomePath, 'config.toml'), '[mcp_servers.shared]\nenabled = false\n') + writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n') + + syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) + + expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).not.toContain( + '[mcp_servers.' + ) + }) +}) + +describe('MCP ownership migration', () => { + it.each([1, 2, 3])( + 'keeps pre-ownership baseline version %s canonical for one pass', + (version) => { + writeFileSync( + join(runtimeHomePath, 'config.toml'), + '[mcp_servers.removed]\ncommand = "old"\n' + ) + writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n') + writeFileSync( + join(runtimeHomePath, '.orca-config-settings-baseline.json'), + JSON.stringify({ version, settings: {} }) + ) + + syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) + + expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).not.toContain( + '[mcp_servers.' + ) + expect( + JSON.parse( + readFileSync(join(runtimeHomePath, '.orca-config-settings-baseline.json'), 'utf-8') + ) + ).toMatchObject({ mcpServers: [] }) + writeFileSync(join(runtimeHomePath, 'config.toml'), '[mcp_servers.added]\ncommand = "new"\n') + + syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) + + expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).toContain( + '[mcp_servers.added]' + ) + } + ) + + it('keeps the retained shared home one-way without an ownership baseline', () => { + writeFileSync(join(runtimeHomePath, 'config.toml'), '[mcp_servers.removed]\ncommand = "old"\n') + writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n') + + syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath }) + + expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).not.toContain( + '[mcp_servers.' + ) + }) + + it('tracks commented CRLF names so their later removal remains authoritative', () => { + writeFileSync( + join(runtimeHomePath, 'config.toml'), + '[mcp_servers.shared]\ncommand = "runtime"\n' + ) + writeFileSync( + join(systemHomePath, 'config.toml'), + '[mcp_servers.shared] # see [docs]\r\ncommand = "system"\r\n' + ) + + syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) + expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).not.toContain('"runtime"') + writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n') + + syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) + + expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).not.toContain( + '[mcp_servers.shared]' + ) + }) +}) diff --git a/src/main/codex/codex-config-mirror.test.ts b/src/main/codex/codex-config-mirror.test.ts index 2469a2be724..74db1cd9c05 100644 --- a/src/main/codex/codex-config-mirror.test.ts +++ b/src/main/codex/codex-config-mirror.test.ts @@ -52,6 +52,10 @@ function getRuntimeConfigPath(): string { return join(userDataDir, 'codex-runtime-home', 'home', 'config.toml') } +function getRuntimeBaselinePath(): string { + return join(userDataDir, 'codex-runtime-home', 'home', '.orca-config-settings-baseline.json') +} + beforeEach(() => { fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-config-home-')) userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-config-user-data-')) @@ -364,6 +368,96 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { expect(runtimeConfig.match(/\[projects\."\/repo"\]/g)?.length).toBe(1) }) + it('preserves runtime-only MCP servers and nested descendants with system precedence', () => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + [ + '[mcp_servers.runtime_only]', + 'command = "runtime-command"', + '', + '[mcp_servers.runtime_only.env]', + 'MODE = "runtime"', + '', + '[mcp_servers.shared]', + 'command = "runtime-shared"', + '' + ].join('\n'), + 'utf-8' + ) + writeFileSync( + getSystemConfigPath(), + [ + '[mcp_servers."shared"]', + 'command = "system-shared"', + '', + '[mcp_servers.system_only]', + 'command = "system-only"', + '' + ].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain('[mcp_servers.runtime_only]') + expect(runtimeConfig).toContain('[mcp_servers.runtime_only.env]') + expect(runtimeConfig).toContain('MODE = "runtime"') + expect(runtimeConfig).toContain('command = "system-shared"') + expect(runtimeConfig).not.toContain('runtime-shared') + expect(runtimeConfig.match(/\[mcp_servers\.(?:shared|"shared")\]/g)).toHaveLength(1) + expect(runtimeConfig).toContain('[mcp_servers.system_only]') + expect(JSON.parse(readFileSync(getRuntimeBaselinePath(), 'utf-8'))).toMatchObject({ + mcpServers: ['shared', 'system_only'] + }) + }) + + it('revokes a previously mirrored MCP server when the system source deletes it', () => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync(getRuntimeConfigPath(), '[mcp_servers.revoked]\ncommand = "run"\n', 'utf-8') + writeFileSync(getSystemConfigPath(), '[mcp_servers.revoked]\ncommand = "run"\n', 'utf-8') + + syncSystemConfigIntoManagedCodexHome() + writeFileSync(getSystemConfigPath(), 'model = "system"\n', 'utf-8') + syncSystemConfigIntoManagedCodexHome() + + expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).not.toContain('[mcp_servers.revoked]') + }) + + it('keeps runtime-only MCP additions after a source refresh and remains byte-idempotent', () => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync(getRuntimeConfigPath(), '[mcp_servers.runtime_only]\ncommand = "run"\n', 'utf-8') + writeFileSync(getSystemConfigPath(), 'model = "system"\n', 'utf-8') + + syncSystemConfigIntoManagedCodexHome() + const first = readFileSync(getRuntimeConfigPath(), 'utf-8') + syncSystemConfigIntoManagedCodexHome() + + expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe(first) + expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toContain('[mcp_servers.runtime_only]') + }) + + it('keeps an explicit system MCP disable canonical', () => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + '[mcp_servers.blocked]\ncommand = "runtime"\nenabled = true\n', + 'utf-8' + ) + writeFileSync( + getSystemConfigPath(), + '[mcp_servers."blocked"]\ncommand = "system"\nenabled = false\n', + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain('enabled = false') + expect(runtimeConfig).not.toContain('enabled = true') + }) + it('deduplicates basic and literal project headers by decoded Windows path', () => { mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) writeFileSync( diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index af6f010da18..275e350328a 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -1,3 +1,4 @@ +import { readMcpServerTomlOwnership } from './config-toml-mcp-servers' import { dirname, join } from 'node:path' import { observeAgentStateFile } from './codex-path-observation' import { @@ -21,6 +22,7 @@ import { preserveRuntimeConflictValues } from './codex-config-settings-preservat import { applyCodexDaemonSocketGuard } from './codex-daemon-socket-path-guard' import { deduplicateProjectTomlSections, + getMcpServerTomlSectionName, getProjectTrustLevel, getRevocationTomlSectionHeaderKey, getTomlSectionHeaderKey, @@ -109,7 +111,9 @@ function mirrorSystemConfigIntoManagedCodexHome(homes: CodexSettingsPromotionHom ), // Why: this pass made the runtime's marketplace and plugin tables canonical, // so a later source config that lacks one is a removal, not an addition. - mirroredRegistrations: true + mirroredRegistrations: true, + mirroredMcpServers: mirrorResult.mirroredMcpServerNames, + mirroredMcpServerRoot: mirrorResult.mirroredMcpServerRoot }) return true } @@ -167,11 +171,14 @@ export function syncSystemConfigIntoLegacySharedCodexHome( let mirroredRuntimeConfig = runtimeConfigBeforeMirror ?? '' if (rawSystemConfig.trim() !== '') { const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(homes.systemHomePath) + // The retired home has no ownership baseline; its entire MCP root stays canonical. mirroredRuntimeConfig = runtimeConfigBeforeMirror !== null ? mergeSystemCodexConfigIntoRuntime( runtimeConfigBeforeMirror, - prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir) + prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir), + new Set(), + true ) : prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir) } @@ -191,7 +198,12 @@ export function syncSystemConfigIntoLegacySharedCodexHome( type CodexConfigMirrorResult = | { status: 'skipped-missing-source' } | { status: 'refused-indeterminate'; error: unknown } - | { status: 'mirrored'; preservedConflictKeys: ReadonlySet } + | { + status: 'mirrored' + preservedConflictKeys: ReadonlySet + mirroredMcpServerNames: ReadonlySet + mirroredMcpServerRoot: boolean + } function syncSystemConfigIntoManagedCodexHomeUnsafe( { runtimeHomePath, systemHomePath, systemConfigDir }: CodexSettingsPromotionHomes, @@ -225,34 +237,55 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe( ) return runtimeConfigExists ? { status: 'skipped-missing-source' } - : { status: 'mirrored', preservedConflictKeys: new Set() } + : { + status: 'mirrored', + preservedConflictKeys: new Set(), + mirroredMcpServerNames: new Set(), + mirroredMcpServerRoot: false + } } const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(systemHomePath, systemConfigDir) if (!runtimeConfigExists) { - writeFileAtomically( - runtimeConfigPath, - applyCodexDaemonSocketGuard( - prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir), - runtimeHomePath - ) + const freshRuntimeConfig = applyCodexDaemonSocketGuard( + prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir), + runtimeHomePath ) - return { status: 'mirrored', preservedConflictKeys: new Set() } + const ownership = readMcpServerTomlOwnership(freshRuntimeConfig) + writeFileAtomically(runtimeConfigPath, freshRuntimeConfig) + return { + status: 'mirrored', + preservedConflictKeys: new Set(), + mirroredMcpServerNames: ownership.names, + mirroredMcpServerRoot: ownership.ownsRoot + } } const systemConfig = prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir) + const { names: mirroredMcpServerNames, ownsRoot: mirroredMcpServerRoot } = + readMcpServerTomlOwnership(systemConfig) // Why: reuse the bytes already observed above rather than re-reading. A second // read could succeed where the first failed and re-open the gap this closes. const runtimeConfig = runtimeConfigObservation.value const preserved = preserveRuntimeConflictValues( - mergeSystemCodexConfigIntoRuntime(runtimeConfig, systemConfig), + mergeSystemCodexConfigIntoRuntime( + runtimeConfig, + systemConfig, + promotionPlan.mirroredMcpServers, + promotionPlan.mirroredMcpServerRoot + ), promotionPlan.runtimeValuesToPreserve ) const nextRuntimeConfig = applyCodexDaemonSocketGuard(preserved.content, runtimeHomePath) if (nextRuntimeConfig !== runtimeConfig) { writeFileAtomically(runtimeConfigPath, nextRuntimeConfig) } - return { status: 'mirrored', preservedConflictKeys: preserved.keys } + return { + status: 'mirrored', + preservedConflictKeys: preserved.keys, + mirroredMcpServerNames, + mirroredMcpServerRoot + } } export function resolveCodexConfigMirrorSourceDirectory( @@ -284,7 +317,12 @@ export function prepareSystemConfigForFreshRuntimeMirror( return stripRuntimeOwnedTomlSections(prepareSystemConfigForRuntimeMirror(config, systemConfigDir)) } -function mergeSystemCodexConfigIntoRuntime(runtimeConfig: string, systemConfig: string): string { +function mergeSystemCodexConfigIntoRuntime( + runtimeConfig: string, + systemConfig: string, + mirroredMcpServerNames: ReadonlySet = new Set(), + mirroredMcpServerRoot = false +): string { const runtimeSections = deduplicateProjectTomlSections(getTomlSections(runtimeConfig)) const runtimeProjectHeaders = new Set( runtimeSections @@ -307,6 +345,7 @@ function mergeSystemCodexConfigIntoRuntime(runtimeConfig: string, systemConfig: .filter((section) => getProjectTrustLevel(section.block) === 'trusted') .map((section) => getTomlSectionHeaderKey(section.header)) ) + const systemMcpServers = readMcpServerTomlOwnership(systemConfig) // Why: ordinary Codex settings should mirror ~/.codex exactly; runtime hook // trust and project trust are written under Orca's managed CODEX_HOME and // must survive the copy unless the user explicitly revoked project trust in @@ -314,7 +353,19 @@ function mergeSystemCodexConfigIntoRuntime(runtimeConfig: string, systemConfig: return joinTomlBlocks([ stripRuntimeOwnedTomlSections(systemConfig, runtimeProjectHeaders), ...runtimeSections - .filter((section) => isRuntimePreservedTomlSection(section.header)) + .filter((section) => { + if (isRuntimePreservedTomlSection(section.header)) { + return true + } + const mcpServerName = getMcpServerTomlSectionName(section.header) + return ( + mcpServerName !== null && + !systemMcpServers.ownsRoot && + !mirroredMcpServerRoot && + !systemMcpServers.names.has(mcpServerName) && + !mirroredMcpServerNames.has(mcpServerName) + ) + }) .filter( (section) => !isRuntimeProjectTomlSection(section.header) || diff --git a/src/main/codex/codex-home-paths.ts b/src/main/codex/codex-home-paths.ts index b265245f92d..c76aee5c1ea 100644 --- a/src/main/codex/codex-home-paths.ts +++ b/src/main/codex/codex-home-paths.ts @@ -47,6 +47,14 @@ export function getOrcaManagedCodexHomePath(): string { return managedHomePath } +/** Config files an Orca-launched local Codex reads trust from, in the hook installer's lock order. */ +export function getLocalCodexTrustConfigFiles(): string[] { + return [ + join(getOrcaManagedCodexHomePath(), 'config.toml'), + join(getSystemCodexHomePath(), 'config.toml') + ] +} + export function getCodexSessionBackfillStateDirPath(): string { return join(getOrcaUserDataPath(), 'codex-session-backfill') } diff --git a/src/main/codex/codex-hook-legacy-cleanup.ts b/src/main/codex/codex-hook-legacy-cleanup.ts index d2b5c3b586c..415aabf6155 100644 --- a/src/main/codex/codex-hook-legacy-cleanup.ts +++ b/src/main/codex/codex-hook-legacy-cleanup.ts @@ -8,6 +8,10 @@ import { writeHooksJson } from '../agent-hooks/installer-utils' import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path' +import { + isAgentStatusHooksEnabledForAgent, + type AgentStatusHooksSettings +} from '../../shared/agent-status-hooks-setting' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { findManagedTomlBlocks } from '../agent-hooks/managed-toml-ownership' import { writeConfigAtomically, type CodexTrustEntry } from './config-toml-trust' @@ -42,6 +46,17 @@ export function setSystemCodexHomeHookSweepSuppressed(gate: () => boolean): void systemCodexHomeHookSweepSuppressed = gate } +// Why per agent: Codex turned off must re-arm the sweep exactly like the global switch off, +// or its remove() leaves Orca's entry in the real ~/.codex. +export function shouldSuppressSystemCodexHomeHookSweep(args: { + isHostSystemDefaultRealHome: boolean + settings: AgentStatusHooksSettings +}): boolean { + return ( + args.isHostSystemDefaultRealHome && isAgentStatusHooksEnabledForAgent(args.settings, 'codex') + ) +} + function getLegacyCodexProfileTomlPath(): string { return join(getSystemCodexHomePath(), `${LEGACY_ORCA_PROFILE_NAME}.config.toml`) } diff --git a/src/main/codex/codex-hook-sweep-per-agent-opt-out.test.ts b/src/main/codex/codex-hook-sweep-per-agent-opt-out.test.ts new file mode 100644 index 00000000000..780f0dbbf80 --- /dev/null +++ b/src/main/codex/codex-hook-sweep-per-agent-opt-out.test.ts @@ -0,0 +1,132 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import type * as Os from 'node:os' +import { join } from 'node:path' +import { wrapPosixHookCommand } from '../agent-hooks/installer-utils' +import type { GlobalSettings } from '../../shared/global-settings-types' +import { setupCodexHookHomes } from './hook-service-test-harness' + +const { getPathMock, homedirMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>(), + homedirMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ + app: { + getPath: getPathMock + } +})) + +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + homedir: homedirMock + } +}) + +import { CodexHookService } from './hook-service' +import { + setSystemCodexHomeHookSweepSuppressed, + shouldSuppressSystemCodexHomeHookSweep +} from './codex-hook-legacy-cleanup' + +const homes = setupCodexHookHomes(homedirMock, getPathMock) + +type HookSettings = Pick + +const CODEX_OFF: HookSettings = { agentStatusHooksEnabled: true, disabledTuiAgents: ['codex'] } +const ALL_ON: HookSettings = { agentStatusHooksEnabled: true, disabledTuiAgents: [] } +const OTHER_OFF: HookSettings = { agentStatusHooksEnabled: true, disabledTuiAgents: ['claude'] } +const GLOBAL_OFF: HookSettings = { agentStatusHooksEnabled: false, disabledTuiAgents: [] } + +function orcaEntryCommand(): string { + const scriptPath = join( + homes.userDataDir, + 'agent-hooks', + process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh' + ) + return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath) +} + +function seedRealHomeHooks(): string { + const systemCodexHome = join(homes.tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + systemHooksPath, + `${JSON.stringify({ + hooks: { + Stop: [ + { hooks: [{ type: 'command', command: 'user-hook' }] }, + { hooks: [{ type: 'command', command: orcaEntryCommand() }] } + ] + } + })}\n`, + 'utf-8' + ) + return systemHooksPath +} + +function realHomeStopHooks(systemHooksPath: string): unknown { + return JSON.parse(readFileSync(systemHooksPath, 'utf-8')).hooks.Stop +} + +describe('system ~/.codex sweep gate', () => { + afterEach(() => { + setSystemCodexHomeHookSweepSuppressed(() => false) + }) + + it.each([ + { label: 'Codex turned off', settings: CODEX_OFF, suppressed: false }, + { label: 'the global switch off', settings: GLOBAL_OFF, suppressed: false }, + { label: 'every agent on', settings: ALL_ON, suppressed: true }, + { label: 'another agent turned off', settings: OTHER_OFF, suppressed: true } + ])('on the real-home lane with $label, suppressed=$suppressed', ({ settings, suppressed }) => { + expect( + shouldSuppressSystemCodexHomeHookSweep({ isHostSystemDefaultRealHome: true, settings }) + ).toBe(suppressed) + }) + + it('never suppresses off the real-home lane', () => { + expect( + shouldSuppressSystemCodexHomeHookSweep({ + isHostSystemDefaultRealHome: false, + settings: ALL_ON + }) + ).toBe(false) + }) + + it('turning Codex off removes the Orca entry from the real ~/.codex and keeps user hooks', async () => { + const systemHooksPath = seedRealHomeHooks() + setSystemCodexHomeHookSweepSuppressed(() => + shouldSuppressSystemCodexHomeHookSweep({ + isHostSystemDefaultRealHome: true, + settings: CODEX_OFF + }) + ) + + await new CodexHookService().remove() + + expect(realHomeStopHooks(systemHooksPath)).toEqual([ + { hooks: [{ type: 'command', command: 'user-hook' }] } + ]) + }) + + it('leaves the real-home entry in place while Codex hooks are on', async () => { + const systemHooksPath = seedRealHomeHooks() + setSystemCodexHomeHookSweepSuppressed(() => + shouldSuppressSystemCodexHomeHookSweep({ + isHostSystemDefaultRealHome: true, + settings: ALL_ON + }) + ) + + await new CodexHookService().remove() + + expect(realHomeStopHooks(systemHooksPath)).toEqual([ + { hooks: [{ type: 'command', command: 'user-hook' }] }, + { hooks: [{ type: 'command', command: orcaEntryCommand() }] } + ]) + }) +}) diff --git a/src/main/codex/codex-index-heal-binary-contract.test.ts b/src/main/codex/codex-index-heal-binary-contract.test.ts index 5580fc02d1d..8954f618a6a 100644 --- a/src/main/codex/codex-index-heal-binary-contract.test.ts +++ b/src/main/codex/codex-index-heal-binary-contract.test.ts @@ -3,10 +3,12 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' +import { zstdCompressSync } from 'node:zlib' import { afterEach, beforeAll, describe, expect, it } from 'vitest' +import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args' import SyncDatabase from '../sqlite/sync-database' import { runCodexAppServerSession, type CodexAppServerRpc } from './codex-app-server-session' -import { findNewestCodexStateDbPath } from './codex-state-db' +import { findNewestCodexStateDbPath, readCodexStateDbBackfillStatus } from './codex-state-db' // Why this file exists: every other index-heal test drives a stub app-server and // asserts "healed" as "the `thread/read` call did not error". That pins Orca's half @@ -17,7 +19,7 @@ import { findNewestCodexStateDbPath } from './codex-state-db' // real-binary backstop, built to the same shape as the Git binary compatibility // contract in src/shared/git-binary-compatibility.test.ts. // -// Keep it narrow. It pins the four arms that ablation established Orca relies on, +// Keep it narrow. It pins the arms that ablation established Orca relies on, // and deliberately asserts nothing else about the app-server, so an unrelated Codex // release does not redden it into being disabled. @@ -83,11 +85,18 @@ describeCodexContract( mkdirSync(join(home, 'sessions'), { recursive: true }) // An app-server session over an empty sessions tree is what stamps the backfill complete. await runAppServerSession(home, async () => undefined) + // Why: the account bridge links history only after this no-request session stamps it. + expect(readCodexStateDbBackfillStatus(home).kind).toBe('complete') expect(readThreadRows(home)).toEqual([]) return home } - function writeRollout(home: string, threadId: string, stamp: string): void { + function writeRollout( + home: string, + threadId: string, + stamp: string, + options: { compressed?: boolean } = {} + ): void { const dayDir = join(home, 'sessions', '2026', '08', '29') mkdirSync(dayDir, { recursive: true }) const meta = { @@ -112,10 +121,13 @@ describeCodexContract( type: 'event_msg', payload: { type: 'user_message', message: 'index-heal contract fixture' } } - writeFileSync( - join(dayDir, `rollout-${stamp}-${threadId}.jsonl`), - `${JSON.stringify(meta)}\n${JSON.stringify(userMessage)}\n` - ) + const contents = `${JSON.stringify(meta)}\n${JSON.stringify(userMessage)}\n` + const fileName = `rollout-${stamp}-${threadId}.jsonl` + if (options.compressed) { + writeFileSync(join(dayDir, `${fileName}.zst`), zstdCompressSync(contents)) + } else { + writeFileSync(join(dayDir, fileName), contents) + } } // Why reuse runCodexAppServerSession rather than a local JSON-RPC client: it is the @@ -128,7 +140,8 @@ describeCodexContract( return runCodexAppServerSession( { command: binary!, - args: ['app-server'], + // Why: the heal and account state-DB creation launch with these exact args. + args: [...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS], cliPath: binary!, env: { CODEX_HOME: home }, timeoutMs: SESSION_TIMEOUT_MS @@ -171,6 +184,19 @@ describeCodexContract( expect(readThreadRows(home)).toEqual([{ id: threadId, archived: 0 }]) }) + // Why: the account bridge links `.jsonl.zst` history and heals it with the same read. + it('indexes a compressed-only rollout on read', async () => { + const home = await createBackfilledCodexHome() + const threadId = '01a04f62-715e-7830-9371-50db585caa74' + writeRollout(home, threadId, '2026-08-29T17-00-00', { compressed: true }) + + await runAppServerSession(home, async (rpc) => { + await rpc.request('thread/read', { threadId }) + }) + + expect(readThreadRows(home)).toEqual([{ id: threadId, archived: 0 }]) + }) + it('leaves an already-indexed thread as a single row', async () => { const home = await createBackfilledCodexHome() const threadId = '01a04f62-715e-7830-9371-50db585caa72' diff --git a/src/main/codex/codex-journal-command-turn.ts b/src/main/codex/codex-journal-command-turn.ts new file mode 100644 index 00000000000..90e2f556b84 --- /dev/null +++ b/src/main/codex/codex-journal-command-turn.ts @@ -0,0 +1,165 @@ +// The conversation command a Codex child is running. Codex carries a command out as a turn of its +// own; that turn writes no record, its rows join the command's turn, and its end is the command's. +// Held by the child's translator, so it ends with the child and nothing has to release it. + +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' +import { providerDiagnostic } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' +import type { JournalLifecycleMutationInput } from '../native-chat/agent-session-journal/journal-row-builders' +import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { structuredCompactionOutcome } from '../native-chat/agent-session-wire/structured-conversation-command-outcome' +import { readCodexJournalRecord } from './codex-structured-journal-translation-values' +import { readCodexTurnId } from './codex-structured-thread-facts' + +type CarriedCommand = { + command: StructuredAgentSessionCommandRun + compacted: boolean + /** Codex's own error row already says why the command failed. */ + failureShown: boolean +} + +export class CodexJournalCommandTurn { + /** Sent, and not yet carried by a provider turn. */ + private awaiting: StructuredAgentSessionCommandRun | null = null + private readonly carried = new Map() + + /** Before Orca sends the command: the next primary turn to start carries it out. */ + begin(command: StructuredAgentSessionCommandRun): void { + this.awaiting = command + } + + /** The command was never taken. */ + forget(turnId: string): void { + if (this.awaiting?.turnId === turnId) { + this.awaiting = null + } + } + + /** The command the primary turn `providerTurnId` carries out. Idempotent per provider turn, so a + * refused frame's retry gets the same answer. */ + claim(providerTurnId: string): StructuredAgentSessionCommandRun | null { + const carried = this.carried.get(providerTurnId) + if (carried || !this.awaiting) { + return carried?.command ?? null + } + const command = this.awaiting + this.awaiting = null + this.carried.set(providerTurnId, { command, compacted: false, failureShown: false }) + return command + } + + isCarrying(providerTurnId: string): boolean { + return this.carried.has(providerTurnId) + } + + scopeFor(providerTurnId: string): AgentJournalTurnScope | null { + const carried = this.carried.get(providerTurnId) + return carried + ? { kind: 'turn', turnItemId: agentJournalItemKey(carried.command.identity) } + : null + } + + /** The provider turn a Stop on `turnId` interrupts: none while the command has not started one. */ + providerTurnId(turnId: string): string | undefined { + if (this.awaiting?.turnId === turnId) { + return undefined + } + for (const [providerTurnId, { command }] of this.carried) { + if (command.turnId === turnId) { + return providerTurnId + } + } + return turnId + } + + /** Codex reported the compaction, in the turn carrying the command. */ + compacted(providerTurnId: string): void { + const carried = this.carried.get(providerTurnId) + if (carried) { + carried.compacted = true + } + } + + /** The command's end, for the batch that settles the provider turn carrying it. */ + end( + providerTurnId: string, + ended: { + status: string | null + error: string | null + completedAt: number + } + ): JournalLifecycleMutationInput[] { + const carried = this.carried.get(providerTurnId) + if (!carried) { + return [] + } + const { command } = carried + const detail = ended.error === null ? undefined : providerDiagnostic(ended.error, 'person') + const verdict = structuredCompactionOutcome({ + compacted: carried.compacted, + // Codex reports the user's stop as the turn's own status. + interruptRequested: ended.status === 'interrupted', + // A turn that did not complete failed, not merely went unconfirmed. + failed: ended.status !== 'completed' || detail ? (detail ? { detail } : {}) : null + }) + const turnScope = { kind: 'turn' as const, turnItemId: agentJournalItemKey(command.identity) } + return [ + // A success already drew Codex's own compaction marker inside the command's turn. + ...(verdict.failure && !carried.failureShown + ? [ + { + kind: 'item' as const, + identity: command.resultIdentity, + body: { + kind: 'status' as const, + ...agentSessionFailureWords(verdict.failure, { + surface: 'row', + agentName: TUI_AGENT_DISPLAY_NAMES.codex + }), + tone: 'error' as const + }, + turnScope + } + ] + : []), + { + kind: 'item', + identity: command.identity, + body: agentJournalTurnBody({ + ...command.running, + providerTurnId, + state: verdict.outcome === 'cancellation' ? 'interrupted' : 'completed', + outcome: verdict.outcome, + completedAt: ended.completedAt + }), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] + } + + /** Codex's `error` frame, already a row in the turn it names. A turn-ending one says why the + * command failed, so the failed completion that follows it adds no second row. */ + errorShown(params: unknown): void { + const providerTurnId = readCodexTurnId(params) + const carried = providerTurnId ? this.carried.get(providerTurnId) : undefined + // A stream error Codex is about to retry ends nothing. + if (carried && readCodexJournalRecord(params).willRetry !== true) { + carried.failureShown = true + } + } + + settled(providerTurnId: string): void { + this.carried.delete(providerTurnId) + } + + clear(): void { + this.awaiting = null + this.carried.clear() + } +} diff --git a/src/main/codex/codex-journal-turn-scopes.ts b/src/main/codex/codex-journal-turn-scopes.ts new file mode 100644 index 00000000000..eb49f243bf4 --- /dev/null +++ b/src/main/codex/codex-journal-turn-scopes.ts @@ -0,0 +1,45 @@ +// Which turn a Codex row belongs to, stated when the row is written. +// +// A primary-thread row belongs to the turn it names: that turn's lifecycle record, or the +// conversation command that claimed it. A child thread has turns of its own that the timeline +// does not draw, so its rows belong to the primary turn running when they arrive — the work the +// user is watching — or to no turn once the primary is idle. + +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' +import { codexTurnLifecycleIdentity } from './codex-structured-journal-translation-turns' + +export class CodexJournalTurnScopes { + constructor( + private readonly deps: { + /** Without it no turn record is keyed, so every row reads as the thread's. */ + sessionId: string | undefined + primaryThreadId: () => string | null + activeTurn: (threadId: string) => string | null + /** The command turn's scope, for a primary turn carrying a conversation command. */ + commandScope: (turnId: string) => AgentJournalTurnScope | null + } + ) {} + + scopeFor(threadId: string, turnId: string | null): AgentJournalTurnScope { + const primary = this.deps.primaryThreadId() + const primaryTurnId = + primary === null ? null : threadId === primary ? turnId : this.deps.activeTurn(primary) + if (primaryTurnId === null) { + return AGENT_JOURNAL_THREAD_SCOPE + } + const { sessionId } = this.deps + return ( + this.deps.commandScope(primaryTurnId) ?? + (sessionId === undefined + ? AGENT_JOURNAL_THREAD_SCOPE + : { + kind: 'turn', + turnItemId: agentJournalItemKey(codexTurnLifecycleIdentity(sessionId, primaryTurnId)) + }) + ) + } +} diff --git a/src/main/codex/codex-persistent-command-retention.test.ts b/src/main/codex/codex-persistent-command-retention.test.ts index 498d5b186ef..fa6aef48ae9 100644 --- a/src/main/codex/codex-persistent-command-retention.test.ts +++ b/src/main/codex/codex-persistent-command-retention.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' @@ -45,7 +46,7 @@ function fixture(maxMetadataBytes?: number) { { sink, maxMetadataBytes, - linkageFor: () => ({}), + attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }), schedule: (run) => { scheduled.add(run) return () => { @@ -71,14 +72,15 @@ describe('persistent command retention', () => { items.streams.flush() const originalJoin = Array.prototype.join let retainedJoins = 0 - const spy = vi - .spyOn(Array.prototype, 'join') - .mockImplementation(function (this: unknown[], separator) { - if (this[0] === 'retained-prefix') { - retainedJoins += 1 - } - return originalJoin.call(this, separator) - }) + const spy = vi.spyOn(Array.prototype, 'join').mockImplementation(function ( + this: unknown[], + separator + ) { + if (this[0] === 'retained-prefix') { + retainedJoins += 1 + } + return originalJoin.call(this, separator) + }) try { for (let index = 0; index < 100; index += 1) { items.streams.flush() @@ -118,7 +120,7 @@ describe('persistent command retention', () => { sink, streams: items.streams, activeItems: items.activeItems, - linkageFor: () => ({}) + attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }) }) ).toEqual({ accepted: true }) } @@ -214,7 +216,7 @@ describe('persistent command retention', () => { sink, streams: items.streams, activeItems: items.activeItems, - linkageFor: () => ({}) + attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }) }) ).toEqual({ accepted: true }) expect(items.activeItems.size).toBe(1) diff --git a/src/main/codex/codex-prompt-registry-retention.test.ts b/src/main/codex/codex-prompt-registry-retention.test.ts index 78e10cbcc77..4757d731dcf 100644 --- a/src/main/codex/codex-prompt-registry-retention.test.ts +++ b/src/main/codex/codex-prompt-registry-retention.test.ts @@ -108,3 +108,22 @@ describe('Codex prompt claim lifetime', () => { expect(prompt.deref()).toBeUndefined() }) }) + +describe('Codex abandoned command approvals', () => { + it("reports only a command's own approval that its turn ended unanswered, once", () => { + const registry = new CodexPromptRegistry() + const ask = (id: number, method: string, params: Record) => + registry.register({ id, method, params: { threadId: 'thread', turnId: 'turn', ...params } }) + ask(1, 'item/commandExecution/requestApproval', { itemId: 'unanswered' }) + const answered = ask(2, 'item/commandExecution/requestApproval', { itemId: 'answered' }) + ask(3, 'item/commandExecution/requestApproval', { itemId: 'parent', approvalId: 'sub' }) + ask(4, 'item/fileChange/requestApproval', { itemId: 'patch' }) + if (!answered) { + throw new Error('Fixture prompt was refused') + } + registry.forget(answered) + registry.clearTurn('thread', 'turn') + expect(registry.takeAbandonedCommands()).toEqual([{ threadId: 'thread', itemId: 'unanswered' }]) + expect(registry.takeAbandonedCommands()).toEqual([]) + }) +}) diff --git a/src/main/codex/codex-prompt-registry.ts b/src/main/codex/codex-prompt-registry.ts index 059f8a7a0d3..eda1fa0bf30 100644 --- a/src/main/codex/codex-prompt-registry.ts +++ b/src/main/codex/codex-prompt-registry.ts @@ -31,6 +31,9 @@ export type CodexPendingPrompt = { answers: Map } +export type CodexAbandonedCommand = { threadId: string; itemId: string } +const NO_ABANDONED_COMMANDS: readonly CodexAbandonedCommand[] = [] + export type CodexPromptClaim = { readonly itemId: string readonly prompt: CodexPendingPrompt @@ -54,6 +57,7 @@ export class CodexPromptRegistry { private readonly journalItemIds = new Map() private readonly boundPrompts = new Map() private readonly claims = new Map() + private abandonedCommands: CodexAbandonedCommand[] = [] get sizes(): { prompts: number; journalBindings: number } { return { prompts: this.byAddress.size, journalBindings: this.journalItemIds.size } @@ -232,14 +236,33 @@ export class CodexPromptRegistry { ) for (const prompt of prompts) { this.forget(prompt) + // Codex abandons a turn's unanswered prompts: a command still awaiting approval never ran. + // An `approvalId` asks for a subcommand, not the item's own command. + if ( + prompt.method === CODEX_COMMAND_APPROVAL_METHOD && + prompt.promptKey === prompt.codexItemId + ) { + this.abandonedCommands.push({ threadId: prompt.threadId, itemId: prompt.codexItemId }) + } } } + /** The commands whose approval a turn ended without, since the last call. */ + takeAbandonedCommands(): readonly CodexAbandonedCommand[] { + if (this.abandonedCommands.length === 0) { + return NO_ABANDONED_COMMANDS + } + const taken = this.abandonedCommands + this.abandonedCommands = [] + return taken + } + clear(): void { this.byAddress.clear() this.journalItemIds.clear() this.boundPrompts.clear() this.claims.clear() + this.abandonedCommands = [] } private address(threadId: string, promptKey: string): string { diff --git a/src/main/codex/codex-requested-close-turn-timing.test.ts b/src/main/codex/codex-requested-close-turn-timing.test.ts index fbf50badaef..a7baf6a14b5 100644 --- a/src/main/codex/codex-requested-close-turn-timing.test.ts +++ b/src/main/codex/codex-requested-close-turn-timing.test.ts @@ -1,4 +1,5 @@ import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' +import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' @@ -72,6 +73,7 @@ describe('requested-close durable turn timing', () => { reportedOptions: {}, fastModeTierByModel: new Map(), dispatchEchoes: createCodexDispatchEchoes(), + turnOpenWaits: createCodexTurnOpenWaits(), translator } const sessions = new Map([['session-1', session]]) diff --git a/src/main/codex/codex-server-request-disposition.test.ts b/src/main/codex/codex-server-request-disposition.test.ts index 371927513bc..ec3a193af6c 100644 --- a/src/main/codex/codex-server-request-disposition.test.ts +++ b/src/main/codex/codex-server-request-disposition.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from 'vitest' import { CODEX_ATTESTATION_METHOD, CODEX_AUTH_TOKEN_REFRESH_METHOD, - CODEX_BLOCKING_SERVER_REQUEST_METHODS, CODEX_DYNAMIC_TOOL_CALL_METHOD, CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD, CODEX_LEGACY_EXEC_APPROVAL_METHOD, @@ -95,23 +94,6 @@ describe('Codex blocking server request dispositions', () => { ) }) - it('enumerates every server request in the negotiated stable schema', () => { - expect(new Set(CODEX_BLOCKING_SERVER_REQUEST_METHODS)).toEqual( - new Set([ - CODEX_COMMAND_APPROVAL_METHOD, - CODEX_FILE_CHANGE_APPROVAL_METHOD, - CODEX_USER_INPUT_METHOD, - CODEX_MCP_ELICITATION_METHOD, - CODEX_PERMISSIONS_APPROVAL_METHOD, - CODEX_DYNAMIC_TOOL_CALL_METHOD, - CODEX_AUTH_TOKEN_REFRESH_METHOD, - CODEX_ATTESTATION_METHOD, - CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD, - CODEX_LEGACY_EXEC_APPROVAL_METHOD - ]) - ) - }) - it('bounds the future-method fallback to one explicit rejection', () => { const { registry, connection } = harness() diff --git a/src/main/codex/codex-server-request-disposition.ts b/src/main/codex/codex-server-request-disposition.ts index 4e358da86b1..438bbfcc92b 100644 --- a/src/main/codex/codex-server-request-disposition.ts +++ b/src/main/codex/codex-server-request-disposition.ts @@ -18,19 +18,6 @@ export const CODEX_ATTESTATION_METHOD = 'attestation/generate' export const CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD = 'applyPatchApproval' export const CODEX_LEGACY_EXEC_APPROVAL_METHOD = 'execCommandApproval' -export const CODEX_BLOCKING_SERVER_REQUEST_METHODS = [ - CODEX_COMMAND_APPROVAL_METHOD, - CODEX_FILE_CHANGE_APPROVAL_METHOD, - CODEX_USER_INPUT_METHOD, - CODEX_MCP_ELICITATION_METHOD, - CODEX_PERMISSIONS_APPROVAL_METHOD, - CODEX_DYNAMIC_TOOL_CALL_METHOD, - CODEX_AUTH_TOKEN_REFRESH_METHOD, - CODEX_ATTESTATION_METHOD, - CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD, - CODEX_LEGACY_EXEC_APPROVAL_METHOD -] as const - export type CodexServerRequestDisposition = | { kind: 'prompt'; prompt: CodexPendingPrompt } | { kind: 'responded'; method: string } diff --git a/src/main/codex/codex-session-index-heal-state.ts b/src/main/codex/codex-session-index-heal-state.ts index 0a5fd0499c1..ab62b77669d 100644 --- a/src/main/codex/codex-session-index-heal-state.ts +++ b/src/main/codex/codex-session-index-heal-state.ts @@ -22,7 +22,7 @@ const HEAL_UNSUPPORTED_RETRY_INTERVAL_MS = 24 * 60 * 60 * 1000 const HEAL_FAILED_THREAD_RETRY_INTERVAL_MS = 24 * 60 * 60 * 1000 const CODEX_ROLLOUT_THREAD_ID_PATTERN = - /^rollout-(.+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i + /^rollout-(.+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl(?:\.zst)?$/i export type CodexSessionIndexHealPaths = { auditLogPath: string @@ -70,11 +70,11 @@ export async function collectPendingHealThreads( if (!isPathInsideOrEqual(paths.systemSessionsRoot, line.target)) { continue } - const match = CODEX_ROLLOUT_THREAD_ID_PATTERN.exec(lastPathSegment(line.target)) - if (!match) { + const rollout = parseCodexRolloutThreadId(line.target) + if (!rollout) { continue } - const threadId = match[2].toLowerCase() + const { threadId } = rollout const auditRecordId = typeof line.recordId === 'string' ? line.recordId : null if ( auditRecordId @@ -88,13 +88,21 @@ export async function collectPendingHealThreads( pendingByThreadId.delete(threadId) continue } - pendingByThreadId.set(threadId, { threadId, rolloutStamp: match[1], auditRecordId }) + pendingByThreadId.set(threadId, { threadId, rolloutStamp: rollout.rolloutStamp, auditRecordId }) } return [...pendingByThreadId.values()].sort((left, right) => left.rolloutStamp < right.rolloutStamp ? 1 : left.rolloutStamp > right.rolloutStamp ? -1 : 0 ) } +/** Thread id (lower-cased) and timestamp segment encoded in a rollout file name. */ +export function parseCodexRolloutThreadId( + filePath: string +): { threadId: string; rolloutStamp: string } | null { + const match = CODEX_ROLLOUT_THREAD_ID_PATTERN.exec(lastPathSegment(filePath)) + return match ? { threadId: match[2].toLowerCase(), rolloutStamp: match[1] } : null +} + function lastPathSegment(filePath: string): string { return filePath.split(/[\\/]/).at(-1) ?? '' } diff --git a/src/main/codex/codex-session-index-heal.test.ts b/src/main/codex/codex-session-index-heal.test.ts index 20051fe1058..1be3d047207 100644 --- a/src/main/codex/codex-session-index-heal.test.ts +++ b/src/main/codex/codex-session-index-heal.test.ts @@ -10,10 +10,12 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' +import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args' import type { CodexAppServerInvocation } from './codex-app-server-session' import { createCodexSessionBackfillAuditWriter } from './codex-session-backfill-audit' import { CODEX_SESSION_INDEX_HEAL_VERSION } from './codex-session-index-heal-state' import { + buildNativeHealInvocation, runCodexSessionIndexHeal, type CodexSessionIndexHealPaths } from './codex-session-index-heal' @@ -340,6 +342,7 @@ describe('runCodexSessionIndexHeal', () => { const marker = JSON.parse(readFileSync(rig.paths.healMarkerPath, 'utf-8')) as { retryableFailureAt: number } + expect(marker.retryableFailureAt).toEqual(expect.any(Number)) marker.retryableFailureAt = 0 writeFileSync(rig.paths.healMarkerPath, `${JSON.stringify(marker)}\n`, 'utf-8') const retried = await runCodexSessionIndexHeal(rig.paths, { @@ -490,6 +493,25 @@ describe('runCodexSessionIndexHeal', () => { expect(resumed.healedThreads + summary.healedThreads).toBe(4) }) + it('writes no completion marker when stop flips inside the last batch', async () => { + const rig = createHealRig({ + auditedThreads: [ + { stamp: '2026-07-02T10-00-00', id: threadId('2') }, + { stamp: '2026-07-01T10-00-00', id: threadId('1') } + ] + }) + + const summary = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + readConcurrency: 1, + interBatchDelayMs: 0, + shouldStop: () => rig.readLog().threadIds.length > 0 + }) + + expect(summary).toMatchObject({ outcome: 'stopped', healedThreads: 1 }) + expect(existsSync(rig.paths.healMarkerPath)).toBe(false) + }) + it('does not spawn another server when stop flips during the inter-batch delay', async () => { const rig = createHealRig({ auditedThreads: [ @@ -777,3 +799,14 @@ describe('runCodexSessionIndexHeal', () => { warnSpy.mockRestore() }) }) + +describe('buildNativeHealInvocation', () => { + it('starts a read-only, plugin-free app-server pinned to the given home', () => { + const invocation = buildNativeHealInvocation('/codex-home', 1_000) + + for (const arg of CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS) { + expect(invocation.args).toContain(arg) + } + expect(invocation.env).toEqual({ CODEX_HOME: '/codex-home' }) + }) +}) diff --git a/src/main/codex/codex-session-index-heal.ts b/src/main/codex/codex-session-index-heal.ts index 12747312646..364263f2f47 100644 --- a/src/main/codex/codex-session-index-heal.ts +++ b/src/main/codex/codex-session-index-heal.ts @@ -1,6 +1,7 @@ import { dirname, join } from 'node:path' import { resolveCodexCommand } from '../codex-cli/command' import { isTransientSqliteContention } from '../sqlite/sqlite-read-failure' +import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args' import { getSpawnArgsForWindows } from '../win32-utils' import { getCodexSessionBackfillStateDirPath } from './codex-home-paths' import { resolveCodexSessionBackfillPaths } from './codex-session-backfill' @@ -17,7 +18,8 @@ import { import { isCodexAppServerUnsupportedError, runCodexAppServerSession, - type CodexAppServerInvocation + type CodexAppServerInvocation, + type CodexAppServerRpc } from './codex-app-server-session' export type { CodexSessionIndexHealPaths } from './codex-session-index-heal-state' @@ -49,12 +51,20 @@ export type CodexSessionIndexHealSummary = { export type CodexSessionIndexHealOptions = { /** Polled between reads and batches; true stops promptly, progress is kept. */ shouldStop?: () => boolean - buildInvocation?: (systemCodexHomePath: string, timeoutMs: number) => CodexAppServerInvocation + buildInvocation?: (codexHomePath: string, timeoutMs: number) => CodexAppServerInvocation + runSession?: ( + invocation: CodexAppServerInvocation, + body: (rpc: CodexAppServerRpc) => Promise + ) => Promise readsPerServerSession?: number readConcurrency?: number interBatchDelayMs?: number } +export type CodexThreadReadOutcome = HealLedgerOutcome + +export type CodexThreadReadPassOutcome = 'completed' | 'stopped' | 'unsupported' | 'aborted' + let backgroundHealTask: Promise | null = null export function resolveCodexSessionIndexHealPaths( @@ -132,69 +142,30 @@ export async function runCodexSessionIndexHeal( return summary } - const systemCodexHomePath = dirname(paths.systemSessionsRoot) - const buildInvocation = options.buildInvocation ?? buildNativeHealInvocation - const readsPerServerSession = resolveHealWorkLimit( - options.readsPerServerSession, - HEAL_READS_PER_SERVER_SESSION - ) - const readConcurrency = resolveHealWorkLimit(options.readConcurrency, HEAL_READ_CONCURRENCY) - const interBatchDelayMs = options.interBatchDelayMs ?? HEAL_INTER_BATCH_DELAY_MS const shouldStop = options.shouldStop ?? ((): boolean => false) - - for (let offset = 0; offset < pending.length; offset += readsPerServerSession) { - if (shouldStop()) { - summary.outcome = 'stopped' - return summary - } - if (offset > 0 && interBatchDelayMs > 0) { - await new Promise((resolve) => setTimeout(resolve, interBatchDelayMs)) - if (shouldStop()) { - // Why: opt-out can happen during the throttle delay; do not spawn a - // real-home app-server after the lane has been disabled. - summary.outcome = 'stopped' - return summary + const outcome = await readCodexThreadsForIndexHeal( + dirname(paths.systemSessionsRoot), + pending, + (thread, readOutcome) => { + if (readOutcome === 'healed') { + summary.healedThreads += 1 + } else if (readOutcome === 'missing') { + // The backfilled rollout was deleted after the audit was written. + summary.missingThreads += 1 + } else { + summary.failedThreads += 1 } - } - const batch = pending.slice(offset, offset + readsPerServerSession) - const timeoutMs = HEAL_BATCH_TIMEOUT_BASE_MS + HEAL_BATCH_TIMEOUT_PER_READ_MS * batch.length - try { - await runCodexAppServerSession( - buildInvocation(systemCodexHomePath, timeoutMs), - async (rpc) => { - let nextIndex = 0 - const worker = async (): Promise => { - while (nextIndex < batch.length && !shouldStop()) { - const thread = batch[nextIndex] - nextIndex += 1 - await healOneThread(rpc, thread, paths, summary) - } - } - await Promise.all(Array.from({ length: readConcurrency }, () => worker())) - } - ) - } catch (error) { - if (isCodexAppServerUnsupportedError(error)) { - if (shouldStop()) { - summary.outcome = 'stopped' - return summary - } - // Why: no retry churn on old CLIs — remember unsupported and re-probe - // after the retry interval or a version bump; nothing is marked healed. - writeHealMarker(paths, auditBytes, summary, { unsupportedAt: Date.now() }) - summary.outcome = 'unsupported' - return summary - } - // Transport failure (timeout, early exit, spawn error): unprocessed ids - // were never appended to the ledger, so the next pass resumes them. - console.warn('[codex-session-index-heal] Heal batch aborted:', error) - summary.outcome = 'aborted' - return summary - } + recordHealOutcome(paths, thread, readOutcome) + }, + options + ) + if (outcome === 'unsupported') { + // Why: no retry churn on old CLIs — remember unsupported and re-probe + // after the retry interval or a version bump; nothing is marked healed. + writeHealMarker(paths, auditBytes, summary, { unsupportedAt: Date.now() }) } - - if (shouldStop()) { - summary.outcome = 'stopped' + if (outcome !== 'completed' || shouldStop()) { + summary.outcome = outcome === 'completed' ? 'stopped' : outcome return summary } writeHealMarker( @@ -206,16 +177,76 @@ export async function runCodexSessionIndexHeal( return summary } -async function healOneThread( - rpc: { request: (method: string, params?: Record) => Promise }, - thread: PendingHealThread, - paths: CodexSessionIndexHealPaths, - summary: CodexSessionIndexHealSummary -): Promise { +/** + * Drives `thread/read` over `threads` in bounded app-server batches, reporting + * each settled read. Transport failures and sqlite contention abort the pass + * without reporting, so the caller's next pass retries those threads. + */ +export async function readCodexThreadsForIndexHeal( + codexHomePath: string, + threads: readonly T[], + onOutcome: (thread: T, outcome: CodexThreadReadOutcome) => void, + options: CodexSessionIndexHealOptions = {} +): Promise { + const buildInvocation = options.buildInvocation ?? buildNativeHealInvocation + const runSession = options.runSession ?? runCodexAppServerSession + const readsPerServerSession = resolveHealWorkLimit( + options.readsPerServerSession, + HEAL_READS_PER_SERVER_SESSION + ) + const readConcurrency = resolveHealWorkLimit(options.readConcurrency, HEAL_READ_CONCURRENCY) + const interBatchDelayMs = options.interBatchDelayMs ?? HEAL_INTER_BATCH_DELAY_MS + const shouldStop = options.shouldStop ?? ((): boolean => false) + + for (let offset = 0; offset < threads.length; offset += readsPerServerSession) { + if (shouldStop()) { + return 'stopped' + } + if (offset > 0 && interBatchDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, interBatchDelayMs)) + if (shouldStop()) { + // Why: opt-out can happen during the throttle delay; do not spawn an + // app-server after the lane has been disabled. + return 'stopped' + } + } + const batch = threads.slice(offset, offset + readsPerServerSession) + const timeoutMs = HEAL_BATCH_TIMEOUT_BASE_MS + HEAL_BATCH_TIMEOUT_PER_READ_MS * batch.length + try { + await runSession(buildInvocation(codexHomePath, timeoutMs), async (rpc) => { + let nextIndex = 0 + const worker = async (): Promise => { + while (nextIndex < batch.length && !shouldStop()) { + const thread = batch[nextIndex] + nextIndex += 1 + onOutcome(thread, await readOneThread(rpc, thread.threadId)) + } + } + await Promise.all(Array.from({ length: readConcurrency }, () => worker())) + }) + } catch (error) { + if (shouldStop()) { + return 'stopped' + } + if (isCodexAppServerUnsupportedError(error)) { + return 'unsupported' + } + // Transport failure (timeout, early exit, spawn error): unprocessed ids + // were never reported, so the next pass resumes them. + console.warn('[codex-session-index-heal] Heal batch aborted:', error) + return 'aborted' + } + } + return 'completed' +} + +async function readOneThread( + rpc: CodexAppServerRpc, + threadId: string +): Promise { try { - await rpc.request('thread/read', { threadId: thread.threadId }) - summary.healedThreads += 1 - recordHealOutcome(paths, thread, 'healed') + await rpc.request('thread/read', { threadId }) + return 'healed' } catch (error) { if (isCodexAppServerUnsupportedError(error)) { throw error @@ -223,22 +254,18 @@ async function healOneThread( const message = error instanceof Error ? error.message : String(error) if (!message.startsWith('codex app-server thread/read failed')) { // Not an RPC-level response: the server died or timed out. Abort the - // batch without recording, so the id is retried on the next pass. + // batch without reporting, so the id is retried on the next pass. throw error } if (/no rollout found/i.test(message)) { - // The backfilled rollout was deleted after the audit was written. - summary.missingThreads += 1 - recordHealOutcome(paths, thread, 'missing') - return + return 'missing' } if (isTransientSqliteContention(message)) { - // Why: an active Codex process can briefly own sqlite; leave the id off - // the ledger and abort this pass so a later startup resumes it. + // Why: an active Codex process can briefly own sqlite; leave the id + // unreported and abort this pass so a later startup resumes it. throw error } - summary.failedThreads += 1 - recordHealOutcome(paths, thread, 'failed') + return 'failed' } } @@ -259,20 +286,23 @@ function resolveHealWorkLimit(value: number | undefined, maximum: number): numbe return Math.min(Math.floor(value), maximum) } -function buildNativeHealInvocation( - systemCodexHomePath: string, +export function buildNativeHealInvocation( + codexHomePath: string, timeoutMs: number ): CodexAppServerInvocation { const command = resolveCodexCommand() - const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, ['app-server']) + // Why: each session is torn down after one batch, so plugin startup could + // leave marketplace clones running; indexing needs neither plugins nor tools. + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, [ + ...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS + ]) return { command: spawnCmd, args: spawnArgs, cliPath: command, - // Why: pin the real home explicitly — nested Orca launches can inherit a - // managed CODEX_HOME from the daemon environment, which would index the - // wrong sqlite DB. - env: { CODEX_HOME: systemCodexHomePath }, + // Why: pin the home explicitly — nested Orca launches can inherit a managed + // CODEX_HOME from the daemon environment, which would index the wrong sqlite DB. + env: { CODEX_HOME: codexHomePath }, timeoutMs } } diff --git a/src/main/codex/codex-state-db-test-fixture.ts b/src/main/codex/codex-state-db-test-fixture.ts new file mode 100644 index 00000000000..e84f0328c3b --- /dev/null +++ b/src/main/codex/codex-state-db-test-fixture.ts @@ -0,0 +1,14 @@ +import { mkdirSync } from 'node:fs' +import { join } from 'node:path' +import SyncDatabase from '../sqlite/sync-database' + +/** Writes a Codex state DB whose startup backfill reports `status`. */ +export function writeCodexStateDbBackfillStatus(codexHomePath: string, status: string): void { + mkdirSync(codexHomePath, { recursive: true }) + const db = new SyncDatabase(join(codexHomePath, 'state_5.sqlite')) + db.exec( + 'CREATE TABLE backfill_state (id INTEGER PRIMARY KEY, status TEXT NOT NULL); ' + + `INSERT INTO backfill_state (id, status) VALUES (1, '${status}')` + ) + db.close() +} diff --git a/src/main/codex/codex-state-db.test.ts b/src/main/codex/codex-state-db.test.ts index 7cf0ac88ac5..a89a556b807 100644 --- a/src/main/codex/codex-state-db.test.ts +++ b/src/main/codex/codex-state-db.test.ts @@ -1,12 +1,13 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import SyncDatabase from '../sqlite/sync-database' import { findNewestCodexStateDbPath, isCodexStateDbBackfillPending, - readCodexStateDbBackfillStatus + readCodexStateDbBackfillStatus, + readIndexedCodexThreadIds } from './codex-state-db' const temporaryHomes: string[] = [] @@ -67,6 +68,19 @@ describe('Codex state DB backfill status', () => { expect(isCodexStateDbBackfillPending(home)).toBe(true) }) + it('counts compressed rollouts, which Codex also backfills', async () => { + const home = await createHome() + const sessions = join(home, 'sessions', '2026', '08', '04') + await mkdir(sessions, { recursive: true }) + await Promise.all( + Array.from({ length: 100 }, (_, index) => + writeFile(join(sessions, `rollout-${index}.jsonl.zst`), '') + ) + ) + + expect(isCodexStateDbBackfillPending(home)).toBe(true) + }) + it('does not call a complete backfill pending', async () => { const home = await createHome() createBackfillDb(home, 5, 'complete') @@ -74,3 +88,27 @@ describe('Codex state DB backfill status', () => { expect(isCodexStateDbBackfillPending(home)).toBe(false) }) }) + +describe('readIndexedCodexThreadIds', () => { + it('returns lower-cased thread ids from the newest state DB', async () => { + const home = await createHome() + const path = createBackfillDb(home, 5, 'complete') + const db = new SyncDatabase(path) + db.exec( + "CREATE TABLE threads (id TEXT PRIMARY KEY); INSERT INTO threads (id) VALUES ('ABC'), ('def')" + ) + db.close() + + expect(readIndexedCodexThreadIds(home)).toEqual(new Set(['abc', 'def'])) + }) + + it('returns null when there is no state DB or no threads table', async () => { + const home = await createHome() + expect(readIndexedCodexThreadIds(home)).toBeNull() + + createBackfillDb(home, 5, 'complete') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(readIndexedCodexThreadIds(home)).toBeNull() + warn.mockRestore() + }) +}) diff --git a/src/main/codex/codex-state-db.ts b/src/main/codex/codex-state-db.ts index e2b437aea3a..ae8ff3eb046 100644 --- a/src/main/codex/codex-state-db.ts +++ b/src/main/codex/codex-state-db.ts @@ -71,6 +71,37 @@ export function readCodexStateDbBackfillStatus(codexHomePath: string): CodexStat } } +/** + * Lower-cased thread ids already in Codex's state DB, or null when the DB is + * missing or unreadable. Read-only; never creates or mutates Codex's database. + */ +export function readIndexedCodexThreadIds(codexHomePath: string): Set | null { + const stateDbPath = findNewestCodexStateDbPath(codexHomePath) + if (!stateDbPath) { + return null + } + let db: SyncDatabase | null = null + try { + db = new SyncDatabase(stateDbPath, { readonly: true, fileMustExist: true }) + const ids = new Set() + for (const row of db.prepare('SELECT id FROM threads').all()) { + if (typeof row.id === 'string') { + ids.add(row.id.toLowerCase()) + } + } + return ids + } catch (error) { + console.warn('[codex-state-db] Failed to read indexed Codex threads:', error) + return null + } finally { + try { + db?.close() + } catch { + // A close failure cannot change the read-only result already collected. + } + } +} + export function countCodexSessionFilesUpTo(sessionsRoot: string, limit: number): number { let count = 0 const pendingDirectories = [sessionsRoot] @@ -85,7 +116,11 @@ export function countCodexSessionFilesUpTo(sessionsRoot: string, limit: number): for (const entry of entries) { if (entry.isDirectory()) { pendingDirectories.push(join(directory, entry.name)) - } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + } else if ( + entry.isFile() && + // Why: Codex's startup backfill parses compressed rollouts too. + (entry.name.endsWith('.jsonl') || entry.name.endsWith('.jsonl.zst')) + ) { count += 1 if (count >= limit) { break diff --git a/src/main/codex/codex-structured-child-environment.test.ts b/src/main/codex/codex-structured-child-environment.test.ts index 201efc0b0c5..4dcd49e9645 100644 --- a/src/main/codex/codex-structured-child-environment.test.ts +++ b/src/main/codex/codex-structured-child-environment.test.ts @@ -1,7 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { openCodexAppServerConnection } from './codex-app-server-connection' import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity' import { buildCodexStructuredChildEnvironment } from './codex-structured-child-environment' -import { ORCA_STRUCTURED_SESSION_ENV } from '../../shared/structured-session-marker' import { mintStructuredWorkerHandle, mintStructuredWorkerPaneKey, @@ -9,6 +9,10 @@ import { structuredWorkerProcessIncarnation } from '../runtime/structured-worker-identity' +const DEV_CLI_BIN_FIRST = /^[^:;]*[\\/]cli[\\/]bin[:;]/ +// The dev launcher by absolute path: a login shell's profile cannot reorder it behind a global. +const DEV_CLI_LAUNCHER = /^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/ + describe('buildCodexStructuredChildEnvironment', () => { it('keeps shell exports while pinned launch values win', () => { expect( @@ -28,11 +32,16 @@ describe('buildCodexStructuredChildEnvironment', () => { EXAMPLE_GATEWAY_TOKEN: 'shell-exported', CODEX_HOME: '/pinned/home', [CODEX_SPAWN_TOKEN_ENV]: 'spawn-token', - [ORCA_STRUCTURED_SESSION_ENV]: '1' + ORCA_AGENT_SESSION_ID: 'session-not-a-worker', + ORCA_STRUCTURED_SESSION: '1', + ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER), + ORCA_USER_DATA_PATH: expect.any(String), + // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. + PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) }) }) - it('adds the orchestration handle only for a registered structured worker', () => { + it('names every session by its id, and adds the handle only for a registered worker', () => { const launch = { command: 'codex', args: ['app-server'], @@ -44,8 +53,12 @@ describe('buildCodexStructuredChildEnvironment', () => { const sessionId = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' expect(buildCodexStructuredChildEnvironment(launch, 'spawn-token', sessionId)).toEqual({ [CODEX_SPAWN_TOKEN_ENV]: 'spawn-token', - // No identity yet, so the child carries only the refuse-rather-than-guess marker. - [ORCA_STRUCTURED_SESSION_ENV]: '1' + // Not a worker, so no handle: the id alone names this chat as a caller. + ORCA_AGENT_SESSION_ID: sessionId, + ORCA_STRUCTURED_SESSION: '1', + ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER), + ORCA_USER_DATA_PATH: expect.any(String), + PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) }) const handle = mintStructuredWorkerHandle() @@ -61,7 +74,8 @@ describe('buildCodexStructuredChildEnvironment', () => { try { const env = buildCodexStructuredChildEnvironment(launch, 'spawn-token', sessionId) expect(env.ORCA_TERMINAL_HANDLE).toBe(handle) - expect(env.ORCA_CLI_COMMAND).toBe('orca') + expect(env.ORCA_AGENT_SESSION_ID).toBe(sessionId) + expect(env.ORCA_CLI_COMMAND).toMatch(DEV_CLI_LAUNCHER) // A pane key here would leak into hook-emitted agent statuses, which assume a PTY leaf. expect(env.ORCA_PANE_KEY).toBeUndefined() } finally { @@ -69,3 +83,62 @@ describe('buildCodexStructuredChildEnvironment', () => { } }) }) + +/** A real child speaking Codex's JSONL framing, answering with the environment it was spawned with. */ +const ENV_REPORTING_APP_SERVER = String.raw` + const readline = require('node:readline') + const send = (payload) => process.stdout.write(JSON.stringify(payload) + '\n') + readline.createInterface({ input: process.stdin }).on('line', (line) => { + const message = JSON.parse(line) + if (message.method === 'initialize') return send({ id: message.id, result: {} }) + if (message.method === 'test/env') { + return send({ + id: message.id, + result: { + sessionId: process.env.ORCA_AGENT_SESSION_ID ?? null, + cliCommand: process.env.ORCA_CLI_COMMAND ?? null, + path: process.env.PATH ?? process.env.Path ?? null + } + }) + } + }) +` + +describe('the spawned Codex child', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it("runs with its own session id and this app's CLI, over an id inherited by Orca itself", async () => { + // The builder's output is an overlay on process.env, so only the spawned child proves the id + // survives the merge — an Orca launched inside another session inherits that session's id. + vi.stubEnv('ORCA_AGENT_SESSION_ID', 'a0b1c2d3-0000-4000-8000-00000000abcd') + const sessionId = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' + const env = buildCodexStructuredChildEnvironment( + { + command: process.execPath, + args: ['-e', ENV_REPORTING_APP_SERVER], + cwd: process.cwd(), + codexHome: null, + resumeThreadId: null, + env: {} + }, + 'spawn-token', + sessionId + ) + const connection = await openCodexAppServerConnection({ + command: process.execPath, + args: ['-e', ENV_REPORTING_APP_SERVER], + env + }) + try { + await expect(connection.request('test/env')).resolves.toEqual({ + sessionId, + cliCommand: expect.stringMatching(DEV_CLI_LAUNCHER), + path: expect.stringMatching(DEV_CLI_BIN_FIRST) + }) + } finally { + await connection.close() + } + }) +}) diff --git a/src/main/codex/codex-structured-child-environment.ts b/src/main/codex/codex-structured-child-environment.ts index 72bf17a1bce..38ea91abe26 100644 --- a/src/main/codex/codex-structured-child-environment.ts +++ b/src/main/codex/codex-structured-child-environment.ts @@ -1,6 +1,6 @@ import type { CodexStructuredLaunch } from './codex-structured-session-state' import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity' -import { structuredWorkerChildIdentityEnv } from '../runtime/structured-worker-child-identity-env' +import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env' export function buildCodexStructuredChildEnvironment( launch: CodexStructuredLaunch, @@ -8,9 +8,8 @@ export function buildCodexStructuredChildEnvironment( sessionId: string ): Record { return { - // Only a dispatched structured worker gets the orchestration identity and the Orca CLI on - // PATH; an ordinary chat session's env passes through untouched. - ...structuredWorkerChildIdentityEnv(sessionId, { + // Every structured session speaks orchestration as itself: its injected id and the Orca CLI. + ...structuredSessionChildIdentityEnv(sessionId, { ...launch.env, ...(launch.codexHome ? { CODEX_HOME: launch.codexHome } : {}) }), diff --git a/src/main/codex/codex-structured-child-work-producer.test.ts b/src/main/codex/codex-structured-child-work-producer.test.ts new file mode 100644 index 00000000000..611b7f75b57 --- /dev/null +++ b/src/main/codex/codex-structured-child-work-producer.test.ts @@ -0,0 +1,530 @@ +// A Codex session's frames, through the real adapter, into the host's child records: the order the +// host receives them in, and whether the parent row the records imply is today's row. + +import { describe, expect, it } from 'vitest' +import { + foldAgentLeadStatus, + type AgentLeadStatusResolution +} from '../../shared/agent-lead-status-fold' +import { createAgentChildWorkAdmission } from '../../shared/agent-status-child-work-admission' +import type { AgentChildWorkRecord } from '../../shared/agent-status-child-work' +import { agentChildWorkLiveness } from '../../shared/agent-status-child-work-liveness' +import { reconcileAgentChildWorkEvidence } from '../../shared/agent-status-child-work-reconciliation' +import { + agentChildWorkOwnedLiveness, + deriveAgentChildDisplayState +} from '../../shared/agent-status-child-work-display' +import { projectAgentChildWorkViews } from '../../shared/agent-status-child-work-view' +import { createAgentStatusStore } from '../../shared/agent-status-store' +import { agentJournalLinkageFields } from '../../shared/agent-session-journal-producer' +import type { + AgentJournalItemBody, + AgentJournalProducerLinkage +} from '../../shared/agent-session-journal-types' +import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { fakeCodex, identityFor, THREAD_ID } from './codex-structured-session-adapter-fixture' +import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' + +const parent = makeStructuredAgentStatusSubject( + { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + 'session-1' +) +const REVIEWER = 'thread-reviewer' +const TESTER = 'thread-tester' +const LINTER = 'thread-linter' + +type Frame = { method: string; params: Record } +type Delivery = { kind: 'journal' | 'legacy' | 'evidence'; detail: string } +type Liveness = ReturnType +/** What the records and today's strip each said when the journal wrote or published a row. */ +type JournalMoment = { recorded: Liveness; legacy: Liveness } + +const turn = ( + method: 'turn/started' | 'turn/completed', + threadId: string, + id: string, + status = 'completed' +): Frame => ({ + method, + params: { threadId, turn: { id, status } } +}) +const spawned = (child: string, name: string, parentTurn: string): Frame => ({ + method: 'item/started', + params: { + threadId: THREAD_ID, + turnId: parentTurn, + item: { + type: 'subAgentActivity', + id: `spawn-${child}`, + kind: 'started', + agentThreadId: child, + agentPath: `/root/${name}` + } + } +}) +const item = ( + method: 'item/started' | 'item/completed', + threadId: string, + turnId: string, + fields: Record +): Frame => ({ + method, + params: { threadId, turnId, item: fields } +}) +const status = (threadId: string, activeFlags: string[]): Frame => ({ + method: 'thread/status/changed', + params: { threadId, status: { type: 'active', activeFlags } } +}) + +async function producer() { + const codex = fakeCodex() + const store = createAgentStatusStore({ epoch: 'epoch-1', mode: 'authority' }) + expect(store.applyMutation({ parent: { subject: parent } })).not.toBeNull() + let minted = 0 + const admission = createAgentChildWorkAdmission(store, { + mintChildWorkId: () => `child-${++minted}` + }) + const deliveries: Delivery[] = [] + const moments: JournalMoment[] = [] + const records = (): AgentChildWorkRecord[] => store.getChildren(parent) + const recordedLiveness = () => + agentChildWorkLiveness(records().filter((record) => record.membership === 'live')) + // Each journal write publishes the parent's row, so the records must imply its state right then. + const moment = () => + moments.push({ + recorded: recordedLiveness(), + legacy: agentChildWorkLiveness(adapter.backgroundTaskState('session-1')?.tasks) + }) + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => ({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: null + }), + openConnection: codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => 1_700_000_000_500, + onBackgroundTasksChanged: (_sessionId, state) => + deliveries.push({ kind: 'legacy', detail: String(state?.tasks?.length ?? 0) }), + onChildWorkEvidence: (sessionId, evidence) => { + expect(sessionId).toBe('session-1') + deliveries.push({ kind: 'evidence', detail: evidence.map((edge) => edge.type).join(',') }) + reconcileAgentChildWorkEvidence({ store, admission, parent, provider: 'codex', evidence }) + } + }) + const rows: { body: AgentJournalItemBody; linkage: AgentJournalProducerLinkage }[] = [] + const journal: StructuredAgentSessionEventSink = { + appendItem: (identity, body, options) => { + deliveries.push({ kind: 'journal', detail: JSON.stringify(identity) }) + rows.push({ body, linkage: agentJournalLinkageFields(options) }) + moment() + }, + appendTombstone: () => {}, + publish: moment + } + await adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9', + events: journal + }) + const send = (frame: Frame): Delivery[] => { + const from = deliveries.length + codex.connections[0]!.handlers.onNotification?.(frame.method, frame.params) + return deliveries.slice(from) + } + /** The journal moments one frame produced. */ + const momentsOf = (frame: Frame): JournalMoment[] => { + const from = moments.length + send(frame) + return moments.slice(from) + } + const byDescription = (description: string) => + records().find((record) => record.description === description) + const display = (description: string) => { + const children = records() + const views = projectAgentChildWorkViews( + children, + children.flatMap((child) => store.getAliasesForChild(child.childWorkId)) + ) + const view = views.find((candidate) => candidate.description === description) + return view && deriveAgentChildDisplayState(view, agentChildWorkOwnedLiveness(views, view.id)) + } + /** The producer stamp on the newest journal row that carries this text. */ + const stampOf = (text: string) => + rows.findLast((row) => JSON.stringify(row.body).includes(text))?.linkage + return { + adapter, + codex, + send, + momentsOf, + records, + recordedLiveness, + byDescription, + display, + stampOf + } +} + +const fold = ( + leadState: 'working' | 'done', + childWorkLiveness: Liveness +): AgentLeadStatusResolution => foldAgentLeadStatus({ leadState, childWorkLiveness }) +/** The strip has no word for a child waiting on a human: to it, that child is working. */ +const asStrip = (liveness: Liveness): Liveness => (liveness === 'waiting' ? 'working' : liveness) +const shellFrame = ( + method: 'item/started' | 'item/completed', + threadId: string, + turnId: string, + id: string, + command: string, + source = 'unifiedExecStartup' +): Frame => + item(method, threadId, turnId, { + type: 'commandExecution', + id, + command, + source, + status: method === 'item/started' ? 'inProgress' : 'completed', + ...(method === 'item/completed' ? { exitCode: 0 } : {}) + }) + +describe('Codex structured child-work producer', () => { + it('delivers evidence only after the journal wrote the frame and the legacy row republished', async () => { + const { send, records } = await producer() + send(turn('turn/started', THREAD_ID, 'p1')) + send(turn('turn/started', REVIEWER, 'r1')) + const deliveries = send(spawned(REVIEWER, 'review', 'p1')) + const kinds = deliveries.map((delivery) => delivery.kind) + // The frame's own rows, then the parent's republished row, and only then its children. + expect(kinds.filter((kind) => kind === 'journal').length).toBeGreaterThan(0) + expect(kinds.slice(kinds.indexOf('legacy'))).toEqual(['legacy', 'evidence']) + expect(records()).toEqual([ + expect.objectContaining({ description: 'review', membership: 'live' }) + ]) + }) + + it('records the parent state today reads, at every journal write, while adding outcome and activity', async () => { + const { adapter, momentsOf, records, recordedLiveness, byDescription, display } = + await producer() + const steps: { + frame: Frame + lead: 'working' | 'done' + childWaits?: true + check?: () => void + }[] = [ + { frame: turn('turn/started', THREAD_ID, 'p1'), lead: 'working' }, + // Codex reports the child's turn before its announcement. + { + frame: turn('turn/started', REVIEWER, 'r1'), + lead: 'working', + check: () => expect(records()).toEqual([]) + }, + { frame: spawned(REVIEWER, 'review', 'p1'), lead: 'working' }, + // An approved command: Codex starts it on the approval path and reports its exit from + // unified exec. + { + frame: shellFrame('item/started', REVIEWER, 'r1', 'cmd-1', 'npm test', 'agent'), + lead: 'working', + check: () => { + expect(byDescription('review')?.operation).toMatchObject({ + toolName: 'Bash', + input: 'npm test', + basis: 'open' + }) + expect(byDescription('npm test')).toMatchObject({ + membership: 'live', + parentChildWorkId: byDescription('review')?.childWorkId + }) + } + }, + // The child starts a dev server it will leave running past its own turn. + { + frame: shellFrame('item/started', REVIEWER, 'r1', 'exec-1', 'npm run dev'), + lead: 'working', + check: () => + expect(byDescription('npm run dev')).toMatchObject({ + membership: 'live', + parentChildWorkId: byDescription('review')?.childWorkId + }) + }, + { + frame: shellFrame('item/completed', REVIEWER, 'r1', 'cmd-1', 'npm test'), + lead: 'working', + check: () => { + // A finished command leaves nothing behind. + expect(byDescription('npm test')).toBeUndefined() + // The dev server is still the child's open call while its turn runs. + expect(byDescription('review')?.operation).toMatchObject({ + toolName: 'Bash', + input: 'npm run dev' + }) + } + }, + { + frame: item('item/completed', REVIEWER, 'r1', { + type: 'agentMessage', + id: 'msg-1', + text: 'Dev server is up' + }), + lead: 'working' + }, + // The parent's turn ends first; its child keeps running. + { + frame: turn('turn/completed', THREAD_ID, 'p1'), + lead: 'done', + check: () => + expect(byDescription('review')).toMatchObject({ membership: 'live', state: 'working' }) + }, + // The legacy task list carries no child state, so only the records can say a child waits, + // and the shared fold ranks that wait above the parent's own state. + { + frame: status(REVIEWER, ['waitingOnApproval']), + lead: 'done', + childWaits: true, + check: () => { + expect(byDescription('review')?.state).toBe('waiting') + expect(agentChildWorkLiveness(adapter.backgroundTaskState('session-1')?.tasks)).toBe( + 'working' + ) + } + }, + { frame: status(REVIEWER, []), lead: 'done' }, + { + frame: turn('turn/completed', REVIEWER, 'r1'), + lead: 'done', + check: () => { + expect(byDescription('review')).toMatchObject({ + membership: 'settled', + outcome: 'succeeded', + lastMessage: 'Dev server is up' + }) + expect(byDescription('npm run dev')).toMatchObject({ + membership: 'live', + parentChildWorkId: byDescription('review')?.childWorkId + }) + // Finished, but a shell it launched still runs: the CLI parent rule reads monitoring. + expect(display('review')).toBe('monitoring') + } + }, + { frame: turn('turn/started', THREAD_ID, 'p2'), lead: 'working' }, + // The parent asks the finished child a follow-up: the same record, a new run. + { + frame: turn('turn/started', REVIEWER, 'r2'), + lead: 'working', + check: () => + expect(byDescription('review')).toMatchObject({ + childWorkId: 'child-1', + membership: 'live', + invocation: { invocationId: 'r2', generation: 2 }, + previousInvocations: [expect.objectContaining({ outcome: 'succeeded' })] + }) + }, + { frame: spawned(TESTER, 'test', 'p2'), lead: 'working' }, + { frame: turn('turn/started', TESTER, 't1'), lead: 'working' }, + { frame: spawned(LINTER, 'lint', 'p2'), lead: 'working' }, + { frame: turn('turn/started', LINTER, 'l1'), lead: 'working' }, + // Codex ends this child's turn with an error it will not retry, then a failed completion. + { + frame: { + method: 'error', + params: { threadId: LINTER, turnId: 'l1', willRetry: false, error: { message: 'boom' } } + }, + lead: 'working', + check: () => expect(byDescription('lint')).toMatchObject({ membership: 'live' }) + }, + { + frame: turn('turn/completed', LINTER, 'l1', 'failed'), + lead: 'working', + check: () => + expect(byDescription('lint')).toMatchObject({ membership: 'settled', outcome: 'failed' }) + }, + { + frame: turn('turn/completed', REVIEWER, 'r2', 'interrupted'), + lead: 'working', + check: () => + expect(byDescription('review')).toMatchObject({ + membership: 'settled', + outcome: 'cancelled' + }) + }, + { frame: turn('turn/completed', THREAD_ID, 'p2'), lead: 'done' }, + { + frame: turn('turn/completed', TESTER, 't1', 'failed'), + lead: 'done', + check: () => + expect(byDescription('test')).toMatchObject({ membership: 'settled', outcome: 'failed' }) + }, + { + frame: shellFrame('item/completed', REVIEWER, 'r1', 'exec-1', 'npm run dev'), + lead: 'done', + check: () => { + expect(byDescription('npm run dev')).toBeUndefined() + expect(display('review')).toBe('interrupted') + } + } + ] + let journalMoments = 0 + for (const [index, step] of steps.entries()) { + const frameMoments = momentsOf(step.frame) + journalMoments += frameMoments.length + for (const [at, { recorded, legacy }] of frameMoments.entries()) { + expect({ index, at, parent: fold(step.lead, asStrip(recorded)) }).toEqual({ + index, + at, + parent: fold(step.lead, legacy) + }) + } + const legacy = agentChildWorkLiveness(adapter.backgroundTaskState('session-1')?.tasks) + const recorded = recordedLiveness() + const expected = step.childWaits ? 'waiting' : legacy + expect({ index, parent: fold(step.lead, recorded) }).toEqual({ + index, + parent: fold(step.lead, expected) + }) + expect({ index, liveness: recorded }).toEqual({ index, liveness: expected }) + step.check?.() + } + expect(journalMoments).toBeGreaterThan(steps.length) + const settled = records() + await adapter.closeSession('session-1') + // Every child had already ended; closing the session changes none of what they said. + expect(records()).toEqual(settled) + expect( + records().map(({ description, membership, outcome }) => ({ + description, + membership, + outcome + })) + ).toEqual([ + { description: 'review', membership: 'settled', outcome: 'cancelled' }, + { description: 'test', membership: 'settled', outcome: 'failed' }, + { description: 'lint', membership: 'settled', outcome: 'failed' } + ]) + expect(adapter.backgroundTaskState('session-1')).toBeUndefined() + }) + + it("never reads done while the main agent's own shell runs past its turn", async () => { + const { momentsOf, send, recordedLiveness } = await producer() + send(turn('turn/started', THREAD_ID, 'p1')) + send(shellFrame('item/started', THREAD_ID, 'p1', 'exec-dev', 'npm run dev')) + const monitoring = { stateName: 'working', workingMode: 'monitoring' } + // The turn ends with the dev server running: straight to monitoring, never done in between. + const turnEnd = momentsOf(turn('turn/completed', THREAD_ID, 'p1')) + expect(turnEnd.length).toBeGreaterThan(0) + for (const { recorded } of turnEnd) { + expect(fold('done', recorded)).toEqual(monitoring) + } + expect(fold('done', recordedLiveness())).toEqual(monitoring) + momentsOf(shellFrame('item/completed', THREAD_ID, 'p1', 'exec-dev', 'npm run dev')) + expect(fold('done', recordedLiveness())).toEqual({ stateName: 'done' }) + }) + + it('ends an approval left unanswered when its turn ends: Codex never ran the command', async () => { + const { adapter, codex, send, records, recordedLiveness, display, byDescription } = + await producer() + const approve = (threadId: string, turnId: string, itemId: string, command: string) => { + // The approval path starts the item before it asks, and drops the question at turn end. + send(shellFrame('item/started', threadId, turnId, itemId, command, 'agent')) + codex.connections[0]!.handlers.onServerRequest?.({ + id: `approval-${itemId}`, + method: 'item/commandExecution/requestApproval', + params: { itemId, threadId, turnId } + }) + } + send(turn('turn/started', THREAD_ID, 'p1')) + send(turn('turn/started', REVIEWER, 'r1')) + send(spawned(REVIEWER, 'review', 'p1')) + approve(REVIEWER, 'r1', 'call-child', 'npm run e2e') + approve(THREAD_ID, 'p1', 'call-main', 'npm run dev') + expect(records().filter((record) => record.kind === 'command')).toHaveLength(2) + // The user stops the child, then the main agent, each at its approval. + send(turn('turn/completed', REVIEWER, 'r1', 'interrupted')) + expect(byDescription('npm run e2e')).toBeUndefined() + expect(display('review')).toBe('interrupted') + send(turn('turn/completed', THREAD_ID, 'p1', 'interrupted')) + expect(byDescription('npm run dev')).toBeUndefined() + expect(adapter.backgroundTaskState('session-1')).toBeNull() + expect(fold('done', recordedLiveness())).toEqual({ stateName: 'done' }) + }) + + it('keeps an answered approval running past its turn', async () => { + const { adapter, codex, send, byDescription } = await producer() + send(turn('turn/started', THREAD_ID, 'p1')) + send(shellFrame('item/started', THREAD_ID, 'p1', 'call-1', 'npm run dev', 'agent')) + codex.connections[0]!.handlers.onServerRequest?.({ + id: 'approval-1', + method: 'item/commandExecution/requestApproval', + params: { itemId: 'call-1', threadId: THREAD_ID, turnId: 'p1' } + }) + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'call-1', + kind: 'approval', + response: { kind: 'option', optionId: 'accept' }, + fence: 7, + commit: async () => {} + }) + send(turn('turn/completed', THREAD_ID, 'p1')) + expect(byDescription('npm run dev')).toMatchObject({ membership: 'live' }) + expect(adapter.backgroundTaskState('session-1')?.tasks).toEqual([ + expect.objectContaining({ kind: 'command', description: 'npm run dev' }) + ]) + }) + + it("numbers a child's runs as the journal does: a row's attempt is its record's generation", async () => { + const { send, byDescription, stampOf } = await producer() + const says = (turnId: string, text: string) => + item('item/completed', REVIEWER, turnId, { type: 'agentMessage', id: `msg-${text}`, text }) + // The journal stamps a child row with its run only once it is past the first. + const runs = (text: string) => { + const stamp = stampOf(text) + return { + agentId: stamp?.agentId, + attempt: stamp ? (stamp.attempt ?? 1) : undefined, + generation: byDescription('review')?.invocation.generation + } + } + send(turn('turn/started', THREAD_ID, 'p1')) + // Codex reports the child's first turn before the spawn that announces it. + send(turn('turn/started', REVIEWER, 'r1')) + send(spawned(REVIEWER, 'review', 'p1')) + send(says('r1', 'run 1')) + expect(runs('run 1')).toEqual({ agentId: REVIEWER, attempt: 1, generation: 1 }) + send(turn('turn/completed', REVIEWER, 'r1')) + // Each follow-up the parent sends is the child's next run, on both sides. + for (const run of [2, 3]) { + send(turn('turn/started', REVIEWER, `r${run}`)) + send(says(`r${run}`, `run ${run}`)) + expect(runs(`run ${run}`)).toEqual({ agentId: REVIEWER, attempt: run, generation: run }) + send(turn('turn/completed', REVIEWER, `r${run}`)) + } + }) + + it('settles a live child with no reported outcome when the provider exits unexpectedly', async () => { + const { codex, send, records } = await producer() + send(turn('turn/started', THREAD_ID, 'p1')) + send(spawned(REVIEWER, 'review', 'p1')) + send(turn('turn/started', REVIEWER, 'r1')) + expect(records()).toEqual([ + expect.objectContaining({ description: 'review', membership: 'live', state: 'working' }) + ]) + codex.connections[0]!.handlers.onExit?.(new Error('provider exited')) + expect(records()).toEqual([ + expect.objectContaining({ + description: 'review', + membership: 'settled', + state: 'done', + outcome: 'unknown' + }) + ]) + }) +}) diff --git a/src/main/codex/codex-structured-compaction-refusal.test.ts b/src/main/codex/codex-structured-compaction-refusal.test.ts new file mode 100644 index 00000000000..fa8800f9605 --- /dev/null +++ b/src/main/codex/codex-structured-compaction-refusal.test.ts @@ -0,0 +1,42 @@ +// A compaction Codex refuses up front keeps Codex's own words for the chat's failure row. + +import { describe, expect, it } from 'vitest' +import { structuredAgentSessionCommandTurn } from '../native-chat/agent-session-wire/structured-agent-session-command-turn' +import { CodexAppServerRequestError } from './codex-app-server-connection' +import { acquired, fakeCodex } from './codex-structured-session-adapter-fixture' + +describe('Codex compaction refused at the request', () => { + it("carries Codex's message as the detail, apart from Orca's wrapper", async () => { + const codex = fakeCodex({ + 'thread/compact/start': () => { + throw new CodexAppServerRequestError( + 'thread/compact/start', + -32600, + 'codex app-server thread/compact/start failed: thread has nothing to compact', + 'thread has nothing to compact' + ) + } + }) + const adapter = await acquired(codex) + const turn = structuredAgentSessionCommandTurn('cmd-1') + + await expect( + adapter.compact({ + sessionId: 'session-1', + fence: 7, + command: { + clientMessageId: 'cmd-1', + ...turn, + running: { kind: 'turn', turnId: turn.turnId, state: 'running' } + } + }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'The provider did not accept this message: thread has nothing to compact.', + rejection: { + kind: 'providerRejected', + detail: { text: 'thread has nothing to compact', audience: 'person' } + } + }) + }) +}) diff --git a/src/main/codex/codex-structured-conversation-stop.test.ts b/src/main/codex/codex-structured-conversation-stop.test.ts new file mode 100644 index 00000000000..5d8a9c5627f --- /dev/null +++ b/src/main/codex/codex-structured-conversation-stop.test.ts @@ -0,0 +1,100 @@ +// A Codex Stop that names no turn. The fake keeps Codex 0.157's turn bookkeeping: it answers +// `turn/start` before it opens the turn, and refuses an interrupt until then. + +import { describe, expect, it, vi } from 'vitest' +import { structuredAgentSessionCommandTurn } from '../native-chat/agent-session-wire/structured-agent-session-command-turn' +import { + CODEX_TEST_THREAD_ID, + codexTurnLifecycleRig +} from './codex-structured-dispatch-test-support' + +type Rig = Awaited> + +async function openedTurn(rig: Rig): Promise { + const sending = rig.send('client-1') + await vi.waitFor(() => expect(rig.turns.turnId).toBe('turn-1')) + rig.turns.start() + await sending +} + +const stop = (rig: Rig, resolveLiveTurnId?: () => string | null) => + rig.adapter.cancelTurn({ + sessionId: 'session-1', + fence: 7, + ...(resolveLiveTurnId ? { resolveLiveTurnId } : {}) + }) + +describe('a Codex Stop that names no turn', () => { + it('interrupts the turn Codex opened when the journal shows none yet', async () => { + const rig = await codexTurnLifecycleRig() + await openedTurn(rig) + + await expect(stop(rig, () => null)).resolves.toEqual({ cancelled: true }) + expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-1']) + }) + + it('interrupts no turn that already ended', async () => { + const rig = await codexTurnLifecycleRig() + await openedTurn(rig) + rig.turns.end('completed') + + await expect(stop(rig)).resolves.toEqual({ cancelled: false }) + expect(rig.interrupts()).toEqual([]) + }) + + it("names the journal's turn over the one it saw open, and carries Codex's refusal", async () => { + const rig = await codexTurnLifecycleRig() + await openedTurn(rig) + + await expect(stop(rig, () => 'turn-journal')).resolves.toEqual({ + cancelled: false, + refusal: { + detail: { + text: 'expected active turn id turn-journal but found turn-1', + audience: 'person' + } + } + }) + expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-journal']) + }) + + it('interrupts nothing for another fence', async () => { + const rig = await codexTurnLifecycleRig() + await openedTurn(rig) + + await expect(rig.adapter.cancelTurn({ sessionId: 'session-1', fence: 6 })).resolves.toEqual({ + cancelled: false + }) + expect(rig.interrupts()).toEqual([]) + }) + + it('interrupts no earlier turn while a compaction the journal shows has not started', async () => { + const rig = await codexTurnLifecycleRig() + await openedTurn(rig) + rig.turns.end('completed') + const turn = structuredAgentSessionCommandTurn('operation-1') + const compaction = rig.adapter.compact({ + sessionId: 'session-1', + fence: 7, + command: { + clientMessageId: 'operation-1', + ...turn, + running: { kind: 'turn', turnId: turn.turnId, state: 'running' } + } + }) + await vi.waitFor(() => + expect(rig.codex.connections[0]!.calls.at(-1)?.method).toBe('thread/compact/start') + ) + + await expect(stop(rig, () => turn.turnId)).resolves.toEqual({ cancelled: false }) + expect(rig.interrupts()).toEqual([]) + + rig.notify('turn/started', { threadId: CODEX_TEST_THREAD_ID, turn: { id: 'turn-compact' } }) + rig.notify('thread/compacted', { threadId: CODEX_TEST_THREAD_ID }) + rig.notify('turn/completed', { + threadId: CODEX_TEST_THREAD_ID, + turn: { id: 'turn-compact', status: 'completed' } + }) + await expect(compaction).resolves.toEqual({ state: 'accepted', providerIdentity: null }) + }) +}) diff --git a/src/main/codex/codex-structured-dispatch-admission.test.ts b/src/main/codex/codex-structured-dispatch-admission.test.ts index 5fc811b9382..b3c8a3fefeb 100644 --- a/src/main/codex/codex-structured-dispatch-admission.test.ts +++ b/src/main/codex/codex-structured-dispatch-admission.test.ts @@ -7,6 +7,7 @@ import { acquiredCodexAdapter, echoUserMessage, fakeCodexAppServer, + openAfterTurnStarts, startTurn, CODEX_TEST_THREAD_ID, CODEX_TEST_USER_MESSAGE, @@ -140,7 +141,12 @@ describe('codex dispatch admission', () => { const { CodexAppServerRequestError } = await import('./codex-app-server-connection') const codex = fakeCodexAppServer({ 'turn/start': () => { - throw new CodexAppServerRequestError('turn/start', -32602, 'thread not found') + throw new CodexAppServerRequestError( + 'turn/start', + -32602, + 'codex app-server turn/start failed: thread not found', + 'thread not found' + ) } }) const settlements: LateSettlement[] = [] @@ -148,9 +154,14 @@ describe('codex dispatch admission', () => { const connection = codex.connections[0]! startTurn(connection, 'turn-1') + // Codex's own words reach the sentence and the fact; Orca's prefix reaches neither. expect(await send(adapter, 'client-1')).toEqual({ state: 'rejected', - reason: 'thread not found' + reason: 'The provider did not accept this message: thread not found.', + rejection: { + kind: 'providerRejected', + detail: { text: 'thread not found', audience: 'person' } + } }) // A refused write is disarmed, so a later echo of that id settles nothing. @@ -205,8 +216,9 @@ describe('codex dispatch admission', () => { await expect(send(adapter, 'client-unknown', 1_700_000_000_100)).rejects.toThrow( 'request timed out after write' ) - await send(adapter, 'client-later', 1_700_000_000_400) - startTurn(connection, 'turn-later') + const later = send(adapter, 'client-later', 1_700_000_000_400) + await openAfterTurnStarts(connection, 2, () => startTurn(connection, 'turn-later')) + await later echoUserMessage(connection, { turnId: 'turn-later', itemId: 'item-later', @@ -269,9 +281,10 @@ describe('codex dispatch admission', () => { const adapter = await acquiredCodexAdapter({ codex, settlements, sink: recorded.sink }) const connection = codex.connections[0]! - await send(adapter, 'client-opening', 1_700_000_000_600) - await send(adapter, 'client-queued', 1_700_000_000_200) - startTurn(connection, 'turn-1') + const opening = send(adapter, 'client-opening', 1_700_000_000_600) + const queued = send(adapter, 'client-queued', 1_700_000_000_200) + await openAfterTurnStarts(connection, 2, () => startTurn(connection, 'turn-1')) + await Promise.all([opening, queued]) await send(adapter, 'client-mid-turn', 1_700_000_000_100) echoUserMessage(connection, { @@ -315,8 +328,9 @@ describe('codex dispatch admission', () => { const adapter = await acquiredCodexAdapter({ codex, settlements, sink: recorded.sink }) const connection = codex.connections[0]! - await send(adapter, 'client-late-echo', 1_700_000_000_100) - startTurn(connection, 'turn-1') + const sending = send(adapter, 'client-late-echo', 1_700_000_000_100) + await openAfterTurnStarts(connection, 1, () => startTurn(connection, 'turn-1')) + await sending connection.handlers.onNotification?.('turn/completed', { threadId: CODEX_TEST_THREAD_ID, turn: { id: 'turn-1' } @@ -347,7 +361,8 @@ describe('codex dispatch admission', () => { } expect(await send(adapter, 'client-overflow')).toEqual({ state: 'rejected', - reason: 'codex structured dispatch queue is full' + reason: 'codex structured dispatch queue is full', + rejection: { kind: 'queueFull' } }) echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u0', clientId: 'client-0' }) diff --git a/src/main/codex/codex-structured-dispatch-echo.test.ts b/src/main/codex/codex-structured-dispatch-echo.test.ts index 94826c1df12..a6791e5e788 100644 --- a/src/main/codex/codex-structured-dispatch-echo.test.ts +++ b/src/main/codex/codex-structured-dispatch-echo.test.ts @@ -126,3 +126,44 @@ describe('readCodexDispatchEcho', () => { ).toBeNull() }) }) + +describe('the turn Codex answered a send into but has not opened', () => { + const NONE_OPEN = new Set() + + it('is the turn the latest armed send was answered into', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + echoes.bindTurn('client-1', 'thread-1', 'turn-1') + echoes.arm('client-2') + echoes.bindTurn('client-2', 'thread-1', 'turn-2') + + expect(echoes.answeredUnopenedTurn('thread-1', NONE_OPEN)).toBe('turn-2') + }) + + it('is none once Codex opened that turn', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + echoes.bindTurn('client-1', 'thread-1', 'turn-1') + + expect(echoes.answeredUnopenedTurn('thread-1', new Set(['turn-1']))).toBeNull() + }) + + it('is none once that turn ended, even with its send still armed for an echo', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + echoes.bindTurn('client-1', 'thread-1', 'turn-1') + // A completed end leaves its unechoed send armed. + expect(echoes.endTurn('thread-1', 'turn-1', { status: 'completed' })).toEqual([]) + + expect(echoes.answeredUnopenedTurn('thread-1', NONE_OPEN)).toBeNull() + }) + + it('is none for a send not yet answered, or answered on another thread', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + echoes.arm('client-2') + echoes.bindTurn('client-2', 'thread-2', 'turn-2') + + expect(echoes.answeredUnopenedTurn('thread-1', NONE_OPEN)).toBeNull() + }) +}) diff --git a/src/main/codex/codex-structured-dispatch-echo.ts b/src/main/codex/codex-structured-dispatch-echo.ts index 4ecfcf2da01..0d3794b5136 100644 --- a/src/main/codex/codex-structured-dispatch-echo.ts +++ b/src/main/codex/codex-structured-dispatch-echo.ts @@ -1,8 +1,17 @@ +import type { ProviderDiagnostic } from '../../shared/agent-session-failure' import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' -/** Sends awaiting their echo. A send whose echo never arrives is - * retired by the journal's pending-submission recovery on exit, not from here. */ +/** Sends awaiting their echo. One bound to a turn that ended without taking it settles from that + * end; any other whose echo never arrives is retired by the journal's recovery on exit. */ export const MAX_CODEX_PENDING_DISPATCH_ECHOES = 256 +/** Turn ends kept for an answer read after the turn it names had already ended. */ +export const MAX_CODEX_RECORDED_TURN_ENDS = 64 + +/** How a primary-thread turn ended, as Codex reported it. */ +export type CodexTurnEnd = + | { status: 'completed' } + | { status: 'interrupted' } + | { status: 'failed'; detail?: ProviderDiagnostic } export type CodexDispatchRequestOrigin = { requestedAt: number @@ -24,6 +33,19 @@ export type CodexDispatchEchoes = { settle: (clientMessageId: string) => boolean /** Drops an armed send whose write never reached the provider. */ disarm: (clientMessageId: string) => void + /** + * Binds a send to the turn Codex answered it into. Returns that turn's end when the answer is + * read after it; a send that end settles is no longer armed. + */ + bindTurn: (clientMessageId: string, threadId: string, turnId: string) => CodexTurnEnd | null + /** The turn the latest armed send was answered into that is neither in `openTurnIds` nor ended: + * one Codex has picked for the send but not opened. */ + answeredUnopenedTurn: (threadId: string, openTurnIds: ReadonlySet) => string | null + /** + * Records a turn's end and returns the sends bound to it that it settles: all of them unless it + * completed, which echoes its pending input first, so one it never echoed waits for recovery. + */ + endTurn: (threadId: string, turnId: string, end: CodexTurnEnd) => string[] /** Submission origin for this exact send, retained until its echo settles it. */ requestOrigin: (clientMessageId: string) => CodexDispatchRequestOrigin | null /** Highest causal sequence assigned to a dispatch in this session. */ @@ -33,8 +55,14 @@ export type CodexDispatchEchoes = { } export function createCodexDispatchEchoes(): CodexDispatchEchoes { - const armed = new Map() + const armed = new Map< + string, + { requestedAt: number | null; sequence: number; turn?: { threadId: string; turnId: string } } + >() + const endedTurns = new Map() let nextSequence = 0 + const turnKey = (threadId: string, turnId: string): string => JSON.stringify([threadId, turnId]) + const settles = (end: CodexTurnEnd): boolean => end.status !== 'completed' return { arm(clientMessageId, requestedAt) { const existing = armed.get(clientMessageId) @@ -52,6 +80,51 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { }, settle: (clientMessageId) => armed.delete(clientMessageId), disarm: (clientMessageId) => void armed.delete(clientMessageId), + bindTurn: (clientMessageId, threadId, turnId) => { + const entry = armed.get(clientMessageId) + if (!entry) { + return null + } + entry.turn = { threadId, turnId } + const end = endedTurns.get(turnKey(threadId, turnId)) ?? null + if (end && settles(end)) { + armed.delete(clientMessageId) + } + return end + }, + answeredUnopenedTurn: (threadId, openTurnIds) => { + const answered = [...armed.values()].flatMap(({ turn }) => + turn?.threadId === threadId && + !openTurnIds.has(turn.turnId) && + !endedTurns.has(turnKey(threadId, turn.turnId)) + ? [turn.turnId] + : [] + ) + return answered.at(-1) ?? null + }, + endTurn: (threadId, turnId, end) => { + const turn = turnKey(threadId, turnId) + endedTurns.delete(turn) + endedTurns.set(turn, end) + for (const oldest of endedTurns.keys()) { + if (endedTurns.size <= MAX_CODEX_RECORDED_TURN_ENDS) { + break + } + endedTurns.delete(oldest) + } + if (!settles(end)) { + return [] + } + const settled = [...armed].flatMap(([clientMessageId, entry]) => + entry.turn && turnKey(entry.turn.threadId, entry.turn.turnId) === turn + ? [clientMessageId] + : [] + ) + for (const clientMessageId of settled) { + armed.delete(clientMessageId) + } + return settled + }, requestOrigin: (clientMessageId) => { const origin = armed.get(clientMessageId) return origin?.requestedAt === null || origin === undefined @@ -61,6 +134,7 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { latestSequence: () => nextSequence - 1, clear: () => { armed.clear() + endedTurns.clear() nextSequence = 0 }, get size() { diff --git a/src/main/codex/codex-structured-dispatch-test-support.ts b/src/main/codex/codex-structured-dispatch-test-support.ts index 42efd57bab4..86f19349ee2 100644 --- a/src/main/codex/codex-structured-dispatch-test-support.ts +++ b/src/main/codex/codex-structured-dispatch-test-support.ts @@ -1,5 +1,5 @@ +import { expect, vi } from 'vitest' import type { - AgentJournalItemIdentity, AgentJournalMessageItem, AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' @@ -11,6 +11,7 @@ import type { } from './codex-app-server-connection' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' +import { codexTurnLifecycleFake } from './codex-turn-lifecycle-fake' import type { CodexStructuredSessionAdapterDeps } from './codex-structured-session-state' export const CODEX_TEST_THREAD_ID = 'thread-abc' @@ -30,11 +31,9 @@ type FakeConnection = Omit & { calls: { method: string; params?: Record }[] } -export type LateSettlement = { - sessionId: string - clientMessageId: string - providerIdentity: AgentJournalItemIdentity -} +export type LateSettlement = Parameters< + NonNullable +>[0] /** A `codex app-server` whose turn traffic the test drives by hand. */ export function fakeCodexAppServer(routes: Record = {}): { @@ -86,6 +85,7 @@ export async function acquiredCodexAdapter(input: { settlements: LateSettlement[] sink?: StructuredAgentSessionEventSink captureTurnProcesses?: CodexStructuredSessionAdapterDeps['captureTurnProcesses'] + requestTimeoutMs?: number }): Promise { const adapter = new CodexStructuredSessionAdapter({ resolveLaunch: async () => ({ @@ -98,8 +98,11 @@ export async function acquiredCodexAdapter(input: { openConnection: input.codex.openConnection, readProcessStartTime: async () => 1_700_000_000_000, captureTurnProcesses: input.captureTurnProcesses ?? (async () => null), + // A Stop must never reach for real processes on this machine under the fake pid. + terminateTurnProcesses: async () => true, now: () => 1_700_000_000_500, - onDispatchSettledLate: (settlement) => input.settlements.push(settlement) + onDispatchSettledLate: (settlement) => input.settlements.push(settlement), + ...(input.requestTimeoutMs === undefined ? {} : { requestTimeoutMs: input.requestTimeoutMs }) }) const identity: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -133,9 +136,50 @@ export function echoUserMessage( }) } -export function startTurn(connection: FakeConnection, turnId: string): void { +export function startTurn(connection: Pick, turnId: string): void { connection.handlers.onNotification?.('turn/started', { threadId: CODEX_TEST_THREAD_ID, turn: { id: turnId } }) } + +/** Runs `open` once `count` sends reached Codex, which opens a turn only after it answers. */ +export async function openAfterTurnStarts( + connection: Pick, + count: number, + open: () => void +): Promise { + await vi.waitFor(() => + expect(connection.calls.filter((call) => call.method === 'turn/start')).toHaveLength(count) + ) + open() +} + +/** An acquired adapter over a fake Codex that keeps Codex's own turn bookkeeping. */ +export async function codexTurnLifecycleRig(options: { requestTimeoutMs?: number } = {}) { + const codex = fakeCodexAppServer() + const notify = (method: string, params: unknown): void => + codex.connections.at(-1)?.handlers.onNotification?.(method, params) + const turns = codexTurnLifecycleFake(CODEX_TEST_THREAD_ID, () => notify) + Object.assign(codex.routes, turns.routes) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements, ...options }) + const send = (clientMessageId: string) => + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId, + body: CODEX_TEST_USER_MESSAGE, + fence: 7 + }) + const interrupts = () => + codex.connections[0]!.calls.filter((call) => call.method === 'turn/interrupt') + return { codex, turns, adapter, send, settlements, notify, interrupts } +} + +/** The promise's value, or `held` when it has not settled `withinMs` after every earlier task. */ +export function settledWithin(promise: Promise, withinMs = 50): Promise { + return Promise.race([ + promise, + new Promise<'held'>((resolve) => setTimeout(() => resolve('held'), withinMs)) + ]) +} diff --git a/src/main/codex/codex-structured-fast-mode.test.ts b/src/main/codex/codex-structured-fast-mode.test.ts index 3029bff280b..73f279bd2c0 100644 --- a/src/main/codex/codex-structured-fast-mode.test.ts +++ b/src/main/codex/codex-structured-fast-mode.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { USER_MESSAGE, adapterFor, + answerWithOpenedTurn, fakeCodex, identityFor, type Route @@ -19,9 +20,9 @@ describe('Codex structured Fast mode dispatch', () => { } ], nextCursor: null - }), - 'turn/start': () => ({ turn: { id: 'turn-fast' } }) + }) }) + codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-fast') const adapter = adapterFor(codex) await adapter.acquire({ identity: identityFor('session-1'), @@ -43,7 +44,8 @@ describe('Codex structured Fast mode dispatch', () => { }) it('uses Standard on the first turn after acquisition with Fast explicitly off', async () => { - const codex = fakeCodex({ 'turn/start': () => ({ turn: { id: 'turn-standard' } }) }) + const codex = fakeCodex() + codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-standard') const adapter = adapterFor(codex) await adapter.acquire({ identity: identityFor('session-1'), @@ -91,10 +93,8 @@ describe('Codex structured Fast mode dispatch', () => { ], nextCursor: null })) - const codex = fakeCodex({ - 'model/list': listModels, - 'turn/start': () => ({ turn: { id: 'turn-recovered' } }) - }) + const codex = fakeCodex({ 'model/list': listModels }) + codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-recovered') const adapter = adapterFor(codex) await expect( adapter.acquire({ diff --git a/src/main/codex/codex-structured-item-stream-contracts.ts b/src/main/codex/codex-structured-item-stream-contracts.ts index ebbd05731d4..733a81455c3 100644 --- a/src/main/codex/codex-structured-item-stream-contracts.ts +++ b/src/main/codex/codex-structured-item-stream-contracts.ts @@ -2,7 +2,7 @@ import type { AgentJournalItemIdentity } from '../../shared/agent-session-journa import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { codexJournalItem, CodexThreadItem } from './codex-structured-item-translation' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import type { CodexRowAttribution } from './codex-subagent-linkage' export type CodexItemStreamDeps = { sink: StructuredAgentSessionEventSink @@ -13,7 +13,7 @@ export type CodexItemStreamDeps = { turnId: string | null, item: CodexThreadItem ) => AgentJournalItemIdentity - linkageFor: CodexRowLinkage + attributionFor: CodexRowAttribution coalesceMs?: number maxRetainedBytes?: number maxTotalRetainedBytes?: number diff --git a/src/main/codex/codex-structured-item-streams.ts b/src/main/codex/codex-structured-item-streams.ts index 03085bf6913..306fcf27619 100644 --- a/src/main/codex/codex-structured-item-streams.ts +++ b/src/main/codex/codex-structured-item-streams.ts @@ -1,4 +1,8 @@ import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalRowAttribution +} from '../../shared/agent-session-journal-types' import { createAgentSessionDeltaCoalescer } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' import { CodexItemStreamRetention } from './codex-item-stream-retention' import { appendCodexItemAndPublish } from './codex-structured-journal-sink' @@ -51,12 +55,14 @@ export function createCodexStructuredItemStreams( const states = new CodexItemStreamRetention(deps.maxMetadataBytes) const checkpointLengths = new Map() const pendingCheckpoints = new Set() - // Which thread and turn produced each stream, resolved to linkage per append + // Which thread and turn produced each stream, resolved to its attribution per append // so a parent learned after the first checkpoint still reaches the row. const producers = new Map() - const linkageOf = (key: string) => { + const attributionOf = (key: string): AgentJournalRowAttribution => { const producer = producers.get(key) - return producer ? deps.linkageFor(producer.threadId, producer.turnId) : {} + return producer + ? deps.attributionFor(producer.threadId, producer.turnId) + : { turnScope: AGENT_JOURNAL_THREAD_SCOPE } } // Patch updates are authoritative item snapshots. Keep the latest rejected // snapshot until the journal admits it; unlike streamed deltas, there is no @@ -114,7 +120,7 @@ export function createCodexStructuredItemStreams( } return appendCodexItemAndPublish(deps.sink, state.identity, translated.body, { coalescingKey: `checkpoint:${agentJournalItemKey(state.identity)}`, - ...linkageOf(key) + ...attributionOf(key) }).accepted } @@ -191,7 +197,7 @@ export function createCodexStructuredItemStreams( deps.sink, pending.identity, pending.body, - linkageOf(key) + attributionOf(key) ) if (!admission.accepted) { return admission diff --git a/src/main/codex/codex-structured-journal-compactions.test.ts b/src/main/codex/codex-structured-journal-compactions.test.ts index b86cea2725d..4da87a0c75f 100644 --- a/src/main/codex/codex-structured-journal-compactions.test.ts +++ b/src/main/codex/codex-structured-journal-compactions.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' @@ -82,7 +83,7 @@ describe('compaction provider generation compatibility', () => { if (stage === 'append' && reject) { return { accepted: false, reason: 'backpressure' } } - sink.appendItem(identity, body) + sink.appendItem(identity, body, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) return { accepted: true } } sink.tryPublish = () => { diff --git a/src/main/codex/codex-structured-journal-compactions.ts b/src/main/codex/codex-structured-journal-compactions.ts index 9aa2e8f4cf1..61743c1996c 100644 --- a/src/main/codex/codex-structured-journal-compactions.ts +++ b/src/main/codex/codex-structured-journal-compactions.ts @@ -1,5 +1,4 @@ import { createHash } from 'node:crypto' -import { isCodexCompactionComplete } from '../native-chat/agent-session-wire/structured-session-compaction' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CODEX_JOURNAL_ADMITTED, @@ -8,7 +7,16 @@ import { import { MAX_CODEX_GENERIC_TURN_BUCKETS } from './codex-structured-journal-limits' import { appendCodexLifecycleItem, publishCodexLifecycle } from './codex-structured-journal-sink' import { readCodexTurnId } from './codex-structured-thread-facts' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import { readRecord } from './codex-item-field-readers' +import type { CodexRowAttribution } from './codex-subagent-linkage' + +function isCodexCompactionComplete(method: string, params: unknown): boolean { + return ( + method === 'thread/compacted' || + (method === 'item/completed' && + readRecord(readRecord(params).item).type === 'contextCompaction') + ) +} export class CodexJournalCompactions { private readonly turns = new Map() @@ -16,7 +24,9 @@ export class CodexJournalCompactions { constructor( private readonly sink: StructuredAgentSessionEventSink, private readonly activeTurn: (threadId: string) => string | null, - private readonly linkageFor: CodexRowLinkage + private readonly attributionFor: CodexRowAttribution, + /** Evidence for a conversation command the turn carries; its marker is the command's result. */ + private readonly compacted: (turnId: string) => void ) {} handle(event: { @@ -31,6 +41,7 @@ export class CodexJournalCompactions { if (!turnId) { return null } + this.compacted(turnId) // Collapse compactions within a thread/turn; the canonical item replaces its legacy fallback. const key = createHash('sha256') .update(JSON.stringify([event.threadId, turnId])) @@ -44,7 +55,7 @@ export class CodexJournalCompactions { this.sink, { provider: 'orca', clientMessageId: `codex-compaction:${key}` }, { kind: 'status', text: 'Context compacted', presentation: 'compaction' }, - this.linkageFor(event.threadId, turnId) + this.attributionFor(event.threadId, turnId) ) if (!admission.accepted) { return admission diff --git a/src/main/codex/codex-structured-journal-contracts.ts b/src/main/codex/codex-structured-journal-contracts.ts index 28b5583a9b4..74cf5013aff 100644 --- a/src/main/codex/codex-structured-journal-contracts.ts +++ b/src/main/codex/codex-structured-journal-contracts.ts @@ -4,6 +4,7 @@ import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-sessio import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' import type { CodexSubagentExecutions } from './codex-subagent-executions' +import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-session-wire/structured-agent-session-adapter' export type CodexJournalTranslatorDeps = { sink: StructuredAgentSessionEventSink @@ -34,6 +35,13 @@ export type CodexJournalTranslatorDeps = { export type CodexJournalTranslator = { handle: (event: CodexStructuredSessionEvent) => CodexJournalTranslationAdmission + /** Before Orca sends a conversation command: the next primary turn carries it out, and that + * turn's end is the command's. */ + beginCommand: (command: StructuredAgentSessionCommandRun) => void + /** The command was never taken. */ + forgetCommand: (turnId: string) => void + /** The provider turn a Stop naming `turnId` interrupts; undefined while a command has none. */ + commandProviderTurnId: (turnId: string) => string | undefined cancelPrompt: (journalItemId: string) => CodexJournalTranslationAdmission restoreThread: ( threadId: string, diff --git a/src/main/codex/codex-structured-journal-generic-frames.ts b/src/main/codex/codex-structured-journal-generic-frames.ts index 7d9f54462fc..a8b400a6d93 100644 --- a/src/main/codex/codex-structured-journal-generic-frames.ts +++ b/src/main/codex/codex-structured-journal-generic-frames.ts @@ -12,7 +12,8 @@ import { MAX_CODEX_GENERIC_TURN_BUCKETS } from './codex-structured-journal-limits' import { readCodexTurnId } from './codex-structured-thread-facts' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' +import type { CodexRowAttribution } from './codex-subagent-linkage' const OVERFLOW_BUCKET = '__codex-generic-overflow__' /** `producer` is absent only on the overflow bucket, which pools every thread's @@ -59,7 +60,7 @@ export class CodexJournalGenericFrames { constructor( private readonly deps: Pick & { - linkageFor: CodexRowLinkage + attributionFor: CodexRowAttribution }, private readonly activeTurn: (threadId: string) => string | null ) { @@ -102,10 +103,10 @@ export class CodexJournalGenericFrames { provider: 'orca' as const, clientMessageId: `provider-frame:codex:${this.fallbackSequence}` } - const linkage = this.deps.linkageFor(threadId, frameTurnId) + const attribution = this.deps.attributionFor(threadId, frameTurnId) const admission = this.deps.sink.tryAppendItem - ? this.deps.sink.tryAppendItem(identity, translated.body, linkage) - : (this.deps.sink.appendItem(identity, translated.body, linkage), CODEX_JOURNAL_ADMITTED) + ? this.deps.sink.tryAppendItem(identity, translated.body, attribution) + : (this.deps.sink.appendItem(identity, translated.body, attribution), CODEX_JOURNAL_ADMITTED) if (!admission.accepted) { this.fallbackSequence -= 1 return admission @@ -139,11 +140,12 @@ export class CodexJournalGenericFrames { provider: 'orca' as const, clientMessageId: `provider-frame-suppressed:codex:${bucket}` } + // A summary across evicted turns names no producer and belongs to no turn. const options = { coalescingKey: `provider-frame-suppressed:codex:${bucket}`, ...(summary.producer - ? this.deps.linkageFor(summary.producer.threadId, summary.producer.turnId) - : {}) + ? this.deps.attributionFor(summary.producer.threadId, summary.producer.turnId) + : { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) } const admission = this.deps.sink.tryAppendItem ? this.deps.sink.tryAppendItem(identity, { kind: 'status', text }, options) diff --git a/src/main/codex/codex-structured-journal-goal-admission.test.ts b/src/main/codex/codex-structured-journal-goal-admission.test.ts index b0a9e50ba2c..f2a3a9b166c 100644 --- a/src/main/codex/codex-structured-journal-goal-admission.test.ts +++ b/src/main/codex/codex-structured-journal-goal-admission.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { @@ -137,7 +138,7 @@ describe('codex goal lifecycle admission', () => { appendTombstone: () => {}, publish: () => {} } satisfies StructuredAgentSessionEventSink - const goals = new CodexJournalGoals(sink, () => ({})) + const goals = new CodexJournalGoals(sink, () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE })) const update = (goal: Record = {}) => goals.handle({ threadId: THREAD, method: 'thread/goal/updated', params: goalFrame(goal) }) const clear = () => @@ -178,7 +179,7 @@ describe('codex goal lifecycle admission', () => { appendTombstone: () => {}, publish: () => {} } satisfies StructuredAgentSessionEventSink - const goals = new CodexJournalGoals(sink, () => ({})) + const goals = new CodexJournalGoals(sink, () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE })) const send = (threadId: string) => goals.handle({ threadId, method: 'thread/goal/updated', params: goalFrame() }) @@ -211,7 +212,7 @@ describe('codex goal lifecycle admission', () => { appendTombstone: () => {}, publish: () => {} } satisfies StructuredAgentSessionEventSink - const goals = new CodexJournalGoals(sink, () => ({})) + const goals = new CodexJournalGoals(sink, () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE })) const event = { threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() } goals.handle(event) diff --git a/src/main/codex/codex-structured-journal-goal-resume.test.ts b/src/main/codex/codex-structured-journal-goal-resume.test.ts index 4609a06fb0d..6df0ba01575 100644 --- a/src/main/codex/codex-structured-journal-goal-resume.test.ts +++ b/src/main/codex/codex-structured-journal-goal-resume.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { @@ -130,7 +131,9 @@ describe('codex goal lifecycle resume', () => { it('does not append a cleared snapshot when the journal has no prior goal occurrence', async () => { const journal = goalJournal() journal.unbind() - const resumed = new CodexJournalGoals(journal.sink, () => ({})) + const resumed = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) expect( resumed.handle({ @@ -151,7 +154,9 @@ describe('codex goal lifecycle resume', () => { it('does not revisit durable history for accounting-only updates', async () => { const journal = goalJournal() - const goals = new CodexJournalGoals(journal.sink, () => ({})) + const goals = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) goals.handle({ threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() }) await journal.drained() const visits = journal.visits() @@ -176,7 +181,9 @@ describe('codex goal lifecycle resume', () => { it('rebuilds dedupe state after the journal epoch is replaced', async () => { const journal = goalJournal() - const goals = new CodexJournalGoals(journal.sink, () => ({})) + const goals = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) const event = { threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() } goals.handle(event) @@ -198,7 +205,9 @@ describe('codex goal lifecycle resume', () => { it('visits a large journal once per epoch when thread churn exceeds the transient LRU', async () => { const journal = goalJournal() journal.seedProviderItems(10_000) - const goals = new CodexJournalGoals(journal.sink, () => ({})) + const goals = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) const threadCount = MAX_CODEX_GOAL_THREADS + 1 const sendRound = () => { for (let index = 0; index < threadCount; index += 1) { @@ -227,7 +236,9 @@ describe('codex goal lifecycle resume', () => { const journal = goalJournal() journal.seedProviderItems(10_000) journal.unbind() - const goals = new CodexJournalGoals(journal.sink, () => ({})) + const goals = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) for (let index = 0; index < MAX_CODEX_GOAL_THREADS; index += 1) { goals.handle({ @@ -249,7 +260,9 @@ describe('codex goal lifecycle resume', () => { it('retries a journal-derived transition after lifecycle backpressure', async () => { const journal = goalJournal({ watermarks: { maxLifecycleQueuedOperations: 1 } }) - const goals = new CodexJournalGoals(journal.sink, () => ({})) + const goals = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) journal.unbind() expect( @@ -317,7 +330,9 @@ describe('codex goal lifecycle resume', () => { }) } - const prior = new CodexJournalGoals(journal.sink, () => ({})) + const prior = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) for (const state of scenario.beforeResume) { send(prior, state) } @@ -329,7 +344,9 @@ describe('codex goal lifecycle resume', () => { prior.dispose() journal.unbind() - const resumed = new CodexJournalGoals(journal.sink, () => ({})) + const resumed = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) resumed.handle({ threadId: THREAD, method: scenario.resumed.method, diff --git a/src/main/codex/codex-structured-journal-goal-revision.test.ts b/src/main/codex/codex-structured-journal-goal-revision.test.ts index e6f96ad5503..2fbe33ab901 100644 --- a/src/main/codex/codex-structured-journal-goal-revision.test.ts +++ b/src/main/codex/codex-structured-journal-goal-revision.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -93,10 +94,10 @@ describe('codex goal accounting revisions', () => { await journal.appendItem( { provider: 'orca', clientMessageId: 'earlier' }, { kind: 'status', text: 'Context compacted' }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const { sink, drained, subscribe, published } = journalSink(journal) - const goals = new CodexJournalGoals(sink, () => ({})) + const goals = new CodexJournalGoals(sink, () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE })) goals.handle({ threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() }) await drained() @@ -104,7 +105,7 @@ describe('codex goal accounting revisions', () => { await journal.appendItem( { provider: 'orca', clientMessageId: 'later' }, { kind: 'status', text: 'Something after the goal' }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) subscribe() @@ -160,14 +161,18 @@ describe('codex goal accounting revisions', () => { root = await mkdtemp(join(tmpdir(), 'orca-goal-resume-revision-')) const journal = await journals.open({ identity: IDENTITY, journalDir: root }) const first = journalSink(journal) - const prior = new CodexJournalGoals(first.sink, () => ({})) + const prior = new CodexJournalGoals(first.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) prior.handle({ threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() }) await first.drained() prior.dispose() const [created] = journal.snapshot().items const second = journalSink(journal) - const resumed = new CodexJournalGoals(second.sink, () => ({})) + const resumed = new CodexJournalGoals(second.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) // Only a few seconds more: a resume snapshot still refreshes, since no live tick follows. const snapshot = goalFrame({ timeUsedSeconds: 3, updatedAt: 1789067991 }) resumed.handle({ threadId: THREAD, method: 'thread/goal/updated', params: snapshot }) diff --git a/src/main/codex/codex-structured-journal-goal-rows.test.ts b/src/main/codex/codex-structured-journal-goal-rows.test.ts index ff234c7d5fb..053376e30e0 100644 --- a/src/main/codex/codex-structured-journal-goal-rows.test.ts +++ b/src/main/codex/codex-structured-journal-goal-rows.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' @@ -35,8 +36,11 @@ function frames(): { }, publish: vi.fn() } as unknown as StructuredAgentSessionEventSink - const goals = new CodexJournalGoals(sink, () => ({})) - const generic = new CodexJournalGenericFrames({ sink, linkageFor: () => ({}) }, () => null) + const goals = new CodexJournalGoals(sink, () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE })) + const generic = new CodexJournalGenericFrames( + { sink, attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }) }, + () => null + ) return { rows, frames: { diff --git a/src/main/codex/codex-structured-journal-goals.ts b/src/main/codex/codex-structured-journal-goals.ts index 681a483c12f..d73b163765e 100644 --- a/src/main/codex/codex-structured-journal-goals.ts +++ b/src/main/codex/codex-structured-journal-goals.ts @@ -24,7 +24,7 @@ import { } from './codex-structured-journal-contracts' import { MAX_CODEX_GOAL_THREADS } from './codex-structured-journal-limits' import { appendCodexLifecycleTransition } from './codex-structured-journal-sink' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import type { CodexRowAttribution } from './codex-subagent-linkage' type GoalAccounting = { key: string; timeUsedSeconds: number; tokenBudget: number | null } @@ -70,7 +70,7 @@ export class CodexJournalGoals { constructor( private readonly sink: StructuredAgentSessionEventSink, - private readonly linkageFor: CodexRowLinkage + private readonly attributionFor: CodexRowAttribution ) {} handle(event: { @@ -132,7 +132,7 @@ export class CodexJournalGoals { event.method === 'thread/goal/cleared' ), // A goal belongs to its thread, not to one run of it, so no turn is named. - this.linkageFor(event.threadId, null) + this.attributionFor(event.threadId, null) ) if (!admission.accepted) { return admission diff --git a/src/main/codex/codex-structured-journal-items.ts b/src/main/codex/codex-structured-journal-items.ts index 6717f3edabe..4e6ebe8e5e0 100644 --- a/src/main/codex/codex-structured-journal-items.ts +++ b/src/main/codex/codex-structured-journal-items.ts @@ -1,7 +1,7 @@ import type { AgentJournalItemBody, AgentJournalItemIdentity, - AgentJournalProducerLinkage + AgentJournalRowAttribution } from '../../shared/agent-session-journal-types' import { requiresTerminalSettlement } from '../native-chat/agent-session-journal/journal-terminal-settlement' import { @@ -31,7 +31,7 @@ import type { CodexActiveJournalItem } from './codex-structured-journal-settleme import { readCodexJournalString } from './codex-structured-journal-translation-values' import { readCodexTurnId } from './codex-structured-thread-facts' import { readCodexDispatchEcho } from './codex-structured-dispatch-echo' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import type { CodexRowAttribution } from './codex-subagent-linkage' export class CodexJournalItems { readonly ordinals = new CodexTurnOrdinals() @@ -44,7 +44,7 @@ export class CodexJournalItems { private readonly deps: Pick< CodexJournalTranslatorDeps, 'sink' | 'coalesceMs' | 'maxRetainedBytes' | 'schedule' - > & { maxMetadataBytes?: number; linkageFor: CodexRowLinkage }, + > & { maxMetadataBytes?: number; attributionFor: CodexRowAttribution }, private readonly activeTurn: (threadId: string) => string | null, private readonly suppress: (threadId: string, turnId: string) => void ) { @@ -56,7 +56,7 @@ export class CodexJournalItems { maxMetadataBytes: deps.maxMetadataBytes, turnIdFor: (threadId, params) => readCodexTurnId(params) ?? this.activeTurn(threadId), identityFor: (threadId, turnId, item) => this.identityFor(threadId, turnId, item), - linkageFor: deps.linkageFor + attributionFor: deps.attributionFor }) } @@ -122,7 +122,7 @@ export class CodexJournalItems { event.method, identity, translated, - this.deps.linkageFor(event.threadId, turnId) + this.deps.attributionFor(event.threadId, turnId) ) if (!admission.accepted) { return { handled: true, admission } @@ -151,17 +151,22 @@ export class CodexJournalItems { method: string, identity: AgentJournalItemIdentity, translated: ReturnType, - linkage: AgentJournalProducerLinkage + attribution: AgentJournalRowAttribution ): CodexJournalTranslationAdmission { if (!translated.body) { return CODEX_JOURNAL_ADMITTED } if (method === 'item/completed') { - const admission = appendCodexLifecycleItem(this.deps.sink, identity, translated.body, linkage) + const admission = appendCodexLifecycleItem( + this.deps.sink, + identity, + translated.body, + attribution + ) return admission.accepted ? publishCodexLifecycle(this.deps.sink) : admission } const options = requiresTerminalSettlement(translated.body) ? { lifecycle: true } : {} - const appendOptions = { ...options, ...linkage } + const appendOptions = { ...options, ...attribution } const admission = this.deps.sink.tryAppendItem ? this.deps.sink.tryAppendItem(identity, translated.body, appendOptions) : (this.deps.sink.appendItem(identity, translated.body, appendOptions), @@ -235,7 +240,7 @@ export class CodexJournalItems { this.deps.sink, evicted.identity, evictedActiveBody(translated), - this.deps.linkageFor(evicted.threadId, evicted.turnId) + this.deps.attributionFor(evicted.threadId, evicted.turnId) ) if (!admission.accepted) { return admission diff --git a/src/main/codex/codex-structured-journal-prompts.ts b/src/main/codex/codex-structured-journal-prompts.ts index 96c58f9b8b7..9c145915fe5 100644 --- a/src/main/codex/codex-structured-journal-prompts.ts +++ b/src/main/codex/codex-structured-journal-prompts.ts @@ -20,7 +20,7 @@ import { } from './codex-structured-journal-sink' import type { CodexPendingJournalPrompt } from './codex-structured-journal-settlement' import { readCodexTurnId } from './codex-structured-thread-facts' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import type { CodexRowAttribution } from './codex-subagent-linkage' import { journalLifecycleItemMutation } from '../native-chat/agent-session-journal/journal-row-builders' type CodexGroupedPendingJournalPrompt = CodexPendingJournalPrompt & { promptKey: string } @@ -30,7 +30,7 @@ export class CodexJournalPrompts { constructor( private readonly deps: Pick & { - linkageFor: CodexRowLinkage + attributionFor: CodexRowAttribution }, private readonly detailFor: (threadId: string, itemId: string) => string | null, private readonly activeTurn: (threadId: string) => string | null @@ -118,7 +118,7 @@ export class CodexJournalPrompts { ) const mutations = group.flatMap(([, prompt]) => { const body = cancelledJournalPromptBody(prompt.body) - const producer = this.deps.linkageFor(prompt.threadId, prompt.turnId) + const producer = this.deps.attributionFor(prompt.threadId, prompt.turnId) return body ? [journalLifecycleItemMutation(producer, prompt.identity, body)] : [] }) const admission = appendCodexLifecycleMutations( @@ -152,7 +152,7 @@ export class CodexJournalPrompts { )}:${encodeURIComponent(event.promptKey)}`, items, // A child's approval arrives on the child's own thread, so it names the asker. - this.deps.linkageFor(event.threadId, turnId) + this.deps.attributionFor(event.threadId, turnId) ) } @@ -170,7 +170,7 @@ export class CodexJournalPrompts { this.deps.sink, evicted.identity, cancelled, - this.deps.linkageFor(evicted.threadId, evicted.turnId) + this.deps.attributionFor(evicted.threadId, evicted.turnId) ) if (!admission.accepted) { return admission diff --git a/src/main/codex/codex-structured-journal-provider-verdicts.ts b/src/main/codex/codex-structured-journal-provider-verdicts.ts deleted file mode 100644 index c8685dfedab..00000000000 --- a/src/main/codex/codex-structured-journal-provider-verdicts.ts +++ /dev/null @@ -1,40 +0,0 @@ -// What Codex's own verdict frames mean beyond the row they print. -// -// Both are decoration to the transcript and load-bearing to the session's state, -// which is why they are read here rather than left to the generic-frame fallback. - -import { codexThreadStoppedRunning, readCodexErrorWillRetry } from './codex-structured-thread-facts' - -export type CodexProviderVerdict = - /** - * Codex ended this turn with a fault. - * - * The app server emits `error` ONLY for a failure that affects turn status, and - * hardcodes `willRetry: false` there; a stream error it is about to retry carries - * `willRetry: true` and ends nothing. `turn/completed` may never follow, so this - * frame is the turn's only end — without it the running lifecycle row is a latch - * nothing re-derives and the chat reads working for the life of the session. - */ - | 'turn-failed' - /** - * Codex reports the thread is no longer running. - * - * This settles no OPEN turn: the app server clears `running` on every error, - * including the ones it says do not affect turn status, so a turn still open - * here is still running and `turn/completed` is its end. What it does settle is - * a send whose dispatch was never answered — a timed-out dispatch is recorded - * as unverified delivery, reads as work still owed, and nothing else in a live - * session re-derives it. - */ - | 'thread-stopped-running' - | null - -export function readCodexProviderVerdict(method: string, params: unknown): CodexProviderVerdict { - if (method === 'error') { - return readCodexErrorWillRetry(params) ? null : 'turn-failed' - } - if (method === 'thread/status/changed') { - return codexThreadStoppedRunning(params) ? 'thread-stopped-running' : null - } - return null -} diff --git a/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts b/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts new file mode 100644 index 00000000000..7c636545daf --- /dev/null +++ b/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { nativeChatTurnFold } from '../../shared/native-chat-turn-fold' +import { nativeChatTurnMembership } from '../../shared/native-chat-turn-membership' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { projectStructuredItemToNativeChat } from '../../shared/structured-agent-session-projection' +import { selectStructuredAgentTurnBars } from '../../shared/structured-agent-session-turn-timing' +import { JournalDerivedTurnScope } from '../native-chat/agent-session-journal/journal-derived-turn-scope' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' + +const THREAD_ID = 'thread-abc' + +function historicalTurn(index: number): Record { + const id = `turn-${index}` + return { + id, + status: 'completed', + startedAt: 1_700_000_000 + index * 100, + completedAt: 1_700_000_050 + index * 100, + items: [ + { + type: 'userMessage', + id: `user-${index}`, + content: [{ type: 'text', text: `ask ${index}` }] + }, + { type: 'agentMessage', id: `interim-${index}`, text: `looking ${index}` }, + { type: 'agentMessage', id: `answer-${index}`, text: `answer ${index}` } + ] + } +} + +/** What the journal hands a reader after a restore: rows in append order. With `statesScope`, each + * row carries the turn scope the journal gives a row the translator writes without one. */ +function restoredJournal(turnCount: number, statesScope: boolean): AgentJournalRenderItem[] { + const items: AgentJournalRenderItem[] = [] + const derived = new JournalDerivedTurnScope() + const translator = createCodexJournalTranslator({ + sink: { + appendItem: (identity, body, options) => { + const sequence = items.length + 1 + const itemId = agentJournalItemKey(identity) + const turnScope = options.turnScope ?? derived.scopeFor(body) + derived.observe(itemId, true, undefined, body) + items.push({ + itemId, + revision: 1, + body, + sequence, + observedAt: sequence, + ...(statesScope ? { turnScope } : {}) + }) + }, + appendTombstone: () => {}, + publish: () => {} + }, + sessionId: 'session-1', + primaryThreadId: () => THREAD_ID + }) + const turns = Array.from({ length: turnCount }, (_, index) => historicalTurn(index + 1)) + expect(translator.restoreThread(THREAD_ID, { turns })).toEqual({ accepted: true }) + return items +} + +describe('grouping a Codex thread restored from full history', () => { + it.each([ + ['states each row’s turn', true], + ['states no scope', false] + ])( + 'keeps each turn with its own rows and folds each to its own answer on a host that %s', + (_host, statesScope) => { + const items = restoredJournal(3, statesScope) + const bars = selectStructuredAgentTurnBars(items, [], null) + const messages = items + .map(projectStructuredItemToNativeChat) + .filter((message): message is NativeChatMessage => message !== null) + const { turnKeys } = nativeChatTurnMembership(messages, { items, submissions: [] }) + const opener = (index: number): string => `codex:${THREAD_ID}:turn-${index}:0` + + expect(messages.map((message, index) => [message.role, turnKeys[index]])).toEqual([ + ['user', opener(1)], + ['assistant', opener(1)], + ['assistant', opener(1)], + ['user', opener(2)], + ['assistant', opener(2)], + ['assistant', opener(2)], + ['user', opener(3)], + ['assistant', opener(3)], + ['assistant', opener(3)] + ]) + expect([...bars.settledTurns.keys()]).toEqual([opener(1), opener(2), opener(3)]) + + const { foldedRows } = nativeChatTurnFold({ + rows: messages.map((message, index) => ({ + turnKey: turnKeys[index], + role: message.role, + rendersProse: true, + outlivesTurn: false, + reportsFailure: false, + reportsCompaction: false + })), + settledTurnKeys: new Set(bars.settledTurns.keys()), + expandedTurnKeys: new Set() + }) + const visible = messages.filter((_, index) => !foldedRows.has(index)) + expect(visible.map((message) => message.blocks)).toEqual( + ['ask 1', 'answer 1', 'ask 2', 'answer 2', 'ask 3', 'answer 3'].map((text) => [ + { type: 'text', text } + ]) + ) + } + ) +}) diff --git a/src/main/codex/codex-structured-journal-settlement.ts b/src/main/codex/codex-structured-journal-settlement.ts index e2164b9498d..a3f4c7f5599 100644 --- a/src/main/codex/codex-structured-journal-settlement.ts +++ b/src/main/codex/codex-structured-journal-settlement.ts @@ -1,7 +1,8 @@ -import type { - AgentJournalItemBody, - AgentJournalItemIdentity, - AgentJournalTurnLifecycle +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalItemIdentity, + type AgentJournalTurnLifecycle } from '../../shared/agent-session-journal-types' import { journalLifecycleItemMutation, @@ -26,7 +27,7 @@ import { codexTurnLifecycleIdentity } from './codex-structured-journal-translation-turns' import { appendCodexLifecycleMutations } from './codex-structured-journal-sink' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import type { CodexRowAttribution } from './codex-subagent-linkage' export type CodexActiveJournalItem = { threadId: string @@ -53,9 +54,10 @@ export function settleCodexJournalSession(input: { currentTurnIds: ReadonlyMap> primaryThreadId: string | null ordinals: CodexTurnOrdinals - /** Terminal lifecycle for a turn the provider left running when it ended. */ - settledTurnLifecycle: (threadId: string, turnId: string) => AgentJournalTurnLifecycle - linkageFor: CodexRowLinkage + /** Terminal lifecycle for a turn the provider left running when it ended; null for a turn a + * conversation command claimed, whose record the host settles. */ + settledTurnLifecycle: (threadId: string, turnId: string) => AgentJournalTurnLifecycle | null + attributionFor: CodexRowAttribution }): StructuredAgentSessionSinkAdmission { // Rows from every thread settle in this one batch, so each names its own producer. const mutations: JournalLifecycleMutationInput[] = [] @@ -67,13 +69,13 @@ export function settleCodexJournalSession(input: { : codexJournalItem(active.item) const body = interruptedBody(translated.body) if (body) { - mutations.push(settledRow(input.linkageFor, active, body)) + mutations.push(settledRow(input.attributionFor, active, body)) } } for (const prompt of input.pendingPrompts.values()) { const body = cancelledJournalPromptBody(prompt.body) if (body) { - mutations.push(settledRow(input.linkageFor, prompt, body)) + mutations.push(settledRow(input.attributionFor, prompt, body)) } } for (const [threadId, turnIds] of input.currentTurnIds) { @@ -81,11 +83,15 @@ export function settleCodexJournalSession(input: { continue } for (const turnId of turnIds) { - mutations.push({ - kind: 'item', - identity: codexTurnLifecycleIdentity(input.event.sessionId, turnId), - body: codexTurnLifecycleBody(input.settledTurnLifecycle(threadId, turnId)) - }) + const turnLifecycle = input.settledTurnLifecycle(threadId, turnId) + if (turnLifecycle) { + mutations.push({ + kind: 'item', + identity: codexTurnLifecycleIdentity(input.event.sessionId, turnId), + body: codexTurnLifecycleBody(turnLifecycle), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + } turnOrdinalsToForget.push({ threadId, turnId }) } } @@ -114,7 +120,9 @@ export function settleCodexJournalTurn(input: { activeItems: Map pendingPrompts?: Map clearPromptTurn?: (threadId: string, turnId: string) => void - linkageFor: CodexRowLinkage + attributionFor: CodexRowAttribution + /** The end of a conversation command the turn carried, which settles with it. */ + commandEnd?: readonly JournalLifecycleMutationInput[] }): StructuredAgentSessionSinkAdmission { const mutations: JournalLifecycleMutationInput[] = [] const activeItemsToForget: { key: string; threadId: string; itemId: string }[] = [] @@ -133,7 +141,7 @@ export function settleCodexJournalTurn(input: { : codexJournalItem(active.item) const body = interruptedBody(translated.body) if (body) { - mutations.push(settledRow(input.linkageFor, active, body)) + mutations.push(settledRow(input.attributionFor, active, body)) } activeItemsToForget.push({ key, threadId: active.threadId, itemId: active.item.id }) } @@ -143,7 +151,7 @@ export function settleCodexJournalTurn(input: { } const body = cancelledJournalPromptBody(prompt.body) if (body) { - mutations.push(settledRow(input.linkageFor, prompt, body)) + mutations.push(settledRow(input.attributionFor, prompt, body)) } pendingPromptsToForget.push(key) } @@ -152,9 +160,11 @@ export function settleCodexJournalTurn(input: { mutations.push({ kind: 'item', identity: codexTurnLifecycleIdentity(input.sessionId, input.turnId), - body: codexTurnLifecycleBody(input.turnLifecycle) + body: codexTurnLifecycleBody(input.turnLifecycle), + turnScope: AGENT_JOURNAL_THREAD_SCOPE }) } + mutations.push(...(input.commandEnd ?? [])) const admission = appendCodexLifecycleMutations( input.sink, `turn-completed:${input.sessionId}:${input.threadId}:${input.turnId}`, @@ -174,89 +184,13 @@ export function settleCodexJournalTurn(input: { return ADMITTED } -/** Settle streamed items whose terminal notification was rejected as oversized. */ -export function settleCodexOversizedNotification(input: { - sessionId: string - threadId: string - method: string - sink: StructuredAgentSessionEventSink - streams: CodexStructuredItemStreams - activeItems: Map - linkageFor: CodexRowLinkage -}): StructuredAgentSessionSinkAdmission { - const itemType = oversizedStreamItemType(input.method) - if (!itemType) { - return ADMITTED - } - const mutations: JournalLifecycleMutationInput[] = [] - const activeItemsToForget: { key: string; threadId: string; itemId: string }[] = [] - for (const [key, active] of input.activeItems) { - if (active.threadId !== input.threadId || active.item.type !== itemType) { - continue - } - const streamed = input.streams.snapshot(active.threadId, active.item.id) - const translated = streamed - ? codexStreamingJournalItem(active.item, streamed.text) - : codexJournalItem(active.item) - const body = interruptedBody(translated.body) - if (body) { - mutations.push(settledRow(input.linkageFor, active, body)) - } - activeItemsToForget.push({ key, threadId: active.threadId, itemId: active.item.id }) - } - if (mutations.length === 0) { - return ADMITTED - } - const admission = appendCodexLifecycleMutations( - input.sink, - `oversized-notification:${input.sessionId}:${input.threadId}:${input.method}`, - mutations - ) - if (!admission.accepted) { - return admission - } - for (const active of activeItemsToForget) { - input.streams.forget(active.threadId, active.itemId) - input.activeItems.delete(active.key) - } - return ADMITTED -} - -function oversizedStreamItemType(method: string): CodexThreadItem['type'] | null { - if (method === 'item/agentMessage/delta') { - return 'agentMessage' - } - if (method === 'item/plan/delta') { - return 'plan' - } - if ( - method === 'command/exec/outputDelta' || - method === 'process/outputDelta' || - method === 'item/commandExecution/outputDelta' || - method === 'item/commandExecution/terminalInteraction' - ) { - return 'commandExecution' - } - if (method === 'item/fileChange/outputDelta' || method === 'item/fileChange/patchUpdated') { - return 'fileChange' - } - if ( - method === 'item/reasoning/summaryTextDelta' || - method === 'item/reasoning/summaryPartAdded' || - method === 'item/reasoning/textDelta' - ) { - return 'reasoning' - } - return null -} - /** A settled item or prompt, naming its producer: the settlement can be the row's first write. */ function settledRow( - linkageFor: CodexRowLinkage, + attributionFor: CodexRowAttribution, row: { threadId: string; turnId: string | null; identity: AgentJournalItemIdentity }, body: AgentJournalItemBody ): JournalLifecycleMutationInput { - return journalLifecycleItemMutation(linkageFor(row.threadId, row.turnId), row.identity, body) + return journalLifecycleItemMutation(attributionFor(row.threadId, row.turnId), row.identity, body) } function interruptedBody(body: AgentJournalItemBody | null): AgentJournalItemBody | null { diff --git a/src/main/codex/codex-structured-journal-sink.ts b/src/main/codex/codex-structured-journal-sink.ts index 95a27702365..0823e5679cf 100644 --- a/src/main/codex/codex-structured-journal-sink.ts +++ b/src/main/codex/codex-structured-journal-sink.ts @@ -1,10 +1,10 @@ import type { AgentJournalItemBody, AgentJournalItemIdentity, - AgentJournalProducerLinkage + AgentJournalRowAttribution } from '../../shared/agent-session-journal-types' import type { - StructuredAgentSessionAppendOptions, + StructuredAgentSessionItemAppendOptions, StructuredAgentSessionEventSink, StructuredAgentSessionLifecycleIdentityResolver, StructuredAgentSessionSinkAdmission @@ -35,7 +35,11 @@ export function appendCodexLifecycleMutations( } else { for (const mutation of chunk) { if (mutation.kind === 'item') { - const options = { lifecycle: true, ...mutation.linkage } + const options = { + lifecycle: true, + ...mutation.linkage, + turnScope: mutation.turnScope + } if (sink.tryAppendItem) { admission = sink.tryAppendItem(mutation.identity, mutation.body, options) if (!admission.accepted) { @@ -74,7 +78,7 @@ export function appendCodexItemAndPublish( sink: StructuredAgentSessionEventSink, identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - options: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ): StructuredAgentSessionSinkAdmission { const admission = sink.tryAppendItem ? sink.tryAppendItem(identity, body, options) @@ -95,9 +99,9 @@ export function appendCodexLifecycleItem( sink: StructuredAgentSessionEventSink, identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - linkage: AgentJournalProducerLinkage + attribution: AgentJournalRowAttribution ): CodexJournalTranslationAdmission { - const options = { lifecycle: true, ...linkage } + const options = { lifecycle: true, ...attribution } if (sink.tryAppendItem) { return criticalAdmission(sink.tryAppendItem(identity, body, options)) } @@ -110,14 +114,14 @@ export function appendCodexLifecycleTransition( identitySizeBound: AgentJournalItemIdentity, body: AgentJournalItemBody, resolveIdentity: StructuredAgentSessionLifecycleIdentityResolver, - linkage: AgentJournalProducerLinkage + attribution: AgentJournalRowAttribution ): CodexJournalTranslationAdmission { if (sink.tryAppendLifecycleTransition) { return criticalAdmission( - sink.tryAppendLifecycleTransition(identitySizeBound, body, resolveIdentity, linkage) + sink.tryAppendLifecycleTransition(identitySizeBound, body, resolveIdentity, attribution) ) } - const admission = appendCodexLifecycleItem(sink, identitySizeBound, body, linkage) + const admission = appendCodexLifecycleItem(sink, identitySizeBound, body, attribution) return admission.accepted ? publishCodexLifecycle(sink) : admission } @@ -136,7 +140,7 @@ export function admitCodexLifecycleItems( sink: StructuredAgentSessionEventSink, settlementId: string, items: readonly Pick[], - linkage: AgentJournalProducerLinkage + attribution: AgentJournalRowAttribution ): CodexJournalTranslationAdmission { if (items.length === 0) { return { accepted: false, reason: 'untranslated' } @@ -145,14 +149,14 @@ export function admitCodexLifecycleItems( const admission = criticalAdmission( sink.tryAppendLifecycleBatch( settlementId, - items.map((item) => journalLifecycleItemMutation(linkage, item.identity, item.body)), + items.map((item) => journalLifecycleItemMutation(attribution, item.identity, item.body)), { lifecycle: true } ) ) return admission.accepted ? publishCodexLifecycle(sink) : admission } for (const item of items) { - const admission = appendCodexLifecycleItem(sink, item.identity, item.body, linkage) + const admission = appendCodexLifecycleItem(sink, item.identity, item.body, attribution) if (!admission.accepted) { return admission } diff --git a/src/main/codex/codex-structured-journal-subagent-readers.test.ts b/src/main/codex/codex-structured-journal-subagent-readers.test.ts index 117f204e963..7b7266345e7 100644 --- a/src/main/codex/codex-structured-journal-subagent-readers.test.ts +++ b/src/main/codex/codex-structured-journal-subagent-readers.test.ts @@ -9,7 +9,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import { selectStructuredAgentTurnActivity } from '../../shared/native-chat-turn-activity' -import { latestStructuredAgentSessionAssistantMessage } from '../../shared/structured-agent-session-projection' +import { latestStructuredAgentSessionAssistantMessage } from '../../shared/structured-agent-session-latest-request' import { isStructuredAgentSessionThinking, statusStructuredAgentSessionToolCall diff --git a/src/main/codex/codex-structured-journal-translation-frames.ts b/src/main/codex/codex-structured-journal-translation-frames.ts deleted file mode 100644 index f94c10bfe66..00000000000 --- a/src/main/codex/codex-structured-journal-translation-frames.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * The translator's provider-frame arms. - * - * Each returns null for a frame it does not own, which is the translator's - * signal to keep looking. Split out so the translator reads as routing rather - * than as the shape checks each arm performs. - */ - -import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' -import type { CodexJournalItems } from './codex-structured-journal-items' -import type { CodexJournalTranslationAdmission } from './codex-structured-journal-contracts' -import { settleCodexOversizedNotification } from './codex-structured-journal-settlement' -import { - readCodexJournalRecord, - readCodexJournalString -} from './codex-structured-journal-translation-values' - -type OversizedInput = Parameters[0] - -/** A notification the transport refused to carry whole: settle whatever it - * opened rather than leaving the item mid-flight. */ -export function settleCodexOversizedNotificationFrame(input: { - sessionId: string - threadId: string - kind: string - payload: unknown - sink: OversizedInput['sink'] - streams: OversizedInput['streams'] - activeItems: OversizedInput['activeItems'] - linkageFor: OversizedInput['linkageFor'] -}): CodexJournalTranslationAdmission | null { - if (input.kind !== 'frame:oversized-notification') { - return null - } - const method = readCodexJournalString(readCodexJournalRecord(input.payload), 'method') - return method - ? settleCodexOversizedNotification({ - sessionId: input.sessionId, - threadId: input.threadId, - method, - sink: input.sink, - streams: input.streams, - activeItems: input.activeItems, - linkageFor: input.linkageFor - }) - : null -} - -export function createCodexOversizedNotificationSettler( - deps: { sink: OversizedInput['sink']; linkageFor: OversizedInput['linkageFor'] }, - items: Pick -) { - return settleOversizedNotification - - /** Settles the item a notification the transport refused to carry left - * mid-flight; null when the frame is not one. */ - function settleOversizedNotification( - event: Extract - ): CodexJournalTranslationAdmission | null { - return settleCodexOversizedNotificationFrame({ - ...event, - sink: deps.sink, - streams: items.streams, - activeItems: items.activeItems, - linkageFor: deps.linkageFor - }) - } -} diff --git a/src/main/codex/codex-structured-journal-translation-restore.ts b/src/main/codex/codex-structured-journal-translation-restore.ts index 7005c10a43b..5dc68d6f764 100644 --- a/src/main/codex/codex-structured-journal-translation-restore.ts +++ b/src/main/codex/codex-structured-journal-translation-restore.ts @@ -59,6 +59,17 @@ export function restoreCodexJournalThread(input: { if (!turnId) { continue } + // Ahead of the turn's items, as the live path writes it: readers credit every + // row to the nearest turn record before it. + const lifecycle = input.restoreTurnLifecycle + ? historicalTurnLifecycle(input.threadId, turn) + : null + if (lifecycle) { + const admission = input.restoreTurnLifecycle?.(lifecycle) ?? { accepted: true } + if (!admission.accepted) { + return admission + } + } input.currentTurnIds.set(input.threadId, new Set([turnId])) for (const item of Array.isArray(turn.items) ? turn.items : []) { const admission = input.handleItem({ @@ -72,15 +83,6 @@ export function restoreCodexJournalThread(input: { } input.currentTurnIds.delete(input.threadId) input.ordinals.forgetTurn(input.threadId, turnId) - const lifecycle = input.restoreTurnLifecycle - ? historicalTurnLifecycle(input.threadId, turn) - : null - if (lifecycle) { - const admission = input.restoreTurnLifecycle?.(lifecycle) ?? { accepted: true } - if (!admission.accepted) { - return admission - } - } } input.flush() return { accepted: true } diff --git a/src/main/codex/codex-structured-journal-translation-settlement.test.ts b/src/main/codex/codex-structured-journal-translation-settlement.test.ts index ab0e602e955..344d99e03b4 100644 --- a/src/main/codex/codex-structured-journal-translation-settlement.test.ts +++ b/src/main/codex/codex-structured-journal-translation-settlement.test.ts @@ -551,7 +551,8 @@ describe('codex journal translation', () => { userItemId: `codex:${THREAD_ID}:${TURN_ID}:0`, startedAt: expect.any(Number), completedAt: expect.any(Number) - } + }, + turnScope: { kind: 'thread' } } ] } diff --git a/src/main/codex/codex-structured-journal-translation-streams.test.ts b/src/main/codex/codex-structured-journal-translation-streams.test.ts index 8a924a9d78a..ce6fd220132 100644 --- a/src/main/codex/codex-structured-journal-translation-streams.test.ts +++ b/src/main/codex/codex-structured-journal-translation-streams.test.ts @@ -215,55 +215,6 @@ describe('codex journal translation', () => { ).toEqual(['notification:future/notification', 'request:future/request', 'frame:unclassified']) }) - it('terminalizes the active streamed item when an oversized notification is rejected', () => { - const { translator, tap } = translatorWith() - translator.handle(TURN_STARTED) - translator.handle( - notification('item/started', { - item: { - type: 'commandExecution', - id: 'exec-oversized', - command: 'run', - status: 'inProgress' - } - }) - ) - const admission = translator.handle({ - type: 'provider-frame', - sessionId: SESSION_ID, - threadId: THREAD_ID, - kind: 'frame:oversized-notification', - payload: { - reason: 'record-too-large', - observedBytes: 20 * 1024 * 1024, - maxBytes: 16 * 1024 * 1024, - classification: 'notification', - method: 'item/commandExecution/outputDelta' - } - }) - - expect(admission).toEqual({ accepted: true }) - expect(tap.rows).toEqual([ - expect.objectContaining({ - body: expect.objectContaining({ kind: 'tool-call', state: 'running' }) - }), - expect.objectContaining({ - body: expect.objectContaining({ kind: 'tool-call', state: 'failed' }) - }), - expect.objectContaining({ - body: expect.objectContaining({ - kind: 'status', - providerFrame: expect.objectContaining({ kind: 'frame:oversized-notification' }) - }) - }) - ]) - const diagnostic = tap.rows[2]?.body - expect( - diagnostic?.kind === 'status' ? diagnostic.providerFrame?.payload.byteLength : 0 - ).toBeGreaterThan(0) - expect(JSON.stringify(diagnostic)).toContain('record-too-large') - }) - it('admits suppressed diagnostics before settling a completed turn', () => { const tap = recorder() let rejectSuppression = true diff --git a/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts b/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts index d4881de1897..a853ca2bfd8 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts @@ -2,7 +2,10 @@ import type { AgentJournalTurnLifecycle, AgentJournalTurnOutcome } from '../../shared/agent-session-journal-types' -import { agentJournalSubmissionKey } from '../../shared/agent-session-journal-item-key' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../shared/agent-session-journal-item-key' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CODEX_JOURNAL_ADMITTED, @@ -27,7 +30,10 @@ import { readCodexTurnId, readCodexTurnStatus } from './codex-structured-thread-facts' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import type { CodexRowAttribution } from './codex-subagent-linkage' +import type { CodexJournalCommandTurn } from './codex-journal-command-turn' +import { readRecord, readString } from './codex-item-field-readers' +import { recordCodexCommandTurnClaim } from './codex-command-turn-claim' type TurnBoundaryEvent = { sessionId: string @@ -37,6 +43,14 @@ type TurnBoundaryEvent = { dispatchSequenceAtReceipt?: number } +type TurnTerminal = { + state: 'completed' | 'interrupted' + completedAt: number + /** Null when Codex named no verdict, or when the host inferred this end itself. */ + outcome?: AgentJournalTurnOutcome | null + durationMs?: number | null +} + /** Opens and settles the durable lifecycle row for each primary-thread turn. */ export class CodexJournalTurnBoundaries { private readonly recentTurns = new CodexJournalRecentTurns() @@ -51,7 +65,8 @@ export class CodexJournalTurnBoundaries { clearPromptTurn?: (threadId: string, turnId: string) => void flushSuppression: () => CodexJournalTranslationAdmission resetActivity: (threadId: string) => void - linkageFor: CodexRowLinkage + attributionFor: CodexRowAttribution + commands: CodexJournalCommandTurn now?: () => number } ) {} @@ -65,15 +80,20 @@ export class CodexJournalTurnBoundaries { return { accepted: false, reason: 'backpressure' } } const startedAt = this.receiptTime(event) - const admission = publishCodexTurnLifecycle({ - sink: this.deps.sink, - primaryThreadId: this.deps.primaryThreadId(), - sessionId: event.sessionId, - threadId: event.threadId, - turnId, - state: 'running', - startedAt - }) + const command = + event.threadId === this.deps.primaryThreadId() ? this.deps.commands.claim(turnId) : null + // The command's turn is the record: this turn writes none, and its rows join the command's. + const admission = command + ? recordCodexCommandTurnClaim(this.deps.sink, agentJournalItemKey(command.identity), turnId) + : publishCodexTurnLifecycle({ + sink: this.deps.sink, + primaryThreadId: this.deps.primaryThreadId(), + sessionId: event.sessionId, + threadId: event.threadId, + turnId, + state: 'running', + startedAt + }) if (admission.accepted) { this.deps.activeTurns.remember( event.threadId, @@ -132,6 +152,8 @@ export class CodexJournalTurnBoundaries { return admission } + /** Codex ends every turn with exactly one `turn/completed`, a failed one after + * its `error` frame included, so this is the only live end. */ complete(event: TurnBoundaryEvent): CodexJournalTranslationAdmission { const suppressionAdmission = this.deps.flushSuppression() if (!suppressionAdmission.accepted) { @@ -146,15 +168,20 @@ export class CodexJournalTurnBoundaries { // turn boundary is no evidence contact was lost. Only `settleSession` may // write `unverifiable`. const status = readCodexTurnStatus(event.params) - const turnLifecycle = - event.threadId === this.deps.primaryThreadId() - ? this.settled(event.threadId, turnId, { - state: codexTurnLifecycleState(status), - outcome: codexTurnOutcome(status), - completedAt: this.receiptTime(event), - durationMs: readCodexTurnDurationMs(event.params) - }) - : null + const completedAt = this.receiptTime(event) + const commandEnd = this.deps.commands.end(turnId, { + status, + error: readString(readRecord(readRecord(readRecord(event.params).turn).error), 'message'), + completedAt + }) + const turnLifecycle = this.ownsRecord(event.threadId, turnId) + ? this.settled(event.threadId, turnId, { + state: codexTurnLifecycleState(status), + outcome: codexTurnOutcome(status), + completedAt, + durationMs: readCodexTurnDurationMs(event.params) + }) + : null const requestOrigin = this.deps.activeTurns.requestOrigin(event.threadId, turnId) const latestDispatchSequence = this.deps.activeTurns.latestDispatchSequence( event.threadId, @@ -170,69 +197,8 @@ export class CodexJournalTurnBoundaries { activeItems: this.deps.items.activeItems, pendingPrompts: this.deps.pendingPrompts, ...(this.deps.clearPromptTurn ? { clearPromptTurn: this.deps.clearPromptTurn } : {}), - linkageFor: this.deps.linkageFor - }) - if (admission.accepted) { - if (turnLifecycle) { - this.recentTurns.remember( - event.threadId, - turnLifecycle, - requestOrigin, - latestDispatchSequence - ) - } - this.deps.items.ordinals.forgetTurn(event.threadId, turnId) - this.deps.activeTurns.forget(event.threadId, turnId) - this.deps.resetActivity(event.threadId) - } - return admission - } - - /** - * Settles the turn a terminal `error` names. - * - * Codex reports a fault that ended a turn as an `error` notification carrying - * that turn's id, and `turn/completed` may never follow it — the app server - * marks the thread not-running off the error alone. Without this the running - * lifecycle row is a latch nothing re-derives, and the chat reads "Working" - * for the life of the session. A retrying stream error is NOT a turn end and - * never reaches here. - */ - fail(event: TurnBoundaryEvent): CodexJournalTranslationAdmission { - const suppressionAdmission = this.deps.flushSuppression() - if (!suppressionAdmission.accepted) { - return suppressionAdmission - } - const turnId = readCodexTurnId(event.params) ?? this.deps.activeTurns.current(event.threadId) - // An error naming an already-settled turn is not a second end: its terminal - // row holds the start and duration this one could not reconstruct. - if (!turnId || !this.deps.activeTurns.isActive(event.threadId, turnId)) { - return CODEX_JOURNAL_ADMITTED - } - const turnLifecycle = - event.threadId === this.deps.primaryThreadId() - ? this.settled(event.threadId, turnId, { - state: 'completed', - outcome: 'failure', - completedAt: this.receiptTime(event) - }) - : null - const requestOrigin = this.deps.activeTurns.requestOrigin(event.threadId, turnId) - const latestDispatchSequence = this.deps.activeTurns.latestDispatchSequence( - event.threadId, - turnId - ) - const admission = settleCodexJournalTurn({ - sink: this.deps.sink, - sessionId: event.sessionId, - threadId: event.threadId, - turnId, - turnLifecycle, - streams: this.deps.items.streams, - activeItems: this.deps.items.activeItems, - pendingPrompts: this.deps.pendingPrompts, - ...(this.deps.clearPromptTurn ? { clearPromptTurn: this.deps.clearPromptTurn } : {}), - linkageFor: this.deps.linkageFor + attributionFor: this.deps.attributionFor, + commandEnd }) if (admission.accepted) { if (turnLifecycle) { @@ -245,6 +211,7 @@ export class CodexJournalTurnBoundaries { } this.deps.items.ordinals.forgetTurn(event.threadId, turnId) this.deps.activeTurns.forget(event.threadId, turnId) + this.deps.commands.settled(turnId) this.deps.resetActivity(event.threadId) } return admission @@ -252,17 +219,7 @@ export class CodexJournalTurnBoundaries { /** Terminal lifecycle for a remembered turn; `startedAt` is absent when the start was never seen. * The verdict travels as one record so a caller cannot supply the state and drop the outcome. */ - settled( - threadId: string, - turnId: string, - terminal: { - state: 'completed' | 'interrupted' - completedAt: number - /** Null when Codex named no verdict, or when the host inferred this end itself. */ - outcome?: AgentJournalTurnOutcome | null - durationMs?: number | null - } - ): AgentJournalTurnLifecycle { + settled(threadId: string, turnId: string, terminal: TurnTerminal): AgentJournalTurnLifecycle { const startedAt = this.deps.activeTurns.startedAt(threadId, turnId) // Carried forward from the exact echoed send that was attributed to this turn. const requestOrigin = this.deps.activeTurns.requestOrigin(threadId, turnId) @@ -278,9 +235,15 @@ export class CodexJournalTurnBoundaries { } } + /** Whether this translator writes the turn's record: a primary turn no command claimed. */ + ownsRecord(threadId: string, turnId: string): boolean { + return threadId === this.deps.primaryThreadId() && !this.deps.commands.isCarrying(turnId) + } + clear(): void { this.deps.activeTurns.clear() this.recentTurns.clear() + this.deps.commands.clear() } private receiptTime(event: TurnBoundaryEvent): number { diff --git a/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts b/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts index 6fc1e88d184..6fb589caf4e 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts @@ -346,7 +346,7 @@ describe('codex turn lifecycle rows', () => { const lifecycle = reduced(tap.rows).find((row) => row.key === LIFECYCLE_KEY) expect(lifecycle?.body).toMatchObject({ kind: 'turn', - state: 'interrupted', + state: 'completed', outcome: 'failure', requestedAt: 900 }) @@ -381,17 +381,17 @@ describe('codex turn lifecycle rows', () => { }) // `TurnStatus` in the app-server protocol is `completed | interrupted | failed | - // inProgress`, and every one of those collapses to the same terminal lifecycle - // arm. `outcome` is what keeps a Codex failure distinguishable from a stop, and - // a status this build cannot place stays unknown rather than borrowing one. + // inProgress`. Only `interrupted` is a stop; every other end completed the + // turn, and `outcome` says how. A status this build cannot place stays unknown + // rather than borrowing a verdict. it.each([ - ['interrupted', 'cancellation'], - ['failed', 'failure'], - ['cancelled', undefined], - ['inProgress', undefined] + ['interrupted', 'interrupted', 'cancellation'], + ['failed', 'completed', 'failure'], + ['someFutureStatus', 'completed', undefined], + ['inProgress', 'completed', undefined] ] as const)( - 'maps a %s turn status to an interrupted lifecycle with outcome %s', - (status, outcome) => { + 'maps a %s turn status to a %s lifecycle with outcome %s', + (status, state, outcome) => { const tap = recorder() const translator = translatorFor(tap) @@ -405,7 +405,7 @@ describe('codex turn lifecycle rows', () => { body: { kind: 'turn', turnId: TURN_ID, - state: 'interrupted', + state, ...(outcome ? { outcome } : {}), userItemId: USER_ITEM_ID, startedAt: 1_000, @@ -512,14 +512,29 @@ describe('codex turn lifecycle rows', () => { completedAt: 1_700_000_101, items: [] }, + { + id: 'turn-failed', + status: 'failed', + startedAt: 1_700_000_150, + completedAt: 1_700_000_152, + durationMs: 2_400, + items: [] + }, + { + id: 'turn-unplaced', + status: 'someFutureStatus', + startedAt: 1_700_000_170, + completedAt: 1_700_000_171, + items: [] + }, { id: 'turn-open', status: 'inProgress', startedAt: 1_700_000_200, items: [] }, { id: 'turn-untimed', status: 'completed', items: [] } ] }) ).toEqual({ accepted: true }) + // Each record precedes its turn's items, the order the live path writes. expect(tap.rows).toEqual([ - expect.objectContaining({ body: expect.objectContaining({ kind: 'message' }) }), { key: 'legacy:codex:session-1:turn-lifecycle%3Aturn-done', body: { @@ -533,6 +548,7 @@ describe('codex turn lifecycle rows', () => { durationMs: 41_900 } }, + expect.objectContaining({ body: expect.objectContaining({ kind: 'message' }) }), { key: 'legacy:codex:session-1:turn-lifecycle%3Aturn-cut', body: { @@ -544,6 +560,32 @@ describe('codex turn lifecycle rows', () => { startedAt: 1_700_000_100_000, completedAt: 1_700_000_101_000 } + }, + { + // The same shape a live failed completion writes. + key: 'legacy:codex:session-1:turn-lifecycle%3Aturn-failed', + body: { + kind: 'turn', + turnId: 'turn-failed', + state: 'completed', + outcome: 'failure', + userItemId: 'codex:thread-abc:turn-failed:0', + startedAt: 1_700_000_150_000, + completedAt: 1_700_000_152_000, + durationMs: 2_400 + } + }, + { + // Ended, but not a status this build can place: no verdict, never a clean finish. + key: 'legacy:codex:session-1:turn-lifecycle%3Aturn-unplaced', + body: { + kind: 'turn', + turnId: 'turn-unplaced', + state: 'completed', + userItemId: 'codex:thread-abc:turn-unplaced:0', + startedAt: 1_700_000_170_000, + completedAt: 1_700_000_171_000 + } } ]) expect(tap.tombstones).toEqual([]) diff --git a/src/main/codex/codex-structured-journal-translation-turn-settles-once.test.ts b/src/main/codex/codex-structured-journal-translation-turn-settles-once.test.ts new file mode 100644 index 00000000000..77638a7d1ea --- /dev/null +++ b/src/main/codex/codex-structured-journal-translation-turn-settles-once.test.ts @@ -0,0 +1,299 @@ +import { describe, expect, it } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../shared/agent-session-journal-item-key' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' + +const SESSION_ID = 'session-1' +const THREAD_ID = 'thread-abc' +const TURN_ID = 'turn-1' +const NEXT_TURN_ID = 'turn-2' +const CLIENT_MESSAGE_ID = 'client-1' + +type Row = { key: string; body: AgentJournalItemBody } + +function recorder() { + const rows: Row[] = [] + let refuseTerminal = 0 + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity: AgentJournalItemIdentity, body) => + rows.push({ key: agentJournalItemKey(identity), body }), + tryAppendItem: (identity: AgentJournalItemIdentity, body) => { + if (body.kind === 'turn' && body.state !== 'running' && refuseTerminal > 0) { + refuseTerminal -= 1 + return { accepted: false, reason: 'backpressure' } + } + rows.push({ key: agentJournalItemKey(identity), body }) + return { accepted: true } + }, + appendTombstone: () => {}, + publish: () => {} + } + return { + sink, + rows, + refuseNextTerminalWrite: () => { + refuseTerminal += 1 + } + } +} + +function notification(method: string, params: unknown, observedAt: number) { + return { + type: 'notification', + sessionId: SESSION_ID, + threadId: THREAD_ID, + method, + params, + observedAt + } satisfies CodexStructuredSessionEvent +} + +function translator(tap: ReturnType) { + return createCodexJournalTranslator({ + sink: tap.sink, + sessionId: SESSION_ID, + primaryThreadId: () => THREAD_ID, + dispatchRequestOrigin: () => ({ requestedAt: 900, sequence: 0 }) + }) +} + +/** Every lifecycle write for a turn, in order: what a reader could observe. */ +function turnWrites(rows: readonly Row[], turnId: string) { + return rows.flatMap((row) => + row.body.kind === 'turn' && row.body.turnId === turnId ? [row.body] : [] + ) +} + +function terminalWrites(rows: readonly Row[], turnId: string) { + return turnWrites(rows, turnId).filter((body) => body.state !== 'running') +} + +/** The body the journal reducer keeps for a turn's lifecycle row. */ +function settledRecord(rows: readonly Row[], turnId: string) { + return rows + .map((row) => row.body) + .findLast((body) => body.kind === 'turn' && body.turnId === turnId) +} + +/** Codex's frames for a turn that fails: the error, then the failed completion. */ +function runFailedTurn(handle: (event: CodexStructuredSessionEvent) => unknown) { + handle(notification('turn/started', { turn: { id: TURN_ID } }, 1_000)) + handle( + notification( + 'item/started', + { + turnId: TURN_ID, + turn: { id: TURN_ID }, + item: { type: 'userMessage', id: 'user-1', clientId: CLIENT_MESSAGE_ID } + }, + 1_100 + ) + ) + handle( + notification( + 'item/completed', + { + turnId: TURN_ID, + item: { type: 'agentMessage', id: 'agent-1', text: 'Checking the build' } + }, + 1_500 + ) + ) + handle( + notification( + 'error', + { + threadId: THREAD_ID, + turnId: TURN_ID, + willRetry: false, + error: { message: 'stream disconnected before completion' } + }, + 2_000 + ) + ) + handle( + notification( + 'turn/completed', + { turn: { id: TURN_ID, status: 'failed', durationMs: 1_100 } }, + 2_100 + ) + ) +} + +describe('a Codex turn ends once, on its completion', () => { + it('records the failed completion Codex sends after the error, with the start this host saw', () => { + const tap = recorder() + const codex = translator(tap) + + runFailedTurn((event) => codex.handle(event)) + + expect(terminalWrites(tap.rows, TURN_ID)).toHaveLength(1) + expect(settledRecord(tap.rows, TURN_ID)).toEqual({ + kind: 'turn', + turnId: TURN_ID, + state: 'completed', + outcome: 'failure', + userItemId: agentJournalSubmissionKey(CLIENT_MESSAGE_ID), + startedAt: 1_000, + requestedAt: 900, + completedAt: 2_100, + durationMs: 1_100 + }) + expect( + tap.rows.filter((row) => row.body.kind === 'status' && row.body.tone === 'error') + ).toHaveLength(1) + }) + + it('records an exit between the error and the completion as interrupted, with no verdict', () => { + // Orca records only an end it observed. The exit is that end; the failure + // Codex would have named arrives only in the completion, which never came. + const tap = recorder() + const codex = translator(tap) + const events: CodexStructuredSessionEvent[] = [] + runFailedTurn((event) => events.push(event)) + + for (const event of events.slice(0, -1)) { + codex.handle(event) + } + codex.handle({ + type: 'ended', + sessionId: SESSION_ID, + reason: 'lost child', + cause: 'unexpected-exit', + fence: 1, + acquisitionGeneration: 'generation-1', + observedAt: 2_050 + }) + + expect(terminalWrites(tap.rows, TURN_ID)).toEqual([ + { + kind: 'turn', + turnId: TURN_ID, + state: 'interrupted', + userItemId: agentJournalSubmissionKey(CLIENT_MESSAGE_ID), + startedAt: 1_000, + requestedAt: 900, + completedAt: 2_050 + } + ]) + expect( + tap.rows.filter((row) => row.body.kind === 'status' && row.body.tone === 'error') + ).toHaveLength(1) + }) + + it('revises the still-open turn with a send Codex echoes between the error and the completion', () => { + // Codex records a failed turn's pending input after its `error` frame and + // before `turn/completed`, so the echo belongs to a turn that is still open. + const tap = recorder() + const codex = translator(tap) + const events: CodexStructuredSessionEvent[] = [] + runFailedTurn((event) => events.push(event)) + const [started, echo, reply, error, completion] = events + + for (const event of [started, reply, error, echo, completion]) { + if (event) { + codex.handle(event) + } + } + + expect(turnWrites(tap.rows, TURN_ID).map((body) => body.state)).toEqual([ + 'running', + 'running', + 'completed' + ]) + expect(settledRecord(tap.rows, TURN_ID)).toMatchObject({ + outcome: 'failure', + userItemId: agentJournalSubmissionKey(CLIENT_MESSAGE_ID), + requestedAt: 900, + startedAt: 1_000, + durationMs: 1_100 + }) + }) + + it('settles once when the sink refuses the completion and its retry lands', () => { + // A refused frame is Orca's only redelivery of a completion, and a refused + // end changes nothing, so the retry settles the turn exactly once. + const tap = recorder() + const codex = translator(tap) + const completion = notification( + 'turn/completed', + { turn: { id: TURN_ID, status: 'completed', durationMs: 900 } }, + 2_000 + ) + + codex.handle(notification('turn/started', { turn: { id: TURN_ID } }, 1_000)) + tap.refuseNextTerminalWrite() + expect(codex.handle(completion)).toEqual({ accepted: false, reason: 'backpressure' }) + expect(codex.handle(completion)).toEqual({ accepted: true }) + + expect(terminalWrites(tap.rows, TURN_ID)).toEqual([ + expect.objectContaining({ + state: 'completed', + outcome: 'success', + startedAt: 1_000, + completedAt: 2_000, + durationMs: 900 + }) + ]) + }) + + it('settles an ordinary turn exactly as before', () => { + const tap = recorder() + const codex = translator(tap) + + codex.handle(notification('turn/started', { turn: { id: TURN_ID } }, 1_000)) + codex.handle( + notification( + 'turn/completed', + { turn: { id: TURN_ID, status: 'completed', durationMs: 3_250 } }, + 4_500 + ) + ) + + expect(terminalWrites(tap.rows, TURN_ID)).toEqual([ + { + kind: 'turn', + turnId: TURN_ID, + state: 'completed', + outcome: 'success', + userItemId: `codex:${THREAD_ID}:${TURN_ID}:0`, + startedAt: 1_000, + completedAt: 4_500, + durationMs: 3_250 + } + ]) + }) + + it('settles the next turn on its own after a failed one', () => { + const tap = recorder() + const codex = translator(tap) + + runFailedTurn((event) => codex.handle(event)) + const failed = settledRecord(tap.rows, TURN_ID) + codex.handle(notification('turn/started', { turn: { id: NEXT_TURN_ID } }, 3_000)) + codex.handle( + notification( + 'turn/completed', + { turn: { id: NEXT_TURN_ID, status: 'completed', durationMs: 1_000 } }, + 4_000 + ) + ) + + expect(settledRecord(tap.rows, TURN_ID)).toEqual(failed) + expect(terminalWrites(tap.rows, NEXT_TURN_ID)).toHaveLength(1) + expect(settledRecord(tap.rows, NEXT_TURN_ID)).toMatchObject({ + state: 'completed', + outcome: 'success', + startedAt: 3_000, + completedAt: 4_000 + }) + }) +}) diff --git a/src/main/codex/codex-structured-journal-translation-turn-state.ts b/src/main/codex/codex-structured-journal-translation-turn-state.ts index ec1cff041db..4c1e739705d 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-state.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-state.ts @@ -57,12 +57,6 @@ export class CodexJournalActiveTurns { return [...(this.byThread.get(threadId) ?? [])].at(-1) ?? null } - /** Whether this turn is still open here. A terminal row already written carries - * the turn's start and duration, so a later end must not overwrite it. */ - isActive(threadId: string, turnId: string): boolean { - return this.byThread.get(threadId)?.has(turnId) === true - } - startedAt(threadId: string, turnId: string): number | undefined { return this.startedAtByTurn.get(this.turnKey(threadId, turnId)) } @@ -167,7 +161,7 @@ type RecentTurn = { bytes: number } -/** Bounded terminal lifecycle window for exact echoes that arrive after completion. */ +/** Bounded terminal lifecycle window: exact echoes that arrive after completion revise it. */ export class CodexJournalRecentTurns { private readonly turns = new Map() private retainedBytes = 0 diff --git a/src/main/codex/codex-structured-journal-translation-turns.ts b/src/main/codex/codex-structured-journal-translation-turns.ts index 8678c175599..349233a32d1 100644 --- a/src/main/codex/codex-structured-journal-translation-turns.ts +++ b/src/main/codex/codex-structured-journal-translation-turns.ts @@ -1,9 +1,10 @@ -import type { - AgentJournalItemIdentity, - AgentJournalTurnItem, - AgentJournalTurnLifecycle, - AgentJournalTurnLifecycleState, - AgentJournalTurnOutcome +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalTurnItem, + type AgentJournalTurnLifecycle, + type AgentJournalTurnLifecycleState, + type AgentJournalTurnOutcome } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' @@ -43,12 +44,12 @@ export function codexTurnLifecycleBody( return agentJournalTurnBody(turnLifecycle) } -/** Maps a `turn/completed` status; a missing one is a clean finish. A terminal - * `error` also ends a turn and names its own outcome rather than coming here. */ +/** Maps a `turn/completed` status, live or restored. Only `interrupted` is a stop; + * a failed turn completed, and `codexTurnOutcome` says it failed. */ export function codexTurnLifecycleState( status: string | null ): Extract { - return status === null || status === 'completed' ? 'completed' : 'interrupted' + return status === 'interrupted' ? 'interrupted' : 'completed' } /** @@ -109,6 +110,7 @@ export function publishCodexTurnLifecycle(input: { // The running row's `ts` is the host's turn-start receipt so clients can anchor a live counter. const appendOptions = { lifecycle: true, + turnScope: AGENT_JOURNAL_THREAD_SCOPE, ...(input.state === 'running' && input.startedAt !== undefined ? { observedAt: input.startedAt } : {}) diff --git a/src/main/codex/codex-structured-journal-translation-writers.ts b/src/main/codex/codex-structured-journal-translation-writers.ts index c4fba2c1370..b13a7212142 100644 --- a/src/main/codex/codex-structured-journal-translation-writers.ts +++ b/src/main/codex/codex-structured-journal-translation-writers.ts @@ -1,6 +1,6 @@ // The translator's writers, built once. Split out so the translator reads as // routing, and so one place shows that every writer is handed the same -// producer resolver: the roster's, which knows each child thread. +// row attribution: the roster's producer, which knows each child thread, and the turn scope. import { CodexJournalCompactions } from './codex-structured-journal-compactions' import type { CodexJournalTranslatorDeps } from './codex-structured-journal-contracts' @@ -8,38 +8,55 @@ import { CodexJournalGenericFrames } from './codex-structured-journal-generic-fr import { CodexJournalGoals } from './codex-structured-journal-goals' import { CodexJournalItems } from './codex-structured-journal-items' import { CodexJournalPrompts } from './codex-structured-journal-prompts' -import { createCodexOversizedNotificationSettler } from './codex-structured-journal-translation-frames' import { CodexJournalActiveTurns } from './codex-structured-journal-translation-turn-state' +import { CodexJournalTurnScopes } from './codex-journal-turn-scopes' +import { CodexJournalCommandTurn } from './codex-journal-command-turn' import { CodexSubagentRoster } from './codex-subagent-roster' +import type { CodexRowAttribution } from './codex-subagent-linkage' export function createCodexJournalTranslatorWriters(deps: CodexJournalTranslatorDeps) { const activeTurns = new CodexJournalActiveTurns() const activeTurn = (threadId: string): string | null => activeTurns.current(threadId) + const primaryThreadId = (): string | null => deps.primaryThreadId?.() ?? null + const commands = new CodexJournalCommandTurn() + const turnScopes = new CodexJournalTurnScopes({ + sessionId: deps.sessionId, + primaryThreadId, + activeTurn, + commandScope: (turnId) => commands.scopeFor(turnId) + }) const subagents = new CodexSubagentRoster({ sink: deps.sink, - primaryThreadId: () => deps.primaryThreadId?.() ?? null, + primaryThreadId, activeTurn, + turnScopeFor: (threadId, turnId) => turnScopes.scopeFor(threadId, turnId), ...(deps.subagentExecutions ? { executions: deps.subagentExecutions } : {}) }) const { linkageFor } = subagents.linkage - const producerDeps = { ...deps, linkageFor } + const attributionFor: CodexRowAttribution = (threadId, turnId) => ({ + ...linkageFor(threadId, turnId), + turnScope: turnScopes.scopeFor(threadId, turnId) + }) + const producerDeps = { ...deps, attributionFor } const genericFrames = new CodexJournalGenericFrames(producerDeps, activeTurn) const items = new CodexJournalItems(producerDeps, activeTurn, (threadId, turnId) => genericFrames.suppress(threadId, turnId) ) return { activeTurns, + commands, subagents, - linkageFor, + attributionFor, genericFrames, items, - compactions: new CodexJournalCompactions(deps.sink, activeTurn, linkageFor), - goals: new CodexJournalGoals(deps.sink, linkageFor), + compactions: new CodexJournalCompactions(deps.sink, activeTurn, attributionFor, (turnId) => + commands.compacted(turnId) + ), + goals: new CodexJournalGoals(deps.sink, attributionFor), prompts: new CodexJournalPrompts( producerDeps, (threadId, itemId) => items.detailFor(threadId, itemId), activeTurn - ), - settleOversizedNotification: createCodexOversizedNotificationSettler(producerDeps, items) + ) } } diff --git a/src/main/codex/codex-structured-journal-translation.test.ts b/src/main/codex/codex-structured-journal-translation.test.ts index d902b7b4725..676d9054ed7 100644 --- a/src/main/codex/codex-structured-journal-translation.test.ts +++ b/src/main/codex/codex-structured-journal-translation.test.ts @@ -287,7 +287,7 @@ describe('codex journal translation', () => { ).toBe('idle') }) - describe('a terminal error ends the turn it names', () => { + describe('a turn-ending error is a row on the turn it names, and the completion ends it', () => { function statusOf(rows: readonly Row[]) { return projectStructuredAgentSessionStatus( reduced(rows).map((row, sequence) => ({ @@ -308,7 +308,11 @@ describe('codex journal translation', () => { }) } - it('settles the turn and keeps the provider sentence as its own row', () => { + const FAILED_COMPLETION = notification('turn/completed', { + turn: { id: TURN_ID, status: 'failed', durationMs: 2_400 } + }) + + it('keeps the turn working through the error, and the failed completion settles it', () => { const tap = recorder() const translator = createCodexJournalTranslator({ sink: tap.sink, @@ -316,17 +320,21 @@ describe('codex journal translation', () => { }) translator.handle(TURN_STARTED) - expect(statusOf(tap.rows)).toBe('working') - translator.handle(errorNotification({ turnId: TURN_ID, willRetry: false })) + expect(statusOf(tap.rows)).toBe('working') + expect(tap.rows.filter((row) => row.body.kind === 'turn')).toHaveLength(1) + + translator.handle(FAILED_COMPLETION) + expect(statusOf(tap.rows)).toBe('idle') expect(reduced(tap.rows).map((row) => row.body)).toContainEqual( expect.objectContaining({ kind: 'turn', turnId: TURN_ID, state: 'completed', - outcome: 'failure' + outcome: 'failure', + durationMs: 2_400 }) ) // The message the user reads is still a row of its own. @@ -353,7 +361,7 @@ describe('codex journal translation', () => { expect(tap.rows.filter((row) => row.body.kind === 'turn')).toHaveLength(1) }) - it('does not overwrite the terminal row of a turn that already completed', () => { + it('writes only its row for an error after the turn completed', () => { const tap = recorder() const translator = createCodexJournalTranslator({ sink: tap.sink, @@ -361,21 +369,16 @@ describe('codex journal translation', () => { }) translator.handle(TURN_STARTED) - translator.handle( - notification('turn/completed', { - turn: { id: TURN_ID, status: 'failed', durationMs: 4_000 } - }) - ) + translator.handle(FAILED_COMPLETION) const settled = reduced(tap.rows).find((row) => row.body.kind === 'turn')?.body translator.handle(errorNotification({ turnId: TURN_ID, willRetry: false })) - // The completion carries the duration a late error could not reconstruct. expect(reduced(tap.rows).find((row) => row.body.kind === 'turn')?.body).toEqual(settled) expect(statusOf(tap.rows)).toBe('idle') }) - it('settles the running turn when the error names no turn', () => { + it('settles nothing when the error names no turn', () => { const tap = recorder() const translator = createCodexJournalTranslator({ sink: tap.sink, @@ -385,21 +388,8 @@ describe('codex journal translation', () => { translator.handle(TURN_STARTED) translator.handle(errorNotification({ willRetry: false })) - expect(statusOf(tap.rows)).toBe('idle') - }) - - it('does not reopen a running row when the completion arrives after the error', () => { - const tap = recorder() - const translator = createCodexJournalTranslator({ - sink: tap.sink, - primaryThreadId: () => THREAD_ID - }) - - translator.handle(TURN_STARTED) - translator.handle(errorNotification({ turnId: TURN_ID, willRetry: false })) - translator.handle(notification('turn/completed', { turn: { id: TURN_ID, status: 'failed' } })) - - expect(statusOf(tap.rows)).toBe('idle') + expect(statusOf(tap.rows)).toBe('working') + expect(tap.rows.filter((row) => row.body.kind === 'turn')).toHaveLength(1) }) }) @@ -446,14 +436,12 @@ describe('codex journal translation', () => { expect(stopped).toEqual([]) }) - it('releases after systemError arrives before the terminal error notification', () => { + it('releases after the failed completion that follows systemError and the error', () => { const stopped: string[] = [] const { translator } = translatorReporting(stopped) translator.handle(TURN_STARTED) translator.handle(statusChanged('systemError')) - expect(stopped).toEqual([]) - translator.handle( notification('error', { turnId: TURN_ID, @@ -461,6 +449,9 @@ describe('codex journal translation', () => { error: { message: 'fatal' } }) ) + expect(stopped).toEqual([]) + + translator.handle(notification('turn/completed', { turn: { id: TURN_ID, status: 'failed' } })) expect(stopped).toEqual([THREAD_ID]) }) diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index fb90cb73128..8e05506509a 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -11,9 +11,8 @@ import { restoreCodexJournalThread } from './codex-structured-journal-translatio import { CodexJournalTurnBoundaries } from './codex-structured-journal-translation-turn-boundaries' import { createCodexJournalTranslatorWriters } from './codex-structured-journal-translation-writers' import { publishCodexTurnLifecycle } from './codex-structured-journal-translation-turns' -import { readCodexProviderVerdict } from './codex-structured-journal-provider-verdicts' import { createCodexThreadItemRouter } from './codex-structured-journal-thread-item-routing' -import { readCodexTurnId } from './codex-structured-thread-facts' +import { codexThreadStoppedRunning, readCodexTurnId } from './codex-structured-thread-facts' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' export type { @@ -38,14 +37,14 @@ export function createCodexJournalTranslator( ): CodexJournalTranslator { const { activeTurns, + commands, subagents, - linkageFor, + attributionFor, genericFrames, items, compactions, goals, - prompts, - settleOversizedNotification + prompts } = createCodexJournalTranslatorWriters(deps) const flushStreams = (): CodexJournalTranslationAdmission => items.streams.flush() ? CODEX_JOURNAL_ADMITTED : { accepted: false, reason: 'backpressure' } @@ -65,7 +64,8 @@ export function createCodexJournalTranslator( ...(deps.clearPromptTurn ? { clearPromptTurn: deps.clearPromptTurn } : {}), flushSuppression: () => genericFrames.flush(), resetActivity, - linkageFor, + attributionFor, + commands, ...(deps.now ? { now: deps.now } : {}) }) let primaryThreadStoppedRunning = false @@ -160,11 +160,13 @@ export function createCodexJournalTranslator( // The host saw the child go, not what Codex made of the turn, so the row // carries no outcome: the end is observed, the verdict is unknown. settledTurnLifecycle: (threadId, turnId) => - turnBoundaries.settled(threadId, turnId, { - state: 'interrupted', - completedAt: event.observedAt ?? deps.now?.() ?? Date.now() - }), - linkageFor + turnBoundaries.ownsRecord(threadId, turnId) + ? turnBoundaries.settled(threadId, turnId, { + state: 'interrupted', + completedAt: event.observedAt ?? deps.now?.() ?? Date.now() + }) + : null, + attributionFor }) if (!admission.accepted) { return admission @@ -205,10 +207,6 @@ export function createCodexJournalTranslator( ) } if (event.type === 'provider-frame') { - const settlement = settleOversizedNotification(event) - if (settlement && !settlement.accepted) { - return settlement - } return genericFrames.appendUnhandled(event.kind, event.payload, event.threadId) } if (event.method === 'turn/started' || event.method === 'turn/completed') { @@ -249,30 +247,32 @@ export function createCodexJournalTranslator( return publishActivity(event, routed) } } - const verdict = readCodexProviderVerdict(event.method, event.params) + // A thread that stopped running settles no open turn: Codex clears `running` + // on every error, and an open turn ends on its `turn/completed`. It releases + // a send whose dispatch was never answered, which nothing else re-derives live. if ( - verdict === 'thread-stopped-running' && + event.method === 'thread/status/changed' && + codexThreadStoppedRunning(event.params) && event.threadId === (deps.primaryThreadId?.() ?? null) ) { primaryThreadStoppedRunning = true reportPrimaryThreadStoppedRunning() } - // The row carries the provider's sentence and is written first, so it lands - // inside the turn this same frame is about to end. + // A turn-ending `error` is a row inside the turn it names; the failed + // `turn/completed` Codex sends after it is that turn's end. const unhandled = genericFrames.appendUnhandled( `notification:${event.method}`, event.params, event.threadId ) - if (unhandled.accepted && verdict === 'turn-failed') { - const failed = turnBoundaries.fail(event) - if (!failed.accepted) { - return failed - } - reportPrimaryThreadStoppedRunning() + if (unhandled.accepted && event.method === 'error') { + commands.errorShown(event.params) } return publishActivity(event, unhandled) }, + beginCommand: (command) => commands.begin(command), + forgetCommand: (turnId) => commands.forget(turnId), + commandProviderTurnId: (turnId) => commands.providerTurnId(turnId), cancelPrompt: (journalItemId) => prompts.cancel(journalItemId), resolvePrompt: (journalItemId) => prompts.resolve(journalItemId), flush: () => { diff --git a/src/main/codex/codex-structured-journal-turn-endings-replay.test.ts b/src/main/codex/codex-structured-journal-turn-endings-replay.test.ts new file mode 100644 index 00000000000..d1de7e74766 --- /dev/null +++ b/src/main/codex/codex-structured-journal-turn-endings-replay.test.ts @@ -0,0 +1,206 @@ +// Real `codex app-server` frame orders, replayed through the translator. +// +// The fixture is trimmed from captures of the real binary on 0.141.0 (the oldest +// version Orca exercises) and 0.158.0, with only the upstream provider faked: +// statuses, `willRetry`, the provider's sentence and `durationMs`, at the host +// receipt time `t` each frame arrived. Ids are replaced with synthetic ones. + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import { + readCodexThreadId, + readCodexTurnDurationMs, + readCodexTurnId, + readCodexTurnStatus +} from './codex-structured-thread-facts' + +const SESSION_ID = 'session-1' + +type CapturedFrame = { case: string; t: number; method: string; params: Record } +type Row = { key: string; body: AgentJournalItemBody } + +const FRAMES: CapturedFrame[] = readFileSync( + join(__dirname, '__fixtures__', 'codex-app-server-turn-endings.jsonl'), + 'utf8' +) + .split('\n') + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line)) + +function framesOf(name: string): CapturedFrame[] { + const frames = FRAMES.filter((frame) => frame.case === name) + expect(frames.length).toBeGreaterThan(0) + return frames +} + +/** Replays a case's frames, or only its first `count`. */ +function replay(name: string, count = Number.POSITIVE_INFINITY) { + const frames = framesOf(name).slice(0, count) + const threadId = readCodexThreadId(frames[0]?.params) ?? '' + const rows: Row[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity: AgentJournalItemIdentity, body) => + rows.push({ key: agentJournalItemKey(identity), body }), + appendTombstone: () => {}, + publish: () => {} + } + const translator = createCodexJournalTranslator({ + sink, + sessionId: SESSION_ID, + primaryThreadId: () => threadId + }) + for (const frame of frames) { + expect( + translator.handle({ + type: 'notification', + sessionId: SESSION_ID, + threadId, + method: frame.method, + params: frame.params, + observedAt: frame.t + }) + ).toEqual({ accepted: true }) + } + return { frames, rows, translator } +} + +function turnWrites(rows: readonly Row[], turnId: string) { + return rows + .map((row) => row.body) + .filter((body) => body.kind === 'turn' && body.turnId === turnId) +} + +function terminalWrites(rows: readonly Row[], turnId: string) { + return turnWrites(rows, turnId).filter((body) => body.kind === 'turn' && body.state !== 'running') +} + +function receipt(frames: readonly CapturedFrame[], method: string, turnId: string): number { + const frame = frames.find( + (entry) => entry.method === method && readCodexTurnId(entry.params) === turnId + ) + if (!frame) { + throw new Error(`no ${method} for ${turnId}`) + } + return frame.t +} + +function completion(frames: readonly CapturedFrame[], turnId: string) { + const frame = frames.find( + (entry) => entry.method === 'turn/completed' && readCodexTurnId(entry.params) === turnId + ) + return frame + ? { + t: frame.t, + status: readCodexTurnStatus(frame.params), + durationMs: readCodexTurnDurationMs(frame.params) + } + : null +} + +const VERDICT: Record = { + completed: { state: 'completed', outcome: 'success' }, + failed: { state: 'completed', outcome: 'failure' }, + interrupted: { state: 'interrupted', outcome: 'cancellation' } +} + +const ENDED_CASES = [...new Set(FRAMES.map((frame) => frame.case))].filter( + (name) => name !== '0.158.0-conn-refused' +) + +describe('captured Codex turns end once, on their own completion', () => { + it.each(ENDED_CASES)('%s', (name) => { + const { frames, rows } = replay(name) + const turnIds = frames + .filter((frame) => frame.method === 'turn/started') + .map((frame) => readCodexTurnId(frame.params) ?? '') + + expect(turnIds.length).toBeGreaterThan(0) + for (const turnId of turnIds) { + const end = completion(frames, turnId) + expect(end).not.toBeNull() + if (!end) { + continue + } + const verdict = VERDICT[end.status ?? ''] + expect(verdict).toBeDefined() + // One terminal write per turn, and it is Codex's own completion: its time, + // its duration and its verdict, with the start this host saw. + expect(terminalWrites(rows, turnId)).toEqual([ + expect.objectContaining({ + state: verdict?.state, + outcome: verdict?.outcome, + startedAt: receipt(frames, 'turn/started', turnId), + completedAt: end.t, + durationMs: end.durationMs + }) + ]) + } + }) + + it('keeps every failed turn working through its error until the completion', () => { + for (const name of ENDED_CASES) { + const frames = framesOf(name) + const failedTurns = frames + .filter((frame) => frame.method === 'error' && frame.params.willRetry === false) + .map((frame) => readCodexTurnId(frame.params) ?? '') + for (const turnId of failedTurns) { + const errorAt = frames.findIndex( + (frame) => + frame.method === 'error' && + frame.params.willRetry === false && + readCodexTurnId(frame.params) === turnId + ) + // Through the error, the turn is open; the completion right after it ends it. + expect(terminalWrites(replay(name, errorAt + 1).rows, turnId)).toEqual([]) + expect(terminalWrites(replay(name).rows, turnId)).toHaveLength(1) + } + } + }) + + it('keeps a turn Codex is still retrying on 0.158.0 running, and the exit sweep ends it', () => { + const { frames, rows, translator } = replay('0.158.0-conn-refused') + const turnId = + readCodexTurnId(frames.find((frame) => frame.method === 'turn/started')?.params) ?? '' + const retries = frames.filter((frame) => frame.method === 'error') + + // Seven "Reconnecting... waiting for network" frames over four minutes, all + // willRetry, and no completion: the turn is genuinely still open. + expect(retries).toHaveLength(7) + expect(retries.every((frame) => frame.params.willRetry === true)).toBe(true) + expect(turnWrites(rows, turnId)).toEqual([ + expect.objectContaining({ + state: 'running', + startedAt: receipt(frames, 'turn/started', turnId) + }) + ]) + + translator.handle({ + type: 'ended', + sessionId: SESSION_ID, + reason: 'lost child', + cause: 'unexpected-exit', + fence: 1, + acquisitionGeneration: 'generation-1', + observedAt: 250_000 + }) + + expect(terminalWrites(rows, turnId)).toEqual([ + { + kind: 'turn', + turnId, + state: 'interrupted', + userItemId: `codex:${readCodexThreadId(frames[0]?.params)}:${turnId}:0`, + startedAt: receipt(frames, 'turn/started', turnId), + completedAt: 250_000 + } + ]) + }) +}) diff --git a/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts b/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts new file mode 100644 index 00000000000..4e3caaa8bc0 --- /dev/null +++ b/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts @@ -0,0 +1,149 @@ +// A failed Codex turn through the real path its record takes: +// translator → deferred sink queue → on-disk journal → the shared turn-timing reader. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { + completedStructuredAgentTurnSeconds, + selectStructuredAgentRunningTurnTiming +} from '../../shared/structured-agent-session-turn-timing' +import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory' +import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' + +const SESSION = 'session-codex-failed-turn' +const THREAD = 'thread-abc' +const TURN = 'turn-1' + +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) { + await cleanup() + } +}) + +async function session() { + const root = await mkdtemp(join(tmpdir(), 'orca-codex-failed-turn-')) + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + journalDir: root, + now: () => 1_000 + }) + const deferred = createDeferredStructuredAgentSessionEventSink() + deferred.bind({ journal, fence: 1, publish: () => {} }) + cleanups.push(async () => { + deferred.close() + await journal.close() + await rm(root, { recursive: true, force: true }) + }) + const translator = createCodexJournalTranslator({ + sink: deferred.sink, + sessionId: SESSION, + primaryThreadId: () => THREAD, + schedule: (run) => { + run() + return () => {} + } + }) + const on = (method: string, params: Record, observedAt: number) => + translator.handle({ + type: 'notification', + sessionId: SESSION, + threadId: THREAD, + method, + params: { threadId: THREAD, ...params }, + observedAt + }) + return { + on, + drained: () => deferred.drained(), + items: async () => { + await deferred.drained() + return journal.snapshot().items + } + } +} + +function turnRecord(rows: Awaited>['items']>>) { + return rows + .map((item) => readAgentJournalTurn(item.body)) + .findLast((record) => record?.turnId === TURN) +} + +describe('a failed Codex turn in the journal', () => { + it('keeps the failed completion, its duration and the start, when it lands while the error is still queued', async () => { + const { on, drained, items } = await session() + on('turn/started', { turn: { id: TURN } }, 1_000) + on( + 'item/completed', + { turnId: TURN, item: { type: 'agentMessage', id: 'agent-1', text: 'Checking the build' } }, + 1_500 + ) + await drained() + + // Codex writes both frames back to back; the error's status row is still being + // written when the failed completion arrives. + on( + 'error', + { turnId: TURN, willRetry: false, error: { message: 'stream disconnected' } }, + 3_000 + ) + on('turn/completed', { turn: { id: TURN, status: 'failed', durationMs: 1_900 } }, 3_100) + + const rows = await items() + expect(turnRecord(rows)).toMatchObject({ + state: 'completed', + outcome: 'failure', + startedAt: 1_000, + completedAt: 3_100, + durationMs: 1_900 + }) + // "Worked for" under the turn's message reads Codex's own duration. + expect( + completedStructuredAgentTurnSeconds(selectStructuredAgentRunningTurnTiming(rows, TURN)) + ).toBe(1) + }) + + // Codex ends a turn once, so a second completion is not expected. If one came, + // the settlement id keeps the first record, whether it is still queued or written. + it.each([ + ['still queued', false], + ['already written', true] + ] as const)( + 'keeps the first completion when a second one arrives while the first is %s', + async (_label, drainFirst) => { + const { on, drained, items } = await session() + on('turn/started', { turn: { id: TURN } }, 1_000) + await drained() + + // The reply's row is being written, so the first completion waits behind it. + on( + 'item/completed', + { turnId: TURN, item: { type: 'agentMessage', id: 'agent-1', text: 'Done' } }, + 1_900 + ) + on('turn/completed', { turn: { id: TURN, status: 'completed', durationMs: 900 } }, 2_000) + if (drainFirst) { + await drained() + } + on('turn/completed', { turn: { id: TURN, status: 'failed' } }, 3_000) + + expect(turnRecord(await items())).toMatchObject({ + state: 'completed', + outcome: 'success', + startedAt: 1_000, + completedAt: 2_000, + durationMs: 900 + }) + } + ) +}) diff --git a/src/main/codex/codex-structured-launch-resolution.test.ts b/src/main/codex/codex-structured-launch-resolution.test.ts index 60aab5ed1db..36ab39e3bab 100644 --- a/src/main/codex/codex-structured-launch-resolution.test.ts +++ b/src/main/codex/codex-structured-launch-resolution.test.ts @@ -180,6 +180,16 @@ describe('codex structured launch resolution', () => { }) }) + // A thread opened on the configured default and then given a turn on the saved model reads to + // Codex as a model switch, and it injects the saved model's whole prompt a second time. + it('opens the thread on the model the record saved', async () => { + const launch = await resolverFor( + record({ options: { model: 'gpt-chosen', effort: 'high', fastMode: 'false' } }) + )({ identity: IDENTITY }) + + expect(launch.model).toBe('gpt-chosen') + }) + // The configured CLI arguments are a terminal concern: a durable record written before they // stopped being read must not smuggle one back into app-server's argv. it("ignores the record's durable launch arguments", async () => { diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 8a3fe002cb1..ebfdb27200a 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -94,6 +94,8 @@ export function createCodexStructuredLaunchResolver( const permissionPolicy = deps.resolvePermissionPolicy?.() const head = agentSessionProviderHandleChainHead(record.providerHandleChain) const resumeThreadId = head?.handle.provider === 'codex' ? head.handle.threadId : null + // The same saved options every turn sends, so the thread and its turns name one model. + const model = record.options?.model return { command, args: ['app-server'], @@ -107,6 +109,7 @@ export function createCodexStructuredLaunchResolver( // forked or adopted head names a conversation Codex held. ...(resumeThreadId && head?.origin === 'created' ? { supersedeIfUnsaved: true } : {}), ...(permissionPolicy ? { permissionPolicy } : {}), + ...(model ? { model } : {}), ...(resumeThreadId ? { resumePath: await (deps.resolveRollout ?? resolvePinnedCodexRolloutProof)( diff --git a/src/main/codex/codex-structured-owner-identity.ts b/src/main/codex/codex-structured-owner-identity.ts index 44f25ea4724..805a767e620 100644 --- a/src/main/codex/codex-structured-owner-identity.ts +++ b/src/main/codex/codex-structured-owner-identity.ts @@ -17,6 +17,32 @@ export const CODEX_SPAWN_TOKEN_ENV = 'ORCA_AGENT_SESSION_SPAWN_TOKEN' const START_TIME_READ_ATTEMPTS = 3 +/** + * The child's identity, read once. The real connection reports its spawn before the handshake, and + * `onSpawned` makes it durable there, so a crash mid-start leaves an owner recovery can stop; a + * connection that reports no spawn is identified once it is open. + */ +export function codexSpawnedProcessIdentity( + input: { + identity: AgentSessionJournalIdentity + spawnToken: string + onSpawned?: (process: AgentSessionProcessIdentity) => Promise + }, + readStartTime?: (pid: number) => Promise +): { + onSpawned: (pid: number) => Promise + read: (pid: number | undefined) => Promise +} { + let spawned: Promise | undefined + return { + onSpawned: async (pid) => { + spawned = codexProcessIdentity({ ...input, pid }, readStartTime) + await input.onSpawned?.(await spawned) + }, + read: (pid) => spawned ?? codexProcessIdentity({ ...input, pid }, readStartTime) + } +} + export async function codexProcessIdentity( input: { identity: AgentSessionJournalIdentity diff --git a/src/main/codex/codex-structured-prompt-ownership.test.ts b/src/main/codex/codex-structured-prompt-ownership.test.ts index cfb77e628a7..7dd048dd08a 100644 --- a/src/main/codex/codex-structured-prompt-ownership.test.ts +++ b/src/main/codex/codex-structured-prompt-ownership.test.ts @@ -159,7 +159,7 @@ describe('Codex live prompt ownership', () => { sessionId: 'session-1', itemId: 'journal-prompt', kind: 'approval', - optionId: 'accept', + response: { kind: 'option', optionId: 'accept' }, fence: 7, commit: async () => { expect(codex.connections[0]?.replies).toEqual([]) @@ -210,7 +210,7 @@ describe('Codex live prompt ownership', () => { sessionId: 'session-1', itemId: 'journal-prompt', kind: 'approval', - optionId: 'accept', + response: { kind: 'option', optionId: 'accept' }, fence: 7, commit }) @@ -224,7 +224,7 @@ describe('Codex live prompt ownership', () => { sessionId: 'session-1', itemId: 'journal-prompt', kind: 'approval', - optionId: 'accept', + response: { kind: 'option', optionId: 'accept' }, fence: 7, commit }) @@ -237,7 +237,7 @@ describe('Codex live prompt ownership', () => { sessionId: 'session-1', itemId: 'journal-prompt', kind: 'approval', - optionId: 'accept', + response: { kind: 'option', optionId: 'accept' }, fence: 8, commit }) @@ -261,12 +261,12 @@ describe('Codex live prompt ownership', () => { fence: 7, prompt: { itemId: 'journal-prompt' } }) - ).resolves.toEqual({ cancelled: false }) + ).resolves.toEqual({ cancelled: false, refusal: {} }) await adapter.answerPrompt({ sessionId: 'session-1', itemId: 'journal-prompt', kind: 'approval', - optionId: 'decline', + response: { kind: 'option', optionId: 'decline' }, fence: 7, commit: async () => undefined }) @@ -382,7 +382,7 @@ describe('Codex live prompt ownership', () => { sessionId: 'session-1', itemId: siblingItemId, kind: 'question', - optionId: 'no', + response: { kind: 'answers', answers: [{ questionId: 'q2', optionIds: [], other: 'no' }] }, fence: 7, commit: async () => undefined }) @@ -497,7 +497,7 @@ describe('Codex live prompt ownership', () => { sessionId: 'session-1', itemId: promptItemId, kind: 'approval', - optionId: 'accept', + response: { kind: 'option', optionId: 'accept' }, fence: 7, commit }) @@ -545,7 +545,7 @@ describe('Codex live prompt ownership', () => { sessionId: 'session-1', itemId: promptItemId, kind: 'approval', - optionId: 'accept', + response: { kind: 'option', optionId: 'accept' }, fence: 7, commit }) @@ -587,7 +587,7 @@ describe('Codex live prompt ownership', () => { sessionId: 'session-1', itemId: promptItemId, kind: 'approval', - optionId: 'accept', + response: { kind: 'option', optionId: 'accept' }, fence: 7, commit }) diff --git a/src/main/codex/codex-structured-prompt-ownership.ts b/src/main/codex/codex-structured-prompt-ownership.ts index 28d060d955f..ff42a105fc6 100644 --- a/src/main/codex/codex-structured-prompt-ownership.ts +++ b/src/main/codex/codex-structured-prompt-ownership.ts @@ -1,28 +1,91 @@ import { + AgentSessionPromptAnswerRejectedError, AgentSessionPromptUnavailableError, + type AgentSessionCancelOutcome, type StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import type { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' -import { answerCodexPrompt } from './codex-structured-prompt-replies' +import { + answerCodexPrompt, + prepareCodexPromptAnswer, + type CodexPendingPrompt, + type CodexPreparedAnswer +} from './codex-structured-prompt-replies' import { requireLiveCodexSession, type CodexSession } from './codex-structured-session-state' import type { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation' type CancelInput = Parameters[0] type AnswerInput = Parameters[0] -export async function cancelCodexStructuredTurn(input: { - request: CancelInput - sessions: Map - compactions: StructuredSessionCompaction - cancellation: CodexStructuredTurnCancellation -}): Promise<{ cancelled: boolean }> { - const { request, sessions, compactions, cancellation } = input - const session = requireLiveCodexSession(sessions, request.sessionId) - const turnId = compactions.providerTurnId(request.sessionId, request.turnId) +/** A command's Stop interrupts the provider turn carrying it; any other turn is its own. */ +function providerTurnId(session: CodexSession, turnId: string): string | undefined { + return session.translator ? session.translator.commandProviderTurnId(turnId) : turnId +} + +/** How long a Stop waits for Codex to open the turn it answered a send into. Under the quit + * path's eviction budget, which a close or quit queued behind the Stop spends. */ +export const CODEX_STOP_TURN_OPEN_WAIT_MS = 5_000 + +/** + * A Stop that names no turn: interrupt the turn the journal shows, or else the one Codex reported + * started and not yet ended, which the journal can trail by a publish, or else the one Codex + * answered a send into, once it opens. + */ +async function cancelCodexConversation( + input: Parameters[0], + session: CodexSession +): Promise { + const { request, sessions, cancellation } = input + const liveTurnId = request.resolveLiveTurnId?.() ?? null + // A turn the journal shows that Codex has not started yet (a compaction's) has nothing to stop. + const turnId = + liveTurnId === null + ? ([...(session.activeTurnIds ?? [])].at(-1) ?? (await openedAnsweredTurn(session))) + : providerTurnId(session, liveTurnId) if (!turnId) { return { cancelled: false } } + const acquisitionGeneration = session.acquisitionGeneration + return cancellation.cancel( + session, + session.threadId, + turnId, + () => + sessions.get(request.sessionId) === session && + !session.ended && + session.fence === request.fence && + session.acquisitionGeneration === acquisitionGeneration + ) +} + +/** The turn Codex answered a send into and has not opened yet, once it opens; null when it ends, + * the thread stops running or the child exits first, or the wait runs out. */ +async function openedAnsweredTurn(session: CodexSession): Promise { + const openTurnIds = session.activeTurnIds ?? new Set() + const answered = session.dispatchEchoes.answeredUnopenedTurn(session.threadId, openTurnIds) + if (!answered) { + return null + } + // Codex refuses an interrupt before it opens the turn. + await session.turnOpenWaits.wait(answered, CODEX_STOP_TURN_OPEN_WAIT_MS) + return session.activeTurnIds?.has(answered) ? answered : null +} + +export async function cancelCodexStructuredTurn(input: { + request: CancelInput + sessions: Map + cancellation: CodexStructuredTurnCancellation +}): Promise { + const { request, sessions, cancellation } = input + const session = requireLiveCodexSession(sessions, request.sessionId) const prompt = request.prompt + const requestedTurnId = request.turnId + if (requestedTurnId === undefined) { + return prompt ? { cancelled: false } : cancelCodexConversation(input, session) + } + const turnId = providerTurnId(session, requestedTurnId) + if (!turnId) { + return { cancelled: false } + } if (!prompt) { return cancellation.cancel(session, session.threadId, turnId) } @@ -43,7 +106,7 @@ export async function cancelCodexStructuredTurn(input: { !session.ended && session.fence === request.fence && session.acquisitionGeneration === acquisitionGeneration && - compactions.providerTurnId(request.sessionId, request.turnId) === turnId && + providerTurnId(session, requestedTurnId) === turnId && session.prompts.ownsBoundClaim(claim, prompt.itemId, claim.prompt.threadId, promptTurnId) let interruptConfirmed = false try { @@ -69,6 +132,19 @@ export async function cancelCodexStructuredTurn(input: { } } +function prepareCodexAnswer( + prompt: CodexPendingPrompt, + response: AnswerInput['response'] +): CodexPreparedAnswer { + try { + return prepareCodexPromptAnswer(prompt, response) + } catch (error) { + throw new AgentSessionPromptAnswerRejectedError( + error instanceof Error ? error.message : String(error) + ) + } +} + export async function answerCodexStructuredPrompt(input: { request: AnswerInput sessions: Map @@ -84,6 +160,7 @@ export async function answerCodexStructuredPrompt(input: { throw new AgentSessionPromptUnavailableError(request.itemId) } try { + const prepared = prepareCodexAnswer(claim.prompt, request.response) await request.commit() if ( sessions.get(request.sessionId) !== session || @@ -95,7 +172,7 @@ export async function answerCodexStructuredPrompt(input: { throw new AgentSessionPromptUnavailableError(request.itemId) } session.translator?.resolvePrompt(request.itemId) - answerCodexPrompt(session.prompts, session.connection, claim, request.optionId) + answerCodexPrompt(session.prompts, session.connection, claim, prepared) } catch (error) { session.prompts.releaseClaim(claim) throw error diff --git a/src/main/codex/codex-structured-prompt-replies.test.ts b/src/main/codex/codex-structured-prompt-replies.test.ts index e575f27632b..9f17a3efb4c 100644 --- a/src/main/codex/codex-structured-prompt-replies.test.ts +++ b/src/main/codex/codex-structured-prompt-replies.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from 'vitest' import { AGENT_SESSION_ID_MAX_LENGTH } from '../../shared/agent-session-wire' +import type { AgentSessionPromptResponse } from '../../shared/agent-session-question-answer' import { applyCodexPromptAnswer, CodexPromptRegistry, + prepareCodexPromptAnswer, + type CodexPendingPrompt, MAX_CODEX_PROMPT_REGISTRY_BYTES, MAX_CODEX_PROMPT_REGISTRY_ENTRIES, codexJournalPromptIdPart, @@ -11,6 +14,29 @@ import { encodeCodexQuestionOptionId } from './codex-structured-prompt-replies' +function picked(optionId: string): AgentSessionPromptResponse { + return { kind: 'answers', answers: [{ questionId: 'q1', optionIds: [optionId] }] } +} + +function typed(questionId: string, other: string): AgentSessionPromptResponse { + return { kind: 'answers', answers: [{ questionId, optionIds: [], other }] } +} + +function registered(prompt: CodexPendingPrompt | null): CodexPendingPrompt { + if (!prompt) { + throw new Error('expected the request to register') + } + return prompt +} + +function answer( + prompt: CodexPendingPrompt | null, + response: AgentSessionPromptResponse +): Record | null { + const live = registered(prompt) + return applyCodexPromptAnswer(live, prepareCodexPromptAnswer(live, response)) +} + function userInputRequest(questionIds: string[]): { id: number method: string @@ -59,7 +85,7 @@ describe('codex question option ids', () => { expect(Buffer.byteLength(optionId, 'utf8')).toBeLessThan(1024) expect(codexJournalPromptIdPart(longQuestionId)).not.toBe(longQuestionId) - expect(applyCodexPromptAnswer(prompt as NonNullable, optionId)).toEqual({ + expect(answer(prompt, picked(optionId))).toEqual({ answers: { [longQuestionId]: { answers: [longAnswer] } } }) }) @@ -219,11 +245,11 @@ describe('CodexPromptRegistry', () => { }) describe('applyCodexPromptAnswer', () => { - it('accepts a bare answer only when the request has one question', () => { + it('answers the lone question of a single-question request with typed text', () => { const registry = new CodexPromptRegistry() const single = registry.register(userInputRequest(['q1'])) - expect(applyCodexPromptAnswer(single as NonNullable, 'sure')).toEqual({ + expect(answer(single, typed('q1', 'sure'))).toEqual({ answers: { q1: { answers: ['sure'] } } }) }) @@ -232,29 +258,32 @@ describe('applyCodexPromptAnswer', () => { const registry = new CodexPromptRegistry() const many = registry.register(userInputRequest(['q1', 'q2'])) - expect(() => applyCodexPromptAnswer(many as NonNullable, 'sure')).toThrow( - 'does not name a question' - ) - expect(() => - applyCodexPromptAnswer( - many as NonNullable, - encodeCodexQuestionOptionId('q3', 'sure') - ) - ).toThrow('does not name a question') + expect(() => answer(many, picked('sure'))).toThrow('does not name a question') + expect(() => answer(many, typed('q3', 'sure'))).toThrow('does not name a question') }) it('keeps the last answer when a question is answered twice', () => { const registry = new CodexPromptRegistry() const single = registry.register(userInputRequest(['q1'])) - const prompt = single as NonNullable - applyCodexPromptAnswer(prompt, encodeCodexQuestionOptionId('q1', 'first')) + answer(single, typed('q1', 'first')) - expect(applyCodexPromptAnswer(prompt, encodeCodexQuestionOptionId('q1', 'second'))).toEqual({ + expect(answer(single, typed('q1', 'second'))).toEqual({ answers: { q1: { answers: ['second'] } } }) }) + it('refuses an answer over the registry bound before recording anything', () => { + const registry = new CodexPromptRegistry() + const single = registry.register(userInputRequest(['q1'])) + const live = registered(single) + + expect(() => prepareCodexPromptAnswer(live, typed('q1', 'x'.repeat(64 * 1024 + 1)))).toThrow( + 'exceeds bounded registry state' + ) + expect(live.answers.size).toBe(0) + }) + it('refuses question and option collections that exceed bounded live state', () => { const registry = new CodexPromptRegistry() const tooManyQuestions = registry.register( diff --git a/src/main/codex/codex-structured-prompt-replies.ts b/src/main/codex/codex-structured-prompt-replies.ts index 6e742bf827c..d4301d8f27c 100644 --- a/src/main/codex/codex-structured-prompt-replies.ts +++ b/src/main/codex/codex-structured-prompt-replies.ts @@ -1,3 +1,4 @@ +import type { AgentSessionPromptResponse } from '../../shared/agent-session-question-answer' import type { CodexAppServerConnection } from './codex-app-server-connection' import { CODEX_PROMPT_MAX_ANSWER_BYTES } from './codex-prompt-registry-bounds' import { @@ -55,35 +56,61 @@ export function decodeCodexQuestionOptionId( } } +/** One answer, checked against the prompt but not yet recorded on it. */ +export type CodexPreparedAnswer = + | { kind: 'decision'; decision: CodexApprovalDecision } + | { kind: 'answer'; questionId: string; answer: string } + +/** Validates a client's choice against the prompt without recording it, so an answer Codex + * cannot take is refused before the journal commits it. */ +export function prepareCodexPromptAnswer( + prompt: CodexPendingPrompt, + response: AgentSessionPromptResponse +): CodexPreparedAnswer { + if (prompt.method !== CODEX_USER_INPUT_METHOD) { + if (response.kind !== 'option' || !isCodexApprovalDecision(response.optionId)) { + throw new Error(`Codex item ${prompt.codexItemId} takes an approval decision`) + } + return { kind: 'decision', decision: response.optionId } + } + // Each Codex question is its own journal item, so an answer names exactly one question. + const entry = + response.kind === 'answers' && response.answers.length === 1 ? response.answers[0] : null + if (!entry) { + throw new Error(`Codex item ${prompt.codexItemId} takes one question answer`) + } + const optionId = entry.optionIds[0] + const decoded = + optionId === undefined + ? { questionId: entry.questionId, answer: entry.other?.trim() ?? '' } + : (prompt.optionAnswers.get(optionId) ?? decodeCodexQuestionOptionId(optionId)) + const questionId = + (decoded?.questionId + ? (prompt.questionIdAliases.get(decoded.questionId) ?? decoded.questionId) + : null) ?? (prompt.questionIds.length === 1 ? prompt.questionIds[0] : null) + const answer = decoded?.answer ?? optionId ?? '' + if (!questionId || !prompt.questionIds.includes(questionId)) { + throw new Error(`The answer does not name a question on Codex item ${prompt.codexItemId}`) + } + if (Buffer.byteLength(answer, 'utf8') > CODEX_PROMPT_MAX_ANSWER_BYTES) { + throw new Error('codex prompt answer exceeds bounded registry state') + } + return { kind: 'answer', questionId, answer } +} + /** - * Records one answer and returns the reply payload once the request is fully + * Records one prepared answer and returns the reply payload once the request is fully * answered. A multi-question user-input request stays pending until every * question has an answer, because Codex takes one reply for all of them. */ export function applyCodexPromptAnswer( prompt: CodexPendingPrompt, - optionId: string + prepared: CodexPreparedAnswer ): Record | null { - if (prompt.method !== CODEX_USER_INPUT_METHOD) { - if (!isCodexApprovalDecision(optionId)) { - throw new Error(`${optionId} is not a Codex approval decision`) - } - return { decision: optionId } + if (prepared.kind === 'decision') { + return { decision: prepared.decision } } - const mapped = prompt.optionAnswers.get(optionId) - const decoded = mapped ?? decodeCodexQuestionOptionId(optionId) - const questionId = - (decoded?.questionId - ? (prompt.questionIdAliases.get(decoded.questionId) ?? decoded.questionId) - : null) ?? (prompt.questionIds.length === 1 ? prompt.questionIds[0] : null) - const answer = decoded?.answer ?? optionId - if (!questionId || !prompt.questionIds.includes(questionId)) { - throw new Error(`${optionId} does not name a question on Codex item ${prompt.codexItemId}`) - } - if (Buffer.byteLength(answer, 'utf8') > CODEX_PROMPT_MAX_ANSWER_BYTES) { - throw new Error('codex prompt answer exceeds bounded registry state') - } - prompt.answers.set(questionId, answer) + prompt.answers.set(prepared.questionId, prepared.answer) if (prompt.questionIds.some((id) => !prompt.answers.has(id))) { return null } @@ -104,13 +131,13 @@ export function answerCodexPrompt( registry: CodexPromptRegistry, connection: Pick, claim: CodexPromptClaim, - optionId: string + prepared: CodexPreparedAnswer ): void { if (!registry.ownsClaim(claim)) { throw new Error(`codex app-server is no longer waiting on ${claim.itemId}`) } const prompt = claim.prompt - const reply = applyCodexPromptAnswer(prompt, optionId) + const reply = applyCodexPromptAnswer(prompt, prepared) if (reply === null) { registry.releaseClaim(claim) return diff --git a/src/main/codex/codex-structured-provider-events.ts b/src/main/codex/codex-structured-provider-events.ts index 06563cad748..5dc608fbb59 100644 --- a/src/main/codex/codex-structured-provider-events.ts +++ b/src/main/codex/codex-structured-provider-events.ts @@ -24,6 +24,7 @@ export function translateCodexNotification(input: { }): CodexJournalTranslationAdmission { const { sessionId, session, method, params, observedAt, dispatchSequenceAtReceipt } = input codexRewind.observeCodexRewindActivity(session, method, params) + session.turnOpenWaits.observe(session.threadId, method, params) if (input.turnCancellation.handleNotification(sessionId, session, method, params, observedAt)) { return { accepted: true } } diff --git a/src/main/codex/codex-structured-question-order.test.ts b/src/main/codex/codex-structured-question-order.test.ts new file mode 100644 index 00000000000..59a36dbdf67 --- /dev/null +++ b/src/main/codex/codex-structured-question-order.test.ts @@ -0,0 +1,260 @@ +// A Codex ask with several questions, driven from the real host journal through +// the client's session reducer to the rows the transcript list draws. +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalRenderItem +} from '../../shared/agent-session-journal-types' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + reduceStructuredAgentSession, + type StructuredAgentSessionState +} from '../../shared/structured-agent-session-reducer' +import { projectStructuredAgentSessionMessages } from '../../shared/structured-agent-session-message-projection' +import { projectNativeChatTranscriptMessages } from '../../shared/native-chat-transcript-projection' +import { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { CodexJournalPrompts } from './codex-structured-journal-prompts' +import { CODEX_USER_INPUT_METHOD } from './codex-structured-prompt-replies' +import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestOperationId, + resetHostTestOperationIds +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { + projectStructuredQuestionMessages, + structuredQuestionTranscript +} from '../../renderer/src/components/native-chat/structured-agent-question-projection' + +const CALLER = { callerKey: 'client-1' } + +type Asked = readonly { id: string; question: string }[] + +// Codex's question ids are the model's own words, so their text order is not +// the order it asked in. These two asks spell the two orders live QA saw. +const ASKED: Asked = [ + { id: 'scope', question: 'Which files are in scope?' }, + { id: 'priority', question: 'What matters most?' }, + { id: 'deadline', question: 'When is it due?' } +] +const ASKED_OUT_OF_ORDER: Asked = [ + { id: 'format', question: 'Which format?' }, + { id: 'audience', question: 'Who reads it?' }, + { id: 'length', question: 'How long?' } +] + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let sink: StructuredAgentSessionEventSink | null +let client: StructuredAgentSessionState +let clock: number + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-codex-question-order-')) + resetHostTestOperationIds() + sink = null + clock = HOST_TEST_NOW + const adapter: StructuredAgentSessionAdapter = { + acquire: vi.fn(async ({ fence, events }) => { + sink = events ?? null + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: HOST_TEST_NOW + } + } + }), + releaseAcquisition: vi.fn(async () => true), + dispatch: vi.fn(), + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(async ({ commit }) => commit()), + setOption: vi.fn(async () => undefined) + } + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + // Every write lands on its own millisecond, as it does live. + now: () => (clock += 1) + }) + expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + if (!page.ok) { + throw new Error('no history page') + } + client = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'history-page', + page: page.page + }) + host.subscribe({ + id: 'client', + sessionId: SESSION, + cursor: page.page.liveCursor ?? page.page.window.nextCursor, + emit: (event) => { + client = reduceStructuredAgentSession(client, { type: 'event', event }) + } + }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function codexPrompts(): CodexJournalPrompts { + if (!sink) { + throw new Error('session was never acquired') + } + return new CodexJournalPrompts( + { sink, attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }) }, + () => null, + () => 'turn-1' + ) +} + +async function ask(prompts: CodexJournalPrompts, asked: Asked = ASKED): Promise { + prompts.handle({ + threadId: THREAD, + method: CODEX_USER_INPUT_METHOD, + codexItemId: 'codex-item-1', + promptKey: '7', + params: { + threadId: THREAD, + turnId: 'turn-1', + questions: asked.map((question) => ({ + ...question, + options: [ + { label: 'Yes', description: '' }, + { label: 'No', description: '' } + ] + })) + } + }) + await host.flushStreamedEvents(SESSION) +} + +function questionItem(question: string): AgentJournalRenderItem { + const item = client.items.find( + (candidate) => candidate.body.kind === 'question' && candidate.body.question === question + ) + if (!item || item.body.kind !== 'question') { + throw new Error(`no journal item for ${question}`) + } + return item +} + +async function answer(question: string): Promise { + const item = questionItem(question) + if (item.body.kind !== 'question') { + return + } + const fields = { + itemId: item.itemId, + expectedRevision: item.revision, + optionId: item.body.options[0]!.id + } + const result = await host.respondToPrompt(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.respondTo:question', + sessionId: SESSION, + fields + }) + }, + kind: 'question', + ...fields + }) + expect(result.ok).toBe(true) + await host.flushStreamedEvents(SESSION) +} + +/** The prompt rows the transcript list draws, top to bottom, as the questions each one shows. */ +function drawnPromptRows(): string[][] { + const { receipts } = structuredQuestionTranscript(client.items) + // The desktop list's projection: its comparator adds only a rank for rows the host never writes. + const rows = projectNativeChatTranscriptMessages( + projectStructuredAgentSessionMessages( + client.items, + [], + client.submissions, + projectStructuredQuestionMessages + ) + ) + return rows.flatMap((row) => { + const prompt = receipts.get(row.id) + if (!prompt || prompt.kind !== 'question') { + return [] + } + const questions = prompt.questions?.length ? prompt.questions : [prompt] + return [questions.map(({ question }) => `${question} (${prompt.resolution.state})`)] + }) +} + +describe('a Codex ask with several questions', () => { + it('keeps the pending rest of the ask below the question already answered', async () => { + await ask(codexPrompts()) + await answer(ASKED[0]!.question) + + expect(drawnPromptRows()).toEqual([ + ['Which files are in scope? (resolved)'], + ['What matters most? (pending)', 'When is it due? (pending)'] + ]) + }) + + it('draws every answered question in the order Codex asked it', async () => { + await ask(codexPrompts()) + for (const { question } of ASKED) { + await answer(question) + } + + expect(drawnPromptRows()).toEqual([ + ['Which files are in scope? (resolved)'], + ['What matters most? (resolved)'], + ['When is it due? (resolved)'] + ]) + // Mobile draws the shared projection in journal order, one row per question. + expect( + projectStructuredAgentSessionMessages(client.items, [], client.submissions).map( + ({ blocks }) => (blocks[0]?.type === 'text' ? blocks[0].text.split('\n')[0] : null) + ) + ).toEqual(ASKED.map(({ question }) => question)) + }) + + it('draws a cancelled ask in the order Codex asked it', async () => { + const prompts = codexPrompts() + await ask(prompts, ASKED_OUT_OF_ORDER) + prompts.cancel(questionItem(ASKED_OUT_OF_ORDER[0]!.question).itemId) + await host.flushStreamedEvents(SESSION) + + expect(drawnPromptRows()).toEqual([ + ['Which format? (cancelled)'], + ['Who reads it? (cancelled)'], + ['How long? (cancelled)'] + ]) + }) +}) diff --git a/src/main/codex/codex-structured-rewind.test.ts b/src/main/codex/codex-structured-rewind.test.ts index 64ebff43e38..a5f34cc98fd 100644 --- a/src/main/codex/codex-structured-rewind.test.ts +++ b/src/main/codex/codex-structured-rewind.test.ts @@ -204,11 +204,10 @@ describe('Codex rewind', () => { } return original(method) }) - const onReverted = vi.fn() - expect( - await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop', onReverted }) - ).toEqual({ ok: false, reason: 'history-limit' }) - expect(onReverted).not.toHaveBeenCalled() + expect(await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({ + ok: false, + reason: 'history-limit' + }) expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false) }) it('refuses a missing target before mutation', async () => { diff --git a/src/main/codex/codex-structured-rewind.ts b/src/main/codex/codex-structured-rewind.ts index e5913b37be5..b222694422d 100644 --- a/src/main/codex/codex-structured-rewind.ts +++ b/src/main/codex/codex-structured-rewind.ts @@ -280,7 +280,6 @@ export async function rewindCodexSession( if (record(reply.thread).id !== session.threadId) { throw new Error('agent_session_rewind:foreign-thread') } - await input.onReverted?.() const items = await verifyCodexRevertedHistory(session, reply, input.beforeTurnId, timeoutMs) if ( items.length !== expectedItems.size || diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 2ce13d21250..e765955d53c 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -8,12 +8,15 @@ import { closeFailedCodexAcquisition, stopSupersededCodexAcquisition } from './codex-structured-acquisition-lifecycle' -import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' +import { CodexBackgroundTaskTracker, codexChildWorkSink } from './codex-background-task-tracker' import { CodexSubagentExecutions } from './codex-subagent-executions' import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' import { createCodexJournalTranslator } from './codex-structured-journal-translation' import { openCodexAppServerConnection } from './codex-app-server-connection' -import { codexProcessIdentity, codexProviderHandleLink } from './codex-structured-owner-identity' +import { + codexProviderHandleLink, + codexSpawnedProcessIdentity +} from './codex-structured-owner-identity' import { buildCodexStructuredChildEnvironment } from './codex-structured-child-environment' import { openCodexThread } from './codex-structured-thread-open' import { @@ -30,6 +33,7 @@ import { reportedCodexThreadOptions } from './codex-structured-fast-mode' import { + assertCodexConnectionOpen, codexSessionLifecycle, mintCodexAcquisitionGeneration, type CodexAcquisitionRegistry, @@ -38,9 +42,12 @@ import { type CodexStructuredSessionAdapterDeps } from './codex-structured-session-state' import type { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation' +import type { CodexStructuredSessionTeardown } from './codex-structured-session-teardown' import type { CodexStructuredNotificationRetry } from './codex-structured-notification-retry' import type { deliverCodexServerRequest } from './codex-structured-provider-events' +const TURN_BOUNDARIES: ReadonlySet = new Set(['turn/started', 'turn/completed']) + export async function acquireCodexStructuredSession(input: { input: StructuredAgentSessionAcquireInput deps: CodexStructuredSessionAdapterDeps @@ -59,12 +66,7 @@ export async function acquireCodexStructuredSession(input: { request: Parameters[2] ) => void handleUnhandledFrame: (sessionId: string, kind: string, payload: unknown) => void - forceCloseUnexpected: ( - sessionId: string, - fence: number, - acquisitionGeneration: string, - reason: Error - ) => Promise + forceCloseUnexpected: CodexStructuredSessionTeardown['forceCloseUnexpected'] }): Promise { const { input: acquireInput, @@ -106,6 +108,7 @@ export async function acquireCodexStructuredSession(input: { }) : null const open = deps.openConnection ?? openCodexAppServerConnection + const spawnIdentity = codexSpawnedProcessIdentity(acquireInput, deps.readProcessStartTime) try { await stopSupersededCodexAcquisition({ sessionId, @@ -134,7 +137,7 @@ export async function acquireCodexStructuredSession(input: { { onNotification: (method, params) => { // Stamped at receipt, ahead of any pre-publication buffering or retry. - const observedAt = isCodexTurnBoundary(method) ? (deps.now?.() ?? Date.now()) : undefined + const observedAt = TURN_BOUNDARIES.has(method) ? (deps.now?.() ?? Date.now()) : undefined const dispatchSequenceAtReceipt = method === 'turn/started' ? dispatchEchoes.latestSequence() : undefined input.deliver( @@ -165,6 +168,7 @@ export async function acquireCodexStructuredSession(input: { () => input.handleUnhandledFrame(sessionId, kind, payload), Buffer.byteLength(JSON.stringify(payload ?? null), 'utf8') ), + onSpawned: spawnIdentity.onSpawned, onExit: (error) => { try { handleCodexSessionExit({ @@ -198,14 +202,11 @@ export async function acquireCodexStructuredSession(input: { primaryThreadId = opened.threadId const restoreAdmission = translator?.restoreThread(opened.threadId, opened.thread ?? {}) if (restoreAdmission && !restoreAdmission.accepted) { - throw new AgentSessionAcquisitionRefusal( + throw AgentSessionAcquisitionRefusal.historyTooLarge( 'Codex thread history exceeds the bounded restore queue; history was not partially imported.' ) } - const process = await codexProcessIdentity( - { ...acquireInput, pid: connection.pid }, - deps.readProcessStartTime - ) + const process = await spawnIdentity.read(connection.pid) acquisitions.assertCurrent(sessionId, attempt) const acquired: AgentSessionAcquisition = { process, @@ -220,9 +221,7 @@ export async function acquireCodexStructuredSession(input: { }), acquisitionGeneration: mintCodexAcquisitionGeneration(deps) } - if (connection.closed) { - throw new Error(`codex app-server for session ${sessionId} exited while being acquired`) - } + assertCodexConnectionOpen(connection, sessionId) acquisitions.assertCurrent(sessionId, attempt) const options = restoredCodexSessionOptions(acquireInput.options) const catalogAccess = codexAcquireCatalogAccess(deps, launch) @@ -234,10 +233,10 @@ export async function acquireCodexStructuredSession(input: { timeoutMs: deps.requestTimeoutMs }) acquisitions.assertCurrent(sessionId, attempt) - if (connection.closed) { - throw new Error(`codex app-server for session ${sessionId} exited while being acquired`) - } + assertCodexConnectionOpen(connection, sessionId) acquisitions.deleteIfCurrent(sessionId, attempt) + // Where this session's child work goes: the host's records, after each frame is journaled. + const sink = codexChildWorkSink(sessionId, deps) const session: CodexSession = { connection, ...codexSessionLifecycle(acquireInput.fence, acquired.acquisitionGeneration as string), @@ -252,7 +251,7 @@ export async function acquireCodexStructuredSession(input: { ...(catalogAccess ? { catalogAccess } : {}), dispatchEchoes, translator, - backgroundTasks: new CodexBackgroundTaskTracker(opened.threadId, subagentExecutions), + backgroundTasks: new CodexBackgroundTaskTracker(opened.threadId, subagentExecutions, sink), forceCloseUnexpected: (reason) => input.forceCloseUnexpected( sessionId, @@ -297,7 +296,3 @@ export async function acquireCodexStructuredSession(input: { attempt.finish() } } - -function isCodexTurnBoundary(method: string): boolean { - return method === 'turn/started' || method === 'turn/completed' -} diff --git a/src/main/codex/codex-structured-session-adapter-fixture.ts b/src/main/codex/codex-structured-session-adapter-fixture.ts index 3f2d32ee2eb..27e6eadb6c5 100644 --- a/src/main/codex/codex-structured-session-adapter-fixture.ts +++ b/src/main/codex/codex-structured-session-adapter-fixture.ts @@ -89,6 +89,19 @@ export function fakeCodex(routes: Record = {}): { return { connections, openConnection, routes } } +/** A `turn/start` route for a Codex that opened the turn before its answer was read. */ +export function answerWithOpenedTurn( + codex: Pick, 'connections'>, + turnId: string +): Route { + return () => { + codex.connections + .at(-1) + ?.handlers.onNotification?.('turn/started', { threadId: THREAD_ID, turn: { id: turnId } }) + return { turn: { id: turnId } } + } +} + export function adapterFor( codex: ReturnType, launch: Partial = {}, diff --git a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts index fc458304dc8..bcfd2f733f0 100644 --- a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts +++ b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts @@ -152,7 +152,7 @@ describe('CodexStructuredSessionAdapter lifecycle', () => { sessionId: 'session-2', itemId: 'codex-item-1', kind: 'approval', - optionId: 'accept', + response: { kind: 'option', optionId: 'accept' }, fence: 1, commit: async () => undefined }) @@ -181,6 +181,7 @@ describe('CodexStructuredSessionAdapter lifecycle', () => { type: 'ended', sessionId: 'session-1', reason: 'codex app-server connection ended', + failure: { kind: 'providerExited' }, cause: 'unexpected-exit', fence: 7, acquisitionGeneration: 'generation-1', diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index f17003f7e40..70b2ecd7147 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -1,12 +1,11 @@ import { describe, expect, it, vi } from 'vitest' +import { AgentSessionPromptAnswerRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { CodexAppServerRequestError, type openCodexAppServerConnection } from './codex-app-server-connection' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity' -import { ORCA_STRUCTURED_SESSION_ENV } from '../../shared/structured-session-marker' -import { encodeCodexQuestionOptionId } from './codex-structured-prompt-replies' import { CodexStructuredSessionAdapter, type CodexStructuredLaunch, @@ -17,6 +16,7 @@ import { USER_MESSAGE, acquired, adapterFor, + answerWithOpenedTurn, fakeCodex, identityFor } from './codex-structured-session-adapter-fixture' @@ -35,7 +35,12 @@ describe('CodexStructuredSessionAdapter.acquire', () => { expect(codex.connections[0].launch.env).toEqual({ [CODEX_SPAWN_TOKEN_ENV]: 'spawn-9', CODEX_HOME: '/codex/home', - [ORCA_STRUCTURED_SESSION_ENV]: '1' + ORCA_AGENT_SESSION_ID: 'session-1', + ORCA_STRUCTURED_SESSION: '1', + ORCA_CLI_COMMAND: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/), + ORCA_USER_DATA_PATH: expect.any(String), + // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. + PATH: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[:;]/) }) expect(codex.connections[0].launch.cwd).toBe('/work/repo') expect(codex.connections[0].calls[0]).toEqual({ @@ -58,6 +63,20 @@ describe('CodexStructuredSessionAdapter.acquire', () => { expect(acquisition.acquisitionGeneration).toBe('generation-1') }) + // A thread opened on Codex's configured default and then given a turn on the chosen model + // reads to Codex as a model switch, and it injects the chosen model's whole prompt again. + it('opens the thread on the model the session chose, not on the configured default', async () => { + const codex = fakeCodex() + const adapter = adapterFor(codex, { model: 'gpt-chosen' }) + + await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) + + expect(codex.connections[0].calls[0]).toEqual({ + method: 'thread/start', + params: { cwd: '/work/repo', model: 'gpt-chosen' } + }) + }) + it('resumes the thread the durable handle chain names, not the client one', async () => { const codex = fakeCodex() const adapter = adapterFor(codex, { @@ -174,7 +193,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { sessionId: 'session-1', itemId: 'codex-item-early', kind: 'approval', - optionId: 'accept', + response: { kind: 'option', optionId: 'accept' }, fence: 7, commit: async () => undefined }) @@ -353,7 +372,8 @@ describe('CodexStructuredSessionAdapter.acquire', () => { describe('CodexStructuredSessionAdapter.dispatch', () => { it('admits a send as soon as Codex owns it', async () => { - const codex = fakeCodex({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) }) + const codex = fakeCodex() + codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-1') const adapter = await acquired(codex) const outcome = await adapter.dispatch({ @@ -452,7 +472,12 @@ describe('CodexStructuredSessionAdapter.dispatch', () => { body: USER_MESSAGE, fence: 7 }) - ).toEqual({ state: 'rejected', reason: 'turn already running' }) + ).toEqual({ + // Built without Codex's own words, so nothing is quoted and no detail is invented. + state: 'rejected', + reason: 'The provider did not accept this message.', + rejection: { kind: 'providerRejected' } + }) }) it('rethrows a dead child so the wire settles the submission unknown', async () => { @@ -489,9 +514,9 @@ describe('CodexStructuredSessionAdapter.dispatch', () => { } ], nextCursor: null - }), - 'turn/start': () => ({ turn: { id: 'turn-1' } }) + }) }) + codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-1') const adapter = await acquired(codex) await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'gpt-5', fence: 7 }) @@ -535,7 +560,7 @@ describe('CodexStructuredSessionAdapter prompts', () => { sessionId: 'session-1', itemId: 'codex:thread-abc:turn-1:3', kind: 'approval', - optionId: 'accept', + response: { kind: 'option', optionId: 'accept' }, fence: 7, commit: async () => undefined }) @@ -548,7 +573,7 @@ describe('CodexStructuredSessionAdapter prompts', () => { sessionId: 'session-1', itemId: 'codex:thread-abc:turn-1:3', kind: 'approval', - optionId: 'decline', + response: { kind: 'option', optionId: 'decline' }, fence: 7, commit: async () => undefined }) @@ -589,7 +614,7 @@ describe('CodexStructuredSessionAdapter prompts', () => { sessionId: 'session-1', itemId: 'codex-item-1', kind: 'approval', - optionId: 'accept', + response: { kind: 'option', optionId: 'accept' }, fence: 7, commit: async () => undefined }) @@ -676,7 +701,7 @@ describe('CodexStructuredSessionAdapter prompts', () => { sessionId: 'session-1', itemId, kind: 'approval', - optionId, + response: { kind: 'option', optionId }, fence: 7, commit: async () => undefined }) @@ -697,17 +722,20 @@ describe('CodexStructuredSessionAdapter prompts', () => { const adapter = await acquired(codex) askApproval(codex) + const commit = vi.fn(async () => undefined) await expect( adapter.answerPrompt({ sessionId: 'session-1', itemId: 'codex-item-1', kind: 'approval', - optionId: 'yolo', + response: { kind: 'option', optionId: 'yolo' }, fence: 7, - commit: async () => undefined + commit }) - ).rejects.toThrow('is not a Codex approval decision') + ).rejects.toThrow(AgentSessionPromptAnswerRejectedError) + // Refused before the journal records an answer the agent never receives. + expect(commit).not.toHaveBeenCalled() expect(codex.connections[0].replies).toEqual([]) }) @@ -732,7 +760,7 @@ describe('CodexStructuredSessionAdapter prompts', () => { sessionId: 'session-1', itemId: 'codex-item-2', kind: 'question', - optionId: encodeCodexQuestionOptionId('q1', 'yes'), + response: { kind: 'answers', answers: [{ questionId: 'q1', optionIds: [], other: 'yes' }] }, fence: 7, commit: async () => undefined }) @@ -742,7 +770,7 @@ describe('CodexStructuredSessionAdapter prompts', () => { sessionId: 'session-1', itemId: 'codex-item-2', kind: 'question', - optionId: encodeCodexQuestionOptionId('q2', 'no'), + response: { kind: 'answers', answers: [{ questionId: 'q2', optionIds: [], other: 'no' }] }, fence: 7, commit: async () => undefined }) @@ -778,7 +806,7 @@ describe('CodexStructuredSessionAdapter prompts', () => { sessionId: 'session-1', itemId: 'codex-item-gone', kind: 'approval', - optionId: 'accept', + response: { kind: 'option', optionId: 'accept' }, fence: 7, commit: async () => undefined }) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index b9affd8bd00..82f927e0e86 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -3,7 +3,6 @@ import type { AgentJournalMessageItem, AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' -import { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' import { isCodexAppServerRequestError } from './codex-app-server-connection' import type { AgentSessionAcquisition, @@ -14,6 +13,7 @@ import type { } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexJournalTranslationAdmission } from './codex-structured-journal-translation' import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start' +import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' import { supportsCodexStructuredLocation } from './codex-structured-location-support' import { CodexStructuredSessionTeardown } from './codex-structured-session-teardown' import { @@ -34,6 +34,10 @@ import { translateCodexNotification } from './codex-structured-provider-events' import { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation' +import { + codexDispatchRejection, + settleCodexSendsInEndedTurn +} from './codex-structured-turn-end-settlement' import { createCodexStructuredNotificationRetry } from './codex-structured-notification-retry' import { acquireCodexStructuredSession } from './codex-structured-session-acquire' import { changeCodexThreadGoal } from './codex-structured-thread-goal' @@ -49,7 +53,6 @@ export type { } from './codex-structured-session-state' export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdapter { - private readonly compactions = new StructuredSessionCompaction() private readonly sessions = new Map() private readonly acquisitions = new CodexAcquisitionRegistry() private readonly turnCancellation: CodexStructuredTurnCancellation @@ -153,15 +156,17 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap return admission } if (event.type === 'notification') { - this.compactions.codex(event.sessionId, event.method, event.params) + // Only an admitted turn end settles; a refused one settles on the retry that lands. + settleCodexSendsInEndedTurn(session, event.method, event.params, (settlement) => + this.deps.onDispatchSettledLate?.({ sessionId: event.sessionId, ...settlement }) + ) // After the admission check, so a refused frame is observed by the strip // only on the retry that also reaches the journal. - if (session.backgroundTasks.observe(event)) { + if (session.backgroundTasks.observe(event, session.prompts.takeAbandonedCommands())) { this.deps.onBackgroundTasksChanged?.(event.sessionId, session.backgroundTasks.state) } - } - if (event.type === 'ended') { - this.compactions.ended(event.sessionId) + // After the journal and the parent's republished row, never ahead of either. + session.backgroundTasks.publishChildWork() } this.deps.onEvent?.(event) return admission @@ -229,7 +234,6 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap cancelCodexStructuredTurn({ request, sessions: this.sessions, - compactions: this.compactions, cancellation: this.turnCancellation }) @@ -244,29 +248,31 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap recoverRewind: NonNullable = (input) => codexRewind.recoverCodexRewind(this.session(input.sessionId), input, this.deps.requestTimeoutMs) - compact: NonNullable = (input) => { + /** The ack is Codex's receipt; the translator ends the command's turn from the turn it opens. */ + compact: NonNullable = async (input) => { const session = this.session(input.sessionId) - return this.compactions.run( - input.sessionId, - session.threadId, - async () => { - await this.turnCancellation.captureBaseline(session) - return session.connection - .request( - 'thread/compact/start', - { threadId: session.threadId }, - { timeoutMs: this.deps.requestTimeoutMs } + session.translator?.beginCommand(input.command) + try { + await this.turnCancellation.captureBaseline(session) + await session.connection.request( + 'thread/compact/start', + { threadId: session.threadId }, + { timeoutMs: this.deps.requestTimeoutMs } + ) + return { state: 'accepted', providerIdentity: null } + } catch (error) { + session.translator?.forgetCommand(input.command.turnId) + if (isCodexAppServerRequestError(error)) { + // Codex's own words, when it gave any, are the one part of the error a person can use. + return { + state: 'rejected', + ...codexDispatchRejection( + agentSessionFailureFact('providerRejected', { detail: providerDiagnosticOf(error) }) ) - .catch((error) => { - if (isCodexAppServerRequestError(error)) { - return { error: error.message } - } - throw error - }) - }, - input.onLateResult, - input.turnId - ) + } + } + throw error + } } changeThreadGoal: NonNullable = (input) => @@ -277,7 +283,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap this.deps.requestTimeoutMs ) - supportsThreadGoal = (sessionId: string): boolean => this.sessions.has(sessionId) + // Provider-level: a goal change at rest starts the agent first. + supportsThreadGoal = (): boolean => true answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (request) => answerCodexStructuredPrompt({ request, sessions: this.sessions }) diff --git a/src/main/codex/codex-structured-session-cancel.test.ts b/src/main/codex/codex-structured-session-cancel.test.ts index 1ac807a5ccd..2d6c50adae4 100644 --- a/src/main/codex/codex-structured-session-cancel.test.ts +++ b/src/main/codex/codex-structured-session-cancel.test.ts @@ -142,14 +142,14 @@ describe('CodexStructuredSessionAdapter.cancelTurn', () => { turnId: 'turn-1', fence: 7 }) - ).resolves.toEqual({ cancelled: false }) + ).resolves.toEqual({ cancelled: false, refusal: {} }) await expect( (await acquired(absent)).cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) - ).resolves.toEqual({ cancelled: false }) + ).resolves.toEqual({ cancelled: false, refusal: {} }) }) it('rethrows an unsettled interrupt so the turn is not shown as cancelled', async () => { @@ -228,14 +228,22 @@ describe('CodexStructuredSessionAdapter.cancelTurn', () => { await expect( adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) - ).resolves.toEqual({ cancelled: false }) + ).resolves.toEqual({ cancelled: false, unconfirmed: true }) expect(events).toContainEqual(expect.objectContaining({ method: 'turn/completed' })) }) it('accepts an immediate resend after verified interruption', async () => { let nextTurn = 0 const codex = fakeCodex() - codex.routes['turn/start'] = () => ({ turn: { id: `turn-${++nextTurn}` } }) + codex.routes['turn/start'] = () => { + // Codex opens each turn it answers; a send's dispatch waits for that. + const turnId = `turn-${++nextTurn}` + codex.connections[0].handlers.onNotification?.('turn/started', { + threadId: THREAD_ID, + turn: { id: turnId } + }) + return { turn: { id: turnId } } + } codex.routes['turn/interrupt'] = () => { completeTurn(codex) return {} diff --git a/src/main/codex/codex-structured-session-close.test.ts b/src/main/codex/codex-structured-session-close.test.ts index 17f3aecf671..9d80a0fa982 100644 --- a/src/main/codex/codex-structured-session-close.test.ts +++ b/src/main/codex/codex-structured-session-close.test.ts @@ -1,4 +1,5 @@ import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' +import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' import { describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { @@ -76,7 +77,7 @@ function claudeAdapterStub(): StructuredAgentSessionAdapter { } describe('Codex structured session close lifecycle', () => { - it('forwards a one-shot exit when lifecycle admission is rejected', () => { + function backpressuredSession(requestedClose: boolean) { const connection: CodexAppServerConnection = { pid: 4321, closed: true, @@ -87,7 +88,6 @@ describe('Codex structured session close lifecycle', () => { close: async () => true } const prompts = new CodexPromptRegistry() - const clearPrompts = vi.spyOn(prompts, 'clear') const translator = { handle: vi.fn().mockReturnValueOnce({ accepted: false, reason: 'backpressure' as const }), dispose: vi.fn() @@ -96,7 +96,7 @@ describe('Codex structured session close lifecycle', () => { connection, backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), ended: false, - requestedClose: false, + requestedClose, fence: 7, acquisitionGeneration: 'generation-1', threadId: THREAD, @@ -106,9 +106,15 @@ describe('Codex structured session close lifecycle', () => { reportedOptions: {}, fastModeTierByModel: new Map(), dispatchEchoes: createCodexDispatchEchoes(), + turnOpenWaits: createCodexTurnOpenWaits(), translator } - const sessions = new Map([['session-1', session]]) + return { connection, prompts, translator, session, sessions: new Map([['session-1', session]]) } + } + + it('forwards a one-shot exit when lifecycle admission is rejected', () => { + const { connection, prompts, translator, session, sessions } = backpressuredSession(false) + const clearPrompts = vi.spyOn(prompts, 'clear') const onEvent = vi.fn() expect( @@ -125,13 +131,33 @@ describe('Codex structured session close lifecycle', () => { expect(clearPrompts).toHaveBeenCalledOnce() expect(onEvent).toHaveBeenCalledOnce() expect(translator.dispose).toHaveBeenCalledOnce() - expect(onEvent.mock.calls[0]?.[0]).toMatchObject({ - cause: 'unexpected-exit', - settlementRetryRequired: true - }) + expect(onEvent.mock.calls[0]?.[0]).toMatchObject({ cause: 'unexpected-exit' }) expect(translator.handle).toHaveBeenCalledOnce() }) + it("ends a Stop's wait for its turn to open when a requested close cannot publish its end yet", async () => { + const { connection, prompts, session, sessions } = backpressuredSession(true) + let released = false + void session.turnOpenWaits.wait('turn-1', 60_000).then(() => { + released = true + }) + + expect( + handleCodexSessionExit({ + sessions, + sessionId: 'session-1', + connection, + error: new Error('codex session closed'), + closedByOrca: true, + prompts + }) + ).toBe(false) + await Promise.resolve() + // Left for the retry, but the child is gone: nothing waits on a turn it would open. + expect(session.ended).toBe(false) + expect(released).toBe(true) + }) + it('mints a distinct child generation even when acquisitions share one fence', async () => { const { adapter } = adapterFixture() const input = { identity: identity('session-1'), fence: 7, spawnToken: 'spawn-1' } diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index 1ed8dcd385c..5bf973308b1 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -1,3 +1,4 @@ +import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' import type { CodexAppServerConnection } from './codex-app-server-connection-types' import { closeProcessRegistry } from '../../shared/child-process/close-process-registry' import { @@ -14,6 +15,9 @@ export function handleCodexSessionExit(input: { sessionId: string connection: CodexAppServerConnection | null error: Error + /** Set by Orca's own close. Absent only from the connection's onExit, which the connection + * withholds while Orca is closing the child. */ + closedByOrca?: true prompts?: CodexSession['prompts'] allowFailedSettlement?: boolean onEvent?: (event: CodexStructuredSessionEvent) => void @@ -25,26 +29,28 @@ export function handleCodexSessionExit(input: { return false } session.exitObservedAt ??= Date.now() + // Before the admission check: the child is gone whether or not its end was admitted. + session.turnOpenWaits.releaseAll() const event: StructuredAgentSessionEndedEvent = { type: 'ended', sessionId: input.sessionId, reason: input.error.message, + // Only the child's own exit blames Codex; a close Orca made, for any reason, is Orca's. + failure: input.closedByOrca + ? agentSessionFailureFact('hostFault') + : agentSessionFailureFact('providerExited', { detail: providerDiagnosticOf(input.error) }), cause: session.requestedClose ? 'requested-close' : 'unexpected-exit', fence: session.fence, acquisitionGeneration: session.acquisitionGeneration, observedAt: session.exitObservedAt } as const - // A synchronous sink rejection (usually backpressure) is handed to host - // recovery, which appends the bounded fallback before reacquisition. + // A synchronous sink rejection (usually backpressure) leaves the terminal rows to the host's + // exit settlement, which writes its own bounded fallback. const admission = session.translator?.handle(event) ?? { accepted: true } - if (!admission.accepted) { - // The connection invokes onExit exactly once. Forward a flagged event so - // host recovery can append its no-new-blob fallback even when admission is - // backpressured; waiting for a second callback would strand the lease. - if (event.cause !== 'unexpected-exit' && !input.allowFailedSettlement) { - return false - } - event.settlementRetryRequired = true + // The connection invokes onExit exactly once, so an unexpected exit is forwarded even when + // admission is backpressured; waiting for a second callback would strand the lease. + if (!admission.accepted && event.cause !== 'unexpected-exit' && !input.allowFailedSettlement) { + return false } session.ended = true // Nothing can echo for this child any more; the journal's pending-submission @@ -52,6 +58,8 @@ export function handleCodexSessionExit(input: { session.dispatchEchoes.clear() session.backgroundTasks.clear() input.onBackgroundTasksChanged?.(input.sessionId, null) + // Every close path funnels here, so the session's children end with it on each one. + session.backgroundTasks.publishChildWork() session.unbindReadingControl?.() input.onEvent?.(event) session.prompts.clear() @@ -97,6 +105,7 @@ export async function closeCodexPublishedSession( sessionId, connection: session.connection, error: options?.unexpectedReason ?? new Error('codex session closed'), + closedByOrca: true, prompts: session.prompts, ...(options?.allowFailedSettlement ? { allowFailedSettlement: true } : {}), ...(onEvent ? { onEvent } : {}) diff --git a/src/main/codex/codex-structured-session-exit-blame.test.ts b/src/main/codex/codex-structured-session-exit-blame.test.ts new file mode 100644 index 00000000000..591f0e61ae8 --- /dev/null +++ b/src/main/codex/codex-structured-session-exit-blame.test.ts @@ -0,0 +1,68 @@ +// Only the Codex app-server child's own exit says Codex stopped; a close Orca made — a journal +// sink that could not take a frame, a forced close — is Orca's. + +import { describe, expect, it, vi } from 'vitest' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { adapterFor, fakeCodex, identityFor } from './codex-structured-session-adapter-fixture' +import type { CodexStructuredSessionEvent } from './codex-structured-session-state' + +function sink(overrides: Partial = {}) { + return { appendItem: vi.fn(), appendTombstone: vi.fn(), publish: vi.fn(), ...overrides } +} + +async function startedCodex(events: StructuredAgentSessionEventSink) { + const codex = fakeCodex() + const emitted: CodexStructuredSessionEvent[] = [] + const adapter = adapterFor(codex, {}, emitted) + await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 's', events }) + const ended = async () => { + await vi.waitFor(() => expect(emitted.some((event) => event.type === 'ended')).toBe(true)) + return emitted.filter((event) => event.type === 'ended') + } + return { adapter, connection: codex.connections[0], ended } +} + +describe('what a started Codex session says ended it', () => { + it('says Codex stopped when the child exits on its own', async () => { + const { connection, ended } = await startedCodex(sink()) + + connection.handlers.onExit?.(new Error('codex app-server connection ended: killed')) + + const [event] = await ended() + expect(event).toMatchObject({ cause: 'unexpected-exit', failure: { kind: 'providerExited' } }) + const failure = event && 'failure' in event ? event.failure : undefined + expect( + failure && agentSessionFailureWords(failure, { surface: 'row', agentName: 'Codex' }).text + ).toBe( + 'Codex stopped while this response was in progress. You can continue in this conversation.' + ) + }) + + it('blames Orca when a journal sink failure makes Orca close the child', async () => { + const { connection, ended } = await startedCodex( + sink({ tryAppendItem: () => ({ accepted: false, reason: 'failed' }) }) + ) + + connection.handlers.onUnhandledFrame?.('frame:invalid-json', '{') + + expect(await ended()).toMatchObject([ + { + reason: 'Codex provider frame frame:invalid-json could not be durably recorded (failed)', + cause: 'unexpected-exit', + failure: { kind: 'hostFault' } + } + ]) + expect(connection.closeCount).toBe(1) + }) + + it('blames Orca for a forced close', async () => { + const { adapter, ended } = await startedCodex(sink()) + + await expect(adapter.forceCloseSession('session-1')).resolves.toBe(true) + + expect(await ended()).toMatchObject([ + { cause: 'unexpected-exit', failure: { kind: 'hostFault' } } + ]) + }) +}) diff --git a/src/main/codex/codex-structured-session-options-catalog.test.ts b/src/main/codex/codex-structured-session-options-catalog.test.ts index a9fd420eb0a..aac9e2946cf 100644 --- a/src/main/codex/codex-structured-session-options-catalog.test.ts +++ b/src/main/codex/codex-structured-session-options-catalog.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' +import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' import type { CodexAppServerConnection } from './codex-app-server-connection' import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' import { @@ -62,6 +63,7 @@ function storeSession( reportedOptions: { model: 'gpt-live', effort: 'high' }, fastModeTierByModel: new Map(), dispatchEchoes: createCodexDispatchEchoes(), + turnOpenWaits: createCodexTurnOpenWaits(), translator: null, catalogAccess: { store, fingerprint: FINGERPRINT, accountHomePath: '/homes/a' } } diff --git a/src/main/codex/codex-structured-session-options.test.ts b/src/main/codex/codex-structured-session-options.test.ts index 47ed8b50e3a..d99ecc0305e 100644 --- a/src/main/codex/codex-structured-session-options.test.ts +++ b/src/main/codex/codex-structured-session-options.test.ts @@ -1,4 +1,5 @@ import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' +import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' import { describe, expect, it, vi } from 'vitest' import type { CodexAppServerConnection } from './codex-app-server-connection' import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' @@ -36,6 +37,7 @@ function optionSession(request: CodexAppServerConnection['request']): CodexSessi reportedOptions: { model: 'gpt-live', effort: 'high' }, fastModeTierByModel: new Map(), dispatchEchoes: createCodexDispatchEchoes(), + turnOpenWaits: createCodexTurnOpenWaits(), translator: null } } diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index b5e4796d21d..627e33dbfa9 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -3,14 +3,20 @@ import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import { randomUUID } from 'node:crypto' +import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' import { cancelProcessAcquisition } from '../../shared/child-process/cancel-process-acquisition' import type { CodexAppServerConnection, openCodexAppServerConnection } from './codex-app-server-connection' import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' +import { + createCodexTurnOpenWaits, + type CodexTurnOpenWaits +} from './codex-structured-turn-open-wait' import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' +import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import type { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexJournalTranslator } from './codex-structured-journal-translation' import type { CodexTurnProcessSnapshot } from './codex-structured-turn-processes' @@ -34,6 +40,8 @@ export type CodexStructuredLaunch = { * rollout for it, start a new thread in its place. Never set for a thread a resume proved. */ supersedeIfUnsaved?: boolean permissionPolicy?: CodexStructuredPermissionPolicy + /** The model the session chose; the thread opens on it so its first turn is not a switch. */ + model?: string env?: Record } @@ -75,12 +83,16 @@ export type CodexStructuredSessionAdapterDeps = { sessionId: string, state: AgentSessionBackgroundTaskState | null ) => void - /** Identity for a send admitted earlier, once Codex echoes the user message. */ - onDispatchSettledLate?: (input: { - sessionId: string - clientMessageId: string - providerIdentity: AgentJournalItemIdentity - }) => void + /** What the session's child work did, delivered after the journal handled the frame. */ + onChildWorkEvidence?: (sessionId: string, evidence: AgentChildWorkEvidence[]) => void + /** A send admitted earlier: its identity once Codex echoes it, or its rejection when the turn + * Codex answered it into ended without taking it. */ + onDispatchSettledLate?: ( + input: { sessionId: string; clientMessageId: string } & ( + | { providerIdentity: AgentJournalItemIdentity } + | ({ state: 'rejected' } & AgentJournalDispatchRejection) + ) + ) => void /** Codex reported its thread not running with no turn open: a send whose * dispatch was never answered is owed nothing after this. */ onPrimaryThreadStoppedRunning?: (input: { sessionId: string }) => void @@ -110,7 +122,11 @@ export type CodexSession = { threadId: string historyPath: string | null historyMode?: 'legacy' | 'paginated' + /** Primary-thread turns Codex reported started and not yet ended, as read off the wire: what + * rewind waits out and what a Stop naming no turn interrupts when the journal shows none. */ activeTurnIds?: Set + /** Stops waiting for the turn Codex answered a send into to open. */ + turnOpenWaits: CodexTurnOpenWaits dispatchPending?: boolean prompts: CodexAcquisitionWindow['prompts'] options: Map @@ -141,8 +157,27 @@ export function mintCodexAcquisitionGeneration(deps: CodexStructuredSessionAdapt export function codexSessionLifecycle( fence: number, acquisitionGeneration: string -): Pick { - return { ended: false, requestedClose: false, fence, acquisitionGeneration } +): Pick< + CodexSession, + 'ended' | 'requestedClose' | 'fence' | 'acquisitionGeneration' | 'turnOpenWaits' +> { + return { + ended: false, + requestedClose: false, + fence, + acquisitionGeneration, + turnOpenWaits: createCodexTurnOpenWaits() + } +} + +/** A child that exited while being acquired never becomes the session's. */ +export function assertCodexConnectionOpen( + connection: Pick, + sessionId: string +): void { + if (connection.closed) { + throw new Error(`codex app-server for session ${sessionId} exited while being acquired`) + } } export function requireLiveCodexSession( diff --git a/src/main/codex/codex-structured-thread-facts.ts b/src/main/codex/codex-structured-thread-facts.ts index 7422d7e44ff..f2a42e6d3db 100644 --- a/src/main/codex/codex-structured-thread-facts.ts +++ b/src/main/codex/codex-structured-thread-facts.ts @@ -47,6 +47,11 @@ export function readCodexTurnStatus(payload: unknown): string | null { return nonEmptyString(record(root.turn)?.status) ?? nonEmptyString(root.status) } +/** A failed `turn/completed` carries Codex's reason as `turn.error.message`. */ +export function readCodexTurnErrorMessage(payload: unknown): string | null { + return nonEmptyString(record(record(record(payload)?.turn)?.error)?.message) +} + /** Codex's own turn duration, already in milliseconds; absent or malformed reads as null. */ export function readCodexTurnDurationMs(payload: unknown): number | null { const root = record(payload) @@ -57,12 +62,6 @@ export function readCodexTurnDurationMs(payload: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null } -/** `error` carries `willRetry`: Codex sets it on a stream error it is about to - * retry, and omits it (false) on one that ended the turn the frame names. */ -export function readCodexErrorWillRetry(payload: unknown): boolean { - return record(payload)?.willRetry === true -} - /** `thread/status/changed` carries a TAGGED status (`{status:{type}}`), never a * bare string. `idle` and `systemError` are the two arms that mean the thread * is not running; `active` and `notLoaded` are not. */ @@ -70,3 +69,13 @@ export function codexThreadStoppedRunning(payload: unknown): boolean { const type = record(record(payload)?.status)?.type return type === 'idle' || type === 'systemError' } + +/** An `active` thread flags each request it has open on the user (an approval, a question). */ +export function codexThreadWaitsOnUser(payload: unknown): boolean { + const status = record(record(payload)?.status) + const flags = status?.type === 'active' ? status.activeFlags : null + return ( + Array.isArray(flags) && + flags.some((flag) => flag === 'waitingOnApproval' || flag === 'waitingOnUserInput') + ) +} diff --git a/src/main/codex/codex-structured-thread-open.test.ts b/src/main/codex/codex-structured-thread-open.test.ts index b819a0dc6b2..fede869a83c 100644 --- a/src/main/codex/codex-structured-thread-open.test.ts +++ b/src/main/codex/codex-structured-thread-open.test.ts @@ -261,6 +261,29 @@ describe('openCodexThread', () => { ) }) + it('opens the replacement thread on the chosen model', async () => { + const request = codexWithoutRollout() + + await openCodexThread( + connectionFor(request), + { + cwd: '/workspace', + resumeThreadId: 'thread-unsaved', + supersedeIfUnsaved: true, + model: 'gpt-chosen' + }, + 2_000 + ) + + // A resume keeps the thread's own saved model, provider and effort, which naming one skips. + expect(request.mock.calls[0]?.[1]).not.toHaveProperty('model') + expect(request).toHaveBeenLastCalledWith( + 'thread/start', + { cwd: '/workspace', model: 'gpt-chosen' }, + { timeoutMs: 2_000 } + ) + }) + it('keeps the resume failure for a thread a resume already proved', async () => { const request = codexWithoutRollout() diff --git a/src/main/codex/codex-structured-thread-open.ts b/src/main/codex/codex-structured-thread-open.ts index 83cd710e136..0f8b530d624 100644 --- a/src/main/codex/codex-structured-thread-open.ts +++ b/src/main/codex/codex-structured-thread-open.ts @@ -90,14 +90,18 @@ export async function openCodexThread( resumePath?: string | null supersedeIfUnsaved?: boolean permissionPolicy?: CodexStructuredPermissionPolicy + /** Why: Codex renders a thread's base instructions for its opening model; a first turn on + * another model reads as a mid-conversation switch and injects a second full prompt. */ + model?: string }, timeoutMs: number | undefined ): Promise { const resumeThreadId = launch.resumeThreadId + const threadSettings = { cwd: launch.cwd, ...launch.permissionPolicy } const startThread = (): Promise => connection.request( 'thread/start', - { cwd: launch.cwd, ...launch.permissionPolicy }, + { ...threadSettings, ...(launch.model ? { model: launch.model } : {}) }, { timeoutMs } ) let supersededThreadId: string | undefined @@ -105,10 +109,10 @@ export async function openCodexThread( if (!resumeThreadId) { opened = await startThread() } else { + // No model: naming one makes Codex skip the thread's saved model, provider and effort. const resumeParams = { threadId: resumeThreadId, - cwd: launch.cwd, - ...launch.permissionPolicy, + ...threadSettings, ...(launch.resumePath ? { path: launch.resumePath } : {}) } try { diff --git a/src/main/codex/codex-structured-turn-cancellation.ts b/src/main/codex/codex-structured-turn-cancellation.ts index 4418da81057..524ab252e4f 100644 --- a/src/main/codex/codex-structured-turn-cancellation.ts +++ b/src/main/codex/codex-structured-turn-cancellation.ts @@ -3,6 +3,8 @@ import { type CodexAppServerConnection } from './codex-app-server-connection' import { isCodexAppServerUnsupportedError } from './codex-app-server-session' +import { providerDiagnosticOf } from '../../shared/agent-session-failure' +import type { AgentSessionCancelOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexSession, CodexStructuredSessionAdapterDeps, @@ -91,7 +93,7 @@ export class CodexStructuredTurnCancellation { turnId: string, isCurrent: () => boolean = () => true, onConfirmed?: () => CodexJournalTranslationAdmission - ): Promise<{ cancelled: boolean }> { + ): Promise { const state = this.state(session) const key = turnKey(threadId, turnId) state.blockedCompletions.add(key) @@ -151,7 +153,14 @@ export class CodexStructuredTurnCancellation { // A failed cancellation must not permanently divert the provider's later // completion for this turn. Let the normal completion path settle it. this.releaseCompletion(session, key) - return { cancelled: false } + if (acknowledged) { + return { cancelled: false, unconfirmed: true } + } + if (!requestError) { + return { cancelled: false } + } + const detail = providerDiagnosticOf(requestError) + return { cancelled: false, refusal: detail ? { detail } : {} } } private capture(pid: number | undefined): Promise { diff --git a/src/main/codex/codex-structured-turn-end-settlement.test.ts b/src/main/codex/codex-structured-turn-end-settlement.test.ts new file mode 100644 index 00000000000..38cc648f79d --- /dev/null +++ b/src/main/codex/codex-structured-turn-end-settlement.test.ts @@ -0,0 +1,321 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import { classifyDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection' +import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' +import { + acquiredCodexAdapter, + CODEX_TEST_THREAD_ID, + CODEX_TEST_USER_MESSAGE, + fakeCodexAppServer, + type LateSettlement +} from './codex-structured-dispatch-test-support' +import { codexTurnLifecycleFake } from './codex-turn-lifecycle-fake' + +async function turnEndRig() { + const codex = fakeCodexAppServer() + const turns = codexTurnLifecycleFake(CODEX_TEST_THREAD_ID, () => { + const handlers = codex.connections.at(-1)?.handlers + return (method, params) => handlers?.onNotification?.(method, params) + }) + Object.assign(codex.routes, turns.routes) + const settlements: LateSettlement[] = [] + const bodies: AgentJournalItemBody[] = [] + const adapter = await acquiredCodexAdapter({ + codex, + settlements, + sink: { + appendItem: (_identity, body) => bodies.push(body), + appendTombstone: () => {}, + publish: () => {} + } + }) + const send = (clientMessageId: string) => + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId, + body: CODEX_TEST_USER_MESSAGE, + fence: 7 + }) + // Codex answers a cold send before it opens the turn. + const sendAndOpen = async (clientMessageId: string) => { + const sending = send(clientMessageId) + await vi.waitFor(() => expect(turns.turnId).not.toBeNull()) + turns.start() + return sending + } + const settledIds = () => settlements.map(({ clientMessageId }) => clientMessageId) + const categoryOf = (settlement: LateSettlement | undefined) => + settlement && 'state' in settlement ? classifyDispatchRejection(settlement).category : null + return { codex, turns, adapter, send, sendAndOpen, settlements, settledIds, categoryOf, bodies } +} + +describe('a Codex send its turn ended without echoing', () => { + it('is withdrawn when the turn is interrupted, once', async () => { + const rig = await turnEndRig() + await expect(rig.sendAndOpen('client-1')).resolves.toEqual({ state: 'admitted' }) + + rig.turns.end('interrupted') + + expect(rig.settlements).toEqual([ + expect.objectContaining({ + sessionId: 'session-1', + clientMessageId: 'client-1', + state: 'rejected' + }) + ]) + expect(rig.categoryOf(rig.settlements[0])).toBe('withdrawn') + }) + + it('is rejected in Codex words when its failed turn ends without echoing it', async () => { + const rig = await turnEndRig() + await rig.sendAndOpen('client-1') + + rig.codex.connections[0]!.handlers.onNotification?.('error', { + threadId: CODEX_TEST_THREAD_ID, + turnId: 'turn-1', + willRetry: false, + error: { message: 'usage limit reached' } + }) + rig.turns.end('failed', 'usage limit reached') + + expect(rig.settlements).toEqual([ + expect.objectContaining({ + clientMessageId: 'client-1', + state: 'rejected', + rejection: { + kind: 'providerRejected', + detail: { text: 'usage limit reached', audience: 'person' } + } + }) + ]) + }) + + it('is accepted when Codex records it after the error that fails its turn', async () => { + const rig = await turnEndRig() + await rig.sendAndOpen('client-1') + rig.turns.echo('client-1') + await rig.send('client-2') + + // A failed turn keeps its steered input: Codex records it after the error, before the end. + rig.codex.connections[0]!.handlers.onNotification?.('error', { + threadId: CODEX_TEST_THREAD_ID, + turnId: 'turn-1', + willRetry: false, + error: { message: 'usage limit reached' } + }) + rig.turns.echo('client-2') + rig.turns.end('failed', 'usage limit reached') + + expect(rig.settlements).toEqual([ + expect.objectContaining({ clientMessageId: 'client-1', providerIdentity: expect.anything() }), + expect.objectContaining({ + clientMessageId: 'client-2', + providerIdentity: expect.objectContaining({ turnId: 'turn-1' }) + }) + ]) + }) + + it('stays pending, still armed, when its turn completes without echoing it', async () => { + const rig = await turnEndRig() + await rig.sendAndOpen('client-1') + + rig.turns.end('completed') + expect(rig.settlements).toEqual([]) + + // Codex echoes before a completed end; this late one only proves the send is still armed. + rig.turns.echo('client-1') + expect(rig.settlements).toEqual([ + expect.objectContaining({ + clientMessageId: 'client-1', + providerIdentity: expect.objectContaining({ turnId: 'turn-1' }) + }) + ]) + }) + + it('accepts a send its turn echoed, with its key, exactly once', async () => { + const rig = await turnEndRig() + await rig.sendAndOpen('client-1') + rig.turns.echo('client-1') + + rig.turns.end('interrupted') + + expect(rig.settlements).toEqual([ + { + sessionId: 'session-1', + clientMessageId: 'client-1', + providerIdentity: { + provider: 'codex', + threadId: CODEX_TEST_THREAD_ID, + turnId: 'turn-1', + ordinal: 0 + } + } + ]) + }) + + it('ignores an echo that arrives after the withdrawal', async () => { + const rig = await turnEndRig() + await rig.sendAndOpen('client-1') + rig.turns.end('interrupted') + + rig.turns.echo('client-1') + + expect(rig.settledIds()).toEqual(['client-1']) + expect(rig.categoryOf(rig.settlements[0])).toBe('withdrawn') + }) + + it('settles each of two sends steered into one interrupted turn once', async () => { + const rig = await turnEndRig() + await rig.sendAndOpen('client-1') + await rig.send('client-2') + expect(rig.turns.turnId).toBe('turn-1') + + rig.turns.end('interrupted') + + expect(rig.settledIds().sort()).toEqual(['client-1', 'client-2']) + }) + + it('settles a send whose answer arrived after turn/started when the turn is interrupted', async () => { + const rig = await turnEndRig() + const release = rig.turns.holdNextAnswer() + const sending = rig.send('client-1') + await vi.waitFor(() => expect(rig.turns.turnId).toBe('turn-1')) + rig.turns.start() + release() + await expect(sending).resolves.toEqual({ state: 'admitted' }) + + rig.turns.end('interrupted') + + expect(rig.settledIds()).toEqual(['client-1']) + }) + + it('settles a send by the recorded end of a turn that finished before its answer arrived', async () => { + const rig = await turnEndRig() + const release = rig.turns.holdNextAnswer() + const sending = rig.send('client-1') + await vi.waitFor(() => expect(rig.turns.turnId).toBe('turn-1')) + rig.turns.start() + rig.turns.end('interrupted') + const rowsAtEnd = rig.bodies.filter((body) => body.kind === 'turn').length + release() + + const outcome = await sending + expect(outcome.state === 'rejected' && classifyDispatchRejection(outcome).category).toBe( + 'withdrawn' + ) + expect(rig.settlements).toEqual([]) + // The answer opens nothing: the turn keeps its terminal row. + expect(rig.bodies.filter((body) => body.kind === 'turn')).toHaveLength(rowsAtEnd) + expect(rig.bodies.findLast((body) => body.kind === 'turn')).toMatchObject({ + state: 'interrupted' + }) + }) + + it('rejects a send in Codex words by the recorded end of a failed turn that finished before its answer', async () => { + const rig = await turnEndRig() + const release = rig.turns.holdNextAnswer() + const sending = rig.send('client-1') + await vi.waitFor(() => expect(rig.turns.turnId).toBe('turn-1')) + rig.turns.start() + rig.turns.end('failed', 'usage limit reached') + release() + + await expect(sending).resolves.toMatchObject({ + state: 'rejected', + rejection: { + kind: 'providerRejected', + detail: { text: 'usage limit reached', audience: 'person' } + } + }) + // Settled once, by the answer: a late echo is no longer owed anything. + rig.turns.echo('client-1') + expect(rig.settlements).toEqual([]) + }) + + it('leaves a send pending, still armed, when its answer is read after its turn completed', async () => { + const rig = await turnEndRig() + const release = rig.turns.holdNextAnswer() + const sending = rig.send('client-1') + await vi.waitFor(() => expect(rig.turns.turnId).toBe('turn-1')) + rig.turns.start() + rig.turns.end('completed') + release() + + await expect(sending).resolves.toEqual({ state: 'admitted' }) + expect(rig.settlements).toEqual([]) + rig.turns.echo('client-1') + expect(rig.settledIds()).toEqual(['client-1']) + expect(rig.settlements[0]).toHaveProperty('providerIdentity') + }) + + it('leaves a send whose answer timed out to its echo', async () => { + const rig = await turnEndRig() + rig.codex.routes['turn/start'] = () => { + throw new Error('codex app-server turn/start exceeded 30000ms') + } + await expect(rig.send('client-1')).rejects.toThrow('exceeded') + + rig.codex.connections[0]!.handlers.onNotification?.('turn/started', { + threadId: CODEX_TEST_THREAD_ID, + turn: { id: 'turn-9' } + }) + rig.codex.connections[0]!.handlers.onNotification?.('item/completed', { + threadId: CODEX_TEST_THREAD_ID, + turn: { id: 'turn-9' }, + item: { type: 'userMessage', id: 'item-9', clientId: 'client-1', content: [] } + }) + + expect(rig.settlements).toEqual([ + expect.objectContaining({ + clientMessageId: 'client-1', + providerIdentity: expect.objectContaining({ turnId: 'turn-9' }) + }) + ]) + }) + + it('ignores a turn end on a child thread', async () => { + const rig = await turnEndRig() + await rig.sendAndOpen('client-1') + + rig.codex.connections[0]!.handlers.onNotification?.('turn/completed', { + threadId: 'thread-child', + turn: { id: 'turn-1', status: 'interrupted' } + }) + + expect(rig.settlements).toEqual([]) + }) +}) + +describe('a send bound to a turn', () => { + it('dies with the settlement its turn end makes', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + echoes.bindTurn('client-1', 'thread-1', 'turn-1') + + expect(echoes.endTurn('thread-1', 'turn-1', { status: 'interrupted' })).toEqual(['client-1']) + expect(echoes.size).toBe(0) + expect(echoes.settle('client-1')).toBe(false) + }) + + it('dies with its child, which forgets recorded turn ends too', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + echoes.bindTurn('client-1', 'thread-1', 'turn-1') + echoes.endTurn('thread-2', 'turn-2', { status: 'interrupted' }) + + echoes.clear() + + expect(echoes.size).toBe(0) + echoes.arm('client-2') + expect(echoes.bindTurn('client-2', 'thread-2', 'turn-2')).toBeNull() + }) + + it('is matched by thread as well as turn id', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + echoes.bindTurn('client-1', 'thread-1', 'turn-1') + + expect(echoes.endTurn('thread-2', 'turn-1', { status: 'interrupted' })).toEqual([]) + expect(echoes.size).toBe(1) + }) +}) diff --git a/src/main/codex/codex-structured-turn-end-settlement.ts b/src/main/codex/codex-structured-turn-end-settlement.ts new file mode 100644 index 00000000000..5187aabf45a --- /dev/null +++ b/src/main/codex/codex-structured-turn-end-settlement.ts @@ -0,0 +1,96 @@ +// A send Codex answered into a turn that then ended without echoing it. Codex clears +// a turn's pending input when it is interrupted, so that send never reached the model +// and is withdrawn, as a Stop's host-side withdrawal is. Any other end records pending +// input before `turn/completed`, a failed turn after its `error` frame, so only that +// frame settles: a failed turn that never echoed the send refused it, in Codex's words, +// and a completed one leaves it pending for the journal's recovery on exit. + +import { + agentSessionFailureFact, + providerDiagnostic, + type ProviderDiagnostic, + type SubmissionRejectionFact +} from '../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentJournalDispatchRejection +} from '../../shared/agent-session-failure-words' +import type { CodexTurnEnd } from './codex-structured-dispatch-echo' +import type { CodexSession } from './codex-structured-session-state' +import { + readCodexThreadId, + readCodexTurnErrorMessage, + readCodexTurnId, + readCodexTurnStatus +} from './codex-structured-thread-facts' +import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' + +/** A message Codex rejected, in the words that name Codex and its legacy markers. */ +export function codexDispatchRejection( + failure: SubmissionRejectionFact +): AgentJournalDispatchRejection { + return agentSessionFailureWords(failure, { + surface: 'rejection', + agentName: TUI_AGENT_DISPLAY_NAMES.codex, + provider: 'codex' + }) +} + +export type CodexTurnEndSettlement = { + clientMessageId: string + state: 'rejected' +} & AgentJournalDispatchRejection + +function errorDetail(params: unknown): ProviderDiagnostic | undefined { + const message = readCodexTurnErrorMessage(params) + return message ? providerDiagnostic(message, 'person') : undefined +} + +/** The end a primary-thread notification reports for its turn, or null for any other frame. */ +export function readCodexTurnEnd(method: string, params: unknown): CodexTurnEnd | null { + if (method !== 'turn/completed') { + return null + } + const status = readCodexTurnStatus(params) + if (status === 'interrupted') { + return { status: 'interrupted' } + } + if (status === 'failed') { + const detail = errorDetail(params) + return { status: 'failed', ...(detail ? { detail } : {}) } + } + return { status: 'completed' } +} + +/** How an ended turn settles a send it never echoed; null leaves the send to its echo. */ +export function codexTurnEndRejection(end: CodexTurnEnd): AgentJournalDispatchRejection | null { + if (end.status === 'interrupted') { + return agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + } + if (end.status === 'failed') { + return codexDispatchRejection( + agentSessionFailureFact('providerRejected', end.detail ? { detail: end.detail } : {}) + ) + } + return null +} + +/** Settles the sends bound to the turn this admitted notification ended. */ +export function settleCodexSendsInEndedTurn( + session: Pick, + method: string, + params: unknown, + settle: (settlement: CodexTurnEndSettlement) => void +): void { + const turnId = readCodexTurnId(params) + const end = readCodexTurnEnd(method, params) + if (!turnId || !end || (readCodexThreadId(params) ?? session.threadId) !== session.threadId) { + return + } + const rejection = codexTurnEndRejection(end) + for (const clientMessageId of session.dispatchEchoes.endTurn(session.threadId, turnId, end)) { + if (rejection) { + settle({ clientMessageId, state: 'rejected', ...rejection }) + } + } +} diff --git a/src/main/codex/codex-structured-turn-open-wait.test.ts b/src/main/codex/codex-structured-turn-open-wait.test.ts new file mode 100644 index 00000000000..125be2bba0b --- /dev/null +++ b/src/main/codex/codex-structured-turn-open-wait.test.ts @@ -0,0 +1,147 @@ +// A Codex Stop that names no turn, sent after Codex answered a cold send and before it opened that +// turn, waits for the turn to open, or provably not to, and never the send itself. The fake keeps +// Codex 0.157's turn bookkeeping: it answers before it opens the turn, and refuses an interrupt +// until then. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + CODEX_TEST_THREAD_ID, + codexTurnLifecycleRig, + settledWithin +} from './codex-structured-dispatch-test-support' +import { CODEX_STOP_TURN_OPEN_WAIT_MS } from './codex-structured-prompt-ownership' + +type Rig = Awaited> + +const ADMITTED = { state: 'admitted' } +const REFUSED = { cancelled: false } + +const stop = (rig: Rig) => rig.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) + +/** A cold send Codex answered into `turn-1` and has not opened. */ +async function answeredColdSend(rig: Rig): Promise { + const sending = rig.send('client-1') + await vi.waitFor(() => expect(rig.turns.turnId).toBe('turn-1')) + // The send is not held for the turn to open: its handover ends at the answer. + expect(await settledWithin(sending)).toEqual(ADMITTED) +} + +/** A Stop sent in the window, which Codex would refuse if it reached Codex now. */ +async function waitingStop(rig: Rig) { + await answeredColdSend(rig) + const stopping = stop(rig) + expect(await settledWithin(stopping)).toBe('held') + expect(rig.interrupts()).toEqual([]) + // Wrapped: an async function returning the promise itself would wait for it. + return { stopping } +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('a Codex send answered before its turn opens', () => { + it('is admitted at the answer when no Stop is pending', async () => { + const rig = await codexTurnLifecycleRig() + + await answeredColdSend(rig) + + expect(rig.turns.turnId).toBe('turn-1') + }) +}) + +describe("a no-turn Stop in the window between Codex's answer and its turn opening", () => { + it('waits for the turn to open, then stops it', async () => { + const rig = await codexTurnLifecycleRig() + const { stopping } = await waitingStop(rig) + + rig.turns.start() + + expect(await settledWithin(stopping)).toEqual({ cancelled: true }) + expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-1']) + expect(rig.turns.turnId).toBeNull() + }) + + it('does not wait when Codex opened the turn before its answer was read', async () => { + const rig = await codexTurnLifecycleRig() + const release = rig.turns.holdNextAnswer() + const sending = rig.send('client-1') + await vi.waitFor(() => expect(rig.turns.turnId).toBe('turn-1')) + rig.turns.start() + release() + await sending + + expect(await settledWithin(stop(rig))).toEqual({ cancelled: true }) + }) + + it('does not wait for a send Codex steered into the running turn', async () => { + const rig = await codexTurnLifecycleRig() + await answeredColdSend(rig) + rig.turns.start() + expect(await settledWithin(rig.send('client-2'))).toEqual(ADMITTED) + + expect(await settledWithin(stop(rig))).toEqual({ cancelled: true }) + expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-1']) + }) + + it('stops nothing when the turn ends without opening', async () => { + const rig = await codexTurnLifecycleRig() + const { stopping } = await waitingStop(rig) + + rig.turns.end('interrupted') + + expect(await settledWithin(stopping)).toEqual(REFUSED) + expect(rig.interrupts()).toEqual([]) + }) + + it.each(['idle', 'systemError'])( + 'stops nothing when Codex reports the thread %s', + async (type) => { + const rig = await codexTurnLifecycleRig() + const { stopping } = await waitingStop(rig) + + rig.notify('thread/status/changed', { threadId: CODEX_TEST_THREAD_ID, status: { type } }) + + expect(await settledWithin(stopping)).toEqual(REFUSED) + expect(rig.interrupts()).toEqual([]) + } + ) + + it('keeps waiting when a child thread stops running', async () => { + const rig = await codexTurnLifecycleRig() + const { stopping } = await waitingStop(rig) + + rig.notify('thread/status/changed', { threadId: 'thread-child', status: { type: 'idle' } }) + + expect(await settledWithin(stopping)).toBe('held') + rig.turns.start() + expect(await settledWithin(stopping)).toEqual({ cancelled: true }) + }) + + it('stops nothing when the child exits', async () => { + const rig = await codexTurnLifecycleRig() + const { stopping } = await waitingStop(rig) + + rig.codex.connections[0]!.handlers.onExit?.(new Error('codex app-server exited')) + + expect(await settledWithin(stopping)).toEqual(REFUSED) + expect(rig.interrupts()).toEqual([]) + }) + + it('stops nothing once its bound runs out', async () => { + const rig = await codexTurnLifecycleRig() + await answeredColdSend(rig) + vi.useFakeTimers() + let outcome: unknown = 'held' + void stop(rig).then((value) => { + outcome = value + }) + + await vi.advanceTimersByTimeAsync(CODEX_STOP_TURN_OPEN_WAIT_MS - 1) + expect(outcome).toBe('held') + await vi.advanceTimersByTimeAsync(1) + + expect(outcome).toEqual(REFUSED) + expect(rig.interrupts()).toEqual([]) + }) +}) diff --git a/src/main/codex/codex-structured-turn-open-wait.ts b/src/main/codex/codex-structured-turn-open-wait.ts new file mode 100644 index 00000000000..63e37294005 --- /dev/null +++ b/src/main/codex/codex-structured-turn-open-wait.ts @@ -0,0 +1,54 @@ +// A Stop's wait for the turn Codex answered a send into to open, or provably not to: it ended, +// the thread stopped running, or the child is gone. Held in memory only. + +import { + codexThreadStoppedRunning, + readCodexThreadId, + readCodexTurnId +} from './codex-structured-thread-facts' + +export type CodexTurnOpenWaits = { + /** Resolves once `turnId` opens or can no longer, and after `withinMs` at the latest. */ + wait: (turnId: string, withinMs: number) => Promise + /** Ends the waits a notification on the session's own thread answers. */ + observe: (threadId: string, method: string, params: unknown) => void + /** Ends every wait: the child that would open their turns is gone. */ + releaseAll: () => void +} + +export function createCodexTurnOpenWaits(): CodexTurnOpenWaits { + const waits = new Map<() => void, string>() + const release = (turnId?: string): void => { + for (const [endWait, waitedTurnId] of waits) { + if (turnId === undefined || waitedTurnId === turnId) { + endWait() + } + } + } + return { + wait: (turnId, withinMs) => + new Promise((resolve) => { + const endWait = (): void => { + clearTimeout(bound) + waits.delete(endWait) + resolve() + } + const bound = setTimeout(endWait, withinMs) + waits.set(endWait, turnId) + }), + observe: (threadId, method, params) => { + if ((readCodexThreadId(params) ?? threadId) !== threadId) { + return + } + if (method === 'thread/status/changed' && codexThreadStoppedRunning(params)) { + release() + return + } + const turnId = readCodexTurnId(params) + if (turnId && (method === 'turn/started' || method === 'turn/completed')) { + release(turnId) + } + }, + releaseAll: () => release() + } +} diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index 2d22ce42f3e..825a6c3c375 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -1,3 +1,4 @@ +import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { NativeChatBlock } from '../../shared/native-chat-types' import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' @@ -7,7 +8,11 @@ import { } from './codex-app-server-connection' import { isCodexAppServerUnsupportedError } from './codex-app-server-session' import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' -import { DISPATCH_REJECTED_CODEX_QUEUE_FULL } from '../../shared/structured-agent-session-dispatch-rejection' +import { readCodexTurnId } from './codex-structured-thread-facts' +import { + codexDispatchRejection, + codexTurnEndRejection +} from './codex-structured-turn-end-settlement' import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' // Writing a Codex turn and learning which message landed where, which are not @@ -15,7 +20,8 @@ import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured // message issued while a turn is running is COALESCED into that turn: the same // turn id comes back, no second `turn/started` fires, and the user message is // echoed only when the running turn reaches it. So the response proves -// admission and nothing about identity, which the echo settles later. +// admission and nothing about identity, which the echo settles later. The turn +// it names is kept with the send, so that turn's end can settle it. /** Keys Codex accepts as per-turn overrides. An unlisted key would otherwise * become an arbitrary client-controlled `turn/start` parameter. Permission posture is owned by @@ -86,7 +92,8 @@ function codexTurnOptions(host: CodexTurnHost): Record { /** * Hands one submission to Codex. False means the bounded correlation window - * refused it before the write; otherwise resolves when Codex has taken it. + * refused it before the write; otherwise resolves with the turn Codex answered + * it into, or null when the answer named none. */ export async function startCodexTurn( host: CodexTurnHost, @@ -96,13 +103,13 @@ export async function startCodexTurn( requestedAt?: number timeoutMs?: number } -): Promise { +): Promise<{ turnId: string | null } | false> { // Armed before the write: the echo and `turn/started` can both land while the // response is in flight, and the start must snapshot this send in its frontier. if (!host.dispatchEchoes.arm(input.clientMessageId, input.requestedAt)) { return false } - await host.connection.request( + const answer = await host.connection.request( 'turn/start', { threadId: host.threadId, @@ -112,7 +119,7 @@ export async function startCodexTurn( }, { timeoutMs: input.timeoutMs } ) - return true + return { turnId: readCodexTurnId(answer) } } /** @@ -126,19 +133,32 @@ export async function dispatchCodexTurn( input: { clientMessageId: string; body: AgentJournalMessageItem; requestedAt?: number }, timeoutMs: number | undefined ): Promise { + let answer: { turnId: string | null } | false try { - if (!(await startCodexTurn(session, { ...input, timeoutMs }))) { - return { state: 'rejected', reason: DISPATCH_REJECTED_CODEX_QUEUE_FULL } - } + answer = await startCodexTurn(session, { ...input, timeoutMs }) } catch (error) { if (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) { // Codex answered and declined, so no echo for this write can arrive. session.dispatchEchoes.disarm(input.clientMessageId) - return { state: 'rejected', reason: (error as Error).message } + // Codex's own words, when it gave any, are the one part of the error a person can use. + return { + state: 'rejected', + ...codexDispatchRejection( + agentSessionFailureFact('providerRejected', { detail: providerDiagnosticOf(error) }) + ) + } } // A timeout or transport failure can happen after the frame was written. // Keep the correlation armed so a later echo can prove delivery. throw error } - return { state: 'admitted' } + if (!answer) { + return { state: 'rejected', ...codexDispatchRejection(agentSessionFailureFact('queueFull')) } + } + // An answer read after the turn it names already ended is settled by that end. + const endedFirst = answer.turnId + ? session.dispatchEchoes.bindTurn(input.clientMessageId, session.threadId, answer.turnId) + : null + const rejection = endedFirst ? codexTurnEndRejection(endedFirst) : null + return rejection ? { state: 'rejected', ...rejection } : { state: 'admitted' } } diff --git a/src/main/codex/codex-subagent-executions.ts b/src/main/codex/codex-subagent-executions.ts index 0f74babe734..87d9327c70c 100644 --- a/src/main/codex/codex-subagent-executions.ts +++ b/src/main/codex/codex-subagent-executions.ts @@ -94,6 +94,15 @@ export class CodexSubagentExecutions { return { child, execution } } + /** The child's thread closed with no `turn/completed`: the turn it was running ended, and how + * it went is unknown. A child running none has nothing to end. */ + closeThread(agentThreadId: string): void { + const current = this.children.get(agentThreadId)?.execution + if (current?.state === 'working') { + this.observeTurn(agentThreadId, current.turnId, 'unverifiable') + } + } + /** Survives the child's turn, so a row outliving that turn can still name it. */ label(agentThreadId: string): string | null { return this.children.get(agentThreadId)?.label ?? null @@ -109,6 +118,11 @@ export class CodexSubagentExecutions { return this.children.get(agentThreadId)?.turnOrdinals.get(turnId) ?? null } + /** The child as last observed, without creating one. */ + find(agentThreadId: string): Readonly | undefined { + return this.children.get(agentThreadId) + } + workingChildren(): CodexExecutionChild[] { return [...this.children.values()].filter( (child) => child.registered && child.execution?.state === 'working' diff --git a/src/main/codex/codex-subagent-linkage.ts b/src/main/codex/codex-subagent-linkage.ts index 59d57a8e4c0..52f6bdc2611 100644 --- a/src/main/codex/codex-subagent-linkage.ts +++ b/src/main/codex/codex-subagent-linkage.ts @@ -8,15 +8,18 @@ // run are learned from the roster's executions, and a later revision of the // row picks them up when they arrive. -import type { AgentJournalProducerLinkage } from '../../shared/agent-session-journal-types' +import type { + AgentJournalProducerLinkage, + AgentJournalRowAttribution +} from '../../shared/agent-session-journal-types' import type { CodexSubagentExecutions } from './codex-subagent-executions' -/** Linkage for a row one thread produced, within one of that thread's turns - * (null outside any). Every Codex write site resolves through this. */ -export type CodexRowLinkage = ( +/** Who produced a row one thread wrote within one of that thread's turns (null outside any), + * and which turn the row belongs to. Every Codex write site resolves through this. */ +export type CodexRowAttribution = ( threadId: string, turnId: string | null -) => AgentJournalProducerLinkage +) => AgentJournalRowAttribution export class CodexSubagentLinkage { constructor( @@ -26,7 +29,7 @@ export class CodexSubagentLinkage { } ) {} - linkageFor: CodexRowLinkage = (threadId, turnId) => { + linkageFor = (threadId: string, turnId: string | null): AgentJournalProducerLinkage => { const primary = this.deps.primaryThreadId() // An unknown primary means the session's thread is still opening, and no // turn has run that could have spawned a child. diff --git a/src/main/codex/codex-subagent-roster-state.ts b/src/main/codex/codex-subagent-roster-state.ts new file mode 100644 index 00000000000..02c79411b79 --- /dev/null +++ b/src/main/codex/codex-subagent-roster-state.ts @@ -0,0 +1,36 @@ +// One spawn group's roster state, and the ids its journal row is keyed on. + +import type { + AgentJournalItemIdentity, + AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' +import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' + +/** The turn a group belongs to when Codex reports activity outside any turn. + * Mirrors the generic-frame bucket name so the two read alike in the journal. */ +export type RosterGroup = { + groupId: string + identity: AgentJournalItemIdentity + /** The spawning turn's scope: the row reports its children beside that turn's work. */ + turnScope: AgentJournalTurnScope + /** Insertion order is the display order; the map holds the state. */ + entries: Map + executionTurns: Map + /** Times each label has been claimed, so a repeat gets an ordinal suffix. */ + labelCounts: Map + /** Last body written, so an idempotent replay writes no new revision. */ + lastSerialized: string | null +} + +/** Group identity: the parent turn that spawned the children. `agentPath` is a + * tree rooted at the parent thread, so every child of one turn shares a row + * no matter which thread's stream carried its activity item. */ +export function codexSubagentGroupId(threadId: string, turnId: string | null): string { + return `${threadId}:${turnId ?? 'outside-turn'}` +} + +/** Durable journal identity for the group's row — stable across revisions and + * across a restart, so replay finds the same row instead of appending a new one. */ +export function codexSubagentGroupIdentity(groupId: string): AgentJournalItemIdentity { + return { provider: 'orca', clientMessageId: `codex-subagents:${groupId}` } +} diff --git a/src/main/codex/codex-subagent-roster.test.ts b/src/main/codex/codex-subagent-roster.test.ts index 9d3e2c690c4..dcaecb0e3e5 100644 --- a/src/main/codex/codex-subagent-roster.test.ts +++ b/src/main/codex/codex-subagent-roster.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it } from 'vitest' import { isAdmissibleAgentJournalItemBody } from '../../shared/agent-session-journal-schemas' import type { @@ -43,6 +44,7 @@ function createHarness(options: { threadId?: string | null } = {}): { tryPublish: () => ({ accepted: true }) } const roster = new CodexSubagentRoster({ + turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, sink, primaryThreadId: () => (options.threadId === undefined ? THREAD : options.threadId), activeTurn: () => TURN, @@ -138,6 +140,7 @@ function createCoalescingHarness(): { } } const roster = new CodexSubagentRoster({ + turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, sink, primaryThreadId: () => THREAD, activeTurn: () => TURN, @@ -668,6 +671,7 @@ describe('CodexSubagentRoster', () => { const published: number[] = [] const refusal = { accepted: false, reason: 'backpressure' } as const const roster = new CodexSubagentRoster({ + turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, sink: { appendItem: () => {}, appendTombstone: () => {}, @@ -721,6 +725,7 @@ describe('CodexSubagentRoster', () => { const appended: Appended[] = [] const published: number[] = [] const roster = new CodexSubagentRoster({ + turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, sink: { appendItem: () => {}, appendTombstone: () => {}, @@ -765,6 +770,7 @@ describe('CodexSubagentRoster', () => { it('propagates sink backpressure instead of reporting the row as written', () => { const roster = new CodexSubagentRoster({ + turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, sink: { appendItem: () => {}, appendTombstone: () => {}, diff --git a/src/main/codex/codex-subagent-roster.ts b/src/main/codex/codex-subagent-roster.ts index 99b37e9c772..db02ac7f454 100644 --- a/src/main/codex/codex-subagent-roster.ts +++ b/src/main/codex/codex-subagent-roster.ts @@ -13,12 +13,9 @@ // thread, and a real turn id is assumed freshly minted per turn. Seeding from // the journal is the fix. -import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import { isTerminalSubagentState } from '../../shared/native-chat-subagent-summary' -import type { - NativeChatSubagentEntry, - NativeChatSubagentState -} from '../../shared/native-chat-types' +import type { NativeChatSubagentState } from '../../shared/native-chat-types' import type { StructuredAgentSessionEventSink, StructuredAgentSessionSinkAdmission @@ -40,6 +37,12 @@ import { readCodexTurnId } from './codex-structured-thread-facts' import { codexSubagentGroupBody } from './codex-subagent-group-body' import { CodexSubagentLinkage } from './codex-subagent-linkage' export { codexSubagentGroupBody } from './codex-subagent-group-body' +export { codexSubagentGroupId, codexSubagentGroupIdentity } from './codex-subagent-roster-state' +import { + codexSubagentGroupId, + codexSubagentGroupIdentity, + type RosterGroup +} from './codex-subagent-roster-state' import type { CodexThreadItem } from './codex-structured-item-translation' import { MAX_CODEX_SUBAGENT_GROUPS, @@ -49,38 +52,12 @@ import { const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } -/** The turn a group belongs to when Codex reports activity outside any turn. - * Mirrors the generic-frame bucket name so the two read alike in the journal. */ -type RosterGroup = { - groupId: string - identity: AgentJournalItemIdentity - /** Insertion order is the display order; the map holds the state. */ - entries: Map - executionTurns: Map - /** Times each label has been claimed, so a repeat gets an ordinal suffix. */ - labelCounts: Map - /** Last body written, so an idempotent replay writes no new revision. */ - lastSerialized: string | null -} - -/** Group identity: the parent turn that spawned the children. `agentPath` is a - * tree rooted at the parent thread, so every child of one turn shares a row - * no matter which thread's stream carried its activity item. */ -export function codexSubagentGroupId(threadId: string, turnId: string | null): string { - return `${threadId}:${turnId ?? 'outside-turn'}` -} - -/** Durable journal identity for the group's row — stable across revisions and - * across a restart, so replay finds the same row instead of appending a new one. */ -export function codexSubagentGroupIdentity(groupId: string): AgentJournalItemIdentity { - return { provider: 'orca', clientMessageId: `codex-subagents:${groupId}` } -} - export type CodexSubagentRosterDeps = { sink: StructuredAgentSessionEventSink /** The thread that owns the agent tree; falls back to the event's thread. */ primaryThreadId: () => string | null activeTurn: (threadId: string) => string | null + turnScopeFor: (threadId: string, turnId: string | null) => AgentJournalTurnScope now?: () => number executions?: CodexSubagentExecutions } @@ -280,6 +257,7 @@ export class CodexSubagentRoster { const group: RosterGroup = { groupId, identity: codexSubagentGroupIdentity(groupId), + turnScope: this.deps.turnScopeFor(ownerThreadId, ownerTurnId), entries: new Map(), executionTurns: new Map(), labelCounts: new Map(), @@ -361,7 +339,10 @@ export class CodexSubagentRoster { // The publish must NOT reuse that key: the queue coalesces by key alone, // with no op-kind check, so a publish carrying it would splice out the // still-queued append and the row would never reach the journal. - const options = { coalescingKey: `codex-subagents:${group.groupId}` } + const options = { + coalescingKey: `codex-subagents:${group.groupId}`, + turnScope: group.turnScope + } const admission = this.deps.sink.tryAppendItem ? this.deps.sink.tryAppendItem(group.identity, body, options) : (this.deps.sink.appendItem(group.identity, body, options), ADMITTED) diff --git a/src/main/codex/codex-trust-config-concurrent-launch.test.ts b/src/main/codex/codex-trust-config-concurrent-launch.test.ts index d08d7a54969..0159ce608d9 100644 --- a/src/main/codex/codex-trust-config-concurrent-launch.test.ts +++ b/src/main/codex/codex-trust-config-concurrent-launch.test.ts @@ -25,6 +25,7 @@ const { CodexAppServerUnsupportedError } = await import('./codex-app-server-clie const { codexAppServerCapabilityCache } = await import('./codex-app-server-capability-cache') const { _internals, grantManagedCodexHookTrust } = await import('./codex-hook-trust-grant') const { markCodexProjectTrusted } = await import('../agent-trust-presets') +const { getLocalCodexTrustConfigFiles } = await import('./codex-home-paths') const { setCodexTrustGrantTelemetry } = await import('./codex-trust-grant-telemetry') const { computeTrustKey, @@ -207,7 +208,7 @@ describe('two Codex pane launches against one config.toml', () => { // Let the grant capture config.toml and start its session. await tick() await tick() - const marked = markCodexProjectTrusted(workspace) + const marked = markCodexProjectTrusted(workspace, getLocalCodexTrustConfigFiles()) await tick() // The lane must hold the preset write back until rollback has run. expect(readFileSync(tomlPath, 'utf-8')).not.toContain('trust_level') @@ -397,7 +398,7 @@ describe('reentrancy under concurrency', () => { // write nested inside both. const outcome = await runExclusivelyForCodexTrustConfig(tomlPath, () => runExclusivelyForCodexTrustConfig(systemToml, async () => { - await markCodexProjectTrusted(workspace) + await markCodexProjectTrusted(workspace, getLocalCodexTrustConfigFiles()) return grantManagedCodexHookTrust(buildPlan(entries)) }) ) diff --git a/src/main/codex/codex-trust-grant-main-thread-boundary.test.ts b/src/main/codex/codex-trust-grant-main-thread-boundary.test.ts deleted file mode 100644 index 4b88d95991c..00000000000 --- a/src/main/codex/codex-trust-grant-main-thread-boundary.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { existsSync, readFileSync, readdirSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -/** - * Ratchet for stablyai/orca#16441. - * - * Codex hook trust used to be granted by blocking the Electron main thread on - * `spawnSync` of a bundled ELECTRON_RUN_AS_NODE entry, for the whole - * app-server deadline: 15s native, 35s WSL, ~45s on the three-session real-home - * path. The window showed "Not Responding" during cold start and pane launch. - * - * The subprocess only ever existed to donate an event loop to a deliberately - * blocked parent, so this guards the shape of the fix rather than one call - * site: nothing on the trust-grant lane may start a child process - * synchronously, and the forked entry must stay gone. - */ -const CODEX_DIR = __dirname - -const SYNC_SPAWN_PATTERN = /\b(?:spawnSync|execSync|execFileSync|runProcessSync)\s*[(<]/ - -/** Drop comments so the prose explaining the old idiom is not an offender. */ -function codeText(contents: string): string { - return contents - .split('\n') - .filter((line) => !/^\s*(?:\/\/|\/\*|\*)/.test(line)) - .join('\n') -} - -function listCodexSourceFiles(): string[] { - return readdirSync(CODEX_DIR).filter((name) => name.endsWith('.ts') && !name.endsWith('.test.ts')) -} - -describe('codex trust grant main-thread boundary', () => { - it('starts no child process synchronously anywhere in the codex module', () => { - const offenders = listCodexSourceFiles().filter((name) => - SYNC_SPAWN_PATTERN.test(codeText(readFileSync(join(CODEX_DIR, name), 'utf8'))) - ) - expect(offenders).toEqual([]) - }) - - it('keeps the forked grant entry and its blocking bridge deleted', () => { - for (const name of [ - 'codex-app-server-grant-bridge.ts', - 'codex-app-server-grant-entry.ts', - 'codex-app-server-grant-envelope.ts' - ]) { - expect(existsSync(join(CODEX_DIR, name))).toBe(false) - } - }) - - it('keeps the trust-grant lane on async entry points', () => { - const grant = readFileSync(join(CODEX_DIR, 'codex-hook-trust-grant.ts'), 'utf8') - expect(grant).toContain('export async function grantManagedCodexHookTrust(') - const host = readFileSync(join(CODEX_DIR, 'codex-trust-grant-host.ts'), 'utf8') - expect(host).toContain('export async function resolveCodexTrustGrantHost(') - const realHome = readFileSync(join(CODEX_DIR, 'codex-real-home-hook-install.ts'), 'utf8') - expect(realHome).toContain('}): Promise {') - }) -}) diff --git a/src/main/codex/codex-turn-lifecycle-fake.ts b/src/main/codex/codex-turn-lifecycle-fake.ts new file mode 100644 index 00000000000..4ed2507640b --- /dev/null +++ b/src/main/codex/codex-turn-lifecycle-fake.ts @@ -0,0 +1,112 @@ +// Codex 0.157's turn bookkeeping as `turn/start` and `turn/interrupt` see it, for +// tests. From app-server `turn_processor.rs`: `turn/start` picks the turn before it +// answers, a send while a turn is open is steered into it under the same id with no +// second `turn/started`, and `turn_interrupt_inner` refuses with -32600 until the +// turn has started. The answer can be held, so a test can deliver it after the +// turn's own frames, as the wire allows. + +import { CodexAppServerRequestError } from './codex-app-server-connection' + +type Notify = (method: string, params: unknown) => void + +export type CodexTurnLifecycleFake = { + routes: { + 'turn/start': () => unknown + 'turn/interrupt': (params: Record | undefined) => unknown + } + /** The next `turn/start` answer waits until the returned release runs. */ + holdNextAnswer: () => () => void + /** Codex emits `turn/started` for the turn it picked last. */ + start: () => void + /** Codex ends the picked or running turn on its own. */ + end: (status: 'completed' | 'interrupted' | 'failed', errorMessage?: string) => void + /** Codex echoes a user message it recorded in the current turn. */ + echo: (clientId: string) => void + readonly turnId: string | null +} + +function refusal(message: string): CodexAppServerRequestError { + return new CodexAppServerRequestError( + 'turn/interrupt', + -32600, + `codex app-server turn/interrupt failed: ${message}`, + message + ) +} + +export function codexTurnLifecycleFake( + threadId: string, + notify: () => Notify +): CodexTurnLifecycleFake { + let minted = 0 + let echoes = 0 + let picked: string | null = null + let active: string | null = null + let lastTurn: string | null = null + let held: Promise | null = null + const finish = (turnId: string, status: string, errorMessage?: string): void => { + picked = null + active = null + notify()('turn/completed', { + threadId, + turn: { id: turnId, status, ...(errorMessage ? { error: { message: errorMessage } } : {}) } + }) + } + return { + routes: { + 'turn/start': () => { + const turnId = active ?? picked ?? `turn-${++minted}` + picked ??= active ? null : turnId + lastTurn = turnId + const answer = { turn: { id: turnId, status: 'inProgress' } } + const wait = held + held = null + return wait ? wait.then(() => answer) : answer + }, + 'turn/interrupt': (params) => { + const turnId = params?.turnId + if (!active) { + throw refusal('no active turn to interrupt') + } + if (active !== turnId) { + throw refusal(`expected active turn id ${String(turnId)} but found ${active}`) + } + // Codex answers the interrupt once the turn has aborted. + finish(active, 'interrupted') + return {} + } + }, + holdNextAnswer: () => { + let release!: () => void + held = new Promise((resolve) => { + release = resolve + }) + return () => release() + }, + start: () => { + if (!picked) { + throw new Error('no picked turn to start') + } + active = picked + notify()('turn/started', { threadId, turn: { id: active, status: 'inProgress' } }) + }, + end: (status, errorMessage) => { + const turnId = active ?? picked + if (!turnId) { + throw new Error('no turn to end') + } + finish(turnId, status, errorMessage) + }, + echo: (clientId) => { + const turnId = active ?? picked ?? lastTurn + notify()('item/completed', { + threadId, + turn: { id: turnId }, + item: { type: 'userMessage', id: `item-user-${++echoes}`, clientId, content: [] } + }) + }, + get turnId() { + return active ?? picked + } + } +} diff --git a/src/main/codex/config-settings-baseline.ts b/src/main/codex/config-settings-baseline.ts index 5cc26e361b9..d6c44909ef8 100644 --- a/src/main/codex/config-settings-baseline.ts +++ b/src/main/codex/config-settings-baseline.ts @@ -21,6 +21,9 @@ export type CodexSettingsBaseline = { * table reads as an addition rather than as a canonical removal. */ registrations: ReadonlyMap> + /** MCP server names the last mirror copied from the canonical source. */ + mcpServers: ReadonlySet + mcpServerRoot: boolean } type StoredSettingsBaseline = { @@ -28,6 +31,8 @@ type StoredSettingsBaseline = { settings: Record conflicts?: Record registrations?: Record> + mcpServers?: string[] + mcpServerRoot?: boolean } /** @@ -81,7 +86,14 @@ function readParsedCodexSettingsBaseline( conflicts.set(key, conflict) } } - return { settings, conflicts, registrations: readStoredRegistrations(parsed.registrations) } + return { + settings, + conflicts, + registrations: readStoredRegistrations(parsed.registrations), + mcpServers: readStoredMcpServers(parsed.mcpServers), + // Older mirrors owned the whole MCP root; retain that removal policy for one pass. + mcpServerRoot: parsed.mcpServers === undefined || parsed.mcpServerRoot === true + } } catch (error) { // Why: invalid baseline state is still `null` — resetting it is the intent, // and only a read that FAILED must be preserved. @@ -89,6 +101,10 @@ function readParsedCodexSettingsBaseline( } } +function readStoredMcpServers(stored: string[] | undefined): ReadonlySet { + return new Set((stored ?? []).filter((name): name is string => typeof name === 'string')) +} + function readStoredRegistrations( stored: Record> | undefined ): Map> { @@ -124,7 +140,8 @@ export function writeCodexSettingsBaseline( ): void { const file: StoredSettingsBaseline = { version: 3, - settings: Object.fromEntries(baseline.settings) + settings: Object.fromEntries(baseline.settings), + mcpServers: [...baseline.mcpServers] } if (baseline.conflicts.size > 0) { file.conflicts = Object.fromEntries(baseline.conflicts) @@ -134,6 +151,9 @@ export function writeCodexSettingsBaseline( [...baseline.registrations].map(([key, fields]) => [key, Object.fromEntries(fields)]) ) } + if (baseline.mcpServerRoot) { + file.mcpServerRoot = true + } const baselinePath = getCodexSettingsBaselinePath(runtimeHomePath) const serialized = `${JSON.stringify(file, null, 2)}\n` let existing: string | null = null diff --git a/src/main/codex/config-settings-promotion.ts b/src/main/codex/config-settings-promotion.ts index 52f4d6eefcd..56e499a6c18 100644 --- a/src/main/codex/config-settings-promotion.ts +++ b/src/main/codex/config-settings-promotion.ts @@ -37,6 +37,9 @@ export type CodexSettingsBaselineSnapshotOptions = { * mirrored would read a source config that never had them as a removal. */ mirroredRegistrations?: boolean + /** Names copied from the canonical source in this mirror pass. */ + mirroredMcpServers?: ReadonlySet + mirroredMcpServerRoot?: boolean } /** @@ -71,7 +74,9 @@ export function snapshotCodexRuntimeSettingsBaseline( conflicts, registrations: options.mirroredRegistrations ? readCodexRegistrationBaseline(runtimeConfig) - : new Map() + : new Map(), + mcpServers: options.mirroredMcpServers ?? new Set(), + mcpServerRoot: options.mirroredMcpServerRoot ?? false }) } catch (error) { console.warn('[codex-settings-promotion] failed to snapshot settings baseline', error) @@ -88,6 +93,9 @@ export type CodexSettingsPromotionHomes = { export type CodexSettingsPromotionPlan = { conflicts: ReadonlyMap runtimeValuesToPreserve: ReadonlyMap + /** MCP names the previous mirror copied from the canonical source. */ + mirroredMcpServers: ReadonlySet + mirroredMcpServerRoot: boolean } function getHostPromotionHomes(): CodexSettingsPromotionHomes { @@ -142,6 +150,8 @@ function promoteCodexRuntimeSettingsToSystemUnsafe( throw new Error('Codex settings baseline could not be read') } const baseline = baselineObservation.kind === 'present' ? baselineObservation.baseline : null + const mirroredMcpServers = baseline?.mcpServers ?? new Set() + const mirroredMcpServerRoot = baseline?.mcpServerRoot ?? false const updates = new Map() const conflicts = new Map() const runtimeValuesToPreserve = new Map() @@ -160,7 +170,7 @@ function promoteCodexRuntimeSettingsToSystemUnsafe( // canonical is an addition, never a removal it must honor. Scalars still need a // real baseline, so they stay gated above. if (updates.size === 0 && !hasCodexRegistrationEntries(runtimeTomlObservation.value)) { - return { conflicts, runtimeValuesToPreserve } + return { conflicts, runtimeValuesToPreserve, mirroredMcpServers, mirroredMcpServerRoot } } // Why: a fresh host has no ~/.codex; create it owner-only (holds auth.json) or the atomic write ENOENTs and the mirror wipes it. mkdirSync(systemHomePath, { recursive: true, mode: 0o700 }) @@ -202,17 +212,17 @@ function promoteCodexRuntimeSettingsToSystemUnsafe( ) ) if (nextContent === systemContent) { - return { conflicts, runtimeValuesToPreserve } + return { conflicts, runtimeValuesToPreserve, mirroredMcpServers, mirroredMcpServerRoot } } if (targetExists && parseWslUncPath(writeTarget.path)) { // Why: \\wsl$ 9P symlink metadata is unreliable; write through the existing file to preserve the WSL-side inode. writeFileSync(writeTarget.path, nextContent, 'utf-8') - return { conflicts, runtimeValuesToPreserve } + return { conflicts, runtimeValuesToPreserve, mirroredMcpServers, mirroredMcpServerRoot } } writeFileAtomically(writeTarget.path, nextContent, { mode: writeTarget.mode }) - return { conflicts, runtimeValuesToPreserve } + return { conflicts, runtimeValuesToPreserve, mirroredMcpServers, mirroredMcpServerRoot } } type PromotionCollectionContext = { @@ -264,7 +274,12 @@ function getComparableRaw(value: TopLevelSettingValue | undefined): string | nul } function emptyPromotionPlan(): CodexSettingsPromotionPlan { - return { conflicts: new Map(), runtimeValuesToPreserve: new Map() } + return { + conflicts: new Map(), + runtimeValuesToPreserve: new Map(), + mirroredMcpServers: new Set(), + mirroredMcpServerRoot: false + } } // Why: follow an existing dotfile-manager symlink and carry its mode forward so an atomic write can't widen a 0600 config. diff --git a/src/main/codex/config-toml-line-scan.ts b/src/main/codex/config-toml-line-scan.ts index 98d096167fe..0e983aac2dd 100644 --- a/src/main/codex/config-toml-line-scan.ts +++ b/src/main/codex/config-toml-line-scan.ts @@ -95,8 +95,18 @@ export function updateTomlLineScanState(state: TomlLineScanState, line: string): } export function getTomlTableHeader(line: string): string | null { - const match = /^(\s*\[\[?.+\]\]?\s*)(?:#.*)?$/.exec(line) - return match?.[1] ?? null + let index = 0 + while (index < line.length && line[index] !== '#') { + if (line[index] === '"') { + index = skipTomlBasicString(line, index + 1) + } else if (line[index] === "'") { + index = skipTomlLiteralString(line, index + 1) + } else { + index++ + } + } + const header = line.slice(0, index).trimEnd() + return /^\s*\[\[?.+\]\]?$/.test(header) ? header : null } export function parseTomlSingleLineStringValue( diff --git a/src/main/codex/config-toml-mcp-servers.test.ts b/src/main/codex/config-toml-mcp-servers.test.ts new file mode 100644 index 00000000000..e1d57770074 --- /dev/null +++ b/src/main/codex/config-toml-mcp-servers.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { readMcpServerTomlOwnership } from './config-toml-mcp-servers' +import { getTomlTableHeader } from './config-toml-line-scan' + +describe('MCP server TOML ownership', () => { + it.each([ + '[mcp_servers."server.with.dot"]\ncommand = "agent"', + '[mcp_servers]\n"server.with.dot" = { command = "agent" }', + 'mcp_servers."server.with.dot".command = "agent"', + '[mcp_servers."server.with.dot".env]\nMODE = "fixture"', + '[mcp_servers."server.with.dot"] # see [docs]\ncommand = "agent"', + '[mcp_servers."server.with.dot"] # server\r\ncommand = "agent"\r\n', + '[[mcp_servers."server.with.dot"]] # array\r\ncommand = "agent"' + ])('recognizes the decoded server name in %s', (config) => { + expect(readMcpServerTomlOwnership(config)).toEqual({ + names: new Set(['server.with.dot']), + ownsRoot: false + }) + }) + + it('treats a root assignment as ownership of the whole closed table', () => { + expect(readMcpServerTomlOwnership('"mcp_servers" = { shared = { enabled = false } }')).toEqual({ + names: new Set(), + ownsRoot: true + }) + }) + + it.each([ + '[profile] # comment\r\nmcp_servers = { foo = {} }\r\n', + '[profile] # see [docs]\nmcp_servers = { foo = {} }\n', + '[not valid]\nmcp_servers = { foo = {} }\n' + ])('does not treat a nested assignment as a canonical root in %s', (config) => { + expect(readMcpServerTomlOwnership(config)).toEqual({ names: new Set(), ownsRoot: false }) + }) + + it('ignores apparent keys in strings, arrays and unrelated tables', () => { + const config = [ + 'description = """', + '[mcp_servers.fake]', + 'mcp_servers = {}', + '"""', + 'args = [', + '"mcp_servers.quoted = {}",', + ']', + '[profile]', + 'mcp_servers = {}', + '[mcp_servers.real]', + 'command = "agent"' + ].join('\n') + expect(readMcpServerTomlOwnership(config)).toEqual({ + names: new Set(['real']), + ownsRoot: false + }) + }) +}) + +describe('commented TOML headers', () => { + it.each<[string, string | null]>([ + ['[mcp_servers."name#with]bracket"] # see [docs]\r', '[mcp_servers."name#with]bracket"]'], + ['[[mcp_servers.name]] # comment\r', '[[mcp_servers.name]]'], + ["[mcp_servers.'literal#name'] # comment", "[mcp_servers.'literal#name']"], + ['# [mcp_servers.fake]', null], + ['command = "[mcp_servers.fake]"', null] + ])('recognizes the structural header in %s', (line, header) => { + expect(getTomlTableHeader(line)).toBe(header) + }) +}) diff --git a/src/main/codex/config-toml-mcp-servers.ts b/src/main/codex/config-toml-mcp-servers.ts new file mode 100644 index 00000000000..12a492a7c88 --- /dev/null +++ b/src/main/codex/config-toml-mcp-servers.ts @@ -0,0 +1,42 @@ +import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path' +import { + createTomlLineScanState, + getTomlTableHeader, + isTomlStructuralLine, + updateTomlLineScanState +} from './config-toml-line-scan' + +/** Canonical inline root assignments own the whole table, which TOML forbids extending. */ +export function readMcpServerTomlOwnership(config: string): { + names: ReadonlySet + ownsRoot: boolean +} { + const names = new Set() + let ownsRoot = false + let tablePath: string[] | null = [] + let state = createTomlLineScanState() + for (const line of config.split('\n')) { + if (isTomlStructuralLine(state)) { + const header = getTomlTableHeader(line) + if (header) { + tablePath = parseTomlTableHeaderPath(header)?.segments ?? null + if (tablePath?.[0] === 'mcp_servers' && tablePath[1] !== undefined) { + names.add(tablePath[1]) + } + } else { + const key = parseTomlKeyPath(line) + const path = + tablePath && key && line[key.end] === '=' ? [...tablePath, ...key.segments] : [] + if (path[0] === 'mcp_servers') { + if (path[1] === undefined) { + ownsRoot = true + } else { + names.add(path[1]) + } + } + } + } + state = updateTomlLineScanState(state, line) + } + return { names, ownsRoot } +} diff --git a/src/main/codex/config-toml-runtime-owned-sections.ts b/src/main/codex/config-toml-runtime-owned-sections.ts index 0ff9ab19f6e..fee0cfdf643 100644 --- a/src/main/codex/config-toml-runtime-owned-sections.ts +++ b/src/main/codex/config-toml-runtime-owned-sections.ts @@ -5,6 +5,7 @@ import { isTomlStructuralLine, updateTomlLineScanState } from './config-toml-line-scan' +import { parseTomlTableHeaderPath } from './config-toml-key-path' import { normalizeCodexProjectPathForLookup, normalizeCodexProjectPathForRevocationLookup, @@ -91,6 +92,17 @@ export function isRuntimeProjectTomlSection(header: string): boolean { return parseCodexProjectHeaderPath(header) !== null } +const CODEX_MCP_SERVER_TABLE_ROOT = 'mcp_servers' + +/** Returns the decoded MCP server name for an owner table or nested descendant. */ +export function getMcpServerTomlSectionName(header: string): string | null { + const table = parseTomlTableHeaderPath(header) + if (!table || table.isArray || table.segments[0] !== CODEX_MCP_SERVER_TABLE_ROOT) { + return null + } + return table.segments[1] ?? null +} + export function getTomlSectionHeaderKey(header: string): string { const projectPath = parseCodexProjectHeaderPath(header) return projectPath === null diff --git a/src/main/codex/config-toml-trust-api-parity.test.ts b/src/main/codex/config-toml-trust-stale-writes.test.ts similarity index 59% rename from src/main/codex/config-toml-trust-api-parity.test.ts rename to src/main/codex/config-toml-trust-stale-writes.test.ts index d20cde89b8e..6a8fb8398de 100644 --- a/src/main/codex/config-toml-trust-api-parity.test.ts +++ b/src/main/codex/config-toml-trust-stale-writes.test.ts @@ -1,12 +1,6 @@ -import { afterEach, describe, expect, expectTypeOf, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { chmodSync, readFileSync, readdirSync, statSync } from 'node:fs' import * as trustApi from './config-toml-trust' -import type { - CodexEventLabel, - CodexHookTrustState, - CodexProjectTrustLevel, - CodexTrustEntry -} from './config-toml-trust' import { createTrustConfigFixture, removeTrustConfigFixture @@ -20,42 +14,6 @@ afterEach(() => { } }) -describe('config-toml-trust public API', () => { - it('retains the exact runtime export surface', () => { - expect(Object.keys(trustApi).sort()).toEqual( - [ - 'codexHookSourcePathsEqual', - 'computeTrustKey', - 'computeTrustedHash', - 'escapeTomlString', - 'getCodexExplicitHomeHookSourcePath', - 'normalizeCodexHookSourcePath', - 'normalizeCodexProjectPathForLookup', - 'normalizeCodexProjectPathForRevocationLookup', - 'normalizeHookTrustKeyForLookup', - 'parseCodexProjectHeaderPath', - 'parseTrustKey', - 'readHookTrustEntries', - 'readHookTrustEntriesFromContent', - 'removeHookTrustEntries', - 'removeHookTrustEntriesFromContent', - 'upsertHookTrustEntries', - 'upsertHookTrustEntriesInContent', - 'upsertProjectTrustLevel', - 'upsertProjectTrustLevelInContent', - 'writeConfigAtomically' - ].sort() - ) - }) - - it('retains the public type contracts', () => { - expectTypeOf<'stop' | 'session_start'>().toMatchTypeOf() - expectTypeOf().toEqualTypeOf<'trusted' | 'untrusted'>() - expectTypeOf().toHaveProperty('sourcePath').toEqualTypeOf() - expectTypeOf().toEqualTypeOf() - }) -}) - describe('config.toml partial and stale writes', () => { it('preserves a truncated malformed prefix while appending trust', () => { const partial = ['model = "gpt-5"', '[mcp_servers.partial', 'command = "still-user-data'].join( diff --git a/src/main/codex/hook-service-wsl-runtime.test.ts b/src/main/codex/hook-service-wsl-runtime.test.ts index ba03d835231..c052ad2f158 100644 --- a/src/main/codex/hook-service-wsl-runtime.test.ts +++ b/src/main/codex/hook-service-wsl-runtime.test.ts @@ -29,17 +29,6 @@ type HooksConfig = { hooks: Record } -const managedEvents = [ - 'SessionStart', - 'UserPromptSubmit', - 'PreToolUse', - 'PermissionRequest', - 'PostToolUse', - 'SubagentStart', - 'SubagentStop', - 'Stop' -] as const - let tempRoots: string[] = [] afterEach(() => { @@ -511,7 +500,6 @@ describe('Codex WSL runtime hook install', () => { expect((await _internals.installManagedHooksIntoWslRuntime(plan)).state).toBe('installed') const installed = JSON.parse(readFileSync(plan.configPath, 'utf-8')) as HooksConfig - expect(Object.keys(installed.hooks).sort()).toEqual([...managedEvents].sort()) const managedCommand = installed.hooks.UserPromptSubmit[0]?.hooks?.[0]?.command expect(managedCommand).toBe(expectedManagedCommand(plan.commandScriptPath)) expect(installed.hooks.UserPromptSubmit[1]?.hooks?.[0]?.command).toBe(userCommand) diff --git a/src/main/crash-reporting/gpu-crash-diagnostics.test.ts b/src/main/crash-reporting/gpu-crash-diagnostics.test.ts index 1982facd5f3..09c4d3d7658 100644 --- a/src/main/crash-reporting/gpu-crash-diagnostics.test.ts +++ b/src/main/crash-reporting/gpu-crash-diagnostics.test.ts @@ -1,5 +1,3 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { buildGpuCrashDiagnostics, GpuCrashDiagnosticsRecorder } from './gpu-crash-diagnostics' @@ -223,29 +221,3 @@ describe('GpuCrashDiagnosticsRecorder', () => { expect(recordBreadcrumb).toHaveBeenCalledWith({ gpuInfoLevel: 'unavailable' }) }) }) - -describe('GPU crash diagnostics production wiring', () => { - it('starts diagnostics without delaying safe-graphics fallback', () => { - const source = readFileSync( - join(__dirname, '..', 'startup', 'main-process-preflight.ts'), - 'utf8' - ) - const listenerSource = readFileSync( - join(__dirname, '..', 'startup', 'main-process-ready-runtime.ts'), - 'utf8' - ) - const listenerStart = listenerSource.indexOf(" app.on('child-process-gone'") - expect(listenerStart).toBeGreaterThan(0) - const listener = listenerSource.slice( - listenerStart, - listenerSource.indexOf('\n })', listenerStart) - ) - expect(source).toMatch( - /recordBreadcrumb: \(data\) =>\s*recordDurableCrashBreadcrumb\('gpu_crash_hardware', data\)/ - ) - expect(listener).toMatch( - /const crashedAt = performance\.now\(\)[\s\S]*?void state\.gpuCrashDiagnostics\?\.record\(\)[\s\S]*?void handleGpuChildCrash\(details\.reason, details\.exitCode \?\? null, crashedAt\)/ - ) - expect(listener).not.toMatch(/state\.gpuCrashDiagnostics\?\.record\(\)[\s\S]*?\.then\(/) - }) -}) diff --git a/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts b/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts index 43441250708..4454bc48457 100644 --- a/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts +++ b/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts @@ -1,55 +1,9 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD, DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, - GpuCrashFallbackTracker, - isGpuFallbackCrashCandidate + GpuCrashFallbackTracker } from './gpu-crash-fallback-decision' -import { shouldRecordProcessGoneCrash } from './process-gone-classification' - -/** - * Replays the win32 GPU-child deaths from the 1.4.190 'crashed' renderer cluster - * through the production decision path, so the reason no host was ever offered - * Safe Graphics Mode is pinned rather than argued about. - * - * Each session below is one crash report's `Recent activity`; a breadcrumb's - * `suppressedSinceLast=N` means N further identical GPU deaths were coalesced - * into it, so the session saw N+1 GPU crashes. - */ - -type FieldSession = { - /** Crash-report id from the field bundle. */ - report: string - /** GPU child crash times, ms since main-process start. */ - gpuCrashesMsSinceLaunch: number[] -} - -// crashed.txt: every win32 report in the cluster. Times are (breadcrumb ts - -// mainProcessStartedAt); coalesced repeats are placed inside the same second, -// which is the only interval the emitted breadcrumb pins them to. -const CRASHED_CLUSTER_SESSIONS: FieldSession[] = [ - // 23:36:54.200 - 23:36:49.931, suppressedSinceLast=1 -> 2 crashes - { report: 'db1f1ee2', gpuCrashesMsSinceLaunch: [4_269, 4_800] }, - // 02:58:25.287 - 02:58:20.749, no suppression -> 1 crash - { report: '66cc54d8', gpuCrashesMsSinceLaunch: [4_538] }, - // 21:47:05.776 - 21:46:59.584, suppressedSinceLast=1 -> 2 crashes - { report: '1f5564de', gpuCrashesMsSinceLaunch: [6_192, 6_240] }, - // 00:17:17.840 - 00:17:15.732, no suppression -> 1 crash - { report: '96d8c63b', gpuCrashesMsSinceLaunch: [2_108] } -] - -/** Ready-phase `child-process-gone` listener body — the wiring these claims rest on. */ -function readChildProcessGoneListener(): string { - const source = readFileSync( - join(__dirname, '..', 'startup', 'main-process-ready-runtime.ts'), - 'utf8' - ) - const start = source.indexOf(" app.on('child-process-gone'") - expect(start).toBeGreaterThan(0) - return source.slice(start, source.indexOf('\n })', start)) -} function newTracker(): GpuCrashFallbackTracker { return new GpuCrashFallbackTracker({ @@ -58,73 +12,7 @@ function newTracker(): GpuCrashFallbackTracker { }) } -const FIELD_GPU_EVENT = { - source: 'child', - processType: 'GPU', - serviceName: 'GPU', - reason: 'crashed', - expectedTeardown: 'none' -} as const - describe('1.4.190 win32 GPU-child crash cluster', () => { - it('does not let process_gone_suppressed gate the fallback candidate check', () => { - // The GPU death is suppressed as recoverable churn (no user-facing report)... - expect( - shouldRecordProcessGoneCrash({ - ...FIELD_GPU_EVENT, - platform: 'win32', - exitCode: -2147483645 - }) - ).toBe(false) - // ...but the fallback path reads the raw child-process-gone event, so the - // suppression cannot hide a broken driver from recovery. - expect( - isGpuFallbackCrashCandidate({ - platform: 'win32', - processType: 'GPU', - reason: 'crashed' - }) - ).toBe(true) - // Both assertions above still pass if the candidate check is moved behind the - // suppressed-report path, so pin that nothing branches ahead of it in index.ts. - const listener = readChildProcessGoneListener() - const guardStart = listener.indexOf('isGpuFallbackCrashCandidate(') - expect(guardStart).toBeGreaterThan(0) - expect(listener.slice(0, guardStart).match(/\bif\s*\(/g) ?? []).toHaveLength(1) - expect(listener).toMatch( - /isGpuFallbackCrashCandidate\([\s\S]*?state\.gpuCrashDiagnostics\?\.record\(\)[\s\S]*?handleGpuChildCrash\(/ - ) - // The `if (` count alone still allows `recorded && isGpuFallbackCrashCandidate(...)`, which - // re-couples recovery to the suppression decision, so pin the guard to that check alone. - const recoveryGuard = listener.slice( - listener.lastIndexOf('if (', guardStart), - listener.indexOf('handleGpuChildCrash(') - ) - expect(recoveryGuard).not.toMatch(/&&|\|\|/) - }) - - it('never reaches the burst threshold in any observed cluster session', () => { - const outcomes = CRASHED_CLUSTER_SESSIONS.map((session) => { - // Each launch constructs a fresh tracker (src/main/index.ts), so evidence - // does not survive the relaunch these users performed after every crash. - const tracker = newTracker() - const engaged = session.gpuCrashesMsSinceLaunch.some( - (at) => tracker.recordGpuCrash(at).shouldEngageFallback - ) - return { - report: session.report, - gpuCrashes: session.gpuCrashesMsSinceLaunch.length, - engagedSafeGraphicsPrompt: engaged - } - }) - expect(outcomes).toEqual([ - { report: 'db1f1ee2', gpuCrashes: 2, engagedSafeGraphicsPrompt: false }, - { report: '66cc54d8', gpuCrashes: 1, engagedSafeGraphicsPrompt: false }, - { report: '1f5564de', gpuCrashes: 2, engagedSafeGraphicsPrompt: false }, - { report: '96d8c63b', gpuCrashes: 1, engagedSafeGraphicsPrompt: false } - ]) - }) - it('engages on the session that did reach three crashes (field launch 51b9e93c)', () => { // oom.txt, win32: GPU crashed/exitCode=34 with suppressedSinceLast=2, then // `gpu_fallback_engaged (crashesInWindow=3)` and `gpu_fallback_restart_deferred`. diff --git a/src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts b/src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts index 23707754ff8..dd918ba9397 100644 --- a/src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts +++ b/src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts @@ -1,5 +1,3 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' const { showMessageBoxMock } = vi.hoisted(() => ({ @@ -105,22 +103,3 @@ describe('handleGpuFallbackRecoveredLaunch', () => { expect(handlers.restartWithHardware).not.toHaveBeenCalled() }) }) - -describe('recovered safe-graphics production wiring', () => { - it('prompts only after the recovered window is shown and persists both consent states', () => { - const windowSource = readFileSync( - join(__dirname, '..', 'startup', 'main-window-controller.ts'), - 'utf8' - ) - const lifecycleSource = readFileSync( - join(__dirname, '..', 'startup', 'gpu-lifecycle.ts'), - 'utf8' - ) - expect(windowSource).toMatch( - /window\.once\('show',[\s\S]*?presentGpuFallbackRecoveredLaunchPrompt\(window\)/ - ) - expect(lifecycleSource).toMatch( - /persistMarker:[\s\S]*?userConfirmed: false[\s\S]*?confirmMarker:[\s\S]*?userConfirmed: true/ - ) - }) -}) diff --git a/src/main/cursor-accounts/status.ts b/src/main/cursor-accounts/status.ts new file mode 100644 index 00000000000..e2104c36c38 --- /dev/null +++ b/src/main/cursor-accounts/status.ts @@ -0,0 +1,32 @@ +import type { CursorAccountStatus } from '../../shared/rate-limit-types' +import { readCursorAuthSession } from '../rate-limits/cursor-auth' +import { isCursorSessionTokenExpired } from '../rate-limits/cursor-session-token' + +function signedOut(error: string | null): CursorAccountStatus { + return { + signedIn: false, + email: null, + displayName: null, + credentialSource: null, + planType: null, + tokenFresh: false, + error + } +} + +export async function getCursorAccountStatus(): Promise { + const readResult = await readCursorAuthSession() + if (readResult.status !== 'ok') { + return signedOut(readResult.status === 'error' ? readResult.error : null) + } + const session = readResult.session + return { + signedIn: true, + email: session.email, + displayName: session.displayName, + credentialSource: session.source, + planType: session.membershipType, + tokenFresh: !isCursorSessionTokenExpired(session.token), + error: null + } +} diff --git a/src/main/daemon/__fixtures__/freebuff-serialize-baseline.json b/src/main/daemon/__fixtures__/freebuff-serialize-baseline.json new file mode 100644 index 00000000000..1f35b2abaa0 --- /dev/null +++ b/src/main/daemon/__fixtures__/freebuff-serialize-baseline.json @@ -0,0 +1,30 @@ +{ + "freebuff-lifecycle": [ + "none/false/2/31:22f0e3d90d75ceaf6fd95853c0b772074e441596caadcea8d133b64bad5db0f6", + "none/true/2/31:22f0e3d90d75ceaf6fd95853c0b772074e441596caadcea8d133b64bad5db0f6", + "shrink/false/1/29:b5d123b659e7cdd5a3d8c6031529a032d60311b1073e46717d2cbed8d46b6ba4", + "shrink/false/2/32:c3408d293abaa4e25303a4d1acf1ea2f096a35ff685e548f98aec777355d10a5", + "shrink/false/2/34:6c1dd1d115eb1c2af110cfa30ce84649b206ff48c76bcbcdfa31877d1b918140", + "shrink/true/1/29:b5d123b659e7cdd5a3d8c6031529a032d60311b1073e46717d2cbed8d46b6ba4", + "shrink/true/2/32:c3408d293abaa4e25303a4d1acf1ea2f096a35ff685e548f98aec777355d10a5", + "shrink/true/2/34:6c1dd1d115eb1c2af110cfa30ce84649b206ff48c76bcbcdfa31877d1b918140", + "shrink-grow/false/2/33:407f38549bb5be0a2545ba448d21940a201f1cca92e8117e42f3b30907d7d486", + "shrink-grow/true/2/33:407f38549bb5be0a2545ba448d21940a201f1cca92e8117e42f3b30907d7d486", + "jitter/false/2/34:adbbc27204f00658207075d10787b1ae8d929226d70a5321e029635193dc432b", + "jitter/true/2/34:adbbc27204f00658207075d10787b1ae8d929226d70a5321e029635193dc432b" + ], + "freebuff-login": [ + "shrink-grow/false/1/21:92981723de5526414b99435b93b94ec87341b90acc7fea71b492e3ea6ae0d445", + "shrink-grow/false/1/23:92981723de5526414b99435b93b94ec87341b90acc7fea71b492e3ea6ae0d445", + "shrink-grow/false/2/33:f8c02c8afab034cc4fdcaa06b97764a32f6432d0c6d5f47b5f675fe6fd4cf67c", + "shrink-grow/true/1/21:44734d78cecaf5ae6203125b92dde9b61816b626efd6b643f78ce3f41c8b1b2d", + "shrink-grow/true/1/23:44734d78cecaf5ae6203125b92dde9b61816b626efd6b643f78ce3f41c8b1b2d", + "shrink-grow/true/2/33:f8c02c8afab034cc4fdcaa06b97764a32f6432d0c6d5f47b5f675fe6fd4cf67c", + "jitter/false/1/11:073b9a35e7a854d650d63f00dcc126f89656fab0f39951de83ff50fd564faab9", + "jitter/false/1/21:a046fe007dfe1161435083c3ed3448b4e74fa2203d692db99353b8df3c45f9a1", + "jitter/true/1/11:073b9a35e7a854d650d63f00dcc126f89656fab0f39951de83ff50fd564faab9", + "jitter/true/1/21:719241163ae1c3629c6a6e0739bb66324f620b8e285d176853d06d0ae26a6c00" + ], + "freebuff-ready": [], + "freebuff-trust": [] +} diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index 9f8d878eddb..8d409a9dc87 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -28,6 +28,7 @@ import { readCurrentDaemonReadyIdentity } from './daemon-ready-identity' import { publishDaemonPidFile } from './daemon-spawner' import { isNativePtyException } from './daemon-native-pty-exception' import { startDaemonScopeDeathWatch } from './daemon-scope-death-watch' +import { isWindowsProcessTableAvailable } from '../windows/windows-process-table' export type ParsedDaemonArgs = { socketPath: string @@ -334,6 +335,13 @@ async function main(): Promise { daemonLog.log('ready') warmWindowsConptyOnce() + // Whether the addon loads is fixed for this process, and a detached daemon has + // no stderr, so the module's own warn cannot report it here. Both answers, so a + // bundle can tell "native" from "never asked" (#16905). Loading it now also pays + // the dlopen off the first teardown. + if (process.platform === 'win32') { + daemonLog.log('windows-process-table', { native: isWindowsProcessTableAvailable() }) + } } // Only auto-run when executed directly (not imported for testing, or for the build guard's diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index 142d1706f9c..e0c4d5230ea 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' describe('foreground-confirmation daemon protocol', () => { it('rejects daemons from before the fresh-confirmation RPC', () => { - expect(PROTOCOL_VERSION).toBe(36) + expect(PROTOCOL_VERSION).toBe(37) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23) @@ -19,5 +19,6 @@ describe('foreground-confirmation daemon protocol', () => { expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(33) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(34) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(35) + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(36) }) }) diff --git a/src/main/daemon/daemon-host-manifest.ts b/src/main/daemon/daemon-host-manifest.ts new file mode 100644 index 00000000000..83d6d4984da --- /dev/null +++ b/src/main/daemon/daemon-host-manifest.ts @@ -0,0 +1,162 @@ +import { cpSync, existsSync, mkdirSync } from 'node:fs' +import { dirname, join, win32 as winPath } from 'node:path' + +// What the relocated host is made of: which files are mirrored, where each lands, +// and which of a package's files are runtime rather than bulk. The lifecycle +// around it -- when to materialize, what keeps a host valid, pruning -- is in +// daemon-host-relocation.ts. + +/** + * The host exe keeps the app exe's own file name, so the relocated image is a byte-for-byte, + * name-included copy of a signed binary — nothing for EDR to read as a renamed image (MITRE T1036). + * Survival comes from the path (see daemon-host-relocation.ts). The one name-sensitive updater path is the + * no-PowerShell `taskkill /IM` fallback, where the daemon is killed and terminals cold-restore — + * the documented pre-relocation outcome, not a failure. + */ +export const daemonHostExeName = (execPath: string): string => winPath.basename(execPath) + +// V8 snapshots + ICU data the Electron bootstrap reads even under ELECTRON_RUN_AS_NODE; siblings of Orca.exe. +const RUNTIME_DATA_FILES = ['icudtl.dat', 'snapshot_blob.bin', 'v8_context_snapshot.bin'] + +/** + * Everything `require('@vscode/windows-process-tree')` walks, package-relative. + * + * One list, read three times: the copy plan mirrors the package containing them, + * materialization refuses to start without them, and a materialized host is only + * valid while it still has them. Keeping those three in one place is what stops + * "what we copy" and "what we accept" from drifting apart -- drift there means a + * host that is copied, published, refused, and copied again on every launch. + */ +export const WINDOWS_PROCESS_TREE_REQUIRED = [ + 'package.json', + 'lib/index.js', + 'build/Release/windows_process_tree.node' +] as const + +export type CopyOp = { + sourcePath: string + /** Destination path relative to the host root, posix-separated. */ + destRel: string + kind: 'file' | 'dir' + /** When true, a missing source is skipped rather than failing the copy. */ + optional?: boolean + /** Per-source-path predicate for dir copies: return false to skip a path. */ + filter?: (sourcePath: string) => boolean +} + +export type DaemonHostSources = { + appDir: string + execPath: string + resourcesPath: string + entrySourcePath: string + entryRelPath: string + /** The addon's package; without it the daemon forks a shell per poll (#16905). */ + windowsProcessTreeDir: string +} + +/** A required file, a directory on the way to one, or anything in lib/ (index.js requires its siblings). */ +function isRuntimeProcessTreePath(packageRel: string): boolean { + return ( + packageRel === '' || + packageRel.startsWith('lib/') || + WINDOWS_PROCESS_TREE_REQUIRED.some( + (required) => required === packageRel || required.startsWith(`${packageRel}/`) + ) + ) +} + +// win32 path semantics so Windows paths decompose correctly off-win32 in cross-platform unit tests; production runs on win32 only. +export function toPosixRelative(fromDir: string, absPath: string): string { + return winPath.relative(fromDir, absPath).split(winPath.sep).join('/') +} + +export function destPath(root: string, destRel: string): string { + return join(root, ...destRel.split('/')) +} + +// Drop node-pty's .pdb symbols and non-host-arch prebuilds (its bulk); keyed on host arch so a future win32-arm64 build keeps the prebuild it needs. +const HOST_WIN_PREBUILD_DIR = `win32-${process.arch}`.toLowerCase() + +function isRuntimeNodePtyPath(sourcePath: string): boolean { + const p = sourcePath.toLowerCase() + if (p.endsWith('.pdb')) { + return false + } + // Keep only the host arch's win32 prebuild; drop any other win32- dir. + const prebuild = p.match(/prebuilds[\\/](win32-[^\\/]+)/) + return !prebuild || prebuild[1] === HOST_WIN_PREBUILD_DIR +} + +/** + * The ordered copy plan. Every destRel mirrors the source's win-unpacked relative path so require() + * and node-pty's loader resolve the mirror identically to the packaged app. Pure so tests can assert layout. + */ +export function buildDaemonHostManifest(sources: DaemonHostSources): CopyOp[] { + const { appDir, execPath, resourcesPath, entrySourcePath, entryRelPath } = sources + const ops: CopyOp[] = [] + + // Host exe (verbatim name) + V8/ICU blobs at dest root. Top-level DLLs omitted: GPU/media libs a windowless run-as-node host never loads (~48MB saved). + ops.push({ sourcePath: execPath, destRel: daemonHostExeName(execPath), kind: 'file' }) + for (const name of RUNTIME_DATA_FILES) { + ops.push({ sourcePath: join(appDir, name), destRel: name, kind: 'file', optional: true }) + } + + // Daemon bundle: entry + sibling chunks/ + out/package.json (CJS/ESM loader resolution), mirrored verbatim. + ops.push({ sourcePath: entrySourcePath, destRel: entryRelPath, kind: 'file' }) + const chunksDir = join(winPath.dirname(entrySourcePath), 'chunks') + ops.push({ + sourcePath: chunksDir, + destRel: toPosixRelative(appDir, chunksDir), + kind: 'dir', + optional: true + }) + const pkgJson = join(resourcesPath, 'app.asar.unpacked', 'out', 'package.json') + ops.push({ + sourcePath: pkgJson, + destRel: toPosixRelative(appDir, pkgJson), + kind: 'file', + optional: true + }) + + // @vscode/windows-process-tree, mirrored so the daemon's require() resolves it; without it every snapshot forks a powershell.exe (#16905). + // Filtered to the runtime files: the installed package is ~25MB of gyp intermediates (.obj/.pdb) around a ~150KB addon. + const { windowsProcessTreeDir } = sources + ops.push({ + sourcePath: windowsProcessTreeDir, + destRel: toPosixRelative(appDir, windowsProcessTreeDir), + kind: 'dir', + filter: (sourcePath) => + isRuntimeProcessTreePath(toPosixRelative(windowsProcessTreeDir, sourcePath)) + }) + // node-pty tree, mirrored so require('node-pty') resolves it; filtered to drop unused .pdb/other-arch prebuilds. + const nodePtyDir = join(resourcesPath, 'node_modules', 'node-pty') + ops.push({ + sourcePath: nodePtyDir, + destRel: toPosixRelative(appDir, nodePtyDir), + kind: 'dir', + filter: isRuntimeNodePtyPath + }) + + return ops +} + +export function executeManifest(ops: CopyOp[], stagingRoot: string): void { + for (const op of ops) { + if (!existsSync(op.sourcePath)) { + if (op.optional) { + continue + } + throw new Error(`daemon-host relocation: missing required input ${op.sourcePath}`) + } + const dest = destPath(stagingRoot, op.destRel) + mkdirSync(dirname(dest), { recursive: true }) + const { filter } = op + // Dereference symlinks so the copy holds no link back into the install dir. + cpSync(op.sourcePath, dest, { + recursive: op.kind === 'dir', + dereference: true, + force: true, + ...(filter ? { filter: (src: string) => filter(src) } : {}) + }) + } +} diff --git a/src/main/daemon/daemon-host-relocation.test.ts b/src/main/daemon/daemon-host-relocation.test.ts index e899a67ed43..7b81bdcfa1e 100644 --- a/src/main/daemon/daemon-host-relocation.test.ts +++ b/src/main/daemon/daemon-host-relocation.test.ts @@ -5,12 +5,14 @@ import { mkdtempSync, readFileSync, readdirSync, + realpathSync, renameSync, rmSync, utimesSync, writeFileSync } from 'node:fs' import os from 'node:os' +import { createRequire } from 'node:module' import { basename, dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -37,8 +39,8 @@ function installHostApp(): void { } as AppEnvironment) } +import { buildDaemonHostManifest } from './daemon-host-manifest' import { - buildDaemonHostManifest, collectPinnedDaemonVersions, getRelocatedDaemonHost, materializeRelocatedDaemonHost, @@ -88,8 +90,35 @@ function buildInstallFixture(root: string): void { mkdirSync(join(prebuildsRoot, arch), { recursive: true }) writeFileSync(join(prebuildsRoot, arch, 'pty.node'), `${arch}-prebuild`) } + const processTreeDir = join(root, 'resources', 'node_modules', '@vscode', 'windows-process-tree') + mkdirSync(join(processTreeDir, 'build', 'Release'), { recursive: true }) + mkdirSync(join(processTreeDir, 'lib'), { recursive: true }) + mkdirSync(join(processTreeDir, 'src'), { recursive: true }) + writeFileSync(join(processTreeDir, 'package.json'), '{"main":"lib/index.js"}') + writeFileSync(join(processTreeDir, 'lib', 'index.js'), 'module.exports = {}') + writeFileSync( + join(processTreeDir, 'build', 'Release', 'windows_process_tree.node'), + 'process-tree-native' + ) + writeFileSync(join(processTreeDir, 'build', 'Release', 'windows_process_tree.pdb'), 'symbols') + writeFileSync(join(processTreeDir, 'src', 'process.cc'), 'source') + writeFileSync(join(processTreeDir, 'lib', 'promises.js'), 'require("./index")') + mkdirSync(join(processTreeDir, 'build', 'Release', 'obj'), { recursive: true }) + writeFileSync(join(processTreeDir, 'build', 'Release', 'obj', 'addon.obj'), 'intermediate') } +const PROCESS_TREE_DIR_REL = join('resources', 'node_modules', '@vscode', 'windows-process-tree') + +const PROCESS_TREE_ADDON_REL = join( + 'resources', + 'node_modules', + '@vscode', + 'windows-process-tree', + 'build', + 'Release', + 'windows_process_tree.node' +) + // The win32 prebuild dir the running host arch loads vs. the one that is pruned. const HOST_PREBUILD = `win32-${process.arch}` const OTHER_PREBUILD = HOST_PREBUILD === 'win32-arm64' ? 'win32-x64' : 'win32-arm64' @@ -139,7 +168,8 @@ describe('buildDaemonHostManifest', () => { execPath: 'C:\\app\\Orca.exe', resourcesPath: 'C:\\app\\resources', entrySourcePath: 'C:\\app\\resources\\app.asar.unpacked\\out\\main\\daemon-entry.js', - entryRelPath: 'resources/app.asar.unpacked/out/main/daemon-entry.js' + entryRelPath: 'resources/app.asar.unpacked/out/main/daemon-entry.js', + windowsProcessTreeDir: 'C:\\app\\resources\\node_modules\\@vscode\\windows-process-tree' }) const byDest = new Map(ops.map((op) => [op.destRel, op])) // The host exe keeps the source basename: a verbatim, signature-preserving copy with no @@ -147,6 +177,9 @@ describe('buildDaemonHostManifest', () => { expect(byDest.get('Orca.exe')?.kind).toBe('file') const exeOp = ops.find((op) => op.sourcePath === 'C:\\app\\Orca.exe') expect(exeOp?.destRel).toBe('Orca.exe') + // Without this op the relocated daemon cannot resolve the native process + // table and falls back to a powershell.exe scan per snapshot (#16905). + expect(byDest.get('resources/node_modules/@vscode/windows-process-tree')?.kind).toBe('dir') // V8/ICU data blobs are read by the Electron bootstrap and kept. expect(byDest.has('icudtl.dat')).toBe(true) // GPU/graphics DLLs are never loaded by the windowless host, so not copied. @@ -201,6 +234,34 @@ describe('materializeRelocatedDaemonHost', () => { const marker = JSON.parse(readFileSync(join(dest, '.materialized.json'), 'utf8')) expect(marker.version).toBe('9.9.9') expect(marker.entryRelPath).toBe('resources/app.asar.unpacked/out/main/daemon-entry.js') + // The whole package is mirrored, so require() finds every hop it walks. + const processTreeDest = join( + dest, + 'resources', + 'node_modules', + '@vscode', + 'windows-process-tree' + ) + expect(existsSync(join(processTreeDest, 'build', 'Release', 'windows_process_tree.node'))).toBe( + true + ) + expect(existsSync(join(processTreeDest, 'lib', 'index.js'))).toBe(true) + expect(existsSync(join(processTreeDest, 'lib', 'promises.js'))).toBe(true) + // Build intermediates are ~25MB of the installed package; the loader never reads them. + expect(existsSync(join(processTreeDest, 'build', 'Release', 'windows_process_tree.pdb'))).toBe( + false + ) + expect(existsSync(join(processTreeDest, 'build', 'Release', 'obj'))).toBe(false) + expect(existsSync(join(processTreeDest, 'src'))).toBe(false) + // The loader requires the BARE package, so resolution runs through the copied + // package.json's `main`. Asserting the .node subpath instead would still pass + // with package.json dropped from the filter, while the daemon's own require + // failed and it silently went back to the CIM scan. + expect( + createRequire( + join(dest, 'resources', 'app.asar.unpacked', 'out', 'main', 'chunks', 'a.js') + ).resolve('@vscode/windows-process-tree') + ).toBe(realpathSync(join(processTreeDest, 'lib', 'index.js'))) }) it('copies the exe verbatim: same file name and same bytes as the install-dir exe', () => { @@ -225,6 +286,71 @@ describe('materializeRelocatedDaemonHost', () => { expect(getRelocatedDaemonHost()?.execPath).toBe(join(dest, 'Orca Nightly.exe')) }) + it.each([ + ['the binary', PROCESS_TREE_ADDON_REL], + ['package.json', join(PROCESS_TREE_DIR_REL, 'package.json')], + ['lib/index.js', join(PROCESS_TREE_DIR_REL, 'lib', 'index.js')] + ])('refuses a mirror that lost %s', (_label, relativePath) => { + // Any one of them missing means require() cannot reach the addon, and a host + // that cannot load it runs anyway -- forking a shell per snapshot (#16905). + materializeRelocatedDaemonHost() + const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9') + expect(getRelocatedDaemonHost()).not.toBeNull() + + rmSync(join(dest, relativePath)) + + expect(getRelocatedDaemonHost()).toBeNull() + }) + + it('rematerializes a host whose copied addon went missing', () => { + materializeRelocatedDaemonHost() + const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9') + const relocatedAddon = join(dest, PROCESS_TREE_ADDON_REL) + + rmSync(relocatedAddon) + // A sentinel proves the host was rebuilt rather than reused. + const sentinel = join(dest, 'sentinel.txt') + writeFileSync(sentinel, 'remove') + + expect(materializeRelocatedDaemonHost()).not.toBeNull() + expect(readFileSync(relocatedAddon, 'utf8')).toBe('process-tree-native') + expect(existsSync(sentinel)).toBe(false) + }) + + it('does not read the install dir to decide an existing host is still good', () => { + // Relocation exists to outlive the install dir, so validity cannot depend on + // it: an updater mid-copy would otherwise condemn an intact host. A real + // upgrade changes the version keying this directory, which already forces a + // rebuild, and nothing else in the mirror is source-verified either. + materializeRelocatedDaemonHost() + const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9') + const sentinel = join(dest, 'sentinel.txt') + writeFileSync(sentinel, 'keep') + + rmSync(join(installDir, 'resources', 'node_modules', '@vscode'), { + recursive: true, + force: true + }) + + expect(getRelocatedDaemonHost()).not.toBeNull() + expect(existsSync(sentinel)).toBe(true) + }) + + it.each([ + ['the binary', PROCESS_TREE_ADDON_REL], + ['package.json', join(PROCESS_TREE_DIR_REL, 'package.json')], + ['lib/index.js', join(PROCESS_TREE_DIR_REL, 'lib', 'index.js')] + ])('refuses to copy anything when the install lost %s', (_label, relativePath) => { + // Each of these would be accepted by the copy plan and then refused by the + // mirror check, which is a ~260MB copy per launch to reach a verdict the + // source could have given for free. Sharing one list is what prevents that. + rmSync(join(installDir, relativePath)) + + expect(materializeRelocatedDaemonHost()).toBeNull() + // Not even the host root: the source is checked before any directory is made. + expect(existsSync(join(localAppDataDir, 'Orca', 'daemon-host'))).toBe(false) + }) + it('is idempotent: a valid marker short-circuits without recopying', () => { materializeRelocatedDaemonHost() const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9') @@ -278,6 +404,13 @@ describe('getRelocatedDaemonHost', () => { join(dest, 'resources', 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js'), 'e' ) + // The process-table files too, or this passes because the mirror has no addon + // and never exercises the version comparison it is named for. + mkdirSync(join(dest, PROCESS_TREE_DIR_REL, 'lib'), { recursive: true }) + mkdirSync(dirname(join(dest, PROCESS_TREE_ADDON_REL)), { recursive: true }) + writeFileSync(join(dest, PROCESS_TREE_DIR_REL, 'package.json'), '{}') + writeFileSync(join(dest, PROCESS_TREE_DIR_REL, 'lib', 'index.js'), 'm') + writeFileSync(join(dest, PROCESS_TREE_ADDON_REL), 'n') writeFileSync( join(dest, '.materialized.json'), JSON.stringify({ diff --git a/src/main/daemon/daemon-host-relocation.ts b/src/main/daemon/daemon-host-relocation.ts index 13aab9fd8f9..51fed83530a 100644 --- a/src/main/daemon/daemon-host-relocation.ts +++ b/src/main/daemon/daemon-host-relocation.ts @@ -1,6 +1,5 @@ import { randomBytes } from 'node:crypto' import { - cpSync, existsSync, mkdirSync, readFileSync, @@ -9,8 +8,17 @@ import { rmSync, writeFileSync } from 'node:fs' -import { dirname, join, win32 as winPath } from 'node:path' +import { join, win32 as winPath } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' +import { + buildDaemonHostManifest, + daemonHostExeName, + destPath, + executeManifest, + toPosixRelative, + WINDOWS_PROCESS_TREE_REQUIRED, + type DaemonHostSources +} from './daemon-host-manifest' import type { ProcessLivenessVerdict } from './daemon-incarnation-evidence-types' import { parseDaemonPidFile } from './daemon-pid-file-parse' import { quarantineCorruptDaemonPidRecord } from './daemon-pid-record-quarantine' @@ -41,52 +49,12 @@ const MARKER_NAME = '.materialized.json' // LOCAL appData (not roaming) so OneDrive/roaming never syncs this ~260MB runtime. Shared with NSIS uninstall (config/nsis/orca-installer-hooks.nsh) — keep in sync. const LOCAL_HOST_ROOT_NAME = 'Orca' -/** - * The host exe keeps the app exe's own file name, so the relocated image is a byte-for-byte, - * name-included copy of a signed binary — nothing for EDR to read as a renamed image (MITRE T1036). - * Survival comes from the path (see the module header). The one name-sensitive updater path is the - * no-PowerShell `taskkill /IM` fallback, where the daemon is killed and terminals cold-restore — - * the documented pre-relocation outcome, not a failure. - */ -const daemonHostExeName = (execPath: string): string => winPath.basename(execPath) - -// V8 snapshots + ICU data the Electron bootstrap reads even under ELECTRON_RUN_AS_NODE; siblings of Orca.exe. -const RUNTIME_DATA_FILES = ['icudtl.dat', 'snapshot_blob.bin', 'v8_context_snapshot.bin'] - -type CopyOp = { - sourcePath: string - /** Destination path relative to the host root, posix-separated. */ - destRel: string - kind: 'file' | 'dir' - /** When true, a missing source is skipped rather than failing the copy. */ - optional?: boolean - /** Per-source-path predicate for dir copies: return false to skip a path. */ - filter?: (sourcePath: string) => boolean -} - -type DaemonHostSources = { - appDir: string - execPath: string - resourcesPath: string - entrySourcePath: string - entryRelPath: string -} - type MaterializeMarker = { version: string completedAt: string entryRelPath: string } -// win32 path semantics so Windows paths decompose correctly off-win32 in cross-platform unit tests; production runs on win32 only. -function toPosixRelative(fromDir: string, absPath: string): string { - return winPath.relative(fromDir, absPath).split(winPath.sep).join('/') -} - -function destPath(root: string, destRel: string): string { - return join(root, ...destRel.split('/')) -} - // Mirror getDaemonEntryPath()'s resolution order so the copied entry is the exact file the in-dir fork would run. function resolveEntrySourcePath(resourcesPath: string): string { const unpackedRoot = join(resourcesPath, 'app.asar.unpacked') @@ -132,83 +100,8 @@ function collectDaemonHostSources(): DaemonHostSources | null { execPath, resourcesPath, entrySourcePath, - entryRelPath: toPosixRelative(appDir, entrySourcePath) - } -} - -// Drop node-pty's .pdb symbols and non-host-arch prebuilds (its bulk); keyed on host arch so a future win32-arm64 build keeps the prebuild it needs. -const HOST_WIN_PREBUILD_DIR = `win32-${process.arch}`.toLowerCase() -function isRuntimeNodePtyPath(sourcePath: string): boolean { - const p = sourcePath.toLowerCase() - if (p.endsWith('.pdb')) { - return false - } - // Keep only the host arch's win32 prebuild; drop any other win32- dir. - const prebuild = p.match(/prebuilds[\\/](win32-[^\\/]+)/) - return !prebuild || prebuild[1] === HOST_WIN_PREBUILD_DIR -} - -/** - * The ordered copy plan. Every destRel mirrors the source's win-unpacked relative path so require() - * and node-pty's loader resolve the mirror identically to the packaged app. Pure so tests can assert layout. - */ -export function buildDaemonHostManifest(sources: DaemonHostSources): CopyOp[] { - const { appDir, execPath, resourcesPath, entrySourcePath, entryRelPath } = sources - const ops: CopyOp[] = [] - - // Host exe (verbatim name) + V8/ICU blobs at dest root. Top-level DLLs omitted: GPU/media libs a windowless run-as-node host never loads (~48MB saved). - ops.push({ sourcePath: execPath, destRel: daemonHostExeName(execPath), kind: 'file' }) - for (const name of RUNTIME_DATA_FILES) { - ops.push({ sourcePath: join(appDir, name), destRel: name, kind: 'file', optional: true }) - } - - // Daemon bundle: entry + sibling chunks/ + out/package.json (CJS/ESM loader resolution), mirrored verbatim. - ops.push({ sourcePath: entrySourcePath, destRel: entryRelPath, kind: 'file' }) - const chunksDir = join(winPath.dirname(entrySourcePath), 'chunks') - ops.push({ - sourcePath: chunksDir, - destRel: toPosixRelative(appDir, chunksDir), - kind: 'dir', - optional: true - }) - const pkgJson = join(resourcesPath, 'app.asar.unpacked', 'out', 'package.json') - ops.push({ - sourcePath: pkgJson, - destRel: toPosixRelative(appDir, pkgJson), - kind: 'file', - optional: true - }) - - // node-pty tree, mirrored so require('node-pty') resolves it; filtered to drop unused .pdb/other-arch prebuilds. - const nodePtyDir = join(resourcesPath, 'node_modules', 'node-pty') - ops.push({ - sourcePath: nodePtyDir, - destRel: toPosixRelative(appDir, nodePtyDir), - kind: 'dir', - filter: isRuntimeNodePtyPath - }) - - return ops -} - -function executeManifest(ops: CopyOp[], stagingRoot: string): void { - for (const op of ops) { - if (!existsSync(op.sourcePath)) { - if (op.optional) { - continue - } - throw new Error(`daemon-host relocation: missing required input ${op.sourcePath}`) - } - const dest = destPath(stagingRoot, op.destRel) - mkdirSync(dirname(dest), { recursive: true }) - const { filter } = op - // Dereference symlinks so the copy holds no link back into the install dir. - cpSync(op.sourcePath, dest, { - recursive: op.kind === 'dir', - dereference: true, - force: true, - ...(filter ? { filter: (src: string) => filter(src) } : {}) - }) + entryRelPath: toPosixRelative(appDir, entrySourcePath), + windowsProcessTreeDir: join(resourcesPath, 'node_modules', '@vscode', 'windows-process-tree') } } @@ -230,6 +123,17 @@ function readMarker(dir: string): MaterializeMarker | null { return null } +function processTreeRelDir(sources: DaemonHostSources): string { + return toPosixRelative(sources.appDir, sources.windowsProcessTreeDir) +} + +/** True when any file require() needs is absent from a copy of the package. */ +function missingProcessTreeFiles(packageDir: string): boolean { + return WINDOWS_PROCESS_TREE_REQUIRED.some( + (relative) => !existsSync(join(packageDir, ...relative.split('/'))) + ) +} + function hostRootDir(): string { // Prefer LOCAL appData (see LOCAL_HOST_ROOT_NAME); fall back to userData only if LOCALAPPDATA is unset. const localAppData = process.env.LOCALAPPDATA @@ -260,6 +164,13 @@ export function getRelocatedDaemonHost(): RelocatedDaemonHost | null { if (!existsSync(execPath) || !existsSync(entryPath)) { return null } + // A mirror the daemon cannot load the addon from still runs -- it just forks a + // shell per snapshot (#16905) -- so treat it as unmaterialized and rebuild. Hosts + // from before this shipped have none of these files. Checked in the mirror, never + // in the install dir, which is the thing relocation exists to outlive. + if (missingProcessTreeFiles(destPath(dest, processTreeRelDir(sources)))) { + return null + } return { execPath, entryPath } } @@ -276,6 +187,12 @@ export function materializeRelocatedDaemonHost(): RelocatedDaemonHost | null { if (!sources) { return null } + // Checked against the source before copying: the mirror check below would refuse + // the result anyway, and re-copying ~260MB on every launch to reach that verdict + // is the loop this shares its list with the copy plan to prevent. + if (missingProcessTreeFiles(sources.windowsProcessTreeDir)) { + return null + } const version = getAppEnvironment().getVersion() const root = hostRootDir() const dest = join(root, version) diff --git a/src/main/daemon/daemon-launch-paths.ts b/src/main/daemon/daemon-launch-paths.ts index 049800daff4..532bc75c574 100644 --- a/src/main/daemon/daemon-launch-paths.ts +++ b/src/main/daemon/daemon-launch-paths.ts @@ -60,7 +60,10 @@ export function probeDaemonSocket( socketPath: string, timeoutMs = DAEMON_SOCKET_PROBE_TIMEOUT_MS ): Promise { - const { promise, resolve } = Promise.withResolvers() + let resolve!: (alive: boolean) => void + const promise = new Promise((settle) => { + resolve = settle + }) if (process.platform !== 'win32' && !existsSync(socketPath)) { resolve(false) return promise diff --git a/src/main/daemon/daemon-process-identity-query.ts b/src/main/daemon/daemon-process-identity-query.ts index 479aa821ecf..d4eee6b1b84 100644 --- a/src/main/daemon/daemon-process-identity-query.ts +++ b/src/main/daemon/daemon-process-identity-query.ts @@ -16,22 +16,26 @@ export type PsProcessIdentity = { startedAtMs: number | null } -function parsePsProcessIdentity(output: string): PsProcessIdentity { +function parsePsProcessIdentity(output: string, utc = false): PsProcessIdentity { // BSD ps formats lstart as a fixed-width 24-character timestamp. - const startedAtMs = Date.parse(output.slice(0, 24)) + const startedAtMs = Date.parse(output.slice(0, 24) + (utc ? ' UTC' : '')) return { commandLine: output.slice(24).trim(), startedAtMs: Number.isFinite(startedAtMs) ? startedAtMs : null } } -export function getPsProcessIdentity(pid: number): PsProcessIdentity | null { +export function getPsProcessIdentity( + pid: number, + options?: { utc?: boolean } +): PsProcessIdentity | null { try { const output = execFileSync('ps', ['-p', String(pid), '-o', 'lstart=', '-o', 'command='], { encoding: 'utf8', - timeout: 2_000 + timeout: 2_000, + ...(options?.utc ? { env: { ...process.env, TZ: 'UTC', LC_ALL: 'C' } } : {}) }) - return parsePsProcessIdentity(output) + return parsePsProcessIdentity(output, options?.utc) } catch { return null } diff --git a/src/main/daemon/daemon-process-identity-time-zone.test.ts b/src/main/daemon/daemon-process-identity-time-zone.test.ts new file mode 100644 index 00000000000..aee3e4f4420 --- /dev/null +++ b/src/main/daemon/daemon-process-identity-time-zone.test.ts @@ -0,0 +1,32 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { getPsProcessIdentity } from './daemon-process-identity-query' + +const { execFileSync } = vi.hoisted(() => ({ execFileSync: vi.fn() })) +vi.mock('node:child_process', () => ({ execFileSync, execFile: vi.fn() })) + +afterEach(() => { + vi.unstubAllEnvs() + vi.clearAllMocks() +}) + +it.each(['America/Los_Angeles', 'America/New_York', 'UTC'])( + 'keeps the autumn clock transition unambiguous under %s', + (timezone) => { + vi.stubEnv('TZ', timezone) + execFileSync.mockReturnValue('Sun Nov 1 09:30:00 2026 /path/to/orca\n') + expect(getPsProcessIdentity(42, { utc: true })).toEqual({ + startedAtMs: Date.parse('2026-11-01T09:30:00Z'), + commandLine: '/path/to/orca' + }) + expect(execFileSync).toHaveBeenCalledWith( + 'ps', + ['-p', '42', '-o', 'lstart=', '-o', 'command='], + expect.objectContaining({ env: expect.objectContaining({ TZ: 'UTC', LC_ALL: 'C' }) }) + ) + } +) + +it('treats an unreadable UTC process start as unknown', () => { + execFileSync.mockReturnValue(' /path/to/orca\n') + expect(getPsProcessIdentity(42, { utc: true })?.startedAtMs).toBeNull() +}) diff --git a/src/main/daemon/daemon-protocol-version.test.ts b/src/main/daemon/daemon-protocol-version.test.ts index dfe84947ff5..b909c4c8fc8 100644 --- a/src/main/daemon/daemon-protocol-version.test.ts +++ b/src/main/daemon/daemon-protocol-version.test.ts @@ -3,6 +3,7 @@ import { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, ASYNC_CWD_VALIDATION_DAEMON_PROTOCOL_VERSION, + CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION, CODEX_SHELL_LAUNCH_PREFLIGHT_DAEMON_PROTOCOL_VERSION, COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION, @@ -19,7 +20,8 @@ import { describe('daemon protocol version', () => { it('ships bounded history transfer after the 2031-unsubscribe fact', () => { - expect(PROTOCOL_VERSION).toBe(36) + expect(PROTOCOL_VERSION).toBe(37) + expect(CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION).toBe(37) expect(CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION).toBe(36) expect(ASYNC_CWD_VALIDATION_DAEMON_PROTOCOL_VERSION).toBe(35) expect(CODEX_SHELL_LAUNCH_PREFLIGHT_DAEMON_PROTOCOL_VERSION).toBe(34) @@ -33,7 +35,7 @@ describe('daemon protocol version', () => { expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26) expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual( - Array.from({ length: 35 }, (_, index) => index + 1) + Array.from({ length: 36 }, (_, index) => index + 1) ) }) diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index bae2ae972dc..9c0ed0128fc 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,6 +1,7 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. -// v36 launches shells from content-addressed wrapper trees; older owners stay attachable. -export const PROTOCOL_VERSION = 36 +// v37 runs Codex without the shared background server at the shell launch boundary; older owners stay attachable. +export const PROTOCOL_VERSION = 37 +export const CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION = 37 export const CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION = 36 export const ASYNC_CWD_VALIDATION_DAEMON_PROTOCOL_VERSION = 35 export const CODEX_SHELL_LAUNCH_PREFLIGHT_DAEMON_PROTOCOL_VERSION = 34 @@ -30,7 +31,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, - 28, 29, 30, 31, 32, 33, 34, 35 + 28, 29, 30, 31, 32, 33, 34, 35, 36 ] as const export function supportsPtyStartupIngress(protocolVersion: number): boolean { diff --git a/src/main/daemon/daemon-pty-buffer-snapshots.ts b/src/main/daemon/daemon-pty-buffer-snapshots.ts index f640483a39f..0bfb6cd44c9 100644 --- a/src/main/daemon/daemon-pty-buffer-snapshots.ts +++ b/src/main/daemon/daemon-pty-buffer-snapshots.ts @@ -184,6 +184,11 @@ export abstract class DaemonPtyBufferSnapshots extends DaemonPtySessionControl { this.markSessionDirty(id) } + async resetInputModes(id: string): Promise { + await this.client.request('resetInputModes', { sessionId: id }) + this.markSessionDirty(id) + } + acknowledgeDataEvent(_id: string, _charCount: number): void { // No flow control for daemon-backed terminals } diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 2fbfd17a039..c3330e61826 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -162,6 +162,10 @@ export class DaemonPtyRouter implements IPtyProvider { await this.adapterFor(id).clearBuffer(id) } + async resetInputModes(id: string): Promise { + await this.adapterFor(id).resetInputModes(id) + } + async closeStartupQueryAuthority(id: string): Promise { return (await this.adapterFor(id).closeStartupQueryAuthority?.(id)) ?? 0 } diff --git a/src/main/daemon/daemon-pty-spawn-preparations.ts b/src/main/daemon/daemon-pty-spawn-preparations.ts index 633f0d1c7f5..af52051a483 100644 --- a/src/main/daemon/daemon-pty-spawn-preparations.ts +++ b/src/main/daemon/daemon-pty-spawn-preparations.ts @@ -33,7 +33,14 @@ export class DaemonPtySpawnPreparations { clientId, requestId } - this.cancellationByPreparation.set(preparation, Promise.withResolvers()) + let resolveCancellation!: () => void + const cancellation = new Promise((resolve) => { + resolveCancellation = resolve + }) + this.cancellationByPreparation.set(preparation, { + promise: cancellation, + resolve: resolveCancellation + }) if (Number.isSafeInteger(cancelAfterMs) && Number(cancelAfterMs) > 0) { preparation.cancelTimer = setTimeout( () => this.cancelPreparation(preparation), diff --git a/src/main/daemon/daemon-pty-spawn-result.ts b/src/main/daemon/daemon-pty-spawn-result.ts index 5e799230fa1..afc7315abcd 100644 --- a/src/main/daemon/daemon-pty-spawn-result.ts +++ b/src/main/daemon/daemon-pty-spawn-result.ts @@ -289,7 +289,7 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { const snapshotPrefix = reattachSnapshot.scrollbackAnsi + reattachSnapshot.rehydrateSequences const snapshotFrame = reattachSnapshot.snapshotAnsi const snapshotPayload = snapshotPrefix + snapshotFrame - // Why kitty flags ride beside the payload, not inside it: the snapshot reaches renderer xterms where POST_REPLAY_REATTACH_RESET's kitty reset must win (terminal-query-authority.md §kitty). + // Why kitty flags ride beside the payload, not inside it: renderers re-assert them in the replay epilogue, after the payload's screen switches (terminal-query-authority.md §kitty). // Why known `0` is no longer dropped: the pane tracker must be able to tell // "the app negotiated nothing" from "this reattach proved nothing". const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags( diff --git a/src/main/daemon/daemon-request-router.ts b/src/main/daemon/daemon-request-router.ts index ae007366dd0..9bd0d7963b6 100644 --- a/src/main/daemon/daemon-request-router.ts +++ b/src/main/daemon/daemon-request-router.ts @@ -129,6 +129,9 @@ export class DaemonRequestRouter { case 'clearScrollback': this.options.host.clearScrollback(request.payload.sessionId) return {} + case 'resetInputModes': + this.options.host.resetInputModes(request.payload.sessionId) + return {} case 'listSessions': return { sessions: this.options.host.listSessions() } case 'shutdownIfIdle': diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index ef89b3ef3b0..3479b4eea45 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -437,23 +437,6 @@ describe('DaemonServer', () => { expect(['healthy', 'unhealthy', 'unknown']).toContain(result.health) }) - it('handles write (fire-and-forget)', async () => { - await startServer() - const c = await connectClient() - - await c.request('createOrAttach', { - sessionId: 'test-session', - cols: 80, - rows: 24 - }) - - // Should not throw - c.notify('write', { sessionId: 'test-session', data: 'ls\n' }) - - // Give the server time to process - await new Promise((r) => setTimeout(r, 50)) - }) - it('handles resize', async () => { await startServer() const c = await connectClient() diff --git a/src/main/daemon/daemon-zsh-shell-ready-wrapper-spec.ts b/src/main/daemon/daemon-zsh-shell-ready-wrapper-spec.ts index 70ea670966c..88e8af0b805 100644 --- a/src/main/daemon/daemon-zsh-shell-ready-wrapper-spec.ts +++ b/src/main/daemon/daemon-zsh-shell-ready-wrapper-spec.ts @@ -11,10 +11,10 @@ export function getDaemonZshWrapperSpec(): ZshStartupHookSpec { overlayRestoreComment: "# Why: ~/.zshrc can export the user's default OpenCode config after spawn.", restores: { + managedWslCli: false, agentTeamsPath: true, remoteCliBinDir: false, - codexHome: true, - codexLaunchPreflight: true + codexHome: true } } } diff --git a/src/main/daemon/degraded-daemon-pty-provider.test.ts b/src/main/daemon/degraded-daemon-pty-provider.test.ts index f2e86abab28..363e9125b26 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.test.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.test.ts @@ -50,6 +50,7 @@ function createProvider( getCwd: vi.fn(async () => ''), getInitialCwd: vi.fn(async () => ''), clearBuffer: vi.fn(async () => {}), + resetInputModes: vi.fn(async () => {}), acknowledgeDataEvent: vi.fn(), hasChildProcesses: vi.fn(async () => false), getForegroundProcess: vi.fn(async () => null), diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index 2b50424ae83..6db8c80ac87 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -168,6 +168,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { } clearBuffer = (id: string): Promise => this.providerFor(id).clearBuffer(id) + resetInputModes = (id: string): Promise => this.providerFor(id).resetInputModes(id) async closeStartupQueryAuthority(id: string): Promise { return (await this.providerFor(id).closeStartupQueryAuthority?.(id)) ?? 0 diff --git a/src/main/daemon/node-pty-error-hints.test.ts b/src/main/daemon/node-pty-error-hints.test.ts index f43951f05a6..361d8b73ba1 100644 --- a/src/main/daemon/node-pty-error-hints.test.ts +++ b/src/main/daemon/node-pty-error-hints.test.ts @@ -1,22 +1,12 @@ import { describe, expect, it } from 'vitest' +import { + LEGACY_PTY_ALLOCATION_HINT, + LEGACY_TERMINAL_PROCESS_LIMIT_HINT, + PTY_ALLOCATION_HINT, + TERMINAL_PROCESS_LIMIT_HINT +} from '../../shared/terminal-spawn-error-copy' import { addNodePtyRecoveryHint, parseNodePtyDiagnostic } from './node-pty-error-hints' -const PTY_ALLOCATION_HINT = [ - 'Your system cannot allocate any more pty devices.', - '', - 'Orca requires a pty device to launch a new terminal. This error is usually due to having too many terminal windows or terminal sessions open, either in Orca or another program.', - '', - 'Free up some pty devices and try again.' -].join('\n') - -const TERMINAL_PROCESS_LIMIT_HINT = [ - 'Your system cannot start another terminal process.', - '', - 'This is usually due to having too many terminal sessions or other processes running.', - '', - 'Close unused terminals or quit unused processes and try again.' -].join('\n') - describe('node-pty diagnostic error hints', () => { it('parses the native step and errno without dropping the original message', () => { const message = @@ -32,46 +22,71 @@ describe('node-pty diagnostic error hints', () => { const message = "node-pty: open_slave failed: EMFILE (errno 24, Too many open files) - slave='/dev/ttys003'" - expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT} ${message}`) + expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT}\n${message}`) }) it('hints when the system cannot allocate a pty master', () => { const message = 'node-pty: posix_openpt failed: ENFILE (errno 23, Too many open files in system)' - expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT} ${message}`) + expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT}\n${message}`) }) it('hints when macOS cannot configure a pty master device', () => { const message = 'node-pty: posix_openpt failed: errno (errno 6, Device not configured)' - expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT} ${message}`) + expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT}\n${message}`) }) it('hints local wrapped spawn errors from pty allocation failures', () => { const message = 'Failed to spawn shell "/bin/zsh": node-pty: open_slave failed: EMFILE (errno 24, Too many open files) - slave=\'/dev/ttys003\' (shell: /bin/zsh, cwd: /tmp, arch: arm64, platform: darwin 25.0.0, orca: 1.4.178). If this persists, please file an issue.' - expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT} ${message}`) + expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT}\n${message}`) + }) + + it('keeps the whole recovery action on the first line', () => { + const message = + 'Failed to spawn shell "/bin/zsh": node-pty: posix_openpt failed: errno (errno 6, Device not configured) (shell: /bin/zsh). If this persists, please file an issue.' + + expect(addNodePtyRecoveryHint(message).split('\n')[0]).toBe(PTY_ALLOCATION_HINT) }) it('hints unstructured openpty allocation failures', () => { const message = 'Failed to spawn shell "/bin/bash": openpty(3) failed.' - expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT} ${message}`) + expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT}\n${message}`) }) it('hints when posix_spawn reports the per-user process limit', () => { const message = "node-pty: posix_spawn failed: EAGAIN (errno 35, Resource temporarily unavailable) - helper='/tmp/node-pty/spawn-helper'" - expect(addNodePtyRecoveryHint(message)).toBe(`${TERMINAL_PROCESS_LIMIT_HINT} ${message}`) + expect(addNodePtyRecoveryHint(message)).toBe(`${TERMINAL_PROCESS_LIMIT_HINT}\n${message}`) }) it('does not duplicate an existing recovery hint', () => { const message = "node-pty: open_slave failed: EMFILE (errno 24, Too many open files) - slave='/dev/ttys003'" - const hinted = `${PTY_ALLOCATION_HINT} ${message}` + const hinted = `${PTY_ALLOCATION_HINT}\n${message}` + + expect(addNodePtyRecoveryHint(hinted)).toBe(hinted) + }) + + it('does not duplicate hints from an older remote host', () => { + const ptyError = 'node-pty: open_slave failed: EMFILE (errno 24, Too many open files)' + const processError = 'node-pty: posix_spawn failed: EAGAIN (errno 35, Resource unavailable)' + + expect(addNodePtyRecoveryHint(`${LEGACY_PTY_ALLOCATION_HINT} ${ptyError}`)).toBe( + `${LEGACY_PTY_ALLOCATION_HINT} ${ptyError}` + ) + expect(addNodePtyRecoveryHint(`${LEGACY_TERMINAL_PROCESS_LIMIT_HINT} ${processError}`)).toBe( + `${LEGACY_TERMINAL_PROCESS_LIMIT_HINT} ${processError}` + ) + }) + + it('does not duplicate a legacy hint on unstructured openpty failures', () => { + const hinted = `${LEGACY_PTY_ALLOCATION_HINT} Failed to spawn shell "/bin/bash": openpty(3) failed.` expect(addNodePtyRecoveryHint(hinted)).toBe(hinted) }) diff --git a/src/main/daemon/node-pty-error-hints.ts b/src/main/daemon/node-pty-error-hints.ts index 022fb98c5be..182beb41a79 100644 --- a/src/main/daemon/node-pty-error-hints.ts +++ b/src/main/daemon/node-pty-error-hints.ts @@ -1,3 +1,10 @@ +import { + LEGACY_PTY_ALLOCATION_HINT, + LEGACY_TERMINAL_PROCESS_LIMIT_HINT, + PTY_ALLOCATION_HINT, + TERMINAL_PROCESS_LIMIT_HINT +} from '../../shared/terminal-spawn-error-copy' + export type NodePtyDiagnostic = { step: string errno: number @@ -25,22 +32,6 @@ const RESOURCE_EXHAUSTION_ERRNOS = new Set([ 35 // EAGAIN on macOS ]) -const PTY_ALLOCATION_HINT = [ - 'Your system cannot allocate any more pty devices.', - '', - 'Orca requires a pty device to launch a new terminal. This error is usually due to having too many terminal windows or terminal sessions open, either in Orca or another program.', - '', - 'Free up some pty devices and try again.' -].join('\n') - -const TERMINAL_PROCESS_LIMIT_HINT = [ - 'Your system cannot start another terminal process.', - '', - 'This is usually due to having too many terminal sessions or other processes running.', - '', - 'Close unused terminals or quit unused processes and try again.' -].join('\n') - export function parseNodePtyDiagnostic(message: string): NodePtyDiagnostic | null { const match = NODE_PTY_DIAGNOSTIC_RE.exec(message) ?? NODE_PTY_DIAGNOSTIC_ANYWHERE_RE.exec(message) @@ -70,18 +61,30 @@ export function getNodePtyRecoveryHint(diagnostic: NodePtyDiagnostic): string | return null } +function hasPtyAllocationHint(message: string): boolean { + return message.startsWith(PTY_ALLOCATION_HINT) || message.startsWith(LEGACY_PTY_ALLOCATION_HINT) +} + export function addNodePtyRecoveryHint(message: string): string { const diagnostic = parseNodePtyDiagnostic(message) if (!diagnostic) { - if (GENERIC_PTY_ALLOCATION_RE.test(message) && !message.startsWith(PTY_ALLOCATION_HINT)) { - return `${PTY_ALLOCATION_HINT} ${message}` + if (GENERIC_PTY_ALLOCATION_RE.test(message) && !hasPtyAllocationHint(message)) { + return `${PTY_ALLOCATION_HINT}\n${message}` } return message } const hint = getNodePtyRecoveryHint(diagnostic) - if (hint && message.startsWith(hint)) { + if ( + !hint || + message.startsWith(hint) || + (hint === PTY_ALLOCATION_HINT && hasPtyAllocationHint(message)) || + (hint === TERMINAL_PROCESS_LIMIT_HINT && message.startsWith(LEGACY_TERMINAL_PROCESS_LIMIT_HINT)) + ) { return message } - return hint ? `${hint} ${message}` : message + // Older clients need both stale-daemon markers before their first-line IPC truncation. + const separator = + hint === PTY_ALLOCATION_HINT || hint === TERMINAL_PROCESS_LIMIT_HINT ? '\n' : ' ' + return `${hint}${separator}${message}` } diff --git a/src/main/daemon/process-boundary-ground.test.ts b/src/main/daemon/process-boundary-ground.test.ts index dfdf5262edd..5cefaa0694b 100644 --- a/src/main/daemon/process-boundary-ground.test.ts +++ b/src/main/daemon/process-boundary-ground.test.ts @@ -142,7 +142,7 @@ describe('process boundary ground at a proven crash', () => { barrier.accept({ data, rawStartSeq: 0, rawEndSeq: data.length, transformed: false }) await vi.waitFor(() => expect(released).toHaveLength(3)) - expect(released[1]).toBe(PROCESS_BOUNDARY_GROUND) + expect(released[1]).toBe(`\x1b]133;D;137\x07${PROCESS_BOUNDARY_GROUND}`) expect(released[2]).toBe('\x1b[?2004h$ ') expect(barrier.getOwner()).toBe('shell') const snapshot = live.getSnapshot() @@ -155,4 +155,19 @@ describe('process boundary ground at a proven crash', () => { } expect(live.getBufferTailLines(24).slice(0, 2)).toEqual(['$ tui', '$ ']) }) + + it('pauses on an escape boundary so a mid-proof snapshot has no open OSC', () => { + const live = emulator(DAEMON_SESSION_SCROLLBACK_ROWS) + const barrier = new TerminalShellRecoveryBarrier({ + confirmShellForeground: () => new Promise(() => {}), + release: (emission) => write(live, emission.data), + isAlive: () => true + }) + + const data = `\x1b[?1049h${DEAD_PROCESS_ARMS}TUI\x1b]133;D;137\x07$ ` + barrier.accept({ data, rawStartSeq: 0, rawEndSeq: data.length, transformed: false }) + + expect(live.getSnapshot().pendingEscapeTailAnsi).toBeUndefined() + barrier.dispose() + }) }) diff --git a/src/main/daemon/pty-subprocess-env-inheritance.test.ts b/src/main/daemon/pty-subprocess-env-inheritance.test.ts index f6f6cb3a056..636b53d70a1 100644 --- a/src/main/daemon/pty-subprocess-env-inheritance.test.ts +++ b/src/main/daemon/pty-subprocess-env-inheritance.test.ts @@ -125,11 +125,13 @@ describe('createPtySubprocess', () => { const saved = { ORCA_PANE_KEY: process.env.ORCA_PANE_KEY, ORCA_TAB_ID: process.env.ORCA_TAB_ID, - ORCA_WORKTREE_ID: process.env.ORCA_WORKTREE_ID + ORCA_WORKTREE_ID: process.env.ORCA_WORKTREE_ID, + ORCA_WSL_CLI_DIR: process.env.ORCA_WSL_CLI_DIR } process.env.ORCA_PANE_KEY = 'parent-tab:parent-leaf' process.env.ORCA_TAB_ID = 'parent-tab' process.env.ORCA_WORKTREE_ID = 'parent-worktree' + process.env.ORCA_WSL_CLI_DIR = 'C:/parent/wsl-managed-cli' try { await createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) @@ -147,6 +149,7 @@ describe('createPtySubprocess', () => { expect(env.ORCA_PANE_KEY).toBeUndefined() expect(env.ORCA_TAB_ID).toBeUndefined() expect(env.ORCA_WORKTREE_ID).toBeUndefined() + expect(env.ORCA_WSL_CLI_DIR).toBeUndefined() }) it('preserves explicit child Orca pane identity over parent env', async () => { diff --git a/src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts b/src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts new file mode 100644 index 00000000000..94e352ce695 --- /dev/null +++ b/src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts @@ -0,0 +1,280 @@ +import type { IPty } from 'node-pty' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ProcessTableRow } from '../../shared/process-table-snapshot' +import type * as SnapshotReader from '../../shared/process-table-snapshot-reader' +import { createDaemonPtySubprocessHandle } from './pty-subprocess/subprocess-handle' +import { resolveSpawnFileForegroundFromRows } from './pty-subprocess/spawn-file-foreground-process' +import { inspectTerminalHostProcess } from './terminal-host-process-inspection' +import { Session } from './session' +const { readSnapshot, readFresh, readStrict, members, readWindows, resolveWindows } = vi.hoisted( + () => ({ + readSnapshot: vi.fn(), + readFresh: vi.fn(), + readStrict: vi.fn(), + members: vi.fn(), + readWindows: vi.fn(), + resolveWindows: vi.fn() + }) +) +vi.mock('../../shared/process-table-snapshot-reader', async (importOriginal) => ({ + ...(await importOriginal()), + getProcessTableSnapshot: readSnapshot, + getFreshProcessTableSnapshot: readFresh, + getStrictProcessTableSnapshotWithAge: readStrict +})) +vi.mock('../providers/windows-pty-job-membership', () => ({ + readWindowsPtyJobProcessIds: members, + isWindowsPtyJobReadable: () => true +})) +vi.mock('../windows/windows-process-table', () => ({ + readWindowsProcessIdentityTable: readWindows, + readWindowsProcessIdentityTableFresh: readWindows +})) +vi.mock('../providers/windows-agent-foreground-process', () => ({ + shouldInspectWindowsAgentForeground: () => true, + resolveWindowsAgentForegroundProcessWithAvailability: resolveWindows +})) + +function table(command: string | null, loginWrapper = false): ProcessTableRow[] { + const tpgid = command === null ? (loginWrapper ? 101 : 100) : 102 + const root: ProcessTableRow = { + pid: 100, + ppid: 1, + pgid: 100, + tpgid, + tty: 'ttys004', + startTime: 'Thu Sep 3 16:02:01 2026', + stat: tpgid === 100 ? 'Ss+' : 'Ss', + command: loginWrapper ? '"/Applications/Orca shell login" -fp user' : '/bin/zsh' + } + return [ + root, + ...(loginWrapper + ? [ + { + ...root, + pid: 101, + ppid: 100, + pgid: 101, + stat: command === null ? 'S+' : 'S', + command: '-zsh' + } + ] + : []), + ...(command === null + ? [] + : [{ ...root, pid: 102, ppid: loginWrapper ? 101 : 100, pgid: 102, stat: 'S+', command }]) + ] +} + +function createHandle(loginWrapper = false) { + const proc: IPty & { processNameIsSpawnFile: true } = { + pid: 100, + cols: 80, + rows: 24, + handleFlowControl: false, + process: loginWrapper ? '/Applications/Orca shell login' : '/bin/zsh', + processNameIsSpawnFile: true, + onData: () => ({ dispose() {} }), + onExit: () => ({ dispose() {} }), + write() {}, + resize() {}, + clear() {}, + kill() {}, + pause() {}, + resume() {} + } + return createDaemonPtySubprocessHandle({ + process: proc, + shellPath: '/bin/zsh', + spawnCwd: '/tmp', + env: {}, + startupCommandDeliveredInShellArgs: false, + reportsChildExitStatus: true, + sessionId: 'static-name', + startupAgentRecognition: null + }) +} + +async function inspect(handle: ReturnType) { + const session = new Session({ + sessionId: 'static-name', + subprocess: handle, + shellReadySupported: false, + cols: 80, + rows: 24, + scrollback: 10 + }) + try { + return await inspectTerminalHostProcess({ + sessionId: session.sessionId, + session, + authorityGeneration: 'generation', + nextObservationEpoch: () => 1 + }) + } finally { + session.dispose() + } +} + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + vi.resetAllMocks() +}) + +describe.each(['linux', 'darwin'] as const)('static spawn-file foreground on %s', (platform) => { + it.each(['vim', 'sleep', 'node', 'npm', 'node /usr/bin/claude'])( + 'resolves %s in both the synchronous tracker and host inspection', + async (command) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + const rows = table(command, platform === 'darwin') + readSnapshot.mockResolvedValue(rows) + readFresh.mockResolvedValue(rows) + readStrict.mockResolvedValue({ rows, capturedAgeMs: 0 }) + const handle = createHandle(platform === 'darwin') + const expected = command.includes('claude') ? 'claude' : command + expect(handle.processNameIsSpawnFile).toBe(true) + expect(await handle.confirmForegroundProcess?.()).toBe(expected) + expect(handle.getForegroundProcess()).toBe(expected) + expect(await inspect(createHandle(platform === 'darwin'))).toMatchObject({ + foregroundProcess: expected, + hasChildProcesses: true + }) + expect(readStrict).toHaveBeenCalledTimes(1) + } + ) + + it('observes a command ending and returns to an idle login shell', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + const handle = createHandle(true) + readFresh.mockResolvedValue(table('vim', true)) + expect(await handle.confirmForegroundProcess?.()).toBe('vim') + const rows = table(null, true) + readFresh.mockResolvedValue(rows) + readSnapshot.mockResolvedValue(rows) + readStrict.mockResolvedValue({ rows, capturedAgeMs: 0 }) + expect(await handle.confirmForegroundProcess?.()).toBe('zsh') + const inspection = await inspect(handle) + expect(inspection).toMatchObject({ + foregroundProcess: null, + hasChildProcesses: false, + childProcessEvidence: 'no-children' + }) + }) + + it('does not interpret a failed process read as a childless shell', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + readFresh.mockRejectedValue(new Error('unreadable')) + readSnapshot.mockRejectedValue(new Error('unreadable')) + readStrict.mockRejectedValue(new Error('unreadable')) + const handle = createHandle() + expect(await handle.confirmForegroundProcess?.()).toBeNull() + const inspection = await inspect(handle) + expect(inspection).toMatchObject({ + hasChildProcesses: true, + childProcessEvidence: 'unverifiable', + foregroundProcessEvidence: { verdict: 'unverifiable' } + }) + }) + + it('retains ordinary foreground names across a failed background refresh', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(100_000) + readSnapshot.mockResolvedValue(table('vim')) + const handle = createHandle() + expect(handle.getForegroundProcess()).toBe('zsh') + await vi.waitFor(() => expect(handle.getForegroundProcess()).toBe('vim')) + readSnapshot.mockRejectedValue(new Error('unreadable')) + vi.setSystemTime(102_000) + expect(handle.getForegroundProcess()).toBe('vim') + await vi.waitFor(() => expect(readSnapshot).toHaveBeenCalledTimes(2)) + expect(handle.getForegroundProcess()).toBe('vim') + handle.dispose() + }) + + it.each(['T', 'S'])( + 'keeps the close guard live when the shell is foreground and a child has state %s', + async (stat) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + const rows = table(null, platform === 'darwin') + rows.push({ ...rows[0], pid: 102, ppid: rows.at(-1)!.pid, pgid: 102, stat, command: 'vim' }) + readSnapshot.mockResolvedValue(rows) + readStrict.mockResolvedValue({ rows, capturedAgeMs: 0 }) + const inspection = await inspect(createHandle(platform === 'darwin')) + expect(inspection).toMatchObject({ + foregroundProcess: null, + hasChildProcesses: true, + childProcessEvidence: 'children' + }) + } + ) +}) + +it('ignores stopped/background children and another terminal beneath the same root', () => { + const rows = table(null) + rows.push({ ...rows[0], pid: 102, ppid: 100, pgid: 102, stat: 'T', command: 'vim' }) + rows.push({ ...rows[0], pid: 103, ppid: 100, tty: 'ttys009', command: 'claude' }) + expect(resolveSpawnFileForegroundFromRows(rows, 100)).toEqual({ + available: true, + processName: 'zsh' + }) + expect(resolveSpawnFileForegroundFromRows(rows, 999)).toEqual({ + available: false, + processName: null + }) +}) + +it('uses Windows job membership and the native process table for ordinary children', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + resolveWindows.mockResolvedValue({ available: true, processName: null }) + members.mockReturnValue(new Set([100, 102])) + readWindows.mockResolvedValue([ + { pid: 100, ppid: 1, name: 'pwsh.exe' }, + { pid: 102, ppid: 100, name: 'vim.exe' } + ]) + readStrict.mockRejectedValue(new Error('POSIX evidence unavailable')) + const handle = createHandle() + expect(await handle.confirmForegroundProcess?.()).toBe('vim.exe') + expect(await inspect(handle)).toMatchObject({ + foregroundProcess: 'vim.exe', + hasChildProcesses: true + }) +}) + +it('keeps missing Windows job membership unverifiable', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + resolveWindows.mockResolvedValue({ available: true, processName: null }) + members.mockReturnValue(null) + readStrict.mockRejectedValue(new Error('POSIX evidence unavailable')) + expect(await inspect(createHandle())).toMatchObject({ + foregroundProcess: null, + hasChildProcesses: true, + childProcessEvidence: 'unverifiable' + }) +}) + +it('reports an idle Windows shell only when the owned job contains the shell alone', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + resolveWindows.mockResolvedValue({ available: true, processName: null }) + members.mockReturnValue(new Set([100])) + readStrict.mockRejectedValue(new Error('POSIX evidence unavailable')) + expect(await inspect(createHandle())).toMatchObject({ hasChildProcesses: false }) + expect(readWindows).not.toHaveBeenCalled() +}) + +it('keeps the Windows close guard live when a shell descendant is selected above another job', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + resolveWindows.mockResolvedValue({ available: true, processName: null }) + members.mockReturnValue(new Set([100, 102, 103, 104])) + readWindows.mockResolvedValue([ + { pid: 100, ppid: 1, name: 'pwsh.exe' }, + { pid: 102, ppid: 100, name: 'vim.exe' }, + { pid: 103, ppid: 100, name: 'cmd.exe' }, + { pid: 104, ppid: 103, name: 'pwsh.exe' } + ]) + readStrict.mockRejectedValue(new Error('POSIX evidence unavailable')) + const inspection = await inspect(createHandle()) + expect(inspection).toMatchObject({ hasChildProcesses: true, childProcessEvidence: 'children' }) +}) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 329d1ce5d6b..1b5bc3d1102 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -84,7 +84,7 @@ export async function createPtySubprocess(opts: PtySubprocessOptions): Promise { + // Bash re-raises SIGHUP; Zsh exits with the signal number. + for (const [shell, expectedExitCode] of [ + ['/bin/bash', 129], + ['/bin/zsh', 1] + ] as const) { + it.skipIf(!existsSync(shell))( + `gracefully closes an interactive ${shell} before the daemon force-kill deadline`, + async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-bun-shell-hangup-')) + try { + const entry = join(directory, 'shell-hangup.cjs') + writeFileSync( + entry, + ` +const {spawnBunPty} = require(${JSON.stringify(join(__dirname, 'bun-pty-process.ts'))}) +const {createDaemonPtySubprocessHandle} = require(${JSON.stringify(join(__dirname, 'subprocess-handle.ts'))}) +const {SessionTerminationController} = require(${JSON.stringify(join(__dirname, '../session-termination-controller.ts'))}) +const {existsSync} = require('node:fs') +const {join} = require('node:path') +const cwd = ${JSON.stringify(directory)} +const ready = join(cwd, 'ready'), cleanup = join(cwd, 'hangup-cleanup') +const shell = ${JSON.stringify(shell)} +const env = {...process.env,PS1:'',ORCA_TEST_READY:ready,ORCA_TEST_CLEANUP:cleanup} +const proc = spawnBunPty({file:shell,args:shell.endsWith('/bash')?['--noprofile','--norc','-i']:['-f','-i'],cwd,env,cols:80,rows:24}) +const subprocess = createDaemonPtySubprocessHandle({process:proc,shellPath:shell,spawnCwd:cwd,env,startupCommandDeliveredInShellArgs:false,reportsChildExitStatus:true,sessionId:'shell-hangup',startupAgentRecognition:null}) +let exited = false, forced = false, exitCode, elapsedMs, startedAt +const forceKill = subprocess.forceKill +subprocess.forceKill = () => {forced = true;forceKill()} +const controller = new SessionTerminationController({sessionId:'shell-hangup',subprocess,launchAgent:null,isExited:()=>exited,releaseProducerPause:()=>proc.resume()}) +subprocess.onExit(code => { + exited = true + exitCode = code + elapsedMs = Date.now() - startedAt + controller.markPhysicalExit() + controller.cancelForceKillFallback() +}) +const waitFor = async predicate => { + const deadline = Date.now() + 8000 + while (!predicate()) { + if (Date.now() >= deadline) throw new Error('Timed out waiting for shell hangup') + await Bun.sleep(10) + } +} +;(async()=>{ + try { + // Observe normal hangup cleanup without replacing the shell's SIGHUP handler. + proc.write(${JSON.stringify('trap \'printf cleaned > "$ORCA_TEST_CLEANUP"\' EXIT; printf ready > "$ORCA_TEST_READY"\r')}) + await waitFor(() => existsSync(ready)) + startedAt = Date.now() + controller.kill() + await waitFor(() => exited) + let reaped = false + try {process.kill(proc.pid, 0)} catch (error) {if(error.code==='ESRCH')reaped=true;else throw error} + console.log(JSON.stringify({cleaned:existsSync(cleanup),forced,exitCode,elapsedMs,reaped})) + } finally { + controller.cancelForceKillFallback() + if (!exited) { + subprocess.forceKill() + await waitFor(() => exited) + } + controller.disposeSubprocessHandle() + } +})().catch(error => {console.error(error);process.exitCode=1}) +` + ) + const result = await runProcess({ + program: runtimePath, + args: [entry], + timeoutMs: 25_000 + }) + expect(result.timedOut).toBe(false) + expect(result.code, result.stderr).toBe(0) + const evidence = JSON.parse(result.stdout) + expect(evidence).toEqual({ + cleaned: true, + forced: false, + exitCode: expectedExitCode, + elapsedMs: expect.any(Number), + reaped: true + }) + expect(evidence.elapsedMs).toBeLessThan(5_000) + } finally { + removeTreeSync(directory) + } + } + ) + } + + it('keeps a real Ctrl-Z job suspended while pausing and resuming a background producer', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-bun-job-control-')) + try { + writeFileSync(join(directory, 'producer.cjs'), 'setInterval(()=>console.log("flow-tick"),10)') + writeFileSync( + join(directory, 'sleeper.sh'), + 'printf \'%s\' "$$" > sleeper-ready\nexec sleep 30\n' + ) + const entry = join(directory, 'job-control.cjs') + writeFileSync( + entry, + ` +const {spawnBunPty} = require(${JSON.stringify(join(__dirname, 'bun-pty-process.ts'))}) +const {readPosixPtyProcessTable,forceKillPosixPtyProcessGroups} = require(${JSON.stringify(join(__dirname, '../../pty/posix-pty-process-groups.ts'))}) +const {existsSync,readFileSync} = require('node:fs') +const ready = ${JSON.stringify(join(directory, 'sleeper-ready'))} +const signals = [] +const proc = spawnBunPty({ + file:'/bin/bash', args:['--noprofile','--norc','-i'], cwd:${JSON.stringify(directory)}, + env:{...process.env,PS1:'',ORCA_TEST_RUNTIME:process.execPath},cols:80,rows:24 +}, {signalProcessGroup:(pgid,signal)=>{process.kill(-pgid,signal);signals.push([pgid,signal])}}) +let output = '', exited = false +proc.onData(data => output += data) +proc.onExit(() => {exited = true}) +const isAlive = pid => { + try {process.kill(pid, 0);return true} + catch (error) {if(error.code==='ESRCH')return false;throw error} +} +const rows = async () => { + const table = (await readPosixPtyProcessTable(proc.pid)).trim().split(/\\r?\\n/).map(row => { + const [pid,pgid,tty,state] = row.trim().split(/\\s+/) + return {pid:Number(pid),pgid:Number(pgid),tty,state} + }).filter(row => row.pid > 0 && row.state) + const root = table.find(row => row.pid === proc.pid) + // BusyBox discovery returns all processes; this probe owns only its shell's terminal. + return root ? table.filter(row => row.tty === root.tty) : [] +} +const waitFor = async predicate => { + const deadline = Date.now() + 8000 + while (Date.now() < deadline) { + const value = await predicate() + if (value) return value + await Bun.sleep(20) + } + throw new Error('Timed out waiting for terminal process state') +} +;(async()=>{ + try { + proc.write('/bin/bash sleeper.sh\\r') + // A forked PID can appear before Bash gives its group the foreground terminal. + const sleeperPid = await waitFor(() => existsSync(ready) && Number(readFileSync(ready, 'utf8'))) + const sleeper = await waitFor(async () => (await rows()).find(row => row.pid === sleeperPid)) + proc.write('\\x1a') + await waitFor(async () => (await rows()).some(row => row.pid === sleeper.pid && row.state.startsWith('T'))) + proc.write(${JSON.stringify('"$ORCA_TEST_RUNTIME" producer.cjs &\r')}) + await waitFor(() => output.split('flow-tick').length > 5) + proc.pause() + await waitFor(() => signals.filter(([,signal]) => signal === 'SIGSTOP').length >= 2) + await Bun.sleep(100) + const pausedLength = output.length + await Bun.sleep(100) + const producerPaused = pausedLength === output.length + proc.resume() + await waitFor(() => signals.some(([,signal]) => signal === 'SIGCONT')) + await waitFor(() => output.length > pausedLength) + const sleeperAfter = (await rows()).find(row => row.pid === sleeper.pid) + console.log(JSON.stringify({producerPaused,producerResumed:true,userJobStopped:sleeperAfter?.state.startsWith('T')===true,userJobSignalled:signals.some(([pgid])=>pgid===sleeper.pgid)})) + } finally { + let ownedPids = [] + try { + proc.resume() + const ownedRows = await rows() + ownedPids = ownedRows.map(row => row.pid) + const root = ownedRows.find(row => row.pid === proc.pid) + if (!root) throw new Error('Cleanup could not find the owned shell') + // Keep Bash running until it reaps its jobs; container PID 1 may not reap orphans. + forceKillPosixPtyProcessGroups(proc.pid, () => {throw new Error('Cleanup lost terminal ownership')}, { + signalProcessGroup: pgid => {if (pgid !== root.pgid) process.kill(-pgid, 'SIGKILL')} + }) + process.kill(proc.pid, 'SIGCONT') + await waitFor(() => ownedPids.every(pid => pid === proc.pid || !isAlive(pid))) + } finally { + try { + forceKillPosixPtyProcessGroups(proc.pid, () => proc.kill('SIGKILL')) + await waitFor(() => exited && ownedPids.every(pid => !isAlive(pid))) + } finally { + proc.destroy() + } + } + } +})().catch(error => {console.error(error);process.exitCode=1}) +` + ) + const result = await runProcess({ program: runtimePath, args: [entry], timeoutMs: 25_000 }) + expect(result.timedOut).toBe(false) + expect(result.code, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toEqual({ + producerPaused: true, + producerResumed: true, + userJobStopped: true, + userJobSignalled: false + }) + } finally { + removeTreeSync(directory) + } + }) +}) diff --git a/src/main/daemon/pty-subprocess/bun-pty-process-capabilities.ts b/src/main/daemon/pty-subprocess/bun-pty-process-capabilities.ts new file mode 100644 index 00000000000..8d236ab5aa8 --- /dev/null +++ b/src/main/daemon/pty-subprocess/bun-pty-process-capabilities.ts @@ -0,0 +1,27 @@ +import type { BunRuntime } from './bun-pty-process-contract' + +function currentRuntime(): unknown { + return 'Bun' in globalThis ? globalThis.Bun : undefined +} + +function isBunRuntime(runtime: unknown): runtime is BunRuntime { + return ( + typeof runtime === 'object' && + runtime !== null && + 'spawn' in runtime && + typeof runtime.spawn === 'function' && + 'Terminal' in runtime && + typeof runtime.Terminal === 'function' + ) +} + +export function canUseBunPty(runtime: unknown = currentRuntime()): boolean { + return isBunRuntime(runtime) +} + +export function resolveBunRuntime(runtime: unknown = currentRuntime()): BunRuntime { + if (!isBunRuntime(runtime)) { + throw new Error('Bun terminal runtime is unavailable') + } + return runtime +} diff --git a/src/main/daemon/pty-subprocess/bun-pty-process-contract.ts b/src/main/daemon/pty-subprocess/bun-pty-process-contract.ts new file mode 100644 index 00000000000..b43dd57a659 --- /dev/null +++ b/src/main/daemon/pty-subprocess/bun-pty-process-contract.ts @@ -0,0 +1,72 @@ +import type * as pty from 'node-pty' +import type { JobTerminationOutcome } from '../../windows/windows-pty-job' +import type { WindowsBunPtyJob } from './windows-bun-pty-job' +import type { createWindowsBunPtyLaunch } from './windows-bun-pty-launch' + +export type BunTerminal = { + closed: boolean + write(data: string | ArrayBufferView): number + resize(cols: number, rows: number): void + close(): void +} + +export type BunSubprocess = { + pid: number + terminal: BunTerminal + exited: Promise + signalCode?: string | null + kill(signal?: string | number): void +} + +export type BunTerminalOptions = { + cols: number + rows: number + name: string + data(terminal: BunTerminal, data: Uint8Array): void + exit?(terminal: BunTerminal, exitCode: number, signal: string | null): void + drain?(terminal: BunTerminal): void +} + +export type BunRuntime = { + Terminal: new (options: BunTerminalOptions) => BunTerminal + spawn( + command: string[], + options: { + cwd: string + env: Record + terminal: BunTerminal | BunTerminalOptions + windowsVerbatimArguments?: boolean + onExit?(process: BunSubprocess, exitCode: number, signalCode: string | null): void + } + ): BunSubprocess +} + +export type BunPtyProcess = pty.IPty & { + destroy(): void + processNameIsSpawnFile?: true + jobRootProcessIsWrapper?: true + shellProcessId?: number + waitForSpawn?(): Promise + terminateOwnedTree?(): JobTerminationOutcome + listOwnedProcessIds?(): readonly number[] | null + signalProcess?(signal: string): void +} + +export type BunPtySpawnArgs = { + file: string + args: string[] + cwd: string + env: Record + cols: number + rows: number +} + +export type SpawnBunPtyDeps = { + platform?: NodeJS.Platform + runtime?: BunRuntime + assignHostJob?: () => boolean + createJob?: (pid: number) => WindowsBunPtyJob | null + createWindowsLaunch?: typeof createWindowsBunPtyLaunch + readProcessTable?: () => string + signalProcessGroup?: (pgid: number, signal: NodeJS.Signals) => void +} diff --git a/src/main/daemon/pty-subprocess/bun-pty-process-flow-control.test.ts b/src/main/daemon/pty-subprocess/bun-pty-process-flow-control.test.ts new file mode 100644 index 00000000000..5c4a438238b --- /dev/null +++ b/src/main/daemon/pty-subprocess/bun-pty-process-flow-control.test.ts @@ -0,0 +1,387 @@ +import { constants } from 'node:os' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createBunPtyProducerFlowControl } from './bun-pty-process-flow-control' + +const TABLE = '4321 4321 pts/test T\n4322 4322 pts/test' +const settled = (): Promise => new Promise((resolve) => setImmediate(resolve)) + +function createHarness() { + let exited = false + const reads: { resolve: (table: string) => void; signal: AbortSignal }[] = [] + const readProcessTableAsync = vi.fn( + (signal: AbortSignal) => + new Promise((resolve) => { + reads.push({ resolve, signal }) + }) + ) + const signalProcessGroup = vi.fn<(pgid: number, signal: NodeJS.Signals) => void>() + const kill = vi.fn() + const flow = createBunPtyProducerFlowControl({ + platform: 'linux', + processHandle: { pid: 4321, kill, terminal: { closed: false, close() {} } }, + windowsJob: null, + isExited: () => exited, + readProcessTable: () => TABLE, + readProcessTableAsync, + signalProcessGroup + }) + return { + flow, + reads, + kill, + readProcessTableAsync, + signalProcessGroup, + exit: () => { + exited = true + } + } +} + +afterEach(() => vi.useRealTimers()) + +describe('asynchronous POSIX producer flow control', () => { + it.each(['S', 'R', ''])( + 'leaves jobs running unless the shell is observed stopped (state %s)', + async (state) => { + const harness = createHarness() + harness.flow.pause() + expect(harness.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]]) + expect(harness.readProcessTableAsync).not.toHaveBeenCalled() + await settled() + harness.reads[0].resolve(TABLE.replace('pts/test T', `pts/test ${state}`)) + await settled() + expect(harness.signalProcessGroup).not.toHaveBeenCalled() + harness.flow.resume() + await settled() + harness.reads[1].resolve(TABLE) + await settled() + expect(harness.kill.mock.calls).toEqual([ + [constants.signals.SIGSTOP], + [constants.signals.SIGCONT] + ]) + expect(harness.signalProcessGroup).not.toHaveBeenCalled() + } + ) + + it('does not attempt a group pause after the owned shell cannot be stopped', async () => { + const harness = createHarness() + harness.kill.mockImplementationOnce(() => { + throw Object.assign(new Error('denied'), { code: 'EPERM' }) + }) + harness.flow.pause() + await settled() + harness.flow.resume() + await settled() + expect(harness.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]]) + expect(harness.readProcessTableAsync).not.toHaveBeenCalled() + expect(harness.signalProcessGroup).not.toHaveBeenCalled() + }) + + it('retries a failed resume lookup without another caller resume or stale group signals', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const harness = createHarness() + harness.flow.pause() + await settled() + harness.reads[0].resolve(TABLE) + await settled() + harness.readProcessTableAsync.mockRejectedValueOnce(new Error('temporary ps failure')) + harness.flow.resume() + await settled() + expect(harness.signalProcessGroup.mock.calls).toEqual([ + [4321, 'SIGSTOP'], + [4322, 'SIGSTOP'] + ]) + expect(harness.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]]) + + await vi.advanceTimersByTimeAsync(1_000) + expect(harness.readProcessTableAsync).toHaveBeenCalledTimes(3) + harness.reads[1].resolve('4321 4321 pts/test T\n4322 4322 pts/other\n4323 4323 pts/test') + await settled() + expect(harness.signalProcessGroup.mock.calls.slice(2)).toEqual([[4321, 'SIGCONT']]) + await vi.advanceTimersByTimeAsync(5_000) + expect(harness.readProcessTableAsync).toHaveBeenCalledTimes(3) + }) + + it.each(['pause', 'shutdown', 'exit'] as const)( + 'cancels a scheduled resume retry after %s', + async (action) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const harness = createHarness() + harness.flow.pause() + await settled() + harness.reads[0].resolve(TABLE) + await settled() + harness.readProcessTableAsync.mockRejectedValueOnce(new Error('temporary ps failure')) + harness.flow.resume() + await settled() + expect(vi.getTimerCount()).toBe(1) + if (action === 'pause') { + harness.flow.pause() + await settled() + harness.reads[1].resolve(TABLE) + await settled() + } else { + if (action === 'exit') { + harness.exit() + } + harness.flow.resumeForShutdown() + } + expect(vi.getTimerCount()).toBe(0) + const signals = harness.signalProcessGroup.mock.calls.length + await vi.advanceTimersByTimeAsync(5_000) + expect(harness.signalProcessGroup).toHaveBeenCalledTimes(signals) + expect(harness.readProcessTableAsync).toHaveBeenCalledTimes(action === 'pause' ? 3 : 2) + } + ) + + it('bounds retries while discovery stays unavailable and resumes after it recovers', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const harness = createHarness() + harness.flow.pause() + await settled() + harness.reads[0].resolve(TABLE) + await settled() + harness.readProcessTableAsync.mockRejectedValue(new Error('ps unavailable')) + harness.flow.resume() + await settled() + await vi.advanceTimersByTimeAsync(2_000) + expect(harness.readProcessTableAsync).toHaveBeenCalledTimes(6) + expect(vi.getTimerCount()).toBe(1) + expect(harness.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]]) + expect(harness.signalProcessGroup).toHaveBeenCalledTimes(2) + harness.readProcessTableAsync.mockResolvedValue(TABLE) + await vi.advanceTimersByTimeAsync(500) + expect(harness.signalProcessGroup).toHaveBeenLastCalledWith(4321, 'SIGCONT') + expect(vi.getTimerCount()).toBe(0) + }) + + it('resumes a root-only suspension when process group discovery is unavailable throughout', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const harness = createHarness() + harness.readProcessTableAsync.mockRejectedValue(new Error('ps unavailable')) + harness.flow.pause() + await settled() + harness.flow.resume() + await settled() + expect(harness.kill.mock.calls).toEqual([ + [constants.signals.SIGSTOP], + [constants.signals.SIGCONT] + ]) + expect(harness.signalProcessGroup).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + + it('reapplies pause after a partial resume and keeps the shell stopped until all jobs resume', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const harness = createHarness() + const table = `${TABLE}\n4323 4323 pts/test` + let denyOnce = true + harness.signalProcessGroup.mockImplementation((pgid, signal) => { + if (pgid === 4323 && signal === 'SIGCONT' && denyOnce) { + denyOnce = false + throw Object.assign(new Error('denied'), { code: 'EPERM' }) + } + }) + harness.flow.pause() + await settled() + harness.reads[0].resolve(table) + await settled() + harness.flow.resume() + await settled() + harness.reads[1].resolve(table) + await settled() + expect(harness.signalProcessGroup.mock.calls.slice(3)).toEqual([ + [4322, 'SIGCONT'], + [4323, 'SIGCONT'] + ]) + expect(vi.getTimerCount()).toBe(1) + harness.flow.pause() + await settled() + harness.reads[2].resolve(table) + await settled() + expect(harness.signalProcessGroup.mock.calls.slice(5)).toEqual([ + [4321, 'SIGSTOP'], + [4322, 'SIGSTOP'], + [4323, 'SIGSTOP'] + ]) + expect(vi.getTimerCount()).toBe(0) + harness.flow.resume() + await settled() + harness.reads[3].resolve(table) + await settled() + expect(harness.signalProcessGroup.mock.calls.slice(8)).toEqual([ + [4322, 'SIGCONT'], + [4323, 'SIGCONT'], + [4321, 'SIGCONT'] + ]) + }) + + it('does not delay the shell resume for a job group that already exited', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const harness = createHarness() + harness.flow.pause() + await settled() + harness.reads[0].resolve(TABLE) + await settled() + harness.signalProcessGroup.mockImplementationOnce(() => { + throw Object.assign(new Error('gone'), { code: 'ESRCH' }) + }) + harness.flow.resume() + await settled() + harness.reads[1].resolve(TABLE) + await settled() + expect(harness.signalProcessGroup).toHaveBeenLastCalledWith(4321, 'SIGCONT') + expect(vi.getTimerCount()).toBe(0) + }) + + it('coalesces repeated pressure changes while discovery is pending', async () => { + const harness = createHarness() + harness.flow.pause() + await settled() + for (let i = 0; i < 1_000; i += 1) { + harness.flow.resume() + harness.flow.pause() + } + harness.flow.resume() + await settled() + expect(harness.readProcessTableAsync).toHaveBeenCalledOnce() + expect(harness.signalProcessGroup).not.toHaveBeenCalled() + + harness.reads[0].resolve(TABLE) + await settled() + expect(harness.signalProcessGroup).not.toHaveBeenCalled() + expect(harness.kill.mock.calls).toEqual([ + [constants.signals.SIGSTOP], + [constants.signals.SIGCONT] + ]) + + for (let i = 0; i < 20; i += 1) { + harness.flow.pause() + await settled() + harness.reads[2 * i + 1].resolve(TABLE) + await settled() + harness.flow.resume() + await settled() + harness.reads[2 * i + 2].resolve(TABLE) + await settled() + } + expect(harness.readProcessTableAsync).toHaveBeenCalledTimes(41) + expect(harness.signalProcessGroup).toHaveBeenCalledTimes(80) + expect(harness.signalProcessGroup).toHaveBeenLastCalledWith(4321, 'SIGCONT') + }) + + it('revalidates group ownership when resuming after a process id is reused', async () => { + const harness = createHarness() + harness.flow.pause() + await settled() + harness.reads[0].resolve(TABLE) + await settled() + harness.flow.resume() + await settled() + harness.reads[1].resolve('4321 4321 pts/test T\n4322 4322 pts/other\n4323 4323 pts/test') + await settled() + + expect(harness.signalProcessGroup.mock.calls).toEqual([ + [4321, 'SIGSTOP'], + [4322, 'SIGSTOP'], + [4321, 'SIGCONT'] + ]) + }) + + it('does not resume a still-paused session when pressure returns during a resume scan', async () => { + const harness = createHarness() + harness.flow.pause() + await settled() + harness.reads[0].resolve(TABLE) + await settled() + harness.flow.resume() + await settled() + harness.flow.pause() + harness.reads[1].resolve(TABLE) + await settled() + expect(harness.signalProcessGroup.mock.calls).toEqual([ + [4321, 'SIGSTOP'], + [4322, 'SIGSTOP'] + ]) + + harness.flow.resume() + await settled() + harness.reads[2].resolve(TABLE) + await settled() + expect(harness.signalProcessGroup).toHaveBeenLastCalledWith(4321, 'SIGCONT') + }) + + it.each(['shutdown', 'exit'] as const)('ignores a late scan after %s', async (action) => { + const harness = createHarness() + harness.flow.pause() + await settled() + if (action === 'shutdown') { + harness.flow.resumeForShutdown() + expect(harness.reads[0].signal.aborted).toBe(true) + } else { + harness.exit() + } + harness.reads[0].resolve(TABLE) + await settled() + expect(harness.signalProcessGroup).not.toHaveBeenCalled() + expect(harness.kill.mock.calls).toEqual( + action === 'shutdown' + ? [[constants.signals.SIGSTOP], [constants.signals.SIGCONT]] + : [[constants.signals.SIGSTOP]] + ) + }) + + it('releases stopped groups before shutdown while an asynchronous resume is pending', async () => { + const harness = createHarness() + harness.flow.pause() + await settled() + harness.reads[0].resolve(TABLE) + await settled() + harness.flow.resume() + await settled() + harness.flow.resumeForShutdown() + expect(harness.reads[1].signal.aborted).toBe(true) + expect(harness.signalProcessGroup.mock.calls).toEqual([ + [4321, 'SIGSTOP'], + [4322, 'SIGSTOP'], + [4322, 'SIGCONT'], + [4321, 'SIGCONT'] + ]) + harness.reads[1].resolve(TABLE) + await settled() + expect(harness.signalProcessGroup).toHaveBeenCalledTimes(4) + }) + + it('automatically retries a partially failed resume of a partially stopped tree', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const harness = createHarness() + const table = `${TABLE}\n4323 4323 pts/test S` + harness.signalProcessGroup.mockImplementation((pgid, signal) => { + if (pgid === 4323 && signal === 'SIGSTOP') { + throw new Error('temporary stop failure') + } + }) + harness.flow.pause() + await settled() + harness.reads[0].resolve(table) + await settled() + harness.signalProcessGroup.mockImplementationOnce(() => { + throw Object.assign(new Error('denied'), { code: 'EPERM' }) + }) + harness.flow.resume() + await settled() + harness.reads[1].resolve(table) + await settled() + await vi.advanceTimersByTimeAsync(500) + harness.reads[2].resolve(table) + await settled() + expect(harness.signalProcessGroup.mock.calls).toEqual([ + [4321, 'SIGSTOP'], + [4322, 'SIGSTOP'], + [4323, 'SIGSTOP'], + [4322, 'SIGCONT'], + [4322, 'SIGCONT'], + [4321, 'SIGCONT'] + ]) + }) +}) diff --git a/src/main/daemon/pty-subprocess/bun-pty-process-flow-control.ts b/src/main/daemon/pty-subprocess/bun-pty-process-flow-control.ts new file mode 100644 index 00000000000..5b7dfd7a313 --- /dev/null +++ b/src/main/daemon/pty-subprocess/bun-pty-process-flow-control.ts @@ -0,0 +1,185 @@ +import { constants } from 'node:os' +import type { WindowsBunPtyJob } from './windows-bun-pty-job' +import { isPosixPtyRootStopped, readPosixPtyProcessTable } from '../../pty/posix-pty-process-groups' + +import { createBunPtyProcessSuspension } from './bun-pty-process-suspension' + +const TRANSITION_RETRY_MS = 500 + +type BunPtyProcessHandle = Readonly<{ + pid: number + kill(signal?: string | number): void + terminal: Readonly<{ closed: boolean; close(): void }> +}> + +export type BunPtyProducerFlowControl = Readonly<{ + pause(): void + resume(): void + resumeForShutdown(): void +}> + +export function createBunPtyProducerFlowControl( + options: Readonly<{ + platform: NodeJS.Platform + processHandle: BunPtyProcessHandle + windowsJob: WindowsBunPtyJob | null + isExited: () => boolean + readProcessTable?: () => string + readProcessTableAsync?: (signal: AbortSignal) => Promise + signalProcessGroup?: (pgid: number, signal: NodeJS.Signals) => void + }> +): BunPtyProducerFlowControl { + let state: 'running' | 'paused' | 'uncertain' = 'running' + let pauseRequested = false + let shuttingDown = false + let pendingRead: AbortController | undefined + let transitionRetry: ReturnType | undefined + let pauseDenied = false + const signalRoot = (signal: 'SIGSTOP' | 'SIGCONT'): void => { + // The runtime's named STOP/CONT signals are not portable across POSIX platforms. + options.processHandle.kill(constants.signals[signal]) + } + + const suspension = createBunPtyProcessSuspension({ + pid: options.processHandle.pid, + platform: options.platform, + signalRoot, + readProcessTable: options.readProcessTable, + signalProcessGroup: options.signalProcessGroup + }) + const pausePermanentlyDenied = (error: unknown): boolean => + error instanceof Error && 'code' in error && (error.code === 'EPERM' || error.code === 'EACCES') + + const clearTransitionRetry = (): void => { + clearTimeout(transitionRetry) + transitionRetry = undefined + } + + const needsTransition = (): boolean => + !shuttingDown && !options.isExited() && state !== (pauseRequested ? 'paused' : 'running') + + const retryTransition = (): void => { + if (!needsTransition() || transitionRetry) { + return + } + // Callers send transitions once; retain the obligation until fresh ownership confirms every group. + transitionRetry = setTimeout(() => { + transitionRetry = undefined + reconcile() + }, TRANSITION_RETRY_MS) + transitionRetry.unref?.() + } + + const reconcile = (): void => { + if (!needsTransition() || pendingRead) { + return + } + if (options.platform === 'win32') { + const succeeded = pauseRequested ? options.windowsJob?.pause() : options.windowsJob?.resume() + state = succeeded ? (pauseRequested ? 'paused' : 'running') : 'uncertain' + if (pauseRequested && !succeeded) { + pauseRequested = false + } + retryTransition() + return + } + if (pauseRequested && state === 'running') { + try { + signalRoot('SIGSTOP') + state = 'uncertain' + } catch (error) { + if (pausePermanentlyDenied(error)) { + pauseDenied = true + pauseRequested = false + } + retryTransition() + return + } + } + const controller = new AbortController() + pendingRead = controller + // Process groups change as the shell runs jobs; revalidate them without blocking PTY output. + void Promise.resolve() + .then(() => + options.readProcessTableAsync + ? options.readProcessTableAsync(controller.signal) + : options.readProcessTable + ? options.readProcessTable() + : readPosixPtyProcessTable(options.processHandle.pid, controller.signal) + ) + .catch(() => '') + .then((table) => { + pendingRead = undefined + if (!needsTransition()) { + return + } + const nextPaused = pauseRequested + // Partial signals require a fresh transition even if the requested state changes again. + state = 'uncertain' + if (nextPaused) { + // Signal delivery is asynchronous; prove the shell stopped before suspending its jobs. + if (!isPosixPtyRootStopped(table, options.processHandle.pid)) { + retryTransition() + return + } + suspension.signal('SIGSTOP', table, true) + } else if (suspension.hasStoppedGroups()) { + suspension.signal('SIGCONT', table, true) + } else { + signalRoot('SIGCONT') + } + state = nextPaused ? 'paused' : 'running' + }) + .catch((error) => { + if (pauseRequested && pausePermanentlyDenied(error)) { + pauseDenied = true + pauseRequested = false + reconcile() + } else { + retryTransition() + } + }) + } + + return { + pause() { + if (shuttingDown || options.isExited() || pauseDenied) { + return + } + clearTransitionRetry() + pauseRequested = true + reconcile() + }, + resume() { + clearTransitionRetry() + pauseDenied = false + pauseRequested = false + reconcile() + }, + resumeForShutdown() { + clearTransitionRetry() + shuttingDown = true + if (options.platform === 'win32') { + if (!options.isExited()) { + options.windowsJob?.resume() + } + state = 'running' + return + } + pendingRead?.abort() + try { + if (!options.isExited() && state !== 'running') { + // Teardown must release stopped jobs before the root receives its exit signal. + if (suspension.hasStoppedGroups()) { + suspension.signal('SIGCONT') + } else { + signalRoot('SIGCONT') + } + } + } catch { + // A failed resume must not prevent the caller from terminating the PTY. + } + state = 'running' + } + } +} diff --git a/src/main/daemon/pty-subprocess/bun-pty-process-pause-retry.test.ts b/src/main/daemon/pty-subprocess/bun-pty-process-pause-retry.test.ts new file mode 100644 index 00000000000..cbda02305ea --- /dev/null +++ b/src/main/daemon/pty-subprocess/bun-pty-process-pause-retry.test.ts @@ -0,0 +1,128 @@ +import { constants } from 'node:os' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createBunPtyProducerFlowControl } from './bun-pty-process-flow-control' + +const TABLE = '4321 4321 pts/test T\n4322 4322 pts/test S' +const settled = (): Promise => new Promise((resolve) => setImmediate(resolve)) + +function createHarness() { + let exited = false + const kill = vi.fn() + const signalProcessGroup = vi.fn() + const readProcessTableAsync = vi.fn<(signal: AbortSignal) => Promise>() + const flow = createBunPtyProducerFlowControl({ + platform: 'linux', + processHandle: { pid: 4321, kill, terminal: { closed: false, close() {} } }, + windowsJob: null, + isExited: () => exited, + readProcessTableAsync, + signalProcessGroup + }) + return { flow, kill, readProcessTableAsync, signalProcessGroup, exit: () => (exited = true) } +} + +afterEach(() => vi.useRealTimers()) + +describe('Bun producer pause retry', () => { + it('retries a failed root suspension before attempting any group signals', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const harness = createHarness() + harness.kill.mockImplementationOnce(() => { + throw new Error('temporary signal rejection') + }) + harness.readProcessTableAsync.mockResolvedValue(TABLE) + harness.flow.pause() + await settled() + expect(harness.readProcessTableAsync).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(500) + expect(harness.kill.mock.calls).toEqual([ + [constants.signals.SIGSTOP], + [constants.signals.SIGSTOP] + ]) + expect(harness.signalProcessGroup.mock.calls).toEqual([ + [4321, 'SIGSTOP'], + [4322, 'SIGSTOP'] + ]) + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps ownership discovery pending when the stopped root has no controlling tty', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const harness = createHarness() + harness.readProcessTableAsync.mockResolvedValueOnce('4321 4321 ? T').mockResolvedValue(TABLE) + harness.flow.pause() + await settled() + expect(harness.signalProcessGroup).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(500) + expect(harness.signalProcessGroup.mock.calls).toEqual([ + [4321, 'SIGSTOP'], + [4322, 'SIGSTOP'] + ]) + expect(vi.getTimerCount()).toBe(0) + }) + + it.each(['lookup failure', 'shell still running'])( + 'eventually stops jobs after a transient %s without another pause request', + async (failure) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const harness = createHarness() + if (failure === 'lookup failure') { + harness.readProcessTableAsync.mockRejectedValueOnce(new Error('ps timed out')) + } else { + harness.readProcessTableAsync.mockResolvedValueOnce(TABLE.replace('test T', 'test S')) + } + harness.readProcessTableAsync.mockResolvedValue(TABLE) + harness.flow.pause() + await settled() + expect(harness.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]]) + expect(harness.signalProcessGroup).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(499) + expect(harness.readProcessTableAsync).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(1) + expect(harness.signalProcessGroup.mock.calls).toEqual([ + [4321, 'SIGSTOP'], + [4322, 'SIGSTOP'] + ]) + expect(vi.getTimerCount()).toBe(0) + harness.flow.resume() + await settled() + expect(harness.signalProcessGroup.mock.calls.slice(2)).toEqual([ + [4322, 'SIGCONT'], + [4321, 'SIGCONT'] + ]) + } + ) + + it.each(['resume', 'shutdown', 'exit'] as const)( + 'bounds failed pause probes and cancels them after %s', + async (action) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const harness = createHarness() + harness.readProcessTableAsync.mockRejectedValue(new Error('ps unavailable')) + harness.flow.pause() + await settled() + await vi.advanceTimersByTimeAsync(2_000) + expect(harness.readProcessTableAsync).toHaveBeenCalledTimes(5) + expect(vi.getTimerCount()).toBe(1) + expect(harness.signalProcessGroup).not.toHaveBeenCalled() + if (action === 'resume') { + harness.flow.resume() + } else { + if (action === 'exit') { + harness.exit() + } + harness.flow.resumeForShutdown() + } + await settled() + const reads = harness.readProcessTableAsync.mock.calls.length + await vi.advanceTimersByTimeAsync(5_000) + expect(harness.readProcessTableAsync).toHaveBeenCalledTimes(reads) + expect(vi.getTimerCount()).toBe(0) + expect(harness.kill.mock.calls).toEqual( + action === 'exit' + ? [[constants.signals.SIGSTOP]] + : [[constants.signals.SIGSTOP], [constants.signals.SIGCONT]] + ) + } + ) +}) diff --git a/src/main/daemon/pty-subprocess/bun-pty-process-runtime.ts b/src/main/daemon/pty-subprocess/bun-pty-process-runtime.ts new file mode 100644 index 00000000000..b6409795043 --- /dev/null +++ b/src/main/daemon/pty-subprocess/bun-pty-process-runtime.ts @@ -0,0 +1,311 @@ +import { constants } from 'node:os' +import { + assignCurrentProcessToBunPtyHostJob, + createWindowsBunPtyJob, + type WindowsBunPtyJob +} from './windows-bun-pty-job' +import { createWindowsBunPtyLaunch, type WindowsBunPtyLaunch } from './windows-bun-pty-launch' +import type { + BunPtyProcess, + BunPtySpawnArgs, + BunSubprocess, + BunTerminal, + BunTerminalOptions, + SpawnBunPtyDeps +} from './bun-pty-process-contract' +import { resolveBunRuntime } from './bun-pty-process-capabilities' +import { createBunPtyProducerFlowControl } from './bun-pty-process-flow-control' + +export function spawnBunPty(args: BunPtySpawnArgs, deps: SpawnBunPtyDeps = {}): BunPtyProcess { + const runtime = resolveBunRuntime(deps.runtime) + + const platform = deps.platform ?? process.platform + let processHandle: BunSubprocess + let windowsLaunch: WindowsBunPtyLaunch | null = null + let windowsJob: WindowsBunPtyJob | null = null + let windowsTerminal: BunTerminal | null = null + let processExitCode: number | undefined + let terminalFinished = false + let clearInFlight: Promise | null = null + const dataListeners = new Set<(data: string) => void>() + const exitListeners = new Set<(event: { exitCode: number; signal?: number }) => void>() + const decoder = new TextDecoder() + let pendingData = '' + let exited = false + let exitCode = 0 + let exitSignal: number | undefined + // Keep a closed Bun native handle from escaping as a daemon RPC failure. + let terminalUnavailable = false + let appliedCols = args.cols + let appliedRows = args.rows + + const emitData = (data: string): void => { + if (dataListeners.size === 0) { + pendingData = (pendingData + data).slice(-512 * 1024) + return + } + for (const listener of dataListeners) { + listener(data) + } + } + const onProcessExit = (code: number): void => { + processExitCode = code + windowsLaunch?.dispose() + if (!windowsTerminal || terminalFinished) { + emitExit(code) + return + } + // ConPTY closes off-thread; retain listeners until its final frame reaches EOF. + if (!windowsTerminal.closed) { + windowsTerminal.close() + } + } + + const emitExit = (code: number): void => { + if (exited) { + return + } + exited = true + producerFlowControl.resumeForShutdown() + windowsLaunch?.readShellProcessId() + exitCode = code + exitSignal = Object.entries(constants.signals).find( + ([name]) => name === processHandle.signalCode + )?.[1] + const pending = decoder.decode() + if (pending) { + emitData(pending) + } + for (const dispose of [ + () => (processHandle.terminal.closed ? undefined : processHandle.terminal.close()), + () => windowsJob?.close() + ]) { + try { + dispose() + } catch (error) { + console.warn('[daemon/pty] PTY cleanup failed:', error) + } + } + for (const listener of exitListeners) { + listener({ exitCode: code, ...(exitSignal === undefined ? {} : { signal: exitSignal }) }) + } + dataListeners.clear() + exitListeners.clear() + } + + if (platform === 'win32') { + if (!(deps.assignHostJob ?? assignCurrentProcessToBunPtyHostJob)()) { + throw new Error('Windows Bun PTY host crash ownership is unavailable') + } + windowsLaunch = (deps.createWindowsLaunch ?? createWindowsBunPtyLaunch)(args) + } + try { + const terminalOptions: BunTerminalOptions = { + cols: args.cols, + rows: args.rows, + name: args.env.TERM ?? 'xterm-256color', + data: (_terminal, data) => { + const decoded = decoder.decode(data, { stream: true }) + if (decoded) { + emitData(decoded) + } + }, + exit() { + terminalFinished = true + if (processExitCode !== undefined) { + emitExit(processExitCode) + } + } + } + // Inline Bun terminals cannot be reused by the Windows clear command. + if (windowsLaunch) { + windowsTerminal = new runtime.Terminal(terminalOptions) + } + processHandle = runtime.spawn(windowsLaunch?.command ?? [args.file, ...args.args], { + cwd: args.cwd, + env: windowsLaunch?.env ?? args.env, + ...(windowsLaunch + ? { + windowsVerbatimArguments: windowsLaunch.windowsVerbatimArguments + } + : {}), + terminal: windowsTerminal ?? terminalOptions + }) + } catch (error) { + windowsTerminal?.close() + windowsLaunch?.dispose() + throw error + } + if (windowsLaunch) { + try { + windowsJob = (deps.createJob ?? createWindowsBunPtyJob)(processHandle.pid) + if (!windowsJob) { + throw new Error('Windows Bun PTY job ownership is unavailable') + } + windowsLaunch.release() + } catch (error) { + windowsJob?.terminate() + try { + processHandle.kill('SIGTERM') + } catch { + // The failed gate release still owns cleanup through the job when available. + } + if (!processHandle.terminal.closed) { + processHandle.terminal.close() + } + windowsJob?.close() + windowsLaunch.dispose() + // A running gate can temporarily lock its private working directory on Windows. + const disposeLaunch = (): void => windowsLaunch?.dispose() + void processHandle.exited.then(disposeLaunch, disposeLaunch) + throw error + } + } + void processHandle.exited.then(onProcessExit, () => onProcessExit(1)) + + const producerFlowControl = createBunPtyProducerFlowControl({ + platform, + processHandle, + windowsJob, + isExited: () => exited, + ...(deps.readProcessTable ? { readProcessTable: deps.readProcessTable } : {}), + ...(deps.signalProcessGroup ? { signalProcessGroup: deps.signalProcessGroup } : {}) + }) + + const windowsCapabilities = windowsJob + ? { + waitForSpawn: () => windowsLaunch?.waitForSpawn(processHandle.exited) ?? Promise.resolve(), + terminateOwnedTree: () => windowsJob?.terminate() ?? 'unavailable', + listOwnedProcessIds: () => windowsJob?.listProcessIds() ?? null, + jobRootProcessIsWrapper: true as const, + signalProcess(signal: string) { + if (signal === 'SIGWINCH') { + return + } + if (windowsJob?.terminate() === 'terminated') { + return + } + try { + processHandle.kill(signal) + } finally { + if (!processHandle.terminal.closed) { + processHandle.terminal.close() + } + } + } + } + : {} + + const clearCapability = windowsLaunch + ? { + clear() { + if (exited || clearInFlight) { + return + } + try { + const clearProcess = runtime.spawn(windowsLaunch.clearCommand, { + cwd: args.cwd, + env: args.env, + terminal: processHandle.terminal, + windowsVerbatimArguments: true + }) + clearInFlight = clearProcess.exited + const settled = (): void => { + clearInFlight = null + } + void clearInFlight.then(settled, settled) + } catch { + clearInFlight = null + } + } + } + : {} + + const terminate = (signal: string): void => { + producerFlowControl.resumeForShutdown() + const treeTerminated = windowsJob?.terminate() === 'terminated' + try { + processHandle.kill(signal) + } catch (error) { + if (!treeTerminated) { + throw error + } + } + } + + return { + pid: processHandle.pid, + get shellProcessId() { + return windowsLaunch?.readShellProcessId() + }, + handleFlowControl: false, + processNameIsSpawnFile: true, + clear() {}, + process: args.file, + get cols() { + return appliedCols + }, + get rows() { + return appliedRows + }, + onData(listener) { + if (pendingData) { + const data = pendingData + pendingData = '' + listener(data) + } + if (exited) { + return { dispose() {} } + } + dataListeners.add(listener) + return { dispose: () => dataListeners.delete(listener) } + }, + onExit(listener) { + if (exited) { + listener({ exitCode, ...(exitSignal === undefined ? {} : { signal: exitSignal }) }) + return { dispose() {} } + } + exitListeners.add(listener) + return { dispose: () => exitListeners.delete(listener) } + }, + write(data) { + if (exited || terminalUnavailable || processHandle.terminal.closed) { + return + } + try { + processHandle.terminal.write(data) + } catch { + terminalUnavailable = true + } + }, + resize(cols, rows) { + if (exited || terminalUnavailable || processHandle.terminal.closed) { + return + } + try { + processHandle.terminal.resize(cols, rows) + appliedCols = cols + appliedRows = rows + } catch { + terminalUnavailable = true + } + }, + ...clearCapability, + ...producerFlowControl, + ...windowsCapabilities, + // Interactive POSIX shells ignore SIGTERM. + kill(signal = platform === 'win32' ? 'SIGTERM' : 'SIGHUP') { + if (!exited) { + terminate(signal) + } + }, + destroy() { + if (!exited) { + terminate(platform === 'win32' ? 'SIGTERM' : 'SIGHUP') + } + if (!processHandle.terminal.closed) { + processHandle.terminal.close() + } + } + } +} diff --git a/src/main/daemon/pty-subprocess/bun-pty-process-suspension.test.ts b/src/main/daemon/pty-subprocess/bun-pty-process-suspension.test.ts new file mode 100644 index 00000000000..da7bf0c09e3 --- /dev/null +++ b/src/main/daemon/pty-subprocess/bun-pty-process-suspension.test.ts @@ -0,0 +1,214 @@ +import { constants } from 'node:os' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createBunPtyProducerFlowControl } from './bun-pty-process-flow-control' + +const TABLE = '4321 4321 pts/test T\n4322 4322 pts/test S\n4323 4323 pts/test T' +const settled = (): Promise => new Promise((resolve) => setImmediate(resolve)) + +function harness(platform: NodeJS.Platform = 'linux') { + let exited = false + const kill = vi.fn() + const signalProcessGroup = vi.fn() + const readProcessTableAsync = vi.fn(async () => TABLE) + const windowsJob = { + listProcessIds: () => [], + pause: vi.fn(() => true), + resume: vi.fn(() => true), + terminate: () => 'terminated' as const, + close() {} + } + const flow = createBunPtyProducerFlowControl({ + platform, + processHandle: { pid: 4321, kill, terminal: { closed: false, close() {} } }, + windowsJob, + isExited: () => exited, + readProcessTable: () => TABLE, + readProcessTableAsync, + signalProcessGroup + }) + return { + flow, + kill, + signalProcessGroup, + readProcessTableAsync, + windowsJob, + exit: () => (exited = true) + } +} + +afterEach(() => vi.useRealTimers()) + +describe('flow-control suspension ownership', () => { + it.each(['resume', 'shutdown'] as const)( + 'preserves a Ctrl-Z stopped job during %s', + async (action) => { + const h = harness() + h.flow.pause() + await settled() + h.readProcessTableAsync.mockResolvedValue(TABLE.replace('4322 pts/test S', '4322 pts/test T')) + if (action === 'resume') { + h.flow.resume() + } else { + h.flow.resumeForShutdown() + } + await settled() + expect(h.signalProcessGroup.mock.calls).toEqual([ + [4321, 'SIGSTOP'], + [4322, 'SIGSTOP'], + [4322, 'SIGCONT'], + [4321, 'SIGCONT'] + ]) + } + ) + + it('does not resume a group it already released when another group needs a retry', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const h = harness() + h.readProcessTableAsync.mockResolvedValue(TABLE.replace('4323 pts/test T', '4323 pts/test S')) + let failed = false + h.signalProcessGroup.mockImplementation((pgid, signal) => { + if (pgid === 4323 && signal === 'SIGCONT' && !failed) { + failed = true + throw new Error('temporary resume failure') + } + }) + h.flow.pause() + await settled() + h.flow.resume() + await settled() + // The user can suspend a job again after its first successful resume. + h.readProcessTableAsync.mockResolvedValue(TABLE.replace('4322 pts/test S', '4322 pts/test T')) + await vi.advanceTimersByTimeAsync(500) + expect( + h.signalProcessGroup.mock.calls.filter( + ([pid, signal]) => pid === 4322 && signal === 'SIGCONT' + ) + ).toHaveLength(1) + expect(h.signalProcessGroup).toHaveBeenLastCalledWith(4321, 'SIGCONT') + expect(vi.getTimerCount()).toBe(0) + }) + + it.each(['EPERM', 'EACCES'])('does not retry a root pause denied with %s', async (code) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const h = harness() + h.kill.mockImplementationOnce(() => { + throw Object.assign(new Error('denied'), { code }) + }) + h.flow.pause() + await settled() + h.flow.pause() + await vi.advanceTimersByTimeAsync(30_000) + expect(h.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]]) + expect(h.readProcessTableAsync).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + h.flow.resume() + h.flow.pause() + await settled() + expect(h.kill).toHaveBeenCalledTimes(2) + h.flow.resumeForShutdown() + }) + + it('rolls back acquired stops after a denied job pause without repeatedly scanning or resuming the denied job', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const h = harness() + h.readProcessTableAsync.mockResolvedValue(`${TABLE}\n4324 4324 pts/test S`) + h.signalProcessGroup.mockImplementation((pgid, signal) => { + if (pgid === 4324 && signal === 'SIGSTOP') { + throw Object.assign(new Error('denied'), { code: 'EPERM' }) + } + }) + h.flow.pause() + await settled() + await vi.advanceTimersByTimeAsync(30_000) + expect(h.signalProcessGroup.mock.calls).toEqual([ + [4321, 'SIGSTOP'], + [4322, 'SIGSTOP'], + [4324, 'SIGSTOP'], + [4322, 'SIGCONT'], + [4321, 'SIGCONT'] + ]) + expect(h.readProcessTableAsync).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(0) + }) + + it('retains the resume obligation when rollback after a denied pause also fails', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const h = harness() + let failed = false + h.signalProcessGroup.mockImplementation((pgid, signal) => { + if (pgid === 4322 && signal === 'SIGSTOP') { + throw Object.assign(new Error('denied'), { code: 'EPERM' }) + } + if (pgid === 4321 && signal === 'SIGCONT' && !failed) { + failed = true + throw Object.assign(new Error('resume denied'), { code: 'EPERM' }) + } + }) + h.flow.pause() + await settled() + expect(vi.getTimerCount()).toBe(1) + await vi.advanceTimersByTimeAsync(500) + expect(h.signalProcessGroup.mock.calls).toEqual([ + [4321, 'SIGSTOP'], + [4322, 'SIGSTOP'], + [4321, 'SIGCONT'], + [4321, 'SIGCONT'] + ]) + expect(vi.getTimerCount()).toBe(0) + }) +}) + +describe('Windows resume retries', () => { + it('releases a failed partial pause without repeatedly attempting the denied pause', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const h = harness('win32') + h.windowsJob.pause.mockReturnValueOnce(false) + h.windowsJob.resume.mockReturnValueOnce(false) + h.flow.pause() + await vi.advanceTimersByTimeAsync(500) + expect(h.windowsJob.resume).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(500) + expect(h.windowsJob.resume).toHaveBeenCalledTimes(2) + expect(h.windowsJob.pause).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + h.flow.pause() + expect(h.windowsJob.pause).toHaveBeenCalledTimes(2) + h.flow.resumeForShutdown() + }) + + it('retries a failed resume without another caller transition', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const h = harness('win32') + h.windowsJob.resume.mockReturnValueOnce(false) + h.flow.pause() + h.flow.resume() + expect(h.windowsJob.resume).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(500) + expect(h.windowsJob.resume).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(0) + expect(h.readProcessTableAsync).not.toHaveBeenCalled() + }) + + it.each(['pause', 'shutdown', 'exit'] as const)( + 'cancels stale resume retries after %s', + async (action) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const h = harness('win32') + h.windowsJob.resume.mockReturnValue(false) + h.flow.pause() + h.flow.resume() + if (action === 'pause') { + h.flow.pause() + } else { + if (action === 'exit') { + h.exit() + } + h.flow.resumeForShutdown() + } + const resumes = h.windowsJob.resume.mock.calls.length + await vi.advanceTimersByTimeAsync(5_000) + expect(h.windowsJob.resume).toHaveBeenCalledTimes(resumes) + expect(vi.getTimerCount()).toBe(0) + } + ) +}) diff --git a/src/main/daemon/pty-subprocess/bun-pty-process-suspension.ts b/src/main/daemon/pty-subprocess/bun-pty-process-suspension.ts new file mode 100644 index 00000000000..3fc20931d5c --- /dev/null +++ b/src/main/daemon/pty-subprocess/bun-pty-process-suspension.ts @@ -0,0 +1,78 @@ +import { + getPosixPtyStoppedJobGroups, + signalPosixPtyProcessGroups +} from '../../pty/posix-pty-process-groups' + +export function createBunPtyProcessSuspension(options: { + pid: number + platform: NodeJS.Platform + signalRoot: (signal: 'SIGSTOP' | 'SIGCONT') => void + readProcessTable?: () => string + signalProcessGroup?: (pgid: number, signal: NodeJS.Signals) => void +}) { + const stoppedGroups = new Set() + return { + hasStoppedGroups: () => stoppedGroups.size > 0, + signal(signal: 'SIGSTOP' | 'SIGCONT', table?: string, requireGroups = false): void { + const alreadyStopped = + signal === 'SIGSTOP' && table !== undefined + ? getPosixPtyStoppedJobGroups(table, options.pid) + : new Set() + let resumeFailed = false + signalPosixPtyProcessGroups( + options.pid, + signal, + () => { + if (requireGroups) { + throw new Error('Paused PTY group ownership is unavailable') + } + options.signalRoot(signal) + }, + { + platform: options.platform, + ...(table !== undefined + ? { readProcessTable: () => table } + : options.readProcessTable + ? { readProcessTable: options.readProcessTable } + : {}), + signalProcessGroup(pgid) { + if ( + signal === 'SIGSTOP' + ? alreadyStopped.has(pgid) && !stoppedGroups.has(pgid) + : !stoppedGroups.has(pgid) + ) { + return + } + // Keep the shell stopped until every preceding job group has resumed. + if (signal === 'SIGCONT' && requireGroups && resumeFailed) { + throw new Error('An earlier PTY group could not be resumed') + } + try { + if (options.signalProcessGroup) { + options.signalProcessGroup(pgid, signal) + } else { + process.kill(-pgid, signal) + } + } catch (error) { + const gone = error instanceof Error && 'code' in error && error.code === 'ESRCH' + if (gone) { + stoppedGroups.delete(pgid) + } + resumeFailed = !gone + throw error + } + if (signal === 'SIGSTOP') { + stoppedGroups.add(pgid) + } else { + stoppedGroups.delete(pgid) + } + } + } + ) + if (signal === 'SIGCONT') { + // A fresh successful scan also retires groups that no longer belong to this terminal. + stoppedGroups.clear() + } + } + } +} diff --git a/src/main/daemon/pty-subprocess/bun-pty-process.integration.test.ts b/src/main/daemon/pty-subprocess/bun-pty-process.integration.test.ts new file mode 100644 index 00000000000..1c4f0436d6f --- /dev/null +++ b/src/main/daemon/pty-subprocess/bun-pty-process.integration.test.ts @@ -0,0 +1,298 @@ +import { existsSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { runProcess, runProcessSync } from '../../../shared/child-process/run-process' +import { orcadBunRuntimeFilename } from '../../../shared/orcad-artifacts' +import { ORCAD_BUN_VERSION } from '../../../shared/orcad-bun-runtime' +import { removeTreeSync } from '../../../shared/windows-transient-lock-removal' + +const runtimePath = + process.env.BUN_EXECUTABLE ?? + resolve(__dirname, '../../../../out/orcad', orcadBunRuntimeFilename(process.platform)) + +async function runTerminalScript(script: string): Promise { + expect(runProcessSync({ program: runtimePath, args: ['--version'] }).stdout.trim()).toBe( + ORCAD_BUN_VERSION + ) + const directory = mkdtempSync(join(tmpdir(), 'orca-bun-terminal-')) + try { + const entry = join(directory, 'terminal.cjs') + writeFileSync( + entry, + [ + `const {spawnBunPty} = require(${JSON.stringify(join(__dirname, 'bun-pty-process.ts'))})`, + `const args = {file: process.execPath, cwd: ${JSON.stringify(directory)}, env: process.env, cols: 80, rows: 24}`, + script + ].join('\n') + ) + const result = await runProcess({ program: runtimePath, args: [entry], timeoutMs: 30_000 }) + expect(result.timedOut).toBe(false) + expect(result.code, result.stderr).toBe(0) + return JSON.parse(result.stdout) + } finally { + removeTreeSync(directory) + } +} + +describe.skipIf(!existsSync(runtimePath) || process.platform === 'win32')( + 'real Bun terminal', + () => { + it('drains multi-byte output before publishing process exit and applies resize', async () => { + const result = await runTerminalScript(` + const expected = '⌘状態'.repeat(200_000) + const proc = spawnBunPty({...args, args: ['-e', 'process.stdout.write("⌘状態".repeat(200000));process.exitCode=17']}) + let output = '' + proc.resize(103, 37) + proc.onData(data => output += data) + proc.onExit(event => { + console.log(JSON.stringify({event, exact: output === expected, cols:proc.cols, rows:proc.rows})) + proc.destroy() + }) + `) + expect(result).toEqual({ event: { exitCode: 17 }, exact: true, cols: 103, rows: 37 }) + }) + + it('receives the real shell identity from a gated Bun subprocess', async () => { + const result = await runTerminalScript(` + const {createWindowsBunPtyLaunch} = require(${JSON.stringify(join(__dirname, 'windows-bun-pty-launch.ts'))}) + const proc = spawnBunPty({...args,args:['-e','setTimeout(()=>{process.exitCode=17},100)']}, { + platform:'win32', assignHostJob:()=>true, + createJob:()=>({listProcessIds:()=>[], pause:()=>true,resume:()=>true,terminate:()=> 'terminated',close(){}}), + createWindowsLaunch:launch => createWindowsBunPtyLaunch(launch, { + runtimePath:process.execPath,workerPath:${JSON.stringify(join(__dirname, 'windows-bun-pty-gate-entry.ts'))} + }) + }) + proc.onExit(event => { + console.log(JSON.stringify({event, shellIdentified:proc.shellProcessId>0 && proc.shellProcessId!==proc.pid})) + proc.destroy() + }) + `) + expect(result).toEqual({ event: { exitCode: 17 }, shellIdentified: true }) + }) + + it('reports signal termination distinctly from an ordinary exit', async () => { + const result = await runTerminalScript(` + const proc = spawnBunPty({...args,args:['-e', 'console.log("ready");setInterval(()=>{},1000)']}) + proc.onData(() => proc.kill('SIGTERM')) + proc.onExit(event => { console.log(JSON.stringify(event));proc.destroy() }) + `) + expect(result).toEqual({ exitCode: 143, signal: 15 }) + }) + + it('pauses and resumes the owned process when process discovery is unavailable', async () => { + const result = await runTerminalScript(` + const expected = 'ready' + 'x'.repeat(1024 * 1024) + const continueOutput = require('node:path').join(args.cwd, 'continue-output') + const proc = spawnBunPty({...args,env:{...args.env,ORCA_TEST_CONTINUE:continueOutput},args:['-e','process.stdout.write("ready");const timer=setInterval(()=>{if(!require("node:fs").existsSync(process.env.ORCA_TEST_CONTINUE))return;clearInterval(timer);process.stdout.write("x".repeat(1024*1024))},1)']},{readProcessTable:()=>''}) + let output = '', paused = false, stable = false + proc.onData(data => { + output += data + if (paused) return + paused = true + proc.pause() + setTimeout(() => { + const settled = output.length + require('node:fs').writeFileSync(continueOutput, 'continue') + setTimeout(() => { stable = output.length === settled;proc.resume() }, 150) + }, 150) + }) + proc.onExit(event => { + console.log(JSON.stringify({event,stable,exact:output===expected})) + proc.destroy() + }) + `) + expect(result).toEqual({ event: { exitCode: 0 }, stable: true, exact: true }) + }) + + it.skipIf(!existsSync('/bin/bash')).each([ + [false, 0], + [false, 100], + [true, 0], + [true, 100] + ] as const)( + 'stops foreground and background floods without losing output (resume failure: %s, signal gap: %sms)', + async (rejectFirstResume, stopSignalGapMs) => { + const result = await runTerminalScript(` + const expected = 16 * 1024 * 1024 + const {join} = require('node:path') + const {writeFileSync,existsSync} = require('node:fs') + const producer = join(args.cwd, 'producer.cjs') + const backgroundReady = join(args.cwd, 'background-ready') + const foregroundReady = join(args.cwd, 'foreground-ready') + const go = join(args.cwd, 'go') + const continueOutput = join(args.cwd, 'continue-output') + writeFileSync(producer, [ + 'const {writeFileSync,existsSync}=require("node:fs")', + 'writeFileSync(process.argv[2],"ready")', + 'const deadline=setTimeout(()=>process.exit(97),10000)', + 'const ready=setInterval(()=>{if(!existsSync(process.argv[3]))return;clearInterval(ready);process.stdout.write("x".repeat(65536));const continued=setInterval(()=>{if(!existsSync(process.argv[4]))return;clearInterval(continued);clearTimeout(deadline);let count=1;const timer=setInterval(()=>{process.stdout.write("x".repeat(65536));if(++count===128)clearInterval(timer)},1)},1)},1)' + ].join(';')) + const groups = new Set() + let bytes = 0, paused = false, settledBytes = 0, stable = false, verifying = false, rejectedResume = false + const proc = spawnBunPty({ + ...args, file:'/bin/bash', + args:['--noprofile','--norc','-i','-c','exec 2>/dev/null; "$ORCA_TEST_RUNTIME" "$ORCA_TEST_PRODUCER" "$ORCA_TEST_BACKGROUND_READY" "$ORCA_TEST_GO" "$ORCA_TEST_CONTINUE" & "$ORCA_TEST_RUNTIME" "$ORCA_TEST_PRODUCER" "$ORCA_TEST_FOREGROUND_READY" "$ORCA_TEST_GO" "$ORCA_TEST_CONTINUE"; wait'], + env:{...args.env,ORCA_TEST_RUNTIME:process.execPath,ORCA_TEST_PRODUCER:producer,ORCA_TEST_BACKGROUND_READY:backgroundReady,ORCA_TEST_FOREGROUND_READY:foregroundReady,ORCA_TEST_GO:go,ORCA_TEST_CONTINUE:continueOutput} + },{signalProcessGroup:(pgid,signal)=>{ + if (signal === 'SIGCONT' && ${rejectFirstResume} && !rejectedResume) { + rejectedResume = true + throw Object.assign(new Error('transient resume failure'), {code:'EPERM'}) + } + process.kill(-pgid,signal) + if (signal === 'SIGSTOP') { + groups.add(pgid) + // Give Bash time to react between signals; stopping its jobs first can end its wait. + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ${stopSignalGapMs}) + } + if (groups.size < 3 || verifying) return + verifying = true + writeFileSync(continueOutput, 'continue') + setTimeout(() => { + settledBytes = bytes + setTimeout(() => { stable = bytes === settledBytes;proc.resume() }, 150) + },150) + }}) + const ready = setInterval(() => { + if (!existsSync(backgroundReady) || !existsSync(foregroundReady)) return + clearInterval(ready) + writeFileSync(go, 'go') + }, 5) + let beats = 0 + const heartbeat = setInterval(() => beats++, 5) + proc.onData(data => { + bytes += data.length + // Drain both initial writes before measuring whether stopped producers emit more. + if (!paused && bytes === 2 * 65536) { + paused = true + proc.pause() + } + }) + proc.onExit(event => { + clearInterval(ready) + clearInterval(heartbeat) + console.log(JSON.stringify({event,stable,exact:bytes===expected,pausedBeforeExit:settledBytes10,jobControlGroups:groups.size>=3,rejectedResume})) + proc.destroy() + }) + `) + expect(result).toEqual({ + event: { exitCode: 0 }, + stable: true, + exact: true, + pausedBeforeExit: true, + responsive: true, + jobControlGroups: true, + rejectedResume: rejectFirstResume + }) + } + ) + } +) + +describe.skipIf(!existsSync(runtimePath) || process.platform !== 'win32')( + 'native Windows Bun terminal', + () => { + it('falls back after actual shell spawn rejection and cleans each private launch directory', async () => { + const result = await runTerminalScript(` + const {spawnNativeDaemonPty} = require(${JSON.stringify(join(__dirname, 'native-pty-spawn.ts'))}) + const {createWindowsBunPtyLaunch} = require(${JSON.stringify(join(__dirname, 'windows-bun-pty-launch.ts'))}) + const {existsSync} = require('node:fs') + const {dirname,join} = require('node:path') + const directories = [] + const attempts = [join(args.cwd,'missing-pwsh.exe'),join(args.cwd,'missing-powershell.exe'),process.execPath].map(shellPath=>({ + shellPath,shellArgs:['-e','process.exitCode=17'],effectiveCwd:args.cwd,validationCwd:args.cwd,startupCommandDeliveredInShellArgs:true + })) + spawnNativeDaemonPty({ + shellPath:attempts[0].shellPath,shellArgs:attempts[0].shellArgs,spawnCwd:args.cwd, + env:args.env,cols:80,rows:24,windowsFallbackAttempts:attempts + }, {canUseBunPty:()=>true, spawnBunPty:options=>spawnBunPty(options, { + createWindowsLaunch:launchArgs=>{ + const launch = createWindowsBunPtyLaunch(launchArgs, { + runtimePath:process.execPath,workerPath:${JSON.stringify(join(__dirname, 'windows-bun-pty-gate-entry.ts'))} + }) + directories.push(dirname(launch.command.at(-1))) + return launch + } + })}).then(({process:proc,shellPath})=>{ + proc.onExit(event=>{ + console.log(JSON.stringify({event,fallback:shellPath===process.execPath,attempts:directories.length,cleaned:directories.every(path=>!existsSync(path))})) + proc.destroy() + }) + }).catch(error=>{console.error(error);process.exitCode=1}) + `) + expect(result).toEqual({ + event: { exitCode: 17 }, + fallback: true, + attempts: 3, + cleaned: true + }) + }, 35_000) + + it('enumerates and suspends a native job with more than 64 processes', async () => { + const result = await runTerminalScript(` + const {createWindowsBunPtyLaunch} = require(${JSON.stringify(join(__dirname, 'windows-bun-pty-launch.ts'))}) + const script = 'for(let i=0;i<65;i++)Bun.spawn([process.execPath,"-e","setInterval(()=>{},1000)"],{stdin:"ignore",stdout:"ignore",stderr:"ignore"});setInterval(()=>console.log("tick"),10)' + const proc = spawnBunPty({...args,args:['-e',script]}, { + createWindowsLaunch:launch => createWindowsBunPtyLaunch(launch, { + runtimePath:process.execPath,workerPath:${JSON.stringify(join(__dirname, 'windows-bun-pty-gate-entry.ts'))} + }) + }) + let bytes=0,started=false,evidence + proc.onData(data=>{ + bytes+=data.length + if(started || !data.includes('tick'))return + const members=proc.listOwnedProcessIds() + if(!members || members.length<67)return + started=true + proc.pause() + setTimeout(()=>{ + const pausedBytes=bytes + setTimeout(()=>{ + const stopped=bytes===pausedBytes + proc.resume() + setTimeout(()=>{ + evidence={members:members.length,stopped,resumed:bytes>pausedBytes} + proc.kill() + },150) + },150) + },150) + }) + proc.onExit(()=>{ + console.log(JSON.stringify(evidence)) + proc.destroy() + }) + `) + expect(result).toEqual({ members: 67, stopped: true, resumed: true }) + }, 35_000) + + it('opens ConPTY without IPC and identifies the shell inside its job', async () => { + const result = await runTerminalScript(` + const {createWindowsBunPtyLaunch} = require(${JSON.stringify(join(__dirname, 'windows-bun-pty-launch.ts'))}) + const proc = spawnBunPty({...args,args:['-e','console.log("ready");setInterval(()=>{},1000)']}, { + createWindowsLaunch:launch => createWindowsBunPtyLaunch(launch, { + runtimePath:process.execPath,workerPath:${JSON.stringify(join(__dirname, 'windows-bun-pty-gate-entry.ts'))} + }) + }) + let output = '', evidence + proc.onData(data => output += data) + const timer = setInterval(() => { + const shell = proc.shellProcessId + const members = proc.listOwnedProcessIds() + if (!output.includes('ready') || !shell || !members?.includes(shell)) return + clearInterval(timer) + evidence = {distinctShell:shell!==proc.pid, gateOwned:members.includes(proc.pid), shellOwned:true} + proc.kill() + }, 10) + proc.onExit(event => { + clearInterval(timer) + console.log(JSON.stringify({evidence, exited:event.exitCode!==undefined})) + proc.destroy() + }) + `) + expect(result).toEqual({ + evidence: { distinctShell: true, gateOwned: true, shellOwned: true }, + exited: true + }) + }) + } +) diff --git a/src/main/daemon/pty-subprocess/bun-pty-process.test.ts b/src/main/daemon/pty-subprocess/bun-pty-process.test.ts new file mode 100644 index 00000000000..6726524f53d --- /dev/null +++ b/src/main/daemon/pty-subprocess/bun-pty-process.test.ts @@ -0,0 +1,652 @@ +import { constants } from 'node:os' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { canUseBunPty, spawnBunPty } from './bun-pty-process' +import type { BunRuntime, BunTerminalOptions } from './bun-pty-process-contract' +import { readWindowsPtyJobProcessIds } from '../../providers/windows-pty-job-membership' +import * as posixPtyGroups from '../../pty/posix-pty-process-groups' + +type FakeTerminal = { + closed: boolean + write(data: string | ArrayBufferView): number + resize(cols: number, rows: number): void + close(): void +} + +let testRuntime: NonNullable[1]>['runtime'] + +function createBunHarness({ closeImmediately = true } = {}) { + let resolveExit: (code: number) => void = () => {} + let windowsTerminalOptions: BunTerminalOptions | undefined + const terminal: FakeTerminal = { + closed: false, + write: vi.fn(() => 1), + resize: vi.fn(), + close: vi.fn(function (this: FakeTerminal) { + this.closed = true + if (closeImmediately) { + windowsTerminalOptions?.exit?.(terminal, 0, null) + } + }) + } + const processHandle = { + pid: 4321, + terminal, + kill: vi.fn(), + exited: new Promise((resolve) => { + resolveExit = resolve + }) + } + const spawn = vi.fn( + (_command: string[], _options: Parameters[1]) => processHandle + ) + testRuntime = { + Terminal: class { + closed = false + write = terminal.write + resize = terminal.resize + close = terminal.close + constructor(options: BunTerminalOptions) { + windowsTerminalOptions = options + return terminal + } + }, + spawn + } + const emitData = (data: Uint8Array): void => { + const options = spawn.mock.calls[0]?.[1] + const callbacks = + windowsTerminalOptions ?? + (options && 'data' in options.terminal ? options.terminal : undefined) + if (!callbacks) { + throw new Error('missing terminal callbacks') + } + callbacks.data(terminal, data) + } + return { + processHandle, + resolveExit, + spawn, + terminal, + emitData, + finishTerminal: () => windowsTerminalOptions?.exit?.(terminal, 0, null) + } +} + +function spawn(deps?: Parameters[1]) { + return spawnBunPty( + { + file: '/bin/sh', + args: ['-l'], + cwd: '/tmp', + env: { TERM: 'xterm-256color' }, + cols: 80, + rows: 24 + }, + { platform: 'linux', runtime: testRuntime, ...deps } + ) +} + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + testRuntime = undefined +}) + +describe('Bun.Terminal PTY adapter', () => { + it('cancels a pending ownership lookup on natural exit without delivering a late stop', async () => { + const harness = createBunHarness() + let finishRead: (table: string) => void = () => {} + const read = vi.spyOn(posixPtyGroups, 'readPosixPtyProcessTable').mockImplementation( + () => + new Promise((resolve) => { + finishRead = resolve + }) + ) + const signalProcessGroup = vi.fn() + const proc = spawn({ signalProcessGroup }) + proc.pause() + await new Promise((resolve) => setImmediate(resolve)) + const signal = read.mock.calls[0][1] + expect(signal?.aborted).toBe(false) + harness.resolveExit(0) + await harness.processHandle.exited + expect(signal?.aborted).toBe(true) + finishRead('4321 4321 pts/test T\n4322 4322 pts/test') + await new Promise((resolve) => setImmediate(resolve)) + expect(signalProcessGroup).not.toHaveBeenCalled() + expect(harness.processHandle.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]]) + }) + + it('cancels a queued resume retry immediately on natural exit', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const harness = createBunHarness() + const read = vi + .spyOn(posixPtyGroups, 'readPosixPtyProcessTable') + .mockResolvedValueOnce('4321 4321 pts/test T\n4322 4322 pts/test') + .mockRejectedValueOnce(new Error('temporary ps failure')) + const signalProcessGroup = vi.fn() + const proc = spawn({ signalProcessGroup }) + proc.pause() + await new Promise((resolve) => setImmediate(resolve)) + proc.resume() + await new Promise((resolve) => setImmediate(resolve)) + expect(vi.getTimerCount()).toBe(1) + harness.resolveExit(0) + await harness.processHandle.exited + expect(vi.getTimerCount()).toBe(0) + await vi.advanceTimersByTimeAsync(5_000) + expect(read).toHaveBeenCalledTimes(2) + expect(signalProcessGroup.mock.calls).toEqual([ + [4321, 'SIGSTOP'], + [4322, 'SIGSTOP'] + ]) + expect(harness.processHandle.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]]) + }) + + it('exposes initial and successfully applied dimensions for terminal inspection', () => { + const harness = createBunHarness() + const proc = spawn() + expect({ cols: proc.cols, rows: proc.rows }).toEqual({ cols: 80, rows: 24 }) + proc.resize(103, 37) + expect(harness.terminal.resize).toHaveBeenCalledWith(103, 37) + expect({ cols: proc.cols, rows: proc.rows }).toEqual({ cols: 103, rows: 37 }) + }) + + it.each(['closed', 'exited', 'failed'] as const)( + 'retains last applied dimensions when resize is %s', + async (reason) => { + const harness = createBunHarness() + const proc = spawn() + proc.resize(103, 37) + if (reason === 'closed') { + harness.terminal.closed = true + } + if (reason === 'exited') { + harness.resolveExit(0) + await harness.processHandle.exited + } + if (reason === 'failed') { + vi.mocked(harness.terminal.resize).mockImplementationOnce(() => { + throw new Error('closed') + }) + } + proc.resize(120, 40) + expect({ cols: proc.cols, rows: proc.rows }).toEqual({ cols: 103, rows: 37 }) + } + ) + + it('requires Bun.Terminal as well as Bun.spawn', () => { + const spawn = vi.fn() + expect(canUseBunPty({ spawn })).toBe(false) + expect(canUseBunPty({ Terminal: class {}, spawn })).toBe(true) + }) + + it('streams split UTF-8 and reports exit to current and late listeners', async () => { + const harness = createBunHarness() + const proc = spawn() + const onData = vi.fn() + const onExit = vi.fn() + proc.onData(onData) + proc.onExit(onExit) + + const bytes = new TextEncoder().encode('⌘状') + harness.emitData(bytes.slice(0, 2)) + expect(onData).not.toHaveBeenCalled() + harness.emitData(bytes.slice(2)) + expect(onData).toHaveBeenCalledWith('⌘状') + + harness.resolveExit(7) + await harness.processHandle.exited + await Promise.resolve() + expect(onExit).toHaveBeenCalledWith({ exitCode: 7 }) + + const lateExit = vi.fn() + proc.onExit(lateExit) + expect(lateExit).toHaveBeenCalledWith({ exitCode: 7 }) + }) + + it('preserves output arriving before the first data listener', () => { + const harness = createBunHarness() + const proc = spawn() + harness.emitData(new TextEncoder().encode('startup output')) + const listener = vi.fn() + proc.onData(listener) + expect(listener).toHaveBeenCalledWith('startup output') + }) + + it('preserves signal termination and never signals the exited handle during disposal', async () => { + const harness = createBunHarness() + const proc = spawn() + Object.assign(harness.processHandle, { signalCode: 'SIGTERM' }) + harness.resolveExit(143) + await harness.processHandle.exited + await Promise.resolve() + const listener = vi.fn() + proc.onExit(listener) + proc.destroy() + expect(listener).toHaveBeenCalledWith({ exitCode: 143, signal: 15 }) + expect(harness.processHandle.kill).not.toHaveBeenCalled() + expect(harness.terminal.close).toHaveBeenCalledOnce() + }) + + it('disposes data and exit listeners without retaining them', async () => { + const harness = createBunHarness() + const proc = spawn() + const onData = vi.fn() + const onExit = vi.fn() + const dataSubscription = proc.onData(onData) + const exitSubscription = proc.onExit(onExit) + + dataSubscription.dispose() + exitSubscription.dispose() + harness.emitData(new TextEncoder().encode('ignored')) + harness.resolveExit(0) + await harness.processHandle.exited + await Promise.resolve() + + expect(onData).not.toHaveBeenCalled() + expect(onExit).not.toHaveBeenCalled() + }) + + it.each(['darwin', 'linux'] as const)( + 'forwards input, resize, hangup, explicit signals, and destroy on %s', + (platform) => { + const harness = createBunHarness() + const proc = spawn({ platform }) + + proc.write('hello') + proc.resize(120, 40) + proc.kill() + proc.kill('SIGTERM') + proc.kill('SIGINT') + proc.kill('SIGKILL') + proc.destroy() + + expect(harness.terminal.write).toHaveBeenCalledWith('hello') + expect(harness.terminal.resize).toHaveBeenCalledWith(120, 40) + expect(harness.processHandle.kill.mock.calls).toEqual([ + ['SIGHUP'], + ['SIGTERM'], + ['SIGINT'], + ['SIGKILL'], + ['SIGHUP'] + ]) + expect(harness.terminal.close).toHaveBeenCalledOnce() + } + ) + + it('destroys a still-running process even if its terminal has already closed', () => { + const harness = createBunHarness() + const proc = spawn() + harness.terminal.closed = true + proc.destroy() + expect(harness.processHandle.kill).toHaveBeenCalledWith('SIGHUP') + expect(harness.terminal.close).not.toHaveBeenCalled() + }) + + it('contains a native terminal write failure and suppresses later writes', () => { + const harness = createBunHarness() + harness.terminal.write = vi.fn(() => { + throw new Error('terminal closed') + }) + const proc = spawn() + + expect(() => proc.write('first')).not.toThrow() + proc.write('second') + + expect(harness.terminal.write).toHaveBeenCalledOnce() + }) + + it('contains a native terminal resize failure and suppresses later resizes', () => { + const harness = createBunHarness() + harness.terminal.resize = vi.fn(() => { + throw new Error('terminal closed') + }) + const proc = spawn() + + expect(() => proc.resize(120, 40)).not.toThrow() + proc.resize(100, 30) + + expect(harness.terminal.resize).toHaveBeenCalledOnce() + }) + + it('pauses and resumes the POSIX producer process group once per transition', async () => { + createBunHarness() + const signalProcessGroup = vi.fn() + const proc = spawn({ + readProcessTable: () => ' 4321 4321 pts/test T\n 4322 4322 pts/test', + signalProcessGroup + }) + + proc.pause() + proc.pause() + await vi.waitFor(() => expect(signalProcessGroup).toHaveBeenCalledTimes(2)) + proc.resume() + proc.resume() + await vi.waitFor(() => expect(signalProcessGroup).toHaveBeenCalledTimes(4)) + + expect(signalProcessGroup.mock.calls).toEqual([ + [4321, 'SIGSTOP'], + [4322, 'SIGSTOP'], + [4322, 'SIGCONT'], + [4321, 'SIGCONT'] + ]) + }) + + it('resumes a paused process group before graceful shutdown', async () => { + const harness = createBunHarness() + const signalProcessGroup = vi.fn() + const proc = spawn({ + readProcessTable: () => ' 4321 4321 pts/test T\n 4322 4322 pts/test', + signalProcessGroup + }) + + proc.pause() + await vi.waitFor(() => expect(signalProcessGroup).toHaveBeenCalledTimes(2)) + proc.kill() + + expect(signalProcessGroup.mock.calls).toEqual([ + [4321, 'SIGSTOP'], + [4322, 'SIGSTOP'], + [4322, 'SIGCONT'], + [4321, 'SIGCONT'] + ]) + expect(harness.processHandle.kill).toHaveBeenCalledWith('SIGHUP') + }) + + it('falls back to Bun process signals when group signaling is unavailable', async () => { + const harness = createBunHarness() + vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('not supported'), { code: 'EINVAL' }) + }) + const proc = spawn({ readProcessTable: () => '' }) + + proc.pause() + await vi.waitFor(() => + expect(harness.processHandle.kill).toHaveBeenCalledWith(constants.signals.SIGSTOP) + ) + proc.resume() + await vi.waitFor(() => + expect(harness.processHandle.kill).toHaveBeenCalledWith(constants.signals.SIGCONT) + ) + + expect(harness.processHandle.kill.mock.calls).toEqual([ + [constants.signals.SIGSTOP], + [constants.signals.SIGCONT] + ]) + }) + + it('gates a Windows shell behind exact job ownership and exposes owned capabilities', async () => { + const harness = createBunHarness({ closeImmediately: false }) + const assignHostJob = vi.fn(() => true) + const release = vi.fn() + const dispose = vi.fn() + const waitForSpawn = vi.fn(async () => {}) + let reportedShellPid: number | undefined + const job = { + listProcessIds: vi.fn(() => [4321, 4322]), + pause: vi.fn(() => true), + resume: vi.fn(() => true), + terminate: vi.fn(() => 'terminated' as const), + close: vi.fn() + } + const createJob = vi.fn(() => job) + const createWindowsLaunch = vi.fn(() => ({ + command: ['cmd.exe', '/d /c launch.cmd'], + clearCommand: ['cmd.exe', '/d /c clear.cmd'], + env: { TERM: 'xterm-256color', ORCA_BUN_PTY_JOB_GATE: 'gate' }, + windowsVerbatimArguments: true as const, + release, + dispose, + waitForSpawn, + readShellProcessId: () => reportedShellPid + })) + const proc = spawn({ + platform: 'win32', + assignHostJob, + createJob, + createWindowsLaunch + }) + + expect(harness.spawn.mock.calls[0]?.[0]).toEqual(['cmd.exe', '/d /c launch.cmd']) + expect(harness.spawn.mock.calls[0]?.[1]).toMatchObject({ + windowsVerbatimArguments: true, + env: { ORCA_BUN_PTY_JOB_GATE: 'gate' }, + terminal: harness.terminal + }) + expect(assignHostJob.mock.invocationCallOrder[0]).toBeLessThan( + harness.spawn.mock.invocationCallOrder[0] + ) + expect(createJob).toHaveBeenCalledWith(4321) + expect(createJob.mock.invocationCallOrder[0]).toBeLessThan(release.mock.invocationCallOrder[0]) + await proc.waitForSpawn?.() + expect(waitForSpawn).toHaveBeenCalledWith(harness.processHandle.exited) + + proc.pause() + proc.pause() + proc.resume() + proc.resume() + expect(job.pause).toHaveBeenCalledOnce() + expect(job.resume).toHaveBeenCalledOnce() + expect(proc.jobRootProcessIsWrapper).toBe(true) + expect(readWindowsPtyJobProcessIds(proc)).toBeNull() + expect(harness.spawn.mock.calls[0]?.[1]).not.toHaveProperty('ipc') + reportedShellPid = 4322 + expect(proc.shellProcessId).toBe(4322) + expect(readWindowsPtyJobProcessIds(proc)).toEqual(new Set([4322])) + job.listProcessIds.mockReturnValueOnce([4321, 4323]) + expect(readWindowsPtyJobProcessIds(proc)).toBeNull() + expect(proc.shellProcessId).toBe(4322) + expect(proc.listOwnedProcessIds?.()).toEqual([4321, 4322]) + expect(proc.terminateOwnedTree?.()).toBe('terminated') + + job.terminate.mockClear() + proc.signalProcess?.('SIGINT') + expect(job.terminate).toHaveBeenCalledOnce() + expect(harness.processHandle.kill).not.toHaveBeenCalled() + + proc.clear() + proc.clear() + expect(harness.spawn.mock.calls[1]?.[0]).toEqual(['cmd.exe', '/d /c clear.cmd']) + expect(harness.spawn.mock.calls[1]?.[1]).toMatchObject({ + terminal: harness.terminal, + windowsVerbatimArguments: true + }) + expect(harness.spawn).toHaveBeenCalledTimes(2) + + const lastOutput = vi.fn() + const onExit = vi.fn() + proc.onData(lastOutput) + proc.onExit(onExit) + harness.resolveExit(0) + await harness.processHandle.exited + await Promise.resolve() + expect(onExit).not.toHaveBeenCalled() + expect(job.close).not.toHaveBeenCalled() + harness.emitData(new TextEncoder().encode('final ConPTY frame')) + harness.finishTerminal() + expect(lastOutput).toHaveBeenCalledWith('final ConPTY frame') + expect(onExit).toHaveBeenCalledOnce() + expect(job.close).toHaveBeenCalledOnce() + expect(dispose).toHaveBeenCalledOnce() + expect(job.resume).toHaveBeenCalledOnce() + job.resume.mockImplementation(() => { + throw new Error('job already closed') + }) + expect(() => { + proc.pause() + proc.resume() + proc.kill() + proc.destroy() + }).not.toThrow() + expect(job.resume).toHaveBeenCalledOnce() + }) + + it('does not release a Windows gate without exact job ownership', async () => { + const harness = createBunHarness() + const release = vi.fn() + const dispose = vi.fn() + + expect(() => + spawn({ + platform: 'win32', + assignHostJob: () => true, + createJob: () => null, + createWindowsLaunch: () => ({ + command: ['cmd.exe', '/d /c launch.cmd'], + clearCommand: ['cmd.exe', '/d /c clear.cmd'], + env: {}, + windowsVerbatimArguments: true, + waitForSpawn: async () => {}, + readShellProcessId: () => undefined, + release, + dispose + }) + }) + ).toThrow('Windows Bun PTY job ownership is unavailable') + + expect(release).not.toHaveBeenCalled() + expect(harness.processHandle.kill).toHaveBeenCalledWith('SIGTERM') + expect(harness.terminal.close).toHaveBeenCalledOnce() + expect(dispose).toHaveBeenCalledOnce() + harness.resolveExit(1) + await harness.processHandle.exited + expect(dispose).toHaveBeenCalledTimes(2) + }) + + it('does not spawn a Windows PTY without host crash ownership', () => { + const harness = createBunHarness() + const createWindowsLaunch = vi.fn() + + expect(() => + spawn({ + platform: 'win32', + assignHostJob: () => false, + createWindowsLaunch + }) + ).toThrow('Windows Bun PTY host crash ownership is unavailable') + + expect(createWindowsLaunch).not.toHaveBeenCalled() + expect(harness.spawn).not.toHaveBeenCalled() + }) + + it('preserves a Windows PTY after a failed suspension and allows a retry', () => { + const harness = createBunHarness() + const job = { + listProcessIds: vi.fn(() => [4321]), + pause: vi.fn(() => true).mockReturnValueOnce(false), + resume: vi.fn(() => true), + terminate: vi.fn(() => 'terminated' as const), + close: vi.fn() + } + const proc = spawn({ + platform: 'win32', + assignHostJob: () => true, + createJob: () => job, + createWindowsLaunch: () => ({ + command: ['cmd.exe', '/d /c launch.cmd'], + clearCommand: ['cmd.exe', '/d /c clear.cmd'], + env: {}, + windowsVerbatimArguments: true, + waitForSpawn: async () => {}, + readShellProcessId: () => undefined, + release: vi.fn(), + dispose: vi.fn() + }) + }) + + proc.pause() + proc.write('still usable') + proc.pause() + proc.resume() + + expect(job.pause).toHaveBeenCalledTimes(2) + expect(job.resume).toHaveBeenCalledOnce() + expect(job.terminate).not.toHaveBeenCalled() + expect(harness.terminal.close).not.toHaveBeenCalled() + expect(harness.terminal.write).toHaveBeenCalledWith('still usable') + + proc.kill() + proc.kill('SIGKILL') + proc.destroy() + expect(harness.processHandle.kill.mock.calls).toEqual([['SIGTERM'], ['SIGKILL'], ['SIGTERM']]) + expect(job.terminate).toHaveBeenCalledTimes(3) + expect(harness.terminal.close).toHaveBeenCalledOnce() + }) + + it('delivers Windows exit after cleanup failures', async () => { + const harness = createBunHarness() + const cleanupError = new Error('job close failed') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const proc = spawn({ + platform: 'win32', + assignHostJob: () => true, + createJob: () => ({ + listProcessIds: vi.fn(() => []), + pause: vi.fn(() => true), + resume: vi.fn(() => true), + terminate: vi.fn(() => 'terminated' as const), + close: vi.fn(() => { + throw cleanupError + }) + }), + createWindowsLaunch: () => ({ + command: ['cmd.exe', '/d /c launch.cmd'], + clearCommand: ['cmd.exe', '/d /c clear.cmd'], + env: {}, + windowsVerbatimArguments: true, + waitForSpawn: async () => {}, + readShellProcessId: () => undefined, + release: vi.fn(), + dispose: vi.fn() + }) + }) + const onExit = vi.fn() + proc.onExit(onExit) + + harness.resolveExit(9) + await harness.processHandle.exited + await Promise.resolve() + + expect(onExit).toHaveBeenCalledWith({ exitCode: 9 }) + expect(warn).toHaveBeenCalledWith('[daemon/pty] PTY cleanup failed:', cleanupError) + }) + + it('terminates and closes Windows job state when gate release fails', () => { + const harness = createBunHarness() + const dispose = vi.fn() + const job = { + listProcessIds: vi.fn(() => [4321]), + pause: vi.fn(() => true), + resume: vi.fn(() => true), + terminate: vi.fn(() => 'terminated' as const), + close: vi.fn() + } + + expect(() => + spawn({ + platform: 'win32', + assignHostJob: () => true, + createJob: () => job, + createWindowsLaunch: () => ({ + command: ['cmd.exe', '/d /c launch.cmd'], + clearCommand: ['cmd.exe', '/d /c clear.cmd'], + env: {}, + windowsVerbatimArguments: true, + waitForSpawn: async () => {}, + readShellProcessId: () => undefined, + release() { + throw new Error('gate release failed') + }, + dispose + }) + }) + ).toThrow('gate release failed') + + expect(job.terminate).toHaveBeenCalledOnce() + expect(job.close).toHaveBeenCalledOnce() + expect(harness.processHandle.kill).toHaveBeenCalledWith('SIGTERM') + expect(harness.terminal.close).toHaveBeenCalledOnce() + expect(dispose).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/daemon/pty-subprocess/bun-pty-process.ts b/src/main/daemon/pty-subprocess/bun-pty-process.ts new file mode 100644 index 00000000000..b8d68b65ad4 --- /dev/null +++ b/src/main/daemon/pty-subprocess/bun-pty-process.ts @@ -0,0 +1,2 @@ +export { canUseBunPty } from './bun-pty-process-capabilities' +export { spawnBunPty } from './bun-pty-process-runtime' diff --git a/src/main/daemon/pty-subprocess/foreground-process-tracker.ts b/src/main/daemon/pty-subprocess/foreground-process-tracker.ts index 8726dc87281..7a96877c204 100644 --- a/src/main/daemon/pty-subprocess/foreground-process-tracker.ts +++ b/src/main/daemon/pty-subprocess/foreground-process-tracker.ts @@ -1,6 +1,6 @@ import type * as pty from 'node-pty' +import { ptyShellProcessId } from '../../windows/windows-pty-job' import { getAgentForegroundContextPaths } from '../../providers/agent-foreground-context-paths' -import { resolveAgentForegroundProcessWithAvailability } from '../../providers/agent-foreground-process' import { confirmPtyShellForeground } from './pty-shell-foreground-confirmation' import { judgeCachedAgentJobEvidence, @@ -24,6 +24,11 @@ import { import { isShellProcess } from '../../../shared/shell-process-detection' import { resolveFallbackForegroundProcess } from './foreground-fallback-process' import { parsePtySessionId } from '../pty-session-id' +import { + ptyProcessNameIsSpawnFile, + createPtyForegroundResolver, + shouldCachePtyForeground +} from './spawn-file-foreground-process' const FOREGROUND_AGENT_CACHE_TTL_MS = 1000 const SHELL_FOREGROUND_REFRESH_RETRY_MS = 5_000 @@ -52,6 +57,8 @@ export function createPtyForegroundProcessTracker(args: { isDead: () => boolean }): PtyForegroundProcessTracker { const proc = args.process + const staticName = ptyProcessNameIsSpawnFile(proc) + const resolveForeground = createPtyForegroundResolver(proc) let lastOutputAt = 0 // `pid` anchors the identity to the row that proved it (null when ambiguous). let cachedAgentForeground: CachedAgentForeground | null = null @@ -69,16 +76,12 @@ export function createPtyForegroundProcessTracker(args: { let foregroundRefreshInFlight = false let lastForegroundRefreshStartedAt = 0 const getFallbackProcess = (): string | null => - resolveFallbackForegroundProcess(proc.process, args.shellPath) + resolveFallbackForegroundProcess(staticName ? args.shellPath : proc.process, args.shellPath) const getActiveStartupAgent = ( now = Date.now() ): { processName: string; expiresAt: number } | null => { - if (!startupAgentForeground) { - return null - } - if (now > startupAgentForeground.expiresAt) { + if (startupAgentForeground && now > startupAgentForeground.expiresAt) { startupAgentForeground = null - return null } return startupAgentForeground } @@ -138,7 +141,7 @@ export function createPtyForegroundProcessTracker(args: { } } const anchor = cachedAgentForeground - void resolveAgentForegroundProcessWithAvailability(proc.pid, fallbackProcess, { + void resolveForeground(proc.pid, fallbackProcess, { contextPaths, ...(anchor?.pid != null ? { anchorProcessId: anchor.pid, anchorProcessName: anchor.processName } @@ -148,13 +151,13 @@ export function createPtyForegroundProcessTracker(args: { if (args.isDead() || !available) { return } - if (!processName || !recognizeAgentProcess(processName)) { + if (!shouldCachePtyForeground(processName, staticName)) { if (process.platform === 'win32' && fallbackIsShell && cachedAgentForeground !== null) { // Job, not console: needs no console attachment, so no fork (#10857). const verdict = judgeCachedAgentJobEvidence({ jobProcessIds: readWindowsPtyJobProcessIds(proc), jobSupported: isWindowsPtyJobReadable(), - shellPid: proc.pid, + shellPid: ptyShellProcessId(proc) ?? proc.pid, anchorProcessId: cachedAgentForeground.pid, identityAgeMs: Date.now() - cachedAgentForeground.refreshedAt }) @@ -248,7 +251,8 @@ export function createPtyForegroundProcessTracker(args: { if ( cachedAgentForeground && fallbackProcess !== null && - (isAgentForegroundWrapperProcess(fallbackProcess) || + (staticName || + isAgentForegroundWrapperProcess(fallbackProcess) || inspectOuterWrapper || (process.platform === 'win32' && isShellProcess(fallbackProcess))) ) { @@ -279,33 +283,30 @@ export function createPtyForegroundProcessTracker(args: { ) { return fallbackProcess } - const resolution = await resolveAgentForegroundProcessWithAvailability( - proc.pid, - fallbackProcess, - { - contextPaths, - fresh: true, - ...(process.platform === 'win32' - ? { - forceProcessScan: true, - readWindowsConsoleAttachedProcessIds: () => - readWindowsConsoleAttachedProcessIds(proc.pid) - } - : {}) - } - ) + const resolution = await resolveForeground(proc.pid, fallbackProcess, { + contextPaths, + fresh: true, + ...(process.platform === 'win32' + ? { + forceProcessScan: true, + readWindowsConsoleAttachedProcessIds: () => + readWindowsConsoleAttachedProcessIds(proc.pid) + } + : {}) + }) if (args.isDead() || !resolution.available) { return null } - const recognized = recognizeAgentProcess(resolution.processName) - if (recognized) { + const processName = + recognizeAgentProcess(resolution.processName)?.processName ?? resolution.processName + if (shouldCachePtyForeground(processName, staticName)) { cachedAgentForeground = { - processName: recognized.processName, + processName, pid: resolution.processId ?? null, refreshedAt: Date.now() } startupAgentForeground = null - return recognized.processName + return cachedAgentForeground.processName } cachedAgentForeground = null startupAgentForeground = null diff --git a/src/main/daemon/pty-subprocess/native-pty-spawn-bun.test.ts b/src/main/daemon/pty-subprocess/native-pty-spawn-bun.test.ts new file mode 100644 index 00000000000..26a3fcda7dc --- /dev/null +++ b/src/main/daemon/pty-subprocess/native-pty-spawn-bun.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from 'vitest' + +const { nodePtyFactory, wrapShellSpawnMock } = vi.hoisted(() => ({ + nodePtyFactory: vi.fn(() => ({ spawn: vi.fn() })), + wrapShellSpawnMock: vi.fn((file: string, args: string[]) => ({ file, args })) +})) + +vi.mock('node-pty', nodePtyFactory) +vi.mock('../../providers/macos-tcc-login-shell', () => ({ + hostReportsChildExitStatus: (file: string) => file !== '/usr/bin/login', + wrapShellSpawnForMacosTccAttribution: wrapShellSpawnMock +})) + +import { spawnNativeDaemonPty } from './native-pty-spawn' + +describe('native PTY runtime selection', () => { + it('spawns with Bun.Terminal without loading node-pty', async () => { + const dispose = vi.fn() + const spawnBunPty = vi.fn(() => ({ + pid: 9876, + cols: 80, + rows: 24, + process: '/bin/zsh', + handleFlowControl: false, + onData: vi.fn(() => ({ dispose })), + onExit: vi.fn(() => ({ dispose })), + write: vi.fn(), + resize: vi.fn(), + clear: vi.fn(), + kill: vi.fn(), + destroy: vi.fn(), + pause: vi.fn(), + resume: vi.fn() + })) + + const result = await spawnNativeDaemonPty( + { + shellPath: '/bin/zsh', + shellArgs: ['-l'], + spawnCwd: '/tmp', + env: { TERM: 'xterm-256color' }, + cols: 80, + rows: 24, + windowsFallbackAttempts: [] + }, + { canUseBunPty: () => true, spawnBunPty } + ) + + expect(result.process.pid).toBe(9876) + expect(spawnBunPty).toHaveBeenCalledOnce() + expect(nodePtyFactory).not.toHaveBeenCalled() + }) + + it('applies the macOS login wrapper before selecting the Bun PTY runtime', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + const spawnBunPty = vi.fn(() => ({ + pid: 9877, + cols: 80, + rows: 24, + process: '/usr/bin/login', + handleFlowControl: false, + onData: vi.fn(() => ({ dispose: vi.fn() })), + onExit: vi.fn(() => ({ dispose: vi.fn() })), + write: vi.fn(), + resize: vi.fn(), + clear: vi.fn(), + kill: vi.fn(), + destroy: vi.fn(), + pause: vi.fn(), + resume: vi.fn() + })) + const onMacosTccSpawnStrategy = vi.fn() + wrapShellSpawnMock.mockReturnValueOnce({ + file: '/usr/bin/login', + args: ['-flpq', 'tester', '/bin/zsh', '-l'] + }) + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + + try { + const result = await spawnNativeDaemonPty( + { + shellPath: '/bin/zsh', + shellArgs: ['-l'], + spawnCwd: '/tmp', + env: { TERM: 'xterm-256color' }, + cols: 80, + rows: 24, + windowsFallbackAttempts: [], + onMacosTccSpawnStrategy + }, + { canUseBunPty: () => true, spawnBunPty } + ) + + expect(result.process.pid).toBe(9877) + expect(spawnBunPty).toHaveBeenCalledWith( + expect.objectContaining({ + file: '/usr/bin/login', + args: ['-flpq', 'tester', '/bin/zsh', '-l'] + }) + ) + expect(result.reportsChildExitStatus).toBe(false) + expect(onMacosTccSpawnStrategy).toHaveBeenCalledWith('wrapped') + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) +}) diff --git a/src/main/daemon/pty-subprocess/native-pty-spawn-windows.test.ts b/src/main/daemon/pty-subprocess/native-pty-spawn-windows.test.ts new file mode 100644 index 00000000000..8d6f7117d08 --- /dev/null +++ b/src/main/daemon/pty-subprocess/native-pty-spawn-windows.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { spawnNativeDaemonPty } from './native-pty-spawn' +import { WindowsBunPtySpawnUnconfirmedError } from './windows-bun-pty-spawn-receipt' + +const attempts = ['pwsh.exe', 'powershell.exe', 'cmd.exe'].map((shellPath) => ({ + shellPath, + shellArgs: [shellPath === 'cmd.exe' ? '/K' : '-NoExit'], + effectiveCwd: 'C:\\work', + validationCwd: 'C:\\work', + startupCommandDeliveredInShellArgs: true +})) +const args = { + shellPath: attempts[0]!.shellPath, + shellArgs: attempts[0]!.shellArgs, + spawnCwd: 'C:\\work', + env: {}, + cols: 80, + rows: 24, + windowsFallbackAttempts: attempts +} + +function createProcess(waitForSpawn: () => Promise) { + return { + pid: 9876, + cols: 80, + rows: 24, + process: 'gate', + handleFlowControl: false, + onData: vi.fn(() => ({ dispose: vi.fn() })), + onExit: vi.fn(() => ({ dispose: vi.fn() })), + write: vi.fn(), + resize: vi.fn(), + clear: vi.fn(), + kill: vi.fn(), + destroy: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + waitForSpawn + } +} + +describe('Windows Bun shell fallback after gated spawn', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform')! + beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + afterEach(() => { + Object.defineProperty(process, 'platform', platform) + vi.restoreAllMocks() + }) + + it('walks both fallback shells when gate wrappers start but their actual shells fail', async () => { + const spawnBunPty = vi.fn(({ file }: { file: string }) => + createProcess(async () => { + await Promise.resolve() + if (file !== 'cmd.exe') { + throw new Error(`spawn ${file} EACCES`) + } + }) + ) + const result = await spawnNativeDaemonPty(args, { canUseBunPty: () => true, spawnBunPty }) + expect(spawnBunPty.mock.calls.map(([args]) => args.file)).toEqual([ + 'pwsh.exe', + 'powershell.exe', + 'cmd.exe' + ]) + expect(result.shellPath).toBe('cmd.exe') + expect(result.startupCommandDeliveredInShellArgs).toBe(true) + expect(spawnBunPty.mock.results[0]!.value.destroy).toHaveBeenCalledOnce() + expect(spawnBunPty.mock.results[1]!.value.destroy).toHaveBeenCalledOnce() + expect(spawnBunPty.mock.results[2]!.value.destroy).not.toHaveBeenCalled() + }) + + it('does not report a wrapper as a working shell before its actual spawn is confirmed', async () => { + let confirm!: () => void + const confirmation = new Promise((resolve) => { + confirm = resolve + }) + const finished = vi.fn() + const spawnBunPty = vi.fn(() => createProcess(() => confirmation)) + const result = spawnNativeDaemonPty(args, { canUseBunPty: () => true, spawnBunPty }).then( + finished + ) + await Promise.resolve() + expect(finished).not.toHaveBeenCalled() + confirm() + await result + expect(finished).toHaveBeenCalledOnce() + }) + + it('destroys an unconfirmed gate on cancellation without starting a fallback shell', async () => { + const controller = new AbortController() + const proc = createProcess(() => new Promise(() => {})) + const spawnBunPty = vi.fn(() => proc) + const result = spawnNativeDaemonPty( + { ...args, signal: controller.signal }, + { canUseBunPty: () => true, spawnBunPty } + ) + controller.abort(new Error('spawn canceled')) + await expect(result).rejects.toThrow('spawn canceled') + expect(proc.destroy).toHaveBeenCalledOnce() + expect(spawnBunPty).toHaveBeenCalledOnce() + }) + + it.each([0, 1])( + 'stops at an ambiguous attempt %s to avoid running its startup command twice', + async (ambiguousIndex) => { + const spawnBunPty = vi.fn(({ file }: { file: string }) => + createProcess(async () => { + if (file === attempts[ambiguousIndex]!.shellPath) { + throw new WindowsBunPtySpawnUnconfirmedError('missing receipt') + } + throw new Error('spawn ENOENT') + }) + ) + await expect( + spawnNativeDaemonPty(args, { canUseBunPty: () => true, spawnBunPty }) + ).rejects.toBeInstanceOf(WindowsBunPtySpawnUnconfirmedError) + expect(spawnBunPty).toHaveBeenCalledTimes(ambiguousIndex + 1) + expect(spawnBunPty.mock.results.at(-1)!.value.destroy).toHaveBeenCalledOnce() + } + ) +}) diff --git a/src/main/daemon/pty-subprocess/native-pty-spawn.ts b/src/main/daemon/pty-subprocess/native-pty-spawn.ts index e0332ba9921..e89e8a5aac4 100644 --- a/src/main/daemon/pty-subprocess/native-pty-spawn.ts +++ b/src/main/daemon/pty-subprocess/native-pty-spawn.ts @@ -1,4 +1,5 @@ -import * as pty from 'node-pty' +import type * as pty from 'node-pty' +import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' import { hostReportsChildExitStatus, wrapShellSpawnForMacosTccAttribution @@ -6,6 +7,12 @@ import { import type { WindowsShellSpawnAttempt } from '../../providers/windows-shell-fallback-chain' import { assignHostProcessToKillOnCloseJob } from '../../windows/windows-pty-job' +import { canUseBunPty, spawnBunPty } from './bun-pty-process' +import { WindowsBunPtySpawnUnconfirmedError } from './windows-bun-pty-spawn-receipt' + +async function loadNodePty(): Promise { + return import('node-pty') +} export type SpawnedDaemonPty = { process: pty.IPty shellPath: string @@ -15,25 +22,66 @@ export type SpawnedDaemonPty = { reportsChildExitStatus: boolean } +type NativePtyRuntime = { + canUseBunPty: typeof canUseBunPty + spawnBunPty: typeof spawnBunPty +} + /** Walks the Windows PowerShell -> cmd.exe fallback chain when ConPTY rejects the primary shell. */ -export function spawnNativeDaemonPty(args: { - shellPath: string - shellArgs: string[] - spawnCwd: string - env: Record - cols: number - rows: number - windowsFallbackAttempts: WindowsShellSpawnAttempt[] - onMacosTccSpawnStrategy?: (strategy: 'wrapped' | 'direct') => void -}): SpawnedDaemonPty { +export async function spawnNativeDaemonPty( + args: { + shellPath: string + shellArgs: string[] + spawnCwd: string + env: Record + cols: number + rows: number + windowsFallbackAttempts: WindowsShellSpawnAttempt[] + signal?: AbortSignal + onMacosTccSpawnStrategy?: (strategy: 'wrapped' | 'direct') => void + }, + runtime: NativePtyRuntime = { canUseBunPty, spawnBunPty } +): Promise { let reportsChildExitStatus = true - const spawnAt = (shellPath: string, shellArgs: string[], cwd: string): pty.IPty => { + const spawnAt = async ( + shellPath: string, + shellArgs: string[], + cwd: string + ): Promise => { + args.signal?.throwIfAborted() const wrapped = wrapShellSpawnForMacosTccAttribution(shellPath, shellArgs, args.env) + reportsChildExitStatus = hostReportsChildExitStatus(wrapped.file) + if (runtime.canUseBunPty()) { + const proc = runtime.spawnBunPty({ + file: wrapped.file, + args: wrapped.args, + cwd, + env: args.env, + cols: args.cols, + rows: args.rows + }) + try { + if (proc.waitForSpawn) { + await waitForPromiseWithSignal(proc.waitForSpawn(), args.signal) + } + args.signal?.throwIfAborted() + } catch (error) { + try { + proc.destroy() + } catch (cleanupError) { + console.warn('[daemon/pty] Failed shell launch cleanup failed:', cleanupError) + } + throw error + } + args.onMacosTccSpawnStrategy?.(wrapped.file === shellPath ? 'direct' : 'wrapped') + return proc + } + const nodePty = await loadNodePty() // Why: children inherit job membership, so the host job must exist before the first Windows PTY. if (process.platform === 'win32') { assignHostProcessToKillOnCloseJob() } - const proc = pty.spawn(wrapped.file, wrapped.args, { + const proc = nodePty.spawn(wrapped.file, wrapped.args, { name: args.env.TERM ?? 'xterm-256color', cols: args.cols, rows: args.rows, @@ -48,7 +96,7 @@ export function spawnNativeDaemonPty(args: { } try { - const process_ = spawnAt(args.shellPath, args.shellArgs, args.spawnCwd) + const process_ = await spawnAt(args.shellPath, args.shellArgs, args.spawnCwd) return { process: process_, shellPath: args.shellPath, @@ -56,12 +104,13 @@ export function spawnNativeDaemonPty(args: { reportsChildExitStatus } } catch (primaryErr) { - if (process.platform !== 'win32') { + args.signal?.throwIfAborted() + if (process.platform !== 'win32' || primaryErr instanceof WindowsBunPtySpawnUnconfirmedError) { throw primaryErr } for (const attempt of args.windowsFallbackAttempts.slice(1)) { try { - const process = spawnAt(attempt.shellPath, attempt.shellArgs, attempt.effectiveCwd) + const process = await spawnAt(attempt.shellPath, attempt.shellArgs, attempt.effectiveCwd) const message = primaryErr instanceof Error ? primaryErr.message : String(primaryErr) console.warn( `[daemon/pty] Primary shell "${args.shellPath}" failed (${message}), fell back to "${attempt.shellPath}"` @@ -73,7 +122,11 @@ export function spawnNativeDaemonPty(args: { startupCommandDeliveredInShellArgs: attempt.startupCommandDeliveredInShellArgs, reportsChildExitStatus } - } catch { + } catch (error) { + args.signal?.throwIfAborted() + if (error instanceof WindowsBunPtySpawnUnconfirmedError) { + throw error + } // This fallback shell also failed -- try the next link in the chain. } } diff --git a/src/main/daemon/pty-subprocess/pty-shell-foreground-confirmation.ts b/src/main/daemon/pty-subprocess/pty-shell-foreground-confirmation.ts index 2fdd75e3518..978da1ac67f 100644 --- a/src/main/daemon/pty-subprocess/pty-shell-foreground-confirmation.ts +++ b/src/main/daemon/pty-subprocess/pty-shell-foreground-confirmation.ts @@ -1,4 +1,5 @@ import type * as pty from 'node-pty' +import { ptyShellProcessId } from '../../windows/windows-pty-job' import { confirmShellForegroundProcess } from '../../providers/agent-foreground-process' import { readWindowsPtyJobProcessIds } from '../../providers/windows-pty-job-membership' @@ -7,14 +8,14 @@ import { readWindowsPtyJobProcessIds } from '../../providers/windows-pty-job-mem * never cached state. */ export async function confirmPtyShellForeground(args: { process: pty.IPty - shellPath: string + shellPath: string | undefined isDead: () => boolean }): Promise { if (args.isDead() || !args.process.pid) { return false } const confirmed = await confirmShellForegroundProcess( - args.process.pid, + ptyShellProcessId(args.process), args.shellPath, process.platform === 'win32' ? { readWindowsPtyJobProcessIds: () => readWindowsPtyJobProcessIds(args.process) } diff --git a/src/main/daemon/pty-subprocess/shell-launch-plan.ts b/src/main/daemon/pty-subprocess/shell-launch-plan.ts index b50ef12baac..0c10f1493a5 100644 --- a/src/main/daemon/pty-subprocess/shell-launch-plan.ts +++ b/src/main/daemon/pty-subprocess/shell-launch-plan.ts @@ -206,7 +206,8 @@ export function createPtyShellLaunchPlan( hasStartupCommand: Boolean(opts.command), waitsForShellReady, emitsStartupIdentity: waitsForShellReady - }) + }), + { hasStartupCommand: Boolean(opts.command) } ) Object.assign(env, shellLaunch.env) shellArgs = diff --git a/src/main/daemon/pty-subprocess/spawn-environment.ts b/src/main/daemon/pty-subprocess/spawn-environment.ts index 129747d4a0d..a063ffb3dd6 100644 --- a/src/main/daemon/pty-subprocess/spawn-environment.ts +++ b/src/main/daemon/pty-subprocess/spawn-environment.ts @@ -1,3 +1,5 @@ +import { getLegacyOpenCodeEnvKeysToDelete } from '../../opencode/legacy-shared-config-dir' +import { restoreOrStripOverlayEnv } from '../../../shared/agent-overlay-env' import { delimiter } from 'node:path' import { dropInheritedOrcaFishHistory } from '../../fish-history-session' import { removeAppImageRuntimeEnv } from '../../pty/appimage-terminal-env' @@ -21,6 +23,7 @@ import { expandWindowsEnvironmentVariables, expandWindowsPathEnvironmentVariables } from '../../../shared/windows-environment-expansion' +import { applyScrubSafeAgentEnvAliases } from '../../../shared/agent-hook-scrub-safe-env' import type { TuiAgent } from '../../../shared/tui-agent' import type { PtySubprocessOptions } from '../pty-subprocess' @@ -28,7 +31,9 @@ const PANE_IDENTITY_ENV_KEYS = [ 'ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID', - 'ORCA_AGENT_LAUNCH_TOKEN' + 'ORCA_AGENT_LAUNCH_TOKEN', + // Not identity but equally per-spawn: an inherited copy names another launch's CLI. + 'ORCA_WSL_CLI_DIR' ] as const const WINDOWS_PATH_ENV_KEY_RE = /^path$/i @@ -50,11 +55,33 @@ function deleteRequestedDaemonEnvKeys( env: Record, keys: readonly string[] | undefined ): void { + const userDataPath = process.env.ORCA_USER_DATA_PATH + if (userDataPath) { + for (const key of getLegacyOpenCodeEnvKeysToDelete(env, userDataPath, {})) { + delete env[key] + } + } // Why: persistent daemon state can differ from Electron; delete CODEX_HOME only when its Orca overlay owns it. const deleteOrcaOwnedCodexHome = keys?.includes('ORCA_CODEX_HOME') === true && env.ORCA_CODEX_HOME !== undefined && env.CODEX_HOME === env.ORCA_CODEX_HOME + // A merged caller config can supersede the daemon's recorded overlay source. + if ( + keys?.includes('ORCA_OPENCODE_CONFIG_DIR') && + (env.OPENCODE_CONFIG_DIR === undefined || + env.OPENCODE_CONFIG_DIR === env.ORCA_OPENCODE_CONFIG_DIR) + ) { + restoreOrStripOverlayEnv( + env, + { + primary: 'OPENCODE_CONFIG_DIR', + overlay: 'ORCA_OPENCODE_CONFIG_DIR', + source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR' + }, + {} + ) + } for (const key of keys ?? []) { delete env[key] } @@ -169,6 +196,9 @@ export function createDaemonPtyEnvironment(opts: PtySubprocessOptions): Record executableName(row.command) === shellName && !row.stat.includes('Z')) + .sort((left, right) => left.depth - right.depth)[0] + if (!shell) { + return 'unverifiable' + } + // The macOS login wrapper and its spawned shell are launch plumbing, not user jobs. + const launchChain = new Set([rootPid]) + let ancestor: ProcessTableRow | undefined = shell + while (ancestor && !launchChain.has(ancestor.pid)) { + launchChain.add(ancestor.pid) + ancestor = index.byPid.get(ancestor.ppid) + } + return tree.some((row) => !launchChain.has(row.pid) && !row.stat.includes('Z')) + ? 'children' + : 'no-children' +} + +export function inspectSpawnFileWindowsChildProcesses(proc: IPty): PtyChildProcessVerdict { + const members = readWindowsPtyJobProcessIds(proc) + return members === null ? 'unverifiable' : members.size > 1 ? 'children' : 'no-children' +} diff --git a/src/main/daemon/pty-subprocess/spawn-file-foreground-process.ts b/src/main/daemon/pty-subprocess/spawn-file-foreground-process.ts new file mode 100644 index 00000000000..b5763f238b1 --- /dev/null +++ b/src/main/daemon/pty-subprocess/spawn-file-foreground-process.ts @@ -0,0 +1,125 @@ +import type { IPty } from 'node-pty' +import { isShellProcess } from '../../../shared/shell-process-detection' +import { + getCommandTokenPathBasename, + getFirstCommandToken +} from '../../../shared/command-token-scanner' +import { + collectDescendantsFromIndex, + getProcessTableIndex +} from '../../../shared/process-table-index' +import type { ProcessTableRow } from '../../../shared/process-table-snapshot' +import { + getFreshProcessTableSnapshot, + getProcessTableSnapshot +} from '../../../shared/process-table-snapshot-reader' +import { selectForegroundProcessCandidate } from '../../../shared/foreground-process-selection' +import { resolveOuterWrapperForegroundProcess } from '../../../shared/foreground-wrapper-agent' +import { recognizeAgentProcess } from '../../../shared/agent-process-recognition' +import { + resolveAgentForegroundProcessWithAvailability, + type AgentForegroundProcessResolution, + type AgentForegroundResolutionOptions +} from '../../providers/agent-foreground-process' +import { readWindowsPtyJobProcessIds } from '../../providers/windows-pty-job-membership' +import { ptyShellProcessId } from '../../windows/windows-pty-job' +import { + readWindowsProcessIdentityTable, + readWindowsProcessIdentityTableFresh +} from '../../windows/windows-process-table' + +export function ptyProcessNameIsSpawnFile(proc: IPty): boolean { + return 'processNameIsSpawnFile' in proc && proc.processNameIsSpawnFile === true +} + +export function createPtyForegroundResolver( + proc: IPty +): typeof resolveAgentForegroundProcessWithAvailability { + return ptyProcessNameIsSpawnFile(proc) + ? (_pid, fallback, options) => resolveSpawnFileForegroundProcess(proc, fallback, options) + : resolveAgentForegroundProcessWithAvailability +} + +export function shouldCachePtyForeground(name: string | null, staticName: boolean): name is string { + return ( + name !== null && (recognizeAgentProcess(name) !== null || (staticName && !isShellProcess(name))) + ) +} + +export function resolveSpawnFileForegroundFromRows( + rows: readonly ProcessTableRow[], + rootPid: number +): AgentForegroundProcessResolution { + const index = getProcessTableIndex(rows) + const root = index.byPid.get(rootPid) + if (!root || !root.tpgid || root.tpgid < 0 || !root.tty || root.tty === '?') { + return { available: false, processName: null } + } + const tree = [{ ...root, depth: 0 }, ...collectDescendantsFromIndex(index, rootPid)] + const candidates = tree + .filter((row) => row.pgid === root.tpgid && row.tty === root.tty && !/[TZ]/.test(row.stat)) + .sort((left, right) => right.depth - left.depth) + const foreground = candidates[0] + if (!foreground) { + return { available: false, processName: null } + } + const name = getCommandTokenPathBasename(getFirstCommandToken(foreground.command)).replace( + /^-/, + '' + ) + const selected = selectForegroundProcessCandidate(candidates, tree) + return { + available: name.length > 0, + processName: selected + ? resolveOuterWrapperForegroundProcess(selected.recognized, selected.candidate, tree) + : recognizeAgentProcess(name) + ? null + : name || null + } +} + +export async function resolveSpawnFileForegroundProcess( + proc: IPty, + fallbackProcess: string | null, + options: AgentForegroundResolutionOptions = {} +): Promise { + try { + if (process.platform !== 'win32') { + const rows = options.fresh + ? await getFreshProcessTableSnapshot() + : await getProcessTableSnapshot() + return resolveSpawnFileForegroundFromRows(rows, proc.pid) + } + const resolution = await resolveAgentForegroundProcessWithAvailability( + proc.pid, + fallbackProcess, + options + ) + if (!resolution.available || recognizeAgentProcess(resolution.processName)) { + return resolution + } + const members = readWindowsPtyJobProcessIds(proc) + const shellPid = ptyShellProcessId(proc) + if (!members || shellPid === undefined) { + return { available: false, processName: null } + } + if (members.size === 1) { + return { available: true, processName: fallbackProcess } + } + const rows = options.fresh + ? await readWindowsProcessIdentityTableFresh() + : await readWindowsProcessIdentityTable() + const candidate = collectDescendantsFromIndex(getProcessTableIndex(rows), shellPid) + .filter((row) => members.has(row.pid)) + .sort((left, right) => right.depth - left.depth)[0] + // Only the agent resolver can grant an identity after ambiguity and console checks. + if (candidate && recognizeAgentProcess(candidate.name)) { + return resolution + } + return candidate + ? { available: true, processName: candidate.name, processId: candidate.pid } + : { available: false, processName: null } + } catch { + return { available: false, processName: null } + } +} diff --git a/src/main/daemon/pty-subprocess/spawn-file-foreground-rejected-agents.test.ts b/src/main/daemon/pty-subprocess/spawn-file-foreground-rejected-agents.test.ts new file mode 100644 index 00000000000..d79dc806e5a --- /dev/null +++ b/src/main/daemon/pty-subprocess/spawn-file-foreground-rejected-agents.test.ts @@ -0,0 +1,134 @@ +import type { IPty } from 'node-pty' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProcessTableRow } from '../../../shared/process-table-snapshot' +import { __setWindowsProcessTreeLoaderForTests } from '../../windows/windows-process-table' +import { + resolveSpawnFileForegroundFromRows, + resolveSpawnFileForegroundProcess +} from './spawn-file-foreground-process' + +const { members } = vi.hoisted(() => ({ members: vi.fn() })) +vi.mock('../../providers/windows-pty-job-membership', () => ({ + readWindowsPtyJobProcessIds: members +})) + +const proc: IPty = { + pid: 100, + cols: 80, + rows: 24, + handleFlowControl: false, + process: 'powershell.exe', + onData: () => ({ dispose() {} }), + onExit: () => ({ dispose() {} }), + write() {}, + resize() {}, + clear() {}, + kill() {}, + pause() {}, + resume() {} +} + +const root: ProcessTableRow = { + pid: 100, + ppid: 1, + pgid: 100, + tpgid: 101, + tty: 'pts/test', + stat: 'S', + startTime: 'shell-start', + command: '/bin/zsh' +} + +beforeEach(() => members.mockReturnValue(new Set([100, 101, 102]))) +afterEach(() => { + __setWindowsProcessTreeLoaderForTests() + vi.restoreAllMocks() + vi.clearAllMocks() +}) + +describe('POSIX static-name agent selection', () => { + it('does not pick a rejected sibling agent by its executable basename', () => { + expect( + resolveSpawnFileForegroundFromRows( + [ + root, + { ...root, pid: 101, ppid: 100, pgid: 101, stat: 'S+', command: 'claude' }, + { ...root, pid: 102, ppid: 100, pgid: 101, stat: 'S+', command: 'codex' } + ], + 100 + ) + ).toEqual({ available: true, processName: null }) + }) + + it('does not promote a headless one-shot agent from its executable basename', () => { + expect( + resolveSpawnFileForegroundFromRows( + [ + root, + { ...root, pid: 101, ppid: 100, pgid: 101, stat: 'S+', command: 'claude -p "review"' } + ], + 100 + ) + ).toEqual({ available: true, processName: null }) + }) + + it.each(['vim', 'npm', 'sleep'])('retains the ordinary %s name', (command) => { + expect( + resolveSpawnFileForegroundFromRows( + [root, { ...root, pid: 101, ppid: 100, pgid: 101, stat: 'S+', command }], + 100 + ) + ).toEqual({ available: true, processName: command }) + }) +}) + +describe('Windows static-name agent selection', () => { + function installRows(names: string[]): void { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const rows = [ + { pid: process.pid, ppid: 0, name: 'vitest.exe', commandLine: 'vitest' }, + { pid: 100, ppid: 1, name: 'powershell.exe', commandLine: 'powershell.exe' }, + ...names.map((name, index) => ({ pid: 101 + index, ppid: 100, name, commandLine: name })) + ] + __setWindowsProcessTreeLoaderForTests(() => ({ + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, + getAllProcesses: (callback) => callback(rows) + })) + } + + it('does not re-admit a detached agent through owned job membership', async () => { + installRows(['droid.exe']) + const consoleMembers = vi.fn(async () => new Set([100, 999])) + expect( + await resolveSpawnFileForegroundProcess(proc, 'powershell.exe', { + fresh: true, + readWindowsConsoleAttachedProcessIds: consoleMembers + }) + ).toEqual({ available: true, processName: 'powershell.exe' }) + expect(consoleMembers).toHaveBeenCalledOnce() + }) + + it('does not choose a rejected sibling agent from the identity table', async () => { + installRows(['claude.exe', 'codex.exe']) + expect( + await resolveSpawnFileForegroundProcess(proc, 'powershell.exe', { fresh: true }) + ).toEqual({ available: true, processName: 'powershell.exe' }) + }) + + it('retains a positively authorized agent', async () => { + installRows(['droid.exe']) + expect( + await resolveSpawnFileForegroundProcess(proc, 'powershell.exe', { + fresh: true, + readWindowsConsoleAttachedProcessIds: async () => new Set([100, 101]) + }) + ).toEqual({ available: true, processName: 'droid', processId: 101 }) + }) + + it('retains an ordinary executable from the identity table', async () => { + installRows(['vim.exe']) + expect( + await resolveSpawnFileForegroundProcess(proc, 'powershell.exe', { fresh: true }) + ).toEqual({ available: true, processName: 'vim.exe', processId: 101 }) + }) +}) diff --git a/src/main/daemon/pty-subprocess/spawn-preflight-bun.test.ts b/src/main/daemon/pty-subprocess/spawn-preflight-bun.test.ts new file mode 100644 index 00000000000..d40b30e2b32 --- /dev/null +++ b/src/main/daemon/pty-subprocess/spawn-preflight-bun.test.ts @@ -0,0 +1,41 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const fixture = vi.hoisted((): { shellPid?: number } => ({})) +vi.mock('./bun-pty-process', () => ({ + canUseBunPty: () => true, + spawnBunPty: () => ({ + pid: 41, + get shellProcessId() { + return fixture.shellPid + }, + onExit(callback: (event: { exitCode: number }) => void) { + queueMicrotask(() => callback({ exitCode: 0 })) + return { dispose() {} } + } + }) +})) + +import { runPtySpawnHealthProbe } from './spawn-preflight' + +beforeEach(() => + vi.stubGlobal( + 'process', + Object.create(process, { + platform: { value: 'win32' } + }) + ) +) +afterEach(() => vi.unstubAllGlobals()) + +it.each([undefined, 41, 0])( + 'refuses successful gate exit without shell identity %s', + async (pid) => { + fixture.shellPid = pid + await expect(runPtySpawnHealthProbe()).rejects.toThrow('could not identify the Windows shell') + } +) + +it('accepts successful exit with the separate original shell identity', async () => { + fixture.shellPid = 42 + await expect(runPtySpawnHealthProbe()).resolves.toBeUndefined() +}) diff --git a/src/main/daemon/pty-subprocess/spawn-preflight.ts b/src/main/daemon/pty-subprocess/spawn-preflight.ts index facaf0a69bd..404f5b258f0 100644 --- a/src/main/daemon/pty-subprocess/spawn-preflight.ts +++ b/src/main/daemon/pty-subprocess/spawn-preflight.ts @@ -1,6 +1,7 @@ -import * as pty from 'node-pty' +import type * as pty from 'node-pty' import { statSync } from 'node:fs' import { release } from 'node:os' +import { getCmdExePath } from '../../../shared/windows-batch-spawn' import { ensureNodePtySpawnHelperExecutable, getNodePtySpawnHelperCandidates, @@ -10,9 +11,14 @@ import { import { resolveSafePtyDefaultCwd } from '../../providers/pty-default-cwd' import { TerminalAttachCanceledError } from '../daemon-errors' import { DaemonProtocolError } from '../types' +import { canUseBunPty, spawnBunPty } from './bun-pty-process' const PTY_SPAWN_HEALTH_TIMEOUT_MS = 4_000 +async function loadNodePty(): Promise { + return import('node-pty') +} + function daemonEnvironmentDiagSuffix(): string { const orca = process.env.ORCA_APP_VERSION?.trim() || '0.0.0-dev' const systemVersion = @@ -79,7 +85,7 @@ function preflightDaemonCwd(): void { } function preflightMacNodePtySpawnEnvironment(): void { - if (process.platform !== 'darwin') { + if (process.platform !== 'darwin' || canUseBunPty()) { return } let candidates: string[] @@ -119,7 +125,9 @@ export async function preflightPtySpawn(args: { sessionId: string signal?: AbortSignal }): Promise { - ensureNodePtySpawnHelperExecutable() + if (!canUseBunPty()) { + ensureNodePtySpawnHelperExecutable() + } preflightUnixPtySpawnEnvironment() try { if (process.platform === 'win32') { @@ -154,21 +162,34 @@ export function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: s return formatted } -export function runPtySpawnHealthProbe(): Promise { +export async function runPtySpawnHealthProbe(): Promise { + const requiresShellIdentity = process.platform === 'win32' && canUseBunPty() const cwd = isExistingDirectory(process.env.ORCA_USER_DATA_PATH) ? process.env.ORCA_USER_DATA_PATH : resolveSafePtyDefaultCwd() + const command = + process.platform === 'win32' + ? { file: getCmdExePath(), args: ['/d', '/c', 'exit', '0'] } + : { file: '/bin/sh', args: ['-c', 'exit 0'] } let proc: pty.IPty try { - proc = pty.spawn('/bin/sh', ['-c', 'exit 0'], { - name: 'xterm-256color', - cols: 2, - rows: 1, - cwd, - env: { ...process.env, TERM: 'xterm-256color' } - }) + const env: Record = { TERM: 'xterm-256color' } + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) { + env[key] = value + } + } + proc = canUseBunPty() + ? spawnBunPty({ ...command, cols: 2, rows: 1, cwd, env }) + : (await loadNodePty()).spawn(command.file, command.args, { + name: 'xterm-256color', + cols: 2, + rows: 1, + cwd, + env + }) } catch (err) { - throw formatPtySpawnError(err, '/bin/sh', cwd) + throw formatPtySpawnError(err, command.file, cwd) } return new Promise((resolve, reject) => { @@ -201,7 +222,18 @@ export function runPtySpawnHealthProbe(): Promise { }, PTY_SPAWN_HEALTH_TIMEOUT_MS) exitDisposable = proc.onExit(({ exitCode }) => { if (exitCode === 0) { - finish() + const shellPid = 'shellProcessId' in proc ? proc.shellProcessId : undefined + if ( + requiresShellIdentity && + (typeof shellPid !== 'number' || + !Number.isSafeInteger(shellPid) || + shellPid <= 0 || + shellPid === proc.pid) + ) { + finish(new Error('PTY spawn health check could not identify the Windows shell')) + } else { + finish() + } } else { finish(new Error(`PTY spawn health check exited with code ${exitCode}`)) } @@ -210,10 +242,10 @@ export function runPtySpawnHealthProbe(): Promise { } export function preflightPtySpawnHealth(): boolean { - if (process.platform === 'win32') { + if (process.platform === 'win32' && !canUseBunPty()) { return false } - if (process.platform === 'darwin') { + if (!canUseBunPty()) { ensureNodePtySpawnHelperExecutable() } preflightUnixPtySpawnEnvironment() diff --git a/src/main/daemon/pty-subprocess/subprocess-handle.ts b/src/main/daemon/pty-subprocess/subprocess-handle.ts index 5d7ef163424..56b739bfccc 100644 --- a/src/main/daemon/pty-subprocess/subprocess-handle.ts +++ b/src/main/daemon/pty-subprocess/subprocess-handle.ts @@ -9,8 +9,13 @@ import { isValidPtySize } from '../daemon-pty-size' import type { SubprocessHandle } from '../session-subprocess-handle' import { createPtyForegroundProcessTracker } from './foreground-process-tracker' import { PtyPreListenerEvents } from './pre-listener-events' +import { ptyProcessNameIsSpawnFile } from './spawn-file-foreground-process' +import { inspectSpawnFileWindowsChildProcesses } from './spawn-file-child-processes' -type DisposableNativePty = pty.IPty & { destroy?: () => void } +type DisposableNativePty = pty.IPty & { + destroy?: () => void + signalProcess?: (signal: string) => void +} export function createDaemonPtySubprocessHandle(args: { process: pty.IPty @@ -26,7 +31,7 @@ export function createDaemonPtySubprocessHandle(args: { const reportsChildExitStatus = args.reportsChildExitStatus const proc = args.process // node-pty exposes destroy at runtime but omits it from IPty. - const nativeProc = proc as DisposableNativePty + const nativeProc: DisposableNativePty = proc const events = new PtyPreListenerEvents() let dead = false // I/O failure is not exit evidence; keep termination and producer flow control available. @@ -64,6 +69,10 @@ export function createDaemonPtySubprocessHandle(args: { const slavePath = readPtySlavePath(proc) return { pid: proc.pid, + processNameIsSpawnFile: ptyProcessNameIsSpawnFile(proc), + ...(process.platform === 'win32' + ? { inspectChildProcesses: () => inspectSpawnFileWindowsChildProcesses(proc) } + : {}), shellPath: args.shellPath, shellCwd: args.spawnCwd, shellPathEnv: args.env.PATH, @@ -166,6 +175,14 @@ export function createDaemonPtySubprocessHandle(args: { if (dead) { return } + if (nativeProc.signalProcess) { + try { + nativeProc.signalProcess(sig) + } catch { + /* The process may have exited. */ + } + return + } const signalRootPid = (): void => { try { process.kill(proc.pid, sig) diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-gate-entry.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-gate-entry.ts new file mode 100644 index 00000000000..9c17da7fa18 --- /dev/null +++ b/src/main/daemon/pty-subprocess/windows-bun-pty-gate-entry.ts @@ -0,0 +1,21 @@ +import { unlinkSync } from 'node:fs' +import { readWindowsBunPtyGateRequest, runWindowsBunPtyGate } from './windows-bun-pty-gate' + +async function main(): Promise { + const requestPath = process.argv[2] + if (!requestPath) { + throw new Error('Windows PTY gate request path is required') + } + const request = readWindowsBunPtyGateRequest(requestPath) + // Arguments can contain agent prompts; do not retain them for the shell's lifetime. + unlinkSync(requestPath) + process.exitCode = await runWindowsBunPtyGate(request) +} + +void main().catch((error: unknown) => { + console.error( + '[pty] Windows job gate failed:', + error instanceof Error ? error.message : String(error) + ) + process.exitCode = 1 +}) diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-gate.integration.test.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-gate.integration.test.ts new file mode 100644 index 00000000000..bb1c3d88a47 --- /dev/null +++ b/src/main/daemon/pty-subprocess/windows-bun-pty-gate.integration.test.ts @@ -0,0 +1,89 @@ +import { build } from 'esbuild' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { runProcess, runProcessSync } from '../../../shared/child-process/run-process' +import { orcadBunRuntimeFilename } from '../../../shared/orcad-artifacts' +import { ORCAD_BUN_VERSION } from '../../../shared/orcad-bun-runtime' +import { createWindowsBunPtyLaunch } from './windows-bun-pty-launch' + +const runtimePath = + process.env.BUN_EXECUTABLE ?? + resolve(__dirname, '../../../../out/orcad', orcadBunRuntimeFilename(process.platform)) +const available = existsSync(runtimePath) + +describe.skipIf(!available)('bundled Windows job gate under Bun', () => { + it('executes the worker with real Bun flags and preserves long executable argv', async () => { + expect(runProcessSync({ program: runtimePath, args: ['--version'] }).stdout.trim()).toBe( + ORCAD_BUN_VERSION + ) + const directory = mkdtempSync(join(tmpdir(), 'orca-gate-contract-')) + const workerPath = join(directory, 'windows-bun-pty-gate-entry.js') + try { + await build({ + entryPoints: [join(__dirname, 'windows-bun-pty-gate-entry.ts')], + bundle: true, + platform: 'node', + format: 'cjs', + outfile: workerPath, + logLevel: 'silent' + }) + const argv = ['x'.repeat(16000), 'a b', 'quote"', '%value%&!', '状態', ''] + const env = Object.fromEntries( + Object.entries(process.env).filter( + (entry): entry is [string, string] => entry[1] !== undefined + ) + ) + const launch = createWindowsBunPtyLaunch( + { + file: runtimePath, + args: ['-e', 'console.log(JSON.stringify(process.argv.slice(1)))', ...argv], + cwd: directory, + env + }, + { runtimePath, workerPath } + ) + try { + launch.release() + const result = await runProcess({ + program: launch.command[0]!, + args: launch.command.slice(1), + cwd: directory, + env: launch.env, + timeoutMs: 10_000 + }) + expect(result.timedOut).toBe(false) + expect(result.code, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toEqual(argv) + await expect(launch.waitForSpawn(Promise.resolve(result.code!))).resolves.toBeUndefined() + expect(existsSync(launch.command.at(-1)!)).toBe(false) + } finally { + launch.dispose() + } + const failedLaunch = createWindowsBunPtyLaunch( + { file: join(directory, 'missing-shell.exe'), args: [], cwd: directory, env }, + { runtimePath, workerPath } + ) + try { + failedLaunch.release() + const result = await runProcess({ + program: failedLaunch.command[0]!, + args: failedLaunch.command.slice(1), + cwd: directory, + env: failedLaunch.env, + timeoutMs: 10_000 + }) + expect(result.timedOut).toBe(false) + expect(result.code).toBe(1) + await expect(failedLaunch.waitForSpawn(Promise.resolve(1))).rejects.toThrow( + /missing-shell|ENOENT|not found/ + ) + } finally { + failedLaunch.dispose() + } + } finally { + rmSync(directory, { recursive: true, force: true }) + } + }, 15_000) +}) diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-gate.test.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-gate.test.ts new file mode 100644 index 00000000000..a87870f80d5 --- /dev/null +++ b/src/main/daemon/pty-subprocess/windows-bun-pty-gate.test.ts @@ -0,0 +1,150 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import type { spawnProcess } from '../../../shared/child-process/run-process' +import { runWindowsBunPtyGate, type WindowsBunPtyGateRequest } from './windows-bun-pty-gate' + +const request: WindowsBunPtyGateRequest = { + file: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', + args: ['-NoLogo', '-NoExit', '-Command', 'A'.repeat(16000)], + cwd: 'C:\\work', + gatePath: 'gate', + shellPidPath: 'shell.pid', + runtimeOptions: {} +} + +describe('Windows Bun PTY job gate worker', () => { + it.each(['exit', 'error'] as const)( + 'ignores Windows console interrupts only while supervising a child (%s)', + async (outcome) => { + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const previousListeners = process.listeners('SIGINT') + const child = new EventEmitter() + try { + const result = runWindowsBunPtyGate(request, { + waitForGate: async () => {}, + reportSpawnError: vi.fn(), + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture exposes only the child events the gate consumes. + spawn: () => child as ReturnType + }) + expect(process.listeners('SIGINT')).toHaveLength(previousListeners.length + 1) + await Promise.resolve() + if (outcome === 'exit') { + child.emit('exit', 17) + await expect(result).resolves.toBe(17) + } else { + child.emit('error', new Error('spawn denied')) + await expect(result).rejects.toThrow('spawn denied') + } + expect(process.listeners('SIGINT')).toEqual(previousListeners) + } finally { + platform.mockRestore() + } + } + ) + + it('does not spawn before assignment and propagates the child exit code', async () => { + let release!: () => void + const waitForGate = vi.fn( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const child = Object.assign(new EventEmitter(), { pid: 1234 }) + const reportShellPid = vi.fn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture exposes only the child events and pid the gate consumes. + const spawn = vi.fn(() => child as ReturnType) + const result = runWindowsBunPtyGate(request, { + waitForGate, + spawn, + reportShellPid, + env: { TERM: 'xterm-256color' } + }) + await Promise.resolve() + expect(spawn).not.toHaveBeenCalled() + release() + await Promise.resolve() + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + program: request.file, + args: request.args, + cwd: request.cwd, + stdio: 'inherit' + }) + ) + expect(reportShellPid).not.toHaveBeenCalled() + child.emit('spawn') + expect(reportShellPid).toHaveBeenCalledWith(1234) + child.emit('exit', 17) + await expect(result).resolves.toBe(17) + }) + + it('never starts a child after a failed job gate', async () => { + const spawn = vi.fn() + await expect( + runWindowsBunPtyGate(request, { + waitForGate: async () => { + throw new Error('gate missing') + }, + reportSpawnError: vi.fn(), + spawn + }) + ).rejects.toThrow('gate missing') + expect(spawn).not.toHaveBeenCalled() + }) + + it('keeps supervising the shell when its identity receipt cannot be published', async () => { + const child = Object.assign(new EventEmitter(), { pid: 1234 }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const reportSpawnError = vi.fn() + const result = runWindowsBunPtyGate(request, { + waitForGate: async () => {}, + reportSpawnError, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture exposes only the child events and pid consumed by the gate. + spawn: () => child as ReturnType, + reportShellPid() { + throw new Error('receipt denied') + } + }) + await Promise.resolve() + child.emit('spawn') + expect(warn).toHaveBeenCalledOnce() + child.emit('exit', 17) + await expect(result).resolves.toBe(17) + expect(reportSpawnError).not.toHaveBeenCalled() + warn.mockRestore() + }) + + it('reports a child spawn error instead of a successful wrapper exit', async () => { + const child = new EventEmitter() + const reportSpawnError = vi.fn() + const result = runWindowsBunPtyGate(request, { + waitForGate: async () => {}, + reportSpawnError, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture exposes the error/exit events the gate consumes. + spawn: () => child as ReturnType + }) + await Promise.resolve() + child.emit('error', new Error('spawn denied')) + await expect(result).rejects.toThrow('spawn denied') + expect(reportSpawnError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'spawn denied' }) + ) + }) + + it('reports synchronous native spawn rejection without requiring a child event', async () => { + const reportSpawnError = vi.fn() + await expect( + runWindowsBunPtyGate(request, { + waitForGate: async () => {}, + spawn: () => { + throw new Error('invalid executable') + }, + reportSpawnError + }) + ).rejects.toThrow('invalid executable') + expect(reportSpawnError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'invalid executable' }) + ) + }) +}) diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-gate.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-gate.ts new file mode 100644 index 00000000000..384038c98f7 --- /dev/null +++ b/src/main/daemon/pty-subprocess/windows-bun-pty-gate.ts @@ -0,0 +1,172 @@ +import { readFileSync, statSync, unlinkSync } from 'node:fs' +import { setTimeout as delay } from 'node:timers/promises' +import { win32 } from 'node:path' +import { spawnProcess, type ProcessSpec } from '../../../shared/child-process/run-process' +import { + publishWindowsBunPtyShellPid, + publishWindowsBunPtySpawnError +} from './windows-bun-pty-spawn-receipt' + +export const WINDOWS_BUN_PTY_GATE_ENV = 'ORCA_BUN_PTY_JOB_GATE' +export const WINDOWS_BUN_PTY_RUNTIME_OPTION_KEYS = ['NODE_OPTIONS', 'BUN_OPTIONS'] as const + +export type WindowsBunPtyGateRequest = { + file: string + args: string[] + cwd: string + gatePath: string + shellPidPath: string + runtimeOptions: Partial> +} + +export function readWindowsBunPtyGateRequest(path: string): WindowsBunPtyGateRequest { + if (statSync(path).size > 1024 * 1024) { + throw new Error('Windows PTY gate request exceeds its size limit') + } + let value: unknown + try { + value = JSON.parse(readFileSync(path, 'utf8')) + } catch { + throw new Error('Invalid Windows PTY gate request') + } + const request = value + if ( + typeof request !== 'object' || + request === null || + !('file' in request) || + typeof request.file !== 'string' || + !request.file || + !('args' in request) || + !Array.isArray(request.args) || + !request.args.every((arg): arg is string => typeof arg === 'string') || + !('cwd' in request) || + typeof request.cwd !== 'string' || + !request.cwd || + !('gatePath' in request) || + typeof request.gatePath !== 'string' || + !request.gatePath || + !('shellPidPath' in request) || + typeof request.shellPidPath !== 'string' || + !request.shellPidPath || + !('runtimeOptions' in request) || + typeof request.runtimeOptions !== 'object' || + request.runtimeOptions === null || + Array.isArray(request.runtimeOptions) + ) { + throw new Error('Invalid Windows PTY gate request') + } + const runtimeOptions: WindowsBunPtyGateRequest['runtimeOptions'] = {} + for (const [key, value] of Object.entries(request.runtimeOptions)) { + if ((key !== 'NODE_OPTIONS' && key !== 'BUN_OPTIONS') || typeof value !== 'string') { + throw new Error('Invalid Windows PTY gate request') + } + runtimeOptions[key] = value + } + return { + file: request.file, + args: request.args, + cwd: request.cwd, + gatePath: request.gatePath, + shellPidPath: request.shellPidPath, + runtimeOptions + } +} + +export async function waitForWindowsBunPtyJobGate(gatePath: string): Promise { + const deadline = Date.now() + 30_000 + while (true) { + try { + unlinkSync(gatePath) + return + } catch (error) { + if ( + typeof error !== 'object' || + error === null || + !('code' in error) || + error.code !== 'ENOENT' + ) { + throw error + } + } + if (Date.now() >= deadline) { + throw new Error('Windows PTY job assignment timed out') + } + await delay(5) + } +} + +export function windowsBunPtyChildSpec( + request: WindowsBunPtyGateRequest, + inheritedEnv: NodeJS.ProcessEnv +): ProcessSpec { + const env: NodeJS.ProcessEnv = { ...inheritedEnv, ...request.runtimeOptions } + delete env[WINDOWS_BUN_PTY_GATE_ENV] + delete env.ORCA_BUN_PTY_CHILD_COMMAND + return { + program: request.file, + args: request.args, + cwd: request.cwd, + env, + stdio: 'inherit', + // cmd owns the command text following /K or /C; it must not receive CRT argv escaping. + ...(win32.basename(request.file).toLowerCase() === 'cmd.exe' + ? { windowsVerbatimArguments: true } + : {}) + } +} + +export async function runWindowsBunPtyGate( + request: WindowsBunPtyGateRequest, + deps: { + waitForGate?: (gatePath: string) => Promise + spawn?: typeof spawnProcess + env?: NodeJS.ProcessEnv + reportShellPid?: (pid: number) => void + reportSpawnError?: (error: unknown) => void + } = {} +): Promise { + let spawned = false + // Preserve supervision when Ctrl-C reaches the entire Windows console. + const ignoreInterrupt = (): void => {} + if (process.platform === 'win32') { + process.on('SIGINT', ignoreInterrupt) + } + try { + await (deps.waitForGate ?? waitForWindowsBunPtyJobGate)(request.gatePath) + return await new Promise((resolve, reject) => { + const child = (deps.spawn ?? spawnProcess)( + windowsBunPtyChildSpec(request, deps.env ?? process.env) + ) + child.once('spawn', () => { + spawned = true + if (child.pid !== undefined) { + const report = + deps.reportShellPid ?? + ((pid) => publishWindowsBunPtyShellPid(request.shellPidPath, pid)) + try { + report(child.pid) + } catch (error) { + // Keep supervising the shell; absent identity must remain unverifiable. + console.warn('[pty] Failed to publish Windows shell identity:', error) + } + } + }) + child.once('error', reject) + child.once('exit', (code) => resolve(code ?? 1)) + }) + } catch (error) { + if (!spawned) { + try { + const report = + deps.reportSpawnError ?? + ((error) => publishWindowsBunPtySpawnError(request.shellPidPath, error)) + report(error) + } catch (receiptError) { + console.warn('[pty] Failed to publish Windows shell spawn error:', receiptError) + } + } + throw error + } finally { + process.off('SIGINT', ignoreInterrupt) + } +} diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-job.test.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-job.test.ts new file mode 100644 index 00000000000..ccc3ebc6d1a --- /dev/null +++ b/src/main/daemon/pty-subprocess/windows-bun-pty-job.test.ts @@ -0,0 +1,158 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + __resetWindowsBunPtyJobForTests, + assignCurrentProcessToBunPtyHostJob, + createWindowsBunPtyJob, + type WindowsBunPtyJobNative +} from './windows-bun-pty-job' + +function createNative(overrides: Partial = {}): WindowsBunPtyJobNative { + return { + createJob: vi.fn(() => 7), + configureJob: vi.fn(() => true), + currentProcess: vi.fn(() => 99), + openProcess: vi.fn((_access, pid) => 1_000 + pid), + assignProcess: vi.fn(() => true), + isProcessInJob: vi.fn(() => true), + queryProcessIds: vi.fn(() => [11]), + suspendProcess: vi.fn(() => true), + resumeProcess: vi.fn(() => true), + terminateJob: vi.fn(() => true), + closeHandle: vi.fn(), + ...overrides + } +} + +afterEach(() => { + vi.restoreAllMocks() + __resetWindowsBunPtyJobForTests() +}) + +describe('Windows Bun PTY job ownership', () => { + it('assigns the daemon to one kill-on-close host job', () => { + const native = createNative() + + expect(assignCurrentProcessToBunPtyHostJob(native)).toBe(true) + expect(assignCurrentProcessToBunPtyHostJob(native)).toBe(true) + + expect(native.createJob).toHaveBeenCalledOnce() + expect(native.configureJob).toHaveBeenCalledWith(7, 0x2800) + expect(native.assignProcess).toHaveBeenCalledWith(7, 99) + }) + + it('closes a rejected host job and caches the unavailable result', () => { + const native = createNative({ assignProcess: vi.fn(() => false) }) + + expect(assignCurrentProcessToBunPtyHostJob(native)).toBe(false) + expect(assignCurrentProcessToBunPtyHostJob(native)).toBe(false) + + expect(native.createJob).toHaveBeenCalledOnce() + expect(native.closeHandle).toHaveBeenCalledWith(7) + }) + + it('assigns the gated PTY root before exposing the job', () => { + const native = createNative() + + const job = createWindowsBunPtyJob(11, native) + + expect(job).not.toBeNull() + expect(native.configureJob).toHaveBeenCalledWith(7, 0) + expect(native.openProcess).toHaveBeenCalledWith(0x1901, 11) + expect(native.assignProcess).toHaveBeenCalledWith(7, 1011) + expect(native.closeHandle).toHaveBeenCalledWith(1011) + }) + + it('suspends children that appear during the ownership fence and resumes exact handles', () => { + const queryProcessIds = vi + .fn<() => readonly number[] | null>() + .mockReturnValueOnce([11, 12]) + .mockReturnValueOnce([11, 12, 13]) + .mockReturnValueOnce([11, 12, 13]) + .mockReturnValue([11, 12, 13]) + const native = createNative({ queryProcessIds }) + const job = createWindowsBunPtyJob(11, native)! + vi.mocked(native.closeHandle).mockClear() + + expect(job.pause()).toBe(true) + expect(native.suspendProcess).toHaveBeenCalledWith(1011) + expect(native.suspendProcess).toHaveBeenCalledWith(1012) + expect(native.suspendProcess).toHaveBeenCalledWith(1013) + expect(job.resume()).toBe(true) + + expect(native.resumeProcess).toHaveBeenCalledWith(1011) + expect(native.resumeProcess).toHaveBeenCalledWith(1012) + expect(native.resumeProcess).toHaveBeenCalledWith(1013) + expect(native.closeHandle).toHaveBeenCalledWith(1011) + expect(native.closeHandle).toHaveBeenCalledWith(1012) + expect(native.closeHandle).toHaveBeenCalledWith(1013) + }) + + it('never suspends a PID whose opened handle is outside the owned job', () => { + const native = createNative({ + queryProcessIds: vi.fn(() => [11, 12]), + isProcessInJob: vi.fn((process) => process !== 1012) + }) + const job = createWindowsBunPtyJob(11, native)! + + expect(job.pause()).toBe(false) + + expect(native.suspendProcess).toHaveBeenCalledWith(1011) + expect(native.suspendProcess).not.toHaveBeenCalledWith(1012) + expect(native.resumeProcess).toHaveBeenCalledWith(1011) + expect(native.closeHandle).toHaveBeenCalledWith(1012) + }) + + it('terminates a paused tree without resuming it first', () => { + const native = createNative({ queryProcessIds: vi.fn(() => [11]) }) + const job = createWindowsBunPtyJob(11, native)! + + expect(job.pause()).toBe(true) + expect(job.terminate()).toBe('terminated') + job.close() + + expect(native.terminateJob).toHaveBeenCalledWith(7) + expect(native.resumeProcess).not.toHaveBeenCalled() + expect(native.closeHandle).toHaveBeenCalledWith(1011) + expect(native.closeHandle).toHaveBeenCalledWith(7) + }) + + it('retains an exact handle when resume fails so a later retry can recover it', () => { + const resumeProcess = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true) + const native = createNative({ resumeProcess }) + const job = createWindowsBunPtyJob(11, native)! + vi.mocked(native.closeHandle).mockClear() + + expect(job.pause()).toBe(true) + expect(job.resume()).toBe(false) + expect(native.closeHandle).not.toHaveBeenCalledWith(1011) + expect(job.resume()).toBe(true) + expect(native.closeHandle).toHaveBeenCalledWith(1011) + }) + + it('keeps breakaway denied when forced termination needs kill-on-close', () => { + const native = createNative({ + resumeProcess: vi.fn(() => false), + terminateJob: vi.fn(() => false) + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const job = createWindowsBunPtyJob(11, native)! + expect(job.pause()).toBe(true) + job.close() + expect(native.configureJob).toHaveBeenLastCalledWith(7, 0x2000) + }) + + it('terminates a still-suspended tree instead of abandoning it during close', () => { + const native = createNative({ resumeProcess: vi.fn(() => false) }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const job = createWindowsBunPtyJob(11, native)! + vi.mocked(native.closeHandle).mockClear() + + expect(job.pause()).toBe(true) + job.close() + + expect(native.terminateJob).toHaveBeenCalledWith(7) + expect(native.closeHandle).toHaveBeenCalledWith(1011) + expect(native.closeHandle).toHaveBeenCalledWith(7) + expect(warn).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-job.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-job.ts new file mode 100644 index 00000000000..5f0a7065c85 --- /dev/null +++ b/src/main/daemon/pty-subprocess/windows-bun-pty-job.ts @@ -0,0 +1,218 @@ +import type { JobTerminationOutcome } from '../../windows/windows-pty-job' +import { + __resetWindowsBunPtyNativeForTests, + loadWindowsBunPtyJobNative, + type WindowsBunPtyJobNative, + type WindowsNativeHandle +} from './windows-bun-pty-native' + +export type { WindowsBunPtyJobNative } from './windows-bun-pty-native' + +export type WindowsBunPtyJob = { + listProcessIds(): readonly number[] | null + pause(): boolean + resume(): boolean + terminate(): JobTerminationOutcome + close(): void +} + +const JOB_OBJECT_LIMIT_BREAKAWAY_OK = 0x0000_0800 +const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x0000_2000 +const PROCESS_TERMINATE = 0x0001 +const PROCESS_SET_QUOTA = 0x0100 +const PROCESS_SUSPEND_RESUME = 0x0800 +const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 +const MAX_SUSPEND_PASSES = 8 + +let hostJobAssigned: boolean | null = null + +export function assignCurrentProcessToBunPtyHostJob( + native: WindowsBunPtyJobNative | null = loadWindowsBunPtyJobNative() +): boolean { + if (hostJobAssigned !== null) { + return hostJobAssigned + } + if (!native) { + hostJobAssigned = false + return false + } + const job = native.createJob() + if ( + job === null || + !native.configureJob(job, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK) || + !native.assignProcess(job, native.currentProcess()) + ) { + if (job !== null) { + native.closeHandle(job) + } + hostJobAssigned = false + return false + } + // The host job deliberately lives until Windows closes it during process teardown. + hostJobAssigned = true + return true +} + +class BunPtyJob implements WindowsBunPtyJob { + private readonly suspended = new Map() + private closed = false + private fullySuspended = false + private terminated = false + + constructor( + private readonly rootPid: number, + private readonly handle: WindowsNativeHandle, + private readonly native: WindowsBunPtyJobNative + ) {} + + listProcessIds(): readonly number[] | null { + return this.closed ? null : this.native.queryProcessIds(this.handle) + } + + pause(): boolean { + if (this.closed || this.terminated) { + return false + } + if (this.fullySuspended) { + return true + } + if (this.suspended.size > 0 && !this.resume()) { + return false + } + for (let pass = 0; pass < MAX_SUSPEND_PASSES; pass += 1) { + const pids = this.listProcessIds() + if (!pids) { + this.resume() + return false + } + const ordered = [...pids].sort((left, right) => { + if (left === this.rootPid) { + return -1 + } + if (right === this.rootPid) { + return 1 + } + return left - right + }) + let progressed = false + for (const pid of ordered) { + if (this.suspended.has(pid)) { + continue + } + const process = this.native.openProcess( + PROCESS_SUSPEND_RESUME | PROCESS_QUERY_LIMITED_INFORMATION, + pid + ) + if (process === null) { + continue + } + if (!this.native.isProcessInJob(process, this.handle)) { + this.native.closeHandle(process) + continue + } + if (!this.native.suspendProcess(process)) { + this.native.closeHandle(process) + continue + } + this.suspended.set(pid, process) + progressed = true + } + const remaining = this.listProcessIds() + if (remaining && remaining.every((pid) => this.suspended.has(pid))) { + this.fullySuspended = true + return true + } + if (!remaining || !progressed) { + this.resume() + return false + } + } + this.resume() + return false + } + + resume(): boolean { + this.fullySuspended = false + const ownedPids = this.terminated ? [] : this.listProcessIds() + for (const [pid, process] of this.suspended) { + const processExited = ownedPids !== null && !ownedPids.includes(pid) + if (!this.terminated && !processExited && !this.native.resumeProcess(process)) { + continue + } + this.native.closeHandle(process) + this.suspended.delete(pid) + } + return this.suspended.size === 0 + } + + terminate(): JobTerminationOutcome { + if (this.closed) { + return this.terminated ? 'terminated' : 'unavailable' + } + if (!this.terminated) { + this.terminated = this.native.terminateJob(this.handle) + } + if (this.terminated) { + this.resume() + return 'terminated' + } + return 'unavailable' + } + + close(): void { + if (this.closed) { + return + } + if (!this.resume()) { + console.warn( + '[daemon/pty] Could not resume a Windows PTY tree during cleanup; terminating it' + ) + this.terminated = this.native.terminateJob(this.handle) + if (!this.terminated) { + this.terminated = this.native.configureJob(this.handle, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE) + } + this.resume() + } + this.native.closeHandle(this.handle) + this.closed = true + } +} + +export function createWindowsBunPtyJob( + rootPid: number, + native: WindowsBunPtyJobNative | null = loadWindowsBunPtyJobNative() +): WindowsBunPtyJob | null { + if (!native || !Number.isInteger(rootPid) || rootPid <= 0) { + return null + } + const job = native.createJob() + if (job === null || !native.configureJob(job, 0)) { + if (job !== null) { + native.closeHandle(job) + } + return null + } + const process = native.openProcess( + PROCESS_SET_QUOTA | + PROCESS_TERMINATE | + PROCESS_SUSPEND_RESUME | + PROCESS_QUERY_LIMITED_INFORMATION, + rootPid + ) + if (process === null) { + native.closeHandle(job) + return null + } + const assigned = native.assignProcess(job, process) + native.closeHandle(process) + if (!assigned) { + native.closeHandle(job) + return null + } + return new BunPtyJob(rootPid, job, native) +} + +export function __resetWindowsBunPtyJobForTests(): void { + __resetWindowsBunPtyNativeForTests() + hostJobAssigned = null +} diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-launch.test.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-launch.test.ts new file mode 100644 index 00000000000..1f4df4ef749 --- /dev/null +++ b/src/main/daemon/pty-subprocess/windows-bun-pty-launch.test.ts @@ -0,0 +1,157 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { createWindowsBunPtyLaunch, resolveWindowsBunPtyGateEntry } from './windows-bun-pty-launch' +import { readWindowsBunPtyGateRequest, windowsBunPtyChildSpec } from './windows-bun-pty-gate' +import { publishWindowsBunPtyShellPid } from './windows-bun-pty-spawn-receipt' + +const workerPath = join(__dirname, 'windows-bun-pty-launch.test.ts') + +describe('Windows Bun PTY gated launch', () => { + it('reads only the atomic shell receipt and retains its first identity through cleanup', () => { + const launch = createWindowsBunPtyLaunch( + { file: 'shell.exe', args: [], env: {} }, + { workerPath } + ) + const { shellPidPath } = readWindowsBunPtyGateRequest(launch.command.at(-1)!) + try { + expect(launch.readShellProcessId()).toBeUndefined() + writeFileSync(`${shellPidPath}.pending`, '12') + expect(launch.readShellProcessId()).toBeUndefined() + writeFileSync(shellPidPath, 'not a PID') + expect(launch.readShellProcessId()).toBeUndefined() + for (const receipt of ['0', '-123', '4294967296', '123\n456', '1e3', '12.3']) { + writeFileSync(shellPidPath, receipt) + expect(launch.readShellProcessId()).toBeUndefined() + } + writeFileSync(shellPidPath, '1234') + expect(launch.readShellProcessId()).toBe(1234) + writeFileSync(shellPidPath, '5678') + expect(launch.readShellProcessId()).toBe(1234) + } finally { + launch.dispose() + } + expect(launch.readShellProcessId()).toBe(1234) + }) + + it('publishes a complete PID and leaves no intermediate receipt', () => { + const launch = createWindowsBunPtyLaunch( + { file: 'shell.exe', args: [], env: {} }, + { workerPath } + ) + const { shellPidPath } = readWindowsBunPtyGateRequest(launch.command.at(-1)!) + try { + publishWindowsBunPtyShellPid(shellPidPath, 1234) + expect(launch.readShellProcessId()).toBe(1234) + expect(existsSync(`${shellPidPath}.pending`)).toBe(false) + } finally { + launch.dispose() + } + }) + + it('preserves long executable argv without cmd interpretation and releases only once', () => { + const file = 'C:\\状 態\\%tool%&shell.exe' + const args = ['a b', 'c"d', 'e%F%g', 'h&i', 'j^k', 'bang!', 'line\nbreak', 'x'.repeat(16000)] + const launch = createWindowsBunPtyLaunch( + { file, args, cwd: 'C:\\work tree', env: { TERM: 'xterm-256color' } }, + { workerPath } + ) + const gate = launch.env.ORCA_BUN_PTY_JOB_GATE + const directory = dirname(gate) + try { + const request = readWindowsBunPtyGateRequest(launch.command.at(-1)!) + expect(request).toMatchObject({ file, args, cwd: 'C:\\work tree', gatePath: gate }) + const child = windowsBunPtyChildSpec(request, launch.env) + expect(child.program).toBe(file) + expect(child.args).toEqual(args) + expect(child.windowsVerbatimArguments).toBeUndefined() + expect(child.stdio).toBe('inherit') + expect(child.env).not.toHaveProperty('ORCA_BUN_PTY_JOB_GATE') + expect(launch.windowsVerbatimArguments).toBe(false) + expect(launch.command).toContain('--no-env-file') + expect(launch.command).toContain(`--config=${join(directory, 'bunfig.toml')}`) + expect(launch.command).toContain(`--cwd=${directory}`) + expect(launch.command.join(' ').length).toBeLessThan(8191) + const clear = readFileSync(join(directory, 'clear.cmd')) + expect(clear.includes(Buffer.from('\x1b[3J\x1b[2J\x1b[H'))).toBe(true) + expect(existsSync(gate)).toBe(false) + launch.release() + launch.release() + expect(existsSync(gate)).toBe(true) + } finally { + launch.dispose() + launch.dispose() + } + expect(existsSync(directory)).toBe(false) + }) + + it.each(['/K', '/k', '/C', '/c'])( + 'preserves direct cmd %s command text without CRT escaping', + (commandSwitch) => { + const file = 'C:\\Windows\\System32\\CMD.EXE' + const args = [commandSwitch, 'chcp 65001 > nul & echo 状態%VALUE%!'] + const launch = createWindowsBunPtyLaunch({ file, args, env: {} }, { workerPath }) + try { + const child = windowsBunPtyChildSpec( + readWindowsBunPtyGateRequest(launch.command.at(-1)!), + launch.env + ) + expect(child.program).toBe(file) + expect(child.args).toEqual(args) + expect(child.windowsVerbatimArguments).toBe(true) + } finally { + launch.dispose() + } + } + ) + + it('withholds runtime preload options from the gate while preserving the shell environment', () => { + const env = { + NODE_OPTIONS: '--require C:\\workspace\\hook.js', + BUN_OPTIONS: '--preload hook.js', + TERM: 'xterm-256color' + } + const launch = createWindowsBunPtyLaunch({ file: 'shell.exe', args: [], env }, { workerPath }) + try { + expect(launch.env).not.toHaveProperty('NODE_OPTIONS') + expect(launch.env).not.toHaveProperty('BUN_OPTIONS') + expect( + windowsBunPtyChildSpec(readWindowsBunPtyGateRequest(launch.command.at(-1)!), launch.env).env + ).toEqual(env) + } finally { + launch.dispose() + } + }) + + it('fails before launch when the gate entry is missing', () => { + expect(() => + createWindowsBunPtyLaunch( + { file: 'shell.exe', args: [], env: {} }, + { workerPath: join(workerPath, 'missing') } + ) + ).toThrow('Windows PTY gate entry not found') + }) + + it('rejects a cmd-unsafe line break before creating launch state', () => { + expect(() => + createWindowsBunPtyLaunch( + { file: 'C:\\Windows\\System32\\cmd.exe', args: ['/c', 'first\nsecond'], env: {} }, + { workerPath } + ) + ).toThrow('cmd.exe cannot receive an argument containing a line break') + }) + + it('resolves adjacent, factored-chunk, and unpacked desktop layouts', () => { + const name = 'windows-bun-pty-gate-entry.js' + expect(resolveWindowsBunPtyGateEntry('/orcad', () => true)).toBe(join('/orcad', name)) + expect( + resolveWindowsBunPtyGateEntry( + '/app/out/main/chunks', + (path) => path === join('/app/out/main', name) + ) + ).toBe(join('/app/out/main', name)) + expect(resolveWindowsBunPtyGateEntry('/resources/app.asar/out/main', () => true)).toBe( + join('/resources/app.asar.unpacked/out/main', name) + ) + }) +}) diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-launch.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-launch.ts new file mode 100644 index 00000000000..dda83ad021a --- /dev/null +++ b/src/main/daemon/pty-subprocess/windows-bun-pty-launch.ts @@ -0,0 +1,159 @@ +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, win32 } from 'node:path' +import { + buildWindowsCmdShimCommandLine, + validateWindowsCmdArguments +} from '../../../shared/child-process/windows-command-line' +import { getCmdExePath } from '../../../shared/windows-batch-spawn' +import { + WINDOWS_BUN_PTY_GATE_ENV, + WINDOWS_BUN_PTY_RUNTIME_OPTION_KEYS, + type WindowsBunPtyGateRequest +} from './windows-bun-pty-gate' +import { + readWindowsBunPtySpawnReceipt, + waitForWindowsBunPtySpawn, + type WindowsBunPtySpawnReceipt +} from './windows-bun-pty-spawn-receipt' + +const CLEAR_SEQUENCE = '\x1b[3J\x1b[2J\x1b[H' +const CLEANUP_MAX_RETRIES = 5 +const CLEANUP_RETRY_DELAY_MS = 50 + +export function resolveWindowsBunPtyGateEntry( + runtimeDir = __dirname, + pathExists: (path: string) => boolean = existsSync +): string { + const directory = runtimeDir.replace(/app\.asar(?=[\\/]|$)/, 'app.asar.unpacked') + const candidates = [ + join(directory, 'windows-bun-pty-gate-entry.js'), + join(directory, '..', 'windows-bun-pty-gate-entry.js') + ] + return candidates.find(pathExists) ?? candidates[0]! +} + +function removeLaunchDirectory(directory: string): boolean { + try { + rmSync(directory, { + recursive: true, + force: true, + maxRetries: CLEANUP_MAX_RETRIES, + retryDelay: CLEANUP_RETRY_DELAY_MS + }) + return true + } catch (error) { + console.warn(`[pty] failed to remove Windows Bun launch directory ${directory}:`, error) + return false + } +} + +export type WindowsBunPtyLaunch = { + command: string[] + clearCommand: string[] + env: Record + windowsVerbatimArguments: boolean + readShellProcessId(): number | undefined + waitForSpawn(wrapperExited: Promise): Promise + release(): void + dispose(): void +} + +export function createWindowsBunPtyLaunch( + args: { + file: string + args: string[] + env: Record + cwd?: string + }, + deps: { workerPath?: string; runtimePath?: string } = {} +): WindowsBunPtyLaunch { + if (win32.basename(args.file).toLowerCase() === 'cmd.exe') { + validateWindowsCmdArguments([args.file, ...args.args]) + } + const workerPath = deps.workerPath ?? resolveWindowsBunPtyGateEntry() + if (!existsSync(workerPath)) { + throw new Error(`Windows PTY gate entry not found: ${workerPath}`) + } + const directory = mkdtempSync(join(tmpdir(), 'orca-bun-pty-')) + const gatePath = join(directory, 'job-assigned') + const requestPath = join(directory, 'request.json') + const shellPidPath = join(directory, 'shell.pid') + const configPath = join(directory, 'bunfig.toml') + const clearPath = join(directory, 'clear.cmd') + const cmdExe = getCmdExePath() + let released = false + let disposed = false + let spawnReceipt: WindowsBunPtySpawnReceipt | undefined + const readSpawnReceipt = (): WindowsBunPtySpawnReceipt | undefined => { + if (!disposed) { + spawnReceipt ??= readWindowsBunPtySpawnReceipt(shellPidPath) + } + return spawnReceipt + } + const env: Record = { ...args.env, [WINDOWS_BUN_PTY_GATE_ENV]: gatePath } + const runtimeOptions: WindowsBunPtyGateRequest['runtimeOptions'] = {} + for (const key of WINDOWS_BUN_PTY_RUNTIME_OPTION_KEYS) { + if (env[key] !== undefined) { + runtimeOptions[key] = env[key] + } + delete env[key] + } + + try { + writeFileSync( + requestPath, + JSON.stringify({ + file: args.file, + args: args.args, + cwd: args.cwd ?? process.cwd(), + gatePath, + shellPidPath, + runtimeOptions + } satisfies WindowsBunPtyGateRequest), + { encoding: 'utf8', flag: 'wx', mode: 0o600 } + ) + writeFileSync(configPath, '', { flag: 'wx', mode: 0o600 }) + writeFileSync(clearPath, `@echo off\r\n waitForWindowsBunPtySpawn(readSpawnReceipt, wrapperExited), + release() { + if (released) { + return + } + writeFileSync(gatePath, '', { flag: 'wx' }) + released = true + }, + dispose() { + if (disposed) { + return + } + readSpawnReceipt() + disposed = removeLaunchDirectory(directory) + } + } +} diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-native.test.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-native.test.ts new file mode 100644 index 00000000000..de6ff4ee264 --- /dev/null +++ b/src/main/daemon/pty-subprocess/windows-bun-pty-native.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from 'vitest' +import { queryWindowsBunPtyProcessIds } from './windows-bun-pty-native' + +function writeProcessList(bytes: Uint8Array, pids: number[]): boolean { + const capacity = (bytes.byteLength - 8) / 8 + const view = new DataView(bytes.buffer) + view.setUint32(0, pids.length, true) + view.setUint32(4, Math.min(pids.length, capacity), true) + pids + .slice(0, capacity) + .forEach((pid, index) => view.setBigUint64(8 + index * 8, BigInt(pid), true)) + return pids.length <= capacity +} + +describe('Windows Bun job process enumeration', () => { + it.each([false, true])( + 'grows an incomplete process list when the native call returns %s', + (result) => { + const pids = Array.from({ length: 257 }, (_, index) => index + 1) + const query = vi.fn((bytes: Uint8Array) => writeProcessList(bytes, pids) || result) + expect(queryWindowsBunPtyProcessIds(query)).toEqual(pids) + expect(query.mock.calls.map(([bytes]) => (bytes.byteLength - 8) / 8)).toEqual([64, 256, 1024]) + } + ) + + it('does not mistake a failed native query for an empty job', () => { + const query = vi.fn(() => false) + expect(queryWindowsBunPtyProcessIds(query)).toBeNull() + expect(query).toHaveBeenCalledOnce() + }) + + it('returns an empty list only when the native query succeeds', () => { + expect(queryWindowsBunPtyProcessIds(() => true)).toEqual([]) + }) + + it('bounds growth when a process tree exceeds the inventory limit', () => { + const query = vi.fn((bytes: Uint8Array) => { + new DataView(bytes.buffer).setUint32(0, 20_000, true) + return false + }) + expect(queryWindowsBunPtyProcessIds(query)).toBeNull() + expect(query).toHaveBeenCalledTimes(5) + }) + + it.each([0, 0x1_0000_0000])( + 'refuses invalid PID %s without reporting partial ownership', + (pid) => { + expect( + queryWindowsBunPtyProcessIds((bytes) => writeProcessList(bytes, [1234, pid])) + ).toBeNull() + } + ) +}) diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-native.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-native.ts new file mode 100644 index 00000000000..c9d72e9bf9d --- /dev/null +++ b/src/main/daemon/pty-subprocess/windows-bun-pty-native.ts @@ -0,0 +1,176 @@ +import { createRequire } from 'node:module' + +export type WindowsNativeHandle = number | bigint +type NativePointer = number | bigint + +export type WindowsBunPtyJobNative = { + createJob(): WindowsNativeHandle | null + configureJob(job: WindowsNativeHandle, flags: number): boolean + currentProcess(): WindowsNativeHandle + openProcess(access: number, pid: number): WindowsNativeHandle | null + assignProcess(job: WindowsNativeHandle, process: WindowsNativeHandle): boolean + isProcessInJob(process: WindowsNativeHandle, job: WindowsNativeHandle): boolean + queryProcessIds(job: WindowsNativeHandle): readonly number[] | null + suspendProcess(process: WindowsNativeHandle): boolean + resumeProcess(process: WindowsNativeHandle): boolean + terminateJob(job: WindowsNativeHandle): boolean + closeHandle(handle: WindowsNativeHandle): void +} + +type FfiFunction = { args: readonly string[]; returns: string } +type FfiLibrary = { symbols: T } +type BunFfi = { + dlopen(name: string, symbols: Record): FfiLibrary + ptr(view: ArrayBufferView): NativePointer +} + +type Kernel32 = { + CreateJobObjectW(attributes: null, name: null): WindowsNativeHandle | null + SetInformationJobObject( + job: WindowsNativeHandle, + infoClass: number, + info: NativePointer, + infoLength: number + ): number + GetCurrentProcess(): WindowsNativeHandle + OpenProcess(access: number, inherit: number, pid: number): WindowsNativeHandle | null + AssignProcessToJobObject(job: WindowsNativeHandle, process: WindowsNativeHandle): number + IsProcessInJob( + process: WindowsNativeHandle, + job: WindowsNativeHandle, + result: NativePointer + ): number + QueryInformationJobObject( + job: WindowsNativeHandle, + infoClass: number, + info: NativePointer, + infoLength: number, + returnLength: null + ): number + TerminateJobObject(job: WindowsNativeHandle, exitCode: number): number + CloseHandle(handle: WindowsNativeHandle): number +} + +type Ntdll = { + NtSuspendProcess(process: WindowsNativeHandle): number + NtResumeProcess(process: WindowsNativeHandle): number +} + +const requireFromMain = createRequire(__filename) +const JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9 +const JOB_OBJECT_BASIC_PROCESS_ID_LIST = 3 +const JOB_LIMIT_FLAGS_OFFSET = 16 +const JOB_EXTENDED_LIMITS_BYTES = 144 +const MAX_JOB_PROCESS_IDS = 16_384 + +export function queryWindowsBunPtyProcessIds( + query: (buffer: Uint8Array) => boolean +): readonly number[] | null { + for (let capacity = 64; capacity <= MAX_JOB_PROCESS_IDS; capacity *= 4) { + const bytes = new Uint8Array(8 + capacity * 8) + const queried = query(bytes) + const view = new DataView(bytes.buffer) + const assigned = view.getUint32(0, true) + const count = view.getUint32(4, true) + // These output counts survive the FFI boundary; thread-local GetLastError may not. + if (assigned > count) { + continue + } + if (!queried || count > capacity) { + return null + } + const pids: number[] = [] + for (let index = 0; index < count; index += 1) { + const pid = Number(view.getBigUint64(8 + index * 8, true)) + if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 0xffff_ffff) { + return null + } + pids.push(pid) + } + return pids + } + return null +} + +let cachedNative: WindowsBunPtyJobNative | null | undefined + +export function loadWindowsBunPtyJobNative(): WindowsBunPtyJobNative | null { + if (cachedNative !== undefined) { + return cachedNative + } + if (process.platform !== 'win32') { + cachedNative = null + return cachedNative + } + try { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned Bun runtime supplies these FFI exports; loading failures refuse job ownership. + const ffi = requireFromMain('bun:ffi') as BunFfi + const kernel = ffi.dlopen('kernel32.dll', { + CreateJobObjectW: { args: ['ptr', 'ptr'], returns: 'ptr' }, + SetInformationJobObject: { args: ['ptr', 'u32', 'ptr', 'u32'], returns: 'i32' }, + GetCurrentProcess: { args: [], returns: 'ptr' }, + OpenProcess: { args: ['u32', 'i32', 'u32'], returns: 'ptr' }, + AssignProcessToJobObject: { args: ['ptr', 'ptr'], returns: 'i32' }, + IsProcessInJob: { args: ['ptr', 'ptr', 'ptr'], returns: 'i32' }, + QueryInformationJobObject: { + args: ['ptr', 'u32', 'ptr', 'u32', 'ptr'], + returns: 'i32' + }, + TerminateJobObject: { args: ['ptr', 'u32'], returns: 'i32' }, + CloseHandle: { args: ['ptr'], returns: 'i32' } + }) + const ntdll = ffi.dlopen('ntdll.dll', { + NtSuspendProcess: { args: ['ptr'], returns: 'i32' }, + NtResumeProcess: { args: ['ptr'], returns: 'i32' } + }) + const { symbols } = kernel + cachedNative = { + createJob: () => symbols.CreateJobObjectW(null, null), + configureJob(job, flags) { + const limits = new Uint8Array(JOB_EXTENDED_LIMITS_BYTES) + new DataView(limits.buffer).setUint32(JOB_LIMIT_FLAGS_OFFSET, flags, true) + return ( + symbols.SetInformationJobObject( + job, + JOB_OBJECT_EXTENDED_LIMIT_INFORMATION, + ffi.ptr(limits), + limits.byteLength + ) !== 0 + ) + }, + currentProcess: () => symbols.GetCurrentProcess(), + openProcess: (access, pid) => symbols.OpenProcess(access, 0, pid), + assignProcess: (job, process) => symbols.AssignProcessToJobObject(job, process) !== 0, + isProcessInJob(process, job) { + const result = new Uint32Array(1) + return symbols.IsProcessInJob(process, job, ffi.ptr(result)) !== 0 && result[0] !== 0 + }, + queryProcessIds(job) { + return queryWindowsBunPtyProcessIds( + (bytes) => + symbols.QueryInformationJobObject( + job, + JOB_OBJECT_BASIC_PROCESS_ID_LIST, + ffi.ptr(bytes), + bytes.byteLength, + null + ) !== 0 + ) + }, + suspendProcess: (process) => ntdll.symbols.NtSuspendProcess(process) >= 0, + resumeProcess: (process) => ntdll.symbols.NtResumeProcess(process) >= 0, + terminateJob: (job) => symbols.TerminateJobObject(job, 1) !== 0, + closeHandle: (handle) => { + symbols.CloseHandle(handle) + } + } + return cachedNative + } catch { + cachedNative = null + return cachedNative + } +} + +export function __resetWindowsBunPtyNativeForTests(): void { + cachedNative = undefined +} diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-spawn-receipt.test.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-spawn-receipt.test.ts new file mode 100644 index 00000000000..56824521921 --- /dev/null +++ b/src/main/daemon/pty-subprocess/windows-bun-pty-spawn-receipt.test.ts @@ -0,0 +1,90 @@ +import { existsSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createWindowsBunPtyLaunch } from './windows-bun-pty-launch' +import { readWindowsBunPtyGateRequest } from './windows-bun-pty-gate' +import { + publishWindowsBunPtyShellPid, + publishWindowsBunPtySpawnError, + WindowsBunPtySpawnUnconfirmedError +} from './windows-bun-pty-spawn-receipt' + +const workerPath = join(__dirname, 'windows-bun-pty-spawn-receipt.test.ts') +const neverExits = new Promise(() => {}) + +describe('Windows Bun shell spawn confirmation', () => { + afterEach(() => vi.useRealTimers()) + + it('waits for the actual shell and preserves successful immediate exit through cleanup', async () => { + const launch = createWindowsBunPtyLaunch( + { file: 'shell.exe', args: [], env: {} }, + { workerPath } + ) + const { shellPidPath } = readWindowsBunPtyGateRequest(launch.command.at(-1)!) + const ready = vi.fn() + let exit!: (code: number) => void + const exited = new Promise((resolve) => { + exit = resolve + }) + const waiting = launch.waitForSpawn(exited).then(ready) + try { + await Promise.resolve() + expect(ready).not.toHaveBeenCalled() + publishWindowsBunPtyShellPid(shellPidPath, 1234) + exit(17) + launch.dispose() + await waiting + expect(ready).toHaveBeenCalledOnce() + expect(launch.readShellProcessId()).toBe(1234) + expect(existsSync(dirname(shellPidPath))).toBe(false) + } finally { + launch.dispose() + } + }) + + it('preserves a definite spawn error through cleanup so the caller can retry another shell', async () => { + const launch = createWindowsBunPtyLaunch( + { file: 'shell.exe', args: [], env: {} }, + { workerPath } + ) + const { shellPidPath } = readWindowsBunPtyGateRequest(launch.command.at(-1)!) + try { + publishWindowsBunPtySpawnError(shellPidPath, new Error('spawn ENOENT')) + expect(existsSync(`${shellPidPath}.error.pending`)).toBe(false) + launch.dispose() + await expect(launch.waitForSpawn(Promise.resolve(1))).rejects.toThrow('spawn ENOENT') + } finally { + launch.dispose() + } + }) + + it('refuses to retry an exited gate without a receipt because its shell may have run', async () => { + const launch = createWindowsBunPtyLaunch( + { file: 'shell.exe', args: [], env: {} }, + { workerPath } + ) + try { + await expect(launch.waitForSpawn(Promise.resolve(0))).rejects.toBeInstanceOf( + WindowsBunPtySpawnUnconfirmedError + ) + } finally { + launch.dispose() + } + }) + + it('bounds the wait for a live gate that never publishes its shell identity', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + const launch = createWindowsBunPtyLaunch( + { file: 'shell.exe', args: [], env: {} }, + { workerPath } + ) + try { + const waiting = launch.waitForSpawn(neverExits) + const assertion = expect(waiting).rejects.toBeInstanceOf(WindowsBunPtySpawnUnconfirmedError) + vi.setSystemTime(Date.now() + 30_001) + await assertion + } finally { + launch.dispose() + } + }) +}) diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-spawn-receipt.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-spawn-receipt.ts new file mode 100644 index 00000000000..43d193b76af --- /dev/null +++ b/src/main/daemon/pty-subprocess/windows-bun-pty-spawn-receipt.ts @@ -0,0 +1,66 @@ +import { readFileSync, renameSync, writeFileSync } from 'node:fs' +import { setTimeout as delay } from 'node:timers/promises' + +export type WindowsBunPtySpawnReceipt = { pid: number } | { error: string } + +export class WindowsBunPtySpawnUnconfirmedError extends Error {} + +function publishReceipt(path: string, value: string): void { + const pending = `${path}.pending` + writeFileSync(pending, value, { flag: 'wx', mode: 0o600 }) + // ConPTY cannot inherit Bun IPC; publish the receipt atomically. + renameSync(pending, path) +} + +export function publishWindowsBunPtyShellPid(path: string, pid: number): void { + publishReceipt(path, String(pid)) +} + +export function publishWindowsBunPtySpawnError(path: string, error: unknown): void { + publishReceipt(`${path}.error`, error instanceof Error ? error.message : String(error)) +} + +export function readWindowsBunPtySpawnReceipt(path: string): WindowsBunPtySpawnReceipt | undefined { + try { + const receipt = readFileSync(path, 'utf8') + const pid = Number(receipt) + if (/^[1-9][0-9]{0,9}$/.test(receipt) && Number.isSafeInteger(pid) && pid <= 0xffff_ffff) { + return { pid } + } + } catch { + // Missing or unreadable identity never proves the shell failed to spawn. + } + try { + return { error: readFileSync(`${path}.error`, 'utf8') } + } catch { + return undefined + } +} + +export async function waitForWindowsBunPtySpawn( + readReceipt: () => WindowsBunPtySpawnReceipt | undefined, + wrapperExited: Promise +): Promise { + let ended = false + const markEnded = (): void => { + ended = true + } + void wrapperExited.then(markEnded, markEnded) + const deadline = Date.now() + 30_000 + while (true) { + const receipt = readReceipt() + if (receipt) { + if ('pid' in receipt) { + return + } + if (ended) { + throw new Error(receipt.error) + } + } + if (ended || Date.now() >= deadline) { + // An unreported shell may already have run a startup command; never retry it. + throw new WindowsBunPtySpawnUnconfirmedError('Windows shell spawn could not be confirmed') + } + await delay(5) + } +} diff --git a/src/main/daemon/serialize-grid-transcript-replay.test.ts b/src/main/daemon/serialize-grid-transcript-replay.test.ts index 7bd60cf0205..cbe9b5db2c0 100644 --- a/src/main/daemon/serialize-grid-transcript-replay.test.ts +++ b/src/main/daemon/serialize-grid-transcript-replay.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import { existsSync, readdirSync, readFileSync } from 'node:fs' import { basename, join } from 'node:path' import { describe, expect, it } from 'vitest' @@ -27,9 +28,50 @@ const OLD_ADDON_PATH = process.env.ORCA_OLD_SERIALIZE_ADDON const SEEDS = Math.max(1, Number(process.env.SERIALIZE_TRANSCRIPT_SEEDS) || 2) // Checkpoints (default seeds) whose new replay diverges exactly as the previous -// build's did — pre-existing upstream limitations, not regressions (verified -// with ORCA_OLD_SERIALIZE_ADDON). Shrink when one is fixed. -const KNOWN_PREEXISTING_I2_FAILURES: Record = { less: 6, nano: 2, opencode: 5 } +// build's did — pre-existing serializer limitations, not regressions (verified +// with ORCA_OLD_SERIALIZE_ADDON): the live SGR pen leaks into the alt buffer, and +// an alt buffer first entered after a shrink keeps hidden scrollback. Shrink when one is fixed. +const KNOWN_PREEXISTING_I2_FAILURES: Record = { + less: 6, + nano: 2, + opencode: 5, + // Shrink leaves the cursor one column short; also present in the pre-Qoder serializer. + 'qoder-no-account': 2, + 'qoder-ready': 2, + // Codex 0.157 header border restores with an extra attribute bit (STA-8628 fixtures). + 'codex-0157-config-override-embedded-warning': 22, + 'codex-0157-effort-override-embedded-warning': 4, + 'codex-0157-no-daemon-effort-override': 16, + 'codex-0157-plain-ready': 18, + // Fresh-home 0.157/0.158 captures: the live pen's true-colour fg/bg leaks onto restored cells. + 'codex-0157-fresh-home-daemon-install': 48, + 'codex-0158-fresh-home-greeting': 9, + 'codex-0158-model-announcement-dialog': 8, + // Codex 0.157/0.158 startup-dialog captures: the same live-pen true-colour leak onto restored cells. + 'codex-0157-update-available-dialog': 26, + 'codex-0158-update-available-dialog': 8, + 'codex-0157-hooks-review-dialog': 24, + 'codex-0158-hooks-review-dialog': 8, + 'codex-0157-model-retired-dialog': 22, + 'codex-0158-model-retired-dialog': 6, + // Same extra dim bit on the 0.157/0.158 header row (STA-8834 fixtures). + 'codex-0-157-1-update-dialog': 16, + 'codex-0-158-0-approval': 12, + 'codex-0-158-0-timed-turn': 20, + 'codex-0-158-0-trustprompt': 36, + 'claude-dialog-trust-workspace-answered': 13, + // DSH-TUI's whale intro paints whole rows of 24-bit background, and every one of this + // transcript's divergences is the same shape: `visible-grid row=0`, a true-colour + // background that the round trip does not restore to default. Verified as upstream, not a + // regression, by replaying it against the previous build + // (`build-serialize-addon-at-ref.mjs --ref origin/main`): I1 and I3 both hold. + 'dsh-tui-ready-no-key': 10 +} + +// Exact resize checkpoints and full GridDiff hashes from base 6835b9b4e3ea, not this branch. +const FREEBUFF_BASELINE: Record = JSON.parse( + readFileSync(join(__dirname, '__fixtures__/freebuff-serialize-baseline.json'), 'utf8') +) type Transcript = { name: string; data: string; cols: number; rows: number } type Schedule = 'none' | 'shrink' | 'shrink-grow' | 'jitter' @@ -122,6 +164,7 @@ describe('serialize round trip over captured PTY transcripts', () => { async (_name, transcript) => { const counts: Partial> = {} const blocking: string[] = [] + const failureSignatures: string[] = [] for (const schedule of SCHEDULES) { for (const conpty of [false, true]) { for (let seed = 1; seed <= SEEDS; seed++) { @@ -136,6 +179,14 @@ describe('serialize round trip over captured PTY transcripts', () => { ` ${transcript.name} ${schedule} conpty=${conpty} seed=${seed}: ${d.stage} row=${d.row} ${JSON.stringify(d.expected)?.slice(0, 160)} -> ${JSON.stringify(d.actual)?.slice(0, 160)}` ) } + if (check.gridDiff.new) { + const signature = createHash('sha256') + .update(JSON.stringify(check.gridDiff.new)) + .digest('hex') + failureSignatures.push( + `${schedule}/${conpty}/${seed}/${check.stepIndex}:${signature}` + ) + } for (const verdict of verdicts(check, serializers.length > 1)) { counts[verdict] = (counts[verdict] ?? 0) + 1 if (verdict === 'i1-bytes-differ' || verdict === 'regression') { @@ -152,7 +203,9 @@ describe('serialize round trip over captured PTY transcripts', () => { console.log(`${transcript.name} ${JSON.stringify(counts)}`) } expect(blocking).toEqual([]) - if (!OLD_ADDON_PATH && SEEDS === 2) { + if (SEEDS === 2 && transcript.name.startsWith('freebuff-')) { + expect(failureSignatures).toEqual(FREEBUFF_BASELINE[transcript.name] ?? []) + } else if (!OLD_ADDON_PATH && SEEDS === 2) { expect(counts['new-fail'] ?? 0).toBe(KNOWN_PREEXISTING_I2_FAILURES[transcript.name] ?? 0) } }, diff --git a/src/main/daemon/session-output-plane.ts b/src/main/daemon/session-output-plane.ts index 507ca01a927..04b3f773bda 100644 --- a/src/main/daemon/session-output-plane.ts +++ b/src/main/daemon/session-output-plane.ts @@ -125,6 +125,16 @@ export class SessionOutputPlane { }) } + /** Grounds the emulator and the cold-restore records without a client + * broadcast; attached renderers ground themselves (Reset Terminal). */ + applyInputModeGround(ground: string): void { + if (this.disposed) { + return + } + this.emulator.write(ground) + this.record({ kind: 'output', data: ground }) + } + isCursorOnEmptyPromptLine(): boolean { return this.emulator.isCursorOnEmptyPromptLine() } diff --git a/src/main/daemon/session-reset-input-modes.test.ts b/src/main/daemon/session-reset-input-modes.test.ts new file mode 100644 index 00000000000..ea1aa7e7351 --- /dev/null +++ b/src/main/daemon/session-reset-input-modes.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest' +import { Session } from './session' +import type { SubprocessHandle } from './session-subprocess-handle' + +function createSession() { + let onData: ((data: string) => void) | null = null + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Session reads only these members. + const handle = { + pid: 999, + getForegroundProcess: () => null, + confirmShellForeground: vi.fn(async () => false), + write: () => {}, + resize: () => {}, + pause: () => {}, + resume: () => {}, + kill: () => {}, + forceKill: () => {}, + signal: () => {}, + terminateOwnedTree: () => 'unavailable' as const, + onData(cb: (data: string) => void) { + onData = cb + }, + onExit() {}, + dispose: () => {} + } as unknown as SubprocessHandle + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the omitted options are optional. + const session = new Session({ + sessionId: 'reset', + cols: 80, + rows: 24, + subprocess: handle, + shellReadySupported: false + } as never) + const received: string[] = [] + session.attachClient({ onData: (data: string) => received.push(data), onExit: () => {} }) + return { session, received, emit: (data: string) => onData?.(data) } +} + +async function readModes(session: Session) { + await session.settleShellOwnershipConfirmation() + return session.getSnapshot()!.modes +} + +describe('Session.resetInputModes', () => { + it('grounds modes an app left armed without a command end, and the next snapshot carries it', async () => { + const { session, received, emit } = createSession() + // A crashed app's arming with no OSC 133;D the barrier could ground at. + emit('prompt$ app\r\n\x1b[>31u\x1b[?1000h\x1b[?1006h\x1b[?2004h\x1b[?1h') + const armed = await readModes(session) + expect(armed).toMatchObject({ + kittyKeyboardFlags: 31, + mouseTracking: true, + bracketedPaste: true + }) + session.takePendingOutput(false) + const broadcast = received.length + + session.resetInputModes() + + expect(await readModes(session)).toMatchObject({ + kittyKeyboardFlags: 0, + mouseTracking: false, + sgrMouseMode: false, + bracketedPaste: false, + applicationCursor: false, + alternateScreen: false + }) + // Clients ground themselves; a zero-raw span here would be dropped or duplicated. + expect(received).toHaveLength(broadcast) + const records = session.takePendingOutput(false)!.records + expect(records).toEqual([{ kind: 'output', data: expect.stringContaining('\x1b[<99u') }]) + session.dispose() + }) + + it('keeps focus reporting the terminal host armed for the pane', async () => { + const { session, emit } = createSession() + // ConPTY arms ?1004h before any shell marker. + emit('\x1b[?1004hprompt$ ') + await readModes(session) + session.takePendingOutput(false) + + session.resetInputModes() + + const [record] = session.takePendingOutput(false)!.records + expect(record).toMatchObject({ kind: 'output' }) + expect(record?.kind === 'output' && record.data).not.toContain('\x1b[?1004l') + session.dispose() + }) +}) diff --git a/src/main/daemon/session-subprocess-handle.ts b/src/main/daemon/session-subprocess-handle.ts index 9268686d78e..be336c998f2 100644 --- a/src/main/daemon/session-subprocess-handle.ts +++ b/src/main/daemon/session-subprocess-handle.ts @@ -1,9 +1,12 @@ +import type { PtyChildProcessVerdict } from '../../shared/terminal-process-inspection' import type { TerminalExitCause } from '../../shared/terminal-exit-cause' import type { JobTerminationOutcome } from '../windows/windows-pty-job' export type SubprocessHandle = { pid: number + processNameIsSpawnFile?: boolean + inspectChildProcesses?(): PtyChildProcessVerdict /** Live foreground process name of the PTY (node-pty's `.process`), e.g. * 'claude' / 'codex' / 'zsh'. Null once the child has exited. */ getForegroundProcess(options?: { rawFallback?: boolean }): string | null diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index a52e40a8678..7d113665292 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -4,10 +4,7 @@ import { createSessionOutputPipeline } from './session-output-pipeline' import { SessionProducerPause } from './session-producer-pause' import { SessionShellReadyBarrier } from './session-shell-ready-barrier' import type { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier' -import { - SessionTerminationController, - IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS -} from './session-termination-controller' +import { SessionTerminationController } from './session-termination-controller' import type { SubprocessHandle } from './session-subprocess-handle' import type { JobTerminationOutcome } from '../windows/windows-pty-job' import type { SessionOptions } from './session-options' @@ -21,6 +18,7 @@ import type { TakePendingOutputResult, TerminalSnapshot } from './types' +import type { PtyChildProcessVerdict } from '../../shared/terminal-process-inspection' import type { TerminalExitCause } from '../../shared/terminal-exit-cause' export class Session { @@ -29,6 +27,7 @@ export class Session { readonly terminalHandle: string | null readonly launchAgent: TuiAgent | null readonly wslDistro: string | null + readonly processNameIsSpawnFile: boolean private _state: SessionState = 'running' private _exitCode: number | null = null private _disposed = false @@ -47,6 +46,7 @@ export class Session { this.launchAgent = opts.launchAgent ?? null this.wslDistro = opts.wslDistro ?? null this.subprocess = opts.subprocess + this.processNameIsSpawnFile = opts.subprocess.processNameIsSpawnFile === true this.onSessionExit = opts.onExit const pipeline = createSessionOutputPipeline({ cols: opts.cols, @@ -146,13 +146,9 @@ export class Session { // Daemon POSIX PTYs need the local provider's cooked-echo containment (#13137). // DA1/CPR stay immediate unless an echo-risk reply is already held (#13892, #15559). - if (this.startupIngress.answerLiveQueryReply(data)) { - return - } - - // Why: keep queuing during the post-ready flush-gate window ('ready' but not yet flushed); a - // direct write would race fresh input ahead of the buffered startup command. - if (this.shellReady.tryEnqueue(data)) { + // Why the queue: keep queuing during the post-ready flush-gate window ('ready' but not yet + // flushed); a direct write would race fresh input ahead of the buffered startup command. + if (this.startupIngress.answerLiveQueryReply(data) || this.shellReady.tryEnqueue(data)) { return } @@ -195,9 +191,7 @@ export class Session { this.termination.scheduleForceDisposeFallback() } - async forceKillAndWaitForExit( - timeoutMs = IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS - ): Promise { + async forceKillAndWaitForExit(timeoutMs?: number): Promise { await this.termination.forceKillAndWaitForExit(timeoutMs) } @@ -253,6 +247,10 @@ export class Session { return this.output.getCwd() } + inspectChildProcesses(): PtyChildProcessVerdict { + return this.subprocess.inspectChildProcesses?.() ?? 'unverifiable' + } + getForegroundProcess(options?: { rawFallback?: boolean }): string | null { return this.subprocess.getForegroundProcess(options) } @@ -275,6 +273,10 @@ export class Session { this.output.clearScrollback(this.subprocess, this.shellReady.isGatingWrites) } + resetInputModes(): void { + this.output.applyInputModeGround(this.recoveryBarrier.groundInputModes()) + } + prepareForFinalSnapshot(): string { const held = this.shellReady.releaseHeldBytes() this.startupIngress.snapshotBarrier() diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index 5208f9ced6b..1f0a84b3a0c 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -126,7 +126,8 @@ const UNWRAPPED: ShellLaunchConfig = { */ export function getShellLaunchConfig( shellPath: string, - features: readonly ShellStartupFeature[] + features: readonly ShellStartupFeature[], + options: { hasStartupCommand?: boolean } = {} ): ShellLaunchConfig { const shellName = pathWin32.basename(basename(shellPath)).toLowerCase() @@ -177,17 +178,18 @@ export function getShellLaunchConfig( } } - // Why: mirrors local-pty-shell-ready.ts; markerless fish stays unwrapped. The - // selection is baked into the init command, so fish needs no feature env var. - if (shellName === 'fish' && features.includes('ready')) { + // Why: mirrors local-pty-shell-ready.ts; markerless fish stays unwrapped unless a + // startup command (e.g. Orca's Codex launch) needs the codex wrapper. The selection + // is baked into the init command, so fish needs no feature env var. + if (shellName === 'fish' && (features.includes('ready') || options.hasStartupCommand)) { return { args: [ '-l', '-C', - `${getFishShellReadyInitCommand(SHELL_READY_MARKER)}\n${getFishCodexShellLaunchPreflight()}` + `${getFishShellReadyInitCommand(SHELL_READY_MARKER, features.includes('ready'))}\n${getFishCodexShellLaunchPreflight()}` ], env: {}, - supportsReadyMarker: true + supportsReadyMarker: features.includes('ready') } } diff --git a/src/main/daemon/terminal-armed-input-modes.ts b/src/main/daemon/terminal-armed-input-modes.ts index e2ba02a2ca2..ca0e37a6410 100644 --- a/src/main/daemon/terminal-armed-input-modes.ts +++ b/src/main/daemon/terminal-armed-input-modes.ts @@ -8,7 +8,7 @@ const HOST_ARMABLE_MODE = 1004 type ModeKey = number | 'kitty-main' | 'kitty-alt' // host: HOST_ARMABLE_MODE armed before any marker or by a prompt a 133;C proved; the ground keeps it. // prompt: armed outside a command, unproven until C. -// command: armed after C; still on at 133;D, it triggers the ground. +// command: armed after C; still on at any 133;D, it triggers the ground until disarmed. // stale: anything else still on; the ground clears it without it ever triggering. type ModeOwner = 'host' | 'prompt' | 'command' | 'stale' // Matches xterm.js's eviction limit, so the model drops the same entries. @@ -104,13 +104,14 @@ export class TerminalArmedInputModes { this.enableOwner = 'prompt' } - /** OSC 133;D: true when the command left a mode it armed. Demoting makes it one-shot. */ + /** OSC 133;D: true when a command's mode is still on. Not demoted: a nested shell's + * stray D gets a refuted proof, and the app's real D must still trigger. */ markCommandEnd(): boolean { let left = false for (const [key, owner] of this.owners) { - if (owner === 'command' || owner === 'prompt') { - // The other screen's kitty flags stay parked in xterm and reach no input. - left ||= owner === 'command' && (typeof key === 'number' || key === this.kittyKey()) + // The other screen's kitty flags stay parked in xterm and reach no input. + left ||= owner === 'command' && (typeof key === 'number' || key === this.kittyKey()) + if (owner === 'prompt') { this.owners.set(key, 'stale') } } diff --git a/src/main/daemon/terminal-attach-cancellation.test.ts b/src/main/daemon/terminal-attach-cancellation.test.ts new file mode 100644 index 00000000000..1c9fa0a1ea3 --- /dev/null +++ b/src/main/daemon/terminal-attach-cancellation.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest' +import { waitForTerminalAttachOperation } from './terminal-attach-cancellation' + +describe('terminal attach cancellation', () => { + it('removes cancellation listeners when the operation settles first', async () => { + const controller = new AbortController() + const removeListener = vi.spyOn(controller.signal, 'removeEventListener') + + await expect( + waitForTerminalAttachOperation(Promise.resolve('ready'), controller.signal, 'session-1') + ).resolves.toBe('ready') + + expect(removeListener).toHaveBeenCalledTimes(1) + }) + + it('preserves operation-first ordering when settlement and abort share a turn', async () => { + const controller = new AbortController() + const operation = Promise.withResolvers() + const waiting = waitForTerminalAttachOperation( + operation.promise, + controller.signal, + 'session-3' + ) + operation.resolve('ready') + controller.abort() + await expect(waiting).resolves.toBe('ready') + }) + + it('rejects promptly on cancellation while the operation remains pending', async () => { + const controller = new AbortController() + const operation = new Promise(() => {}) + const waiting = waitForTerminalAttachOperation(operation, controller.signal, 'session-2') + + controller.abort() + + await expect(waiting).rejects.toMatchObject({ name: 'TerminalAttachCanceledError' }) + }) +}) diff --git a/src/main/daemon/terminal-attach-cancellation.ts b/src/main/daemon/terminal-attach-cancellation.ts index 9e87cbdb1bc..21c037d91d3 100644 --- a/src/main/daemon/terminal-attach-cancellation.ts +++ b/src/main/daemon/terminal-attach-cancellation.ts @@ -1,17 +1,17 @@ import { TerminalAttachCanceledError } from './daemon-errors' +import { PromiseSettlementWaiters } from '../../shared/promise-settlement-waiters' -/** Never resolves; only rejects, so it can bound a wait without settling it. */ -export function rejectOnAbort(signal: AbortSignal | undefined, sessionId: string): Promise { +export function waitForTerminalAttachOperation( + operation: Promise, + signal: AbortSignal | undefined, + sessionId: string +): Promise { if (!signal) { - return new Promise(() => {}) + return operation } - return new Promise((_resolve, reject) => { - if (signal.aborted) { - reject(new TerminalAttachCanceledError(sessionId)) - return - } - signal.addEventListener('abort', () => reject(new TerminalAttachCanceledError(sessionId)), { - once: true - }) + return new PromiseSettlementWaiters(operation).wait({ + signal, + abortInMicrotask: true, + createAbortError: () => new TerminalAttachCanceledError(sessionId) }) } diff --git a/src/main/daemon/terminal-descendant-shutdown.test.ts b/src/main/daemon/terminal-descendant-shutdown.test.ts index 52258d0a650..c47727f3f90 100644 --- a/src/main/daemon/terminal-descendant-shutdown.test.ts +++ b/src/main/daemon/terminal-descendant-shutdown.test.ts @@ -28,9 +28,10 @@ describe('terminal shutdown process-table batching', () => { startedAt: 'Mon Jul 13 12:54:47 2026' } ]).flat() + // Each scan is stamped when it starts, after the walks that produced the snapshots. readProcessTable - .mockResolvedValueOnce({ rows, capturedAtMs: Date.now() }) - .mockResolvedValue({ rows: [], capturedAtMs: Date.now() }) + .mockImplementationOnce(async () => ({ rows, capturedAtMs: Date.now() + 1 })) + .mockImplementation(async () => ({ rows: [], capturedAtMs: Date.now() + 1 })) const shutdowns = Array.from({ length: 20 }, (_, index) => { const rootPid = 100 + index const snapshot = collectDescendantRows(rootPid, [ diff --git a/src/main/daemon/terminal-history-log-encode-allocation.test.ts b/src/main/daemon/terminal-history-log-encode-allocation.test.ts new file mode 100644 index 00000000000..00d7a320810 --- /dev/null +++ b/src/main/daemon/terminal-history-log-encode-allocation.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { decodeTerminalHistoryLog, encodeLogBatch, encodeLogHeader } from './terminal-history-log' +import type { PendingOutputRecord } from './types' + +afterEach(() => vi.restoreAllMocks()) + +describe('terminal history batch encoding', () => { + it('preserves released bytes for empty, Unicode, resize and clear records', () => { + const records: PendingOutputRecord[] = [ + { kind: 'output', data: '' }, + { kind: 'output', data: 'Aé🐳\ud800\u0000\x1b[31m' }, + { kind: 'resize', cols: -1, rows: 65536 }, + { kind: 'resize', cols: 80.9, rows: 24.9 }, + { kind: 'clear' } + ] + const encoded = encodeLogBatch(0x1_ffff_ffff, records) + expect(encoded.toString('hex')).toBe( + '0104000000ffffffff0200000000021000000041c3a9f09f90b3efbfbd001b5b33316d03040000000000ffff0304000000500018000400000000' + ) + expect(decodeTerminalHistoryLog(Buffer.concat([encodeLogHeader(3), encoded]))).toEqual({ + generation: 3, + truncatedTail: false, + batches: [ + { + seq: 0xffff_ffff, + records: [ + { kind: 'output', data: '' }, + { kind: 'output', data: 'Aé🐳\ufffd\u0000\x1b[31m' }, + { kind: 'resize', cols: 0, rows: 65535 }, + { kind: 'resize', cols: 80, rows: 24 }, + { kind: 'clear' } + ] + } + ] + }) + expect(encodeLogBatch(-1, []).toString('hex')).toBe('0104000000ffffffff') + }) + + it('encodes a 4 MiB checkpoint increment without intermediate framed buffers', () => { + const records: PendingOutputRecord[] = Array.from({ length: 1024 }, () => ({ + kind: 'output', + data: 'x'.repeat(4096) + })) + const alloc = vi.spyOn(Buffer, 'alloc') + const allocUnsafe = vi.spyOn(Buffer, 'allocUnsafe') + const from = vi.spyOn(Buffer, 'from') + const concat = vi.spyOn(Buffer, 'concat') + + const encoded = encodeLogBatch(17, records) + const observed = { + alloc: alloc.mock.calls.length, + allocUnsafe: allocUnsafe.mock.calls.length, + from: from.mock.calls.length, + concat: concat.mock.calls.length, + concatBytes: concat.mock.results.reduce( + (bytes, result) => + result.type === 'return' && Buffer.isBuffer(result.value) + ? bytes + result.value.length + : bytes, + 0 + ) + } + vi.restoreAllMocks() + console.info( + JSON.stringify({ records: records.length, encodedBytes: encoded.length, ...observed }) + ) + + expect(encoded.length).toBe(9 + 1024 * (5 + 4096)) + expect(decodeTerminalHistoryLog(Buffer.concat([encodeLogHeader(0), encoded]))?.batches).toEqual( + [{ seq: 17, records }] + ) + expect(observed).toEqual({ alloc: 0, allocUnsafe: 1, from: 1024, concat: 0, concatBytes: 0 }) + }) + + it('keeps UTF-16 replacement local to each output record', () => { + const encoded = encodeLogBatch(1, [ + { kind: 'output', data: '\ud800' }, + { kind: 'output', data: '\udc00' }, + { kind: 'output', data: '\ud800\udc00' } + ]) + expect(encoded.toString('hex')).toBe( + '0104000000010000000203000000efbfbd0203000000efbfbd0204000000f0908080' + ) + }) +}) diff --git a/src/main/daemon/terminal-history-log.ts b/src/main/daemon/terminal-history-log.ts index 8f5fdd07c8d..35bdf5c2921 100644 --- a/src/main/daemon/terminal-history-log.ts +++ b/src/main/daemon/terminal-history-log.ts @@ -61,20 +61,43 @@ export function decodeLogHeader(buffer: Buffer): number | null { } export function encodeLogBatch(seq: number, records: PendingOutputRecord[]): Buffer { - const frames: Buffer[] = [encodeFrame(FRAME_BATCH, encodeSeqPayload(seq))] + let byteLength = 9 + const outputPayloads: Buffer[] = [] for (const record of records) { if (record.kind === 'output') { - frames.push(encodeFrame(FRAME_OUTPUT, Buffer.from(record.data, 'utf8'))) - } else if (record.kind === 'resize') { - const payload = Buffer.alloc(4) - payload.writeUInt16LE(clampU16(record.cols), 0) - payload.writeUInt16LE(clampU16(record.rows), 2) - frames.push(encodeFrame(FRAME_RESIZE, payload)) + const payload = Buffer.from(record.data, 'utf8') + outputPayloads.push(payload) + byteLength += 5 + payload.length } else { - frames.push(encodeFrame(FRAME_CLEAR, Buffer.alloc(0))) + byteLength += record.kind === 'resize' ? 9 : 5 } } - return Buffer.concat(frames) + const batch = Buffer.allocUnsafe(byteLength) + batch.writeUInt8(FRAME_BATCH, 0) + batch.writeUInt32LE(4, 1) + batch.writeUInt32LE(seq >>> 0, 5) + let offset = 9 + let outputIndex = 0 + for (const record of records) { + if (record.kind === 'output') { + batch.writeUInt8(FRAME_OUTPUT, offset) + const payload = outputPayloads[outputIndex++] + batch.writeUInt32LE(payload.length, offset + 1) + payload.copy(batch, offset + 5) + offset += 5 + payload.length + } else if (record.kind === 'resize') { + batch.writeUInt8(FRAME_RESIZE, offset) + batch.writeUInt32LE(4, offset + 1) + batch.writeUInt16LE(clampU16(record.cols), offset + 5) + batch.writeUInt16LE(clampU16(record.rows), offset + 7) + offset += 9 + } else { + batch.writeUInt8(FRAME_CLEAR, offset) + batch.writeUInt32LE(0, offset + 1) + offset += 5 + } + } + return batch } /** Returns null for missing magic / unknown format version — callers fall @@ -150,19 +173,6 @@ export function decodeTerminalHistoryLog(buffer: Buffer): TerminalHistoryLogCont return { generation, batches, truncatedTail } } -function encodeFrame(kind: number, payload: Buffer): Buffer { - const header = Buffer.alloc(5) - header.writeUInt8(kind, 0) - header.writeUInt32LE(payload.length, 1) - return Buffer.concat([header, payload]) -} - -function encodeSeqPayload(seq: number): Buffer { - const payload = Buffer.alloc(4) - payload.writeUInt32LE(seq >>> 0, 0) - return payload -} - function clampU16(value: number): number { return Math.max(0, Math.min(0xffff, Math.floor(value))) } diff --git a/src/main/daemon/terminal-history-permission-repair.ts b/src/main/daemon/terminal-history-permission-repair.ts index 7cb8414026e..ffb4ea852cc 100644 --- a/src/main/daemon/terminal-history-permission-repair.ts +++ b/src/main/daemon/terminal-history-permission-repair.ts @@ -116,7 +116,10 @@ export function scheduleTerminalHistoryPermissionRepair(basePath: string): Promi } scheduledBasePaths.delete(oldest.value) } - const { promise, resolve: settle } = Promise.withResolvers() + let settle!: (repaired: boolean) => void + const promise = new Promise((resolve) => { + settle = resolve + }) const timer = setTimeout(() => { repairTerminalHistoryPermissions(key).then(settle, () => settle(false)) }, REPAIR_START_DELAY_MS) diff --git a/src/main/daemon/terminal-host-process-inspection.ts b/src/main/daemon/terminal-host-process-inspection.ts index 2f9fb1491d9..e149179c1aa 100644 --- a/src/main/daemon/terminal-host-process-inspection.ts +++ b/src/main/daemon/terminal-host-process-inspection.ts @@ -1,11 +1,14 @@ import { isShellProcess } from '../../shared/agent-detection' import { recognizeAgentProcess } from '../../shared/agent-process-recognition' +import type { PtyChildProcessVerdict } from '../../shared/terminal-process-inspection' import type { RemoteForegroundEvidence } from '../../shared/foreground-process-evidence' import { getCheapProcessTableSnapshot } from '../../shared/cheap-process-table-snapshot-reader' import { getStrictProcessTableSnapshotWithAge } from '../../shared/process-table-snapshot-reader' import { resolveRemoteForegroundEvidence } from '../providers/agent-foreground-process' import { buildPaneProcessFingerprint } from '../providers/posix-pane-foreground-fingerprint' import type { Session } from './session' +import { resolveSpawnFileForegroundFromRows } from './pty-subprocess/spawn-file-foreground-process' +import { inspectSpawnFileChildProcessesFromRows } from './pty-subprocess/spawn-file-child-processes' import { clearSteadyStateAnchor, getSteadyStateAnchor, @@ -16,6 +19,7 @@ import { SessionNotFoundError } from './types' export type TerminalHostProcessInspection = { foregroundProcess: string | null hasChildProcesses: boolean + childProcessEvidence?: PtyChildProcessVerdict foregroundProcessEvidence?: RemoteForegroundEvidence } @@ -76,13 +80,32 @@ export async function inspectTerminalHostProcess(args: { } args.onTier?.('full') - const foregroundProcess = session.getForegroundProcess() + let foregroundProcess = session.getForegroundProcess() + let childProcessEvidence: PtyChildProcessVerdict | undefined = session.processNameIsSpawnFile + ? 'unverifiable' + : undefined + if (session.processNameIsSpawnFile && process.platform === 'win32' && incarnationMatches) { + foregroundProcess = await session.confirmForegroundProcess() + childProcessEvidence = session.inspectChildProcesses() + } let evidence: RemoteForegroundEvidence if (!incarnationMatches) { evidence = unverifiableEvidence(args, session, 'incarnation_mismatch') } else { try { const snapshot = await getStrictProcessTableSnapshotWithAge() + if (session.processNameIsSpawnFile && process.platform !== 'win32') { + const observed = resolveSpawnFileForegroundFromRows(snapshot.rows, session.pid) + foregroundProcess = observed.available ? observed.processName : foregroundProcess + childProcessEvidence = inspectSpawnFileChildProcessesFromRows( + snapshot.rows, + session.pid, + session.getForegroundProcess({ rawFallback: true }) + ) + if (observed.available && observed.processName && !isShellProcess(observed.processName)) { + childProcessEvidence = 'children' + } + } evidence = resolveRemoteForegroundEvidence( { rootPid: session.pid, fallbackProcess: foregroundProcess }, { @@ -110,7 +133,11 @@ export async function inspectTerminalHostProcess(args: { evidence.verdict === 'live' ? (evidence.processName ?? ordinaryForeground) : foregroundProcess, - hasChildProcesses: nonShellForeground, + hasChildProcesses: + childProcessEvidence === undefined + ? nonShellForeground + : childProcessEvidence !== 'no-children', + ...(childProcessEvidence === undefined ? {} : { childProcessEvidence }), foregroundProcessEvidence: evidence } } diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index 7a8dbe379cc..15e86a70a72 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -12,7 +12,7 @@ import type { TerminalHostTombstones } from './terminal-host-tombstones' import type { TerminalSessionTeardown } from './terminal-session-teardown' import { resolveDaemonSessionScrollbackRows } from './daemon-session-scrollback-window' import { TerminalAttachCanceledError } from './daemon-errors' -import { rejectOnAbort } from './terminal-attach-cancellation' +import { waitForTerminalAttachOperation } from './terminal-attach-cancellation' import { SessionNotFoundError } from './types' import { resolveWslSessionContext } from './wsl-session-context' @@ -47,10 +47,11 @@ export async function createOrAttachTerminalSession( // reaches this a beat after the attach that retired it, and refusing surfaced the raw // SessionNotFoundError to the user. Windows makes it the common case, where the plain-shell // sweep holds the claim across an OS identity probe and taskkill (#18046). - await Promise.race([ + await waitForTerminalAttachOperation( deps.sessionTeardown.settle(opts.sessionId), - rejectOnAbort(opts.cancelSignal, opts.sessionId) - ]) + opts.cancelSignal, + opts.sessionId + ) deps.assertCreateAllowed() existing = deps.sessions.get(opts.sessionId) // Unkillable child, or a fresh teardown claimed it while we waited: still nobody's to recreate. diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 8ccfe31b707..d5669618c7a 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -21,7 +21,7 @@ import { TerminalHostTombstones } from './terminal-host-tombstones' import { listLiveTerminalHostSessions } from './terminal-host-session-listing' import { createOrAttachTerminalSession } from './terminal-host-session-create' import { TerminalAttachCanceledError } from './daemon-errors' -import { rejectOnAbort } from './terminal-attach-cancellation' +import { waitForTerminalAttachOperation } from './terminal-attach-cancellation' import { randomUUID } from 'node:crypto' import { pruneRetiredPtyIncarnations } from '../../shared/retired-pty-incarnations' import { @@ -84,7 +84,7 @@ export class TerminalHost { // Why: the create ahead of us can be stuck on an unreachable share for // minutes. Waiting unconditionally is what let one dead path strand every // later create and attach for the session, so a canceled caller leaves. - await Promise.race([inFlight, rejectOnAbort(opts.cancelSignal, opts.sessionId)]) + await waitForTerminalAttachOperation(inFlight, opts.cancelSignal, opts.sessionId) this.assertCreateOrAttachAllowed(opts) } this.assertCreateOrAttachAllowed(opts) @@ -277,6 +277,10 @@ export class TerminalHost { getAliveTerminalHostSession(this.sessions, sessionId).clearScrollback() } + resetInputModes(sessionId: string): void { + getAliveTerminalHostSession(this.sessions, sessionId).resetInputModes() + } + // Why: null-not-throw — checkpoint is best-effort against a session that may have just exited. getSnapshot(sessionId: string, opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null { return getTerminalHostSnapshot(this.sessions.get(sessionId), opts) diff --git a/src/main/daemon/terminal-mode-rehydrate-sequences.ts b/src/main/daemon/terminal-mode-rehydrate-sequences.ts index ac4e34897b6..8537de08961 100644 --- a/src/main/daemon/terminal-mode-rehydrate-sequences.ts +++ b/src/main/daemon/terminal-mode-rehydrate-sequences.ts @@ -1,11 +1,9 @@ import type { TerminalModes } from './types' import { RESET_GRAPHIC_RENDITION } from '../../shared/terminal-mode-reset-profiles' -// Why no kitty flags here: rehydrateSequences feeds renderer xterms, and -// POST_REPLAY_REATTACH_RESET's deliberate kitty reset (stale CSI-u Ctrl+C -// hazard) must stay authoritative. modes.kittyKeyboardFlags exists for -// emulator re-seed parity only; a re-seeded emulator answers ?0u and -// protocol-conformant programs re-push. +// Why no kitty flags here: renderers re-assert the snapshot's kitty flags +// (carried beside the payload) in their replay epilogue, after these screen +// switches. A re-seeded emulator uses modes.kittyKeyboardFlags directly. export function buildRehydrateSequences(modes: TerminalModes): string { const seqs: string[] = [] if (modes.alternateScreen) { diff --git a/src/main/daemon/terminal-shell-armed-input-mode-recovery.test.ts b/src/main/daemon/terminal-shell-armed-input-mode-recovery.test.ts index fef4d762c69..630fe21d0bf 100644 --- a/src/main/daemon/terminal-shell-armed-input-mode-recovery.test.ts +++ b/src/main/daemon/terminal-shell-armed-input-mode-recovery.test.ts @@ -50,14 +50,14 @@ describe('armed input modes arm the unclean-death trigger', () => { expect(triggers(scanner, chunk)).toBe(false) }) - it('stays one-shot until a fresh enable', () => { + it('re-triggers at every D until the mode is disarmed', () => { const scanner = new TerminalShellLifecycleScanner() expect(triggers(scanner, `${COMMAND_START}\x1b[?1004hRUN${COMMAND_DONE}`)).toBe(true) - // A refuted proof leaves ?1004 armed; later prompts must not pause again. - expect(triggers(scanner, `$ ${COMMAND_START}ls${COMMAND_DONE}`)).toBe(false) - expect(triggers(scanner, `$ ${COMMAND_START}ls${COMMAND_DONE}`)).toBe(false) - expect(triggers(scanner, `$ ${COMMAND_START}\x1b[?1000hRUN${COMMAND_DONE}`)).toBe(true) + // A refuted proof leaves ?1004 armed and command-owned; each later D re-asks. + expect(triggers(scanner, `$ ${COMMAND_START}ls${COMMAND_DONE}`)).toBe(true) + expect(triggers(scanner, `$ ${COMMAND_START}ls${COMMAND_DONE}`)).toBe(true) + expect(triggers(scanner, `\x1b[?1004l$ ${COMMAND_START}ls${COMMAND_DONE}`)).toBe(false) }) it('treats modes the prompt armed before the command started as shell-owned', () => { @@ -342,6 +342,25 @@ describe('Session grounds a proven normal-buffer death', () => { expect(snapshot?.snapshotAnsi).toContain('\x1b[?1004h') }) + it("grounds an agent's modes when it dies after nested shells' refuted Ds", async () => { + const leak = { data: `${COMMAND_START}nested\r\n${COMMAND_DONE}`, confirm: false } + const { snapshot, records, proofs } = await runSteps([ + { data: `${PROMPT_START}$ ${COMMAND_START}\x1b[>1u\x1b[?1003hAGENT` }, + ...Array.from({ length: 5 }, () => leak), + { data: `EXIT\r\n${COMMAND_DONE}${PROMPT_START}$ `, confirm: true }, + { data: `${COMMAND_START}ls\r\n${COMMAND_DONE}${PROMPT_START}$ ` } + ]) + + // The confirmed ground disarms the modes: the next prompt opens no episode. + expect(proofs).toBe(6) + expect( + records.some( + (record) => record.kind === 'output' && record.data.includes(PROCESS_BOUNDARY_GROUND) + ) + ).toBe(true) + expect(snapshot?.modes.mouseTrackingMode).toBe('none') + }) + it('flushes without the ground when the proof is refuted', async () => { const { snapshot, records } = await runNormalBufferDeath(false) diff --git a/src/main/daemon/terminal-shell-lifecycle-scanner.test.ts b/src/main/daemon/terminal-shell-lifecycle-scanner.test.ts index 7d5136d9050..48e1658bef1 100644 --- a/src/main/daemon/terminal-shell-lifecycle-scanner.test.ts +++ b/src/main/daemon/terminal-shell-lifecycle-scanner.test.ts @@ -14,6 +14,7 @@ describe('TerminalShellLifecycleScanner', () => { const events = scanner.scan(chunk) expect(events.uncleanDeathTriggerEnd).toBe(chunk.indexOf('shell-marker')) + expect(events.uncleanDeathTriggerStart).toBe(chunk.indexOf('\x1b]133;D')) expect(chunk.slice(events.uncleanDeathTriggerEnd)).toBe('shell-marker') expect(scanner.isAlternateScreenActive).toBe(true) expect(scanner.owner).toBeUndefined() @@ -45,6 +46,7 @@ describe('TerminalShellLifecycleScanner', () => { const events = scanner.scan('37\x07tail') expect(events.uncleanDeathTriggerEnd).toBe('37\x07'.length) + expect(events.uncleanDeathTriggerStart).toBe(0) expect('37\x07tail'.slice(events.uncleanDeathTriggerEnd)).toBe('tail') expect(scanner.owner).toBeUndefined() }) @@ -268,18 +270,19 @@ describe('TerminalShellLifecycleScanner', () => { }) describe('unclean trigger arming', () => { - it('fires once per alternate-screen occupancy and re-arms only on a fresh entry', () => { + it('fires at every D while the alternate screen stays up, and never once it is left', () => { const scanner = new TerminalShellLifecycleScanner() + const prompt = '\x1b]133;C\x07ls\r\n\x1b]133;D;0\x07' expect(scanner.scan('\x1b[?1049hTUI\x1b]133;D;137\x07').uncleanDeathTriggerEnd).toBeDefined() - // Refuted path: no reset scanned, alt still active — a later prompt's D must not re-trigger. - const second = scanner.scan('\x1b]133;C\x07ls\r\n\x1b]133;D;0\x07') - expect(second.uncleanDeathTriggerEnd).toBeUndefined() - expect(second.cleanExitCandidate).toBeUndefined() + // Refuted path: no reset scanned, so each later D re-asks — one may be the TUI's real death. + for (let index = 0; index < 5; index += 1) { + expect(scanner.scan(prompt).uncleanDeathTriggerEnd).toBeDefined() + } expect(scanner.isAlternateScreenActive).toBe(true) - expect( - scanner.scan('\x1b]133;C\x07\x1b[?1049hAGAIN\x1b]133;D;9\x07').uncleanDeathTriggerEnd - ).toBeDefined() + const left = scanner.scan(`\x1b[?1049l${prompt}`) + expect(left.uncleanDeathTriggerEnd).toBeUndefined() + expect(scanner.scan(prompt).uncleanDeathTriggerEnd).toBeUndefined() }) }) diff --git a/src/main/daemon/terminal-shell-lifecycle-scanner.ts b/src/main/daemon/terminal-shell-lifecycle-scanner.ts index f62ab47cfd3..f93f9c4df66 100644 --- a/src/main/daemon/terminal-shell-lifecycle-scanner.ts +++ b/src/main/daemon/terminal-shell-lifecycle-scanner.ts @@ -34,6 +34,8 @@ export type ShellLifecycleScanEvents = { * and after this index were NOT consumed; the caller re-feeds them. */ uncleanDeathTriggerEnd?: number + /** Where that OSC 133;D begins in the chunk; 0 when it began in an earlier one. */ + uncleanDeathTriggerStart?: number /** An OSC 133;D closed a command that had entered the alternate screen and left it cleanly. */ cleanExitCandidate?: { generation: number } } @@ -54,11 +56,6 @@ export class TerminalShellLifecycleScanner { private altActive = false private commandEnteredAlternateScreen = false private readonly inputModes = new TerminalArmedInputModes() - // Why one-shot: a refuted proof leaves the alt screen up (no reset was ever - // scanned), and without disarming every later prompt's D would re-open a full - // pause-and-inspect episode. Only a fresh alternate-screen enable re-arms; input - // modes are one-shot by markCommandEnd's demotion. - private uncleanTriggerArmed = false get owner(): TerminalOwner | undefined { return this.ownerState @@ -97,7 +94,6 @@ export class TerminalShellLifecycleScanner { // this a mirror seeded mid-TUI never arms its unclean-death trigger and // the whole occupancy loses recovery. this.altActive = opts.alternateScreen - this.uncleanTriggerArmed = opts.alternateScreen this.commandEnteredAlternateScreen = opts.alternateScreen } } @@ -123,7 +119,6 @@ export class TerminalShellLifecycleScanner { this.inputModes.reset() this.altActive = false this.commandEnteredAlternateScreen = false - this.uncleanTriggerArmed = false continue } if (oscPayload !== undefined) { @@ -142,13 +137,12 @@ export class TerminalShellLifecycleScanner { } // An alternate screen or a command's input mode still up at command-finished // means the app died without its own teardown; the caller must repair first. - const leftInputModes = this.inputModes.markCommandEnd() - const uncleanDeath = (this.uncleanTriggerArmed && this.altActive) || leftInputModes + // Every D re-asks: a refuted one may be a nested shell's while the app lives. + const uncleanDeath = this.inputModes.markCommandEnd() || this.altActive const cleanExit = !uncleanDeath && this.commandEnteredAlternateScreen && !this.altActive this.revoke() this.commandEnteredAlternateScreen = false if (uncleanDeath) { - this.uncleanTriggerArmed = false this.scanTail = '' // Why the clamp: a complete OSC can never sit wholly inside the // carried tail (extractScanTail keeps incomplete ones only), so this @@ -158,6 +152,7 @@ export class TerminalShellLifecycleScanner { 0, match.index + match[0].length - previousTailLength ) + events.uncleanDeathTriggerStart = Math.max(0, match.index - previousTailLength) return events } if (cleanExit) { @@ -195,7 +190,6 @@ export class TerminalShellLifecycleScanner { this.altActive = enabled if (enabled) { this.commandEnteredAlternateScreen = true - this.uncleanTriggerArmed = true } } } diff --git a/src/main/daemon/terminal-shell-recovery-barrier.test.ts b/src/main/daemon/terminal-shell-recovery-barrier.test.ts index 7cea4ed314e..dead64c9bec 100644 --- a/src/main/daemon/terminal-shell-recovery-barrier.test.ts +++ b/src/main/daemon/terminal-shell-recovery-barrier.test.ts @@ -4,6 +4,10 @@ import { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier' import type { PtyIngressEmission } from '../../shared/pty-startup-ingress' const TRIGGER = '\x1b[?1049hTUI\x1b]133;D;137\x07' +// The barrier holds the whole D mark; the ground rides on it. +const MARK = '\x1b]133;D;137\x07' +const HEAD = TRIGGER.slice(0, -MARK.length) +const GROUNDED = `${MARK}${PROCESS_BOUNDARY_GROUND}` function passthrough(data: string, rawStartSeq = 0): PtyIngressEmission { return { data, rawStartSeq, rawEndSeq: rawStartSeq + data.length, transformed: false } @@ -47,14 +51,14 @@ describe('TerminalShellRecoveryBarrier', () => { barrier.accept(passthrough(`${TRIGGER}SHELL-PROMPT`, 100)) expect(confirm).toHaveBeenCalledTimes(1) expect(released).toEqual([ - { data: TRIGGER, rawStartSeq: 100, rawEndSeq: 100 + TRIGGER.length, transformed: false } + { data: HEAD, rawStartSeq: 100, rawEndSeq: 100 + HEAD.length, transformed: false } ]) resolveConfirm?.(true) await vi.waitFor(() => expect(released).toHaveLength(3)) expect(released[1]).toEqual({ - data: PROCESS_BOUNDARY_GROUND, - rawStartSeq: 100 + TRIGGER.length, + data: GROUNDED, + rawStartSeq: 100 + HEAD.length, rawEndSeq: 100 + TRIGGER.length, transformed: true }) @@ -80,12 +84,7 @@ describe('TerminalShellRecoveryBarrier', () => { resolveConfirm?.(true) await vi.waitFor(() => expect(released).toHaveLength(4)) - expect(released.map((emission) => emission.data)).toEqual([ - TRIGGER, - PROCESS_BOUNDARY_GROUND, - 'late-1', - 'late-2' - ]) + expect(released.map((emission) => emission.data)).toEqual([HEAD, GROUNDED, 'late-1', 'late-2']) }) it('flushes unmodified with no injection when the proof is refuted', async () => { @@ -97,8 +96,8 @@ describe('TerminalShellRecoveryBarrier', () => { barrier.accept(passthrough(`${TRIGGER}nested-shell`)) resolveConfirm?.(false) - await vi.waitFor(() => expect(released).toHaveLength(2)) - expect(released.map((emission) => emission.data)).toEqual([TRIGGER, 'nested-shell']) + await vi.waitFor(() => expect(released).toHaveLength(3)) + expect(released.map((emission) => emission.data)).toEqual([HEAD, MARK, 'nested-shell']) expect(barrier.getOwner()).toBeUndefined() }) @@ -110,12 +109,12 @@ describe('TerminalShellRecoveryBarrier', () => { }) barrier.accept(passthrough(`${TRIGGER}prompt`)) - await vi.waitFor(() => expect(released).toHaveLength(2)) - expect(released.map((emission) => emission.data)).toEqual([TRIGGER, 'prompt']) + await vi.waitFor(() => expect(released).toHaveLength(3)) + expect(released.map((emission) => emission.data)).toEqual([HEAD, MARK, 'prompt']) resolveConfirm?.(true) await new Promise((resolve) => setTimeout(resolve, 5)) - expect(released).toHaveLength(2) + expect(released).toHaveLength(3) expect(barrier.getOwner()).toBeUndefined() }) @@ -128,7 +127,7 @@ describe('TerminalShellRecoveryBarrier', () => { barrier.accept(passthrough(TRIGGER)) barrier.accept(passthrough('0123456789')) - expect(released.map((emission) => emission.data)).toEqual([TRIGGER, '0123456789']) + expect(released.map((emission) => emission.data)).toEqual([HEAD, MARK, '0123456789']) expect(barrier.getOwner()).toBeUndefined() }) @@ -144,8 +143,8 @@ describe('TerminalShellRecoveryBarrier', () => { alive = false resolveConfirm?.(true) - await vi.waitFor(() => expect(released).toHaveLength(2)) - expect(released.map((emission) => emission.data)).toEqual([TRIGGER, 'prompt']) + await vi.waitFor(() => expect(released).toHaveLength(3)) + expect(released.map((emission) => emission.data)).toEqual([HEAD, MARK, 'prompt']) expect(barrier.getOwner()).toBeUndefined() }) @@ -165,11 +164,11 @@ describe('TerminalShellRecoveryBarrier', () => { await vi.waitFor(() => expect(released.map((emission) => emission.data)).toEqual([ - TRIGGER, - PROCESS_BOUNDARY_GROUND, + HEAD, + GROUNDED, 'first-prompt', - '\x1b[?1049hAGAIN\x1b]133;D;9\x07', - PROCESS_BOUNDARY_GROUND, + '\x1b[?1049hAGAIN', + `\x1b]133;D;9\x07${PROCESS_BOUNDARY_GROUND}`, 'second-prompt' ]) ) @@ -274,13 +273,12 @@ describe('TerminalShellRecoveryBarrier', () => { await vi.waitFor(() => expect(released.map((emission) => emission.data)).toEqual([ head, - '37\x07', - PROCESS_BOUNDARY_GROUND, + `37\x07${PROCESS_BOUNDARY_GROUND}`, 'PROMPT' ]) ) expect(released[1]).toMatchObject({ rawStartSeq: head.length, rawEndSeq: head.length + 3 }) - expect(released[3]).toMatchObject({ + expect(released[2]).toMatchObject({ rawStartSeq: head.length + 3, rawEndSeq: head.length + tail.length }) @@ -308,34 +306,71 @@ describe('TerminalShellRecoveryBarrier', () => { await settled await vi.waitFor(() => - expect(released.map((emission) => emission.data)).toEqual([ - TRIGGER, - PROCESS_BOUNDARY_GROUND, - 'after-poison' - ]) + expect(released.map((emission) => emission.data)).toEqual([HEAD, GROUNDED, 'after-poison']) ) await expect(barrier.idle()).resolves.toBeUndefined() }) - it('opens at most one episode per alternate-screen occupancy after a refuted proof', async () => { + it('asks at every D of a live TUI and releases every byte, in order, unmodified', async () => { const { barrier, released, confirm } = createBarrier({ confirm: async () => false }) - - barrier.accept(passthrough(`${TRIGGER}prompt`)) - await vi.waitFor(() => expect(released).toHaveLength(2)) - expect(confirm).toHaveBeenCalledTimes(1) - - // Why: the refuted path never scans a reset, so alt stays active — later - // ordinary prompts must not each re-open a pause-and-inspect episode. + const leak = '\x1b]133;C\x07nested\x1b]133;D;0\x07' + const sent = [`\x1b[?1049h\x1b[?1003hTUI${leak}frame`] for (let index = 0; index < 5; index += 1) { - const prompt = passthrough(`\x1b]133;C\x07ls\r\n\x1b]133;D;0\x07`) - barrier.accept(prompt) - expect(released.at(-1)).toBe(prompt) + sent.push(`${leak}frame${index}`) } - expect(confirm).toHaveBeenCalledTimes(1) - // A fresh alternate-screen entry re-arms recovery. - barrier.accept(passthrough(`\x1b]133;C\x07\x1b[?1049hAGAIN\x1b]133;D;9\x07`)) - expect(confirm).toHaveBeenCalledTimes(2) + let seq = 0 + for (const data of sent) { + barrier.accept(passthrough(data, seq)) + seq += data.length + } + await vi.waitFor(() => expect(confirm).toHaveBeenCalledTimes(6)) + await barrier.idle() + + expect(released.map((emission) => emission.data).join('')).toBe(sent.join('')) + expect(released.every((emission) => !emission.transformed)).toBe(true) + expect(barrier.getOwner()).toBeUndefined() + }) + + it("grounds a TUI that dies after many nested shells' Ds were refuted", async () => { + let dead = false + const { barrier, released, confirm } = createBarrier({ confirm: async () => dead }) + const leak = '\x1b]133;C\x07nested\x1b]133;D;0\x07' + + barrier.accept(passthrough(`\x1b[?1049hTUI${leak.repeat(5)}`)) + await vi.waitFor(() => expect(confirm).toHaveBeenCalledTimes(5)) + await barrier.idle() + dead = true + barrier.accept(passthrough('\x1b]133;D;137\x07prompt')) + + await vi.waitFor(() => expect(barrier.getOwner()).toBe('shell')) + expect(released.slice(-2).map((emission) => emission.data)).toEqual([GROUNDED, 'prompt']) + // Grounded once: the next prompt opens no episode. + barrier.accept(passthrough('\x1b]133;C\x07ls\x1b]133;D;0\x07')) + expect(confirm).toHaveBeenCalledTimes(6) + }) + + it('grounds a real death D that arrives while a stray D is still being proven', async () => { + const proofs: ((confirmed: boolean) => void)[] = [] + const { barrier, released, confirm } = createBarrier({ + confirm: () => new Promise((resolve) => void proofs.push(resolve)) + }) + const leak = '\x1b[?1049h\x1b[?1003hTUI\x1b]133;C\x07nested\x1b]133;D;0\x07' + + barrier.accept(passthrough(leak, 0)) + barrier.accept(passthrough('frame\x1b]133;D;137\x07prompt', leak.length)) + proofs[0]?.(false) + await vi.waitFor(() => expect(confirm).toHaveBeenCalledTimes(2)) + proofs[1]?.(true) + + await vi.waitFor(() => expect(barrier.getOwner()).toBe('shell')) + expect(released.map((emission) => emission.data)).toEqual([ + leak.slice(0, -'\x1b]133;D;0\x07'.length), + '\x1b]133;D;0\x07', + 'frame', + GROUNDED, + 'prompt' + ]) }) it('bounds awaitProofSettled by its own deadline when a clean-exit proof hangs', async () => { @@ -359,7 +394,7 @@ describe('TerminalShellRecoveryBarrier', () => { const barrier = new TerminalShellRecoveryBarrier({ confirmShellForeground: confirm, release: (emission) => { - if (!headThrown && emission.data === TRIGGER) { + if (!headThrown && emission.data === HEAD) { headThrown = true throw new Error('client transport died mid-broadcast') } @@ -373,10 +408,7 @@ describe('TerminalShellRecoveryBarrier', () => { resolveConfirm?.(true) await vi.waitFor(() => - expect(released.map((emission) => emission.data)).toEqual([ - PROCESS_BOUNDARY_GROUND, - 'SHELL-PROMPT' - ]) + expect(released.map((emission) => emission.data)).toEqual([GROUNDED, 'SHELL-PROMPT']) ) expect(barrier.getOwner()).toBe('shell') }) @@ -385,11 +417,11 @@ describe('TerminalShellRecoveryBarrier', () => { const { barrier, released } = createBarrier({ confirm: () => new Promise(() => {}) }) barrier.accept(passthrough(`${TRIGGER}prompt`)) - expect(released.map((emission) => emission.data)).toEqual([TRIGGER]) + expect(released.map((emission) => emission.data)).toEqual([HEAD]) barrier.flushPending() - expect(released.map((emission) => emission.data)).toEqual([TRIGGER, 'prompt']) + expect(released.map((emission) => emission.data)).toEqual([HEAD, MARK, 'prompt']) expect(barrier.getOwner()).toBeUndefined() }) @@ -398,8 +430,50 @@ describe('TerminalShellRecoveryBarrier', () => { barrier.accept(passthrough(`${TRIGGER}prompt`)) barrier.dispose() - expect(released.map((emission) => emission.data)).toEqual([TRIGGER]) + expect(released.map((emission) => emission.data)).toEqual([HEAD]) barrier.accept(passthrough('after-dispose')) expect(released).toHaveLength(1) }) + + it('carries the ground on an ESC-backslash terminator split from its ESC', async () => { + const { barrier, released } = createBarrier() + const head = '\x1b[?1049hTUI\x1b]133;D;137\x1b' + barrier.accept(passthrough(head, 0)) + barrier.accept(passthrough('\\PROMPT', head.length)) + + await vi.waitFor(() => expect(barrier.getOwner()).toBe('shell')) + expect(released).toEqual([ + passthrough(head, 0), + { + data: `\\${PROCESS_BOUNDARY_GROUND}`, + rawStartSeq: head.length, + rawEndSeq: head.length + 1, + transformed: true + }, + passthrough('PROMPT', head.length + 1) + ]) + }) + + it.each([ + ['confirmed', async () => true], + ['refuted', async () => false], + ['timed out', () => new Promise(() => {})] + ])('covers at least one raw unit with every emission when %s', async (_, confirm) => { + const { barrier, released } = createBarrier({ confirm, maxPendingMs: 20 }) + const stream = [`${TRIGGER}p1`, '\x1b[?1049h\x1b]133;D;1\x1b', '\\p2', `${TRIGGER}`, 'p3'] + let seq = 0 + for (const data of stream) { + barrier.accept(passthrough(data, seq)) + seq += data.length + } + await vi.waitFor(() => expect(released.at(-1)?.rawEndSeq).toBe(seq)) + + expect(released.every((emission) => emission.rawEndSeq > emission.rawStartSeq)).toBe(true) + const raw = released.map((emission) => + emission.transformed + ? emission.data.slice(0, emission.rawEndSeq - emission.rawStartSeq) + : emission.data + ) + expect(raw.join('')).toBe(stream.join('')) + }) }) diff --git a/src/main/daemon/terminal-shell-recovery-barrier.ts b/src/main/daemon/terminal-shell-recovery-barrier.ts index 69149835216..d8a29d4b4b4 100644 --- a/src/main/daemon/terminal-shell-recovery-barrier.ts +++ b/src/main/daemon/terminal-shell-recovery-barrier.ts @@ -7,6 +7,9 @@ import type { TerminalOwner } from '../../shared/terminal-owner' // flood or hang means the trigger misfired, so bail out and flush unmodified. const MAX_QUEUED_BYTES = 262_144 const MAX_PENDING_MS = 750 +// Why bounded: the grounded mark is one indivisible span, and the relay never +// sends a span wider than its 16K source frame. +const MAX_HELD_MARK_CHARS = 4096 export type TerminalShellRecoveryBarrierOptions = { /** Fresh execution-host proof that the spawned shell owns the PTY foreground. */ @@ -22,13 +25,17 @@ export type TerminalShellRecoveryBarrierOptions = { * Ordered output barrier for dead-TUI mode recovery. Sits between startup * ingress and the output plane. When a shell-integration command-done marker * (OSC 133;D) arrives while the alternate screen is still active, the stream - * pauses at that exact byte boundary, the execution host proves the shell owns - * the PTY foreground, and on proof a mode reset is injected as in-stream output - * so every downstream consumer (host emulator, mirrors, attached renderers, + * holds that marker, the execution host proves the shell owns the PTY + * foreground, and on proof a mode reset is injected as in-stream output so + * every downstream consumer (host emulator, mirrors, attached renderers, * history) converges — and the queued shell prompt then paints onto the normal - * buffer instead of the discarded alternate screen. Any failure (refuted proof, - * timeout, overflow, death, disposal) flushes the queue unmodified, preserving - * incumbent behavior. Clean alternate-screen exits prove ownership without + * buffer instead of the discarded alternate screen. Holding the whole marker + * keeps a mid-proof snapshot on an escape boundary. The reset rides on it so + * every emission covers at least one raw unit: a zero-raw span is never sent by + * credit-windowed delivery (SSH relay), and its seq would not rise above a + * mid-proof snapshot, so snapshot-seq dedup would drop it. Any failure (refuted + * proof, timeout, overflow, death, disposal) flushes the queue unmodified, + * preserving incumbent behavior. Clean alternate-screen exits prove ownership without * pausing: the model needs no correction, only snapshot metadata. */ export class TerminalShellRecoveryBarrier { @@ -43,7 +50,6 @@ export class TerminalShellRecoveryBarrier { private queuedBytes = 0 private pending = false private pendingGeneration = 0 - private pendingRawSeq = 0 private pendingEpisode = 0 private bailTimer: ReturnType | null = null private idleWaiters: (() => void)[] = [] @@ -80,6 +86,13 @@ export class TerminalShellRecoveryBarrier { return this.scanner.owner } + /** Reset Terminal: grounds the lifecycle model now and returns the bytes for + * the host's other models. Not released downstream: a zero-raw span is dropped + * by credit-windowed delivery and snapshot-seq dedup, so each client grounds itself. */ + groundInputModes(): string { + return this.scanner.groundProcessBoundary() + } + /** Answers a paired runtime's ownership question from the barrier's settled * state. The barrier scans bytes before any consumer receives them, so its * verdict is never behind the caller's parse position — a fresh process @@ -187,12 +200,16 @@ export class TerminalShellRecoveryBarrier { this.releaseDownstream(emission) return } + // end >= 1 keeps the held mark inside this emission's raw span. const splittable = - !emission.transformed && emission.rawEndSeq - emission.rawStartSeq === emission.data.length + end >= 1 && + !emission.transformed && + emission.rawEndSeq - emission.rawStartSeq === emission.data.length if (!splittable) { // Why skip the episode: the raw-seq boundary inside a transformed emission - // cannot be reconstructed, so release everything and keep the scanner - // honest about the remainder — incumbent behavior for this rare corner. + // (or a mark ending outside this one) cannot be reconstructed, so release + // everything and keep the scanner honest about the remainder — incumbent + // behavior for this rare corner. try { this.releaseDownstream(emission) } catch { @@ -202,20 +219,29 @@ export class TerminalShellRecoveryBarrier { this.consumeForStateOnly(emission.data.slice(end)) return } + const start = Math.max(events.uncleanDeathTriggerStart ?? 0, end - MAX_HELD_MARK_CHARS) + const markSeq = emission.rawStartSeq + start const splitSeq = emission.rawStartSeq + end - try { - this.releaseDownstream({ - data: emission.data.slice(0, end), - rawStartSeq: emission.rawStartSeq, - rawEndSeq: splitSeq, - transformed: false - }) - } catch { - // Why swallowed: the emulator and records already took the head inside - // emit before a client's broadcast threw; aborting here would cost the - // post-boundary prompt its entire recovery episode. + if (start > 0) { + try { + this.releaseDownstream({ + data: emission.data.slice(0, start), + rawStartSeq: emission.rawStartSeq, + rawEndSeq: markSeq, + transformed: false + }) + } catch { + // Why swallowed: the emulator and records already took the head inside + // emit before a client's broadcast threw; aborting here would cost the + // post-boundary prompt its entire recovery episode. + } } - this.enterPending(splitSeq) + this.enterPending({ + data: emission.data.slice(start, end), + rawStartSeq: markSeq, + rawEndSeq: splitSeq, + transformed: false + }) if (end < emission.data.length) { this.enqueue({ data: emission.data.slice(end), @@ -240,14 +266,15 @@ export class TerminalShellRecoveryBarrier { } } - private enterPending(rawSeq: number): void { + /** Opens an episode holding `mark` as the queue head, already scanned. */ + private enterPending(mark: PtyIngressEmission): void { this.pending = true this.pendingEpisode += 1 this.pendingGeneration = this.scanner.generation - this.pendingRawSeq = rawSeq const episode = this.pendingEpisode this.bailTimer = setTimeout(() => this.finishPending(episode, false), this.maxPendingMs) this.bailTimer.unref?.() + this.enqueue(mark) // Why the guard: the callback is injected; a synchronous throw must not // escape after pending flipped true and strand the episode until the bail. let proof: Promise @@ -279,21 +306,9 @@ export class TerminalShellRecoveryBarrier { this.queue = [] this.queuedBytes = 0 try { - if (confirmed && this.isAlive()) { - // Scanned before release so alt-state stays honest. - const ground = this.scanner.groundProcessBoundary() - try { - this.releaseDownstream({ - data: ground, - rawStartSeq: this.pendingRawSeq, - rawEndSeq: this.pendingRawSeq, - transformed: true - }) - } catch { - // Why swallowed: a throwing downstream client must not strand the - // queued prompt bytes below. - } - this.scanner.trySetOwner(this.pendingGeneration) + const mark = queued.shift() + if (mark) { + this.releaseMark(mark, confirmed && this.isAlive()) } for (let index = 0; index < queued.length; index += 1) { if (this.disposed) { @@ -318,6 +333,27 @@ export class TerminalShellRecoveryBarrier { } } + private releaseMark(mark: PtyIngressEmission, grounded: boolean): void { + try { + this.releaseDownstream( + grounded + ? { + ...mark, + // Scanned before release so alt-state stays honest. + data: mark.data + this.scanner.groundProcessBoundary(), + transformed: true + } + : mark + ) + } catch { + // Why swallowed: a throwing downstream client must not strand the + // queued prompt bytes behind it. + } + if (grounded) { + this.scanner.trySetOwner(this.pendingGeneration) + } + } + private enqueue(emission: PtyIngressEmission): void { this.queue.push(emission) this.queuedBytes += emission.data.length diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index d8d88fab033..2ef4dfeb117 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -206,9 +206,10 @@ export type GetCwdRequest = { } } -export type ClearScrollbackRequest = { +// Why resetInputModes is a type, not a clear flag: an older daemon rejects it instead of clearing. +export type TerminalBufferActionRequest = { id: string - type: 'clearScrollback' + type: 'clearScrollback' | 'resetInputModes' payload: { sessionId: string } @@ -321,7 +322,7 @@ export type DaemonRequest = | InspectProcessRequest | ConfirmForegroundProcessRequest | ConfirmShellForegroundRequest - | ClearScrollbackRequest + | TerminalBufferActionRequest | ShutdownRequest | PingRequest | SystemResolverHealthRequest diff --git a/src/main/daemon/windows-conpty-warmup.test.ts b/src/main/daemon/windows-conpty-warmup.test.ts index 1ab19b0c97e..a10d054926c 100644 --- a/src/main/daemon/windows-conpty-warmup.test.ts +++ b/src/main/daemon/windows-conpty-warmup.test.ts @@ -1,6 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type * as pty from 'node-pty' import { warmWindowsConptyOnce } from './windows-conpty-warmup' +import { assignHostProcessToKillOnCloseJob } from '../windows/windows-pty-job' + +vi.mock('../windows/windows-pty-job', () => ({ + assignHostProcessToKillOnCloseJob: vi.fn(() => true) +})) function setPlatform(platform: NodeJS.Platform): () => void { const original = process.platform @@ -17,6 +22,7 @@ afterEach(() => { restorePlatform?.() restorePlatform = null vi.restoreAllMocks() + vi.clearAllMocks() }) function makeFakePty(): { proc: pty.IPty; fireExit: () => void } { @@ -41,6 +47,7 @@ describe('warmWindowsConptyOnce', () => { await flushImmediates() expect(spawnPty).not.toHaveBeenCalled() + expect(assignHostProcessToKillOnCloseJob).not.toHaveBeenCalled() }) it('spawns a short-lived cmd.exe with the bundled ConPTY on Windows', async () => { @@ -52,6 +59,7 @@ describe('warmWindowsConptyOnce', () => { await flushImmediates() expect(spawnPty).toHaveBeenCalledTimes(1) + expect(assignHostProcessToKillOnCloseJob).toHaveBeenCalledBefore(vi.mocked(spawnPty)) const [file, args, options] = vi.mocked(spawnPty).mock.calls[0] expect(String(file).toLowerCase()).toContain('cmd') expect(args).toEqual(['/c', 'exit']) diff --git a/src/main/daemon/windows-conpty-warmup.ts b/src/main/daemon/windows-conpty-warmup.ts index aea1c50b2db..98c3144380c 100644 --- a/src/main/daemon/windows-conpty-warmup.ts +++ b/src/main/daemon/windows-conpty-warmup.ts @@ -1,7 +1,36 @@ import os from 'node:os' -import * as pty from 'node-pty' +import type * as pty from 'node-pty' +import { createRequire } from 'node:module' +import { canUseBunPty, spawnBunPty } from './pty-subprocess/bun-pty-process' +import { assignHostProcessToKillOnCloseJob } from '../windows/windows-pty-job' const WARMUP_KILL_TIMEOUT_MS = 10_000 +const requireFromMain = createRequire(__filename) + +const spawnWarmupPty: typeof pty.spawn = (file, args, options) => { + if (canUseBunPty()) { + if (!Array.isArray(args)) { + throw new Error('Bun PTY requires argument arrays') + } + const env: Record = {} + for (const [key, value] of Object.entries(options.env ?? process.env)) { + if (value !== undefined) { + env[key] = value + } + } + return spawnBunPty({ + file, + args, + cwd: options.cwd ?? os.homedir(), + env, + cols: options.cols ?? 2, + rows: options.rows ?? 1 + }) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: node-pty's installed package implements the declared spawn contract. + const nodePty = requireFromMain('node-pty') as typeof pty + return nodePty.spawn(file, args, options) +} /** * Pays the one-time cost of the first ConPTY spawn (conpty native module @@ -9,7 +38,7 @@ const WARMUP_KILL_TIMEOUT_MS = 10_000 * those binaries) at daemon boot instead of on the user's first terminal. * Measured ~2.7s on a Windows dev profile for the first spawn vs ~70ms after. */ -export function warmWindowsConptyOnce(spawnPty: typeof pty.spawn = pty.spawn): void { +export function warmWindowsConptyOnce(spawnPty: typeof pty.spawn = spawnWarmupPty): void { if (process.platform !== 'win32') { return } @@ -17,6 +46,10 @@ export function warmWindowsConptyOnce(spawnPty: typeof pty.spawn = pty.spawn): v // real spawn arriving first simply does the warming itself. setImmediate(() => { try { + // Warm-up children must die with the daemon, even before its first real terminal. + if (!canUseBunPty()) { + assignHostProcessToKillOnCloseJob() + } const proc = spawnPty(process.env.COMSPEC || 'cmd.exe', ['/c', 'exit'], { name: 'xterm-256color', cols: 2, diff --git a/src/main/dsh/dsh-home-patch.test.ts b/src/main/dsh/dsh-home-patch.test.ts new file mode 100644 index 00000000000..53ab8986715 --- /dev/null +++ b/src/main/dsh/dsh-home-patch.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' +import { + applyManagedDshPatch, + findManagedDshPatchRegion, + readManagedDshHooksConfigPath, + removeManagedDshPatch +} from './dsh-home-patch' + +const HOOKS_PATH = '/home/dev/.orca/agent-hooks/dsh-hooks.json' + +/** applyManagedDshPatch returns null only for files it refuses to edit; these cases expect an edit. */ +function applyOrFail(text: string, hooksPath = HOOKS_PATH): string { + const next = applyManagedDshPatch(text, hooksPath) + if (next === null) { + throw new Error('expected the patch file to be editable') + } + return next +} + +// The body DSH writes into a freshly initialized patch file. +const PRISTINE = [ + '# Your patch layer for this dsh profile, applied after every bundle layer:', + '# a top-level YAML array of loader patch entries (id-targeted config', + '# overrides, disables, and insert lists; `!!js` expressions allowed).', + '[]', + '' +].join('\n') + +const USER_ROWS = ['- id: llm-deepseek', ' config:', " apiKeyEnv: 'MY_KEY'", ''].join('\n') + +describe('applyManagedDshPatch', () => { + it('creates the managed block in an empty file', () => { + const text = applyOrFail('') + expect(readManagedDshHooksConfigPath(text)).toBe(HOOKS_PATH) + expect(text).toContain("name: '@deepseek-ai/dsh-hooks-claude-code'") + expect(text.endsWith('\n')).toBe(true) + }) + + it('replaces the empty flow sequence DSH ships, keeping its comments', () => { + const text = applyOrFail(PRISTINE) + // `- item` after `[]` is a YAML parse error, so the `[]` token has to go. + expect(text).not.toMatch(/^\[]$/m) + expect(text).toContain('# Your patch layer for this dsh profile') + expect(readManagedDshHooksConfigPath(text)).toBe(HOOKS_PATH) + }) + + it('appends after user rows without touching them', () => { + const text = applyOrFail(USER_ROWS) + expect(text.startsWith(USER_ROWS.trimEnd())).toBe(true) + expect(readManagedDshHooksConfigPath(text)).toBe(HOOKS_PATH) + }) + + it('rewrites its own block in place rather than stacking copies', () => { + const once = applyOrFail(USER_ROWS, '/old/path.json') + const twice = applyOrFail(once) + expect(twice.match(/orca-managed-dsh-hooks \(managed by Orca/g)).toHaveLength(1) + expect(readManagedDshHooksConfigPath(twice)).toBe(HOOKS_PATH) + expect(twice).not.toContain('/old/path.json') + }) + + it('is idempotent', () => { + const once = applyOrFail(PRISTINE) + expect(applyOrFail(once)).toBe(once) + }) + + it('treats `[] # comment` as the empty document it is, keeping the comment', () => { + // The exact-match check missed this and appended `- insert:` after the flow sequence. + const text = '[] # keep empty\n' + const next = applyOrFail(text) + expect(next).not.toMatch(/^\s*\[]/m) + expect(next).toContain('# keep empty') + expect(readManagedDshHooksConfigPath(next)).toBe(HOOKS_PATH) + }) + + it.each([ + '- id: llm-deepseek\n config: {}\n', // block sequence — editable + '\n', + '# only a comment\n' + ])('still edits %j', (text) => { + expect(applyManagedDshPatch(text, HOOKS_PATH)).not.toBeNull() + }) + + it.each(['[{ id: llm-deepseek }]\n', '[\n { id: a },\n { id: b }\n]\n'])( + 'refuses to append after the non-empty flow sequence %j', + (text) => { + // YAML forbids a block entry after a flow sequence: appending would leave DSH unable + // to parse the user's own layer either, so there is no safe in-place edit. + expect(applyManagedDshPatch(text, HOOKS_PATH)).toBeNull() + } + ) + + it('quotes a path containing a single quote', () => { + const awkward = "/home/o'brien/.orca/agent-hooks/dsh-hooks.json" + expect(readManagedDshHooksConfigPath(applyOrFail('', awkward))).toBe(awkward) + }) +}) + +describe('removeManagedDshPatch', () => { + it('restores the empty flow sequence when nothing else remains', () => { + const installed = applyOrFail(PRISTINE) + const { text, changed } = removeManagedDshPatch(installed) + expect(changed).toBe(true) + // Without this the file would come back as an unparseable empty document. + expect(text.trimEnd().endsWith('[]')).toBe(true) + expect(text).toContain('# Your patch layer for this dsh profile') + }) + + it('leaves user rows alone and adds no [] when they remain', () => { + const installed = applyOrFail(USER_ROWS) + const { text } = removeManagedDshPatch(installed) + expect(text.trimEnd()).toBe(USER_ROWS.trimEnd()) + }) + + it('reports no change when the file carries no managed block', () => { + expect(removeManagedDshPatch(USER_ROWS)).toEqual({ text: USER_ROWS, changed: false }) + }) +}) + +describe('findManagedDshPatchRegion', () => { + const ORPHAN_START = '# >>> orca-managed-dsh-hooks (managed by Orca; do not edit) >>>' + + it('fails closed on a truncated region rather than guessing its extent', () => { + // Splicing a guessed end marker would delete the user rows that follow. + const truncated = `${ORPHAN_START}\n${USER_ROWS}` + expect(findManagedDshPatchRegion(truncated)).toBeNull() + expect(removeManagedDshPatch(truncated).changed).toBe(false) + }) + + it('never pairs an orphan start with a later block\u2019s end', () => { + // The data-loss shape: an interrupted write leaves an orphan start above the user's + // rows, and the next install appends a complete block below them. Pairing the orphan + // with the new end marker would make the region cover the user's rows, so install + // (rewrite) and remove (strip) would both delete them. + const truncated = `${ORPHAN_START}\n${USER_ROWS}` + const installed = applyOrFail(truncated) + expect(installed).toContain('apiKeyEnv') + + const region = findManagedDshPatchRegion(installed) + expect(region).not.toBeNull() + const covered = installed.split('\n').slice(region?.startLine ?? 0, (region?.endLine ?? 0) + 1) + expect(covered.join('\n')).not.toContain('apiKeyEnv') + + // Both mutating paths must leave the user's rows intact, twice over. + expect(applyOrFail(installed)).toContain('apiKeyEnv') + expect(removeManagedDshPatch(installed).text).toContain('apiKeyEnv') + }) +}) diff --git a/src/main/dsh/dsh-home-patch.ts b/src/main/dsh/dsh-home-patch.ts new file mode 100644 index 00000000000..723d030e214 --- /dev/null +++ b/src/main/dsh/dsh-home-patch.ts @@ -0,0 +1,195 @@ +/** + * Orca's managed block inside `$DSH_HOME/cordis.patch.yml`. + * + * That file is a hand-editable top-level YAML sequence of loader patch entries, and no + * YAML library is vendored in the main process, so Orca manages only its own + * marker-delimited region: install rewrites the region, remove strips it, and everything + * outside the markers is copied through byte for byte. Appending sequence entries to a + * block sequence is always valid YAML, so the region can live at the end of any file. + * + * The one shape that is not append-safe is an empty *flow* sequence (`[]`), which is what + * DSH writes into a freshly initialized patch file. `- item` after `[]` is a parse error, + * so that token is dropped when the managed block is added and restored when it is the + * last thing removed — otherwise the file would come back as an unparseable empty + * document. + */ + +const START_MARKER = '# >>> orca-managed-dsh-hooks (managed by Orca; do not edit) >>>' +const END_MARKER = '# <<< orca-managed-dsh-hooks <<<' + +/** The loader row id Orca owns. A patch row is addressed by id, so this must be stable. */ +const MANAGED_ROW_ID = 'orca-agent-hooks' + +const EMPTY_FLOW_SEQUENCE = '[]' + +export type ManagedDshPatchRegion = { startLine: number; endLine: number } + +function splitLines(text: string): string[] { + return text.split('\n') +} + +/** Locate the managed region, or null when the file carries none. */ +export function findManagedDshPatchRegion(text: string): ManagedDshPatchRegion | null { + // Why the NEAREST preceding start, not the first one: an interrupted write can leave an + // orphan start marker with no end. Pairing that orphan with a LATER block's end marker + // makes the region swallow every row in between — so the next install (which rewrites the + // region) or remove (which strips it) would delete the user's own rows. Walking forward + // and resetting the candidate on each start keeps an orphan un-paired, which leaves it as + // an inert comment line rather than a deletion range. + let startLine = -1 + for (const [index, line] of splitLines(text).entries()) { + const trimmed = line.trim() + if (trimmed === START_MARKER) { + startLine = index + } else if (trimmed === END_MARKER && startLine !== -1) { + return { startLine, endLine: index } + } + } + return null +} + +function buildManagedBlock(managedHooksPath: string): string[] { + return [ + START_MARKER, + '- insert:', + ` - id: ${MANAGED_ROW_ID}`, + " name: '@deepseek-ai/dsh-hooks-claude-code'", + ' config:', + ` configPath: ${quoteYamlScalar(managedHooksPath)}`, + END_MARKER + ] +} + +/** Single-quoted YAML scalar: the only escape inside one is a doubled quote. */ +function quoteYamlScalar(value: string): string { + return `'${value.replaceAll("'", "''")}'` +} + +function unquoteYamlScalar(value: string): string { + const trimmed = value.trim() + if (trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2) { + return trimmed.slice(1, -1).replaceAll("''", "'") + } + if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) { + return trimmed.slice(1, -1) + } + return trimmed +} + +/** The `configPath` Orca's managed region currently points at, if any. */ +export function readManagedDshHooksConfigPath(text: string): string | undefined { + const region = findManagedDshPatchRegion(text) + if (!region) { + return undefined + } + for (const line of splitLines(text).slice(region.startLine + 1, region.endLine)) { + const match = /^\s*configPath:\s*(.+?)\s*$/.exec(line) + if (match) { + return unquoteYamlScalar(match[1]) + } + } + return undefined +} + +function stripRegion(lines: string[], region: ManagedDshPatchRegion): string[] { + return [...lines.slice(0, region.startLine), ...lines.slice(region.endLine + 1)] +} + +function isBlank(line: string): boolean { + return line.trim().length === 0 +} + +function isComment(line: string): boolean { + return line.trim().startsWith('#') +} + +function documentBody(lines: readonly string[]): readonly string[] { + return lines.filter((line) => !isBlank(line) && !isComment(line)) +} + +/** Strips a trailing `# …` so `[] # keep empty` reads as the empty sequence it is. */ +function withoutTrailingComment(line: string): string { + const hash = line.indexOf('#') + return (hash === -1 ? line : line.slice(0, hash)).trim() +} + +/** True when the body is the empty flow sequence, with or without a trailing comment. */ +function isEmptyFlowDocument(lines: readonly string[]): boolean { + const body = documentBody(lines) + return body.length === 1 && withoutTrailingComment(body[0]) === EMPTY_FLOW_SEQUENCE +} + +/** + * True when Orca cannot append its block to this file: the body is a NON-EMPTY flow + * sequence (`[a, b]`, or one spread over lines). + * + * Why it matters: YAML forbids a block entry after a flow sequence, so appending Orca's + * `- insert:` would produce a file DSH cannot parse — losing the user's own patch layer as + * well as Orca's hooks. There is no safe in-place edit, so install refuses instead. + * + * Exported because status has to report the same refusal on every read, not just on the + * install that first hit it. + */ +export function isDshPatchFileUnappendable(text: string): boolean { + const lines = splitLines(text) + const body = documentBody(lines) + return body.length > 0 && body[0].trimStart().startsWith('[') && !isEmptyFlowDocument(lines) +} + +function withoutTrailingBlanks(lines: readonly string[]): readonly string[] { + const end = lines.findLastIndex((line) => !isBlank(line)) + return lines.slice(0, end + 1) +} + +function joinPreservingTrailingNewline(lines: readonly string[]): string { + const text = lines.join('\n') + return text.endsWith('\n') || text.length === 0 ? text : `${text}\n` +} + +/** + * Install (or refresh) Orca's managed region so the DSH hook bridge reads + * `managedHooksPath`. Everything outside the markers is preserved. + * + * Returns null when the file cannot be edited safely — see isNonEmptyFlowDocument. The + * caller reports that; it must never write a file DSH would then fail to parse. + */ +export function applyManagedDshPatch(text: string, managedHooksPath: string): string | null { + const lines = splitLines(text) + const block = buildManagedBlock(managedHooksPath) + const region = findManagedDshPatchRegion(text) + if (region) { + return joinPreservingTrailingNewline([ + ...lines.slice(0, region.startLine), + ...block, + ...lines.slice(region.endLine + 1) + ]) + } + if (isDshPatchFileUnappendable(text)) { + return null + } + // Why dropped: `- item` after `[]` is a parse error, and an empty sequence has nothing to + // preserve. Only the token goes — a trailing comment on that line stays. + const kept = isEmptyFlowDocument(lines) + ? lines.map((line) => + withoutTrailingComment(line) === EMPTY_FLOW_SEQUENCE + ? line.slice(line.indexOf(EMPTY_FLOW_SEQUENCE) + EMPTY_FLOW_SEQUENCE.length) + : line + ) + : lines + return joinPreservingTrailingNewline([...withoutTrailingBlanks(kept), ...block]) +} + +/** Strip Orca's managed region, restoring `[]` when nothing else is left. */ +export function removeManagedDshPatch(text: string): { text: string; changed: boolean } { + const region = findManagedDshPatchRegion(text) + if (!region) { + return { text, changed: false } + } + const kept = withoutTrailingBlanks(stripRegion(splitLines(text), region)) + // Why restore `[]`: a document of comments alone is not a valid entry list, so removing + // Orca's block must not leave DSH a file it cannot parse. + const body = kept.every((line) => isBlank(line) || isComment(line)) + ? [...kept, EMPTY_FLOW_SEQUENCE] + : kept + return { text: joinPreservingTrailingNewline(body), changed: true } +} diff --git a/src/main/dsh/hook-service.test.ts b/src/main/dsh/hook-service.test.ts new file mode 100644 index 00000000000..331ecd97888 --- /dev/null +++ b/src/main/dsh/hook-service.test.ts @@ -0,0 +1,200 @@ +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { DshHookService } from './hook-service' +import { DSH_HOOK_EVENTS } from './hook-settings' + +// Why: getSharedManagedScriptPath() writes under homedir()/.orca and the patch layer +// resolves via DSH_HOME ?? ~/.dsh. Point the home env at a temp dir and clear DSH_HOME so +// install/remove never touches the real ~/.orca or a developer's own DSH home. +// Why both names: os.homedir() reads $HOME on POSIX and %USERPROFILE% on Windows, and this +// file asserts the Windows script name too — setting only HOME would let a Windows run edit +// the developer's real home. +let home: string +let originalHome: string | undefined +let originalUserProfile: string | undefined +let originalDshHome: string | undefined + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'orca-dsh-hook-')) + originalHome = process.env.HOME + originalUserProfile = process.env.USERPROFILE + originalDshHome = process.env.DSH_HOME + process.env.HOME = home + process.env.USERPROFILE = home + delete process.env.DSH_HOME +}) + +afterEach(() => { + if (originalHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = originalHome + } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE + } else { + process.env.USERPROFILE = originalUserProfile + } + if (originalDshHome === undefined) { + delete process.env.DSH_HOME + } else { + process.env.DSH_HOME = originalDshHome + } + rmSync(home, { recursive: true, force: true }) +}) + +const configPath = (): string => join(home, '.dsh', 'cordis.patch.yml') +const managedHooksPath = (): string => join(home, '.orca', 'agent-hooks', 'dsh-hooks.json') +const scriptPath = (): string => + join(home, '.orca', 'agent-hooks', process.platform === 'win32' ? 'dsh-hook.cmd' : 'dsh-hook.sh') + +function readManagedHooks(): { hooks: Record } { + const parsed: { hooks: Record } = JSON.parse( + readFileSync(managedHooksPath(), 'utf-8') + ) + return parsed +} + +describe('DshHookService', () => { + it('reports not_installed before install, with no DSH home on disk', () => { + expect(new DshHookService().getStatus().state).toBe('not_installed') + }) + + it('installs the patch block, the managed hooks file, and the script', () => { + const status = new DshHookService().install() + expect(status.state).toBe('installed') + expect(status.managedHooksPresent).toBe(true) + expect(status.configPath).toBe(configPath()) + + const patch = readFileSync(configPath(), 'utf-8') + expect(patch).toContain("name: '@deepseek-ai/dsh-hooks-claude-code'") + expect(patch).toContain(`configPath: '${managedHooksPath()}'`) + + // Exactly the events DSH's bridge can fire — registering more would register nothing + // for them and leave the status permanently `partial`. + expect(Object.keys(readManagedHooks().hooks).sort()).toEqual([...DSH_HOOK_EVENTS].sort()) + expect(readFileSync(scriptPath(), 'utf-8').length).toBeGreaterThan(0) + }) + + it('posts to the dsh hook route', () => { + new DshHookService().install() + expect(readFileSync(scriptPath(), 'utf-8')).toContain('/hook/dsh') + }) + + it('restores the pane identity DSH scrubs before anything reads it', () => { + // DSH drops env names containing KEY/TOKEN, so the script has to recover ORCA_PANE_KEY + // and ORCA_AGENT_LAUNCH_TOKEN from their aliases before the guard or the spool run. + new DshHookService().install() + const script = readFileSync(scriptPath(), 'utf-8') + const restoreAt = script.indexOf('ORCA_AGENT_PANE') + const guardAt = script.indexOf('ORCA_AGENT_HOOK_PORT') + expect(restoreAt).toBeGreaterThan(-1) + expect(script).toContain('ORCA_AGENT_LAUNCH') + expect(restoreAt).toBeLessThan(guardAt) + }) + + it('is idempotent', () => { + const service = new DshHookService() + service.install() + const first = readFileSync(configPath(), 'utf-8') + expect(service.install().state).toBe('installed') + expect(readFileSync(configPath(), 'utf-8')).toBe(first) + }) + + it('preserves an existing user patch layer through install and remove', () => { + const userRows = ['- id: llm-deepseek', ' config:', ' thinking: disabled', ''].join('\n') + mkdirSync(join(home, '.dsh'), { recursive: true }) + writeFileSync(configPath(), userRows, 'utf-8') + + const service = new DshHookService() + expect(service.install().state).toBe('installed') + expect(readFileSync(configPath(), 'utf-8')).toContain('thinking: disabled') + + expect(service.remove().state).toBe('not_installed') + expect(readFileSync(configPath(), 'utf-8').trimEnd()).toBe(userRows.trimEnd()) + }) + + it('removes the managed hooks file along with the block', () => { + const service = new DshHookService() + service.install() + service.remove() + expect(() => readFileSync(managedHooksPath(), 'utf-8')).toThrow() + expect(service.getStatus().state).toBe('not_installed') + }) + + it('reports partial when an event loses its managed hook', () => { + const service = new DshHookService() + service.install() + const managed = readManagedHooks() + delete managed.hooks.Stop + writeFileSync(managedHooksPath(), JSON.stringify(managed, null, 2), 'utf-8') + + const status = service.getStatus() + expect(status.state).toBe('partial') + expect(status.detail).toContain('Stop') + }) + + it('reports not_installed when the block points somewhere else', () => { + const service = new DshHookService() + service.install() + writeFileSync( + configPath(), + readFileSync(configPath(), 'utf-8').replace(managedHooksPath(), '/somewhere/else.json'), + 'utf-8' + ) + const status = service.getStatus() + expect(status.state).toBe('not_installed') + expect(status.detail).toContain('/somewhere/else.json') + }) + + // Why POSIX-only: on Windows chmod toggles the read-only attribute, so `mode & 0o777` + // reads 0o666 for any writable file and the assertion cannot mean what it says. + it.skipIf(process.platform === 'win32')('keeps an owner-only patch file owner-only', () => { + // CWE-732: the temp+rename replacement must not widen the file to the umask default. + const userRows = '- id: llm-deepseek\n config: {}\n' + mkdirSync(join(home, '.dsh'), { recursive: true }) + writeFileSync(configPath(), userRows, 'utf-8') + chmodSync(configPath(), 0o600) + + expect(new DshHookService().install().state).toBe('installed') + expect(statSync(configPath()).mode & 0o777).toBe(0o600) + }) + + it('refuses a flow-style patch file instead of corrupting it', () => { + // Appending a block entry after `[…]` is invalid YAML — DSH would then fail to load the + // user's own layer as well as Orca's hooks, so install must change nothing. + const flow = '[{ id: llm-deepseek }]\n' + mkdirSync(join(home, '.dsh'), { recursive: true }) + writeFileSync(configPath(), flow, 'utf-8') + + const service = new DshHookService() + const installed = service.install() + expect(installed.state).toBe('error') + expect(installed.detail).toContain('flow-style sequence') + expect(readFileSync(configPath(), 'utf-8')).toBe(flow) + + // Why re-read: a later status poll must keep saying why, not decay to a bare + // `not_installed` that gives the user nothing to act on. + const polled = service.getStatus() + expect(polled.state).toBe('error') + expect(polled.detail).toContain('flow-style sequence') + }) + + it('honours DSH_HOME', () => { + const dshHome = join(home, 'custom-dsh-home') + process.env.DSH_HOME = dshHome + const status = new DshHookService().install() + expect(status.configPath).toBe(join(dshHome, 'cordis.patch.yml')) + expect(status.state).toBe('installed') + }) +}) diff --git a/src/main/dsh/hook-service.ts b/src/main/dsh/hook-service.ts new file mode 100644 index 00000000000..0767f686f24 --- /dev/null +++ b/src/main/dsh/hook-service.ts @@ -0,0 +1,253 @@ +import { mkdirSync, readFileSync, rmSync } from 'node:fs' +import { dirname } from 'node:path' +import type { SFTPWrapper } from 'ssh2' + +import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { + buildWindowsAgentHookCurlPostCommand, + writeHooksJson, + writeManagedScript +} from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' +import { + readTextFileRemote, + writeManagedScriptRemote, + writeTextFileRemoteAtomic +} from '../agent-hooks/installer-utils-remote' +import { + buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, + buildWindowsHookEnvironmentGuardLines, + buildWindowsHookStdinDrainEpilogue +} from '../agent-hooks/hook-stdin-contract' +import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command' +import { + applyManagedDshPatch, + isDshPatchFileUnappendable, + readManagedDshHooksConfigPath, + removeManagedDshPatch +} from './dsh-home-patch' +import { + buildDshManagedHooksFile, + DSH_HOOK_EVENTS, + getDshConfigPath, + getDshManagedCommand, + getDshManagedCommandMatcher, + getDshManagedHooksPath, + getDshManagedScriptPath, + getDshRemoteConfigPath, + getDshRemoteManagedCommand, + getDshRemoteManagedHooksPath, + readManagedDshHookEvents +} from './hook-settings' + +function getManagedScript(target: 'local' | 'posix' = 'local'): string { + if (target === 'local' && process.platform === 'win32') { + return [ + '@echo off', + 'setlocal', + // Why: same scrub as POSIX — restore the canonical names from their aliases first. + 'if not defined ORCA_PANE_KEY if defined ORCA_AGENT_PANE set "ORCA_PANE_KEY=%ORCA_AGENT_PANE%"', + 'if not defined ORCA_AGENT_LAUNCH_TOKEN if defined ORCA_AGENT_LAUNCH set "ORCA_AGENT_LAUNCH_TOKEN=%ORCA_AGENT_LAUNCH%"', + 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + ...buildWindowsHookEnvironmentGuardLines(), + buildWindowsAgentHookCurlPostCommand('dsh'), + 'exit /b 0', + ...buildWindowsHookStdinDrainEpilogue(), + '' + ].join('\r\n') + } + + return [ + '#!/bin/sh', + // Why first: DSH's shell executor drops every env var whose NAME contains KEY, TOKEN, + // SECRET or PASSWORD before the hook starts, which takes ORCA_PANE_KEY and + // ORCA_AGENT_LAUNCH_TOKEN with it. Orca mirrors both onto scrub-safe aliases at spawn + // (see agent-hook-scrub-safe-env.ts); restore the canonical names from them so every + // line below — including the shared spool and post builders — is unchanged. + ': "${ORCA_PANE_KEY:=${ORCA_AGENT_PANE:-}}"', + ': "${ORCA_AGENT_LAUNCH_TOKEN:=${ORCA_AGENT_LAUNCH:-}}"', + 'export ORCA_PANE_KEY ORCA_AGENT_LAUNCH_TOKEN', + ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('dsh'), + // Why: the endpoint file holds the live port/token; a PTY that outlived an Orca + // restart carries stale env, so source it to reach the new server. + 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', + ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', + 'fi', + 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', + ' exit 0', + 'fi', + ...buildPosixAgentHookPostCommand('dsh').map((line, index, lines) => + index === lines.length - 1 ? `${line} >/dev/null 2>&1 || spool_hook_event` : line + ), + 'exit 0', + '' + ].join('\n') +} + +/** '' when the file is absent (both are created lazily), null when it exists but cannot be read. */ +function readTextOrAbsent(path: string): string | null { + try { + return readFileSync(path, 'utf-8') + } catch (error) { + return isDefinitiveAbsence(error) ? '' : null + } +} + +/** null for anything that is not parseable JSON; the caller reads that as "no events". */ +function parseJsonOrNull(text: string): unknown { + try { + return JSON.parse(text) + } catch { + return null + } +} + +function writePatchText(configPath: string, text: string): void { + mkdirSync(dirname(configPath), { recursive: true }) + // Why writeHooksJson: it owns the temp+rename and the rolling .bak this file needs too. + // Why preserveMode: an owner-only patch file must not widen to the umask default on rewrite. + writeHooksJson(configPath, {}, { serialized: text, preserveMode: true }) +} + +/** Why one constant: status has to say exactly what install said, on every later read. */ +const FLOW_STYLE_DETAIL = + 'The DSH home patch is a flow-style sequence ([…]); rewrite it as a block sequence (one `- ` entry per line) so Orca can add its hooks without breaking it' + +function status( + configPath: string, + state: AgentHookInstallState, + detail: string | null, + managedHooksPresent = false +): AgentHookInstallStatus { + return { agent: 'dsh', state, configPath, managedHooksPresent, detail } +} + +function buildStatus( + patchText: string, + managedHooksPath: string, + managedText: string | null, + configPath: string +): AgentHookInstallStatus { + if (managedText === null) { + return status(configPath, 'error', 'Could not read Orca managed hooks file') + } + // Why before the pointer check: a refused file carries no managed region, so the pointer + // path would report a bare `not_installed` and drop the one detail that says why. + if (isDshPatchFileUnappendable(patchText)) { + return status(configPath, 'error', FLOW_STYLE_DETAIL) + } + const pointer = readManagedDshHooksConfigPath(patchText) + if (pointer !== managedHooksPath) { + return status( + configPath, + 'not_installed', + pointer === undefined + ? null + : `The Orca patch block points at ${pointer}, not the Orca managed hooks file` + ) + } + const present = readManagedDshHookEvents( + parseJsonOrNull(managedText), + getDshManagedCommandMatcher() + ) + const missing = DSH_HOOK_EVENTS.filter((event) => !present.has(event)) + if (missing.length === 0) { + return status(configPath, 'installed', null, true) + } + // Why the split: nothing present is an uninstalled agent; some present is a broken install, + // and naming the gap is the only way a user can tell those apart. + return present.size === 0 + ? status(configPath, 'not_installed', null) + : status(configPath, 'partial', `Managed hook missing for events: ${missing.join(', ')}`, true) +} + +/** Installs Orca's status hooks into DSH. See `docs/reference/dsh-harness-integration.md` + * for the profile/patch-layer model and the env-scrub finding the aliases work around. */ +export class DshHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getDshManagedScriptPath(), getManagedScript()) + } + + getStatus(): AgentHookInstallStatus { + const configPath = getDshConfigPath() + const patchText = readTextOrAbsent(configPath) + if (patchText === null) { + return status(configPath, 'error', 'Could not read the DSH home patch file') + } + const managedHooksPath = getDshManagedHooksPath() + return buildStatus(patchText, managedHooksPath, readTextOrAbsent(managedHooksPath), configPath) + } + + install(): AgentHookInstallStatus { + const configPath = getDshConfigPath() + const patchText = readTextOrAbsent(configPath) + if (patchText === null) { + return status(configPath, 'error', 'Could not read the DSH home patch file') + } + const scriptPath = getDshManagedScriptPath() + const managedHooksPath = getDshManagedHooksPath() + // Write the script and the managed hooks file first so the patch layer never points at + // files that do not exist yet — a bridge that cannot read its config runs no hooks. + writeManagedScript(scriptPath, getManagedScript()) + writeHooksJson( + managedHooksPath, + { hooks: {} }, + { serialized: buildDshManagedHooksFile(getDshManagedCommand(scriptPath)) } + ) + const nextText = applyManagedDshPatch(patchText, managedHooksPath) + if (nextText === null) { + // Why refuse rather than edit: YAML forbids a block entry after a flow sequence, so + // appending here would leave DSH unable to parse the user's own patch layer either. + return status(configPath, 'error', FLOW_STYLE_DETAIL) + } + if (nextText !== patchText) { + writePatchText(configPath, nextText) + } + return this.getStatus() + } + + /** Install on an SSH execution host, where DSH's shell contract is always POSIX. */ + async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { + const remoteConfigPath = getDshRemoteConfigPath(remoteHome) + const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/dsh-hook.sh` + const remoteManagedHooksPath = getDshRemoteManagedHooksPath(remoteHome) + try { + const body = (await readTextFileRemote(sftp, remoteConfigPath)) ?? '' + await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) + await writeTextFileRemoteAtomic( + sftp, + remoteManagedHooksPath, + buildDshManagedHooksFile(getDshRemoteManagedCommand(remoteScriptPath)) + ) + const nextText = applyManagedDshPatch(body, remoteManagedHooksPath) + if (nextText === null) { + return status(remoteConfigPath, 'error', FLOW_STYLE_DETAIL) + } + await writeTextFileRemoteAtomic(sftp, remoteConfigPath, nextText) + return status(remoteConfigPath, 'installed', null, true) + } catch (err) { + return status(remoteConfigPath, 'error', err instanceof Error ? err.message : String(err)) + } + } + + remove(): AgentHookInstallStatus { + const configPath = getDshConfigPath() + const patchText = readTextOrAbsent(configPath) + if (patchText === null) { + return status(configPath, 'error', 'Could not read the DSH home patch file') + } + const { text: nextText, changed } = removeManagedDshPatch(patchText) + if (changed) { + writePatchText(configPath, nextText) + } + // Why force: the file is Orca's own and may already be gone; its absence is the goal. + rmSync(getDshManagedHooksPath(), { force: true }) + return this.getStatus() + } +} + +export const dshHookService = new DshHookService() diff --git a/src/main/dsh/hook-settings.ts b/src/main/dsh/hook-settings.ts new file mode 100644 index 00000000000..8ccbb8a9283 --- /dev/null +++ b/src/main/dsh/hook-settings.ts @@ -0,0 +1,110 @@ +import { homedir } from 'node:os' +import { join, posix as pathPosix } from 'node:path' +import { + buildManagedCommandHook, + createManagedCommandMatcher, + getSharedManagedScriptPath, + wrapPosixHookCommand, + wrapWindowsHookCommand, + type HookDefinition +} from '../agent-hooks/installer-utils' +import { readManagedHookEventsFromJson } from '../agent-hooks/managed-hooks-json-events' + +const DSH_SCRIPT_BASE = 'dsh-hook' + +/** + * The events DeepSeek Harness's own Claude-Code hook bridge + * (`@deepseek-ai/dsh-hooks-claude-code`) can fire. This is a strict subset of Claude's: + * the bridge documents no `Notification`, no `PermissionRequest` and no `SessionEnd`, + * and registering an unsupported event name makes it register nothing for that event. + * `normalizeDshEvent` is written against exactly this list. + */ +export const DSH_HOOK_EVENTS = [ + 'SessionStart', + 'UserPromptSubmit', + 'PreToolUse', + 'PostToolUse', + 'Stop' +] as const + +export const DSH_MANAGED_HOOKS_FILE_NAME = 'dsh-hooks.json' + +/** `$DSH_HOME`, matching the launcher's own `DSH_HOME ?? ~/.dsh` resolution. */ +export function getDshHome(): string { + return process.env.DSH_HOME?.trim() || join(homedir(), '.dsh') +} + +/** + * The home-level patch layer. + * + * Why here and not in a profile: DSH composes every profile as bundle patches, then the + * profile's own `cordis.patch.yml`, then this file. Installing one layer above every + * profile means a pane the user started themselves — any profile, including one Orca + * never launched — still reports status, and Orca never edits a profile the user owns. + */ +export function getDshConfigPath(): string { + return join(getDshHome(), 'cordis.patch.yml') +} + +export function getDshRemoteConfigPath(remoteHome: string): string { + // Why: a remote $DSH_HOME is unknown over SFTP; default matches the launcher's own resolution. + return pathPosix.join(remoteHome.replace(/\/$/, ''), '.dsh', 'cordis.patch.yml') +} + +export function getDshManagedScriptFileName(): string { + return process.platform === 'win32' ? `${DSH_SCRIPT_BASE}.cmd` : `${DSH_SCRIPT_BASE}.sh` +} + +export function getDshManagedScriptPath(): string { + return getSharedManagedScriptPath(getDshManagedScriptFileName()) +} + +export function getDshManagedHooksPath(): string { + return getSharedManagedScriptPath(DSH_MANAGED_HOOKS_FILE_NAME) +} + +export function getDshRemoteManagedHooksPath(remoteHome: string): string { + return pathPosix.join( + remoteHome.replace(/\/$/, ''), + '.orca', + 'agent-hooks', + DSH_MANAGED_HOOKS_FILE_NAME + ) +} + +export function getDshManagedCommand(scriptPath: string): string { + // Why: DSH runs hooks through `ctx.shell`, which the base profile binds to bash + // everywhere except Windows, where it binds to PowerShell — the same split these two + // wrappers already encode. + return process.platform === 'win32' + ? wrapWindowsHookCommand(scriptPath) + : wrapPosixHookCommand(scriptPath) +} + +export function getDshRemoteManagedCommand(scriptPath: string): string { + return wrapPosixHookCommand(scriptPath) +} + +export function getDshManagedCommandMatcher(): (command: string | undefined) => boolean { + return createManagedCommandMatcher(getDshManagedScriptFileName()) +} + +/** + * The managed hooks file is Orca's outright: DSH has no user-owned `hooks.json` + * convention of its own, and the bridge reads whatever single path it is pointed at. So + * generate it wholesale rather than merging into someone's file. + */ +export function buildDshManagedHooksFile(command: string): string { + const hooks: Record = {} + for (const event of DSH_HOOK_EVENTS) { + hooks[event] = [{ hooks: [buildManagedCommandHook(command)] }] + } + return `${JSON.stringify({ hooks }, null, 2)}\n` +} + +export function readManagedDshHookEvents( + parsed: unknown, + isManagedCommand: (command: string | undefined) => boolean +): Set { + return readManagedHookEventsFromJson(parsed, DSH_HOOK_EVENTS, isManagedCommand) +} diff --git a/src/main/durable-file-write-syscall-proof.test.ts b/src/main/durable-file-write-syscall-proof.test.ts index bf18d6c0092..34563b0196a 100644 --- a/src/main/durable-file-write-syscall-proof.test.ts +++ b/src/main/durable-file-write-syscall-proof.test.ts @@ -1,6 +1,15 @@ // Empirical proof that the durable write fsyncs the file, and the directory where the platform // allows it. Counted at the module boundary rather than inferred from reading the implementation. -import { closeSync, fsyncSync, mkdtempSync, openSync, readFileSync, rmSync } from 'node:fs' +import { + closeSync, + existsSync, + fsyncSync, + mkdtempSync, + openSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' import type * as NodeFs from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -9,23 +18,57 @@ import { expect, it, vi } from 'vitest' /** Why the rename is recorded too: an fsync moved after the rename still fsyncs a file, so a * fsync-only log reads identically for the correct and the broken order. The rename is the boundary * the ordering is defined against, so it has to appear in the same sequence. */ -const syscalls: ('fsync:file' | 'fsync:directory' | 'rename')[] = [] +const syscalls: ('fsync:file' | 'fsync:directory' | 'rename' | 'link')[] = [] vi.mock('node:fs', async () => { const actual = await vi.importActual('node:fs') return { ...actual, fsyncSync: (fd: number) => { + actual.fsyncSync(fd) syscalls.push(actual.fstatSync(fd).isDirectory() ? 'fsync:directory' : 'fsync:file') - return actual.fsyncSync(fd) }, renameSync: (from: NodeFs.PathLike, to: NodeFs.PathLike) => { + actual.renameSync(from, to) syscalls.push('rename') - return actual.renameSync(from, to) + }, + linkSync: (from: NodeFs.PathLike, to: NodeFs.PathLike) => { + actual.linkSync(from, to) + syscalls.push('link') } } }) +it('publishes a new file durably and cannot replace an existing destination', async () => { + const { publishFileDurableSync } = await import('./durable-file-write') + const dir = mkdtempSync(join(tmpdir(), 'orca-publish-fsync-')) + try { + const supported = directoryFsyncSupported(dir) + const staged = join(dir, 'staged') + const target = join(dir, 'target') + writeFileSync(staged, 'first') + const fd = openSync(staged, 'r+') + try { + fsyncSync(fd) + } finally { + closeSync(fd) + } + syscalls.length = 0 + expect(publishFileDurableSync(staged, target)).toBe(true) + expect(syscalls).toEqual(supported ? ['link', 'fsync:directory'] : ['link']) + expect(existsSync(staged)).toBe(false) + expect(readFileSync(target, 'utf8')).toBe('first') + writeFileSync(staged, 'second') + syscalls.length = 0 + expect(publishFileDurableSync(staged, target)).toBe(false) + expect(readFileSync(target, 'utf8')).toBe('first') + expect(readFileSync(staged, 'utf8')).toBe('second') + expect(syscalls).toEqual([]) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + /** Windows cannot open a directory for fsync, and some filesystems reject it; probe rather than * assume, so the expectation tracks the real platform instead of a hardcoded OS list. */ function directoryFsyncSupported(directory: string): boolean { diff --git a/src/main/durable-file-write.ts b/src/main/durable-file-write.ts index 83b0eabc5ed..05e849327b7 100644 --- a/src/main/durable-file-write.ts +++ b/src/main/durable-file-write.ts @@ -4,9 +4,13 @@ // hour's loss; fsync stops it from happening. import { closeSync, fsyncSync, openSync, rmSync, writeFileSync } from 'node:fs' -import { copyFile, open, readdir, rename, rm, stat } from 'node:fs/promises' +import { copyFile, open, readdir, rm, stat } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' -import { renameFileWithWindowsRetry } from './codex-accounts/fs-utils' +import { + publishFileWithoutOverwrite, + renameFileWithWindowsRetry, + renameFileWithWindowsRetryAsync +} from './codex-accounts/fs-utils' /** * fsync a directory so a rename within it is durable. Best-effort by design: Windows cannot open a @@ -43,12 +47,28 @@ function syncDirectorySync(directory: string): void { } } +/** Rename an already-fsynced file and make the containing directory durable. */ +export function renameDurableSync(tmpPath: string, finalPath: string): void { + renameFileWithWindowsRetry(tmpPath, finalPath) + syncDirectorySync(dirname(finalPath)) +} + +/** Publish an already-fsynced file without replacing a concurrently created destination. */ +export function publishFileDurableSync(tmpPath: string, finalPath: string): boolean { + if (!publishFileWithoutOverwrite(tmpPath, finalPath)) { + return false + } + syncDirectorySync(dirname(finalPath)) + rmSync(tmpPath) + return true +} + /** * Rename and then fsync the containing directory. For callers that already fsynced the temp file * themselves and need the rename made durable. */ export async function renameDurable(tmpPath: string, finalPath: string): Promise { - await rename(tmpPath, finalPath) + await renameFileWithWindowsRetryAsync(tmpPath, finalPath) await syncDirectory(dirname(finalPath)) } @@ -96,7 +116,7 @@ export async function copyFileDurable(sourcePath: string, finalPath: string): Pr } finally { await handle.close() } - await rename(tmpPath, finalPath) + await renameFileWithWindowsRetryAsync(tmpPath, finalPath) renamed = true await syncDirectory(dirname(finalPath)) return true @@ -132,10 +152,9 @@ export async function writeFileDurableIfCurrent( try { // Why: fsync BEFORE rename. A rename that lands first can expose a zero-length file. await writeTempFileDurable(tmpPath, payload) - if (!isCurrent()) { + if (!(await renameFileWithWindowsRetryAsync(tmpPath, finalPath, isCurrent))) { return false } - await rename(tmpPath, finalPath) renamed = true await syncDirectory(dirname(finalPath)) return true @@ -202,9 +221,8 @@ export function writeFileDurableSync( } finally { closeSync(fd) } - renameFileWithWindowsRetry(tmpPath, finalPath) + renameDurableSync(tmpPath, finalPath) renamed = true - syncDirectorySync(dirname(finalPath)) } finally { if (!renamed) { rmSync(tmpPath, { force: true }) diff --git a/src/main/execution-host-workspace-trust.test.ts b/src/main/execution-host-workspace-trust.test.ts new file mode 100644 index 00000000000..6fdb942b57b --- /dev/null +++ b/src/main/execution-host-workspace-trust.test.ts @@ -0,0 +1,210 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, sep } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as Os from 'node:os' +import type { AgentTrustPreset } from './agent-trust-presets' +import { linkGitWorktree, workspaceTrustWritten } from './workspace-trust-test-fixtures' + +const state = vi.hoisted(() => ({ home: '' })) + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, homedir: () => state.home } +}) + +import { + AGENT_TRUST_INHERITS_FROM_A_HOME, + applyWorkspaceTrustOnThisHost, + type WorkspaceTrustHost +} from './execution-host-workspace-trust' + +const PRESETS: readonly AgentTrustPreset[] = [ + 'claude', + 'codex', + 'cursor', + 'copilot', + 'qoder', + 'antigravity' +] +// Why these three: each accepts trust from any ancestor folder, so trust on a home covers it all. +const INHERITING_PRESETS: readonly AgentTrustPreset[] = ['claude', 'copilot', 'qoder'] +const EXACT_OR_SELF_LIMITING_PRESETS: readonly AgentTrustPreset[] = [ + 'codex', + 'cursor', + 'antigravity' +] + +let root: string + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-host-trust-'))) + state.home = join(root, 'home', 'me') + mkdirSync(state.home, { recursive: true }) + writeFileSync(join(state.home, '.claude.json'), '{}') +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +function thisHost(overrides: Partial = {}): () => WorkspaceTrustHost { + return () => ({ + homes: [state.home], + claudeConfig: () => ({ configFile: join(state.home, '.claude.json'), keyStyle: 'posix' }), + codexConfigFiles: () => [join(state.home, '.codex', 'config.toml')], + deadlineMs: 1_500, + ...overrides + }) +} + +function trustWritten(preset: AgentTrustPreset): boolean { + return workspaceTrustWritten(state.home, preset) +} + +describe('applyWorkspaceTrustOnThisHost', () => { + it.each(PRESETS)('writes %s trust for an ordinary project folder', async (preset) => { + const workspace = join(root, 'projects', 'app') + mkdirSync(workspace, { recursive: true }) + await applyWorkspaceTrustOnThisHost(preset, workspace, thisHost()) + expect(trustWritten(preset)).toBe(true) + }) + + const tooBroad: [string, () => { workspace: string; homes?: string[] }][] = [ + ['the home', () => ({ workspace: state.home })], + ['a folder containing the home', () => ({ workspace: join(root, 'home') })], + ['a filesystem root', () => ({ workspace: '/' })], + [ + 'a symlink to the home', + () => { + symlinkSync(state.home, join(root, 'home-link'), 'junction') + return { workspace: join(root, 'home-link') } + } + ], + [ + // Why a missing segment: realpath fails there, and join() would collapse the `..` itself. + 'a missing path that resolves to the home through ..', + () => ({ workspace: [state.home, 'missing', '..'].join(sep) }) + ], + [ + 'the home, when the host names it through a symlink', + () => { + symlinkSync(state.home, join(root, 'home-link'), 'junction') + return { workspace: state.home, homes: [join(root, 'home-link')] } + } + ] + ] + describe.each(tooBroad)('for %s', (_label, arrange) => { + it.each(INHERITING_PRESETS)('writes no %s trust', async (preset) => { + const { workspace, homes } = arrange() + await applyWorkspaceTrustOnThisHost( + preset, + workspace, + thisHost(homes ? { homes } : undefined) + ) + expect(trustWritten(preset)).toBe(false) + }) + }) + + it('guards exactly the agents that inherit trust from a home', () => { + expect(PRESETS.filter((preset) => AGENT_TRUST_INHERITS_FROM_A_HOME[preset])).toEqual( + INHERITING_PRESETS + ) + }) + + it.each(EXACT_OR_SELF_LIMITING_PRESETS)( + 'trusts a home folder workspace for %s, whose trust there covers only the home', + async (preset) => { + await applyWorkspaceTrustOnThisHost(preset, state.home, thisHost()) + expect(trustWritten(preset)).toBe(true) + } + ) + + it('trusts a home folder workspace for Codex under the key Codex looks up', async () => { + await applyWorkspaceTrustOnThisHost('codex', state.home, thisHost()) + expect(readFileSync(join(state.home, '.codex', 'config.toml'), 'utf-8')).toContain( + `[projects."${state.home}"]` + ) + }) + + it('trusts a Codex worktree whose main checkout is the home, as Codex would on "Yes"', async () => { + const worktree = join(root, 'worktrees', 'feature') + linkGitWorktree(state.home, worktree) + await applyWorkspaceTrustOnThisHost('codex', worktree, thisHost()) + expect(readFileSync(join(state.home, '.codex', 'config.toml'), 'utf-8')).toContain( + `[projects."${state.home}"]` + ) + }) + + it.each(PRESETS.filter((preset) => preset !== 'codex'))( + 'trusts a worktree whose main checkout is the home for %s, which stores the worktree path', + async (preset) => { + const worktree = join(root, 'worktrees', 'feature') + linkGitWorktree(state.home, worktree) + await applyWorkspaceTrustOnThisHost(preset, worktree, thisHost()) + expect(trustWritten(preset)).toBe(true) + } + ) + + it('trusts the exact subfolder Codex starts in, inside a folder that is not a repo', async () => { + const subfolder = join(root, 'notes', 'sub') + mkdirSync(subfolder, { recursive: true }) + await applyWorkspaceTrustOnThisHost('codex', subfolder, thisHost()) + expect(readFileSync(join(state.home, '.codex', 'config.toml'), 'utf-8')).toContain( + `[projects."${subfolder}"]` + ) + }) + + it("trusts a worktree's main checkout for Codex when that checkout is not a home", async () => { + const mainCheckout = join(root, 'repo') + const worktree = join(root, 'worktrees', 'feature') + linkGitWorktree(mainCheckout, worktree) + await applyWorkspaceTrustOnThisHost('codex', worktree, thisHost()) + const written = readFileSync(join(state.home, '.codex', 'config.toml'), 'utf-8') + expect(written).toContain(`[projects."${mainCheckout}"]`) + expect(written).not.toContain(`[projects."${worktree}"]`) + }) + + it.each(INHERITING_PRESETS)('writes no %s trust when the host knows no home', async (preset) => { + const workspace = join(root, 'projects', 'app') + mkdirSync(workspace, { recursive: true }) + await applyWorkspaceTrustOnThisHost( + preset, + workspace, + thisHost({ homes: [null, '', undefined] }) + ) + expect(trustWritten(preset)).toBe(false) + }) + + it('contains a host description or writer that throws, so the launch proceeds', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const workspace = join(root, 'projects', 'app') + mkdirSync(workspace, { recursive: true }) + await expect( + applyWorkspaceTrustOnThisHost('claude', workspace, () => { + throw new Error('homedir unavailable') + }) + ).resolves.toBeUndefined() + await expect( + applyWorkspaceTrustOnThisHost( + 'codex', + workspace, + thisHost({ + codexConfigFiles: () => { + throw new Error('userData unavailable') + } + }) + ) + ).resolves.toBeUndefined() + expect(warn).toHaveBeenCalledTimes(2) + warn.mockRestore() + }) +}) diff --git a/src/main/execution-host-workspace-trust.ts b/src/main/execution-host-workspace-trust.ts new file mode 100644 index 00000000000..5664e63d634 --- /dev/null +++ b/src/main/execution-host-workspace-trust.ts @@ -0,0 +1,142 @@ +import { realpathSync } from 'node:fs' +import { resolve } from 'node:path' +import { markQoderWorkspaceTrusted } from './qoder/workspace-trust' +import { + type AgentTrustPreset, + markAntigravityWorkspaceTrusted, + markCodexProjectTrusted, + markCopilotFolderTrusted, + markCursorWorkspaceTrusted, + resolveCodexProjectTrustRoot +} from './agent-trust-presets' +import { awaitAgentTrustWriteWithinDeadline } from './agent-trust-write-deadline' +import { + type ClaudeTrustConfigTarget, + grantClaudeWorkspaceTrust +} from './claude/claude-folder-trust-file' +import { isTooBroadToPreTrust } from '../shared/home-or-filesystem-root' + +/** + * What the host that runs the agent knows about where that agent reads trust. Relay-safe: + * main describes this machine or a WSL guest, the SSH relay describes its own host. + */ +export type WorkspaceTrustHost = { + /** Homes the agent may read trust under; with none known, an agent that inherits trust writes nothing. */ + homes: readonly (string | null | undefined)[] + /** The config Claude reads on this host, or null when this host cannot tell. */ + claudeConfig: () => ClaudeTrustConfigTarget | null + /** Every config.toml the launched Codex may read, in the hook installer's lock order. */ + codexConfigFiles: () => readonly string[] + deadlineMs: number +} + +/** + * Whether trust on a home or a disk root would also trust the folders below it, per each agent's + * own lookup; such trust is never pre-written. Claude walks up parent folders (to the repo root, + * else the disk root); Copilot and Qoder accept any trusted ancestor. Codex matches its start + * folder or that folder's repo root, Antigravity the exact folder, and Cursor never inherits from + * a home, a folder above one or a shallow path. + */ +export const AGENT_TRUST_INHERITS_FROM_A_HOME: Record = { + claude: true, + codex: false, + cursor: false, + copilot: true, + qoder: true, + antigravity: false +} + +/** + * Agents whose lookup keys on the folder they start in, so Orca trusts that folder, not the + * workspace root, even outside any workspace. Codex checks its start folder, then that folder's + * repo root; a non-git workspace root above the start folder is neither. + */ +export const AGENT_TRUST_KEYED_BY_START_FOLDER: Record = { + claude: false, + codex: true, + cursor: false, + copilot: false, + qoder: false, + antigravity: false +} + +// Why resolve() too: Claude stores it, and it collapses `..` even where realpath fails. +function withResolvedForm(path: string): string[] { + try { + return [path, resolve(path), realpathSync.native(path)] + } catch { + return [path, resolve(path)] + } +} + +/** The path the preset's writer stores: Codex trusts a linked worktree's main checkout. */ +function storedTrustPath(preset: AgentTrustPreset, workspacePath: string): string { + return preset === 'codex' ? resolveCodexProjectTrustRoot(workspacePath) : workspacePath +} + +/** + * Whether trust stored for `storedPath` would cover a home: it is a root, a home or a folder + * above one. Both sides are compared given and resolved, since the writers store the realpath. + */ +function wouldTrustAHome(storedPath: string, homes: readonly string[]): boolean { + const homeForms = homes.flatMap(withResolvedForm) + return withResolvedForm(storedPath).some((form) => isTooBroadToPreTrust(form, homeForms)) +} + +async function writePreset( + preset: AgentTrustPreset, + storedPath: string, + host: WorkspaceTrustHost +): Promise { + switch (preset) { + case 'claude': { + const target = host.claudeConfig() + if (target) { + await grantClaudeWorkspaceTrust(target, storedPath) + } + return + } + case 'codex': + return markCodexProjectTrusted(storedPath, host.codexConfigFiles()) + case 'cursor': + return markCursorWorkspaceTrusted(storedPath) + case 'copilot': + return markCopilotFolderTrusted(storedPath) + case 'qoder': + return markQoderWorkspaceTrusted(storedPath) + case 'antigravity': + return markAntigravityWorkspaceTrusted(storedPath) + } +} + +/** + * The one place a preset's trust is written, on the host that runs the agent. For an agent that + * inherits trust from a home, refuses when the path the writer would store covers a home. Never + * throws or waits past the host's deadline: any failure or miss means the agent asks. + */ +export async function applyWorkspaceTrustOnThisHost( + preset: AgentTrustPreset, + workspacePath: string, + describeHost: () => WorkspaceTrustHost +): Promise { + try { + const host = describeHost() + const storedPath = storedTrustPath(preset, workspacePath) + if (AGENT_TRUST_INHERITS_FROM_A_HOME[preset]) { + const homes = host.homes.filter((home): home is string => Boolean(home)) + if (homes.length === 0 || wouldTrustAHome(storedPath, homes)) { + return + } + } + await awaitAgentTrustWriteWithinDeadline(writePreset(preset, storedPath, host), { + preset, + workspacePath, + deadlineMs: host.deadlineMs + }) + } catch (error) { + console.warn( + `[agent-trust] ${preset} trust for ${workspacePath} failed; the agent will ask`, + error + ) + } +} diff --git a/src/main/git-bash.test.ts b/src/main/git-bash.test.ts index 367c07cbe54..bd4633b019f 100644 --- a/src/main/git-bash.test.ts +++ b/src/main/git-bash.test.ts @@ -169,4 +169,66 @@ describe('Git Bash path discovery', () => { ) expect(isGitForWindowsBashLauncherPath('bash.exe')).toBe(false) }) + + it('identifies a launcher in an install folder named anything else', () => { + // Layout confirmed against a real Git 2.x install: bin, cmd, mingw64, usr, git-bash.exe. + const renamedInstall = (path: string): boolean => + [ + 'C:\\Tools\\Git-2.55\\bin\\bash.exe', + 'C:\\Tools\\Git-2.55\\usr\\bin\\bash.exe', + 'C:\\Tools\\Git-2.55\\cmd\\git.exe', + 'C:\\Tools\\Git-2.55\\git-bash.exe' + ].includes(path) + expect( + isGitForWindowsBashLauncherPath('C:\\Tools\\Git-2.55\\bin\\bash.exe', { + exists: renamedInstall + }) + ).toBe(true) + expect( + isGitForWindowsBashLauncherPath('C:\\Users\\a\\scoop\\apps\\git\\current\\bin\\bash.exe', { + exists: (path) => path.startsWith('C:\\Users\\a\\scoop\\apps\\git\\current\\') + }) + ).toBe(true) + }) + + // Each marker is individually necessary, so dropping one from the predicate cannot stay green. + it.each([ + ['usr\\bin\\bash.exe', 'C:\\Tools\\Git-2.55\\usr\\bin\\bash.exe'], + ['cmd\\git.exe', 'C:\\Tools\\Git-2.55\\cmd\\git.exe'], + ['git-bash.exe', 'C:\\Tools\\Git-2.55\\git-bash.exe'] + ])('refuses a renamed install root missing only %s', (_label, missingMarker) => { + expect( + isGitForWindowsBashLauncherPath('C:\\Tools\\Git-2.55\\bin\\bash.exe', { + exists: (path) => path !== missingMarker + }) + ).toBe(false) + }) + + it('refuses a bash that is not a Git for Windows launcher, even where every path exists', () => { + const everythingExists = (): boolean => true + // A directly launched MSYS bash: no install root sits inside `usr`, so the markers cannot be met. + expect( + isGitForWindowsBashLauncherPath('C:\\Program Files\\Git\\usr\\bin\\bash.exe', { + exists: (path) => !path.includes('\\usr\\usr\\') && !path.includes('\\usr\\cmd\\') + }) + ).toBe(false) + // Observed on a real Cygwin root: none of the three markers is present, nor bin\bash.exe itself. + const observedCygwinRoot = ['C:\\cygwin64\\etc', 'C:\\cygwin64\\var'] + expect( + isGitForWindowsBashLauncherPath('C:\\cygwin64\\bin\\bash.exe', { + exists: (path) => observedCygwinRoot.includes(path) + }) + ).toBe(false) + // A Cygwin root carrying a bash at both marker positions is still refused: `git-bash.exe` is Git + // for Windows' own launcher, and no Cygwin package installs one. + expect( + isGitForWindowsBashLauncherPath('C:\\cygwin64\\bin\\bash.exe', { + exists: (path) => path.endsWith('bin\\bash.exe') || path.endsWith('cmd\\git.exe') + }) + ).toBe(false) + expect( + isGitForWindowsBashLauncherPath('C:\\Tools\\sh\\bash.exe', { exists: everythingExists }) + ).toBe(false) + expect(isGitForWindowsBashLauncherPath('bash.exe', { exists: everythingExists })).toBe(false) + }) }) diff --git a/src/main/git-bash.ts b/src/main/git-bash.ts index 40866325ae7..a57edcaee4d 100644 --- a/src/main/git-bash.ts +++ b/src/main/git-bash.ts @@ -114,10 +114,46 @@ export function isGitForWindowsBashPath(shellPath: string): boolean { return /(?:^|\\)(?:git|portablegit)(?:\\usr)?\\bin\\bash\.exe$/.test(normalized) } -/** Git for Windows' `bin\bash.exe` is a launcher: it runs `..\usr\bin\bash.exe` as a child and waits. */ -export function isGitForWindowsBashLauncherPath(shellPath: string): boolean { - return /(?:^|\\)(?:git|portablegit)\\bin\\bash\.exe$/.test( - pathWin32.normalize(shellPath).toLowerCase() +/** + * Files only a Git for Windows install root carries. `usr\bin\bash.exe` is the launcher's own + * hand-off target, and the other two separate that root from a Cygwin or MSYS2 one: neither ships a + * `cmd\` directory, and `git-bash.exe` is Git for Windows' own launcher rather than an upstream git + * binary, so no Cygwin package can put it here. All three confirmed present on a real Git 2.x + * install and absent from a real Cygwin root. + */ +const GIT_FOR_WINDOWS_ROOT_MARKERS = [ + ['usr', 'bin', 'bash.exe'], + ['cmd', 'git.exe'], + ['git-bash.exe'] +] as const + +/** + * Git for Windows' `bin\bash.exe` is a launcher: it runs `..\usr\bin\bash.exe` as a child and waits. + * + * The installer's folder is named `Git`, but a user-chosen install directory, an unzipped + * PortableGit, and Scoop's `apps\git\current` are equally real, so a folder this does not recognize + * falls back to the install layout instead of denying the hand-off. + */ +export function isGitForWindowsBashLauncherPath( + shellPath: string, + options: Pick = {} +): boolean { + const normalized = pathWin32.normalize(shellPath) + if (/(?:^|\\)(?:git|portablegit)\\bin\\bash\.exe$/.test(normalized.toLowerCase())) { + return true + } + const binDirectory = pathWin32.dirname(normalized) + if ( + pathWin32.basename(normalized).toLowerCase() !== 'bash.exe' || + pathWin32.basename(binDirectory).toLowerCase() !== 'bin' + ) { + return false + } + // `usr\bin\bash.exe` lands here too, and is refused because no install root sits inside `usr`. + const installRoot = pathWin32.dirname(binDirectory) + const exists = options.exists ?? existsSync + return GIT_FOR_WINDOWS_ROOT_MARKERS.every((marker) => + exists(pathWin32.join(installRoot, ...marker)) ) } diff --git a/src/main/git/repo-api-parity.test.ts b/src/main/git/repo-api-parity.test.ts deleted file mode 100644 index 9143b7bf65f..00000000000 --- a/src/main/git/repo-api-parity.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from 'vitest' -import * as repo from './repo' - -describe('repo public API parity', () => { - it('keeps the historical runtime export surface', () => { - expect(Object.keys(repo).sort()).toEqual( - [ - 'DEFAULT_BASE_REF_PROBES', - 'buildSearchBaseRefsArgv', - 'getBaseRefDefault', - 'getBranchConflictKind', - 'getDefaultBaseRef', - 'getDefaultRemote', - 'getGitRepoRoot', - 'getLinkedWorktreeMainRepoRoot', - 'getRecentDriftSubjects', - 'getRemoteCommitUrl', - 'getRemoteCount', - 'getRemoteDrift', - 'getRemoteFileUrl', - 'getRemoteUrl', - 'getRepoName', - 'isForEachRefExcludeUnsupportedError', - 'isGitRepo', - 'mergeBaseRefSearchResultGroups', - 'normalizeGitRepoRootForInputPath', - 'normalizeRefSearchQuery', - 'parseAndFilterSearchRefDetails', - 'parseRemoteCount', - 'resolveDefaultBaseRefViaExec', - 'resolveDefaultBaseRefWithLocalGit', - 'searchBaseRefDetails', - 'searchBaseRefs' - ].sort() - ) - }) -}) diff --git a/src/main/git/status-read-coalescing.test.ts b/src/main/git/status-read-coalescing.test.ts index e0170f8d6a5..e7d755e7cfa 100644 --- a/src/main/git/status-read-coalescing.test.ts +++ b/src/main/git/status-read-coalescing.test.ts @@ -1,7 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as BoundedFileReader from '../../shared/node-bounded-file-reader' -import type * as NodeFs from 'node:fs' -import path from 'node:path' import { createBoundedFileReaderModuleMock, createFsPromisesModuleMock, @@ -92,62 +90,6 @@ describe('getStatus', () => { expect(addOptions?.preferWslDirectGit).toBeUndefined() }) - it('benchmarks concurrent status burst subprocess pressure', async () => { - const benchPath = process.env.ORCA_GIT_STATUS_COALESCING_BENCH_JSON - if (!benchPath) { - return - } - - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(false) - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args.includes('status')) { - return Promise.resolve({ stdout: '' }) - } - if (args.includes('--numstat')) { - return Promise.resolve({ stdout: '' }) - } - return Promise.resolve({ stdout: '' }) - }) - - const runBurst = async (withSignals: boolean): Promise => { - gitExecFileAsyncMock.mockClear() - await Promise.all( - Array.from({ length: 10 }, () => - getStatus('/repo', withSignals ? { signal: new AbortController().signal } : {}) - ) - ) - return gitExecFileAsyncMock.mock.calls.filter(([args]) => - (args as string[]).includes('status') - ).length - } - - const startedAt = performance.now() - const unsignalledStatusCommandCalls = await runBurst(false) - const signalledStatusCommandCalls = await runBurst(true) - const durationMs = performance.now() - startedAt - const { mkdirSync, writeFileSync } = await vi.importActual('fs') - mkdirSync(path.dirname(benchPath), { recursive: true }) - writeFileSync( - benchPath, - JSON.stringify({ - scenario: 'git-status-concurrent-burst', - concurrentCalls: 10, - unsignalledStatusCommandCalls, - signalledStatusCommandCalls, - statusArgs: [ - '-c', - 'core.quotePath=false', - 'status', - '--porcelain=v2', - '--branch', - '--untracked-files=all' - ], - durationMs - }) - ) - }) - it('coalesces identical in-flight status reads without caching after settle', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) diff --git a/src/main/git/upstream.test.ts b/src/main/git/upstream.test.ts index b7644ad5543..1c4c45e0911 100644 --- a/src/main/git/upstream.test.ts +++ b/src/main/git/upstream.test.ts @@ -1,6 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as NodeFs from 'node:fs' -import path from 'node:path' const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() @@ -25,54 +23,7 @@ describe('getUpstreamStatus', () => { invalidateGitUpstreamStatusReads() }) - it('benchmarks concurrent upstream Git command pressure', async () => { - const benchPath = process.env.ORCA_GIT_UPSTREAM_COALESCING_BENCH_JSON - if (!benchPath) { - return - } - gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args[0] === 'symbolic-ref') { - return Promise.resolve({ stdout: 'main\n' }) - } - if (args[0] === 'rev-parse') { - return Promise.resolve({ stdout: 'origin/main\n' }) - } - if (args[0] === 'rev-list') { - return Promise.resolve({ stdout: '2\t3\n' }) - } - if (args[0] === 'log') { - return Promise.resolve({ stdout: '+ abc123 remote work\n' }) - } - throw new Error(`unexpected git args: ${args.join(' ')}`) - }) - - await Promise.all(Array.from({ length: 10 }, () => getUpstreamStatus('/repo'))) - - const commands = gitExecFileAsyncMock.mock.calls.map(([args, options]) => ({ args, options })) - const commandCounts = Object.fromEntries( - ['symbolic-ref', 'rev-parse', 'rev-list', 'log'].map((command) => [ - command, - commands.filter(({ args }) => args[0] === command).length - ]) - ) - const { mkdirSync, writeFileSync } = await vi.importActual('node:fs') - mkdirSync(path.dirname(benchPath), { recursive: true }) - writeFileSync( - benchPath, - JSON.stringify({ - scenario: 'local-git-upstream-concurrent-burst', - concurrentCalls: 10, - physicalGitCalls: commands.length, - commandCounts, - commandChain: ['symbolic-ref', 'rev-parse', 'rev-list', 'log'].map((command) => - commands.find(({ args }) => args[0] === command) - ) - }) - ) - }) - - // Why: the benchmark above only runs under an env var, so this is the CI-enforced - // guard that the native/WSL path actually coalesces rather than fanning out. + // Why: the CI-enforced guard that the native/WSL path actually coalesces rather than fanning out. it('shares one physical read across ten identical native callers', async () => { let resolveSymbolicRef = (): void => {} const symbolicRefGate = new Promise((resolve) => { diff --git a/src/main/git/worktree-create-preparation-real-git.test.ts b/src/main/git/worktree-create-preparation-real-git.test.ts index 19c023a2403..08795fcab1f 100644 --- a/src/main/git/worktree-create-preparation-real-git.test.ts +++ b/src/main/git/worktree-create-preparation-real-git.test.ts @@ -55,6 +55,137 @@ afterEach(async () => { }) describe('prepared worktree creation with real Git', () => { + it.each([false, true])( + 'attaches the prepared HEAD and runs the hook (base advanced: %s)', + async (advanceBase) => { + const { repoPath, root } = await createRepo() + const preparedPath = join(root, 'prepared checkout') + const finalPath = join(root, 'final checkout') + const hooksPath = join(root, 'hooks') + await mkdir(hooksPath) + await writeFile( + join(hooksPath, 'post-checkout'), + '#!/bin/sh\nprintf \'%s\\n\' "$@" >> checkout-hook.txt\ngit symbolic-ref --short HEAD >> checkout-hook.txt\n', + { mode: 0o755 } + ) + git(repoPath, ['config', 'core.hooksPath', hooksPath]) + git(repoPath, ['config', 'branch.autoSetupMerge', 'always']) + await prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + 'main', + createWorktreePreparationLockReason('attach-with-hook') + ) + expect(existsSync(join(preparedPath, 'checkout-hook.txt'))).toBe(false) + if (advanceBase) { + await writeFile(join(repoPath, 'version.txt'), 'advanced\n') + git(repoPath, ['commit', '--quiet', '-am', 'advance base']) + } + const targetHead = git(repoPath, ['rev-parse', 'HEAD']) + await finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/attached', 'main') + + expect(git(finalPath, ['rev-parse', 'HEAD'])).toBe(targetHead) + expect(git(finalPath, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature/attached') + expect( + git(finalPath, ['for-each-ref', '--format=%(upstream)', 'refs/heads/feature/attached']) + ).toBe('') + expect(await readFile(join(finalPath, 'checkout-hook.txt'), 'utf8')).toBe( + `${targetHead}\n${targetHead}\n1\nfeature/attached\n` + ) + await rm(join(finalPath, 'checkout-hook.txt')) + expect(await readFile(join(finalPath, 'version.txt'), 'utf8')).toBe( + advanceBase ? 'advanced\n' : 'one\n' + ) + expect(git(finalPath, ['status', '--porcelain'])).toBe('') + } + ) + + it('never publishes a branch at a HEAD changed before attachment', async () => { + const { repoPath, root } = await createRepo() + const preparedPath = join(root, 'prepared-race') + const finalPath = join(root, 'final-race') + await prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + 'main', + createWorktreePreparationLockReason('head-race') + ) + const expectedHead = git(repoPath, ['rev-parse', 'HEAD']) + git(repoPath, ['checkout', '--quiet', '-b', 'other']) + await writeFile(join(repoPath, 'version.txt'), 'other\n') + git(repoPath, ['commit', '--quiet', '-am', 'other commit']) + const otherHead = git(repoPath, ['rev-parse', 'HEAD']) + git(repoPath, ['checkout', '--quiet', 'main']) + + const original = gitRunner.gitExecFileAsync + const spy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation((args, options) => { + if (args.includes('checkout') || args.includes('switch')) { + git(finalPath, ['reset', '--hard', otherHead]) + } + return original(args, options) + }) + try { + await finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/race', 'main') + } finally { + spy.mockRestore() + } + expect(git(repoPath, ['rev-parse', 'main'])).toBe(expectedHead) + expect(git(finalPath, ['rev-parse', 'HEAD'])).toBe(expectedHead) + expect(git(finalPath, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature/race') + expect(await readFile(join(finalPath, 'version.txt'), 'utf8')).toBe('one\n') + }) + + it('accepts a commit made by the post-checkout hook during attachment', async () => { + const { repoPath, root } = await createRepo() + const preparedPath = join(root, 'prepared-hook-commit') + const finalPath = join(root, 'final-hook-commit') + const hooksPath = join(root, 'hooks') + await mkdir(hooksPath) + await writeFile( + join(hooksPath, 'post-checkout'), + '#!/bin/sh\nprintf "invoked\\n" >> hook-invocations.txt\ngit add hook-invocations.txt\ngit commit --quiet -m "hook commit"\n', + { mode: 0o755 } + ) + git(repoPath, ['config', 'core.hooksPath', hooksPath]) + await prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + 'main', + createWorktreePreparationLockReason('hook-commit') + ) + const baseHead = git(repoPath, ['rev-parse', 'HEAD']) + + await finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/hook-commit', 'main') + + expect(git(finalPath, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature/hook-commit') + expect(git(finalPath, ['rev-parse', 'HEAD^'])).toBe(baseHead) + expect(git(finalPath, ['show', '-s', '--format=%s', 'HEAD'])).toBe('hook commit') + expect(await readFile(join(finalPath, 'hook-invocations.txt'), 'utf8')).toBe('invoked\n') + expect(git(finalPath, ['status', '--porcelain'])).toBe('') + }) + + it('cleans up a branch when post-checkout rejects the attachment', async () => { + const { repoPath, root } = await createRepo() + const preparedPath = join(root, 'prepared-hook-failure') + const finalPath = join(root, 'final-hook-failure') + const hooksPath = join(root, 'hooks') + await mkdir(hooksPath) + await writeFile(join(hooksPath, 'post-checkout'), '#!/bin/sh\nexit 1\n', { mode: 0o755 }) + git(repoPath, ['config', 'core.hooksPath', hooksPath]) + await prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + 'main', + createWorktreePreparationLockReason('hook-failure') + ) + + await expect( + finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/hook-failure', 'main') + ).rejects.toThrow() + expect(existsSync(finalPath)).toBe(false) + expect(git(repoPath, ['branch', '--list', 'feature/hook-failure'])).toBe('') + }) + it('retains preparation ownership when the removal command cannot start', async () => { const fixture = await createRepo() const repoPath = await realpath(fixture.repoPath) @@ -65,7 +196,7 @@ describe('prepared worktree creation with real Git', () => { await prepareWorktreeCreateCheckout(repoPath, preparedPath, 'main', lockReason) const original = gitRunner.gitExecFileAsync const spy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation((args, options) => { - if (args.includes('remove') && args.includes(preparedPath)) { + if (args.includes('remove') && args.some((arg) => areWorktreePathsEqual(arg, preparedPath))) { return Promise.reject(new Error('injected removal launch failure')) } return original(args, options) @@ -113,7 +244,7 @@ describe('prepared worktree creation with real Git', () => { const spy = vi .spyOn(gitRunner, 'gitExecFileAsync') .mockImplementation(async (args, options) => { - if (args.includes('remove') && args.includes(stalePath)) { + if (args.includes('remove') && args.some((arg) => areWorktreePathsEqual(arg, stalePath))) { markRemovalStarted() await removalGate } @@ -145,7 +276,8 @@ describe('prepared worktree creation with real Git', () => { expect(existsSync(stalePath)).toBe(false) const remaining = await listWorktrees(repoPath, { includeCreatePreparations: true }) expect(remaining).toHaveLength(2) - expect(remaining.map((w) => w.path)).toEqual(expect.arrayContaining([repoPath, finalPath])) + expect(remaining.some((w) => areWorktreePathsEqual(w.path, repoPath))).toBe(true) + expect(remaining.some((w) => areWorktreePathsEqual(w.path, finalPath))).toBe(true) expect(hasPendingStalePreparationCleanup()).toBe(false) } finally { releaseRemoval() diff --git a/src/main/gitea/client-scan-invalidation.test.ts b/src/main/gitea/client-scan-invalidation.test.ts new file mode 100644 index 00000000000..b93bf4e1824 --- /dev/null +++ b/src/main/gitea/client-scan-invalidation.test.ts @@ -0,0 +1,169 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../git/runner', () => ({ + gitExecFileAsync: vi.fn(async () => ({ + stdout: 'https://git.example.com/team/repo.git\n', + stderr: '' + })) +})) +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSSHGitProvider: vi.fn(() => undefined), + getSSHGitProviderGeneration: () => 0 +})) +vi.mock('../source-control/pull-request-template', () => ({ + readHostedPullRequestTemplate: vi.fn(async () => '') +})) + +import { + getGiteaPullRequestForBranch, + getGiteaPullRequestForBranchOrThrow, + getGiteaRepoSlug, + invalidateGiteaPullRequestScanForRepo +} from './client' +import { createGiteaPullRequest } from './pull-request-creation' +import { _resetGiteaRepoRefCache } from './repository-ref' +import { _resetGiteaPullRequestScanCache } from './pull-request-scan-cache' + +const branch = 'feature/gitea' +const pullRequest = { + number: 7, + title: 'Add Gitea', + state: 'open', + html_url: 'https://git.example.com/team/repo/pulls/7', + head: { ref: branch } +} + +function createReview() { + return createGiteaPullRequest( + '/repo', + { provider: 'gitea', base: 'main', head: branch, title: 'Add Gitea', body: '' }, + 'local' + ) +} + +describe('Gitea scan invalidation after creation', () => { + beforeEach(() => { + vi.stubEnv('ORCA_GITEA_TOKEN', 'test-token') + vi.stubEnv('ORCA_GITEA_API_BASE_URL', '') + _resetGiteaRepoRefCache() + _resetGiteaPullRequestScanCache() + }) + + afterEach(() => { + _resetGiteaRepoRefCache() + _resetGiteaPullRequestScanCache() + vi.unstubAllGlobals() + vi.unstubAllEnvs() + }) + + it('refreshes cached misses in both modes after a successful create and coalesces readers', async () => { + let created = false + let listCalls = 0 + let createCalls = 0 + vi.stubGlobal( + 'fetch', + vi.fn(async (url: URL, init?: RequestInit) => { + expect(url.pathname).toBe('/api/v1/repos/team/repo/pulls') + if (init?.method === 'POST') { + createCalls++ + created = true + return Response.json(pullRequest) + } + listCalls++ + return Response.json(created ? [pullRequest] : []) + }) + ) + + expect(await getGiteaPullRequestForBranch('/repo', branch)).toBeNull() + expect(await getGiteaPullRequestForBranchOrThrow('/repo', branch)).toBeNull() + expect(listCalls).toBe(2) + expect(await createReview()).toMatchObject({ ok: true, number: 7 }) + + const refreshed = await Promise.all([ + getGiteaPullRequestForBranchOrThrow('/repo', branch), + getGiteaPullRequestForBranchOrThrow('/repo', branch), + getGiteaPullRequestForBranch('/repo', branch), + getGiteaPullRequestForBranch('/repo', branch) + ]) + expect(refreshed.map((review) => review?.number)).toEqual([7, 7, 7, 7]) + expect(listCalls).toBe(4) + expect(createCalls).toBe(1) + expect(await getGiteaPullRequestForBranchOrThrow('/repo', branch)).toMatchObject({ number: 7 }) + expect(listCalls).toBe(4) + }) + + it('retires a strict miss when a create attempt recovers an existing pull request', async () => { + let attempted = false + let listCalls = 0 + vi.stubGlobal( + 'fetch', + vi.fn(async (_url: URL, init?: RequestInit) => { + if (init?.method === 'POST') { + attempted = true + return Response.json({ message: 'already exists' }, { status: 409 }) + } + listCalls++ + return Response.json(attempted ? [pullRequest] : []) + }) + ) + + expect(await getGiteaPullRequestForBranchOrThrow('/repo', branch)).toBeNull() + expect(await createReview()).toMatchObject({ + ok: false, + code: 'already_exists', + existingReview: { number: 7 } + }) + expect(await getGiteaPullRequestForBranchOrThrow('/repo', branch)).toMatchObject({ number: 7 }) + expect(listCalls).toBe(3) + }) + + it.each(['before', 'after'] as const)( + 'keeps fresh strict results when a pre-invalidation scan finishes %s its replacement', + async (completionOrder) => { + const oldResponse = Promise.withResolvers() + const freshResponse = Promise.withResolvers() + const oldStarted = Promise.withResolvers() + const fetchMock = vi + .fn() + .mockImplementationOnce(() => { + oldStarted.resolve() + return oldResponse.promise + }) + .mockImplementationOnce(() => freshResponse.promise) + vi.stubGlobal('fetch', fetchMock) + + const stale = getGiteaPullRequestForBranchOrThrow('/repo', branch) + await oldStarted.promise + const repo = await getGiteaRepoSlug('/repo') + if (!repo) { + throw new Error('Expected test repository') + } + invalidateGiteaPullRequestScanForRepo(repo) + const fresh = getGiteaPullRequestForBranchOrThrow('/repo', branch) + const concurrent = getGiteaPullRequestForBranchOrThrow('/repo', branch) + try { + await new Promise((resolve) => setImmediate(resolve)) + expect(fetchMock).toHaveBeenCalledTimes(2) + if (completionOrder === 'before') { + oldResponse.resolve(Response.json([])) + expect(await stale).toBeNull() + } + freshResponse.resolve(Response.json([pullRequest])) + expect(await fresh).toMatchObject({ number: 7 }) + expect(await concurrent).toMatchObject({ number: 7 }) + if (completionOrder === 'after') { + oldResponse.resolve(Response.json([])) + expect(await stale).toBeNull() + } + expect(await getGiteaPullRequestForBranchOrThrow('/repo', branch)).toMatchObject({ + number: 7 + }) + expect(fetchMock).toHaveBeenCalledTimes(2) + } finally { + oldResponse.resolve(Response.json([])) + freshResponse.resolve(Response.json([pullRequest])) + await Promise.all([stale, fresh, concurrent]) + } + } + ) +}) diff --git a/src/main/gitea/client.ts b/src/main/gitea/client.ts index ba9e7c3ed8f..ba768bb8131 100644 --- a/src/main/gitea/client.ts +++ b/src/main/gitea/client.ts @@ -131,7 +131,9 @@ function giteaPullRequestScanKey(repo: GiteaRepoRef): string { /** Invalidate the shared /pulls scan after Orca itself creates a PR so the * next worktree-card refresh sees it instead of a cached miss. */ export function invalidateGiteaPullRequestScanForRepo(repo: GiteaRepoRef): void { - invalidateGiteaPullRequestScan(giteaPullRequestScanKey(repo)) + const repoKey = giteaPullRequestScanKey(repo) + invalidateGiteaPullRequestScan(repoKey) + invalidateGiteaPullRequestScan(`${repoKey}::strict`) } async function getCommitStatus( diff --git a/src/main/github/work-item-details-api-parity.test.ts b/src/main/github/work-item-details-api-parity.test.ts deleted file mode 100644 index 187680f64cd..00000000000 --- a/src/main/github/work-item-details-api-parity.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, expectTypeOf, it } from 'vitest' -import type { GitHubPRFile, GitHubPRFileContents } from '../../shared/github/pull-request-types' -import type { GitHubWorkItemDetails } from '../../shared/github/work-item-types' -import type { IssueSourcePreference } from '../../shared/repo-types' -import type { LocalGitExecOptions } from './gh-utils' -import type { GitHubApiRepository } from './github-api-repository' -import * as workItemDetails from './work-item-details' - -type GetWorkItemDetails = ( - repoPath: string, - number: number, - type?: 'issue' | 'pr', - connectionId?: string | null, - localGitOptions?: LocalGitExecOptions, - preference?: IssueSourcePreference -) => Promise - -type GetPRFileContents = (args: { - repoPath: string - connectionId?: string | null - localGitOptions?: LocalGitExecOptions - prRepo?: GitHubApiRepository | null - prNumber: number - path: string - oldPath?: string - status: GitHubPRFile['status'] - headSha: string - baseSha: string -}) => Promise - -describe('work-item-details public API parity', () => { - it('retains only the established runtime exports and call signatures', () => { - expect(Object.keys(workItemDetails).sort()).toEqual(['getPRFileContents', 'getWorkItemDetails']) - expectTypeOf(workItemDetails.getWorkItemDetails).toEqualTypeOf() - expectTypeOf(workItemDetails.getPRFileContents).toEqualTypeOf() - }) -}) diff --git a/src/main/global-fetch-call-site-audit.test.ts b/src/main/global-fetch-call-site-audit.test.ts index 0c2133f320e..907092515f5 100644 --- a/src/main/global-fetch-call-site-audit.test.ts +++ b/src/main/global-fetch-call-site-audit.test.ts @@ -23,6 +23,7 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map([ ['main/orca-profiles/profile-cloud-client.ts', 1], ['main/orca-profiles/profile-cloud-org-members-client.ts', 1], ['main/rate-limits/codex-fetcher.ts', 3], + ['main/rate-limits/zcode-usage-fetcher.ts', 1], ['main/runtime/push/push-gateway-client.ts', 1], ['main/runtime/relay/relay-http-client.ts', 2], ['main/runtime/relay/relay-region-catalog-fetch.ts', 1], diff --git a/src/main/headless-automation-dispatcher-source-boundary.test.ts b/src/main/headless-automation-dispatcher-source-boundary.test.ts deleted file mode 100644 index 5f7a10fedec..00000000000 --- a/src/main/headless-automation-dispatcher-source-boundary.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -const source = readFileSync(join(__dirname, 'startup', 'main-process-automations.ts'), 'utf8') - -function sourceBetween(startPattern: string, endPattern: string): string { - const start = source.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf(endPattern, start + startPattern.length) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) -} - -describe('headless automation dispatcher source boundaries', () => { - it('creates new-per-run workspaces from the resolved run target repo', () => { - const createArgsSection = sourceBetween('buildHeadlessAutomationWorktreeCreateArgs({', '})') - - expect(createArgsSection).toContain('repo: target.repo') - expect(createArgsSection).not.toContain('automation.sourceContext') - }) -}) diff --git a/src/main/hermes/hermes-config-comment-boundaries.test.ts b/src/main/hermes/hermes-config-comment-boundaries.test.ts new file mode 100644 index 00000000000..b28ad582ed9 --- /dev/null +++ b/src/main/hermes/hermes-config-comment-boundaries.test.ts @@ -0,0 +1,90 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { parseDocument } from 'yaml' +import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem' +import { disablePlugin, enablePlugin, updateConfigContent } from './hermes-config-yaml' +import { HermesHookService } from './hook-service' + +const fixtures = [ + ...[' ', ' '].flatMap((indent) => + [true, false].map((retained) => ({ + name: `trailing list comment (${indent.length} spaces, retained: ${retained})`, + list: `${indent}# list head\n${retained ? `${indent}- keep\n` : ''}${indent}- orca-status # managed entry\n${indent}# list tail\n`, + suffix: ' custom: "off"\nmodel: "001"\n' + })) + ), + { + name: 'comment before the next root setting', + list: ' - keep\n - orca-status # managed entry\n', + suffix: '# Operator notes\nmodel: "001"\n' + }, + { + name: 'comment before the next plugin setting', + list: ' - keep\n - orca-status # managed entry\n', + suffix: ' # Plugin notes\n custom: "off"\nmodel: "001"\n' + }, + { + name: 'comment at the end of the document', + list: ' - keep\n - orca-status # managed entry\n', + suffix: '# End notes\n' + }, + { + name: 'leading comment on the first retained entry', + list: ' # lead\n - orca-status\n # between\n - keep\n', + suffix: 'model: "001"\n' + } +] + +describe.each(['\n', '\r\n'])('Hermes comment boundaries (%j)', (eol) => { + describe.each(['install', 'remove'])('%s', (operation) => { + it.each(fixtures)('preserves $name exactly once through the service', async (fixture) => { + const key = operation === 'install' ? 'disabled' : 'enabled' + const enabled = operation === 'install' ? ' enabled: [orca-status]\n' : '' + const input = `plugins:\n${enabled} ${key}:\n${fixture.list}${fixture.suffix}`.replaceAll( + '\n', + eol + ) + const updater = operation === 'install' ? enablePlugin : disablePlugin + const result = updateConfigContent(input, updater) + expect(result.detail).toBeUndefined() + const output = result.content ?? '' + const parsed = parseDocument(output) + expect(parsed.errors).toEqual([]) + expect(parsed.warnings).toEqual([]) + expect(parsed.toJS()).toEqual(updater(parseDocument(input).toJS())) + for (const comment of input.match(/#[^\r\n]*/g) ?? []) { + expect(output.split(comment)).toHaveLength(2) + } + expect(output.endsWith(fixture.suffix.replaceAll('\n', eol))).toBe(true) + expect(updateConfigContent(output, updater).content).toBe(output) + if (eol === '\r\n') { + expect(output.replaceAll(eol, '')).not.toContain('\n') + } + + const root = mkdtempSync(join(tmpdir(), 'orca-hermes-comments-')) + const home = join(root, '.hermes') + mkdirSync(home) + const path = join(home, 'config.yaml') + vi.stubEnv('HERMES_HOME', home) + try { + writeFileSync(path, input) + const service = new HermesHookService() + const status = operation === 'install' ? service.install() : service.remove() + expect(status.state).toBe(operation === 'install' ? 'installed' : 'not_installed') + expect(readFileSync(path, 'utf8')).toBe(output) + expect(readFileSync(`${path}.bak`, 'utf8')).toBe(input) + if (operation === 'install') { + writeFileSync(path, input) + const remote = await service.installRemote(createManagedHookLocalFilesystem(), root) + expect(remote.state).toBe('installed') + expect(readFileSync(path, 'utf8')).toBe(output) + } + } finally { + vi.unstubAllEnvs() + rmSync(root, { recursive: true, force: true }) + } + }) + }) +}) diff --git a/src/main/hermes/hermes-config-document.test.ts b/src/main/hermes/hermes-config-document.test.ts new file mode 100644 index 00000000000..744b6c23f6f --- /dev/null +++ b/src/main/hermes/hermes-config-document.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' +import { disablePlugin, enablePlugin, updateConfigContent } from './hermes-config-yaml' + +const fixture = [ + '# Operator documentation', + 'model: "001" # keep quoted', + 'prompt: |', + ' First line', + ' Second line', + 'defaults: &defaults', + ' temperature: 0.25 # setting', + 'other: *defaults', + '', + 'plugins:', + ' enabled:', + ' - "zeta" # last alphabetically', + ' - alpha # first occurrence', + ' - alpha # duplicate belongs to user', + ' disabled: [orca-status, blocked] # disabled choices', + ' custom: "off" # unrelated plugin setting', + '# End documentation', + '' +].join('\n') + +describe('Hermes YAML document edits', () => { + it.each(['\n', '\r\n'])('preserves comments and values through install/remove (%j)', (eol) => { + const initial = fixture.replaceAll('\n', eol) + const installed = updateConfigContent(initial, enablePlugin) + expect(installed.detail).toBeUndefined() + expect(installed.content).not.toBeNull() + const enabled = installed.content ?? '' + expect(parse(enabled)).toEqual({ + ...parse(initial), + plugins: { + enabled: ['zeta', 'alpha', 'alpha', 'orca-status'], + disabled: ['blocked'], + custom: 'off' + } + }) + const removed = updateConfigContent(enabled, disablePlugin).content ?? '' + expect(parse(removed)).toEqual({ + ...parse(initial), + plugins: { enabled: ['zeta', 'alpha', 'alpha'], disabled: ['blocked'], custom: 'off' } + }) + for (const output of [enabled, removed]) { + for (const comment of initial.match(/#[^\r\n]*/g) ?? []) { + expect(output).toContain(comment) + } + expect(output).toContain('model: "001" # keep quoted') + expect(output).toContain(`prompt: |${eol} First line${eol} Second line`) + expect(output).toContain('&defaults') + expect(output).toContain('*defaults') + if (eol === '\r\n') { + expect(output.replaceAll('\r\n', '')).not.toContain('\n') + } + } + }) + + it.each([ + '# existing\r\nplugins:\r\n enabled: [zeta, orca-status, alpha, alpha]\r\n', + 'plugins: &plugins\n enabled: [orca-status]\nother: *plugins\n' + ])('returns exact bytes for a semantic no-op', (input) => { + expect(updateConfigContent(input, enablePlugin)).toEqual({ content: input }) + }) + + it.each(['# no plugins\nmodel: test\n', 'plugins:\n disabled: [blocked]\n'])( + 'does not add absent lists when removing', + (input) => expect(updateConfigContent(input, disablePlugin)).toEqual({ content: input }) + ) + + it('retains comments attached to removed Orca entries', () => { + const input = + 'plugins:\n enabled:\n # keep this note\n - orca-status # managed entry\n - other\n' + const output = updateConfigContent(input, disablePlugin).content ?? '' + expect(parse(output).plugins.enabled).toEqual(['other']) + expect(output).toContain('# keep this note') + expect(output).toContain('# managed entry') + expect(output.match(/# keep this note/g)).toHaveLength(1) + }) + + it.each([ + 'plugins: [', + 'plugins: {}\nplugins: {}\n', + '- invalid root\n', + 'plugins: unexpected\n', + 'plugins:\n enabled: not-a-list\n', + 'plugins:\n enabled: [123]\n', + 'plugins:\n disabled: not-a-list\n', + 'plugins: !custom {}\n', + 'defaults: &defaults {plugins: {enabled: [other]}}\n<<: *defaults\n', + 'defaults: &defaults {enabled: [other]}\nplugins:\n <<: *defaults\n', + 'plugins: &plugins\n enabled: [other]\nother: *plugins\n', + 'plugins:\n enabled: &enabled [other]\nother: *enabled\n', + 'enabled: &enabled [other]\nplugins:\n enabled: *enabled\n', + 'plugins:\n enabled: [*missing]\n' + ])('refuses an unsafe update without a replacement (%s)', (input) => { + const output = updateConfigContent(input, enablePlugin) + expect(output.content).toBeNull() + expect(output.detail).toBeTruthy() + }) + + it('keeps folded scalars, quoted strings and long lines in a real update', () => { + const input = `# header\nprompt: >-\n First line\n Second line\nquoted: 'off'\nlong: ${'word ' + .repeat(30) + .trimEnd()}\nplugins: {enabled: [other]}\n` + const output = updateConfigContent(input, enablePlugin).content ?? '' + expect(output).toContain('prompt: >-\n First line\n Second line\n') + expect(output).toContain("quoted: 'off'") + expect(output).toContain(`long: ${'word '.repeat(30).trimEnd()}\n`) + expect(parse(output)).toEqual({ + ...parse(input), + plugins: { enabled: ['other', 'orca-status'] } + }) + }) + + it.each(['\n', '\r\n'])( + 'leaves unrelated source bytes intact with unusual indentation (%j)', + (eol) => { + const prefix = + 'model: "001" # spacing\nprompt: >-\n First\n Second\n\n# plugins\nplugins:\n custom: |-\n Unrelated\n text\n'.replaceAll( + '\n', + eol + ) + const input = `${prefix} enabled: [other] # choices${eol}# ending${eol}` + const installed = updateConfigContent(input, enablePlugin).content ?? '' + expect(installed.startsWith(prefix)).toBe(true) + expect(installed.endsWith(`# ending${eol}`)).toBe(true) + const removed = updateConfigContent(installed, disablePlugin).content ?? '' + expect(removed.startsWith(prefix)).toBe(true) + expect(parse(removed)).toEqual(parse(input)) + } + ) + + it.each([ + 'model: "001"', + 'model: "001"\n# end\n...\n', + '{model: "001"}\n', + 'plugins: {} # empty\n', + 'plugins: {custom: "off",} # flow\n', + 'plugins:\n custom: >-\n First\n Second\n# end\n' + ])('inserts missing plugin keys without losing other values (%s)', (input) => { + const installed = updateConfigContent(input, enablePlugin) + expect(installed.detail).toBeUndefined() + const output = installed.content ?? '' + const parsed = parse(input) + expect(parse(output)).toEqual({ + ...parsed, + plugins: { ...parsed.plugins, enabled: ['orca-status'] } + }) + for (const comment of input.match(/#[^\r\n]*/g) ?? []) { + expect(output).toContain(comment) + } + if (input.includes('First')) { + expect(output).toContain('custom: >-\n First\n Second\n') + } + }) + + it.each(['', '# empty document\n', 'null\n'])('enables a plugin in an empty config', (input) => { + const output = updateConfigContent(input, enablePlugin).content ?? '' + expect(parse(output)).toEqual({ plugins: { enabled: ['orca-status'] } }) + if (input.includes('#')) { + expect(output).toContain('# empty document') + } + }) +}) diff --git a/src/main/hermes/hermes-config-document.ts b/src/main/hermes/hermes-config-document.ts new file mode 100644 index 00000000000..71fe4ade608 --- /dev/null +++ b/src/main/hermes/hermes-config-document.ts @@ -0,0 +1,141 @@ +import { isDeepStrictEqual } from 'node:util' +import { isAlias, isMap, isNode, isScalar, isSeq, parseDocument, visit } from 'yaml' +import type { Document, YAMLMap, YAMLSeq } from 'yaml' +import type { HermesConfig } from './hermes-config-yaml' +import { applyHermesPluginSourceEdits } from './hermes-config-source-edits' + +function preserveRemovedComments(sequence: YAMLSeq, removed: unknown[], first: unknown): void { + const comments = removed.flatMap((node) => + isNode(node) + ? [node === first && !sequence.flow ? null : node.commentBefore, node.comment].filter( + (text) => text != null + ) + : [] + ) + if (comments.length > 0) { + sequence.comment = [sequence.comment, ...comments].filter((text) => text != null).join('\n') + } +} + +function updateStringSequence(plugins: YAMLMap, key: string, values: unknown): void { + if (values === undefined) { + return + } + if (!Array.isArray(values) || values.some((value) => typeof value !== 'string')) { + throw new Error(`Hermes plugins.${key} must be a string list`) + } + const sequence = plugins.get(key, true) + if (sequence === undefined) { + plugins.set(key, values) + return + } + if (!isSeq(sequence)) { + throw new Error(`Hermes plugins.${key} must be a string list`) + } + const remaining = [...values] + const removed: unknown[] = [] + const first = sequence.items[0] + sequence.items = sequence.items.filter((item) => { + if (!isScalar(item) || typeof item.value !== 'string') { + throw new Error(`Hermes plugins.${key} must contain plain string entries`) + } + const index = remaining.indexOf(item.value) + if (index === -1) { + removed.push(item) + return false + } + remaining.splice(index, 1) + return true + }) + preserveRemovedComments(sequence, removed, first) + for (const value of remaining) { + sequence.add(value) + } +} + +export function updateHermesPluginDocument( + content: string, + next: HermesConfig +): { content: string | null; detail?: string } { + try { + const document: Document = parseDocument(content) + if (document.errors.length > 0 || document.warnings.length > 0) { + throw new Error( + [...document.errors, ...document.warnings].map((item) => item.message).join('; ') + ) + } + if (isScalar(document.contents) && document.contents.value === null) { + const previous = document.contents + document.contents = document.createNode({}) + document.contents.commentBefore = previous.commentBefore + document.contents.comment = previous.comment + } + if (document.contents === null) { + document.contents = document.createNode({}) + } + if (!isMap(document.contents)) { + throw new Error('Hermes config.yaml root must be a mapping') + } + if (document.contents.anchor) { + throw new Error('Cannot safely edit an anchored Hermes root') + } + if (document.contents.has('<<')) { + throw new Error('Cannot safely edit a merged Hermes root') + } + + let plugins = document.get('plugins', true) + if (plugins === undefined) { + document.set('plugins', document.createNode({})) + plugins = document.get('plugins', true) + } + if (!isMap(plugins)) { + throw new Error('Hermes plugins must be a plain mapping') + } + // Aliases into a changed subtree could silently change unrelated settings. + visit(plugins, { + Node: (_key, node) => { + if (isAlias(node) || node.anchor || node.tag) { + throw new Error('Cannot safely edit Hermes plugins with anchors, aliases or tags') + } + }, + Pair: (_key, pair) => { + if (isScalar(pair.key) && pair.key.value === '<<') { + throw new Error('Cannot safely edit Hermes plugins with YAML merge keys') + } + } + }) + for (const key of ['enabled', 'disabled']) { + const existing = plugins.get(key, true) + if ( + existing !== undefined && + (!isSeq(existing) || + existing.items.some((item) => !isScalar(item) || typeof item.value !== 'string')) + ) { + throw new Error(`Hermes plugins.${key} must be a string list`) + } + } + const nextPlugins = next.plugins + if (typeof nextPlugins !== 'object' || nextPlugins === null) { + throw new Error('Hermes plugins must be a mapping') + } + if ('enabled' in nextPlugins) { + updateStringSequence(plugins, 'enabled', nextPlugins.enabled) + } + if ('disabled' in nextPlugins) { + updateStringSequence(plugins, 'disabled', nextPlugins.disabled) + } + const output = applyHermesPluginSourceEdits(content, document) + // Re-parse to reject edits that would change any unrelated alias-resolved value. + const verified = parseDocument(output) + if ( + verified.errors.length > 0 || + verified.warnings.length > 0 || + !isDeepStrictEqual(verified.toJS(), next) + ) { + throw new Error('Hermes plugin update would change unrelated configuration') + } + return { content: output } + } catch (error) { + return { content: null, detail: error instanceof Error ? error.message : String(error) } + } +} diff --git a/src/main/hermes/hermes-config-flow-insertion.test.ts b/src/main/hermes/hermes-config-flow-insertion.test.ts new file mode 100644 index 00000000000..f00c76c8f9e --- /dev/null +++ b/src/main/hermes/hermes-config-flow-insertion.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'vitest' +import { isMap, parseDocument } from 'yaml' +import { enablePlugin, updateConfigContent } from './hermes-config-yaml' + +const pluginEntries = [ + { name: 'empty', text: ' # empty,\n', expected: {} }, + { name: 'nonempty', text: 'custom: keep # note\n', expected: { custom: 'keep' } }, + { + name: 'trailing comma', + text: 'custom: keep, # separator,\n', + expected: { custom: 'keep' } + }, + { + name: 'comment comma', + text: 'custom: keep # no separator,\n', + expected: { custom: 'keep' } + }, + { + name: 'nested commas', + text: 'custom: {names: ["a,", b,],} # nested,\n', + expected: { custom: { names: ['a,', 'b'] } } + }, + { + name: 'explicit entry key', + text: '? custom : keep # explicit,\n', + expected: { custom: 'keep' } + } +] + +const contexts = [ + { name: 'block root', prefix: 'plugins: {', suffix: '} # outside' }, + { + name: 'indented block root', + prefix: ' model: "001" # before\n plugins: {', + suffix: ' } # outside\n prompt: |-\n Keep this text', + settings: { model: '001', prompt: 'Keep this text' } + }, + { name: 'explicit plugins key', prefix: '? "plugins"\n: {', suffix: '} # outside' }, + { + name: 'indented explicit plugins key', + prefix: ' ? plugins\n : {', + suffix: ' } # outside' + }, + { name: 'flow on next line', prefix: 'plugins:\n {', suffix: '} # outside' }, + { + name: 'nested in root flow', + prefix: '{model: "001", plugins: {', + suffix: '}, extra: [one, two,]} # outside', + settings: { model: '001', extra: ['one', 'two'] } + } +] + +function expectInsertion(source: string, expected: unknown, eol: string): string { + const original = parseDocument(source) + expect(original.errors).toEqual([]) + expect(original.warnings).toEqual([]) + const plugins = original.get('plugins', true) + const parent = isMap(plugins) ? plugins : original.contents + if (!isMap(parent) || !parent.range) { + throw new Error('Fixture must contain a ranged mapping') + } + const position = parent.range[1] - 1 + const result = updateConfigContent(source, enablePlugin) + expect(result.detail).toBeUndefined() + const output = result.content ?? '' + const document = parseDocument(output) + expect(document.errors).toEqual([]) + expect(document.warnings).toEqual([]) + expect(document.toJS()).toEqual(expected) + expect(output.startsWith(source.slice(0, position))).toBe(true) + expect(output.endsWith(source.slice(position))).toBe(true) + expect(updateConfigContent(output, enablePlugin)).toEqual({ content: output }) + if (eol === '\r\n') { + expect(output.replaceAll(eol, '')).not.toContain('\n') + } + return output +} + +describe.each(['\n', '\r\n'])('Hermes flow insertion indentation (%j)', (eol) => { + describe.each([false, true])('final newline = %j', (finalNewline) => { + describe.each(contexts)('$name', ({ prefix, suffix, settings }) => { + it.each(pluginEntries)('$name plugin map', ({ text, expected }) => { + const source = `${prefix}${text}${suffix}${finalNewline ? '\n' : ''}`.replaceAll('\n', eol) + expectInsertion( + source, + { ...settings, plugins: { ...expected, enabled: ['orca-status'] } }, + eol + ) + }) + }) + + it.each([ + { name: 'empty root', text: '{ # empty,\n}', expected: {} }, + { name: 'nonempty root', text: '{model: "001" # note,\n}', expected: { model: '001' } }, + { + name: 'indented root with nested trailing comma', + text: ' {model: {names: ["a,", b,],}, # separator,\n}', + expected: { model: { names: ['a,', 'b'] } } + } + ])('$name flow map', ({ text, expected }) => { + const source = `${text}${finalNewline ? '\n' : ''}`.replaceAll('\n', eol) + expectInsertion(source, { ...expected, plugins: { enabled: ['orca-status'] } }, eol) + }) + }) +}) + +describe.each(['\n', '\r\n'])('Hermes flow insertion with tab separation (%j)', (eol) => { + it.each(pluginEntries.slice(0, 3))('$name map after a tab', ({ text, expected }) => { + const source = `plugins: {${text}\t}`.replaceAll('\n', eol) + expectInsertion(source, { plugins: { ...expected, enabled: ['orca-status'] } }, eol) + }) + + it.each([ + { + name: 'minimal tab', + source: 'plugins: {custom: keep # note\n\t}', + padding: '\n ' + }, + { + name: 'deeper escaped quoted key', + source: ' "plu\\u0067ins": {custom: keep # note\n \t}', + padding: '\n ' + }, + { + name: 'mixed spaces and tabs below required indentation', + source: ' plugins: {custom: keep # note\n \t \t }', + padding: '\n ' + }, + { + name: 'whitespace-only closing line', + source: 'plugins: {custom: keep # note\n\t \t }', + padding: '\n ' + }, + { + name: 'sufficient spaces before a tab', + source: 'plugins: {custom: keep # note\n \t}', + padding: '' + }, + { + name: 'deeper sufficient spaces before mixed separation', + source: ' plugins: {custom: keep # note\n \t \t}', + padding: '' + }, + { + name: 'root flow with zero required indentation', + source: ' {plugins: {custom: keep # note\n\t}}', + padding: '' + }, + { + name: 'tab after content on the same line', + source: 'plugins: {custom: keep\t}', + padding: '' + }, + { + name: 'explicit plugins key', + source: '? "plugins"\n: {custom: keep # note\n\t}', + padding: '\n ' + }, + { + name: 'deeper split explicit key', + source: ' ?\n "plugins"\n : {custom: keep # note\n \t}', + padding: '\n ' + } + ])('preserves source around $name', ({ source: input, padding }) => { + const source = input.replaceAll('\n', eol) + const output = expectInsertion( + source, + { plugins: { custom: 'keep', enabled: ['orca-status'] } }, + eol + ) + const insertion = `${padding}, enabled: [ orca-status ]`.replaceAll('\n', eol) + expect(output).toBe(source.replace('}', `${insertion}}`)) + }) + + it('rejects an unrelated value change after repairing tab-separated insertion', () => { + const source = 'model: "001"\nplugins: {custom: keep # note\n\t}'.replaceAll('\n', eol) + expect( + updateConfigContent(source, (config) => ({ ...enablePlugin(config), model: 'changed' })) + ).toEqual({ + content: null, + detail: 'Hermes plugin update would change unrelated configuration' + }) + }) +}) diff --git a/src/main/hermes/hermes-config-preservation.test.ts b/src/main/hermes/hermes-config-preservation.test.ts new file mode 100644 index 00000000000..9756f7a3230 --- /dev/null +++ b/src/main/hermes/hermes-config-preservation.test.ts @@ -0,0 +1,202 @@ +import { + chmodSync, + existsSync, + linkSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + symlinkSync, + utimesSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { parse } from 'yaml' +import { HermesHookService } from './hook-service' + +const configured = + '# Operator comments\r\nmodel: "fixture"\r\nplugins:\r\n enabled: [orca-status]\r\n' +const initial = + '# Recovery notes\nmodel: "001"\nprompt: |\n First\n Second\nplugins:\n enabled: [other] # choices\n' + +describe('Hermes config preservation on disk', () => { + let directory: string + let configPath: string + const service = new HermesHookService() + + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'orca-hermes-config-')) + configPath = join(directory, 'config.yaml') + vi.stubEnv('HERMES_HOME', directory) + }) + + afterEach(() => { + vi.unstubAllEnvs() + rmSync(directory, { recursive: true, force: true }) + }) + + it('leaves unchanged bytes, inode, timestamp, mode and backup untouched', () => { + writeFileSync(configPath, configured, { mode: 0o600 }) + writeFileSync(`${configPath}.bak`, 'previous recovery point', { mode: 0o600 }) + utimesSync(configPath, 1_600_000_000, 1_600_000_000) + const before = statSync(configPath) + const backup = statSync(`${configPath}.bak`) + expect(service.install().state).toBe('installed') + expect(readFileSync(configPath, 'utf8')).toBe(configured) + const after = statSync(configPath) + expect([after.ino, after.mtimeMs, after.mode]).toEqual([ + before.ino, + before.mtimeMs, + before.mode + ]) + expect(statSync(`${configPath}.bak`)).toEqual(backup) + expect(readFileSync(`${configPath}.bak`, 'utf8')).toBe('previous recovery point') + }) + + it('does not create a backup on a no-op', () => { + writeFileSync(configPath, configured) + service.install() + expect(existsSync(`${configPath}.bak`)).toBe(false) + }) + + it('does not create a config when removing an absent integration', () => { + expect(service.remove().state).toBe('not_installed') + expect(readdirSync(directory)).toEqual([]) + }) + + it('preserves comments and multiline values through real install and removal', () => { + writeFileSync(configPath, initial) + expect(service.install().state).toBe('installed') + const installed = readFileSync(configPath, 'utf8') + expect(parse(installed).plugins.enabled).toEqual(['other', 'orca-status']) + expect(readFileSync(`${configPath}.bak`, 'utf8')).toBe(initial) + expect(service.remove().state).toBe('not_installed') + const removed = readFileSync(configPath, 'utf8') + for (const output of [installed, removed]) { + expect(output).toContain('# Recovery notes') + expect(output).toContain('# choices') + expect(output).toContain('model: "001"') + expect(output).toContain('prompt: |\n First\n Second\n') + } + expect(parse(removed)).toEqual(parse(initial)) + expect(readFileSync(`${configPath}.bak`, 'utf8')).toBe(installed) + }) + + it.skipIf(process.platform === 'win32').each([ + [0o600, 0o022], + [0o600, 0], + [0o640, 0o077], + [0o640, 0] + ])('preserves mode %i with umask %i and a byte-exact backup', (mode, mask) => { + writeFileSync(configPath, initial) + chmodSync(configPath, mode) + const previous = process.umask(mask) + try { + expect(service.install().state).toBe('installed') + } finally { + process.umask(previous) + } + expect(statSync(configPath).mode & 0o777).toBe(mode) + expect(statSync(`${configPath}.bak`).mode & 0o777).toBe(mode) + expect(readFileSync(`${configPath}.bak`, 'utf8')).toBe(initial) + expect(readdirSync(directory).sort()).toEqual(['config.yaml', 'config.yaml.bak', 'plugins']) + }) + + it.skipIf(process.platform === 'win32')( + 'creates a private new config with permissive umask', + () => { + const previous = process.umask(0) + try { + expect(service.install().state).toBe('installed') + } finally { + process.umask(previous) + } + expect(statSync(configPath).mode & 0o777).toBe(0o600) + expect(existsSync(`${configPath}.bak`)).toBe(false) + } + ) + + it.skipIf(process.platform === 'win32')('keeps the config symlink and updates its target', () => { + const target = join(directory, 'dotfiles.yaml') + writeFileSync(target, initial, { mode: 0o600 }) + symlinkSync(target, configPath) + expect(service.install().state).toBe('installed') + expect(lstatSync(configPath).isSymbolicLink()).toBe(true) + expect(parse(readFileSync(target, 'utf8')).plugins.enabled).toContain('orca-status') + expect(readFileSync(`${target}.bak`, 'utf8')).toBe(initial) + }) + + it.skipIf(process.platform === 'win32')('refuses a dangling config symlink', () => { + symlinkSync(join(directory, 'missing.yaml'), configPath) + expect(() => service.install()).toThrow() + expect(lstatSync(configPath).isSymbolicLink()).toBe(true) + expect(readdirSync(directory)).toEqual(['config.yaml']) + }) + + it.skipIf(process.platform === 'win32')( + 'refuses a symlinked backup without touching its target', + () => { + writeFileSync(configPath, initial) + const target = join(directory, 'unrelated') + writeFileSync(target, 'preserve me') + symlinkSync(target, `${configPath}.bak`) + expect(() => service.install()).toThrow('Refusing to overwrite symlinked backup') + expect(readFileSync(configPath, 'utf8')).toBe(initial) + expect(readFileSync(target, 'utf8')).toBe('preserve me') + expect(readdirSync(directory).some((name) => name.endsWith('.tmp'))).toBe(false) + } + ) + + it('replaces a hard-linked backup without changing the unrelated inode', () => { + writeFileSync(configPath, initial) + const target = join(directory, 'unrelated') + writeFileSync(target, 'preserve me') + linkSync(target, `${configPath}.bak`) + expect(service.install().state).toBe('installed') + expect(readFileSync(target, 'utf8')).toBe('preserve me') + expect(readFileSync(`${configPath}.bak`, 'utf8')).toBe(initial) + }) + + it('keeps the config and cleans temporary files when backup publication fails', () => { + writeFileSync(configPath, initial) + mkdirSync(`${configPath}.bak`) + expect(() => service.install()).toThrow() + expect(readFileSync(configPath, 'utf8')).toBe(initial) + expect(readdirSync(directory).some((name) => name.endsWith('.tmp'))).toBe(false) + }) + + it('keeps installed plugin files if removal cannot back up the config', () => { + expect(service.install().state).toBe('installed') + const before = readFileSync(configPath, 'utf8') + mkdirSync(`${configPath}.bak`) + expect(() => service.remove()).toThrow() + expect(readFileSync(configPath, 'utf8')).toBe(before) + expect(existsSync(join(directory, 'plugins', 'orca-status', '__init__.py'))).toBe(true) + expect(readdirSync(directory).some((name) => name.endsWith('.tmp'))).toBe(false) + }) + + it('refuses alias-bearing removal before deleting installed plugin files', () => { + expect(service.install().state).toBe('installed') + const input = 'plugins:\n enabled: &enabled [orca-status, other]\ncopy: *enabled\n' + writeFileSync(configPath, input) + expect(service.remove().state).toBe('error') + expect(readFileSync(configPath, 'utf8')).toBe(input) + expect(existsSync(join(directory, 'plugins', 'orca-status', '__init__.py'))).toBe(true) + expect(existsSync(`${configPath}.bak`)).toBe(false) + }) + + it.each(['plugins: [', 'plugins: unexpected\n', 'plugins:\n enabled: not-a-list\n'])( + 'does not install files or rewrite unexpected YAML (%s)', + (input) => { + writeFileSync(configPath, input) + expect(service.install().state).toBe('error') + expect(readFileSync(configPath, 'utf8')).toBe(input) + expect(readdirSync(directory)).toEqual(['config.yaml']) + } + ) +}) diff --git a/src/main/hermes/hermes-config-remote-adapter.test.ts b/src/main/hermes/hermes-config-remote-adapter.test.ts new file mode 100644 index 00000000000..3005d9ef9dd --- /dev/null +++ b/src/main/hermes/hermes-config-remote-adapter.test.ts @@ -0,0 +1,118 @@ +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { parse } from 'yaml' +import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem' +import { HermesHookService } from './hook-service' + +describe('Hermes remote installer through a local filesystem adapter (no SSH)', () => { + let directory: string + let configPath: string + const service = new HermesHookService() + + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'orca-hermes-remote-adapter-')) + mkdirSync(join(directory, '.hermes')) + configPath = join(directory, '.hermes', 'config.yaml') + }) + afterEach(() => rmSync(directory, { recursive: true, force: true })) + + it('does not rewrite a configured CRLF file or an existing backup', async () => { + const initial = '# operator notes\r\nplugins:\r\n enabled: [zeta, orca-status, alpha]\r\n' + writeFileSync(configPath, initial) + writeFileSync(`${configPath}.bak`, 'recovery point') + const before = statSync(configPath) + expect((await service.installRemote(createManagedHookLocalFilesystem(), directory)).state).toBe( + 'installed' + ) + expect(readFileSync(configPath, 'utf8')).toBe(initial) + const after = statSync(configPath) + expect([after.ino, after.mtimeMs, after.mode]).toEqual([ + before.ino, + before.mtimeMs, + before.mode + ]) + expect(readFileSync(`${configPath}.bak`, 'utf8')).toBe('recovery point') + }) + + it.each(['\n', '\r\n'])( + 'preserves comments and parsed values during real install (%j)', + async (eol) => { + const input = + '# notes\nmodel: "001"\nprompt: |\n First\n Second\nplugins:\n enabled: [other]\n'.replaceAll( + '\n', + eol + ) + writeFileSync(configPath, input) + const filesystem = createManagedHookLocalFilesystem() + expect((await service.installRemote(filesystem, directory)).state).toBe('installed') + const installed = readFileSync(configPath, 'utf8') + expect(installed).toContain('# notes') + expect(installed).toContain(`prompt: |${eol} First${eol} Second`) + expect(parse(installed)).toEqual({ + ...parse(input), + plugins: { enabled: ['other', 'orca-status'] } + }) + const before = statSync(configPath) + expect((await service.installRemote(filesystem, directory)).state).toBe('installed') + expect(statSync(configPath).ino).toBe(before.ino) + expect(existsSync(`${configPath}.bak`)).toBe(false) + } + ) + + it.skipIf(process.platform === 'win32').each([0o600, 0o640])( + 'preserves mode %i despite umask', + async (mode) => { + writeFileSync(configPath, 'model: fixture\n') + chmodSync(configPath, mode) + const previous = process.umask(0o077) + try { + expect( + (await service.installRemote(createManagedHookLocalFilesystem(), directory)).state + ).toBe('installed') + } finally { + process.umask(previous) + } + expect(statSync(configPath).mode & 0o777).toBe(mode) + } + ) + + it.each(['plugins: [', 'plugins: unexpected\n', 'plugins:\n enabled: [123]\n'])( + 'refuses unsafe YAML before writing plugin files (%s)', + async (input) => { + writeFileSync(configPath, input) + expect( + (await service.installRemote(createManagedHookLocalFilesystem(), directory)).state + ).toBe('error') + expect(readFileSync(configPath, 'utf8')).toBe(input) + expect(readdirSync(join(directory, '.hermes'))).toEqual(['config.yaml']) + } + ) + + it('keeps the config and cleans staged files when remote rename fails', async () => { + writeFileSync(configPath, 'model: fixture\n') + const filesystem = createManagedHookLocalFilesystem() + const rename = filesystem.ext_openssh_rename.bind(filesystem) + filesystem.ext_openssh_rename = (source, destination, callback) => { + if (destination === configPath) { + callback(new Error('fixture rename refusal')) + } else { + rename(source, destination, callback) + } + } + expect((await service.installRemote(filesystem, directory)).state).toBe('error') + expect(readFileSync(configPath, 'utf8')).toBe('model: fixture\n') + expect(readdirSync(join(directory, '.hermes')).sort()).toEqual(['config.yaml', 'plugins']) + }) +}) diff --git a/src/main/hermes/hermes-config-source-edits.test.ts b/src/main/hermes/hermes-config-source-edits.test.ts new file mode 100644 index 00000000000..01bc0a06f27 --- /dev/null +++ b/src/main/hermes/hermes-config-source-edits.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest' +import { parseDocument } from 'yaml' +import { disablePlugin, enablePlugin, updateConfigContent } from './hermes-config-yaml' + +describe.each(['\n', '\r\n'])('Hermes source edit boundaries (%j)', (eol) => { + describe.each([ + { + operation: 'remove', + key: 'enabled', + updater: disablePlugin, + expected: { enabled: [] } + }, + { + operation: 'install', + key: 'disabled', + updater: enablePlugin, + expected: { disabled: [], enabled: ['orca-status'] } + } + ])('$operation with an emptied block list', ({ key, updater, expected }) => { + it('replaces a minimal indentless list without a final newline', () => { + const input = `plugins:${eol} ${key}:${eol} - orca-status` + const result = updateConfigContent(input, updater) + expect(result.detail).toBeUndefined() + const document = parseDocument(result.content ?? '') + expect(document.errors).toEqual([]) + expect(document.warnings).toEqual([]) + expect(document.toJS()).toEqual({ plugins: expected }) + }) + + it.each([ + ['indentless', ' ', ' '], + ['deeper indentless', ' ', ' '], + ['indented', ' ', ' '] + ])('preserves comments and unrelated settings (%s)', (_name, keyIndent, listIndent) => { + const prefix = [ + 'model: "001" # untouched spacing', + 'plugins:', + `${keyIndent}"${key}": # list note`, + `${listIndent}# first choice`, + '' + ].join(eol) + const custom = `${keyIndent}custom: 'off' # plugin setting${eol}` + const suffix = ['prompt: |-', ' First', ' Second', ''].join(eol) + const input = `${prefix}${listIndent}- orca-status # managed choice${eol}${custom}${suffix}` + const result = updateConfigContent(input, updater) + expect(result.detail).toBeUndefined() + const output = result.content ?? '' + const document = parseDocument(output) + expect(document.errors).toEqual([]) + expect(document.warnings).toEqual([]) + expect(document.toJS()).toEqual({ + model: '001', + plugins: { ...expected, custom: 'off' }, + prompt: 'First\nSecond' + }) + expect(output.startsWith(prefix)).toBe(true) + expect(output).toContain(custom) + expect(output.endsWith(suffix)).toBe(true) + expect(output.match(/# managed choice/g)).toHaveLength(1) + if (eol === '\r\n') { + expect(output.replaceAll(eol, '')).not.toContain('\n') + } + }) + }) + + it.each([ + { + name: 'plugin trailing comma before a comment', + input: 'plugins: {custom: keep, # retain this comment\n }\n', + expected: { plugins: { custom: 'keep', enabled: ['orca-status'] } } + }, + { + name: 'root trailing comma before a comment', + input: '{model: "001", # retain this comment\n}\n', + expected: { model: '001', plugins: { enabled: ['orca-status'] } } + }, + { + name: 'plugin trailing comma before several comments', + input: 'plugins: {custom: "keep # ,", # first\n # last\n } # outside\n', + expected: { plugins: { custom: 'keep # ,', enabled: ['orca-status'] } } + }, + { + name: 'root trailing comma after a nested map', + input: '{model: {name: "001",}, # first\n # last\n}\n', + expected: { model: { name: '001' }, plugins: { enabled: ['orca-status'] } } + }, + { + name: 'plugin comment ending in a comma without a separator', + input: 'plugins: {custom: keep # this comma is only a comment ,\n }\n', + expected: { plugins: { custom: 'keep', enabled: ['orca-status'] } } + }, + { + name: 'root comment ending in a comma without a separator', + input: '{model: "001" # this comma is only a comment ,\n}\n', + expected: { model: '001', plugins: { enabled: ['orca-status'] } } + }, + { + name: 'earlier plugin separator without a trailing comma', + input: 'plugins: {custom: keep, extra: "off" # last value\n }\n', + expected: { plugins: { custom: 'keep', extra: 'off', enabled: ['orca-status'] } } + }, + { + name: 'nested sequence comma without a mapping separator', + input: 'plugins: {custom: [one, two,] # nested comma only\n }\n', + expected: { plugins: { custom: ['one', 'two'], enabled: ['orca-status'] } } + } + ])('inserts entries with $name', ({ input, expected }) => { + const source = input.replaceAll('\n', eol) + const result = updateConfigContent(source, enablePlugin) + expect(result.detail).toBeUndefined() + const output = result.content ?? '' + const document = parseDocument(output) + expect(document.errors).toEqual([]) + expect(document.warnings).toEqual([]) + expect(document.toJS()).toEqual(expected) + const closingBrace = source.lastIndexOf('}') + expect(output.startsWith(source.slice(0, closingBrace))).toBe(true) + expect(output.endsWith(source.slice(closingBrace))).toBe(true) + if (eol === '\r\n') { + expect(output.replaceAll(eol, '')).not.toContain('\n') + } + }) +}) diff --git a/src/main/hermes/hermes-config-source-edits.ts b/src/main/hermes/hermes-config-source-edits.ts new file mode 100644 index 00000000000..17b770e8abd --- /dev/null +++ b/src/main/hermes/hermes-config-source-edits.ts @@ -0,0 +1,158 @@ +import { isDeepStrictEqual } from 'node:util' +import { Document, isMap, isNode, isSeq, parseDocument } from 'yaml' +import type { Node, YAMLMap, YAMLSeq } from 'yaml' + +type SourceEdit = { start: number; end: number; text: string } + +function columnAt(source: string, offset: number): number { + return offset - source.lastIndexOf('\n', offset - 1) - 1 +} + +function indentFragment(fragment: string, difference: number): string { + return fragment.replace( + /\n( *)(?=\S)/g, + (_match, spaces: string) => `\n${' '.repeat(Math.max(0, spaces.length + difference))}` + ) +} + +function requireRange(node: unknown): [number, number, number] { + if (!isNode(node) || !node.range) { + throw new Error('Missing Hermes YAML source range') + } + return node.range +} + +function insertPair( + source: string, + printed: string, + parent: YAMLMap, + updated: YAMLMap, + key: string, + enclosing: YAMLMap = parent +): SourceEdit { + const pair = updated.items.find((item) => isNode(item.key) && item.key.toJSON() === key) + if (!pair) { + throw new Error(`Missing Hermes YAML key: ${key}`) + } + const keyRange = requireRange(pair.key) + const valueRange = requireRange(pair.value) + const parentRange = requireRange(parent) + if (parent.flow) { + const position = parentRange[1] - 1 + if (source[position] !== '}') { + throw new Error('Unexpected Hermes flow mapping boundary') + } + const tail = + parent.srcToken?.type === 'flow-collection' ? parent.srcToken.items.at(-1) : undefined + const trailingComma = + tail?.key === undefined && + tail?.sep === undefined && + tail?.value === undefined && + tail?.start.some((token) => token.type === 'comma') + const separator = parent.items.length > 0 && !trailingComma ? ', ' : ' ' + // A flow closing brace may be outdented farther than an inserted entry or comma. + const minimumColumn = enclosing.flow ? 0 : columnAt(source, requireRange(enclosing)[0]) + 1 + const column = columnAt(source, position) + const linePrefix = source.slice(position - column, position) + const spacesBeforeTab = /^( *)\t[ \t]*$/.exec(linePrefix)?.[1]?.length + // Tabs are separation, so insufficient leading spaces require a fresh indented line. + const padding = + spacesBeforeTab !== undefined && spacesBeforeTab < minimumColumn + ? `\n${' '.repeat(minimumColumn)}` + : ' '.repeat(Math.max(0, minimumColumn - column)) + return { + start: position, + end: position, + text: padding + separator + printed.slice(keyRange[0], valueRange[1]) + } + } + const column = columnAt(source, parentRange[0]) + const fragment = printed.slice(keyRange[0], valueRange[2]) + const prefix = parentRange[1] > 0 && source[parentRange[1] - 1] !== '\n' ? '\n' : '' + const text = `${prefix}${' '.repeat(column)}${indentFragment(fragment, column - columnAt(printed, keyRange[0]))}` + return { + start: parentRange[1], + end: parentRange[1], + text: text.endsWith('\n') ? text : `${text}\n` + } +} + +export function applyHermesPluginSourceEdits(source: string, document: Document): string { + const printed = document.toString({ lineWidth: 0 }) + const original = parseDocument(source, { keepSourceTokens: true }) + const updated = parseDocument(printed) + if (!isMap(original.contents)) { + return source.includes('\r\n') ? printed.replaceAll('\n', '\r\n') : printed + } + if (!isMap(updated.contents)) { + throw new Error('Missing updated Hermes mapping') + } + const plugins = original.get('plugins', true) + const updatedPlugins = updated.get('plugins', true) + if (!isMap(updatedPlugins)) { + throw new Error('Missing updated Hermes plugins') + } + const edits: SourceEdit[] = [] + if (plugins === undefined) { + edits.push(insertPair(source, printed, original.contents, updated.contents, 'plugins')) + } else { + if (!isMap(plugins)) { + throw new Error('Unexpected Hermes plugins mapping') + } + for (const key of ['enabled', 'disabled']) { + const previous = plugins.get(key, true) + const next = updatedPlugins.get(key, true) + if (!isNode(next)) { + continue + } + if (previous === undefined) { + edits.push(insertPair(source, printed, plugins, updatedPlugins, key, original.contents)) + } else if (isNode(previous) && !isDeepStrictEqual(previous.toJSON(), next.toJSON())) { + const edited = document.createNode(document.getIn(['plugins', key], true)) + if (!isSeq(edited)) { + throw new Error('Missing edited Hermes plugin list') + } + edits.push(replaceNode(source, plugins, previous, edited)) + } + } + } + const newline = source.includes('\r\n') ? '\r\n' : '\n' + let result = source + for (const edit of edits.sort((a, b) => b.start - a.start)) { + result = + result.slice(0, edit.start) + edit.text.replaceAll('\n', newline) + result.slice(edit.end) + } + return result +} + +function replaceNode(source: string, parent: YAMLMap, previous: Node, next: YAMLSeq): SourceEdit { + const before = requireRange(previous) + // YAML ranges can include the next key's indentation after a trailing comment. + const trailingIndent = + /(?:^|\n)([ \t]+)$/.exec(source.slice(before[0], before[2]))?.[1].length ?? 0 + const end = before[2] - trailingIndent + // Printing only this list keeps neighboring comments out of its replacement. + const isolated = new Document() + const sequence = next.clone() + if (!isSeq(sequence)) { + throw new Error('Missing cloned Hermes plugin list') + } + isolated.contents = sequence + // The original leading comment remains outside the replaced source range. + isolated.contents.commentBefore = undefined + let fragment = isolated.toString({ lineWidth: 0 }) + if (!source.slice(before[0], end).endsWith('\n') && !next.comment) { + fragment = fragment.replace(/\n$/, '') + } + const column = columnAt(source, before[0]) + // Empty flow lists cannot use a block sequence's indentless position. + const replacementColumn = + isSeq(previous) && !previous.flow && (next.flow || next.items.length === 0) + ? Math.max(column, columnAt(source, requireRange(parent)[0]) + 2) + : column + return { + start: before[0], + end, + text: ' '.repeat(replacementColumn - column) + indentFragment(fragment, replacementColumn) + } +} diff --git a/src/main/hermes/hermes-config-yaml.ts b/src/main/hermes/hermes-config-yaml.ts index 9641359c3dd..c1fbdea9886 100644 --- a/src/main/hermes/hermes-config-yaml.ts +++ b/src/main/hermes/hermes-config-yaml.ts @@ -1,6 +1,8 @@ -import { parse, stringify } from 'yaml' +import { isDeepStrictEqual } from 'node:util' +import { parse } from 'yaml' import { HERMES_PLUGIN_NAME } from './hermes-managed-plugin-source' +import { updateHermesPluginDocument } from './hermes-config-document' export type HermesConfig = Record @@ -41,16 +43,14 @@ export function parseHermesConfig(content: string | null): ConfigParseResult { } } -export function serializeHermesConfig(config: HermesConfig): string { - return `${stringify(config, { lineWidth: 0 }).trimEnd()}\n` -} - export function enablePlugin(config: HermesConfig): HermesConfig { const next: HermesConfig = { ...config } const plugins = isRecord(next.plugins) ? { ...next.plugins } : {} const enabled = asStringArray(plugins.enabled) ?? [] const disabled = asStringArray(plugins.disabled) - plugins.enabled = Array.from(new Set([...enabled, HERMES_PLUGIN_NAME])).sort() + plugins.enabled = enabled.includes(HERMES_PLUGIN_NAME) + ? enabled + : [...enabled, HERMES_PLUGIN_NAME] if (disabled === null) { // Why: Hermes treats a malformed disabled list as empty. Normalize it here // so Orca's install status matches what the real Hermes loader will do. @@ -70,7 +70,7 @@ export function disablePlugin(config: HermesConfig): HermesConfig { } const plugins = { ...next.plugins } const enabled = asStringArray(plugins.enabled) - if (enabled !== null) { + if (enabled !== null && plugins.enabled !== undefined) { plugins.enabled = enabled.filter((name) => name !== HERMES_PLUGIN_NAME) } next.plugins = plugins @@ -85,7 +85,11 @@ export function updateConfigContent( if (!parsed.ok) { return { content: null, detail: parsed.detail } } - return { content: serializeHermesConfig(updater(parsed.config)) } + const next = updater(parsed.config) + if (isDeepStrictEqual(parsed.config, next)) { + return { content: content ?? '' } + } + return updateHermesPluginDocument(content ?? '', next) } export function getConfigEnablement(config: HermesConfig): { diff --git a/src/main/hermes/hermes-home-filesystem.ts b/src/main/hermes/hermes-home-filesystem.ts index 7cd7a48a863..9ff322d5bfa 100644 --- a/src/main/hermes/hermes-home-filesystem.ts +++ b/src/main/hermes/hermes-home-filesystem.ts @@ -1,18 +1,11 @@ -import { randomUUID } from 'node:crypto' -import { - copyFileSync, - existsSync, - mkdirSync, - readFileSync, - renameSync, - unlinkSync, - writeFileSync -} from 'node:fs' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' -import { dirname, join } from 'node:path' +import { join } from 'node:path' import type { ConfigParseResult, HermesConfig } from './hermes-config-yaml' -import { parseHermesConfig, serializeHermesConfig } from './hermes-config-yaml' +import { parseHermesConfig } from './hermes-config-yaml' +import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path' +import { writeHooksJson } from '../agent-hooks/installer-utils' import { HERMES_PLUGIN_MARKER, HERMES_PLUGIN_NAME, @@ -41,43 +34,19 @@ function getInitPath(pluginDir = getPluginDir()): string { return join(pluginDir, '__init__.py') } -export function readConfigFile(configPath: string): ConfigParseResult { - if (!existsSync(configPath)) { - return { ok: true, config: {} } - } - return parseHermesConfig(readFileSync(configPath, 'utf-8')) +type ConfigFileReadResult = + | { ok: true; config: HermesConfig; content: string } + | Extract + +export function readConfigFile(configPath: string): ConfigFileReadResult { + const readPath = resolveHooksJsonWritePath(configPath) + const content = existsSync(readPath) ? readFileSync(readPath, 'utf-8') : '' + const parsed = parseHermesConfig(content) + return parsed.ok ? { ...parsed, content } : parsed } -export function writeConfigFile(configPath: string, config: HermesConfig): void { - const dir = dirname(configPath) - mkdirSync(dir, { recursive: true }) - const serialized = serializeHermesConfig(config) - if (existsSync(configPath)) { - try { - if (readFileSync(configPath, 'utf-8') === serialized) { - return - } - } catch { - // Fall through to the atomic write path. - } - } - - const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`) - try { - writeFileSync(tmpPath, serialized, 'utf-8') - if (existsSync(configPath)) { - copyFileSync(configPath, `${configPath}.bak`) - } - renameSync(tmpPath, configPath) - } finally { - if (existsSync(tmpPath)) { - try { - unlinkSync(tmpPath) - } catch { - // best effort - } - } - } +export function writeConfigFile(configPath: string, content: string): void { + writeHooksJson(configPath, {}, { serialized: content, preserveMode: true, defaultMode: 0o600 }) } export function getPluginFilesState(pluginDir = getPluginDir()): { diff --git a/src/main/hermes/hook-service.test.ts b/src/main/hermes/hook-service.test.ts index 4180caf616e..3158b551480 100644 --- a/src/main/hermes/hook-service.test.ts +++ b/src/main/hermes/hook-service.test.ts @@ -74,7 +74,7 @@ describe('HermesHookService', () => { expect(config.plugins.disabled).toEqual([]) }) - it('normalizes malformed plugin lists during install', () => { + it('refuses malformed plugin lists during install', () => { writeFileSync( join(homeDir, 'config.yaml'), ['plugins:', ' enabled: "not-a-list"', ' disabled: "not-a-list"', ''].join('\n'), @@ -83,12 +83,12 @@ describe('HermesHookService', () => { const status = new HermesHookService().install() - expect(status.state).toBe('installed') + expect(status.state).toBe('error') const config = parse(readFileSync(join(homeDir, 'config.yaml'), 'utf-8')) as { plugins: { enabled: string[]; disabled: string[] } } - expect(config.plugins.enabled).toEqual([_internals.HERMES_PLUGIN_NAME]) - expect(config.plugins.disabled).toEqual([]) + expect(config.plugins.enabled).toBe('not-a-list') + expect(config.plugins.disabled).toBe('not-a-list') }) it('reports partial when the plugin exists but is not enabled', () => { diff --git a/src/main/hermes/hook-service.ts b/src/main/hermes/hook-service.ts index 6b1b141f721..6b6d6ec8873 100644 --- a/src/main/hermes/hook-service.ts +++ b/src/main/hermes/hook-service.ts @@ -96,8 +96,20 @@ export class HermesHookService { } } + const next = updateConfigContent(parsed.content, enablePlugin) + if (next.content === null) { + return { + agent: 'hermes', + state: 'error', + configPath, + managedHooksPresent: getPluginFilesState().managed, + detail: `Could not update Hermes config.yaml: ${next.detail ?? 'unknown error'}` + } + } writePluginFiles() - writeConfigFile(configPath, enablePlugin(parsed.config)) + if (next.content !== parsed.content) { + writeConfigFile(configPath, next.content) + } return this.getStatus() } @@ -150,11 +162,23 @@ export class HermesHookService { detail: `Could not parse Hermes config.yaml: ${parsed.detail}` } } + const next = updateConfigContent(parsed.content, disablePlugin) + if (next.content === null) { + return { + agent: 'hermes', + state: 'error', + configPath, + managedHooksPresent: getPluginFilesState().managed, + detail: `Could not update Hermes config.yaml: ${next.detail ?? 'unknown error'}` + } + } + if (next.content !== parsed.content) { + writeConfigFile(configPath, next.content) + } const pluginDir = getPluginDir() if (getPluginFilesState(pluginDir).managed) { rmSync(pluginDir, { recursive: true, force: true }) } - writeConfigFile(configPath, disablePlugin(parsed.config)) return this.getStatus() } } diff --git a/src/main/index.ts b/src/main/index.ts index 522e59b908f..bd02d96abcc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,6 +1,7 @@ -import { app, type BrowserWindow } from 'electron' +import { app, clipboard, dialog, type BrowserWindow } from 'electron' import { parseSkillShareId } from '../shared/skill-share-link' import { createMacAppActivationHandler } from './window/macos-app-activation' +import { isBackgroundLaunch } from './window/foreground-activation-policy' import { focusExistingWindow as focusExistingWindowAction, setMainWindowOpener @@ -13,6 +14,18 @@ import { initializeMainProcessReady } from './startup/main-process-ready' import { installMainProcessQuitHandlers } from './startup/main-process-quit' import { shouldActivateDesktopForSecondInstance } from './startup/single-instance-lock' import { resolveOpenedMarkdownDocuments } from './startup/os-opened-markdown-files' +import { + formatProfileStateStartupFailure, + isDivergedProfileStateFailure, + profileStateStartupFailureClass +} from './persistence/profile-state/profile-state-startup-failure' +import { recordDurableCrashBreadcrumb } from './crash-reporting/durable-crash-breadcrumb' +import { + chooseProfileStateCopy, + presentProfileStateStartupRecoveryDialog, + readProfileStateCopySavedTimes +} from './persistence/profile-state/profile-state-startup-recovery-dialog' +import { profileStateDesktopRecoveryArgs } from './startup/profile-state-recovery-preflight' function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow { return openMainWindowController(options) @@ -107,9 +120,55 @@ if (preflightReady) { registerMainProcessIpcHandlers() installMainProcessQuitHandlers() void app.whenReady().then(async () => { - await initializeMainProcessReady({ - openMainWindow, - handleMacAppActivation - }) + try { + await initializeMainProcessReady({ + openMainWindow, + handleMacAppActivation + }) + } catch (error) { + const message = + formatProfileStateStartupFailure(error) ?? + `Orca could not finish starting: ${error instanceof Error ? error.message : String(error)}` + const failureClass = profileStateStartupFailureClass(error) + if (failureClass !== undefined) { + recordDurableCrashBreadcrumb('profile_state_startup_failed', { + failure_class: failureClass + }) + } + console.error(`[profile-state] ${message}`) + if (!state.isServeMode && !isBackgroundLaunch()) { + try { + if (isDivergedProfileStateFailure(error)) { + const userDataPath = app.getPath('userData') + const choice = await chooseProfileStateCopy({ + ...readProfileStateCopySavedTimes(userDataPath), + showMessageBox: (options) => dialog.showMessageBox(options) + }) + if (choice !== undefined) { + // Recovery needs both profile locks, which only a fresh process can own safely. + app.relaunch({ + args: profileStateDesktopRecoveryArgs(process.argv, { + userDataPath, + selector: { kind: choice } + }) + }) + } + app.exit(1) + return + } + await presentProfileStateStartupRecoveryDialog({ + message, + ...(failureClass === 'recovery-required' || failureClass === 'ambiguous-authority' + ? { recoveryCommand: 'orca profile state exports' } + : {}), + showMessageBox: (options) => dialog.showMessageBox(options), + copyToClipboard: (text) => clipboard.writeText(text) + }) + } catch (dialogError) { + console.warn('[profile-state] Recovery dialog failed; exiting safely:', dialogError) + } + } + app.exit(1) + } }) } diff --git a/src/main/ipc/agent-trust.ts b/src/main/ipc/agent-trust.ts deleted file mode 100644 index 34f39570ccb..00000000000 --- a/src/main/ipc/agent-trust.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { ipcMain } from 'electron' -import { - type AgentTrustPreset, - markAntigravityWorkspaceTrusted, - markCodexProjectTrusted, - markCopilotFolderTrusted, - markCursorWorkspaceTrusted -} from '../agent-trust-presets' -import { markRemoteAgentWorkspaceTrusted } from '../remote-agent-trust-presets' - -/** - * Why: cursor-agent, GitHub Copilot CLI, and Codex gate first-launch in an - * unfamiliar directory behind a "Do you trust this folder?" menu that consumes - * keystrokes (numbered options / single-letter shortcuts). Orca's draft-URL - * paste flow needs the input box, not the menu, so before Orca spawns the - * agent it asks main to write the same trust artifacts the agents write - * after the user accepts. Best-effort: any IO error is swallowed so a failed - * trust write never blocks the workspace from opening. - */ -export function registerAgentTrustHandlers(): void { - ipcMain.removeHandler('agentTrust:markTrusted') - ipcMain.handle( - 'agentTrust:markTrusted', - async ( - _event, - args: { preset: AgentTrustPreset; workspacePath: string; connectionId?: string } - ): Promise => { - if (!args || typeof args.workspacePath !== 'string' || !args.workspacePath) { - return - } - try { - const connectionId = typeof args.connectionId === 'string' ? args.connectionId.trim() : '' - if (connectionId) { - // Why: SSH-launched agents read trust artifacts from the remote - // user's home, not from this desktop process. - await markRemoteAgentWorkspaceTrusted({ - preset: args.preset, - connectionId, - workspacePath: args.workspacePath - }) - } else if (args.preset === 'cursor') { - markCursorWorkspaceTrusted(args.workspacePath) - } else if (args.preset === 'copilot') { - markCopilotFolderTrusted(args.workspacePath) - } else if (args.preset === 'codex') { - markCodexProjectTrusted(args.workspacePath) - } else if (args.preset === 'antigravity') { - markAntigravityWorkspaceTrusted(args.workspacePath) - } - } catch { - // Best-effort: see Why above. The user can still accept the trust - // prompt manually if writing the artifact fails. - } - } - ) -} diff --git a/src/main/ipc/ai-vault-scan-coalescing.test.ts b/src/main/ipc/ai-vault-scan-coalescing.test.ts index 8a8ad0a3665..0efbea4d1ee 100644 --- a/src/main/ipc/ai-vault-scan-coalescing.test.ts +++ b/src/main/ipc/ai-vault-scan-coalescing.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from 'node:events' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AiVaultListResult } from '../../shared/ai-vault-types' import type { IFilesystemProvider } from '../providers/types' @@ -121,37 +122,44 @@ describe('Agent Session History scan coalescing', () => { await expect(second).resolves.toMatchObject({ sessions: [], issues: [] }) }) - it('keeps a shared multi-window scan alive when one window cancels', async () => { - let resolveRelay: ((result: AiVaultListResult) => void) | undefined - mocks.requestActiveSshAiVaultSessionList.mockImplementation( - () => - new Promise((resolve) => { - resolveRelay = resolve - }) - ) - registerAiVaultHandlers() - const list = ipcHandler('aiVault:listSessions') - const cancel = ipcHandler('aiVault:cancelListSessions') - const firstEvent = { sender: { id: 1 } } - const secondEvent = { sender: { id: 2 } } - const first = list(firstEvent, { - executionHostScope: 'ssh:dev-box', - requestToken: 'scan' - }) as Promise - const second = list(secondEvent, { - executionHostScope: 'ssh:dev-box', - requestToken: 'scan' - }) as Promise - await vi.waitFor(() => expect(resolveRelay).toBeDefined()) + it.each(['cancel', 'destroyed', 'render-process-gone', 'did-navigate'])( + 'keeps a shared multi-window scan alive when one window emits %s', + async (eventName) => { + let resolveRelay: ((result: AiVaultListResult) => void) | undefined + mocks.requestActiveSshAiVaultSessionList.mockImplementation( + () => + new Promise((resolve) => { + resolveRelay = resolve + }) + ) + registerAiVaultHandlers() + const list = ipcHandler('aiVault:listSessions') + const cancel = ipcHandler('aiVault:cancelListSessions') + const firstEvent = { sender: Object.assign(new EventEmitter(), { id: 1 }) } + const secondEvent = { sender: Object.assign(new EventEmitter(), { id: 2 }) } + const first = list(firstEvent, { + executionHostScope: 'ssh:dev-box', + requestToken: 'scan' + }) as Promise + const second = list(secondEvent, { + executionHostScope: 'ssh:dev-box', + requestToken: 'scan' + }) as Promise + await vi.waitFor(() => expect(resolveRelay).toBeDefined()) - cancel(firstEvent, { requestToken: 'scan' }) + if (eventName === 'cancel') { + cancel(firstEvent, { requestToken: 'scan' }) + } else { + firstEvent.sender.emit(eventName) + } - // Electron logs every rejected handler, so a cancelled scan resolves instead. - await expect(first).resolves.toMatchObject({ cancelled: true, sessions: [], issues: [] }) - expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledTimes(1) - resolveRelay?.(EMPTY_RESULT) - await expect(second).resolves.toEqual(EMPTY_RESULT) - }) + // Electron logs every rejected handler, so a cancelled scan resolves instead. + await expect(first).resolves.toMatchObject({ cancelled: true, sessions: [], issues: [] }) + expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledTimes(1) + resolveRelay?.(EMPTY_RESULT) + await expect(second).resolves.toEqual(EMPTY_RESULT) + } + ) it('reports a real scan failure as a host issue rather than cancellation', async () => { mocks.requestActiveSshAiVaultSessionList.mockRejectedValue(new Error('relay socket closed')) @@ -162,7 +170,7 @@ describe('Agent Session History scan coalescing', () => { // SSH host legs convert unexpected throws into scan issues so an `all` // multi-host list still returns the other hosts' sessions. const result = await list( - { sender: { id: 1 } }, + { sender: Object.assign(new EventEmitter(), { id: 1 }) }, { executionHostScope: 'ssh:dev-box', requestToken: 'scan' } ) expect(result).toMatchObject({ @@ -180,7 +188,7 @@ describe('Agent Session History scan coalescing', () => { // The local leg degrades like the SSH legs above: a rejection reaches the // renderer as a raw string painted over the list instead of an issue row. const result = await list( - { sender: { id: 1 } }, + { sender: Object.assign(new EventEmitter(), { id: 1 }) }, { executionHostScope: 'local', requestToken: 'scan' } ) expect(result).toMatchObject({ @@ -190,6 +198,68 @@ describe('Agent Session History scan coalescing', () => { expect(result).not.toHaveProperty('cancelled') }) + it('aborts local and SSH all-host legs only after the last renderer leaves', async () => { + const scanSignals: AbortSignal[] = [] + const waitForAbort = (signal: AbortSignal): Promise => { + scanSignals.push(signal) + return new Promise((resolve) => { + signal.addEventListener('abort', () => resolve(EMPTY_RESULT), { once: true }) + }) + } + mocks.scanAiVaultSessionsInWorker.mockImplementation((_args, signal: AbortSignal) => + waitForAbort(signal) + ) + mocks.requestActiveSshAiVaultSessionList.mockImplementation( + (_targetId, _params, options: { signal: AbortSignal }) => waitForAbort(options.signal) + ) + let resolveRuntime: ((result: AiVaultListResult) => void) | undefined + mocks.scanRuntimeAiVaultSessions.mockImplementation( + () => + new Promise((resolve) => { + resolveRuntime = resolve + }) + ) + registerRuntimeHost() + const list = ipcHandler('aiVault:listSessions') + const firstEvent = { sender: Object.assign(new EventEmitter(), { id: 1 }) } + const secondEvent = { sender: Object.assign(new EventEmitter(), { id: 2 }) } + const first = list(firstEvent, { executionHostScope: 'all', requestToken: 'scan' }) + const second = list(secondEvent, { executionHostScope: 'all', requestToken: 'scan' }) + await vi.waitFor(() => expect(scanSignals).toHaveLength(2)) + await vi.waitFor(() => expect(resolveRuntime).toBeDefined()) + + firstEvent.sender.emit('did-navigate') + await expect(first).resolves.toMatchObject({ cancelled: true }) + expect(scanSignals.every((signal) => !signal.aborted)).toBe(true) + secondEvent.sender.emit('render-process-gone') + await expect(second).resolves.toMatchObject({ cancelled: true }) + expect(scanSignals.every((signal) => signal.aborted)).toBe(true) + for (const event of [firstEvent, secondEvent]) { + expect(event.sender.eventNames()).toEqual([]) + } + // Runtime RPC cannot be canceled; its late result remains safely observed. + resolveRuntime?.(EMPTY_RESULT) + }) + + it('does not start an abandoned scan after ownership initialization completes', async () => { + let finishOwnership: (() => void) | undefined + registerAiVaultHandlers({ + ensureStructuredSessionOwnership: () => + new Promise((resolve) => { + finishOwnership = resolve + }) + }) + const event = { sender: Object.assign(new EventEmitter(), { id: 1 }) } + const pending = ipcHandler('aiVault:listSessions')(event, { requestToken: 'scan' }) + + event.sender.emit('did-navigate') + finishOwnership?.() + + await expect(pending).resolves.toMatchObject({ cancelled: true }) + expect(mocks.scanAiVaultSessionsInWorker).not.toHaveBeenCalled() + expect(event.sender.eventNames()).toEqual([]) + }) + it('re-joins a preempted same-scope caller onto the forced refresh', async () => { const signals: AbortSignal[] = [] let resolveForced: ((result: AiVaultListResult) => void) | undefined diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index 17f282e54be..b9f5661e19c 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from 'node:events' import { homedir } from 'node:os' import { join, sep } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -488,20 +489,17 @@ describe('listAiVaultSessions host routing', () => { }) ) registerAiVaultHandlers() - const event = { sender: { id: 7 } } + const event = { sender: Object.assign(new EventEmitter(), { id: 7 }) } const pending = getIpcHandler('aiVault:listSessions')(event, { executionHostScope: 'ssh:dev-box', requestToken: 'scan-1' }) await vi.waitFor(() => expect(relaySignal).toBeDefined()) - await getIpcHandler('aiVault:cancelListSessions')(event, { - requestToken: 'scan-1' - }) + await getIpcHandler('aiVault:cancelListSessions')(event, { requestToken: 'scan-1' }) expect(relaySignal?.aborted).toBe(true) - // Resolved, not rejected: Electron logs every rejected handler, and a - // superseded scan is normal control flow rather than a failure. + // Superseded scans resolve because Electron logs every rejected handler. await expect(pending).resolves.toMatchObject({ cancelled: true, sessions: [] }) }) }) diff --git a/src/main/ipc/cursor-accounts.ts b/src/main/ipc/cursor-accounts.ts new file mode 100644 index 00000000000..78273dfb1cb --- /dev/null +++ b/src/main/ipc/cursor-accounts.ts @@ -0,0 +1,6 @@ +import { ipcMain } from 'electron' +import { getCursorAccountStatus } from '../cursor-accounts/status' + +export function registerCursorAccountHandlers(): void { + ipcMain.handle('cursorAccounts:getStatus', () => getCursorAccountStatus()) +} diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.ts index 63ba9b1d0b1..0bcfd1bd686 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.ts @@ -1,5 +1,6 @@ import { AGENT_LAUNCH_RUNTIME_CAPABILITY, + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, @@ -25,6 +26,7 @@ import { export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapability[] = [ AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, diff --git a/src/main/ipc/emulator-frame-stream-socket-lifetime.test.ts b/src/main/ipc/emulator-frame-stream-socket-lifetime.test.ts new file mode 100644 index 00000000000..e09b897af93 --- /dev/null +++ b/src/main/ipc/emulator-frame-stream-socket-lifetime.test.ts @@ -0,0 +1,71 @@ +import { EventEmitter, once } from 'node:events' +import { createServer, type Server } from 'node:http' +import type { Socket } from 'node:net' +import { afterEach, expect, it, vi } from 'vitest' + +const handlers = new Map unknown>() +vi.mock('electron', () => ({ + ipcMain: { + handle: (channel: string, handler: (event: unknown, args: unknown) => unknown) => { + handlers.set(channel, handler) + } + }, + BrowserWindow: { fromWebContents: () => ({}) } +})) + +import { registerEmulatorFrameStreamHandlers } from './emulator-frame-stream' + +class Owner extends EventEmitter { + isDestroyed = (): boolean => false + send = (): void => { + this.emit('frame-received') + } +} + +let server: Server | null = null +let owner: Owner | null = null + +afterEach(async () => { + owner?.emit('destroyed') + owner = null + if (server) { + server.closeAllConnections() + await new Promise((resolve) => server?.close(() => resolve())) + server = null + } +}) + +it.each(['did-navigate', 'render-process-gone', 'destroyed'])( + 'closes the live MJPEG HTTP socket after %s', + async (goneEvent) => { + registerEmulatorFrameStreamHandlers() + const sockets = new Set() + const httpServer = createServer((_request, response) => { + response.writeHead(200, { 'content-type': 'application/octet-stream' }) + response.write(Buffer.from([0xff, 0xd8, 0x01, 0x02, 0xff, 0xd9])) + }) + server = httpServer + httpServer.on('connection', (socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + }) + await new Promise((resolve) => httpServer.listen(0, '127.0.0.1', resolve)) + const address = httpServer.address() + if (!address || typeof address === 'string') { + throw new Error('Expected TCP address') + } + const sender = new Owner() + owner = sender + const frameReceived = once(sender, 'frame-received') + handlers.get('emulator:frameStreamStart')?.( + { sender }, + { streamUrl: `http://127.0.0.1:${address.port}/stream.mjpeg` } + ) + await frameReceived + expect(sockets.size).toBe(1) + const closed = Promise.all(Array.from(sockets, (socket) => once(socket, 'close'))) + sender.emit(goneEvent) + await closed + expect(sockets.size).toBe(0) + } +) diff --git a/src/main/ipc/emulator-frame-stream.ts b/src/main/ipc/emulator-frame-stream.ts index 4cf8eee1a5b..9a806704154 100644 --- a/src/main/ipc/emulator-frame-stream.ts +++ b/src/main/ipc/emulator-frame-stream.ts @@ -1,11 +1,11 @@ -import { BrowserWindow, ipcMain, type WebContents } from 'electron' +import { BrowserWindow, ipcMain } from 'electron' import { randomUUID } from 'node:crypto' import { MjpegFrameStream } from '../emulator/mjpeg-frame-stream' +import { abortWhenRendererGone } from './renderer-lifetime-abort' type FrameStreamSession = { - owner: WebContents stream: MjpegFrameStream - onOwnerDestroyed: () => void + disposeLifetime: () => void } const sessions = new Map() @@ -15,11 +15,9 @@ function stopFrameStream(streamId: string): void { if (!session) { return } - session.stream.stop() - // Why: `.once('destroyed')` self-removes only when that event fires (window - // close), so an explicit stop must drop it or each show/hide cycle leaks one. - session.owner.removeListener('destroyed', session.onOwnerDestroyed) sessions.delete(streamId) + session.disposeLifetime() + session.stream.stop() } function frameToArrayBuffer(frame: Buffer): ArrayBuffer { @@ -45,12 +43,12 @@ export function registerEmulatorFrameStreamHandlers(): void { args.streamUrl, { onError: (message) => { - if (!owner.isDestroyed()) { + if (sessions.has(streamId) && !owner.isDestroyed()) { owner.send('emulator:frameStreamError', { streamId, message }) } }, onFrame: (frame) => { - if (!owner.isDestroyed()) { + if (sessions.has(streamId) && !owner.isDestroyed()) { owner.send('emulator:frameStreamFrame', { streamId, bytes: frameToArrayBuffer(frame) @@ -61,10 +59,22 @@ export function registerEmulatorFrameStreamHandlers(): void { args.streamKey ) - const onOwnerDestroyed = (): void => stopFrameStream(streamId) - sessions.set(streamId, { owner, stream, onOwnerDestroyed }) - owner.once('destroyed', onOwnerDestroyed) - stream.start() + const lifetime = abortWhenRendererGone(owner) + const onRendererGone = (): void => stopFrameStream(streamId) + sessions.set(streamId, { + stream, + disposeLifetime: () => { + lifetime.signal.removeEventListener('abort', onRendererGone) + lifetime.dispose() + } + }) + lifetime.signal.addEventListener('abort', onRendererGone, { once: true }) + try { + stream.start() + } catch (error) { + stopFrameStream(streamId) + throw error + } return { streamId } } ) diff --git a/src/main/ipc/emulator-renderer-lifetime.test.ts b/src/main/ipc/emulator-renderer-lifetime.test.ts new file mode 100644 index 00000000000..ecaa54b20ce --- /dev/null +++ b/src/main/ipc/emulator-renderer-lifetime.test.ts @@ -0,0 +1,233 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { MjpegFrameStreamCallbacks } from '../emulator/mjpeg-frame-stream' +import type { ScrcpyVideoSubscriber } from '../emulator/scrcpy-video-registry' + +const mocks = vi.hoisted(() => ({ + handlers: new Map unknown>(), + frameCallbacks: new Set(), + videoSubscribers: new Set(), + frameStarts: vi.fn(), + frameStops: vi.fn(), + videoStarts: vi.fn(), + videoStops: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: (channel: string, handler: (event: unknown, args: unknown) => unknown) => { + mocks.handlers.set(channel, handler) + } + }, + BrowserWindow: { fromWebContents: () => ({}) } +})) +vi.mock('../emulator/mjpeg-frame-stream', () => ({ + MjpegFrameStream: class { + constructor( + _url: string, + private callbacks: MjpegFrameStreamCallbacks + ) {} + start(): void { + mocks.frameStarts() + mocks.frameCallbacks.add(this.callbacks) + } + stop(): void { + mocks.frameStops() + mocks.frameCallbacks.delete(this.callbacks) + } + } +})) +vi.mock('../emulator/scrcpy-video-registry', () => ({ + scrcpyVideoRegistry: { + subscribe: (_deviceId: string, subscriber: ScrcpyVideoSubscriber) => { + mocks.videoStarts() + mocks.videoSubscribers.add(subscriber) + return () => { + mocks.videoStops() + mocks.videoSubscribers.delete(subscriber) + } + } + } +})) +vi.mock('../emulator/emulator-probe', () => ({ emulatorProbe: () => {} })) + +import { registerEmulatorFrameStreamHandlers } from './emulator-frame-stream' +import { registerEmulatorVideoStreamHandlers } from './emulator-video-stream' + +class Owner extends EventEmitter { + send = vi.fn() + isDestroyed = (): boolean => false +} + +const owners: Owner[] = [] +const goneEvents = ['did-navigate', 'render-process-gone', 'destroyed'] as const + +function owner(): Owner { + const sender = new Owner() + owners.push(sender) + return sender +} + +function start(sender: Owner, kind: 'frame' | 'video'): string { + const result = mocks.handlers.get(`emulator:${kind}StreamStart`)?.( + { sender }, + kind === 'frame' + ? { streamUrl: 'http://127.0.0.1:0/stream.mjpeg' } + : { deviceId: 'emulator-5554' } + ) + if (!result || typeof result !== 'object' || !('streamId' in result)) { + throw new Error('Missing stream result') + } + if (typeof result.streamId !== 'string') { + throw new Error('Missing stream id') + } + return result.streamId +} + +function sendFrames(): void { + for (const callbacks of mocks.frameCallbacks) { + callbacks.onFrame(Buffer.from([0xff, 0xd8, 0xff, 0xd9])) + } + for (const subscriber of mocks.videoSubscribers) { + subscriber({ + type: 'frame', + frame: { config: false, keyFrame: true, pts: '0', bytes: new ArrayBuffer(4) } + }) + } +} + +beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + registerEmulatorFrameStreamHandlers() + registerEmulatorVideoStreamHandlers() +}) + +afterEach(() => { + for (const sender of owners.splice(0)) { + sender.emit('destroyed') + } + vi.clearAllTimers() + vi.useRealTimers() + mocks.frameCallbacks.clear() + mocks.videoSubscribers.clear() +}) + +it.each(goneEvents)('stops both live streams on %s and leaves no per-frame delivery', (event) => { + const sender = owner() + start(sender, 'frame') + start(sender, 'video') + vi.runOnlyPendingTimers() + sendFrames() + expect(sender.send).toHaveBeenCalledTimes(2) + + sender.emit(event) + expect(mocks.frameCallbacks.size).toBe(0) + expect(mocks.videoSubscribers.size).toBe(0) + expect(mocks.frameStops).toHaveBeenCalledTimes(1) + expect(mocks.videoStops).toHaveBeenCalledTimes(1) + sendFrames() + expect(sender.send).toHaveBeenCalledTimes(2) + for (const gone of goneEvents) { + expect(sender.listenerCount(gone)).toBe(0) + } +}) + +it.each(goneEvents)('does not start deferred video work after %s', (event) => { + const sender = owner() + start(sender, 'video') + sender.emit(event) + vi.runOnlyPendingTimers() + expect(mocks.videoStarts).not.toHaveBeenCalled() + expect(mocks.videoSubscribers.size).toBe(0) +}) + +it('keeps only the current document streams after repeated reloads', () => { + const sender = owner() + for (let cycle = 0; cycle < 15; cycle++) { + start(sender, 'frame') + start(sender, 'video') + vi.runOnlyPendingTimers() + sender.emit('did-navigate') + } + start(sender, 'frame') + start(sender, 'video') + vi.runOnlyPendingTimers() + expect(mocks.frameCallbacks.size).toBe(1) + expect(mocks.videoSubscribers.size).toBe(1) + sendFrames() + expect(sender.send).toHaveBeenCalledTimes(2) +}) + +it('keeps streams through same-document or prevented navigation and releases on explicit stop', () => { + const sender = owner() + const frameId = start(sender, 'frame') + const videoId = start(sender, 'video') + vi.runOnlyPendingTimers() + sender.emit('did-start-navigation') + sender.emit('did-navigate-in-page') + sendFrames() + expect(sender.send).toHaveBeenCalledTimes(2) + mocks.handlers.get('emulator:frameStreamStop')?.({ sender }, { streamId: frameId }) + mocks.handlers.get('emulator:videoStreamStop')?.({ sender }, { streamId: videoId }) + expect(mocks.frameCallbacks.size).toBe(0) + expect(mocks.videoSubscribers.size).toBe(0) + for (const event of goneEvents) { + expect(sender.listenerCount(event)).toBe(0) + } +}) + +it('releases a failed frame-stream start without retaining renderer listeners', () => { + const sender = owner() + mocks.frameStarts.mockImplementationOnce(() => { + throw new Error('Stream unavailable') + }) + expect(() => start(sender, 'frame')).toThrow('Stream unavailable') + expect(mocks.frameStops).toHaveBeenCalledTimes(1) + for (const event of goneEvents) { + expect(sender.listenerCount(event)).toBe(0) + } +}) + +it('keeps another renderer streams live when the first document reloads', () => { + const first = owner() + const second = owner() + for (const sender of [first, second]) { + start(sender, 'frame') + start(sender, 'video') + } + vi.runOnlyPendingTimers() + first.emit('did-navigate') + sendFrames() + expect(first.send).not.toHaveBeenCalled() + expect(second.send).toHaveBeenCalledTimes(2) + expect(mocks.frameCallbacks.size).toBe(1) + expect(mocks.videoSubscribers.size).toBe(1) +}) + +it.each([...goneEvents, 'explicit stop'] as const)( + 'suppresses retired frame callbacks after %s while keeping current stream errors', + (event) => { + const sender = owner() + const streamId = start(sender, 'frame') + const callbacks = [...mocks.frameCallbacks][0] + callbacks.onError('active failure') + expect(sender.send).toHaveBeenCalledWith('emulator:frameStreamError', { + streamId, + message: 'active failure' + }) + sender.send.mockClear() + mocks.frameStops.mockImplementationOnce(() => callbacks.onError('stop failure')) + if (event === 'explicit stop') { + mocks.handlers.get('emulator:frameStreamStop')?.({ sender }, { streamId }) + } else { + sender.emit(event) + } + start(sender, 'frame') + callbacks.onError('late failure') + callbacks.onFrame(Buffer.from([0xff, 0xd8, 0xff, 0xd9])) + expect(sender.send).not.toHaveBeenCalled() + sendFrames() + expect(sender.send).toHaveBeenCalledOnce() + } +) diff --git a/src/main/ipc/emulator-video-stream.ts b/src/main/ipc/emulator-video-stream.ts index 8bfe8367103..f6aeb8f2f75 100644 --- a/src/main/ipc/emulator-video-stream.ts +++ b/src/main/ipc/emulator-video-stream.ts @@ -2,6 +2,7 @@ import { BrowserWindow, ipcMain, type WebContents } from 'electron' import { randomUUID } from 'node:crypto' import { scrcpyVideoRegistry } from '../emulator/scrcpy-video-registry' import { emulatorProbe } from '../emulator/emulator-probe' +import { abortWhenRendererGone } from './renderer-lifetime-abort' // Bridges the main-process scrcpy video registry to renderer subscribers. The // renderer calls emulator:videoStreamStart with a deviceId; meta + H.264 access @@ -11,7 +12,8 @@ export function registerEmulatorVideoStreamHandlers(): void { type Subscription = { owner: WebContents unsubscribe: () => void - onOwnerDestroyed: () => void + disposeLifetime: () => void + startTimer: ReturnType | null } const subscriptions = new Map() @@ -20,11 +22,12 @@ export function registerEmulatorVideoStreamHandlers(): void { if (!subscription || (owner && subscription.owner !== owner)) { return } - subscription.unsubscribe() - // Why: `.once('destroyed')` self-removes only when that event fires (window - // close), so an explicit stop must drop it or each show/hide cycle leaks one. - subscription.owner.removeListener('destroyed', subscription.onOwnerDestroyed) subscriptions.delete(streamId) + if (subscription.startTimer !== null) { + clearTimeout(subscription.startTimer) + } + subscription.disposeLifetime() + subscription.unsubscribe() } ipcMain.handle( @@ -44,14 +47,21 @@ export function registerEmulatorVideoStreamHandlers(): void { throw new Error('Video stream id is already in use by another renderer') } stopSubscription(streamId, owner) - const onOwnerDestroyed = (): void => stopSubscription(streamId, owner) + const lifetime = abortWhenRendererGone(owner) + const onRendererGone = (): void => stopSubscription(streamId, owner) const pendingSubscription: Subscription = { owner, unsubscribe: () => {}, - onOwnerDestroyed + disposeLifetime: () => { + lifetime.signal.removeEventListener('abort', onRendererGone) + lifetime.dispose() + }, + startTimer: null } subscriptions.set(streamId, pendingSubscription) - setTimeout(() => { + lifetime.signal.addEventListener('abort', onRendererGone, { once: true }) + pendingSubscription.startTimer = setTimeout(() => { + pendingSubscription.startTimer = null if (owner.isDestroyed() || subscriptions.get(streamId) !== pendingSubscription) { return } @@ -75,7 +85,6 @@ export function registerEmulatorVideoStreamHandlers(): void { }) pendingSubscription.unsubscribe = unsubscribe }, 0) - owner.once('destroyed', onOwnerDestroyed) return { streamId } } ) diff --git a/src/main/ipc/filesystem-allowed-roots.ts b/src/main/ipc/filesystem-allowed-roots.ts index fb4e9854c2e..115e038218d 100644 --- a/src/main/ipc/filesystem-allowed-roots.ts +++ b/src/main/ipc/filesystem-allowed-roots.ts @@ -1,4 +1,4 @@ -import { resolve } from 'node:path' +import { isAbsolute, resolve } from 'node:path' import type { Store } from '../persistence' import { computeWorkspaceRoot, getWorktreePathSettings } from './worktree-logic' import { @@ -14,13 +14,13 @@ import { import type { FolderWorkspace } from '../../shared/folder-workspace-types' import type { ProjectGroup } from '../../shared/project-group-types' import type { Repo } from '../../shared/repo-types' +import { hasRemoteFilesystemOwner } from './remote-filesystem-owner' type FolderScopeStore = Pick & Partial> -// Why: SSH repo paths are remote-host paths; treating them as local roots could authorize unrelated local folders or probe SSH-only paths. function filterLocalRepos(repos: readonly Repo[]): Repo[] { - return repos.filter((repo) => !repo.connectionId) + return repos.filter((repo) => !hasRemoteFilesystemOwner(repo)) } export function getLocalRepos(store: Store) { @@ -30,11 +30,11 @@ export function getLocalRepos(store: Store) { function isRemoteOnlyFolderScope( folderPath: string, projectGroupId: string, - connectionId: string | null | undefined, + hasRemoteOwner: boolean, childGroupIndex: ProjectGroupChildIndex, repos: readonly Repo[] ): boolean { - if (connectionId) { + if (hasRemoteOwner) { return true } const groupIds = collectProjectGroupSubtreeIds(childGroupIndex, projectGroupId) @@ -45,7 +45,7 @@ function isRemoteOnlyFolderScope( isPathInsideOrEqual(folderPath, repo.path) ) { // One local candidate settles the scope without scanning the remaining repositories. - if (!repo.connectionId) { + if (!hasRemoteFilesystemOwner(repo)) { return false } hasRemoteCandidate = true @@ -54,15 +54,21 @@ function isRemoteOnlyFolderScope( return hasRemoteCandidate } -function getFolderWorkspaceConnectionId( +/** + * Deliberately stricter than `resolveFolderWorkspaceHost`, which lets the workspace's own + * `executionHostId` pin win: a workspace pinned `local` under a group carrying only a legacy + * `connectionId` dispatches locally but is denied here. Letting the pin win would hand out a root + * the store refuses today, so authorization keeps the fail-closed read of either field. + */ +function hasRemoteFolderWorkspaceOwner( workspace: FolderWorkspace, projectGroups: readonly ProjectGroup[] -): string | null { - return ( - workspace.connectionId ?? - projectGroups.find((group) => group.id === workspace.projectGroupId)?.connectionId ?? - null - ) +): boolean { + const group = projectGroups.find((group) => group.id === workspace.projectGroupId) + return hasRemoteFilesystemOwner({ + connectionId: workspace.connectionId ?? group?.connectionId, + executionHostId: workspace.executionHostId ?? group?.executionHostId + }) } function getLocalFolderScopeRoots(store: Store, repos: readonly Repo[]): string[] { @@ -77,7 +83,7 @@ function getLocalFolderScopeRoots(store: Store, repos: readonly Repo[]): string[ !isRemoteOnlyFolderScope( group.parentPath, group.id, - group.connectionId, + hasRemoteFilesystemOwner(group), childGroupIndex, repos ) @@ -90,7 +96,7 @@ function getLocalFolderScopeRoots(store: Store, repos: readonly Repo[]): string[ !isRemoteOnlyFolderScope( workspace.folderPath, workspace.projectGroupId, - getFolderWorkspaceConnectionId(workspace, projectGroups), + hasRemoteFolderWorkspaceOwner(workspace, projectGroups), childGroupIndex, repos ) @@ -101,6 +107,28 @@ function getLocalFolderScopeRoots(store: Store, repos: readonly Repo[]): string[ return roots } +/** The single path implementation that both judges and resolves a local root. */ +type HostPathResolver = { + isAbsolute: (value: string) => boolean + resolve: (value: string) => string +} + +/** + * The allowed root a `workspaceDir` with no local repo to anchor it may contribute, or `null`. + * + * The predicate has to be the host's own, not `isRuntimePathAbsolute`: that helper accepts either + * flavour, so on POSIX it calls `C:\ws` absolute while this `resolve` reads the same string as a + * relative name and anchors the root under the main-process cwd — authorizing an unrelated local + * tree. Pairing both here keeps them from diverging again; `hostPath` is injectable so the + * flavour matrix can run POSIX and Windows without the test choosing on `process.platform`. + */ +export function resolveUnanchoredWorkspaceRoot( + workspaceDir: string, + hostPath: HostPathResolver = { isAbsolute, resolve } +): string | null { + return hostPath.isAbsolute(workspaceDir) ? hostPath.resolve(workspaceDir) : null +} + export function getAllowedRoots(store: Store): string[] { // Why one read: `getRepos` rehydrates every repo, and this runs twice per filesystem IPC. const repos = store.getRepos() @@ -112,7 +140,10 @@ export function getAllowedRoots(store: Store): string[] { ] if (settings.workspaceDir) { if (localRepos.length === 0) { - roots.push(resolve(settings.workspaceDir)) + const unanchoredRoot = resolveUnanchoredWorkspaceRoot(settings.workspaceDir) + if (unanchoredRoot) { + roots.push(unanchoredRoot) + } } else { const projectRuntimeByRepoId = resolveLocalProjectRuntimesForRepos(store, localRepos) for (const repo of localRepos) { diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts index 894e39945c1..6110ea7baf2 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -183,7 +183,7 @@ async function isPathAllowedIncludingRegisteredWorktrees( return true } - if (isRegisteredWorktreePath(targetPath)) { + if (isRegisteredWorktreePath(targetPath, store)) { return true } @@ -198,7 +198,9 @@ async function isPathAllowedIncludingRegisteredWorktrees( return true } - if (await isPathAllowedByCanonicalRegisteredRoot(targetPath, options.canonicalSourcePath)) { + if ( + await isPathAllowedByCanonicalRegisteredRoot(targetPath, options.canonicalSourcePath, store) + ) { return true } @@ -206,8 +208,8 @@ async function isPathAllowedIncludingRegisteredWorktrees( // Why: linked worktrees are already git-trusted; reuse the cached root index so reads don't spawn `git worktree list` each time. return ( - isRegisteredWorktreePath(targetPath) || - (await isPathAllowedByCanonicalRegisteredRoot(targetPath, options.canonicalSourcePath)) + isRegisteredWorktreePath(targetPath, store) || + (await isPathAllowedByCanonicalRegisteredRoot(targetPath, options.canonicalSourcePath, store)) ) } diff --git a/src/main/ipc/filesystem-canonical-owner.test.ts b/src/main/ipc/filesystem-canonical-owner.test.ts new file mode 100644 index 00000000000..ec4888a3f52 --- /dev/null +++ b/src/main/ipc/filesystem-canonical-owner.test.ts @@ -0,0 +1,369 @@ +import { join, posix, resolve, win32 } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' +import type { Repo } from '../../shared/repo-types' +import type { ProjectGroup } from '../../shared/project-group-types' +import type { FolderWorkspace } from '../../shared/folder-workspace-types' +import { resolveFolderWorkspaceHost } from '../../shared/folder-workspace-execution-host' +import { + getAllowedRoots, + getLocalRepos, + resolveUnanchoredWorkspaceRoot +} from './filesystem-allowed-roots' +import { isPathAllowed } from './filesystem-auth' +import { + __resetCreatedWorktreeRootsForTests, + invalidateAuthorizedRootsCache, + isRegisteredWorktreePath, + rebuildAuthorizedRootsCache, + registerCreatedWorktreeRoot, + registerWorktreeRootsForRepo +} from './registered-worktree-roots-cache' + +const mocks = vi.hoisted(() => ({ + graph: vi.fn(), + stat: vi.fn(), + realpath: vi.fn(), + workspaceRoot: vi.fn(), + pathSettings: vi.fn(), + projectRuntimes: vi.fn() +})) +vi.mock('node:fs/promises', () => ({ stat: mocks.stat, realpath: mocks.realpath })) +vi.mock('../repo-worktrees', () => ({ listRepoWorktreeGraph: mocks.graph, isRepoRoot: vi.fn() })) +vi.mock('./worktree-logic', () => ({ + computeWorkspaceRoot: mocks.workspaceRoot, + getWorktreePathSettings: mocks.pathSettings +})) +vi.mock('../project-runtime-git-options', () => ({ + getWorktreeMirrorDistroForRuntime: vi.fn(), + resolveLocalProjectRuntimesForRepos: mocks.projectRuntimes +})) + +/** + * Widened past `Repo['executionHostId']` on purpose: the union names the stamps Orca writes, while a + * store carries whatever an older build, a hand-edited catalog or a partial migration left behind. + * Those are exactly the stamps authorization has to place, so the matrix must be able to build them. + */ +type Owner = { connectionId?: string | null; executionHostId?: string | null } +const root = resolve('/owner-fixture') +const linked = resolve('/linked-fixture') +function repo(owner: Owner = {}, overrides: Partial = {}): Repo { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only `executionHostId` leaves the union, and only to the malformed stamps above; every other field is a checked `Repo` field. + return { + id: 'repo', + path: root, + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + ...owner, + ...overrides + } as Repo +} +function group(owner: Owner = {}, overrides: Partial = {}): ProjectGroup { + return { + id: 'group', + name: 'group', + parentPath: root, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 0, + updatedAt: 0, + ...owner, + ...overrides + } +} +function folder(owner: Owner = {}): FolderWorkspace { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: same widened stamp as `repo`; all other fields are checked `FolderWorkspace` fields. + return { + id: 'folder', + projectGroupId: 'group', + name: 'folder', + folderPath: root, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0, + ...owner + } as FolderWorkspace +} +function storeFor( + repos: Repo[] = [], + groups: ProjectGroup[] = [], + folders: FolderWorkspace[] = [], + settings: { workspaceDir?: string } = {} +): Store { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Authorization reads only these catalog and settings methods; fixtures omit workspaceDir unless the case is about it. + return { + getRepos: () => repos, + getProjectGroups: () => groups, + getFolderWorkspaces: () => folders, + getSettings: () => settings + } as Store +} + +const deniedOwners: { name: string; owner: Owner }[] = [ + { name: 'canonical SSH', owner: { executionHostId: 'ssh:host-a' } }, + { name: 'encoded canonical SSH', owner: { executionHostId: 'ssh:host%20a' } }, + { name: 'legacy SSH', owner: { connectionId: 'host-a' } }, + { + name: 'explicit local with legacy SSH', + owner: { executionHostId: 'local', connectionId: 'host-a' } + }, + { + name: 'runtime with legacy SSH', + owner: { executionHostId: 'runtime:env', connectionId: 'host-a' } + }, + // Host ids the shared parser rejects. Each must fail closed: an owner we cannot place is not + // evidence of a local one, and a bare `getSshTargetIdForExecutionHost` answers null for all five. + { name: 'empty SSH target', owner: { executionHostId: 'ssh:' } }, + { name: 'undecodable SSH target', owner: { executionHostId: 'ssh:%zz' } }, + { name: 'alias-delimiter SSH target', owner: { executionHostId: 'ssh:host-a|alias' } }, + { name: 'wrong-case SSH prefix', owner: { executionHostId: 'SSH:host-a' } }, + { name: 'unknown host kind', owner: { executionHostId: 'relay:host-a' } } +] +const allowedOwners: { name: string; owner: Owner }[] = [ + { name: 'unscoped local', owner: {} }, + { name: 'explicit local', owner: { executionHostId: 'local' } }, + { name: 'blank host stamp', owner: { executionHostId: ' ' } }, + { name: 'own-store runtime', owner: { executionHostId: 'runtime:env' } } +] + +beforeEach(() => { + invalidateAuthorizedRootsCache() + __resetCreatedWorktreeRootsForTests() + vi.clearAllMocks() + mocks.graph.mockResolvedValue([]) + mocks.stat.mockResolvedValue({}) + mocks.realpath.mockImplementation(async (path: string) => path) + mocks.projectRuntimes.mockReturnValue(new Map()) + mocks.pathSettings.mockImplementation( + (_repo: Repo, settings: { workspaceDir: string }) => settings + ) + mocks.workspaceRoot.mockImplementation( + (_repoPath: string, settings: { workspaceDir: string }) => settings.workspaceDir + ) +}) + +describe.each(deniedOwners)('$name fixture matrix', ({ owner }) => { + it.each([ + ['repo root', () => storeFor([repo(owner)])], + ['group scope', () => storeFor([], [group(owner)])], + ['folder scope', () => storeFor([], [], [folder(owner)])], + ['folder inheriting the group', () => storeFor([], [group(owner)], [folder()])], + [ + 'local-looking group and folder over a remote child repo', + () => storeFor([repo(owner, { path: join(root, 'child') })], [group()], [folder()]) + ] + ])('denies the %s', (_case, build) => { + expect(isPathAllowed(join(root, 'file'), build())).toBe(false) + }) +}) + +describe.each(deniedOwners)('$name filesystem ownership', ({ owner }) => { + it('does not grant a repository root or register linked roots', async () => { + const store = storeFor([repo(owner)]) + expect(getLocalRepos(store)).toEqual([]) + expect(isPathAllowed(join(root, 'file'), store)).toBe(false) + registerWorktreeRootsForRepo(store, 'repo', [linked]) + registerCreatedWorktreeRoot(store, 'repo', linked) + expect(isRegisteredWorktreePath(linked, store)).toBe(false) + await rebuildAuthorizedRootsCache(store) + expect(isRegisteredWorktreePath(root, store)).toBe(false) + expect(mocks.graph).not.toHaveBeenCalled() + expect(mocks.stat).not.toHaveBeenCalled() + }) + + it.each(['group', 'folder', 'inherited group'] as const)( + 'does not grant an empty %s scope', + (kind) => { + const store = storeFor( + [], + kind !== 'folder' ? [group(owner)] : [], + kind === 'folder' ? [folder(owner)] : kind === 'inherited group' ? [folder()] : [] + ) + expect(getAllowedRoots(store)).toEqual([]) + expect(isPathAllowed(join(root, 'file'), store)).toBe(false) + } + ) + + it('does not infer a local group or folder from a remote child repo', () => { + const store = storeFor( + [repo(owner, { path: join(root, 'child'), projectGroupId: 'nested' })], + [group(), group({}, { id: 'nested', parentGroupId: 'group', parentPath: null })], + [folder()] + ) + expect(getAllowedRoots(store)).toEqual([]) + expect(isPathAllowed(join(root, 'file'), store)).toBe(false) + }) +}) + +describe.each(allowedOwners)('$name filesystem ownership', ({ owner }) => { + it('preserves repo, group, folder and recovered worktree roots', () => { + for (const store of [ + storeFor([repo(owner)]), + storeFor([], [group(owner)]), + storeFor([], [], [folder(owner)]) + ]) { + expect(getAllowedRoots(store)).toEqual([root]) + expect(isPathAllowed(join(root, 'file'), store)).toBe(true) + } + const store = storeFor([repo(owner)]) + registerWorktreeRootsForRepo(store, 'repo', [root]) + registerCreatedWorktreeRoot(store, 'repo', linked) + invalidateAuthorizedRootsCache() + expect(isRegisteredWorktreePath(linked, store)).toBe(true) + expect(mocks.graph).not.toHaveBeenCalled() + expect(mocks.stat).not.toHaveBeenCalled() + }) +}) + +it('preserves an unpinned mixed local and SSH folder scope', () => { + const store = storeFor( + [ + repo({ executionHostId: 'ssh:host-a' }, { path: join(root, 'remote') }), + repo({}, { id: 'local', path: join(root, 'local') }) + ], + [group()], + [folder()] + ) + expect(getAllowedRoots(store)).toEqual([join(root, 'local'), root, root]) +}) + +it('keeps an explicit SSH folder scope remote even with a local candidate', () => { + const store = storeFor( + [repo({}, { path: join(root, 'local') })], + [group({ executionHostId: 'ssh:host-a' })], + [folder()] + ) + expect(getAllowedRoots(store)).toEqual([join(root, 'local')]) + expect(isPathAllowed(join(root, 'file'), store)).toBe(false) +}) + +it('preserves explicit local folder overrides and the legacy empty connection override', () => { + expect( + getAllowedRoots( + storeFor( + [], + [group({ executionHostId: 'ssh:host-a' })], + [folder({ executionHostId: 'local' })] + ) + ) + ).toEqual([root]) + expect( + getAllowedRoots( + storeFor([], [group({ connectionId: 'host-a' })], [folder({ connectionId: '' })]) + ) + ).toEqual([root]) +}) + +it('denies a workspace pinned local under a group carrying only a legacy connection', () => { + const pinnedFolder = folder({ executionHostId: 'local' }) + const legacyGroup = group({ connectionId: 'host-a' }) + const store = storeFor([], [legacyGroup], [pinnedFolder]) + expect(getAllowedRoots(store)).toEqual([]) + expect(isPathAllowed(join(root, 'file'), store)).toBe(false) + // Dispatch reads the same row as local; authorization is deliberately the stricter of the two, + // because agreeing would grant a root this store refuses today. + expect( + resolveFolderWorkspaceHost( + { folderWorkspaces: [pinnedFolder], projectGroups: [legacyGroup], repos: [] }, + 'folder' + ) + ).toEqual({ kind: 'local' }) + // The mirrored row needs no such note: the workspace's own legacy connection is remote on both sides. + const mirrored = storeFor( + [], + [group({ executionHostId: 'local' }, { parentPath: null })], + [folder({ connectionId: 'host-a' })] + ) + expect(getAllowedRoots(mirrored)).toEqual([]) + expect(isPathAllowed(join(root, 'file'), mirrored)).toBe(false) +}) + +it('still grants a local directory that happens to share an SSH repo path', () => { + const store = storeFor([ + repo({ executionHostId: 'ssh:host-a' }), + repo({}, { id: 'mine', path: root }) + ]) + expect(getAllowedRoots(store)).toEqual([root]) + expect(isPathAllowed(join(root, 'file'), store)).toBe(true) +}) + +describe('workspace-directory fallback', () => { + const workspaceDir = resolve('/ws-fixture') + + it('does not widen past the roots a local repo of its own would grant', () => { + const local = getAllowedRoots(storeFor([repo()], [], [], { workspaceDir })) + const sshOnly = getAllowedRoots( + storeFor([repo({ executionHostId: 'ssh:host-a' })], [], [], { workspaceDir }) + ) + expect(local).toEqual([root, workspaceDir]) + // The SSH repo's own path is gone; nothing beyond the fallback the same config already granted. + expect(sshOnly).toEqual([workspaceDir]) + expect( + isPathAllowed( + join(root, 'file'), + storeFor([repo({ connectionId: 'host-a' })], [], [], { workspaceDir }) + ) + ).toBe(false) + }) + + it('grants a workspace directory this host reads as absolute, with no local repo', () => { + expect(getAllowedRoots(storeFor([], [], [], { workspaceDir: '/ws-fixture' }))).toEqual([ + resolve('/ws-fixture') + ]) + }) + + it.each([ + ['bare name', 'orca-ws'], + ['parent traversal', '..'], + ['relative traversal', '../orca-ws'] + ])('grants nothing for a repo-relative %s with no repo to anchor it', (_case, dir) => { + // `resolve` would anchor these to the main-process cwd, granting an unrelated tree. + expect(getAllowedRoots(storeFor([], [], [], { workspaceDir: dir }))).toEqual([]) + expect( + getAllowedRoots( + storeFor([repo({ executionHostId: 'ssh:host-a' })], [], [], { + workspaceDir: dir + }) + ) + ).toEqual([]) + }) + + // Why guarded rather than injected: this is the end-to-end claim that the foreign-flavour string + // never reaches `resolve`, so it has to run against the real host path module — and on Windows the + // same string is a legitimate root. The flavour matrix below covers both hosts unguarded. + it.skipIf(process.platform === 'win32')( + 'does not anchor a Windows-style workspace directory under the POSIX main-process cwd', + () => { + const store = storeFor([], [], [], { workspaceDir: 'C:\\workspaces' }) + expect(getAllowedRoots(store)).toEqual([]) + // The cross-platform predicate used to grant exactly this: `/C:\workspaces`. + expect(isPathAllowed(join(resolve('C:\\workspaces'), 'file'), store)).toBe(false) + } + ) + + it.each([ + ['POSIX absolute on POSIX', posix, '/orca-ws', true], + ['relative on POSIX', posix, '../orca-ws', false], + ['Windows drive on POSIX', posix, 'C:\\orca-ws', false], + ['Windows UNC on POSIX', posix, '\\\\wsl$\\Ubuntu\\home\\me\\ws', false], + ['POSIX absolute on Windows', win32, '/orca-ws', true], + ['relative on Windows', win32, '..\\orca-ws', false], + ['Windows drive on Windows', win32, 'C:\\orca-ws', true], + ['Windows UNC on Windows', win32, '\\\\wsl$\\Ubuntu\\home\\me\\ws', true] + ])( + 'grants an unanchored %s workspace directory only when that host reads it as absolute', + (_case, hostPath, dir, granted) => { + expect(resolveUnanchoredWorkspaceRoot(dir, hostPath) !== null).toBe(granted) + } + ) +}) diff --git a/src/main/ipc/filesystem-download-lifetime.test.ts b/src/main/ipc/filesystem-download-lifetime.test.ts new file mode 100644 index 00000000000..037c3329be3 --- /dev/null +++ b/src/main/ipc/filesystem-download-lifetime.test.ts @@ -0,0 +1,342 @@ +import { EventEmitter } from 'node:events' +import { resolve } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' + +const mocks = vi.hoisted(() => ({ + handlers: new Map Promise>(), + showSaveDialog: vi.fn(), + stat: vi.fn(), + open: vi.fn(), + rename: vi.fn(), + rm: vi.fn() +})) +vi.mock('electron', () => ({ + BrowserWindow: { fromWebContents: () => null }, + dialog: { showSaveDialog: mocks.showSaveDialog }, + ipcMain: { + handle: (name: string, handler: (...args: unknown[]) => Promise) => + mocks.handlers.set(name, handler) + } +})) +vi.mock('node:fs/promises', () => ({ ...mocks, writeFile: vi.fn() })) +vi.mock('./filesystem-download-folder', () => ({ + registerFilesystemDownloadFolderHandlers: vi.fn() +})) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ requireSshFilesystemProvider: vi.fn() })) + +import { createFilesystemHandlerContext } from './filesystem/filesystem-handler-context' +import { registerFilesystemDownloadHandlers } from './filesystem/filesystem-download-handlers' +import { DOWNLOAD_SESSION_TTL_MS } from './filesystem/filesystem-download-promotion' + +const destination = resolve('fake-downloads', 'file.txt') +const lifetimeEvents = ['destroyed', 'render-process-gone', 'did-navigate'] as const +const senders: EventEmitter[] = [] +let context: ReturnType + +function makeSender(id = 1) { + const sender = Object.assign(new EventEmitter(), { id, isDestroyed: vi.fn(() => false) }) + sender.setMaxListeners(0) + senders.push(sender) + return sender +} + +function makeHandle() { + return { close: vi.fn(async () => {}), writeFile: vi.fn(async () => {}) } +} + +function invoke(name: string, sender: ReturnType, args: unknown) { + const handler = mocks.handlers.get(`fs:${name}`) + if (!handler) { + throw new Error(`Missing handler: ${name}`) + } + return handler({ sender }, args) +} + +async function start(sender: ReturnType) { + const result = await invoke('startDownloadedFile', sender, { suggestedName: 'file.txt' }) + if ( + !result || + typeof result !== 'object' || + !('transferId' in result) || + typeof result.transferId !== 'string' + ) { + throw new Error('Download did not start') + } + return result.transferId +} + +function expectNoListeners(sender: ReturnType) { + for (const event of lifetimeEvents) { + expect(sender.listenerCount(event)).toBe(0) + } +} + +beforeEach(() => { + vi.resetAllMocks() + vi.useFakeTimers() + mocks.handlers.clear() + mocks.showSaveDialog.mockResolvedValue({ canceled: false, filePath: destination }) + mocks.stat.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + mocks.open.mockImplementation(async () => makeHandle()) + mocks.rename.mockResolvedValue(undefined) + mocks.rm.mockResolvedValue(undefined) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Download handlers never access the store; unrelated handlers are not registered. + const store = {} as Store + const cancellations = { begin: () => null, finish: () => {}, cancel: () => {} } + context = createFilesystemHandlerContext(store, undefined, cancellations, cancellations) + registerFilesystemDownloadHandlers(context) +}) + +afterEach(async () => { + await Promise.all( + [...context.downloadSessions.keys()].map((id) => context.closeDownloadSession(id, true)) + ) + for (const sender of senders.splice(0)) { + sender.removeAllListeners() + } + vi.clearAllTimers() + vi.useRealTimers() +}) + +describe('chunked download renderer ownership', () => { + it('releases all renderer listeners after twenty alternating finish and cancel calls', async () => { + const sender = makeSender() + for (let index = 0; index < 20; index += 1) { + const transferId = await start(sender) + await invoke(index % 2 ? 'finishDownloadedFile' : 'cancelDownloadedFile', sender, { + transferId + }) + } + expect(context.downloadSessions.size).toBe(0) + expectNoListeners(sender) + expect(vi.getTimerCount()).toBe(0) + }) + + it('does not open a dialog for an already destroyed renderer', async () => { + const sender = makeSender() + sender.isDestroyed.mockReturnValue(true) + await expect( + invoke('startDownloadedFile', sender, { suggestedName: 'file.txt' }) + ).resolves.toEqual({ canceled: true }) + expect(mocks.showSaveDialog).not.toHaveBeenCalled() + expectNoListeners(sender) + }) + + it.each( + lifetimeEvents.flatMap((event) => + (['dialog', 'stat', 'open'] as const).map((phase) => ({ event, phase })) + ) + )('cancels pending $phase work after $event', async ({ event, phase }) => { + const sender = makeSender() + const held = Promise.withResolvers() + const operation = + phase === 'dialog' ? mocks.showSaveDialog : phase === 'stat' ? mocks.stat : mocks.open + const callsBefore = operation.mock.calls.length + const opensBefore = mocks.open.mock.calls.length + operation.mockReturnValueOnce(held.promise) + const pending = invoke('startDownloadedFile', sender, { suggestedName: 'file.txt' }) + await vi.waitFor(() => expect(operation).toHaveBeenCalledTimes(callsBefore + 1)) + sender.emit(event) + expectNoListeners(sender) + const handle = makeHandle() + held.resolve( + phase === 'dialog' + ? { canceled: false, filePath: destination } + : phase === 'stat' + ? { isDirectory: () => false } + : handle + ) + await expect(pending).resolves.toEqual({ canceled: true }) + expect(context.downloadSessions.size).toBe(0) + expect(handle.close).toHaveBeenCalledTimes(phase === 'open' ? 1 : 0) + expect(mocks.open).toHaveBeenCalledTimes(opensBefore + (phase === 'open' ? 1 : 0)) + if (phase === 'open') { + expect(mocks.rm).toHaveBeenCalledWith(mocks.open.mock.calls.at(-1)?.[0], { force: true }) + } + expectNoListeners(sender) + expect(vi.getTimerCount()).toBe(0) + }) + + it.each(['dialog', 'stat', 'open'] as const)( + 'releases ownership when %s rejects', + async (phase) => { + const sender = makeSender() + const error = new Error(`${phase} failed`) + const operation = + phase === 'dialog' ? mocks.showSaveDialog : phase === 'stat' ? mocks.stat : mocks.open + operation.mockRejectedValueOnce(error) + await expect( + invoke('startDownloadedFile', sender, { suggestedName: 'file.txt' }) + ).rejects.toBe(error) + expectNoListeners(sender) + expect(context.downloadSessions.size).toBe(0) + expect(vi.getTimerCount()).toBe(0) + } + ) + + it('releases ownership when the user cancels the dialog', async () => { + const sender = makeSender() + mocks.showSaveDialog.mockResolvedValueOnce({ canceled: true }) + await expect( + invoke('startDownloadedFile', sender, { suggestedName: 'file.txt' }) + ).resolves.toEqual({ canceled: true }) + expectNoListeners(sender) + expect(mocks.open).not.toHaveBeenCalled() + }) + + it.each(lifetimeEvents)( + 'closes only live sessions owned by the renderer on %s', + async (event) => { + const first = makeSender(1), + second = makeSender(2) + const handles = [makeHandle(), makeHandle(), makeHandle()] + for (const handle of handles) { + mocks.open.mockResolvedValueOnce(handle) + } + const firstId = await start(first), + secondId = await start(first), + otherId = await start(second) + first.emit(event) + await vi.waitFor(() => expect(mocks.rm).toHaveBeenCalledTimes(2)) + expect([...context.downloadSessions.keys()]).toEqual([otherId]) + expect(handles[0].close).toHaveBeenCalledOnce() + expect(handles[1].close).toHaveBeenCalledOnce() + expect(handles[2].close).not.toHaveBeenCalled() + expectNoListeners(first) + await invoke('cancelDownloadedFile', first, { transferId: firstId }) + await invoke('cancelDownloadedFile', first, { transferId: secondId }) + expect(handles[0].close).toHaveBeenCalledOnce() + expect(handles[1].close).toHaveBeenCalledOnce() + } + ) + + it('keeps a concurrent session owned after its sibling finishes', async () => { + const sender = makeSender(), + firstHandle = makeHandle(), + secondHandle = makeHandle() + mocks.open.mockResolvedValueOnce(firstHandle).mockResolvedValueOnce(secondHandle) + const firstId = await start(sender), + secondId = await start(sender) + await invoke('finishDownloadedFile', sender, { transferId: firstId }) + sender.emit('destroyed') + await vi.waitFor(() => expect(secondHandle.close).toHaveBeenCalledOnce()) + expect(context.downloadSessions.has(secondId)).toBe(false) + expect(firstHandle.close).toHaveBeenCalledOnce() + expectNoListeners(sender) + }) + + it.each(['did-start-navigation', 'did-navigate-in-page'])( + 'keeps a live document transfer across %s', + async (event) => { + const sender = makeSender(), + transferId = await start(sender) + sender.emit(event) + expect(context.downloadSessions.has(transferId)).toBe(true) + await invoke('appendDownloadedFileChunk', sender, { + transferId, + contentBase64: Buffer.from('bytes').toString('base64') + }) + const handle = context.downloadSessions.get(transferId)?.handle + expect(handle?.writeFile).toHaveBeenCalledWith(Buffer.from('bytes')) + } + ) + + it('does not cancel a replacement sender with the same numeric id', async () => { + const oldSender = makeSender(7), + replacement = makeSender(7) + await start(oldSender) + const current = await start(replacement) + oldSender.emit('destroyed') + expect(context.downloadSessions.has(current)).toBe(true) + expect(context.downloadSessions.size).toBe(1) + expectNoListeners(oldSender) + }) + + it('does not let a late old-document open affect a replacement download', async () => { + const sender = makeSender(), + late = makeHandle(), + current = makeHandle() + const held = Promise.withResolvers>() + mocks.open.mockReturnValueOnce(held.promise).mockResolvedValueOnce(current) + const pending = invoke('startDownloadedFile', sender, { suggestedName: 'file.txt' }) + await vi.waitFor(() => expect(mocks.open).toHaveBeenCalledOnce()) + sender.emit('did-navigate') + const transferId = await start(sender) + held.resolve(late) + await expect(pending).resolves.toEqual({ canceled: true }) + expect([...context.downloadSessions.keys()]).toEqual([transferId]) + expect(late.close).toHaveBeenCalledOnce() + expect(current.close).not.toHaveBeenCalled() + await invoke('finishDownloadedFile', sender, { transferId }) + expect(current.close).toHaveBeenCalledOnce() + expectNoListeners(sender) + }) + + it('cleans a late temporary path even if handle close and removal both fail', async () => { + const sender = makeSender(), + handle = makeHandle() + const held = Promise.withResolvers>() + handle.close.mockRejectedValueOnce(new Error('close failed')) + mocks.open.mockReturnValueOnce(held.promise) + mocks.rm.mockRejectedValueOnce(new Error('remove failed')) + const pending = invoke('startDownloadedFile', sender, { suggestedName: 'file.txt' }) + await vi.waitFor(() => expect(mocks.open).toHaveBeenCalledOnce()) + sender.emit('destroyed') + held.resolve(handle) + await expect(pending).resolves.toEqual({ canceled: true }) + expectNoListeners(sender) + expect(handle.close).toHaveBeenCalledOnce() + expect(mocks.rm).toHaveBeenCalledExactlyOnceWith(mocks.open.mock.calls[0][0], { force: true }) + expect(context.downloadSessions.size).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('releases owner listeners before waiting for a canceled handle to close', async () => { + const sender = makeSender(), + handle = makeHandle(), + held = Promise.withResolvers() + handle.close.mockReturnValueOnce(held.promise) + mocks.open.mockResolvedValueOnce(handle) + const transferId = await start(sender) + const pending = invoke('cancelDownloadedFile', sender, { transferId }) + try { + expectNoListeners(sender) + expect(context.downloadSessions.size).toBe(0) + sender.emit('destroyed') + expect(handle.close).toHaveBeenCalledOnce() + } finally { + held.resolve() + await pending + } + }) + + it('releases ownership on timeout and does not close again after later owner events', async () => { + const sender = makeSender(), + handle = makeHandle() + mocks.open.mockResolvedValueOnce(handle) + await start(sender) + await vi.advanceTimersByTimeAsync(DOWNLOAD_SESSION_TTL_MS) + expectNoListeners(sender) + expect(context.downloadSessions.size).toBe(0) + expect(handle.close).toHaveBeenCalledOnce() + expect(mocks.rm).toHaveBeenCalledOnce() + sender.emit('destroyed') + expect(handle.close).toHaveBeenCalledOnce() + }) + + it('keeps ownership cleanup when promotion fails or handle close rejects', async () => { + const sender = makeSender(), + handle = makeHandle() + handle.close.mockRejectedValueOnce(new Error('close failed')) + mocks.open.mockResolvedValueOnce(handle) + mocks.rename.mockRejectedValueOnce(new Error('promotion failed')) + const transferId = await start(sender) + await expect(invoke('finishDownloadedFile', sender, { transferId })).rejects.toThrow( + 'promotion failed' + ) + expectNoListeners(sender) + expect(mocks.rm).toHaveBeenCalledOnce() + expect(handle.close).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/ipc/filesystem-download-transfers.test.ts b/src/main/ipc/filesystem-download-transfers.test.ts index 9d031ff4d39..227372c567d 100644 --- a/src/main/ipc/filesystem-download-transfers.test.ts +++ b/src/main/ipc/filesystem-download-transfers.test.ts @@ -261,10 +261,9 @@ describe('registerFilesystemHandlers', () => { statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) registerFilesystemHandlers(store as never) - const started = await handlers.get('fs:startDownloadedFile')!( - { sender: {} }, - { suggestedName: 'report.pdf' } - ) + const started = await handlers.get('fs:startDownloadedFile')!(folderDownloadEvent, { + suggestedName: 'report.pdf' + }) expect(started).toMatchObject({ canceled: false, destinationPath: '/downloads/report.pdf' @@ -300,10 +299,9 @@ describe('registerFilesystemHandlers', () => { statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) registerFilesystemHandlers(store as never) - const started = await handlers.get('fs:startDownloadedFile')!( - { sender: {} }, - { suggestedName: 'report.pdf' } - ) + const started = await handlers.get('fs:startDownloadedFile')!(folderDownloadEvent, { + suggestedName: 'report.pdf' + }) if (!started || typeof started !== 'object' || !('transferId' in started)) { throw new Error('download did not start') } diff --git a/src/main/ipc/filesystem-git-status-staging.test.ts b/src/main/ipc/filesystem-git-status-staging.test.ts index 5cc1b4e9540..b86a75b2314 100644 --- a/src/main/ipc/filesystem-git-status-staging.test.ts +++ b/src/main/ipc/filesystem-git-status-staging.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from 'node:events' import path from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import { @@ -305,42 +306,51 @@ describe('registerFilesystemHandlers', () => { }) }) - it('aborts tokenized local status without crossing renderer boundaries', async () => { - registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) - const statusSignals: AbortSignal[] = [] - getStatusMock.mockImplementation( - (_worktreePath: string, options: { signal?: AbortSignal }) => - new Promise((_resolve, reject) => { - if (options.signal) { - statusSignals.push(options.signal) - options.signal.addEventListener('abort', () => reject(new Error('aborted')), { - once: true - }) - } - }) - ) - registerFilesystemHandlers(store as never) + it.each(['cancel', 'did-navigate', 'render-process-gone', 'destroyed'])( + 'aborts tokenized local status on %s without crossing renderer boundaries', + async (eventName) => { + registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) + const statusSignals: AbortSignal[] = [] + getStatusMock.mockImplementation( + (_worktreePath: string, options: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + if (options.signal) { + statusSignals.push(options.signal) + options.signal.addEventListener('abort', () => reject(new Error('aborted')), { + once: true + }) + } + }) + ) + registerFilesystemHandlers(store as never) - const firstEvent = { sender: { id: 7 } } - const secondEvent = { sender: { id: 8 } } - const firstRequest = handlers.get('git:status')!(firstEvent, { - worktreePath: WORKTREE_FEATURE_PATH, - requestToken: 'status-1' - }) as Promise - const secondRequest = handlers.get('git:status')!(secondEvent, { - worktreePath: WORKTREE_FEATURE_PATH, - requestToken: 'status-1' - }) as Promise - await vi.waitFor(() => expect(statusSignals).toHaveLength(2)) - await handlers.get('git:cancelStatus')!(firstEvent, { requestToken: 'status-1' }) + const firstEvent = { sender: Object.assign(new EventEmitter(), { id: 7 }) } + const secondEvent = { sender: Object.assign(new EventEmitter(), { id: 8 }) } + const firstRequest = handlers.get('git:status')!(firstEvent, { + worktreePath: WORKTREE_FEATURE_PATH, + requestToken: 'status-1' + }) as Promise + const secondRequest = handlers.get('git:status')!(secondEvent, { + worktreePath: WORKTREE_FEATURE_PATH, + requestToken: 'status-1' + }) as Promise + await vi.waitFor(() => expect(statusSignals).toHaveLength(2)) + if (eventName === 'cancel') { + await handlers.get('git:cancelStatus')!(firstEvent, { requestToken: 'status-1' }) + } else { + firstEvent.sender.emit(eventName) + } - expect(statusSignals[0]?.aborted).toBe(true) - expect(statusSignals[1]?.aborted).toBe(false) - await expect(firstRequest).rejects.toThrow('aborted') + expect(statusSignals[0]?.aborted).toBe(true) + expect(statusSignals[1]?.aborted).toBe(false) + await expect(firstRequest).rejects.toThrow('aborted') - await handlers.get('git:cancelStatus')!(secondEvent, { requestToken: 'status-1' }) - await expect(secondRequest).rejects.toThrow('aborted') - }) + await handlers.get('git:cancelStatus')!(secondEvent, { requestToken: 'status-1' }) + await expect(secondRequest).rejects.toThrow('aborted') + expect(firstEvent.sender.eventNames()).toEqual([]) + expect(secondEvent.sender.eventNames()).toEqual([]) + } + ) it('checks ignored paths through local and SSH git providers', async () => { registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) diff --git a/src/main/ipc/filesystem-import-local-rollback.test.ts b/src/main/ipc/filesystem-import-local-rollback.test.ts new file mode 100644 index 00000000000..364c6c88eee --- /dev/null +++ b/src/main/ipc/filesystem-import-local-rollback.test.ts @@ -0,0 +1,254 @@ +import { constants } from 'node:fs' +import { join, resolve } from 'node:path' +import { Readable, Writable } from 'node:stream' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { lstatMock, mkdirMock, openMock, readdirMock, rmMock, unlinkMock } = vi.hoisted(() => ({ + lstatMock: vi.fn(), + mkdirMock: vi.fn(), + openMock: vi.fn(), + readdirMock: vi.fn(), + rmMock: vi.fn(), + unlinkMock: vi.fn() +})) + +vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: vi.fn() })) +vi.mock('node:fs/promises', () => ({ + lstat: lstatMock, + mkdir: mkdirMock, + open: openMock, + readdir: readdirMock, + rm: rmMock, + unlink: unlinkMock +})) + +import { importOneSource } from './filesystem-import-local' +import { recursiveCopyDir } from './filesystem-import-local-tree-copy' + +type EntryKind = 'directory' | 'file' | 'symlink' | 'unsupported' + +function statFor(kind: EntryKind) { + return { + size: 7, + ino: 1, + dev: 1, + isDirectory: () => kind === 'directory', + isFile: () => kind === 'file', + isSymbolicLink: () => kind === 'symlink' + } +} + +const source = resolve('import-fixture', 'incoming') +const destination = resolve('import-fixture', 'folder-workspace') +const target = join(destination, 'incoming') +const stats = new Map>() +const entries = new Map() + +function addEntry(parent: string, name: string, kind: EntryKind): string { + const path = join(parent, name) + stats.set(path, statFor(kind)) + entries.set(parent, [...(entries.get(parent) ?? []), { name, kind }]) + return path +} + +beforeEach(() => { + vi.resetAllMocks() + stats.clear() + entries.clear() + stats.set(source, statFor('directory')) + lstatMock.mockImplementation(async (path: string) => { + const stat = stats.get(path) + if (stat) { + return stat + } + throw Object.assign(new Error('missing'), { code: 'ENOENT' }) + }) + readdirMock.mockImplementation(async (path: string) => + (entries.get(path) ?? []).map(({ name, kind }) => ({ name, ...statFor(kind) })) + ) + mkdirMock.mockResolvedValue(undefined) + rmMock.mockResolvedValue(undefined) + unlinkMock.mockResolvedValue(undefined) + openMock.mockRejectedValue(new Error('unexpected file copy')) +}) + +describe('local directory import rollback ownership', () => { + it.each(['EEXIST', 'EACCES', 'EPERM'])( + 'does not remove output after root mkdir %s', + async (code) => { + mkdirMock.mockRejectedValue(Object.assign(new Error(code), { code })) + + expect(await importOneSource(source, destination, new Set())).toEqual({ + sourcePath: source, + status: 'failed', + reason: code + }) + expect(mkdirMock).toHaveBeenCalledExactlyOnceWith(target, { recursive: false }) + expect(rmMock).not.toHaveBeenCalled() + expect(openMock).not.toHaveBeenCalled() + } + ) + + it.each(['existing', 'reserved'])( + 'preserves a late conflict after deconflicting a %s name', + async (conflict) => { + const reserved = new Set() + if (conflict === 'existing') { + stats.set(target, statFor('directory')) + } else { + reserved.add('incoming') + } + mkdirMock.mockRejectedValue(Object.assign(new Error('late conflict'), { code: 'EEXIST' })) + + expect(await importOneSource(source, destination, reserved)).toMatchObject({ + status: 'failed' + }) + expect(mkdirMock).toHaveBeenCalledExactlyOnceWith(join(destination, 'incoming copy'), { + recursive: false + }) + expect(rmMock).not.toHaveBeenCalled() + } + ) + + it('cleans its directory when reading it fails after creation', async () => { + readdirMock.mockResolvedValueOnce([]).mockRejectedValueOnce(new Error('source unreadable')) + + expect(await importOneSource(source, destination, new Set())).toMatchObject({ + status: 'failed', + reason: 'source unreadable' + }) + expect(rmMock).toHaveBeenCalledExactlyOnceWith(target, { recursive: true, force: true }) + }) + + it('keeps the copy error when best-effort cleanup also fails', async () => { + addEntry(source, 'lost.txt', 'file') + openMock.mockRejectedValue(new Error('source changed')) + rmMock.mockRejectedValue(new Error('cleanup denied')) + + expect(await importOneSource(source, destination, new Set())).toMatchObject({ + status: 'failed', + reason: 'source changed' + }) + expect(rmMock).toHaveBeenCalledExactlyOnceWith(target, { recursive: true, force: true }) + }) + + it('rolls back the owned root once when a nested mkdir fails', async () => { + addEntry(source, 'child', 'directory') + mkdirMock.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('nested conflict')) + + expect(await importOneSource(source, destination, new Set())).toMatchObject({ + status: 'failed', + reason: 'nested conflict' + }) + expect(mkdirMock.mock.calls).toEqual([ + [target, { recursive: false }], + [join(target, 'child'), { recursive: false }] + ]) + expect(rmMock).toHaveBeenCalledExactlyOnceWith(target, { recursive: true, force: true }) + }) + + it.each(['symlink', 'unsupported', 'missing'])( + 'rolls back the owned root once when a nested entry becomes %s', + async (kind) => { + const child = addEntry(source, 'child', 'directory') + const file = addEntry(child, 'changed.txt', 'file') + if (kind === 'missing') { + stats.delete(file) + } else { + stats.set(file, statFor(kind === 'symlink' ? 'symlink' : 'unsupported')) + } + + expect(await importOneSource(source, destination, new Set())).toMatchObject({ + status: 'failed' + }) + expect(mkdirMock).toHaveBeenCalledTimes(2) + expect(rmMock).toHaveBeenCalledExactlyOnceWith(target, { recursive: true, force: true }) + expect(openMock).not.toHaveBeenCalled() + } + ) + + it.each(['top-level', 'nested'])( + 'skips a %s symlink before creating output', + async (location) => { + if (location === 'top-level') { + stats.set(source, statFor('symlink')) + } else { + addEntry(addEntry(source, 'child', 'directory'), 'link', 'symlink') + } + + expect(await importOneSource(source, destination, new Set())).toMatchObject({ + status: 'skipped', + reason: 'symlink' + }) + expect(mkdirMock).not.toHaveBeenCalled() + expect(rmMock).not.toHaveBeenCalled() + } + ) + + it('copies nested files with exclusive destinations and preserves rename metadata', async () => { + const file = addEntry(addEntry(source, 'child', 'directory'), 'data.txt', 'file') + const renamedTarget = join(destination, 'incoming copy') + const chunks: Buffer[] = [] + const closeSource = vi.fn().mockResolvedValue(undefined) + const closeDestination = vi.fn().mockResolvedValue(undefined) + openMock.mockImplementation(async (_path: string, flags: unknown) => { + if (flags === 'wx') { + return { + createWriteStream: () => + new Writable({ + write(chunk, _encoding, callback) { + chunks.push(Buffer.from(chunk)) + callback() + } + }), + close: closeDestination + } + } + return { + stat: async () => statFor('file'), + createReadStream: () => Readable.from([Buffer.from('payload')]), + close: closeSource + } + }) + + expect(await importOneSource(source, destination, new Set(['incoming']))).toEqual({ + sourcePath: source, + status: 'imported', + destPath: renamedTarget, + kind: 'directory', + renamed: true + }) + expect(mkdirMock.mock.calls).toEqual([ + [renamedTarget, { recursive: false }], + [join(renamedTarget, 'child'), { recursive: false }] + ]) + expect(openMock.mock.calls).toEqual([ + [file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)], + [join(renamedTarget, 'child', 'data.txt'), 'wx'] + ]) + expect(Buffer.concat(chunks).toString()).toBe('payload') + expect(closeSource).toHaveBeenCalledOnce() + expect(closeDestination).toHaveBeenCalledOnce() + expect(rmMock).not.toHaveBeenCalled() + expect(unlinkMock).not.toHaveBeenCalled() + }) + + it('leaves directory cleanup out of a top-level file failure', async () => { + stats.set(source, statFor('file')) + + expect(await importOneSource(source, destination, new Set())).toMatchObject({ + status: 'failed' + }) + expect(mkdirMock).not.toHaveBeenCalled() + expect(rmMock).not.toHaveBeenCalled() + expect(unlinkMock).not.toHaveBeenCalled() + }) + + it('cleans an owned root when the copy helper is called directly', async () => { + const failure = new Error('read failed') + readdirMock.mockRejectedValue(failure) + + await expect(recursiveCopyDir(source, target)).rejects.toBe(failure) + expect(rmMock).toHaveBeenCalledExactlyOnceWith(target, { recursive: true, force: true }) + }) +}) diff --git a/src/main/ipc/filesystem-import-local-tree-copy.ts b/src/main/ipc/filesystem-import-local-tree-copy.ts index f2eb3492377..06d68368ce9 100644 --- a/src/main/ipc/filesystem-import-local-tree-copy.ts +++ b/src/main/ipc/filesystem-import-local-tree-copy.ts @@ -1,5 +1,5 @@ import { constants } from 'node:fs' -import { lstat, mkdir, open, readdir, unlink } from 'node:fs/promises' +import { lstat, mkdir, open, readdir, rm, unlink } from 'node:fs/promises' import { basename, join } from 'node:path' import { pipeline } from 'node:stream/promises' @@ -23,13 +23,18 @@ export async function preScanForSymlinks(dirPath: string): Promise { return false } -/** - * Recursively copy a directory and all its contents. Uses copyFile for - * individual files to leverage native OS copy primitives instead of - * buffering entire files into memory. - */ export async function recursiveCopyDir(srcDir: string, destDir: string): Promise { + // A failed exclusive mkdir gives us no ownership to roll back. await mkdir(destDir, { recursive: false }) + try { + await copyDirectoryContents(srcDir, destDir) + } catch (error) { + await rm(destDir, { recursive: true, force: true }).catch(() => {}) + throw error + } +} + +async function copyDirectoryContents(srcDir: string, destDir: string): Promise { const entries = await readdir(srcDir, { withFileTypes: true }) for (const entry of entries) { const srcPath = join(srcDir, entry.name) @@ -39,7 +44,8 @@ export async function recursiveCopyDir(srcDir: string, destDir: string): Promise throw new Error(`Symlink not allowed in '${entry.name}'`) } if (statResult.isDirectory()) { - await recursiveCopyDir(srcPath, dstPath) + await mkdir(dstPath, { recursive: false }) + await copyDirectoryContents(srcPath, dstPath) continue } if (!statResult.isFile()) { diff --git a/src/main/ipc/filesystem-import-local.ts b/src/main/ipc/filesystem-import-local.ts index 1df2b3f2fb3..c77d841a53d 100644 --- a/src/main/ipc/filesystem-import-local.ts +++ b/src/main/ipc/filesystem-import-local.ts @@ -1,4 +1,4 @@ -import { lstat, rm } from 'node:fs/promises' +import { lstat } from 'node:fs/promises' import { basename, join, resolve } from 'node:path' import { authorizeExternalPath } from './filesystem-auth' import { isENOENT } from './filesystem-path-containment' @@ -82,9 +82,6 @@ export async function importOneSource( ? recursiveCopyDir(resolvedSource, destPath) : copyLocalFileNoFollow(resolvedSource, destPath)) } catch (error) { - if (isDir) { - await rm(destPath, { recursive: true, force: true }).catch(() => {}) - } return { sourcePath, status: 'failed', diff --git a/src/main/ipc/filesystem-markdown-document-listing.test.ts b/src/main/ipc/filesystem-markdown-document-listing.test.ts index d70d46c48e1..be94530352d 100644 --- a/src/main/ipc/filesystem-markdown-document-listing.test.ts +++ b/src/main/ipc/filesystem-markdown-document-listing.test.ts @@ -1,15 +1,28 @@ import path from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as MarkdownDocumentsModule from './markdown-documents' import { handlers, store, - dirEntry, WORKTREE_FEATURE_PATH, readdirMock, getSshFilesystemProviderMock, resetFilesystemIpcMocks } from './filesystem-test-harness' +const { listMarkdownDocumentsMock, localOptionsMock } = vi.hoisted(() => ({ + listMarkdownDocumentsMock: vi.fn(), + localOptionsMock: vi.fn() +})) + +vi.mock('./markdown-documents', async (importOriginal) => ({ + ...(await importOriginal()), + listMarkdownDocuments: listMarkdownDocumentsMock +})) +vi.mock('./local-worktree-runtime-options', () => ({ + getLocalGitOptionsForRegisteredWorktree: localOptionsMock +})) + vi.mock('electron', async () => (await import('./filesystem-test-harness')).electronMock) vi.mock('fs/promises', async () => (await import('./filesystem-test-harness')).fsPromisesMock) vi.mock( @@ -64,95 +77,39 @@ import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cach describe('registerFilesystemHandlers', () => { beforeEach(() => { resetFilesystemIpcMocks() + listMarkdownDocumentsMock.mockReset().mockResolvedValue([]) + localOptionsMock.mockReset().mockReturnValue({}) // Reset module-level auth cache so each test starts with a fresh dirty // flag — prevents stale worktree data from a prior test's cache rebuild. invalidateAuthorizedRootsCache() }) - it('lists markdown documents recursively for a registered worktree', async () => { - readdirMock.mockImplementation(async (dirPath: string) => { - if (dirPath === WORKTREE_FEATURE_PATH) { - return [ - dirEntry({ name: 'README.md', file: true }), - dirEntry({ name: 'docs', directory: true }), - dirEntry({ name: 'script.ts', file: true }) - ] - } - if (dirPath === path.join(WORKTREE_FEATURE_PATH, 'docs')) { - return [ - dirEntry({ name: 'Guide.MDX', file: true }), - dirEntry({ name: 'notes.markdown', file: true }) - ] - } - return [] - }) - + it('lists local documents through the bundled discovery path after authorization', async () => { + const documents = [{ filePath: path.join(WORKTREE_FEATURE_PATH, 'README.md') }] + listMarkdownDocumentsMock.mockResolvedValue(documents) registerFilesystemHandlers(store as never) await expect( - handlers.get('fs:listMarkdownDocuments')!(null, { - rootPath: WORKTREE_FEATURE_PATH - }) - ).resolves.toEqual([ - { - filePath: path.join(WORKTREE_FEATURE_PATH, 'docs', 'Guide.MDX'), - relativePath: 'docs/Guide.MDX', - basename: 'Guide.MDX', - name: 'Guide' - }, - { - filePath: path.join(WORKTREE_FEATURE_PATH, 'docs', 'notes.markdown'), - relativePath: 'docs/notes.markdown', - basename: 'notes.markdown', - name: 'notes' - }, - { - filePath: path.join(WORKTREE_FEATURE_PATH, 'README.md'), - relativePath: 'README.md', - basename: 'README.md', - name: 'README' - } - ]) + handlers.get('fs:listMarkdownDocuments')!(null, { rootPath: WORKTREE_FEATURE_PATH }) + ).resolves.toBe(documents) + expect(localOptionsMock).toHaveBeenCalledWith( + store, + WORKTREE_FEATURE_PATH, + WORKTREE_FEATURE_PATH + ) + expect(listMarkdownDocumentsMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, {}) + expect(readdirMock).not.toHaveBeenCalled() }) - it('skips ignored and symlinked directories when listing markdown documents', async () => { - readdirMock.mockImplementation(async (dirPath: string) => { - if (dirPath === WORKTREE_FEATURE_PATH) { - return [ - dirEntry({ name: '.git', directory: true }), - dirEntry({ name: '.hidden', directory: true }), - dirEntry({ name: '.github', directory: true }), - dirEntry({ name: 'node_modules', directory: true }), - dirEntry({ name: 'linked-docs', directory: true, symlink: true }), - dirEntry({ name: 'visible.md', file: true }) - ] - } - if (dirPath === path.join(WORKTREE_FEATURE_PATH, '.github')) { - return [dirEntry({ name: 'CONTRIBUTING.md', file: true })] - } - throw new Error(`Unexpected readdir: ${dirPath}`) - }) - + it('passes the workspace runtime distro into document discovery', async () => { + localOptionsMock.mockReturnValue({ wslDistro: 'Ubuntu' }) registerFilesystemHandlers(store as never) - await expect( - handlers.get('fs:listMarkdownDocuments')!(null, { - rootPath: WORKTREE_FEATURE_PATH - }) - ).resolves.toEqual([ - { - filePath: path.join(WORKTREE_FEATURE_PATH, '.github', 'CONTRIBUTING.md'), - relativePath: '.github/CONTRIBUTING.md', - basename: 'CONTRIBUTING.md', - name: 'CONTRIBUTING' - }, - { - filePath: path.join(WORKTREE_FEATURE_PATH, 'visible.md'), - relativePath: 'visible.md', - basename: 'visible.md', - name: 'visible' - } - ]) + await handlers.get('fs:listMarkdownDocuments')!(null, { rootPath: WORKTREE_FEATURE_PATH }) + + expect(listMarkdownDocumentsMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { + wslDistro: 'Ubuntu' + }) }) it('rejects markdown document listing for authorized but unregistered roots', async () => { @@ -165,6 +122,7 @@ describe('registerFilesystemHandlers', () => { ).rejects.toThrow('Access denied: unknown repository or worktree path') expect(readdirMock).not.toHaveBeenCalled() + expect(listMarkdownDocumentsMock).not.toHaveBeenCalled() }) it('lists remote markdown documents through the SSH filesystem provider', async () => { @@ -196,5 +154,7 @@ describe('registerFilesystemHandlers', () => { name: 'README' } ]) + expect(listMarkdownDocumentsMock).not.toHaveBeenCalled() + expect(localOptionsMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/ipc/filesystem-pull-request-linked-issue-lifetime.test.ts b/src/main/ipc/filesystem-pull-request-linked-issue-lifetime.test.ts new file mode 100644 index 00000000000..e71ea22f7ba --- /dev/null +++ b/src/main/ipc/filesystem-pull-request-linked-issue-lifetime.test.ts @@ -0,0 +1,189 @@ +import { setImmediate as nextTurn } from 'node:timers/promises' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + handlers, + store, + WORKTREE_FEATURE_PATH, + resolveCommitMessageSettingsMock, + generatePullRequestFieldsFromContextMock, + getPullRequestDraftContextMock, + resolveHostedReviewBodyForGenerationMock, + loadPullRequestLinkedIssueMock, + getSshGitProviderMock, + resetFilesystemIpcMocks +} from './filesystem-test-harness' + +const linkedLookup = vi.hoisted(() => ({ run: (): Promise => Promise.resolve(null) })) + +vi.mock('electron', async () => (await import('./filesystem-test-harness')).electronMock) +vi.mock('fs/promises', async () => (await import('./filesystem-test-harness')).fsPromisesMock) +vi.mock( + '../wsl-unc-delete', + async () => (await import('./filesystem-test-harness')).wslUncDeleteMock +) +vi.mock( + '../crash-reporting/crash-breadcrumb-store', + async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock +) +vi.mock( + '../local-downloaded-folder-promotion', + async () => (await import('./filesystem-test-harness')).folderPromotionMock +) +vi.mock( + '../git/status', + async () => (await import('./filesystem-test-harness')).gitStatusModuleMock +) +vi.mock( + '../git/check-ignored-paths', + async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock +) +vi.mock('../git/worktree', async () => (await import('./filesystem-test-harness')).gitWorktreeMock) +vi.mock( + '../providers/ssh-filesystem-dispatch', + async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock +) +vi.mock( + '../providers/ssh-git-dispatch', + async () => (await import('./filesystem-test-harness')).sshGitDispatchMock +) +vi.mock( + '../text-generation/commit-message-text-generation', + async () => (await import('./filesystem-test-harness')).textGenerationModuleMock +) +vi.mock( + '../text-generation/pull-request-context', + async () => (await import('./filesystem-test-harness')).pullRequestContextMock +) +vi.mock( + '../source-control/pull-request-template', + async () => (await import('./filesystem-test-harness')).pullRequestTemplateMock +) +vi.mock('../source-control/pull-request-linked-issue', async () => { + const { loadPullRequestLinkedIssueMock } = await import('./filesystem-test-harness') + return { + loadPullRequestLinkedIssue: (...args: unknown[]) => { + loadPullRequestLinkedIssueMock(...args) + // A vi.fn return observer would itself handle the rejection under test. + return linkedLookup.run() + } + } +}) + +import { registerFilesystemHandlers } from './filesystem' +import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' + +describe.each(['local', 'SSH'])('PR linked-issue lifetime on %s', (host) => { + const draftContext = { + base: 'main', + branch: 'feature/ai', + branchChangedByPreparation: false, + commitSummary: 'a1b2c3d Add generation', + changeSummary: 'README.md | 2 +-', + patch: '+hello', + currentTitle: '', + currentBody: '', + currentDraft: false + } + const request = { + base: 'main', + title: '', + body: '', + draft: false, + worktreePath: host === 'SSH' ? '/remote/repo' : WORKTREE_FEATURE_PATH, + ...(host === 'SSH' ? { connectionId: 'conn-1' } : {}) + } + + beforeEach(() => { + resetFilesystemIpcMocks() + linkedLookup.run = () => Promise.resolve(null) + invalidateAuthorizedRootsCache() + resolveCommitMessageSettingsMock.mockReturnValue({ + ok: true, + params: { agentId: 'codex', model: 'gpt-5.4-mini' } + }) + resolveHostedReviewBodyForGenerationMock.mockResolvedValue('') + getPullRequestDraftContextMock.mockResolvedValue(draftContext) + getSshGitProviderMock.mockReturnValue({ + exec: vi.fn(), + executeCommitMessagePlan: vi.fn() + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing IPC harness provides every Store member reached by this handler. + registerFilesystemHandlers(store as never) + }) + + it.each(['no changes', 'context error', 'template error'])( + 'observes a late lookup rejection after %s', + async (outcome) => { + const issue = Promise.withResolvers() + linkedLookup.run = () => issue.promise + const preparationError = new Error('branch preparation failed') + if (outcome === 'no changes') { + getPullRequestDraftContextMock.mockResolvedValue(null) + } else if (outcome === 'context error') { + getPullRequestDraftContextMock.mockRejectedValue(preparationError) + } else { + resolveHostedReviewBodyForGenerationMock.mockRejectedValue(preparationError) + } + const unhandled = vi.fn() + process.on('unhandledRejection', unhandled) + try { + await expect( + handlers.get('git:generatePullRequestFields')!(null, request) + ).resolves.toEqual({ + success: false, + error: + outcome === 'no changes' ? 'No branch changes to summarize.' : preparationError.message + }) + expect(loadPullRequestLinkedIssueMock).toHaveBeenCalledTimes(1) + issue.reject(new Error('Timed out waiting for a GitLab operation slot.')) + await nextTurn() + expect(unhandled).not.toHaveBeenCalled() + expect(generatePullRequestFieldsFromContextMock).not.toHaveBeenCalled() + } finally { + void issue.promise.catch(() => undefined) + process.off('unhandledRejection', unhandled) + } + } + ) + + it('observes a lookup rejection while preparation is pending and preserves the later error', async () => { + const issue = Promise.withResolvers() + const preparation = Promise.withResolvers() + const failure = new Error('lookup admission failed') + linkedLookup.run = () => issue.promise + getPullRequestDraftContextMock.mockReturnValue(preparation.promise) + const unhandled = vi.fn() + process.on('unhandledRejection', unhandled) + const result = Promise.resolve( + handlers.get('git:generatePullRequestFields')!(null, request) + ).catch((error: unknown) => error) + try { + await nextTurn() + expect(getPullRequestDraftContextMock).toHaveBeenCalledTimes(1) + issue.reject(failure) + await nextTurn() + expect(unhandled).not.toHaveBeenCalled() + preparation.resolve(draftContext) + expect(await result).toBe(failure) + expect(generatePullRequestFieldsFromContextMock).not.toHaveBeenCalled() + } finally { + issue.resolve(null) + preparation.resolve(draftContext) + await result + process.off('unhandledRejection', unhandled) + } + }) + + it('still rejects the caller when preparation succeeds but the lookup fails', async () => { + const issue = Promise.withResolvers() + const failure = new Error('lookup failed') + linkedLookup.run = () => issue.promise + const result = Promise.resolve( + handlers.get('git:generatePullRequestFields')!(null, request) + ).catch((error: unknown) => error) + await nextTurn() + issue.reject(failure) + expect(await result).toBe(failure) + expect(generatePullRequestFieldsFromContextMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/filesystem-watcher-canonical-root-paths.test.ts b/src/main/ipc/filesystem-watcher-canonical-root-paths.test.ts index 45c14d485e0..9aa42e3107e 100644 --- a/src/main/ipc/filesystem-watcher-canonical-root-paths.test.ts +++ b/src/main/ipc/filesystem-watcher-canonical-root-paths.test.ts @@ -1,3 +1,4 @@ +import { createWatcherSender } from './filesystem-watcher-test-sender' /* * macOS FSEvents reports OS-canonical paths (symlinks resolved, on-disk * casing). Before the rewrite at the watcher boundary those events reached the @@ -80,7 +81,7 @@ describe('local filesystem watcher canonical root paths', () => { return { unsubscribe: vi.fn() } as never }) const sendMock = vi.fn() - const sender = { isDestroyed: () => false, send: sendMock, once: vi.fn(), id: 1 } + const sender = createWatcherSender(1, sendMock) await handlers['fs:watchWorktree']({ sender }, { worktreePath }) watcherCallback!(null, events) await vi.waitFor( diff --git a/src/main/ipc/filesystem-watcher-document-lifetime.test.ts b/src/main/ipc/filesystem-watcher-document-lifetime.test.ts new file mode 100644 index 00000000000..9bc42d1ef73 --- /dev/null +++ b/src/main/ipc/filesystem-watcher-document-lifetime.test.ts @@ -0,0 +1,299 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createDebouncedBatch } from './filesystem-watcher-batch-control' + +type WatchArgs = { worktreePath: string; connectionId?: string } +class Sender extends EventEmitter { + constructor(readonly id: number) { + super() + } + isDestroyed = () => false + send = vi.fn() +} +const { handleMock, watchRemote, createLocal, getProvider } = vi.hoisted(() => ({ + handleMock: vi.fn(), + watchRemote: vi.fn(), + createLocal: vi.fn(), + getProvider: vi.fn() +})) +vi.mock('electron', () => ({ ipcMain: { handle: handleMock } })) +vi.mock('node:fs/promises', () => ({ stat: async () => ({ isDirectory: () => true }) })) +vi.mock('./filesystem-watcher-local-events', () => ({ + createLocalWatcher: createLocal, + scheduleLocalBatchFlush: vi.fn() +})) +vi.mock('./parcel-watcher-process', () => ({ disposeWatcherProcess: vi.fn() })) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: getProvider, + onSshFilesystemProviderRegistered: () => () => {} +})) +import { + REMOTE_WATCH_RETRY_MS, + watcherLifecycleState as state +} from './filesystem-watcher-lifecycle-state' +import { getLocalWatcherRoot, getRemoteWatcherKey } from './filesystem-watcher-paths' +import { reinstallRemoteWatchersForConnection } from './filesystem-watcher-remote-controller' +import { reinstallRemoteWatchersForConnectionCore } from './filesystem-watcher-remote-provider-rearm' +import { + registerFilesystemWatcherHandlers, + closeAllWatchers, + closeLocalWatcherForWorktreePath, + closeRemoteWatcherForWorktreePath, + restoreLocalWatcherAfterFailedRemoval, + restoreRemoteWatcherAfterFailedRemoval +} from './filesystem-watcher' + +const handlers = new Map Promise>() +function invoke(channel: string, sender: Sender, args: WatchArgs): Promise { + const handler = handlers.get(channel) + if (!handler) { + throw new Error(`Missing ${channel}`) + } + return handler({ sender }, args) +} +const watch = (sender: Sender, args: WatchArgs) => invoke('fs:watchWorktree', sender, args) +const unwatch = (sender: Sender, args: WatchArgs) => invoke('fs:unwatchWorktree', sender, args) +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} +function localRoot(unsubscribe = vi.fn(async () => {})) { + return { + subscription: { unsubscribe }, + listeners: new Map(), + batch: createDebouncedBatch(), + rootPath: '/folder' + } +} + +beforeEach(async () => { + await closeAllWatchers() + vi.clearAllMocks() + createLocal.mockImplementation(async () => localRoot()) + watchRemote.mockImplementation(async () => vi.fn()) + getProvider.mockReturnValue({ watch: watchRemote }) + handleMock.mockImplementation((channel, handler) => handlers.set(channel, handler)) + registerFilesystemWatcherHandlers() +}) +afterEach(async () => { + await closeAllWatchers() + vi.useRealTimers() +}) + +describe('filesystem watcher renderer document ownership', () => { + it.each(['did-navigate', 'render-process-gone'])( + 'releases installed local and SSH roots on %s without closing sibling owners', + async (event) => { + const sender = new Sender(1) + const sibling = new Sender(2) + const local = { worktreePath: '/folder' } + const remote = { ...local, connectionId: 'ssh' } + const root = localRoot() + const remoteClose = vi.fn() + createLocal.mockResolvedValue(root) + watchRemote.mockResolvedValue(remoteClose) + await watch(sender, local) + await watch(sender, remote) + await watch(sibling, local) + await watch(sibling, remote) + sender.emit(event) + await Promise.resolve() + expect(root.subscription.unsubscribe).not.toHaveBeenCalled() + expect(remoteClose).not.toHaveBeenCalled() + expect([...root.listeners.keys()]).toEqual([2]) + expect([...state.desiredRemoteWatchers.values()][0].listeners.size).toBe(1) + sibling.emit(event) + await Promise.resolve() + expect(root.subscription.unsubscribe).toHaveBeenCalledTimes(1) + expect(remoteClose).toHaveBeenCalledTimes(1) + expect(state.watchedRoots.size).toBe(0) + expect(state.remoteWatchers.size).toBe(0) + expect(state.desiredRemoteWatchers.size).toBe(0) + expect(sender.eventNames()).toEqual([]) + expect(sibling.eventNames()).toEqual([]) + } + ) + + it('keeps same-document and blocked navigations alive, and removes lifecycle listeners at shutdown', async () => { + const sender = new Sender(1) + for (let cycle = 0; cycle < 3; cycle++) { + await watch(sender, { worktreePath: '/folder' }) + sender.emit('did-start-navigation') + sender.emit('did-navigate-in-page') + expect(state.watchedRoots.size).toBe(1) + for (const event of ['destroyed', 'did-navigate', 'render-process-gone']) { + expect(sender.listenerCount(event)).toBe(1) + } + await closeAllWatchers() + expect(sender.eventNames()).toEqual([]) + } + }) + + it.each(['local', 'ssh'])( + 'does not revive a cancelled %s setup for a joiner whose document reloaded', + async (kind) => { + const sender = new Sender(1) + const joiner = new Sender(2) + const args = { worktreePath: '/folder', ...(kind === 'ssh' ? { connectionId: 'ssh' } : {}) } + const install = deferred & (() => void)>() + const setup = kind === 'ssh' ? watchRemote : createLocal + setup.mockReturnValueOnce(install.promise) + const first = watch(sender, args) + await vi.waitFor(() => expect(setup).toHaveBeenCalledTimes(1)) + await unwatch(sender, args) + await Promise.resolve() + const key = + kind === 'ssh' ? getRemoteWatcherKey('ssh', '/folder') : getLocalWatcherRoot('/folder').key + const token = + kind === 'ssh' + ? state.inFlightRemoteInstalls.get(key) + : state.inFlightLocalInstalls.get(key) + expect(token?.abortController.signal.aborted).toBe(true) + const pendingJoiner = watch(joiner, args) + joiner.emit('did-navigate') + const fresh = watch(joiner, { ...args, worktreePath: '/fresh' }) + const closeLate = vi.fn(async () => {}) + install.resolve(Object.assign(closeLate, localRoot(closeLate))) + await Promise.all([first, pendingJoiner, fresh]) + expect(setup.mock.calls.filter(([path]) => path === '/folder')).toHaveLength(1) + expect(closeLate).toHaveBeenCalledTimes(1) + expect(kind === 'ssh' ? state.remoteWatchers.has(key) : state.watchedRoots.has(key)).toBe( + false + ) + expect(setup).toHaveBeenCalledTimes(2) + } + ) + + it.each(['local', 'ssh'])( + 'aborts pending %s setup on renderer crash and discards late success', + async (kind) => { + const sender = new Sender(1) + const setup = kind === 'ssh' ? watchRemote : createLocal + const install = deferred & (() => void)>() + setup.mockReturnValueOnce(install.promise) + const pending = watch(sender, { + worktreePath: '/folder', + ...(kind === 'ssh' ? { connectionId: 'ssh' } : {}) + }) + await vi.waitFor(() => expect(setup).toHaveBeenCalledTimes(1)) + const token = [ + ...(kind === 'ssh' ? state.inFlightRemoteInstalls : state.inFlightLocalInstalls).values() + ][0] + sender.emit('render-process-gone') + await Promise.resolve() + expect(token.abortController.signal.aborted).toBe(true) + const closeLate = vi.fn(async () => {}) + install.resolve(Object.assign(closeLate, localRoot(closeLate))) + await pending + expect(closeLate).toHaveBeenCalledTimes(1) + expect(state.watchedRoots.size + state.remoteWatchers.size).toBe(0) + } + ) + + it('does not re-arm stale SSH handler or retry snapshots after reload', async () => { + vi.useFakeTimers() + const sender = new Sender(1) + const args = { worktreePath: '/folder', connectionId: 'ssh' } + watchRemote.mockRejectedValueOnce(new Error('temporary unavailable')) + await watch(sender, args) + const retry = deferred<() => void>() + watchRemote.mockReturnValueOnce(retry.promise) + await vi.advanceTimersByTimeAsync(REMOTE_WATCH_RETRY_MS) + expect(watchRemote).toHaveBeenCalledTimes(2) + sender.emit('did-navigate') + // A fresh document can ask for the same root while the old retry is settling. + const fresh = watch(sender, args) + retry.resolve(vi.fn()) + await fresh + expect(state.pendingRemoteWatcherRetries.size).toBe(0) + expect(state.remoteWatcherResyncStates.size).toBe(0) + expect(watchRemote).toHaveBeenCalledTimes(2) + }) + + it('does not re-arm an old provider snapshot into the same WebContents after replacement', async () => { + const sender = new Sender(1) + const args = { worktreePath: '/folder', connectionId: 'ssh' } + await watch(sender, args) + const pending = deferred<'unavailable'>() + const dependencies = { + install: vi.fn(() => pending.promise), + requestResync: vi.fn(), + scheduleRetry: vi.fn(), + scheduleDormant: vi.fn() + } + reinstallRemoteWatchersForConnectionCore('ssh', dependencies) + sender.emit('did-navigate') + await watch(sender, args) + pending.resolve('unavailable') + await pending.promise + await Promise.resolve() + expect(dependencies.scheduleRetry).not.toHaveBeenCalled() + expect(dependencies.requestResync).not.toHaveBeenCalled() + expect(dependencies.scheduleDormant).not.toHaveBeenCalled() + }) + + it.each(['local', 'ssh'])( + 'does not restore a replaced sibling document after an awaited %s removal recovery', + async (kind) => { + const first = new Sender(1) + const sibling = new Sender(2) + const args = { worktreePath: '/folder', ...(kind === 'ssh' ? { connectionId: 'ssh' } : {}) } + await watch(first, args) + await watch(sibling, args) + await (kind === 'ssh' + ? closeRemoteWatcherForWorktreePath('ssh', '/folder') + : closeLocalWatcherForWorktreePath('/folder')) + const install = deferred & (() => void)>() + const setup = kind === 'ssh' ? watchRemote : createLocal + setup.mockReturnValueOnce(install.promise) + const restore = + kind === 'ssh' + ? restoreRemoteWatcherAfterFailedRemoval('ssh', '/folder') + : restoreLocalWatcherAfterFailedRemoval('/folder') + await vi.waitFor(() => expect(setup).toHaveBeenCalledTimes(2)) + sibling.emit('did-navigate') + install.resolve(Object.assign(vi.fn(), localRoot())) + await restore + expect(sibling.send).not.toHaveBeenCalled() + expect(first.send).toHaveBeenCalledTimes(1) + const roots = kind === 'ssh' ? state.remoteWatchers : state.watchedRoots + expect([...roots.values()][0].listeners.size).toBe(1) + } + ) + + it('retains zero roots and listeners across 15 reloads and a renderer crash', async () => { + const sender = new Sender(1) + const closeLocal = vi.fn(async () => {}) + const closeRemote = vi.fn() + createLocal.mockImplementation(async () => localRoot(closeLocal)) + watchRemote.mockResolvedValue(closeRemote) + for (let generation = 0; generation < 16; generation++) { + await watch(sender, { worktreePath: `/folder-${generation}` }) + await watch(sender, { worktreePath: `/folder-${generation}`, connectionId: 'ssh' }) + sender.emit(generation === 15 ? 'render-process-gone' : 'did-navigate') + await Promise.resolve() + expect( + state.watchedRoots.size + state.remoteWatchers.size + state.desiredRemoteWatchers.size + ).toBe(0) + expect(sender.eventNames()).toEqual([]) + } + expect(closeLocal).toHaveBeenCalledTimes(16) + expect(closeRemote).toHaveBeenCalledTimes(16) + }) + + it('clears desired SSH roots while no provider is available and does not re-arm them on reconnect', async () => { + const sender = new Sender(1) + getProvider.mockReturnValue(undefined) + await watch(sender, { worktreePath: '/folder', connectionId: 'ssh' }) + expect(state.pendingRemoteWatcherRetries.size).toBe(1) + sender.emit('render-process-gone') + getProvider.mockReturnValue({ watch: watchRemote }) + reinstallRemoteWatchersForConnection('ssh') + expect(state.pendingRemoteWatcherRetries.size).toBe(0) + expect(state.desiredRemoteWatchers.size).toBe(0) + expect(watchRemote).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/filesystem-watcher-dormant-rearm.test.ts b/src/main/ipc/filesystem-watcher-dormant-rearm.test.ts index 7d27d4ca780..c4931cd7cb2 100644 --- a/src/main/ipc/filesystem-watcher-dormant-rearm.test.ts +++ b/src/main/ipc/filesystem-watcher-dormant-rearm.test.ts @@ -1,3 +1,4 @@ +import { senderEvents } from './filesystem-watcher-test-sender' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { handleMock, getSshFilesystemProviderMock, providerRegistrationListeners } = vi.hoisted( @@ -36,10 +37,11 @@ const DORMANT_FIRST_MS = 60_000 function createSender(id: number): { isDestroyed: () => boolean send: ReturnType + removeListener: ReturnType once: ReturnType id: number } { - return { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id } + return { isDestroyed: () => false, send: vi.fn(), ...senderEvents(), id } } describe('remote filesystem watcher dormant re-arm', () => { diff --git a/src/main/ipc/filesystem-watcher-handlers.ts b/src/main/ipc/filesystem-watcher-handlers.ts index bac74f63589..39f9ef0c184 100644 --- a/src/main/ipc/filesystem-watcher-handlers.ts +++ b/src/main/ipc/filesystem-watcher-handlers.ts @@ -1,10 +1,12 @@ import { ipcMain } from 'electron' +import { isCurrentWatcherSender } from './filesystem-watcher-sender-lifetime' import { onSshFilesystemProviderRegistered } from '../providers/ssh-filesystem-dispatch' import { watcherLifecycleState } from './filesystem-watcher-lifecycle-state' import { getRemoteWatcherKey } from './filesystem-watcher-paths' import { cancelInFlightRemoteInstallIfUnowned, forgetDesiredRemoteWatcher, + registerWatcherSenderCleanup, releaseRemoteWatchListener } from './filesystem-watcher-listener-lifecycle' import { @@ -30,6 +32,7 @@ export function registerFilesystemWatcherHandlers(): void { ipcMain.handle( 'fs:watchWorktree', async (event, args: { worktreePath: string; connectionId?: string }): Promise => { + const senderSignal = registerWatcherSenderCleanup(event.sender) if (args.connectionId) { // Why: a real new watch reopens the subsystem after closeAllWatchers latched it shut (also resets tests between cases). watcherLifecycleState.remoteWatchersClosed = false @@ -42,6 +45,9 @@ export function registerFilesystemWatcherHandlers(): void { args.connectionId, args.worktreePath ) + if (!isCurrentWatcherSender(event.sender, senderSignal)) { + return + } if (result === 'capacity') { // Why straight to the dormant backoff: the cap is full until some other root is released, // which a 1 Hz reinstall cannot bring about — it only adds relay load per refused root. diff --git a/src/main/ipc/filesystem-watcher-large-batch.test.ts b/src/main/ipc/filesystem-watcher-large-batch.test.ts index 3d71581595e..240ca629042 100644 --- a/src/main/ipc/filesystem-watcher-large-batch.test.ts +++ b/src/main/ipc/filesystem-watcher-large-batch.test.ts @@ -1,3 +1,4 @@ +import { createWatcherSender } from './filesystem-watcher-test-sender' import { join, resolve } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -68,10 +69,7 @@ describe('local filesystem watcher large batches', () => { return { unsubscribe: vi.fn() } as never }) - await handlers['fs:watchWorktree']( - { sender: { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } }, - { worktreePath } - ) + await handlers['fs:watchWorktree']({ sender: createWatcherSender(1) }, { worktreePath }) const events = Array.from({ length: 200_000 }, (_, index): WatcherEvent => ({ type: 'delete', @@ -92,7 +90,7 @@ describe('local filesystem watcher large batches', () => { return { unsubscribe: vi.fn() } as never }) const worktreePath = resolve('/tmp/repo') - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const sender = createWatcherSender(1) await handlers['fs:watchWorktree']({ sender }, { worktreePath }) watcherCallback?.( @@ -124,7 +122,7 @@ describe('local filesystem watcher large batches', () => { }) const worktreePath = resolve('/tmp/repo') const filePath = join(worktreePath, 'a.ts') - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const sender = createWatcherSender(1) await handlers['fs:watchWorktree']({ sender }, { worktreePath }) watcherCallback?.(null, [{ type: 'update', path: filePath }]) @@ -152,7 +150,7 @@ describe('local filesystem watcher large batches', () => { return { unsubscribe: vi.fn() } as never }) const worktreePath = resolve('/tmp/repo') - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const sender = createWatcherSender(1) await handlers['fs:watchWorktree']({ sender }, { worktreePath }) // Step under the trailing window so only the max wait can force a flush. diff --git a/src/main/ipc/filesystem-watcher-lifecycle-state.ts b/src/main/ipc/filesystem-watcher-lifecycle-state.ts index 7d6a120c0a7..feef5d40373 100644 --- a/src/main/ipc/filesystem-watcher-lifecycle-state.ts +++ b/src/main/ipc/filesystem-watcher-lifecycle-state.ts @@ -65,7 +65,7 @@ export const watcherLifecycleState = { watchedRoots: new Map(), unwatchableRoots: new Set(), // Why: key cleanup by sender WebContents (not per root) to avoid MaxListeners warnings when a workspace has many worktrees open. - senderCleanupRegistered: new Set(), + senderLifetimes: new Map void }>(), pendingTeardowns: new Map>(), // Why: @parcel/watcher unsubscribe does native async work that sender-destroy can start before shutdown, so will-quit must still await it. pendingLocalUnsubscribes: new Set>(), diff --git a/src/main/ipc/filesystem-watcher-listener-lifecycle.ts b/src/main/ipc/filesystem-watcher-listener-lifecycle.ts index 713305069e3..c2dd5f3dc77 100644 --- a/src/main/ipc/filesystem-watcher-listener-lifecycle.ts +++ b/src/main/ipc/filesystem-watcher-listener-lifecycle.ts @@ -8,6 +8,7 @@ import { watcherLifecycleState } from './filesystem-watcher-lifecycle-state' import { cancelLocalBatchFlush } from './filesystem-watcher-batch-control' +import { captureWatcherSenderLifetime } from './filesystem-watcher-sender-lifetime' export function rememberUnwatchableRoot(rootKey: string): void { const { unwatchableRoots } = watcherLifecycleState @@ -172,13 +173,8 @@ export function releaseRemoteWatchListener(key: string, senderId: number): void watcherLifecycleState.remoteWatchers.delete(key) } -export function registerWatcherSenderCleanup(sender: WebContents): void { - if (watcherLifecycleState.senderCleanupRegistered.has(sender.id)) { - return - } - watcherLifecycleState.senderCleanupRegistered.add(sender.id) - sender.once('destroyed', () => { - watcherLifecycleState.senderCleanupRegistered.delete(sender.id) +export function registerWatcherSenderCleanup(sender: WebContents): AbortSignal { + return captureWatcherSenderLifetime(sender, () => { cleanupLocalWatchersForSender(sender.id) cleanupRemoteWatchersForSender(sender.id) }) @@ -215,6 +211,9 @@ function cleanupRemoteWatchersForSender(senderId: number): void { for (const key of Array.from(watcherLifecycleState.desiredRemoteWatchers.keys())) { forgetDesiredRemoteWatcher(key, senderId) } + for (const resync of watcherLifecycleState.remoteWatcherResyncStates.values()) { + resync.listeners.delete(senderId) + } for (const [key, suspended] of watcherLifecycleState.suspendedRemoteWatcherListeners) { suspended.listeners.delete(senderId) if (suspended.listeners.size === 0) { diff --git a/src/main/ipc/filesystem-watcher-local-events.test.ts b/src/main/ipc/filesystem-watcher-local-events.test.ts index b5ec3eab939..343ee2bc3ad 100644 --- a/src/main/ipc/filesystem-watcher-local-events.test.ts +++ b/src/main/ipc/filesystem-watcher-local-events.test.ts @@ -336,7 +336,7 @@ describe('local filesystem watcher flush serialization', () => { // Why real timers: fake-timers' refresh() revives a cleared handle, but Node's is a no-op — the bug only shows on real Timeouts. vi.useRealTimers() statMock.mockResolvedValue({ isDirectory: () => true }) - const listener = { ...sender, id: 7, once: vi.fn() } + const listener = { ...sender, id: 7, removeListener: vi.fn(), once: vi.fn() } try { await subscribeLocalWatcher('/repo', listener as never) watcherCallback?.(null, [{ type: 'delete', path: '/repo/file.ts' }]) diff --git a/src/main/ipc/filesystem-watcher-local-install.ts b/src/main/ipc/filesystem-watcher-local-install.ts index 27cfc6dafcf..806ab75b82d 100644 --- a/src/main/ipc/filesystem-watcher-local-install.ts +++ b/src/main/ipc/filesystem-watcher-local-install.ts @@ -27,48 +27,50 @@ export async function installLocalWatcher( ): Promise { let root: WatchedRoot try { - const s = await stat(rootPath) - if (!s.isDirectory()) { - console.warn(`[filesystem-watcher] not a directory: ${rootKey}`) + try { + const s = await stat(rootPath) + if (!s.isDirectory()) { + console.warn(`[filesystem-watcher] not a directory: ${rootKey}`) + rememberUnwatchableRoot(rootKey) + return 'unavailable' + } + } catch { + console.warn(`[filesystem-watcher] cannot stat root: ${rootKey}`) rememberUnwatchableRoot(rootKey) return 'unavailable' } - } catch { - console.warn(`[filesystem-watcher] cannot stat root: ${rootKey}`) - rememberUnwatchableRoot(rootKey) - return 'unavailable' - } - try { - // Why: WSL paths use one snapshot subprocess inside the distro so `wsl --shutdown` can kill it; native Windows uses @parcel/watcher. - root = isWslPath(worktreePath) - ? await createWslWatcher( - rootKey, - worktreePath, - { - ignoreDirs: WATCHER_IGNORE_DIRS, - scheduleBatchFlush: scheduleLocalBatchFlush, - watchedRoots: watcherLifecycleState.watchedRoots - }, - cancelToken.abortController.signal - ) - : await createLocalWatcher(rootKey, rootPath, cancelToken.abortController.signal) - } catch (error) { - // Why: setup can fail after its child misses the exit deadline; retain that owner even when the renderer-facing error is swallowed. - retainLocalWatcherPhysicalFailure(rootKey, error) - if (cancelToken.cancelled) { - if (isWatcherProcessFailure(error) && error.code === 'process_unavailable') { - throw error + try { + // Why: WSL paths use one snapshot subprocess inside the distro so `wsl --shutdown` can kill it; native Windows uses @parcel/watcher. + root = isWslPath(worktreePath) + ? await createWslWatcher( + rootKey, + worktreePath, + { + ignoreDirs: WATCHER_IGNORE_DIRS, + scheduleBatchFlush: scheduleLocalBatchFlush, + watchedRoots: watcherLifecycleState.watchedRoots + }, + cancelToken.abortController.signal + ) + : await createLocalWatcher(rootKey, rootPath, cancelToken.abortController.signal) + } catch (error) { + // Why: setup can fail after its child misses the exit deadline; retain that owner even when the renderer-facing error is swallowed. + retainLocalWatcherPhysicalFailure(rootKey, error) + if (cancelToken.cancelled) { + if (isWatcherProcessFailure(error) && error.code === 'process_unavailable') { + throw error + } + return 'cancelled' } - return 'cancelled' + // Why: capacity is transient — allow retry once another child exits instead of caching this root as permanently failed. + if (error instanceof WatcherChildCapacityError) { + scheduleCapacityRetry(cancelToken.listeners) + return 'capacity' + } + rememberUnwatchableRoot(rootKey) + return 'unavailable' } - // Why: capacity is transient — allow retry once another child exits instead of caching this root as permanently failed. - if (error instanceof WatcherChildCapacityError) { - scheduleCapacityRetry(cancelToken.listeners) - return 'capacity' - } - rememberUnwatchableRoot(rootKey) - return 'unavailable' } finally { if (watcherLifecycleState.inFlightLocalInstalls.get(rootKey) === cancelToken) { watcherLifecycleState.inFlightLocalInstalls.delete(rootKey) diff --git a/src/main/ipc/filesystem-watcher-local-removal.ts b/src/main/ipc/filesystem-watcher-local-removal.ts index a3d4b6ff558..78f015f7b94 100644 --- a/src/main/ipc/filesystem-watcher-local-removal.ts +++ b/src/main/ipc/filesystem-watcher-local-removal.ts @@ -1,3 +1,4 @@ +import { isCurrentWatcherSender } from './filesystem-watcher-sender-lifetime' import type { WebContents } from 'electron' import type { FsChangedPayload } from '../../shared/filesystem-entry-types' import { @@ -10,6 +11,7 @@ import { getLocalWatcherRoot } from './filesystem-watcher-paths' import { watcherLifecycleState } from './filesystem-watcher-lifecycle-state' import { abandonLocalUnsubscribes, + registerWatcherSenderCleanup, clearLocalCapacityRetry, trackDetachedLocalUnsubscribe } from './filesystem-watcher-listener-lifecycle' @@ -124,29 +126,40 @@ export async function restoreLocalWatcherAfterFailedRemoval(worktreePath: string return } watcherLifecycleState.suspendedLocalWatcherListeners.delete(rootKey) - const failures: unknown[] = [] - const failedListeners = new Map() - for (const sender of suspended.listeners.values()) { - if (sender.isDestroyed()) { + const failures: { sender: WebContents; signal: AbortSignal; error: unknown }[] = [] + const owners = Array.from(suspended.listeners.values(), (sender) => ({ + sender, + signal: registerWatcherSenderCleanup(sender) + })) + for (const { sender, signal } of owners) { + if (!isCurrentWatcherSender(sender, signal)) { continue } try { - await subscribeLocalWatcher(suspended.worktreePath, sender) + await subscribeLocalWatcher(suspended.worktreePath, sender, undefined, signal) + if (!isCurrentWatcherSender(sender, signal)) { + continue + } sender.send('fs:changed', { worktreePath: suspended.worktreePath, events: [{ kind: 'overflow', absolutePath: suspended.worktreePath }] } satisfies FsChangedPayload) } catch (error) { - failures.push(error) - failedListeners.set(sender.id, sender) + if (!isCurrentWatcherSender(sender, signal)) { + continue + } + failures.push({ sender, signal, error }) } } - if (failures.length > 0) { + const liveFailures = failures.filter(({ sender, signal }) => + isCurrentWatcherSender(sender, signal) + ) + if (liveFailures.length > 0) { watcherLifecycleState.suspendedLocalWatcherListeners.set(rootKey, { worktreePath: suspended.worktreePath, - listeners: failedListeners + listeners: new Map(liveFailures.map(({ sender }) => [sender.id, sender])) }) - throw failures[0] + throw liveFailures[0].error } } diff --git a/src/main/ipc/filesystem-watcher-local-subscription.ts b/src/main/ipc/filesystem-watcher-local-subscription.ts index e888893dbf4..c667210078a 100644 --- a/src/main/ipc/filesystem-watcher-local-subscription.ts +++ b/src/main/ipc/filesystem-watcher-local-subscription.ts @@ -11,21 +11,25 @@ import { addLocalWatchListener, clearLocalCapacityRetry, rememberUnwatchableRoot, + registerWatcherSenderCleanup, takeLocalCapacityRetryListeners, trackDetachedLocalUnsubscribe } from './filesystem-watcher-listener-lifecycle' import { cancelLocalBatchFlush } from './filesystem-watcher-batch-control' import { scheduleLocalCapacityRetry } from './filesystem-watcher-local-capacity' import { installLocalWatcher } from './filesystem-watcher-local-install' +import { isCurrentWatcherSender } from './filesystem-watcher-sender-lifetime' // ── Subscribe / Unsubscribe ────────────────────────────────────────── export async function subscribeLocalWatcher( worktreePath: string, sender: WebContents, - generation = watcherLifecycleState.localWatcherLifecycleGeneration + generation = watcherLifecycleState.localWatcherLifecycleGeneration, + senderSignal = registerWatcherSenderCleanup(sender) ): Promise { if ( + !isCurrentWatcherSender(sender, senderSignal) || watcherLifecycleState.localWatchersClosed || generation !== watcherLifecycleState.localWatcherLifecycleGeneration ) { @@ -33,7 +37,7 @@ export async function subscribeLocalWatcher( } const finishInstall = beginWatcherInstall(worktreePath) try { - await subscribeWhileRemovalAllowed(worktreePath, sender, generation) + await subscribeWhileRemovalAllowed(worktreePath, sender, generation, senderSignal) } finally { finishInstall() } @@ -42,9 +46,11 @@ export async function subscribeLocalWatcher( async function subscribeWhileRemovalAllowed( worktreePath: string, sender: WebContents, - generation: number + generation: number, + senderSignal: AbortSignal ): Promise { if ( + !isCurrentWatcherSender(sender, senderSignal) || watcherLifecycleState.localWatchersClosed || generation !== watcherLifecycleState.localWatcherLifecycleGeneration ) { @@ -70,6 +76,9 @@ async function subscribeWhileRemovalAllowed( watcherLifecycleState.pendingTeardowns.delete(rootKey) } const capacityRetryListeners = takeLocalCapacityRetryListeners(rootKey) + const retrySignals = new Map( + capacityRetryListeners.map((listener) => [listener.id, registerWatcherSenderCleanup(listener)]) + ) if (root) { for (const listener of capacityRetryListeners) { @@ -91,6 +100,10 @@ async function subscribeWhileRemovalAllowed( } } const result = await pendingInstall + const liveCapacityListeners = capacityRetryListeners.filter((listener) => + isCurrentWatcherSender(listener, retrySignals.get(listener.id)!) + ) + const senderIsCurrent = isCurrentWatcherSender(sender, senderSignal) if ( result === 'cancelled' && !canJoinInstall && @@ -102,33 +115,42 @@ async function subscribeWhileRemovalAllowed( watcherLifecycleState.pendingLocalInstallPromises.delete(rootKey) } const retryListeners = new Map( - capacityRetryListeners.map((listener) => [listener.id, listener]) + liveCapacityListeners.map((listener) => [listener.id, listener]) ) - retryListeners.set(sender.id, sender) + if (senderIsCurrent) { + retryListeners.set(sender.id, sender) + } for (const listener of retryListeners.values()) { if (!listener.isDestroyed()) { - await subscribeWhileRemovalAllowed(worktreePath, listener, generation) + await subscribeWhileRemovalAllowed( + worktreePath, + listener, + generation, + listener === sender ? senderSignal : retrySignals.get(listener.id)! + ) } } return } if (!inFlight) { if (result === 'installed') { - for (const listener of capacityRetryListeners) { + for (const listener of liveCapacityListeners) { addLocalWatchListener(rootKey, listener) } } else if (result === 'capacity') { const retryListeners = new Map( - capacityRetryListeners.map((listener) => [listener.id, listener]) + liveCapacityListeners.map((listener) => [listener.id, listener]) ) - retryListeners.set(sender.id, sender) + if (senderIsCurrent) { + retryListeners.set(sender.id, sender) + } scheduleLocalCapacityRetry(rootKey, worktreePath, retryListeners, subscribeLocalWatcher) } } if ( result === 'installed' && watcherLifecycleState.watchedRoots.has(rootKey) && - !sender.isDestroyed() && + senderIsCurrent && (!inFlight || inFlight.listeners.has(sender.id)) ) { addLocalWatchListener(rootKey, sender) diff --git a/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts b/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts index eb161c1ba30..da8b92dbcf8 100644 --- a/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts +++ b/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts @@ -1,3 +1,4 @@ +import { createWatcherSender } from './filesystem-watcher-test-sender' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as ParcelWatcherProcess from './parcel-watcher-process' @@ -85,6 +86,7 @@ describe('local filesystem watcher unsubscribe cleanup', () => { const sender = { isDestroyed: () => false, send: vi.fn(), + removeListener: vi.fn(), once: vi.fn((event: string, callback: () => void) => { if (event === 'destroyed') { destroyedCallbacks.push(callback) @@ -124,7 +126,7 @@ describe('local filesystem watcher unsubscribe cleanup', () => { watcherCallback = callback as typeof watcherCallback return { unsubscribe: unsubscribeMock } as never }) - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const sender = createWatcherSender(1) await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/repo' }) watcherCallback(new Error('root disappeared'), []) @@ -157,6 +159,7 @@ describe('local filesystem watcher unsubscribe cleanup', () => { const sender = { isDestroyed: () => false, send: vi.fn(), + removeListener: vi.fn(), once: vi.fn((event: string, callback: () => void) => { if (event === 'destroyed') { destroyedCallbacks.push(callback) @@ -197,18 +200,8 @@ describe('local filesystem watcher unsubscribe cleanup', () => { subscribeResolvers.push(resolve as (subscription: { unsubscribe: () => void }) => void) }) ) - const senderOne = { - isDestroyed: () => false, - send: vi.fn(), - once: vi.fn(), - id: 1 - } - const senderTwo = { - isDestroyed: () => false, - send: vi.fn(), - once: vi.fn(), - id: 2 - } + const senderOne = createWatcherSender(1) + const senderTwo = createWatcherSender(2) const watchOne = handlers['fs:watchWorktree']( { sender: senderOne }, @@ -248,12 +241,7 @@ describe('local filesystem watcher unsubscribe cleanup', () => { vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) const unsubscribeMock = vi.fn() vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: unsubscribeMock } as never) - const sender = { - isDestroyed: () => false, - send: vi.fn(), - once: vi.fn(), - id: 1 - } + const sender = createWatcherSender(1) await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/repo' }) @@ -274,12 +262,7 @@ describe('local filesystem watcher unsubscribe cleanup', () => { vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) const unsubscribeMock = vi.fn() vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: unsubscribeMock } as never) - const sender = { - isDestroyed: () => false, - send: vi.fn(), - once: vi.fn(), - id: 1 - } + const sender = createWatcherSender(1) await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/repo' }) await closeLocalWatcherForWorktreePath('/tmp/repo') @@ -294,12 +277,7 @@ describe('local filesystem watcher unsubscribe cleanup', () => { vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) const unsubscribeMock = vi.fn() vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: unsubscribeMock } as never) - const sender = { - isDestroyed: () => false, - send: vi.fn(), - once: vi.fn(), - id: 1 - } + const sender = createWatcherSender(1) await handlers['fs:watchWorktree']({ sender }, { worktreePath: watchPath }) await closeLocalWatcherForWorktreePath(closePath) @@ -323,12 +301,7 @@ describe('local filesystem watcher unsubscribe cleanup', () => { watcherCallback = callback as typeof watcherCallback return { unsubscribe: unsubscribeMock } as never }) - const sender = { - isDestroyed: () => false, - send: vi.fn(), - once: vi.fn(), - id: 1 - } + const sender = createWatcherSender(1) await handlers['fs:watchWorktree']({ sender }, { worktreePath: watchPath }) watcherCallback(null, [{ type: 'update', path: `${watchPath}\\file.txt` }]) @@ -349,7 +322,7 @@ describe('local filesystem watcher unsubscribe cleanup', () => { const terminationError = new Error('watcher child did not exit') const unsubscribeMock = vi.fn().mockRejectedValue(terminationError) vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: unsubscribeMock } as never) - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const sender = createWatcherSender(1) await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/repo' }) @@ -372,6 +345,7 @@ describe('local filesystem watcher unsubscribe cleanup', () => { const sender = { isDestroyed: () => false, send: vi.fn(), + removeListener: vi.fn(), once: vi.fn((event: string, callback: () => void) => { if (event === 'destroyed') { destroyedCallbacks.push(callback) @@ -413,6 +387,7 @@ describe('local filesystem watcher unsubscribe cleanup', () => { const sender = { isDestroyed: () => false, send: vi.fn(), + removeListener: vi.fn(), once: vi.fn((event: string, callback: () => void) => { if (event === 'destroyed') { destroyedCallbacks.push(callback) @@ -448,7 +423,7 @@ describe('local filesystem watcher unsubscribe cleanup', () => { watcherCallback = callback as typeof watcherCallback return { unsubscribe } }) - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const sender = createWatcherSender(1) await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/repo' }) watcherCallback(terminationError, []) @@ -478,7 +453,7 @@ describe('local filesystem watcher unsubscribe cleanup', () => { hooks?.signal?.addEventListener('abort', () => reject(terminationError), { once: true }) }) ) - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const sender = createWatcherSender(1) const watchPromise = handlers['fs:watchWorktree']( { sender }, { worktreePath: '/tmp/repo' } @@ -499,12 +474,7 @@ describe('local filesystem watcher unsubscribe cleanup', () => { vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) const unsubscribeMock = vi.fn() vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: unsubscribeMock } as never) - const sender = { - isDestroyed: () => false, - send: vi.fn(), - once: vi.fn(), - id: 1 - } + const sender = createWatcherSender(1) await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/repo' }) @@ -532,12 +502,7 @@ describe('local filesystem watcher unsubscribe cleanup', () => { resolveSubscribe = resolve as typeof resolveSubscribe }) ) - const sender = { - isDestroyed: () => false, - send: vi.fn(), - once: vi.fn(), - id: 1 - } + const sender = createWatcherSender(1) const watchPromise = handlers['fs:watchWorktree']( { sender }, @@ -581,8 +546,8 @@ describe('local filesystem watcher unsubscribe cleanup', () => { replacementCallback = callback return { unsubscribe: replacementUnsubscribe } }) - const firstSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } - const replacementSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const firstSender = createWatcherSender(1) + const replacementSender = createWatcherSender(2) const firstWatch = handlers['fs:watchWorktree']( { sender: firstSender }, @@ -629,9 +594,9 @@ describe('local filesystem watcher unsubscribe cleanup', () => { .mockImplementationOnce(install as never) .mockImplementationOnce(install as never) const lateUnsubscribe = vi.fn() - const firstSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } - const joinerSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } - const reopenSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 3 } + const firstSender = createWatcherSender(1) + const joinerSender = createWatcherSender(2) + const reopenSender = createWatcherSender(3) const first = handlers['fs:watchWorktree']( { sender: firstSender }, { worktreePath: '/tmp/repo' } @@ -675,12 +640,7 @@ describe('local filesystem watcher unsubscribe cleanup', () => { resolveSubscribe = resolve as typeof resolveSubscribe }) ) - const sender = { - isDestroyed: () => false, - send: vi.fn(), - once: vi.fn(), - id: 1 - } + const sender = createWatcherSender(1) const watchPromise = handlers['fs:watchWorktree']( { sender }, @@ -708,12 +668,7 @@ describe('local filesystem watcher unsubscribe cleanup', () => { vi.mocked(subscribeParcelWatcher) .mockResolvedValueOnce({ unsubscribe: firstUnsubscribe } as never) .mockResolvedValueOnce({ unsubscribe: replacementUnsubscribe } as never) - const sender = { - isDestroyed: () => false, - send: vi.fn(), - once: vi.fn(), - id: 1 - } + const sender = createWatcherSender(1) await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/repo' }) await closeLocalWatcherForWorktreePath('/tmp/repo') @@ -731,7 +686,7 @@ describe('local filesystem watcher unsubscribe cleanup', () => { vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) const firstUnsubscribe = vi.fn() vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: firstUnsubscribe } as never) - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const sender = createWatcherSender(1) await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/repo' }) await closeLocalWatcherForWorktreePath('/tmp/repo') @@ -751,6 +706,7 @@ describe('local filesystem watcher unsubscribe cleanup', () => { const sender = { isDestroyed: () => false, send: vi.fn(), + removeListener: vi.fn(), once: vi.fn((event: string, callback: () => void) => { if (event === 'destroyed') { destroyedCallbacks.push(callback) @@ -779,12 +735,7 @@ describe('local filesystem watcher unsubscribe cleanup', () => { resolveSubscribe = resolve as typeof resolveSubscribe }) ) - const sender = { - isDestroyed: () => false, - send: vi.fn(), - once: vi.fn(), - id: 1 - } + const sender = createWatcherSender(1) const watchPromise = handlers['fs:watchWorktree']( { sender }, diff --git a/src/main/ipc/filesystem-watcher-native-capacity.test.ts b/src/main/ipc/filesystem-watcher-native-capacity.test.ts index 670b7eb2af3..9d291f7a9ee 100644 --- a/src/main/ipc/filesystem-watcher-native-capacity.test.ts +++ b/src/main/ipc/filesystem-watcher-native-capacity.test.ts @@ -1,3 +1,4 @@ +import { createWatcherSender } from './filesystem-watcher-test-sender' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { handleMock, statMock, subscribeViaWatcherProcessMock, disposeWatcherProcessMock } = @@ -71,7 +72,7 @@ describe('native filesystem watcher capacity recovery', () => { subscribeViaWatcherProcessMock .mockRejectedValueOnce(new WatcherChildCapacityError()) .mockResolvedValueOnce({ unsubscribe }) - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const sender = createWatcherSender(1) const args = { worktreePath: '/tmp/native-capacity-root' } await handlers['fs:watchWorktree']({ sender }, args) @@ -86,7 +87,7 @@ describe('native filesystem watcher capacity recovery', () => { it('cancels the native capacity wait when its renderer unwatches', async () => { const releases = fillWatcherChildCapacity() subscribeViaWatcherProcessMock.mockRejectedValue(new WatcherChildCapacityError()) - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const sender = createWatcherSender(1) const args = { worktreePath: '/tmp/native-capacity-root' } await handlers['fs:watchWorktree']({ sender }, args) diff --git a/src/main/ipc/filesystem-watcher-real.test.ts b/src/main/ipc/filesystem-watcher-real.test.ts index 7c9556e7ba4..f884e596f72 100644 --- a/src/main/ipc/filesystem-watcher-real.test.ts +++ b/src/main/ipc/filesystem-watcher-real.test.ts @@ -1,3 +1,4 @@ +import { createWatcherSender } from './filesystem-watcher-test-sender' /* * Real (unmocked) @parcel/watcher integration test. * @@ -76,15 +77,6 @@ describe('filesystem-watcher real @parcel/watcher integration', () => { vi.clearAllMocks() }) - // Why: a clear, separate failure mode — if the native addon is missing from - // the bundle the watcher silently no-ops (doInstallLocalWatcher swallows the - // import error), so the wiring assertion below would time out with a vague - // message. This makes "addon absent" distinct from "wiring broken". - it('loads the real @parcel/watcher native addon', async () => { - const watcher = await import('@parcel/watcher') - expect(typeof watcher.subscribe).toBe('function') - }) - // Why: this integration targets the Linux native watcher path described // above; macOS developer sandboxes can load the addon while suppressing // subscribe callbacks, which makes this an environment check instead. @@ -95,12 +87,7 @@ describe('filesystem-watcher real @parcel/watcher integration', () => { // tmpdir() returns /var, so compare canonical paths instead of aliases. tempDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-fswatch-real-'))) const sendMock = vi.fn() - const sender = { - isDestroyed: () => false, - send: sendMock, - once: vi.fn(), - id: 1 - } + const sender = createWatcherSender(1, sendMock) // Subscribe resolves only after the native watcher is installed. await handlers['fs:watchWorktree']({ sender }, { worktreePath: tempDir }) @@ -139,7 +126,7 @@ describe('filesystem-watcher real @parcel/watcher integration', () => { await mkdir(join(root.realRoot, 'src'), { recursive: true }) const sendMock = vi.fn() - const sender = { isDestroyed: () => false, send: sendMock, once: vi.fn(), id: 1 } + const sender = createWatcherSender(1, sendMock) await handlers['fs:watchWorktree']({ sender }, { worktreePath: root.aliasRoot }) const expectedPath = join(root.aliasRoot, 'src', 'agent-edit.ts') diff --git a/src/main/ipc/filesystem-watcher-remote-batch.test.ts b/src/main/ipc/filesystem-watcher-remote-batch.test.ts index de1f4fb3d37..fc4566b97c9 100644 --- a/src/main/ipc/filesystem-watcher-remote-batch.test.ts +++ b/src/main/ipc/filesystem-watcher-remote-batch.test.ts @@ -1,3 +1,4 @@ +import { senderEvents } from './filesystem-watcher-test-sender' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { FsChangeEvent } from '../../shared/filesystem-entry-types' @@ -34,7 +35,13 @@ describe('remote filesystem watcher batching', () => { const watchCallbacks: WatchCallback[] = [] function makeSender(overrides: Partial<{ isDestroyed: () => boolean }> = {}) { - return { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1, ...overrides } + return { + isDestroyed: () => false, + send: vi.fn(), + ...senderEvents(), + id: 1, + ...overrides + } } beforeEach(async () => { diff --git a/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts b/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts index 60e25db9040..26140876bb5 100644 --- a/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts +++ b/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts @@ -1,3 +1,4 @@ +import { createWatcherSender } from './filesystem-watcher-test-sender' import { beforeEach, describe, expect, it, vi } from 'vitest' const { handleMock, getSshFilesystemProviderMock } = vi.hoisted(() => ({ @@ -50,8 +51,8 @@ describe('remote filesystem watcher cancellation', () => { ) getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } - const senderOne = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } - const senderTwo = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const senderOne = createWatcherSender(1) + const senderTwo = createWatcherSender(2) const first = handlers['fs:watchWorktree']({ sender: senderOne }, args) as Promise const second = handlers['fs:watchWorktree']({ sender: senderTwo }, args) as Promise @@ -101,8 +102,8 @@ describe('remote filesystem watcher cancellation', () => { }) getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } - const firstSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } - const secondSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const firstSender = createWatcherSender(1) + const secondSender = createWatcherSender(2) const first = handlers['fs:watchWorktree']({ sender: firstSender }, args) as Promise await Promise.resolve() @@ -136,6 +137,7 @@ describe('remote filesystem watcher cancellation', () => { const destroyedSender = { isDestroyed: () => false, send: vi.fn(), + removeListener: vi.fn(), once: vi.fn((event: string, callback: () => void) => { if (event === 'destroyed') { destroyedCallbacks.push(callback) @@ -156,7 +158,7 @@ describe('remote filesystem watcher cancellation', () => { installs.get('/destroyed')?.resolve(vi.fn()) await destroyedWatch - const shutdownSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const shutdownSender = createWatcherSender(2) const shutdownArgs = { worktreePath: '/shutdown', connectionId: 'conn-1' } const shutdownWatch = handlers['fs:watchWorktree']( { sender: shutdownSender }, @@ -182,8 +184,8 @@ describe('remote filesystem watcher cancellation', () => { ) getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } - const senderOne = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } - const senderTwo = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const senderOne = createWatcherSender(1) + const senderTwo = createWatcherSender(2) const first = handlers['fs:watchWorktree']({ sender: senderOne }, args) as Promise await Promise.resolve() @@ -216,8 +218,8 @@ describe('remote filesystem watcher cancellation', () => { ) getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } - const firstSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } - const secondSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const firstSender = createWatcherSender(1) + const secondSender = createWatcherSender(2) const first = handlers['fs:watchWorktree']({ sender: firstSender }, args) as Promise await Promise.resolve() @@ -259,9 +261,9 @@ describe('remote filesystem watcher cancellation', () => { const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } const reopenArgs = { worktreePath: '/home/me/other', connectionId: 'conn-1' } const lateUnwatch = vi.fn() - const firstSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } - const joinerSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } - const reopenSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 3 } + const firstSender = createWatcherSender(1) + const joinerSender = createWatcherSender(2) + const reopenSender = createWatcherSender(3) const first = handlers['fs:watchWorktree']({ sender: firstSender }, args) as Promise await Promise.resolve() diff --git a/src/main/ipc/filesystem-watcher-remote-capacity.test.ts b/src/main/ipc/filesystem-watcher-remote-capacity.test.ts index 97a8f82bf33..f67083f845d 100644 --- a/src/main/ipc/filesystem-watcher-remote-capacity.test.ts +++ b/src/main/ipc/filesystem-watcher-remote-capacity.test.ts @@ -1,3 +1,4 @@ +import { createWatcherSender } from './filesystem-watcher-test-sender' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { handleMock, getSshFilesystemProviderMock } = vi.hoisted(() => ({ @@ -58,7 +59,7 @@ describe('remote filesystem watcher capacity refusals', () => { throw new Error(WATCH_ROOT_CAPACITY_REFUSAL_MESSAGE) }) getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const sender = createWatcherSender(1) const args = { worktreePath: '/home/me/repos/one', connectionId: 'conn-capacity' } await handlers['fs:watchWorktree']({ sender }, args) @@ -78,7 +79,7 @@ describe('remote filesystem watcher capacity refusals', () => { throw new Error('Relay channel lost') }) getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const sender = createWatcherSender(2) const args = { worktreePath: '/home/me/repos/two', connectionId: 'conn-unavailable' } await handlers['fs:watchWorktree']({ sender }, args) diff --git a/src/main/ipc/filesystem-watcher-remote-controller.ts b/src/main/ipc/filesystem-watcher-remote-controller.ts index 1b6953cd292..8a4f4cc2e91 100644 --- a/src/main/ipc/filesystem-watcher-remote-controller.ts +++ b/src/main/ipc/filesystem-watcher-remote-controller.ts @@ -1,6 +1,7 @@ import type { WebContents } from 'electron' import type { RemoteWatcherInstallToken } from './filesystem-watcher-lifecycle-state' import { watcherLifecycleState } from './filesystem-watcher-lifecycle-state' +import { registerWatcherSenderCleanup } from './filesystem-watcher-listener-lifecycle' import { installRemoteWatcherCore, type RemoteWatcherTerminalErrorHandler @@ -14,14 +15,16 @@ export function installRemoteWatcher( sender: WebContents, connectionId: string, worktreePath: string, - generation = watcherLifecycleState.remoteWatcherLifecycleGeneration + generation = watcherLifecycleState.remoteWatcherLifecycleGeneration, + senderSignal = registerWatcherSenderCleanup(sender) ) { return installRemoteWatcherCore( sender, connectionId, worktreePath, handleRemoteWatcherTerminalError, - generation + generation, + senderSignal ) } diff --git a/src/main/ipc/filesystem-watcher-remote-dormant.ts b/src/main/ipc/filesystem-watcher-remote-dormant.ts index 7c168114f51..11783f7bb57 100644 --- a/src/main/ipc/filesystem-watcher-remote-dormant.ts +++ b/src/main/ipc/filesystem-watcher-remote-dormant.ts @@ -1,3 +1,5 @@ +import { isCurrentWatcherSender } from './filesystem-watcher-sender-lifetime' +import { registerWatcherSenderCleanup } from './filesystem-watcher-listener-lifecycle' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { isWatcherRemovalInProgressError } from './watcher-removal-gate' import type { @@ -74,6 +76,7 @@ async function rearmDormantRemoteWatcher( } const listeners = Array.from(desired.listeners.values()) + const signals = listeners.map(registerWatcherSenderCleanup) let results: Awaited>[] try { results = await Promise.all( @@ -84,6 +87,9 @@ async function rearmDormantRemoteWatcher( // Why: removal owns the key now and either forgets the intent or restores the watch itself. return } + if (!listeners.some((listener, index) => isCurrentWatcherSender(listener, signals[index]))) { + return + } scheduleDormantRemoteWatcherRearmCore( connectionId, worktreePath, @@ -92,10 +98,16 @@ async function rearmDormantRemoteWatcher( ) return } + if (!listeners.some((listener, index) => isCurrentWatcherSender(listener, signals[index]))) { + return + } dependencies.requestResync( key, worktreePath, - listeners.filter((_, index) => results[index] === 'installed') + listeners.filter( + (listener, index) => + results[index] === 'installed' && isCurrentWatcherSender(listener, signals[index]) + ) ) // Why: 'cancelled' means shutdown or the last listener left, so only a refusal stays dormant. if (results.some((result) => result === 'unavailable' || result === 'capacity')) { diff --git a/src/main/ipc/filesystem-watcher-remote-install.ts b/src/main/ipc/filesystem-watcher-remote-install.ts index 3ab5babbd91..b1a6bab61fa 100644 --- a/src/main/ipc/filesystem-watcher-remote-install.ts +++ b/src/main/ipc/filesystem-watcher-remote-install.ts @@ -15,6 +15,7 @@ import type { } from './filesystem-watcher-lifecycle-state' import { watcherLifecycleState } from './filesystem-watcher-lifecycle-state' import { getRemoteWatcherKey } from './filesystem-watcher-paths' +import { isCurrentWatcherSender } from './filesystem-watcher-sender-lifetime' import { addInFlightRemoteInstallListener, addRemoteWatchListener, @@ -34,10 +35,12 @@ export async function installRemoteWatcherCore( connectionId: string, worktreePath: string, onTerminalError: RemoteWatcherTerminalErrorHandler, - generation = watcherLifecycleState.remoteWatcherLifecycleGeneration + generation = watcherLifecycleState.remoteWatcherLifecycleGeneration, + senderSignal = registerWatcherSenderCleanup(sender) ): Promise { // Why: refuse installs racing in after teardown (or a waiter from an earlier lifecycle) so provider.watch() isn't called post-shutdown. if ( + !isCurrentWatcherSender(sender, senderSignal) || watcherLifecycleState.remoteWatchersClosed || generation !== watcherLifecycleState.remoteWatcherLifecycleGeneration ) { @@ -50,7 +53,8 @@ export async function installRemoteWatcherCore( connectionId, worktreePath, onTerminalError, - generation + generation, + senderSignal ) } finally { finishInstall() @@ -62,7 +66,8 @@ async function installRemoteWatcherWhileRemovalAllowed( connectionId: string, worktreePath: string, onTerminalError: RemoteWatcherTerminalErrorHandler, - generation: number + generation: number, + senderSignal: AbortSignal ): Promise { const provider = getSshFilesystemProvider(connectionId) if (!provider || sender.isDestroyed()) { @@ -85,6 +90,9 @@ async function installRemoteWatcherWhileRemovalAllowed( addInFlightRemoteInstallListener(inFlight, sender) } const result = await pendingInstall + if (!isCurrentWatcherSender(sender, senderSignal)) { + return 'cancelled' + } if ( result === 'installed' && watcherLifecycleState.remoteWatchers.has(key) && @@ -108,7 +116,8 @@ async function installRemoteWatcherWhileRemovalAllowed( connectionId, worktreePath, onTerminalError, - generation + generation, + senderSignal ) } return result diff --git a/src/main/ipc/filesystem-watcher-remote-provider-rearm.ts b/src/main/ipc/filesystem-watcher-remote-provider-rearm.ts index 3cc657c4086..5c55c537f07 100644 --- a/src/main/ipc/filesystem-watcher-remote-provider-rearm.ts +++ b/src/main/ipc/filesystem-watcher-remote-provider-rearm.ts @@ -1,3 +1,4 @@ +import { isCurrentWatcherSender } from './filesystem-watcher-sender-lifetime' import type { WebContents } from 'electron' import { isWatcherRemovalInProgressError } from './watcher-removal-gate' import type { @@ -5,7 +6,10 @@ import type { RequestRemoteWatcherResync } from './filesystem-watcher-remote-retry' import { watcherLifecycleState } from './filesystem-watcher-lifecycle-state' -import { clearDormantRemoteWatcher } from './filesystem-watcher-listener-lifecycle' +import { + clearDormantRemoteWatcher, + registerWatcherSenderCleanup +} from './filesystem-watcher-listener-lifecycle' type ScheduleRemoteWatcherRetry = ( sender: WebContents, @@ -79,24 +83,33 @@ export function reinstallRemoteWatchersForConnectionCore( watcherLifecycleState.loggedUnavailableRemoteWatchers.delete(key) const listeners = Array.from(desired.listeners.values()) + const signals = listeners.map(registerWatcherSenderCleanup) + const liveListeners = () => + listeners.filter((listener, index) => isCurrentWatcherSender(listener, signals[index])) void Promise.all( listeners.map((listener) => dependencies.install(listener, desired.connectionId, desired.worktreePath) ) ) .then((results) => { + if (liveListeners().length === 0) { + return + } // Why: events between the transport dropping and this reinstall are gone for good. dependencies.requestResync( key, desired.worktreePath, - listeners.filter((_, index) => results[index] === 'installed') + listeners.filter( + (listener, index) => + results[index] === 'installed' && isCurrentWatcherSender(listener, signals[index]) + ) ) if (results.some((result) => result === 'capacity')) { dependencies.scheduleDormant(desired.connectionId, desired.worktreePath) return } if (results.some((result) => result === 'unavailable')) { - for (const listener of listeners) { + for (const listener of liveListeners()) { dependencies.scheduleRetry( listener, desired.connectionId, @@ -111,7 +124,7 @@ export function reinstallRemoteWatchersForConnectionCore( if (isWatcherRemovalInProgressError(error)) { return } - for (const listener of listeners) { + for (const listener of liveListeners()) { dependencies.scheduleRetry( listener, desired.connectionId, diff --git a/src/main/ipc/filesystem-watcher-remote-rearm.test.ts b/src/main/ipc/filesystem-watcher-remote-rearm.test.ts index 0eecfbd82e9..056b10579a7 100644 --- a/src/main/ipc/filesystem-watcher-remote-rearm.test.ts +++ b/src/main/ipc/filesystem-watcher-remote-rearm.test.ts @@ -1,3 +1,4 @@ +import { createWatcherSender } from './filesystem-watcher-test-sender' import { beforeEach, describe, expect, it, vi } from 'vitest' const { handleMock, getSshFilesystemProviderMock, providerRegistrationListeners } = vi.hoisted( @@ -51,8 +52,8 @@ describe('remote filesystem watcher re-arm', () => { it('still resyncs when a fresh watch beat the failed reinstall to the retry slot', async () => { vi.useFakeTimers() const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const senderOne = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } - const senderTwo = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const senderOne = createWatcherSender(1) + const senderTwo = createWatcherSender(2) const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } getSshFilesystemProviderMock.mockReturnValue({ watch: vi.fn().mockResolvedValue(vi.fn()) }) diff --git a/src/main/ipc/filesystem-watcher-remote-removal.ts b/src/main/ipc/filesystem-watcher-remote-removal.ts index 0e9c741851a..a6c7f2a6636 100644 --- a/src/main/ipc/filesystem-watcher-remote-removal.ts +++ b/src/main/ipc/filesystem-watcher-remote-removal.ts @@ -1,3 +1,4 @@ +import { isCurrentWatcherSender } from './filesystem-watcher-sender-lifetime' import type { WebContents } from 'electron' import type { FsChangedPayload } from '../../shared/filesystem-entry-types' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' @@ -5,6 +6,7 @@ import { watcherLifecycleState } from './filesystem-watcher-lifecycle-state' import { getRemoteWatcherKey } from './filesystem-watcher-paths' import { clearDormantRemoteWatcher, + registerWatcherSenderCleanup, clearRemoteWatcherResync } from './filesystem-watcher-listener-lifecycle' import { @@ -71,11 +73,18 @@ export async function restoreRemoteWatcherAfterFailedRemoval( return } watcherLifecycleState.suspendedRemoteWatcherListeners.delete(key) - for (const sender of suspended.listeners.values()) { - if (sender.isDestroyed()) { + const owners = Array.from(suspended.listeners.values(), (sender) => ({ + sender, + signal: registerWatcherSenderCleanup(sender) + })) + for (const { sender, signal } of owners) { + if (!isCurrentWatcherSender(sender, signal)) { + continue + } + const result = await installRemoteWatcher(sender, connectionId, worktreePath, undefined, signal) + if (!isCurrentWatcherSender(sender, signal)) { continue } - const result = await installRemoteWatcher(sender, connectionId, worktreePath) if (result === 'capacity') { scheduleDormantRemoteWatcherRearm(connectionId, worktreePath) } else if (result === 'unavailable') { diff --git a/src/main/ipc/filesystem-watcher-remote-retry.ts b/src/main/ipc/filesystem-watcher-remote-retry.ts index d73f9ed4db4..02c797e9c75 100644 --- a/src/main/ipc/filesystem-watcher-remote-retry.ts +++ b/src/main/ipc/filesystem-watcher-remote-retry.ts @@ -1,3 +1,4 @@ +import { isCurrentWatcherSender } from './filesystem-watcher-sender-lifetime' import type { WebContents } from 'electron' import type { FsChangedPayload } from '../../shared/filesystem-entry-types' import { isWatcherRemovalInProgressError } from './watcher-removal-gate' @@ -8,7 +9,10 @@ import { watcherLifecycleState } from './filesystem-watcher-lifecycle-state' import { getRemoteWatcherKey } from './filesystem-watcher-paths' -import { clearRemoteWatcherResync } from './filesystem-watcher-listener-lifecycle' +import { + clearRemoteWatcherResync, + registerWatcherSenderCleanup +} from './filesystem-watcher-listener-lifecycle' import { isCurrentDesiredRemoteWatcher } from './filesystem-watcher-remote-desired' export type InstallRemoteWatcher = ( @@ -44,6 +48,9 @@ export function scheduleRemoteWatcherRetryCore( resyncOnInstall = false ): void { const key = getRemoteWatcherKey(connectionId, worktreePath) + if (watcherLifecycleState.remoteWatchersClosed || !isCurrentDesiredRemoteWatcher(key, sender)) { + return + } const existingRetry = watcherLifecycleState.pendingRemoteWatcherRetryListeners.get(key) if (existingRetry) { if (!sender.isDestroyed()) { @@ -89,15 +96,24 @@ export function scheduleRemoteWatcherRetryCore( const listeners = Array.from(retry.listeners.values()).filter( (listener) => !listener.isDestroyed() && isCurrentDesiredRemoteWatcher(key, listener) ) + const signals = listeners.map(registerWatcherSenderCleanup) + const liveListeners = () => + listeners.filter((listener, index) => isCurrentWatcherSender(listener, signals[index])) void Promise.all( listeners.map((listener) => dependencies.install(listener, connectionId, worktreePath)) ) .then((results) => { + if (liveListeners().length === 0) { + return + } if (retry.resyncOnInstall) { dependencies.requestResync( key, worktreePath, - listeners.filter((_, index) => results[index] === 'installed') + listeners.filter( + (listener, index) => + results[index] === 'installed' && isCurrentWatcherSender(listener, signals[index]) + ) ) } // Why capacity leaves the fast window: the relay is refusing on a full watch-root cap, and a @@ -108,7 +124,7 @@ export function scheduleRemoteWatcherRetryCore( } // Why: don't re-arm on 'cancelled' (renderer stopped watching) — it would fire a stale overflow when the 60s window expires. if (results.some((result) => result === 'unavailable')) { - for (const listener of listeners) { + for (const listener of liveListeners()) { scheduleRemoteWatcherRetryCore( listener, connectionId, @@ -124,7 +140,7 @@ export function scheduleRemoteWatcherRetryCore( if (isWatcherRemovalInProgressError(error)) { return } - for (const listener of listeners) { + for (const listener of liveListeners()) { scheduleRemoteWatcherRetryCore( listener, connectionId, diff --git a/src/main/ipc/filesystem-watcher-removal-deadline.test.ts b/src/main/ipc/filesystem-watcher-removal-deadline.test.ts index 9eabdb8964e..ca1e731c99a 100644 --- a/src/main/ipc/filesystem-watcher-removal-deadline.test.ts +++ b/src/main/ipc/filesystem-watcher-removal-deadline.test.ts @@ -1,3 +1,4 @@ +import { createWatcherSender } from './filesystem-watcher-test-sender' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as ParcelWatcherProcess from './parcel-watcher-process' @@ -84,12 +85,7 @@ describe('local filesystem watcher removal deadline', () => { // subscribe that ignores the abort signal exercises the deadline rather than the cancel path. // Once, so the wedge cannot leak into the next test. vi.mocked(subscribeViaWatcherProcess).mockImplementationOnce(() => new Promise(() => {})) - const sender = { - isDestroyed: () => false, - send: vi.fn(), - once: vi.fn(), - id: 1 - } + const sender = createWatcherSender(1) const watchPromise = handlers['fs:watchWorktree']( { sender }, @@ -132,7 +128,7 @@ describe('local filesystem watcher removal deadline', () => { }) ) vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: unsubscribeMock } as never) - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const sender = createWatcherSender(1) await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/repo' }) @@ -160,12 +156,7 @@ describe('local filesystem watcher removal deadline', () => { try { vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) vi.mocked(subscribeViaWatcherProcess).mockImplementationOnce(() => new Promise(() => {})) - const sender = { - isDestroyed: () => false, - send: vi.fn(), - once: vi.fn(), - id: 1 - } + const sender = createWatcherSender(1) const watchPromise = handlers['fs:watchWorktree']( { sender }, @@ -209,7 +200,7 @@ describe('local filesystem watcher removal deadline', () => { }) ) vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: unsubscribeMock } as never) - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const sender = createWatcherSender(1) await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/repo' }) vi.useFakeTimers() diff --git a/src/main/ipc/filesystem-watcher-sender-lifetime.ts b/src/main/ipc/filesystem-watcher-sender-lifetime.ts new file mode 100644 index 00000000000..b72948471b1 --- /dev/null +++ b/src/main/ipc/filesystem-watcher-sender-lifetime.ts @@ -0,0 +1,36 @@ +import type { WebContents } from 'electron' +import { abortWhenRendererGone } from './renderer-lifetime-abort' +import { watcherLifecycleState } from './filesystem-watcher-lifecycle-state' + +export function captureWatcherSenderLifetime( + sender: WebContents, + cleanup: () => void +): AbortSignal { + const existing = watcherLifecycleState.senderLifetimes.get(sender.id) + if (existing) { + return existing.signal + } + if (sender.isDestroyed()) { + return AbortSignal.abort() + } + const lifetime = abortWhenRendererGone(sender) + watcherLifecycleState.senderLifetimes.set(sender.id, lifetime) + lifetime.signal.addEventListener( + 'abort', + () => { + lifetime.dispose() + watcherLifecycleState.senderLifetimes.delete(sender.id) + cleanup() + }, + { once: true } + ) + return lifetime.signal +} + +export function isCurrentWatcherSender(sender: WebContents, signal: AbortSignal): boolean { + return ( + !sender.isDestroyed() && + !signal.aborted && + watcherLifecycleState.senderLifetimes.get(sender.id)?.signal === signal + ) +} diff --git a/src/main/ipc/filesystem-watcher-shutdown.ts b/src/main/ipc/filesystem-watcher-shutdown.ts index 9f9cd2de353..31a57ecab10 100644 --- a/src/main/ipc/filesystem-watcher-shutdown.ts +++ b/src/main/ipc/filesystem-watcher-shutdown.ts @@ -8,7 +8,10 @@ export async function closeAllWatchers(): Promise { // Why: drop the intent with the rest of the state, but keep the provider-registration // subscription — a new fs:watchWorktree reopens the subsystem and still needs the re-arm hook. watcherLifecycleState.desiredRemoteWatchers.clear() - watcherLifecycleState.senderCleanupRegistered.clear() + for (const lifetime of watcherLifecycleState.senderLifetimes.values()) { + lifetime.dispose() + } + watcherLifecycleState.senderLifetimes.clear() watcherLifecycleState.unwatchableRoots.clear() watcherLifecycleState.suspendedLocalWatcherListeners.clear() watcherLifecycleState.suspendedRemoteWatcherListeners.clear() diff --git a/src/main/ipc/filesystem-watcher-terminal-resync.test.ts b/src/main/ipc/filesystem-watcher-terminal-resync.test.ts index 31e87d7a4c9..e8a81d1a7ff 100644 --- a/src/main/ipc/filesystem-watcher-terminal-resync.test.ts +++ b/src/main/ipc/filesystem-watcher-terminal-resync.test.ts @@ -41,6 +41,7 @@ const OVERFLOW_PAYLOAD = { type MockSender = { isDestroyed: () => boolean send: ReturnType + removeListener: ReturnType once: ReturnType id: number destroy: () => void @@ -52,6 +53,7 @@ function createSender(id: number): MockSender { return { isDestroyed: () => destroyed, send: vi.fn(), + removeListener: vi.fn(), once: vi.fn((event: string, handler: () => void) => { if (event === 'destroyed') { destroyedHandlers.push(handler) diff --git a/src/main/ipc/filesystem-watcher-test-sender.ts b/src/main/ipc/filesystem-watcher-test-sender.ts new file mode 100644 index 00000000000..4ff5a5bbcc1 --- /dev/null +++ b/src/main/ipc/filesystem-watcher-test-sender.ts @@ -0,0 +1,18 @@ +import { vi, type Mock } from 'vitest' + +type SenderEvents = { once: Mock; removeListener: Mock } + +export function senderEvents(): SenderEvents { + return { once: vi.fn(), removeListener: vi.fn() } +} + +export function createWatcherSender( + id: number, + send: Mock = vi.fn() +): SenderEvents & { + id: number + isDestroyed: () => boolean + send: Mock +} { + return { id, isDestroyed: () => false, send, ...senderEvents() } +} diff --git a/src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts b/src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts index 3473caa8a73..78274bbe028 100644 --- a/src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts +++ b/src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts @@ -1,3 +1,5 @@ +import { createWatcherSender } from './filesystem-watcher-test-sender' +import { statSync } from 'node:fs' import { beforeEach, describe, expect, it, vi } from 'vitest' const { handleMock } = vi.hoisted(() => ({ @@ -29,6 +31,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' import { stat } from 'node:fs/promises' +import { watcherLifecycleState } from './filesystem-watcher-lifecycle-state' type HandlerMap = Record unknown> @@ -48,15 +51,31 @@ describe('filesystem watcher unwatchable root cache', () => { await closeAllWatchers() }) + it('releases the install record when the root is a file', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const sender = createWatcherSender(1) + vi.mocked(stat).mockResolvedValue(statSync(new URL(import.meta.url))) + try { + await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/not-directory' }) + expect(watcherLifecycleState.inFlightLocalInstalls.size).toBe(0) + expect(watcherLifecycleState.pendingLocalInstallPromises.size).toBe(0) + } finally { + warnSpy.mockRestore() + await closeAllWatchers() + } + }) + it('evicts oldest failed local roots while suppressing recent retries', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const sender = createWatcherSender(1) vi.mocked(stat).mockRejectedValue(new Error('missing')) for (let i = 0; i < 257; i += 1) { await handlers['fs:watchWorktree']({ sender }, { worktreePath: `/tmp/missing-${i}` }) } expect(stat).toHaveBeenCalledTimes(257) + expect(watcherLifecycleState.inFlightLocalInstalls.size).toBe(0) + expect(watcherLifecycleState.pendingLocalInstallPromises.size).toBe(0) await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/missing-0' }) expect(stat).toHaveBeenCalledTimes(258) diff --git a/src/main/ipc/filesystem-watcher.test.ts b/src/main/ipc/filesystem-watcher.test.ts index 2d9181d1155..db6fcf206e5 100644 --- a/src/main/ipc/filesystem-watcher.test.ts +++ b/src/main/ipc/filesystem-watcher.test.ts @@ -1,3 +1,4 @@ +import { createWatcherSender } from './filesystem-watcher-test-sender' import { beforeEach, describe, expect, it, vi } from 'vitest' const { handleMock, getSshFilesystemProviderMock, providerRegistrationListeners } = vi.hoisted( @@ -106,7 +107,7 @@ describe('registerFilesystemWatcherHandlers', () => { vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: vi.fn() } as never) await handlers['fs:watchWorktree']( - { sender: { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } }, + { sender: createWatcherSender(1) }, { worktreePath: 'C:\\repo' } ) @@ -140,7 +141,7 @@ describe('registerFilesystemWatcherHandlers', () => { rootPath: worktreePath } }) - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const sender = createWatcherSender(1) const args = { worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' } await expect(handlers['fs:watchWorktree']({ sender }, args)).resolves.toBeUndefined() @@ -162,7 +163,7 @@ describe('registerFilesystemWatcherHandlers', () => { reserveWatcherChild() ) vi.mocked(createWslWatcher).mockRejectedValue(new WatcherChildCapacityError()) - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const sender = createWatcherSender(1) const args = { worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' } await handlers['fs:watchWorktree']({ sender }, args) @@ -178,7 +179,7 @@ describe('registerFilesystemWatcherHandlers', () => { it('rejects installs during destructive removal and allows a retry afterward', async () => { vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: vi.fn() } as never) - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const sender = createWatcherSender(1) const removal = acquireWatcherRemovalGate('/repo') await removal.ready @@ -201,12 +202,12 @@ describe('registerFilesystemWatcherHandlers', () => { await expect( handlers['fs:watchWorktree']( - { sender: { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } }, + { sender: createWatcherSender(1) }, { worktreePath: '/home/me/repo', connectionId: 'conn-1' } ) ).resolves.toBeUndefined() await handlers['fs:watchWorktree']( - { sender: { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } }, + { sender: createWatcherSender(1) }, { worktreePath: '/home/me/repo', connectionId: 'conn-1' } ) @@ -226,7 +227,7 @@ describe('registerFilesystemWatcherHandlers', () => { vi.useFakeTimers() const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const sendMock = vi.fn() - const sender = { isDestroyed: () => false, send: sendMock, once: vi.fn(), id: 1 } + const sender = createWatcherSender(1, sendMock) const unwatchMock = vi.fn() const watchMock = vi.fn().mockResolvedValue(unwatchMock) getSshFilesystemProviderMock.mockReturnValueOnce(undefined) @@ -261,7 +262,7 @@ describe('registerFilesystemWatcherHandlers', () => { vi.useFakeTimers() const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const sendMock = vi.fn() - const sender = { isDestroyed: () => false, send: sendMock, once: vi.fn(), id: 1 } + const sender = createWatcherSender(1, sendMock) const unwatchMock = vi.fn() const retryWatchMock = vi.fn().mockResolvedValue(unwatchMock) getSshFilesystemProviderMock @@ -299,7 +300,7 @@ describe('registerFilesystemWatcherHandlers', () => { getSshFilesystemProviderMock.mockReturnValueOnce(undefined) await handlers['fs:watchWorktree']( - { sender: { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } }, + { sender: createWatcherSender(1) }, { worktreePath: '/home/me/repo', connectionId: 'conn-1' } ) @@ -315,8 +316,8 @@ describe('registerFilesystemWatcherHandlers', () => { it('retries all live renderer owners after a terminal relay watch failure', async () => { vi.useFakeTimers() const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const senderOne = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } - const senderTwo = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const senderOne = createWatcherSender(1) + const senderTwo = createWatcherSender(2) const watchMock = vi.fn().mockResolvedValue(vi.fn()) getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) @@ -354,7 +355,7 @@ describe('registerFilesystemWatcherHandlers', () => { }) it('reinstalls an SSH worktree watch when the provider is re-registered after a reconnect', async () => { - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const sender = createWatcherSender(1) const staleUnwatch = vi.fn() const watchMock = vi.fn().mockResolvedValue(staleUnwatch) getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) @@ -390,7 +391,7 @@ describe('registerFilesystemWatcherHandlers', () => { it('re-arms an SSH watch whose first install found no provider yet', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const sender = createWatcherSender(1) // A connect slower than the retry window leaves the renderer subscribed with nothing installed. getSshFilesystemProviderMock.mockReturnValue(undefined) @@ -411,7 +412,7 @@ describe('registerFilesystemWatcherHandlers', () => { it('resyncs after a reconnect whose reinstall only succeeded on a retry', async () => { vi.useFakeTimers() const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const sender = createWatcherSender(1) getSshFilesystemProviderMock.mockReturnValue({ watch: vi.fn().mockResolvedValue(vi.fn()) }) await handlers['fs:watchWorktree']( @@ -440,7 +441,7 @@ describe('registerFilesystemWatcherHandlers', () => { }) it('does not resurrect an SSH watch the renderer already unwatched', async () => { - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const sender = createWatcherSender(1) const watchMock = vi.fn().mockResolvedValue(vi.fn()) getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) @@ -462,7 +463,7 @@ describe('registerFilesystemWatcherHandlers', () => { }) it('leaves watches on other connections untouched when one provider re-registers', async () => { - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const sender = createWatcherSender(1) const watchMock = vi.fn().mockResolvedValue(vi.fn()) getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) @@ -480,8 +481,8 @@ describe('registerFilesystemWatcherHandlers', () => { }) it('reinstalls one shared watch when several senders share a re-registered connection', async () => { - const senderOne = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } - const senderTwo = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const senderOne = createWatcherSender(1) + const senderTwo = createWatcherSender(2) const watchMock = vi.fn().mockResolvedValue(vi.fn()) getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) @@ -515,8 +516,11 @@ describe('registerFilesystemWatcherHandlers', () => { const sender = { isDestroyed: () => destroyed, send: vi.fn(), - once: vi.fn((_event: string, handler: () => void) => { - destroyHandlers.push(handler) + removeListener: vi.fn(), + once: vi.fn((event: string, handler: () => void) => { + if (event === 'destroyed') { + destroyHandlers.push(handler) + } }), id: 1 } @@ -545,8 +549,8 @@ describe('registerFilesystemWatcherHandlers', () => { it('shares SSH worktree watchers across renderer senders until the last unwatch', async () => { const sendOne = vi.fn() const sendTwo = vi.fn() - const senderOne = { isDestroyed: () => false, send: sendOne, once: vi.fn(), id: 1 } - const senderTwo = { isDestroyed: () => false, send: sendTwo, once: vi.fn(), id: 2 } + const senderOne = createWatcherSender(1, sendOne) + const senderTwo = createWatcherSender(2, sendTwo) const unwatchMock = vi.fn() const watchMock = vi.fn().mockResolvedValue(unwatchMock) getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) @@ -590,7 +594,7 @@ describe('registerFilesystemWatcherHandlers', () => { const provider = { watch: vi.fn().mockResolvedValue(vi.fn()), closeWatch } getSshFilesystemProviderMock.mockReturnValue(provider) await handlers['fs:watchWorktree']( - { sender: { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } }, + { sender: createWatcherSender(1) }, { worktreePath: '/home/me/repo', connectionId: 'conn-1' } ) @@ -615,7 +619,7 @@ describe('registerFilesystemWatcherHandlers', () => { .mockResolvedValueOnce(replacementUnwatch) const closeWatch = vi.fn().mockResolvedValue(undefined) getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock, closeWatch }) - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const sender = createWatcherSender(1) await handlers['fs:watchWorktree']( { sender }, @@ -642,7 +646,7 @@ describe('registerFilesystemWatcherHandlers', () => { const watchMock = vi.fn().mockResolvedValue(vi.fn()) const closeWatch = vi.fn().mockResolvedValue(undefined) getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock, closeWatch }) - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const sender = createWatcherSender(1) await handlers['fs:watchWorktree']( { sender }, @@ -666,7 +670,7 @@ describe('registerFilesystemWatcherHandlers', () => { const watchMock = vi.fn().mockResolvedValue(firstUnwatch) const closeWatch = vi.fn().mockResolvedValue(undefined) getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock, closeWatch }) - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const sender = createWatcherSender(1) const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } await handlers['fs:watchWorktree']({ sender }, args) @@ -685,14 +689,12 @@ describe('registerFilesystemWatcherHandlers', () => { getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock, closeWatch }) const destroyedCallbacks: (() => void)[] = [] const sender = { - isDestroyed: () => false, - send: vi.fn(), + ...createWatcherSender(1), once: vi.fn((event: string, callback: () => void) => { if (event === 'destroyed') { destroyedCallbacks.push(callback) } - }), - id: 1 + }) } const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } @@ -709,8 +711,8 @@ describe('registerFilesystemWatcherHandlers', () => { it('preserves remote event routing when acknowledged teardown rejects', async () => { const sendOne = vi.fn() const sendTwo = vi.fn() - const senderOne = { isDestroyed: () => false, send: sendOne, once: vi.fn(), id: 1 } - const senderTwo = { isDestroyed: () => false, send: sendTwo, once: vi.fn(), id: 2 } + const senderOne = createWatcherSender(1, sendOne) + const senderTwo = createWatcherSender(2, sendTwo) const watchMock = vi.fn().mockResolvedValue(vi.fn()) const closeWatch = vi .fn() @@ -747,8 +749,8 @@ describe('registerFilesystemWatcherHandlers', () => { it('dedupes concurrent pending SSH worktree watcher installs', async () => { const sendOne = vi.fn() const sendTwo = vi.fn() - const senderOne = { isDestroyed: () => false, send: sendOne, once: vi.fn(), id: 1 } - const senderTwo = { isDestroyed: () => false, send: sendTwo, once: vi.fn(), id: 2 } + const senderOne = createWatcherSender(1, sendOne) + const senderTwo = createWatcherSender(2, sendTwo) const unwatchMock = vi.fn() let resolveWatch: (unwatch: () => void) => void = () => {} const watchPromise = new Promise<() => void>((resolve) => { @@ -791,8 +793,8 @@ describe('registerFilesystemWatcherHandlers', () => { it('keeps a pending SSH watcher install alive when only one pending sender unwatches', async () => { const sendOne = vi.fn() const sendTwo = vi.fn() - const senderOne = { isDestroyed: () => false, send: sendOne, once: vi.fn(), id: 1 } - const senderTwo = { isDestroyed: () => false, send: sendTwo, once: vi.fn(), id: 2 } + const senderOne = createWatcherSender(1, sendOne) + const senderTwo = createWatcherSender(2, sendTwo) const unwatchMock = vi.fn() let resolveWatch: (unwatch: () => void) => void = () => {} const watchPromise = new Promise<() => void>((resolve) => { @@ -833,14 +835,12 @@ describe('registerFilesystemWatcherHandlers', () => { it('unsubscribes if the sender is destroyed while an SSH watcher is opening', async () => { const destroyedCallbacks: (() => void)[] = [] const sender = { - isDestroyed: () => false, - send: vi.fn(), + ...createWatcherSender(1), once: vi.fn((event: string, callback: () => void) => { if (event === 'destroyed') { destroyedCallbacks.push(callback) } - }), - id: 1 + }) } const unwatchMock = vi.fn() let resolveWatch: (unwatch: () => void) => void = () => {} @@ -871,8 +871,8 @@ describe('registerFilesystemWatcherHandlers', () => { it('revives a pending SSH watcher install when a new sender joins after cancellation', async () => { const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } - const senderOne = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } - const senderTwo = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const senderOne = createWatcherSender(1) + const senderTwo = createWatcherSender(2) const unwatchMock = vi.fn() let resolveWatch!: (unwatch: () => void) => void const watchMock = vi.fn().mockReturnValue( @@ -907,14 +907,12 @@ describe('registerFilesystemWatcherHandlers', () => { it('registers one destroyed listener for many SSH worktree watches', async () => { const destroyedCallbacks: (() => void)[] = [] const sender = { - isDestroyed: () => false, - send: vi.fn(), + ...createWatcherSender(99), once: vi.fn((event: string, callback: () => void) => { if (event === 'destroyed') { destroyedCallbacks.push(callback) } - }), - id: 99 + }) } const unwatchMock = vi.fn() const watchMock = vi.fn().mockResolvedValue(unwatchMock) @@ -929,7 +927,7 @@ describe('registerFilesystemWatcherHandlers', () => { // Why: WebContents warns after 10 listeners. The cleanup work still covers // every remote watch by scanning the shared remote watcher registry. - expect(sender.once).toHaveBeenCalledTimes(1) + expect(sender.once).toHaveBeenCalledTimes(3) expect(destroyedCallbacks).toHaveLength(1) destroyedCallbacks[0]() diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index fe1568dbf72..2d452fe4ee6 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from 'node:events' import path from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import { @@ -608,38 +609,50 @@ describe('registerFilesystemHandlers', () => { // Why #7721: without a cancel path, every workspace switch left the previous // workspace's full-tree SSH scan running, stacking scans on the relay until // interactive fs.readDir/fs.stat starved past their 30s timeout. - it('fs:cancelListFiles aborts an in-flight SSH listing by request token (#7721)', async () => { - let capturedSignal: AbortSignal | undefined - const listFilesMock = vi.fn( - (_rootPath: string, options: { signal?: AbortSignal }) => - new Promise((_resolve, reject) => { - capturedSignal = options.signal - options.signal?.addEventListener('abort', () => reject(new Error('listing cancelled')), { - once: true + it.each(['cancel', 'did-navigate', 'render-process-gone', 'destroyed'])( + 'aborts an in-flight SSH file listing on %s (#7721)', + async (eventName) => { + let capturedSignal: AbortSignal | undefined + const listFilesMock = vi.fn( + (_rootPath: string, options: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + capturedSignal = options.signal + options.signal?.addEventListener( + 'abort', + () => reject(new Error('listing cancelled')), + { + once: true + } + ) }) - }) - ) - getSshFilesystemProviderMock.mockReturnValue({ listFiles: listFilesMock }) + ) + getSshFilesystemProviderMock.mockReturnValue({ listFiles: listFilesMock }) - registerFilesystemHandlers(store as never) + registerFilesystemHandlers(store as never) - // Why: cancellation keys are scoped to the issuing webContents, so the - // cancel must come from the same sender as the listing request. - const senderEvent = { sender: { id: 7 } } - const pending = handlers.get('fs:listFiles')!(senderEvent, { - rootPath: '/home/user/repo', - connectionId: 'conn-1', - requestToken: 'token-1' - }) as Promise + // Why: cancellation keys are scoped to the issuing webContents, so the + // cancel must come from the same sender as the listing request. + const senderEvent = { sender: Object.assign(new EventEmitter(), { id: 7 }) } + const pending = handlers.get('fs:listFiles')!(senderEvent, { + rootPath: '/home/user/repo', + connectionId: 'conn-1', + requestToken: 'token-1' + }) as Promise - expect(capturedSignal?.aborted).toBe(false) - await handlers.get('fs:cancelListFiles')!(senderEvent, { requestToken: 'token-1' }) - expect(capturedSignal?.aborted).toBe(true) - await expect(pending).rejects.toThrow('listing cancelled') + expect(capturedSignal?.aborted).toBe(false) + if (eventName === 'cancel') { + await handlers.get('fs:cancelListFiles')!(senderEvent, { requestToken: 'token-1' }) + } else { + senderEvent.sender.emit(eventName) + } + expect(capturedSignal?.aborted).toBe(true) + await expect(pending).rejects.toThrow('listing cancelled') + expect(senderEvent.sender.eventNames()).toEqual([]) - // Unknown or already-settled tokens are a no-op, not an error. - expect(() => - handlers.get('fs:cancelListFiles')!(senderEvent, { requestToken: 'unknown' }) - ).not.toThrow() - }) + // Unknown or already-settled tokens are a no-op, not an error. + expect(() => + handlers.get('fs:cancelListFiles')!(senderEvent, { requestToken: 'unknown' }) + ).not.toThrow() + } + ) }) diff --git a/src/main/ipc/filesystem/filesystem-download-handlers.ts b/src/main/ipc/filesystem/filesystem-download-handlers.ts index 6b73df909da..b542b235a48 100644 --- a/src/main/ipc/filesystem/filesystem-download-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-download-handlers.ts @@ -5,6 +5,7 @@ import { getRuntimePathBasename } from '../../../shared/cross-platform-path' import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' import { sanitizeLocalDownloadFilename } from '../../local-download-filename' import { registerFilesystemDownloadFolderHandlers } from '../filesystem-download-folder' +import { abortWhenRendererGone } from '../renderer-lifetime-abort' import type { FilesystemHandlerContext } from './filesystem-handler-context' import { cleanupLocalTransferPath, @@ -24,7 +25,7 @@ function validateRequiredString(value: unknown, label: string): string { } export function registerFilesystemDownloadHandlers(context: FilesystemHandlerContext): void { - const { downloadSessions, closeDownloadSession, cleanupDownloadSessionsForSender } = context + const { downloadSessions, closeDownloadSession } = context ipcMain.handle( 'fs:downloadFile', @@ -122,21 +123,42 @@ export function registerFilesystemDownloadHandlers(context: FilesystemHandlerCon const suggestedName = sanitizeLocalDownloadFilename( validateRequiredString(args?.suggestedName, 'suggestedName') ) - const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined - const dialogResult = parentWindow - ? await dialog.showSaveDialog(parentWindow, { defaultPath: suggestedName }) - : await dialog.showSaveDialog({ defaultPath: suggestedName }) - if (dialogResult.canceled || !dialogResult.filePath) { + if (event.sender.isDestroyed()) { return { canceled: true } } - - const destinationPath = dialogResult.filePath - const { existed } = await inspectDownloadDestination(destinationPath) - const tempPath = createSiblingTransferPath(destinationPath, 'download') + const lifetime = abortWhenRendererGone(event.sender) const transferId = randomUUID() + const disposeRendererLifetime = (): void => { + lifetime.signal.removeEventListener('abort', onRendererGone) + lifetime.dispose() + } + const onRendererGone = (): void => { + disposeRendererLifetime() + void closeDownloadSession(transferId, true) + } + lifetime.signal.addEventListener('abort', onRendererGone, { once: true }) + let admitted = false + let tempPath: string | null = null + let handle: Awaited> | null = null try { - const handle = await open(tempPath, 'wx') - const senderId = typeof event.sender.id === 'number' ? event.sender.id : Number.NaN + const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined + const dialogResult = parentWindow + ? await dialog.showSaveDialog(parentWindow, { defaultPath: suggestedName }) + : await dialog.showSaveDialog({ defaultPath: suggestedName }) + if (lifetime.signal.aborted || dialogResult.canceled || !dialogResult.filePath) { + return { canceled: true } + } + + const destinationPath = dialogResult.filePath + const { existed } = await inspectDownloadDestination(destinationPath) + if (lifetime.signal.aborted) { + return { canceled: true } + } + tempPath = createSiblingTransferPath(destinationPath, 'download') + handle = await open(tempPath, 'wx') + if (lifetime.signal.aborted) { + return { canceled: true } + } const cleanupTimer = setTimeout(() => { void closeDownloadSession(transferId, true) }, DOWNLOAD_SESSION_TTL_MS) @@ -149,13 +171,16 @@ export function registerFilesystemDownloadHandlers(context: FilesystemHandlerCon destinationExisted: existed, handle, cleanupTimer, - senderId + disposeRendererLifetime }) - event.sender.once?.('destroyed', () => cleanupDownloadSessionsForSender(senderId)) + admitted = true return { canceled: false, transferId, destinationPath } - } catch (error) { - await cleanupLocalTransferPath(tempPath) - throw error + } finally { + if (!admitted) { + disposeRendererLifetime() + await handle?.close().catch(() => {}) + await cleanupLocalTransferPath(tempPath) + } } } ) diff --git a/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts b/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts index 1c16a119b3b..2361bbb4f4c 100644 --- a/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts @@ -89,6 +89,8 @@ export function registerFilesystemGitPullRequestGenerationHandlers( repoPath: args.worktreePath, connectionId: args.connectionId }) + // Preparation can return before this lookup settles; retain its error for the later await. + void linkedIssueDetailsPromise.catch(() => undefined) let context: Awaited> try { const currentBody = await resolveHostedReviewBodyForGeneration({ @@ -151,6 +153,8 @@ export function registerFilesystemGitPullRequestGenerationHandlers( connectionId: args.connectionId, localGitOptions: gitOptions }) + // Preparation can return before this lookup settles; retain its error for the later await. + void linkedIssueDetailsPromise.catch(() => undefined) let context: Awaited> try { const currentBody = await resolveHostedReviewBodyForGeneration({ diff --git a/src/main/ipc/filesystem/filesystem-handler-context.ts b/src/main/ipc/filesystem/filesystem-handler-context.ts index 02488931e1e..231034d9a91 100644 --- a/src/main/ipc/filesystem/filesystem-handler-context.ts +++ b/src/main/ipc/filesystem/filesystem-handler-context.ts @@ -11,7 +11,7 @@ export type DownloadSession = { destinationExisted: boolean handle: FileHandle cleanupTimer: ReturnType - senderId: number + disposeRendererLifetime: () => void } export type FilesystemHandlerContext = { @@ -25,7 +25,6 @@ export type FilesystemHandlerContext = { transferId: string, cleanupTemp: boolean ) => Promise - cleanupDownloadSessionsForSender: (senderId: number) => void } export function createFilesystemHandlerContext( @@ -46,6 +45,7 @@ export function createFilesystemHandlerContext( return null } downloadSessions.delete(transferId) + session.disposeRendererLifetime() clearTimeout(session.cleanupTimer) await session.handle.close().catch(() => {}) if (cleanupTemp) { @@ -54,14 +54,6 @@ export function createFilesystemHandlerContext( return session } - const cleanupDownloadSessionsForSender = (senderId: number): void => { - for (const [transferId, session] of Array.from(downloadSessions)) { - if (session.senderId === senderId) { - void closeDownloadSession(transferId, true) - } - } - } - return { store, commitMessageAgentEnv, @@ -69,7 +61,6 @@ export function createFilesystemHandlerContext( downloadSessions, listFilesCancellations, gitStatusCancellations, - closeDownloadSession, - cleanupDownloadSessionsForSender + closeDownloadSession } } diff --git a/src/main/ipc/filesystem/filesystem-read-handlers.ts b/src/main/ipc/filesystem/filesystem-read-handlers.ts index 2850ba659b4..1d9fe681fee 100644 --- a/src/main/ipc/filesystem/filesystem-read-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-read-handlers.ts @@ -13,6 +13,7 @@ import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cach import { resolveAuthorizedPath } from '../filesystem-auth' import { isENOENT } from '../filesystem-path-containment' import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from '../markdown-documents' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' import { recordCrashBreadcrumb } from '../../crash-reporting/crash-breadcrumb-store' import { buildReadDirErrorBreadcrumb, type ReadDirThrowSite } from '../readdir-error-diagnostics' import type { FilesystemHandlerContext } from './filesystem-handler-context' @@ -131,7 +132,10 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext return markdownDocumentsFromRelativePaths(args.rootPath, relativePaths) } const rootPath = await resolveRegisteredWorktreePath(args.rootPath, store) - return listMarkdownDocuments(rootPath) + return listMarkdownDocuments( + rootPath, + getLocalGitOptionsForRegisteredWorktree(store, args.rootPath, rootPath) + ) } ) diff --git a/src/main/ipc/folder-repo-git-upgrade-lifetime.test.ts b/src/main/ipc/folder-repo-git-upgrade-lifetime.test.ts new file mode 100644 index 00000000000..8205381f997 --- /dev/null +++ b/src/main/ipc/folder-repo-git-upgrade-lifetime.test.ts @@ -0,0 +1,225 @@ +import { join } from 'node:path' +import type { BrowserWindow } from 'electron' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../shared/repo-types' +import type { Store } from '../persistence' + +const fake = vi.hoisted(() => ({ + stat: vi.fn(), + git: vi.fn(), + root: vi.fn(), + prepare: vi.fn(), + invalidate: vi.fn(), + reposChanged: vi.fn(), + worktreesChanged: vi.fn(), + unsubscribe: vi.fn() +})) +vi.mock('node:fs/promises', () => ({ stat: fake.stat })) +vi.mock('node:fs', () => ({ realpathSync: (path: string) => path })) +vi.mock('../git/repo', () => ({ isGitRepo: fake.git, getGitRepoRoot: fake.root })) +vi.mock('../worktree-root-preparation', () => ({ prepareLocalWorktreeRootForRepo: fake.prepare })) +vi.mock('./registered-worktree-roots-cache', () => ({ + invalidateAuthorizedRootsCache: fake.invalidate +})) +vi.mock('./repos/repos-changed-notification', () => ({ notifyReposChanged: fake.reposChanged })) +vi.mock('./worktree-remote', () => ({ notifyWorktreesChanged: fake.worktreesChanged })) +vi.mock('./worktree-base-directory-poller', () => ({ + WORKTREE_BASE_BACKSTOP_TICKS: 15, + WORKTREE_BASE_POLL_INTERVAL_MS: 2000, + createWorktreePollerWindowVisibility: () => ({ + isWindowVisible: () => true, + onWindowBecameVisible: () => fake.unsubscribe + }) +})) + +import { + startFolderRepoGitUpgradeWatch, + stopFolderRepoGitUpgradeWatch +} from './folder-repo-git-upgrade' +import { wakeFolderRepoGitUpgradeWatch } from './folder-repo-git-upgrade-wake' + +const marker = { mtimeMs: 1, ctimeMs: 1, ino: 1 } +const root = join('mock', 'folder') +function folder(path = root): Repo { + return { id: path, path, kind: 'folder', displayName: 'Folder', addedAt: 0, badgeColor: 'blue' } +} +function deferred() { + let resolve: (value: T) => void = () => {} + let reject: (error: Error) => void = () => {} + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} +function begin(repos = [folder()], meta: ReturnType = {}) { + const store = { + getRepos: () => repos, + getRepo: (id: string) => repos.find((repo) => repo.id === id), + getAllWorktreeMeta: () => meta, + updateRepo: vi.fn((id: string, patch: Partial) => { + const repo = repos.find((row) => row.id === id) + return repo ? Object.assign(repo, patch) : null + }) + } + const window = { isDestroyed: () => false } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Every Store method reachable by this poller is supplied; root preparation is mocked. + const storeFixture = store as unknown as Store + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Visibility and notifiers are mocked; only isDestroyed is read on this window. + const windowFixture = window as BrowserWindow + startFolderRepoGitUpgradeWatch(storeFixture, windowFixture, { pollIntervalMs: 25 }) + return { store, window } +} +async function flush(): Promise { + for (let index = 0; index < 10; index++) { + await Promise.resolve() + } +} + +beforeEach(() => { + vi.useFakeTimers() + vi.resetAllMocks() + fake.stat.mockResolvedValue(marker) + fake.git.mockReturnValue(true) + fake.root.mockImplementation((path: string) => path) + fake.prepare.mockResolvedValue(undefined) +}) +afterEach(() => { + stopFolderRepoGitUpgradeWatch() + vi.useRealTimers() +}) + +describe('folder repo upgrade poll lifetime', () => { + it('does not start Git checks or mutations when a marker arrives after stop', async () => { + const pending = deferred() + fake.stat.mockReturnValue(pending.promise) + const { store } = begin() + await vi.advanceTimersByTimeAsync(25) + expect(fake.stat).toHaveBeenCalledTimes(1) + stopFolderRepoGitUpgradeWatch() + pending.resolve(marker) + await flush() + expect(fake.git).not.toHaveBeenCalled() + expect(fake.root).not.toHaveBeenCalled() + expect(store.updateRepo).not.toHaveBeenCalled() + expect(fake.prepare).not.toHaveBeenCalled() + expect(fake.invalidate).not.toHaveBeenCalled() + expect(fake.reposChanged).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + + it('stops the remaining folder scan when a pending marker fails after stop', async () => { + const pending = deferred() + fake.stat.mockReturnValue(pending.promise) + begin([folder(), folder(join('mock', 'other'))]) + await vi.advanceTimersByTimeAsync(25) + stopFolderRepoGitUpgradeWatch() + pending.reject(new Error('ENOENT')) + await flush() + expect(fake.stat).toHaveBeenCalledTimes(1) + expect(fake.git).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + + it('does not restore a rejected marker after its owner stops', async () => { + fake.git.mockImplementationOnce(() => { + stopFolderRepoGitUpgradeWatch() + return false + }) + begin() + await vi.advanceTimersByTimeAsync(25) + expect(fake.git).toHaveBeenCalledTimes(1) + begin() + await vi.advanceTimersByTimeAsync(25) + expect(fake.git).toHaveBeenCalledTimes(2) + expect(fake.prepare).toHaveBeenCalledTimes(1) + }) + + it('does not prune a replacement watcher cache when earlier preparation finishes', async () => { + const pending = deferred() + fake.prepare.mockReturnValueOnce(pending.promise) + begin() + await vi.advanceTimersByTimeAsync(25) + expect(fake.prepare).toHaveBeenCalledTimes(1) + stopFolderRepoGitUpgradeWatch() + fake.git.mockReturnValue(false) + begin([folder(join('mock', 'replacement'))]) + await vi.advanceTimersByTimeAsync(25) + expect(fake.git).toHaveBeenCalledTimes(2) + pending.resolve() + await flush() + await vi.advanceTimersByTimeAsync(50) + expect(fake.git).toHaveBeenCalledTimes(2) + expect(fake.reposChanged).not.toHaveBeenCalled() + expect(fake.invalidate).toHaveBeenCalledTimes(1) + }) + + it('keeps active upgrade preparation, cache invalidation and notifications', async () => { + const { store, window } = begin() + await vi.advanceTimersByTimeAsync(25) + expect(store.updateRepo).toHaveBeenCalledWith(root, { + kind: 'git', + folderUpgradeGitRootPath: root, + externalWorktreeVisibility: 'hide' + }) + expect(fake.prepare).toHaveBeenCalledTimes(1) + expect(fake.invalidate).toHaveBeenCalledTimes(1) + expect(fake.reposChanged).toHaveBeenCalledWith(window) + expect(fake.worktreesChanged).toHaveBeenCalledWith(window, root) + }) + + it('retries a missing marker and dedupes stable rejected markers', async () => { + fake.stat.mockRejectedValueOnce(new Error('ENOENT')) + fake.git.mockReturnValue(false) + begin() + await vi.advanceTimersByTimeAsync(100) + expect(fake.stat).toHaveBeenCalledTimes(4) + expect(fake.git).toHaveBeenCalledTimes(1) + fake.stat.mockResolvedValue({ ...marker, mtimeMs: 2 }) + await vi.advanceTimersByTimeAsync(25) + expect(fake.git).toHaveBeenCalledTimes(2) + }) + + it('still excludes SSH, WSL, nonlocal execution hosts and git projects', async () => { + begin([ + { ...folder(), connectionId: 'ssh' }, + folder(String.raw`\\wsl$\Ubuntu\home\project`), + { ...folder(), executionHostId: 'runtime:other' }, + { ...folder(), kind: 'git' } + ]) + await vi.advanceTimersByTimeAsync(25) + expect(fake.stat).not.toHaveBeenCalled() + expect(fake.git).not.toHaveBeenCalled() + }) + + it('does not upgrade a project with extra folder workspaces', async () => { + const { store } = begin([folder()], { + [`${root}::${root}::workspace:extra`]: { + displayName: 'Extra', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + } + }) + await vi.advanceTimersByTimeAsync(25) + expect(fake.stat).toHaveBeenCalledTimes(1) + expect(fake.git).not.toHaveBeenCalled() + expect(store.updateRepo).not.toHaveBeenCalled() + }) + + it('releases timer, wake and visibility ownership on stop', async () => { + begin() + stopFolderRepoGitUpgradeWatch() + wakeFolderRepoGitUpgradeWatch() + await vi.advanceTimersByTimeAsync(1000) + expect(fake.unsubscribe).toHaveBeenCalledTimes(1) + expect(fake.stat).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/main/ipc/folder-repo-git-upgrade.ts b/src/main/ipc/folder-repo-git-upgrade.ts index e393ae56158..8a0a6130c0e 100644 --- a/src/main/ipc/folder-repo-git-upgrade.ts +++ b/src/main/ipc/folder-repo-git-upgrade.ts @@ -164,10 +164,17 @@ async function pollOnce(watch: UpgradeWatch): Promise { const key = normalizeRuntimePathForComparison(repo.path) liveKeys.add(key) const signature = await readGitMarkerSignature(repo.path) + if (watch.disposed) { + return + } if (signature === null || rejectedMarkers.get(key) === signature) { continue } - if ((await upgradeFolderRepo(watch, repo.id)) === 'rejected') { + const result = await upgradeFolderRepo(watch, repo.id) + if (watch.disposed) { + return + } + if (result === 'rejected') { rejectedMarkers.set(key, signature) } } diff --git a/src/main/ipc/freebuff-detection.test.ts b/src/main/ipc/freebuff-detection.test.ts new file mode 100644 index 00000000000..6c74e6409b5 --- /dev/null +++ b/src/main/ipc/freebuff-detection.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { + getTuiAgentDetectionProbeCommands, + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + resolveDetectedTuiAgentIds +} from './tui-agent-detection-commands' + +describe('Freebuff detection', () => { + it.each(['darwin', 'linux', 'win32', 'wsl'] as const)( + 'detects Freebuff independently of Codebuff on %s', + (runtime) => { + expect( + getTuiAgentDetectionProbeCommands(KNOWN_TUI_AGENT_DETECTION_COMMANDS, runtime) + ).toContain('freebuff') + expect( + resolveDetectedTuiAgentIds( + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + new Set(['freebuff']), + runtime + ) + ).toEqual(['freebuff']) + expect( + resolveDetectedTuiAgentIds( + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + new Set(['codebuff']), + runtime + ) + ).toEqual(['codebuff']) + expect( + resolveDetectedTuiAgentIds( + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + new Set(['freebuff', 'codebuff']), + runtime + ) + ).toEqual(expect.arrayContaining(['freebuff', 'codebuff'])) + } + ) +}) diff --git a/src/main/ipc/github-ipc-channel-parity.test.ts b/src/main/ipc/github-ipc-channel-parity.test.ts deleted file mode 100644 index 1746890199a..00000000000 --- a/src/main/ipc/github-ipc-channel-parity.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { readFileSync } from 'node:fs' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = await vi.hoisted(async () => { - const { createGitHubIpcMocks } = await import('./github-ipc-module-mocks') - return createGitHubIpcMocks() -}) - -vi.mock('electron', () => mocks.electron) -vi.mock('../github/client', () => mocks.client) -vi.mock('../github/work-item-details', () => mocks.workItemDetails) -vi.mock('../github/pr-refresh-coordinator', () => mocks.prRefresh) -vi.mock('../telemetry/client', () => mocks.telemetry) -vi.mock('../telemetry/cohort-classifier', () => mocks.cohort) -vi.mock('./ui', () => mocks.ui) - -import * as github from './github' -import { createGitHubIpcHarness } from './github-ipc-test-harness' - -const EXPECTED_GITHUB_IPC_CHANNELS = [ - 'gh:prForBranch', - 'gh:refreshPRNow', - 'gh:enqueuePRRefresh', - 'gh:reportVisiblePRRefreshCandidates', - 'gh:issue', - 'gh:listIssues', - 'gh:createIssue', - 'gh:listWorkItems', - 'gh:countWorkItems', - 'gh:workItem', - 'gh:workItemByOwnerRepo', - 'gh:workItemDetails', - 'gh:notifyWorkItemMutated', - 'gh:prFileContents', - 'gh:repoSlug', - 'gh:repoUpstream', - 'gh:prChecks', - 'gh:prCheckDetails', - 'gh:prComments', - 'gh:setPRCommentReaction', - 'gh:resolveReviewThread', - 'gh:setPRFileViewed', - 'gh:addPRReviewCommentReply', - 'gh:addPRReviewComment', - 'gh:updatePRTitle', - 'gh:mergePR', - 'gh:setPRAutoMerge', - 'gh:updatePRState', - 'gh:markPRReadyForReview', - 'gh:rerunPRChecks', - 'gh:requestPRReviewers', - 'gh:removePRReviewers', - 'gh:updateIssue', - 'gh:addIssueComment', - 'gh:listLabels', - 'gh:listAssignableUsers', - 'gh:viewer', - 'gh:checkOrcaStarred', - 'gh:starOrca', - 'gh:rateLimit', - 'gh:diagnoseAuth', - 'gh:listBindableAccounts', - 'gh:validateAccountBinding', - 'gh:listAccessibleProjects', - 'gh:resolveProjectRef', - 'gh:listProjectViews', - 'gh:getProjectViewTable', - 'gh:projectWorkItemDetailsBySlug', - 'gh:updateProjectItemField', - 'gh:clearProjectItemField', - 'gh:updateIssueBySlug', - 'gh:updatePullRequestBySlug', - 'gh:addIssueCommentBySlug', - 'gh:updateIssueCommentBySlug', - 'gh:deleteIssueCommentBySlug', - 'gh:listLabelsBySlug', - 'gh:listAssignableUsersBySlug', - 'gh:listIssueTypesBySlug', - 'gh:updateIssueTypeBySlug' -] as const - -describe('GitHub IPC channel parity', () => { - const harness = createGitHubIpcHarness(mocks) - - beforeEach(harness.reset) - - it('preserves the facade and fixed IPC channel contract', () => { - github.registerGitHubHandlers(harness.store as never, harness.stats as never) - - // The preload facade now composes the two GitHub bridge owners; inspect - // both owners so the channel census remains tied to the actual invokes. - const preloadSource = [ - '../../preload/api/gh-bridge-pull-requests-and-work-items.ts', - '../../preload/api/gh-bridge-mutations-and-projects.ts' - ] - .map((relativePath) => readFileSync(new URL(relativePath, import.meta.url), 'utf8')) - .join('\n') - const exposedChannels = [...preloadSource.matchAll(/ipcRenderer\.invoke\('(gh:[^']+)'/g)].map( - (match) => match[1] - ) - const registeredChannels = mocks.electron.ipcMain.handle.mock.calls.map( - ([channel]) => channel as string - ) - - expect(Object.keys(github)).toEqual(['registerGitHubHandlers']) - expect(new Set(registeredChannels).size).toBe(registeredChannels.length) - expect(registeredChannels).toEqual(EXPECTED_GITHUB_IPC_CHANNELS) - expect(registeredChannels.toSorted()).toEqual([...new Set(exposedChannels)].toSorted()) - }) -}) diff --git a/src/main/ipc/github-star-telemetry.test.ts b/src/main/ipc/github-star-telemetry.test.ts index 30be879cef8..9dd9a1c1e91 100644 --- a/src/main/ipc/github-star-telemetry.test.ts +++ b/src/main/ipc/github-star-telemetry.test.ts @@ -55,31 +55,6 @@ describe('registerGitHubHandlers', () => { }) }) - it('accepts every app star source for success telemetry', async () => { - starOrcaMock.mockResolvedValue(true) - - registerGitHubHandlers(store as never, stats as never) - - for (const source of [ - 'star_nag', - 'agent_value_moment', - 'onboarding_completed', - 'settings', - 'landing' - ] as const) { - await expect(handlers['gh:starOrca'](null, source)).resolves.toBe(true) - } - - expect(trackMock).toHaveBeenCalledTimes(5) - expect(trackMock.mock.calls.map(([, props]) => props)).toEqual([ - { source: 'star_nag', nth_repo_added: undefined }, - { source: 'agent_value_moment', nth_repo_added: undefined }, - { source: 'onboarding_completed', nth_repo_added: undefined }, - { source: 'settings', nth_repo_added: undefined }, - { source: 'landing', nth_repo_added: undefined } - ]) - }) - it('does not emit app_starred_orca when the star action returns false', async () => { starOrcaMock.mockResolvedValue(false) diff --git a/src/main/ipc/hooks/register-worktree-hook-check-handler.ts b/src/main/ipc/hooks/register-worktree-hook-check-handler.ts index 6b64dbe6a6f..443f3b108db 100644 --- a/src/main/ipc/hooks/register-worktree-hook-check-handler.ts +++ b/src/main/ipc/hooks/register-worktree-hook-check-handler.ts @@ -1,3 +1,4 @@ +import { getStoredRepoSshConnectionId } from '../../repo-execution-host' import { ipcMain } from 'electron' import type { ExecutionHostId } from '../../../shared/execution-host' import { isFolderRepo } from '../../../shared/repo-kind' @@ -29,8 +30,9 @@ export function registerWorktreeHookCheckHandler(context: WorktreeIpcContext): v return { status: 'ok', hasHooks: false, hooks: null, mayNeedUpdate: false } } - if (repo.connectionId) { - const fsProvider = getSshFilesystemProvider(repo.connectionId) + const connectionId = getStoredRepoSshConnectionId(repo) + if (connectionId) { + const fsProvider = getSshFilesystemProvider(connectionId) if (!fsProvider) { return { status: 'error', hasHooks: false, hooks: null, mayNeedUpdate: false } } diff --git a/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts b/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts index 1ddf379f784..ca1bfbcfb78 100644 --- a/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts +++ b/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts @@ -1,3 +1,4 @@ +import { getStoredRepoSshConnectionId } from '../../repo-execution-host' import { getLocalProjectWorktreeGitOptions } from '../../project-runtime-git-options' import { ipcMain } from 'electron' import type { ExecutionHostId } from '../../../shared/execution-host' @@ -32,9 +33,10 @@ export function registerWorktreeHookFileHandlers(context: WorktreeIpcContext): v source: 'none' as const } } - if (repo.connectionId) { + const connectionId = getStoredRepoSshConnectionId(repo) + if (connectionId) { const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') - const fsProvider = getSshFilesystemProvider(repo.connectionId) + const fsProvider = getSshFilesystemProvider(connectionId) if (!fsProvider) { return { status: 'error', @@ -92,9 +94,10 @@ export function registerWorktreeHookFileHandlers(context: WorktreeIpcContext): v if (!repo || isFolderRepo(repo)) { return } - if (repo.connectionId) { + const connectionId = getStoredRepoSshConnectionId(repo) + if (connectionId) { const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') - const fsProvider = getSshFilesystemProvider(repo.connectionId) + const fsProvider = getSshFilesystemProvider(connectionId) if (!fsProvider) { throw new Error( 'Remote filesystem unavailable. Reconnect the SSH target before retrying.' @@ -110,7 +113,7 @@ export function registerWorktreeHookFileHandlers(context: WorktreeIpcContext): v return } await fsProvider.createDir(joinWorktreeRelativePath(repo.path, '.orca')) - if (await isIssueCommandIgnoredByGit(repo.path, repo.connectionId)) { + if (await isIssueCommandIgnoredByGit(repo.path, connectionId)) { await fsProvider.writeFile(issueCommandPath, `${trimmed}\n`) return } diff --git a/src/main/ipc/hooks/register-worktree-hook-inspection-handler.ts b/src/main/ipc/hooks/register-worktree-hook-inspection-handler.ts index 36dcfbc1288..6df6e79dbb8 100644 --- a/src/main/ipc/hooks/register-worktree-hook-inspection-handler.ts +++ b/src/main/ipc/hooks/register-worktree-hook-inspection-handler.ts @@ -1,3 +1,4 @@ +import { getStoredRepoSshConnectionId } from '../../repo-execution-host' import { ipcMain } from 'electron' import type { ExecutionHostId } from '../../../shared/execution-host' import { isFolderRepo } from '../../../shared/repo-kind' @@ -23,8 +24,9 @@ export function registerWorktreeHookInspectionHandler(context: WorktreeIpcContex return inspectSetupScriptImportCandidates( async (relativePath) => { const filePath = joinWorktreeRelativePath(repo.path, relativePath) - if (repo.connectionId) { - const fsProvider = getSshFilesystemProvider(repo.connectionId) + const connectionId = getStoredRepoSshConnectionId(repo) + if (connectionId) { + const fsProvider = getSshFilesystemProvider(connectionId) if (!fsProvider) { return null } @@ -48,8 +50,9 @@ export function registerWorktreeHookInspectionHandler(context: WorktreeIpcContex { fileExists: async (relativePath) => { const filePath = joinWorktreeRelativePath(repo.path, relativePath) - if (repo.connectionId) { - const fsProvider = getSshFilesystemProvider(repo.connectionId) + const connectionId = getStoredRepoSshConnectionId(repo) + if (connectionId) { + const fsProvider = getSshFilesystemProvider(connectionId) if (!fsProvider) { return false } diff --git a/src/main/ipc/hooks/worktree-hook-execution-host.test.ts b/src/main/ipc/hooks/worktree-hook-execution-host.test.ts new file mode 100644 index 00000000000..181d5a441d5 --- /dev/null +++ b/src/main/ipc/hooks/worktree-hook-execution-host.test.ts @@ -0,0 +1,251 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../shared/repo-types' +import { hydrateRepo } from '../../persistence/tracking-repos/repo-hydration' + +const mocks = vi.hoisted(() => ({ + handlers: new Map unknown>(), + hasHooks: vi.fn(), + loadHooks: vi.fn(), + effectiveHooks: vi.fn(), + localRead: vi.fn(), + localWrite: vi.fn(), + localFileRead: vi.fn(), + localStat: vi.fn(), + remoteRead: vi.fn(), + remoteWrite: vi.fn(), + remoteStat: vi.fn(), + remoteMkdir: vi.fn(), + provider: vi.fn(), + ignored: vi.fn() +})) +vi.mock('electron', () => ({ + ipcMain: { + handle: (channel: string, handler: (event: unknown, args: unknown) => unknown) => + mocks.handlers.set(channel, handler) + } +})) +vi.mock('../../hooks', () => ({ + hasHooksFile: mocks.hasHooks, + loadHooks: mocks.loadHooks, + getEffectiveHooks: mocks.effectiveHooks, + hasUnrecognizedOrcaYamlKeys: () => false, + parseOrcaYaml: () => ({ issueCommand: 'shared' }) +})) +vi.mock('../../issue-command-file', () => ({ + readIssueCommand: mocks.localRead, + writeIssueCommand: mocks.localWrite, + isIssueCommandIgnoredByGit: mocks.ignored +})) +vi.mock('../../project-runtime-git-options', () => ({ + getLocalProjectWorktreeGitOptions: () => ({}) +})) +vi.mock('../../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: mocks.provider +})) +vi.mock('node:fs/promises', () => ({ readFile: mocks.localFileRead, stat: mocks.localStat })) +vi.mock('../../../shared/setup-script-imports', () => ({ + inspectSetupScriptImportCandidates: async ( + read: (path: string) => Promise, + options?: { fileExists: (path: string) => Promise } + ) => { + await read('setup.sh') + await options?.fileExists('package.json') + return [] + } +})) +vi.mock('../../effective-hook-config', () => ({ + getEffectiveSetupRunPolicy: () => 'manual', + getDefaultTabCommandTrustContent: () => undefined +})) +import { RuntimeRepositoryHooksCommands } from '../../runtime/runtime-repository-hooks-commands' +import { RuntimeRepositoryIssueCommand } from '../../runtime/runtime-repository-issue-command' +import { getRepoExecutionHostId } from '../../../shared/execution-host' +import { registerWorktreeHookCheckHandler } from './register-worktree-hook-check-handler' +import { registerWorktreeHookFileHandlers } from './register-worktree-hook-file-handlers' +import { registerWorktreeHookInspectionHandler } from './register-worktree-hook-inspection-handler' + +beforeEach(() => { + vi.clearAllMocks() + mocks.handlers.clear() + mocks.remoteRead.mockResolvedValue({ content: 'remote command', isBinary: false }) + mocks.remoteWrite.mockResolvedValue(undefined) + mocks.remoteMkdir.mockResolvedValue(undefined) + mocks.remoteStat.mockResolvedValue({ type: 'file' }) + mocks.ignored.mockResolvedValue(true) + mocks.localRead.mockResolvedValue({ localContent: 'wrong host' }) + mocks.localWrite.mockResolvedValue(undefined) + mocks.localFileRead.mockResolvedValue('wrong host') + mocks.localStat.mockResolvedValue({ isDirectory: () => false }) + mocks.hasHooks.mockReturnValue(false) + mocks.provider.mockReturnValue({ + readFile: mocks.remoteRead, + writeFile: mocks.remoteWrite, + createDir: mocks.remoteMkdir, + stat: mocks.remoteStat + }) +}) + +const owners = [ + { label: 'legacy SSH', fields: { connectionId: 'host-a' }, target: 'host-a' }, + { label: 'canonical SSH', fields: { executionHostId: 'ssh:host-a' }, target: 'host-a' }, + { + label: 'canonical SSH over stale legacy target', + fields: { executionHostId: 'ssh:host-a', connectionId: 'stale' }, + target: 'host-a' + }, + { label: 'local', fields: {}, target: null }, + { + label: 'explicit local over stale legacy target', + fields: { executionHostId: 'local', connectionId: 'stale' }, + target: null + }, + { label: 'own-store runtime stamp', fields: { executionHostId: 'runtime:env' }, target: null }, + { + label: 'runtime stamp with nested legacy target', + fields: { executionHostId: 'runtime:env', connectionId: 'nested' }, + target: null + } +] as const satisfies readonly { + label: string + fields: Pick + target: string | null +}[] + +const channels = [ + 'hooks:check', + 'hooks:readIssueCommand', + 'hooks:writeIssueCommand', + 'hooks:inspectSetupScriptImports' +] as const + +function register(repo: Repo): void { + const context = { store: { getRepos: () => [repo], getRepo: () => repo } } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These adapters only read the provided store accessors. + registerWorktreeHookCheckHandler(context as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These adapters only read the provided store accessors. + registerWorktreeHookFileHandlers(context as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These adapters only read the provided store accessors. + registerWorktreeHookInspectionHandler(context as never) +} + +function makeRepo(fields: Pick): Repo { + return hydrateRepo( + { + id: 'repo', + path: '/remote/fixture', + displayName: 'fixture', + badgeColor: '#000', + addedAt: 0, + ...fields + }, + new Map() + ) +} + +function expectRoute(target: string | null): void { + const localCalls = [ + mocks.hasHooks, + mocks.localRead, + mocks.localWrite, + mocks.localFileRead, + mocks.localStat, + mocks.effectiveHooks + ] + if (target) { + expect(mocks.provider).toHaveBeenCalledWith(target) + for (const [connectionId] of mocks.provider.mock.calls) { + expect(connectionId).toBe(target) + } + for (const method of localCalls) { + expect(method).not.toHaveBeenCalled() + } + } else { + expect(mocks.provider).not.toHaveBeenCalled() + expect(localCalls.some((method) => method.mock.calls.length > 0)).toBe(true) + expect(mocks.remoteRead).not.toHaveBeenCalled() + expect(mocks.remoteWrite).not.toHaveBeenCalled() + expect(mocks.remoteStat).not.toHaveBeenCalled() + } +} + +describe.each(owners)('desktop hooks: $label', ({ fields, target }) => { + it.each(channels)('routes %s to the stored owner', async (channel) => { + const repo = makeRepo(fields) + register(repo) + await mocks.handlers.get(channel)!(null, { + repoId: repo.id, + hostId: getRepoExecutionHostId(repo), + content: 'save' + }) + expectRoute(target) + if (channel === 'hooks:writeIssueCommand' && target) { + expect(mocks.ignored).toHaveBeenCalledWith(repo.path, target) + expect(mocks.remoteWrite).toHaveBeenCalledWith( + '/remote/fixture/.orca/issue-command', + 'save\n' + ) + } + }) +}) + +describe.each(owners)('runtime hooks: $label', ({ fields, target }) => { + it.each(['get', 'check', 'inspect', 'read', 'write'] as const)( + 'routes %s to the stored owner', + async (method) => { + const repo = makeRepo(fields) + const hooks = new RuntimeRepositoryHooksCommands({ resolveRepo: async () => repo }) + const issue = new RuntimeRepositoryIssueCommand({ + resolveRepo: async () => repo, + getLocalGitArgs: () => [] + }) + if (method === 'get') { + await hooks.getRepoHooks(repo.id) + } else if (method === 'check') { + await hooks.checkRepoHooks(repo.id) + } else if (method === 'inspect') { + await hooks.inspectRepoSetupScriptImports(repo.id) + } else if (method === 'read') { + await issue.read(repo.id) + } else { + await issue.write(repo.id, 'save') + } + expectRoute(target) + if (method === 'write' && target) { + expect(mocks.ignored).toHaveBeenCalledWith(repo.path, target) + expect(mocks.remoteWrite).toHaveBeenCalledWith( + '/remote/fixture/.orca/issue-command', + 'save\n' + ) + } + } + ) +}) + +it('refuses an unreachable canonical SSH write without touching local files', async () => { + const repo = makeRepo({ executionHostId: 'ssh:host-a' }) + register(repo) + mocks.provider.mockReturnValue(null) + await expect( + mocks.handlers.get('hooks:writeIssueCommand')!(null, { + repoId: repo.id, + hostId: 'ssh:host-a', + content: 'save' + }) + ).rejects.toThrow('Remote filesystem unavailable') + expect(mocks.localWrite).not.toHaveBeenCalled() +}) + +it.each(channels)('keeps folder repositories out of %s', async (channel) => { + const repo = { ...makeRepo({ executionHostId: 'ssh:host-a' }), kind: 'folder' as const } + register(repo) + await mocks.handlers.get(channel)!(null, { + repoId: repo.id, + hostId: 'ssh:host-a', + content: 'save' + }) + expect(mocks.provider).not.toHaveBeenCalled() + expect(mocks.hasHooks).not.toHaveBeenCalled() + expect(mocks.localRead).not.toHaveBeenCalled() + expect(mocks.localWrite).not.toHaveBeenCalled() + expect(mocks.localFileRead).not.toHaveBeenCalled() +}) diff --git a/src/main/ipc/markdown-documents-ripgrep-real.test.ts b/src/main/ipc/markdown-documents-ripgrep-real.test.ts new file mode 100644 index 00000000000..373a6502c81 --- /dev/null +++ b/src/main/ipc/markdown-documents-ripgrep-real.test.ts @@ -0,0 +1,114 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, dirname, extname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resetBundledRipgrepPathCacheForTests } from '../ripgrep/bundled-ripgrep-path' +import { listMarkdownDocuments } from './markdown-documents' + +describe('Markdown listing with bundled ripgrep', () => { + let root: string + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca Markdown 日本語 ')) + resetBundledRipgrepPathCacheForTests() + }) + + afterEach(async () => { + vi.unstubAllEnvs() + resetBundledRipgrepPathCacheForTests() + await rm(root, { recursive: true, force: true }) + }) + + async function put(relativePath: string, content = 'document'): Promise { + const filePath = join(root, relativePath) + await mkdir(dirname(filePath), { recursive: true }) + await writeFile(filePath, content) + } + + it('preserves Markdown visibility independently of ignore files and rg configuration', async () => { + const included = [ + 'README.md', + 'Guide.MDX', + 'docs/Notes.MARKDOWN', + '.hidden.md', + 'docs/.hidden.mdx', + '.github/CONTRIBUTING.md', + 'docs/.github/nested/guide.md', + '.github/normal/.hidden.md', + 'ignored-git/readme.md', + 'ignored-rg/readme.md', + 'ignored-dot/readme.md', + 'space folder/日本語 document.md', + 'uppercase/NODE_MODULES/visible.md' + ] + const excluded = [ + 'plain.txt', + '.md', + 'docs/.MDX', + '.git/hidden.md', + 'node_modules/hidden.md', + 'docs/node_modules/hidden.md', + '.hidden/hidden.md', + '.hidden/.github/hidden.md', + '.github/.hidden/hidden.md', + '.github/node_modules/hidden.md', + '.github/.md/hidden.md', + '.github.txt/hidden.md', + 'docs/.md/hidden.md' + ] + for (const filePath of [...included, ...excluded]) { + await put(filePath) + } + await put('.gitignore', 'ignored-git/\n') + await put('.rgignore', 'ignored-rg/\n') + await put('.ignore', 'ignored-dot/\n') + await put('rg-config', '--glob=!*.md\n') + vi.stubEnv('RIPGREP_CONFIG_PATH', join(root, 'rg-config')) + + expect(await listMarkdownDocuments(root)).toEqual( + included + .sort((left, right) => left.localeCompare(right)) + .map((relativePath) => ({ + filePath: join(root, relativePath), + relativePath, + basename: basename(relativePath), + name: basename(relativePath, extname(relativePath)) + })) + ) + }) + + it('does not follow directory links or file links', async () => { + await put('docs/original.md') + await symlink( + join(root, 'docs'), + join(root, 'linked-docs'), + process.platform === 'win32' ? 'junction' : 'dir' + ) + // File symlinks require privileges that Windows test runners may not have. + if (process.platform !== 'win32') { + await symlink(join(root, 'docs/original.md'), join(root, 'linked.md')) + await symlink(join(root, 'missing.md'), join(root, 'broken.md')) + } + expect((await listMarkdownDocuments(root)).map((document) => document.relativePath)).toEqual([ + 'docs/original.md' + ]) + }) + + it.skipIf(process.platform === 'win32')('preserves line breaks in filenames', async () => { + const names = ['line\nbreak.md', 'carriage\rreturn.MDX', 'nested\nfolder/文書.md'] + for (const name of names) { + await put(name) + } + expect((await listMarkdownDocuments(root)).map((document) => document.relativePath)).toEqual( + names.sort((left, right) => left.localeCompare(right)) + ) + }) + + it('returns an empty list for an empty ordinary folder', async () => { + await expect(listMarkdownDocuments(root)).resolves.toEqual([]) + }) + + it('rejects a missing root rather than treating it as an empty folder', async () => { + await expect(listMarkdownDocuments(join(root, 'missing'))).rejects.toThrow() + }) +}) diff --git a/src/main/ipc/markdown-documents-ripgrep.test.ts b/src/main/ipc/markdown-documents-ripgrep.test.ts new file mode 100644 index 00000000000..daee482bae1 --- /dev/null +++ b/src/main/ipc/markdown-documents-ripgrep.test.ts @@ -0,0 +1,146 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { resolve } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })) +vi.mock('../ripgrep/bundled-ripgrep-spawn', () => ({ spawnBundledRipgrep: spawnMock })) + +import { listMarkdownDocuments } from './markdown-documents' + +class ListingProcess extends EventEmitter { + stdout = new PassThrough() + stderr = new PassThrough() + pid: number | undefined = 123 + kill = vi.fn(() => true) +} + +const root = resolve('/workspace/docs') +const originalPlatform = process.platform +let child: ListingProcess + +beforeEach(() => { + child = new ListingProcess() + spawnMock.mockReset().mockReturnValue(child) +}) +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) +}) + +describe('Markdown document ripgrep lifecycle', () => { + it('decodes split Unicode and NUL records without splitting newline filenames', async () => { + const result = listMarkdownDocuments(root) + const bytes = Buffer.from('./日本語\nnotes.MDX\0./.md\0./script.ts\0./README.md\0') + for (const byte of bytes) { + child.stdout.write(Buffer.from([byte])) + } + child.emit('close', 0, null) + + expect((await result).map((doc) => doc.basename).sort()).toEqual([ + 'README.md', + '日本語\nnotes.MDX' + ]) + expect(child.kill).not.toHaveBeenCalled() + expect(child.stdout.listenerCount('data')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + }) + + it('accepts an empty listing', async () => { + const result = listMarkdownDocuments(root) + child.emit('close', 1, null) + await expect(result).resolves.toEqual([]) + }) + + it('rejects an unreadable subtree even after receiving valid documents', async () => { + const result = listMarkdownDocuments(root) + child.stdout.write('./README.md\0') + child.stderr.write('Permission denied') + child.emit('close', 2, null) + await expect(result).rejects.toThrow('Permission denied') + }) + + it('rejects a truncated final path instead of returning partial documents', async () => { + const result = listMarkdownDocuments(root) + child.stdout.write('./README.md\0./unfinished.md') + child.emit('close', 0, null) + await expect(result).rejects.toThrow('Incomplete path') + }) + + it.each(['../escape.md', '/outside.md', './dir/../escape.md'])( + 'rejects a path outside the relative listing protocol: %s', + async (path) => { + const result = listMarkdownDocuments(root) + child.stdout.write(`${path}\0`) + await expect(result).rejects.toThrow('Invalid path') + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + } + ) + + it('rejects an oversized unfinished record without retaining the process', async () => { + const result = listMarkdownDocuments(root) + child.stdout.write(`./${'a'.repeat(1024 * 1024)}`) + await expect(result).rejects.toThrow('path exceeds') + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('rejects a spawn failure and does not signal a missing process', async () => { + const result = listMarkdownDocuments(root) + child.pid = undefined + child.emit('error', Object.assign(new Error('missing bundled binary'), { code: 'ENOENT' })) + await expect(result).rejects.toThrow('missing bundled binary') + expect(child.kill).not.toHaveBeenCalled() + }) + + it('rejects synchronous spawn failures', async () => { + spawnMock.mockImplementation(() => { + throw new Error('spawn refused') + }) + await expect(listMarkdownDocuments(root)).rejects.toThrow('spawn refused') + }) + + it('times out, kills the child and releases listeners even if close never arrives', async () => { + vi.useFakeTimers() + const result = listMarkdownDocuments(root) + const rejected = expect(result).rejects.toThrow('timed out') + child.stdout.write('./README.md\0') + await vi.advanceTimersByTimeAsync(15_000) + await rejected + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + expect(child.stdout.listenerCount('data')).toBe(0) + expect(child.stderr.listenerCount('data')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + expect(vi.getTimerCount()).toBe(0) + expect(() => child.emit('error', new Error('late error'))).not.toThrow() + expect(() => child.stdout.emit('error', new Error('late pipe error'))).not.toThrow() + }) + + it('rejects stdout failure instead of returning an incomplete set', async () => { + const result = listMarkdownDocuments(root) + child.stdout.emit('error', new Error('broken pipe')) + await expect(result).rejects.toThrow('broken pipe') + }) + + it('does not confuse an unreachable WSL cwd with no matching documents', async () => { + const result = listMarkdownDocuments(root, { wslDistro: 'Ubuntu' }) + child.emit('close', 97, null) + await expect(result).rejects.toThrow('Search root is not reachable') + }) + + it.each([ + { path: root, options: { wslDistro: 'Ubuntu' }, distro: 'Ubuntu' }, + { path: '\\\\wsl.localhost\\Debian\\home\\repo', options: {}, distro: 'Debian' } + ])('selects the Linux binary for $distro', async ({ path, options, distro }) => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const result = listMarkdownDocuments(path, options) + expect(spawnMock).toHaveBeenCalledWith(expect.any(Array), { + cwd: path, + wslDistro: options.wslDistro, + wslDistroForOutput: distro, + stdio: ['ignore', 'pipe', 'pipe'] + }) + child.emit('close', 1, null) + await result + }) +}) diff --git a/src/main/ipc/markdown-documents.ts b/src/main/ipc/markdown-documents.ts index 38466da6087..3977999b9af 100644 --- a/src/main/ipc/markdown-documents.ts +++ b/src/main/ipc/markdown-documents.ts @@ -1,6 +1,11 @@ -import { readdir } from 'node:fs/promises' import { basename as pathBasename, extname, isAbsolute, join, relative, resolve } from 'node:path' import type { MarkdownDocument } from '../../shared/filesystem-entry-types' +import { spawnBundledRipgrep } from '../ripgrep/bundled-ripgrep-spawn' +import { parseWslPath } from '../wsl' +import { + isRipgrepMissingCwdExit, + ripgrepMissingCwdError +} from '../../shared/ripgrep-process-availability' function normalizeRelativePath(path: string): string { return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '') @@ -88,34 +93,132 @@ export function markdownDocumentsFromRelativePaths( .sort((a, b) => a.relativePath.localeCompare(b.relativePath)) } -export async function listMarkdownDocuments(rootPath: string): Promise { - const documents: MarkdownDocument[] = [] +const MARKDOWN_LISTING_TIMEOUT_MS = 15_000 +const MAX_MARKDOWN_PATH_BYTES = 1024 * 1024 - async function visitDirectory(dirPath: string): Promise { - const entries = await readdir(dirPath, { withFileTypes: true }) - for (const entry of entries) { - if (entry.isSymbolicLink()) { - continue +export async function listMarkdownDocuments( + rootPath: string, + options: { wslDistro?: string } = {} +): Promise { + const child = spawnBundledRipgrep( + [ + '--files', + '--hidden', + '--no-ignore', + '--no-config', + '--null', + '--path-separator', + '/', + // Directory-only globs preserve hidden Markdown files without traversing hidden folders. + '--glob', + '**', + '--glob', + '!**/.*/', + '--glob', + '**/.github/', + '--glob', + '!**/node_modules/', + '.' + ], + { + cwd: rootPath, + wslDistro: options.wslDistro, + wslDistroForOutput: parseWslPath(rootPath)?.distro ?? options.wslDistro, + stdio: ['ignore', 'pipe', 'pipe'] + } + ) + + return new Promise((resolveListing, reject) => { + const documents: MarkdownDocument[] = [] + let carry = '' + let stderr = '' + let settled = false + const finish = (error?: Error): void => { + if (settled) { + return } - - const entryPath = join(dirPath, entry.name) - if (entry.isDirectory()) { - if (entry.name === '.git' || entry.name === 'node_modules') { - continue + settled = true + clearTimeout(timer) + child.stdout?.off('data', onData) + child.stderr?.off('data', onStderr) + child.stdout?.off('error', onError) + child.stderr?.off('error', onError) + child.off('close', onClose) + child.off('error', onError) + // A spawn or pipe error can arrive after a timeout has already settled the listing. + child.on('error', ignoreLateError) + child.stdout?.on('error', ignoreLateError) + child.stderr?.on('error', ignoreLateError) + carry = '' + if (error) { + if (child.pid !== undefined) { + try { + child.kill('SIGKILL') + } catch { + // The process may have exited before the timeout or stream error arrived. + } } - if (entry.name.startsWith('.') && entry.name !== '.github') { - continue - } - await visitDirectory(entryPath) - continue - } - - if (entry.isFile() && isMarkdownDocumentName(entry.name)) { - documents.push(markdownDocumentFromFilePath(rootPath, entryPath)) + documents.length = 0 + child.stdout?.resume() + child.stderr?.resume() + reject(error) + } else { + resolveListing(documents.sort((a, b) => a.relativePath.localeCompare(b.relativePath))) } } - } - - await visitDirectory(rootPath) - return documents.sort((a, b) => a.relativePath.localeCompare(b.relativePath)) + const onError = (error: Error): void => finish(error) + const onStderr = (chunk: string): void => { + stderr = (stderr + chunk).slice(0, 4096) + } + const onData = (chunk: string): void => { + carry += chunk + let start = 0 + let end: number + while ((end = carry.indexOf('\0', start)) !== -1) { + const path = carry.slice(start, end) + if (Buffer.byteLength(path) > MAX_MARKDOWN_PATH_BYTES) { + finish(new Error('Markdown document path exceeds the listing limit')) + return + } + if (!path.startsWith('./') || path.split('/').includes('..')) { + finish(new Error('Invalid path in Markdown document listing')) + return + } + if (isMarkdownDocumentName(path)) { + documents.push(markdownDocumentFromFilePath(rootPath, join(rootPath, path.slice(2)))) + } + start = end + 1 + } + carry = carry.slice(start) + if (Buffer.byteLength(carry) > MAX_MARKDOWN_PATH_BYTES) { + finish(new Error('Markdown document path exceeds the listing limit')) + } + } + const onClose = (code: number | null, signal: NodeJS.Signals | null): void => { + if (isRipgrepMissingCwdExit(code)) { + finish(ripgrepMissingCwdError(rootPath)) + } else if (signal || (code !== 0 && code !== 1)) { + finish(new Error(`Markdown document listing failed (${signal ?? code}): ${stderr.trim()}`)) + } else if (carry) { + finish(new Error('Incomplete path in Markdown document listing')) + } else { + finish() + } + } + const timer = setTimeout( + () => finish(new Error('Markdown document listing timed out')), + MARKDOWN_LISTING_TIMEOUT_MS + ) + timer.unref?.() + child.stdout?.setEncoding('utf8') + child.stderr?.setEncoding('utf8') + child.stdout?.on('data', onData) + child.stderr?.on('data', onStderr) + child.stdout?.on('error', onError) + child.stderr?.on('error', onError) + child.once('error', onError) + child.once('close', onClose) + }) } + +function ignoreLateError(): void {} diff --git a/src/main/ipc/native-chat-renderer-lifetime.test.ts b/src/main/ipc/native-chat-renderer-lifetime.test.ts new file mode 100644 index 00000000000..c35be09cf4e --- /dev/null +++ b/src/main/ipc/native-chat-renderer-lifetime.test.ts @@ -0,0 +1,184 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { + NativeChatTranscriptSubscription, + SubscribeNativeChatTranscriptArgs +} from '../native-chat/transcript-watch-contract' + +const mocks = vi.hoisted(() => ({ + listeners: new Map void>(), + subscribe: + vi.fn< + ( + args: SubscribeNativeChatTranscriptArgs, + signal?: AbortSignal + ) => Promise + >() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + on: (channel: string, listener: (event: unknown, args: unknown) => void) => { + mocks.listeners.set(channel, listener) + } + } +})) +vi.mock('../native-chat/transcript-watch', () => ({ + subscribeNativeChatTranscript: mocks.subscribe +})) +vi.mock('../native-chat/transcript-read-cache', () => ({ clearNativeChatTranscriptCache: vi.fn() })) + +import { + _getNativeChatPendingSubscriptionCountForTest, + _getNativeChatSenderCleanupCountForTest, + clearNativeChatSubscriptions, + registerNativeChatHandlers +} from './native-chat' + +class Sender extends EventEmitter { + send = vi.fn() + isDestroyed = (): boolean => false + constructor(readonly id: number) { + super() + } +} + +const live = new Set() +const goneEvents = ['did-navigate', 'render-process-gone', 'destroyed'] as const + +function subscribe(sender: Sender, subscriptionId: string): void { + mocks.listeners.get('nativeChat:subscribe')?.( + { sender }, + { subscriptionId, agent: 'claude', sessionId: 'one-agent-session' } + ) +} + +function emitAppend(): void { + for (const args of live) { + args.onAppend([]) + } +} + +function pendingSetup() { + let resolve: (subscription: NativeChatTranscriptSubscription) => void = () => {} + const promise = new Promise((settle) => { + resolve = settle + }) + const unsubscribe = vi.fn() + return { promise, resolve: () => resolve({ watching: true, unsubscribe }), unsubscribe } +} + +beforeEach(() => { + clearNativeChatSubscriptions() + live.clear() + mocks.subscribe.mockReset().mockImplementation(async (args) => { + live.add(args) + return { watching: true, unsubscribe: () => void live.delete(args) } + }) + registerNativeChatHandlers() +}) + +afterEach(() => { + clearNativeChatSubscriptions() +}) + +it.each(goneEvents)('releases every live viewer subscription after %s', async (event) => { + const sender = new Sender(1) + subscribe(sender, 'pane-a') + subscribe(sender, 'pane-b') + await Promise.resolve() + expect(live.size).toBe(2) + emitAppend() + expect(sender.send).toHaveBeenCalledTimes(2) + + sender.emit(event) + expect(live.size).toBe(0) + expect(_getNativeChatSenderCleanupCountForTest()).toBe(0) + emitAppend() + expect(sender.send).toHaveBeenCalledTimes(2) + for (const gone of goneEvents) { + expect(sender.listenerCount(gone)).toBe(0) + } +}) + +it.each(goneEvents)('aborts pending setup and closes a late watcher after %s', async (event) => { + const pending = pendingSetup() + mocks.subscribe.mockReturnValueOnce(pending.promise) + const sender = new Sender(2) + subscribe(sender, 'pending') + const signal = mocks.subscribe.mock.calls[0]?.[1] + expect(signal?.aborted).toBe(false) + + sender.emit(event) + expect(signal?.aborted).toBe(true) + expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(0) + pending.resolve() + await Promise.resolve() + expect(pending.unsubscribe).toHaveBeenCalledOnce() +}) + +it('does not publish callbacks from a replaced document into its replacement', async () => { + const pending = pendingSetup() + mocks.subscribe.mockReturnValueOnce(pending.promise) + const sender = new Sender(3) + subscribe(sender, 'same-id') + const oldArgs = mocks.subscribe.mock.calls[0]?.[0] + if (!oldArgs) { + throw new Error('Subscription was not started') + } + sender.emit('did-navigate') + subscribe(sender, 'same-id') + await Promise.resolve() + oldArgs.onTranscriptPending?.() + oldArgs.onInitialSnapshot?.([], false, 0) + oldArgs.onReplace?.([], false, 0) + oldArgs.onAppend([]) + expect(sender.send).not.toHaveBeenCalled() + pending.resolve() + await Promise.resolve() + expect(pending.unsubscribe).toHaveBeenCalledOnce() + emitAppend() + expect(sender.send).toHaveBeenCalledOnce() +}) + +it('retains only the current watcher after repeated reloads', async () => { + const sender = new Sender(4) + for (let cycle = 0; cycle < 15; cycle++) { + subscribe(sender, `old-${cycle}`) + await Promise.resolve() + sender.emit('did-navigate') + } + subscribe(sender, 'current') + await Promise.resolve() + expect(live.size).toBe(1) + emitAppend() + expect(sender.send).toHaveBeenCalledOnce() + for (const event of goneEvents) { + expect(sender.listenerCount(event)).toBe(1) + } +}) + +it('preserves another viewer of the same agent session when one renderer reloads', async () => { + const first = new Sender(5) + const second = new Sender(6) + subscribe(first, 'shared-session') + subscribe(second, 'shared-session') + await Promise.resolve() + first.emit('did-navigate') + emitAppend() + expect(live.size).toBe(1) + expect(first.send).not.toHaveBeenCalled() + expect(second.send).toHaveBeenCalledOnce() +}) + +it('preserves subscriptions through same-document and prevented navigation', async () => { + const sender = new Sender(7) + subscribe(sender, 'current') + await Promise.resolve() + sender.emit('did-start-navigation') + sender.emit('did-navigate-in-page') + emitAppend() + expect(live.size).toBe(1) + expect(sender.send).toHaveBeenCalledOnce() +}) diff --git a/src/main/ipc/native-chat-renderer-watcher-cleanup.test.ts b/src/main/ipc/native-chat-renderer-watcher-cleanup.test.ts new file mode 100644 index 00000000000..a2cd6bb69ac --- /dev/null +++ b/src/main/ipc/native-chat-renderer-watcher-cleanup.test.ts @@ -0,0 +1,62 @@ +import { EventEmitter } from 'node:events' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const listeners = new Map void>() +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + on: (channel: string, listener: (event: unknown, args: unknown) => void) => { + listeners.set(channel, listener) + } + } +})) + +import { clearNativeChatSubscriptions, registerNativeChatHandlers } from './native-chat' +import { getActiveNativeChatWatcherCount } from '../native-chat/transcript-watcher-count' + +let transcriptDirectory = '' +let transcriptPath = '' + +beforeEach(async () => { + clearNativeChatSubscriptions() + registerNativeChatHandlers() + transcriptDirectory = await mkdtemp(join(tmpdir(), 'orca-native-chat-renderer-lifetime-')) + transcriptPath = join(transcriptDirectory, 'session.jsonl') + await writeFile( + transcriptPath, + `${JSON.stringify({ + type: 'user', + uuid: 'message-1', + timestamp: '2026-09-25T00:00:00.000Z', + message: { role: 'user', content: 'Viewer fixture' } + })}\n` + ) +}) + +afterEach(async () => { + clearNativeChatSubscriptions() + await rm(transcriptDirectory, { recursive: true, force: true }) +}) + +it.each(['did-navigate', 'render-process-gone', 'destroyed'])( + 'releases the installed transcript watcher on %s', + async (event) => { + const sender = Object.assign(new EventEmitter(), { + id: 1, + isDestroyed: () => false, + send: vi.fn() + }) + const before = getActiveNativeChatWatcherCount() + listeners.get('nativeChat:subscribe')?.( + { sender }, + { subscriptionId: 'view', agent: 'claude', sessionId: 'session', transcriptPath } + ) + await vi.waitFor(() => expect(sender.send).toHaveBeenCalled()) + expect(getActiveNativeChatWatcherCount()).toBe(before + 1) + sender.emit(event) + expect(getActiveNativeChatWatcherCount()).toBe(before) + } +) diff --git a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts index 1c90227cfd2..071a3c6ddb6 100644 --- a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts +++ b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from 'node:events' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { NativeChatTurnLifecycle } from '../../shared/native-chat-types' @@ -48,6 +49,7 @@ type SenderHarness = { id: number isDestroyed: () => boolean once: (event: string, callback: () => void) => void + removeListener: (event: string, callback: () => void) => void send: ReturnType } } @@ -78,23 +80,18 @@ function deferredSubscription(): DeferredSubscription { function createSender(id: number): SenderHarness { let destroyed = false - const destroyedCallbacks: (() => void)[] = [] + const events = new EventEmitter() return { destroy: () => { destroyed = true - for (const callback of destroyedCallbacks) { - callback() - } + events.emit('destroyed') }, - registeredCleanupCount: () => destroyedCallbacks.length, + registeredCleanupCount: () => events.listenerCount('destroyed'), sender: { id, isDestroyed: () => destroyed, - once: (event, callback) => { - if (event === 'destroyed') { - destroyedCallbacks.push(callback) - } - }, + once: (event, callback) => void events.once(event, callback), + removeListener: (event, callback) => void events.removeListener(event, callback), send: vi.fn() } } diff --git a/src/main/ipc/native-chat.test.ts b/src/main/ipc/native-chat.test.ts index 680dd315339..4892990c4ac 100644 --- a/src/main/ipc/native-chat.test.ts +++ b/src/main/ipc/native-chat.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from 'node:events' import { appendFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -188,17 +189,11 @@ describe('nativeChat:readSession handler', () => { expect(subscribe).toBeDefined() const sent: { channel: string; payload: unknown }[] = [] - let destroyedCb: (() => void) | undefined - const sender = { + const sender = Object.assign(new EventEmitter(), { id: 1, isDestroyed: () => false, - once: (event: string, cb: () => void) => { - if (event === 'destroyed') { - destroyedCb = cb - } - }, send: (channel: string, payload: unknown) => sent.push({ channel, payload }) - } + }) const previousHome = process.env.HOME process.env.HOME = root @@ -241,8 +236,8 @@ describe('nativeChat:readSession handler', () => { expect(appendedIds()).toContain('a-1') // Destroyed window tears down the watcher without error. - expect(destroyedCb).toBeDefined() - destroyedCb!() + expect(sender.listenerCount('destroyed')).toBe(1) + sender.emit('destroyed') } finally { if (previousHome === undefined) { delete process.env.HOME @@ -264,17 +259,11 @@ describe('nativeChat:readSession handler', () => { expect(subscribe).toBeDefined() const sent: { channel: string; payload: unknown }[] = [] - let destroyedCb: (() => void) | undefined - const sender = { + const sender = Object.assign(new EventEmitter(), { id: 7, isDestroyed: () => false, - once: (event: string, cb: () => void) => { - if (event === 'destroyed') { - destroyedCb = cb - } - }, send: (channel: string, payload: unknown) => sent.push({ channel, payload }) - } + }) const previousHome = process.env.HOME process.env.HOME = root @@ -290,7 +279,7 @@ describe('nativeChat:readSession handler', () => { } }) - destroyedCb!() + sender.emit('destroyed') } finally { if (previousHome === undefined) { delete process.env.HOME @@ -322,17 +311,11 @@ describe('nativeChat:readSession handler', () => { expect(subscribe).toBeDefined() let destroyed = false - let destroyedCb: (() => void) | undefined - const sender = { + const sender = Object.assign(new EventEmitter(), { id: 41, isDestroyed: () => destroyed, - once: (event: string, cb: () => void) => { - if (event === 'destroyed') { - destroyedCb = cb - } - }, send: vi.fn() - } + }) const previousHome = process.env.HOME process.env.HOME = root @@ -346,9 +329,9 @@ describe('nativeChat:readSession handler', () => { } ) - expect(destroyedCb).toBeDefined() + expect(sender.listenerCount('destroyed')).toBe(1) destroyed = true - destroyedCb!() + sender.emit('destroyed') await waitFor(() => _getNativeChatSenderCleanupCountForTest() === 0) expect(sender.send).not.toHaveBeenCalled() diff --git a/src/main/ipc/native-chat.ts b/src/main/ipc/native-chat.ts index f94424b67f6..9e5058282ef 100644 --- a/src/main/ipc/native-chat.ts +++ b/src/main/ipc/native-chat.ts @@ -12,6 +12,7 @@ import { type NativeChatTranscriptSubscription, type SubscribeNativeChatTranscriptArgs } from '../native-chat/transcript-watch' +import { abortWhenRendererGone } from './renderer-lifetime-abort' // Re-export so existing test imports of `clearNativeChatTranscriptCache` from // this module keep working after the cache moved to transcript-read-cache.ts. @@ -89,14 +90,12 @@ type PendingSubscription = { controller: AbortController } -// Why: live subscriptions are keyed by (webContents.id, subscriptionId) so the -// same renderer can watch several panes, and a destroyed window tears down all -// of its watchers — strict teardown to avoid fd leaks (plan U4 risk). +// A renderer document owns its viewers; replacement, crash, or destruction releases them. const liveSubscriptions = new Map>() // Why: unsubscribe and renderer destruction must invalidate async watcher setup // before it can publish a late subscription into the live map. const pendingSubscriptions = new Map>() -const senderCleanupRegistered = new Set() +const senderLifetimes = new Map>() function teardownSubscription(senderId: number, subscriptionId: string): void { const pendingBySubId = pendingSubscriptions.get(senderId) @@ -118,8 +117,9 @@ function teardownSubscription(senderId: number, subscriptionId: string): void { } function teardownAllForSender(senderId: number): void { - // The destroyed event can arrive before async subscription setup stores a watcher. - senderCleanupRegistered.delete(senderId) + const lifetime = senderLifetimes.get(senderId) + senderLifetimes.delete(senderId) + lifetime?.dispose() for (const pending of pendingSubscriptions.get(senderId)?.values() ?? []) { pending.controller.abort() } @@ -134,13 +134,22 @@ function teardownAllForSender(senderId: number): void { liveSubscriptions.delete(senderId) } -function registerSenderCleanup(sender: WebContents): void { - if (senderCleanupRegistered.has(sender.id)) { - return +function registerSenderCleanup(sender: WebContents): AbortSignal { + const existing = senderLifetimes.get(sender.id) + if (existing) { + return existing.signal } - senderCleanupRegistered.add(sender.id) - // Strict teardown: a closed/reloaded window releases every watcher it owns. - sender.once('destroyed', () => teardownAllForSender(sender.id)) + const lifetime = abortWhenRendererGone(sender) + const onRendererGone = (): void => teardownAllForSender(sender.id) + senderLifetimes.set(sender.id, { + signal: lifetime.signal, + dispose: () => { + lifetime.signal.removeEventListener('abort', onRendererGone) + lifetime.dispose() + } + }) + lifetime.signal.addEventListener('abort', onRendererGone, { once: true }) + return lifetime.signal } function beginPendingSubscription(senderId: number, subscriptionId: string): PendingSubscription { @@ -177,7 +186,9 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW // Replace any prior subscription under the same id (session change/resubscribe). const pending = beginPendingSubscription(sender.id, subscriptionId) - registerSenderCleanup(sender) + const rendererSignal = registerSenderCleanup(sender) + const canPublish = (): boolean => + !sender.isDestroyed() && !rendererSignal.aborted && !pending.controller.signal.aborted const subscribeArgs: SubscribeNativeChatTranscriptArgs = { agent, @@ -185,7 +196,7 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg transcriptPath, initialLimit: limit, onTranscriptPending: () => { - if (sender.isDestroyed()) { + if (!canPublish()) { return } // `pending` marks a window with no transcript behind it yet; clients that @@ -197,7 +208,7 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg sender.send('nativeChat:appended', payload) }, onInitialSnapshot: (messages, hasMore, _beforeOffset, error, lifecycle) => { - if (sender.isDestroyed()) { + if (!canPublish()) { return } // Forward an initial-drain error so a watching client's first frame carries it @@ -215,7 +226,7 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg sender.send('nativeChat:appended', payload) }, onReplace: (messages, hasMore, _beforeOffset, lifecycle) => { - if (sender.isDestroyed()) { + if (!canPublish()) { return } sender.send('nativeChat:appended', { @@ -229,7 +240,7 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg } satisfies NativeChatAppendedPayload) }, onAppend: (messages, lifecycle) => { - if (sender.isDestroyed()) { + if (!canPublish()) { return } const payload: NativeChatAppendedPayload = { @@ -254,7 +265,7 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg // Why: unmount, destruction, or a newer same-id subscribe can invalidate setup // while path resolution is pending; only the owning generation may publish its watcher. const stillCurrent = takePendingSubscription(sender.id, subscriptionId, pending) - if (sender.isDestroyed() || !stillCurrent) { + if (!canPublish() || !stillCurrent) { subscription.unsubscribe() return } @@ -266,7 +277,7 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg } bySubId.set(subscriptionId, { subscription }) liveSubscriptions.set(sender.id, bySubId) - if (!subscription.watching && !sender.isDestroyed()) { + if (!subscription.watching && canPublish()) { const payload: NativeChatAppendedPayload = { subscriptionId, frame: { @@ -282,16 +293,19 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg /** Test-only: drop all live and pending transcript subscriptions between runs. */ export function clearNativeChatSubscriptions(): void { - const senderIds = new Set([...liveSubscriptions.keys(), ...pendingSubscriptions.keys()]) + const senderIds = new Set([ + ...senderLifetimes.keys(), + ...liveSubscriptions.keys(), + ...pendingSubscriptions.keys() + ]) for (const senderId of senderIds) { teardownAllForSender(senderId) } pendingSubscriptions.clear() - senderCleanupRegistered.clear() } export function _getNativeChatSenderCleanupCountForTest(): number { - return senderCleanupRegistered.size + return senderLifetimes.size } export function _getNativeChatPendingSubscriptionCountForTest(): number { diff --git a/src/main/ipc/notebook.ts b/src/main/ipc/notebook.ts index cb7f4e564b5..70f8224011b 100644 --- a/src/main/ipc/notebook.ts +++ b/src/main/ipc/notebook.ts @@ -49,11 +49,13 @@ export function registerNotebookHandlers(store: Store): void { 'notebook:listPythonEnvironments', async ( _event, - args: { filePath: string; rootPath: string | null } + args: { filePath: string; rootPath: string | null; runWorkspaceInterpreters: boolean } ): Promise => { await resolveAuthorizedPath(args.filePath, store) // Why the unresolved path: rootPath is in the same (possibly symlinked) form, e.g. /tmp. - return listPythonEnvironments(args.filePath, args.rootPath) + return listPythonEnvironments(args.filePath, args.rootPath, { + runWorkspaceInterpreters: args.runWorkspaceInterpreters === true + }) } ) diff --git a/src/main/ipc/notification-options.ts b/src/main/ipc/notification-options.ts index a19f6044a46..24d43545e13 100644 --- a/src/main/ipc/notification-options.ts +++ b/src/main/ipc/notification-options.ts @@ -76,7 +76,10 @@ function formatAgentNotificationStatusText(args: NotificationDispatchRequest): s if (args.agentState === 'working') { return translateMain('notifications.agentStatus.working', 'working') } - return args.agentState === 'done' && args.agentInterrupted + if (args.agentState === 'done' && args.agentTurnOutcome === 'failure') { + return translateMain('notifications.agentStatus.failed', 'failed') + } + return args.agentState === 'done' && args.agentTurnOutcome === 'cancellation' ? translateMain('notifications.agentStatus.stopped', 'stopped') : translateMain('notifications.agentStatus.finished', 'finished') } @@ -104,7 +107,7 @@ function hasAgentNotificationSnapshot(args: NotificationDispatchRequest): boolea args.agentToolName || args.agentToolInput || args.agentLastAssistantMessage || - args.agentInterrupted + args.agentTurnOutcome !== undefined ) } diff --git a/src/main/ipc/notifications-message-formatting.test.ts b/src/main/ipc/notifications-message-formatting.test.ts index abf41bfae3c..3d519293f3c 100644 --- a/src/main/ipc/notifications-message-formatting.test.ts +++ b/src/main/ipc/notifications-message-formatting.test.ts @@ -217,7 +217,7 @@ describe('registerNotificationHandlers', () => { worktreeLabel: 'feat/notis', agentType: 'claude', agentState: 'done', - agentInterrupted: true, + agentTurnOutcome: 'cancellation', agentLastAssistantMessage: 'Stopped by user.' } ) @@ -316,7 +316,12 @@ describe('registerNotificationHandlers', () => { ) }) - it('reports an interrupted finish as stopped', async () => { + it.each([ + { agentTurnOutcome: 'cancellation', word: 'stopped' }, + { agentTurnOutcome: 'failure', word: 'failed' }, + { agentTurnOutcome: 'success', word: 'finished' }, + { agentTurnOutcome: undefined, word: 'finished' } + ] as const)('words a $agentTurnOutcome finish as $word', async ({ agentTurnOutcome, word }) => { registerNotificationHandlers({ getSettings: () => ({ notifications: { @@ -336,14 +341,40 @@ describe('registerNotificationHandlers', () => { worktreeLabel: 'feat/notis', agentType: 'claude', agentState: 'done', - agentInterrupted: true + ...(agentTurnOutcome ? { agentTurnOutcome } : {}) } ) expect(notificationCtorMock).toHaveBeenCalledWith( expectedNativeNotificationOptions({ - title: 'feat/notis - Claude stopped', - body: 'Claude stopped.' + title: `feat/notis - Claude ${word}`, + body: `Claude ${word}.` + }) + ) + }) + + it('counts a success verdict alone as an agent snapshot', async () => { + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: false, + suppressWhenFocused: true + } + }) + } as never) + + const handler = getDispatchHandler() + await handler( + {}, + { source: 'agent-task-complete', worktreeLabel: 'feat/notis', agentTurnOutcome: 'success' } + ) + + expect(notificationCtorMock).toHaveBeenCalledWith( + expectedNativeNotificationOptions({ + title: 'feat/notis - Agent finished', + body: 'Agent finished.' }) ) }) diff --git a/src/main/ipc/orca-profile-project-transfer-args.ts b/src/main/ipc/orca-profile-project-transfer-args.ts new file mode 100644 index 00000000000..c2882baf6c4 --- /dev/null +++ b/src/main/ipc/orca-profile-project-transfer-args.ts @@ -0,0 +1,25 @@ +import type { TransferOrcaProfileProjectArgs } from '../../shared/orca-profiles' + +export function transferProjectArgsFromUnknown(args: unknown): TransferOrcaProfileProjectArgs { + if ( + typeof args !== 'object' || + args === null || + !('sourceProfileId' in args) || + typeof args.sourceProfileId !== 'string' || + !('targetProfileId' in args) || + typeof args.targetProfileId !== 'string' || + !('repoId' in args) || + typeof args.repoId !== 'string' || + !('mode' in args) || + (args.mode !== 'move' && args.mode !== 'copy') + ) { + throw new Error('invalid_orca_profile_project_transfer') + } + const sourceProfileId = args.sourceProfileId.trim() + const targetProfileId = args.targetProfileId.trim() + const repoId = args.repoId.trim() + if (!sourceProfileId || !targetProfileId || !repoId) { + throw new Error('invalid_orca_profile_project_transfer') + } + return { sourceProfileId, targetProfileId, repoId, mode: args.mode } +} diff --git a/src/main/ipc/orca-profiles-switch-persistence.test.ts b/src/main/ipc/orca-profiles-switch-persistence.test.ts new file mode 100644 index 00000000000..e948d4054e7 --- /dev/null +++ b/src/main/ipc/orca-profiles-switch-persistence.test.ts @@ -0,0 +1,115 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from '../persistence/loading-store/profile-state-maintenance-fixture' +import { registerOrcaProfileHandlers } from './orca-profiles' + +const { handlers, quit, select } = vi.hoisted(() => ({ + handlers: new Map Promise>(), + quit: vi.fn(), + select: vi.fn() +})) +vi.mock('electron', () => ({ + app: { quit }, + ipcMain: { + handle: (channel: string, handler: (event: unknown, args: unknown) => Promise) => { + handlers.set(channel, handler) + } + } +})) +vi.mock('../app-relaunch', () => ({ relaunchApp: vi.fn() })) +vi.mock('../orca-profiles/profile-index-store', () => ({ + getOrcaProfileListState: () => ({ activeProfileId: 'source', profiles: [] }), + setActiveOrcaProfile: select, + createLocalOrcaProfile: vi.fn(), + seedNewOrcaProfileTelemetryConsent: vi.fn() +})) +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +describe('plain profile switch persistence', () => { + it('preserves shutdown changes when quit starts during the switch checkpoint', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + store.upsertSshRemotePtyLease({ targetId: 'remote', ptyId: 'pty', state: 'attached' }) + await store.flushPendingOrThrowAsync() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const hold = async (write: () => Promise) => { + started.resolve() + await release.promise + await write() + } + const selective = authority.writeSerializedDomains.bind(authority) + const complete = authority.writeCompleteSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce((domains) => + hold(() => selective(domains)) + ) + vi.spyOn(authority, 'writeCompleteSerializedDomains').mockImplementationOnce((domains) => + hold(() => complete(domains)) + ) + store.updateSettings({ theme: 'dark' }) + registerOrcaProfileHandlers(store) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const switching = handlers + .get('orcaProfiles:switch')?.( + { sender: { isDestroyed: () => false, send: vi.fn() } }, + { profileId: 'target' } + ) + .catch((error: unknown) => error) + await started.promise + store.markSshRemotePtyLeasesForShutdown('remote', 'detached') + const final = store.flushFinalOrThrowAsync() + release.resolve() + await final + await expect(switching).resolves.toEqual({ status: 'relaunching' }) + expect(readState()).toMatchObject({ + settings: { theme: 'dark' }, + sshRemotePtyLeases: [expect.objectContaining({ state: 'detached' })] + }) + }) + + it('admits pre-relaunch writes and includes SSH detach in the final source checkpoint', async () => { + const { store, readState, dataFile } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + store.upsertSshRemotePtyLease({ targetId: 'remote', ptyId: 'pty', state: 'attached' }) + await store.flushPendingOrThrowAsync() + const cleanupSaved = vi.fn() + let final: Promise | undefined + quit.mockImplementation(() => { + store.markSshRemotePtyLeasesForShutdown('remote', 'detached') + final = store.flushFinalOrThrowAsync({ exportJsonCompatibility: true }) + }) + registerOrcaProfileHandlers(store, { + onBeforeRelaunch: async () => { + store.updateSettings({ theme: 'light' }) + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + cleanupSaved() + } + }) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const switchProfile = handlers.get('orcaProfiles:switch') + expect(switchProfile).toBeDefined() + await switchProfile?.( + { sender: { isDestroyed: () => false, send: vi.fn() } }, + { profileId: 'target' } + ) + await vi.advanceTimersByTimeAsync(150) + expect(final).toBeDefined() + await final + expect(select).toHaveBeenCalledWith('target') + expect(cleanupSaved).toHaveBeenCalledOnce() + expect(readState()).toMatchObject({ + settings: { theme: 'light' }, + sshRemotePtyLeases: [expect.objectContaining({ state: 'detached' })] + }) + expect(JSON.parse(readFileSync(dataFile, 'utf8'))).toEqual(readState()) + }) +}) diff --git a/src/main/ipc/orca-profiles.test.ts b/src/main/ipc/orca-profiles.test.ts index 215b559327d..2a8b24851d3 100644 --- a/src/main/ipc/orca-profiles.test.ts +++ b/src/main/ipc/orca-profiles.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ProfileStoragePaths from '../orca-profiles/profile-storage-paths' const { handlers, @@ -11,7 +12,8 @@ const { getOrcaProfileListStateMock, seedNewOrcaProfileTelemetryConsentMock, setActiveOrcaProfileMock, - transferOrcaProfileProjectMock + transferOrcaProfileProjectMock, + hasOrcaProfileStateDatabaseMock } = vi.hoisted(() => ({ handlers: new Map unknown>(), appExitMock: vi.fn(), @@ -23,7 +25,8 @@ const { getOrcaProfileListStateMock: vi.fn(), seedNewOrcaProfileTelemetryConsentMock: vi.fn(), setActiveOrcaProfileMock: vi.fn(), - transferOrcaProfileProjectMock: vi.fn() + transferOrcaProfileProjectMock: vi.fn(), + hasOrcaProfileStateDatabaseMock: vi.fn() })) vi.mock('electron', () => ({ @@ -54,21 +57,36 @@ vi.mock('../orca-profiles/profile-index-store', () => ({ setActiveOrcaProfile: setActiveOrcaProfileMock })) -function makeStoreMock(flushPendingOrThrowAsync = vi.fn()): { - flushPendingOrThrowAsync: typeof flushPendingOrThrowAsync - freezeWrites: ReturnType - getSettings: () => Record -} { - return { flushPendingOrThrowAsync, freezeWrites: vi.fn(), getSettings: () => ({}) } +function makeStoreMock(flushPendingOrThrowAsync = vi.fn()) { + const freezeWrites = vi.fn() + const resumeMaintenance = vi.fn(async () => {}) + return { + flushPendingOrThrowAsync, + freezeWrites, + resumeMaintenance, + beginProfileMaintenance: vi.fn(async (options: unknown) => { + await flushPendingOrThrowAsync(options) + freezeWrites() + return { resume: resumeMaintenance } + }), + getSettings: () => ({}) + } } vi.mock('../orca-profiles/profile-project-transfer', () => ({ transferOrcaProfileProject: transferOrcaProfileProjectMock })) +vi.mock('../orca-profiles/profile-storage-paths', async (importOriginal) => ({ + ...(await importOriginal()), + hasOrcaProfileStateDatabase: hasOrcaProfileStateDatabaseMock +})) + import { registerOrcaProfileHandlers } from './orca-profiles' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' +const ipcEvent = { sender: { isDestroyed: () => false, send: vi.fn() } } + describe('registerOrcaProfileHandlers', () => { beforeEach(() => { // Why the port and per-test: userData resolves through AppEnvironment now, and @@ -76,6 +94,7 @@ describe('registerOrcaProfileHandlers', () => { installFakeAppEnvironment({ getPath: () => '/tmp/orca-user-data' }) vi.useFakeTimers() handlers.clear() + ipcEvent.sender.send.mockClear() appExitMock.mockReset() appQuitMock.mockReset() appRelaunchMock.mockReset() @@ -87,6 +106,7 @@ describe('registerOrcaProfileHandlers', () => { seedNewOrcaProfileTelemetryConsentMock.mockReset() setActiveOrcaProfileMock.mockReset() transferOrcaProfileProjectMock.mockReset() + hasOrcaProfileStateDatabaseMock.mockReset().mockReturnValue(false) }) afterEach(() => { @@ -107,12 +127,12 @@ describe('registerOrcaProfileHandlers', () => { registerOrcaProfileHandlers(makeStoreMock() as never) - await expect(Promise.resolve(handlers.get('orcaProfiles:list')?.(null))).resolves.toEqual({ + await expect(Promise.resolve(handlers.get('orcaProfiles:list')?.(ipcEvent))).resolves.toEqual({ ...listState, multiProfileUi: false }) await expect( - Promise.resolve(handlers.get('orcaProfiles:createLocal')?.(null, { name: 'Work' })) + Promise.resolve(handlers.get('orcaProfiles:createLocal')?.(ipcEvent, { name: 'Work' })) ).resolves.toBe(createState) expect(createLocalOrcaProfileMock).toHaveBeenCalledWith({ name: 'Work' }) }) @@ -127,11 +147,13 @@ describe('registerOrcaProfileHandlers', () => { }) registerOrcaProfileHandlers(makeStoreMock() as never) - await expect(Promise.resolve(handlers.get('orcaProfiles:list')?.(null))).resolves.toEqual({ - activeProfileId: 'local-default', - profiles: [], - multiProfileUi: true - }) + await expect(Promise.resolve(handlers.get('orcaProfiles:list')?.(ipcEvent))).resolves.toEqual( + { + activeProfileId: 'local-default', + profiles: [], + multiProfileUi: true + } + ) } finally { if (previous === undefined) { delete process.env.ORCA_MULTI_PROFILE_UI @@ -155,7 +177,7 @@ describe('registerOrcaProfileHandlers', () => { registerOrcaProfileHandlers(makeStoreMock(flush) as never, { onBeforeRelaunch }) const resultPromise = Promise.resolve( - handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-work' }) + handlers.get('orcaProfiles:switch')?.(ipcEvent, { profileId: 'local-work' }) ) await expect(resultPromise).resolves.toEqual({ status: 'relaunching' }) @@ -189,7 +211,7 @@ describe('registerOrcaProfileHandlers', () => { registerOrcaProfileHandlers(makeStoreMock(flush) as never) await expect( - Promise.resolve(handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-work' })) + Promise.resolve(handlers.get('orcaProfiles:switch')?.(ipcEvent, { profileId: 'local-work' })) ).rejects.toThrow('flush_failed') expect(setActiveOrcaProfileMock).not.toHaveBeenCalled() @@ -206,10 +228,10 @@ describe('registerOrcaProfileHandlers', () => { registerOrcaProfileHandlers(makeStoreMock(flush) as never, { onBeforeRelaunch }) const switchProfile = Promise.resolve( - handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-work' }) + handlers.get('orcaProfiles:switch')?.(ipcEvent, { profileId: 'local-work' }) ) const rejection = expect(switchProfile).rejects.toThrow('orca_profile_persistence_timeout') - await vi.advanceTimersByTimeAsync(20_000) + await vi.advanceTimersByTimeAsync(60_000) await rejection expect(setActiveOrcaProfileMock).not.toHaveBeenCalled() @@ -225,7 +247,9 @@ describe('registerOrcaProfileHandlers', () => { registerOrcaProfileHandlers(makeStoreMock() as never) await expect( - Promise.resolve(handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-default' })) + Promise.resolve( + handlers.get('orcaProfiles:switch')?.(ipcEvent, { profileId: 'local-default' }) + ) ).resolves.toEqual({ status: 'already-active' }) expect(setActiveOrcaProfileMock).not.toHaveBeenCalled() @@ -236,7 +260,7 @@ describe('registerOrcaProfileHandlers', () => { registerOrcaProfileHandlers(makeStoreMock() as never) await expect( - Promise.resolve(handlers.get('orcaProfiles:switch')?.(null, { profileId: ' ' })) + Promise.resolve(handlers.get('orcaProfiles:switch')?.(ipcEvent, { profileId: ' ' })) ).rejects.toThrow('invalid_orca_profile_id') }) @@ -260,7 +284,7 @@ describe('registerOrcaProfileHandlers', () => { await expect( Promise.resolve( - handlers.get('orcaProfiles:transferProject')?.(null, { + handlers.get('orcaProfiles:transferProject')?.(ipcEvent, { sourceProfileId: ' personal ', targetProfileId: ' work ', repoId: ' repo-1 ', @@ -302,7 +326,7 @@ describe('registerOrcaProfileHandlers', () => { await expect( Promise.resolve( - handlers.get('orcaProfiles:transferProject')?.(null, { + handlers.get('orcaProfiles:transferProject')?.(ipcEvent, { sourceProfileId: 'personal', targetProfileId: 'work', repoId: 'repo-1', @@ -328,11 +352,56 @@ describe('registerOrcaProfileHandlers', () => { await vi.advanceTimersByTimeAsync(150) expect(appRelaunchMock).toHaveBeenCalledOnce() + expect(ipcEvent.sender.send).toHaveBeenCalledWith('app:restart-committed') expect(relaunchAppMock).toHaveBeenCalledWith('profile-transfer') expect(appQuitMock).toHaveBeenCalledOnce() expect(appExitMock).not.toHaveBeenCalled() }) + it('relaunches the closed source when a completed move cannot update the profile index', async () => { + const store = makeStoreMock() + getOrcaProfileListStateMock.mockReturnValue({ activeProfileId: 'personal', profiles: [] }) + transferOrcaProfileProjectMock.mockReturnValue({ status: 'transferred', mode: 'move' }) + setActiveOrcaProfileMock.mockImplementationOnce(() => { + throw new Error('profile index disk full') + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every Store operation exercised by these IPC handlers. + registerOrcaProfileHandlers(store as never) + + await expect( + handlers.get('orcaProfiles:transferProject')?.(ipcEvent, { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'move' + }) + ).rejects.toThrow('profile index disk full') + + expect(store.freezeWrites).toHaveBeenCalledOnce() + expect(store.resumeMaintenance).not.toHaveBeenCalled() + expect(ipcEvent.sender.send).toHaveBeenCalledWith('app:restart-committed') + await vi.advanceTimersByTimeAsync(150) + expect(relaunchAppMock).toHaveBeenCalledWith('profile-transfer') + expect(appQuitMock).toHaveBeenCalledOnce() + }) + + it('keeps the active profile writable during a transfer between inactive profiles', async () => { + const store = makeStoreMock() + getOrcaProfileListStateMock.mockReturnValue({ activeProfileId: 'active', profiles: [] }) + transferOrcaProfileProjectMock.mockReturnValue({ status: 'transferred', mode: 'copy' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies the Store operations exercised by the handlers. + registerOrcaProfileHandlers(store as never) + await handlers.get('orcaProfiles:transferProject')?.(ipcEvent, { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'copy' + }) + expect(store.beginProfileMaintenance).not.toHaveBeenCalled() + expect(store.freezeWrites).not.toHaveBeenCalled() + expect(store.flushPendingOrThrowAsync).toHaveBeenCalledBefore(transferOrcaProfileProjectMock) + }) + it('rejects transfers that would mutate the active target profile offline', async () => { getOrcaProfileListStateMock.mockReturnValue({ activeProfileId: 'work', @@ -342,7 +411,7 @@ describe('registerOrcaProfileHandlers', () => { await expect( Promise.resolve( - handlers.get('orcaProfiles:transferProject')?.(null, { + handlers.get('orcaProfiles:transferProject')?.(ipcEvent, { sourceProfileId: 'personal', targetProfileId: 'work', repoId: 'repo-1', @@ -353,4 +422,84 @@ describe('registerOrcaProfileHandlers', () => { expect(transferOrcaProfileProjectMock).not.toHaveBeenCalled() }) + + it('freezes a newly migrated source after transfer failure and reopens its current profile', async () => { + const store = makeStoreMock() + const onBeforeRelaunch = vi.fn() + getOrcaProfileListStateMock.mockReturnValue({ activeProfileId: 'personal', profiles: [] }) + transferOrcaProfileProjectMock.mockImplementation(() => { + hasOrcaProfileStateDatabaseMock.mockReturnValue(true) + throw new Error('source commit interrupted') + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every Store operation exercised by these IPC handlers. + registerOrcaProfileHandlers(store as never, { onBeforeRelaunch }) + + await expect( + Promise.resolve( + handlers.get('orcaProfiles:transferProject')?.(ipcEvent, { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'move' + }) + ) + ).rejects.toThrow('source commit interrupted') + + expect(store.flushPendingOrThrowAsync).toHaveBeenCalledBefore(transferOrcaProfileProjectMock) + expect(store.freezeWrites).toHaveBeenCalledOnce() + expect(store.freezeWrites).toHaveBeenCalledBefore(onBeforeRelaunch) + expect(setActiveOrcaProfileMock).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(150) + expect(ipcEvent.sender.send).toHaveBeenCalledWith('app:restart-committed') + expect(relaunchAppMock).toHaveBeenCalledWith('profile-transfer') + expect(appQuitMock).toHaveBeenCalledOnce() + }) + + it('keeps an active JSON source writable after validation fails without a migration', async () => { + const store = makeStoreMock() + const onBeforeRelaunch = vi.fn() + getOrcaProfileListStateMock.mockReturnValue({ activeProfileId: 'personal', profiles: [] }) + transferOrcaProfileProjectMock.mockImplementation(() => { + throw new Error('unknown_source_repo') + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every Store operation exercised by these IPC handlers. + registerOrcaProfileHandlers(store as never, { onBeforeRelaunch }) + + await expect( + Promise.resolve( + handlers.get('orcaProfiles:transferProject')?.(ipcEvent, { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'move' + }) + ) + ).rejects.toThrow('unknown_source_repo') + + expect(store.resumeMaintenance).toHaveBeenCalledOnce() + expect(onBeforeRelaunch).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(150) + expect(relaunchAppMock).not.toHaveBeenCalled() + }) + + it.each([ + null, + {}, + { sourceProfileId: 4 }, + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'invalid' + } + ])('rejects malformed transfer arguments before disk work: %j', async (args) => { + const store = makeStoreMock() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every Store operation exercised by these IPC handlers. + registerOrcaProfileHandlers(store as never) + await expect( + Promise.resolve(handlers.get('orcaProfiles:transferProject')?.(ipcEvent, args)) + ).rejects.toThrow('invalid_orca_profile_project_transfer') + expect(store.flushPendingOrThrowAsync).not.toHaveBeenCalled() + expect(transferOrcaProfileProjectMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/orca-profiles.ts b/src/main/ipc/orca-profiles.ts index 480c6f350f9..694841e544e 100644 --- a/src/main/ipc/orca-profiles.ts +++ b/src/main/ipc/orca-profiles.ts @@ -1,4 +1,4 @@ -import { app, ipcMain } from 'electron' +import { app, ipcMain, type WebContents } from 'electron' import type { Store } from '../persistence' import { relaunchApp, type AppRelaunchReason } from '../app-relaunch' import type { @@ -33,8 +33,12 @@ import { import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { isMultiProfileUiEnabled } from '../orca-profiles/profile-ui-scope' import { transferOrcaProfileProject } from '../orca-profiles/profile-project-transfer' +import { transferActiveProfileProject } from '../orca-profiles/profile-active-transfer' import { findOrcaProfileProjectsByPath } from '../orca-profiles/profile-project-presence' -import { flushActiveProfileBeforeFileMutation } from '../orca-profiles/profile-persistence-deadline' +import { + flushActiveProfileBeforeFileMutation, + flushActiveProfileBeforeRelaunch +} from '../orca-profiles/profile-persistence-deadline' import { normalizeExecutionHostId } from '../../shared/execution-host' import { createCloudLinkedOrcaProfile, @@ -47,6 +51,7 @@ import { import { registerOrcaProfileOrgMemberHandlers } from './orca-profile-org-members-handlers' import { onOrcaCloudSessionInvalidated } from '../orca-profiles/profile-cloud-session-invalidation' import { broadcastOrcaProfileAuthStatusChanged } from './orca-profile-auth-status-broadcast' +import { transferProjectArgsFromUnknown } from './orca-profile-project-transfer-args' type RegisterOrcaProfileHandlersOptions = { onBeforeRelaunch?: () => void | Promise @@ -55,40 +60,16 @@ type RegisterOrcaProfileHandlersOptions = { } function profileIdFromArgs(args: unknown): string { - if ( - !args || - typeof args !== 'object' || - typeof (args as SwitchOrcaProfileArgs).profileId !== 'string' - ) { - throw new Error('invalid_orca_profile_id') - } - const profileId = (args as SwitchOrcaProfileArgs).profileId.trim() + const profileId = + args && typeof args === 'object' && 'profileId' in args && typeof args.profileId === 'string' + ? args.profileId.trim() + : '' if (!profileId) { throw new Error('invalid_orca_profile_id') } return profileId } -function transferProjectArgsFromUnknown(args: unknown): TransferOrcaProfileProjectArgs { - if (!args || typeof args !== 'object') { - throw new Error('invalid_orca_profile_project_transfer') - } - const candidate = args as TransferOrcaProfileProjectArgs - const sourceProfileId = candidate.sourceProfileId?.trim() - const targetProfileId = candidate.targetProfileId?.trim() - const repoId = candidate.repoId?.trim() - const mode = candidate.mode - if (!sourceProfileId || !targetProfileId || !repoId || (mode !== 'move' && mode !== 'copy')) { - throw new Error('invalid_orca_profile_project_transfer') - } - return { - sourceProfileId, - targetProfileId, - repoId, - mode - } -} - function findProjectsByPathArgsFromUnknown(args: unknown): FindOrcaProfileProjectsByPathArgs { if (!args || typeof args !== 'object') { throw new Error('invalid_orca_profile_project_path') @@ -157,7 +138,12 @@ async function runBeforeProfileRelaunch( } } -function scheduleProfileRelaunch(reason: Extract): void { +type ProfileRelaunchReason = Extract + +function scheduleProfileRelaunch(reason: ProfileRelaunchReason, sender: WebContents): void { + if (!sender.isDestroyed()) { + sender.send('app:restart-committed') + } setTimeout(() => { relaunchApp(reason) // Why: app.quit() (not app.exit) so before-quit/will-quit still run — @@ -197,7 +183,7 @@ export function registerOrcaProfileHandlers( ipcMain.handle( 'orcaProfiles:switch', - async (_event, args: SwitchOrcaProfileArgs): Promise => { + async (event, args: SwitchOrcaProfileArgs): Promise => { const profileId = profileIdFromArgs(args) const current = getOrcaProfileListState() if (profileId === current.activeProfileId) { @@ -217,11 +203,12 @@ export function registerOrcaProfileHandlers( } // Why: the current profile must be persisted before the global index // points startup at the target profile. - await flushActiveProfileBeforeFileMutation(store) - await runBeforeProfileRelaunch(options.onBeforeRelaunch) + // Switching leaves source files intact; relaunch cleanup still needs its live writer. + await flushActiveProfileBeforeRelaunch(store) setActiveOrcaProfile(profileId) + await runBeforeProfileRelaunch(options.onBeforeRelaunch) - scheduleProfileRelaunch('profile-switch') + scheduleProfileRelaunch('profile-switch', event.sender) return { status: 'relaunching' } } @@ -230,7 +217,7 @@ export function registerOrcaProfileHandlers( ipcMain.handle( 'orcaProfiles:transferProject', async ( - _event, + event, rawArgs: TransferOrcaProfileProjectArgs ): Promise => { const args = transferProjectArgsFromUnknown(rawArgs) @@ -241,19 +228,37 @@ export function registerOrcaProfileHandlers( if (args.mode === 'move' && args.sourceProfileId === current.activeProfileId) { // Why: transfer before any relaunch side effect so a duplicate-target // or validation failure cannot strand the app in a quitting state. - await flushActiveProfileBeforeFileMutation(store) - const result = transferOrcaProfileProject(args, getProfileUserDataPath()) + const result = await transferActiveProfileProject( + args, + getProfileUserDataPath(), + store, + async () => { + await runBeforeProfileRelaunch(options.onBeforeRelaunch) + scheduleProfileRelaunch('profile-transfer', event.sender) + } + ) if (result.status === 'transferred') { - store.freezeWrites() await runBeforeProfileRelaunch(options.onBeforeRelaunch) - setActiveOrcaProfile(args.targetProfileId) - scheduleProfileRelaunch('profile-transfer') + try { + setActiveOrcaProfile(args.targetProfileId) + } finally { + // The source has already changed and its writer cannot resume. + scheduleProfileRelaunch('profile-transfer', event.sender) + } return { ...result, willRelaunch: true } } return result } - await flushActiveProfileBeforeFileMutation(store) - return transferOrcaProfileProject(args, getProfileUserDataPath()) + if (args.sourceProfileId !== current.activeProfileId) { + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + return transferOrcaProfileProject(args, getProfileUserDataPath()) + } + const maintenance = await flushActiveProfileBeforeFileMutation(store) + try { + return transferOrcaProfileProject(args, getProfileUserDataPath()) + } finally { + await maintenance.resume() + } } ) diff --git a/src/main/ipc/parcel-watcher-in-process-fallback.ts b/src/main/ipc/parcel-watcher-in-process-fallback.ts index bcd2812d35d..1807ba603a5 100644 --- a/src/main/ipc/parcel-watcher-in-process-fallback.ts +++ b/src/main/ipc/parcel-watcher-in-process-fallback.ts @@ -1,4 +1,5 @@ import type * as ParcelWatcher from '@parcel/watcher' +import { loadParcelWatcher } from './parcel-watcher-module-loader' import { createWatcherProcessEventDeliveryQueue } from './parcel-watcher-event-delivery' import { WatcherProcessFailure } from './parcel-watcher-process-failure' import type { @@ -64,7 +65,7 @@ export async function subscribeWithInProcessWatcher( try { // Why: setup ownership starts before module loading; an abort or timeout // during the import must settle the caller just like one during the crawl. - watcher = await Promise.race([import('@parcel/watcher'), cancellation]) + watcher = await Promise.race([loadParcelWatcher(), cancellation]) } catch (error) { clearPendingControls() throw error diff --git a/src/main/ipc/parcel-watcher-module-loader.test.ts b/src/main/ipc/parcel-watcher-module-loader.test.ts new file mode 100644 index 00000000000..98e5eada431 --- /dev/null +++ b/src/main/ipc/parcel-watcher-module-loader.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { loadParcelWatcher } from './parcel-watcher-module-loader' + +const state = vi.hoisted((): { named: unknown; fallback: unknown } => ({ + named: undefined, + fallback: undefined +})) +vi.mock('@parcel/watcher', () => ({ + get subscribe() { + return state.named + }, + get default() { + return state.fallback + } +})) +beforeEach(() => { + state.named = undefined + state.fallback = undefined +}) + +it('uses named exports when the runtime exposes them', async () => { + const subscribe = vi.fn() + state.named = subscribe + state.fallback = { subscribe: vi.fn() } + expect((await loadParcelWatcher()).subscribe).toBe(subscribe) +}) + +it('loads the full CommonJS default when a packaged wrapper has no named exports', async () => { + const subscribe = vi.fn() + const getEventsSince = vi.fn() + state.fallback = { subscribe, getEventsSince } + expect(await loadParcelWatcher()).toBe(state.fallback) + expect((await loadParcelWatcher()).getEventsSince).toBe(getEventsSince) +}) + +it.each([undefined, null, {}, { subscribe: false }])( + 'rejects invalid watcher exports (%j)', + async (fallback) => { + state.fallback = fallback + await expect(loadParcelWatcher()).rejects.toThrow('parcel_watcher_module_invalid') + } +) diff --git a/src/main/ipc/parcel-watcher-module-loader.ts b/src/main/ipc/parcel-watcher-module-loader.ts new file mode 100644 index 00000000000..28a501363b5 --- /dev/null +++ b/src/main/ipc/parcel-watcher-module-loader.ts @@ -0,0 +1,11 @@ +import type * as ParcelWatcher from '@parcel/watcher' + +/** Native loading stays in the watcher child, across named and CommonJS exports. */ +export async function loadParcelWatcher(): Promise { + const loaded = await import('@parcel/watcher') + const watcher = typeof loaded.subscribe === 'function' ? loaded : loaded.default + if (!watcher || typeof watcher.subscribe !== 'function') { + throw new Error('parcel_watcher_module_invalid') + } + return watcher +} diff --git a/src/main/ipc/parcel-watcher-process-entry.ts b/src/main/ipc/parcel-watcher-process-entry.ts index ad0e79d6b97..0de05010f47 100644 --- a/src/main/ipc/parcel-watcher-process-entry.ts +++ b/src/main/ipc/parcel-watcher-process-entry.ts @@ -8,6 +8,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import type * as ParcelWatcher from '@parcel/watcher' +import { loadParcelWatcher } from './parcel-watcher-module-loader' import { startShallowWatcher } from './parcel-watcher-shallow-subscription' import { detectShallowWatchDelivery } from './shallow-watch-delivery-probe' import { @@ -46,7 +47,7 @@ async function startCanary(getStableActivityRevision: () => number | null): Prom let lastEventAt = 0 try { canaryDir = configuredCanaryDir ?? mkdtempSync(join(tmpdir(), 'orca-watcher-canary-')) - const watcher = await import('@parcel/watcher') + const watcher = await loadParcelWatcher() // Why: pin the Windows backend like the main subscriptions do, so the // canary never probes for Watchman. const opts = ( @@ -211,7 +212,7 @@ function main(): void { send({ op: 'watch-error', id, message: errorMessage(error) }) ) } - const watcher = await import('@parcel/watcher') + const watcher = await loadParcelWatcher() return await watcher.subscribe( dir, (err, events) => { diff --git a/src/main/ipc/pty-controller-process-inventory.test.ts b/src/main/ipc/pty-controller-process-inventory.test.ts index e89d888978a..004735c61cb 100644 --- a/src/main/ipc/pty-controller-process-inventory.test.ts +++ b/src/main/ipc/pty-controller-process-inventory.test.ts @@ -47,7 +47,16 @@ vi.mock('node-pty', () => ({ })) vi.mock('../opencode/hook-service', () => ({ - openCodeHookService: { buildPtyEnv: () => ({}), clearPty: vi.fn() } + openCodeHookService: { + buildPtyEnv: () => ({}), + refreshLegacySharedPlugin: vi.fn(), + clearPty: vi.fn() + }, + openCode2HookService: { + buildPtyEnv: () => ({}), + refreshLegacySharedPlugin: vi.fn(), + clearPty: vi.fn() + } })) vi.mock('../pi/titlebar-extension-service', () => ({ diff --git a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts index f73f11e7f93..48cff20c9dd 100644 --- a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts +++ b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts @@ -173,13 +173,13 @@ describe('registerPtyHandlers', () => { expect(spawnOptions.envToDelete ?? []).not.toContain('ORCA_CODEX_HOME') expect(spawnOptions.envToDelete).toContain('REMOVE_ME') }) - it('prepares Codex project trust before a daemon-backed interactive launch', async () => { + it('prepares Codex launch state before a daemon-backed interactive launch', async () => { const workspacePath = '/repo/worktrees/new-feature' const resolveHome = vi.fn( ( _target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }, _launchEnv?: NodeJS.ProcessEnv, - _launchContext?: { workspacePath?: string; launchAgent?: TuiAgent } + _launchContext?: { unavailableManagedHomePath?: string } ) => null ) @@ -191,7 +191,6 @@ describe('registerPtyHandlers', () => { }) expect(resolveHome.mock.calls[0]?.[0]).toEqual({ runtime: 'host' }) - expect(resolveHome.mock.calls[0]?.[2]).toEqual({ workspacePath, launchAgent: 'codex' }) }) it('injects explicit proxy settings on the daemon path', async () => { const env = await daemonSpawnAndGetEnv({}, undefined, () => ({ @@ -413,6 +412,17 @@ describe('registerPtyHandlers', () => { ]) ) }) + it('strips an inherited agent session id', async () => { + // Why: a daemon forked by an Orca launched inside a structured session inherits its id, + // and every daemon pane would present that session as its orchestration caller. + const inherited = await daemonSpawnAndGetOptions(undefined, undefined, undefined, { + ORCA_AGENT_SESSION_ID: 'a0b1c2d3-0000-4000-8000-00000000abcd', + ORCA_STRUCTURED_SESSION: '1' + }) + expect(inherited.envToDelete).toEqual( + expect.arrayContaining(['ORCA_AGENT_SESSION_ID', 'ORCA_STRUCTURED_SESSION']) + ) + }) it('preserves an explicitly requested Claude child-session stamp', async () => { // Why: only inherited values are poison; a caller deliberately spawning a // nested Claude child passes the stamp in args.env and must keep it. diff --git a/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts b/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts index 128f163c5b3..5ae2adb49d7 100644 --- a/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts +++ b/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { openCodeClearPtyMock, piClearPtyMock } from './pty-ipc-mock-registry' import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops' import { SSH_PTY_IDENTITY_MISMATCH_ERROR, SSH_SESSION_EXPIRED_ERROR @@ -359,7 +360,8 @@ describe('registerPtyHandlers', () => { const exitListeners = new Set<(payload: { id: string; code: number }) => void>() const runtime = { setPtyController: vi.fn(), - onPtyExit: vi.fn() + onPtyExit: vi.fn(), + intentionalPtyStops: new TerminalIntentionalStops() } setLocalPtyProvider({ spawn: vi.fn(), @@ -393,15 +395,14 @@ describe('registerPtyHandlers', () => { handlers.clear() registerPtyHandlers(mainWindow as never, runtime as never) const controller = runtime.setPtyController.mock.calls[0]?.[0] as { - markReversibleStops: (ptyIds: readonly string[]) => () => void stopAndWait: (ptyId: string) => Promise } - const release = controller.markReversibleStops(['local-pty']) + const settleStop = runtime.intentionalPtyStops.mark('local-pty', 'reversible', null) const stopPromise = controller.stopAndWait('local-pty') await vi.advanceTimersByTimeAsync(1_200) await expect(stopPromise).resolves.toBe(true) - release() + settleStop(true) expect( mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit') diff --git a/src/main/ipc/pty-dead-owner-respawn.test.ts b/src/main/ipc/pty-dead-owner-respawn.test.ts index 4669d7e5528..7a72c0d53b4 100644 --- a/src/main/ipc/pty-dead-owner-respawn.test.ts +++ b/src/main/ipc/pty-dead-owner-respawn.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { withDurableRuntimeStore } from '../runtime/runtime-durable-store-fixture' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { SessionNotFoundError } from '../daemon/daemon-errors' import { makePaneKey } from '../../shared/stable-pane-id' @@ -102,7 +103,7 @@ describe('registerPtyHandlers', () => { }, terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-proven-absent-owner' } } - const store = { + const store = withDurableRuntimeStore({ getWorkspaceSession: vi.fn(() => session), setWorkspaceSession: vi.fn((next) => { session = next @@ -113,7 +114,7 @@ describe('registerPtyHandlers', () => { getFolderWorkspaces: vi.fn(() => []), getProjectGroups: vi.fn(() => []), getRepos: vi.fn(() => []) - } + }) const runtime = { setPtyController: vi.fn(), resolveTerminalPane: vi.fn(() => { @@ -226,7 +227,7 @@ describe('registerPtyHandlers', () => { }, terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-probe-blip-owner' } } - const store = { + const store = withDurableRuntimeStore({ getWorkspaceSession: vi.fn(() => session), setWorkspaceSession: vi.fn((next) => { session = next @@ -237,7 +238,7 @@ describe('registerPtyHandlers', () => { getFolderWorkspaces: vi.fn(() => []), getProjectGroups: vi.fn(() => []), getRepos: vi.fn(() => []) - } + }) const runtime = { setPtyController: vi.fn(), resolveTerminalPane: vi.fn(() => { @@ -350,7 +351,7 @@ describe('registerPtyHandlers', () => { }, terminalPtyIncarnationsByPaneKey: {} } - const store = { + const store = withDurableRuntimeStore({ getWorkspaceSession: vi.fn(() => session), setWorkspaceSession: vi.fn((next) => { session = next @@ -361,7 +362,7 @@ describe('registerPtyHandlers', () => { getFolderWorkspaces: vi.fn(() => []), getProjectGroups: vi.fn(() => []), getRepos: vi.fn(() => []) - } + }) let runtimeOwnsPane = true const runtime = { setPtyController: vi.fn(), diff --git a/src/main/ipc/pty-ipc-daemon-provider-fixtures.ts b/src/main/ipc/pty-ipc-daemon-provider-fixtures.ts index 4c74a7a82e9..6d43b0dec35 100644 --- a/src/main/ipc/pty-ipc-daemon-provider-fixtures.ts +++ b/src/main/ipc/pty-ipc-daemon-provider-fixtures.ts @@ -96,7 +96,7 @@ export function createDaemonActiveProviderFixtures(ctx: { getSelectedCodexHomePath?: ( target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }, launchEnv?: NodeJS.ProcessEnv, - launchContext?: { workspacePath?: string; launchAgent?: TuiAgent } + launchContext?: { unavailableManagedHomePath?: string } ) => string | null, getSettings?: () => { httpProxyUrl?: string @@ -157,7 +157,7 @@ export function createDaemonActiveProviderFixtures(ctx: { getSelectedCodexHomePath?: ( target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }, launchEnv?: NodeJS.ProcessEnv, - launchContext?: { workspacePath?: string; launchAgent?: TuiAgent } + launchContext?: { unavailableManagedHomePath?: string } ) => string | null, getSettings?: () => { httpProxyUrl?: string diff --git a/src/main/ipc/pty-ipc-mock-registry.ts b/src/main/ipc/pty-ipc-mock-registry.ts index 71b77621b75..f49978843d5 100644 --- a/src/main/ipc/pty-ipc-mock-registry.ts +++ b/src/main/ipc/pty-ipc-mock-registry.ts @@ -108,12 +108,14 @@ export const childProcessModuleMock = (original: Record) => ({ export const openCodeHookServiceModuleMock = () => ({ openCodeHookService: { buildPtyEnv: openCodeBuildPtyEnvMock, + refreshLegacySharedPlugin: vi.fn<() => void>(), clearPty: openCodeClearPtyMock }, // Separate mock per variant: assembly.ts picks the service by variant, and a shared // mock would hide a regression that hands an OpenCode 2 pane the v1 plugin. openCode2HookService: { buildPtyEnv: openCode2BuildPtyEnvMock, + refreshLegacySharedPlugin: vi.fn<() => void>(), clearPty: openCodeClearPtyMock } }) diff --git a/src/main/ipc/pty-ipc-spawn-drivers.ts b/src/main/ipc/pty-ipc-spawn-drivers.ts index 46a19ab9e92..dde291ce072 100644 --- a/src/main/ipc/pty-ipc-spawn-drivers.ts +++ b/src/main/ipc/pty-ipc-spawn-drivers.ts @@ -29,7 +29,7 @@ export function createPtyIpcSpawnDrivers(ctx: { getSelectedCodexHomePath?: ( target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }, launchEnv?: NodeJS.ProcessEnv, - launchContext?: { workspacePath?: string; launchAgent?: TuiAgent } + launchContext?: { unavailableManagedHomePath?: string } ) => string | null, getSettings?: () => { agentStatusHooksEnabled?: boolean diff --git a/src/main/ipc/pty-pane-reservation-settlement.test.ts b/src/main/ipc/pty-pane-reservation-settlement.test.ts index b6aba0855e6..30ef457a81d 100644 --- a/src/main/ipc/pty-pane-reservation-settlement.test.ts +++ b/src/main/ipc/pty-pane-reservation-settlement.test.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime/runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { spawnMock, registerPtyMock } from './pty-ipc-mock-registry' import { setupPtyIpcSuite } from './pty-ipc-test-harness' @@ -112,7 +113,7 @@ describe('registerPtyHandlers', () => { }, terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-dead-ssh-owner' } } - const store = { + const store = withDurableRuntimeStore({ getWorkspaceSession: vi.fn((requestedHostId?: string) => { expect(requestedHostId).toBe(hostId) return session @@ -128,7 +129,7 @@ describe('registerPtyHandlers', () => { removeSshRemotePtyLease: vi.fn(), markSshRemotePtyLease: vi.fn(), clearSshRemotePtyKillIntent: vi.fn() - } + }) const runtime = { setPtyController: vi.fn(), resolveTerminalPane: vi.fn(() => { diff --git a/src/main/ipc/pty-pane-restart-replace.test.ts b/src/main/ipc/pty-pane-restart-replace.test.ts index e8e91ea7d45..50558a0f870 100644 --- a/src/main/ipc/pty-pane-restart-replace.test.ts +++ b/src/main/ipc/pty-pane-restart-replace.test.ts @@ -3,6 +3,7 @@ import { setupPtyIpcSuite, type PtyIpcSuiteFixtures } from './pty-ipc-test-harne import { SessionNotFoundError } from '../daemon/daemon-errors' import { makePaneKey } from '../../shared/stable-pane-id' import { registerPtyHandlers, setLocalPtyProvider } from './pty' +import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -137,6 +138,7 @@ function installRestartHarness( session = next }), flushOrThrow: vi.fn(), + runDurableMutation: vi.fn(async (mutate: () => { value: T }) => mutate().value), persistPtyBinding: vi.fn(), getFolderWorkspace: vi.fn(() => undefined), getFolderWorkspaces: vi.fn(() => []), @@ -160,7 +162,8 @@ function installRestartHarness( seedHeadlessTerminal: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), - onPtyData: vi.fn() + onPtyData: vi.fn(), + intentionalPtyStops: new TerminalIntentionalStops() } return { providerSpawn, shutdown, store, runtime, control } } diff --git a/src/main/ipc/pty-persisted-incarnation-repair.test.ts b/src/main/ipc/pty-persisted-incarnation-repair.test.ts index 743963d0189..c057d6ec5d2 100644 --- a/src/main/ipc/pty-persisted-incarnation-repair.test.ts +++ b/src/main/ipc/pty-persisted-incarnation-repair.test.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime/runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { statSyncMock } from './pty-ipc-mock-registry' import { setupPtyIpcSuite } from './pty-ipc-test-harness' @@ -272,7 +273,7 @@ describe('registerPtyHandlers', () => { }, terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-dead-persisted-owner' } } - const store = { + const store = withDurableRuntimeStore({ getWorkspaceSession: vi.fn(() => session), setWorkspaceSession: vi.fn((next) => { session = next @@ -293,7 +294,7 @@ describe('registerPtyHandlers', () => { ]), getProjectGroups: vi.fn(() => []), getRepos: vi.fn(() => []) - } + }) const runtime = { setPtyController: vi.fn(), resolveTerminalPane: vi.fn(() => { @@ -434,7 +435,7 @@ describe('registerPtyHandlers', () => { }, terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-unproven-owner' } } - const store = { + const store = withDurableRuntimeStore({ getWorkspaceSession: vi.fn(() => session), setWorkspaceSession: vi.fn((next) => { session = next @@ -445,7 +446,7 @@ describe('registerPtyHandlers', () => { getFolderWorkspaces: vi.fn(() => []), getProjectGroups: vi.fn(() => []), getRepos: vi.fn(() => []) - } + }) const runtime = { setPtyController: vi.fn(), resolveTerminalPane: vi.fn(() => { diff --git a/src/main/ipc/pty-reset-input-modes.test.ts b/src/main/ipc/pty-reset-input-modes.test.ts new file mode 100644 index 00000000000..55fa628078d --- /dev/null +++ b/src/main/ipc/pty-reset-input-modes.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest' +import { onMock } from './pty-ipc-mock-registry' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { registerPtyHandlers } from './pty' + +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +describe('Reset Terminal main-side entry points', () => { + const { handlers, mainWindow, installDaemonTestProvider } = setupPtyIpcSuite() + + function setup() { + const resetInputModes = vi.fn(async () => {}) + installDaemonTestProvider({ resetInputModes }) + let controller: { resetInputModes: (ptyId: string) => Promise } | undefined + const runtime = { + setPtyController: vi.fn((next) => { + controller = next + }), + resetHeadlessTerminalInputModes: vi.fn(async () => {}) + } + handlers.clear() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registration reads only these members. + registerPtyHandlers(mainWindow as never, runtime as never) + return { resetInputModes, runtime, controller: controller! } + } + + it('grounds the provider and the headless model from the pane IPC', () => { + const { resetInputModes, runtime } = setup() + const listener = onMock.mock.calls.findLast( + (entry: unknown[]) => entry[0] === 'pty:resetInputModes' + )?.[1] + if (typeof listener !== 'function') { + throw new Error('missing pty:resetInputModes listener') + } + + listener(null, { id: 'pty-1' }) + + expect(resetInputModes).toHaveBeenCalledWith('pty-1') + expect(runtime.resetHeadlessTerminalInputModes).toHaveBeenCalledWith('pty-1') + // The pane grounded itself before sending; echoing back would ground it twice. + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith( + 'pty:resetInputModes:request', + expect.anything() + ) + }) + + it("grounds the host window's pane and the provider for a runtime-initiated reset", async () => { + const { resetInputModes, controller } = setup() + + await controller.resetInputModes('pty-1') + + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:resetInputModes:request', { + ptyId: 'pty-1' + }) + expect(resetInputModes).toHaveBeenCalledWith('pty-1') + }) + + it('swallows an older host rejecting the request', async () => { + const { resetInputModes, controller } = setup() + resetInputModes.mockRejectedValueOnce(new Error('Unknown request type: resetInputModes')) + + await expect(controller.resetInputModes('pty-1')).resolves.toBeUndefined() + }) +}) diff --git a/src/main/ipc/pty-serializer-settlement-mapping.test.ts b/src/main/ipc/pty-serializer-settlement-mapping.test.ts index 47082d6e745..70e358a8014 100644 --- a/src/main/ipc/pty-serializer-settlement-mapping.test.ts +++ b/src/main/ipc/pty-serializer-settlement-mapping.test.ts @@ -3,6 +3,7 @@ import { spawnMock, openCodeClearPtyMock, piClearPtyMock } from './pty-ipc-mock- import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { makePaneKey } from '../../shared/stable-pane-id' import { OrcaRuntimeService } from '../runtime/orca-runtime' +import type * as WslManagedCliModule from '../cli/wsl-managed-cli' import { SSH_PTY_IDENTITY_MISMATCH_ERROR, SSH_SESSION_EXPIRED_ERROR @@ -46,6 +47,13 @@ vi.mock('../telemetry/client', () => vi.mock('../telemetry/classify-error', () => import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) ) +const managedWslCliDir = vi.hoisted(() => + vi.fn((): string | null => 'C:\\orca-user-data\\wsl-managed-cli\\hash') +) +vi.mock('../cli/wsl-managed-cli', async (importOriginal) => ({ + ...(await importOriginal()), + getManagedWslCliDir: managedWslCliDir +})) vi.mock('../cli/linux-terminal-orca-cli-shim', () => import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) ) @@ -248,7 +256,10 @@ describe('registerPtyHandlers', () => { }) ).rejects.toThrow(/ORCA_TERMINAL_SESSION_STATE_SAVE_FAILED/) - expect(remoteShutdown).toHaveBeenCalledWith(appPtyId, { immediate: true }) + expect(remoteShutdown).toHaveBeenCalledWith(appPtyId, { + immediate: true, + expectedIncarnationId: incarnationId + }) expect(store.upsertSshRemotePtyLease).not.toHaveBeenCalled() expect(store.removeSshRemotePtyLease).not.toHaveBeenCalled() expect(openCodeClearPtyMock).toHaveBeenCalledWith(appPtyId) @@ -455,12 +466,13 @@ describe('registerPtyHandlers', () => { await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, - env: { ORCA_TERMINAL_HANDLE: 'term_untrusted' } + env: { ORCA_TERMINAL_HANDLE: 'term_untrusted', ORCA_WSL_CLI_DIR: 'C:\\stale' } }) const spawnCall = spawnMock.mock.calls.at(-1)! const env = spawnCall[2].env as Record expect(env.ORCA_TERMINAL_HANDLE).toBe('term_trusted') + expect(env.ORCA_WSL_CLI_DIR).toBeUndefined() expect(runtime.preAllocateHandleForPty).toHaveBeenCalledWith(expect.any(String)) }) it('forwards the trusted Orca terminal handle into managed WSL terminals', async () => { @@ -497,11 +509,13 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_TERMINAL_HANDLE).toBe('term_wsl') expect(env.ORCA_USER_DATA_PATH).toBe('/tmp/orca-user-data') expect(env.ORCA_CLI_COMMAND).toBe('orca-ide') + expect(env.ORCA_WSL_CLI_DIR).toBe('C:\\orca-user-data\\wsl-managed-cli\\hash') expect(env.WSLENV?.split(':')).toEqual( expect.arrayContaining([ 'ORCA_TERMINAL_HANDLE/u', 'ORCA_USER_DATA_PATH/p', 'ORCA_CLI_COMMAND/u', + 'ORCA_WSL_CLI_DIR/p', 'ORCA_AGENT_HOOK_PORT/u', 'ORCA_AGENT_HOOK_TOKEN/u', // Why: bare WSL shells no longer create ~/.omp; only status extension is exported (#10196). @@ -513,7 +527,8 @@ describe('registerPtyHandlers', () => { expect.arrayContaining(['ORCA_OMP_SOURCE_AGENT_DIR/p']) ) }) - it('forces managed ORCA_USER_DATA_PATH for WSL spawns even when the caller provides a stale root', async () => { + it('forces managed WSL env over stale caller values, even when CLI setup fails', async () => { + managedWslCliDir.mockReturnValueOnce(null) const platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, @@ -534,7 +549,8 @@ describe('registerPtyHandlers', () => { rows: 24, shellOverride: 'wsl.exe', env: { - ORCA_USER_DATA_PATH: '/tmp/stale-orca-user-data' + ORCA_USER_DATA_PATH: '/tmp/stale-orca-user-data', + ORCA_WSL_CLI_DIR: '/tmp/stale-wsl-cli' } }) } finally { @@ -547,5 +563,6 @@ describe('registerPtyHandlers', () => { const env = spawnCall[2].env as Record expect(spawnCall[0]).toBe('wsl.exe') expect(env.ORCA_USER_DATA_PATH).toBe('/tmp/orca-user-data') + expect(env.ORCA_WSL_CLI_DIR).toBeUndefined() }) }) diff --git a/src/main/ipc/pty-spawn-env-agent-overlays.test.ts b/src/main/ipc/pty-spawn-env-agent-overlays.test.ts index 1595835801f..4c7204f1980 100644 --- a/src/main/ipc/pty-spawn-env-agent-overlays.test.ts +++ b/src/main/ipc/pty-spawn-env-agent-overlays.test.ts @@ -9,7 +9,6 @@ import { } from './pty-ipc-mock-registry' import { posixOnlyIt } from './pty-ipc-test-constants' import { setupPtyIpcSuite } from './pty-ipc-test-harness' -import type { TuiAgent } from '../../shared/tui-agent' import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) @@ -60,13 +59,13 @@ describe('registerPtyHandlers', () => { const { spawnAndGetEnv } = setupPtyIpcSuite() describe('spawn environment', () => { - it('prepares Codex launch state for the workspace before spawning an interactive tab', async () => { + it('prepares Codex launch state before spawning an interactive tab', async () => { const workspacePath = '/repo/worktrees/new-feature' const resolveHome = vi.fn( ( _target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }, _launchEnv?: NodeJS.ProcessEnv, - _launchContext?: { workspacePath?: string; launchAgent?: TuiAgent } + _launchContext?: { unavailableManagedHomePath?: string } ) => null ) @@ -82,7 +81,6 @@ describe('registerPtyHandlers', () => { ) expect(resolveHome.mock.calls[0]?.[0]).toEqual({ runtime: 'host' }) - expect(resolveHome.mock.calls[0]?.[2]).toEqual({ workspacePath, launchAgent: 'codex' }) expect(resolveHome.mock.invocationCallOrder[0]).toBeLessThan( spawnMock.mock.invocationCallOrder[0]! ) diff --git a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts index c36fa3e40f5..bed6953e2b1 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -351,6 +351,16 @@ describe('registerPtyHandlers', () => { expect(env.CLAUDE_CODE_SESSION_ID).toBeUndefined() expect(env.CLAUDE_CODE_BRIDGE_SESSION_ID).toBeUndefined() }) + it('strips an inherited agent session id so a pane never claims that session', async () => { + // Why: an Orca launched inside a structured session inherits its id; every pane would then + // present that session as its orchestration caller instead of its own terminal. + const env = await spawnAndGetEnv(undefined, { + ORCA_AGENT_SESSION_ID: 'a0b1c2d3-0000-4000-8000-00000000abcd', + ORCA_STRUCTURED_SESSION: '1' + }) + expect(env.ORCA_AGENT_SESSION_ID).toBeUndefined() + expect(env.ORCA_STRUCTURED_SESSION).toBeUndefined() + }) it('keeps an explicitly requested Claude child-session stamp on a local spawn', async () => { const env = await spawnAndGetEnv( { CLAUDE_CODE_CHILD_SESSION: '1' }, diff --git a/src/main/ipc/pty-ssh-undelivered-kill.test.ts b/src/main/ipc/pty-ssh-undelivered-kill.test.ts index d59d7d8543a..2d92ffa1859 100644 --- a/src/main/ipc/pty-ssh-undelivered-kill.test.ts +++ b/src/main/ipc/pty-ssh-undelivered-kill.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops' import { SSH_SESSION_EXPIRED_ERROR } from '../providers/ssh-pty-errors' import { registerPtyHandlers, @@ -79,7 +80,8 @@ function installController(handlers: Map) { setPtyController: vi.fn(), markPtyStopRequested: vi.fn(), markPtyLivenessUnverifiable: vi.fn(), - onPtyExit: vi.fn() + onPtyExit: vi.fn(), + intentionalPtyStops: new TerminalIntentionalStops() } handlers.clear() return { runtime } @@ -91,7 +93,7 @@ describe('undelivered SSH stops', () => { function install(store: ReturnType): { kill: (ptyId: string) => boolean stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise - markReversibleStops: (ptyIds: readonly string[]) => () => void + recordUnconfirmedStop: (ptyId: string) => boolean runtime: ReturnType['runtime'] } { const { runtime } = installController(handlers as never) @@ -103,15 +105,16 @@ describe('undelivered SSH stops', () => { undefined, store as never ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registerPtyHandlers installs the runtime PTY controller, which implements these members. const controller = runtime.setPtyController.mock.calls[0]?.[0] as { kill: (ptyId: string) => boolean stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise - markReversibleStops: (ptyIds: readonly string[]) => () => void + recordUnconfirmedStop: (ptyId: string) => boolean } return { kill: controller.kill, stopAndWait: controller.stopAndWait, - markReversibleStops: controller.markReversibleStops, + recordUnconfirmedStop: controller.recordUnconfirmedStop, runtime } } @@ -298,15 +301,41 @@ describe('undelivered SSH stops', () => { ) setPtyOwnership(SCOPED_PTY_ID, 'ssh-1') restorePtyIncarnation(SCOPED_PTY_ID, 'inc-f') - const { kill, markReversibleStops } = install(store) - const release = markReversibleStops([SCOPED_PTY_ID]) + const { kill, runtime } = install(store) + const settleStop = runtime.intentionalPtyStops.mark(SCOPED_PTY_ID, 'reversible', null) try { kill(SCOPED_PTY_ID) await new Promise((resolve) => setTimeout(resolve, 0)) expect(store.recordSshRemotePtyKillIntent).not.toHaveBeenCalled() } finally { - release() + settleStop(false) + unregisterSshPtyProvider('ssh-1') + deletePtyOwnership(SCOPED_PTY_ID) + } + }) + + it('records nothing while a reversible stop owns the PTY and a restart stop joins it', async () => { + const store = createKillStore() + registerSshPtyProvider( + 'ssh-1', + sshProviderStub(async () => { + throw new Error('socket closed') + }) + ) + setPtyOwnership(SCOPED_PTY_ID, 'ssh-1') + restorePtyIncarnation(SCOPED_PTY_ID, 'inc-f') + const { kill, runtime } = install(store) + const settleSleep = runtime.intentionalPtyStops.mark(SCOPED_PTY_ID, 'reversible', null) + const settleRestart = runtime.intentionalPtyStops.mark(SCOPED_PTY_ID, 'replaced', null) + + try { + kill(SCOPED_PTY_ID) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(store.recordSshRemotePtyKillIntent).not.toHaveBeenCalled() + } finally { + settleRestart(false) + settleSleep(false) unregisterSshPtyProvider('ssh-1') deletePtyOwnership(SCOPED_PTY_ID) } @@ -327,8 +356,8 @@ describe('undelivered SSH stops', () => { } }) - // No incarnation means no fence, and an unfenced order can only be discarded or guessed at. - it('records nothing when the PTY incarnation was never learned', async () => { + // A legacy id with no incarnation has no fence, and an unfenced order can only be guessed at. + it('records nothing for a legacy id whose PTY incarnation was never learned', async () => { const store = createKillStore() registerSshPtyProvider( 'ssh-1', @@ -348,4 +377,82 @@ describe('undelivered SSH stops', () => { deletePtyOwnership('ssh:ssh-1@@pty-8') } }) + + // A `pty2:` id is epoch-scoped, so it names one process even when the incarnation was never + // learned: an offline close across a relaunch still owes the kill. + it('records the stop for an epoch-scoped id whose incarnation was never learned', () => { + const store = createKillStore() + const ptyId = 'ssh:ssh-1@@pty2:epoch-a:4' + setPtyOwnership(ptyId, 'ssh-1') + const { kill } = install(store) + + try { + expect(kill(ptyId)).toBe(false) + expect(store.recordSshRemotePtyKillIntent).toHaveBeenCalledWith('ssh-1', 'pty2:epoch-a:4', { + requestedAt: expect.any(Number), + attempts: 0 + }) + } finally { + deletePtyOwnership(ptyId) + } + }) + + // One recorder: the explicit-close receipt promises the retry for an epoch-scoped id too. + it("records an explicit close's unconfirmed stop for an epoch-scoped id with no incarnation", () => { + const store = createKillStore() + const ptyId = 'ssh:ssh-1@@pty2:epoch-b:5' + setPtyOwnership(ptyId, 'ssh-1') + const { recordUnconfirmedStop } = install(store) + + try { + expect(recordUnconfirmedStop(ptyId)).toBe(true) + expect(store.recordSshRemotePtyKillIntent).toHaveBeenCalledTimes(1) + expect(store.recordSshRemotePtyKillIntent).toHaveBeenCalledWith('ssh-1', 'pty2:epoch-b:5', { + requestedAt: expect.any(Number), + attempts: 0 + }) + } finally { + deletePtyOwnership(ptyId) + } + }) + + // An explicit close records its order when the stop goes unconfirmed, before the follow-up kill, + // so its receipt can promise the reconnect retry from the record itself. + it("records an explicit close's unconfirmed stop and says so", () => { + const store = createKillStore() + setPtyOwnership(SCOPED_PTY_ID, 'ssh-1') + restorePtyIncarnation(SCOPED_PTY_ID, 'inc-g') + const { recordUnconfirmedStop } = install(store) + + try { + expect(recordUnconfirmedStop(SCOPED_PTY_ID)).toBe(true) + expect(store.recordSshRemotePtyKillIntent).toHaveBeenCalledWith( + 'ssh-1', + 'pty-7', + expect.objectContaining({ incarnationId: 'inc-g', attempts: 0 }) + ) + } finally { + deletePtyOwnership(SCOPED_PTY_ID) + } + }) + + it('reports no recorded order for a local or reversibly stopped PTY', () => { + const store = createKillStore() + setPtyOwnership('local-pty', null) + restorePtyIncarnation('local-pty', 'inc-h') + setPtyOwnership(SCOPED_PTY_ID, 'ssh-1') + restorePtyIncarnation(SCOPED_PTY_ID, 'inc-i') + const { recordUnconfirmedStop, runtime } = install(store) + const settleStop = runtime.intentionalPtyStops.mark(SCOPED_PTY_ID, 'reversible', null) + + try { + expect(recordUnconfirmedStop('local-pty')).toBe(false) + expect(recordUnconfirmedStop(SCOPED_PTY_ID)).toBe(false) + expect(store.recordSshRemotePtyKillIntent).not.toHaveBeenCalled() + } finally { + settleStop(false) + deletePtyOwnership('local-pty') + deletePtyOwnership(SCOPED_PTY_ID) + } + }) }) diff --git a/src/main/ipc/pty/delivery/accept.ts b/src/main/ipc/pty/delivery/accept.ts index 0582bed3353..979a50d3008 100644 --- a/src/main/ipc/pty/delivery/accept.ts +++ b/src/main/ipc/pty/delivery/accept.ts @@ -36,7 +36,7 @@ export function acceptPtyDataForRenderer( const preservesSeq = !payload.transformed && rawLength === payload.data.length const startSeq = typeof outputSeq === 'number' ? Math.max(0, outputSeq - rawLength) : undefined const projectionId = projection?.identity.projectionSemanticsId - if (session.mainWindow.isDestroyed()) { + if (!session.mainWindow || session.mainWindow.isDestroyed()) { if (projectionId) { session.sshOutputIntake?.transferProjections([projectionId], 'renderer-destroyed') } diff --git a/src/main/ipc/pty/delivery/accounting.ts b/src/main/ipc/pty/delivery/accounting.ts index eddc49be3f2..16fc3520f86 100644 --- a/src/main/ipc/pty/delivery/accounting.ts +++ b/src/main/ipc/pty/delivery/accounting.ts @@ -132,7 +132,11 @@ export function clearDeliveryResyncProbe(session: PtyIpcSession): void { } export function requestDeliveryResyncForGatedPty(session: PtyIpcSession): void { - if (session.deliveryResyncOutstandingRequestId !== null || session.mainWindow.isDestroyed()) { + if ( + session.deliveryResyncOutstandingRequestId !== null || + !session.mainWindow || + session.mainWindow.isDestroyed() + ) { return } session.deliveryResyncRequestSerial += 1 diff --git a/src/main/ipc/pty/delivery/debug-snapshot.ts b/src/main/ipc/pty/delivery/debug-snapshot.ts index facc04e4e74..3388ef153f7 100644 --- a/src/main/ipc/pty/delivery/debug-snapshot.ts +++ b/src/main/ipc/pty/delivery/debug-snapshot.ts @@ -46,13 +46,14 @@ export function buildMainDeliveryDiagnostics(session: PtyIpcSession): PtyMainDel }) } perPty.sort((a, b) => b.inFlightChars + b.pendingChars - (a.inFlightChars + a.pendingChars)) - const windowAlive = !session.mainWindow.isDestroyed() + const { mainWindow } = session + const windowAlive = mainWindow && !mainWindow.isDestroyed() return { appVersion: getAppEnvironment().getVersion(), mainUptimeMs: Math.round(process.uptime() * 1000), - windowFocused: windowAlive ? session.mainWindow.isFocused() : null, - windowVisible: windowAlive ? session.mainWindow.isVisible() : null, - windowMinimized: windowAlive ? session.mainWindow.isMinimized() : null, + windowFocused: windowAlive ? mainWindow.isFocused() : null, + windowVisible: windowAlive ? mainWindow.isVisible() : null, + windowMinimized: windowAlive ? mainWindow.isMinimized() : null, msSinceLastPowerSuspend: lastPowerSuspendAtMs === null ? null : now - lastPowerSuspendAtMs, msSinceLastPowerResume: lastPowerResumeAtMs === null ? null : now - lastPowerResumeAtMs, perPty: perPty.slice(0, DELIVERY_DIAGNOSTICS_MAX_PTYS), diff --git a/src/main/ipc/pty/delivery/exit.ts b/src/main/ipc/pty/delivery/exit.ts index 9aba356b3c3..ad144b4d587 100644 --- a/src/main/ipc/pty/delivery/exit.ts +++ b/src/main/ipc/pty/delivery/exit.ts @@ -7,57 +7,8 @@ import { allocatePtyLifecycleSequence } from '../host-env/types' import { makePtyDataPayload, sendPtyDataToRenderer } from './payload' import { getRendererInFlightCharsForPty } from './accounting' import { clearFlushTimerIfIdle } from './flush' -import { ptyIncarnationById } from '../provider/ownership-state' import type { PtyIpcSession } from '../session' -export type ReplacedPtyStop = { - incarnationId: string | undefined - expiryTimer?: NodeJS.Timeout -} - -/** Labels the exit of a PTY that main stops so a new process can take its pane. Settle with - * whether the stop succeeded; a failed stop leaves no label behind. */ -export function markReplacedPtyStop( - session: PtyIpcSession, - id: string -): (stopped: boolean) => void { - clearTimeout(session.replacedPtyStopsById.get(id)?.expiryTimer) - const mark: ReplacedPtyStop = { incarnationId: ptyIncarnationById.get(id) } - session.replacedPtyStopsById.set(id, mark) - return (stopped) => { - if (session.replacedPtyStopsById.get(id) !== mark) { - return - } - if (!stopped) { - session.replacedPtyStopsById.delete(id) - return - } - // Why a window: an SSH exit can reach the renderer after the stop settles; bound it like a synthetic kill. - mark.expiryTimer = setTimeout(() => { - if (session.replacedPtyStopsById.get(id) === mark) { - session.replacedPtyStopsById.delete(id) - } - }, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS) - mark.expiryTimer.unref?.() - } -} - -function consumeReplacedPtyStop( - session: PtyIpcSession, - payload: { id: string; incarnationId?: string } -): boolean { - const mark = session.replacedPtyStopsById.get(payload.id) - if ( - !mark || - (mark.incarnationId && payload.incarnationId && mark.incarnationId !== payload.incarnationId) - ) { - return false - } - clearTimeout(mark.expiryTimer) - session.replacedPtyStopsById.delete(payload.id) - return true -} - export function rememberSyntheticKillExit( session: PtyIpcSession, id: string, @@ -105,7 +56,7 @@ export function preparePtyExitForRenderer( session: PtyIpcSession, payload: { id: string; code: number; incarnationId?: string } ): (() => void) | null { - if (session.mainWindow.isDestroyed()) { + if (!session.mainWindow || session.mainWindow.isDestroyed()) { session.sshOutputIntake?.transferPtyProjections(payload.id, 'renderer-destroyed') return () => {} } @@ -171,7 +122,7 @@ export function finalizePtyExitForRenderer( session: PtyIpcSession, payload: { id: string; code: number; incarnationId?: string } ): void { - if (session.mainWindow.isDestroyed()) { + if (!session.mainWindow || session.mainWindow.isDestroyed()) { session.rendererCreditBeforeExitByPty.delete(payload.id) return } @@ -202,12 +153,12 @@ export function finalizePtyExitForRenderer( session.schedulePendingDataAfterCreditReport(true) } } + const intentionalStops = + session.runtime?.intentionalPtyStops?.claimExit(payload.id, payload.incarnationId) ?? [] session.mainWindow.webContents.send('pty:exit', { ...payload, - ...(session.reversibleStopOwnersByPtyId.has(payload.id) - ? { preserveRendererBinding: true } - : {}), - ...(consumeReplacedPtyStop(session, payload) ? { replacedByRestart: true } : {}) + ...(intentionalStops.includes('reversible') ? { preserveRendererBinding: true } : {}), + ...(intentionalStops.includes('replaced') ? { replacedByRestart: true } : {}) }) } @@ -229,7 +180,7 @@ export function sendPtyExitToRenderer( } export function sendPtySpawnedToRenderer(session: PtyIpcSession, id: string): void { - if (!session.mainWindow.isDestroyed()) { + if (session.mainWindow && !session.mainWindow.isDestroyed()) { session.mainWindow.webContents.send('pty:spawned', { id }) } } diff --git a/src/main/ipc/pty/delivery/flush.ts b/src/main/ipc/pty/delivery/flush.ts index 038d955a932..ffe3c3014e8 100644 --- a/src/main/ipc/pty/delivery/flush.ts +++ b/src/main/ipc/pty/delivery/flush.ts @@ -50,13 +50,17 @@ export function clearDispatcherReadyWatchdog(session: PtyIpcSession): void { export function armDispatcherReadyWatchdog(session: PtyIpcSession): void { clearDispatcherReadyWatchdog(session) - if (session.mainWindow.isDestroyed()) { + if (!session.mainWindow || session.mainWindow.isDestroyed()) { return } // Why: one-shot self-heal — force the gate open if the reloaded page never signals ready, so a dropped handshake can't hold it forever. Unref'd so it can't keep the process alive. session.dispatcherReadyWatchdogTimer = setTimeout(() => { session.dispatcherReadyWatchdogTimer = null - if (session.rendererPtyDispatcherReady || session.mainWindow.isDestroyed()) { + if ( + session.rendererPtyDispatcherReady || + !session.mainWindow || + session.mainWindow.isDestroyed() + ) { return } session.rendererPtyDispatcherReady = true @@ -77,7 +81,7 @@ export function clearFlushTimerIfIdle(session: PtyIpcSession): void { export function flushPendingData(session: PtyIpcSession): void { session.flushTimer = null - if (session.mainWindow.isDestroyed()) { + if (!session.mainWindow || session.mainWindow.isDestroyed()) { // Why release now: bookkeeping is being wiped, so no future drain can resume these producers — local shells would wedge. session.producerFlowControl.releaseAll() session.clearDeliveryResyncProbe() diff --git a/src/main/ipc/pty/delivery/lifecycle-reset.ts b/src/main/ipc/pty/delivery/lifecycle-reset.ts index 961c09188a1..475ddcadc0e 100644 --- a/src/main/ipc/pty/delivery/lifecycle-reset.ts +++ b/src/main/ipc/pty/delivery/lifecycle-reset.ts @@ -1,4 +1,4 @@ -import type { WebContents } from 'electron' +import type { PtyRendererDelivery } from '../session' import { didFinishLoadHandler, didFinishLoadWebContents, @@ -60,9 +60,14 @@ export function clearRendererLifecycleResetHandlers(): void { setRendererLifecycleResetState({ contents: null, handler: null, navigation: null }) } -export function registerRendererLifecycleResetHandlers(webContents: WebContents): void { +export function registerRendererLifecycleResetHandlers( + webContents?: PtyRendererDelivery['webContents'] +): void { clearRendererLifecycleResetHandlers() markRendererPtysHiddenForRendererLifecycleReset() + if (!webContents) { + return + } const handler = markRendererPtysHiddenForRendererLifecycleReset const navigationHandler = (details: { isMainFrame: boolean; isSameDocument: boolean }) => { if (!details.isMainFrame || details.isSameDocument) { diff --git a/src/main/ipc/pty/delivery/payload.ts b/src/main/ipc/pty/delivery/payload.ts index 119bb173ed7..8be6dfd11dd 100644 --- a/src/main/ipc/pty/delivery/payload.ts +++ b/src/main/ipc/pty/delivery/payload.ts @@ -38,7 +38,7 @@ export function sendModelRestoreNeededMarker( reason: PtyModelRestoreReason, markerSeq: number | undefined ): boolean { - if (session.mainWindow.isDestroyed()) { + if (!session.mainWindow || session.mainWindow.isDestroyed()) { return false } try { @@ -61,6 +61,12 @@ export function sendPtyDataToRenderer( payload: PtyDataPayload, projectionAdmissionIds?: readonly string[] ): { sent: boolean; projectionsTransferred: boolean } { + if (!session.mainWindow) { + if (projectionAdmissionIds) { + session.sshOutputIntake?.transferProjections(projectionAdmissionIds, 'renderer-destroyed') + } + return { sent: false, projectionsTransferred: projectionAdmissionIds !== undefined } + } const charCount = getPtyPayloadCharCount(payload) const accounting = session.rendererDeliveryAccountingByPty.get(id) const hadAccounting = accounting !== undefined diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index dedd44e70e3..7d825062903 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -1,16 +1,21 @@ import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing' -import { isOpenCode2LaunchCommand } from '../../../../shared/opencode-launch-command' +import { selectOpenCodeHookAgent } from '../../../../shared/opencode-launch-command' import { detectExplicitPiAgentKindFromCommand, isPiCompatibleAgentType } from '../../../../shared/pi-agent-kind' import { applyTerminalGitCredentialPromptGuard } from '../../terminal-git-credential-guard' import { openCode2HookService, openCodeHookService } from '../../../opencode/hook-service' +import { + OPENCODE_CONFIG_DIR_ENV_KEYS, + isOpenCodeLegacySharedConfigDir +} from '../../../opencode/legacy-shared-config-dir' import { mimoCodeHookService } from '../../../mimo/hook-service' import { agentHookServer } from '../../../agent-hooks/server' import { wslHookRelayManager } from '../../../agent-hooks/wsl-hook-relay-manager' import { piTitlebarExtensionService } from '../../../pi/titlebar-extension-service' import { prependOrcaCliDirToChildPath } from '../../../cli/orca-cli-child-path' +import { getManagedWslCliDir, getWslCliCommandName } from '../../../cli/wsl-managed-cli' import { stripLegacyTerminalShimEnv } from '../../../pty/legacy-terminal-shim-dir' import { mergePersistedWindowsPath } from '../../../pty/windows-environment-path' import { resolveCodexShellLaunchPreflightCommand } from '../../../pty/codex-shell-launch-preflight' @@ -44,15 +49,31 @@ export function buildPtyHostEnv( mergePersistedWindowsPath(baseEnv) Object.assign(baseEnv, buildConfiguredProxyEnv(opts.networkProxySettings)) - // Why: local path's baseEnv includes process.env but the daemon path doesn't (fork inheritance, not IPC); check both sources so guards stay in lock-step across spawn paths. - const preexistingOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv) + // Why: pre-1.4.209 panes exported Orca's retired shared hooks dir; inheriting it hides the user's global OpenCode config. + const isLegacyOpenCodeHooksDir = (dir: string | undefined): boolean => + isOpenCodeLegacySharedConfigDir(dir, opts.userDataPath) + const inheritedOpenCodeEnv: NodeJS.ProcessEnv = {} + for (const key of OPENCODE_CONFIG_DIR_ENV_KEYS) { + if (isLegacyOpenCodeHooksDir(baseEnv[key])) { + delete baseEnv[key] + } + if (!isLegacyOpenCodeHooksDir(process.env[key])) { + inheritedOpenCodeEnv[key] = process.env[key] + } + } + // A daemon or sibling shell can retain a retired path that main no longer sees. + openCodeHookService.refreshLegacySharedPlugin() + openCode2HookService.refreshLegacySharedPlugin() + const resolvedOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv, inheritedOpenCodeEnv) + const preexistingOpenCodeConfigDir = isLegacyOpenCodeHooksDir(resolvedOpenCodeConfigDir) + ? undefined + : resolvedOpenCodeConfigDir const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand) - // Typed launches do not carry the picker identity; infer the beta binary so - // it receives the OpenCode 2 hook endpoint and isolated plugin overlay. - const openCodeAgent = - opts.launchAgent === 'opencode2' || isOpenCode2LaunchCommand(launchCommandHint) - ? 'opencode2' - : 'opencode' + const openCodeAgent = selectOpenCodeHookAgent( + opts.launchAgent, + launchCommandHint, + (agent) => opts.agentStatusHooksEnabled && isTuiAgentEnabled(agent, opts.disabledTuiAgents) + ) const explicitPiAgentKind = isPiCompatibleAgentType(opts.launchAgent) ? opts.launchAgent : opts.launchAgent === undefined @@ -87,12 +108,26 @@ export function buildPtyHostEnv( ? resolvePiAgentSourceDir(baseEnv, 'prime-agent') : resolveScopedPiAgentSourceDir(baseEnv, 'prime-agent') - if (opts.agentStatusHooksEnabled) { + restoreOrStripOverlayEnv( + baseEnv, + { + primary: 'OPENCODE_CONFIG_DIR', + overlay: 'ORCA_OPENCODE_CONFIG_DIR', + source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR', + preserveExplicitPrimary: true + }, + inheritedOpenCodeEnv + ) + delete baseEnv.ORCA_OPENCODE_AGENT + if (openCodeAgent) { // Why: OPENCODE_CONFIG_DIR is a single path, not a colon-list; mirror the user's value into an overlay so their plugins and Orca's status plugin coexist. See docs/opencode-config-dir-collision.md. const openCodeStatusService = openCodeAgent === 'opencode2' ? openCode2HookService : openCodeHookService baseEnv.ORCA_OPENCODE_AGENT = openCodeAgent - Object.assign(baseEnv, openCodeStatusService.buildPtyEnv(id, preexistingOpenCodeConfigDir)) + // WSL owns its config writes; only the guest overlay may enter a WSL pane. + if (!opts.isWsl) { + Object.assign(baseEnv, openCodeStatusService.buildPtyEnv(id, preexistingOpenCodeConfigDir)) + } if (baseEnv.OPENCODE_CONFIG_DIR) { // Why: ~/.zshrc can re-export the user's default after spawn; shell-ready wrappers restore this PTY-scoped value. baseEnv.ORCA_OPENCODE_CONFIG_DIR = baseEnv.OPENCODE_CONFIG_DIR @@ -103,6 +138,8 @@ export function buildPtyHostEnv( delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR } } + } + if (opts.agentStatusHooksEnabled) { if (isMimoLaunchCommand(launchCommandHint)) { const preexistingMimocodeHome = resolveMimocodeSourceHome(baseEnv) Object.assign(baseEnv, mimoCodeHookService.buildPtyEnv(id, preexistingMimocodeHome)) @@ -116,11 +153,6 @@ export function buildPtyHostEnv( } } } else { - restoreOrStripOverlayEnv(baseEnv, { - primary: 'OPENCODE_CONFIG_DIR', - overlay: 'ORCA_OPENCODE_CONFIG_DIR', - source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR' - }) restoreOrStripOverlayEnv(baseEnv, { primary: 'MIMOCODE_HOME', overlay: 'ORCA_MIMOCODE_HOME', @@ -147,13 +179,15 @@ export function buildPtyHostEnv( baseEnv.ORCA_AGENT_HOOK_ENDPOINT = guestEndpoint } // Why: OpenCode loads its status plugin from a guest config overlay, so point OPENCODE_CONFIG_DIR at the guest dir the relay materialized. - const opencodeOverlayDir = wslHookRelayManager.getOpenCodeOverlayDir(distro, openCodeAgent) + const opencodeOverlayDir = openCodeAgent + ? wslHookRelayManager.getOpenCodeOverlayDir(distro, openCodeAgent) + : null if (opencodeOverlayDir) { baseEnv.OPENCODE_CONFIG_DIR = opencodeOverlayDir baseEnv.ORCA_OPENCODE_CONFIG_DIR = opencodeOverlayDir delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR } else { - // Why: relay not connected yet (or older guest bundle) — never cross the Windows overlay path into WSL; drop it so in-guest OpenCode uses its own config (pre-fix behavior, no status but no regression). + // Only guest overlays belong in WSL; otherwise let OpenCode use its guest config. delete baseEnv.OPENCODE_CONFIG_DIR delete baseEnv.ORCA_OPENCODE_CONFIG_DIR delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR @@ -252,7 +286,8 @@ export function buildPtyHostEnv( // Why: user startup files may re-export CODEX_HOME; shell-ready wrappers restore this runtime home before Codex launches. baseEnv.ORCA_CODEX_HOME = opts.selectedCodexHomePath const preflightCommand = resolveCodexShellLaunchPreflightCommand({ - hooksEnabled: opts.codexStatusHooksEnabled ?? opts.agentStatusHooksEnabled, + hooksEnabled: + opts.agentStatusHooksEnabled && isTuiAgentEnabled('codex', opts.disabledTuiAgents), isPackaged: opts.isPackaged, isWsl: opts.isWsl, managedHomePath: opts.selectedCodexHomePath, @@ -271,11 +306,17 @@ export function buildPtyHostEnv( delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT } + // Why: an inherited copy (e.g. Orca launched from a WSL pane) names another launch's CLI. + delete baseEnv.ORCA_WSL_CLI_DIR // Why: WSL shells need the managed userData root for shell-ready wrappers; dev-mode terminals need the same export so `orca` targets the live dev instance. if (opts.isWsl) { baseEnv.ORCA_USER_DATA_PATH = opts.userDataPath // Why: managed WSL registration uses `orca-ide`; exposing that literal scopes agent guidance to WSL without a bare-orca shim. - baseEnv.ORCA_CLI_COMMAND = opts.isPackaged ? 'orca-ide' : 'orca-dev' + baseEnv.ORCA_CLI_COMMAND = getWslCliCommandName(opts.isPackaged) + const managedCliDir = getManagedWslCliDir(opts) + if (managedCliDir) { + baseEnv.ORCA_WSL_CLI_DIR = managedCliDir + } } else { if (!opts.isPackaged) { baseEnv.ORCA_USER_DATA_PATH ??= opts.userDataPath @@ -293,7 +334,7 @@ export function buildPtyHostEnv( baseEnv.BROWSER === undefined && process.env.BROWSER === undefined ) { - const cliCommand = opts.isWsl ? (opts.isPackaged ? 'orca-ide' : 'orca-dev') : 'orca' + const cliCommand = opts.isWsl ? getWslCliCommandName(opts.isPackaged) : 'orca' baseEnv.BROWSER = `${cliCommand} open-url --url %s` } diff --git a/src/main/ipc/pty/host-env/codex-home.ts b/src/main/ipc/pty/host-env/codex-home.ts index 7abde336181..fbc0db2a04d 100644 --- a/src/main/ipc/pty/host-env/codex-home.ts +++ b/src/main/ipc/pty/host-env/codex-home.ts @@ -1,7 +1,5 @@ import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' import type { GlobalSettings } from '../../../../shared/global-settings-types' -import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' -import { isAgentStatusHooksEnabled } from '../../../agent-hooks/managed-agent-hook-controls' import { isCodexHomeAuthReadyForLaunch, waitForManagedCodexAuthReady @@ -31,12 +29,6 @@ export function shouldSkipCodexHomeEnvForWindowsShell( return isWslShellName(shellPath) || (typeof cwd === 'string' && parseWslPath(cwd) !== null) } -export function isCodexStatusHooksEnabled(settings: GlobalSettings | undefined): boolean { - return ( - isAgentStatusHooksEnabled(settings) && isTuiAgentEnabled('codex', settings?.disabledTuiAgents) - ) -} - // Why: with the real-home flag ON, a host system-default launch resolves to a // null managed home. Signal the env builder to strip a nested-Orca-inherited // override instead of injecting one, so Codex runs on the user's own ~/.codex. diff --git a/src/main/ipc/pty/host-env/codex-resume.ts b/src/main/ipc/pty/host-env/codex-resume.ts index 62e199e09c3..a7118cb08b8 100644 --- a/src/main/ipc/pty/host-env/codex-resume.ts +++ b/src/main/ipc/pty/host-env/codex-resume.ts @@ -30,7 +30,6 @@ export type PrepareCodexResumeHomeArgs = { providerSession?: AgentProviderSessionMetadata target: CodexAccountSelectionTarget launchEnv?: NodeJS.ProcessEnv - workspacePath?: string } export function prepareCodexResumeHome( @@ -49,8 +48,7 @@ export function prepareCodexResumeHome( preparation: prepareCodexSessionResume({ providerSession, target: args.target, - launchEnv: args.launchEnv, - workspacePath: args.workspacePath + launchEnv: args.launchEnv }) } } diff --git a/src/main/ipc/pty/host-env/fresh-spawn-routing.ts b/src/main/ipc/pty/host-env/fresh-spawn-routing.ts index 64034d544c2..e03c29e79bc 100644 --- a/src/main/ipc/pty/host-env/fresh-spawn-routing.ts +++ b/src/main/ipc/pty/host-env/fresh-spawn-routing.ts @@ -49,8 +49,14 @@ export function beginPtySpawnForWorktree( } } catch (error) { // Why: worktree ID and cwd can be different roots; release earlier admissions before rejecting. - finishes.toReversed().forEach((finish) => finish()) + for (let index = finishes.length - 1; index >= 0; index -= 1) { + finishes[index]!() + } throw error } - return () => finishes.toReversed().forEach((finish) => finish()) + return () => { + for (let index = finishes.length - 1; index >= 0; index -= 1) { + finishes[index]!() + } + } } diff --git a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts new file mode 100644 index 00000000000..8a9114eb942 --- /dev/null +++ b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts @@ -0,0 +1,495 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + createDaemonPtyEnvironment, + rescrubDaemonPtyEnvironment +} from '../../../daemon/pty-subprocess/spawn-environment' +import { getInheritedAgentHookEnvKeysToDelete } from './pi-agent' +import { buildPtyHostEnv } from './assembly' +import type { BuildPtyHostEnvOptions } from './types' + +const fixture = vi.hoisted(() => ({ userData: '', guestOverlay: '' })) +vi.mock('../../../../shared/app-environment', () => ({ + getAppEnvironment: () => ({ getPath: () => fixture.userData }) +})) +vi.mock('../../../agent-hooks/server', () => ({ + agentHookServer: { buildPtyEnv: () => ({ ORCA_AGENT_HOOK_PORT: '12345' }) } +})) +vi.mock('../../../agent-hooks/wsl-hook-relay-manager', () => ({ + wslHookRelayManager: { + ensureForDistro: vi.fn(), + getGuestEndpointFilePath: () => '/guest/endpoint.json', + getOpenCodeOverlayDir: () => fixture.guestOverlay, + getGuestAgentPath: () => null + } +})) +vi.mock('../../../pi/titlebar-extension-service', () => ({ + piTitlebarExtensionService: { buildPtyEnv: () => ({}), buildFreshOmpEnv: () => ({}) } +})) +vi.mock('../../../cli/orca-cli-child-path', () => ({ prependOrcaCliDirToChildPath: () => {} })) +vi.mock('../../../cli/wsl-managed-cli', () => ({ + getManagedWslCliDir: () => undefined, + getWslCliCommandName: () => 'orca-ide' +})) + +let root: string +let config: string +let custom: string +let options: BuildPtyHostEnvOptions +const plugin = (dir: string, agent: string) => join(dir, 'plugins', `orca-${agent}-status.js`) + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-opencode-disabled-')) + const home = join(root, 'home') + const xdg = join(root, 'xdg') + mkdirSync(home) + fixture.userData = join(root, 'user-data') + fixture.guestOverlay = join(root, 'guest-overlay') + config = join(xdg, 'opencode') + custom = join(root, 'custom') + mkdirSync(join(custom, 'plugins'), { recursive: true }) + writeFileSync(join(custom, 'opencode.json'), '{"model":"fixture"}') + writeFileSync(join(custom, 'plugins', 'user.js'), '// user plugin') + vi.stubEnv('HOME', home) + vi.stubEnv('USERPROFILE', home) + vi.stubEnv('XDG_CONFIG_HOME', xdg) + for (const key of [ + 'OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_SOURCE_CONFIG_DIR', + 'ORCA_OPENCODE_AGENT', + 'ZDOTDIR' + ]) { + vi.stubEnv(key, undefined) + } + options = { + isPackaged: true, + userDataPath: fixture.userData, + selectedCodexHomePath: null, + agentStatusHooksEnabled: true + } +}) +afterEach(() => { + vi.unstubAllEnvs() + rmSync(root, { recursive: true, force: true }) +}) + +describe('OpenCode installation uses the current enabled agents', () => { + const combinations = [ + { disabled: [], fallback: 'opencode' }, + { disabled: ['opencode'], fallback: 'opencode2' }, + { disabled: ['opencode2'], fallback: 'opencode' }, + { disabled: ['opencode', 'opencode2'], fallback: undefined } + ] + for (const { disabled, fallback } of combinations) { + it.each(['default', 'custom'])(`bare %s config with disabled ${disabled.join(',')}`, (kind) => { + const env = buildPtyHostEnv( + 'pane', + kind === 'custom' ? { OPENCODE_CONFIG_DIR: custom } : {}, + { + ...options, + disabledTuiAgents: disabled + } + ) + expect(env.ORCA_OPENCODE_AGENT).toBe(fallback) + const selected = env.OPENCODE_CONFIG_DIR ?? config + for (const agent of ['opencode', 'opencode2']) { + expect(existsSync(plugin(selected, agent))).toBe(agent === fallback) + } + if (kind === 'custom') { + expect(readFileSync(join(selected, 'plugins', 'user.js'), 'utf8')).toBe('// user plugin') + expect(readFileSync(join(selected, 'opencode.json'), 'utf8')).toBe('{"model":"fixture"}') + expect(existsSync(plugin(custom, 'opencode'))).toBe(false) + } + }) + it.each(['opencode', 'opencode2'] as const)( + `explicit %s with disabled ${disabled.join(',')}`, + (agent) => { + for (const selection of [ + { launchCommand: `${agent} --session fixture` }, + { launchAgent: agent } + ]) { + const env = buildPtyHostEnv( + 'pane', + {}, + { ...options, ...selection, disabledTuiAgents: disabled } + ) + const selected = disabled.includes(agent) ? undefined : agent + expect(env.ORCA_OPENCODE_AGENT).toBe(selected) + expect(existsSync(plugin(config, agent))).toBe(selected === agent) + expect(existsSync(plugin(config, agent === 'opencode' ? 'opencode2' : 'opencode'))).toBe( + false + ) + } + } + ) + } + + it('reads enable/disable changes on the same services without deleting installed files', () => { + buildPtyHostEnv('first', {}, options) + const installed = plugin(config, 'opencode') + writeFileSync(installed, '// already installed sentinel') + const env = buildPtyHostEnv('second', {}, { ...options, disabledTuiAgents: ['opencode'] }) + expect(env.ORCA_OPENCODE_AGENT).toBe('opencode2') + expect(readFileSync(installed, 'utf8')).toBe('// already installed sentinel') + buildPtyHostEnv('third', {}, options) + expect(readFileSync(installed, 'utf8')).toContain('/hook/opencode') + }) + + it.each([true, false])( + 'restores inherited source and clears markers with hooks %s', + (enabled) => { + const first = buildPtyHostEnv('first', { OPENCODE_CONFIG_DIR: custom }, options) + const original = readFileSync(plugin(first.OPENCODE_CONFIG_DIR, 'opencode'), 'utf8') + const env = buildPtyHostEnv( + 'second', + { ...first }, + { + ...options, + agentStatusHooksEnabled: enabled, + disabledTuiAgents: ['opencode', 'opencode2'] + } + ) + expect(env.OPENCODE_CONFIG_DIR).toBe(custom) + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined() + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined() + expect(env.ORCA_OPENCODE_AGENT).toBeUndefined() + expect(readFileSync(plugin(first.OPENCODE_CONFIG_DIR, 'opencode'), 'utf8')).toBe(original) + expect(env.ORCA_AGENT_HOOK_PORT).toBe(enabled ? '12345' : undefined) + } + ) + + it('drops an inherited overlay without a source and preserves an unrelated explicit config', () => { + for (const primary of [fixture.guestOverlay, custom]) { + const env = buildPtyHostEnv( + 'pane', + { + OPENCODE_CONFIG_DIR: primary, + ORCA_OPENCODE_CONFIG_DIR: fixture.guestOverlay, + ORCA_OPENCODE_AGENT: 'opencode' + }, + { ...options, disabledTuiAgents: ['opencode', 'opencode2'] } + ) + expect(env.OPENCODE_CONFIG_DIR).toBe(primary === custom ? custom : undefined) + expect(env.ORCA_OPENCODE_AGENT).toBeUndefined() + } + }) + + it('does not write a native plugin for WSL or inject a disabled guest overlay', () => { + const enabled = buildPtyHostEnv( + 'wsl-enabled', + {}, + { ...options, isWsl: true, launchAgent: 'opencode2' } + ) + expect(enabled.OPENCODE_CONFIG_DIR).toBe(fixture.guestOverlay) + expect(existsSync(config)).toBe(false) + const disabled = buildPtyHostEnv( + 'wsl-disabled', + {}, + { + ...options, + isWsl: true, + disabledTuiAgents: ['opencode', 'opencode2'] + } + ) + expect(disabled.OPENCODE_CONFIG_DIR).toBeUndefined() + expect(disabled.ORCA_AGENT_HOOK_ENDPOINT).toBe('/guest/endpoint.json') + expect(existsSync(config)).toBe(false) + }) +}) + +it.each(['source', 'no-source', 'user-config'])( + 'carries sparse host cleanup into daemon %s env', + (kind) => { + const prepared = buildPtyHostEnv( + 'daemon-pane', + {}, + { + ...options, + disabledTuiAgents: ['opencode', 'opencode2'] + } + ) + vi.stubEnv('OPENCODE_CONFIG_DIR', kind === 'user-config' ? custom : fixture.guestOverlay) + vi.stubEnv('ORCA_OPENCODE_CONFIG_DIR', fixture.guestOverlay) + vi.stubEnv('ORCA_OPENCODE_AGENT', 'opencode') + if (kind === 'source') { + vi.stubEnv('ORCA_OPENCODE_SOURCE_CONFIG_DIR', custom) + } + const result = createDaemonPtyEnvironment({ + sessionId: 'fixture', + cols: 80, + rows: 24, + env: prepared, + envToDelete: getInheritedAgentHookEnvKeysToDelete(prepared) + }) + expect(result.OPENCODE_CONFIG_DIR).toBe(kind === 'no-source' ? undefined : custom) + expect(result.ORCA_OPENCODE_AGENT).toBeUndefined() + expect(result.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined() + expect(result.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined() + } +) + +it.each([ + { primary: 'overlay', source: true, explicit: true, expected: 'current' }, + { primary: 'current', source: true, explicit: false, expected: 'current' }, + { primary: 'overlay', source: true, explicit: false, expected: 'previous' }, + { primary: 'absent', source: true, explicit: false, expected: 'previous' }, + { primary: 'overlay', source: false, explicit: false, expected: undefined }, + { primary: 'overlay', source: false, explicit: true, expected: 'current' }, + { primary: 'absent', source: false, explicit: false, expected: undefined } +])('daemon config precedence: $primary, source $source, explicit $explicit', (scenario) => { + const prepared = buildPtyHostEnv( + 'daemon-pane', + scenario.explicit ? { OPENCODE_CONFIG_DIR: custom } : {}, + { ...options, disabledTuiAgents: ['opencode', 'opencode2'] } + ) + const previous = join(root, 'previous') + mkdirSync(previous) + writeFileSync(join(previous, 'opencode.json'), '{"model":"previous"}') + vi.stubEnv( + 'OPENCODE_CONFIG_DIR', + scenario.primary === 'absent' + ? undefined + : scenario.primary === 'current' + ? custom + : fixture.guestOverlay + ) + vi.stubEnv('ORCA_OPENCODE_CONFIG_DIR', fixture.guestOverlay) + vi.stubEnv('ORCA_OPENCODE_SOURCE_CONFIG_DIR', scenario.source ? previous : undefined) + vi.stubEnv('ORCA_OPENCODE_AGENT', 'opencode') + const request = { + sessionId: 'fixture', + cols: 80, + rows: 24, + env: prepared, + envToDelete: getInheritedAgentHookEnvKeysToDelete(prepared) + } + const result = createDaemonPtyEnvironment(request) + rescrubDaemonPtyEnvironment(result, request) + if (scenario.expected) { + expect(readFileSync(join(result.OPENCODE_CONFIG_DIR, 'opencode.json'), 'utf8')).toBe( + scenario.expected === 'current' ? '{"model":"fixture"}' : '{"model":"previous"}' + ) + } else { + expect(result.OPENCODE_CONFIG_DIR).toBeUndefined() + } + expect(result.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined() + expect(result.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined() + expect(result.ORCA_OPENCODE_AGENT).toBeUndefined() +}) + +it.each(['opencode', 'opencode2'] as const)( + 'keeps the freshly selected %s overlay through a stale daemon', + (agent) => { + const prepared = buildPtyHostEnv( + 'pane', + { OPENCODE_CONFIG_DIR: custom }, + { + ...options, + launchAgent: agent + } + ) + vi.stubEnv('OPENCODE_CONFIG_DIR', fixture.guestOverlay) + vi.stubEnv('ORCA_OPENCODE_CONFIG_DIR', fixture.guestOverlay) + vi.stubEnv('ORCA_OPENCODE_SOURCE_CONFIG_DIR', join(root, 'stale-source')) + const result = createDaemonPtyEnvironment({ + sessionId: 'fixture', + cols: 80, + rows: 24, + env: prepared, + envToDelete: getInheritedAgentHookEnvKeysToDelete(prepared) + }) + expect(readFileSync(join(result.OPENCODE_CONFIG_DIR, 'opencode.json'), 'utf8')).toBe( + '{"model":"fixture"}' + ) + expect(existsSync(plugin(result.OPENCODE_CONFIG_DIR, agent))).toBe(true) + expect(result.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(custom) + expect(result.ORCA_OPENCODE_AGENT).toBe(agent) + } +) + +it.each([true, false])( + 'preserves explicit host config over inherited markers with hooks %s', + (enabled) => { + const stale = join(root, 'stale-source') + mkdirSync(stale) + writeFileSync(join(stale, 'opencode.json'), '{"model":"stale"}') + for (const inheritedFromProcess of [true, false]) { + const markers = { + ORCA_OPENCODE_CONFIG_DIR: join(root, 'old-overlay'), + ORCA_OPENCODE_SOURCE_CONFIG_DIR: stale + } + for (const [key, value] of Object.entries(markers)) { + vi.stubEnv(key, inheritedFromProcess ? value : undefined) + } + const env = buildPtyHostEnv( + 'explicit-config', + { + ...(inheritedFromProcess ? {} : markers), + OPENCODE_CONFIG_DIR: custom + }, + { ...options, agentStatusHooksEnabled: enabled } + ) + expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'opencode.json'), 'utf8')).toBe( + '{"model":"fixture"}' + ) + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(enabled ? custom : undefined) + expect(existsSync(plugin(stale, 'opencode'))).toBe(false) + } + } +) + +it.each([true, false])( + 'drops host config for disabled WSL variants with explicit primary %s', + (explicit) => { + const overlay = 'C:\\Users\\fixture\\Orca\\opencode-overlays\\old' + const source = 'C:\\Users\\fixture\\config\\opencode' + vi.stubEnv('ORCA_OPENCODE_CONFIG_DIR', overlay) + vi.stubEnv('ORCA_OPENCODE_SOURCE_CONFIG_DIR', source) + const env = buildPtyHostEnv( + 'wsl-disabled-inherited', + { + OPENCODE_CONFIG_DIR: explicit ? source : overlay, + ORCA_OPENCODE_CONFIG_DIR: overlay, + ORCA_OPENCODE_SOURCE_CONFIG_DIR: source + }, + { ...options, isWsl: true, disabledTuiAgents: ['opencode', 'opencode2'] } + ) + expect(env.OPENCODE_CONFIG_DIR).toBeUndefined() + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined() + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined() + expect(env.ORCA_OPENCODE_AGENT).toBeUndefined() + expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBe('/guest/endpoint.json') + expect(existsSync(config)).toBe(false) + } +) + +// Why: pre-1.4.209 panes exported Orca's retired /opencode-hooks/shared dir. OpenCode 2 +// treats OPENCODE_CONFIG_DIR as the only config dir, so inheriting it loaded a stale plugin and hid +// the user's global config. +describe.each([ + { name: 'marked', marked: true }, + { name: 'unmarked', marked: false } +])('inherited retired shared hooks dir ($name)', ({ marked }) => { + it.each([ + { agent: 'opencode', hooksDir: 'opencode-hooks' }, + { agent: 'opencode', hooksDir: 'opencode2-hooks' }, + { agent: 'opencode2', hooksDir: 'opencode-hooks' }, + { agent: 'opencode2', hooksDir: 'opencode2-hooks' } + ] as const)('drops $hooksDir for $agent panes', ({ agent, hooksDir }) => { + const legacy = join(fixture.userData, hooksDir, 'shared') + mkdirSync(join(legacy, 'plugins'), { recursive: true }) + const env = buildPtyHostEnv( + 'pane', + marked + ? { OPENCODE_CONFIG_DIR: legacy, ORCA_OPENCODE_CONFIG_DIR: legacy } + : { OPENCODE_CONFIG_DIR: legacy }, + { ...options, launchAgent: agent } + ) + expect(env.OPENCODE_CONFIG_DIR).toBeUndefined() + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined() + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined() + expect(existsSync(plugin(config, agent))).toBe(true) + expect(existsSync(join(fixture.userData, `${agent}-config-overlays`))).toBe(false) + }) +}) + +it('keeps a user config dir that merely sits beside the retired hooks dir', () => { + const neighbour = join(fixture.userData, 'opencode-hooks', 'mine') + mkdirSync(neighbour, { recursive: true }) + const env = buildPtyHostEnv('pane', { OPENCODE_CONFIG_DIR: neighbour }, options) + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(neighbour) + expect(env.OPENCODE_CONFIG_DIR).not.toBeUndefined() +}) + +it.each(['explicit', 'inherited'])('refreshes the %s stale plugin with hooks off', (source) => { + const legacy = join(fixture.userData, 'opencode-hooks', 'shared') + const stalePlugin = join(legacy, 'plugins', 'orca-opencode-status.js') + mkdirSync(join(legacy, 'plugins'), { recursive: true }) + writeFileSync(stalePlugin, 'export default { id: "orca-opencode-status", server() {} }\n') + if (source === 'inherited') { + vi.stubEnv('OPENCODE_CONFIG_DIR', legacy) + } + const env = buildPtyHostEnv( + 'pane', + source === 'explicit' ? { OPENCODE_CONFIG_DIR: legacy } : {}, + { ...options, agentStatusHooksEnabled: false } + ) + expect(env.OPENCODE_CONFIG_DIR).toBeUndefined() + expect(readFileSync(stalePlugin, 'utf8')).toContain('setup') +}) + +it.each([true, false])('strips daemon-inherited retired paths (known to main: %s)', (known) => { + const legacy = join(fixture.userData, 'opencode-hooks', 'shared') + if (known) { + vi.stubEnv('OPENCODE_CONFIG_DIR', legacy) + } + const env = buildPtyHostEnv('pane', {}, { ...options, agentStatusHooksEnabled: false }) + vi.stubEnv('ORCA_USER_DATA_PATH', fixture.userData) + vi.stubEnv('OPENCODE_CONFIG_DIR', legacy) + const request = { sessionId: 'pane', cols: 80, rows: 24, cwd: root, env } + const result = createDaemonPtyEnvironment(request) + expect(result.OPENCODE_CONFIG_DIR).toBeUndefined() + result.OPENCODE_CONFIG_DIR = legacy + rescrubDaemonPtyEnvironment(result, request) + expect(result.OPENCODE_CONFIG_DIR).toBeUndefined() +}) + +it('preserves explicit user config over a retired daemon-inherited path', () => { + vi.stubEnv('ORCA_USER_DATA_PATH', fixture.userData) + vi.stubEnv('OPENCODE_CONFIG_DIR', join(fixture.userData, 'opencode-hooks', 'shared')) + const env = { OPENCODE_CONFIG_DIR: custom } + const result = createDaemonPtyEnvironment({ + sessionId: 'pane', + cols: 80, + rows: 24, + cwd: root, + env + }) + expect(result.OPENCODE_CONFIG_DIR).toBe(custom) +}) + +it('does not restore a retired source from process.env with hooks disabled', () => { + vi.stubEnv('ORCA_OPENCODE_SOURCE_CONFIG_DIR', join(fixture.userData, 'opencode-hooks', 'shared')) + const env = buildPtyHostEnv('pane', {}, { ...options, agentStatusHooksEnabled: false }) + expect(env.OPENCODE_CONFIG_DIR).toBeUndefined() +}) + +it.each([true, false])( + 'preserves explicit config with a retired parent source (hooks: %s)', + (enabled) => { + vi.stubEnv( + 'ORCA_OPENCODE_SOURCE_CONFIG_DIR', + join(fixture.userData, 'opencode-hooks', 'shared') + ) + const env = buildPtyHostEnv( + 'pane', + { OPENCODE_CONFIG_DIR: custom }, + { ...options, agentStatusHooksEnabled: enabled } + ) + if (enabled) { + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(custom) + expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'opencode.json'), 'utf8')).toBe( + '{"model":"fixture"}' + ) + } else { + expect(env.OPENCODE_CONFIG_DIR).toBe(custom) + } + } +) + +it('repairs both legacy variants without main inheriting any retired path or enabling hooks', () => { + for (const agent of ['opencode', 'opencode2']) { + const path = plugin(join(fixture.userData, `${agent}-hooks`, 'shared'), agent) + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, '// old plugin') + } + buildPtyHostEnv('pane', {}, { ...options, agentStatusHooksEnabled: false }) + for (const agent of ['opencode', 'opencode2']) { + expect( + readFileSync(plugin(join(fixture.userData, `${agent}-hooks`, 'shared'), agent), 'utf8') + ).toContain('setup') + } +}) diff --git a/src/main/ipc/pty/host-env/pi-agent.ts b/src/main/ipc/pty/host-env/pi-agent.ts index 545d04d063d..caf18e3e1aa 100644 --- a/src/main/ipc/pty/host-env/pi-agent.ts +++ b/src/main/ipc/pty/host-env/pi-agent.ts @@ -8,7 +8,11 @@ import { type PiAgentKind } from '../../../../shared/pi-agent-kind' import { readSessionShellStartupEnvVar } from '../../../pty/shell-startup-env' -import { AGENT_HOOK_RUNTIME_ENV_KEYS, CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS } from './spawn-env-keys' +import { + AGENT_HOOK_RUNTIME_ENV_KEYS, + CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS, + ORCA_AGENT_SESSION_CALLER_ENV_KEYS +} from './spawn-env-keys' export function readEnvWithProcessFallback( baseEnv: Record, @@ -130,37 +134,27 @@ export function getInheritedAgentHookEnvKeysToDelete( ): string[] { const env = spawnEnv ?? {} // Why: providers merge process.env after cleanup; delete stale hook keys without dropping fresh coordinates buildPtyHostEnv set. - return AGENT_HOOK_RUNTIME_ENV_KEYS.filter((key) => env[key] === undefined) + return [ + ...AGENT_HOOK_RUNTIME_ENV_KEYS, + 'ORCA_OPENCODE_AGENT', + 'ORCA_OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_SOURCE_CONFIG_DIR' + ].filter((key) => env[key] === undefined) } -export function getInheritedClaudeSessionStampEnvKeysToDelete( +export function getInheritedAgentSessionStampEnvKeysToDelete( spawnEnv: Record | undefined ): string[] { const env = spawnEnv ?? {} - // Why: strip only values inherited from the pty host; a caller that explicitly - // provides a stamp (deliberately spawning a nested Claude child) keeps it. - return CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS.filter((key) => env[key] === undefined) + // Why: a caller that explicitly provides a Claude stamp (a nested Claude child) keeps it; no + // terminal is a structured session, so the session caller keys always go. + return [ + ...CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS.filter((key) => env[key] === undefined), + ...ORCA_AGENT_SESSION_CALLER_ENV_KEYS + ] } -// Why: a nested terminal can inherit prior OpenCode/Pi/OMP overlay env; restore the user's recorded source dir, else strip only Orca-owned values. -export function restoreOrStripOverlayEnv( - baseEnv: Record, - keys: { - primary: string - overlay: string - source: string - } -): void { - const sourceValue = baseEnv[keys.source] ?? process.env[keys.source] - const overlayValue = baseEnv[keys.overlay] ?? process.env[keys.overlay] - if (sourceValue) { - baseEnv[keys.primary] = sourceValue - } else if (overlayValue && baseEnv[keys.primary] === overlayValue) { - delete baseEnv[keys.primary] - } - delete baseEnv[keys.overlay] - delete baseEnv[keys.source] -} +export { restoreOrStripOverlayEnv } from '../../../../shared/agent-overlay-env' export function isMimoLaunchCommand(launchCommand: string | undefined): boolean { const binary = getCommandTokenPathBasename(getFirstCommandToken(launchCommand ?? '')) @@ -183,16 +177,20 @@ export function resolveMimocodeSourceHome(baseEnv: Record): stri } export function resolveOpenCodeSourceConfigDir( - baseEnv: Record + baseEnv: Record, + inheritedEnv: NodeJS.ProcessEnv = process.env ): string | undefined { + const configDir = baseEnv.OPENCODE_CONFIG_DIR ?? inheritedEnv.OPENCODE_CONFIG_DIR + const orcaConfigDir = baseEnv.ORCA_OPENCODE_CONFIG_DIR ?? inheritedEnv.ORCA_OPENCODE_CONFIG_DIR + if (configDir && orcaConfigDir && configDir !== orcaConfigDir) { + return configDir + } const sourceDir = - baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR ?? process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR + baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR ?? inheritedEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR if (sourceDir) { return sourceDir } - const configDir = baseEnv.OPENCODE_CONFIG_DIR ?? process.env.OPENCODE_CONFIG_DIR - const orcaConfigDir = baseEnv.ORCA_OPENCODE_CONFIG_DIR ?? process.env.ORCA_OPENCODE_CONFIG_DIR // Why: with no recorded source dir, an inherited OPENCODE_CONFIG_DIR is Orca-owned, not user config; treating it as user config makes child Orcas mirror the hook dir. if (configDir && orcaConfigDir && configDir === orcaConfigDir) { return undefined diff --git a/src/main/ipc/pty/host-env/spawn-env-keys.ts b/src/main/ipc/pty/host-env/spawn-env-keys.ts index cabbde7b0ec..ed070c7d043 100644 --- a/src/main/ipc/pty/host-env/spawn-env-keys.ts +++ b/src/main/ipc/pty/host-env/spawn-env-keys.ts @@ -1,3 +1,6 @@ +import { ORCA_AGENT_SESSION_ID_ENV } from '../../../../shared/agent-session-caller-env' +import { ORCA_STRUCTURED_SESSION_ENV } from '../../../../shared/structured-session-marker' + export const AGENT_HOOK_RUNTIME_ENV_KEYS = [ 'ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN', @@ -15,3 +18,9 @@ export const CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS = [ 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_BRIDGE_SESSION_ID' ] as const + +// Why: Orca writes these only into a structured session's own spawn, so an inherited value means a pty host launched from inside one — every pane would claim that session as its orchestration caller. +export const ORCA_AGENT_SESSION_CALLER_ENV_KEYS = [ + ORCA_AGENT_SESSION_ID_ENV, + ORCA_STRUCTURED_SESSION_ENV +] as const diff --git a/src/main/ipc/pty/host-env/types.ts b/src/main/ipc/pty/host-env/types.ts index 37ef0614a50..20981d0cce4 100644 --- a/src/main/ipc/pty/host-env/types.ts +++ b/src/main/ipc/pty/host-env/types.ts @@ -29,7 +29,6 @@ export type BuildPtyHostEnvOptions = { agentStatusHooksEnabled: boolean /** Per-agent opt-out; disabled agents must not receive managed extensions. */ disabledTuiAgents?: Iterable | null - codexStatusHooksEnabled?: boolean networkProxySettings?: NetworkProxySettings /** Headless paired runtimes hand browser launches to the client-hosted Orca browser. */ routeBrowserOpensToClient?: boolean @@ -38,8 +37,6 @@ export type BuildPtyHostEnvOptions = { } export type CodexHomeLaunchContext = { - workspacePath?: string - launchAgent?: TuiAgent unavailableManagedHomePath?: string } @@ -56,7 +53,6 @@ export type PrepareCodexSessionResume = (args: { providerSession: AgentProviderSessionMetadata target: CodexAccountSelectionTarget launchEnv?: NodeJS.ProcessEnv - workspacePath?: string }) => Promise export type CodexHomePtySpawnedLifecycleArgs = { diff --git a/src/main/ipc/pty/ipc/renderer-kill.ts b/src/main/ipc/pty/ipc/renderer-kill.ts index 8f73e72aa59..10a0ffa476e 100644 --- a/src/main/ipc/pty/ipc/renderer-kill.ts +++ b/src/main/ipc/pty/ipc/renderer-kill.ts @@ -1,10 +1,11 @@ import { getPtyIpc } from '../../pty-host-bindings' import type { Store } from '../../../persistence' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { TerminalIntentionalStopKind } from '../../../runtime/terminal-intentional-stops' import type { IPtyProvider } from '../../../providers/types' import { parseAppSshPtyId } from '../../../providers/ssh-pty-id' import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../../../shared/pty-liveness-verdict' -import { ptyOwnership } from '../provider/ownership-state' +import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' import { getProviderForPty, sshProviders, tryGetProviderForPty } from '../provider/registry' import { finishPtyShutdown, isPtyAlreadyGoneError } from '../provider/liveness' import { recordUndeliveredSshPtyKill } from '../runtime/undelivered-ssh-kill' @@ -31,27 +32,49 @@ export function installPtyKillIpcHandler(deps: PtyKillIpcDeps): void { ) } -/** Stops a pane's PTY for the spawn replacing it. `markReplaced` labels the exit so the renderer - * reads it as a handoff, not the pane dying; a failed stop removes the label with nothing sent. */ -export async function stopReplacedPanePty( - deps: PtyKillIpcDeps, - id: string, - markReplaced: (id: string) => (stopped: boolean) => void -): Promise { - const settle = markReplaced(id) - try { - await stopRendererOwnedPty(deps, { id }) - } catch (err) { - settle(false) - throw err - } - settle(true) +/** Stops a pane's PTY for the spawn replacing it. The stop register labels the exit so the + * renderer reads it as a handoff, not the pane dying; a failed stop leaves no label. */ +export async function stopReplacedPanePty(deps: PtyKillIpcDeps, id: string): Promise { + await stopRendererOwnedPtyAs(deps, { id }, 'replaced') } /** Stops a renderer-owned PTY and settles only once its shutdown has been observed or synthesized. */ export async function stopRendererOwnedPty( deps: PtyKillIpcDeps, args: { id: string; keepHistory?: boolean } +): Promise { + // Why: only hibernation passes keepHistory, and its exit must keep the pane's wake binding. + await stopRendererOwnedPtyAs(deps, args, args?.keepHistory === true ? 'reversible' : null) +} + +async function stopRendererOwnedPtyAs( + deps: PtyKillIpcDeps, + args: { id: string; keepHistory?: boolean }, + intentionalStop: TerminalIntentionalStopKind | null +): Promise { + if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) { + // Why: runtime terminal handles belong to terminal.close; unowned PTY routing could target the local provider. + throw new Error('Invalid PTY provider id') + } + const settleStop = intentionalStop + ? deps.runtime?.intentionalPtyStops?.mark( + args.id, + intentionalStop, + ptyIncarnationById.get(args.id) ?? null + ) + : undefined + let stopped = false + try { + await stopRendererOwnedPtyProcess(deps, args) + stopped = true + } finally { + settleStop?.(stopped) + } +} + +async function stopRendererOwnedPtyProcess( + deps: PtyKillIpcDeps, + args: { id: string; keepHistory?: boolean } ): Promise { const { store, @@ -61,10 +84,6 @@ export async function stopRendererOwnedPty( rememberSyntheticKillExit, sendPtyExitToRenderer } = deps - if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) { - // Why: runtime terminal handles belong to terminal.close; unowned PTY routing could target the local provider. - throw new Error('Invalid PTY provider id') - } runtime?.markPtyStopRequested?.(args.id) const ownedConnectionId = ptyOwnership.get(args.id) const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null diff --git a/src/main/ipc/pty/ipc/resize-visibility.ts b/src/main/ipc/pty/ipc/resize-visibility.ts index d3326a2df6e..62e74738b72 100644 --- a/src/main/ipc/pty/ipc/resize-visibility.ts +++ b/src/main/ipc/pty/ipc/resize-visibility.ts @@ -180,7 +180,7 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void { ipcMain.removeAllListeners('pty:rendererDispatcherReady') ipcMain.on('pty:rendererDispatcherReady', (event) => { // Why: the reconcile below destructively clears delivery accounting, so a straggler handshake from a dying window must not reset the new window. - if (!isMainWindowPtyIpcEvent(event, mainWindow, mainWindow.webContents)) { + if (!isMainWindowPtyIpcEvent(event, mainWindow)) { return } // Why: a handshake while the gate is already open means a page load whose lifecycle reset was missed; clear the dead page's stale accounting so it can't permanently gate survivors. @@ -322,4 +322,13 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void { .catch(() => {}) runtime?.clearHeadlessTerminalBuffer(args.id).catch(() => {}) }) + + ipcMain.removeAllListeners('pty:resetInputModes') + ipcMain.on('pty:resetInputModes', (_event, args: { id: string }) => { + // Why: an older daemon or relay rejects the request; its model keeps the modes until reattach. + tryGetProviderForPty(args.id) + ?.resetInputModes(args.id) + .catch(() => {}) + runtime?.resetHeadlessTerminalInputModes(args.id).catch(() => {}) + }) } diff --git a/src/main/ipc/pty/ipc/serialize-buffer.ts b/src/main/ipc/pty/ipc/serialize-buffer.ts index d7b5d35d642..1c314875745 100644 --- a/src/main/ipc/pty/ipc/serialize-buffer.ts +++ b/src/main/ipc/pty/ipc/serialize-buffer.ts @@ -39,7 +39,7 @@ export function installPtySerializeBufferIpc(session: PtyIpcSession): void { ) => { // Why: the snapshot seeds terminal restore state, so only the main window may settle it. if ( - !isMainWindowPtyIpcEvent(event, session.mainWindow, session.mainWindow.webContents) || + !isMainWindowPtyIpcEvent(event, session.mainWindow) || typeof args?.requestId !== 'string' ) { return @@ -88,7 +88,8 @@ export function requestSerializedBuffer( ptyId: string, opts?: { scrollbackRows?: number } ): Promise { - if (session.mainWindow.isDestroyed()) { + const { mainWindow } = session + if (!mainWindow || mainWindow.isDestroyed()) { return Promise.resolve(null) } @@ -106,6 +107,6 @@ export function requestSerializedBuffer( if (opts) { payload.opts = opts } - session.mainWindow.webContents.send('pty:serializeBuffer:request', payload) + mainWindow.webContents.send('pty:serializeBuffer:request', payload) }) } diff --git a/src/main/ipc/pty/ipc/spawn-commit-persist.ts b/src/main/ipc/pty/ipc/spawn-commit-persist.ts index 7aaa90bf9b8..46fd8e714bb 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-persist.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-persist.ts @@ -1,29 +1,25 @@ import { toSshExecutionHostId } from '../../../../shared/execution-host' -import { markNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority' import { closeStartupQueryAuthorityForPty, getRelayPtyId } from '../provider/registry' import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure' import { recordCodexPaneAccountForSpawn } from '../host-env/codex-home' import { persistAdmittedStablePaneBinding } from '../pane/stable-owner' +import { claimSshPaneLease } from '../pane/ssh-pane-lease-claim' import { pendingByPaneKey, pendingPtyIdBySerializerGeneration, rendererSerializerReadiness } from '../pane/serializer-state' -import { ptyOwnership, ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state' +import { ptyOwnership, ptyIncarnationById } from '../provider/ownership-state' import { ptySizes } from '../delivery/visibility-state' import { resolveCommittedPtySize, type PtyGrid } from '../delivery/attached-pty-size' -import { clearProviderPtyState } from '../provider/state-cleanup' +import { discardUnpersistedPtySpawn } from '../pane/spawn-registration' import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span' import type { PtyIpcSpawnState } from './spawn-state' -export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ - rendererPreSignaled: boolean - rendererAlreadyRegistered: boolean - committedSize: PtyGrid -}> { +export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise { const args = ctx.args try { - ctx.stablePaneBindingPersisted = persistAdmittedStablePaneBinding({ + ctx.stablePaneBindingPersisted = await persistAdmittedStablePaneBinding({ store: ctx.deps.store, owner: ctx.stablePaneOwner, result: ctx.result, @@ -36,10 +32,62 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ throw error } console.error('[pty] failed to persist PTY binding after attach:', error) - throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { + throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage(error)), { agentSessionOperationOutcome: 'unknown' as const }) } + const committedSize = resolveCommittedPtySize({ + result: ctx.result, + requested: { cols: args.cols, rows: args.rows }, + cachedBeforeAttach: ctx.sessionSizeBeforeAttach + }) + const relayResultId = getRelayPtyId(args.connectionId, ctx.result.id) + // Persist the binding before acknowledging spawn so the renderer debounce cannot orphan history. + if ( + ctx.deps.store && + typeof args.worktreeId === 'string' && + typeof args.tabId === 'string' && + ctx.validatedLeafId !== null && + !ctx.stablePaneBindingPersisted + ) { + try { + const binding = { + worktreeId: args.worktreeId, + tabId: args.tabId, + leafId: ctx.validatedLeafId, + ptyId: ctx.result.id, + ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), + ...(ctx.cwd ? { startupCwd: ctx.cwd } : {}), + origin: spawnCommitBindingOrigin(ctx.result) + } + const persisted = args.connectionId + ? await ctx.deps.store.persistPtyBinding(binding, toSshExecutionHostId(args.connectionId)) + : await ctx.deps.store.persistPtyBinding(binding) + if (persisted === false) { + throw new Error('terminal_pane_owner_changed') + } + } catch (err) { + console.error('[pty] failed to persist PTY binding after spawn:', err) + await discardUnpersistedPtySpawn(ctx.provider, ctx.result, () => { + if (args.connectionId && ctx.deps.store) { + ctx.deps.store.removeSshRemotePtyLease(args.connectionId, relayResultId) + } + }) + if (err instanceof Error && err.message === 'terminal_pane_owner_changed') { + throw err + } + throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage(err)), { + agentSessionOperationOutcome: 'unknown' as const + }) + } + } + return committedSize +} + +export function publishPtyIpcSpawnCommit(ctx: PtyIpcSpawnState, committedSize: PtyGrid): void { + const args = ctx.args + // Why here: every IPC spawn that survives its binding save publishes once through this point. + ctx.deps.runtime?.noteTerminalSpawnCommit?.(ctx.result) ctx.spawnTiming.log(ctx.result.id, { daemon: ctx.isDaemonHostSpawn, reattach: ctx.result.isReattach ?? false @@ -59,7 +107,7 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) } if (ctx.initiallyHidden) { - // Why marked synchronously here: provider data events dispatch on later tasks, so this still lands ahead of the first byte's delivery decision (idempotent if already marked pre-spawn). + // Refresh the pre-spawn hidden mark only after this incarnation survives its save. ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.result.id, true) if (ctx.preSpawnHiddenMarkId !== null && ctx.preSpawnHiddenMarkId !== ctx.result.id) { // Defense: never strand a mark on an id the provider renamed. @@ -69,88 +117,24 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ ctx.deps.syncPtyBackgroundedDelivery(ctx.result.id, 'spawn') closeStartupQueryAuthorityForPty(ctx.result.id) } - // Why: record the native-Windows-ConPTY determination before the headless seed so the emulator's DA1 override exists from byte zero. - if (ctx.nativeWindowsConptySpawn) { - markNativeWindowsConptyPty(ctx.result.id) - } - const relayResultId = getRelayPtyId(args.connectionId, ctx.result.id) - if (ctx.deps.store && args.connectionId) { - // Why: remote PTYs live in the SSH relay grace window after Orca detaches; persist IDs immediately so reconnect reattaches instead of spawning a fresh shell. - ctx.deps.store.upsertSshRemotePtyLease({ - targetId: args.connectionId, - ptyId: relayResultId, - ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), - ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}), - ...(ctx.validatedLeafId ? { leafId: ctx.validatedLeafId } : {}), - state: 'attached', - lastAttachedAt: Date.now() - }) - } if (ctx.preAllocatedHandle && !ctx.stablePaneOwner?.handle) { if (ctx.deps.runtime?.registerPreAllocatedHandleForPty) { ctx.deps.runtime.registerPreAllocatedHandleForPty(ctx.result.id, ctx.preAllocatedHandle) ctx.agentTeamsLeaderHandle = null } } - const committedSize = resolveCommittedPtySize({ - result: ctx.result, - requested: { cols: args.cols, rows: args.rows }, - cachedBeforeAttach: ctx.sessionSizeBeforeAttach - }) ptySizes.set(ctx.result.id, committedSize) if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) { ptySizes.delete(ctx.effectiveSessionAppId) } - // Why: patch the load-bearing ptyId binding synchronously so a force-quit in the renderer's ~450 ms debounce window can't orphan daemon history or an SSH relay lease (Issue #217). - if ( - ctx.deps.store && - typeof args.worktreeId === 'string' && - typeof args.tabId === 'string' && - ctx.validatedLeafId !== null && - !ctx.stablePaneBindingPersisted - ) { - try { - const binding = { - worktreeId: args.worktreeId, - tabId: args.tabId, - leafId: ctx.validatedLeafId, - ptyId: ctx.result.id, - ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), - ...(ctx.cwd ? { startupCwd: ctx.cwd } : {}), - origin: spawnCommitBindingOrigin(ctx.result) - } - if (args.connectionId) { - ctx.deps.store.persistPtyBinding(binding, toSshExecutionHostId(args.connectionId)) - } else { - ctx.deps.store.persistPtyBinding(binding) - } - } catch (err) { - console.error('[pty] failed to persist PTY binding after spawn:', err) - if (!ctx.result.isReattach) { - try { - await ctx.provider.shutdown(ctx.result.id, { immediate: true }) - } catch (shutdownErr) { - console.warn('[pty] failed to clean up PTY after persistence failure:', shutdownErr) - } - clearProviderPtyState(ctx.result.id) - deletePtyOwnership(ctx.result.id) - } - if (!ctx.result.isReattach && args.connectionId && ctx.deps.store) { - ctx.deps.store.removeSshRemotePtyLease(args.connectionId, relayResultId) - } - throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { - agentSessionOperationOutcome: 'unknown' as const - }) - } - } - // Why here and not at the upsert: this path leases before it binds, so supersession fenced on the - // pane's binding still named the predecessor and bailed on every reconnect — one more reattachable - // lease, and one more `pty.attach`, per reconnect forever. Runs after whichever binding write this - // commit made, so the lease/binding order no longer decides. - if (ctx.deps.store && args.connectionId && ctx.validatedLeafId !== null) { - ctx.deps.store.supersedeSshRemotePtyLeasesForBoundPane(args.connectionId, ctx.validatedLeafId) - } - // Why: when the renderer has declared it will own the serializer for this paneKey, suppress the daemon-snapshot seed so its hydration path is sole authority (keyed on paneKey since the ptyId isn't known yet). See docs/mobile-prefer-renderer-scrollback.md. + claimSshPaneLease({ + store: ctx.deps.store, + connectionId: args.connectionId, + ptyId: ctx.result.id, + worktreeId: args.worktreeId, + tabId: args.tabId, + leafId: ctx.validatedLeafId ?? undefined + }) const rendererPreSignaled = ctx.validatedPaneKey ? pendingByPaneKey.has(ctx.validatedPaneKey) : false @@ -166,5 +150,4 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ pendingPtyIdBySerializerGeneration.set(pending.gen, ctx.result.id) } } - return { rendererPreSignaled, rendererAlreadyRegistered, committedSize } } diff --git a/src/main/ipc/pty/ipc/spawn-commit-run-facts.test.ts b/src/main/ipc/pty/ipc/spawn-commit-run-facts.test.ts new file mode 100644 index 00000000000..40cd636896a --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-commit-run-facts.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeWithRuntimeId } from '../../../runtime/orca-runtime-runtime-id' +import { TerminalIntentionalStops } from '../../../runtime/terminal-intentional-stops' +import { TerminalRunFactsRegister } from '../../../runtime/terminal-run-facts' +import { ptySizes } from '../delivery/visibility-state' +import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' +import { commitPtyIpcSpawn } from './spawn-commit' +import { createPtyIpcSpawnState } from './spawn-state' +import type { PtySpawnIpcDeps } from './spawn-types' + +const PTY_ID = 'orca-ipc-pty-run-facts' +const INCARNATION_ID = 'inc-ipc-run-facts' + +async function commit(persistPtyBinding: ReturnType) { + const facts = new TerminalRunFactsRegister() + const stops = new TerminalIntentionalStops() + stops.mark(PTY_ID, 'reversible', null)(true) + const runtime = { + terminalRunFacts: facts, + intentionalPtyStops: stops, + // Why the real method: the case under test is what the runtime does with each commit. + noteTerminalSpawnCommit: OrcaRuntimeWithRuntimeId.prototype.noteTerminalSpawnCommit, + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + cancelPendingPtyRegistration: vi.fn(), + reflowHeadlessTerminalToPtyGrid: vi.fn(), + seedHeadlessTerminal: vi.fn(), + noteTerminalSpawnCommand: vi.fn() + } + const ports = { + runtime, + store: { persistPtyBinding }, + options: {}, + sendPtySpawnedToRenderer: vi.fn() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the commit reads only the ports above, and the store only for its binding save. + const deps = ports as unknown as PtySpawnIpcDeps + const ctx = createPtyIpcSpawnState(deps, { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'leaf-1', + cols: 120, + rows: 40 + }) + ctx.validatedLeafId = 'leaf-1' + ctx.provider = { ...ctx.provider, shutdown: vi.fn().mockResolvedValue(undefined) } + ctx.result = { id: PTY_ID, incarnationId: INCARNATION_ID } + const outcome = await commitPtyIpcSpawn(ctx).then( + () => 'committed', + () => 'discarded' + ) + return { outcome, facts, stops } +} + +describe('renderer spawn commit: run facts', () => { + afterEach(() => { + ptySizes.delete(PTY_ID) + ptyOwnership.delete(PTY_ID) + ptyIncarnationById.delete(PTY_ID) + }) + + it('records a committed spawn and lets it supersede a landed stop no exit pinned', async () => { + const { outcome, facts, stops } = await commit(vi.fn().mockResolvedValue(true)) + + expect(outcome).toBe('committed') + expect(facts.read(PTY_ID, INCARNATION_ID).freshSpawn).toBe(true) + expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([]) + }) + + it('records nothing for a spawn discarded because its binding save failed', async () => { + const persistPtyBinding = vi.fn().mockRejectedValue(new Error('disk full')) + const { outcome, facts, stops } = await commit(persistPtyBinding) + + expect(outcome).toBe('discarded') + expect(persistPtyBinding).toHaveBeenCalledOnce() + expect(facts.read(PTY_ID, INCARNATION_ID).freshSpawn).toBe(false) + expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual(['reversible']) + }) +}) diff --git a/src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts b/src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts index 6266faf0c02..aa726fdc685 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts @@ -9,7 +9,7 @@ import { toAppSshPtyId } from '../../../providers/ssh-pty-id' import { toSshExecutionHostId } from '../../../../shared/execution-host' import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' import { createPtyIpcSpawnState } from './spawn-state' -import { persistPtyIpcSpawnCommit } from './spawn-commit-persist' +import { persistPtyIpcSpawnCommit, publishPtyIpcSpawnCommit } from './spawn-commit-persist' vi.mock('electron', () => ({ app: { getPath: () => testState.dir }, @@ -20,15 +20,7 @@ const TARGET = 'ssh-1' const WORKTREE = 'repo1::/worktree' const TAB = 'tab-1' -/** - * Drives the shipped IPC spawn commit rather than the store primitives it calls. - * - * The store-level suite could not catch this: it exercised bind-then-upsert, and this path does the - * opposite — it writes the lease row first so a force-quit in the renderer's debounce window cannot - * strand a running remote shell without one, then binds the pane. Supersession is fenced on the - * pane's binding, so under this real order it bailed on the predecessor every time and never re-ran, - * and each reconnect left one more reattachable lease for `reattachKnownPtys` to `pty.attach`. - */ +/** Exercises the shipped binding-then-publication order so reconnects retire earlier leases. */ async function commitSshSpawn( store: ReturnType, args: { relayPtyId: string; leafId: string } @@ -45,7 +37,7 @@ async function commitSshSpawn( const ctx = createPtyIpcSpawnState(deps, spawnArgs) ctx.result = { id: toAppSshPtyId(TARGET, args.relayPtyId) } ctx.validatedLeafId = args.leafId - await persistPtyIpcSpawnCommit(ctx) + publishPtyIpcSpawnCommit(ctx, await persistPtyIpcSpawnCommit(ctx)) } /** One pane's layout, so the two host partitions can be given different bindings for one leaf. */ @@ -150,7 +142,7 @@ describe('the IPC spawn commit keeps one reattachable lease per SSH pane', () => state: 'attached' }) expect(bulkReattachPtyIds(store)).toEqual(['pty2:aaa:1', 'pty2:bbb:1']) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: WORKTREE, tabId: TAB, leafId: TEST_LEAF_1, @@ -226,7 +218,7 @@ describe('the IPC spawn commit keeps one reattachable lease per SSH pane', () => }) // Production's writer for an SSH pane binding, and the whole point: it updates ONLY the host // partition, so `local` is left naming the predecessor until the renderer republishes. - store.persistPtyBinding( + await store.persistPtyBinding( { worktreeId: WORKTREE, tabId: TAB, leafId: TEST_LEAF_1, ptyId: successor }, hostId ) diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 90b700f24b2..77491c9fb11 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -4,13 +4,7 @@ import { markClaudePtySpawned } from '../../../claude-accounts/live-pty-gate' import { registerPty } from '../../../memory/pty-registry' import type { PtySpawnResult } from '../../../providers/types' import { clearMigrationUnsupportedPtysForPaneKey } from '../../../agent-hooks/migration-unsupported-pty-state' -import { track } from '../../../telemetry/client' -import { getCohortAtEmit } from '../../../telemetry/cohort-classifier' -import { - agentKindSchema, - launchSourceSchema, - requestKindSchema -} from '../../../../shared/telemetry-events' +import { recordPtySpawnTelemetry } from '../pane/spawn-telemetry' import { shouldSkipCodexHomeEnvForWindowsShell, codexReattachedHomeRouteField @@ -23,65 +17,22 @@ import { admitRendererAgentLaunchAuthority } from '../pane/launch-authority' import type { PtyIpcSpawnState } from './spawn-state' -import { persistPtyIpcSpawnCommit } from './spawn-commit-persist' +import { persistPtyIpcSpawnCommit, publishPtyIpcSpawnCommit } from './spawn-commit-persist' +import { admitPtyReattachOwnership, registerPersistedPtySpawn } from '../pane/spawn-registration' import { reflowHeadlessTerminalToCommittedGrid } from '../delivery/attached-pty-size' +import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore' +import { markNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority' export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args - const { rendererPreSignaled, rendererAlreadyRegistered, committedSize } = - await persistPtyIpcSpawnCommit(ctx) - - // Why: seed the headless emulator before registerPty so concurrent live PTY data lands on top of the seed, not replacing it (mobile keeps the daemon-restored scrollback). - // Skip when the renderer will be authoritative — its xterm buffer is richer than the daemon snapshot. - if (ctx.deps.runtime && !rendererPreSignaled && !rendererAlreadyRegistered) { - const snapshotSeedSize = - typeof ctx.result.snapshotCols === 'number' && typeof ctx.result.snapshotRows === 'number' - ? { cols: ctx.result.snapshotCols, rows: ctx.result.snapshotRows } - : undefined - if (typeof ctx.result.snapshot === 'string' && ctx.result.snapshot.length > 0) { - // Why kitty flags ride seed metadata: the snapshot omits them, but the re-seeded emulator must answer hidden `CSI ? u` with the running app's flags (terminal-query-authority.md). - ctx.deps.runtime.seedHeadlessTerminal(ctx.result.id, ctx.result.snapshot, snapshotSeedSize, { - ...(typeof ctx.result.snapshotKittyKeyboardFlags === 'number' - ? { kittyKeyboardFlags: ctx.result.snapshotKittyKeyboardFlags } - : {}), - ...(ctx.result.snapshotTerminalOwner - ? { terminalOwner: ctx.result.snapshotTerminalOwner } - : {}) - }) - } else if ( - ctx.result.coldRestore && - typeof ctx.result.coldRestore.scrollback === 'string' && - ctx.result.coldRestore.scrollback.length > 0 - ) { - const coldRestoreSeedSize = - typeof ctx.result.coldRestore.cols === 'number' && - typeof ctx.result.coldRestore.rows === 'number' - ? { cols: ctx.result.coldRestore.cols, rows: ctx.result.coldRestore.rows } - : undefined - ctx.deps.runtime.seedHeadlessTerminal( - ctx.result.id, - ctx.result.coldRestore.scrollback, - coldRestoreSeedSize, - { - cwd: ctx.result.coldRestore.cwd, - oscLinks: ctx.result.coldRestore.oscLinks, - preferProviderIfExisting: true - } - ) - } else if (typeof ctx.result.replay === 'string' && ctx.result.replay.length > 0) { - // Why: relay reattach replay is the only restore main never ingests; skip this seed and park-reveal would replace it with a suffix fragment. - ctx.deps.runtime.seedHeadlessTerminal(ctx.result.id, ctx.result.replay) - } + admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId) + if (ctx.nativeWindowsConptySpawn) { + markNativeWindowsConptyPty(ctx.result.id) } - // Why after the seed: a seed skips an existing model, and live bytes may have lazily created - // one at the 80x24 default before the spawn reply revealed the session's real grid. - reflowHeadlessTerminalToCommittedGrid({ - result: ctx.result, - committedSize, - reflowHeadlessTerminalToPtyGrid: ctx.deps.runtime?.reflowHeadlessTerminalToPtyGrid?.bind( - ctx.deps.runtime - ) - }) + // Seed before the first disk await so live output appends to the restored history. + seedHeadlessTerminalFromSpawnResult(ctx.deps.runtime, ctx.result, ctx.validatedPaneKey) + seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result) + const committedSize = await persistPtyIpcSpawnCommit(ctx) if ( typeof args.worktreeId === 'string' && args.worktreeId.length > 0 && @@ -101,7 +52,9 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { + if (!(error instanceof Error) || error.message !== 'agent_session_exited_during_start') { + throw error + } + }) + ctx.deps.runtime?.cancelPendingPtyRegistration?.(ctx.result.id, ctx.result.incarnationId) + ctx.pendingRegistrationPtyId = null + // The renderer drains this incarnation's buffered output and exit without publishing it live. + return resolvePaneSpawnReservation( + ctx.paneSpawnReservationKey, + ctx.paneSpawnReservation, + ctx.result + ) + } ctx.pendingRegistrationPtyId = null } else if (ctx.pendingRegistrationPtyId) { ctx.deps.runtime?.cancelPendingPtyRegistration?.( @@ -131,6 +99,15 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise => - (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, { - workspacePath: ctx.cwd, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined - })) ?? null + (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv)) ?? null ctx.selectedCodexHomePath = !ctx.preAdoptedStablePane && !args.connectionId ? getCompatibleSelectedCodexHomePath( @@ -84,17 +79,12 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis resolveCurrent: async () => getCompatibleSelectedCodexHomePath( ctx.codexSelectionTarget, - (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, { - workspacePath: ctx.cwd, - launchAgent: 'codex' - })) ?? null + (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv)) ?? null ), resolveAfterUnavailable: async (unavailableManagedHomePath) => getCompatibleSelectedCodexHomePath( ctx.codexSelectionTarget, (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, { - workspacePath: ctx.cwd, - launchAgent: 'codex', unavailableManagedHomePath })) ?? null ) @@ -152,7 +142,6 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis wslDistro: ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null, agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), disabledTuiAgents: ptySettings?.disabledTuiAgents, - codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings), networkProxySettings: ptySettings, routeBrowserOpensToClient: ctx.deps.runtime?.shouldRelayTerminalBrowserOpens?.(), deferGitConfigGuardToDaemon: diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index a2adf48771b..4d0d47eaf2e 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -1,3 +1,5 @@ +import { getAppEnvironment } from '../../../../shared/app-environment' +import { getLegacyOpenCodeEnvKeysToDelete } from '../../../opencode/legacy-shared-config-dir' import { isTuiAgent } from '../../../../shared/tui-agent-config' import { CLAUDE_AUTH_ENV_VARS } from '../../../claude-accounts/environment' import { LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS } from '../../../pty/legacy-terminal-shim-dir' @@ -7,7 +9,7 @@ import { mergePtyEnvDeletions, removeCodexHomeDeletionRequests, getInheritedAgentHookEnvKeysToDelete, - getInheritedClaudeSessionStampEnvKeysToDelete + getInheritedAgentSessionStampEnvKeysToDelete } from '../host-env/pi-agent' import { promoteAgentTeamsShimPath, deleteRequestedEnvKeys } from '../host-env/path' import { beginPtySpawnForWorktree } from '../host-env/fresh-spawn-routing' @@ -22,6 +24,7 @@ import { shouldSeedPreAttachPtySize } from '../delivery/attached-pty-size' import { getStartupTerminalIngressIntent } from '../../terminal-startup-color-query-replies' import { resolveConfiguredTerminalShellArgs } from '../configured-terminal-shell-args' import type { PtyIpcSpawnState } from './spawn-state' +import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn' /** Carries deletions to provider-owned environments, including persistent older daemons. */ export async function buildPtyIpcSpawnOptions( @@ -43,7 +46,11 @@ export async function buildPtyIpcSpawnOptions( // Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH. ctx.isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [], ctx.isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(ctx.spawnEnv) : [], - getInheritedClaudeSessionStampEnvKeysToDelete(ctx.spawnEnv), + // The daemon must judge its own inherited value; main may have a different config. + !args.connectionId && !ctx.isDaemonHostSpawn + ? getLegacyOpenCodeEnvKeysToDelete(ctx.spawnEnv, getAppEnvironment().getPath('userData')) + : [], + getInheritedAgentSessionStampEnvKeysToDelete(ctx.spawnEnv), ctx.skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [], // Why: the persistent daemon compares its own merged CODEX_HOME pair; // main cannot safely decide ownership for a process it may not parent. @@ -86,6 +93,22 @@ export async function buildPtyIpcSpawnOptions( if (args.worktreeId !== undefined) { ctx.spawnOptions.worktreeId = args.worktreeId } + const trustWrite = applyAgentWorkspaceTrustToSpawn({ + launchAgent: args.launchAgent, + worktreeId: args.worktreeId, + cwd: ctx.cwd, + store: ctx.deps.store, + isFreshLaunch: !ctx.preAdoptedStablePane && ctx.launchCommand !== undefined, + settings: ctx.deps.getSettings?.(), + env: ctx.spawnEnv, + claudeAuth: ctx.claudeAuth, + wslDistro: ctx.expectedWslDistro, + connectionId: args.connectionId ?? null, + spawnOptions: ctx.spawnOptions + }) + if (trustWrite) { + await trustWrite + } if (ctx.reservationPaneKey) { ctx.spawnOptions.paneKey = ctx.reservationPaneKey } diff --git a/src/main/ipc/pty/ipc/spawn-types.ts b/src/main/ipc/pty/ipc/spawn-types.ts index e14b469d1fb..d1f340be17d 100644 --- a/src/main/ipc/pty/ipc/spawn-types.ts +++ b/src/main/ipc/pty/ipc/spawn-types.ts @@ -104,7 +104,6 @@ export type PtySpawnIpcDeps = { providerSession?: AgentProviderSessionMetadata target: CodexAccountSelectionTarget launchEnv?: NodeJS.ProcessEnv - workspacePath?: string }) => PreparedCodexResumeHome | null noCodexResumeLaunch: (command: string | undefined) => CodexResumeLaunch resolveCodexResumeLaunch: ( diff --git a/src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts b/src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts index ceeb5127b7d..4afd2b008f1 100644 --- a/src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts +++ b/src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts @@ -68,7 +68,7 @@ describe('chunked pty write yield', () => { }) as typeof setImmediate) const outcome = await Promise.race([ - createWriteInput()({ id: PTY_ID, data: THREE_CHUNK_INPUT }), + createWriteInput()({ inputKind: 'driving', id: PTY_ID, data: THREE_CHUNK_INPUT }), afterImmediateTurns(50) ]) @@ -88,6 +88,7 @@ describe('chunked pty write yield', () => { const immediate = vi.spyOn(globalThis, 'setImmediate') const outcome = createWriteInput()({ + inputKind: 'driving', id: PTY_ID, data: 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES) }) diff --git a/src/main/ipc/pty/ipc/write-input-user-input.test.ts b/src/main/ipc/pty/ipc/write-input-user-input.test.ts new file mode 100644 index 00000000000..d4f20f00a68 --- /dev/null +++ b/src/main/ipc/pty/ipc/write-input-user-input.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TerminalRunFactsRegister } from '../../../runtime/terminal-run-facts' +import { ptyOwnership } from '../provider/ownership-state' +import { createPtyWriteInput } from './write-input' + +const PTY_ID = 'pty-user-input' + +const { provider } = vi.hoisted(() => ({ + provider: { write: vi.fn(), hasPty: vi.fn(() => true) } +})) + +vi.mock('../provider/registry', () => ({ + tryGetProviderForPty: (id: string) => (id === PTY_ID ? provider : undefined) +})) + +function createWriteInput(facts: TerminalRunFactsRegister) { + const runtime = { getDriver: () => ({ kind: 'desktop' }), terminalRunFacts: facts } + const mainWindow = { isDestroyed: () => false, webContents: { send: vi.fn() } } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: write input reads only getDriver and terminalRunFacts from the runtime, and isDestroyed/webContents from the window. + return createPtyWriteInput({ mainWindow: mainWindow as never, runtime: runtime as never }) +} + +beforeEach(() => { + ptyOwnership.set(PTY_ID, null) + provider.write.mockReset() +}) + +afterEach(() => { + ptyOwnership.delete(PTY_ID) +}) + +describe('renderer PTY writes: input kind', () => { + it.each(['writePtyInput', 'writePtyInputAccepted'] as const)( + '%s records driving input before the provider write', + async (writer) => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: PTY_ID, incarnationId: 'inc-1' }) + const recordedAtWrite: (number | null)[] = [] + provider.write.mockImplementation(() => { + recordedAtWrite.push(facts.read(PTY_ID, 'inc-1').firstUserInputAt) + }) + + await createWriteInput(facts)[writer]({ id: PTY_ID, data: 'exit\r', inputKind: 'driving' }) + + expect(recordedAtWrite).toEqual([expect.any(Number)]) + } + ) + + it.each([ + ['a launch write', 'launch', 'echo startup\r'], + ['a query reply', 'query-reply', '\x1b[3;4R'], + ['a driving write that is only a reply', 'driving', '\x1b[3;4R'], + ['a driving write that is only focus reports', 'driving', '\x1b[I\x1b[O'] + ] as const)('records nothing for %s', async (_label, inputKind, data) => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: PTY_ID, incarnationId: 'inc-1' }) + + await createWriteInput(facts).writePtyInput({ id: PTY_ID, data, inputKind }) + + expect(provider.write).toHaveBeenCalledOnce() + expect(facts.read(PTY_ID, 'inc-1').firstUserInputAt).toBeNull() + }) +}) diff --git a/src/main/ipc/pty/ipc/write-input.ts b/src/main/ipc/pty/ipc/write-input.ts index c7348ddfaef..2f8ea72cd9b 100644 --- a/src/main/ipc/pty/ipc/write-input.ts +++ b/src/main/ipc/pty/ipc/write-input.ts @@ -1,4 +1,5 @@ -import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent, WebContents } from 'electron' +import type { IpcMainEvent, IpcMainInvokeEvent } from 'electron' +import type { PtyRendererDelivery } from '../session' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' import type { IPtyProvider } from '../../../providers/types' import { isPtyWriteUnavailableError } from '../../../providers/pty-write-unavailable-error' @@ -8,41 +9,42 @@ import { } from '../../../../shared/terminal-input' import { ptyOwnership } from '../provider/ownership-state' import { tryGetProviderForPty } from '../provider/registry' +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' import { interactiveOutputCharsByPty, lastInputAtByPty } from '../delivery/visibility-state' export function isMainWindowPtyIpcEvent( event: IpcMainEvent | IpcMainInvokeEvent, - mainWindow: BrowserWindow, - mainWebContents: WebContents + mainWindow: PtyRendererDelivery | undefined ): boolean { + const mainWebContents = mainWindow?.webContents return ( + !!mainWindow && + !!mainWebContents && event.sender === mainWebContents && !mainWindow.isDestroyed() && !(typeof mainWebContents.isDestroyed === 'function' && mainWebContents.isDestroyed()) ) } -export type PtyWritePayload = { id: string; data: string } +export type PtyWritePayload = { id: string; data: string; inputKind: TerminalInputKind } export type PtyViewportClaimPayload = { id: string; cols: number; rows: number } export function createPtyWriteInput(deps: { - mainWindow: BrowserWindow + mainWindow?: PtyRendererDelivery runtime?: OrcaRuntimeService }): { writePtyInput: (args: PtyWritePayload) => boolean | Promise writePtyInputAccepted: (args: PtyWritePayload) => boolean | Promise isPtyWritePayload: (value: unknown) => value is PtyWritePayload isPtyViewportClaimPayload: (value: unknown) => value is PtyViewportClaimPayload - isPtyWriteEventFromMainWindow: ( - event: IpcMainEvent | IpcMainInvokeEvent, - mainWebContents: WebContents - ) => boolean + isPtyWriteEventFromMainWindow: (event: IpcMainEvent | IpcMainInvokeEvent) => boolean } { const { mainWindow, runtime } = deps const reportUnavailablePtyWrite = (id: string, error: unknown): void => { if ( !isPtyWriteUnavailableError(error) || + !mainWindow || mainWindow.isDestroyed() || (typeof mainWindow.webContents.isDestroyed === 'function' && mainWindow.webContents.isDestroyed()) @@ -144,10 +146,14 @@ export function createPtyWriteInput(deps: { (value as { cols: number }).cols > 0 && (value as { rows: number }).rows > 0 - const isPtyWriteEventFromMainWindow = ( - event: IpcMainEvent | IpcMainInvokeEvent, - mainWebContents: WebContents - ): boolean => isMainWindowPtyIpcEvent(event, mainWindow, mainWebContents) + const isPtyWriteEventFromMainWindow = (event: IpcMainEvent | IpcMainInvokeEvent): boolean => + isMainWindowPtyIpcEvent(event, mainWindow) + + const noteRendererPtyInput = (args: PtyWritePayload): void => { + lastInputAtByPty.set(args.id, performance.now()) + interactiveOutputCharsByPty.set(args.id, 0) + runtime?.terminalRunFacts?.recordInput(args.id, args.inputKind, args.data) + } const writePtyInput = (args: PtyWritePayload): boolean | Promise => { // Why: mobile-presence-lock defense-in-depth — the renderer's onData guard can let one keystroke slip during the state-flip lag, so catch it server-side. See docs/mobile-presence-lock.md. @@ -159,9 +165,7 @@ export function createPtyWriteInput(deps: { return false } try { - const now = performance.now() - lastInputAtByPty.set(args.id, now) - interactiveOutputCharsByPty.set(args.id, 0) + noteRendererPtyInput(args) return writePtyProviderInput(provider, args.id, args.data) } catch { return false @@ -181,9 +185,7 @@ export function createPtyWriteInput(deps: { return false } try { - const now = performance.now() - lastInputAtByPty.set(args.id, now) - interactiveOutputCharsByPty.set(args.id, 0) + noteRendererPtyInput(args) return writePtyProviderInput(provider, args.id, args.data) } catch { return false diff --git a/src/main/ipc/pty/ipc/write.ts b/src/main/ipc/pty/ipc/write.ts index 62adcd02113..04065921d07 100644 --- a/src/main/ipc/pty/ipc/write.ts +++ b/src/main/ipc/pty/ipc/write.ts @@ -1,14 +1,14 @@ -import type { BrowserWindow } from 'electron' +import type { PtyRendererDelivery } from '../session' import { getPtyIpc } from '../../pty-host-bindings' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' import { createPtyWriteInput } from './write-input' export function installPtyWriteIpcHandlers(deps: { - mainWindow: BrowserWindow + mainWindow?: PtyRendererDelivery runtime?: OrcaRuntimeService }): void { const ipcMain = getPtyIpc() - const { mainWindow, runtime } = deps + const { runtime } = deps const { writePtyInput, writePtyInputAccepted, @@ -20,7 +20,7 @@ export function installPtyWriteIpcHandlers(deps: { const hostViewportClaimTails = new Map>() ipcMain.on('pty:write', (event, args: unknown) => { - if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) { + if (!isPtyWriteEventFromMainWindow(event) || !isPtyWritePayload(args)) { return } const claimTail = hostViewportClaimTails.get(args.id) @@ -31,7 +31,7 @@ export function installPtyWriteIpcHandlers(deps: { writePtyInput(args) }) ipcMain.handle('pty:writeAccepted', (event, args: unknown): boolean | Promise => { - if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) { + if (!isPtyWriteEventFromMainWindow(event) || !isPtyWritePayload(args)) { return false } const claimTail = hostViewportClaimTails.get(args.id) @@ -42,11 +42,7 @@ export function installPtyWriteIpcHandlers(deps: { ipcMain.removeAllListeners('pty:claimViewport') ipcMain.on('pty:claimViewport', (event, args: unknown) => { - if ( - !isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || - !runtime || - !isPtyViewportClaimPayload(args) - ) { + if (!isPtyWriteEventFromMainWindow(event) || !runtime || !isPtyViewportClaimPayload(args)) { return } const prior = hostViewportClaimTails.get(args.id) diff --git a/src/main/ipc/pty/opencode-legacy-spawn-deletions.test.ts b/src/main/ipc/pty/opencode-legacy-spawn-deletions.test.ts new file mode 100644 index 00000000000..1c7203ab046 --- /dev/null +++ b/src/main/ipc/pty/opencode-legacy-spawn-deletions.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { createDaemonPtyEnvironment } from '../../daemon/pty-subprocess/spawn-environment' +import { getAppEnvironment } from '../../../shared/app-environment' +import { buildPtyIpcSpawnOptions } from './ipc/spawn-options' +import { createPtyIpcSpawnState } from './ipc/spawn-state' +import type { PtySpawnIpcDeps } from './ipc/spawn-types' +import { buildRuntimePtySpawnOptions } from './runtime/spawn-options' +import { createRuntimePtySpawnState } from './runtime/spawn-state' +import type { PtyRuntimeControllerDeps } from './runtime/controller-deps' + +afterEach(() => vi.unstubAllEnvs()) + +describe.each(['renderer', 'runtime'])('%s retired OpenCode environment deletion', (route) => { + it.each([ + { connectionId: undefined, daemon: false, explicit: undefined, deleted: true }, + { connectionId: undefined, daemon: true, explicit: undefined, deleted: false }, + { connectionId: undefined, daemon: true, explicit: '/user/config', deleted: false }, + { connectionId: 'ssh-host', daemon: false, explicit: undefined, deleted: false } + ])( + 'respects explicit=$explicit, daemon=$daemon, and connection=$connectionId', + async ({ connectionId, daemon, explicit, deleted }) => { + const legacy = join(getAppEnvironment().getPath('userData'), 'opencode-hooks', 'shared') + vi.stubEnv('OPENCODE_CONFIG_DIR', legacy) + const args = { cols: 80, rows: 24, connectionId } + const env: Record = explicit ? { OPENCODE_CONFIG_DIR: explicit } : {} + let deletions: string[] | undefined + if (route === 'renderer') { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this option-only path reads no required dependency methods with no worktree or hidden pane. + const ctx = createPtyIpcSpawnState({} as PtySpawnIpcDeps, args) + ctx.env = env + ctx.isDaemonHostSpawn = daemon + await buildPtyIpcSpawnOptions(ctx) + deletions = ctx.spawnOptions.envToDelete + ctx.finishTerminalInstall() + } else { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this option-only path reads no required dependency methods with no worktree or hidden pane. + const ctx = createRuntimePtySpawnState({} as PtyRuntimeControllerDeps, args) + ctx.env = env + ctx.isDaemonHostSpawn = daemon + await buildRuntimePtySpawnOptions(ctx) + deletions = ctx.spawnOptions.envToDelete + ctx.finishTerminalInstall() + } + expect(deletions?.includes('OPENCODE_CONFIG_DIR') ?? false).toBe(deleted) + expect(env.OPENCODE_CONFIG_DIR).toBe(explicit) + if (daemon) { + vi.stubEnv('ORCA_USER_DATA_PATH', getAppEnvironment().getPath('userData')) + for (const inherited of [legacy, '/daemon/user-config']) { + vi.stubEnv('OPENCODE_CONFIG_DIR', inherited) + const request = { sessionId: 'pane', cols: 80, rows: 24, env, envToDelete: deletions } + const result = createDaemonPtyEnvironment(request) + expect(result.OPENCODE_CONFIG_DIR).toBe( + explicit ?? (inherited === legacy ? undefined : inherited) + ) + expect( + createDaemonPtyEnvironment({ ...request, envToDelete: ['OPENCODE_CONFIG_DIR'] }) + .OPENCODE_CONFIG_DIR + ).toBeUndefined() + } + } + } + ) +}) diff --git a/src/main/ipc/pty/pane/spawn-registration.ts b/src/main/ipc/pty/pane/spawn-registration.ts new file mode 100644 index 00000000000..d96154f7df8 --- /dev/null +++ b/src/main/ipc/pty/pane/spawn-registration.ts @@ -0,0 +1,81 @@ +import type { Store } from '../../../persistence' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { IPtyProvider, PtySpawnResult } from '../../../providers/types' +import { isCurrentPtyExit, ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' +import { clearProviderPtyState } from '../provider/state-cleanup' +import { retirePersistedStablePaneOwner } from './stable-owner' + +export function admitPtyReattachOwnership( + runtime: OrcaRuntimeService | undefined, + result: PtySpawnResult, + connectionId: string | null | undefined +): void { + if (!result.isReattach && result.agentSessionEnsure?.disposition !== 'adopted') { + return + } + runtime?.assertPtyRegistrationAllowed?.(result.id, result.incarnationId) + // A failed local save must not strand a live process already admitted by its host. + ptyOwnership.set(result.id, connectionId ?? ptyOwnership.get(result.id) ?? null) + if (result.incarnationId) { + ptyIncarnationById.set(result.id, result.incarnationId) + } +} + +export async function discardUnpersistedPtySpawn( + provider: IPtyProvider, + result: PtySpawnResult, + onDiscarded?: () => void +): Promise { + if ( + result.isReattach || + result.agentSessionEnsure?.disposition === 'adopted' || + !isCurrentPtyExit(result) + ) { + return + } + try { + await provider.shutdown(result.id, { + immediate: true, + ...(result.incarnationId ? { expectedIncarnationId: result.incarnationId } : {}) + }) + } catch (error) { + console.warn('[pty] failed to clean up PTY after persistence failure:', error) + } + // A replacement may arrive while the execution host finishes shutting down the predecessor. + if (isCurrentPtyExit(result)) { + clearProviderPtyState(result.id) + ptyOwnership.delete(result.id) + onDiscarded?.() + } +} + +// Successful registration must not yield before the remaining spawn publication. +export function registerPersistedPtySpawn( + runtime: OrcaRuntimeService | undefined, + store: Store | undefined, + ...args: Parameters +): Promise | undefined { + try { + runtime?.registerPty(...args) + } catch (error) { + const [ptyId, worktreeId, connectionId, binding] = args + // An exit during the binding write precedes runtime surface registration. + if ( + error instanceof Error && + error.message === 'agent_session_exited_during_start' && + runtime?.getPtyLivenessVerdict?.(ptyId)?.status === 'exited' && + binding + ) { + return retirePersistedStablePaneOwner( + store, + { ...binding, ptyId, persistedIncarnationId: binding.incarnationId }, + worktreeId, + connectionId + ).then(() => { + throw error + }) + } + throw error + } + return undefined +} diff --git a/src/main/ipc/pty/pane/spawn-telemetry.ts b/src/main/ipc/pty/pane/spawn-telemetry.ts new file mode 100644 index 00000000000..ccd3c3d582d --- /dev/null +++ b/src/main/ipc/pty/pane/spawn-telemetry.ts @@ -0,0 +1,24 @@ +import { track } from '../../../telemetry/client' +import { getCohortAtEmit } from '../../../telemetry/cohort-classifier' +import { + agentKindSchema, + launchSourceSchema, + requestKindSchema +} from '../../../../shared/telemetry-events' +import type { PtySpawnIpcArgs } from '../ipc/spawn-types' + +export function recordPtySpawnTelemetry( + telemetry: NonNullable +): void { + const agentKind = agentKindSchema.safeParse(telemetry.agent_kind) + const launchSource = launchSourceSchema.safeParse(telemetry.launch_source) + const requestKind = requestKindSchema.safeParse(telemetry.request_kind) + if (agentKind.success && launchSource.success && requestKind.success) { + track('agent_started', { + agent_kind: agentKind.data, + launch_source: launchSource.data, + request_kind: requestKind.data, + ...getCohortAtEmit() + }) + } +} diff --git a/src/main/ipc/pty/pane/stable-owner.ts b/src/main/ipc/pty/pane/stable-owner.ts index 5d25e11f57c..83ebdd50364 100644 --- a/src/main/ipc/pty/pane/stable-owner.ts +++ b/src/main/ipc/pty/pane/stable-owner.ts @@ -1,3 +1,5 @@ +import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from '../../../persistence/restoring-sessions/workspace-session-write-rollback' +import { cloneWorkspaceSessionState } from '../../../persistence/restoring-sessions/session-owner-fields' import { toSshExecutionHostId } from '../../../../shared/execution-host' import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' import { UNVERIFIED_PROCESS_EXIT_CODE } from '../../../../shared/terminal-exit-cause' @@ -98,8 +100,7 @@ export function resolveStablePaneOwner( } const registeredConnectionId = ptyOwnership.get(ptyId) const parsedSshId = registeredConnectionId === undefined ? parseAppSshPtyId(ptyId) : null - const ownerConnectionId = registeredConnectionId ?? parsedSshId?.connectionId ?? null - if (ownerConnectionId !== (connectionId ?? null)) { + if ((registeredConnectionId ?? parsedSshId?.connectionId ?? null) !== (connectionId ?? null)) { throw new Error('terminal_pane_owner_host_mismatch') } const runtimeIncarnationId = ptyIncarnationById.get(ptyId) @@ -121,41 +122,50 @@ export function resolveStablePaneOwner( } } -export function retirePersistedStablePaneOwner( +export async function retirePersistedStablePaneOwner( store: Store | undefined, owner: StablePaneOwner, worktreeId: string, connectionId: string | null | undefined -): boolean { +): Promise { if (!store) { return false } - const paneKey = makePaneKey(owner.tabId, owner.leafId) - const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined - const current = resolvePersistedStablePaneOwner(store, paneKey, worktreeId, connectionId) - if (!current) { - // Why: persistence already dropped this pane binding (an earlier stop retired it while the - // runtime kept history), so there is nothing left to clear — that is a completed retirement, - // not a competing owner. Reporting failure here strands the pane after its PTY is proven dead. - return true - } - if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.persistedIncarnationId) { - return false - } - const session = store.getWorkspaceSession(hostId) - const retired = retireTerminalSurfaceFromPersistence(session, { - worktreeId, - parentTabId: owner.tabId, - leafId: owner.leafId, - ptyId: owner.ptyId, - ...(current.incarnationId ? { incarnationId: current.incarnationId } : {}) + return store.runDurableMutation(() => { + const paneKey = makePaneKey(owner.tabId, owner.leafId) + const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined + const current = resolvePersistedStablePaneOwner(store, paneKey, worktreeId, connectionId) + if (!current) { + // A renderer removal may still be waiting for its debounced write. + return { value: true, persist: 'if-dirty' } + } + if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.persistedIncarnationId) { + return { value: false, persist: false } + } + const session = cloneWorkspaceSessionState(store.getWorkspaceSession(hostId)) + const retired = retireTerminalSurfaceFromPersistence(session, { + worktreeId, + parentTabId: owner.tabId, + leafId: owner.leafId, + ptyId: owner.ptyId, + ...(current.incarnationId ? { incarnationId: current.incarnationId } : {}) + }) + if (retired === session) { + return { value: false, persist: false } + } + store.setWorkspaceSession(retired, hostId) + const staged = cloneWorkspaceSessionState(store.getWorkspaceSession(hostId)) + return { + value: true, + rollback: () => { + const current = store.getWorkspaceSession(hostId) + const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(session, staged, current) + if (rolledBack !== current) { + store.setWorkspaceSession(rolledBack, hostId) + } + } + } }) - if (retired === session) { - return false - } - store.setWorkspaceSession(retired, hostId) - store.flushOrThrow() - return true } export type StablePaneSpawnContext = { @@ -181,19 +191,19 @@ export function stablePanePersistenceFence( : undefined } -export function persistAdmittedStablePaneBinding(args: { +export async function persistAdmittedStablePaneBinding(args: { store: Store | undefined owner: StablePaneOwner | null result: PtySpawnResult worktreeId: string | undefined startupCwd: string | undefined connectionId: string | null | undefined -}): boolean { +}): Promise { const expectedBinding = stablePanePersistenceFence(args.owner) if (!args.store || !args.owner || !args.worktreeId || !expectedBinding) { return false } - const persisted = args.store.persistPtyBinding( + const persisted = await args.store.persistPtyBinding( { worktreeId: args.worktreeId, tabId: args.owner.tabId, @@ -270,7 +280,7 @@ export async function attachStablePaneOwner( ptyOwnership.delete(owner.ptyId) if ( args.worktreeId && - !retirePersistedStablePaneOwner(args.store, owner, args.worktreeId, args.connectionId) + !(await retirePersistedStablePaneOwner(args.store, owner, args.worktreeId, args.connectionId)) ) { throw new Error('terminal_pane_owner_changed') } diff --git a/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts b/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts index 972f479b492..390bcdd451a 100644 --- a/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts +++ b/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts @@ -9,6 +9,7 @@ // same rule here, and pin that the marked half — the one refusal the relay backed with a pid probe // — still earns the certificate, so a genuinely dead PTY is not left `unverifiable` forever. import { describe, expect, it, vi } from 'vitest' +import { withDurableRuntimeStore } from '../../../runtime/runtime-durable-store-fixture' import { getDefaultWorkspaceSession } from '../../../../shared/constants' import { makePaneKey } from '../../../../shared/stable-pane-id' import { SSH_EXIT_UNCONFIRMED_REASON } from '../../../../shared/pty-liveness-verdict' @@ -54,7 +55,8 @@ function paneStore(): { store: Store; read: () => WorkspaceSessionState } { } as unknown as WorkspaceSessionState return { read: () => session, - store: { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies the persistence methods used by stable-pane retirement and exit bookkeeping. + store: withDurableRuntimeStore({ getWorkspaceSession: () => session, setWorkspaceSession: (next: WorkspaceSessionState) => { session = next @@ -75,7 +77,7 @@ function paneStore(): { store: Store; read: () => WorkspaceSessionState } { removeWorktreeMeta: () => {}, getSettings: () => ({ workspaceDir: '/tmp/workspaces' }), getProjects: () => [] - } as unknown as Store + }) as unknown as Store } } diff --git a/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts b/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts index e68bcf6ec99..ce3c578cfa0 100644 --- a/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts +++ b/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { withDurableRuntimeStore } from '../../../runtime/runtime-durable-store-fixture' import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' import { TerminalSessionOwnerUnverifiedError } from '../../../daemon/daemon-errors' import { @@ -70,13 +71,14 @@ function sessionStore(leaves: string[]): { store: Store; read: () => WorkspaceSe } as unknown as WorkspaceSessionState return { read: () => session, - store: { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every persistence method used by stable-pane retirement. + store: withDurableRuntimeStore({ getWorkspaceSession: () => session, setWorkspaceSession: (next: WorkspaceSessionState) => { session = next }, flushOrThrow: () => {} - } as unknown as Store + }) as unknown as Store } } diff --git a/src/main/ipc/pty/pane/terminal-spawn-restore.ts b/src/main/ipc/pty/pane/terminal-spawn-restore.ts new file mode 100644 index 00000000000..cbe3d337ae3 --- /dev/null +++ b/src/main/ipc/pty/pane/terminal-spawn-restore.ts @@ -0,0 +1,46 @@ +import type { PtySpawnResult } from '../../../providers/types' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import { pendingByPaneKey, rendererSerializerReadiness } from './serializer-state' + +export function seedHeadlessTerminalFromSpawnResult( + runtime: OrcaRuntimeService | undefined, + result: PtySpawnResult, + paneKey: string | null +): void { + // A mounted renderer owns richer history than the provider snapshot. + if ( + !runtime || + (paneKey && pendingByPaneKey.has(paneKey)) || + (result.isReattach === true && rendererSerializerReadiness.has(result.id)) + ) { + return + } + if (typeof result.snapshot === 'string' && result.snapshot.length > 0) { + const size = + typeof result.snapshotCols === 'number' && typeof result.snapshotRows === 'number' + ? { cols: result.snapshotCols, rows: result.snapshotRows } + : undefined + runtime.seedHeadlessTerminal(result.id, result.snapshot, size, { + ...(typeof result.snapshotKittyKeyboardFlags === 'number' + ? { kittyKeyboardFlags: result.snapshotKittyKeyboardFlags } + : {}), + ...(result.snapshotTerminalOwner ? { terminalOwner: result.snapshotTerminalOwner } : {}) + }) + } else if ( + result.coldRestore && + typeof result.coldRestore.scrollback === 'string' && + result.coldRestore.scrollback.length > 0 + ) { + const size = + typeof result.coldRestore.cols === 'number' && typeof result.coldRestore.rows === 'number' + ? { cols: result.coldRestore.cols, rows: result.coldRestore.rows } + : undefined + runtime.seedHeadlessTerminal(result.id, result.coldRestore.scrollback, size, { + cwd: result.coldRestore.cwd, + oscLinks: result.coldRestore.oscLinks, + preferProviderIfExisting: true + }) + } else if (typeof result.replay === 'string' && result.replay.length > 0) { + runtime.seedHeadlessTerminal(result.id, result.replay) + } +} diff --git a/src/main/ipc/pty/provider/bind-listeners.ts b/src/main/ipc/pty/provider/bind-listeners.ts index 935d7f60432..31bacec82f7 100644 --- a/src/main/ipc/pty/provider/bind-listeners.ts +++ b/src/main/ipc/pty/provider/bind-listeners.ts @@ -28,6 +28,7 @@ export function bindProviderListeners(session: PtyIpcSession): void { setLocalWriteUnavailableUnsub( localProvider.onWriteUnavailable?.((payload) => { if ( + !session.mainWindow || session.mainWindow.isDestroyed() || (typeof session.mainWindow.webContents.isDestroyed === 'function' && session.mainWindow.webContents.isDestroyed()) diff --git a/src/main/ipc/pty/provider/listener-lifecycle.ts b/src/main/ipc/pty/provider/listener-lifecycle.ts index 2b31257234e..815749582f8 100644 --- a/src/main/ipc/pty/provider/listener-lifecycle.ts +++ b/src/main/ipc/pty/provider/listener-lifecycle.ts @@ -1,4 +1,4 @@ -import type { WebContents } from 'electron' +import type { PtyRendererDelivery } from '../session' // Why: localProvider.onData/onExit return unsubscribe functions. Without // storing and calling these on re-registration, macOS app re-activation @@ -9,13 +9,13 @@ export let localExitUnsub: (() => void) | null = null export let localBackgroundStreamUnsub: (() => void) | null = null export let localWriteUnavailableUnsub: (() => void) | null = null export let didFinishLoadHandler: (() => void) | null = null -export let didFinishLoadWebContents: WebContents | null = null -export let rendererLifecycleResetWebContents: WebContents | null = null +export let didFinishLoadWebContents: PtyRendererDelivery['webContents'] | null = null +export let rendererLifecycleResetWebContents: PtyRendererDelivery['webContents'] | null = null export let rendererLifecycleResetHandler: (() => void) | null = null // Why: the hidden-delivery gate registries mirror renderer state; a reload/crash destroys owners without unregistering, so they reset when the renderer is replaced (drop memory preserved). export let rendererGateResetLoadHandler: (() => void) | null = null export let rendererGateResetGoneHandler: (() => void) | null = null -export let rendererGateResetWebContents: WebContents | null = null +export let rendererGateResetWebContents: PtyRendererDelivery['webContents'] | null = null // Why: the backgrounded-delivery dedupe map lives in the registerPtyHandlers closure but teardown funnels through module-scope clearProviderPtyState. // Why null-init + wrapper fn: see delivery/debug.ts — rolldown const-folds `export let fn = noop` bridges (STA-5661). let clearBackgroundedDeliverySyncForPtyImpl: ((id: string) => void) | null = null @@ -72,14 +72,14 @@ export function setLocalWriteUnavailableUnsub(fn: (() => void) | null): void { export function setDidFinishLoadHandler( handler: (() => void) | null, - contents: WebContents | null + contents: PtyRendererDelivery['webContents'] | null ): void { didFinishLoadHandler = handler didFinishLoadWebContents = contents } export function setRendererLifecycleResetState(args: { - contents: WebContents | null + contents: PtyRendererDelivery['webContents'] | null handler: (() => void) | null navigation: ((details: RendererNavigationDetails) => void) | null }): void { @@ -89,7 +89,7 @@ export function setRendererLifecycleResetState(args: { } export function setRendererGateResetState(args: { - contents: WebContents | null + contents: PtyRendererDelivery['webContents'] | null load: (() => void) | null gone: (() => void) | null }): void { diff --git a/src/main/ipc/pty/provider/local-configure.ts b/src/main/ipc/pty/provider/local-configure.ts index bf07eddf0bb..310a15f0ae2 100644 --- a/src/main/ipc/pty/provider/local-configure.ts +++ b/src/main/ipc/pty/provider/local-configure.ts @@ -14,7 +14,6 @@ import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' import { buildPtyHostEnv } from '../host-env/assembly' import { getCompatibleSelectedCodexHomePath, - isCodexStatusHooksEnabled, shouldStripInheritedOrcaCodexHome } from '../host-env/codex-home' import type { GetSelectedCodexHomePath } from '../host-env/types' @@ -50,10 +49,7 @@ export function configureLocalPtyProvider(args: { codexSelectionTarget, ctx?.codexHomePathOverride ? ctx.codexHomePathOverride.value - : ((await getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { - workspacePath: ctx?.cwd, - launchAgent: ctx?.launchAgent - })) ?? null) + : ((await getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv)) ?? null) ) const skipCodexHomeEnv = ctx?.isWsl === true && !selectedCodexHomePath const ptySettings = getSettings?.() @@ -89,7 +85,6 @@ export function configureLocalPtyProvider(args: { wslDistro: ctx?.wslDistro ?? null, agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), disabledTuiAgents: ptySettings?.disabledTuiAgents, - codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings), networkProxySettings: ptySettings, routeBrowserOpensToClient: runtime?.shouldRelayTerminalBrowserOpens?.() }) diff --git a/src/main/ipc/pty/pty-spawn-shell-override-parity.test.ts b/src/main/ipc/pty/pty-spawn-shell-override-parity.test.ts deleted file mode 100644 index 67bee9ca700..00000000000 --- a/src/main/ipc/pty/pty-spawn-shell-override-parity.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -/** - * The two spawn preflights are twins: one serves renderer/IPC spawns, the other serves runtime - * spawns (`terminal.create` from the CLI, headless serve, and paired remote environments). They - * had drifted — the runtime twin passed a literal `undefined` for the caller's shell, so on a - * Windows host a runtime-created terminal could only ever be the host's default shell. A caller - * asking for cmd or PowerShell had to send it as `command`, which the provider TYPES into that - * default shell: the pty stayed the default shell with the requested one running inside it, and - * leaving that child dropped the caller's handle back onto a prompt it never asked for. - * - * Source-level because the functional seam is a whole spawn pipeline; what actually regressed is - * one twin silently not reading a field the other reads. - */ -const PREFLIGHTS = ['ipc', 'runtime'] as const - -describe.each(PREFLIGHTS)('%s pty spawn preflight', (lane) => { - const source = readFileSync(join(__dirname, lane, 'spawn-preflight.ts'), 'utf8') - - it("resolves Windows terminal runtime options from the caller's requested shell", () => { - expect(source).toContain('requestedShellOverride: args.shellOverride') - expect(source).not.toContain('requestedShellOverride: undefined') - }) -}) diff --git a/src/main/ipc/pty/register-handlers.ts b/src/main/ipc/pty/register-handlers.ts index 7794a8ef60a..5c7c897474a 100644 --- a/src/main/ipc/pty/register-handlers.ts +++ b/src/main/ipc/pty/register-handlers.ts @@ -1,4 +1,3 @@ -import type { BrowserWindow } from 'electron' import { getAppEnvironment } from '../../../shared/app-environment' import type { OrcaRuntimeService } from '../../runtime/orca-runtime' import type { Store } from '../../persistence' @@ -18,7 +17,6 @@ import { stopReplacedPanePty, type PtyKillIpcDeps } from './ipc/renderer-kill' -import { markReplacedPtyStop } from './delivery/exit' import { installPtyWriteIpcHandlers } from './ipc/write' import { installPtySpawnIpcHandler } from './ipc/spawn' import { installPtyRuntimeController } from './runtime/controller' @@ -48,7 +46,7 @@ import { clearRendererGateResetHandlers, clearDidFinishLoadHandler } from './delivery/lifecycle-reset' -import { createPtyIpcSession, type PtyIpcSessionOptions } from './session' +import { createPtyIpcSession, type PtyIpcSessionOptions, type PtyRendererDelivery } from './session' import { wirePtyIpcSession } from './delivery/wire-session' import { configureLocalPtyProvider } from './provider/local-configure' import { bindProviderListeners } from './provider/bind-listeners' @@ -67,7 +65,7 @@ import { import { ensureLinuxTerminalOrcaCliShimDir } from '../../cli/linux-terminal-orca-cli-shim' export function registerPtyHandlers( - mainWindow: BrowserWindow, + mainWindow?: PtyRendererDelivery, runtime?: OrcaRuntimeService, getSelectedCodexHomePath?: GetSelectedCodexHomePath, getSettings?: () => GlobalSettings, @@ -92,7 +90,7 @@ export function registerPtyHandlers( setInvalidatePendingPtyDrainPolicy(() => {}) // Why: neutralize rebind at the same moment as drain so a daemon replace in this window cannot attach the old accept/exit closures. setRebindProviderListeners(() => {}) - registerRendererLifecycleResetHandlers(mainWindow.webContents) + registerRendererLifecycleResetHandlers(mainWindow?.webContents) const getLocalPtyStartupPromise = (connectionId?: string | null): Promise | undefined => { if (connectionId) { @@ -169,17 +167,19 @@ export function registerPtyHandlers( // Why: the daemon pacer must not keep throttling ptys whose hidden marks died with the renderer; the fresh renderer's sync re-marks the still-hidden ones. session.resyncBackgroundedDeliveriesAfterGateReset() } - setRendererGateResetState({ - contents: mainWindow.webContents, - load: resetRendererPtyDeliveryGateState, - gone: resetRendererPtyDeliveryGateState - }) - mainWindow.webContents.on('did-finish-load', resetRendererPtyDeliveryGateState) - mainWindow.webContents.on('render-process-gone', resetRendererPtyDeliveryGateState) + if (mainWindow) { + setRendererGateResetState({ + contents: mainWindow.webContents, + load: resetRendererPtyDeliveryGateState, + gone: resetRendererPtyDeliveryGateState + }) + mainWindow.webContents.on('did-finish-load', resetRendererPtyDeliveryGateState) + mainWindow.webContents.on('render-process-gone', resetRendererPtyDeliveryGateState) + } // Why: only LocalPtyProvider PTYs (main-process) can be orphaned on reload; daemon sessions survive by design and cleanup would kill them. clearDidFinishLoadHandler() - if (localProvider instanceof LocalPtyProvider) { + if (mainWindow && localProvider instanceof LocalPtyProvider) { const lp = localProvider const finishLoadHandler = () => { // Why: always advance to keep the generation monotonic, but skip the sweep on crash/freeze-recovery reload — it would kill live local PTYs before session restore (#5787). @@ -236,7 +236,6 @@ export function registerPtyHandlers( options, trustedTerminalHandleEnv: session.trustedTerminalHandleEnv, retiredRejectedPtyIds: session.retiredRejectedPtyIds, - reversibleStopOwnersByPtyId: session.reversibleStopOwnersByPtyId, mainWindow, transitionSpawnHiddenRendererPtyDeliveryState: session.transitionSpawnHiddenRendererPtyDeliveryState, @@ -274,8 +273,7 @@ export function registerPtyHandlers( trustedTerminalHandleEnv: session.trustedTerminalHandleEnv, sendPtySpawnedToRenderer: session.sendPtySpawnedToRenderer, syncPtyBackgroundedDelivery: session.syncPtyBackgroundedDelivery, - stopReplacedPty: (id) => - stopReplacedPanePty(killDeps, id, (ptyId) => markReplacedPtyStop(session, ptyId)) + stopReplacedPty: (id) => stopReplacedPanePty(killDeps, id) }) installPtyWriteIpcHandlers({ mainWindow, runtime }) installPtyResizeVisibilityIpc(session) diff --git a/src/main/ipc/pty/register-headless-runtime.test.ts b/src/main/ipc/pty/register-headless-runtime.test.ts index 7063ec2454d..a1b6f723483 100644 --- a/src/main/ipc/pty/register-headless-runtime.test.ts +++ b/src/main/ipc/pty/register-headless-runtime.test.ts @@ -33,6 +33,7 @@ describe('registerHeadlessPtyRuntime', () => { expect(events).toEqual(['handlers', 'hydrate']) expect(registerHandlersMock).toHaveBeenCalledOnce() + expect(registerHandlersMock.mock.calls[0]?.[0]).toBeUndefined() expect(hydrateMock).toHaveBeenCalledWith(store) resolveHydration() diff --git a/src/main/ipc/pty/register-headless-runtime.ts b/src/main/ipc/pty/register-headless-runtime.ts index 2212fbfac3a..424cb40facd 100644 --- a/src/main/ipc/pty/register-headless-runtime.ts +++ b/src/main/ipc/pty/register-headless-runtime.ts @@ -1,4 +1,3 @@ -import type { BrowserWindow } from 'electron' import type { OrcaRuntimeService } from '../../runtime/orca-runtime' import type { Store } from '../../persistence' import type { GlobalSettings } from '../../../shared/global-settings-types' @@ -23,21 +22,8 @@ export function registerHeadlessPtyRuntime( onPtyExit?: (id: string, exitSequence: number) => void } ): Promise { - // Why: headless `orca serve` has no renderer window but still needs the same PTY handlers so remote clients can drive terminals. - // Why a fake rather than null: `registerPtyHandlers` takes a non-null BrowserWindow. `isDestroyed: () => true` - // is what makes that safe — every renderer-liveness guard reads it and skips, so no send is ever attempted. - // Keep `webContents.isDestroyed` in step with it: guards check both, and a missing method reads as "alive". - const headlessWindow = { - isDestroyed: () => true, - webContents: { - isDestroyed: () => true, - send: () => {}, - on: () => {}, - removeListener: () => {} - } - } as unknown as BrowserWindow registerPtyHandlers( - headlessWindow, + undefined, runtime, getSelectedCodexHomePath, getSettings, diff --git a/src/main/ipc/pty/register-without-renderer.test.ts b/src/main/ipc/pty/register-without-renderer.test.ts new file mode 100644 index 00000000000..a5d19f020fc --- /dev/null +++ b/src/main/ipc/pty/register-without-renderer.test.ts @@ -0,0 +1,231 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' +import { setupPtyIpcSuite } from '../pty-ipc-test-harness' +import { + getLocalPtyProvider, + getPtyRendererDeliveryDebugSnapshot, + registerPtyHandlers, + registerSshPtyProvider, + setPtyOwnership, + unregisterSshPtyProvider +} from '../pty' +import { registerHeadlessPtyRuntime } from './register-headless-runtime' +import { onMock } from '../pty-ipc-mock-registry' + +vi.mock('electron', () => import('../pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('../pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('../pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('../pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../../opencode/hook-service', () => + import('../pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../../mimo/hook-service', () => + import('../pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../../agent-hooks/server', () => + import('../pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../../pi/titlebar-extension-service', () => + import('../pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../../pwsh', () => import('../pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../../wsl', async (importOriginal) => + (await import('../pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../../telemetry/client', () => + import('../pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../../telemetry/classify-error', () => + import('../pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../../cli/linux-terminal-orca-cli-shim', () => + import('../pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../../memory/pty-registry', () => + import('../pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../../agent-hooks/migration-unsupported-pty-state', () => + import('../pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../../codex/codex-pane-account-registry', () => + import('../pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../../codex/codex-state-db-backfill-recovery', () => + import('../pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +describe('PTY registration without renderer delivery', () => { + const { + handlers, + mainWindow, + mainWindowIpcEvent, + installObservableDaemonTestProvider, + getPtyWriteListener + } = setupPtyIpcSuite() + + it('keeps daemon output and exits flowing to the runtime without renderer work', async () => { + vi.useFakeTimers() + const daemon = installObservableDaemonTestProvider() + const runtime = new OrcaRuntimeService() + const setController = vi.spyOn(runtime, 'setPtyController') + const onData = vi.spyOn(runtime, 'onPtyData').mockReturnValue(6) + const onExit = vi.spyOn(runtime, 'onPtyExit').mockImplementation(() => {}) + const onLifecycleExit = vi.fn() + setPtyOwnership('daemon-pty', null) + const initialTimerCount = vi.getTimerCount() + + await registerHeadlessPtyRuntime( + runtime, + undefined, + undefined, + undefined, + undefined, + undefined, + { onPtyExit: onLifecycleExit } + ) + const controller = setController.mock.calls[0]?.[0] + expect(controller).toBeDefined() + expect(vi.getTimerCount()).toBe(initialTimerCount) + daemon.emitData('daemon-pty', 'output') + daemon.emitDataGap('daemon-pty', 3) + daemon.emitExit('daemon-pty', 0) + + expect(onData).toHaveBeenCalledWith('daemon-pty', 'output', expect.any(Number), 6, undefined) + expect(onExit).toHaveBeenCalledWith('daemon-pty', 0, undefined, { providerExitObserved: true }) + expect(onLifecycleExit).toHaveBeenCalledWith('daemon-pty', expect.any(Number)) + await expect(controller?.serializeBuffer?.('daemon-pty')).resolves.toBeNull() + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingChars: 0, + rendererInFlightChars: 0, + flushScheduled: false, + diagnostics: { windowFocused: null, windowVisible: null, windowMinimized: null } + }) + expect(mainWindow.webContents.on).not.toHaveBeenCalled() + expect(mainWindow.webContents.send).not.toHaveBeenCalled() + expect(daemon.pauseProducer).not.toHaveBeenCalled() + }) + + it('routes local and SSH control to their providers, including attach and buffer snapshots', async () => { + const remote = installObservableDaemonTestProvider() + const remoteProvider = getLocalPtyProvider() + registerSshPtyProvider('ssh-a', remoteProvider) + const local = installObservableDaemonTestProvider() + const localProvider = getLocalPtyProvider() + const localClear = vi.spyOn(localProvider, 'clearBuffer') + const remoteClear = vi.spyOn(remoteProvider, 'clearBuffer') + const attach = vi.spyOn(localProvider, 'attach').mockResolvedValue({}) + const runtime = new OrcaRuntimeService() + const setController = vi.spyOn(runtime, 'setPtyController') + const remoteId = 'ssh:ssh-a@@remote-pty' + local.getBufferSnapshot.mockResolvedValue({ data: 'local history', cols: 80, rows: 24 }) + remote.getBufferSnapshot.mockResolvedValue({ data: 'remote history', cols: 100, rows: 30 }) + await registerHeadlessPtyRuntime(runtime) + const controller = setController.mock.calls[0]?.[0] + if (!controller) { + throw new Error('missing runtime PTY controller') + } + + expect(controller.write('daemon-pty', 'local input', 'driving')).toBe(true) + expect(controller.write(remoteId, 'remote input', 'driving')).toBe(true) + await controller.clearBuffer?.('daemon-pty') + await controller.clearBuffer?.(remoteId) + await expect(controller.attach?.('daemon-pty')).resolves.toBe(true) + await expect(controller.attach?.(remoteId)).resolves.toBe(false) + await expect(controller.serializeProviderBuffer?.('daemon-pty')).resolves.toMatchObject({ + data: 'local history' + }) + await expect(controller.serializeProviderBuffer?.(remoteId)).resolves.toMatchObject({ + data: 'remote history' + }) + expect(local.write).toHaveBeenCalledExactlyOnceWith('daemon-pty', 'local input') + expect(remote.write).toHaveBeenCalledExactlyOnceWith(remoteId, 'remote input') + expect(localClear).toHaveBeenCalledExactlyOnceWith('daemon-pty') + expect(remoteClear).toHaveBeenCalledExactlyOnceWith(remoteId) + expect(attach).toHaveBeenCalledExactlyOnceWith('daemon-pty') + + unregisterSshPtyProvider('ssh-a') + expect(controller.write(remoteId, 'disconnected input', 'driving')).toBe(false) + await expect(controller.probePtyLiveness?.(remoteId)).resolves.toBeNull() + expect(local.write).toHaveBeenCalledTimes(1) + }) + + it('rejects renderer input when no renderer owns the registration', async () => { + const daemon = installObservableDaemonTestProvider() + const runtime = new OrcaRuntimeService() + setPtyOwnership('daemon-pty', null) + await registerHeadlessPtyRuntime(runtime) + + getPtyWriteListener()(mainWindowIpcEvent, { id: 'daemon-pty', data: 'untrusted' }) + expect( + handlers.get('pty:writeAccepted')?.(mainWindowIpcEvent, { + id: 'daemon-pty', + data: 'untrusted' + }) + ).toBe(false) + expect(daemon.write).not.toHaveBeenCalled() + expect(mainWindow.webContents.send).not.toHaveBeenCalled() + }) + + it('detaches desktop lifecycle listeners and restores delivery after headless re-registration', async () => { + vi.useFakeTimers() + const daemon = installObservableDaemonTestProvider() + const provider = getLocalPtyProvider() + const subscribe = vi.mocked(provider.onData).getMockImplementation() + if (!subscribe) { + throw new Error('missing daemon data subscription') + } + const unsubscribe = vi.fn() + const onDataSubscribe = vi.spyOn(provider, 'onData').mockImplementation((listener) => { + const dispose = subscribe(listener) + return () => { + unsubscribe() + dispose() + } + }) + const runtime = new OrcaRuntimeService() + const onData = vi.spyOn(runtime, 'onPtyData').mockReturnValue(6) + const rendererEvents = new EventEmitter() + mainWindow.webContents.on.mockImplementation((event, listener) => + rendererEvents.on(event, listener) + ) + mainWindow.webContents.removeListener.mockImplementation((event, listener) => + rendererEvents.removeListener(event, listener) + ) + const renderer = { + ...mainWindow, + webContents: Object.assign(mainWindow.webContents, { id: 1 }) + } + registerPtyHandlers(renderer, runtime) + expect(rendererEvents.listenerCount('did-finish-load')).toBe(1) + expect(rendererEvents.listenerCount('render-process-gone')).toBe(2) + + await registerHeadlessPtyRuntime(runtime) + expect(rendererEvents.eventNames()).toEqual([]) + expect(onDataSubscribe).toHaveBeenCalledTimes(2) + expect(unsubscribe).toHaveBeenCalledOnce() + mainWindow.webContents.send.mockClear() + daemon.emitData('daemon-pty', 'output') + vi.advanceTimersByTime(20) + expect(onData).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).not.toHaveBeenCalled() + + registerPtyHandlers(renderer, runtime) + expect(unsubscribe).toHaveBeenCalledTimes(2) + const ready = onMock.mock.calls.findLast( + ([channel]) => channel === 'pty:rendererDispatcherReady' + )?.[1] + ready(mainWindowIpcEvent) + daemon.emitData('daemon-pty', 'output') + vi.advanceTimersByTime(20) + expect(onData).toHaveBeenCalledTimes(2) + expect(rendererEvents.listenerCount('did-finish-load')).toBe(1) + expect(rendererEvents.listenerCount('render-process-gone')).toBe(2) + expect(mainWindow.webContents.send).toHaveBeenCalledWith( + 'pty:data', + expect.objectContaining({ id: 'daemon-pty', data: 'output' }) + ) + }) +}) diff --git a/src/main/ipc/pty/runtime/controller-deps.ts b/src/main/ipc/pty/runtime/controller-deps.ts index db55b637e57..a2dcc21bada 100644 --- a/src/main/ipc/pty/runtime/controller-deps.ts +++ b/src/main/ipc/pty/runtime/controller-deps.ts @@ -1,4 +1,4 @@ -import type { BrowserWindow } from 'electron' +import type { PtyRendererDelivery } from '../session' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' import type { Store } from '../../../persistence' import type { IPtyProvider } from '../../../providers/types' @@ -31,7 +31,6 @@ export type PtyRuntimeControllerDeps = { providerSession?: AgentProviderSessionMetadata target: CodexAccountSelectionTarget launchEnv?: NodeJS.ProcessEnv - workspacePath?: string }) => PreparedCodexResumeHome | null resolveCodexResumeLaunch: ( command: string | undefined, @@ -82,8 +81,7 @@ export type PtyRuntimeControllerDeps = { } trustedTerminalHandleEnv: Set retiredRejectedPtyIds: Map - reversibleStopOwnersByPtyId: Map - mainWindow: BrowserWindow + mainWindow?: PtyRendererDelivery transitionSpawnHiddenRendererPtyDeliveryState?: (id: string, hidden: boolean) => void syncPtyBackgroundedDelivery?: (id: string, caller: string) => void } diff --git a/src/main/ipc/pty/runtime/controller.ts b/src/main/ipc/pty/runtime/controller.ts index 8512bc7c16b..4013e06e37f 100644 --- a/src/main/ipc/pty/runtime/controller.ts +++ b/src/main/ipc/pty/runtime/controller.ts @@ -4,13 +4,13 @@ import type { PtyRuntimeControllerDeps } from './controller-deps' import { spawnPtyFromRuntimeController } from './spawn' import { killPtyFromRuntimeController, - markReversibleStopsFromRuntimeController, retireRejectedPtyFromRuntimeController, stopAndWaitPtyFromRuntimeController } from './kill' import { attachPtyFromRuntimeController, clearBufferFromRuntimeController, + resetInputModesFromRuntimeController, confirmForegroundProcessFromRuntimeController, confirmShellForegroundFromRuntimeController, getCwdFromRuntimeController, @@ -27,6 +27,7 @@ import { waitForRendererSerializerFromRuntimeController, writePtyFromRuntimeController } from './operations' +import { recordUnconfirmedExplicitSshStop } from './undelivered-ssh-kill' import { supportsForegroundProcessEvidenceFromRuntimeController } from './foreground-process-evidence-capability' import { listProcessesFromRuntimeController, @@ -44,9 +45,9 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi }, adoptStablePane, spawn: async (args) => spawnPtyFromRuntimeController(deps, args), - write: (ptyId, data) => writePtyFromRuntimeController(ptyId, data), - writeWithSettlement: (ptyId, data) => - writePtyFromRuntimeController(ptyId, data, { waitForSettlement: true }), + write: (ptyId, data, inputKind) => writePtyFromRuntimeController(deps, ptyId, data, inputKind), + writeWithSettlement: (ptyId, data, inputKind) => + writePtyFromRuntimeController(deps, ptyId, data, inputKind, { waitForSettlement: true }), probePtyLiveness: (ptyId) => probePtyLivenessFromRuntimeController(deps, ptyId), // Why: subscriber-driven ingestion for daemon sessions no renderer pane // ever attached. Local daemon sessions only — SSH panes have their own @@ -56,8 +57,13 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi kill: (ptyId) => killPtyFromRuntimeController(deps, ptyId), retireRejectedPty: (ptyId, stopConfirmed) => retireRejectedPtyFromRuntimeController(deps, ptyId, stopConfirmed), - markReversibleStops: (ptyIds) => markReversibleStopsFromRuntimeController(deps, ptyIds), stopAndWait: (ptyId, opts) => stopAndWaitPtyFromRuntimeController(deps, ptyId, opts), + recordUnconfirmedStop: (ptyId) => + recordUnconfirmedExplicitSshStop({ + store: deps.store, + ptyId, + reversible: runtime?.intentionalPtyStops?.isReversibleStopInFlight(ptyId) ?? false + }), getForegroundProcess: (ptyId) => getForegroundProcessFromRuntimeController(ptyId), inspectProcess: (ptyId, options) => inspectProcessFromRuntimeController(ptyId, options), confirmForegroundProcess: (ptyId) => confirmForegroundProcessFromRuntimeController(ptyId), @@ -65,6 +71,7 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi getCwd: (ptyId) => getCwdFromRuntimeController(ptyId), hasChildProcesses: (ptyId) => hasChildProcessesFromRuntimeController(ptyId), clearBuffer: (ptyId) => clearBufferFromRuntimeController(deps, ptyId), + resetInputModes: (ptyId) => resetInputModesFromRuntimeController(deps, ptyId), hasPty: (ptyId) => hasPtyFromRuntimeController(deps, ptyId), listProcesses: (connectionId, opts) => listProcessesFromRuntimeController(deps, connectionId, opts), diff --git a/src/main/ipc/pty/runtime/kill.ts b/src/main/ipc/pty/runtime/kill.ts index 98bdc1e97ae..edbd12b0b18 100644 --- a/src/main/ipc/pty/runtime/kill.ts +++ b/src/main/ipc/pty/runtime/kill.ts @@ -19,8 +19,7 @@ export function killPtyFromRuntimeController( rememberSyntheticKillExit, sendPtyExitToRenderer, finishPtyShutdown, - retiredRejectedPtyIds, - reversibleStopOwnersByPtyId + retiredRejectedPtyIds } = deps runtime?.markPtyStopRequested?.(ptyId) let connectionId: string | null | undefined = ptyOwnership.get(ptyId) @@ -31,7 +30,7 @@ export function killPtyFromRuntimeController( store, ptyId, connectionId, - reversible: reversibleStopOwnersByPtyId.has(ptyId), + reversible: runtime?.intentionalPtyStops?.isReversibleStopInFlight(ptyId) ?? false, incarnationId }) } @@ -184,31 +183,6 @@ export function retireRejectedPtyFromRuntimeController( }) } -export function markReversibleStopsFromRuntimeController( - deps: PtyRuntimeControllerDeps, - ptyIds: readonly string[] -): () => void { - const { reversibleStopOwnersByPtyId } = deps - for (const ptyId of ptyIds) { - reversibleStopOwnersByPtyId.set(ptyId, (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) + 1) - } - let released = false - return () => { - if (released) { - return - } - released = true - for (const ptyId of ptyIds) { - const owners = (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) - 1 - if (owners > 0) { - reversibleStopOwnersByPtyId.set(ptyId, owners) - } else { - reversibleStopOwnersByPtyId.delete(ptyId) - } - } - } -} - /** * Deliberately records no undelivered-stop intent, unlike `killPtyFromRuntimeController`. * diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts index 703fa009b7d..c555c0b3102 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -12,16 +12,28 @@ import { writeUnverifiable, type WriteSettlement } from '../../../../shared/pty-write-settlement' +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' + +type RuntimeWriteDeps = Pick -export function writePtyFromRuntimeController(ptyId: string, data: string): boolean export function writePtyFromRuntimeController( + deps: RuntimeWriteDeps, ptyId: string, data: string, + inputKind: TerminalInputKind +): boolean +export function writePtyFromRuntimeController( + deps: RuntimeWriteDeps, + ptyId: string, + data: string, + inputKind: TerminalInputKind, options: { waitForSettlement: true } ): WriteSettlement | Promise export function writePtyFromRuntimeController( + deps: RuntimeWriteDeps, ptyId: string, data: string, + inputKind: TerminalInputKind, options?: { waitForSettlement: true } ): boolean | WriteSettlement | Promise { let provider: IPtyProvider @@ -36,6 +48,7 @@ export function writePtyFromRuntimeController( if (!provider.writeWithSettlement) { return writeRefused('provider_cannot_settle') } + deps.runtime?.terminalRunFacts?.recordInput(ptyId, inputKind, data) try { return provider.writeWithSettlement(ptyId, data) } catch { @@ -43,6 +56,7 @@ export function writePtyFromRuntimeController( return writeUnverifiable('provider_threw_after_handoff', true) } } + deps.runtime?.terminalRunFacts?.recordInput(ptyId, inputKind, data) try { return provider.write(ptyId, data) !== false } catch { @@ -169,7 +183,9 @@ export async function clearBufferFromRuntimeController( ptyId: string ): Promise { // Why: desktop xterm and daemon/SSH providers hold separate buffers; clear both so mobile resubscribe can't resurrect cleared history. - deps.mainWindow.webContents.send('pty:clearBuffer:request', { ptyId }) + if (deps.mainWindow && !deps.mainWindow.isDestroyed()) { + deps.mainWindow.webContents.send('pty:clearBuffer:request', { ptyId }) + } try { await getProviderForPty(ptyId).clearBuffer(ptyId) } catch { @@ -177,6 +193,21 @@ export async function clearBufferFromRuntimeController( } } +export async function resetInputModesFromRuntimeController( + deps: PtyRuntimeControllerDeps, + ptyId: string +): Promise { + // Why: a remote client's reset must also ground this host window's view of the pane. + if (deps.mainWindow && !deps.mainWindow.isDestroyed()) { + deps.mainWindow.webContents.send('pty:resetInputModes:request', { ptyId }) + } + try { + await getProviderForPty(ptyId).resetInputModes(ptyId) + } catch { + /* best effort: an older daemon or relay rejects the request */ + } +} + const settledLocalPtyProviderStartups = new WeakSet>() const watchedLocalPtyProviderStartups = new WeakSet>() diff --git a/src/main/ipc/pty/runtime/spawn-commit-run-facts.test.ts b/src/main/ipc/pty/runtime/spawn-commit-run-facts.test.ts new file mode 100644 index 00000000000..5701b9f82c9 --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-commit-run-facts.test.ts @@ -0,0 +1,147 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeWithRuntimeId } from '../../../runtime/orca-runtime-runtime-id' +import { TerminalIntentionalStops } from '../../../runtime/terminal-intentional-stops' +import { TerminalRunFactsRegister } from '../../../runtime/terminal-run-facts' +import { ptySizes } from '../delivery/visibility-state' +import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' +import { commitRuntimePtySpawn } from './spawn-commit' +import { createRuntimePtySpawnState, type RuntimePtySpawnArgs } from './spawn-state' +import type { PtyRuntimeControllerDeps } from './controller-deps' + +const PTY_ID = 'orca-pty-run-facts' +const INCARNATION_ID = 'inc-run-facts' + +const ADOPTED = { + disposition: 'adopted', + owner: { + claim: { kind: 'terminal' }, + generation: 'g1', + phase: 'live', + ptyId: PTY_ID, + surface: { worktreeId: 'wt-1', tabId: 'tab-1', leafId: 'leaf-1', terminalHandle: 'h1' } + } +} + +async function commit( + result: Record, + facts = new TerminalRunFactsRegister(), + intentionalPtyStops = new TerminalIntentionalStops(), + prepare?: (ctx: ReturnType) => void +) { + const runtime = { + terminalRunFacts: facts, + intentionalPtyStops, + // Why the real method: the case under test is what the runtime does with each commit. + noteTerminalSpawnCommit: OrcaRuntimeWithRuntimeId.prototype.noteTerminalSpawnCommit, + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + cancelPendingPtyRegistration: vi.fn(), + reflowHeadlessTerminalToPtyGrid: vi.fn(), + seedHeadlessTerminal: vi.fn(), + noteTerminalSpawnCommand: vi.fn() + } + const ports = { runtime, store: undefined, options: {}, sendPtySpawnedToRenderer: vi.fn() } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the commit reads only the ports above; store-less deps skip every persistence branch. + const deps = ports as unknown as PtyRuntimeControllerDeps + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a worktree-less spawn needs only its grid. + const args = { cols: 120, rows: 40 } as unknown as RuntimePtySpawnArgs + const ctx = createRuntimePtySpawnState(deps, args) + const spawned = { id: PTY_ID, incarnationId: INCARNATION_ID, ...result } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each case sets the spawn-result fields the commit reads. + ctx.result = spawned as unknown as typeof ctx.result + prepare?.(ctx) + await commitRuntimePtySpawn(ctx) + return facts.read(PTY_ID, INCARNATION_ID) +} + +describe('runtime spawn commit: run facts', () => { + afterEach(() => { + ptySizes.delete(PTY_ID) + ptyOwnership.delete(PTY_ID) + ptyIncarnationById.delete(PTY_ID) + }) + + it('records a new process as a fresh spawn', async () => { + expect((await commit({})).freshSpawn).toBe(true) + }) + + it('never reads an SSH adoption that omits isReattach as fresh', async () => { + expect((await commit({ agentSessionEnsure: ADOPTED })).freshSpawn).toBe(false) + }) + + it('never reads a cold restore as fresh', async () => { + const coldRestore = { scrollback: 'prior output', cwd: '/tmp' } + + expect((await commit({ coldRestore })).freshSpawn).toBe(false) + }) + + it('keeps a process run facts when the same incarnation commits again', async () => { + const facts = new TerminalRunFactsRegister() + await commit({}, facts) + facts.recordInput(PTY_ID, 'driving', 'ls\r', 100) + + expect(await commit({ isReattach: true }, facts)).toEqual({ + freshSpawn: true, + firstUserInputAt: 100 + }) + }) + + it.each([ + { spawn: 'new process', result: {} }, + { spawn: 'adoption', result: { agentSessionEnsure: ADOPTED } } + ])('lets a committed $spawn supersede a landed stop that no exit pinned', async ({ result }) => { + const stops = new TerminalIntentionalStops() + stops.mark(PTY_ID, 'reversible', null)(true) + + await commit(result, new TerminalRunFactsRegister(), stops) + + expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([]) + }) + + it('records nothing for a spawn discarded because its binding save failed', async () => { + const facts = new TerminalRunFactsRegister() + const stops = new TerminalIntentionalStops() + stops.mark(PTY_ID, 'reversible', null)(true) + const persistPtyBinding = vi.fn().mockRejectedValue(new Error('disk full')) + + await expect( + commit({}, facts, stops, (ctx) => { + ctx.provider = { ...ctx.provider, shutdown: vi.fn().mockResolvedValue(undefined) } + ctx.hostSessionBinding = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the commit calls only persistPtyBinding on this store. + store: { persistPtyBinding } as unknown as NonNullable< + typeof ctx.hostSessionBinding + >['store'], + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'leaf-1' + } + }) + ).rejects.toThrow() + + expect(persistPtyBinding).toHaveBeenCalledOnce() + expect(facts.read(PTY_ID, INCARNATION_ID).freshSpawn).toBe(false) + expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual(['reversible']) + }) + + it.each([ + { spawn: 'new process', result: {} }, + { spawn: 'adoption', result: { agentSessionEnsure: ADOPTED } } + ])('records nothing for a $spawn that exited during start', async ({ result }) => { + const facts = new TerminalRunFactsRegister() + const stops = new TerminalIntentionalStops() + stops.mark(PTY_ID, 'reversible', null)(true) + + await expect( + commit(result, facts, stops, (ctx) => { + ctx.args.worktreeId = 'wt-1' + ctx.deps.runtime!.registerPty = vi.fn(() => { + throw new Error('agent_session_exited_during_start') + }) + }) + ).rejects.toThrow('agent_session_exited_during_start') + + expect(facts.read(PTY_ID, INCARNATION_ID).freshSpawn).toBe(false) + expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual(['reversible']) + }) +}) diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 09d41460263..f326200cabc 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -1,5 +1,5 @@ import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' -import { ptyOwnership, ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state' +import { ptyOwnership, ptyIncarnationById } from '../provider/ownership-state' import { ptySizes } from '../delivery/visibility-state' import { commitRuntimePtySize } from './spawn-commit-pty-size' import { @@ -16,13 +16,8 @@ import { rendererSerializerReadiness } from '../pane/serializer-state' import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners' -import { track } from '../../../telemetry/client' -import { getCohortAtEmit } from '../../../telemetry/cohort-classifier' -import { - agentKindSchema, - launchSourceSchema, - requestKindSchema -} from '../../../../shared/telemetry-events' +import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore' +import { recordPtySpawnTelemetry } from '../pane/spawn-telemetry' import { persistAdmittedStablePaneBinding } from '../pane/stable-owner' import { claimSshPaneLease } from '../pane/ssh-pane-lease-claim' import { @@ -31,17 +26,34 @@ import { } from '../../../runtime/terminal-model-query-authority' import { toSshExecutionHostId } from '../../../../shared/execution-host' import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure' -import { clearProviderPtyState } from '../provider/state-cleanup' import { resolvePaneSpawnReservation } from '../pane/spawn-reservation' import { admitProviderReattachLaunchIdentity } from '../pane/launch-authority' import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span' import type { RuntimePtySpawnState } from './spawn-state' +import { + admitPtyReattachOwnership, + discardUnpersistedPtySpawn, + registerPersistedPtySpawn +} from '../pane/spawn-registration' export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { const args = ctx.args + admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId) const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity(ctx.result) + if ( + isNativeWindowsLocalPtySpawn({ + connectionId: args.connectionId, + cwd: args.cwd, + shellOverride: ctx.daemonShellOverride + }) + ) { + markNativeWindowsConptyPty(ctx.result.id) + } + // Seed before the first disk await so live output appends to the restored history. + seedHeadlessTerminalFromSpawnResult(ctx.deps.runtime, ctx.result, ctx.spawnIdentityPaneKey) + seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result) try { - ctx.stablePaneBindingPersisted = persistAdmittedStablePaneBinding({ + ctx.stablePaneBindingPersisted = await persistAdmittedStablePaneBinding({ store: ctx.hostSessionBinding?.store, owner: ctx.stablePaneOwner, result: ctx.result, @@ -54,7 +66,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { throw error } console.error('[pty] failed to persist runtime PTY binding after attach:', error) - throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { + throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage(error)), { agentSessionOperationOutcome: 'unknown' as const }) } @@ -63,12 +75,9 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { // reply omits isReattach; derive it once so the size commit and the reservation agree. const adoptedResult = { ...ctx.result, isReattach: true } const owner = ctx.result.agentSessionEnsure.owner - ptyOwnership.set(ctx.result.id, args.connectionId ?? ptyOwnership.get(ctx.result.id) ?? null) - ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, owner.surface.terminalHandle) - if (ctx.result.incarnationId) { - ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) - } - ctx.deps.runtime?.registerPty( + const rejectedRegistration = registerPersistedPtySpawn( + ctx.deps.runtime, + ctx.hostSessionBinding?.store ?? ctx.deps.store, ctx.result.id, owner.surface.worktreeId, args.connectionId ?? null, @@ -80,6 +89,19 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { ...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {}) } ) + if (rejectedRegistration) { + await rejectedRegistration + } + // Why here: an adoption returns before the commit site below. + ctx.deps.runtime?.noteTerminalSpawnCommit?.( + ctx.result, + ctx.hostSessionBinding?.expectedSourceBinding + ) + ptyOwnership.set(ctx.result.id, args.connectionId ?? ptyOwnership.get(ctx.result.id) ?? null) + ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, owner.surface.terminalHandle) + if (ctx.result.incarnationId) { + ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) + } if (!args.connectionId) { ctx.deps.options?.onCodexHomePtySpawned?.({ id: ctx.result.id, @@ -108,95 +130,41 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { agentSessionEnsure: ctx.result.agentSessionEnsure } } - ptyOwnership.set(ctx.result.id, args.connectionId ?? null) - if (ctx.result.incarnationId) { - ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) - } - // Why: record the native-Windows-local-PTY determination before any byte reaches the emulator, so its ConPTY DA1 override exists from byte zero. - if ( - isNativeWindowsLocalPtySpawn({ - connectionId: args.connectionId, - cwd: args.cwd, - shellOverride: ctx.daemonShellOverride - }) - ) { - markNativeWindowsConptyPty(ctx.result.id) - } - const persistSshLease = (): void => - claimSshPaneLease({ - store: ctx.deps.store, - connectionId: args.connectionId, - ptyId: ctx.result.id, - worktreeId: args.worktreeId, - tabId: args.tabId, - leafId: args.leafId - }) - if (!ctx.hostSessionBinding) { - persistSshLease() - } - commitRuntimePtySize(ctx, ctx.result) - if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) { - ptySizes.delete(ctx.effectiveSessionAppId) - } - recordCodexPaneAccountForSpawn({ - ptyId: ctx.result.id, - isDaemonHostSpawn: ctx.isDaemonHostSpawn, - isReattach: ctx.result.isReattach === true, - pinnedByResume: ctx.codexResumeHomeSelected, - launchCodexHomePath: ctx.selectedCodexHomePath, - launchEnv: args.env, - target: ctx.codexSelectionTarget, - settings: ctx.deps.getSettings?.() - }) if (ctx.hostSessionBinding && !ctx.stablePaneBindingPersisted) { try { + const { store, worktreeId, tabId, leafId, expectedSourceBinding } = ctx.hostSessionBinding const binding = { - worktreeId: ctx.hostSessionBinding.worktreeId, - tabId: ctx.hostSessionBinding.tabId, - leafId: ctx.hostSessionBinding.leafId, + worktreeId, + tabId, + leafId, ptyId: ctx.result.id, hostAdmittedMembership: true, ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), ...(ctx.cwd ? { startupCwd: ctx.cwd } : {}), - ...(ctx.hostSessionBinding.expectedSourceBinding - ? { expectedSourceBinding: ctx.hostSessionBinding.expectedSourceBinding } - : {}), - origin: spawnCommitBindingOrigin(ctx.result, ctx.hostSessionBinding.expectedSourceBinding) + ...(expectedSourceBinding ? { expectedSourceBinding } : {}), + origin: spawnCommitBindingOrigin(ctx.result, expectedSourceBinding) } const persisted = args.connectionId - ? ctx.hostSessionBinding.store.persistPtyBinding( - binding, - toSshExecutionHostId(args.connectionId) - ) - : ctx.hostSessionBinding.store.persistPtyBinding(binding) + ? await store.persistPtyBinding(binding, toSshExecutionHostId(args.connectionId)) + : await store.persistPtyBinding(binding) if (persisted === false) { throw new Error('terminal_split_source_not_found') } } catch (err) { console.error('[pty] failed to persist runtime PTY binding after spawn:', err) - if (!ctx.result.isReattach) { - deletePtyOwnership(ctx.result.id) - try { - await ctx.provider.shutdown(ctx.result.id, { immediate: true }) - } catch (shutdownErr) { - console.warn('[pty] failed to clean up PTY after persistence failure:', shutdownErr) - } - clearProviderPtyState(ctx.result.id) - } + await discardUnpersistedPtySpawn(ctx.provider, ctx.result) if (err instanceof Error && err.message === 'terminal_split_source_not_found') { throw err } - throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { + throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage(err)), { agentSessionOperationOutcome: 'unknown' as const }) } - persistSshLease() - } - if (args.preAllocatedHandle && !ctx.stablePaneOwner?.handle) { - ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, args.preAllocatedHandle) } if (args.worktreeId) { - ctx.deps.runtime?.registerPty( + const rejectedRegistration = registerPersistedPtySpawn( + ctx.deps.runtime, + ctx.hostSessionBinding?.store ?? ctx.deps.store, ctx.result.id, args.worktreeId, args.connectionId ?? null, @@ -217,10 +185,48 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { ? shouldSkipCodexHomeEnvForWindowsShell(ctx.daemonShellOverride, ctx.cwd) : undefined ) + if (rejectedRegistration) { + await rejectedRegistration + } } else { // Why: non-worktree PTYs have no later surface-registration phase to clear admission intent. ctx.deps.runtime?.cancelPendingPtyRegistration?.(ctx.result.id, ctx.result.incarnationId) } + // Why after registration: a spawn discarded for a failed save or rejected for exiting during + // start must not record facts or end a stop. + ctx.deps.runtime?.noteTerminalSpawnCommit?.( + ctx.result, + ctx.hostSessionBinding?.expectedSourceBinding + ) + if (args.preAllocatedHandle && !ctx.stablePaneOwner?.handle) { + ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, args.preAllocatedHandle) + } + ptyOwnership.set(ctx.result.id, args.connectionId ?? null) + if (ctx.result.incarnationId) { + ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) + } + claimSshPaneLease({ + store: ctx.deps.store, + connectionId: args.connectionId, + ptyId: ctx.result.id, + worktreeId: args.worktreeId, + tabId: args.tabId, + leafId: args.leafId + }) + commitRuntimePtySize(ctx, ctx.result) + if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) { + ptySizes.delete(ctx.effectiveSessionAppId) + } + recordCodexPaneAccountForSpawn({ + ptyId: ctx.result.id, + isDaemonHostSpawn: ctx.isDaemonHostSpawn, + isReattach: ctx.result.isReattach === true, + pinnedByResume: ctx.codexResumeHomeSelected, + launchCodexHomePath: ctx.selectedCodexHomePath, + launchEnv: args.env, + target: ctx.codexSelectionTarget, + settings: ctx.deps.getSettings?.() + }) // Why: runtime-controller creates (headless serve, CLI, splits) adopt surviving daemon sessions too; without this seed their records stay blank. seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result) // Why: arms main's per-PTY Command Code output detector from the launch command (renderer startupCommand parity). @@ -231,17 +237,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { markClaudePtySpawned(ctx.result.id) } if (args.telemetry && !ctx.stablePaneOwner) { - const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind) - const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source) - const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind) - if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) { - track('agent_started', { - agent_kind: agentKindParse.data, - launch_source: launchSourceParse.data, - request_kind: requestKindParse.data, - ...getCohortAtEmit() - }) - } + recordPtySpawnTelemetry(args.telemetry) } // Why: runtime-owned CLI PTYs bypass the renderer pty:spawn handler; record paneKey here too since hook titles and cache cleanup need this reverse lookup. const paneKey = rememberPaneKeyForPty(ctx.result.id, ctx.env?.ORCA_PANE_KEY) diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 67c633560e9..3b50025a556 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -1,3 +1,5 @@ +import { getAppEnvironment } from '../../../../shared/app-environment' +import { getLegacyOpenCodeEnvKeysToDelete } from '../../../opencode/legacy-shared-config-dir' import type { IPtyProvider, PtySpawnResult } from '../../../providers/types' import { LocalPtyProvider } from '../../../providers/local-pty-provider' import { makePaneKey, isTerminalLeafId } from '../../../../shared/stable-pane-id' @@ -9,7 +11,7 @@ import { mergePtyEnvDeletions, removeCodexHomeDeletionRequests, getInheritedAgentHookEnvKeysToDelete, - getInheritedClaudeSessionStampEnvKeysToDelete + getInheritedAgentSessionStampEnvKeysToDelete } from '../host-env/pi-agent' import { promoteAgentTeamsShimPath, deleteRequestedEnvKeys } from '../host-env/path' import { @@ -29,6 +31,7 @@ import { paneSpawnReservationsByOwnerKey } from '../pane/spawn-reservation' import type { RuntimePtySpawnState } from './spawn-state' +import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn' /** Headless spawns need the same host-side environment isolation as desktop spawns. */ export async function buildRuntimePtySpawnOptions( @@ -72,8 +75,12 @@ export async function buildRuntimePtySpawnOptions( // Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH. ctx.isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [], ctx.isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(ctx.env) : [], + // The daemon must judge its own inherited value; main may have a different config. + !args.connectionId && !ctx.isDaemonHostSpawn + ? getLegacyOpenCodeEnvKeysToDelete(ctx.env, getAppEnvironment().getPath('userData')) + : [], // Why: ungated, unlike the agent-hook keys — the local provider and the relay host also spread their own process.env into every spawn. - getInheritedClaudeSessionStampEnvKeysToDelete(ctx.env) + getInheritedAgentSessionStampEnvKeysToDelete(ctx.env) ) if (ctx.skipCodexHomeEnv) { ctx.spawnOptions.envToDelete = mergePtyEnvDeletions( @@ -107,6 +114,22 @@ export async function buildRuntimePtySpawnOptions( if (args.worktreeId !== undefined) { ctx.spawnOptions.worktreeId = args.worktreeId } + const trustWrite = applyAgentWorkspaceTrustToSpawn({ + launchAgent: args.launchAgent, + worktreeId: args.worktreeId, + cwd: ctx.cwd, + store: ctx.deps.store, + isFreshLaunch: !ctx.preAdoptedStablePane && ctx.launchCommand !== undefined, + settings: ctx.deps.getSettings?.(), + env: ctx.env, + claudeAuth: ctx.claudeAuth, + wslDistro: ctx.expectedWslDistro, + connectionId: args.connectionId ?? null, + spawnOptions: ctx.spawnOptions + }) + if (trustWrite) { + await trustWrite + } ctx.hadSessionSizeBeforeAttach = ctx.effectiveSessionAppId !== undefined ? ptySizes.has(ctx.effectiveSessionAppId) : false ctx.sessionSizeBeforeAttach = diff --git a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts index 46f16081efa..a299327fc3f 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts @@ -43,7 +43,6 @@ function makeDeps(): PtyRuntimeControllerDeps { finishPtyShutdown, trustedTerminalHandleEnv: new Set(), retiredRejectedPtyIds: new Map(), - reversibleStopOwnersByPtyId: new Map(), // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only `operations.ts` (write/clearBuffer) reads `mainWindow`; the spawn preflight and option build never touch it, and a real BrowserWindow cannot exist in vitest. mainWindow: {} as BrowserWindow } diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 2bd10feaab6..484f9166e4b 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -12,7 +12,6 @@ import { resolveCodexHomeAfterManagedAuthReadiness, shouldSkipCodexHomeEnvForWindowsShell, shouldStripInheritedOrcaCodexHome, - isCodexStatusHooksEnabled, codexHomePathsEqual } from '../host-env/codex-home' import { promoteAgentTeamsShimPath } from '../host-env/path' @@ -137,8 +136,7 @@ export async function prepareRuntimePtySpawn( launchAgent: args.launchAgent, providerSession: args.resumeProviderSession, target: ctx.codexSelectionTarget, - launchEnv: args.env, - workspacePath: ctx.cwd + launchEnv: args.env }) const codexResumeLaunch = codexResumePreparation ? await ctx.deps.resolveCodexResumeLaunch(args.command, codexResumePreparation) @@ -188,10 +186,7 @@ export async function prepareRuntimePtySpawn( ctx.env = { ...ctx.env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle } } const selectLaunchCodexHome = async (): Promise => - (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, { - workspacePath: ctx.cwd, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined - })) ?? null + (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env)) ?? null ctx.selectedCodexHomePath = !ctx.preAdoptedStablePane && !args.connectionId ? getCompatibleSelectedCodexHomePath( @@ -219,17 +214,12 @@ export async function prepareRuntimePtySpawn( resolveCurrent: async () => getCompatibleSelectedCodexHomePath( ctx.codexSelectionTarget, - (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, { - workspacePath: ctx.cwd, - launchAgent: 'codex' - })) ?? null + (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env)) ?? null ), resolveAfterUnavailable: async (unavailableManagedHomePath) => getCompatibleSelectedCodexHomePath( ctx.codexSelectionTarget, (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, { - workspacePath: ctx.cwd, - launchAgent: 'codex', unavailableManagedHomePath })) ?? null ) @@ -286,7 +276,6 @@ export async function prepareRuntimePtySpawn( wslDistro: ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null, agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), disabledTuiAgents: ptySettings?.disabledTuiAgents, - codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings), networkProxySettings: ptySettings, routeBrowserOpensToClient: ctx.deps.runtime?.shouldRelayTerminalBrowserOpens?.(), deferGitConfigGuardToDaemon: diff --git a/src/main/ipc/pty/runtime/undelivered-ssh-kill.ts b/src/main/ipc/pty/runtime/undelivered-ssh-kill.ts index e41b1629097..45b80ea1f9e 100644 --- a/src/main/ipc/pty/runtime/undelivered-ssh-kill.ts +++ b/src/main/ipc/pty/runtime/undelivered-ssh-kill.ts @@ -1,6 +1,8 @@ import type { Store } from '../../../persistence' -import { ptyIncarnationById } from '../provider/ownership-state' +import { parseAppSshPtyId } from '../../../providers/ssh-pty-id' +import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' import { getRelayPtyId } from '../provider/registry' +import { isEpochScopedRelayPtyId } from '../../../../shared/ssh-pending-pty-kill' export type UndeliveredSshPtyKill = { store: Store | undefined @@ -25,29 +27,43 @@ export type UndeliveredSshPtyKill = { * - **reversible**: see above. * - **no `connectionId`**: a local PTY's owner is this process, so a failed kill has no later host * to ask; there is nothing to replay against. - * - **no incarnation**: the replay fence is the host-minted PTY incarnation, and a relay renumbers - * from `pty-1` on every start. An order we could never safely aim can only be discarded later, - * or worse, guessed at. + * - **no incarnation on a legacy id**: a legacy relay renumbers from `pty-1` on every start, so + * without the host-minted incarnation the order could never be safely aimed. A `pty2:` id is + * epoch-scoped and names one process, so it is recorded without one. * - **an id naming another connection**: `getRelayPtyId` throws on those, and this runs inside * promise `.catch` handlers where that would surface as an unhandled rejection. */ -export function recordUndeliveredSshPtyKill(args: UndeliveredSshPtyKill): void { +export function recordUndeliveredSshPtyKill(args: UndeliveredSshPtyKill): boolean { const { store, ptyId, connectionId } = args if (!store || !connectionId || args.reversible) { - return - } - const incarnationId = args.incarnationId ?? ptyIncarnationById.get(ptyId) - if (!incarnationId) { - return + return false } let relayPtyId: string try { relayPtyId = getRelayPtyId(connectionId, ptyId) } catch { - return + return false + } + const incarnationId = args.incarnationId ?? ptyIncarnationById.get(ptyId) + if (!incarnationId && !isEpochScopedRelayPtyId(relayPtyId)) { + return false } store.recordSshRemotePtyKillIntent(connectionId, relayPtyId, { requestedAt: args.now ?? Date.now(), - incarnationId, + ...(incarnationId ? { incarnationId } : {}), attempts: 0 }) + return true +} + +/** Records the replay order for an explicit close whose stop went unconfirmed, before its follow-up + * kill is sent, so the close receipt can promise the retry only when an order really exists. */ +export function recordUnconfirmedExplicitSshStop(args: { + store: Store | undefined + ptyId: string + reversible: boolean +}): boolean { + return recordUndeliveredSshPtyKill({ + ...args, + connectionId: ptyOwnership.get(args.ptyId) ?? parseAppSshPtyId(args.ptyId)?.connectionId + }) } diff --git a/src/main/ipc/pty/session.ts b/src/main/ipc/pty/session.ts index 30c6735cde2..be666fc6248 100644 --- a/src/main/ipc/pty/session.ts +++ b/src/main/ipc/pty/session.ts @@ -1,4 +1,4 @@ -import type { BrowserWindow } from 'electron' +import type { BrowserWindow, WebContents } from 'electron' import type { OrcaRuntimeService } from '../../runtime/orca-runtime' import type { Store } from '../../persistence' import type { GlobalSettings } from '../../../shared/global-settings-types' @@ -10,7 +10,6 @@ import type { PtyRendererDeliveryStateReport } from '../../../shared/pty-renderer-delivery-health' import type { PtyRendererDeliveryDebugSnapshot } from './delivery/debug' -import type { ReplacedPtyStop } from './delivery/exit' import { PtyProducerFlowController } from '../pty-producer-flow-control' import { PtyPendingDataDrainQueue, type PendingPtyData } from '../pty-pending-data-drain-queue' import type { SshPtyOutputIntake } from '../ssh-pty-output-intake' @@ -58,8 +57,15 @@ export type PtyIpcSessionOptions = { onPtyExit?: (id: string, exitSequence: number) => void } +export type PtyRendererDelivery = Pick< + BrowserWindow, + 'isDestroyed' | 'isFocused' | 'isVisible' | 'isMinimized' +> & { + webContents: Pick +} + export type PtyIpcSession = { - mainWindow: BrowserWindow + mainWindow?: PtyRendererDelivery runtime?: OrcaRuntimeService store?: Store getSettings?: () => GlobalSettings @@ -101,8 +107,6 @@ export type PtyIpcSession = { string, { cleanupTimer: NodeJS.Timeout; incarnationId: string | undefined } > - reversibleStopOwnersByPtyId: Map - replacedPtyStopsById: Map retiredRejectedPtyIds: Map pendingSerializeRequests: Map< string, @@ -182,7 +186,7 @@ const unsetSessionFn = (): never => { } export function createPtyIpcSession(args: { - mainWindow: BrowserWindow + mainWindow?: PtyRendererDelivery runtime?: OrcaRuntimeService store?: Store getSettings?: () => GlobalSettings @@ -231,8 +235,6 @@ export function createPtyIpcSession(args: { sourceCreditPendingPtys: new Set(), backgroundedDeliverySyncByPty: new Map(), syntheticKillExitPtyIds: new Map(), - reversibleStopOwnersByPtyId: new Map(), - replacedPtyStopsById: new Map(), retiredRejectedPtyIds: new Map(), pendingSerializeRequests: new Map(), canSendPtyDataToRenderer: unsetSessionFn, diff --git a/src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts b/src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts new file mode 100644 index 00000000000..3a8096c5309 --- /dev/null +++ b/src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts @@ -0,0 +1,176 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ClaudeRuntimeAuthPreparation } from '../../claude-accounts/runtime-auth/runtime-auth-types' +import { getDefaultSettings } from '../../../shared/constants' +import type { TuiAgent } from '../../../shared/tui-agent' + +const applyAgentWorkspaceTrust = vi.hoisted(() => + vi.fn<(preset: string, path: string, context: unknown) => Promise>(async () => ({})) +) +vi.mock('../../agent-workspace-trust', () => ({ applyAgentWorkspaceTrust })) + +import { buildPtyIpcSpawnOptions } from './ipc/spawn-options' +import { createPtyIpcSpawnState, type PtyIpcSpawnState } from './ipc/spawn-state' +import type { AdoptStablePaneResult, PtySpawnIpcDeps } from './ipc/spawn-types' +import { buildRuntimePtySpawnOptions } from './runtime/spawn-options' +import { createRuntimePtySpawnState } from './runtime/spawn-state' +import type { PtyRuntimeControllerDeps } from './runtime/controller-deps' + +type BuildInput = { + worktreeId?: string + cwd?: string + connectionId?: string + launchAgent?: TuiAgent + command?: string + restored?: boolean + claudeAuth?: ClaudeRuntimeAuthPreparation | null + wslDistro?: string | null +} + +const RESTORED_PANE: AdoptStablePaneResult = { + result: { id: 'pty-restored' }, + owner: { tabId: 'tab-1', leafId: 'leaf-1', ptyId: 'pty-restored' } +} + +function notReachedByOptionBuilding(): never { + throw new Error('spawn option building must not call this dependency') +} + +function makeDeps(): PtySpawnIpcDeps & PtyRuntimeControllerDeps { + return { + getSettings: () => ({ ...getDefaultSettings('/tmp'), agentWorkspaceTrustEnabled: true }), + getLocalPtyStartupPromise: notReachedByOptionBuilding, + getLocalPtyProviderStartupPromise: notReachedByOptionBuilding, + adoptStablePane: notReachedByOptionBuilding, + assertFolderWorkspacePtyPathUsable: notReachedByOptionBuilding, + resolvePtySpawnStartupCwd: notReachedByOptionBuilding, + localStartupCwdDirectoryExists: notReachedByOptionBuilding, + prepareCodexResumeHome: notReachedByOptionBuilding, + noCodexResumeLaunch: notReachedByOptionBuilding, + resolveCodexResumeLaunch: notReachedByOptionBuilding, + reconcileSharedRuntimeResumeHome: notReachedByOptionBuilding, + stripSequencedStartupResumeArgv: notReachedByOptionBuilding, + transitionSpawnHiddenRendererPtyDeliveryState: notReachedByOptionBuilding, + trustedTerminalHandleEnv: new Set(), + sendPtySpawnedToRenderer: notReachedByOptionBuilding, + syncPtyBackgroundedDelivery: notReachedByOptionBuilding, + stopReplacedPty: notReachedByOptionBuilding, + requestSerializedBuffer: notReachedByOptionBuilding, + shutdownProviderAndDetectExit: notReachedByOptionBuilding, + rememberSyntheticKillExit: notReachedByOptionBuilding, + rememberRetiredRejectedPty: notReachedByOptionBuilding, + sendPtyExitToRenderer: notReachedByOptionBuilding, + finishPtyShutdown: notReachedByOptionBuilding, + retiredRejectedPtyIds: new Map() + } +} + +/** What preflight and env assembly leave for the option builders; both spawn states share it. */ +function seed( + ctx: Pick< + PtyIpcSpawnState, + 'env' | 'cwd' | 'launchCommand' | 'claudeAuth' | 'expectedWslDistro' | 'preAdoptedStablePane' + >, + input: BuildInput +): void { + ctx.env = { CLAUDE_CONFIG_DIR: '/cfg' } + ctx.cwd = input.cwd + ctx.launchCommand = input.command + ctx.claudeAuth = input.claudeAuth ?? null + ctx.expectedWslDistro = input.wslDistro ?? null + ctx.preAdoptedStablePane = input.restored ? RESTORED_PANE : null +} + +async function build(route: 'renderer' | 'runtime', input: BuildInput) { + const args = { + cols: 80, + rows: 24, + worktreeId: input.worktreeId ?? 'repo-1::/repo/wt', + connectionId: input.connectionId, + launchAgent: input.launchAgent, + command: input.command + } + if (route === 'renderer') { + const ctx = createPtyIpcSpawnState(makeDeps(), args) + seed(ctx, input) + await buildPtyIpcSpawnOptions(ctx) + ctx.finishTerminalInstall() + return ctx.spawnOptions + } + const ctx = createRuntimePtySpawnState(makeDeps(), args) + seed(ctx, input) + await buildRuntimePtySpawnOptions(ctx) + ctx.finishTerminalInstall() + return ctx.spawnOptions +} + +beforeEach(() => { + applyAgentWorkspaceTrust.mockReset() + applyAgentWorkspaceTrust.mockResolvedValue({}) +}) + +describe.each(['renderer', 'runtime'] as const)('%s spawn builder agent trust', (route) => { + it('pre-trusts the workspace for a fresh agent launch with the final spawn context', async () => { + await build(route, { + launchAgent: 'codex', + command: 'codex', + wslDistro: 'Ubuntu' + }) + expect(applyAgentWorkspaceTrust).toHaveBeenCalledWith('codex', '/repo/wt', { + env: expect.objectContaining({ CLAUDE_CONFIG_DIR: '/cfg' }), + claudeAuth: null, + wslDistro: 'Ubuntu', + connectionId: null + }) + }) + + it('trusts Codex in a floating terminal at the resolved folder it starts in', async () => { + await build(route, { + worktreeId: 'global-floating-terminal', + cwd: '/Users/me', + launchAgent: 'codex', + command: 'codex' + }) + expect(applyAgentWorkspaceTrust).toHaveBeenCalledWith('codex', '/Users/me', expect.anything()) + }) + + it('holds the spawn until the trust write settles', async () => { + let settleTrust = (): void => {} + applyAgentWorkspaceTrust.mockReturnValueOnce( + new Promise((resolve) => { + settleTrust = () => resolve({}) + }) + ) + let built = false + const building = build(route, { launchAgent: 'claude', command: 'claude' }).then(() => { + built = true + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(built).toBe(false) + settleTrust() + await building + expect(built).toBe(true) + }) + + it('keys on the declared agent even when setup sequencing rewrote the command', async () => { + await build(route, { launchAgent: 'claude', command: 'sh /tmp/orca-setup-runner.sh' }) + expect(applyAgentWorkspaceTrust).toHaveBeenCalledWith('claude', '/repo/wt', expect.anything()) + }) + + it('never re-runs trust for a restored pane or a spawn with no launch command', async () => { + await build(route, { launchAgent: 'claude', command: 'claude', restored: true }) + await build(route, { launchAgent: 'claude' }) + expect(applyAgentWorkspaceTrust).not.toHaveBeenCalled() + }) + + it('forwards the relay trust field on an SSH agent spawn', async () => { + applyAgentWorkspaceTrust.mockResolvedValueOnce({ + agentWorkspaceTrust: { workspacePath: '/repo/wt' } + }) + const options = await build(route, { + connectionId: 'ssh-1', + launchAgent: 'codex', + command: 'codex' + }) + expect(options.agentWorkspaceTrust).toEqual({ workspacePath: '/repo/wt' }) + }) +}) diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts index 0d4f04046b0..56067ebc3e9 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts @@ -34,10 +34,10 @@ const { registerOrcaProfileHandlersMock, registerCodexAccountHandlersMock, registerAgentHookHandlersMock, - registerAgentTrustHandlersMock, registerClaudeAccountHandlersMock, registerMiniMaxCredentialsHandlersMock, registerGrokAccountHandlersMock, + registerCursorAccountHandlersMock, registerClipboardHandlersMock, setTrustedClipboardRendererWebContentsIdMock, registerUpdaterHandlersMock, @@ -101,10 +101,10 @@ const { registerOrcaProfileHandlersMock: vi.fn(), registerCodexAccountHandlersMock: vi.fn(), registerAgentHookHandlersMock: vi.fn(), - registerAgentTrustHandlersMock: vi.fn(), registerClaudeAccountHandlersMock: vi.fn(), registerMiniMaxCredentialsHandlersMock: vi.fn(), registerGrokAccountHandlersMock: vi.fn(), + registerCursorAccountHandlersMock: vi.fn(), registerClipboardHandlersMock: vi.fn(), setTrustedClipboardRendererWebContentsIdMock: vi.fn(), registerUpdaterHandlersMock: vi.fn(), @@ -328,10 +328,6 @@ vi.mock('../agent-hooks', () => ({ registerAgentHookHandlers: registerAgentHookHandlersMock })) -vi.mock('../agent-trust', () => ({ - registerAgentTrustHandlers: registerAgentTrustHandlersMock -})) - vi.mock('../claude-accounts', () => ({ registerClaudeAccountHandlers: registerClaudeAccountHandlersMock })) @@ -344,6 +340,10 @@ vi.mock('../grok-accounts', () => ({ registerGrokAccountHandlers: registerGrokAccountHandlersMock })) +vi.mock('../cursor-accounts', () => ({ + registerCursorAccountHandlers: registerCursorAccountHandlersMock +})) + vi.mock('../../window/attach-main-window-services', () => ({ registerUpdaterHandlers: registerUpdaterHandlersMock })) @@ -432,7 +432,6 @@ describe('registerCoreHandlers', () => { registerOrcaProfileHandlersMock.mockReset() registerCodexAccountHandlersMock.mockReset() registerAgentHookHandlersMock.mockReset() - registerAgentTrustHandlersMock.mockReset() registerClaudeAccountHandlersMock.mockReset() registerMiniMaxCredentialsHandlersMock.mockReset() registerClipboardHandlersMock.mockReset() @@ -528,6 +527,7 @@ describe('registerCoreHandlers', () => { expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts) expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits) expect(registerGrokAccountHandlersMock).toHaveBeenCalled() + expect(registerCursorAccountHandlersMock).toHaveBeenCalled() expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits, codexAccounts) expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats) expect(registerLinearHandlersMock).toHaveBeenCalled() @@ -552,7 +552,7 @@ describe('registerCoreHandlers', () => { expect(registerLocalhostWorktreeLabelHandlersMock).toHaveBeenCalledWith(store) expect(registerTelemetryHandlersMock).toHaveBeenCalledWith(store) expect(registerOrcaProfileHandlersMock).toHaveBeenCalledWith(store, { onBeforeRelaunch }) - expect(registerSessionHandlersMock).toHaveBeenCalledWith(store) + expect(registerSessionHandlersMock).toHaveBeenCalledWith(store, runtime) expect(registerUIHandlersMock).toHaveBeenCalledWith(store, { isDashboardPopoutRenderer: isDashboardPopoutRendererMock }) diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index 0b329335755..e23373c803c 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -60,10 +60,10 @@ import { registerCodexAccountHandlers } from '../codex-accounts' import { registerAgentHookHandlers } from '../agent-hooks' import { registerCodexConfigSyncHandlers } from '../codex-config-sync' import { getPtyIdForPaneKey } from '../pty' -import { registerAgentTrustHandlers } from '../agent-trust' import { registerClaudeAccountHandlers } from '../claude-accounts' import { registerMiniMaxCredentialsHandlers } from '../minimax-credentials' import { registerGrokAccountHandlers } from '../grok-accounts' +import { registerCursorAccountHandlers } from '../cursor-accounts' import { registerUpdaterHandlers } from '../../window/attach-main-window-services' import { registerClipboardHandlers, @@ -148,10 +148,10 @@ export function registerCoreHandlers( registerCodexAccountHandlers(codexAccounts, () => store.getSettings()) registerAgentHookHandlers(runtime, { getPtyIdForPaneKey }) registerCodexConfigSyncHandlers(codexAccounts.runtimeHomeService) - registerAgentTrustHandlers() registerClaudeAccountHandlers(claudeAccounts) registerMiniMaxCredentialsHandlers(rateLimits) registerGrokAccountHandlers() + registerCursorAccountHandlers() registerRateLimitHandlers(rateLimits, codexAccounts) registerGitHubHandlers(store, stats) registerGitLabHandlers(store) @@ -202,7 +202,7 @@ export function registerCoreHandlers( registerBrowserHandlers() registerShellHandlers(store) registerPetHandlers() - registerSessionHandlers(store) + registerSessionHandlers(store, runtime) registerUIHandlers(store, { isDashboardPopoutRenderer }) registerEmulatorFrameStreamHandlers() registerEmulatorVideoStreamHandlers() diff --git a/src/main/ipc/registered-worktree-root-owner.ts b/src/main/ipc/registered-worktree-root-owner.ts new file mode 100644 index 00000000000..37b1ed25e38 --- /dev/null +++ b/src/main/ipc/registered-worktree-root-owner.ts @@ -0,0 +1,35 @@ +import { resolve } from 'node:path' +import { getRepoExecutionHostId } from '../../shared/execution-host' +import type { Repo } from '../../shared/repo-types' +import { hasRemoteFilesystemOwner } from './remote-filesystem-owner' + +export function getWorktreeRootOwnerKey(repo: Repo): string { + return JSON.stringify([repo.id, resolve(repo.path), getRepoExecutionHostId(repo)]) +} + +// Why the shared predicate: an unplaceable host stamp must not register roots either — the same +// allow-list, reached through `git worktree list` instead of the repo path. +export function getLocalWorktreeRootOwners(repos: readonly Repo[]): Map { + return new Map( + repos + .filter((repo) => !hasRemoteFilesystemOwner(repo)) + .map((repo) => [getWorktreeRootOwnerKey(repo), repo]) + ) +} + +export function resolveWorktreeRootOwner( + repos: readonly Repo[], + repo: Repo | string, + owners: ReadonlyMap +): string | undefined { + // ID-only callers are safe only when the entire catalog has one matching row. + if (typeof repo === 'string') { + const matches = repos.filter((candidate) => candidate.id === repo) + if (matches.length !== 1) { + return undefined + } + repo = matches[0] + } + const key = getWorktreeRootOwnerKey(repo) + return !repo.connectionId && owners.has(key) ? key : undefined +} diff --git a/src/main/ipc/registered-worktree-root-ownership.test.ts b/src/main/ipc/registered-worktree-root-ownership.test.ts new file mode 100644 index 00000000000..c03f923d7ec --- /dev/null +++ b/src/main/ipc/registered-worktree-root-ownership.test.ts @@ -0,0 +1,294 @@ +import { join, resolve } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' +import type { Repo } from '../../shared/repo-types' + +const mocks = vi.hoisted(() => ({ graph: vi.fn(), stat: vi.fn(), realpath: vi.fn() })) +vi.mock('node:fs', () => ({ realpathSync: (path: string) => path, statSync: mocks.stat })) +vi.mock('node:fs/promises', () => ({ stat: mocks.stat, realpath: mocks.realpath })) +vi.mock('../repo-worktrees', () => ({ + listRepoWorktreeGraph: mocks.graph, + isRepoRoot: vi.fn(() => false) +})) +vi.mock('./worktree-logic', () => ({ + computeWorkspaceRoot: vi.fn(), + getWorktreePathSettings: vi.fn() +})) +vi.mock('../project-runtime-git-options', () => ({ + getWorktreeMirrorDistroForRuntime: vi.fn(), + resolveLocalProjectRuntimesForRepos: vi.fn() +})) +import { resolveAuthorizedPath } from './filesystem-auth' +import { + __resetCreatedWorktreeRootsForTests, + invalidateAuthorizedRootsCache, + rebuildAuthorizedRootsCache, + registerCreatedWorktreeRoot, + registerWorktreeRootsForRepo +} from './registered-worktree-roots-cache' + +const root = resolve('/registry-review-repo') +const linked = resolve('/registry-review-linked') +const canonical = resolve('/registry-review-canonical') +const replacement = resolve('/registry-review-replacement') +const local: Repo = { id: 'owner', path: root, displayName: 'repo', badgeColor: '#000', addedAt: 0 } +function fixture(repos: Repo[]): Store { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The actual authorization APIs only read these four store methods; filesystem and graph boundaries are mocked. + return { + getRepos: () => repos, + getProjectGroups: () => [], + getFolderWorkspaces: () => [], + getSettings: () => ({}) + } as unknown as Store +} +function deferred() { + let resolvePromise!: (value: T) => void + let rejectPromise!: (error: unknown) => void + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve + rejectPromise = reject + }) + return { promise, resolve: resolvePromise, reject: rejectPromise } +} +async function deferredAlias(store: Store) { + const normalization = deferred() + mocks.realpath.mockImplementation((path: string) => { + if (path === linked) { + return normalization.promise + } + if (path === join(linked, 'file')) { + return Promise.resolve(join(canonical, 'file')) + } + return Promise.resolve(path) + }) + const authorization = resolveAuthorizedPath(join(linked, 'file'), store) + await vi.waitFor(() => expect(mocks.realpath).toHaveBeenCalledWith(linked)) + return { authorization, normalization } +} +async function expectDenied(path: string, store: Store) { + await expect(resolveAuthorizedPath(path, store)).rejects.toThrow('Access denied') +} +beforeEach(() => { + invalidateAuthorizedRootsCache() + __resetCreatedWorktreeRootsForTests() + vi.resetAllMocks() + mocks.graph.mockResolvedValue([]) + mocks.stat.mockResolvedValue({}) + mocks.realpath.mockImplementation(async (path: string) => path) +}) +afterEach(() => vi.useRealTimers()) + +describe('canonical alias publication respects current registration', () => { + it.each(['canonical', 'legacy'] as const)( + 'retires an in-flight alias after %s SSH ownership', + async (hostEncoding) => { + const repos = [{ ...local }] + const store = fixture(repos) + registerWorktreeRootsForRepo(store, local.id, [linked]) + const { authorization, normalization } = await deferredAlias(store) + repos[0] = + hostEncoding === 'canonical' + ? { ...local, executionHostId: 'ssh:remote' } + : { ...local, connectionId: 'remote' } + invalidateAuthorizedRootsCache() + normalization.resolve(canonical) + await expect(authorization).rejects.toThrow('Access denied') + await expectDenied(join(canonical, 'later'), store) + } + ) + it('does not let an obsolete alias outlive a replacement listing', async () => { + const store = fixture([local]) + registerWorktreeRootsForRepo(store, local.id, [linked]) + const { authorization, normalization } = await deferredAlias(store) + registerWorktreeRootsForRepo(store, local.id, [replacement]) + normalization.resolve(canonical) + await expect(authorization).rejects.toThrow('Access denied') + await expectDenied(join(canonical, 'later'), store) + await expect(resolveAuthorizedPath(join(replacement, 'file'), store)).resolves.toBe( + join(replacement, 'file') + ) + }) + it('does not cache an alias from an invalidated listing', async () => { + const store = fixture([local]) + registerWorktreeRootsForRepo(store, local.id, [linked]) + const { authorization, normalization } = await deferredAlias(store) + invalidateAuthorizedRootsCache() + normalization.resolve(canonical) + await expect(authorization).rejects.toThrow('Access denied') + await expectDenied(join(canonical, 'later'), store) + }) + it('retains a completed alias while its original owner remains local', async () => { + const store = fixture([local]) + registerWorktreeRootsForRepo(store, local.id, [linked]) + const { authorization, normalization } = await deferredAlias(store) + normalization.resolve(canonical) + await expect(authorization).resolves.toBe(join(canonical, 'file')) + await expect(resolveAuthorizedPath(join(canonical, 'later'), store)).resolves.toBe( + join(canonical, 'later') + ) + }) +}) + +describe('recovered grant ownership and prune completion', () => { + it('does not apply old ENOENT evidence to a renewed recovered grant', async () => { + const probe = deferred() + mocks.stat.mockReturnValueOnce(probe.promise) + const store = fixture([local]) + registerCreatedWorktreeRoot(store, local.id, linked) + const rebuilding = rebuildAuthorizedRootsCache(store) + await vi.waitFor(() => expect(mocks.stat).toHaveBeenCalledWith(linked)) + registerCreatedWorktreeRoot(store, local.id, linked) + probe.reject(Object.assign(new Error('old absence'), { code: 'ENOENT' })) + await rebuilding + await expect(resolveAuthorizedPath(join(linked, 'file'), store)).resolves.toBe( + join(linked, 'file') + ) + }) + it.each([false, true])( + 'retires recovered roots after same-ID local path replacement (invalidate=%s)', + async (invalidate) => { + const repos = [{ ...local }] + const store = fixture(repos) + registerCreatedWorktreeRoot(store, local.id, linked) + repos[0] = { ...local, path: replacement } + if (invalidate) { + invalidateAuthorizedRootsCache() + } + await expectDenied(join(linked, 'file'), store) + } + ) + it.each(['listed', 'created'] as const)( + 'rejects ambiguous %s registration for one ID and two local paths', + async (kind) => { + const store = fixture([local, { ...local, path: replacement }]) + if (kind === 'listed') { + registerWorktreeRootsForRepo(store, local.id, [linked]) + } else { + registerCreatedWorktreeRoot(store, local.id, linked) + } + await expectDenied(join(linked, 'file'), store) + } + ) + it('preserves a still-local recovered root across ordinary invalidation', async () => { + const store = fixture([local]) + registerCreatedWorktreeRoot(store, local.id, linked) + invalidateAuthorizedRootsCache() + await expect(resolveAuthorizedPath(join(linked, 'file'), store)).resolves.toBe( + join(linked, 'file') + ) + }) + it('preserves a recovered root when its filesystem probe returns EIO', async () => { + const store = fixture([local]) + registerCreatedWorktreeRoot(store, local.id, linked) + mocks.stat.mockRejectedValue(Object.assign(new Error('unavailable'), { code: 'EIO' })) + await rebuildAuthorizedRootsCache(store) + await expect(resolveAuthorizedPath(join(linked, 'file'), store)).resolves.toBe( + join(linked, 'file') + ) + }) + it('preserves a recovered root on timeout and after late ENOENT', async () => { + vi.useFakeTimers() + const probe = deferred() + mocks.stat.mockReturnValueOnce(probe.promise) + const store = fixture([local]) + registerCreatedWorktreeRoot(store, local.id, linked) + const rebuilding = rebuildAuthorizedRootsCache(store) + await vi.advanceTimersByTimeAsync(1_001) + await rebuilding + probe.reject(Object.assign(new Error('late absence'), { code: 'ENOENT' })) + await Promise.resolve() + await expect(resolveAuthorizedPath(join(linked, 'file'), store)).resolves.toBe( + join(linked, 'file') + ) + }) + it('keeps the newer registration when an older graph listing completes', async () => { + const graph = deferred<{ path: string }[]>() + mocks.graph.mockReturnValueOnce(graph.promise) + const store = fixture([local]) + const rebuilding = rebuildAuthorizedRootsCache(store) + expect(mocks.graph).toHaveBeenCalledOnce() + registerWorktreeRootsForRepo(store, local.id, [replacement]) + graph.resolve([{ path: linked }]) + await rebuilding + await expectDenied(join(linked, 'file'), store) + await expect(resolveAuthorizedPath(join(replacement, 'file'), store)).resolves.toBe( + join(replacement, 'file') + ) + }) +}) + +describe('qualified registration', () => { + it.each(['listed', 'created'] as const)( + 'keeps legitimate %s roots with overlapping local and SSH IDs', + async (kind) => { + const remote: Repo = { ...local, executionHostId: 'ssh:remote' } + const store = fixture([local, remote]) + if (kind === 'listed') { + registerWorktreeRootsForRepo(store, local, [linked]) + registerWorktreeRootsForRepo(store, remote, [replacement]) + } else { + registerCreatedWorktreeRoot(store, local, linked) + registerCreatedWorktreeRoot(store, remote, replacement) + } + await expect(resolveAuthorizedPath(join(linked, 'file'), store)).resolves.toBe( + join(linked, 'file') + ) + await expectDenied(join(replacement, 'file'), store) + } + ) + it('preserves a root still owned by another qualified local repository', async () => { + const second = { ...local, path: replacement } + const repos = [local, second] + const store = fixture(repos) + registerCreatedWorktreeRoot(store, local, linked) + registerCreatedWorktreeRoot(store, second, linked) + repos[0] = { ...local, executionHostId: 'ssh:remote' } + invalidateAuthorizedRootsCache() + await expect(resolveAuthorizedPath(join(linked, 'file'), store)).resolves.toBe( + join(linked, 'file') + ) + }) + it('keeps recovered roots across harmless display metadata changes', async () => { + const repos = [{ ...local }] + const store = fixture(repos) + registerCreatedWorktreeRoot(store, local, linked) + repos[0] = { ...local, displayName: 'Renamed' } + invalidateAuthorizedRootsCache() + await expect(resolveAuthorizedPath(join(linked, 'file'), store)).resolves.toBe( + join(linked, 'file') + ) + expect(mocks.graph).not.toHaveBeenCalled() + expect(mocks.stat).not.toHaveBeenCalled() + }) + it('preserves own-store runtime roots but retires a changed runtime identity', async () => { + const runtime: Repo = { ...local, executionHostId: 'runtime:a' } + const repos = [runtime] + const store = fixture(repos) + registerCreatedWorktreeRoot(store, runtime, linked) + await expect(resolveAuthorizedPath(join(linked, 'file'), store)).resolves.toBe( + join(linked, 'file') + ) + repos[0] = { ...runtime, executionHostId: 'runtime:b' } + await expectDenied(join(linked, 'file'), store) + }) + it.each(['canonical', 'legacy'] as const)( + 'discards obsolete %s SSH graph results', + async (hostEncoding) => { + const graph = deferred<{ path: string }[]>() + mocks.graph.mockReturnValueOnce(graph.promise) + const repos = [{ ...local }] + const store = fixture(repos) + const rebuilding = rebuildAuthorizedRootsCache(store) + repos[0] = + hostEncoding === 'canonical' + ? { ...local, executionHostId: 'ssh:remote' } + : { ...local, connectionId: 'remote' } + invalidateAuthorizedRootsCache() + graph.resolve([{ path: linked }]) + await rebuilding + await expectDenied(join(linked, 'file'), store) + expect(mocks.graph).toHaveBeenCalledOnce() + expect(mocks.stat).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/main/ipc/registered-worktree-root-probes.ts b/src/main/ipc/registered-worktree-root-probes.ts new file mode 100644 index 00000000000..ca295f02dd1 --- /dev/null +++ b/src/main/ipc/registered-worktree-root-probes.ts @@ -0,0 +1,70 @@ +import { stat } from 'node:fs/promises' +import { resolve } from 'node:path' +import { withTimeout } from '../../shared/promise-timeout-fallback' +import type { Repo } from '../../shared/repo-types' +import { getErrorCode } from '../git/worktree-operation-options' +import { listRepoWorktreeGraph } from '../repo-worktrees' + +const CREATED_WORKTREE_ROOT_PROBE_TIMEOUT_MS = 1_000 +const AUTHORIZED_ROOTS_REBUILD_CONCURRENCY = 8 + +type ListedRoots = { roots: Set; listingFailed: boolean } + +export async function listWorktreeRootsWithConcurrency( + repos: readonly Repo[] +): Promise { + const results: ListedRoots[] = [] + let nextIndex = 0 + await Promise.all( + Array.from( + { length: Math.min(AUTHORIZED_ROOTS_REBUILD_CONCURRENCY, repos.length) }, + async () => { + while (nextIndex < repos.length) { + const index = nextIndex++ + const repo = repos[index] + const roots = new Set([resolve(repo.path)]) + let listingFailed = false + try { + for (const worktree of await listRepoWorktreeGraph(repo)) { + roots.add(resolve(worktree.path)) + } + } catch (error) { + console.warn( + `[filesystem-auth] skipping repo ${repo.path} during cache rebuild:`, + error + ) + listingFailed = true + } + results[index] = { roots, listingFailed } + } + } + ) + ) + return results +} + +/** An unavailable mount is not evidence that a recovered worktree disappeared. */ +export async function pruneCreatedWorktreeRoots( + recoveredRoots: ReadonlySet, + listed: ListedRoots +): Promise> { + const recovered = new Set(recoveredRoots) + if (!listed.listingFailed) { + await Promise.all( + [...recovered].map(async (root) => { + if (listed.roots.has(root) || (await isRootGoneFromDisk(root))) { + recovered.delete(root) + } + }) + ) + } + return recovered +} + +async function isRootGoneFromDisk(targetPath: string): Promise { + const probe = stat(targetPath).then( + () => false, + (error: unknown) => getErrorCode(error) === 'ENOENT' + ) + return withTimeout(probe, CREATED_WORKTREE_ROOT_PROBE_TIMEOUT_MS, false) +} diff --git a/src/main/ipc/registered-worktree-root-refresh.test.ts b/src/main/ipc/registered-worktree-root-refresh.test.ts new file mode 100644 index 00000000000..f33221adb05 --- /dev/null +++ b/src/main/ipc/registered-worktree-root-refresh.test.ts @@ -0,0 +1,128 @@ +import { join, resolve } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' +import type { Repo } from '../../shared/repo-types' + +const mocks = vi.hoisted(() => ({ graph: vi.fn(), stat: vi.fn(), realpath: vi.fn() })) +vi.mock('node:fs', () => ({ realpathSync: (path: string) => path, statSync: mocks.stat })) +vi.mock('node:fs/promises', () => ({ stat: mocks.stat, realpath: mocks.realpath })) +vi.mock('../repo-worktrees', () => ({ + listRepoWorktreeGraph: mocks.graph, + isRepoRoot: vi.fn(() => false) +})) +vi.mock('./worktree-logic', () => ({ + computeWorkspaceRoot: vi.fn(), + getWorktreePathSettings: vi.fn() +})) +vi.mock('../project-runtime-git-options', () => ({ + getWorktreeMirrorDistroForRuntime: vi.fn(), + resolveLocalProjectRuntimesForRepos: vi.fn() +})) +import { resolveAuthorizedPath } from './filesystem-auth' +import { + __resetCreatedWorktreeRootsForTests, + invalidateAuthorizedRootsCache, + rebuildAuthorizedRootsCache, + registerWorktreeRootsForRepo +} from './registered-worktree-roots-cache' + +const root = resolve('/registry-review-repo') +const linked = resolve('/registry-review-linked') +const replacement = resolve('/registry-review-replacement') +const local: Repo = { id: 'owner', path: root, displayName: 'repo', badgeColor: '#000', addedAt: 0 } +function fixture(repos: Repo[]): Store { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The actual authorization APIs only read these four store methods; filesystem and graph boundaries are mocked. + return { + getRepos: () => repos, + getProjectGroups: () => [], + getFolderWorkspaces: () => [], + getSettings: () => ({}) + } as unknown as Store +} +function deferred() { + let resolvePromise!: (value: T) => void + let rejectPromise!: (error: unknown) => void + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve + rejectPromise = reject + }) + return { promise, resolve: resolvePromise, reject: rejectPromise } +} +beforeEach(() => { + invalidateAuthorizedRootsCache() + __resetCreatedWorktreeRootsForTests() + vi.resetAllMocks() + mocks.graph.mockResolvedValue([]) + mocks.stat.mockResolvedValue({}) + mocks.realpath.mockImplementation(async (path: string) => path) +}) +afterEach(() => vi.useRealTimers()) + +describe('current request follows a superseded rebuild', () => { + it('rebuilds the current local catalog before deciding a request that joined obsolete work', async () => { + const graph = deferred<{ path: string }[]>() + mocks.graph.mockReturnValueOnce(graph.promise) + const repos = [{ ...local }] + const store = fixture(repos) + const first = resolveAuthorizedPath(join(linked, 'file'), store) + const firstResult = first.then( + (value) => ({ value }), + (error) => ({ error }) + ) + await vi.waitFor(() => expect(mocks.graph).toHaveBeenCalledOnce()) + repos[0] = { ...local, path: replacement } + invalidateAuthorizedRootsCache() + mocks.graph.mockResolvedValue([{ path: linked }]) + const current = resolveAuthorizedPath(join(linked, 'file'), store) + const currentResult = current.then( + (value) => ({ value }), + (error) => ({ error }) + ) + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + graph.resolve([]) + const [, result] = await Promise.all([firstResult, currentResult]) + expect(result).toEqual({ value: join(linked, 'file') }) + expect(mocks.graph).toHaveBeenCalledTimes(2) + }) +}) + +it.each([1, 64, 256])('records catalog work for %s cold-rebuild repositories', async (count) => { + let pathReads = 0 + const repos: Repo[] = Array.from({ length: count }, (_, i) => ({ + id: `repo-${i}`, + get path() { + pathReads++ + return resolve(`/review-repo-${i}`) + }, + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + })) + const store = fixture(repos) + const getRepos = vi.spyOn(store, 'getRepos') + await rebuildAuthorizedRootsCache(store) + expect(mocks.graph).toHaveBeenCalledTimes(count) + expect(getRepos.mock.calls.length).toBeLessThanOrEqual(3) + expect(pathReads).toBeLessThanOrEqual(4 * count) +}) +it('records catalog work for one warm linked-file authorization', async () => { + const repo: Repo = { + id: 'repo', + path: resolve('/review-repo'), + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + } + const store = fixture([repo]) + const getRepos = vi.spyOn(store, 'getRepos') + const linked = resolve('/review-linked') + registerWorktreeRootsForRepo(store, repo.id, [linked]) + getRepos.mockClear() + await expect(resolveAuthorizedPath(resolve(linked, 'file'), store)).resolves.toBe( + resolve(linked, 'file') + ) + expect(mocks.graph).not.toHaveBeenCalled() + expect(getRepos.mock.calls.length).toBeLessThanOrEqual(3) +}) diff --git a/src/main/ipc/registered-worktree-roots-cache.ts b/src/main/ipc/registered-worktree-roots-cache.ts index ad8c40535cf..30d89fce2dd 100644 --- a/src/main/ipc/registered-worktree-roots-cache.ts +++ b/src/main/ipc/registered-worktree-roots-cache.ts @@ -1,313 +1,244 @@ -import { stat } from 'node:fs/promises' import { resolve } from 'node:path' -import { withTimeout } from '../../shared/promise-timeout-fallback' -import { getErrorCode } from '../git/worktree-operation-options' +import type { Repo } from '../../shared/repo-types' import type { Store } from '../persistence' -import { isRepoRoot, listRepoWorktreeGraph } from '../repo-worktrees' -import { getLocalRepos } from './filesystem-allowed-roots' +import { + listWorktreeRootsWithConcurrency, + pruneCreatedWorktreeRoots +} from './registered-worktree-root-probes' import { isDescendantOrEqual, normalizeExistingPath } from './filesystem-path-containment' +import { + getLocalWorktreeRootOwners, + resolveWorktreeRootOwner +} from './registered-worktree-root-owner' + +type RegisteredOwner = { + repoId: string + listed: Set | null + recovered: Set + aliases: Set + revision: number + dirty: boolean +} const registeredWorktreeRoots = new Set() -const registeredWorktreeRootsByRepo = new Map>() -/** - * Roots Git confirmed by direct read while `git worktree list` could not see them. - * - * Why a layer of its own: everything in `registeredWorktreeRootsByRepo` is derived from the listing, - * so a rebuild recomputes it from the very listing that failed and would re-deny a worktree the - * create just recovered. This layer survives rebuilds and is pruned only on evidence (#16520). - */ -const createdWorktreeRootsByRepo = new Map>() -/** A recovered create is rare (Git's listing must be broken); cap the layer so it can never grow unbounded. */ -const CREATED_WORKTREE_ROOTS_MAX = 64 -/** The prune runs inside filesystem-auth resolution, so a hung mount must not stall it. */ -const CREATED_WORKTREE_ROOT_PROBE_TIMEOUT_MS = 1_000 -const registeredWorktreeRootRepoIds = new Set() +const registeredOwners = new Map() const registeredWorktreeRootsRevisionByRepo = new Map() -let registeredWorktreeRootsRevisionSequence = 0 -let registeredWorktreeRootsBaseRevision = 0 +const CREATED_WORKTREE_ROOTS_MAX = 64 +let revisionSequence = 0 +let baseRevision = 0 +let invalidationGeneration = 0 +let registryStore: Store | null = null +let currentOwners = new Map() let registeredWorktreeRootsDirty = true let registeredWorktreeRootsRefresh: Promise | null = null -const AUTHORIZED_ROOTS_REBUILD_CONCURRENCY = 8 + +function advanceOwner(owner: RegisteredOwner): void { + owner.revision = ++revisionSequence + owner.aliases.clear() + registeredWorktreeRootsRevisionByRepo.set(owner.repoId, owner.revision) +} + +function synchronizeOwners(store: Store): Repo[] { + const repos = store.getRepos() + const owners = getLocalWorktreeRootOwners(repos) + if (registryStore !== store) { + registeredOwners.clear() + registryStore = store + invalidationGeneration++ + } + let changed = currentOwners.size !== owners.size + for (const [key, owner] of registeredOwners) { + if (!owners.has(key)) { + advanceOwner(owner) + registeredOwners.delete(key) + changed = true + } + } + for (const [key, repo] of owners) { + if (!registeredOwners.has(key)) { + registeredOwners.set(key, { + repoId: repo.id, + listed: null, + recovered: new Set(), + aliases: new Set(), + revision: ++revisionSequence, + dirty: true + }) + changed = true + } + } + currentOwners = owners + if (changed) { + refreshRegisteredWorktreeRoots() + } + registeredWorktreeRootsDirty = [...registeredOwners.values()].some((owner) => owner.dirty) + return repos +} export function invalidateAuthorizedRootsCache(): void { - registeredWorktreeRootsDirty = true - // Why: dirty roots can't be trusted for auth short-circuits; fresh worktrees:list seeds safe per-repo roots before a full rebuild. - registeredWorktreeRoots.clear() - registeredWorktreeRootsByRepo.clear() - registeredWorktreeRootRepoIds.clear() - // The recovered layer is deliberately not cleared: repo mutations are frequent, and dropping it here - // would re-deny a recovered worktree every time an unrelated repo is added or removed. + invalidationGeneration++ + for (const owner of registeredOwners.values()) { + owner.listed = null + owner.dirty = true + advanceOwner(owner) + } refreshRegisteredWorktreeRoots() - registeredWorktreeRootsBaseRevision = ++registeredWorktreeRootsRevisionSequence + registeredWorktreeRootsDirty = true + baseRevision = ++revisionSequence registeredWorktreeRootsRevisionByRepo.clear() } export async function rebuildAuthorizedRootsCache(store: Store): Promise { - // Why: bounded parallelism keeps the Windows speedup without one git process per repo. - // Why no realpath here: canonicalizing every root on invalidation would trigger macOS TCC prompts; handlers still canonicalize the target before any operation. - const repos = getLocalRepos(store) - const perProjectResults = await mapWithConcurrency( - repos, - AUTHORIZED_ROOTS_REBUILD_CONCURRENCY, - async (repo) => { - const roots: string[] = [] - try { - roots.push(resolve(repo.path)) - - for (const worktree of await listRepoWorktreeGraph(repo)) { - roots.push(resolve(worktree.path)) - } - } catch (error) { - // Why: one inaccessible repo (EACCES/EIO) must not break the whole rebuild and disable File Explorer/Quick Open for the rest; skip it. - console.warn(`[filesystem-auth] skipping repo ${repo.path} during cache rebuild:`, error) - return { repoId: repo.id, roots, listingFailed: true } - } - return { repoId: repo.id, roots, listingFailed: false } - } - ) - await pruneCreatedWorktreeRoots(perProjectResults, new Set(repos.map((repo) => repo.id))) - - registeredWorktreeRoots.clear() - registeredWorktreeRootsByRepo.clear() - registeredWorktreeRootRepoIds.clear() - for (const { repoId, roots } of perProjectResults) { - const normalizedRoots = new Set() - for (const root of roots) { - normalizedRoots.add(root) - registeredWorktreeRoots.add(root) - } - registeredWorktreeRootsByRepo.set(repoId, normalizedRoots) - registeredWorktreeRootRepoIds.add(repoId) - } - for (const roots of createdWorktreeRootsByRepo.values()) { - for (const root of roots) { - registeredWorktreeRoots.add(root) - } - } - registeredWorktreeRootsDirty = false - registeredWorktreeRootsBaseRevision = ++registeredWorktreeRootsRevisionSequence - registeredWorktreeRootsRevisionByRepo.clear() -} - -/** - * Retire recovered roots on evidence: the listing can see the worktree again, or it is gone from disk. - * - * Nothing is retired for want of evidence. A repo whose listing threw is left untouched, and so is a - * root whose probe hangs or errors for any reason but ENOENT — a dead mount fails the listing and the - * probe alike, and pruning on that would revoke the worktree in the very outage this layer exists for. - */ -async function pruneCreatedWorktreeRoots( - results: readonly { repoId: string; roots: string[]; listingFailed: boolean }[], - localRepoIds: Set -): Promise { - const listedByRepo = new Map( - results.filter((result) => !result.listingFailed).map((r) => [r.repoId, new Set(r.roots)]) - ) - const probes: Promise[] = [] - for (const [repoId, roots] of createdWorktreeRootsByRepo) { - if (!localRepoIds.has(repoId)) { - createdWorktreeRootsByRepo.delete(repoId) - continue - } - const listed = listedByRepo.get(repoId) - if (!listed) { - continue - } - for (const root of roots) { - if (listed.has(root)) { - roots.delete(root) - continue - } - // Probe in parallel: a repo may hold up to CREATED_WORKTREE_ROOTS_MAX roots, and serial - // timeouts would multiply into a rebuild stall of their own. - probes.push( - isRootGoneFromDisk(root).then((gone) => { - if (gone) { - roots.delete(root) - } - }) - ) - } - } - await Promise.all(probes) - for (const [repoId, roots] of createdWorktreeRootsByRepo) { - if (roots.size === 0) { - createdWorktreeRootsByRepo.delete(repoId) - } - } -} - -/** Only a definitive ENOENT counts as removal; the timeout unblocks the rebuild but cannot cancel the syscall. */ -async function isRootGoneFromDisk(targetPath: string): Promise { - const probe = stat(targetPath).then( - () => false, - (error: unknown) => getErrorCode(error) === 'ENOENT' - ) - return withTimeout(probe, CREATED_WORKTREE_ROOT_PROBE_TIMEOUT_MS, false) -} - -async function mapWithConcurrency( - items: readonly T[], - maxConcurrent: number, - mapper: (item: T) => Promise -): Promise { - const results: R[] = [] - let nextIndex = 0 - const workerCount = Math.min(maxConcurrent, items.length) - await Promise.all( - Array.from({ length: workerCount }, async () => { - while (nextIndex < items.length) { - const index = nextIndex - nextIndex += 1 - results[index] = await mapper(items[index]) + synchronizeOwners(store) + const generation = invalidationGeneration + const pending = [...currentOwners].map(([key, repo]) => ({ + key, + repo, + owner: registeredOwners.get(key), + revision: registeredOwners.get(key)?.revision + })) + const listings = await listWorktreeRootsWithConcurrency(pending.map((entry) => entry.repo)) + const results = pending.map((entry, index) => ({ ...entry, ...listings[index] })) + const isCurrent = (entry: (typeof pending)[number]): boolean => + generation === invalidationGeneration && + registeredOwners.get(entry.key) === entry.owner && + entry.owner?.revision === entry.revision + synchronizeOwners(store) + const pruned = await Promise.all( + results.map(async (entry) => { + if (!entry.owner || !isCurrent(entry)) { + return null } + const recovered = await pruneCreatedWorktreeRoots(entry.owner.recovered, entry) + return { ...entry, recovered } }) ) - return results + synchronizeOwners(store) + for (const entry of pruned) { + // A fresh create or ownership change makes both the graph and absence probes obsolete. + if (!entry || !entry.owner || !isCurrent(entry)) { + continue + } + entry.owner.listed = entry.roots + entry.owner.dirty = false + entry.owner.recovered = entry.recovered + advanceOwner(entry.owner) + } + refreshRegisteredWorktreeRoots() + registeredWorktreeRootsDirty = [...registeredOwners.values()].some((owner) => owner.dirty) } export function registerWorktreeRootsForRepo( store: Store, - repoId: string, + repo: Repo | string, worktreeRoots: string[] ): void { - const localRepoIds = new Set(getLocalRepos(store).map((repo) => repo.id)) - for (const registeredRepoId of registeredWorktreeRootsByRepo.keys()) { - if (!localRepoIds.has(registeredRepoId)) { - registeredWorktreeRootsByRepo.delete(registeredRepoId) - registeredWorktreeRootRepoIds.delete(registeredRepoId) - registeredWorktreeRootsRevisionByRepo.set( - registeredRepoId, - ++registeredWorktreeRootsRevisionSequence - ) - } - } - - if (!localRepoIds.has(repoId)) { - refreshRegisteredWorktreeRoots() - registeredWorktreeRootsDirty = !allLocalRepoRootsRegistered(localRepoIds) + const repos = synchronizeOwners(store) + const key = resolveWorktreeRootOwner(repos, repo, currentOwners) + const owner = key === undefined ? undefined : registeredOwners.get(key) + if (!owner) { return } - - registeredWorktreeRootsByRepo.set(repoId, new Set(worktreeRoots.map((root) => resolve(root)))) - registeredWorktreeRootRepoIds.add(repoId) - registeredWorktreeRootsRevisionByRepo.set(repoId, ++registeredWorktreeRootsRevisionSequence) + owner.listed = new Set(worktreeRoots.map((root) => resolve(root))) + owner.dirty = false + advanceOwner(owner) refreshRegisteredWorktreeRoots() - registeredWorktreeRootsDirty = !allLocalRepoRootsRegistered(localRepoIds) + registeredWorktreeRootsDirty = [...registeredOwners.values()].some((entry) => entry.dirty) } -/** - * Authorize one worktree root that Git confirmed by direct read but could not list. - * - * Why not `registerWorktreeRootsForRepo`: that replaces the repo's set, and a create recovered without - * a listing has no full set to put there. The root goes in the recovered layer instead, so the next - * rebuild cannot drop it while Git's listing is still broken (#16520). - */ +/** Preserve directly confirmed creates when an unavailable listing cannot name them. */ export function registerCreatedWorktreeRoot( store: Store, - repoId: string, + repo: Repo | string, worktreeRoot: string ): void { - const localRepoIds = new Set(getLocalRepos(store).map((repo) => repo.id)) - if (!localRepoIds.has(repoId)) { + const repos = synchronizeOwners(store) + const key = resolveWorktreeRootOwner(repos, repo, currentOwners) + const owner = key === undefined ? undefined : registeredOwners.get(key) + if (!owner) { return } - const roots = createdWorktreeRootsByRepo.get(repoId) ?? new Set() const root = resolve(worktreeRoot) - if (!roots.has(root) && roots.size >= CREATED_WORKTREE_ROOTS_MAX) { - // Refuse rather than evict: dropping an already-authorized root denies a worktree the user is - // using, while declining this one only leaves the new create as unauthorized as it is today. + if (!owner.recovered.has(root) && owner.recovered.size >= CREATED_WORKTREE_ROOTS_MAX) { console.warn( - `[filesystem-auth] recovered-root layer full for repo ${repoId}; not authorizing ${root}` + `[filesystem-auth] recovered-root layer full for repo ${owner.repoId}; not authorizing ${root}` ) return } - roots.add(root) - createdWorktreeRootsByRepo.set(repoId, roots) - registeredWorktreeRootsRevisionByRepo.set(repoId, ++registeredWorktreeRootsRevisionSequence) + owner.recovered.add(root) + owner.dirty = true + advanceOwner(owner) refreshRegisteredWorktreeRoots() registeredWorktreeRootsDirty = true } -/** The recovered layer outlives cache invalidation by design, so suites need an explicit reset. */ export function __resetCreatedWorktreeRootsForTests(): void { - createdWorktreeRootsByRepo.clear() + for (const owner of registeredOwners.values()) { + owner.recovered.clear() + advanceOwner(owner) + } refreshRegisteredWorktreeRoots() } export function getRegisteredWorktreeRootsRevision(repoId: string): number { - return registeredWorktreeRootsRevisionByRepo.get(repoId) ?? registeredWorktreeRootsBaseRevision + return registeredWorktreeRootsRevisionByRepo.get(repoId) ?? baseRevision } export async function ensureAuthorizedRootsCache(store: Store): Promise { - if (!registeredWorktreeRootsDirty) { - return + synchronizeOwners(store) + // Follow one superseded refresh; continuous catalog churn must not pin authorization forever. + for (let attempt = 0; registeredWorktreeRootsDirty && attempt < 2; attempt++) { + if (!registeredWorktreeRootsRefresh) { + registeredWorktreeRootsRefresh = rebuildAuthorizedRootsCache(store).finally(() => { + registeredWorktreeRootsRefresh = null + }) + } + await registeredWorktreeRootsRefresh } - if (!registeredWorktreeRootsRefresh) { - registeredWorktreeRootsRefresh = rebuildAuthorizedRootsCache(store).finally(() => { - registeredWorktreeRootsRefresh = null - }) - } - await registeredWorktreeRootsRefresh } -/** - * Resolve and verify that a worktree path belongs to a registered repo. - * - * Why not resolveAuthorizedPath: linked worktrees can live outside repo/workspace roots; git trusts exact `git worktree list` registration, not containment. - */ export async function resolveRegisteredWorktreePath( worktreePath: string, store: Store ): Promise { - // Reject malformed paths (null byte) early to prevent probing via realpath. if (!worktreePath || worktreePath.includes('\0')) { throw new Error('Access denied: invalid worktree path') } - + synchronizeOwners(store) const resolvedTarget = resolve(worktreePath) - if (registeredWorktreeRoots.has(resolvedTarget) || isRepoRoot(store.getRepos(), resolvedTarget)) { + if ( + registeredWorktreeRoots.has(resolvedTarget) || + [...currentOwners.values()].some((repo) => resolve(repo.path) === resolvedTarget) + ) { return resolvedTarget } - - if (registeredWorktreeRootsDirty) { - await ensureAuthorizedRootsCache(store) - } - + await ensureAuthorizedRootsCache(store) if (registeredWorktreeRoots.has(resolvedTarget)) { return resolvedTarget } - - // Resolve symlinks only after the cheap registered-root check: on macOS realpath() can trigger TCC prompts. const normalizedTarget = await normalizeExistingPath(resolvedTarget) + synchronizeOwners(store) if (registeredWorktreeRoots.has(normalizedTarget)) { return normalizedTarget } - throw new Error('Access denied: unknown repository or worktree path') } function refreshRegisteredWorktreeRoots(): void { registeredWorktreeRoots.clear() - for (const byRepo of [registeredWorktreeRootsByRepo, createdWorktreeRootsByRepo]) { - for (const roots of byRepo.values()) { - for (const root of roots) { - registeredWorktreeRoots.add(root) + for (const owner of registeredOwners.values()) { + for (const roots of [owner.listed, owner.recovered, owner.aliases]) { + if (roots) { + for (const root of roots) { + registeredWorktreeRoots.add(root) + } } } } } -function allLocalRepoRootsRegistered(localRepoIds: Set): boolean { - for (const repoId of localRepoIds) { - if (!registeredWorktreeRootRepoIds.has(repoId)) { - return false - } - } - return true -} - -export function isRegisteredWorktreePath(targetPath: string): boolean { +export function isRegisteredWorktreePath(targetPath: string, store: Store): boolean { + synchronizeOwners(store) for (const root of registeredWorktreeRoots) { if (isDescendantOrEqual(targetPath, root)) { return true @@ -318,31 +249,49 @@ export function isRegisteredWorktreePath(targetPath: string): boolean { export async function isPathAllowedByCanonicalRegisteredRoot( targetPath: string, - sourcePath: string | undefined + sourcePath: string | undefined, + store: Store ): Promise { if (!sourcePath) { return false } + synchronizeOwners(store) const textualRoot = findRegisteredWorktreeRoot(sourcePath) if (!textualRoot) { return false } + const generation = invalidationGeneration + const owners = [...registeredOwners.values()] + .filter( + (owner) => + owner.listed?.has(textualRoot) || + owner.recovered.has(textualRoot) || + owner.aliases.has(textualRoot) + ) + .map((owner) => ({ owner, revision: owner.revision })) const canonicalRoot = await normalizeExistingPath(textualRoot) - if (!isDescendantOrEqual(targetPath, canonicalRoot)) { + synchronizeOwners(store) + if (generation !== invalidationGeneration || !isDescendantOrEqual(targetPath, canonicalRoot)) { return false } - // Why: #1524 stopped realpath'ing every root (macOS privacy prompts); cache only the actively-accessed root so /var→/private/var aliases resolve. - registeredWorktreeRoots.add(canonicalRoot) - return true + let allowed = false + for (const { owner, revision } of owners) { + if (owner.revision !== revision) { + continue + } + owner.aliases.add(canonicalRoot) + allowed = true + } + if (allowed) { + registeredWorktreeRoots.add(canonicalRoot) + } + return allowed } function findRegisteredWorktreeRoot(targetPath: string): string | null { let bestRoot: string | null = null for (const root of registeredWorktreeRoots) { - if (!isDescendantOrEqual(targetPath, root)) { - continue - } - if (!bestRoot || root.length > bestRoot.length) { + if (isDescendantOrEqual(targetPath, root) && (!bestRoot || root.length > bestRoot.length)) { bestRoot = root } } diff --git a/src/main/ipc/remote-filesystem-owner.ts b/src/main/ipc/remote-filesystem-owner.ts new file mode 100644 index 00000000000..9fc17f83bad --- /dev/null +++ b/src/main/ipc/remote-filesystem-owner.ts @@ -0,0 +1,25 @@ +import { parseExecutionHostId } from '../../shared/execution-host' + +/** + * Whether another machine holds this row's files, so its path must not authorize local reads. + * + * Fails closed on a stamp the parser rejects — empty `ssh:`, a bad escape, an embedded `|` (refused + * so an alias cannot be rebound), an unknown prefix. `getSshTargetIdForExecutionHost` answers null + * for all of them, and in an allow-list "cannot place this owner" must not read as "this machine". + * A `runtime:` stamp stays local: on a repo row it names the store's own runtime, never a peer. + */ +export function hasRemoteFilesystemOwner(scope: { + connectionId?: string | null + executionHostId?: string | null +}): boolean { + // Keep legacy exclusions, including runtime rows whose connection belongs to that runtime. + if (scope.connectionId) { + return true + } + const stampedHostId = scope.executionHostId?.trim() + if (!stampedHostId) { + return false + } + const host = parseExecutionHostId(stampedHostId) + return host === null || host.kind === 'ssh' +} diff --git a/src/main/ipc/remote-workspace-relay-sync.ts b/src/main/ipc/remote-workspace-relay-sync.ts index b4dcab5a0bf..42852224090 100644 --- a/src/main/ipc/remote-workspace-relay-sync.ts +++ b/src/main/ipc/remote-workspace-relay-sync.ts @@ -2,7 +2,8 @@ import type { RemoteWorkspaceObservedPatchResult, RemoteWorkspaceObservedSnapshot, RemoteWorkspacePatchResult, - RemoteWorkspaceSession + RemoteWorkspaceSession, + RemoteWorkspaceSnapshot } from '../../shared/remote-workspace-types' import type { SshTarget } from '../../shared/ssh-types' import { getActiveMultiplexer } from './ssh' @@ -18,9 +19,11 @@ import { remoteWorkspaceSessionMatchesSnapshot } from './remote-workspace-snapshot-normalization' -export async function getRemoteSnapshot( - target: SshTarget -): Promise { +// Keep comparison and cache mutation together in the response continuation. +export async function readRemoteSnapshot( + target: SshTarget, + receive: (snapshot: RemoteWorkspaceSnapshot) => Result +): Promise { const mux = getActiveMultiplexer(target.id) if (!mux) { return null @@ -28,8 +31,7 @@ export async function getRemoteSnapshot( const namespace = getRemoteWorkspaceNamespace(target) try { const raw = await mux.request('workspace.get', { namespace }) - const snapshot = normalizeSnapshot(raw, namespace) - return rememberRemoteWorkspaceSnapshot(target.id, snapshot) + return receive(normalizeSnapshot(raw, namespace)) } catch (err) { if ((err as { code?: unknown })?.code === -32601) { return null @@ -38,6 +40,14 @@ export async function getRemoteSnapshot( } } +export function getRemoteSnapshot( + target: SshTarget +): Promise { + return readRemoteSnapshot(target, (snapshot) => + rememberRemoteWorkspaceSnapshot(target.id, snapshot) + ) +} + function observePatchResult( targetId: string, result: RemoteWorkspacePatchResult diff --git a/src/main/ipc/remote-workspace-snapshot-cache.ts b/src/main/ipc/remote-workspace-snapshot-cache.ts index 36be88e633a..2f9e26c5e81 100644 --- a/src/main/ipc/remote-workspace-snapshot-cache.ts +++ b/src/main/ipc/remote-workspace-snapshot-cache.ts @@ -17,11 +17,12 @@ type RemoteWorkspaceSnapshotCacheEntry = { const latestSnapshotByTargetId = new Map() -function snapshotsAreIdentical( - previous: RemoteWorkspaceObservedSnapshot, +export function remoteWorkspaceSnapshotsAreIdentical( + previous: RemoteWorkspaceSnapshot | undefined, next: RemoteWorkspaceSnapshot ): boolean { return ( + previous !== undefined && previous.namespace === next.namespace && previous.revision === next.revision && previous.updatedAt === next.updatedAt && @@ -59,7 +60,7 @@ export function rememberRemoteWorkspaceSnapshot( // observations do not revoke an in-flight upload authority. const normalizedSnapshot = normalizeSnapshot(snapshot, snapshot.namespace) const current = latestSnapshotByTargetId.get(targetId) - if (current && snapshotsAreIdentical(current.snapshot, normalizedSnapshot)) { + if (current && remoteWorkspaceSnapshotsAreIdentical(current.snapshot, normalizedSnapshot)) { // Re-reading an unchanged revision is not a new host observation. Keep the // token (and the contiguous local-patch authorization window) stable so a // polling read cannot invalidate an upload that is already in flight. diff --git a/src/main/ipc/remote-workspace-stale-resync.test.ts b/src/main/ipc/remote-workspace-stale-resync.test.ts index 2c7ceece9d4..07950563a94 100644 --- a/src/main/ipc/remote-workspace-stale-resync.test.ts +++ b/src/main/ipc/remote-workspace-stale-resync.test.ts @@ -1,9 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' +import { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { encodeJsonRpcFrame } from '../ssh/relay-protocol' import { REMOTE_WORKSPACE_STALE_NOTIFICATION, + REMOTE_WORKSPACE_CHANGED_NOTIFICATION, type RemoteWorkspaceChangedEvent, - type RemoteWorkspaceSession + type RemoteWorkspaceSession, + type RemoteWorkspaceSnapshot } from '../../shared/remote-workspace-types' const { getActiveMultiplexerMock, getSshConnectionStoreMock } = vi.hoisted(() => ({ @@ -29,6 +33,13 @@ import { handleRemoteWorkspaceNotification, registerRemoteWorkspaceHandlers } from './remote-workspace' +import { + getCachedRemoteWorkspaceSnapshot, + rememberLocallyPatchedRemoteWorkspaceSnapshot, + rememberRemoteWorkspaceSnapshot +} from './remote-workspace-snapshot-cache' +import { isRemoteWorkspaceResyncInFlight } from './remote-workspace-stale-resync' +import { CLIENT_ID } from './remote-workspace-client-identity' function session(activeTabId: string): RemoteWorkspaceSession { return { @@ -41,6 +52,16 @@ function session(activeTabId: string): RemoteWorkspaceSession { } } +function snapshot(revision: number, tabId: string): RemoteWorkspaceSnapshot { + return { + namespace: 'target-1', + revision, + updatedAt: revision, + schemaVersion: 1, + session: session(tabId) + } +} + describe('workspace.stale resync', () => { const sent: RemoteWorkspaceChangedEvent[] = [] const request = vi.fn() @@ -150,4 +171,271 @@ describe('workspace.stale resync', () => { await Promise.resolve() expect(sent).toHaveLength(1) }) + + it.each(['own', 'peer'] as const)( + 'checks a %s snapshot against the own patch reply received during its pending read', + async (source) => { + rememberRemoteWorkspaceSnapshot('target-1', snapshot(1, 'tab-before-patch')) + const ownSnapshot = snapshot(2, 'tab-from-own-patch') + const readSnapshot = source === 'own' ? ownSnapshot : snapshot(3, 'tab-from-peer') + request.mockResolvedValue(readSnapshot) + let releaseRead: ((value: RemoteWorkspaceSnapshot) => void) | undefined + request.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseRead = resolve + }) + ) + + handleRemoteWorkspaceNotification('target-1', REMOTE_WORKSPACE_STALE_NOTIFICATION, { + namespace: 'target-1' + }) + expect(request).toHaveBeenCalledTimes(1) + expect(isRemoteWorkspaceResyncInFlight('target-1')).toBe(true) + + // The relay publishes the stale marker before returning this client's patch reply. + rememberLocallyPatchedRemoteWorkspaceSnapshot('target-1', ownSnapshot) + releaseRead?.(readSnapshot) + await vi.waitFor(() => expect(isRemoteWorkspaceResyncInFlight('target-1')).toBe(false)) + + expect(sent.map((event) => event.snapshot.session.activeTabId)).toEqual( + source === 'own' ? [] : ['tab-from-peer'] + ) + } + ) + + it('delivers a peer snapshot cached by a stale-revision patch reply during the read', async () => { + rememberRemoteWorkspaceSnapshot('target-1', snapshot(1, 'tab-before-peer')) + const peerSnapshot = snapshot(2, 'tab-from-peer') + let releaseRead: ((value: RemoteWorkspaceSnapshot) => void) | undefined + request.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseRead = resolve + }) + ) + + handleRemoteWorkspaceNotification('target-1', REMOTE_WORKSPACE_STALE_NOTIFICATION, { + namespace: 'target-1' + }) + // A rejected local patch caches the peer snapshot, but the renderer only records a conflict. + rememberRemoteWorkspaceSnapshot('target-1', peerSnapshot) + releaseRead?.(peerSnapshot) + await vi.waitFor(() => expect(isRemoteWorkspaceResyncInFlight('target-1')).toBe(false)) + + expect(sent.map((event) => event.snapshot.session.activeTabId)).toEqual(['tab-from-peer']) + expect(request).toHaveBeenCalledTimes(1) + }) + + it('suppresses an own reply already acknowledged before the marker', async () => { + const ownSnapshot = snapshot(2, 'tab-from-own-patch') + rememberLocallyPatchedRemoteWorkspaceSnapshot('target-1', ownSnapshot) + request.mockResolvedValue(ownSnapshot) + + handleRemoteWorkspaceNotification('target-1', REMOTE_WORKSPACE_STALE_NOTIFICATION, { + namespace: 'target-1' + }) + await vi.waitFor(() => expect(isRemoteWorkspaceResyncInFlight('target-1')).toBe(false)) + + expect(sent).toEqual([]) + }) + + it('still reads a queued peer change after suppressing the own echo', async () => { + const ownSnapshot = snapshot(2, 'tab-from-own-patch') + let releaseRead: ((value: RemoteWorkspaceSnapshot) => void) | undefined + request.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseRead = resolve + }) + ) + request.mockResolvedValue(snapshot(3, 'tab-from-peer')) + + for (let index = 0; index < 2; index += 1) { + handleRemoteWorkspaceNotification('target-1', REMOTE_WORKSPACE_STALE_NOTIFICATION, { + namespace: 'target-1' + }) + } + + rememberLocallyPatchedRemoteWorkspaceSnapshot('target-1', ownSnapshot) + releaseRead?.(ownSnapshot) + await vi.waitFor(() => expect(isRemoteWorkspaceResyncInFlight('target-1')).toBe(false)) + + expect(request).toHaveBeenCalledTimes(2) + expect(sent.map((event) => event.snapshot.session.activeTabId)).toEqual(['tab-from-peer']) + }) + + it('keeps an own acknowledgement from suppressing another target', async () => { + const releases: ((value: RemoteWorkspaceSnapshot) => void)[] = [] + request.mockImplementation( + () => new Promise((resolve) => releases.push(resolve)) + ) + for (const targetId of ['target-1', 'target-2']) { + handleRemoteWorkspaceNotification(targetId, REMOTE_WORKSPACE_STALE_NOTIFICATION, { + namespace: 'target-1' + }) + } + const ownSnapshot = snapshot(2, 'same-tab') + rememberLocallyPatchedRemoteWorkspaceSnapshot('target-1', ownSnapshot) + releases[0]?.(ownSnapshot) + releases[1]?.(ownSnapshot) + await vi.waitFor(() => { + expect(isRemoteWorkspaceResyncInFlight('target-1')).toBe(false) + expect(isRemoteWorkspaceResyncInFlight('target-2')).toBe(false) + }) + + expect(sent.map((event) => event.targetId)).toEqual(['target-2']) + }) + + it('does not deliver a captured read after a same-token own acknowledgement advances the cache', async () => { + rememberRemoteWorkspaceSnapshot('target-1', snapshot(1, 'initial-tab')) + request.mockResolvedValue(snapshot(3, 'newer-own-tab')) + let releaseRead: ((value: RemoteWorkspaceSnapshot) => void) | undefined + request.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseRead = resolve + }) + ) + handleRemoteWorkspaceNotification('target-1', REMOTE_WORKSPACE_STALE_NOTIFICATION, { + namespace: 'target-1' + }) + + releaseRead?.(snapshot(2, 'peer-tab')) + queueMicrotask(() => { + rememberLocallyPatchedRemoteWorkspaceSnapshot('target-1', snapshot(3, 'newer-own-tab')) + }) + await vi.waitFor(() => expect(isRemoteWorkspaceResyncInFlight('target-1')).toBe(false)) + + expect(getCachedRemoteWorkspaceSnapshot('target-1')?.revision).toBe(3) + expect(sent.map((event) => event.snapshot.revision)).toEqual([]) + expect(request).toHaveBeenCalledTimes(2) + }) + + it.each([ + { source: 'peer', order: 'response-first' }, + { source: 'peer', order: 'notification-first' }, + { source: 'own', order: 'response-first' }, + { source: 'own', order: 'notification-first' } + ])( + 'never publishes an older read after a newer $source notification ($order)', + async ({ source, order }) => { + let receive: ((data: Buffer) => void) | undefined + const mux = new SshChannelMultiplexer({ + write: () => {}, + onData: (callback) => { + receive = callback + }, + onClose: () => {} + }) + const read = vi.spyOn(mux, 'request') + getActiveMultiplexerMock.mockReturnValue(mux) + mux.onNotification((method, params) => + handleRemoteWorkspaceNotification('target-1', method, params) + ) + const initial = rememberRemoteWorkspaceSnapshot('target-1', snapshot(2, 'initial-tab')) + const sourceClientId = source === 'own' ? CLIENT_ID : 'peer-client' + try { + handleRemoteWorkspaceNotification('target-1', REMOTE_WORKSPACE_STALE_NOTIFICATION, { + namespace: 'target-1' + }) + const reply = { jsonrpc: '2.0', id: 1, result: snapshot(2, 'older-read') } as const + const changed = { + jsonrpc: '2.0', + method: REMOTE_WORKSPACE_CHANGED_NOTIFICATION, + params: { snapshot: snapshot(3, 'newer-source'), sourceClientId } + } as const + const messages = order === 'response-first' ? [reply, changed] : [changed, reply] + receive?.( + Buffer.concat(messages.map((message, index) => encodeJsonRpcFrame(message, index + 1, 0))) + ) + if (source === 'own') { + expect(getCachedRemoteWorkspaceSnapshot('target-1')?.hostObservationToken).toBe( + initial.hostObservationToken + ) + } + await new Promise((resolve) => setImmediate(resolve)) + expect(getCachedRemoteWorkspaceSnapshot('target-1')?.revision).toBe(3) + expect(sent.map((event) => event.snapshot.revision)).toEqual([3]) + await vi.waitFor(() => expect(read).toHaveBeenCalledTimes(2)) + receive?.( + encodeJsonRpcFrame({ jsonrpc: '2.0', id: 2, result: snapshot(3, 'newer-source') }, 3, 0) + ) + await vi.waitFor(() => expect(isRemoteWorkspaceResyncInFlight('target-1')).toBe(false)) + expect(sent.map((event) => event.snapshot.revision)).toEqual([3]) + expect(sent[0].sourceClientId).toBe(sourceClientId) + } finally { + mux.dispose() + } + } + ) + + it.each([2, 3])( + 'does not deliver a captured read after peer revision %i replaces it', + async (peerRevision) => { + rememberRemoteWorkspaceSnapshot('target-1', snapshot(1, 'initial-tab')) + const peer = snapshot(peerRevision, 'newer-peer') + request.mockResolvedValue(peer) + let releaseRead: ((value: RemoteWorkspaceSnapshot) => void) | undefined + request.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseRead = resolve + }) + ) + handleRemoteWorkspaceNotification('target-1', REMOTE_WORKSPACE_STALE_NOTIFICATION, { + namespace: 'target-1' + }) + + releaseRead?.(snapshot(2, 'older-read')) + queueMicrotask(() => { + handleRemoteWorkspaceNotification('target-1', REMOTE_WORKSPACE_CHANGED_NOTIFICATION, { + snapshot: peer, + sourceClientId: 'peer-client' + }) + }) + await vi.waitFor(() => expect(isRemoteWorkspaceResyncInFlight('target-1')).toBe(false)) + + expect(getCachedRemoteWorkspaceSnapshot('target-1')?.session.activeTabId).toBe('newer-peer') + expect(sent.map((event) => event.snapshot.session.activeTabId)).toEqual(['newer-peer']) + expect(request).toHaveBeenCalledTimes(2) + } + ) + + it('accepts a relay reset after rereading a response that raced a host change', async () => { + rememberRemoteWorkspaceSnapshot('target-1', snapshot(40, 'before-reset')) + request.mockResolvedValue(snapshot(1, 'after-reset')) + let releaseRead: ((value: RemoteWorkspaceSnapshot) => void) | undefined + request.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseRead = resolve + }) + ) + handleRemoteWorkspaceNotification('target-1', REMOTE_WORKSPACE_STALE_NOTIFICATION, { + namespace: 'target-1' + }) + handleRemoteWorkspaceNotification('target-1', REMOTE_WORKSPACE_CHANGED_NOTIFICATION, { + snapshot: snapshot(41, 'last-before-reset'), + sourceClientId: 'peer-client' + }) + releaseRead?.(snapshot(1, 'after-reset')) + await vi.waitFor(() => expect(isRemoteWorkspaceResyncInFlight('target-1')).toBe(false)) + + expect(getCachedRemoteWorkspaceSnapshot('target-1')?.revision).toBe(1) + expect(sent.map((event) => event.snapshot.revision)).toEqual([41, 1]) + expect(request).toHaveBeenCalledTimes(2) + }) + + it('still accepts a lower revision after a relay reset', async () => { + rememberRemoteWorkspaceSnapshot('target-1', snapshot(40, 'before-reset')) + request.mockResolvedValue(snapshot(1, 'after-reset')) + handleRemoteWorkspaceNotification('target-1', REMOTE_WORKSPACE_STALE_NOTIFICATION, { + namespace: 'target-1' + }) + await vi.waitFor(() => expect(isRemoteWorkspaceResyncInFlight('target-1')).toBe(false)) + expect(getCachedRemoteWorkspaceSnapshot('target-1')?.revision).toBe(1) + expect(sent.map((event) => event.snapshot.session.activeTabId)).toEqual(['after-reset']) + expect(request).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/main/ipc/remote-workspace-stale-resync.ts b/src/main/ipc/remote-workspace-stale-resync.ts index 38c96977e97..93f25722df2 100644 --- a/src/main/ipc/remote-workspace-stale-resync.ts +++ b/src/main/ipc/remote-workspace-stale-resync.ts @@ -1,7 +1,11 @@ import type { RemoteWorkspaceObservedSnapshot } from '../../shared/remote-workspace-types' import type { SshTarget } from '../../shared/ssh-types' -import { getRemoteSnapshot } from './remote-workspace-relay-sync' -import { getCachedRemoteWorkspaceSnapshot } from './remote-workspace-snapshot-cache' +import { readRemoteSnapshot } from './remote-workspace-relay-sync' +import { + getCachedRemoteWorkspaceSnapshot, + remoteWorkspaceSnapshotsAreIdentical, + rememberRemoteWorkspaceSnapshot +} from './remote-workspace-snapshot-cache' import { remoteWorkspaceSessionMatchesSnapshot } from './remote-workspace-snapshot-normalization' type PendingResync = { promise: Promise; requeued: boolean } @@ -39,17 +43,48 @@ export function resyncStaleRemoteWorkspace( try { do { pending.requeued = false - const previous = getCachedRemoteWorkspaceSnapshot(target.id) - const snapshot = await getRemoteSnapshot(target) - if (!snapshot) { - return + const cachedBeforeRead = getCachedRemoteWorkspaceSnapshot(target.id) + const observation = await readRemoteSnapshot(target, (snapshot) => { + // An own patch reply can update the cache while this read is pending. + const previous = getCachedRemoteWorkspaceSnapshot(target.id) + const changedDuringRead = cachedBeforeRead + ? !remoteWorkspaceSnapshotsAreIdentical(previous, cachedBeforeRead) + : previous !== undefined + if (changedDuringRead) { + if (!remoteWorkspaceSnapshotsAreIdentical(previous, snapshot)) { + // Reread a conflicting observation; revision comparisons would reject valid relay resets. + pending.requeued = true + return null + } + // Only a same-token own ack proves the renderer has this; a stale-revision reply caches undelivered peer state. + if ( + !cachedBeforeRead || + previous?.hostObservationToken === cachedBeforeRead.hostObservationToken + ) { + return null + } + } + return { + unchanged: + !changedDuringRead && + remoteWorkspaceSessionMatchesSnapshot(previous, snapshot.session), + snapshot: rememberRemoteWorkspaceSnapshot(target.id, snapshot) + } + }) + if (!observation) { + continue } // Suppress the echo: our own patch response already cached this session, and re-publishing it // makes the renderer rehydrate a state it authored. - if (remoteWorkspaceSessionMatchesSnapshot(previous, snapshot.session)) { + if (observation.unchanged) { continue } - deliver(snapshot) + const latest = getCachedRemoteWorkspaceSnapshot(target.id) + if (!remoteWorkspaceSnapshotsAreIdentical(latest, observation.snapshot)) { + pending.requeued = true + continue + } + deliver(observation.snapshot) } while (pending.requeued) } catch (error) { onError(error) diff --git a/src/main/ipc/runtime-environment-cli-removal-routing.test.ts b/src/main/ipc/runtime-environment-cli-removal-routing.test.ts new file mode 100644 index 00000000000..d0dd7de03e2 --- /dev/null +++ b/src/main/ipc/runtime-environment-cli-removal-routing.test.ts @@ -0,0 +1,180 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { encodePairingOffer } from '../../shared/pairing' +import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' +import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' +import { + addEnvironmentFromPairingCode, + getEnvironmentStorePath, + removeEnvironment +} from '../../shared/runtime-environment-store' +import { + getPreferredPairingOffer, + type KnownRuntimeEnvironment +} from '../../shared/runtime-environments' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import { + applyRuntimeEnvironmentCapabilityVerdict, + captureRuntimeEnvironmentCapabilityEvidence, + resetRuntimeEnvironmentCapabilityEvidence, + runtimeEnvironmentCapabilityOutcome +} from './runtime-environment-capability-evidence' + +type ResponseCallbacks = { + onResponse: (response: RuntimeRpcResponse) => void +} + +const { subscribeMock, supportsMock } = vi.hoisted(() => ({ + subscribeMock: vi.fn(), + supportsMock: vi.fn() +})) + +vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } })) +vi.mock('../../shared/remote-runtime-client', async (importOriginal) => ({ + ...(await importOriginal()), + subscribeRemoteRuntimeRequest: subscribeMock +})) +vi.mock('./runtime-environment-shared-control-support', async (importOriginal) => ({ + ...(await importOriginal()), + supportsSharedControl: supportsMock +})) + +import { createRuntimeEnvironmentStatusOwner } from './runtime-environment-status-owner' +import { subscribeRuntimeEnvironment } from './runtime-environment-transport-routing' + +let userDataPath: string + +beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-cli-removal-routing-')) + resetRuntimeEnvironmentCapabilityEvidence() + subscribeMock.mockReset() + supportsMock.mockReset() +}) + +afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) +}) + +describe('runtime responses the app can no longer attribute to a saved server', () => { + it('keeps verifying status after the store stops resolving the environment', () => { + const environment = seedEnvironment() + const transport = { + isReady: () => false, + request: vi.fn(), + establish: vi.fn(), + pause: vi.fn() + } + const owner = createRuntimeEnvironmentStatusOwner(userDataPath, environment, transport) + removeEnvironment(userDataPath, environment.id) + + // A throw here escapes `void verify()` as an unhandled rejection and skips settleWaiters(), + // hanging every refresh() caller until its own 15s timeout. + expect(() => owner.acceptVerified(statusResponse())).not.toThrow() + // Teardown for a removed environment belongs to the removal watcher, not to a status reader. + expect(transport.establish).toHaveBeenCalledOnce() + owner.dispose() + }) + + it.each([ + ['direct subscription', 'browser.screencast'], + ['support-routed subscription on a host without shared control', 'files.watch'] + ])('forwards a %s response after `orca environment rm`', async (_label, method) => { + const { deliver, onEvent, environment } = await subscribed(method) + // Mirrors the CLI: it edits the store without telling the running app. + removeEnvironment(userDataPath, environment.id) + const response = { + id: method, + ok: true as const, + result: {}, + _meta: { runtimeId: 'r' } + } + + expect(() => deliver(response)).not.toThrow() + expect(onEvent).toHaveBeenCalledWith({ type: 'response', response }) + }) + + it.each([ + ['direct subscription', 'browser.screencast'], + ['support-routed subscription on a host without shared control', 'files.watch'] + ])('forwards a %s response when the store file is unreadable', async (_label, method) => { + const { deliver, onEvent } = await subscribed(method) + // Any store failure is fatal on this path, not just a removal: a half-written or + // ACL-hardened settings file reaches the same unguarded socket callback. + writeFileSync(getEnvironmentStorePath(userDataPath), '{not json') + const response = { + id: method, + ok: true as const, + result: {}, + _meta: { runtimeId: 'r' } + } + + expect(() => deliver(response)).not.toThrow() + expect(onEvent).toHaveBeenCalledWith({ type: 'response', response }) + }) +}) + +function statusResponse() { + return { + id: 'status.get', + ok: true as const, + result: { + runtimeId: 'r', + rendererGraphEpoch: 1, + graphStatus: 'ready' as const, + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY] + }, + _meta: { runtimeId: 'r' } + } +} + +async function subscribed(method: string) { + const environment = seedEnvironment() + supportsMock.mockResolvedValue(absentOutcome(environment)) + let callbacks: ResponseCallbacks | null = null + subscribeMock.mockImplementation( + async (_pairing, _method, _params, _timeoutMs, received: ResponseCallbacks) => { + callbacks = received + return { close: () => {} } satisfies Partial + } + ) + const onEvent = vi.fn() + await subscribeRuntimeEnvironment(userDataPath, environment.id, method, {}, 1000, { + onEvent, + onClose: () => {} + }) + return { + deliver: (response: RuntimeRpcResponse) => callbacks?.onResponse(response), + onEvent, + environment + } +} + +function seedEnvironment(): KnownRuntimeEnvironment { + return addEnvironmentFromPairingCode(userDataPath, { + name: 'dev box', + pairingCode: encodePairingOffer({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'device-token', + publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64') + }) + }) +} + +function absentOutcome(environment: KnownRuntimeEnvironment) { + const evidence = captureRuntimeEnvironmentCapabilityEvidence( + environment.id, + getPreferredPairingOffer(environment) + ) + applyRuntimeEnvironmentCapabilityVerdict({ + evidence, + verdict: 'absent', + runtimeId: 'r' + }) + return runtimeEnvironmentCapabilityOutcome(evidence, 'absent', 'r') +} diff --git a/src/main/ipc/runtime-environment-status-owner.ts b/src/main/ipc/runtime-environment-status-owner.ts index 4ac3c067f74..26b5264ef9f 100644 --- a/src/main/ipc/runtime-environment-status-owner.ts +++ b/src/main/ipc/runtime-environment-status-owner.ts @@ -8,7 +8,7 @@ import { getPreferredPairingOffer, type KnownRuntimeEnvironment } from '../../shared/runtime-environments' -import { markEnvironmentUsed } from '../../shared/runtime-environment-store' +import { recordRuntimeEnvironmentUsage } from './runtime-environment-usage-record' import { RuntimeHostStatusOwner } from '../../shared/runtime-host-status-owner' import { RUNTIME_HOST_STATUS_CHANNEL, @@ -61,7 +61,7 @@ export function createRuntimeEnvironmentStatusOwner( runtimeId: response._meta.runtimeId }) if (accepted && active && !isRuntimeEnvironmentManuallyDisconnected(environment.id)) { - markEnvironmentUsed(userDataPath, environment.id, { + recordRuntimeEnvironmentUsage(userDataPath, environment.id, { runtimeId: response._meta.runtimeId, pairedDeviceId: response.result.pairedDeviceId }) diff --git a/src/main/ipc/runtime-environment-support-routing.ts b/src/main/ipc/runtime-environment-support-routing.ts index 9566b2fc1b7..f81d045a568 100644 --- a/src/main/ipc/runtime-environment-support-routing.ts +++ b/src/main/ipc/runtime-environment-support-routing.ts @@ -7,6 +7,7 @@ import type { import type { KnownRuntimeEnvironment } from '../../shared/runtime-environments' import { getPreferredPairingOffer } from '../../shared/runtime-environments' import { markEnvironmentUsed, resolveEnvironment } from '../../shared/runtime-environment-store' +import { recordRuntimeEnvironmentUsage } from './runtime-environment-usage-record' import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version' import { subscribeRemoteRuntimeRequest, @@ -262,7 +263,7 @@ function subscriptionCallbacks( return { onResponse: (response: RuntimeRpcResponse) => { if (response.ok && shouldMarkUsed()) { - markEnvironmentUsed(args.userDataPath, args.environment.id, { + recordRuntimeEnvironmentUsage(args.userDataPath, args.environment.id, { runtimeId: response._meta.runtimeId }) } diff --git a/src/main/ipc/runtime-environment-transport-routing.ts b/src/main/ipc/runtime-environment-transport-routing.ts index f39962c20cb..327f1428ea7 100644 --- a/src/main/ipc/runtime-environment-transport-routing.ts +++ b/src/main/ipc/runtime-environment-transport-routing.ts @@ -1,6 +1,7 @@ import { getPreferredPairingOffer } from '../../shared/runtime-environments' import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version' import { resolveEnvironment, markEnvironmentUsed } from '../../shared/runtime-environment-store' +import { recordRuntimeEnvironmentUsage } from './runtime-environment-usage-record' import { isOrchestrationMutation } from '../../shared/orchestration-rpc-contract' import type { RuntimeOrchestrationEnvelope, @@ -195,7 +196,7 @@ export async function subscribeRuntimeEnvironment( return } markedUsed = true - markEnvironmentUsed(userDataPath, environment.id, { runtimeId }) + recordRuntimeEnvironmentUsage(userDataPath, environment.id, { runtimeId }) } const callbacksWithMarkUsed = { onResponse: (response: RuntimeRpcResponse) => { diff --git a/src/main/ipc/runtime-environment-usage-record.ts b/src/main/ipc/runtime-environment-usage-record.ts new file mode 100644 index 00000000000..af56ede3a30 --- /dev/null +++ b/src/main/ipc/runtime-environment-usage-record.ts @@ -0,0 +1,26 @@ +import { markEnvironmentUsed } from '../../shared/runtime-environment-store' + +/** + * Records `lastUsedAt` for callers that cannot report a failure to anyone. + * + * Why: subscription frames arrive on a websocket callback with no promise to reject and no + * caller to observe the result, so a throw from the store unwinds into the socket emitter and + * kills the main process. `orca environment rm` makes that routine — it edits the store behind + * the running app's back, so every later response resolves an environment that is gone. Usage + * bookkeeping has no consumer, so nothing here is worth an exception. Awaited request paths keep + * calling `markEnvironmentUsed` directly: there the rejection is observable and correct. + */ +export function recordRuntimeEnvironmentUsage( + userDataPath: string, + selector: string, + args: { runtimeId?: string | null; pairedDeviceId?: string } = {} +): void { + try { + markEnvironmentUsed(userDataPath, selector, args) + } catch (error) { + console.warn( + `Skipped last-used bookkeeping for runtime environment ${selector}:`, + error instanceof Error ? error.message : error + ) + } +} diff --git a/src/main/ipc/sender-scoped-request-cancellation.test.ts b/src/main/ipc/sender-scoped-request-cancellation.test.ts new file mode 100644 index 00000000000..e8bfc6b8d6b --- /dev/null +++ b/src/main/ipc/sender-scoped-request-cancellation.test.ts @@ -0,0 +1,148 @@ +import { EventEmitter } from 'node:events' +import type { IpcMainInvokeEvent } from 'electron' +import { describe, expect, it } from 'vitest' +import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation' + +function requestEvent(id = 1) { + const sender = Object.assign(new EventEmitter(), { id }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The registry only reads sender.id and its EventEmitter lifetime methods. + const event = { sender } as unknown as IpcMainInvokeEvent + return { sender, event } +} + +const lifetimeEvents = ['destroyed', 'render-process-gone', 'did-navigate'] as const + +describe('sender-scoped request lifetime', () => { + it.each(lifetimeEvents)( + 'aborts every owned request on %s and preserves another sender', + (name) => { + const registry = createSenderScopedRequestCancellations() + const first = requestEvent(1) + const second = requestEvent(2) + const firstRequests = ['one', 'two'].map((token) => registry.begin(first.event, token)) + const secondRequest = registry.begin(second.event, 'one') + + first.sender.emit(name) + + expect(firstRequests.map((request) => request?.signal.aborted)).toEqual([true, true]) + expect(secondRequest?.signal.aborted).toBe(false) + for (const eventName of lifetimeEvents) { + expect(first.sender.listenerCount(eventName)).toBe(0) + } + registry.finish(second.event, 'one', secondRequest) + } + ) + + it('keeps only one set of lifecycle listeners for concurrent requests', () => { + const registry = createSenderScopedRequestCancellations() + const { event, sender } = requestEvent() + const controllers = Array.from({ length: 100 }, (_, index) => registry.begin(event, `${index}`)) + + for (const name of lifetimeEvents) { + expect(sender.listenerCount(name)).toBe(1) + } + controllers.forEach((controller, index) => registry.finish(event, `${index}`, controller)) + for (const name of lifetimeEvents) { + expect(sender.listenerCount(name)).toBe(0) + } + expect(controllers.some((controller) => controller?.signal.aborted)).toBe(false) + }) + + it('does not accumulate live requests across repeated document replacements', () => { + const registry = createSenderScopedRequestCancellations() + const { event, sender } = requestEvent() + const controllers: AbortController[] = [] + for (let generation = 0; generation < 16; generation++) { + if (generation > 0) { + sender.emit('did-navigate') + } + for (let request = 0; request < 100; request++) { + const controller = registry.begin(event, `${generation}:${request}`) + if (controller) { + controllers.push(controller) + } + } + } + + expect(controllers.filter((controller) => !controller.signal.aborted)).toHaveLength(100) + for (const name of lifetimeEvents) { + expect(sender.listenerCount(name)).toBe(1) + } + sender.emit('destroyed') + expect(controllers.every((controller) => controller.signal.aborted)).toBe(true) + }) + + it('preserves requests across same-document or prevented navigation', () => { + const registry = createSenderScopedRequestCancellations() + const { event, sender } = requestEvent() + const controller = registry.begin(event, 'one') + + sender.emit('did-start-navigation') + sender.emit('will-navigate', { defaultPrevented: true }) + sender.emit('did-navigate-in-page') + + expect(controller?.signal.aborted).toBe(false) + registry.finish(event, 'one', controller) + }) + + it('keeps the replacement when an old request finishes late', () => { + const registry = createSenderScopedRequestCancellations() + const { event, sender } = requestEvent() + const old = registry.begin(event, 'one') + const replacement = registry.begin(event, 'one') + expect(old?.signal.aborted).toBe(true) + + registry.finish(event, 'one', old) + registry.cancel(event, 'one') + + expect(replacement?.signal.aborted).toBe(true) + registry.finish(event, 'one', replacement) + for (const name of lifetimeEvents) { + expect(sender.listenerCount(name)).toBe(0) + } + }) + + it('does not let a prior document finish remove the current document owner', () => { + const registry = createSenderScopedRequestCancellations() + const { event, sender } = requestEvent() + const old = registry.begin(event, 'one') + sender.emit('did-navigate') + const current = registry.begin(event, 'one') + + registry.finish(event, 'one', old) + expect(current?.signal.aborted).toBe(false) + sender.emit('render-process-gone') + + expect(current?.signal.aborted).toBe(true) + }) + + it('allows synchronous finish callbacks while aborting the owner', () => { + const registry = createSenderScopedRequestCancellations() + const { event, sender } = requestEvent() + const first = registry.begin(event, 'one') + const second = registry.begin(event, 'two') + first?.signal.addEventListener('abort', () => registry.finish(event, 'one', first)) + second?.signal.addEventListener('abort', () => registry.finish(event, 'two', second)) + + sender.emit('destroyed') + + expect(first?.signal.aborted).toBe(true) + expect(second?.signal.aborted).toBe(true) + for (const name of lifetimeEvents) { + expect(sender.listenerCount(name)).toBe(0) + } + }) + + it('preserves the no-token opt-out and ignores unknown cancellation', () => { + const registry = createSenderScopedRequestCancellations() + const { event, sender } = requestEvent() + + expect(registry.begin(event, undefined)).toBeNull() + expect(registry.begin(event, '')).toBeNull() + registry.finish(event, undefined, null) + registry.cancel(event, 'unknown') + for (const name of lifetimeEvents) { + expect(sender.listenerCount(name)).toBe(0) + } + }) +}) diff --git a/src/main/ipc/sender-scoped-request-cancellation.ts b/src/main/ipc/sender-scoped-request-cancellation.ts index 9700be9c3da..229bc33c7fd 100644 --- a/src/main/ipc/sender-scoped-request-cancellation.ts +++ b/src/main/ipc/sender-scoped-request-cancellation.ts @@ -1,4 +1,5 @@ import type { IpcMainInvokeEvent } from 'electron' +import { abortWhenRendererGone } from './renderer-lifetime-abort' export type SenderScopedRequestCancellations = { /** Registers a cancellable request; aborts any previous request that reused the token. */ @@ -13,38 +14,67 @@ export type SenderScopedRequestCancellations = { cancel: (event: IpcMainInvokeEvent, requestToken: string) => void } -/** - * Registry for renderer-cancellable IPC requests. Keys are scoped to the - * issuing webContents so one window's token can never cancel another window's - * request, and reusing a token aborts the previous request before the new one - * registers. - */ +type SenderRequests = { + controllers: Map + lifetime: ReturnType +} + +/** Requests belong to the issuing document; one window cannot cancel another's work. */ export function createSenderScopedRequestCancellations(): SenderScopedRequestCancellations { - const controllers = new Map() - const keyFor = (event: IpcMainInvokeEvent, requestToken: string): string => - `${event.sender.id}\0${requestToken}` + const senders = new Map() + const release = (senderId: number, requests: SenderRequests): void => { + if (senders.get(senderId) === requests) { + senders.delete(senderId) + } + requests.lifetime.dispose() + } + const requestsFor = (event: IpcMainInvokeEvent): SenderRequests => { + const senderId = event.sender.id + let requests = senders.get(senderId) + if (!requests) { + const lifetime = abortWhenRendererGone(event.sender) + const owned: SenderRequests = { controllers: new Map(), lifetime } + senders.set(senderId, owned) + lifetime.signal.addEventListener( + 'abort', + () => { + // Detach before abort callbacks can finish old requests or register new ones. + release(senderId, owned) + for (const controller of owned.controllers.values()) { + controller.abort() + } + owned.controllers.clear() + }, + { once: true } + ) + requests = owned + } + return requests + } return { begin: (event, requestToken) => { if (!requestToken) { return null } - const key = keyFor(event, requestToken) - controllers.get(key)?.abort() + senders.get(event.sender.id)?.controllers.get(requestToken)?.abort() const controller = new AbortController() - controllers.set(key, controller) + requestsFor(event).controllers.set(requestToken, controller) return controller }, finish: (event, requestToken, controller) => { if (!requestToken || !controller) { return } - const key = keyFor(event, requestToken) - if (controllers.get(key) === controller) { - controllers.delete(key) + const requests = senders.get(event.sender.id) + if (requests?.controllers.get(requestToken) === controller) { + requests.controllers.delete(requestToken) + if (requests.controllers.size === 0) { + release(event.sender.id, requests) + } } }, cancel: (event, requestToken) => { - controllers.get(keyFor(event, requestToken))?.abort() + senders.get(event.sender.id)?.controllers.get(requestToken)?.abort() } } } diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index 9fdfd8214b2..f1f0c1b14e1 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -1,11 +1,13 @@ import { ipcMain } from 'electron' import type { Store } from '../persistence' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import { parseTerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target' import type { WorkspaceSessionPatch, WorkspaceSessionState } from '../../shared/workspace-session-state-types' -export function registerSessionHandlers(store: Store): void { +export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeService): void { // Why: hostId is an optional second arg so an older renderer that invokes // these channels without it keeps reading/writing the 'local' partition // exactly as before. Channel names stay stable. @@ -28,20 +30,42 @@ export function registerSessionHandlers(store: Store): void { store.patchWorkspaceSession(args, hostId) }) + // Why: a renderer save cannot shrink membership main owns, so each close commits it explicitly. + ipcMain.handle( + 'session:close-terminal-surface', + (_event, args: { worktreeId?: unknown; target?: unknown; reason?: unknown } | undefined) => { + const target = parseTerminalSurfaceCloseTarget(args?.target) + if (typeof args?.worktreeId !== 'string' || !target) { + throw new Error('invalid_terminal_surface') + } + // Why only these two: main alone closes a tab for its process exit. + return runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: args.worktreeId, + target, + reason: args.reason === 'cleanup' ? 'cleanup' : 'user' + }) + } + ) + ipcMain.handle('session:flush', () => { // Why: durable lifecycle RPCs must propagate disk failures instead of // returning success through Store.flush(), which intentionally only logs. - store.flushOrThrow() + return store.flushPendingOrThrowAsync() }) - // Synchronous variant for the renderer's beforeunload handler. - // sendSync blocks the renderer until this returns, guaranteeing the - // data (including terminal scrollback buffers) is persisted to disk - // before the window closes — regardless of before-quit ordering. + // Older renderers block on the reply; main remains free to await the writer. ipcMain.on('session:set-sync', (event, args: WorkspaceSessionState, hostId?: string | null) => { - store.setWorkspaceSession(args, hostId) - store.flush() - event.returnValue = true + void (async () => { + try { + store.setWorkspaceSession(args, hostId) + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + } catch (error) { + console.error('[persistence] Failed to flush legacy session checkpoint:', error) + } finally { + // This legacy response has always been best effort, including on disk errors. + event.returnValue = true + } + })() }) ipcMain.on( diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index f3c1b8aeaf8..d2a79ef1885 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -12,6 +12,7 @@ import { SETTINGS_CHANGED_WHITELIST, type SettingsChangedKey } from '../../share import type { AgentAwakeService } from '../agent-awake-service' import { sanitizeFloatingWorkspaceDirectorySetting } from './floating-workspace-directory' import { applyAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { isAgentStatusHooksEnabledForAgent } from '../../shared/agent-status-hooks-setting' import { recordManagedHookInstallFailure } from '../agent-hooks/install-telemetry' import { applyElectronProxySettings } from '../network/proxy-settings' import { applyBrowserSessionProxies } from '../browser/browser-session-proxy' @@ -242,13 +243,7 @@ export function registerSettingsHandlers( userInitiated: true, shouldHydrateShellPath: app.isPackaged, onInstallError: recordManagedHookInstallFailure, - shouldContinue: (agent) => { - const settings = store.getSettings() - return ( - settings.agentStatusHooksEnabled !== false && - !settings.disabledTuiAgents.includes(agent) - ) - } + shouldContinue: (agent) => isAgentStatusHooksEnabledForAgent(store.getSettings(), agent) }) } catch (error) { console.warn('[settings] failed to reconcile managed agent hooks:', error) diff --git a/src/main/ipc/shallow-watch-delivery-probe-lifetime.test.ts b/src/main/ipc/shallow-watch-delivery-probe-lifetime.test.ts new file mode 100644 index 00000000000..6ca70330518 --- /dev/null +++ b/src/main/ipc/shallow-watch-delivery-probe-lifetime.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const h = vi.hoisted(() => ({ + watch: vi.fn(), + mkdir: vi.fn(), + write: vi.fn(), + remove: vi.fn(), + close: vi.fn(), + on: vi.fn(), + deliver: () => {}, + fail: () => {} +})) + +vi.mock('node:fs', () => ({ watch: h.watch })) +vi.mock('node:fs/promises', () => ({ + mkdtemp: h.mkdir, + writeFile: h.write, + rm: h.remove +})) + +import { + detectShallowWatchDelivery, + measureShallowWatchDelivery, + resetShallowWatchDeliveryProbeForTests +} from './shallow-watch-delivery-probe' + +beforeEach(() => { + vi.useFakeTimers() + vi.resetAllMocks() + resetShallowWatchDeliveryProbeForTests() + h.deliver = () => {} + h.fail = () => {} + h.mkdir.mockResolvedValue('/fake-shallow-probe') + h.write.mockResolvedValue(undefined) + h.remove.mockResolvedValue(undefined) + h.on.mockImplementation((_event: string, callback: () => void) => { + h.fail = callback + }) + h.watch.mockImplementation((_path: string, _options: unknown, callback: () => void) => { + h.deliver = callback + return { on: h.on, close: h.close } + }) +}) + +afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + resetShallowWatchDeliveryProbeForTests() +}) + +function expectReleased(): void { + expect(h.close).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + expect(h.remove).toHaveBeenCalledExactlyOnceWith('/fake-shallow-probe', { + recursive: true, + force: true + }) +} + +describe('shallow probe resource ownership', () => { + it.each([1, 2])('closes the watcher and clears the timer when write %i fails', async (write) => { + if (write === 2) { + h.write.mockResolvedValueOnce(undefined) + } + h.write.mockRejectedValueOnce(new Error('ENOSPC')) + + await expect(measureShallowWatchDelivery()).resolves.toBe(false) + + expect(h.write).toHaveBeenCalledTimes(write) + expectReleased() + expect(h.close.mock.invocationCallOrder[0]).toBeLessThan(h.remove.mock.invocationCallOrder[0]) + }) + + it('releases resources after successful delivery', async () => { + h.write.mockImplementation(async () => h.deliver()) + + await expect(measureShallowWatchDelivery()).resolves.toBe(true) + + expect(h.write).toHaveBeenCalledTimes(2) + expectReleased() + }) + + it('releases resources after the delivery deadline', async () => { + const result = measureShallowWatchDelivery(20) + await vi.advanceTimersByTimeAsync(20) + + await expect(result).resolves.toBe(false) + expectReleased() + }) + + it('releases resources when the watcher reports an error', async () => { + h.write.mockImplementation(async () => h.fail()) + + await expect(measureShallowWatchDelivery()).resolves.toBe(false) + expectReleased() + }) + + it.each(['delivery', 'write failure'])( + 'preserves false fallback when close throws after %s', + async (outcome) => { + h.close.mockImplementation(() => { + throw new Error('close failed') + }) + if (outcome === 'delivery') { + h.write.mockImplementation(async () => h.deliver()) + } else { + h.write.mockRejectedValueOnce(new Error('write failed')) + } + + await expect(measureShallowWatchDelivery()).resolves.toBe(false) + expectReleased() + } + ) + + it('removes the directory when watcher creation fails', async () => { + h.watch.mockImplementation(() => { + throw new Error('EMFILE') + }) + + await expect(measureShallowWatchDelivery()).resolves.toBe(false) + + expect(h.close).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + expect(h.remove).toHaveBeenCalledOnce() + }) + + it('keeps successful delivery when temporary-directory removal fails', async () => { + h.write.mockImplementation(async () => h.deliver()) + h.remove.mockRejectedValue(new Error('cleanup failed')) + + await expect(measureShallowWatchDelivery()).resolves.toBe(true) + expectReleased() + }) + + it('caches a failed probe once per process after releasing its resources', async () => { + h.write.mockRejectedValueOnce(new Error('write failed')) + + await expect(detectShallowWatchDelivery()).resolves.toBe(false) + await expect(detectShallowWatchDelivery()).resolves.toBe(false) + + expect(h.watch).toHaveBeenCalledOnce() + expect(h.write).toHaveBeenCalledOnce() + expectReleased() + }) +}) diff --git a/src/main/ipc/shallow-watch-delivery-probe.ts b/src/main/ipc/shallow-watch-delivery-probe.ts index ed32ff39ab1..09970e4c082 100644 --- a/src/main/ipc/shallow-watch-delivery-probe.ts +++ b/src/main/ipc/shallow-watch-delivery-probe.ts @@ -19,17 +19,22 @@ export async function measureShallowWatchDelivery(timeoutMs = PROBE_TIMEOUT_MS): directory = await mkdtemp(join(tmpdir(), 'orca-shallow-probe-')) const { promise, resolve } = Promise.withResolvers() const watcher = watch(directory, { persistent: false }, () => resolve(true)) - watcher.on('error', () => resolve(false)) - // Deliberately not unref'd: this one-shot must resolve even if the child - // has no other pending work at probe time. - const timer = setTimeout(() => resolve(false), timeoutMs) - // Two writes: some backends coalesce the creation of the first entry. - await writeFile(join(directory, 'probe'), '1') - await writeFile(join(directory, 'probe'), '2') - const delivered = await promise - clearTimeout(timer) - watcher.close() - return delivered + let timer: ReturnType | undefined + try { + watcher.on('error', () => resolve(false)) + // Deliberately not unref'd: this one-shot must resolve even if the child + // has no other pending work at probe time. + timer = setTimeout(() => resolve(false), timeoutMs) + // Two writes: some backends coalesce the creation of the first entry. + await writeFile(join(directory, 'probe'), '1') + await writeFile(join(directory, 'probe'), '2') + return await promise + } finally { + if (timer !== undefined) { + clearTimeout(timer) + } + watcher.close() + } } catch { return false } finally { diff --git a/src/main/ipc/speech-worker-exit-lifetime.test.ts b/src/main/ipc/speech-worker-exit-lifetime.test.ts new file mode 100644 index 00000000000..dc0cfb4a210 --- /dev/null +++ b/src/main/ipc/speech-worker-exit-lifetime.test.ts @@ -0,0 +1,288 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' +import type { ModelManager } from '../speech/model-manager' +import type { SttService } from '../speech/stt-service' +import type { RuntimeStore } from '../runtime/runtime-store-contract' + +type SpeechHandler = ( + event: { sender: { id: number } }, + modelOrSession: string, + hotwords?: string[], + sessionId?: string +) => Promise + +type TestWorker = EventEmitter & { + emitStoppedOnStop: boolean + postMessage: (message: { type: string }) => void +} + +const environment = vi.hoisted( + (): { + handlers: Map + window: TestWindow | null + service: SttService | null + workers: TestWorker[] + readyOnInit: boolean + } => ({ + handlers: new Map(), + window: null, + service: null, + workers: [], + readyOnInit: true + }) +) + +vi.mock('electron', () => ({ + ipcMain: { + handle: (name: string, callback: SpeechHandler) => environment.handlers.set(name, callback) + }, + BrowserWindow: { fromWebContents: () => environment.window }, + systemPreferences: { + getMediaAccessStatus: () => 'granted', + askForMediaAccess: async () => true + } +})) +vi.mock('../speech/speech-runtime-service', () => ({ + getSpeechModelManager: () => ({ getModelState: async () => ({ status: 'ready' }) }), + getSpeechSttService: () => environment.service +})) +vi.mock('../speech/openai-api-key-store', () => ({ + readOpenAiSpeechApiKey: vi.fn(), + clearOpenAiSpeechApiKey: vi.fn(), + hasOpenAiSpeechApiKey: () => false, + saveOpenAiSpeechApiKey: vi.fn() +})) +vi.mock('../speech/model-catalog', () => ({ + SPEECH_MODEL_CATALOG: [], + getCatalogModel: () => ({ + id: 'test-model', + provider: 'local', + type: 'transducer', + streaming: true, + sampleRate: 16000, + files: [] + }) +})) +vi.mock('../speech/stt-worker-paths', () => ({ + getSttWorkerPath: () => 'unused-worker', + getSherpaModulePath: () => 'unused-module' +})) +vi.mock('node:worker_threads', async () => { + const { EventEmitter: WorkerEvents } = await import('node:events') + return { + Worker: class extends WorkerEvents implements TestWorker { + emitStoppedOnStop = true + constructor() { + super() + environment.workers.push(this) + } + postMessage(message: { type: string }): void { + if (message.type === 'init' && environment.readyOnInit) { + queueMicrotask(() => this.emit('message', { type: 'ready' })) + } + if (message.type === 'stop' && this.emitStoppedOnStop) { + queueMicrotask(() => this.emit('message', { type: 'stopped' })) + } + } + async terminate(): Promise { + this.emit('exit', 0) + return 0 + } + } + } +}) + +import { registerSpeechHandlers } from './speech' +import { SttService as SpeechService } from '../speech/stt-service' +import { RuntimeMobileDictationController } from '../runtime/runtime-mobile-dictation-controller' + +class TestWindow extends EventEmitter { + destroyed = false + webContents = { send: vi.fn() } + isDestroyed(): boolean { + return this.destroyed + } +} + +function handler(name: string): SpeechHandler { + const callback = environment.handlers.get(name) + if (!callback) { + throw new Error(`Missing handler: ${name}`) + } + return callback +} + +function worker(): TestWorker { + const current = environment.workers.at(-1) + if (!current) { + throw new Error('No worker created') + } + return current +} + +function service(): SttService { + if (!environment.service) { + throw new Error('No service created') + } + return environment.service +} + +const sender = { sender: { id: 9 } } +const start = (session = 'one'): Promise => + handler('speech:startDictation')(sender, 'test-model', undefined, session) +const stop = (session = 'one'): Promise => handler('speech:stopDictation')(sender, session) + +describe('speech worker exit ownership', () => { + let window: TestWindow + beforeEach(() => { + environment.handlers.clear() + environment.workers = [] + environment.readyOnInit = true + window = new TestWindow() + environment.window = window + const modelManager: Pick = { + getModelState: async () => ({ id: 'test-model', status: 'ready' as const }), + getModelDir: () => 'unused-model' + } + environment.service = new SpeechService( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Local startup reads only these two model-manager methods. + modelManager as ModelManager + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Mocked runtime accessors do not read the store. + registerSpeechHandlers({} as Store) + }) + afterEach(async () => { + window.destroyed = true + window.emit('closed') + await Promise.resolve() + await Promise.resolve() + await service().prepareModelForDeletion('test-model') + }) + + it.each([0, 1])('releases the window listener after unexpected exit code %i', async (code) => { + for (let cycle = 0; cycle < 15; cycle++) { + await start(String(cycle)) + expect(window.listenerCount('closed')).toBe(1) + const current = worker() + current.emit('exit', code) + expect(service().isActive()).toBe(false) + expect(window.listenerCount('closed')).toBe(0) + expect(current.listenerCount('message')).toBe(0) + expect(current.listenerCount('error')).toBe(0) + expect(current.listenerCount('exit')).toBe(0) + await stop(String(cycle)) + } + expect( + window.webContents.send.mock.calls.filter(([name]) => name === 'speech:stopped') + ).toHaveLength(15) + }) + + it('does not duplicate the in-flight stop notification', async () => { + await start() + worker().emitStoppedOnStop = false + const pendingStop = stop() + worker().emit('exit', 1) + await pendingStop + expect(window.listenerCount('closed')).toBe(0) + expect( + window.webContents.send.mock.calls.filter(([name]) => name === 'speech:stopped') + ).toHaveLength(1) + }) + + it('keeps a normal stop reusable and ignores a later idle-worker exit', async () => { + await start() + const current = worker() + await stop() + expect(window.listenerCount('closed')).toBe(0) + await start('two') + expect(worker()).toBe(current) + await stop('two') + current.emit('exit', 0) + expect( + window.webContents.send.mock.calls.filter(([name]) => name === 'speech:stopped') + ).toHaveLength(2) + }) + + it('cleans a failed startup and allows a new session', async () => { + environment.readyOnInit = false + const pendingStart = start() + const rejected = expect(pendingStart).rejects.toThrow('Speech worker exited before ready: 1') + await Promise.resolve() + worker().emit('exit', 1) + await rejected + expect(window.listenerCount('closed')).toBe(0) + environment.readyOnInit = true + await start('two') + expect(service().isActive()).toBe(true) + await stop('two') + }) + + it('preserves error delivery and listener cleanup', async () => { + await start() + const current = worker() + current.emit('error', new Error('synthetic failure')) + await vi.waitFor(() => expect(window.listenerCount('closed')).toBe(0)) + expect(window.webContents.send).toHaveBeenCalledWith('speech:error', { + error: 'Error: synthetic failure', + sessionId: 'one' + }) + current.emit('exit', 1) + expect(service().isActive()).toBe(false) + }) + + it('preserves startup cancellation without duplicate stopped events', async () => { + environment.readyOnInit = false + const pendingStart = start() + const rejected = expect(pendingStart).rejects.toThrow('Speech worker exited before ready: 1') + await Promise.resolve() + worker().emitStoppedOnStop = false + const pendingStop = stop() + worker().emit('exit', 1) + await Promise.all([pendingStop, rejected]) + expect(window.listenerCount('closed')).toBe(0) + expect( + window.webContents.send.mock.calls.filter(([name]) => name === 'speech:stopped') + ).toHaveLength(1) + }) + + it('preserves mobile partial text and completion after worker exit', async () => { + const store = { getSettings: () => ({ voice: { enabled: true, sttModel: 'test-model' } }) } + const controller = new RuntimeMobileDictationController( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The controller reads settings; mocked speech accessors ignore the rest of the store. + () => store as RuntimeStore + ) + const params = { dictationId: 'mobile', clientId: 'client', connectionId: 'connection' } + await controller.start(params) + worker().emit('message', { type: 'partial', text: 'keep these words' }) + worker().emit('exit', 0) + await expect(controller.finish(params)).resolves.toEqual({ + dictationId: 'mobile', + text: 'keep these words' + }) + expect(service().isActive()).toBe(false) + }) + + it('clears old ownership before notifying a sink that immediately starts again', async () => { + let restarted: Promise | undefined + await service().startDictation( + 'test-model', + (event) => { + if (event.type === 'stopped') { + restarted = service().startDictation('test-model', vi.fn(), undefined, 'replacement') + } + }, + undefined, + 'original' + ) + const retired = worker() + retired.emit('exit', 0) + expect(restarted).toBeDefined() + await restarted + expect(worker()).not.toBe(retired) + retired.emit('exit', 0) + retired.emit('message', { type: 'stopped' }) + expect(service().isActive()).toBe(true) + await service().stopDictation('replacement') + }) +}) diff --git a/src/main/ipc/ssh-connection-state-callbacks.ts b/src/main/ipc/ssh-connection-state-callbacks.ts index e42b164cae8..5daa25ed2b1 100644 --- a/src/main/ipc/ssh-connection-state-callbacks.ts +++ b/src/main/ipc/ssh-connection-state-callbacks.ts @@ -120,9 +120,9 @@ export function handleSshConnectionStateChange(targetId: string, state: SshConne export function createSshConnectionCallbacks(): SshConnectionCallbacks { return { - onCredentialRequest: (targetId, kind, detail, signal) => { + onCredentialRequest: (targetId, kind, detail, echo, signal) => { credentialRequestedForTarget.add(targetId) - return requestCredential(getCurrentMainWindow, targetId, kind, detail, signal) + return requestCredential(getCurrentMainWindow, targetId, kind, detail, echo, signal) }, onStateChange: handleSshConnectionStateChange } diff --git a/src/main/ipc/ssh-host-partition-session-export.test.ts b/src/main/ipc/ssh-host-partition-session-export.test.ts index 39a85658ae0..1ec5167f866 100644 --- a/src/main/ipc/ssh-host-partition-session-export.test.ts +++ b/src/main/ipc/ssh-host-partition-session-export.test.ts @@ -1,3 +1,4 @@ +import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' /** * What the remote-workspace export publishes when the renderer omits `session`, against the real * `Store`. @@ -10,7 +11,7 @@ * Drives the real `Store` rather than a `getWorkspaceSession` fake: the whole defect is which * partition the read reaches, and a fake answers whatever the test tells it to. */ -import { mkdtempSync, realpathSync } from 'node:fs' +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -105,12 +106,13 @@ function runtimeAuthoredTab(): TerminalTab { } } -const stores: InstanceType[] = [] +const directories: string[] = [] let hostSnapshot: RemoteWorkspaceSnapshot -afterEach(() => { - for (const store of stores.splice(0)) { - store.flush() +afterEach(async () => { + await closeTestStores() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) } vi.restoreAllMocks() }) @@ -162,8 +164,8 @@ beforeEach(() => { * local blob still carries the worktree key with an empty list. */ function createStrandedStore(): InstanceType { const dir = realpathSync(mkdtempSync(join(tmpdir(), 'orca-ssh-partition-export-'))) - const store = new Store({ dataFile: join(dir, 'orca-data.json') }) - stores.push(store) + directories.push(dir) + const store = createSqliteTestStore(Store, { dataFile: join(dir, 'orca-data.json') }) store.addRepo(remoteRepo(REPO_ID, '/remote/checkout', TARGET_ID)) // A second populated SSH partition: the fallback has to reach the publishing target's own // partition, not merely "some" partition that happens to hold tabs. diff --git a/src/main/ipc/ssh-passphrase.test.ts b/src/main/ipc/ssh-passphrase.test.ts index 560ddd6d6f3..f3d2a65f9ed 100644 --- a/src/main/ipc/ssh-passphrase.test.ts +++ b/src/main/ipc/ssh-passphrase.test.ts @@ -25,6 +25,7 @@ describe('SSH credential requests', () => { 'target-1', 'keyboard-interactive', 'Duo response', + undefined, controller.signal ) const request = vi.mocked(window.webContents.send).mock.calls[0][1] as { requestId: string } diff --git a/src/main/ipc/ssh-passphrase.ts b/src/main/ipc/ssh-passphrase.ts index 6de7d483bcd..abebfa3a3ae 100644 --- a/src/main/ipc/ssh-passphrase.ts +++ b/src/main/ipc/ssh-passphrase.ts @@ -18,6 +18,7 @@ export function requestCredential( targetId: string, kind: SshCredentialKind, detail: string, + echo?: boolean, signal?: AbortSignal ): Promise { const requestId = randomUUID() @@ -43,7 +44,7 @@ export function requestCredential( const win = getMainWindow() if (win && !win.isDestroyed()) { - win.webContents.send('ssh:credential-request', { requestId, targetId, kind, detail }) + win.webContents.send('ssh:credential-request', { requestId, targetId, kind, detail, echo }) } else { finish(null) } diff --git a/src/main/ipc/telemetry.test.ts b/src/main/ipc/telemetry.test.ts index 9b28270551a..b49e1882e98 100644 --- a/src/main/ipc/telemetry.test.ts +++ b/src/main/ipc/telemetry.test.ts @@ -161,6 +161,7 @@ describe('telemetry IPC handlers', () => { bucket_source: 'crossed_now' }) handler({}, 'daemon_audit_eligibility', {}) + handler({}, 'agent_token_usage', {}) expect(trackMock).not.toHaveBeenCalled() expect(getCohortAtEmitMock).not.toHaveBeenCalled() }) diff --git a/src/main/ipc/telemetry.ts b/src/main/ipc/telemetry.ts index 767ebed598f..ad954b20ce1 100644 --- a/src/main/ipc/telemetry.ts +++ b/src/main/ipc/telemetry.ts @@ -23,6 +23,7 @@ import type { EventName, EventProps, OptInVia } from '../../shared/telemetry-eve let storeRef: Store | null = null const MAIN_OWNED_TELEMETRY_EVENTS = new Set([ + 'agent_token_usage', 'app_starred_orca', 'daemon_adopted', 'daemon_audit_eligibility', diff --git a/src/main/ipc/terminal-render-desync-evidence.ts b/src/main/ipc/terminal-render-desync-evidence.ts index 9ea82246539..dc536faf1f8 100644 --- a/src/main/ipc/terminal-render-desync-evidence.ts +++ b/src/main/ipc/terminal-render-desync-evidence.ts @@ -1,9 +1,10 @@ import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises' import path from 'node:path' import { app, ipcMain } from 'electron' -import type { - WriteTerminalRenderDesyncEvidenceArgs, - WriteTerminalRenderDesyncEvidenceResult +import { + TERMINAL_RENDER_DESYNC_CAPTURE_ID_PATTERN, + type WriteTerminalRenderDesyncEvidenceArgs, + type WriteTerminalRenderDesyncEvidenceResult } from '../../shared/terminal-render-desync-evidence' import { isTrustedUIRenderer } from './ui' @@ -12,7 +13,6 @@ const MAX_PNG_DATA_URL_BYTES = 40 * 1024 * 1024 const MAX_METADATA_BYTES = 1024 * 1024 const MAX_CAPTURE_DIRECTORIES = 4 const MAX_EVIDENCE_BYTES = 96 * 1024 * 1024 -const CAPTURE_ID_PATTERN = /^[a-zA-Z0-9_-]{1,120}$/ const PNG_DATA_URL_PREFIX = 'data:image/png;base64,' let evidenceWriteQueue = Promise.resolve() @@ -41,7 +41,7 @@ export async function writeTerminalRenderDesyncEvidence( userDataPath: string, args: WriteTerminalRenderDesyncEvidenceArgs ): Promise { - if (!CAPTURE_ID_PATTERN.test(args.captureId)) { + if (!TERMINAL_RENDER_DESYNC_CAPTURE_ID_PATTERN.test(args.captureId)) { throw new Error('Invalid render-desync capture id') } if (args.phase !== 'corrupt' && args.phase !== 'healed') { diff --git a/src/main/ipc/worktree-head-identity-reader.ts b/src/main/ipc/worktree-head-identity-reader.ts index 64459403341..37471496999 100644 --- a/src/main/ipc/worktree-head-identity-reader.ts +++ b/src/main/ipc/worktree-head-identity-reader.ts @@ -310,6 +310,12 @@ export async function readGitCommonHeadIdentities( cache.entries.delete(name) } } + // Failed entries may have no cached identity; retire their retry markers on the same evidence. + for (const name of cache.unverified) { + if (!present.has(name)) { + cache.unverified.delete(name) + } + } } if (entryNames === null || scope.all || scope.listing) { await relist() diff --git a/src/main/ipc/worktree-head-identity-retirement.test.ts b/src/main/ipc/worktree-head-identity-retirement.test.ts new file mode 100644 index 00000000000..71830e7f312 --- /dev/null +++ b/src/main/ipc/worktree-head-identity-retirement.test.ts @@ -0,0 +1,264 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' + +const fake = vi.hoisted(() => ({ + files: new Map(), + failures: new Set(), + names: new Set(), + readFile: vi.fn<(path: string) => Promise>(), + readdir: vi.fn<() => Promise<{ name: string; isDirectory: () => boolean }[]>>() +})) +vi.mock('node:fs/promises', () => ({ readFile: fake.readFile, readdir: fake.readdir })) +vi.mock('./worktree-remote', () => ({ notifyWorktreeHeadIdentitiesChanged: vi.fn() })) + +import { + createWorktreeHeadIdentityCache, + readGitCommonHeadIdentities +} from './worktree-head-identity-reader' +import { + createWorktreeHeadIdentityRefreshState, + disposeWorktreeHeadIdentityRefreshState, + refreshWorktreeHeadIdentities +} from './worktree-head-identity-refresh' +import { + FULL_HEAD_IDENTITY_SCOPE, + headIdentityScopeForEntry, + LISTING_HEAD_IDENTITY_SCOPE, + PRIMARY_HEAD_IDENTITY_SCOPE +} from './worktree-head-identity-scope' + +const COMMON = join('/mock', 'project', '.git') +const OID = 'a'.repeat(40) +const NEW_OID = 'b'.repeat(40) +const entryFile = (name: string, file: string): string => join(COMMON, 'worktrees', name, file) +const worktreePath = (name: string): string => join('/mock', name) + +function addEntry(name: string, oid = OID): void { + fake.names.add(name) + fake.files.set(entryFile(name, 'gitdir'), join(worktreePath(name), '.git')) + fake.files.set(entryFile(name, 'HEAD'), oid) +} + +function fsError(code: string): Error { + return Object.assign(new Error(`synthetic ${code}`), { code }) +} + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1_000_000) + vi.resetAllMocks() + fake.files.clear() + fake.failures.clear() + fake.names.clear() + fake.files.set(join(COMMON, 'HEAD'), OID) + fake.readdir.mockImplementation(async () => + [...fake.names].map((name) => ({ name, isDirectory: () => true })) + ) + fake.readFile.mockImplementation(async (path) => { + if (fake.failures.has(path)) { + throw fsError('EIO') + } + const value = fake.files.get(path) + if (value === undefined) { + throw fsError('ENOENT') + } + return value + }) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('head identity failed-entry retirement', () => { + it.each([true, false])( + 'retires removed failure markers with a prior identity: %s', + async (seed) => { + const name = 'wt-e\u0301' + addEntry(name) + const cache = createWorktreeHeadIdentityCache() + if (seed) { + await readGitCommonHeadIdentities(COMMON, cache) + } + fake.failures.add(entryFile(name, 'HEAD')) + const failed = await readGitCommonHeadIdentities(COMMON, cache) + expect(failed.complete).toBe(false) + expect(cache.unverified.has(name)).toBe(true) + expect(cache.entries.has(name)).toBe(seed) + fake.names.delete(name) + + const recovered = await readGitCommonHeadIdentities( + COMMON, + cache, + LISTING_HEAD_IDENTITY_SCOPE + ) + + expect(recovered.complete).toBe(true) + expect(cache.unverified.size).toBe(0) + expect(cache.entries.size).toBe(0) + expect(recovered.identities.map((identity) => identity.worktreePath)).toEqual([ + join('/mock', 'project') + ]) + } + ) + + it.each(['EIO', 'ENOTDIR'])( + 'preserves failure markers when the listing rejects with %s', + async (code) => { + addEntry('pending') + const cache = createWorktreeHeadIdentityCache() + await readGitCommonHeadIdentities(COMMON, cache) + fake.failures.add(entryFile('pending', 'HEAD')) + await readGitCommonHeadIdentities(COMMON, cache) + fake.names.delete('pending') + fake.readdir.mockRejectedValue(fsError(code)) + + const result = await readGitCommonHeadIdentities(COMMON, cache, LISTING_HEAD_IDENTITY_SCOPE) + + expect(result.complete).toBe(false) + expect(cache.unverified.has('pending')).toBe(true) + expect(cache.entries.get('pending')?.head).toBe(OID) + expect(cache.entryNames).toBeNull() + } + ) + + it('retires failures when the worktrees directory is confirmed absent', async () => { + addEntry('removed') + const cache = createWorktreeHeadIdentityCache() + fake.failures.add(entryFile('removed', 'HEAD')) + await readGitCommonHeadIdentities(COMMON, cache) + fake.readdir.mockRejectedValue(fsError('ENOENT')) + + const result = await readGitCommonHeadIdentities(COMMON, cache, FULL_HEAD_IDENTITY_SCOPE) + + expect(result.complete).toBe(true) + expect(cache.unverified.size).toBe(0) + expect(cache.entryNames).toEqual([]) + }) + + it('keeps a listed failed entry retryable and preserves its last verified identity', async () => { + addEntry('pending') + const cache = createWorktreeHeadIdentityCache() + await readGitCommonHeadIdentities(COMMON, cache) + fake.failures.add(entryFile('pending', 'HEAD')) + await readGitCommonHeadIdentities(COMMON, cache) + + const stillFailed = await readGitCommonHeadIdentities( + COMMON, + cache, + LISTING_HEAD_IDENTITY_SCOPE + ) + + expect(stillFailed.complete).toBe(false) + expect(cache.unverified.has('pending')).toBe(true) + expect(cache.entries.get('pending')?.head).toBe(OID) + fake.failures.clear() + fake.files.set(entryFile('pending', 'HEAD'), NEW_OID) + const recovered = await readGitCommonHeadIdentities(COMMON, cache, PRIMARY_HEAD_IDENTITY_SCOPE) + expect(recovered.complete).toBe(true) + expect(cache.entries.get('pending')?.head).toBe(NEW_OID) + expect(cache.unverified.size).toBe(0) + }) + + it('removes only failure markers absent from the successful listing', async () => { + addEntry('removed') + addEntry('pending') + const cache = createWorktreeHeadIdentityCache() + fake.failures.add(entryFile('removed', 'gitdir')) + fake.failures.add(entryFile('pending', 'gitdir')) + await readGitCommonHeadIdentities(COMMON, cache) + fake.names.delete('removed') + + const result = await readGitCommonHeadIdentities(COMMON, cache, LISTING_HEAD_IDENTITY_SCOPE) + + expect(result.complete).toBe(false) + expect([...cache.unverified]).toEqual(['pending']) + }) + + it('reads a reused admin-entry name after its failed predecessor was removed', async () => { + addEntry('reused') + const cache = createWorktreeHeadIdentityCache() + fake.failures.add(entryFile('reused', 'HEAD')) + await readGitCommonHeadIdentities(COMMON, cache) + fake.names.delete('reused') + await readGitCommonHeadIdentities(COMMON, cache, LISTING_HEAD_IDENTITY_SCOPE) + fake.failures.clear() + addEntry('reused', NEW_OID) + + const result = await readGitCommonHeadIdentities( + COMMON, + cache, + headIdentityScopeForEntry('reused') + ) + + expect(result.complete).toBe(true) + expect(cache.entries.get('reused')?.head).toBe(NEW_OID) + }) + + it('does not retain historical failures after twenty confirmed removals', async () => { + const cache = createWorktreeHeadIdentityCache() + for (let index = 0; index < 20; index++) { + const name = `removed-${index}` + addEntry(name) + fake.failures.add(entryFile(name, 'HEAD')) + await readGitCommonHeadIdentities(COMMON, cache, FULL_HEAD_IDENTITY_SCOPE) + fake.names.delete(name) + await readGitCommonHeadIdentities(COMMON, cache, LISTING_HEAD_IDENTITY_SCOPE) + } + + expect(cache.unverified.size).toBe(0) + expect((await readGitCommonHeadIdentities(COMMON, cache)).complete).toBe(true) + }) + + it('keeps retirement scoped to the cache that received a successful listing', async () => { + addEntry('pending') + fake.failures.add(entryFile('pending', 'HEAD')) + const first = createWorktreeHeadIdentityCache() + const second = createWorktreeHeadIdentityCache() + await readGitCommonHeadIdentities(COMMON, first) + await readGitCommonHeadIdentities(COMMON, second) + fake.names.clear() + + await readGitCommonHeadIdentities(COMMON, first, LISTING_HEAD_IDENTITY_SCOPE) + + expect(first.unverified.size).toBe(0) + expect([...second.unverified]).toEqual(['pending']) + }) + + it('restores scoped refresh costs and drops retired baseline rows after recovery', async () => { + for (let index = 0; index < 8; index++) { + addEntry(`live-${index}`) + } + addEntry('retired') + const state = createWorktreeHeadIdentityRefreshState() + const host: Parameters[0] = { + path: COMMON, + repos: new Map([['repo', {}]]), + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Refresh reads only isDestroyed; the window notifier is mocked. + mainWindow: { isDestroyed: () => false } as never, + disposed: false + } + try { + await refreshWorktreeHeadIdentities(host, state, false) + fake.failures.add(entryFile('retired', 'HEAD')) + await refreshWorktreeHeadIdentities(host, state, true, headIdentityScopeForEntry('retired')) + fake.names.delete('retired') + vi.setSystemTime(1_061_000) + await refreshWorktreeHeadIdentities(host, state, false, FULL_HEAD_IDENTITY_SCOPE) + fake.readFile.mockClear() + fake.readdir.mockClear() + + for (let index = 0; index < 4; index++) { + await refreshWorktreeHeadIdentities(host, state, true, headIdentityScopeForEntry('live-0')) + } + + expect(fake.readFile).toHaveBeenCalledTimes(8) + expect(fake.readdir).not.toHaveBeenCalled() + expect(state.cache.unverified.size).toBe(0) + expect(state.baseline?.size).toBe(9) + expect(state.baseline?.has(worktreePath('retired'))).toBe(false) + } finally { + disposeWorktreeHeadIdentityRefreshState(state) + } + }) +}) diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index ec77b7e900f..ce11df4f4f0 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -74,7 +74,7 @@ import { import { requireSshGitProvider } from '../providers/ssh-git-dispatch' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import type { SshGitProvider } from '../providers/ssh-git-provider' -import { TUI_AGENT_CONFIG, isTuiAgent } from '../../shared/tui-agent-config' +import { isTuiAgent } from '../../shared/tui-agent-config' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { runWorktreeChangeInvalidators } from './worktree-change-invalidators' import { @@ -152,11 +152,6 @@ import { import { createSequencedSetupAgentCommands } from '../../shared/setup-agent-sequencing' import { shouldWaitForSetupBeforeAgentStartup } from '../../shared/setup-agent-startup-policy' import { createWorktreeCreateTimingRecorder } from '../worktree-create-timing' -import { - markCodexProjectTrusted, - markCopilotFolderTrusted, - markCursorWorkspaceTrusted -} from '../agent-trust-presets' import { getLocalProjectGitExecOptions, getLocalProjectWorktreeGitOptions, @@ -435,20 +430,6 @@ async function spawnLocalStartupAndSetupTerminals(args: { try { // Why: only after `git worktree add` + metadata registration is the path safe for a runtime PTY to boot the agent while setup runs alongside. - if (isTuiAgent(createdWithAgent)) { - const preset = TUI_AGENT_CONFIG[createdWithAgent].preflightTrust - try { - if (preset === 'cursor') { - markCursorWorkspaceTrusted(worktree.path) - } else if (preset === 'copilot') { - markCopilotFolderTrusted(worktree.path) - } else if (preset === 'codex') { - markCodexProjectTrusted(worktree.path) - } - } catch { - // Best-effort: launch still proceeds and the agent can ask interactively. - } - } const terminal = await runtime.createTerminal(`id:${worktree.id}`, { command: sequencedStartup.command, ...(setup ? { claudeAgentTeamsSourceCommand: startup.command } : {}), @@ -2741,7 +2722,8 @@ async function performLocalWorktreeCreate( branch: branchName, baseBranch, refreshLocalBaseRef: settings.refreshLocalBaseRefOnWorktreeCreate, - options: preparedWorktreeOptions + options: preparedWorktreeOptions, + timing }) timing.recordPreparedCheckout( prepared.status === 'hit' @@ -2754,6 +2736,9 @@ async function performLocalWorktreeCreate( rearm.fire = prepared.rearm return prepared.result } + if (prepared.rearm) { + rearm.fire = prepared.rearm + } } else { timing.recordPreparedCheckout({ status: 'miss', @@ -2940,14 +2925,14 @@ async function performLocalWorktreeCreate( // Why gated: registration replaces the repo's root set, so registering a create recovered without // a listing would revoke filesystem access to every worktree that listing would have named. if (listingComplete) { - registerWorktreeRootsForRepo(store, repo.id, [ + registerWorktreeRootsForRepo(store, repo, [ repo.path, ...gitWorktrees.map((worktree) => worktree.path) ]) } else { // Recovered without a listing: authorize just the new root, or the create the user just made // is rejected by filesystem/git-status IPC until a full scan repopulates the cache. - registerCreatedWorktreeRoot(store, repo.id, created.path) + registerCreatedWorktreeRoot(store, repo, created.path) } // Why: link user-configured shared paths (e.g. `node_modules`, `.env`) before setup runs so setup scripts see them in place. diff --git a/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts b/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts index d7549def4ed..fdeea389d61 100644 --- a/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts +++ b/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts @@ -319,7 +319,10 @@ describe('authoritative local worktree metadata pruning integration', () => { expect(store.removeWorktreeLineage).not.toHaveBeenCalled() // Only the detected listing re-derives; its fresh re-scan is what registers the roots. - expect(isRegisteredWorktreePath(REPO_PATH)).toBe(channel === 'worktrees:listDetected') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture supplies the local repository rows read by this registry check. + expect(isRegisteredWorktreePath(REPO_PATH, store as never)).toBe( + channel === 'worktrees:listDetected' + ) } ) @@ -357,7 +360,8 @@ describe('authoritative local worktree metadata pruning integration', () => { await pending expect(store.removeWorktreeLineage).not.toHaveBeenCalled() - expect(isRegisteredWorktreePath(newPath)).toBe(true) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture supplies the local repository rows read by this registry check. + expect(isRegisteredWorktreePath(newPath, store as never)).toBe(true) }) it.each(['scan generation', 'caller request'] as const)( @@ -406,7 +410,8 @@ describe('authoritative local worktree metadata pruning integration', () => { expect(store.pruneSessionlessMissingLocalWorktreeMetadataForRepo).not.toHaveBeenCalled() expect(store.removeWorktreeLineage).not.toHaveBeenCalled() - expect(isRegisteredWorktreePath(REPO_PATH)).toBe(false) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture supplies the local repository rows read by this registry check. + expect(isRegisteredWorktreePath(REPO_PATH, store as never)).toBe(false) expect(pruneCleanupScanSnapshotsMock).not.toHaveBeenCalled() expect(pruneSpaceAnalysisSnapshotsMock).not.toHaveBeenCalled() } @@ -454,7 +459,8 @@ describe('authoritative local worktree metadata pruning integration', () => { expect(store.pruneSessionlessMissingLocalWorktreeMetadataForRepo).toHaveBeenCalledTimes(1) expect(store.removeWorktreeLineage).not.toHaveBeenCalled() - expect(isRegisteredWorktreePath(newPath)).toBe(true) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture supplies the local repository rows read by this registry check. + expect(isRegisteredWorktreePath(newPath, store as never)).toBe(true) }) it('does not capture or prune on an initial WSL scan', async () => { diff --git a/src/main/ipc/worktrees-canonical-ssh-listing.test.ts b/src/main/ipc/worktrees-canonical-ssh-listing.test.ts new file mode 100644 index 00000000000..31a2b43a968 --- /dev/null +++ b/src/main/ipc/worktrees-canonical-ssh-listing.test.ts @@ -0,0 +1,230 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { hydrateRepo } from '../persistence/tracking-repos/repo-hydration' +import type { Repo } from '../../shared/repo-types' +import { + getSshProviderAuthority, + resetSshProviderAuthorities, + rotateSshProviderAuthority +} from '../ssh/ssh-provider-authority' + +const mocks = vi.hoisted(() => ({ + handlers: new Map unknown>(), + provider: vi.fn(), + remoteList: vi.fn(), + git: vi.fn(), + roots: vi.fn(), + rootsRevision: vi.fn(), + pruneLineage: vi.fn() +})) +vi.mock('electron', () => ({ + ipcMain: { + handle: (channel: string, handler: (event: unknown, args?: unknown) => unknown) => + mocks.handlers.set(channel, handler) + }, + app: { getPath: () => '/test' } +})) +vi.mock('../git/runner', async (original) => ({ + ...(await original>()), + gitExecFileAsync: mocks.git +})) +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: mocks.provider, + getSshGitProviderGeneration: () => 1 +})) +vi.mock('../project-runtime-git-options', () => ({ getLocalProjectWorktreeGitOptions: () => ({}) })) +vi.mock('./registered-worktree-roots-cache', () => ({ + getRegisteredWorktreeRootsRevision: mocks.rootsRevision, + registerWorktreeRootsForRepo: mocks.roots +})) +vi.mock('../worktree-lineage-pruning', () => ({ + pruneLineageForMissingRepoWorktrees: mocks.pruneLineage +})) +vi.mock('./worktrees/listing/authoritative-local-worktree-metadata-pruning', () => ({ + pruneMetadataMissingFromAuthoritativeLocalScan: vi.fn() +})) +import { registerDetectedWorktreeHandlers } from './worktrees/listing/register-detected-worktree-handlers' +import { registerWorktreeCatalogHandlers } from './worktrees/listing/register-worktree-catalog-handlers' +import { registerHostCatalogHandlers } from './worktrees/listing/register-host-catalog-handlers' +import { + __resetDetectedWorktreeScanCacheForTests, + __getDetectedWorktreeScanCacheStatsForTests, + invalidateDetectedWorktreeScanCache, + listDetectedGitWorktrees, + rememberLocalWorktreeRoots +} from './worktrees/listing/detected-worktree-scan-cache' + +const path = '/remote/repository' +const rows = [{ path, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }] +const channels = ['legacyDetected', 'hostDetected', 'list', 'listAll', 'known'] as const +function fixture(fields: Pick) { + const repo = hydrateRepo( + { id: 'repo', path, displayName: 'repo', badgeColor: '#000', addedAt: 0, ...fields }, + new Map() + ) + const store = { + getRepo: () => repo, + getRepos: () => [repo], + getProjects: () => [], + getSettings: () => ({}), + getAllWorktreeMeta: () => ({}), + getProjectHostSetups: () => [], + getWorktreeMeta: () => undefined, + setWorktreeMeta: vi.fn(), + getAllWorktreeLineage: () => ({}), + getAllWorkspaceLineage: () => ({}), + removeWorktreeLineage: vi.fn(), + captureNativeLocalWorktreeMetadataScanExpectation: () => undefined + } + const context = { + store, + detectedWorktreeCancellations: { + begin: () => new AbortController(), + finish: () => {}, + cancel: () => {} + } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These handlers only use this store and request cancellation surface. + registerDetectedWorktreeHandlers(context as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Catalog listing uses only the supplied store accessors. + registerWorktreeCatalogHandlers(context as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Known-host reading uses only the supplied store accessors. + registerHostCatalogHandlers(context as never) + return { repo, store } +} +function invoke(channel: (typeof channels)[number]) { + const hostArgs = { + repoId: 'repo', + executionHostId: 'ssh:host-a', + providerRequestId: 'request', + expectedAuthority: getSshProviderAuthority('host-a') + } + if (channel === 'legacyDetected') { + return mocks.handlers.get('worktrees:listDetected')!(null, { repoId: 'repo' }) + } + if (channel === 'hostDetected') { + return mocks.handlers.get('worktrees:listDetected')!(null, hostArgs) + } + if (channel === 'known') { + return mocks.handlers.get('worktrees:listKnownForExecutionHost')!(null, hostArgs) + } + return mocks.handlers.get(`worktrees:${channel}`)!(null, { repoId: 'repo' }) +} +beforeEach(() => { + vi.clearAllMocks() + mocks.handlers.clear() + __resetDetectedWorktreeScanCacheForTests() + resetSshProviderAuthorities() + mocks.provider.mockReturnValue({ listWorktrees: mocks.remoteList }) + mocks.remoteList.mockResolvedValue(rows) + mocks.rootsRevision.mockReturnValue(1) + mocks.git.mockRejectedValue(new Error('unexpected local Git')) +}) + +describe.each([ + { hostEncoding: 'canonical', fields: { executionHostId: 'ssh:host-a' as const } }, + { hostEncoding: 'legacy', fields: { connectionId: 'host-a' } } +])('$hostEncoding SSH listing', ({ fields }) => { + it.each(channels)('keeps %s on the direct SSH boundary', async (channel) => { + fixture(fields) + const result = await invoke(channel) + if (channel === 'known') { + expect(result).toMatchObject({ status: 'complete', result: { authoritative: false } }) + expect(mocks.remoteList).not.toHaveBeenCalled() + } else { + expect(mocks.remoteList).toHaveBeenCalledTimes(1) + expect(mocks.remoteList.mock.calls[0][0]).toBe(path) + if (channel === 'hostDetected') { + expect(result).toMatchObject({ status: 'complete', result: { authoritative: true } }) + } else if (channel === 'legacyDetected') { + expect(result).toMatchObject({ authoritative: true }) + } else { + expect(result).toHaveLength(1) + expect(mocks.pruneLineage).toHaveBeenCalledTimes(1) + } + } + expect(mocks.git).not.toHaveBeenCalled() + expect(mocks.roots).not.toHaveBeenCalled() + expect(__getDetectedWorktreeScanCacheStatsForTests()).toEqual({ cacheSize: 0, inFlightSize: 0 }) + }) + + it('reports disconnection without replaying an authoritative local-cache answer', async () => { + fixture(fields) + await invoke('legacyDetected') + mocks.provider.mockReturnValue(undefined) + expect(await invoke('legacyDetected')).toMatchObject({ authoritative: false }) + expect(mocks.git).not.toHaveBeenCalled() + }) + + it('rejects a legacy listing after provider authority rotates', async () => { + fixture(fields) + let finish = (_rows: typeof rows): void => {} + mocks.remoteList.mockReturnValue( + new Promise((resolve) => { + finish = resolve + }) + ) + const pending = invoke('legacyDetected') + await Promise.resolve() + rotateSshProviderAuthority('host-a') + finish(rows) + expect(await pending).toMatchObject({ authoritative: false }) + expect(mocks.roots).not.toHaveBeenCalled() + expect(mocks.pruneLineage).not.toHaveBeenCalled() + }) + + it('bypasses local cache when the lower scan entry point is called directly', async () => { + const { repo, store } = fixture(fields) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Remote scan bypass only needs the mocked Git options and provider. + await listDetectedGitWorktrees(store as never, repo) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Remote scan bypass only needs the mocked Git options and provider. + await listDetectedGitWorktrees(store as never, repo) + expect(mocks.remoteList).toHaveBeenCalledTimes(2) + expect(__getDetectedWorktreeScanCacheStatsForTests()).toEqual({ cacheSize: 0, inFlightSize: 0 }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: A remote row must return before reading the store. + rememberLocalWorktreeRoots(store as never, repo, rows) + expect(mocks.roots).not.toHaveBeenCalled() + }) + + it.each([ + ['list', 'mutation'], + ['listAll', 'mutation'], + ['list', 'roots revision'], + ['listAll', 'roots revision'] + ] as const)('preserves lineage when %s is overtaken by a %s', async (channel, change) => { + const { repo } = fixture(fields) + let finish = (_rows: typeof rows): void => {} + mocks.remoteList.mockReturnValue( + new Promise((resolve) => { + finish = resolve + }) + ) + const pending = invoke(channel) + expect(mocks.remoteList).toHaveBeenCalledTimes(1) + if (change === 'mutation') { + invalidateDetectedWorktreeScanCache(repo.id) + } else { + mocks.rootsRevision.mockReturnValue(2) + } + finish(rows) + expect(await pending).toHaveLength(1) + expect(mocks.pruneLineage).not.toHaveBeenCalled() + expect(mocks.roots).not.toHaveBeenCalled() + }) +}) + +it.each([undefined, 'nested'])( + 'keeps runtime rows out of local cache and root registration: %s', + async (connectionId) => { + const { repo, store } = fixture({ executionHostId: 'runtime:env', connectionId }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime scan must reject before any listing or store side effects. + await expect(listDetectedGitWorktrees(store as never, repo)).rejects.toThrow( + 'not reachable from this process' + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: A nonlocal row must return before reading the store. + rememberLocalWorktreeRoots(store as never, repo, rows) + expect(mocks.provider).not.toHaveBeenCalled() + expect(mocks.git).not.toHaveBeenCalled() + expect(mocks.roots).not.toHaveBeenCalled() + expect(__getDetectedWorktreeScanCacheStatsForTests()).toEqual({ cacheSize: 0, inFlightSize: 0 }) + } +) diff --git a/src/main/ipc/worktrees-removal-recovery.test.ts b/src/main/ipc/worktrees-removal-recovery.test.ts index cfe962cc09a..d3231d50bd8 100644 --- a/src/main/ipc/worktrees-removal-recovery.test.ts +++ b/src/main/ipc/worktrees-removal-recovery.test.ts @@ -279,6 +279,60 @@ describe('registerWorktreeHandlers', () => { }) }) + it.each(['unproven', 'removal-fails'] as const)( + 'keeps desktop orphan cleanup retryable when the directory is %s', + async (mode) => { + const parentDir = await mkdtemp(join(tmpdir(), 'orca-ipc-orphan-retention-')) + const repoPath = join(parentDir, 'repo') + const orphanPath = join(parentDir, 'orphan') + const worktreeId = `repo-1::${orphanPath}` + await mkdir(orphanPath, { recursive: true }) + if (mode === 'removal-fails') { + const adminPath = join(repoPath, '.git', 'worktrees', 'orphan') + await mkdir(adminPath, { recursive: true }) + await writeFile(join(orphanPath, '.git'), `gitdir: ${adminPath}\n`) + await writeFile(join(adminPath, 'gitdir'), `${join(orphanPath, '.git')}\n`) + } + const repo = { id: 'repo-1', path: repoPath, displayName: 'repo', badgeColor: '', addedAt: 0 } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + mockKnownFeatureWorktree(orphanPath, repoPath) + getEffectiveHooksMock.mockReturnValue(null) + removeWorktreeMock.mockRejectedValue( + Object.assign(new Error('Git remove failed'), { + stderr: `fatal: '${orphanPath}' is not a working tree` + }) + ) + const finish = vi.fn().mockResolvedValue(undefined) + runtimeStub.acquireFileWatcherRemoval.mockResolvedValue({ finish }) + const removePath = vi + .spyOn(localWorktreeFilesystem, 'removeLocalWorktreePath') + .mockRejectedValue(new Error('injected removal failure')) + try { + await expect(handlers['worktrees:remove'](null, { worktreeId })).rejects.toThrow( + 'Worktree is no longer registered with Git but its directory remains.' + ) + await expect(lstat(orphanPath)).resolves.toBeTruthy() + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith( + ['worktree', 'prune'], + expect.anything() + ) + expect(finish).toHaveBeenCalledWith(false) + expect(removePath).toHaveBeenCalledTimes(mode === 'removal-fails' ? 1 : 0) + await rm(orphanPath, { recursive: true, force: true }) + await expect(handlers['worktrees:remove'](null, { worktreeId })).resolves.toEqual({ + catalogVersion: anyCatalogVersion + }) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(finish).toHaveBeenLastCalledWith(true) + } finally { + removePath.mockRestore() + await rm(parentDir, { recursive: true, force: true }) + } + } + ) + it('recovers forced Windows long-path worktree removal through local deletion and prune', async () => { setPlatform('win32') const parentDir = await mkdtemp(join(tmpdir(), 'orca-ipc-long-path-')) diff --git a/src/main/ipc/worktrees-ssh-provider-authority.test.ts b/src/main/ipc/worktrees-ssh-provider-authority.test.ts index 8c2d6b0edd7..8d14755ab18 100644 --- a/src/main/ipc/worktrees-ssh-provider-authority.test.ts +++ b/src/main/ipc/worktrees-ssh-provider-authority.test.ts @@ -264,51 +264,59 @@ describe('registerWorktreeHandlers', () => { expect(store.removeWorktreeLineage).not.toHaveBeenCalled() }) - it('cancels an SSH provider request by sender-scoped provider request ID', async () => { - let providerSignal: AbortSignal | undefined - const provider = { - listWorktrees: vi.fn( - (_repoPath: string, options?: { signal?: AbortSignal }) => - new Promise((_resolve, reject) => { - providerSignal = options?.signal - providerSignal?.addEventListener( - 'abort', - () => reject(new DOMException('Canceled', 'AbortError')), - { once: true } - ) - }) - ) - } - const sshRepo = { - id: 'repo-1', - path: '/remote/repo', - displayName: 'repo', - badgeColor: '#000', - addedAt: 0, - connectionId: 'target-a' - } - store.getRepos.mockReturnValue([sshRepo]) - getSshGitProviderMock.mockReturnValue(provider) + it.each(['cancel', 'did-navigate', 'render-process-gone', 'destroyed'])( + 'cancels an SSH provider request on %s', + async (eventName) => { + let providerSignal: AbortSignal | undefined + const provider = { + listWorktrees: vi.fn( + (_repoPath: string, options?: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + providerSignal = options?.signal + providerSignal?.addEventListener( + 'abort', + () => reject(new DOMException('Canceled', 'AbortError')), + { once: true } + ) + }) + ) + } + const sshRepo = { + id: 'repo-1', + path: '/remote/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + store.getRepos.mockReturnValue([sshRepo]) + getSshGitProviderMock.mockReturnValue(provider) - const pending = handlers['worktrees:listDetected'](ipcEvent, { - providerRequestId: 'request-1' as ProviderRequestId, - repoId: sshRepo.id, - executionHostId: toSshExecutionHostId('target-a'), - expectedAuthority: getSshProviderAuthority('target-a') - }) - await Promise.resolve() - handlers['worktrees:cancelListDetected'](ipcEvent, { - providerRequestId: 'request-1' as ProviderRequestId - }) + const pending = handlers['worktrees:listDetected'](ipcEvent, { + providerRequestId: 'request-1' as ProviderRequestId, + repoId: sshRepo.id, + executionHostId: toSshExecutionHostId('target-a'), + expectedAuthority: getSshProviderAuthority('target-a') + }) + await Promise.resolve() + if (eventName === 'cancel') { + handlers['worktrees:cancelListDetected'](ipcEvent, { + providerRequestId: 'request-1' as ProviderRequestId + }) + } else { + ipcEvent.sender.emit(eventName) + } - expect(providerSignal?.aborted).toBe(true) - await expect(pending).resolves.toMatchObject({ - status: 'canceled', - providerRequestId: 'request-1' - }) - expect(store.setWorktreeMeta).not.toHaveBeenCalled() - expect(store.removeWorktreeLineage).not.toHaveBeenCalled() - }) + expect(providerSignal?.aborted).toBe(true) + await expect(pending).resolves.toMatchObject({ + status: 'canceled', + providerRequestId: 'request-1' + }) + expect(store.setWorktreeMeta).not.toHaveBeenCalled() + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + expect(ipcEvent.sender.eventNames()).toEqual([]) + } + ) it('settles a noncooperative SSH provider at the main-owned deadline and cleans up', async () => { vi.useFakeTimers() diff --git a/src/main/ipc/worktrees-test-ipc-surface.ts b/src/main/ipc/worktrees-test-ipc-surface.ts index 7df4f6cda0a..985107b067e 100644 --- a/src/main/ipc/worktrees-test-ipc-surface.ts +++ b/src/main/ipc/worktrees-test-ipc-surface.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from 'node:events' import { type Mock, vi } from 'vitest' import type { WorktreeMeta } from '../../shared/worktree/meta-types' @@ -50,7 +51,7 @@ export const mainWindow: TestMainWindow = { send: vi.fn() } } -export const ipcEvent = { sender: { id: 1 } } +export const ipcEvent = { sender: Object.assign(new EventEmitter(), { id: 1 }) } export const store: TestStore = { getProfileStorageDirectory: vi.fn(() => '/profile-a'), getRepos: vi.fn(), diff --git a/src/main/ipc/worktrees/listing/detected-provider-listing.ts b/src/main/ipc/worktrees/listing/detected-provider-listing.ts index 84243189b0b..975cb5eeb78 100644 --- a/src/main/ipc/worktrees/listing/detected-provider-listing.ts +++ b/src/main/ipc/worktrees/listing/detected-provider-listing.ts @@ -1,3 +1,7 @@ +import { + getRepoExecutionHostId, + getSshTargetIdForExecutionHost +} from '../../../../shared/execution-host' import type { Store } from '../../../persistence/loading-store/store' import type { Repo } from '../../../../shared/repo-types' import { getSshGitProvider } from '../../../providers/ssh-git-dispatch' @@ -61,9 +65,11 @@ export async function listDetectedWorktreesForCapturedRepo( store: Store, repo: Repo, isCurrent: () => boolean, - capturedProvider = repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined, + capturedProvider?: SshGitProvider, providerAbort?: { signal: AbortSignal; status: () => 'canceled' | 'timed-out' } ): Promise { + const connectionId = getSshTargetIdForExecutionHost(getRepoExecutionHostId(repo)) + const provider = capturedProvider ?? (connectionId ? getSshGitProvider(connectionId) : undefined) const abortedResult = () => providerAbort?.signal.aborted ? ({ providerAbortStatus: providerAbort.status() } as const) @@ -105,7 +111,7 @@ export async function listDetectedWorktreesForCapturedRepo( ) } } - if (repo.connectionId && !capturedProvider) { + if (connectionId && !provider) { const aborted = abortedResult() if (aborted) { return aborted @@ -124,8 +130,8 @@ export async function listDetectedWorktreesForCapturedRepo( } const scan = await scanUntilNotOvertaken( repo.id, - repo.connectionId && capturedProvider - ? () => listSshWorktreesWithMutationWitness(capturedProvider, repo, providerAbort?.signal) + connectionId && provider + ? () => listSshWorktreesWithMutationWitness(provider, repo, providerAbort?.signal) : () => listDetectedGitWorktrees(store, repo), () => isCurrent() && !providerAbort?.signal.aborted ) @@ -197,7 +203,7 @@ export async function listDetectedWorktreesForCapturedRepo( // Why: retention alone leaves inert rows with no explanation; the cause rides with the listing. const unavailableReason = describeWorktreeScanFailure(err) const failureKind = classifyWorktreeScanFailure(unavailableReason) - if (repo.connectionId) { + if (connectionId) { const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex()) return { repoId: repo.id, diff --git a/src/main/ipc/worktrees/listing/detected-scan-failure-authority.test.ts b/src/main/ipc/worktrees/listing/detected-scan-failure-authority.test.ts index 4e436b8dcf1..5c600a0f8ff 100644 --- a/src/main/ipc/worktrees/listing/detected-scan-failure-authority.test.ts +++ b/src/main/ipc/worktrees/listing/detected-scan-failure-authority.test.ts @@ -56,8 +56,10 @@ function wslHostFailure(): Error { }) } +const store = createStore() + async function listDetected(): Promise { - const result = await listDetectedWorktreesForCapturedRepo(createStore(), repo, () => true) + const result = await listDetectedWorktreesForCapturedRepo(store, repo, () => true) return result as DetectedWorktreeListResult } @@ -81,7 +83,7 @@ describe('detected worktree listing authority', () => { // Why: the retained rows must carry the cause, or the user sees inert worktrees with no explanation. expect(result.unavailableReason).toContain('Command failed: wsl.exe') // The destructive halves of a fresh scan must not run against a listing that failed. - expect(isRegisteredWorktreePath(REPO_PATH)).toBe(false) + expect(isRegisteredWorktreePath(REPO_PATH, store)).toBe(false) expect(removeWorktreeLineage).not.toHaveBeenCalled() }) @@ -118,7 +120,7 @@ describe('detected worktree listing authority', () => { expect(result.authoritative).toBe(false) expect(result.unavailableReason).toContain('No such file or directory') - expect(isRegisteredWorktreePath(REPO_PATH)).toBe(false) + expect(isRegisteredWorktreePath(REPO_PATH, store)).toBe(false) expect(removeWorktreeLineage).not.toHaveBeenCalled() }) @@ -136,7 +138,7 @@ describe('detected worktree listing authority', () => { expect(result.source).toBe('git') expect(result.worktrees).toEqual([]) expect(result.unavailableReason).toBeUndefined() - expect(isRegisteredWorktreePath(REPO_PATH)).toBe(true) + expect(isRegisteredWorktreePath(REPO_PATH, store)).toBe(true) }) it('keeps an empty listing authoritative when the repo path is gone', async () => { @@ -161,6 +163,6 @@ describe('detected worktree listing authority', () => { expect(result.authoritative).toBe(true) expect(result.worktrees.map((worktree) => worktree.path)).toEqual([REPO_PATH]) - expect(isRegisteredWorktreePath(REPO_PATH)).toBe(true) + expect(isRegisteredWorktreePath(REPO_PATH, store)).toBe(true) }) }) diff --git a/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts b/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts index 4ed0b88ffce..dad44c64a69 100644 --- a/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts +++ b/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts @@ -1,3 +1,4 @@ +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import type { GitWorktreeInfo } from '../../../../shared/worktree/types' import type { Store } from '../../../persistence/loading-store/store' import type { Repo } from '../../../../shared/repo-types' @@ -119,7 +120,7 @@ export async function listDetectedGitWorktrees( repo: Repo ): Promise { const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(store, repo) - if (repo.connectionId || isFolderRepo(repo)) { + if (getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID || isFolderRepo(repo)) { const generation = getLocalWorktreeScanGeneration(repo.id) return { gitWorktrees: await listRepoWorktreesForDetectedScan(repo, localWorktreeGitOptions), @@ -295,11 +296,11 @@ export function rememberLocalWorktreeRoots( repo: Repo, gitWorktrees: GitWorktreeInfo[] ): void { - if (repo.connectionId) { + if (getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) { return } // Why: reuse the `git worktree list` result so later git/file IPC validation skips a second scan that can trigger macOS folder-permission prompts. - registerWorktreeRootsForRepo(store, repo.id, [ + registerWorktreeRootsForRepo(store, repo, [ repo.path, ...gitWorktrees.map((worktree) => worktree.path) ]) diff --git a/src/main/ipc/worktrees/listing/host-qualified-worktree-listing.ts b/src/main/ipc/worktrees/listing/host-qualified-worktree-listing.ts index 3026a1b0652..57800d63bab 100644 --- a/src/main/ipc/worktrees/listing/host-qualified-worktree-listing.ts +++ b/src/main/ipc/worktrees/listing/host-qualified-worktree-listing.ts @@ -1,3 +1,9 @@ +import { + getRepoExecutionHostId, + getSshTargetIdForExecutionHost, + parseExecutionHostId, + LOCAL_EXECUTION_HOST_ID +} from '../../../../shared/execution-host' import type { Store } from '../../../persistence/loading-store/store' import { PROVIDER_REQUEST_ID_MAX_UTF8_BYTES } from '../../../../shared/detected-worktree-provider-contract' import type { @@ -5,7 +11,6 @@ import type { HostQualifiedDetectedWorktreeResult, DirectSshDetectedWorktreeRequest } from '../../../../shared/detected-worktree-provider-contract' -import { parseExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { isCurrentSshProviderAuthority } from '../../../ssh/ssh-provider-authority' import { getSshGitProvider } from '../../../providers/ssh-git-dispatch' import { @@ -63,9 +68,10 @@ export async function listHostQualifiedDetectedWorktrees( if (!repo) { return rejected('ambiguous-owner') } + const connectionId = getSshTargetIdForExecutionHost(getRepoExecutionHostId(repo)) if ( - (parsedHost.kind === 'local' && repo.connectionId) || - (parsedHost.kind === 'ssh' && repo.connectionId !== parsedHost.targetId) + (parsedHost.kind === 'local' && connectionId) || + (parsedHost.kind === 'ssh' && connectionId !== parsedHost.targetId) ) { return rejected('rejected') } @@ -75,8 +81,8 @@ export async function listHostQualifiedDetectedWorktrees( return false } if ( - (parsedHost.kind === 'local' && repo.connectionId) || - (parsedHost.kind === 'ssh' && repo.connectionId !== parsedHost.targetId) + (parsedHost.kind === 'local' && connectionId) || + (parsedHost.kind === 'ssh' && connectionId !== parsedHost.targetId) ) { return false } diff --git a/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts b/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts index 66e8e85a4a5..442a70985c8 100644 --- a/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts @@ -5,7 +5,11 @@ import type { DirectSshDetectedWorktreeRequest, ProviderRequestId } from '../../../../shared/detected-worktree-provider-contract' -import { parseExecutionHostId } from '../../../../shared/execution-host' +import { + parseExecutionHostId, + getRepoExecutionHostId, + getSshTargetIdForExecutionHost +} from '../../../../shared/execution-host' import { registerSshProviderRequestAbort, getSshProviderAuthority, @@ -99,17 +103,16 @@ export function registerDetectedWorktreeHandlers(context: WorktreeIpcContext): v worktrees: [] } } - const provider = repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined - const authority = repo.connectionId - ? { ...getSshProviderAuthority(repo.connectionId) } - : undefined + const connectionId = getSshTargetIdForExecutionHost(getRepoExecutionHostId(repo)) + const provider = connectionId ? getSshGitProvider(connectionId) : undefined + const authority = connectionId ? { ...getSshProviderAuthority(connectionId) } : undefined const result = await listDetectedWorktreesForCapturedRepo( store, repo, () => isCapturedRepoCurrent(store, repo) && - (!repo.connectionId || - (getSshGitProvider(repo.connectionId) === provider && + (!connectionId || + (getSshGitProvider(connectionId) === provider && authority !== undefined && isCurrentSshProviderAuthority(authority))), provider diff --git a/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts index 4467506e790..ac231a4c697 100644 --- a/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts @@ -1,3 +1,8 @@ +import { + getRepoExecutionHostId, + getSshTargetIdForExecutionHost, + parseExecutionHostId +} from '../../../../shared/execution-host' import { ipcMain } from 'electron' import type { ListKnownWorktreesForExecutionHostArgs, @@ -5,7 +10,6 @@ import type { ForgetRemovedWorktreesForExecutionHostArgs, ForgetRemovedWorktreesForExecutionHostResult } from '../../../../shared/detected-worktree-provider-contract' -import { parseExecutionHostId } from '../../../../shared/execution-host' import type { DetectedWorktree } from '../../../../shared/worktree/types' import { isFolderRepo } from '../../../../shared/repo-kind' import { projectResolvedWorktreeLineage } from '../../../../shared/resolved-worktree-lineage' @@ -47,7 +51,10 @@ export function registerHostCatalogHandlers(context: WorktreeIpcContext): void { // Why: findExactRepoOwner repeats this same all-candidates-owned check, and getRepos() re-hydrates the // whole catalog, so a separate pass here is pure cost. const repo = findExactRepoOwner(store, requestedRepoId, requestedExecutionHostId) - if (!repo || repo.connectionId !== parsedHost.targetId) { + if ( + !repo || + getSshTargetIdForExecutionHost(getRepoExecutionHostId(repo)) !== parsedHost.targetId + ) { return rejected() } const complete = (worktrees: DetectedWorktree[]): HostQualifiedKnownWorktreeResult => ({ diff --git a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts index 4f3055c63a2..89de29f30e8 100644 --- a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts @@ -1,6 +1,10 @@ import { ipcMain } from 'electron' import { isFolderRepo } from '../../../../shared/repo-kind' -import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { + getRepoExecutionHostId, + getSshTargetIdForExecutionHost, + type ExecutionHostId +} from '../../../../shared/execution-host' import { getSshGitProvider } from '../../../providers/ssh-git-dispatch' import { EMPTY_RETIRED_NAME_REGISTRY } from '../../../../shared/worktree/retired-name-registry' import { getRetiredNameRegistryForRepo } from '../../../worktree-name-retirement' @@ -28,6 +32,8 @@ import { readAllWorktreeMetaForRepo } from '../../../persistence/host-qualified-worktree-meta' import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import { getLocalWorktreeScanGeneration } from '../../../local-worktree-scan-generation' +import { getRegisteredWorktreeRootsRevision } from '../../registered-worktree-roots-cache' const WORKTREE_LIST_ALL_CONCURRENCY = 8 @@ -89,6 +95,7 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo // Why: each local repo listing can spawn `git worktree list`; cap fan-out so large fleets don't start unbounded subprocesses. const results = await mapWithConcurrency(repos, WORKTREE_LIST_ALL_CONCURRENCY, async (repo) => { + const connectionId = getSshTargetIdForExecutionHost(getRepoExecutionHostId(repo)) try { let gitWorktrees let freshScan = true @@ -97,18 +104,22 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo let hygieneDue: boolean | undefined if (isFolderRepo(repo)) { return listVisibleFolderWorkspaces(store, repo) - } else if (repo.connectionId) { - const provider = getSshGitProvider(repo.connectionId) + } else if (connectionId) { + const provider = getSshGitProvider(connectionId) if (!provider) { warnOnce( loggedUnavailableSshGitProviders, - `${repo.connectionId}:${repo.id}`, - `[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}` + `${connectionId}:${repo.id}`, + `[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${connectionId}` ) return listDisconnectedSshWorktrees(store, repo, sshMetaIndexForRepo(repo)) } - loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`) + loggedUnavailableSshGitProviders.delete(`${connectionId}:${repo.id}`) try { + sideEffectToken = { + generation: getLocalWorktreeScanGeneration(repo.id), + authorizedRootsRevision: getRegisteredWorktreeRootsRevision(repo.id) + } gitWorktrees = await provider.listWorktrees(repo.path) } catch (err) { warnOnce( @@ -177,8 +188,9 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo if (!repo) { return [] } - const allMeta = repo.connectionId ? readAllWorktreeMetaForRepo(store, repo) : undefined - const sshWorktreeMetaIndex = repo.connectionId + const connectionId = getSshTargetIdForExecutionHost(getRepoExecutionHostId(repo)) + const allMeta = connectionId ? readAllWorktreeMetaForRepo(store, repo) : undefined + const sshWorktreeMetaIndex = connectionId ? createSshWorktreeMetaIndex(Object.entries(allMeta ?? {})) : new Map() @@ -190,18 +202,22 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo let hygieneDue: boolean | undefined if (isFolderRepo(repo)) { return listVisibleFolderWorkspaces(store, repo) - } else if (repo.connectionId) { - const provider = getSshGitProvider(repo.connectionId) + } else if (connectionId) { + const provider = getSshGitProvider(connectionId) if (!provider) { warnOnce( loggedUnavailableSshGitProviders, - `${repo.connectionId}:${repo.id}`, - `[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}` + `${connectionId}:${repo.id}`, + `[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${connectionId}` ) return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) } - loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`) + loggedUnavailableSshGitProviders.delete(`${connectionId}:${repo.id}`) try { + sideEffectToken = { + generation: getLocalWorktreeScanGeneration(repo.id), + authorizedRootsRevision: getRegisteredWorktreeRootsRevision(repo.id) + } gitWorktrees = await provider.listWorktrees(repo.path) } catch (err) { warnOnce( diff --git a/src/main/ipc/worktrees/metadata/workspace-lineage-candidate-scan.test.ts b/src/main/ipc/worktrees/metadata/workspace-lineage-candidate-scan.test.ts new file mode 100644 index 00000000000..e3ccee07086 --- /dev/null +++ b/src/main/ipc/worktrees/metadata/workspace-lineage-candidate-scan.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import { createLineageResolutionContext } from './lineage-owner-resolution' +import { getFolderLineageCandidateRepos } from './workspace-lineage-filtering' + +vi.mock('../../worktree-logic', () => ({ parseWorktreeId: vi.fn() })) + +function repo(id: string, fields: Partial = {}): Repo { + return { id, path: `/root/${id}`, displayName: id, badgeColor: '#000', addedAt: 0, ...fields } +} + +function group(id = 'group', fields: Partial = {}): ProjectGroup { + return { + id, + name: id, + parentPath: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 0, + updatedAt: 0, + ...fields + } +} + +function folder(fields: Partial = {}): FolderWorkspace { + return { + id: 'folder', + projectGroupId: 'group', + name: 'folder', + folderPath: '/root', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0, + ...fields + } +} + +function context(repos: Repo[], groups = [group()]) { + const store = { + getRepos: () => repos, + getFolderWorkspaces: () => [], + getProjectGroups: () => groups + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Context construction only reads these three store catalogs. + return createLineageResolutionContext(store as never) +} + +describe('folder lineage candidate lookup', () => { + it('reads grouped target membership once across a large path-candidate list', () => { + let targetReads = 0 + const grouped = Array.from({ length: 300 }, (_, index): Repo => ({ + ...repo(`grouped-${index}`, { projectGroupId: 'group', path: `/elsewhere/${index}` }), + get connectionId() { + targetReads += 1 + return null + } + })) + const candidates = Array.from({ length: 1_000 }, (_, index) => repo(`path-${index}`)) + const result = getFolderLineageCandidateRepos(context([...grouped, ...candidates]), folder()) + + expect(result).toEqual([...grouped, ...candidates]) + expect(targetReads).toBe(300) + }) + + it('preserves descendant-group priority, path boundaries and local/SSH membership', () => { + const entries = [ + repo('path-a', { connectionId: 'a' }), + repo('group-b', { projectGroupId: 'nested', path: '/elsewhere', connectionId: 'b' }), + repo('local-null', { connectionId: null }), + repo('group-local', { projectGroupId: 'group' }), + repo('path-b', { connectionId: 'b' }), + repo('local-absent'), + repo('sibling', { path: '/root-other/repo', connectionId: 'b' }), + repo('root', { path: '/root', connectionId: 'b' }) + ] + const snapshot = structuredClone(entries) + const result = getFolderLineageCandidateRepos( + context(entries, [group(), group('nested', { parentGroupId: 'group' })]), + folder() + ) + + expect(result.map((entry) => entry.id)).toEqual([ + 'group-b', + 'group-local', + 'local-null', + 'path-b', + 'local-absent', + 'root' + ]) + expect(result[0]).toBe(entries[1]) + expect(entries).toEqual(snapshot) + }) + + it.each([ + { folderTarget: 'a', groupTarget: 'b', expected: ['grouped', 'a'] }, + { folderTarget: null, groupTarget: 'b', expected: ['grouped', 'b'] }, + { folderTarget: undefined, groupTarget: undefined, expected: ['grouped', 'local'] } + ])( + 'keeps explicit target precedence: $folderTarget / $groupTarget', + ({ folderTarget, groupTarget, expected }) => { + const entries = [ + repo('a', { connectionId: 'a' }), + repo('b', { connectionId: 'b' }), + repo('local'), + repo('grouped', { projectGroupId: 'group', path: '/outside' }) + ] + expect( + getFolderLineageCandidateRepos( + context(entries, [group('group', { connectionId: groupTarget })]), + folder({ connectionId: folderTarget }) + ).map((entry) => entry.id) + ).toEqual(expected) + } + ) + + it('keeps every contained target when the folder has no grouped repositories', () => { + const entries = [ + repo('local'), + repo('remote', { connectionId: 'a' }), + repo('outside', { path: '/elsewhere' }) + ] + expect(getFolderLineageCandidateRepos(context(entries), folder())).toEqual(entries.slice(0, 2)) + }) + + it('does not read grouped targets when there are no path candidates', () => { + let targetReads = 0 + const grouped: Repo = { + ...repo('grouped', { projectGroupId: 'group' }), + get connectionId() { + targetReads += 1 + return 'a' + } + } + expect(getFolderLineageCandidateRepos(context([grouped]), folder())).toEqual([grouped]) + expect(targetReads).toBe(0) + }) + + it('rebuilds target membership after a later change in the same context', () => { + const grouped = repo('grouped', { projectGroupId: 'group', connectionId: 'a' }) + const a = repo('a', { connectionId: 'a' }) + const b = repo('b', { connectionId: 'b' }) + const current = context([grouped, a, b]) + expect(getFolderLineageCandidateRepos(current, folder())).toEqual([grouped, a]) + grouped.connectionId = 'b' + expect(getFolderLineageCandidateRepos(current, folder())).toEqual([grouped, b]) + }) +}) diff --git a/src/main/ipc/worktrees/metadata/workspace-lineage-filtering.ts b/src/main/ipc/worktrees/metadata/workspace-lineage-filtering.ts index 3106812418c..c478b59b81a 100644 --- a/src/main/ipc/worktrees/metadata/workspace-lineage-filtering.ts +++ b/src/main/ipc/worktrees/metadata/workspace-lineage-filtering.ts @@ -36,18 +36,20 @@ export function getFolderLineageCandidateRepos( ) const group = context.groupsById.get(folder.projectGroupId)?.[0] const connectionId = folder.connectionId ?? group?.connectionId ?? null - return connectionId - ? [...grouped, ...pathRepos.filter((repo) => (repo.connectionId ?? null) === connectionId)] - : grouped.length > 0 - ? [ - ...grouped, - ...pathRepos.filter((repo) => - new Set(grouped.map((candidate) => candidate.connectionId ?? null)).has( - repo.connectionId ?? null - ) - ) - ] - : pathRepos + if (connectionId) { + return [...grouped, ...pathRepos.filter((repo) => (repo.connectionId ?? null) === connectionId)] + } + if (grouped.length === 0) { + return pathRepos + } + if (pathRepos.length === 0) { + return grouped + } + const groupedConnectionIds = new Set(grouped.map((repo) => repo.connectionId ?? null)) + return [ + ...grouped, + ...pathRepos.filter((repo) => groupedConnectionIds.has(repo.connectionId ?? null)) + ] } export function resolveFolderLineageOwner( diff --git a/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts index c22e6b0ad96..5a36199c8e3 100644 --- a/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts +++ b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts @@ -11,17 +11,10 @@ import { } from '../../../git/worktree' import { gitExecFileAsync } from '../../../git/runner' import { getWorktreeSharedLinkPaths } from '../../../git/worktree-shared-directories' -import { - getLocalWorktreePathAccess, - removeLocalWorktreePath, - toLocalWorktreeRuntimePath -} from '../../../local-worktree-filesystem' +import { cleanupLocalOrphanedWorktreeDirectory } from '../../../local-orphaned-worktree-cleanup' import { recoverLocalWindowsWorktreeRemoval } from '../../../local-worktree-removal-recovery' import { withWorktreeRemoveStageSpan } from '../../../observability/instrumentation' -import { - canSafelyRemoveOrphanedWorktreeDirectory, - findRegisteredDeletableWorktree -} from '../../../worktree-removal-safety' +import { findRegisteredDeletableWorktree } from '../../../worktree-removal-safety' import { CLIENT_REMOVAL_HOME } from '../../../worktree-removal-home-guard' import { cleanupUnusedWorktreePushTargetRemote, @@ -162,25 +155,12 @@ export async function removeRegisteredLocalWorktree( console.warn( `[worktrees] Orphaned worktree detected at ${canonicalWorktreePath}, cleaning up` ) - const access = getLocalWorktreePathAccess(localWorktreeGitOptions) - if ( - await canSafelyRemoveOrphanedWorktreeDirectory( - toLocalWorktreeRuntimePath(canonicalWorktreePath, localWorktreeGitOptions), - toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - CLIENT_REMOVAL_HOME, - access.statPath, - access.readPath - ) - ) { - await runtime.closeFileWatchersForRemoval(canonicalWorktreePath) - await removeLocalWorktreePath(canonicalWorktreePath, localWorktreeGitOptions).catch( - () => {} - ) - } else { - console.warn( - `[worktrees] Refusing recursive cleanup for unproven worktree directory: ${canonicalWorktreePath}` - ) - } + await cleanupLocalOrphanedWorktreeDirectory( + repo.path, + canonicalWorktreePath, + localWorktreeGitOptions, + (path) => runtime.closeFileWatchersForRemoval(path) + ) // Why: remove failed so git still tracks it (.git/worktrees/); prune or the stale entry keeps its branch locked. await gitExecFileAsync(['worktree', 'prune'], { cwd: repo.path, diff --git a/src/main/kimi/hook-service-config-delete-race.test.ts b/src/main/kimi/hook-service-config-delete-race.test.ts new file mode 100644 index 00000000000..6a801cef4c0 --- /dev/null +++ b/src/main/kimi/hook-service-config-delete-race.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const deleteRace = vi.hoisted((): { path: string | null; code: string } => ({ + path: null, + code: 'ENOENT' +})) + +// Why: simulates the config file (or an ancestor directory) vanishing between +// writeConfigToml's existsSync check and its statSync mode read — e.g. a +// concurrent uninstall, user delete, or a directory replaced by a file. +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + const statSync = ( + target: NodeFs.PathLike, + options?: NodeFs.StatSyncOptions + ): NodeFs.Stats | NodeFs.BigIntStats | undefined => { + if (typeof target === 'string' && target === deleteRace.path) { + throw Object.assign(new Error(`${deleteRace.code}: simulated race`), { + code: deleteRace.code + }) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: forwarding actual.statSync's own overload signature. + return actual.statSync(target, options as NodeFs.StatSyncOptions) + } + const patched = { ...actual, statSync } + return { ...patched, default: patched } +}) + +const fs = await vi.importActual('node:fs') +const { KimiHookService } = await import('./hook-service') +const { KIMI_HOOK_EVENTS } = await import('./kimi-hook-config-toml') + +let home: string +let originalHome: string | undefined +let originalKimiHome: string | undefined +let originalUserProfile: string | undefined + +const configPath = (): string => join(home, '.kimi-code', 'config.toml') + +beforeEach(() => { + home = fs.mkdtempSync(join(tmpdir(), 'orca-kimi-hook-delete-race-')) + originalHome = process.env.HOME + originalKimiHome = process.env.KIMI_CODE_HOME + originalUserProfile = process.env.USERPROFILE + process.env.HOME = home + process.env.KIMI_CODE_HOME = join(home, '.kimi-code') + process.env.USERPROFILE = home +}) + +afterEach(() => { + deleteRace.path = null + deleteRace.code = 'ENOENT' + if (originalHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = originalHome + } + if (originalKimiHome === undefined) { + delete process.env.KIMI_CODE_HOME + } else { + process.env.KIMI_CODE_HOME = originalKimiHome + } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE + } else { + process.env.USERPROFILE = originalUserProfile + } + fs.rmSync(home, { recursive: true, force: true }) +}) + +describe('KimiHookService config delete race', () => { + it.each(['ENOENT', 'ENOTDIR'])( + 'still writes the config when the mode stat fails with %s', + (code) => { + fs.mkdirSync(join(home, '.kimi-code'), { recursive: true }) + fs.writeFileSync(configPath(), 'api_key = "fixture-only"\n') + deleteRace.path = configPath() + deleteRace.code = code + + expect(() => new KimiHookService().install()).not.toThrow() + + const config = fs.readFileSync(configPath(), 'utf-8') + for (const event of KIMI_HOOK_EVENTS) { + expect(config).toContain(`event = "${event}"`) + } + } + ) +}) diff --git a/src/main/kimi/hook-service-mode-read-failure.test.ts b/src/main/kimi/hook-service-mode-read-failure.test.ts new file mode 100644 index 00000000000..4e82137e832 --- /dev/null +++ b/src/main/kimi/hook-service-mode-read-failure.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const modeReadFailure = vi.hoisted((): { path: string | null } => ({ path: null })) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + const statSync = (target: NodeFs.PathLike): NodeFs.Stats | NodeFs.BigIntStats => { + if (typeof target === 'string' && target === modeReadFailure.path) { + throw new Error('mode unavailable') + } + return actual.statSync(target) + } + const patched = { ...actual, statSync } + return { ...patched, default: patched } +}) + +const fs = await vi.importActual('node:fs') +const { KimiHookService } = await import('./hook-service') + +let home: string +let originalHome: string | undefined +let originalKimiHome: string | undefined +let originalUserProfile: string | undefined + +const configPath = (): string => join(home, '.kimi-code', 'config.toml') + +beforeEach(() => { + home = fs.mkdtempSync(join(tmpdir(), 'orca-kimi-hook-mode-read-')) + originalHome = process.env.HOME + originalKimiHome = process.env.KIMI_CODE_HOME + originalUserProfile = process.env.USERPROFILE + process.env.HOME = home + process.env.KIMI_CODE_HOME = join(home, '.kimi-code') + process.env.USERPROFILE = home +}) + +afterEach(() => { + modeReadFailure.path = null + if (originalHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = originalHome + } + if (originalKimiHome === undefined) { + delete process.env.KIMI_CODE_HOME + } else { + process.env.KIMI_CODE_HOME = originalKimiHome + } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE + } else { + process.env.USERPROFILE = originalUserProfile + } + fs.rmSync(home, { recursive: true, force: true }) +}) + +describe('KimiHookService config mode preservation', () => { + it('does not replace an existing config when its mode cannot be read', () => { + fs.mkdirSync(join(home, '.kimi-code'), { recursive: true }) + const original = 'api_key = "fixture-only"\n' + fs.writeFileSync(configPath(), original) + modeReadFailure.path = configPath() + + expect(() => new KimiHookService().install()).toThrow('mode unavailable') + expect(fs.readFileSync(configPath(), 'utf-8')).toBe(original) + }) +}) diff --git a/src/main/kimi/hook-service.test.ts b/src/main/kimi/hook-service.test.ts index 24a77444e19..a88c68f3d04 100644 --- a/src/main/kimi/hook-service.test.ts +++ b/src/main/kimi/hook-service.test.ts @@ -1,4 +1,13 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -8,17 +17,20 @@ import { KIMI_HOOK_EVENTS } from './kimi-hook-config-toml' // Why: getSharedManagedScriptPath() writes the managed script under // homedir()/.orca, and getKimiHome() honors KIMI_CODE_HOME. Point both at a // temp dir so the local install/remove cycle never touches the real ~/.orca or -// ~/.kimi-code. os.homedir() resolves $HOME on POSIX (verified at write time). +// ~/.kimi-code. os.homedir() resolves HOME on POSIX and USERPROFILE on Windows. let home: string let originalHome: string | undefined let originalKimiHome: string | undefined +let originalUserProfile: string | undefined beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'orca-kimi-hook-')) originalHome = process.env.HOME originalKimiHome = process.env.KIMI_CODE_HOME + originalUserProfile = process.env.USERPROFILE process.env.HOME = home process.env.KIMI_CODE_HOME = join(home, '.kimi-code') + process.env.USERPROFILE = home }) afterEach(() => { @@ -32,11 +44,17 @@ afterEach(() => { } else { process.env.KIMI_CODE_HOME = originalKimiHome } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE + } else { + process.env.USERPROFILE = originalUserProfile + } rmSync(home, { recursive: true, force: true }) }) const configPath = (): string => join(home, '.kimi-code', 'config.toml') const scriptPath = (): string => join(home, '.orca', 'agent-hooks', 'kimi-hook.sh') +const supportsPosixFileModes = process.platform !== 'win32' describe('KimiHookService', () => { it('reports not_installed before install', () => { @@ -89,4 +107,41 @@ describe('KimiHookService', () => { const afterRemove = readFileSync(configPath(), 'utf-8') expect(afterRemove).toBe(userConfig) }) + + it.skipIf(!supportsPosixFileModes)( + 'preserves an existing config mode while installing and removing hooks', + () => { + mkdirSync(join(home, '.kimi-code'), { recursive: true }) + writeFileSync(configPath(), 'api_key = "sk-secret"\n') + const existingMode = 0o640 + chmodSync(configPath(), existingMode) + + const service = new KimiHookService() + const originalUmask = process.umask(0o077) + try { + service.install() + expect(statSync(configPath()).mode & 0o777).toBe(existingMode) + expect(statSync(`${configPath()}.bak`).mode & 0o777).toBe(existingMode) + expect( + readdirSync(join(home, '.kimi-code')).filter((name) => name.endsWith('.tmp')) + ).toEqual([]) + + service.remove() + expect(statSync(configPath()).mode & 0o777).toBe(existingMode) + } finally { + process.umask(originalUmask) + } + } + ) + + it.skipIf(!supportsPosixFileModes)('creates a new config with an owner-only mode', () => { + const originalUmask = process.umask(0o022) + try { + new KimiHookService().install() + + expect(statSync(configPath()).mode & 0o777).toBe(0o600) + } finally { + process.umask(originalUmask) + } + }) }) diff --git a/src/main/kimi/hook-service.ts b/src/main/kimi/hook-service.ts index 21fee64d276..f4f4dfbe534 100644 --- a/src/main/kimi/hook-service.ts +++ b/src/main/kimi/hook-service.ts @@ -1,9 +1,11 @@ import { copyFileSync, + chmodSync, existsSync, mkdirSync, readFileSync, renameSync, + statSync, unlinkSync, writeFileSync } from 'node:fs' @@ -12,6 +14,7 @@ import { dirname, join, posix as pathPosix } from 'node:path' import { randomUUID } from 'node:crypto' import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' import { createManagedCommandMatcher, getSharedManagedScriptPath, @@ -137,6 +140,8 @@ function readConfigToml(configPath: string): string | null { function writeConfigToml(configPath: string, text: string): void { const dir = dirname(configPath) mkdirSync(dir, { recursive: true }) + // Why: renameSync replaces the inode, so the temp mode becomes the config mode. + let mode = 0o600 if (existsSync(configPath)) { try { if (readFileSync(configPath, 'utf-8') === text) { @@ -145,10 +150,19 @@ function writeConfigToml(configPath: string, text: string): void { } catch { // Fall through to the atomic write path. } + try { + mode = statSync(configPath).mode & 0o777 + } catch (error) { + // Why: file was deleted between the existsSync check and here — nothing to preserve. + if (!isDefinitiveAbsence(error)) { + throw error + } + } } const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`) try { - writeFileSync(tmpPath, text, 'utf-8') + writeFileSync(tmpPath, text, { encoding: 'utf-8', mode: 0o600 }) + chmodSync(tmpPath, mode) if (existsSync(configPath)) { copyFileSync(configPath, `${configPath}.bak`) } diff --git a/src/main/lazy-worker-thread-host.ts b/src/main/lazy-worker-thread-host.ts index 01f15eb1c66..2de02689afc 100644 --- a/src/main/lazy-worker-thread-host.ts +++ b/src/main/lazy-worker-thread-host.ts @@ -1,6 +1,12 @@ import type { Worker } from 'node:worker_threads' -export type WorkerThreadFactory = () => Worker +export type WorkerRequestTransport = Pick & { + on(...args: Parameters): unknown + off(...args: Parameters): unknown + removeAllListeners(): unknown +} + +export type WorkerThreadFactory = () => WorkerRequestTransport /** * Owns the lifetime of one lazily-spawned worker thread: spawn on demand, @@ -10,7 +16,7 @@ export type WorkerThreadFactory = () => Worker * queued calls closed instead of moving the work back onto the main thread. */ export class LazyWorkerThreadHost { - private worker: Worker | null = null + private worker: WorkerRequestTransport | null = null private idleTimer: NodeJS.Timeout | null = null private cleanupListeners: (() => void) | null = null private reportedUnavailable = false @@ -29,12 +35,12 @@ export class LazyWorkerThreadHost { } ) {} - get current(): Worker | null { + get current(): WorkerRequestTransport | null { return this.worker } /** The live worker, spawning one if needed; null when no worker can be had. */ - ensure(): Worker | null { + ensure(): WorkerRequestTransport | null { if (this.worker) { return this.worker } diff --git a/src/main/lib/html-to-pdf.test.ts b/src/main/lib/html-to-pdf.test.ts new file mode 100644 index 00000000000..03ead7c66f1 --- /dev/null +++ b/src/main/lib/html-to-pdf.test.ts @@ -0,0 +1,315 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' + +const fake = vi.hoisted(() => ({ + files: new Map(), + handlers: new Map void>(), + write: vi.fn<(path: string, html: string, options: unknown) => Promise>(), + unlink: vi.fn<(path: string) => Promise>(), + construct: vi.fn<(options: unknown) => void>(), + load: vi.fn<(path: string) => Promise>(), + images: vi.fn<(script: string, userGesture: boolean) => Promise>(), + print: vi.fn<(options: unknown) => Promise>(), + isDestroyed: vi.fn<() => boolean>(), + destroy: vi.fn<() => void>(), + uuid: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ + app: { getPath: () => '/mock-export-temp' }, + BrowserWindow: class { + constructor(options: unknown) { + fake.construct(options) + } + webContents = { + once: (event: string, listener: (...args: unknown[]) => void) => { + fake.handlers.set(event, listener) + }, + executeJavaScript: fake.images, + printToPDF: fake.print + } + loadFile = fake.load + isDestroyed = fake.isDestroyed + destroy = fake.destroy + } +})) +vi.mock('node:fs/promises', () => ({ writeFile: fake.write, unlink: fake.unlink })) +vi.mock('node:crypto', () => ({ randomUUID: fake.uuid })) + +import { ExportTimeoutError, htmlToPdf } from './html-to-pdf' + +const HTML = '

synthetic export

' +const TEMP_PATH = join('/mock-export-temp', 'orca-export-test-export.html') +const PDF = Buffer.from('synthetic PDF buffer') + +beforeEach(() => { + vi.useFakeTimers() + vi.resetAllMocks() + fake.files.clear() + fake.handlers.clear() + fake.uuid.mockReturnValue('test-export') + fake.write.mockImplementation(async (path, html) => { + fake.files.set(path, html) + }) + fake.unlink.mockImplementation(async (path) => { + fake.files.delete(path) + }) + fake.load.mockImplementation(async () => { + fake.handlers.get('did-finish-load')?.() + }) + fake.images.mockResolvedValue() + fake.print.mockResolvedValue(PDF) + fake.isDestroyed.mockReturnValue(false) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('htmlToPdf resource ownership', () => { + it('removes the temporary document when window construction throws', async () => { + const error = new Error('window construction failed') + fake.construct.mockImplementation(() => { + throw error + }) + + await expect(htmlToPdf(HTML)).rejects.toBe(error) + + expect(fake.write).toHaveBeenCalledWith(TEMP_PATH, HTML, { encoding: 'utf-8', flag: 'wx' }) + expect(fake.unlink).toHaveBeenCalledExactlyOnceWith(TEMP_PATH) + expect(fake.files.size).toBe(0) + expect(fake.destroy).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + + it('removes a partial document when writing rejects', async () => { + const error = new Error('disk full') + fake.write.mockImplementation(async (path) => { + fake.files.set(path, ' { + const error = new Error('write denied') + fake.write.mockRejectedValue(error) + fake.unlink.mockRejectedValue(new Error('ENOENT')) + + await expect(htmlToPdf(HTML)).rejects.toBe(error) + + expect(fake.unlink).toHaveBeenCalledExactlyOnceWith(TEMP_PATH) + expect(fake.construct).not.toHaveBeenCalled() + }) + + it('preserves the construction error when best-effort cleanup fails', async () => { + const error = new Error('window construction failed') + fake.construct.mockImplementation(() => { + throw error + }) + fake.unlink.mockRejectedValue(new Error('unlink denied')) + + await expect(htmlToPdf(HTML)).rejects.toBe(error) + + expect(fake.unlink).toHaveBeenCalledExactlyOnceWith(TEMP_PATH) + expect(fake.files.size).toBe(1) + }) + + it('does not accumulate temporary documents across repeated failed exports', async () => { + fake.construct.mockImplementation(() => { + throw new Error('window construction failed') + }) + for (let index = 0; index < 10; index++) { + fake.uuid.mockReturnValue(`export-${index}`) + await expect(htmlToPdf(HTML)).rejects.toThrow('window construction failed') + } + + expect(fake.files.size).toBe(0) + expect(fake.unlink).toHaveBeenCalledTimes(10) + }) + + it('keeps window security, print settings and the returned PDF unchanged', async () => { + await expect(htmlToPdf(HTML)).resolves.toBe(PDF) + + expect(fake.construct).toHaveBeenCalledExactlyOnceWith({ + show: false, + webPreferences: { + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + javascript: true + } + }) + expect(fake.load).toHaveBeenCalledExactlyOnceWith(TEMP_PATH) + expect(fake.images).toHaveBeenCalledWith(expect.stringContaining('document.images'), true) + expect(fake.print).toHaveBeenCalledExactlyOnceWith({ + printBackground: true, + pageSize: 'A4', + margins: { top: 0.75, bottom: 0.75, left: 0.75, right: 0.75 } + }) + expect(fake.destroy).toHaveBeenCalledOnce() + expect(fake.unlink).toHaveBeenCalledExactlyOnceWith(TEMP_PATH) + expect(fake.files.size).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('waits for images before printing', async () => { + const images = Promise.withResolvers() + fake.images.mockReturnValue(images.promise) + const result = htmlToPdf(HTML) + await vi.advanceTimersByTimeAsync(0) + + expect(fake.images).toHaveBeenCalledOnce() + expect(fake.print).not.toHaveBeenCalled() + expect(fake.unlink).not.toHaveBeenCalled() + + images.resolve() + await expect(result).resolves.toBe(PDF) + expect(fake.print).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + }) + + it.each(['images', 'print'] as const)('cleans up after %s failure', async (stage) => { + const error = new Error(`${stage} failed`) + fake[stage].mockRejectedValue(error) + + await expect(htmlToPdf(HTML)).rejects.toBe(error) + + expect(fake.destroy).toHaveBeenCalledOnce() + expect(fake.files.size).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('cleans up after a load rejection', async () => { + const error = new Error('load failed') + fake.load.mockRejectedValue(error) + + await expect(htmlToPdf(HTML)).rejects.toBe(error) + + expect(fake.destroy).toHaveBeenCalledOnce() + expect(fake.files.size).toBe(0) + expect(fake.images).not.toHaveBeenCalled() + }) + + it('keeps the render timeout type and clears its resources', async () => { + const images = Promise.withResolvers() + fake.images.mockReturnValue(images.promise) + const result = expect(htmlToPdf(HTML)).rejects.toBeInstanceOf(ExportTimeoutError) + await vi.advanceTimersByTimeAsync(60_000) + await result + + expect(fake.destroy).toHaveBeenCalledOnce() + expect(fake.files.size).toBe(0) + expect(vi.getTimerCount()).toBe(0) + images.reject(new Error('late renderer failure')) + await Promise.resolve() + }) + + it('does not destroy an already-destroyed window again', async () => { + fake.isDestroyed.mockReturnValue(true) + + await expect(htmlToPdf(HTML)).resolves.toBe(PDF) + + expect(fake.destroy).not.toHaveBeenCalled() + expect(fake.files.size).toBe(0) + }) + + it('still removes the document if window destruction throws', async () => { + const error = new Error('window destruction failed') + fake.destroy.mockImplementation(() => { + throw error + }) + + await expect(htmlToPdf(HTML)).rejects.toBe(error) + + expect(fake.unlink).toHaveBeenCalledExactlyOnceWith(TEMP_PATH) + expect(fake.files.size).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps a successful export successful when best-effort cleanup rejects', async () => { + fake.unlink.mockRejectedValue(new Error('unlink denied')) + + await expect(htmlToPdf(HTML)).resolves.toBe(PDF) + + expect(fake.destroy).toHaveBeenCalledOnce() + expect(fake.unlink).toHaveBeenCalledExactlyOnceWith(TEMP_PATH) + expect(vi.getTimerCount()).toBe(0) + }) + it('leaves a pre-existing file at the temp path in place instead of deleting it', async () => { + const error = Object.assign(new Error('file already exists'), { code: 'EEXIST' }) + fake.files.set(TEMP_PATH, 'FILE THIS EXPORT DID NOT CREATE') + fake.write.mockRejectedValue(error) + + await expect(htmlToPdf(HTML)).rejects.toBe(error) + + expect(fake.unlink).not.toHaveBeenCalled() + expect(fake.files.get(TEMP_PATH)).toBe('FILE THIS EXPORT DID NOT CREATE') + expect(fake.construct).not.toHaveBeenCalled() + }) + + it('times out and cleans up when loading never settles', async () => { + fake.load.mockReturnValue(new Promise(() => {})) + + const result = expect(htmlToPdf(HTML)).rejects.toBeInstanceOf(ExportTimeoutError) + await vi.advanceTimersByTimeAsync(60_000) + await result + + expect(fake.images).not.toHaveBeenCalled() + expect(fake.destroy).toHaveBeenCalledOnce() + expect(fake.unlink).toHaveBeenCalledExactlyOnceWith(TEMP_PATH) + expect(fake.files.size).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('times out and cleans up when neither load event ever fires', async () => { + fake.load.mockResolvedValue() + + const result = expect(htmlToPdf(HTML)).rejects.toBeInstanceOf(ExportTimeoutError) + await vi.advanceTimersByTimeAsync(60_000) + await result + + expect(fake.print).not.toHaveBeenCalled() + expect(fake.destroy).toHaveBeenCalledOnce() + expect(fake.files.size).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('cleans up after a did-fail-load event', async () => { + fake.load.mockImplementation(async () => { + fake.handlers.get('did-fail-load')?.({}, -6, 'ERR_FILE_NOT_FOUND') + }) + + await expect(htmlToPdf(HTML)).rejects.toThrow('ERR_FILE_NOT_FOUND (-6)') + + expect(fake.images).not.toHaveBeenCalled() + expect(fake.destroy).toHaveBeenCalledOnce() + expect(fake.files.size).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('never removes a temp path belonging to a concurrent export', async () => { + const images = Promise.withResolvers() + fake.images.mockReturnValueOnce(images.promise) + fake.uuid.mockReturnValueOnce('first-export').mockReturnValueOnce('second-export') + const firstPath = join('/mock-export-temp', 'orca-export-first-export.html') + const secondPath = join('/mock-export-temp', 'orca-export-second-export.html') + + const first = htmlToPdf(HTML) + await vi.advanceTimersByTimeAsync(0) + await expect(htmlToPdf(HTML)).resolves.toBe(PDF) + + expect(fake.unlink).toHaveBeenCalledExactlyOnceWith(secondPath) + expect(fake.files.has(firstPath)).toBe(true) + + images.resolve() + await expect(first).resolves.toBe(PDF) + expect(fake.unlink).toHaveBeenCalledWith(firstPath) + expect(fake.files.size).toBe(0) + }) +}) diff --git a/src/main/lib/html-to-pdf.ts b/src/main/lib/html-to-pdf.ts index f0cc467e515..a375e7ef7c7 100644 --- a/src/main/lib/html-to-pdf.ts +++ b/src/main/lib/html-to-pdf.ts @@ -33,66 +33,93 @@ new Promise((resolve) => { export async function htmlToPdf(html: string): Promise { const tempDir = app.getPath('temp') const tempPath = path.join(tempDir, `orca-export-${randomUUID()}.html`) - await writeFile(tempPath, html, 'utf-8') - const win = new BrowserWindow({ - show: false, - webPreferences: { - sandbox: true, - contextIsolation: true, - nodeIntegration: false, - // Why: image-wait needs to run a short script inside the export page, and - // the exported renderer DOM may already embed scripts/SVGs (e.g. Mermaid) - // that need JS to paint correctly. The window stays sandboxed and - // isolated so this is safe. - javascript: true + // Why: 'wx' is an exclusive create, so the shared temp dir cannot pre-seat this + // path as a symlink and have the export write through it. EEXIST is the one + // failure where the path is not ours, so it must not be unlinked below. + try { + await writeFile(tempPath, html, { encoding: 'utf-8', flag: 'wx' }) + } catch (error) { + if (isAlreadyExists(error)) { + throw error } - }) - - let timer: NodeJS.Timeout | undefined + await removeOwnTempDocument(tempPath) + throw error + } try { - const loadPromise = new Promise((resolve, reject) => { - win.webContents.once('did-finish-load', () => resolve()) - win.webContents.once('did-fail-load', (_event, errorCode, errorDescription) => { - reject(new Error(`Failed to load export document: ${errorDescription} (${errorCode})`)) - }) + const win = new BrowserWindow({ + show: false, + webPreferences: { + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + // Why: image-wait needs to run a short script inside the export page, and + // the exported renderer DOM may already embed scripts/SVGs (e.g. Mermaid) + // that need JS to paint correctly. The window stays sandboxed and + // isolated so this is safe. + javascript: true + } }) - await win.loadFile(tempPath) - await loadPromise + let timer: NodeJS.Timeout | undefined - const renderAndPrint = (async (): Promise => { - await win.webContents.executeJavaScript(WAIT_FOR_IMAGES_SCRIPT, true) - return win.webContents.printToPDF({ - printBackground: true, - pageSize: 'A4', - margins: { - top: 0.75, - bottom: 0.75, - left: 0.75, - right: 0.75 - } - }) - })() - - const timeoutPromise = new Promise((_resolve, reject) => { - timer = setTimeout(() => reject(new ExportTimeoutError()), EXPORT_TIMEOUT_MS) - }) - - return await Promise.race([renderAndPrint, timeoutPromise]) - } finally { - if (timer) { - clearTimeout(timer) - } - if (!win.isDestroyed()) { - win.destroy() - } try { - await unlink(tempPath) - } catch { - // Why: best-effort cleanup — losing the temp file should not surface - // as a user-facing export failure. + // Why: the timeout has to cover loading too. An export document whose script + // never yields fires neither did-finish-load nor did-fail-load, so a + // render-only timeout would leave this window and its temp file forever. + const timeoutPromise = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new ExportTimeoutError()), EXPORT_TIMEOUT_MS) + }) + + const loadAndPrint = (async (): Promise => { + const loadPromise = new Promise((resolve, reject) => { + win.webContents.once('did-finish-load', () => resolve()) + win.webContents.once('did-fail-load', (_event, errorCode, errorDescription) => { + reject(new Error(`Failed to load export document: ${errorDescription} (${errorCode})`)) + }) + }) + + await win.loadFile(tempPath) + await loadPromise + await win.webContents.executeJavaScript(WAIT_FOR_IMAGES_SCRIPT, true) + return win.webContents.printToPDF({ + printBackground: true, + pageSize: 'A4', + margins: { + top: 0.75, + bottom: 0.75, + left: 0.75, + right: 0.75 + } + }) + })() + + return await Promise.race([loadAndPrint, timeoutPromise]) + } finally { + if (timer) { + clearTimeout(timer) + } + if (!win.isDestroyed()) { + win.destroy() + } } + } finally { + await removeOwnTempDocument(tempPath) + } +} + +function isAlreadyExists(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'EEXIST' +} + +// unlink never follows a symlink, so this removes the entry this export created +// and never the target of one swapped in underneath it. +async function removeOwnTempDocument(tempPath: string): Promise { + try { + await unlink(tempPath) + } catch { + // Why: best-effort cleanup — losing the temp file should not surface + // as a user-facing export failure. } } diff --git a/src/main/linear/teams.test.ts b/src/main/linear/teams.test.ts index d0d8986373d..55808711824 100644 --- a/src/main/linear/teams.test.ts +++ b/src/main/linear/teams.test.ts @@ -72,16 +72,15 @@ function makeConnection(pages: TNode[][]) { return { nodes, pageInfo: { hasNextPage: pages.length > 1 }, - fetchNext: vi - .fn() - .mockImplementation( - async function fetchNext(this: { nodes: TNode[]; pageInfo: { hasNextPage: boolean } }) { - pageIndex += 1 - this.nodes.push(...(pages[pageIndex] ?? [])) - this.pageInfo.hasNextPage = pageIndex < pages.length - 1 - return this - } - ) + fetchNext: vi.fn().mockImplementation(async function fetchNext(this: { + nodes: TNode[] + pageInfo: { hasNextPage: boolean } + }) { + pageIndex += 1 + this.nodes.push(...(pages[pageIndex] ?? [])) + this.pageInfo.hasNextPage = pageIndex < pages.length - 1 + return this + }) } } diff --git a/src/main/linux-update-package-type.test.ts b/src/main/linux-update-package-type.test.ts index 7453c727bb8..ed1abf90b47 100644 --- a/src/main/linux-update-package-type.test.ts +++ b/src/main/linux-update-package-type.test.ts @@ -221,7 +221,7 @@ describe('getLinuxRootPackageType', () => { }) it('returns unusable when the marker is unreadable', async () => { - // A directory in the marker's place makes readFileSync fail with EISDIR. + // A directory in the marker's place makes fail with EISDIR. await fsp.mkdir(path.join(resourcesDir, 'package-type')) const module = await loadPackageType() expect(module.getLinuxPackageType()).toBe('unusable') diff --git a/src/main/local-orphaned-worktree-cleanup.ts b/src/main/local-orphaned-worktree-cleanup.ts new file mode 100644 index 00000000000..9ed666e5021 --- /dev/null +++ b/src/main/local-orphaned-worktree-cleanup.ts @@ -0,0 +1,39 @@ +import type { LocalWorktreeFilesystemOptions } from './local-worktree-filesystem' +import { + getLocalWorktreePathAccess, + removeLocalWorktreePath, + toLocalWorktreeRuntimePath +} from './local-worktree-filesystem' +import { + canSafelyRemoveOrphanedWorktreeDirectory, + isWorktreePathMissing, + ORPHANED_WORKTREE_DIRECTORY_MESSAGE +} from './worktree-removal-safety' +import { CLIENT_REMOVAL_HOME } from './worktree-removal-home-guard' + +export async function cleanupLocalOrphanedWorktreeDirectory( + repoPath: string, + path: string, + options: LocalWorktreeFilesystemOptions, + closeWatchers: (path: string) => Promise +): Promise { + const access = getLocalWorktreePathAccess(options) + const runtimePath = toLocalWorktreeRuntimePath(path, options) + if ( + await canSafelyRemoveOrphanedWorktreeDirectory( + runtimePath, + toLocalWorktreeRuntimePath(repoPath, options), + CLIENT_REMOVAL_HOME, + access.statPath, + access.readPath + ) + ) { + await closeWatchers(path) + await removeLocalWorktreePath(path, options).catch(() => {}) + } else { + console.warn(`[worktrees] Refusing recursive cleanup for unproven worktree directory: ${path}`) + } + if (!(await isWorktreePathMissing(runtimePath, access.statPath))) { + throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) + } +} diff --git a/src/main/local-worktree-filesystem.test.ts b/src/main/local-worktree-filesystem.test.ts index c9e97f72e90..d69d82b914f 100644 --- a/src/main/local-worktree-filesystem.test.ts +++ b/src/main/local-worktree-filesystem.test.ts @@ -25,17 +25,18 @@ import { toHostFilesystemPath, toHostRemovalPath } from './local-worktree-filesystem' +import { isWorktreePathMissing } from './worktree-removal-safety' function completeExecFile(stdout = ''): void { runProcessMock.mockResolvedValue({ code: 0, signal: null, stdout, stderr: '', timedOut: false }) } -function failExecFile(exitCode: number): void { +function failExecFile(exitCode: number, stderr = 'missing'): void { runProcessMock.mockResolvedValue({ code: exitCode, signal: null, stdout: '', - stderr: 'missing', + stderr, timedOut: false }) } @@ -171,7 +172,7 @@ describe('local worktree filesystem runtime access', () => { it('uses the selected WSL distro for stat, read, and removal on Windows', async () => { await withPlatform('win32', async () => { - completeExecFile('file') + completeExecFile('regular file') const access = getLocalWorktreePathAccess({ wslDistro: 'Ubuntu' }) await expect(access.statPath('/home/me/repo/.git')).resolves.toEqual({ type: 'file' }) @@ -218,14 +219,68 @@ describe('local worktree filesystem runtime access', () => { }) }) - it('reports missing WSL stat targets with an ENOENT-shaped error', async () => { + // Captured verbatim from live hosts: GNU coreutils 9.4 (Ubuntu 24.04) and BusyBox v1.36.1. + it.each([ + ['GNU', "stat: cannot statx '/mnt/c/repo/missing/.git': No such file or directory\n"], + ['GNU', "stat: cannot statx '/mnt/c/repo/missing/.git': Not a directory\n"], + ['BusyBox', "stat: can't stat '/mnt/c/repo/missing/.git': No such file or directory\n"], + ['BusyBox', "stat: can't stat '/mnt/c/repo/missing/.git': Not a directory\n"] + ])('reports a missing WSL stat target as ENOENT on %s userland', async (_userland, stderr) => { await withPlatform('win32', async () => { - failExecFile(2) + failExecFile(1, stderr) const access = getLocalWorktreePathAccess({ wslDistro: 'Ubuntu' }) await expect(access.statPath('/mnt/c/repo/missing/.git')).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(isWorktreePathMissing('/mnt/c/repo/missing', access.statPath)).resolves.toBe( + true + ) + }) + }) + + it.each([ + ['GNU', "stat: cannot statx '/mnt/c/repo/locked': Permission denied\n"], + ['BusyBox', "stat: can't stat '/mnt/c/repo/locked': Permission denied\n"] + ])( + 'does not treat an unreadable WSL stat target as missing on %s userland', + async (_userland, stderr) => { + await withPlatform('win32', async () => { + failExecFile(1, stderr) + const access = getLocalWorktreePathAccess({ wslDistro: 'Ubuntu' }) + + await expect(access.statPath('/mnt/c/repo/locked')).rejects.toThrow('Permission denied') + // A delete that did not happen still has to look like a failure, or cleanup silently "succeeds". + await expect(isWorktreePathMissing('/mnt/c/repo/locked', access.statPath)).resolves.toBe( + false + ) + }) + } + ) + + it('pins the stat probe to the C locale so its error text stays English', async () => { + await withPlatform('win32', async () => { + completeExecFile('directory') + await getLocalWorktreePathAccess({ wslDistro: 'Ubuntu' }).statPath('/mnt/c/repo/feature') + + const args = runProcessMock.mock.calls[0]?.[0].args as string[] + expect(args.at(-1)).toContain('LC_ALL=C stat -c %F --') + }) + }) + + it('reports a translated stat failure as a failure rather than as absence', async () => { + await withPlatform('win32', async () => { + // Synthetic: no host available here ships a non-English locale, and the probe pins LC_ALL=C + // anyway. The point is the direction it errs if that pin ever comes off. + failExecFile(1, "stat: '/mnt/c/repo/missing': \n") + const access = getLocalWorktreePathAccess({ wslDistro: 'Ubuntu' }) + + await expect(access.statPath('/mnt/c/repo/missing')).rejects.not.toMatchObject({ + code: 'ENOENT' + }) + await expect(isWorktreePathMissing('/mnt/c/repo/missing', access.statPath)).resolves.toBe( + false + ) }) }) }) diff --git a/src/main/local-worktree-filesystem.ts b/src/main/local-worktree-filesystem.ts index 1078b1f50bc..d6820dd5b14 100644 --- a/src/main/local-worktree-filesystem.ts +++ b/src/main/local-worktree-filesystem.ts @@ -18,8 +18,6 @@ type LocalWorktreePathAccess = { } const WSL_FILE_OPERATION_TIMEOUT_MS = 30_000 -/** The stat probe's explicit "missing path" branch. */ -const WSL_MISSING_PATH_EXIT_CODE = 2 function shouldUseWslFilesystem(options: LocalWorktreeFilesystemOptions): boolean { return process.platform === 'win32' && !!options.wslDistro?.trim() @@ -48,18 +46,27 @@ async function runWslCommand(distro: string, command: string): Promise { } if (result.code !== 0) { throw Object.assign(new Error(result.stderr.trim() || `wsl.exe exited ${result.code}`), { - exitCode: result.code + exitCode: result.code, + stderr: result.stderr }) } return result.stdout } +/** + * Only stat's trailing strerror text is portable: GNU coreutils says `cannot statx`, + * BusyBox says `can't stat`, so matching the verb made a BusyBox distro report a + * successful cleanup as a permanent failure. The tail stays English because the probe + * pins LC_ALL=C, and stat is the only thing in that probe that writes to stderr. + */ +const WSL_MISSING_PATH_STDERR = /: (?:No such file or directory|Not a directory)\r?\n?$/ + function isWslMissingPathError(error: unknown): boolean { - return ( - typeof error === 'object' && - error !== null && - (error as { exitCode?: unknown }).exitCode === WSL_MISSING_PATH_EXIT_CODE - ) + if (typeof error !== 'object' || error === null || !('exitCode' in error)) { + return false + } + const stderr = 'stderr' in error && typeof error.stderr === 'string' ? error.stderr : '' + return error.exitCode === 1 && WSL_MISSING_PATH_STDERR.test(stderr) } export function toLocalWorktreeRuntimePath( @@ -83,19 +90,26 @@ export function getLocalWorktreePathAccess( return { statPath: async (path) => { const target = quotePosixShell(toLinuxPath(path)) - const stdout = await runWslCommand( - distro, - [ - `target=${target}`, - 'if [ -L "$target" ]; then printf symlink; elif [ -f "$target" ]; then printf file; elif [ -d "$target" ]; then printf directory; else exit 2; fi' - ].join('\n') - ).catch((error) => { - if (isWslMissingPathError(error)) { - throw Object.assign(new Error(`missing ${path}`), { code: 'ENOENT' }) + // Shell file tests conflate permission failures with absence; stat preserves the reason. + const stdout = await runWslCommand(distro, `LC_ALL=C stat -c %F -- ${target}`).catch( + (error: unknown) => { + if (isWslMissingPathError(error)) { + throw Object.assign(new Error(`missing ${path}`), { code: 'ENOENT' }) + } + throw error } - throw error - }) - return { type: stdout.trim() } + ) + const kind = stdout.trim() + return { + type: + kind === 'symbolic link' + ? 'symlink' + : kind === 'regular file' || kind === 'regular empty file' + ? 'file' + : kind === 'directory' + ? 'directory' + : 'other' + } }, readPath: async (path) => { const target = quotePosixShell(toLinuxPath(path)) diff --git a/src/main/macos-keychain/generic-password.test.ts b/src/main/macos-keychain/generic-password.test.ts new file mode 100644 index 00000000000..38ec1b05a72 --- /dev/null +++ b/src/main/macos-keychain/generic-password.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const execFileMock = vi.hoisted(() => vi.fn()) + +vi.mock('node:child_process', () => ({ execFile: execFileMock })) + +import { + deleteKeychainPassword, + isKeychainNotFoundError, + readKeychainPassword, + writeKeychainPassword +} from './generic-password' + +const originalPlatform = process.platform + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) +} + +function respond(stdout: string): void { + execFileMock.mockImplementation((_file, _args, _options, callback) => { + callback(null, stdout, '') + return { kill: vi.fn() } + }) +} + +function fail(error: unknown): void { + execFileMock.mockImplementation((_file, _args, _options, callback) => { + callback(error, '', '') + return { kill: vi.fn() } + }) +} + +beforeEach(() => { + execFileMock.mockReset() + setPlatform('darwin') +}) + +afterEach(() => { + setPlatform(originalPlatform) +}) + +describe('readKeychainPassword', () => { + it('returns the trimmed secret for a stored item', async () => { + respond(' secret-value\n') + await expect(readKeychainPassword('cursor-access-token', 'cursor-user')).resolves.toBe( + 'secret-value' + ) + expect(execFileMock.mock.calls[0]?.[1]).toEqual([ + 'find-generic-password', + '-s', + 'cursor-access-token', + '-a', + 'cursor-user', + '-w' + ]) + }) + + it('returns null rather than throwing when the item does not exist', async () => { + fail(Object.assign(new Error('The specified item could not be found'), { code: 44 })) + await expect(readKeychainPassword('svc', 'acct')).resolves.toBeNull() + }) + + it('rethrows a denied or locked keychain so callers can report it', async () => { + fail(new Error('User interaction is not allowed')) + await expect(readKeychainPassword('svc', 'acct')).rejects.toThrow( + 'User interaction is not allowed' + ) + }) + + it('never shells out off macOS', async () => { + setPlatform('win32') + await expect(readKeychainPassword('svc', 'acct')).resolves.toBeNull() + await writeKeychainPassword('svc', 'acct', 'value') + await deleteKeychainPassword('svc', 'acct') + expect(execFileMock).not.toHaveBeenCalled() + }) +}) + +describe('deleteKeychainPassword', () => { + it('swallows a missing item', async () => { + fail(Object.assign(new Error('could not be found'), { code: 44 })) + await expect(deleteKeychainPassword('svc', 'acct')).resolves.toBeUndefined() + }) + + it('surfaces an access failure only when the caller asks for it', async () => { + fail(new Error('User interaction is not allowed')) + await expect(deleteKeychainPassword('svc', 'acct')).resolves.toBeUndefined() + await expect( + deleteKeychainPassword('svc', 'acct', { failOnAccessError: true }) + ).rejects.toThrow('User interaction is not allowed') + }) +}) + +describe('isKeychainNotFoundError', () => { + it('recognizes the security(1) not-found signals', () => { + expect(isKeychainNotFoundError({ code: 44 })).toBe(true) + expect(isKeychainNotFoundError(new Error('The specified item could not be found'))).toBe(true) + expect(isKeychainNotFoundError(new Error('User interaction is not allowed'))).toBe(false) + }) +}) diff --git a/src/main/macos-keychain/generic-password.ts b/src/main/macos-keychain/generic-password.ts new file mode 100644 index 00000000000..8a805edd809 --- /dev/null +++ b/src/main/macos-keychain/generic-password.ts @@ -0,0 +1,147 @@ +// Why execFile and not runProcess: this is a relocation of the Claude keychain +// reader's existing exception, kept byte-for-byte so Claude's credential path and +// its tests are unchanged. See the child_process import allowlist. +import { execFile } from 'node:child_process' + +const KEYCHAIN_COMMAND_TIMEOUT_MS = 3_000 + +type SecurityCommandResult = { + stdout: string + stderr: string +} + +export function isKeychainNotFoundError(error: unknown): boolean { + const code = + error && typeof error === 'object' && 'code' in error + ? (error as { code?: unknown }).code + : undefined + const message = + error && typeof error === 'object' + ? `${String((error as { stderr?: unknown }).stderr ?? '')} ${String( + (error as { message?: unknown }).message ?? '' + )}`.toLowerCase() + : String(error).toLowerCase() + return code === 44 || message.includes('could not be found') || message.includes('not be found') +} + +export function execSecurityCommand(args: string[]): Promise { + return new Promise((resolve, reject) => { + let settled = false + let child: ReturnType | undefined + const timer = setTimeout(() => { + if (settled) { + return + } + settled = true + child?.kill() + reject( + Object.assign(new Error(`security timed out after ${KEYCHAIN_COMMAND_TIMEOUT_MS}ms`), { + code: 'ETIMEDOUT', + stderr: '' + }) + ) + }, KEYCHAIN_COMMAND_TIMEOUT_MS) + + const settle = (callback: () => void): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + callback() + } + + // Why: Node's execFile timeout only signals the `security` process; a + // stuck callback would otherwise leave auth/keychain operations pending. + try { + child = execFile( + 'security', + args, + { timeout: KEYCHAIN_COMMAND_TIMEOUT_MS }, + (error, stdout, stderr) => { + if (error) { + settle(() => + reject( + Object.assign(error, { + stdout: String(stdout), + stderr: String(stderr) + }) + ) + ) + return + } + settle(() => resolve({ stdout: String(stdout), stderr: String(stderr) })) + } + ) + } catch (error) { + settle(() => reject(error)) + } + }) +} + +export function execSecurity( + args: string[], + options?: { ignoreFailure?: boolean; ignoreNotFound?: boolean } +): Promise { + return execSecurityCommand(args).then(undefined, (error: unknown) => { + if (options?.ignoreNotFound && isKeychainNotFoundError(error)) { + return + } + if (!options?.ignoreFailure) { + throw error + } + }) +} + +export async function readKeychainPassword( + service: string, + account: string +): Promise { + if (process.platform !== 'darwin') { + return null + } + try { + const { stdout } = await execSecurityCommand([ + 'find-generic-password', + '-s', + service, + '-a', + account, + '-w' + ]) + if (stdout.trim()) { + return stdout.trim() + } + throw new Error(`Could not read macOS Keychain item ${service}/${account}.`) + } catch (error) { + if (isKeychainNotFoundError(error)) { + return null + } + throw error + } +} + +export async function writeKeychainPassword( + service: string, + account: string, + contents: string +): Promise { + if (process.platform !== 'darwin') { + return + } + await execSecurity(['add-generic-password', '-U', '-s', service, '-a', account, '-w', contents]) +} + +export async function deleteKeychainPassword( + service: string, + account: string, + options?: { failOnAccessError?: boolean } +): Promise { + if (process.platform !== 'darwin') { + return + } + await execSecurity(['delete-generic-password', '-s', service, '-a', account], { + ignoreNotFound: true, + ignoreFailure: !options?.failOnAccessError + }) +} diff --git a/src/main/menu/register-app-menu.test.ts b/src/main/menu/register-app-menu.test.ts index 08b5a489a9c..b5a12718058 100644 --- a/src/main/menu/register-app-menu.test.ts +++ b/src/main/menu/register-app-menu.test.ts @@ -72,6 +72,20 @@ function getSubmenu( } describe('registerAppMenu', () => { + it('shows the Settings hint when the user assigns a shortcut', () => { + registerAppMenu({ + ...buildMenuOptions(), + getKeybindings: () => ({ 'app.settings': ['Mod+Comma'] }) + }) + + const submenu = getSubmenu(getTemplate(), isMac ? 'Orca' : 'File') + expect(submenu).toEqual( + expect.arrayContaining([ + expect.objectContaining({ label: `Settings\t${isMac ? '⌘,' : 'Ctrl+,'}` }) + ]) + ) + }) + it('toggles missing default-on appearance settings from visible to hidden', () => { expect(getNextDefaultOnAppearanceSettingValue(undefined)).toBe(false) expect(getNextDefaultOnAppearanceSettingValue(true)).toBe(false) @@ -381,10 +395,8 @@ describe('registerAppMenu', () => { const fileLabels = getSubmenu(template, 'File').map((item) => item.label) expect(fileLabels).not.toContain(`Export as PDF...\t${isMac ? '⌘⇧E' : 'Ctrl+Shift+E'}`) - expect(fileLabels[0]).toBe(`Settings\t${isMac ? '⌘,' : 'Ctrl+,'}`) - expect(fileLabels).toEqual( - expect.arrayContaining([`Settings\t${isMac ? '⌘,' : 'Ctrl+,'}`, 'Exit']) - ) + expect(fileLabels[0]).toBe('Settings') + expect(fileLabels).toEqual(expect.arrayContaining(['Settings', 'Exit'])) const helpLabels = getSubmenu(template, 'Help').map((item) => item.label) expect(helpLabels).toEqual( @@ -403,9 +415,7 @@ describe('registerAppMenu', () => { const template = getTemplate() const appSubmenu = getSubmenu(template, 'Orca') const appLabels = appSubmenu.map((item) => item.label) - expect(appLabels).toEqual( - expect.arrayContaining(['Check for Updates...', `Settings\t${isMac ? '⌘,' : 'Ctrl+,'}`]) - ) + expect(appLabels).toEqual(expect.arrayContaining(['Check for Updates...', 'Settings'])) // Why: on macOS File should NOT duplicate Settings/Exit — those live in // the system app menu. Without global Export, there is no File item left. expect(template.find((item) => item.label === 'File')).toBeUndefined() diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts index 2f77e7f8941..889e0cd0b08 100644 --- a/src/main/menu/register-app-menu.ts +++ b/src/main/menu/register-app-menu.ts @@ -113,8 +113,16 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void { click: checkForUpdatesClick } + const settingsBindings = getEffectiveKeybindingsForAction( + 'app.settings', + process.platform, + getKeybindings?.() + ) + const settingsShortcut = settingsBindings.length + ? `\t${formatKeybindingList(settingsBindings, process.platform)}` + : '' const settingsItem: Electron.MenuItemConstructorOptions = { - label: `${translateMain('menu.settings', 'Settings')}\t${shortcutLabel('app.settings')}`, + label: `${translateMain('menu.settings', 'Settings')}${settingsShortcut}`, click: () => onOpenSettings() } diff --git a/src/main/muse-usage/store-snapshot-filtering.test.ts b/src/main/muse-usage/store-snapshot-filtering.test.ts new file mode 100644 index 00000000000..0db268083d9 --- /dev/null +++ b/src/main/muse-usage/store-snapshot-filtering.test.ts @@ -0,0 +1,106 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { MuseUsageStore } from './store' +import { museUsageAggregation } from './scanner' +import * as filters from '../usage/usage-scope-filters' +import type { MuseUsageAttributedEvent } from './types' + +const fake = vi.hoisted(() => ({ directory: '' })) +vi.mock('electron', () => ({ app: { getPath: () => fake.directory } })) +vi.mock('../usage-cache-snapshot-writer', () => ({ + UsageCacheSnapshotWriter: class { + flush = async () => {} + write = async () => {} + } +})) + +class FixtureStore extends MuseUsageStore { + constructor(count: number) { + super({ getRepos: () => [], getAllWorktreeMeta: () => ({}) }) + const events: MuseUsageAttributedEvent[] = Array.from({ length: count }, (_, index) => { + const day = ['2026-09-25', '2026-08-01', '2026-05-01'][index % 3] + const inside = index % 2 === 0 + return { + sessionId: `session-${index}`, + timestamp: `${day}T12:00:00.000Z`, + day, + eventKey: String(index), + cwd: null, + model: index % 4 === 0 ? null : `model-${index % 3}`, + projectKey: `project-${index % 4}`, + projectLabel: `Project ${index % 4}`, + repoId: inside ? 'repo' : null, + worktreeId: inside ? `folder-${index % 4}` : null, + inputTokens: 100, + cachedInputTokens: 20, + outputTokens: 10, + reasoningOutputTokens: 2, + totalTokens: 110 + } + }) + const projection = museUsageAggregation.aggregate(events) + this.state = { ...this.state, ...projection } + } +} + +beforeEach(() => { + fake.directory = mkdtempSync(join(tmpdir(), 'orca-muse-snapshot-')) + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 8, 26, 12)) +}) +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + rmSync(fake.directory, { recursive: true, force: true }) +}) + +it('filters each accumulated projection once per snapshot', () => { + const store = new FixtureStore(20_000) + const daily = vi.spyOn(filters, 'filterUsageDaily') + const sessions = vi.spyOn(filters, 'filterUsageSessions') + const result = store.getSnapshot('orca', '30d') + expect(result.summary.sessions).toBeGreaterThan(0) + expect(result.recentSessions).toHaveLength(10) + expect(sessions.mock.calls[0]?.[0]).toHaveLength(20_000) + expect({ daily: daily.mock.calls.length, sessions: sessions.mock.calls.length }).toEqual({ + daily: 1, + sessions: 1 + }) +}) + +it.each(['orca', 'all'] as const)( + 'keeps snapshot projections equal to individual %s reads', + async (scope) => { + const store = new FixtureStore(30) + for (const range of ['7d', '30d', '90d', 'all'] as const) { + for (const limit of [0, 1, 10]) { + const snapshot = store.getSnapshot(scope, range, limit) + expect(snapshot).toEqual({ + scanState: store.getScanState(), + summary: await store.getSummary(scope, range), + daily: await store.getDaily(scope, range), + modelBreakdown: await store.getBreakdown(scope, range, 'model'), + projectBreakdown: await store.getBreakdown(scope, range, 'project'), + recentSessions: await store.getRecentSessions(scope, range, limit) + }) + } + } + } +) + +it('keeps empty snapshots and later calls independent', () => { + const store = new FixtureStore(0) + const first = store.getSnapshot('all', 'all') + const next = store.getSnapshot('orca', '7d', 0) + expect(first.daily).toEqual([]) + expect(first.recentSessions).toEqual([]) + expect(next.summary).toMatchObject({ + sessions: 0, + events: 0, + totalTokens: 0, + scope: 'orca', + range: '7d' + }) +}) diff --git a/src/main/muse-usage/store.ts b/src/main/muse-usage/store.ts index 95f13f3ec8c..b1b22996848 100644 --- a/src/main/muse-usage/store.ts +++ b/src/main/muse-usage/store.ts @@ -56,16 +56,15 @@ export class MuseUsageStore extends UsageProviderStoreLifecycle< range: MuseUsageRange, recentSessionLimit = 10 ): MuseUsageSnapshot { + const daily = this.getFilteredDaily(scope, range) + const sessions = this.getFilteredSessions(scope, range) return { scanState: this.getScanState(), - summary: this.buildSummary(scope, range), - daily: buildMuseUsageDailyPoints(this.getFilteredDaily(scope, range)), - modelBreakdown: this.buildBreakdown(scope, range, 'model'), - projectBreakdown: this.buildBreakdown(scope, range, 'project'), - recentSessions: buildMuseUsageRecentSessions( - this.getFilteredSessions(scope, range), - recentSessionLimit - ) + summary: buildMuseUsageSummary(scope, range, daily, sessions), + daily: buildMuseUsageDailyPoints(daily), + modelBreakdown: buildMuseUsageBreakdownRows('model', scope, daily, sessions), + projectBreakdown: buildMuseUsageBreakdownRows('project', scope, daily, sessions), + recentSessions: buildMuseUsageRecentSessions(sessions, recentSessionLimit) } } diff --git a/src/main/muse/hook-settings.ts b/src/main/muse/hook-settings.ts index 5b5fe3f63fd..0ef3d5d462b 100644 --- a/src/main/muse/hook-settings.ts +++ b/src/main/muse/hook-settings.ts @@ -4,11 +4,11 @@ import { buildManagedCommandHook, createManagedCommandMatcher, getSharedManagedScriptPath, - isPlainObject, wrapPosixHookCommand, wrapWindowsHookCommand, type HookDefinition } from '../agent-hooks/installer-utils' +import { readManagedHookEventsFromJson } from '../agent-hooks/managed-hooks-json-events' const MUSE_SCRIPT_BASE = 'muse-hook' @@ -87,45 +87,9 @@ export function readManagedMuseHookEvents( parsed: unknown, isManagedCommand: (command: string | undefined) => boolean ): Set { - const present = new Set() - if (!isPlainObject(parsed) || !isPlainObject(parsed.hooks)) { - return present - } - for (const event of MUSE_HOOK_EVENTS) { - const definitions = parsed.hooks[event] - if (!Array.isArray(definitions)) { - continue - } - // Why: a hand-edited managed file can hold null definitions, non-array - // hook lists, or null entries — treat all of them as absent so status - // calculation never throws on user content. - if ( - definitions.some((definition) => - managedHookEntries(definition).some((hook) => isManagedCommand(hookEntryCommand(hook))) - ) - ) { - present.add(event) - } - } - return present + return readManagedHookEventsFromJson(parsed, MUSE_HOOK_EVENTS, isManagedCommand) } export function getMuseManagedCommandMatcher(): (command: string | undefined) => boolean { return createManagedCommandMatcher(getMuseManagedScriptFileName()) } - -function managedHookEntries(definition: unknown): readonly unknown[] { - if (!isPlainObject(definition)) { - return [] - } - const hooks = definition.hooks - return Array.isArray(hooks) ? hooks : [] -} - -function hookEntryCommand(hook: unknown): string | undefined { - if (!isPlainObject(hook)) { - return undefined - } - const command = hook.command - return typeof command === 'string' ? command : undefined -} diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.test.ts index 5f0b2f3bc7a..34e0294b458 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.test.ts @@ -153,6 +153,46 @@ describe('agent model catalog store', () => { expect(save).toHaveBeenCalledTimes(2) }) + it("keeps a live child's default effort through a listing that names none, across a restart", async () => { + const directory = mkdtempSync(join(tmpdir(), 'agent-model-catalog-')) + const store = new AgentModelCatalogStore() + await store.attachPersistence(createAgentModelCatalogFilePersistence(directory)) + const efforts = [ + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ] + const listing = (defaultEffort?: string): AgentModelCatalogSuccess => ({ + models: [ + { + id: 'opus', + label: 'Opus', + isDefault: true, + efforts, + ...(defaultEffort ? { defaultEffort } : {}) + } + ], + fastModeTierByModel: new Map(), + origin: 'live-session' + }) + store.recordSuccess('fp', 'claude', listing('medium')) + // A session-less probe never names Claude's default. + store.recordSuccess('fp', 'claude', { ...listing(), origin: 'probe' }) + expect(store.get('fp')!.models[0]!.defaultEffort).toBe('medium') + await store.flushPersistence() + const restarted = new AgentModelCatalogStore() + await restarted.attachPersistence(createAgentModelCatalogFilePersistence(directory)) + expect(restarted.get('fp')!.models[0]!.defaultEffort).toBe('medium') + + // A newer report replaces it; a model that stops offering it drops it. + store.recordSuccess('fp', 'claude', listing('high')) + expect(store.get('fp')!.models[0]!.defaultEffort).toBe('high') + store.recordSuccess('fp', 'claude', { + ...listing(), + models: [{ id: 'opus', label: 'Opus', isDefault: true, efforts: [efforts[0]!] }] + }) + expect(store.get('fp')!.models[0]).not.toHaveProperty('defaultEffort') + }) + it('persists successes only and hydrates them across a restart', async () => { const directory = mkdtempSync(join(tmpdir(), 'agent-model-catalog-')) const store = new AgentModelCatalogStore() diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts index eb26d78de5f..dcb6e3935ac 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts @@ -53,6 +53,22 @@ function tierRecord(tiers: ReadonlyMap): Record return Object.fromEntries(tiers.entries()) } +/** A listing that names no default effort for a model keeps the one a live child reported for it, + * while that model still offers it: Claude's listing never names one, only a running child does. */ +function withKnownDefaultEfforts( + models: readonly AgentSessionModelOption[], + previous: AgentModelCatalogEntry | undefined +): AgentSessionModelOption[] { + return models.map((model) => { + const known = previous?.models.find((entry) => entry.id === model.id)?.defaultEffort + return model.defaultEffort === undefined && + known !== undefined && + model.efforts.some((choice) => choice.value === known) + ? { ...model, defaultEffort: known } + : { ...model } + }) +} + function listingKey(entry: AgentModelCatalogEntry): string { return JSON.stringify([ entry.origin, @@ -135,16 +151,16 @@ export class AgentModelCatalogStore { // An empty list identifies no model; it is doubt, not a catalog. return null } + const previous = this.entries.get(fingerprint) const entry: AgentModelCatalogEntry = { agent, fingerprint, - models: success.models.map((model) => ({ ...model })), + models: withKnownDefaultEfforts(success.models, previous), ...(success.fastModeSupport ? { fastModeSupport: success.fastModeSupport } : {}), fastModeTierByModel: tierRecord(success.fastModeTierByModel), origin: success.origin, fetchedAt: this.now() } - const previous = this.entries.get(fingerprint) this.entries.delete(fingerprint) this.entries.set(fingerprint, entry) this.failures.delete(fingerprint) diff --git a/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts b/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts index 978f076d6e9..c44390b738e 100644 --- a/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A repair drops what it cannot replay, and says so. // // Two things make a suffix unreplayable: a row this build cannot parse, and a @@ -102,9 +103,18 @@ afterEach(async () => { describe('a malformed row', () => { it('keeps the readable prefix live and drops the rest of the epoch', async () => { const journal = await open() - await journal.appendItem(item(0), body('readable'), { fence: 1 }) - await journal.appendItem(item(1), body('unreadable'), { fence: 1 }) - await journal.appendItem(item(2), body('after the fault'), { fence: 1 }) + await journal.appendItem(item(0), body('readable'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(item(1), body('unreadable'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(item(2), body('after the fault'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.close() await withJournalDatabase((db) => { db.prepare('UPDATE journal_rows SET row_json = ? WHERE seq = ?').run('{"not":"a row"}', 3) @@ -118,8 +128,14 @@ describe('a malformed row', () => { it('discloses the line it could not read', async () => { const journal = await open() - await journal.appendItem(item(0), body('readable'), { fence: 1 }) - await journal.appendItem(item(1), body('later'), { fence: 1 }) + await journal.appendItem(item(0), body('readable'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(item(1), body('later'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.close() await withJournalDatabase((db) => { db.prepare('UPDATE journal_rows SET row_json = ? WHERE seq = ?').run('}{', 2) @@ -141,7 +157,10 @@ describe('a sequence gap', () => { it('drops every row after the hole and reports the epoch corrupt', async () => { const journal = await open() for (let ordinal = 0; ordinal < 5; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`m${ordinal}`), { fence: 1 }) + await journal.appendItem(item(ordinal), body(`m${ordinal}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } await journal.close() // Sequence 1 is the epoch row, so the items occupy 2..6. Removing 4 leaves @@ -163,7 +182,10 @@ describe('a sequence gap', () => { it('still reports corrupt on the next probe, with the deleted suffix unrebuilt', async () => { const journal = await open() for (let ordinal = 0; ordinal < 5; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`m${ordinal}`), { fence: 1 }) + await journal.appendItem(item(ordinal), body(`m${ordinal}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } await journal.close() await withJournalDatabase((db) => { @@ -177,7 +199,10 @@ describe('a sequence gap', () => { // Same policy the emptied-epoch repair takes: a session that writes into the // epoch owns it, and a later import must not replace rows the user has seen. const writable = await open() - await writable.appendItem(item(9), body('typed after the repair'), { fence: 1 }) + await writable.appendItem(item(9), body('typed after the repair'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await writable.close() expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: false }) }) @@ -187,7 +212,10 @@ describe('a sequence gap', () => { it('is not settled by the repair disclosure it appends for a malformed row', async () => { const journal = await open() for (let ordinal = 0; ordinal < 3; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`m${ordinal}`), { fence: 1 }) + await journal.appendItem(item(ordinal), body(`m${ordinal}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } await journal.close() await withJournalDatabase((db) => { @@ -207,7 +235,10 @@ describe('a missing epoch row', () => { // renders a repaired journal as a clean timeline. it('rejects the whole surviving range rather than declaring it contiguous', async () => { const journal = await open() - await journal.appendItem(item(0), body('anchor'), { fence: 1 }) + await journal.appendItem(item(0), body('anchor'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.appendSubmission({ clientMessageId: 'client-message-1', payloadFingerprint: 'fingerprint-1', @@ -245,7 +276,10 @@ describe('a missing epoch row', () => { // consulted again and the dropped rows never come back. it('keeps asking for provider history until the epoch has content of its own', async () => { const journal = await open() - await journal.appendItem(item(0), body('anchor'), { fence: 1 }) + await journal.appendItem(item(0), body('anchor'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.close() await withJournalDatabase((db) => { db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(1) @@ -258,7 +292,10 @@ describe('a missing epoch row', () => { // A session that writes into the epoch owns it: its own rows are not a // repair placeholder, and a later import must not replace them. const writable = await open() - await writable.appendItem(item(1), body('typed after the repair'), { fence: 1 }) + await writable.appendItem(item(1), body('typed after the repair'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await writable.close() expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: false }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts index d142672f8e5..17fb2b1eb49 100644 --- a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // The crash boundary: the host wrote a submission row, dispatched, and died // before it learned whether the provider took the message. Replay must reconcile // without duplicating the user's message and without losing it. @@ -16,7 +17,8 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-projection' -import { dispatchWriteFailureReason } from '../../../shared/structured-agent-session-dispatch-rejection' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { digestPayload } from './journal-payload-bounds' import { reconcileSubmissions, @@ -137,7 +139,10 @@ describe('crash between provider accept and journal commit', () => { }) // The provider's own copy of the message arrives next, under the identity // reconciliation adopted. It must land in the bubble the user already sees. - await restarted.appendItem(outcome.identity, userMessage('deploy the thing'), { fence: 2 }) + await restarted.appendItem(outcome.identity, userMessage('deploy the thing'), { + fence: 2, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const items = restarted.snapshot().items expect(items).toHaveLength(1) @@ -180,7 +185,7 @@ describe('crash between provider accept and journal commit', () => { await journal.resolveDispatch({ clientMessageId: 'cm_write_failed', state: 'rejected', - reason: dispatchWriteFailureReason(new Error('broken pipe')), + ...agentSessionFailureWords(agentSessionFailureFact('writeFailed'), { surface: 'rejection' }), fence: 1 }) @@ -191,7 +196,7 @@ describe('crash between provider accept and journal commit', () => { // so recovery must not reopen it as doubt. expect(restarted.submissions()[0]).toMatchObject({ dispatchState: 'rejected', - reason: 'provider_write_failed: broken pipe' + reason: 'provider_write_failed' }) expect(restarted.submissions()[0]?.recovered).toBeUndefined() expect(hasUnansweredStructuredAgentSessionDispatch(restarted.submissions())).toBe(false) @@ -250,7 +255,9 @@ describe('crash between provider accept and journal commit', () => { await restarted.resolveDispatch({ clientMessageId: 'cm_1', state: 'rejected', - reason: 'not_delivered', + ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { + surface: 'rejection' + }), fence: 2, recovered: true }) diff --git a/src/main/native-chat/agent-session-journal/journal-database.ts b/src/main/native-chat/agent-session-journal/journal-database.ts index 6f1cd60733b..e1c4da54a08 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.ts @@ -7,6 +7,7 @@ import Database from '../../sqlite/sync-database' import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files' import { createJournalTablesSql, JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' +import { ensureQueuedMessagesTable } from './queued-message-schema' export const JOURNAL_BUSY_TIMEOUT_MS = 5000 @@ -37,6 +38,10 @@ export function openJournalDatabase(dbPath: string): OpenJournalDatabase { try { configureJournalPragmas(probe) createJournalSchema(probe, stored) + // Outside `createJournalSchema` on purpose: its early return skips a db + // already at the current version, and this table must exist at EVERY + // writable open with no `user_version` bump (see `ensureQueuedMessagesTable`). + ensureQueuedMessagesTable(probe) hardenSqliteDatabaseFiles(dbPath) const opened = { db: probe, readOnly: false } transferred = true diff --git a/src/main/native-chat/agent-session-journal/journal-derived-turn-scope.ts b/src/main/native-chat/agent-session-journal/journal-derived-turn-scope.ts new file mode 100644 index 00000000000..0fbadede3bc --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-derived-turn-scope.ts @@ -0,0 +1,51 @@ +// The turn scope of a row written before rows stated one: the root turn that was open when the +// row was created. Hosts now state a scope on every item they write, so this reads only legacy +// rows, and rows an older host writes into a newer journal after a downgrade. It needs no +// persisted state: the journal is replayed in full on every open, in creation order. +// +// A root turn record created running opens a span, which closes when a revision ends it or +// removes its turn body — a legacy `/compact` row was created as a running turn carrier and then +// overwritten with plain status. A turn record created already settled (rebuilt history) stays +// open until the next root turn record: that is its position within the provider's history. +// Rows created in the window between a crash and the stale-turn sweep land in the dead turn, +// which is where their position puts them too. + +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' + +export class JournalDerivedTurnScope { + private openTurnItemId: string | null = null + + /** The scope for a row being created now that states none. A turn record belongs to no turn. */ + scopeFor(body: AgentJournalItemBody): AgentJournalTurnScope { + return this.openTurnItemId === null || readAgentJournalTurn(body) + ? AGENT_JOURNAL_THREAD_SCOPE + : { kind: 'turn', turnItemId: this.openTurnItemId } + } + + /** Every change to an item, from any row, after it is applied. `before` is undefined when the + * row created the item, `after` when it removed it. Only the session's own turns open a span. */ + observe( + itemId: string, + root: boolean, + before: AgentJournalItemBody | undefined, + after: AgentJournalItemBody | undefined + ): void { + const turn = readAgentJournalTurn(after) + if (before === undefined && turn && root) { + this.openTurnItemId = itemId + return + } + if ( + itemId === this.openTurnItemId && + readAgentJournalTurn(before)?.state === 'running' && + turn?.state !== 'running' + ) { + this.openTurnItemId = null + } + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts index 70052865242..668232e607d 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts @@ -4,9 +4,9 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import { + classifyDispatchRejection, DISPATCH_REJECTED_WRITE_FAILED, - dispatchRejectionReasonIsInternal, - dispatchRejectionWasTransportWriteFailure + isWriteFailureSubmission } from '../../../shared/structured-agent-session-dispatch-rejection' import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' import type { openAgentSessionJournal } from './journal-store-factory' @@ -45,7 +45,14 @@ async function settle(reason: string): Promise { body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, fence: 1 }) - await journal.resolveDispatch({ clientMessageId: 'msg-1', state: 'rejected', reason, fence: 1 }) + await journal.resolveDispatch({ + clientMessageId: 'msg-1', + // A rejection's reason comes only from `agentSessionFailureWords`; an unknown's is still free + // text (an adapter's error), so it is what reaches the bound. + state: 'unknown', + reason, + fence: 1 + }) return journal.snapshot().submissions[0]?.reason ?? null } @@ -81,7 +88,8 @@ describe('dispatch reason bounding', () => { it('keeps a clipped transport failure classifiable', async () => { const stored = await settle(`${DISPATCH_REJECTED_WRITE_FAILED}: ${HUGE}`) expect(stored).not.toBe(`${DISPATCH_REJECTED_WRITE_FAILED}: ${HUGE}`) - expect(dispatchRejectionWasTransportWriteFailure(stored)).toBe(true) - expect(dispatchRejectionReasonIsInternal(stored)).toBe(true) + // Read as an older host wrote it: by the reason alone. + expect(isWriteFailureSubmission({ reason: stored })).toBe(true) + expect(classifyDispatchRejection({ reason: stored })).toMatchObject({ kind: 'writeFailed' }) }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts new file mode 100644 index 00000000000..89722959799 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts @@ -0,0 +1,73 @@ +// How a `dispatch` row settles its submission. Field by field, so a key the row gains must be +// copied here to reach any reader. + +import { + readAgentSessionFailureFact, + type UnreadAgentSessionFailureFact +} from '../../../shared/agent-session-failure' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { journalDispatchRowApplies } from './journal-dispatch-settlement' +import type { JournalReducerState } from './journal-reducer' +import { notePersonTurnAccepted, placeHandedOverMessage } from './journal-submission-fold' +import type { JournalRow } from './journal-row-schema' + +export function applyJournalDispatchRow( + state: JournalReducerState, + row: Extract +): void { + const submission = state.submissions.get(row.clientMessageId) + // Shared with the queued-draft returned hook: a row ignored here must not alter a draft. + if (!submission || !journalDispatchRowApplies(submission)) { + return + } + submission.fence = row.fence + submission.dispatchState = row.state + submission.providerItemId = row.providerItemId + submission.reason = row.reason + const rejection = row.state === 'rejected' ? readStoredRejectionFact(row.rejection) : undefined + if (rejection) { + submission.rejection = rejection + } else { + delete submission.rejection + } + submission.resolvedAt = row.state === 'pending' ? null : row.ts + if (row.state === 'pending') { + submission.handedOverAt = row.ts + placeHandedOverMessage(state, submission, row) + } + if (row.recovered) { + submission.recovered = row.recovered + } else { + delete submission.recovered + } + if (row.state === 'accepted') { + notePersonTurnAccepted(state, submission) + } + if (row.state !== 'accepted' || !row.providerItemId) { + return + } + state.aliases.set(row.providerItemId, agentJournalSubmissionKey(row.clientMessageId)) + state.receipts.set(row.clientMessageId, { + clientMessageId: row.clientMessageId, + providerItemId: row.providerItemId, + cursor: { epoch: row.epoch, sequence: row.seq }, + acceptedAt: row.ts + }) +} + +/** A stored rejection fact, read where it can be placed; a kind it cannot place is kept as + * written, so the classifier still knows a fact was there without this build claiming what it + * says. Shared with the queued-draft table, whose returned card mirrors its submission. */ +export function readStoredRejectionFact(value: unknown): UnreadAgentSessionFailureFact | undefined { + return readAgentSessionFailureFact(value) ?? unreadFailureFact(value) +} + +function unreadFailureFact(value: unknown): UnreadAgentSessionFailureFact | undefined { + return typeof value === 'object' && + value !== null && + 'kind' in value && + typeof value.kind === 'string' && + value.kind + ? { kind: value.kind } + : undefined +} diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts new file mode 100644 index 00000000000..7f9711151fc --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { + agentSessionFailureFact, + type SubmissionRejectionKind +} from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_HOST_RESTARTED +} from '../../../shared/structured-agent-session-dispatch-rejection' +import { rejectedDraftSettlement } from './journal-dispatch-settlement' + +function settle(kind: SubmissionRejectionKind) { + return rejectedDraftSettlement( + agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'rejection' }) + ) +} + +describe('what a rejection does to the draft it was consumed from', () => { + it("a Stop's withdrawal sends it back to waiting, under the queue's pause rather than a hold of its own", () => { + expect(settle('cancelled')).toEqual({ state: 'waiting' }) + expect(rejectedDraftSettlement({ reason: DISPATCH_REJECTED_CANCELLED })).toEqual({ + state: 'waiting' + }) + }) + + it('a restart or close before hand-over sends it back to waiting too', () => { + for (const kind of ['hostRestarted', 'chatClosed', 'notDelivered'] as const) { + expect(settle(kind)).toEqual({ state: 'waiting' }) + } + expect(rejectedDraftSettlement({ reason: DISPATCH_REJECTED_HOST_RESTARTED })).toEqual({ + state: 'waiting' + }) + }) + + it('a failure returns the card for the user to act on', () => { + for (const kind of [ + 'providerRejected', + 'providerExited', + 'hostStopped', + 'startFailed', + 'writeFailed', + 'queueFull' + ] as const) { + expect(settle(kind)).toEqual({ state: 'returned' }) + } + expect(rejectedDraftSettlement({ reason: 'the provider said no' })).toEqual({ + state: 'returned' + }) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts new file mode 100644 index 00000000000..7e1d6949bf3 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts @@ -0,0 +1,58 @@ +// The one decision for whether a committed dispatch row changes a submission's +// effective delivery answer, and what a rejection does to the draft it was +// consumed from. The reducer folds rows through the first and the queued +// draft's settlement hook fires through it, so the two can never disagree: a +// row the reducer ignores must not alter a draft. + +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { classifyDispatchRejection } from '../../../shared/structured-agent-session-dispatch-rejection' +import type { JournalDispatchRow } from './journal-row-schema' + +/** `rejected` and `accepted` are terminal; a late row for an absent or settled + * submission must not reopen the answer. */ +export function journalDispatchRowApplies( + submission: Pick | undefined +): boolean { + return ( + submission !== undefined && + submission.dispatchState !== 'rejected' && + submission.dispatchState !== 'accepted' + ) +} + +/** A consumed draft's submission settled `rejected`: the draft is settled by + * `rejectedDraftSettlement`, since its text has no other holder once it left + * the sender's outbox as a draft. */ +export function consumedSubmissionWasRejected( + submission: Pick | undefined +): boolean { + return submission?.dispatchState === 'rejected' +} + +/** What a consumed draft becomes when its submission is rejected. */ +export type RejectedDraftSettlement = { state: 'returned' } | { state: 'waiting' } + +/** + * Where no one failed the user — a Stop withdrew it, or a restart or close + * interrupted it before hand-over — the draft goes back to waiting at its own + * position, under whatever pauses the queue: the Stop's own pause, or the + * restart's, derived from the host instance. A returned card would block the + * drafts behind it on a failure that never happened. A failure returns the + * card with its refusal for the user to act on. + */ +export function rejectedDraftSettlement( + rejection: Pick & { rejection?: unknown } +): RejectedDraftSettlement { + return classifyDispatchRejection(rejection).verdict === null + ? { state: 'waiting' } + : { state: 'returned' } +} + +/** True when committing this row NEWLY settles the submission to `rejected` — + * the only transition that settles a consumed draft. */ +export function journalDispatchRowNewlyRejects( + submission: Pick | undefined, + row: Pick +): boolean { + return row.state === 'rejected' && journalDispatchRowApplies(submission) +} diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts index 96273366d5f..7d87399c62b 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Republishing an epoch is ONE transaction. import { mkdtemp, rm } from 'node:fs/promises' @@ -86,8 +87,16 @@ describe('journal epoch replacement', () => { it('discards every superseded row in the same transaction', async () => { const journal = await journals.open({ identity: IDENTITY, journalDir: root }) - await journal.appendItem(item(1), { kind: 'status', text: 'old' }, { fence: 1 }) - await journal.appendItem(item(2), { kind: 'status', text: 'older' }, { fence: 1 }) + await journal.appendItem( + item(1), + { kind: 'status', text: 'old' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal.appendItem( + item(2), + { kind: 'status', text: 'older' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) const before = journal.epoch await journal.replaceEpochItems('legacy_import', 1, [ diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts index 9512d5e9fa1..cc9a2adc1bd 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts @@ -7,6 +7,8 @@ import type { AgentJournalItemBody, AgentJournalItemIdentity, + AgentJournalProducerLinkage, + AgentJournalTurnScope, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type Database from '../../sqlite/sync-database' @@ -22,10 +24,13 @@ import { import type { AgentJournalEpochReason, JournalRow } from './journal-row-schema' import { assertJournalFence } from './journal-write-guards' -export type JournalReplacementItem = { +export type JournalReplacementItem = AgentJournalProducerLinkage & { identity: AgentJournalItemIdentity body: AgentJournalItemBody observedAt?: number + /** Absent for history rebuilt from a source that never stated one: derived from position, as + * a legacy row's is, and then written down. */ + turnScope?: AgentJournalTurnScope } export function replaceJournalEpoch(input: { @@ -56,7 +61,9 @@ export function replaceJournalEpoch(input: { body: item.body, seq: state.lastSequence + 1, fence: input.fence, - ts: item.observedAt ?? input.now() + ts: item.observedAt ?? input.now(), + linkage: item, + turnScope: item.turnScope ?? state.derivedTurnScope.scopeFor(item.body) }) assertJournalFence(row.fence, state.highestFence) applyJournalRow(state, row) diff --git a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts index d6424ce7952..34fed89be53 100644 --- a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // An empty chat beside a pre-SQLite journal explains itself. // // The SQLite move shipped no importer, so a session whose history is a @@ -142,7 +143,7 @@ describe('a chat whose history is still in the pre-SQLite format', () => { await journal.appendItem( { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'history' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.close() // Deleting the anchor leaves every row unanchored: replay keeps nothing, so diff --git a/src/main/native-chat/agent-session-journal/journal-handle-ownership.test.ts b/src/main/native-chat/agent-session-journal/journal-handle-ownership.test.ts index 592f7471c5c..7a69de557a4 100644 --- a/src/main/native-chat/agent-session-journal/journal-handle-ownership.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-handle-ownership.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Every path that can open a SQLite connection releases it. // // Asserting that the happy path closes cleanly proves nothing: these sites are @@ -73,7 +74,11 @@ afterEach(async () => { describe('the standalone probe owns its own connection', () => { it('leaves no handle behind after fifty repeated loads', async () => { const journal = await journals.open({ identity: IDENTITY, journalDir: root }) - await journal.appendItem(item(1), { kind: 'message', role: 'user', blocks: [] }, { fence: 1 }) + await journal.appendItem( + item(1), + { kind: 'message', role: 'user', blocks: [] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) await journal.close() for (let attempt = 0; attempt < 50; attempt += 1) { @@ -141,7 +146,10 @@ describe('failure paths inside the open call', () => { // connection exists, which is what lets the factory need no `finally`. it('leaves nothing open when a post-connection step of open() throws', async () => { const journal = await journals.open({ identity: IDENTITY, journalDir: root }) - await journal.appendItem(item(1), runningTool(), { fence: 1 }) + await journal.appendItem(item(1), runningTool(), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.close() // Replay runs after the connection is open, so a read it cannot serve diff --git a/src/main/native-chat/agent-session-journal/journal-item-fold.ts b/src/main/native-chat/agent-session-journal/journal-item-fold.ts new file mode 100644 index 00000000000..10aee4a99c4 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-item-fold.ts @@ -0,0 +1,97 @@ +// Folding one item write into the render model: the revision, tombstone and creating-write rules +// the reducer header states. Turn scope is the creating write's too, with one exception, which the +// submission fold owns: a queued message's scope and position are its handover's. + +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + agentJournalLinkageFields, + isRootAgentJournalItem, + namesAgentJournalProducer +} from '../../../shared/agent-session-journal-producer' +import type { JournalReducerState } from './journal-reducer' + +export function statedOrDerivedTurnScope( + state: JournalReducerState, + write: { body: AgentJournalItemBody; turnScope?: AgentJournalTurnScope } +): AgentJournalTurnScope { + return write.turnScope ?? state.derivedTurnScope.scopeFor(write.body) +} + +export function upsertJournalItem( + state: JournalReducerState, + itemId: string, + revision: number, + next: AgentJournalRenderItem, + /** The creating write's fence: the generation a running turn belongs to. */ + fence: number +): void { + const tombstoned = state.tombstones.get(itemId) + if (tombstoned !== undefined && revision <= tombstoned) { + return + } + const existing = state.items.get(itemId) + if (existing && revision <= existing.revision) { + return + } + state.derivedTurnScope.observe(itemId, isRootAgentJournalItem(next), existing?.body, next.body) + if (!existing) { + state.items.set(itemId, next) + state.itemFences.set(itemId, fence) + state.tombstones.delete(itemId) + return + } + // Creation sequence is the ordering key; a revision refreshes content only. + // `observedAt` is pinned with it: clients sort the timeline by that timestamp, + // so letting a revision advance it makes the row jump past everything that + // landed in between — the provider's own echo of a send revises the submission + // row, which relocated the user's bubble below later rows. + const submitted = + existing.body.kind === 'message' && + existing.body.role === 'user' && + parseAgentJournalItemKey(itemId)?.provider === 'orca' + const { sequenceIndex: _revisedAt, ...revised } = next + state.items.set(itemId, { + ...revised, + // Settlements, prompt answers and reopen sweeps revise rows any agent wrote + // without naming one; each would otherwise hand a subagent's row to the session. + ...(namesAgentJournalProducer(next) ? {} : agentJournalLinkageFields(existing)), + // Provider history may normalize text or omit local attachments from the original send. + body: submitted ? existing.body : next.body, + sequence: existing.sequence, + ...(existing.sequenceIndex !== undefined ? { sequenceIndex: existing.sequenceIndex } : {}), + observedAt: existing.observedAt, + turnScope: existing.turnScope ?? next.turnScope + }) + state.tombstones.delete(itemId) +} + +export function removeJournalItem( + state: JournalReducerState, + itemId: string, + revision: number +): void { + const existing = state.items.get(itemId) + if (existing && revision <= existing.revision) { + return + } + const tombstoned = state.tombstones.get(itemId) + if (tombstoned !== undefined && revision <= tombstoned) { + return + } + if (existing) { + state.derivedTurnScope.observe( + itemId, + isRootAgentJournalItem(existing), + existing.body, + undefined + ) + } + state.tombstones.set(itemId, revision) + state.items.delete(itemId) + state.itemFences.delete(itemId) +} diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts index 92b085e2e0f..7751265b72e 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Legacy import runs the existing per-agent transcript decoders and keys the // results by identity read off the same raw lines. Fixtures are shaped like the // files the providers actually write. @@ -567,7 +568,7 @@ describe('import failures', () => { await journal.appendItem( { provider: 'codex', threadId: CODEX_SESSION, turnId: 'turn-1', ordinal: 1 }, { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'kept' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const before = journal.epoch const metadataOnly = await writeFixture('metadata-only.jsonl', [ diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts new file mode 100644 index 00000000000..a5427f76fdc --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts @@ -0,0 +1,159 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { openJournalDatabase } from './journal-database' +import { + classifyJournalOpenFailure, + createJournalOpenReadRefusals, + journalOpenReadRefusal +} from './journal-open-failure' +import { loadJournal } from './journal-open' +import { journalDatabaseFile } from './journal-paths' + +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-open-failure-')) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +/** What the journal's own open throws for the file as it stands. */ +function openFailure(): unknown { + try { + loadJournal(root, 'session-1') + } catch (error) { + return error + } + throw new Error('the journal opened') +} + +/** A thrown value shaped as node:sqlite shapes one, with the result code it reports. */ +function nodeSqliteError(errcode: number): Error { + return Object.assign(new Error('sqlite'), { code: 'ERR_SQLITE_ERROR', errcode }) +} + +function systemError(code: string, errno: number): Error { + return Object.assign(new Error(`${code}: open`), { code, errno }) +} + +describe('classifyJournalOpenFailure', () => { + it('calls a journal that is not a database corrupt', async () => { + await writeFile(journalDatabaseFile(root), 'not a database '.repeat(64)) + const error = openFailure() + expect(error).toMatchObject({ errcode: 26 }) + expect(classifyJournalOpenFailure(error)).toBe('journalCorrupt') + }) + + it('calls a journal whose pages are damaged corrupt', async () => { + const path = journalDatabaseFile(root) + const opened = openJournalDatabase(path) + opened.db.exec('PRAGMA journal_mode = DELETE') + opened.db.close() + const bytes = await readFile(path) + // Page 1 holds the header and schema; every table's root page follows it. + bytes.fill(0xab, 4096) + await writeFile(path, bytes) + const error = openFailure() + expect(error).toMatchObject({ errcode: 11 }) + expect(classifyJournalOpenFailure(error)).toBe('journalCorrupt') + }) + + it.each([ + ['SQLITE_CORRUPT_VTAB', 267], + ['SQLITE_CORRUPT_SEQUENCE', 523], + ['SQLITE_CORRUPT_INDEX', 779] + ])('reads an extended corrupt code as corrupt: %s', (_name, errcode) => { + expect(classifyJournalOpenFailure(nodeSqliteError(errcode))).toBe('journalCorrupt') + }) + + it("reads the Bun driver's corrupt code as corrupt", () => { + const error = Object.assign(new Error('file is not a database'), { + name: 'SQLiteError', + code: 'SQLITE_NOTADB', + errno: 26 + }) + expect(classifyJournalOpenFailure(error)).toBe('journalCorrupt') + }) + + it('finds corruption a wrapper names as its cause', () => { + const wrapped = new Error('opening the conversation failed', { + cause: new Error('the journal would not open', { cause: nodeSqliteError(11) }) + }) + expect(classifyJournalOpenFailure(wrapped)).toBe('journalCorrupt') + }) + + it.each([ + ['a busy database', nodeSqliteError(5)], + ['a locked database', nodeSqliteError(6)], + ['a database SQLite cannot open', nodeSqliteError(14)], + ['a disk I/O error', nodeSqliteError(10)], + ['permission denied', systemError('EACCES', -13)], + ['too many open files', systemError('EMFILE', -24)], + ['a Windows error whose low byte reads as corrupt', systemError('EUNKNOWN', -4085)], + ['an error that only claims a corrupt code', Object.assign(new Error('x'), { errcode: 11 })], + ['a thrown string', 'database disk image is malformed'], + ['nothing at all', undefined] + ])('calls any other failure one that can clear: %s', (_label, error) => { + expect(classifyJournalOpenFailure(error)).toBe('journalUnavailable') + }) + + it('stops on a cause chain that loops back on itself', () => { + const first = new Error('first') + const second = new Error('second', { cause: first }) + Object.assign(first, { cause: second }) + expect(classifyJournalOpenFailure(first)).toBe('journalUnavailable') + }) +}) + +describe('journalOpenReadRefusal', () => { + it('names the reason, keeps the message the code and the storage text only as the cause', () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const storage = nodeSqliteError(26) + const refusal = journalOpenReadRefusal(storage) + expect(refusal.message).toBe('agent_session_journal_unreadable') + expect(refusal.refusal).toMatchObject({ + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + }) + expect(refusal.cause).toBe(storage) + vi.restoreAllMocks() + }) + + it('passes a refusal the open already raised through unchanged', () => { + const raised = agentSessionRefusalError('agent_session_identity_required', { + reason: 'recordMissing' + }) + expect(journalOpenReadRefusal(raised)).toBe(raised) + }) +}) + +describe('createJournalOpenReadRefusals', () => { + it('logs a session once per failure until it opens, and each session on its own', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const refusals = createJournalOpenReadRefusals() + const denied = systemError('EACCES', -13) + const corrupt = nodeSqliteError(26) + + for (let attempt = 0; attempt < 3; attempt += 1) { + const refusal = refusals.refusal('session-1', denied) + expect(refusal.refusal).toMatchObject({ details: { reason: 'journalUnavailable' } }) + expect(refusal.cause).toBe(denied) + } + expect(warn).toHaveBeenCalledTimes(1) + refusals.refusal('session-2', denied) + expect(warn).toHaveBeenCalledTimes(2) + expect(refusals.refusal('session-1', corrupt).refusal).toMatchObject({ + details: { reason: 'journalCorrupt' } + }) + expect(warn).toHaveBeenCalledTimes(3) + refusals.forget('session-1') + refusals.refusal('session-1', corrupt) + expect(warn).toHaveBeenCalledTimes(4) + vi.restoreAllMocks() + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.ts new file mode 100644 index 00000000000..5e4cff1f3ee --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.ts @@ -0,0 +1,80 @@ +// What a failed journal open means for the chat: its history is damaged, which no retry reads +// past, or the open failed in a way that can clear (a lock, permissions, too many open files). + +import type { AgentSessionRefusalReason } from '../../../shared/agent-session-refusal-details' +import { + AgentSessionRefusalError, + isAgentSessionRefusalError, + refuse +} from '../../../shared/agent-session-wire-refusals' +import { isSqliteCorruption } from '../../sqlite/sqlite-read-failure' + +export type JournalOpenFailure = AgentSessionRefusalReason<'agent_session_journal_unreadable'> + +// Bounds a cause chain that loops back on itself. +const MAX_CAUSE_DEPTH = 8 + +/** Damage only where the storage says so; anything unproven can clear. */ +export function classifyJournalOpenFailure(error: unknown): JournalOpenFailure { + let current = error + for (let depth = 0; depth < MAX_CAUSE_DEPTH && current !== undefined; depth += 1) { + if (isSqliteCorruption(current)) { + return 'journalCorrupt' + } + current = current instanceof Error ? current.cause : undefined + } + return 'journalUnavailable' +} + +/** + * What a read throws when the conversation it reaches cannot be opened. The storage's own text + * (a path, "file is not a database") goes to the log only; the reader gets the classified refusal, + * whose message stays the bare code. + */ +export function journalOpenReadRefusal(error: unknown): AgentSessionRefusalError { + if (isAgentSessionRefusalError(error)) { + return error + } + return unreadableRefusal(error, classifyJournalOpenFailure(error), true) +} + +const MAX_LOGGED_SESSIONS = 256 + +/** + * The read door's refusals for one host. A reader reconnects on a timer while an open can clear, + * so a session's failure is logged once until that session opens or the failure changes. + */ +export function createJournalOpenReadRefusals() { + const logged = new Map() + return { + refusal: (sessionId: string, error: unknown): AgentSessionRefusalError => { + if (isAgentSessionRefusalError(error)) { + return error + } + const reason = classifyJournalOpenFailure(error) + const failure = `${reason}:${error instanceof Error ? error.message : String(error)}` + const repeat = logged.get(sessionId) === failure + // Past the cap a new session logs every failure rather than evict another's. + if (!repeat && (logged.has(sessionId) || logged.size < MAX_LOGGED_SESSIONS)) { + logged.set(sessionId, failure) + } + return unreadableRefusal(error, reason, !repeat) + }, + /** The session opened or closed: its next failure is news. */ + forget: (sessionId: string): void => { + logged.delete(sessionId) + } + } +} + +function unreadableRefusal( + error: unknown, + reason: JournalOpenFailure, + log: boolean +): AgentSessionRefusalError { + if (log) { + console.warn('[agent-session] opening the conversation for a read failed:', error) + } + const code = 'agent_session_journal_unreadable' + return new AgentSessionRefusalError(refuse(code, { reason }, code), { cause: error }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts index c4138ba2241..c252a57d8a5 100644 --- a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts +++ b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts @@ -1,8 +1,12 @@ +import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { DISPATCH_DOUBT_HOST_RESTARTED } from './journal-dispatch-doubt-reasons' import type { AgentSessionJournal } from './journal-store' /** Settles every submission a process fact left unanswerable. Doubt is never - * proof of non-delivery, so nothing here ever becomes re-deliverable. */ + * proof of non-delivery, so nothing here ever becomes re-deliverable. A queued + * submission was never handed over, so it is not in doubt and is left alone. */ export async function markJournalPendingSubmissionsUnknown( journal: AgentSessionJournal, fence: number, @@ -12,8 +16,9 @@ export async function markJournalPendingSubmissionsUnknown( .submissions() .filter( (entry) => - entry.dispatchState === 'pending' || - (entry.dispatchState === 'unknown' && entry.recovered !== true) + !isQueuedAgentJournalSubmission(entry) && + (entry.dispatchState === 'pending' || + (entry.dispatchState === 'unknown' && entry.recovered !== true)) ) for (const entry of unresolved) { // An earlier reason already names a sharper fact than "the host restarted". @@ -31,27 +36,51 @@ export async function markJournalPendingSubmissionsUnknown( } /** Settles every submission a child that never proved its start left unanswered as `rejected`: - * such a child accepted nothing, so each is provably unwritten and safe to send again. */ + * such a child accepted nothing, so each is provably unwritten and safe to send again. A queued + * submission was never handed to that child; the delivery loop settles it. */ export async function rejectJournalPendingSubmissions( journal: AgentSessionJournal, fence: number, - reason: string + rejection: AgentJournalDispatchRejection ): Promise { const unwritten = journal .submissions() .filter( (entry) => - entry.dispatchState === 'pending' || - (entry.dispatchState === 'unknown' && entry.recovered !== true) + !isQueuedAgentJournalSubmission(entry) && + (entry.dispatchState === 'pending' || + (entry.dispatchState === 'unknown' && entry.recovered !== true)) ) for (const entry of unwritten) { await journal.resolveDispatch({ clientMessageId: entry.clientMessageId, state: 'rejected', - reason, + ...rejection, fence, recovered: true }) } return unwritten.map((entry) => entry.clientMessageId) } + +/** Rejects queued submissions — accepted, never handed over, so provably unwritten. */ +export async function rejectJournalQueuedSubmissions( + journal: AgentSessionJournal, + fence: number, + rejection: AgentJournalDispatchRejection, + which: (submission: AgentJournalSubmission) => boolean = () => true +): Promise { + const queued = journal + .submissions() + .filter((entry) => isQueuedAgentJournalSubmission(entry) && which(entry)) + for (const entry of queued) { + await journal.resolveDispatch({ + clientMessageId: entry.clientMessageId, + state: 'rejected', + ...rejection, + fence, + recovered: true + }) + } + return queued.map((entry) => entry.clientMessageId) +} diff --git a/src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts b/src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts index 07ae011b790..d31d2569c18 100644 --- a/src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -79,7 +80,8 @@ function seeded() { seq, fence: 1, ts: 1_000 + seq, - ...(stamp ? { linkage: stamp } : {}) + ...(stamp ? { linkage: stamp } : {}), + turnScope: AGENT_JOURNAL_THREAD_SCOPE }) ) } @@ -90,7 +92,12 @@ function seeded() { journalLifecycleBatchRowBuilder( () => state, `settle-${seq}`, - identities.map((identity) => ({ kind: 'item' as const, identity, body: text('settled') })), + identities.map((identity) => ({ + kind: 'item' as const, + identity, + body: text('settled'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })), { fence: 1 } )(seq, 1_000 + seq) ) @@ -180,15 +187,35 @@ describe('producer inheritance across a reopen', () => { it('replays an inherited producer from disk exactly as it was folded live', async () => { const journal = await open() - await journal.appendItem(child, text('working'), { fence: 1, ...linkage }) - await journal.appendItem(own, text('working'), { fence: 1 }) - await journal.appendItem(child, text('still working'), { fence: 1 }) + await journal.appendItem(child, text('working'), { + fence: 1, + ...linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(own, text('working'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(child, text('still working'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.appendLifecycleBatch({ settlementId: 'settle-1', fence: 1, mutations: [ - { kind: 'item', identity: child, body: text('settled') }, - { kind: 'item', identity: own, body: text('settled') } + { + kind: 'item', + identity: child, + body: text('settled'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }, + { + kind: 'item', + identity: own, + body: text('settled'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } ] }) const live = journal.snapshot().items diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts new file mode 100644 index 00000000000..3fc8c373788 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -0,0 +1,369 @@ +// The journal's draft-store collaborator: every read and write of one session's +// `queued_messages` rows, serialized on the same queue as the journal's own +// appends so a draft mutation can never interleave with the consume that +// converts it. Drafts are NEVER owed work: nothing here feeds the reducer, +// working status, teardown, or the idle sweep. + +import type Database from '../../sqlite/sync-database' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS +} from '../../../shared/agent-session-host-authority' +import type { JournalReducerState } from './journal-reducer' +import type { JournalRow } from './journal-row-schema' +import type { JournalSubmissionConsume } from './journal-store-contracts' +import { adoptQueuedMessages, holdQueuedMessages } from './queued-message-holds' +import { + clearQueuePause, + queuePauseHoldsBack, + readQueuePause, + recordQueuePause, + retireQueuePauseIfNothingHeld, + type QueuePauseFact, + type QueuePauseReason +} from './queued-message-pause-table' +import { + consumeQueuedMessageInTransaction, + getQueuedMessage, + insertQueuedMessage, + listQueuedMessages, + queuedMessagesSettledByOp, + withdrawQueuedMessages, + type QueuedMessageHoldReason, + type QueuedMessageRow +} from './queued-message-table' +import { draftsDeliveredByAppliedEcho } from './queued-message-delivered-echo' +import { pruneQueuedMessages, retainedSubmissionVerdict } from './queued-message-retention' +import { + owedBackToWaiting, + queuedMessageSettlementOwed, + settleOwedQueuedMessages, + settleQueuedMessagesForRow +} from './queued-message-settlement' +import { AgentSessionJournalError, assertJournalWritable } from './journal-write-guards' + +/** Tombstones must outlive the window in which their operation id could still be admitted as new. */ +export const QUEUED_MESSAGE_REPLAY_WINDOW_MS = + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS + +export type JournalQueuedMessagesDeps = { + sessionId: string + now: () => number + serialize: (run: () => Promise) => Promise + database: () => { db: Database.Database } + readOnly: () => boolean + state: () => JournalReducerState + /** The journal's own commit notification. Every standalone draft-table + * transaction that changed rows fires it after COMMIT, so a draft or hold + * change publishes and wakes the drain through the same path a journal row + * does — no call site can forget. In-transaction consume and the returned + * transition already ride their row's own commit. */ + committed: () => void +} + +export class JournalQueuedMessages { + /** Bumped on every draft-table write, so publication memos recompute only when they must. */ + private changeRevision = 0 + private listed: { revision: number; rows: readonly QueuedMessageRow[] } | null = null + private paused: { revision: number; fact: QueuePauseFact | null } | null = null + + constructor(private readonly deps: JournalQueuedMessagesDeps) {} + + revision(): number { + return this.changeRevision + } + + /** The submission row of the latest accepted turn a person asked for; 0 when none. What + * ends the queue's pause, read from the reducer in O(1). */ + latestPersonTurnSequence(): number { + return this.deps.state().latestPersonTurnSequence + } + + /** A journal transaction rolled back: nothing read inside it may stay cached. */ + invalidate(): void { + this.changeRevision++ + } + + /** Cached per revision: the drain re-checks on every journal publish, so an + * unchanged table must cost no SQL read or body parse on token streams. */ + list(): readonly QueuedMessageRow[] { + if (this.listed?.revision !== this.changeRevision) { + this.listed = { + revision: this.changeRevision, + rows: listQueuedMessages(this.deps.database().db, this.deps.sessionId) + } + } + return this.listed.rows + } + + get(messageId: string): QueuedMessageRow | null { + return getQueuedMessage(this.deps.database().db, this.deps.sessionId, messageId) + } + + /** Replay receipts for one caller-scoped operation key. */ + receipts(settledByOp: string): QueuedMessageRow[] { + return queuedMessagesSettledByOp(this.deps.database().db, this.deps.sessionId, settledByOp) + } + + /** `pausedBy`: the queue is paused in the SAME transaction as this card lands + * (a /clear's carry), so the drain never sees it unpaused and no pause fact + * exists without a card under it. */ + insert(input: { + messageId: string + body: AgentJournalMessageItem + fingerprint: string + hostInstance: string + pausedBy?: QueuePauseReason + }): Promise { + const { pausedBy, ...draft } = input + const { sessionId } = this.deps + let inserted = false + return this.transact( + (db) => { + const existing = getQueuedMessage(db, sessionId, draft.messageId) + if (existing) { + // One id, one draft: admission replays a recorded operation before it + // gets here, so an existing row is the same accept landing twice. + return existing + } + inserted = true + const row = insertQueuedMessage(db, { ...draft, sessionId, now: this.deps.now() }) + if (pausedBy) { + recordQueuePause(db, { sessionId, fact: this.pauseFact(pausedBy) }) + } + return row + }, + () => inserted + ) + } + + /** Hold one waiting draft whose conversion failed. Stored on the row, so it + * survives handle eviction and restart; withdraw and consume clear it in their + * own UPDATE. */ + hold(input: { messageIds: readonly string[]; reason: QueuedMessageHoldReason }): Promise { + return this.transact( + (db) => holdQueuedMessages(db, { ...input, sessionId: this.deps.sessionId }), + (held) => held > 0 + ).then(() => undefined) + } + + /** Where the user's last Stop took effect, if it is still recorded; cached per revision. */ + pause(): QueuePauseFact | null { + if (this.paused?.revision !== this.changeRevision) { + this.paused = { + revision: this.changeRevision, + fact: readQueuePause(this.deps.database().db, this.deps.sessionId) + } + } + return this.paused.fact + } + + /** A Stop took effect here: the queue is paused from this position on — if, judged in + * the same transaction, it holds back a card at all. Returns whether it recorded. */ + recordPause(reason: QueuePauseReason): Promise { + const fact = this.pauseFact(reason) + return this.transact( + (db) => + queuePauseHoldsBack(db, this.pauseScope()) && + (recordQueuePause(db, { sessionId: this.deps.sessionId, fact }), true), + (recorded) => recorded + ) + } + + /** What `queuePauseHoldsBack` judges a pause by, from this journal's submissions. */ + private pauseScope() { + return { + sessionId: this.deps.sessionId, + owedToWaiting: owedBackToWaiting(this.deps.state().submissions) + } + } + + private pauseFact(reason: QueuePauseReason): QueuePauseFact { + const { epoch, lastSequence: sequence } = this.deps.state() + return { reason, epoch, sequence, recordedAt: this.deps.now() } + } + + /** Ends the queue's pause: `stop` retires that Stop fact (never a later one), + * `adoptInto` adopts a restart's rows into this host instance. Returns whether + * anything changed. */ + liftPause(input: { stop: QueuePauseFact | null; adoptInto: string | null }): Promise { + const { sessionId } = this.deps + return this.transact( + (db) => + (input.stop ? clearQueuePause(db, { sessionId, fact: input.stop }) : 0) + + (input.adoptInto === null + ? 0 + : adoptQueuedMessages(db, { sessionId, hostInstance: input.adoptInto })), + (changed) => changed > 0 + ).then((changed) => changed > 0) + } + + /** Compare-and-transition waiting ∪ returned rows to op-stamped tombstones, + * kept only so a replay of the settling operation answers "spent". */ + withdraw(input: { + messageIds: readonly string[] + settledByOp: string + }): Promise { + if (input.messageIds.length === 0) { + // Delete races and empty carries land here; neither may cost a write transaction. + return Promise.resolve([]) + } + return this.transact( + (db) => + withdrawQueuedMessages(db, { + ...input, + sessionId: this.deps.sessionId, + now: this.deps.now() + }), + (withdrawn) => withdrawn.length > 0 + ) + } + + /** One standalone draft-table transaction on the journal's queue; one that + * changed rows bumps the revision and notifies after COMMIT. */ + private transact( + run: (db: Database.Database) => T, + changed: (result: T) => boolean + ): Promise { + return this.deps.serialize(async () => { + assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) + const { db } = this.deps.database() + db.exec('BEGIN IMMEDIATE') + let result: T + let retired: number + try { + result = run(db) + // Any draft write may take the last card a pause holds back. + retired = retireQueuePauseIfNothingHeld(db, this.pauseScope()) + db.exec('COMMIT') + } catch (error) { + db.exec('ROLLBACK') + throw error + } + if (changed(result) || retired > 0) { + this.changeRevision++ + this.deps.committed() + } + return result + }) + } + + /** The standing writer hook, within the append's transaction + * (`settleQueuedMessagesForRow`). */ + onRowInTransaction(db: Database.Database, row: JournalRow): void { + this.changeRevision += settleQueuedMessagesForRow(db, { + sessionId: this.deps.sessionId, + state: this.deps.state(), + drafts: () => this.list(), + row, + now: this.deps.now() + }) + this.changeRevision += retireQueuePauseIfNothingHeld(db, this.pauseScope()) + } + + /** The in-transaction consume for `appendSubmission`; a false compare-and-set + * throws so the whole append — draft transition AND submission row — rolls back. */ + consumeInTransaction( + db: Database.Database, + input: JournalSubmissionConsume & { consumedAs: string } + ): void { + const { db: own } = this.deps.database() + if (own !== db) { + // Same handle only: a second connection could not join the transaction. + throw new AgentSessionJournalError('journal_closed', 'consume crossed database handles') + } + const consumed = consumeQueuedMessageInTransaction(db, { + ...input, + sessionId: this.deps.sessionId, + now: this.deps.now() + }) + if (!consumed) { + throw new QueuedMessageNotConsumableError(input.messageId, input.expect) + } + retireQueuePauseIfNothingHeld(db, this.pauseScope()) + this.changeRevision++ + } + + /** A skipped live settlement the journal already decided (`queued-message-settlement.ts`). */ + settlementOwed(): boolean { + return queuedMessageSettlementOwed(this.list(), this.deps.state().submissions) + } + + /** Waiting drafts a skipped echo hook left unwithdrawn; reads every item, so only the drain + * step asks, right before a draft would send. */ + deliveredByEchoOwed(): boolean { + return draftsDeliveredByAppliedEcho(this.deps.state(), this.list()).length > 0 + } + + /** Applies owed settlements now, so a skipped live transition heals without a reopen. */ + settleOwed(): Promise { + return this.transact( + (db) => + settleOwedQueuedMessages(db, { + sessionId: this.deps.sessionId, + state: this.deps.state(), + now: this.deps.now() + }), + (settled) => settled > 0 + ).then(() => undefined) + } + + /** Bookkeeping at open: a failure is reported and retried at the next open, + * never allowed to fail opening the chat. */ + repairAndPruneAtOpen(): Promise { + return this.repairAndPrune().catch((error: unknown) => { + console.warn('[journal-open] queued-message repair skipped:', { + sessionId: this.deps.sessionId, + error: error instanceof Error ? error.message : String(error) + }) + }) + } + + /** + * Open-time reconciliation, a re-derivation behind the stored fact: owed + * settlements apply exactly as the live hook would have (covers consume → + * crash → downgrade → upgrade, where the old build rejected the leftover with + * no hook), then retention runs; a pause left holding back nothing retires. + */ + repairAndPrune(): Promise { + if (this.deps.readOnly()) { + return Promise.resolve() + } + const { sessionId } = this.deps + return this.transact( + (db) => { + const [now, state] = [this.deps.now(), this.deps.state()] + return ( + settleOwedQueuedMessages(db, { sessionId, state, now }) + + pruneQueuedMessages(db, { + sessionId, + now, + replayWindowMs: QUEUED_MESSAGE_REPLAY_WINDOW_MS, + submissionVerdict: retainedSubmissionVerdict(state.submissions) + }) + ) + }, + (changed) => changed > 0 + ).then(() => undefined) + } +} + +/** The per-append hook converting one draft inside the append's own transaction. */ +export function queuedMessageConsumeHook( + queuedMessages: JournalQueuedMessages, + consumedAs: string, + consume: JournalSubmissionConsume +): (db: Database.Database) => void { + return (db) => queuedMessages.consumeInTransaction(db, { ...consume, consumedAs }) +} + +export class QueuedMessageNotConsumableError extends Error { + constructor( + readonly messageId: string, + readonly expected: 'waiting' | 'returned' + ) { + super(`queued message ${messageId} is no longer ${expected}`) + this.name = 'QueuedMessageNotConsumableError' + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-reducer-producer-linkage.test.ts b/src/main/native-chat/agent-session-journal/journal-reducer-producer-linkage.test.ts new file mode 100644 index 00000000000..6c3640c15cb --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-reducer-producer-linkage.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalMessageItem +} from '../../../shared/agent-session-journal-types' +import { applyJournalRow, createJournalReducerState, renderJournalState } from './journal-reducer' +import { buildJournalItemRow, journalLifecycleBatchRowBuilder } from './journal-row-builders' + +const EPOCH = 'epoch-1' + +function text(value: string): AgentJournalMessageItem { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: value }] } +} + +function base(seq: number): { v: number; epoch: string; seq: number; fence: number; ts: number } { + return { v: 1, epoch: EPOCH, seq, fence: 1, ts: 1_000 + seq } +} + +describe('producer linkage round-trips through the reducer', () => { + const identity: AgentJournalItemIdentity = { + provider: 'claude', + sessionId: 'claude-session', + uuid: 'child-1' + } + const linkage = { + agentId: 'task-1', + parentAgentId: 'task-parent', + providerParentRef: 'toolu_1', + producerKind: 'agent' as const, + attempt: 2 + } + + it('copies the whole bundle onto the render item on the plain item path', () => { + const state = createJournalReducerState('session-1', EPOCH) + applyJournalRow( + state, + buildJournalItemRow({ + state, + identity, + body: text('looking'), + seq: 1, + fence: 1, + ts: 1_001, + linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ) + expect(renderJournalState(state).items[0]).toMatchObject(linkage) + }) + + it('copies it on the lifecycle-batch path too, which is a separate upsert', () => { + const state = createJournalReducerState('session-1', EPOCH) + applyJournalRow(state, { + kind: 'lifecycle-batch', + settlementId: 'settle-1', + mutations: [{ kind: 'item', itemId: 'i-child', revision: 1, body: text('looking') }], + ...base(1), + ...linkage + }) + expect(renderJournalState(state).items[0]).toMatchObject(linkage) + }) + + it('reads each mutation of a mixed batch as its own producer', () => { + // A batch can CREATE rows several agents produced — a settlement landing + // before any checkpoint did. The mutation that names a producer is that + // producer's; the one naming none is the session's own, beside it. + const state = createJournalReducerState('session-1', EPOCH) + applyJournalRow( + state, + journalLifecycleBatchRowBuilder( + () => state, + 'settle-mixed', + [ + { + kind: 'item', + identity, + body: text('child'), + linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }, + { + kind: 'item', + identity: { provider: 'claude', sessionId: 'claude-session', uuid: 'own-1' }, + body: text('own'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ], + { fence: 1 } + )(1, 1_001) + ) + + const [child, own] = renderJournalState(state).items + expect(child).toMatchObject({ body: text('child'), ...linkage }) + expect(own?.body).toEqual(text('own')) + expect(own && 'agentId' in own).toBe(false) + }) + + it('lets a correction win over the provisional row, without moving the bubble', () => { + // Write-through then correct: the row is written under the spawn call's own + // id, then re-appended under the canonical one. Revision is assigned inside + // the journal's serialized write step, so the later append always outranks + // — and `sequence`/`observedAt` stay pinned, so re-attributing a row does + // not relocate it in the timeline. + const state = createJournalReducerState('session-1', EPOCH) + const provisional = { agentId: 'toolu_1', providerParentRef: 'toolu_1' } + applyJournalRow( + state, + buildJournalItemRow({ + state, + identity, + body: text('looking'), + seq: 1, + fence: 1, + ts: 1_001, + linkage: provisional, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ) + applyJournalRow( + state, + buildJournalItemRow({ + state, + identity, + body: text('looking'), + seq: 9, + fence: 1, + ts: 9_999, + linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ) + + const items = renderJournalState(state).items + expect(items).toHaveLength(1) + expect(items[0]).toMatchObject({ revision: 2, ...linkage }) + expect(items[0]).toMatchObject({ sequence: 1, observedAt: 1_001 }) + }) + + it('does not let a stale checkpoint undo a correction that already landed', () => { + // A text checkpoint carrying the OLD stamp, submitted after the correction, + // would re-root the row. It cannot: revision is read at write time, so the + // last write wins and the lane resolves linkage fresh on every checkpoint. + const state = createJournalReducerState('session-1', EPOCH) + applyJournalRow( + state, + buildJournalItemRow({ + state, + identity, + body: text('a'), + seq: 1, + fence: 1, + ts: 1, + linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ) + applyJournalRow( + state, + buildJournalItemRow({ + state, + identity, + body: text('a and more'), + seq: 2, + fence: 1, + ts: 2, + linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ) + const items = renderJournalState(state).items + expect(items[0]).toMatchObject({ revision: 2, ...linkage }) + }) + + it('keeps linkage when a later revision rewrites the row', () => { + // The resolved-append path lost the marker once before by rebuilding the + // row without it, so the SECOND write is the one that matters here. + const state = createJournalReducerState('session-1', EPOCH) + for (const [seq, body] of [ + [1, text('look')], + [2, text('looking at the lane')] + ] as const) { + applyJournalRow( + state, + buildJournalItemRow({ + state, + identity, + body, + seq, + fence: 1, + ts: 1_000 + seq, + linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ) + } + const items = renderJournalState(state).items + expect(items).toHaveLength(1) + expect(items[0]).toMatchObject({ revision: 2, ...linkage }) + }) + + it("renders a row that predates linkage as the session's own", () => { + const state = createJournalReducerState('session-1', EPOCH) + applyJournalRow(state, { + kind: 'item', + itemId: 'i-legacy', + revision: 1, + body: text('written before linkage existed'), + ...base(1) + }) + const item = renderJournalState(state).items[0] + expect(item && 'agentId' in item).toBe(false) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-reducer-rejection-fact.test.ts b/src/main/native-chat/agent-session-journal/journal-reducer-rejection-fact.test.ts new file mode 100644 index 00000000000..3332e53b0b3 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-reducer-rejection-fact.test.ts @@ -0,0 +1,100 @@ +// A dispatch row's typed rejection fact, as the reducer folds it onto the submission. + +import { describe, expect, it } from 'vitest' +import { + classifyDispatchRejection, + DISPATCH_REJECTED_QUEUE_FULL +} from '../../../shared/structured-agent-session-dispatch-rejection' +import { + applyJournalRow, + createJournalReducerState, + type JournalReducerState +} from './journal-reducer' +import type { JournalRow } from './journal-row-schema' + +const EPOCH = 'epoch-1' + +function base(seq: number): { v: number; epoch: string; seq: number; fence: number; ts: number } { + return { v: 1, epoch: EPOCH, seq, fence: 1, ts: 1_000 + seq } +} + +function fold(rows: JournalRow[]): JournalReducerState { + const state = createJournalReducerState('session-1', EPOCH) + for (const row of rows) { + applyJournalRow(state, row) + } + return state +} + +describe('a rejected dispatch', () => { + const submission: JournalRow = { + kind: 'submission', + clientMessageId: 'cm_1', + payloadFingerprint: 'fp_1', + providerHandle: { kind: 'codex', threadId: 'thread-1' }, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + ...base(1) + } + + it("copies a rejection's typed fact onto the submission, and only on `rejected`", () => { + const rejection = { kind: 'providerRejected', detail: { text: 'Too long', audience: 'person' } } + const state = fold([ + submission, + { + kind: 'dispatch', + clientMessageId: 'cm_1', + state: 'rejected', + providerItemId: null, + reason: 'The provider did not accept this message.', + rejection: { kind: 'providerRejected', detail: { text: 'Too long', audience: 'person' } }, + ...base(2) + } + ]) + expect(state.submissions.get('cm_1')).toMatchObject({ + dispatchState: 'rejected', + reason: 'The provider did not accept this message.', + rejection + }) + + const doubt = fold([ + { ...submission, clientMessageId: 'cm_2' }, + { + kind: 'dispatch', + clientMessageId: 'cm_2', + state: 'unknown', + providerItemId: null, + reason: 'provider_exited_before_acknowledgement', + rejection: { kind: 'writeFailed' }, + ...base(2) + } + ]) + expect(doubt.submissions.get('cm_2')).not.toHaveProperty('rejection') + }) + + it('keeps only the kind of a rejection fact it cannot place, so it reads as no verdict', () => { + const rejected = (rejection: unknown): JournalRow => { + const row: JournalRow = { + kind: 'dispatch', + clientMessageId: 'cm_1', + state: 'rejected', + providerItemId: null, + reason: DISPATCH_REJECTED_QUEUE_FULL, + ...base(2) + } + // A row read from disk carries whatever the host that wrote it did. + return Object.assign(row, { rejection }) + } + // A newer host's kind: the marker beside it must not decide. + const newer = fold([submission, rejected({ kind: 'futureKind', detail: 'x' })]) + const settled = newer.submissions.get('cm_1') + expect(settled).toMatchObject({ dispatchState: 'rejected', rejection: { kind: 'futureKind' } }) + expect(settled && classifyDispatchRejection(settled)).toEqual({ + category: 'undelivered', + verdict: null + }) + // Not a fact at all: dropped, leaving the reason. + const malformed = fold([submission, rejected('queueFull')]).submissions.get('cm_1') + expect(malformed).not.toHaveProperty('rejection') + expect(malformed && classifyDispatchRejection(malformed)).toMatchObject({ kind: 'queueFull' }) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts index 825c5aaf347..45681f32871 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { describe, expect, it } from 'vitest' import { agentJournalItemKey, @@ -17,11 +18,7 @@ import { renderJournalState, type JournalReducerState } from './journal-reducer' -import { - buildJournalItemRow, - buildJournalTombstoneRow, - journalLifecycleBatchRowBuilder -} from './journal-row-builders' +import { buildJournalItemRow, buildJournalTombstoneRow } from './journal-row-builders' import type { JournalRow } from './journal-row-schema' const EPOCH = 'epoch-1' @@ -118,6 +115,39 @@ describe('ordering', () => { expect(renderJournalState(state).items.map((item) => item.itemId)).toEqual(['earlier', 'later']) }) + it("places a batch's writes by their order in it, and keeps that place on revision", () => { + // One Codex ask writes all its questions in one batch; their ids are not their order. + const state = fold([ + { + kind: 'lifecycle-batch', + settlementId: 'ask', + mutations: [ + { kind: 'item', itemId: 'scope', revision: 1, body: text('first') }, + { kind: 'item', itemId: 'priority', revision: 1, body: text('second') }, + { kind: 'item', itemId: 'deadline', revision: 1, body: text('third') } + ], + ...base(1) + }, + { + kind: 'lifecycle-batch', + settlementId: 'answer', + mutations: [{ kind: 'item', itemId: 'deadline', revision: 2, body: text('answered') }], + ...base(2) + } + ]) + expect( + renderJournalState(state).items.map(({ itemId, sequence, sequenceIndex }) => ({ + itemId, + sequence, + sequenceIndex + })) + ).toEqual([ + { itemId: 'scope', sequence: 1, sequenceIndex: undefined }, + { itemId: 'priority', sequence: 1, sequenceIndex: 1 }, + { itemId: 'deadline', sequence: 1, sequenceIndex: 2 } + ]) + }) + it('orders by sequence even when the observed timestamp runs backwards', () => { const state = fold([ { kind: 'item', itemId: 'late', revision: 1, body: text('late'), ...base(1), ts: 9_000 }, @@ -624,7 +654,15 @@ describe('re-adding a tombstoned row', () => { const state = createJournalReducerState('session-1', EPOCH) applyJournalRow( state, - buildJournalItemRow({ state, identity, body: text('first'), seq: 1, fence: 1, ts: 1_001 }) + buildJournalItemRow({ + state, + identity, + body: text('first'), + seq: 1, + fence: 1, + ts: 1_001, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) ) applyJournalRow(state, buildJournalTombstoneRow({ state, itemId, seq: 2, fence: 1, ts: 1_002 })) expect(renderJournalState(state).items).toEqual([]) @@ -633,7 +671,15 @@ describe('re-adding a tombstoned row', () => { // `items` would restart at 1 and lose to the tombstone forever. applyJournalRow( state, - buildJournalItemRow({ state, identity, body: text('second'), seq: 3, fence: 1, ts: 1_003 }) + buildJournalItemRow({ + state, + identity, + body: text('second'), + seq: 3, + fence: 1, + ts: 1_003, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) ) expect(renderJournalState(state).items.map((item) => item.body)).toEqual([text('second')]) }) @@ -649,12 +695,28 @@ describe('re-adding a tombstoned row', () => { const state = createJournalReducerState('session-1', EPOCH) applyJournalRow( state, - buildJournalItemRow({ state, identity, body: text('first'), seq: 1, fence: 1, ts: 1_001 }) + buildJournalItemRow({ + state, + identity, + body: text('first'), + seq: 1, + fence: 1, + ts: 1_001, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) ) applyJournalRow(state, buildJournalTombstoneRow({ state, itemId, seq: 2, fence: 1, ts: 1_002 })) applyJournalRow( state, - buildJournalItemRow({ state, identity, body: text('second'), seq: 3, fence: 1, ts: 1_003 }) + buildJournalItemRow({ + state, + identity, + body: text('second'), + seq: 3, + fence: 1, + ts: 1_003, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) ) expect(state.tombstones.get(itemId)).toBeUndefined() @@ -662,170 +724,3 @@ describe('re-adding a tombstoned row', () => { expect(renderJournalState(state).items).toEqual([]) }) }) - -describe('producer linkage round-trips through the reducer', () => { - const identity: AgentJournalItemIdentity = { - provider: 'claude', - sessionId: 'claude-session', - uuid: 'child-1' - } - const linkage = { - agentId: 'task-1', - parentAgentId: 'task-parent', - providerParentRef: 'toolu_1', - producerKind: 'agent' as const, - attempt: 2 - } - - it('copies the whole bundle onto the render item on the plain item path', () => { - const state = createJournalReducerState('session-1', EPOCH) - applyJournalRow( - state, - buildJournalItemRow({ - state, - identity, - body: text('looking'), - seq: 1, - fence: 1, - ts: 1_001, - linkage - }) - ) - expect(renderJournalState(state).items[0]).toMatchObject(linkage) - }) - - it('copies it on the lifecycle-batch path too, which is a separate upsert', () => { - const state = createJournalReducerState('session-1', EPOCH) - applyJournalRow(state, { - kind: 'lifecycle-batch', - settlementId: 'settle-1', - mutations: [{ kind: 'item', itemId: 'i-child', revision: 1, body: text('looking') }], - ...base(1), - ...linkage - }) - expect(renderJournalState(state).items[0]).toMatchObject(linkage) - }) - - it('reads each mutation of a mixed batch as its own producer', () => { - // A batch can CREATE rows several agents produced — a settlement landing - // before any checkpoint did. The mutation that names a producer is that - // producer's; the one naming none is the session's own, beside it. - const state = createJournalReducerState('session-1', EPOCH) - applyJournalRow( - state, - journalLifecycleBatchRowBuilder( - () => state, - 'settle-mixed', - [ - { kind: 'item', identity, body: text('child'), linkage }, - { - kind: 'item', - identity: { provider: 'claude', sessionId: 'claude-session', uuid: 'own-1' }, - body: text('own') - } - ], - { fence: 1 } - )(1, 1_001) - ) - - const [child, own] = renderJournalState(state).items - expect(child).toMatchObject({ body: text('child'), ...linkage }) - expect(own?.body).toEqual(text('own')) - expect(own && 'agentId' in own).toBe(false) - }) - - it('lets a correction win over the provisional row, without moving the bubble', () => { - // Write-through then correct: the row is written under the spawn call's own - // id, then re-appended under the canonical one. Revision is assigned inside - // the journal's serialized write step, so the later append always outranks - // — and `sequence`/`observedAt` stay pinned, so re-attributing a row does - // not relocate it in the timeline. - const state = createJournalReducerState('session-1', EPOCH) - const provisional = { agentId: 'toolu_1', providerParentRef: 'toolu_1' } - applyJournalRow( - state, - buildJournalItemRow({ - state, - identity, - body: text('looking'), - seq: 1, - fence: 1, - ts: 1_001, - linkage: provisional - }) - ) - applyJournalRow( - state, - buildJournalItemRow({ - state, - identity, - body: text('looking'), - seq: 9, - fence: 1, - ts: 9_999, - linkage - }) - ) - - const items = renderJournalState(state).items - expect(items).toHaveLength(1) - expect(items[0]).toMatchObject({ revision: 2, ...linkage }) - expect(items[0]).toMatchObject({ sequence: 1, observedAt: 1_001 }) - }) - - it('does not let a stale checkpoint undo a correction that already landed', () => { - // A text checkpoint carrying the OLD stamp, submitted after the correction, - // would re-root the row. It cannot: revision is read at write time, so the - // last write wins and the lane resolves linkage fresh on every checkpoint. - const state = createJournalReducerState('session-1', EPOCH) - applyJournalRow( - state, - buildJournalItemRow({ state, identity, body: text('a'), seq: 1, fence: 1, ts: 1, linkage }) - ) - applyJournalRow( - state, - buildJournalItemRow({ - state, - identity, - body: text('a and more'), - seq: 2, - fence: 1, - ts: 2, - linkage - }) - ) - const items = renderJournalState(state).items - expect(items[0]).toMatchObject({ revision: 2, ...linkage }) - }) - - it('keeps linkage when a later revision rewrites the row', () => { - // The resolved-append path lost the marker once before by rebuilding the - // row without it, so the SECOND write is the one that matters here. - const state = createJournalReducerState('session-1', EPOCH) - for (const [seq, body] of [ - [1, text('look')], - [2, text('looking at the lane')] - ] as const) { - applyJournalRow( - state, - buildJournalItemRow({ state, identity, body, seq, fence: 1, ts: 1_000 + seq, linkage }) - ) - } - const items = renderJournalState(state).items - expect(items).toHaveLength(1) - expect(items[0]).toMatchObject({ revision: 2, ...linkage }) - }) - - it("renders a row that predates linkage as the session's own", () => { - const state = createJournalReducerState('session-1', EPOCH) - applyJournalRow(state, { - kind: 'item', - itemId: 'i-legacy', - revision: 1, - body: text('written before linkage existed'), - ...base(1) - }) - const item = renderJournalState(state).items[0] - expect(item && 'agentId' in item).toBe(false) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index 273502c8cf2..ed12c4a6cba 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -4,9 +4,11 @@ // // Rules: highest revision wins, a tombstone removes, a late lower revision is // dropped rather than resurrecting stale content, and ordering is by the -// sequence of the row that CREATED an item (a later revision updates the body, -// it does not move the bubble). Producer linkage is likewise the creating -// write's: a revision naming no producer keeps it, one naming any replaces it. +// position (sequence, then place in the row) of the write that CREATED an item +// (a later revision updates the body, it does not move the bubble) — except a +// queued message, which sits where its handover put it. Producer linkage is +// likewise the creating write's: a revision naming no producer keeps it, one +// naming any replaces it. import type { AgentJournalAcceptanceReceipt, @@ -15,18 +17,19 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { journalBatchMutationProducer, journalRenderItem } from './journal-render-item' +import { compareAgentJournalItems } from '../../../shared/agent-session-journal-position' import { agentJournalSubmissionKey, parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { - agentJournalLinkageFields, - namesAgentJournalProducer -} from '../../../shared/agent-session-journal-producer' import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { JournalDerivedTurnScope } from './journal-derived-turn-scope' +import { removeJournalItem, statedOrDerivedTurnScope, upsertJournalItem } from './journal-item-fold' import { journalItemRevisionIsStale } from './journal-item-revision' import type { JournalRow } from './journal-row-schema' -import { dispatchRejectionWasTransportWriteFailure } from '../../../shared/structured-agent-session-dispatch-rejection' +import { acceptSubmissionFromProviderItem, applyJournalSubmission } from './journal-submission-fold' +import { applyJournalDispatchRow } from './journal-dispatch-reducer' +import { isWriteFailureSubmission } from '../../../shared/structured-agent-session-dispatch-rejection' export const MAX_JOURNAL_APPLIED_SETTLEMENT_IDS = 4_096 @@ -39,6 +42,8 @@ export type JournalReducerState = { oldestSequence: number highestFence: number items: Map + /** Fence of the writer that created each item: the generation a running turn belongs to. */ + itemFences: Map /** Revision of a removed item, so a late lower revision cannot resurrect it. */ tombstones: Map submissions: Map @@ -47,6 +52,11 @@ export type JournalReducerState = { * echo from appending a second copy of the user's own message. */ aliases: Map appliedSettlementIds: Set + /** Scope for rows stored without one; rebuilt by replay, never persisted. */ + derivedTurnScope: JournalDerivedTurnScope + /** The submission row of the latest turn a person asked for (`origin: 'client'`) that the + * provider accepted; 0 when none. Kept as it folds so the queue's pause reads it in O(1). */ + latestPersonTurnSequence: number } export function createJournalReducerState(sessionId: string, epoch: string): JournalReducerState { @@ -58,11 +68,14 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou oldestSequence: 1, highestFence: 0, items: new Map(), + itemFences: new Map(), tombstones: new Map(), submissions: new Map(), receipts: new Map(), aliases: new Map(), - appliedSettlementIds: new Set() + appliedSettlementIds: new Set(), + derivedTurnScope: new JournalDerivedTurnScope(), + latestPersonTurnSequence: 0 } } @@ -79,48 +92,47 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo } const itemId = resolveJournalItemId(state, row.itemId, row.body) acceptSubmissionFromProviderItem(state, row.itemId, itemId, row) - upsertItem(state, itemId, row.revision, journalRenderItem(itemId, row.revision, row.body, row)) + upsertJournalItem( + state, + itemId, + row.revision, + journalRenderItem(itemId, row.revision, row.body, row, statedOrDerivedTurnScope(state, row)), + row.fence + ) return } if (row.kind === 'tombstone') { - removeItem(state, resolveItemId(state, row.itemId), row.revision) + removeJournalItem(state, resolveItemId(state, row.itemId), row.revision) return } if (row.kind === 'lifecycle-batch') { if (state.appliedSettlementIds.has(row.settlementId)) { return } - for (const mutation of row.mutations) { + for (const [sequenceIndex, mutation] of row.mutations.entries()) { if (mutation.kind === 'item') { if (journalItemRevisionIsStale(state, mutation.itemId, mutation.revision)) { continue } - const itemId = resolveJournalItemId(state, mutation.itemId, mutation.body) + const { revision, body } = mutation + const itemId = resolveJournalItemId(state, mutation.itemId, body) acceptSubmissionFromProviderItem(state, mutation.itemId, itemId, row) - upsertItem( - state, - itemId, - mutation.revision, - journalRenderItem( - itemId, - mutation.revision, - mutation.body, - row, - journalBatchMutationProducer(row, mutation) - ) - ) + const producer = journalBatchMutationProducer(row, mutation) + const scope = statedOrDerivedTurnScope(state, mutation) + const item = journalRenderItem(itemId, revision, body, row, scope, producer, sequenceIndex) + upsertJournalItem(state, itemId, revision, item, row.fence) } else { - removeItem(state, resolveItemId(state, mutation.itemId), mutation.revision) + removeJournalItem(state, resolveItemId(state, mutation.itemId), mutation.revision) } } rememberAppliedSettlementId(state, row.settlementId) return } if (row.kind === 'submission') { - applySubmission(state, row) + applyJournalSubmission(state, row) return } - applyDispatch(state, row) + applyJournalDispatchRow(state, row) } export function rememberAppliedSettlementId( @@ -146,16 +158,38 @@ export function resolveJournalItemId( if (aliased) { return aliased } - const identity = parseAgentJournalItemKey(itemId) - if ( - !body || - body.kind !== 'message' || - body.role !== 'user' || - !identity || - identity.provider === 'orca' - ) { + const submissionId = journalEchoClaimant(state, itemId, body) + if (!submissionId) { return itemId } + state.aliases.set(itemId, submissionId) + return submissionId +} + +/** A user message the provider wrote: the only item a submission's echo can be. */ +export function isProviderUserMessageEcho( + itemId: string, + body: AgentJournalRenderItem['body'] +): boolean { + const identity = parseAgentJournalItemKey(itemId) + return ( + body.kind === 'message' && + body.role === 'user' && + identity !== null && + identity.provider !== 'orca' + ) +} + +/** The submission item a provider's echo of a user message would fold into, read without + * claiming it; null when the item is not such an echo, or no submission may claim it. */ +export function journalEchoClaimant( + state: JournalReducerState, + itemId: string, + body?: AgentJournalRenderItem['body'] +): string | null { + if (!body || !isProviderUserMessageEcho(itemId, body)) { + return null + } const fingerprint = structuredAgentSessionPayloadFingerprint({ method: 'agentSession.send', sessionId: state.sessionId, @@ -172,166 +206,22 @@ export function resolveJournalItemId( .find( (candidate) => candidate.dispatchState !== 'rejected' && - !dispatchRejectionWasTransportWriteFailure(candidate.reason) && + !isWriteFailureSubmission(candidate) && candidate.payloadFingerprint === fingerprint && state.items.get(agentJournalSubmissionKey(candidate.clientMessageId))?.revision === 0 ) - if (!submission) { - return itemId - } - const submissionId = agentJournalSubmissionKey(submission.clientMessageId) - state.aliases.set(itemId, submissionId) - return submissionId + return submission ? agentJournalSubmissionKey(submission.clientMessageId) : null } function resolveItemId(state: JournalReducerState, itemId: string): string { return state.aliases.get(itemId) ?? itemId } -function upsertItem( - state: JournalReducerState, - itemId: string, - revision: number, - next: AgentJournalRenderItem -): void { - const tombstoned = state.tombstones.get(itemId) - if (tombstoned !== undefined && revision <= tombstoned) { - return - } - const existing = state.items.get(itemId) - if (existing && revision <= existing.revision) { - return - } - if (!existing) { - state.items.set(itemId, next) - state.tombstones.delete(itemId) - return - } - // Creation sequence is the ordering key; a revision refreshes content only. - // `observedAt` is pinned with it: clients sort the timeline by that timestamp, - // so letting a revision advance it makes the row jump past everything that - // landed in between — the provider's own echo of a send revises the submission - // row, which relocated the user's bubble below later rows. - const submitted = - existing.body.kind === 'message' && - existing.body.role === 'user' && - parseAgentJournalItemKey(itemId)?.provider === 'orca' - state.items.set(itemId, { - ...next, - // Settlements, prompt answers and reopen sweeps revise rows any agent wrote - // without naming one; each would otherwise hand a subagent's row to the session. - ...(namesAgentJournalProducer(next) ? {} : agentJournalLinkageFields(existing)), - // Provider history may normalize text or omit local attachments from the original send. - body: submitted ? existing.body : next.body, - sequence: existing.sequence, - observedAt: existing.observedAt - }) - state.tombstones.delete(itemId) -} - -function removeItem(state: JournalReducerState, itemId: string, revision: number): void { - const existing = state.items.get(itemId) - if (existing && revision <= existing.revision) { - return - } - const tombstoned = state.tombstones.get(itemId) - if (tombstoned !== undefined && revision <= tombstoned) { - return - } - state.tombstones.set(itemId, revision) - state.items.delete(itemId) -} - -function applySubmission( - state: JournalReducerState, - row: Extract -): void { - state.submissions.set(row.clientMessageId, { - clientMessageId: row.clientMessageId, - fence: row.fence, - payloadFingerprint: row.payloadFingerprint, - dispatchState: 'pending', - providerItemId: null, - reason: null, - submittedAt: row.ts, - resolvedAt: null - }) - const itemId = agentJournalSubmissionKey(row.clientMessageId) - upsertItem(state, itemId, 0, journalRenderItem(itemId, 0, row.body, row)) -} - -function applyDispatch( - state: JournalReducerState, - row: Extract -): void { - const submission = state.submissions.get(row.clientMessageId) - if (!submission) { - return - } - // `rejected` is terminal; a late `unknown` must not reopen a settled answer. - if (submission.dispatchState === 'rejected' || submission.dispatchState === 'accepted') { - return - } - submission.fence = row.fence - submission.dispatchState = row.state - submission.providerItemId = row.providerItemId - submission.reason = row.reason - submission.resolvedAt = row.state === 'pending' ? null : row.ts - if (row.recovered) { - submission.recovered = row.recovered - } else { - delete submission.recovered - } - if (row.state !== 'accepted' || !row.providerItemId) { - return - } - state.aliases.set(row.providerItemId, agentJournalSubmissionKey(row.clientMessageId)) - state.receipts.set(row.clientMessageId, { - clientMessageId: row.clientMessageId, - providerItemId: row.providerItemId, - cursor: { epoch: row.epoch, sequence: row.seq }, - acceptedAt: row.ts - }) -} - -function acceptSubmissionFromProviderItem( - state: JournalReducerState, - providerItemId: string, - resolvedItemId: string, - row: Pick -): void { - if (providerItemId === resolvedItemId) { - return - } - const submission = [...state.submissions.values()].find( - (candidate) => agentJournalSubmissionKey(candidate.clientMessageId) === resolvedItemId - ) - if ( - !submission || - submission.dispatchState === 'accepted' || - submission.dispatchState === 'rejected' - ) { - return - } - submission.fence = row.fence - submission.dispatchState = 'accepted' - submission.providerItemId = providerItemId - submission.reason = null - submission.resolvedAt = row.ts - delete submission.recovered - state.receipts.set(submission.clientMessageId, { - clientMessageId: submission.clientMessageId, - providerItemId, - cursor: { epoch: row.epoch, sequence: row.seq }, - acceptedAt: row.ts - }) -} - /** Project the folded state into the client-facing snapshot. */ export function renderJournalState(state: JournalReducerState): AgentJournalSnapshot { - // Sequence is the sole ordering key; map insertion order is not, because a - // re-created item re-enters the map after the items that followed it. - const items = [...state.items.values()].sort((a, b) => a.sequence - b.sequence) + // The journal position is the sole ordering key; map insertion order is not, + // because a re-created item re-enters the map after the items that followed it. + const items = [...state.items.values()].sort(compareAgentJournalItems) return { sessionId: state.sessionId, cursor: { epoch: state.epoch, sequence: state.lastSequence }, diff --git a/src/main/native-chat/agent-session-journal/journal-render-item.ts b/src/main/native-chat/agent-session-journal/journal-render-item.ts index 6f41ff3e862..fd58b2c25e4 100644 --- a/src/main/native-chat/agent-session-journal/journal-render-item.ts +++ b/src/main/native-chat/agent-session-journal/journal-render-item.ts @@ -1,7 +1,8 @@ import type { AgentJournalItemBody, AgentJournalProducerLinkage, - AgentJournalRenderItem + AgentJournalRenderItem, + AgentJournalTurnScope } from '../../../shared/agent-session-journal-types' import { agentJournalLinkageFields, @@ -19,16 +20,21 @@ export function journalRenderItem( revision: number, body: AgentJournalItemBody, row: JournalRow, - producer: AgentJournalProducerLinkage = row + turnScope: AgentJournalTurnScope, + producer: AgentJournalProducerLinkage = row, + /** Which of the row's writes this is; only a lifecycle batch has more than one. */ + sequenceIndex = 0 ): AgentJournalRenderItem { return { itemId, revision, body, sequence: row.seq, + ...(sequenceIndex > 0 ? { sequenceIndex } : {}), observedAt: row.ts, ...(row.recovered ? { recoveredAt: row.ts } : {}), ...(row.recovered ? { recovered: row.recovered } : {}), + turnScope, ...agentJournalLinkageFields(producer) } } diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts index 46d161b1172..aaac2bc1a20 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Wiring the restart reconciler: what provider history is allowed to decide // about a submission the crash boundary could only doubt. @@ -15,6 +16,9 @@ import { digestPayload } from './journal-payload-bounds' import { reconcileJournalSubmissionsAgainstHistory } from './journal-restart-reconciliation' import type { ProviderHistoryItem, ProviderHistoryWindow } from './journal-submission-reconciler' import { createTrackedJournalOpener } from './journal-store-test-open' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { classifyDispatchRejection } from '../../../shared/structured-agent-session-dispatch-rejection' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -122,7 +126,18 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { expect(settled).toEqual(['cm_1']) const submission = journal.submissions()[0] expect(submission?.dispatchState).toBe('rejected') - expect(submission?.reason).toBe('not_delivered') + // A sentence, since released clients print the reason as it is, and the fact beside it. + expect(submission?.reason).toBe( + agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { surface: 'rejection' }) + .reason + ) + expect(submission?.rejection).toEqual({ kind: 'notDelivered' }) + // Nobody failed: the crash stranded it before the provider took it. + expect(submission && classifyDispatchRejection(submission)).toEqual({ + category: 'undelivered', + verdict: null, + kind: 'notDelivered' + }) }) it('leaves a submission unknown while the provider reports a turn in flight', async () => { @@ -201,7 +216,8 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { const journal = await open() // An identical message, delivered and committed BEFORE the one that crashed. await journal.appendItem(claudeIdentity('uuid-old'), userMessage('deploy the thing'), { - fence: 1 + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await journal.appendSubmission({ clientMessageId: 'cm_1', @@ -254,7 +270,7 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { 'accepted', 'rejected' ]) - expect(restarted.submissions()[1]?.reason).toBe('not_delivered') + expect(restarted.submissions()[1]?.rejection).toEqual({ kind: 'notDelivered' }) }) it('leaves two identical unsettled sends unknown rather than guessing between them', async () => { diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts index 626baa9820a..f1484bf5da8 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts @@ -10,6 +10,8 @@ // through the user's Retry, which rotates the client message id; Orca still // never puts a message back on the wire on the user's behalf. +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import type { AgentJournalMessageItem, AgentJournalSubmission @@ -18,6 +20,7 @@ import { agentJournalItemKey, agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { AgentSessionJournal } from './journal-store' import { reconcileSubmissions, type ProviderHistoryWindow } from './journal-submission-reconciler' @@ -32,6 +35,8 @@ function comparableBody(body: AgentJournalMessageItem | undefined): boolean { return ( body?.kind === 'message' && body.role === 'user' && + // A conversation command leaves no user item in provider history to find. + body.command === undefined && body.blocks.length === 1 && body.blocks[0]?.type === 'text' && body.blocks[0].text.trim().length > 0 @@ -42,7 +47,11 @@ function comparableSubmissions(journal: AgentSessionJournal): AgentJournalSubmis const { items, submissions } = journal.snapshot() const bodies = new Map(items.map((item) => [item.itemId, item.body])) return submissions.filter((submission) => { - if (submission.dispatchState !== 'pending' && submission.dispatchState !== 'unknown') { + if ( + (submission.dispatchState !== 'pending' && submission.dispatchState !== 'unknown') || + // Never handed over, so provider history cannot hold it. + isQueuedAgentJournalSubmission(submission) + ) { return false } const body = bodies.get(agentJournalSubmissionKey(submission.clientMessageId)) @@ -111,7 +120,9 @@ export async function reconcileJournalSubmissionsAgainstHistory(input: { : { clientMessageId: outcome.clientMessageId, state: 'rejected', - reason: outcome.reason, + ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { + surface: 'rejection' + }), fence: input.fence, recovered: true } diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index e17ccf0cdf8..46d41ecb7cf 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -1,9 +1,10 @@ import type { - AgentJournalDispatchState, AgentJournalItemBody, AgentJournalItemIdentity, AgentJournalMessageItem, AgentJournalProducerLinkage, + AgentJournalRowAttribution, + AgentJournalTurnScope, AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types' import { journalRowSchemaVersion } from '../../../shared/agent-session-journal-types' @@ -26,6 +27,7 @@ import { MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS } from './journal-row-schema' import { boundInlineText, DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' +import { assertSubmissionIdUnused } from './journal-write-guards' import type { ResolveDispatchInput } from './journal-store-contracts' type RowBuilder = (seq: number, ts: number) => T @@ -34,7 +36,7 @@ export function journalItemRowBuilder( state: () => JournalReducerState, identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - options: AgentJournalProducerLinkage & { fence: number; observedAt?: number; recovered?: true } + options: AgentJournalRowAttribution & { fence: number; observedAt?: number; recovered?: true } ): RowBuilder { return (seq, ts) => buildJournalItemRow({ @@ -45,7 +47,8 @@ export function journalItemRowBuilder( fence: options.fence, ts: options.observedAt ?? ts, recovered: options.recovered, - linkage: options + linkage: options, + turnScope: options.turnScope }) } @@ -65,10 +68,29 @@ export function journalSubmissionRowBuilder( payloadFingerprint: string body: AgentJournalMessageItem fence: number - } + handoverRecorded?: true + queuedMessageId?: string + origin?: 'client' | 'host' + }, + /** Present when the append hands off a queued draft: the row names that draft, stamped here + * from the consume itself so no hand-off path can leave the link off. */ + consume?: { messageId: string } ): RowBuilder { - return (seq, ts) => - buildJournalSubmissionRow({ state: state(), providerHandle, ...input, seq, ts }) + return (seq, ts) => { + assertSubmissionIdUnused(state().submissions, input.clientMessageId) + if (consume && (input.queuedMessageId ?? consume.messageId) !== consume.messageId) { + throw new Error(`submission ${input.clientMessageId} names a draft it does not consume`) + } + const queuedMessageId = consume?.messageId ?? input.queuedMessageId + return buildJournalSubmissionRow({ + state: state(), + providerHandle, + ...input, + ...(queuedMessageId !== undefined ? { queuedMessageId } : {}), + seq, + ts + }) + } } export function journalDispatchRowBuilder( @@ -76,24 +98,26 @@ export function journalDispatchRowBuilder( input: ResolveDispatchInput ): RowBuilder { const providerItemId = - input.state === 'accepted' ? agentJournalItemKey(input.providerIdentity) : null - return (seq, ts) => - buildJournalDispatchRow({ - state: state(), - clientMessageId: input.clientMessageId, - dispatchState: input.state, - providerItemId, - reason: boundedDispatchReason(input), - seq, - fence: input.fence, - ts, - recovered: input.recovered - }) + input.state === 'accepted' && input.providerIdentity + ? agentJournalItemKey(input.providerIdentity) + : null + // The only dispatch-row builder: its input type is what makes a rejected row carry its fact. + return (seq, ts) => ({ + kind: 'dispatch', + clientMessageId: input.clientMessageId, + state: input.state, + providerItemId, + reason: boundedDispatchReason(input), + ...(input.state === 'rejected' ? { rejection: input.rejection } : {}), + ...journalRowBase(state().epoch, seq, input.fence, ts), + ...(input.recovered ? { recovered: input.recovered } : {}), + ...(input.state === 'pending' ? { turnScope: input.turnScope } : {}) + }) } /** `reason` is the only unbounded field written by Orca's own code: a provider error is * arbitrary text, and a multi-megabyte one reached the row verbatim. Bounded head-first, - * because `dispatchRejectionWasTransportWriteFailure` prefix-matches the value. Rows + * because `isWriteFailureSubmission` prefix-matches the value. Rows * written before this keep their full text, so readers still meet unbounded ones. */ function boundedDispatchReason(input: ResolveDispatchInput): string | null { if (input.state === 'accepted' || input.state === 'pending' || !input.reason) { @@ -110,6 +134,8 @@ export type JournalLifecycleMutationInput = /** Who wrote the row. Absent ⇒ the session's own agent on a first write, * and the row's existing producer on a revision. */ linkage?: AgentJournalProducerLinkage + /** Which turn the row belongs to. Kept from the write that creates the row. */ + turnScope: AgentJournalTurnScope } | { kind: 'tombstone'; identity: AgentJournalItemIdentity } @@ -118,13 +144,14 @@ export type JournalLifecycleMutationInput = * settled before any checkpoint landed — and one batch can mix producers. * The session's own rows carry no key at all: absence is the claim. */ export function journalLifecycleItemMutation( - producer: AgentJournalProducerLinkage, + attribution: AgentJournalRowAttribution, identity: AgentJournalItemIdentity, body: AgentJournalItemBody ): JournalLifecycleMutationInput { - return namesAgentJournalProducer(producer) - ? { kind: 'item', identity, body, linkage: agentJournalLinkageFields(producer) } - : { kind: 'item', identity, body } + const { turnScope } = attribution + return namesAgentJournalProducer(attribution) + ? { kind: 'item', identity, body, turnScope, linkage: agentJournalLinkageFields(attribution) } + : { kind: 'item', identity, body, turnScope } } /** The persisted form of one mutation, shared with the partitioner's size probe @@ -140,6 +167,7 @@ export function journalLifecycleMutationRow( itemId, revision, body: mutation.body, + turnScope: mutation.turnScope, ...agentJournalLinkageFields(mutation.linkage) } : { kind: 'tombstone', itemId, revision } @@ -213,6 +241,7 @@ export function buildJournalItemRow(input: { ts: number recovered?: true linkage?: AgentJournalProducerLinkage + turnScope: AgentJournalTurnScope }): JournalItemRow { const itemId = agentJournalItemKey(input.identity) const resolved = input.state.aliases.get(itemId) ?? itemId @@ -230,6 +259,7 @@ export function buildJournalItemRow(input: { body: input.body, ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts, [input.body]), ...(input.recovered ? { recovered: input.recovered } : {}), + turnScope: input.turnScope, ...agentJournalLinkageFields(input.linkage) } } @@ -267,6 +297,9 @@ export function buildJournalSubmissionRow(input: { seq: number fence: number ts: number + handoverRecorded?: true + queuedMessageId?: string + origin?: 'client' | 'host' }): JournalSubmissionRow { return { kind: 'submission', @@ -274,28 +307,9 @@ export function buildJournalSubmissionRow(input: { payloadFingerprint: input.payloadFingerprint, providerHandle: input.providerHandle, body: input.body, - ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts) - } -} - -export function buildJournalDispatchRow(input: { - state: JournalReducerState - clientMessageId: string - dispatchState: AgentJournalDispatchState - providerItemId: string | null - reason: string | null - seq: number - fence: number - ts: number - recovered?: true -}): JournalDispatchRow { - return { - kind: 'dispatch', - clientMessageId: input.clientMessageId, - state: input.dispatchState, - providerItemId: input.providerItemId, - reason: input.reason, ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts), - ...(input.recovered ? { recovered: input.recovered } : {}) + ...(input.handoverRecorded ? { handoverRecorded: true } : {}), + ...(input.queuedMessageId !== undefined ? { queuedMessageId: input.queuedMessageId } : {}), + ...(input.origin !== undefined ? { origin: input.origin } : {}) } } diff --git a/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts b/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts index fe23a5ddcb5..6ebc20e5a33 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Context facts ride turn rows. Every kind the writer produces must replay, and // one this build cannot read must cost the fact, never the row or the journal. @@ -91,7 +92,10 @@ describe('context facts on replayed turn rows', () => { it('replays every part and kind the writer produces, unchanged', async () => { const journal = await open() for (const [index, facts] of FACTS.entries()) { - await journal.appendItem(row(index), turn(`turn-${index}`, facts), { fence: 1 }) + await journal.appendItem(row(index), turn(`turn-${index}`, facts), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } const written = journal.snapshot().items.map((item) => item.body) await journal.close() @@ -104,11 +108,14 @@ describe('context facts on replayed turn rows', () => { it('keeps a turn row whose facts it cannot read, and everything after it, minus the facts', async () => { const journal = await open() - await journal.appendItem(row(0), turn('turn-0', FACTS[0]), { fence: 1 }) + await journal.appendItem(row(0), turn('turn-0', FACTS[0]), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.appendItem( row(1), { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'after' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.close() const opened = openJournalDatabase(journalDatabaseFile(root)) diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts b/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts index b764ebd5ec9..f49dda6dcb6 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -39,12 +40,12 @@ describe('journal row schema versions', () => { await journal.appendItem( identity, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( { provider: 'legacy', agent: 'codex', sessionId: 'session-1', recordId: 'turn-lifecycle:t1' }, agentJournalTurnBody({ turnId: 't1', state: 'running', startedAt: 1_000 }), - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.close() const opened = openJournalDatabase(journalDatabaseFile(join(root, 'session-1'))) diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts index 870b5259357..1d013255b21 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION +} from '../../../shared/agent-session-journal-types' import { MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS, parseJournalRow, @@ -277,7 +280,8 @@ describe('producer linkage on the persisted row', () => { seq: 1, fence: 1, ts: 1_700_000_000_000, - ...(withLinkage ? { linkage } : {}) + ...(withLinkage ? { linkage } : {}), + turnScope: AGENT_JOURNAL_THREAD_SCOPE }) const parsed = parseJournalRow(JSON.stringify(row)) return parsed.ok ? parsed.row : null @@ -348,10 +352,21 @@ describe('producer linkage on the persisted row', () => { it('round-trips a batch mutation that names its own producer, with no version bump', () => { const state = createJournalReducerState('session-1', 'epoch-1') - const own = { kind: 'item' as const, identity: { ...identity, uuid: 'u-own' }, body } + const own = { + kind: 'item' as const, + identity: { ...identity, uuid: 'u-own' }, + body, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } const build = (child: JournalLifecycleMutationInput) => journalLifecycleBatchRowBuilder(() => state, 'settle-1', [child, own], { fence: 1 })(1, 1) - const row = build({ kind: 'item', identity, body, linkage }) + const row = build({ + kind: 'item', + identity, + body, + linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const parsed = parseJournalRow(JSON.stringify(row)) const mutations = parsed.ok && parsed.row.kind === 'lifecycle-batch' ? parsed.row.mutations : [] @@ -359,7 +374,9 @@ describe('producer linkage on the persisted row', () => { expect(mutations[1] && 'agentId' in mutations[1]).toBe(false) // The same batch without the stamp writes the same version: an older host // ignores the unknown keys rather than latching the journal read-only. - expect(row.v).toBe(build({ kind: 'item', identity, body }).v) + expect(row.v).toBe( + build({ kind: 'item', identity, body, turnScope: AGENT_JOURNAL_THREAD_SCOPE }).v + ) }) it('keeps a batch mutation but drops its unusable producer id', () => { diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 99cb47971e9..1c7ea36eaef 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -5,12 +5,14 @@ // UNREADABLE, not skippable: the caller must degrade to read-only rather than // render a partial timeline or compact past a row it cannot interpret. +import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION, type AgentJournalDispatchState, type AgentJournalItemBody, type AgentJournalMessageItem, type AgentJournalProducerLinkage, + type AgentJournalTurnScope, type AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types' import { @@ -36,6 +38,10 @@ type JournalRowBase = AgentJournalProducerLinkage & { ts: number /** Set when crash reconciliation appended the row after the fact. */ recovered?: true + /** Which turn the item this row creates belongs to. Rides the base, and is not a `v` bump, + * for the reason linkage does. Absent on rows from hosts that predate it: the reducer + * derives one for them on read. */ + turnScope?: AgentJournalTurnScope } /** First row of every epoch: binds the epoch to a provider handle and records why it opened. */ @@ -77,8 +83,21 @@ export type JournalSubmissionRow = JournalRowBase & { payloadFingerprint: string providerHandle: AgentSessionProviderHandle body: AgentJournalMessageItem + /** Accepted to be handed over by a later `dispatch{pending}` row; absent on rows whose writer + * dispatched in the same step. Older readers keep the key and ignore it. */ + handoverRecorded?: true + /** The queued draft this submission hands off; absent for a direct send. Older readers keep + * the key and ignore it. */ + queuedMessageId?: string + /** Who asked for this turn: `client` for a person's send over the client send RPC (typed, or + * a queued card they sent now); `host` for Orca's own — orchestration mail, a restart + * continuation, a launch prompt, the queue's automatic drain. Absent on rows from before it + * was recorded. Older readers keep the key and ignore it. */ + origin?: JournalSubmissionOrigin } +export type JournalSubmissionOrigin = 'client' | 'host' + export type JournalDispatchRow = JournalRowBase & { kind: 'dispatch' clientMessageId: string @@ -86,6 +105,11 @@ export type JournalDispatchRow = JournalRowBase & { /** Provider item identity adopted on accept. */ providerItemId: string | null reason: string | null + /** On `pending`: the turn the message was handed into, which becomes its row's scope. */ + turnScope?: AgentJournalTurnScope + /** On `rejected`: why, typed. Older readers keep the key and ignore it; a malformed one is + * dropped when read, never the row. */ + rejection?: AgentSessionFailureFact } /** An item mutation may name its own producer, because one batch can CREATE @@ -98,6 +122,7 @@ export type JournalLifecycleMutation = itemId: string revision: number body: AgentJournalItemBody + turnScope?: AgentJournalTurnScope }) | { kind: 'tombstone'; itemId: string; revision: number } @@ -163,10 +188,12 @@ export function parseJournalRow(line: string): JournalRowParse { } const upcast = upcastRow(record, version) dropUnusableProducerLinkage(upcast) + dropUnusableTurnScope(upcast) if (upcast.kind === 'lifecycle-batch' && Array.isArray(upcast.mutations)) { for (const mutation of upcast.mutations) { if (isPlainObject(mutation)) { dropUnusableProducerLinkage(mutation) + dropUnusableTurnScope(mutation) } } } @@ -195,6 +222,24 @@ function dropUnusableProducerLinkage(record: Record): void { } } +/** A scope this build cannot place, removed like unusable linkage: the row then reads as one + * written before scopes existed, and the reducer derives its scope. */ +function dropUnusableTurnScope(record: Record): void { + const scope = record.turnScope + if ( + scope !== undefined && + !( + isPlainObject(scope) && + (scope.kind === 'thread' || + (scope.kind === 'turn' && + typeof scope.turnItemId === 'string' && + scope.turnItemId.length > 0)) + ) + ) { + delete record.turnScope + } +} + /** Context facts this build cannot read, removed from the turn row that carries * them. Same reasoning as linkage: they are an annotation on the turn, and * rejecting the row for them would truncate the journal from that row on. */ diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.ts index 85ff7da7a3f..b45a841d26e 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.ts @@ -1,8 +1,14 @@ import type Database from '../../sqlite/sync-database' import { insertJournalRow, upsertJournalSessionRow } from './journal-row-table' +import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { JournalRow } from './journal-row-schema' import { assertJournalFence, assertJournalWritable } from './journal-write-guards' +/** Runs between BEGIN IMMEDIATE and COMMIT, on the SAME connection as the row + * insert; a throw rolls the whole append back. Synchronous by construction so + * nothing can interleave inside the transaction. */ +export type JournalRowTransactionHook = (db: Database.Database, row: JournalRow) => void + export type JournalRowWriterDeps = { sessionId: string now: () => number @@ -12,12 +18,23 @@ export type JournalRowWriterDeps = { highestFence: () => number nextSequence: () => number commit: (row: JournalRow) => void + /** Standing hook run for EVERY appended row — the queued-draft returned + * transition rides here so no rejection path can bypass it. Bookkeeping: it + * runs in its own savepoint, so its failure is reported and never vetoes the row. */ + inTransaction?: JournalRowTransactionHook + /** After any rollback, so a cache filled inside the transaction cannot outlive it. */ + rolledBack?: () => void } +const BOOKKEEPING_SAVEPOINT = 'journal_row_bookkeeping' + export class JournalRowWriter { constructor(private readonly deps: JournalRowWriterDeps) {} - enqueue(build: (seq: number, ts: number) => JournalRow): Promise { + enqueue( + build: (seq: number, ts: number) => JournalRow, + hook?: JournalRowTransactionHook + ): Promise { return this.deps.serialize(async () => { assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) const row = build(this.deps.nextSequence(), this.deps.now()) @@ -27,9 +44,12 @@ export class JournalRowWriter { try { insertJournalRow(db, this.deps.sessionId, row) upsertJournalSessionRow(db, this.deps.sessionId, row.epoch, row.ts) + hook?.(db, row) + this.runBookkeeping(db, row) db.exec('COMMIT') } catch (error) { db.exec('ROLLBACK') + this.deps.rolledBack?.() throw error } // COMMIT landed, so the row is durable: adopt it before anything that can @@ -39,4 +59,36 @@ export class JournalRowWriter { return row }) } + + /** Assign the next sequence, make the row durable, and fold it through the SAME reducer + * replay uses — all inside one serialized step — answering where the row landed. */ + append( + build: (seq: number, ts: number) => JournalRow, + hook?: JournalRowTransactionHook + ): Promise { + return this.enqueue(build, hook).then((row) => ({ epoch: row.epoch, sequence: row.seq })) + } + + private runBookkeeping(db: Database.Database, row: JournalRow): void { + const hook = this.deps.inTransaction + if (!hook) { + return + } + db.exec(`SAVEPOINT ${BOOKKEEPING_SAVEPOINT}`) + try { + hook(db, row) + db.exec(`RELEASE ${BOOKKEEPING_SAVEPOINT}`) + } catch (error) { + db.exec(`ROLLBACK TO ${BOOKKEEPING_SAVEPOINT}`) + db.exec(`RELEASE ${BOOKKEEPING_SAVEPOINT}`) + this.deps.rolledBack?.() + // The draft store re-derives what this missed from the committed rows: at open, and in + // the drain step before a draft sends. + console.warn('[journal-append] row bookkeeping skipped:', { + sessionId: this.deps.sessionId, + kind: row.kind, + error: error instanceof Error ? error.message : String(error) + }) + } + } } diff --git a/src/main/native-chat/agent-session-journal/journal-store-close.test.ts b/src/main/native-chat/agent-session-journal/journal-store-close.test.ts index 960c65a638a..5ab255749c1 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-close.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-close.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // `close()`: enqueue-time admission, and the fulfilled/rejected split. // // The two failures this file exists to prevent: an append enqueued in the same @@ -13,6 +14,8 @@ import type { AgentJournalItemIdentity, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { journalDatabaseFile } from './journal-paths' import type { AgentSessionJournal } from './journal-store' import { createTrackedJournalOpener } from './journal-store-test-open' @@ -78,7 +81,10 @@ afterEach(async () => { describe('closed-state admission happens at enqueue', () => { it('completes a write enqueued in the same turn as the close', async () => { const journal = await openJournal() - const append = journal.appendItem(item(1), body('before'), { fence: 1 }) + const append = journal.appendItem(item(1), body('before'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const closed = journal.close() await expect(append).resolves.toBeDefined() @@ -90,7 +96,10 @@ describe('closed-state admission happens at enqueue', () => { it('refuses a write offered while the close is still in flight, without queueing it', async () => { const journal = await openJournal() const closing = journal.close() - const refused = journal.appendItem(item(1), body('during'), { fence: 1 }) + const refused = journal.appendItem(item(1), body('during'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) // The rejection is available before the close step has run: it never joined // the queue, so nothing is ever chained behind a close. @@ -107,7 +116,12 @@ describe('closed-state admission happens at enqueue', () => { (error: unknown) => error ) const refusals = [ - settle(journal.appendItem(item(1), body('after'), { fence: 1 })), + settle( + journal.appendItem(item(1), body('after'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ), settle(journal.appendTombstone(item(3), { fence: 1 })), settle( journal.appendSubmission({ @@ -117,12 +131,28 @@ describe('closed-state admission happens at enqueue', () => { fence: 1 }) ), - settle(journal.resolveDispatch({ clientMessageId: 'cm_1', state: 'rejected', fence: 1 })), + settle( + journal.resolveDispatch({ + clientMessageId: 'cm_1', + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('hostFault'), { + surface: 'rejection' + }), + fence: 1 + }) + ), settle( journal.appendLifecycleBatch({ settlementId: 'settle', fence: 1, - mutations: [{ kind: 'item', identity: item(4), body: body('x') }] + mutations: [ + { + kind: 'item', + identity: item(4), + body: body('x'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] }) ), settle(journal.rollEpoch('handle_forked', 1)), @@ -137,12 +167,20 @@ describe('closed-state admission happens at enqueue', () => { // caller's own turn, so a refusal never advances the queue. it('rejects without waiting for the queue to advance', async () => { const journal = await openJournal() - const inFlight = journal.appendItem(item(1), body('admitted'), { fence: 1 }) + const inFlight = journal.appendItem(item(1), body('admitted'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const closing = journal.close() // Settles while the admitted append is still running: it reached the gate in // the caller's own turn and never joined the queue behind it. - await expect(journal.appendItem(item(2), body('later'), { fence: 1 })).rejects.toMatchObject({ + await expect( + journal.appendItem(item(2), body('later'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).rejects.toMatchObject({ code: 'journal_closed' }) await expect(inFlight).resolves.toBeDefined() @@ -164,7 +202,10 @@ describe('closed-state admission happens at enqueue', () => { describe('a rejected close is a real retry', () => { it('retries the release, releases the handle, and then goes terminal', async () => { const journal = await openJournal() - await journal.appendItem(item(1), body('durable'), { fence: 1 }) + await journal.appendItem(item(1), body('durable'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const injected = injectReleaseFailure(journal) await expect(journal.close()).rejects.toThrow('injected release failure') @@ -172,7 +213,12 @@ describe('a rejected close is a real retry', () => { // Write-closed anyway: retry exists to release the OS handle, never to // resurrect the store. - await expect(journal.appendItem(item(2), body('after'), { fence: 1 })).rejects.toMatchObject({ + await expect( + journal.appendItem(item(2), body('after'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).rejects.toMatchObject({ code: 'journal_closed' }) diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index a4ff455b31f..42d167914ce 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -12,6 +12,7 @@ import { JournalEpochController } from './journal-epoch-controller' import { JournalItemAppender } from './journal-item-appender' import { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' import type { JournalLoad } from './journal-open' +import { JournalQueuedMessages } from './journal-queued-messages' import type { JournalReducerState } from './journal-reducer' import { JournalRowWriter } from './journal-row-writer' import { restoreJournalStore } from './journal-store-restore' @@ -19,6 +20,10 @@ import type { JournalRow } from './journal-row-schema' import type { AgentSessionJournal } from './journal-store' export type JournalStoreHost = { + /** Fires the journal's commit listener for a durable change that appended no + * row — a standalone draft-table transaction — so readers learn of it the + * same way they learn of a row. */ + notifyCommitted: () => void identity: AgentSessionJournalIdentity journalDir: string now: () => number @@ -44,6 +49,7 @@ export type JournalStoreCollaborators = { epochController: JournalEpochController itemAppender: JournalItemAppender lifecycleBatchAppender: JournalLifecycleBatchAppender + queuedMessages: JournalQueuedMessages /** Restores the store's state from disk. Owned here because it needs the same * collaborators the constructor just built. */ restore: () => Promise @@ -62,9 +68,24 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal cursor: host.cursor, adopt: host.adopt }) + const queuedMessages = new JournalQueuedMessages({ + sessionId: host.identity.sessionId, + now: host.now, + serialize: host.serialize, + database: host.database, + readOnly: host.readOnly, + state: host.state, + committed: host.notifyCommitted + }) return { epochController, - restore: () => restoreJournalStore(host, { epochController }), + queuedMessages, + // Behind the stored fact: settles drafts whose consumed submission the loaded journal shows + // refused (a downgrade wrote no hook), then prunes. Bookkeeping, never failing the open. + restore: () => + restoreJournalStore(host, { epochController }).then(() => + queuedMessages.repairAndPruneAtOpen() + ), rowWriter: new JournalRowWriter({ sessionId: host.identity.sessionId, now: host.now, @@ -73,7 +94,11 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal readOnly: host.readOnly, highestFence: () => host.state().highestFence, nextSequence: () => host.state().lastSequence + 1, - commit: host.commit + commit: host.commit, + // Every rejection is a dispatch row through this one writer; the draft + // returned-transition rides it so no path can bypass the hook. + inTransaction: (db, row) => queuedMessages.onRowInTransaction(db, row), + rolledBack: () => queuedMessages.invalidate() }), itemAppender: new JournalItemAppender({ state: host.state, diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index cea47e381fe..dcb6a4bd5d1 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -1,10 +1,12 @@ +import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import type { AgentJournalCursor, AgentJournalItemBody, AgentJournalItemIdentity, AgentJournalMessageItem, - AgentJournalProducerLinkage, AgentJournalResetReason, + AgentJournalRowAttribution, + AgentJournalTurnScope, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { JournalLoad } from './journal-open' @@ -28,11 +30,18 @@ export type ResolveDispatchInput = { clientMessageId: string fence: number recovered?: true -} & ( - | { state: 'accepted'; providerIdentity: AgentJournalItemIdentity } - | { state: 'pending' } - | { state: 'rejected' | 'unknown'; reason?: string | null } -) +} & + /** A null identity: the provider took the message without echoing an item of its own, as a + * conversation command it carries out in place. */ + ( + | { state: 'accepted'; providerIdentity: AgentJournalItemIdentity | null } + /** The turn the message is handed into — the live root turn, or `thread` when none runs. */ + | { state: 'pending'; turnScope: AgentJournalTurnScope } + /** `reason` is what released clients print, `rejection` what newer ones read: both from + * `agentSessionFailureWords`, never written by hand. */ + | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | { state: 'unknown'; reason?: string | null } + ) export type JournalAppendResult = { cursor: AgentJournalCursor @@ -40,7 +49,7 @@ export type JournalAppendResult = { revision: number } -export type JournalItemAppendOptions = AgentJournalProducerLinkage & { +export type JournalItemAppendOptions = AgentJournalRowAttribution & { fence: number observedAt?: number recovered?: true @@ -59,6 +68,23 @@ export type JournalSubmissionInput = { payloadFingerprint: string body: AgentJournalMessageItem fence: number + /** The send is accepted now and handed over later, by a `dispatch{pending}` row. */ + handoverRecorded?: true + /** Stamped by `appendSubmission` from its consume; a caller-passed value must match it. */ + queuedMessageId?: string + /** Who asked for this turn (`JournalSubmissionRow.origin`). */ + origin?: 'client' | 'host' +} + +/** A submission append that converts a queued draft, in one transaction. */ +export type JournalSubmissionConsume = { + messageId: string + expect: 'waiting' | 'returned' + /** The operation ledger's caller-scoped key; null for the host's own drain. */ + settledByOp: string | null + /** The host process handing it off, stamped on the draft so a hand-off withdrawn back to + * waiting belongs to the process that sent it, not the one that first wrote the card. */ + hostInstance?: string } export type JournalItemAppendInput = { diff --git a/src/main/native-chat/agent-session-journal/journal-store-restore.ts b/src/main/native-chat/agent-session-journal/journal-store-restore.ts index fc69dd339d8..1b6f3a260a3 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-restore.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-restore.ts @@ -6,6 +6,7 @@ // same host the collaborators use, so the store keeps the state and this owns // the sequence. +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import type { JournalEpochController } from './journal-epoch-controller' import { replayJournal } from './journal-open' import type { JournalStoreHost } from './journal-store-collaborators' @@ -39,7 +40,9 @@ export function restoreJournalStore( publishRepairEpoch: () => collaborators.epochController.start('unreconcilable_prefix', host.state().highestFence), adopt: host.adopt, - appendItem: (identity, body, fence) => host.journal().appendItem(identity, body, { fence }), + // Repair and file-format notices are about the conversation, not any turn in it. + appendItem: (identity, body, fence) => + host.journal().appendItem(identity, body, { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE }), agent: host.identity.agent, highestFence: () => host.state().highestFence, malformedRows: host.malformedRows, diff --git a/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts b/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts index df34ef73e73..daa2e819647 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Two independent version axes, both fail closed. // // `PRAGMA user_version` the DB SHAPE, known before the first read @@ -86,14 +87,19 @@ afterEach(async () => { describe('axis 1: the database shape', () => { it('latches read-only on a newer user_version and writes nothing', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.close() await withDatabase((db) => db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`)) const before = await stat(journalDatabaseFile(root)) const reopened = await open() expect(reopened.isReadOnly).toBe(true) - await expect(reopened.appendItem(item(1), body('b'), { fence: 1 })).rejects.toMatchObject({ + await expect( + reopened.appendItem(item(1), body('b'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).rejects.toMatchObject({ code: 'journal_read_only' }) // The file this build must not touch is byte-identical afterwards. @@ -120,7 +126,10 @@ describe('axis 1: the database shape', () => { it('migrates an older user_version forward on reopen', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.close() await withDatabase((db) => db.pragma('user_version = 0')) @@ -137,7 +146,10 @@ describe('axis 1: the database shape', () => { describe('axis 2: the row body shape', () => { it('degrades to read-only on a row from a newer build, without skipping it', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const epoch = journal.epoch const nextSeq = journal.cursor().sequence + 1 await journal.close() @@ -159,7 +171,9 @@ describe('axis 2: the row body shape', () => { const reopened = await open() expect(reopened.isReadOnly).toBe(true) - await expect(reopened.appendItem(item(1), body('b'), { fence: 1 })).rejects.toMatchObject({ + await expect( + reopened.appendItem(item(1), body('b'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).rejects.toMatchObject({ code: 'journal_read_only' }) await reopened.close() @@ -174,7 +188,10 @@ describe('axis 2: the row body shape', () => { it('skips a malformed row without giving up the journal, and discloses the skip', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const epoch = journal.epoch const nextSeq = journal.cursor().sequence + 1 await journal.close() @@ -195,7 +212,10 @@ describe('axis 2: the row body shape', () => { it('keeps one disclosure row across reopens instead of stacking duplicates', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const epoch = journal.epoch const nextSeq = journal.cursor().sequence + 1 await journal.close() @@ -214,7 +234,10 @@ describe('axis 2: the row body shape', () => { it('reopens a journal holding an admitted malformed-percent item id without throwing', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const epoch = journal.epoch const nextSeq = journal.cursor().sequence + 1 await journal.close() diff --git a/src/main/native-chat/agent-session-journal/journal-store.test.ts b/src/main/native-chat/agent-session-journal/journal-store.test.ts index ffaea5c5d6f..67eb19f8cbe 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, readdir, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -76,7 +77,10 @@ describe('sequences', () => { const journal = await open() const results = await Promise.all( Array.from({ length: 25 }, (_unused, index) => - journal.appendItem(item(index), body(`m${index}`), { fence: 1 }) + journal.appendItem(item(index), body(`m${index}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) ) ) const sequences = results.map((result) => result.cursor.sequence) @@ -89,9 +93,9 @@ describe('sequences', () => { it('serializes revisions of one item so the last write wins deterministically', async () => { const journal = await open() const results = await Promise.all([ - journal.appendItem(item(0), body('a'), { fence: 1 }), - journal.appendItem(item(0), body('b'), { fence: 1 }), - journal.appendItem(item(0), body('c'), { fence: 1 }) + journal.appendItem(item(0), body('a'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }), + journal.appendItem(item(0), body('b'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }), + journal.appendItem(item(0), body('c'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) ]) expect(results.map((result) => result.revision)).toEqual([1, 2, 3]) expect(journal.snapshot().items).toHaveLength(1) @@ -100,9 +104,18 @@ describe('sequences', () => { it('visits reduced items at their creation sequence without promoting an older revision', async () => { const journal = await open() - await journal.appendItem(item(0), body('first'), { fence: 1 }) - const latest = await journal.appendItem(item(1), body('second'), { fence: 1 }) - await journal.appendItem(item(0), body('first revised'), { fence: 1 }) + await journal.appendItem(item(0), body('first'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + const latest = await journal.appendItem(item(1), body('second'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(item(0), body('first revised'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const visited: { itemId: string; sequence: number }[] = [] journal.visitItems((itemId, sequence) => visited.push({ itemId, sequence })) @@ -131,23 +144,26 @@ describe('sequences', () => { await journal.appendItem( turnItem('turn-1'), { kind: 'turn', turnId: 'turn-1', state: 'running' }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) - await journal.appendItem(item(0), body('work'), { fence: 1 }) + await journal.appendItem(item(0), body('work'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await bothAgreeOn('turn-1') // The completion is a revision, so it keeps the row's creation sequence rather than moving it. await journal.appendItem( turnItem('turn-1'), { kind: 'turn', turnId: 'turn-1', state: 'completed' }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await bothAgreeOn(null) await journal.appendItem( turnItem('turn-2'), { kind: 'turn', turnId: 'turn-2', state: 'running' }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await bothAgreeOn('turn-2') }) @@ -165,8 +181,14 @@ describe('sequences', () => { const mimicIdentity = identityFor(digestFormMimic) const journal = await open() - const oversized = await journal.appendItem(oversizedIdentity, body('oversized'), { fence: 1 }) - const mimic = await journal.appendItem(mimicIdentity, body('mimic'), { fence: 1 }) + const oversized = await journal.appendItem(oversizedIdentity, body('oversized'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + const mimic = await journal.appendItem(mimicIdentity, body('mimic'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) expect(oversized.itemId).not.toBe(mimic.itemId) expect([oversized.revision, mimic.revision]).toEqual([1, 1]) @@ -187,17 +209,28 @@ describe('sequences', () => { describe('fences', () => { it('rejects an append from a writer behind the journal', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 7 }) - await expect(journal.appendItem(item(1), body('b'), { fence: 6 })).rejects.toBeInstanceOf( - AgentSessionJournalError - ) + await journal.appendItem(item(0), body('a'), { + fence: 7, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await expect( + journal.appendItem(item(1), body('b'), { fence: 6, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).rejects.toBeInstanceOf(AgentSessionJournalError) }) it('keeps accepting appends after a rejected one', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 7 }) - await journal.appendItem(item(1), body('b'), { fence: 6 }).catch(() => undefined) - await journal.appendItem(item(2), body('c'), { fence: 7 }) + await journal.appendItem(item(0), body('a'), { + fence: 7, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal + .appendItem(item(1), body('b'), { fence: 6, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + .catch(() => undefined) + await journal.appendItem(item(2), body('c'), { + fence: 7, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) expect(journal.snapshot().items.map((entry) => entry.body)).toEqual([body('a'), body('c')]) }) }) @@ -205,7 +238,10 @@ describe('fences', () => { describe('replay', () => { it('adopts a caller-provided load without replaying the rows again', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const loaded = await loadJournal(root, IDENTITY.sessionId) expect(loaded).not.toBeNull() await journal.close() @@ -216,9 +252,18 @@ describe('replay', () => { it('reopens to the same render model the live writer held', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) - await journal.appendItem(item(1), body('b'), { fence: 1 }) - await journal.appendItem(item(0), body('a2'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(item(1), body('b'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(item(0), body('a2'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.appendTombstone(item(1), { fence: 1 }) const live = journal.snapshot() @@ -228,9 +273,15 @@ describe('replay', () => { it('serves a resume from a cursor and refuses one from a stale epoch', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const cursor = journal.cursor() - await journal.appendItem(item(1), body('b'), { fence: 1 }) + await journal.appendItem(item(1), body('b'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const resumed = journal.readSince(cursor) expect(resumed.ok && resumed.rows).toHaveLength(1) @@ -241,7 +292,10 @@ describe('replay', () => { it('rebuilds from a clean epoch after a rollover', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.rollEpoch('unreconcilable_prefix', 2) expect(journal.snapshot().items).toHaveLength(0) @@ -253,7 +307,10 @@ describe('replay', () => { it('keeps the intact prefix and drops the rejected suffix', async () => { const journal = await open() for (let index = 0; index < 4; index += 1) { - await journal.appendItem(item(index), body(`m${index}`), { fence: 1 }) + await journal.appendItem(item(index), body(`m${index}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } const before = journal.epoch await journal.close() @@ -306,7 +363,14 @@ describe('lifecycle batches', () => { const input = { settlementId: 'concurrent-settlement', fence: 1, - mutations: [{ kind: 'item' as const, identity: item(1), body: body('settled') }] + mutations: [ + { + kind: 'item' as const, + identity: item(1), + body: body('settled'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] } const [first, replay] = await Promise.all([ @@ -325,17 +389,27 @@ describe('lifecycle batches', () => { sessionId: 'session-1', recordId: 'turn-lifecycle:turn-1' } - await journal.appendItem(turn, { kind: 'status', text: 'working' }, { fence: 1 }) + await journal.appendItem( + turn, + { kind: 'status', text: 'working' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) const settled = await journal.appendLifecycleBatch({ settlementId: 'exit:turn-1', fence: 1, mutations: [ - { kind: 'item', identity: item(1), body: body('tool settled') }, + { + kind: 'item', + identity: item(1), + body: body('tool settled'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }, { kind: 'item', identity: { provider: 'orca', clientMessageId: 'exit-status' }, - body: { kind: 'status', text: 'Provider exited' } + body: { kind: 'status', text: 'Provider exited' }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE }, { kind: 'tombstone', identity: turn } ] @@ -356,7 +430,14 @@ describe('lifecycle batches', () => { const replay = await reopened.appendLifecycleBatch({ settlementId: 'exit:turn-1', fence: 1, - mutations: [{ kind: 'item', identity: item(9), body: body('must not appear') }] + mutations: [ + { + kind: 'item', + identity: item(9), + body: body('must not appear'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] }) expect(replay).toEqual(beforeReplay) expect( @@ -397,7 +478,10 @@ describe('journal location', () => { describe('on-disk layout', () => { it('keeps the session database and its projection in one directory', async () => { const journal: AgentSessionJournal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) expect(await readdir(root)).toContain('journal.db') await journal.close() await withJournalDatabase(root, (db) => { @@ -424,3 +508,34 @@ async function withJournalDatabase( opened.db.close() } } + +describe('what a handle found on disk when it opened', () => { + const submission = (clientMessageId: string) => ({ + clientMessageId, + payloadFingerprint: 'fp', + body: { kind: 'message' as const, role: 'user' as const, blocks: [] }, + fence: 1, + handoverRecorded: true as const + }) + + it('names rows an earlier handle wrote, and never a row of a later epoch', async () => { + const earlier = await open() + await earlier.appendItem(item(1), body('one'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await earlier.appendSubmission(submission('earlier')) + await earlier.close() + + const journal = await open() + const leftover = journal.submissions().find((entry) => entry.clientMessageId === 'earlier') + expect(journal.wroteBeforeOpen(leftover?.acceptedSequence)).toBe(true) + + // Sequences restart with an epoch, so a row accepted after it can sit below the open cursor. + await journal.replaceEpochItems('handle_forked', 1, []) + await journal.appendSubmission(submission('later')) + const later = journal.submissions().find((entry) => entry.clientMessageId === 'later') + expect(later?.acceptedSequence).toBeLessThanOrEqual(2) + expect(journal.wroteBeforeOpen(later?.acceptedSequence)).toBe(false) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index ec445cb7004..1f6015c3940 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -1,5 +1,6 @@ // Append-only journal store for one agent session. +import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import { randomUUID } from 'node:crypto' import type { AgentJournalAcceptanceReceipt, @@ -10,6 +11,7 @@ import type { AgentJournalSubmission, AgentJournalThreadGoal, AgentJournalTurnLifecycle, + AgentJournalTurnScope, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' @@ -18,6 +20,7 @@ import type { AgentSessionContextUsage } from '../../../shared/agent-session-con import { latestStructuredAgentContextFacts } from '../../../shared/structured-agent-session-context-usage' import { activeStructuredAgentSessionTurnIdBySequence, + liveStructuredAgentSessionTurnScope, newestStructuredAgentSessionTurnBySequence } from '../../../shared/structured-agent-session-live-turn' import { agentSessionJournalCloseRetries } from './journal-close-retry' @@ -28,7 +31,8 @@ import { readJournalRowsAfterCursor, type JournalLoad } from './journal-open' import { journalDatabaseFile } from './journal-paths' import { markJournalPendingSubmissionsUnknown, - rejectJournalPendingSubmissions + rejectJournalPendingSubmissions, + rejectJournalQueuedSubmissions } from './journal-pending-submission-recovery' import { applyJournalRow, @@ -48,11 +52,13 @@ import type { JournalItemAppendOptions, JournalLifecycleBatchInput, JournalReadSince, + JournalSubmissionConsume, JournalSubmissionInput, JournalTombstoneInput, ResolveDispatchInput } from './journal-store-contracts' -import type { AgentJournalEpochReason, JournalRow } from './journal-row-schema' +import { queuedMessageConsumeHook, type JournalQueuedMessages } from './journal-queued-messages' +import type { AgentJournalEpochReason } from './journal-row-schema' import { AgentSessionJournalError } from './journal-write-guards' import type { JournalRowWriter } from './journal-row-writer' import type { JournalEpochController } from './journal-epoch-controller' @@ -75,7 +81,9 @@ export class AgentSessionJournal { private state: JournalReducerState private readOnly = false private malformedRows = 0 + private openedThrough: AgentJournalCursor = { epoch: '', sequence: 0 } private database: OpenJournalDatabase | null = null + private onCommitted: (() => void) | null = null private readonly queue: JournalWriteQueue private readonly closer: JournalConnectionCloser private readonly rowWriter: JournalRowWriter @@ -83,6 +91,8 @@ export class AgentSessionJournal { private readonly itemAppender: JournalItemAppender private readonly lifecycleBatchAppender: JournalLifecycleBatchAppender private readonly restore: () => Promise + /** Draft rows queued while the agent works; never reducer input or owed work. */ + readonly queuedMessages: JournalQueuedMessages constructor(options: AgentSessionJournalOptions) { this.identity = options.identity @@ -111,20 +121,28 @@ export class AgentSessionJournal { this.readOnly = readOnly }, cursor: this.cursor, - adopt: (loaded) => this.adoptLoadedJournal(loaded), - commit: (row) => applyJournalRow(this.state, row), + adopt: (loaded) => { + this.adoptLoadedJournal(loaded) + this.onCommitted?.() + }, + commit: (row) => { + applyJournalRow(this.state, row) + this.onCommitted?.() + }, + notifyCommitted: () => this.onCommitted?.(), loaded: () => this.loaded, malformedRows: () => this.malformedRows, setMalformedRows: (count) => { this.malformedRows = count }, journal: () => this, - enqueue: (build) => this.enqueue(build) + enqueue: (build) => this.rowWriter.enqueue(build) }) this.rowWriter = collaborators.rowWriter this.epochController = collaborators.epochController this.itemAppender = collaborators.itemAppender this.lifecycleBatchAppender = collaborators.lifecycleBatchAppender + this.queuedMessages = collaborators.queuedMessages this.restore = collaborators.restore } @@ -140,6 +158,16 @@ export class AgentSessionJournal { return this.journalDir } + /** Whether a row at this sequence was on disk when this handle opened, so an earlier handle + * wrote it. Sequences restart with each epoch, so a row of a later epoch never was. */ + wroteBeforeOpen(sequence: number | undefined): boolean { + return ( + sequence !== undefined && + this.state.epoch === this.openedThrough.epoch && + sequence <= this.openedThrough.sequence + ) + } + /** What the last open's repair did. */ get repair(): { malformedRows: number } { return { malformedRows: this.malformedRows } @@ -150,6 +178,7 @@ export class AgentSessionJournal { this.database = openJournalDatabase(this.dbPath) try { await this.restore() + this.openedThrough = this.cursor() } catch (error) { // Nothing else holds a reference to this connection, so a throw here is // the leak site unless the store releases it itself — and a close that @@ -167,6 +196,12 @@ export class AgentSessionJournal { return this.closer.close() } + /** Told of every durable change, epoch replacements included, so a reader learns of a write + * without its writer saying so. One listener: a later call replaces it. It must not throw. */ + observeCommits(listener: () => void): void { + this.onCommitted = listener + } + cursor = (): AgentJournalCursor => ({ epoch: this.state.epoch, sequence: this.state.lastSequence @@ -192,6 +227,10 @@ export class AgentSessionJournal { activeTurnId = (): string | null => activeStructuredAgentSessionTurnIdBySequence(this.state.items.values()) + /** Where a row written now belongs: the running turn, or the conversation. */ + liveTurnScope = (): AgentJournalTurnScope => + liveStructuredAgentSessionTurnScope(this.state.items.values()) + /** The newest turn record whatever state it settled in, for readers that need the outcome. */ newestTurn = (): AgentJournalTurnLifecycle | null => newestStructuredAgentSessionTurnBySequence(this.state.items.values()) @@ -207,8 +246,13 @@ export class AgentSessionJournal { /** Includes revisions and completion tombstones, whose timestamps disappear from render items. */ lastActivityAt = (): number => this.state.lastActivityAt + /** Fence of the writer that created the item, while it is in the timeline. */ + itemFence = (itemId: string): number | undefined => this.state.itemFences.get(itemId) + submissions = (): AgentJournalSubmission[] => [...this.state.submissions.values()] + submission = (clientMessageId: string) => this.state.submissions.get(clientMessageId) + pendingSubmissions = (): AgentJournalSubmission[] => this.submissions().filter((entry) => entry.dispatchState === 'pending') @@ -243,7 +287,7 @@ export class AgentSessionJournal { appendItem( identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - options: JournalItemAppendOptions = { fence: 0 } + options: JournalItemAppendOptions ): Promise { return this.itemAppender.append(identity, body, options) } @@ -253,8 +297,8 @@ export class AgentSessionJournal { options: JournalTombstoneInput ): Promise { const itemId = agentJournalItemKey(identity) - return this.enqueue(journalTombstoneRowBuilder(() => this.state, itemId, options.fence)).then( - (row) => ({ epoch: row.epoch, sequence: row.seq }) + return this.rowWriter.append( + journalTombstoneRowBuilder(() => this.state, itemId, options.fence) ) } @@ -267,10 +311,16 @@ export class AgentSessionJournal { * anything, and it doubles as the optimistic user bubble so an accepted echo * reconciles into an existing slot instead of appending a second copy. */ - appendSubmission(input: JournalSubmissionInput): Promise { - return this.enqueue( - journalSubmissionRowBuilder(() => this.state, this.identity.providerHandle, input) - ).then((row) => ({ epoch: row.epoch, sequence: row.seq })) + appendSubmission( + input: JournalSubmissionInput, + /** Present: this submission is a queued draft's conversion, and the draft's + * state transition commits in the SAME transaction — exactly-once consume. */ + consume?: JournalSubmissionConsume + ): Promise { + return this.rowWriter.append( + journalSubmissionRowBuilder(() => this.state, this.identity.providerHandle, input, consume), + consume && queuedMessageConsumeHook(this.queuedMessages, input.clientMessageId, consume) + ) } /** @@ -281,10 +331,7 @@ export class AgentSessionJournal { * string here would silently give the user a second copy of their own message. */ resolveDispatch(input: ResolveDispatchInput): Promise { - return this.enqueue(journalDispatchRowBuilder(() => this.state, input)).then((row) => ({ - epoch: row.epoch, - sequence: row.seq - })) + return this.rowWriter.append(journalDispatchRowBuilder(() => this.state, input)) } /** Retire unanswered sends after their execution owner ended, without assuming delivery. */ @@ -293,8 +340,20 @@ export class AgentSessionJournal { } /** Reject unanswered sends after an owner that never proved its start ended: none was written. */ - async rejectPendingSubmissions(fence: number, reason: string): Promise { - return rejectJournalPendingSubmissions(this, fence, reason) + async rejectPendingSubmissions( + fence: number, + rejection: AgentJournalDispatchRejection + ): Promise { + return rejectJournalPendingSubmissions(this, fence, rejection) + } + + /** Reject sends accepted but never handed over, optionally only those `which` names. */ + async rejectQueuedSubmissions( + fence: number, + rejection: AgentJournalDispatchRejection, + which?: (submission: AgentJournalSubmission) => boolean + ): Promise { + return rejectJournalQueuedSubmissions(this, fence, rejection, which) } /** The escape hatch for corruption, an unreconcilable prefix, a forked handle, @@ -324,13 +383,4 @@ export class AgentSessionJournal { } return this.database } - - /** - * Assign the next sequence, make the row durable, and fold it through the - * SAME reducer replay uses — all inside one serialized step, so concurrent - * callers cannot interleave and mint the same sequence. - */ - private enqueue(build: (seq: number, ts: number) => JournalRow): Promise { - return this.rowWriter.enqueue(build) - } } diff --git a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts index b0dff559b1a..1fea8b9efa5 100644 --- a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -222,7 +223,10 @@ describe('journal reopen after the writing host is gone', () => { { id: 'a', label: 'read_readme', state: 'working', startedAt: 10 }, { id: 'b', label: 'read_package', state: 'working', startedAt: 10 } ]) - await live.appendItem(row.identity, row.body, { fence: 0 }) + await live.appendItem(row.identity, row.body, { + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) // Still the writing host: it can see the children, so the row says so. const beforeRestart = live.snapshot().items.at(-1)! @@ -245,7 +249,10 @@ describe('journal reopen after the writing host is gone', () => { it('revises the row in place rather than appending a second one', async () => { const live = await open() const row = rosterRow([{ id: 'a', label: 'read', state: 'working', startedAt: 10 }]) - await live.appendItem(row.identity, row.body, { fence: 0 }) + await live.appendItem(row.identity, row.body, { + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const before = live.snapshot().items.length await live.close() @@ -257,7 +264,10 @@ describe('journal reopen after the writing host is gone', () => { it('settles a persisted working background task to unverifiable', async () => { const live = await open() const row = backgroundTaskRow() - await live.appendItem(row.identity, row.body, { fence: 0 }) + await live.appendItem(row.identity, row.body, { + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const beforeRestart = live.snapshot().items.at(-1)! expect(taskOf(beforeRestart.body)).toMatchObject({ state: 'working' }) expect(twinOf(beforeRestart.body)).toBe('Started background command "sleep 20"') @@ -273,7 +283,10 @@ describe('journal reopen after the writing host is gone', () => { it('writes nothing on a second reopen once every child is settled', async () => { const live = await open() const row = rosterRow([{ id: 'a', label: 'read', state: 'working', startedAt: 10 }]) - await live.appendItem(row.identity, row.body, { fence: 0 }) + await live.appendItem(row.identity, row.body, { + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await live.close() const once = await open() diff --git a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts new file mode 100644 index 00000000000..2c9c6cfcd04 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts @@ -0,0 +1,113 @@ +// Folding submission rows: the queue entry, its message row, where a handover places it, and the +// provider item an accepted message adopts. + +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { journalRenderItem } from './journal-render-item' +import { statedOrDerivedTurnScope, upsertJournalItem } from './journal-item-fold' +import type { JournalReducerState } from './journal-reducer' +import type { JournalRow } from './journal-row-schema' +import { journalDispatchRowApplies } from './journal-dispatch-settlement' + +export function applyJournalSubmission( + state: JournalReducerState, + row: Extract +): void { + state.submissions.set(row.clientMessageId, { + clientMessageId: row.clientMessageId, + fence: row.fence, + payloadFingerprint: row.payloadFingerprint, + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: row.ts, + resolvedAt: null, + ...(row.handoverRecorded ? { handoverRecorded: true, acceptedSequence: row.seq } : {}), + // A malformed stored link is dropped, never the row. + ...(typeof row.queuedMessageId === 'string' && row.queuedMessageId.length > 0 + ? { queuedMessageId: row.queuedMessageId } + : {}), + ...(row.origin === 'client' || row.origin === 'host' ? { origin: row.origin } : {}) + }) + const itemId = agentJournalSubmissionKey(row.clientMessageId) + // A message handed over later belongs to no turn until its handover names one. + const turnScope = row.handoverRecorded + ? AGENT_JOURNAL_THREAD_SCOPE + : statedOrDerivedTurnScope(state, row) + upsertJournalItem( + state, + itemId, + 0, + journalRenderItem(itemId, 0, row.body, row, turnScope), + row.fence + ) +} + +/** A queued message joins the conversation where it was handed over, not where it was accepted: + * what the agent did meanwhile — a command it waited behind, say — happened before it. It joins + * the turn that handover delivered it into — a steer — or none. Rows from hosts that predate the + * stated scope are scoped at the handover, as their creation would have been. */ +export function placeHandedOverMessage( + state: JournalReducerState, + submission: AgentJournalSubmission, + row: Extract +): void { + const itemId = agentJournalSubmissionKey(submission.clientMessageId) + const item = state.items.get(itemId) + if (!submission.handoverRecorded || !item) { + return + } + const { sequenceIndex: _acceptedAt, ...accepted } = item + state.items.set(itemId, { + ...accepted, + sequence: row.seq, + observedAt: row.ts, + turnScope: row.turnScope ?? state.derivedTurnScope.scopeFor(item.body) + }) +} + +export function acceptSubmissionFromProviderItem( + state: JournalReducerState, + providerItemId: string, + resolvedItemId: string, + row: Pick +): void { + if (providerItemId === resolvedItemId) { + return + } + const submission = [...state.submissions.values()].find( + (candidate) => agentJournalSubmissionKey(candidate.clientMessageId) === resolvedItemId + ) + if (!submission || !journalDispatchRowApplies(submission)) { + return + } + submission.fence = row.fence + submission.dispatchState = 'accepted' + notePersonTurnAccepted(state, submission) + submission.providerItemId = providerItemId + submission.reason = null + submission.resolvedAt = row.ts + delete submission.recovered + state.receipts.set(submission.clientMessageId, { + clientMessageId: submission.clientMessageId, + providerItemId, + cursor: { epoch: row.epoch, sequence: row.seq }, + acceptedAt: row.ts + }) +} + +/** A person's turn the provider accepted: the fact the queue's pause is lifted by. */ +export function notePersonTurnAccepted( + state: JournalReducerState, + submission: Pick +): void { + if (submission.origin === 'client' && submission.acceptedSequence !== undefined) { + state.latestPersonTurnSequence = Math.max( + state.latestPersonTurnSequence, + submission.acceptedSequence + ) + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts new file mode 100644 index 00000000000..0d62ed1b232 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts @@ -0,0 +1,154 @@ +// A submission that hands off a queued draft names that draft (`queuedMessageId`), +// persisted on its journal row and published on the submission; a direct send +// names none. Clients read the link, never a draft id compared with a submission id. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AgentJournalSubmissionSchema } from '../../../shared/agent-session-journal-schemas' +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { createJournalReducerState, applyJournalRow } from './journal-reducer' +import { parseJournalRow, serializeJournalRow, type JournalRow } from './journal-row-schema' +import type { AgentSessionJournal } from './journal-store' +import { createTrackedJournalOpener } from './journal-store-test-open' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-q', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } +} +const BODY: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'queued text' }] +} + +let root: string +let clock = 1_000 +const journals = createTrackedJournalOpener() + +function open(): Promise { + return journals.open({ + identity: IDENTITY, + journalDir: root, + now: () => ++clock, + mintEpoch: () => `epoch-${clock}` + }) +} + +async function handOff(journal: AgentSessionJournal, draftId: string, submissionId: string) { + await journal.queuedMessages.insert({ + messageId: draftId, + body: BODY, + fingerprint: 'fp', + hostInstance: 'p' + }) + await journal.appendSubmission( + { clientMessageId: submissionId, payloadFingerprint: 'fp', body: BODY, fence: 0 }, + { messageId: draftId, expect: 'waiting', settledByOp: null } + ) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-queued-link-')) + clock = 1_000 +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe('the submission names the queued draft it hands off', () => { + it('on every hand-off, and never on a direct send', async () => { + const journal = await open() + await handOff(journal, 'draft-1', 'handoff-1') + await journal.appendSubmission({ + clientMessageId: 'direct-1', + payloadFingerprint: 'fp-direct', + body: BODY, + fence: 0 + }) + expect(journal.submission('handoff-1')?.queuedMessageId).toBe('draft-1') + expect(journal.submission('direct-1')).not.toHaveProperty('queuedMessageId') + }) + + it('survives a reload, which replays the rows through the reducer', async () => { + let journal = await open() + await handOff(journal, 'draft-1', 'handoff-1') + await journal.close() + journal = await open() + expect(journal.submission('handoff-1')?.queuedMessageId).toBe('draft-1') + expect( + journal.snapshot().submissions.find((entry) => entry.clientMessageId === 'handoff-1') + ).toMatchObject({ queuedMessageId: 'draft-1' }) + }) + + it('refuses a caller naming a different draft than the one it consumes, and writes nothing', async () => { + const journal = await open() + await journal.queuedMessages.insert({ + messageId: 'draft-1', + body: BODY, + fingerprint: 'fp', + hostInstance: 'p' + }) + await expect( + journal.appendSubmission( + { + clientMessageId: 'handoff-1', + payloadFingerprint: 'fp', + body: BODY, + fence: 0, + queuedMessageId: 'draft-2' + }, + { messageId: 'draft-1', expect: 'waiting', settledByOp: null } + ) + ).rejects.toThrow() + expect(journal.submissions()).toHaveLength(0) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + }) + + it('is published: the wire schema keeps the field rather than stripping it', async () => { + const journal = await open() + await handOff(journal, 'draft-1', 'handoff-1') + const published = AgentJournalSubmissionSchema.parse(journal.submission('handoff-1')) + expect(published.queuedMessageId).toBe('draft-1') + }) +}) + +describe('the persisted row', () => { + const row: JournalRow = { + v: 1, + kind: 'submission', + epoch: 'epoch-1', + seq: 1, + fence: 0, + ts: 1, + clientMessageId: 'handoff-1', + payloadFingerprint: 'fp', + providerHandle: IDENTITY.providerHandle, + body: BODY, + queuedMessageId: 'draft-1' + } + + it('parses with the key, which a reader that does not know it simply keeps', () => { + const parsed = parseJournalRow(serializeJournalRow(row)) + expect(parsed).toMatchObject({ ok: true, row: { queuedMessageId: 'draft-1' } }) + }) + + it('keeps a row whose stored link is malformed, dropping only the link', () => { + const parsed = parseJournalRow(JSON.stringify({ ...row, queuedMessageId: 42 })) + if (!parsed.ok) { + throw new Error('the row must survive a malformed link') + } + const state = createJournalReducerState('session-q', 'epoch-1') + applyJournalRow(state, parsed.row) + expect(state.submissions.get('handoff-1')).not.toHaveProperty('queuedMessageId') + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts index 16999de0363..4647ba1e0e3 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts @@ -18,6 +18,7 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { DISPATCH_REJECTED_NOT_DELIVERED } from '../../../shared/structured-agent-session-dispatch-rejection' export type ProviderHistoryItem = { /** The provider's own id for this item. Used to claim it at most once; the @@ -55,7 +56,7 @@ export type SubmissionReconciliation = | { clientMessageId: string; outcome: 'rejected'; reason: SubmissionRejectionReason } | { clientMessageId: string; outcome: 'unknown'; reason: SubmissionUnknownReason } -export type SubmissionRejectionReason = 'not_delivered' +export type SubmissionRejectionReason = typeof DISPATCH_REJECTED_NOT_DELIVERED export type SubmissionUnknownReason = | 'history_boundary_inconsistent' @@ -197,6 +198,6 @@ function resolveOne( return { clientMessageId: submission.clientMessageId, outcome: 'rejected', - reason: 'not_delivered' + reason: DISPATCH_REJECTED_NOT_DELIVERED } } diff --git a/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts b/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts new file mode 100644 index 00000000000..6842c119f73 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from 'vitest' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalItemIdentity, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { projectNativeChatTranscriptMessages } from '../../../shared/native-chat-transcript-projection' +import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' +import { + applyJournalRow, + createJournalReducerState, + renderJournalState, + type JournalReducerState +} from './journal-reducer' +import { + buildJournalItemRow, + buildJournalSubmissionRow, + journalRowBase +} from './journal-row-builders' +import type { JournalRow } from './journal-row-schema' + +const TURN_1: AgentJournalItemIdentity = { provider: 'orca', clientMessageId: 'turn-1' } +const TURN_2: AgentJournalItemIdentity = { provider: 'orca', clientMessageId: 'turn-2' } +const COMMAND_TURN: AgentJournalItemIdentity = { + provider: 'orca', + clientMessageId: 'command-turn:cmd-1' +} +const inTurn = (identity: AgentJournalItemIdentity): AgentJournalTurnScope => ({ + kind: 'turn', + turnItemId: agentJournalItemKey(identity) +}) + +function row(id: string): AgentJournalItemIdentity { + return { provider: 'orca', clientMessageId: id } +} + +function prose(text: string): AgentJournalItemBody { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } +} + +function turn(turnId: string, state: 'running' | 'completed'): AgentJournalItemBody { + return { kind: 'turn', turnId, state, startedAt: 1 } +} + +/** Builds rows against a scratch state, so the same list can be folded more than once. */ +function rows() { + const state = createJournalReducerState('session-1', 'epoch-1') + const built: JournalRow[] = [] + let seq = 0 + const push = (next: JournalRow): void => { + built.push(next) + applyJournalRow(state, next) + } + return { + state, + built, + item( + identity: AgentJournalItemIdentity, + body: AgentJournalItemBody, + scope?: AgentJournalTurnScope + ) { + seq += 1 + const next = buildJournalItemRow({ + state, + identity, + body, + seq, + fence: 1, + ts: 1_000 + seq, + turnScope: scope ?? AGENT_JOURNAL_THREAD_SCOPE + }) + if (!scope) { + // A row from a host that predates stated scopes. + delete next.turnScope + } + push(next) + }, + submission(clientMessageId: string) { + seq += 1 + push( + buildJournalSubmissionRow({ + state, + clientMessageId, + payloadFingerprint: `fp-${clientMessageId}`, + providerHandle: { kind: 'codex', threadId: 'thread-1' }, + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: clientMessageId }] + }, + seq, + fence: 1, + ts: 1_000 + seq, + handoverRecorded: true + }) + ) + }, + handover(clientMessageId: string, scope?: AgentJournalTurnScope) { + seq += 1 + // Built by hand: a row from an older host carries no scope, which the builder never writes. + push({ + kind: 'dispatch', + clientMessageId, + state: 'pending', + providerItemId: null, + reason: null, + ...journalRowBase(state.epoch, seq, 1, 1_000 + seq), + ...(scope ? { turnScope: scope } : {}) + }) + } + } +} + +function scopeOf(state: JournalReducerState, identity: AgentJournalItemIdentity) { + return state.items.get(agentJournalItemKey(identity))?.turnScope +} + +function submissionScope(state: JournalReducerState, clientMessageId: string) { + return state.items.get(agentJournalSubmissionKey(clientMessageId))?.turnScope +} + +function refold(built: readonly JournalRow[]) { + const state = createJournalReducerState('session-1', 'epoch-1') + for (const next of built) { + applyJournalRow(state, next) + } + return renderJournalState(state) +} + +describe('stated turn scope', () => { + it('keeps the scope of the write that created the row', () => { + const { state, item } = rows() + item(TURN_1, turn('t1', 'running'), AGENT_JOURNAL_THREAD_SCOPE) + item(row('answer'), prose('first'), inTurn(TURN_1)) + // A revision stating another scope only updates the content. + item(row('answer'), prose('second'), AGENT_JOURNAL_THREAD_SCOPE) + expect(scopeOf(state, row('answer'))).toEqual(inTurn(TURN_1)) + }) + + it('places a queued message only when its handover states a turn', () => { + const { state, item, submission, handover } = rows() + item(TURN_1, turn('t1', 'running'), AGENT_JOURNAL_THREAD_SCOPE) + submission('steer') + submission('later') + // Accepted while a turn runs, but not yet delivered into it. + expect(submissionScope(state, 'steer')).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + handover('steer', inTurn(TURN_1)) + handover('later', AGENT_JOURNAL_THREAD_SCOPE) + expect(submissionScope(state, 'steer')).toEqual(inTurn(TURN_1)) + expect(submissionScope(state, 'later')).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + }) + + it('keeps a message held behind a command out of the command turn', () => { + const { state, item, submission, handover } = rows() + submission('cmd-1') + handover('cmd-1', AGENT_JOURNAL_THREAD_SCOPE) + item(COMMAND_TURN, turn('compact:cmd-1', 'running'), AGENT_JOURNAL_THREAD_SCOPE) + submission('held') + expect(submissionScope(state, 'held')).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + item(row('result'), prose('compacted'), inTurn(COMMAND_TURN)) + item(COMMAND_TURN, turn('compact:cmd-1', 'completed'), AGENT_JOURNAL_THREAD_SCOPE) + const drawn = (messages: readonly { id: string }[]) => + messages + .map((message) => message.id) + .filter((id) => id === agentJournalItemKey(row('result')) || id.includes('held')) + const expected = [agentJournalItemKey(row('result')), agentJournalSubmissionKey('held')] + const onPhone = () => { + const { items, submissions } = renderJournalState(state) + return projectStructuredAgentSessionMessages(items, [], submissions) + } + // Still waiting: drawn after everything the agent did, the command's result included. + expect(drawn(onPhone())).toEqual(expected) + handover('held', AGENT_JOURNAL_THREAD_SCOPE) + expect(submissionScope(state, 'held')).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + // Delivered: placed where it was handed over, by sequence for paging and the phone, and by + // timestamp for the desktop transcript. + expect(drawn(onPhone())).toEqual(expected) + expect(drawn(projectNativeChatTranscriptMessages(onPhone()))).toEqual(expected) + }) +}) + +describe('scope derived for rows stored without one', () => { + it('places rows inside a live span and closes it at the terminal revision', () => { + const { state, item } = rows() + item(row('before'), prose('before')) + item(TURN_1, turn('t1', 'running')) + item(row('inside'), prose('inside')) + item(TURN_1, turn('t1', 'completed')) + item(row('after'), prose('after')) + expect(scopeOf(state, row('before'))).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + expect(scopeOf(state, TURN_1)).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + expect(scopeOf(state, row('inside'))).toEqual(inTurn(TURN_1)) + expect(scopeOf(state, row('after'))).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + }) + + it('keeps a turn rebuilt already settled open until the next turn record', () => { + const { state, item } = rows() + item(TURN_1, turn('t1', 'completed')) + item(row('first'), prose('first')) + item(TURN_2, turn('t2', 'completed')) + item(row('second'), prose('second')) + expect(scopeOf(state, row('first'))).toEqual(inTurn(TURN_1)) + expect(scopeOf(state, row('second'))).toEqual(inTurn(TURN_2)) + }) + + it('closes a legacy /compact carrier where its turn body is overwritten, and shows the carrier', () => { + const { state, item } = rows() + const carrier = row('compact:op-1') + item(carrier, { + kind: 'status', + text: 'Compacting conversation…', + turnLifecycle: { turnId: 'compact:op-1', state: 'running' } + }) + item(row('summary'), prose('summary')) + item(carrier, { kind: 'status', text: 'Conversation compacted.' }) + item(row('after'), prose('after')) + expect(scopeOf(state, carrier)).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + expect(scopeOf(state, row('summary'))).toEqual(inTurn(carrier)) + expect(scopeOf(state, row('after'))).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + }) + + it('lands rows written after a crash left a turn running in that turn', () => { + const { state, item } = rows() + item(TURN_1, turn('t1', 'running')) + item(row('crash-window'), prose('written before the sweep')) + expect(scopeOf(state, row('crash-window'))).toEqual(inTurn(TURN_1)) + }) + + it('derives the same scopes on replay as live', () => { + const { state, built, item, submission, handover } = rows() + item(TURN_1, turn('t1', 'running')) + item(row('inside'), prose('inside')) + submission('steer') + handover('steer') + item(TURN_1, turn('t1', 'completed')) + item(row('after'), prose('after')) + expect(submissionScope(state, 'steer')).toEqual(inTurn(TURN_1)) + expect(refold(built)).toEqual(renderJournalState(state)) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-write-guards.ts b/src/main/native-chat/agent-session-journal/journal-write-guards.ts index e1869ae0c73..eeab1671649 100644 --- a/src/main/native-chat/agent-session-journal/journal-write-guards.ts +++ b/src/main/native-chat/agent-session-journal/journal-write-guards.ts @@ -5,7 +5,11 @@ export class AgentSessionJournalError extends Error { constructor( - readonly code: 'journal_read_only' | 'journal_stale_fence' | 'journal_closed', + readonly code: + | 'journal_read_only' + | 'journal_stale_fence' + | 'journal_closed' + | 'journal_submission_exists', message: string ) { super(message) @@ -34,3 +38,17 @@ export function assertJournalFence(fence: number, highestFence: number): void { ) } } + +/** One id, one delivery: a second submission row under an id would reset its + * settled answer to pending and hand the message over again. */ +export function assertSubmissionIdUnused( + submissions: ReadonlyMap, + clientMessageId: string +): void { + if (submissions.has(clientMessageId)) { + throw new AgentSessionJournalError( + 'journal_submission_exists', + `a submission ${clientMessageId} is already recorded` + ) + } +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts new file mode 100644 index 00000000000..75b2e1ff65d --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts @@ -0,0 +1,213 @@ +// Draft bookkeeping never vetoes a journal row: a failing draft transition +// rolls back alone and the next open re-derives it, and a draft table an +// earlier build created gains the columns this build writes. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import Database from '../../sqlite/sync-database' +import { journalDatabaseFile } from './journal-paths' +import { JournalQueuedMessages } from './journal-queued-messages' +import type { AgentSessionJournal } from './journal-store' +import { createTrackedJournalOpener } from './journal-store-test-open' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-q', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } +} +const REFUSAL = agentSessionFailureWords( + agentSessionFailureFact('providerRejected', { + detail: { text: 'Claude refused this payload', audience: 'person' } + }), + { surface: 'rejection' } +) + +const BODY: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'queued text' }] +} + +let root: string +let clock = 1_000 +const journals = createTrackedJournalOpener() + +function open(): Promise { + return journals.open({ + identity: IDENTITY, + journalDir: root, + now: () => ++clock, + mintEpoch: () => `epoch-${clock}` + }) +} + +async function queueAndConsume(journal: AgentSessionJournal, messageId: string): Promise { + const body: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'queued text' }] + } + await journal.queuedMessages.insert({ + messageId, + body, + fingerprint: `fp-${messageId}`, + hostInstance: 'proc-1' + }) + await journal.appendSubmission( + { + clientMessageId: `sub-${messageId}`, + payloadFingerprint: `fp-${messageId}`, + body, + fence: 0, + handoverRecorded: true + }, + { messageId, expect: 'waiting', settledByOp: null } + ) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-queued-bookkeeping-')) + clock = 1_000 +}) + +afterEach(async () => { + vi.restoreAllMocks() + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe('draft bookkeeping inside a journal append', () => { + it('a throwing draft transition still commits the rejection row, and the next open recovers the draft', async () => { + let journal = await open() + await queueAndConsume(journal, 'draft-1') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + vi.spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction').mockImplementationOnce(() => { + throw new Error('table queued_messages has no column named returned_rejection') + }) + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...REFUSAL, + fence: 0 + }) + // The journal's own answer stands: Stop, failed starts and refusals depend on it. + expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected') + expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') + expect(warn).toHaveBeenCalledWith( + '[journal-append] row bookkeeping skipped:', + expect.objectContaining({ kind: 'dispatch' }) + ) + await journal.close() + journal = await open() + expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected') + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'returned', + returnedReason: REFUSAL.reason, + returnedRejection: { kind: 'providerRejected' } + }) + }) + + it('an older draft table without a later column is healed at open, so a refusal returns the card', async () => { + const first = await open() + await first.close() + const db = new Database(journalDatabaseFile(root)) + db.exec('DROP TABLE queued_messages') + // The shape an earlier build of the draft table wrote: no returned_rejection. + db.exec(`CREATE TABLE queued_messages ( + session_id TEXT NOT NULL, message_id TEXT NOT NULL, position INTEGER NOT NULL, + body_json TEXT NOT NULL, fingerprint TEXT NOT NULL, created_at INTEGER NOT NULL, + host_instance TEXT NOT NULL, state TEXT NOT NULL, hold_reason TEXT, + returned_reason TEXT, settled_at INTEGER, settled_by_op TEXT, consumed_as TEXT, + PRIMARY KEY (session_id, message_id))`) + db.close() + const journal = await open() + expect(journal.isReadOnly).toBe(false) + await queueAndConsume(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...REFUSAL, + fence: 0 + }) + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'returned', + returnedRejection: { kind: 'providerRejected' } + }) + }) + + describe('a failed COMMIT', () => { + /** The append's own COMMIT fails once, after its hooks ran. */ + function failNextCommit() { + const exec = Database.prototype.exec + let armed = true + return vi.spyOn(Database.prototype, 'exec').mockImplementation(function ( + this: Database, + sql: string + ) { + if (armed && sql === 'COMMIT') { + armed = false + throw new Error('SQLITE_FULL') + } + return exec.call(this, sql) + }) + } + + async function consumeFailingCommit(journal: AgentSessionJournal): Promise { + await journal.queuedMessages.insert({ + messageId: 'draft-1', + body: BODY, + fingerprint: 'fp-draft-1', + hostInstance: 'proc-1' + }) + expect(journal.queuedMessages.list()).toMatchObject([{ state: 'waiting' }]) + const commit = failNextCommit() + try { + await expect( + journal.appendSubmission( + { clientMessageId: 'sub-draft-1', payloadFingerprint: 'fp', body: BODY, fence: 0 }, + { messageId: 'draft-1', expect: 'waiting', settledByOp: null } + ) + ).rejects.toThrow('SQLITE_FULL') + } finally { + commit.mockRestore() + } + } + + it('leaves no uncommitted draft state cached: the per-row hook reads drafts only for an echo', async () => { + const journal = await open() + const list = vi.spyOn(JournalQueuedMessages.prototype, 'list') + await consumeFailingCommit(journal) + // Once by the test itself before the append; never inside it. + expect(list).toHaveBeenCalledTimes(1) + list.mockRestore() + expect(journal.submissions()).toHaveLength(0) + expect(journal.queuedMessages.list()).toMatchObject([ + { messageId: 'draft-1', state: 'waiting' } + ]) + }) + + it('invalidates what any read inside the rolled-back transaction cached', async () => { + const journal = await open() + // Some other bookkeeping reads the list inside the transaction, after the consume wrote. + vi.spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction').mockImplementation(function ( + this: JournalQueuedMessages + ) { + this.list() + }) + await consumeFailingCommit(journal) + expect(journal.queuedMessages.list()).toMatchObject([ + { messageId: 'draft-1', state: 'waiting' } + ]) + }) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts new file mode 100644 index 00000000000..376d3145a9d --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts @@ -0,0 +1,206 @@ +// A draft sent back to waiting after a handed-over hand-off was rejected as +// never delivered is withdrawn when the provider echoes that message: the +// first send reached the agent, so sending it again would repeat it. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { queuedMessageFingerprint } from '../agent-session-wire/structured-agent-session-queued-messages' +import { JournalQueuedMessages } from './journal-queued-messages' +import type { AgentSessionJournal } from './journal-store' +import { createTrackedJournalOpener } from './journal-store-test-open' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-q', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } +} +const STOP_WITHDRAWAL = agentSessionFailureWords(agentSessionFailureFact('cancelled'), { + surface: 'rejection' +}) + +let root: string +let clock = 1_000 +const journals = createTrackedJournalOpener() + +function message(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +function echo(journal: AgentSessionJournal, uuid: string, text: string) { + return journal.appendItem({ provider: 'claude', sessionId: 'native-1', uuid }, message(text), { + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) +} + +/** A draft consumed and handed to the agent, then rejected as never delivered (the provider + * confirmed a Stop withdrew it): back to waiting. `handedOver: false` rejects it before + * hand-over instead, which proves it was never written. */ +async function withdrawnDraft( + text: string, + options: { handedOver: boolean } = { handedOver: true } +): Promise { + const journal = await open() + await handOffAndReject(journal, text, options) + return journal +} + +function open(): Promise { + return journals.open({ + identity: IDENTITY, + journalDir: root, + now: () => ++clock, + mintEpoch: () => `epoch-${clock}` + }) +} + +async function handOffAndReject( + journal: AgentSessionJournal, + text: string, + options: { handedOver: boolean } = { handedOver: true } +): Promise { + const body = message(text) + const fingerprint = queuedMessageFingerprint(IDENTITY.sessionId, body) + await journal.queuedMessages.insert({ + messageId: 'draft-1', + body, + fingerprint, + hostInstance: 'p' + }) + await journal.appendSubmission( + { + clientMessageId: 'sub-draft-1', + payloadFingerprint: fingerprint, + body, + fence: 0, + handoverRecorded: true + }, + { messageId: 'draft-1', expect: 'waiting', settledByOp: null } + ) + if (options.handedOver) { + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'pending', + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...STOP_WITHDRAWAL, + fence: 0 + }) + } else { + await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL) + } + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'waiting', + consumedAs: null + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-queued-echo-')) + clock = 1_000 +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe("a waiting draft whose 'never delivered' claim an echo disproves", () => { + it('is withdrawn when the provider echoes the message it was withdrawn from', async () => { + const journal = await withdrawnDraft('did it land?') + await echo(journal, 'echo-1', 'did it land?') + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'withdrawn', + settledByOp: null + }) + // The rejection stays terminal: the echo is kept apart, not folded into it. + expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected') + expect(journal.snapshot().items.map((item) => item.itemId)).toHaveLength(2) + }) + + it('stays waiting when the rejected hand-off never reached the agent: the echo is some other message', async () => { + const journal = await withdrawnDraft('did it land?', { handedOver: false }) + await echo(journal, 'echo-1', 'did it land?') + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + }) + + it('retires the pause in the per-row hook when that echo withdraws the last card it holds back', async () => { + const journal = await withdrawnDraft('did it land?') + expect(await journal.queuedMessages.recordPause('stopped')).toBe(true) + await echo(journal, 'echo-1', 'did it land?') + expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') + expect(journal.queuedMessages.pause()).toBeNull() + }) + + it('stays waiting for an echo of some other text', async () => { + const journal = await withdrawnDraft('did it land?') + await echo(journal, 'echo-1', 'something else') + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + }) + + it('stays waiting when a live send of the same text claims the echo', async () => { + const journal = await withdrawnDraft('did it land?') + const body = message('did it land?') + await journal.appendSubmission({ + clientMessageId: 'typed-again', + payloadFingerprint: queuedMessageFingerprint(IDENTITY.sessionId, body), + body, + fence: 0, + handoverRecorded: true + }) + await echo(journal, 'echo-1', 'did it land?') + expect(journal.submission('typed-again')?.dispatchState).toBe('accepted') + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + }) + + describe('when the per-row hook was skipped', () => { + it('the re-derivation withdraws it from the echo already in the journal, and at reopen', async () => { + let journal = await withdrawnDraft('did it land?') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const hook = vi + .spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction') + .mockImplementationOnce(() => { + throw new Error('bookkeeping failed') + }) + try { + await echo(journal, 'echo-1', 'did it land?') + } finally { + hook.mockRestore() + warn.mockRestore() + } + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + expect(journal.queuedMessages.deliveredByEchoOwed()).toBe(true) + // The drain's heal, before the draft could send again. + await journal.queuedMessages.settleOwed() + expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') + expect(journal.queuedMessages.deliveredByEchoOwed()).toBe(false) + await journal.close() + journal = await open() + expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') + }) + + it('an unclaimed echo from before the hand-off proves nothing about it', async () => { + const journal = await open() + await echo(journal, 'typed-in-the-agent', 'did it land?') + await handOffAndReject(journal, 'did it land?') + expect(journal.queuedMessages.deliveredByEchoOwed()).toBe(false) + await journal.queuedMessages.settleOwed() + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + }) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.ts b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.ts new file mode 100644 index 00000000000..f111b49037f --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.ts @@ -0,0 +1,128 @@ +// A draft sent back to waiting rests on its submission's "never delivered" +// claim. The provider echoing that message proves the claim wrong: the first +// delivery happened. The reducer keeps such an echo apart (a rejected +// submission may not claim it), so it is read here, from the row itself. + +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { + isProviderUserMessageEcho, + journalEchoClaimant, + type JournalReducerState +} from './journal-reducer' +import type { JournalRow } from './journal-row-schema' +import type { QueuedMessageRow } from './queued-message-table' + +/** The waiting draft this appended row proves was delivered, or null. Called + * before the row applies, for every row, so a row that is no new provider + * echo of a user message returns before anything else is read. */ +export function draftDeliveredByEcho( + state: JournalReducerState, + drafts: () => readonly QueuedMessageRow[], + row: JournalRow +): string | null { + const echoes = appendedItems(row).filter( + (item) => + isProviderUserMessageEcho(item.itemId, item.body) && + !state.items.has(item.itemId) && + !state.aliases.has(item.itemId) && + journalEchoClaimant(state, item.itemId, item.body) === null + ) + if (echoes.length === 0) { + return null + } + const spent = spentWaitingDrafts(state, drafts()) + for (const item of echoes) { + const delivered = spent.find((draft) => echoProvesDelivered(state, draft, item.body, row.seq)) + if (delivered) { + return delivered.draft.messageId + } + } + return null +} + +/** + * The re-derivation behind that per-row hook, which is bookkeeping and may be + * skipped: waiting drafts an echo ALREADY in the journal proves delivered. An + * unclaimed echo is the item still stored under its provider id — a claimed one + * was folded into its submission's item. Reads every item, so callers run it + * only where a draft is about to send, never per streamed row. + */ +export function draftsDeliveredByAppliedEcho( + state: JournalReducerState, + drafts: readonly QueuedMessageRow[] +): string[] { + const spent = spentWaitingDrafts(state, drafts) + if (spent.length === 0) { + return [] + } + const delivered = new Set() + for (const item of state.items.values()) { + if (!isProviderUserMessageEcho(item.itemId, item.body)) { + continue + } + for (const candidate of spent) { + if (echoProvesDelivered(state, candidate, item.body, item.sequence)) { + delivered.add(candidate.draft.messageId) + } + } + } + return [...delivered] +} + +type SpentDraft = { draft: QueuedMessageRow; since: number } + +/** Waiting drafts some hand-off of which was handed over, then rejected as never delivered; + * `since` is the earliest such hand-off's row. A hand-off rejected before hand-over is + * provably unwritten: an echo matching it is some other message, and must not delete the card. */ +function spentWaitingDrafts( + state: JournalReducerState, + drafts: readonly QueuedMessageRow[] +): SpentDraft[] { + const since = new Map() + for (const submission of state.submissions.values()) { + if ( + submission.queuedMessageId !== undefined && + submission.dispatchState === 'rejected' && + submission.handedOverAt !== undefined + ) { + const sequence = submission.acceptedSequence ?? 0 + const earliest = since.get(submission.queuedMessageId) + since.set(submission.queuedMessageId, Math.min(earliest ?? sequence, sequence)) + } + } + return drafts.flatMap((draft) => { + const from = since.get(draft.messageId) + return draft.state === 'waiting' && from !== undefined ? [{ draft, since: from }] : [] + }) +} + +/** The one predicate both paths share: an unclaimed echo appended after a disproved hand-off, + * carrying the draft's own payload. */ +function echoProvesDelivered( + state: JournalReducerState, + spent: SpentDraft, + body: AgentJournalItemBody, + sequence: number +): boolean { + return ( + sequence > spent.since && + structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: state.sessionId, + fields: { body } + }) === spent.draft.fingerprint + ) +} + +function appendedItems(row: JournalRow): { itemId: string; body: AgentJournalItemBody }[] { + if (row.kind === 'item') { + return [{ itemId: row.itemId, body: row.body }] + } + if (row.kind === 'lifecycle-batch') { + return row.mutations.flatMap((mutation) => + mutation.kind === 'item' ? [{ itemId: mutation.itemId, body: mutation.body }] : [] + ) + } + return [] +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-holds.ts b/src/main/native-chat/agent-session-journal/queued-message-holds.ts new file mode 100644 index 00000000000..665466d60e7 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-holds.ts @@ -0,0 +1,49 @@ +// Per-draft holds: what keeps one card from auto-sending, stored on its row. A +// Stop or a restart pauses the whole queue instead (`queued-message-pause-table.ts`, +// and the host instance each row records); a per-draft hold is only a +// conversion that failed, which an explicit Send releases. + +import type Database from '../../sqlite/sync-database' +import type { QueuedMessageHoldReason } from './queued-message-table' + +/** Hold waiting drafts from auto-sending. The hold retires with the row: consume + * and withdraw clear it in their own UPDATE. Returns how many rows it newly reached. */ +export function holdQueuedMessages( + db: Database.Database, + input: { + sessionId: string + messageIds: readonly string[] + reason: QueuedMessageHoldReason + } +): number { + const update = db.prepare( + `UPDATE queued_messages SET hold_reason = ? + WHERE session_id = ? AND message_id = ? AND state = 'waiting' + AND (hold_reason IS NULL OR hold_reason <> ?)` + ) + let held = 0 + for (const messageId of input.messageIds) { + held += Number(update.run(input.reason, input.sessionId, messageId, input.reason).changes ?? 0) + } + return held +} + +/** Ends a restart's pause: waiting rows another host instance wrote are adopted + * into this one, the same fact the pause is derived from, so no second copy + * exists. Also clears a per-row 'stopped' hold an earlier build of the queue + * wrote, which this build only ever lifts. Returns how many rows it changed. */ +export function adoptQueuedMessages( + db: Database.Database, + input: { sessionId: string; hostInstance: string } +): number { + return Number( + db + .prepare( + `UPDATE queued_messages + SET host_instance = ?, hold_reason = CASE WHEN hold_reason = 'stopped' THEN NULL ELSE hold_reason END + WHERE session_id = ? AND state = 'waiting' + AND (host_instance <> ? OR hold_reason = 'stopped')` + ) + .run(input.hostInstance, input.sessionId, input.hostInstance).changes ?? 0 + ) +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause-table.ts b/src/main/native-chat/agent-session-journal/queued-message-pause-table.ts new file mode 100644 index 00000000000..41387165de0 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-pause-table.ts @@ -0,0 +1,161 @@ +// The queue-level pause fact: where in the journal the user's last Stop (or a +// /clear, which starts its replacement paused) took effect. The pause itself is never stored — it is derived from this fact and +// the journal rows after it (a user-requested turn that started ends it); the +// fact only records the one event the journal's closed row kinds cannot carry. +// An explicit Resume retires it. + +import type Database from '../../sqlite/sync-database' + +export type QueuePauseReason = 'stopped' | 'cleared' + +export type QueuePauseFact = { + reason: QueuePauseReason + /** The journal position the Stop took effect at: rows after it are later. */ + epoch: string + sequence: number + recordedAt: number +} + +export function readQueuePause(db: Database.Database, sessionId: string): QueuePauseFact | null { + const row: unknown = db + .prepare( + 'SELECT reason, epoch, sequence, recorded_at FROM queued_message_pauses WHERE session_id = ?' + ) + .get(sessionId) + if ( + typeof row !== 'object' || + row === null || + !('reason' in row) || + (row.reason !== 'stopped' && row.reason !== 'cleared') || + !('epoch' in row) || + typeof row.epoch !== 'string' || + !('sequence' in row) || + typeof row.sequence !== 'number' || + !('recorded_at' in row) || + typeof row.recorded_at !== 'number' + ) { + // A reason this build cannot place reads as no pause rather than a wrong one. + return null + } + return { + reason: row.reason, + epoch: row.epoch, + sequence: row.sequence, + recordedAt: row.recorded_at + } +} + +/** The latest Stop replaces an earlier one: only the last interruption decides. */ +export function recordQueuePause( + db: Database.Database, + input: { sessionId: string; fact: QueuePauseFact } +): void { + db.prepare( + `INSERT INTO queued_message_pauses (session_id, reason, epoch, sequence, recorded_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (session_id) DO UPDATE SET + reason = excluded.reason, epoch = excluded.epoch, + sequence = excluded.sequence, recorded_at = excluded.recorded_at` + ).run( + input.sessionId, + input.fact.reason, + input.fact.epoch, + input.fact.sequence, + input.fact.recordedAt + ) +} + +/** Compare-and-clear: only the fact the caller judged, so a Stop recorded since stands. */ +export function clearQueuePause( + db: Database.Database, + input: { sessionId: string; fact: Pick } +): number { + return Number( + db + .prepare( + 'DELETE FROM queued_message_pauses WHERE session_id = ? AND epoch = ? AND sequence = ?' + ) + .run(input.sessionId, input.fact.epoch, input.fact.sequence).changes ?? 0 + ) +} + +type QueueCardState = { state: string; holdReason: string | null } + +/** A card a pause holds back: waiting, with no hold of its own, wherever it sits. + * While one exists — or a hand-off still owed a return to waiting + * (`queuePauseHoldsBack`) — the pause is KEPT: a Stop records it, and it is + * retired only once none remains, so deleting a returned card that blocks such + * cards leaves them paused rather than sending them unasked. */ +export function isPausableQueuedMessage(row: QueueCardState): boolean { + return row.state === 'waiting' && row.holdReason === null +} + +/** Whether Resume would send anything: a pausable card not behind a returned one, + * which blocks everything after it until the user acts, exactly as the drain + * reads it. Only then is the kept pause PUBLISHED, so its header never offers a + * Resume that sends nothing. */ +export function hasResumableQueuedMessage(rows: readonly QueueCardState[]): boolean { + for (const row of rows) { + if (row.state === 'returned') { + return false + } + if (isPausableQueuedMessage(row)) { + return true + } + } + return false +} + +/** What a queue pause holds back, judged inside the caller's transaction: a waiting + * card with no hold of its own (`isPausableQueuedMessage`, in SQL), or a dispatched + * one whose settlement back to waiting is still owed — its hook was skipped, so the + * row has not caught up with its rejected submission, which only the journal's + * submissions can tell (`owedToWaiting`). */ +export function queuePauseHoldsBack( + db: Database.Database, + input: { sessionId: string; owedToWaiting: (consumedRef: string) => boolean } +): boolean { + const pausable = db + .prepare( + `SELECT 1 FROM queued_messages + WHERE session_id = ? AND state = 'waiting' AND hold_reason IS NULL LIMIT 1` + ) + .get(input.sessionId) + if (pausable !== undefined) { + return true + } + return db + .prepare( + `SELECT consumed_as FROM queued_messages + WHERE session_id = ? AND state = 'dispatched' AND consumed_as IS NOT NULL` + ) + .all(input.sessionId) + .some( + (row) => + typeof row === 'object' && + row !== null && + 'consumed_as' in row && + typeof row.consumed_as === 'string' && + input.owedToWaiting(row.consumed_as) + ) +} + +/** A pause is over the cards it paused: once it holds back none (`queuePauseHoldsBack`), + * the fact goes too, in the same transaction as the write that took the last one, so + * it can never outlive them and catch a card typed long after. */ +export function retireQueuePauseIfNothingHeld( + db: Database.Database, + input: { sessionId: string; owedToWaiting: (consumedRef: string) => boolean } +): number { + // Runs on every appended journal row: with no pause recorded there is nothing to judge. + const recorded = db + .prepare('SELECT 1 FROM queued_message_pauses WHERE session_id = ?') + .get(input.sessionId) + if (recorded === undefined || queuePauseHoldsBack(db, input)) { + return 0 + } + return Number( + db.prepare('DELETE FROM queued_message_pauses WHERE session_id = ?').run(input.sessionId) + .changes ?? 0 + ) +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-retention.ts b/src/main/native-chat/agent-session-journal/queued-message-retention.ts new file mode 100644 index 00000000000..cf18a1e5006 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-retention.ts @@ -0,0 +1,74 @@ +// Retention for the draft table: which settled rows may be deleted, and when. A row is kept for +// as long as anything could still read it — a replayed operation, or a late refusal returning it. + +import type Database from '../../sqlite/sync-database' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { listQueuedMessages } from './queued-message-table' + +/** What the loaded journal says about a dispatched draft's consumed submission. */ +export type QueuedMessageSubmissionVerdict = + /** Still owed an answer — a crash leftover the delivery loop will reject; keep the row. */ + | 'pending' + /** `accepted` or `unknown`: terminal and not refused. */ + | 'terminal-not-refused' + /** Absent from the current epoch. */ + | 'absent' + /** Effectively rejected; the open-time repair settles it rather than pruning. */ + | 'rejected' + +/** + * Retention: `withdrawn` tombstones live for the operation-replay window; + * a `dispatched` row only once its consumed submission is terminal-and-not- + * refused or absent AND the window has passed — never while pending, so a slow + * refusal can still return it. `waiting` and `returned` rows are never pruned. + */ +export function pruneQueuedMessages( + db: Database.Database, + input: { + sessionId: string + now: number + replayWindowMs: number + submissionVerdict: (consumedRef: string) => QueuedMessageSubmissionVerdict + } +): number { + const cutoff = input.now - input.replayWindowMs + const tombstones = db + .prepare( + `DELETE FROM queued_messages + WHERE session_id = ? AND state = 'withdrawn' AND settled_at IS NOT NULL AND settled_at < ?` + ) + .run(input.sessionId, cutoff) + let pruned = Number(tombstones.changes ?? 0) + for (const row of listQueuedMessages(db, input.sessionId)) { + if (row.state !== 'dispatched' || row.settledAt === null || row.settledAt >= cutoff) { + continue + } + // A dispatched row always names its hand-off; one that does not has nothing to wait for. + const verdict = row.consumedAs === null ? 'absent' : input.submissionVerdict(row.consumedAs) + if (verdict === 'terminal-not-refused' || verdict === 'absent') { + db.prepare('DELETE FROM queued_messages WHERE session_id = ? AND message_id = ?').run( + input.sessionId, + row.messageId + ) + pruned += 1 + } + } + return pruned +} + +/** How retention reads a consumed submission from the loaded journal. Run after the + * owed settlements, which already settled every rejected row. */ +export function retainedSubmissionVerdict( + submissions: ReadonlyMap +): (consumedRef: string) => QueuedMessageSubmissionVerdict { + return (consumedRef) => { + const submission = submissions.get(consumedRef) + if (!submission) { + return 'absent' + } + if (submission.dispatchState === 'accepted' || submission.dispatchState === 'unknown') { + return 'terminal-not-refused' + } + return submission.dispatchState === 'rejected' ? 'rejected' : 'pending' + } +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-schema.ts b/src/main/native-chat/agent-session-journal/queued-message-schema.ts new file mode 100644 index 00000000000..31806e6b71a --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-schema.ts @@ -0,0 +1,72 @@ +// The draft table's shape, created and healed at every writable open. + +import type Database from '../../sqlite/sync-database' + +/** Columns a later build added, so an older draft table can gain them in place. */ +const NULLABLE_COLUMNS: readonly (readonly [name: string, type: string])[] = [ + ['hold_reason', 'TEXT'], + ['returned_reason', 'TEXT'], + ['returned_rejection', 'TEXT'], + ['settled_at', 'INTEGER'], + ['settled_by_op', 'TEXT'], + ['consumed_as', 'TEXT'] +] + +/** + * Created idempotently at EVERY writable open, never lazily at first insert, so + * no reader hits "no such table". Deliberately no `user_version` bump: an old + * build sees stored == supported and stays writable, ignoring the table; a bump + * would latch every opened db read-only after a downgrade (`journal-database.ts`). + * For the same reason a missing column is added here rather than versioned: + * `CREATE TABLE IF NOT EXISTS` never reshapes a table an earlier build created. + */ +export function ensureQueuedMessagesTable(db: Database.Database): void { + db.exec(` +CREATE TABLE IF NOT EXISTS queued_messages ( + session_id TEXT NOT NULL, + message_id TEXT NOT NULL, + position INTEGER NOT NULL, + body_json TEXT NOT NULL, + fingerprint TEXT NOT NULL, + created_at INTEGER NOT NULL, + host_instance TEXT NOT NULL, + state TEXT NOT NULL, + hold_reason TEXT, + returned_reason TEXT, + returned_rejection TEXT, + settled_at INTEGER, + settled_by_op TEXT, + consumed_as TEXT, + PRIMARY KEY (session_id, message_id) +); +`) + const names = new Set( + db + .prepare('PRAGMA table_info(queued_messages)') + .all() + .flatMap((column) => + typeof column === 'object' && + column !== null && + 'name' in column && + typeof column.name === 'string' + ? [column.name] + : [] + ) + ) + for (const [name, type] of NULLABLE_COLUMNS) { + if (!names.has(name)) { + db.exec(`ALTER TABLE queued_messages ADD COLUMN ${name} ${type}`) + } + } + db.exec(` +CREATE UNIQUE INDEX IF NOT EXISTS queued_messages_consumed_as + ON queued_messages (session_id, consumed_as) WHERE consumed_as IS NOT NULL; +CREATE TABLE IF NOT EXISTS queued_message_pauses ( + session_id TEXT PRIMARY KEY, + reason TEXT NOT NULL, + epoch TEXT NOT NULL, + sequence INTEGER NOT NULL, + recorded_at INTEGER NOT NULL +); +`) +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts new file mode 100644 index 00000000000..75e62256780 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts @@ -0,0 +1,142 @@ +// What a journal row does to the drafts, and the re-derivation behind it. The +// live hook runs inside each append's transaction; it is bookkeeping and may be +// skipped, so a dispatched draft whose current submission the journal already +// rejected is owed the settlement it would have applied, which the open-time +// repair and the drain both apply. + +import type Database from '../../sqlite/sync-database' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { + consumedSubmissionWasRejected, + journalDispatchRowNewlyRejects, + rejectedDraftSettlement +} from './journal-dispatch-settlement' +import type { JournalReducerState } from './journal-reducer' +import type { JournalRow } from './journal-row-schema' +import { draftDeliveredByEcho, draftsDeliveredByAppliedEcho } from './queued-message-delivered-echo' +import { + listQueuedMessages, + settleRejectedQueuedMessage, + withdrawQueuedMessages, + type QueuedMessageRow +} from './queued-message-table' + +type Submissions = ReadonlyMap + +/** Some dispatched draft still waits on a settlement the journal already decided. */ +export function queuedMessageSettlementOwed( + rows: readonly QueuedMessageRow[], + submissions: Submissions +): boolean { + return rows.some( + (row) => + row.state === 'dispatched' && + row.consumedAs !== null && + consumedSubmissionWasRejected(submissions.get(row.consumedAs)) + ) +} + +/** A dispatched draft's consumed submission settled so that the draft is owed a + * return to waiting (a withdrawal, not a refusal): what a queue pause must still + * count as a card it holds back while that settlement is owed. */ +export function owedBackToWaiting(submissions: Submissions): (consumedRef: string) => boolean { + return (consumedRef) => { + const submission = submissions.get(consumedRef) + return ( + submission !== undefined && + consumedSubmissionWasRejected(submission) && + rejectedDraftSettlement(submission).state === 'waiting' + ) + } +} + +/** Applies each owed settlement, and withdraws each waiting draft an applied echo proves + * delivered (`draftsDeliveredByAppliedEcho`); returns how many drafts changed. */ +export function settleOwedQueuedMessages( + db: Database.Database, + input: { sessionId: string; state: JournalReducerState; now: number } +): number { + const { submissions } = input.state + let settled = 0 + for (const row of listQueuedMessages(db, input.sessionId)) { + const consumedRef = row.consumedAs + const submission = consumedRef === null ? undefined : submissions.get(consumedRef) + if ( + row.state !== 'dispatched' || + consumedRef === null || + !consumedSubmissionWasRejected(submission) + ) { + continue + } + const changed = settleRejectedQueuedMessage(db, { + sessionId: input.sessionId, + consumedRef, + reason: submission?.reason ?? null, + rejection: submission?.rejection, + now: input.now + }) + settled += changed ? 1 : 0 + } + const delivered = draftsDeliveredByAppliedEcho( + input.state, + listQueuedMessages(db, input.sessionId) + ) + if (delivered.length > 0) { + settled += withdrawQueuedMessages(db, { + sessionId: input.sessionId, + messageIds: delivered, + settledByOp: null, + now: input.now + }).length + } + return settled +} + +/** + * The live hook, before `row` applies: an echo proving a waiting draft's first + * send was delivered withdraws it; a row that NEWLY settles a dispatched + * draft's current submission to `rejected` settles the draft — a refusal + * returns it, a withdrawal (a Stop, a restart) sends it back to waiting. + * Decided by the same function the reducer folds rows through, so a row the + * journal's settlement rules ignore never alters a draft. Returns how many + * drafts changed. + */ +export function settleQueuedMessagesForRow( + db: Database.Database, + input: { + sessionId: string + state: JournalReducerState + /** Read only once the row holds an unclaimed echo: a list read inside the append's + * transaction must not be cached under state a rollback could undo. */ + drafts: () => readonly QueuedMessageRow[] + row: JournalRow + now: number + } +): number { + const { row } = input + let changed = 0 + const delivered = draftDeliveredByEcho(input.state, input.drafts, row) + if (delivered !== null) { + // Its first send reached the agent after all; sending it again would repeat it. + changed += withdrawQueuedMessages(db, { + sessionId: input.sessionId, + messageIds: [delivered], + settledByOp: null, + now: input.now + }).length + } + if (row.kind !== 'dispatch' || row.state !== 'rejected') { + return changed + } + if (!journalDispatchRowNewlyRejects(input.state.submissions.get(row.clientMessageId), row)) { + return changed + } + const settled = settleRejectedQueuedMessage(db, { + sessionId: input.sessionId, + consumedRef: row.clientMessageId, + reason: row.reason, + rejection: row.rejection, + now: input.now + }) + return changed + (settled ? 1 : 0) +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts new file mode 100644 index 00000000000..4f9b6f81e03 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts @@ -0,0 +1,835 @@ +// The draft store's contract: exactly-once consume in one transaction, the +// rejected-draft settlement following the journal's EFFECTIVE settlement, retention +// that never outruns a slow refusal, and rows that survive epoch replacement. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import Database from '../../sqlite/sync-database' +import { journalDatabaseFile } from './journal-paths' +import { + JournalQueuedMessages, + QUEUED_MESSAGE_REPLAY_WINDOW_MS, + QueuedMessageNotConsumableError +} from './journal-queued-messages' +import type { AgentSessionJournal } from './journal-store' +import { createTrackedJournalOpener } from './journal-store-test-open' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-q', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } +} + +let root: string +let clock = 1_000 + +function tick(): number { + clock += 1 + return clock +} + +function message(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +const journals = createTrackedJournalOpener() +const STOP_WITHDRAWAL = agentSessionFailureWords(agentSessionFailureFact('cancelled'), { + surface: 'rejection' +}) +const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { + surface: 'rejection' +}) +const PROVIDER_REFUSAL = agentSessionFailureFact('providerRejected', { + detail: { text: 'Claude refused this payload', audience: 'person' } +}) +/** A provider refusal as a settled rejection stores it: its sentence and typed fact. */ +function refusal(text: string) { + return agentSessionFailureWords( + agentSessionFailureFact('providerRejected', { detail: { text, audience: 'person' } }), + { surface: 'rejection' } + ) +} + +async function open(): Promise { + const journal = await journals.open({ + identity: IDENTITY, + journalDir: root, + now: tick, + mintEpoch: () => `epoch-${clock}` + }) + return journal +} + +async function queueDraft(journal: AgentSessionJournal, messageId: string, text = 'queued text') { + return journal.queuedMessages.insert({ + messageId, + body: message(text), + fingerprint: `fp-${messageId}`, + hostInstance: 'proc-1' + }) +} + +async function consumeDraft( + journal: AgentSessionJournal, + messageId: string, + options: { as?: string; expect?: 'waiting' | 'returned'; settledByOp?: string | null } = {} +) { + const draft = journal.queuedMessages.get(messageId) + await journal.appendSubmission( + { + clientMessageId: options.as ?? `sub-${messageId}`, + payloadFingerprint: draft?.fingerprint ?? `fp-${messageId}`, + body: draft?.body ?? message('queued text'), + fence: 0, + handoverRecorded: true + }, + { + messageId, + expect: options.expect ?? 'waiting', + settledByOp: options.settledByOp ?? null + } + ) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-queued-message-')) + clock = 1_000 +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe('draft rows', () => { + it('creates the table at open without bumping user_version, so an old build stays writable', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await journal.close() + const db = new Database(journalDatabaseFile(root), { readonly: true }) + try { + const version = Number(db.pragma('user_version', { simple: true })) + // An old build compares stored == supported and keeps writing; a bump + // would latch it read-only after downgrade. + expect(version).toBe(2) + const table = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") + .get('queued_messages') + expect(table).toBeDefined() + } finally { + db.close() + } + }) + + it('opens a database an older build shaped (no drafts table) and creates the table', async () => { + const first = await open() + await first.appendItem( + { provider: 'orca', clientMessageId: 'seed' }, + { kind: 'status', text: 'seed' }, + { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await first.close() + const db = new Database(journalDatabaseFile(root)) + db.exec('DROP TABLE queued_messages') + db.close() + const journal = await open() + expect(journal.isReadOnly).toBe(false) + const row = await queueDraft(journal, 'draft-1') + expect(row.position).toBe(1) + }) + + it('assigns monotonic positions and lists in order', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await queueDraft(journal, 'draft-2') + const listed = journal.queuedMessages.list() + expect(listed.map((row) => [row.messageId, row.position, row.state])).toEqual([ + ['draft-1', 1, 'waiting'], + ['draft-2', 2, 'waiting'] + ]) + }) + + it('replays an insert under an already-used id instead of duplicating', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + const again = await queueDraft(journal, 'draft-1') + expect(again.position).toBe(1) + expect(journal.queuedMessages.list()).toHaveLength(1) + }) + + it('drafts survive epoch replacement, which deletes only journal rows', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await journal.replaceEpochItems('handle_forked', 0, []) + expect(journal.queuedMessages.list().map((row) => row.messageId)).toEqual(['draft-1']) + }) +}) + +describe('consume', () => { + it('converts waiting → dispatched and appends the submission in one transaction', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + const row = journal.queuedMessages.get('draft-1') + expect(row?.state).toBe('dispatched') + expect(row?.consumedAs).toBe('sub-draft-1') + expect(journal.submissions().map((entry) => entry.clientMessageId)).toEqual(['sub-draft-1']) + }) + + it('never hands a draft off under its own id: the submission names it by link, not id equality', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await expect(consumeDraft(journal, 'draft-1', { as: 'draft-1' })).rejects.toBeInstanceOf( + QueuedMessageNotConsumableError + ) + expect(journal.submissions()).toHaveLength(0) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + }) + + it('never records a second submission under an id it already holds, whatever state it settled in', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('refused'), + fence: 0 + }) + const cursor = journal.cursor() + await expect( + journal.appendSubmission({ + clientMessageId: 'sub-draft-1', + payloadFingerprint: 'fp-draft-1', + body: message('queued text'), + fence: 0, + handoverRecorded: true + }) + ).rejects.toMatchObject({ code: 'journal_submission_exists' }) + // The refusal stands: re-appending would reset it to pending and hand it over again. + expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected') + expect(journal.cursor()).toEqual(cursor) + }) + + it('a second consume of the same draft fails and appends nothing (exactly-once)', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await expect(consumeDraft(journal, 'draft-1', { as: 'second-id' })).rejects.toBeInstanceOf( + QueuedMessageNotConsumableError + ) + expect(journal.submissions().map((entry) => entry.clientMessageId)).toEqual(['sub-draft-1']) + }) + + it('a consume racing a withdraw loses and appends nothing', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await journal.queuedMessages.withdraw({ + messageIds: ['draft-1'], + settledByOp: 'caller\u0000op-1' + }) + await expect(consumeDraft(journal, 'draft-1')).rejects.toBeInstanceOf( + QueuedMessageNotConsumableError + ) + expect(journal.submissions()).toHaveLength(0) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') + }) + + it('a failed submission insert rolls the draft transition back', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + const cursor = journal.cursor() + // Occupy the next sequence directly so the append's INSERT violates the + // primary key inside the transaction, after the draft was transitioned. + const db = new Database(journalDatabaseFile(root)) + db.prepare( + 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' + ).run(IDENTITY.sessionId, cursor.epoch, cursor.sequence + 1, tick(), '{}') + db.close() + await expect(consumeDraft(journal, 'draft-1')).rejects.toThrow() + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + }) +}) + +describe('returned transition (D1/N4)', () => { + it('a non-withdrawn rejection returns the consumed draft with its reason', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('Claude refused this payload'), + rejection: PROVIDER_REFUSAL, + fence: 0 + }) + const row = journal.queuedMessages.get('draft-1') + expect(row?.state).toBe('returned') + expect(row?.returnedReason).toBe(refusal('Claude refused this payload').reason) + expect(row?.returnedRejection).toEqual(PROVIDER_REFUSAL) + }) + + it('a late rejection row after acceptance settles nothing and returns no card', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'accepted', + providerIdentity: { provider: 'claude', sessionId: 'native-1', uuid: 'echo-1' }, + fence: 0 + }) + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('late duplicate'), + fence: 0 + }) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') + }) + + it("a Stop's withdrawal before the agent received it sends the draft back to waiting, held like the rest, and it survives a restart", async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + // The Stop's own withdrawal path: the queued (not handed over) submission. + expect(await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL)).toEqual(['sub-draft-1']) + // Nothing failed: no refusal to show, its position kept, its spent id recorded. + const requeued = { + state: 'waiting', + position: 1, + holdReason: null, + consumedAs: null, + returnedReason: null, + returnedRejection: null + } + expect(journal.queuedMessages.get('draft-1')).toMatchObject(requeued) + // Atomic with the rejection row: a crash before the Stop answered keeps the text. + await journal.close() + clock += QUEUED_MESSAGE_REPLAY_WINDOW_MS + 1_000 + journal = await open() + expect(journal.queuedMessages.get('draft-1')).toMatchObject(requeued) + }) + + it('a draft sent back to waiting hands off again under a fresh id, never a spent one', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL) + // The spent id already names a rejected submission: one id, one delivery. + await expect(consumeDraft(journal, 'draft-1')).rejects.toMatchObject({ + code: 'journal_submission_exists' + }) + expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected') + await consumeDraft(journal, 'draft-1', { as: 'fresh-1' }) + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'dispatched', + consumedAs: 'fresh-1' + }) + // Both hand-offs name the draft. + expect(journal.submission('fresh-1')).toMatchObject({ + dispatchState: 'pending', + queuedMessageId: 'draft-1' + }) + expect(journal.submission('sub-draft-1')?.queuedMessageId).toBe('draft-1') + }) + + it("a restart between consume and handover sends the draft back to waiting under the restart's pause", async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.close() + journal = await open() + expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') + await journal.rejectQueuedSubmissions(0, HOST_RESTARTED, (submission) => + journal.wroteBeforeOpen(submission.acceptedSequence) + ) + // No stored hold: the restart's pause derives from the row's host instance. + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'waiting', + holdReason: null, + hostInstance: 'proc-1', + consumedAs: null, + returnedReason: null + }) + }) + + it('refuse → Send under a fresh id → refuse again returns the card again; a late duplicate of the first refusal never touches the re-send (N4)', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('first refusal'), + rejection: PROVIDER_REFUSAL, + fence: 0 + }) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('returned') + // Send on the returned card re-consumes under a fresh submission id. + await consumeDraft(journal, 'draft-1', { as: 'resend-1', expect: 'returned' }) + // The earlier refusal retires with the card: the row now describes the re-send. + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'dispatched', + consumedAs: 'resend-1', + returnedReason: null, + returnedRejection: null + }) + // A duplicate resolution of the FIRST submission is ignored by the journal + // and must not alter the draft's current relation. + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('duplicate of first refusal'), + fence: 0 + }) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') + // The re-send's own refusal returns the card, matched via consumed_as. + await journal.resolveDispatch({ + clientMessageId: 'resend-1', + state: 'rejected', + ...refusal('second refusal'), + fence: 0 + }) + const returned = journal.queuedMessages.get('draft-1') + expect(returned?.state).toBe('returned') + expect(returned?.returnedReason).toBe(refusal('second refusal').reason) + // The first refusal's fact does not outlive it: the pair is the second submission's. + expect(returned?.returnedRejection).toEqual(refusal('second refusal').rejection) + expect(returned?.returnedRejection).not.toEqual(PROVIDER_REFUSAL) + }) + + it('a rejection never revives a withdrawn draft', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('refused'), + fence: 0 + }) + await journal.queuedMessages.withdraw({ messageIds: ['draft-1'], settledByOp: 'c\u0000op' }) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('again'), + fence: 0 + }) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') + }) + + it('the returned row and its stored reason survive epoch replacement and reopen (B1)', async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('stored refusal'), + fence: 0 + }) + await journal.replaceEpochItems('handle_forked', 0, []) + await journal.close() + journal = await open() + const row = journal.queuedMessages.get('draft-1') + expect(row?.state).toBe('returned') + expect(row?.returnedReason).toBe(refusal('stored refusal').reason) + }) +}) + +describe('withdraw', () => { + it('withdraws waiting and returned rows together into op-stamped receipts', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1', 'first text') + await queueDraft(journal, 'draft-2', 'second text') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('refused'), + fence: 0 + }) + const withdrawn = await journal.queuedMessages.withdraw({ + messageIds: ['draft-1', 'draft-2'], + settledByOp: 'caller\u0000stop-1' + }) + expect(withdrawn.map((row) => [row.messageId, row.body.blocks])).toEqual([ + ['draft-1', [{ type: 'text', text: 'first text' }]], + ['draft-2', [{ type: 'text', text: 'second text' }]] + ]) + // A lost acknowledgement replays from the tombstones, keyed by the + // caller-scoped operation key — never from the ledger. + const receipts = journal.queuedMessages.receipts('caller\u0000stop-1') + expect(receipts.map((row) => row.messageId)).toEqual(['draft-1', 'draft-2']) + // Pending or dispatched rows stay outside the withdrawable set. + const second = await journal.queuedMessages.withdraw({ + messageIds: ['draft-1'], + settledByOp: 'caller\u0000stop-2' + }) + expect(second).toHaveLength(0) + }) + + it('two callers reusing one operation id read only their own receipts', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await journal.queuedMessages.withdraw({ + messageIds: ['draft-1'], + settledByOp: 'caller-a\u0000op-1' + }) + expect(journal.queuedMessages.receipts('caller-b\u0000op-1')).toHaveLength(0) + expect(journal.queuedMessages.receipts('caller-a\u0000op-1')).toHaveLength(1) + }) +}) + +describe('open-time repair and retention', () => { + it('a failed repair is reported and skipped, never failing the open', async () => { + const repair = vi + .spyOn(JournalQueuedMessages.prototype, 'repairAndPrune') + .mockRejectedValueOnce(new Error('SQLITE_FULL')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + try { + const journal = await open() + expect(warn).toHaveBeenCalledWith( + '[journal-open] queued-message repair skipped:', + expect.objectContaining({ error: 'SQLITE_FULL' }) + ) + await queueDraft(journal, 'draft-1') + expect(journal.queuedMessages.list()).toHaveLength(1) + } finally { + repair.mockRestore() + warn.mockRestore() + } + }) + + it('returns a dispatched row whose loaded submission is effectively rejected (downgrade wrote no hook)', async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('refused while downgraded'), + rejection: PROVIDER_REFUSAL, + fence: 0 + }) + await journal.close() + // Simulate the old build having written the rejection with no hook: put the + // draft back to dispatched behind the stored fact. + const db = new Database(journalDatabaseFile(root)) + db.prepare( + "UPDATE queued_messages SET state = 'dispatched', returned_reason = NULL, returned_rejection = NULL WHERE message_id = ?" + ).run('draft-1') + db.close() + journal = await open() + const row = journal.queuedMessages.get('draft-1') + expect(row?.state).toBe('returned') + expect(row?.returnedReason).toBe(refusal('refused while downgraded').reason) + expect(row?.returnedRejection).toEqual(PROVIDER_REFUSAL) + }) + + it('sends back to waiting a dispatched row whose submission a Stop withdrew with no hook, never leaving it dispatched', async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL) + await journal.close() + const db = new Database(journalDatabaseFile(root)) + db.prepare( + "UPDATE queued_messages SET state = 'dispatched', hold_reason = NULL, consumed_as = 'sub-draft-1' WHERE message_id = ?" + ).run('draft-1') + db.close() + clock += QUEUED_MESSAGE_REPLAY_WINDOW_MS + 1_000 + journal = await open() + // The same settlement the live hook applies. + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'waiting', + holdReason: null, + consumedAs: null, + returnedReason: null + }) + }) + + it('keeps a dispatched row while its submission is still pending, even past the window, so a late rejection still settles it (N5)', async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.close() + // Reopen "25 hours" later: the submission is still queued/pending. + clock += QUEUED_MESSAGE_REPLAY_WINDOW_MS + 60 * 60 * 1000 + journal = await open() + expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') + // The delivery loop's leftover rejection now sends it back to waiting. + await journal.rejectQueuedSubmissions(0, HOST_RESTARTED) + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'waiting', + consumedAs: null + }) + }) + + it('prunes accepted and withdrawn rows once the replay window passes, and never waiting or returned rows', async () => { + let journal = await open() + await queueDraft(journal, 'accepted-1') + await consumeDraft(journal, 'accepted-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-accepted-1', + state: 'accepted', + providerIdentity: { provider: 'claude', sessionId: 'native-1', uuid: 'echo-1' }, + fence: 0 + }) + await queueDraft(journal, 'withdrawn-1') + await journal.queuedMessages.withdraw({ + messageIds: ['withdrawn-1'], + settledByOp: 'c\u0000op-w' + }) + await queueDraft(journal, 'waiting-1') + await queueDraft(journal, 'returned-1') + await consumeDraft(journal, 'returned-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-returned-1', + state: 'rejected', + ...refusal('refused'), + fence: 0 + }) + await journal.close() + clock += QUEUED_MESSAGE_REPLAY_WINDOW_MS + 1_000 + journal = await open() + expect(journal.queuedMessages.list().map((row) => [row.messageId, row.state])).toEqual([ + ['waiting-1', 'waiting'], + ['returned-1', 'returned'] + ]) + }) + + it('keeps fresh tombstones inside the replay window', async () => { + let journal = await open() + await queueDraft(journal, 'withdrawn-1') + await journal.queuedMessages.withdraw({ + messageIds: ['withdrawn-1'], + settledByOp: 'c\u0000op-w' + }) + await journal.close() + clock += 1_000 + journal = await open() + expect(journal.queuedMessages.get('withdrawn-1')?.state).toBe('withdrawn') + }) +}) + +describe('holds', () => { + it('a hold is stored on the row, survives reopen, and withdraw clears it', async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' }) + expect(journal.queuedMessages.get('draft-1')?.holdReason).toBe('send_failed') + await journal.close() + journal = await open() + expect(journal.queuedMessages.get('draft-1')?.holdReason).toBe('send_failed') + await journal.queuedMessages.withdraw({ messageIds: ['draft-1'], settledByOp: 'c\u0000op' }) + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'withdrawn', + holdReason: null + }) + }) + + it('consume clears the hold in the same transaction (Send-now overrides it)', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' }) + await consumeDraft(journal, 'draft-1') + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'dispatched', + holdReason: null + }) + }) + + it('a withdraw naming no drafts touches nothing — a Delete race with no rows costs no write', async () => { + const journal = await open() + await journal.close() + await expect( + journal.queuedMessages.withdraw({ messageIds: [], settledByOp: 'c\u0000op' }) + ).resolves.toEqual([]) + }) + + it('holds reach only waiting rows', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('refused'), + fence: 0 + }) + await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' }) + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'returned', + holdReason: null + }) + }) +}) + +describe("the queue's Stop fact", () => { + it('records where the Stop took effect, survives reopen, and the latest Stop replaces an earlier one', async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await journal.queuedMessages.recordPause('stopped') + const first = journal.queuedMessages.pause() + expect(first).toMatchObject({ reason: 'stopped', sequence: journal.cursor().sequence }) + await journal.appendItem( + { provider: 'orca', clientMessageId: 'later' }, + { kind: 'status', text: 'later' }, + { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal.queuedMessages.recordPause('stopped') + expect(journal.queuedMessages.pause()?.sequence).toBe((first?.sequence ?? 0) + 1) + await journal.close() + journal = await open() + expect(journal.queuedMessages.pause()?.sequence).toBe((first?.sequence ?? 0) + 1) + // The pause is the queue's, never a row's. + expect(journal.queuedMessages.get('draft-1')?.holdReason).toBeNull() + }) + + it("a /clear's replacement records its pause as 'cleared', read back the same way", async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await journal.queuedMessages.recordPause('cleared') + await journal.close() + journal = await open() + expect(journal.queuedMessages.pause()).toMatchObject({ reason: 'cleared' }) + }) + + it('retires in the write that takes the last card it holds back: a hold of its own', async () => { + const journal = await open() + await queueDraft(journal, 'draft-held') + expect(await journal.queuedMessages.recordPause('stopped')).toBe(true) + await journal.queuedMessages.hold({ messageIds: ['draft-held'], reason: 'send_failed' }) + expect(journal.queuedMessages.pause()).toBeNull() + }) + + it('records nothing over a queue with no card it holds back, judged in its own transaction', async () => { + const journal = await open() + expect(await journal.queuedMessages.recordPause('stopped')).toBe(false) + expect(journal.queuedMessages.pause()).toBeNull() + await queueDraft(journal, 'draft-1') + expect(await journal.queuedMessages.recordPause('stopped')).toBe(true) + expect(journal.queuedMessages.pause()).not.toBeNull() + }) + + it('a hand-off whose return to waiting is still owed (its hook skipped) keeps the pause', async () => { + const journal = await open() + await queueDraft(journal, 'draft-sent') + await queueDraft(journal, 'draft-other') + await consumeDraft(journal, 'draft-sent') + expect(await journal.queuedMessages.recordPause('stopped')).toBe(true) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const hook = vi + .spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction') + .mockImplementationOnce(() => { + throw new Error('bookkeeping failed') + }) + try { + await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL) + } finally { + hook.mockRestore() + warn.mockRestore() + } + expect(journal.queuedMessages.get('draft-sent')?.state).toBe('dispatched') + // The only waiting card goes; the owed one is still a card the pause holds back. + await journal.queuedMessages.withdraw({ messageIds: ['draft-other'], settledByOp: 'c\u0000op' }) + expect(journal.queuedMessages.pause()).not.toBeNull() + await journal.queuedMessages.settleOwed() + expect(journal.queuedMessages.get('draft-sent')?.state).toBe('waiting') + expect(journal.queuedMessages.pause()).not.toBeNull() + }) + + it('lifting retires only the Stop fact it judged, never one recorded since', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await journal.queuedMessages.recordPause('stopped') + const judged = journal.queuedMessages.pause() + await journal.appendItem( + { provider: 'orca', clientMessageId: 'later' }, + { kind: 'status', text: 'later' }, + { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal.queuedMessages.recordPause('stopped') + expect(await journal.queuedMessages.liftPause({ stop: judged, adoptInto: null })).toBe(false) + expect(journal.queuedMessages.pause()).not.toBeNull() + expect( + await journal.queuedMessages.liftPause({ + stop: journal.queuedMessages.pause(), + adoptInto: null + }) + ).toBe(true) + expect(journal.queuedMessages.pause()).toBeNull() + }) + + it("adopting a restart's rows moves them into this instance and clears an older build's stored 'stopped' hold; send_failed stays", async () => { + const journal = await open() + await journal.queuedMessages.insert({ + messageId: 'draft-restart', + body: message('written before the restart'), + fingerprint: 'fp-draft-restart', + hostInstance: 'proc-0' + }) + await queueDraft(journal, 'draft-legacy') + await queueDraft(journal, 'draft-failed') + await journal.queuedMessages.hold({ messageIds: ['draft-failed'], reason: 'send_failed' }) + const db = new Database(journalDatabaseFile(root)) + db.prepare("UPDATE queued_messages SET hold_reason = 'stopped' WHERE message_id = ?").run( + 'draft-legacy' + ) + db.close() + journal.queuedMessages.invalidate() + expect(await journal.queuedMessages.liftPause({ stop: null, adoptInto: 'proc-1' })).toBe(true) + expect( + journal.queuedMessages.list().map((row) => [row.messageId, row.hostInstance, row.holdReason]) + ).toEqual([ + ['draft-restart', 'proc-1', null], + ['draft-legacy', 'proc-1', null], + ['draft-failed', 'proc-1', 'send_failed'] + ]) + }) + + it('a lift with nothing to lift changes nothing and fires no commit notification', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + const revision = journal.queuedMessages.revision() + expect(await journal.queuedMessages.liftPause({ stop: null, adoptInto: 'proc-1' })).toBe(false) + expect(journal.queuedMessages.revision()).toBe(revision) + }) +}) + +describe('the commit listener', () => { + it('draft-table writes fire it exactly when rows changed, so no caller publishes by hand', async () => { + const journal = await open() + let commits = 0 + journal.observeCommits(() => { + commits += 1 + }) + await queueDraft(journal, 'draft-1') + expect(commits).toBe(1) + // An idempotent replay changes nothing and stays silent. + await queueDraft(journal, 'draft-1') + expect(commits).toBe(1) + await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' }) + expect(commits).toBe(2) + await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' }) + expect(commits).toBe(2) + await journal.queuedMessages.withdraw({ messageIds: ['draft-1'], settledByOp: 'c\u0000op' }) + expect(commits).toBe(3) + await journal.queuedMessages.withdraw({ messageIds: ['draft-1'], settledByOp: 'c\u0000op-2' }) + expect(commits).toBe(3) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts new file mode 100644 index 00000000000..ffc9b240987 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-table.ts @@ -0,0 +1,326 @@ +// Host-owned draft rows for messages queued while the main agent is working. +// +// A queued message is NOT a journal row: it becomes one — an ordinary +// submission — only when consume converts it, in the same transaction as the +// submission's append. Until then it lives here, `session_id`-keyed so it +// survives epoch rollover and replacement (`journal-row-table.ts` deletes only +// `journal_rows`). After a refusal its text survives as a `returned` row a +// rewind cannot delete; after a withdrawal it waits again. + +import type Database from '../../sqlite/sync-database' +import type { UnreadAgentSessionFailureFact } from '../../../shared/agent-session-failure' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { rejectedDraftSettlement } from './journal-dispatch-settlement' +import { readStoredRejectionFact } from './journal-dispatch-reducer' + +export type QueuedMessageState = 'waiting' | 'dispatched' | 'returned' | 'withdrawn' + +/** Why ONE waiting draft is held from auto-sending: its conversion failed. + * Stored on the row, so it survives handle eviction and restart; a wire marker + * (it publishes as `pausedReason`). A Stop or a restart pauses the whole queue + * instead. A reader treats an unknown stored value as a plain hold. */ +export type QueuedMessageHoldReason = 'send_failed' + +/** Definitively unsettled: what Stop, /clear, Edit and the budget count, and + * what the published list shows. Pending/unknown/accepted deliveries and + * tombstones stay outside it. */ +export function isUnsettledQueuedMessage(row: Pick): boolean { + return row.state === 'waiting' || row.state === 'returned' +} + +export type QueuedMessageRow = { + sessionId: string + messageId: string + position: number + body: AgentJournalMessageItem + fingerprint: string + createdAt: number + hostInstance: string + state: QueuedMessageState + /** Non-null holds this one waiting draft from auto-sending; typed values in + * `QueuedMessageHoldReason`, unknown strings read as a plain hold. */ + holdReason: string | null + /** A returned card's refusal, mirroring its submission's `reason` and `rejection` pair. */ + returnedReason: string | null + returnedRejection: UnreadAgentSessionFailureFact | null + settledAt: number | null + /** The operation ledger's caller-scoped key, making settled rows mutation receipts. */ + settledByOp: string | null + /** The submission that last handed it off: set on every dispatched row, kept on a returned + * card, cleared when a withdrawal sends it back to waiting. Host-only; the published link is + * the submission's `queuedMessageId`. */ + consumedAs: string | null +} + +const COLUMNS = + 'session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state, hold_reason, returned_reason, returned_rejection, settled_at, settled_by_op, consumed_as' + +export function insertQueuedMessage( + db: Database.Database, + input: { + sessionId: string + messageId: string + body: AgentJournalMessageItem + fingerprint: string + hostInstance: string + now: number + } +): QueuedMessageRow { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the statement selects exactly one aliased numeric column; better-sqlite3 types rows as unknown. + const highest = db + .prepare('SELECT COALESCE(MAX(position), 0) AS p FROM queued_messages WHERE session_id = ?') + .get(input.sessionId) as { p?: number } | undefined + const position = Number(highest?.p ?? 0) + 1 + db.prepare( + `INSERT INTO queued_messages (${COLUMNS}) + VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', NULL, NULL, NULL, NULL, NULL, NULL)` + ).run( + input.sessionId, + input.messageId, + position, + JSON.stringify(input.body), + input.fingerprint, + input.now, + input.hostInstance + ) + return { + sessionId: input.sessionId, + messageId: input.messageId, + position, + body: input.body, + fingerprint: input.fingerprint, + createdAt: input.now, + hostInstance: input.hostInstance, + state: 'waiting', + holdReason: null, + returnedReason: null, + returnedRejection: null, + settledAt: null, + settledByOp: null, + consumedAs: null + } +} + +export function listQueuedMessages(db: Database.Database, sessionId: string): QueuedMessageRow[] { + return db + .prepare(`SELECT ${COLUMNS} FROM queued_messages WHERE session_id = ? ORDER BY position ASC`) + .all(sessionId) + .flatMap((row) => toStoredRow(row) ?? []) +} + +export function getQueuedMessage( + db: Database.Database, + sessionId: string, + messageId: string +): QueuedMessageRow | null { + const row = db + .prepare(`SELECT ${COLUMNS} FROM queued_messages WHERE session_id = ? AND message_id = ?`) + .get(sessionId, messageId) + return row === undefined ? null : toStoredRow(row) +} + +/** + * The one waiting→dispatched (or returned→dispatched) transition, always under + * a fresh submission id — never the draft's own — so no reader can mistake id + * equality for the hand-off link. MUST run inside the caller's transaction — the journal + * writer's, between BEGIN IMMEDIATE and COMMIT — so a failed submission append + * rolls the consume back and a failed consume rolls the append back. Returns + * false when the draft was not in the expected state, in which case the caller + * throws to abort the append. + */ +export function consumeQueuedMessageInTransaction( + db: Database.Database, + input: { + sessionId: string + messageId: string + expect: 'waiting' | 'returned' + /** The fresh submission id; never the draft's own id. */ + consumedAs: string + settledByOp: string | null + /** The handing-off process; absent keeps the row's own. */ + hostInstance?: string + now: number + } +): boolean { + if (input.consumedAs === input.messageId) { + return false + } + const changed = db + .prepare( + `UPDATE queued_messages + SET state = 'dispatched', hold_reason = NULL, returned_reason = NULL, returned_rejection = NULL, + settled_at = ?, settled_by_op = ?, consumed_as = ?, host_instance = COALESCE(?, host_instance) + WHERE session_id = ? AND message_id = ? AND state = ?` + ) + .run( + input.now, + input.settledByOp, + input.consumedAs, + input.hostInstance ?? null, + input.sessionId, + input.messageId, + input.expect + ) + return Number(changed.changes ?? 0) === 1 +} + +/** Compare-and-transition unsettled rows (waiting ∪ returned) to withdrawn + * tombstones stamped with the operation's caller-scoped key, kept only so a + * replay of the settling operation answers "spent"; null when the host itself + * withdrew it. Returns the rows actually transitioned; their text stays in + * this database, never on the wire. */ +export function withdrawQueuedMessages( + db: Database.Database, + input: { + sessionId: string + messageIds: readonly string[] + settledByOp: string | null + now: number + } +): QueuedMessageRow[] { + const withdrawn: QueuedMessageRow[] = [] + for (const messageId of input.messageIds) { + const row = getQueuedMessage(db, input.sessionId, messageId) + if (!row || !isUnsettledQueuedMessage(row)) { + continue + } + db.prepare( + `UPDATE queued_messages + SET state = 'withdrawn', hold_reason = NULL, settled_at = ?, settled_by_op = ? + WHERE session_id = ? AND message_id = ? AND state IN ('waiting', 'returned')` + ).run(input.now, input.settledByOp, input.sessionId, messageId) + withdrawn.push({ + ...row, + state: 'withdrawn', + holdReason: null, + settledAt: input.now, + settledByOp: input.settledByOp + }) + } + return withdrawn +} + +/** + * dispatched → returned, or back to waiting (`rejectedDraftSettlement`), + * matched on the draft's CURRENT hand-off (`consumed_as`), so a re-send refused + * again still settles while a late duplicate of an earlier refusal matches + * nothing. A draft back to waiting keeps its position and carries no refusal; + * its spent submissions stay findable by their `queuedMessageId` link. + */ +export function settleRejectedQueuedMessage( + db: Database.Database, + input: { + sessionId: string + consumedRef: string + reason: string | null + rejection: UnreadAgentSessionFailureFact | undefined + now: number + } +): boolean { + const settlement = rejectedDraftSettlement({ reason: input.reason, rejection: input.rejection }) + const changed = + settlement.state === 'waiting' + ? db + .prepare( + `UPDATE queued_messages + SET state = 'waiting', hold_reason = NULL, consumed_as = NULL, + returned_reason = NULL, returned_rejection = NULL, settled_at = NULL, settled_by_op = NULL + WHERE session_id = ? AND state = 'dispatched' AND consumed_as = ?` + ) + .run(input.sessionId, input.consumedRef) + : db + .prepare( + `UPDATE queued_messages + SET state = 'returned', returned_reason = ?, returned_rejection = ?, settled_at = ? + WHERE session_id = ? AND state = 'dispatched' AND consumed_as = ?` + ) + .run( + input.reason, + input.rejection ? JSON.stringify(input.rejection) : null, + input.now, + input.sessionId, + input.consumedRef + ) + return Number(changed.changes ?? 0) > 0 +} + +/** Replay receipts: every row a given caller-scoped operation settled. */ +export function queuedMessagesSettledByOp( + db: Database.Database, + sessionId: string, + settledByOp: string +): QueuedMessageRow[] { + return db + .prepare( + `SELECT ${COLUMNS} FROM queued_messages + WHERE session_id = ? AND settled_by_op = ? ORDER BY position ASC` + ) + .all(sessionId, settledByOp) + .flatMap((row) => toStoredRow(row) ?? []) +} + +function toStoredRow(row: unknown): QueuedMessageRow | null { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: rows come from this file's own SELECTs, which name exactly these columns; better-sqlite3 types them as unknown. + const record = row as { + session_id: string + message_id: string + position: number + body_json: string + fingerprint: string + created_at: number + host_instance: string + state: string + hold_reason: string | null + returned_reason: string | null + returned_rejection: string | null + settled_at: number | null + settled_by_op: string | null + consumed_as: string | null + } + let body: AgentJournalMessageItem + try { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: body_json is written only by insertQueuedMessage from a schema-validated AgentJournalMessageItem. + body = JSON.parse(record.body_json) as AgentJournalMessageItem + } catch { + // Our own writer stringified it; an unreadable body is corruption, and a + // row we cannot re-materialize must not masquerade as an empty message. + return null + } + const state = record.state + if ( + state !== 'waiting' && + state !== 'dispatched' && + state !== 'returned' && + state !== 'withdrawn' + ) { + return null + } + return { + sessionId: record.session_id, + messageId: record.message_id, + position: record.position, + body, + fingerprint: record.fingerprint, + createdAt: record.created_at, + hostInstance: record.host_instance, + state, + holdReason: record.hold_reason, + returnedReason: record.returned_reason, + returnedRejection: storedRejection(record.returned_rejection), + settledAt: record.settled_at, + settledByOp: record.settled_by_op, + consumedAs: record.consumed_as + } +} + +function storedRejection(json: string | null): UnreadAgentSessionFailureFact | null { + if (json === null) { + return null + } + try { + return readStoredRejectionFact(JSON.parse(json)) ?? null + } catch { + // The refusal stays readable from `returned_reason`; a bad fact must not lose the card. + return null + } +} diff --git a/src/main/native-chat/agent-session-wire/agent-session-empty-delta-retention.test.ts b/src/main/native-chat/agent-session-wire/agent-session-empty-delta-retention.test.ts index 3907fa7a645..1ab1089d494 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-empty-delta-retention.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-empty-delta-retention.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import { createCodexStructuredItemStreams } from '../../codex/codex-structured-item-streams' import { createAgentSessionDeltaCoalescer } from './agent-session-delta-coalescer' @@ -9,7 +10,7 @@ describe('empty streamed deltas', () => { sink: { appendItem() {}, appendTombstone() {}, publish() {} }, turnIdFor: () => 'turn', identityFor: () => ({ provider: 'codex', threadId: 'thread', turnId: 'turn', ordinal: 0 }), - linkageFor: () => ({}), + attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }), schedule: () => () => {} }) const append = (delta: string) => @@ -24,15 +25,16 @@ describe('empty streamed deltas', () => { } const originalJoin = Array.prototype.join let retainedSlots = -1 - const spy = vi - .spyOn(Array.prototype, 'join') - .mockImplementation(function (this: unknown[], separator) { - // Byte counters cannot detect empty entries retained by the stream's chunk array. - if (separator === '' && this[0] === prefix) { - retainedSlots = this.length - } - return originalJoin.call(this, separator) - }) + const spy = vi.spyOn(Array.prototype, 'join').mockImplementation(function ( + this: unknown[], + separator + ) { + // Byte counters cannot detect empty entries retained by the stream's chunk array. + if (separator === '' && this[0] === prefix) { + retainedSlots = this.length + } + return originalJoin.call(this, separator) + }) let snapshot: ReturnType try { snapshot = streams.snapshot('thread', 'item') diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts index 17bdf710490..f13d41373d7 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -40,7 +41,10 @@ function body(text: string): AgentJournalItemBody { async function appendItems(count: number, text: string): Promise { for (let ordinal = 1; ordinal <= count; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`${text}-${ordinal}`), { fence: 1 }) + await journal.appendItem(item(ordinal), body(`${text}-${ordinal}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } } diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts index cda878f5a01..97ce6ecd216 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts @@ -12,7 +12,10 @@ import type { AgentJournalMessageItem, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION +} from '../../../shared/agent-session-journal-types' import { AGENT_SESSION_HISTORY_MAX_LIMIT } from '../../../shared/agent-session-wire' import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES, @@ -61,7 +64,10 @@ function body(text: string): AgentJournalItemBody { async function appendItems(count: number): Promise { for (let ordinal = 1; ordinal <= count; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`item-${ordinal}`), { fence: 1 }) + await journal.appendItem(item(ordinal), body(`item-${ordinal}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } } @@ -216,7 +222,10 @@ describe('history page byte ceiling', () => { async function appendLargeItems(count: number): Promise { for (let ordinal = 1; ordinal <= count; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`${ordinal}:${LARGE_TEXT}`), { fence: 1 }) + await journal.appendItem(item(ordinal), body(`${ordinal}:${LARGE_TEXT}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } } @@ -300,7 +309,10 @@ describe('history page byte ceiling', () => { }) it('degrades a single over-budget item to a visible truncation marker instead of overflowing', async () => { - await journal.appendItem(item(1), body(`1:${'y'.repeat(3 * 1024 * 1024)}`), { fence: 1 }) + await journal.appendItem(item(1), body(`1:${'y'.repeat(3 * 1024 * 1024)}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const tail = pageOf( readAgentSessionHistory(journal, { sessionId: 'session-1', direction: 'tail', limit: 40 }) @@ -320,14 +332,28 @@ describe('projectJournalBatch', () => { sessionId: 'session-1', recordId: 'turn-lifecycle:turn-1' } - await journal.appendItem(turn, { kind: 'status', text: 'working' }, { fence: 1 }) + await journal.appendItem( + turn, + { kind: 'status', text: 'working' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) const cursor = journal.cursor() await journal.appendLifecycleBatch({ settlementId: 'settlement-1', fence: 1, mutations: [ - { kind: 'item', identity: item(1), body: body('one') }, - { kind: 'item', identity: item(2), body: body('two') }, + { + kind: 'item', + identity: item(1), + body: body('one'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }, + { + kind: 'item', + identity: item(2), + body: body('two'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }, { kind: 'tombstone', identity: turn } ] }) @@ -371,7 +397,10 @@ describe('projectJournalBatch', () => { it('publishes touched items at their current reduced state, not as a delta', async () => { await appendItems(1) const cursor = journal.cursor() - await journal.appendItem(item(1), body('revised'), { fence: 1 }) + await journal.appendItem(item(1), body('revised'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const since = journal.readSince(cursor) if (!since.ok) { throw new Error(`expected rows, got reset ${since.reset}`) @@ -440,7 +469,7 @@ describe('projectJournalBatch', () => { await journal.appendItem( { provider: 'codex', threadId: 'thread-1', turnId: 'root-turn', ordinal: 2 }, message, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const page = readAgentSessionHistory(journal, { @@ -636,7 +665,10 @@ describe('identity bounding at admission', () => { ordinal: 1 } const start = { epoch: journal.epoch, sequence: journal.cursor().sequence } - const appended = await journal.appendItem(oversized, body('bounded'), { fence: 1 }) + const appended = await journal.appendItem(oversized, body('bounded'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) expect(appended.itemId.length).toBeLessThan(2048) expect(appended.itemId).toContain('~orca-oversized~') diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts index 57f2291e476..b70a18a8386 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts @@ -6,6 +6,7 @@ // key instead of appearing as a second copy of the user's own message. import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { compareAgentJournalItems } from '../../../shared/agent-session-journal-position' import type { AgentJournalRenderItem, AgentJournalSnapshot, @@ -65,7 +66,7 @@ export function projectJournalBatch(input: { const items = [...touchedItemIds] .map((itemId) => live.get(itemId)) .filter((item) => item !== undefined) - .sort((a, b) => a.sequence - b.sequence) + .sort(compareAgentJournalItems) return { ok: true, batch: { diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts index 214c889b5c9..df2330346ae 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Recovery drives the real journal loader against real on-disk damage: a hole // punched in the row sequence, and a row stamped with a schema this host cannot // read — on both version axes, because only one of them is detectable before a @@ -6,14 +7,14 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalItemIdentity, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import { openJournalDatabase } from '../agent-session-journal/journal-database' import { JOURNAL_DB_SCHEMA_VERSION } from '../agent-session-journal/journal-database-schema' -import { loadJournal } from '../agent-session-journal/journal-open' +import { loadJournal, replayJournal } from '../agent-session-journal/journal-open' import { journalDatabaseFile } from '../agent-session-journal/journal-paths' import { readJournalEpochRows } from '../agent-session-journal/journal-row-table' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' @@ -24,6 +25,12 @@ import { recoveryJournalDir } from './agent-session-journal-recovery' +// Only the store's own replay goes through the mock; the probe's, inside the same module, does not. +vi.mock('../agent-session-journal/journal-open', async (importOriginal) => { + const actual = await importOriginal<{ replayJournal: typeof replayJournal }>() + return { ...actual, replayJournal: vi.fn(actual.replayJournal) } +}) + const CODEX_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' const IDENTITY: AgentSessionJournalIdentity = { @@ -74,7 +81,7 @@ async function seedJournal(count: number): Promise { await journal.appendItem( item(ordinal), { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `item-${ordinal}` }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } const epoch = journal.epoch @@ -160,6 +167,21 @@ describe('openAgentSessionJournalWithRecovery', () => { expect(opened.snapshot().items).toHaveLength(2) }) + it('reads the journal once: the probe is the open', async () => { + await seedJournal(2) + vi.mocked(replayJournal).mockClear() + const opened = journals.track( + await openAgentSessionJournalWithRecovery({ + identity: IDENTITY, + journalDir, + fence: 1, + historyFilePath + }).then((result) => result.journal) + ) + expect(opened.snapshot().items).toHaveLength(2) + expect(replayJournal).not.toHaveBeenCalled() + }) + it('rebuilds a holed journal in place on a fresh epoch', async () => { await seedJournal(3) await deleteRow(3) @@ -269,7 +291,8 @@ describe('openAgentSessionJournalWithRecovery', () => { { kind: 'item', identity: { provider: 'orca', clientMessageId: 'approval-1' }, - body: { kind: 'status', text: 'approved' } + body: { kind: 'status', text: 'approved' }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE } ] }) @@ -384,7 +407,7 @@ describe('openAgentSessionJournalWithRecovery', () => { await reopened.journal.appendItem( item(2), { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'typed later' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const epoch = reopened.journal.epoch await reopened.journal.close() diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts index 6e771a31809..29d324be971 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts @@ -67,7 +67,9 @@ export async function openAgentSessionJournalWithRecovery(input: { } const journal = await openAgentSessionJournal({ identity: input.identity, - journalDir: input.journalDir + journalDir: input.journalDir, + // The probe is this open's replay; omitted, not `null`, when there was nothing to load. + ...(probe ? { loaded: probe } : {}) }) if (!probe?.corrupt) { return { journal, recovery: null } diff --git a/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts b/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts new file mode 100644 index 00000000000..5ff159ce2e6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts @@ -0,0 +1,135 @@ +// One subscriber's catch-up: page it forward to the journal head, or hand it +// the empty caught-up frame its per-emit fields still owe it. Split from the +// subscriber registry so the registry stays the bookkeeping and this stays the +// paging policy. + +import { + AGENT_SESSION_HISTORY_MAX_LIMIT, + type AgentSessionBackgroundTaskState, + type AgentSessionSlashCommand, + type AgentSessionSubscribeEvent, + type AgentSessionTurnActivity +} from '../../../shared/agent-session-wire' +import { sameJournalCursor } from '../agent-session-journal/journal-cursor' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { emptyAgentSessionBatch } from './agent-session-empty-batch' +import { createAgentSessionCatchUpReader } from './agent-session-history-page' +import { + subscriberQueuedMessagesChanged, + type SubscriberFieldHooks +} from './agent-session-subscriber-frame-fields' +import type { Subscriber } from './structured-agent-session-subscribers' + +export type SubscriberDeliveryPort = { + hooks: SubscriberFieldHooks & { + readCommands?: (sessionId: string) => AgentSessionSlashCommand[] | undefined + } + emit: ( + subscriber: Subscriber, + event: AgentSessionSubscribeEvent, + options?: { withholdQueued?: boolean } + ) => void + isActive: (subscriber: Subscriber) => boolean + activity: (sessionId: string) => AgentSessionTurnActivity | null +} + +export function deliverToSubscriber( + port: SubscriberDeliveryPort, + input: { + subscriber: Subscriber + journal: AgentSessionJournal + hostNow: number + emitCheckpoint: boolean + backgroundTasks?: AgentSessionBackgroundTaskState | null | undefined + activity?: AgentSessionTurnActivity | null | undefined + } +): void { + const { subscriber, journal, hostNow, emitCheckpoint, backgroundTasks, activity } = input + const checkpointActivity = emitCheckpoint ? port.activity(subscriber.sessionId) : undefined + const publishedActivity = activity !== undefined ? activity : checkpointActivity + const shared = { + hostNow, + ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), + ...(publishedActivity !== undefined ? { activity: publishedActivity } : {}) + } + // Caught up, so there are no rows to read: every publish behind a commit's own delivery. + if (!journal.isReadOnly && sameJournalCursor(subscriber.cursor, journal.cursor())) { + emitCaughtUp(port, subscriber, emitCheckpoint, shared) + return + } + const readPage = createAgentSessionCatchUpReader(journal) + while (true) { + const result = readPage({ + sessionId: subscriber.sessionId, + direction: 'after', + cursor: subscriber.cursor, + limit: AGENT_SESSION_HISTORY_MAX_LIMIT + }) + if (!result.ok) { + const page = { ...result.page, fence: subscriber.fence } + port.emit(subscriber, { + type: 'reset', + sessionId: subscriber.sessionId, + reset: result.reset, + page, + fence: subscriber.fence, + ...shared + }) + subscriber.cursor = page.liveCursor ?? page.window.nextCursor + return + } + const page = result.page + const advanced = page.window.nextCursor.sequence > subscriber.cursor.sequence + if (!advanced) { + emitCaughtUp(port, subscriber, emitCheckpoint, shared) + return + } + port.emit( + subscriber, + { + type: 'batch', + sessionId: subscriber.sessionId, + batch: { + cursor: page.window.nextCursor, + items: page.items, + removedItemIds: page.removedItemIds, + submissions: page.submissions + }, + fence: subscriber.fence, + ...shared + }, + // On a multi-page catch-up the draft list rides only the final page, or a + // consumed card would vanish pages before its bubble arrives. + { withholdQueued: page.hasNewer } + ) + subscriber.cursor = page.window.nextCursor + if (!page.hasNewer || !port.isActive(subscriber)) { + return + } + } +} + +function emitCaughtUp( + port: SubscriberDeliveryPort, + subscriber: Subscriber, + emitCheckpoint: boolean, + shared: { + hostNow: number + backgroundTasks?: AgentSessionBackgroundTaskState | null + activity?: AgentSessionTurnActivity | null + } +): void { + const commandsChanged = + port.hooks.readCommands !== undefined && + (port.hooks.readCommands(subscriber.sessionId) ?? null) !== subscriber.commands + const queuedChanged = subscriberQueuedMessagesChanged(port.hooks, subscriber) + if (emitCheckpoint || shared.activity !== undefined || commandsChanged || queuedChanged) { + port.emit(subscriber, { + type: 'batch', + sessionId: subscriber.sessionId, + batch: emptyAgentSessionBatch(subscriber.cursor), + fence: subscriber.fence, + ...shared + }) + } +} diff --git a/src/main/native-chat/agent-session-wire/agent-session-subscriber-frame-fields.ts b/src/main/native-chat/agent-session-wire/agent-session-subscriber-frame-fields.ts new file mode 100644 index 00000000000..eebec1e6d1b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-subscriber-frame-fields.ts @@ -0,0 +1,77 @@ +// Which per-emit fields ride one subscriber frame: the provider command catalog +// and the queue publication (the draft list with the queue's pause). Both are +// identity-deduplicated against the LAST VALUE SENT — never advanced on a frame +// that withheld the field, or the final replacement would be suppressed — and +// both attach whole to hydrating frames. + +import type { + AgentSessionSlashCommand, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' +import type { QueuePublication } from './structured-agent-session-queued-publication' + +export type SubscriberFieldState = { + sessionId: string + commands?: AgentSessionSlashCommand[] | null + /** The last queue publication actually SENT. */ + queuePublication?: QueuePublication +} + +export type SubscriberFieldHooks = { + readCommands?: (sessionId: string) => AgentSessionSlashCommand[] | undefined + readQueuePublication?: (sessionId: string) => QueuePublication | undefined +} + +export type SubscriberFrame = { + frame: AgentSessionSubscribeEvent + commands: AgentSessionSlashCommand[] | null + attachedQueued: boolean + queued: QueuePublication | undefined +} + +/** Builds the frame to emit; the caller stores the returned refs only after the + * emit succeeded, so a dropped subscriber never advances its dedup state. */ +export function buildSubscriberFrame( + hooks: SubscriberFieldHooks, + subscriber: SubscriberFieldState, + event: AgentSessionSubscribeEvent, + withholdQueued: boolean +): SubscriberFrame { + const commands = hooks.readCommands?.(subscriber.sessionId) ?? null + const includeCommands = + hooks.readCommands !== undefined && + event.type !== 'end' && + (event.type !== 'batch' || commands !== subscriber.commands) + // Withheld on intermediate catch-up pages (the caller says so), attached to + // every hydrating frame, and to batches only when the list changed. + const queued = withholdQueued ? undefined : hooks.readQueuePublication?.(subscriber.sessionId) + const attachedQueued = + queued !== undefined && + event.type !== 'end' && + (event.type !== 'batch' || queued !== subscriber.queuePublication) + return { + frame: { + ...event, + ...(includeCommands ? { commands: commands ?? null } : {}), + ...(attachedQueued && queued + ? { queuedMessages: queued.queuedMessages, queuePause: queued.queuePause } + : {}) + }, + commands, + attachedQueued, + queued + } +} + +/** Whether a caught-up publish with no rows still owes this subscriber a frame: + * draft inserts and pause changes write no journal row, so an unchanged cursor + * must still deliver the changed publication. */ +export function subscriberQueuedMessagesChanged( + hooks: SubscriberFieldHooks, + subscriber: SubscriberFieldState +): boolean { + return ( + hooks.readQueuePublication !== undefined && + hooks.readQueuePublication(subscriber.sessionId) !== subscriber.queuePublication + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts new file mode 100644 index 00000000000..98a6f7e42dc --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -0,0 +1,866 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +// A send is accepted, then delivered: the host answers once the message is recorded, and the +// session's delivery loop starts a provider child for it and hands it over. Against the real host, +// store and journal; each assertion reads what an open chat or the journal's next reader sees. + +import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { + AgentSessionSubscribeEvent, + AgentSessionTurnCompletionEvent +} from '../../../shared/agent-session-wire' +import { + classifyDispatchRejection, + DISPATCH_REJECTED_CANCELLED +} from '../../../shared/structured-agent-session-dispatch-rejection' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import { + AgentSessionPreSpawnError, + type StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { journalIdentityFor } from './structured-agent-session-attach' +import { attachParamsForRecord } from './structured-agent-session-conversation-open' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionChildEndCause } from './structured-agent-session-host-types' +import { persistRewindRecord } from './structured-rewind-recovery' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestDrawnRowIds, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' + +const CALLER = { callerKey: 'client-1' } + +function eventually(assertion: () => void | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquire: Mock +let dispatch: Mock +let adapterExtras: Partial +let idleMs: number + +const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + acquisitionGeneration: `generation-${acquire.mock.calls.length}`, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: store.getRecord(SESSION)?.providerHandleChain.length + ? ('resumed' as const) + : ('created' as const), + mintedAtFence: fence, + observedAt: NOW + } +}) + +async function startHost(): Promise { + host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire, + dispatch, + closeSession: vi.fn(async () => true), + releaseAcquisition: vi.fn(async () => true), + cancelTurn: vi.fn(async () => ({ cancelled: false })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined), + ...adapterExtras + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, + idleSweep: { intervalMs: 5, idleMs }, + now: () => NOW + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-accept-deliver-')) + resetHostTestOperationIds() + adapterExtras = {} + idleMs = 60 * 60_000 + acquire = vi.fn(spawnChild) + dispatch = vi.fn(async () => ({ + state: 'accepted' as const, + providerIdentity: { + provider: 'codex' as const, + threadId: THREAD, + turnId: `turn-${dispatch.mock.calls.length}`, + ordinal: dispatch.mock.calls.length + } + })) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + await startHost() + expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function sendParams(text: string) { + const body = hostTestMessage(text) + return { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + } +} + +/** Accepted at once, whatever the child is doing; answers the message id. */ +async function accept(text: string): Promise { + const params = sendParams(text) + expect(await host.send(CALLER, params)).toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'pending', handoverRecorded: true } } + }) + return params.envelope.clientOperationId +} + +function stop() { + const turnId = 'turn-none' + return host.cancel(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: SESSION, + fields: { turnId } + }) + }, + turnId + }) +} + +async function submission(id: string): Promise { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === id + ) +} + +/** Read back after the conversation was closed, through the same open any reader takes. */ +async function reopened(id: string): Promise { + await host.revealSession(SESSION) + return submission(id) +} + +async function errorRows(): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] + ) +} + +async function errorFailures(): Promise<(AgentSessionFailureFact | undefined)[]> { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.failure] : [] + ) +} + +let subscriptions = 0 + +async function subscribe(): Promise { + const events: AgentSessionSubscribeEvent[] = [] + subscriptions += 1 + // Cloned as received: a frame shares the journal's live objects, which later rows revise. + await host.subscribe({ + id: `sub-${subscriptions}`, + sessionId: SESSION, + emit: (event) => events.push(structuredClone(event)) + }) + return events +} + +/** What an open chat was told about one message, in frame order. */ +function framedStates(events: AgentSessionSubscribeEvent[], id: string): string[] { + return events.flatMap((event) => + event.type === 'batch' + ? event.batch.submissions + .filter((entry) => entry.clientMessageId === id) + .map((entry) => + entry.dispatchState === 'pending' && entry.handedOverAt !== undefined + ? 'handed-over' + : entry.dispatchState + ) + : [] + ) +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((next, fail) => { + resolve = next + reject = fail + }) + return { promise, resolve, reject } +} + +/** Rows written by an earlier host process that ended before handing them over. */ +async function writeAsEarlierProcess( + write: (journal: AgentSessionJournal, fence: number) => Promise +): Promise { + await host.close(SESSION) + const record = store.getRecord(SESSION)! + const params = attachParamsForRecord(record, { + clientOperationId: 'earlier', + expectedRuntimeFence: record.lease.runtimeFence + }) + const journal = await openAgentSessionJournal({ + identity: journalIdentityFor(record, params), + journalDir: journalDirectoryFor(root, { + workspaceId: record.location.workspaceId, + sessionId: SESSION + }) + }) + await write(journal, record.lease.runtimeFence) + await journal.close() +} + +function earlierSubmission(id: string, text: string, handoverRecorded?: true) { + const body = hostTestMessage(text) + return { + clientMessageId: id, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }), + body, + ...(handoverRecorded ? { handoverRecorded } : {}) + } +} + +describe('a send is answered at acceptance', () => { + it('answers before the child starts, then an open chat sees the handover and the reply (W2)', async () => { + await host.close(SESSION) + const starting = deferred() + acquire.mockImplementationOnce(async (input) => { + await starting.promise + return spawnChild(input) + }) + + const answering = deferred() + const answer = dispatch.getMockImplementation()! + dispatch.mockImplementationOnce(async (input) => { + await answering.promise + return answer(input) + }) + + const id = await accept('hello') + const events = await subscribe() + await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) + expect(dispatch).not.toHaveBeenCalled() + + starting.resolve() + await eventually(async () => expect(framedStates(events, id)).toEqual(['handed-over'])) + answering.resolve() + await eventually(async () => + expect(framedStates(events, id)).toEqual(['handed-over', 'accepted']) + ) + }) + + it('accepts a second send while the first one starts the child, before handing either over (W6)', async () => { + await host.close(SESSION) + const starting = deferred() + acquire.mockImplementationOnce(async (input) => { + await starting.promise + return spawnChild(input) + }) + const first = await accept('first') + await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) + const second = host.send(CALLER, sendParams('second')) + + starting.resolve() + const secondResult = await second + if (!secondResult.ok) { + throw new Error('the second send was refused') + } + const secondId = secondResult.value.clientMessageId + await eventually(async () => + expect((await submission(secondId))?.dispatchState).toBe('accepted') + ) + expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([first, secondId]) + // The second was accepted while the start held the queue — before the first was handed over. + expect((await submission(secondId))!.submittedAt).toBeLessThanOrEqual( + (await submission(first))!.handedOverAt! + ) + const rows = (await host.journalSnapshot(SESSION)).submissions + expect(rows.map((row) => row.clientMessageId)).toEqual([first, secondId]) + }) +}) + +describe('a start the chat needed and did not get', () => { + it('writes one error row and rejects every queued message with it; the next send starts (W3)', async () => { + await host.close(SESSION) + acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')) + const first = await accept('first') + const second = await accept('second') + + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) + const rows = await errorRows() + expect(rows).toHaveLength(1) + // Orca's spawn error goes to the log; the row and every message say what failed, typed. The + // child is gone, but no exit was observed, so nothing blames the provider. + expect(rows[0]).toBe("Codex couldn't restart. Send your message to try again.") + const failure = { + kind: 'restartFailed', + refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } } + } + expect(await errorFailures()).toEqual([failure]) + for (const id of [first, second]) { + expect(await submission(id)).toMatchObject({ + dispatchState: 'rejected', + reason: rows[0], + rejection: failure + }) + } + + const next = await accept('after the fix') + await eventually(async () => expect((await submission(next))?.dispatchState).toBe('accepted')) + expect(await errorRows()).toHaveLength(1) + }) + + it('draws the messages it failed above the error row, since they were accepted first', async () => { + await host.close(SESSION) + acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')) + const first = await accept('first') + const second = await accept('second') + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) + + const snapshot = await host.journalSnapshot(SESSION) + const errorRow = snapshot.items.find( + (item) => item.body.kind === 'status' && item.body.tone === 'error' + )?.itemId + const shown = [agentJournalSubmissionKey(first), agentJournalSubmissionKey(second), errorRow] + const drawn = hostTestDrawnRowIds(snapshot, [ + { clientMessageId: first, text: 'first' }, + { clientMessageId: second, text: 'second' } + ]) + expect(drawn.filter((id) => shown.includes(id))).toEqual(shown) + }) + + it('notifies failed once for the queued messages one start failure refused', async () => { + await host.close(SESSION) + acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')) + const completions: AgentSessionTurnCompletionEvent[] = [] + host.subscribeTurnCompletions({ id: 'dot-1', emit: (event) => completions.push(event) }) + await accept('first') + const second = await accept('second') + + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) + await host.flushAllStreamedEvents() + expect(completions).toEqual([ + { + type: 'completion', + completion: expect.objectContaining({ + sessionId: SESSION, + turnId: agentJournalSubmissionKey(second), + outcome: 'failure' + }) + } + ]) + }) + + it.each([ + [ + 'eligibility', + () => { + adapterExtras = { supportsLocation: () => false } + }, + { + text: "Codex couldn't restart. Start a new chat to continue.", + failure: { + kind: 'restartFailed', + refusal: { + code: 'structured_agent_session_unsupported', + details: { reason: 'hostUnsupported' } + } + } + } + ], + [ + 'spawn', + () => acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')), + { + text: "Codex couldn't restart. Send your message to try again.", + failure: { + kind: 'restartFailed', + refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } } + } + } + ], + [ + 'auth', + () => + acquire.mockRejectedValueOnce( + new AgentSessionPreSpawnError(new Error('Not logged in. Please run /login.')) + ), + { + // No process ever started, so nothing says the provider stopped. + text: "Codex couldn't restart. Send your message to try again.", + failure: { + kind: 'restartFailed', + refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } } + } + } + ] + ])('writes one row a live chat sees for a %s refusal (W14)', async (_source, arrange, row) => { + await host.close(SESSION) + arrange() + await host.flushAllStreamedEvents() + await startHost() + const id = await accept('hello') + const events = await subscribe() + + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) + expect(await errorRows()).toEqual([row.text]) + expect(await errorFailures()).toEqual([row.failure]) + const framedRows = events.flatMap((event) => + event.type === 'batch' || event.type === 'snapshot' + ? (event.type === 'batch' ? event.batch.items : event.page.items).filter( + (item) => item.body.kind === 'status' && item.body.tone === 'error' + ) + : [] + ) + expect(framedRows.length).toBeGreaterThan(0) + }) + + it('names the start failure on queued messages when the attach fails after acquiring (W4′a)', async () => { + await host.close(SESSION) + const id = await accept('hello') + // The attach's own success record is the post-acquisition step that fails. + const record = vi.spyOn(store, 'recordOperationOutcome') + record.mockImplementation(async (input) => { + if (input.operationId !== id && input.outcome.status === 'succeeded') { + record.mockRestore() + throw new Error('record store write failed') + } + return AgentSessionRecordStore.prototype.recordOperationOutcome.call(store, input) + }) + + // Read through the open any reader takes, so a conversation the failure dropped is reopened + // and its message read as that reopen settles it. + let settled: AgentJournalSubmission | undefined + await eventually(async () => { + settled = await reopened(id) + expect(settled?.dispatchState).not.toBe('pending') + }) + // The message names the start that failed, not a close or a restart it never met — and never + // the store's own error, which is Orca's and goes to the log. + expect(settled).toMatchObject({ + dispatchState: 'rejected', + rejection: (await errorFailures())[0] + }) + expect(settled?.reason).not.toContain('record store write failed') + expect(await errorRows()).toEqual([settled?.reason]) + }) +}) + +describe('an attach that fails after indexing its child', () => { + it('leaves no child behind, so the next send starts one and is delivered', async () => { + await host.close(SESSION) + const owned: boolean[] = [] + host.subscribeStatus({ + id: 'list-1', + emit: (event) => { + if (event.type === 'status') { + owned.push(event.session.hostExecutionOwned === true) + } + } + }) + const first = await accept('hello') + // The attach's own success record is the step after `onAttached` indexed the child. + const record = vi.spyOn(store, 'recordOperationOutcome') + record.mockImplementation(async (input) => { + if (input.operationId !== first && input.outcome.status === 'succeeded') { + record.mockRestore() + throw new Error('record store write failed') + } + return AgentSessionRecordStore.prototype.recordOperationOutcome.call(store, input) + }) + await eventually(async () => expect((await submission(first))?.dispatchState).toBe('rejected')) + // Nothing was indexed, so nothing had to be taken back: no list ever showed a child. + expect(host['sessions'].get(SESSION)?.child).toBeNull() + expect(owned).not.toContain(true) + const acquiresBefore = acquire.mock.calls.length + + const next = await accept('after the failure') + + await eventually(async () => expect((await submission(next))?.dispatchState).toBe('accepted')) + expect(acquire).toHaveBeenCalledTimes(acquiresBefore + 1) + expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([next]) + }) +}) + +describe('what an earlier host process left behind', () => { + it('rejects a message it accepted and never handed over, as not sent (W4′b)', async () => { + await writeAsEarlierProcess(async (journal, fence) => { + await journal.appendSubmission({ ...earlierSubmission('queued', 'q', true), fence }) + }) + + await host.revealSession(SESSION) + + expect(await submission('queued')).toMatchObject({ + dispatchState: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { + surface: 'rejection' + }) + }) + expect(dispatch).not.toHaveBeenCalled() + }) + + it('leaves a legacy pending message and a handed-over one in doubt, never re-sent (W4′c)', async () => { + const providerHistoryWindow = vi.fn(async () => null) + adapterExtras = { providerHistoryWindow } + await writeAsEarlierProcess(async (journal, fence) => { + await journal.appendSubmission({ ...earlierSubmission('legacy', 'l'), fence }) + await journal.appendSubmission({ ...earlierSubmission('handed', 'h', true), fence }) + await journal.resolveDispatch({ + clientMessageId: 'handed', + state: 'pending', + fence, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + }) + await host.flushAllStreamedEvents() + await startHost() + + await host.revealSession(SESSION) + + expect(await submission('legacy')).toMatchObject({ dispatchState: 'unknown', recovered: true }) + expect(await submission('handed')).toMatchObject({ dispatchState: 'unknown', recovered: true }) + // Deciding them from provider history waits for a won lease (W4′d). + expect(providerHistoryWindow).not.toHaveBeenCalled() + expect(dispatch).not.toHaveBeenCalled() + }) +}) + +describe('a child that exits before its message is handed over', () => { + it('rejects the message with the exit reason instead of starting another child (W24)', async () => { + // Each child the loop starts dies between its start step and its handover step. + const awaitStarted = vi.fn(async (sessionId: string) => { + await host.handleAdapterEvent({ + type: 'ended', + sessionId, + fence: store.getRecord(sessionId)!.lease.runtimeFence, + acquisitionGeneration: `generation-${acquire.mock.calls.length}`, + reason: 'codex app-server crashed', + cause: 'unexpected-exit' + }) + }) + adapterExtras = { awaitStarted } + await host.close(SESSION) + await startHost() + + const id = await accept('hello') + + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) + expect(await submission(id)).toMatchObject({ + reason: 'Codex stopped before this message was sent.', + rejection: { kind: 'providerExited' } + }) + expect(acquire).toHaveBeenCalledTimes(2) + expect(dispatch).not.toHaveBeenCalled() + }) +}) + +describe('a start whose failure the delivery loop settles before the exit is published', () => { + it('keeps one row in the words its rejected messages carry', async () => { + // The adapter's startup answer and its later exit event word the same start differently. + const awaitStarted = vi.fn(async () => agentSessionFailureFact('startFailed')) + adapterExtras = { awaitStarted } + acquire.mockImplementation(async (input) => ({ + ...(await spawnChild(input)), + providerChildPhase: 'starting' as const + })) + await host.close(SESSION) + await startHost() + + const first = await accept('first') + const second = await accept('second') + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) + await host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: `generation-${acquire.mock.calls.length}`, + reason: 'codex app-server exited with code 1', + failure: agentSessionFailureFact('providerStartFailed', { + detail: { text: 'codex: config.toml is invalid', audience: 'person' } + }), + cause: 'unexpected-exit', + startupUnproven: true + }) + await host.flushStreamedEvents(SESSION) + + const rows = (await host.journalSnapshot(SESSION)).items.filter((item) => + item.itemId.includes('start-failure') + ) + expect(rows).toHaveLength(1) + const words = agentSessionFailureWords(agentSessionFailureFact('startFailed'), { + surface: 'rejection', + agentName: 'Codex', + provider: 'codex' + }) + expect(rows[0].body).toMatchObject({ text: words.reason, failure: words.rejection }) + for (const id of [first, second]) { + expect(await submission(id)).toMatchObject({ + dispatchState: 'rejected', + reason: words.reason, + rejection: words.rejection + }) + } + }) +}) + +describe('Stop withdraws what is queued', () => { + it('withdraws a crash leftover ahead of any delivery step (W17a)', async () => { + await writeAsEarlierProcess(async (journal, fence) => { + await journal.appendSubmission({ ...earlierSubmission('leftover', 'l', true), fence }) + }) + + // Stop's own open wakes the delivery loop, whose first step queues behind this Stop. + expect(await stop()).toMatchObject({ ok: true }) + + expect(await submission('leftover')).toMatchObject({ + dispatchState: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + }) + expect(acquire).toHaveBeenCalledTimes(1) + }) + + it('withdraws a message whose start holds the queue: nothing is handed over (W17b)', async () => { + await host.close(SESSION) + const starting = deferred() + acquire.mockImplementationOnce(async (input) => { + await starting.promise + return spawnChild(input) + }) + const id = await accept('hello') + await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) + const stopped = stop() + + starting.resolve() + expect(await stopped).toMatchObject({ ok: true }) + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) + expect(await submission(id)).toMatchObject({ reason: DISPATCH_REJECTED_CANCELLED }) + expect((await submission(id))?.handedOverAt).toBeUndefined() + expect(dispatch).not.toHaveBeenCalled() + }) + + it('stops a child still proving its start, and the delivery loop ends with it (W17c)', async () => { + const ended = deferred() + const awaitStarted = vi.fn(() => ended.promise) + const closeSession = vi.fn(async () => { + ended.resolve() + return true + }) + adapterExtras = { awaitStarted, closeSession } + await host.close(SESSION) + await startHost() + acquire.mockImplementationOnce(async (input) => ({ + ...(await spawnChild(input)), + providerChildPhase: 'starting' as const + })) + const id = await accept('hello') + await eventually(async () => expect(awaitStarted).toHaveBeenCalled()) + + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + + expect(await reopened(id)).toMatchObject({ + dispatchState: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + }) + expect(closeSession).toHaveBeenCalled() + // A loop still waiting on that start would swallow this send; it starts a new child instead. + awaitStarted.mockImplementation(async () => undefined) + const next = await accept('after stop') + await eventually(async () => expect((await submission(next))?.dispatchState).toBe('accepted')) + expect(dispatch).toHaveBeenCalledTimes(1) + }) +}) + +describe('an eviction between acceptance and handover', () => { + it('rejects the message as not sent, never leaves it in doubt (W24)', async () => { + const started = deferred() + adapterExtras = { awaitStarted: () => started.promise } + await host.close(SESSION) + await startHost() + const id = await accept('hello') + await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) + // Between the delivery loop's start step and its handover step. + await host.close(SESSION) + started.resolve() + + expect(await reopened(id)).toMatchObject({ + dispatchState: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) + }) + expect(dispatch).not.toHaveBeenCalled() + }) + + it('rejects a queued message behind a handed-over one, which alone stays in doubt (W24)', async () => { + const second = deferred() + const awaitStarted = vi.fn(async (): Promise => undefined) + adapterExtras = { awaitStarted } + await host.close(SESSION) + await startHost() + dispatch.mockResolvedValueOnce({ state: 'admitted' }) + const handed = await accept('handed over') + await eventually(async () => expect((await submission(handed))?.handedOverAt).toBeDefined()) + awaitStarted.mockImplementation(() => second.promise) + const queued = await accept('still queued') + await eventually(async () => expect(awaitStarted).toHaveBeenCalledTimes(2)) + + await host.close(SESSION) + second.resolve() + + expect(await reopened(queued)).toMatchObject({ + dispatchState: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) + }) + expect(await submission(handed)).toMatchObject({ dispatchState: 'unknown' }) + expect(dispatch).toHaveBeenCalledTimes(1) + }) + + it('does not stop an idle child while a message is still queued for it (W24)', async () => { + idleMs = 0 + const started = deferred() + adapterExtras = { awaitStarted: () => started.promise } + await host.close(SESSION) + await startHost() + const id = await accept('hello') + await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) + // The idle sweep ticks every few milliseconds meanwhile. + await new Promise((resolve) => setTimeout(resolve, 20)) + + started.resolve() + // Handed to the child it was queued for; the eviction may follow once nothing is owed. + await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(1)) + expect(dispatch.mock.calls[0]?.[0].clientMessageId).toBe(id) + expect(acquire).toHaveBeenCalledTimes(2) + }) +}) + +// A close abandons what is queued before it stops the child, so a release that then fails still +// leaves every queued message rejected as closed, never blamed on the provider. +describe('a close that stops the child and then fails', () => { + const END_CHILD = { + evict: () => host.close(SESSION) + } satisfies Partial Promise>> + + it.each([ + { end: 'evict', starting: true, kind: 'chatClosed', verdict: null }, + { end: 'evict', starting: false, kind: 'chatClosed', verdict: null } + ] as const)( + 'rejects what is queued as $kind after a $end (during startup: $starting)', + async ({ end, starting, kind, verdict }) => { + const started = deferred() + adapterExtras = { + awaitStarted: () => started.promise, + // Once: the host's own teardown acknowledges again. + acknowledgeSessionRelease: vi.fn().mockImplementationOnce(() => { + throw new Error('release acknowledgement failed') + }) + } + await host.close(SESSION) + await startHost() + acquire.mockImplementationOnce(async (input) => ({ + ...(await spawnChild(input)), + ...(starting ? { providerChildPhase: 'starting' as const } : {}) + })) + const id = await accept('hello') + await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) + + await expect(END_CHILD[end]()).rejects.toThrow() + expect(host.hasSession(SESSION)).toBe(true) + started.resolve() + + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) + const rejected = (await submission(id))! + expect(rejected.rejection).toMatchObject({ kind }) + expect(classifyDispatchRejection(rejected).verdict).toBe(verdict) + expect(dispatch).not.toHaveBeenCalled() + } + ) +}) + +describe('a compaction or rewind an earlier child left prepared', () => { + async function leftPrepared(prepare: (fence: number) => Promise): Promise { + await host.close(SESSION) + await prepare(store.getRecord(SESSION)!.lease.runtimeFence) + // A new process: nothing is open and no view attaches. + await host.flushAllStreamedEvents() + await startHost() + } + + it("ignores an older build's interrupted compaction, so a send is accepted and delivered (R16)", async () => { + await leftPrepared((fence) => + store.setConversationCommand(SESSION, fence, { + command: 'compact', + runtimeFence: fence, + operationId: 'compact-op', + callerKey: 'client-1', + phase: 'prepared', + state: 'unknown' + }) + ) + + const id = await accept('after the compaction') + + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) + // Nothing settles the record: it belongs to a child this host no longer runs. + expect(store.getRecord(SESSION)?.conversationCommand).toMatchObject({ phase: 'prepared' }) + }) + + it('completes a rewind the provider already applied at open, so a send is accepted (R16)', async () => { + await leftPrepared((fence) => + persistRewindRecord(store, SESSION, fence, { + operationId: 'rewind-op', + callerKey: 'client-1', + itemId: 'orca:rewound', + providerItemId: `codex:${THREAD}:turn-1:0`, + expectedEpoch: 'epoch-before', + phase: 'provider-succeeded', + hydrationVerified: true, + retained: [] + }) + ) + + const id = await accept('after the rewind') + + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) + expect(store.getRecord(SESSION)?.rewind).toMatchObject({ phase: 'completed' }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts index e0549e1816e..7c12909e036 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -15,6 +15,7 @@ import { attachFingerprintFields, type AgentSessionAttachParams } from './structured-agent-session-attach' +import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' import { performAttach } from './structured-agent-session-attach-flow' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' @@ -107,7 +108,7 @@ function expectSettledAttachLease(record: AgentSessionRecord | null): void { expect(record).not.toBeNull() const lease = record!.lease const durableState = lease.handoffStage ?? lease.claimStatus - expect(['live', 'released', 'recovering', 'manual-recovery']).toContain(durableState) + expect(['live', 'released', 'recovering']).toContain(durableState) expect(lease.handoffStage).not.toBe('new-owner-proving') } @@ -154,6 +155,7 @@ describe('structured session acquisition options', () => { store: initialStore, adapter: withHistory('created'), journalRoot: root, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -194,6 +196,7 @@ describe('structured session acquisition options', () => { store, adapter: withHistory('resumed'), journalRoot: root, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-b', claimKeyId: 'key-1', @@ -228,6 +231,7 @@ describe('structured session acquisition options', () => { store, adapter: sessionAdapter, journalRoot: root, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -260,6 +264,7 @@ describe('structured session acquisition options', () => { store, adapter: sessionAdapter, journalRoot: root!, + openConversation: openTestAttachConversation(root!), authority: { spawnToken, claimKeyId: 'key-1', @@ -291,6 +296,7 @@ describe('structured session acquisition options', () => { store, adapter: adapter({ origin: 'created' }), journalRoot: root, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -330,6 +336,7 @@ describe('structured session acquisition options', () => { } }), journalRoot: root, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-b', claimKeyId: 'key-1', @@ -369,6 +376,7 @@ describe('structured session acquisition options', () => { store, adapter: sessionAdapter, journalRoot: root, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -408,6 +416,7 @@ describe('structured session acquisition options', () => { store, adapter: failingAdapter, journalRoot: root, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -421,7 +430,10 @@ describe('structured session acquisition options', () => { }) ).resolves.toEqual({ ok: false, - refusal: { code: 'agent_session_operation_invalid', message: 'model list unavailable' } + refusal: { + code: 'agent_session_operation_invalid', + message: "Codex couldn't restart. Send your message to try again." + } }) expect(releaseAcquisition).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease.ownerProcess).toBeNull() @@ -499,6 +511,7 @@ describe('structured session acquisition options', () => { store: target, adapter: failingAdapter, journalRoot: root!, + openConversation: openTestAttachConversation(root!, failingAdapter), authority: { spawnToken: operationId === CREATE_OPERATION ? 'spawn-a' : 'spawn-b', claimKeyId: 'key-1', @@ -511,12 +524,16 @@ describe('structured session acquisition options', () => { onAttached: () => {} }) - // A proven exit before the journal opens is answered once, as the refusal its replay gives. + // A proven exit before the journal opens is answered once, as the refusal its replay gives; + // no exit was observed, so it names no situation. const failed = perform(store, CREATE_OPERATION, null) await (exitProven && failurePoint !== 'journal' ? expect(failed).resolves.toEqual({ ok: false, - refusal: { code: 'agent_session_operation_invalid', message: injected.message } + refusal: { + code: 'agent_session_operation_invalid', + message: "Codex couldn't restart. Send your message to try again." + } }) : expect(failed).rejects.toThrow( exitProven ? injected.message : 'agent_session_acquisition_exit_unproven' @@ -553,14 +570,12 @@ describe('structured session acquisition options', () => { }) await expect(perform(reopened, RESUME_OPERATION, 2)).resolves.toMatchObject({ ok: true }) expectSettledAttachLease(reopened.getRecord(SESSION)) - } else { + } else if (failurePoint === 'proof' || failurePoint === 'journal') { + // A recorded owner goes to recovery, which concludes about it before the next start. expect(failedRecord?.lease).toMatchObject({ runtimeFence: 1, claimStatus: failurePoint === 'journal' ? 'live' : 'reserved', - handoffStage: - failurePoint === 'proof' || failurePoint === 'journal' - ? 'recovering' - : 'manual-recovery', + handoffStage: 'recovering', // The settled operation must not stay named by the lease as an in-flight transfer. handoffOperationId: null, reservedSpawnToken: 'spawn-a' @@ -569,12 +584,25 @@ describe('structured session acquisition options', () => { ok: false, refusal: { code: 'agent_session_ownership_unknown' } }) + } else { + // No owner was recorded, and the adapter closed the stdio of anything it spawned: released, + // with no death evidence, since nothing proved one. + expect(failedRecord?.lease).toMatchObject({ + runtimeFence: 2, + claimStatus: 'released', + handoffStage: null, + handoffOperationId: null, + ownerProcess: null, + reservedSpawnToken: null, + deathEvidence: null + }) + await expect(perform(reopened, RESUME_OPERATION, 2)).resolves.toMatchObject({ ok: true }) } }) }) }) -describe('the tab id a create records', () => { +describe('the tab a create reserves', () => { async function openStore() { root = await mkdtemp(join(tmpdir(), 'orca-surface-tab-id-')) return AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) @@ -585,6 +613,7 @@ describe('the tab id a create records', () => { store, adapter: adapter({ origin: 'created' }), journalRoot: root!, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -603,30 +632,18 @@ describe('the tab id a create records', () => { }) } - it('pins the id the caller reserved on the record and answers with it', async () => { + it('takes no tab at attach, then answers a retry naming another tab with the one it was given', async () => { const store = await openStore() - const result = await attachWith(store, 'chat-tab-1') + const created = await attachWith(store, 'chat-tab-1') + // Publishing the tab takes the id, so a create that never gets there leaves nothing behind. + expect(created.ok && created.value.tabId).toBeUndefined() + expect(store.getSessionTabId(SESSION)).toBeNull() - expect(result).toMatchObject({ ok: true, value: { tabId: 'chat-tab-1' } }) - expect(store.getRecord(SESSION)?.surfaceTabId).toBe('chat-tab-1') - }) - - it('records the id clients derive when the caller reserved none', async () => { - const store = await openStore() - const result = await attachWith(store) - - // Every reader still keys by the derived id, so an unreserved chat must not record another. - const derived = `structured-agent-session-${SESSION}` - expect(result).toMatchObject({ ok: true, value: { tabId: derived } }) - expect(store.getRecord(SESSION)?.surfaceTabId).toBe(derived) - }) - - it('answers a retry that names another tab with the one the record holds', async () => { - const store = await openStore() - await attachWith(store, 'chat-tab-1') - const retried = await attachWith(store, 'chat-tab-2') - - expect(retried).toMatchObject({ ok: true, value: { tabId: 'chat-tab-1' } }) - expect(store.getRecord(SESSION)?.surfaceTabId).toBe('chat-tab-1') + await store.setSessionTabVisibility(SESSION, true, 'chat-tab-1') + expect(await attachWith(store, 'chat-tab-2')).toMatchObject({ + ok: true, + value: { tabId: 'chat-tab-1' } + }) + expect(store.getSessionTabId(SESSION)).toBe('chat-tab-1') }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts index 7968176a5a9..ac2f1823e5f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts @@ -26,16 +26,8 @@ export async function acquireOwner( if (!spawnToken) { throw new Error('agent_session_ownership_unknown') } - // Pre-spawn proof is single-use: this retry may create a child after the durable clear. try { try { - record = await input.store.setReservationProcesslessProof({ - sessionId: record.sessionId, - fence, - spawnToken, - processlessAt: null, - now: input.now() - }) await input.onAcquiring?.() } catch (error) { throw new AgentSessionPreSpawnError(error) @@ -47,7 +39,15 @@ export async function acquireOwner( spawnToken, ...(record.options ? { options: record.options } : {}), ...(input.eventSink ? { events: input.eventSink } : {}), - ...(input.recordPhase ? { recordPhase: input.recordPhase } : {}) + ...(input.recordPhase ? { recordPhase: input.recordPhase } : {}), + onSpawned: async (process) => { + record = await input.store.commitProcessIdentity({ + sessionId: record.sessionId, + fence, + process, + now: input.now() + }) + } }) const providerChildPhase = acquired.providerChildPhase ?? 'ready' // A starting child has proven nothing: the record keeps the reservation's saved options as diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 7fa1446d6c5..bb003f93427 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -1,3 +1,4 @@ +import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionAccountHome, @@ -61,8 +62,8 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => this.owner(input.sessionId).dispatch(input) - rewindSupport: NonNullable = (sessionId) => - this.liveOwnerOrNull(sessionId)?.rewindSupport?.(sessionId) ?? { + rewindSupport: NonNullable = (sessionId, agent) => + this.capabilityOwner(sessionId, agent)?.rewindSupport?.(sessionId) ?? { supported: false, reason: 'unsupported' } @@ -94,11 +95,11 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return change(input) } - supportsThreadGoal = (sessionId: string): boolean => - this.liveOwnerOrNull(sessionId)?.supportsThreadGoal?.(sessionId) ?? false + supportsThreadGoal = (sessionId: string, agent?: string): boolean => + this.capabilityOwner(sessionId, agent)?.supportsThreadGoal?.(sessionId) ?? false - recordsContextUsage = (sessionId: string): boolean => - this.liveOwnerOrNull(sessionId)?.recordsContextUsage?.(sessionId) ?? false + recordsContextUsage = (sessionId: string, agent?: string): boolean => + this.capabilityOwner(sessionId, agent)?.recordsContextUsage?.(sessionId) ?? false stopBackgroundTasks: NonNullable = ( input @@ -122,6 +123,8 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi awaitOptionWritable = (sessionId: string): Promise => this.liveOwnerOrNull(sessionId)?.awaitOptionWritable?.(sessionId) ?? Promise.resolve() + awaitStarted = (sessionId: string): Promise => + this.liveOwnerOrNull(sessionId)?.awaitStarted?.(sessionId) ?? Promise.resolve() readOptions = (input: { sessionId: string; fence: number }) => { const reader = this.owner(input.sessionId).readOptions @@ -214,6 +217,11 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return adapter } + /** The live owner, or for a session at rest the provider it would start under. */ + private capabilityOwner(sessionId: string, agent?: string): StructuredAgentSessionAdapter | null { + return this.liveOwnerOrNull(sessionId) ?? (agent ? this.adapterForAgent(agent) : null) + } + private liveOwnerOrNull(sessionId: string): StructuredAgentSessionAdapter | null { const route = this.routes.get(sessionId) return route?.state === 'live' ? route.adapter : null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 7085700cdcb..df81e8ec1a0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -15,6 +15,7 @@ import type { AgentJournalItemBody, AgentJournalMessageItem, AgentJournalDispatchState, + AgentJournalTurnItem, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle' @@ -27,22 +28,39 @@ import type { AgentSessionBackgroundTaskState, AgentSessionOptionsResult, AgentSessionSlashCommand, - AgentSessionThreadGoalChange, - AgentSessionWireRefusalCode + AgentSessionThreadGoalChange } from '../../../shared/agent-session-wire' -import { isAgentSessionWireRefusalCode } from '../../../shared/agent-session-wire-refusals' +import { + isAgentSessionWireRefusalCode, + type AgentSessionRefusalReason +} from '../../../shared/agent-session-wire-refusals' +import type { + ProviderDiagnostic, + SubmissionRejectionFact +} from '../../../shared/agent-session-failure' +import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' +import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' export class AgentSessionAcquisitionRefusal extends Error { + readonly code = 'agent_session_operation_invalid' + constructor( message: string, - readonly code: AgentSessionWireRefusalCode = 'agent_session_operation_invalid' + /** The situation, so the chat can say what to do; the message is Orca's log wording. Absent, + * the provider refused its own start. */ + readonly reason: AgentSessionRefusalReason<'agent_session_operation_invalid'> = 'providerStartFailed' ) { super(message) this.name = 'AgentSessionAcquisitionRefusal' } + + /** The conversation's history is more than this host can restore. */ + static historyTooLarge(message: string): AgentSessionAcquisitionRefusal { + return new AgentSessionAcquisitionRefusal(message, 'historyTooLarge') + } } export class AgentSessionPromptUnavailableError extends Error { @@ -52,11 +70,19 @@ export class AgentSessionPromptUnavailableError extends Error { } } +/** The provider cannot take this answer. Thrown before the journal commit, so nothing is recorded. */ +export class AgentSessionPromptAnswerRejectedError extends Error { + constructor(message: string) { + super(message) + this.name = 'AgentSessionPromptAnswerRejectedError' + } +} + /** * The provider's own root process was observed to exit, but its descendant tree - * could not be verified. The lease keys on the root's pid and start time, so its - * observed death releases the reservation; nothing is claimed about descendants. - * Never thrown when a descendant was observed still alive — that stays unproven. + * was not proven gone. The lease keys on the root's pid and start time, so its + * observed death releases the reservation; nothing is claimed about descendants, + * including one seen still alive. */ export class AgentSessionAcquisitionRootExitObservedError extends Error { constructor(cause: unknown) { @@ -94,11 +120,24 @@ export type AgentSessionAcquisition = { providerChildPhase?: StructuredAgentSessionProviderChildPhase } +/** A refusal before spawn that a person can act on; the site that refused names it. */ +export type AgentSessionPreSpawnReason = Extract< + AgentSessionRefusalReason<'agent_session_operation_invalid'>, + 'managedAccountEnvOverride' | 'accountSwitchInProgress' | 'managedAccountUnsupported' +> + /** Acquisition failed with first-hand proof that no provider process existed. */ export class AgentSessionPreSpawnError extends Error { - constructor(cause: unknown) { - super(cause instanceof Error ? cause.message : String(cause), { cause }) + /** Absent: Orca's own reason, which only the log reads. A wrapped pre-spawn error keeps its. */ + readonly reason: AgentSessionPreSpawnReason | undefined + + constructor( + cause: unknown, + options: { reason?: AgentSessionPreSpawnReason; message?: string } = {} + ) { + super(options.message ?? (cause instanceof Error ? cause.message : String(cause)), { cause }) this.name = 'AgentSessionPreSpawnError' + this.reason = options.reason ?? (isAgentSessionPreSpawnError(cause) ? cause.reason : undefined) } } @@ -106,6 +145,23 @@ export function isAgentSessionPreSpawnError(error: unknown): error is AgentSessi return error instanceof Error && error.name === 'AgentSessionPreSpawnError' } +/** A conversation command the host handed to a provider child: the running turn it opened for it, + * which the child's translator ends, and where the command's own result row goes. */ +export type StructuredAgentSessionCommandRun = { + clientMessageId: string + /** What a Stop names. */ + turnId: string + identity: AgentJournalItemIdentity + resultIdentity: AgentJournalItemIdentity + /** The turn record as the host wrote it; the translator's end revises it. */ + running: AgentJournalTurnItem +} + +/** A command the provider took answers it in place and echoes no item of its own. */ +export type AgentSessionCommandAdmission = + | AgentSessionDispatchOutcome + | { state: 'accepted'; providerIdentity: null } + export type AgentSessionDispatchOutcome = /** The provider owns the turn now, under this identity. */ | { state: 'accepted'; providerIdentity: AgentJournalItemIdentity } @@ -116,21 +172,24 @@ export type AgentSessionDispatchOutcome = * anything and never promotes this to `unknown`. */ | { state: 'admitted' } - | { state: 'rejected'; reason: string } + /** Words from `agentSessionFailureWords`, never written by hand. */ + | ({ state: 'rejected' } & AgentJournalDispatchRejection) /** The call did not settle. Never re-send on the user's behalf. */ | { state: 'unknown'; reason: string } export type StructuredAgentSessionEndedEvent = { type: 'ended' sessionId: string + /** Log text only; the chat's words come from `failure`. */ reason: string + /** Why it ended, as the adapter knows it: the provider's exit with its own diagnostic, or an + * Orca fault. Absent reads as a provider exit with nothing to add. */ + failure?: SubmissionRejectionFact cause: 'unexpected-exit' | 'requested-close' fence: number acquisitionGeneration: string - /** Host receipt of the child exit, retained across settlement retries. */ + /** Host receipt of the child exit: the end time of a turn it interrupted. */ observedAt?: number - /** Translator could not admit terminal rows; host recovery must append its bounded fallback. */ - settlementRetryRequired?: boolean /** The provider ended before it finished starting, so resuming it would repeat the failure. */ startupUnproven?: true } @@ -165,6 +224,9 @@ export type StructuredAgentSessionAcquireInput = { /** Provider events may begin before acquisition returns. */ events?: StructuredAgentSessionEventSink recordPhase?: AgentSessionCreatePhaseRecorder + /** Durably records the child's identity the moment it exists, before any handshake, so a crash + * mid-start leaves an owner recovery can stop. The acquisition's `process` must match it. */ + onSpawned?: (process: AgentSessionProcessIdentity) => Promise } export type StructuredAgentSessionSetOptionInput = { @@ -174,6 +236,14 @@ export type StructuredAgentSessionSetOptionInput = { fence: number } +/** `refusal`: the provider answered the Stop and declined it, in its own words when it gave any. + * `unconfirmed`: the provider took the Stop, but Orca could not confirm the turn's work ended. */ +export type AgentSessionCancelOutcome = { + cancelled: boolean + refusal?: { detail?: ProviderDiagnostic } + unconfirmed?: true +} + export type StructuredAgentSessionAdapter = { /** Provider-aware capability check for hosts that route more than one adapter. */ supportsCreate?(location: AgentSessionExecutionLocation, agent: string): boolean @@ -186,7 +256,7 @@ export type StructuredAgentSessionAdapter = { /** Reaps an acquired provider when the host cannot commit or prove its lease. * Returns true only after provider child exit is proven. Throws * `AgentSessionAcquisitionRootExitObservedError` when the provider root's own - * exit was observed first-hand but its descendants could not be verified. */ + * exit was observed first-hand but its descendants were not proven gone. */ releaseAcquisition?(input: { sessionId: string }): Promise dispatch(input: { sessionId: string @@ -199,7 +269,8 @@ export type StructuredAgentSessionAdapter = { /** Revalidate after preparation, immediately before writing to the provider. */ beforeDispatch?: () => Promise }): Promise - rewindSupport?(sessionId: string): AgentSessionRewindSupport + /** `agent` answers for a session with no child running, from the provider alone. */ + rewindSupport?(sessionId: string, agent?: string): AgentSessionRewindSupport recoverRewind?(input: { sessionId: string fence: number @@ -215,22 +286,24 @@ export type StructuredAgentSessionAdapter = { onPrepared?: ( items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] ) => Promise - onReverted?: () => Promise }): Promise< | { ok: true; items?: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] } | { ok: false; reason: AgentSessionRewindReason } > + /** Sends a conversation compaction and answers with the provider's receipt of it, as a dispatch + * does. The command's turn is the child's journal translator's to end, from the provider's own + * frames; a child that ends first leaves it to the host's settlement of that child. */ compact?(input: { - turnId: string sessionId: string fence: number - onLateResult?: (result: { error?: string }) => Promise - }): Promise<{ error?: string }> + command: StructuredAgentSessionCommandRun + }): Promise /** Cancels one turn, not the session: a session-wide interrupt would also kill - * a turn the client never asked to stop. */ + * a turn the client never asked to stop. With no `turnId` the conversation asked to stop + * everything it has in flight — a running turn, or a dispatch whose turn has not opened yet. */ cancelTurn(input: { sessionId: string - turnId: string + turnId?: string fence: number prompt?: { itemId: string } /** Latest journal submission for this fence, when the host has one. */ @@ -239,7 +312,7 @@ export type StructuredAgentSessionAdapter = { * could have named. A function, not a value, because the guard re-checks after the * delivery fence may have waited. Absent for direct callers with no journal. */ resolveLiveTurnId?: () => string | null - }): Promise<{ cancelled: boolean }> + }): Promise /** Changes the provider thread's goal. `rejected` is the provider refusing the * change; a throw leaves its effect unknown. Absent where no goal exists. */ changeThreadGoal?(input: { @@ -250,10 +323,10 @@ export type StructuredAgentSessionAdapter = { * start a new goal rather than rewrite that one's objective in place. */ replacesGoal: boolean }): Promise<{ ok: true } | { ok: false; rejected: string }> - /** Whether this live session can change its goal. */ - supportsThreadGoal?(sessionId: string): boolean - /** Whether this live session writes context facts to its turn rows. */ - recordsContextUsage?(sessionId: string): boolean + /** Whether this session can change its goal; `agent` answers one at rest. */ + supportsThreadGoal?(sessionId: string, agent?: string): boolean + /** Whether this session writes context facts to its turn rows; `agent` answers one at rest. */ + recordsContextUsage?(sessionId: string, agent?: string): boolean stopBackgroundTasks?(input: { sessionId: string fence: number @@ -263,13 +336,14 @@ export type StructuredAgentSessionAdapter = { /** The `/` surface the running provider reports for itself. Undefined when the * provider never reports one, which is what keeps the client on its catalog. */ readCommands?(sessionId: string): AgentSessionSlashCommand[] | undefined - /** Claims the live callback, commits the journal CAS while that claim is held, then answers it. - * A prompt cancel claims the same callback, so only one operation can commit. */ + /** Claims the live callback, builds the provider reply, commits the journal CAS while that claim is + * held, then answers it. A reply that cannot be built throws `AgentSessionPromptAnswerRejectedError` + * before the commit. A prompt cancel claims the same callback, so only one operation can commit. */ answerPrompt(input: { sessionId: string itemId: string kind: 'approval' | 'question' - optionId: string + response: AgentSessionPromptResponse fence: number commit: () => Promise }): Promise @@ -278,6 +352,10 @@ export type StructuredAgentSessionAdapter = { ): Promise>> /** Resolves once a live session can take an option write, or after a bound; never rejects. */ awaitOptionWritable?(sessionId: string): Promise + /** Resolves once a session published before it proved its start has proven it, failed, or been + * closed; at once for any other. A start that did not land resolves with the chat's words for + * why. Never rejects. */ + awaitStarted?(sessionId: string): Promise readOptions?(input: { sessionId: string; fence: number }): Promise /** Option keys skipped after a provider rejected their persisted restore value. */ readOptionRestoreFailures?(sessionId: string): readonly string[] @@ -343,8 +421,8 @@ function provenExitAcquisitionFailure(cause: unknown): unknown { } /** Whether a stop left the provider root gone. The lease follows the root, so a first-hand root - * exit or a processless child ends the session even with descendants unverified; any other - * failure, including known-live descendants, still throws. */ + * exit or a processless child ends the session whatever its descendants did; any other + * failure still throws. */ export async function stopAgentSessionProviderRoot(stop: () => Promise): Promise { try { return (await stop()) === true diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index 0248799980a..371b942c2f4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Source validation must finish before a new session claims the provider conversation. import { mkdtemp, rm, writeFile, truncate } from 'node:fs/promises' @@ -11,10 +12,12 @@ import { attachFingerprintFields, type AgentSessionAttachParams } from './structured-agent-session-attach' +import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' import { performAttach, type AttachFlowInput } from './structured-agent-session-attach-flow' import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { agentSessionJournalCloseRetries } from '../agent-session-journal/journal-close-retry' import * as legacyImport from '../agent-session-journal/journal-legacy-import' +import { StructuredAgentSessionHost } from './structured-agent-session-host' const NOW = 1_800_000_000_000 const SESSION = 'codex_adopting_session' @@ -121,6 +124,7 @@ async function attach( store, adapter: sessionAdapter, journalRoot: root!, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -159,7 +163,7 @@ describe('adopting a provider conversation on create', () => { await journal.appendItem( { provider: 'legacy', agent: 'codex', sessionId: THREAD, recordId: 'journal-only' }, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'not yet in rollout' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.close() }) @@ -210,7 +214,7 @@ describe('adopting a provider conversation on create', () => { } ) - it('still releases acquisition and closes the provisional journal on an import write failure', async () => { + it('still releases acquisition on an import write failure, and leaves the conversation open', async () => { root = await mkdtemp(join(tmpdir(), 'orca-adopt-write-failure-')) const transcriptPath = join(root, 'rollout.jsonl') await writeCodexRollout(transcriptPath, 'valid source') @@ -222,7 +226,51 @@ describe('adopting a provider conversation on create', () => { await expect(attach(transcriptPath, sessionAdapter)).rejects.toThrow('disk write failed') expect(sessionAdapter.acquire).toHaveBeenCalledTimes(1) expect(sessionAdapter.releaseAcquisition).toHaveBeenCalledTimes(1) - expect(close).toHaveBeenCalledTimes(1) + // The journal is the conversation's, not the attach's: a failed import closes nothing. + expect(close).not.toHaveBeenCalled() + }) + + it('leaves the conversation writable when the import fails after acquiring', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-adopt-host-failure-')) + const transcriptPath = join(root, 'rollout.jsonl') + await writeCodexRollout(transcriptPath, 'valid source') + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + const host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) + vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems').mockRejectedValueOnce( + new Error('disk write failed') + ) + const attached = await host + .attach({ callerKey: 'client-1' }, attachParams(transcriptPath)) + .catch(() => null) + expect(attached?.ok).not.toBe(true) + + const body = { kind: 'message' as const, role: 'user' as const, blocks: [] } + const sent = await host.send( + { callerKey: 'client-1' }, + { + envelope: { + sessionId: SESSION, + clientOperationId: `${NOW}-${'2'.padStart(32, '0')}`, + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + } + ) + // The failed attach kept the conversation's own journal open, so the send is recorded. + expect(sent).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) + await host.flushAllStreamedEvents() }) it('prepares a valid source once before acquisition and imports those exact items', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts index 459d21b1f3b..3eccc4f9457 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts @@ -1,7 +1,11 @@ -import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import { + agentSessionRefusalError, + isAgentSessionRefusalError, + refuse, + type AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionAttachParams, AttachedJournal } from './structured-agent-session-attach' -import { agentSessionJournalCloseRetries } from '../agent-session-journal/journal-close-retry' import type { JournalReplacementItem } from '../agent-session-journal/journal-epoch-replacement' import { importLegacyTranscriptIntoJournal, @@ -19,10 +23,13 @@ export async function prepareAdoptedTranscript( } catch (error) { return { ok: false, - refusal: { - code: 'agent_session_identity_required', - message: error instanceof Error ? error.message : String(error) - } + refusal: isAgentSessionRefusalError(error) + ? error.refusal + : refuse( + 'agent_session_identity_required', + { reason: 'transcriptUnreadable' }, + error instanceof Error ? error.message : String(error) + ) } } } @@ -36,7 +43,9 @@ async function readAdoptedTranscript( return null } if (!adopt.transcriptPath) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptNotFound' + }) } const prepared = await prepareLegacyTranscriptImport({ agent: params.agent, @@ -50,7 +59,9 @@ async function readAdoptedTranscript( throw new Error(prepared.error) } if (prepared.items.length === 0) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptUnreadable' + }) } return prepared.items } @@ -62,13 +73,8 @@ export async function importAdoptedTranscript( record: AgentSessionRecord, prepared: JournalReplacementItem[] | null ): Promise { - try { - await applyAdoptedTranscript(params, attached, record, prepared) - } catch (error) { - // Publication has not taken ownership of this provisional journal yet. - await agentSessionJournalCloseRetries.closeOrRetain(attached.journal) - throw error - } + // The journal is the conversation's, which outlives a failed import; nothing here closes it. + await applyAdoptedTranscript(params, attached, record, prepared) } async function applyAdoptedTranscript( @@ -87,7 +93,9 @@ async function applyAdoptedTranscript( return } if (!adopt.transcriptPath) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptNotFound' + }) } const imported = await importLegacyTranscriptIntoJournal({ journal: attached.journal, @@ -105,6 +113,8 @@ async function applyAdoptedTranscript( // `replaced: false` means the transcript decoded to nothing. The row promised a conversation and // the provider resumed one, so an empty journal here is a disagreement, not an empty chat. if (!imported.replaced) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptUnreadable' + }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts new file mode 100644 index 00000000000..a4c93fa00f8 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts @@ -0,0 +1,212 @@ +// Giving a session its provider child back. +// +// Only work starts one: the delivery loop for a queued message, and the few operations that need +// the provider itself — never a view, and never the app launching. It runs inside the session's +// serialize, with the attach it is given, so the eligibility it reads is the one the attach acts +// on. + +import { + providerDiagnosticOf, + type ProviderDiagnostic +} from '../../../shared/agent-session-failure' +import { + agentSessionRefusalFromReference, + readAgentSessionRefusalReference, + refuse, + refuseUnclassified, + type AgentSessionRefusalDetailsByCode, + type AgentSessionWireRefusalCode +} from '../../../shared/agent-session-wire-refusals' +import { agentSessionWriteNoticeEnglish } from '../../../shared/agent-session-refusal-notice' +import type { + AgentSessionAttachResult, + AgentSessionMutationResult, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import { terminalOwnerRefusalMessage } from '../../../shared/agent-session-legacy-handoff-lease' +import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' +import { attachStructuredAgentSessionUnderSerialize } from './structured-agent-session-attach-orchestration' +import { failedCreateRefusal } from './structured-agent-session-failed-create-refusal' +import { adapterSupportsRecord } from './structured-agent-session-provider-support' +import { + structuredAgentSessionResumeOperationId, + structuredAgentSessionResumeParams +} from './structured-agent-session-resume-eligibility' + +/** A resume answers with the attach's own refusal, verdict and all, so the asker can tell a lease + * someone else is settling from an owner that will not come back. */ +export type StructuredAgentSessionResumeOutcome = + | { ok: true } + | { + ok: false + refusal: AgentSessionWireRefusal + /** What the provider said about the failed start, for the chat's own record; host-side + * only, never on the refusal. */ + diagnostic?: ProviderDiagnostic + } + +/** The attach's caller key: the ledger row a start settles is Orca's own. */ +const AGENT_START_CALLER_KEY = 'trusted-local:agent-start' + +/** + * Gives the session a provider child if it has none, for a caller inside its serialize — which is + * what makes "if it has none" exact: two askers run this in turn, and the second finds the first + * one's child. A failed start leaves the next asker to make its own. + */ +export async function ensureStructuredAgentSessionAgent( + context: StructuredAgentSessionAttachContext, + sessionId: string, + startedFor?: string +): Promise { + if (context.sessions.get(sessionId)?.child) { + return { ok: true } + } + const started = await startStructuredAgentSessionAgent(context, sessionId, startedFor) + if (!started.ok || context.sessions.get(sessionId)?.child) { + return started + } + return refuseResume( + 'agent_session_ownership_unknown', + { reason: 'noProviderChild' }, + 'The session attached without a provider child to write to.' + ) +} + +/** The same, for an operation's admission: a start that throws is that operation's refusal. */ +export function ensureStructuredAgentSessionAgentForOperation( + context: StructuredAgentSessionAttachContext, + sessionId: string +): Promise { + return ensureStructuredAgentSessionAgent(context, sessionId).catch((error: unknown) => { + // The error is Orca's own and goes to the log; the refusal says only that the start failed. + console.warn('[agent-session] starting the agent for an operation failed:', error) + return { + ok: false, + refusal: refuseUnclassified( + 'agent_session_owner_restart_failed', + agentSessionWriteNoticeEnglish(['restartFailed']) + ) + } + }) +} + +async function startStructuredAgentSessionAgent( + context: StructuredAgentSessionAttachContext, + sessionId: string, + startedFor: string | undefined +): Promise { + const callerKey = AGENT_START_CALLER_KEY + // The record is read only once this host has adjudicated it and recovery resolution has + // concluded about any owner a failed attempt left in `recovering`, so the eligibility below sees + // the lease the resolver handed back. + const unreconciled = await context.reconcileLeases(sessionId) + if (unreconciled) { + return { ok: false, refusal: unreconciled } + } + await context.runtimeState.resolveRecovery(sessionId) + const record = context.deps.store.getRecord(sessionId) + if (!record) { + return refuseResume( + 'agent_session_identity_required', + { reason: 'recordMissing' }, + 'No structured session exists by that id.' + ) + } + if (!adapterSupportsRecord(context.deps.adapter, record)) { + return refuseResume( + 'structured_agent_session_unsupported', + { reason: 'hostUnsupported' }, + 'This execution host cannot resume the requested structured agent session.' + ) + } + const params = structuredAgentSessionResumeParams( + record, + structuredAgentSessionResumeOperationId(context.now()) + ) + if (!params) { + return record.lease.unreconciled + ? refuseResume( + 'execution_owner_reconciling', + { reason: 'hostReconciling' }, + 'This host has not yet adjudicated the session lease.' + ) + : record.lease.claimStatus === 'conflicted' + ? refuseResume( + 'agent_session_conflict', + { reason: 'claimConflicted' }, + terminalOwnerRefusalMessage(record.lease) + ) + : refuseResume( + 'agent_session_ownership_unknown', + { reason: 'notResumable' }, + 'The session lease is not one this host may resume.' + ) + } + let attached: AgentSessionMutationResult + let acquisitionError: unknown + try { + attached = await attachStructuredAgentSessionUnderSerialize(context, callerKey, params, { + ...(startedFor === undefined ? {} : { startedFor }), + onAcquisitionFailed: (error) => { + acquisitionError = error + } + }) + } catch (error) { + // The attach settles an acquisition that failed — the ledger row, the released lease — before + // it rethrows the cause. That row is the answer: a failure it recorded is this resume's + // refusal, verdict and all. Only an error it did not record is a fault for the caller. + const settled = settledResumeRefusal( + context, + callerKey, + params.envelope.clientOperationId, + sessionId, + error + ) + if (settled) { + return withDiagnostic(settled.refusal, error) + } + throw error + } + return attached.ok ? { ok: true } : withDiagnostic(attached.refusal, acquisitionError) +} + +function withDiagnostic( + refusal: AgentSessionWireRefusal, + error: unknown +): StructuredAgentSessionResumeOutcome { + const diagnostic = providerDiagnosticOf(error) + return { ok: false, refusal, ...(diagnostic ? { diagnostic } : {}) } +} + +function settledResumeRefusal( + context: Pick, + callerKey: string, + operationId: string, + sessionId: string, + error: unknown +): { ok: false; refusal: AgentSessionWireRefusal } | null { + const outcome = context.deps.store.getOperationRow(callerKey, operationId)?.outcome + const reference = + outcome?.status === 'failed' + ? readAgentSessionRefusalReference({ code: outcome.code, details: outcome.details }) + : undefined + if (outcome?.status !== 'failed' || !reference) { + return null + } + return failedCreateRefusal( + agentSessionRefusalFromReference( + reference, + outcome.message ?? (error instanceof Error ? error.message : String(error)) + ), + outcome.status, + context.deps.store.getRecord(sessionId) + ) +} + +function refuseResume( + code: C, + details: NoInfer, + message: string +): StructuredAgentSessionResumeOutcome { + return { ok: false, refusal: refuse(code, details, message) } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts new file mode 100644 index 00000000000..dbb3a63e7b5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts @@ -0,0 +1,256 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +// What an open chat receives, asserted at its subscriber rather than in the journal: a fresh +// subscribe re-reads the journal and hides a write that never reached the readers already open. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } +const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquire: Mock +let generation = 0 + +/** Everything a live subscriber was sent after it opened. */ +function liveReader() { + const events: AgentSessionSubscribeEvent[] = [] + host.subscribe({ id: 'pane', sessionId: SESSION, emit: (event) => events.push(event) }) + const opened = events.length + const received = () => { + const items: AgentJournalRenderItem[] = [] + const submissions: AgentJournalSubmission[] = [] + for (const event of events.slice(opened)) { + if (event.type === 'batch') { + items.push(...event.batch.items) + submissions.push(...event.batch.submissions) + } else if (event.type === 'snapshot' || event.type === 'reset') { + items.push(...event.page.items) + submissions.push(...event.page.submissions) + } + } + const rows = new Map() + for (const item of items) { + if (item.body.kind === 'status') { + rows.set(item.itemId, item.body.text) + } + } + return { + statuses: items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])), + /** Each status row as the chat renders it: its latest revision, once. */ + statusRows: [...rows.values()], + submissions, + batches: events.slice(opened).filter((event) => event.type === 'batch').length + } + } + return { received } +} + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + expect(sent, JSON.stringify(sent)).toMatchObject({ ok: true }) + return sent.ok ? sent.value.clientMessageId : '' +} + +function exitBeforeProof(): Promise { + return host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + acquisitionGeneration: `generation-${generation}`, + reason: EXIT_REASON, + failure: { kind: 'providerExited', detail: { text: EXIT_REASON, audience: 'log' } }, + cause: 'unexpected-exit', + startupUnproven: true + }) +} + +function providerSink() { + const events = acquire.mock.calls.at(-1)?.[0].events + if (!events) { + throw new Error('no acquired provider sink') + } + return events +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-append-delivery-')) + resetHostTestOperationIds() + generation = 0 + acquire = vi.fn(async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: generation === 0 ? ('created' as const) : ('resumed' as const), + mintedAtFence: fence, + observedAt: NOW + }, + acquisitionGeneration: `generation-${++generation}`, + providerChildPhase: 'starting' as const + })) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire, + releaseAcquisition: vi.fn(async () => true), + closeSession: vi.fn(async () => true), + dispatch: vi.fn(async () => ({ state: 'admitted' as const })), + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${generation + 1}`, + now: () => NOW + }) + await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ + ok: true + }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('an open chat receives every row its journal commits', () => { + it('shows a failed start whose lease could not be handed back', async () => { + const held = await send('hello') + const pane = liveReader() + // The exit settles the journal, then fails to release the lease: nothing moves the fence. + vi.spyOn(store, 'transitionHandoff').mockRejectedValueOnce(new Error('record store busy')) + + await exitBeforeProof() + + // The exit ends the child; the delivery loop, which reads why, rejects what it had queued. + await vi.waitFor(() => + expect(pane.received().submissions).toContainEqual( + expect.objectContaining({ clientMessageId: held, dispatchState: 'rejected' }) + ) + ) + // One row, however many of its writers reported the start. + expect(pane.received().statusRows).toEqual([ + 'Codex stopped before it finished starting. Send your message to try again.' + ]) + }) + + it('shows a revision the provider queued with no publish behind it', async () => { + const pane = liveReader() + const identity = { provider: 'orca' as const, clientMessageId: 'context-usage' } + const body = { kind: 'status' as const, text: 'context usage answered after the turn' } + + expect( + providerSink().tryReviseResolvedItem?.(4_096, () => ({ identity, body }), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).toEqual({ + accepted: true + }) + await host.flushStreamedEvents(SESSION) + + expect(pane.received().statuses).toEqual(['context usage answered after the turn']) + }) + + it('shows a row appended straight to the journal', async () => { + const pane = liveReader() + const journal = host['sessions'].get(SESSION)?.journal + if (!journal) { + throw new Error('the attached chat has no journal') + } + + await journal.appendItem( + { provider: 'orca', clientMessageId: 'host-note' }, + { kind: 'status', text: 'written by a writer that publishes nothing' }, + { + fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ) + + expect(pane.received().statuses).toEqual(['written by a writer that publishes nothing']) + }) +}) + +describe('an open chat receives each row once', () => { + it('when the provider frame that wrote it also publishes', async () => { + const pane = liveReader() + const sink = providerSink() + const journal = host['sessions'].get(SESSION)?.journal + if (!journal) { + throw new Error('the attached chat has no journal') + } + const readSince = vi.spyOn(journal, 'readSince') + + sink.appendItem( + { provider: 'orca', clientMessageId: 'streamed' }, + { kind: 'status', text: 'streamed row' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + sink.publish() + await host.flushStreamedEvents(SESSION) + + expect(pane.received().statuses).toEqual(['streamed row']) + // The frame's publish finds the reader caught up and reads no rows: streaming stays one read. + expect(readSince).toHaveBeenCalledOnce() + }) + + it('when a writer publishes the row it appended', async () => { + const pane = liveReader() + const journal = host['sessions'].get(SESSION)?.journal + if (!journal) { + throw new Error('the attached chat has no journal') + } + + await journal.appendItem( + { provider: 'orca', clientMessageId: 'host-row' }, + { kind: 'status', text: 'host row' }, + { + fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ) + host['subscribers'].publish(SESSION, journal) + + expect(pane.received().statuses).toEqual(['host row']) + // The second publish found nothing past the reader's cursor, so it sent nothing at all. + expect(pane.received().batches).toBe(1) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts index 4b9b46716cb..90c638c23fb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts @@ -15,6 +15,7 @@ import type { } from './structured-agent-session-host-types' import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import type { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' +import type { StructuredAgentSessionConversationOpenOptions } from './structured-agent-session-conversation-open' export type StructuredAgentSessionAttachContext = { deps: StructuredAgentSessionHostDeps @@ -38,8 +39,10 @@ export type StructuredAgentSessionAttachContext = { reconcileLeases: (sessionId: string) => Promise serialize: (sessionId: string, task: () => Promise) => Promise now: () => number - /** Paired with `sessions.delete` by `forgetStructuredAgentSession`; a failed attach that only - * deleted would leave the store's row behind. */ - forgetStatus: (sessionId: string) => void - publishStatus?: (sessionId: string) => void + publishStatus: (sessionId: string) => void + /** The conversation's one open journal, opened when closed; see `conversation-open`. */ + openConversation: ( + sessionId: string, + options?: StructuredAgentSessionConversationOpenOptions + ) => Promise } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts index 3a77aa2d6cc..16af00e32ea 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts @@ -24,8 +24,7 @@ export async function settlePostAcquisitionAttachFailure( ? 'root-exit-observed' : 'exit-proven' } - // A failed close must not prevent durable failure settlement. - await Promise.resolve(input.onAttachFailed?.()).catch(() => undefined) + input.onAcquisitionReleased?.(cause, { rootGone: exitProof !== 'unproven' }) try { await input.store.settleFailedPostAcquisitionAttachment({ sessionId: record.sessionId, @@ -36,6 +35,7 @@ export async function settlePostAcquisitionAttachFailure( outcome: { status: 'failed', code: 'agent_session_operation_invalid', + details: { reason: 'attachFailed' }, message: cause instanceof Error ? cause.message : String(cause) }, exitProof, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index ab9c44949e0..8fdee5c62bd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -1,7 +1,9 @@ +import { refuse } from '../../../shared/agent-session-wire-refusals' import { settlePostAcquisitionAttachFailure } from './structured-agent-session-attach-failure' import { failedAcquisitionRefusal, - failedAcquisitionSettlement + failedAcquisitionSettlement, + preSpawnFailureInWords } from './structured-agent-session-failed-create-refusal' import type { StructuredAgentSessionAdapter, @@ -41,6 +43,7 @@ import { type AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' export type AttachFlowInput = { store: AgentSessionRecordStore @@ -66,8 +69,14 @@ export type AttachFlowInput = { onAcquiring?: () => Promise | void /** Settles writes already captured by the superseded journal before opening another. */ beforeJournalOpen?: () => Promise | void - /** Closes and removes partial publication after journal attachment fails. */ - onAttachFailed?: () => Promise + /** The conversation's own open journal, which the attach adopts: it never opens one itself. */ + openConversation: (record: AgentSessionRecord) => Promise + /** A failure after acquisition released the session's acquisition; `cause` is that failure and + * `rootGone` whether the release saw the provider root go. */ + onAcquisitionReleased?: (cause: unknown, verdict: { rootGone: boolean }) => void + /** The error an acquisition failed with, for a host-side reader of the provider's words; the + * refusal never carries them. */ + onAcquisitionFailed?: (error: unknown) => void } export async function performAttach( @@ -76,10 +85,11 @@ export async function performAttach( const { params, store } = input const unsupported = (): AgentSessionMutationResult => ({ ok: false, - refusal: { - code: 'structured_agent_session_unsupported', - message: 'This execution host cannot create the requested structured agent session.' - } + refusal: refuse( + 'structured_agent_session_unsupported', + { reason: 'hostUnsupported' }, + 'This execution host cannot create the requested structured agent session.' + ) }) const sessionId = params.envelope.sessionId const admitted = admitAttachOrRefuse(params) @@ -168,6 +178,10 @@ export async function performAttach( acquiredOwner = true } } catch (error) { + const wording = { + record: reservedRecord ?? store.getRecord(sessionId), + newSession: !params.providerHandle + } const spawnToken = reservedRecord?.lease.reservedSpawnToken if (reservedRecord && spawnToken && !unsupportedReservationSettlementAttempted) { // Settle processless proof and failed operation atomically. @@ -178,7 +192,7 @@ export async function performAttach( spawnToken, callerKey: input.callerKey, operationId: params.envelope.clientOperationId, - ...failedAcquisitionSettlement(error), + ...failedAcquisitionSettlement(error, wording), now: input.now() }) } catch (settlementError) { @@ -188,11 +202,18 @@ export async function performAttach( ) } } + input.onAcquisitionFailed?.(error) + const failed = failedAcquisitionRefusal(error, wording) + const thrown = failed ? error : preSpawnFailureInWords(error, wording) + if (failed || thrown !== error) { + // The answer carries only its sentence, so what failed is kept here. + console.warn('[agent-session] provider start failed:', error) + } return ( - failedAcquisitionRefusal(error) ?? { + failed ?? { ok: false, refusal: classifyStoreFailure( - error, + thrown, store.getRecord(sessionId)?.lease.runtimeFence ?? null, store.getRecord(sessionId) ) @@ -208,6 +229,7 @@ export async function performAttach( params, journalRoot: input.journalRoot, adapter: input.adapter, + openConversation: input.openConversation, providerHistoryWindow }) await importAdoptedTranscript(params, attached, record, preparedTranscript.items) @@ -222,6 +244,7 @@ export async function performAttach( } const fence = record.lease.runtimeFence + const tabId = store.getSessionTabId(sessionId) return { ok: true, replayed, @@ -232,7 +255,7 @@ export async function performAttach( fence, page: readAgentSessionHydrationPage(attached.journal, fence), unconfirmedClientMessageIds: attached.unconfirmedClientMessageIds, - ...(record.surfaceTabId ? { tabId: record.surfaceTabId } : {}) + ...(tabId ? { tabId } : {}) } } } @@ -276,6 +299,7 @@ async function settleUnsupportedReservation( outcome: { status: 'failed', code: 'structured_agent_session_unsupported', + details: { reason: 'hostUnsupported' }, message: 'Structured session support changed before the provider could start.' }, exitProof: 'processless', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 5482826d072..6fc715022aa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -1,5 +1,5 @@ +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { recoverStructuredRewind } from './structured-rewind-recovery' -import { recoverInterruptedCompaction } from './structured-compaction-recovery' // The host's attach, lifted out of the host class. // // Attach is the one operation that touches every collaborator the host owns — the lease @@ -14,20 +14,26 @@ import type { AgentSessionTurnActivity } from '../../../shared/agent-session-wire' import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { performAttach } from './structured-agent-session-attach-flow' +import { performAttach, type AttachFlowInput } from './structured-agent-session-attach-flow' import { stampFailedCreateOwnerVerdict } from './structured-agent-session-failed-create-refusal' import { pinnedAgentSessionLaunchArgs, pinnedAgentSessionLaunchEnv } from './structured-agent-session-launch-env' import { refuseAgentSessionMutation } from './structured-agent-session-mutation-admission' -import { isResumableStructuredAgentSessionRecord } from './structured-agent-session-resume-eligibility' -import { retryPendingStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' -import { settleStaleSessionStateOnAcquire } from './structured-agent-session-stale-turn-verdict' +import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' -import { forgetStructuredAgentSession } from './structured-agent-session-host-lifetime' +import type { + StructuredAgentSessionProviderChild, + StructuredAgentSessionStopVerdict +} from './structured-agent-session-host-types' +import { + endProviderChild, + indexProviderChild, + structuredAgentSessionConversationFence +} from './structured-agent-session-provider-child' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import { agentSessionJournalCloseRetries } from '../agent-session-journal/journal-close-retry' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { addAgentSessionCreatePhaseAttributes, @@ -37,17 +43,18 @@ import { } from '../../observability/agent-session-instrumentation' export type StructuredAgentSessionAttachOptions = { - /** Provider-exit recovery: refuses once the ticket the restart was issued for is stale. */ - admitRecoveryTicket?: () => boolean recordPhase?: AgentSessionCreatePhaseRecorder + onAcquisitionFailed?: AttachFlowInput['onAcquisitionFailed'] + /** The queued message a start is for; see `StructuredAgentSessionProviderChild.startedFor`. */ + startedFor?: string } /** * The attach itself, for a caller already inside the session's serialize. * - * That is every caller that has to know what the session looks like RIGHT NOW: a hold, a send - * making sure it has an owner, provider-exit recovery. They run their - * check and this attach in one serialized step, so "the session has no child" is still true when + * That is every caller that has to know what the session looks like RIGHT NOW: the delivery loop, + * and an operation that needs the provider. They run their check and this attach in one serialized + * step, so "the session has no child" is still true when * the attach starts. `attachStructuredAgentSession` is this under `serialize`, for a client. */ export function attachStructuredAgentSessionUnderSerialize( @@ -96,12 +103,10 @@ async function runAttach( ): Promise> { const sessionId = params.envelope.sessionId const recordPhase = options.recordPhase - if (options.admitRecoveryTicket && !options.admitRecoveryTicket()) { - return refuseAgentSessionMutation({ - code: 'agent_session_checkpoint_stale', - message: 'The provider-exit recovery ticket is no longer current.' - }) - } + // Readers of a conversation already open are re-baselined when this attach moves its fence. + const fenceBefore = context.sessions.has(sessionId) + ? structuredAgentSessionConversationFence(context.deps.store, sessionId) + : null const unreconciled = await withAgentSessionCreatePhase('reconcile_leases', recordPhase, () => context.reconcileLeases(sessionId) ) @@ -111,42 +116,23 @@ async function runAttach( await withAgentSessionCreatePhase('resolve_recovery', recordPhase, () => context.runtimeState.resolveRecovery(sessionId) ) - // Retries a durable provider-exit journal settlement before a new owner is reserved. Answers - // settled when the record has none pending, so every attach can ask unconditionally. - const settled = await withAgentSessionCreatePhase('settlement_retry', recordPhase, () => - retryPendingStructuredAgentSessionSettlement({ - deps: context.deps, - sessions: context.sessions, - sessionId, - params, - now: () => context.now() - }) - ) - if (!settled) { - return refuseAgentSessionMutation({ - code: 'agent_session_ownership_unknown', - message: 'The provider-exit terminal journal settlement is still pending; retry attach.' - }) - } const probe = await withAgentSessionCreatePhase('probe_owner', recordPhase, () => context.runtimeState.probeOwner(sessionId) ) // A child this attach spawns writes through a sink this attempt owns. Only a successful - // attach makes it the session's; any other exit closes it with whatever the child queued. + // attach makes the child and its sink the session's; any other exit closes the sink with + // whatever the child queued, and leaves the conversation's child as it was. const attemptSink = context.runtimeState.mintEventSink(sessionId) - let attemptSinkAdopted = false - // A lease handed back cleanly is what a resume replaces. A writer current as of that owner is - // rebased onto the fence this attach publishes, since the restart is the only thing that moved it. - const released = context.deps.store.getRecord(sessionId) - const resumedFromFence = - released && isResumableStructuredAgentSessionRecord(released) - ? released.lease.runtimeFence - : undefined - const attached = stampFailedCreateOwnerVerdict( - context.deps.store, - callerKey, - params.envelope, - await performAttach({ + // Read before the reserve clears it: how the previous generation ended decides how whatever it + // left running is settled. + const priorRecord = context.deps.store.getRecord(sessionId) + const priorDeathEvidence = priorRecord?.lease.deathEvidence ?? null + const attempt: { candidate: AttachCandidate | null; committed: boolean } = { + candidate: null, + committed: false + } + try { + const attached = await performAttach({ store: context.deps.store, adapter: context.deps.adapter, journalRoot: context.deps.journalRoot, @@ -170,66 +156,51 @@ async function runAttach( params, now: () => context.now(), recordPhase, - // Site 9: this closes the PRIOR map entry it drops, never the provisional - // journal — it has no reference to that one. `onAttached` owns that. - onAttachFailed: async () => { - await forgetStructuredAgentSession(context, sessionId) - context.runtimeState.currentEventSink(sessionId)?.close() - context.runtimeState.discardEventSink(sessionId) + ...(options.onAcquisitionFailed ? { onAcquisitionFailed: options.onAcquisitionFailed } : {}), + openConversation: async (record) => { + const conversation = await context.openConversation(record.sessionId, { + acquisition: true + }) + if (!conversation) { + throw new Error('agent_session_identity_required') + } + return conversation.journal }, + // The cleanup released the acquisition, which for a re-attach is the live child itself. + onAcquisitionReleased: (cause, verdict) => + endReleasedChild(context, sessionId, cause, verdict), onAttached: async (attached, acquisitionGeneration, acquiredOwner, providerChildPhase) => { - const fence = context.deps.store.getRecord(sessionId)?.lease.runtimeFence ?? 0 - const previous = context.sessions.get(sessionId) - const previousFence = previous?.fence + const fence = structuredAgentSessionConversationFence(context.deps.store, sessionId) + const current = context.sessions.get(sessionId)?.child ?? null + const startedFor = acquiredOwner ? options.startedFor : current?.startedFor // A re-attach to a live child keeps the sink that child already writes through. const eventSink = acquiredOwner ? attemptSink : (context.runtimeState.currentEventSink(sessionId) ?? attemptSink) - // Site 8: the provisional journal has no owner until the map takes it, - // and the barrier below throws by design. - try { - if (acquiredOwner) { - // Before the drain: the buffered events are the new child's, never a stale row's. - await settleStaleSessionStateOnAcquire({ - journal: attached.journal, - sessionId, - fence, - acquisitionGeneration - }) - } - await bindAndDrain(eventSink, attached.journal, fence, (activity) => - context.subscribers.publish(sessionId, attached.journal, activity) - ) - } catch (error) { - await agentSessionJournalCloseRetries.closeOrRetain(attached.journal) - throw error + if (acquiredOwner) { + // Before the drain: the buffered events are the new child's, never a stale row's. + await settleStaleStructuredAgentSessionState({ + journal: attached.journal, + sessionId, + fence, + acquisitionGeneration, + deathEvidence: priorDeathEvidence, + failureTextContext: structuredAgentSessionFailureWordsContext(priorRecord) + }) } - // Site 10: a `set` over a live entry would orphan its handle — and a - // close that REJECTED did not release it. The replacement is therefore - // ABORTED rather than completed over a handle nothing can reach again: - // `previous` stays indexed, so teardown still owns it and can retry. - if (previous && previous.journal !== attached.journal) { - try { - await previous.journal.close() - } catch (error) { - await agentSessionJournalCloseRetries.closeOrRetain(attached.journal) - throw error + await bindAndDrain(eventSink, attached.journal, fence, (activity) => + context.subscribers.publish(sessionId, attached.journal, activity) + ) + attempt.candidate = { + sink: eventSink, + child: { + generation: acquisitionGeneration ?? current?.generation ?? null, + fence, + // A re-attach to a live child keeps what that child already proved, and its cause. + phase: acquiredOwner ? providerChildPhase : (current?.phase ?? 'ready'), + ...(startedFor === undefined ? {} : { startedFor }) } } - context.runtimeState.adoptEventSink(sessionId, eventSink) - attemptSinkAdopted = eventSink === attemptSink - context.sessions.set(sessionId, { - journal: attached.journal, - params, - fence, - hasProviderChild: true, - // A re-attach to a live child keeps what that child already proved. - providerChildPhase: acquiredOwner - ? providerChildPhase - : (previous?.providerChildPhase ?? 'ready'), - acquisitionGeneration: acquisitionGeneration ?? previous?.acquisitionGeneration ?? null, - resumedFromFence: acquiredOwner ? resumedFromFence : previous?.resumedFromFence - }) await recoverStructuredRewind( context.deps.store, sessionId, @@ -238,22 +209,61 @@ async function runAttach( context.deps.adapter, context.now ) - await recoverInterruptedCompaction(context.deps.store, sessionId, attached.journal, fence) - if (attached.recovery) { - context.subscribers.reset(sessionId, attached.journal, attached.recovery.reset, fence) - } else if (previousFence !== undefined && previousFence !== fence) { + if (fenceBefore !== null && fence !== fenceBefore) { context.subscribers.snapshot(sessionId, attached.journal, fence) } else { context.subscribers.publish(sessionId, attached.journal) } } - }).finally(() => { - if (!attemptSinkAdopted) { - attemptSink.close() - } }) - ) - return attached + const { candidate } = attempt + const conversation = context.sessions.get(sessionId) + if (attached.ok && candidate && conversation) { + context.runtimeState.adoptEventSink(sessionId, candidate.sink) + attempt.committed = candidate.sink === attemptSink + indexProviderChild(conversation, candidate.child) + context.publishStatus?.(sessionId) + } + return stampFailedCreateOwnerVerdict(context.deps.store, callerKey, params.envelope, attached) + } finally { + if (!attempt.committed) { + attemptSink.close() + } + } +} + +type AttachCandidate = { + child: StructuredAgentSessionProviderChild + sink: DeferredStructuredAgentSessionEventSink +} + +function endReleasedChild( + context: StructuredAgentSessionAttachContext, + sessionId: string, + cause: unknown, + verdict: StructuredAgentSessionStopVerdict +): void { + const session = context.sessions.get(sessionId) + const child = session?.child + if ( + !session || + !child || + !endProviderChild(session, { + generation: child.generation, + fence: child.fence, + cause: 'attach-failed', + reason: cause instanceof Error ? cause.message : String(cause), + // Orca failed to attach; the provider said nothing. + failure: agentSessionFailureFact('hostFault'), + duringStartup: child.phase === 'starting', + ...verdict + }) + ) { + return + } + context.runtimeState.currentEventSink(sessionId)?.close() + context.runtimeState.discardEventSink(sessionId) + context.publishStatus?.(sessionId) } /** Binds the sink to the journal and waits for the barrier the host publishes diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts index 97f429933d8..36f6ebc79e8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts @@ -8,11 +8,13 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' import { digestPayload } from '../agent-session-journal/journal-payload-bounds' import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' import { attachJournal, journalIdentityFor, @@ -85,6 +87,7 @@ async function attach(adapter: StructuredAgentSessionAdapter) { record: RECORD, params: PARAMS, journalRoot: root, + openConversation: openTestAttachConversation(root), adapter }) journals.track(attached.journal) @@ -110,11 +113,24 @@ describe('attachJournal restart reconciliation', () => { expect(attached.unconfirmedClientMessageIds).toEqual([]) const submission = attached.journal.submissions()[0] expect(submission?.dispatchState).toBe('rejected') - expect(submission?.reason).toBe('not_delivered') + expect(submission?.rejection).toEqual({ kind: 'notDelivered' }) // Deciding is not sending: nothing here puts the message back on the wire. expect(dispatch).not.toHaveBeenCalled() }) + it('gives a send the provider never received no verdict and no listing', async () => { + await crashedJournal() + const { adapter } = adapterWith(async () => window()) + + const attached = await attach(adapter) + + // Nobody failed: the crash stranded it, so the chat must not read Failed or be listed by it. + const { items, submissions } = attached.journal.snapshot() + expect( + projectStructuredAgentSessionStatusState(items, submissions, RECORD.lease.runtimeFence) + ).toMatchObject({ summary: { status: null }, latestRequest: null }) + }) + it('still reports a submission unconfirmed when the window cannot decide it', async () => { await crashedJournal() const { adapter, dispatch } = adapterWith(async () => window({ turnInFlight: true })) @@ -147,4 +163,37 @@ describe('attachJournal restart reconciliation', () => { expect(attached.unconfirmedClientMessageIds).toEqual(['cm_1']) expect(attached.journal.submissions()[0]?.dispatchState).toBe('unknown') }) + + it('leaves a message the open conversation still has queued alone (W4′e)', async () => { + const journal = await journals.open({ + identity: IDENTITY, + journalDir: journalDirectoryFor(root, { + workspaceId: IDENTITY.workspaceId, + sessionId: IDENTITY.sessionId + }) + }) + await journal.appendSubmission({ + clientMessageId: 'queued', + payloadFingerprint: digestPayload('still queued'), + body: userMessage('still queued'), + fence: RECORD.lease.runtimeFence, + handoverRecorded: true + }) + // History that holds nothing: absence would prove a handed-over message undelivered. + const { adapter, dispatch } = adapterWith(async () => window()) + + const attached = await attachJournal({ + record: RECORD, + params: PARAMS, + journalRoot: root, + + adapter, + openConversation: async () => journal + }) + + expect(attached.journal).toBe(journal) + expect(journal.submissions()[0]).toMatchObject({ dispatchState: 'pending' }) + expect(journal.submissions()[0]?.handedOverAt).toBeUndefined() + expect(dispatch).not.toHaveBeenCalled() + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts new file mode 100644 index 00000000000..1e2322cbc6d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts @@ -0,0 +1,18 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { openStructuredAgentSessionConversationJournal } from './structured-agent-session-conversation-open' + +/** For a test that attaches without a host: the conversation's journal, through the one open a + * host would take, so the attach under test adopts it the way it adopts the host's. */ +export function openTestAttachConversation( + journalRoot: string, + adapter: Pick = {} +): (record: AgentSessionRecord) => Promise { + return async (record) => + ( + await openStructuredAgentSessionConversationJournal({ journalRoot, adapter }, record, { + acquisition: true + }) + ).session.journal +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index 2e42ffe9893..10c4866cea3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -23,12 +23,15 @@ import type { AgentSessionLaunchEnv, AgentSessionRecord } from '../../../shared/agent-session-record' -import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' import { - AGENT_SESSION_WIRE_REFUSAL_CODES, + AgentSessionRefusalError, + agentSessionRefusalFromReference, + agentSessionRefusalReference, + isAgentSessionWireRefusalCode, + refuse, type AgentSessionMutationEnvelope, - type AgentSessionWireRefusal, - type AgentSessionWireRefusalCode + type AgentSessionRefusalReference, + type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { agentSessionFingerprintConflict, @@ -36,15 +39,9 @@ import { } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle' -import { agentSessionJournalCloseRetries } from '../agent-session-journal/journal-close-retry' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { reconcileJournalSubmissionsAgainstHistory } from '../agent-session-journal/journal-restart-reconciliation' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' -import { - openAgentSessionJournalWithRecovery, - type AgentSessionJournalRecovery -} from './agent-session-journal-recovery' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { structuredAgentSessionRefusalMessage } from './structured-agent-session-refusal-message' @@ -64,8 +61,8 @@ export type AgentSessionAttachParams = { runtimeKind: 'native' /** Host-resolved defaults for a create-by-intent; remote attach schemas do not accept them. */ options?: Readonly> - /** The tab id a create reserves for this chat; absent records the id clients derive. Never on - * the attach fingerprint: which tab shows the chat is not which conversation it attaches to. */ + /** The tab id a create reserves for this chat, taken when its tab is published. Never on the + * attach fingerprint: which tab shows the chat is not which conversation it attaches to. */ surfaceTabId?: string launchArgs?: string[] /** Omitted only for create-by-intent; the adapter proves the durable handle. */ @@ -125,10 +122,11 @@ export function admitAttachOrRefuse( if (params.providerHandle && params.providerHandle.kind !== params.provider) { return { ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: `A ${params.provider} session requires a ${params.provider} provider handle.` - } + refusal: refuse( + 'agent_session_operation_invalid', + { reason: 'requestMalformed' }, + `A ${params.provider} session requires a ${params.provider} provider handle.` + ) } } const fingerprint = computeAgentSessionPayloadFingerprint({ @@ -166,16 +164,13 @@ export function journalIdentityFor( export type AttachedJournal = { journal: AgentSessionJournal - recovery: AgentSessionJournalRecovery | null - /** Submissions still `unknown` after this open: the crash boundary settled - * them there and provider history could not decide them either. */ + /** Submissions the crash boundary left `unknown` that provider history could not decide. */ unconfirmedClientMessageIds: string[] } /** - * Open the session's journal, recovering it when the stored one is unusable, - * settle every submission left in flight by a previous process, then let - * provider history decide the ones it can prove. + * The conversation's journal — opened, and its crash boundary settled, by the conversation's own + * open — with provider history deciding the submissions that boundary could only doubt. * * Why the reconciliation belongs HERE and nowhere else: this runs after the * record store handed this host the lease and before `onAttached` starts a @@ -183,54 +178,44 @@ export type AttachedJournal = { * is read, and the window stays valid until the resume consumes it. Every other * settlement site — a proven child exit — runs while the host * may still start another child, and a read there could be overtaken before it - * is acted on. Orca still never re-sends: this decides state only. + * is acted on. Orca still never re-sends: this decides state only. A queued + * submission is left alone: it was never handed over, so history cannot hold it. */ export async function attachJournal(input: { record: AgentSessionRecord params: AgentSessionAttachParams journalRoot: string adapter: StructuredAgentSessionAdapter + /** The host's open conversation, whose journal the attach adopts. */ + openConversation: (record: AgentSessionRecord) => Promise /** Provider history sampled before a new child is acquired. `null` means the * adapter had no usable history; omit to read lazily for direct callers. */ providerHistoryWindow?: ProviderHistoryWindow | null }): Promise { const identity = journalIdentityFor(input.record, input.params) const fence = input.record.lease.runtimeFence - const historyFilePath = input.adapter.historyFilePath - ? await input.adapter.historyFilePath({ identity }) - : null - const opened = await openAgentSessionJournalWithRecovery({ + const journal = await input.openConversation(input.record) + const settled = await reconcileAgainstProviderHistory({ + adapter: input.adapter, identity, - journalDir: journalDirectoryFor(input.journalRoot, { - workspaceId: identity.workspaceId, - sessionId: identity.sessionId - }), + journal, fence, - historyFilePath + accountHome: input.record.accountHome, + ...(Object.hasOwn(input, 'providerHistoryWindow') + ? { history: input.providerHistoryWindow } + : {}) }) - try { - // That await is a WRITE. A failure in it leaves the journal with no caller - // holding a reference to close it. - const unconfirmed = await opened.journal.markPendingSubmissionsUnknown(fence) - const settled = await reconcileAgainstProviderHistory({ - adapter: input.adapter, - identity, - journal: opened.journal, - fence, - accountHome: input.record.accountHome, - ...(Object.hasOwn(input, 'providerHistoryWindow') - ? { history: input.providerHistoryWindow } - : {}) - }) - return { - ...opened, - unconfirmedClientMessageIds: unconfirmed.filter((id) => !settled.includes(id)) - } - } catch (error) { - // A rejected close leaves the handle open, so the journal is retained for a - // later retry rather than dropped along with the only reference to it. - await agentSessionJournalCloseRetries.closeOrRetain(opened.journal) - throw error + return { + journal, + unconfirmedClientMessageIds: journal + .submissions() + .filter( + (entry) => + entry.dispatchState === 'unknown' && + entry.recovered === true && + !settled.includes(entry.clientMessageId) + ) + .map((entry) => entry.clientMessageId) } } @@ -317,10 +302,8 @@ export function reserveRequestFor(input: { provider: params.provider, accountHome: params.accountHome, ...(params.options ? { options: params.options } : {}), - // Create path only: an existing record keeps its own. Unreserved, it is the id every client - // still derives, so nothing keyed by it moves until those readers copy the recorded one. - ...(params.envelope.expectedRuntimeFence === null - ? { surfaceTabId: params.surfaceTabId ?? structuredAgentSessionTabId(input.sessionId) } + ...(params.envelope.expectedRuntimeFence === null && params.surfaceTabId + ? { surfaceTabId: params.surfaceTabId } : {}), ...(authority.launchArgs ? { launchArgs: authority.launchArgs } : {}), ...(authority.launchEnv ? { launchEnv: authority.launchEnv } : {}), @@ -353,16 +336,21 @@ export function classifyStoreFailure( record: AgentSessionRecord | null = null ): AgentSessionWireRefusal { const rawCode = error instanceof Error ? error.message : String(error) - if (!(AGENT_SESSION_WIRE_REFUSAL_CODES as readonly string[]).includes(rawCode)) { + if (!isAgentSessionWireRefusalCode(rawCode)) { throw error } - const code = rawCode as AgentSessionWireRefusalCode - return { - code, - // Why: a latched session is exactly where a bare store code strands the user. - message: - structuredAgentSessionRefusalMessage(code, record) ?? - `The session store refused this call: ${code}.`, - ...(code === 'agent_session_checkpoint_stale' && currentFence !== null ? { currentFence } : {}) - } + // A refusal error's message is its code, so its details are this code's. + const emitted: AgentSessionRefusalReference = + error instanceof AgentSessionRefusalError + ? agentSessionRefusalReference(error.refusal) + : { code: rawCode } + // Why: a latched session is exactly where a bare store code strands the user. + const told = structuredAgentSessionRefusalMessage(emitted, record) + const reference = told?.reference ?? emitted + return agentSessionRefusalFromReference( + reference.code === 'agent_session_checkpoint_stale' && currentFence !== null + ? { code: reference.code, details: { ...reference.details, currentFence } } + : reference, + told?.message ?? `The session store refused this call: ${rawCode}.` + ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts index 5d922d72350..08ee51f49f7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts @@ -4,6 +4,7 @@ import type { AgentSessionHistoryResult } from '../../../shared/agent-session-wire' import { readStructuredAgentSessionHistoryResult } from './structured-agent-session-history-result' +import { tryReadQueuePublication } from './structured-agent-session-queued-publication' import type { AgentSessionSubscribers, AgentSessionSubscribeInput @@ -12,43 +13,55 @@ import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' export class StructuredAgentSessionBackgroundTaskChannel { constructor( private readonly deps: StructuredAgentSessionHostDeps, private readonly sessions: Map, private readonly subscribers: AgentSessionSubscribers, - private readonly requireSession: (sessionId: string) => StructuredAgentSessionHostSession, + /** The host's accessor: opens a conversation at rest, and never starts an agent. */ + private readonly conversation: ( + sessionId: string + ) => Promise, /** Task edges change the status summary too; the feed's equality check * keeps a no-op re-projection from reaching subscribers. */ private readonly onPublished: (sessionId: string) => void ) {} - history(request: AgentSessionHistoryRequest): AgentSessionHistoryResult { + async history(request: AgentSessionHistoryRequest): Promise { + const journal = (await this.conversation(request.sessionId)).journal const result = readStructuredAgentSessionHistoryResult({ - journal: this.requireSession(request.sessionId).journal, + journal, record: this.deps.store.getRecord(request.sessionId), request }) const backgroundTasks = this.state(request.sessionId) + const queue = tryReadQueuePublication(journal) const hostNow = this.deps.now?.() ?? Date.now() return { ...result, page: { ...result.page, hostNow, + // A stale history answer never replaces newer live subscription state; + // the client's reducer keeps live-over-history precedence. + ...(queue !== undefined + ? { queuedMessages: queue.queuedMessages, queuePause: queue.queuePause } + : {}), ...(backgroundTasks !== undefined ? { backgroundTasks } : {}) } } } - subscribe(input: AgentSessionSubscribeInput): () => void { - const session = this.requireSession(input.sessionId) + /** Resolves once the conversation is open and the subscriber holds its opening frame. */ + async subscribe(input: AgentSessionSubscribeInput): Promise<() => void> { + const session = await this.conversation(input.sessionId) const backgroundTasks = this.state(input.sessionId) return this.subscribers.open({ ...input, journal: session.journal, - fence: this.deps.store.getRecord(input.sessionId)?.lease.runtimeFence ?? 0, + fence: structuredAgentSessionConversationFence(this.deps.store, input.sessionId), ...(backgroundTasks !== undefined ? { backgroundTasks } : {}) }) } @@ -57,7 +70,11 @@ export class StructuredAgentSessionBackgroundTaskChannel { const session = this.sessions.get(sessionId) const state = publishedState !== undefined ? publishedState : this.state(sessionId) if (session && state !== undefined) { - this.subscribers.backgroundTasks(sessionId, state, session.fence) + this.subscribers.backgroundTasks( + sessionId, + state, + structuredAgentSessionConversationFence(this.deps.store, sessionId) + ) this.onPublished(sessionId) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts new file mode 100644 index 00000000000..61e3f7ef3ad --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts @@ -0,0 +1,291 @@ +// Stop and `/compact` on the shipping Claude adapter, driven by the frames a stream-json child +// writes: which result ends the command, and what the next message is handed to. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { ClaudeControlRequestError } from '../../claude/claude-agent-sdk-control-requests' +import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import { + fakeClaude, + PROVIDER_SESSION_ID, + type FakeConnection +} from '../../claude/claude-structured-session-test-support' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' +import { structuredAgentSessionCommandTurn } from './structured-agent-session-command-turn' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let adapter: ClaudeStructuredSessionAdapter +let claude: ReturnType +/** The adapter's clock, which a real child's exit reads at a different instant than the host's. */ +let adapterNow = NOW + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-compact-stop-')) + resetHostTestOperationIds() + adapterNow = NOW + 7 + // No echo of its own: each test writes the frames Claude would. + claude = fakeClaude({ replayUuid: null }) + adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: root, + claudeConfigDir: join(root, 'claude-home'), + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0, + continuesChain: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0 + }), + onEvent: (event) => { + const mapped = structuredClaudeLifecycleEvent(event) + if (mapped) { + void host.handleAdapterEvent(mapped) + } + }, + // The runtime's own wiring: the provider's answer is what settles a send it took. + onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => adapterNow + }) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + // The production router is what declares create support; the bare adapter only knows locations. + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) + const attached = await host.attach( + CALLER, + hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + }) + ) + expect(attached).toMatchObject({ ok: true }) + await adapter.awaitStarted(SESSION) +}) + +afterEach(async () => { + await adapter.closeAll() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function envelope(method: string, fields: Record) { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +async function compact(): Promise { + const params = { + command: 'compact' as const, + envelope: envelope('agentSession.conversationCommand', { command: 'compact' }) + } + await expect(host.conversationCommand(CALLER, params)).resolves.toMatchObject({ ok: true }) + return params.envelope.clientOperationId +} + +function stop(cmid: string) { + const { turnId } = structuredAgentSessionCommandTurn(cmid) + return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', { turnId }), turnId }) +} + +/** The uuid of the frame that carried `text` to Claude, and the child it went to. */ +async function sent(text: string): Promise<{ connection: FakeConnection; uuid: string }> { + return vi.waitFor(() => { + for (const connection of claude.connections) { + const frame = connection.sent.find((message) => JSON.stringify(message).includes(text)) + if (frame) { + return { connection, uuid: String(frame.uuid) } + } + } + throw new Error(`nothing sent ${text}`) + }) +} + +function frame(connection: FakeConnection, message: Record): void { + connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) +} + +function result(uuid: string, overrides: Record = {}): Record { + return { + type: 'result', + subtype: 'success', + is_error: false, + uuid: `result-${uuid}`, + user_message_uuid: uuid, + ...overrides + } +} + +const INTERRUPTED = { + subtype: 'error_during_execution', + is_error: true, + terminal_reason: 'aborted_streaming' +} + +async function commandState(cmid: string) { + await host.flushStreamedEvents(SESSION) + const { itemId } = structuredAgentSessionCommandTurn(cmid) + const snapshot = await host.journalSnapshot(SESSION) + return readAgentJournalTurn(snapshot.items.find((item) => item.itemId === itemId)?.body) +} + +it('ends a hung /compact Claude will not interrupt by stopping it, and answers the next send in its own turn', async () => { + claude.routes.interrupt = () => { + throw new ClaudeControlRequestError('interrupt', 'Claude did not answer the interrupt.') + } + const cmid = await compact() + const { connection: compacting } = await sent('/compact') + + await expect(stop(cmid)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await vi.waitFor(async () => expect((await commandState(cmid))?.state).toBe('interrupted')) + + const body = hostTestMessage('what next?') + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + const { connection, uuid } = await sent('what next?') + expect(compacting.closed).toBe(true) + expect(connection).not.toBe(compacting) + frame(connection, { + type: 'user', + uuid, + parent_tool_use_id: null, + message: { role: 'user', content: [{ type: 'text', text: 'what next?' }] } + }) + frame(connection, { + type: 'assistant', + uuid: 'answer', + parent_tool_use_id: null, + message: { id: 'msg-1', role: 'assistant', content: [{ type: 'text', text: 'Here is.' }] } + }) + frame(connection, result(uuid)) + + await vi.waitFor(async () => { + await host.flushStreamedEvents(SESSION) + const snapshot = await host.journalSnapshot(SESSION) + const answer = snapshot.items.find( + (item) => item.body.kind === 'message' && item.body.role === 'assistant' + ) + expect(answer?.turnScope).toMatchObject({ kind: 'turn' }) + expect(answer?.turnScope).not.toEqual({ + kind: 'turn', + turnItemId: structuredAgentSessionCommandTurn(cmid).itemId + }) + }) +}) + +it('ends a stopped /compact on its own interrupted result, and a late copy of that result does not end the next one', async () => { + claude.routes.interrupt = () => ({}) + const first = await compact() + const { connection, uuid: firstUuid } = await sent('/compact') + + await expect(stop(first)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + // The interrupt was taken: the command runs until Claude answers it. + expect((await commandState(first))?.state).toBe('running') + frame(connection, result(firstUuid, INTERRUPTED)) + await vi.waitFor(async () => + expect(await commandState(first)).toMatchObject({ + state: 'interrupted', + outcome: 'cancellation' + }) + ) + + const second = await compact() + await vi.waitFor(() => + expect( + connection.sent.filter((message) => JSON.stringify(message).includes('/compact')) + ).toHaveLength(2) + ) + const secondUuid = String(connection.sent.at(-1)?.uuid) + frame(connection, result(firstUuid, INTERRUPTED)) + frame( + connection, + result('an-earlier-send', { subtype: 'error_during_execution', is_error: true }) + ) + expect((await commandState(second))?.state).toBe('running') + + frame(connection, { type: 'system', subtype: 'compact_boundary', uuid: 'boundary' }) + frame(connection, result(secondUuid)) + await vi.waitFor(async () => + expect(await commandState(second)).toMatchObject({ state: 'completed', outcome: 'success' }) + ) +}) + +it('settles a /compact whose Claude child exits mid-command through that child, and delivers what waited', async () => { + const cmid = await compact() + const { connection: compacting } = await sent('/compact') + const body = hostTestMessage('after the exit') + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + + // Claude sends no end frame at all: its child is gone. + compacting.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed')) + + await vi.waitFor(async () => expect((await commandState(cmid))?.state).toBe('interrupted')) + const { connection } = await sent('after the exit') + expect(connection).not.toBe(compacting) +}) + +it('tells why a /compact ended when its Claude child exits after taking it', async () => { + const cmid = await compact() + const { connection, uuid } = await sent('/compact') + // Claude echoes the command, so the send is answered; then the child dies with no end frame. + frame(connection, { + type: 'user', + uuid, + parent_tool_use_id: null, + message: { role: 'user', content: [{ type: 'text', text: '/compact' }] } + }) + await host.flushStreamedEvents(SESSION) + adapterNow = NOW + 7 + connection.handlers.onExit?.(new Error('claude stream-json exited (code 1)')) + + await vi.waitFor(async () => expect((await commandState(cmid))?.state).toBe('interrupted')) + await vi.waitFor(async () => { + await host.flushStreamedEvents(SESSION) + const snapshot = await host.journalSnapshot(SESSION) + // The exit's words are the host's sentence, never Claude's log text. + const exitRow = snapshot.items.find( + (item) => item.body.kind === 'status' && item.body.failure?.kind === 'providerExited' + ) + expect(exitRow?.body).toMatchObject({ + text: 'Claude stopped while this response was in progress. You can continue in this conversation.', + tone: 'error' + }) + expect(exitRow?.turnScope).toEqual({ + kind: 'turn', + turnItemId: structuredAgentSessionCommandTurn(cmid).itemId + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts new file mode 100644 index 00000000000..0fe20620160 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts @@ -0,0 +1,146 @@ +// Claude's echo of a sent message both answers the send and opens its turn. Whatever order the +// host publishes those in, a chat reading its frames one at a time must read working throughout: +// Stop and every session list's Working come from that one rule. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import { + fakeClaude, + PROVIDER_SESSION_ID +} from '../../claude/claude-structured-session-test-support' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } + +let root: string +let host: StructuredAgentSessionHost +let adapter: ClaudeStructuredSessionAdapter +let store: AgentSessionRecordStore +const claude = { current: fakeClaude({ replayUuid: null }) } + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-echo-working-')) + resetHostTestOperationIds() + claude.current = fakeClaude({ replayUuid: null }) + const lifecycle: Promise[] = [] + adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: root, + claudeConfigDir: join(root, 'claude-home'), + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: false, + continuesChain: false + }), + onEvent: (event) => { + const mapped = structuredClaudeLifecycleEvent(event) + if (mapped) { + lifecycle.push(host.handleAdapterEvent(mapped)) + } + }, + // As the runtime wires it. + onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + openConnection: claude.current.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => NOW + }) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) + const params = hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + }) + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) + await adapter.awaitStarted(SESSION) + await Promise.all(lifecycle) +}) + +afterEach(async () => { + await adapter.closeAll() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function settled(): Promise { + return new Promise((resolve) => setTimeout(resolve, 20)) +} + +it('reads working at every published frame from the send through the echo that opens its turn', async () => { + const submissions = new Map() + const turns = new Map() + const working: boolean[] = [] + const fence = store.getRecord(SESSION)!.lease.runtimeFence + host.subscribe({ + id: 'chat-1', + sessionId: SESSION, + emit: (event) => { + if (event.type !== 'batch') { + return + } + for (const submission of event.batch.submissions) { + submissions.set(submission.clientMessageId, submission) + } + for (const item of event.batch.items) { + if (item.body.kind === 'turn') { + turns.set(item.itemId, item.body.state) + } + } + const running = [...turns].find(([, state]) => state === 'running')?.[0] ?? null + working.push( + isStructuredAgentSessionMainAgentWorking(running, [...submissions.values()], fence) + ) + } + }) + const body = hostTestMessage('hi') + await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + await settled() + const connection = claude.current.connections[0]! + // Claude echoes the written message back, which is what opens its turn. + connection.handlers.onMessage?.({ ...connection.sent.at(-1)!, uuid: 'echo-uuid' }) + await settled() + + expect([...submissions.values()].map((submission) => submission.dispatchState)).toEqual([ + 'accepted' + ]) + expect([...turns.values()]).toEqual(['running']) + expect(working).not.toContain(false) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts new file mode 100644 index 00000000000..e8b8d0f0044 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts @@ -0,0 +1,196 @@ +// A follow-up Claude queued behind the running turn is dropped by Stop, so the chat must record it +// as withdrawn and stop reading as working — under the SessionStart hook Orca installs, whose frame +// proves the start before the turn's system/init says the CLI can cancel its queue. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' +import { projectStructuredAgentSessionStatus } from '../../../shared/structured-agent-session-projection' +import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import { + fakeClaude, + PROVIDER_SESSION_ID +} from '../../claude/claude-structured-session-test-support' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } +// As Claude Code 2.1.280 advertises them on a turn's system/init frame. +const CAPABILITIES = ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1', 'msg_lifecycle_v1'] + +let root: string +let host: StructuredAgentSessionHost +let adapter: ClaudeStructuredSessionAdapter +let store: AgentSessionRecordStore +let queued: string[] +let claude: ReturnType + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-queued-stop-')) + resetHostTestOperationIds() + queued = [] + claude = fakeClaude({ + replayUuid: null, + initProof: 'session-start', + // What the real CLI answers: cancel_queued cancels the queue, a plain interrupt keeps it. + routes: { + interrupt: (params) => + params?.cancelQueued + ? { still_queued: [], cancelled: queued.splice(0) } + : { still_queued: [...queued] } + } + }) + const lifecycle: Promise[] = [] + adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: root, + claudeConfigDir: join(root, 'claude-home'), + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: false, + continuesChain: false + }), + onEvent: (event) => { + const mapped = structuredClaudeLifecycleEvent(event) + if (mapped) { + lifecycle.push(host.handleAdapterEvent(mapped)) + } + }, + // As the runtime wires it. + onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => NOW + }) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) + const params = hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + }) + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) + await adapter.awaitStarted(SESSION) + await Promise.all(lifecycle) +}) + +afterEach(async () => { + await adapter.closeAll() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function eventually(assertion: () => unknown): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +function envelope( + method: 'agentSession.send' | 'agentSession.cancel', + fields: Record +) { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + if (!sent.ok) { + throw new Error('send refused') + } + return sent.value.clientMessageId +} + +async function dispatch(clientMessageId: string) { + const submission = (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) + return { state: submission?.dispatchState, reason: submission?.reason } +} + +async function status(): Promise { + const snapshot = await host.journalSnapshot(SESSION) + return projectStructuredAgentSessionStatus( + snapshot.items, + snapshot.submissions, + store.getRecord(SESSION)!.lease.runtimeFence + ) +} + +it('withdraws a follow-up Claude queued behind the running turn when that turn is stopped', async () => { + const connection = claude.connections[0]! + const first = await send('Write a long reply.') + await eventually(() => expect(connection.sent).toHaveLength(1)) + // Claude opens the turn: its system/init, then the echo of the message it runs. + connection.handlers.onMessage?.({ + type: 'system', + subtype: 'init', + session_id: PROVIDER_SESSION_ID, + uuid: 'turn-init', + model: 'claude-sonnet-5', + capabilities: CAPABILITIES + }) + connection.handlers.onMessage?.({ + ...connection.sent.at(-1)!, + uuid: connection.sent.at(-1)!.uuid + }) + await eventually(async () => expect((await dispatch(first)).state).toBe('accepted')) + const turnId = activeStructuredAgentSessionTurnId((await host.journalSnapshot(SESSION)).items) + expect(turnId).not.toBeNull() + + const followUp = await send('And then this.') + await eventually(() => expect(connection.sent).toHaveLength(2)) + queued.push(String(connection.sent.at(-1)!.uuid)) + await eventually(async () => expect((await dispatch(followUp)).state).toBe('pending')) + + const stopped = await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', { turnId }), + turnId: turnId! + }) + expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) + // The interrupted turn closes as the CLI ends it. + connection.handlers.onMessage?.({ + type: 'result', + subtype: 'error_during_execution', + session_id: PROVIDER_SESSION_ID, + uuid: 'interrupted-result' + }) + await eventually(async () => + expect(await dispatch(followUp)).toEqual({ + state: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + }) + ) + await eventually(async () => expect(await status()).toBe('idle')) +}, 15_000) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts index a73e46b898e..4a8f0a1440f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts @@ -11,7 +11,7 @@ import { import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { evictHeldStructuredAgentSession } from './structured-agent-session-host-lifetime' +import { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -24,7 +24,7 @@ afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) -describe('Claude root-exit eviction', () => { +describe('Claude root-exit stop', () => { it('releases a captured live claim after the provider root exits', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-claude-root-exit-')) roots.push(root) @@ -78,6 +78,7 @@ describe('Claude root-exit eviction', () => { journalDir: join(root, 'journal') }) const close = vi.spyOn(journal, 'close') + const publishStatus = vi.fn() const params: AgentSessionAttachParams = { envelope: { sessionId: 'session-1', @@ -103,10 +104,11 @@ describe('Claude root-exit eviction', () => { { journal, params, - fence, - hasProviderChild: true, - providerChildPhase: 'ready', - acquisitionGeneration: acquisition.acquisitionGeneration ?? null + child: { + generation: acquisition.acquisitionGeneration ?? null, + fence, + phase: 'ready' + } } ] ]) @@ -115,13 +117,13 @@ describe('Claude root-exit eviction', () => { claude.connections[0]!.handlers.onExit?.(new Error('provider exited')) await expect( - evictHeldStructuredAgentSession( + stopStructuredAgentSessionAgentUnderSerialize( { deps, runtimeState, sessions, now: () => NOW + 30 * 60_000, - forgetStatus: vi.fn() + publishStatus }, 'session-1' ) @@ -132,8 +134,10 @@ describe('Claude root-exit eviction', () => { ownerProcess: null, deathEvidence: { kind: 'exit-observed' } }) - expect(sessions.size).toBe(0) - expect(close).toHaveBeenCalledOnce() + // The agent went to rest; the conversation stays open and listed. + expect(sessions.get('session-1')?.child).toBeNull() + expect(close).not.toHaveBeenCalled() + expect(publishStatus).toHaveBeenCalledWith('session-1') // The adapter agrees the session is over: nothing is left to refuse the next start. await expect(adapter.closeSession('session-1')).resolves.toBe(true) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts index aa3f298a077..fee7f9e1d3d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -1,6 +1,8 @@ +import { AgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' +import { tryReadQueuePublication } from './structured-agent-session-queued-publication' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession @@ -28,16 +30,28 @@ export class StructuredAgentSessionClientDelivery { private readonly sessions: Map, now: () => number, deps: () => StructuredAgentSessionHostDeps, - private readonly onJournalActivity?: (sessionId: string) => void + private readonly onJournalActivity?: (sessionId: string) => void, + onAgentStarted?: (sessionId: string) => void ) { - this.statusFeed = createStructuredAgentSessionHostStatusFeed({ sessions, now, deps }) - this.turnCompletionFeed = new StructuredAgentSessionTurnCompletionFeed({ sessions, now }) + this.statusFeed = createStructuredAgentSessionHostStatusFeed({ + sessions, + now, + deps, + ...(onAgentStarted ? { onAgentStarted } : {}) + }) + this.turnCompletionFeed = new StructuredAgentSessionTurnCompletionFeed({ + sessions, + now, + readStatusState: (sessionId, journal) => this.statusFeed.statusState(sessionId, journal) + }) this.sendSettlement = new StructuredAgentSessionSendSettlement((sessionId) => this.requireJournal(sessionId) ) this.waitForSendSettlement = this.sendSettlement.wait this.subscribers = new AgentSessionSubscribers({ readCommands: (sessionId) => deps().adapter.readCommands?.(sessionId), + readQueuePublication: (sessionId) => + tryReadQueuePublication(sessions.get(sessionId)?.journal), onJournalPublished: (sessionId, journal) => this.publishJournal(sessionId, journal) }) } @@ -66,10 +80,16 @@ export class StructuredAgentSessionClientDelivery { subscriber: StructuredAgentSessionTurnCompletionSubscriber ): (() => void) => this.turnCompletionFeed.subscribe(subscriber) - closeSession(sessionId: string): void { + /** The conversation's handle closed. Its status row stays in every session list; the + * agent-status store keeps it too while the chat still has a tab to show it in. */ + closeSession(sessionId: string, options: { listed: boolean }): void { this.sendSettlement.closeSession(sessionId) - this.statusFeed.close(sessionId) - // The next attach re-baselines rather than announcing the turn it was already holding. + if (options.listed) { + this.statusFeed.revokeLive(sessionId) + } else { + this.statusFeed.close(sessionId) + } + // The next open re-baselines rather than announcing the turn it was already holding. this.turnCompletionFeed.forget(sessionId) } @@ -81,7 +101,8 @@ export class StructuredAgentSessionClientDelivery { this.statusFeed.publish(sessionId, journal) this.sendSettlement.publish(sessionId, journal) // Derived here rather than per-subscriber: this edge runs whether or not anyone is - // subscribed, which is the whole reason a backgrounded chat can complete at all. + // subscribed, which is the whole reason a backgrounded chat can complete at all. After the + // status publish, so it reads the projection that publish cached. this.turnCompletionFeed.observe(sessionId, journal) this.onJournalActivity?.(sessionId) } @@ -89,7 +110,7 @@ export class StructuredAgentSessionClientDelivery { private requireJournal(sessionId: string): AgentSessionJournal { const journal = this.sessions.get(sessionId)?.journal if (!journal) { - throw new Error(AGENT_SESSION_NOT_ATTACHED.code) + throw new AgentSessionRefusalError(AGENT_SESSION_NOT_ATTACHED) } return journal } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts index b9e8f8e506d..74d5355edd5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A close that REJECTED did not release the handle. // // `AgentSessionJournal.close()` is retryable by design: the release step is @@ -78,10 +79,7 @@ function hostSession(journal: AgentSessionJournal): StructuredAgentSessionHostSe return { journal, params: {} as StructuredAgentSessionHostSession['params'], - fence: 1, - hasProviderChild: false, - providerChildPhase: 'ready', - acquisitionGeneration: null + child: null } } @@ -138,7 +136,7 @@ function attachContext( reconcileLeases: async () => null, serialize: (_sessionId: string, task: () => Promise) => task(), now: () => 1, - forgetStatus: () => undefined + publishStatus: () => undefined } as unknown as StructuredAgentSessionAttachContext } @@ -178,40 +176,35 @@ describe('the registry', () => { }) describe('the attach orchestration', () => { - it('ABORTS the map replacement when the previous journal will not close', async () => { - const previousDir = join(root, 'previous') - const provisionalDir = join(root, 'provisional') - const previous = flakyClose( - await journals.open({ identity: IDENTITY, journalDir: previousDir }), - 1 - ) - const provisional = await journals.open({ - identity: IDENTITY, - journalDir: provisionalDir - }) - attachFlow.journal = provisional - const sessions = new Map([[SESSION, hostSession(previous)]]) + // The attach adopts the conversation's one open journal; it never opens a second handle, so + // there is no replacement to abort and no provisional journal to close. + it('keeps the journal it adopted indexed and open when an attach succeeds', async () => { + const directory = join(root, 'adopted') + const journal = await journals.open({ identity: IDENTITY, journalDir: directory }) + attachFlow.journal = journal + const sessions = new Map([[SESSION, hostSession(journal)]]) + await attachStructuredAgentSession(attachContext(sessions), 'caller-1', attachParams) + + expect(sessions.get(SESSION)?.journal).toBe(journal) await expect( - attachStructuredAgentSession(attachContext(sessions), 'caller-1', attachParams) - ).rejects.toThrow('close rejected') - - // The live entry is UNTOUCHED: overwriting it would have left its handle - // open with nothing able to reach it again. - expect(sessions.get(SESSION)?.journal).toBe(previous) - // And the provisional journal is owned by the registry, not orphaned. + journal.appendItem( + { provider: 'orca', clientMessageId: 'after-attach' }, + { + kind: 'status', + text: 'still writable' + }, + { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + ).resolves.toBeDefined() expect(agentSessionJournalCloseRetries.pendingDirectories).toEqual([]) - await expectNothingHoldsTheDirectory(provisionalDir) }) - it('retains the provisional journal when its own close rejects on the barrier path', async () => { - const provisionalDir = join(root, 'provisional-barrier') - const provisional = flakyClose( - await journals.open({ identity: IDENTITY, journalDir: provisionalDir }), - 1 - ) - attachFlow.journal = provisional - const sessions = new Map() + it('leaves the conversation indexed and open when the sink barrier fails', async () => { + const directory = join(root, 'adopted-barrier') + const journal = await journals.open({ identity: IDENTITY, journalDir: directory }) + attachFlow.journal = journal + const sessions = new Map([[SESSION, hostSession(journal)]]) const context = attachContext(sessions) const failing = { sink: {}, @@ -229,9 +222,20 @@ describe('the attach orchestration', () => { 'sink barrier failed' ) - expect(sessions.size).toBe(0) - // Retained rather than dropped, so teardown can still release the handle. - expect(agentSessionJournalCloseRetries.pendingDirectories).toEqual([provisionalDir]) + // A failed attach does not end the conversation: its queued messages and the failure row + // are written into this same journal. + expect(sessions.get(SESSION)?.journal).toBe(journal) + await expect( + journal.appendItem( + { provider: 'orca', clientMessageId: 'after-failure' }, + { + kind: 'status', + text: 'still writable' + }, + { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + ).resolves.toBeDefined() + expect(agentSessionJournalCloseRetries.pendingDirectories).toEqual([]) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts new file mode 100644 index 00000000000..993ee3fd258 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts @@ -0,0 +1,191 @@ +// After `/compact`, every reader that reports on the user's requests reads past the command: the +// sidebar's prompt, preview, verdict and instant, restart resume, and an older client's label. + +import { describe, expect, it } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { latestStructuredAgentSessionRequest } from '../../../shared/structured-agent-session-latest-request' +import { projectStructuredAgentSessionStatusSummary } from '../../../shared/structured-agent-session-projection' +import { projectTurnItemHistory } from '../../runtime/rpc/methods/structured-agent-session-turn-item-capability' +import { structuredAgentSessionWorkingAtStop } from './structured-agent-session-working-at-teardown' +import { + journal, + NOW, + record, + SESSION, + TEARDOWN_CURRENT +} from './structured-agent-session-restart-resume-test-harness' + +const PROMPT = agentJournalSubmissionKey('prompt-1') +const COMMAND = agentJournalSubmissionKey('command-1') +const REAL_TURN = 'legacy:codex:session:turn-lifecycle%3Aturn-1' +const COMMAND_TURN = 'orca:command-turn%3Acommand-1' + +let sequence = 0 +function item(itemId: string, body: AgentJournalRenderItem['body'], scope?: string) { + sequence += 1 + return { + itemId, + revision: 1, + body, + sequence, + observedAt: NOW, + turnScope: scope ? { kind: 'turn' as const, turnItemId: scope } : { kind: 'thread' as const } + } +} + +function accepted(clientMessageId: string): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: 'fp', + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: NOW, + resolvedAt: NOW + } +} + +/** "List three fruits", answered, then `/compact` in its own turn. */ +function compactedAfterARealTurn(commandState: 'running' | 'completed' = 'completed') { + sequence = 0 + const items: AgentJournalRenderItem[] = [ + item(PROMPT, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'List three fruits' }] + }), + item(REAL_TURN, { + kind: 'turn', + turnId: 'turn-1', + state: 'completed', + outcome: 'success', + userItemId: PROMPT, + startedAt: NOW, + completedAt: NOW + 1_000 + }), + item( + 'codex:answer', + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Apple, pear, fig' }] }, + REAL_TURN + ), + item(COMMAND, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + }), + item(COMMAND_TURN, { + kind: 'turn', + turnId: 'compact:command-1', + state: commandState, + userItemId: COMMAND, + startedAt: NOW + 5_000, + ...(commandState === 'completed' ? { outcome: 'success', completedAt: NOW + 9_000 } : {}) + }), + item( + 'codex:summary', + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Summary of it all' }] }, + COMMAND_TURN + ), + item( + 'orca:command-result%3Acommand-1', + { kind: 'status', text: 'Conversation compacted.', presentation: 'compaction' }, + COMMAND_TURN + ) + ] + return { items, submissions: [accepted('prompt-1'), accepted('command-1')] } +} + +describe('the sidebar after /compact (B7)', () => { + it('keeps the last real prompt, its answer, its verdict and its instant', () => { + const { items, submissions } = compactedAfterARealTurn() + expect(projectStructuredAgentSessionStatusSummary(items, submissions)).toEqual({ + status: 'idle', + latestPrompt: 'List three fruits', + lastAssistantMessage: 'Apple, pear, fig', + turnOutcome: 'success', + statusStartedAt: NOW + 1_000 + }) + }) + + it('reads working while the command runs', () => { + const { items, submissions } = compactedAfterARealTurn('running') + expect(projectStructuredAgentSessionStatusSummary(items, submissions)).toMatchObject({ + status: 'working', + latestPrompt: 'List three fruits' + }) + }) +}) + +describe('the completion feed around /compact (B6)', () => { + // The feed announces on a change of latest request; /compact running or settled leaves it be. + it.each(['running', 'completed'] as const)( + 'keeps the last real turn latest while it is %s', + (state) => { + const { items, submissions } = compactedAfterARealTurn(state) + expect(latestStructuredAgentSessionRequest(items, submissions)).toMatchObject({ + kind: 'turn', + id: 'turn-1', + running: false, + outcome: 'success' + }) + } + ) +}) + +describe('restart resume around /compact (B14)', () => { + function markerFor(items: AgentJournalRenderItem[], tasks: boolean) { + return structuredAgentSessionWorkingAtStop({ + sessionId: SESSION, + session: { journal: journal(items), child: { fence: 1 } }, + getRecord: () => record(), + backgroundTasks: () => + tasks ? [{ id: 'task-a', kind: 'agent', description: 'Review', state: 'working' }] : [], + trigger: 'quit', + teardownId: TEARDOWN_CURRENT, + now: NOW + }) + } + + it('records nothing while the command runs', () => { + expect(markerFor(compactedAfterARealTurn('running').items, false)).toBeNull() + }) + + it('anchors a settled lead with live children on its last real turn', () => { + const marker = markerFor(compactedAfterARealTurn().items, true) + expect(marker?.work).toEqual({ kind: 'turn', id: 'turn-1' }) + // The newest user message is still the last real prompt, as before commands were messages. + expect(marker?.latestUserItemId).toBe(PROMPT) + }) +}) + +describe("an older client's label for a command turn (B17)", () => { + it("names the session's agent, not the one its key suggests", () => { + const { items, submissions } = compactedAfterARealTurn() + const history = { + ok: true as const, + page: { + sessionId: SESSION, + epoch: 'epoch-1', + direction: 'tail' as const, + items, + removedItemIds: [], + submissions, + window: { oldest: null, newest: null, nextCursor: { epoch: 'epoch-1', sequence: 0 } }, + hasOlder: false, + hasNewer: false + } + } + const projected = projectTurnItemHistory(history, { clientKind: 'runtime' }, 'claude') + const commandTurn = projected.page.items.find((entry) => entry.itemId === COMMAND_TURN) + expect(commandTurn?.body).toMatchObject({ + kind: 'status', + text: expect.stringContaining('Claude') + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts new file mode 100644 index 00000000000..cb83dba870a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts @@ -0,0 +1,320 @@ +// A conversation command the user sent, such as `/compact`, carried out as a turn of its own. +// +// The command is an ordinary queued message until the delivery loop hands it over. There the loop +// opens the command's turn and sends it; the provider's receipt resolves the message, as it does +// any send. The provider child's journal translator ends the turn from the provider's own frames, +// and a child that ends first is settled with it. The host writes a command's end only when the +// provider never took it. While the turn runs it takes no input, so the loop hands nothing over. + +import { + agentSessionFailureFact, + type AgentSessionFailureFact, + type SubmissionRejectionFact +} from '../../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentJournalDispatchRejection, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' +import { + agentJournalItemKey, + agentJournalSubmissionKey, + parseAgentJournalItemKey +} from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalMessageItem, + type AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionConversationCommand } from '../../../shared/agent-session-conversation-command' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { agentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' +import { + agentJournalTurnBody, + readAgentJournalTurn +} from '../../../shared/agent-session-turn-record' +import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { + AgentSessionCommandAdmission, + StructuredAgentSessionAdapter, + StructuredAgentSessionProviderChildPhase +} from './structured-agent-session-adapter' +import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' +import { conversationCommandBlocked } from './structured-conversation-command-admission' + +export const STRUCTURED_AGENT_SESSION_COMPACT_COMMAND = 'compact' + +/** What the user sent for `/compact`: the text they typed, and the command it names. */ +export function structuredAgentSessionCompactBody(): AgentJournalMessageItem { + return { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: STRUCTURED_AGENT_SESSION_COMPACT_COMMAND } + } +} + +type AwaitedSubmission = Pick< + AgentJournalSubmission, + 'clientMessageId' | 'dispatchState' | 'acceptedSequence' +> + +/** Optional `submissions` as the dead-generation journal reads it. */ +export type StructuredAgentSessionAwaitedCommandJournal = { + submissions?: () => readonly AwaitedSubmission[] + itemBody: AgentSessionJournal['itemBody'] +} + +/** The command the oldest message still waiting on the provider names: a start that fails now + * fails that message first, so its next step is to run the command again. */ +export function structuredAgentSessionAwaitedCommand( + journal: StructuredAgentSessionAwaitedCommandJournal +): AgentSessionConversationCommand | undefined { + let oldest: AwaitedSubmission | undefined + for (const submission of journal.submissions?.() ?? []) { + if ( + submission.dispatchState === 'pending' && + (oldest === undefined || (submission.acceptedSequence ?? 0) < (oldest.acceptedSequence ?? 0)) + ) { + oldest = submission + } + } + const body = oldest && journal.itemBody(agentJournalSubmissionKey(oldest.clientMessageId)) + return body?.kind === 'message' && body.command?.name === STRUCTURED_AGENT_SESSION_COMPACT_COMMAND + ? STRUCTURED_AGENT_SESSION_COMPACT_COMMAND + : undefined +} + +/** The command's turn: its record and the `turnId` a Stop names. The `compact:` prefix is how the + * host's Stop and delivery gate tell a command's turn from any other. */ +export function structuredAgentSessionCommandTurn(clientMessageId: string): { + identity: AgentJournalItemIdentity + itemId: string + turnId: string + /** The command's one result row, inside its turn. */ + resultIdentity: AgentJournalItemIdentity +} { + const identity = { provider: 'orca' as const, clientMessageId: `command-turn:${clientMessageId}` } + return { + identity, + itemId: agentJournalItemKey(identity), + turnId: `compact:${clientMessageId}`, + resultIdentity: { provider: 'orca', clientMessageId: `command-result:${clientMessageId}` } + } +} + +/** Whether the journal's running turn is a command's, which takes no input while it runs. */ +export function structuredAgentSessionCommandRunning( + journal: Pick +): boolean { + const turnId = journal.activeTurnId() + return turnId !== null && isStructuredAgentSessionCommandTurnId(turnId) +} + +export function isStructuredAgentSessionCommandTurnId(turnId: string): boolean { + return turnId.startsWith('compact:') +} + +const STOP_NOTE_PREFIX = 'stop:' + +/** A Stop's note, on the turn it named. The key says what it is, so a later Stop can read it. */ +export function structuredAgentSessionStopNoteIdentity( + clientOperationId: string +): AgentJournalItemIdentity { + return { provider: 'orca', clientMessageId: `${STOP_NOTE_PREFIX}${clientOperationId}` } +} + +/** Whether an earlier Stop already asked the running command `turnId` names to end. Read from the + * journal, so nothing is held that could outlive the command. */ +export function structuredAgentSessionCommandWasStopped( + journal: Pick, + turnId: string +): boolean { + const { itemId } = structuredAgentSessionCommandTurn(turnId.slice('compact:'.length)) + return journal.snapshot().items.some((item) => { + const identity = parseAgentJournalItemKey(item.itemId) + return ( + item.turnScope?.kind === 'turn' && + item.turnScope.turnItemId === itemId && + identity?.provider === 'orca' && + identity.clientMessageId.startsWith(STOP_NOTE_PREFIX) + ) + }) +} + +export type StructuredAgentSessionCommandHandoverContext = { + sessionId: string + journal: AgentSessionJournal + fence: number + adapter: StructuredAgentSessionAdapter + providerChildPhase?: () => StructuredAgentSessionProviderChildPhase | undefined + /** Who a failure the handover meets names, as the start's own row does. */ + failureTextContext?: AgentSessionFailureWordsContext + record: () => AgentSessionRecord | null + flushStreamedEvents: () => Promise + now: () => number +} + +/** Refuses the command, or opens its turn and sends it. */ +export async function handOverStructuredAgentSessionCommand( + ctx: StructuredAgentSessionCommandHandoverContext, + submission: AgentJournalSubmission, + body: AgentJournalMessageItem +): Promise { + const { clientMessageId } = submission + // Provider frames already received decide whether a turn is running. + await ctx.flushStreamedEvents() + const blocked = commandBlocked(ctx, body) + if (blocked) { + await ctx.journal.resolveDispatch({ + clientMessageId, + state: 'rejected', + ...agentSessionFailureWords(blocked, { ...ctx.failureTextContext, surface: 'rejection' }), + fence: ctx.fence + }) + return + } + const turn = structuredAgentSessionCommandTurn(clientMessageId) + await ctx.journal.resolveDispatch({ + clientMessageId, + state: 'pending', + fence: ctx.fence, + turnScope: ctx.journal.liveTurnScope() + }) + const startedAt = ctx.now() + const running = agentJournalTurnBody({ + turnId: turn.turnId, + state: 'running', + userItemId: agentJournalSubmissionKey(clientMessageId), + requestedAt: structuredAgentSessionHandoverOrigin(ctx.journal, submission), + startedAt + }) + await ctx.journal.appendItem(turn.identity, running, { + fence: ctx.fence, + observedAt: startedAt, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + let admission: AgentSessionCommandAdmission + try { + admission = await ctx.adapter.compact!({ + sessionId: ctx.sessionId, + fence: ctx.fence, + command: { clientMessageId, ...turn, running } + }) + } catch (error) { + // A child that had not proven its start took nothing, so the command provably did not run. Any + // other throw is a lost reply: the command may have run. + const unsent = + ctx.providerChildPhase?.() === 'starting' + ? { + state: 'rejected' as const, + ...structuredAgentSessionStartFailure({ error }, ctx.failureTextContext) + } + : { + state: 'unknown' as const, + reason: error instanceof Error ? error.message : String(error) + } + await settleUnsentCommand(ctx, clientMessageId, unsent) + return + } + if (admission.state === 'rejected') { + // The provider refused the compaction itself: its row reads as the compaction failing. + await settleUnsentCommand( + ctx, + clientMessageId, + admission, + agentSessionFailureFact('compactionFailed', { detail: admission.rejection.detail }) + ) + } else if (admission.state !== 'admitted') { + // An unknown write leaves the turn to the provider's end or the child's: it may have run. + await ctx.journal.resolveDispatch({ clientMessageId, ...admission, fence: ctx.fence }) + } +} + +/** Where the turn a handed-over submission runs in starts counting: its handover, so time spent + * held behind a command or a start is not counted as the agent's work. */ +export function structuredAgentSessionHandoverOrigin( + journal: AgentSessionJournal, + submission: AgentJournalSubmission +): number { + const handedOver = journal + .submissions() + .find((entry) => entry.clientMessageId === submission.clientMessageId) + return handedOver?.handedOverAt ?? submission.submittedAt +} + +/** The command's end when the provider never took it: refused, or lost with the adapter's throw. + * The message's answer goes first, so a crash before the turn's end leaves a running turn, which + * the stale-turn sweep settles, never an ended turn whose message still reads as in flight. */ +async function settleUnsentCommand( + ctx: StructuredAgentSessionCommandHandoverContext, + clientMessageId: string, + unsent: + | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | { state: 'unknown'; reason: string }, + /** What the result row reports, when it is not the rejection's own fact. */ + rowFailure?: AgentSessionFailureFact +): Promise { + const turn = structuredAgentSessionCommandTurn(clientMessageId) + const running = readAgentJournalTurn(ctx.journal.itemBody(turn.itemId) ?? undefined) + await ctx.journal.resolveDispatch({ clientMessageId, ...unsent, fence: ctx.fence }) + if (running?.state !== 'running') { + return + } + const refused = unsent.state === 'rejected' + const mutations: JournalLifecycleMutationInput[] = [ + ...(refused + ? [ + { + kind: 'item' as const, + identity: turn.resultIdentity, + body: { + kind: 'status' as const, + ...agentSessionFailureWords(rowFailure ?? unsent.rejection, { + ...ctx.failureTextContext, + surface: 'row' + }), + tone: 'error' as const + }, + turnScope: { kind: 'turn' as const, turnItemId: turn.itemId } + } + ] + : []), + { + kind: 'item', + identity: turn.identity, + body: agentJournalTurnBody({ + ...running, + ...(refused + ? { state: 'completed', outcome: 'failure', completedAt: ctx.now() } + : { state: 'unverifiable' }) + }), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] + await ctx.journal.appendLifecycleBatch({ + settlementId: `command-settled:${clientMessageId}`, + fence: ctx.fence, + mutations + }) +} + +/** Why the command may not run now, as the fact its message is rejected with; null when it may. */ +function commandBlocked( + ctx: StructuredAgentSessionCommandHandoverContext, + body: AgentJournalMessageItem +): SubmissionRejectionFact | null { + if (body.command?.name !== STRUCTURED_AGENT_SESSION_COMPACT_COMMAND || !ctx.adapter.compact) { + return agentSessionFailureFact('commandRefused') + } + const record = ctx.record() + if (!record) { + return agentSessionFailureFact('hostFault') + } + const refusal = conversationCommandBlocked(ctx, record, 'handover') + return refusal + ? agentSessionFailureFact('commandRefused', { refusal: agentSessionRefusalReference(refusal) }) + : null +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts new file mode 100644 index 00000000000..bf3cd1f96a6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -0,0 +1,326 @@ +// Stopping an agent and closing its conversation's handle are two things, and a reader, a session +// list and a send each see only the one that happened. Ticks are driven by hand. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import { readStructuredSessionGateFacts } from '../../runtime/orchestration/structured-mailbox-pointer-host' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { setStructuredAgentSessionHost } from './structured-agent-session-registry' +import { + collectSubscriber, + createRestTestRig, + foundRestTestChat, + IDLE_MS, + readerSaw, + REST_TEST_CALLER as CALLER, + REST_TEST_SESSION as SESSION, + restTestSend, + sweepOnce, + type RestTestRig +} from './structured-agent-session-rest-test-rig' +import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep' +import { hostTestAttachParams } from './structured-agent-session-host-test-data' + +let rig: RestTestRig + +const COLD_START = { timeout: 10_000 } + +beforeEach(async () => { + rig = await createRestTestRig({ idleSweep: { intervalMs: 3_600_000 } }) + setStructuredAgentSessionHost(rig.host) +}) + +afterEach(async () => { + setStructuredAgentSessionHost(null) + await rig.dispose() +}) + +function fence(): number { + return rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 1 +} + +function openSession(): StructuredAgentSessionHostSession | undefined { + return rig.host.collaboratorsForTests().sessions.get(SESSION) +} + +function statusRows(): AgentSessionStatusSummary[] { + return rig.statusEvents + .flatMap((event) => (event.type === 'status' ? [event.session] : [])) + .filter((summary) => summary.sessionId === SESSION) +} + +describe('a stop that fails', () => { + it('is retried on the next tick (P2-11 a)', async () => { + await foundRestTestChat(rig) + rig.adapter.closeSession.mockResolvedValueOnce(false) + rig.clock.now += IDLE_MS + 1 + + await sweepOnce(rig.host) + expect(openSession()?.owesProviderChildWindDown).toMatchObject({ + generation: expect.any(String) + }) + await sweepOnce(rig.host) + + expect(rig.adapter.closeSession).toHaveBeenCalledTimes(2) + expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + expect(statusRows().at(-1)?.hostExecutionOwned).toBeUndefined() + }) + + it('finishes its wind-down on the next tick, whatever its own writes did to the clock (P2-11 b)', async () => { + await foundRestTestChat(rig) + rig.clock.now += IDLE_MS + 1 + // The child is proven gone and its work settled, then handing the lease back fails. + const transition = vi + .spyOn(rig.store, 'transitionHandoff') + .mockRejectedValueOnce(new Error('store unavailable')) + + await sweepOnce(rig.host) + expect(transition).toHaveBeenCalledOnce() + expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('live') + // Five minutes later, not thirty. + rig.clock.now += 5 * 60_000 + await sweepOnce(rig.host) + + expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + expect(rig.adapter.closeSession).toHaveBeenCalledOnce() + }) +}) + +describe('closing the handle', () => { + it('keeps a tabbed chat listed and its reader open, and a send reopens it for that reader (P2-14)', async () => { + await foundRestTestChat(rig) + const reader = collectSubscriber() + await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) + rig.clock.now += IDLE_MS + 1 + + await sweepOnce(rig.host) + expect(rig.host.hasSession(SESSION)).toBe(false) + // The row stays in the agent-status store, idle and no longer running here, and a session + // list that connects now still gets it. + expect(rig.sink.forget).not.toHaveBeenCalled() + expect(rig.sink.publish.mock.calls.at(-1)?.[0]).toMatchObject({ sessionId: SESSION }) + expect(statusRows().at(-1)?.hostExecutionOwned).toBeUndefined() + const late: { type: string; sessions?: AgentSessionStatusSummary[] }[] = [] + rig.host.subscribeStatus({ id: 'late', emit: (event) => late.push(event) }) + expect(late[0]?.sessions?.map((summary) => summary.sessionId)).toContain(SESSION) + expect(reader.events.some((event) => event.type === 'end')).toBe(false) + + const sent = await rig.host.send(CALLER, restTestSend('after the close', fence())) + expect(sent.ok).toBe(true) + // A cold start: reopen, reconcile and acquire. + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2), COLD_START) + // The reader opened on the first handle is fed by the second. + await vi.waitFor(() => expect(readerSaw(reader.events).texts).toContain('after the close')) + }) + + it('drops the agent-status row of a chat whose tab is gone (P2-14, retired)', async () => { + await foundRestTestChat(rig) + await rig.store.setSessionTabVisibility(SESSION, false) + rig.clock.now += IDLE_MS + 1 + + await sweepOnce(rig.host) + expect(rig.host.hasSession(SESSION)).toBe(false) + expect(rig.sink.forget).toHaveBeenCalled() + }) + + it('forgets the row of a resting chat once its tab closes (P2-32)', async () => { + await foundRestTestChat(rig) + rig.clock.now += IDLE_MS + 1 + await sweepOnce(rig.host) + expect(rig.sink.forget).not.toHaveBeenCalled() + + await rig.host.setSessionTabVisibility(SESSION, false) + expect(rig.sink.forget).toHaveBeenCalled() + }) + + it('keeps the row when a chat with an open tab is evicted, and forgets it once the tab closes', async () => { + await foundRestTestChat(rig) + + await rig.host.close(SESSION) + expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) + // The stop says not-running; the row belongs to the tab, so nothing forgets it. + expect(rig.sink.forget).not.toHaveBeenCalled() + expect(rig.sink.publish.mock.calls.at(-1)?.[0]).toMatchObject({ sessionId: SESSION }) + expect(rig.sink.publish.mock.calls.at(-1)?.[0].hostExecutionOwned).toBeUndefined() + + await rig.host.setSessionTabVisibility(SESSION, false) + expect(rig.sink.forget).toHaveBeenCalledOnce() + }) + + it('never hands a reader a handle it is closing (P2-25)', async () => { + await foundRestTestChat(rig) + const journal = openSession()?.journal + if (!journal) { + throw new Error('the conversation should be open') + } + const closing = Promise.withResolvers() + const gate = Promise.withResolvers() + const close = journal.close.bind(journal) + vi.spyOn(journal, 'close').mockImplementation(async () => { + await close() + closing.resolve() + await gate.promise + }) + rig.clock.now += IDLE_MS + 1 + const tick = sweepOnce(rig.host) + await closing.promise + + const history = rig.host.history({ sessionId: SESSION, direction: 'tail' }) + const facts = readStructuredSessionGateFacts(SESSION) + gate.resolve() + await tick + + const page = await history + expect(page.ok && page.page.items.length).toBeGreaterThan(0) + await expect(facts).resolves.not.toBeNull() + // And the handle a write finds is a live one. + const sent = await rig.host.send(CALLER, restTestSend('after the close', fence())) + expect(sent.ok).toBe(true) + // A cold start: reopen, reconcile and acquire. + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2), COLD_START) + }) + + it('never re-enters the session lock: a send and a Stop right after still finish (P2-28)', async () => { + await foundRestTestChat(rig) + rig.clock.now += IDLE_MS + 1 + const tick = sweepOnce(rig.host) + const within = (work: Promise) => + Promise.race([ + work, + new Promise((_, reject) => setTimeout(() => reject(new Error('wedged')), 2_000)) + ]) + const sent = within(rig.host.send(CALLER, restTestSend('right after', fence()))) + + await within(tick) + expect(await sent).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) + await expect(within(sweepOnce(rig.host))).resolves.toBeUndefined() + }) +}) + +describe('the sweep and the lease (P2-20)', () => { + it('reads only open conversations and never probes or resolves a lease', async () => { + await foundRestTestChat(rig) + const probeOwner = vi.fn(async () => ({ outcome: 'pid-absent' as const })) + await rig.restart({ probeOwner }) + setStructuredAgentSessionHost(rig.host) + await rig.store.transitionHandoff(SESSION, (current) => ({ + ...current, + lease: { ...current.lease, handoffStage: 'recovering' } + })) + const resolveRecovery = vi.spyOn( + rig.host.collaboratorsForTests().runtimeState, + 'resolveRecovery' + ) + // Open, at rest, on a lease still recovering. + await rig.host.journalSnapshot(SESSION) + expect(rig.host.hasSession(SESSION)).toBe(true) + rig.clock.now += IDLE_MS + 1 + + await sweepOnce(rig.host) + expect(rig.host.hasSession(SESSION)).toBe(false) + expect(probeOwner).not.toHaveBeenCalled() + expect(resolveRecovery).not.toHaveBeenCalled() + }) +}) + +describe('a start that never finishes (P2-15)', () => { + it('is stopped by the sweep, and the delivery loop alone writes its one row and rejection', async () => { + const stopReason = 'Codex never finished starting, so Orca stopped it.' + const order: string[] = [] + const started = Promise.withResolvers() + rig.adapter.closeSession.mockImplementation(async () => { + order.push('stopped') + started.resolve() + return true + }) + const spawn = rig.adapter.acquire.getMockImplementation()! + rig.adapter.acquire.mockImplementationOnce(async (input) => ({ + ...(await spawn(input)), + providerChildPhase: 'starting' as const + })) + Object.assign(rig.host.deps.adapter, { awaitStarted: () => started.promise }) + const reject = AgentSessionJournal.prototype.rejectQueuedSubmissions + vi.spyOn(AgentSessionJournal.prototype, 'rejectQueuedSubmissions').mockImplementation(function ( + this: AgentSessionJournal, + ...args + ) { + // Not the open's sweep of an earlier process's leftovers. + if (args[1].rejection.kind !== 'hostRestarted') { + order.push(`rejected: ${args[1].reason}`) + } + return reject.apply(this, args) + }) + const reader = collectSubscriber() + const attached = await rig.host.attach(CALLER, hostTestAttachParams(null)) + expect(attached.ok).toBe(true) + await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) + const sent = await rig.host.send(CALLER, restTestSend('stuck behind the start', fence())) + expect(sent.ok).toBe(true) + rig.clock.now += IDLE_MS + 1 + + await sweepOnce(rig.host) + expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) + await vi.waitFor(() => + expect(readerSaw(reader.events).submissions).toContainEqual( + expect.objectContaining({ dispatchState: 'rejected', reason: stopReason }) + ) + ) + // One rejection, written after the stop by the loop, with the stop's own words. + expect(order).toEqual(['stopped', `rejected: ${stopReason}`]) + const errorRows = reader.events.flatMap((event) => + event.type === 'batch' + ? event.batch.items.filter( + (item) => item.body.kind === 'status' && item.body.tone === 'error' + ) + : [] + ) + expect(errorRows.map((item) => (item.body.kind === 'status' ? item.body.text : null))).toEqual([ + stopReason + ]) + expect(rig.adapter.dispatch).not.toHaveBeenCalled() + + const again = await rig.host.send(CALLER, restTestSend('try again', fence())) + expect(again.ok).toBe(true) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce(), COLD_START) + await expect(sweepOnce(rig.host)).resolves.toBeUndefined() + }) +}) + +describe('the wind-down retry with a message queued (P2-31)', () => { + it('waits for the delivery rather than rejecting a message accepted after the failed stop', async () => { + const queued = { clientMessageId: 'm', dispatchState: 'pending', handoverRecorded: true } + const session = { + journal: { + submissions: () => [queued], + pendingSubmissions: () => [queued], + snapshot: () => ({ items: [] }) + }, + child: null, + owesProviderChildWindDown: { generation: 'generation-1', fence: 1 } + } + const stopAgent = vi.fn(async () => undefined) + const sweep = new StructuredAgentSessionIdleSweep({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a session fixture carrying only the journal and child facts the sweep reads. + sessions: Object.assign(new Map([[SESSION, session as never]]), { + lastActivityAt: () => 0, + touch: () => undefined + }), + serialize: (_id, task) => task(), + now: () => IDLE_MS + 1, + isDisposed: () => false, + deliveryActive: () => true, + backgroundTaskState: () => undefined, + hasOpenDispatch: () => false, + stopAgent, + stopStartingAgent: stopAgent, + closeConversation: vi.fn(async () => false), + onError: (_id, error) => { + throw error + } + }) + await sweep.tick() + expect(stopAgent).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts new file mode 100644 index 00000000000..0fbd1a0a233 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -0,0 +1,148 @@ +// How a conversation is reached, closed and swept — the host's lifetime rules in one place. +// +// A conversation is reached only through `conversation`, which opens it at rest and starts no +// agent. It closes when its tab closes or the idle sweep finds its handle is only a cache. Every +// public entry point here takes the session's serialize once and calls the under-serialize forms, +// because the queue is not reentrant. + +import { + AgentSessionRefusalError, + agentSessionRefusalError +} from '../../../shared/agent-session-wire-refusals' +import { createJournalOpenReadRefusals } from '../agent-session-journal/journal-open-failure' +import type { StructuredAgentSessionConversations } from './structured-agent-session-conversations' +import { + abandonQueuedStructuredAgentSessionMessages, + closeStructuredAgentSessionConversationUnderSerialize, + stopStructuredAgentSessionAgentUnderSerialize, + type StructuredAgentSessionLifetimeContext +} from './structured-agent-session-host-lifetime' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep' +import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' +import { adapterSupportsRecord } from './structured-agent-session-provider-support' + +export type StructuredAgentSessionConversationLifetime = ReturnType< + typeof createStructuredAgentSessionConversationLifetime +> + +export function createStructuredAgentSessionConversationLifetime(host: { + /** Resolved per call: the host's collaborators are assigned after this is built. */ + context: () => StructuredAgentSessionLifetimeContext + sessions: StructuredAgentSessionConversations + serialize: (sessionId: string, task: () => Promise) => Promise + /** PR 1's one open function, for a caller inside the session's serialize. */ + open: (sessionId: string) => Promise + deliveryActive: (sessionId: string) => boolean + /** The handle closed: `listed` keeps the chat's row in the agent-status store for its tab. */ + closeStatus: (sessionId: string, options: { listed: boolean }) => void +}) { + let disposed = false + const { sessions, serialize } = host + const deps = () => host.context().deps + const readRefusals = createJournalOpenReadRefusals() + // The sweep's stop puts an idle agent to rest: nothing is queued, so no loop reads its cause. + const stopAgent = (sessionId: string) => + stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId) + + const closeConversation = (sessionId: string): Promise => + closeStructuredAgentSessionConversationUnderSerialize( + { + sessions, + closeStatus: (id) => { + const tabs = deps().store.getVisibleSessionTabIndex() + // A legacy store cannot say, so the row stays; restart is the boundary that forgets. + host.closeStatus(id, { listed: !tabs.present || tabs.sessionIds.includes(id) }) + } + }, + sessionId + ) + + const idleSweep = new StructuredAgentSessionIdleSweep({ + sessions, + serialize, + now: () => host.context().now(), + isDisposed: () => disposed, + deliveryActive: host.deliveryActive, + backgroundTaskState: (sessionId) => deps().adapter.backgroundTaskState?.(sessionId), + hasOpenDispatch: (sessionId) => { + const record = deps().store.getRecord(sessionId) + return record !== null && deps().hasOpenDispatch?.(record) === true + }, + stopAgent, + // A host stop: the delivery loop waiting on this child writes the one error row and rejects + // what is queued with it, both worded from the hostStopped fact. + stopStartingAgent: (sessionId) => + stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { + cause: 'host-stop' + }), + closeConversation, + onError: (sessionId, error) => deps().onEventSinkError?.({ sessionId, error }), + ...deps().idleSweep + }) + + return { + idleSweep, + stopAgent, + /** Quit has begun: nothing opens a conversation or sweeps one after this. */ + dispose: (): void => { + disposed = true + idleSweep.dispose() + }, + /** + * The only way any code reaches a session. An open conversation answers without the lock, so + * a read never waits behind a start; a closed one is opened once, under it. Nothing here + * touches the lease or starts a child. Use the result before the next `await`: a close can + * drop it after. + */ + conversation: async (sessionId: string): Promise => { + const open = sessions.get(sessionId) + if (open) { + readRefusals.forget(sessionId) + return open + } + const record = deps().store.getRecord(sessionId) + if (!record) { + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'recordMissing' + }) + } + if (!adapterSupportsRecord(deps().adapter, record)) { + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) + } + return serialize(sessionId, async () => { + // Read at the open itself: a read queued before quit began runs after it. + if (disposed) { + throw new AgentSessionRefusalError(AGENT_SESSION_NOT_ATTACHED) + } + const session = await host.open(sessionId).catch((error: unknown) => { + throw readRefusals.refusal(sessionId, error) + }) + if (!session) { + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'recordMissing' + }) + } + readRefusals.forget(sessionId) + return session + }) + }, + /** Ends a chat's resources, not the chat: its record and journal stay on disk, and what is + * still queued will not be sent. */ + close: (sessionId: string): Promise => + serialize(sessionId, async () => { + readRefusals.forget(sessionId) + const session = sessions.get(sessionId) + if (session) { + // Abandoned before the stop, so no start delivers it. + await abandonQueuedStructuredAgentSessionMessages(deps(), sessionId, session.journal) + } + await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { + cause: 'evict' + }) + await closeConversation(sessionId) + }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts new file mode 100644 index 00000000000..dd8853954e3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts @@ -0,0 +1,186 @@ +// The one way a conversation's journal becomes open on this host: for a send, for a reader, and +// for an attach that finds none open. +// +// It opens with recovery, so an unusable journal is rebuilt rather than refused, and it marks what +// an earlier host process handed over and left unanswered as in doubt, and settles what it left +// running — the crash boundary. That +// needs no lease: provider history decides such a row later, under a won lease, in the attach. A +// row an earlier process accepted and never handed over is the delivery loop's, which the open +// wakes. Nothing here starts a provider child. + +import type { AgentJournalResetReason } from '../../../shared/agent-session-journal-types' +import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { openAgentSessionJournalWithRecovery } from './agent-session-journal-recovery' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + attachFingerprintFields, + journalIdentityFor, + type AgentSessionAttachParams +} from './structured-agent-session-attach' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' +import type { + StructuredAgentSessionHostDeps, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' + +export type OpenedStructuredAgentSessionConversation = { + session: StructuredAgentSessionHostSession + /** Set when the journal was rebuilt on the way; readers reload from a snapshot. */ + reset: AgentJournalResetReason | null +} + +export type StructuredAgentSessionConversationOpenDeps = { + store: Pick + adapter: Pick + journalRoot: string + onEventSinkError?: StructuredAgentSessionHostDeps['onEventSinkError'] +} + +/** An acquisition's own open: its reserve cleared the record's death evidence, so it settles + * what the gone generation left running itself, from what it read before. */ +export type StructuredAgentSessionConversationOpenOptions = { acquisition?: boolean } + +export type StructuredAgentSessionConversationOpenContext = { + deps: StructuredAgentSessionConversationOpenDeps + sessions: Map + /** Indexes a conversation that just became open; the host publishes it and wakes delivery. */ + adoptOpened: ( + sessionId: string, + opened: OpenedStructuredAgentSessionConversation + ) => Promise +} + +/** The open conversation, or null when this host has no record of it. For a caller inside the + * session's serialize, which is what makes "not open yet" exact. */ +export async function openStructuredAgentSessionConversation( + context: StructuredAgentSessionConversationOpenContext, + sessionId: string, + options: StructuredAgentSessionConversationOpenOptions = {} +): Promise { + const open = context.sessions.get(sessionId) + if (open) { + return open + } + const record = context.deps.store.getRecord(sessionId) + if (!record) { + return null + } + const opened = await openStructuredAgentSessionConversationJournal(context.deps, record, options) + await context.adoptOpened(sessionId, opened) + return opened.session +} + +/** The open itself, indexed by nobody yet: the caller adopts the result. */ +export async function openStructuredAgentSessionConversationJournal( + deps: Omit, + record: AgentSessionRecord, + options: StructuredAgentSessionConversationOpenOptions = {} +): Promise { + const { sessionId } = record + const fence = record.lease.runtimeFence + const params = attachParamsForRecord(record, { + clientOperationId: `read-restore:${sessionId}`, + expectedRuntimeFence: fence + }) + const identity = journalIdentityFor(record, params) + const opened = await openAgentSessionJournalWithRecovery({ + identity, + journalDir: journalDirectoryFor(deps.journalRoot, { + workspaceId: record.location.workspaceId, + sessionId + }), + fence, + historyFilePath: (await deps.adapter.historyFilePath?.({ identity })) ?? null + }) + try { + // A queued row found here is a leftover the delivery loop's first step rejects; a handed-over + // one is only doubt, which provider history decides under a won lease. + await opened.journal.markPendingSubmissionsUnknown(fence) + } catch (error) { + deps.onEventSinkError?.({ sessionId, error }) + } + // No child in this process writes to a journal nobody had open, so whatever it shows running + // belongs to a generation that is gone, whatever the lease still claims. Settled before any + // reader or child sees it. + if (!options.acquisition) { + await settleGoneGeneration(deps, record, opened.journal) + } + return { + session: { journal: opened.journal, params, child: null }, + reset: opened.recovery?.reset ?? null + } +} + +/** + * The open's settle again, for a conversation already open: a proof of death written since it + * opened (the startup reconcile, a recovery) revises what the open could only call `unverifiable`. + * A record holds a proof only while released, so no child here is writing. A no-op once revised. + */ +export async function resettleOpenStructuredAgentSessionConversation( + deps: StructuredAgentSessionConversationOpenDeps, + sessionId: string, + session: StructuredAgentSessionHostSession | undefined +): Promise { + const record = deps.store.getRecord(sessionId) + if (session && record?.lease.deathEvidence) { + await settleGoneGeneration(deps, record, session.journal) + } +} + +async function settleGoneGeneration( + deps: Pick, + record: AgentSessionRecord, + journal: AgentSessionJournal +): Promise { + try { + await settleStaleStructuredAgentSessionState({ + journal, + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + acquisitionGeneration: null, + deathEvidence: record.lease.deathEvidence ?? null, + failureTextContext: structuredAgentSessionFailureWordsContext(record) + }) + } catch (error) { + // Best effort: the next open or acquire re-derives it. + deps.onEventSinkError?.({ sessionId: record.sessionId, error }) + } +} + +export function attachParamsForRecord( + record: AgentSessionRecord, + input: { + clientOperationId: string + expectedRuntimeFence: number + } +): AgentSessionAttachParams { + const params: AgentSessionAttachParams = { + envelope: { + sessionId: record.sessionId, + clientOperationId: input.clientOperationId, + expectedRuntimeFence: input.expectedRuntimeFence, + payloadFingerprint: '' + }, + location: record.location, + provider: record.provider, + agent: record.provider, + accountHome: record.accountHome, + runtimeKind: 'native' + } + return { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: record.sessionId, + fields: attachFingerprintFields(params) + }) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts new file mode 100644 index 00000000000..ab0368a6b92 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -0,0 +1,277 @@ +// A Stop that names no turn stops what the conversation has in flight: it withdraws what is +// queued, and interrupts a handed-over message even before the provider has opened its turn — +// the gap no client can name a turn for. Against the real host, store and journal. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { CancelParams } from '../../../shared/rpc-contract/structured-agent-session-params' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } +const ALREADY_FINISHED = 'The provider had already finished this turn.' + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let dispatch: Mock +let cancelTurn: Mock +let awaitStarted: Mock> + +function eventually(assertion: () => void | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-conversation-stop-')) + resetHostTestOperationIds() + // Admitted, not accepted: the message is written and its turn has not opened. + dispatch = vi.fn(async () => ({ state: 'admitted' as const })) + cancelTurn = vi.fn(async () => ({ cancelled: true })) + awaitStarted = vi.fn(async () => undefined) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire: async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + acquisitionGeneration: 'generation-1', + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: 'created' as const, + mintedAtFence: fence, + observedAt: NOW + } + }), + dispatch, + awaitStarted, + closeSession: vi.fn(async () => true), + releaseAcquisition: vi.fn(async () => true), + cancelTurn, + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-1', + now: () => NOW + }) + expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function send(text: string) { + const body = hostTestMessage(text) + const clientOperationId = hostTestOperationId() + const result = host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId, + expectedRuntimeFence: 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + return { id: clientOperationId, result } +} + +function stop(turnId?: string, clientOperationId = hostTestOperationId()) { + const fields = turnId === undefined ? {} : { turnId } + return host.cancel(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId, + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: SESSION, + fields + }) + }, + ...fields + }) +} + +async function submission(id: string): Promise { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === id + ) +} + +async function statusRows(): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [item.body.text] : [] + ) +} + +describe('a Stop that names no turn', () => { + it('is a valid cancel, and only a plain Stop may omit the turn', () => { + const envelope = { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: 1, + payloadFingerprint: '0'.repeat(64) + } + expect(CancelParams.safeParse({ envelope }).success).toBe(true) + expect( + CancelParams.safeParse({ envelope, prompt: { itemId: 'item-1', expectedRevision: 1 } }) + .success + ).toBe(false) + expect(CancelParams.safeParse({ envelope, scope: 'background-tasks' }).success).toBe(false) + }) + + it('interrupts a handed-over message before its turn opens, as a cancellation', async () => { + const { id, result } = send('hello') + await result + await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) + expect( + (await host.journalSnapshot(SESSION)).items.some((item) => item.body.kind === 'turn') + ).toBe(false) + + const stopped = await stop() + + expect(stopped).toEqual({ + ok: true, + replayed: false, + fence: 1, + cursor: expect.anything(), + value: { cancelled: true } + }) + expect(cancelTurn).toHaveBeenCalledTimes(1) + expect(cancelTurn.mock.calls[0]![0]).not.toHaveProperty('turnId') + expect(cancelTurn.mock.calls[0]![0]).toMatchObject({ sessionId: SESSION, fence: 1 }) + expect(await statusRows()).toEqual(['Cancellation requested.']) + }) + + it('withdraws what is queued on a ready child and asks the provider for nothing more', async () => { + const started = Promise.withResolvers() + awaitStarted.mockImplementationOnce(() => started.promise) + const { id, result } = send('hello') + await result + await eventually(() => expect(awaitStarted).toHaveBeenCalled()) + + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + started.resolve(undefined) + + expect(await submission(id)).toMatchObject({ + dispatchState: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + }) + expect(cancelTurn).not.toHaveBeenCalled() + await host.flushStreamedEvents(SESSION) + expect(dispatch).not.toHaveBeenCalled() + expect(await statusRows()).toEqual([]) + }) + + it('withdraws a send still on its way in, which the host takes first', async () => { + const { id, result } = send('hello') + const stopped = stop() + + expect(await result).toMatchObject({ ok: true }) + expect(await stopped).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(await submission(id)).toMatchObject({ + dispatchState: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + }) + await host.flushStreamedEvents(SESSION) + expect(dispatch).not.toHaveBeenCalled() + }) + + it('says the agent did not stop, in its words, when it refused', async () => { + const { id, result } = send('hello') + await result + await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) + cancelTurn.mockResolvedValueOnce({ + cancelled: false, + refusal: { detail: { text: 'no active turn to interrupt', audience: 'person' } } + }) + + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) + + expect(cancelTurn).toHaveBeenCalledOnce() + expect(await statusRows()).toEqual(["Codex didn't stop: no active turn to interrupt."]) + }) + + it('says the Stop is unconfirmed, not that nothing ran, when the provider took it', async () => { + const { id, result } = send('hello') + await result + await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) + cancelTurn.mockResolvedValueOnce({ cancelled: false, unconfirmed: true }) + + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) + + expect(await statusRows()).toEqual(['Cancellation was not confirmed.']) + }) + + it('says the agent had no turn to stop when it had none', async () => { + const { id, result } = send('hello') + await result + await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) + cancelTurn.mockResolvedValueOnce({ cancelled: false }) + + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) + + expect(await statusRows()).toEqual(['Codex had no turn running to stop.']) + }) + + it('stops nothing when it reuses the id of a Stop the host already ran', async () => { + const operationId = hostTestOperationId() + expect(await stop(undefined, operationId)).toMatchObject({ ok: true, replayed: false }) + const { id, result } = send('hello') + await result + await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) + + // Why the client never reuses a no-turn Stop's id: the same id is the same Stop. + expect(await stop(undefined, operationId)).toMatchObject({ + ok: true, + replayed: true, + value: { cancelled: false } + }) + expect(cancelTurn).not.toHaveBeenCalled() + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(cancelTurn).toHaveBeenCalledOnce() + }) + + it('is a quiet no-op with nothing in flight', async () => { + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) + expect(cancelTurn).not.toHaveBeenCalled() + expect(await statusRows()).toEqual([]) + }) +}) + +describe('a Stop that names its turn, as an older client sends it', () => { + it('reaches the provider with that turn and keeps its not-cancelled note', async () => { + cancelTurn.mockResolvedValueOnce({ cancelled: false }) + + expect(await stop('turn-1')).toMatchObject({ + ok: true, + value: { turnId: 'turn-1', cancelled: false } + }) + expect(cancelTurn).toHaveBeenCalledWith(expect.objectContaining({ turnId: 'turn-1' })) + expect(await statusRows()).toEqual([ALREADY_FINISHED]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts new file mode 100644 index 00000000000..954525c6429 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts @@ -0,0 +1,161 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { StructuredAgentSessionConversations } from './structured-agent-session-conversations' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { hostTestAttachParams } from './structured-agent-session-host-test-data' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +const journals = createTrackedJournalOpener() +let root: string | null = null + +afterEach(async () => { + await journals.closeAll() + if (root) { + await rm(root, { recursive: true, force: true }) + root = null + } +}) + +async function openJournal(name: string): Promise { + root ??= await mkdtemp(join(tmpdir(), 'orca-conversations-')) + return journals.open({ identity: IDENTITY, journalDir: join(root, name) }) +} + +function session(journal: AgentSessionJournal) { + return { + journal, + params: hostTestAttachParams(null), + child: null + } +} + +function appendStatus(journal: AgentSessionJournal, text: string) { + return journal.appendItem( + { provider: 'orca', clientMessageId: text }, + { kind: 'status', text }, + { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} + +describe('a conversation delivers what its journal commits', () => { + it('delivers an append that no writer published', async () => { + const deliver = vi.fn() + const conversations = new StructuredAgentSessionConversations({ + deliver, + onDeliveryError: vi.fn(), + now: () => 0 + }) + const journal = await openJournal('a') + conversations.set('session-1', session(journal)) + + await appendStatus(journal, 'written') + + // Already delivered when the writer's await returns, as an explicit publish would have been. + expect(deliver).toHaveBeenCalledOnce() + expect(deliver).toHaveBeenCalledWith('session-1', journal) + }) + + it('binds a handle set through a plain map reference', async () => { + const deliver = vi.fn() + // Collaborators hold the host's map as a plain `Map`; `set` still reaches the binding. + const sessions: Map = + new StructuredAgentSessionConversations({ + deliver, + onDeliveryError: vi.fn(), + now: () => 0 + }) + const journal = await openJournal('a') + sessions.set('session-1', session(journal)) + + await appendStatus(journal, 'through the plain map') + + expect(deliver).toHaveBeenCalledExactlyOnceWith('session-1', journal) + }) + + it('delivers an epoch replacement, which readers must reload from', async () => { + const deliver = vi.fn() + const conversations = new StructuredAgentSessionConversations({ + deliver, + onDeliveryError: vi.fn(), + now: () => 0 + }) + const journal = await openJournal('a') + conversations.set('session-1', session(journal)) + + await journal.replaceEpochItems('handle_forked', 0, []) + + expect(deliver).toHaveBeenCalledExactlyOnceWith('session-1', journal) + }) + + it('delivers nothing for a handle the conversation has replaced', async () => { + const deliver = vi.fn() + const conversations = new StructuredAgentSessionConversations({ + deliver, + onDeliveryError: vi.fn(), + now: () => 0 + }) + const replaced = await openJournal('a') + const current = await openJournal('b') + conversations.set('session-1', session(replaced)) + conversations.set('session-1', session(current)) + + await appendStatus(replaced, 'stale') + expect(deliver).not.toHaveBeenCalled() + + await appendStatus(current, 'live') + expect(deliver).toHaveBeenCalledExactlyOnceWith('session-1', current) + }) + + it('delivers nothing once the conversation is dropped', async () => { + const deliver = vi.fn() + const conversations = new StructuredAgentSessionConversations({ + deliver, + onDeliveryError: vi.fn(), + now: () => 0 + }) + const journal = await openJournal('a') + conversations.set('session-1', session(journal)) + conversations.delete('session-1') + + await appendStatus(journal, 'after close') + + expect(deliver).not.toHaveBeenCalled() + }) + + it('reports a reader failure without failing the durable write', async () => { + const failure = new Error('reader failed') + const onDeliveryError = vi.fn() + const conversations = new StructuredAgentSessionConversations({ + deliver: () => { + throw failure + }, + onDeliveryError, + now: () => 0 + }) + const journal = await openJournal('a') + conversations.set('session-1', session(journal)) + + await expect(appendStatus(journal, 'durable')).resolves.toMatchObject({ + itemId: expect.any(String) + }) + + expect(onDeliveryError).toHaveBeenCalledExactlyOnceWith('session-1', failure) + expect(journal.snapshot().items.map((item) => item.body)).toContainEqual({ + kind: 'status', + text: 'durable' + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts new file mode 100644 index 00000000000..c312d6013fd --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts @@ -0,0 +1,73 @@ +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' + +/** + * The host's open conversations. A journal handle becomes a conversation's when it is set here, + * and from then on every change it commits reaches that conversation's readers: no writer + * publishes what it appended, so none can forget to. + * + * Delivery runs a microtask after the commit, so a reader that throws cannot fail a write that is + * already durable. A handle this map has since replaced or dropped delivers nothing. + * + * Each entry also carries when it last saw activity — its open, then every journal publish — which + * is the idle sweep's clock. In memory only, so it dies with the entry. + */ +export class StructuredAgentSessionConversations extends Map< + string, + StructuredAgentSessionHostSession +> { + private readonly activity = new Map() + + constructor( + private readonly delivery: { + deliver: (sessionId: string, journal: AgentSessionJournal) => void + onDeliveryError: (sessionId: string, error: unknown) => void + /** A conversation became held: state that waited on it (queued drafts) re-derives. */ + onOpened?: (sessionId: string) => void + now: () => number + } + ) { + super() + } + + override set(sessionId: string, session: StructuredAgentSessionHostSession): this { + const { journal } = session + let queued = false + journal.observeCommits(() => { + if (queued) { + return + } + queued = true + queueMicrotask(() => { + queued = false + if (this.get(sessionId)?.journal !== journal) { + return + } + try { + this.delivery.deliver(sessionId, journal) + } catch (error) { + this.delivery.onDeliveryError(sessionId, error) + } + }) + }) + this.activity.set(sessionId, this.delivery.now()) + const adopted = super.set(sessionId, session) + this.delivery.onOpened?.(sessionId) + return adopted + } + + override delete(sessionId: string): boolean { + this.activity.delete(sessionId) + return super.delete(sessionId) + } + + touch(sessionId: string): void { + if (this.has(sessionId)) { + this.activity.set(sessionId, this.delivery.now()) + } + } + + lastActivityAt(sessionId: string): number | undefined { + return this.activity.get(sessionId) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts new file mode 100644 index 00000000000..6e364ccb477 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts @@ -0,0 +1,237 @@ +// A host that dies while its Codex child is still starting leaves a lease behind. The child's +// identity is committed the moment it spawns, before the handshake, so the next host can stop that +// exact process and start over instead of guessing whether anything is running. + +import { cp, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS +} from '../../../shared/agent-session-host-authority' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { openCodexAppServerConnection } from '../../codex/codex-app-server-connection' +import { adapterFor, fakeCodex } from '../../codex/codex-structured-session-adapter-fixture' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } +const CHILD_PID = 4321 + +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-crash-mid-start-')) + resetHostTestOperationIds() +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +type HostGeneration = 'dying' | 'relaunched' + +/** Each generation's files. The relaunch opens a copy taken at the crash: the dying host stays in + * this process with its attach still pending, and a dead process writes nothing after it dies. */ +function generationRoot(generation: HostGeneration): string { + return join(root, generation) +} + +async function crash(dying: AgentSessionRecordStore): Promise { + // An empty renewal queues behind every write the dying host committed, so they are on disk. + await dying.renewLeases([]) + await cp(generationRoot('dying'), generationRoot('relaunched'), { + recursive: true, + // A write still in flight at the crash never landed, and a dead process holds no lock. + filter: (source) => !source.endsWith('.tmp') && !source.includes('.lock') + }) +} + +function openStore(generation: HostGeneration): Promise { + return AgentSessionRecordStore.open({ + directory: join(generationRoot(generation), 'store'), + hostId: 'local' + }) +} + +/** A Codex adapter whose child spawns, reports its pid the way the real connection does, and then + * never answers the thread handshake: the host dies in that window. */ +function adapterThatNeverFinishesStarting(): StructuredAgentSessionAdapter { + const codex = fakeCodex({ + 'thread/start': () => new Promise(() => {}), + 'thread/resume': () => new Promise(() => {}) + }) + const openConnection: typeof openCodexAppServerConnection = async (launch, handlers = {}) => { + const connection = await codex.openConnection(launch, handlers) + await handlers.onSpawned?.(CHILD_PID) + return connection + } + return Object.assign(adapterFor({ ...codex, openConnection }), { supportsCreate: () => true }) +} + +/** A Codex adapter whose child never gets as far as reporting its pid: the reservation is all the + * next host finds. */ +function adapterThatNeverSpawns(): StructuredAgentSessionAdapter { + const codex = fakeCodex() + const openConnection: typeof openCodexAppServerConnection = () => new Promise(() => {}) + return Object.assign(adapterFor({ ...codex, openConnection }), { supportsCreate: () => true }) +} + +function host( + generation: HostGeneration, + store: AgentSessionRecordStore, + adapter: StructuredAgentSessionAdapter, + overrides: Partial = {} +): StructuredAgentSessionHost { + return new StructuredAgentSessionHost({ + store, + adapter, + journalRoot: generationRoot(generation), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW, + ...overrides + }) +} + +describe('a host that dies while its Codex child is starting', () => { + it('leaves the spawned child recorded, so the next host stops it by identity and starts over', async () => { + const first = await openStore('dying') + const dying = host('dying', first, adapterThatNeverFinishesStarting()) + void dying.attach(CALLER, hostTestAttachParams(null)).catch(() => {}) + await vi.waitFor(() => expect(first.getRecord(SESSION)?.lease.ownerProcess).toBeTruthy()) + // Durable before the handshake returned: the only record the next host will have. + expect(first.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'reserved', + handoffStage: 'new-owner-proving', + ownerProcess: { pid: CHILD_PID, spawnToken: 'spawn-a' } + }) + + // The relaunch. The orphan is still alive until something stops it. + let orphanAlive = true + const stopOwnerProcess = vi.fn(() => { + orphanAlive = false + }) + const restarted = fakeCodex() + await crash(first) + const store = await openStore('relaunched') + const relaunched = host( + 'relaunched', + store, + Object.assign(adapterFor(restarted), { supportsCreate: () => true }), + { + mintSpawnToken: () => 'spawn-b', + probeOwner: async () => + orphanAlive + ? { outcome: 'identity-matched', matchedOn: ['spawn-token'] } + : { outcome: 'pid-absent' }, + stopOwnerProcess + } + ) + await relaunched.restoreReadableSessions() + + expect(stopOwnerProcess).toHaveBeenCalledWith(CHILD_PID, 'SIGTERM') + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + ownerProcess: null, + deathEvidence: { kind: 'pid-absent' } + }) + // What the next send's delivery does: start at the record's current fence. + const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null + expect(await relaunched.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: true }) + expect(restarted.connections).toHaveLength(1) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'live', + ownerProcess: { spawnToken: 'spawn-b' } + }) + }) +}) + +// The client keeps a create it never heard back from and retries it under the same operation id, so +// that replay, not a fresh start, is what the user's Retry and first send go through. +describe('a create replayed after the host that ran it died', () => { + const PAST_OPERATION_EXPIRY = + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS + 60_000 + + it.each([ + ['its child was recorded', adapterThatNeverFinishesStarting, 0], + [ + 'its child was recorded, and its operation row has since expired', + adapterThatNeverFinishesStarting, + PAST_OPERATION_EXPIRY + ], + ['nothing was recorded beyond the reservation', adapterThatNeverSpawns, 0], + [ + 'nothing was recorded, and its operation row has since expired', + adapterThatNeverSpawns, + PAST_OPERATION_EXPIRY + ] + ] as const)('starts an agent when %s', async (_case, dyingAdapter, elapsedMs) => { + const params = hostTestAttachParams(null) + const first = await openStore('dying') + const dying = host('dying', first, dyingAdapter()) + void dying.attach(CALLER, params).catch(() => {}) + await vi.waitFor(() => + expect(first.getRecord(SESSION)?.lease).toMatchObject( + dyingAdapter === adapterThatNeverSpawns + ? { claimStatus: 'reserved' } + : { claimStatus: 'reserved', ownerProcess: { pid: CHILD_PID } } + ) + ) + + const restarted = fakeCodex() + await crash(first) + const store = await openStore('relaunched') + const relaunched = host( + 'relaunched', + store, + Object.assign(adapterFor(restarted), { supportsCreate: () => true }), + { + mintSpawnToken: () => 'spawn-b', + // A reservation with no pid probes indeterminate: no token scan off Linux. + probeOwner: async (record): Promise => + record.lease.ownerProcess + ? { outcome: 'pid-absent' } + : { outcome: 'indeterminate', reason: 'spawn token scan unavailable' }, + now: () => NOW + elapsedMs + } + ) + await relaunched.restoreReadableSessions() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + runtimeFence: 2 + }) + + const replayed = await relaunched.attach(CALLER, params) + // Before, `agent_session_ownership_unknown` while the row was pending, then `_operation_expired`. + expect(replayed.ok ? null : replayed.refusal.code).toBeNull() + expect(replayed).toMatchObject({ ok: true, value: { sessionId: SESSION, fence: 3 } }) + expect(restarted.connections).toHaveLength(1) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'live', + runtimeFence: 3, + ownerProcess: { spawnToken: 'spawn-b' } + }) + expect( + store.getOperationRow(CALLER.callerKey, params.envelope.clientOperationId)?.outcome + ).toEqual({ status: 'succeeded', sessionId: SESSION }) + + // Settled now: the same id replays that answer and never starts a second agent. + await expect(relaunched.attach(CALLER, params)).resolves.toMatchObject({ + ok: true, + replayed: true + }) + expect(restarted.connections).toHaveLength(1) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts new file mode 100644 index 00000000000..f91c8baba15 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -0,0 +1,537 @@ +// Where a turn a crash cut short ends, when the provider wrote nothing while it worked. +// +// Claude reports a Bash call once when it starts and again only when it finishes, so a command +// that runs for half a minute leaves one journal row at its start. The lease renewal the host +// wrote every ten seconds is what saw the child working after that row. + +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { + completedStructuredAgentTurnSeconds, + selectStructuredAgentTurnTimings +} from '../../../shared/structured-agent-session-turn-timing' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { AGENT_SESSION_STORE_FILE_NAME } from '../../runtime/agent-session-record-store-file' +import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import { + AgentSessionAcquisitionExitUnprovenError, + type StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { resettleOpenStructuredAgentSessionConversation } from './structured-agent-session-conversation-open' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import { + hostTestAttachParams, + HOST_TEST_LOCATION as LOCATION, + HOST_TEST_SESSION as SESSION +} from './structured-agent-session-host-test-data' + +const PROVIDER_SESSION = 'provider-session-alpha-1' +/** The tool call's row: the last thing the provider wrote before the crash. */ +const TOOL_STARTED_AT = 1_800_000_000_000 +/** The last renewal before the crash, while the command was still running. */ +const LAST_RENEWED_AT = TOOL_STARTED_AT + 25_000 +/** Orca comes back an hour later. */ +const RELAUNCHED_AT = TOOL_STARTED_AT + 60 * 60 * 1000 + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost + +function crashedClaudeRecord(): AgentSessionRecord { + const linkId = 'claude-13-link' + return { + schemaVersion: 2, + sessionId: SESSION, + location: LOCATION, + provider: 'claude', + providerHandleChain: [ + { + linkId, + handle: { provider: 'claude', sessionId: PROVIDER_SESSION, leafUuid: null }, + origin: 'created', + mintedAtFence: 13, + observedAt: TOOL_STARTED_AT - 60_000 + } + ], + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + createdAt: TOOL_STARTED_AT - 60_000, + updatedAt: LAST_RENEWED_AT, + lease: { + sessionId: SESSION, + runtimeKind: 'native', + runtimeFence: 13, + handoffStage: null, + provenHandleLinkId: linkId, + ownerProcess: { + hostId: 'local', + pid: 12_546, + processStartTimeMs: TOOL_STARTED_AT - 60_000, + spawnToken: 'spawn-crashed' + }, + reservedSpawnToken: 'spawn-crashed', + leaseDeadlineAt: LAST_RENEWED_AT + 30_000, + lastRenewedAt: LAST_RENEWED_AT, + handoffOperationId: null, + journalCheckpoint: null, + claimKeyId: 'key-1', + claimStatus: 'live', + unreconciled: false, + deathEvidence: null + } + } +} + +async function seedCrashedStore(): Promise { + const directory = join(root, 'store') + await mkdir(directory, { recursive: true }) + await writeFile( + join(directory, AGENT_SESSION_STORE_FILE_NAME), + JSON.stringify({ + schemaVersion: 2, + hostId: 'local', + records: { [SESSION]: crashedClaudeRecord() }, + operations: {}, + retiredClaimKeys: [], + unusableRecords: {} + }), + 'utf-8' + ) + store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) +} + +/** A running turn whose only row after its start is a Bash call that never reported back, for a + * send the provider never acknowledged: the reopen settles it at the fence after the crash. */ +async function seedClaudeToolTurn(): Promise { + let now = TOOL_STARTED_AT - 2_000 + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: LOCATION.workspaceId, + hostId: LOCATION.executionHostId, + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION, leafUuid: null } + }, + journalDir: journalDirectoryFor(root, { + workspaceId: LOCATION.workspaceId, + sessionId: SESSION + }), + now: () => now + }) + await journal.appendSubmission({ + clientMessageId: 'send-1', + payloadFingerprint: '0'.repeat(64), + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'run the loop' }] }, + fence: 13 + }) + const turnIdentity = { + provider: 'claude' as const, + sessionId: PROVIDER_SESSION, + uuid: 'uuid-turn' + } + const turnScope = { kind: 'turn' as const, turnItemId: agentJournalItemKey(turnIdentity) } + await journal.appendItem( + turnIdentity, + { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: now }, + { fence: 13, turnScope } + ) + now = TOOL_STARTED_AT + await journal.appendItem( + { provider: 'claude', sessionId: PROVIDER_SESSION, uuid: 'uuid-bash' }, + { + kind: 'tool-call', + name: 'Bash', + input: { command: 'for i in $(seq 90); do sleep 1; done' }, + state: 'running' + }, + { fence: 13, turnScope } + ) + await journal.close() +} + +function openHost(overrides: Partial): void { + host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire: vi.fn(), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn(), + supportsCreate: () => true + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-new', + now: () => RELAUNCHED_AT, + ...overrides + }) +} + +/** What a client reads over the wire. */ +async function settledTurn() { + return (await host.journalSnapshot(SESSION)).items + .map((item) => readAgentJournalTurn(item.body)) + .find(Boolean) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-crash-turn-end-')) + await seedCrashedStore() + await seedClaudeToolTurn() +}) + +afterEach(async () => { + await host?.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('a turn a crash cut short mid-tool', () => { + it('ends at the last renewal, not at the tool call the provider last reported', async () => { + openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) }) + + await host.restoreReadableSessions() + + expect(store.getRecord(SESSION)?.lease.deathEvidence).toMatchObject({ + kind: 'pid-absent', + observedAt: RELAUNCHED_AT, + lastProvenAliveAt: LAST_RENEWED_AT + }) + expect(await settledTurn()).toMatchObject({ + state: 'interrupted', + completedAt: LAST_RENEWED_AT + }) + // "Worked for 27s", where the tool call's row alone reads 2s. + const [timing] = selectStructuredAgentTurnTimings( + (await host.journalSnapshot(SESSION)).items + ).values() + expect(completedStructuredAgentTurnSeconds(timing)).toBe(27) + }) + + it('ends at the pre-crash renewal when the child outlived Orca and recovery stopped it', async () => { + // The orphan is alive at relaunch, but its output went nowhere: none of that is work shown. + let alive = true + const probeOwner = async (): Promise => + alive + ? { outcome: 'identity-matched', matchedOn: ['spawn-token'] } + : { outcome: 'pid-absent' } + const stopOwnerProcess = vi.fn(() => { + alive = false + }) + openHost({ probeOwner, stopOwnerProcess }) + + await host.restoreReadableSessions() + + expect(stopOwnerProcess).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease.deathEvidence).toMatchObject({ + kind: 'pid-absent', + lastProvenAliveAt: LAST_RENEWED_AT + }) + expect(await settledTurn()).toMatchObject({ + state: 'interrupted', + completedAt: LAST_RENEWED_AT + }) + }) +}) + +/** Every turn state a subscriber was sent, in order: snapshots, then live batches. */ +function turnStatesSent(events: readonly AgentSessionSubscribeEvent[]) { + return events + .flatMap((event) => + event.type === 'snapshot' ? event.page.items : event.type === 'batch' ? event.batch.items : [] + ) + .flatMap((item) => readAgentJournalTurn(item.body) ?? []) +} + +function attach(fence: number) { + return host.attach( + { callerKey: 'client-1' }, + hostTestAttachParams(fence, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION, leafUuid: null } + }) + ) +} + +/** Runs whatever this session's serialize already has queued. */ +function drainSession(): Promise { + return host.collaboratorsForTests().serialize(SESSION, async () => {}) +} + +// On desktop the chat on screen at relaunch opens before the startup reconcile has probed its owner, +// so the open can only call the turn unverifiable; the proof, whoever writes it, then revises it. +describe('a turn a read reached before the reconcile proved its owner dead', () => { + it('reads unverifiable, then interrupted at the last renewal, and a subscriber is sent both', async () => { + openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) }) + const events: AgentSessionSubscribeEvent[] = [] + const unsubscribe = await host.subscribe({ + id: 'reader-1', + sessionId: SESSION, + emit: (event) => events.push(event) + }) + expect(await settledTurn()).toEqual(UNVERIFIABLE_TURN) + + await host.reconcileRestartLeases() + await drainSession() + + // A paired or phone client learns of the revision through the ordinary journal publish. + await vi.waitFor(() => + expect(turnStatesSent(events)).toMatchObject([ + { state: 'unverifiable' }, + { state: 'interrupted', completedAt: LAST_RENEWED_AT } + ]) + ) + const { items } = await host.journalSnapshot(SESSION) + const [timing] = selectStructuredAgentTurnTimings(items).values() + expect(completedStructuredAgentTurnSeconds(timing)).toBe(27) + expect(items.filter((item) => item.body.kind === 'status')).toHaveLength(1) + unsubscribe() + }) + + it('revises nothing twice, whoever re-runs the settle', async () => { + openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) }) + await host.history({ sessionId: SESSION, direction: 'tail' }) + await host.reconcileRestartLeases() + await drainSession() + const settled = await host.journalSnapshot(SESSION) + + await host + .collaboratorsForTests() + .serialize(SESSION, () => + resettleOpenStructuredAgentSessionConversation( + host.deps, + SESSION, + host.collaboratorsForTests().sessions.get(SESSION) + ) + ) + await host.restoreReadableSessions() + + expect(await host.journalSnapshot(SESSION)).toEqual(settled) + }) + + it('revises it when recovery stops a child that outlived Orca, with no send', async () => { + let alive = true + const probeOwner = async (): Promise => + alive + ? { outcome: 'identity-matched', matchedOn: ['spawn-token'] } + : { outcome: 'pid-absent' } + const stopOwnerProcess = vi.fn(() => { + alive = false + }) + const acquire = vi.fn() + openHost({ + adapter: { + acquire, + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn(), + supportsCreate: () => true + }, + probeOwner, + stopOwnerProcess + }) + await host.history({ sessionId: SESSION, direction: 'tail' }) + expect(await settledTurn()).toEqual(UNVERIFIABLE_TURN) + + // The reconcile only parks the live orphan in recovery; recovery's stop is what proves it gone. + await host.restoreReadableSessions() + await drainSession() + + expect(stopOwnerProcess).toHaveBeenCalledOnce() + expect(acquire).not.toHaveBeenCalled() + expect(await settledTurn()).toMatchObject({ + state: 'interrupted', + completedAt: LAST_RENEWED_AT + }) + }) + + it('never touches the turn a start after the crash is writing', async () => { + const acquire = vi.fn( + async ({ fence, spawnToken, onSpawned, events }) => { + const process = { + hostId: 'local', + pid: 8_000, + processStartTimeMs: RELAUNCHED_AT, + spawnToken + } + await onSpawned?.(process) + // The new child is already working when its start lands, ahead of the queued revision. + events?.appendItem( + { provider: 'claude', sessionId: PROVIDER_SESSION, uuid: 'uuid-turn-2' }, + { kind: 'turn', turnId: 'turn-2', state: 'running', startedAt: RELAUNCHED_AT }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + return { + process, + link: { + linkId: `claude-${fence}-link`, + handle: { provider: 'claude', sessionId: PROVIDER_SESSION, leafUuid: null }, + origin: 'resumed', + mintedAtFence: fence, + observedAt: RELAUNCHED_AT + } + } + } + ) + openHost({ + adapter: { + acquire, + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn(), + supportsCreate: () => true + }, + probeOwner: async () => ({ outcome: 'pid-absent' }) + }) + await host.history({ sessionId: SESSION, direction: 'tail' }) + + // The start runs the reconcile itself, so the revision it queues waits behind the start. + await expect(attach(14)).resolves.toMatchObject({ ok: true }) + await host.flushStreamedEvents(SESSION) + await drainSession() + + const turns = (await host.journalSnapshot(SESSION)).items.flatMap( + (item) => readAgentJournalTurn(item.body) ?? [] + ) + expect(turns).toMatchObject([ + { turnId: 'turn-1', state: 'interrupted', completedAt: LAST_RENEWED_AT }, + { turnId: 'turn-2', state: 'running' } + ]) + }) + + it('stays unverifiable when the revision cannot be written, and a later open revises it', async () => { + let now = RELAUNCHED_AT + const onEventSinkError = vi.fn() + openHost({ + probeOwner: async () => ({ outcome: 'pid-absent' }), + now: () => now, + onEventSinkError + }) + await host.history({ sessionId: SESSION, direction: 'tail' }) + const { journal } = host.collaboratorsForTests().sessions.get(SESSION)! + vi.spyOn(journal, 'appendLifecycleBatch').mockRejectedValueOnce(new Error('disk full')) + + await host.reconcileRestartLeases() + await drainSession() + + expect(onEventSinkError).toHaveBeenCalledOnce() + expect(await settledTurn()).toEqual(UNVERIFIABLE_TURN) + // The proof is durable on the record, so the next open converges. + now += STRUCTURED_AGENT_SESSION_IDLE_MS + 1 + await host.collaboratorsForTests().lifetime.idleSweep.tick() + expect(host.hasSession(SESSION)).toBe(false) + expect(await settledTurn()).toMatchObject({ + state: 'interrupted', + completedAt: LAST_RENEWED_AT + }) + }) +}) + +/** A relaunched host whose first start after the crash, at fence 15, fails with `failure`; every + * later start succeeds. The crashed owner, and any child left behind, probe gone. */ +async function hostWithFailingFirstStart(failure: Error) { + let now = RELAUNCHED_AT + const acquire = vi.fn( + async ({ fence, spawnToken, onSpawned }) => { + const process = { hostId: 'local', pid: 7_000 + fence, processStartTimeMs: now, spawnToken } + await onSpawned?.(process) + if (fence === 15) { + throw failure + } + return { + process, + link: { + linkId: `claude-${fence}-link`, + handle: { provider: 'claude', sessionId: PROVIDER_SESSION, leafUuid: null }, + origin: 'resumed', + mintedAtFence: fence, + observedAt: now + } + } + } + ) + openHost({ + adapter: { + acquire, + releaseAcquisition: async () => true, + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn(), + supportsCreate: () => true + }, + probeOwner: async () => ({ outcome: 'pid-absent' }), + now: () => now + }) + await host.reconcileRestartLeases() + return { + attach, + advance: (ms: number) => { + now += ms + } + } +} + +/** The turn as the crashed owner left it, with no end: nothing proves when that owner stopped. */ +const UNVERIFIABLE_TURN = { + turnId: 'turn-1', + state: 'unverifiable', + startedAt: TOOL_STARTED_AT - 2_000 +} + +// The relaunch proved the fence-13 owner gone, but a start reserving fence 15 clears that proof +// before the turn is settled; what the record holds afterwards is about the start's own child. +describe('a turn a newer start could not settle before it failed', () => { + it('is not judged by the death of the child it left for recovery', async () => { + const { attach, advance } = await hostWithFailingFirstStart( + new AgentSessionAcquisitionExitUnprovenError(new Error('hung')) + ) + + await attach(14).catch(() => undefined) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeFence: 15, + handoffStage: 'recovering', + lastRenewedAt: RELAUNCHED_AT + }) + advance(60_000) + // Recovery records that child's death, then the client retries at the fence it was told. + await expect(attach(15)).resolves.toMatchObject({ refusal: { currentFence: 16 } }) + expect(store.getRecord(SESSION)?.lease.deathEvidence).toMatchObject({ + ownerFence: 15, + lastProvenAliveAt: RELAUNCHED_AT + }) + await expect(attach(16)).resolves.toMatchObject({ ok: true }) + + expect(await settledTurn()).toEqual(UNVERIFIABLE_TURN) + }) + + it('is not judged by the watched exit of a start that failed', async () => { + const { attach, advance } = await hostWithFailingFirstStart(new Error('claude exited (code 1)')) + + await expect(attach(14)).resolves.toMatchObject({ ok: false }) + expect(store.getRecord(SESSION)?.lease.deathEvidence).toMatchObject({ + kind: 'exit-observed', + ownerFence: 15, + observedAt: RELAUNCHED_AT + }) + advance(60_000) + await expect(attach(16)).resolves.toMatchObject({ ok: true }) + + // Main ended it at the failed start, an hour after the crash, with the start's reason. + expect(await settledTurn()).toEqual(UNVERIFIABLE_TURN) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index d553fbaecf3..5a55bbb26cc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts @@ -1,19 +1,25 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' -import { dispatchRejectionReasonIsInternal } from '../../../shared/structured-agent-session-dispatch-rejection' +import { + agentSessionFailureFact, + MAX_PROVIDER_DIAGNOSTIC_CHARS, + providerDiagnostic +} from '../../../shared/agent-session-failure' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { captureUnfinishedStructuredAgentSessionWork, - MAX_UNEXPECTED_EXIT_REASON_CHARS, settleStructuredAgentSessionDeadGeneration, - UNEXPECTED_PROVIDER_EXIT_OUTCOME, unfinishedStructuredAgentSessionWorkWasInterrupted } from './structured-agent-session-dead-generation-settlement' +const UNEXPECTED_PROVIDER_EXIT_OUTCOME = + 'The agent stopped while this response was in progress. You can continue in this conversation.' + const SESSION = 'session-dead-generation' const THREAD = 'thread-1' let root: string @@ -49,7 +55,7 @@ async function seedUnfinishedWork(): Promise { await journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 }, { kind: 'tool-call', name: 'shell', input: { command: 'pnpm test' }, state: 'running' }, - { fence: 7 } + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 2 }, @@ -60,7 +66,7 @@ async function seedUnfinishedWork(): Promise { options: [{ id: 'yes', label: 'Allow' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }, - { fence: 7 } + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 3 }, @@ -70,12 +76,12 @@ async function seedUnfinishedWork(): Promise { options: [{ id: 'web', label: 'Web' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }, - { fence: 7 } + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 4 }, { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 900 }, - { fence: 7 } + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } @@ -143,7 +149,7 @@ describe('dead structured-session generation settlement', () => { ).toHaveLength(1) }) - it('keeps the actionable tail when the provider dumps a stderr wall into its exit reason', async () => { + it('keeps a stderr wall out of the sentence, as a bounded detail for a log', async () => { await seedUnfinishedWork() await expect( @@ -155,19 +161,48 @@ describe('dead structured-session generation settlement', () => { pendingSubmissionReason: 'provider_exited_before_acknowledgement', verdict: { state: 'interrupted', completedAt: 1_000 }, showUnexpectedExitOutcome: true, - unexpectedExitReason: 'stack frame '.repeat(4_000) + exitFailure: agentSessionFailureFact('providerExited', { + detail: providerDiagnostic('stack frame '.repeat(4_000), 'log') + }) }) ).resolves.toBe(true) const statuses = journal .snapshot() - .items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) + .items.flatMap((item) => (item.body.kind === 'status' ? [item.body] : [])) expect(statuses).toHaveLength(1) - // The cause is bounded before composing, so the row never reaches the byte cap that would - // truncate the sentence telling the user the conversation is still usable. - expect(statuses[0]).toContain('stack frame') - expect(statuses[0]).toMatch(/You can continue in this conversation\.$/) - expect(statuses[0]?.length).toBeLessThan(MAX_UNEXPECTED_EXIT_REASON_CHARS * 2) + expect(statuses[0]?.text).toBe(UNEXPECTED_PROVIDER_EXIT_OUTCOME) + expect(statuses[0]?.failure?.kind).toBe('providerExited') + expect(statuses[0]?.failure?.detail?.audience).toBe('log') + expect(statuses[0]?.failure?.detail?.text.length).toBe(MAX_PROVIDER_DIAGNOSTIC_CHARS) + }) + + it("words Orca's own fault as Orca's, never as the provider stopping", async () => { + await seedUnfinishedWork() + + await settleStructuredAgentSessionDeadGeneration({ + journal, + sessionId: SESSION, + fence: 7, + settlementId: `provider-exit:${SESSION}:7:generation-1`, + pendingSubmissionReason: 'provider_exited_before_acknowledgement', + verdict: { state: 'interrupted', completedAt: 1_000 }, + showUnexpectedExitOutcome: true, + // Orca stopped the provider because its own journal failed. + exitFailure: agentSessionFailureFact('hostFault') + }) + + const statuses = journal + .snapshot() + .items.flatMap((item) => (item.body.kind === 'status' ? [item.body] : [])) + expect(statuses).toEqual([ + { + kind: 'status', + text: "Orca ran into a problem, so this didn't go through. Try again.", + failure: { kind: 'hostFault' }, + tone: 'error' + } + ]) }) it('retries an already settled expected close without writing through a closed journal gate', async () => { @@ -190,6 +225,7 @@ describe('dead structured-session generation settlement', () => { | 'submissions' | 'markPendingSubmissionsUnknown' | 'rejectPendingSubmissions' + | 'rejectQueuedSubmissions' | 'appendLifecycleBatch' > = { snapshot: () => ({ @@ -203,6 +239,9 @@ describe('dead structured-session generation settlement', () => { rejectPendingSubmissions: async () => { throw new Error('journal_closed') }, + rejectQueuedSubmissions: async () => { + throw new Error('journal_closed') + }, appendLifecycleBatch: async () => { throw new Error('journal_closed') } @@ -272,16 +311,24 @@ describe('dead structured-session generation settlement', () => { settlementId: `provider-exit:${SESSION}:7:generation-1`, pendingSubmissionReason: 'provider_closed_before_acknowledgement', verdict: { state: 'interrupted', completedAt: 1_000 }, - unexpectedExitReason: 'claude stream-json exited (code 1): not signed in', - exitedDuringStartup: true + exitFailure: agentSessionFailureFact('providerExited', { + detail: providerDiagnostic('code 1\nnot signed in', 'log') + }), + exitedDuringStartup: { generation: 'generation-1' } }) - const reason = - 'The provider stopped before it finished starting: claude stream-json exited (code 1): not signed in.' + // The sentence is Orca's; the stderr the exit carried rides as a log detail only. expect(journal.submissions()).toEqual([ - expect.objectContaining({ clientMessageId: 'client-held', dispatchState: 'rejected', reason }) + expect.objectContaining({ + clientMessageId: 'client-held', + dispatchState: 'rejected', + reason: 'The agent stopped before it finished starting. Send your message to try again.', + rejection: { + kind: 'providerStartFailed', + detail: { text: 'code 1\nnot signed in', audience: 'log' } + } + }) ]) - expect(dispatchRejectionReasonIsInternal(reason)).toBe(false) }) it("keeps a subagent's settled rows the subagent's, in one batch and after a reopen", async () => { @@ -304,7 +351,7 @@ describe('dead structured-session generation settlement', () => { await journal.appendItem( childCall, { kind: 'tool-call', name: 'shell', input: { command: 'ls' }, state: 'running' }, - { fence: 7, ...child } + { fence: 7, ...child, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( childAsk, @@ -315,7 +362,7 @@ describe('dead structured-session generation settlement', () => { options: [{ id: 'yes', label: 'Allow' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }, - { fence: 7, ...child } + { fence: 7, ...child, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await settleStructuredAgentSessionDeadGeneration({ @@ -375,12 +422,12 @@ describe('whether a dead generation interrupted anything', () => { options: [{ id: 'yes', label: 'Allow' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }, - { fence: 7 } + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 2 }, { kind: 'turn', turnId: 'turn-1', state: 'completed', startedAt: 900, completedAt: 950 }, - { fence: 7 } + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 1b5f73fdbda..72caac40844 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -1,78 +1,48 @@ +import { + agentSessionFailureFact, + MAX_PROVIDER_DIAGNOSTIC_CHARS, + type SubmissionRejectionFact +} from '../../../shared/agent-session-failure' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import type { - AgentJournalItemBody, - AgentJournalRenderItem +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { partitionJournalLifecycleMutations } from '../agent-session-journal/journal-lifecycle-batch-partition' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' -import { - boundJournalStatusText, - cancelledJournalPromptBody -} from '../agent-session-journal/journal-prompt-body-bounds' +import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { + agentSessionFailureWords, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' +import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' +import { + hasStructuredAgentSessionStartFailureRow, + structuredAgentSessionStartFailureRow +} from './structured-agent-session-start-failure-row' +import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' +import { + provenUnverifiableTurnRevisions, runningTurnLifecycleRevisions, + turnVerdictFromDeathEvidence, type StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict' +import { + exitedRootTurnScope, + runningRootTurnScope +} from './structured-agent-session-exit-turn-scope' -export const UNEXPECTED_PROVIDER_EXIT_OUTCOME = - 'The provider stopped while this response was in progress. You can continue in this conversation.' - -/** A provider may put a whole stderr dump in its exit reason; unbounded it would push the - * actionable tail past the row's byte cap and lose it to truncation. */ -export const MAX_UNEXPECTED_EXIT_REASON_CHARS = 512 - -/** The cause is the only thing separating an auth failure from an OOM kill, so it is carried - * into the copy rather than left in the durable record nothing renders. */ -export function unexpectedProviderExitOutcome(reason?: string): string { - const detail = exitReasonDetail(reason) - return detail - ? `The provider stopped while this response was in progress: ${detail}. You can continue in this conversation.` - : UNEXPECTED_PROVIDER_EXIT_OUTCOME -} - -/** A restart that produced no child, answered to the send that asked for it; its cause is the - * whole story, and nothing is remembered, so the next try is a fresh one. A new chat is offered - * only when the host holds nothing this chat could restart from. */ -export function ownerRestartFailedOutcome(input: { - agentName: string - reason?: string - resumable: boolean -}): string { - const detail = exitReasonDetail(input.reason) - const failed = detail - ? `${input.agentName} couldn't restart: ${detail}.` - : `${input.agentName} couldn't restart.` - return input.resumable ? failed : `${failed} Start a new chat to continue.` -} - -/** A start that never finished has no response to interrupt; its cause is the whole story. */ -export function providerStartupFailureOutcome(reason?: string): string { - const detail = exitReasonDetail(reason) - return detail - ? `The provider stopped before it finished starting: ${detail}.` - : 'The provider stopped before it finished starting.' -} - -/** Why a send a child that never started left unwritten was rejected. The child's own diagnostic is - * the cause the user can act on, so it is the reason, in the words the chat row uses. */ -export function providerStartupFailureRejection(cause?: unknown): string { - return providerStartupFailureOutcome( - cause === undefined ? undefined : cause instanceof Error ? cause.message : String(cause) - ) -} - -function exitReasonDetail(reason: string | undefined): string | undefined { - return reason - ?.slice(0, MAX_UNEXPECTED_EXIT_REASON_CHARS) - .trim() - .replace(/[.\s]+$/, '') -} +/** Bounds the exit reason the lease keeps as log evidence; a provider diagnostic is held to the + * same cap. */ +export const MAX_UNEXPECTED_EXIT_REASON_CHARS = MAX_PROVIDER_DIAGNOSTIC_CHARS type DeadGenerationSubmission = Pick< ReturnType[number], - 'clientMessageId' | 'dispatchState' | 'recovered' + 'clientMessageId' | 'dispatchState' | 'recovered' | 'handoverRecorded' | 'handedOverAt' > export type DeadGenerationJournal = { @@ -140,10 +110,13 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { verdict: StructuredAgentSessionTurnVerdict pendingSubmissionReason: string showUnexpectedExitOutcome?: boolean - /** Why the provider stopped, when the host has it. Rendered with the outcome copy. */ - unexpectedExitReason?: string - /** The provider never finished starting; the outcome says so instead of naming a response. */ - exitedDuringStartup?: boolean + /** Why the provider stopped, as the adapter told it; the row's sentence is this fact's. */ + exitFailure?: SubmissionRejectionFact + /** Who a failed start's sentence names. */ + failureTextContext?: AgentSessionFailureWordsContext + /** The provider never finished starting: the start that failed, keyed by the child's + * generation. Its row is the one the delivery loop writes for the same start. */ + exitedDuringStartup?: { generation: string | null } onError?: (sessionId: string, error: unknown) => void }): Promise { try { @@ -152,36 +125,58 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { if (!showUnexpectedExitOutcome && !hasUnfinishedWork) { return true } - // A child that never proved its start accepted nothing — input is written only after it - // initializes — so every send it left unanswered is provably unwritten and is rejected with the - // child's own diagnostic. A proven child's unanswered sends stay in doubt. - await (input.exitedDuringStartup - ? input.journal.rejectPendingSubmissions( - input.fence, - providerStartupFailureRejection(input.unexpectedExitReason) - ) + // A queued message is the delivery loop's to settle: it was never handed to this child. A + // child that never proved its start accepted nothing either — input is written only after it + // initializes — so every send it was handed is rejected with the child's own diagnostic. A + // proven child's handed-over sends stay in doubt. + const startupFailure = input.exitedDuringStartup + ? structuredAgentSessionStartFailure({ exit: input.exitFailure }, input.failureTextContext) + : null + await (startupFailure + ? input.journal.rejectPendingSubmissions(input.fence, startupFailure) : input.journal.markPendingSubmissionsUnknown(input.fence, input.pendingSubmissionReason)) const items = input.journal.snapshot().items const mutations: JournalLifecycleMutationInput[] = [] - if (showUnexpectedExitOutcome) { + if (showUnexpectedExitOutcome && input.exitedDuringStartup && startupFailure) { + const startKey = input.exitedDuringStartup.generation ?? input.settlementId + // A start a message waited on is the delivery loop's to record, before or after this exit, + // in the words it rejected the message with; this row is for a command, goal or rewind start. + // A row already written stays: rejected is terminal, so its words are not reworded. + const recordedByDeliveryLoop = + input.journal.submissions?.().some(isQueuedAgentJournalSubmission) || + hasStructuredAgentSessionStartFailureRow(items, startKey) + if (!recordedByDeliveryLoop) { + mutations.push(structuredAgentSessionStartFailureRow(startKey, startupFailure)) + } + } else if (showUnexpectedExitOutcome) { + // The turn the exit ended, and an error so no fold ever hides why it stopped. mutations.push({ kind: 'item', identity: { provider: 'orca', clientMessageId: input.settlementId }, body: { kind: 'status', - text: boundJournalStatusText( - input.exitedDuringStartup - ? providerStartupFailureOutcome(input.unexpectedExitReason) - : unexpectedProviderExitOutcome(input.unexpectedExitReason) - ) - } + ...agentSessionFailureWords( + input.exitFailure ?? agentSessionFailureFact('providerExited'), + { + ...input.failureTextContext, + surface: 'row' + } + ), + tone: 'error' + }, + turnScope: exitedRootTurnScope(items, input.verdict) }) } for (const item of items) { const identity = parseAgentJournalItemKey(item.itemId) const body = terminalDeadGenerationBody(item) if (identity && body) { - mutations.push({ kind: 'item', identity, body }) + mutations.push({ + kind: 'item', + identity, + body, + turnScope: item.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE + }) } } mutations.push(...runningTurnLifecycleRevisions(items, input.verdict)) @@ -201,6 +196,85 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { } } +/** + * Settles whatever a generation with no child in this process left running: found when a new child + * is acquired, or when a chat is reopened for reading. Derived from the journal and the lease's + * death evidence each time, so nothing is owed in between. Proven death ends the turn interrupted, + * and a proof written after an earlier settle revises what that settle left `unverifiable`. Must + * run before a new child's buffered events land, or a live turn would be judged. + */ +export async function settleStaleStructuredAgentSessionState(input: { + journal: AgentSessionJournal + sessionId: string + fence: number + acquisitionGeneration: string | null + deathEvidence: AgentSessionDeathEvidence | null + /** Who the exit row names. */ + failureTextContext?: AgentSessionFailureWordsContext +}): Promise { + const { journal } = input + const items = journal.snapshot().items + // Each turn is judged by the evidence only if it names that turn's owner. + const verdictFor = (item: AgentJournalRenderItem) => + turnVerdictFromDeathEvidence(input.deathEvidence, journal.itemFence(item.itemId)) + // Per attempt: a retry re-partitions only what is left, and a reused chunk id would skip it. + const generation = input.acquisitionGeneration ?? `seq-${journal.cursor().sequence}` + const settlementId = `stale-session:${input.sessionId}:${input.fence}:${generation}` + const mutations: JournalLifecycleMutationInput[] = [] + for (const item of items) { + const identity = parseAgentJournalItemKey(item.itemId) + const body = terminalDeadGenerationBody(item) + if (identity && body) { + mutations.push({ + kind: 'item', + identity, + body, + turnScope: item.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE + }) + } + } + const proven = provenUnverifiableTurnRevisions(items, input.deathEvidence, journal) + mutations.push( + ...items.flatMap((item) => runningTurnLifecycleRevisions([item], verdictFor(item))), + ...proven + ) + const evidence = input.deathEvidence + if ( + evidence && + (proven.length > 0 || + items.some((item) => isInProgressItem(item) && verdictFor(item).state === 'interrupted')) + ) { + mutations.unshift({ + kind: 'item', + // Named by the death it explains, so a retry after a partly written settle adds no second row. + identity: { + provider: 'orca', + clientMessageId: `stale-session:${input.sessionId}:death-${evidence.ownerFence ?? 'unowned'}-${evidence.observedAt}` + }, + // The death evidence is Orca's log text, never a sentence for a person: the row says only + // that the provider stopped. + body: { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { + ...input.failureTextContext, + surface: 'row' + }), + tone: 'error' + }, + turnScope: runningRootTurnScope(items) + }) + } + for (const chunk of partitionJournalLifecycleMutations(settlementId, mutations)) { + await journal.appendLifecycleBatch({ + settlementId: chunk.settlementId, + fence: input.fence, + recovered: true, + mutations: chunk.mutations + }) + } + return mutations.length +} + function terminalDeadGenerationBody(item: AgentJournalRenderItem): AgentJournalItemBody | null { if (item.body.kind === 'tool-call' && item.body.state === 'running') { return { ...item.body, state: 'failed' } @@ -247,7 +321,9 @@ function hasUnsettledSubmission(journal: DeadGenerationJournal): boolean { return submissions ? submissions.some( (submission) => - submission.dispatchState === 'pending' || + // A queued message is not work in progress: nothing has it yet. + (submission.dispatchState === 'pending' && + !(submission.handoverRecorded && submission.handedOverAt === undefined)) || (submission.dispatchState === 'unknown' && submission.recovered !== true) ) : (journal.pendingSubmissions?.().length ?? 0) > 0 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts new file mode 100644 index 00000000000..242daf74faf --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-delivery-loop' +import type { + StructuredAgentSessionChildEndCause, + StructuredAgentSessionEndedChild +} from './structured-agent-session-host-types' + +const EXIT_FAILURE = agentSessionFailureFact('providerExited', { + detail: { text: 'crashed', audience: 'log' } +}) + +function ended( + cause: StructuredAgentSessionChildEndCause, + duringStartup: boolean +): StructuredAgentSessionEndedChild { + return { + generation: 'generation-1', + fence: 2, + rootGone: true, + cause, + reason: null, + failure: EXIT_FAILURE, + duringStartup, + endedAt: { epoch: 'epoch-1', sequence: 3 } + } +} + +describe('what a child end means for the messages queued behind it', () => { + it.each([false, true])("fails nothing after a user's Stop (during startup: %s)", (starting) => { + expect(structuredAgentSessionEndedChildFailure(ended('user-stop', starting))).toBeNull() + }) + + it.each([false, true])( + 'is a start Orca stopped after a host stop (during startup: %s)', + (starting) => { + expect(structuredAgentSessionEndedChildFailure(ended('host-stop', starting))).toEqual({ + failure: { kind: 'hostStopped' } + }) + } + ) + + it.each(['exit', 'attach-failed', 'evict'] as const)( + 'carries the recorded failure of a %s, as a failed start while starting', + (cause) => { + expect(structuredAgentSessionEndedChildFailure(ended(cause, true))).toEqual({ + exit: EXIT_FAILURE + }) + expect(structuredAgentSessionEndedChildFailure(ended(cause, false))).toEqual({ + failure: EXIT_FAILURE + }) + expect( + structuredAgentSessionEndedChildFailure({ ...ended(cause, false), failure: undefined }) + ).toEqual({ failure: agentSessionFailureFact('providerExited') }) + } + ) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts new file mode 100644 index 00000000000..373e2ae3c36 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -0,0 +1,301 @@ +// The one thing that starts a provider child for a send, the one thing that hands a message to +// it, and the one thing that settles a queued message because of a start, a child or a leftover. +// +// A send is accepted on its own serialized step and returns; this loop does the rest. It exists +// for a session exactly while a message is queued there — accepted, not yet handed over — and no +// child is running a conversation command: a command's turn takes no input, and the commit that +// ends it wakes the loop again. Every step re-reads the journal and the conversation's child +// record to decide, so there is no loop state to disagree with them. Each step is its own serialized task. That is what lets a Stop +// that arrives while a start holds the queue withdraw the queued messages before the handover that +// would have written them. Stop and the conversation's close are the only other writers of a +// queued message: a child's exit only ends the child, and this loop reads why. + +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + agentSessionFailureFact, + type SubmissionRejectionFact +} from '../../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { + structuredAgentSessionStartFailure, + type StructuredAgentSessionStartFailureCause +} from './structured-agent-session-failure-text' +import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' +import type { + StructuredAgentSessionChildEndCause, + StructuredAgentSessionEndedChild, + StructuredAgentSessionHostSession, + StructuredAgentSessionProviderChildIdentity +} from './structured-agent-session-host-types' +import { + oldestQueuedSubmission, + recordStructuredAgentSessionStartFailure +} from './structured-agent-session-start-failure-row' +import { failedProviderChildStart } from './structured-agent-session-provider-child' +import { handOverSubmission } from './structured-agent-session-turns' +import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' + +export type StructuredAgentSessionDeliveryLoopDeps = { + sessions: ReadonlyMap + adapter: StructuredAgentSessionAdapter + serialize: (sessionId: string, task: () => Promise) => Promise + /** A start step, tracked from enqueue so quit waits for the child it may produce. */ + trackStart: (start: Promise) => Promise + /** Gives the session a provider child if it has none; for a caller inside `serialize`. */ + /** Starts a child for `startedFor`, the queued message at the head, if the session has none. */ + ensureProviderChild: ( + sessionId: string, + startedFor: string + ) => Promise + /** The fence the conversation's own writes carry; see `structuredAgentSessionConversationFence`. */ + conversationFence: (sessionId: string) => number + /** Who the chat's failure sentences name. */ + failureTextContext: (sessionId: string) => AgentSessionFailureWordsContext + onError: (sessionId: string, error: unknown) => void + record: (sessionId: string) => AgentSessionRecord | null + flushStreamedEvents: (sessionId: string) => Promise + now: () => number +} + +type Step = 'continue' | 'stop' + +type Prepared = + | 'stop' + | Extract + | { ok: true; awaited: StructuredAgentSessionProviderChildIdentity | null } + +/** A failed start before it is worded; `fail` words it once, through the one wording point. */ +type StartFailure = { startKey: string | null; cause: StructuredAgentSessionStartFailureCause } + +export class StructuredAgentSessionDeliveryLoop { + private readonly running = new Set() + private disposed = false + + constructor(private readonly deps: StructuredAgentSessionDeliveryLoopDeps) {} + + isRunning(sessionId: string): boolean { + return this.running.has(sessionId) + } + + /** Quit: no step after this one starts a child or hands a message over. */ + dispose(): void { + this.disposed = true + } + + /** From inside the session's serialize, after a message was accepted or the conversation + * opened. A loop already running re-reads the journal on its next step. */ + wake(sessionId: string): void { + if (this.disposed || this.running.has(sessionId)) { + return + } + this.running.add(sessionId) + void this.run(sessionId) + } + + private async run(sessionId: string): Promise { + try { + for (;;) { + const prepared = await this.deps.trackStart( + this.deps.serialize(sessionId, () => this.prepare(sessionId)) + ) + if (prepared === 'stop') { + return + } + if (!prepared.ok) { + const { refusal, diagnostic } = prepared + const cause = { refusal, ...(diagnostic ? { diagnostic } : {}) } + await this.deps.serialize(sessionId, () => + this.fail(sessionId, { startKey: null, cause }) + ) + return + } + // A child published before it proved its start takes no input yet; waited for outside + // the queue so a Stop can reach it meanwhile. + const failure = await this.deps.adapter.awaitStarted?.(sessionId) + const handed = await this.deps.serialize(sessionId, () => + this.handOver(sessionId, prepared.awaited, failure || null) + ) + if (handed === 'stop') { + return + } + } + } catch (error) { + // The error is Orca's own and goes to the log; the chat says only that Orca failed. + this.deps.onError(sessionId, error) + const cause = { hostFault: true } as const + await this.deps + .serialize(sessionId, () => this.fail(sessionId, { startKey: null, cause })) + .catch((failure: unknown) => { + // Rows left queued are rejected by the next open, or by the next loop an accept wakes. + this.running.delete(sessionId) + this.deps.onError(sessionId, failure) + }) + } + } + + /** Settles what an earlier host process left queued, then makes the session ready. */ + private async prepare(sessionId: string): Promise { + const session = this.deps.sessions.get(sessionId) + if (!session || this.disposed) { + return this.stop(sessionId) + } + await session.journal.rejectQueuedSubmissions( + this.deps.conversationFence(sessionId), + agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { surface: 'rejection' }), + // A handle closes only with nothing queued, so one an earlier handle wrote is a leftover. + (submission) => session.journal.wroteBeforeOpen(submission.acceptedSequence) + ) + const oldest = oldestQueuedSubmission(session) + // A running command takes no input while its child carries it; its end is a commit, which + // wakes the loop again. With no child it is a gone generation's, which the start below settles. + if (!oldest || (session.child && structuredAgentSessionCommandRunning(session.journal))) { + return this.stop(sessionId) + } + const failedStart = startThatFailedWhileQueued(session, oldest) + if (failedStart) { + return this.fail(sessionId, failedStart) + } + const ready = await this.deps.ensureProviderChild(sessionId, oldest.clientMessageId) + if (!ready.ok) { + return ready + } + const child = this.deps.sessions.get(sessionId)?.child + // The child this run waits on; handover checks it is still the one there. + return { + ok: true, + awaited: child ? { generation: child.generation, fence: child.fence } : null + } + } + + private async handOver( + sessionId: string, + awaited: StructuredAgentSessionProviderChildIdentity | null, + startFailure: SubmissionRejectionFact | null + ): Promise { + const session = this.deps.sessions.get(sessionId) + if (!session || this.disposed) { + return this.stop(sessionId) + } + // Re-derived here, not carried from the start: the child may have ended, or another may have + // taken its place, since. + const { child } = session + const awaitedChild = + child && awaited && child.generation === awaited.generation && child.fence === awaited.fence + ? child + : null + // The host's `starting` trails the adapter's `started` by one serialized step, so for the child + // waited on, the adapter's own answer decides whether its start landed. + if (!awaitedChild || (awaitedChild.phase === 'starting' && startFailure !== null)) { + // The child waited on is gone, replaced by another, or settled its start without proving it. + const ended = awaitedChild ? undefined : session.lastEndedChild + const endedFailure = ended ? structuredAgentSessionEndedChildFailure(ended) : undefined + // A user's Stop is not a failure: the next step starts, or waits on, a child for what is + // queued. + if (endedFailure === null) { + return 'continue' + } + return this.fail(sessionId, { + startKey: awaited?.generation ?? null, + cause: endedFailure ?? + // Gone with no end observed: nothing says the provider stopped. + { failure: startFailure ?? agentSessionFailureFact('startFailed') } + }) + } + const next = oldestQueuedSubmission(session) + if (!next) { + return this.stop(sessionId) + } + await handOverSubmission( + { + sessionId, + journal: session.journal, + fence: awaitedChild.fence, + adapter: this.deps.adapter, + providerChildPhase: () => this.deps.sessions.get(sessionId)?.child?.phase, + failureTextContext: this.deps.failureTextContext(sessionId), + record: () => this.deps.record(sessionId), + flushStreamedEvents: () => this.deps.flushStreamedEvents(sessionId), + now: this.deps.now + }, + next + ) + return 'continue' + } + + private async fail(sessionId: string, failure: StartFailure): Promise<'stop'> { + const session = this.deps.sessions.get(sessionId) + if (session) { + await recordStructuredAgentSessionStartFailure( + { journal: session.journal, fence: this.deps.conversationFence(sessionId) }, + { + startKey: failure.startKey, + ...structuredAgentSessionStartFailure( + failure.cause, + this.deps.failureTextContext(sessionId) + ) + } + ) + } + return this.stop(sessionId) + } + + /** Inside the serialized step that found nothing to do, so an accept after it wakes anew. */ + private stop(sessionId: string): 'stop' { + this.running.delete(sessionId) + return 'stop' + } +} + +/** A start that died while this message waited on it — a view's, say — is the message's failed + * start: settled with it, under its key, rather than started again into the same failure. */ +function startThatFailedWhileQueued( + session: StructuredAgentSessionHostSession, + oldest: NonNullable> +): StartFailure | null { + const ended = failedProviderChildStart(session) + if ( + !ended || + oldest.acceptedSequence === undefined || + ended.endedAt.epoch !== session.journal.cursor().epoch || + ended.endedAt.sequence < oldest.acceptedSequence + ) { + return null + } + const cause = structuredAgentSessionEndedChildFailure(ended) + return cause ? { startKey: ended.generation, cause } : null +} + +function providerEndFailure( + ended: StructuredAgentSessionEndedChild +): StructuredAgentSessionStartFailureCause { + if (ended.duringStartup) { + return { exit: ended.failure } + } + return { failure: ended.failure ?? agentSessionFailureFact('providerExited') } +} + +// Every end cause, so a new one does not compile until it says whether it fails what is queued. +const ENDED_CHILD_FAILURE = { + 'user-stop': () => null, + // The host stopping the child is Orca's cause, never the provider's: a start that never finished. + 'host-stop': () => ({ failure: agentSessionFailureFact('hostStopped') }), + exit: providerEndFailure, + // The attach records its own fault as the end's failure. + 'attach-failed': providerEndFailure, + // Reached only when an eviction's stop landed and a later step failed, leaving the conversation. + evict: providerEndFailure +} satisfies Record< + StructuredAgentSessionChildEndCause, + (ended: StructuredAgentSessionEndedChild) => StructuredAgentSessionStartFailureCause | null +> + +/** Why a queued message the child never took is rejected; null when its end fails nothing. */ +export function structuredAgentSessionEndedChildFailure( + ended: StructuredAgentSessionEndedChild +): StructuredAgentSessionStartFailureCause | null { + return ENDED_CHILD_FAILURE[ended.cause](ended) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts index 19989e10711..f7a50627370 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts @@ -1,3 +1,4 @@ +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { stopAgentSessionProviderRoot, type StructuredAgentSessionLifecycleEvent @@ -7,12 +8,8 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import type { StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' -import type { StructuredAgentSessionHolds } from './structured-agent-session-holds' import { settleStructuredAgentSessionProviderStarted } from './structured-agent-session-provider-started' -import { - isStructuredAgentSessionRecoveryTicketCurrent, - settleUnexpectedStructuredAgentSessionExit -} from './structured-agent-session-unexpected-exit' +import { settleUnexpectedStructuredAgentSessionExit } from './structured-agent-session-unexpected-exit' export class StructuredAgentSessionEventRecovery { private readonly sinkFailures = new Set() @@ -25,12 +22,8 @@ export class StructuredAgentSessionEventRecovery { flushLifecycle: (sessionId: string) => Promise publishFence: (sessionId: string, session: StructuredAgentSessionHostSession) => void publishStatus?: (sessionId: string) => void - hasResumeCapableHolder: (sessionId: string) => boolean - restartReleaseGrace: (sessionId: string) => void serialize: (sessionId: string, task: () => Promise) => Promise now: () => number - /** The one restart every asker shares; the holds put an unheld child on the idle clock. */ - ensureProviderChild: StructuredAgentSessionHolds['ensureProviderChild'] onBarrierError: (sessionId: string, error: unknown) => void } ) {} @@ -42,14 +35,13 @@ export class StructuredAgentSessionEventRecovery { this.sinkFailures.add(sessionId) void this.context .serialize(sessionId, async () => { - const session = this.context.sessions.get(sessionId) + const child = this.context.sessions.get(sessionId)?.child const stop = this.context.deps.adapter.forceCloseSession ?? this.context.deps.adapter.closeSession - if (!session?.hasProviderChild || !stop) { + if (!child || !stop) { return null } - const fence = session.fence - const acquisitionGeneration = session.acquisitionGeneration + const { fence, generation: acquisitionGeneration } = child const stopped = await stopAgentSessionProviderRoot(() => stop(sessionId)) if (!stopped || !acquisitionGeneration) { return null @@ -58,6 +50,8 @@ export class StructuredAgentSessionEventRecovery { type: 'ended', sessionId, reason: `journal sink failure: ${error instanceof Error ? error.message : String(error)}`, + // Orca stopped the provider because its own journal failed. + failure: agentSessionFailureFact('hostFault'), cause: 'unexpected-exit', fence, acquisitionGeneration @@ -68,34 +62,12 @@ export class StructuredAgentSessionEventRecovery { .finally(() => this.sinkFailures.delete(sessionId)) } + /** An exit is settled and shown; nothing restarts the child. The next send does, through the + * delivery loop, which also owns any message still queued. */ async handle(event: StructuredAgentSessionLifecycleEvent): Promise { if (event.type === 'started') { return settleStructuredAgentSessionProviderStarted(this.context, event) } - const ticket = await settleUnexpectedStructuredAgentSessionExit(this.context, event) - if (!ticket) { - return - } - // One serialized step with the ticket check inside it: a hold or a send that got there first - // has already replaced the owner, and this step finds that child and attaches nothing — or, - // once the lease has moved on, refuses on the stale ticket rather than spawning a second child. - try { - const resumed = await this.context.serialize(ticket.sessionId, () => - this.context.ensureProviderChild(ticket.sessionId, { - admitRecoveryTicket: () => - isStructuredAgentSessionRecoveryTicketCurrent(this.context, ticket) - }) - ) - if (!resumed.ok && isStructuredAgentSessionRecoveryTicketCurrent(this.context, ticket)) { - this.context.onBarrierError( - ticket.sessionId, - new Error(`${resumed.refusal.code}: ${resumed.refusal.message}`) - ) - } - } catch (error) { - if (isStructuredAgentSessionRecoveryTicketCurrent(this.context, ticket)) { - this.context.onBarrierError(ticket.sessionId, error) - } - } + await settleUnexpectedStructuredAgentSessionExit(this.context, event) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts index c9a32533db4..428a3c1c0d4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts @@ -15,6 +15,8 @@ export type StructuredAgentSessionSinkOperation = { lifecycleBytes?: number lifecycle?: boolean coalescingKey?: string + /** The queued operation with this key wins, as the journal keeps a settlement's first batch. */ + keepsFirst?: boolean run: (target: StructuredAgentSessionEventTarget) => Promise | void } @@ -117,10 +119,13 @@ export class StructuredAgentSessionSinkQueue { if (this.failure !== null) { return { accepted: false, reason: 'failed' } } - const sequence = ++this.acceptedSequence const key = options.coalescingKey ?? operation.coalescingKey const replaceAt = key ? this.queue.findIndex((queued) => queued.coalescingKey === key) : -1 const replaced = replaceAt >= 0 ? this.queue[replaceAt] : undefined + if (replaced && operation.keepsFirst) { + return { accepted: true } + } + const sequence = ++this.acceptedSequence const lifecycle = operation.lifecycle ?? options.lifecycle === true const lifecycleBytes = lifecycle ? (operation.lifecycleBytes ?? operation.bytes) : 0 const nextBytes = this.queuedBytes - (replaced?.bytes ?? 0) + operation.bytes diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts index 6c3747fb824..46dc8fbf7e4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody, @@ -37,7 +38,7 @@ type Recorded = { * it: a double that omits the third parameter makes every assertion about what * the sink forwards pass against `undefined`, which is how this went unnoticed * before. Kept separate so the call-order assertions stay about call order. */ -const journalAppendOptions: JournalItemAppendOptions[] = [] +const journalAppendOptions: Partial[] = [] function target( fence: number, @@ -92,8 +93,8 @@ describe('deferred structured agent-session event sink', () => { const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() - deferred.sink.appendItem(identity(0), BODY) - deferred.sink.appendItem(identity(1), BODY) + deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) deferred.sink.publish() expect(log).toEqual([]) @@ -112,10 +113,10 @@ describe('deferred structured agent-session event sink', () => { const deferred = createDeferredStructuredAgentSessionEventSink() deferred.bind(target(1, log)) - deferred.sink.appendItem(identity(0), BODY) + deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) // The re-attach that raised the fence. deferred.bind(target(2, log)) - deferred.sink.appendItem(identity(1), BODY) + deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await deferred.drained() expect(log).toEqual([ @@ -130,7 +131,7 @@ describe('deferred structured agent-session event sink', () => { deferred.bind(target(1, log)) deferred.unbind() - deferred.sink.appendItem(identity(0), BODY) + deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) expect(log).toEqual([]) deferred.bind(target(2, log)) await deferred.drained() @@ -143,9 +144,15 @@ describe('deferred structured agent-session event sink', () => { const deferred = createDeferredStructuredAgentSessionEventSink() expect( - deferred.sink.tryAppendLifecycleTransition?.(identity(0), BODY, () => identity(1)) + deferred.sink.tryAppendLifecycleTransition?.(identity(0), BODY, () => identity(1), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) ).toEqual({ accepted: true }) - expect(deferred.sink.tryAppendLifecycleTransition?.(identity(0), BODY, () => null)).toEqual({ + expect( + deferred.sink.tryAppendLifecycleTransition?.(identity(0), BODY, () => null, { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).toEqual({ accepted: true }) deferred.bind(target(2, log)) @@ -161,10 +168,10 @@ describe('deferred structured agent-session event sink', () => { const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() - deferred.sink.appendItem(identity(0), BODY) + deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) deferred.close() deferred.bind(target(3, log)) - deferred.sink.appendItem(identity(1), BODY) + deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await deferred.drained() expect(log).toEqual([]) @@ -180,7 +187,7 @@ describe('deferred structured agent-session event sink', () => { }) deferred.bind(target(4, log, 0)) - deferred.sink.appendItem(identity(0), BODY) + deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) deferred.sink.appendTombstone(identity(1)) const barrier = await deferred.drained() @@ -202,14 +209,14 @@ describe('deferred structured agent-session event sink', () => { const runtime = new StructuredAgentSessionHostRuntimeState({ store: {} } as never) const failed = runtime.eventSinkFor('session-1') failed.bind(target(1, [], 0)) - failed.sink.appendItem(identity(0), BODY) + failed.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await expect(failed.drained()).resolves.toMatchObject({ ok: false }) const recovered = runtime.eventSinkFor('session-1') expect(recovered).not.toBe(failed) const log: Recorded[] = [] recovered.bind(target(2, log)) - recovered.sink.appendItem(identity(1), BODY) + recovered.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await expect(recovered.drained()).resolves.toEqual({ ok: true }) expect(log).toEqual([{ call: 'appendItem', fence: 2, ordinal: 1 }]) }) @@ -229,9 +236,15 @@ describe('deferred structured agent-session event sink', () => { onBackpressureChange: (paused) => changes.push(paused) }) - expect(deferred.sink.tryAppendItem?.(identity(0), BODY)).toEqual({ accepted: true }) - expect(deferred.sink.tryAppendItem?.(identity(1), BODY)).toEqual({ accepted: true }) - expect(deferred.sink.tryAppendItem?.(identity(2), BODY)).toEqual({ + expect( + deferred.sink.tryAppendItem?.(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).toEqual({ accepted: true }) + expect( + deferred.sink.tryAppendItem?.(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).toEqual({ accepted: true }) + expect( + deferred.sink.tryAppendItem?.(identity(2), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).toEqual({ accepted: false, reason: 'backpressure' }) @@ -257,11 +270,17 @@ describe('deferred structured agent-session event sink', () => { } }) - expect(deferred.sink.tryAppendItem?.(identity(0), BODY)).toEqual({ accepted: true }) expect( - deferred.sink.tryAppendResolvedItemAndPublish?.(identity(1), BODY, () => identity(1)) + deferred.sink.tryAppendItem?.(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) ).toEqual({ accepted: true }) - expect(deferred.sink.tryAppendItem?.(identity(2), BODY)).toEqual({ + expect( + deferred.sink.tryAppendResolvedItemAndPublish?.(identity(1), BODY, () => identity(1), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).toEqual({ accepted: true }) + expect( + deferred.sink.tryAppendItem?.(identity(2), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).toEqual({ accepted: false, reason: 'backpressure' }) @@ -292,7 +311,9 @@ describe('deferred structured agent-session event sink', () => { onBackpressureChange: (paused) => changes.push(paused) }) - expect(deferred.sink.tryAppendItem?.(identity(0), BODY)).toEqual({ accepted: true }) + expect( + deferred.sink.tryAppendItem?.(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).toEqual({ accepted: true }) expect(deferred.state()).toMatchObject({ backpressured: true, queuedOperations: 1 }) expect(readingControl.pauseReading).toHaveBeenCalledOnce() @@ -322,7 +343,7 @@ describe('deferred structured agent-session event sink', () => { deferred.sink.appendLifecycleBatch?.( 'settlement-1', - [{ kind: 'item', identity: identity(0), body: BODY }], + [{ kind: 'item', identity: identity(0), body: BODY, turnScope: AGENT_JOURNAL_THREAD_SCOPE }], { lifecycle: true } ) expect(deferred.sink.tryPublish?.({ lifecycle: true })).toEqual({ @@ -354,7 +375,9 @@ describe('deferred structured agent-session event sink', () => { }) const releaseFirst = deferred.sink.bindReadingControl?.(firstControl) - expect(deferred.sink.tryAppendItem?.(identity(0), BODY)).toEqual({ accepted: true }) + expect( + deferred.sink.tryAppendItem?.(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).toEqual({ accepted: true }) expect(firstControl.pauseReading).toHaveBeenCalledOnce() const releaseSecond = deferred.sink.bindReadingControl?.(secondControl) @@ -373,7 +396,7 @@ describe('deferred structured agent-session event sink', () => { it('replaces a queued same-item checkpoint before it runs', async () => { const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() - const options = { coalescingKey: 'checkpoint:item-1' } + const options = { coalescingKey: 'checkpoint:item-1', turnScope: AGENT_JOURNAL_THREAD_SCOPE } deferred.sink.appendItem(identity(0), BODY, options) deferred.sink.appendItem(identity(1), BODY, options) @@ -384,13 +407,44 @@ describe('deferred structured agent-session event sink', () => { expect(log).toEqual([{ call: 'appendItem', fence: 6, ordinal: 1 }]) }) + it('keeps the first queued lifecycle batch for a settlement, as the journal does', async () => { + // The journal applies a settlement id once and skips any later batch with it, + // so the queue must not let a later batch replace one it has not run yet. + const log: Recorded[] = [] + const deferred = createDeferredStructuredAgentSessionEventSink() + const batch = (ordinal: number) => [ + { + kind: 'item' as const, + identity: identity(ordinal), + body: BODY, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] + + deferred.sink.appendLifecycleBatch?.('turn-completed:turn-1', batch(0)) + expect(deferred.sink.tryAppendLifecycleBatch?.('turn-completed:turn-1', batch(1))).toEqual({ + accepted: true + }) + expect(deferred.state().queuedOperations).toBe(1) + + const bound = target(6, log) + deferred.bind(bound) + await deferred.drained() + + expect( + vi + .mocked(bound.journal.appendLifecycleBatch) + .mock.calls.map(([input]) => input.mutations.map((mutation) => mutation.identity)) + ).toEqual([[identity(0)]]) + }) + it('keeps a replacement checkpoint after distinct intervening operations', async () => { const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() - const options = { coalescingKey: 'checkpoint:item-1' } + const options = { coalescingKey: 'checkpoint:item-1', turnScope: AGENT_JOURNAL_THREAD_SCOPE } deferred.sink.appendItem(identity(0), BODY, options) - deferred.sink.appendItem(identity(1), BODY) + deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) deferred.sink.appendItem(identity(2), BODY, options) deferred.bind(target(6, log)) await deferred.drained() @@ -434,10 +488,15 @@ describe('producer linkage reaches the journal through every append path', () => const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() deferred.bind(target(5, log)) - deferred.sink[append]?.(identity(1), BODY, { ...LINKAGE }) + deferred.sink[append]?.(identity(1), BODY, { + ...LINKAGE, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await deferred.drained() - expect(journalAppendOptions).toEqual([{ fence: 5, ...LINKAGE }]) + expect(journalAppendOptions).toEqual([ + { fence: 5, turnScope: AGENT_JOURNAL_THREAD_SCOPE, ...LINKAGE } + ]) deferred.close() } }) @@ -453,10 +512,15 @@ describe('producer linkage reaches the journal through every append path', () => const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() deferred.bind(target(5, log)) - deferred.sink[append]?.(identity(1), BODY, () => identity(1), { ...LINKAGE }) + deferred.sink[append]?.(identity(1), BODY, () => identity(1), { + ...LINKAGE, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await deferred.drained() - expect(journalAppendOptions).toEqual([{ fence: 5, ...LINKAGE }]) + expect(journalAppendOptions).toEqual([ + { fence: 5, turnScope: AGENT_JOURNAL_THREAD_SCOPE, ...LINKAGE } + ]) deferred.close() } }) @@ -470,7 +534,7 @@ describe('producer linkage reaches the journal through every append path', () => deferred.bind(target(5, log)) deferred.sink.appendLifecycleBatch?.( 'settle-1', - [{ kind: 'item', identity: identity(1), body: BODY }], + [{ kind: 'item', identity: identity(1), body: BODY, turnScope: AGENT_JOURNAL_THREAD_SCOPE }], { ...LINKAGE } ) @@ -485,13 +549,13 @@ describe('producer linkage reaches the journal through every append path', () => const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() deferred.bind(target(5, log)) - deferred.sink.appendItem(identity(1), BODY) + deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await deferred.drained() // A control, not a pin. Absence is the claim, so the keys must be missing // rather than present-and-undefined: a reader holding this options object // would read `agentId: undefined` as a key that exists. - expect(journalAppendOptions).toEqual([{ fence: 5 }]) + expect(journalAppendOptions).toEqual([{ fence: 5, turnScope: AGENT_JOURNAL_THREAD_SCOPE }]) deferred.close() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts index 923342b7fa6..f712c36cc91 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -2,7 +2,8 @@ import { agentJournalItemKey } from '../../../shared/agent-session-journal-item- import type { AgentJournalItemBody, AgentJournalItemIdentity, - AgentJournalProducerLinkage + AgentJournalProducerLinkage, + AgentJournalTurnScope } from '../../../shared/agent-session-journal-types' import type { AgentSessionTurnActivity } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -36,6 +37,11 @@ export type StructuredAgentSessionAppendOptions = AgentJournalProducerLinkage & observedAt?: number } +/** An item write states which turn its row belongs to; the write that creates the row decides. */ +export type StructuredAgentSessionItemAppendOptions = StructuredAgentSessionAppendOptions & { + turnScope: AgentJournalTurnScope +} + export type StructuredAgentSessionLifecycleJournal = Pick< AgentSessionJournal, 'epoch' | 'visitItems' @@ -58,7 +64,7 @@ export type StructuredAgentSessionRevisionResolver = ( /** A revision's body is derived from the row it revises, so coalescing one away would lose it. */ export type StructuredAgentSessionRevisionOptions = Omit< - StructuredAgentSessionAppendOptions, + StructuredAgentSessionItemAppendOptions, 'coalescingKey' > @@ -69,7 +75,7 @@ export type StructuredAgentSessionEventSink = { appendItem( identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - options?: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ): void appendTombstone( identity: AgentJournalItemIdentity, @@ -84,40 +90,40 @@ export type StructuredAgentSessionEventSink = { tryAppendItem?( identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - options?: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ): StructuredAgentSessionSinkAdmission /** Queues an ordinary append whose identity is resolved after journal bind. */ tryAppendResolvedItem?( identitySizeBound: AgentJournalItemIdentity, body: AgentJournalItemBody, resolveIdentity: StructuredAgentSessionIdentityResolver, - options?: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ): StructuredAgentSessionSinkAdmission /** Queues one resolved append and its publication as a single admitted operation. */ tryAppendResolvedItemAndPublish?( identitySizeBound: AgentJournalItemIdentity, body: AgentJournalItemBody, resolveIdentity: StructuredAgentSessionIdentityResolver, - options?: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ): StructuredAgentSessionSinkAdmission /** Queues a read-modify-write of one row; `reservedBytes` must bound the resolved write. */ tryReviseResolvedItem?( reservedBytes: number, resolve: StructuredAgentSessionRevisionResolver, - options?: StructuredAgentSessionRevisionOptions + options: StructuredAgentSessionRevisionOptions ): StructuredAgentSessionSinkAdmission /** Queues one revision and its publication as a single admitted operation. */ tryReviseResolvedItemAndPublish?( reservedBytes: number, resolve: StructuredAgentSessionRevisionResolver, - options?: StructuredAgentSessionRevisionOptions + options: StructuredAgentSessionRevisionOptions ): StructuredAgentSessionSinkAdmission /** Queues one journal-derived lifecycle append; a null resolution is a no-op. */ tryAppendLifecycleTransition?( identitySizeBound: AgentJournalItemIdentity, body: AgentJournalItemBody, resolveIdentity: StructuredAgentSessionIdentityResolver, - options?: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ): StructuredAgentSessionSinkAdmission /** Current durable epoch, when this deferred sink is bound to its journal. */ journalEpoch?(): string | null @@ -205,6 +211,7 @@ export function createDeferredStructuredAgentSessionEventSink( { bytes: Buffer.byteLength(JSON.stringify({ settlementId, mutations }), 'utf8') + 512, coalescingKey: `lifecycle:${settlementId}`, + keepsFirst: true, run: (bound) => bound.journal.appendLifecycleBatch({ settlementId, @@ -230,7 +237,7 @@ export function createDeferredStructuredAgentSessionEventSink( return { sink: { - appendItem: (identity, body, options = {}) => { + appendItem: (identity, body, options) => { queue.submit( { bytes: estimateStructuredAgentSessionItemBytes(identity, body), @@ -245,7 +252,7 @@ export function createDeferredStructuredAgentSessionEventSink( options ) }, - tryAppendItem: (identity, body, options = {}) => + tryAppendItem: (identity, body, options) => queue.submit( { bytes: estimateStructuredAgentSessionItemBytes(identity, body), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts index cb0cb2fb233..8fc810de2a4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts @@ -30,8 +30,8 @@ function context(): StructuredAgentSessionEvictionContext & { order: string[] } return true }) } as unknown as StructuredAgentSessionEvictionContext['adapter'], - forget: vi.fn(async () => { - order.push('forget') + acknowledgeRelease: vi.fn(() => { + order.push('acknowledgeRelease') }), discardSink: vi.fn(() => order.push('discardSink')), settleWork: vi.fn(async () => { @@ -51,7 +51,7 @@ function runtimeState(): StructuredAgentSessionHostRuntimeState { } describe('structured agent session eviction', () => { - it('stops the child before it lets the sink go, then forgets the session', async () => { + it('stops the child before it lets the sink go, then acknowledges the release', async () => { const ctx = context() await evictStructuredAgentSession(ctx) expect(ctx.order).toEqual([ @@ -62,7 +62,7 @@ describe('structured agent session eviction', () => { 'close', 'discardSink', 'releaseLease', - 'forget' + 'acknowledgeRelease' ]) }) @@ -90,7 +90,7 @@ describe('structured agent session eviction', () => { 'close-sink', 'discard-sink', 'release-lease', - 'forget-session' + 'acknowledge-release' ]) }) @@ -114,7 +114,7 @@ describe('structured agent session eviction', () => { } }) - it('aborts after a failed drain barrier without unbinding or forgetting the session', async () => { + it('aborts after a failed drain barrier without unbinding or acknowledging the release', async () => { const ctx = context() ctx.eventSink.drained = vi.fn(async () => { ctx.order.push('drained') @@ -128,7 +128,7 @@ describe('structured agent session eviction', () => { expect(ctx.eventSink.close).not.toHaveBeenCalled() expect(ctx.discardSink).not.toHaveBeenCalled() expect(ctx.releaseLease).not.toHaveBeenCalled() - expect(ctx.forget).not.toHaveBeenCalled() + expect(ctx.acknowledgeRelease).not.toHaveBeenCalled() expect(ctx.order).toEqual(['closeSession', 'drained']) }) }) @@ -154,9 +154,9 @@ describe('rows the provider emits while closing', () => { return true } } as never, - forget: async () => {}, discardSink: () => state.discardEventSink(sessionId), - releaseLease: async () => {} + releaseLease: async () => {}, + acknowledgeRelease: () => {} }) expect(published).toEqual(['final-flush']) @@ -174,9 +174,13 @@ describe('a child that will not stop', () => { ctx.adapter.closeSession = vi.fn(async () => { throw error }) + const stopped = vi.fn() + ctx.onProviderChildStopped = stopped await evictStructuredAgentSession(ctx) + // The host ends its child on the one reading of the verdict, not a second one of its own. + expect(stopped).toHaveBeenCalledWith({ rootGone: true }) expect(ctx.order).toEqual([ 'drained', 'settleWork', @@ -184,18 +188,18 @@ describe('a child that will not stop', () => { 'close', 'discardSink', 'releaseLease', - 'forget' + 'acknowledgeRelease' ]) }) - it('aborts without forgetting the session, so the next close is a real retry', async () => { + it('aborts without acknowledging the release, so the next stop is a real retry', async () => { const ctx = context() ctx.adapter.closeSession = vi.fn(async () => false) await expect(evictStructuredAgentSession(ctx)).rejects.toMatchObject({ step: 'stop-provider-child' }) - expect(ctx.forget).not.toHaveBeenCalled() + expect(ctx.acknowledgeRelease).not.toHaveBeenCalled() expect(ctx.discardSink).not.toHaveBeenCalled() expect(ctx.order).toEqual([]) }) @@ -210,7 +214,7 @@ describe('a child that will not stop', () => { StructuredAgentSessionEvictionError ) expect(ctx.eventSink.close).not.toHaveBeenCalled() - expect(ctx.forget).not.toHaveBeenCalled() + expect(ctx.acknowledgeRelease).not.toHaveBeenCalled() }) }) @@ -225,9 +229,9 @@ describe('eviction against the real sink cache', () => { sessionId, eventSink: state.eventSinkFor(sessionId), adapter: { closeSession: async () => true } as never, - forget: async () => {}, discardSink: () => state.discardEventSink(sessionId), - releaseLease: async () => {} + releaseLease: async () => {}, + acknowledgeRelease: () => {} }) const published: string[] = [] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts index e5a1d942818..226781117bd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts @@ -1,4 +1,4 @@ -// Releasing one structured session's resources. +// Stopping one structured session's provider child and handing its lease back. // // Teardown is a DATA list, not a method body, for the reason this file exists at all: the host // tracked which sessions were live in a map, and tore them down at three unrelated call sites @@ -22,6 +22,7 @@ import { type StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import type { StructuredAgentSessionStopVerdict } from './structured-agent-session-host-types' import { withTimeout } from '../../../shared/promise-timeout-fallback' export type StructuredAgentSessionEvictionContext = { @@ -29,17 +30,17 @@ export type StructuredAgentSessionEvictionContext = { hasProviderChild?: boolean eventSink: DeferredStructuredAgentSessionEventSink adapter: StructuredAgentSessionAdapter - /** Closes the session's journal handle and drops the map entry. Async and - * awaited: `close()` is ordered behind queued writes, and a delete that - * returns while the close is still queued leaves nothing to retry. */ - forget: () => Promise + /** Tells the adapter the released lease is done with, so it drops this child's route and index. + * The conversation stays: stopping the agent never closes its journal. */ + acknowledgeRelease: () => Promise | void /** Drops the cached sink so a later attach mints a fresh one. */ discardSink: () => void /** Fires right before the stop, while the child's turn and background roster are still live. A * throw is logged, never allowed to abort the stop. */ beforeProviderChildStop?: () => void - /** Fires once the adapter has PROVEN the child gone, so host bookkeeping stops claiming one. */ - onProviderChildStopped?: () => void + /** Fires with the stop's verdict once `stopAgentSessionProviderRoot` read the root gone, so host + * bookkeeping stops claiming a child. */ + onProviderChildStopped?: (verdict: StructuredAgentSessionStopVerdict) => void /** Whether this host still owes the child's wind-down. Distinct from `hasProviderChild`, which a * proven exit retires mid-run: the two disagree for exactly the steps a retry has to repeat. */ owesProviderChildWindDown?: boolean @@ -51,7 +52,7 @@ export type StructuredAgentSessionEvictionContext = { } /** The resume offer is advisory; a stalled sink must not hold the child's stop behind it. */ -const SNAPSHOT_DRAIN_TIMEOUT_MS = 1_000 +export const SNAPSHOT_DRAIN_TIMEOUT_MS = 1_000 export type StructuredAgentSessionEvictionStep = { name: string @@ -84,13 +85,13 @@ export const STRUCTURED_AGENT_SESSION_EVICTION_STEPS: readonly StructuredAgentSe } // An adapter with no close has nothing to stop; anything else must PROVE the exit. const stop = context.adapter.disposeSession ?? context.adapter.closeSession - if ( - stop && - !(await stopAgentSessionProviderRoot(() => stop.call(context.adapter, context.sessionId))) - ) { + const rootGone = stop + ? await stopAgentSessionProviderRoot(() => stop.call(context.adapter, context.sessionId)) + : true + if (!rootGone) { throw new Error('provider child exit was not proven') } - context.onProviderChildStopped?.() + context.onProviderChildStopped?.({ rootGone }) } }, { @@ -115,11 +116,11 @@ export const STRUCTURED_AGENT_SESSION_EVICTION_STEPS: readonly StructuredAgentSe // these two; eviction has to as well. { name: 'discard-sink', run: (context) => context.discardSink() }, // Why here and not last: the durable lease still names a process this host just stopped, and a - // record left claiming a live owner is one nothing can resume — the next surface to open the - // chat would find a session it may not acquire. Placed BEFORE forget so a release that cannot - // be written aborts while the session is still indexed, which is what makes the retry real. + // record left claiming a live owner is one nothing can resume — the next send would find a + // session it may not acquire. Placed BEFORE the acknowledgement so a release that cannot be + // written aborts while the adapter still routes the session, which is what makes the retry real. { name: 'release-lease', run: (context) => context.releaseLease() }, - { name: 'forget-session', run: (context) => context.forget() } + { name: 'acknowledge-release', run: (context) => context.acknowledgeRelease() } ] export class StructuredAgentSessionEvictionError extends Error { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-exit-turn-scope.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-exit-turn-scope.ts new file mode 100644 index 00000000000..c336a1d3eca --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-exit-turn-scope.ts @@ -0,0 +1,36 @@ +// Which turn a gone generation's exit row belongs to, so the row reports on the turn it ended. + +import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalRenderItem, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import type { StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict' + +/** The turn the exit ended: still running, or already ended at the exit's instant by the child's + * own translator, which settles its open turn when the child goes. */ +export function exitedRootTurnScope( + items: readonly AgentJournalRenderItem[], + verdict: StructuredAgentSessionTurnVerdict +): AgentJournalTurnScope { + const endedAt = verdict.state === 'interrupted' ? verdict.completedAt : undefined + const ended = items.findLast((item) => { + const turn = isRootAgentJournalItem(item) ? readAgentJournalTurn(item.body) : null + return ( + turn?.state === 'running' || + (turn?.state === 'interrupted' && endedAt !== undefined && turn.completedAt === endedAt) + ) + }) + return ended ? { kind: 'turn', turnItemId: ended.itemId } : AGENT_JOURNAL_THREAD_SCOPE +} + +export function runningRootTurnScope( + items: readonly AgentJournalRenderItem[] +): AgentJournalTurnScope { + const running = items.findLast( + (item) => isRootAgentJournalItem(item) && readAgentJournalTurn(item.body)?.state === 'running' + ) + return running ? { kind: 'turn', turnItemId: running.itemId } : AGENT_JOURNAL_THREAD_SCOPE +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts index 3d064eed7e0..317e7ca81a0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts @@ -13,6 +13,7 @@ import { type StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { withObservedProviderExit } from './structured-agent-session-failure-text' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -23,6 +24,10 @@ import { const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'claude stream-json exited (code 1): stderr tail' +// The refusal says what the chat's start failure says; the error text stays in the log. +const COULD_NOT_RESTART = "Codex couldn't restart. Send your message to try again." +const PROVIDER_STOPPED = + 'Codex stopped before it finished starting. Send your message to try again.' let root: string let store: AgentSessionRecordStore @@ -66,47 +71,75 @@ afterEach(async () => { }) describe('failed create owner verdict', () => { - it('answers an exit-proven failure as exited on the first call and its replay, and a new operation starts fresh', async () => { - // The cleanup's release proves the whole tree gone: the common failed start. - acquire.mockRejectedValueOnce(new Error(EXIT_REASON)) - const first = hostTestAttachParams(null) - const refusal = { - code: 'agent_session_operation_invalid', - message: EXIT_REASON, - ownerVerdict: 'exited' + it.each([ + // The cleanup's release proves the whole tree gone, which says nothing about why it failed. + ['a failure the cleanup proved gone', () => new Error(EXIT_REASON), {}, COULD_NOT_RESTART], + // Only an exit the adapter saw says the provider stopped. + [ + 'an exit the adapter observed', + () => withObservedProviderExit(new Error(EXIT_REASON)), + { reason: 'providerStartFailed' }, + PROVIDER_STOPPED + ] + ])( + 'answers %s as exited on the first call and its replay, and a new operation starts fresh', + async (_case, failure, situation, message) => { + acquire.mockRejectedValueOnce(failure()) + const first = hostTestAttachParams(null) + // The replay names the same details as the first answer: the ledger kept them beside the code, + // and the verdict reaches released clients at the top level exactly as before. + const refusal = { + code: 'agent_session_operation_invalid', + details: { ...situation, ownerVerdict: 'exited' }, + message, + ownerVerdict: 'exited' + } + + await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) + await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) + expect(acquire).toHaveBeenCalledOnce() + + const retry = hostTestAttachParams(null) + expect(retry.envelope.clientOperationId).not.toBe(first.envelope.clientOperationId) + await expect(host.attach(CALLER, retry)).resolves.toMatchObject({ ok: true }) + expect(acquire).toHaveBeenCalledTimes(2) + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') } + ) - await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) - await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) - expect(acquire).toHaveBeenCalledOnce() + it.each([ + // A cleanup that saw the root go may have stopped it itself. + ['a root exit the cleanup saw', () => new Error(EXIT_REASON), {}, COULD_NOT_RESTART], + [ + 'a root exit the adapter observed', + () => withObservedProviderExit(new Error(EXIT_REASON)), + { reason: 'providerStartFailed' }, + PROVIDER_STOPPED + ] + ])( + 'answers %s as exited on the first call, in the shape its replay takes', + async (_case, cause, situation, message) => { + acquire.mockRejectedValueOnce(new AgentSessionAcquisitionRootExitObservedError(cause())) + const first = hostTestAttachParams(null) + // The replay names the same details as the first answer: the ledger kept them beside the code, + // and the verdict reaches released clients at the top level exactly as before. + const refusal = { + code: 'agent_session_operation_invalid', + details: { ...situation, ownerVerdict: 'exited' }, + message, + ownerVerdict: 'exited' + } - const retry = hostTestAttachParams(null) - expect(retry.envelope.clientOperationId).not.toBe(first.envelope.clientOperationId) - await expect(host.attach(CALLER, retry)).resolves.toMatchObject({ ok: true }) - expect(acquire).toHaveBeenCalledTimes(2) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') - }) + await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) + await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) + expect(acquire).toHaveBeenCalledOnce() - it('answers a first-hand root exit as exited on the first call, in the shape its replay takes', async () => { - acquire.mockRejectedValueOnce( - new AgentSessionAcquisitionRootExitObservedError(new Error(EXIT_REASON)) - ) - const first = hostTestAttachParams(null) - const refusal = { - code: 'agent_session_operation_invalid', - message: EXIT_REASON, - ownerVerdict: 'exited' + await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ + ok: true + }) + expect(acquire).toHaveBeenCalledTimes(2) } - - await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) - await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) - expect(acquire).toHaveBeenCalledOnce() - - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ - ok: true - }) - expect(acquire).toHaveBeenCalledTimes(2) - }) + ) it('answers an acquisition refusal with its verdict directly', async () => { acquire.mockRejectedValueOnce(new AgentSessionAcquisitionRefusal('not signed in')) @@ -115,7 +148,8 @@ describe('failed create owner verdict', () => { ok: false, refusal: { code: 'agent_session_operation_invalid', - message: 'not signed in', + details: { reason: 'providerStartFailed', ownerVerdict: 'exited' }, + message: PROVIDER_STOPPED, ownerVerdict: 'exited' } }) @@ -132,6 +166,10 @@ describe('failed create owner verdict', () => { ok: false, refusal: { code: 'agent_session_ownership_unknown', ownerVerdict: 'unverifiable' } }) - expect(store.getRecord(SESSION)?.lease.claimStatus).not.toBe('released') + // Released so the next start can go ahead, but with no death evidence: nothing proved it. + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + deathEvidence: null + }) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts index a5e00fde53c..90f52db3d09 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts @@ -1,7 +1,12 @@ -import type { - AgentSessionAttachResult, - AgentSessionMutationResult, - AgentSessionWireRefusal +import { + isAgentSessionWireRefusalCode, + refuse, + refuseUnclassified, + withAgentSessionRefusalFacts, + type AgentSessionAttachResult, + type AgentSessionMutationResult, + type AgentSessionRefusalDetailsByCode, + type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { AgentSessionOperationOutcome, @@ -15,16 +20,50 @@ import { AgentSessionAcquisitionExitUnprovenError, AgentSessionAcquisitionRefusal, AgentSessionAcquisitionRootExitObservedError, + AgentSessionPreSpawnError, isAgentSessionPreSpawnError } from './structured-agent-session-adapter' +import { + providerExitObserved, + structuredAgentSessionStartFailure +} from './structured-agent-session-failure-text' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' + +/** Who a failed acquisition's sentence names, and whether it was the session's first start. */ +export type FailedAcquisitionWording = { + record: AgentSessionRecord | null + newSession: boolean +} + +/** The refusal a failed acquisition answers with, worded as the start failure it records: its + * error text is Orca's or the provider's log wording, so it goes to the log, never the wire. */ +function failedAcquisitionWireRefusal( + details: AgentSessionRefusalDetailsByCode['agent_session_operation_invalid'] | undefined, + wording: FailedAcquisitionWording +): AgentSessionWireRefusal { + const code = 'agent_session_operation_invalid' + const refusal = details ? refuse(code, details, code) : refuseUnclassified(code, code) + const { reason } = structuredAgentSessionStartFailure( + { refusal, ...(wording.newSession ? { newSession: true as const } : {}) }, + structuredAgentSessionFailureWordsContext(wording.record) + ) + return { ...refusal, message: reason } +} /** What a failed acquisition proved about its process, and the outcome its operation settles to. */ -export function failedAcquisitionSettlement(error: unknown): { +export function failedAcquisitionSettlement( + error: unknown, + wording: FailedAcquisitionWording +): { exitProof: AgentSessionAcquisitionExitProof outcome: Extract } { if (error instanceof AgentSessionAcquisitionExitUnprovenError) { - const outcome = { code: 'agent_session_ownership_unknown', message: error.message } + const outcome = { + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven' as const }, + message: error.message + } return { exitProof: 'unproven', outcome: { status: 'failed', ...outcome } } } const exitProof = isAgentSessionPreSpawnError(error) @@ -32,33 +71,79 @@ export function failedAcquisitionSettlement(error: unknown): { : error instanceof AgentSessionAcquisitionRootExitObservedError ? 'root-exit-observed' : 'exit-proven' - const message = error instanceof Error ? error.message : String(error) - const code = - error instanceof AgentSessionAcquisitionRefusal ? error.code : 'agent_session_operation_invalid' - return { exitProof, outcome: { status: 'failed', code, message } } + const raw = error instanceof Error ? error.message : String(error) + const details = failedAcquisitionDetails(error) + const message = + failedAcquisitionRefusal(error, wording)?.refusal.message ?? + // A store refusal's message is its code, which its replay has always carried. + (isAgentSessionWireRefusalCode(raw) + ? raw + : failedAcquisitionWireRefusal(details, wording).message) + return { + exitProof, + outcome: { + status: 'failed', + code: 'agent_session_operation_invalid', + ...(details ? { details } : {}), + message + } + } +} + +/** Cleanup proved the child gone after the start failed, whatever failed it. */ +function isExitProvenAcquisitionFailure(error: unknown): error is Error { + return ( + error instanceof AgentSessionAcquisitionRootExitObservedError || + error instanceof AgentSessionAcquisitionExitProvenError + ) +} + +/** The situation a failed acquisition stands for: what the adapter typed, or a provider the + * adapter saw exit while starting. A provider refusing a request, a timeout, a store refusal or + * Orca's own fault names none: the child being gone now says nothing about why. */ +function failedAcquisitionDetails( + error: unknown +): AgentSessionRefusalDetailsByCode['agent_session_operation_invalid'] | undefined { + if (error instanceof AgentSessionAcquisitionRefusal) { + return { reason: error.reason } + } + if (isAgentSessionPreSpawnError(error) && error.reason) { + return { reason: error.reason } + } + if (isExitProvenAcquisitionFailure(error) && providerExitObserved(error)) { + return { reason: 'providerStartFailed' } + } + return undefined } /** A failed acquisition answered as a refusal on the first call, in the shape its replay takes; * null leaves the error to the store-failure classification. */ export function failedAcquisitionRefusal( - error: unknown + error: unknown, + wording: FailedAcquisitionWording ): { ok: false; refusal: AgentSessionWireRefusal } | null { - if (error instanceof AgentSessionAcquisitionRefusal) { - return { ok: false, refusal: { code: error.code, message: error.message } } - } - // A proven exit is a settled fact; its message is the provider's own diagnostic. - if ( - error instanceof AgentSessionAcquisitionRootExitObservedError || - error instanceof AgentSessionAcquisitionExitProvenError - ) { + // A proven exit is a settled failure, answered in the shape its ledger row replays. + if (error instanceof AgentSessionAcquisitionRefusal || isExitProvenAcquisitionFailure(error)) { return { ok: false, - refusal: { code: 'agent_session_operation_invalid', message: error.message } + refusal: failedAcquisitionWireRefusal(failedAcquisitionDetails(error), wording) } } return null } +/** A start that failed before any process still throws, in the sentence its replay reads: the + * error's own text is Orca's or the refusing site's, so it goes to the log. A message that is + * itself a code keeps it, since the wire routes on it. */ +export function preSpawnFailureInWords(error: unknown, wording: FailedAcquisitionWording): unknown { + if (!isAgentSessionPreSpawnError(error) || /^[a-z0-9_]+$/.test(error.message)) { + return error + } + return new AgentSessionPreSpawnError(error, { + message: failedAcquisitionSettlement(error, wording).outcome.message + }) +} + /** Only a durably failed operation says anything about retrying under a new one. */ export function failedCreateRefusal( refusal: AgentSessionWireRefusal, @@ -68,7 +153,9 @@ export function failedCreateRefusal( return status === 'failed' && record ? { ok: false, - refusal: { ...refusal, ownerVerdict: agentSessionLeaseOwnerVerdict(record.lease) } + refusal: withAgentSessionRefusalFacts(refusal, { + ownerVerdict: agentSessionLeaseOwnerVerdict(record.lease) + }) } : { ok: false, refusal } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts index c61d93cbb8f..69f9b95c03a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts @@ -82,8 +82,11 @@ describe('a create that fails after its child wrote through the unbound sink', ( const failed = host.attach(CALLER, hostTestAttachParams(null)) await (cause instanceof AgentSessionPreSpawnError - ? expect(failed).rejects.toThrow(EXIT_REASON) - : expect(failed).resolves.toMatchObject({ ok: false, refusal: { message: EXIT_REASON } })) + ? expect(failed).rejects.toThrow("Codex couldn't restart. Send your message to try again.") + : expect(failed).resolves.toMatchObject({ + ok: false, + refusal: { message: "Codex couldn't restart. Send your message to try again." } + })) await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: true @@ -118,7 +121,7 @@ describe('a create that fails after its child wrote through the unbound sink', ( // The session stays indexed across this failure: it is a resume, not a create. const failed = host.attach(CALLER, hostTestAttachParams(releasedFence)) await (cause instanceof AgentSessionPreSpawnError - ? expect(failed).rejects.toThrow(EXIT_REASON) + ? expect(failed).rejects.toThrow("Codex couldn't restart. Send your message to try again.") : expect(failed).resolves.toMatchObject({ ok: false })) const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts new file mode 100644 index 00000000000..fcebdda7726 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_PROVIDER_DIAGNOSTIC_CHARS, + providerDiagnostic, + withProviderDiagnostic +} from '../../../shared/agent-session-failure' +import { + AgentSessionRefusalError, + refuse, + refuseUnclassified +} from '../../../shared/agent-session-wire-refusals' +import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapter' +import { MAX_UNEXPECTED_EXIT_REASON_CHARS } from './structured-agent-session-dead-generation-settlement' +import { + structuredAgentSessionStartFailure, + withObservedProviderExit +} from './structured-agent-session-failure-text' + +/** What Orca's own error text looks like: a code, a marker, a uuid, a path, an exception. */ +const ORCA_INTERNAL = + /agent_session_|execution_owner|provider_[a-z_]+|[0-9a-f]{8}-[0-9a-f]{4}-|[/\\][\w.-]+[/\\]|Error:|ENOENT/ + +describe('structuredAgentSessionStartFailure', () => { + it('keeps a provider diagnostic only when the error carried one', () => { + const carried = withProviderDiagnostic( + new Error('claude stream-json exited (code 1): boom'), + providerDiagnostic('code 1\nboom', 'log') + ) + expect(structuredAgentSessionStartFailure({ error: carried }, { agentName: 'Claude' })).toEqual( + { + reason: "Claude couldn't start. Send your message to try again.", + rejection: { kind: 'startFailed', detail: { text: 'code 1\nboom', audience: 'log' } } + } + ) + // Orca's own words, however provider-like, are never promoted to a detail. + expect( + structuredAgentSessionStartFailure({ error: new Error('Not logged in. Run /login.') }) + .rejection + ).toEqual({ kind: 'startFailed' }) + }) + + it('keeps a start refusal the adapter typed', () => { + const refusal = new AgentSessionAcquisitionRefusal( + 'Claude is not signed in for the selected account.', + 'notSignedIn' + ) + expect(structuredAgentSessionStartFailure({ error: refusal }, { agentName: 'Claude' })).toEqual( + { + reason: + 'Claude is not signed in for the selected account. Sign in, then send your message again.', + rejection: { kind: 'notSignedIn' } + } + ) + }) + + it.each([ + [ + 'managedAccountEnvOverride', + 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.' + ], + [ + 'accountSwitchInProgress', + 'A Claude account switch is in progress. Try again after it finishes.' + ], + [ + 'managedAccountUnsupported', + 'While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings, then send your message again.' + ] + ] as const)('words a start refused for %s by that situation', (reason, sentence) => { + const code = 'agent_session_operation_invalid' + expect( + structuredAgentSessionStartFailure( + { refusal: refuse(code, { reason }, code) }, + { agentName: 'Claude' } + ) + ).toEqual({ reason: sentence, rejection: { kind: reason } }) + }) + + it('words a refused restart by its situation, never its message', () => { + const words = structuredAgentSessionStartFailure( + { + refusal: refuse( + 'agent_session_ownership_unknown', + { reason: 'ownerUnproven' }, + 'Orca cannot prove that process 4242 on host-1 has exited.' + ) + }, + { agentName: 'Claude' } + ) + expect(words).toEqual({ + reason: "Claude couldn't restart. Send your message to try again.", + rejection: { + kind: 'restartFailed', + refusal: { + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven' } + } + } + }) + }) + + it("reads an exit before the start as a failed start, and an Orca fault as Orca's", () => { + expect( + structuredAgentSessionStartFailure({ + exit: { kind: 'providerExited', detail: { text: 'stderr', audience: 'log' } } + }).rejection + ).toEqual({ kind: 'providerStartFailed', detail: { text: 'stderr', audience: 'log' } }) + expect(structuredAgentSessionStartFailure({ exit: { kind: 'hostFault' } }).rejection).toEqual({ + kind: 'hostFault' + }) + expect(structuredAgentSessionStartFailure({ hostFault: true }).reason).not.toMatch( + ORCA_INTERNAL + ) + }) + + it("holds any provider detail to the lease record's cap", () => { + expect(MAX_PROVIDER_DIAGNOSTIC_CHARS).toBe(512) + expect(MAX_UNEXPECTED_EXIT_REASON_CHARS).toBe(MAX_PROVIDER_DIAGNOSTIC_CHARS) + const long = 'x'.repeat(4_000) + // However the caller built the detail, the fact stores at most the cap. + const words = structuredAgentSessionStartFailure({ + diagnostic: { text: long, audience: 'log' } + }) + expect(words.rejection.detail?.text).toHaveLength(MAX_UNEXPECTED_EXIT_REASON_CHARS) + expect( + structuredAgentSessionStartFailure({ + exit: { kind: 'providerExited', detail: { text: long, audience: 'log' } } + }).rejection.detail?.text + ).toHaveLength(MAX_UNEXPECTED_EXIT_REASON_CHARS) + }) + + it('says the provider stopped only when an exit was observed', () => { + // An `exited` verdict says only that nothing runs now, not that the provider stopped. + const gone = refuseUnclassified('agent_session_operation_invalid', 'thread gone', { + ownerVerdict: 'exited' + }) + const person = { text: 'no rollout found for thread id T', audience: 'person' } as const + expect( + structuredAgentSessionStartFailure( + { refusal: gone, diagnostic: person }, + { agentName: 'Codex' } + ) + ).toEqual({ + reason: "Codex couldn't restart. Send your message to try again.", + rejection: { + kind: 'restartFailed', + detail: person, + refusal: { + code: 'agent_session_operation_invalid', + details: { ownerVerdict: 'exited' } + } + } + }) + const observed = refuse( + 'agent_session_operation_invalid', + { reason: 'providerStartFailed', ownerVerdict: 'exited' }, + 'exited (code 1)' + ) + expect( + structuredAgentSessionStartFailure({ + refusal: observed, + diagnostic: { text: 'code 1', audience: 'log' } + }) + ).toEqual({ + reason: 'The agent stopped before it finished starting. Send your message to try again.', + rejection: { kind: 'providerStartFailed', detail: { text: 'code 1', audience: 'log' } } + }) + // A start that threw proves nothing about the provider: it may be Orca's, or a failed spawn. + expect( + structuredAgentSessionStartFailure({ error: new Error('spawn claude ENOENT') }, {}) + ).toEqual({ + reason: "The agent couldn't start. Send your message to try again.", + rejection: { kind: 'startFailed' } + }) + // The same error, once the adapter that observed the child's exit marked it, blames the provider. + const exit = withObservedProviderExit( + withProviderDiagnostic(new Error('exited (code 1)'), providerDiagnostic('code 1', 'log')) + ) + expect( + structuredAgentSessionStartFailure({ error: new Error('wrapped', { cause: exit }) }) + ).toEqual({ + reason: 'The agent stopped before it finished starting. Send your message to try again.', + rejection: { kind: 'providerStartFailed', detail: { text: 'code 1', audience: 'log' } } + }) + }) + + it('reads a thrown refusal the same as a returned one', () => { + const thrown = new AgentSessionRefusalError( + refuse( + 'agent_session_conflict', + { reason: 'claimConflicted' }, + 'Another process claims this session.' + ) + ) + expect(thrown.message).toBe('agent_session_conflict') + expect(structuredAgentSessionStartFailure({ refusal: thrown.refusal }).rejection).toEqual({ + kind: 'restartFailed', + refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } } + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts new file mode 100644 index 00000000000..c0c2172230d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -0,0 +1,163 @@ +// The facts a start or an exit reduces to, and the one place a failed start is worded. The +// sentence itself comes from `agentSessionFailureWords`, beside the fact it states. + +import { + agentSessionFailureFact, + providerDiagnosticOf, + type SubmissionRejectionFact, + type AgentSessionFailureKind, + type ProviderDiagnostic +} from '../../../shared/agent-session-failure' +import type { AgentSessionRefusalReason } from '../../../shared/agent-session-refusal-details' +import { + agentSessionFailureWords, + type AgentJournalDispatchRejection, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' +import { + agentSessionRefusalReference, + type AgentSessionWireRefusal +} from '../../../shared/agent-session-wire-refusals' +import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapter' + +/** Start refusals whose situation is itself what the person reads, with its own next step. */ +const TYPED_START_REFUSALS = [ + 'notSignedIn', + 'historyTooLarge', + 'managedAccountEnvOverride', + 'accountSwitchInProgress', + 'managedAccountUnsupported' +] as const satisfies readonly (AgentSessionFailureKind & + AgentSessionRefusalReason<'agent_session_operation_invalid'>)[] + +function typedStartRefusal( + reason: string | undefined +): (typeof TYPED_START_REFUSALS)[number] | undefined { + return TYPED_START_REFUSALS.find((typed) => typed === reason) +} + +/** Marks the error an adapter observed its child's exit with, where it observed it. */ +export function withObservedProviderExit(error: TError): TError { + return Object.assign(error, { providerExitObserved: true }) +} + +/** Whether the adapter saw the child exit on its own. Follows `cause` and the errors a cleanup + * aggregated, since the acquisition errors wrap what the adapter threw. */ +export function providerExitObserved(error: unknown, depth = 0): boolean { + if (depth >= 6 || !(error instanceof Error)) { + return false + } + if ('providerExitObserved' in error && error.providerExitObserved === true) { + return true + } + if ( + error instanceof AggregateError && + error.errors.some((inner) => providerExitObserved(inner, depth + 1)) + ) { + return true + } + return providerExitObserved(error.cause, depth + 1) +} + +/** A start that did not land. A refusal the adapter typed keeps its situation, and an exit the + * adapter observed says the provider stopped; anything else blames no one — it may be Orca's, or + * a spawn that failed. Either keeps the provider's diagnostic when the error carried one. */ +export function providerStartupFailureFact(cause?: unknown): SubmissionRejectionFact { + const typed = typedStartRefusal( + cause instanceof AgentSessionAcquisitionRefusal ? cause.reason : undefined + ) + if (typed) { + return agentSessionFailureFact(typed) + } + return agentSessionFailureFact( + providerExitObserved(cause) ? 'providerStartFailed' : 'startFailed', + { detail: providerDiagnosticOf(cause) } + ) +} + +/** A child that ended before it proved its start: an exit is a start that failed, keeping the + * provider's diagnostic; an Orca fault or a typed start refusal stays what it was. */ +function startupFailureFromExit( + failure: SubmissionRejectionFact | undefined +): SubmissionRejectionFact { + if (!failure || failure.kind === 'providerExited') { + return agentSessionFailureFact('providerStartFailed', { detail: failure?.detail }) + } + return failure +} + +/** What the chat records when a session could not be made ready. Only a refusal the host typed as + * an observed exit says the provider stopped; a verdict of `exited` means only that nothing runs + * now. */ +function refusedStartFailureFact( + cause: Extract +): SubmissionRejectionFact { + const { refusal, diagnostic } = cause + const reason = refusal.details?.reason + const typed = typedStartRefusal(reason) + if (typed) { + return agentSessionFailureFact(typed) + } + if (reason === 'providerStartFailed') { + return agentSessionFailureFact('providerStartFailed', { detail: diagnostic }) + } + return agentSessionFailureFact(cause.newSession ? 'startFailed' : 'restartFailed', { + detail: diagnostic, + refusal: agentSessionRefusalReference(refusal) + }) +} + +/** Why a start the chat needed did not land, as the place that saw it knows it. */ +export type StructuredAgentSessionStartFailureCause = + /** The session could not be made ready; the provider's words, if any, are kept host-side, off + * the refusal. `newSession`: one that never ran, so it failed to start rather than restart. */ + | { refusal: AgentSessionWireRefusal; diagnostic?: ProviderDiagnostic; newSession?: true } + /** A start that threw, or an adapter's own startup failure; any diagnostic it carries. */ + | { error: unknown } + /** The child ended before it proved its start, as its ended event told it. */ + | { exit: SubmissionRejectionFact | undefined } + /** The provider exited while starting; only its words are known, and the caller names their + * audience. */ + | { diagnostic: ProviderDiagnostic | undefined } + /** Orca's own fault; its error belongs in the log. */ + | { hostFault: true } + /** Already typed where it was observed. */ + | { failure: SubmissionRejectionFact } + +/** A start failure's row repeats the sentence its rejected messages carry: both are about the + * messages the start was for. */ +export type StructuredAgentSessionStartFailureWords = AgentJournalDispatchRejection + +/** The fact a failed start records, for a writer that words it on its own surface. */ +export function structuredAgentSessionStartFailureFact( + cause: StructuredAgentSessionStartFailureCause +): SubmissionRejectionFact { + if ('refusal' in cause) { + return refusedStartFailureFact(cause) + } + if ('error' in cause) { + return providerStartupFailureFact(cause.error) + } + if ('exit' in cause) { + return startupFailureFromExit(cause.exit) + } + if ('diagnostic' in cause) { + return agentSessionFailureFact('providerStartFailed', { detail: cause.diagnostic }) + } + if ('hostFault' in cause) { + return agentSessionFailureFact('hostFault') + } + return cause.failure +} + +/** The one place a failed start is worded: the error row and every message it rejects carry this + * sentence and this fact, whichever writer saw the start fail. */ +export function structuredAgentSessionStartFailure( + cause: StructuredAgentSessionStartFailureCause, + context: AgentSessionFailureWordsContext = {} +): StructuredAgentSessionStartFailureWords { + return agentSessionFailureWords(structuredAgentSessionStartFailureFact(cause), { + ...context, + surface: 'rejection' + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts index 20531bd6465..8ce8d7d1507 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Removing a session from the host's map and removing its status row are ONE operation. // // The store keeps a row until told to drop it, and `structuredHostOwned` bypasses the staleness @@ -21,11 +22,10 @@ import type { StructuredAgentSessionAttachContext } from './structured-agent-ses import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' -// Everything before the journal is out of scope here; what matters is that the orchestration's -// own `onAttachFailed` runs, which is the real one. +// Everything before the journal is out of scope here; what matters is what the orchestration does +// when the attach throws after acquisition. vi.mock('./structured-agent-session-attach-flow', () => ({ - performAttach: async (input: { onAttachFailed?: () => Promise }) => { - await input.onAttachFailed?.() + performAttach: async () => { throw new Error('attach failed after acquisition') } })) @@ -111,12 +111,12 @@ async function workingSession(): Promise<{ await journal.appendItem( PROMPT, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'ship it' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const sessions = new Map([ [ @@ -136,10 +136,7 @@ async function workingSession(): Promise<{ accountHome: ownerRecord().accountHome, runtimeKind: 'native' }, - fence: 1, - hasProviderChild: true, - providerChildPhase: 'ready', - acquisitionGeneration: null + child: { generation: null, fence: 1, phase: 'ready' } } ] ]) @@ -190,7 +187,7 @@ function attachContext( reconcileLeases: async () => null, serialize: (_sessionId: string, task: () => Promise) => task(), now: () => 1, - forgetStatus: (sessionId: string) => feed.forget(sessionId) + publishStatus: (sessionId: string) => feed.publish(sessionId) } as unknown as StructuredAgentSessionAttachContext } @@ -230,15 +227,19 @@ describe('a session that leaves the host without an explicit close', () => { expect(server.getStatusSnapshot()).toEqual([expect.objectContaining({ prompt: 'other host' })]) }) - it('leaves the agent-status store with it when an attach fails', async () => { + // A failed attach no longer drops the session: the conversation stays open for the failure to be + // written into, so its row stays with it and the later close forgets both together. + it('keeps the session and its status row together when an attach fails', async () => { const { server, feed, sessions } = await workingSession() + const drop = vi.spyOn(server, 'dropStructuredStatus') await expect( attachStructuredAgentSession(attachContext(sessions, feed), 'caller-1', attachParams) ).rejects.toThrow('attach failed after acquisition') - expect(sessions.has(SESSION)).toBe(false) - expect(server.getStatusSnapshot()).toEqual([]) + expect(sessions.has(SESSION)).toBe(true) + expect(drop).not.toHaveBeenCalled() + expect(server.getStatusSnapshot()).toHaveLength(1) }) // The feed's own cache deliberately retains the projection for reload history; only the store diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts index bbc1ec49d4c..72317e8ed81 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -70,33 +71,37 @@ async function seedGroupedQuestion(): Promise<{ itemId: string; revision: number if (!events) { throw new Error('seedGroupedQuestion requires an acquired session') } - events.appendItem(identity, { - kind: 'question', - question: '2 grouped questions from Claude', - options: [], - questions: [ - { - id: 'q1', - question: 'Targets', - multiSelect: true, - options: [ - { id: 'target-web', label: 'Web' }, - { id: 'target-mobile', label: 'Mobile' } - ] - }, - { - id: 'q2', - question: 'Host', - multiSelect: false, - options: [], - freeTextQuestionId: 'q2' - } - ], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - }) + events.appendItem( + identity, + { + kind: 'question', + question: '2 grouped questions from Claude', + options: [], + questions: [ + { + id: 'q1', + question: 'Targets', + multiSelect: true, + options: [ + { id: 'target-web', label: 'Web' }, + { id: 'target-mobile', label: 'Mobile' } + ] + }, + { + id: 'q2', + question: 'Host', + multiSelect: false, + options: [], + freeTextQuestionId: 'q2' + } + ], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) await host.flushStreamedEvents(SESSION) const itemId = agentJournalItemKey(identity) - const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) const appended = page.ok ? page.page.items.find((item) => item.itemId === itemId) : null if (!appended) { throw new Error('provider question was not written to the journal') @@ -141,23 +146,78 @@ afterEach(async () => { }) describe('grouped question admission', () => { - it('admits renderer question-group payloads with child ids and multi-select answers', async () => { + it('reads the packed answer an older client sends into structured answers', async () => { const attached = await host.attach(CALLER, attachParams()) expect(attached.ok).toBe(true) const prompt = await seedGroupedQuestion() - const optionId = encodeAgentSessionQuestionAnswers([ + const answers = [ { questionId: 'q1', optionIds: ['target-web', 'target-mobile'] }, { questionId: 'q2', optionIds: [], other: 'SSH host' } - ]) + ] + const optionId = encodeAgentSessionQuestionAnswers(answers) const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId } const result = await host.respondToPrompt(CALLER, { envelope: envelope('agentSession.respondTo:question', fields), kind: 'question', ...fields }) - expect(result).toMatchObject({ ok: true, value: { resolution: { state: 'resolved' } } }) + expect(result).toMatchObject({ + ok: true, + value: { resolution: { state: 'resolved', selectedOptionId: optionId, answers } } + }) expect(answerPrompt).toHaveBeenCalledWith( - expect.objectContaining({ itemId: prompt.itemId, optionId }) + expect.objectContaining({ itemId: prompt.itemId, response: { kind: 'answers', answers } }) ) }) + + it('takes structured answers past the old option-id bound and keeps the packed form for older readers', async () => { + const attached = await host.attach(CALLER, attachParams()) + expect(attached.ok).toBe(true) + const prompt = await seedGroupedQuestion() + const typed = 'Proceed with the replacement, but wait for the capture. '.repeat(40) + const answers = [ + { questionId: 'q1', optionIds: ['target-web'] }, + { questionId: 'q2', optionIds: [], other: typed } + ] + const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, answers } + const result = await host.respondToPrompt(CALLER, { + envelope: envelope('agentSession.respondTo:question', fields), + kind: 'question', + ...fields + }) + + expect(typed.length).toBeGreaterThan(1024) + expect(result).toMatchObject({ + ok: true, + value: { + resolution: { + state: 'resolved', + selectedOptionId: encodeAgentSessionQuestionAnswers(answers), + answers + } + } + }) + expect(answerPrompt).toHaveBeenCalledWith( + expect.objectContaining({ response: { kind: 'answers', answers } }) + ) + }) + + it('refuses answers that do not match the questions without reaching the provider', async () => { + const attached = await host.attach(CALLER, attachParams()) + expect(attached.ok).toBe(true) + const prompt = await seedGroupedQuestion() + const answers = [{ questionId: 'q1', optionIds: ['target-web'] }] + const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, answers } + const result = await host.respondToPrompt(CALLER, { + envelope: envelope('agentSession.respondTo:question', fields), + kind: 'question', + ...fields + }) + + expect(result).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) + expect(answerPrompt).not.toHaveBeenCalled() + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume-race.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume-race.test.ts deleted file mode 100644 index 974a69b1c3c..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume-race.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { runKeyedSerializedOperation } from '../../cli/keyed-promise-queue' -import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-hold-resume' -import { StructuredAgentSessionHolds } from './structured-agent-session-holds' - -const GRACE_MS = 15_000 -const pendingHolds: StructuredAgentSessionHolds[] = [] - -/** The host's per-session queue: a second hold waits for the attach the first one is running. */ -function keyedSerialize() { - const chains = new Map>() - return (sessionId: string, task: () => Promise) => - runKeyedSerializedOperation(chains, sessionId, task) -} - -function resumeHarness() { - const resumeGate = Promise.withResolvers() - let child = false - let turnActive = false - const evict = vi.fn(async () => { - child = false - }) - const resume = vi.fn(async () => { - await resumeGate.promise - child = true - return { ok: true as const } - }) - const holds = new StructuredAgentSessionHolds({ - resume, - serialize: keyedSerialize(), - hasProviderChild: () => child, - hasOwedWork: () => turnActive, - evict, - graceMs: GRACE_MS - }) - pendingHolds.push(holds) - return { - holds, - resume, - resumeGate, - evict, - hasChild: () => child, - setTurnActive: (value: boolean) => { - turnActive = value - } - } -} - -beforeEach(() => vi.useFakeTimers()) -afterEach(() => { - for (const holds of pendingHolds.splice(0)) { - holds.dispose() - } - vi.useRealTimers() -}) - -describe('a surface leaving while its structured session resumes', () => { - it('releases the acquired child after the last surface disconnects during resume', async () => { - const { holds, resumeGate, evict, hasChild } = resumeHarness() - const hold = holds.hold('session-1', 'connection-1:chat') - - holds.release('session-1', 'connection-1:chat') - expect(holds.isReleasePending('session-1')).toBe(false) - resumeGate.resolve() - await hold - - expect(hasChild()).toBe(true) - expect(holds.isHeld('session-1')).toBe(false) - expect(holds.isReleasePending('session-1')).toBe(true) - await vi.advanceTimersByTimeAsync(GRACE_MS - 1) - expect(evict).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(1) - expect(evict).toHaveBeenCalledExactlyOnceWith('session-1') - expect(hasChild()).toBe(false) - }) - - it('waits for an active turn before releasing the late child', async () => { - const { holds, resumeGate, evict, setTurnActive } = resumeHarness() - const hold = holds.hold('session-1', 'connection-1:chat') - holds.release('session-1', 'connection-1:chat') - setTurnActive(true) - resumeGate.resolve() - await hold - - await vi.advanceTimersByTimeAsync(GRACE_MS * 2) - expect(evict).not.toHaveBeenCalled() - expect(holds.isReleasePending('session-1')).toBe(true) - - setTurnActive(false) - await vi.advanceTimersByTimeAsync(GRACE_MS) - expect(evict).toHaveBeenCalledExactlyOnceWith('session-1') - }) - - it.each([false, true])('preserves an arriving holder with resume=%s', async (resume) => { - const { holds, resumeGate, evict } = resumeHarness() - const first = holds.hold('session-1', 'connection-1:chat') - holds.release('session-1', 'connection-1:chat') - const replacement = holds.hold('session-1', 'connection-2:chat', { resume }) - resumeGate.resolve() - await Promise.all([first, replacement]) - - await vi.advanceTimersByTimeAsync(GRACE_MS * 2) - expect(holds.isHeld('session-1')).toBe(true) - expect(holds.isReleasePending('session-1')).toBe(false) - expect(evict).not.toHaveBeenCalled() - - holds.release('session-1', 'connection-2:chat') - await vi.advanceTimersByTimeAsync(GRACE_MS) - expect(evict).toHaveBeenCalledExactlyOnceWith('session-1') - }) - - it('cancels the late-child release when a surface reconnects during grace', async () => { - const { holds, resumeGate, evict } = resumeHarness() - const hold = holds.hold('session-1', 'connection-1:chat') - holds.release('session-1', 'connection-1:chat') - resumeGate.resolve() - await hold - expect(holds.isReleasePending('session-1')).toBe(true) - - await holds.hold('session-1', 'connection-2:chat') - await vi.advanceTimersByTimeAsync(GRACE_MS * 2) - expect(holds.isReleasePending('session-1')).toBe(false) - expect(evict).not.toHaveBeenCalled() - }) - - it('preserves a failed resume without scheduling eviction', async () => { - const { holds, resumeGate, evict, hasChild } = resumeHarness() - const failure = new Error('provider acquisition failed') - const hold = holds.hold('session-1', 'connection-1:chat') - const rejected = expect(hold).rejects.toBe(failure) - holds.release('session-1', 'connection-1:chat') - resumeGate.reject(failure) - await rejected - - await vi.advanceTimersByTimeAsync(GRACE_MS * 2) - expect(hasChild()).toBe(false) - expect(holds.isHeld('session-1')).toBe(false) - expect(holds.isReleasePending('session-1')).toBe(false) - expect(evict).not.toHaveBeenCalled() - }) - - it('leaves late acquisition cleanup to host teardown after disposal', async () => { - const { holds, resumeGate, evict } = resumeHarness() - const hold = holds.hold('session-1', 'connection-1:chat') - holds.release('session-1', 'connection-1:chat') - holds.dispose() - resumeGate.resolve() - await hold - - await vi.advanceTimersByTimeAsync(GRACE_MS * 2) - expect(holds.isReleasePending('session-1')).toBe(false) - expect(evict).not.toHaveBeenCalled() - }) - - it('does not restart release timers when a surface leaves after disposal', async () => { - const { holds, resumeGate, evict } = resumeHarness() - const hold = holds.hold('session-1', 'connection-1:chat') - resumeGate.resolve() - await hold - holds.dispose() - holds.release('session-1', 'connection-1:chat') - - await vi.advanceTimersByTimeAsync(GRACE_MS * 2) - expect(holds.isHeld('session-1')).toBe(false) - expect(holds.isReleasePending('session-1')).toBe(false) - expect(evict).not.toHaveBeenCalled() - }) - - it('releases a late child acquired after explicit close forgot its holders', async () => { - const { holds, resumeGate, evict } = resumeHarness() - const hold = holds.hold('session-1', 'connection-1:chat') - holds.forget('session-1') - resumeGate.resolve() - await hold - - await vi.advanceTimersByTimeAsync(GRACE_MS) - expect(holds.isHeld('session-1')).toBe(false) - expect(evict).toHaveBeenCalledExactlyOnceWith('session-1') - }) - - it('lets a holder that left and came back make its own attempt behind a failing one, and its failure releases it', async () => { - const { holds, resume, resumeGate, evict } = resumeHarness() - const first = holds.hold('session-1', 'same-holder') - const firstRejected = expect(first).rejects.toThrow('acquisition failed') - holds.release('session-1', 'same-holder') - const replacement = holds.hold('session-1', 'same-holder') - const replacementRejected = expect(replacement).rejects.toThrow('acquisition failed') - await vi.advanceTimersByTimeAsync(0) - expect(holds.isHeld('session-1')).toBe(true) - expect(resume).toHaveBeenCalledOnce() - - // The gate stays rejected, so the replacement's own attempt fails the same way. - resumeGate.reject(new Error('acquisition failed')) - await Promise.all([firstRejected, replacementRejected]) - - expect(resume).toHaveBeenCalledTimes(2) - expect(holds.isHeld('session-1')).toBe(false) - expect(holds.isReleasePending('session-1')).toBe(false) - await vi.advanceTimersByTimeAsync(GRACE_MS * 2) - expect(evict).not.toHaveBeenCalled() - }) - - it('keeps a re-hold that finds the child the failing attempt ahead of it left behind', async () => { - const gate = Promise.withResolvers() - let child = false - const holds = new StructuredAgentSessionHolds({ - // The child is up before the attempt settles, and then the attempt fails behind it. - resume: async () => { - child = true - await gate.promise - return { ok: true as const } - }, - serialize: keyedSerialize(), - hasProviderChild: () => child, - hasOwedWork: () => false, - evict: async () => {}, - graceMs: GRACE_MS - }) - pendingHolds.push(holds) - const first = holds.hold('session-1', 'same-holder') - const rejected = expect(first).rejects.toThrow('acquisition failed') - holds.release('session-1', 'same-holder') - const replacement = holds.hold('session-1', 'same-holder') - - gate.reject(new Error('acquisition failed')) - await rejected - // The first hold's failure released only the holder it added, at the incarnation it added; - // the replacement then ran, found the child, and kept the holder it re-took. - await replacement - - expect(holds.isHeld('session-1')).toBe(true) - expect(holds.isReleasePending('session-1')).toBe(false) - }) - - it('starts a fresh resume once the failed one has settled', async () => { - let child = false - const resume = vi - .fn<() => Promise>() - .mockRejectedValueOnce(new Error('first acquisition failed')) - .mockImplementationOnce(async () => { - child = true - return { ok: true } - }) - const holds = new StructuredAgentSessionHolds({ - resume, - serialize: keyedSerialize(), - hasProviderChild: () => child, - hasOwedWork: () => false, - evict: async () => {}, - graceMs: GRACE_MS - }) - pendingHolds.push(holds) - - await expect(holds.hold('session-1', 'chat-1')).rejects.toThrow('first acquisition failed') - await holds.hold('session-1', 'chat-1') - - expect(resume).toHaveBeenCalledTimes(2) - expect(holds.isHeld('session-1')).toBe(true) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts deleted file mode 100644 index 9ea59e18487..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts +++ /dev/null @@ -1,130 +0,0 @@ -// Giving a session its provider child back. -// -// This is the replacement for the startup resume, and the difference is only in WHO asks: the same -// eligibility rule, run when a surface binds, when a send finds the owner gone, or when a child -// exits under an open surface — never when the app launches. It runs inside the session's -// serialize, with the attach it is given, so the eligibility it reads is the one the attach acts -// on. A write-capable hold must fail when acquisition is refused so the surface never mistakes a -// readable journal for a live provider child. - -import type { - AgentSessionAttachResult, - AgentSessionMutationResult, - AgentSessionWireRefusal -} from '../../../shared/agent-session-wire' -import { isAgentSessionWireRefusalCode } from '../../../shared/agent-session-wire-refusals' -import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' -import { - attachStructuredAgentSessionUnderSerialize, - type StructuredAgentSessionAttachOptions -} from './structured-agent-session-attach-orchestration' -import { failedCreateRefusal } from './structured-agent-session-failed-create-refusal' -import { adapterSupportsRecord } from './structured-agent-session-provider-support' -import { - structuredAgentSessionResumeOperationId, - structuredAgentSessionResumeParams -} from './structured-agent-session-resume-eligibility' - -/** A resume answers with the attach's own refusal, verdict and all, so the asker can tell a lease - * someone else is settling from an owner that will not come back. */ -export type StructuredAgentSessionResumeOutcome = - | { ok: true } - | { ok: false; refusal: AgentSessionWireRefusal } - -export async function resumeHeldStructuredAgentSession(input: { - sessionId: string - context: StructuredAgentSessionAttachContext - /** Who is asking; the attach keys the ledger row it settles by it. */ - callerKey: string - attachOptions?: StructuredAgentSessionAttachOptions -}): Promise { - const { sessionId, context, callerKey } = input - // The record is read only once this host has adjudicated it and exited any recovery stage a - // failed attempt latched — a lease left in `manual-recovery` by an unproven exit is one the - // resolver hands back, and the eligibility below must see it that way. - const unreconciled = await context.reconcileLeases(sessionId) - if (unreconciled) { - return { ok: false, refusal: unreconciled } - } - await context.runtimeState.resolveRecovery(sessionId) - const record = context.deps.store.getRecord(sessionId) - if (!record) { - return refuse('agent_session_identity_required', 'No structured session exists by that id.') - } - if (!adapterSupportsRecord(context.deps.adapter, record)) { - return refuse( - 'structured_agent_session_unsupported', - 'This execution host cannot resume the requested structured agent session.' - ) - } - const params = structuredAgentSessionResumeParams( - record, - structuredAgentSessionResumeOperationId(context.now()) - ) - if (!params) { - return record.lease.unreconciled - ? refuse( - 'execution_owner_reconciling', - 'This host has not yet adjudicated the session lease.' - ) - : record.lease.claimStatus === 'conflicted' - ? refuse('agent_session_conflict', 'Another process claims this session.') - : refuse( - 'agent_session_ownership_unknown', - 'The session lease is not one this host may resume.' - ) - } - let attached: AgentSessionMutationResult - try { - attached = await attachStructuredAgentSessionUnderSerialize( - context, - callerKey, - params, - input.attachOptions - ) - } catch (error) { - // The attach settles an acquisition that failed — the ledger row, the released lease — before - // it rethrows the cause. That row is the answer: a failure it recorded is this resume's - // refusal, verdict and all. Only an error it did not record is a fault for the caller. - const settled = settledResumeRefusal( - context, - callerKey, - params.envelope.clientOperationId, - sessionId, - error - ) - if (settled) { - return settled - } - throw error - } - return attached.ok ? { ok: true } : { ok: false, refusal: attached.refusal } -} - -function settledResumeRefusal( - context: Pick, - callerKey: string, - operationId: string, - sessionId: string, - error: unknown -): StructuredAgentSessionResumeOutcome | null { - const outcome = context.deps.store.getOperationRow(callerKey, operationId)?.outcome - if (outcome?.status !== 'failed' || !isAgentSessionWireRefusalCode(outcome.code)) { - return null - } - return failedCreateRefusal( - { - code: outcome.code, - message: outcome.message ?? (error instanceof Error ? error.message : String(error)) - }, - outcome.status, - context.deps.store.getRecord(sessionId) - ) -} - -function refuse( - code: AgentSessionWireRefusal['code'], - message: string -): StructuredAgentSessionResumeOutcome { - return { ok: false, refusal: { code, message } } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-holders.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-holders.ts deleted file mode 100644 index 531717168b1..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-holders.ts +++ /dev/null @@ -1,71 +0,0 @@ -// Who WANTS this session alive, as a set of ids rather than a count. -// -// A refcount is the obvious shape and the wrong one. Every path that decrements it — a chat tab -// closing, a transport dying, a client retrying a release it already sent — can fire twice or not -// at all, and an integer cannot tell those apart: a duplicate release evicts a session somebody is -// still looking at, and a lost one leaks the child forever. A set answers both idempotently, -// because it records WHICH surface holds the session, not how many do. - -type Holder = { resumeCapable: boolean; incarnation: symbol } - -export class StructuredAgentSessionHolders { - private readonly bySession = new Map>() - - /** True when the session gained its FIRST holder — the edge that ends a pending release. */ - add(sessionId: string, holderId: string, resumeCapable = true): boolean { - const holders = this.bySession.get(sessionId) - if (!holders) { - this.bySession.set(sessionId, new Map([[holderId, { resumeCapable, incarnation: Symbol() }]])) - return true - } - const previous = holders.get(holderId) - holders.set(holderId, { - resumeCapable: (previous?.resumeCapable ?? false) || resumeCapable, - incarnation: previous?.incarnation ?? Symbol() - }) - return false - } - - /** True when the session lost its LAST holder — the edge that starts one. */ - remove(sessionId: string, holderId: string, expectedIncarnation?: symbol): boolean { - const holders = this.bySession.get(sessionId) - if ( - expectedIncarnation !== undefined && - holders?.get(holderId)?.incarnation !== expectedIncarnation - ) { - return false - } - if (!holders?.delete(holderId) || holders.size > 0) { - return false - } - this.bySession.delete(sessionId) - return true - } - - isHeld(sessionId: string): boolean { - return (this.bySession.get(sessionId)?.size ?? 0) > 0 - } - - has(sessionId: string, holderId: string): boolean { - return this.bySession.get(sessionId)?.has(holderId) ?? false - } - - incarnation(sessionId: string, holderId: string): symbol | undefined { - return this.bySession.get(sessionId)?.get(holderId)?.incarnation - } - - holderIds(sessionId: string): string[] { - return [...(this.bySession.get(sessionId)?.keys() ?? [])] - } - - hasResumeCapableHolder(sessionId: string): boolean { - return [...(this.bySession.get(sessionId)?.values() ?? [])].some( - (holder) => holder.resumeCapable - ) - } - - /** Drops every holder of one session without evaluating the edge, for a session that is gone. */ - forget(sessionId: string): void { - this.bySession.delete(sessionId) - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-holds.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-holds.test.ts deleted file mode 100644 index 68027f81887..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-holds.test.ts +++ /dev/null @@ -1,346 +0,0 @@ -// The parts a session's lifetime is assembled from: the holder set, the release clock, and the -// deadline that keeps teardown from hanging. - -import { afterEach, describe, expect, it, vi } from 'vitest' -import { runKeyedSerializedOperation } from '../../cli/keyed-promise-queue' -import { StructuredAgentSessionHolders } from './structured-agent-session-holders' -import { StructuredAgentSessionReleaseClock } from './structured-agent-session-release-clock' -import { StructuredAgentSessionHolds } from './structured-agent-session-holds' -import { - STRUCTURED_AGENT_SESSION_EVICTION_STEPS, - evictStructuredAgentSession -} from './structured-agent-session-eviction' -import { - StructuredAgentSessionEvictionTimeoutError, - withStructuredAgentSessionEvictionDeadline -} from './structured-agent-session-eviction-deadline' - -const clocks: StructuredAgentSessionReleaseClock[] = [] - -/** The host's per-session queue, so a hold and a writer really take turns. */ -function keyedSerialize() { - const chains = new Map>() - return (sessionId: string, task: () => Promise) => - runKeyedSerializedOperation(chains, sessionId, task) -} - -function clock(deps: { - hasOwedWork?: () => boolean - isHeld?: () => boolean - evict: (sessionId: string) => Promise - onError?: (input: { sessionId: string; error: unknown }) => void -}): StructuredAgentSessionReleaseClock { - const created = new StructuredAgentSessionReleaseClock({ - hasOwedWork: deps.hasOwedWork ?? (() => false), - isHeld: deps.isHeld ?? (() => false), - evict: deps.evict, - ...(deps.onError ? { onError: deps.onError } : {}), - graceMs: 1 - }) - clocks.push(created) - return created -} - -afterEach(() => { - for (const created of clocks.splice(0)) { - created.dispose() - } -}) - -describe('the holder set', () => { - it('reports the first and last holder, and nothing in between', () => { - const holders = new StructuredAgentSessionHolders() - - expect(holders.add('session-1', 'a')).toBe(true) - expect(holders.add('session-1', 'b')).toBe(false) - expect(holders.remove('session-1', 'a')).toBe(false) - expect(holders.remove('session-1', 'b')).toBe(true) - expect(holders.isHeld('session-1')).toBe(false) - }) - - // The reason this is a set and not a count: every release path can fire twice or not at all. - it('absorbs a duplicate hold and a duplicate release', () => { - const holders = new StructuredAgentSessionHolders() - - holders.add('session-1', 'a') - holders.add('session-1', 'a') - expect(holders.remove('session-1', 'a')).toBe(true) - expect(holders.remove('session-1', 'a')).toBe(false) - expect(holders.holderIds('session-1')).toEqual([]) - }) - - it('keeps one session holders out of another session holders', () => { - const holders = new StructuredAgentSessionHolders() - - holders.add('session-1', 'a') - holders.add('session-2', 'a') - holders.remove('session-1', 'a') - - expect(holders.isHeld('session-1')).toBe(false) - expect(holders.isHeld('session-2')).toBe(true) - }) - - it('distinguishes retaining holders from holders that may resume a provider', () => { - const holders = new StructuredAgentSessionHolders() - - holders.add('session-1', 'subscriber', false) - expect(holders.hasResumeCapableHolder('session-1')).toBe(false) - - holders.add('session-1', 'chat', true) - expect(holders.hasResumeCapableHolder('session-1')).toBe(true) - holders.remove('session-1', 'chat') - expect(holders.hasResumeCapableHolder('session-1')).toBe(false) - }) -}) - -describe('the release clock', () => { - it('waits out a running turn instead of evicting into it', async () => { - const evict = vi.fn(async () => {}) - let turnRunning = true - const releasing = clock({ hasOwedWork: () => turnRunning, evict }) - - releasing.arm('session-1') - await new Promise((resolve) => setTimeout(resolve, 30)) - expect(evict).not.toHaveBeenCalled() - - turnRunning = false - await vi.waitFor(() => expect(evict).toHaveBeenCalledWith('session-1')) - }) - - it('stands down when a holder arrives during the wait', async () => { - const evict = vi.fn(async () => {}) - const releasing = clock({ isHeld: () => true, evict }) - - releasing.arm('session-1') - await new Promise((resolve) => setTimeout(resolve, 30)) - - expect(evict).not.toHaveBeenCalled() - }) - - it('keeps an idle unheld child for thirty minutes, and activity starts the window over', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - try { - const evict = vi.fn(async () => {}) - const idle = new StructuredAgentSessionReleaseClock({ - hasOwedWork: () => false, - isHeld: () => false, - evict - }) - clocks.push(idle) - const minutes = (count: number) => vi.advanceTimersByTimeAsync(count * 60_000) - - idle.arm('session-1') - await minutes(20) - idle.renew('session-1') - await minutes(29) - expect(evict).not.toHaveBeenCalled() - - await minutes(1) - expect(evict).toHaveBeenCalledWith('session-1') - } finally { - vi.useRealTimers() - } - }) - - it('does not start a window for a session nothing released', async () => { - const evict = vi.fn(async () => {}) - const releasing = clock({ evict }) - - releasing.renew('session-1') - await new Promise((resolve) => setTimeout(resolve, 30)) - - expect(releasing.isArmed('session-1')).toBe(false) - expect(evict).not.toHaveBeenCalled() - }) - - it('reports a failed eviction rather than swallowing it', async () => { - const onError = vi.fn() - const releasing = clock({ - evict: async () => { - throw new Error('child would not stop') - }, - onError - }) - - releasing.arm('session-1') - - await vi.waitFor(() => - expect(onError).toHaveBeenCalledWith({ - sessionId: 'session-1', - error: expect.objectContaining({ message: 'child would not stop' }) - }) - ) - }) -}) - -describe('holds', () => { - it('resumes a session on its first hold and not on a retained one', async () => { - let child = false - const resume = vi.fn(async () => { - child = true - return { ok: true as const } - }) - const holds = new StructuredAgentSessionHolds({ - resume, - serialize: keyedSerialize(), - hasProviderChild: () => child, - hasOwedWork: () => false, - evict: async () => {}, - graceMs: 1 - }) - - await holds.hold('session-1', 'stream-1', { resume: false }) - expect(resume).not.toHaveBeenCalled() - - await holds.hold('session-1', 'chat-1') - expect(resume).toHaveBeenCalledOnce() - - child = true - await holds.hold('session-1', 'chat-2') - expect(resume).toHaveBeenCalledOnce() - holds.dispose() - }) - - it('runs one resume for a writer and a hold that ask in the same gap', async () => { - const gate = Promise.withResolvers() - let child = false - const resume = vi.fn(async () => { - await gate.promise - child = true - return { ok: true as const } - }) - const serialize = keyedSerialize() - const holds = new StructuredAgentSessionHolds({ - resume, - serialize, - hasProviderChild: () => child, - hasOwedWork: () => false, - evict: async () => {}, - graceMs: 1 - }) - - // A send's ensure-owner step: already inside the session's serialize when it asks. - const writer = serialize('session-1', () => holds.ensureProviderChild('session-1')) - const hold = holds.hold('session-1', 'chat-1') - gate.resolve() - - await expect(writer).resolves.toEqual({ ok: true }) - await hold - // The hold ran after the writer's step and found the child: nothing to resume. - expect(resume).toHaveBeenCalledOnce() - // The surface arrived while the writer's resume ran, so the child it got is held, not idle. - expect(holds.isHeld('session-1')).toBe(true) - expect(holds.isReleasePending('session-1')).toBe(false) - holds.dispose() - }) - - it('puts a child a writer resumed with no surface on the idle clock', async () => { - let child = false - const serialize = keyedSerialize() - const holds = new StructuredAgentSessionHolds({ - resume: async () => { - child = true - return { ok: true as const } - }, - serialize, - hasProviderChild: () => child, - hasOwedWork: () => false, - evict: async () => {}, - graceMs: 60_000 - }) - - await serialize('session-1', () => holds.ensureProviderChild('session-1')) - - expect(holds.isHeld('session-1')).toBe(false) - expect(holds.isReleasePending('session-1')).toBe(true) - holds.dispose() - }) - - it('never arms the clock for a session with nothing to stop', async () => { - const evict = vi.fn(async () => {}) - const holds = new StructuredAgentSessionHolds({ - resume: async () => ({ ok: true as const }), - serialize: keyedSerialize(), - hasProviderChild: () => false, - hasOwedWork: () => false, - evict, - graceMs: 1 - }) - - await holds.hold('session-1', 'chat-1', { resume: false }) - holds.release('session-1', 'chat-1') - await new Promise((resolve) => setTimeout(resolve, 20)) - - expect(evict).not.toHaveBeenCalled() - expect(holds.isReleasePending('session-1')).toBe(false) - holds.dispose() - }) - - it('fails a write-capable hold when resume proves no provider child', async () => { - const holds = new StructuredAgentSessionHolds({ - resume: async () => ({ ok: true as const }), - serialize: keyedSerialize(), - hasProviderChild: () => false, - hasOwedWork: () => false, - evict: async () => {}, - graceMs: 1 - }) - - await expect(holds.hold('session-1', 'chat-1')).rejects.toThrow( - 'agent_session_ownership_unknown' - ) - expect(holds.isHeld('session-1')).toBe(false) - holds.dispose() - }) -}) - -describe('the teardown deadline', () => { - it('leaves the child loaded instead of forcing it, and keeps the session indexed', async () => { - const forget = vi.fn() - const releaseLease = vi.fn(async () => {}) - - await expect( - evictStructuredAgentSession( - { - sessionId: 'session-1', - eventSink: { - unbind: vi.fn(), - drained: vi.fn(async () => {}), - close: vi.fn() - } as never, - adapter: { closeSession: () => new Promise(() => {}) } as never, - forget, - discardSink: vi.fn(), - releaseLease - }, - withStructuredAgentSessionEvictionDeadline(STRUCTURED_AGENT_SESSION_EVICTION_STEPS, 5) - ) - ).rejects.toMatchObject({ step: 'stop-provider-child' }) - - expect(forget).not.toHaveBeenCalled() - expect(releaseLease).not.toHaveBeenCalled() - }) - - it('names the step that ran out of time', async () => { - const [step] = withStructuredAgentSessionEvictionDeadline( - [{ name: 'slow-step', run: () => new Promise(() => {}) }], - 5 - ) - - await expect(step?.run({} as never)).rejects.toBeInstanceOf( - StructuredAgentSessionEvictionTimeoutError - ) - }) - - it('does not delay a step that finishes', async () => { - const ran: string[] = [] - const steps = withStructuredAgentSessionEvictionDeadline( - [{ name: 'fast-step', run: () => void ran.push('fast-step') }], - 5_000 - ) - - await steps[0]?.run({} as never) - - expect(ran).toEqual(['fast-step']) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-holds.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-holds.ts deleted file mode 100644 index 0acbccb6b55..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-holds.ts +++ /dev/null @@ -1,179 +0,0 @@ -// The lifetime of a structured session, tied to the surfaces that want one. -// -// Nothing used to tell the host that a chat WANTED a session, and nothing told it when a chat -// stopped wanting one. Both halves of that gap cost real processes: sessions nobody had opened got -// an app-server at every launch, and sessions the user closed kept theirs until the app quit. -// -// A surface takes a hold when it binds and drops it when it goes away. The first hold on a session -// with no child resumes it — that, and not the shape of a lease on disk, is what makes a provider -// process exist. The last hold leaving starts the idle release clock. Transport close is the BACKSTOP, -// not the mechanism: a client that vanishes mid-flight never sends its release, so the caller -// registers one against the connection and the holder set absorbs the duplicate. -// -// A send to a childless session resumes it too, and so does provider-exit recovery under an open -// surface. All three go through `ensureProviderChild` inside the session's serialize, so they take -// turns: the first to run attaches, and the next finds the child and attaches nothing. Two attaches -// for one session would race against the same released fence, and the loser's stale fence refused -// it — a hold that lost dropped its holder, a send that lost was refused. - -import { - StructuredAgentSessionReleaseClock, - type StructuredAgentSessionReleaseClockDeps -} from './structured-agent-session-release-clock' -import { StructuredAgentSessionHolders } from './structured-agent-session-holders' -import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-hold-resume' -import type { StructuredAgentSessionAttachOptions } from './structured-agent-session-attach-orchestration' - -export type StructuredAgentSessionHoldsDeps = { - /** Attaches a provider child, for a caller already inside `serialize`. */ - resume: ( - sessionId: string, - attachOptions?: StructuredAgentSessionAttachOptions - ) => Promise - serialize: (sessionId: string, task: () => Promise) => Promise - /** Whether evicting this session would actually free anything. */ - hasProviderChild: (sessionId: string) => boolean - hasOwedWork: (sessionId: string) => boolean - evict: (sessionId: string) => Promise - onError?: (input: { sessionId: string; error: unknown }) => void - graceMs?: number -} - -export type StructuredAgentSessionHoldOptions = { - /** False for a hold that only RETAINS — a subscription stream, which must not make a child - * exist just by reading history. */ - resume?: boolean -} - -export class StructuredAgentSessionHolds { - private readonly holders = new StructuredAgentSessionHolders() - private readonly clock: StructuredAgentSessionReleaseClock - private disposed = false - - constructor(private readonly deps: StructuredAgentSessionHoldsDeps) { - const clockDeps: StructuredAgentSessionReleaseClockDeps = { - hasOwedWork: deps.hasOwedWork, - isHeld: (sessionId) => this.holders.isHeld(sessionId), - evict: (sessionId) => this.deps.evict(sessionId), - ...(deps.onError ? { onError: deps.onError } : {}), - ...(deps.graceMs === undefined ? {} : { graceMs: deps.graceMs }) - } - this.clock = new StructuredAgentSessionReleaseClock(clockDeps) - } - - async hold( - sessionId: string, - holderId: string, - options: StructuredAgentSessionHoldOptions = {} - ): Promise { - const alreadyHeld = this.holders.has(sessionId, holderId) - this.holders.add(sessionId, holderId, options.resume !== false) - const incarnation = this.holders.incarnation(sessionId, holderId) - // Unconditional, not only on the first-holder edge: a second surface arriving during the grace - // window must cancel the pending release too. - this.clock.cancel(sessionId) - if (options.resume === false) { - return - } - let resumed: StructuredAgentSessionResumeOutcome - try { - resumed = await this.deps.serialize(sessionId, () => this.ensureProviderChild(sessionId)) - } catch (error) { - this.releaseFailedHold(sessionId, holderId, alreadyHeld, incarnation) - throw error - } - if (!resumed.ok) { - this.releaseFailedHold(sessionId, holderId, alreadyHeld, incarnation) - // The RPC surface raises a refusal as its code. - throw new Error(resumed.refusal.code) - } - } - - /** Only the holder this call added, at the incarnation it added: a same-ID hold that left and - * came back while this one waited owns the holder now, and its own attempt decides it. */ - private releaseFailedHold( - sessionId: string, - holderId: string, - alreadyHeld: boolean, - incarnation: symbol | undefined - ): void { - if (!alreadyHeld && incarnation !== undefined) { - this.release(sessionId, holderId, incarnation) - } - } - - /** - * Gives the session a provider child if it has none. - * - * For a caller already inside the session's serialize, which is what makes "if it has none" - * exact: a hold and a send that both find the owner gone run this in turn, and the second sees - * the first one's child. Each caller makes at most one attach, and a failed one leaves the - * next caller to make its own. With no surface holding the session afterwards, the child goes - * on the same clock a departed surface would start — including the surface that held it when - * provider-exit recovery began and left while the attach ran. - */ - async ensureProviderChild( - sessionId: string, - attachOptions?: StructuredAgentSessionAttachOptions - ): Promise { - if (this.deps.hasProviderChild(sessionId)) { - return { ok: true } - } - const resumed = await this.deps.resume(sessionId, attachOptions) - if (!resumed.ok) { - return resumed - } - if (!this.deps.hasProviderChild(sessionId)) { - return { - ok: false, - refusal: { - code: 'agent_session_ownership_unknown', - message: 'The session attached without a provider child to write to.' - } - } - } - // The last surface can disconnect before acquisition makes a child available to release. - if (!this.disposed && !this.holders.isHeld(sessionId)) { - this.clock.arm(sessionId) - } - return { ok: true } - } - - /** Activity — a journal write, or a start reaching the work it held; only a pending release - * notices, and it restarts its full window. */ - renew(sessionId: string): void { - this.clock.renew(sessionId) - } - - release(sessionId: string, holderId: string, expectedIncarnation?: symbol): void { - if (!this.holders.remove(sessionId, holderId, expectedIncarnation)) { - return - } - if (!this.disposed && this.deps.hasProviderChild(sessionId)) { - this.clock.arm(sessionId) - } - } - - /** Drops the holders of a session that is gone, whoever evicted it. */ - forget(sessionId: string): void { - this.clock.cancel(sessionId) - this.holders.forget(sessionId) - } - - isHeld(sessionId: string): boolean { - return this.holders.isHeld(sessionId) - } - - hasResumeCapableHolder(sessionId: string): boolean { - return this.holders.hasResumeCapableHolder(sessionId) - } - - isReleasePending(sessionId: string): boolean { - return this.clock.isArmed(sessionId) - } - - dispose(): void { - this.disposed = true - this.clock.dispose() - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts new file mode 100644 index 00000000000..ed2e48fd562 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -0,0 +1,156 @@ +// The host's conversations: how one becomes open, and the delivery loop that hands its accepted +// messages to a provider child. Bundled because they share one invariant — a conversation open +// with a message queued has a delivery loop — and the open is where a loop for leftovers wakes. + +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import type { AgentJournalResetReason } from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + openStructuredAgentSessionConversation, + resettleOpenStructuredAgentSessionConversation, + type OpenedStructuredAgentSessionConversation, + type StructuredAgentSessionConversationOpenOptions +} from './structured-agent-session-conversation-open' +import { StructuredAgentSessionDeliveryLoop } from './structured-agent-session-delivery-loop' +import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' +import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' +import type { + StructuredAgentSessionHostDeps, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' +import { recoverStructuredRewind } from './structured-rewind-recovery' + +export type StructuredAgentSessionConversationDelivery = { + loop: StructuredAgentSessionDeliveryLoop + /** For a caller inside the session's serialize. */ + open: ( + sessionId: string, + options?: StructuredAgentSessionConversationOpenOptions + ) => Promise + /** Every commit a conversation's journal makes: one may have ended the command that held its + * queue. Enqueued through the session's serialize, never read here, so a commit that lands while + * a step is deciding to stop wakes the loop after that step rather than being lost to it. */ + afterCommit: (sessionId: string, journal: AgentSessionJournal) => void + /** Stops the loop and the resettle on a proof of death; quit's first step. */ + dispose: () => void + /** Indexes a conversation some other open produced, as `open` would have. */ + adoptOpened: ( + sessionId: string, + opened: OpenedStructuredAgentSessionConversation + ) => Promise +} + +export function createStructuredAgentSessionConversationDelivery(input: { + deps: StructuredAgentSessionHostDeps + sessions: Map + serialize: (sessionId: string, task: () => Promise) => Promise + trackStart: (start: Promise) => Promise + /** Starts a child for `startedFor`, the queued message at the head, if the session has none. */ + ensureProviderChild: ( + sessionId: string, + startedFor: string + ) => Promise + reset: (sessionId: string, journal: AgentSessionJournal, reset: AgentJournalResetReason) => void + publishRestored: (sessionId: string) => void + flushStreamedEvents: (sessionId: string) => Promise +}): StructuredAgentSessionConversationDelivery { + const { deps, sessions } = input + const loop = new StructuredAgentSessionDeliveryLoop({ + sessions, + adapter: deps.adapter, + serialize: input.serialize, + trackStart: input.trackStart, + ensureProviderChild: input.ensureProviderChild, + conversationFence: (sessionId) => + structuredAgentSessionConversationFence(deps.store, sessionId), + failureTextContext: (sessionId) => + structuredAgentSessionFailureWordsContext( + deps.store.getRecord(sessionId), + sessions.get(sessionId)?.journal + ), + onError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }), + record: (sessionId) => deps.store.getRecord(sessionId), + flushStreamedEvents: input.flushStreamedEvents, + now: () => deps.now?.() ?? Date.now() + }) + const adoptOpened = async ( + sessionId: string, + opened: OpenedStructuredAgentSessionConversation + ): Promise => { + const { session, reset } = opened + sessions.set(sessionId, session) + if (reset) { + input.reset(sessionId, session.journal, reset) + } + input.publishRestored(sessionId) + await settleInterruptedCommands(deps, sessionId, session) + if (session.journal.submissions().some(isQueuedAgentJournalSubmission)) { + loop.wake(sessionId) + } + } + const wakesQueued = new Set() + const afterCommit = (sessionId: string, journal: AgentSessionJournal): void => { + if ( + wakesQueued.has(sessionId) || + structuredAgentSessionCommandRunning(journal) || + !journal.submissions().some(isQueuedAgentJournalSubmission) + ) { + return + } + wakesQueued.add(sessionId) + void input + .serialize(sessionId, async () => { + wakesQueued.delete(sessionId) + loop.wake(sessionId) + }) + .catch((error: unknown) => { + wakesQueued.delete(sessionId) + deps.onEventSinkError?.({ sessionId, error }) + }) + } + // A chat open before its owner's death was proven revises what its open settled. Queued, never + // awaited: the writer can hold this session's serialize (an attach recovering its lease). + const stopResettling = deps.store.onDeathEvidence((sessionId) => { + if (sessions.has(sessionId)) { + void input + .trackStart( + input.serialize(sessionId, () => + resettleOpenStructuredAgentSessionConversation(deps, sessionId, sessions.get(sessionId)) + ) + ) + .catch((error: unknown) => deps.onEventSinkError?.({ sessionId, error })) + } + }) + return { + loop, + afterCommit, + adoptOpened, + dispose: () => { + loop.dispose() + stopResettling() + }, + open: (sessionId, options) => + openStructuredAgentSessionConversation({ deps, sessions, adoptOpened }, sessionId, options) + } +} + +/** + * A rewind found prepared when the conversation opens was started under a child this process no + * longer has — the open runs only when none is indexed — so nothing will finish it, and left alone + * it refuses every send until a view attaches. Settled here instead of by a start inside + * acceptance. A Codex rewind only its provider can prove stays for the attach. + */ +async function settleInterruptedCommands( + deps: StructuredAgentSessionHostDeps, + sessionId: string, + session: StructuredAgentSessionHostSession +): Promise { + const fence = structuredAgentSessionConversationFence(deps.store, sessionId) + try { + await recoverStructuredRewind(deps.store, sessionId, session.journal, fence) + } catch (error) { + deps.onEventSinkError?.({ sessionId, error }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 0eed19031c7..c9ba8714b4e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -1,27 +1,34 @@ -// The host's half of a session's lifetime: what a close does, and what a hold is wired to. +// The host's half of a session's lifetime: stopping its agent, and closing its conversation. // -// Lifted out of the host for the same reason attaching was — the host is a coordinator, and the -// sequence that stops a provider child and hands its lease back reads better next to the holder -// bookkeeping that decides when to run it than buried among the twenty other things a session can -// do. +// Two operations, because they end two different things. Stopping the agent ends the provider +// child and hands the lease back; the conversation — its open journal, its status row and its +// readers — stays, and the next send starts a new child. Closing the conversation drops the open +// journal handle, a cache the next read or write reopens. +// +// Both are written for a caller already inside the session's serialize: the queue is not +// reentrant, so every public entry point takes it once and calls these. -import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work-liveness' -import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { evictStructuredAgentSession, STRUCTURED_AGENT_SESSION_EVICTION_STEPS, type StructuredAgentSessionEvictionContext } from './structured-agent-session-eviction' import { withStructuredAgentSessionEvictionDeadline } from './structured-agent-session-eviction-deadline' -import { StructuredAgentSessionHolds } from './structured-agent-session-holds' import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import type { + StructuredAgentSessionChildEndCause, StructuredAgentSessionHostDeps, - StructuredAgentSessionHostSession + StructuredAgentSessionHostSession, + StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' +import { + endProviderChild, + structuredAgentSessionConversationFence +} from './structured-agent-session-provider-child' import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release' -import { resumeHeldStructuredAgentSession } from './structured-agent-session-hold-resume' -import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' export type StructuredAgentSessionLifetimeContext = { @@ -29,8 +36,8 @@ export type StructuredAgentSessionLifetimeContext = { runtimeState: StructuredAgentSessionHostRuntimeState sessions: Map now: () => number - /** Drops the session's row from the agent-status store; see `forgetStructuredAgentSession`. */ - forgetStatus: (sessionId: string) => void + /** Re-projects the session's status after its agent stopped and the chat stays. */ + publishStatus?: (sessionId: string) => void /** Quit-only snapshot taken immediately before the provider child is stopped. */ restartWitness?: { beforeStop: (sessionId: string) => void @@ -38,73 +45,96 @@ export type StructuredAgentSessionLifetimeContext = { } } -/** Dropping a session and dropping its status row are ONE operation: the store keeps the row until - * told, so a caller that only deletes strands a live-looking row no reader can ever decay. */ -export async function forgetStructuredAgentSession( - context: StructuredAgentSessionLifetimeContext, - sessionId: string -): Promise { - await context.sessions.get(sessionId)?.journal.close() - context.sessions.delete(sessionId) - context.forgetStatus(sessionId) +type ConversationCloseDeps = Pick & { + store: Pick } -function hasProviderChild( - context: StructuredAgentSessionLifetimeContext, - sessionId: string -): boolean { - return context.sessions.get(sessionId)?.hasProviderChild === true +/** A conversation's handle closes with nothing queued: what is still queued when the chat closes, + * or the app quits, will not be handed over. Best effort: the next open's delivery loop rejects a + * leftover itself. */ +export async function abandonQueuedStructuredAgentSessionMessages( + deps: ConversationCloseDeps, + sessionId: string, + journal: StructuredAgentSessionHostSession['journal'] +): Promise { + await journal + .rejectQueuedSubmissions( + structuredAgentSessionConversationFence(deps.store, sessionId), + agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) + ) + .catch((error: unknown) => deps.onEventSinkError?.({ sessionId, error })) } /** The wind-down this host owes for the session's child. A live child always owes one, whatever a - * previous childless eviction recorded: a remembered `false` must never outrank the child in front - * of it. */ -function owesProviderChildWindDown(session: StructuredAgentSessionHostSession): boolean { - return session.hasProviderChild || session.owesProviderChildWindDown === true + * previous childless eviction recorded: a remembered tombstone must never outrank the child in + * front of it. */ +function owedProviderChildWindDown( + session: StructuredAgentSessionHostSession +): StructuredAgentSessionProviderChildIdentity | undefined { + return session.child + ? { generation: session.child.generation, fence: session.child.fence } + : session.owesProviderChildWindDown } -/** Runs the eviction steps under a deadline. A step that fails — or runs out of time — aborts the - * rest, which leaves the session indexed and the child loaded so the next close is a real retry. */ -export async function evictHeldStructuredAgentSession( +/** + * The agent goes to rest; the conversation stays. Runs the eviction steps under a deadline. A step + * that fails — or runs out of time — aborts the rest and leaves the wind-down owed, so the next + * stop is a real retry. `ending` is how the child's end is told: a user's Stop, the host stopping it + * for a cause (with its text), or an eviction the conversation's close follows. + */ +export async function stopStructuredAgentSessionAgentUnderSerialize( context: StructuredAgentSessionLifetimeContext, - sessionId: string + sessionId: string, + ending: { + cause: Extract + reason?: string + } = { cause: 'user-stop' } ): Promise { const session = context.sessions.get(sessionId) if (!session) { return } - // The obligation OUTLIVES the child. `hasProviderChild` is retired the instant the adapter - // proves the exit, so a step that aborts after that point would otherwise leave the retry - // reading "no child here" and skipping the settlement and the lease release it still owes. - const owesWindDown = owesProviderChildWindDown(session) - session.owesProviderChildWindDown = owesWindDown + // The obligation OUTLIVES the child. `child` is ended the instant the adapter proves the exit, + // so a step that aborts after that point would otherwise leave the retry reading "no child + // here" and skipping the settlement and the lease release it still owes. + const owed = owedProviderChildWindDown(session) + session.owesProviderChildWindDown = owed + const stopping = session.child let settlementError: unknown const eviction: StructuredAgentSessionEvictionContext = { sessionId, // The retry must not re-stop a child the adapter already proved gone, so this stays honest. - hasProviderChild: session.hasProviderChild, - owesProviderChildWindDown: owesWindDown, + hasProviderChild: stopping !== null, + owesProviderChildWindDown: owed !== undefined, eventSink: context.runtimeState.eventSinkFor(sessionId), adapter: context.deps.adapter, ...(context.restartWitness ? { beforeProviderChildStop: () => context.restartWitness?.beforeStop(sessionId) } : {}), - // Host state must not disagree with the adapter for the seven steps in between. - onProviderChildStopped: () => { - session.hasProviderChild = false + // Host state must not disagree with the adapter for the steps in between. + onProviderChildStopped: (verdict) => { + if (stopping) { + endProviderChild(session, { + generation: stopping.generation, + fence: stopping.fence, + cause: ending.cause, + reason: ending.reason ?? null, + duringStartup: stopping.phase === 'starting', + ...verdict + }) + } context.restartWitness?.stopped(sessionId) }, - forget: async () => { - await forgetStructuredAgentSession(context, sessionId) - context.deps.adapter.acknowledgeSessionRelease?.(sessionId) - }, + acknowledgeRelease: () => context.deps.adapter.acknowledgeSessionRelease?.(sessionId), discardSink: () => context.runtimeState.discardEventSink(sessionId), settleWork: async () => { + const fence = + owed?.fence ?? structuredAgentSessionConversationFence(context.deps.store, sessionId) const settled = await settleStructuredAgentSessionDeadGeneration({ journal: session.journal, sessionId, - fence: session.fence, - settlementId: `expected-close:${sessionId}:${session.fence}:${session.acquisitionGeneration ?? 'unknown'}`, + fence, + settlementId: `expected-close:${sessionId}:${fence}:${owed?.generation ?? 'unknown'}`, pendingSubmissionReason: 'provider_closed_before_acknowledgement', verdict: { state: 'interrupted', completedAt: context.now() }, showUnexpectedExitOutcome: false, @@ -114,20 +144,24 @@ export async function evictHeldStructuredAgentSession( } }) if (!settled) { - // Without the cause the quit log names the step and nothing else. + // Without the cause the log names the step and nothing else. throw new Error('dead generation work settlement failed', { cause: settlementError }) } }, releaseLease: async () => { - await releaseStoredStructuredAgentSessionOwner({ - store: context.deps.store, - sessionId, - hasProviderChild: owesWindDown, - expectedFence: session.fence, - now: context.now() - }) - session.owesProviderChildWindDown = false - context.forgetStatus(sessionId) + if (owed) { + await releaseStoredStructuredAgentSessionOwner({ + store: context.deps.store, + sessionId, + hasProviderChild: true, + expectedFence: owed.fence, + now: context.now() + }) + } + session.owesProviderChildWindDown = undefined + // Whatever ended the child, the row belongs to the conversation: it shows not-running, and + // only the conversation's close forgets it. + context.publishStatus?.(sessionId) } } await evictStructuredAgentSession( @@ -136,6 +170,41 @@ export async function evictHeldStructuredAgentSession( ) } +/** Whether the conversation's handle is only a cache now: no child, no wind-down owed, and nothing + * queued or waiting on the provider. */ +export function structuredAgentSessionConversationClosable( + session: StructuredAgentSessionHostSession +): boolean { + return ( + owedProviderChildWindDown(session) === undefined && + !session.journal.submissions().some(isQueuedAgentJournalSubmission) && + session.journal.pendingSubmissions().length === 0 + ) +} + +/** + * Drops the conversation's open handle. The entry leaves the map BEFORE the handle closes, so a + * lock-free reader sees an open handle or none — never one that is closing — and one arriving + * after the delete waits behind this step and reopens. Answers false, closing nothing, when the + * handle is still more than a cache. + */ +export async function closeStructuredAgentSessionConversationUnderSerialize( + context: Pick & { + /** The status row outlives the handle; see `StructuredAgentSessionClientDelivery`. */ + closeStatus: (sessionId: string) => void + }, + sessionId: string +): Promise { + const session = context.sessions.get(sessionId) + if (!session || !structuredAgentSessionConversationClosable(session)) { + return false + } + context.sessions.delete(sessionId) + context.closeStatus(sessionId) + await session.journal.close() + return true +} + /** Stops every provider child owned by this host while keeping failed evictions reachable. A * session whose child is already stopped but whose wind-down aborted is still in scope — that is * the retry. */ @@ -146,9 +215,9 @@ export async function evictOwnedStructuredAgentSessions( retainOnFailure: Set ): Promise { const ownedSessionIds = [...context.sessions] - .filter(([, session]) => owesProviderChildWindDown(session)) + .filter(([, session]) => owedProviderChildWindDown(session) !== undefined) .map(([sessionId]) => sessionId) - // Retained up front and cleared only once an eviction settles: the quit phase is bounded, and a + // Retained up front and cleared only once a stop settles: the quit phase is bounded, and a // timeout leaves these still running. Closing their journals underneath them is the one outcome // the retain set exists to prevent. for (const sessionId of ownedSessionIds) { @@ -159,7 +228,7 @@ export async function evictOwnedStructuredAgentSessions( ownedSessionIds.map(async (sessionId) => { try { await context.serialize(sessionId, () => - evictHeldStructuredAgentSession(context, sessionId) + stopStructuredAgentSessionAgentUnderSerialize(context, sessionId) ) retainOnFailure.delete(sessionId) } catch (error) { @@ -171,47 +240,3 @@ export async function evictOwnedStructuredAgentSessions( throw new AggregateError(failures, 'structured agent-session child eviction failed') } } - -/** The holds resume through the host's own attach, inside the session's serialize: a hold's - * resume and a send's ensure-owner step are the same serialized attach with a different asker. */ -export function createStructuredAgentSessionHolds( - attachContext: () => StructuredAgentSessionAttachContext, - close: (sessionId: string) => Promise -): StructuredAgentSessionHolds { - const context = attachContext() - return new StructuredAgentSessionHolds({ - resume: (sessionId, attachOptions) => - resumeHeldStructuredAgentSession({ - sessionId, - context: attachContext(), - callerKey: attachOptions?.admitRecoveryTicket - ? 'trusted-local:provider-exit-recovery' - : 'trusted-local:surface-hold', - ...(attachOptions ? { attachOptions } : {}) - }), - // Tracked from enqueue: a quit drains a queued resume before it evicts, so no child is - // spawned behind the eviction and orphaned. - serialize: (sessionId, task) => { - const current = attachContext() - return current.tasks.trackAttach(current.serialize(sessionId, task)) - }, - evict: close, - hasProviderChild: (sessionId) => hasProviderChild(context, sessionId), - // A send pending while the child is still starting is held for that start; evicting would - // refuse it. Any other pending send may wait on an echo that never comes, so eviction retires it. - // Subagents, commands and monitors outlive the lead's turn inside the child, so the live roster - // the sidebar shows as working is owed too; stopping the child would end them silently. - hasOwedWork: (sessionId) => { - const session = context.sessions.get(sessionId) - return session - ? activeStructuredAgentSessionTurnId(session.journal.snapshot().items) !== null || - (session.providerChildPhase === 'starting' && - session.journal.pendingSubmissions().length > 0) || - agentChildWorkLiveness(context.deps.adapter.backgroundTaskState?.(sessionId)?.tasks) !== - null - : false - }, - onError: (error) => context.deps.onEventSinkError?.(error), - ...(context.deps.releaseGraceMs === undefined ? {} : { graceMs: context.deps.releaseGraceMs }) - }) -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 364f1e7c0dd..2a4e282ec23 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -1,10 +1,11 @@ -// Everything a client can ask an ATTACHED session to do: send a turn, cancel one, answer a prompt, -// change an option, read the options back. +// Everything a client can ask a session to do: send a turn, cancel one, answer a prompt, change an +// option, read the options back. // // They share one shape — admit the envelope against the lease, run a plan, publish the journal — so -// they share one path here rather than five copies in the host. The host keeps attach, holds and -// teardown. A send is the one mutation that may need those first: it makes sure the session has -// an owner as a step of its own serialized admission, see `structured-agent-session-send-preparation`. +// they share one path here rather than five copies in the host. The host keeps attach and teardown. +// Each opens the conversation first. A send, a Stop and an option pick are conversation writes, +// admitted without the writer lease; the delivery loop starts the provider child a send needs, and +// an operation only the provider can perform starts it before admission. import type { AgentJournalItemIdentity, @@ -15,20 +16,30 @@ import type { AgentSessionMutationEnvelope, AgentSessionMutationResult, AgentSessionOptionResult, - AgentSessionOptionsResult, AgentSessionPromptResult, AgentSessionSendResult, AgentSessionThreadGoalChange, AgentSessionThreadGoalResult } from '../../../shared/agent-session-wire' -import type { StructuredAgentSessionHolds } from './structured-agent-session-holds' +import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import { + agentSessionFailureWords, + type AgentJournalDispatchRejection +} from '../../../shared/agent-session-failure-words' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import type { AgentSessionPromptRequest } from './structured-agent-session-turns-prompt' import { threadGoalPlan } from './structured-agent-session-thread-goal' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { admitAndRunAgentSessionMutation, - type AgentSessionMutationRequest + type AgentSessionMutationRequest, + type AgentSessionMutationSessionPreparation } from './structured-agent-session-mutation-admission' import { - prepareStructuredAgentSessionSend, + openForWrite, + openWithAgent, + sendPreparation, + structuredAgentSessionFailureWordsContext, structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' import { @@ -38,28 +49,43 @@ import { setOptionPlan, type MutationPlan } from './structured-agent-session-mutation-plans' +import { runQueueableStructuredAgentSessionSend } from './structured-agent-session-queued-send' +import { runStopWithQueuePause } from './structured-agent-session-queued-stop' import type { StructuredAgentSessionCaller, StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { + readStructuredAgentSessionOptions, + recordStructuredAgentSessionOptionIntent +} from './structured-agent-session-options-read' export type StructuredAgentSessionMutationContext = { deps: StructuredAgentSessionHostDeps sessions: Map publish: (sessionId: string, journal: StructuredAgentSessionHostSession['journal']) => void flushStreamedEvents: (sessionId: string) => Promise - requireSession: (sessionId: string) => StructuredAgentSessionHostSession + /** The host's accessor, for a caller outside the session's serialize. */ + conversation: (sessionId: string) => Promise serialize: (sessionId: string, task: () => Promise) => Promise - /** A send that finds the owner gone brings it back through here, inside its own serialize. */ - holds: Pick - /** Makes a closed session's journal readable again, inside the caller's serialize, for a send - * the ledger answers without an owner. */ - restoreReadable: (sessionId: string) => Promise + /** The session's conversation, opened when closed; inside the caller's serialize. */ + openConversation: (sessionId: string) => Promise + /** Gives the session a provider child; inside the caller's serialize. */ + ensureAgent: (sessionId: string) => Promise + /** A message was accepted: the session's delivery loop hands it over. */ + wakeDelivery: (sessionId: string) => void + /** Stops the session's provider child, keeping its conversation; inside the caller's serialize. */ + stopAgent: (sessionId: string) => Promise + /** Only for gate inputs living in the RECORD store, which can settle with no + * journal commit (a conversation command). Draft-table changes need no call: + * the draft store notifies through the journal's own commit listener. */ + wakeQueuedDrain?: (sessionId: string) => void now: () => number } -function mutate( +/** Admits the envelope and runs the plan inside the session's serialize. */ +export function mutateStructuredAgentSession( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, envelope: AgentSessionMutationEnvelope, @@ -77,7 +103,7 @@ function mutate( prepareSession, publish: (journal) => context.publish(envelope.sessionId, journal), flushStreamedEvents: context.flushStreamedEvents, - providerChildPhase: () => context.sessions.get(envelope.sessionId)?.providerChildPhase, + providerChildPhase: () => context.sessions.get(envelope.sessionId)?.child?.phase, now: () => context.now() }) ) @@ -90,22 +116,34 @@ export function sendStructuredAgentSessionTurn( envelope: AgentSessionMutationEnvelope body: AgentJournalMessageItem retryUnknown?: true + delivery?: 'queue-if-active' + /** Host-local, set only by the client-facing `agentSession.send` RPC (the + * renderer's launch prompt included): recorded as the submission's `client` + * origin, whose started turn ends a Stop's or a restart's queue pause. + * Orchestration mail, a restart continuation and `agent.launch`'s host-sent + * prompt never set it. */ + userSend?: true beforeRun?: () => void } ): Promise> { const plan = sendPlan(params) - return mutate( + return mutateStructuredAgentSession( context, caller, params.envelope, { ...plan, - run: (ctx) => { - const blocked = structuredAgentSessionSendBlock(context.deps.store.getRecord(ctx.sessionId)) - return blocked ? Promise.resolve(blocked) : plan.run(ctx) - } + run: (ctx) => + runQueueableStructuredAgentSessionSend( + context, + ctx, + params, + async () => + structuredAgentSessionSendBlock(context.deps.store.getRecord(ctx.sessionId)) ?? + (await plan.run(ctx)) + ) }, - (ledger, record) => prepareStructuredAgentSessionSend(context, params.envelope, ledger, record) + sendPreparation(context, params.envelope) ) } @@ -114,37 +152,88 @@ export function cancelStructuredAgentSessionTurn( caller: StructuredAgentSessionCaller, params: { envelope: AgentSessionMutationEnvelope - turnId: string + turnId?: string scope?: 'background-tasks' taskId?: string prompt?: { itemId: string; expectedRevision: number } } ): Promise> { - const command = context.deps.store.getRecord(params.envelope.sessionId)?.conversationCommand - // Interrupts must reach a provider while the command awaits its terminal frame. - const cancellationContext = - command?.command === 'compact' && command.phase === 'prepared' - ? { - ...context, - serialize: (sessionId: string, task: () => Promise) => - context.serialize(`compact-cancel:${sessionId}`, task) - } - : context - return mutate(cancellationContext, caller, params.envelope, cancelPlan(params)) + if (params.scope || params.prompt) { + return mutateStructuredAgentSession( + context, + caller, + params.envelope, + cancelPlan(params), + openForWrite(context, params.envelope) + ) + } + const plan = cancelPlan({ + ...params, + stopChild: () => context.stopAgent(params.envelope.sessionId) + }) + return mutateStructuredAgentSession( + context, + caller, + params.envelope, + { + ...plan, + // Stop's queue step, the same for every client: once the Stop takes effect + // the queue is paused. The cards stay published; nothing is withdrawn and no + // text ever rides the answer. + run: (ctx) => + runStopWithQueuePause(ctx, async (tookEffect) => { + // Stop withdraws every queued SUBMISSION first, whatever the start or the child is doing. + const withdrawn = await ctx.journal.rejectQueuedSubmissions( + ctx.fence, + agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + ) + const named = params.turnId !== undefined ? { turnId: params.turnId } : {} + const child = context.sessions.get(ctx.sessionId)?.child + if (child?.phase === 'starting') { + // A start that may never land is the one thing here Stop has to end; the chat stays. + await tookEffect() + await context.stopAgent(ctx.sessionId) + return { ok: true, value: { ...named, cancelled: true } } + } + // A Stop naming no turn ends nothing more unless the session reads working, by the rule + // every session list and the chat's own Stop read it. + const inFlight = + params.turnId !== undefined || + isStructuredAgentSessionMainAgentWorking( + ctx.journal.activeTurnId(), + ctx.journal.submissions(), + ctx.fence + ) + const record = context.deps.store.getRecord(ctx.sessionId) + if (!child || !inFlight) { + if (withdrawn.length > 0) { + await tookEffect() + } + return { ok: true, value: { ...named, cancelled: withdrawn.length > 0 } } + } + await tookEffect() + return plan.run({ + ...ctx, + failureTextContext: structuredAgentSessionFailureWordsContext(record) + }) + }) + }, + openForWrite(context, params.envelope) + ) } export function respondToStructuredAgentSessionPrompt( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, - params: { - envelope: AgentSessionMutationEnvelope - kind: 'approval' | 'question' - itemId: string - expectedRevision: number - optionId: string - } + params: AgentSessionPromptRequest & { envelope: AgentSessionMutationEnvelope } ): Promise> { - return mutate(context, caller, params.envelope, promptPlan(params)) + return mutateStructuredAgentSession( + context, + caller, + params.envelope, + promptPlan(params), + openForWrite(context, params.envelope) + ) } export async function setStructuredAgentSessionOption( @@ -154,7 +243,26 @@ export async function setStructuredAgentSessionOption( ): Promise> { // Outside the queue: a pick made while the provider starts then queues behind what its start persists. await context.deps.adapter.awaitOptionWritable?.(params.envelope.sessionId) - return mutate(context, caller, params.envelope, setOptionPlan(params)) + const plan = setOptionPlan(params) + const atRest = () => !context.sessions.get(params.envelope.sessionId)?.child + return mutateStructuredAgentSession( + context, + caller, + params.envelope, + { + ...plan, + // Read as the call is admitted: with no child running, the pick is a conversation write — + // intent the next start replays. A running child's pick is still its owner's to make. + get conversationWrite() { + return atRest() ? (true as const) : undefined + }, + run: (ctx) => + atRest() + ? recordStructuredAgentSessionOptionIntent(context.deps.store, ctx, params) + : plan.run(ctx) + }, + openForWrite(context, params.envelope) + ) } export function changeStructuredAgentSessionThreadGoal( @@ -162,38 +270,13 @@ export function changeStructuredAgentSessionThreadGoal( caller: StructuredAgentSessionCaller, params: { envelope: AgentSessionMutationEnvelope; change: AgentSessionThreadGoalChange } ): Promise> { - return mutate(context, caller, params.envelope, threadGoalPlan(params)) -} - -export function readStructuredAgentSessionOptions( - context: StructuredAgentSessionMutationContext, - sessionId: string -): Promise { - return context.serialize(sessionId, async () => { - const session = context.requireSession(sessionId) - if (!context.deps.adapter.readOptions) { - throw new Error('structured_agent_session_options_unsupported') - } - const options = await context.deps.adapter.readOptions({ sessionId, fence: session.fence }) - return { - ...options, - rewind: - context.deps.store.getRecord(sessionId)?.rewind?.phase === 'prepared' || - context.deps.store.getRecord(sessionId)?.rewind?.phase === 'provider-succeeded' - ? { supported: false, reason: 'outcome-unknown' } - : (context.deps.adapter.rewindSupport?.(sessionId) ?? { - supported: false, - reason: 'unsupported' - }), - conversationCommands: context.deps.adapter.compact ? ['clear', 'compact'] : ['clear'], - ...(context.deps.adapter.supportsThreadGoal?.(sessionId) - ? { threadGoal: { current: session.journal.threadGoal() } } - : {}), - ...(context.deps.adapter.recordsContextUsage?.(sessionId) - ? { contextUsage: { current: session.journal.contextUsage() } } - : {}) - } - }) + return mutateStructuredAgentSession( + context, + caller, + params.envelope, + threadGoalPlan(params), + openWithAgent(context, params.envelope) + ) } /** Settle provider-proven delivery independently of an in-flight client mutation. */ @@ -202,12 +285,16 @@ export async function settleStructuredAgentSessionLateDispatch( input: { sessionId: string clientMessageId: string - } & ({ providerIdentity: AgentJournalItemIdentity } | { state: 'rejected'; reason: string }) + } & ( + | { providerIdentity: AgentJournalItemIdentity } + | ({ state: 'rejected' } & AgentJournalDispatchRejection) + ) ): Promise { const session = context.sessions.get(input.sessionId) if (!session) { return } + const fence = structuredAgentSessionConversationFence(context.deps.store, input.sessionId) // The journal queue drains before close; the host queue would defer this past teardown. await session.journal.resolveDispatch( 'providerIdentity' in input @@ -215,55 +302,16 @@ export async function settleStructuredAgentSessionLateDispatch( clientMessageId: input.clientMessageId, state: 'accepted', providerIdentity: input.providerIdentity, - fence: session.fence + fence } : { clientMessageId: input.clientMessageId, state: 'rejected', reason: input.reason, - fence: session.fence + rejection: input.rejection, + fence } ) - context.publish(input.sessionId, session.journal) -} - -/** - * Releases sends the provider can no longer be holding. - * - * A dispatch whose RPC timed out is recorded `unknown` — doubt, never proof of - * non-delivery — and a live `unknown` reads as work still owed, so the session - * shows working until something re-derives it. The provider reporting its thread - * not running, with no turn open, IS that re-derivation. - * - * `pending` is deliberately untouched: that send's dispatch has not returned yet - * and may be in flight right now. And `recovered` only retires the obligation — - * it never makes a send re-deliverable, because the provider may well have run it. - */ -export async function releaseStructuredAgentSessionUnansweredDispatches( - context: Pick, - input: { sessionId: string; reason: string } -): Promise { - const session = context.sessions.get(input.sessionId) - if (!session) { - return - } - const stranded = session.journal - .submissions() - .filter((entry) => entry.dispatchState === 'unknown' && entry.recovered !== true) - if (stranded.length === 0) { - return - } - for (const entry of stranded) { - await session.journal.resolveDispatch({ - clientMessageId: entry.clientMessageId, - state: 'unknown', - // The earlier reason names a sharper fact than this one does. - reason: entry.reason ?? input.reason, - fence: session.fence, - recovered: true - }) - } - context.publish(input.sessionId, session.journal) } /** The host's thin mutation surface. Each call re-reads the context, so a session diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts index 39fe7c3fc22..63f37a37590 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts @@ -34,8 +34,9 @@ export class StructuredAgentSessionHostRuntimeState { this.leaseRenewer.start() } - stopLeaseRenewal(): void { - this.leaseRenewer.stop() + /** Resolves once a renewal tick already in flight has finished writing. */ + stopLeaseRenewal(): Promise { + return this.leaseRenewer.stop() } /** The sink the session's current child writes through, created on first use. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts index 3bd6e46b98e..8a18040daf3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts @@ -9,19 +9,26 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' export function setStructuredAgentSessionTabVisibility( host: { deps: { - store: { setSessionTabVisibility: (sessionId: string, visible: boolean) => Promise } + store: { + setSessionTabVisibility: ( + sessionId: string, + visible: boolean, + tabId?: string + ) => Promise + } } restartResume: { dismiss: (sessionIds: readonly string[]) => Promise } }, sessionId: string, - visible: boolean + visible: boolean, + tabId?: string ): Promise { if (!visible) { void host.restartResume.dismiss([sessionId]).catch(() => { console.warn('[structured-agent-session] forgetting recovery records on chat close failed') }) } - return host.deps.store.setSessionTabVisibility(sessionId, visible) + return host.deps.store.setSessionTabVisibility(sessionId, visible, tabId) } export type StructuredAgentSessionTab = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts index d04e17d2d26..aa8692a0c73 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts @@ -1,11 +1,25 @@ import { describe, expect, it, vi } from 'vitest' -import { structuredAgentSessionHostTeardownPhases } from './structured-agent-session-host-teardown' +import { WILL_QUIT_TEARDOWN_DEADLINE_MS } from '../../../shared/quit-teardown-deadline' +import { + GRACEFUL_EXIT_MS as CLAUDE_WINDOWS_GRACEFUL_EXIT_MS, + SUPERVISED_GRACEFUL_EXIT_MS +} from '../../claude/claude-child-exit-proof-ladder' +import { GRACEFUL_EXIT_MS as CODEX_WINDOWS_GRACEFUL_EXIT_MS } from '../../codex/codex-app-server-connection' +import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../../codex/codex-app-server-posix-supervisor' +import { SNAPSHOT_DRAIN_TIMEOUT_MS } from './structured-agent-session-eviction' +import { + CHILD_EVICTION_TIMEOUT_MS, + EVICTION_MARGIN_MS, + RESUME_MARKER_RECORD_TIMEOUT_MS, + structuredAgentSessionHostTeardownPhases +} from './structured-agent-session-host-teardown' + +const noop = async (): Promise => undefined describe('structured agent-session host teardown', () => { it('names every phase, so the quit-path order is pinned rather than incidental', () => { - const noop = async (): Promise => undefined const phases = structuredAgentSessionHostTeardownPhases({ - holds: { dispose: noop }, + idleSweep: { dispose: noop }, runtimeState: { stopLeaseRenewal: () => undefined, flushAllEventSinks: noop }, tasks: { drainAttaches: noop }, evictOwnedSessions: noop, @@ -14,7 +28,7 @@ describe('structured agent-session host teardown', () => { }) expect(phases.map((phase) => phase.name)).toEqual([ 'begin-resume-markers', - 'dispose-holds', + 'dispose-idle-sweep', 'stop-lease-renewal', 'drain-attaches', 'evict-owned-sessions', @@ -23,6 +37,53 @@ describe('structured agent-session host teardown', () => { ]) }) + it("derives quit's child-eviction bound from every provider's supervised close", () => { + // Eviction drains the sink for the resume offer before it stops the child, inside one bound. + // Each close's tree-kill fallback is deliberately outside it: once main exits the supervisor + // stops its group itself, and next launch's recovery settles the lease. + const closes = { + claude: SUPERVISED_GRACEFUL_EXIT_MS, + codex: PROVIDER_SUPERVISOR_MAX_STOP_MS, + // No supervisor on Windows, so its closes wait less than any supervised one. + claudeWindows: CLAUDE_WINDOWS_GRACEFUL_EXIT_MS, + codexWindows: CODEX_WINDOWS_GRACEFUL_EXIT_MS + } + for (const closeMs of Object.values(closes)) { + expect(SNAPSHOT_DRAIN_TIMEOUT_MS + closeMs + EVICTION_MARGIN_MS).toBeLessThanOrEqual( + CHILD_EVICTION_TIMEOUT_MS + ) + } + // The resume markers recorded after eviction still fit under quit's global deadline. + expect(CHILD_EVICTION_TIMEOUT_MS + RESUME_MARKER_RECORD_TIMEOUT_MS).toBeLessThan( + WILL_QUIT_TEARDOWN_DEADLINE_MS + ) + }) + + it('ends child eviction as soon as every chat has closed, not at its bound', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const evict = structuredAgentSessionHostTeardownPhases({ + idleSweep: { dispose: noop }, + runtimeState: { stopLeaseRenewal: () => undefined, flushAllEventSinks: noop }, + tasks: { drainAttaches: noop }, + evictOwnedSessions: noop, + beginResumeMarkers: () => {}, + recordResumeMarkers: noop + }).find((phase) => phase.name === 'evict-owned-sessions') + try { + let finished = false + const run = Promise.resolve(evict?.run()).then(() => { + finished = true + }) + // No timer advances: the phase settles with the eviction, not at CHILD_EVICTION_TIMEOUT_MS. + await vi.advanceTimersByTimeAsync(0) + expect(finished).toBe(true) + await run + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + it('bounds stalled recovery publication without preventing later cleanup', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -30,7 +91,7 @@ describe('structured agent-session host teardown', () => { const cleaned = vi.fn(async () => {}) const flush = vi.fn(async () => cleaned()) const phases = structuredAgentSessionHostTeardownPhases({ - holds: { dispose: cleaned }, + idleSweep: { dispose: cleaned }, runtimeState: { stopLeaseRenewal: () => {}, flushAllEventSinks: flush }, tasks: { drainAttaches: cleaned }, evictOwnedSessions: cleaned, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts index 6b1693c3dca..bc9a48c5dac 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts @@ -7,8 +7,12 @@ // nothing can ever close them. import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' +import { SUPERVISED_GRACEFUL_EXIT_MS } from '../../claude/claude-child-exit-proof-ladder' +import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../../codex/codex-app-server-posix-supervisor' +import { SNAPSHOT_DRAIN_TIMEOUT_MS } from './structured-agent-session-eviction' import type { StructuredAgentSessionRestartResume } from './structured-agent-session-restart-resume-host' import { + abandonQueuedStructuredAgentSessionMessages, evictOwnedStructuredAgentSessions, type StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' @@ -22,12 +26,20 @@ export type StructuredAgentSessionTeardownPhase = { } /** Advisory persistence must not hold shutdown open. */ -const RESUME_MARKER_RECORD_TIMEOUT_MS = 2_000 +export const RESUME_MARKER_RECORD_TIMEOUT_MS = 2_000 -/** Eight steps at ten seconds each would outlast the global quit deadline, and a quit that dies - * mid-eviction leaves the lease unreleased — the exact state restart has to clean up. Bounded - * well below that deadline so the phases after this one still get to run. */ -const CHILD_EVICTION_TIMEOUT_MS = 8_000 +/** Covers a provider's stop observed late on a loaded host. */ +export const EVICTION_MARGIN_MS = 1_000 + +/** A quit that dies mid-eviction leaves the lease unreleased — the exact state restart has to + * clean up — so this covers the sink drain plus the longest supervised provider close, well below + * the global quit deadline so later phases still run. A close's tree-kill fallback is outside it: + * once main exits the supervisor stops its group itself, and next launch's recovery settles the + * lease. Windows closes have no supervisor and wait less. */ +export const CHILD_EVICTION_TIMEOUT_MS = + SNAPSHOT_DRAIN_TIMEOUT_MS + + Math.max(SUPERVISED_GRACEFUL_EXIT_MS, PROVIDER_SUPERVISOR_MAX_STOP_MS) + + EVICTION_MARGIN_MS /** Bounds a phase without swallowing its failure, which `withTimeout` alone would. */ async function withPhaseTimeout(run: () => Promise, timeoutMs: number): Promise { @@ -46,9 +58,9 @@ async function withPhaseTimeout(run: () => Promise, timeoutMs: number): Pr /** The quit-path phase order, which is load-bearing rather than incidental. */ export function structuredAgentSessionHostTeardownPhases(collaborators: { - holds: { dispose: () => Promise | void } + idleSweep: { dispose: () => Promise | void } runtimeState: { - stopLeaseRenewal: () => void + stopLeaseRenewal: () => Promise | void flushAllEventSinks: () => Promise } tasks: { drainAttaches: () => Promise } @@ -68,7 +80,7 @@ export function structuredAgentSessionHostTeardownPhases(collaborators: { } } }, - { name: 'dispose-holds', run: () => collaborators.holds.dispose() }, + { name: 'dispose-idle-sweep', run: () => collaborators.idleSweep.dispose() }, { name: 'stop-lease-renewal', run: () => collaborators.runtimeState.stopLeaseRenewal() }, { name: 'drain-attaches', run: () => collaborators.tasks.drainAttaches() }, { @@ -93,6 +105,8 @@ export async function tearDownStructuredAgentSessionHost(input: { sessions: Map retainSessionIds?: ReadonlySet acknowledgeSessionRelease?: (sessionId: string) => void + /** Quit closes every conversation, so it settles what they still queue as a close does. */ + abandonQueued?: (sessionId: string, session: StructuredAgentSessionHostSession) => Promise }): Promise { const failures: unknown[] = [] for (const phase of input.phases) { @@ -107,7 +121,12 @@ export async function tearDownStructuredAgentSessionHost(input: { ([sessionId]) => !input.retainSessionIds?.has(sessionId) ) // `allSettled`, so one rejected close cannot skip the others. - const closed = await Promise.allSettled(entries.map(([, session]) => session.journal.close())) + const closed = await Promise.allSettled( + entries.map(async ([sessionId, session]) => { + await input.abandonQueued?.(sessionId, session) + await session.journal.close() + }) + ) closed.forEach((result, index) => { const sessionId = entries[index]?.[0] if (result.status === 'fulfilled') { @@ -133,7 +152,7 @@ export async function tearDownStructuredAgentSessionHost(input: { export async function flushStructuredAgentSessionHost( context: StructuredAgentSessionLifetimeContext & - Pick[0], 'holds' | 'tasks'> & { + Pick[0], 'idleSweep' | 'tasks'> & { restartResume: StructuredAgentSessionRestartResume serialize: (sessionId: string, task: () => Promise) => Promise trigger: AgentSessionResumeTrigger @@ -160,6 +179,8 @@ export async function flushStructuredAgentSessionHost( sessions: context.sessions, retainSessionIds, acknowledgeSessionRelease: (sessionId) => - context.deps.adapter.acknowledgeSessionRelease?.(sessionId) + context.deps.adapter.acknowledgeSessionRelease?.(sessionId), + abandonQueued: (sessionId, session) => + abandonQueuedStructuredAgentSessionMessages(context.deps, sessionId, session.journal) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts new file mode 100644 index 00000000000..b069fecd1bb --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts @@ -0,0 +1,123 @@ +// The helper is what the restart specs remove their temp directory behind, so what it waits for is +// load-bearing: a store commit that lands afterwards re-creates the directory it just removed. + +import { existsSync } from 'node:fs' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } + +describe('abandoning a structured agent-session host', () => { + it('waits for the restart the delivery loop woke for an accepted send', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-abandon-')) + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + // Holds the restart inside its provider acquisition, so the point teardown must not run past + // is exact rather than a timing window. + let gate: Promise | null = null + let openGate = (): void => {} + let reportEntered = (): void => {} + const entered = new Promise((resolve) => { + reportEntered = resolve + }) + const adapter: StructuredAgentSessionAdapter = { + acquire: async ({ fence }) => { + if (gate) { + reportEntered() + await gate + } + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW - 1_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + } + }, + dispatch: async () => ({ + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } + }), + cancelTurn: async () => ({ cancelled: true }), + answerPrompt: async () => undefined, + releaseAcquisition: async () => true, + setOption: async () => undefined + } + const host = new StructuredAgentSessionHost({ + store, + adapter, + probeOwner: async () => ({ + outcome: 'identity-matched', + matchedOn: ['process-start-time'] + }), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) + expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + // The conversation stays and its provider child does not, so the next send makes the delivery + // loop start one — the shape the refusal-oracle spec ends on. + await host.close(SESSION) + gate = new Promise((resolve) => { + openGate = resolve + }) + + const body = hostTestMessage('delivery loop') + expect( + await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: `${NOW}-000000000000000000000000000003e9`, + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + ).toMatchObject({ ok: true }) + await entered + + let abandoned = false + const abandoning = abandonStructuredAgentSessionHost(host).then(() => { + abandoned = true + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(abandoned).toBe(false) + + openGate() + await abandoning + + // Nothing is left to put the directory back after this returns. + await rm(root, { recursive: true }) + await new Promise((resolve) => setTimeout(resolve, 200)) + expect(existsSync(root)).toBe(false) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.ts new file mode 100644 index 00000000000..a4069875bad --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.ts @@ -0,0 +1,25 @@ +// What a host that VANISHES without a clean quit has to release, for the tests that model one. +// +// Deliberately not the quit path: quit evicts provider children and releases leases, and that is +// the state a restart test is checking gets re-derived from disk. It stops short of ownership. +// +// What it cannot skip is work already in flight, which is what every copy of this helper used to +// get wrong. Two producers reach the session store after the last awaited call has returned — a +// lease-renewal tick, and the restart the delivery loop wakes for an accepted send — and the store +// re-creates its own directory before every commit. A commit landing after the test removed its +// temp directory therefore puts that directory back, and the removal fails with ENOTEMPTY. Quit +// waits for both, in its `stop-lease-renewal` and `drain-attaches` phases; so does this. + +import type { StructuredAgentSessionHost } from './structured-agent-session-host' + +export async function abandonStructuredAgentSessionHost( + host: StructuredAgentSessionHost +): Promise { + // First, so the drain below cannot race the loop into enqueueing another step. + host['conversationDelivery'].dispose() + host['lifetime'].dispose() + await host['runtimeState'].stopLeaseRenewal() + await host['tasks'].drainAttaches() + await Promise.all([...host['sessions'].values()].map((session) => session.journal.close())) + host['sessions'].clear() +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts index 69e6d029d60..959a02919a2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts @@ -1,6 +1,12 @@ -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalMessageItem, + AgentJournalSnapshot +} from '../../../shared/agent-session-journal-types' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import { projectNativeChatTranscriptMessages } from '../../../shared/native-chat-transcript-projection' +import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' +import { createStructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox' import { attachFingerprintFields } from './structured-agent-session-attach' import type { AgentSessionAttachParams } from './structured-agent-session-attach' @@ -61,3 +67,23 @@ export function hostTestAttachParams( } } } + +/** The row ids a chat draws from `snapshot`, top to bottom, while its composer still holds `sent` + * as dispatched, as it does until the journal accepts them. */ +export function hostTestDrawnRowIds( + snapshot: AgentJournalSnapshot, + sent: readonly { clientMessageId: string; text: string }[] +): string[] { + const outbox = sent.map((message) => ({ + ...createStructuredAgentSessionOutboxEntry({ + ...message, + sessionId: HOST_TEST_SESSION, + attachments: [], + queuedAt: HOST_TEST_NOW + }), + state: 'dispatching' as const + })) + return projectNativeChatTranscriptMessages( + projectStructuredAgentSessionMessages(snapshot.items, outbox, snapshot.submissions) + ).map(({ id }) => id) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts index e0ac9c00d88..d354ad911f0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -98,16 +99,20 @@ async function seedApproval(optionId = 'allow'): Promise<{ itemId: string; revis if (!events) { throw new Error('seedApproval requires an acquired session') } - events.appendItem(identity, { - kind: 'approval', - title: 'Run the command?', - detail: null, - options: [{ id: optionId, label: 'Allow' }], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - }) + events.appendItem( + identity, + { + kind: 'approval', + title: 'Run the command?', + detail: null, + options: [{ id: optionId, label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) await host.flushStreamedEvents(SESSION) const itemId = agentJournalItemKey(identity) - const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) const appended = page.ok ? page.page.items.find((item) => item.itemId === itemId) : null if (!appended) { throw new Error('provider approval was not written to the journal') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 574ced3cc38..44699536a6f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -1,4 +1,6 @@ +import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' @@ -18,7 +20,7 @@ export type StructuredAgentSessionCaller = { callerKey: string } /** What the host believes about a session it just made addressable again. The workspace and agent * come from the record, so a caller publishes the host's view rather than a client's assertion. * `readable` is false when the journal could not be opened — the tab is still worth publishing, - * because attach recovers what read restore cannot. */ + * because the chat shows that failure and its Retry. */ export type StructuredAgentSessionReveal = { sessionId: string workspaceId: string @@ -26,28 +28,66 @@ export type StructuredAgentSessionReveal = { readable: boolean } +/** Which provider child: the adapter acquisition and the lease fence it writes at. */ +export type StructuredAgentSessionProviderChildIdentity = { + readonly generation: string | null + readonly fence: number +} + +/** The provider process behind a conversation. Written only in + * `structured-agent-session-provider-child`. */ +export type StructuredAgentSessionProviderChild = StructuredAgentSessionProviderChildIdentity & { + /** A publish-first acquire is `starting` until the adapter's `started` event; only then are its + * reported options fact. */ + phase: StructuredAgentSessionProviderChildPhase + /** The queued message whose delivery started this child, fixed when the start is made; absent + * for any other start. In memory only: it tells a restart offer its own start from another. */ + readonly startedFor?: string +} + +/** What ending a child established about its provider root. A stop's comes only from + * `stopAgentSessionProviderRoot`; an observed exit's root is gone by definition. */ +export type StructuredAgentSessionStopVerdict = { rootGone: boolean } + +export type StructuredAgentSessionChildEndCause = + | 'user-stop' + | 'host-stop' + | 'exit' + | 'attach-failed' + | 'evict' + +/** How the conversation's last child ended. In memory only: the delivery loop reads it to tell a + * Stop from a failure. */ +export type StructuredAgentSessionEndedChild = StructuredAgentSessionProviderChildIdentity & + StructuredAgentSessionStopVerdict & { + /** `user-stop` is a Stop the user asked for; `host-stop` is the host stopping the child for a + * cause of its own, which fails the start the delivery loop was waiting on. */ + cause: StructuredAgentSessionChildEndCause + /** Descriptive text only — the provider's diagnostic, or the host's cause. Decides nothing. */ + reason: string | null + /** What the chat records about this end; absent reads as a provider exit with no detail. */ + failure?: SubmissionRejectionFact + duringStartup: boolean + startedFor?: string + /** Where the conversation's journal stood when the child ended, to order the end against a + * message's acceptance. */ + endedAt: AgentJournalCursor + } + +/** The conversation: its journal, params and readers outlive any child that serves it. */ export type StructuredAgentSessionHostSession = { - journal: AgentSessionJournal + /** Readonly: a new handle enters only through the session map's `set`, which binds its delivery. */ + readonly journal: AgentSessionJournal params: AgentSessionAttachParams - fence: number - /** Whether THIS host generation is running the provider process behind the session. A journal - * restored for reading has none — so it may not be evicted to free a child, nor have its lease - * released as an observed exit. */ - hasProviderChild: boolean - /** Whether the child behind `hasProviderChild` has proven its start. A publish-first acquire - * is `starting` until the adapter's `started` event; only then are its reported options fact. */ - providerChildPhase: StructuredAgentSessionProviderChildPhase + /** The child THIS host generation runs for the conversation. A conversation opened for reading + * has none — so it may not be evicted to free a child, nor have its lease released as an + * observed exit. */ + child: StructuredAgentSessionProviderChild | null /** The wind-down this host still owes for a child it started: settling that generation's work - * and handing the lease back. A separate fact from `hasProviderChild`, which goes false the - * moment the adapter proves the exit — an eviction that aborts after that point must still be - * able to finish the wind-down on the next close. */ - owesProviderChildWindDown?: boolean - /** Exact adapter acquisition behind `hasProviderChild`; retained after exit to fence recovery. */ - acquisitionGeneration: string | null - /** The fence of the released owner this child replaced, when it was resumed into a lease handed - * back cleanly. A writer current as of that owner is admitted at `fence`: the restart is the - * only thing that moved it. Absent for a create or a journal restored for reading. */ - resumedFromFence?: number + * and handing the lease back. Outlives `child`, which ends the moment the adapter proves the + * exit — an eviction that aborts after that point must still finish it on the next close. */ + owesProviderChildWindDown?: StructuredAgentSessionProviderChildIdentity + lastEndedChild?: StructuredAgentSessionEndedChild } export type StructuredAgentSessionHostDeps = { @@ -73,8 +113,10 @@ export type StructuredAgentSessionHostDeps = { provider: AgentSessionRecord['provider'] ) => Promise | undefined> | Record | undefined now?: () => number - /** How long a session outlives its last surface. Tests drive this; production takes the default. */ - releaseGraceMs?: number + /** The idle sweep's period and window. Tests drive these; production takes the defaults. */ + idleSweep?: { intervalMs?: number; idleMs?: number } + /** Whether an orchestration dispatch still owns this session's worker; absent answers no. */ + hasOpenDispatch?: (record: AgentSessionRecord) => boolean onEventSinkError?: (input: { sessionId: string; error: unknown }) => void /** Every status projection this host publishes. `replay` marks a re-projection of state the host * already knew (restore, an arriving subscriber) rather than a fresh journal edge. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 62f88672342..5db9e5d0d74 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' @@ -8,6 +9,7 @@ import { AgentSessionRecordStore } from '../../runtime/agent-session-record-stor import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { adapter, attach, @@ -138,11 +140,13 @@ describe('attach', () => { }) const params = attachParams() + // Orca's own store fault: the child is gone, but nothing blames the provider. const refused = { ok: false, refusal: { code: 'agent_session_operation_invalid', - message: 'agent_session_provider_handle_stale_fence', + details: { ownerVerdict: 'exited' }, + message: "Codex couldn't restart. Send your message to try again.", ownerVerdict: 'exited' } } @@ -160,7 +164,10 @@ describe('attach', () => { await expect(host.attach(CALLER, attachParams())).resolves.toMatchObject({ ok: false, - refusal: { message: 'commit failed', ownerVerdict: 'exited' } + refusal: { + message: "Codex couldn't restart. Send your message to try again.", + ownerVerdict: 'exited' + } }) expect(releaseAcquisition).toHaveBeenCalledWith({ sessionId: SESSION }) @@ -180,7 +187,8 @@ describe('attach', () => { }) events?.appendItem( { provider: 'orca', clientMessageId: 'old-journal-write' }, - { kind: 'status', text: 'old journal write' } + { kind: 'status', text: 'old journal write' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await vi.waitFor(() => expect(append).toHaveBeenCalledOnce()) const released = await store.evictProvenDeadOwner({ @@ -208,7 +216,7 @@ describe('cancel', () => { turnId: 'turn-1' }) expect(result).toMatchObject({ ok: true, value: { cancelled: true } }) - const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(page.ok && page.page.items[0]?.body).toMatchObject({ kind: 'status', text: 'Cancellation requested.' @@ -332,7 +340,7 @@ describe('cancel', () => { refusal: { code: 'agent_session_operation_unknown' } }) expect(cancelTurn).toHaveBeenCalledTimes(1) - expect(host.history({ sessionId: SESSION, direction: 'tail' })).toMatchObject({ + expect(await host.history({ sessionId: SESSION, direction: 'tail' })).toMatchObject({ ok: true, page: { items: [ @@ -383,7 +391,7 @@ describe('respondToPrompt', () => { options: [{ id: 'allow', label: 'Allow' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }, - child + { ...child, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.flushStreamedEvents(SESSION) const itemId = agentJournalItemKey(identity) @@ -395,7 +403,7 @@ describe('respondToPrompt', () => { ...fields }) - const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) const answered = page.ok ? page.page.items.find((item) => item.itemId === itemId) : null expect(answered).toMatchObject({ revision: 2, @@ -490,7 +498,7 @@ describe('respondToPrompt', () => { ...fields }) expect(result.ok).toBe(true) - const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) const statusId = agentJournalItemKey({ provider: 'orca', clientMessageId: `${prompt.itemId}#delivery` @@ -520,7 +528,7 @@ describe('setOption', () => { }) expect(setOption).toHaveBeenCalledTimes(1) expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-5', effort: 'high' }) - const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(page.ok && page.page.items).toHaveLength(0) }) @@ -547,16 +555,19 @@ describe('restart', () => { * them. Every lease loads unreconciled, so this is the state that decides * whether a persisted session is reachable at all. */ async function reboot( - probeOwner: (record: AgentSessionRecord) => Promise + probeOwner: (record: AgentSessionRecord) => Promise, + adapterOverrides: Partial = {}, + stopOwnerProcess?: StructuredAgentSessionHostDeps['stopOwnerProcess'] ) { store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) host = new StructuredAgentSessionHost({ store, - adapter: adapter(), + adapter: { ...adapter(), ...adapterOverrides }, journalRoot: root, claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-b', probeOwner, + ...(stopOwnerProcess ? { stopOwnerProcess } : {}), now: () => NOW }) replaceHostTestState({ store, host }) @@ -601,13 +612,13 @@ describe('restart', () => { expect(host.listSessionTabs()).toEqual([ { sessionId: SESSION, workspaceId: 'workspace-1', agent: 'codex' } ]) - const history = host.history({ sessionId: SESSION, direction: 'tail' }) + const history = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(history.ok && history.page.items).not.toHaveLength(0) expect(acquire).not.toHaveBeenCalled() expect(listRecords).toHaveBeenCalledTimes(restoreReads) }) - it('clears a stale conflicted recovery at restart, and reacquires the native owner when a surface holds it', async () => { + it('clears a stale conflicted recovery at restart, and reacquires the native owner on the next start', async () => { await attach() await store.transitionHandoff(SESSION, (record) => ({ ...record, @@ -615,16 +626,17 @@ describe('restart', () => { ...record.lease, // How a terminal owner an older build recorded loads. claimStatus: 'conflicted', - handoffStage: 'manual-recovery' + handoffStage: 'recovering' } })) await reboot(async () => ({ outcome: 'pid-absent' })) acquire.mockClear() await host.restoreReadableSessions() - // The recovery stage clears on evidence at startup; the child comes back only once a surface - // holds the session (see structured-agent-session-surface-lifetime.test.ts). - await host.hold(SESSION, 'surface-1') + // The recovery stage clears on evidence at startup; the child comes back only once work + // starts it — here the explicit attach a send's delivery would make. + const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + expect(await host.attach(CALLER, ensureParams(fence))).toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ @@ -633,14 +645,14 @@ describe('restart', () => { handoffStage: null, handoffOperationId: null }) - await expect(host.handoffStatus(SESSION)).resolves.toMatchObject({ + expect(host.handoffStatus(SESSION)).toMatchObject({ owner: 'native', phase: 'idle', stage: null }) }) - it('answers the owner status of a starting chat once its start settles', async () => { + it('answers native for a chat whose start is still in flight', async () => { await attach() await reboot(async () => ({ outcome: 'pid-absent' })) await host.restoreReadableSessions() @@ -656,24 +668,45 @@ describe('restart', () => { return settled(input) }) - const hold = host.hold(SESSION, 'surface-1') + const start = host.attach( + CALLER, + ensureParams(store.getRecord(SESSION)?.lease.runtimeFence ?? 0) + ) await started.promise + const claimMidStart = store.getRecord(SESSION)?.lease.claimStatus const status = host.handoffStatus(SESSION) release.resolve() - await hold + await start - await expect(status).resolves.toMatchObject({ owner: 'native', stage: null }) + // Mid-start the lease is only reserved; ownership does not wait for the agent. + expect(claimMidStart).toBe('reserved') + expect(status).toMatchObject({ owner: 'native' }) }) - it("keeps a session whose owner cannot be probed out of a live writer's hands", async () => { + it('vouches for no owner of a chat this host cannot run', async () => { + await attach() + + await reboot(async () => ({ outcome: 'pid-absent' }), { supportsCreate: () => false }) + expect(() => host.handoffStatus(SESSION)).toThrow('structured_agent_session_unsupported') + }) + + it('releases a session whose owner can never be probed, signalling nothing, and starts over', async () => { await attach() const held = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - await reboot(async () => ({ outcome: 'indeterminate', reason: 'no probe on this host' })) + const stopOwnerProcess = vi.fn() + await reboot( + async () => ({ outcome: 'indeterminate', reason: 'no probe on this host' }), + {}, + stopOwnerProcess + ) + acquire.mockClear() - expect(await host.attach(CALLER, ensureParams(held))).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_ownership_unknown' } + expect(await host.attach(CALLER, ensureParams(await staleFenceFrom(held)))).toMatchObject({ + ok: true }) + expect(acquire).toHaveBeenCalledOnce() + // An unverifiable pid may already belong to an unrelated process. + expect(stopOwnerProcess).not.toHaveBeenCalled() }) it('does not remember a failed adjudication as done', async () => { @@ -696,7 +729,7 @@ describe('subscribe', () => { it('opens with a snapshot and then streams cursor-qualified batches', async () => { await attach() const events: AgentSessionSubscribeEvent[] = [] - const dispose = host.subscribe({ + const dispose = await host.subscribe({ id: 'sub-1', sessionId: SESSION, emit: (event) => events.push(event) @@ -726,7 +759,7 @@ describe('subscribe', () => { } const events: AgentSessionSubscribeEvent[] = [] - host.subscribe({ + await host.subscribe({ id: 'sub-2', sessionId: SESSION, emit: (event) => events.push(event), @@ -747,14 +780,14 @@ describe('subscribe', () => { it('drops a failed transport without aborting the mutation or other subscribers', async () => { await attach() const events: AgentSessionSubscribeEvent[] = [] - host.subscribe({ + await host.subscribe({ id: 'dead-sub', sessionId: SESSION, emit: () => { throw new Error('socket closed') } }) - host.subscribe({ + await host.subscribe({ id: 'live-sub', sessionId: SESSION, emit: (event) => events.push(event) @@ -766,15 +799,25 @@ describe('subscribe', () => { body }) - expect(result).toMatchObject({ ok: true, value: { submission: { dispatchState: 'accepted' } } }) - expect(dispatch).toHaveBeenCalledTimes(1) - expect(events.some((event) => event.type === 'batch')).toBe(true) + expect(result).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) + // The failed transport does not stop the delivery loop either: the handover still lands and + // reaches the live subscriber. + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => + expect( + events.some( + (event) => + event.type === 'batch' && + event.batch.submissions?.some((entry) => entry.dispatchState === 'accepted') + ) + ).toBe(true) + ) }) it('resets a subscriber whose epoch is gone', async () => { await attach() const events: AgentSessionSubscribeEvent[] = [] - host.subscribe({ + await host.subscribe({ id: 'sub-3', sessionId: SESSION, emit: (event) => events.push(event), @@ -786,7 +829,7 @@ describe('subscribe', () => { it('publishes the replacement fence when the owner generation changes', async () => { const record = await attach() const events: AgentSessionSubscribeEvent[] = [] - host.subscribe({ + await host.subscribe({ id: 'sub-4', sessionId: SESSION, emit: (event) => events.push(event) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 4ba2e6db478..efdd83d9f14 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -2,53 +2,60 @@ import type { AgentSessionRewindParams } from '../../../shared/agent-session-rew import { rewindStructuredAgentSession } from './structured-agent-session-rewind' import { StructuredConversationCommandController } from './structured-conversation-command-controller' // Structured agent-session host: where the lease, journal, and provider adapter meet. -// Mutations share one durable admission path and serialize per session. +// Mutations share one durable admission path and serialize per session. A conversation is reached +// only through `conversation`, which opens it at rest; an agent is started only by work that needs +// it, and the idle sweep is the one thing that puts it to rest. import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type * as SessionWire from '../../../shared/agent-session-wire' import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' import { createRestartReconciler } from './structured-agent-session-restart-reconcile' import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' import * as providerSupport from './structured-agent-session-provider-support' -import { createStructuredAgentSessionHostRestore } from './structured-agent-session-reveal' +import { + createStructuredAgentSessionHostRestore, + revealStructuredAgentSession +} from './structured-agent-session-reveal' import { structuredAgentSessionOwnerStatus } from './structured-agent-session-owner-status' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' +import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import { - createStructuredAgentSessionHolds, - evictHeldStructuredAgentSession, - type StructuredAgentSessionLifetimeContext -} from './structured-agent-session-host-lifetime' -import type { - StructuredAgentSessionHolds, - StructuredAgentSessionHoldOptions -} from './structured-agent-session-holds' + ensureStructuredAgentSessionAgent, + ensureStructuredAgentSessionAgentForOperation +} from './structured-agent-session-agent-start' +import { + createStructuredAgentSessionConversationLifetime, + type StructuredAgentSessionConversationLifetime +} from './structured-agent-session-conversation-lifetime' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import * as sessionTabs from './structured-agent-session-host-tabs' import { structuredAgentSessionMutationDelegates, settleStructuredAgentSessionLateDispatch, - type StructuredAgentSessionMutationContext, - releaseStructuredAgentSessionUnansweredDispatches + type StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import { releaseStructuredAgentSessionUnansweredDispatches } from './structured-agent-session-unanswered-dispatch-release' import { flushStructuredAgentSessionHost } from './structured-agent-session-host-teardown' import type { StructuredAgentSessionCaller, StructuredAgentSessionHostDeps, - StructuredAgentSessionHostSession, StructuredAgentSessionReveal } from './structured-agent-session-host-types' import { StructuredAgentSessionEventRecovery } from './structured-agent-session-event-recovery' import { StructuredAgentSessionBackgroundTaskChannel } from './structured-agent-session-background-task-channel' import { StructuredAgentSessionClientDelivery } from './structured-agent-session-client-delivery' +import { StructuredAgentSessionConversations } from './structured-agent-session-conversations' import { createStructuredAgentSessionRestartResume, type StructuredAgentSessionRestartResume } from './structured-agent-session-restart-resume-host' import { structuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-wiring' +import { createStructuredAgentSessionConversationDelivery } from './structured-agent-session-host-delivery' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import { wireStructuredAgentSessionQueuedMessages } from './structured-agent-session-queued-wiring' export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' export class StructuredAgentSessionHost { @@ -56,12 +63,25 @@ export class StructuredAgentSessionHost { () => this.mutationContext(), this ) - private readonly sessions = new Map() + private readonly sessions = new StructuredAgentSessionConversations({ + deliver: (sessionId, journal) => { + this.subscribers.publish(sessionId, journal) + this.conversationDelivery.afterCommit(sessionId, journal) + }, + onDeliveryError: (sessionId, error) => this.deps.onEventSinkError?.({ sessionId, error }), + onOpened: (sessionId) => this.queued.drain.schedule(sessionId), + now: () => this.now() + }) + private readonly queued = wireStructuredAgentSessionQueuedMessages(this.sessions, () => + this.mutationContext() + ) + // Every journal publish is activity: the one renewal the idle sweep reads. private readonly clientDelivery = new StructuredAgentSessionClientDelivery( this.sessions, () => this.now(), () => this.deps, - (sessionId) => this.holds.renew(sessionId) + (sessionId) => this.queued.onJournalActivity(sessionId), + (sessionId) => this.restartResume.onAgentStarted(sessionId) ) private readonly subscribers = this.clientDelivery.subscribers private readonly tasks = new StructuredAgentSessionTaskQueue() @@ -70,7 +90,10 @@ export class StructuredAgentSessionHost { sessionId: string ) => Promise private readonly restore: ReturnType - private readonly holds: StructuredAgentSessionHolds + private readonly lifetime: StructuredAgentSessionConversationLifetime + private readonly conversationDelivery: ReturnType< + typeof createStructuredAgentSessionConversationDelivery + > private readonly eventRecovery: StructuredAgentSessionEventRecovery private readonly backgroundTasks: StructuredAgentSessionBackgroundTaskChannel /** Public because the RPC surface addresses it directly; see the restart-resume collaborator. */ @@ -81,7 +104,7 @@ export class StructuredAgentSessionHost { deps, this.sessions, this.subscribers, - (sessionId) => this.requireSession(sessionId), + (sessionId) => this.lifetime.conversation(sessionId), this.clientDelivery.publishStatus ) this.runtimeState = new StructuredAgentSessionHostRuntimeState(deps, (sessionId, error) => @@ -93,21 +116,32 @@ export class StructuredAgentSessionHost { ...(deps.probeOwners ? { probeMany: deps.probeOwners } : {}), now: () => this.now() }) - this.holds = createStructuredAgentSessionHolds( - () => this.attachContext(), - (sessionId) => this.close(sessionId) - ) - this.restore = createStructuredAgentSessionHostRestore(deps, this.sessions, () => this.now(), { + this.conversationDelivery = createStructuredAgentSessionConversationDelivery({ + deps, + sessions: this.sessions, + serialize: (sessionId, task) => this.serialize(sessionId, task), + // Quit drains a delivery start before it evicts, so the child it produces is stopped. + trackStart: (start) => this.tasks.trackAttach(start), + ensureProviderChild: (sessionId, startedFor) => + ensureStructuredAgentSessionAgent(this.attachContext(), sessionId, startedFor), + reset: (sessionId, journal, reset) => + this.subscribers.reset( + sessionId, + journal, + reset, + structuredAgentSessionConversationFence(deps.store, sessionId) + ), + publishRestored: this.clientDelivery.publishRestored, + flushStreamedEvents: (sessionId) => this.flushStreamedEvents(sessionId) + }) + this.restore = createStructuredAgentSessionHostRestore(deps, { reconcile: this.reconcileLeases, resolveRecovery: (sessionId) => this.runtimeState.resolveRecovery(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), hasSession: this.hasSession, // Site 10: cannot overwrite a live entry — the restorer returns early on // `hasSession` inside the same serialized step as this `set`. - onReadable: (sessionId, restored) => { - this.sessions.set(sessionId, restored) - this.clientDelivery.publishRestored(sessionId) - } + onReadable: this.conversationDelivery.adoptOpened }) this.eventRecovery = new StructuredAgentSessionEventRecovery({ deps, @@ -115,38 +149,37 @@ export class StructuredAgentSessionHost { sessions: this.sessions, flushLifecycle: (sessionId) => this.runtimeState.lifecycleBarrier(sessionId), publishFence: (sessionId, session) => - this.subscribers.snapshot(sessionId, session.journal, session.fence), + this.subscribers.snapshot( + sessionId, + session.journal, + structuredAgentSessionConversationFence(deps.store, sessionId) + ), publishStatus: this.clientDelivery.publishStatusAndSettlement, - hasResumeCapableHolder: (sessionId) => this.holds.hasResumeCapableHolder(sessionId), - restartReleaseGrace: (sessionId) => this.holds.renew(sessionId), - // Tracked: a quit drains a queued restart before it evicts, so no child outlives it. serialize: (sessionId, task) => this.tasks.trackAttach(this.serialize(sessionId, task)), now: () => this.now(), - ensureProviderChild: (id, options) => this.holds.ensureProviderChild(id, options), onBarrierError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }) }) - this.restartResume = createStructuredAgentSessionRestartResume(deps, this.sessions, { - ...structuredAgentSessionRestartResumeSurfaces(this, this.now), - publish: this.subscribers.publish.bind(this.subscribers) + this.restartResume = createStructuredAgentSessionRestartResume( + deps, + this.sessions, + structuredAgentSessionRestartResumeSurfaces(this, this.now) + ) + this.lifetime = createStructuredAgentSessionConversationLifetime({ + context: () => this.lifetimeContext(), + sessions: this.sessions, + serialize: (sessionId, task) => this.serialize(sessionId, task), + open: (sessionId) => this.conversationDelivery.open(sessionId), + deliveryActive: (sessionId) => this.conversationDelivery.loop.isRunning(sessionId), + closeStatus: (sessionId, options) => this.clientDelivery.closeSession(sessionId, options) }) this.runtimeState.startLeaseRenewal() + this.lifetime.idleSweep.start() } private now = (): number => this.deps.now?.() ?? Date.now() hasSession = (sessionId: string): boolean => this.sessions.has(sessionId) - isHeld = (sessionId: string): boolean => this.holds.isHeld(sessionId) - - /** A surface bound to this session and wants it live. The FIRST hold on a session with no - * provider child is what resumes one; a retained hold (a subscription) only keeps it. */ - hold = ( - sessionId: string, - holderId: string, - options?: StructuredAgentSessionHoldOptions - ): Promise => this.holds.hold(sessionId, holderId, options) - - /** That surface is gone. The child outlives it by the idle window, and by any running turn. */ - release = (sessionId: string, holderId: string): void => this.holds.release(sessionId, holderId) + sessionAgent = (sessionId: string) => this.deps.store.getRecord(sessionId)?.provider ?? null handleAdapterEvent = (event: Parameters[0]) => this.eventRecovery.handle(event) @@ -157,7 +190,7 @@ export class StructuredAgentSessionHost { runtimeState: this.runtimeState, sessions: this.sessions, now: () => this.now(), - forgetStatus: this.clientDelivery.forgetStatus + publishStatus: this.clientDelivery.publishStatus } } @@ -169,28 +202,31 @@ export class StructuredAgentSessionHost { tasks: this.tasks, reconcileLeases: (sessionId) => this.reconcileLeases(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), - publishStatus: this.clientDelivery.publishStatus + publishStatus: this.clientDelivery.publishStatus, + openConversation: this.conversationDelivery.open } } - /** Releases a session's resources without ending the conversation: the record and journal stay - * on disk, so the same session can be attached again. */ - close(sessionId: string): Promise { - return this.serialize(sessionId, async () => { - await evictHeldStructuredAgentSession(this.lifetimeContext(), sessionId) - this.clientDelivery.closeSession(sessionId) - // The holders now look at a session that is gone; a failed eviction throws above, keeping them. - this.holds.forget(sessionId) - }) - } + /** Releases a session's resources without ending the conversation; see the lifetime's close. */ + close = (sessionId: string): Promise => this.lifetime.close(sessionId) supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => providerSupport.adapterSupportsCreate(this.deps.adapter, location, agent) listSessionTabs = () => sessionTabs.listStructuredAgentSessionTabs(this.sessions) getPersistedVisibleSessionTabIndex = () => this.deps.store.getVisibleSessionTabIndex() + getSessionTabId = (sessionId: string): string | null => this.deps.store.getSessionTabId(sessionId) - setSessionTabVisibility = (sessionId: string, visible: boolean): Promise => - sessionTabs.setStructuredAgentSessionTabVisibility(this, sessionId, visible) + setSessionTabVisibility = async ( + sessionId: string, + visible: boolean, + tabId?: string + ): Promise => { + await sessionTabs.setStructuredAgentSessionTabVisibility(this, sessionId, visible, tabId) + // The tab edge of the row's lifetime; the handle close is the other. + if (!visible && !this.sessions.get(sessionId)?.child) { + this.clientDelivery.forgetStatus(sessionId) + } + } reconcileRestartLeases = async (): Promise => { const refusal = await this.reconcileLeases('startup') @@ -204,7 +240,7 @@ export class StructuredAgentSessionHost { /** Make one persisted session addressable again; see `structured-agent-session-reveal`. */ revealSession = (sessionId: string): Promise => - this.restore.revealSession(sessionId) + revealStructuredAgentSession(this.deps, sessionId, (id) => this.lifetime.conversation(id)) private serialize = this.tasks.serialize.bind(this.tasks) @@ -221,9 +257,10 @@ export class StructuredAgentSessionHost { // Trigger inlined rather than imported: `AgentSessionResumeTrigger` in shared is the canonical // type, and this file has no line budget left for the import. async flushAllStreamedEvents(options?: { trigger?: 'quit' | 'update' }): Promise { + this.conversationDelivery.dispose() await flushStructuredAgentSessionHost({ ...this.lifetimeContext(), - holds: this.holds, + idleSweep: this.lifetime, tasks: this.tasks, restartResume: this.restartResume, serialize: this.serialize, @@ -237,16 +274,24 @@ export class StructuredAgentSessionHost { sessions: this.sessions, publish: (sessionId, journal) => this.subscribers.publish(sessionId, journal), flushStreamedEvents: this.flushStreamedEvents, - requireSession: (sessionId) => this.requireSession(sessionId), + conversation: this.lifetime.conversation, serialize: (sessionId, task) => this.serialize(sessionId, task), - holds: this.holds, - restoreReadable: (sessionId) => this.restore.restoreReadableUnderSerialize(sessionId), + openConversation: this.conversationDelivery.open, + ensureAgent: (sessionId) => + ensureStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId), + wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), + stopAgent: this.lifetime.stopAgent, + wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId), now: () => this.now() } } send = this.conversationCommands.send + queuedMessageSend = this.queued.queuedMessageSend + queuedMessageDelete = this.queued.queuedMessageDelete + queuedMessagesResume = this.queued.queuedMessagesResume + waitForSendSettlement = this.clientDelivery.waitForSendSettlement private mutations = structuredAgentSessionMutationDelegates(() => this.mutationContext()) @@ -267,27 +312,19 @@ export class StructuredAgentSessionHost { commands: this.deps.adapter.readCommands?.(sessionId) }) - async handoffStatus(sessionId: string): Promise { - this.requireSession(sessionId) - // Queued behind an in-flight attach, so a starting chat answers with its settled owner. - return this.serialize(sessionId, async () => { - const record = this.deps.store.getRecord(sessionId) - if (!record) { - throw new Error('agent_session_identity_required') - } - return structuredAgentSessionOwnerStatus(record) - }) - } + /** From the record store, never the session map: an idle-released chat has no map entry. */ + handoffStatus = (sessionId: string): SessionWire.AgentSessionHandoffStatus => + structuredAgentSessionOwnerStatus(this.deps, sessionId) history: StructuredAgentSessionBackgroundTaskChannel['history'] = (request) => this.backgroundTasks.history(request) /** The fully reduced timeline, for readers that cannot tolerate a page's ambiguity — rows are * revised or tombstoned in place, so an item's ABSENCE from a bounded page proves nothing. */ - journalSnapshot = (sessionId: string): AgentJournalSnapshot => - this.requireSession(sessionId).journal.snapshot() + journalSnapshot = async (sessionId: string): Promise => + (await this.lifetime.conversation(sessionId)).journal.snapshot() - subscribe = (input: AgentSessionSubscribeInput): (() => void) => + subscribe = (input: AgentSessionSubscribeInput): Promise<() => void> => this.backgroundTasks.subscribe(input) settleLateDispatch = (input: Parameters[1]) => @@ -308,11 +345,13 @@ export class StructuredAgentSessionHost { /** Turns that settle from now on. Live-only: nothing missed is replayed. */ subscribeTurnCompletions = this.clientDelivery.subscribeTurnCompletions - private requireSession(sessionId: string): StructuredAgentSessionHostSession { - const session = this.sessions.get(sessionId) - if (!session) { - throw new Error(AGENT_SESSION_NOT_ATTACHED.code) - } - return session - } + /** Test rigs only: the collaborators the host builds itself, typed, for tests that drive them. */ + collaboratorsForTests = () => ({ + sessions: this.sessions, + subscribers: this.subscribers, + runtimeState: this.runtimeState, + conversationDelivery: this.conversationDelivery, + lifetime: this.lifetime, + serialize: this.serialize + }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts new file mode 100644 index 00000000000..25288dc6490 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -0,0 +1,318 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +// The idle sweep on a real host: what puts an agent to rest, what keeps it running, and what a +// reader and the session lists see when it does. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import { + collectSubscriber, + createRestTestRig, + foundRestTestChat, + readerSaw, + IDLE_MS, + REST_TEST_CALLER as CALLER, + REST_TEST_SESSION as SESSION, + REST_TEST_THREAD as THREAD, + restTestSend, + sweepTicks, + type RestTestRig +} from './structured-agent-session-rest-test-rig' +import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep' + +let rig: RestTestRig + +beforeEach(async () => { + rig = await createRestTestRig() +}) + +afterEach(async () => { + await rig.dispose() +}) + +function providerEvents() { + const events = rig.adapter.acquire.mock.calls.at(-1)?.[0].events + if (!events) { + throw new Error('no provider child was started') + } + return events +} + +function lastStatus(): AgentSessionStatusSummary | undefined { + return rig.statusEvents + .flatMap((event) => (event.type === 'status' ? [event.session] : [])) + .findLast((summary) => summary.sessionId === SESSION) +} + +function fence(): number { + return rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 1 +} + +describe('the idle sweep', () => { + it('stops an idle agent and keeps the conversation, its status row and its reader (P2-07)', async () => { + await foundRestTestChat(rig) + const reader = collectSubscriber() + await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) + rig.clock.now += IDLE_MS + 1 + + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + await vi.waitFor(() => expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released')) + await vi.waitFor(() => expect(rig.adapter.acknowledgeSessionRelease).toHaveBeenCalledOnce()) + expect(rig.adapter.acknowledgeSessionRelease).toHaveBeenCalledWith(SESSION) + // Listed, idle, and no longer running on this host — never dropped from any list. + expect(lastStatus()).toMatchObject({ status: 'idle' }) + expect(lastStatus()?.hostExecutionOwned).toBeUndefined() + expect(rig.sink.forget).not.toHaveBeenCalled() + expect(reader.events.some((event) => event.type === 'end')).toBe(false) + + // Everything a chat at rest answers still answers, and the next send starts a new agent. + await expect(rig.host.readOptions(SESSION)).resolves.toMatchObject({ current: {} }) + const sent = await rig.host.send(CALLER, restTestSend('carry on', fence())) + expect(sent.ok).toBe(true) + await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(readerSaw(reader.events).texts).toContain('carry on')) + }) + + it('never stops an agent a message is queued for, and hands the message over (P2-08)', async () => { + await foundRestTestChat(rig) + rig.adapter.closeSession.mockClear() + // The loop takes the message and waits on the child's start, outside the lock. + const started = Promise.withResolvers() + const awaitStarted = vi.fn(() => started.promise) + Object.assign(rig.host.deps.adapter, { awaitStarted }) + const reader = collectSubscriber() + await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) + const sent = await rig.host.send(CALLER, restTestSend('queued one', fence())) + expect(sent.ok).toBe(true) + await vi.waitFor(() => expect(awaitStarted).toHaveBeenCalled()) + rig.clock.now += IDLE_MS + 1 + + await sweepTicks() + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + started.resolve() + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) + const settled = (await rig.host.journalSnapshot(SESSION)).submissions.at(-1) + expect(settled?.dispatchState).toBe('accepted') + const seen = readerSaw(reader.events).submissions + expect(seen.some((row) => row.dispatchState === 'accepted')).toBe(true) + expect( + seen.some((row) => row.dispatchState === 'rejected' || row.dispatchState === 'unknown') + ).toBe(false) + }) + + it('never stops an agent whose background work still runs (P2-09)', async () => { + await foundRestTestChat(rig) + // A Codex subagent thread is one of these tasks; the tracker projects both kinds alike. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a background roster fixture; the sweep reads only whether live tasks exist. + rig.adapter.backgroundTaskState.mockReturnValue({ + state: 'monitoring', + tasks: [ + { + taskId: 'subagent-1', + kind: 'subagent', + status: 'running', + title: 'reviewer', + startedAt: rig.clock.now + } + ] + } as never) + rig.clock.now += IDLE_MS + 1 + + await sweepTicks() + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + rig.adapter.backgroundTaskState.mockReturnValue(undefined) + rig.clock.now += IDLE_MS + 1 + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + }) + + // A finished child reads done before the lead's wake-up turn writes its first row; stopping the + // agent in that gap would lose the wake-up. Owed work is activity, as main's release clock had it. + it('gives an agent a full idle window after its background work ends', async () => { + await foundRestTestChat(rig) + rig.adapter.backgroundTaskState.mockReturnValue({ + state: 'monitoring', + tasks: [{ id: 'subagent-1', kind: 'agent', state: 'working' }] + }) + rig.clock.now += IDLE_MS + 1 + await sweepTicks() + rig.adapter.backgroundTaskState.mockReturnValue({ + state: 'monitoring', + tasks: [{ id: 'subagent-1', kind: 'agent', state: 'done' }] + }) + + await sweepTicks() + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + rig.clock.now += IDLE_MS + 1 + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + }) + + // Owed work is read every tick, not once a window: work that ends just before a window would + // have closed still leaves the agent a full window after it. + it('gives a full window after background work that ends late in a window', async () => { + await foundRestTestChat(rig) + const subagent = (state: 'working' | 'done') => ({ + state: 'monitoring' as const, + tasks: [{ id: 'subagent-1', kind: 'agent' as const, state }] + }) + rig.adapter.backgroundTaskState.mockReturnValue(subagent('working')) + rig.clock.now += IDLE_MS + 1 + await sweepTicks() + rig.clock.now += IDLE_MS - 60_000 + await sweepTicks() + rig.adapter.backgroundTaskState.mockReturnValue(subagent('done')) + rig.clock.now += 60_000 + 1 + + await sweepTicks() + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + rig.clock.now += IDLE_MS + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + }) + + it('stops an agent whose roster holds only children that went idle or finished', async () => { + await foundRestTestChat(rig) + rig.adapter.backgroundTaskState.mockReturnValue({ + state: 'monitoring', + tasks: [ + { id: 'subagent-1', kind: 'agent', state: 'idle' }, + { id: 'command-1', kind: 'command', state: 'done' } + ] + }) + rig.clock.now += IDLE_MS + 1 + + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + }) + + it('never stops an agent while its lead turn runs, however quiet (P2-10)', async () => { + await foundRestTestChat(rig) + providerEvents().appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 50 }, + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await rig.host.flushStreamedEvents(SESSION) + rig.clock.now += IDLE_MS + 1 + + await sweepTicks() + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + }) + + it('stops an agent whose chat is still open on screen (P2-12)', async () => { + await foundRestTestChat(rig) + const reader = collectSubscriber() + await rig.host.subscribe({ id: 'on-screen', sessionId: SESSION, emit: reader.emit }) + rig.clock.now += IDLE_MS + 1 + + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + expect(reader.events.some((event) => event.type === 'end')).toBe(false) + }) + + it('counts the idle window from the last activity (P2-13)', async () => { + await foundRestTestChat(rig) + rig.clock.now += IDLE_MS - 60_000 + // Activity at 29 minutes: a provider row reaching the journal. + providerEvents().appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 60 }, + { kind: 'status', text: 'still thinking' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await rig.host.flushStreamedEvents(SESSION) + rig.clock.now += 60_001 + + await sweepTicks() + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + rig.clock.now += IDLE_MS + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + }) + + it('never stops a worker whose orchestration dispatch is open, and stops it once it settles (P2-19 i)', async () => { + await rig.dispose() + let open = true + const hasOpenDispatch = vi.fn(() => open) + rig = await createRestTestRig({ hasOpenDispatch }) + await foundRestTestChat(rig) + rig.clock.now += 2 * IDLE_MS + + await sweepTicks(12) + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + expect(hasOpenDispatch).toHaveBeenCalledWith(expect.objectContaining({ sessionId: SESSION })) + open = false + rig.clock.now += IDLE_MS + 1 + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + }) + + it('keeps a child an unanswered prompt waits on (P2-22 i)', async () => { + await foundRestTestChat(rig) + providerEvents().appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'subagent-turn', ordinal: 70 }, + { + kind: 'approval', + title: 'Run the command?', + detail: null, + options: [{ id: 'allow', label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await rig.host.flushStreamedEvents(SESSION) + rig.clock.now += IDLE_MS + 1 + + await sweepTicks() + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + }) +}) + +describe('the idle sweep with no child running (P2-22 ii)', () => { + it('closes a handle whose only leftover is a prompt nobody can answer', async () => { + const journal = { + submissions: () => [], + pendingSubmissions: () => [], + snapshot: () => ({ + items: [ + { + itemId: 'prompt', + revision: 1, + sequence: 1, + observedAt: 0, + body: { + kind: 'approval', + title: 'Run?', + detail: null, + options: [], + resolution: { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + } + } + ] + }) + } + const sessions = Object.assign( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a session fixture carrying only the journal and child facts the sweep reads. + new Map([[SESSION, { journal, child: null } as never]]), + { lastActivityAt: () => 0, touch: () => undefined } + ) + const stopAgent = vi.fn(async () => undefined) + const closeConversation = vi.fn(async () => true) + const sweep = new StructuredAgentSessionIdleSweep({ + sessions, + serialize: (_id, task) => task(), + now: () => IDLE_MS + 1, + isDisposed: () => false, + deliveryActive: () => false, + backgroundTaskState: () => undefined, + hasOpenDispatch: () => false, + stopAgent, + stopStartingAgent: stopAgent, + closeConversation, + onError: (_id, error) => { + throw error + } + }) + await sweep.tick() + expect(stopAgent).not.toHaveBeenCalled() + expect(closeConversation).toHaveBeenCalledWith(SESSION) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts new file mode 100644 index 00000000000..2394b604171 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -0,0 +1,151 @@ +// The one thing that puts an idle agent to rest. +// +// Nothing a viewer does keeps an agent alive or starts one: a chat on screen and a chat in a +// background tab are the same to this sweep. Every few minutes it looks at each open conversation +// and stops the provider child of one that has been quiet for the idle window and owes no work, +// then drops the open journal handle of one that is only a cache. The conversation itself — its +// record, tab, status row and readers — is untouched, and the next send starts a new child. +// +// Owed work is derived on every tick, never stored, so there is nothing to disagree with it. + +import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work-liveness' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionBackgroundTaskState } from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' + +export const STRUCTURED_AGENT_SESSION_IDLE_SWEEP_INTERVAL_MS = 5 * 60_000 +export const STRUCTURED_AGENT_SESSION_IDLE_MS = 30 * 60_000 + +export type StructuredAgentSessionIdleSweepDeps = { + sessions: ReadonlyMap & { + lastActivityAt: (sessionId: string) => number | undefined + touch: (sessionId: string) => void + } + serialize: (sessionId: string, task: () => Promise) => Promise + now: () => number + isDisposed: () => boolean + deliveryActive: (sessionId: string) => boolean + backgroundTaskState: (sessionId: string) => AgentSessionBackgroundTaskState | null | undefined + /** An orchestration dispatch that still owns this session's worker; derived from its database. */ + hasOpenDispatch: (sessionId: string) => boolean + /** Each of these runs inside the session's serialize and never takes it again. */ + stopAgent: (sessionId: string) => Promise + stopStartingAgent: (sessionId: string) => Promise + closeConversation: (sessionId: string) => Promise + onError: (sessionId: string, error: unknown) => void + intervalMs?: number + idleMs?: number +} + +/** A prompt the user has not answered. A subagent can raise one the lead turn cannot see. */ +export function hasPendingStructuredAgentSessionPrompt( + items: readonly AgentJournalRenderItem[] +): boolean { + return items.some( + (item) => + (item.body.kind === 'approval' || item.body.kind === 'question') && + item.body.resolution.state === 'pending' + ) +} + +export class StructuredAgentSessionIdleSweep { + private timer: ReturnType | null = null + private running = false + + constructor(private readonly deps: StructuredAgentSessionIdleSweepDeps) {} + + start(): void { + this.timer = setInterval( + () => void this.tick(), + this.deps.intervalMs ?? STRUCTURED_AGENT_SESSION_IDLE_SWEEP_INTERVAL_MS + ) + // An idle sweep must never be the reason a process stays alive at quit. + this.timer.unref?.() + } + + dispose(): void { + if (this.timer) { + clearInterval(this.timer) + this.timer = null + } + } + + /** One pass over every open conversation. Sessions run concurrently, so one waiting behind a + * locked start does not hold up the rest; a pass still running skips the next. */ + async tick(): Promise { + if (this.running || this.deps.isDisposed()) { + return + } + this.running = true + try { + await Promise.allSettled( + [...this.deps.sessions.keys()].map((sessionId) => + this.deps + .serialize(sessionId, () => this.tickUnderSerialize(sessionId)) + .catch((error: unknown) => this.deps.onError(sessionId, error)) + ) + ) + } finally { + this.running = false + } + } + + /** Re-derives everything inside the session's lock, so an accept already queued ahead is seen. */ + private async tickUnderSerialize(sessionId: string): Promise { + const session = this.deps.sessions.get(sessionId) + if (!session || this.deps.isDisposed()) { + return + } + // A stop that failed after the child was proven gone: finish it now, before the idle test, so + // the rows its settlement wrote cannot push the retry out. A message accepted since goes first. + if ( + session.owesProviderChildWindDown !== undefined && + !session.child && + !this.queuedOrDelivering(sessionId, session) + ) { + await this.deps.stopAgent(sessionId) + return + } + // Owed work is activity, read every tick, so the agent gets a full window once it ends: a child + // can read done before the lead's wake-up turn writes anything. + if (session.child && session.child.phase !== 'starting' && this.owesWork(sessionId, session)) { + this.deps.sessions.touch(sessionId) + return + } + const lastActivityAt = this.deps.sessions.lastActivityAt(sessionId) ?? this.deps.now() + if (this.deps.now() - lastActivityAt < (this.deps.idleMs ?? STRUCTURED_AGENT_SESSION_IDLE_MS)) { + return + } + if (session.child) { + // A start that has been quiet this long is not coming: the host stops it, with its reason. + if (session.child.phase === 'starting') { + await this.deps.stopStartingAgent(sessionId) + return + } + await this.deps.stopAgent(sessionId) + } + await this.deps.closeConversation(sessionId) + } + + private queuedOrDelivering(sessionId: string, session: StructuredAgentSessionHostSession) { + return ( + this.deps.deliveryActive(sessionId) || + session.journal.submissions().some(isQueuedAgentJournalSubmission) + ) + } + + /** Work the running child still owes. Scoped to the child: with none, nothing here can pin the + * handle, and a leftover prompt or turn row is only history. */ + private owesWork(sessionId: string, session: StructuredAgentSessionHostSession): boolean { + const items = session.journal.snapshot().items + return ( + activeStructuredAgentSessionTurnId(items) !== null || + this.queuedOrDelivering(sessionId, session) || + agentChildWorkLiveness(this.deps.backgroundTaskState(sessionId)?.tasks) !== null || + this.deps.hasOpenDispatch(sessionId) || + hasPendingStructuredAgentSessionPrompt(items) + ) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts index b35690c4e7d..e7ce667ed2b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts @@ -6,15 +6,16 @@ import { agentJournalLinkageFields } from '../../../shared/agent-session-journal-producer' import type { JournalItemAppendOptions } from '../agent-session-journal/journal-store-contracts' -import type { StructuredAgentSessionAppendOptions } from './structured-agent-session-event-sink' +import type { StructuredAgentSessionItemAppendOptions } from './structured-agent-session-event-sink' export function structuredAgentSessionJournalAppendOptions( fence: number, - options: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ): JournalItemAppendOptions { return { fence, ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }), + turnScope: options.turnScope, ...agentJournalLinkageFields(options) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts index 1c9f271004c..f6ca0bc4a4e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Journal handle ownership across the wire layer. // // Every one of these sites is reached only when something has already gone @@ -15,11 +16,7 @@ import { journalDatabaseFile } from '../agent-session-journal/journal-paths' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' import { openAgentSessionJournalWithRecovery } from './agent-session-journal-recovery' -import { - evictStructuredAgentSession, - STRUCTURED_AGENT_SESSION_EVICTION_STEPS, - type StructuredAgentSessionEvictionContext -} from './structured-agent-session-eviction' +import { closeStructuredAgentSessionConversationUnderSerialize } from './structured-agent-session-host-lifetime' import { tearDownStructuredAgentSessionHost } from './structured-agent-session-host-teardown' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -73,29 +70,7 @@ function hostSession(journal: AgentSessionJournal): StructuredAgentSessionHostSe return { journal, params: {} as StructuredAgentSessionHostSession['params'], - fence: 1, - hasProviderChild: false, - providerChildPhase: 'ready', - acquisitionGeneration: null - } -} - -function evictionContext( - overrides: Partial -): StructuredAgentSessionEvictionContext { - return { - sessionId: SESSION, - hasProviderChild: false, - eventSink: { - drained: async () => ({ ok: true }) as const, - unbind: () => undefined, - close: () => undefined - } as unknown as StructuredAgentSessionEvictionContext['eventSink'], - adapter: {} as StructuredAgentSessionEvictionContext['adapter'], - forget: async () => undefined, - discardSink: () => undefined, - releaseLease: async () => undefined, - ...overrides + child: null } } @@ -117,7 +92,7 @@ describe('site 6: recovery rehydration', () => { await seeded.appendItem( { provider: 'codex', threadId: SESSION, turnId: 'turn-1', ordinal }, { kind: 'status', text: `seed-${ordinal}` }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } await seeded.close() @@ -140,46 +115,44 @@ describe('site 6: recovery rehydration', () => { }) }) -describe('sites 9 and 10: the delete and overwrite callbacks', () => { - it('awaits the journal close before dropping the map entry', async () => { +describe('sites 9 and 10: closing a conversation handle', () => { + it('drops the map entry before the close, and releases the handle', async () => { const journal = await journals.open({ identity: IDENTITY, journalDir }) const sessions = new Map([[SESSION, hostSession(journal)]]) const order: string[] = [] + const close = journal.close.bind(journal) + journal.close = async () => { + // A lock-free reader arriving now must find no entry, never a closing handle. + order.push(sessions.has(SESSION) ? 'close-while-indexed' : 'close-after-delete') + await close() + order.push('closed') + } - await evictStructuredAgentSession( - evictionContext({ - forget: async () => { - order.push('close-started') - await sessions.get(SESSION)?.journal.close() - order.push('closed') - sessions.delete(SESSION) - order.push('forgotten') - } - }), - STRUCTURED_AGENT_SESSION_EVICTION_STEPS - ) + await expect( + closeStructuredAgentSessionConversationUnderSerialize( + { sessions, closeStatus: () => order.push('status') }, + SESSION + ) + ).resolves.toBe(true) - expect(order).toEqual(['close-started', 'closed', 'forgotten']) + expect(order).toEqual(['status', 'close-after-delete', 'closed']) expect(sessions.size).toBe(0) await expectNothingHoldsTheDirectory(journalDir) }) - it('aborts the eviction with the session still indexed when the close rejects', async () => { + it('surfaces a rejected close to its caller', async () => { const journal = await journals.open({ identity: IDENTITY, journalDir }) const sessions = new Map([[SESSION, hostSession(journal)]]) + const close = journal.close.bind(journal) + journal.close = () => Promise.reject(new Error('close rejected')) await expect( - evictStructuredAgentSession( - evictionContext({ - forget: async () => { - await Promise.reject(new Error('close rejected')) - } - }), - STRUCTURED_AGENT_SESSION_EVICTION_STEPS + closeStructuredAgentSessionConversationUnderSerialize( + { sessions, closeStatus: () => undefined }, + SESSION ) - ).rejects.toMatchObject({ step: 'forget-session' }) - // Still indexed, so the next close is a real retry. - expect(sessions.has(SESSION)).toBe(true) + ).rejects.toThrow('close rejected') + journal.close = close }) }) @@ -242,9 +215,13 @@ describe('site 11: host teardown is failure-complete', () => { const failing = sessions.get(SESSION) const closeError = new Error('close rejected') if (failing) { - failing.journal = { - close: () => Promise.reject(closeError) - } as unknown as AgentSessionJournal + sessions.set(SESSION, { + ...failing, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: teardown calls only `close`, and this map is a plain `Map` that binds no delivery. + journal: { + close: () => Promise.reject(closeError) + } as unknown as AgentSessionJournal + }) } await expect( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index 1874f7ee1c6..8a43b1a8eff 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -24,6 +25,8 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' const CALLER = { callerKey: 'client-1' } @@ -60,8 +63,8 @@ function sendParams(text: string): { } } -function submissions(): unknown { - const state = host.history({ sessionId: SESSION, direction: 'tail' }) +async function submissions(): Promise { + const state = await host.history({ sessionId: SESSION, direction: 'tail' }) return state.ok ? state.page.submissions : null } @@ -71,6 +74,18 @@ function journal(): AgentSessionJournal { ).sessions.get(SESSION)!.journal } +/** A send is accepted first; this waits for the delivery loop to hand it to the provider. */ +async function handedOver(clientMessageId: string): Promise { + await vi.waitFor(async () => { + expect( + journal() + .submissions() + .find((entry) => entry.clientMessageId === clientMessageId)?.handedOverAt + ).toBeDefined() + expect(dispatch).toHaveBeenCalled() + }) +} + beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-wire-late-settle-')) resetHostTestOperationIds() @@ -126,14 +141,14 @@ describe('settling a send the provider proves it received after the ack window', }) ) const events: AgentSessionSubscribeEvent[] = [] - const unsubscribe = host.subscribe({ + const unsubscribe = await host.subscribe({ id: 'late-receipt', sessionId: SESSION, emit: (event) => events.push(event) }) const params = sendParams('echo before send completes') const pending = host.send(CALLER, params) - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) + await vi.waitFor(async () => expect(dispatch).toHaveBeenCalledTimes(1)) try { await host.settleLateDispatch({ sessionId: SESSION, @@ -152,10 +167,13 @@ describe('settling a send the provider proves it received after the ack window', finishDispatch({ state: 'unknown', reason: 'ack timeout' }) unsubscribe() } - await expect(pending).resolves.toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'accepted' } } - }) + await expect(pending).resolves.toMatchObject({ ok: true }) + // The late `unknown` from the handover does not reopen the proven acceptance. + await vi.waitFor(async () => + expect(await submissions()).toMatchObject([ + { clientMessageId: params.envelope.clientOperationId, dispatchState: 'accepted' } + ]) + ) await expect(host.send(CALLER, { ...params, retryUnknown: true })).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'accepted' } } @@ -167,6 +185,10 @@ describe('settling a send the provider proves it received after the ack window', dispatch.mockResolvedValueOnce({ state: 'unknown', reason: 'ack timeout' }) const params = sendParams('received just before shutdown') await host.send(CALLER, params) + await handedOver(params.envelope.clientOperationId) + await vi.waitFor(async () => + expect(await submissions()).toMatchObject([{ dispatchState: 'unknown' }]) + ) let settlement: Promise | undefined closeSession.mockImplementationOnce(async () => { settlement = host.settleLateDispatch({ @@ -181,15 +203,18 @@ describe('settling a send the provider proves it received after the ack window', await host.close(SESSION) await expect(settlement).resolves.toBeUndefined() await host.revealSession(SESSION) - expect(submissions()).toMatchObject([{ dispatchState: 'accepted' }]) + expect(await submissions()).toMatchObject([{ dispatchState: 'accepted' }]) expect(dispatch).toHaveBeenCalledTimes(1) }) it('moves a durable unknown to accepted so nothing offers to send it again', async () => { dispatch.mockRejectedValueOnce(new Error('socket closed')) const params = sendParams('sent while a turn was running') - const first = await host.send(CALLER, params) - expect(first).toMatchObject({ ok: true, value: { submission: { dispatchState: 'unknown' } } }) + await host.send(CALLER, params) + await handedOver(params.envelope.clientOperationId) + await vi.waitFor(async () => + expect(await submissions()).toMatchObject([{ dispatchState: 'unknown' }]) + ) await host.settleLateDispatch({ sessionId: SESSION, @@ -197,7 +222,7 @@ describe('settling a send the provider proves it received after the ack window', providerIdentity: { provider: 'claude', sessionId: THREAD, uuid: 'late-uuid' } }) - expect(submissions()).toMatchObject([ + expect(await submissions()).toMatchObject([ { clientMessageId: params.envelope.clientOperationId, dispatchState: 'accepted' } ]) // The point of the fix: the client stops rendering Retry, and Retry is what @@ -209,19 +234,21 @@ describe('settling a send the provider proves it received after the ack window', dispatch.mockResolvedValueOnce({ state: 'admitted' }) const params = sendParams('queued behind the active turn') await host.send(CALLER, params) + await handedOver(params.envelope.clientOperationId) await host.settleLateDispatch({ sessionId: SESSION, clientMessageId: params.envelope.clientOperationId, state: 'rejected', - reason: DISPATCH_REJECTED_CANCELLED + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) }) - expect(submissions()).toMatchObject([ + expect(await submissions()).toMatchObject([ { clientMessageId: params.envelope.clientOperationId, dispatchState: 'rejected', - reason: DISPATCH_REJECTED_CANCELLED + reason: DISPATCH_REJECTED_CANCELLED, + rejection: { kind: 'cancelled' } } ]) }) @@ -230,6 +257,7 @@ describe('settling a send the provider proves it received after the ack window', dispatch.mockResolvedValueOnce({ state: 'admitted' }) const params = sendParams('settle from provider echo') await host.send(CALLER, params) + await handedOver(params.envelope.clientOperationId) vi.spyOn(journal(), 'resolveDispatch').mockRejectedValueOnce( new Error('direct settlement write failed') ) @@ -244,10 +272,13 @@ describe('settling a send the provider proves it received after the ack window', await journal().appendItem( { provider: 'claude', sessionId: THREAD, uuid: 'echo-row' }, params.body, - { fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1 } + { + fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } ) - expect(submissions()).toMatchObject([ + expect(await submissions()).toMatchObject([ { clientMessageId: params.envelope.clientOperationId, dispatchState: 'accepted', @@ -259,6 +290,9 @@ describe('settling a send the provider proves it received after the ack window', it('leaves an already accepted send alone', async () => { const params = sendParams('ordinary send') await host.send(CALLER, params) + await vi.waitFor(async () => + expect(await submissions()).toMatchObject([{ dispatchState: 'accepted' }]) + ) await host.settleLateDispatch({ sessionId: SESSION, @@ -266,7 +300,7 @@ describe('settling a send the provider proves it received after the ack window', providerIdentity: { provider: 'claude', sessionId: THREAD, uuid: 'a-different-uuid' } }) - expect(submissions()).toMatchObject([ + expect(await submissions()).toMatchObject([ { clientMessageId: params.envelope.clientOperationId, dispatchState: 'accepted' } ]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts index 300f1423cb3..cd26b96e5cd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts @@ -57,12 +57,19 @@ async function launchAndDeliver(): Promise<{ text: 'fix the failing test' }) const sent = await send.mock.results[0]!.value - return { - messageId, - dispatchState: sent.ok - ? sent.value.submission.dispatchState - : `refused:${sent.refusal.code}:${sent.refusal.message}` + if (!sent.ok) { + return { messageId, dispatchState: `refused:${sent.refusal.code}:${sent.refusal.message}` } } + // Accepted first; the delivery loop hands it over, and that outcome is what reached the agent. + let dispatchState = sent.value.submission.dispatchState + await vi.waitFor(async () => { + dispatchState = + (await host.journalSnapshot(created.value.sessionId)).submissions.find( + (entry) => entry.clientMessageId === sent.value.clientMessageId + )?.dispatchState ?? 'missing' + expect(dispatchState).not.toBe('pending') + }) + return { messageId, dispatchState } } beforeEach(() => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-release.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-release.ts index bacc56fbcaf..fe584a50fce 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-release.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-release.ts @@ -51,7 +51,7 @@ export async function releaseStoredStructuredAgentSessionOwnerAfterUnexpectedExi acquisitionGeneration: string | null now: number exitObservedAt?: number - settlementRetry?: { settlementId: string; detail: string } + exitReason?: string }): Promise { if (input.acquisitionGeneration !== input.expectedAcquisitionGeneration) { throw new Error('agent_session_checkpoint_stale') @@ -69,6 +69,6 @@ export async function releaseStoredStructuredAgentSessionOwnerAfterUnexpectedExi expectedFence: input.expectedFence, now: input.now, exitObservedAt: input.exitObservedAt, - ...(input.settlementRetry ? { settlementRetry: input.settlementRetry } : {}) + ...(input.exitReason ? { exitReason: input.exitReason } : {}) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts index f969e3c0bbf..9545e42b051 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts @@ -190,11 +190,36 @@ describe('structured agent-session lease renewal', () => { { timeout: 5000 } ) } finally { - renewer.stop() + await renewer.stop() vi.useRealTimers() } }) + it('stops only once a renewal already in flight has finished writing', async () => { + const store = await liveStore() + let releaseProbe = (): void => {} + const probing = new Promise((resolve) => { + releaseProbe = resolve + }) + const renewer = new StructuredAgentSessionLeaseRenewer({ + store, + probe: async () => { + await probing + return { outcome: 'identity-matched', matchedOn: ['process-start-time'] } + }, + now: () => NOW + 10_000 + }) + + void renewer.renewNow() + // Nothing has been written yet: the tick is parked in its probe. + expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(NOW) + const stopped = renewer.stop() + releaseProbe() + await stopped + + expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(NOW + 10_000) + }) + it('renews every live owner only after re-proving its child identity', async () => { const store = await liveStore() const probe = vi.fn(async () => ({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts index f1e5ca98305..a61e11b6f4f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts @@ -10,6 +10,9 @@ const RENEW_INTERVAL_MS = Math.floor(AGENT_SESSION_LEASE_TTL_MS / 3) export class StructuredAgentSessionLeaseRenewer { private timer: ReturnType | null = null private running = false + /** The tick in flight. Never rejects: the timer path reports renewal failures through `onError`, + * and stopping must not turn one into a teardown failure as well. */ + private inFlight: Promise = Promise.resolve() constructor( private readonly input: { @@ -32,71 +35,81 @@ export class StructuredAgentSessionLeaseRenewer { this.timer.unref?.() } - stop(): void { + /** Clearing the interval only stops the NEXT tick. A tick already past its guard still has a + * store transaction to commit, and that transaction re-creates the store directory, so a stop + * that returned before it landed would let the write outlive whatever tore the host down. */ + stop(): Promise { if (this.timer) { clearInterval(this.timer) this.timer = null } + return this.inFlight } - async renewNow(): Promise { + renewNow(): Promise { if (this.running) { - return + return this.inFlight } this.running = true - try { - const records = this.input.store.listRecords().filter( - (record) => - !record.lease.unreconciled && - record.lease.claimStatus === 'live' && - record.lease.ownerProcess !== null && - // A record parked in recovery has no transport the host can vouch for; renewing it - // keeps an orphan pid's lease reading as a healthy owner. - record.lease.handoffStage !== 'recovering' && - record.lease.handoffStage !== 'manual-recovery' - ) - const probes = await this.probe(records) - const renewals: { - sessionId: string - fence: number - childProbe: AgentSessionOwnerProbe - now: number - }[] = [] - const now = this.input.now() - for (const record of records) { - const probe = probes.get(record.sessionId) - if (!probe) { - continue - } - renewals.push({ - sessionId: record.sessionId, - fence: record.lease.runtimeFence, - childProbe: probe, - now - }) - } - // The store persists the whole record file per transaction, so keep the healthy path to - // one commit. If one renewal is superseded, retrying individually preserves isolation. - let results: PromiseSettledResult[] - try { - const renewed = await this.input.store.renewLeases(renewals) - results = renewed.map((record) => ({ status: 'fulfilled', value: record }) as const) - } catch { - results = await Promise.allSettled( - renewals.map((renewal) => this.input.store.renewLease(renewal)) - ) - } - results.forEach((result, index) => { - if (result.status === 'rejected') { - const renewal = renewals[index] - if (renewal) { - this.input.onError?.({ sessionId: renewal.sessionId, error: result.reason }) - } - } - }) - } finally { + const attempt = this.renewOnce().finally(() => { this.running = false + }) + this.inFlight = attempt.then( + () => undefined, + () => undefined + ) + return attempt + } + + private async renewOnce(): Promise { + const records = this.input.store.listRecords().filter( + (record) => + !record.lease.unreconciled && + record.lease.claimStatus === 'live' && + record.lease.ownerProcess !== null && + // A record parked in recovery has no transport the host can vouch for; renewing it + // keeps an orphan pid's lease reading as a healthy owner. + record.lease.handoffStage !== 'recovering' + ) + const probes = await this.probe(records) + const renewals: { + sessionId: string + fence: number + childProbe: AgentSessionOwnerProbe + now: number + }[] = [] + const now = this.input.now() + for (const record of records) { + const probe = probes.get(record.sessionId) + if (!probe) { + continue + } + renewals.push({ + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + childProbe: probe, + now + }) } + // The store persists the whole record file per transaction, so keep the healthy path to + // one commit. If one renewal is superseded, retrying individually preserves isolation. + let results: PromiseSettledResult[] + try { + const renewed = await this.input.store.renewLeases(renewals) + results = renewed.map((record) => ({ status: 'fulfilled', value: record }) as const) + } catch { + results = await Promise.allSettled( + renewals.map((renewal) => this.input.store.renewLease(renewal)) + ) + } + results.forEach((result, index) => { + if (result.status === 'rejected') { + const renewal = renewals[index] + if (renewal) { + this.input.onError?.({ sessionId: renewal.sessionId, error: result.reason }) + } + } + }) } private async probe( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts index 42ffe172cd7..213126fb5eb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts @@ -25,12 +25,18 @@ import { const CALLER = { callerKey: 'client-1' } +/** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ +function eventually(assertion: () => void | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let acquire: Mock let probe: Mock<() => Promise> let stopOwnerProcess: Mock<(pid: number, signal: 'SIGTERM' | 'SIGKILL') => void> +let dispatch: Mock function openHost(): void { host = new StructuredAgentSessionHost({ @@ -39,7 +45,7 @@ function openHost(): void { acquire, closeSession: vi.fn(async () => true), releaseAcquisition: vi.fn(async () => true), - dispatch: vi.fn(async () => ({ state: 'admitted' as const })), + dispatch, cancelTurn: vi.fn(async () => ({ cancelled: false })), answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) @@ -54,7 +60,13 @@ function openHost(): void { } /** Writes the lease an older build left behind, then starts a fresh app generation over it. */ -async function persistFromOlderBuild(lease: Partial): Promise { +/** Older builds also wrote the retired settlement latch fields. */ +type OlderBuildLease = Partial & { + settlementRetryRequired?: boolean + settlementRetryId?: string +} + +async function persistFromOlderBuild(lease: OlderBuildLease): Promise { expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) const attached = store.getRecord(SESSION)?.lease await host.flushAllStreamedEvents() @@ -65,6 +77,23 @@ async function persistFromOlderBuild(lease: Partial) openHost() } +/** A send is accepted at once; what became of it is the submission's state once the host's + * delivery settles it — handed over, or rejected with the reason the chat shows. */ +async function delivered(text: string) { + const sent = await send(text) + expect(sent).toMatchObject({ ok: true }) + const clientMessageId = sent.ok ? sent.value.clientMessageId : '' + const submission = async () => + (await host.journalSnapshot(SESSION)).submissions.find( + (candidate) => candidate.clientMessageId === clientMessageId + ) + await eventually(async () => { + const current = await submission() + expect(current?.dispatchState !== 'pending' || current?.handedOverAt !== undefined).toBe(true) + }) + return submission() +} + async function send(text: string) { const body = hostTestMessage(text) return host.send(CALLER, { @@ -87,6 +116,7 @@ beforeEach(async () => { resetHostTestOperationIds() probe = vi.fn(async () => ({ outcome: 'pid-absent' as const })) stopOwnerProcess = vi.fn() + dispatch = vi.fn(async () => ({ state: 'admitted' as const })) acquire = vi.fn(async ({ fence, spawnToken }) => ({ process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW - 1_000, spawnToken }, link: { @@ -134,10 +164,11 @@ describe('a record an older build left mid terminal handoff', () => { expect(store.getRecord(SESSION)?.lease).toMatchObject({ handoffStage: null, - handoffOperationId: null, - settlementRetryRequired: undefined + handoffOperationId: null }) - expect(await send('after the upgrade')).toMatchObject({ ok: true }) + expect(store.getRecord(SESSION)?.lease).not.toHaveProperty('settlementRetryRequired') + expect(await delivered('after the upgrade')).toMatchObject({ dispatchState: 'pending' }) + expect(dispatch).toHaveBeenCalledOnce() expect(acquire).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ runtimeKind: 'native', @@ -169,7 +200,8 @@ describe('a record an older build left mid terminal handoff', () => { handoffStage: null, handoffOperationId: null }) - expect(await send('after the upgrade')).toMatchObject({ ok: true }) + expect(await delivered('after the upgrade')).toMatchObject({ dispatchState: 'pending' }) + expect(dispatch).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live' }) }) @@ -188,7 +220,8 @@ describe('a record an older build left mid terminal handoff', () => { handoffStage: null, claimStatus: 'released' }) - expect(await send('after the upgrade')).toMatchObject({ ok: true }) + expect(await delivered('after the upgrade')).toMatchObject({ dispatchState: 'pending' }) + expect(dispatch).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ runtimeKind: 'native', claimStatus: 'live' @@ -208,21 +241,40 @@ describe('a record an older build left mid terminal handoff', () => { expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'conflicted', - handoffStage: 'manual-recovery' + handoffStage: 'recovering' }) - expect(await send('while the terminal still runs')).toMatchObject({ + // Sending and opening the chat both say what frees it: quitting that terminal agent. A send is + // accepted, then rejected by the start that cannot take the lease, and the chat's row says why, + // worded from the refusal's details; only the live refusal names the process. + const quitTerminal = + 'This chat is still open in a terminal agent (process 4242). Quit that agent to continue the chat here.' + expect(await delivered('while the terminal still runs')).toMatchObject({ + dispatchState: 'rejected' + }) + expect( + (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] + ) + ).toEqual([ + "Codex couldn't restart. This chat is still open in a terminal agent. Quit that agent to continue the chat here." + ]) + const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null + expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: false, - refusal: { code: 'agent_session_conflict' } + refusal: { code: 'agent_session_conflict', message: quitTerminal } }) + expect(dispatch).not.toHaveBeenCalled() expect(stopOwnerProcess).not.toHaveBeenCalled() expect(acquire).not.toHaveBeenCalled() - // The user closes the terminal; the next open proves it gone and the chat takes over. + // The user closes the terminal; the next send's start proves it gone and the chat takes over. probe.mockResolvedValue({ outcome: 'pid-absent' }) - await host.hold(SESSION, 'surface-1') expect(stopOwnerProcess).not.toHaveBeenCalled() - expect(await send('after the terminal closed')).toMatchObject({ ok: true }) + expect(await delivered('after the terminal closed')).toMatchObject({ + dispatchState: 'pending' + }) + expect(dispatch).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ runtimeKind: 'native', claimStatus: 'live', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts new file mode 100644 index 00000000000..95861c81f29 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts @@ -0,0 +1,232 @@ +// The chat's own Stop and every session list read whether the main agent is working through one +// rule, but over two copies of the host's journal: the chat reduces its stream, and a list reads the +// status feed. Both leave the host from one publication edge, so once each has landed they must say +// the same thing. Against the real host, store, journal, stream and status feed. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionBackgroundTaskState } from '../../../shared/agent-session-background-task-wire' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' +import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + reduceStructuredAgentSession, + type StructuredAgentSessionState +} from '../../../shared/structured-agent-session-reducer' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } +const PROVIDER_ROW = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1' } + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let dispatch: Mock +let events: StructuredAgentSessionEventSink | undefined +let backgroundTasks: AgentSessionBackgroundTaskState | null +let chat: StructuredAgentSessionState +let listed: AgentSessionStatusSummary | undefined + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-main-agent-working-')) + resetHostTestOperationIds() + events = undefined + backgroundTasks = null + chat = EMPTY_STRUCTURED_AGENT_SESSION + listed = undefined + // Written, and the provider has neither opened a turn for it nor answered it. + dispatch = vi.fn(async () => ({ state: 'admitted' as const })) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire: async (input) => { + events = input.events + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: input.spawnToken + }, + acquisitionGeneration: 'generation-1', + link: { + linkId: `link-${input.fence}`, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: 'created' as const, + mintedAtFence: input.fence, + observedAt: NOW + } + } + }, + dispatch, + awaitStarted: vi.fn(async () => undefined), + closeSession: vi.fn(async () => true), + releaseAcquisition: vi.fn(async () => true), + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined), + backgroundTaskState: () => backgroundTasks + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-1', + now: () => NOW + }) + expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + host.subscribe({ + id: 'chat-1', + sessionId: SESSION, + emit: (event) => { + chat = reduceStructuredAgentSession(chat, { type: 'event', event: structuredClone(event) }) + } + }) + host.subscribeStatus({ + id: 'list-1', + emit: (event) => { + if (event.type === 'status' && event.session.sessionId === SESSION) { + listed = structuredClone(event.session) + } else if (event.type === 'snapshot') { + listed = event.sessions.find((session) => session.sessionId === SESSION) ?? listed + } + } + }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const clientOperationId = hostTestOperationId() + expect( + await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId, + expectedRuntimeFence: 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + ).toMatchObject({ ok: true }) + return clientOperationId +} + +async function provider(ordinal: number, body: AgentJournalItemBody): Promise { + events!.appendItem({ ...PROVIDER_ROW, ordinal }, body, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + events!.publish() + await host.flushStreamedEvents(SESSION) +} + +/** What the chat's Stop reads: the transport state's rule over the chat's reduced stream. */ +function chatReadsWorking(): boolean { + return isStructuredAgentSessionMainAgentWorking( + activeStructuredAgentSessionTurnId(chat.items), + chat.submissions, + chat.fence + ) +} + +/** What a session list's row reads for the main agent, and the whole row, from the status feed. */ +function listReads(): { mainAgent: string; row: string } { + const summary = listed + if (!summary?.status) { + return { mainAgent: 'none', row: 'none' } + } + const status = structuredAgentSessionAgentStatus({ ...summary, status: summary.status }) + return { mainAgent: status.mainAgent.state, row: status.state } +} + +describe('the main agent read by the chat and by a session list', () => { + it('agrees while a rate-limited request retries with no turn open', async () => { + const id = await send('hello') + await vi.waitFor(async () => + expect((await host.journalSnapshot(SESSION)).submissions[0]?.handedOverAt).toBeDefined() + ) + // What Claude writes for an HTTP 429 retry: a status row, no echo, no turn. + await provider(1, { kind: 'status', text: 'rate_limit', tone: 'error' }) + await provider(2, { kind: 'status', text: 'rate_limit', tone: 'error' }) + + expect(chat.submissions.map((submission) => submission.clientMessageId)).toEqual([id]) + expect(activeStructuredAgentSessionTurnId(chat.items)).toBeNull() + expect(chatReadsWorking()).toBe(true) + expect(listReads().mainAgent).toBe('working') + }) + + it('agrees once the handed-over message is answered and only a subagent still runs', async () => { + dispatch.mockResolvedValueOnce({ + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 0 } + }) + await send('fan out') + await vi.waitFor(async () => + expect((await host.journalSnapshot(SESSION)).submissions[0]?.dispatchState).toBe('accepted') + ) + await provider(1, { kind: 'turn', turnId: 'turn-1', state: 'running' }) + expect(chatReadsWorking()).toBe(true) + expect(listReads().mainAgent).toBe('working') + + await provider(1, { kind: 'turn', turnId: 'turn-1', state: 'completed', outcome: 'success' }) + backgroundTasks = { + state: 'monitoring', + tasks: [{ id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working' }] + } + host.publishBackgroundTaskState(SESSION) + await host.flushStreamedEvents(SESSION) + + expect(chat.backgroundTasks?.tasks).toHaveLength(1) + // The row reads Working for the subagent; the main agent, and so Stop, does not. + expect(listReads()).toEqual({ mainAgent: 'done', row: 'working' }) + expect(chatReadsWorking()).toBe(false) + }) + + it('agrees when the child that was handed the message exits', async () => { + await send('hello') + await vi.waitFor(async () => + expect((await host.journalSnapshot(SESSION)).submissions[0]?.handedOverAt).toBeDefined() + ) + expect(chatReadsWorking()).toBe(true) + + await host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: 'generation-1', + reason: 'codex app-server crashed', + cause: 'unexpected-exit' + }) + await host.flushStreamedEvents(SESSION) + + expect(chat.fence).toBe(store.getRecord(SESSION)!.lease.runtimeFence) + expect(chatReadsWorking()).toBe(false) + expect(listReads().mainAgent).not.toBe('working') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index 04001450621..6a623428e4f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -5,8 +5,8 @@ // // Admission is two-phase for a call that brings a `prepareSession`. The ledger's // answer comes first and places nothing; a call it will admit may then give the -// session an owner, and only after that are the row placed and the lease and -// fence checked — against the lease as it stands once the owner is there. +// session an owner, and only after that are the row placed and the lease +// checked — against the lease as it stands once the owner is there. import { admitAgentSessionMutation, @@ -15,10 +15,11 @@ import { } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionOperationDecision } from '../../../shared/agent-session-operation-ledger' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { - AgentSessionMutationEnvelope, - AgentSessionMutationResult, - AgentSessionWireRefusal +import { + refuse, + type AgentSessionMutationEnvelope, + type AgentSessionMutationResult, + type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { AGENT_SESSION_UNATTACHED_REFUSAL_CODE } from '../../../shared/structured-agent-session-read-refusal' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' @@ -31,10 +32,11 @@ import type { AgentSessionTurnContext } from './structured-agent-session-turns' // The code is shared with the client so a read that refuses this way can be told apart from a // transcript that failed to load; the two must never drift apart. -export const AGENT_SESSION_NOT_ATTACHED: AgentSessionWireRefusal = { - code: AGENT_SESSION_UNATTACHED_REFUSAL_CODE, - message: 'This host holds no attached session by that id.' -} +export const AGENT_SESSION_NOT_ATTACHED: AgentSessionWireRefusal = refuse( + AGENT_SESSION_UNATTACHED_REFUSAL_CODE, + { reason: 'sessionNotAttached' }, + 'This host holds no attached session by that id.' +) export function refuseAgentSessionMutation(refusal: AgentSessionWireRefusal): { ok: false @@ -44,7 +46,7 @@ export function refuseAgentSessionMutation(refusal: AgentSessionWireRefusal): { } export type AgentSessionMutationSessionPreparation = - | { ok: true; envelope: AgentSessionMutationEnvelope } + | { ok: true } | { ok: false; refusal: AgentSessionWireRefusal } export type AgentSessionMutationRequest = { @@ -56,8 +58,7 @@ export type AgentSessionMutationRequest = { /** Journal of the attached session, read after `prepareSession`; absent when this host holds none. */ journal: () => AgentSessionJournal | undefined /** Between the ledger's answer and the lease check, for a call that may first have to make the - * session ready for itself. Answers with the envelope to admit — the caller's, or one moved - * onto a fence the preparation itself published — or with the refusal that ends the call. */ + * session ready for itself. Answers with the refusal that ends the call, if any. */ prepareSession?: ( ledger: Exclude, record: AgentSessionRecord @@ -71,8 +72,7 @@ export type AgentSessionMutationRequest = { export async function admitAndRunAgentSessionMutation( request: AgentSessionMutationRequest ): Promise> { - const { plan } = request - let { envelope } = request + const { plan, envelope } = request const hostFingerprint = computeAgentSessionPayloadFingerprint({ method: plan.method, sessionId: envelope.sessionId, @@ -98,7 +98,6 @@ export async function admitAndRunAgentSessionMutation( if (!prepared.ok) { return prepared } - envelope = prepared.envelope } } const journal = request.journal() @@ -110,7 +109,8 @@ export async function admitAndRunAgentSessionMutation( envelope, hostFingerprint, now: request.now(), - ...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {}) + ...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {}), + ...(plan.conversationWrite ? { conversationWrite: true } : {}) }) if (!admitted) { return refuseAgentSessionMutation(AGENT_SESSION_NOT_ATTACHED) @@ -137,14 +137,15 @@ export async function admitAndRunAgentSessionMutation( return { ok: true, replayed: true, fence, cursor: journal.cursor(), value: replay.value } } // Nothing durable landed, so this id is about to run for the first time. A - // refused call leaves its ledger row behind, and replaying past the lease and - // the fence would let a resend act under an owner that has since changed — so - // a first run pays the full admission price either way. + // refused call leaves its ledger row behind, and replaying past the lease + // would let a resend act with no live owner — so a first run pays the full + // admission price either way. const rerun = admitAgentSessionMutation({ envelope, hostFingerprint, ledger: { decision: 'admit', row: admission.row }, - lease: record.lease + lease: record.lease, + ...(plan.conversationWrite ? { conversationWrite: true } : {}) }) if (rerun.decision === 'refused') { return refuseAgentSessionMutation(rerun.refusal) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index d8d2876d272..388fabd7bd4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -14,7 +14,13 @@ import type { AgentSessionPromptResult, AgentSessionSendResult } from '../../../shared/agent-session-wire' +import type { AgentSessionConversationCommandResult } from '../../../shared/agent-session-conversation-command' import { DISPATCH_DOUBT_SUBMISSION_MISSING } from '../agent-session-journal/journal-dispatch-doubt-reasons' +import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { + STRUCTURED_AGENT_SESSION_COMPACT_COMMAND, + structuredAgentSessionCompactBody +} from './structured-agent-session-command-turn' import { performCancel, performPrompt, @@ -23,13 +29,26 @@ import { type AgentSessionTurnContext, type TurnOutcome } from './structured-agent-session-turns' +import type { AgentSessionPromptRequest } from './structured-agent-session-turns-prompt' +import { queuedSendAnswer } from './structured-agent-session-queued-send-answer' + +/** The body-only hash: what the reducer recomputes to alias a provider echo + * onto its submission, so the stored value must never include control fields. */ +function sendBodyFingerprint(sessionId: string, body: AgentJournalMessageItem): string { + return structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId, + fields: { body } + }) +} export type MutationPlan = { method: string fields: Record operationIdScope?: 'global' + /** Admitted without the writer lease: see `admitAgentSessionMutation`. */ + conversationWrite?: true markUnknownBeforeRun?: boolean - beforeRun?: () => void run: (ctx: AgentSessionTurnContext) => Promise> replay: (ctx: AgentSessionTurnContext, outcome: AgentSessionOperationOutcome) => TValue | null rerunWhenReplayMissing?: (ctx: AgentSessionTurnContext) => boolean @@ -41,6 +60,8 @@ export function sendPlan(params: { envelope: AgentSessionMutationEnvelope body: AgentJournalMessageItem retryUnknown?: true + delivery?: 'queue-if-active' + userSend?: true beforeRun?: () => void }): MutationPlan { // The operation id IS the client message id: one send, one durable row, one @@ -49,20 +70,31 @@ export function sendPlan(params: { return { method: 'agentSession.send', operationIdScope: 'global', + conversationWrite: true, markUnknownBeforeRun: true, - // A control signal is not payload; it cannot alter durable replay. - fields: { body: params.body }, - ...(params.beforeRun ? { beforeRun: params.beforeRun } : {}), + // `delivery` joins the OPERATION fingerprint only; the submission row keeps + // the body-only fingerprint the reducer's echo-aliasing recomputes. + fields: { body: params.body, ...(params.delivery ? { delivery: params.delivery } : {}) }, recoverUnknownFromDurableState: true, // `retryUnknown` is a compatibility-only client signal. A recorded send // always replays and never reaches the provider twice. - run: (ctx) => - performSend(ctx, { + run: (ctx) => { + // Asked at acceptance: a send accepted after this one is queued behind it. + params.beforeRun?.() + return performSend(ctx, { + origin: params.userSend ? 'client' : 'host', clientMessageId, - payloadFingerprint: params.envelope.payloadFingerprint, + payloadFingerprint: sendBodyFingerprint(params.envelope.sessionId, params.body), body: params.body - }), + }) + }, replay: (ctx, outcome) => { + // A send this host queued answers from its draft, then its hand-off; a + // withdrawn draft replays as spent — never as missing-submission doubt. + const queued = queuedSendAnswer(ctx.journal, clientMessageId) + if (queued) { + return queued + } const submission = ctx.journal .submissions() .find((entry) => entry.clientMessageId === clientMessageId) @@ -91,17 +123,61 @@ export function sendPlan(params: { } } +export type ConversationCommandAcceptance = + | { clientMessageId: string } + /** What an older build's run of this operation recorded. */ + | { recorded: AgentSessionConversationCommandResult } + +/** `/compact` accepted like a send: one submission, keyed by the operation id, that the delivery + * loop carries out as the command's own turn. */ +export function conversationCommandPlan(params: { + envelope: AgentSessionMutationEnvelope + priorRecord: () => AgentSessionConversationCommandResult | null +}): MutationPlan { + const clientMessageId = params.envelope.clientOperationId + return { + method: 'agentSession.conversationCommand', + conversationWrite: true, + markUnknownBeforeRun: true, + fields: { command: STRUCTURED_AGENT_SESSION_COMPACT_COMMAND }, + recoverUnknownFromDurableState: true, + run: async (ctx) => { + const sent = await performSend(ctx, { + clientMessageId, + // Only a client asks through the command RPC: the person's own turn. + origin: 'client', + payloadFingerprint: params.envelope.payloadFingerprint, + body: structuredAgentSessionCompactBody() + }) + return sent.ok ? { ok: true, value: { clientMessageId } } : sent + }, + replay: (ctx, outcome) => { + if (outcome.status === 'succeeded' && outcome.conversationCommand) { + return { recorded: outcome.conversationCommand } + } + if (ctx.journal.submissions().some((entry) => entry.clientMessageId === clientMessageId)) { + return { clientMessageId } + } + const prior = params.priorRecord() + return prior ? { recorded: prior } : null + } + } +} + export function cancelPlan(params: { envelope: AgentSessionMutationEnvelope - turnId: string + turnId?: string scope?: 'background-tasks' taskId?: string prompt?: { itemId: string; expectedRevision: number } + stopChild?: () => Promise }): MutationPlan { return { method: 'agentSession.cancel', + // Stop is a conversation write; a prompt or background-task cancel needs the live child. + ...(params.scope || params.prompt ? {} : { conversationWrite: true as const }), fields: { - turnId: params.turnId, + ...(params.turnId !== undefined ? { turnId: params.turnId } : {}), ...(params.scope ? { scope: params.scope } : {}), ...(params.taskId ? { taskId: params.taskId } : {}), ...(params.prompt ? { prompt: params.prompt } : {}) @@ -109,29 +185,32 @@ export function cancelPlan(params: { run: (ctx) => performCancel(ctx, { clientOperationId: params.envelope.clientOperationId, - turnId: params.turnId, + ...(params.turnId !== undefined ? { turnId: params.turnId } : {}), ...(params.scope ? { scope: params.scope } : {}), ...(params.taskId ? { taskId: params.taskId } : {}), - ...(params.prompt ? { prompt: params.prompt } : {}) + ...(params.prompt ? { prompt: params.prompt } : {}), + ...(params.stopChild ? { stopChild: params.stopChild } : {}) }), // Interrupting twice would kill a turn the client never asked to stop, so a - // replay reports the turn as already handled instead. - replay: () => ({ turnId: params.turnId, cancelled: false }) + // replay reports the turn as already handled. + replay: () => ({ + ...(params.turnId !== undefined ? { turnId: params.turnId } : {}), + cancelled: false + }) } } -export function promptPlan(params: { - kind: 'approval' | 'question' - itemId: string - expectedRevision: number - optionId: string -}): MutationPlan { +export function promptPlan( + params: AgentSessionPromptRequest +): MutationPlan { return { method: `agentSession.respondTo:${params.kind}`, + // The client hashes exactly what it sent; the absent one of these two drops out of the digest. fields: { itemId: params.itemId, expectedRevision: params.expectedRevision, - optionId: params.optionId + optionId: params.optionId, + answers: params.answers }, run: (ctx) => performPrompt(ctx, params), replay: (ctx) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts index b1eb5704f01..094ddffdcb2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts @@ -2,7 +2,6 @@ import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' import { AgentSessionPreDispatchError, - AGENT_SESSION_ADMISSION_BARRIER_TIMEOUT_MS, runSettledAgentSessionMutation } from './structured-agent-session-operation-settlement' import { @@ -42,6 +41,9 @@ async function context(): Promise { } } +/** Longer than any bookkeeping bound: a refusal must already be answered by then. */ +const SETTLED_WAIT_MS = 2_000 + afterEach(() => { vi.useRealTimers() vi.restoreAllMocks() @@ -80,7 +82,7 @@ it('returns a pre-dispatch refusal without waiting on redundant uncertainty pers }) try { await refusing.promise - await vi.advanceTimersByTimeAsync(AGENT_SESSION_ADMISSION_BARRIER_TIMEOUT_MS) + await vi.advanceTimersByTimeAsync(SETTLED_WAIT_MS) expect(returned).toBe(true) expect(writes).toHaveBeenCalledOnce() expect(hostTestState().dispatch).not.toHaveBeenCalled() @@ -129,9 +131,10 @@ it.each([1, 2])( } ) -// The pre-dispatch check judges only what the journal already holds; the send path never waits on -// the provider's stream barrier, so a sink that stalls or fails cannot delay or double a send. -it('dispatches without touching the event-stream barrier', async () => { +// A send's plan only accepts: it records the submission and never waits on the provider's stream +// barrier, so a sink that stalls or fails cannot delay or double a send. Handing it over is the +// delivery loop's. +it('accepts without touching the event-stream barrier or the provider', async () => { const ctx = await context() const { store } = hostTestState() vi.spyOn(store, 'recordOperationOutcome').mockResolvedValue() @@ -149,42 +152,33 @@ it('dispatches without touching the event-stream barrier', async () => { }) expect(result).toMatchObject({ ok: true }) expect(beforeRun).toHaveBeenCalledOnce() - expect(hostTestState().dispatch).toHaveBeenCalledOnce() - expect(ctx.journal.submissions()[0]?.dispatchState).toBe('accepted') + expect(hostTestState().dispatch).not.toHaveBeenCalled() + expect(ctx.journal.submissions()[0]).toMatchObject({ + dispatchState: 'pending', + handoverRecorded: true + }) expect(barrier).not.toHaveBeenCalled() }) -it('refuses a superseded send without waiting on a stalled refusal write, and never dispatches late', async () => { +it('refuses a superseded send at acceptance, recording and dispatching nothing', async () => { const ctx = await context() const { store } = hostTestState() vi.spyOn(store, 'recordOperationOutcome').mockResolvedValue() - const pending = Promise.withResolvers() - const refusing = Promise.withResolvers() - vi.spyOn(ctx.journal, 'resolveDispatch').mockImplementationOnce(() => { - refusing.resolve() - return pending.promise.then(() => ctx.journal.cursor()) - }) - vi.spyOn(console, 'warn').mockImplementation(() => {}) const beforeRun = vi.fn(() => { throw new AgentSessionPreDispatchError('agent_session_restart_work_superseded') }) const body = hostTestMessage('Continue the interrupted work') const operation = envelope('agentSession.send', { body }) vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const result = runSettledAgentSessionMutation({ + const result = await runSettledAgentSessionMutation({ store, operationCallerKey: 'test', envelope: operation, context: ctx, plan: sendPlan({ envelope: operation, body, beforeRun }) }).catch((error: unknown) => error) - await refusing.promise - await vi.advanceTimersByTimeAsync(AGENT_SESSION_ADMISSION_BARRIER_TIMEOUT_MS) - expect(await result).toBeInstanceOf(AgentSessionPreDispatchError) - expect(hostTestState().dispatch).not.toHaveBeenCalled() - expect(ctx.journal.submissions()[0]?.dispatchState).toBe('pending') - pending.resolve() - await vi.advanceTimersByTimeAsync(0) + expect(result).toBeInstanceOf(AgentSessionPreDispatchError) + expect(ctx.journal.submissions()).toEqual([]) expect(hostTestState().dispatch).not.toHaveBeenCalled() expect(vi.getTimerCount()).toBe(0) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts index baf871aef65..e4675bfefb1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts @@ -11,8 +11,6 @@ export class AgentSessionPreDispatchError extends Error { } } -export const AGENT_SESSION_ADMISSION_BARRIER_TIMEOUT_MS = 2_000 - export async function runSettledAgentSessionMutation(input: { store: AgentSessionRecordStore operationCallerKey: string @@ -33,10 +31,7 @@ export async function runSettledAgentSessionMutation(input: { if (input.plan.markUnknownBeforeRun) { await settle({ status: 'unknown' }) } - outcome = await input.plan.run({ - ...input.context, - ...(input.plan.beforeRun ? { beforeDispatch: input.plan.beforeRun } : {}) - }) + outcome = await input.plan.run(input.context) await settle( outcome.ok ? (input.plan.settledOutcome?.(outcome.value) ?? { @@ -46,6 +41,8 @@ export async function runSettledAgentSessionMutation(input: { : { status: 'failed', code: outcome.refusal.code, + ...(outcome.refusal.details ? { details: outcome.refusal.details } : {}), + // The row's own field, which builds before details read; copied from the legacy mirror. ...(outcome.refusal.rewindReason ? { rewindReason: outcome.refusal.rewindReason } : {}) } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts new file mode 100644 index 00000000000..7700a616b38 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts @@ -0,0 +1,61 @@ +// What an operation that needs the agent is told when it cannot have one: a typed refusal in +// words a person can read, never Orca's own error text. + +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' +import { ensureStructuredAgentSessionAgentForOperation } from './structured-agent-session-agent-start' +import { recordStructuredAgentSessionOptionIntent } from './structured-agent-session-options-read' + +const SESSION = 'session-1' + +describe('an operation whose agent start throws', () => { + it('is refused as a failed restart, with the error only in the log', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const cause = new Error('EACCES: permission denied, open /Users/me/.orca/leases.json') + const context = { + sessions: new Map(), + reconcileLeases: () => Promise.reject(cause) + } + + const refused = await ensureStructuredAgentSessionAgentForOperation( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial context double; the start throws at `reconcileLeases`, before any other member is read. + context as unknown as StructuredAgentSessionAttachContext, + SESSION + ) + + expect(refused).toEqual({ + ok: false, + refusal: { + code: 'agent_session_owner_restart_failed', + message: "The agent couldn't restart." + } + }) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('starting the agent'), cause) + warn.mockRestore() + }) +}) + +describe('an option picked while the chat is at rest', () => { + it('refuses a key the provider would not accept as a rejected option', async () => { + const persistOptions = vi.fn(async () => {}) + const refused = await recordStructuredAgentSessionOptionIntent( + { + getRecord: () => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the intent reads only the record's provider and options. + ({ provider: 'codex', options: {} }) as unknown as AgentSessionRecord + }, + { sessionId: SESSION, persistOptions, publish: () => {} }, + { key: 'notAnOption', value: 'x' } + ) + + expect(refused).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'optionRejected' } + } + }) + expect(persistOptions).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts index 607db1e6c4b..47f67910cf9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts @@ -1,6 +1,10 @@ /** Option validation failed before the provider session was mutated. */ export class AgentSessionOptionRejectedError extends Error { - constructor(cause: unknown) { + constructor( + cause: unknown, + /** `providerStarting`: nothing is wrong with the value; the session cannot take it yet. */ + readonly refusalReason: 'optionRejected' | 'providerStarting' = 'optionRejected' + ) { super(cause instanceof Error ? cause.message : String(cause), { cause }) this.name = 'AgentSessionOptionRejectedError' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts index 7957ed326a8..3eae8a7ae08 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts @@ -152,6 +152,11 @@ describe('structured session options and close', () => { envelope: envelope('agentSession.send', { body }), body }) + // Accepted, then handed over by the delivery loop; the status is read once it answered. + await vi.waitFor(() => expect(dispatchedModels).toEqual([DEFAULT_MODEL])) + await vi.waitFor(async () => + expect((await host.journalSnapshot(SESSION)).submissions[0]?.dispatchState).toBe('accepted') + ) const events: AgentSessionStatusEvent[] = [] host.subscribeStatus({ id: 'session-list', emit: (event) => events.push(event) }) expect(events).toEqual([ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts new file mode 100644 index 00000000000..3bad8d9789f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -0,0 +1,134 @@ +// A chat's options, live or at rest. +// +// At rest nothing here needs a child. The pick is the record's own `options` — what the next start replays — +// and the list is the host's model catalog, the same one the picker already reads before a chat +// exists. A pick made at rest is written to the record as intent, through the same transition a live +// pick takes, so the next start applies it. + +import { + refuse, + type AgentSessionOptionResult, + type AgentSessionOptionsResult +} from '../../../shared/agent-session-wire' +import { decodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { journalOpenReadRefusal } from '../agent-session-journal/journal-open-failure' +import { isClaudeStructuredOptionKey } from '../../claude/claude-structured-options' +import { isCodexTurnOptionKey } from '../../codex/codex-structured-turn-start' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' + +type RestingOptions = Pick + +async function readStructuredAgentSessionOptionsAtRest( + deps: Pick, + sessionId: string +): Promise { + const record = deps.store.getRecord(sessionId) + if (!record) { + throw new Error('agent_session_identity_required') + } + const catalog = (await deps.modelCatalog + ?.read({ agent: record.provider, sessionId }) + .catch(() => null)) ?? { origin: 'unknown' as const } + const models = catalog.origin === 'unknown' ? [] : catalog.models + const saved = record.options ?? {} + const fastMode = + saved.fastMode === undefined + ? null + : decodeStructuredAgentSessionOptionValue('fastMode', saved.fastMode) + // An unknown model is one the client already treats as unconfirmed. + const model = saved.model ?? models.find((entry) => entry.isDefault)?.id ?? '' + // As a live child answers: the pick, else what Claude runs for this model when none is sent. + // A live Codex child answers only the effort its thread reported, never the model's default. + const effort = + saved.effort ?? + (record.provider === 'claude' + ? models.find((entry) => entry.id === model)?.defaultEffort + : undefined) + return { + models, + ...(catalog.origin !== 'unknown' && catalog.fastModeSupport + ? { fastModeSupport: catalog.fastModeSupport } + : {}), + current: { + model, + ...(effort ? { effort } : {}), + ...(typeof fastMode === 'boolean' ? { fastMode } : {}) + } + } +} + +/** Records a pick for the next start. Only a key the provider would accept is kept. */ +export async function recordStructuredAgentSessionOptionIntent( + store: Pick, + ctx: Pick, + input: { key: string; value: string } +): Promise> { + const record = store.getRecord(ctx.sessionId) + const accepted = + record?.provider === 'codex' + ? isCodexTurnOptionKey(input.key) + : record?.provider === 'claude' && isClaudeStructuredOptionKey(input.key) + if (!record || !accepted) { + return { + ok: false, + refusal: refuse( + 'agent_session_operation_invalid', + { reason: 'optionRejected' }, + `${record?.provider ?? 'This session'} has no session option named ${input.key}` + ) + } + } + const options = { ...record.options, [input.key]: input.value } + await ctx.persistOptions(options) + ctx.publish() + return { ok: true, value: { ...input, options } } +} + +/** A live child's own answer, or the answer at rest; the goal, usage and rewind either way. */ +export async function readStructuredAgentSessionOptions( + context: Pick< + StructuredAgentSessionMutationContext, + 'deps' | 'serialize' | 'openConversation' | 'conversation' + >, + sessionId: string +): Promise { + const { adapter, store } = context.deps + const live = await context.serialize(sessionId, async () => { + const session = await context.openConversation(sessionId).catch((error: unknown) => { + throw journalOpenReadRefusal(error) + }) + const child = session?.child + if (!child) { + return null + } + if (!adapter.readOptions) { + throw new Error('structured_agent_session_options_unsupported') + } + return adapter.readOptions({ sessionId, fence: child.fence }) + }) + const options = live ?? (await readStructuredAgentSessionOptionsAtRest(context.deps, sessionId)) + // Re-acquired after the reads above: the handle they saw may have closed and reopened since. + const session = await context.conversation(sessionId) + const phase = store.getRecord(sessionId)?.rewind?.phase + const agent = session.params.provider + return { + ...options, + rewind: + phase === 'prepared' || phase === 'provider-succeeded' + ? { supported: false, reason: 'outcome-unknown' } + : (adapter.rewindSupport?.(sessionId, agent) ?? { + supported: false, + reason: 'unsupported' + }), + conversationCommands: adapter.compact ? ['clear', 'compact'] : ['clear'], + ...(adapter.supportsThreadGoal?.(sessionId, agent) + ? { threadGoal: { current: session.journal.threadGoal() } } + : {}), + ...(adapter.recordsContextUsage?.(sessionId, agent) + ? { contextUsage: { current: session.journal.contextUsage() } } + : {}) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts index d2d5d746469..9da47ae1f22 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts @@ -1,10 +1,12 @@ -// Switching away from a chat starts the release clock, and the clock must never stop a provider -// child that still owes the user work. +// The idle sweep must never stop a provider child that still owes the user work. // // A Claude chat is published before its CLI answers initialize, and a message sent in that window -// is held until it does; evicting then refuses a message the user already sent. And a lead whose -// turn has settled can leave subagents, commands and monitors running inside the child; evicting -// then ends them silently. +// is accepted and stays queued until it does; the delivery loop hands it over once startup lands. +// The idle sweep ticks meanwhile. Inside the idle window it must leave that queued message and the +// starting child alone, and every journal publish — the handover included — starts the window +// again; only a chat quiet for the whole window loses its agent. And a lead whose turn has settled +// can leave subagents, commands and monitors running inside the child; stopping it then ends them +// silently. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -29,8 +31,8 @@ import { } from './structured-agent-session-host-test-data' const CALLER = { callerKey: 'client-1' } -const SURFACE = 'desktop-chat:1' -const GRACE_MS = 5 +const SWEEP_MS = 5 +const IDLE_MS = 1_000 let root: string let store: AgentSessionRecordStore @@ -39,12 +41,14 @@ let adapter: ClaudeStructuredSessionAdapter let claude: ReturnType let landInit: () => void let lifecycle: Promise[] +let clock: number beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-owed-work-release-')) resetHostTestOperationIds() claude = fakeClaude() lifecycle = [] + clock = NOW const initLanded = new Promise((resolve) => { landInit = resolve }) @@ -65,7 +69,7 @@ beforeEach(async () => { lifecycle.push(host.handleAdapterEvent(mapped)) } }, - // As the runtime wires it: a held prompt's outcome reaches the journal out of band. + // As the runtime wires it: an admitted prompt's outcome reaches the journal out of band. onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), // Initialize answers only when the test says so. openConnection: async (launch, handlers) => { @@ -87,8 +91,8 @@ beforeEach(async () => { journalRoot: root, claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', - releaseGraceMs: GRACE_MS, - now: () => NOW + idleSweep: { intervalMs: SWEEP_MS, idleMs: IDLE_MS }, + now: () => clock }) }) @@ -111,7 +115,6 @@ async function attachStarting(): Promise { }) ) expect(created).toMatchObject({ ok: true }) - await host.hold(SESSION, SURFACE) } async function send(text: string, dispatchState = 'pending'): Promise { @@ -133,38 +136,48 @@ async function send(text: string, dispatchState = 'pending'): Promise { return sent.ok ? sent.value.clientMessageId : '' } -function dispatchState(clientMessageId: string): string | undefined { - return host - .journalSnapshot(SESSION) - .submissions.find((entry) => entry.clientMessageId === clientMessageId)?.dispatchState +async function dispatchState(clientMessageId: string): Promise { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + )?.dispatchState } -/** Long enough for several grace windows to elapse, so "not evicted" means the clock declined. */ -function waitOutSeveralGraceWindows(): Promise { - return new Promise((resolve) => setTimeout(resolve, GRACE_MS * 20)) +/** The delivery loop hands a message over on its own serialized steps after startup lands; this + * yields to them without moving the host clock. */ +async function untilSent(connection: { sent: unknown[] }): Promise { + for (let turn = 0; turn < 2000 && connection.sent.length === 0; turn += 1) { + await new Promise((resolve) => setImmediate(resolve)) + } } -describe('a chat left while its Claude CLI is still starting', () => { - it('keeps the session for a message it is holding, and delivers it once startup lands', async () => { +/** Long enough for many sweep ticks, so "not stopped" means the sweep declined. */ +function waitOutSeveralSweeps(): Promise { + return new Promise((resolve) => setTimeout(resolve, SWEEP_MS * 20)) +} + +describe('a Claude chat whose CLI is still starting', () => { + it('keeps a message queued inside the idle window, and delivers it once startup lands', async () => { await attachStarting() const held = await send('sent while starting') - host.release(SESSION, SURFACE) - await waitOutSeveralGraceWindows() + clock += IDLE_MS - 1 + await waitOutSeveralSweeps() expect(host.hasSession(SESSION)).toBe(true) expect(claude.connections[0].closeCount).toBe(0) - expect(dispatchState(held)).toBe('pending') + expect(await dispatchState(held)).toBe('pending') landInit() - await adapter.drainStartup(SESSION) + await adapter.awaitStarted(SESSION) - expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]) - await vi.waitFor(() => expect(dispatchState(held)).toBe('accepted')) + await vi.waitFor( + () => expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]), + { timeout: 3000 } + ) + await vi.waitFor(async () => expect(await dispatchState(held)).toBe('accepted')) }) - it('gives the message it wrote at startup a full grace to open its turn', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + it('gives the message it hands over at startup a full idle window to open its turn', async () => { await attachStarting() const held = await send('sent while starting') const connection = claude.connections[0] @@ -172,31 +185,34 @@ describe('a chat left while its Claude CLI is still starting', () => { connection.send = async (message) => { connection.sent.push(message) } - host.release(SESSION, SURFACE) - await vi.advanceTimersByTimeAsync(GRACE_MS * 3 - 1) + clock += IDLE_MS - 1 + await waitOutSeveralSweeps() expect(host.hasSession(SESSION)).toBe(true) - // Startup lands just before the clock's next tick. + // Startup lands just before the window closes; the handover's publish starts it again. landInit() - await adapter.drainStartup(SESSION) + await adapter.awaitStarted(SESSION) await Promise.all(lifecycle) + await untilSent(connection) expect(connection.sent).toEqual([expect.objectContaining({ type: 'user' })]) - await vi.advanceTimersByTimeAsync(GRACE_MS - 1) + clock += IDLE_MS - 1 + await waitOutSeveralSweeps() expect(host.hasSession(SESSION)).toBe(true) expect(connection.closeCount).toBe(0) connection.handlers.onMessage?.(connection.sent[0]) await host.flushStreamedEvents(SESSION) - await vi.advanceTimersByTimeAsync(GRACE_MS * 3) + await waitOutSeveralSweeps() expect(host.hasSession(SESSION)).toBe(true) - expect(dispatchState(held)).toBe('accepted') + expect(await dispatchState(held)).toBe('accepted') }) - it('is released after the grace once its turn has finished', async () => { + it('stops the agent and closes the conversation once its turn has finished and it idled', async () => { await attachStarting() landInit() - await adapter.drainStartup(SESSION) - await send('answered', 'accepted') + await adapter.awaitStarted(SESSION) + const answered = await send('answered') + await vi.waitFor(async () => expect(await dispatchState(answered)).toBe('accepted')) claude.connections[0].handlers.onMessage?.({ type: 'result', subtype: 'success', @@ -206,25 +222,25 @@ describe('a chat left while its Claude CLI is still starting', () => { result: 'done' }) await host.flushStreamedEvents(SESSION) - expect(host.journalSnapshot(SESSION).submissions).toHaveLength(1) + expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(1) - host.release(SESSION, SURFACE) + clock += IDLE_MS await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) expect(claude.connections[0].closeCount).toBe(1) }) - it('is released after the grace when it owes nothing', async () => { + it('stops a start that stayed quiet for the whole window when it owes nothing', async () => { await attachStarting() - host.release(SESSION, SURFACE) + clock += IDLE_MS await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) expect(claude.connections[0].closeCount).toBe(1) }) }) -describe('a chat left while its settled lead still has background work running', () => { +describe('a chat whose settled lead still has background work running', () => { function frame(message: Record): void { claude.connections[0].handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) } @@ -232,8 +248,9 @@ describe('a chat left while its settled lead still has background work running', async function settleTurnLeavingTask(taskType: string): Promise { await attachStarting() landInit() - await adapter.drainStartup(SESSION) - await send('fan out', 'accepted') + await adapter.awaitStarted(SESSION) + const fanOut = await send('fan out') + await vi.waitFor(async () => expect(await dispatchState(fanOut)).toBe('accepted')) frame({ type: 'system', subtype: 'task_started', @@ -253,11 +270,11 @@ describe('a chat left while its settled lead still has background work running', ['a subagent', 'local_agent'], ['a background command', 'local_bash'], ['a monitor', 'monitor'] - ])('keeps the session while %s runs, then releases it once that settles', async (_, type) => { + ])('keeps the agent while %s runs, then stops it once that settles', async (_, type) => { await settleTurnLeavingTask(type) - host.release(SESSION, SURFACE) - await waitOutSeveralGraceWindows() + clock += IDLE_MS + await waitOutSeveralSweeps() expect(host.hasSession(SESSION)).toBe(true) expect(claude.connections[0].closeCount).toBe(0) @@ -272,6 +289,7 @@ describe('a chat left while its settled lead still has background work running', // A finished background task can wake the lead; that turn is owed too until it settles. frame({ type: 'result', subtype: 'success', uuid: 'result-2', is_error: false, result: 'ok' }) await host.flushStreamedEvents(SESSION) + clock += IDLE_MS await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) expect(claude.connections[0].closeCount).toBe(1) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts index 890b8f34fee..b9db10c1579 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts @@ -1,27 +1,25 @@ -import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import { adapterSupportsRecord } from './structured-agent-session-provider-support' /** The `agentSession.handoffStatus` answer. Released desktop clients gate worktree activation on - * `owner`, so the method outlives the terminal handoff it was named for. */ + * `owner`, so the method outlives the terminal handoff it was named for. It reports ownership, not + * liveness: a chat whose agent is stopped, idle-released or still starting is owned all the same. */ export function structuredAgentSessionOwnerStatus( - record: AgentSessionRecord + deps: Pick, + sessionId: string ): AgentSessionHandoffStatus { - const { handoffStage: stage, handoffOperationId: operationId } = record.lease - if (stage === 'manual-recovery') { - return { - owner: 'none', - direction: 'to-native', - phase: 'failed', - stage, - operationId, - error: { - message: "Couldn't verify which runtime owns this session — manual recovery is required", - recoverableOwner: 'none' - } - } + const record = deps.store.getRecord(sessionId) + if (!record) { + throw new Error('agent_session_identity_required') } + // Same refusal as reveal: a host that cannot run this chat vouches for no owner. + if (!adapterSupportsRecord(deps.adapter, record)) { + throw new Error('structured_agent_session_unsupported') + } + const { handoffStage: stage, handoffOperationId: operationId } = record.lease return { - owner: record.lease.claimStatus === 'live' && record.lease.ownerProcess ? 'native' : 'none', + owner: 'native', direction: stage ? 'to-native' : null, phase: stage ? 'switching' : 'idle', stage, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts new file mode 100644 index 00000000000..89772d45469 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts @@ -0,0 +1,185 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { mapRuntimeError } from '../../runtime/rpc/errors' +import { + AgentSessionPreSpawnError, + type AgentSessionPreSpawnReason, + type StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { + attachFingerprintFields, + type AgentSessionAttachParams +} from './structured-agent-session-attach' +import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' +import { performAttach } from './structured-agent-session-attach-flow' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-alpha' +const OPERATION = `${NOW}-${'1'.padStart(32, '0')}` +let root: string | null = null + +afterEach(async () => { + vi.restoreAllMocks() + if (root) { + await rm(root, { recursive: true, force: true }) + } + root = null +}) + +function createParams(): AgentSessionAttachParams { + const params: AgentSessionAttachParams = { + envelope: { + sessionId: SESSION, + clientOperationId: OPERATION, + expectedRuntimeFence: null, + payloadFingerprint: '' + }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + runtimeKind: 'native' + } + return { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields(params) + }) + } + } +} + +/** The thrown first answer as the wire sends it, and the ledger's replay of the same operation. */ +async function firstAnswerAndReplay(thrown: AgentSessionPreSpawnError) { + root = await mkdtemp(join(tmpdir(), 'orca-pre-spawn-first-answer-')) + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const unused = async (): Promise => { + throw new Error('not reached before a spawn') + } + const adapter: StructuredAgentSessionAdapter = { + acquire: async () => { + throw thrown + }, + dispatch: unused, + cancelTurn: unused, + answerPrompt: unused, + setOption: unused + } + const input = { + store, + adapter, + journalRoot: root, + openConversation: openTestAttachConversation(root), + authority: { + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: OPERATION, + probe: { outcome: 'reservation-unused' as const } + }, + callerKey: 'client-1', + params: createParams(), + now: () => NOW, + onAttached: () => {} + } + const first = await performAttach(input).then( + () => null, + (error: unknown) => error + ) + expect(first).toBeInstanceOf(AgentSessionPreSpawnError) + return { + first: mapRuntimeError('req-1', { runtimeId: 'runtime-1' }, first), + replay: await performAttach(input) + } +} + +describe('a create that fails before any process spawns', () => { + it.each<[string, string, AgentSessionPreSpawnReason | undefined, string]>([ + [ + 'the managed account env override', + 'This Claude launch defines explicit Anthropic auth environment variables.', + 'managedAccountEnvOverride', + 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.' + ], + [ + 'an account switch in progress', + 'A Claude account switch is in progress. Try again after it finishes.', + 'accountSwitchInProgress', + 'A Claude account switch is in progress. Try again after it finishes.' + ], + [ + 'a Claude account added in WSL', + 'structured Claude is not offered under the active managed Claude account', + 'managedAccountUnsupported', + 'While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings, then send your message again.' + ], + [ + "Orca's own reason", + 'claude sessions pin CLAUDE_CONFIG_DIR, not CODEX_HOME', + undefined, + "Claude couldn't start. Send your message to try again." + ] + ])('answers %s first in the words its replay reads', async (_, raw, reason, sentence) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + const { first, replay } = await firstAnswerAndReplay( + new AgentSessionPreSpawnError(new Error(raw), reason ? { reason } : {}) + ) + + // Thrown as before: the wire code is unchanged, only the words are the replay's. + expect(first).toMatchObject({ ok: false, error: { code: 'runtime_error', message: sentence } }) + expect(replay).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + message: sentence, + ...(reason ? { details: { reason } } : {}) + } + }) + if (!reason) { + expect(replay).not.toHaveProperty('refusal.details') + } + if (raw !== sentence) { + expect(JSON.stringify([first, replay])).not.toContain(raw) + } + // What failed is kept for the log. + expect(warn).toHaveBeenCalledWith( + '[agent-session] provider start failed:', + expect.objectContaining({ message: raw }) + ) + }) + + it('keeps a message that is itself a code, and the wire code it maps to', async () => { + const { first } = await firstAnswerAndReplay( + new AgentSessionPreSpawnError(new Error('runtime_unavailable')) + ) + + expect(first).toMatchObject({ + ok: false, + error: { code: 'runtime_unavailable', message: 'runtime_unavailable' } + }) + }) + + it('keeps the situation of a pre-spawn error it wraps', () => { + const typed = new AgentSessionPreSpawnError(new Error('switching'), { + reason: 'accountSwitchInProgress' + }) + + expect(new AgentSessionPreSpawnError(typed).reason).toBe('accountSwitchInProgress') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts index 0f115da5ebd..348d5a3d90c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts @@ -12,6 +12,7 @@ import { attachFingerprintFields, type AgentSessionAttachParams } from './structured-agent-session-attach' +import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' import { performAttach } from './structured-agent-session-attach-flow' const NOW = 1_800_000_000_000 @@ -86,6 +87,7 @@ describe('processless structured session reservation', () => { store, adapter, journalRoot: root, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -133,6 +135,7 @@ describe('processless structured session reservation', () => { store, adapter, journalRoot: root, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -172,6 +175,7 @@ describe('processless structured session reservation', () => { store, adapter, journalRoot: root, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-drift', claimKeyId: 'key-1', @@ -194,7 +198,6 @@ describe('processless structured session reservation', () => { claimStatus: 'released', handoffStage: null, reservedSpawnToken: null, - processlessAt: null, runtimeFence: 2, deathEvidence: { kind: 'pid-absent', detail: 'reservation failed before spawn' } }) @@ -218,7 +221,6 @@ describe('processless structured session reservation', () => { throw new AgentSessionPreSpawnError(new Error('workspace no longer exists')) }) } as unknown as StructuredAgentSessionAdapter - const processlessProof = vi.spyOn(store, 'setReservationProcesslessProof') const settlement = vi.spyOn(store, 'settleFailedAcquisition') await expect( @@ -226,6 +228,7 @@ describe('processless structured session reservation', () => { store, adapter, journalRoot: root, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -237,20 +240,15 @@ describe('processless structured session reservation', () => { now: () => NOW, onAttached: () => {} }) - ).rejects.toThrow('workspace no longer exists') + ).rejects.toThrow("Codex couldn't restart. Send your message to try again.") expect(settlement).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ exitProof: 'processless', spawnToken: 'spawn-a' }) ) - // No separate durable proof write: the only proof call is acquisition's single-use clear. - expect(processlessProof).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ processlessAt: null }) - ) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, handoffOperationId: null, runtimeFence: 2, - processlessAt: null, reservedSpawnToken: null, deathEvidence: { kind: 'pid-absent', detail: 'reservation failed before spawn' } }) @@ -297,6 +295,7 @@ describe('processless structured session reservation', () => { store, adapter, journalRoot: root, + openConversation: openTestAttachConversation(root!), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -309,7 +308,9 @@ describe('processless structured session reservation', () => { onAttached: () => {} } - await expect(performAttach(input)).rejects.toThrow('launch not ready') + await expect(performAttach(input)).rejects.toThrow( + "Codex couldn't restart. Send your message to try again." + ) await expect(performAttach(input)).resolves.toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_invalid' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts index a9e32b026e6..e0ae5da9d81 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -50,7 +51,7 @@ async function pendingPrompt(): Promise<{ journal: AgentSessionJournal; itemId: resolvedAt: null } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) return { journal, itemId: item.itemId } } @@ -117,7 +118,7 @@ describe('performCancel for a pending prompt', () => { resolvedAt: null } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts index 7f71a9c28ce..7324e3be9ec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts @@ -2,7 +2,7 @@ import type { AgentJournalItemBody, AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' -import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import { refuse, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { AgentSessionTurnContext } from './structured-agent-session-turns' type PendingPromptBody = Extract @@ -11,8 +11,12 @@ export type PendingPromptValidation = | { ok: true; item: AgentJournalRenderItem; prompt: PendingPromptBody } | { ok: false; refusal: AgentSessionWireRefusal } -function invalid(message: string): PendingPromptValidation { - return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } } +/** The prompt the client named is not one waiting on the user: nothing to answer. */ +function promptGone(message: string): PendingPromptValidation { + return { + ok: false, + refusal: refuse('agent_session_operation_invalid', { reason: 'promptGone' }, message) + } } export function validatePendingPrompt( @@ -25,34 +29,36 @@ export function validatePendingPrompt( ): PendingPromptValidation { const item = ctx.journal.snapshot().items.find((entry) => entry.itemId === input.itemId) if (!item) { - return invalid(`No item ${input.itemId} in session ${ctx.sessionId}.`) + return promptGone(`No item ${input.itemId} in session ${ctx.sessionId}.`) } const prompt = item.body.kind === 'approval' || item.body.kind === 'question' ? item.body : null if (!prompt || (input.kind !== undefined && prompt.kind !== input.kind)) { - return invalid( + return promptGone( `Item ${input.itemId} is not a pending${input.kind ? ` ${input.kind}` : ' prompt'}.` ) } if (item.revision !== input.expectedRevision) { return { ok: false, - refusal: { - code: 'agent_session_item_revision_stale', - message: `Item ${input.itemId} has moved on.`, - currentRevision: item.revision, - resolution: prompt.resolution - } + refusal: refuse( + 'agent_session_item_revision_stale', + { reason: 'promptMoved', currentRevision: item.revision, resolution: prompt.resolution }, + `Item ${input.itemId} has moved on.` + ) } } if (prompt.resolution.state !== 'pending') { return { ok: false, - refusal: { - code: 'agent_session_already_resolved', - message: `Item ${input.itemId} was already ${prompt.resolution.state}.`, - currentRevision: item.revision, - resolution: prompt.resolution - } + refusal: refuse( + 'agent_session_already_resolved', + { + reason: 'promptAlreadyResolved', + currentRevision: item.revision, + resolution: prompt.resolution + }, + `Item ${input.itemId} was already ${prompt.resolution.state}.` + ) } } return { ok: true, item, prompt } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts new file mode 100644 index 00000000000..30e81ab2d1e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -0,0 +1,743 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +// The provider child is its own record on the conversation: stopping it, losing it or failing to +// start it ends the child, never the conversation. Against the real host, store and journal, with a +// live subscriber opened before each action. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { + AgentSessionStatusSummary, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' +import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { + agentSessionFailureFact, + type AgentSessionFailureFact, + type SubmissionRejectionFact +} from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { ensureStructuredAgentSessionAgent } from './structured-agent-session-agent-start' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' +import { + HOST_TEST_LOCATION, + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquire: Mock +let dispatch: Mock +let adapterExtras: Partial + +function eventually(assertion: () => void | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +function generation(): string { + return `generation-${acquire.mock.calls.length}` +} + +const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + acquisitionGeneration: generation(), + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: store.getRecord(SESSION)?.providerHandleChain.length + ? ('resumed' as const) + : ('created' as const), + mintedAtFence: fence, + observedAt: NOW + } +}) + +/** A Claude-shaped child: published at spawn, so it is `starting` until `started`. */ +const spawnStartingChild: StructuredAgentSessionAdapter['acquire'] = async (input) => ({ + ...(await spawnChild(input)), + providerChildPhase: 'starting' as const +}) + +function startHost(): void { + host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire, + dispatch, + closeSession: vi.fn(async () => true), + releaseAcquisition: vi.fn(async () => true), + cancelTurn: vi.fn(async () => ({ cancelled: false })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined), + ...adapterExtras + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, + now: () => NOW + }) +} + +async function restartHost(): Promise { + await host.flushAllStreamedEvents() + startHost() +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-child-record-')) + resetHostTestOperationIds() + adapterExtras = {} + acquire = vi.fn(spawnChild) + dispatch = vi.fn(async (input) => ({ + state: 'accepted' as const, + providerIdentity: { + provider: 'codex' as const, + threadId: THREAD, + turnId: `turn-${input.clientMessageId}`, + ordinal: dispatch.mock.calls.length + } + })) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + startHost() + expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + await host.close(SESSION) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function sendParams(text: string) { + const body = hostTestMessage(text) + return { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + } +} + +async function accept(text: string): Promise { + const params = sendParams(text) + const sent = await host.send(CALLER, params) + expect(sent).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) + return params.envelope.clientOperationId +} + +function stop() { + const turnId = 'turn-none' + return host.cancel(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: SESSION, + fields: { turnId } + }) + }, + turnId + }) +} + +async function submission(id: string): Promise { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === id + ) +} + +async function statusRows(): Promise< + { + itemId: string + text: string + tone?: string + failure?: AgentSessionFailureFact + }[] +> { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' + ? [ + { + itemId: item.itemId, + text: item.body.text, + ...(item.body.tone ? { tone: item.body.tone } : {}), + ...(item.body.failure ? { failure: item.body.failure } : {}) + } + ] + : [] + ) +} + +/** The child the conversation has now, as its lifecycle events name it. */ +function currentChild() { + const child = conversation()?.child + if (!child?.generation) { + throw new Error('no child indexed') + } + return { sessionId: SESSION, fence: child.fence, acquisitionGeneration: child.generation } +} + +function exit(child: ReturnType, reason: string, startupUnproven?: true) { + return host.handleAdapterEvent({ + type: 'ended', + ...child, + reason, + // As an adapter reports it: the exit's stderr as a log detail. + failure: { kind: 'providerExited', detail: { text: reason, audience: 'log' } }, + cause: 'unexpected-exit', + ...(startupUnproven ? { startupUnproven } : {}) + }) +} + +function rejectedIn(events: AgentSessionSubscribeEvent[], id: string): boolean { + return events.some( + (event) => + event.type === 'batch' && + event.batch.submissions.some( + (entry) => entry.clientMessageId === id && entry.dispatchState === 'rejected' + ) + ) +} + +function conversation() { + return host['sessions'].get(SESSION) +} + +async function subscribe(): Promise { + const events: AgentSessionSubscribeEvent[] = [] + await host.subscribe({ + id: 'sub-1', + sessionId: SESSION, + emit: (event) => events.push(structuredClone(event)) + }) + return events +} + +/** The chat's status row as a session list sees it, frame by frame. */ +function watchStatus(): AgentSessionStatusSummary[] { + const frames: AgentSessionStatusSummary[] = [] + host.subscribeStatus({ + id: 'list-1', + emit: (event) => { + if (event.type === 'status' && event.session.sessionId === SESSION) { + frames.push(event.session) + } + } + }) + return frames +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +describe('Stop on a child still proving its start', () => { + it('ends the child and keeps the conversation and its readers (R1)', async () => { + const ended = deferred() + adapterExtras = { + awaitStarted: vi.fn(() => ended.promise), + closeSession: vi.fn(async () => { + ended.resolve() + return true + }) + } + await restartHost() + acquire.mockImplementationOnce(spawnStartingChild) + const first = await accept('hello') + const journal = conversation()?.journal + const events = await subscribe() + const frames = watchStatus() + await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) + + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + + // The same conversation: no reopen, and the chat told it is idle again. + expect(conversation()?.journal).toBe(journal) + expect(conversation()?.child).toBeNull() + expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'user-stop', rootGone: true }) + expect(frames.at(-1)).not.toHaveProperty('hostExecutionPhase') + expect(frames.at(-1)).not.toHaveProperty('hostExecutionOwned') + expect(await submission(first)).toMatchObject({ + dispatchState: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + }) + // A Stop is not a failure: no row, and the loop is gone. + expect(await statusRows()).toEqual([]) + await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) + + const next = await accept('after stop') + await eventually(async () => expect((await submission(next))?.dispatchState).toBe('accepted')) + expect(conversation()?.journal).toBe(journal) + expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([next]) + // The reader opened before the Stop saw the next message delivered on the same stream. + expect( + events.some( + (event) => + event.type === 'batch' && + event.batch.submissions.some( + (entry) => entry.clientMessageId === next && entry.dispatchState === 'accepted' + ) + ) + ).toBe(true) + }) +}) + +describe('settling an earlier child before the next one takes its message', () => { + it("settles the earlier child's turn from its death evidence and leaves the queued message to the new child (R1)", async () => { + // The earlier child exited mid-turn; the released lease keeps only its death evidence. + await store.transitionHandoff(SESSION, (record) => ({ + ...record, + lease: { + ...record.lease, + deathEvidence: { kind: 'exit-observed', detail: 'provider exited', observedAt: NOW - 1_000 } + } + })) + const releasedFence = store.getRecord(SESSION)!.lease.runtimeFence + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: HOST_TEST_LOCATION.workspaceId, + hostId: HOST_TEST_LOCATION.executionHostId, + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + journalDir: journalDirectoryFor(root, { + workspaceId: HOST_TEST_LOCATION.workspaceId, + sessionId: SESSION + }) + }) + await journal.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'earlier-turn', ordinal: 0 }, + { kind: 'turn', turnId: 'earlier-turn', state: 'running', startedAt: NOW - 5_000 }, + { fence: releasedFence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal.close() + const id = await accept('for the next child') + + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) + // The exit was observed, so its receipt ends the turn, and the chat says why it stopped. + const items = conversation()!.journal.snapshot().items + expect(items.map((item) => readAgentJournalTurn(item.body)).filter(Boolean)).toContainEqual( + expect.objectContaining({ + turnId: 'earlier-turn', + state: 'interrupted', + completedAt: NOW - 1_000 + }) + ) + expect( + items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) + ).toContain( + 'Codex stopped while this response was in progress. You can continue in this conversation.' + ) + // Handed over at the new child's fence, which the attach reserved after settling. + const newFence = store.getRecord(SESSION)!.lease.runtimeFence + expect(newFence).toBeGreaterThan(releasedFence) + expect((await submission(id))?.fence).toBe(newFence) + expect(conversation()?.child).toMatchObject({ generation: generation(), fence: newFence }) + }) +}) + +const START_EXIT = 'claude stream-json exited (code 1)' +const START_TEXT = 'Codex stopped before it finished starting. Send your message to try again.' +const START_FAILURE: SubmissionRejectionFact = { + kind: 'providerStartFailed', + detail: { text: START_EXIT, audience: 'log' } +} + +describe('a published child that dies while it proves its start', () => { + const EXIT = START_EXIT + const TEXT = START_TEXT + + it.each([['the loop sees the start fail first'], ['the exit is processed first']])( + 'leaves one error row keyed by the start, and every queued message rejected with it: %s (R2)', + async (order) => { + const settled = deferred() + adapterExtras = { awaitStarted: vi.fn(() => settled.promise) } + await restartHost() + acquire.mockImplementation(spawnStartingChild) + const first = await accept('first') + const events = await subscribe() + const second = await accept('second') + await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalled()) + const child = currentChild() + + if (order === 'the exit is processed first') { + await exit(child, EXIT, true) + settled.resolve(START_FAILURE) + } else { + settled.resolve(START_FAILURE) + await eventually(async () => + expect((await submission(second))?.dispatchState).toBe('rejected') + ) + await exit(child, EXIT, true) + } + + await eventually(async () => + expect((await submission(second))?.dispatchState).toBe('rejected') + ) + expect(await statusRows()).toEqual([ + { + itemId: `orca:${encodeURIComponent(`start-failure:${child.acquisitionGeneration}`)}`, + text: TEXT, + tone: 'error', + failure: START_FAILURE + } + ]) + for (const id of [first, second]) { + expect(await submission(id)).toMatchObject({ + dispatchState: 'rejected', + reason: TEXT, + rejection: START_FAILURE + }) + } + expect(rejectedIn(events, second)).toBe(true) + expect(dispatch).not.toHaveBeenCalled() + expect(acquire).toHaveBeenCalledTimes(2) + } + ) +}) + +/** A start by an operation that needs the agent, such as a goal change, outside the loop. */ +function startForOperation() { + return host['serialize'](SESSION, () => + ensureStructuredAgentSessionAgent(host['attachContext'](), SESSION) + ) +} + +describe('a start another operation made that dies while a sent message waits on it', () => { + const EXIT = START_EXIT + const TEXT = START_TEXT + + it("is the message's own failed start: one error row, the message rejected, no second start (R2)", async () => { + adapterExtras = { awaitStarted: vi.fn(async () => START_FAILURE) } + await restartHost() + acquire.mockImplementation(spawnStartingChild) + // An operation that needs the agent starts a child that has not proven its start. + await startForOperation() + const operationChild = currentChild() + const events = await subscribe() + const params = sendParams('hello') + + // Accepted first; that child's exit is settled before the loop's first step. + const sent = host.send(CALLER, params) + const exited = exit(operationChild, EXIT, true) + expect(await sent).toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'pending' } } + }) + await exited + const id = params.envelope.clientOperationId + + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) + await settleLoop() + expect((await submission(id))?.reason).toBe(TEXT) + expect(await statusRows()).toEqual([ + { + itemId: `orca:${encodeURIComponent(`start-failure:${operationChild.acquisitionGeneration}`)}`, + text: TEXT, + tone: 'error', + failure: START_FAILURE + } + ]) + expect(rejectedIn(events, id)).toBe(true) + // The setup's child and the operation's: nothing started again into the same failure. + expect(acquire).toHaveBeenCalledTimes(2) + expect(dispatch).not.toHaveBeenCalled() + }) + + it('leaves a message sent after that start failed to a fresh start (R2)', async () => { + await restartHost() + acquire.mockImplementationOnce(spawnStartingChild) + await startForOperation() + await exit(currentChild(), EXIT, true) + expect(await statusRows()).toHaveLength(1) + + const id = await accept('after the failure') + + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) + expect(acquire).toHaveBeenCalledTimes(3) + }) + + it('starts again for a message whose proven child crashed: only a failed start settles it (R2)', async () => { + await restartHost() + await startForOperation() + const params = sendParams('hello') + + const sent = host.send(CALLER, params) + const exited = exit(currentChild(), 'codex app-server crashed') + await sent + await exited + const id = params.envelope.clientOperationId + + await eventually(async () => expect((await submission(id))?.dispatchState).not.toBe('pending')) + expect((await submission(id))?.dispatchState).toBe('accepted') + expect(acquire).toHaveBeenCalledTimes(3) + }) + + it('waits on a child started since the failed one, not on the failure (R2)', async () => { + adapterExtras = { awaitStarted: vi.fn(async () => undefined) } + await restartHost() + acquire.mockImplementation(spawnStartingChild) + await startForOperation() + const params = sendParams('hello') + + // A second operation's start lands after the first child's exit, before the loop's first step. + const sent = host.send(CALLER, params) + const exited = exit(currentChild(), EXIT, true) + const held = startForOperation() + await sent + await exited + await held + const id = params.envelope.clientOperationId + + await eventually(async () => expect((await submission(id))?.dispatchState).not.toBe('pending')) + expect((await submission(id))?.dispatchState).toBe('accepted') + expect(acquire).toHaveBeenCalledTimes(3) + }) +}) + +describe('a child that ends before its message is handed over', () => { + it('starts one child for the message, then rejects it and stops (R2)', async () => { + // The child the loop starts exits between its start step and its handover step. + adapterExtras = { + awaitStarted: vi.fn(async () => { + await exit(currentChild(), 'codex app-server crashed') + }) + } + await restartHost() + const id = await accept('hello') + const events = await subscribe() + + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) + // The exit's stderr rides beside the sentence, never in it. + const failure = { + kind: 'providerExited', + detail: { text: 'codex app-server crashed', audience: 'log' } + } + expect(await submission(id)).toMatchObject({ + reason: 'Codex stopped before this message was sent.', + rejection: failure + }) + expect(await statusRows()).toEqual([ + { itemId: expect.any(String), text: (await submission(id))?.reason, tone: 'error', failure } + ]) + expect(rejectedIn(events, id)).toBe(true) + await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) + expect(acquire).toHaveBeenCalledTimes(2) + expect(dispatch).not.toHaveBeenCalled() + }) +}) + +describe('another child indexed while the loop waits on the one it started', () => { + it('hands nothing over until the child now there has proven its start (R2)', async () => { + const starts = new Map>>() + const startOf = (generation: string) => { + const start = starts.get(generation) ?? deferred() + starts.set(generation, start) + return start + } + adapterExtras = { + awaitStarted: vi.fn(() => startOf(currentChild().acquisitionGeneration).promise), + // The stop ends the child without settling the start the loop is waiting on. + closeSession: vi.fn(async () => true) + } + await restartHost() + acquire.mockImplementation(spawnStartingChild) + const first = await accept('first') + await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalledTimes(1)) + const stopped = currentChild() + expect(await stop()).toMatchObject({ ok: true }) + const second = await accept('second') + // An operation that needs the agent starts its own child before the loop's handover step runs. + await host['serialize'](SESSION, () => + ensureStructuredAgentSessionAgent(host['attachContext'](), SESSION) + ) + const replacement = currentChild() + expect(replacement.acquisitionGeneration).not.toBe(stopped.acquisitionGeneration) + + startOf(stopped.acquisitionGeneration).resolve() + await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalledTimes(2)) + expect(dispatch).not.toHaveBeenCalled() + + await host.handleAdapterEvent({ + type: 'started', + ...replacement, + reportedOptions: { model: 'sonnet' }, + restoreSkippedOptions: [] + }) + startOf(replacement.acquisitionGeneration).resolve() + + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) + expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([second]) + expect(await submission(first)).toMatchObject({ reason: DISPATCH_REJECTED_CANCELLED }) + }) +}) + +describe('a quit with a message still queued', () => { + /** Read by the next launch, through the same open any reader takes. */ + async function afterRelaunch(id: string): Promise { + startHost() + await host.revealSession(SESSION) + return await submission(id) + } + + it('settles a message no child ever had the way a chat close does (R2)', async () => { + // Quit has begun — its first step stops the delivery loops — when this message is accepted. + host['conversationDelivery'].loop.dispose() + const id = await accept('hello') + expect(conversation()?.child).toBeNull() + await host.flushAllStreamedEvents() + + expect(await afterRelaunch(id)).toMatchObject({ + dispatchState: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) + }) + }) + + it('waits for the start already in flight and stops the child it produced (R2)', async () => { + const starting = deferred() + const closeSession = vi.fn(async () => true) + adapterExtras = { closeSession } + await restartHost() + // The loop's start step is under way, but has not reached its attach yet. + const resolveRecovery = host['runtimeState'].resolveRecovery.bind(host['runtimeState']) + const recovering = vi.spyOn(host['runtimeState'], 'resolveRecovery') + recovering.mockImplementationOnce(async (sessionId) => { + await starting.promise + return resolveRecovery(sessionId) + }) + const id = await accept('hello') + await eventually(() => expect(recovering).toHaveBeenCalled()) + + const quit = host.flushAllStreamedEvents() + starting.resolve() + await quit + + expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released' }) + expect(dispatch).not.toHaveBeenCalled() + expect(await afterRelaunch(id)).toMatchObject({ + dispatchState: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) + }) + }) +}) + +describe('a send whose start failed, sent again with the same operation id', () => { + it('replays the recorded rejection and starts no second agent (R2)', async () => { + acquire.mockRejectedValueOnce(new Error('spawn claude ENOENT')) + const fenceBefore = store.getRecord(SESSION)!.lease.runtimeFence + const params = sendParams('hello') + // A failed start is a rejected message, never a refused send. + expect(await host.send(CALLER, params)).toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'pending' } } + }) + const id = params.envelope.clientOperationId + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) + // The start moved the fence while the message was out. + expect(store.getRecord(SESSION)!.lease.runtimeFence).toBeGreaterThan(fenceBefore) + const starts = acquire.mock.calls.length + + const replay = await host.send(CALLER, params) + + expect(replay).toMatchObject({ + ok: true, + replayed: true, + value: { submission: { clientMessageId: id, dispatchState: 'rejected' } } + }) + await settleLoop() + expect(acquire).toHaveBeenCalledTimes(starts) + expect(dispatch).not.toHaveBeenCalled() + }) +}) + +async function settleLoop(): Promise { + await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) +} + +describe('how a stopped child ends the start its loop was waiting on', () => { + /** A child the loop waits on, whose start the stop below does not settle, so a message sent + * after the stop is queued when the loop next looks. */ + async function stoppedWhileStarting(stop: () => Promise) { + const start = deferred() + adapterExtras = { + awaitStarted: vi.fn(() => start.promise), + closeSession: vi.fn(async () => true) + } + await restartHost() + acquire.mockImplementationOnce(spawnStartingChild) + await accept('first') + await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalledTimes(1)) + await stop() + const second = await accept('second') + adapterExtras.awaitStarted = undefined + start.resolve() + return second + } + + it("goes on after a user's Stop and delivers what was sent since (R2)", async () => { + const second = await stoppedWhileStarting(async () => { + expect(await stop()).toMatchObject({ ok: true }) + }) + + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) + expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'user-stop', reason: null }) + expect(await statusRows()).toEqual([]) + }) + + it("fails the start after a host stop, as a start Orca stopped rather than the provider's (R2)", async () => { + const reason = 'the start watchdog fired' + const second = await stoppedWhileStarting(() => + host['serialize'](SESSION, () => + stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { + cause: 'host-stop', + reason + }) + ) + ) + + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) + // The sentence is the constructor's, not the reason the stop was given. + const text = 'Codex never finished starting, so Orca stopped it.' + expect(await submission(second)).toMatchObject({ + reason: text, + rejection: { kind: 'hostStopped' } + }) + expect(await statusRows()).toEqual([ + { itemId: expect.any(String), text, tone: 'error', failure: { kind: 'hostStopped' } } + ]) + expect(dispatch).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts new file mode 100644 index 00000000000..da5e491e1d1 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts @@ -0,0 +1,83 @@ +// The provider child behind a conversation, kept as its own record on the conversation's entry. +// +// The entry is the conversation and outlives any number of children. A child enters only when an +// attach has fully succeeded, and leaves only through `endProviderChild`, which every ending shares: +// an exit, a failed re-attach, a Stop and an eviction. Each is matched on the child's generation +// and fence, so an ending that arrives late for an older child cannot end a newer one. + +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { + StructuredAgentSessionEndedChild, + StructuredAgentSessionHostSession, + StructuredAgentSessionProviderChild, + StructuredAgentSessionProviderChildIdentity +} from './structured-agent-session-host-types' + +type ChildBearer = Pick & { + journal: Pick +} + +/** The fence a conversation write carries: the record's, which is where the next child starts. A + * child's own writes carry `child.fence`, which equals it while that child holds the lease. */ +export function structuredAgentSessionConversationFence( + store: Pick, + sessionId: string +): number { + return store.getRecord(sessionId)?.lease.runtimeFence ?? 0 +} + +/** For the end of a successful attach only: a failed one never wrote a child to take back. */ +export function indexProviderChild( + session: ChildBearer, + child: StructuredAgentSessionProviderChild +): void { + session.child = child +} + +export function markProviderChildStarted( + session: ChildBearer, + identity: StructuredAgentSessionProviderChildIdentity +): boolean { + const child = matchingChild(session, identity) + if (child) { + child.phase = 'ready' + } + return child !== null +} + +export function endProviderChild( + session: ChildBearer, + ended: Omit +): boolean { + const child = matchingChild(session, ended) + if (!child) { + return false + } + session.child = null + session.lastEndedChild = { + ...ended, + ...(child.startedFor === undefined ? {} : { startedFor: child.startedFor }), + endedAt: session.journal.cursor() + } + return true +} + +/** The conversation's last start died before it proved itself, and nothing started since. Only a + * send retries it: a view or an exit recovery would respawn into the same failure, adding a row. */ +export function failedProviderChildStart( + session: Pick +): StructuredAgentSessionEndedChild | null { + const ended = session.lastEndedChild + return !session.child && ended?.duringStartup && ended.cause !== 'user-stop' ? ended : null +} + +function matchingChild( + session: ChildBearer, + identity: StructuredAgentSessionProviderChildIdentity +): StructuredAgentSessionProviderChild | null { + const { child } = session + return child && child.generation === identity.generation && child.fence === identity.fence + ? child + : null +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts index c8a2e4bba14..5da0861aa19 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts @@ -5,12 +5,15 @@ import { afterEach, describe, expect, it } from 'vitest' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' import { HOST_TEST_NOW, HOST_TEST_SESSION, hostTestAttachParams, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' const CLAUDE_SESSION = 'claude-session' const hosts: StructuredAgentSessionHost[] = [] @@ -34,7 +37,12 @@ function claudeAdapter(): StructuredAgentSessionAdapter { observedAt: HOST_TEST_NOW } }), - dispatch: async () => ({ state: 'rejected', reason: 'unused' }), + dispatch: async () => ({ + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { + surface: 'rejection' + }) + }), cancelTurn: async () => ({ cancelled: false }), answerPrompt: async () => undefined, setOption: async () => undefined @@ -58,15 +66,8 @@ function createHost( return host } -async function abandonHost(host: StructuredAgentSessionHost): Promise { - host['runtimeState'].stopLeaseRenewal() - host['holds'].dispose() - await Promise.all([...host['sessions'].values()].map((session) => session.journal.close())) - host['sessions'].clear() -} - afterEach(async () => { - await Promise.all(hosts.splice(0).map(abandonHost)) + await Promise.all(hosts.splice(0).map(abandonStructuredAgentSessionHost)) await rm(root, { recursive: true, force: true }) root = '' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts index 52699fd062f..b1836983869 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts @@ -37,7 +37,17 @@ beforeEach(async () => { resetHostTestOperationIds() lifecycle = [] statuses = [] - const claude = fakeClaude({ initDelayMs: INIT_DELAY_MS, initModel: 'claude-opus-9' }) + // A CLI whose own default is not the catalog's: startup reports it through get_settings, + // since system/init arrives only with the first command. + const claude = fakeClaude({ + initDelayMs: INIT_DELAY_MS, + initModel: 'claude-opus-9', + settings: { + applied: { model: 'claude-opus-9', effort: 'high', advisor: null, ultracode: false }, + effective: { model: 'claude-opus-9', effortLevel: 'high', env: {} }, + sources: {} + } + }) adapter = new ClaudeStructuredSessionAdapter({ resolveLaunch: async () => ({ pathToClaudeCodeExecutable: 'claude', @@ -80,8 +90,8 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -function claudeParams() { - return hostTestAttachParams(null, { +function claudeParams(expectedRuntimeFence: number | null = null) { + return hostTestAttachParams(expectedRuntimeFence, { provider: 'claude', agent: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, @@ -102,7 +112,7 @@ describe('a publish-first Claude create whose init is slow', () => { expect(store.getRecord(SESSION)?.options?.model).toBeUndefined() expect(lastPhase()).toBe('starting') - await adapter.drainStartup(SESSION) + await adapter.awaitStarted(SESSION) await Promise.all(lifecycle) expect(store.getRecord(SESSION)?.options?.model).toBe('claude-opus-9') @@ -116,7 +126,7 @@ describe('a publish-first Claude create whose init is slow', () => { ).resolves.toMatchObject({ ok: true }) expect(store.getRecord(SESSION)?.options?.model).toBe('opus') - await adapter.drainStartup(SESSION) + await adapter.awaitStarted(SESSION) await Promise.all(lifecycle) expect(store.getRecord(SESSION)?.options?.model).toBe('opus') @@ -126,19 +136,21 @@ describe('a publish-first Claude create whose init is slow', () => { it('keeps the picked model across a resume whose new child starts on its own default', async () => { const params = claudeParams() await host.attach(CALLER, { ...params, options: { model: 'opus' } }) - await adapter.drainStartup(SESSION) + await adapter.awaitStarted(SESSION) await Promise.all(lifecycle) await host.close(SESSION) const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - // Reopening the chat: the surface's first hold resumes the session. - await host.hold(SESSION, 'chat-1') + // Starting the chat again resumes the session under a new fence. + await expect(host.attach(CALLER, claudeParams(releasedFence))).resolves.toMatchObject({ + ok: true + }) expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(releasedFence) // The new child's init reports its CLI default; the saved pick is restored over it. expect(store.getRecord(SESSION)?.options?.model).toBe('opus') expect(lastPhase()).toBe('starting') - await adapter.drainStartup(SESSION) + await adapter.awaitStarted(SESSION) await Promise.all(lifecycle) expect(store.getRecord(SESSION)?.options?.model).toBe('opus') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts index 55c718f967b..8f3c0390aaa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts @@ -16,6 +16,7 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { nativeSessionOptionsFromReport } from './structured-agent-session-option-restoration' +import { markProviderChildStarted } from './structured-agent-session-provider-child' export type StructuredAgentSessionProviderStartedContext = { deps: StructuredAgentSessionHostDeps @@ -23,7 +24,6 @@ export type StructuredAgentSessionProviderStartedContext = { serialize: (sessionId: string, task: () => Promise) => Promise now: () => number publishStatus?: (sessionId: string) => void - restartReleaseGrace: (sessionId: string) => void onBarrierError: (sessionId: string, error: unknown) => void } @@ -35,16 +35,14 @@ export function settleStructuredAgentSessionProviderStarted( return context.serialize(event.sessionId, async () => { const session = context.sessions.get(event.sessionId) if ( - !session?.hasProviderChild || - session.fence !== event.fence || - session.acquisitionGeneration !== event.acquisitionGeneration + !session || + !markProviderChildStarted(session, { + generation: event.acquisitionGeneration, + fence: event.fence + }) ) { return } - session.providerChildPhase = 'ready' - // Prompts held for the start are written now but open a turn only on their echo; a release - // tick in between would stop the child before it runs them. - context.restartReleaseGrace(event.sessionId) try { await persistStartedOptions(context, event) } catch (error) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts new file mode 100644 index 00000000000..9cc22f9547a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts @@ -0,0 +1,161 @@ +// Queued drafts across conversation commands: a /compact is a queued message +// and then a turn, so a capable send during it becomes a card that waits for it +// like any turn, while Delete and Send-now answer at once; a /clear in flight +// admits no draft onto the source it is superseding; and a draft /clear carries +// to its replacement is fingerprinted for the replacement, so the provider's +// echo folds into its sent bubble. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER as CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { + HOST_TEST_THREAD as THREAD, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig() +}) + +afterEach(() => rig.dispose()) + +const WAIT_REFUSAL = { + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'conversationCommandInFlight' }, + message: 'Wait for the conversation operation to finish.' + } +} + +function command(name: 'compact' | 'clear') { + const fields = { command: name } + return rig.host.conversationCommand(CALLER, { + envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), + ...fields + }) +} + +async function queuedId( + result: ReturnType['result'] +): Promise { + const queued = await result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + return queued.value.queued.messageId +} + +describe('a /compact in flight', () => { + /** A /compact the provider took: a queued message, then its own turn, running until the + * provider ends it (`finishCompact`). */ + async function compactRunning(): Promise { + expect(await command('compact')).toMatchObject({ ok: true, value: { command: 'compact' } }) + await eventually(() => expect(rig.compact).toHaveBeenCalledOnce()) + } + + it('turns a capable send into a card, which waits for the compaction and then drains', async () => { + await compactRunning() + const draftId = await queuedId(rig.send('sent while compacting', 'queue-if-active').result) + expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) + // The compaction's turn owes work, so the drain waits behind it like any turn. + await new Promise((resolve) => setTimeout(resolve, 150)) + expect(await rig.handoff(draftId)).toBeUndefined() + rig.finishCompact() + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + expect(await rig.drafts()).toHaveLength(0) + }) + + it('answers Delete at once, and Send-now sends its card behind the compaction like any send', async () => { + await compactRunning() + const deletedId = await queuedId(rig.send('deleted while compacting', 'queue-if-active').result) + const sentId = await queuedId(rig.send('sent now while compacting', 'queue-if-active').result) + const hung = new Promise<'hung'>((resolve) => setTimeout(() => resolve('hung'), 2_000)) + // Nothing holds the session's lane for the compaction's length any more. + expect(await Promise.race([rig.deleteQueued(deletedId), hung])).toMatchObject({ + ok: true, + value: { deleted: true } + }) + expect(await Promise.race([rig.sendNow(sentId), hung])).toMatchObject({ + ok: true, + value: { submission: { queuedMessageId: sentId } } + }) + // Accepted, then handed over only once the compaction ends — the order every send keeps. + await new Promise((resolve) => setTimeout(resolve, 150)) + expect(rig.dispatch).toHaveBeenCalledTimes(0) + rig.finishCompact() + await eventually(async () => expect((await rig.handoff(sentId))?.handedOverAt).toBeDefined()) + }) +}) + +describe('/clear', () => { + it('in flight, refuses a capable send as today: no card lands on the source it supersedes', async () => { + const attach = rig.host.attach.bind(rig.host) + let release: (() => void) | undefined + const released = new Promise((resolve) => { + release = resolve + }) + const spy = vi.spyOn(rig.host, 'attach').mockImplementationOnce(async (...args) => { + await released + return attach(...args) + }) + try { + const cleared = command('clear') + // Nothing is recorded before the clear commits; wait until it is starting the replacement. + await eventually(() => expect(spy).toHaveBeenCalled()) + expect(await rig.send('sent while clearing', 'queue-if-active').result).toEqual(WAIT_REFUSAL) + release?.() + const done = await cleared + const replacementId = done.ok ? done.value.replacementSessionId : undefined + if (!replacementId) { + throw new Error('expected a replacement session') + } + expect(await rig.drafts()).toHaveLength(0) + expect(await rig.drafts(replacementId)).toHaveLength(0) + } finally { + spy.mockRestore() + } + }) + + it("a carried draft sent on the replacement: the provider's echo folds into its one bubble", async () => { + const working = await rig.workingSend() + const draftId = await queuedId(rig.send('carried text', 'queue-if-active').result) + await rig.stop() + await rig.settleAccepted(working, 'a') + const cleared = await command('clear') + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!replacementId) { + throw new Error('expected a replacement session') + } + expect(await rig.sendNow(draftId, hostTestOperationId(), replacementId)).toMatchObject({ + ok: true, + value: { submission: expect.anything() } + }) + const journal = rig.host.collaboratorsForTests().sessions.get(replacementId)?.journal + const sent = journal?.submissions().findLast((entry) => entry.queuedMessageId === draftId) + if (!journal || !sent) { + throw new Error('expected the carried draft sent on the replacement') + } + await journal.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-echo', ordinal: 0 }, + hostTestMessage('carried text'), + { fence: sent.fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const snapshot = await rig.host.journalSnapshot(replacementId) + const userBubbles = snapshot.items.filter( + (item) => item.body.kind === 'message' && item.body.role === 'user' + ) + expect(userBubbles).toHaveLength(1) + expect(snapshot.submissions.find((entry) => entry.queuedMessageId === draftId)).toMatchObject({ + dispatchState: 'accepted' + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts new file mode 100644 index 00000000000..bc4697be6df --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts @@ -0,0 +1,302 @@ +// The one queue gate: admission, the drain step and Send-now consume a single +// typed hold decision, so the lists cannot drift — pinned here with a clear in +// doubt, Send-now's override set, and the replay-preference rule for a refused +// draft. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { structuredQueueHold } from './structured-agent-session-queued-messages' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER as CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { + HOST_TEST_SESSION as SESSION, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' + +let rig: QueuedMessageTestRig +let host: QueuedMessageTestRig['host'] +let store: QueuedMessageTestRig['store'] + +beforeEach(async () => { + rig = await createQueuedMessageTestRig() + ;({ host, store } = rig) +}) + +afterEach(() => rig.dispose()) + +const envelope: QueuedMessageTestRig['envelope'] = (...args) => rig.envelope(...args) +const send: QueuedMessageTestRig['send'] = (...args) => rig.send(...args) +const sendNow: QueuedMessageTestRig['sendNow'] = (...args) => rig.sendNow(...args) +const submission: QueuedMessageTestRig['submission'] = (...args) => rig.submission(...args) +const drafts: QueuedMessageTestRig['drafts'] = () => rig.drafts() +const workingSend: QueuedMessageTestRig['workingSend'] = () => rig.workingSend() +const settleAccepted: QueuedMessageTestRig['settleAccepted'] = (...args) => + rig.settleAccepted(...args) +const settleRejected: QueuedMessageTestRig['settleRejected'] = (...args) => + rig.settleRejected(...args) + +describe('the one queue gate', () => { + it('a clear an older build left prepared holds nothing: Send-now sends the draft', async () => { + await workingSend() + const queued = await send('queued behind the clear', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + // An unfinished /clear changed nothing the chat reads, so it refuses no send. + await store.setConversationCommand(SESSION, 1, { + command: 'clear', + runtimeFence: 1, + operationId: hostTestOperationId(), + callerKey: CALLER.callerKey, + phase: 'prepared', + state: 'unknown' + }) + expect(await sendNow(draftId)).toMatchObject({ ok: true }) + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + }) + + it('Send-now refuses on a pending prompt and overrides a running turn', async () => { + const working = await workingSend() + const queued = await send('queued mid-turn', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + await journal.appendItem( + { provider: 'orca', clientMessageId: 'prompt-1' }, + { + kind: 'approval', + title: 'Allow the tool?', + detail: null, + options: [], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + expect(await sendNow(draftId)).toMatchObject({ + ok: false, + refusal: { message: expect.stringContaining('pending request') } + }) + // Read in place: the gate runs on every admission and drain step. + const snapshot = vi.spyOn(journal, 'snapshot') + expect(structuredQueueHold({ journal, record: store.getRecord(SESSION), fence: 1 })).toBe( + 'prompt' + ) + expect(snapshot).not.toHaveBeenCalled() + snapshot.mockRestore() + await journal.appendItem( + { provider: 'orca', clientMessageId: 'prompt-1' }, + { + kind: 'approval', + title: 'Allow the tool?', + detail: null, + options: [], + resolution: { + state: 'resolved', + selectedOptionId: 'allow', + resolvedBy: 'client-1', + resolvedAt: 1 + } + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + // The turn still runs (`working`), which Send-now alone may override. + expect(await submission(working)).toMatchObject({ dispatchState: 'pending' }) + expect(await sendNow(draftId)).toMatchObject({ + ok: true, + value: { submission: expect.anything() } + }) + }) +}) + +describe('replay preference', () => { + it('a replayed send whose draft was refused answers with the returned card, never the rejected submission', async () => { + const working = await workingSend() + const body = hostTestMessage('refused later') + const clientOperationId = hostTestOperationId() + const params = { + envelope: envelope( + { body, delivery: 'queue-if-active' }, + 'agentSession.send', + clientOperationId + ), + body, + delivery: 'queue-if-active' as const + } + expect(await host.send(CALLER, params)).toMatchObject({ + ok: true, + value: { queued: { state: 'waiting' } } + }) + await settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(clientOperationId)).toBeDefined()) + await settleRejected(await rig.handoffId(clientOperationId), 'provider refused this payload') + await eventually(async () => + expect(await drafts()).toMatchObject([{ messageId: clientOperationId, state: 'returned' }]) + ) + // The original reply was lost; the retry must agree with the card, or the + // same text renders twice — once on a Retry row, once on the card. + const replay = await host.send(CALLER, params) + expect(replay).toMatchObject({ + ok: true, + replayed: true, + value: { queued: { messageId: clientOperationId, state: 'returned' } } + }) + if (replay.ok && 'submission' in replay.value) { + throw new Error('replay answered with the rejected submission') + } + }) +}) + +describe('replay of a deleted card', () => { + it('answers withdrawn once its row is pruned, never with the rejected hand-off it came back from', async () => { + const working = await workingSend() + const body = hostTestMessage('refused, then deleted') + const clientOperationId = hostTestOperationId() + const params = { + envelope: envelope( + { body, delivery: 'queue-if-active' }, + 'agentSession.send', + clientOperationId + ), + body, + delivery: 'queue-if-active' as const + } + await host.send(CALLER, params) + await settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(clientOperationId)).toBeDefined()) + await settleRejected(await rig.handoffId(clientOperationId), 'provider refused this payload') + await eventually(async () => + expect(await drafts()).toMatchObject([{ messageId: clientOperationId, state: 'returned' }]) + ) + expect(await rig.deleteQueued(clientOperationId)).toMatchObject({ + ok: true, + value: { deleted: true } + }) + // Retention later drops the tombstone; the rejected hand-off still names the draft. + const journal = host.collaboratorsForTests().sessions.get(SESSION)?.journal + if (!journal) { + throw new Error('expected the conversation open') + } + vi.spyOn(journal.queuedMessages, 'get').mockReturnValue(null) + const replay = await host.send(CALLER, params) + expect(replay).toMatchObject({ + ok: true, + replayed: true, + value: { queued: { messageId: clientOperationId, state: 'withdrawn' } } + }) + if (replay.ok && 'submission' in replay.value) { + throw new Error('replay answered with the rejected hand-off') + } + }) +}) + +describe('the hand-off link on answers', () => { + it('a replayed queued send, once drained, answers with the hand-off that names its draft', async () => { + const working = await workingSend() + const body = hostTestMessage('drained later') + const clientOperationId = hostTestOperationId() + const params = { + envelope: envelope( + { body, delivery: 'queue-if-active' }, + 'agentSession.send', + clientOperationId + ), + body, + delivery: 'queue-if-active' as const + } + await host.send(CALLER, params) + await settleAccepted(working, 'a') + await eventually(async () => + expect((await rig.handoff(clientOperationId))?.queuedMessageId).toBe(clientOperationId) + ) + const replayed = await host.send(CALLER, params) + expect(replayed).toMatchObject({ + ok: true, + replayed: true, + value: { submission: { queuedMessageId: clientOperationId } } + }) + // Handed off under a fresh id, never the draft's (the send operation's) own. + expect( + replayed.ok && 'submission' in replayed.value && replayed.value.submission.clientMessageId + ).not.toBe(clientOperationId) + // The first send, direct, names no draft. + expect(await submission(working)).not.toHaveProperty('queuedMessageId') + }) + + it('a queued send asked again after its ledger row is gone answers with its hand-off, never sending twice', async () => { + const working = await workingSend() + const body = hostTestMessage('asked again') + const clientOperationId = hostTestOperationId() + const params = { + envelope: envelope( + { body, delivery: 'queue-if-active' }, + 'agentSession.send', + clientOperationId + ), + body, + delivery: 'queue-if-active' as const, + userSend: true as const + } + await host.send(CALLER, params) + await settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(clientOperationId)).toBeDefined()) + const handedOffAs = await rig.handoffId(clientOperationId) + // The ledger forgot the id, so the send runs again rather than replaying. + const operations = store['transactions'].state.operations + for (const [key, row] of operations) { + if (row.operationId === clientOperationId) { + operations.delete(key) + } + } + const count = (await host.journalSnapshot(SESSION)).submissions.length + expect(await host.send(CALLER, params)).toMatchObject({ + ok: true, + replayed: false, + value: { submission: { clientMessageId: handedOffAs, queuedMessageId: clientOperationId } } + }) + expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(count) + }) +}) + +describe('Send-now rerun', () => { + it('a Send whose answer never settled answers again with the submission it made, never re-sending it', async () => { + const working = await workingSend() + const queued = await send('refused twice', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + await settleRejected(await rig.handoffId(draftId), 'first refusal') + await eventually(async () => + expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }]) + ) + const operationId = hostTestOperationId() + expect(await sendNow(draftId, operationId)).toMatchObject({ ok: true }) + await settleRejected(operationId, 'second refusal') + await eventually(async () => + expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }]) + ) + // The host died before the Send's answer settled: its ledger row is still pending, so it reruns. + for (const row of store['transactions'].state.operations.values()) { + if (row.operationId === operationId) { + row.outcome = { status: 'pending' } + } + } + const count = (await host.journalSnapshot(SESSION)).submissions.length + expect(await sendNow(draftId, operationId)).toMatchObject({ + ok: true, + value: { clientMessageId: operationId, submission: { dispatchState: 'rejected' } } + }) + expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(count) + expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts new file mode 100644 index 00000000000..fcc167cbb67 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -0,0 +1,299 @@ +// One real-host rig for the mid-turn queue suites: store, journal, adapter +// mocks, and the send/stop/draft helpers every suite shares. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, vi, type Mock } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { AgentSessionQueuePause } from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { rotateStructuredAgentSessionHostInstanceForTests } from './structured-agent-session-queued-pause' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +export const QUEUED_RIG_CALLER = { callerKey: 'client-1' } + +export function eventually(assertion: () => void | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +export type QueuedMessageTestRig = Awaited> + +export async function createQueuedMessageTestRig() { + const root = await mkdtemp(join(tmpdir(), 'orca-queued-messages-')) + resetHostTestOperationIds() + // Admitted: the message is written and unanswered, so the session owes work + // until the test settles it. + const dispatch: Mock = vi.fn(async () => ({ + state: 'admitted' as const + })) + const awaitStarted: Mock> = vi.fn( + async () => undefined + ) + // The provider's receipt of a /compact; its end arrives later, as `finishCompact` writes it. + const compact: Mock> = vi.fn(async () => ({ + state: 'accepted' as const, + providerIdentity: null + })) + let events: StructuredAgentSessionEventSink | undefined + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire: async ({ fence, spawnToken, events: sink }) => { + events = sink + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + acquisitionGeneration: 'generation-1', + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: 'created' as const, + mintedAtFence: fence, + observedAt: NOW + } + } + }, + dispatch, + awaitStarted, + closeSession: vi.fn(async () => true), + releaseAcquisition: vi.fn(async () => true), + compact, + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-1', + now: () => NOW + }) + expect(await host.attach(QUEUED_RIG_CALLER, hostTestAttachParams(null))).toMatchObject({ + ok: true + }) + + function envelope( + fields: Record, + method: string, + clientOperationId: string, + sessionId = SESSION + ) { + return { + sessionId, + clientOperationId, + expectedRuntimeFence: 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId, + fields + }) + } + } + + /** A client's send, as the `agentSession.send` RPC hands it to the host; + * `internal` is a host-side sender (orchestration mail, a restart continuation). */ + function send(text: string, delivery?: 'queue-if-active', options?: { internal?: true }) { + const body = hostTestMessage(text) + const clientOperationId = hostTestOperationId() + const fields = { body, ...(delivery ? { delivery } : {}) } + const result = host.send(QUEUED_RIG_CALLER, { + envelope: envelope(fields, 'agentSession.send', clientOperationId), + body, + ...(delivery ? { delivery } : {}), + ...(options?.internal ? {} : { userSend: true as const }) + }) + return { id: clientOperationId, result } + } + + function stop(clientOperationId = hostTestOperationId(), caller = QUEUED_RIG_CALLER) { + return host.cancel(caller, { + envelope: envelope({}, 'agentSession.cancel', clientOperationId) + }) + } + + function sendNow( + messageId: string, + clientOperationId = hostTestOperationId(), + sessionId = SESSION + ) { + return host.queuedMessageSend(QUEUED_RIG_CALLER, { + envelope: envelope( + { messageId }, + 'agentSession.queuedMessageSend', + clientOperationId, + sessionId + ), + messageId + }) + } + + function deleteQueued(messageId: string, clientOperationId = hostTestOperationId()) { + return host.queuedMessageDelete(QUEUED_RIG_CALLER, { + envelope: envelope({ messageId }, 'agentSession.queuedMessageDelete', clientOperationId), + messageId + }) + } + + async function submission(id: string): Promise { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === id + ) + } + + /** The latest submission that hands off this draft, found by its link. */ + async function handoff(draftId: string): Promise { + return (await host.journalSnapshot(SESSION)).submissions.findLast( + (entry) => entry.queuedMessageId === draftId + ) + } + + /** The submission id a draft went out under: never the draft's own id. */ + async function handoffId(draftId: string): Promise { + const sent = await handoff(draftId) + if (!sent) { + throw new Error(`draft ${draftId} has not been handed off`) + } + return sent.clientMessageId + } + + async function drafts( + sessionId = SESSION + ): Promise<{ messageId: string; state: string; paused?: true }[]> { + const page = await host.history({ sessionId, direction: 'tail' }) + if (!page.ok) { + throw new Error('history refused') + } + return (page.page.queuedMessages ?? []).map(({ messageId, state, paused }) => ({ + messageId, + state, + ...(paused ? { paused } : {}) + })) + } + + /** A first send that keeps the session working until the test settles it. */ + async function workingSend(): Promise { + const { id, result } = send('work on this') + await result + await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) + return id + } + + async function settleAccepted(id: string, itemId: string): Promise { + await host.settleLateDispatch({ + sessionId: SESSION, + clientMessageId: id, + providerIdentity: { + provider: 'codex', + threadId: THREAD, + turnId: `turn-${itemId}`, + ordinal: 0 + } + }) + } + + /** A provider refusal, written as the host writes one: the sentence and the typed fact. */ + async function settleRejected(id: string, providerText: string): Promise { + const detail = { text: providerText, audience: 'person' as const } + await host.settleLateDispatch({ + sessionId: SESSION, + clientMessageId: id, + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('providerRejected', { detail }), { + surface: 'rejection' + }) + }) + } + + /** What the provider's translator writes when a /compact's turn ends, as a success. */ + function finishCompact(): void { + const { command } = compact.mock.calls.at(-1)![0] + events!.appendLifecycleBatch!( + `turn-completed:${command.clientMessageId}`, + [ + { + kind: 'item', + identity: command.identity, + body: { ...command.running, state: 'completed', outcome: 'success', completedAt: NOW }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ], + { lifecycle: true } + ) + } + + /** A host-process restart, as the queue sees it: the conversation closes, and + * opens afresh under a new instance id while its rows survive. */ + async function restartHostProcess(): Promise { + await host.close(SESSION) + rotateStructuredAgentSessionHostInstanceForTests() + } + + /** The queue's published pause: null when it sends on its own. */ + async function queuePause(sessionId = SESSION): Promise { + const page = await host.history({ sessionId, direction: 'tail' }) + if (!page.ok) { + throw new Error('history refused') + } + return page.page.queuePause ?? null + } + + function resume(clientOperationId = hostTestOperationId()) { + return host.queuedMessagesResume(QUEUED_RIG_CALLER, { + envelope: envelope({}, 'agentSession.queuedMessagesResume', clientOperationId) + }) + } + + async function dispose(): Promise { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) + } + + return { + root, + store, + host, + dispatch, + awaitStarted, + compact, + finishCompact, + envelope, + send, + stop, + sendNow, + deleteQueued, + submission, + handoff, + handoffId, + drafts, + workingSend, + settleAccepted, + settleRejected, + restartHostProcess, + queuePause, + resume, + dispose + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts new file mode 100644 index 00000000000..aa9d31cbf10 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts @@ -0,0 +1,862 @@ +// Mid-turn queueing against the real host, store and journal: a capable send +// while the session owes work becomes a draft, the drain converts exactly one +// draft when the work settles, Stop holds the queue (never withdrawing text) +// until a user send starts its turn and lifts the pause, /clear carries the +// cards to its replacement session, and a refused conversion comes back as a +// returned card while a withdrawn one waits again. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + QUEUED_MESSAGE_PAUSED_SEND_FAILED, + type AgentSessionQueuedMessage, + type AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' +import { ConversationCommandParams } from '../../../shared/rpc-contract/structured-agent-session-params' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' +import { rotateStructuredAgentSessionHostInstanceForTests } from './structured-agent-session-queued-pause' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER as CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { + HOST_TEST_SESSION as SESSION, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' + +let rig: QueuedMessageTestRig +let host: QueuedMessageTestRig['host'] +let store: QueuedMessageTestRig['store'] +let dispatch: QueuedMessageTestRig['dispatch'] +let awaitStarted: QueuedMessageTestRig['awaitStarted'] + +beforeEach(async () => { + rig = await createQueuedMessageTestRig() + ;({ host, store, dispatch, awaitStarted } = rig) +}) + +afterEach(() => rig.dispose()) + +const envelope: QueuedMessageTestRig['envelope'] = (...args) => rig.envelope(...args) +const send: QueuedMessageTestRig['send'] = (...args) => rig.send(...args) +const stop: QueuedMessageTestRig['stop'] = (...args) => rig.stop(...args) +const sendNow: QueuedMessageTestRig['sendNow'] = (...args) => rig.sendNow(...args) +const deleteQueued: QueuedMessageTestRig['deleteQueued'] = (...args) => rig.deleteQueued(...args) +const drafts: QueuedMessageTestRig['drafts'] = (...args) => rig.drafts(...args) +const workingSend: QueuedMessageTestRig['workingSend'] = () => rig.workingSend() +const settleAccepted: QueuedMessageTestRig['settleAccepted'] = (...args) => + rig.settleAccepted(...args) +const settleRejected: QueuedMessageTestRig['settleRejected'] = (...args) => + rig.settleRejected(...args) + +describe('accept', () => { + it('queues a capable send while the session owes work; an ordinary send still dispatches', async () => { + await workingSend() + const queued = await send('queued behind', 'queue-if-active').result + expect(queued).toMatchObject({ + ok: true, + value: { queued: { position: 1, state: 'waiting' } } + }) + // The draft is not a submission, feeds no reducer, and owes no work. + expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(1) + expect(await drafts()).toMatchObject([{ state: 'waiting' }]) + }) + + it('replays the same queued answer for the same operation id', async () => { + await workingSend() + const body = hostTestMessage('queued behind') + const clientOperationId = hostTestOperationId() + const params = { + envelope: envelope( + { body, delivery: 'queue-if-active' }, + 'agentSession.send', + clientOperationId + ), + body, + delivery: 'queue-if-active' as const + } + expect(await host.send(CALLER, params)).toMatchObject({ + ok: true, + replayed: false, + value: { queued: { state: 'waiting' } } + }) + expect(await host.send(CALLER, params)).toMatchObject({ + ok: true, + replayed: true, + value: { queued: { state: 'waiting' } } + }) + expect(await drafts()).toHaveLength(1) + }) + + it('routes an image send to the immediate path even while working (text-only v1)', async () => { + await workingSend() + const body = { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'image-ref' as const, path: '/tmp/shot.png' }] + } + const clientOperationId = hostTestOperationId() + const result = await host.send(CALLER, { + envelope: envelope( + { body, delivery: 'queue-if-active' }, + 'agentSession.send', + clientOperationId + ), + body, + delivery: 'queue-if-active' + }) + expect(result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + expect(await drafts()).toHaveLength(0) + }) + + it('a send without the delivery field never queues, whatever the session is doing', async () => { + await workingSend() + const { result } = send('old client send') + expect(await result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + expect(await drafts()).toHaveLength(0) + }) + + it('refuses past the draft-count budget with a readable message', async () => { + await workingSend() + for (let index = 0; index < 20; index += 1) { + expect(await send(`draft ${index}`, 'queue-if-active').result).toMatchObject({ ok: true }) + } + expect(await send('one too many', 'queue-if-active').result).toMatchObject({ + ok: false, + refusal: { message: expect.stringContaining('queue is full') } + }) + }) +}) + +describe('drain', () => { + it('drains a single draft when the owed work settles, and one of two drafts per settle (A1)', async () => { + const working = await workingSend() + const first = await send('first queued', 'queue-if-active').result + const second = await send('second queued', 'queue-if-active').result + if (!first.ok || !('queued' in first.value) || !second.ok || !('queued' in second.value)) { + throw new Error('expected queued receipts') + } + const firstId = first.value.queued.messageId + const secondId = second.value.queued.messageId + await settleAccepted(working, 'a') + // The drain converts the OLDEST actionable draft; the consumed submission + // owes work again, which holds the second draft (one message per turn). + await eventually(async () => expect(await rig.handoff(firstId)).toBeDefined()) + expect(await rig.handoff(secondId)).toBeUndefined() + expect(await drafts()).toMatchObject([{ messageId: secondId, state: 'waiting' }]) + await settleAccepted(await rig.handoffId(firstId), 'b') + await eventually(async () => expect(await rig.handoff(secondId)).toBeDefined()) + expect(await drafts()).toHaveLength(0) + }) + + it('takes no serialized drain step while the session is working, then drains when the work settles', async () => { + const working = await workingSend() + const flush = vi.spyOn(host, 'flushStreamedEvents') + const queued = await send('waits for the turn', 'queue-if-active').result + await send('and another', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + // Every wake during the turn is answered by the pre-check, not a step. + expect(flush).not.toHaveBeenCalled() + await settleAccepted(working, 'a') + const draftId = queued.value.queued.messageId + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + expect(flush).toHaveBeenCalled() + }) + + it('a refused conversion returns the card with its stored reason, and an idle send overtakes a lone returned card (N1)', async () => { + const working = await workingSend() + const queued = await send('will be refused', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + await settleRejected(await rig.handoffId(draftId), 'provider refused this payload') + await eventually(async () => + expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }]) + ) + // Classified like a rejected submission: from the fact, not the sentence. + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.queuedMessages?.[0]?.returnedRejection).toEqual({ + kind: 'providerRejected', + detail: { text: 'provider refused this payload', audience: 'person' } + }) + // The lone returned card traps nothing: a new capable send goes immediately. + const overtaking = await send('sent past the card', 'queue-if-active').result + expect(overtaking).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + // And the card still offers Send: a fresh submission id re-delivers it. + const resent = await sendNow(draftId) + expect(resent).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + if (!resent.ok || !('submission' in resent.value)) { + throw new Error('expected the submission arm') + } + expect(resent.value.submission.clientMessageId).not.toBe(draftId) + // The answer names the card it sent; clients read that, never id equality. + expect(resent.value.submission.queuedMessageId).toBe(draftId) + expect(await drafts()).toHaveLength(0) + // Refused again: the card returns, matched through its current submission (N4). + await settleRejected(resent.value.submission.clientMessageId, 'refused again') + await eventually(async () => + expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }]) + ) + }) + + it('a skipped settlement hook heals on the next drain step, not only at the next open', async () => { + const working = await workingSend() + const queued = await send('refused while the hook fails', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const hook = vi + .spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction') + .mockImplementationOnce(() => { + throw new Error('bookkeeping failed') + }) + try { + await settleRejected(await rig.handoffId(draftId), 'provider refused this payload') + await eventually(async () => + expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }]) + ) + } finally { + hook.mockRestore() + warn.mockRestore() + } + }) + + it('a waiting draft behind a returned card does not drain until the card is acted on (S5)', async () => { + const working = await workingSend() + const first = await send('to be refused', 'queue-if-active').result + const second = await send('waits behind the card', 'queue-if-active').result + if (!first.ok || !('queued' in first.value) || !second.ok || !('queued' in second.value)) { + throw new Error('expected queued receipts') + } + await settleAccepted(working, 'a') + const firstId = first.value.queued.messageId + const secondId = second.value.queued.messageId + await eventually(async () => expect(await rig.handoff(firstId)).toBeDefined()) + await settleRejected(await rig.handoffId(firstId), 'refused') + await eventually(async () => + expect(await drafts()).toMatchObject([ + { messageId: firstId, state: 'returned' }, + { messageId: secondId, state: 'waiting' } + ]) + ) + // Deleting the card unblocks the one behind it. + expect(await deleteQueued(firstId)).toMatchObject({ ok: true, value: { deleted: true } }) + await eventually(async () => expect(await rig.handoff(secondId)).toBeDefined()) + }) +}) + +describe('held drafts', () => { + it('a restart pauses the queue, reason restarted, and it survives a reopen; never auto-sent', async () => { + const working = await workingSend() + const queued = await send('written before the restart', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await rig.restartHostProcess() + await settleAccepted(working, 'a') + // The queue is paused, not the card: it carries no hold of its own. + expect(await drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + await host.close(SESSION) + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.handoff(draftId)).toBeUndefined() + expect(await sendNow(draftId)).toMatchObject({ + ok: true, + value: { submission: expect.anything() } + }) + }) + + it("a restart's pause also lifts when the user's next send starts its turn, exactly like a Stop's", async () => { + const working = await workingSend() + const queued = await send('written before the restart', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await rig.restartHostProcess() + await settleAccepted(working, 'a') + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.handoff(draftId)).toBeUndefined() + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + // The user's send starting its turn lifts it, and adopts the row into this instance. + const next = send('user starts a new turn') + await next.result + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + await settleAccepted(next.id, 'b') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + }) + + it('a failed conversion leaves the draft waiting and paused with its error; Send retries', async () => { + const working = await workingSend() + const queued = await send('conversion fails once', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + const original = AgentSessionJournal.prototype.appendSubmission + const append = vi + .spyOn(AgentSessionJournal.prototype, 'appendSubmission') + .mockImplementationOnce(async () => { + throw new Error('disk full') + }) + try { + await settleAccepted(working, 'a') + await eventually(async () => + expect(await drafts()).toMatchObject([ + { messageId: draftId, state: 'waiting', paused: true } + ]) + ) + } finally { + append.mockRestore() + } + expect(AgentSessionJournal.prototype.appendSubmission).toBe(original) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + // A marker the client localizes, never host-authored copy. + expect(page.ok && page.page.queuedMessages?.[0]?.pausedReason).toBe( + QUEUED_MESSAGE_PAUSED_SEND_FAILED + ) + // The marker is stored on the row, so a host restart keeps "Couldn't send" + // instead of downgrading the card to a plain pause. + rotateStructuredAgentSessionHostInstanceForTests() + const restarted = await host.history({ sessionId: SESSION, direction: 'tail' }) + expect(restarted.ok && restarted.page.queuedMessages?.[0]?.pausedReason).toBe( + QUEUED_MESSAGE_PAUSED_SEND_FAILED + ) + expect(await rig.handoff(draftId)).toBeUndefined() + expect(await sendNow(draftId)).toMatchObject({ + ok: true, + value: { submission: expect.anything() } + }) + expect(await drafts()).toHaveLength(0) + }) +}) + +describe('Stop and Delete', () => { + it('Stop pauses the queue — from ANY client — and the cards stay published; no text rides the answer', async () => { + await workingSend() + const first = await send('first text', 'queue-if-active').result + const second = await send('second text', 'queue-if-active').result + if (!first.ok || !('queued' in first.value) || !second.ok || !('queued' in second.value)) { + throw new Error('expected queued receipts') + } + // The Stop comes from a DIFFERENT client than the one that typed the + // drafts: it must never move their text anywhere. + const operationId = hostTestOperationId() + const stopped = await stop(operationId, { callerKey: 'client-2' }) + expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(stopped.ok && Object.keys(stopped.value).sort()).toEqual(['cancelled']) + expect(await drafts()).toEqual([ + { messageId: first.value.queued.messageId, state: 'waiting' }, + { messageId: second.value.queued.messageId, state: 'waiting' } + ]) + // One pause for the whole queue: "Queue paused because you interrupted". + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + // A lost acknowledgement replays the settled Stop; still no text, no field. + const replayed = await stop(operationId, { callerKey: 'client-2' }) + expect(replayed).toMatchObject({ ok: true, replayed: true, value: { cancelled: false } }) + expect(replayed.ok && Object.keys(replayed.value).sort()).toEqual(['cancelled']) + expect(await drafts()).toHaveLength(2) + }) + + /** A draft consumed into a submission the delivery loop has not handed over: + * the loop is held at the child's start proof until the returned release. */ + async function consumedButNotHandedOver(): Promise<{ draftId: string; release: () => void }> { + const working = await workingSend() + const queued = await send('stopped in flight', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + let release: () => void = () => undefined + awaitStarted.mockImplementationOnce( + () => new Promise((resolve) => (release = () => resolve(undefined))) + ) + await settleAccepted(working, 'a') + const draftId = queued.value.queued.messageId + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + expect((await rig.handoff(draftId))?.handedOverAt).toBeUndefined() + return { draftId, release: () => release() } + } + + it("a Stop between consume and the agent's receipt sends the draft back to waiting, paused like the rest", async () => { + const { draftId, release } = await consumedButNotHandedOver() + const stopped = await stop() + release() + expect(stopped).toMatchObject({ ok: true }) + expect(await drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + // Nothing failed, so the card carries no refusal: it reads like any other paused card. + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + const card = page.ok ? page.page.queuedMessages?.[0] : undefined + expect(card).not.toHaveProperty('returnedReason') + expect(card).not.toHaveProperty('returnedRejection') + expect(dispatch).toHaveBeenCalledTimes(1) + }) + + it('the Stop pause survives eviction and reopen, and Send-now overrides it', async () => { + const working = await workingSend() + const queued = await send('paused by stop', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await stop() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + await settleAccepted(working, 'a') + // Evict the handle and reopen (the history read opens the conversation at + // rest): the pause is derived from what the journal holds, so nothing drains. + await host.close(SESSION) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.handoff(draftId)).toBeUndefined() + // Send-now overrides the pause — the user acting is a release. + expect(await sendNow(draftId)).toMatchObject({ + ok: true, + value: { submission: expect.anything() } + }) + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + }) + + it("the user's next send lifts the stopped hold once its turn starts, and the held draft drains after that turn", async () => { + const working = await workingSend() + const queued = await send('paused by stop', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await stop() + await settleAccepted(working, 'a') + // Settling the stopped turn is not the user starting one: still paused. + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.handoff(draftId)).toBeUndefined() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + // The host accepting the send is not yet a turn: the pause lifts when the + // provider accepts it, and the draft drains after that turn. + const next = send('user starts a new turn') + await next.result + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + await settleAccepted(next.id, 'b') + expect(await rig.queuePause()).toBeNull() + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + }) + + it('a host-internal send (orchestration mail, a restart continuation, a host-sent launch prompt) never lifts the pause', async () => { + const working = await workingSend() + const queued = await send('paused by stop', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await stop() + await settleAccepted(working, 'a') + // All three reach the host as a send the client send RPC did not make. + const mail = send('coordinator mail', undefined, { internal: true }) + expect(await mail.result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + // The journal records who asked, which is what the pause reads. + expect(await rig.submission(mail.id)).toMatchObject({ origin: 'host' }) + expect(await rig.submission(working)).toMatchObject({ origin: 'client' }) + await settleAccepted(mail.id, 'b') + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.handoff(draftId)).toBeUndefined() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + }) + + it("a user send lifts nothing from a 'send_failed' hold — that card waits for its explicit Send", async () => { + const working = await workingSend() + const queued = await send('conversion fails once', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + const append = vi + .spyOn(AgentSessionJournal.prototype, 'appendSubmission') + .mockImplementationOnce(async () => { + throw new Error('disk full') + }) + try { + await settleAccepted(working, 'a') + await eventually(async () => + expect(await drafts()).toMatchObject([ + { messageId: draftId, state: 'waiting', paused: true } + ]) + ) + } finally { + append.mockRestore() + } + const next = send('user starts a new turn') + await next.result + await settleAccepted(next.id, 'b') + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.handoff(draftId)).toBeUndefined() + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.queuedMessages?.[0]?.pausedReason).toBe( + QUEUED_MESSAGE_PAUSED_SEND_FAILED + ) + // The explicit Send is still the release. + expect(await sendNow(draftId)).toMatchObject({ + ok: true, + value: { submission: expect.anything() } + }) + }) + + it('a Stop whose pause record fails still interrupts; only the pause is lost, and it is reported', async () => { + await workingSend() + const queued = await send('kept by the stop', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const record = vi + .spyOn(JournalQueuedMessages.prototype, 'recordPause') + .mockRejectedValueOnce(new Error('disk full')) + const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + try { + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(warned).toHaveBeenCalledWith(expect.stringContaining('queue pause'), expect.anything()) + } finally { + record.mockRestore() + warned.mockRestore() + } + expect(await rig.queuePause()).toBeNull() + // The draft is intact (never withdrawn), merely unpaused. + expect(await drafts()).toEqual([{ messageId: queued.value.queued.messageId, state: 'waiting' }]) + }) + + it('Delete returns no body, replays from the receipt, and a fresh delete reports the disposition', async () => { + await workingSend() + const queued = await send('delete me', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + const operationId = hostTestOperationId() + const deleted = await deleteQueued(draftId, operationId) + expect(deleted).toMatchObject({ + ok: true, + replayed: false, + value: { deleted: true, messageId: draftId } + }) + // The card leaving the published list is the whole answer. + expect(deleted.ok && Object.keys(deleted.value).sort()).toEqual(['deleted', 'messageId']) + expect(await drafts()).toHaveLength(0) + expect(await deleteQueued(draftId, operationId)).toMatchObject({ + ok: true, + replayed: true, + value: { deleted: true, messageId: draftId } + }) + // A FRESH delete of the already-withdrawn draft reports the disposition. + expect(await deleteQueued(draftId)).toMatchObject({ + ok: true, + value: { deleted: false, disposition: 'withdrawn' } + }) + }) +}) + +describe('/clear', () => { + function clear(clientOperationId: string) { + const fields = { command: 'clear' as const } + return host.conversationCommand(CALLER, { + envelope: envelope(fields, 'agentSession.conversationCommand', clientOperationId), + ...fields + }) + } + + /** Two drafts paused by a Stop, then the work settled so command admission + * has nothing pending. */ + async function pausedDrafts(): Promise<[string, string]> { + const working = await workingSend() + const first = await send('first text', 'queue-if-active').result + const second = await send('second text', 'queue-if-active').result + if (!first.ok || !('queued' in first.value) || !second.ok || !('queued' in second.value)) { + throw new Error('expected queued receipts') + } + await stop() + await settleAccepted(working, 'a') + return [first.value.queued.messageId, second.value.queued.messageId] + } + + it('the clear schema refuses the never-shipped withdraw opt-in', () => { + const base = { envelope: envelope({}, 'agentSession.conversationCommand', 'op-schema') } + expect(ConversationCommandParams.safeParse({ ...base, command: 'clear' }).success).toBe(true) + expect( + ConversationCommandParams.safeParse({ ...base, command: 'clear', withdrawQueued: true }) + .success + ).toBe(false) + }) + + it('carries the drafts to the replacement session as visible cards on a paused queue — the same for every client', async () => { + const [firstId, secondId] = await pausedDrafts() + const operationId = hostTestOperationId() + const cleared = await clear(operationId) + expect(cleared).toMatchObject({ ok: true, value: { command: 'clear', state: 'completed' } }) + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!replacementId) { + throw new Error('expected a replacement session') + } + // The source's cards are spent tombstones; the replacement shows them on a + // queue paused by the clear — not "because you interrupted" — until the user + // acts: Resume, or their next send starting its turn. + expect(await drafts()).toHaveLength(0) + expect(await drafts(replacementId)).toEqual([ + { messageId: firstId, state: 'waiting' }, + { messageId: secondId, state: 'waiting' } + ]) + expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + // Paused from before the first carried card lands: the idle replacement auto-sends nothing. + await new Promise((resolve) => setTimeout(resolve, 250)) + expect((await host.journalSnapshot(replacementId)).submissions).toHaveLength(0) + // A lost acknowledgement's replay re-runs nothing and duplicates nothing. + const replayed = await clear(operationId) + expect(replayed).toMatchObject({ ok: true, replayed: true }) + expect(await drafts(replacementId)).toHaveLength(2) + // The carried card still answers Send-now, on the replacement. + expect(await rig.sendNow(firstId, hostTestOperationId(), replacementId)).toMatchObject({ + ok: true, + value: { submission: expect.anything() } + }) + }) + + it("the replacement's 'cleared' pause lifts through Resume exactly like a Stop's", async () => { + const [firstId] = await pausedDrafts() + const cleared = await clear(hostTestOperationId()) + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!replacementId) { + throw new Error('expected a replacement session') + } + expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + const resumed = await host.queuedMessagesResume(CALLER, { + envelope: envelope( + {}, + 'agentSession.queuedMessagesResume', + hostTestOperationId(), + replacementId + ) + }) + expect(resumed).toMatchObject({ ok: true, value: { resumed: true } }) + expect(await rig.queuePause(replacementId)).toBeNull() + await eventually(async () => + expect( + (await host.journalSnapshot(replacementId)).submissions.some( + (entry) => entry.queuedMessageId === firstId + ) + ).toBe(true) + ) + }) + + it('a carry whose insert fails leaves no pause over the empty replacement', async () => { + await pausedDrafts() + const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const insert = vi + .spyOn(JournalQueuedMessages.prototype, 'insert') + .mockRejectedValueOnce(new Error('disk full')) + let replacementId: string | undefined + try { + const cleared = await clear(hostTestOperationId()) + replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + } finally { + insert.mockRestore() + warned.mockRestore() + } + if (!replacementId) { + throw new Error('expected a replacement session') + } + expect(await drafts(replacementId)).toHaveLength(0) + const journal = host.collaboratorsForTests().sessions.get(replacementId)?.journal + expect(journal?.queuedMessages.pause()).toBeNull() + }) + + it('a returned card carries over as a plain waiting draft on the paused replacement', async () => { + const working = await workingSend() + const queued = await send('refused then cleared', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + await settleRejected(await rig.handoffId(draftId), 'provider refused this payload') + await eventually(async () => + expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }]) + ) + const cleared = await clear(hostTestOperationId()) + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!replacementId) { + throw new Error('expected a replacement session') + } + // The refusal belonged to the source's submissions; on the replacement the + // text is simply a waiting draft again, behind the replacement's pause. + expect(await drafts(replacementId)).toEqual([{ messageId: draftId, state: 'waiting' }]) + expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + expect(await drafts()).toHaveLength(0) + }) + + it('a clear an older build left prepared holds no draft: it drains when the turn settles', async () => { + const working = await workingSend() + const queued = await send('behind the clear', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await store.setConversationCommand(SESSION, 1, { + command: 'clear', + runtimeFence: 1, + operationId: hostTestOperationId(), + callerKey: CALLER.callerKey, + phase: 'prepared', + state: 'unknown' + }) + await settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + }) + + it('a clear with no drafts carries nothing and answers exactly as before', async () => { + const cleared = await clear(hostTestOperationId()) + expect(cleared).toMatchObject({ ok: true, value: { command: 'clear', state: 'completed' } }) + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!replacementId) { + throw new Error('expected a replacement session') + } + expect(await drafts(replacementId)).toHaveLength(0) + }) +}) + +describe('publication', () => { + async function subscribeEvents(): Promise { + const events: AgentSessionSubscribeEvent[] = [] + await host.subscribe({ + id: 'subscriber-1', + sessionId: SESSION, + emit: (event) => events.push(event) + }) + return events + } + + function queuedFrames(events: AgentSessionSubscribeEvent[]): AgentSessionQueuedMessage[][] { + return events.flatMap((event) => + event.type !== 'end' && event.queuedMessages !== undefined && event.queuedMessages !== null + ? [event.queuedMessages] + : [] + ) + } + + it('hydrates the list on subscribe, publishes draft inserts at an unchanged cursor, and carries the shrunk list with the consumed submission in one frame', async () => { + const working = await workingSend() + const events = await subscribeEvents() + // Hydration: the opening snapshot carries the (empty) list. + expect(events[0]).toMatchObject({ type: 'snapshot', queuedMessages: [] }) + const queued = await send('queued behind', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + // The insert writes no journal row, yet the caught-up publish delivers it. + await eventually(() => { + const lists = queuedFrames(events) + expect(lists.at(-1)).toMatchObject([{ messageId: draftId, state: 'waiting' }]) + }) + await settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + // The frame that carries the consumed submission also carries the shrunk list. + const consumeFrame = events.find( + (event) => + event.type === 'batch' && + event.batch.submissions.some((entry) => entry.queuedMessageId === draftId) + ) + expect(consumeFrame).toBeDefined() + if (consumeFrame?.type === 'batch') { + expect(consumeFrame.queuedMessages).toEqual([]) + } + }) + + it('a failed conversion reaches live subscribers as a paused card, with no further journal commit', async () => { + const working = await workingSend() + const events = await subscribeEvents() + const queued = await send('conversion fails once', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + const append = vi + .spyOn(AgentSessionJournal.prototype, 'appendSubmission') + .mockImplementationOnce(async () => { + throw new Error('disk full') + }) + try { + await settleAccepted(working, 'a') + await eventually(() => + expect(queuedFrames(events).at(-1)).toMatchObject([ + { messageId: draftId, paused: true, pausedReason: QUEUED_MESSAGE_PAUSED_SEND_FAILED } + ]) + ) + } finally { + append.mockRestore() + } + // Send releases the process-level pause, which later tests' reused ids would otherwise inherit. + expect(await sendNow(draftId)).toMatchObject({ ok: true }) + }) + + it("live frames carry the queue's pause with the list, and Resume's lift", async () => { + await workingSend() + const events = await subscribeEvents() + const queued = await send('paused by stop', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const pauses = () => + events.flatMap((event) => + event.type !== 'end' && event.queuePause !== undefined ? [event.queuePause] : [] + ) + await eventually(() => expect(pauses().at(-1)).toBeNull()) + await stop() + await eventually(() => expect(pauses().at(-1)).toEqual({ reason: 'stopped' })) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(() => expect(pauses().at(-1)).toBeNull()) + }) + + it('an idle Stop that takes no effect pauses nothing', async () => { + const working = await workingSend() + const first = await send('to be refused', 'queue-if-active').result + const second = await send('waits behind the card', 'queue-if-active').result + if (!first.ok || !('queued' in first.value) || !second.ok || !('queued' in second.value)) { + throw new Error('expected queued receipts') + } + const firstId = first.value.queued.messageId + await settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(firstId)).toBeDefined()) + await settleRejected(await rig.handoffId(firstId), 'refused') + await eventually(async () => + expect(await drafts()).toMatchObject([{ messageId: firstId, state: 'returned' }, {}]) + ) + // Idle, nothing in flight and nothing withdrawn: the Stop changes nothing. + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) + expect(await rig.queuePause()).toBeNull() + }) + + it('an unchanged list is not re-sent on later frames', async () => { + await workingSend() + const events = await subscribeEvents() + await send('queued behind', 'queue-if-active').result + await eventually(() => expect(queuedFrames(events).length).toBeGreaterThan(0)) + const framesAfterInsert = queuedFrames(events).length + // Another journal commit with no draft change re-sends nothing. + const { result } = send('another working send') + await result + await eventually(async () => { + const last = events.at(-1) + expect(last?.type).toBe('batch') + }) + expect(queuedFrames(events).length).toBe(framesAfterInsert) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts new file mode 100644 index 00000000000..273af6dfee6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -0,0 +1,378 @@ +// Mid-turn queueing: the accept decision that turns a send into a host-held +// draft, the serialized drain that converts one draft into an ordinary +// submission when the session stops owing work, and the published draft list. +// +// Drafts are never owed work: they feed no reducer, no working status, no +// teardown and no idle sweep. The drain re-reads every gate inside its own +// serialized step, so there is no loop state to disagree with the journal. + +import { randomUUID } from 'node:crypto' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { + QUEUED_MESSAGE_PAUSED_SEND_FAILED, + type AgentSessionSendResult, + type AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import { + createStructuredAgentSessionOperationId, + structuredAgentSessionPayloadFingerprint +} from '../../../shared/structured-agent-session-mutation' +import { queuedSendAnswer } from './structured-agent-session-queued-send-answer' +import { structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' +import { isUnsettledQueuedMessage } from '../agent-session-journal/queued-message-table' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { QueuedMessageNotConsumableError } from '../agent-session-journal/journal-queued-messages' +import type { QueuedMessageRow } from '../agent-session-journal/queued-message-table' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { + structuredAgentSessionHostInstance, + structuredQueuePause +} from './structured-agent-session-queued-pause' + +/** Budget at accept, in the send schema's own unit (`Buffer.byteLength` of the + * serialized blocks); refused readably rather than trimmed. */ +export const QUEUED_MESSAGES_MAX_COUNT = 20 +export const QUEUED_MESSAGES_MAX_TOTAL_BYTES = 1024 * 1024 + +/** Text-only v1: any image block routes to the immediate path. */ +export function queuedMessageBodyIsTextOnly(body: AgentJournalMessageItem): boolean { + return body.blocks.every((block) => block.type === 'text') +} + +/** Walks the reduced items in place: the gate runs on every admission and + * drain step, so it must not render a snapshot of the whole journal. */ +export function pendingPromptExists(journal: Pick): boolean { + let pending = false + journal.visitItems((_itemId, _sequence, body) => { + if ( + !pending && + (body.kind === 'approval' || body.kind === 'question') && + body.resolution.state === 'pending' + ) { + pending = true + } + }) + return pending +} + +/** Waiting, not held on its own, not positioned behind a returned card, and the + * queue not paused. The admission rule (§accept) and the drain's selection + * both read it. */ +function oldestActionableQueuedMessage( + journal: Pick +): QueuedMessageRow | null { + const rows = journal.queuedMessages.list() + // Nothing waiting costs no pause derivation: this runs on every journal publish. + if (!rows.some((row) => row.state === 'waiting') || structuredQueuePause(journal) !== null) { + return null + } + for (const row of rows) { + if (row.state === 'returned') { + // A returned card blocks everything after it until the user acts. + return null + } + if (row.state === 'waiting' && row.holdReason === null) { + return row + } + } + return null +} + +/** + * Why the queue is not sending right now — ONE decision for admission, the + * drain step and Send-now, so the lists cannot drift. Each caller's override + * policy sits next to its use: + * + * admission: `blocked` refuses (the immediate path's own refusal); any other + * hold, or an actionable backlog, queues the send as a draft. + * drain step: any hold returns early; whatever clears it publishes or + * commits, which re-derives. + * Send-now: overrides only `working` (plus FIFO order and the stored hold); + * `blocked` and `prompt` refuse readably. + * + * `blocked` is whatever refuses any send (an uncertain rewind, a cleared source); + * the rest are waits. A /compact is a queued message and then a turn, + * so it holds the queue as `working`; an older build's compaction record belongs + * to a child this host no longer runs and holds nothing. Host-local vocabulary — + * never on the wire. + */ +export type StructuredQueueHold = 'blocked' | 'working' | 'prompt' + +export function structuredQueueHold(input: { + journal: AgentSessionJournal + record: AgentSessionRecord | null + fence: number +}): StructuredQueueHold | null { + // Whatever refuses any send refuses the queue too: an uncertain rewind or a source a + // clear superseded. One rule, the immediate path's own. + if (structuredAgentSessionSendBlock(input.record)) { + return 'blocked' + } + const { journal } = input + // `prompt` outranks `working`: it is the one wait Send-now may not override, + // so a prompt raised mid-turn must not read as merely `working`. + if (pendingPromptExists(journal)) { + return 'prompt' + } + if ( + isStructuredAgentSessionMainAgentWorking( + journal.activeTurnId(), + journal.submissions(), + input.fence + ) + ) { + return 'working' + } + return null +} + +/** + * Whether a `queue-if-active` send becomes a draft: any queue hold short of + * `blocked`, or an actionable draft already exists (FIFO backlog — an + * ADMISSION rule only, never a drain gate). A lone returned card, or a paused + * queue, does not trap a new send: the user acting now wins, and that send's + * turn starting is what lifts the pause — Orca's own queue policy, a stated + * deviation from held-head backlog counting. + */ +export function shouldQueueStructuredAgentSessionSend(input: { + journal: AgentSessionJournal + record: AgentSessionRecord | null + fence: number +}): boolean { + const hold = structuredQueueHold(input) + if (hold === 'blocked') { + // The immediate path's own refusal (`structuredAgentSessionSendBlock`) + // answers; queueing behind a fence would strand the draft. + return false + } + if (hold !== null) { + return true + } + return oldestActionableQueuedMessage(input.journal) !== null +} + +/** A draft's payload fingerprint in the session that will send it: the reducer + * aliases the provider's echo to the submission by recomputing exactly this. */ +export function queuedMessageFingerprint(sessionId: string, body: AgentJournalMessageItem): string { + return structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId, + fields: { body } + }) +} + +/** The accept-side budget refusal, or null when the draft fits. */ +export function queuedMessageBudgetRefusal( + journal: AgentSessionJournal, + body: AgentJournalMessageItem +): AgentSessionWireRefusal | null { + const unsettled = journal.queuedMessages.list().filter(isUnsettledQueuedMessage) + const bytes = unsettled.reduce( + (sum, row) => sum + Buffer.byteLength(JSON.stringify(row.body.blocks), 'utf8'), + Buffer.byteLength(JSON.stringify(body.blocks), 'utf8') + ) + if (unsettled.length >= QUEUED_MESSAGES_MAX_COUNT || bytes > QUEUED_MESSAGES_MAX_TOTAL_BYTES) { + return { + code: 'agent_session_operation_invalid', + message: 'The message queue is full. Send again after the current turn ends.' + } + } + return null +} + +/** + * The accept branch: a capable send while the session is working (or behind an + * actionable backlog) becomes a draft instead of a submission. Returns null for + * the immediate path — an incapable client, an image body (text-only v1), a + * replayed id the journal already answers, or an idle session. + */ +export async function maybeQueueStructuredAgentSessionSend( + context: { + deps: { store: { getRecord: (sessionId: string) => AgentSessionRecord | null } } + }, + ctx: { + sessionId: string + journal: AgentSessionJournal + fence: number + }, + params: { + envelope: { clientOperationId: string } + body: AgentJournalMessageItem + delivery?: 'queue-if-active' + } +): Promise< + | { ok: true; value: AgentSessionSendResult } + | { ok: false; refusal: AgentSessionWireRefusal } + | null +> { + const clientMessageId = params.envelope.clientOperationId + if (params.delivery !== 'queue-if-active' || !queuedMessageBodyIsTextOnly(params.body)) { + return null + } + // Asked again with no ledger answer: a send this host queued answers as its replay would — + // its hand-off goes out under a fresh id, so no submission under this id guards it. + const queuedBefore = queuedSendAnswer(ctx.journal, clientMessageId) + if (queuedBefore) { + return { ok: true, value: queuedBefore } + } + // A recorded direct submission under this id replays through today's path. + if (ctx.journal.submissions().some((entry) => entry.clientMessageId === clientMessageId)) { + return null + } + if ( + !shouldQueueStructuredAgentSessionSend({ + journal: ctx.journal, + record: context.deps.store.getRecord(ctx.sessionId), + fence: ctx.fence + }) + ) { + return null + } + const refusal = queuedMessageBudgetRefusal(ctx.journal, params.body) + if (refusal) { + return { ok: false, refusal } + } + // The insert notifies through the journal's commit listener: publication and + // the drain re-derive with no call here to forget. + const row = await ctx.journal.queuedMessages.insert({ + messageId: clientMessageId, + body: params.body, + fingerprint: queuedMessageFingerprint(ctx.sessionId, params.body), + hostInstance: structuredAgentSessionHostInstance() + }) + return { + ok: true, + value: { + clientMessageId, + queued: { messageId: row.messageId, position: row.position, state: row.state } + } + } +} + +export type QueuedMessageDrainDeps = { + sessions: ReadonlyMap + getRecord: (sessionId: string) => AgentSessionRecord | null + serialize: (sessionId: string, task: () => Promise) => Promise + /** The streamed-event barrier: a turn-open already accepted by the host is + * committed before the gates are read, so no stored busy flag is needed. */ + flushStreamedEvents: (sessionId: string) => Promise + conversationFence: (sessionId: string) => number + /** The consumed submission is ordinary #22821 work from here on. */ + wakeDelivery: (sessionId: string) => void + onError: (sessionId: string, error: unknown) => void +} + +/** + * The serialized drain. Woken by every journal commit (turn, submission, prompt, + * command and Stop settlements are all commits), by draft mutations, and by the + * conversation opening; each step re-derives everything and consumes at most one + * draft — the consumed submission then owes work, which gates the next. + */ +export class StructuredAgentSessionQueuedMessageDrain { + private readonly scheduled = new Set() + + constructor(private readonly deps: QueuedMessageDrainDeps) {} + + schedule(sessionId: string): void { + const journal = this.deps.sessions.get(sessionId)?.journal + if (!journal || journal.isReadOnly) { + return + } + // Cheap pre-check so token streams do not pay a serialized step per delta. + // Skipping while working is safe: whatever ends the work is itself a commit + // that schedules again, and the step re-reads every gate after its flush. + try { + if ( + !journal.queuedMessages.settlementOwed() && + (oldestActionableQueuedMessage(journal) === null || + isStructuredAgentSessionMainAgentWorking( + journal.activeTurnId(), + journal.submissions(), + this.deps.conversationFence(sessionId) + )) + ) { + return + } + } catch { + // The handle is opening or closing; the next commit re-schedules. + return + } + if (this.scheduled.has(sessionId)) { + return + } + this.scheduled.add(sessionId) + void this.deps + .serialize(sessionId, () => { + this.scheduled.delete(sessionId) + return this.step(sessionId) + }) + .catch((error: unknown) => { + this.scheduled.delete(sessionId) + this.deps.onError(sessionId, error) + }) + } + + private async step(sessionId: string): Promise { + const session = this.deps.sessions.get(sessionId) + if (!session || session.journal.isReadOnly) { + return + } + await this.deps.flushStreamedEvents(sessionId) + const journal = session.journal + if (journal.queuedMessages.settlementOwed() || journal.queuedMessages.deliveredByEchoOwed()) { + // A live per-row hook was skipped; heal now, before a draft sends, rather than at reopen. + await journal.queuedMessages.settleOwed().catch((error: unknown) => { + this.deps.onError(sessionId, error) + }) + } + const next = oldestActionableQueuedMessage(journal) + if (!next) { + return + } + const record = this.deps.getRecord(sessionId) + const fence = this.deps.conversationFence(sessionId) + // Live facts only, through the one gate; the backlog is never a gate, so a + // lone draft drains. Whatever clears a hold publishes or commits, which + // re-derives this step. + if (structuredQueueHold({ journal, record, fence }) !== null) { + return + } + // Always a fresh id: the submission names its draft by `queuedMessageId`, never by id equality. + const submissionId = createStructuredAgentSessionOperationId(randomUUID) + try { + await journal.appendSubmission( + { + clientMessageId: submissionId, + // The queue's own automatic send: it never ends a pause. + origin: 'host', + payloadFingerprint: next.fingerprint, + body: next.body, + fence, + handoverRecorded: true + }, + { + messageId: next.messageId, + expect: 'waiting', + settledByOp: null, + hostInstance: structuredAgentSessionHostInstance() + } + ) + } catch (error) { + if (error instanceof QueuedMessageNotConsumableError) { + // Lost a race with a Send-now or Delete; their transition stands. + return + } + // Pre-consume failure: the draft stays waiting, held with the marker on + // the card (a stored fact, so it survives eviction and restart). The + // hold's own commit notification publishes it. An explicit Send retries; + // no automatic retry loop. + await journal.queuedMessages + .hold({ messageIds: [next.messageId], reason: QUEUED_MESSAGE_PAUSED_SEND_FAILED }) + .catch(() => {}) + throw error + } + this.deps.wakeDelivery(sessionId) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts new file mode 100644 index 00000000000..89fc2b0c432 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts @@ -0,0 +1,316 @@ +// `agentSession.queuedMessageSend` / `agentSession.queuedMessageDelete`, and +// /clear's carry of the source's drafts to its replacement session. Settling +// operations stamp op-scoped tombstone receipts, so a lost acknowledgement +// replays from the rows themselves — never from the operation ledger, which +// records only that an operation happened. No mutation returns draft text: +// the published list is the one authority a client renders. + +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { agentSessionOperationKey } from '../../../shared/agent-session-operation-ledger' +import type { + AgentSessionMutationEnvelope, + AgentSessionMutationResult, + AgentSessionQueuedMessageDeleteResult, + AgentSessionQueuedMessagesResumeResult, + AgentSessionSendResult +} from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { QueuedMessageNotConsumableError } from '../agent-session-journal/journal-queued-messages' +import type { QueuedMessageRow } from '../agent-session-journal/queued-message-table' +import type { MutationPlan } from './structured-agent-session-mutation-plans' +import { + queuedMessageFingerprint, + structuredQueueHold +} from './structured-agent-session-queued-messages' +import { + resumeStructuredQueue, + structuredAgentSessionHostInstance +} from './structured-agent-session-queued-pause' +import { unsettledQueuedMessages } from './structured-agent-session-queued-stop' +import { + mutateStructuredAgentSession, + type StructuredAgentSessionMutationContext +} from './structured-agent-session-host-mutations' +import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' +import { + openForWrite, + structuredAgentSessionSendBlock +} from './structured-agent-session-send-preparation' +import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' + +function invalid(message: string): { + ok: false + refusal: { code: 'agent_session_operation_invalid'; message: string } +} { + return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } } +} + +function submissionFor( + ctx: AgentSessionTurnContext, + clientMessageId: string +): AgentJournalSubmission | undefined { + return ctx.journal.submissions().find((entry) => entry.clientMessageId === clientMessageId) +} + +/** One transaction, stamped with the operation's caller-scoped key so a replay + * answers "spent" from the receipts. Withdrawal retires any hold in the same + * UPDATE, and the store's commit notification publishes the change. */ +export async function withdrawQueuedMessagesForOperation( + journal: AgentSessionJournal, + input: { + sessionId: string + messageIds: readonly string[] + callerKey: string + operationId: string + } +): Promise { + return journal.queuedMessages.withdraw({ + messageIds: input.messageIds, + settledByOp: agentSessionOperationKey(input.callerKey, input.operationId) + }) +} + +/** + * /clear's carry: the source's unsettled drafts become rows on the replacement + * session — the SAME for every client version, with no text on the wire — so the + * cards stay visible where the user now is. The replacement's queue starts + * paused ('cleared'), lifted exactly like a Stop's: the cards were written for the context /clear just + * discarded, so they wait for the user's next turn there, or Resume, rather than + * sending into the fresh context unasked. Each card lands with the pause in one + * transaction, so the drain never sees a carried card unpaused and no pause is + * left over an empty queue if an insert fails. Runs after the + * replacement's attach succeeded and before the clear commits. Each insert is + * idempotent on (session, message), so a retried clear replays it safely; the source rows are then tombstoned. Bookkeeping around the clear: + * a failure leaves the cards on the superseded source — whose supersession + * fence already blocks the drain — reported, never gating the clear. A crash + * between the copy and the tombstone leaves both, which the fence also makes + * harmless: nothing is lost and nothing runs. + */ +export async function carryQueuedMessagesToClearReplacement( + ctx: AgentSessionTurnContext, + input: { + replacementSessionId: string + replacementJournal: AgentSessionJournal | undefined + callerKey: string + operationId: string + } +): Promise { + try { + const rows = unsettledQueuedMessages(ctx.journal) + if (rows.length === 0) { + return + } + const replacement = input.replacementJournal + if (!replacement) { + throw new Error('the replacement journal is not open') + } + for (const row of rows) { + // A returned card carries over as a plain waiting draft — its refusal + // belonged to the source's submissions. The fingerprint is re-scoped to the + // replacement, or its echo could never alias the sent bubble. + await replacement.queuedMessages.insert({ + messageId: row.messageId, + body: row.body, + fingerprint: queuedMessageFingerprint(input.replacementSessionId, row.body), + hostInstance: structuredAgentSessionHostInstance(), + pausedBy: 'cleared' + }) + } + await withdrawQueuedMessagesForOperation(ctx.journal, { + sessionId: ctx.sessionId, + messageIds: rows.map((row) => row.messageId), + callerKey: input.callerKey, + operationId: input.operationId + }) + } catch (error) { + console.warn("[agent-session] /clear's queued-draft carry skipped:", { + sessionId: ctx.sessionId, + error: error instanceof Error ? error.message : String(error) + }) + } +} + +/** Draft actions run like any mutation: admitted on the session's lane, the + * conversation opened for the write. */ +function mutateQueued( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + envelope: AgentSessionMutationEnvelope, + plan: MutationPlan +): Promise> { + return mutateStructuredAgentSession( + context, + caller, + envelope, + plan, + openForWrite(context, envelope) + ) +} + +/** + * Send-now. It overrides ONLY queue policy — FIFO order, pause, the busy-turn + * wait — through the same send block and pending-prompt gates as any send; + * supersession, Stop and prepared commands are never overridden. The card goes + * out under this operation's id, never its own, and the submission names it by + * `queuedMessageId`; one id still means one delivery. + */ +export function sendQueuedStructuredAgentMessage( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + params: { envelope: AgentSessionMutationEnvelope; messageId: string } +): Promise> { + const { messageId } = params + const operationId = params.envelope.clientOperationId + const plan: MutationPlan = { + method: 'agentSession.queuedMessageSend', + fields: { messageId }, + conversationWrite: true, + run: async (ctx): Promise> => { + // A rerun of this operation after it consumed the card (its answer never + // settled): answer with the submission it made, never append it again. + const consumedHere = submissionFor(ctx, operationId) + if (consumedHere?.queuedMessageId === messageId) { + return { ok: true, value: { clientMessageId: operationId, submission: consumedHere } } + } + // The one queue gate; Send-now's override set is exactly `working` (plus + // FIFO order and the stored hold, which the consume below clears). + const record = context.deps.store.getRecord(ctx.sessionId) + const hold = structuredQueueHold({ journal: ctx.journal, record, fence: ctx.fence }) + if (hold === 'blocked') { + return structuredAgentSessionSendBlock(record) ?? invalid('This conversation cannot send.') + } + if (hold === 'prompt') { + return invalid('Answer the pending request before sending this message.') + } + const row = ctx.journal.queuedMessages.get(messageId) + if (!row) { + return invalid('No queued message by that id.') + } + if (row.state === 'withdrawn') { + return invalid('This queued message was withdrawn.') + } + if (row.state === 'dispatched') { + // Already a submission — answer with it rather than sending twice. + const submission = row.consumedAs === null ? undefined : submissionFor(ctx, row.consumedAs) + return submission + ? { ok: true, value: { clientMessageId: submission.clientMessageId, submission } } + : invalid('This queued message was already sent.') + } + const submissionId = operationId + try { + await ctx.journal.appendSubmission( + { + clientMessageId: submissionId, + // The person asked for this turn, so it ends a Stop's pause once it starts. + origin: 'client', + payloadFingerprint: row.fingerprint, + body: row.body, + fence: ctx.fence, + handoverRecorded: true + }, + { + messageId, + expect: row.state, + settledByOp: agentSessionOperationKey(ctx.resolvedBy, operationId), + hostInstance: structuredAgentSessionHostInstance() + } + ) + } catch (error) { + if (error instanceof QueuedMessageNotConsumableError) { + return invalid('The queued message changed underneath this Send; try again.') + } + throw error + } + const submission = submissionFor(ctx, submissionId) + if (!submission) { + throw new Error('agent_session_submission_lost') + } + context.wakeDelivery(ctx.sessionId) + return { ok: true, value: { clientMessageId: submissionId, submission } } + }, + replay: (ctx) => { + const opKey = agentSessionOperationKey(ctx.resolvedBy, operationId) + const row = ctx.journal.queuedMessages + .receipts(opKey) + .find((receipt) => receipt.messageId === messageId) + if (!row || row.state !== 'dispatched') { + return null + } + const submission = row.consumedAs === null ? undefined : submissionFor(ctx, row.consumedAs) + return submission ? { clientMessageId: submission.clientMessageId, submission } : null + } + } + return mutateQueued(context, caller, params.envelope, plan) +} + +/** Delete = discard, with no body in the answer: the card leaving the published + * list IS the outcome, so a lost answer needs no re-ask. An Edit is the client + * copying the text it already renders, then this Delete. */ +export function deleteQueuedStructuredAgentMessage( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + params: { envelope: AgentSessionMutationEnvelope; messageId: string } +): Promise> { + const { messageId } = params + const operationId = params.envelope.clientOperationId + const plan: MutationPlan = { + method: 'agentSession.queuedMessageDelete', + fields: { messageId }, + conversationWrite: true, + run: async (ctx): Promise> => { + const row = ctx.journal.queuedMessages.get(messageId) + if (!row) { + return { ok: true, value: { deleted: false, messageId, disposition: 'missing' } } + } + if (row.state === 'dispatched') { + return { ok: true, value: { deleted: false, messageId, disposition: 'dispatched' } } + } + if (row.state === 'withdrawn') { + return { ok: true, value: { deleted: false, messageId, disposition: 'withdrawn' } } + } + // The withdrawal notifies through the journal's commit listener, which + // also re-derives the drain — deleting a returned card can unblock the + // drafts behind it. + const withdrawn = await withdrawQueuedMessagesForOperation(ctx.journal, { + sessionId: ctx.sessionId, + messageIds: [messageId], + callerKey: ctx.resolvedBy, + operationId + }) + return withdrawn.length > 0 + ? { ok: true, value: { deleted: true, messageId } } + : { ok: true, value: { deleted: false, messageId, disposition: 'withdrawn' } } + }, + replay: (ctx) => { + const replayed = ctx.journal.queuedMessages + .receipts(agentSessionOperationKey(ctx.resolvedBy, operationId)) + .some((row) => row.messageId === messageId && row.state === 'withdrawn') + return replayed ? { deleted: true, messageId } : null + } + } + return mutateQueued(context, caller, params.envelope, plan) +} + +/** Resume: ends the queue's pause — a Stop's, or a restart's — so the cards send + * again, oldest first, as the session goes idle. A no-op when nothing is paused, + * and a per-card `send_failed` hold stays for its own Send. */ +export function resumeStructuredAgentQueue( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + params: { envelope: AgentSessionMutationEnvelope } +): Promise> { + const plan: MutationPlan = { + method: 'agentSession.queuedMessagesResume', + fields: {}, + conversationWrite: true, + // The lift notifies through the journal's commit listener, which publishes the + // cleared pause and wakes the drain. + run: async (ctx) => ({ + ok: true, + value: { resumed: await resumeStructuredQueue(ctx.journal) } + }), + // Like Stop's replay: the Resume already ran, so this one lifts nothing. + replay: () => ({ resumed: false }) + } + return mutateQueued(context, caller, params.envelope, plan) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts new file mode 100644 index 00000000000..1349a6a178e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -0,0 +1,476 @@ +// A Stop pauses the whole queue, derived from the journal: it lasts until a turn +// a person asked for (a send over the client RPC, or a card they sent now) +// starts — the provider accepts it, never merely the host — or they Resume. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + HOST_TEST_SESSION, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' +import { + QUEUED_RIG_CALLER, + createQueuedMessageTestRig, + eventually, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { sameQueuePause } from './structured-agent-session-queued-publication' +import { + structuredAgentSessionHostInstance, + structuredQueuePause +} from './structured-agent-session-queued-pause' + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig() +}) + +afterEach(() => rig.dispose()) + +/** No drain step may convert the drafts, and the queue reads paused. */ +async function expectPaused(...draftIds: string[]): Promise { + await new Promise((resolve) => setTimeout(resolve, 250)) + for (const draftId of draftIds) { + expect(await rig.handoff(draftId)).toBeUndefined() + } + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) +} + +async function queuedDraft(text: string): Promise { + const queued = await rig.send(text, 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + return queued.value.queued.messageId +} + +/** A draft behind a Stop, with the stopped turn settled so the session is idle. */ +async function stoppedDraft(): Promise { + const working = await rig.workingSend() + const draftId = await queuedDraft('paused by stop') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + return draftId +} + +/** A queued draft handed off and handed over, found by its hand-off link. */ +async function handedOver(draftId: string): Promise { + await eventually(async () => expect((await rig.handoff(draftId))?.handedOverAt).toBeDefined()) +} + +/** A user send the host accepted and handed over, still unanswered by the provider. */ +async function handedOverUserSend(text: string): Promise { + const { id, result } = rig.send(text) + expect(await result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + await eventually(async () => expect((await rig.submission(id))?.handedOverAt).toBeDefined()) + return id +} + +describe("a Stop's queue pause", () => { + it('outlives a user send the provider accepts and then refuses; a later send that starts lifts it', async () => { + const draftId = await stoppedDraft() + const refused = await handedOverUserSend('the start fails') + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + await rig.settleRejected(refused, 'turn/start refused') + await expectPaused(draftId) + const started = await handedOverUserSend('this one starts') + await rig.settleAccepted(started, 'started') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + }) + + it('a Stop after the user send supersedes it: that send starting its turn lifts nothing', async () => { + const draftId = await stoppedDraft() + const earlier = await handedOverUserSend('sent before the second stop') + await rig.stop() + await rig.settleAccepted(earlier, 'late') + await expectPaused(draftId) + }) + + it('survives a restart, and a send made after the Stop still ends it when its turn starts there', async () => { + const draftId = await stoppedDraft() + const inFlight = await handedOverUserSend('sent before the restart') + // Derived from the journal, not remembered: a restart forgets nothing it needs. + await rig.restartHostProcess() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + await rig.settleAccepted(inFlight, 'after-restart') + // The Stop's pause is over; the restart's own lasts until a turn asked for since it. + await eventually(async () => expect(await rig.queuePause()).toEqual({ reason: 'restarted' })) + const next = rig.send('sent after the restart') + await next.result + await rig.settleAccepted(next.id, 'next') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + }) + + it("a draft typed while the stopped turn winds down waits with the rest: the pause is the queue's", async () => { + const working = await rig.workingSend() + const olderId = await queuedDraft('paused by the stop') + await rig.stop() + const typedId = await queuedDraft('typed while stopping') + await rig.settleAccepted(working, 'stopped') + await expectPaused(olderId, typedId) + expect(await rig.drafts()).toEqual([ + { messageId: olderId, state: 'waiting' }, + { messageId: typedId, state: 'waiting' } + ]) + }) + + it('Send-now sends only its own card; the rest stay paused until that turn starts, then drain after it', async () => { + const working = await rig.workingSend() + const sentId = await queuedDraft('sent now') + const heldId = await queuedDraft('held until that turn starts') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + expect(await rig.sendNow(sentId)).toMatchObject({ + ok: true, + value: { submission: { origin: 'client', queuedMessageId: sentId } } + }) + await handedOver(sentId) + // Only the card the user asked for went: the queue is still paused. + await expectPaused(heldId) + expect(await rig.drafts()).toEqual([{ messageId: heldId, state: 'waiting' }]) + // A turn the user asked for has now started, which ends the pause. + await rig.settleAccepted(await rig.handoffId(sentId), 'sent-now') + await eventually(async () => expect(await rig.handoff(heldId)).toBeDefined()) + }) + + it('a card sent now that the provider refuses lifts nothing', async () => { + const working = await rig.workingSend() + // Ahead of the refused card, so its return blocks nothing Resume would send. + const heldId = await queuedDraft('held by the stop') + const sentId = await queuedDraft('sent now, refused') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + await rig.sendNow(sentId) + await handedOver(sentId) + await rig.settleRejected(await rig.handoffId(sentId), 'turn/start refused') + await expectPaused(heldId) + }) + + it('an old send answered again after its ledger row is gone lifts nothing from a later Stop', async () => { + const working = await rig.workingSend() + const draftId = await queuedDraft('paused by stop') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + // The ledger forgot the id, so the send runs again and answers with its accepted submission. + const operations = rig.store['transactions'].state.operations + for (const [key, row] of operations) { + if (row.operationId === working) { + operations.delete(key) + } + } + const body = hostTestMessage('work on this') + const replayed = await rig.host.send(QUEUED_RIG_CALLER, { + envelope: rig.envelope({ body }, 'agentSession.send', working), + body, + userSend: true + }) + expect(replayed).toMatchObject({ + ok: true, + replayed: false, + value: { submission: { dispatchState: 'accepted' } } + }) + // A later journal commit re-derives the pause; the old send was accepted before the Stop. + const mail = rig.send('coordinator mail', undefined, { internal: true }) + await mail.result + await rig.settleAccepted(mail.id, 'mail') + await expectPaused(draftId) + }) +}) + +describe('the pause read', () => { + it("costs no scan of the submissions: the reducer keeps the latest person's accepted turn", async () => { + const draftId = await stoppedDraft() + const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!journal) { + throw new Error('expected the conversation open') + } + const scan = vi.spyOn(journal, 'submissions') + // Read on every publish, per subscriber: it must not walk the submissions. + expect(structuredQueuePause(journal)).toEqual({ reason: 'stopped' }) + expect(scan).not.toHaveBeenCalled() + scan.mockRestore() + const before = journal.queuedMessages.latestPersonTurnSequence() + const mail = rig.send('coordinator mail', undefined, { internal: true }) + await mail.result + await rig.settleAccepted(mail.id, 'mail') + // Orca's own turn moves nothing; a person's does, and lifts the pause. + expect(journal.queuedMessages.latestPersonTurnSequence()).toBe(before) + const next = rig.send('user starts a new turn') + await next.result + await rig.settleAccepted(next.id, 'next') + expect(journal.queuedMessages.latestPersonTurnSequence()).toBe( + journal.submission(next.id)?.acceptedSequence + ) + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + }) +}) + +describe('a pause is over the cards it paused', () => { + it('a Stop over an empty queue pauses nothing: a card typed during a later mail turn drains', async () => { + const working = await rig.workingSend() + await rig.stop() + await rig.settleAccepted(working, 'stopped') + const mail = rig.send('coordinator mail', undefined, { internal: true }) + await mail.result + await eventually(async () => + expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined() + ) + const followUp = await queuedDraft('typed during the mail turn') + await rig.settleAccepted(mail.id, 'mail') + await eventually(async () => expect(await rig.handoff(followUp)).toBeDefined()) + expect(await rig.queuePause()).toBeNull() + }) + + it('a Stop over an empty queue pauses nothing: a correction typed before the turn ends drains', async () => { + const working = await rig.workingSend() + await rig.stop() + const correction = await queuedDraft('typed right after the stop') + await rig.settleAccepted(working, 'stopped') + await eventually(async () => expect(await rig.handoff(correction)).toBeDefined()) + }) + + it('deleting the last paused card ends the pause, so a card typed later is not held by it', async () => { + const working = await rig.workingSend() + const only = await queuedDraft('paused, then deleted') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.deleteQueued(only)).toMatchObject({ ok: true, value: { deleted: true } }) + const mail = rig.send('coordinator mail', undefined, { internal: true }) + await mail.result + await eventually(async () => + expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined() + ) + const later = await queuedDraft('typed during the mail turn') + await rig.settleAccepted(mail.id, 'mail') + await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) + }) +}) + +describe('a pause only over cards Resume could send', () => { + it('a Stop that leaves only a returned card publishes no pause and keeps no fact', async () => { + const working = await rig.workingSend() + const draftId = await queuedDraft('refused before the stop') + await rig.settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + await rig.settleRejected(await rig.handoffId(draftId), 'provider refused this payload') + await eventually(async () => + expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'returned' }]) + ) + // A lone returned card traps nothing: this send goes now, and the Stop interrupts it. + const next = await handedOverUserSend('sent past the card') + expect(await rig.stop()).toMatchObject({ ok: true }) + await rig.settleAccepted(next, 'stopped') + expect(await rig.queuePause()).toBeNull() + const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + expect(journal?.queuedMessages.pause()).toBeNull() + }) + + it('a returned card blocking the paused cards hides the pause but keeps it; deleting that card shows it again, and only Resume sends', async () => { + const working = await rig.workingSend() + const refusedId = await queuedDraft('refused after the stop') + const behindId = await queuedDraft('waits behind the card') + await rig.settleAccepted(working, 'a') + await handedOver(refusedId) + // The Stop interrupts the refused card's turn and pauses the card behind it. + await rig.stop() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + await rig.settleRejected(await rig.handoffId(refusedId), 'provider refused this payload') + await eventually(async () => + expect(await rig.drafts()).toEqual([ + { messageId: refusedId, state: 'returned' }, + { messageId: behindId, state: 'waiting' } + ]) + ) + const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!journal) { + throw new Error('expected the conversation open') + } + // Resume would send nothing past the returned card, so no header offers it; the pause stays. + expect(await rig.queuePause()).toBeNull() + expect(journal.queuedMessages.pause()).toMatchObject({ reason: 'stopped' }) + // Deleting the blocking card shows the pause again: the card behind it does not send unasked. + expect(await rig.deleteQueued(refusedId)).toMatchObject({ ok: true, value: { deleted: true } }) + await expectPaused(behindId) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => expect(await rig.handoff(behindId)).toBeDefined()) + }) + + it('a restart over only a card held by its own failed send publishes no pause', async () => { + const working = await rig.workingSend() + const draftId = await queuedDraft('conversion fails once') + const append = vi + .spyOn(AgentSessionJournal.prototype, 'appendSubmission') + .mockImplementationOnce(async () => { + throw new Error('disk full') + }) + try { + await rig.settleAccepted(working, 'a') + await eventually(async () => + expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'waiting', paused: true }]) + ) + } finally { + append.mockRestore() + } + await rig.restartHostProcess() + // Only its own Send releases that card: a queue-level Resume would send nothing. + expect(await rig.queuePause()).toBeNull() + }) + + it('compares a pause by presence before reason, so appearing or clearing is always a change', () => { + expect(sameQueuePause(null, {})).toBe(false) + expect(sameQueuePause({}, null)).toBe(false) + expect(sameQueuePause(null, null)).toBe(true) + expect(sameQueuePause({ reason: 'stopped' }, { reason: 'stopped' })).toBe(true) + expect(sameQueuePause({ reason: 'stopped' }, { reason: 'cleared' })).toBe(false) + }) +}) + +describe("a restart's pause", () => { + it("once a person's turn ends it, stays ended when the conversation reopens", async () => { + const working = await rig.workingSend() + const first = await queuedDraft('first') + const second = await queuedDraft('second') + await rig.restartHostProcess() + await rig.settleAccepted(working, 'a') + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + const next = rig.send('user starts a new turn') + await next.result + await rig.settleAccepted(next.id, 'b') + await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) + // Reopened, that turn is "before this open", yet the pause it ended stays ended: + // the lift adopted the rows into this process. + await rig.host.close(HOST_TEST_SESSION) + expect(await rig.queuePause()).toBeNull() + expect(await rig.drafts()).toContainEqual({ messageId: second, state: 'waiting' }) + }) +}) + +describe('a card handed off after a restart', () => { + it('belongs to the process that sent it: withdrawn back to waiting, it raises no restart pause', async () => { + const working = await rig.workingSend() + const draftId = await queuedDraft('refused, then re-sent after a restart') + await rig.settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + await rig.settleRejected(await rig.handoffId(draftId), 'provider refused this payload') + await eventually(async () => + expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'returned' }]) + ) + await rig.restartHostProcess() + // Sent again in this process, then withdrawn by a Stop before the agent had it. + let release: () => void = () => undefined + rig.awaitStarted.mockImplementationOnce( + () => new Promise((resolve) => (release = () => resolve(undefined))) + ) + expect(await rig.sendNow(draftId)).toMatchObject({ ok: true }) + await rig.stop() + release() + await eventually(async () => + expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) + ) + const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!journal) { + throw new Error('expected the conversation open') + } + expect(journal.queuedMessages.get(draftId)?.hostInstance).toBe( + structuredAgentSessionHostInstance() + ) + // With the Stop's pause gone, nothing else holds it: no restart happened since it was sent. + await journal.queuedMessages.liftPause({ + stop: journal.queuedMessages.pause(), + adoptInto: null + }) + expect(structuredQueuePause(journal)).toBeNull() + }) +}) + +describe('Resume', () => { + it('lifts the pause and the queue drains, oldest first; a second Resume is a no-op', async () => { + const working = await rig.workingSend() + const first = await queuedDraft('first') + const second = await queuedDraft('second') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + await expectPaused(first, second) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + expect(await rig.queuePause()).toBeNull() + await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) + expect(await rig.handoff(second)).toBeUndefined() + // Nothing is paused now: another Resume changes nothing. + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: false } }) + }) + + it('is idempotent: a replay of the same Resume answers without lifting a later pause', async () => { + const working = await rig.workingSend() + const draftId = await queuedDraft('paused twice') + await rig.stop() + const operationId = hostTestOperationId() + expect(await rig.resume(operationId)).toMatchObject({ ok: true, value: { resumed: true } }) + await rig.stop() + expect(await rig.resume(operationId)).toMatchObject({ + ok: true, + replayed: true, + value: { resumed: false } + }) + await rig.settleAccepted(working, 'stopped') + await expectPaused(draftId) + }) + + it("lifts a restart's pause too", async () => { + const working = await rig.workingSend() + const draftId = await queuedDraft('written before the restart') + await rig.restartHostProcess() + await rig.settleAccepted(working, 'a') + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + }) + + it('is a no-op on a queue that is not paused', async () => { + await rig.workingSend() + await queuedDraft('waiting behind the turn') + expect(await rig.queuePause()).toBeNull() + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: false } }) + }) +}) + +describe('a failed Stop', () => { + it('records nothing when it fails before taking effect, so the queue sends as if no Stop was pressed', async () => { + const working = await rig.workingSend() + const draftId = await queuedDraft('queued before the stop') + const reject = vi + .spyOn(AgentSessionJournal.prototype, 'rejectQueuedSubmissions') + .mockRejectedValueOnce(new Error('disk full')) + try { + await expect(rig.stop()).rejects.toThrow('disk full') + } finally { + reject.mockRestore() + } + expect(await rig.queuePause()).toBeNull() + await rig.settleAccepted(working, 'working') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + }) + + it('keeps its pause when it fails after the interrupt reached the agent', async () => { + await rig.workingSend() + const draftId = await queuedDraft('paused by stop') + const append = AgentSessionJournal.prototype.appendItem + const failing = vi + .spyOn(AgentSessionJournal.prototype, 'appendItem') + .mockImplementation(async function (this: AgentSessionJournal, ...args) { + // The status note written after the provider was asked to stop. + if (args[1].kind === 'status') { + throw new Error('disk full') + } + return append.apply(this, args) + }) + try { + await expect(rig.stop()).rejects.toThrow('disk full') + } finally { + failing.mockRestore() + } + await expectPaused(draftId) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts new file mode 100644 index 00000000000..1428de75fe6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts @@ -0,0 +1,108 @@ +// Whether the queue is paused, and why — DERIVED, never stored as a flag. The +// queue is paused when: +// - 'stopped': the user's last Stop took effect (its recorded journal +// position) and no turn a person asked for has started since — or +// 'cleared', the same for a /clear's replacement, whose carried cards +// start paused; or +// - 'restarted': a waiting draft was written by another host process and no +// turn a person asked for has started since this conversation opened. +// A person's turn is a submission whose recorded origin is `client` (a send over +// the client send RPC, or a card they sent now) that the provider accepted. +// Orchestration mail, a restart continuation, a host-sent launch prompt and the +// queue's own drain are `host` and never lift it. An explicit Resume lifts any. + +import { randomUUID } from 'node:crypto' +import type { AgentSessionQueuePause } from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { QueuePauseFact } from '../agent-session-journal/queued-message-pause-table' + +/** A per-process id, minted once per host process like the runtime's own + * `runtimeId` (`orca-runtime-runtime-id.ts`); a draft written by another + * instance pauses the queue rather than auto-sending after a restart. */ +let hostInstance = randomUUID() + +export function structuredAgentSessionHostInstance(): string { + return hostInstance +} + +/** Simulates a host-process restart. Tests only. */ +export function rotateStructuredAgentSessionHostInstanceForTests(): string { + hostInstance = randomUUID() + return hostInstance +} + +type PauseJournal = Pick + +// Both read the reducer's latest accepted person turn (its submission row), so a +// derivation on every publish costs no scan of the submissions. + +function stopEnded(journal: PauseJournal, stop: QueuePauseFact): boolean { + const latest = journal.queuedMessages.latestPersonTurnSequence() + // Sent after the Stop: a send made before it no longer lifts it, even if its turn starts later. + return stop.epoch !== journal.cursor().epoch ? latest > 0 : latest > stop.sequence +} + +function restartPending(journal: PauseJournal): boolean { + return journal.queuedMessages + .list() + .some((row) => row.state === 'waiting' && row.hostInstance !== hostInstance) +} + +function restartEnded(journal: PauseJournal): boolean { + const latest = journal.queuedMessages.latestPersonTurnSequence() + return latest > 0 && !journal.wroteBeforeOpen(latest) +} + +/** The queue's pause, derived; null when the queue sends on its own. */ +export function structuredQueuePause(journal: PauseJournal): AgentSessionQueuePause | null { + const stop = journal.queuedMessages.pause() + if (stop && !stopEnded(journal, stop)) { + return { reason: stop.reason } + } + if (restartPending(journal) && !restartEnded(journal)) { + return { reason: 'restarted' } + } + return null +} + +/** + * Every journal publish: retire what a person's started turn already ended — the + * Stop fact it superseded, and a restart's rows, adopted into this instance. The + * derivation already reads them as lifted; the write keeps that answer when the + * handle reopens (the restart's "since this conversation opened" moves) and spares + * later derivations the submission scan. Bookkeeping: a failure is reported. + */ +export async function retireEndedQueuePause( + sessionId: string, + journal: PauseJournal +): Promise { + try { + const stop = journal.queuedMessages.pause() + const retireStop = stop !== null && stopEnded(journal, stop) ? stop : null + const adopt = restartPending(journal) && restartEnded(journal) + if (retireStop === null && !adopt) { + return + } + await journal.queuedMessages.liftPause({ + stop: retireStop, + adoptInto: adopt ? hostInstance : null + }) + } catch (error) { + console.warn("[agent-session] a started turn's queue-pause retirement skipped:", { + sessionId, + error: error instanceof Error ? error.message : String(error) + }) + } +} + +/** Resume: ends whichever pause holds the queue. Returns whether it was paused. */ +export async function resumeStructuredQueue(journal: PauseJournal): Promise { + if (structuredQueuePause(journal) === null) { + return false + } + await journal.queuedMessages.liftPause({ + stop: journal.queuedMessages.pause(), + adoptInto: hostInstance + }) + return true +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts new file mode 100644 index 00000000000..10b1f75fc3e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts @@ -0,0 +1,112 @@ +// The published view of a conversation's queue: its whole draft list and the +// queue's pause, on the `commands` precedent — read per emit, reference-stable +// while unchanged, so the subscribers' identity dedup keeps token streams from +// re-sending it. The two ride together: a client never sees one without the other. + +import { + QUEUED_MESSAGE_PAUSED_SEND_FAILED, + type AgentSessionQueuedMessage, + type AgentSessionQueuePause +} from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { hasResumableQueuedMessage } from '../agent-session-journal/queued-message-pause-table' +import { structuredQueuePause } from './structured-agent-session-queued-pause' + +export type QueuePublication = { + queuedMessages: AgentSessionQueuedMessage[] + queuePause: AgentSessionQueuePause | null +} + +/** Waiting and returned rows only. `paused` is a per-card hold (a failed + * conversion); a Stop or a restart pauses the queue, published once beside it. */ +function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] { + const published: AgentSessionQueuedMessage[] = [] + for (const row of journal.queuedMessages.list()) { + if (row.state !== 'waiting' && row.state !== 'returned') { + continue + } + const held = row.state === 'waiting' && row.holdReason !== null + published.push({ + messageId: row.messageId, + position: row.position, + body: row.body, + state: row.state, + ...(held ? { paused: true as const } : {}), + // The stored reason is a typed marker; an unknown one reads as a plain hold. + ...(held && row.holdReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED + ? { pausedReason: QUEUED_MESSAGE_PAUSED_SEND_FAILED } + : {}), + ...(row.state === 'returned' ? { returnedReason: row.returnedReason } : {}), + ...(row.state === 'returned' && row.returnedRejection + ? { returnedRejection: row.returnedRejection } + : {}) + }) + } + return published +} + +type ListMemo = { key: string; serialized: string; list: AgentSessionQueuedMessage[] } + +/** Reference-stable per journal handle: an unchanged list is never + * re-serialized onto token-stream frames, and any draft-table write changes + * the reference by construction. */ +const listMemos = new WeakMap() +const publications = new WeakMap() + +function readPublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] { + const key = String(journal.queuedMessages.revision()) + const memo = listMemos.get(journal) + if (memo && memo.key === key) { + return memo.list + } + const list = computePublishedQueuedMessages(journal) + // Belt for the identity dedup: equal recomputed content keeps the previous reference. + const serialized = JSON.stringify(list) + if (memo && memo.serialized === serialized) { + listMemos.set(journal, { key, serialized, list: memo.list }) + return memo.list + } + listMemos.set(journal, { key, serialized, list }) + return list +} + +/** Presence first: a pause appearing or clearing is a change even when neither side + * names a reason this build can read. */ +export function sameQueuePause( + previous: { reason?: string } | null, + next: { reason?: string } | null +): boolean { + return (previous === null) === (next === null) && previous?.reason === next?.reason +} + +export function readQueuePublication(journal: AgentSessionJournal): QueuePublication { + const queuedMessages = readPublishedQueuedMessages(journal) + // Read per emit: the pause also turns on submissions (a person's turn starting). + // Kept over any card it holds back, but shown only over one Resume would send, so its + // header never offers to send nothing; deleting a blocking returned card shows it again. + const pausable = hasResumableQueuedMessage(journal.queuedMessages.list()) + const queuePause = pausable ? structuredQueuePause(journal) : null + const previous = publications.get(journal) + if ( + previous && + previous.queuedMessages === queuedMessages && + sameQueuePause(previous.queuePause, queuePause) + ) { + return previous + } + const publication = { queuedMessages, queuePause } + publications.set(journal, publication) + return publication +} + +/** For readers that must never fail on drafts — a subscriber stream, a history + * page: a closing handle answers "no claim" (absent) instead of throwing. */ +export function tryReadQueuePublication( + journal: AgentSessionJournal | undefined +): QueuePublication | undefined { + try { + return journal ? readQueuePublication(journal) : undefined + } catch { + return undefined + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-answer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-answer.ts new file mode 100644 index 00000000000..e0eb83e0a0b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-answer.ts @@ -0,0 +1,42 @@ +// What a send this host queued answers with when it is asked again — a lost +// acknowledgement's replay, or a rerun the operation ledger no longer covers: +// from its draft first, then from the hand-off that names it. + +import type { AgentSessionSendResult } from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' + +/** Null for a send this host never queued. A refused conversion answers with + * its returned card, never the rejected submission, or the same text would + * render twice — on a Retry row AND the card. */ +export function queuedSendAnswer( + journal: Pick, + clientMessageId: string +): AgentSessionSendResult | null { + const draft = journal.queuedMessages.get(clientMessageId) + if (draft) { + const consumed = + draft.state === 'dispatched' && draft.consumedAs !== null + ? journal.submission(draft.consumedAs) + : undefined + return consumed + ? { clientMessageId, submission: consumed } + : { + clientMessageId, + queued: { messageId: draft.messageId, position: draft.position, state: draft.state } + } + } + // The draft row was pruned; its last hand-off still names it. Only a withdrawn row is pruned + // while its last hand-off stands rejected — a card the user deleted — so it answers withdrawn, + // never as a refused send. + const handoff = journal + .submissions() + .findLast((entry) => entry.queuedMessageId === clientMessageId) + if (handoff?.dispatchState === 'rejected') { + // The pruned row's position went with it; a withdrawn receipt names no place in the queue. + return { + clientMessageId, + queued: { messageId: clientMessageId, position: 0, state: 'withdrawn' } + } + } + return handoff ? { clientMessageId, submission: handoff } : null +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts new file mode 100644 index 00000000000..d411efc4239 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts @@ -0,0 +1,34 @@ +// A send's queue step around its immediate path: the queue decision before it. A +// person's send lifts a paused queue through its recorded origin once its turn +// starts (`structured-agent-session-queued-pause.ts`), not through anything here. + +import type { AgentSessionSendResult } from '../../../shared/agent-session-wire' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import { maybeQueueStructuredAgentSessionSend } from './structured-agent-session-queued-messages' +import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' + +export async function runQueueableStructuredAgentSessionSend( + context: StructuredAgentSessionMutationContext, + ctx: AgentSessionTurnContext, + params: { + envelope: { clientOperationId: string } + body: AgentJournalMessageItem + delivery?: 'queue-if-active' + userSend?: true + }, + immediate: () => Promise> +): Promise> { + // The queue decision runs first: a capable send while the session owes work (a + // /compact included — it is a queued message like any other) becomes a draft; + // only a `blocked` hold, which never queues, falls through to the refusal. + const queued = await maybeQueueStructuredAgentSessionSend(context, ctx, params) + if (queued) { + return queued + } + const accepted = await immediate() + if (accepted.ok) { + context.wakeDelivery(ctx.sessionId) + } + return accepted +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts new file mode 100644 index 00000000000..16a9b4aeaf7 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts @@ -0,0 +1,66 @@ +// Stop's pause on the queue. A Stop never withdraws a draft and no text ever +// travels back over the wire: it records WHERE in the journal it took effect, +// and the queue is paused from there (`structured-agent-session-queued-pause.ts` +// derives it) until a turn a person asked for starts, or they Resume. The cards +// stay published, and Send-now sends one card without lifting the pause for the +// rest until that card's turn starts. The record is bookkeeping: a failure is +// reported and never gates the interrupt. + +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + isUnsettledQueuedMessage, + type QueuedMessageRow +} from '../agent-session-journal/queued-message-table' +import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' + +/** The one unsettled-card predicate /clear's carry and the budget share: + * waiting or returned. Pending/unknown/accepted deliveries stay outside it. */ +export function unsettledQueuedMessages(journal: AgentSessionJournal): QueuedMessageRow[] { + return journal.queuedMessages.list().filter(isUnsettledQueuedMessage) +} + +/** + * Runs a Stop and records its queue pause at the point it takes effect — after + * it withdrew the queued sends, as it reaches the agent, or, reaching no agent, + * once it withdrew something — and only over cards it then holds back. The Stop + * calls `tookEffect` there. A Stop that throws before then changed nothing and + * recorded nothing, so there is nothing to undo; one that fails after it keeps + * the pause, since the interrupt may have landed. A draft whose hand-off the + * Stop withdrew is back to waiting in its own place, under this same pause. The + * drain cannot slip a draft in between: it runs on the same serialized lane as + * the Stop. + */ +export async function runStopWithQueuePause( + ctx: AgentSessionTurnContext, + stop: (tookEffect: () => Promise) => Promise> +): Promise> { + let attempted = false + return stop(async () => { + if (attempted) { + return + } + attempted = true + const { queuedMessages } = ctx.journal + // A hand-off this Stop's withdrawal sent back may not have caught up yet (its hook + // was skipped): heal it first, as the drain would, so the pause sees it waiting. + try { + if (queuedMessages.settlementOwed()) { + await queuedMessages.settleOwed() + } + } catch (error) { + report(ctx, 'owed settlement', error) + } + // Recorded only over a card it holds back — judged in its own transaction, which + // still counts an owed return to waiting if that heal failed. + await queuedMessages + .recordPause('stopped') + .catch((error: unknown) => report(ctx, 'queue pause', error)) + }) +} + +function report(ctx: AgentSessionTurnContext, step: string, error: unknown): void { + console.warn(`[agent-session] Stop's ${step} skipped:`, { + sessionId: ctx.sessionId, + error: error instanceof Error ? error.message : String(error) + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts new file mode 100644 index 00000000000..2b193d5cb82 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts @@ -0,0 +1,64 @@ +// Host wiring for mid-turn queueing: builds the serialized drain from the +// host's mutation context and exposes the draft actions (Send, Delete, Resume), so the host +// class stays a description of its surface. + +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import type { + StructuredAgentSessionCaller, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import { StructuredAgentSessionQueuedMessageDrain } from './structured-agent-session-queued-messages' +import { retireEndedQueuePause } from './structured-agent-session-queued-pause' +import { + deleteQueuedStructuredAgentMessage, + resumeStructuredAgentQueue, + sendQueuedStructuredAgentMessage +} from './structured-agent-session-queued-mutations' + +/** `sessions` are the live conversations (their `touch` is the idle sweep's activity renewal, + * which the drain's schedule rides); everything else comes from the host's mutation context, + * read lazily because the host's fields are still initializing when this is built. */ +export function wireStructuredAgentSessionQueuedMessages( + sessions: ReadonlyMap & { + touch: (sessionId: string) => void + }, + context: () => StructuredAgentSessionMutationContext +) { + const drain = new StructuredAgentSessionQueuedMessageDrain({ + sessions, + getRecord: (sessionId) => context().deps.store.getRecord(sessionId), + serialize: (sessionId, task) => context().serialize(sessionId, task), + flushStreamedEvents: (sessionId) => context().flushStreamedEvents(sessionId), + conversationFence: (sessionId) => + structuredAgentSessionConversationFence(context().deps.store, sessionId), + wakeDelivery: (sessionId) => context().wakeDelivery(sessionId), + onError: (sessionId, error) => context().deps.onEventSinkError?.({ sessionId, error }) + }) + return { + drain, + /** Every journal publish: turn, submission, prompt, command and Stop + * settlements are all commits, and each re-derives the drain's gates — + * and retires a queue pause a person's started turn already ended. */ + onJournalActivity: (sessionId: string) => { + sessions.touch(sessionId) + const journal = sessions.get(sessionId)?.journal + if (journal && !journal.isReadOnly) { + void retireEndedQueuePause(sessionId, journal) + } + drain.schedule(sessionId) + }, + queuedMessageSend: ( + caller: StructuredAgentSessionCaller, + params: Parameters[2] + ) => sendQueuedStructuredAgentMessage(context(), caller, params), + queuedMessageDelete: ( + caller: StructuredAgentSessionCaller, + params: Parameters[2] + ) => deleteQueuedStructuredAgentMessage(context(), caller, params), + queuedMessagesResume: ( + caller: StructuredAgentSessionCaller, + params: Parameters[2] + ) => resumeStructuredAgentQueue(context(), caller, params) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts new file mode 100644 index 00000000000..403cb08a405 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts @@ -0,0 +1,209 @@ +// A consumed draft whose submission a Stop withdrew before the agent had it is +// not a failure: it waits again at its own position under the Stop's hold, so +// it never blocks the paused cards behind it. After the user's next turn the +// whole queue drains one per turn in queue order, the withdrawn draft first, +// under a fresh submission id. + +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + eventually, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig() +}) + +afterEach(() => rig.dispose()) + +async function queuedDraft(text: string): Promise { + const queued = await rig.send(text, 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + return queued.value.queued.messageId +} + +async function submissionIds(): Promise { + return (await rig.host.journalSnapshot(SESSION)).submissions.map((entry) => entry.clientMessageId) +} + +async function handedOver(id: string): Promise { + await eventually(async () => expect((await rig.submission(id))?.handedOverAt).toBeDefined()) +} + +it('Stop, then a user send: the withdrawn draft and the paused cards behind it drain one per turn, in queue order', async () => { + const working = await rig.workingSend() + const a = await queuedDraft('A') + const b = await queuedDraft('B') + const c = await queuedDraft('C') + // The turn ends and the drain consumes A; the agent's start is held, so A is not handed over. + let release: () => void = () => undefined + rig.awaitStarted.mockImplementationOnce( + () => new Promise((resolve) => (release = () => resolve(undefined))) + ) + await rig.settleAccepted(working, 'working') + await eventually(async () => expect(await rig.handoff(a)).toBeDefined()) + // Never under the draft's own id: the submission names A by its link. + const firstA = await rig.handoffId(a) + expect(firstA).not.toBe(a) + expect((await rig.submission(firstA))?.handedOverAt).toBeUndefined() + + expect(await rig.stop()).toMatchObject({ ok: true }) + release() + // A is back in its place, behind the same queue pause as B and C: no returned card blocks them. + const paused = [a, b, c].map((messageId) => ({ messageId, state: 'waiting' })) + expect(await rig.drafts()).toEqual(paused) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + + const d = rig.send('D') + await d.result + await handedOver(d.id) + expect(await rig.drafts()).toEqual(paused) + await rig.settleAccepted(d.id, 'd') + + // After D's turn, A drains first, under another fresh id: the first names the withdrawn submission. + const before = new Set([working, firstA, d.id]) + let resentA = '' + await eventually(async () => { + const fresh = (await submissionIds()).filter((id) => !before.has(id)) + expect(fresh).toHaveLength(1) + resentA = fresh[0] ?? '' + }) + expect((await rig.submission(firstA))?.dispatchState).toBe('rejected') + // Both hand-offs of A name it; D, a direct send, names no draft. + expect((await rig.submission(resentA))?.queuedMessageId).toBe(a) + expect((await rig.submission(firstA))?.queuedMessageId).toBe(a) + expect(await rig.submission(d.id)).not.toHaveProperty('queuedMessageId') + expect((await rig.submission(resentA))?.payloadFingerprint).toBe( + (await rig.submission(firstA))?.payloadFingerprint + ) + expect(await rig.drafts()).toEqual([ + { messageId: b, state: 'waiting' }, + { messageId: c, state: 'waiting' } + ]) + + await handedOver(resentA) + await rig.settleAccepted(resentA, 'a') + await eventually(async () => expect((await rig.handoff(b))?.queuedMessageId).toBe(b)) + expect(await rig.handoff(c)).toBeUndefined() + await handedOver(await rig.handoffId(b)) + await rig.settleAccepted(await rig.handoffId(b), 'b') + await eventually(async () => expect(await rig.handoff(c)).toBeDefined()) + expect(await rig.drafts()).toEqual([]) +}) + +it('a Stop that fails after withdrawing a consumed draft releases it, and it sends again under a fresh id', async () => { + const working = await rig.workingSend() + const a = await queuedDraft('A') + let release: () => void = () => undefined + rig.awaitStarted.mockImplementationOnce( + () => new Promise((resolve) => (release = () => resolve(undefined))) + ) + await rig.settleAccepted(working, 'working') + await eventually(async () => expect(await rig.handoff(a)).toBeDefined()) + const firstA = await rig.handoffId(a) + const withdraw = AgentSessionJournal.prototype.rejectQueuedSubmissions + const failing = vi + .spyOn(AgentSessionJournal.prototype, 'rejectQueuedSubmissions') + .mockImplementation(async function (this: AgentSessionJournal, ...args) { + const withdrawn = await withdraw.apply(this, args) + // Only the Stop's own withdrawal fails, after it landed; the delivery loop's pass through. + if (args[1].rejection.kind === 'cancelled') { + throw new Error('disk full') + } + return withdrawn + }) + try { + await expect(rig.stop()).rejects.toThrow('disk full') + } finally { + failing.mockRestore() + release() + } + expect((await rig.submission(firstA))?.dispatchState).toBe('rejected') + const before = new Set([working, firstA]) + await eventually(async () => { + expect((await submissionIds()).filter((id) => !before.has(id))).toHaveLength(1) + expect(await rig.drafts()).toEqual([]) + }) +}) + +/** A consumed card whose delivery is held at the agent's start, so a Stop withdraws it; + * the settlement hook throws on that withdrawal's row, leaving the card owed a return. */ +async function stopWithSkippedSettlement(): Promise<{ a: string; working: string }> { + const working = await rig.workingSend() + const a = await queuedDraft('A') + let release: () => void = () => undefined + rig.awaitStarted.mockImplementationOnce( + () => new Promise((resolve) => (release = () => resolve(undefined))) + ) + await rig.settleAccepted(working, 'working') + await eventually(async () => expect(await rig.handoff(a)).toBeDefined()) + const settle = JournalQueuedMessages.prototype.onRowInTransaction + let skipped = false + const hook = vi + .spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction') + .mockImplementation(function (this: JournalQueuedMessages, db, row) { + if (!skipped && row.kind === 'dispatch' && row.state === 'rejected') { + skipped = true + throw new Error('bookkeeping failed') + } + return settle.call(this, db, row) + }) + const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + try { + expect(await rig.stop()).toMatchObject({ ok: true }) + } finally { + hook.mockRestore() + warned.mockRestore() + release() + } + expect(skipped).toBe(true) + return { a, working } +} + +it("a Stop whose withdrawal's settlement was skipped still pauses the card it sent back", async () => { + const { a } = await stopWithSkippedSettlement() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + await new Promise((resolve) => setTimeout(resolve, 250)) + // Healed back to waiting, but the Stop's pause holds it: it does not send. + expect(await rig.drafts()).toEqual([{ messageId: a, state: 'waiting' }]) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => + expect( + (await rig.host.journalSnapshot(SESSION)).submissions.filter( + (entry) => entry.queuedMessageId === a + ) + ).toHaveLength(2) + ) +}) + +it('the pause is recorded even when healing the skipped settlement fails, since the card is still owed', async () => { + const heal = vi + .spyOn(JournalQueuedMessages.prototype, 'settleOwed') + .mockRejectedValueOnce(new Error('disk full')) + try { + const { a } = await stopWithSkippedSettlement() + const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal + expect(journal?.queuedMessages.pause()).toMatchObject({ reason: 'stopped' }) + // The drain heals it later; the pause recorded over the owed card holds it then. + await eventually(async () => + expect(await rig.drafts()).toEqual([{ messageId: a, state: 'waiting' }]) + ) + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect( + (await rig.host.journalSnapshot(SESSION)).submissions.filter( + (entry) => entry.queuedMessageId === a + ) + ).toHaveLength(1) + } finally { + heal.mockRestore() + } +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts index 34e3fb8a4cf..514008e49f9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts @@ -48,6 +48,11 @@ const store = { } as unknown as AgentSessionRecordStore let journalRoot: string +const openDeps = () => ({ + store, + journalRoot, + adapter: {} +}) const opened: AgentSessionJournal[] = [] async function writeRemnant(name: string): Promise { @@ -73,29 +78,29 @@ describe('a session whose journal is still the pre-SQLite format', () => { it('is published, carrying the message that explains it', async () => { const transcript = await writeRemnant('log.jsonl') - const restored = await restoreStructuredAgentSessionRead(store, journalRoot, SESSION_ID) + const restored = await restoreStructuredAgentSessionRead(openDeps(), SESSION_ID) expect(restored).not.toBeNull() - opened.push(restored!.journal) - const disclosed = restored!.journal + opened.push(restored!.session.journal) + const disclosed = restored!.session.journal .snapshot() .items.map((entry) => (entry.body.kind === 'status' ? entry.body.text : '')) expect(disclosed.join('')).toContain(transcript) // Publishing it costs no agent process; acquisition still waits for the user. - expect(restored!.hasProviderChild).toBe(false) + expect(restored!.session.child).toBeNull() }) it('is published for a remnant whose log is gone', async () => { await writeRemnant('snapshot.json') - const restored = await restoreStructuredAgentSessionRead(store, journalRoot, SESSION_ID) + const restored = await restoreStructuredAgentSessionRead(openDeps(), SESSION_ID) expect(restored).not.toBeNull() - opened.push(restored!.journal) + opened.push(restored!.session.journal) }) it('still drops a session with neither a journal nor a remnant', async () => { - const restored = await restoreStructuredAgentSessionRead(store, journalRoot, SESSION_ID) + const restored = await restoreStructuredAgentSessionRead(openDeps(), SESSION_ID) expect(restored).toBeNull() }) @@ -103,7 +108,7 @@ describe('a session whose journal is still the pre-SQLite format', () => { it('still drops a session with no record', async () => { await writeRemnant('log.jsonl') - const restored = await restoreStructuredAgentSessionRead(store, journalRoot, 'unknown-session') + const restored = await restoreStructuredAgentSessionRead(openDeps(), 'unknown-session') expect(restored).toBeNull() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts index 65b913daf42..0e1441b729c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts @@ -1,104 +1,32 @@ -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { findJournalFileFormatRemnant } from '../agent-session-journal/journal-file-format-remnant' -import { loadJournal } from '../agent-session-journal/journal-open' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import { existsSync } from 'node:fs' +import { journalDatabaseFile, journalDirectoryFor } from '../agent-session-journal/journal-paths' import { - attachFingerprintFields, - journalIdentityFor, - type AgentSessionAttachParams -} from './structured-agent-session-attach' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' - -export type RestoredStructuredAgentSessionRead = { - journal: AgentSessionJournal - params: AgentSessionAttachParams - fence: number - hasProviderChild: false - providerChildPhase: 'ready' - acquisitionGeneration: null -} + openStructuredAgentSessionConversationJournal, + type OpenedStructuredAgentSessionConversation, + type StructuredAgentSessionConversationOpenDeps +} from './structured-agent-session-conversation-open' +/** + * A reader's open: the conversation's own open, for a session that has a journal to read. One + * with none — never written, or gone — stays unpublished rather than founding an empty one. + * Opening can still write: the crash boundary, and the row explaining an old-format history. + */ export async function restoreStructuredAgentSessionRead( - store: AgentSessionRecordStore, - journalRoot: string, + deps: StructuredAgentSessionConversationOpenDeps, sessionId: string -): Promise { - const record = store.getRecord(sessionId) +): Promise { + const record = deps.store.getRecord(sessionId) if (!record) { return null } - const params = attachParamsForRecord(record, { - clientOperationId: `read-restore:${record.sessionId}`, - expectedRuntimeFence: record.lease.runtimeFence - }) - const journalDir = journalDirectoryFor(journalRoot, { + const journalDir = journalDirectoryFor(deps.journalRoot, { workspaceId: record.location.workspaceId, sessionId }) - const loaded = loadJournal(journalDir, sessionId) - if (loaded?.corrupt) { + // A session still in the pre-SQLite format has no `journal.db`; the open imports it. + if (!existsSync(journalDatabaseFile(journalDir)) && !findJournalFileFormatRemnant(journalDir)) { return null } - // A session still in the pre-SQLite format has no `journal.db` to load. Dropping - // it here leaves it unpublished, which is also what prunes its tab out of the - // saved workspace — so the chat disappears with nowhere to explain itself. - if (!loaded && !findJournalFileFormatRemnant(journalDir)) { - return null - } - const journal = await openAgentSessionJournal({ - identity: journalIdentityFor(record, params), - journalDir, - // Omitted, not `null`: the store reads `null` as "replay already ran and - // found nothing" and founds a fresh epoch. In process the probe above is the - // previous statement, so the window is zero-width; this holds the line for a - // database another process creates in between. - ...(loaded ? { loaded } : {}) - }) - // Read restore opens the journal and nothing else: no adapter call, so no - // provider child. Opening it can still write — a session whose history is in - // the old format founds its epoch and commits the row explaining that here. - return { - journal, - params, - fence: record.lease.runtimeFence, - hasProviderChild: false, - providerChildPhase: 'ready', - acquisitionGeneration: null - } -} - -export function attachParamsForRecord( - record: AgentSessionRecord, - input: { - clientOperationId: string - expectedRuntimeFence: number - } -): AgentSessionAttachParams { - const params: AgentSessionAttachParams = { - envelope: { - sessionId: record.sessionId, - clientOperationId: input.clientOperationId, - expectedRuntimeFence: input.expectedRuntimeFence, - payloadFingerprint: '' - }, - location: record.location, - provider: record.provider, - agent: record.provider, - accountHome: record.accountHome, - runtimeKind: 'native' - } - return { - ...params, - envelope: { - ...params.envelope, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.attach', - sessionId: record.sessionId, - fields: attachFingerprintFields(params) - }) - } - } + return openStructuredAgentSessionConversationJournal(deps, record) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts index 5d579fbe256..d1cc9408a62 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts @@ -19,15 +19,17 @@ describe('StructuredAgentSessionReadableRestorer', () => { (sessionId) => ({ sessionId }) as AgentSessionRecord ) const restorer = new StructuredAgentSessionReadableRestorer({ - store: { listRecords: () => records } as never, - journalRoot: '/tmp/journals', + openDeps: { + store: { getRecord: () => null, listRecords: () => records }, + journalRoot: '/tmp/journals', + adapter: {} + }, supportsRecord: () => true, reconcile: async () => null, resolveRecovery: async () => undefined, serialize: async (_sessionId, task) => task(), hasSession: () => false, - onReadable: () => undefined, - retrySettlement: async () => true + onReadable: () => undefined }) await restorer.restore(['visible-a', 'visible-b', 'background-a', 'background-b']) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts index a11c3a85b6c..06bd043aec0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts @@ -1,30 +1,13 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import type { RestoredStructuredAgentSessionRead } from './structured-agent-session-read-restore' -import { - restoreOneStructuredAgentSessionRead, - restoreOneStructuredAgentSessionReadUnderSerialize, - restoreStructuredAgentSessionsOnRestart -} from './structured-agent-session-restart-restore' +import type { StructuredAgentSessionReadRestoreDeps } from './structured-agent-session-restart-restore' +import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' export class StructuredAgentSessionReadableRestorer { private restorePromise: Promise | null = null constructor( - private readonly input: { - store: AgentSessionRecordStore - journalRoot: string + private readonly input: StructuredAgentSessionReadRestoreDeps & { supportsRecord: (record: AgentSessionRecord) => boolean - reconcile: (sessionId: string) => Promise - resolveRecovery: (sessionId: string) => Promise - serialize: (sessionId: string, task: () => Promise) => Promise - hasSession: (sessionId: string) => boolean - onReadable: (sessionId: string, restored: RestoredStructuredAgentSessionRead) => void - retrySettlement: ( - sessionId: string, - params: RestoredStructuredAgentSessionRead['params'] - ) => Promise } ) {} @@ -36,46 +19,11 @@ export class StructuredAgentSessionReadableRestorer { return this.restorePromise } - /** - * One session, on demand, after startup. - * - * Deliberately outside `restorePromise`: that latch answers "has the startup sweep run", and a - * surface asking for a session the sweep never covered — or that was closed since — must not be - * told yes because the sweep finished. Needs no dedupe of its own; the per-session task queue - * `serialize` runs on already orders concurrent callers, and the second one sees `hasSession`. - * - * Provider-agnostic by construction: eligibility is `supportsRecord`, which the adapter router - * answers for Claude and Codex from the record's own provider. - */ - async restoreOne(sessionId: string): Promise { - if (!this.supports(sessionId)) { - return false - } - await restoreOneStructuredAgentSessionRead(this.input, sessionId) - return this.input.hasSession(sessionId) - } - - /** `restoreOne` for a caller already inside the session's serialize. Reconciliation is skipped - * on purpose: a lease this host has not adjudicated is the attach's problem, and a replay - * needs only the journal. */ - async restoreOneUnderSerialize(sessionId: string): Promise { - if (!this.supports(sessionId)) { - return false - } - await restoreOneStructuredAgentSessionReadUnderSerialize(this.input, sessionId) - return this.input.hasSession(sessionId) - } - - private supports(sessionId: string): boolean { - const record = this.input.store.getRecord(sessionId) - return record !== null && this.input.supportsRecord(record) - } - private async restoreReadableSessions(sessionIds?: readonly string[]): Promise { const targetOrder = sessionIds ? new Map(sessionIds.map((sessionId, index) => [sessionId, index])) : null - const records = this.input.store + const records = this.input.openDeps.store .listRecords() .filter( (record) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index a239e4a5154..7a90850dfc6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A turn that was running when its host went away ends when recovery settles it. That settlement is // the edge the user needs to see — their work stopped — so the session reads as newly done then, // and nothing along the way may call it a success. Every hop is the real one: durable journal, @@ -14,11 +15,11 @@ import type { import { AgentHookServer, _internals } from '../../agent-hooks/server' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' import { - settleStaleSessionStateOnAcquire, - type StructuredAgentSessionTurnVerdict -} from './structured-agent-session-stale-turn-verdict' + settleStaleStructuredAgentSessionState, + settleStructuredAgentSessionDeadGeneration +} from './structured-agent-session-dead-generation-settlement' +import type { StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' import { StructuredAgentSessionTurnCompletionFeed } from './structured-agent-session-turn-completion-feed' @@ -59,12 +60,12 @@ async function sessionWithRunningTurn() { await journal.appendItem( { provider: 'orca', clientMessageId: 'prompt-1' }, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'long job' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 9 }, { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: TURN_STARTED }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const server = new AgentHookServer() const sessions = new Map([[SESSION, indexedStatusFeedSession({ journal })]]) @@ -88,7 +89,11 @@ async function sessionWithRunningTurn() { } } }) - const completions = new StructuredAgentSessionTurnCompletionFeed({ sessions, now: () => clock }) + const completions = new StructuredAgentSessionTurnCompletionFeed({ + sessions, + now: () => clock, + readStatusState: (sessionId, source) => feed.statusState(sessionId, source) + }) const completionEvents: AgentSessionTurnCompletionEvent[] = [] completions.subscribe({ id: 'dot-1', emit: (event) => completionEvents.push(event) }) // Both feeds have seen the turn running, so its settlement is a transition they must judge. @@ -157,11 +162,12 @@ describe('a turn recovery settled after its host went away', () => { it('is dated the same way when a new provider child finds the turn still running', async () => { const session = await sessionWithRunningTurn() session.recoverAt(RECOVERED) - await settleStaleSessionStateOnAcquire({ + await settleStaleStructuredAgentSessionState({ journal: session.journal, sessionId: SESSION, fence: 2, - acquisitionGeneration: 'generation-2' + acquisitionGeneration: 'generation-2', + deathEvidence: null }) session.publish() @@ -191,7 +197,7 @@ describe('a turn recovery settled after its host went away', () => { startedAt: TURN_STARTED, completedAt: EXIT_OBSERVED }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) session.publish() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts index 7cd92bf521b..2da22f95873 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts @@ -11,6 +11,7 @@ import { readProcessStartTimeMs } from '../../runtime/agent-session-process-iden import { createStructuredAgentSessionOwnerProbe } from '../../runtime/structured-agent-session-owner-probe' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { HOST_TEST_NOW as NOW, @@ -59,6 +60,12 @@ async function stopOwner(child: ReturnType): Promise spawnedOwners.delete(child) } +/** What a send's delivery or `agentSession.ensure` does: attach at the record's current fence. */ +async function startAgent(): Promise { + const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null + expect((await host.attach(CALLER, hostTestAttachParams(fence))).ok).toBe(true) +} + function adapter(): StructuredAgentSessionAdapter { return { acquire, @@ -82,17 +89,8 @@ function openHost(overrides: Partial = {}): void }) } -async function abandonHost(abandonedHost: StructuredAgentSessionHost): Promise { - abandonedHost['runtimeState'].stopLeaseRenewal() - abandonedHost['holds'].dispose() - await Promise.all( - [...abandonedHost['sessions'].values()].map((session) => session.journal.close()) - ) - abandonedHost['sessions'].clear() -} - async function reopenStore(): Promise { - await abandonHost(host) + await abandonStructuredAgentSessionHost(host) store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) } @@ -119,37 +117,35 @@ beforeEach(async () => { }) afterEach(async () => { - await abandonHost(host) - await Promise.all([...supersededHosts].map(abandonHost)) + await abandonStructuredAgentSessionHost(host) + await Promise.all([...supersededHosts].map(abandonStructuredAgentSessionHost)) supersededHosts.clear() await Promise.all([...spawnedOwners].map((child) => stopOwner(child))) await rm(root, { recursive: true, force: true }) }) describe('recovery exits', () => { - it('keeps an ownerless unproven acquisition in manual recovery across restart', async () => { + it('releases an ownerless unproven acquisition, so the next start goes ahead', async () => { acquire.mockRejectedValueOnce(new Error('simulated crash before identity commit')) await expect(host.attach(CALLER, hostTestAttachParams(null))).rejects.toThrow( 'agent_session_acquisition_exit_unproven' ) + // No owner was recorded, and the adapter closed the stdio of anything it spawned. expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'reserved', - handoffStage: 'manual-recovery', + claimStatus: 'released', + handoffStage: null, handoffOperationId: null, ownerProcess: null, - runtimeFence: 1, - reservedSpawnToken: 'spawn-a' + runtimeFence: 2, + reservedSpawnToken: null, + deathEvidence: null }) await reopenStore() openHost({ mintSpawnToken: () => 'spawn-b' }) - const refused = await host.attach(CALLER, hostTestAttachParams(1)) - expect(refused).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_ownership_unknown' } - }) - expect(acquire).toHaveBeenCalledOnce() + expect(await host.attach(CALLER, hostTestAttachParams(2))).toMatchObject({ ok: true }) + expect(acquire).toHaveBeenCalledTimes(2) }) it('releases an unproven acquisition whose owner later dies, without replaying it as a handoff', async () => { @@ -197,8 +193,8 @@ describe('recovery exits', () => { runtimeFence: 4 }) - // The ordinary native recovery path remains: the first surface hold resumes it. - await host.hold(SESSION, 'surface-1') + // The ordinary native recovery path remains: the next start resumes it. + await startAgent() expect(acquire).toHaveBeenCalledTimes(3) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', @@ -242,7 +238,7 @@ describe('recovery exits', () => { }) }) - it('heals a stranded native owner during startup restore, and spawns nothing until a surface asks', async () => { + it('heals a stranded native owner during startup restore, and spawns nothing until work asks', async () => { expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) await reopenStore() @@ -262,7 +258,7 @@ describe('recovery exits', () => { await host.restoreReadableSessions() // Healing is startup's job; spawning is not. The orphan is stopped and the lease is free, but - // nothing has asked to look at this session, so no replacement child exists yet. + // nothing has asked this session for work, so no replacement child exists yet. expect(stopOwnerProcess).toHaveBeenCalledWith(4242, 'SIGTERM') expect(acquire).toHaveBeenCalledTimes(1) expect(store.getRecord(SESSION)?.lease).toMatchObject({ @@ -271,7 +267,7 @@ describe('recovery exits', () => { ownerProcess: null }) - await host.hold(SESSION, 'surface-1') + await startAgent() expect(acquire).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease).toMatchObject({ @@ -340,7 +336,7 @@ describe('recovery exits', () => { } }) - await host.hold(SESSION, 'surface-overlap') + await startAgent() expect(store.getRecord(SESSION)?.lease).toMatchObject({ runtimeFence: 3, @@ -348,6 +344,6 @@ describe('recovery exits', () => { handoffStage: null, ownerProcess: { pid: replacement.process.pid, spawnToken: 'spawn-b' } }) - expect(host.history({ sessionId: SESSION, direction: 'tail' }).ok).toBe(true) + expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts index b647ed01883..063e8c64af7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts @@ -1,3 +1,4 @@ +import { spawn } from 'node:child_process' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -8,6 +9,7 @@ import { writeOlderBuildLease } from '../../runtime/agent-session-older-build-lease.test-fixture' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { supervisedPosixLaunch } from '../../codex/codex-app-server-posix-supervisor' import { resolveStructuredSessionRecovery, type StructuredSessionRecoveryResolutionDeps @@ -62,7 +64,7 @@ async function reserve(store: AgentSessionRecordStore) { }) } -async function liveOwner(store: AgentSessionRecordStore) { +async function liveOwner(store: AgentSessionRecordStore, pid = 4242) { const reserved = await reserve(store) const fence = reserved.record.lease.runtimeFence await store.commitProcessIdentity({ @@ -70,7 +72,7 @@ async function liveOwner(store: AgentSessionRecordStore) { fence, process: { hostId: 'local', - pid: 4242, + pid, processStartTimeMs: NOW - 1_000, spawnToken: 'spawn-recovery' }, @@ -90,10 +92,10 @@ async function liveOwner(store: AgentSessionRecordStore) { }) } -async function latch(store: AgentSessionRecordStore, stage: 'recovering' | 'manual-recovery') { +async function latch(store: AgentSessionRecordStore) { return store.transitionHandoff(SESSION, (record) => ({ ...record, - lease: { ...record.lease, handoffStage: stage } + lease: { ...record.lease, handoffStage: 'recovering' } })) } @@ -117,29 +119,30 @@ function deps( } describe('structured session recovery resolution', () => { - it('does not release an ownerless native reservation without processless proof', async () => { + it('releases an ownerless reservation: nothing it recorded can be holding it', async () => { const store = await openStore() await reserve(store) - await latch(store, 'recovering') + await latch(store) const result = await resolveStructuredSessionRecovery( deps(store, () => ({ outcome: 'indeterminate', reason: 'no scan' })), SESSION ) - expect(result).toBe('unresolved') + expect(result).toBe('resolved') expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'reserved', - handoffStage: 'recovering', - runtimeFence: 1, - reservedSpawnToken: 'spawn-recovery' + claimStatus: 'released', + handoffStage: null, + runtimeFence: 2, + reservedSpawnToken: null, + deathEvidence: null }) }) it('evicts a latched owner the probe now proves dead, without a stop request', async () => { const store = await openStore() await liveOwner(store) - await latch(store, 'manual-recovery') + await latch(store) const stopOwnerProcess = vi.fn() const result = await resolveStructuredSessionRecovery( @@ -160,7 +163,7 @@ describe('structured session recovery resolution', () => { it('stops a live identity-matched orphan and evicts only after absence is proven', async () => { const store = await openStore() await liveOwner(store) - await latch(store, 'recovering') + await latch(store) let alive = true const stopOwnerProcess = vi.fn(() => { alive = false @@ -188,10 +191,10 @@ describe('structured session recovery resolution', () => { }) }) - it('escalates the stop request but never evicts an owner that stays alive', async () => { + it('releases an owner that survives the stop ladder, with no death evidence', async () => { const store = await openStore() await liveOwner(store) - await latch(store, 'recovering') + await latch(store) const stopOwnerProcess = vi.fn() const result = await resolveStructuredSessionRecovery( @@ -201,22 +204,25 @@ describe('structured session recovery resolution', () => { SESSION ) - expect(result).toBe('unresolved') - expect(stopOwnerProcess).toHaveBeenCalledWith(4242, 'SIGTERM') - expect(stopOwnerProcess).toHaveBeenCalledWith(4242, 'SIGKILL') - // The latch is preserved verbatim: no fence move, no cleared owner, no lost state. + expect(result).toBe('resolved') + expect(stopOwnerProcess.mock.calls).toEqual([ + [4242, 'SIGTERM'], + [4242, 'SIGKILL'] + ]) + // Its transport died with the runtime that held it; nothing proved it gone, so no evidence. expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'live', - handoffStage: 'recovering', - runtimeFence: 1, - ownerProcess: { pid: 4242 } + claimStatus: 'released', + handoffStage: null, + runtimeFence: 2, + ownerProcess: null, + deathEvidence: null }) }) - it('leaves an unverifiable owner latched and requests no stop', async () => { + it('releases an owner whose identity cannot be verified, and signals nothing', async () => { const store = await openStore() await liveOwner(store) - await latch(store, 'recovering') + await latch(store) const stopOwnerProcess = vi.fn() const result = await resolveStructuredSessionRecovery( @@ -226,11 +232,15 @@ describe('structured session recovery resolution', () => { SESSION ) - expect(result).toBe('unresolved') + expect(result).toBe('resolved') + // The pid may have been reused by an unrelated process. expect(stopOwnerProcess).not.toHaveBeenCalled() expect(store.getRecord(SESSION)?.lease).toMatchObject({ - handoffStage: 'recovering', - runtimeFence: 1 + claimStatus: 'released', + handoffStage: null, + runtimeFence: 2, + ownerProcess: null, + deathEvidence: null }) }) @@ -255,7 +265,7 @@ describe('structured session recovery resolution', () => { expect(await readPersistedLease(directory, SESSION)).toMatchObject({ runtimeKind: 'native', claimStatus: 'conflicted', - handoffStage: 'manual-recovery' + handoffStage: 'recovering' }) expect( @@ -270,7 +280,7 @@ describe('structured session recovery resolution', () => { }) }) - it('resolves a terminal reservation an older build left naming nobody', async () => { + it('releases a terminal reservation an older build left naming nobody at restart', async () => { const directory = await newStoreDirectory() await reserve(await openStore(directory)) await writeOlderBuildLease(directory, SESSION, { runtimeKind: 'tui' }) @@ -279,63 +289,87 @@ describe('structured session recovery resolution', () => { probe: async () => ({ outcome: 'indeterminate', reason: 'no scan' }), now: NOW }) - // Only the kind changes: no process is recorded for a conflict to name. + // No process is recorded for a conflict to name, so there is nothing to wait out. expect(store.getRecord(SESSION)?.lease).toMatchObject({ runtimeKind: 'native', - claimStatus: 'reserved', - handoffStage: 'manual-recovery' + claimStatus: 'released', + handoffStage: null, + deathEvidence: null }) - expect( await resolveStructuredSessionRecovery( deps(store, () => ({ outcome: 'reservation-unused' })), SESSION ) - ).toBe('resolved') - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - handoffStage: null, - claimStatus: 'released' - }) - }) - - it('frees a conflicted claim once its named owner is proven gone', async () => { - const store = await openStore() - await liveOwner(store) - await store.markClaimConflicted(SESSION, NOW) - - expect( - await resolveStructuredSessionRecovery( - deps(store, () => ({ outcome: 'pid-absent' })), - SESSION - ) - ).toBe('resolved') - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - handoffStage: null, - claimStatus: 'released', - deathEvidence: { kind: 'pid-absent' } - }) - }) - - it('never stops the process a conflicted claim names, and keeps the conflict without proof', async () => { - const store = await openStore() - await liveOwner(store) - await store.markClaimConflicted(SESSION, NOW) - const stopOwnerProcess = vi.fn() - - const result = await resolveStructuredSessionRecovery( - deps(store, () => ({ outcome: 'identity-matched', matchedOn: ['spawn-token'] }), { - stopOwnerProcess - }), - SESSION - ) - - // Ownership was never settled, so the process on the other side of the conflict is not - // Orca's to kill; only the user can decide which claimant wins. - expect(stopOwnerProcess).not.toHaveBeenCalled() - expect(result).toBe('unresolved') - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'conflicted', - handoffStage: 'manual-recovery' - }) + ).toBe('not-applicable') }) }) + +describe.runIf(process.platform !== 'win32')( + 'structured session recovery of a supervised owner', + () => { + const recordedPids: number[] = [] + const alive = (pid: number): boolean => { + try { + process.kill(pid, 0) + return true + } catch (error) { + return !(error instanceof Error && 'code' in error && error.code === 'ESRCH') + } + } + + afterEach(() => { + for (const pid of recordedPids.splice(0)) { + if (alive(pid)) { + process.kill(pid, 'SIGKILL') + } + } + }) + + it('evicts only after the supervisor has reaped a provider that ignores SIGTERM', async () => { + const launch = supervisedPosixLaunch( + { + command: process.execPath, + args: [ + '-e', + "process.on('SIGTERM', () => {}); process.stdout.write(process.pid + '\\n'); setInterval(() => {}, 60000)" + ] + }, + process.env + ) + const supervisor = spawn(launch.command, launch.args, { + env: launch.env, + stdio: ['pipe', 'pipe', 'ignore'], + detached: true + }) + recordedPids.push(supervisor.pid!) + const provider = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('provider never started')), 10_000) + supervisor.stdout.once('data', (chunk: Buffer) => { + clearTimeout(timeout) + resolve(Number(chunk.toString().trim())) + }) + }) + recordedPids.push(provider) + const store = await openStore() + await liveOwner(store, supervisor.pid!) + await latch(store) + + const result = await resolveStructuredSessionRecovery( + { + store, + // The recorded pid is the supervisor's; its absence is the proof recovery evicts on. + probeRecord: async () => + alive(supervisor.pid!) ? MATCHED : { outcome: 'pid-absent' as const }, + now: () => NOW + 10_000 + }, + SESSION + ) + + expect(result).toBe('resolved') + // Evicted on proof of death, which must hold for the provider too, not only the supervisor. + expect(store.getRecord(SESSION)?.lease.deathEvidence).not.toBeNull() + expect(alive(provider)).toBe(false) + }) + } +) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts index 40317552d6e..385d438f7a0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts @@ -1,8 +1,12 @@ /** - * Exits from latched recovery stages. A session lands in `recovering` / `manual-recovery` - * when evidence about its owner was UNAVAILABLE; this module re-asks with present-time - * evidence and releases the lease only on proof. A stop is a request — the lease moves only - * after a later probe proves the process absent, never on a timeout. + * Exits from the `recovering` stage. A session lands there when evidence about its owner was + * unavailable; this re-asks with present-time evidence and always concludes. A dead owner is + * evicted on proof. A live one is stopped by identity and evicted once + * proven gone. One that outlives the stop, or whose identity cannot be verified, is released + * anyway: its transport died with the runtime that held it, so nothing can drive it, and no signal + * is sent to a pid that cannot be verified as the one recorded. Only a conflicted claim, which is + * how a terminal owner an older build recorded now loads, is waited out and never stopped: it is + * the user's own agent, and its exit is its way out. */ import { @@ -11,6 +15,8 @@ import { type AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../../codex/codex-app-server-posix-supervisor' +import { releaseUnprovenAgentSessionOwner } from '../../runtime/agent-session-lease-transitions' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' export type StructuredSessionRecoveryStopSignal = 'SIGTERM' | 'SIGKILL' @@ -23,8 +29,13 @@ export type StructuredSessionRecoveryResolutionDeps = { delay?: (ms: number) => Promise } -const STOP_PROBES_PER_SIGNAL = 4 const STOP_PROBE_INTERVAL_MS = 250 +// A POSIX structured owner is its provider supervisor, which exits only after its provider +// group. A SIGKILL that lands first leaves the group running, so SIGTERM outlasts its stop. +const STOP_PROBES: Record = { + SIGTERM: Math.ceil(PROVIDER_SUPERVISOR_MAX_STOP_MS / STOP_PROBE_INTERVAL_MS) + 1, + SIGKILL: 4 +} const UNRESOLVED_REFUSALS: ReadonlySet = new Set([ 'agent_session_ownership_unknown', @@ -33,62 +44,46 @@ const UNRESOLVED_REFUSALS: ReadonlySet = new Set([ 'agent_session_identity_required' ]) -/** Which latched records this module may re-ask about. */ -export function structuredSessionRecoveryIsResolvable(record: AgentSessionRecord): boolean { - if (record.lease.settlementRetryRequired) { - // Settlement latches are cleared only by a successful journal retry, never by owner probing. - return false - } - const { claimStatus, handoffStage, ownerProcess } = record.lease - if (handoffStage !== 'recovering' && handoffStage !== 'manual-recovery') { - return false - } - if (claimStatus === 'conflicted') { - // A conflict names one process. Re-asking is only meaningful against that name; with none - // recorded there is nothing present-time evidence could settle, and the user decides. - return ownerProcess !== null - } - return true -} - -/** Stopping a matched owner is only Orca's call when Orca owned its transport. A conflicted claim - * means ownership was never settled — including a terminal an older build recorded as owner. */ -function recoveryMayStopOwner(record: AgentSessionRecord): boolean { - return record.lease.claimStatus !== 'conflicted' -} - export async function resolveStructuredSessionRecovery( deps: StructuredSessionRecoveryResolutionDeps, sessionId: string ): Promise<'resolved' | 'unresolved' | 'not-applicable'> { const record = deps.store.getRecord(sessionId) - if (!record || !structuredSessionRecoveryIsResolvable(record)) { + if (record?.lease.handoffStage !== 'recovering') { return 'not-applicable' } let probe = await deps.probeRecord(record) const owner = record.lease.ownerProcess - if ( - owner && - owner.hostId === deps.store.hostId && - isProvenAliveProbe(probe) && - recoveryMayStopOwner(record) - ) { - // The owner is a live child of a runtime that no longer exists; its transport cannot be - // reconstructed, so the only way forward is to stop it and prove it gone. + if (owner && record.lease.claimStatus === 'conflicted' && !isProvenDeadProbe(probe)) { + // A terminal agent keeps its transport across a restart, so only proof of its exit is a way in. + return 'unresolved' + } + if (owner && isProvenAliveProbe(probe)) { + if (owner.hostId !== deps.store.hostId) { + return 'unresolved' + } probe = await stopOwnerAndReprobe(deps, record, owner.pid) } try { - await deps.store.evictProvenDeadOwner({ - sessionId, - expectedFence: record.lease.runtimeFence, - probe, - now: deps.now() - }) + await (owner && !isProvenDeadProbe(probe) + ? deps.store.transitionHandoff(sessionId, (latest) => + releaseUnprovenAgentSessionOwner({ + record: latest, + expectedFence: record.lease.runtimeFence, + now: deps.now() + }) + ) + : deps.store.evictProvenDeadOwner({ + sessionId, + expectedFence: record.lease.runtimeFence, + probe, + now: deps.now() + })) return 'resolved' } catch (error) { const code = error instanceof Error ? error.message : String(error) if (UNRESOLVED_REFUSALS.has(code)) { - // No proof yet; the record is preserved untouched and the next attempt re-asks. + // The record moved under this resolution; the next attempt re-asks against what it is now. return 'unresolved' } throw error @@ -105,7 +100,7 @@ async function stopOwnerAndReprobe( let probe: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'owner stop requested' } for (const signal of ['SIGTERM', 'SIGKILL'] as const) { stop(pid, signal) - for (let attempt = 0; attempt < STOP_PROBES_PER_SIGNAL; attempt += 1) { + for (let attempt = 0; attempt < STOP_PROBES[signal]; attempt += 1) { probe = await deps.probeRecord(record) if (isProvenDeadProbe(probe)) { return probe diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts new file mode 100644 index 00000000000..505bec74c23 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts @@ -0,0 +1,274 @@ +// A refusal's details name the situation, so every place that answers for a refusal — the first +// reply, a ledger replay, the store fallback copy — must name the same one. + +import { describe, expect, it } from 'vitest' +import { + agentSessionRecordFixture, + agentSessionLeaseFixture +} from '../../../shared/agent-session-record.test-fixture' +import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { classifyStoreFailure } from './structured-agent-session-attach' +import { + AgentSessionAcquisitionExitProvenError, + AgentSessionAcquisitionExitUnprovenError, + AgentSessionAcquisitionRefusal, + AgentSessionPreSpawnError +} from './structured-agent-session-adapter' +import { + failedAcquisitionRefusal, + failedAcquisitionSettlement +} from './structured-agent-session-failed-create-refusal' +import { withObservedProviderExit } from './structured-agent-session-failure-text' +import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' + +const CLAUDE_CREATE = { + record: agentSessionRecordFixture(), + newSession: true +} + +function replay(outcome: Parameters[0]['outcome']) { + return resolveAgentSessionReplayOutcome({ + operationId: 'op-1', + outcome, + reconstruct: () => null + }) +} + +describe('a ledger replay names the details its first answer did', () => { + it.each([ + [new AgentSessionAcquisitionRefusal('not signed in', 'notSignedIn'), 'notSignedIn'], + [ + new AgentSessionAcquisitionExitProvenError( + withObservedProviderExit(new Error('exited (code 1)')) + ), + 'providerStartFailed' + ], + // Gone now, with no exit observed: no situation, on the first answer or the replay. + [new AgentSessionAcquisitionExitProvenError(new Error('spawn codex ENOENT')), undefined] + ])('for a failed create: %s', (error, reason) => { + const first = failedAcquisitionRefusal(error, CLAUDE_CREATE) + const replayed = replay(failedAcquisitionSettlement(error, CLAUDE_CREATE).outcome) + expect(first?.refusal.details?.reason).toBe(reason) + expect(replayed).toMatchObject({ + decision: 'refuse', + refusal: { code: first?.refusal.code, ...(reason ? { details: first?.refusal.details } : {}) } + }) + if (!reason) { + expect(replayed).not.toHaveProperty('refusal.details') + } + }) + + it('for a create whose cleanup could not prove the child gone', () => { + const outcome = failedAcquisitionSettlement( + new AgentSessionAcquisitionExitUnprovenError(new Error('probe failed')), + CLAUDE_CREATE + ).outcome + expect(replay(outcome)).toMatchObject({ + refusal: { code: 'agent_session_ownership_unknown', details: { reason: 'ownerUnproven' } } + }) + }) + + it.each([ + [ + new AgentSessionAcquisitionExitProvenError( + withObservedProviderExit( + new Error('claude stream-json exited (code 1): claude: not signed in (rig)') + ) + ), + 'Claude stopped before it finished starting. Send your message to try again.' + ], + [ + new AgentSessionAcquisitionExitProvenError(new Error('spawn claude ENOENT')), + "Claude couldn't start. Send your message to try again." + ], + [ + new AgentSessionAcquisitionRefusal( + 'Claude is not signed in for the selected account. Sign in with the Claude CLI for this CLAUDE_CONFIG_DIR, then retry.', + 'notSignedIn' + ), + 'Claude is not signed in for the selected account. Sign in, then send your message again.' + ], + [ + AgentSessionAcquisitionRefusal.historyTooLarge( + 'Codex thread history exceeds the bounded restore queue; history was not partially imported.' + ), + "This conversation's history is too large to restore here. Start a new chat to continue." + ] + ])('answers and replays %s in the sentence its start failure reads as', (error, sentence) => { + const first = failedAcquisitionRefusal(error, CLAUDE_CREATE) + const replayed = replay(failedAcquisitionSettlement(error, CLAUDE_CREATE).outcome) + expect(first?.refusal.message).toBe(sentence) + expect(replayed).toMatchObject({ decision: 'refuse', refusal: { message: sentence } }) + for (const message of [ + first?.refusal.message, + failedAcquisitionSettlement(error, CLAUDE_CREATE).outcome.message + ]) { + expect(message).not.toMatch( + /stream-json|exited \(code|ENOENT|CLAUDE_CONFIG_DIR|restore queue/ + ) + } + }) + + it('replays a spawn that failed before any process in a sentence, not its error', () => { + const outcome = failedAcquisitionSettlement( + new AgentSessionPreSpawnError(new Error('spawn claude ENOENT')), + CLAUDE_CREATE + ).outcome + expect(replay(outcome)).toMatchObject({ + refusal: { message: "Claude couldn't start. Send your message to try again." } + }) + }) + + it('keeps the code a store refusal replays with, and the unproven-exit marker', () => { + expect( + failedAcquisitionSettlement(new Error('agent_session_conflict'), CLAUDE_CREATE).outcome + .message + ).toBe('agent_session_conflict') + expect( + failedAcquisitionSettlement( + new AgentSessionAcquisitionExitUnprovenError(new Error('probe failed')), + CLAUDE_CREATE + ).outcome.message + ).toBe('agent_session_acquisition_exit_unproven') + }) + + it('replays the facts beside the reason, and mirrors them where released clients read', () => { + const resolution = { + state: 'resolved' as const, + selectedOptionId: 'allow', + resolvedBy: 'phone', + resolvedAt: 5 + } + const replayed = replay({ + status: 'failed', + code: 'agent_session_item_revision_stale', + details: { reason: 'promptMoved', currentRevision: 3, resolution } + }) + expect(replayed).toMatchObject({ + refusal: { + code: 'agent_session_item_revision_stale', + details: { reason: 'promptMoved', currentRevision: 3, resolution }, + currentRevision: 3, + resolution + } + }) + }) + + it('reads a row an older host wrote, with no details, as naming none', () => { + const replayed = replay({ status: 'failed', code: 'agent_session_conflict' }) + expect(replayed).toMatchObject({ refusal: { code: 'agent_session_conflict' } }) + expect(replayed.decision === 'refuse' && replayed.refusal).not.toHaveProperty('details') + }) + + it('drops the cause an unreleased build wrote, and a reason the code does not list', () => { + for (const row of [ + { status: 'failed' as const, code: 'agent_session_conflict', cause: 'claimConflicted' }, + { + status: 'failed' as const, + code: 'agent_session_conflict', + details: { reason: 'promptGone' as const } + } + ]) { + const replayed = replay(row) + expect(replayed).toMatchObject({ refusal: { code: 'agent_session_conflict' } }) + expect(replayed.decision === 'refuse' && replayed.refusal).not.toHaveProperty('details') + } + }) + + it('names a code this build cannot place as refused earlier', () => { + expect(replay({ status: 'failed', code: 'agent_session_future_code' })).toMatchObject({ + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'operationRefusedEarlier' } + } + }) + }) + + it('names a lost outcome and a lost result', () => { + expect(replay({ status: 'unknown' })).toMatchObject({ + refusal: { details: { reason: 'outcomeUnknown' } } + }) + expect(replay({ status: 'succeeded', sessionId: 's' })).toMatchObject({ + refusal: { details: { reason: 'resultLost' } } + }) + }) +}) + +describe('the store fallback copy', () => { + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ + ownerProcess: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'spawn-1' } + }) + ) + + it('words a situation its code would misdescribe by the situation', () => { + const refusal = classifyStoreFailure( + agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'replaySuperseded' }), + null, + record + ) + expect(refusal).toMatchObject({ + code: 'agent_session_ownership_unknown', + details: { reason: 'replaySuperseded' } + }) + // Not the latched-owner story: this owner is not in doubt, the replay is just stale. + expect(refusal.message).not.toContain('4242') + }) + + it('keeps the latched-owner story, with its reason, for an owner it cannot prove gone', () => { + const refusal = classifyStoreFailure( + agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'ownerUnproven' }), + null, + record + ) + expect(refusal.details?.reason).toBe('ownerUnproven') + expect(refusal.message).toContain('4242') + }) + + it('does not promise an update fixes a record this build cannot read', () => { + // Unreadable covers a damaged record as well as one a newer build wrote. + expect( + classifyStoreFailure( + agentSessionRefusalError('execution_owner_reconciling', { reason: 'recordUnreadable' }), + null, + null + ) + ).toEqual({ + code: 'execution_owner_reconciling', + details: { reason: 'recordUnreadable' }, + message: + "Orca can't read this chat's saved state. If a newer version of Orca saved it, update Orca to open it; otherwise start a new chat." + }) + }) + + it('names the latch for a bare code an older path still throws', () => { + expect( + classifyStoreFailure(new Error('agent_session_conflict'), null, { + ...record, + lease: { ...record.lease, claimStatus: 'conflicted' } + }).details + ).toEqual({ reason: 'claimConflicted' }) + expect( + classifyStoreFailure(new Error('agent_session_conflict'), null, null) + ).not.toHaveProperty('details') + }) + + it('puts the current fence in the details of a stale checkpoint, and mirrors it', () => { + expect( + classifyStoreFailure( + agentSessionRefusalError('agent_session_checkpoint_stale', { reason: 'fenceStale' }), + 7, + null + ) + ).toEqual({ + code: 'agent_session_checkpoint_stale', + details: { reason: 'fenceStale', currentFence: 7 }, + currentFence: 7, + message: 'The session store refused this call: agent_session_checkpoint_stale.' + }) + // A bare code names no reason, but the fence is still a fact. + expect( + classifyStoreFailure(new Error('agent_session_checkpoint_stale'), 7, null) + ).toMatchObject({ details: { currentFence: 7 }, currentFence: 7 }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts index 0184366e344..7a7a9e289ef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts @@ -7,8 +7,12 @@ * the action that supplies it. */ +import { terminalOwnerRefusalMessage } from '../../../shared/agent-session-legacy-handoff-lease' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionWireRefusalCode } from '../../../shared/agent-session-wire' +import type { + AgentSessionAnyRefusalReason, + AgentSessionRefusalReference +} from '../../../shared/agent-session-wire' function ownerDescription(record: AgentSessionRecord): string { const owner = record.lease.ownerProcess @@ -16,33 +20,71 @@ function ownerDescription(record: AgentSessionRecord): string { } function latchedMessage(record: AgentSessionRecord): string { - const owner = record.lease.ownerProcess - if (record.lease.settlementRetryRequired) { - return 'The provider exited, but Orca has not finished settling the terminal chat state. Reopen this chat to retry the settlement.' - } if (record.lease.claimStatus === 'conflicted') { - return owner - ? `Two runtimes claimed this session and Orca cannot yet prove that ${ownerDescription(record)} has exited. Quit that process, or reopen this chat once it is gone, and Orca will take the session back.` - : 'Two runtimes claimed this session and the record names no process to check. Quit any other Orca or agent process using this workspace, then reopen this chat.' + return terminalOwnerRefusalMessage(record.lease) } - return owner + return record.lease.ownerProcess ? `Orca cannot prove that ${ownerDescription(record)} — the previous owner of this session — has exited, so it will not start a second agent on the same conversation. Quit that process and reopen this chat.` : 'Orca cannot tell whether an agent started for this session before the app stopped, so it will not start a second one on the same conversation. Quit any leftover agent process for this workspace and reopen this chat.' } -/** Null when the code has no session-specific story to tell; the caller keeps its own wording. */ +/** + * A situation whose own words the store's code would get wrong: each of these reaches the chat as + * `ownership_unknown` or `conflict`, which by code alone would read as the latched-owner story. + */ +const SITUATION_MESSAGES: Partial> = { + replaySuperseded: 'A newer start of this chat replaced this one. Try again.', + leaseMoved: 'This chat changed hands while Orca was starting it. Try again.', + spawnIdentityMismatch: + 'The agent that started was not the one Orca launched, so Orca did not use it. Try again.', + identityMismatch: + 'This chat belongs to a different workspace, agent or account. Start a new chat to continue.', + sessionExists: 'This chat already exists. Open it to continue.', + tabIdTaken: 'Another chat already uses this tab. Try again.', + conversationHeldElsewhere: + 'Another chat is already working in this conversation. Open that chat to continue.', + // Also a damaged record, which no update opens. + recordUnreadable: + "Orca can't read this chat's saved state. If a newer version of Orca saved it, update Orca to open it; otherwise start a new chat." +} + +/** + * The words and the situation for a store refusal. The reason is what the emitter named, when it + * named one; the latched branches name their own. Null when neither has a story to tell, and the + * caller keeps its own wording. + */ export function structuredAgentSessionRefusalMessage( - code: AgentSessionWireRefusalCode, + emitted: AgentSessionRefusalReference, record: AgentSessionRecord | null -): string | null { +): { message: string; reference: AgentSessionRefusalReference } | null { + const reason = emitted.details?.reason + const situational = reason ? SITUATION_MESSAGES[reason] : undefined + if (situational) { + return { message: situational, reference: emitted } + } if (!record) { return null } + const { code } = emitted if (code === 'agent_session_ownership_unknown' || code === 'agent_session_conflict') { - return latchedMessage(record) + const message = latchedMessage(record) + if (record.lease.claimStatus === 'conflicted') { + return { message, reference: { code, details: { reason: 'claimConflicted' } } } + } + return { + message, + reference: + code === 'agent_session_conflict' && reason === 'ownerAlive' + ? { code, details: { reason: 'ownerAlive' } } + : { code, details: { reason: 'ownerUnproven' } } + } } if (code === 'execution_owner_reconciling') { - return 'Orca is still working out who owns this session on this machine. Reopen the chat in a moment.' + return { + message: + 'Orca is still working out who owns this session on this machine. Reopen the chat in a moment.', + reference: { code, details: { reason: 'hostReconciling' } } + } } return null } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index 566156e096f..4043e27b30c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -19,6 +19,7 @@ import { import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -103,16 +104,9 @@ async function createHarness(options: { attached?: boolean } = {}) { return harness } -async function abandonHost(host: StructuredAgentSessionHost): Promise { - host['runtimeState'].stopLeaseRenewal() - host['holds'].dispose() - await Promise.all([...host['sessions'].values()].map((session) => session.journal.close())) - host['sessions'].clear() -} - afterEach(async () => { const completed = harnesses.splice(0) - await Promise.all(completed.map(async ({ host }) => abandonHost(host))) + await Promise.all(completed.map(async ({ host }) => abandonStructuredAgentSessionHost(host))) await Promise.all(completed.map(async ({ root }) => rm(root, { recursive: true }))) }) @@ -225,13 +219,20 @@ const UNREACHABLE = new Set([ // StructuredAgentSessionHost.mutate maps an absent record to AGENT_SESSION_NOT_ATTACHED. 'agentSession.setOption:agent_session_identity_required', 'agentSession.send:agent_session_identity_required', - // No structured-agent-session host branch emits agent_session_journal_unreadable. + // Only a send opens the conversation it writes to. 'agentSession.setOption:agent_session_journal_unreadable', - 'agentSession.send:agent_session_journal_unreadable', // Send reconstructs doubt from its global tombstone instead of refusing it. 'agentSession.send:agent_session_operation_unknown', // Only a send restarts a lost owner. - 'agentSession.setOption:agent_session_owner_restart_failed' + 'agentSession.setOption:agent_session_owner_restart_failed', + // A write names its target, not an owner generation; only an attach compares fences. + 'agentSession.setOption:agent_session_checkpoint_stale', + 'agentSession.send:agent_session_checkpoint_stale', + // A send is a conversation write: admitted whoever owns the lease, and a start it needs that + // fails rejects the accepted message rather than refusing the call. + 'agentSession.send:agent_session_conflict', + 'agentSession.send:execution_owner_reconciling', + 'agentSession.send:agent_session_owner_restart_failed' ]) describe('agentSessionRefusalOperationState host oracle', () => { @@ -242,25 +243,13 @@ describe('agentSessionRefusalOperationState host oracle', () => { const stale = await createHarness() for (const method of METHODS) { - const spec = { - method, - operationId: operationId(), - expectedRuntimeFence: 99 - } - record( - await assertHostAgreement(stale, spec, 'agent_session_checkpoint_stale', async () => ({ - harness: stale, - spec: { - ...spec, - expectedRuntimeFence: stale.store.getRecord(SESSION)?.lease.runtimeFence ?? 1 - } - })) - ) + const spec = { method, operationId: operationId(), expectedRuntimeFence: 99 } + await expect(invoke(stale, spec), method).resolves.toMatchObject({ ok: true }) } expect(stale.setOption).toHaveBeenCalledTimes(1) const conflict = await createHarness() - for (const method of ['agentSession.setOption', 'agentSession.send'] as const) { + for (const method of ['agentSession.setOption'] as const) { await setLease(conflict, (current) => ({ ...current, lease: { ...current.lease, handoffStage: 'new-owner-proving' } @@ -340,7 +329,7 @@ describe('agentSessionRefusalOperationState host oracle', () => { record(await assertHostAgreement(unknown, optionUnknown, 'agent_session_operation_unknown')) const reconciling = await createHarness() - for (const method of ['agentSession.setOption', 'agentSession.send'] as const) { + for (const method of ['agentSession.setOption'] as const) { await setLease(reconciling, (current) => ({ ...current, lease: { ...current.lease, unreconciled: true } @@ -357,16 +346,21 @@ describe('agentSessionRefusalOperationState host oracle', () => { ) } - const unrecoverable = await createHarness() - await unrecoverable.host.close(SESSION) - unrecoverable.host.deps.adapter.acquire = async () => { - throw new Error('no provider thread to resume') + const unreadable = await createHarness() + await unreadable.host.close(SESSION) + unreadable.host.deps.adapter.historyFilePath = async () => { + throw new Error('transcript unreadable') } + const unreadableSend = { method: 'agentSession.send' as const, operationId: operationId() } record( await assertHostAgreement( - unrecoverable, - { method: 'agentSession.send', operationId: operationId() }, - 'agent_session_owner_restart_failed' + unreadable, + unreadableSend, + 'agent_session_journal_unreadable', + async () => { + delete unreadable.host.deps.adapter.historyFilePath + return { harness: unreadable, spec: unreadableSend } + } ) ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-release-clock.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-release-clock.ts deleted file mode 100644 index 91daa5606a8..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-release-clock.ts +++ /dev/null @@ -1,85 +0,0 @@ -// The delay between "nothing holds this session and nothing has happened in it" and "stop its -// provider child". -// -// It is an IDLE window, not a short grace. A surface that reconnects — a mobile socket dropping on -// a network switch, a renderer remounting a tab, a worktree switch hiding the pane — releases and -// re-holds, and a send to a chat nobody is looking at restarts its owner; stopping the child soon -// after either costs the user a respawn plus a resume on the next message. And a turn the user -// already asked for must finish: stopping the child mid-answer strands the open turn marker. -// -// So the clock arms when the last holder leaves, every journal write while it is armed starts it -// again, and a tick that finds work still owed — a turn running, a message sent but not yet -// taken by the provider, or a subagent, command or monitor still running — re-arms instead of -// evicting. The child goes only after a full window -// with no holder and no owed work. Quit still stops every child at once. - -export const STRUCTURED_AGENT_SESSION_RELEASE_GRACE_MS = 30 * 60_000 - -export type StructuredAgentSessionReleaseClockDeps = { - /** Never evict while work is owed; a true answer re-arms the clock instead. */ - hasOwedWork: (sessionId: string) => boolean - /** Re-checked at fire time: a holder may have arrived while the timer ran. */ - isHeld: (sessionId: string) => boolean - evict: (sessionId: string) => Promise - onError?: (input: { sessionId: string; error: unknown }) => void - graceMs?: number -} - -export class StructuredAgentSessionReleaseClock { - private readonly timers = new Map>() - private readonly graceMs: number - - constructor(private readonly deps: StructuredAgentSessionReleaseClockDeps) { - this.graceMs = deps.graceMs ?? STRUCTURED_AGENT_SESSION_RELEASE_GRACE_MS - } - - arm(sessionId: string): void { - this.cancel(sessionId) - const timer = setTimeout(() => { - this.timers.delete(sessionId) - this.fire(sessionId) - }, this.graceMs) - // A pending release must never be the reason a process stays alive at quit. - timer.unref?.() - this.timers.set(sessionId, timer) - } - - /** Activity in an unheld session: the idle window starts over. */ - renew(sessionId: string): void { - if (this.timers.has(sessionId)) { - this.arm(sessionId) - } - } - - cancel(sessionId: string): void { - const timer = this.timers.get(sessionId) - if (timer) { - clearTimeout(timer) - this.timers.delete(sessionId) - } - } - - isArmed(sessionId: string): boolean { - return this.timers.has(sessionId) - } - - dispose(): void { - for (const timer of this.timers.values()) { - clearTimeout(timer) - } - this.timers.clear() - } - - private fire(sessionId: string): void { - if (this.deps.isHeld(sessionId)) { - return - } - if (this.deps.hasOwedWork(sessionId)) { - this.arm(sessionId) - return - } - void this.deps.evict(sessionId).catch((error: unknown) => { - this.deps.onError?.({ sessionId, error }) - }) - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts index c81c45dfba5..00fa33a30fb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts @@ -1,9 +1,11 @@ import { rewindRefusal } from './structured-rewind-refusal' import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' import { - AGENT_SESSION_WIRE_REFUSAL_CODES, - type AgentSessionWireRefusal, - type AgentSessionWireRefusalCode + agentSessionRefusalFromReference, + readAgentSessionRefusalReference, + refuse, + type AgentSessionRefusalReference, + type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' export type AgentSessionReplayOutcomeDecision = @@ -23,15 +25,12 @@ export function resolveAgentSessionReplayOutcome(input: { if (outcome.rewindReason) { return { decision: 'refuse', refusal: rewindRefusal(outcome.rewindReason).refusal } } - const code = (AGENT_SESSION_WIRE_REFUSAL_CODES as readonly string[]).includes(outcome.code) - ? (outcome.code as AgentSessionWireRefusalCode) - : 'agent_session_operation_invalid' return { decision: 'refuse', - refusal: { - code, - message: outcome.message ?? `Operation ${operationId} was already refused: ${outcome.code}.` - } + refusal: agentSessionRefusalFromReference( + recordedRefusal(outcome.code, outcome.details), + outcome.message ?? `Operation ${operationId} was already refused: ${outcome.code}.` + ) } } if (outcome.status === 'unknown') { @@ -44,10 +43,11 @@ export function resolveAgentSessionReplayOutcome(input: { } return { decision: 'refuse', - refusal: { - code: 'agent_session_operation_unknown', - message: `The outcome of operation ${operationId} is unknown; it was not run again.` - } + refusal: refuse( + 'agent_session_operation_unknown', + { reason: 'outcomeUnknown' }, + `The outcome of operation ${operationId} is unknown; it was not run again.` + ) } } const recorded = input.reconstruct() @@ -60,10 +60,22 @@ export function resolveAgentSessionReplayOutcome(input: { return outcome.status === 'succeeded' ? { decision: 'refuse', - refusal: { - code: 'agent_session_operation_unknown', - message: `Operation ${operationId} succeeded, but its result is no longer reconstructable.` - } + refusal: refuse( + 'agent_session_operation_unknown', + { reason: 'resultLost' }, + `Operation ${operationId} succeeded, but its result is no longer reconstructable.` + ) } : { decision: 'rerun' } } + +/** The refusal a failed row recorded, so a replay says what the first answer said. A code this + * build does not know was refused for a reason it cannot name. */ +function recordedRefusal(code: string, details: unknown): AgentSessionRefusalReference { + return ( + readAgentSessionRefusalReference({ code, details }) ?? { + code: 'agent_session_operation_invalid', + details: { reason: 'operationRefusedEarlier' } + } + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts index 005986adb44..d3c15de933c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { @@ -63,7 +64,11 @@ describe('resolved revisions', () => { const deferred = createDeferredStructuredAgentSessionEventSink() const bytes = estimateStructuredAgentSessionItemBytes(ROW, text('abc')) for (const suffix of ['a', 'b', 'c']) { - expect(deferred.sink.tryReviseResolvedItem?.(bytes, appendSuffix(suffix))).toEqual({ + expect( + deferred.sink.tryReviseResolvedItem?.(bytes, appendSuffix(suffix), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).toEqual({ accepted: true }) } @@ -77,7 +82,9 @@ describe('resolved revisions', () => { it('skips a revision that resolves to nothing', async () => { const rows = new Map() const deferred = createDeferredStructuredAgentSessionEventSink() - deferred.sink.tryReviseResolvedItem?.(1_000, () => null) + deferred.sink.tryReviseResolvedItem?.(1_000, () => null, { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const target = journalTarget(rows) deferred.bind(target) await expect(deferred.drained()).resolves.toEqual({ ok: true }) @@ -88,8 +95,12 @@ describe('resolved revisions', () => { const rows = new Map() const deferred = createDeferredStructuredAgentSessionEventSink() const bytes = estimateStructuredAgentSessionItemBytes(ROW, text('a')) - deferred.sink.tryReviseResolvedItemAndPublish?.(bytes, appendSuffix('a')) - deferred.sink.tryReviseResolvedItemAndPublish?.(bytes, () => null) + deferred.sink.tryReviseResolvedItemAndPublish?.(bytes, appendSuffix('a'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + deferred.sink.tryReviseResolvedItemAndPublish?.(bytes, () => null, { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const target = journalTarget(rows) const published: string[] = [] vi.mocked(target.publish).mockImplementation(() => @@ -105,10 +116,14 @@ describe('resolved revisions', () => { const rows = new Map() const deferred = createDeferredStructuredAgentSessionEventSink() const bytes = estimateStructuredAgentSessionItemBytes(ROW, text('a')) - deferred.sink.tryReviseResolvedItem?.(bytes, () => ({ - identity: ROW, - body: text('a'.repeat(64)) - })) + deferred.sink.tryReviseResolvedItem?.( + bytes, + () => ({ + identity: ROW, + body: text('a'.repeat(64)) + }), + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) deferred.bind(journalTarget(rows)) await expect(deferred.drained()).resolves.toMatchObject({ ok: false }) expect(rows.size).toBe(0) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.ts index de1190aefc1..eb2fbea97fb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.ts @@ -4,7 +4,7 @@ import type { } from '../../../shared/agent-session-journal-types' import { estimateStructuredAgentSessionItemBytes } from './structured-agent-session-event-sink-estimate' import type { - StructuredAgentSessionAppendOptions, + StructuredAgentSessionItemAppendOptions, StructuredAgentSessionEventSink, StructuredAgentSessionRevisionJournal } from './structured-agent-session-event-sink' @@ -30,7 +30,7 @@ export function createStructuredAgentSessionResolvedAppend( const submit = ( reservedBytes: number, resolve: (journal: StructuredAgentSessionRevisionJournal) => ResolvedItem | null, - options: StructuredAgentSessionAppendOptions, + options: Omit, publish: boolean ) => queue.submit( @@ -58,7 +58,7 @@ export function createStructuredAgentSessionResolvedAppend( options ) const identityOnly = (publish: boolean) => - ((identitySizeBound, body, resolveIdentity, options = {}) => + ((identitySizeBound, body, resolveIdentity, options) => submit( estimateStructuredAgentSessionItemBytes(identitySizeBound, body) + (publish ? 1 : 0), (journal) => { @@ -71,11 +71,11 @@ export function createStructuredAgentSessionResolvedAppend( return { tryAppendResolvedItem: identityOnly(false), tryAppendResolvedItemAndPublish: identityOnly(true), - tryReviseResolvedItem: (reservedBytes, resolve, options = {}) => + tryReviseResolvedItem: (reservedBytes, resolve, options) => submit(reservedBytes, resolve, options, false), - tryReviseResolvedItemAndPublish: (reservedBytes, resolve, options = {}) => + tryReviseResolvedItemAndPublish: (reservedBytes, resolve, options) => submit(reservedBytes + 1, resolve, options, true), - tryAppendLifecycleTransition: (identitySizeBound, body, resolveIdentity, options = {}) => { + tryAppendLifecycleTransition: (identitySizeBound, body, resolveIdentity, options) => { const bytes = estimateStructuredAgentSessionItemBytes(identitySizeBound, body) return queue.submit( { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts new file mode 100644 index 00000000000..3ea116fa361 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -0,0 +1,224 @@ +// A real host over a real store and journal, with a scripted provider and a clock the test moves, +// for the tests of a conversation that outlives its agent. The idle sweep runs on its own short +// interval; a test moves `clock.now` past the idle window and waits for the outcome. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, vi, type Mock } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { + AgentSessionMutationEnvelope, + AgentSessionStatusEvent, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { + AgentSessionDispatchOutcome, + StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep' + +export const REST_TEST_CALLER = { callerKey: 'client-1' } +export const IDLE_MS = STRUCTURED_AGENT_SESSION_IDLE_MS +export const SWEEP_INTERVAL_MS = 5 + +export type RestTestAdapter = { + acquire: Mock + closeSession: Mock> + dispatch: Mock + acknowledgeSessionRelease: Mock< + NonNullable + > + backgroundTaskState: Mock> + readOptions: Mock> +} + +export type RestTestRig = { + root: string + store: AgentSessionRecordStore + host: StructuredAgentSessionHost + adapter: RestTestAdapter + clock: { now: number } + statusEvents: AgentSessionStatusEvent[] + sink: { publish: Mock; forget: Mock } + /** Opens a fresh host over the same store and journals: what a restart leaves behind. */ + restart: (deps?: Partial) => Promise + dispose: () => Promise +} + +let ordinal = 0 +let generations = 0 + +export function acceptedDispatch(): AgentSessionDispatchOutcome { + ordinal += 1 + return { + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: `turn-${ordinal}`, ordinal } + } +} + +export function restTestSend( + text: string, + fence = 1 +): { envelope: AgentSessionMutationEnvelope; body: AgentJournalMessageItem } { + const body = hostTestMessage(text) + return { + body, + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + } + } +} + +/** Every item and submission a reader was sent, whatever frame carried it. */ +export function readerSaw(events: readonly AgentSessionSubscribeEvent[]) { + const items = events.flatMap((event) => + event.type === 'batch' ? event.batch.items : event.type === 'end' ? [] : event.page.items + ) + const submissions = events.flatMap((event) => + event.type === 'batch' ? event.batch.submissions : [] + ) + return { + texts: items.flatMap((item) => + item.body.kind === 'message' + ? item.body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])) + : [] + ), + submissions + } +} + +export function collectSubscriber(): { + events: AgentSessionSubscribeEvent[] + emit: (event: AgentSessionSubscribeEvent) => void +} { + const events: AgentSessionSubscribeEvent[] = [] + return { events, emit: (event) => events.push(event) } +} + +export async function createRestTestRig( + deps: Partial = {} +): Promise { + resetHostTestOperationIds() + ordinal = 0 + const root = await mkdtemp(join(tmpdir(), 'orca-rest-')) + const clock = { now: HOST_TEST_NOW } + const statusEvents: AgentSessionStatusEvent[] = [] + const sink = { publish: vi.fn(), forget: vi.fn() } + let store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const adapter: RestTestAdapter = { + acquire: vi.fn(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: `generation-${++generations}`, + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: store.getRecord(SESSION)?.providerHandleChain.length + ? ('resumed' as const) + : ('created' as const), + mintedAtFence: fence, + observedAt: clock.now + } + })), + closeSession: vi.fn(async () => true), + dispatch: vi.fn(async () => acceptedDispatch()), + acknowledgeSessionRelease: vi.fn(), + backgroundTaskState: vi.fn(() => undefined), + readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } })) + } + const hostFor = (overrides: Partial) => + new StructuredAgentSessionHost({ + store, + adapter: { + ...adapter, + releaseAcquisition: vi.fn(async () => true), + cancelTurn: async () => ({ cancelled: true }), + answerPrompt: async ({ commit }) => commit(), + setOption: async () => undefined + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + probeOwner: async () => ({ outcome: 'pid-absent' }), + now: () => clock.now, + statusSink: sink, + idleSweep: { intervalMs: SWEEP_INTERVAL_MS }, + ...deps, + ...overrides + }) + const rig: RestTestRig = { + root, + store, + host: hostFor({}), + adapter, + clock, + statusEvents, + sink, + restart: async (overrides = {}) => { + await rig.host.flushAllStreamedEvents().catch(() => undefined) + store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + rig.store = store + rig.host = hostFor(overrides) + rig.host.subscribeStatus({ id: 'status', emit: (event) => statusEvents.push(event) }) + return rig.host + }, + dispose: async () => { + await rig.host.flushAllStreamedEvents().catch(() => undefined) + await rm(root, { recursive: true, force: true }) + } + } + rig.host.subscribeStatus({ id: 'status', emit: (event) => statusEvents.push(event) }) + return rig +} + +/** Creates the chat, lists its tab, and sends one message so its journal is on disk. */ +export async function foundRestTestChat(rig: RestTestRig): Promise { + const attached = await rig.host.attach(REST_TEST_CALLER, hostTestAttachParams(null)) + if (!attached.ok) { + throw new Error(`attach refused: ${attached.refusal.code}`) + } + await rig.store.setSessionTabVisibility(SESSION, true) + const sent = await rig.host.send(REST_TEST_CALLER, restTestSend('hello', attached.fence)) + if (!sent.ok) { + throw new Error(`send refused: ${sent.refusal.code}`) + } + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalled()) +} + +/** Runs one sweep pass now, for a test that set `idleSweep.intervalMs` out of reach. */ +export function sweepOnce(host: StructuredAgentSessionHost): Promise { + return host.collaboratorsForTests().lifetime.idleSweep.tick() +} + +/** Waits long enough for several sweep ticks to have run. */ +export function sweepTicks(count = 6): Promise { + return new Promise((resolve) => setTimeout(resolve, SWEEP_INTERVAL_MS * count)) +} + +export { SESSION as REST_TEST_SESSION, THREAD as REST_TEST_THREAD } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts index 46879aefe49..b16f76c1633 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts @@ -2,16 +2,11 @@ // // A different question from storage: the durable record decides which markers are still present; // this decides which of those a resume may act on. The offer, the click and the pre-send check all -// ask it, and all get the same answer: the marker stands until the user sends a newer message. +// ask it, and all get the same answer. -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' -import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' -import { - latestStructuredAgentSessionPrompt, - latestStructuredAgentSessionUserItem -} from '../../../shared/structured-agent-session-projection' +import { latestStructuredAgentSessionPrompt } from '../../../shared/structured-agent-session-latest-request' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { adapterSupportsRecord } from './structured-agent-session-provider-support' @@ -23,61 +18,29 @@ import { /** The only part of a live session this reads. */ export type StructuredAgentSessionRestartJournalSource = { journal: AgentSessionJournal } -export type StructuredAgentSessionRestartCandidateOptions = { - /** A continuation already in flight; its own submission is not newer user work. */ - pendingContinuationId?: string -} - export type StructuredAgentSessionRestartCandidateReader = ( markers: readonly AgentSessionResumeMarker[], - leaseState: 'must-be-released' | 'may-be-held', - options?: StructuredAgentSessionRestartCandidateOptions + leaseState: 'must-be-released' | 'may-be-held' ) => StructuredAgentSessionResumableSet -/** The newest user message in a live session's journal, the same fact the predicate compares - * against a marker. Undefined when the session is not readable here, which decides nothing. */ -export function liveStructuredAgentSessionLatestUserItemId( - sessions: ReadonlyMap, - sessionId: string -): string | null | undefined { - const session = sessions.get(sessionId) - return session - ? (latestStructuredAgentSessionUserItem(session.journal.snapshot().items)?.itemId ?? null) - : undefined -} - export function createStructuredAgentSessionRestartCandidateReader(deps: { /** The host's live session map; a marker's chat is readable once listing has revealed it. */ sessions: ReadonlyMap getRecord: (sessionId: string) => AgentSessionRecord | null adapter: StructuredAgentSessionAdapter + /** Whether the chat moved on since the offer was taken; see the offer withdrawal. */ + movedOn: (marker: AgentSessionResumeMarker) => boolean }): StructuredAgentSessionRestartCandidateReader { - return (markers, leaseState, options = {}) => { - const items = new Map() - const itemsFor = (sessionId: string): AgentJournalRenderItem[] | undefined => { - if (items.has(sessionId)) { - return items.get(sessionId) - } - let snapshot = deps.sessions.get(sessionId)?.journal.snapshot().items - if (snapshot && options.pendingContinuationId) { - const ownItemId = agentJournalSubmissionKey(options.pendingContinuationId) - snapshot = snapshot.filter((item) => item.itemId !== ownItemId) - } - items.set(sessionId, snapshot) - return snapshot - } - return structuredAgentSessionResumableSet({ + return (markers, leaseState) => + structuredAgentSessionResumableSet({ markers, getRecord: deps.getRecord, supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record), - latestPrompt: (sessionId) => latestStructuredAgentSessionPrompt(itemsFor(sessionId) ?? []), - latestUserItemId: (sessionId) => { - const snapshot = itemsFor(sessionId) - return snapshot === undefined - ? undefined - : (latestStructuredAgentSessionUserItem(snapshot)?.itemId ?? null) - }, + movedOn: deps.movedOn, + latestPrompt: (sessionId) => + latestStructuredAgentSessionPrompt( + deps.sessions.get(sessionId)?.journal.snapshot().items ?? [] + ), leaseState }) - } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts new file mode 100644 index 00000000000..9ded5b54d98 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts @@ -0,0 +1,86 @@ +// "Resume all" starts agents a few at a time. A continuation is a send, so a chat counts as started +// once its agent took the message or its start failed — not when the message was accepted, which +// is instant and would let every agent start at once. + +import { expect, it, vi } from 'vitest' +import { + startStructuredAgentSessionContinuation, + type StructuredAgentSessionContinuationDeps +} from './structured-agent-session-restart-continuation' +import { + resumeStructuredAgentSessionsFromRestart, + STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY, + StructuredAgentSessionResumeAdmission +} from './structured-agent-session-restart-resume-runner' +import { marker } from './structured-agent-session-restart-resume-test-harness' + +type Handover = { dispatchState: string; reason?: string | null } | undefined + +it('holds each slot from accept until handover or rejection, and frees it when the wait ends (P2-27)', async () => { + const handovers = new Map>() + let waiting = 0 + let mostWaiting = 0 + const deps = (sessionId: string): StructuredAgentSessionContinuationDeps => ({ + currentFence: () => 1, + send: vi.fn(async () => ({ ok: true, value: { submission: { dispatchState: 'pending' } } })), + awaitHandedOver: async () => { + const handover = Promise.withResolvers() + handovers.set(sessionId, handover) + waiting += 1 + mostWaiting = Math.max(mostWaiting, waiting) + try { + return await handover.promise + } finally { + waiting -= 1 + } + }, + awaitSettlement: async () => ({ dispatchState: 'accepted' }), + note: async () => undefined, + onNoteFailed: () => undefined + }) + const sessions = Array.from({ length: 8 }, (_, index) => `session-${index}`) + const outcomes = resumeStructuredAgentSessionsFromRestart( + { + admission: new StructuredAgentSessionResumeAdmission(), + consumeMarker: async () => true, + resume: async (sessionId) => { + const started = await startStructuredAgentSessionContinuation( + deps(sessionId), + sessionId, + marker(), + 'operation-1' + ) + if ('done' in started && started.done.outcome === 'refused') { + throw new Error(started.done.reason ?? 'refused') + } + } + }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runner reads only each candidate's session id. + sessions.map((sessionId) => ({ sessionId }) as never), + 'modal' + ) + const settle = async (sessionId: string, handover: Handover) => { + await vi.waitFor(() => expect(handovers.has(sessionId)).toBe(true)) + handovers.get(sessionId)!.resolve(handover) + } + + await vi.waitFor(() => expect(waiting).toBe(STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY)) + // Accepted but not yet handed over: a slow start keeps its slot. + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(handovers.size).toBe(STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY) + await settle('session-0', { dispatchState: 'pending' }) + await settle('session-1', { dispatchState: 'rejected', reason: 'Codex could not start.' }) + // The session closed, or too many waited: the wait proves nothing, and the slot is freed. + await settle('session-2', undefined) + for (const sessionId of sessions.slice(3)) { + await settle(sessionId, { dispatchState: 'pending' }) + } + + const results = await outcomes + expect(mostWaiting).toBe(STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY) + expect(results.find((result) => result.sessionId === 'session-1')).toMatchObject({ + outcome: 'refused', + reason: 'Codex could not start.' + }) + expect(results.filter((result) => result.outcome === 'resumed')).toHaveLength(7) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-child-work.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-child-work.test.ts index 63c778144ee..6d8514c830e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-child-work.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-child-work.test.ts @@ -2,10 +2,7 @@ import { expect, it, vi } from 'vitest' import { interruptedRestart } from './structured-agent-session-restart-interruption-test-harness' -import { - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD -} from './structured-agent-session-host-test-data' +import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' // What the user hit: the lead had finished, its subagent had not. The offer names the subagent // from the roster snapshot taken BEFORE the child stopped — the close that follows settles the @@ -21,65 +18,22 @@ it('offers a chat whose subagent the restart stopped, named from the stop-time s ]) }) -// On resume the provider restates what it lost in its own words, rewriting the row before the -// continuation is dispatched. What the offer was admitted for still stands. -it('continues a stopped subagent after the provider restates its row', async () => { - const { host, acquire, dispatch } = await interruptedRestart('children') - await host.hold(SESSION, 'pane') - const events = acquire.mock.calls[0]?.[0].events - if (!events) { - throw new Error('missing resumed provider event sink') - } - const admit = vi.spyOn(host.deps.store, 'admitMutationOperation') - admit.mockImplementationOnce(async (input) => { - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'settled-turn', ordinal: 2 }, - { - kind: 'message', - role: 'system', - blocks: [ - { - type: 'subagent-group', - groupId: 'settled-turn', - agents: [{ id: 'child-1', label: 'Review loop 4', state: 'completed' }] - } - ] - } - ) - await host.flushStreamedEvents(SESSION) - admit.mockRestore() - return host.deps.store.admitMutationOperation(input) - }) +// The offer was admitted for the subagent's work; the continuation asks the agent to carry it on. +it('continues a chat whose subagent the restart stopped', async () => { + const { host, dispatch } = await interruptedRestart('children') expect( (await host.restartResume.continueAfterRestart([SESSION], 'modal')).continued ).toMatchObject([{ outcome: 'continued' }]) expect(dispatch).toHaveBeenCalledTimes(1) - host.release(SESSION, 'pane') }) -// The user opens the chat before choosing Resume, and the reattached provider restates its row. +// The user opens the chat before choosing Resume: reading it starts nothing, so the offer stands. it('still offers and continues a chat the user opened before resuming', async () => { const { host, acquire, dispatch } = await interruptedRestart('children') - await host.hold(SESSION, 'pane') - const events = acquire.mock.calls[0]?.[0].events - if (!events) { - throw new Error('missing resumed provider event sink') - } - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'settled-turn', ordinal: 2 }, - { - kind: 'message', - role: 'system', - blocks: [ - { - type: 'subagent-group', - groupId: 'settled-turn', - agents: [{ id: 'child-1', label: 'Review loop 4', state: 'completed' }] - } - ] - } - ) - await host.flushStreamedEvents(SESSION) + const unsubscribe = await host.subscribe({ id: 'pane', sessionId: SESSION, emit: vi.fn() }) + await host.history({ sessionId: SESSION, direction: 'tail' }) + unsubscribe() + expect(acquire).not.toHaveBeenCalled() expect(await host.restartResume.list()).toMatchObject([ { sessionId: SESSION, activity: { tasks: [{ kind: 'agent', label: 'Review loop 4' }] } } ]) @@ -87,5 +41,4 @@ it('still offers and continues a chat the user opened before resuming', async () (await host.restartResume.continueAfterRestart([SESSION], 'modal')).continued ).toMatchObject([{ outcome: 'continued' }]) expect(dispatch).toHaveBeenCalledTimes(1) - host.release(SESSION, 'pane') }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-envelope.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-envelope.ts new file mode 100644 index 00000000000..cfa2c7b502e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-envelope.ts @@ -0,0 +1,77 @@ +// The restart continuation as a message: its body, which depends on the marker alone, and its +// identity, which is one per resume action. + +import { createHash } from 'node:crypto' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { restartContinuationMessage } from '../../../shared/agent-session-restart-continuation' +import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' +import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' + +/** The message body, built once so both the send and any test read the same text. */ +export function restartContinuationBody(marker: AgentSessionResumeMarker): AgentJournalMessageItem { + return { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: restartContinuationMessage(marker) }] + } +} + +const hex16 = (parts: readonly unknown[]): string => + createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 16) + +/** Names the offer: every continuation any of its actions sends carries it, and nothing else does. */ +function restartOfferTag(marker: AgentSessionResumeMarker): string { + return hex16([marker.teardownId, marker.sessionId]) +} + +/** One resume action's continuation: the offer's tag, then the action's own part, so a retry is a + * new message, never a replay of the one that failed. Dated by the action, not the quit: the + * ledger refuses a new id dated more than a day back, and an offer has no expiry. */ +export function restartContinuationId( + marker: AgentSessionResumeMarker, + operationId: string, + actionAt: number +): string { + return `${Math.trunc(actionAt).toString().padStart(13, '0')}-${restartOfferTag(marker)}${hex16([ + marker.teardownId, + marker.sessionId, + marker.work.kind, + marker.work.id, + marker.providerHandleRoot, + operationId + ])}` +} + +/** Whether a message is a continuation one of this offer's resume actions sent, read off its id. */ +export function isRestartContinuationOf( + marker: AgentSessionResumeMarker, + clientMessageId: string +): boolean { + return /^\d{13}-[0-9a-f]{32}$/.test(clientMessageId) + ? clientMessageId.slice(14, 30) === restartOfferTag(marker) + : false +} + +/** The fence only fills the envelope: admission names this send by its operation id, not a fence. */ +export function restartContinuationEnvelope( + sessionId: string, + fence: number, + marker: AgentSessionResumeMarker, + continuationId: string +): { envelope: AgentSessionMutationEnvelope; body: AgentJournalMessageItem } { + const body = restartContinuationBody(marker) + return { + body, + envelope: { + sessionId, + clientOperationId: continuationId, + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId, + fields: { body } + }) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts new file mode 100644 index 00000000000..4dff44ff2ab --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts @@ -0,0 +1,113 @@ +// The restart continuation is accepted like any send, so its verdict is its delivery: a cold start +// longer than the legacy client wait must not turn a continuation that went through into an +// "unconfirmed" one. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { structuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-wiring' +import { StructuredAgentSessionSendSettlement } from './structured-agent-session-send-settlement' + +const SESSION = 'session-1' +const MESSAGE = 'continuation-1' + +let submission: AgentJournalSubmission +const journal = { + submissions: (): AgentJournalSubmission[] => [submission], + cursor: () => ({ epoch: 'epoch-1', sequence: 1 }), + activeTurnId: () => null +} + +beforeEach(() => { + vi.useFakeTimers() + submission = { + clientMessageId: MESSAGE, + fence: 2, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null, + handoverRecorded: true + } +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('the restart continuation waits for its delivery (W18)', () => { + it('reaches accepted after a 40 s cold start, with nothing filed as unconfirmed', async () => { + const settlement = new StructuredAgentSessionSendSettlement(() => journal) + const surfaces = structuredAgentSessionRestartResumeSurfaces( + { + revealSession: async () => ({ readable: true }), + send: async () => { + throw new Error('not used') + }, + waitForSendSettlement: settlement.wait + }, + () => 0 + ) + + const verdict = surfaces.awaitSendSettlement(SESSION, MESSAGE) + await vi.advanceTimersByTimeAsync(40_000) + submission = { ...submission, dispatchState: 'accepted', providerItemId: 'item-1' } + settlement.publish(SESSION, journal) + + await expect(verdict).resolves.toMatchObject({ + value: { submission: { dispatchState: 'accepted' } } + }) + }) +}) + +describe('a restart batch holds a chat until its continuation is handed over', () => { + function surfacesOver(settlement: StructuredAgentSessionSendSettlement) { + return structuredAgentSessionRestartResumeSurfaces( + { + revealSession: async () => ({ readable: true }), + send: async () => { + throw new Error('not used') + }, + waitForSendSettlement: settlement.wait + }, + () => 0 + ) + } + + it('keeps waiting through a slow start and resolves once the agent takes the message', async () => { + const settlement = new StructuredAgentSessionSendSettlement(() => journal) + let released = false + const handedOver = surfacesOver(settlement) + .awaitSendHandedOver(SESSION, MESSAGE) + .then((result) => { + released = true + return result + }) + await vi.advanceTimersByTimeAsync(5 * 60_000) + expect(released).toBe(false) + // Handed over: still pending at the provider, no longer queued. + submission = { ...submission, handedOverAt: 2 } + settlement.publish(SESSION, journal) + await expect(handedOver).resolves.toMatchObject({ + value: { submission: { dispatchState: 'pending', handedOverAt: 2 } } + }) + }) + + it('resolves once the start fails and the message is rejected', async () => { + const settlement = new StructuredAgentSessionSendSettlement(() => journal) + const handedOver = surfacesOver(settlement).awaitSendHandedOver(SESSION, MESSAGE) + submission = { ...submission, dispatchState: 'rejected', reason: 'Codex could not start.' } + settlement.publish(SESSION, journal) + await expect(handedOver).resolves.toMatchObject({ + value: { submission: { dispatchState: 'rejected' } } + }) + }) + + it('resolves undefined when the session closes first', async () => { + const settlement = new StructuredAgentSessionSendSettlement(() => journal) + const handedOver = surfacesOver(settlement).awaitSendHandedOver(SESSION, MESSAGE) + settlement.closeSession(SESSION) + await expect(handedOver).resolves.toBeUndefined() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts index 2d558f8f028..0fe36994cd7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts @@ -6,13 +6,22 @@ import { AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE } from '../../../shared/agent-session-restart-continuation' import { - continueStructuredAgentSessionAfterRestart, RestartContinuationSupersededError, + startStructuredAgentSessionContinuation, type StructuredAgentSessionContinuationDeps } from './structured-agent-session-restart-continuation' +/** The whole continuation: handed over, then its verdict. */ +async function continueStructuredAgentSessionAfterRestart( + ...args: Parameters +) { + const started = await startStructuredAgentSessionContinuation(...args) + return 'done' in started ? started.done : started.verdict() +} + function dependencies( - settledDispatch: 'accepted' | 'pending' | 'unknown' | 'rejected' + settledDispatch: 'accepted' | 'pending' | 'unknown' | 'rejected', + handedOver: 'pending' | 'rejected' | undefined = 'pending' ): StructuredAgentSessionContinuationDeps & { note: ReturnType send: ReturnType @@ -27,6 +36,14 @@ function dependencies( dispatchState: settledDispatch, reason: settledDispatch === 'rejected' ? 'provider_refused' : null })), + awaitHandedOver: vi.fn(async () => + handedOver + ? { + dispatchState: handedOver, + reason: handedOver === 'rejected' ? 'Codex could not start.' : null + } + : undefined + ), note: vi.fn(async () => undefined), onNoteFailed: vi.fn() } @@ -36,7 +53,7 @@ it('reports an accepted continuation and records its note', async () => { const deps = dependencies('accepted') await expect( - continueStructuredAgentSessionAfterRestart(deps, SESSION, marker()) + continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1') ).resolves.toEqual({ sessionId: SESSION, outcome: 'continued' @@ -58,20 +75,20 @@ it.each([ const deps = dependencies(settled) await expect( - continueStructuredAgentSessionAfterRestart(deps, SESSION, marker()) + continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1') ).resolves.toEqual(expected) expect(deps.note).toHaveBeenCalledExactlyOnceWith(...note) } ) -it('notes a superseded continuation in the chat and still reports the refusal', async () => { +it("writes nothing in the chat when the user's own message came first, and still refuses", async () => { const deps = dependencies('accepted') deps.send.mockRejectedValue(new RestartContinuationSupersededError()) await expect( - continueStructuredAgentSessionAfterRestart(deps, SESSION, marker()) + continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1') ).rejects.toBeInstanceOf(RestartContinuationSupersededError) - expect(deps.note).toHaveBeenCalledExactlyOnceWith(...REFUSED) + expect(deps.note).not.toHaveBeenCalled() }) // An ownership refusal would meet the user's own message too, so the note gives no advice to send one. @@ -83,7 +100,7 @@ it.each([ deps.send.mockResolvedValue({ ok: false, refusal: { code } }) await expect( - continueStructuredAgentSessionAfterRestart(deps, SESSION, marker()) + continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1') ).resolves.toEqual({ sessionId: SESSION, outcome: 'refused', @@ -98,7 +115,7 @@ it('reports an unattached chat without sending', async () => { deps.currentFence = () => null await expect( - continueStructuredAgentSessionAfterRestart(deps, SESSION, marker()) + continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1') ).resolves.toEqual({ sessionId: SESSION, outcome: 'refused', @@ -106,3 +123,45 @@ it('reports an unattached chat without sending', async () => { }) expect(deps.send).not.toHaveBeenCalled() }) + +// A start that failed rejects the continuation before any provider saw it: that is the verdict. +it('reports a continuation rejected at handover without waiting for the provider', async () => { + const deps = dependencies('accepted', 'rejected') + + await expect( + continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1') + ).resolves.toEqual({ sessionId: SESSION, outcome: 'refused', reason: 'Codex could not start.' }) + expect(deps.awaitSettlement).not.toHaveBeenCalled() + expect(deps.note).toHaveBeenCalledExactlyOnceWith(...REFUSED) +}) + +// The start completes once the agent took the message; the provider's answer is a later verdict. +it('returns at handover and leaves the provider verdict to be awaited', async () => { + const deps = dependencies('accepted') + + const started = await startStructuredAgentSessionContinuation( + deps, + SESSION, + marker(), + 'operation-1' + ) + + expect(deps.awaitHandedOver).toHaveBeenCalledOnce() + expect(deps.awaitSettlement).not.toHaveBeenCalled() + if (!('verdict' in started)) { + throw new Error('expected a handed-over continuation') + } + await expect(started.verdict()).resolves.toEqual({ sessionId: SESSION, outcome: 'continued' }) + expect(deps.note).toHaveBeenCalledOnce() +}) + +// A handover wait that ends without an answer — the session closed, or too many waited — proves +// nothing, so the provider's verdict still decides. +it('still awaits the verdict when the handover wait ends unanswered', async () => { + const deps = dependencies('unknown', undefined) + + await expect( + continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1') + ).resolves.toEqual({ sessionId: SESSION, outcome: 'unknown' }) + expect(deps.note).toHaveBeenCalledExactlyOnceWith(...UNCONFIRMED) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts index 58d104a651c..8038e90e1fc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts @@ -1,27 +1,35 @@ // Asking an interrupted agent to carry on, on the user's opt-in. // -// Reattaching and continuing are SEPARATE operations: `resume` reattaches and sends nothing; this -// adds one message on top of it. Both the restart prompt and an opted-in launch come here, so a -// SETTING can reach this send — acceptable because the work is the user's own, the message asks the -// agent to verify its last action before repeating it, and the launch toast reports what happened. +// The continuation is a send like any other: accepted into the conversation, and delivered by the +// session's delivery loop, which starts the agent. Both the restart prompt and an opted-in launch +// come here, so a SETTING can reach this send — acceptable because the work is the user's own, the +// message asks the agent to verify its last action before repeating it, and the launch toast +// reports what happened. -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import type { - AgentSessionMutationEnvelope, - AgentSessionMutationResult, - AgentSessionSendResult +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalMessageItem +} from '../../../shared/agent-session-journal-types' +import { + readAgentSessionFailureFact, + type UnreadAgentSessionFailureFact +} from '../../../shared/agent-session-failure' +import type { AgentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' +import { + agentSessionSendSubmission, + type AgentSessionMutationEnvelope, + type AgentSessionMutationResult, + type AgentSessionSendResult } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { AGENT_SESSION_RESTART_CONTINUATION_NOTE, AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, - AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE, - restartContinuationMessage + AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE } from '../../../shared/agent-session-restart-continuation' import { AgentSessionPreDispatchError } from './structured-agent-session-operation-settlement' -import { createHash } from 'node:crypto' +import { restartContinuationEnvelope } from './structured-agent-session-restart-continuation-envelope' import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' /** @@ -40,11 +48,16 @@ export type StructuredAgentSessionContinuationOutcome = { sessionId: string outcome: 'continued' | 'pending' | 'unknown' | 'refused' reason?: string + /** The refusal that kept the agent from starting; `reason` is then its code. */ + refusal?: AgentSessionRefusalReference } /** The slice of the host one continuation needs. Structural so this module never imports the host. */ export type StructuredAgentSessionContinuationHost = { - sessions: ReadonlyMap + sessions: ReadonlyMap + /** The fence a conversation write carries; null when this host has no record of the session. + * The send opens the conversation itself, so a closed one still answers. */ + conversationFence: (sessionId: string) => number | null send: (input: { envelope: AgentSessionMutationEnvelope body: AgentJournalMessageItem @@ -54,16 +67,15 @@ export type StructuredAgentSessionContinuationHost = { sessionId: string, clientMessageId: string ) => Promise<{ value: AgentSessionSendResult } | undefined> + awaitSendHandedOver: ( + sessionId: string, + clientMessageId: string + ) => Promise<{ value: AgentSessionSendResult } | undefined> onNoteFailed: (sessionId: string, error: unknown) => void - publish: (sessionId: string, journal: AgentSessionJournal) => void now: () => number - /** Whether the marker is still an offer, with the continuation's own submission set aside. - * Re-asked right before dispatch, so a newer user message refuses the send; a provider turn - * running then does not, since both providers queue a message sent mid-turn. */ - stillResumable: ( - marker: AgentSessionResumeMarker, - options: { pendingContinuationId: string } - ) => boolean + /** Whether the marker is still an offer. Asked at acceptance, inside the session lock, so the + * first message accepted since the restart decides: the user's, or this continuation. */ + stillResumable: (marker: AgentSessionResumeMarker) => boolean } /** Binds one continuation to the host: the superseded check before dispatch, the settlement @@ -73,54 +85,45 @@ export function restartContinuationDeps( marker: AgentSessionResumeMarker ): StructuredAgentSessionContinuationDeps { return { - currentFence: (sessionId) => host.sessions.get(sessionId)?.fence ?? null, + currentFence: host.conversationFence, send: (input) => host.send({ ...input, beforeRun: () => { - if ( - !host.stillResumable(marker, { - pendingContinuationId: input.envelope.clientOperationId - }) - ) { + if (!host.stillResumable(marker)) { throw new RestartContinuationSupersededError() } } }), awaitSettlement: async (sessionId, clientMessageId) => - (await host.awaitSendSettlement(sessionId, clientMessageId))?.value.submission, + agentSessionSendSubmission( + (await host.awaitSendSettlement(sessionId, clientMessageId))?.value + ), + awaitHandedOver: async (sessionId, clientMessageId) => + agentSessionSendSubmission( + (await host.awaitSendHandedOver(sessionId, clientMessageId))?.value + ), onNoteFailed: host.onNoteFailed, note: restartNoteWriter(host) } } -/** The note for a reattach that failed before any continuation was attempted. */ -export function noteRestartReattachFailed( - host: StructuredAgentSessionContinuationHost, - sessionId: string -): Promise { - return noteNotContinued( - { note: restartNoteWriter(host), onNoteFailed: host.onNoteFailed }, - sessionId, - 'not-connected' - ) -} - -/** Writes a host-authored status note into the chat and publishes it to open panes. */ +/** Writes a host-authored status note into the chat. */ function restartNoteWriter( - host: Pick + host: Pick ): StructuredAgentSessionContinuationDeps['note'] { return async (sessionId, text, tone) => { const session = host.sessions.get(sessionId) - if (!session) { + const fence = host.conversationFence(sessionId) + if (!session || fence === null) { return } await session.journal.appendItem( { provider: 'orca', clientMessageId: `restart-continuation:${sessionId}:${host.now()}` }, { kind: 'status', text, ...(tone ? { tone } : {}) }, - { fence: session.fence } + // About the conversation, not any turn in it. + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) - host.publish(sessionId, session.journal) } } @@ -131,60 +134,25 @@ const OWNERSHIP_REFUSALS = new Set([ 'execution_owner_reconciling' ]) +/** The user's own message was accepted first; the offer is spent, and nothing failed. */ +export const RESTART_CONTINUATION_SUPERSEDED = 'agent_session_restart_work_superseded' + /** Only this pre-dispatch failure proves a thrown send did not deliver. */ export class RestartContinuationSupersededError extends AgentSessionPreDispatchError { constructor() { - super('agent_session_restart_work_superseded') + super(RESTART_CONTINUATION_SUPERSEDED) this.name = 'RestartContinuationSupersededError' } } -/** The message body, built once so both the send and any test read the same text. */ -export function restartContinuationBody(marker: AgentSessionResumeMarker): AgentJournalMessageItem { - return { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: restartContinuationMessage(marker) }] - } -} - -/** The fence is read AFTER the reconnect: reattaching mints a new one, and the pre-reconnect value - * would be refused by the mutation admission. */ -export function restartContinuationEnvelope( - sessionId: string, - fence: number, - marker: AgentSessionResumeMarker -): { envelope: AgentSessionMutationEnvelope; body: AgentJournalMessageItem } { - const body = restartContinuationBody(marker) - return { - body, - envelope: { - sessionId, - // The same interrupted work must reach the durable ledger with the same message identity. - clientOperationId: `${marker.recordedAt.toString().padStart(13, '0')}-${createHash('sha256') - .update( - JSON.stringify([ - marker.teardownId, - sessionId, - marker.work.kind, - marker.work.id, - marker.providerHandleRoot - ]) - ) - .digest('hex') - .slice(0, 32)}`, - expectedRuntimeFence: fence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId, - fields: { body } - }) - } - } +type ContinuationSubmission = { + dispatchState?: string + reason?: string | null + rejection?: UnreadAgentSessionFailureFact } export type StructuredAgentSessionContinuationDeps = { - /** Runtime fence as it stands now; null when the session is not attached. */ + /** Runtime fence as it stands now; null when this host has no record of the session. */ currentFence: (sessionId: string) => number | null send: (input: { envelope: AgentSessionMutationEnvelope @@ -192,8 +160,10 @@ export type StructuredAgentSessionContinuationDeps = { }) => Promise<{ ok: boolean refusal?: { code: string } - /** The submission is where the provider's answer lives; the envelope only says Orca took it. */ - value?: { submission?: { dispatchState?: string; reason?: string | null } } + /** The submission is where the provider's answer lives; the envelope only says Orca took it. + * A continuation never sends `delivery`, so a queued answer cannot arrive; the key exists so + * the host's union return stays assignable. */ + value?: { submission?: { dispatchState?: string; reason?: string | null }; queued?: unknown } }> /** * Waits for that send's dispatch to stop being `pending`, through the host's existing settlement @@ -205,7 +175,13 @@ export type StructuredAgentSessionContinuationDeps = { awaitSettlement: ( sessionId: string, clientMessageId: string - ) => Promise<{ dispatchState?: string; reason?: string | null } | undefined> + ) => Promise + /** Waits until the send is handed to a started agent or rejected. Undefined when the wait ended + * first — the session closed, or too many waited — which proves neither. */ + awaitHandedOver: ( + sessionId: string, + clientMessageId: string + ) => Promise /** Records a host-authored journal note: that this send was Orca's, not the user's, or that the * chat did not carry on. `tone` is a display hint older clients render as plain text. */ note: (sessionId: string, text: string, tone?: 'error' | 'warning') => Promise @@ -214,26 +190,61 @@ export type StructuredAgentSessionContinuationDeps = { onNoteFailed: (sessionId: string, error: unknown) => void } +/** A continuation handed to its agent, or already decided. */ +export type StartedStructuredAgentSessionContinuation = + | { done: StructuredAgentSessionContinuationOutcome } + | { verdict: () => Promise } + /** - * Sends the continuation to ONE already-reconnected session. - * - * The caller must have reconnected it first: this deliberately does not reconnect, so that every - * eligibility check, the admission gate and the consume-once ordering stay in the resume path and - * are not re-implemented here. + * Sends the continuation to ONE session and returns once the agent has taken it or its start + * failed — the point a restart batch counts a start as done. What the provider then answered is + * the `verdict`, awaited separately so a slow answer does not hold the batch. */ -export async function continueStructuredAgentSessionAfterRestart( +export async function startStructuredAgentSessionContinuation( deps: StructuredAgentSessionContinuationDeps, sessionId: string, - marker: AgentSessionResumeMarker -): Promise { - let result: StructuredAgentSessionContinuationOutcome + marker: AgentSessionResumeMarker, + /** This action's continuation, as its offer recorded it. */ + continuationId: string +): Promise { + let started: StartedStructuredAgentSessionContinuation try { - result = await sendContinuation(deps, sessionId, marker) + started = await sendContinuation(deps, sessionId, marker, continuationId) } catch (error) { - await noteNotContinued(deps, sessionId, 'refused') + // The user's own message came first: nothing failed, so the chat says nothing. + if (!(error instanceof RestartContinuationSupersededError)) { + await noteNotContinued(deps, sessionId, 'refused') + } throw error } - if (result.outcome !== 'continued') { + if ('done' in started) { + await noteOutcome(deps, sessionId, started.done) + return started + } + const { verdict } = started + return { + verdict: async () => { + const outcome = await verdict() + await noteOutcome(deps, sessionId, outcome) + return outcome + } + } +} + +async function noteOutcome( + deps: Pick, + sessionId: string, + result: StructuredAgentSessionContinuationOutcome +): Promise { + if (result.outcome === 'continued') { + // Only an accepted dispatch gets the note: it is a durable claim that Orca asked this agent to + // carry on. Best effort — losing it must not turn a delivered continuation into a failure. + try { + await deps.note(sessionId, AGENT_SESSION_RESTART_CONTINUATION_NOTE) + } catch (error) { + deps.onNoteFailed(sessionId, error) + } + } else { await noteNotContinued( deps, sessionId, @@ -244,7 +255,6 @@ export async function continueStructuredAgentSessionAfterRestart( : 'refused' ) } - return result } /** The chat itself carries the failure, so it survives the toast, a dismissed record and a restart, @@ -272,13 +282,14 @@ async function noteNotContinued( async function sendContinuation( deps: StructuredAgentSessionContinuationDeps, sessionId: string, - marker: AgentSessionResumeMarker -): Promise { + marker: AgentSessionResumeMarker, + continuationId: string +): Promise { const fence = deps.currentFence(sessionId) if (fence === null) { - return { sessionId, outcome: 'refused', reason: 'agent_session_not_attached' } + return { done: { sessionId, outcome: 'refused', reason: 'agent_session_not_attached' } } } - const { envelope, body } = restartContinuationEnvelope(sessionId, fence, marker) + const { envelope, body } = restartContinuationEnvelope(sessionId, fence, marker, continuationId) const sent = await deps.send({ envelope, body }).catch((error: unknown) => { if (error instanceof AgentSessionPreDispatchError) { throw error @@ -288,46 +299,64 @@ async function sendContinuation( return null }) if (!sent) { - return { sessionId, outcome: 'unknown' } + return { done: { sessionId, outcome: 'unknown' } } } if (!sent.ok) { return { - sessionId, - outcome: 'refused', - reason: sent.refusal?.code ?? 'agent_session_send_failed' + done: { + sessionId, + outcome: 'refused', + reason: sent.refusal?.code ?? 'agent_session_send_failed' + } } } - // The send result carries the dispatch as it stood when Orca took the message, which for a normal - // successful send is `pending`. Judging it here would report every delivered continuation as - // pending and never write the note, so the settled value is what decides. - const submission = - (await deps.awaitSettlement(sessionId, envelope.clientOperationId).catch(() => undefined)) ?? - sent.value?.submission + const clientMessageId = envelope.clientOperationId + const handedOver = await deps.awaitHandedOver(sessionId, clientMessageId).catch(() => undefined) + if (handedOver?.dispatchState === 'rejected') { + return { done: refusedBy(sessionId, handedOver) } + } + return { + verdict: async () => + verdictOf( + sessionId, + // The send result carries the dispatch as it stood when Orca took the message, which for + // a normal successful send is `pending`, so the settled value is what decides. + (await deps.awaitSettlement(sessionId, clientMessageId).catch(() => undefined)) ?? + handedOver ?? + sent.value?.submission + ) + } +} + +function refusedBy( + sessionId: string, + submission: ContinuationSubmission +): StructuredAgentSessionContinuationOutcome { + // A start the agent was refused files that refusal's code, which the failure guidance keys on. + const refusal = readAgentSessionFailureFact(submission.rejection)?.refusal + return { + sessionId, + outcome: 'refused', + reason: refusal?.code ?? submission.reason ?? 'agent_session_dispatch_rejected', + ...(refusal ? { refusal } : {}) + } +} + +function verdictOf( + sessionId: string, + submission: ContinuationSubmission | undefined +): StructuredAgentSessionContinuationOutcome { const dispatch = submission?.dispatchState - if (dispatch === 'rejected') { - return { - sessionId, - outcome: 'refused', - reason: submission?.reason ?? 'agent_session_dispatch_rejected' - } + if (submission && dispatch === 'rejected') { + return refusedBy(sessionId, submission) } if (dispatch === 'pending') { // Still pending after settlement gave up: handed off, never confirmed. return { sessionId, outcome: 'pending' } } - if (dispatch !== 'accepted') { - // `unknown`, or a peer that reported no state at all. Delivery is unverifiable, so this claims - // neither success nor failure — and writes no note saying the agent was asked to continue. - return { sessionId, outcome: 'unknown' } - } - // Only an accepted dispatch gets the note: it is a durable claim that Orca asked this agent to - // carry on, and it must not sit beside a message the provider refused or never confirmed. Best - // effort beyond that — losing the note must not turn a delivered continuation into a failure — - // but reported, never swallowed. - try { - await deps.note(sessionId, AGENT_SESSION_RESTART_CONTINUATION_NOTE) - } catch (error) { - deps.onNoteFailed(sessionId, error) - } - return { sessionId, outcome: 'continued' } + // `unknown`, or a peer that reported no state at all: delivery is unverifiable, so this claims + // neither success nor failure — and writes no note saying the agent was asked to continue. + return dispatch === 'accepted' + ? { sessionId, outcome: 'continued' } + : { sessionId, outcome: 'unknown' } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts index 5dcc3ca7690..f1fe0a40b42 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts @@ -2,21 +2,18 @@ import { afterEach, expect, it, vi } from 'vitest' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, - AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, - AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE + AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE } from '../../../shared/agent-session-restart-continuation' -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' -import { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { latestStructuredAgentSessionUserItem } from '../../../shared/structured-agent-session-projection' import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner' +import { STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER } from './structured-agent-session-restart-resume-wiring' import { interruptedRestart, statusNotes } from './structured-agent-session-restart-interruption-test-harness' +import { CALLER, envelope } from './structured-agent-session-host-test-harness' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, hostTestMessage } from './structured-agent-session-host-test-data' @@ -24,206 +21,71 @@ import { afterEach(() => vi.restoreAllMocks()) -function providerEvents(acquire: Awaited>['acquire']) { - const events = acquire.mock.calls[0]?.[0].events - if (!events) { - throw new Error('missing resumed provider event sink') - } - return events -} - -// The reported case: after the reattach the provider replays a queued message, which the chat -// journals as a newer user turn, so the continuation is refused just before dispatch. -it('files a continuation superseded by a replayed message, lists it and says so in the chat', async () => { - const { host, acquire, dispatch, root } = await interruptedRestart() - await host.restartResume.list() - await host.hold(SESSION, 'pane') - const events = providerEvents(acquire) - const append = AgentSessionJournal.prototype.appendSubmission - vi.spyOn(AgentSessionJournal.prototype, 'appendSubmission').mockImplementationOnce( - async function (this: AgentSessionJournal, input) { - const cursor = await append.call(this, input) - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'replayed-turn', ordinal: 1 }, - hostTestMessage('A queued notification the provider replayed') - ) - return cursor - } - ) - - const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') - - expect(dispatch).not.toHaveBeenCalled() - expect(result.resumed).toMatchObject([ - { outcome: 'refused', reason: 'agent_session_restart_work_superseded' } - ]) - const failure = { - sessionId: SESSION, - outcome: 'refused', - reason: 'agent_session_restart_work_superseded' - } - expect(result.failed).toMatchObject([failure]) - expect(await host.restartResume.listFailures()).toMatchObject([failure]) - expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toHaveLength(1) - expect(statusNotes(host)).toContainEqual({ - text: AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, - tone: 'error' - }) - host.release(SESSION, 'pane') -}) - -// Nothing was attempted and nothing is owed: the user moved on between listing and acting. +// Nothing was owed once the user's own message came first: the offer is spent and nothing is filed. it('files nothing for a chat the user moved on in before its attempt, and spends the offer', async () => { - const { host, acquire, root } = await interruptedRestart() + const { host, root, dispatch } = await interruptedRestart() await host.restartResume.list() - await host.hold(SESSION, 'pane') - const events = providerEvents(acquire) const admit = StructuredAgentSessionResumeAdmission.prototype.run vi.spyOn(StructuredAgentSessionResumeAdmission.prototype, 'run').mockImplementationOnce( async function (this, ...args) { - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'newer-turn', ordinal: 1 }, - hostTestMessage('A newer task from another client') - ) - await host.flushStreamedEvents(SESSION) + const body = hostTestMessage('A newer task from another client') + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) return admit.apply(this, args) } ) const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') - expect(result.resumed).toMatchObject([{ reason: 'agent_session_resume_not_eligible' }]) + expect(result.continued.filter((entry) => entry.outcome === 'continued')).toEqual([]) expect(result.failed).toEqual([]) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) const capsule = new AgentSessionRecoveryCapsule(root) expect(await capsule.listFailed(NOW)).toEqual([]) expect(await capsule.list(NOW)).toEqual([]) - expect(statusNotes(host)).toEqual([]) - host.release(SESSION, 'pane') + expect(await statusNotes(host)).toEqual([]) }) -it.each(['resume', 'continueAfterRestart'] as const)( - 'says so in the chat when the reattach itself fails (%s)', - async (action) => { - const { host, acquire } = await interruptedRestart() - await host.restartResume.list() - acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) +// The continuation is accepted and its agent then fails to start: the message is rejected with the +// cause, the failure is filed, and the chat says the agent did not carry on. +it('says so in the chat when the agent cannot start for the continuation', async () => { + const { host, acquire } = await interruptedRestart() + await host.restartResume.list() + acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) - await host.restartResume[action]([SESSION], 'modal') + await host.restartResume.continueAfterRestart([SESSION], 'modal') - expect(await host.restartResume.listFailures()).toMatchObject([ - { sessionId: SESSION, outcome: 'refused' } - ]) - // The fix depends on why it failed, which the dialog explains; "send a message" would not work. - expect(statusNotes(host)).toEqual([ - { text: AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE, tone: 'error' } - ]) - } -) + expect(await host.restartResume.listFailures()).toMatchObject([ + { sessionId: SESSION, outcome: 'refused', retryable: true } + ]) + expect(await statusNotes(host)).toContainEqual({ + text: AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, + tone: 'error' + }) +}) -/** A continuation the provider accepted whose settlement could not be written: filed unconfirmed. */ -async function unconfirmedContinuation() { - const state = await interruptedRestart() - const { host, store } = state +// A send that throws after Orca may have taken it cannot be proven undelivered: filed unconfirmed, +// and it stays on record while the agent that may be carrying on keeps running. +it('keeps an unconfirmed failure while the agent the continuation started keeps running', async () => { + const { host, store } = await interruptedRestart() vi.spyOn(console, 'warn').mockImplementation(() => {}) await host.restartResume.list() - await host.hold(SESSION, 'pane') const settle = store.recordOperationOutcome.bind(store) vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { - if (input.outcome.status === 'succeeded') { + if ( + input.callerKey === STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER && + input.outcome.status === 'succeeded' + ) { throw new Error('operation outcome could not be persisted') } return settle(input) }) + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'unconfirmed' }]) - expect(statusNotes(host)).toContainEqual({ + expect(await statusNotes(host)).toContainEqual({ text: AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, tone: 'warning' }) - const continuation = host.journalSnapshot(SESSION).submissions.at(-1) - const providerItemId = continuation?.providerItemId - if (!continuation || !providerItemId) { - throw new Error('missing accepted continuation') - } - return { ...state, continuation, providerItemId, events: providerEvents(state.acquire) } -} - -// The warning asked the user to check the agent's reply; a turn opened by the continuation's own -// message is that reply starting, however the provider names the message. -it.each(['submission key', 'provider key'] as const)( - 'retires an unconfirmed failure once the continuation opens a turn (%s)', - async (naming) => { - const { host, root, continuation, providerItemId, events } = await unconfirmedContinuation() - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'continued-turn', ordinal: 1 }, - { - kind: 'turn', - turnId: 'continued-turn', - state: 'running', - userItemId: - naming === 'submission key' - ? agentJournalSubmissionKey(continuation.clientMessageId) - : providerItemId - }, - { lifecycle: true } - ) - await host.flushStreamedEvents(SESSION) - - expect(await host.restartResume.listFailures()).toEqual([]) - await vi.waitFor(async () => { - expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toEqual([]) - }) - host.release(SESSION, 'pane') - } -) - -it('keeps an unconfirmed failure while the newest turn is not the continuation’s', async () => { - const { host, events } = await unconfirmedContinuation() - // Provider output opened this turn, keyed by its own row rather than by any user message. - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'provider-turn', ordinal: 1 }, - { kind: 'turn', turnId: 'provider-turn', state: 'running', userItemId: 'provider-turn-row' }, - { lifecycle: true } - ) - await host.flushStreamedEvents(SESSION) - expect(await host.restartResume.listFailures()).toMatchObject([{ outcome: 'unconfirmed' }]) - host.release(SESSION, 'pane') -}) - -// Nothing journaled the continuation, so the newest user message is still the interrupted one and -// the turn it opened is the interrupted work reporting in, not the agent carrying on. -it('keeps an unconfirmed failure whose continuation was never journaled while the interrupted turn runs', async () => { - const { host, acquire, store, marker } = await interruptedRestart('submission', false) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - await host.restartResume.list() - await host.hold(SESSION, 'pane') - const events = providerEvents(acquire) - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'original-turn', ordinal: 1 }, - hostTestMessage('Perform the original task') - ) - await host.flushStreamedEvents(SESSION) - // A send that throws before recording anything cannot be proven undelivered. - vi.spyOn(store, 'admitMutationOperation').mockRejectedValueOnce(new Error('store unavailable')) - const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') - expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'unconfirmed' }]) - const submissions = host.journalSnapshot(SESSION).submissions - const original = submissions[0]?.providerItemId - if (submissions.length !== 1 || !original) { - throw new Error('expected only the original submission, accepted by the provider') - } - expect(latestStructuredAgentSessionUserItem(host.journalSnapshot(SESSION).items)?.itemId).toBe( - marker?.latestUserItemId - ) - - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'original-turn', ordinal: 2 }, - { kind: 'turn', turnId: 'original-turn', state: 'running', userItemId: original }, - { lifecycle: true } - ) - await host.flushStreamedEvents(SESSION) - - expect(await host.restartResume.listFailures()).toMatchObject([{ outcome: 'unconfirmed' }]) - host.release(SESSION, 'pane') }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts index 3b8fad40de0..ee769b9e8c3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts @@ -4,9 +4,8 @@ // the offer, so without this record nothing durable would point at the chat the user has to // continue by hand. The capsule holds the record; this decides what goes in and when it leaves. // -// Whether a record is still current is derived, never cached: it is current only while the chat's -// newest user message is the one it had when the failure was filed. The user's own send, from any -// client, therefore retires it without a hook on the send path. +// A record ends when the chat's agent is started again outside a resume action — the host retires +// it at that start, with the offer — or by a successful retry, or a dismissal. import type { AgentSessionRecoveryCapsule, @@ -18,19 +17,10 @@ import type { AgentSessionResumeFailureOutcome, AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' -import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types' -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' -import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer' -import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { latestStructuredAgentSessionUserItem } from '../../../shared/structured-agent-session-projection' import { normalizeOptionalField } from '../../../shared/agent-status-field-normalization' import { AGENT_MODEL_MAX_LENGTH } from '../../../shared/agent-status-types' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { adapterSupportsRecord } from './structured-agent-session-provider-support' -import { - liveStructuredAgentSessionLatestUserItemId, - type StructuredAgentSessionRestartJournalSource -} from './structured-agent-session-restart-candidates' import type { StructuredAgentSessionContinuationOutcome } from './structured-agent-session-restart-continuation' import type { StructuredAgentSessionResumeCandidate, @@ -40,37 +30,18 @@ import { STRUCTURED_AGENT_SESSION_RESUME_NOT_ELIGIBLE, type StructuredAgentSessionResumeOutcome } from './structured-agent-session-restart-resume-runner' +import { RESTART_CONTINUATION_SUPERSEDED } from './structured-agent-session-restart-continuation' type FailureCapsule = Pick< AgentSessionRecoveryCapsule, - | 'listFailed' - | 'completeResume' - | 'failResume' - | 'rollbackResume' - | 'dismiss' - | 'clearAll' - | 'forgetSuperseded' + 'listFailed' | 'completeResume' | 'failResume' | 'rollbackResume' | 'dismiss' | 'clearAll' > -/** What a failure is filed against: the chat's newest user message as its own attempt ended. Kept - * per chat rather than read at settlement, because the rest of a batch can take a while and a - * message the user sends meanwhile answers the failure rather than belongs to it. */ -export type StructuredAgentSessionRestartAttempts = { - observe: (sessionId: string) => void - latestUserItemId: (sessionId: string) => string | null -} - export type StructuredAgentSessionRestartFailureLedger = { /** The stored records, current or not. */ read: () => Promise - /** The current records as rows a surface can show; sessions this host no longer holds are left - * out, and records the chat has since superseded are dropped and pruned. */ + /** The records as rows a surface can show; sessions this build cannot run are left out. */ list: () => Promise - /** One action's attempts; a chat never observed after an attempt falls back to its reserved - * marker. */ - attempts: ( - markers: ReadonlyMap - ) => StructuredAgentSessionRestartAttempts /** Settles one operation's reservations: the agent carried on, or the failure is filed. Rows the * operation still owns after that are reopened. */ settle: ( @@ -78,7 +49,8 @@ export type StructuredAgentSessionRestartFailureLedger = { outcomes: readonly StructuredAgentSessionResumeOutcome[], action: { candidates: readonly StructuredAgentSessionResumeCandidate[] - attempts: StructuredAgentSessionRestartAttempts + /** The reserved markers; a failure carries its marker's message id for older readers. */ + markers: ReadonlyMap /** How a reattached session's action ended; null when the agent carried on. Reattaching alone * is not the whole action, so the runner's own outcome cannot decide this. */ failureAfterResume: (sessionId: string) => AgentSessionResumeFailureOutcome | null @@ -99,54 +71,14 @@ export function continuationFailureOutcome( return outcome === 'continued' ? null : outcome === 'refused' ? 'refused' : 'unconfirmed' } -/** Whether the chat itself has moved past a failure: a newer user message, or, for a delivery - * nobody confirmed, a turn the continuation's own message opened. */ -function failureAnsweredByChat( - failure: AgentSessionResumeFailureRecord, - snapshot: AgentJournalSnapshot -): boolean { - const latest = latestStructuredAgentSessionUserItem(snapshot.items)?.itemId ?? null - if (latest !== failure.latestUserItemId) { - return true - } - // Only when the continuation's message was journaled: otherwise the newest user message is the - // interrupted one, whose own turn proves nothing about the continuation. - return ( - failure.outcome === 'unconfirmed' && - latest !== null && - latest !== failure.marker.latestUserItemId && - newestTurnUserItemId(snapshot) === latest - ) -} - -/** The user message that opened the newest root turn, resolved through a provider key the send was - * accepted under. */ -function newestTurnUserItemId(snapshot: AgentJournalSnapshot): string | null { - for (let index = snapshot.items.length - 1; index >= 0; index -= 1) { - const item = snapshot.items[index] - const turn = isRootAgentJournalItem(item) ? readAgentJournalTurn(item?.body) : null - if (!turn) { - continue - } - const key = turn.userItemId - if (key === undefined) { - return null - } - const accepted = snapshot.submissions.find((entry) => entry.providerItemId === key) - return accepted ? agentJournalSubmissionKey(accepted.clientMessageId) : key - } - return null -} - export function createStructuredAgentSessionRestartFailureLedger(deps: { capsule?: FailureCapsule - sessions: ReadonlyMap - /** Makes a persisted chat's journal readable here, as listing an offer does. */ - reveal: (sessionId: string) => Promise getRecord: (sessionId: string) => AgentSessionRecord | null adapter: StructuredAgentSessionAdapter /** The predicate a retry applies to the failure's marker. */ retryable: (marker: AgentSessionResumeMarker) => boolean + /** Makes the failed chats readable here, so `retryable` reads each one's journal. */ + reveal: (markers: readonly AgentSessionResumeMarker[]) => Promise now: () => number /** The capsule's single mutation lane, shared with the offer's own operations. */ enqueue: (operation: () => Promise) => Promise @@ -184,41 +116,16 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { failedAt: failure.failedAt, outcome: failure.outcome, reason: failure.reason, + ...(failure.details ? { details: failure.details } : {}), retryable: deps.retryable(failure.marker) } ] } const list = async (): Promise => { - const current: AgentSessionResumeFailureRecord[] = [] - const superseded: AgentSessionResumeFailureRecord[] = [] - for (const failure of await read()) { - const sessionId = failure.marker.sessionId - if (!deps.sessions.has(sessionId)) { - await deps.reveal(sessionId) - } - const snapshot = deps.sessions.get(sessionId)?.journal.snapshot() - // An unreadable journal decides nothing; the record stays until something that can decide. - if (snapshot && failureAnsweredByChat(failure, snapshot)) { - superseded.push(failure) - } else { - current.push(failure) - } - } - const capsule = deps.capsule - if (capsule && superseded.length > 0) { - const gone = superseded.map((failure) => ({ - sessionId: failure.marker.sessionId, - recordedAt: failure.marker.recordedAt, - failedAt: failure.failedAt - })) - void deps - .enqueue(() => capsule.forgetSuperseded(gone, deps.now())) - .catch(() => { - console.warn('[structured-agent-session] pruning superseded restart failures failed') - }) - } - return current.flatMap(toRow) + const failures = await read() + await deps.reveal(failures.map((failure) => failure.marker)) + return failures.flatMap(toRow) } const settle: StructuredAgentSessionRestartFailureLedger['settle'] = async ( @@ -232,16 +139,22 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { } const completed: string[] = [] const failures: AgentSessionResumeFailureInput[] = [] - const promptBySession = new Map( - action.candidates.map((candidate) => [candidate.sessionId, candidate.latestPrompt]) - ) + // A retry keeps the prompt its first failure named: the chat's newest user message since then + // is the rejected continuation, and a message of the user's own would have ended the offer. + const promptBySession = new Map([ + ...action.candidates.map( + (candidate) => [candidate.sessionId, candidate.latestPrompt] as const + ), + ...(await read()).map((filed) => [filed.marker.sessionId, filed.latestPrompt] as const) + ]) for (const outcome of outcomes) { const resumed = outcome.outcome === 'resumed' - // Ineligible means the user moved on or the offer no longer applies (record gone, - // conversation forked), so there is nothing to retry and the offer is spent. + // Ineligible means the offer no longer applies (record gone, conversation forked), and + // superseded means the user's own message came first: nothing to retry, and the offer is spent. const failure = resumed ? action.failureAfterResume(outcome.sessionId) - : outcome.reason === STRUCTURED_AGENT_SESSION_RESUME_NOT_ELIGIBLE + : outcome.reason === STRUCTURED_AGENT_SESSION_RESUME_NOT_ELIGIBLE || + outcome.reason === RESTART_CONTINUATION_SUPERSEDED ? null : 'refused' if (failure === null) { @@ -255,8 +168,9 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { reason: resumed ? action.failureReason(outcome.sessionId) : (outcome.reason ?? 'agent_session_resume_refused'), + ...(!resumed && outcome.details ? { details: outcome.details } : {}), latestPrompt: promptBySession.get(outcome.sessionId) ?? '', - latestUserItemId: action.attempts.latestUserItemId(outcome.sessionId) + latestUserItemId: action.markers.get(outcome.sessionId)?.latestUserItemId ?? null }) } await deps @@ -280,27 +194,9 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { }) } - const attempts: StructuredAgentSessionRestartFailureLedger['attempts'] = (markers) => { - const observed = new Map() - return { - // Observed after the attempt, so the continuation's own message is part of the filed state. - observe: (sessionId) => { - const latest = liveStructuredAgentSessionLatestUserItemId(deps.sessions, sessionId) - if (latest !== undefined) { - observed.set(sessionId, latest) - } - }, - latestUserItemId: (sessionId) => { - const latest = observed.get(sessionId) - return latest !== undefined ? latest : (markers.get(sessionId)?.latestUserItemId ?? null) - } - } - } - return { read, list, - attempts, settle, dismiss: (sessionIds, beforeClearAll) => deps.enqueue(async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index 1bcb27ca4a2..6566105ee87 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A chat interrupted mid-turn by a restart, rebuilt on a fresh host over the same store, for the // restart-resume ownership and failure tests. @@ -10,8 +11,8 @@ import { } from '../../runtime/agent-session-recovery-capsule' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { parseAgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' -import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner' import { adapter, @@ -25,14 +26,30 @@ import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD, + hostTestAttachParams, hostTestMessage } from './structured-agent-session-host-test-data' -export const GRACE = 15_000 +/** Starts the agent explicitly — the attach a client's ensure makes — for a test that needs a + * running child before its next step. Nothing else starts one ahead of a send. */ +export async function startAgent(state: { + host: StructuredAgentSessionHost + store: AgentSessionRecordStore +}): Promise { + const result = await state.host.attach( + CALLER, + hostTestAttachParams(state.store.getRecord(SESSION)?.lease.runtimeFence ?? null) + ) + expect(result.ok).toBe(true) +} export async function interruptedRestart( work: 'turn' | 'submission' | 'send-after-reply' | 'children' = 'turn', - historyBoundaryConsistent = true + historyBoundaryConsistent = true, + /** What the restarted host proves about the recorded owner; gone unless a test says otherwise. */ + probeOwner: NonNullable = async () => ({ + outcome: 'pid-absent' + }) ) { const previous = hostTestState() await attach() @@ -44,7 +61,8 @@ export async function interruptedRestart( // An earlier exchange had finished; the user's next send had not opened a turn yet. events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'earlier-turn', ordinal: 1 }, - { kind: 'turn', turnId: 'earlier-turn', state: 'completed' } + { kind: 'turn', turnId: 'earlier-turn', state: 'completed' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await previous.host.flushStreamedEvents(SESSION) } @@ -52,10 +70,13 @@ export async function interruptedRestart( previous.dispatch.mockResolvedValueOnce({ state: 'admitted' }) const body = hostTestMessage('Perform the original task') await previous.host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + // Accepted first, handed over after: the work in flight is a send the provider took. + await vi.waitFor(() => expect(previous.dispatch).toHaveBeenCalledOnce()) } else if (work === 'children') { events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'settled-turn', ordinal: 1 }, - { kind: 'turn', turnId: 'settled-turn', state: 'completed' } + { kind: 'turn', turnId: 'settled-turn', state: 'completed' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const group = { provider: 'codex', @@ -74,20 +95,21 @@ export async function interruptedRestart( } ] }) - events.appendItem(group, roster('working')) + events.appendItem(group, roster('working'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) previous.host.deps.adapter.backgroundTaskState = () => ({ state: 'monitoring', tasks: [{ id: 'child-1', kind: 'agent', description: 'Review loop 4', state: 'working' }] }) // As the real adapters do: the child's own close settles the children it can no longer hear. previous.host.deps.adapter.closeSession = async () => { - events.appendItem(group, roster('unverifiable')) + events.appendItem(group, roster('unverifiable'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) return true } } else { events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'interrupted-turn', ordinal: 1 }, - { kind: 'turn', turnId: 'interrupted-turn', state: 'running' } + { kind: 'turn', turnId: 'interrupted-turn', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } await previous.host.flushStreamedEvents(SESSION) @@ -97,6 +119,8 @@ export async function interruptedRestart( hostId: 'local' }) const closeSession = vi.fn(async () => true) + // The relaunch comes after the quit that recorded the offer. + const clock = { now: NOW + 1 } const host = new StructuredAgentSessionHost({ store, adapter: { @@ -115,10 +139,9 @@ export async function interruptedRestart( journalRoot: previous.root, claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-next', - probeOwner: async () => ({ outcome: 'pid-absent' }), + probeOwner, recoveryCapsule: new AgentSessionRecoveryCapsule(previous.root), - releaseGraceMs: GRACE, - now: () => NOW + now: () => clock.now }) replaceHostTestState({ store, host }) previous.acquire.mockClear() @@ -128,43 +151,34 @@ export async function interruptedRestart( await readFile(join(previous.root, AGENT_SESSION_RECOVERY_CAPSULE_FILE), 'utf8') ) const marker = parseAgentSessionResumeMarker(capsule.entries[0]?.marker) - return { ...hostTestState(), host, store, closeSession, marker } + return { ...hostTestState(), host, store, closeSession, marker, clock } } -export function statusNotes(host: StructuredAgentSessionHost) { - return host - .journalSnapshot(SESSION) - .items.flatMap((item) => - item.body.kind === 'status' ? [{ text: item.body.text, tone: item.body.tone }] : [] - ) +export async function statusNotes(host: StructuredAgentSessionHost) { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [{ text: item.body.text, tone: item.body.tone }] : [] + ) } -/** A reattach that succeeds and a continuation the host refuses: a message from another client - * lands while the continuation is being recorded. `userAnswers` has the user reply in the chat - * just before or after its own attempt, while the rest of a batch would still be running. */ +/** A continuation the host refuses because the user's own message was accepted first: another + * client's send lands after the action reserved the offer, just before the continuation is + * accepted. `userAnswers` instead has the user send before or after the whole attempt. */ export async function supersededRefusal(userAnswers?: 'before' | 'after') { - const { host, acquire, dispatch, root } = await interruptedRestart() + const { host, store, acquire, dispatch, root } = await interruptedRestart() await host.restartResume.list() - await host.hold(SESSION, 'pane') - const events = acquire.mock.calls[0]?.[0].events - if (!events) { - throw new Error('missing resumed provider event sink') - } - const append = AgentSessionJournal.prototype.appendSubmission - const writing = vi.spyOn(AgentSessionJournal.prototype, 'appendSubmission') - writing.mockImplementationOnce(async function (this: AgentSessionJournal, input) { - const cursor = await append.call(this, input) - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'newer-turn', ordinal: 1 }, - hostTestMessage('A newer task from another client') - ) - return cursor - }) - const admit = StructuredAgentSessionResumeAdmission.prototype.run - const admitting = vi.spyOn(StructuredAgentSessionResumeAdmission.prototype, 'run') - const body = hostTestMessage('Carry on from where you stopped') + const body = hostTestMessage('A newer task from another client') const answer = () => host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + const send = host.send + const writing = vi.spyOn(host, 'send') + if (!userAnswers) { + writing.mockImplementationOnce(async (caller, params) => { + await answer() + return send(caller, params) + }) + } + const admit = StructuredAgentSessionResumeAdmission.prototype.run + const admitting = vi.spyOn(StructuredAgentSessionResumeAdmission.prototype, 'run') if (userAnswers) { admitting.mockImplementationOnce(async function (this, ...args) { await (userAnswers === 'before' ? answer() : null) @@ -177,9 +191,7 @@ export async function supersededRefusal(userAnswers?: 'before' | 'after') { } try { const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') - expect(result.continued).toMatchObject([{ outcome: 'refused' }]) - expect(dispatch).toHaveBeenCalledTimes(userAnswers ? 1 : 0) - return { host, root, result } + return { host, store, acquire, dispatch, root, result } } finally { writing.mockRestore() admitting.mockRestore() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-after-reattach.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-after-reattach.test.ts index 912594a1737..032b0e07b2a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-after-reattach.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-after-reattach.test.ts @@ -1,99 +1,42 @@ -// An offer across the provider reattaching and rewriting the chat in its own words: a notice turn -// of its own, restated rows. None of it withdraws the offer or changes the message Resume sends. +// An offer across a failed attempt and its retry, and across builds: the message Resume sends +// depends on the marker alone, and each action sends its own. import { expect, it } from 'vitest' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { AGENT_SESSION_RESTART_CONTINUATION_MESSAGE } from '../../../shared/agent-session-restart-continuation' -import { restartContinuationBody } from './structured-agent-session-restart-continuation' +import { restartContinuationBody } from './structured-agent-session-restart-continuation-envelope' import { interruptedRestart } from './structured-agent-session-restart-interruption-test-harness' import { HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD + HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' -function resumedEvents(state: Awaited>) { - // The latest acquisition: an earlier one may have been refused. - const events = state.acquire.mock.calls.at(-1)?.[0].events - if (!events) { - throw new Error('missing resumed provider event sink') - } - return events -} - -// THE REPORTED REFUSAL: opening the chat reattached Claude, which opened a turn of its own to say -// the previous session did not finish, and closed it. The chat is still offered and Resume sends — -// including when the offer is a send that had not become a turn, where that closed notice turn is -// the newest turn in the journal. -it.each(['turn', 'submission'] as const)( - 'still offers and continues a %s offer after the provider opens and closes a notice turn', - async (work) => { - const state = await interruptedRestart(work, false) - const { host, dispatch } = state - await host.hold(SESSION, 'pane') - const events = resumedEvents(state) - const notice = { provider: 'codex', threadId: THREAD, turnId: 'notice-turn' } as const - events.appendItem( - { ...notice, ordinal: 1 }, - { kind: 'turn', turnId: 'notice-turn', state: 'running', userItemId: 'turn:notice-turn' } - ) - events.appendItem( - { ...notice, ordinal: 2 }, - { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: "didn't finish before the previous session ended" }] - } - ) - events.appendItem( - { ...notice, ordinal: 1 }, - { kind: 'turn', turnId: 'notice-turn', state: 'completed', userItemId: 'turn:notice-turn' } - ) - await host.flushStreamedEvents(SESSION) - - expect(await host.restartResume.list()).toMatchObject([{ sessionId: SESSION }]) - expect( - (await host.restartResume.continueAfterRestart([SESSION], 'modal')).continued - ).toMatchObject([{ outcome: 'continued' }]) - expect(dispatch).toHaveBeenCalledTimes(1) - host.release(SESSION, 'pane') - } -) - -// A retry after the first attempt never reached the provider, with the rows restated in between: -// the body depends on the marker alone, so the ledger fingerprint stays the offer's. -it('sends the same continuation body on a retry after the provider restates its rows', async () => { - const state = await interruptedRestart('children') - const { host, acquire, dispatch, marker } = state +// The first attempt's start failed, so its continuation was rejected and never reached the agent. +// A retry is a new action with a new message, not a replay of the rejected one, and it is +// delivered with the same body. +it("delivers a retry as a new continuation after the first one's start failed", async () => { + const { host, acquire, dispatch, marker } = await interruptedRestart('children') if (!marker) { throw new Error('missing interrupted restart marker') } acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) - await host.restartResume.continueAfterRestart([SESSION], 'modal') - expect(await host.restartResume.listFailures()).toMatchObject([{ retryable: true }]) + const first = await host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(first.failed).toMatchObject([{ sessionId: SESSION, outcome: 'refused', retryable: true }]) + const [rejected] = (await host.journalSnapshot(SESSION)).submissions + expect(rejected).toMatchObject({ dispatchState: 'rejected' }) - await host.hold(SESSION, 'pane') - resumedEvents(state).appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'settled-turn', ordinal: 2 }, - { - kind: 'message', - role: 'system', - blocks: [ - { - type: 'subagent-group', - groupId: 'settled-turn', - agents: [{ id: 'child-1', label: 'Review loop 4', state: 'completed' }] - } - ] - } - ) - await host.flushStreamedEvents(SESSION) const retried = await host.restartResume.continueAfterRestart([SESSION], 'retry') expect(retried.continued).toMatchObject([{ outcome: 'continued' }]) - expect(dispatch).toHaveBeenCalledTimes(1) - expect(dispatch.mock.calls[0]?.[0].body).toEqual(restartContinuationBody(marker)) - host.release(SESSION, 'pane') + expect(dispatch).toHaveBeenCalledOnce() + const sent = dispatch.mock.calls[0]?.[0] + expect(sent?.clientMessageId).not.toBe(rejected?.clientMessageId) + expect(sent?.body).toEqual(restartContinuationBody(marker)) + expect((await host.journalSnapshot(SESSION)).submissions).toMatchObject([ + { clientMessageId: rejected?.clientMessageId, dispatchState: 'rejected' }, + { clientMessageId: sent?.clientMessageId, dispatchState: 'accepted' } + ]) + expect(retried.failed).toEqual([]) }) // A marker from a build that recorded only a working lead: no snapshot, so no activity to name, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts index 69f6f661c37..aefa34f4b67 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts @@ -1,10 +1,11 @@ -// The ways an offer ends. Every ending is the user's own act, and every ending DELETES the -// durable record — nothing stays behind to be re-filtered on every later read. +// The ways an offer ends. Every ending DELETES the durable record — nothing stays behind to be +// re-filtered on every later read. import { expect, it, vi } from 'vitest' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' -import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' +import { rename, writeFile, rm } from 'node:fs/promises' import { interruptedRestart } from './structured-agent-session-restart-interruption-test-harness' +import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import { CALLER, envelope } from './structured-agent-session-host-test-harness' import { HOST_TEST_NOW as NOW, @@ -12,36 +13,47 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' -// The user answering the chat themselves is the one signal the offer is moot. The next listing -// notices and deletes the durable record, not merely hides it. +// The user's own message starts the chat's agent again, which ends the offer: the durable record +// is deleted, not merely hidden (R-04). it('deletes the offer once the user sends their own message in that chat', async () => { const { host, root, dispatch } = await interruptedRestart() const capsule = new AgentSessionRecoveryCapsule(root) expect(await capsule.list(NOW)).toHaveLength(1) - await host.hold(SESSION, 'pane') dispatch.mockResolvedValueOnce({ state: 'admitted' }) const body = hostTestMessage('Never mind, do this instead') await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + // Written when the agent's start is proven: a cold start. + await vi.waitFor( + async () => { + expect(await capsule.list(NOW)).toEqual([]) + }, + { timeout: 10_000 } + ) expect(await host.restartResume.list()).toEqual([]) - await vi.waitFor(async () => { - expect(await capsule.list(NOW)).toEqual([]) - }) - host.release(SESSION, 'pane') }) -// A journal this host cannot read says nothing about the user moving on, so it must not end the offer. +// A journal this host cannot read decides nothing, so it must not end the offer. it('keeps the offer when the chat cannot be read on this host', async () => { - const { host, root } = await interruptedRestart('submission') + const { host, root, store } = await interruptedRestart('submission') const capsule = new AgentSessionRecoveryCapsule(root) - const restoring = vi - .spyOn(StructuredAgentSessionReadableRestorer.prototype, 'restoreOne') - .mockRejectedValue(new Error('journal unreadable')) + const location = store.getRecord(SESSION)?.location + if (!location) { + throw new Error('missing session record') + } + // A file where the journal directory belongs: this host cannot open the conversation at all. + const journalDir = journalDirectoryFor(root, { + workspaceId: location.workspaceId, + sessionId: SESSION + }) + await rename(journalDir, `${journalDir}.aside`) + await writeFile(journalDir, 'not a journal') try { expect(await host.restartResume.list()).toMatchObject([{ sessionId: SESSION }]) } finally { - restoring.mockRestore() + await rm(journalDir) + await rename(`${journalDir}.aside`, journalDir) } expect(await capsule.list(NOW)).toHaveLength(1) expect(await host.restartResume.list()).toMatchObject([{ sessionId: SESSION }]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts new file mode 100644 index 00000000000..d36e5702d1d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts @@ -0,0 +1,548 @@ +// A restart offer ends when the chat moves on after the restart: another message accepted, or its +// agent started, other than by the offer's own continuation. Each case reads the offer list and the +// capsule, never only an in-memory set. + +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { + AgentSessionRecoveryCapsule, + AGENT_SESSION_RECOVERY_CAPSULE_FILE +} from '../../runtime/agent-session-recovery-capsule' +import { parseAgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { + interruptedRestart, + statusNotes +} from './structured-agent-session-restart-interruption-test-harness' +import { CALLER, envelope } from './structured-agent-session-host-test-harness' +import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep' +import { createStructuredAgentSessionRestartOfferWithdrawal } from './structured-agent-session-restart-offer-withdrawal' +import { restartContinuationId } from './structured-agent-session-restart-continuation-envelope' +import { sweepOnce } from './structured-agent-session-rest-test-rig' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestMessage +} from './structured-agent-session-host-test-data' + +afterEach(() => vi.restoreAllMocks()) + +const SUPERSEDED = 'agent_session_restart_work_superseded' + +/** A start under a loaded machine: reconcile, acquire, hand over. */ +const COLD_START = { timeout: 10_000 } + +async function offered(work: 'turn' | 'submission' = 'turn') { + const state = await interruptedRestart(work, false) + expect(await state.host.restartResume.list()).toHaveLength(1) + return state +} + +function offersIn(root: string) { + return new AgentSessionRecoveryCapsule(root).list(NOW) +} + +function userSend(text: string) { + const body = hostTestMessage(text) + return { envelope: envelope('agentSession.send', { body }), body } +} + +function compactParams() { + return { + command: 'compact' as const, + envelope: envelope('agentSession.conversationCommand', { command: 'compact' }) + } +} + +function sentTexts(dispatch: Awaited>['dispatch']): string[] { + return dispatch.mock.calls.map(([input]) => + input.body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('') + ) +} + +it('withdraws the offer when /compact starts the agent at rest (R-01)', async () => { + const { host, root, acquire } = await offered() + Object.assign(host.deps.adapter, { compact: vi.fn(async () => ({})) }) + + expect(await host.conversationCommand(CALLER, compactParams())).toMatchObject({ ok: true }) + + expect(acquire).toHaveBeenCalledOnce() + await vi.waitFor(async () => expect(await offersIn(root)).toEqual([]), COLD_START) + expect(await host.restartResume.list()).toEqual([]) +}) + +it('keeps the offer through every way of looking at the chat (R-02)', async () => { + const { host, root, acquire } = await offered() + const unsubscribe = await host.subscribe({ id: 'pane', sessionId: SESSION, emit: vi.fn() }) + await host.history({ sessionId: SESSION, direction: 'tail' }) + await host.readOptions(SESSION) + host.readCommands(SESSION) + await host.handoffStatus(SESSION) + await host.revealSession(SESSION) + unsubscribe() + + expect(acquire).not.toHaveBeenCalled() + expect(await host.restartResume.list()).toHaveLength(1) + expect(await offersIn(root)).toHaveLength(1) +}) + +it("does not let the offer's own continuation withdraw it (R-03)", async () => { + const { host, root, dispatch } = await offered() + const dismiss = vi.spyOn(AgentSessionRecoveryCapsule.prototype, 'dismiss') + + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect(result.continued).toMatchObject([{ outcome: 'continued' }]) + expect(dispatch).toHaveBeenCalledOnce() + // The start was the continuation's own: whatever asked, it withdrew nothing. + for (const call of dismiss.mock.results) { + expect(await call.value).toBe(0) + } + // Ended by the success path instead. + expect(await offersIn(root)).toEqual([]) +}) + +it('refuses the continuation when another start lands inside its action (R-03b)', async () => { + const { host, root, dispatch } = await offered() + Object.assign(host.deps.adapter, { compact: vi.fn(async () => ({})) }) + const send = host.send + vi.spyOn(host, 'send').mockImplementationOnce(async (caller, params) => { + // Another client's /compact starts the agent after the action reserved the offer. + expect(await host.conversationCommand(CALLER, compactParams())).toMatchObject({ ok: true }) + return send(caller, params) + }) + + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect(result.continued).toMatchObject([{ outcome: 'refused', reason: SUPERSEDED }]) + expect(dispatch).not.toHaveBeenCalled() + expect(await offersIn(root)).toEqual([]) + expect(result.failed).toEqual([]) +}) + +it('keeps the offer when a tabbed chat is restored at boot (R-05)', async () => { + const { host, store, root, acquire } = await offered() + await store.setSessionTabVisibility(SESSION, true) + + await host.restoreReadableSessions([SESSION]) + + expect(acquire).not.toHaveBeenCalled() + expect(await host.restartResume.list()).toHaveLength(1) + expect(await offersIn(root)).toHaveLength(1) +}) + +it('reads an older build’s marker whose message id no longer matches, and one with none (R-06)', async () => { + const { host, root, dispatch, marker } = await offered('submission') + if (!marker) { + throw new Error('missing interrupted restart marker') + } + // The older build compared this id with the chat's newest message; nothing does now. + await host.restartResume.dismiss([SESSION]) + await new AgentSessionRecoveryCapsule(root).record( + [{ ...marker, latestUserItemId: 'user-sent-under-the-older-build' }], + NOW + ) + expect(await host.restartResume.list()).toHaveLength(1) + const { latestUserItemId: _dropped, ...withoutId } = marker + expect(parseAgentSessionResumeMarker(withoutId)).toEqual(withoutId) + + await host.send(CALLER, userSend('Carry on')) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce(), COLD_START) + await vi.waitFor(async () => expect(await offersIn(root)).toEqual([]), COLD_START) +}) + +it('still writes the message id the previous build requires on every marker', async () => { + const { root } = await offered('submission') + const capsule = JSON.parse( + await readFile(join(root, AGENT_SESSION_RECOVERY_CAPSULE_FILE), 'utf8') + ) + expect(capsule.entries[0]?.marker).toHaveProperty('latestUserItemId') +}) + +/** Sends a message whose start fails, and waits for it to be rejected. */ +async function sendWhoseStartFails(state: Awaited>) { + state.acquire.mockRejectedValueOnce(new Error('Not signed in')) + const params = userSend('while signed out') + await state.host.send(CALLER, params) + await vi.waitFor(async () => + expect( + (await state.host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === params.envelope.clientOperationId + ) + ).toMatchObject({ dispatchState: 'rejected' }) + ) +} + +// A message the user sent is activity even when its start then failed. +it('withdraws the offer when the user sends a message whose start then fails (R-08)', async () => { + const state = await offered() + const { host, root, dispatch } = state + + await sendWhoseStartFails(state) + + expect(await host.restartResume.list()).toEqual([]) + await vi.waitFor(async () => expect(await offersIn(root)).toEqual([]), COLD_START) + // A click from a surface that still shows the offer finds nothing to act on. + expect(await host.restartResume.continueAfterRestart([SESSION], 'stale-click')).toMatchObject({ + resumed: [], + continued: [] + }) + expect(dispatch).not.toHaveBeenCalled() +}) + +// The idle sweep closes a chat's open handle, and the next read opens a new one: the user's message +// still counts, because it is read against where the journal stood when the offer was taken. +it('keeps the offer withdrawn after the idle sweep closes the chat and it is read again', async () => { + const state = await offered() + const { host, root, clock } = state + await sendWhoseStartFails(state) + clock.now += STRUCTURED_AGENT_SESSION_IDLE_MS + 1 + + await sweepOnce(host) + expect(host.hasSession(SESSION)).toBe(false) + await host.revealSession(SESSION) + + expect(await host.restartResume.list()).toEqual([]) + // The listing retires what it finds moved on; the write trails the answer. + await vi.waitFor(async () => expect(await offersIn(root)).toEqual([])) +}) + +// A failure the chat moved on from is read from its journal like an offer is, open or not. +it('keeps a failure not retryable after the user moved on and the sweep closed the chat', async () => { + const state = await offered() + const { host, clock } = state + state.acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + const resumed = await host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(resumed.failed).toMatchObject([{ sessionId: SESSION, retryable: true }]) + await sendWhoseStartFails(state) + expect(await host.restartResume.listFailures()).toMatchObject([{ retryable: false }]) + clock.now += STRUCTURED_AGENT_SESSION_IDLE_MS + 1 + + await sweepOnce(host) + expect(host.hasSession(SESSION)).toBe(false) + + expect(await host.restartResume.listFailures()).toMatchObject([{ retryable: false }]) +}) + +const DAY_AND_AN_HOUR = 25 * 60 * 60 * 1000 + +// An offer has no expiry; the ledger refuses a new id dated more than a day back. +it('resumes more than a day after the quit', async () => { + const state = await offered() + const { host, clock, dispatch } = state + clock.now += DAY_AND_AN_HOUR + + const resumed = await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect(resumed.continued).toMatchObject([{ sessionId: SESSION, outcome: 'continued' }]) + expect(sentTexts(dispatch)).toHaveLength(1) + expect(await host.restartResume.listFailures()).toEqual([]) +}) + +// The offer remembers its own continuations; nothing about them expires before the offer does. +it('keeps a failed resume retryable more than a day later, after the ledger pruned its row', async () => { + const state = await offered() + const { host, clock, store } = state + state.acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + const first = await host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(first.failed).toMatchObject([{ sessionId: SESSION, retryable: true }]) + clock.now += DAY_AND_AN_HOUR + // What an app restart does to the operation ledger a day on. + await store.reconcileOnRestart({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reconciliation only reads the probe outcome. + probe: async () => ({ outcome: 'pid-absent' }) as never, + now: clock.now + }) + + expect(await host.restartResume.listFailures()).toMatchObject([ + { sessionId: SESSION, retryable: true } + ]) + const retried = await host.restartResume.continueAfterRestart([SESSION], 'retry') + expect(retried.continued).toMatchObject([{ sessionId: SESSION, outcome: 'continued' }]) +}) + +// The start was the continuation's own even after the provider refused it: the child it started, +// running or since stopped, is still the offer's, so the offer stays retryable. +it('keeps a resume retryable when its agent started but refused the continuation', async () => { + const state = await offered() + const { host, store } = state + const acquire = state.acquire.getMockImplementation() + if (!acquire) { + throw new Error('the harness acquire has no implementation') + } + state.acquire.mockImplementationOnce(async (input) => ({ + ...(await acquire(input)), + acquisitionGeneration: 'generation-continuation' + })) + state.dispatch.mockResolvedValueOnce({ + state: 'rejected', + ...agentSessionFailureWords( + agentSessionFailureFact('providerRejected', { + detail: { text: 'the provider refused the turn', audience: 'log' } + }), + { surface: 'rejection' } + ) + }) + + const first = await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect(state.acquire).toHaveBeenCalledOnce() + expect(first.failed).toMatchObject([{ sessionId: SESSION, retryable: true }]) + expect(await host.restartResume.listFailures()).toMatchObject([ + { sessionId: SESSION, retryable: true } + ]) + // The child the continuation started exits after proving its start. + await host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + acquisitionGeneration: 'generation-continuation', + reason: 'codex app-server exited', + cause: 'unexpected-exit' + }) + expect(await host.restartResume.listFailures()).toMatchObject([ + { sessionId: SESSION, retryable: true } + ]) + const retried = await host.restartResume.continueAfterRestart([SESSION], 'retry') + expect(retried.continued).toMatchObject([{ sessionId: SESSION, outcome: 'continued' }]) +}) + +// A continuation handed to the agent may have landed even with no answer yet, so a retry could send +// it twice: the failure it files is not retryable, as for one whose dispatch is in doubt. +it('keeps an unanswered continuation the agent was handed from being sent again', async () => { + const state = await offered() + const { host } = state + // The agent took it and never answered; the wait for that answer ended first (too many waited). + state.dispatch.mockResolvedValueOnce({ state: 'admitted' }) + const wait = host.waitForSendSettlement + vi.spyOn(host, 'waitForSendSettlement').mockImplementation(async (...args) => + args[2]?.until === 'handed-over' ? wait(...args) : undefined + ) + + const first = await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect(first.continued).toMatchObject([{ sessionId: SESSION, outcome: 'pending' }]) + expect(await host.restartResume.listFailures()).toMatchObject([ + { sessionId: SESSION, outcome: 'unconfirmed', retryable: false } + ]) + expect(await host.restartResume.continueAfterRestart([SESSION], 'retry')).toMatchObject({ + continued: [] + }) + expect(state.dispatch).toHaveBeenCalledOnce() +}) + +// The rejected continuation is the chat's newest user message; the row still names the user's. +it("names the user's prompt on a failed retry, not the rejected continuation", async () => { + const state = await offered('submission') + const { host } = state + state.acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + const first = await host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(first.failed).toMatchObject([{ latestPrompt: 'Perform the original task' }]) + + state.acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + const retried = await host.restartResume.continueAfterRestart([SESSION], 'retry') + + expect(retried.failed).toMatchObject([ + { latestPrompt: 'Perform the original task', retryable: true } + ]) +}) + +// Resume that runs by itself at launch goes through the same call a click does, and the same rule +// decides: whichever of the two was accepted first since the restart wins. +it("refuses an automatic continuation when the user's message was accepted first, and writes nothing", async () => { + const { host, root, dispatch } = await offered() + const events: AgentSessionSubscribeEvent[] = [] + const unsubscribe = await host.subscribe({ + id: 'pane', + sessionId: SESSION, + emit: (event) => events.push(structuredClone(event)) + }) + const send = host.send + vi.spyOn(host, 'send').mockImplementationOnce(async (caller, params) => { + // Both accepted before any start, the user's first: the continuation queues behind the user's + // acceptance, ahead of the start that acceptance wakes. + let continuation: ReturnType | undefined + const append = AgentSessionJournal.prototype.appendSubmission + vi.spyOn(AgentSessionJournal.prototype, 'appendSubmission').mockImplementationOnce( + async function (this: AgentSessionJournal, ...args) { + continuation = send(caller, params) + await new Promise((resolve) => setTimeout(resolve, 50)) + return append.apply(this, args) + } + ) + await send(CALLER, userSend('A new request')) + return continuation! + }) + + const result = await host.restartResume.continueAfterRestart(undefined, 'launch') + + expect(result.continued).toMatchObject([{ outcome: 'refused', reason: SUPERSEDED }]) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce(), COLD_START) + expect(sentTexts(dispatch)).toEqual(['A new request']) + // Nothing the user did failed: no row reached the reader, and nothing is kept. + expect(await statusNotes(host)).toEqual([]) + expect( + events.some( + (event) => + event.type === 'batch' && event.batch.items.some((item) => item.body.kind === 'status') + ) + ).toBe(false) + expect(await offersIn(root)).toEqual([]) + expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toEqual([]) + unsubscribe() +}) + +it('delivers a clicked continuation and a message sent right after it, in the order accepted', async () => { + const { host, root, dispatch } = await offered() + const send = host.send + vi.spyOn(host, 'send').mockImplementationOnce(async (caller, params) => { + const continuation = send(caller, params) + const user = send(CALLER, userSend('And then this')) + await user + return continuation + }) + + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect(result.continued).toMatchObject([{ outcome: 'continued' }]) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(2), COLD_START) + const [first, second] = sentTexts(dispatch) + expect(first).not.toBe('And then this') + expect(second).toBe('And then this') + expect(await offersIn(root)).toEqual([]) +}) + +describe('reading the chat against where the offer was taken', () => { + const TAKEN = { epoch: 'epoch-1', sequence: 5 } + + const OFFER = { + sessionId: SESSION, + work: { kind: 'turn' as const, id: 'turn-1' }, + recordedAt: NOW, + trigger: 'quit' as const, + providerHandleRoot: 'codex:"thread"', + teardownId: 'teardown-1' + } + + function movedOn( + input: { + epoch?: string + submissions?: { + clientMessageId: string + acceptedSequence: number + dispatchState: string + handoverRecorded?: boolean + handedOverAt?: number + }[] + }, + journalCursor: { epoch: string; sequence: number } | null = TAKEN + ): boolean { + const session = { + child: null, + journal: { + cursor: () => ({ epoch: input.epoch ?? TAKEN.epoch, sequence: 9 }), + submissions: () => input.submissions ?? [] + } + } + const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fact reads only the journal's cursor and submissions and the child fields given here. + sessions: new Map([[SESSION, session as never]]), + now: () => NOW, + enqueue: (operation) => operation() + }) + return withdrawal.movedOn({ ...OFFER, ...(journalCursor ? { journalCursor } : {}) }) + } + + it('counts a message accepted after that position, and nothing before it', () => { + const at = (acceptedSequence: number) => ({ + clientMessageId: `m-${acceptedSequence}`, + acceptedSequence, + dispatchState: 'rejected' + }) + expect(movedOn({ submissions: [at(4), at(5)] })).toBe(false) + expect(movedOn({ submissions: [at(4), at(6)] })).toBe(true) + }) + + it('counts a journal on another epoch as moved on', () => { + expect(movedOn({ epoch: 'epoch-2' })).toBe(true) + }) + + it("does not count the offer's own continuation while queued or rejected, so a retry still runs", () => { + const own = restartContinuationId(OFFER, 'operation-1', NOW) + const rejected = { clientMessageId: own, acceptedSequence: 7, dispatchState: 'rejected' } + const queued = { ...rejected, dispatchState: 'pending', handoverRecorded: true } + expect(movedOn({ submissions: [rejected] })).toBe(false) + expect(movedOn({ submissions: [queued] })).toBe(false) + // Handed over, it may have reached the agent, answered or not. + expect(movedOn({ submissions: [{ ...queued, handedOverAt: NOW }] })).toBe(true) + expect(movedOn({ submissions: [{ ...rejected, dispatchState: 'accepted' }] })).toBe(true) + // Another offer's continuation, and any other message, is the chat moving on. + const other = restartContinuationId({ ...OFFER, teardownId: 'teardown-2' }, 'operation-1', NOW) + expect(movedOn({ submissions: [{ ...rejected, clientMessageId: other }] })).toBe(true) + }) + + // The retry's reservation lapses with the app, and the capsule hands back the failure's marker, + // written before the retry ran. The next launch rejects the retry's queued continuation. + it('keeps a failure retryable after the app crashed during its retry', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-crash-during-retry-')) + try { + const capsule = new AgentSessionRecoveryCapsule(root) + await capsule.record([{ ...OFFER, journalCursor: TAKEN }], NOW) + await capsule.beginResume([SESSION], 'operation-a', NOW) + await capsule.failResume( + 'operation-a', + [ + { + sessionId: SESSION, + failedAt: NOW, + outcome: 'refused', + reason: 'x', + latestPrompt: '', + latestUserItemId: null + } + ], + NOW + ) + const [retrying] = await capsule.beginResume([SESSION], 'operation-b', NOW + 1) + if (!retrying) { + throw new Error('the retry reserved nothing') + } + const retry = restartContinuationId(retrying, 'operation-b', NOW + 1) + const afterCrash = NOW + 60 * 60_000 + expect(await capsule.list(afterCrash)).toEqual([]) + const [failed] = await capsule.listFailed(afterCrash) + + const session = { + child: null, + journal: { + cursor: () => ({ epoch: TAKEN.epoch, sequence: 9 }), + submissions: () => [ + { clientMessageId: retry, acceptedSequence: 7, dispatchState: 'rejected' } + ] + } + } + const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fact reads only the journal's cursor and submissions and the child fields given here. + sessions: new Map([[SESSION, session as never]]), + now: () => afterCrash, + enqueue: (operation) => operation() + }) + if (!failed) { + throw new Error('the failure did not outlive the crash') + } + expect(withdrawal.movedOn(failed.marker)).toBe(false) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('leaves an older build’s offer, which recorded no position, to a start', () => { + const after = { clientMessageId: 'm-7', acceptedSequence: 7, dispatchState: 'accepted' } + expect(movedOn({ submissions: [after], epoch: 'epoch-2' }, null)).toBe(false) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts new file mode 100644 index 00000000000..76d8006d9a1 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts @@ -0,0 +1,92 @@ +// When a restart offer ends without being acted on: the chat moved on. +// +// One derived fact decides it, for the offer list and for the continuation's own acceptance alike: +// since the offer was taken, another message was accepted in the chat, or its agent proved a start. +// A message is read against the journal position the offer recorded, so the answer survives any +// number of handle closes. A start is known only to the host that saw it, so it is also written +// to the recovery file when it happens. A restored row, a replayed row or a view carries neither, +// so opening a chat withdraws nothing. The rest of a resume action does not count: its own +// continuation, and the start that continuation waits on. + +import type { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' +import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { isRestartContinuationOf } from './structured-agent-session-restart-continuation-envelope' + +export type StructuredAgentSessionRestartOfferWithdrawal = ReturnType< + typeof createStructuredAgentSessionRestartOfferWithdrawal +> + +export type StructuredAgentSessionRestartOfferSession = Pick< + StructuredAgentSessionHostSession, + 'journal' | 'child' | 'lastEndedChild' +> + +export function createStructuredAgentSessionRestartOfferWithdrawal(deps: { + sessions: ReadonlyMap + capsule?: Pick + now: () => number + /** The capsule's single mutation lane, shared with the offer's own operations. */ + enqueue: (operation: () => Promise) => Promise +}) { + const movedOn = (marker: AgentSessionResumeMarker): boolean => { + const session = deps.sessions.get(marker.sessionId) + if (!session) { + return false + } + const taken = marker.journalCursor + // An older build's offer recorded no position: only a start withdraws it. + const accepted = + taken !== undefined && + (session.journal.cursor().epoch !== taken.epoch || + session.journal.submissions().some( + (submission) => + (submission.acceptedSequence ?? 0) > taken.sequence && + // The offer's own continuation, still queued or rejected, never reached the agent: a + // retry sends a new one. One handed over may have, answered or not. + !( + (isQueuedAgentJournalSubmission(submission) || + submission.dispatchState === 'rejected') && + isRestartContinuationOf(marker, submission.clientMessageId) + ) + )) + // Proven, not merely spawned: a start that failed during startup never ran the agent. Whose + // start it was is fixed when it was made, so a continuation rejected since still owns it. + const started = + session.child?.phase === 'ready' + ? session.child + : session.lastEndedChild?.duringStartup === false + ? session.lastEndedChild + : undefined + return ( + accepted || + (started !== undefined && + !(started.startedFor !== undefined && isRestartContinuationOf(marker, started.startedFor))) + ) + } + + return { + movedOn, + /** The chat's agent proved a start: the offer and any failure record go from the recovery file, + * unless the start was for one of that offer's own continuations. Advisory: a failed write is + * logged, never raised. */ + onAgentStarted: (sessionId: string): void => { + const session = deps.sessions.get(sessionId) + const capsule = deps.capsule + if (!capsule || !session) { + return + } + const startedFor = session.child?.startedFor + void deps + .enqueue(() => + capsule.dismiss([sessionId], deps.now(), (marker) => + startedFor === undefined ? false : isRestartContinuationOf(marker, startedFor) + ) + ) + .catch(() => { + console.warn('[structured-agent-session] withdrawing a restart offer failed') + }) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index c3301861c01..59ff6958d53 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdir, rm, writeFile } from 'node:fs/promises' import { AgentSessionRecoveryCapsule, @@ -7,8 +8,6 @@ import { parseAgentSessionResumeMarker } from '../../../shared/agent-session-res import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' import { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { pendingApproval } from './structured-agent-session-restart-resume-test-harness' -import { restartContinuationEnvelope } from './structured-agent-session-restart-continuation' import { AGENT_SESSION_RESTART_CONTINUATION_NOTE, AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, @@ -16,8 +15,8 @@ import { } from '../../../shared/agent-session-restart-continuation' import { STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER } from './structured-agent-session-restart-resume-wiring' import { - GRACE, interruptedRestart, + startAgent, statusNotes, supersededRefusal } from './structured-agent-session-restart-interruption-test-harness' @@ -33,6 +32,8 @@ import { HOST_TEST_THREAD as THREAD, hostTestMessage } from './structured-agent-session-host-test-data' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' afterEach(() => vi.useRealTimers()) @@ -40,7 +41,7 @@ it('publishes continuation attribution to the subscribed chat without another pr const { host } = await interruptedRestart() await host.restartResume.list() const emit = vi.fn() - const unsubscribe = host.subscribe({ id: 'pane', sessionId: SESSION, emit }) + const unsubscribe = await host.subscribe({ id: 'pane', sessionId: SESSION, emit }) try { expect( (await host.restartResume.continueAfterRestart([SESSION], 'modal')).continued @@ -81,273 +82,75 @@ it.each(['turn', 'submission'] as const)( async (work) => { const { host, dispatch } = await interruptedRestart(work, false) expect(await host.restartResume.list()).toHaveLength(1) - await host.hold(SESSION, 'pane') + await startAgent(hostTestState()) dispatch.mockResolvedValueOnce({ state: 'admitted' }) const body = hostTestMessage('Stop the old task and do this instead') await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) await host.restartResume.continueAfterRestart([SESSION], 'modal') expect(dispatch).toHaveBeenCalledTimes(1) - expect(host.journalSnapshot(SESSION).submissions).toHaveLength(work === 'turn' ? 1 : 2) - host.release(SESSION, 'pane') - expect(host.isHeld(SESSION)).toBe(false) + expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(work === 'turn' ? 1 : 2) } ) -it('preserves the offer when the original submission receives its provider echo', async () => { - const { host, acquire, dispatch } = await interruptedRestart('submission', false) - expect(await host.restartResume.list()).toHaveLength(1) - await host.hold(SESSION, 'pane') - const events = acquire.mock.calls[0]?.[0].events - if (!events) { - throw new Error('missing resumed provider event sink') - } - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'original-turn', ordinal: 1 }, - hostTestMessage('Perform the original task') - ) - await host.flushStreamedEvents(SESSION) - expect(host.journalSnapshot(SESSION).submissions[0]?.dispatchState).toBe('accepted') - expect( - (await host.restartResume.continueAfterRestart([SESSION], 'modal')).continued - ).toMatchObject([{ outcome: 'continued' }]) - expect(acquire).toHaveBeenCalledTimes(1) - expect(dispatch).toHaveBeenCalledTimes(1) - host.release(SESSION, 'pane') - expect(host.isHeld(SESSION)).toBe(false) -}) - -// Teardown judged the chat working, and only the user moving on withdraws that. A send the -// provider proves it never received is still the chat Orca stopped; the continuation asks the agent +// Teardown judged the chat working. A send the provider proves it never received is still the chat +// Orca stopped; the continuation asks the agent // to check what finished rather than refusing. it('still continues a chat whose last send acquisition proves was never delivered', async () => { const { host, acquire, dispatch } = await interruptedRestart('submission') expect(await host.restartResume.list()).toHaveLength(1) const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') - expect(host.journalSnapshot(SESSION).submissions[0]).toMatchObject({ + expect((await host.journalSnapshot(SESSION)).submissions[0]).toMatchObject({ dispatchState: 'rejected', - reason: 'not_delivered' + rejection: { kind: 'notDelivered' } }) expect(acquire).toHaveBeenCalledTimes(1) expect(dispatch).toHaveBeenCalledTimes(1) expect(result.continued).toMatchObject([{ outcome: 'continued' }]) - expect(host.isHeld(SESSION)).toBe(false) }) -/** Holds the continuation at send admission while `during` runs, then lets it proceed. */ -async function continueAcross( - state: Awaited>, - during: (events: NonNullable>) => void -) { - const { host, store } = state - expect(await host.restartResume.list()).toHaveLength(1) - await host.hold(SESSION, 'pane') - const events = resumedEvents(state) - if (!events) { - throw new Error('missing resumed provider event sink') - } - const admitting = Promise.withResolvers() - const proceed = Promise.withResolvers() - const admit = store.admitMutationOperation - vi.spyOn(store, 'admitMutationOperation').mockImplementationOnce(async (input) => { - admitting.resolve() - await proceed.promise - return admit(input) - }) - const continuing = host.restartResume.continueAfterRestart([SESSION], 'modal') - await admitting.promise - during(events) - await host.flushStreamedEvents(SESSION) - proceed.resolve() - return continuing -} - -function resumedEvents(state: Awaited>) { - return state.acquire.mock.calls[0]?.[0].events -} - -it('refuses at send admission once the user has sent a newer message', async () => { - const state = await interruptedRestart() - const { host, dispatch } = state - const result = await continueAcross(state, (events) => - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'newer-turn', ordinal: 1 }, - hostTestMessage('A newer task from another client') - ) - ) - expect(result.resumed).toMatchObject([ - { outcome: 'refused', reason: 'agent_session_restart_work_superseded' } - ]) - expect(dispatch).not.toHaveBeenCalled() - expect(host.journalSnapshot(SESSION).submissions).toMatchObject([ - { dispatchState: 'rejected', reason: 'agent_session_restart_work_superseded' } - ]) - host.release(SESSION, 'pane') - expect(host.isHeld(SESSION)).toBe(false) -}) - -// Claude opens a turn of its own on reattach to say the last session did not finish. Resume is -// pressed while that turn still runs: the continuation is sent and the provider queues it. -it('sends the continuation while a turn the provider opened is still running', async () => { - const state = await interruptedRestart() - const { host, dispatch } = state - const result = await continueAcross(state, (events) => - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'notice-turn', ordinal: 1 }, - { kind: 'turn', turnId: 'notice-turn', state: 'running' } - ) - ) - expect(result.continued).toMatchObject([{ outcome: 'continued' }]) - expect(dispatch).toHaveBeenCalledTimes(1) - host.release(SESSION, 'pane') -}) - -// The send the user made just before quitting, after an earlier exchange had finished. Neither -// that finished turn nor the provider completing the send's own turn after the restart withdraws it. +// The send the user made just before quitting, after an earlier exchange had finished: that +// finished turn does not withdraw it. it('offers and continues a send made after an earlier completed turn', async () => { - const state = await interruptedRestart('send-after-reply', false) - const { host, dispatch, marker } = state + const { host, dispatch, marker } = await interruptedRestart('send-after-reply', false) expect(marker?.work.kind).toBe('submission') - const result = await continueAcross(state, (events) => - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'original-turn', ordinal: 1 }, - { kind: 'turn', turnId: 'original-turn', state: 'completed' } - ) - ) + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') expect(result.continued).toMatchObject([{ outcome: 'continued' }]) expect(dispatch).toHaveBeenCalledTimes(1) - host.release(SESSION, 'pane') }) -it.each([false, true])( - 'refuses a queued newer message before dispatch even if later settlement fails: %s', - async (settlementFails) => { - const { host, store, acquire, dispatch } = await interruptedRestart('submission', false) - expect(await host.restartResume.list()).toHaveLength(1) - await host.hold(SESSION, 'pane') - const events = acquire.mock.calls[0]?.[0].events - if (!events) { - throw new Error('missing resumed provider event sink') - } - const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const settle = store.recordOperationOutcome.bind(store) - let admitted = false - vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { - if (admitted) { - if (settlementFails) { - throw new Error('operation outcome could not be persisted') - } - return settle(input) - } - await settle(input) - admitted = true - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'original-turn', ordinal: 2 }, - { kind: 'status', text: 'Provider finished its last action' } - ) - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'original-turn', ordinal: 1 }, - hostTestMessage('A newer task from another client'), - { lifecycle: true } - ) - }) - - const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') - - expect(result.continued).toMatchObject([ - { outcome: 'refused', reason: 'agent_session_restart_work_superseded' } - ]) - expect(dispatch).not.toHaveBeenCalled() - expect(host.journalSnapshot(SESSION).submissions).toHaveLength(2) - expect(host.journalSnapshot(SESSION).submissions[1]?.dispatchState).toBe('rejected') - host.release(SESSION, 'pane') - expect(host.isHeld(SESSION)).toBe(false) - // The offer is spent, but the refusal is kept as a durable failure: a retry finds it, and the - // superseded chat is still not eligible, so nothing runs and the record stays for the user. - expect(await host.restartResume.continueAfterRestart([SESSION], 'retry')).toMatchObject({ - resumed: [], - continued: [], - sessions: [], - failed: [ - { sessionId: SESSION, outcome: 'refused', reason: 'agent_session_restart_work_superseded' } - ] - }) - expect(dispatch).not.toHaveBeenCalled() - if (settlementFails) { - expect(store.recordOperationOutcome).toHaveBeenCalledOnce() - expect(warning).not.toHaveBeenCalled() - } - warning.mockRestore() - } -) - -// What the provider says after reattaching does not re-judge the offer. -it.each(['completed', 'approval', 'question'] as const)( - 'continues past provider %s evidence accepted while recording the continuation', - async (event) => { - const { host, acquire, dispatch } = await interruptedRestart() - await host.restartResume.list() - await host.hold(SESSION, 'pane') - const events = acquire.mock.calls[0]?.[0].events - if (!events) { - throw new Error('missing resumed provider event sink') - } - const append = AgentSessionJournal.prototype.appendSubmission - const writing = vi.spyOn(AgentSessionJournal.prototype, 'appendSubmission') - writing.mockImplementationOnce(async function (this: AgentSessionJournal, input) { - const cursor = await append.call(this, input) - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'interrupted-turn', ordinal: 1 }, - event === 'completed' - ? { kind: 'turn', turnId: 'interrupted-turn', state: 'completed' } - : { ...pendingApproval().body, question: 'Which action?', kind: event }, - { lifecycle: true } - ) - return cursor - }) - try { - const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') - expect(result.continued).toMatchObject([{ outcome: 'continued' }]) - expect(dispatch).toHaveBeenCalledTimes(1) - } finally { - writing.mockRestore() - host.release(SESSION, 'pane') - } - } -) - it('replays the same logical continuation through the durable send ledger', async () => { - const { host, store, dispatch, marker } = await interruptedRestart() - if (!marker) { - throw new Error('missing interrupted restart marker') - } + const { host, dispatch } = await interruptedRestart() + const sending = vi.spyOn(host, 'send') await host.restartResume.continueAfterRestart([SESSION], 'modal') - const fence = store.getRecord(SESSION)?.lease.runtimeFence - if (fence === undefined) { - throw new Error('missing resumed lease') + const sent = sending.mock.calls[0]?.[1] + sending.mockRestore() + if (!sent) { + throw new Error('the continuation was not sent') } const replay = await host.send( { callerKey: STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER }, - restartContinuationEnvelope(SESSION, fence, marker) + { envelope: sent.envelope, body: sent.body } ) expect(replay).toMatchObject({ ok: true, replayed: true }) - expect(host.journalSnapshot(SESSION).submissions).toHaveLength(1) + expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(1) expect(dispatch).toHaveBeenCalledTimes(1) }) +// A send that throws after Orca may have taken it is not proof it was not delivered. it.each([false, true])( - 'keeps dispatched delivery unconfirmed when settlement fails (uncertainty write fails: %s)', + 'keeps a continuation unconfirmed when its acceptance cannot be recorded (uncertainty write fails: %s)', async (uncertaintyFails) => { - const { host, store, dispatch } = await interruptedRestart() + const { host, store } = await interruptedRestart() const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) expect(await host.restartResume.list()).toHaveLength(1) - await host.hold(SESSION, 'pane') const settle = store.recordOperationOutcome.bind(store) - let dispatched = false - vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { - if (input.outcome.status === 'succeeded') { - dispatched = true - } - if (dispatched && (input.outcome.status === 'succeeded' || uncertaintyFails)) { + const recording = vi.spyOn(store, 'recordOperationOutcome') + recording.mockImplementation(async (input) => { + // Only the continuation's own record: the start it makes records its attach as usual. + if ( + input.callerKey === STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER && + (input.outcome.status === 'succeeded' || uncertaintyFails) + ) { throw new Error('operation outcome could not be persisted') } return settle(input) @@ -355,127 +158,96 @@ it.each([false, true])( const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') - expect(dispatch).toHaveBeenCalledTimes(1) - expect(host.journalSnapshot(SESSION).submissions[0]?.dispatchState).toBe('accepted') expect(result.continued).toMatchObject([{ sessionId: SESSION, outcome: 'unknown' }]) - // Filed as unconfirmed, with a warning in the chat; the continuation's own message is part of - // the filed state, so it does not retire the record it caused. + // Filed as unconfirmed, with a warning in the chat. expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'unconfirmed' }]) - expect(statusNotes(host)).toContainEqual({ + expect(await statusNotes(host)).toContainEqual({ text: AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, tone: 'warning' }) - host.release(SESSION, 'pane') - expect(host.isHeld(SESSION)).toBe(false) - await host.restartResume.continueAfterRestart([SESSION], 'retry') - expect(dispatch).toHaveBeenCalledTimes(1) - expect(await host.restartResume.listFailures()).toMatchObject([{ outcome: 'unconfirmed' }]) - expect(warning.mock.calls.flat()).not.toContainEqual( - expect.objectContaining({ message: 'operation outcome could not be persisted' }) - ) + recording.mockRestore() warning.mockRestore() } ) -it('releases a failed acquisition and leaves the offer retryable', async () => { +// The continuation is accepted, then its start fails: the message is rejected with the cause and +// the failure is filed, and nothing is stopped because nothing started. +it('rejects the continuation when its start fails, and files a retryable refusal', async () => { const { host, acquire, dispatch, closeSession } = await interruptedRestart() acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) - expect(await host.restartResume.resume([SESSION], 'modal')).toMatchObject([ - { outcome: 'refused' } + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(result.resumed).toMatchObject([{ outcome: 'refused' }]) + expect(result.continued).toMatchObject([{ outcome: 'refused' }]) + // Nothing ran, so the offer stands as a failure a retry can act on. + expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'refused', retryable: true }]) + expect(await statusNotes(host)).toContainEqual({ + text: AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, + tone: 'error' + }) + expect((await host.journalSnapshot(SESSION)).submissions).toMatchObject([ + { dispatchState: 'rejected' } ]) - expect(host.isHeld(SESSION)).toBe(false) - await host.restartResume.continueAfterRestart([SESSION], 'retry') - expect(acquire).toHaveBeenCalledTimes(2) - expect(dispatch).toHaveBeenCalledTimes(1) + expect(acquire).toHaveBeenCalledTimes(1) + expect(dispatch).not.toHaveBeenCalled() expect(closeSession).not.toHaveBeenCalled() }) -it.each([false, true])( - 'admits one durable continuation under concurrent calls (pane already live: %s)', - async (alreadyLive) => { - const { host, root, acquire, dispatch } = await interruptedRestart() - if (alreadyLive) { - expect(await host.restartResume.list()).toHaveLength(1) - await host.hold(SESSION, 'pane') - } - const results = await Promise.all([ - host.restartResume.continueAfterRestart([SESSION], 'window-one'), - host.restartResume.continueAfterRestart([SESSION], 'window-two') - ]) - expect( - results.flatMap((result) => result.resumed).filter((r) => r.outcome === 'resumed') - ).toHaveLength(1) - expect( - results.flatMap((result) => result.continued).filter((r) => r.outcome === 'continued') - ).toHaveLength(1) - expect(acquire).toHaveBeenCalledTimes(1) - expect(dispatch).toHaveBeenCalledTimes(1) - expect(host.journalSnapshot(SESSION).submissions).toHaveLength(1) - expect(await new AgentSessionRecoveryCapsule(root).list(NOW)).toEqual([]) - await host.restartResume.continueAfterRestart([SESSION], 'later-click') - expect(dispatch).toHaveBeenCalledTimes(1) - host.release(SESSION, 'pane') - } -) +it('admits one durable continuation under concurrent calls', async () => { + const { host, root, acquire, dispatch } = await interruptedRestart() + const results = await Promise.all([ + host.restartResume.continueAfterRestart([SESSION], 'window-one'), + host.restartResume.continueAfterRestart([SESSION], 'window-two') + ]) + expect( + results.flatMap((result) => result.resumed).filter((r) => r.outcome === 'resumed') + ).toHaveLength(1) + expect( + results.flatMap((result) => result.continued).filter((r) => r.outcome === 'continued') + ).toHaveLength(1) + expect(acquire).toHaveBeenCalledTimes(1) + expect(dispatch).toHaveBeenCalledTimes(1) + expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(1) + expect(await new AgentSessionRecoveryCapsule(root).list(NOW)).toEqual([]) + await host.restartResume.continueAfterRestart([SESSION], 'later-click') + expect(dispatch).toHaveBeenCalledTimes(1) +}) -it.each([false, true])( - 'releases reconnect acquisition to idle eviction (pane: %s)', - async (pane) => { - const { host, acquire, dispatch, closeSession } = await interruptedRestart() - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - expect(await host.restartResume.resume([SESSION], 'modal')).toMatchObject([ - { outcome: 'resumed' } - ]) - expect(host.isHeld(SESSION)).toBe(false) - if (pane) { - await host.hold(SESSION, 'pane') - await vi.advanceTimersByTimeAsync(GRACE * 2) - expect(closeSession).not.toHaveBeenCalled() - host.release(SESSION, 'pane') - } - await vi.advanceTimersByTimeAsync(GRACE) - await vi.waitFor(() => expect(closeSession).toHaveBeenCalledTimes(1)) - expect(acquire).toHaveBeenCalledTimes(1) - expect(dispatch).not.toHaveBeenCalled() - } -) - -it('retains acquisition through slow continuation settlement, then releases it', async () => { - const { host, dispatch, closeSession } = await interruptedRestart() - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) +// The batch counts a chat done once its agent took the continuation; the provider's slow answer is +// awaited after, and decides the outcome. +it('waits out a slow provider answer before reporting the continuation', async () => { + const { host, dispatch } = await interruptedRestart() const settlement = Promise.withResolvers>>() const dispatched = Promise.withResolvers() dispatch.mockImplementationOnce(() => { dispatched.resolve() return settlement.promise }) - const continuing = host.restartResume.continueAfterRestart([SESSION], 'modal') + let settled = false + const continuing = host.restartResume + .continueAfterRestart([SESSION], 'modal') + .finally(() => (settled = true)) await dispatched.promise - await vi.advanceTimersByTimeAsync(GRACE * 2) - expect(host.isHeld(SESSION)).toBe(true) - expect(closeSession).not.toHaveBeenCalled() - settlement.resolve({ state: 'rejected', reason: 'provider refused' }) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(settled).toBe(false) + settlement.resolve({ + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { + surface: 'rejection' + }) + }) expect((await continuing).continued).toMatchObject([{ outcome: 'refused' }]) - expect(host.isHeld(SESSION)).toBe(false) - await vi.advanceTimersByTimeAsync(GRACE) - await vi.waitFor(() => expect(closeSession).toHaveBeenCalledTimes(1)) expect(dispatch).toHaveBeenCalledTimes(1) }) -// Opening the chat is inspection only. The explicit restart action is what removes the durable -// offer, so ordinary pane lifecycle must not make this status disappear. +// Opening the chat is inspection only: it starts nothing, and the explicit restart action is what +// removes the durable offer, so reading the chat must not make this status disappear. it('keeps offering a chat after the user opens it', async () => { - const { host, root, store } = await interruptedRestart() + const { host, root, acquire } = await interruptedRestart() expect(await host.restartResume.list()).toHaveLength(1) - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - await host.hold(SESSION, 'pane') - host.release(SESSION, 'pane') - await vi.advanceTimersByTimeAsync(GRACE) - // The whole eviction, not just the provider stop: the lease returns to `released` on the step - // before the last, and until it does the offer is refused for a reason that is not recovery. - await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - vi.useRealTimers() + const unsubscribe = await host.subscribe({ id: 'pane', sessionId: SESSION, emit: vi.fn() }) + await host.history({ sessionId: SESSION, direction: 'tail' }) + unsubscribe() + expect(acquire).not.toHaveBeenCalled() expect(await host.restartResume.list()).toHaveLength(1) await host.restartResume.recordMarkers() @@ -508,7 +280,8 @@ it('serializes teardown publication behind an explicit dismissal', async () => { } events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 1 }, - { kind: 'turn', turnId: 'working', state: 'running' } + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.flushStreamedEvents(SESSION) host.restartResume.beginTeardown('quit') @@ -544,7 +317,7 @@ it('keeps concurrent recovery reads independent and non-destructive', async () = list.mockRestore() }) -it('fails closed on corrupt recovery storage while ordinary hold and send still work', async () => { +it('fails closed on corrupt recovery storage while an ordinary send still works', async () => { const { host, root, dispatch } = await interruptedRestart() await writeFile(join(root, AGENT_SESSION_RECOVERY_CAPSULE_FILE), '{') const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -555,81 +328,56 @@ it('fails closed on corrupt recovery storage while ordinary hold and send still sessions: [], failed: [] }) - await host.hold(SESSION, 'pane') const body = hostTestMessage('A fresh ordinary request') expect( await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) ).toMatchObject({ ok: true }) - expect(dispatch).toHaveBeenCalledTimes(1) - // list; the action's read of offers and of failures; the post-action refresh of both. - expect(warning).toHaveBeenCalledTimes(5) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) + // list; the action's read of offers and of failures; the post-action refresh of both. The send + // cannot withdraw an offer it cannot read either, and says so. + const withdrawing = '[structured-agent-session] withdrawing a restart offer failed' + await vi.waitFor(() => expect(warning).toHaveBeenLastCalledWith(withdrawing)) + expect(warning.mock.calls.filter(([message]) => message !== withdrawing)).toHaveLength(5) warning.mockRestore() - host.release(SESSION, 'pane') }) -// The toast is gone in seconds and the offer is spent by the reattach, so without this record -// nothing on any surface would still name the chat the user has to continue by hand. -it('keeps a refused continuation as a durable failure that names the chat and the reason', async () => { - const { host, root, result } = await supersededRefusal() - const failure = { - sessionId: SESSION, - outcome: 'refused', - reason: 'agent_session_restart_work_superseded', - latestPrompt: expect.any(String), - agent: 'codex', - retryable: false - } - expect(result).toMatchObject({ sessions: [], failed: [failure] }) - // The chat itself says what happened and what to do. - expect(statusNotes(host)).toContainEqual({ - text: AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, - tone: 'error' +// The user's own message was accepted first: the continuation is refused, and since nothing the +// user did failed, the chat says nothing and no failure is kept. +it("refuses a continuation quietly when the user's own message was accepted first", async () => { + const { host, root, dispatch, result } = await supersededRefusal() + expect(result).toMatchObject({ + continued: [{ outcome: 'refused', reason: 'agent_session_restart_work_superseded' }], + sessions: [], + failed: [] }) - expect(await host.restartResume.list()).toEqual([]) - expect(await host.restartResume.listFailures()).toMatchObject([failure]) - // Durable: a fresh reader of the same file sees it too. - expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toMatchObject([ - { marker: { sessionId: SESSION }, outcome: 'refused' } - ]) - host.release(SESSION, 'pane') + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + expect(await statusNotes(host)).toEqual([]) + expect(await new AgentSessionRecoveryCapsule(root).list(NOW)).toEqual([]) + expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toEqual([]) }) -// The failure asked the user to continue the chat themselves; their own message is that -// continuation. Nothing on the send path clears it: the listing sees the newer message. +/** A continuation whose start failed, filed as a retryable failure. */ +async function failedContinuation() { + const state = await interruptedRestart() + state.acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + const result = await state.host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'refused' }]) + return state +} + +// Starting the chat's agent again answers a failure, as it ends an offer. it('retires a recorded failure once the user sends in that chat, with no send hook', async () => { - const { host, root } = await supersededRefusal() + const { host, root, dispatch } = await failedContinuation() const body = hostTestMessage('Carry on from where you stopped') await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - expect(await host.restartResume.listFailures()).toEqual([]) - // Pruned from the file too, not only hidden. + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + // Pruned from the file, not only hidden. await vi.waitFor(async () => { expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toEqual([]) }) - host.release(SESSION, 'pane') + expect(await host.restartResume.listFailures()).toEqual([]) }) -// The user can reply in a chat while other chats in the same action are still being continued, -// before its turn in the batch or after its own note asks them to. Either reply answers the failure. -it.each(['before', 'after'] as const)( - 'retires a failure the user answered %s its own attempt, before the action settled', - async (userAnswers) => { - const { host, root, result } = await supersededRefusal(userAnswers) - expect(result.resumed).toMatchObject([ - userAnswers === 'before' ? { reason: 'agent_session_resume_not_eligible' } : {} - ]) - expect( - statusNotes(host).some( - (note) => note.text === AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE - ) - ).toBe(userAnswers === 'after') - expect(result.failed).toEqual([]) - await vi.waitFor(async () => { - expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toEqual([]) - }) - host.release(SESSION, 'pane') - } -) - it('removes a failure when a named retry succeeds', async () => { const { host, root, dispatch } = await interruptedRestart() const capsule = new AgentSessionRecoveryCapsule(root) @@ -645,7 +393,7 @@ it('removes a failure when a named retry succeeds', async () => { outcome: 'refused', reason: 'agent_session_conflict', latestPrompt: '', - latestUserItemId: pending!.latestUserItemId + latestUserItemId: pending!.latestUserItemId ?? null } ], NOW @@ -661,7 +409,7 @@ it('removes a failure when a named retry succeeds', async () => { }) it('dismisses one failure by name and leaves the rest of the durable records alone', async () => { - const { host, root } = await supersededRefusal() + const { host, root } = await failedContinuation() const capsule = new AgentSessionRecoveryCapsule(root) const other = parseAgentSessionResumeMarker({ sessionId: 'session-other', @@ -680,7 +428,6 @@ it('dismisses one failure by name and leaves the rest of the durable records alo expect(await host.restartResume.dismiss([SESSION])).toBe(1) expect(await host.restartResume.listFailures()).toEqual([]) expect(await capsule.list(NOW)).toEqual([other]) - host.release(SESSION, 'pane') }) it('logs teardown capsule publication failure and still releases the provider', async () => { @@ -692,14 +439,17 @@ it('logs teardown capsule publication failure and still releases the provider', } events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 1 }, - { kind: 'turn', turnId: 'working', state: 'running' } + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await previous.host.flushStreamedEvents(SESSION) const capsulePath = join(previous.root, AGENT_SESSION_RECOVERY_CAPSULE_FILE) await mkdir(capsulePath) const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) await expect(previous.host.flushAllStreamedEvents()).resolves.toBeUndefined() - expect(() => previous.host.journalSnapshot(SESSION)).toThrow('agent_session_ownership_unknown') + await expect(previous.host.journalSnapshot(SESSION)).rejects.toThrow( + 'agent_session_ownership_unknown' + ) expect(warning).toHaveBeenCalledWith( '[structured-agent-session] recording recovery capsule failed' ) @@ -708,3 +458,29 @@ it('logs teardown capsule publication failure and still releases the provider', warning.mockRestore() await rm(capsulePath, { recursive: true }) }) + +// The resume ledger's reason stays the refusal code, which is what every renderer's guidance keys +// on; the details are filed beside it, never in its place. +it('files a restart refused by a conflicted claim under its code, with its details beside it', async () => { + // The terminal agent that holds the claim is still running, so nothing may take it over. + const { host, store } = await interruptedRestart('turn', true, async () => ({ + outcome: 'identity-matched', + matchedOn: ['spawn-token'] + })) + await store.transitionHandoff(SESSION, (record) => ({ + ...record, + lease: { + ...record.lease, + claimStatus: 'conflicted', + ownerProcess: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'terminal' } + } + })) + + await host.restartResume.continueAfterRestart([SESSION], 'modal') + + await vi.waitFor(async () => + expect(await host.restartResume.listFailures()).toMatchObject([ + { reason: 'agent_session_conflict', details: { reason: 'claimConflicted' } } + ]) + ) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts index f46ff48ce15..90875832c8f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts @@ -10,10 +10,13 @@ vi.mock('./structured-agent-session-read-restore', () => ({ restoreStructuredAgentSessionRead: restoreRead })) -import { - restoreOneStructuredAgentSessionRead, - restoreStructuredAgentSessionsOnRestart -} from './structured-agent-session-restart-restore' +import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' + +const NO_OPEN_DEPS = { + store: { getRecord: () => null, listRecords: () => [] }, + journalRoot: '/tmp/journals', + adapter: {} +} describe('restart journal restoration', () => { beforeEach(() => restoreRead.mockReset()) @@ -22,17 +25,19 @@ describe('restart journal restoration', () => { const gate = Promise.withResolvers() let active = 0 let peak = 0 - restoreRead.mockImplementation(async (_store, _root, sessionId: string) => { + restoreRead.mockImplementation(async (_deps, sessionId: string) => { active += 1 peak = Math.max(peak, active) await gate.promise active -= 1 return { - journal: {}, - params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' }, - fence: 1, - hasProviderChild: false, - sessionId + session: { + journal: {}, + params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' }, + child: null, + sessionId + }, + reset: null } }) const records = Array.from( @@ -41,15 +46,13 @@ describe('restart journal restoration', () => { ) const restoration = restoreStructuredAgentSessionsOnRestart({ - store: {} as never, - journalRoot: '/tmp/journals', + openDeps: NO_OPEN_DEPS, records, reconcile: async () => null, resolveRecovery: async () => undefined, serialize: async (_sessionId, task) => task(), hasSession: () => false, - onReadable: () => undefined, - retrySettlement: async () => true + onReadable: () => undefined }) await vi.waitFor(() => expect(active).toBe(4)) @@ -61,7 +64,7 @@ describe('restart journal restoration', () => { expect(peak).toBe(4) }) - it('runs pending settlement retry after recovery resolution', async () => { + it('settles what a gone generation left running after recovery resolution, before publishing', async () => { const calls: string[] = [] const params: AgentSessionAttachParams = { envelope: { @@ -81,64 +84,52 @@ describe('restart journal restoration', () => { accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex' }, runtimeKind: 'native' } - restoreRead.mockResolvedValue({ - journal: {}, - params, - fence: 4, - hasProviderChild: false, - acquisitionGeneration: null + const restored = { + session: { journal: {}, params, child: null }, + reset: null + } + // The open is what settles: it runs after recovery resolution and before the publish. + restoreRead.mockImplementation(async () => { + calls.push('open') + return restored }) - await restoreOneStructuredAgentSessionRead( - { - store: {} as never, - journalRoot: '/tmp/journals', - reconcile: async () => null, - resolveRecovery: async () => { - calls.push('resolveRecovery') - }, - serialize: async (_sessionId, task) => task(), - hasSession: () => false, - onReadable: () => { - calls.push('onReadable') - }, - retrySettlement: async (_sessionId, restoredParams) => { - calls.push( - restoredParams === params ? 'retrySettlement:restored-params' : 'retrySettlement' - ) - return true - } + await restoreStructuredAgentSessionsOnRestart({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here. + records: [{ sessionId: 'session-1' } as AgentSessionRecord], + openDeps: NO_OPEN_DEPS, + reconcile: async () => null, + resolveRecovery: async () => { + calls.push('resolveRecovery') }, - 'session-1' - ) + serialize: async (_sessionId, task) => task(), + hasSession: () => false, + onReadable: (_sessionId, readable) => { + calls.push(readable === restored ? 'onReadable:restored' : 'onReadable') + } + }) - expect(calls).toEqual(['resolveRecovery', 'onReadable', 'retrySettlement:restored-params']) + expect(calls).toEqual(['resolveRecovery', 'open', 'onReadable:restored']) }) - it('does not rerun settlement retry when a second restore finds the session already open', async () => { - const retrySettlement = vi.fn(async () => true) + it('does not settle again when a second restore finds the session already open', async () => { restoreRead.mockResolvedValue({ - journal: {}, - params: {}, - fence: 4, - hasProviderChild: false, - acquisitionGeneration: null + session: { journal: {}, params: {}, child: null }, + reset: null }) - await restoreOneStructuredAgentSessionRead( - { - store: {} as never, - journalRoot: '/tmp/journals', - reconcile: async () => null, - resolveRecovery: async () => undefined, - serialize: async (_sessionId, task) => task(), - hasSession: () => true, - onReadable: () => undefined, - retrySettlement - }, - 'session-1' - ) + await restoreStructuredAgentSessionsOnRestart({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here. + records: [{ sessionId: 'session-1' } as AgentSessionRecord], + openDeps: NO_OPEN_DEPS, + reconcile: async () => null, + resolveRecovery: async () => undefined, + serialize: async (_sessionId, task) => task(), + hasSession: () => true, + onReadable: () => undefined + }) - expect(retrySettlement).not.toHaveBeenCalled() + // The open is where the settlement runs, and a session already open is not opened again. + expect(restoreRead).not.toHaveBeenCalled() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts index 38c04d2a597..d54687c25a6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -8,42 +8,37 @@ // It does not owe a provider child. This used to resume every record whose lease was `released`, // which is the normal end state of a chat the user closed cleanly — so a // healthy profile started an app-server per session it had ever used, in parallel, at every launch, -// with no client attached and nothing on screen. A child now exists because a surface asked for the -// session (see `structured-agent-session-holds`), not because a record survived on disk. +// with no client attached and nothing on screen. A child now exists because work asked for it — a +// send, through the delivery loop — not because a record survived on disk. import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { mapWithConcurrency } from '../../../shared/map-with-concurrency' -import { - restoreStructuredAgentSessionRead, - type RestoredStructuredAgentSessionRead -} from './structured-agent-session-read-restore' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { + OpenedStructuredAgentSessionConversation, + StructuredAgentSessionConversationOpenDeps +} from './structured-agent-session-conversation-open' +import { restoreStructuredAgentSessionRead } from './structured-agent-session-read-restore' const JOURNAL_RESTORE_CONCURRENCY = 4 export type StructuredAgentSessionReadRestoreDeps = { - store: AgentSessionRecordStore - journalRoot: string + openDeps: StructuredAgentSessionConversationOpenDeps & { + store: Pick + } reconcile: (sessionId: string) => Promise resolveRecovery: (sessionId: string) => Promise serialize: (sessionId: string, task: () => Promise) => Promise hasSession: (sessionId: string) => boolean - onReadable: (sessionId: string, restored: RestoredStructuredAgentSessionRead) => void - retrySettlement: ( + onReadable: ( sessionId: string, - params: RestoredStructuredAgentSessionRead['params'] - ) => Promise + opened: OpenedStructuredAgentSessionConversation + ) => Promise | void } -/** - * One session's share of the restart restore, and the whole of an on-demand one. - * - * Startup maps this over every supported record; a surface asking for a session it cannot see - * calls it for one id. The CALLER decides which records are eligible — startup filters by - * `supportsRecord` before mapping, so an on-demand caller owes the same check. - */ -export async function restoreOneStructuredAgentSessionRead( +/** One session's share of the restart restore. Startup maps this over every supported record. */ +async function restoreOneStructuredAgentSessionRead( input: StructuredAgentSessionReadRestoreDeps, sessionId: string ): Promise { @@ -57,29 +52,21 @@ export async function restoreOneStructuredAgentSessionRead( ) } -/** The serialized half of the restore, for a caller already inside the session's serialize — a - * send replaying into a session this host has closed, which needs the journal and no child. */ -export async function restoreOneStructuredAgentSessionReadUnderSerialize( - input: Pick< - StructuredAgentSessionReadRestoreDeps, - 'store' | 'journalRoot' | 'hasSession' | 'onReadable' | 'retrySettlement' - >, +/** The serialized half of the restore. */ +async function restoreOneStructuredAgentSessionReadUnderSerialize( + input: Pick, sessionId: string ): Promise { if (input.hasSession(sessionId)) { - // A surface that took a hold mid-restore already attached this one. + // A read or a send mid-restore already opened this one. return } - const restored = await restoreStructuredAgentSessionRead( - input.store, - input.journalRoot, - sessionId - ) - if (!restored) { + const opened = await restoreStructuredAgentSessionRead(input.openDeps, sessionId) + if (!opened) { return } - input.onReadable(sessionId, restored) - await input.retrySettlement(sessionId, restored.params) + // The open settled what a gone generation left running, so no reader sees it run. + await input.onReadable(sessionId, opened) } export async function restoreStructuredAgentSessionsOnRestart( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index 0770190c2f4..d053caf006b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts @@ -1,21 +1,18 @@ // Restart offers are durable per-session records. Listing reserves nothing and removes only offers // the chat has provably moved past; an explicit action reserves records, and only a completed -// action removes them — or files what went wrong. +// action removes them — or files what went wrong. Starting the chat's agent again withdraws them. import { randomUUID } from 'node:crypto' +import { + AgentSessionRefusalError, + agentSessionRefusalFromReference +} from '../../../shared/agent-session-wire-refusals' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import type { AgentSessionResumeMarker, AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import type { - AgentSessionMutationEnvelope, - AgentSessionMutationResult, - AgentSessionSendResult -} from '../../../shared/agent-session-wire' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { createStructuredAgentSessionRestartCandidateReader } from './structured-agent-session-restart-candidates' import { @@ -34,33 +31,21 @@ import { type StructuredAgentSessionResumeOutcome } from './structured-agent-session-restart-resume-runner' import { - continueStructuredAgentSessionAfterRestart, - noteRestartReattachFailed, restartContinuationDeps, + startStructuredAgentSessionContinuation, type StructuredAgentSessionContinuationHost, type StructuredAgentSessionContinuationOutcome } from './structured-agent-session-restart-continuation' +import { restartContinuationId } from './structured-agent-session-restart-continuation-envelope' +import { + createStructuredAgentSessionRestartOfferWithdrawal, + type StructuredAgentSessionRestartOfferSession +} from './structured-agent-session-restart-offer-withdrawal' +import type { StructuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-wiring' import { createStructuredAgentSessionRestartWitnesses } from './structured-agent-session-restart-witnesses' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -type LiveSession = { journal: AgentSessionJournal; hasProviderChild: boolean; fence: number } - -export type StructuredAgentSessionRestartResumeSurfaces = { - publish: (sessionId: string, journal: AgentSessionJournal) => void - revealSession: (sessionId: string) => Promise<{ readable: boolean }> - hold: (sessionId: string, holderId: string) => Promise - release: (sessionId: string, holderId: string) => void - send: (input: { - envelope: AgentSessionMutationEnvelope - body: AgentJournalMessageItem - beforeRun?: () => void - }) => Promise> - awaitSendSettlement: ( - sessionId: string, - clientMessageId: string - ) => Promise<{ value: AgentSessionSendResult } | undefined> - onNoteFailed: (sessionId: string, error: unknown) => void - now: () => number -} +type LiveSession = StructuredAgentSessionRestartOfferSession export type StructuredAgentSessionRestartResume = { /** Teardown: begin, then per session a snapshot right before its child stops and a confirmation @@ -72,10 +57,6 @@ export type StructuredAgentSessionRestartResume = { list: () => Promise /** Offers already acted on whose agent did not carry on. Read-only; nothing here is spent. */ listFailures: () => Promise - resume: ( - sessionIds: readonly string[] | undefined, - owner: string - ) => Promise continueAfterRestart: ( sessionIds: readonly string[] | undefined, owner: string @@ -87,6 +68,8 @@ export type StructuredAgentSessionRestartResume = { }> /** Named sessions forget their offer or failure; unnamed, every durable record goes. */ dismiss: (sessionIds?: readonly string[]) => Promise + /** The chat's agent proved a start: its offer ends unless the start is a resume's own. */ + onAgentStarted: (sessionId: string) => void } export function createStructuredAgentSessionRestartResume( @@ -101,11 +84,6 @@ export function createStructuredAgentSessionRestartResume( const admission = new StructuredAgentSessionResumeAdmission() const enqueueRecoveryOperation = createStructuredAgentSessionRestartOperationQueue() - const derive = createStructuredAgentSessionRestartCandidateReader({ - sessions, - getRecord: deps.store.getRecord, - adapter: deps.adapter - }) const witnesses = createStructuredAgentSessionRestartWitnesses({ sessions, getRecord: deps.store.getRecord, @@ -115,15 +93,24 @@ export function createStructuredAgentSessionRestartResume( now: surfaces.now, enqueue: enqueueRecoveryOperation }) + const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ + sessions, + ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), + now: surfaces.now, + enqueue: enqueueRecoveryOperation + }) + const derive = createStructuredAgentSessionRestartCandidateReader({ + sessions, + getRecord: deps.store.getRecord, + adapter: deps.adapter, + movedOn: withdrawal.movedOn + }) const failures = createStructuredAgentSessionRestartFailureLedger({ ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), - sessions, - reveal: async (sessionId) => { - await surfaces.revealSession(sessionId).catch(() => null) - }, getRecord: deps.store.getRecord, adapter: deps.adapter, retryable: (marker) => derive([marker], 'may-be-held').candidates.length === 1, + reveal: (markers) => revealMarkers(markers), now: surfaces.now, enqueue: enqueueRecoveryOperation }) @@ -153,19 +140,20 @@ export function createStructuredAgentSessionRestartResume( const continuationHost: StructuredAgentSessionContinuationHost = { ...surfaces, sessions, - stillResumable: (marker, options) => - derive([marker], 'may-be-held', options).candidates.length === 1 + conversationFence: (sessionId) => + deps.store.getRecord(sessionId) + ? structuredAgentSessionConversationFence(deps.store, sessionId) + : null, + stillResumable: (marker) => derive([marker], 'may-be-held').candidates.length === 1 } + /** One explicit action: reserve the offers, then continue each through `continueOne`, a few at + * a time. The runner counts a chat as done once `continueOne` returns. */ const run = async ( sessionIds: readonly string[] | undefined, owner: string, - afterAcquire?: (marker: AgentSessionResumeMarker) => Promise, - settlement: Omit[2], 'candidates' | 'attempts'> = { - failureAfterResume: () => null, - failureReason: () => 'agent_session_resume_refused' - } - ): Promise => { + continueOne: (marker: AgentSessionResumeMarker, continuationId: string) => Promise + ) => { // An explicit action supersedes teardown witnesses captured by this host. The durable mutation // lane below also drains a publication already in flight before completion. witnesses.clear() @@ -176,25 +164,26 @@ export function createStructuredAgentSessionRestartResume( retireSuperseded(derived.superseded) const eligible = derived.candidates.filter((candidate) => requested.has(candidate.sessionId)) if (eligible.length === 0) { - return [] + return null } const operationId = randomUUID() + const actionAt = surfaces.now() + // Tagged with its offer, so a rejected one is told apart for as long as the offer lasts. + const continuationFor = (marker: AgentSessionResumeMarker) => + restartContinuationId(marker, operationId, actionAt) const reserved = (await enqueueRecoveryOperation( () => deps.recoveryCapsule?.beginResume( eligible.map((candidate) => candidate.sessionId), operationId, - surfaces.now() + actionAt ) ?? Promise.resolve([]) )) ?? [] const markersBySession = new Map(reserved.map((marker) => [marker.sessionId, marker])) const candidates = derive(reserved, 'may-be-held').candidates - const attempts = failures.attempts(markersBySession) - - let outcomes: StructuredAgentSessionResumeOutcome[] try { - outcomes = await resumeStructuredAgentSessionsFromRestart( + const outcomes = await resumeStructuredAgentSessionsFromRestart( { admission, consumeMarker: async (sessionId) => { @@ -202,26 +191,16 @@ export function createStructuredAgentSessionRestartResume( return marker !== undefined && derive([marker], 'may-be-held').candidates.length === 1 }, resume: async (sessionId) => { - const holder = `restart-resume:${sessionId}` - try { - await surfaces.hold(sessionId, holder).catch(async (error: unknown) => { - // The reattach failure is filed like any other, so the chat must say so too. - await noteRestartReattachFailed(continuationHost, sessionId) - throw error - }) - const marker = markersBySession.get(sessionId) - if (marker) { - await afterAcquire?.(marker) - } - } finally { - attempts.observe(sessionId) - surfaces.release(sessionId, holder) + const marker = markersBySession.get(sessionId) + if (marker) { + await continueOne(marker, continuationFor(marker)) } } }, candidates, owner ) + return { operationId, outcomes, candidates, markers: markersBySession } } catch (error) { if (deps.recoveryCapsule) { await enqueueRecoveryOperation(() => @@ -232,8 +211,6 @@ export function createStructuredAgentSessionRestartResume( } throw error } - await failures.settle(operationId, outcomes, { candidates, attempts, ...settlement }) - return outcomes } const continueAfterRestart = async ( @@ -246,34 +223,50 @@ export function createStructuredAgentSessionRestartResume( failed?: StructuredAgentSessionResumeFailure[] }> => { const continued: StructuredAgentSessionContinuationOutcome[] = [] - const continuationFor = (sessionId: string) => - continued.find((outcome) => outcome.sessionId === sessionId) - const resumed = await run( - sessionIds, - owner, - async (marker) => { - continued.push( - await continueStructuredAgentSessionAfterRestart( - restartContinuationDeps(continuationHost, marker), - marker.sessionId, - marker - ) - ) - }, - { + const verdicts: Promise[] = [] + // A chat holds its slot until its agent took the continuation or its start failed, so a batch + // never starts more agents at once than the runner allows; the provider's answer comes after. + const action = await run(sessionIds, owner, async (marker, continuationId) => { + const started = await startStructuredAgentSessionContinuation( + restartContinuationDeps(continuationHost, marker), + marker.sessionId, + marker, + continuationId + ) + if ('done' in started) { + continued.push(started.done) + const { outcome, reason, refusal } = started.done + if (outcome === 'refused') { + // Thrown as a refusal so the filed failure keeps its details beside the code. + throw refusal + ? new AgentSessionRefusalError(agentSessionRefusalFromReference(refusal, refusal.code)) + : new Error(reason ?? 'agent_session_continuation_refused') + } + return + } + verdicts.push(started.verdict().then((outcome) => void continued.push(outcome))) + }) + await Promise.all(verdicts) + const resumed = action?.outcomes ?? [] + if (action) { + await failures.settle(action.operationId, resumed, { + candidates: action.candidates, + markers: action.markers, failureAfterResume: (sessionId) => { - const outcome = continuationFor(sessionId) - // Reattached but never asked to continue: nothing confirms the agent carried on. - return outcome ? continuationFailureOutcome(outcome.outcome) : 'unconfirmed' + const outcome = continued.find((entry) => entry.sessionId === sessionId) + return outcome ? continuationFailureOutcome(outcome.outcome) : null }, failureReason: (sessionId) => { - const outcome = continuationFor(sessionId) + const outcome = continued.find((entry) => entry.sessionId === sessionId) return outcome?.reason ?? outcome?.outcome ?? 'agent_session_continuation_unknown' } - } - ) + }) + } for (const outcome of resumed) { - if (outcome.outcome !== 'resumed') { + if ( + outcome.outcome !== 'resumed' && + !continued.some((entry) => entry.sessionId === outcome.sessionId) + ) { continued.push({ sessionId: outcome.sessionId, outcome: 'refused', @@ -307,7 +300,7 @@ export function createStructuredAgentSessionRestartResume( // Do not let a teardown witness already captured in this host republish after explicit // dismissal. A later capture is a new interruption and may create a fresh offer normally. dismiss: (sessionIds) => failures.dismiss(sessionIds, witnesses.clear), - resume: (sessionIds, owner) => run(sessionIds, owner), - continueAfterRestart + continueAfterRestart, + onAgentStarted: withdrawal.onAgentStarted } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts index 33a07e80335..a298e23f454 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts @@ -1,14 +1,17 @@ // Delivering the explicit restart action: the one path that turns a resumable candidate back into -// a live agent. +// a working agent. // // The manual "Resume" button and the automatic setting both land here, so the two can never drift // into different eligibility or different double-fire protection. // -// Resume itself acquires a provider child, not a new send. The first resume-capable hold on a -// childless session re-acquires the provider's own conversation — Claude's `resume` by session id, -// Codex's thread id — which is native continuation. Nothing re-sends the user's prompt: that is -// what makes an agent redo work it already finished. +// A resume is the continuation send. Its delivery starts the provider on its own conversation — +// Claude's `resume` by session id, Codex's thread id — which is native continuation. Nothing +// re-sends the user's prompt: that is what makes an agent redo work it already finished. +import { + isAgentSessionRefusalError, + type AgentSessionAnyRefusalDetails +} from '../../../shared/agent-session-wire-refusals' import { forEachWithConcurrency } from '../../../shared/map-with-concurrency' import type { StructuredAgentSessionResumeCandidate } from './structured-agent-session-restart-resume-set' @@ -27,6 +30,8 @@ export type StructuredAgentSessionResumeOutcome = { outcome: 'resumed' | 'refused' /** Refusal code; `agent_session_resume_already_in_progress` names the live owner in `owner`. */ reason?: string + /** A thrown refusal's details, kept apart so `reason` stays the code readers match. */ + details?: AgentSessionAnyRefusalDetails owner?: string } @@ -52,7 +57,7 @@ function resumeAdmissionOwner(error: unknown): string | null { * One resume per session at a time, whoever is asking. * * Two surfaces can reach for the same chat at once — the banner's "Resume all" and a user clicking - * one row — and both would otherwise take a hold, race the acquisition, and leave the loser's + * one row — and both would otherwise send the continuation twice, and leave the loser's * refusal looking like a real failure. The second caller is told who holds it instead. */ export class StructuredAgentSessionResumeAdmission { @@ -80,7 +85,7 @@ export type StructuredAgentSessionResumeRunnerDeps = { admission: StructuredAgentSessionResumeAdmission /** Validates this action's durable reservation. False means the candidate is no longer eligible. */ consumeMarker: (sessionId: string) => Promise - /** Acquires the provider child for the reserved session. */ + /** Continues the reserved session; resolves once its agent took the message or refused it. */ resume: (sessionId: string) => Promise concurrency?: number } @@ -123,10 +128,12 @@ async function resumeOne( } catch (error) { const reason = error instanceof Error ? error.message : String(error) const owner = resumeAdmissionOwner(error) + const details = isAgentSessionRefusalError(error) ? error.refusal.details : undefined return { sessionId, outcome: 'refused', reason, + ...(details ? { details } : {}), ...(owner === null ? {} : { owner }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts index 245fbcf4bbf..3c91a9732b9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts @@ -7,12 +7,12 @@ // restored thread), and every reading of those rewrites as "the work is done" dropped chats that // were owed a resume. // -// An offer ends only by the user's own actions. The ones this predicate can see — a newer message -// of theirs in that chat, or the conversation forked — are reported back as `superseded` so the -// caller DELETES the record rather than filtering it forever. What remains are structural checks -// that are not about work at all: the record still exists and this build supports it, and the -// lease is free. +// An offer ends when the chat moves on after the restart — another message accepted, or its agent +// started — or when the conversation forked. Both are reported back as `superseded` so the caller +// DELETES the record rather than filtering it forever. What remains are structural checks that are not about work at all: the record still +// exists and this build supports it, and the lease is free. +import type { AgentSessionAnyRefusalDetails } from '../../../shared/agent-session-wire-refusals' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { agentSessionProviderHandleChainHead, @@ -57,6 +57,8 @@ export type StructuredAgentSessionResumeFailure = StructuredAgentSessionResumeCa outcome: AgentSessionResumeFailureOutcome /** The host's or provider's refusal code, verbatim, so it can be quoted in a report. */ reason: string + /** The refusal's details beside its code in `reason`; absent on older records and non-refusals. */ + details?: AgentSessionAnyRefusalDetails /** Whether naming it in an action would run it again: whether it is still an offer. A * continuation the chat already holds, or the user having moved on, makes a retry a no-op no * matter what the reason says. */ @@ -65,8 +67,8 @@ export type StructuredAgentSessionResumeFailure = StructuredAgentSessionResumeCa export type StructuredAgentSessionResumableSet = { candidates: StructuredAgentSessionResumeCandidate[] - /** Markers the chat has provably moved past — a newer user message, or a forked conversation. - * Every ending deletes: the caller retires these rather than re-filtering them forever. */ + /** Markers the chat has provably moved past, or whose conversation forked. Every ending deletes: + * the caller retires these rather than re-filtering them forever. */ superseded: AgentSessionResumeMarker[] } @@ -75,8 +77,8 @@ export type StructuredAgentSessionResumeSetInput = { getRecord: (sessionId: string) => AgentSessionRecord | null supportsRecord: (record: AgentSessionRecord) => boolean latestPrompt: (sessionId: string) => string - /** Undefined when the chat's journal is not readable here, which decides nothing. */ - latestUserItemId: (sessionId: string) => string | null | undefined + /** Whether the chat moved on since the offer was taken; false when its journal is not open here. */ + movedOn: (marker: AgentSessionResumeMarker) => boolean /** * Whether the lease must be free. * @@ -103,7 +105,7 @@ export function structuredAgentSessionResumableSet( continue } // A conversation that FORKED since teardown is not the one we marked, and can never be again: - // deleted like a newer message, so it cannot sit unseen forever. Compared by identity root, + // deleted, so it cannot sit unseen forever. Compared by identity root, // because a resume legitimately advances Claude's leaf and that is not a fork. const head = agentSessionProviderHandleChainHead(record.providerHandleChain) if (!head) { @@ -113,10 +115,7 @@ export function structuredAgentSessionResumableSet( superseded.push(marker) continue } - // The user moving on is the one thing that withdraws the offer. Anything the provider does on - // its own after reattaching — a turn it opens, a prompt, restated rows — is not. - const latestUserItemId = input.latestUserItemId(marker.sessionId) - if (latestUserItemId !== undefined && latestUserItemId !== marker.latestUserItemId) { + if (input.movedOn(marker)) { superseded.push(marker) continue } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts index e5b65009634..e359e31157b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts @@ -145,7 +145,7 @@ export type HarnessJournal = { appendItem: (envelope: unknown, body: { kind: string; text: string }) => Promise } -export type HarnessSession = { journal: HarnessJournal; hasProviderChild: boolean; fence?: number } +export type HarnessSession = { journal: HarnessJournal; child: { fence: number } | null } export function journal( items: AgentJournalRenderItem[], @@ -203,13 +203,12 @@ export function resumableSet(input: { markers: AgentSessionResumeMarker[] items?: AgentJournalRenderItem[] chain?: AgentSessionRecord['providerHandleChain'] - latestUserItemId?: string | null }) { return structuredAgentSessionResumableSet({ markers: input.markers, getRecord: () => record(input.chain === undefined ? {} : { chain: input.chain }), supportsRecord: () => true, latestPrompt: () => 'fix the auth bug', - latestUserItemId: () => input.latestUserItemId ?? null + movedOn: () => false }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts index 4494392fb48..4004502e408 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts @@ -11,7 +11,27 @@ import type { AgentSessionMutationResult, AgentSessionSendResult } from '../../../shared/agent-session-wire' -import type { StructuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-host' +import { MAX_TIMER_DELAY_MS } from '../../../shared/timer-delay' +import type { SendSettlementWaitOptions } from './structured-agent-session-send-settlement' + +export type StructuredAgentSessionRestartResumeSurfaces = { + revealSession: (sessionId: string) => Promise<{ readable: boolean }> + send: (input: { + envelope: AgentSessionMutationEnvelope + body: AgentJournalMessageItem + beforeRun?: () => void + }) => Promise> + awaitSendSettlement: ( + sessionId: string, + clientMessageId: string + ) => Promise<{ value: AgentSessionSendResult } | undefined> + awaitSendHandedOver: ( + sessionId: string, + clientMessageId: string + ) => Promise<{ value: AgentSessionSendResult } | undefined> + onNoteFailed: (sessionId: string, error: unknown) => void + now: () => number +} /** The caller key the continuation sends under, so its writes are attributable to Orca itself. */ export const STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER = @@ -21,8 +41,6 @@ export const STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER = * dependency, and nothing outside this list is reachable from here. */ type RestartResumeHostBindings = { revealSession: (sessionId: string) => Promise<{ readable: boolean }> - hold: (sessionId: string, holderId: string) => Promise - release: (sessionId: string, holderId: string) => void send: ( caller: { callerKey: string }, params: { @@ -34,21 +52,30 @@ type RestartResumeHostBindings = { /** The host's existing settlement waiter; a send returns while its dispatch is still pending. */ waitForSendSettlement: ( sessionId: string, - clientMessageId: string + clientMessageId: string, + options: SendSettlementWaitOptions ) => Promise<{ value: AgentSessionSendResult } | undefined> } export function structuredAgentSessionRestartResumeSurfaces( host: RestartResumeHostBindings, now: () => number -): Omit { +): StructuredAgentSessionRestartResumeSurfaces { return { revealSession: host.revealSession, - hold: host.hold, - release: host.release, send: (params) => host.send({ callerKey: STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER }, params), - awaitSendSettlement: host.waitForSendSettlement, + // Accepted like any send, so its verdict is its delivery, however long the start takes; the + // wait ends when the submission settles or the session closes. + awaitSendSettlement: (sessionId, clientMessageId) => + host.waitForSendSettlement(sessionId, clientMessageId, { budgetMs: MAX_TIMER_DELAY_MS }), + // How long a restart batch holds a chat's slot: until the agent took the message or its start + // failed. Undefined — the session closed, or too many waited — frees the slot too. + awaitSendHandedOver: (sessionId, clientMessageId) => + host.waitForSendSettlement(sessionId, clientMessageId, { + until: 'handed-over', + budgetMs: MAX_TIMER_DELAY_MS + }), onNoteFailed: () => console.warn('[structured-agent-session] restart continuation attribution failed'), now diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts index 1d6a8a1fc5e..efcd70345a8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts @@ -17,6 +17,7 @@ import { structuredAgentSessionWorkingAtStop } from './structured-agent-session- import { CLAUDE_ROOT, claudeRecord, + EPOCH, HANDLE_ROOT, journal, TEARDOWN_CURRENT, @@ -48,7 +49,7 @@ describe('deriving what was working at teardown', () => { it('marks a session this host was running a turn for', () => { const markers = markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'running')]), hasProviderChild: true }] + [SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }] ]), getRecord: () => record(), backgroundTasks: () => undefined, @@ -64,6 +65,7 @@ describe('deriving what was working at teardown', () => { recordedAt: NOW, trigger: 'quit', teardownId: TEARDOWN_CURRENT, + journalCursor: { epoch: EPOCH, sequence: 1 }, providerHandleRoot: HANDLE_ROOT, latestUserItemId: null, activity: { state: 'working', prompts: [], tasks: [] } @@ -74,7 +76,7 @@ describe('deriving what was working at teardown', () => { it('carries the update trigger so the surface can say the restart was not the user choice', () => { const [recorded] = markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'running')]), hasProviderChild: true }] + [SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }] ]), getRecord: () => record(), backgroundTasks: () => undefined, @@ -89,7 +91,7 @@ describe('deriving what was working at teardown', () => { it('marks nothing for an idle session', () => { expect( markersAtTeardown({ - sessions: new Map([[SESSION, { journal: journal([]), hasProviderChild: true }]]), + sessions: new Map([[SESSION, { journal: journal([]), child: { fence: 1 } }]]), getRecord: () => record(), backgroundTasks: () => undefined, trigger: 'quit', @@ -103,7 +105,7 @@ describe('deriving what was working at teardown', () => { expect( markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), hasProviderChild: true }] + [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }] ]), getRecord: () => record(), backgroundTasks: () => undefined, @@ -115,12 +117,12 @@ describe('deriving what was working at teardown', () => { }) // The user's stated fear. A journal restored for READING carries whatever `running` row an older - // crash left behind, and it is the live `hasProviderChild` — not that row — that decides. + // crash left behind, and it is the live `child` — not that row — that decides. it('marks nothing for a stale running row this host was not executing', () => { expect( markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'running')]), hasProviderChild: false }] + [SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: null }] ]), getRecord: () => record(), backgroundTasks: () => undefined, @@ -141,7 +143,7 @@ describe('deriving what was working at teardown', () => { SESSION, { journal: journal([turnItem('turn-1', 'running'), pendingApproval()]), - hasProviderChild: true + child: { fence: 1 } } ] ]), @@ -165,7 +167,7 @@ describe('deriving what was working at teardown', () => { it('marks a settled lead whose subagent was still running, anchored on its last turn', () => { const markers = markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), hasProviderChild: true }] + [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }] ]), getRecord: () => record(), backgroundTasks: () => [ @@ -188,7 +190,7 @@ describe('deriving what was working at teardown', () => { it('records only the live rows of the roster, bounded', () => { const [recorded] = markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), hasProviderChild: true }] + [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }] ]), getRecord: () => record(), backgroundTasks: () => [ @@ -213,7 +215,7 @@ describe('deriving what was working at teardown', () => { it('marks a settled lead whose only live work is a monitor', () => { const markers = markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), hasProviderChild: true }] + [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }] ]), getRecord: () => record(), backgroundTasks: () => [{ id: 'task-m', kind: 'monitor', name: 'ci-watch' }], @@ -230,7 +232,7 @@ describe('deriving what was working at teardown', () => { expect( markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), hasProviderChild: true }] + [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }] ]), getRecord: () => record(), backgroundTasks: () => [ @@ -248,7 +250,7 @@ describe('deriving what was working at teardown', () => { it('records the identity root so an advancing Claude leaf cannot invalidate the marker', () => { const [recorded] = markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'running')]), hasProviderChild: true }] + [SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }] ]), getRecord: () => claudeRecord(null), backgroundTasks: () => undefined, @@ -264,7 +266,7 @@ describe('deriving what was working at teardown', () => { expect( markersAtTeardown({ sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'running')]), hasProviderChild: true }] + [SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }] ]), getRecord: () => record({ chain: [] }), backgroundTasks: () => undefined, @@ -285,7 +287,7 @@ describe('deriving what was working at teardown', () => { SESSION, { journal: journal([], false, [submission('msg-1', 'pending')]), - hasProviderChild: true + child: { fence: 1 } } ] ]), @@ -310,7 +312,7 @@ describe('deriving what was working at teardown', () => { journal: journal([turnItem('turn-0', 'completed')], false, [ submission('msg-1', 'pending') ]), - hasProviderChild: true + child: { fence: 1 } } ] ]), @@ -324,6 +326,67 @@ describe('deriving what was working at teardown', () => { expect(recorded?.work).toEqual({ kind: 'submission', id: 'msg-1' }) }) + // Accepted while the agent was starting and never handed over: the chat shows working, but no + // agent had the message, and quit rejects it as never sent. + it('marks nothing for a session whose only work is a message still queued', () => { + const queued = { ...submission('msg-1', 'pending'), handoverRecorded: true as const } + expect( + markersAtTeardown({ + sessions: new Map([ + [ + SESSION, + { + journal: journal([turnItem('turn-0', 'completed')], false, [queued]), + child: { fence: 1 } + } + ] + ]), + getRecord: () => claudeRecord(null), + backgroundTasks: () => undefined, + trigger: 'quit', + teardownId: TEARDOWN_CURRENT, + now: NOW + }) + ).toEqual([]) + }) + + it('marks a handed-over message over a newer queued one, and a turn a queued one waits behind', () => { + const handedOver = { + ...submission('msg-1', 'pending'), + handoverRecorded: true as const, + handedOverAt: NOW + } + const queued = { ...submission('msg-2', 'pending'), handoverRecorded: true as const } + const markers = markersAtTeardown({ + sessions: new Map([ + [ + SESSION, + { + journal: journal([turnItem('turn-0', 'completed')], false, [handedOver, queued]), + child: { fence: 1 } + } + ], + [ + 'session-running', + { + journal: journal([turnItem('turn-1', 'running')], false, [queued]), + child: { fence: 1 } + } + ] + ]), + getRecord: () => claudeRecord(null), + backgroundTasks: () => undefined, + trigger: 'quit', + teardownId: TEARDOWN_CURRENT, + now: NOW + }) + + expect(markers.map((entry) => entry.work)).toEqual([ + { kind: 'submission', id: 'msg-1' }, + { kind: 'turn', id: 'turn-1' } + ]) + }) + // Once a turn exists it is the better identity: it is what eviction rewrites, so it is what the // journal can be asked about at launch. it('prefers the running turn over the send that opened it', () => { @@ -335,7 +398,7 @@ describe('deriving what was working at teardown', () => { journal: journal([turnItem('turn-1', 'running')], false, [ submission('msg-1', 'accepted') ]), - hasProviderChild: true + child: { fence: 1 } } ] ]), @@ -390,7 +453,7 @@ describe('the resumable set', () => { getRecord: () => claudeRecord('5aed93d6-advanced-leaf'), supportsRecord: () => true, latestPrompt: () => '', - latestUserItemId: () => null + movedOn: () => false }) expect(candidates).toHaveLength(1) @@ -405,14 +468,14 @@ describe('the resumable set', () => { getRecord: () => claudeRecord(null, 'prov-session-2'), supportsRecord: () => true, latestPrompt: () => '', - latestUserItemId: () => null + movedOn: () => false }) expect(set.candidates).toEqual([]) expect(set.superseded).toEqual([forked]) }) - // An offer has no expiry: it ends only by the user's own actions, however old it is. + // An offer has no expiry, however old it is. it('still offers a months-old marker', () => { const monthsAgo = NOW - 90 * 24 * 60 * 60 * 1000 expect(resumableSet({ markers: [marker({ recordedAt: monthsAgo })] }).candidates).toHaveLength( @@ -420,16 +483,6 @@ describe('the resumable set', () => { ) }) - // The user moving on is what withdraws an offer — and it is reported for DELETION, not merely - // filtered, so the record does not have to be re-filtered on every read forever. - it('withdraws and reports for deletion once the user has sent a newer message', () => { - const withdrawn = marker() - const set = resumableSet({ markers: [withdrawn], latestUserItemId: 'user-newer' }) - - expect(set.candidates).toEqual([]) - expect(set.superseded).toEqual([withdrawn]) - }) - // Structural refusals are NOT endings: a record this host cannot see right now must not delete // a durable offer the user still owns. it('does not report a structurally refused marker for deletion', () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts index 884fba78a4e..524d8943fac 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts @@ -95,7 +95,12 @@ async function restartWithPersistedTurn(): Promise { const host = createHost(store) expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) const body = hostTestMessage('persisted conversation') - await host.send(CALLER, { envelope: sendEnvelope(store, { body }), body }) + const sent = await host.send(CALLER, { envelope: sendEnvelope(store, { body }), body }) + if (!sent.ok) { + throw new Error('send was refused') + } + // Delivered, not just accepted: a message still queued at the restart was never a request. + await host.waitForSendSettlement(SESSION, sent.value.clientMessageId) await host.flushAllStreamedEvents() return createHost(await AgentSessionRecordStore.open({ directory, hostId: 'local' })) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts index cc40d483187..2ea9e6f57b7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { expect, it, vi } from 'vitest' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { attach, hostTestState } from './structured-agent-session-host-test-harness' @@ -30,7 +31,7 @@ it.each(['beginTeardown', 'captureBeforeStop', 'recordMarkers'] as const)( ) expect(warning.mock.calls.flat().map(String).join(' ')).not.toContain(failure.message) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - expect(() => host.journalSnapshot(SESSION)).toThrow('agent_session_ownership_unknown') + await expect(host.journalSnapshot(SESSION)).rejects.toThrow('agent_session_ownership_unknown') } finally { operation.mockRestore() warning.mockRestore() @@ -49,12 +50,14 @@ it.each(['approval', 'question', 'completed'])( } events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 1 }, - { kind: 'turn', turnId: 'working', state: 'running' } + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.flushStreamedEvents(SESSION) events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 2 }, - { kind: 'status', text: 'Provider is requesting approval' } + { kind: 'status', text: 'Provider is requesting approval' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 3 }, @@ -65,7 +68,7 @@ it.each(['approval', 'question', 'completed'])( kind: event === 'approval' ? 'approval' : 'question' } : { kind: 'turn', turnId: 'working', state: 'completed' }, - { lifecycle: true } + { lifecycle: true, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.flushAllStreamedEvents() const offered = await new AgentSessionRecoveryCapsule(root).list(NOW) @@ -90,7 +93,8 @@ it.each(['approval', 'question', 'completed'] as const)( } events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 1 }, - { kind: 'turn', turnId: 'working', state: 'running' } + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.flushStreamedEvents(SESSION) host.deps.adapter.closeSession = async () => { @@ -103,7 +107,8 @@ it.each(['approval', 'question', 'completed'] as const)( }, event === 'completed' ? { kind: 'turn', turnId: 'working', state: 'completed' } - : { ...pendingApproval().body, question: 'Which action?', kind: event } + : { ...pendingApproval().body, question: 'Which action?', kind: event }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) return true } @@ -123,7 +128,8 @@ it('marks a settled chat whose subagent was still running', async () => { } events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'settled', ordinal: 1 }, - { kind: 'turn', turnId: 'settled', state: 'completed' } + { kind: 'turn', turnId: 'settled', state: 'completed' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.flushStreamedEvents(SESSION) host.deps.adapter.backgroundTaskState = () => ({ @@ -153,7 +159,8 @@ it('offers nothing for a chat whose child was not proven stopped', async () => { } events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 1 }, - { kind: 'turn', turnId: 'working', state: 'running' } + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.flushStreamedEvents(SESSION) host.deps.adapter.closeSession = async () => false diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resume-eligibility.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resume-eligibility.ts index 1b054018371..93a7962d4a2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-resume-eligibility.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resume-eligibility.ts @@ -7,17 +7,14 @@ // it answers "may this be resumed", not "should it be" — so it lives here now and the caller that // knows a surface is asking is the only one that acts on it. +import { agentSessionLeaseIsReleased } from '../../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { randomUUID } from 'node:crypto' import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { attachParamsForRecord } from './structured-agent-session-read-restore' +import { attachParamsForRecord } from './structured-agent-session-conversation-open' export function isResumableStructuredAgentSessionRecord(record: AgentSessionRecord): boolean { - return ( - !record.lease.unreconciled && - record.lease.claimStatus === 'released' && - record.lease.handoffStage === null - ) + return agentSessionLeaseIsReleased(record.lease) } /** Attach params for a resume, or null when this record's lease is somebody else's problem. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts index 291a6e0c53e..49a116ec7f7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts @@ -3,8 +3,8 @@ * * The reveal path is the only way an Agent Session History row reaches a chat whose tab this * process never published — a chat closed cleanly, or one this process has not opened since - * launch. It is exercised here through the readable restorer rather than the whole host, because - * what it must get right is which records it accepts and what it does when the journal is gone. + * launch. It opens the conversation through the host's accessor and starts no agent; what it must + * get right is which records it accepts and what it answers when the journal cannot be opened. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -13,8 +13,6 @@ import { agentSessionLeaseFixture, agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' -import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' -import * as readRestore from './structured-agent-session-read-restore' import * as providerSupport from './structured-agent-session-provider-support' import { revealStructuredAgentSession } from './structured-agent-session-reveal' @@ -35,114 +33,10 @@ function recordFor(provider: 'claude' | 'codex', sessionId: string): AgentSessio } } -function harness( - records: AgentSessionRecord[], - options: { supports?: (record: AgentSessionRecord) => boolean } = {} -) { - const live = new Map() - const serializedIds: string[] = [] - const serialize = (sessionId: string, task: () => Promise): Promise => { - serializedIds.push(sessionId) - return task() - } - const restorer = new StructuredAgentSessionReadableRestorer({ - store: { - getRecord: (sessionId: string) => records.find((r) => r.sessionId === sessionId) ?? null, - listRecords: () => records - } as never, - journalRoot: '/journals', - supportsRecord: options.supports ?? (() => true), - reconcile: async () => null, - resolveRecovery: async () => undefined, - serialize, - hasSession: (sessionId) => live.has(sessionId), - onReadable: (sessionId, restored) => live.set(sessionId, restored), - retrySettlement: async () => true - }) - return { restorer, live, serializedIds } -} - -const readable = { journal: {}, params: {}, fence: 1 } as never - afterEach(() => { vi.restoreAllMocks() }) -describe('revealing one structured session on demand', () => { - beforeEach(() => { - vi.spyOn(readRestore, 'restoreStructuredAgentSessionRead').mockResolvedValue(readable) - }) - - it.each(['claude', 'codex'] as const)('restores a persisted %s chat', async (provider) => { - const sessionId = `session-${provider}` - const { restorer, live } = harness([recordFor(provider, sessionId)]) - - await expect(restorer.restoreOne(sessionId)).resolves.toBe(true) - expect(live.has(sessionId)).toBe(true) - }) - - it('does not need the startup sweep to have run, or run it', async () => { - // The whole point: `restore()` is latched to once per process, and a surface asking later must - // not be answered from that latch — nor trip it, which would skip every other record. - const records = [recordFor('codex', 'session-asked'), recordFor('claude', 'session-untouched')] - const { restorer, live } = harness(records) - - await restorer.restoreOne('session-asked') - - expect(live.has('session-asked')).toBe(true) - expect(live.has('session-untouched')).toBe(false) - }) - - it('serializes against the session it restores', async () => { - const { restorer, serializedIds } = harness([recordFor('claude', 'session-serialized')]) - - await restorer.restoreOne('session-serialized') - - // Ordering against close/eviction is the task queue's job, so the restore must be inside it. - expect(serializedIds).toEqual(['session-serialized']) - }) - - it('returns the live session instead of restoring over it', async () => { - const { restorer, live } = harness([recordFor('codex', 'session-live')]) - live.set('session-live', readable) - - await expect(restorer.restoreOne('session-live')).resolves.toBe(true) - expect(readRestore.restoreStructuredAgentSessionRead).not.toHaveBeenCalled() - }) - - it('refuses a record no adapter supports', async () => { - const { restorer } = harness([recordFor('codex', 'session-unsupported')], { - supports: () => false - }) - - await expect(restorer.restoreOne('session-unsupported')).resolves.toBe(false) - expect(readRestore.restoreStructuredAgentSessionRead).not.toHaveBeenCalled() - }) - - it('refuses a session this host holds no record for', async () => { - const { restorer } = harness([]) - - await expect(restorer.restoreOne('session-absent')).resolves.toBe(false) - }) -}) - -describe('a record whose journal cannot be read', () => { - it('reports not-readable without throwing, for either provider', async () => { - // A chat whose journal predates the SQLite store restores to nothing here. That is not a - // refusal: attach still recovers it, so the caller publishes the tab and lets the pane's hold - // finish the job. Throwing, or reporting success, would both be wrong. - vi.spyOn(readRestore, 'restoreStructuredAgentSessionRead').mockResolvedValue(null) - const { restorer, live } = harness([ - recordFor('claude', 'session-no-journal-claude'), - recordFor('codex', 'session-no-journal-codex') - ]) - - await expect(restorer.restoreOne('session-no-journal-claude')).resolves.toBe(false) - await expect(restorer.restoreOne('session-no-journal-codex')).resolves.toBe(false) - expect(live.size).toBe(0) - }) -}) - describe('the host answer a client acts on', () => { beforeEach(() => { // Eligibility is the router's call; these cases are about what the answer carries. @@ -154,39 +48,45 @@ describe('the host answer a client acts on', () => { return { ...base, location: { ...base.location, workspaceId } } } - it("answers with the record's own workspace and provider, never a caller's", async () => { - // The security property: a client sends only a session id, so the tab cannot be aimed at - // another workspace by asking for one. - const stored = record('claude', 'workspace-from-record') + it.each(['claude', 'codex'] as const)( + "answers a %s chat with the record's own workspace and provider, never a caller's", + async (provider) => { + // The security property: a client sends only a session id, so the tab cannot be aimed at + // another workspace by asking for one. + const stored = record(provider, 'workspace-from-record') + const open = vi.fn(async () => undefined) - await expect( - revealStructuredAgentSession( - { store: { getRecord: () => stored } as never, adapter: {} as never }, - 'session-answered', - () => true, - async () => true - ) - ).resolves.toEqual({ - sessionId: 'session-answered', - workspaceId: 'workspace-from-record', - agent: 'claude', - readable: true - }) - }) + await expect( + revealStructuredAgentSession( + { store: { getRecord: () => stored } as never, adapter: {} as never }, + 'session-answered', + open + ) + ).resolves.toEqual({ + sessionId: 'session-answered', + workspaceId: 'workspace-from-record', + agent: provider, + readable: true + }) + expect(open).toHaveBeenCalledExactlyOnceWith('session-answered') + } + ) - it('refuses a session this host holds no record for', async () => { + it('refuses a session this host holds no record for, opening nothing', async () => { + const open = vi.fn(async () => undefined) await expect( revealStructuredAgentSession( { store: { getRecord: () => null } as never, adapter: {} as never }, 'session-absent', - () => false, - async () => false + open ) ).rejects.toThrow('agent_session_identity_required') + expect(open).not.toHaveBeenCalled() }) it('refuses a record no adapter of this host supports', async () => { vi.mocked(providerSupport.adapterSupportsRecord).mockReturnValue(false) + const open = vi.fn(async () => undefined) await expect( revealStructuredAgentSession( @@ -195,14 +95,14 @@ describe('the host answer a client acts on', () => { adapter: {} as never }, 'session-answered', - () => false, - async () => false + open ) ).rejects.toThrow('structured_agent_session_unsupported') + expect(open).not.toHaveBeenCalled() }) - it('answers not-readable without refusing when the journal could not be restored', async () => { - // The tab is still worth publishing: attach recovers what read restore cannot. + it('answers not-readable without refusing when the journal could not be opened', async () => { + // The tab is still worth publishing: the chat shows the failure and keeps retrying the read. await expect( revealStructuredAgentSession( { @@ -210,26 +110,10 @@ describe('the host answer a client acts on', () => { adapter: {} as never }, 'session-answered', - () => false, - async () => false + async () => { + throw new Error('journal unreadable') + } ) ).resolves.toMatchObject({ readable: false, agent: 'codex' }) }) - - it('does not restore over a session that is already live', async () => { - const restoreReadable = vi.fn(async () => true) - - await expect( - revealStructuredAgentSession( - { - store: { getRecord: () => record('codex', 'workspace-1') } as never, - adapter: {} as never - }, - 'session-answered', - () => true, - restoreReadable - ) - ).resolves.toMatchObject({ readable: true }) - expect(restoreReadable).not.toHaveBeenCalled() - }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts index bc36df76df8..2a1899ab1cc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts @@ -6,39 +6,41 @@ // launch — is reachable in Agent Session History by id and by nothing else. This is the lookup that // turns that id back into something a client can publish. // -// It is deliberately the whole of what reveal does on the host. It takes no hold: a provider child -// exists because a surface asked for one, and the chat pane asks when it binds. And a journal it -// cannot read is not a refusal — a chat whose journal predates the SQLite store restores to nothing -// here, yet attach still recovers it, so the tab is worth publishing either way. +// It is deliberately the whole of what reveal does on the host. It starts no provider child: only +// a send does. And a journal it cannot open is not a refusal — the chat shows that failure with a +// Retry, so the tab is worth publishing either way. +import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { adapterSupportsRecord } from './structured-agent-session-provider-support' import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate' import type { StructuredAgentSessionHostDeps, - StructuredAgentSessionHostSession, StructuredAgentSessionReveal } from './structured-agent-session-host-types' -import { retryPendingStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' -/** Throws its refusal as the code itself, matching `resumeHeldStructuredAgentSession`. */ +/** Throws its refusal as the code itself. */ export async function revealStructuredAgentSession( deps: Pick, sessionId: string, - hasSession: (sessionId: string) => boolean, - restoreReadable: (sessionId: string) => Promise + openConversation: (sessionId: string) => Promise ): Promise { const record = deps.store.getRecord(sessionId) if (!record) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) } if (!adapterSupportsRecord(deps.adapter, record)) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) } // Lease state is not consulted on purpose: this neither claims the lease nor spawns a child, so a - // contested or reconciling chat still reveals and the hold that follows adjudicates it. Refusing + // contested or reconciling chat still reveals and the send that follows adjudicates it. Refusing // here would hide the one view of a session a user needs when its ownership is in doubt. - const readable = hasSession(sessionId) || (await restoreReadable(sessionId)) + const readable = await openConversation(sessionId).then( + () => true, + () => false + ) return { sessionId, // From the record, never from a caller: a client that knows only a session id must not be able @@ -49,41 +51,23 @@ export async function revealStructuredAgentSession( } } -/** - * The host's whole readable-restore surface: the startup sweep and the on-demand reveal. - * - * Bundled the way the lifetime collaborators are, because the two share the restorer - * and differ only in who is asking — startup, once, for everything; a surface, later, for one. - */ +/** The host's startup readable-restore sweep: reconcile, resolve, then open each chat's journal. */ export function createStructuredAgentSessionHostRestore( deps: StructuredAgentSessionHostDeps, - sessions: Map, - now: () => number, wiring: Omit< ConstructorParameters[0], - 'store' | 'journalRoot' | 'supportsRecord' | 'retrySettlement' + 'openDeps' | 'supportsRecord' > ): { restoreReadableSessions: (sessionIds?: readonly string[]) => Promise - revealSession: (sessionId: string) => Promise - /** One session, for a caller already inside its serialize. */ - restoreReadableUnderSerialize: (sessionId: string) => Promise } { const restorer = new StructuredAgentSessionReadableRestorer({ - store: deps.store, - journalRoot: deps.journalRoot, + openDeps: deps, supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record), - retrySettlement: (sessionId, params) => - retryPendingStructuredAgentSessionSettlement({ deps, sessions, sessionId, params, now }), ...wiring }) const gate = new StructuredAgentSessionRestartRestoreGate() return { - restoreReadableSessions: (sessionIds) => gate.run(() => restorer.restore(sessionIds)), - revealSession: (sessionId) => - revealStructuredAgentSession(deps, sessionId, wiring.hasSession, (id) => - restorer.restoreOne(id) - ), - restoreReadableUnderSerialize: (sessionId) => restorer.restoreOneUnderSerialize(sessionId) + restoreReadableSessions: (sessionIds) => gate.run(() => restorer.restore(sessionIds)) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts new file mode 100644 index 00000000000..d3a262d0511 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts @@ -0,0 +1,227 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +// A Codex rewind whose outcome only the provider can prove, found on a chat at rest: nothing on +// screen will start the agent that settles it, so the next send does, and its message is kept. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { + AgentSessionDispatchOutcome, + StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const caller = { callerKey: 'desktop' } +const KEPT = { provider: 'codex' as const, threadId: THREAD, turnId: 'kept', ordinal: 0 } + +let directory: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let sink: StructuredAgentSessionEventSink +let acquires = 0 +const rewind = vi.fn>() +const recoverRewind = vi.fn>() +const rewindSupport = vi.fn>() +const dispatch = vi.fn() + +function adapter(): StructuredAgentSessionAdapter { + return { + supportsCreate: (_location, agent) => agent === 'codex', + supportsLocation: () => true, + acquire: async (input) => { + acquires += 1 + sink = input.events! + return { + process: { + hostId: 'local', + pid: 4000 + acquires, + processStartTimeMs: HOST_TEST_NOW, + spawnToken: input.spawnToken + }, + acquisitionGeneration: `generation-${acquires}`, + link: { + linkId: `link-${acquires}`, + mintedAtFence: input.fence, + observedAt: HOST_TEST_NOW, + origin: acquires === 1 ? 'created' : 'resumed', + handle: { provider: 'codex', threadId: THREAD } + } + } + }, + dispatch, + cancelTurn: async () => ({ cancelled: false }), + answerPrompt: async () => {}, + setOption: async () => {}, + rewindSupport, + rewind, + recoverRewind, + releaseAcquisition: async () => true, + closeSession: async () => true + } +} + +function openHost(): StructuredAgentSessionHost { + return new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: directory, + claimKeyId: 'key', + now: () => HOST_TEST_NOW, + probeOwner: async () => ({ outcome: 'exit-observed' }), + idleSweep: { intervalMs: 3_600_000 } + }) +} + +beforeEach(async () => { + resetHostTestOperationIds() + acquires = 0 + rewind.mockReset() + recoverRewind.mockReset().mockResolvedValue({ + ok: true, + items: [{ identity: KEPT, body: hostTestMessage('verified history') }] + }) + rewindSupport.mockReset().mockReturnValue({ supported: true }) + dispatch.mockReset().mockImplementation(async (input): Promise => ({ + state: 'accepted', + providerIdentity: { + provider: 'codex', + threadId: THREAD, + turnId: input.clientMessageId, + ordinal: 1 + } + })) + directory = await mkdtemp(join(tmpdir(), 'orca-rewind-rest-')) + store = await AgentSessionRecordStore.open({ + directory: join(directory, 'store'), + hostId: 'local' + }) + host = openHost() +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(directory, { recursive: true, force: true }) +}) + +function fence(): number { + return store.getRecord(SESSION)!.lease.runtimeFence +} + +function rewindParams(itemId: string, expectedEpoch: string) { + return { + itemId, + expectedEpoch, + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: fence(), + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.rewind', + sessionId: SESSION, + fields: { itemId, expectedEpoch } + }) + } + } +} + +function sendParams(text: string) { + const body = hostTestMessage(text) + return { + body, + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: fence(), + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + } + } +} + +/** A chat whose rewind the provider applied, whose Orca side never finished, reopened at rest. */ +async function interruptedRewindAtRest(): Promise { + expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + const drop = { ...KEPT, turnId: 'drop' } + sink.appendItem(KEPT, hostTestMessage('verified history'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + sink.appendItem(drop, hostTestMessage('to be rewound'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + await host.flushStreamedEvents(SESSION) + rewind.mockImplementation(async () => { + throw new Error('history unavailable') + }) + const epoch = (await host.journalSnapshot(SESSION)).cursor.epoch + await expect(host.rewind(caller, rewindParams(agentJournalItemKey(drop), epoch))).rejects.toThrow( + 'history unavailable' + ) + expect(store.getRecord(SESSION)?.rewind).toMatchObject({ phase: 'prepared' }) + await host.flushAllStreamedEvents() + store = await AgentSessionRecordStore.open({ + directory: join(directory, 'store'), + hostId: 'local' + }) + host = openHost() +} + +describe('an interrupted Codex rewind on a chat at rest (R16)', () => { + it('is settled by the start a send makes, and the message is delivered after it', async () => { + await interruptedRewindAtRest() + expect(host.hasSession(SESSION)).toBe(false) + const before = acquires + + const sent = await host.send(caller, sendParams('after the rewind')) + expect(sent).toMatchObject({ ok: true }) + expect(acquires - before).toBe(1) + expect(recoverRewind).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.rewind?.phase).toBe('completed') + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + const snapshot = await host.journalSnapshot(SESSION) + const texts = snapshot.items.flatMap((item) => + item.body.kind === 'message' + ? item.body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])) + : [] + ) + // The recovered history, then the new message — nothing the rewind dropped, nothing lost. + expect(texts).toEqual(['verified history', 'after the rewind']) + expect(snapshot.submissions.at(-1)?.dispatchState).toBe('accepted') + }) +}) + +describe('a rewind asked of a chat at rest (P2-23)', () => { + it('starts the agent first and answers with what the provider says', async () => { + expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + sink.appendItem(KEPT, hostTestMessage('kept'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + await host.flushStreamedEvents(SESSION) + const epoch = (await host.journalSnapshot(SESSION)).cursor.epoch + await host.flushAllStreamedEvents() + store = await AgentSessionRecordStore.open({ + directory: join(directory, 'store'), + hostId: 'local' + }) + host = openHost() + const before = acquires + rewindSupport.mockReturnValue({ supported: false, reason: 'history-not-paginated' }) + + const result = await host.rewind(caller, rewindParams(agentJournalItemKey(KEPT), epoch)) + expect(acquires - before).toBe(1) + expect(result).toMatchObject({ ok: false, refusal: { rewindReason: 'history-not-paginated' } }) + expect(rewind).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index 19ae18dd5f4..22be823ad78 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -8,6 +9,7 @@ import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { nativeChatTurnMembership } from '../../../shared/native-chat-turn-membership' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { StructuredAgentSessionHost } from './structured-agent-session-host' import type { @@ -139,20 +141,26 @@ async function seed(acceptedSubmissions = false) { } }) ).toMatchObject({ ok: true }) + // Accepted into the conversation first; the delivery loop hands it over after. + await vi.waitFor(async () => + expect( + (await host.journalSnapshot(HOST_TEST_SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientOperationId + )?.dispatchState + ).toBe('accepted') + ) if (i === 1) { selectedItemId = agentJournalSubmissionKey(clientOperationId) } } else { - sink.appendItem(identity, body) + sink.appendItem(identity, body, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) } } await host.flushStreamedEvents(HOST_TEST_SESSION) return selectedItemId } -function params( - itemId: string, - expectedEpoch = host.journalSnapshot(HOST_TEST_SESSION).cursor.epoch -) { +async function params(itemId: string, epoch?: string) { + const expectedEpoch = epoch ?? (await host.journalSnapshot(HOST_TEST_SESSION)).cursor.epoch return { itemId, expectedEpoch, @@ -173,14 +181,14 @@ describe('host rewind', () => { it('resolves accepted codex user submissions to provider targets', async () => { const target = await seed(true) expect(target.startsWith('orca:')).toBe(true) - expect(await host.rewind(caller, params(target))).toMatchObject({ ok: true }) - expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(1) + expect(await host.rewind(caller, await params(target))).toMatchObject({ ok: true }) + expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(1) expect(rewind).toHaveBeenCalledWith(expect.objectContaining({ beforeTurnId: 'drop' })) }) it('finishes a durable provider success on reattach without repeating the provider mutation', async () => { const target = await seed() - const request = params(target) + const request = await params(target) const replace = vi .spyOn(AgentSessionJournal.prototype, 'replaceEpochItems') .mockRejectedValueOnce(new Error('disk failed')) @@ -189,7 +197,7 @@ describe('host rewind', () => { replace.mockRestore() const fence = store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence expect(await host.attach(caller, hostTestAttachParams(fence))).toMatchObject({ ok: true }) - expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(1) + expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(1) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true }) expect(rewind).toHaveBeenCalledTimes(1) @@ -197,17 +205,13 @@ describe('host rewind', () => { it('retries complete hydration after native acknowledgement without committing partial history', async () => { const target = await seed() - const before = host.journalSnapshot(HOST_TEST_SESSION) - rewind.mockImplementation(async (input) => { - await input.onReverted?.() + const before = await host.journalSnapshot(HOST_TEST_SESSION) + rewind.mockImplementation(async () => { throw new Error('history unavailable') }) - await expect(host.rewind(caller, params(target))).rejects.toThrow('history unavailable') - expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) - expect(store.getRecord(HOST_TEST_SESSION)?.rewind).toMatchObject({ - phase: 'prepared', - providerApplied: true - }) + await expect(host.rewind(caller, await params(target))).rejects.toThrow('history unavailable') + expect(await host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) + expect(store.getRecord(HOST_TEST_SESSION)?.rewind).toMatchObject({ phase: 'prepared' }) recoverRewind.mockRejectedValueOnce(new Error('history still unavailable')) await expect( host.attach( @@ -222,21 +226,15 @@ describe('host rewind', () => { hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ) ).toMatchObject({ ok: true }) - expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(1) - expect(host.journalSnapshot(HOST_TEST_SESSION).items[0]?.body).toEqual( + expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(1) + expect((await host.journalSnapshot(HOST_TEST_SESSION)).items[0]?.body).toEqual( hostTestMessage('verified history') ) expect(recoverRewind).toHaveBeenCalledTimes(2) expect(rewind).toHaveBeenCalledTimes(1) }) - it('fences stale owners and the second of two concurrent rewinds', async () => { + it('refuses the second of two concurrent rewinds by the epoch it targets', async () => { const target = await seed() - const stale = params(target) - stale.envelope.expectedRuntimeFence++ - expect(await host.rewind(caller, stale)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_checkpoint_stale' } - }) let finish!: () => void rewind.mockImplementation( () => @@ -244,9 +242,9 @@ describe('host rewind', () => { finish = () => resolve({ ok: true }) }) ) - const first = host.rewind(caller, params(target)) - const second = host.rewind(caller, params(target)) - await vi.waitFor(() => expect(finish).toBeTypeOf('function')) + const first = host.rewind(caller, await params(target)) + const second = host.rewind(caller, await params(target)) + await vi.waitFor(async () => expect(finish).toBeTypeOf('function')) finish() expect(await first).toMatchObject({ ok: true }) expect(await second).toMatchObject({ ok: false, refusal: { rewindReason: 'stale-epoch' } }) @@ -254,11 +252,13 @@ describe('host rewind', () => { }) it('replaces the epoch with the retained prefix and replays without another provider call', async () => { const target = await seed() - const request = params(target) + const request = await params(target) const result = await host.rewind(caller, request) expect(result).toMatchObject({ ok: true }) - expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(1) - expect(host.journalSnapshot(HOST_TEST_SESSION).cursor.epoch).not.toBe(request.expectedEpoch) + expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(1) + expect((await host.journalSnapshot(HOST_TEST_SESSION)).cursor.epoch).not.toBe( + request.expectedEpoch + ) expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true }) expect(rewind).toHaveBeenCalledTimes(1) }) @@ -266,9 +266,10 @@ describe('host rewind', () => { const target = await seed() sink.appendItem( { provider: 'orca', clientMessageId: 'active' }, - { kind: 'status', text: 'working', turnLifecycle: { turnId: 'active', state: 'running' } } + { kind: 'status', text: 'working', turnLifecycle: { turnId: 'active', state: 'running' } }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) - expect(await host.rewind(caller, params(target))).toMatchObject({ + expect(await host.rewind(caller, await params(target))).toMatchObject({ ok: false, refusal: { rewindReason: 'busy' } }) @@ -276,11 +277,11 @@ describe('host rewind', () => { }) it('refuses stale epochs and targets from another provider', async () => { const target = await seed() - expect(await host.rewind(caller, params(target, 'old-epoch'))).toMatchObject({ + expect(await host.rewind(caller, await params(target, 'old-epoch'))).toMatchObject({ ok: false, refusal: { rewindReason: 'stale-epoch' } }) - expect(await host.rewind(caller, params('claude:foreign'))).toMatchObject({ + expect(await host.rewind(caller, await params('claude:foreign'))).toMatchObject({ ok: false, refusal: { rewindReason: 'invalid-target' } }) @@ -288,18 +289,18 @@ describe('host rewind', () => { }) it('keeps a failed hydration epoch intact and blocks sends and duplicate rewind', async () => { const target = await seed() - const request = params(target) - const before = host.journalSnapshot(HOST_TEST_SESSION) + const request = await params(target) + const before = await host.journalSnapshot(HOST_TEST_SESSION) rewind.mockRejectedValue(new Error('hydration failed')) await expect(host.rewind(caller, request)).rejects.toThrow('hydration failed') - expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) + expect(await host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) expect(await host.rewind(caller, request)).toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_unknown' } }) const body = hostTestMessage('new prompt') const envelope = { - ...params(target).envelope, + ...(await params(target)).envelope, payloadFingerprint: computeAgentSessionPayloadFingerprint({ method: 'agentSession.send', sessionId: HOST_TEST_SESSION, @@ -324,9 +325,9 @@ describe('host rewind', () => { it('clears an unapplied prepared rewind after observing the target still present', async () => { const target = await seed() - const before = host.journalSnapshot(HOST_TEST_SESSION) + const before = await host.journalSnapshot(HOST_TEST_SESSION) rewind.mockRejectedValueOnce(new Error('read failed before revert')) - await expect(host.rewind(caller, params(target))).rejects.toThrow('read failed') + await expect(host.rewind(caller, await params(target))).rejects.toThrow('read failed') recoverRewind.mockResolvedValueOnce({ ok: false, reason: 'provider-refused' }) expect( await host.attach( @@ -334,9 +335,45 @@ describe('host rewind', () => { hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ) ).toMatchObject({ ok: true }) - expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) + expect(await host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('refused') - expect(await host.rewind(caller, params(target))).toMatchObject({ ok: true }) + expect(await host.rewind(caller, await params(target))).toMatchObject({ ok: true }) + }) + + // The journal is replaced only once the provider proves the revert, so both still hold the turn. + it('settles an acknowledged revert the provider did not keep, so the chat attaches and sends', async () => { + const target = await seed() + const before = await host.journalSnapshot(HOST_TEST_SESSION) + rewind.mockImplementationOnce(async () => { + throw new Error('history unavailable') + }) + await expect(host.rewind(caller, await params(target))).rejects.toThrow('history unavailable') + expect(store.getRecord(HOST_TEST_SESSION)?.rewind).toMatchObject({ phase: 'prepared' }) + recoverRewind.mockResolvedValueOnce({ ok: false, reason: 'provider-refused' }) + expect( + await host.attach( + caller, + hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) + ) + ).toMatchObject({ ok: true }) + expect(await host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) + expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('refused') + const body = hostTestMessage('after the refused rewind') + expect( + await host.send(caller, { + body, + envelope: { + sessionId: HOST_TEST_SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: HOST_TEST_SESSION, + fields: { body } + }) + } + }) + ).toMatchObject({ ok: true }) }) it('keeps host-stamped turn and goal rows through a Codex provider hydration', async () => { @@ -375,27 +412,43 @@ describe('host rewind', () => { completedAt: HOST_TEST_NOW - 4_000, durationMs: 5_000 } - sink.appendItem(message('kept'), hostTestMessage('kept')) - sink.appendItem(goalRow, goalBody) - sink.appendItem(turnRow('kept'), keptTurn) - sink.appendItem(message('drop'), hostTestMessage('drop')) - sink.appendItem(turnRow('drop'), { ...keptTurn, turnId: 'drop', durationMs: 1_000 }) - sink.appendItem(message('tip'), { ...hostTestMessage('tip'), role: 'assistant' }) + sink.appendItem(message('kept'), hostTestMessage('kept'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + sink.appendItem(goalRow, goalBody, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + sink.appendItem(turnRow('kept'), keptTurn, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + sink.appendItem(message('drop'), hostTestMessage('drop'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + sink.appendItem( + turnRow('drop'), + { ...keptTurn, turnId: 'drop', durationMs: 1_000 }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + sink.appendItem( + message('tip'), + { ...hostTestMessage('tip'), role: 'assistant' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) await host.flushStreamedEvents(HOST_TEST_SESSION) // The provider preflight knows only its own items, never the host's turn rows. const items = [{ identity: message('kept'), body: hostTestMessage('kept from provider') }] rewind.mockImplementationOnce(async (input) => { await input.onPrepared?.(items) - await input.onReverted?.() return { ok: true, items } }) - expect(await host.rewind(caller, params(agentJournalItemKey(message('drop'))))).toMatchObject({ + expect( + await host.rewind(caller, await params(agentJournalItemKey(message('drop')))) + ).toMatchObject({ ok: true }) expect( - host.journalSnapshot(HOST_TEST_SESSION).items.map(({ itemId, body }) => ({ itemId, body })) + (await host.journalSnapshot(HOST_TEST_SESSION)).items.map(({ itemId, body }) => ({ + itemId, + body + })) ).toEqual([ { itemId: agentJournalItemKey(message('kept')), body: hostTestMessage('kept from provider') }, { itemId: agentJournalItemKey(goalRow), body: goalBody }, @@ -404,6 +457,97 @@ describe('host rewind', () => { expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') }) + it('keeps each kept turn opened by the message that opened it, under its provider key', async () => { + expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + const message = (turnId: string, ordinal = 0) => ({ + provider: 'codex' as const, + threadId: HOST_TEST_THREAD, + turnId, + ordinal + }) + const turnRow = (turnId: string) => ({ + provider: 'legacy' as const, + agent: 'codex', + sessionId: HOST_TEST_SESSION, + recordId: `turn-lifecycle:${turnId}` + }) + for (const turnId of ['kept', 'drop']) { + const body = hostTestMessage(turnId) + const clientOperationId = hostTestOperationId() + vi.mocked(adapter.dispatch).mockResolvedValueOnce({ + state: 'accepted', + providerIdentity: message(turnId) + }) + expect( + await host.send(caller, { + body, + envelope: { + sessionId: HOST_TEST_SESSION, + clientOperationId, + expectedRuntimeFence: store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: HOST_TEST_SESSION, + fields: { body } + }) + } + }) + ).toMatchObject({ ok: true }) + await vi.waitFor(async () => + expect( + (await host.journalSnapshot(HOST_TEST_SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientOperationId + )?.dispatchState + ).toBe('accepted') + ) + // As the translator writes it: the turn names the submission that opened it. + sink.appendItem( + turnRow(turnId), + { + kind: 'turn', + turnId, + state: 'completed', + outcome: 'success', + userItemId: agentJournalSubmissionKey(clientOperationId), + startedAt: HOST_TEST_NOW - 2_000, + completedAt: HOST_TEST_NOW - 1_000 + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + sink.appendItem( + message(turnId, 1), + { ...hostTestMessage(`answer ${turnId}`), role: 'assistant' }, + { turnScope: { kind: 'turn', turnItemId: agentJournalItemKey(turnRow(turnId)) } } + ) + await host.flushStreamedEvents(HOST_TEST_SESSION) + } + const items = [ + { identity: message('kept'), body: hostTestMessage('kept') }, + { + identity: message('kept', 1), + body: { ...hostTestMessage('answer kept'), role: 'assistant' as const } + } + ] + rewind.mockImplementationOnce(async (input) => { + await input.onPrepared?.(items) + return { ok: true, items } + }) + const target = (await host.journalSnapshot(HOST_TEST_SESSION)).submissions.at(-1)! + expect( + await host.rewind(caller, await params(agentJournalSubmissionKey(target.clientMessageId))) + ).toMatchObject({ ok: true }) + + const rebuilt = await host.journalSnapshot(HOST_TEST_SESSION) + const membership = nativeChatTurnMembership( + rebuilt.items.flatMap(({ itemId, body }) => + body.kind === 'message' ? [{ id: itemId, role: body.role }] : [] + ), + rebuilt + ) + const opener = agentJournalItemKey(message('kept')) + expect(membership.turnKeys).toEqual([opener, opener]) + }) + it('keeps a host goal row when interrupted Codex rewind recovery rebuilds provider history', async () => { expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) const message = (turnId: string) => ({ @@ -425,19 +569,26 @@ describe('host rewind', () => { payload: { head: '{}', byteLength: 2, digest: '4'.repeat(64), truncated: false } } } - sink.appendItem(message('kept'), hostTestMessage('kept')) - sink.appendItem(goalRow, goalBody) - sink.appendItem(message('drop'), hostTestMessage('drop')) - sink.appendItem(message('tip'), { ...hostTestMessage('tip'), role: 'assistant' }) + sink.appendItem(message('kept'), hostTestMessage('kept'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + sink.appendItem(goalRow, goalBody, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + sink.appendItem(message('drop'), hostTestMessage('drop'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + sink.appendItem( + message('tip'), + { ...hostTestMessage('tip'), role: 'assistant' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) await host.flushStreamedEvents(HOST_TEST_SESSION) - rewind.mockImplementationOnce(async (input) => { - await input.onReverted?.() + rewind.mockImplementationOnce(async () => { throw new Error('lost after provider revert') }) - await expect(host.rewind(caller, params(agentJournalItemKey(message('drop'))))).rejects.toThrow( - 'lost after provider revert' - ) + await expect( + host.rewind(caller, await params(agentJournalItemKey(message('drop')))) + ).rejects.toThrow('lost after provider revert') recoverRewind.mockResolvedValueOnce({ ok: true, items: [{ identity: message('kept'), body: hostTestMessage('kept from recovery') }] @@ -450,7 +601,10 @@ describe('host rewind', () => { ).toMatchObject({ ok: true }) expect( - host.journalSnapshot(HOST_TEST_SESSION).items.map(({ itemId, body }) => ({ itemId, body })) + (await host.journalSnapshot(HOST_TEST_SESSION)).items.map(({ itemId, body }) => ({ + itemId, + body + })) ).toEqual([ { itemId: agentJournalItemKey(message('kept')), body: hostTestMessage('kept from recovery') }, { itemId: agentJournalItemKey(goalRow), body: goalBody } @@ -466,10 +620,9 @@ describe('host rewind', () => { })) rewind.mockImplementationOnce(async (input) => { await input.onPrepared?.(items) - await input.onReverted?.() throw new Error('lost after revert') }) - await expect(host.rewind(caller, params(target))).rejects.toThrow('lost after revert') + await expect(host.rewind(caller, await params(target))).rejects.toThrow('lost after revert') recoverRewind.mockResolvedValueOnce({ ok: true, items }) expect( await host.attach( @@ -477,7 +630,7 @@ describe('host rewind', () => { hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ) ).toMatchObject({ ok: true }) - expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(2) + expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(2) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') }) @@ -485,7 +638,7 @@ describe('host rewind', () => { 'never commits a recovered prefix that omits an expected retained %s', async (missing) => { const target = await seed() - const before = host.journalSnapshot(HOST_TEST_SESSION) + const before = await host.journalSnapshot(HOST_TEST_SESSION) const items = [0, 1].map((ordinal) => ({ identity: { provider: 'codex' as const, @@ -499,7 +652,7 @@ describe('host rewind', () => { await input.onPrepared?.(items) throw new Error('reply lost') }) - await expect(host.rewind(caller, params(target))).rejects.toThrow('reply lost') + await expect(host.rewind(caller, await params(target))).rejects.toThrow('reply lost') recoverRewind.mockResolvedValueOnce({ ok: true, items: missing === 'turn' ? [] : items.slice(0, 1) @@ -520,7 +673,7 @@ describe('host rewind', () => { it('settles the existing epoch after a crash between journal commit and record completion', async () => { const target = await seed() - const request = params(target) + const request = await params(target) const transition = store.transitionHandoff.bind(store) const checkpoint = vi .spyOn(store, 'transitionHandoff') @@ -534,7 +687,7 @@ describe('host rewind', () => { }) ) await expect(host.rewind(caller, request)).rejects.toThrow('completion write failed') - const committed = host.journalSnapshot(HOST_TEST_SESSION) + const committed = await host.journalSnapshot(HOST_TEST_SESSION) expect(committed.cursor.epoch).not.toBe(request.expectedEpoch) checkpoint.mockRestore() const replace = vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems') @@ -544,7 +697,7 @@ describe('host rewind', () => { hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ) ).toMatchObject({ ok: true }) - expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(committed) + expect(await host.journalSnapshot(HOST_TEST_SESSION)).toEqual(committed) expect(replace).not.toHaveBeenCalled() replace.mockRestore() expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts index baedbe7c4d1..e855ae97ab2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts @@ -1,4 +1,5 @@ import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { agentJournalLinkageFields } from '../../../shared/agent-session-journal-producer' import { agentJournalItemKey, agentJournalSubmissionKey, @@ -11,8 +12,10 @@ import type { AgentSessionRewindResult } from '../../../shared/agent-session-rewind' import type { AgentSessionMutationResult } from '../../../shared/agent-session-wire' +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from './agent-session-history-page-bounds' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import { openWithAgent } from './structured-agent-session-send-preparation' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission' @@ -35,6 +38,8 @@ export async function rewindStructuredAgentSession( adapter: context.deps.adapter, callerKey: caller.callerKey, envelope: params.envelope, + // Only the provider can do this, so an agent at rest is started first. + prepareSession: openWithAgent(context, params.envelope), journal: () => context.sessions.get(sessionId)?.journal, publish: (journal) => context.publish(sessionId, journal), flushStreamedEvents: context.flushStreamedEvents, @@ -117,10 +122,12 @@ export async function rewindStructuredAgentSession( } const retained = snapshot.items .slice(0, boundary) - .map(({ itemId, body, observedAt }) => ({ + .map(({ itemId, body, observedAt, turnScope, ...linkage }) => ({ itemId: providerKey(itemId), - body, - observedAt + body: withRenamedTurnOpener(body, providerKey), + observedAt, + ...(turnScope ? { turnScope } : {}), + ...agentJournalLinkageFields(linkage) })) if ( retained.length > 10_000 || @@ -162,12 +169,6 @@ export async function rewindStructuredAgentSession( } prepared = { ...prepared, retained } await persistRewindRecord(store, sessionId, ctx.fence, prepared) - }, - onReverted: async () => { - await persistRewindRecord(store, sessionId, ctx.fence, { - ...prepared, - providerApplied: true - }) } }) const fence = store.getRecord(sessionId)!.lease.runtimeFence @@ -226,3 +227,18 @@ export async function rewindStructuredAgentSession( : result }) } + +/** The new epoch keeps no submissions, so a sent message survives only under its provider key; the + * turn it opened must name it by that key too, or the turn anchors on nothing. */ +function withRenamedTurnOpener( + body: AgentJournalItemBody, + rename: (itemId: string) => string +): AgentJournalItemBody { + if (body.kind === 'turn' && body.userItemId !== undefined) { + return { ...body, userItemId: rename(body.userItemId) } + } + const lifecycle = body.kind === 'status' ? body.turnLifecycle : undefined + return body.kind === 'status' && lifecycle?.userItemId !== undefined + ? { ...body, turnLifecycle: { ...lifecycle, userItemId: rename(lifecycle.userItemId) } } + : body +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts new file mode 100644 index 00000000000..61696b573c0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionConversationCommandRecord } from '../../../shared/agent-session-conversation-command' +import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' +import { + openConversationForWrite, + structuredAgentSessionSendBlock +} from './structured-agent-session-send-preparation' + +function withCommand(command: AgentSessionConversationCommandRecord) { + return { ...agentSessionRecordFixture(), conversationCommand: command } +} + +const COMMAND = { operationId: 'operation-1', callerKey: 'client-1', runtimeFence: 7 } + +describe('a send refused by the conversation command it follows', () => { + // A clear's commit is its only durable write, so a record short of it never changed the chat. + it("lets a send follow a /clear that never committed, as an older build's record leaves one", () => { + expect( + structuredAgentSessionSendBlock( + withCommand({ + ...COMMAND, + command: 'clear', + state: 'unknown', + phase: 'prepared', + replacementSessionId: 'clear-replacement-1' + }) + ) + ).toBeNull() + }) + + it('says a committed /clear cleared the conversation', () => { + const blocked = structuredAgentSessionSendBlock( + withCommand({ + ...COMMAND, + command: 'clear', + state: 'completed', + phase: 'committed', + replacementSessionId: 'clear-replacement-1' + }) + ) + + expect(blocked?.refusal).toMatchObject({ + code: 'agent_session_operation_invalid', + details: { reason: 'conversationCleared' } + }) + }) + + it("lets a send follow an older build's unconfirmed /compact, whose child this host no longer runs", () => { + expect( + structuredAgentSessionSendBlock( + withCommand({ ...COMMAND, command: 'compact', state: 'unknown', phase: 'prepared' }) + ) + ).toBeNull() + }) + + it('lets a send follow a /clear whose new conversation failed to start', () => { + expect( + structuredAgentSessionSendBlock( + withCommand({ ...COMMAND, command: 'clear', state: 'completed', phase: 'committed' }) + ) + ).toBeNull() + }) +}) + +describe('a write whose conversation the host could not open', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + const ENVELOPE = { + sessionId: 'session-1', + clientOperationId: 'operation-1', + expectedRuntimeFence: 1, + payloadFingerprint: '' + } + + function refusedBy(error: unknown) { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + return openConversationForWrite(async () => { + throw error + }, ENVELOPE) + } + + it('says a corrupt history is final, in words and not the error', async () => { + const corrupt = Object.assign(new Error('/Users/me/journal.db: file is not a database'), { + code: 'ERR_SQLITE_ERROR', + errcode: 26 + }) + + expect(await refusedBy(corrupt)).toEqual({ + ok: false, + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' }, + message: 'Unable to load this chat.' + } + }) + }) + + it('says any other failed open can clear', async () => { + const denied = Object.assign(new Error('EACCES: permission denied, open /Users/me'), { + code: 'EACCES', + errno: -13 + }) + + expect(await refusedBy(denied)).toEqual({ + ok: false, + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' }, + message: "Orca couldn't open this chat's history right now. Try again." + } + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts index 0f75c9a3322..2494535e019 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts @@ -120,11 +120,16 @@ describe('structured send idempotency', () => { await performSend(context, input) const replay = await performSend(context, input) + // Acceptance records the one submission; the reused id answers with it and writes nothing. + // Handing it over is the delivery loop's, never a second accept's. expect(replay).toMatchObject({ ok: true, - value: { clientMessageId: 'shared-send-id', submission: { dispatchState: 'accepted' } } + value: { + clientMessageId: 'shared-send-id', + submission: { dispatchState: 'pending', handoverRecorded: true } + } }) - expect(dispatch).toHaveBeenCalledOnce() + expect(dispatch).not.toHaveBeenCalled() expect(journal.submissions()).toHaveLength(1) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts new file mode 100644 index 00000000000..630e3fa386e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts @@ -0,0 +1,159 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +// A send can be what opens a conversation this process has not read yet: a chat nobody has on +// screen after the app died, sent to from a phone or the CLI. Whatever that journal shows running +// belongs to a generation that is gone, so it is settled when the journal opens, not only when a +// new child starts: a start that then fails would leave the turn running for every reader. + +import { cp, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + adapter, + attach, + CALLER, + envelope, + hostTestState, + replaceHostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestMessage +} from './structured-agent-session-host-test-data' + +/** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ +function eventually(assertion: () => unknown): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +const relaunchedRoots: string[] = [] + +afterEach(async () => { + await Promise.all( + relaunchedRoots.splice(0).map((dir) => rm(dir, { recursive: true, force: true })) + ) +}) + +/** A host that dies mid-turn, relaunched over a copy of its files taken at the crash. */ +async function relaunchAfterCrashMidTurn( + probe: AgentSessionOwnerProbe, + { reconcile = true }: { reconcile?: boolean } = {} +) { + const dying = hostTestState() + await attach() + const events = dying.acquire.mock.calls[0]?.[0].events + if (!events) { + throw new Error('missing provider event sink') + } + events.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'crashed-turn', ordinal: 1 }, + { kind: 'turn', turnId: 'crashed-turn', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await dying.host.flushStreamedEvents(SESSION) + // An empty renewal queues behind every record write, so they are on disk. + await dying.store.renewLeases([]) + const relaunched = `${dying.root}-relaunched` + relaunchedRoots.push(relaunched) + // A dead process holds no lock. + await cp(dying.root, relaunched, { + recursive: true, + filter: (source) => !source.includes('.lock') + }) + const store = await AgentSessionRecordStore.open({ + directory: join(relaunched, 'store'), + hostId: 'local' + }) + const acquire = vi.fn(async () => { + throw new Error('claude: command not found') + }) + const host = new StructuredAgentSessionHost({ + store, + adapter: { ...adapter(), acquire }, + journalRoot: relaunched, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-next', + probeOwner: async () => probe, + now: () => NOW + }) + if (reconcile) { + await host.reconcileRestartLeases() + } + replaceHostTestState({ store, host }) + return { host, acquire } +} + +async function turnStates(host: StructuredAgentSessionHost) { + return (await host.journalSnapshot(SESSION)).items + .flatMap((item) => readAgentJournalTurn(item.body) ?? []) + .map((turn) => turn.state) +} + +// A host that cannot prove the old owner gone leaves its lease in recovery, still claimed, until +// the next acquire resolves it: the open is not that acquire, and the turn is no less gone. Only +// the proof decides whether it reads interrupted. +const PROBES = [ + ['the old owner is proven gone', { outcome: 'pid-absent' }, 'interrupted'], + [ + 'nothing proves the old owner gone', + { outcome: 'indeterminate', reason: 'This host cannot probe structured session owners.' }, + 'unverifiable' + ] +] as const satisfies readonly (readonly [string, AgentSessionOwnerProbe, string])[] + +it.each(PROBES)( + 'settles a turn a dead generation left running when a send opens the chat and its start fails, when %s', + async (_when, probe, settled) => { + const { host, acquire } = await relaunchAfterCrashMidTurn(probe) + expect(host.hasSession(SESSION)).toBe(false) + + const body = hostTestMessage('sent to a chat nobody has open') + const sendEnvelope = envelope('agentSession.send', { body }) + await expect(host.send(CALLER, { envelope: sendEnvelope, body })).resolves.toMatchObject({ + ok: true + }) + await eventually(async () => + expect( + (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === sendEnvelope.clientOperationId + ) + ).toMatchObject({ dispatchState: 'rejected' }) + ) + + expect(acquire).toHaveBeenCalledOnce() + expect(await turnStates(host)).toEqual([settled]) + await host.flushAllStreamedEvents() + } +) + +it.each(PROBES)( + 'settles the same turn when a reader opens the chat, when %s', + async (_when, probe, settled) => { + const { host } = await relaunchAfterCrashMidTurn(probe) + + await host.revealSession(SESSION) + + expect(await turnStates(host)).toEqual([settled]) + await host.flushAllStreamedEvents() + } +) + +// On desktop the chat on screen at relaunch reads before startup reconciles the leases: nothing +// has proved its owner gone yet, and the reconcile's proof then revises what the open settled. +it('settles it when a read reaches the chat before the startup reconcile, then revises it', async () => { + const { host } = await relaunchAfterCrashMidTurn({ outcome: 'pid-absent' }, { reconcile: false }) + expect(hostTestState().store.getRecord(SESSION)?.lease.claimStatus).toBe('live') + + await host.history({ sessionId: SESSION, direction: 'tail' }) + expect(await turnStates(host)).toEqual(['unverifiable']) + await host.reconcileRestartLeases() + await host.restoreReadableSessions([SESSION]) + + expect(await turnStates(host)).toEqual(['interrupted']) + await host.flushAllStreamedEvents() +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index f48af3b4d4b..259ad16c858 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -1,15 +1,19 @@ -// A send, or a hold, that finds the session's provider child gone, against the real host. +// A send, or a hold, that finds the session's provider child gone, against the real host. The +// send is accepted at once; its delivery restarts the child, or rejects it with the reason. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { agentSessionRefusalOperationState } from '../../../shared/agent-session-refusal-retry' -import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' +import type { + AgentSessionMutationEnvelope, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { CodexAppServerRequestError } from '../../codex/codex-app-server-request-error' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -21,8 +25,11 @@ import { } from './structured-agent-session-host-test-data' const CALLER = { callerKey: 'client-1' } -// Long enough that no release fires mid-test; whether one is pending is asserted directly. -const GRACE_MS = 60_000 + +/** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ +function eventually(assertion: () => void | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} let root: string let store: AgentSessionRecordStore @@ -75,7 +82,6 @@ beforeEach(async () => { journalRoot: root, claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, - releaseGraceMs: GRACE_MS, now: () => NOW, onEventSinkError: ({ error }) => hostErrors.push(error) }) @@ -102,15 +108,37 @@ function sendParams(text: string, operationId = hostTestOperationId()) { return { envelope, body } } -/** Every status row the chat shows, oldest first; none when the session is not even readable. */ -function journalStatuses(): string[] { - if (!host.hasSession(SESSION)) { - return [] - } - const history = host.history({ sessionId: SESSION, direction: 'tail' }) - return history.ok - ? history.page.items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) - : [] +/** Accepted at once, before any owner exists for it; answers the message id. */ +async function accept(params: ReturnType): Promise { + const result = await host.send(CALLER, params) + expect(result, JSON.stringify(result)).toMatchObject({ + ok: true, + replayed: false, + value: { submission: { dispatchState: 'pending', handoverRecorded: true } } + }) + return params.envelope.clientOperationId +} + +async function submission(clientMessageId: string) { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) +} + +/** The submission once delivery is done with it: handed over, or rejected unwritten. */ +async function settled(clientMessageId: string) { + await eventually(async () => { + const current = await submission(clientMessageId) + expect(current?.dispatchState !== 'pending' || current.handedOverAt !== undefined).toBe(true) + }) + return submission(clientMessageId) +} + +/** The failure rows a start the chat needed left, oldest first. */ +async function errorStatuses(): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] + ) } /** The child timed out or exited: its lease is handed back and the host holds no session. */ @@ -127,15 +155,14 @@ describe('a send with no live owner', () => { it('restarts the owner once and delivers against it', async () => { await loseOwner() - const result = await host.send(CALLER, sendParams('after the child died')) + await accept(sendParams('after the child died')) - expect(result).toMatchObject({ ok: true, replayed: false }) + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) expect(acquire).toHaveBeenCalledOnce() - expect(dispatch).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') }) - it('restarts the owner before the send is admitted, so the send is admitted once', async () => { + it('accepts the send before anything restarts, and the restart hands it over', async () => { await loseOwner() const order: string[] = [] const spawnChild = acquire.getMockImplementation()! @@ -149,21 +176,18 @@ describe('a send with no live owner', () => { return admit(args) }) - await expect(host.send(CALLER, sendParams('ensure first'))).resolves.toMatchObject({ - ok: true, - replayed: false - }) + await accept(sendParams('accept first')) + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(order).toEqual(['acquire', 'admit']) + expect(order).toEqual(['admit', 'acquire']) }) it('leaves a live owner alone', async () => { acquire.mockClear() - await expect(host.send(CALLER, sendParams('owner is live'))).resolves.toMatchObject({ - ok: true - }) + await accept(sendParams('owner is live')) + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) expect(acquire).not.toHaveBeenCalled() }) @@ -181,39 +205,14 @@ describe('a send with no live owner', () => { } })) - await host.send(CALLER, sendParams('into a cleared chat')) + await expect(host.send(CALLER, sendParams('into a cleared chat'))).resolves.toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) expect(acquire).not.toHaveBeenCalled() }) - it('renews the idle window on journal activity in an unheld session', async () => { - await loseOwner() - await expect(host.send(CALLER, sendParams('restart'))).resolves.toMatchObject({ ok: true }) - const arm = vi.spyOn(host['holds']['clock'], 'arm') - - await expect(host.send(CALLER, sendParams('more activity'))).resolves.toMatchObject({ - ok: true - }) - - expect(arm).toHaveBeenCalledWith(SESSION) - }) - - it('releases the restarted child on the usual clock only when no surface holds it', async () => { - await loseOwner() - await expect(host.send(CALLER, sendParams('nobody is watching'))).resolves.toMatchObject({ - ok: true - }) - expect(host['holds'].isReleasePending(SESSION)).toBe(true) - - await host.close(SESSION) - // A reading surface that does not itself restart the agent. - await host.hold(SESSION, 'desktop-chat:1', { resume: false }) - await expect(host.send(CALLER, sendParams('the chat is open'))).resolves.toMatchObject({ - ok: true - }) - expect(host['holds'].isReleasePending(SESSION)).toBe(false) - }) - it('restarts an owner that exited while the session stayed readable', async () => { const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 await host.handleAdapterEvent({ @@ -228,15 +227,15 @@ describe('a send with no live owner', () => { expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') acquire.mockClear() - await expect(host.send(CALLER, sendParams('after an exit'))).resolves.toMatchObject({ - ok: true - }) + await accept(sendParams('after an exit')) + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) expect(acquire).toHaveBeenCalledOnce() }) it('restarts nothing for a resend the journal already answers', async () => { const params = sendParams('sent once') - await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: false }) + await accept(params) + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) // The child died during startup: the lease is handed back, the fence moves, and the session // stays readable. The client resends against the new fence. await host.handleAdapterEvent({ @@ -265,12 +264,9 @@ describe('a send with no live owner', () => { expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') // Retry rotates the id: a genuinely new send restarts the owner once. - await expect(host.send(CALLER, sendParams('sent once'))).resolves.toMatchObject({ - ok: true, - replayed: false - }) + await accept(sendParams('sent once')) + await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(2)) expect(acquire).toHaveBeenCalledOnce() - expect(dispatch).toHaveBeenCalledTimes(2) }) it('restarts nothing for a send the ledger holds but the journal never saw', async () => { @@ -317,7 +313,7 @@ describe('a send with no live owner', () => { expect(dispatch).not.toHaveBeenCalled() }) - it('rebases a send that arrives after the restart has already claimed the lease', async () => { + it('accepts a send that arrives while a restart holds the queue, and hands both over in order', async () => { await loseOwner() const lostFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 let claimed = () => {} @@ -331,18 +327,22 @@ describe('a send with no live owner', () => { return spawnChild!(input) }) - const first = host.send(CALLER, sendParams('first')) + const first = await accept(sendParams('first')) await claim expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(lostFence + 1) + // Written against the lost owner's fence, which admits it: a send is a conversation write. const late = sendParams('second') late.envelope.expectedRuntimeFence = lostFence const second = host.send(CALLER, late) release() - expect(await first).toMatchObject({ ok: true }) expect(await second).toMatchObject({ ok: true }) + await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(2)) expect(acquire).toHaveBeenCalledOnce() - expect(dispatch).toHaveBeenCalledTimes(2) + expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([ + first, + late.envelope.clientOperationId + ]) }) it('shares one restart between concurrent sends', async () => { @@ -355,142 +355,151 @@ describe('a send with no live owner', () => { ]) expect(results.map((result) => result.ok)).toEqual([true, true, true]) + await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(3)) expect(acquire).toHaveBeenCalledOnce() - expect(dispatch).toHaveBeenCalledTimes(3) - }) - - it('shares one restart between a hold and a send that arrive in the same gap', async () => { - await loseOwner() - - const [held, sent] = await Promise.allSettled([ - host.hold(SESSION, 'desktop-chat:1'), - host.send(CALLER, sendParams('while the chat opens')) - ]) - - expect(held).toMatchObject({ status: 'fulfilled' }) - expect(sent).toMatchObject({ status: 'fulfilled', value: { ok: true } }) - expect(acquire).toHaveBeenCalledOnce() - expect(dispatch).toHaveBeenCalledOnce() - expect(host['holds'].isHeld(SESSION)).toBe(true) - expect(host['holds'].isReleasePending(SESSION)).toBe(false) }) it('replays into a closed session without spawning anything', async () => { const params = sendParams('sent once') - await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: false }) + await accept(params) + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) await loseOwner() await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true }) await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true }) - // The journal was made readable for the answer; the record's lease was left as it was. + // The conversation was opened for the answer; the record's lease was left as it was. expect(host.hasSession(SESSION)).toBe(true) expect(acquire).not.toHaveBeenCalled() expect(dispatch).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - expect(host['holds'].isReleasePending(SESSION)).toBe(false) }) - it("refuses with the restart's own cause, in the answer and in the chat", async () => { + it("rejects the accepted message with the restart's own cause, and says so in the chat once", async () => { await loseOwner() acquire.mockRejectedValue(new Error('Not signed in. Run codex login')) const params = sendParams('while signed out') + // The acquire's error is Orca's wrapper, not the provider's words: it goes to the log. No exit + // was observed, so the chat does not say the provider stopped. + const cause = "Codex couldn't restart. Send your message to try again." - const result = await host.send(CALLER, params) + const id = await accept(params) - expect(result).toEqual({ - ok: false, - refusal: { - code: 'agent_session_owner_restart_failed', - message: "Codex couldn't restart: Not signed in. Run codex login.", - // The failed attach proved its child gone: nothing runs for this session. - ownerVerdict: 'exited' - } + expect(await settled(id)).toMatchObject({ + dispatchState: 'rejected', + reason: cause, + rejection: { kind: 'restartFailed' } }) expect(dispatch).not.toHaveBeenCalled() - expect(hostErrors).not.toEqual([]) - expect(agentSessionRefusalOperationState('agent_session_owner_restart_failed')).toBe( - 'settled-rejected' - ) - // Refused before admission: the ledger holds nothing a resend would replay. - expect(store.getOperationRow(CALLER.callerKey, params.envelope.clientOperationId)).toBeNull() - // The same status row a failed start leaves, so the reason outlives the error strip. - expect(journalStatuses()).toEqual([ - 'The provider stopped before it finished starting: Not signed in. Run codex login.' - ]) + // Accepted, so the ledger answers a resend with the rejection rather than a second attempt. + expect( + store.getOperationRow(CALLER.callerKey, params.envelope.clientOperationId) + ).toMatchObject({ outcome: { status: 'succeeded' } }) + // One row, in the error tone, so the reason outlives the error strip. + expect(await errorStatuses()).toEqual([cause]) }) - it('restarts again for a Retry of the refused send, under its own id or a new one', async () => { + it("keeps Codex's own words behind a refused resume without saying the provider stopped", async () => { + await loseOwner() + const said = `no rollout found for thread id ${THREAD}` + acquire.mockRejectedValue( + new CodexAppServerRequestError('thread/resume', -32600, `thread/resume failed: ${said}`, said) + ) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + const id = await accept(sendParams('after the thread went away')) + + // The sentence names no cause and quotes nothing; Codex's words ride in the fact for Details. + const rejection = { + kind: 'restartFailed', + detail: { text: said, audience: 'person' }, + refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } } + } + expect(await settled(id)).toMatchObject({ + dispatchState: 'rejected', + reason: "Codex couldn't restart. Send your message to try again.", + rejection + }) + expect(await errorStatuses()).toEqual([ + "Codex couldn't restart. Send your message to try again." + ]) + // Orca's own text is logged once where the start failed. + expect(warn).toHaveBeenCalledWith( + '[agent-session] provider start failed:', + expect.objectContaining({ message: `thread/resume failed: ${said}` }) + ) + warn.mockRestore() + }) + + it('restarts again for a Retry under a new id, and replays a resend of the same id', async () => { await loseOwner() acquire.mockRejectedValue(new Error('Not signed in')) const params = sendParams('while signed out') - await expect(host.send(CALLER, params)).resolves.toMatchObject({ - ok: false, - refusal: { code: 'agent_session_owner_restart_failed' } - }) + await settled(await accept(params)) + expect(acquire).toHaveBeenCalledTimes(1) - // A client that resends the same id gets another attempt, and the chat no second row. + // A client that resends the same id gets the recorded rejection, and the chat no second row. await expect(host.send(CALLER, params)).resolves.toMatchObject({ - ok: false, - refusal: { code: 'agent_session_owner_restart_failed' } + ok: true, + replayed: true, + value: { submission: { dispatchState: 'rejected' } } + }) + expect(acquire).toHaveBeenCalledTimes(1) + expect(await errorStatuses()).toHaveLength(1) + + // The outbox's Retry rotates the id: a fresh attempt, with its own row. + expect(await settled(await accept(sendParams('while signed out')))).toMatchObject({ + dispatchState: 'rejected' }) expect(acquire).toHaveBeenCalledTimes(2) - expect(journalStatuses()).toHaveLength(1) - - // The outbox's Retry rotates the id: also a fresh attempt. - await expect(host.send(CALLER, sendParams('while signed out'))).resolves.toMatchObject({ - ok: false, - refusal: { code: 'agent_session_owner_restart_failed' } - }) - expect(acquire).toHaveBeenCalledTimes(3) + expect(await errorStatuses()).toHaveLength(2) expect(dispatch).not.toHaveBeenCalled() }) it('restarts and delivers a later send once the cause clears', async () => { await loseOwner() acquire.mockRejectedValueOnce(new Error('Not signed in')) - await expect(host.send(CALLER, sendParams('while signed out'))).resolves.toMatchObject({ - ok: false, - refusal: { code: 'agent_session_owner_restart_failed' } + expect(await settled(await accept(sendParams('while signed out')))).toMatchObject({ + dispatchState: 'rejected' }) // The user signed in; nothing about the failed attempt is remembered. - await expect(host.send(CALLER, sendParams('signed in now'))).resolves.toMatchObject({ - ok: true, - replayed: false - }) + await accept(sendParams('signed in now')) + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) expect(acquire).toHaveBeenCalledTimes(2) - expect(dispatch).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') }) it('suggests a new chat only when this host has nothing to restart the chat from', async () => { await loseOwner() acquire.mockRejectedValue(new Error('Not signed in')) - const failed = await host.send(CALLER, sendParams('restart fails')) - expect(failed).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_owner_restart_failed' } - }) - expect(failed.ok ? '' : failed.refusal.message).not.toMatch(/new chat/) + const failed = await settled(await accept(sendParams('restart fails'))) + expect(failed).toMatchObject({ dispatchState: 'rejected' }) + expect(failed?.reason).not.toMatch(/new chat/) // The adapter cannot run this record where it lives: no retry would bring it back. host.deps.adapter.supportsLocation = () => false - const unresumable = await host.send(CALLER, sendParams('cannot resume here')) + const unresumable = await settled(await accept(sendParams('cannot resume here'))) expect(unresumable).toMatchObject({ - ok: false, - refusal: { - code: 'agent_session_owner_restart_failed', - message: - "Codex couldn't restart: This execution host cannot resume the requested structured agent session. Start a new chat to continue." + dispatchState: 'rejected', + reason: "Codex couldn't restart. Start a new chat to continue.", + rejection: { + kind: 'restartFailed', + refusal: { + code: 'structured_agent_session_unsupported', + details: { reason: 'hostUnsupported' } + } } }) }) - it('runs the send as the lease stands when the restart met a lease someone else is settling', async () => { + it('rejects the message with the cause when the restart met a lease someone else is settling', async () => { await loseOwner() - vi.spyOn(host['holds'], 'ensureProviderChild').mockResolvedValueOnce({ + vi.spyOn( + host['conversationDelivery'].loop['deps'], + 'ensureProviderChild' + ).mockResolvedValueOnce({ ok: false, refusal: { code: 'execution_owner_reconciling', @@ -498,161 +507,52 @@ describe('a send with no live owner', () => { } }) - const result = await host.send(CALLER, sendParams('owner being settled')) + const id = await accept(sendParams('owner being settled')) - // The ordinary lease check answers, retryably; nothing terminal and nothing in the chat. - expect(result).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_ownership_unknown' } + // The refusal's prose stays out of the chat; its code rides in the fact. + const cause = "Codex couldn't restart. Send your message to try again." + expect(await settled(id)).toMatchObject({ + dispatchState: 'rejected', + reason: cause, + rejection: { kind: 'restartFailed', refusal: { code: 'execution_owner_reconciling' } } }) expect(acquire).not.toHaveBeenCalled() - expect(journalStatuses()).toEqual([]) + expect(await errorStatuses()).toEqual([cause]) }) - it('runs the send as the lease stands when the restart itself faults', async () => { + it('rejects the message, and reports the fault, when the restart itself faults', async () => { await loseOwner() - vi.spyOn(host['holds'], 'ensureProviderChild').mockRejectedValueOnce( - new Error('spawn-token mint failed') - ) + vi.spyOn( + host['conversationDelivery'].loop['deps'], + 'ensureProviderChild' + ).mockRejectedValueOnce(new Error('spawn-token mint failed')) - const result = await host.send(CALLER, sendParams('bookkeeping failed')) + const id = await accept(sendParams('bookkeeping failed')) - expect(result).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_ownership_unknown' } + // Orca's own fault: reported to the log, and the chat says only that Orca failed. + expect(await settled(id)).toMatchObject({ + dispatchState: 'rejected', + reason: "Orca ran into a problem, so this didn't go through. Try again.", + rejection: { kind: 'hostFault' } }) expect(hostErrors).toContainEqual( expect.objectContaining({ message: 'spawn-token mint failed' }) ) - expect(journalStatuses()).toEqual([]) + expect(await errorStatuses()).toHaveLength(1) }) - it('keeps a second surface holder taken during an auto-restart, and starts nothing for it', async () => { - await host.hold(SESSION, 'desktop-chat:1') - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - acquire.mockClear() - const entered = Promise.withResolvers() - const gate = Promise.withResolvers() - const spawnChild = acquire.getMockImplementation()! - acquire.mockImplementationOnce(async (input) => { - entered.resolve() - await gate.promise - return spawnChild(input) - }) - - // The held child exits: the host restarts it on its own, under the surface that holds it. - const restarted = host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: 'generation-1' - }) - await entered.promise - const second = host.hold(SESSION, 'paired-phone:1') - gate.resolve() - await Promise.all([restarted, second]) - - expect(acquire).toHaveBeenCalledOnce() - expect(host['holds']['holders'].holderIds(SESSION)).toEqual([ - 'desktop-chat:1', - 'paired-phone:1' - ]) - expect(host['holds'].isReleasePending(SESSION)).toBe(false) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') - }) - - it('puts the child of an auto-restart on the idle clock when its surface left mid-attach', async () => { - await host.hold(SESSION, 'desktop-chat:1') - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - acquire.mockClear() - const entered = Promise.withResolvers() - const gate = Promise.withResolvers() - const spawnChild = acquire.getMockImplementation()! - acquire.mockImplementationOnce(async (input) => { - entered.resolve() - await gate.promise - return spawnChild(input) - }) - - const restarted = host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: 'generation-1' - }) - await entered.promise - // The only surface leaves while the host is still spawning the child it asked for. - host.release(SESSION, 'desktop-chat:1') - gate.resolve() - await restarted - - expect(acquire).toHaveBeenCalledOnce() - expect(host['holds'].isHeld(SESSION)).toBe(false) - // Nobody holds the child, so it goes on the same clock a departed surface would start. - expect(host['holds'].isReleasePending(SESSION)).toBe(true) - expect(hostErrors).toEqual([]) - }) - - it('counts a queued restart as in flight from the moment it is asked for, so a quit drains it', async () => { - const closeSession = vi.mocked(host.deps.adapter.closeSession!) - acquire.mockClear() - const gate = Promise.withResolvers() - closeSession.mockImplementationOnce(async () => { - await gate.promise - return true - }) - const closing = host.close(SESSION) - const hold = host.hold(SESSION, 'desktop-chat:1') - let drained = false - void host['tasks'].drainAttaches().then(() => { - drained = true - }) - await new Promise((resolve) => setImmediate(resolve)) - - // The hold waits its turn behind the close, and the quit's drain waits for the hold: the - // child it is about to spawn must exist before the quit decides what to evict. - expect(acquire).not.toHaveBeenCalled() - expect(drained).toBe(false) - - gate.resolve() - await Promise.all([closing, hold]) - await new Promise((resolve) => setImmediate(resolve)) - expect(acquire).toHaveBeenCalledOnce() - expect(drained).toBe(true) - }) - - it('adjudicates a lease this host has not reconciled before a hold resumes it', async () => { - await loseOwner() - await store.transitionHandoff(SESSION, (current) => ({ - ...current, - lease: { ...current.lease, unreconciled: true } - })) - host.deps.probeOwner = async () => ({ outcome: 'pid-absent' }) - - await host.hold(SESSION, 'desktop-chat:1') - - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - unreconciled: false, - claimStatus: 'live' - }) - }) - - it('exits the recovery stage a failed attempt latched before a hold resumes', async () => { + it('exits the recovery stage a failed attempt latched before its delivery resumes it', async () => { await loseOwner() // What an acquisition whose exit could not be proven leaves behind: nobody's, but latched. await store.transitionHandoff(SESSION, (current) => ({ ...current, - lease: { ...current.lease, handoffStage: 'manual-recovery' } + lease: { ...current.lease, handoffStage: 'recovering' } })) host.deps.probeOwner = async () => ({ outcome: 'pid-absent' }) - await host.hold(SESSION, 'desktop-chat:1') + await accept(sendParams('latched owner')) + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) expect(acquire).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ handoffStage: null, @@ -660,19 +560,112 @@ describe('a send with no live owner', () => { }) }) - it('leaves a lease it cannot adjudicate alone', async () => { + it('adjudicates a lease this host has not reconciled before its delivery resumes it', async () => { await loseOwner() await store.transitionHandoff(SESSION, (current) => ({ ...current, lease: { ...current.lease, unreconciled: true } })) - const result = await host.send(CALLER, sendParams('owner unverifiable')) + // The send's start is the same serialized resume a hold runs, reconciliation first. + await accept(sendParams('owner unverified')) - expect(result).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_ownership_unknown' } + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) + expect(acquire).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + unreconciled: false, + claimStatus: 'live' }) - expect(acquire).not.toHaveBeenCalled() + }) +}) + +// A pane keeps the fence of the last frame it read. An idle release and the restart after it +// each move the lease, so that fence can be several generations behind the one a write lands on. +describe('a write fenced to an owner the pane has not seen replaced', () => { + it('delivers a send fenced to the owner an idle release retired', async () => { + const seenFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + await loseOwner() + const params = sendParams('after the release') + params.envelope.expectedRuntimeFence = seenFence + + const id = await accept(params) + expect(await settled(id)).toMatchObject({ dispatchState: 'accepted' }) + + expect(acquire).toHaveBeenCalledOnce() + expect(dispatch).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(seenFence + 1) + }) + + // Clients resend a refused message when the fence they hold moves. A failed start is a + // rejected message now, never a refused send, and the pane keeps the fence it subscribed under. + it("keeps the pane's fence on the rows a failed start publishes, and rejects the message", async () => { + const seenFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + const frames: AgentSessionSubscribeEvent[] = [] + await host.subscribe({ id: 'pane', sessionId: SESSION, emit: (event) => frames.push(event) }) + await loseOwner() + acquire.mockRejectedValueOnce(new Error('Not signed in')) + const subscribed = frames.length + + const id = await accept(sendParams('while signed out')) + + expect(await settled(id)).toMatchObject({ + dispatchState: 'rejected', + rejection: { kind: 'restartFailed' } + }) + expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(seenFence + 1) + const published = frames.slice(subscribed) + expect(published.length).toBeGreaterThan(0) + for (const frame of published) { + expect(frame).toMatchObject({ fence: seenFence }) + } + }) + + it('admits a Stop and a send queued behind the cold start that replaced their owner', async () => { + await loseOwner() + const lostFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + let claimed = () => {} + let release = () => {} + const claim = new Promise((resolve) => (claimed = resolve)) + const spawn = new Promise((resolve) => (release = resolve)) + acquire.mockImplementationOnce(async (input) => { + claimed() + await spawn + return spawnChild(input) + }) + + const firstParams = sendParams('starts the agent') + const first = host.send(CALLER, firstParams) + await claim + const cancelFields = { turnId: 'turn-1' } + const stop = host.cancel(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: lostFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: SESSION, + fields: cancelFields + }) + }, + ...cancelFields + }) + const late = sendParams('typed during the start') + late.envelope.expectedRuntimeFence = lostFence - 1 + const second = host.send(CALLER, late) + release() + + expect(await first).toMatchObject({ ok: true }) + expect(await stop).toMatchObject({ ok: true, replayed: false }) + expect(await second).toMatchObject({ ok: true, replayed: false }) + // The Stop withdrew the message its start held; the one typed after it is delivered. + expect(await settled(late.envelope.clientOperationId)).toMatchObject({ + dispatchState: 'accepted' + }) + expect(await submission(firstParams.envelope.clientOperationId)).toMatchObject({ + dispatchState: 'rejected' + }) + expect(acquire).toHaveBeenCalledOnce() + expect(dispatch).toHaveBeenCalledTimes(1) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index e4a332f7acf..59f265750d2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -1,75 +1,33 @@ -// What a send needs from the session before its lease is checked. -// -// A provider child that exits or fails to start hands its lease back. Before this, a send to that -// session was refused `agent_session_ownership_unknown` — which a client reads as "not admitted -// yet" and resends forever — and only a surface hold could ever make a new child. Now the send -// makes sure it has an owner as a step of its own serialized admission: a released lease where -// resume is allowed gets a child first; anything else runs as it is and meets the lease check. -// A restart that fails refuses with a code the client stops auto-retrying on, carrying the -// restart's own cause, and writes that cause into the chat the way a start that failed does, so -// the user sees why. A manual Retry or a new send is a fresh attempt: a refusal before admission -// leaves no ledger row behind. -// -// The ledger's answer comes first, so a send it already holds a row for restarts nothing: -// admission replays or refuses it whoever owns the session now, and a closed session is made -// readable for that, never given a child. Otherwise a child that dies at startup moves the fence, -// the client resends the same message against the new fence, and each replay spawns another -// child that dies the same way. -// -// Running inside the send's serialize is what makes "no child" exact and the fence bookkeeping -// simple: the owner this send (or a hold just ahead of it) replaced is the one the client was -// current as of, so the send is admitted at the fence the restart published. -// -// A child that has not proven its start is still the owner: the send is admitted against it and -// the adapter holds the message until startup lands, or rejects it with the child's own reason -// when the child dies first. The exit settlement writes that reason into the chat. +// What a send or a Stop needs from the session before the ledger places its row: the +// conversation open. Nothing here needs an owner — a send is accepted into the conversation and +// the delivery loop makes the session ready — so a refusal before acceptance is only one the +// conversation itself makes: a rewind in doubt, a cleared conversation, or a journal that cannot be +// opened. import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { - AgentSessionMutationEnvelope, - AgentSessionWireRefusal -} from '../../../shared/agent-session-wire' -import type { AgentSessionWireRefusalCode } from '../../../shared/agent-session-wire-refusals' -import { boundJournalStatusText } from '../agent-session-journal/journal-prompt-body-bounds' -import { TUI_AGENT_DISPLAY_NAMES } from '../../../shared/tui-agent-display-names' import { - ownerRestartFailedOutcome, - providerStartupFailureOutcome -} from './structured-agent-session-dead-generation-settlement' + refuse, + type AgentSessionMutationEnvelope, + type AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import { TUI_AGENT_DISPLAY_NAMES } from '../../../shared/tui-agent-display-names' +import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' +import { agentSessionWriteNoticeEnglish } from '../../../shared/agent-session-refusal-notice' +import { + classifyJournalOpenFailure, + type JournalOpenFailure +} from '../agent-session-journal/journal-open-failure' +import { + structuredAgentSessionAwaitedCommand, + type StructuredAgentSessionAwaitedCommandJournal +} from './structured-agent-session-command-turn' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' -import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' -import type { AgentSessionMutationSessionPreparation } from './structured-agent-session-mutation-admission' -import { isResumableStructuredAgentSessionRecord } from './structured-agent-session-resume-eligibility' +import { + AGENT_SESSION_NOT_ATTACHED, + type AgentSessionMutationSessionPreparation +} from './structured-agent-session-mutation-admission' import { rewindRefusal } from './structured-rewind-refusal' - -/** - * What a refused resume means for the send that ran it. `transient`: the resume met a lease - * someone else is settling, which is not proof it cannot resume — the send runs as the lease - * stands and admission reports it. `failed`: the restart itself failed; the send answers with the - * cause and stops the client's retry loop, and the user may clear the cause and retry. - * `unresumable`: this host has nothing to restart the chat from — no record, or none it can run — - * so only a new chat continues. A new wire code does not compile until it is classified here. - */ -const RESUME_REFUSAL_OUTCOME: Record< - AgentSessionWireRefusalCode, - 'transient' | 'failed' | 'unresumable' -> = { - execution_owner_reconciling: 'transient', - agent_session_conflict: 'transient', - agent_session_checkpoint_stale: 'transient', - agent_session_ownership_unknown: 'transient', - agent_session_operation_capacity: 'transient', - structured_agent_session_unsupported: 'unresumable', - agent_session_operation_conflict: 'failed', - agent_session_operation_expired: 'failed', - agent_session_operation_invalid: 'failed', - agent_session_operation_unknown: 'failed', - agent_session_item_revision_stale: 'failed', - agent_session_already_resolved: 'failed', - agent_session_identity_required: 'unresumable', - agent_session_journal_unreadable: 'failed', - agent_session_owner_restart_failed: 'failed' -} +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' /** Why the record refuses any send right now, whoever owns it; null when a send may run. */ export function structuredAgentSessionSendBlock( @@ -80,165 +38,94 @@ export function structuredAgentSessionSendBlock( return rewindRefusal('outcome-unknown') } const command = record?.conversationCommand + // Only a committed clear: one that never committed changed nothing, and an older build's + // unconfirmed record is one of those. if ( - command && - ((command.state === 'unknown' && command.phase === 'prepared') || - (command.command === 'clear' && command.replacementSessionId)) + command?.command === 'clear' && + command.phase === 'committed' && + command.replacementSessionId ) { return { ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: command.replacementSessionId - ? 'This conversation has been cleared. Use the current conversation.' - : 'The conversation operation is unconfirmed.' - } + refusal: refuse( + 'agent_session_operation_invalid', + { reason: 'conversationCleared' }, + 'This conversation has been cleared. Use the current conversation.' + ) } } return null } -/** Whether this send is the one that must bring the owner back: no child, a lease handed back - * cleanly, and nothing on the record that refuses the send anyway. Live, unverifiable, still - * reserved, or handed off: that lease is not this send's to replace. */ -export function structuredAgentSessionSendNeedsOwner( - session: StructuredAgentSessionHostSession | undefined, - record: AgentSessionRecord -): boolean { - return ( - session?.hasProviderChild !== true && - isResumableStructuredAgentSessionRecord(record) && - structuredAgentSessionSendBlock(record) === null - ) -} - -type SendPreparationContext = Pick< - StructuredAgentSessionMutationContext, - 'deps' | 'sessions' | 'holds' | 'restoreReadable' | 'publish' -> - -export async function prepareStructuredAgentSessionSend( - context: SendPreparationContext, - envelope: AgentSessionMutationEnvelope, - ledger: 'admit' | 'replay', - record: AgentSessionRecord -): Promise { - const { sessionId } = record - if (ledger !== 'admit') { - if (!context.sessions.has(sessionId)) { - await context.restoreReadable(sessionId) - } - return { ok: true, envelope } - } - if (structuredAgentSessionSendNeedsOwner(context.sessions.get(sessionId), record)) { - const refusal = await restartOwnerForSend(context, envelope, record) - if (refusal) { - return { ok: false, refusal } - } - } - return { ok: true, envelope: admitAtResumedFence(context.sessions.get(sessionId), envelope) } -} - -/** One restart attempt. Answers with the refusal that ends the send, or null when the send goes - * on to admission — after a child, after a transient refusal, or after a fault in the restart's - * own bookkeeping, which is reported and never gates the user's action. */ -async function restartOwnerForSend( - context: SendPreparationContext, - envelope: AgentSessionMutationEnvelope, - record: AgentSessionRecord -): Promise { - const { sessionId } = envelope - let resumed: Awaited> - try { - resumed = await context.holds.ensureProviderChild(sessionId) - } catch (error) { - context.deps.onEventSinkError?.({ sessionId, error }) - return null - } - const outcome = resumed.ok ? null : RESUME_REFUSAL_OUTCOME[resumed.refusal.code] - if (resumed.ok || outcome === 'transient') { - return null - } - const refusal = ownerRestartFailedRefusal(record, resumed.refusal, outcome !== 'unresumable') - context.deps.onEventSinkError?.({ - sessionId, - error: new Error(`${resumed.refusal.code}: ${resumed.refusal.message}`) - }) - // A restart whose child died starting leaves the row any start that died leaves, so the chat - // reads the same whether the send met that death before admission or after it. - await recordFailedRestart( - context, - envelope, - resumed.refusal.ownerVerdict === 'exited' - ? providerStartupFailureOutcome(resumed.refusal.message) - : refusal.message - ) - return refusal -} - -/** The client stops on the code; the message carries the restart's own cause, and the verdict — - * when the failed attach proved its child gone — tells a client nothing runs for the session. */ -function ownerRestartFailedRefusal( - record: AgentSessionRecord, - cause: AgentSessionWireRefusal, - resumable: boolean -): AgentSessionWireRefusal { - return { - code: 'agent_session_owner_restart_failed', - message: ownerRestartFailedOutcome({ - agentName: TUI_AGENT_DISPLAY_NAMES[record.provider], - reason: cause.message, - resumable - }), - ...(cause.ownerVerdict ? { ownerVerdict: cause.ownerVerdict } : {}) - } -} - -/** The same status row a start that failed leaves in the chat, so the reason outlives the error - * strip. The journal is made readable for it when the failed attach left none behind. Keyed by - * the send, not the clock: a resend of the same id that fails again adds no second row. */ -async function recordFailedRestart( - context: SendPreparationContext, - envelope: AgentSessionMutationEnvelope, - text: string -): Promise { - const { sessionId } = envelope - try { - if (!context.sessions.has(sessionId)) { - await context.restoreReadable(sessionId) - } - const session = context.sessions.get(sessionId) - if (!session) { - return - } - const settlementId = `failed-restart:${envelope.clientOperationId}` - await session.journal.appendLifecycleBatch({ - settlementId, - fence: session.fence, - recovered: true, - mutations: [ - { - kind: 'item', - identity: { provider: 'orca', clientMessageId: settlementId }, - body: { kind: 'status', text: boundJournalStatusText(text) } - } - ] - }) - context.publish(sessionId, session.journal) - } catch (error) { - context.deps.onEventSinkError?.({ sessionId, error }) - } -} - -/** A writer current as of the owner this child replaced is current now: the restart was the only - * thing that moved the fence, whether this send ran it or one just ahead of it did. */ -function admitAtResumedFence( - session: StructuredAgentSessionHostSession | undefined, +/** The conversation a send or a Stop writes to, opened when this host holds it closed. */ +export async function openConversationForWrite( + openConversation: (sessionId: string) => Promise, envelope: AgentSessionMutationEnvelope -): AgentSessionMutationEnvelope { - return session?.hasProviderChild && - session.resumedFromFence !== undefined && - envelope.expectedRuntimeFence === session.resumedFromFence - ? { ...envelope, expectedRuntimeFence: session.fence } - : envelope +): Promise { + try { + if (await openConversation(envelope.sessionId)) { + return { ok: true } + } + return { ok: false, refusal: AGENT_SESSION_NOT_ATTACHED } + } catch (error) { + console.warn('[agent-session] opening the conversation for a write failed:', error) + const reason = classifyJournalOpenFailure(error) + return { + ok: false, + refusal: refuse('agent_session_journal_unreadable', { reason }, JOURNAL_OPEN_MESSAGE[reason]) + } + } +} + +// Released clients print a refusal's message for a send; it fits a Stop too. +const JOURNAL_OPEN_MESSAGE: Record = { + journalCorrupt: agentSessionWriteNoticeEnglish(['historyUnusable']), + journalUnavailable: agentSessionWriteNoticeEnglish(['historyUnavailable', 'tryAgain']) +} + +/** The conversation a write lands in, opened when this host holds it closed. */ +export function openForWrite( + context: Pick, + envelope: AgentSessionMutationEnvelope +): () => Promise { + return () => openConversationForWrite(context.openConversation, envelope) +} + +/** For an operation only the provider can perform: the conversation, then its agent. */ +export function openWithAgent( + context: Pick, + envelope: AgentSessionMutationEnvelope +): () => Promise { + return async () => { + const opened = await openConversationForWrite(context.openConversation, envelope) + return opened.ok ? context.ensureAgent(envelope.sessionId) : opened + } +} + +/** A rewind still in doubt once the conversation is open is one only its provider can settle — + * the open settles every other — so a send starts the agent, whose attach recovers it. */ +export function sendPreparation( + context: Pick, + envelope: AgentSessionMutationEnvelope +): () => Promise { + return async () => { + const opened = await openConversationForWrite(context.openConversation, envelope) + const phase = context.deps.store.getRecord(envelope.sessionId)?.rewind?.phase + return opened.ok && (phase === 'prepared' || phase === 'provider-succeeded') + ? context.ensureAgent(envelope.sessionId) + : opened + } +} + +/** Who a failure sentence names: the chat's agent, when the record says; and, given the journal, + * the command a failed start leaves to run again. */ +export function structuredAgentSessionFailureWordsContext( + record: AgentSessionRecord | null, + journal?: StructuredAgentSessionAwaitedCommandJournal +): AgentSessionFailureWordsContext { + const command = journal && structuredAgentSessionAwaitedCommand(journal) + return { + ...(record ? { agentName: TUI_AGENT_DISPLAY_NAMES[record.provider] } : {}), + ...(command ? { command } : {}) + } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts index 181b40660f8..f339aa1113f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts @@ -1,17 +1,17 @@ // A session that published and then lost its child before startup (not signed in, say) keeps a // released lease and a chat the user can still type into. The send is the user asking for the -// child back: the host restarts it before admitting the write and delivers against the new owner, -// instead of parking the message behind a lease nothing would ever re-acquire. +// child back: the host accepts the message, and its delivery restarts the child and hands the +// message to the new owner, instead of parking it behind a lease nothing would ever re-acquire. // // A child is published before it has proven its start, and it owns the send from that moment: the -// message is admitted against it and the adapter holds it for the start. When the child exits -// first, the exit settlement rejects the message and writes the cause into the chat, once, and -// the next send is a fresh restart. +// message is handed to it. When the child exits first, the exit settlement rejects the message and +// writes the cause into the chat, once, and the next send is a fresh restart. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' @@ -22,12 +22,18 @@ import { HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD, hostTestAttachParams, + hostTestDrawnRowIds, hostTestMessage, hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' const CALLER = { callerKey: 'client-1' } + +/** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ +function eventually(assertion: () => void | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' let root: string @@ -53,7 +59,7 @@ function sendEnvelope( } } -/** A send is admitted against the child it meets, proven or not; the adapter holds the rest. */ +/** A send is accepted at once and handed to the child delivery finds or starts. */ async function send( text: string, fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 @@ -63,9 +69,13 @@ async function send( expect(sent, JSON.stringify(sent)).toMatchObject({ ok: true, replayed: false, - value: { submission: { dispatchState: 'pending' } } + value: { submission: { dispatchState: 'pending', handoverRecorded: true } } }) - return sent.ok ? sent.value.clientMessageId : '' + const clientMessageId = sent.ok ? sent.value.clientMessageId : '' + await eventually(async () => + expect((await submission(clientMessageId))?.handedOverAt).toBeDefined() + ) + return clientMessageId } /** The child of the current acquisition, as the adapter would identify it in a lifecycle event. */ @@ -91,21 +101,27 @@ function exitBeforeProof(): Promise { type: 'ended', ...currentChild(), reason: EXIT_REASON, + failure: { kind: 'providerExited', detail: { text: EXIT_REASON, audience: 'log' } }, cause: 'unexpected-exit', startupUnproven: true }) } -function journalStatuses(): string[] { - return host - .journalSnapshot(SESSION) - .items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) +const STARTUP_FAILURE = { + kind: 'providerStartFailed', + detail: { text: EXIT_REASON, audience: 'log' } } -function submission(clientMessageId: string) { - return host - .journalSnapshot(SESSION) - .submissions.find((entry) => entry.clientMessageId === clientMessageId) +async function journalStatuses(): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [item.body.text] : [] + ) +} + +async function submission(clientMessageId: string) { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) } beforeEach(async () => { @@ -166,8 +182,7 @@ describe('a send into a published session whose child ended before startup', () await send('hello again', releasedFence) - // The client was current as of the lost owner, so the send is rebased onto the fence the - // resume published and admitted once, with no stale round trip. + // Accepted at the lost owner's fence and handed to the child delivery started, once. expect(acquire).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') expect(dispatch).toHaveBeenCalledOnce() @@ -183,40 +198,50 @@ describe('a send into a published session whose child ended before startup', () it('retires the held message with the cause when the restarted child exits before proving its start', async () => { const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - const rowsBefore = journalStatuses().length + const rowsBefore = (await journalStatuses()).length const held = await send('still not signed in', releasedFence) await exitBeforeProof() // The child never proved its start, so it accepted nothing: the exit rejects the message this // host admitted, so nothing pins the session and Retry stays offered, and one row names the cause. - expect(submission(held)).toMatchObject({ + expect(await submission(held)).toMatchObject({ dispatchState: 'rejected', - reason: expect.stringContaining(EXIT_REASON), + reason: 'Codex stopped before it finished starting. Send your message to try again.', + rejection: STARTUP_FAILURE, recovered: true }) expect( - host.journalSnapshot(SESSION).submissions.filter((e) => e.dispatchState === 'pending') + (await host.journalSnapshot(SESSION)).submissions.filter((e) => e.dispatchState === 'pending') ).toEqual([]) - expect(journalStatuses().slice(rowsBefore)).toEqual([ - expect.stringMatching(/stopped before it finished starting: .*not signed in/) + expect((await journalStatuses()).slice(rowsBefore)).toEqual([ + 'Codex stopped before it finished starting. Send your message to try again.' ]) + // Accepted before the restart it needed, so the chat draws it above the row naming the cause. + const snapshot = await host.journalSnapshot(SESSION) + const causeRow = snapshot.items.findLast((item) => item.body.kind === 'status')?.itemId + const shown = [agentJournalSubmissionKey(held), causeRow] + expect( + hostTestDrawnRowIds(snapshot, [ + { clientMessageId: held, text: 'still not signed in' } + ]).filter((id) => shown.includes(id)) + ).toEqual(shown) // The failed restart moved the fence twice: the acquisition, and the exit that released it. expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(releasedFence + 2) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') // One spawn per user action: nothing restarted it a second time. expect(acquire).toHaveBeenCalledTimes(2) - // Retry is a fresh action: it restarts once and is admitted against the new child. + // Retry is a fresh action: it restarts once and is handed to the new child. await send('signed in now') expect(acquire).toHaveBeenCalledTimes(3) expect(dispatch).toHaveBeenCalledTimes(2) - expect(journalStatuses().slice(rowsBefore)).toHaveLength(1) + expect((await journalStatuses()).slice(rowsBefore)).toHaveLength(1) }) }) describe('a send while the child of the first start is still proving itself', () => { - it('is admitted against the starting child, and nothing restarts it', async () => { + it('is handed to the starting child, and nothing restarts it', async () => { await send('hello') expect(dispatch).toHaveBeenCalledOnce() @@ -225,7 +250,7 @@ describe('a send while the child of the first start is still proving itself', () await proveStarted() expect(acquire).toHaveBeenCalledOnce() - expect(journalStatuses()).toEqual([]) + expect(await journalStatuses()).toEqual([]) }) it('is retired with the cause when that child exits first, and restarts nothing', async () => { @@ -234,14 +259,15 @@ describe('a send while the child of the first start is still proving itself', () await exitBeforeProof() - expect(submission(held)).toMatchObject({ + expect(await submission(held)).toMatchObject({ dispatchState: 'rejected', - reason: expect.stringContaining(EXIT_REASON), + reason: 'Codex stopped before it finished starting. Send your message to try again.', + rejection: STARTUP_FAILURE, recovered: true }) expect(acquire).toHaveBeenCalledOnce() - expect(journalStatuses()).toEqual([ - expect.stringMatching(/stopped before it finished starting: .*not signed in/) + expect(await journalStatuses()).toEqual([ + 'Codex stopped before it finished starting. Send your message to try again.' ]) expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(fence + 1) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts index b461d508f42..677963ab3f9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts @@ -27,6 +27,74 @@ function emptyJournal(): AgentSessionJournal { } as unknown as AgentSessionJournal } +/** A message accepted and not yet handed over, while `activeTurnId` names the running turn. */ +function queuedJournal( + activeTurnId: string | null, + handedOver = false +): Pick { + return { + cursor: () => ({ epoch: 'epoch-1', sequence: handedOver ? 3 : 2 }), + activeTurnId: () => activeTurnId, + submissions: () => [ + { + clientMessageId: 'client-1', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null, + handoverRecorded: true, + ...(handedOver ? { handedOverAt: 3 } : {}) + } + ] + } +} + +describe('a wait that also ends behind a running command', () => { + const until = 'handed-over-or-behind-command' as const + + it('ends once the message waits behind a running command', async () => { + const settlements = new StructuredAgentSessionSendSettlement(() => queuedJournal('turn-1')) + const pending = settlements.wait('session-1', 'client-1', { until }) + + settlements.publish('session-1', queuedJournal('compact:cmd-1')) + + const settled = await pending + if (!settled || !('submission' in settled.value)) { + throw new Error('expected the submission arm') + } + expect(settled.value.submission.dispatchState).toBe('pending') + expect(settled.value.submission).not.toHaveProperty('handedOverAt') + }) + + it('keeps waiting for the handover behind anything that is not a command', async () => { + const settlements = new StructuredAgentSessionSendSettlement(() => queuedJournal(null)) + let settled = false + const pending = settlements.wait('session-1', 'client-1', { until }).then((result) => { + settled = true + return result + }) + + settlements.publish('session-1', queuedJournal('turn-1')) + await Promise.resolve() + expect(settled).toBe(false) + settlements.publish('session-1', queuedJournal('turn-1', true)) + + await expect(pending).resolves.toMatchObject({ value: { submission: { handedOverAt: 3 } } }) + }) + + it('leaves the plain handover wait to the handover', async () => { + const settlements = new StructuredAgentSessionSendSettlement(() => + queuedJournal('compact:cmd-1') + ) + const pending = settlements.wait('session-1', 'client-1', { until: 'handed-over', budgetMs: 1 }) + + await expect(pending).resolves.toBeUndefined() + }) +}) + describe('structured send settlement compatibility wait', () => { afterEach(() => vi.useRealTimers()) @@ -61,7 +129,7 @@ describe('structured send settlement compatibility wait', () => { it('removes an abandoned wait on transport cancellation', async () => { const settlements = new StructuredAgentSessionSendSettlement(() => journal('pending')) const controller = new AbortController() - const pending = settlements.wait('session-1', 'client-1', controller.signal) + const pending = settlements.wait('session-1', 'client-1', { signal: controller.signal }) controller.abort(new Error('transport closed')) await expect(pending).rejects.toThrow('transport closed') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts index 6150e3412a6..a9815b953f8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts @@ -3,7 +3,12 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionSendResult } from '../../../shared/agent-session-wire' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { isStructuredAgentSessionCommandTurnId } from './structured-agent-session-command-turn' + +/** What a settlement read needs of a journal. */ +type SendSettlementJournal = Pick type SettledSend = { cursor: AgentJournalCursor @@ -12,8 +17,20 @@ type SettledSend = { type SendSettlement = SettledSend | 'pending' | 'missing' +/** What ends a wait: the provider's answer, the host handing the message over, or either that or + * the message waiting behind a running command, which hands nothing over until it ends. */ +export type SendSettlementPoint = 'answered' | 'handed-over' | 'handed-over-or-behind-command' + +export type SendSettlementWaitOptions = { + signal?: AbortSignal + /** How long to observe; unanswered by then resolves undefined. */ + budgetMs?: number + until?: SendSettlementPoint +} + type SendSettlementWaiter = { clientMessageId: string + until: SendSettlementPoint resolve: (result: SettledSend | undefined) => void reject: (error: Error) => void timer: ReturnType @@ -23,12 +40,15 @@ type SendSettlementWaiter = { // Known legacy clients abandon the RPC after 15s without cancelling its socket dispatch. const SEND_SETTLEMENT_WAIT_TIMEOUT_MS = 30_000 +/** Long enough for a cold provider start; a longer one replays through the same wait. */ +export const STRUCTURED_AGENT_SESSION_START_WAIT_MS = 120_000 const MAX_SEND_SETTLEMENT_WAITERS_PER_SESSION = 64 const MAX_SEND_SETTLEMENT_WAITERS = 1_024 function settledSend( - journal: AgentSessionJournal, + journal: SendSettlementJournal, clientMessageId: string, + until: SendSettlementPoint, submission: AgentJournalSubmission | undefined = journal .submissions() .find((candidate) => candidate.clientMessageId === clientMessageId) @@ -36,9 +56,17 @@ function settledSend( if (!submission) { return 'missing' } - return submission.dispatchState === 'pending' - ? 'pending' - : { cursor: journal.cursor(), value: { clientMessageId, submission } } + const waiting = + until === 'answered' + ? submission.dispatchState === 'pending' + : isQueuedAgentJournalSubmission(submission) && + !(until === 'handed-over-or-behind-command' && runningCommand(journal)) + return waiting ? 'pending' : { cursor: journal.cursor(), value: { clientMessageId, submission } } +} + +function runningCommand(journal: SendSettlementJournal): boolean { + const turnId = journal.activeTurnId() + return turnId !== null && isStructuredAgentSessionCommandTurnId(turnId) } function abortError(signal: AbortSignal): Error { @@ -52,17 +80,19 @@ export class StructuredAgentSessionSendSettlement { private readonly waiters = new Map>() private waiterCount = 0 - constructor(private readonly journalFor: (sessionId: string) => AgentSessionJournal) {} + constructor(private readonly journalFor: (sessionId: string) => SendSettlementJournal) {} wait = ( sessionId: string, clientMessageId: string, - signal?: AbortSignal + options: SendSettlementWaitOptions = {} ): Promise => { + const { signal } = options + const until = options.until ?? 'answered' if (signal?.aborted) { return Promise.reject(abortError(signal)) } - const immediate = settledSend(this.journalFor(sessionId), clientMessageId) + const immediate = settledSend(this.journalFor(sessionId), clientMessageId, until) if (immediate === 'missing') { return Promise.reject(new Error('agent session send disappeared before settlement')) } @@ -79,12 +109,13 @@ export class StructuredAgentSessionSendSettlement { return new Promise((resolve, reject) => { const waiter: SendSettlementWaiter = { clientMessageId, + until, resolve, reject, timer: setTimeout(() => { this.remove(sessionId, waiter) resolve(undefined) - }, SEND_SETTLEMENT_WAIT_TIMEOUT_MS) + }, options.budgetMs ?? SEND_SETTLEMENT_WAIT_TIMEOUT_MS) } waiter.timer.unref?.() const session = existingSession ?? new Set() @@ -106,7 +137,7 @@ export class StructuredAgentSessionSendSettlement { }) } - publish(sessionId: string, journal: AgentSessionJournal): void { + publish(sessionId: string, journal: SendSettlementJournal): void { const waiters = this.waiters.get(sessionId) if (!waiters) { return @@ -118,6 +149,7 @@ export class StructuredAgentSessionSendSettlement { const result = settledSend( journal, waiter.clientMessageId, + waiter.until, submissions.get(waiter.clientMessageId) ) if (result !== 'pending') { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts index f2d1ab12230..f0163b22791 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts @@ -23,6 +23,8 @@ import { HOST_TEST_THREAD as THREAD, hostTestMessage } from './structured-agent-session-host-test-data' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' let store: AgentSessionRecordStore let host: StructuredAgentSessionHost @@ -32,6 +34,28 @@ beforeEach(() => { ;({ store, host, dispatch } = hostTestState()) }) +function hostJournal(): AgentSessionJournal { + return ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal +} + +/** A send is accepted, then the session's delivery loop hands it over: wait for the handover's + * outcome, or with `handedOver`, only for the handover itself (an admitted send stays pending). */ +async function delivered(clientMessageId: string, options: { handedOver?: true } = {}) { + let submission: ReturnType[number] | undefined + await vi.waitFor(() => { + submission = hostJournal() + .submissions() + .find((entry) => entry.clientMessageId === clientMessageId) + expect(submission?.handedOverAt).toBeDefined() + if (!options.handedOver) { + expect(submission?.dispatchState).not.toBe('pending') + } + }) + return submission! +} + describe('send', () => { it('writes the submission before dispatching and resolves it accepted', async () => { await attach() @@ -43,9 +67,20 @@ describe('send', () => { if (!result.ok) { throw new Error(`expected a send, got ${result.refusal.code}`) } - expect(result.value.submission.dispatchState).toBe('accepted') + if (!('submission' in result.value)) { + throw new Error('expected the submission arm') + } + // Answered once accepted; the delivery loop hands it over after. + expect(result.value.submission).toMatchObject({ + dispatchState: 'pending', + handoverRecorded: true + }) + expect(result.value.submission.handedOverAt).toBeUndefined() + await expect(delivered(result.value.clientMessageId)).resolves.toMatchObject({ + dispatchState: 'accepted' + }) expect(dispatch).toHaveBeenCalledTimes(1) - const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(page.ok && page.page.items).toHaveLength(1) expect(page.ok && page.page.fence).toBe(1) expect(page.page.hostNow).toBe(NOW) @@ -75,11 +110,11 @@ describe('send', () => { await attach() dispatch.mockRejectedValueOnce(new Error('socket closed')) const body = hostTestMessage('add a retry') - const result = await host.send(CALLER, { - envelope: envelope('agentSession.send', { body }), - body + const params = { envelope: envelope('agentSession.send', { body }), body } + await host.send(CALLER, params) + await expect(delivered(params.envelope.clientOperationId)).resolves.toMatchObject({ + dispatchState: 'unknown' }) - expect(result).toMatchObject({ ok: true, value: { submission: { dispatchState: 'unknown' } } }) }) it('replays a retried send from the journal without dispatching twice', async () => { @@ -87,6 +122,7 @@ describe('send', () => { const body = hostTestMessage('add a retry') const params = { envelope: envelope('agentSession.send', { body }), body } await host.send(CALLER, params) + await delivered(params.envelope.clientOperationId) const retry = await host.send(CALLER, params) expect(retry).toMatchObject({ ok: true, replayed: true }) expect(dispatch).toHaveBeenCalledTimes(1) @@ -98,10 +134,9 @@ describe('send', () => { const body = hostTestMessage('possibly delivered') const params = { envelope: envelope('agentSession.send', { body }), body } - const first = await host.send(CALLER, params) - expect(first).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'unknown' } } + await host.send(CALLER, params) + await expect(delivered(params.envelope.clientOperationId)).resolves.toMatchObject({ + dispatchState: 'unknown' }) // A thrown adapter call is indistinguishable from a lost reply, so Retry // replays the recorded outcome. @@ -110,7 +145,7 @@ describe('send', () => { value: { submission: { dispatchState: 'unknown' } } }) expect(dispatch).toHaveBeenCalledTimes(1) - const state = host.history({ sessionId: SESSION, direction: 'tail' }) + const state = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(state.ok && state.page.submissions).toHaveLength(1) }) @@ -129,6 +164,7 @@ describe('send', () => { const params = { envelope: envelope('agentSession.send', { body }), body } await host.send(CALLER, params) + await delivered(params.envelope.clientOperationId) await expect(host.send(CALLER, { ...params, retryUnknown: true })).resolves.toMatchObject({ ok: true, value: { @@ -136,7 +172,7 @@ describe('send', () => { } }) expect(dispatch).toHaveBeenCalledTimes(1) - const state = host.history({ sessionId: SESSION, direction: 'tail' }) + const state = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(state.ok && state.page.submissions).toHaveLength(1) }) @@ -145,30 +181,29 @@ describe('send', () => { dispatch .mockImplementationOnce(async () => ({ state: 'rejected' as const, - reason: 'provider_write_failed: broken pipe' + ...agentSessionFailureWords(agentSessionFailureFact('writeFailed'), { + surface: 'rejection' + }) })) .mockImplementationOnce(async () => accepted()) const body = hostTestMessage('never written') const params = { envelope: envelope('agentSession.send', { body }), body } - await expect(host.send(CALLER, params)).resolves.toMatchObject({ - ok: true, - value: { - submission: { dispatchState: 'rejected', reason: 'provider_write_failed: broken pipe' } - } + await host.send(CALLER, params) + await expect(delivered(params.envelope.clientOperationId)).resolves.toMatchObject({ + dispatchState: 'rejected', + reason: 'provider_write_failed' }) // What the user's Retry does with a rejection: a fresh client message id, // which is a first delivery by construction and cannot duplicate the frame // that never left the process. - await expect( - host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - ).resolves.toMatchObject({ - ok: true, - replayed: false, - value: { submission: { dispatchState: 'accepted' } } + const rotated = { envelope: envelope('agentSession.send', { body }), body } + await expect(host.send(CALLER, rotated)).resolves.toMatchObject({ ok: true, replayed: false }) + await expect(delivered(rotated.envelope.clientOperationId)).resolves.toMatchObject({ + dispatchState: 'accepted' }) expect(dispatch).toHaveBeenCalledTimes(2) - const state = host.history({ sessionId: SESSION, direction: 'tail' }) + const state = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(state.ok && state.page.submissions).toHaveLength(2) }) @@ -183,10 +218,9 @@ describe('send', () => { const body = hostTestMessage('a message the provider may already hold') const params = { envelope: envelope('agentSession.send', { body }), body } - const first = await host.send(CALLER, params) - expect(first).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'unknown' } } + await host.send(CALLER, params) + await expect(delivered(params.envelope.clientOperationId)).resolves.toMatchObject({ + dispatchState: 'unknown' }) // No `unknown` is re-delivered under its own id, whatever its reason says, // so Retry replays the recorded outcome instead of writing again. @@ -203,6 +237,7 @@ describe('send', () => { const body = hostTestMessage('settled for good') const params = { envelope: envelope('agentSession.send', { body }), body } await host.send(CALLER, params) + await delivered(params.envelope.clientOperationId) const journal = ( host as unknown as { sessions: Map } ).sessions.get(SESSION)!.journal @@ -223,7 +258,7 @@ describe('send', () => { expect(journal.receiptFor(params.envelope.clientOperationId)).not.toBeNull() }) - it('leaves an admitted send pending and writes no dispatch row', async () => { + it('leaves an admitted send pending once handed over', async () => { await attach() dispatch.mockImplementationOnce(async () => ({ state: 'admitted' as const })) const body = hostTestMessage('queued behind a running turn') @@ -233,9 +268,9 @@ describe('send', () => { ok: true, value: { submission: { dispatchState: 'pending', reason: null, resolvedAt: null } } }) - const journal = ( - host as unknown as { sessions: Map } - ).sessions.get(SESSION)!.journal + await delivered(params.envelope.clientOperationId, { handedOver: true }) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) + const journal = hostJournal() expect(journal.pendingSubmissions()).toHaveLength(1) }) @@ -245,6 +280,8 @@ describe('send', () => { const body = hostTestMessage('written, never acknowledged') const params = { envelope: envelope('agentSession.send', { body }), body } await host.send(CALLER, params) + await delivered(params.envelope.clientOperationId, { handedOver: true }) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) const journal = ( host as unknown as { sessions: Map } ).sessions.get(SESSION)!.journal @@ -281,6 +318,8 @@ describe('send', () => { const params = { envelope: envelope('agentSession.send', { body }), body } await expect(host.send(CALLER, params)).rejects.toThrow('operation settlement failed') + // The submission was recorded before the ledger write failed, so it is still delivered. + await delivered(params.envelope.clientOperationId) await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true, @@ -333,9 +372,8 @@ describe('send', () => { await expect(host.send(CALLER, params)).rejects.toThrow('operation settlement failed') settlement.mockRestore() - const journal = ( - host as unknown as { sessions: Map } - ).sessions.get(SESSION)!.journal + await delivered(params.envelope.clientOperationId) + const journal = hostJournal() await journal.rollEpoch('schema_unreadable', store.getRecord(SESSION)?.lease.runtimeFence ?? 1) expect(journal.submissions()).toHaveLength(0) @@ -362,6 +400,7 @@ describe('send', () => { const params = { envelope: envelope('agentSession.send', { body }), body } await host.send(CALLER, params) + await delivered(params.envelope.clientOperationId) expect(dispatch).toHaveBeenCalledTimes(1) await store.recordOperationOutcome({ callerKey: CALLER.callerKey, @@ -393,6 +432,8 @@ describe('send', () => { const body = hostTestMessage('written, then the child died') const params = { envelope: envelope('agentSession.send', { body }), body } await host.send(CALLER, params) + await delivered(params.envelope.clientOperationId, { handedOver: true }) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) const journal = ( host as unknown as { sessions: Map } ).sessions.get(SESSION)!.journal @@ -411,21 +452,25 @@ describe('send', () => { it('advances an explicit retry after a ledger-unknown send is reconciled in the journal', async () => { await attach() - const journal = ( - host as unknown as { sessions: Map } - ).sessions.get(SESSION)!.journal - vi.spyOn(journal, 'resolveDispatch').mockRejectedValueOnce(new Error('journal resolve failed')) + const journal = hostJournal() + const resolve = journal.resolveDispatch.bind(journal) + // The handover row lands; the provider's answer, written after the adapter took the + // message, does not. + vi.spyOn(journal, 'resolveDispatch') + .mockImplementationOnce(resolve) + .mockRejectedValueOnce(new Error('journal resolve failed')) const body = hostTestMessage('possibly delivered before persistence failed') const params = { envelope: envelope('agentSession.send', { body }), body } - await expect(host.send(CALLER, params)).rejects.toThrow('journal resolve failed') - expect(journal.submissions()).toMatchObject([ - { clientMessageId: params.envelope.clientOperationId, dispatchState: 'unknown' } - ]) + await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true }) + await expect(delivered(params.envelope.clientOperationId)).resolves.toMatchObject({ + dispatchState: 'unknown' + }) + // Acceptance is what the ledger answers for; delivery is the journal's to say. expect( store.listOperationRows().find((row) => row.operationId === params.envelope.clientOperationId) ?.outcome - ).toEqual({ status: 'unknown' }) + ).toMatchObject({ status: 'succeeded' }) expect(dispatch).toHaveBeenCalledTimes(1) await journal.markPendingSubmissionsUnknown(store.getRecord(SESSION)?.lease.runtimeFence ?? 1) @@ -446,24 +491,7 @@ describe('send', () => { expect(journal.submissions()).toHaveLength(1) }) - it('refuses a stale fence and hands back the current one', async () => { - const record = await attach() - const body = hostTestMessage('add a retry') - const result = await host.send(CALLER, { - envelope: envelope( - 'agentSession.send', - { body }, - { expectedRuntimeFence: (record?.lease.runtimeFence ?? 1) + 5 } - ), - body - }) - expect(result).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_checkpoint_stale', currentFence: record?.lease.runtimeFence } - }) - }) - - it('reuses a pending send admission after the client refreshes its fence', async () => { + it('admits a send fenced to another generation, delivers it once, and replays it by id', async () => { const record = await attach() const body = hostTestMessage('add a retry') const params = { @@ -474,27 +502,14 @@ describe('send', () => { ), body } - expect(await host.send(CALLER, params)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_checkpoint_stale' } + expect(await host.send(CALLER, params)).toMatchObject({ ok: true, replayed: false }) + await expect(delivered(params.envelope.clientOperationId)).resolves.toMatchObject({ + dispatchState: 'accepted' }) - expect( - store - .listOperationRows() - .filter((row) => row.operationId === params.envelope.clientOperationId) - ).toEqual([]) const retry = { ...params, - envelope: { - ...params.envelope, - expectedRuntimeFence: record?.lease.runtimeFence ?? 1 - } + envelope: { ...params.envelope, expectedRuntimeFence: record?.lease.runtimeFence ?? 1 } } - expect(await host.send(CALLER, retry)).toMatchObject({ - ok: true, - replayed: false, - value: { submission: { dispatchState: 'accepted' } } - }) expect(await host.send(CALLER, retry)).toMatchObject({ ok: true, replayed: true }) expect(dispatch).toHaveBeenCalledTimes(1) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts index 5780359a8ae..7602afa230b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -125,6 +125,11 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) +/** The host starting the agent with no message to deliver, as an operation that needs it does. */ +function startAgent(): Promise { + return host['serialize'](SESSION, () => host['mutationContext']().ensureAgent(SESSION)) +} + describe('settled attach retry', () => { it('settles a post-acquisition journal failure and retries without a restart', async () => { const historyFilePath = vi @@ -207,7 +212,7 @@ describe('settled attach retry', () => { expect(spawnTokens).toEqual(['spawn-safe', 'spawn-safe']) }) - it('fences a crash-interrupted reservation replay until positive recovery', async () => { + it('releases a reservation a crash left ownerless at restart, so the next start goes ahead', async () => { const spawnTokens: string[] = [] acquire.mockImplementation(async ({ fence, spawnToken }) => { spawnTokens.push(spawnToken) @@ -232,17 +237,17 @@ describe('settled attach retry', () => { }) let token = 0 const mintSpawnToken = vi.fn(() => `spawn-${++token}`) - let reservationUnused = false host = new StructuredAgentSessionHost({ store, adapter: adapter(), journalRoot: root, claimKeyId: 'key-1', mintSpawnToken, - probeOwner: async () => - reservationUnused - ? { outcome: 'reservation-unused' } - : { outcome: 'indeterminate', reason: 'spawn token scan unavailable' }, + // A host that cannot read another process's environment, so no scan can prove anything. + probeOwner: async () => ({ + outcome: 'indeterminate', + reason: 'spawn token scan unavailable' + }), now: () => NOW }) const params = hostTestAttachParams(null) @@ -268,36 +273,28 @@ describe('settled attach retry', () => { journalRoot: root, claimKeyId: 'key-1', mintSpawnToken, - probeOwner: async () => - reservationUnused - ? { outcome: 'reservation-unused' } - : { outcome: 'indeterminate', reason: 'spawn token scan unavailable' }, + // A host that cannot read another process's environment, so no scan can prove anything. + probeOwner: async () => ({ + outcome: 'indeterminate', + reason: 'spawn token scan unavailable' + }), now: () => NOW }) - const refused = await host.attach(CALLER, params) - if (refused.ok) { - throw new Error('expected the replayed reservation to stay fenced') - } - expect(refused.refusal.code).toBe('agent_session_ownership_unknown') - expect(acquire).toHaveBeenCalledTimes(1) + await host.restoreReadableSessions() expect(releaseAcquisition).toHaveBeenCalledTimes(1) - expect(mintSpawnToken).toHaveBeenCalledTimes(1) - expect(spawnTokens).toEqual(['spawn-1']) + // No owner was recorded: released at restart, with no evidence, since nothing proved one. expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'reserved', - handoffStage: 'manual-recovery', - runtimeFence: 1, - reservedSpawnToken: 'spawn-1', - ownerProcess: null + claimStatus: 'released', + handoffStage: null, + runtimeFence: 2, + reservedSpawnToken: null, + ownerProcess: null, + deathEvidence: null }) - expect( - store.listOperationRows().find((row) => row.operationId === params.envelope.clientOperationId) - ?.outcome - ).toEqual({ status: 'pending' }) - reservationUnused = true - await host.hold(SESSION, 'desktop-chat:retry') + // The interrupted operation's own retry continues it as a fresh reservation. + await expect(host.attach(CALLER, params)).resolves.toMatchObject({ ok: true }) expect(mintSpawnToken).toHaveBeenCalledTimes(2) expect(spawnTokens).toEqual(['spawn-1', 'spawn-2']) expect(store.getRecord(SESSION)?.lease).toMatchObject({ @@ -322,6 +319,8 @@ describe('settled attach retry', () => { } const first = await host.send(CALLER, unknownParams) expect(first).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) + // Handed over before the host dies: that is what makes the restart's answer doubt. + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) await host.flushAllStreamedEvents() store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) @@ -335,7 +334,7 @@ describe('settled attach retry', () => { now: () => NOW }) await host.restoreReadableSessions() - await host.hold(SESSION, 'desktop-chat:restart') + await startAgent() expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', handoffStage: null, @@ -351,8 +350,8 @@ describe('settled attach retry', () => { if (!sent.ok) { throw new Error(`unexpected restored send refusal: ${sent.refusal.message}`) } - expect(dispatch).toHaveBeenCalledTimes(2) - const restoredHistory = host.history({ sessionId: SESSION, direction: 'tail' }) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(2)) + const restoredHistory = await host.history({ sessionId: SESSION, direction: 'tail' }) if (!restoredHistory.ok) { throw new Error(`unexpected restored history reset: ${restoredHistory.reset}`) } @@ -384,7 +383,10 @@ describe('settled attach retry', () => { await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: false, - refusal: { message: 'resume rejected', ownerVerdict: 'exited' } + refusal: { + message: "Codex couldn't restart. Send your message to try again.", + ownerVerdict: 'exited' + } }) expect(releaseAcquisition).toHaveBeenCalledTimes(1) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.test.ts deleted file mode 100644 index c0d29f6b9ef..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -// The settlement latch governs EVERY unclean restart — SIGKILL, force quit, OOM, a quit that blew -// its deadline — so the evidence it reads decides whether the user sees a failure notice at all. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { - AgentSessionDeathEvidence, - AgentSessionRecord -} from '../../../shared/agent-session-record' -import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' -import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { StructuredAgentSessionLeaseStore } from './structured-agent-session-lease-release' -import { retryLoadedStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' - -const SESSION = 'session-alpha-1' -const THREAD = 'thread-1' -const FENCE = 8 - -let root: string -let journal: AgentSessionJournal -let record: AgentSessionRecord - -function store(): StructuredAgentSessionLeaseStore { - return { - getRecord: () => record, - transitionHandoff: async (_sessionId, transition) => { - record = transition(record) - return record - } - } -} - -function retry(settlementId: string, deathEvidence: AgentSessionDeathEvidence) { - record = agentSessionRecordFixture({ - ...agentSessionRecordFixture().lease, - runtimeKind: 'native', - runtimeFence: FENCE, - deathEvidence, - settlementRetryRequired: true, - settlementRetryId: settlementId - }) - return retryLoadedStructuredAgentSessionSettlement({ - deps: { store: store() }, - sessionId: SESSION, - session: { journal, fence: FENCE, acquisitionGeneration: null }, - now: () => 2_000 - }) -} - -function statusTexts(): string[] { - return journal - .snapshot() - .items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-settlement-retry-')) - journal = await openAgentSessionJournal({ - identity: { - sessionId: SESSION, - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } - }, - journalDir: root, - now: () => 1_000 - }) -}) - -/** A turn the dead generation left running: work to settle either way. */ -async function seedRunningTurn(): Promise { - await journal.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 }, - { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 900 }, - { fence: FENCE } - ) -} - -/** The provider died while the user, not the provider, held the conversation. */ -async function seedIdlePendingApproval(): Promise { - await journal.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 }, - { - kind: 'approval', - title: 'Run command?', - detail: null, - options: [{ id: 'yes', label: 'Allow' }], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - }, - { fence: FENCE } - ) -} - -afterEach(async () => { - await journal.close() - await rm(root, { recursive: true, force: true }) -}) - -describe('pending settlement retry', () => { - it('writes no status row when the death was only adjudicated, not witnessed', async () => { - await seedRunningTurn() - - await expect( - retry(`restart-eviction:${SESSION}:${FENCE}`, { - kind: 'pid-absent', - detail: 'recorded pid absent on host', - observedAt: 1_500 - }) - ).resolves.toBe(true) - - expect(statusTexts()).toEqual([]) - expect(journal.snapshot().items.map((item) => item.body)).toContainEqual( - expect.objectContaining({ kind: 'turn', state: 'unverifiable' }) - ) - expect(record.lease.settlementRetryRequired).toBeUndefined() - }) - - it('writes no status row for an identity mismatch either', async () => { - await seedRunningTurn() - - await expect( - retry(`restart-eviction:${SESSION}:${FENCE}`, { - kind: 'identity-mismatch', - detail: 'mismatched spawn-token', - observedAt: 1_500 - }) - ).resolves.toBe(true) - - expect(statusTexts()).toEqual([]) - }) - - it('reads the evidence, not the settlement id, when deciding to speak', async () => { - // Pins the discriminator: the id shape that normally accompanies a witnessed exit must not - // earn the notice on its own. - await seedRunningTurn() - - await expect( - retry(`provider-exit:${SESSION}:${FENCE}:generation-1`, { - kind: 'pid-absent', - detail: 'recorded pid absent on host', - observedAt: 1_500 - }) - ).resolves.toBe(true) - - expect(statusTexts()).toEqual([]) - }) - - it('writes user-facing copy carrying the cause when the exit was observed', async () => { - await seedRunningTurn() - - await expect( - retry(`provider-exit:${SESSION}:${FENCE}:generation-1`, { - kind: 'exit-observed', - detail: 'transport closed', - observedAt: 1_500 - }) - ).resolves.toBe(true) - - expect(statusTexts()).toEqual([ - 'The provider stopped while this response was in progress: transport closed. You can continue in this conversation.' - ]) - expect(journal.snapshot().items.map((item) => item.body)).toContainEqual( - expect.objectContaining({ kind: 'turn', state: 'interrupted', completedAt: 1_500 }) - ) - }) - - it('stays silent about a witnessed exit that interrupted nothing but a waiting prompt', async () => { - await seedIdlePendingApproval() - - await expect( - retry(`provider-exit:${SESSION}:${FENCE}:generation-1`, { - kind: 'exit-observed', - detail: 'transport closed', - observedAt: 1_500 - }) - ).resolves.toBe(true) - - expect(statusTexts()).toEqual([]) - expect(journal.snapshot().items.map((item) => item.body)).toContainEqual( - expect.objectContaining({ - kind: 'approval', - resolution: expect.objectContaining({ state: 'cancelled' }) - }) - ) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts deleted file mode 100644 index 1000881220e..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { attachJournal } from './structured-agent-session-attach' -import type { - StructuredAgentSessionHostDeps, - StructuredAgentSessionHostSession -} from './structured-agent-session-host-types' -import type { StructuredAgentSessionLeaseStore } from './structured-agent-session-lease-release' -import { turnVerdictFromDeathEvidence } from './structured-agent-session-stale-turn-verdict' -import { - captureUnfinishedStructuredAgentSessionWork, - settleStructuredAgentSessionDeadGeneration, - unfinishedStructuredAgentSessionWorkWasInterrupted -} from './structured-agent-session-dead-generation-settlement' - -export async function retryPendingStructuredAgentSessionSettlement(input: { - deps: StructuredAgentSessionHostDeps - sessions: Map - sessionId: string - params: AgentSessionAttachParams - now: () => number -}): Promise { - const record = input.deps.store.getRecord(input.sessionId) - if (!record?.lease.settlementRetryRequired || !record.lease.settlementRetryId) { - return true - } - let journal = input.sessions.get(input.sessionId)?.journal - if (!journal) { - try { - journal = ( - await attachJournal({ - record, - params: input.params, - journalRoot: input.deps.journalRoot, - adapter: input.deps.adapter - }) - ).journal - } catch (error) { - input.deps.onEventSinkError?.({ sessionId: input.sessionId, error }) - return false - } - } - const current = input.sessions.get(input.sessionId) - const retrySession = - current ?? - ({ - journal, - params: input.params, - fence: record.lease.runtimeFence, - hasProviderChild: false, - acquisitionGeneration: null - } as StructuredAgentSessionHostSession) - return retryLoadedStructuredAgentSessionSettlement({ - deps: input.deps, - sessionId: input.sessionId, - session: retrySession, - now: input.now - }) -} - -export async function retryLoadedStructuredAgentSessionSettlement(input: { - deps: { - store: StructuredAgentSessionLeaseStore - onEventSinkError?: StructuredAgentSessionHostDeps['onEventSinkError'] - } - sessionId: string - session: Pick - now: () => number -}): Promise { - const record = input.deps.store.getRecord(input.sessionId) - if (!record?.lease.settlementRetryRequired || !record.lease.settlementRetryId) { - return true - } - const retrySession = input.session - retrySession.fence = record.lease.runtimeFence - const onError = (id: string, error: unknown): void => - input.deps.onEventSinkError?.({ sessionId: id, error }) - // Only an observed exit earns an end time; a probe-proven death never saw one. - const verdict = turnVerdictFromDeathEvidence(record.lease.deathEvidence) - const ok = await settleStructuredAgentSessionDeadGeneration({ - journal: retrySession.journal, - sessionId: input.sessionId, - fence: retrySession.fence, - settlementId: record.lease.settlementRetryId, - pendingSubmissionReason: 'provider_exited_before_acknowledgement', - verdict, - // The same evidence decides the copy: only a witnessed death is worth telling the user - // about. An unverifiable one is a restart artefact, and the session stays sendable. The - // work check matches the live exit path — a provider that died waiting on a prompt - // interrupted no response, so it must not claim one was in progress. - showUnexpectedExitOutcome: - verdict.state === 'interrupted' && - unfinishedStructuredAgentSessionWorkWasInterrupted( - captureUnfinishedStructuredAgentSessionWork(retrySession.journal), - retrySession.journal, - verdict.completedAt - ), - ...(record.lease.deathEvidence?.detail - ? { unexpectedExitReason: record.lease.deathEvidence.detail } - : {}), - onError - }) - if (!ok) { - return false - } - try { - await input.deps.store.transitionHandoff(input.sessionId, (latest) => { - if ( - latest.lease.runtimeFence !== record.lease.runtimeFence || - !latest.lease.settlementRetryRequired - ) { - throw new Error('agent_session_checkpoint_stale') - } - return { - ...latest, - lease: { - ...latest.lease, - handoffStage: null, - handoffOperationId: null, - settlementRetryRequired: undefined, - settlementRetryId: undefined, - lastRenewedAt: input.now() - } - } - }) - return true - } catch (error) { - input.deps.onEventSinkError?.({ sessionId: input.sessionId, error }) - return false - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts new file mode 100644 index 00000000000..204da81a2e6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts @@ -0,0 +1,116 @@ +// A turn an exited agent left running, whose settlement write failed, is settled by the next open of +// the conversation, not only by the next send: a reader reopening a chat the idle sweep closed, or +// reading it before the restart restore reaches it. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { setStructuredAgentSessionHost } from './structured-agent-session-registry' +import { + collectSubscriber, + createRestTestRig, + foundRestTestChat, + IDLE_MS, + REST_TEST_SESSION as SESSION, + REST_TEST_THREAD, + sweepOnce, + type RestTestRig +} from './structured-agent-session-rest-test-rig' + +let rig: RestTestRig + +beforeEach(async () => { + rig = await createRestTestRig({ idleSweep: { intervalMs: 3_600_000 } }) + setStructuredAgentSessionHost(rig.host) +}) + +afterEach(async () => { + setStructuredAgentSessionHost(null) + await rig.dispose() +}) + +/** A turn in flight whose agent exits, and whose exit settlement the journal refuses: by default + * also the retry that recording the exit queues, so only an open is left to settle it. */ +async function exitWithUnwrittenSettlement(refusedWrites = 2): Promise { + await foundRestTestChat(rig) + const open = rig.host.collaboratorsForTests().sessions.get(SESSION)! + const running = open.child! + rig.adapter.acquire.mock.calls + .at(-1)?.[0] + .events?.appendItem( + { provider: 'codex', threadId: REST_TEST_THREAD, turnId: 'working', ordinal: 50 }, + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await rig.host.flushStreamedEvents(SESSION) + const append = vi.spyOn(open.journal, 'appendLifecycleBatch') + for (let refused = 0; refused < refusedWrites; refused += 1) { + append.mockRejectedValueOnce(new Error('disk full')) + } + await rig.host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + reason: 'killed', + cause: 'unexpected-exit', + fence: running.fence, + acquisitionGeneration: running.generation! + }) + await vi.waitFor(() => + expect(rig.store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + deathEvidence: { kind: 'exit-observed' } + }) + ) + await rig.host.collaboratorsForTests().serialize(SESSION, async () => {}) +} + +async function workingTurnState(): Promise { + const items = (await rig.host.journalSnapshot(SESSION)).items + return items + .map((item) => readAgentJournalTurn(item.body)) + .find((turn) => turn?.turnId === 'working')?.state +} + +describe('a turn its gone agent left running', () => { + it('is settled in place by the retry recording the exit queues, with no reopen', async () => { + await exitWithUnwrittenSettlement(1) + + expect(rig.host.collaboratorsForTests().sessions.has(SESSION)).toBe(true) + expect(await workingTurnState()).toBe('interrupted') + const { items } = await rig.host.journalSnapshot(SESSION) + expect(JSON.stringify(items)).toContain( + 'Codex stopped while this response was in progress. You can continue in this conversation.' + ) + expect(rig.adapter.acquire).toHaveBeenCalledOnce() + }) + + it('is settled when the idle-closed conversation is opened again, and its reader sees it', async () => { + await exitWithUnwrittenSettlement() + expect(await workingTurnState()).toBe('running') + rig.clock.now += IDLE_MS + 1 + await sweepOnce(rig.host) + expect(rig.host.collaboratorsForTests().sessions.has(SESSION)).toBe(false) + + const reader = collectSubscriber() + await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) + + expect(await workingTurnState()).toBe('interrupted') + // The death evidence is Orca's log text: the row says only that the provider stopped. + expect(JSON.stringify(reader.events)).toContain( + 'Codex stopped while this response was in progress. You can continue in this conversation.' + ) + expect(rig.adapter.acquire).toHaveBeenCalledOnce() + }) + + it('is settled by a read after a restart that reaches the chat before the restore does', async () => { + await exitWithUnwrittenSettlement() + await rig.restart() + setStructuredAgentSessionHost(rig.host) + + // The client's read opens the chat first; the restore then finds it open and skips it. + expect(await workingTurnState()).toBe('interrupted') + await rig.host.restoreReadableSessions([SESSION]) + expect(await workingTurnState()).toBe('interrupted') + expect(rig.adapter.acquire).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts index 3264a55fff2..54acad4832b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts @@ -1,14 +1,21 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalItemIdentity, + AgentJournalRenderItem, + AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' import { runningTurnLifecycleRevisions, - settleStaleSessionStateOnAcquire, turnVerdictFromDeathEvidence, UNVERIFIABLE_TURN_VERDICT } from './structured-agent-session-stale-turn-verdict' @@ -21,6 +28,10 @@ const RUNNING_IDENTITY = { ordinal: 0 } +function turnScopeOf(identity: AgentJournalItemIdentity): AgentJournalTurnScope { + return { kind: 'turn', turnItemId: agentJournalItemKey(identity) } +} + function lifecycleItem( turnId: string, state: 'running' | 'completed', @@ -75,17 +86,77 @@ function promptItem(state: 'pending' | 'resolved', sequence: number): AgentJourn } describe('turn verdict from death evidence', () => { - it('earns an end time only from an observed exit', () => { + /** Judges a turn the fence-1 owner wrote, by evidence naming that owner. */ + const verdictForTurn = (evidence: AgentSessionDeathEvidence | null | undefined) => + turnVerdictFromDeathEvidence(evidence && { ownerFence: 1, ...evidence }, 1) + + it('ends a watched exit at the exit', () => { + expect(verdictForTurn({ kind: 'exit-observed', detail: 'exit', observedAt: 500 })).toEqual({ + state: 'interrupted', + completedAt: 500 + }) + }) + + it.each(['pid-absent', 'identity-mismatch'] as const)( + 'ends a %s proof at the last proof of life, never after the probe', + (kind) => { + const proof = (lastProvenAliveAt?: number) => ({ + kind, + detail: 'gone', + observedAt: 9_000, + ...(lastProvenAliveAt === undefined ? {} : { lastProvenAliveAt }) + }) + // Probed at 9000, long after the crash: the downtime is never counted as work. + expect(verdictForTurn(proof(8_000))).toEqual({ state: 'interrupted', completedAt: 8_000 }) + expect(verdictForTurn(proof(9_500))).toEqual({ state: 'interrupted', completedAt: 9_000 }) + // A proof that recorded no proof of life has only the probe. + expect(verdictForTurn(proof())).toEqual({ state: 'interrupted', completedAt: 9_000 }) + } + ) + + it('ends a watched exit at the exit even when a renewal is recorded', () => { expect( - turnVerdictFromDeathEvidence({ kind: 'exit-observed', detail: 'exit', observedAt: 500 }) + verdictForTurn({ + kind: 'exit-observed', + detail: 'exit', + observedAt: 500, + lastProvenAliveAt: 400 + }) ).toEqual({ state: 'interrupted', completedAt: 500 }) - expect( - turnVerdictFromDeathEvidence({ kind: 'pid-absent', detail: 'gone', observedAt: 500 }) - ).toEqual({ state: 'unverifiable' }) - expect( - turnVerdictFromDeathEvidence({ kind: 'identity-mismatch', detail: 'pid', observedAt: 500 }) - ).toEqual({ state: 'unverifiable' }) - expect(turnVerdictFromDeathEvidence(null)).toEqual({ state: 'unverifiable' }) + }) + + it('leaves a release nothing proved unverifiable', () => { + expect(verdictForTurn(null)).toEqual({ state: 'unverifiable' }) + expect(verdictForTurn(undefined)).toEqual({ state: 'unverifiable' }) + }) + it('keeps the rule an older build applied to evidence that names no owner', () => { + // Only a watched exit was proof then; a probe's proof stays unverifiable. + const legacy = { detail: 'gone', observedAt: 9_000, lastProvenAliveAt: 8_000 } + expect(turnVerdictFromDeathEvidence({ ...legacy, kind: 'exit-observed' }, 1)).toEqual({ + state: 'interrupted', + completedAt: 9_000 + }) + expect(turnVerdictFromDeathEvidence({ ...legacy, kind: 'pid-absent' }, 1)).toEqual({ + state: 'unverifiable' + }) + }) + + it('gives a turn no end from evidence about any other owner', () => { + const proof = { + kind: 'pid-absent' as const, + detail: 'gone', + observedAt: 9_000, + lastProvenAliveAt: 8_000 + } + // A newer start's death, and a turn whose writer the timeline no longer knows: neither proves + // the turn's own owner gone. + for (const [evidence, turnFence] of [ + [{ ...proof, ownerFence: 3 }, 1], + [{ ...proof, kind: 'exit-observed' as const, ownerFence: 3 }, 1], + [{ ...proof, ownerFence: 1 }, undefined] + ] as const) { + expect(turnVerdictFromDeathEvidence(evidence, turnFence)).toEqual({ state: 'unverifiable' }) + } }) }) @@ -107,7 +178,8 @@ describe('running turn lifecycle revisions', () => { state: 'interrupted', startedAt: 30, completedAt: 40 - } + }, + turnScope: { kind: 'thread' } } ] ) @@ -162,6 +234,13 @@ describe('running turn lifecycle revisions', () => { }) }) + it('never ends a turn before it began, when the last proof of life predates it', () => { + const item = lifecycleItem('turn-2', 'running', 2, { startedAt: 500 }) + expect( + runningTurnLifecycleRevisions([item], { state: 'interrupted', completedAt: 300 }) + ).toMatchObject([{ body: { kind: 'turn', state: 'interrupted', completedAt: 500 } }]) + }) + it('revises a legacy status-form running row from an older host into a typed turn', () => { expect( runningTurnLifecycleRevisions([legacyLifecycleItem('turn-2', 30)], { state: 'unverifiable' }) @@ -169,7 +248,8 @@ describe('running turn lifecycle revisions', () => { { kind: 'item', identity: RUNNING_IDENTITY, - body: { kind: 'turn', turnId: 'turn-2', state: 'unverifiable', startedAt: 30 } + body: { kind: 'turn', turnId: 'turn-2', state: 'unverifiable', startedAt: 30 }, + turnScope: { kind: 'thread' } } ]) }) @@ -180,11 +260,22 @@ describe('running turn lifecycle revisions', () => { }) }) +/** A probe that found the fence-1 owner gone at 9000, last proven alive at 100. */ +const PROVEN: AgentSessionDeathEvidence = { + kind: 'pid-absent', + detail: 'recorded pid absent on host', + observedAt: 9_000, + ownerFence: 1, + lastProvenAliveAt: 100 +} + describe('stale session state on a cold acquire', () => { function journalWith(items: AgentJournalRenderItem[]) { const appendLifecycleBatch = vi.fn(async () => ({ epoch: 'epoch-1', sequence: 9 })) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the settle reads only these journal members. const journal = { snapshot: () => ({ items }), + itemFence: () => 1, cursor: () => ({ epoch: 'epoch-1', sequence: 8 }), appendLifecycleBatch } as unknown as AgentSessionJournal @@ -198,11 +289,12 @@ describe('stale session state on a cold acquire', () => { ]) await expect( - settleStaleSessionStateOnAcquire({ + settleStaleStructuredAgentSessionState({ journal, sessionId: 'session-1', fence: 14, - acquisitionGeneration: 'generation-2' + acquisitionGeneration: 'generation-2', + deathEvidence: null }) ).resolves.toBe(1) @@ -214,7 +306,8 @@ describe('stale session state on a cold acquire', () => { { kind: 'item', identity: RUNNING_IDENTITY, - body: { kind: 'turn', turnId: 'turn-2', state: 'unverifiable', startedAt: 30 } + body: { kind: 'turn', turnId: 'turn-2', state: 'unverifiable', startedAt: 30 }, + turnScope: { kind: 'thread' } } ] }) @@ -226,11 +319,12 @@ describe('stale session state on a cold acquire', () => { const { journal, appendLifecycleBatch } = journalWith([pending, resolved]) await expect( - settleStaleSessionStateOnAcquire({ + settleStaleStructuredAgentSessionState({ journal, sessionId: 'session-1', fence: 14, - acquisitionGeneration: 'generation-2' + acquisitionGeneration: 'generation-2', + deathEvidence: null }) ).resolves.toBe(1) @@ -255,7 +349,8 @@ describe('stale session state on a cold acquire', () => { resolvedBy: null, resolvedAt: null } - } + }, + turnScope: { kind: 'thread' } } ] }) @@ -285,14 +380,22 @@ describe('stale session state on a cold acquire', () => { turnId: 'turn-1', ordinal: 1 }) - await journal.appendItem(prompt('thread-child'), body, { fence: 1, ...child }) - await journal.appendItem(prompt(THREAD), body, { fence: 1 }) + await journal.appendItem(prompt('thread-child'), body, { + fence: 1, + ...child, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(prompt(THREAD), body, { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) - await settleStaleSessionStateOnAcquire({ + await settleStaleStructuredAgentSessionState({ journal, sessionId: 'session-1', fence: 2, - acquisitionGeneration: 'generation-2' + acquisitionGeneration: 'generation-2', + deathEvidence: null }) expect( @@ -307,26 +410,283 @@ describe('stale session state on a cold acquire', () => { } }) + it('ends a probe-proven turn at its last proof of life, not at a row written after it', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-stale-session-')) + const journals = createTrackedJournalOpener() + let now = 100 + try { + const journal = await journals.open({ + identity: { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + journalDir: root, + now: () => now + }) + const command = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 1 } + const shell = { kind: 'tool-call' as const, name: 'shell', input: { command: 'pnpm test' } } + const turnScope = turnScopeOf({ ...command, ordinal: 0 }) + await journal.appendItem( + { ...command, ordinal: 0 }, + { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 100 }, + { fence: 1, turnScope } + ) + now = 200 + await journal.appendItem(command, { ...shell, state: 'running' }, { fence: 1, turnScope }) + // Rows can outlast the last renewal; only the renewal is proof of life. + now = 700 + await journal.appendItem( + command, + { ...shell, input: { command: 'pnpm test', streamed: 'ok' }, state: 'running' }, + { fence: 1, turnScope } + ) + now = 9_000 + + await settleStaleStructuredAgentSessionState({ + journal, + sessionId: 'session-1', + fence: 2, + acquisitionGeneration: 'generation-2', + deathEvidence: { + kind: 'pid-absent', + detail: 'recorded pid absent on host', + observedAt: 9_000, + ownerFence: 1, + lastProvenAliveAt: 650 + } + }) + + const items = journal.snapshot().items + expect(items.map((item) => readAgentJournalTurn(item.body)).find(Boolean)).toMatchObject({ + state: 'interrupted', + completedAt: 650 + }) + // The probe's detail is Orca's, so the row carries none. + expect( + items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) + ).toEqual([ + 'The agent stopped while this response was in progress. You can continue in this conversation.' + ]) + } finally { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + } + }) + + it('ends a crashed turn at its last proof of life, not at a send accepted before the proof', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-stale-session-')) + const journals = createTrackedJournalOpener() + let now = 100 + try { + const journal = await journals.open({ + identity: { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + journalDir: root, + now: () => now + }) + await journal.appendItem( + RUNNING_IDENTITY, + { kind: 'turn', turnId: 'turn-2', state: 'running', startedAt: 100 }, + { fence: 1, turnScope: turnScopeOf(RUNNING_IDENTITY) } + ) + now = 200 + await journal.appendItem( + { ...RUNNING_IDENTITY, ordinal: 1 }, + { kind: 'tool-call', name: 'shell', input: { command: 'pnpm test' }, state: 'running' }, + { fence: 1, turnScope: turnScopeOf(RUNNING_IDENTITY) } + ) + const settle = (deathEvidence: AgentSessionDeathEvidence | null) => + settleStaleStructuredAgentSessionState({ + journal, + sessionId: 'session-1', + fence: 1, + acquisitionGeneration: null, + deathEvidence + }) + // An hour later the relaunch opens the chat before anything proved the owner gone. + now = 3_600_000 + await settle(null) + const turn = () => journal.snapshot().items.map((item) => readAgentJournalTurn(item.body))[0] + expect(turn()).toMatchObject({ state: 'unverifiable' }) + now = 3_605_000 + await journal.appendSubmission({ + clientMessageId: 'send-1', + payloadFingerprint: 'fingerprint-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'still there?' }] }, + fence: 1, + handoverRecorded: true + }) + await journal.resolveDispatch({ + clientMessageId: 'send-1', + state: 'pending', + turnScope: AGENT_JOURNAL_THREAD_SCOPE, + fence: 1 + }) + now = 3_606_000 + + await settle({ + kind: 'pid-absent', + detail: 'gone', + observedAt: 3_606_000, + ownerFence: 1, + lastProvenAliveAt: 210 + }) + + expect(turn()).toMatchObject({ state: 'interrupted', completedAt: 210 }) + } finally { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + } + }) + + it('revises an unverifiable turn only from a proof naming its own owner, and only once', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-stale-session-')) + const journals = createTrackedJournalOpener() + let now = 100 + try { + const journal = await journals.open({ + identity: { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + journalDir: root, + now: () => now + }) + await journal.appendItem( + RUNNING_IDENTITY, + { kind: 'turn', turnId: 'turn-2', state: 'running', startedAt: 100 }, + { fence: 1, turnScope: turnScopeOf(RUNNING_IDENTITY) } + ) + now = 400 + // The open, before anything proved the fence-1 owner gone. + const settle = (deathEvidence: AgentSessionDeathEvidence | null) => + settleStaleStructuredAgentSessionState({ + journal, + sessionId: 'session-1', + fence: 2, + acquisitionGeneration: null, + deathEvidence + }) + await settle(null) + now = 9_000 + const turn = () => journal.snapshot().items.map((item) => readAgentJournalTurn(item.body))[0] + expect(turn()).toMatchObject({ state: 'unverifiable' }) + + const unrevised = journal.cursor() + await expect(settle({ ...PROVEN, ownerFence: 2 })).resolves.toBe(0) + const { ownerFence: _ownerFence, ...olderBuildProof } = PROVEN + await expect(settle({ ...olderBuildProof, kind: 'exit-observed' })).resolves.toBe(0) + expect(journal.cursor()).toEqual(unrevised) + expect(turn()).toMatchObject({ state: 'unverifiable' }) + + await expect(settle(PROVEN)).resolves.toBe(2) + expect(turn()).toEqual({ + turnId: 'turn-2', + state: 'interrupted', + startedAt: 100, + completedAt: 100 + }) + const revised = journal.cursor() + await expect(settle(PROVEN)).resolves.toBe(0) + expect(journal.cursor()).toEqual(revised) + expect(journal.snapshot().items.filter((item) => item.body.kind === 'status')).toHaveLength(1) + } finally { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + } + }) + + it('adds one row for a death however many attempts its settle takes to write', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-stale-session-')) + const journals = createTrackedJournalOpener() + let now = 100 + try { + const journal = await journals.open({ + identity: { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + journalDir: root, + now: () => now + }) + // Enough running turns that the settle writes two batches, and its retry two again. + for (let index = 0; index < 399; index++) { + const turnId = `turn-${index}` + await journal.appendItem( + { ...RUNNING_IDENTITY, turnId }, + { kind: 'turn', turnId, state: 'running', startedAt: 100 }, + { fence: 1, turnScope: turnScopeOf({ ...RUNNING_IDENTITY, turnId }) } + ) + } + now = 9_000 + const settle = () => + settleStaleStructuredAgentSessionState({ + journal, + sessionId: 'session-1', + fence: 2, + acquisitionGeneration: null, + deathEvidence: PROVEN + }) + const turns = () => + journal.snapshot().items.flatMap((item) => readAgentJournalTurn(item.body) ?? []) + const statusRows = () => + journal.snapshot().items.filter((item) => item.body.kind === 'status') + const append = journal.appendLifecycleBatch.bind(journal) + const secondBatchFails = vi + .spyOn(journal, 'appendLifecycleBatch') + .mockImplementationOnce(append) + .mockRejectedValueOnce(new Error('disk full')) + + await expect(settle()).rejects.toThrow('disk full') + secondBatchFails.mockRestore() + expect(turns().filter((turn) => turn.state === 'running')).toHaveLength(200) + expect(statusRows()).toHaveLength(1) + + await settle() + expect(turns().filter((turn) => turn.state !== 'interrupted')).toEqual([]) + expect(statusRows()).toHaveLength(1) + } finally { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + } + }) + it('writes nothing when no turn is running and keys on the journal position without a generation', async () => { const idle = journalWith([ lifecycleItem('turn-1', 'completed', 1, { startedAt: 10, completedAt: 20 }) ]) await expect( - settleStaleSessionStateOnAcquire({ + settleStaleStructuredAgentSessionState({ journal: idle.journal, sessionId: 'session-1', fence: 14, - acquisitionGeneration: null + acquisitionGeneration: null, + deathEvidence: null }) ).resolves.toBe(0) expect(idle.appendLifecycleBatch).not.toHaveBeenCalled() const running = journalWith([lifecycleItem('turn-2', 'running', 2)]) - await settleStaleSessionStateOnAcquire({ + await settleStaleStructuredAgentSessionState({ journal: running.journal, sessionId: 'session-1', fence: 14, - acquisitionGeneration: null + acquisitionGeneration: null, + deathEvidence: null }) expect(running.appendLifecycleBatch).toHaveBeenCalledWith( expect.objectContaining({ settlementId: 'stale-session:session-1:14:seq-8' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts index 784067b303c..97c951b4517 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts @@ -1,23 +1,24 @@ // What the host may durably say about a turn whose provider child is gone. // -// `interrupted` requires the host to have seen the child exit; that receipt is the only end time it -// is allowed to record. Everything weaker — a pid probe, an identity mismatch, a journal found -// running on a cold acquire — is `unverifiable` and carries no end at all. +// `interrupted` requires proof that the child which wrote the turn is gone: death evidence naming +// that turn's owner by fence — a watched exit, or a local probe that found the recorded pid gone or +// reused. A release nothing proved — lost contact, an unverifiable identity, a stop that outlived the +// ladder — carries none, and neither does a later owner's death; the turn is then `unverifiable` +// with no end at all, until a proof naming its owner is written and revises it. import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import type { - AgentJournalRenderItem, - AgentJournalTurnLifecycle +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalRenderItem, + type AgentJournalTurnLifecycle } from '../../../shared/agent-session-journal-types' import { agentJournalTurnBody, readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' -import { partitionJournalLifecycleMutations } from '../agent-session-journal/journal-lifecycle-batch-partition' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' export type StructuredAgentSessionTurnVerdict = | { state: 'interrupted'; completedAt: number } @@ -28,11 +29,31 @@ export const UNVERIFIABLE_TURN_VERDICT: StructuredAgentSessionTurnVerdict = { } export function turnVerdictFromDeathEvidence( - evidence: AgentSessionDeathEvidence | null | undefined + evidence: AgentSessionDeathEvidence | null | undefined, + /** Fence of the owner that wrote the turn. */ + turnFence: number | undefined ): StructuredAgentSessionTurnVerdict { - return evidence?.kind === 'exit-observed' - ? { state: 'interrupted', completedAt: evidence.observedAt } - : UNVERIFIABLE_TURN_VERDICT + if (!evidence) { + return UNVERIFIABLE_TURN_VERDICT + } + if (evidence.ownerFence === undefined) { + // Evidence an older build wrote names no owner; it keeps the rule that build applied. + return evidence.kind === 'exit-observed' + ? { state: 'interrupted', completedAt: evidence.observedAt } + : UNVERIFIABLE_TURN_VERDICT + } + if (evidence.ownerFence !== turnFence) { + return UNVERIFIABLE_TURN_VERDICT + } + if (evidence.kind === 'exit-observed') { + return { state: 'interrupted', completedAt: evidence.observedAt } + } + // A probe finds a dead child long after it died; its last renewal bounds the end, so the turn never + // counts the time Orca was down. Timeline rows don't: a send can land there after the death. + return { + state: 'interrupted', + completedAt: Math.min(evidence.lastProvenAliveAt ?? evidence.observedAt, evidence.observedAt) + } } /** Revises every still-running lifecycle item in place, keeping its identity and start. */ @@ -40,45 +61,50 @@ export function runningTurnLifecycleRevisions( items: readonly AgentJournalRenderItem[], verdict: StructuredAgentSessionTurnVerdict ): JournalLifecycleMutationInput[] { - const revisions: JournalLifecycleMutationInput[] = [] - for (const item of items) { + return items.flatMap((item) => { const turn = readAgentJournalTurn(item.body) - if (turn?.state !== 'running') { - continue - } - const identity = parseAgentJournalItemKey(item.itemId) - if (!identity) { - continue - } - revisions.push({ - kind: 'item', - identity, - body: agentJournalTurnBody(settledLifecycle(turn, verdict)) - }) - } - return revisions + return turn?.state === 'running' ? turnLifecycleRevision(item, turn, verdict) : [] + }) } -function staleSessionLifecycleRevisions( - items: readonly AgentJournalRenderItem[] +/** + * A turn an earlier settle could only call `unverifiable`, because the proof had not been written + * yet, revised once a proof names the owner that wrote it. Only ever upward, and never from an + * older build's proof, which names no owner. + */ +export function provenUnverifiableTurnRevisions( + items: readonly AgentJournalRenderItem[], + evidence: AgentSessionDeathEvidence | null | undefined, + journal: Pick ): JournalLifecycleMutationInput[] { - const revisions: JournalLifecycleMutationInput[] = [] - for (const item of items) { - const identity = parseAgentJournalItemKey(item.itemId) - if (!identity) { - continue - } - const cancelled = - (item.body.kind === 'approval' || item.body.kind === 'question') && - item.body.resolution.state === 'pending' - ? cancelledJournalPromptBody(item.body) - : null - if (cancelled) { - revisions.push({ kind: 'item', identity, body: cancelled }) - } + const ownerFence = evidence?.ownerFence + if (ownerFence === undefined) { + return [] } - revisions.push(...runningTurnLifecycleRevisions(items, UNVERIFIABLE_TURN_VERDICT)) - return revisions + return items.flatMap((item) => { + const turn = readAgentJournalTurn(item.body) + return turn?.state === 'unverifiable' && journal.itemFence(item.itemId) === ownerFence + ? turnLifecycleRevision(item, turn, turnVerdictFromDeathEvidence(evidence, ownerFence)) + : [] + }) +} + +function turnLifecycleRevision( + item: AgentJournalRenderItem, + turn: AgentJournalTurnLifecycle, + verdict: StructuredAgentSessionTurnVerdict +): JournalLifecycleMutationInput[] { + const identity = parseAgentJournalItemKey(item.itemId) + return identity + ? [ + { + kind: 'item', + identity, + body: agentJournalTurnBody(settledLifecycle(turn, verdict)), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] + : [] } /** The verdict owns the turn's end and nothing else; every other field the row @@ -94,30 +120,10 @@ function settledLifecycle( durationMs: _durationMs, ...kept } = lifecycle - return verdict.state === 'interrupted' - ? { ...kept, state: verdict.state, completedAt: verdict.completedAt } - : { ...kept, state: verdict.state } -} - -/** A running row found when a NEW child is acquired belongs to a generation whose exit nobody - * observed. Must run before that child's buffered events land, or a live turn would be judged. */ -export async function settleStaleSessionStateOnAcquire(input: { - journal: AgentSessionJournal - sessionId: string - fence: number - acquisitionGeneration: string | null -}): Promise { - const { journal } = input - const revisions = staleSessionLifecycleRevisions(journal.snapshot().items) - const generation = input.acquisitionGeneration ?? `seq-${journal.cursor().sequence}` - const settlementId = `stale-session:${input.sessionId}:${input.fence}:${generation}` - for (const chunk of partitionJournalLifecycleMutations(settlementId, revisions)) { - await journal.appendLifecycleBatch({ - settlementId: chunk.settlementId, - fence: input.fence, - recovered: true, - mutations: chunk.mutations - }) + if (verdict.state !== 'interrupted') { + return { ...kept, state: verdict.state } } - return revisions.length + // A renewal can predate the turn, which started with its owner alive; it never ends before that. + const began = Math.max(lifecycle.requestedAt ?? 0, lifecycle.startedAt ?? 0) + return { ...kept, state: verdict.state, completedAt: Math.max(verdict.completedAt, began) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts new file mode 100644 index 00000000000..12fa05710f0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts @@ -0,0 +1,83 @@ +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { structuredAgentSessionStartFailureRowIdentity } from '../../../shared/structured-agent-session-start-failure-row-key' +import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import type { StructuredAgentSessionStartFailureWords } from './structured-agent-session-failure-text' + +/** A start that failed, keyed like its row, in the words `structuredAgentSessionStartFailure` gave. */ +export type StructuredAgentSessionStartFailure = StructuredAgentSessionStartFailureWords & { + startKey: string | null +} + +/** + * The one row a start that failed leaves in the chat, whoever saw it fail: an error row, so the + * reason outlives any error strip. Keyed by the start — the child's generation, or the oldest + * message it was for when no child was ever published — so a second report of the same failure + * lands on the same row instead of adding one. + */ +export function structuredAgentSessionStartFailureRow( + startKey: string, + words: StructuredAgentSessionStartFailureWords +): JournalLifecycleMutationInput { + return { + kind: 'item', + identity: structuredAgentSessionStartFailureRowIdentity(startKey), + // The row repeats the sentence the start's rejected messages carry. + body: { kind: 'status', text: words.reason, tone: 'error', failure: words.rejection }, + // A start that failed opened no turn. + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } +} + +/** Whether the start's row is already written. Its words are the ones its rejected messages carry, + * and rejected is terminal, so the exit's later report of the same start must not reword it. */ +export function hasStructuredAgentSessionStartFailureRow( + items: readonly { itemId: string }[], + startKey: string +): boolean { + const itemId = agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity(startKey)) + return items.some((item) => item.itemId === itemId) +} + +/** + * A start the delivery loop needed and did not get: the start's row, and every queued message + * rejected with the same words. Writes nothing when nothing is still queued: a start whose + * messages Stop withdrew did not fail anyone. + */ +export async function recordStructuredAgentSessionStartFailure( + session: Pick & { fence: number }, + failure: StructuredAgentSessionStartFailure +): Promise { + const oldest = oldestQueuedSubmission(session) + if (!oldest) { + return + } + const startKey = failure.startKey ?? oldest.clientMessageId + await session.journal.appendLifecycleBatch({ + settlementId: `start-failure:${startKey}`, + fence: session.fence, + recovered: true, + mutations: [structuredAgentSessionStartFailureRow(startKey, failure)] + }) + await session.journal.rejectQueuedSubmissions(session.fence, { + reason: failure.reason, + rejection: failure.rejection + }) +} + +export function oldestQueuedSubmission( + session: Pick +): ReturnType[number] | undefined { + let oldest: ReturnType + for (const submission of session.journal.submissions()) { + if ( + isQueuedAgentJournalSubmission(submission) && + (oldest === undefined || (submission.acceptedSequence ?? 0) < (oldest.acceptedSequence ?? 0)) + ) { + oldest = submission + } + } + return oldest +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts new file mode 100644 index 00000000000..5ce1fdb3694 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -0,0 +1,194 @@ +// A start a queued message waited on can be seen failing twice: by the delivery loop, when the +// adapter settles the start without proving it, and by the exit settlement, when the child's exit +// lands. The chat gets one row for that start, the loop's, in the words the message was rejected +// with — whichever of the two reports first. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + agentSessionFailureFact, + type SubmissionRejectionFact +} from '../../../shared/agent-session-failure' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } +const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' +const ADAPTER_FAILURE = agentSessionFailureFact('notSignedIn') +const ADAPTER_FAILURE_TEXT = + 'Codex is not signed in for the selected account. Sign in, then send your message again.' +// The exit's reason is Orca's log text; the row says only that the start stopped. +const EXIT_TEXT = 'Codex stopped before it finished starting. Send your message to try again.' +// The first child (generation-1) is lost at setup; the send starts generation-2. +const START_ROW = agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'start-failure:generation-2' +}) + +function eventually(assertion: () => void | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let generation = 0 +let settleStart: (failure: SubmissionRejectionFact | undefined) => void = () => {} +let awaitStarted = vi.fn<() => Promise>() +let frames: AgentSessionSubscribeEvent[] = [] + +function exitBeforeProof(): Promise { + return host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + acquisitionGeneration: `generation-${generation}`, + reason: EXIT_REASON, + cause: 'unexpected-exit', + startupUnproven: true + }) +} + +async function sendQueued(text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + expect(sent).toMatchObject({ ok: true }) + // The loop started a child and waits on its start with the message still queued. + await eventually(() => expect(awaitStarted).toHaveBeenCalledOnce()) + expect(generation).toBe(2) + return sent.ok ? sent.value.clientMessageId : '' +} + +async function submission(clientMessageId: string) { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) +} + +/** Every text the subscriber was sent for the start's row, in order. */ +function publishedStartRows(): string[] { + return frames.flatMap((frame) => + frame.type === 'batch' + ? frame.batch.items.flatMap((item) => + item.itemId === START_ROW && item.body.kind === 'status' ? [item.body.text] : [] + ) + : [] + ) +} + +async function startRows(): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.itemId === START_ROW && item.body.kind === 'status' ? [item.body.text] : [] + ) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-start-failure-writer-')) + resetHostTestOperationIds() + generation = 0 + frames = [] + awaitStarted = vi.fn( + () => new Promise((resolve) => (settleStart = resolve)) + ) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire: vi.fn(async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: generation === 0 ? ('created' as const) : ('resumed' as const), + mintedAtFence: fence, + observedAt: NOW + }, + acquisitionGeneration: `generation-${++generation}`, + providerChildPhase: 'starting' as const + })), + awaitStarted, + releaseAcquisition: vi.fn(async () => true), + closeSession: vi.fn(async () => true), + dispatch: vi.fn(async () => ({ state: 'admitted' as const })), + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${generation + 1}`, + now: () => NOW + }) + await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ + ok: true + }) + await exitBeforeProof() + await host.subscribe({ id: 'pane', sessionId: SESSION, emit: (event) => frames.push(event) }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('a queued message whose start fails and whose child then exits', () => { + it("keeps the loop's row when the loop saw the failure first", async () => { + const queued = await sendQueued('hello') + + settleStart(ADAPTER_FAILURE) + await eventually(async () => + expect(await submission(queued)).toMatchObject({ + dispatchState: 'rejected', + reason: ADAPTER_FAILURE_TEXT, + rejection: ADAPTER_FAILURE + }) + ) + await exitBeforeProof() + await host.flushStreamedEvents(SESSION) + + expect(await startRows()).toEqual([ADAPTER_FAILURE_TEXT]) + expect(publishedStartRows()).toEqual([ADAPTER_FAILURE_TEXT]) + }) + + it('leaves the row to the loop when the exit lands while the message still waits', async () => { + const queued = await sendQueued('hello') + + await exitBeforeProof() + expect(await submission(queued)).toMatchObject({ dispatchState: 'pending' }) + settleStart(undefined) + await eventually(async () => + expect(await submission(queued)).toMatchObject({ dispatchState: 'rejected' }) + ) + await host.flushStreamedEvents(SESSION) + + expect(await startRows()).toEqual([EXIT_TEXT]) + // Written once, after the message was settled, not first by the exit and again by the loop. + expect(publishedStartRows()).toEqual([EXIT_TEXT]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index 0fd544ec9ee..3c4096be138 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts @@ -4,7 +4,7 @@ import { agentSessionLeaseFixture, agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' -import { providerStartupFailureOutcome } from './structured-agent-session-dead-generation-settlement' +import { structuredAgentSessionCompactBody } from './structured-agent-session-command-turn' import { settleUnexpectedStructuredAgentSessionExit, type StructuredAgentSessionUnexpectedExitContext, @@ -13,16 +13,17 @@ import { const SESSION = 'session-1' const GENERATION = 'generation-1' -const REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' +const REASON = 'claude stream-json exited (code 1): session limit reached' +const STARTUP_TEXT = 'Claude stopped before it finished starting. Send your message to try again.' function startedSession(): StructuredAgentSessionUnexpectedExitSession & { journal: { appendLifecycleBatch: ReturnType } } { return { - hasProviderChild: true, - fence: 7, - acquisitionGeneration: GENERATION, + child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { + cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), + itemBody: () => null, // Nothing ran: the start failed before any response or acknowledged prompt. snapshot: () => ({ items: [] }), appendLifecycleBatch: vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })), @@ -56,7 +57,6 @@ function contextFor(session: StructuredAgentSessionUnexpectedExitSession) { sessions: new Map([[SESSION, session]]), flushLifecycle: async () => ({ ok: true }), publishFence: vi.fn(), - hasResumeCapableHolder: () => true, serialize: async (_sessionId: string, task: () => Promise) => task(), now: () => 1 } @@ -73,50 +73,99 @@ const ended = { } describe('a provider that ends before it finished starting', () => { - it('tells the user why, even with no response in progress, and does not auto-resume', async () => { + it('tells the user why, even with no response in progress', async () => { const session = startedSession() - const ticket = await settleUnexpectedStructuredAgentSessionExit(contextFor(session), { + await settleUnexpectedStructuredAgentSessionExit(contextFor(session), { ...ended, + // The adapter typed the start's own failure; the host keeps it rather than reword it. + failure: { kind: 'notSignedIn' }, startupUnproven: true }) - expect(ticket).toBeNull() expect(session.journal.appendLifecycleBatch).toHaveBeenCalledWith( expect.objectContaining({ mutations: [ expect.objectContaining({ - body: { kind: 'status', text: providerStartupFailureOutcome(REASON) } + // The same row the delivery loop writes for a failed start: an error, keyed by it. + identity: { provider: 'orca', clientMessageId: `start-failure:${GENERATION}` }, + body: { + kind: 'status', + text: 'Claude is not signed in for the selected account. Sign in, then send your message again.', + tone: 'error', + failure: { kind: 'notSignedIn' } + } }) ] }) ) - expect(providerStartupFailureOutcome(REASON)).toContain('not signed in') }) - it('keeps an ordinary idle exit silent and resumable', async () => { + it('keeps an ordinary idle exit silent', async () => { const session = startedSession() - const ticket = await settleUnexpectedStructuredAgentSessionExit(contextFor(session), ended) + await settleUnexpectedStructuredAgentSessionExit(contextFor(session), ended) - expect(ticket).not.toBeNull() + expect(session.child).toBeNull() expect(session.journal.appendLifecycleBatch).not.toHaveBeenCalled() }) it("reads a start that failed off the host's own phase when the provider omits the flag", async () => { - const session = { ...startedSession(), providerChildPhase: 'starting' as const } + const session = { + ...startedSession(), + child: { generation: GENERATION, fence: 7, phase: 'starting' as const } + } - const ticket = await settleUnexpectedStructuredAgentSessionExit(contextFor(session), ended) + await settleUnexpectedStructuredAgentSessionExit(contextFor(session), { + ...ended, + failure: { kind: 'providerExited', detail: { text: REASON, audience: 'log' } } + }) - expect(ticket).toBeNull() expect(session.journal.appendLifecycleBatch).toHaveBeenCalledWith( expect.objectContaining({ mutations: [ expect.objectContaining({ - body: { kind: 'status', text: providerStartupFailureOutcome(REASON) } + // The same row the delivery loop writes for a failed start: an error, keyed by it. + identity: { provider: 'orca', clientMessageId: `start-failure:${GENERATION}` }, + // The exit's stderr stays out of the sentence, as a log detail beside it. + body: { + kind: 'status', + text: STARTUP_TEXT, + tone: 'error', + failure: { + kind: 'providerStartFailed', + detail: { text: REASON, audience: 'log' } + } + } }) ] }) ) }) + + it('names /compact as the next step when the start that failed was carrying it', async () => { + const base = startedSession() + const session = { + ...base, + child: { generation: GENERATION, fence: 7, phase: 'starting' as const }, + journal: { + ...base.journal, + submissions: () => [{ clientMessageId: 'compact-1', dispatchState: 'pending' as const }], + itemBody: () => structuredAgentSessionCompactBody() + } + } + + await settleUnexpectedStructuredAgentSessionExit(contextFor(session), ended) + + const text = 'Claude stopped before it finished starting. Run /compact again.' + expect(session.journal.rejectPendingSubmissions).toHaveBeenCalledWith( + 7, + expect.objectContaining({ reason: text }) + ) + expect(session.journal.appendLifecycleBatch).toHaveBeenCalledWith( + expect.objectContaining({ + mutations: [expect.objectContaining({ body: expect.objectContaining({ text }) })] + }) + ) + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts new file mode 100644 index 00000000000..78e10bec076 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts @@ -0,0 +1,76 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' +import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' + +const SESSION = 'status-session' +const journals = createTrackedJournalOpener() +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-status-child-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +it('publishes each provider child even when a replacement has the same startup phase', async () => { + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: join(root, SESSION) + }) + const sessions = new Map>() + const setChild = ( + child: { + phase: 'starting' | 'ready' + generation: string + fence: number + } | null + ) => sessions.set(SESSION, indexedStatusFeedSession({ journal, child })) + setChild({ phase: 'starting', generation: 'child-1', fence: 1 }) + const events: AgentSessionStatusEvent[] = [] + const feed = new StructuredAgentSessionStatusFeed({ + sessions, + getRecord: () => null, + now: () => 1 + }) + const dispose = feed.subscribe({ id: 'list-1', emit: (event) => events.push(event) }) + expect(events.at(-1)).toMatchObject({ + type: 'snapshot', + sessions: [ + { + hostExecutionOwned: true, + hostExecutionPhase: 'starting', + hostExecutionChild: { generation: 'child-1', fence: 1 } + } + ] + }) + setChild({ phase: 'starting', generation: 'child-2', fence: 2 }) + feed.publish(SESSION, journal) + expect(events.at(-1)).toMatchObject({ + session: { + hostExecutionPhase: 'starting', + hostExecutionChild: { generation: 'child-2', fence: 2 } + } + }) + setChild({ phase: 'ready', generation: 'child-2', fence: 2 }) + feed.publish(SESSION, journal) + expect(events.at(-1)).toMatchObject({ session: { hostExecutionPhase: 'ready' } }) + setChild(null) + feed.publish(SESSION, journal) + expect(events.at(-1)).not.toMatchObject({ session: { hostExecutionPhase: expect.any(String) } }) + expect(events.at(-1)).not.toMatchObject({ session: { hostExecutionChild: expect.any(Object) } }) + dispose() +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts index dcd06c0c7ef..8576df8a95c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Which journal changes republish a session's status now that the summary carries its own state // clock: a moved clock always does, and row activity alone does not once the clock dates the state. @@ -73,7 +74,7 @@ async function openFeed() { } }) const write = async (identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => { - await journal.appendItem(identity, body, { fence: 1 }) + await journal.appendItem(identity, body, { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) feed.publish(SESSION, journal) } await write( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts index e4fa6ebed1b..da7275cc641 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts @@ -1,18 +1,13 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' export function indexedStatusFeedSession(session: { journal: AgentSessionJournal - hasProviderChild?: boolean - providerChildPhase?: 'starting' | 'ready' - fence?: number + child?: (StructuredAgentSessionProviderChildIdentity & { phase: 'starting' | 'ready' }) | null }) { return { journal: session.journal, - fence: session.fence ?? 1, - ...(session.hasProviderChild !== undefined - ? { hasProviderChild: session.hasProviderChild } - : {}), - ...(session.providerChildPhase ? { providerChildPhase: session.providerChildPhase } : {}), + ...(session.child !== undefined ? { child: session.child } : {}), params: { location: { executionHostId: 'local' as const, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index 65c7f54fb30..8ff9de818a3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -1,8 +1,10 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' import type { AgentSessionBackgroundTask, AgentSessionStatusEvent, @@ -33,6 +35,8 @@ const USER_IDENTITY = { ordinal: 1 } as const +type Indexed = Parameters[0] + let root: string const journals = createTrackedJournalOpener() @@ -82,7 +86,8 @@ function feedFor( } } } as unknown as ReadonlyMap>, - getRecord: () => record as AgentSessionRecord | null, + // A partial record still has a lease: the feed reads the conversation's fence off it. + getRecord: () => (record ? { ...agentSessionRecordFixture(), ...record } : null), now: () => (now += 1) }) const events: AgentSessionStatusEvent[] = [] @@ -91,33 +96,17 @@ function feedFor( } describe('StructuredAgentSessionStatusFeed', () => { - it('projects whether the owned child has proven its start, and nothing once it is not owned', async () => { - const journal = await openJournal() - const session = { journal, hasProviderChild: true, providerChildPhase: 'starting' as const } - const sessions = new Map[0]>([[SESSION, session]]) - const { feed, events, dispose } = feedFor(sessions) - expect(events.at(-1)).toMatchObject({ - type: 'snapshot', - sessions: [{ hostExecutionOwned: true, hostExecutionPhase: 'starting' }] - }) - sessions.set(SESSION, { ...session, providerChildPhase: 'ready' }) - feed.publish(SESSION, journal) - expect(events.at(-1)).toMatchObject({ session: { hostExecutionPhase: 'ready' } }) - sessions.set(SESSION, { ...session, hasProviderChild: false }) - feed.publish(SESSION, journal) - expect(events.at(-1)).not.toMatchObject({ session: { hostExecutionPhase: expect.any(String) } }) - dispose() - }) - it('publishes provider ownership transitions without changing journal time', async () => { const journal = await openJournal() - const sessions = new Map([[SESSION, { journal, hasProviderChild: true }]]) + const sessions = new Map([ + [SESSION, { journal, child: { phase: 'ready', generation: 'child-1', fence: 1 } }] + ]) const { feed, events, dispose } = feedFor(sessions) events.length = 0 await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION, journal) expect(events.at(-1)).toEqual({ @@ -130,7 +119,7 @@ describe('StructuredAgentSessionStatusFeed', () => { throw new Error('status publication missing') } const journalTime = firstStatus.session.updatedAt - sessions.get(SESSION)!.hasProviderChild = false + sessions.get(SESSION)!.child = null feed.publish(SESSION, journal) expect(events.at(-1)).toEqual({ type: 'status', @@ -167,8 +156,10 @@ describe('StructuredAgentSessionStatusFeed', () => { it('stops projecting an old-host unknown submission after the owner fence advances', async () => { const journal = await openJournal() - const session = { journal, fence: 1 } - const { feed, events } = feedFor(new Map([[SESSION, session]])) + // The conversation's fence is the record's: a child's end moves it. + const lease = agentSessionRecordFixture().lease + const record = agentSessionRecordFixture({ ...lease, runtimeFence: 1 }) + const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), record) await journal.appendSubmission({ clientMessageId: 'old-host', payloadFingerprint: 'fp', @@ -183,9 +174,10 @@ describe('StructuredAgentSessionStatusFeed', () => { }) feed.publish(SESSION) expect(events.at(-1)).toMatchObject({ session: { status: 'working' } }) - session.fence = 2 + record.lease.runtimeFence = 2 feed.publish(SESSION) - expect(events.at(-1)).toMatchObject({ session: { status: 'idle' } }) + // Its only send outlived the host that sent it and became no turn: nothing left to list. + expect(events.at(-1)).toMatchObject({ session: { status: null } }) }) it('publishes working from the pending submission, before the provider replays the turn', async () => { @@ -224,12 +216,12 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'write a poem' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN_IDENTITY, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) @@ -260,12 +252,12 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN_IDENTITY, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const { feed, events } = feedFor(new Map([[SESSION, { journal }]])) now = 200 @@ -291,20 +283,20 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const assistant = { ...USER_IDENTITY, ordinal: 2 } await journal.appendItem( assistant, { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'first' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const { feed, events } = feedFor(new Map([[SESSION, { journal }]])) now = 200 await journal.appendItem( assistant, { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'finished' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) expect(events.at(-1)).toMatchObject({ @@ -328,12 +320,12 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN_IDENTITY, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const { feed, events } = feedFor(new Map([[SESSION, { journal }]])) for (let revision = 1; revision <= 20; revision += 1) { @@ -341,7 +333,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( TURN_IDENTITY, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) } @@ -365,12 +357,12 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'run the tests' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN_IDENTITY, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) expect(events.at(-1)).toEqual({ @@ -381,7 +373,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( { ...USER_IDENTITY, ordinal: 2 }, { kind: 'tool-call', name: 'shell', input: { command: 'pnpm test' }, state: 'running' }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) @@ -399,7 +391,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'run it' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN_IDENTITY, @@ -410,7 +402,7 @@ describe('StructuredAgentSessionStatusFeed', () => { options: [{ id: 'yes', label: 'Allow' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) @@ -427,7 +419,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) expect(events.at(-1)).toEqual({ @@ -458,7 +450,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const late: AgentSessionStatusEvent[] = [] @@ -490,7 +482,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) @@ -509,12 +501,12 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fix the auth bug' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN_IDENTITY, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION, journal) @@ -542,7 +534,7 @@ describe('StructuredAgentSessionStatusFeed', () => { role: 'user', blocks: [{ type: 'text', text: 'Fix auth' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const seen: (string | null)[] = [] const { feed } = feedFor(new Map([[SESSION, { journal }]]), null, (summary) => @@ -622,7 +614,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) expect(() => feed.publish(SESSION, journal)).not.toThrow() @@ -637,12 +629,12 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN_IDENTITY, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const snapshot = vi.spyOn(journal, 'snapshot') let taskState: 'working' | 'waiting' = 'working' @@ -671,7 +663,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const record = { options: { model: 'first-model' }, providerHandleChain: [] } const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), record) @@ -701,7 +693,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION, journal) expect(events.at(-1)).toEqual({ @@ -740,7 +732,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION, journal) const before = events.length @@ -793,13 +785,15 @@ describe('the status sink sees the roster the broadcast cache deliberately lacks it('receives every change once, ownership revocation, and the forget edge', async () => { const journal = await openJournal() - const sessions = new Map([[SESSION, { journal, hasProviderChild: true }]]) + const sessions = new Map([ + [SESSION, { journal, child: { phase: 'ready', generation: 'child-1', fence: 1 } }] + ]) const { sink, published, forgotten } = sinkFor() const { feed } = feedFor(sessions, null, undefined, undefined, sink) await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION, journal) // A second identical publication is deduped for the sink exactly as for subscribers, so the @@ -815,6 +809,8 @@ describe('the status sink sees the roster the broadcast cache deliberately lacks feed.revokeLive(SESSION) expect(published.at(-1)).toMatchObject({ sessionId: SESSION, status: 'idle' }) expect(published.at(-1)?.hostExecutionOwned).toBeUndefined() + expect(published.at(-1)?.hostExecutionPhase).toBeUndefined() + expect(published.at(-1)?.hostExecutionChild).toBeUndefined() // Exactly what `close` does after eviction: the cache keeps the projection, the sink does not. sessions.delete(SESSION) @@ -859,7 +855,7 @@ describe('the status sink sees the roster the broadcast cache deliberately lacks await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION, journal) expect(() => feed.forget(SESSION)).not.toThrow() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index eef1c0e06f9..22a61a006bb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -22,10 +22,10 @@ import { type AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' -import { projectStructuredAgentSessionStatusSummary } from '../../../shared/structured-agent-session-projection' +import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { StructuredAgentSessionProviderChildPhase } from './structured-agent-session-adapter' +import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' import { structuredAgentSessionProviderSessionMetadata } from './structured-agent-session-history-result' import { StructuredAgentSessionStatusOwnership, @@ -34,6 +34,10 @@ import { export type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-ownership' +export type StructuredAgentSessionStatusState = ReturnType< + typeof projectStructuredAgentSessionStatusState +> + export type StructuredAgentSessionStatusSubscriber = { id: string emit: (event: AgentSessionStatusEvent) => void @@ -42,9 +46,7 @@ export type StructuredAgentSessionStatusSubscriber = { type StatusFeedSession = { journal: AgentSessionJournal params: { location: AgentSessionRecord['location']; provider: AgentSessionRecord['provider'] } - hasProviderChild?: boolean - providerChildPhase?: StructuredAgentSessionProviderChildPhase - fence?: number + child?: Pick | null } export type StructuredAgentSessionStatusFeedDeps = { @@ -60,6 +62,8 @@ export type StructuredAgentSessionStatusFeedDeps = { /** Live provider-owned background tasks for the summary, so session lists can * render subagent children. Optional: a provider without the hook projects none. */ readBackgroundTasks?: (sessionId: string) => AgentSessionBackgroundTaskState | null | undefined + /** The session's agent proved a start: its row's phase became `ready`. */ + onAgentStarted?: (sessionId: string) => void } function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSummary): boolean { @@ -69,6 +73,8 @@ function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSumma a.status === b.status && a.hostExecutionOwned === b.hostExecutionOwned && a.hostExecutionPhase === b.hostExecutionPhase && + a.hostExecutionChild?.generation === b.hostExecutionChild?.generation && + a.hostExecutionChild?.fence === b.hostExecutionChild?.fence && a.rewindBlockedReason === b.rewindBlockedReason && // A moved state clock changes ranking; row activity alone, including a subagent's, does not. // An idle state the journal cannot date still republishes, since readers date it by `updatedAt`, @@ -117,6 +123,7 @@ export function createStructuredAgentSessionHostStatusFeed(args: { onSessionStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged'] statusSink?: StructuredAgentSessionStatusSink } + onAgentStarted?: (sessionId: string) => void }): StructuredAgentSessionStatusFeed { return new StructuredAgentSessionStatusFeed({ sessions: args.sessions, @@ -126,7 +133,8 @@ export function createStructuredAgentSessionHostStatusFeed(args: { readBackgroundTasks: (sessionId) => args.deps().adapter.backgroundTaskState?.(sessionId), // Resolved per call for the same reason the other deps are: the host builds this feed in a // field initializer, before its constructor parameters are assigned. - statusSink: () => args.deps().statusSink + statusSink: () => args.deps().statusSink, + ...(args.onAgentStarted ? { onAgentStarted: args.onAgentStarted } : {}) }) } @@ -144,7 +152,7 @@ export class StructuredAgentSessionStatusFeed { sequence: number readOnly: boolean fence: number | undefined - summary: ReturnType + state: StructuredAgentSessionStatusState } >() @@ -197,7 +205,12 @@ export class StructuredAgentSessionStatusFeed { if (!previous) { return } - const { hostExecutionOwned: _hostExecutionOwned, ...retained } = previous + const { + hostExecutionOwned: _hostExecutionOwned, + hostExecutionPhase: _hostExecutionPhase, + hostExecutionChild: _hostExecutionChild, + ...retained + } = previous this.published.set(sessionId, retained) this.sink(retained) this.broadcast({ @@ -206,6 +219,17 @@ export class StructuredAgentSessionStatusFeed { }) } + /** The projection behind the session's row and the latest request it read, cached per commit, + * so the completion feed follows the same request without snapshotting the journal again. */ + statusState( + sessionId: string, + journal?: AgentSessionJournal + ): StructuredAgentSessionStatusState | null { + const session = this.deps.sessions.get(sessionId) + const source = journal ?? session?.journal + return source ? this.projectionFor(source, this.deps.getRecord(sessionId)) : null + } + /** Re-projects one session after its journal changed; equal projections are not re-sent. */ publish(sessionId: string, journal?: AgentSessionJournal, options?: { replay?: boolean }): void { const session = this.deps.sessions.get(sessionId) @@ -223,6 +247,9 @@ export class StructuredAgentSessionStatusFeed { this.published.set(sessionId, summary) this.sink(summary, session.params.location) this.broadcast({ type: 'status', session: summary }) + if (summary.hostExecutionPhase === 'ready' && previous?.hostExecutionPhase !== 'ready') { + this.deps.onAgentStarted?.(sessionId) + } try { this.deps.onStatusChanged?.(summary, { replay: options?.replay === true }) } catch (error) { @@ -236,34 +263,8 @@ export class StructuredAgentSessionStatusFeed { session: StatusFeedSession, journal: AgentSessionJournal ): AgentSessionStatusSummary { - // An unreadable journal projects as "no turn": the chat itself shows the reset. - const cursor = journal.cursor() - const readOnly = journal.isReadOnly - const fence = session.fence - let projection = this.journalProjections.get(journal) - if ( - !projection || - projection.epoch !== cursor.epoch || - projection.sequence !== cursor.sequence || - projection.readOnly !== readOnly || - projection.fence !== fence - ) { - // A journalled submission bumps `lastSequence`, so the send-time working - // signal reaches the cache; the lease fence does not, hence the extra key. - const snapshot = readOnly ? null : journal.snapshot() - projection = { - ...cursor, - readOnly, - fence, - summary: projectStructuredAgentSessionStatusSummary( - snapshot?.items ?? [], - snapshot?.submissions ?? [], - fence - ) - } - this.journalProjections.set(journal, projection) - } const record = this.deps.getRecord(sessionId) + const { summary: projected } = this.projectionFor(journal, record) const providerSession = structuredAgentSessionProviderSessionMetadata(record) // The journal has no model: the record's acknowledged options are where a mid-session // switch lands, so the row follows whichever is in force. @@ -278,15 +279,14 @@ export class StructuredAgentSessionStatusFeed { sessionId, workspaceId: session.params.location.workspaceId, agent: session.params.provider, - ...(session.hasProviderChild + ...(session.child ? { hostExecutionOwned: true as const, - ...(session.providerChildPhase - ? { hostExecutionPhase: session.providerChildPhase } - : {}) + hostExecutionPhase: session.child.phase, + hostExecutionChild: { generation: session.child.generation, fence: session.child.fence } } : {}), - ...projection.summary, + ...projected, ...(record?.rewind?.phase === 'prepared' || record?.rewind?.phase === 'provider-succeeded' ? { rewindBlockedReason: 'outcome-unknown' as const } : {}), @@ -310,6 +310,41 @@ export class StructuredAgentSessionStatusFeed { } } + private projectionFor( + journal: AgentSessionJournal, + record: AgentSessionRecord | null + ): StructuredAgentSessionStatusState { + // An unreadable journal projects as "no turn": the chat itself shows the reset. + const cursor = journal.cursor() + const readOnly = journal.isReadOnly + // The conversation's fence, which a child's end moves: its unanswered sends stop counting. + const fence = record?.lease.runtimeFence + let projection = this.journalProjections.get(journal) + if ( + !projection || + projection.epoch !== cursor.epoch || + projection.sequence !== cursor.sequence || + projection.readOnly !== readOnly || + projection.fence !== fence + ) { + // A journalled submission bumps `lastSequence`, so the send-time working + // signal reaches the cache; the lease fence does not, hence the extra key. + const snapshot = readOnly ? null : journal.snapshot() + projection = { + ...cursor, + readOnly, + fence, + state: projectStructuredAgentSessionStatusState( + snapshot?.items ?? [], + snapshot?.submissions ?? [], + fence + ) + } + this.journalProjections.set(journal, projection) + } + return projection.state + } + /** A failing sink must never cost the subscribers their status event. */ private sink( summary: AgentSessionStatusSummary, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts index 57218d0f4bd..ad081c6774f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -39,7 +40,7 @@ async function openJournal(): Promise { await journal.appendItem( { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 1 }, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) return journal } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts index cf908932667..a0912e86dbc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A subagent's work must not re-date the session that spawned it. // // The status row takes its completion stamp and acknowledgement clock from the summary's @@ -63,7 +64,15 @@ async function openSession() { const roster: { tasks: AgentSessionBackgroundTask[] } = { tasks: [] } const server = new AgentHookServer() const feed = new StructuredAgentSessionStatusFeed({ - sessions: new Map([[SESSION, indexedStatusFeedSession({ journal, hasProviderChild: true })]]), + sessions: new Map([ + [ + SESSION, + indexedStatusFeedSession({ + journal, + child: { phase: 'ready', generation: 'child-1', fence: 1 } + }) + ] + ]), getRecord: () => null, now: () => 1, readBackgroundTasks: () => ({ state: 'monitoring', tasks: roster.tasks }), @@ -84,7 +93,7 @@ async function openSession() { journal.appendItem( { provider: 'orca', clientMessageId }, { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const latestStatus = () => { const event = events.findLast((candidate) => candidate.type === 'status') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts index 82f8c551642..c39cfe44e1d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts @@ -2,7 +2,10 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION +} from '../../../shared/agent-session-journal-types' import type { AgentSessionStatusEvent, AgentSessionSubscribeEvent @@ -116,7 +119,7 @@ describe('AgentSessionSubscribers', () => { await journal.appendItem( { provider: 'orca', clientMessageId: 'clocked' }, { kind: 'status', text: 'Clocked' }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) subscribers.publish(SESSION, journal) subscribers.backgroundTasks(SESSION, null, 1) @@ -253,12 +256,12 @@ describe('AgentSessionSubscribers', () => { await journal.appendItem( { ...turn, ordinal: 1 }, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'write a poem' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( turn, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) subscribers.publish(SESSION, journal) @@ -318,7 +321,7 @@ describe('AgentSessionSubscribers', () => { await journal.appendItem( { provider: 'orca', clientMessageId: 'after-background-fence' }, { kind: 'status', text: 'After background state' }, - { fence: 2 } + { fence: 2, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) subscribers.publish(SESSION, journal) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts index af5e231730c..ae795674564 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -8,19 +8,18 @@ import type { AgentJournalCursor, AgentJournalResetReason } from '../../../shared/agent-session-journal-types' -import { - AGENT_SESSION_HISTORY_MAX_LIMIT, - type AgentSessionBackgroundTaskState, - type AgentSessionSlashCommand, - type AgentSessionSubscribeEvent, - type AgentSessionTurnActivity +import type { + AgentSessionBackgroundTaskState, + AgentSessionSlashCommand, + AgentSessionSubscribeEvent, + AgentSessionTurnActivity } from '../../../shared/agent-session-wire' +import { buildSubscriberFrame } from './agent-session-subscriber-frame-fields' +import type { QueuePublication } from './structured-agent-session-queued-publication' +import { deliverToSubscriber } from './agent-session-subscriber-catch-up' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { emptyAgentSessionBatch } from './agent-session-empty-batch' -import { - createAgentSessionCatchUpReader, - readAgentSessionHydrationPage -} from './agent-session-history-page' +import { readAgentSessionHydrationPage } from './agent-session-history-page' import { rememberSessionActivity } from './structured-agent-session-activity-retention' export type AgentSessionSubscriberEmit = (event: AgentSessionSubscribeEvent) => void @@ -31,17 +30,23 @@ export type AgentSessionSubscribeInput = { cursor?: AgentJournalCursor } -type Subscriber = { +export type Subscriber = { id: string sessionId: string emit: AgentSessionSubscriberEmit cursor: AgentJournalCursor fence: number commands?: AgentSessionSlashCommand[] | null + /** The last draft list actually SENT — never advanced on a page that withheld + * it, or the final replacement would be suppressed by the identity dedup. */ + queuePublication?: QueuePublication } export type AgentSessionSubscribersHooks = { readCommands?: (sessionId: string) => AgentSessionSlashCommand[] | undefined + /** Revision-stable per emit: an unchanged list keeps its reference, so token + * streams never re-serialize it; any draft-table write changes it. */ + readQueuePublication?: (sessionId: string) => QueuePublication | undefined /** Fires after publications that can change journal content. */ onJournalPublished?: (sessionId: string, journal: AgentSessionJournal) => void now?: () => number @@ -57,6 +62,10 @@ export class AgentSessionSubscribers { return this.activityBySession.size } + subscriberCountForTests(sessionId: string): number { + return this.bySession.get(sessionId)?.size ?? 0 + } + open(input: { id: string sessionId: string @@ -209,71 +218,15 @@ export class AgentSessionSubscribers { backgroundTasks?: AgentSessionBackgroundTaskState | null, activity?: AgentSessionTurnActivity | null ): void { - const checkpointActivity = emitCheckpoint - ? this.activityField(subscriber.sessionId).activity - : undefined - const publishedActivity = activity !== undefined ? activity : checkpointActivity - const readPage = createAgentSessionCatchUpReader(journal) - while (true) { - const result = readPage({ - sessionId: subscriber.sessionId, - direction: 'after', - cursor: subscriber.cursor, - limit: AGENT_SESSION_HISTORY_MAX_LIMIT - }) - if (!result.ok) { - const page = { ...result.page, fence: subscriber.fence } - this.emit(subscriber, { - type: 'reset', - sessionId: subscriber.sessionId, - reset: result.reset, - page, - fence: subscriber.fence, - hostNow, - ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), - ...(publishedActivity !== undefined ? { activity: publishedActivity } : {}) - }) - subscriber.cursor = page.liveCursor ?? page.window.nextCursor - return - } - const page = result.page - const advanced = page.window.nextCursor.sequence > subscriber.cursor.sequence - if (!advanced) { - const commandsChanged = - this.hooks.readCommands !== undefined && - (this.hooks.readCommands(subscriber.sessionId) ?? null) !== subscriber.commands - if (emitCheckpoint || publishedActivity !== undefined || commandsChanged) { - this.emit(subscriber, { - type: 'batch', - sessionId: subscriber.sessionId, - batch: emptyAgentSessionBatch(page.window.nextCursor), - fence: subscriber.fence, - hostNow, - ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), - ...(publishedActivity !== undefined ? { activity: publishedActivity } : {}) - }) - } - return - } - this.emit(subscriber, { - type: 'batch', - sessionId: subscriber.sessionId, - batch: { - cursor: page.window.nextCursor, - items: page.items, - removedItemIds: page.removedItemIds, - submissions: page.submissions - }, - fence: subscriber.fence, - hostNow, - ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), - ...(publishedActivity !== undefined ? { activity: publishedActivity } : {}) - }) - subscriber.cursor = page.window.nextCursor - if (!page.hasNewer || !this.isActive(subscriber)) { - return - } - } + deliverToSubscriber( + { + hooks: this.hooks, + emit: (target, event, options) => this.emit(target, event, options), + isActive: (target) => this.isActive(target), + activity: (sessionId) => this.activityField(sessionId).activity + }, + { subscriber, journal, hostNow, emitCheckpoint, backgroundTasks, activity } + ) } private now = (): number => this.hooks.now?.() ?? Date.now() @@ -283,15 +236,23 @@ export class AgentSessionSubscribers { /** A dead transport cannot be allowed to turn a durable mutation into an * unknown outcome or poison every later publication. */ - private emit(subscriber: Subscriber, event: AgentSessionSubscribeEvent): void { + private emit( + subscriber: Subscriber, + event: AgentSessionSubscribeEvent, + options?: { withholdQueued?: boolean } + ): void { try { - const commands = this.hooks.readCommands?.(subscriber.sessionId) ?? null - const includeCommands = - this.hooks.readCommands !== undefined && - event.type !== 'end' && - (event.type !== 'batch' || commands !== subscriber.commands) - subscriber.emit(includeCommands ? { ...event, commands: commands ?? null } : event) - subscriber.commands = commands + const built = buildSubscriberFrame( + this.hooks, + subscriber, + event, + options?.withholdQueued === true + ) + subscriber.emit(built.frame) + subscriber.commands = built.commands + if (built.attachedQueued) { + subscriber.queuePublication = built.queued + } } catch { this.drop(subscriber) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 661da1ed844..4594c8c38bd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -1,7 +1,9 @@ -// The lifetime of a provider child, from the surfaces that hold the session. +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +// The lifetime of a provider child, against the real host rather than a double. // -// Two leaks meet here and each has to be tested against the real host, not a double: a chat that -// closes without stopping its app-server, and a launch that starts one for every record on disk. +// Two leaks meet here: a chat that closes without stopping its app-server, and a launch that +// starts one for every record on disk. A view never starts or keeps a child; work starts one, the +// idle sweep stops it, and an exit is settled and left for the next send. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -16,7 +18,6 @@ import type { AgentSessionMutationEnvelope, AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' -import { AGENT_SESSION_UNATTACHED_REFUSAL_CODE } from '../../../shared/structured-agent-session-read-refusal' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { AgentSessionAcquisitionRootExitObservedError, @@ -24,7 +25,8 @@ import { } from './structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { unexpectedProviderExitOutcome } from './structured-agent-session-dead-generation-settlement' +import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' import { HOST_TEST_NOW as NOW, @@ -35,11 +37,16 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' + +const UNEXPECTED_PROVIDER_EXIT_OUTCOME = + 'Codex stopped while this response was in progress. You can continue in this conversation.' const CALLER = { callerKey: 'client-1' } -const SURFACE = 'desktop-chat:1' -/** Short enough to keep the suite fast, long enough that an eviction is a decision and not a race. */ -const GRACE_MS = 5 +/** Short enough to keep the suite fast; the host clock below decides what is idle. */ +const SWEEP_MS = 5 +const IDLE_MS = 1_000 let root: string let store: AgentSessionRecordStore @@ -50,6 +57,7 @@ let dispatch: Mock let sink: StructuredAgentSessionEventSink | null let hostErrors: unknown[] let statusSink: StructuredAgentSessionStatusSink +let clock: number function adapter(): StructuredAgentSessionAdapter { return { acquire, @@ -71,8 +79,8 @@ function openHost( journalRoot: root, claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, - releaseGraceMs: GRACE_MS, - now: () => NOW, + idleSweep: { intervalMs: SWEEP_MS, idleMs: IDLE_MS }, + now: () => clock, onEventSinkError: ({ error }) => hostErrors.push(error), statusSink, ...(probeOwner ? { probeOwner } : {}) @@ -92,6 +100,12 @@ async function attach(): Promise { expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) } +/** What a send's delivery or `agentSession.ensure` does: attach at the record's current fence. */ +async function startAgent(): Promise { + const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null + expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: true }) +} + function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { return { sessionId: SESSION, @@ -108,11 +122,12 @@ function envelope(method: string, fields: Record): AgentSession function emitTurnLifecycle(state: 'running' | 'completed', ordinal: number): void { sink?.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal }, - { kind: 'status', text: state, turnLifecycle: { turnId: 'turn-1', state } } + { kind: 'status', text: state, turnLifecycle: { turnId: 'turn-1', state } }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } -/** Eviction is a sequence, not an event: the child stops first and the session is forgotten last. */ +/** The sweep stops the child first and closes the conversation last. */ function waitForEviction(): Promise { return vi.waitFor(() => { expect(closeSession).toHaveBeenCalledWith(SESSION) @@ -120,9 +135,9 @@ function waitForEviction(): Promise { }) } -/** Long enough for several grace windows to elapse, so "not evicted" means the clock declined. */ -function waitOutSeveralGraceWindows(): Promise { - return new Promise((resolve) => setTimeout(resolve, GRACE_MS * 20)) +/** Long enough for many sweep ticks, so "not stopped" means the sweep declined. */ +function waitOutSeveralSweeps(): Promise { + return new Promise((resolve) => setTimeout(resolve, SWEEP_MS * 20)) } /** Fails the next eviction at `drain-published`, which leaves the session indexed for a retry. */ @@ -144,7 +159,8 @@ async function failJournalSinkUntilReleased(): Promise { vi.spyOn(session!.journal, 'appendItem').mockRejectedValueOnce(new Error('disk unavailable')) sink?.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'lost write' }] } + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'lost write' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await vi.waitFor(() => { expect(closeSession).toHaveBeenCalledWith(SESSION) @@ -188,6 +204,7 @@ beforeEach(async () => { resetHostTestOperationIds() sink = null hostErrors = [] + clock = NOW statusSink = { publish: vi.fn(), forget: vi.fn() } let generation = 0 acquire = vi.fn(async ({ fence, spawnToken, events }) => { @@ -207,7 +224,12 @@ beforeEach(async () => { } }) closeSession = vi.fn(async () => true) - dispatch = vi.fn(async () => ({ state: 'rejected' as const, reason: 'unused' })) + dispatch = vi.fn(async () => ({ + state: 'rejected' as const, + ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { + surface: 'rejection' + }) + })) store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) openHost() }) @@ -218,13 +240,13 @@ afterEach(async () => { }) describe('a chat that closes', () => { - it('releases the provider child it was holding', async () => { + it('stops the provider child it started', async () => { await attach() - await host.hold(SESSION, SURFACE) - host.release(SESSION, SURFACE) + await host.close(SESSION) - await waitForEviction() + expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(host.hasSession(SESSION)).toBe(false) expect(hostErrors).toEqual([]) // The record and its journal stay; only the process and the claim on it go. expect(store.getRecord(SESSION)?.lease).toMatchObject({ @@ -234,52 +256,27 @@ describe('a chat that closes', () => { }) }) - it('keeps the child while another surface still holds the session', async () => { - await attach() - await host.hold(SESSION, SURFACE) - await host.hold(SESSION, 'paired-phone:1') - - host.release(SESSION, SURFACE) - await waitOutSeveralGraceWindows() - - expect(closeSession).not.toHaveBeenCalled() - expect(host.hasSession(SESSION)).toBe(true) - }) - // The pane outlives the close by a few frames — a workspace delete closes the chats inside it - // while their panes are still mounted — so whatever a read raises in that window is what the user - // sees. This is the code the client narrows on to keep that window off the pane; a host that - // starts raising a different one there puts the red error back. - it('answers a read from the pane that outlived it with the code the client treats as transitional', async () => { + // while their panes are still mounted. A read in that window reopens the conversation as a cache + // and is answered, never refused; it starts nothing. + it('answers a read from the pane that outlived it without starting a child', async () => { await attach() - await host.hold(SESSION, SURFACE) await host.close(SESSION) - expect(host.hasSession(SESSION)).toBe(false) - expect(() => host.history({ sessionId: SESSION, direction: 'tail' })).toThrow( - AGENT_SESSION_UNATTACHED_REFUSAL_CODE - ) - expect(() => - host.subscribe({ id: 'sub-1', sessionId: SESSION, emit: () => undefined }) - ).toThrow(AGENT_SESSION_UNATTACHED_REFUSAL_CODE) + + expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true) + const unsubscribe = await host.subscribe({ + id: 'sub-1', + sessionId: SESSION, + emit: () => undefined + }) + unsubscribe() + expect(acquire).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') }) - it('does not lose the session to a release the client sent twice', async () => { - await attach() - await host.hold(SESSION, SURFACE) - await host.hold(SESSION, 'paired-phone:1') - - // A retried release must retire ONE holder, which is what a set gets right and a count does not. - host.release(SESSION, SURFACE) - host.release(SESSION, SURFACE) - await waitOutSeveralGraceWindows() - - expect(closeSession).not.toHaveBeenCalled() - expect(host.hasSession(SESSION)).toBe(true) - }) - - it('releases a compatibility wait when the session is evicted', async () => { + it('answers a compatibility wait with what eviction recorded', async () => { await attach() dispatch.mockResolvedValueOnce({ state: 'admitted' }) const body = hostTestMessage('pending until close') @@ -294,14 +291,21 @@ describe('a chat that closes', () => { if (!result.ok) { throw new Error('send was refused') } + // Handed over first: a message still queued at close is rejected as never sent instead. + await vi.waitFor(() => expect(dispatch).toHaveBeenCalled()) const settlement = host.waitForSendSettlement(SESSION, result.value.clientMessageId) await host.close(SESSION) - await expect(settlement).resolves.toBeUndefined() + // Eviction's settlement is a journal write, so the wait sees it rather than timing out. + await expect(settlement).resolves.toMatchObject({ + value: { + submission: { dispatchState: 'unknown', reason: 'provider_closed_before_acknowledgement' } + } + }) }) - it('retries teardown after journal close loses its result', async () => { + it('keeps the stop it made when the journal close loses its result', async () => { await attach() const session = host['sessions'].get(SESSION) expect(session).toBeDefined() @@ -313,27 +317,16 @@ describe('a chat that closes', () => { }) .mockImplementation(closeJournal) - await expect(host.close(SESSION)).rejects.toMatchObject({ - step: 'forget-session', - cause: expect.objectContaining({ message: 'journal close result lost' }) - }) - expect(host.hasSession(SESSION)).toBe(true) - expect(host['sessions'].get(SESSION)?.hasProviderChild).toBe(false) + // The child is stopped and the lease released before the handle closes; the entry is dropped + // before that close, so a lost result leaves no closing handle for a reader to find. + await expect(host.close(SESSION)).rejects.toThrow('journal close result lost') + expect(host.hasSession(SESSION)).toBe(false) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', ownerProcess: null }) - expect(statusSink.forget).toHaveBeenCalledWith({ - kind: 'structured-session', - sessionId: SESSION, - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' - }) await expect(host.close(SESSION)).resolves.toBeUndefined() - expect(host.hasSession(SESSION)).toBe(false) expect(closeSession).toHaveBeenCalledOnce() }) @@ -356,7 +349,7 @@ describe('a chat that closes', () => { await expect(host.close(SESSION)).rejects.toMatchObject({ step: 'drain-published' }) // The child is proven gone, but the wind-down it owes is not done: nothing settled, no release. - expect(session!.hasProviderChild).toBe(false) + expect(session!.child).toBeNull() expect(store.getRecord(SESSION)?.lease.claimStatus).not.toBe('released') await expect(host.close(SESSION)).resolves.toBeUndefined() @@ -370,31 +363,31 @@ describe('a chat that closes', () => { }) describe('a session with a turn in flight', () => { - it('is not evicted while the turn runs, and is once it ends', async () => { + it('is not stopped while the turn runs, and is once it ends and idles', async () => { await attach() - await host.hold(SESSION, SURFACE) emitTurnLifecycle('running', 1) await host.flushStreamedEvents(SESSION) - host.release(SESSION, SURFACE) - await waitOutSeveralGraceWindows() + clock += IDLE_MS + await waitOutSeveralSweeps() expect(closeSession).not.toHaveBeenCalled() expect(host.hasSession(SESSION)).toBe(true) emitTurnLifecycle('completed', 2) await host.flushStreamedEvents(SESSION) + clock += IDLE_MS await waitForEviction() }) - // Codex settles an admitted send only on its echo, which may never come; eviction retires it. - it('is evicted with an admitted send outstanding once no turn runs', async () => { + // Codex settles an admitted send only on its echo, which may never come; the stop retires it. + it('is stopped with an admitted send outstanding once no turn runs', async () => { await attach() - await host.hold(SESSION, SURFACE) await sendPending('admitted, never echoed') + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) - host.release(SESSION, SURFACE) + clock += IDLE_MS await waitForEviction() }) @@ -404,26 +397,22 @@ describe('startup', () => { it('settles an idle absent owner without chat pollution and resumes the same provider identity', async () => { await attach() const beforeRestart = store.getRecord(SESSION) - host['runtimeState'].stopLeaseRenewal() - host['holds'].dispose() - await host['sessions'].get(SESSION)?.journal.close() - host['sessions'].clear() + await abandonStructuredAgentSessionHost(host) store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) openHost(async () => ({ outcome: 'pid-absent' })) await host.restoreReadableSessions() - const restored = host.history({ sessionId: SESSION, direction: 'tail' }) + const restored = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(restored.ok && restored.page.items.some((item) => item.body.kind === 'status')).toBe( false ) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', - ownerProcess: null, - settlementRetryRequired: undefined + ownerProcess: null }) - await host.hold(SESSION, SURFACE) + await startAgent() expect(store.getRecord(SESSION)?.providerHandleChain.at(-1)?.handle).toEqual( beforeRestart?.providerHandleChain.at(-1)?.handle ) @@ -441,16 +430,23 @@ describe('startup', () => { expect(host.listSessionTabs()).toEqual([ { sessionId: SESSION, workspaceId: 'workspace-1', agent: 'codex' } ]) - expect(host.history({ sessionId: SESSION, direction: 'tail' }).ok).toBe(true) + expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') }) - it('gives the child back to a chat a surface actually opens', async () => { + it('gives the child back for work, never for a read', async () => { await attach() await reboot() await host.restoreReadableSessions() - await host.hold(SESSION, SURFACE) + const unsubscribe = await host.subscribe({ + id: 'viewer-1', + sessionId: SESSION, + emit: () => undefined + }) + expect(acquire).not.toHaveBeenCalled() + await startAgent() + unsubscribe() expect(acquire).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ @@ -461,23 +457,23 @@ describe('startup', () => { }) }) -describe('a session evicted and opened again', () => { +describe('a session closed and started again', () => { it('publishes provider events to the reattached chat', async () => { await attach() - await host.hold(SESSION, SURFACE) - host.release(SESSION, SURFACE) - await waitForEviction() + await host.close(SESSION) + expect(host.hasSession(SESSION)).toBe(false) - await host.hold(SESSION, 'desktop-chat:2') + await startAgent() const events: AgentSessionSubscribeEvent[] = [] - const unsubscribe = host.subscribe({ + const unsubscribe = await host.subscribe({ id: 'subscriber-1', sessionId: SESSION, emit: (event) => events.push(event) }) sink?.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-2', ordinal: 1 }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'back again' }] } + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'back again' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) sink?.publish() await host.flushStreamedEvents(SESSION) @@ -487,6 +483,12 @@ describe('a session evicted and opened again', () => { }) }) +async function submissionState(clientMessageId: string): Promise { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + )?.dispatchState +} + describe('an unexpected provider exit', () => { it('publishes terminal settlement to a waiting older client', async () => { await attach() @@ -503,6 +505,8 @@ describe('an unexpected provider exit', () => { if (!result.ok) { throw new Error('send was refused') } + // Accepted first; the exit must meet a message the provider was handed. + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) const settlement = host.waitForSendSettlement(SESSION, result.value.clientMessageId) const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 @@ -526,7 +530,7 @@ describe('an unexpected provider exit', () => { await failJournalSinkUntilReleased() expect(dispatch).not.toHaveBeenCalled() - const history = host.history({ sessionId: SESSION, direction: 'tail' }) + const history = await host.history({ sessionId: SESSION, direction: 'tail' }) expect( history.ok && history.page.items.some( @@ -548,17 +552,18 @@ describe('an unexpected provider exit', () => { replaceFailedSink() }) - it('releases the exact generation, reacquires outside the queue, and dispatches a new message', async () => { + it('releases the exact generation, starts nothing, and the next message starts a child', async () => { await attach() - await host.hold(SESSION, SURFACE) dispatch.mockRejectedValueOnce(new Error('provider delivery became unknown')) const unknownBody = hostTestMessage('message with unknown delivery') + const unknownEnvelope = envelope('agentSession.send', { body: unknownBody }) await expect( - host.send(CALLER, { - envelope: envelope('agentSession.send', { body: unknownBody }), - body: unknownBody - }) - ).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'unknown' } } }) + host.send(CALLER, { envelope: unknownEnvelope, body: unknownBody }) + ).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) + // Accepted, then handed over by the delivery loop, where the thrown dispatch becomes doubt. + await vi.waitFor(async () => + expect(await submissionState(unknownEnvelope.clientOperationId)).toBe('unknown') + ) const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 await host.handleAdapterEvent({ @@ -570,32 +575,42 @@ describe('an unexpected provider exit', () => { acquisitionGeneration: 'generation-1' }) - const recoveredHistory = host.history({ sessionId: SESSION, direction: 'tail' }) + const recoveredHistory = await host.history({ sessionId: SESSION, direction: 'tail' }) expect( recoveredHistory.ok && hasUnansweredStructuredAgentSessionDispatch(recoveredHistory.page.submissions) ).toBe(false) - expect(acquire).toHaveBeenCalledTimes(2) + // No respawn: the exit is settled and shown, and the conversation waits for work. + expect(acquire).toHaveBeenCalledOnce() expect(dispatch).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'live', - runtimeFence: exitedFence + 2, - ownerProcess: { pid: 4242 } + claimStatus: 'released', + runtimeFence: exitedFence + 1 }) dispatch.mockResolvedValueOnce({ state: 'accepted', providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-next', ordinal: 1 } }) const body = hostTestMessage('a distinct next message') - await expect( - host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - ).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'accepted' } } }) + const nextEnvelope = envelope('agentSession.send', { body }) + await expect(host.send(CALLER, { envelope: nextEnvelope, body })).resolves.toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'pending' } } + }) + await vi.waitFor(async () => + expect(await submissionState(nextEnvelope.clientOperationId)).toBe('accepted') + ) + expect(acquire).toHaveBeenCalledTimes(2) expect(dispatch).toHaveBeenCalledTimes(2) }) - it('does not reacquire for a subscription-only hold or a stale child generation', async () => { + it('does not reacquire for a live reader or a stale child generation', async () => { await attach() - await host.hold(SESSION, 'subscriber-1', { resume: false }) + const unsubscribe = await host.subscribe({ + id: 'subscriber-1', + sessionId: SESSION, + emit: () => undefined + }) const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 await host.handleAdapterEvent({ @@ -623,11 +638,11 @@ describe('an unexpected provider exit', () => { runtimeFence: exitedFence + 1, deathEvidence: { kind: 'exit-observed' } }) + unsubscribe() }) it('keeps a requested close out of recovery', async () => { await attach() - await host.hold(SESSION, SURFACE) const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 await host.handleAdapterEvent({ @@ -645,7 +660,6 @@ describe('an unexpected provider exit', () => { it('recovers after a failed lifecycle barrier and dispatches a distinct next message', async () => { await attach() - await host.hold(SESSION, SURFACE) dispatch.mockRejectedValueOnce(new Error('provider delivery became unknown')) const unknownBody = hostTestMessage('message with unknown delivery') const unknownParams = { @@ -654,8 +668,11 @@ describe('an unexpected provider exit', () => { } await expect(host.send(CALLER, unknownParams)).resolves.toMatchObject({ ok: true, - value: { submission: { dispatchState: 'unknown' } } + value: { submission: { dispatchState: 'pending' } } }) + await vi.waitFor(async () => + expect(await submissionState(unknownParams.envelope.clientOperationId)).toBe('unknown') + ) const runtimeState = ( host as unknown as { runtimeState: { lifecycleBarrier: () => Promise<{ ok: false; error: Error }> } @@ -677,21 +694,20 @@ describe('an unexpected provider exit', () => { }) expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'live', - runtimeFence: exitedFence + 2, - ownerProcess: { pid: 4242 } + claimStatus: 'released', + runtimeFence: exitedFence + 1 }) - expect(acquire).toHaveBeenCalledTimes(2) + expect(acquire).toHaveBeenCalledOnce() expect(dispatch).toHaveBeenCalledOnce() expect(hostErrors).toContainEqual(expect.objectContaining({ message: 'journal failed' })) - const history = host.history({ sessionId: SESSION, direction: 'tail' }) + const history = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(history.ok && history.page.submissions[0]?.dispatchState).toBe('unknown') // A send whose delivery outcome is unknown IS work in progress, so the reassuring outcome is - // written — carrying the cause, and never the old bare `Provider exited: ` row. + // written — its failure fact beside it, and never the old bare `Provider exited: ` row. const statuses = history.ok ? history.page.items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) : [] - expect(statuses).toEqual([unexpectedProviderExitOutcome('provider exited')]) + expect(statuses).toEqual([UNEXPECTED_PROVIDER_EXIT_OUTCOME]) expect(statuses.some((text) => text.startsWith('Provider exited'))).toBe(false) dispatch.mockResolvedValueOnce({ @@ -699,15 +715,19 @@ describe('an unexpected provider exit', () => { providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-next', ordinal: 1 } }) const body = hostTestMessage('a distinct next message after failed-barrier recovery') - await expect( - host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - ).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'accepted' } } }) + const nextEnvelope = envelope('agentSession.send', { body }) + await expect(host.send(CALLER, { envelope: nextEnvelope, body })).resolves.toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'pending' } } + }) + await vi.waitFor(async () => + expect(await submissionState(nextEnvelope.clientOperationId)).toBe('accepted') + ) expect(dispatch).toHaveBeenCalledTimes(2) }) - it('latches a failed exit settlement and blocks attach until the terminal batch is written', async () => { + it('releases the lease when the exit settlement cannot be written, and the next send settles the turn it left', async () => { await attach() - await host.hold(SESSION, SURFACE) emitTurnLifecycle('running', 1) await host.flushStreamedEvents(SESSION) const runtimeState = ( @@ -728,7 +748,8 @@ describe('an unexpected provider exit', () => { } ).sessions.get(SESSION) expect(session).toBeDefined() - const appendSettlement = vi + // The conversation's one handle refuses every write of the exit's settlement. + const refusing = vi .spyOn(session!.journal, 'appendLifecycleBatch') .mockRejectedValue(new Error('settlement still unavailable')) const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 @@ -742,30 +763,48 @@ describe('an unexpected provider exit', () => { acquisitionGeneration: 'generation-1' }) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ + const released = store.getRecord(SESSION)?.lease + expect(released).toMatchObject({ claimStatus: 'released', - handoffStage: 'recovering', - settlementRetryRequired: true, - settlementRetryId: `provider-exit:${SESSION}:${exitedFence}:generation-1`, - ownerProcess: null, - runtimeFence: exitedFence + 1 - }) - expect(await host.attach(CALLER, hostTestAttachParams(exitedFence + 1))).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_ownership_unknown' } - }) - expect(acquire).toHaveBeenCalledOnce() - - appendSettlement.mockRestore() - expect(await host.attach(CALLER, hostTestAttachParams(exitedFence + 1))).toMatchObject({ - ok: true - }) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'live', handoffStage: null, - settlementRetryRequired: undefined + ownerProcess: null, + runtimeFence: exitedFence + 1, + deathEvidence: { kind: 'exit-observed', detail: 'provider exited', observedAt: NOW } }) + // The retry recording the exit queues is refused too; once the journal writes again, the next + // acquire re-derives it. + await host.collaboratorsForTests().serialize(SESSION, async () => {}) + refusing.mockRestore() + + dispatch.mockResolvedValueOnce({ + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-next', ordinal: 1 } + }) + const body = hostTestMessage('sent after a settlement that never landed') + const sentEnvelope = envelope('agentSession.send', { body }) + await expect(host.send(CALLER, { envelope: sentEnvelope, body })).resolves.toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'pending' } } + }) + await vi.waitFor(async () => + expect(await submissionState(sentEnvelope.clientOperationId)).toBe('accepted') + ) expect(acquire).toHaveBeenCalledTimes(2) + // The new child's acquire settled the turn from the release's evidence: ended at the exit's + // receipt. The evidence is Orca's log text, so the row says only that the provider stopped. + const history = await host.history({ sessionId: SESSION, direction: 'tail' }) + const items = history.ok ? history.page.items : [] + expect(items.map((item) => readAgentJournalTurn(item.body)).filter(Boolean)).toContainEqual( + expect.objectContaining({ turnId: 'turn-1', state: 'interrupted', completedAt: NOW }) + ) + const statuses = items.flatMap((item) => (item.body.kind === 'status' ? [item.body] : [])) + expect(statuses).toContainEqual({ + kind: 'status', + text: UNEXPECTED_PROVIDER_EXIT_OUTCOME, + failure: { kind: 'providerExited' }, + tone: 'error' + }) + expect(statuses.map((status) => status.text).join('\n')).not.toContain('provider exited') }) }) @@ -780,7 +819,7 @@ describe('a quit over an eviction that never got its retry', () => { failNextDrain() await expect(host.close(SESSION)).rejects.toMatchObject({ step: 'drain-published' }) - expect(host['sessions'].get(SESSION)?.hasProviderChild).toBe(false) + expect(host['sessions'].get(SESSION)?.child).toBeNull() expect(store.getRecord(SESSION)?.lease.claimStatus).not.toBe('released') await host.flushAllStreamedEvents() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts index 3d4e8491986..9fddf6c7b34 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -57,7 +58,7 @@ function appendGoalRow( return journal.appendItem( { provider: 'orca', clientMessageId: `goal-row:${journal.snapshot().items.length}` }, { kind: 'status', text: 'Goal', threadGoal: { state: 'set', goal: { ...GOAL, ...overrides } } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } @@ -125,7 +126,11 @@ describe('performThreadGoalChange', () => { expect(result).toEqual({ ok: false, - refusal: { code: 'agent_session_operation_invalid', message: 'goals feature is disabled' } + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'providerRejected' }, + message: 'goals feature is disabled' + } }) expect(journal.snapshot().items).toEqual([]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts index 4e49e00f587..a779ba51abe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts @@ -1,6 +1,7 @@ // `agentSession.threadGoal`: change the provider thread's goal through the same // admission, ledger and journal path every other session mutation takes. +import { refuse, type AgentSessionRefusalReason } from '../../../shared/agent-session-wire-refusals' import type { AgentJournalItemIdentity, AgentJournalThreadGoal @@ -13,8 +14,11 @@ import type { import type { MutationPlan } from './structured-agent-session-mutation-plans' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' -function refused(message: string): TurnOutcome { - return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } } +function refused( + reason: AgentSessionRefusalReason<'agent_session_operation_invalid'>, + message: string +): TurnOutcome { + return { ok: false, refusal: refuse('agent_session_operation_invalid', { reason }, message) } } /** Keyed by the operation, so a replayed set upserts its one objective row. */ @@ -44,7 +48,7 @@ export async function performThreadGoalChange( input: { clientOperationId: string; change: AgentSessionThreadGoalChange } ): Promise> { if (!ctx.adapter.changeThreadGoal || !ctx.adapter.supportsThreadGoal?.(ctx.sessionId)) { - return refused('Goals are unavailable for this chat session.') + return refused('goalsUnsupported', 'Goals are unavailable for this chat session.') } const { change } = input const identity = objectiveIdentity(input.clientOperationId) @@ -66,15 +70,14 @@ export async function performThreadGoalChange( blocks: [{ type: 'text', text: change.objective }], sentAs: 'goal' }, - { fence: ctx.fence } + // Accepting a goal is delivering it, so it joins whatever turn runs now. + { fence: ctx.fence, turnScope: ctx.journal.liveTurnScope() } ) - ctx.publish() } const withdrawObjective = async (): Promise => { if (change.kind === 'set') { // Nothing was sent as a goal. await ctx.journal.appendTombstone(identity, { fence: ctx.fence }) - ctx.publish() } } let result: Awaited> @@ -91,7 +94,7 @@ export async function performThreadGoalChange( } if (!result.ok) { await withdrawObjective() - return refused(result.rejected) + return refused('providerRejected', result.rejected) } return { ok: true, value: { change: change.kind } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts index c0e652724af..1c2ff9b9e53 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts @@ -1,6 +1,18 @@ import { describe, expect, it, vi } from 'vitest' -import type { AgentJournalTurnLifecycle } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission, + AgentJournalTurnLifecycle +} from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionTurnCompletionEvent } from '../../../shared/agent-session-wire' +import { + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_HOST_RESTARTED, + DISPATCH_REJECTED_NOT_DELIVERED, + DISPATCH_REJECTED_PROVIDER_CLOSED +} from '../../../shared/structured-agent-session-dispatch-rejection' +import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' import { StructuredAgentSessionTurnCompletionFeed } from './structured-agent-session-turn-completion-feed' const LOCATION = { @@ -10,6 +22,8 @@ const LOCATION = { workspaceKind: 'git-worktree' } as const +const START_FAILURE = 'Claude is not signed in.' + function turn( turnId: string, state: AgentJournalTurnLifecycle['state'], @@ -18,33 +32,102 @@ function turn( return { turnId, state, ...(outcome ? { outcome } : {}) } } +function turnItem(lifecycle: AgentJournalTurnLifecycle, sequence: number): AgentJournalRenderItem { + return { + itemId: `codex:turn:${lifecycle.turnId}`, + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'turn', ...lifecycle } + } +} + +function userEntry(clientMessageId: string, sequence: number): AgentJournalRenderItem { + return { + itemId: agentJournalSubmissionKey(clientMessageId), + revision: 0, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] } + } +} + +function sent( + clientMessageId: string, + fields: Partial & Pick +): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + providerItemId: null, + reason: null, + submittedAt: 10, + resolvedAt: 20, + handoverRecorded: true, + ...fields + } +} + +const pending = (clientMessageId: string, fence = 1) => + sent(clientMessageId, { dispatchState: 'pending', fence, handedOverAt: 11, resolvedAt: null }) +const refused = (clientMessageId: string, reason = START_FAILURE) => + sent(clientMessageId, { dispatchState: 'rejected', reason }) + function harness(): { feed: StructuredAgentSessionTurnCompletionFeed setTurn: (next: AgentJournalTurnLifecycle | null) => void + setJournal: ( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[], + fence?: number + ) => void setCursor: (next: { epoch: string; sequence: number }) => void observe: () => void events: AgentSessionTurnCompletionEvent[] + outcomes: () => [string, string][] + /** Whether each completion said the user is being asked something. */ + awaitingUser: () => boolean[] listen: () => () => void } { - let current: AgentJournalTurnLifecycle | null = null + let items: AgentJournalRenderItem[] = [] + let submissions: AgentJournalSubmission[] = [] + let fence: number | undefined let cursor = { epoch: 'epoch-1', sequence: 0 } - const journal = { - newestTurn: () => current, - cursor: () => cursor - } + const journal = { cursor: () => cursor } const sessions = new Map([['session-1', { journal, params: { location: LOCATION } }]]) - const feed = new StructuredAgentSessionTurnCompletionFeed({ sessions, now: () => 1_700 }) + const feed = new StructuredAgentSessionTurnCompletionFeed({ + sessions, + now: () => 1_700, + // The status feed's projection, computed as it computes it. + readStatusState: () => projectStructuredAgentSessionStatusState(items, submissions, fence) + }) const events: AgentSessionTurnCompletionEvent[] = [] return { feed, setTurn: (next) => { - current = next + items = next ? [turnItem(next, 1)] : [] + submissions = [] + }, + setJournal: (nextItems, nextSubmissions, nextFence) => { + items = nextItems + submissions = nextSubmissions + fence = nextFence + cursor = { ...cursor, sequence: cursor.sequence + 1 } }, setCursor: (next) => { cursor = next }, observe: () => feed.observe('session-1'), events, + outcomes: () => + events.flatMap((event): [string, string][] => + event.type === 'completion' ? [[event.completion.turnId, event.completion.outcome]] : [] + ), + awaitingUser: () => + events.flatMap((event) => + event.type === 'completion' ? [event.completion.awaitingUser === true] : [] + ), listen: () => feed.subscribe({ id: 'sub', emit: (event) => events.push(event) }) } } @@ -59,7 +142,8 @@ describe('StructuredAgentSessionTurnCompletionFeed', () => { h.setTurn(turn('turn-1', 'completed', 'success')) h.setCursor({ epoch: 'epoch-1', sequence: 2 }) h.observe() - expect(h.events).toEqual([ + // Strict: an idle settle omits `awaitingUser` rather than sending it undefined. + expect(h.events).toStrictEqual([ { type: 'completion', completion: { @@ -260,4 +344,352 @@ describe('StructuredAgentSessionTurnCompletionFeed', () => { h.feed.observe('session-unknown') expect(emit).not.toHaveBeenCalled() }) + + it('announces no /compact turn and keeps its mark on the last real turn (B6)', () => { + const h = harness() + h.listen() + const real = [userEntry('m1', 1), turnItem(turn('t1', 'completed', 'success'), 2)] + const accepted = sent('m1', { dispatchState: 'accepted' }) + h.setJournal(real, [accepted]) + h.observe() + const command: AgentJournalRenderItem = { + ...userEntry('c1', 3), + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + } + } + const commandTurn = (lifecycle: AgentJournalTurnLifecycle): AgentJournalRenderItem => ({ + ...turnItem({ ...lifecycle, userItemId: command.itemId }, 4), + itemId: 'orca:command-turn:c1' + }) + h.setJournal( + [...real, command, commandTurn(turn('compact:c1', 'running'))], + [accepted, pending('c1')] + ) + h.observe() + h.setJournal( + [...real, command, commandTurn(turn('compact:c1', 'completed', 'success'))], + [accepted, sent('c1', { dispatchState: 'accepted' })] + ) + h.observe() + expect(h.events).toEqual([]) + }) +}) + +describe('a request the agent or its start refused', () => { + const M1 = agentJournalSubmissionKey('m1') + const M2 = agentJournalSubmissionKey('m2') + const M3 = agentJournalSubmissionKey('m3') + const settledTurn = turnItem(turn('t1', 'completed', 'success'), 2) + + /** A session whose first turn succeeded, as the feed saw it happen. */ + function afterSuccessfulTurn() { + const h = harness() + h.listen() + h.setJournal([userEntry('m1', 1), turnItem(turn('t1', 'running'), 2)], []) + h.observe() + h.setJournal([userEntry('m1', 1), settledTurn], [sent('m1', { dispatchState: 'accepted' })]) + h.observe() + expect(h.outcomes()).toEqual([['t1', 'success']]) + return h + } + + it('notifies failed once when the only send fails to start, named by its item key', () => { + const h = harness() + h.listen() + h.observe() + h.setJournal([userEntry('m1', 1)], [pending('m1')]) + h.observe() + h.setJournal([userEntry('m1', 1)], [refused('m1')]) + h.observe() + h.observe() + expect(h.events).toEqual([ + { + type: 'completion', + completion: { + scope: LOCATION, + sessionId: 'session-1', + turnId: M1, + outcome: 'failure', + completedAt: 1_700 + } + } + ]) + }) + + it('stays silent on a first observation of a send that had already failed', () => { + // A restart, reopen or re-attach: the failure is history, not news. + const h = harness() + h.listen() + h.setJournal([userEntry('m1', 1)], [refused('m1')]) + h.observe() + h.observe() + expect(h.events).toEqual([]) + }) + + it('re-baselines an epoch replacement that surfaces an older failure', () => { + const h = afterSuccessfulTurn() + h.setJournal([userEntry('m1', 1)], [refused('m1')]) + h.setCursor({ epoch: 'epoch-2', sequence: 1 }) + h.observe() + expect(h.outcomes()).toEqual([['t1', 'success']]) + }) + + it.each([ + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_HOST_RESTARTED, + DISPATCH_REJECTED_PROVIDER_CLOSED, + DISPATCH_REJECTED_NOT_DELIVERED + ])('never notifies a send %s, alone or after a turn', (reason) => { + const alone = harness() + alone.listen() + alone.observe() + alone.setJournal([userEntry('m1', 1)], [pending('m1')]) + alone.observe() + alone.setJournal([userEntry('m1', 1)], [refused('m1', reason)]) + alone.observe() + expect(alone.events).toEqual([]) + + // The latest request falls back to the turn already announced, which must not announce again. + const h = afterSuccessfulTurn() + const accepted = sent('m1', { dispatchState: 'accepted' }) + h.setJournal([userEntry('m1', 1), settledTurn, userEntry('m2', 3)], [accepted, pending('m2')]) + h.observe() + h.setJournal( + [userEntry('m1', 1), settledTurn, userEntry('m2', 3)], + [accepted, refused('m2', reason)] + ) + h.observe() + expect(h.outcomes()).toEqual([['t1', 'success']]) + }) + + it('never notifies a crash-stranded send that restart reconciliation finds undelivered', () => { + const h = afterSuccessfulTurn() + const items = [userEntry('m1', 1), settledTurn, userEntry('m2', 3)] + const accepted = sent('m1', { dispatchState: 'accepted' }) + h.setJournal(items, [accepted, sent('m2', { dispatchState: 'unknown', recovered: true })], 2) + h.observe() + h.setJournal( + items, + [ + accepted, + sent('m2', { + dispatchState: 'rejected', + reason: DISPATCH_REJECTED_NOT_DELIVERED, + fence: 2, + recovered: true + }) + ], + 2 + ) + h.observe() + expect(h.outcomes()).toEqual([['t1', 'success']]) + }) + + it('notifies failed, then success, when a failed start is retried and the retry succeeds', () => { + const h = harness() + h.listen() + h.observe() + h.setJournal([userEntry('m1', 1)], [refused('m1')]) + h.observe() + h.setJournal([userEntry('m1', 1), userEntry('m2', 2)], [refused('m1'), pending('m2')]) + h.observe() + const accepted = sent('m2', { dispatchState: 'accepted' }) + h.setJournal( + [userEntry('m1', 1), userEntry('m2', 2), turnItem(turn('t2', 'running'), 3)], + [refused('m1'), accepted] + ) + h.observe() + h.setJournal( + [userEntry('m1', 1), userEntry('m2', 2), turnItem(turn('t2', 'completed', 'success'), 3)], + [refused('m1'), accepted] + ) + h.observe() + expect(h.outcomes()).toEqual([ + [M1, 'failure'], + ['t2', 'success'] + ]) + }) + + it('notifies each failed start that follows another', () => { + const h = harness() + h.listen() + h.observe() + h.setJournal([userEntry('m1', 1)], [refused('m1')]) + h.observe() + h.setJournal([userEntry('m1', 1), userEntry('m2', 2)], [refused('m1'), pending('m2')]) + h.observe() + h.setJournal([userEntry('m1', 1), userEntry('m2', 2)], [refused('m1'), refused('m2')]) + h.observe() + expect(h.outcomes()).toEqual([ + [M1, 'failure'], + [M2, 'failure'] + ]) + }) + + it.each([ + ['in one commit', [['m2', 'm3']]], + ['oldest first, across commits', [['m2'], ['m3']]], + ['newest first, across commits', [['m3'], ['m2']]] + ])('notifies once for queued sends one start failure refused %s', (_name, batches) => { + const h = afterSuccessfulTurn() + const items = [userEntry('m1', 1), settledTurn, userEntry('m2', 3), userEntry('m3', 4)] + const accepted = sent('m1', { dispatchState: 'accepted' }) + const queued = (id: string) => sent(id, { dispatchState: 'pending', resolvedAt: null }) + const answered = new Set() + const submissions = () => [ + accepted, + ...['m2', 'm3'].map((id) => (answered.has(id) ? refused(id) : queued(id))) + ] + h.setJournal(items, submissions()) + h.observe() + for (const batch of batches) { + batch.forEach((id) => answered.add(id)) + h.setJournal(items, submissions()) + h.observe() + } + expect(h.outcomes()).toEqual([ + ['t1', 'success'], + [M3, 'failure'] + ]) + }) + + it('does not wait on a send left pending at an older fence', () => { + const h = harness() + h.listen() + h.setJournal([userEntry('m1', 1), userEntry('m2', 2)], [pending('m1', 1), pending('m2', 2)], 2) + h.observe() + h.setJournal([userEntry('m1', 1), userEntry('m2', 2)], [pending('m1', 1), refused('m2')], 2) + h.observe() + expect(h.outcomes()).toEqual([[M2, 'failure']]) + }) +}) + +describe('a request that settles while the user is asked something', () => { + const M1 = agentJournalSubmissionKey('m1') + + /** An approval the user has not answered; `agentId` makes it a subagent's. */ + function approval( + itemId: string, + sequence: number, + state: 'pending' | 'resolved', + agentId?: string + ): AgentJournalRenderItem { + return { + itemId, + revision: state === 'pending' ? 1 : 2, + sequence, + observedAt: sequence, + ...(agentId ? { agentId } : {}), + body: { + kind: 'approval', + title: 'Run command?', + detail: null, + options: [{ id: 'yes', label: 'Allow' }], + resolution: { state, selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + } + } + + it('notifies once when the main turn settles while a subagent waits on an approval', () => { + const h = harness() + h.listen() + const user = userEntry('m1', 1) + const accepted = [sent('m1', { dispatchState: 'accepted' })] + h.setJournal([user, turnItem(turn('t1', 'running'), 2)], accepted) + h.observe() + h.setJournal( + [user, turnItem(turn('t1', 'running'), 2), approval('a1', 3, 'pending', 'child-1')], + accepted + ) + h.observe() + h.setJournal( + [ + user, + turnItem(turn('t1', 'completed', 'success'), 2), + approval('a1', 3, 'pending', 'child-1') + ], + accepted + ) + h.observe() + expect(h.outcomes()).toEqual([['t1', 'success']]) + expect(h.awaitingUser()).toEqual([true]) + + // Answering the prompt settles the session idle on the request already announced. + h.setJournal( + [ + user, + turnItem(turn('t1', 'completed', 'success'), 2), + approval('a1', 3, 'resolved', 'child-1') + ], + accepted + ) + h.observe() + expect(h.outcomes()).toEqual([['t1', 'success']]) + }) + + it('notifies a refused send once while a prompt is pending', () => { + const h = harness() + h.listen() + const prompt = approval('a1', 1, 'pending', 'child-1') + h.setJournal([prompt, userEntry('m1', 2)], [pending('m1')]) + h.observe() + h.setJournal([prompt, userEntry('m1', 2)], [refused('m1')]) + h.observe() + expect(h.outcomes()).toEqual([[M1, 'failure']]) + expect(h.awaitingUser()).toEqual([true]) + h.setJournal([approval('a1', 1, 'resolved', 'child-1'), userEntry('m1', 2)], [refused('m1')]) + h.observe() + expect(h.outcomes()).toEqual([[M1, 'failure']]) + }) + + it('sends nothing while the main turn asks for permission, and one event when it settles', () => { + const h = harness() + h.listen() + const user = userEntry('m1', 1) + const accepted = [sent('m1', { dispatchState: 'accepted' })] + h.setJournal([user, turnItem(turn('t1', 'running'), 2)], accepted) + h.observe() + h.setJournal([user, turnItem(turn('t1', 'running'), 2), approval('a1', 3, 'pending')], accepted) + h.observe() + expect(h.events).toEqual([]) + h.setJournal( + [user, turnItem(turn('t1', 'running'), 2), approval('a1', 3, 'resolved')], + accepted + ) + h.observe() + h.setJournal( + [user, turnItem(turn('t1', 'completed', 'success'), 2), approval('a1', 3, 'resolved')], + accepted + ) + h.observe() + expect(h.outcomes()).toEqual([['t1', 'success']]) + // Idle when it settles: the prompt was already answered. + expect(h.awaitingUser()).toEqual([false]) + }) + + it('still waits on a queued send the prompt hides, so the queue notifies once', () => { + const h = harness() + h.listen() + const prompt = approval('a1', 3, 'pending', 'child-1') + const items = [userEntry('m1', 1), turnItem(turn('t1', 'running'), 2), prompt] + const queued = sent('m2', { dispatchState: 'pending', resolvedAt: null }) + const accepted = sent('m1', { dispatchState: 'accepted' }) + h.setJournal([...items, userEntry('m2', 4)], [accepted, queued]) + h.observe() + h.setJournal( + [ + userEntry('m1', 1), + turnItem(turn('t1', 'completed', 'success'), 2), + prompt, + userEntry('m2', 4) + ], + [accepted, queued] + ) + h.observe() + expect(h.events).toEqual([]) + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts index 1281fbec49b..ca9f64c0573 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts @@ -1,4 +1,5 @@ -// The host's answer to "a turn just finished", derived once per journal commit. +// The host's answer to "a request just finished", derived once per journal commit. A request is +// the one the status row reports: a turn, or a send the agent or its start refused. // // WHY THE HOST DERIVES IT: a structured session runs on the execution host and keeps journalling // whether or not any renderer has a reader mounted. A client that derived completions itself would @@ -10,41 +11,45 @@ // needs, a completion is an edge that has already passed. Keeping a queue would create a durable // obligation with nothing to retire it. -import type { AgentJournalTurnLifecycle } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { readAgentJournalTurnOutcome } from '../../../shared/agent-session-turn-record' import type { AgentSessionTurnCompletion, AgentSessionTurnCompletionEvent } from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionLatestRequest } from '../../../shared/structured-agent-session-latest-request' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionStatusState } from './structured-agent-session-status-feed' export type StructuredAgentSessionTurnCompletionSubscriber = { id: string emit: (event: AgentSessionTurnCompletionEvent) => void } -/** Only the newest-turn reader and cursor are needed here; asking for the whole journal would overstate it. */ type CompletionFeedCursor = { epoch: string; sequence: number } -type CompletionFeedJournal = Pick - type CompletionFeedSession = { - journal: CompletionFeedJournal + journal: Pick params: { location: AgentSessionRecord['location'] } } export type StructuredAgentSessionTurnCompletionFeedDeps = { sessions: ReadonlyMap now: () => number + /** The status feed's projection for this commit, so the event follows the request its row reports. */ + readStatusState: ( + sessionId: string, + journal?: AgentSessionJournal + ) => StructuredAgentSessionStatusState | null } -/** Per-session baseline. `settledTurnId` is the last settled turn this feed has accounted for; - * absence of the whole entry — not a null field — is what makes the first observation silent. */ -type SessionBaseline = CompletionFeedCursor & { settledTurnId: string | null } +type RequestMark = Pick -function isSettled(turn: AgentJournalTurnLifecycle | null): turn is AgentJournalTurnLifecycle { - return turn !== null && turn.state !== 'running' +/** Per-session baseline. `settled` is the last settled request this feed has accounted for; + * absence of the whole entry — not a null field — is what makes the first observation silent. */ +type SessionBaseline = CompletionFeedCursor & { settled: RequestMark | null } + +function settledMark(request: StructuredAgentSessionLatestRequest | null): RequestMark | null { + return request && !request.running ? { kind: request.kind, id: request.id } : null } export class StructuredAgentSessionTurnCompletionFeed { @@ -80,29 +85,24 @@ export class StructuredAgentSessionTurnCompletionFeed { /** * One journal publication. Emits at most one completion, and only on the transition into a - * settled turn this feed has not already accounted for. + * settled request this feed has not already accounted for. * * The first observation of a session only records where it is, so restore, restart, rewind and - * a re-read of history all pass through silently. An already-settled turn republished by an - * in-place revision carries the same turn id and so cannot fire twice. + * a re-read of history all pass through silently. An already-settled request republished by an + * in-place revision carries the same identity and so cannot fire twice. */ - observe(sessionId: string, journal?: CompletionFeedJournal): void { + observe(sessionId: string, journal?: AgentSessionJournal): void { const session = this.deps.sessions.get(sessionId) - if (!session) { + const state = session ? this.deps.readStatusState(sessionId, journal) : null + if (!session || !state) { return } - const source = journal ?? session.journal - const cursor = source.cursor() - const turn = source.newestTurn() - const settled = isSettled(turn) ? turn : null + const cursor = (journal ?? session.journal).cursor() + const request = state.latestRequest const baseline = this.baselines.get(sessionId) if (!baseline) { // Baseline only. Whatever the session was already holding is history, not news. - this.baselines.set(sessionId, { - epoch: cursor.epoch, - sequence: cursor.sequence, - settledTurnId: settled?.turnId ?? null - }) + this.baselines.set(sessionId, { ...cursor, settled: settledMark(request) }) return } if (baseline.epoch !== cursor.epoch || cursor.sequence < baseline.sequence) { @@ -111,24 +111,31 @@ export class StructuredAgentSessionTurnCompletionFeed { // newest settled row as a fresh completion. baseline.epoch = cursor.epoch baseline.sequence = cursor.sequence - baseline.settledTurnId = settled?.turnId ?? null + baseline.settled = settledMark(request) return } baseline.sequence = cursor.sequence - if (!settled) { + if (request?.running) { // A running turn clears the mark, so this detector fires on each running → settled // transition rather than on an id it happens not to have seen. - baseline.settledTurnId = null + baseline.settled = null return } - if (baseline.settledTurnId === settled.turnId) { + // Owed work waits, so sends refused one commit at a time announce once, when the last is + // answered. A pending prompt does not wait (structured chat has no other attention producer): + // the event says so itself, and answering it keeps the same identity. + // A withdrawn send leaves the older request latest. + if ( + state.owesWork || + !request || + (baseline.settled?.kind === request.kind && baseline.settled.id === request.id) + ) { return } - baseline.settledTurnId = settled.turnId + baseline.settled = settledMark(request) // ABSENT OUTCOME IS UNKNOWN: a turn the host only saw stop carries no verdict and gets no // event. Inferring success here is the one mistake that would light the dot on a failure. - const outcome = readAgentJournalTurnOutcome(settled) - if (!outcome) { + if (!request.outcome) { return } this.broadcast({ @@ -136,9 +143,11 @@ export class StructuredAgentSessionTurnCompletionFeed { completion: { scope: session.params.location, sessionId, - turnId: settled.turnId, - outcome, - completedAt: this.deps.now() + turnId: request.id, + outcome: request.outcome, + completedAt: this.deps.now(), + // Stated here, not joined from the status stream: remote clients receive the two unordered. + ...(state.summary.status === 'attention' ? { awaitingUser: true } : {}) } }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts new file mode 100644 index 00000000000..4bd5b412300 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -0,0 +1,125 @@ +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import type { AgentJournalStatusItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionCancelResult } from '../../../shared/agent-session-wire' +import { latestJournalDispatchObservation } from '../agent-session-journal/journal-dispatch-observation' +import type { AgentSessionCancelOutcome } from './structured-agent-session-adapter' +import { + isStructuredAgentSessionCommandTurnId, + structuredAgentSessionCommandWasStopped, + structuredAgentSessionStopNoteIdentity +} from './structured-agent-session-command-turn' +import { validatePendingPrompt } from './structured-agent-session-prompt-state' +import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' + +export async function performCancel( + ctx: AgentSessionTurnContext, + input: { + clientOperationId: string + /** Absent: whatever the conversation has in flight; present: only while that turn is current. */ + turnId?: string + scope?: 'background-tasks' + taskId?: string + prompt?: { itemId: string; expectedRevision: number } + /** Ends the provider child, for a running command the provider did not take the Stop on. */ + stopChild?: () => Promise + } +): Promise> { + if (input.prompt) { + const validated = validatePendingPrompt(ctx, input.prompt) + if (!validated.ok) { + return validated + } + } + let cancelled = false + let note: AgentJournalStatusItem | null = { kind: 'status', text: 'Cancellation requested.' } + // The turn the Stop names, read before the cancel settles it: the note reports on that turn. + const turnScope = ctx.journal.liveTurnScope() + // Only the provider's end or the child's ends a command. A command the provider has not opened a + // turn for, would not interrupt, or was already asked to stop, ends with its child; that child's + // dead-generation settlement writes the command's verdict. + const liveTurnId = ctx.journal.activeTurnId() + const runningCommand = + input.stopChild !== undefined && + liveTurnId !== null && + isStructuredAgentSessionCommandTurnId(liveTurnId) && + (input.turnId === undefined || input.turnId === liveTurnId) + const stoppedBefore = + runningCommand && structuredAgentSessionCommandWasStopped(ctx.journal, liveTurnId) + try { + const dispatchStatus = latestJournalDispatchObservation(ctx.journal, ctx.fence) + const outcome: AgentSessionCancelOutcome = stoppedBefore + ? { cancelled: false } + : input.scope + ? { + cancelled: + ( + await ctx.adapter.stopBackgroundTasks?.({ + sessionId: ctx.sessionId, + fence: ctx.fence, + ...(input.taskId ? { taskId: input.taskId } : {}) + }) + )?.cancelled === true + } + : await ctx.adapter.cancelTurn({ + sessionId: ctx.sessionId, + ...(input.turnId !== undefined ? { turnId: input.turnId } : {}), + fence: ctx.fence, + // The journal is what the client read to name a turn, so it is what judges the request. + resolveLiveTurnId: () => ctx.journal.activeTurnId(), + ...(dispatchStatus ? { dispatchStatus } : {}), + ...(input.prompt ? { prompt: { itemId: input.prompt.itemId } } : {}) + }) + cancelled = outcome.cancelled + if (!cancelled && input.turnId !== undefined) { + note = { kind: 'status', text: 'The provider had already finished this turn.' } + } else if (!cancelled && input.prompt) { + note = null + } else if (!cancelled && outcome.unconfirmed) { + note = { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { + surface: 'row' + }) + } + } else if (!cancelled) { + // Sent only while the chat reads working, so a Stop that ended nothing must say why. + const detail = outcome.refusal?.detail + note = { + kind: 'status', + ...agentSessionFailureWords( + agentSessionFailureFact('stopRefused', detail ? { detail } : {}), + { ...ctx.failureTextContext, surface: 'row' } + ) + } + } + } catch (error) { + if (input.prompt) { + throw error + } + // The adapter's error is Orca's; the row says only that the stop is unconfirmed. + note = { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) + } + } + if (runningCommand && !cancelled) { + await input.stopChild?.() + cancelled = true + note = { kind: 'status', text: 'Cancellation requested.' } + } + if (cancelled && input.prompt) { + await ctx.flushStreamedEvents() + } + const value = { ...(input.turnId !== undefined ? { turnId: input.turnId } : {}), cancelled } + if (input.scope || note === null) { + return { ok: true, value } + } + // Keyed by the operation id so a replayed cancel upserts one item, not two. + await ctx.journal.appendItem( + structuredAgentSessionStopNoteIdentity(input.clientOperationId), + note, + { fence: ctx.fence, turnScope } + ) + return { ok: true, value } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts index cb1685405a1..9230e56cbe8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts @@ -1,4 +1,4 @@ -import type { AgentSessionOptionResult } from '../../../shared/agent-session-wire' +import { refuse, type AgentSessionOptionResult } from '../../../shared/agent-session-wire' import { isAgentSessionOptionRejectedError } from './structured-agent-session-option-error' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -17,7 +17,11 @@ export async function performSetOption( if (isAgentSessionOptionRejectedError(error)) { return { ok: false, - refusal: { code: 'agent_session_operation_invalid', message: error.message } + refusal: refuse( + 'agent_session_operation_invalid', + { reason: error.refusalReason }, + error.message + ) } } throw error diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts index 6ac29cab0ab..370dd12500d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts @@ -1,59 +1,103 @@ +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { - decodeAgentSessionQuestionAnswers, - isValidAgentSessionQuestionAnswers + agentSessionPromptQuestions, + isValidAgentSessionQuestionAnswers, + legacyAgentSessionQuestionAnswers, + legacyAgentSessionSelectedOptionId, + type AgentSessionPromptResponse, + type AgentSessionQuestionAnswer } from '../../../shared/agent-session-question-answer' -import type { AgentJournalResolution } from '../../../shared/agent-session-journal-types' -import type { AgentSessionPromptResult } from '../../../shared/agent-session-wire' -import { decodeCodexQuestionOptionId } from '../../codex/codex-structured-prompt-replies' -import { AgentSessionPromptUnavailableError } from './structured-agent-session-adapter' +import type { + AgentJournalApprovalItem, + AgentJournalQuestionItem, + AgentJournalResolution +} from '../../../shared/agent-session-journal-types' +import { + refuse, + type AgentSessionPromptResult, + type AgentSessionRefusalReason +} from '../../../shared/agent-session-wire' +import { + AgentSessionPromptAnswerRejectedError, + AgentSessionPromptUnavailableError +} from './structured-agent-session-adapter' import { validatePendingPrompt } from './structured-agent-session-prompt-state' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' -function invalid(message: string): TurnOutcome { - return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } } +export type AgentSessionPromptRequest = { + itemId: string + expectedRevision: number + kind: 'approval' | 'question' + /** A decision id; or, from a client that predates `answers`, a question answer packed into one id. */ + optionId?: string + answers?: AgentSessionQuestionAnswer[] +} + +function invalid( + reason: AgentSessionRefusalReason<'agent_session_operation_invalid'>, + message: string +): TurnOutcome { + return { ok: false, refusal: refuse('agent_session_operation_invalid', { reason }, message) } +} + +/** The one place a client's choice is read; an answer an older client packed into `optionId` is unpacked here, once. */ +function readPromptChoice( + prompt: AgentJournalApprovalItem | AgentJournalQuestionItem, + input: AgentSessionPromptRequest +): { response: AgentSessionPromptResponse; selectedOptionId: string } | null { + if (prompt.kind === 'approval') { + const optionId = input.optionId + return optionId !== undefined && prompt.options.some((option) => option.id === optionId) + ? { response: { kind: 'option', optionId }, selectedOptionId: optionId } + : null + } + const answers = + input.answers ?? + (input.optionId === undefined + ? null + : legacyAgentSessionQuestionAnswers(prompt, input.optionId)) + if ( + !answers || + !isValidAgentSessionQuestionAnswers(agentSessionPromptQuestions(prompt), answers) + ) { + return null + } + const selectedOptionId = legacyAgentSessionSelectedOptionId(prompt, answers) + return selectedOptionId === null + ? null + : { response: { kind: 'answers', answers }, selectedOptionId } } export async function performPrompt( ctx: AgentSessionTurnContext, - input: { - itemId: string - expectedRevision: number - optionId: string - kind: 'approval' | 'question' - } + input: AgentSessionPromptRequest ): Promise> { const validated = validatePendingPrompt(ctx, input) if (!validated.ok) { return validated } const { prompt } = validated - const question = prompt.kind === 'question' ? prompt : null - const freeText = decodeCodexQuestionOptionId(input.optionId) - const acceptsFreeText = - question?.freeTextQuestionId !== undefined && - freeText?.questionId === question.freeTextQuestionId && - freeText.answer.trim().length > 0 - const grouped = question?.questions ? decodeAgentSessionQuestionAnswers(input.optionId) : null - const acceptsGrouped = - grouped !== null && - question?.questions !== undefined && - isValidAgentSessionQuestionAnswers(question.questions, grouped) - if ( - !acceptsFreeText && - !acceptsGrouped && - !prompt.options.some((option) => option.id === input.optionId) - ) { - return invalid(`Option ${input.optionId} is not offered by item ${input.itemId}.`) + const choice = readPromptChoice(prompt, input) + if (!choice) { + return invalid( + 'optionRejected', + input.optionId !== undefined + ? `Option ${input.optionId} is not offered by item ${input.itemId}.` + : `The answers do not match the questions on item ${input.itemId}.` + ) } + const { response } = choice const identity = parseAgentJournalItemKey(input.itemId) if (!identity) { - return invalid(`Item id ${input.itemId} is not a well-formed item key.`) + return invalid('requestMalformed', `Item id ${input.itemId} is not a well-formed item key.`) } const resolution: AgentJournalResolution = { state: 'resolved', - selectedOptionId: input.optionId, + selectedOptionId: choice.selectedOptionId, + ...(response.kind === 'answers' ? { answers: response.answers } : {}), resolvedBy: ctx.resolvedBy, resolvedAt: ctx.now() } @@ -63,37 +107,38 @@ export async function performPrompt( sessionId: ctx.sessionId, itemId: input.itemId, kind: input.kind, - optionId: input.optionId, + response, fence: ctx.fence, commit: async () => { committed.item = await ctx.journal.appendItem( identity, { ...prompt, resolution }, - { - fence: ctx.fence - } + // A revision: the prompt keeps the turn it was raised in. + { fence: ctx.fence, turnScope: ctx.journal.liveTurnScope() } ) - ctx.publish() } }) } catch (error) { if (!committed.item && error instanceof AgentSessionPromptUnavailableError) { - return invalid(error.message) + return invalid('promptGone', error.message) + } + if (!committed.item && error instanceof AgentSessionPromptAnswerRejectedError) { + return invalid('optionRejected', error.message) } if (!committed.item) { throw error } + // The adapter's error is Orca's; the row says only what the user needs to know. await ctx.journal.appendItem( { provider: 'orca', clientMessageId: `${input.itemId}#delivery` }, { kind: 'status', - text: `Your answer was recorded but the agent did not confirm it: ${ - error instanceof Error ? error.message : String(error) - }` + ...agentSessionFailureWords(agentSessionFailureFact('answerUnconfirmed'), { + surface: 'row' + }) }, - { fence: ctx.fence } + { fence: ctx.fence, turnScope: ctx.journal.liveTurnScope() } ) - ctx.publish() } const appended = committed.item if (!appended) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index 576743ebac4..302f6dd812f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -43,7 +44,7 @@ describe('performCancel', () => { text: 'Agent is working…', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const cancelTurn = vi.fn(async () => ({ cancelled: true })) const ctx: AgentSessionTurnContext = { @@ -91,7 +92,7 @@ describe('performCancel', () => { text: 'Agent is working…', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) let resolveLiveTurnId: (() => string | null) | undefined const cancelTurn = vi.fn( @@ -123,7 +124,7 @@ describe('performCancel', () => { text: 'Done.', turnLifecycle: { turnId: 'turn-1', state: 'completed' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) expect(resolveLiveTurnId?.()).toBeNull() }) @@ -143,7 +144,7 @@ describe('performCancel', () => { text: 'Agent is working…', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const ctx: AgentSessionTurnContext = { sessionId: 'session-1', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index bea393eddbb..0ce27596966 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -1,37 +1,43 @@ // The effects behind send / cancel / respond / setOption. // -// Admission (lease, fence, idempotency) has already passed by the time anything +// Admission (writer lease, idempotency) has already passed by the time anything // here runs; these functions own only the journal writes and the adapter call, // in that order. Journal first is deliberate: a crash between the two leaves a // row the next attach settles as `unknown`, whereas the reverse would lose a // turn the provider already accepted. +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' import type { AgentJournalMessageItem, AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import type { - AgentSessionCancelResult, - AgentSessionSendResult, - AgentSessionWireRefusal +import { + refuse, + type AgentSessionRefusalReason, + type AgentSessionSendResult, + type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { DISPATCH_DOUBT_PERSISTENCE_FAILED } from '../agent-session-journal/journal-dispatch-doubt-reasons' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { latestJournalDispatchObservation } from '../agent-session-journal/journal-dispatch-observation' import type { AgentSessionDispatchOutcome, StructuredAgentSessionAdapter, StructuredAgentSessionProviderChildPhase } from './structured-agent-session-adapter' -import { providerStartupFailureRejection } from './structured-agent-session-dead-generation-settlement' -import { validatePendingPrompt } from './structured-agent-session-prompt-state' -import { withTimeout } from '../../../shared/promise-timeout-fallback' +import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { - AgentSessionPreDispatchError, - AGENT_SESSION_ADMISSION_BARRIER_TIMEOUT_MS -} from './structured-agent-session-operation-settlement' + handOverStructuredAgentSessionCommand, + structuredAgentSessionHandoverOrigin, + type StructuredAgentSessionCommandHandoverContext +} from './structured-agent-session-command-turn' export { performSetOption } from './structured-agent-session-turns-options' export { performPrompt } from './structured-agent-session-turns-prompt' +export { performCancel } from './structured-agent-session-turns-cancel' export type AgentSessionTurnContext = { sessionId: string @@ -42,13 +48,15 @@ export type AgentSessionTurnContext = { persistOptions: (options: Readonly>) => Promise /** Opaque client identity recorded as the resolver of a prompt. */ resolvedBy: string + /** Republishes state kept outside the journal, such as the record's options or rewind phase. + * Journal appends reach readers on their own. */ publish: () => void /** Drains provider lifecycle already accepted by the execution host. */ flushStreamedEvents: () => Promise - /** Re-derives authorization after submission persistence, immediately before provider dispatch. */ - beforeDispatch?: () => void /** What the host holds about the child this dispatch is for, read at the moment it is needed. */ providerChildPhase?: () => StructuredAgentSessionProviderChildPhase | undefined + /** Who a Stop's refusal row names. */ + failureTextContext?: AgentSessionFailureWordsContext now: () => number } @@ -56,8 +64,11 @@ export type TurnOutcome = | { ok: true; value: TValue } | { ok: false; refusal: AgentSessionWireRefusal } -function invalid(message: string): { ok: false; refusal: AgentSessionWireRefusal } { - return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } } +function invalid( + reason: AgentSessionRefusalReason<'agent_session_operation_invalid'>, + message: string +): { ok: false; refusal: AgentSessionWireRefusal } { + return { ok: false, refusal: refuse('agent_session_operation_invalid', { reason }, message) } } /** A thrown adapter error is indistinguishable from a lost reply, so it settles as `unknown` @@ -65,10 +76,10 @@ function invalid(message: string): { ok: false; refusal: AgentSessionWireRefusal * accepted nothing (input is written only after it initializes), so a dispatch it could not * take is provably unwritten and is rejected with the cause the adapter gave. */ async function dispatchSafely( - ctx: AgentSessionTurnContext, + ctx: AgentSessionHandoverContext, clientMessageId: string, body: AgentJournalMessageItem, - requestedAt: number | undefined + requestedAt: number ): Promise { try { return await ctx.adapter.dispatch({ @@ -76,33 +87,19 @@ async function dispatchSafely( clientMessageId, body, fence: ctx.fence, - ...(ctx.beforeDispatch ? { beforeDispatch: async () => ctx.beforeDispatch?.() } : {}), - ...(requestedAt === undefined ? {} : { requestedAt }) + requestedAt }) } catch (error) { - if (error instanceof AgentSessionPreDispatchError) { - throw error - } if (ctx.providerChildPhase?.() === 'starting') { - return { state: 'rejected', reason: providerStartupFailureRejection(error) } + return { + state: 'rejected', + ...structuredAgentSessionStartFailure({ error }, ctx.failureTextContext) + } } return { state: 'unknown', reason: error instanceof Error ? error.message : String(error) } } } -async function appendStatus( - ctx: AgentSessionTurnContext, - clientMessageId: string, - text: string -): Promise { - await ctx.journal.appendItem( - { provider: 'orca', clientMessageId }, - { kind: 'status', text }, - { fence: ctx.fence } - ) - ctx.publish() -} - /** * One id, one delivery. A submission that already exists replays its recorded * outcome and NEVER goes back on the wire, whatever state it is in and whatever @@ -110,6 +107,8 @@ async function appendStatus( * the whole content of the word — and one message reached the model five times * when this was a judgement call instead of an invariant. A distinct send after * a terminal rejection uses a fresh id, which is a first delivery. + * + * Accepting only records the message; the session's delivery loop hands it over. */ export async function performSend( ctx: AgentSessionTurnContext, @@ -117,13 +116,18 @@ export async function performSend( clientMessageId: string payloadFingerprint: string body: AgentJournalMessageItem + /** Who asked for the turn; absent on callers that predate it. */ + origin?: 'client' | 'host' } ): Promise> { const existing = ctx.journal .submissions() .find((entry) => entry.clientMessageId === input.clientMessageId) if (existing && existing.payloadFingerprint !== input.payloadFingerprint) { - return invalid(`Message id ${input.clientMessageId} was already used for another send.`) + return invalid( + 'messageIdReused', + `Message id ${input.clientMessageId} was already used for another send.` + ) } if (existing) { return { @@ -132,84 +136,10 @@ export async function performSend( } } try { - await ctx.journal.appendSubmission({ ...input, fence: ctx.fence }) + await ctx.journal.appendSubmission({ ...input, fence: ctx.fence, handoverRecorded: true }) } catch { - return invalid('The message could not be recorded and was not sent.') + return invalid('journalWriteFailed', 'The message could not be recorded and was not sent.') } - ctx.publish() - - // The row just written is the send's instant on the host clock; the turn this - // dispatch opens records it so the live counter never re-anchors at turn-open. - const requestedAt = ctx.journal - .submissions() - .find((entry) => entry.clientMessageId === input.clientMessageId)?.submittedAt - const outcome = await dispatchSafely(ctx, input.clientMessageId, input.body, requestedAt).catch( - async (error: unknown) => { - if (error instanceof AgentSessionPreDispatchError) { - const recorded = await withTimeout( - ctx.journal - .resolveDispatch({ - clientMessageId: input.clientMessageId, - state: 'rejected', - reason: error.message, - fence: ctx.fence - }) - .then(() => true), - AGENT_SESSION_ADMISSION_BARRIER_TIMEOUT_MS, - false - ) - if (!recorded) { - console.warn('[structured-agent-session] pre-dispatch refusal persistence failed') - } - ctx.publish() - } - throw error - } - ) - // An admission needs no dispatch row: the submission is already pending. - if (outcome.state === 'admitted') { - ctx.publish() - return { - ok: true, - value: { - clientMessageId: input.clientMessageId, - submission: requireSubmission(ctx, input.clientMessageId) - } - } - } - try { - await ctx.journal.resolveDispatch( - outcome.state === 'accepted' - ? { - clientMessageId: input.clientMessageId, - state: 'accepted', - providerIdentity: outcome.providerIdentity, - fence: ctx.fence - } - : { - clientMessageId: input.clientMessageId, - state: outcome.state, - reason: outcome.reason, - fence: ctx.fence - } - ) - } catch (error) { - // A failed resolution must not strand a pending row; an unknown result is - // explicitly replayable. - try { - await ctx.journal.resolveDispatch({ - clientMessageId: input.clientMessageId, - state: 'unknown', - reason: DISPATCH_DOUBT_PERSISTENCE_FAILED, - fence: ctx.fence - }) - } catch { - // Nothing further to record; the pending row is settled on the next attach. - } - ctx.publish() - throw error - } - ctx.publish() return { ok: true, value: { @@ -219,6 +149,86 @@ export async function performSend( } } +export type AgentSessionHandoverContext = StructuredAgentSessionCommandHandoverContext + +/** + * Hands one queued submission to the provider. The `dispatch{pending}` row goes first: a crash + * after it leaves a message in doubt, never one that reads as queued and so provably unwritten. + */ +export async function handOverSubmission( + ctx: AgentSessionHandoverContext, + submission: AgentJournalSubmission +): Promise { + const { clientMessageId } = submission + const body = ctx.journal.itemBody(agentJournalSubmissionKey(clientMessageId)) + if (body?.kind !== 'message') { + await ctx.journal.resolveDispatch({ + clientMessageId, + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('hostFault'), { surface: 'rejection' }), + fence: ctx.fence + }) + return + } + if (body.command) { + await handOverStructuredAgentSessionCommand(ctx, submission, body) + return + } + // The message joins the turn running at handover, a steer, or opens its own. + await ctx.journal.resolveDispatch({ + clientMessageId, + state: 'pending', + fence: ctx.fence, + turnScope: ctx.journal.liveTurnScope() + }) + // The handover row's instant on the host clock; the turn this dispatch opens records it so the + // live counter never re-anchors at turn-open. + const outcome = await dispatchSafely( + ctx, + clientMessageId, + body, + structuredAgentSessionHandoverOrigin(ctx.journal, submission) + ) + // An admission needs no dispatch row: the submission is already pending. + if (outcome.state === 'admitted') { + return + } + try { + await ctx.journal.resolveDispatch( + outcome.state === 'accepted' + ? { + clientMessageId, + state: 'accepted', + providerIdentity: outcome.providerIdentity, + fence: ctx.fence + } + : outcome.state === 'rejected' + ? { + clientMessageId, + state: 'rejected', + reason: outcome.reason, + rejection: outcome.rejection, + fence: ctx.fence + } + : { clientMessageId, state: 'unknown', reason: outcome.reason, fence: ctx.fence } + ) + } catch (error) { + // A failed resolution must not strand a pending row; an unknown result is + // explicitly replayable. + try { + await ctx.journal.resolveDispatch({ + clientMessageId, + state: 'unknown', + reason: DISPATCH_DOUBT_PERSISTENCE_FAILED, + fence: ctx.fence + }) + } catch { + // Nothing further to record; the pending row is settled on the next open. + } + throw error + } +} + function requireSubmission( ctx: AgentSessionTurnContext, clientMessageId: string @@ -231,64 +241,3 @@ function requireSubmission( } return submission } - -export async function performCancel( - ctx: AgentSessionTurnContext, - input: { - clientOperationId: string - turnId: string - scope?: 'background-tasks' - taskId?: string - prompt?: { itemId: string; expectedRevision: number } - } -): Promise> { - if (input.prompt) { - const validated = validatePendingPrompt(ctx, input.prompt) - if (!validated.ok) { - return validated - } - } - let cancelled = false - let note = 'Cancellation requested.' - try { - const dispatchStatus = latestJournalDispatchObservation(ctx.journal, ctx.fence) - cancelled = input.scope - ? ( - await ctx.adapter.stopBackgroundTasks?.({ - sessionId: ctx.sessionId, - fence: ctx.fence, - ...(input.taskId ? { taskId: input.taskId } : {}) - }) - )?.cancelled === true - : ( - await ctx.adapter.cancelTurn({ - sessionId: ctx.sessionId, - turnId: input.turnId, - fence: ctx.fence, - // The journal is what the client read to name a turn, so it is what judges the request. - resolveLiveTurnId: () => ctx.journal.activeTurnId(), - ...(dispatchStatus ? { dispatchStatus } : {}), - ...(input.prompt ? { prompt: { itemId: input.prompt.itemId } } : {}) - }) - ).cancelled - if (!cancelled) { - note = 'The provider had already finished this turn.' - } - } catch (error) { - if (input.prompt) { - throw error - } - note = `Cancellation was not confirmed: ${ - error instanceof Error ? error.message : String(error) - }` - } - if (cancelled && input.prompt) { - await ctx.flushStreamedEvents() - } - if (input.scope) { - return { ok: true, value: { turnId: input.turnId, cancelled } } - } - // Keyed by the operation id so a replayed cancel upserts one item, not two. - await appendStatus(ctx, input.clientOperationId, note) - return { ok: true, value: { turnId: input.turnId, cancelled } } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts index fc8440500a4..9a27415dc71 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { projectStructuredAgentSessionStatus } from '../../../shared/structured-agent-session-projection' -import { releaseStructuredAgentSessionUnansweredDispatches } from './structured-agent-session-host-mutations' +import { releaseStructuredAgentSessionUnansweredDispatches } from './structured-agent-session-unanswered-dispatch-release' const FENCE = 7 @@ -32,12 +32,13 @@ function contextWith(submissions: AgentJournalSubmission[]) { return { epoch: 'e', sequence: 1 } }) } - // The mutation reads only `journal.submissions`, `journal.resolveDispatch` and `fence`. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: all three are supplied here; the rest of the host session is unreachable from this mutation. - const session = { journal, fence: FENCE } as unknown as ReleaseSession - const publish = vi.fn() - const context: ReleaseContext = { sessions: new Map([['s-1', session]]), publish } - return { context, resolved, publish, journal } + // The mutation reads only `journal.submissions`, `journal.resolveDispatch` and the record fence. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: all three are supplied here; the rest of the session and the record is unreachable from this mutation. + const context = { + sessions: new Map([['s-1', { journal }]]), + deps: { store: { getRecord: () => ({ lease: { runtimeFence: FENCE } }) } } + } as unknown as ReleaseContext + return { context, resolved, journal } } describe('releasing dispatches the provider can no longer answer', () => { @@ -45,7 +46,7 @@ describe('releasing dispatches the provider can no longer answer', () => { const live = [submission({})] // POSITIVE CONTROL: this is the latch being dissolved. expect(projectStructuredAgentSessionStatus([], live, FENCE)).toBe('working') - const { context, resolved, publish } = contextWith(live) + const { context, resolved } = contextWith(live) await releaseStructuredAgentSessionUnansweredDispatches(context, { sessionId: 's-1', @@ -62,14 +63,13 @@ describe('releasing dispatches the provider can no longer answer', () => { recovered: true } ]) - expect(publish).toHaveBeenCalledOnce() expect(projectStructuredAgentSessionStatus([], [submission({ recovered: true })], FENCE)).toBe( 'idle' ) }) it('never touches a pending send, whose dispatch may still be in flight', async () => { - const { context, resolved, publish } = contextWith([ + const { context, resolved } = contextWith([ submission({ clientMessageId: 'm-2', dispatchState: 'pending', reason: null }) ]) @@ -79,7 +79,6 @@ describe('releasing dispatches the provider can no longer answer', () => { }) expect(resolved).toEqual([]) - expect(publish).not.toHaveBeenCalled() }) it('leaves an already recovered unknown alone', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts new file mode 100644 index 00000000000..713838c177e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts @@ -0,0 +1,43 @@ +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' + +/** + * Releases sends the provider can no longer be holding. + * + * A dispatch whose RPC timed out is recorded `unknown` — doubt, never proof of + * non-delivery — and a live `unknown` reads as work still owed, so the session + * shows working until something re-derives it. The provider reporting its thread + * not running, with no turn open, IS that re-derivation. + * + * `pending` is deliberately untouched: that send's dispatch has not returned yet + * and may be in flight right now. And `recovered` only retires the obligation — + * it never makes a send re-deliverable, because the provider may well have run it. + */ +export async function releaseStructuredAgentSessionUnansweredDispatches( + context: Pick & { + deps: { store: Pick } + }, + input: { sessionId: string; reason: string } +): Promise { + const session = context.sessions.get(input.sessionId) + if (!session) { + return + } + const stranded = session.journal + .submissions() + .filter((entry) => entry.dispatchState === 'unknown' && entry.recovered !== true) + if (stranded.length === 0) { + return + } + for (const entry of stranded) { + await session.journal.resolveDispatch({ + clientMessageId: entry.clientMessageId, + state: 'unknown', + // The earlier reason names a sharper fact than this one does. + reason: entry.reason ?? input.reason, + fence: structuredAgentSessionConversationFence(context.deps.store, input.sessionId), + recovered: true + }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index 4ba1a93169b..2cb38a46634 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -7,50 +7,19 @@ import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' -import { unexpectedProviderExitOutcome } from './structured-agent-session-dead-generation-settlement' -import { retryLoadedStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' +import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' import { - isStructuredAgentSessionRecoveryTicketCurrent, settleUnexpectedStructuredAgentSessionExit, type StructuredAgentSessionUnexpectedExitContext, - type StructuredAgentSessionUnexpectedExitSession, - type StructuredAgentSessionRecoveryTicket + type StructuredAgentSessionUnexpectedExitSession } from './structured-agent-session-unexpected-exit' +const exitOutcome = (agent: string): string => + `${agent} stopped while this response was in progress. You can continue in this conversation.` + const SESSION = 'session-1' const GENERATION = 'generation-1' -const ticket: StructuredAgentSessionRecoveryTicket = { - sessionId: SESSION, - releasedFence: 8, - deadAcquisitionGeneration: GENERATION, - stableSettlementId: 'settlement-1' -} - -function recoveryContext(input: { - generation?: string - handoffStage?: AgentSessionRecord['lease']['handoffStage'] - resumeCapable?: boolean -}) { - const session = { - hasProviderChild: false, - fence: 8, - acquisitionGeneration: input.generation ?? GENERATION - } as StructuredAgentSessionHostSession - const record = { - lease: { - runtimeFence: 8, - claimStatus: 'released', - handoffStage: input.handoffStage ?? null - } - } as AgentSessionRecord - return { - sessions: new Map([[SESSION, session]]), - store: { getRecord: () => record }, - hasResumeCapableHolder: () => input.resumeCapable ?? true - } as never -} - function lifecycleItem( turnId: string, sequence: number, @@ -102,20 +71,12 @@ function mutableStore() { } } -describe('provider-exit recovery tickets', () => { - it.each([undefined, 2_000])('keeps exit receipt %s on retry', async (observedAt) => { +describe('provider-exit settlement', () => { + it.each([undefined, 2_000])('keeps exit receipt %s when settling fails', async (observedAt) => { let now = observedAt === undefined ? 2_000 : 30_000 - let record = { - lease: { - handoffStage: null, - runtimeFence: 7, - runtimeKind: 'native', - claimStatus: 'live', - ownerProcess: 'provider', - reservedSpawnToken: null, - processlessAt: null - } - } as unknown as AgentSessionRecord + let record = agentSessionRecordFixture( + agentSessionLeaseFixture({ runtimeKind: 'native', reservedSpawnToken: null }) + ) const store = { getRecord: () => record, transitionHandoff: async ( @@ -127,14 +88,16 @@ describe('provider-exit recovery tickets', () => { .fn() .mockRejectedValueOnce(new Error('journal unavailable')) .mockResolvedValue({ epoch: 'epoch-1', sequence: 2 }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the exit reads only the child record and these journal methods; the rest of the session is unreachable from it. const session = { - hasProviderChild: true, - fence: 7, - acquisitionGeneration: GENERATION, + child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { + cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), + itemBody: () => null, snapshot: () => ({ items: [lifecycleItem('turn-1', 1, { state: 'running', startedAt: 1_000 })] }), + itemFence: () => 7, appendLifecycleBatch, markPendingSubmissionsUnknown: vi.fn(async () => []) } @@ -149,7 +112,6 @@ describe('provider-exit recovery tickets', () => { return { ok: false, error: new Error('sink unavailable') } }, publishFence: vi.fn(), - hasResumeCapableHolder: () => true, serialize: async (_sessionId, task) => task(), now: () => now } as never, @@ -163,20 +125,23 @@ describe('provider-exit recovery tickets', () => { observedAt } ) - expect(record.lease.settlementRetryRequired).toBe(true) - expect(record.lease.deathEvidence?.observedAt).toBe(2_000) + // Released, not latched: a later settle from this evidence finishes what this write left. + expect(record.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + deathEvidence: { kind: 'exit-observed', detail: 'provider exited', observedAt: 2_000 } + }) expect(record.lease.lastRenewedAt).toBe(60_000) expect(record.updatedAt).toBe(60_000) now = 120_000 - await expect( - retryLoadedStructuredAgentSessionSettlement({ - deps: { store } as never, - sessionId: SESSION, - session: { journal: session.journal, fence: 8, acquisitionGeneration: null }, - now: () => now - }) - ).resolves.toBe(true) + await settleStaleStructuredAgentSessionState({ + journal: session.journal, + sessionId: SESSION, + fence: 8, + acquisitionGeneration: 'generation-2', + deathEvidence: record.lease.deathEvidence + }) expect(appendLifecycleBatch.mock.calls.at(-1)?.[0].mutations).toContainEqual( expect.objectContaining({ body: { @@ -188,7 +153,6 @@ describe('provider-exit recovery tickets', () => { } }) ) - expect(record.lease.settlementRetryRequired).toBeUndefined() }) it('uses the fallback when the one-shot translator admission was rejected, revising the running turn in place', async () => { @@ -197,11 +161,12 @@ describe('provider-exit recovery tickets', () => { lifecycleItem('turn-1', 1, { state: 'completed', startedAt: 10, completedAt: 20 }), lifecycleItem('turn-2', 2, { state: 'running', startedAt: 30 }) ] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the exit reads only the child record and these journal methods; the rest of the session is unreachable from it. const session = { - hasProviderChild: true, - fence: 7, - acquisitionGeneration: GENERATION, + child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { + cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), + itemBody: () => null, snapshot: () => ({ items }), appendLifecycleBatch, markPendingSubmissionsUnknown: vi.fn(async () => []) @@ -215,20 +180,18 @@ describe('provider-exit recovery tickets', () => { runtimeKind: 'native', claimStatus: 'live', ownerProcess: 'provider', - reservedSpawnToken: null, - processlessAt: null + reservedSpawnToken: null } }), transitionHandoff: async () => ({ lease: { runtimeFence: 8 } }) } - const result = await settleUnexpectedStructuredAgentSessionExit( + await settleUnexpectedStructuredAgentSessionExit( { store, sessions: new Map([[SESSION, session]]), flushLifecycle: async () => ({ ok: true }), publishFence: vi.fn(), - hasResumeCapableHolder: () => true, serialize: async (_sessionId, task) => task(), now: () => 1_234 } as never, @@ -238,17 +201,15 @@ describe('provider-exit recovery tickets', () => { reason: 'provider exited', cause: 'unexpected-exit', fence: 7, - acquisitionGeneration: GENERATION, - settlementRetryRequired: true + acquisitionGeneration: GENERATION } ) - expect(result).toMatchObject({ releasedFence: 8 }) expect(session.journal.markPendingSubmissionsUnknown).toHaveBeenCalledWith( 7, 'provider_exited_before_acknowledgement' ) - expect(session.hasProviderChild).toBe(false) + expect(session.child).toBeNull() // The running row is revised to interrupted at exit receipt, never tombstoned. expect(appendLifecycleBatch).toHaveBeenCalledExactlyOnceWith({ settlementId: `dead-generation:provider-exit:${SESSION}:7:${GENERATION}`, @@ -261,7 +222,22 @@ describe('provider-exit recovery tickets', () => { provider: 'orca', clientMessageId: `provider-exit:${SESSION}:7:${GENERATION}` }, - body: { kind: 'status', text: unexpectedProviderExitOutcome('provider exited') } + body: { + kind: 'status', + text: exitOutcome('The agent'), + failure: { kind: 'providerExited' }, + tone: 'error' + }, + // The exit belongs to the turn it ended. + turnScope: { + kind: 'turn', + turnItemId: agentJournalItemKey({ + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-2', + ordinal: 0 + }) + } }, { kind: 'item', @@ -272,7 +248,8 @@ describe('provider-exit recovery tickets', () => { state: 'interrupted', startedAt: 30, completedAt: 1_234 - } + }, + turnScope: { kind: 'thread' } } ] }) @@ -305,10 +282,10 @@ describe('provider-exit recovery tickets', () => { sequence: 3 })) const session: StructuredAgentSessionUnexpectedExitSession = { - hasProviderChild: true, - fence: 7, - acquisitionGeneration: GENERATION, + child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { + cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), + itemBody: () => null, snapshot: () => ({ items }), appendLifecycleBatch, markPendingSubmissionsUnknown: vi.fn(async () => []), @@ -331,7 +308,6 @@ describe('provider-exit recovery tickets', () => { return { ok: true } }, publishFence: vi.fn(), - hasResumeCapableHolder: () => true, serialize: async (_sessionId: string, task: () => Promise) => task(), now: () => 1_234 } @@ -351,7 +327,9 @@ describe('provider-exit recovery tickets', () => { expect.objectContaining({ body: { kind: 'status', - text: unexpectedProviderExitOutcome('provider exited after completing the turn') + text: exitOutcome('Claude'), + failure: { kind: 'providerExited' }, + tone: 'error' } }) ]) @@ -362,10 +340,10 @@ describe('provider-exit recovery tickets', () => { it('settles a submission the dead child never acknowledged', async () => { const markPendingSubmissionsUnknown = vi.fn(async () => ['client-1']) const session: StructuredAgentSessionUnexpectedExitSession = { - hasProviderChild: true, - fence: 7, - acquisitionGeneration: GENERATION, + child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { + cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), + itemBody: () => null, snapshot: () => ({ items: [] }), appendLifecycleBatch: vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })), markPendingSubmissionsUnknown, @@ -380,7 +358,6 @@ describe('provider-exit recovery tickets', () => { sessions: new Map([[SESSION, session]]), flushLifecycle: async () => ({ ok: true }), publishFence: vi.fn(), - hasResumeCapableHolder: () => true, serialize: async (_sessionId: string, task: () => Promise) => task(), now: () => 1 } @@ -401,19 +378,24 @@ describe('provider-exit recovery tickets', () => { expect.objectContaining({ mutations: [ expect.objectContaining({ - body: { kind: 'status', text: unexpectedProviderExitOutcome('provider exited') } + body: { + kind: 'status', + text: exitOutcome('Claude'), + failure: { kind: 'providerExited' }, + tone: 'error' + } }) ] }) ) }) - it('does not release or reacquire while terminal settlement retry is still failing', async () => { + it('releases without offering a restart while terminal settlement is failing', async () => { const session: StructuredAgentSessionUnexpectedExitSession = { - hasProviderChild: true, - fence: 7, - acquisitionGeneration: GENERATION, + child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { + cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), + itemBody: () => null, markPendingSubmissionsUnknown: vi.fn(async () => []), rejectPendingSubmissions: vi.fn(async () => []), snapshot: () => ({ @@ -440,45 +422,14 @@ describe('provider-exit recovery tickets', () => { sessions: new Map([[SESSION, session]]), flushLifecycle: async () => ({ ok: false, error: new Error('sink failed') }), publishFence, - hasResumeCapableHolder: () => true, serialize: async (_sessionId, task) => task(), now: () => 1, onBarrierError: release } - const result = await settleUnexpectedStructuredAgentSessionExit(context, event) + await settleUnexpectedStructuredAgentSessionExit(context, event) - expect(result).toBeNull() - expect(session.hasProviderChild).toBe(false) - expect(session.fence).toBe(8) + expect(session.child).toBeNull() expect(publishFence).toHaveBeenCalledTimes(1) expect(release).toHaveBeenCalledTimes(2) }) - - it('admits the exact released generation for a resume-capable holder', () => { - expect(isStructuredAgentSessionRecoveryTicketCurrent(recoveryContext({}), ticket)).toBe(true) - }) - - it('is cancelled by a latched stage before reattachment', () => { - expect( - isStructuredAgentSessionRecoveryTicketCurrent( - recoveryContext({ handoffStage: 'recovering' }), - ticket - ) - ).toBe(false) - }) - - it('is cancelled when its holder or dead acquisition generation is no longer current', () => { - expect( - isStructuredAgentSessionRecoveryTicketCurrent( - recoveryContext({ resumeCapable: false }), - ticket - ) - ).toBe(false) - expect( - isStructuredAgentSessionRecoveryTicketCurrent( - recoveryContext({ generation: 'generation-new' }), - ticket - ) - ).toBe(false) - }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts index 5363b8588e7..3f758f81607 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -1,8 +1,9 @@ -import type { - StructuredAgentSessionEndedEvent, - StructuredAgentSessionProviderChildPhase -} from './structured-agent-session-adapter' +import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionEndedEvent } from './structured-agent-session-adapter' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { endProviderChild } from './structured-agent-session-provider-child' import { releaseStoredStructuredAgentSessionOwnerAfterUnexpectedExit, type StructuredAgentSessionLeaseStore @@ -21,20 +22,10 @@ type UnexpectedExitLifecycleEvent = StructuredAgentSessionEndedEvent & { cause: 'unexpected-exit' } -export type StructuredAgentSessionRecoveryTicket = { - sessionId: string - releasedFence: number - deadAcquisitionGeneration: string - stableSettlementId: string -} - -export type StructuredAgentSessionUnexpectedExitSession = { - journal: DeadGenerationJournal - hasProviderChild: boolean - fence: number - acquisitionGeneration: string | null - providerChildPhase?: StructuredAgentSessionProviderChildPhase -} +export type StructuredAgentSessionUnexpectedExitSession = Pick< + StructuredAgentSessionHostSession, + 'child' | 'lastEndedChild' +> & { journal: DeadGenerationJournal & Pick } export type StructuredAgentSessionUnexpectedExitContext< TSession extends StructuredAgentSessionUnexpectedExitSession = StructuredAgentSessionHostSession @@ -44,7 +35,6 @@ export type StructuredAgentSessionUnexpectedExitContext< flushLifecycle: (sessionId: string) => Promise publishFence: (sessionId: string, session: TSession) => void publishStatus?: (sessionId: string) => void - hasResumeCapableHolder: (sessionId: string) => boolean serialize: (sessionId: string, task: () => Promise) => Promise now: () => number onBarrierError?: (sessionId: string, error: unknown) => void @@ -55,40 +45,50 @@ export async function settleUnexpectedStructuredAgentSessionExit< >( context: StructuredAgentSessionUnexpectedExitContext, event: StructuredAgentSessionEndedEvent -): Promise { +): Promise { if (event.cause !== 'unexpected-exit') { - return null + return } const unexpectedEvent = event as UnexpectedExitLifecycleEvent // Receipt of the exit is the one end time the host may record for a running turn. const observedAt = event.observedAt ?? context.now() return context.serialize(unexpectedEvent.sessionId, async () => { const session = context.sessions.get(unexpectedEvent.sessionId) + const child = session?.child if ( - !session?.hasProviderChild || - session.fence !== unexpectedEvent.fence || - session.acquisitionGeneration !== unexpectedEvent.acquisitionGeneration + !session || + !child || + child.fence !== unexpectedEvent.fence || + child.generation !== unexpectedEvent.acquisitionGeneration ) { - return null + return + } + // The host's own phase decides, so a provider that omits the flag still gets a start that + // failed told as one: the row says so. + const exitedDuringStartup = + unexpectedEvent.startupUnproven === true || child.phase === 'starting' + const endChild = (): void => { + endProviderChild(session, { + generation: child.generation, + fence: child.fence, + cause: 'exit', + reason: unexpectedEvent.reason, + ...(unexpectedEvent.failure ? { failure: unexpectedEvent.failure } : {}), + duringStartup: exitedDuringStartup, + // The adapter publishes an exit only once it saw the root go, first-hand or proven. + rootGone: true + }) + context.publishStatus?.(unexpectedEvent.sessionId) } const record = context.store.getRecord(unexpectedEvent.sessionId) if (!record || record.lease.handoffStage !== null) { // An acquisition or recovery already owns this lease's transition. - session.hasProviderChild = false - context.publishStatus?.(unexpectedEvent.sessionId) - return null + endChild() + return } - // The host's own phase decides, so a provider that omits the flag still gets a start that - // failed told as one: the row says so, and nothing resumes into the same failure. - const exitedDuringStartup = - unexpectedEvent.startupUnproven === true || session.providerChildPhase === 'starting' - let settlementFailed = false const stableSettlementId = providerExitSettlementId(unexpectedEvent) const unfinishedWork = captureUnfinishedStructuredAgentSessionWork(session.journal) - let released: Awaited< - ReturnType - > | null = null try { try { const barrier = await context.flushLifecycle(unexpectedEvent.sessionId) @@ -98,13 +98,15 @@ export async function settleUnexpectedStructuredAgentSessionExit< } catch (error) { context.onBarrierError?.(unexpectedEvent.sessionId, error) } - settlementFailed = !(await retryUnexpectedExitSettlement({ + await retryUnexpectedExitSettlement({ context, event: unexpectedEvent, - session, + journal: session.journal, + fence: child.fence, stableSettlementId, verdict: { state: 'interrupted', completedAt: observedAt }, exitedDuringStartup, + failureTextContext: structuredAgentSessionFailureWordsContext(record, session.journal), // A failed start always says why: no response was running to carry the reason. showUnexpectedExitOutcome: exitedDuringStartup || @@ -113,102 +115,62 @@ export async function settleUnexpectedStructuredAgentSessionExit< session.journal, observedAt ) - })) + }) } finally { // Provider exit was positively observed, so release the owner even when // terminal settlement could not be durably accepted. + let released: Awaited< + ReturnType + > | null = null try { released = await releaseStoredStructuredAgentSessionOwnerAfterUnexpectedExit({ store: context.store, sessionId: unexpectedEvent.sessionId, expectedFence: unexpectedEvent.fence, expectedAcquisitionGeneration: unexpectedEvent.acquisitionGeneration, - acquisitionGeneration: session.acquisitionGeneration, + acquisitionGeneration: child.generation, now: context.now(), exitObservedAt: observedAt, - ...(settlementFailed - ? { - settlementRetry: { - settlementId: stableSettlementId, - // Bare cause: the retry renders it, and `exit-observed` already says the rest. - detail: unexpectedEvent.reason.slice(0, MAX_UNEXPECTED_EXIT_REASON_CHARS) - } - } - : {}) + // Bare cause: whatever this settlement could not write is settled from it later (the + // settle recording it queues, or the next open or acquire); `exit-observed` says the rest. + exitReason: unexpectedEvent.reason.slice(0, MAX_UNEXPECTED_EXIT_REASON_CHARS) }) } catch (error) { context.onBarrierError?.(unexpectedEvent.sessionId, error) } finally { - session.hasProviderChild = false - context.publishStatus?.(unexpectedEvent.sessionId) + endChild() if (released) { - session.fence = released.lease.runtimeFence context.publishFence(unexpectedEvent.sessionId, session) } } } - if (settlementFailed || !released) { - return null - } - // Resuming a start that failed would respawn into the same failure; the next send retries. - if (exitedDuringStartup || !context.hasResumeCapableHolder(unexpectedEvent.sessionId)) { - return null - } - return { - sessionId: unexpectedEvent.sessionId, - releasedFence: released.lease.runtimeFence, - deadAcquisitionGeneration: unexpectedEvent.acquisitionGeneration, - stableSettlementId - } }) } -export function isStructuredAgentSessionRecoveryTicketCurrent( - context: { - store: Pick - sessions: Map< - string, - Pick< - StructuredAgentSessionUnexpectedExitSession, - 'hasProviderChild' | 'fence' | 'acquisitionGeneration' - > - > - hasResumeCapableHolder: (sessionId: string) => boolean - }, - ticket: StructuredAgentSessionRecoveryTicket -): boolean { - const session = context.sessions.get(ticket.sessionId) - const record = context.store.getRecord(ticket.sessionId) - return ( - session?.hasProviderChild === false && - session.fence === ticket.releasedFence && - session.acquisitionGeneration === ticket.deadAcquisitionGeneration && - record?.lease.runtimeFence === ticket.releasedFence && - record.lease.claimStatus === 'released' && - record.lease.handoffStage === null && - context.hasResumeCapableHolder(ticket.sessionId) - ) -} - async function retryUnexpectedExitSettlement(input: { context: Pick event: UnexpectedExitLifecycleEvent - session: Pick + journal: DeadGenerationJournal + fence: number stableSettlementId: string verdict: StructuredAgentSessionTurnVerdict exitedDuringStartup: boolean + failureTextContext: AgentSessionFailureWordsContext showUnexpectedExitOutcome?: boolean }): Promise { return settleStructuredAgentSessionDeadGeneration({ - journal: input.session.journal, + journal: input.journal, sessionId: input.event.sessionId, - fence: input.session.fence, + fence: input.fence, settlementId: input.stableSettlementId, verdict: input.verdict, pendingSubmissionReason: 'provider_exited_before_acknowledgement', showUnexpectedExitOutcome: input.showUnexpectedExitOutcome, - unexpectedExitReason: input.event.reason, - exitedDuringStartup: input.exitedDuringStartup, + ...(input.event.failure ? { exitFailure: input.event.failure } : {}), + failureTextContext: input.failureTextContext, + ...(input.exitedDuringStartup + ? { exitedDuringStartup: { generation: input.event.acquisitionGeneration } } + : {}), onError: input.context.onBarrierError }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts new file mode 100644 index 00000000000..957ac2f275d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts @@ -0,0 +1,212 @@ +// A Claude chat whose CLI exits before it finishes starting leaves one red row per start. A view +// opening, or coming back to, a chat whose last start failed used to start the CLI again, so every +// look at the chat added an identical row. Only a send retries a failed start: it is the user asking. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import { + fakeClaude, + PROVIDER_SESSION_ID +} from '../../claude/claude-structured-session-test-support' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +/** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ +function eventually(assertion: () => unknown): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +const CALLER = { callerKey: 'client-1' } +const SURFACE = 'desktop-chat:1' +const LAUNCH_FAILURE = + 'claude stream-json exited (code 1): qa-shim: simulated claude launch failure' + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let adapter: ClaudeStructuredSessionAdapter +let claude: ReturnType +let lifecycle: Promise[] +/** Every initialize waits on it: a start that must outlast a step does not race a timer. */ +let initGate: Promise + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-view-start-after-failed-start-')) + resetHostTestOperationIds() + lifecycle = [] + initGate = Promise.resolve() + // Every start spawns, is published, and exits before it answers initialize. + claude = fakeClaude({ initDelayMs: 20, exitBeforeInit: LAUNCH_FAILURE }) + adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: root, + claudeConfigDir: join(root, 'claude-home'), + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0, + continuesChain: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0 + }), + onEvent: (event) => { + const mapped = structuredClaudeLifecycleEvent(event) + if (mapped) { + lifecycle.push(host.handleAdapterEvent(mapped)) + } + }, + openConnection: async (launch, handlers) => { + const connection = await claude.openConnection(launch, handlers) + const initialize = connection.initializationResult + return Object.assign(connection, { + initializationResult: async () => { + await initGate + return initialize() + } + }) + }, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => NOW + }) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${claude.connections.length + 1}`, + now: () => NOW + }) +}) + +afterEach(async () => { + await adapter.closeAll() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +/** Waits until the adapter has published every exit it saw and the host settled each one. */ +async function settleExits(): Promise { + await eventually(async () => { + await adapter.drainObservedExits() + await Promise.all(lifecycle) + expect((await host.journalSnapshot(SESSION)).items.length).toBeGreaterThan(0) + }) + await Promise.all(lifecycle) +} + +/** A view of the chat: a subscription, which reads the chat and starts nothing. */ +function view(id: string): Promise<() => void> { + return host.subscribe({ id, sessionId: SESSION, emit: () => undefined }) +} + +/** The chat as it renders: the user's messages and the error rows, in journal order. */ +async function timeline(): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'message' + ? ['message'] + : item.body.kind === 'status' && item.body.tone === 'error' + ? [item.body.text] + : [] + ) +} + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + expect(sent).toMatchObject({ ok: true }) + return sent.ok ? sent.value.clientMessageId : '' +} + +describe('a fresh chat whose Claude start fails', () => { + // QA saw three failed starts from opening the chat alone: the create's, and the view's. + it.each([ + ['after the create already died', false], + ['while the create is still starting', true] + ] as const)( + 'starts once for the open and once for a send, one row each, when the view binds %s', + async (_when, createStillStarting) => { + // Released only once the views bound, so no runner is slow enough to let the create die first. + let releaseCreate = (): void => {} + const createGate = new Promise((resolve) => { + releaseCreate = resolve + }) + if (!createStillStarting) { + releaseCreate() + } + initGate = createGate + claude = fakeClaude({ exitBeforeInit: LAUNCH_FAILURE }) + await expect( + host.attach( + CALLER, + hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + }) + ) + ).resolves.toMatchObject({ ok: true }) + if (!createStillStarting) { + await settleExits() + } + // Two surfaces bind, as a pane and a second window do. + const unsubscribe = await view(SURFACE) + await view('desktop-chat:2') + if (createStillStarting) { + // The views bound to the create's child itself, before it exited. + expect(await timeline()).toEqual([]) + releaseCreate() + } + await settleExits() + // The row says the provider stopped; its stderr stays out of the sentence. + const startFailure = + 'Claude stopped before it finished starting. Send your message to try again.' + // Opening the chat: the create's start, once, and its row. + expect(claude.connections).toHaveLength(1) + expect(await timeline()).toEqual([startFailure]) + + const sent = await send('reply with exactly: alpha') + await eventually(async () => + expect( + (await host.journalSnapshot(SESSION)).submissions.find((s) => s.clientMessageId === sent) + ).toMatchObject({ dispatchState: 'rejected', reason: startFailure }) + ) + await settleExits() + // The send's own start, once, and one row for it below the message. + expect(claude.connections).toHaveLength(2) + expect(await timeline()).toEqual([startFailure, 'message', startFailure]) + + // Switching away and back re-subscribes; it starts nothing and adds no row. + unsubscribe() + await view(SURFACE) + await settleExits() + expect(claude.connections).toHaveLength(2) + expect(await timeline()).toEqual([startFailure, 'message', startFailure]) + } + ) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index 21a5fe4e49d..05c57a5941c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // The profiles already shipped into a dead end. // // Every record here is a shape taken from a real wedged store: a lease that no acquisition, no @@ -6,16 +7,20 @@ // conversation — the journal, the provider handle chain, and the recorded evidence all survive. // // "Usable" means ACQUIRABLE, not acquired. Startup no longer resumes a provider child for a record -// nobody is looking at; a surface taking a hold is what spawns one. So the migration's job is to -// leave the lease in a state a hold can claim, and these tests prove that by adjudicating it rather -// than by reading fields off it. +// nobody is looking at; work that needs the agent is what spawns one. So the migration's job is to +// leave the lease in a state an attach can claim, and these tests prove that by adjudicating it +// rather than by reading fields off it. import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { evaluateAgentSessionAcquisition } from '../../../shared/agent-session-lease-adjudication' +import { + evaluateAgentSessionAcquisition, + type AgentSessionOwnerProbe +} from '../../../shared/agent-session-lease-adjudication' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionClaimStatus, @@ -31,7 +36,7 @@ import { AGENT_SESSION_STORE_FILE_NAME } from '../../runtime/agent-session-recor import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import { journalDirectoryFor } from '../agent-session-journal/journal-paths' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { StructuredAgentSessionHost } from './structured-agent-session-host' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { @@ -145,6 +150,11 @@ function openHost(overrides: Partial = {}): void }) } +/** The host starting the agent with no message to deliver, as an operation that needs it does. */ +function startAgent(): Promise { + return host['serialize'](SESSION, () => host['mutationContext']().ensureAgent(SESSION)) +} + beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-wedged-profile-')) resetHostTestOperationIds() @@ -182,6 +192,9 @@ function isAcquirable(lease: NonNullable>['le ) } +/** After the owner's last renewal, so a turn it proves dead ends at its start, never before. */ +const SEEDED_TURN_STARTED_AT = NOW - 5_000 + async function seedRunningTurn(provider: 'codex' | 'claude' = 'codex'): Promise { const journal = await openAgentSessionJournal({ identity: { @@ -200,8 +213,8 @@ async function seedRunningTurn(provider: 'codex' | 'claude' = 'codex'): Promise< provider === 'codex' ? { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 0 } : { provider: 'claude', sessionId: 'provider-session-alpha-1', uuid: 'uuid-running' }, - { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: NOW - 5_000 }, - { fence: 13 } + { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: SEEDED_TURN_STARTED_AT }, + { fence: 13, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.close() } @@ -265,9 +278,7 @@ describe('already-wedged profiles become usable on load', () => { expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', - handoffStage: null, - settlementRetryRequired: undefined, - settlementRetryId: undefined + handoffStage: null }) expect(acquire).not.toHaveBeenCalled() @@ -284,40 +295,130 @@ describe('already-wedged profiles become usable on load', () => { } ) - it('settles restart eviction through attach when a hold arrives before the boot sweep', async () => { + it.each([ + [ + 'an exit the host saw but could not settle before quitting', + wedgedRecord({ claimStatus: 'released', handoffStage: null }), + { + kind: 'exit-observed', + detail: 'provider exited: transport closed', + observedAt: NOW - 1_000 + } as const, + { state: 'interrupted', completedAt: NOW - 1_000 } + ], + [ + 'a quit that left the owner for a probe to prove gone', + wedgedRecord({ claimStatus: 'live', handoffStage: null, ownerProcess: DEAD_OWNER }), + null, + { state: 'interrupted', completedAt: SEEDED_TURN_STARTED_AT } + ], + [ + 'a quit that left an owner on a host this one cannot probe', + wedgedRecord({ + claimStatus: 'live', + handoffStage: null, + ownerProcess: { ...DEAD_OWNER, hostId: 'remote-host' } + }), + null, + { state: 'unverifiable' } + ] + ] as const)( + 'reopens a chat that was mid-turn at %s with nothing running and no working status', + async (_quit, seeded, deathEvidence, verdict) => { + await seedStore({ ...seeded, lease: { ...seeded.lease, deathEvidence } }) + await seedRunningTurn() + const published: AgentSessionStatusSummary[] = [] + const remote = seeded.lease.ownerProcess?.hostId === 'remote-host' + openHost({ + statusSink: { publish: (summary) => published.push(summary), forget: () => {} }, + // Loss of contact is never proof of death: a remote owner only ever probes indeterminate. + ...(remote + ? { + probeOwner: async () => ({ + outcome: 'indeterminate' as const, + reason: 'owner runs on remote-host, which this host cannot probe' + }) + } + : {}) + }) + + await host.restoreReadableSessions() + + expect(acquire).not.toHaveBeenCalled() + expect(turnLifecycle('turn-1')).toEqual({ + turnId: 'turn-1', + startedAt: NOW - 5_000, + recovered: true, + ...verdict + }) + expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null) + // The row never carries the proof's detail, which is Orca's log text. + const statusRows = restoredJournal() + .snapshot() + .items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) + expect(statusRows).toEqual( + remote + ? [] + : [ + 'Codex stopped while this response was in progress. You can continue in this conversation.' + ] + ) + // What the sidebar reads: every status this restart published says the chat is not working. + expect(published.filter((summary) => summary.sessionId === SESSION)).not.toEqual([]) + expect(published.map((summary) => summary.status)).not.toContain('working') + // A crash is not something the user did: no outcome is claimed, so no reader files it as a + // cancellation the user already knows about. + expect(published.map((summary) => summary.turnOutcome)).toEqual( + published.map(() => undefined) + ) + } + ) + + it('settles restart eviction through attach when a start arrives before the boot sweep', async () => { await seedStore( wedgedRecord({ claimStatus: 'live', handoffStage: null, ownerProcess: DEAD_OWNER }) ) await seedRunningTurn() openHost() - await host.hold(SESSION, 'desktop-chat:restart') + // The attach adjudicates the dead owner first, which moves the fence; like a client's ensure, + // it is retried at the fence the refusal names. + const stale = await host.attach(CALLER, hostTestAttachParams(13)) + const fence = stale.ok ? 13 : (stale.refusal.currentFence ?? 13) + expect(stale.ok || stale.refusal.code === 'agent_session_checkpoint_stale').toBe(true) + expect(stale.ok || (await host.attach(CALLER, hostTestAttachParams(fence))).ok).toBe(true) expect(acquire).toHaveBeenCalledOnce() expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null) - // A pid probe proved the owner gone; nobody saw it exit, so the turn has no end. + // A pid probe proved the owner gone, so the turn was cut short when it was last proven alive. expect(turnLifecycle('turn-1')).toEqual({ turnId: 'turn-1', - state: 'unverifiable', + state: 'interrupted', startedAt: NOW - 5_000, + completedAt: SEEDED_TURN_STARTED_AT, recovered: true }) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', - handoffStage: null, - settlementRetryRequired: undefined, - settlementRetryId: undefined + handoffStage: null }) }) it('settles an observed-exit latch through attach before the boot sweep', async () => { const record = wedgedRecord({ claimStatus: 'released', handoffStage: 'recovering' }) - record.lease.settlementRetryRequired = true - record.lease.settlementRetryId = `provider-exit:${SESSION}:12:generation-1` - record.lease.deathEvidence = { - kind: 'exit-observed', - detail: 'provider exited: transport closed', - observedAt: NOW - 1_000 + // The settlement latch an older build wrote; this build derives the settlement instead. + const olderBuildLatch = { + settlementRetryRequired: true, + settlementRetryId: `provider-exit:${SESSION}:12:generation-1` + } + record.lease = { + ...record.lease, + ...olderBuildLatch, + deathEvidence: { + kind: 'exit-observed', + detail: 'provider exited: transport closed', + observedAt: NOW - 1_000 + } } await seedStore(record) await seedRunningTurn() @@ -337,12 +438,114 @@ describe('already-wedged profiles become usable on load', () => { }) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', - handoffStage: null, - settlementRetryRequired: undefined, - settlementRetryId: undefined + handoffStage: null }) + // The older build's latch is dropped at load, so a downgrade never sees it again. + expect(store.getRecord(SESSION)?.lease).not.toHaveProperty('settlementRetryRequired') + expect(store.getRecord(SESSION)?.lease).not.toHaveProperty('settlementRetryId') }) + it.each([ + ['a restart eviction', false], + ['a proven eviction by recovery', true] + ] as const)( + 'settles the turn %s left at the next acquire when the read restore could not write it', + async (_origin, ownerOutlivedRestart) => { + await seedStore( + wedgedRecord({ claimStatus: 'live', handoffStage: null, ownerProcess: DEAD_OWNER }) + ) + await seedRunningTurn() + let ownerAlive = ownerOutlivedRestart + const stopOwnerProcess = vi.fn(() => { + ownerAlive = false + }) + openHost({ + probeOwner: async () => + ownerAlive + ? { outcome: 'identity-matched', matchedOn: ['spawn-token'] } + : { outcome: 'pid-absent' }, + stopOwnerProcess + }) + // The read restore's settlement fails, and nothing retries it. + const failing = vi + .spyOn(AgentSessionJournal.prototype, 'appendLifecycleBatch') + .mockRejectedValue(new Error('journal unavailable')) + await host.restoreReadableSessions() + failing.mockRestore() + expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe('turn-1') + expect(stopOwnerProcess).toHaveBeenCalledTimes(ownerOutlivedRestart ? 1 : 0) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + deathEvidence: { kind: 'pid-absent' } + }) + + await startAgent() + + expect(acquire).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live' }) + // A pid probe proved the owner gone, so the turn was cut short when it was last proven alive. + expect(turnLifecycle('turn-1')).toEqual({ + turnId: 'turn-1', + state: 'interrupted', + startedAt: NOW - 5_000, + completedAt: SEEDED_TURN_STARTED_AT, + recovered: true + }) + } + ) + + it('releases an owner that survives every stop signal, and the chat starts again', async () => { + await seedStore( + wedgedRecord({ claimStatus: 'live', handoffStage: null, ownerProcess: DEAD_OWNER }) + ) + const stopOwnerProcess = vi.fn() + openHost({ + probeOwner: async () => ({ outcome: 'identity-matched', matchedOn: ['spawn-token'] }), + stopOwnerProcess + }) + + await host.restoreReadableSessions() + + expect(stopOwnerProcess.mock.calls).toEqual([ + [DEAD_OWNER.pid, 'SIGTERM'], + [DEAD_OWNER.pid, 'SIGKILL'] + ]) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + ownerProcess: null, + deathEvidence: null + }) + expect(await host.attach(CALLER, hostTestAttachParams(14))).toMatchObject({ ok: true }) + expect(acquire).toHaveBeenCalledOnce() + }) + + it.each([ + ['a conflicted claim naming no process', { claimStatus: 'conflicted', ownerProcess: null }], + [ + 'an unproven reservation left in manual recovery', + { claimStatus: 'reserved', ownerProcess: null, reservedSpawnToken: 'spawn-lost' } + ] + ] as const)( + 'releases %s an older build left, and the chat starts again', + async (_legacyRecord, lease) => { + await seedStore(wedgedRecord({ handoffStage: 'manual-recovery', ...lease })) + openHost({ probeOwner: async () => ({ outcome: 'indeterminate', reason: 'no scan here' }) }) + + await host.restoreReadableSessions() + + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + runtimeFence: 14, + deathEvidence: null + }) + expect(await host.attach(CALLER, hostTestAttachParams(14))).toMatchObject({ ok: true }) + expect(acquire).toHaveBeenCalledOnce() + } + ) + it('marks a running turn left behind by a released lease unverifiable on a cold acquire', async () => { // No settlement latch: the record was released cleanly, but the journal still says a turn is // running. The child that wrote it is gone and nothing observed its exit. @@ -380,7 +583,7 @@ describe('already-wedged profiles become usable on load', () => { await restoredJournal().appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-2', ordinal: 0 }, { kind: 'turn', turnId: 'turn-2', state: 'running', startedAt: NOW }, - { fence } + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) // A reconnecting client replays its attach; the same operation admits the live owner. @@ -419,27 +622,6 @@ describe('already-wedged profiles become usable on load', () => { expect(acquire).not.toHaveBeenCalled() }) - it('leaves a conflicted record alone while its owner cannot be proven gone', async () => { - await seedStore( - wedgedRecord({ - claimStatus: 'conflicted', - handoffStage: 'manual-recovery', - ownerProcess: DEAD_OWNER - }) - ) - openHost({ - probeOwner: async () => ({ outcome: 'identity-matched', matchedOn: ['spawn-token'] }) - }) - - await host.restoreReadableSessions() - - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'conflicted', - handoffStage: 'manual-recovery', - ownerProcess: { pid: DEAD_OWNER.pid } - }) - }) - it('unlatches a released record that reloaded into recovery with nothing outstanding', async () => { // An evicted lease has no owner and no token, so a restart has nothing to probe. Treating that // as an unproven reservation re-latched it to `recovering` on every single boot. @@ -523,12 +705,13 @@ describe('already-wedged profiles become usable on load', () => { await host.restoreReadableSessions() expect(order).toEqual([]) expect(scan).not.toHaveBeenCalled() - await host.hold(SESSION, 'holder-1') + const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null + expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: true }) expect(order).toEqual(['acquire']) }) - it('names the missing evidence when a latched record still cannot be freed', async () => { + it('waits out a conflicted owner it cannot verify, signalling nothing, until it is proven gone', async () => { await seedStore( wedgedRecord({ claimStatus: 'conflicted', @@ -536,14 +719,23 @@ describe('already-wedged profiles become usable on load', () => { ownerProcess: DEAD_OWNER }) ) - openHost({ probeOwner: async () => ({ outcome: 'indeterminate', reason: 'no answer' }) }) + const stopOwnerProcess = vi.fn() + let probe: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'no answer' } + openHost({ probeOwner: async () => probe, stopOwnerProcess }) await host.restoreReadableSessions() - const refused = await host.attach(CALLER, hostTestAttachParams(13)) + // A terminal agent keeps its transport across a restart, so an unanswered probe is not a way in. + const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null + expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_conflict' } + }) + expect(acquire).not.toHaveBeenCalled() - expect(refused.ok).toBe(false) - const message = refused.ok ? '' : refused.refusal.message - expect(message).toContain('process 12546 on local') - expect(message).not.toContain('The session store refused this call') + // The user quits that terminal: the next start proves it gone and the chat takes over. + probe = { outcome: 'pid-absent' } + await startAgent() + expect(acquire).toHaveBeenCalledOnce() + expect(stopOwnerProcess).not.toHaveBeenCalled() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts index 585cbd4fbbd..cb9f4285d73 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts @@ -9,7 +9,10 @@ import type { import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../../shared/remote-runtime-memory-limits' import { mobileE2EETextPayloadAdmissionBytes } from '../../runtime/rpc/mobile-e2ee-outbound-admission' -import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION +} from '../../../shared/agent-session-journal-types' import { openJournalDatabase } from '../agent-session-journal/journal-database' import { journalDatabaseFile } from '../agent-session-journal/journal-paths' import { insertJournalRow } from '../agent-session-journal/journal-row-table' @@ -39,7 +42,10 @@ beforeEach(async () => { journalDir: root }) for (let ordinal = 1; ordinal <= 20; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`${ordinal}:${LARGE_TEXT}`), { fence: 1 }) + await journal.appendItem(item(ordinal), body(`${ordinal}:${LARGE_TEXT}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts index 8eac02dc17b..c3186d00daf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts @@ -16,7 +16,8 @@ import { agentSessionProviderHandleChainHead, agentSessionProviderHandleRoot } from '../../../shared/agent-session-provider-handle' -import { latestStructuredAgentSessionUserItem } from '../../../shared/structured-agent-session-projection' +import { latestStructuredAgentSessionUserItem } from '../../../shared/structured-agent-session-latest-request' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionResumeMarker, @@ -37,6 +38,12 @@ import { type AgentSessionRestartTask } from '../../../shared/agent-session-restart-activity' import { isLiveChildWork } from '../../../shared/agent-status-child-work-liveness' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { + isStructuredAgentSessionCommandEntry, + isStructuredAgentSessionCommandTurn +} from '../../../shared/structured-agent-session-command-entry' import { activeStructuredAgentSessionTurnId, newestStructuredAgentSessionTurn @@ -83,12 +90,31 @@ function structuredAgentSessionResumeWork( items: readonly AgentJournalRenderItem[], submissions: readonly AgentJournalSubmission[] ): AgentSessionResumeWork | null { + const bodies = new Map(items.map((item) => [item.itemId, item.body])) + const bodyOf = (itemId: string) => bodies.get(itemId) + // A conversation command in flight is nothing to resume: the user ran it, not the agent. + const newest = newestStructuredAgentSessionTurn(items) + const inFlightSubmission = pendingSubmissionInFlight(submissions) + if ( + (newest?.state === 'running' && isStructuredAgentSessionCommandTurn(newest, bodyOf)) || + (inFlightSubmission && + isStructuredAgentSessionCommandEntry( + bodyOf(agentJournalSubmissionKey(inFlightSubmission.clientMessageId)) + )) + ) { + return null + } const inFlight = structuredAgentSessionWorkInFlight(items, submissions) if (inFlight) { return inFlight } - const newest = newestStructuredAgentSessionTurn(items) - return newest ? { kind: 'turn', id: newest.turnId } : null + // A settled lead whose children were the work anchors on its last real turn. + const lastRequest = items.findLast((item) => { + const turn = readAgentJournalTurn(item.body) + return turn !== null && !isStructuredAgentSessionCommandTurn(turn, bodyOf) + }) + const turn = readAgentJournalTurn(lastRequest?.body) + return turn ? { kind: 'turn', id: turn.turnId } : null } function boundedLabel(text: string | undefined): string { @@ -135,8 +161,7 @@ function liveTasks( type WorkingCandidateSession = { journal: AgentSessionJournal /** Only this host generation's own child counts. A restored-for-reading journal has none. */ - hasProviderChild: boolean - fence?: number + child: { fence: number } | null } /** The offer one session is owed, taken right before teardown stops its provider child; null when @@ -154,16 +179,24 @@ export function structuredAgentSessionWorkingAtStop(input: { }): AgentSessionResumeMarker | null { const { sessionId, session } = input // A journal this host cannot read tells us nothing about what the turn was doing. - if (!session?.hasProviderChild || session.journal.isReadOnly) { + if (!session?.child || session.journal.isReadOnly) { return null } const snapshot = session.journal.snapshot() + // A queued message reached no agent, so it is no work to resume: quit rejects it as never sent. + const handedOver = snapshot.submissions.filter( + (submission) => !isQueuedAgentJournalSubmission(submission) + ) const roster = input.backgroundTasks(sessionId) - const status = structuredAgentSessionShownStatus(snapshot, roster, session.fence) + const status = structuredAgentSessionShownStatus( + { items: snapshot.items, submissions: handedOver }, + roster, + session.child.fence + ) if (status.state === 'done') { return null } - const work = structuredAgentSessionResumeWork(snapshot.items, snapshot.submissions) + const work = structuredAgentSessionResumeWork(snapshot.items, handedOver) const head = agentSessionProviderHandleChainHead( input.getRecord(sessionId)?.providerHandleChain ?? [] ) @@ -184,6 +217,7 @@ export function structuredAgentSessionWorkingAtStop(input: { recordedAt: input.now, trigger: input.trigger, teardownId: input.teardownId, + journalCursor: snapshot.cursor, // Root, not key: the close path advances Claude's leaf moments after this runs, and a key // comparison would then refuse the session forever. providerHandleRoot: agentSessionProviderHandleRoot(head.handle), diff --git a/src/main/native-chat/agent-session-wire/structured-compaction-recovery.ts b/src/main/native-chat/agent-session-wire/structured-compaction-recovery.ts deleted file mode 100644 index 2199da05392..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-compaction-recovery.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' - -/** A newly acquired owner cannot still be executing the previous owner's command. */ -export async function recoverInterruptedCompaction( - store: AgentSessionRecordStore, - sessionId: string, - journal: AgentSessionJournal, - fence: number -): Promise { - const command = store.getRecord(sessionId)?.conversationCommand - if ( - command?.command !== 'compact' || - command.phase !== 'prepared' || - command.runtimeFence === undefined || - command.runtimeFence === fence - ) { - return - } - const error = 'Previous compaction completion could not be confirmed after session recovery.' - await journal.appendItem( - { provider: 'orca', clientMessageId: `compact:${command.operationId}` }, - { kind: 'status', text: error }, - { fence } - ) - const recovered = { ...command, phase: 'committed' as const, state: 'unknown' as const, error } - await store.setConversationCommand(sessionId, fence, recovered) - await store.recordOperationOutcome({ - callerKey: command.callerKey, - operationId: command.operationId, - outcome: { status: 'succeeded', sessionId, conversationCommand: recovered } - }) -} diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts index 5a3370c910f..0acdb2dbbce 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts @@ -29,12 +29,12 @@ describe('conversationCommandBlocked background tasks', () => { contextWith({ state: 'monitoring', supportsTaskStop: true }), RECORD ) - expect(blocked).toBe('Stop background tasks before using this command.') + expect(blocked?.message).toBe('Stop background tasks before using this command.') }) it('asks for a stop on a host that predates the stop-capability field', () => { const blocked = conversationCommandBlocked(contextWith({ state: 'monitoring' }), RECORD) - expect(blocked).toBe('Stop background tasks before using this command.') + expect(blocked?.message).toBe('Stop background tasks before using this command.') }) it('asks the user to wait when the provider exposes no stop at all', () => { @@ -43,7 +43,7 @@ describe('conversationCommandBlocked background tasks', () => { contextWith({ state: 'monitoring', supportsStopAll: false }), RECORD ) - expect(blocked).toBe('Wait for background tasks to finish before using this command.') + expect(blocked?.message).toBe('Wait for background tasks to finish before using this command.') }) it('still refuses on the open turn, not on the work the strip now shows', () => { @@ -63,8 +63,77 @@ describe('conversationCommandBlocked background tasks', () => { } ] }) as unknown as ReturnType - expect(conversationCommandBlocked(ctx, RECORD)).toBe( + expect(conversationCommandBlocked(ctx, RECORD)?.message).toBe( 'Wait for the current turn to finish before using this command.' ) }) }) + +describe('conversationCommandBlocked for a command sent at rest (C6, B3)', () => { + const staleTurn = { + items: [{ itemId: 'turn-1', body: { kind: 'turn', turnId: 'turn-1', state: 'running' } }] + } + + it("does not refuse over a dead generation's running turn, which the start sweeps", () => { + const ctx = contextWith(null) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only each item's body. + ctx.journal.snapshot = () => staleTurn as never + expect(conversationCommandBlocked(ctx, RECORD, 'at-rest')).toBeNull() + expect(conversationCommandBlocked(ctx, RECORD)).toMatchObject({ + details: { reason: 'turnActive' }, + message: 'Wait for the current turn to finish before using this command.' + }) + }) + + it("ignores an older build's compaction record", () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only the lease and the command record. + const record = { + lease: {}, + conversationCommand: { command: 'compact', phase: 'prepared', state: 'unknown' } + } as unknown as AgentSessionRecord + expect(conversationCommandBlocked(contextWith(null), record)).toBeNull() + }) + + // A clear's commit is its only durable write, so a record short of it never changed the chat. + it("ignores a clear that never committed, as an older build's record leaves one", () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only the lease and the command record. + const record = { + lease: {}, + conversationCommand: { + command: 'clear', + phase: 'prepared', + state: 'unknown', + replacementSessionId: 'clear-replacement' + } + } as unknown as AgentSessionRecord + expect(conversationCommandBlocked(contextWith(null), record)).toBeNull() + }) + + it('refuses on a committed clear', () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only the lease and the command record. + const record = { + lease: {}, + conversationCommand: { + command: 'clear', + phase: 'committed', + state: 'completed', + replacementSessionId: 'clear-replacement' + } + } as unknown as AgentSessionRecord + expect(conversationCommandBlocked(contextWith(null), record)).toMatchObject({ + code: 'agent_session_operation_invalid', + details: { reason: 'conversationCleared' } + }) + }) + + it('at handover, lets the command itself and messages queued behind it wait', () => { + const ctx = contextWith(null) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only the dispatch fields. + const queued = [ + { clientMessageId: 'command', dispatchState: 'pending', handoverRecorded: true }, + { clientMessageId: 'behind', dispatchState: 'pending', handoverRecorded: true } + ] as never + ctx.journal.submissions = () => queued + expect(conversationCommandBlocked(ctx, RECORD, 'handover')).toBeNull() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts index 78b9f4a59d0..937f63733c5 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts @@ -1,33 +1,55 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' import type { AgentSessionTurnContext } from './structured-agent-session-turns' +import { + refuse, + type AgentSessionRefusalReason, + type AgentSessionWireRefusal +} from '../../../shared/agent-session-wire-refusals' +function blocked( + reason: AgentSessionRefusalReason<'agent_session_operation_invalid'>, + message: string +): AgentSessionWireRefusal { + return refuse('agent_session_operation_invalid', { reason }, message) +} + +export function conversationCommandInFlight(): AgentSessionWireRefusal { + return blocked('conversationCommandInFlight', 'Wait for the conversation operation to finish.') +} + +/** + * Why a conversation command may not run now; null when it may. + * + * `at-rest`: a command accepted with no child running. A running turn on record then belongs to a + * dead generation, which the start before handover sweeps, so it refuses nothing yet. + * `handover`: the command is the oldest queued message, and those queued behind it wait for it. + */ export function conversationCommandBlocked( - ctx: AgentSessionTurnContext, - record: AgentSessionRecord -): string | null { + ctx: Pick, + record: AgentSessionRecord, + admission?: 'at-rest' | 'handover' +): AgentSessionWireRefusal | null { const items = ctx.journal.snapshot().items if (record.rewind?.phase === 'prepared' || record.rewind?.phase === 'provider-succeeded') { - return 'agent_session_rewind:outcome-unknown' + return blocked('rewindUnconfirmed', 'agent_session_rewind:outcome-unknown') } if ( record.conversationCommand?.command === 'clear' && record.conversationCommand.phase === 'committed' && record.conversationCommand.replacementSessionId ) { - return 'This conversation has been cleared. Open the current conversation to continue.' - } - if ( - record.conversationCommand?.state === 'unknown' && - record.conversationCommand.phase === 'prepared' - ) { - return 'The previous conversation operation is unconfirmed.' + return blocked( + 'conversationCleared', + 'This conversation has been cleared. Open the current conversation to continue.' + ) } if (record.lease.handoffStage || record.lease.handoffOperationId) { - return 'Wait for the session handoff to finish.' + return blocked('handoffInFlight', 'Wait for the session handoff to finish.') } - if (activeStructuredAgentSessionTurnId(items)) { - return 'Wait for the current turn to finish before using this command.' + if (admission !== 'at-rest' && activeStructuredAgentSessionTurnId(items)) { + return blocked('turnActive', 'Wait for the current turn to finish before using this command.') } if ( items.some( @@ -36,23 +58,36 @@ export function conversationCommandBlocked( item.body.resolution.state === 'pending' ) ) { - return 'Resolve the pending question or approval before using this command.' + return blocked( + 'promptPending', + 'Resolve the pending question or approval before using this command.' + ) } const backgroundTasks = ctx.adapter.backgroundTaskState?.(ctx.sessionId) if (backgroundTasks?.state === 'monitoring') { // Only ask for a stop the host can actually perform. A provider that // exposes neither a targeted nor an untargeted stop would otherwise leave // the command refused behind an instruction nobody can follow. - return backgroundTasks.supportsTaskStop || backgroundTasks.supportsStopAll !== false - ? 'Stop background tasks before using this command.' - : 'Wait for background tasks to finish before using this command.' + return blocked( + 'backgroundTasksRunning', + backgroundTasks.supportsTaskStop || backgroundTasks.supportsStopAll !== false + ? 'Stop background tasks before using this command.' + : 'Wait for background tasks to finish before using this command.' + ) } if ( - ctx.journal - .submissions() - .some((entry) => entry.dispatchState === 'pending' || entry.dispatchState === 'unknown') + ctx.journal.submissions().some( + (entry) => + (entry.dispatchState === 'pending' && + !(admission === 'handover' && isQueuedAgentJournalSubmission(entry))) || + // Doubt left by an earlier child is not this one's work in flight. + (entry.dispatchState === 'unknown' && entry.recovered !== true && entry.fence === ctx.fence) + ) ) { - return 'Resolve pending or unconfirmed messages before using this command.' + return blocked( + 'messagesUnsettled', + 'Resolve pending or unconfirmed messages before using this command.' + ) } return null } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts index e5a0283ba83..4e4e42c46e4 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts @@ -1,43 +1,41 @@ +import { conversationCommandInFlight } from './structured-conversation-command-admission' import { sendStructuredAgentSessionTurn } from './structured-agent-session-host-mutations' import { runStructuredConversationCommand, type ConversationCommandParams } from './structured-conversation-command' +import { runStructuredCompaction } from './structured-conversation-compaction' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import type { StructuredAgentSessionHost } from './structured-agent-session-host' export class StructuredConversationCommandController { + /** Held only by a clear, which replaces the conversation a send would land in. A compaction is + * a queued message, and sends accepted behind it wait for it in the queue. */ readonly pending = new Map() constructor( private readonly context: () => StructuredAgentSessionMutationContext, - private readonly host: Pick + private readonly host: Pick< + StructuredAgentSessionHost, + 'attach' | 'flushStreamedEvents' | 'waitForSendSettlement' + > ) {} send = ( caller: StructuredAgentSessionCaller, params: Parameters[2] ): ReturnType => this.pending.has(params.envelope.sessionId) - ? Promise.resolve({ - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: 'Wait for the conversation operation to finish.' - } - }) + ? Promise.resolve({ ok: false, refusal: conversationCommandInFlight() }) : sendStructuredAgentSessionTurn(this.context(), caller, params) run = (caller: StructuredAgentSessionCaller, params: ConversationCommandParams) => { + if (params.command === 'compact') { + return runStructuredCompaction(this.context(), this.host, caller, params) + } const key = JSON.stringify([caller.callerKey, params.envelope.clientOperationId]) const pending = this.pending.get(params.envelope.sessionId) if (pending && pending.key !== key) { - return Promise.resolve({ - ok: false as const, - refusal: { - code: 'agent_session_operation_invalid' as const, - message: 'Wait for the conversation operation to finish.' - } - }) + return Promise.resolve({ ok: false as const, refusal: conversationCommandInFlight() }) } const entry = pending ?? { key, count: 0 } entry.count++ @@ -47,6 +45,9 @@ export class StructuredConversationCommandController { if (--entry.count === 0 && this.pending.get(params.envelope.sessionId) === entry) { this.pending.delete(params.envelope.sessionId) } + // A clear can settle with no journal commit (a failed attach), and drafts held behind + // its prepared phase would otherwise wait for an unrelated commit. + this.context().wakeQueuedDrain?.(params.envelope.sessionId) } ) } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-outcome.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-outcome.test.ts new file mode 100644 index 00000000000..a2c0e9aef6b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-outcome.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { structuredCompactionOutcome } from './structured-conversation-command-outcome' + +describe('structuredCompactionOutcome', () => { + it('is a success only when the provider reported the compaction', () => { + expect(structuredCompactionOutcome({ compacted: true, interruptRequested: true })).toEqual({ + outcome: 'success' + }) + }) + + it("reads no compaction after Orca's interrupt as the user's cancellation", () => { + expect( + structuredCompactionOutcome({ + compacted: false, + interruptRequested: true, + failed: { detail: { text: 'API Error: Request was aborted.', audience: 'person' } } + }) + ).toEqual({ outcome: 'cancellation' }) + }) + + it("reads a failure the provider reported as a failed compaction, keeping the provider's words", () => { + expect( + structuredCompactionOutcome({ + compacted: false, + interruptRequested: false, + failed: { detail: { text: 'Not enough messages to compact.', audience: 'person' } } + }) + ).toEqual({ + outcome: 'failure', + failure: { + kind: 'compactionFailed', + detail: { text: 'Not enough messages to compact.', audience: 'person' } + } + }) + expect( + structuredCompactionOutcome({ compacted: false, interruptRequested: false, failed: {} }) + ).toEqual({ outcome: 'failure', failure: { kind: 'compactionFailed' } }) + }) + + it('reads a compaction the provider never reported as a failure it did not confirm', () => { + expect(structuredCompactionOutcome({ compacted: false, interruptRequested: false })).toEqual({ + outcome: 'failure', + failure: { kind: 'compactionUnconfirmed' } + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-outcome.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-outcome.ts new file mode 100644 index 00000000000..cc9513a6ad1 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-outcome.ts @@ -0,0 +1,44 @@ +// How a conversation command the provider ran ended, read the same way for every provider. + +import { + agentSessionFailureFact, + type AgentSessionFailureFact, + type ProviderDiagnostic +} from '../../../shared/agent-session-failure' + +export type StructuredConversationCommandOutcome = { + outcome: 'success' | 'failure' | 'cancellation' + /** On a failure: what its row reports. */ + failure?: AgentSessionFailureFact +} + +/** What the provider showed of one compaction while it ran. */ +export type StructuredCompactionEvidence = { + /** The provider reported the conversation compacted: Claude's boundary, Codex's item. */ + compacted: boolean + /** Orca asked the provider to stop the command. */ + interruptRequested: boolean + /** The provider said the compaction failed, with its words for a person when it gave any. */ + failed?: { detail?: ProviderDiagnostic } | null +} + +/** Only a compaction the provider reported doing is a success: Claude answers a stopped `/compact` + * with the same success result as a finished one, so the result's own verdict cannot decide. With + * none, one Orca asked to stop is the user's cancellation; anything else failed — reported as the + * provider's failure when it said so, and otherwise as a compaction it never confirmed. */ +export function structuredCompactionOutcome( + evidence: StructuredCompactionEvidence +): StructuredConversationCommandOutcome { + if (evidence.compacted) { + return { outcome: 'success' } + } + if (evidence.interruptRequested) { + return { outcome: 'cancellation' } + } + return { + outcome: 'failure', + failure: evidence.failed + ? agentSessionFailureFact('compactionFailed', { detail: evidence.failed.detail }) + : agentSessionFailureFact('compactionUnconfirmed') + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index a261c6dd151..3380c7fd136 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -1,12 +1,18 @@ -import { mkdtemp, rm } from 'node:fs/promises' +import { cp, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-session-host-authority' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionConversationCommand } from '../../../shared/agent-session-conversation-command' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { StructuredAgentSessionHost } from './structured-agent-session-host' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { + AgentSessionAcquisitionRefusal, + type StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep' import { HOST_TEST_NOW, HOST_TEST_SESSION, @@ -18,37 +24,80 @@ import { const caller = { callerKey: 'desktop' } let directory: string +let generation: number +let clock: number let store: AgentSessionRecordStore let host: StructuredAgentSessionHost +let hosts: StructuredAgentSessionHost[] let adapter: StructuredAgentSessionAdapter const compact = vi.fn>() let acquisitions = 0 -function commandParams(command: AgentSessionConversationCommand) { +function envelope(method: string, fields: Record) { return { - command, - envelope: { + sessionId: HOST_TEST_SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, sessionId: HOST_TEST_SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.conversationCommand', - sessionId: HOST_TEST_SESSION, - fields: { command } - }) - } + fields + }) } } +function commandParams(command: AgentSessionConversationCommand) { + return { command, envelope: envelope('agentSession.conversationCommand', { command }) } +} + +function sendParams(text: string) { + const body = hostTestMessage(text) + return { body, envelope: envelope('agentSession.send', { body }) } +} + +const generationRoot = () => join(directory, `generation-${generation}`) + +let ownerProbe: AgentSessionOwnerProbe = { outcome: 'pid-absent' } + +async function openHost(): Promise { + store = await AgentSessionRecordStore.open({ + directory: join(generationRoot(), 'store'), + hostId: 'local' + }) + host = new StructuredAgentSessionHost({ + store, + adapter, + journalRoot: generationRoot(), + claimKeyId: 'key', + now: () => clock, + mintSpawnToken: () => `spawn-${acquisitions}`, + // The owners a restarted host finds died with the process that started them, unless a test says otherwise. + probeOwner: async () => ownerProbe + }) + hosts.push(host) +} + +/** A crash and relaunch: the next host opens what the dying one had written, and nothing after. */ +async function restartHost(): Promise { + await store.renewLeases([]) + const dying = generationRoot() + generation++ + await cp(dying, generationRoot(), { + recursive: true, + filter: (source) => !source.endsWith('.tmp') && !source.includes('.lock') + }) + await openHost() +} + beforeEach(async () => { resetHostTestOperationIds() + ownerProbe = { outcome: 'pid-absent' } acquisitions = 0 - compact.mockReset().mockResolvedValue({}) + generation = 0 + clock = HOST_TEST_NOW + hosts = [] + compact.mockReset().mockResolvedValue({ state: 'accepted', providerIdentity: null }) directory = await mkdtemp(join(tmpdir(), 'orca-conversation-command-')) - store = await AgentSessionRecordStore.open({ - directory: join(directory, 'store'), - hostId: 'local' - }) adapter = { supportsLocation: (location) => location.executionHostId === 'local' && location.wslDistro === null, @@ -81,18 +130,11 @@ beforeEach(async () => { answerPrompt: async () => {}, setOption: async () => {}, compact, - releaseAcquisition: async () => true, - closeSession: async () => true, + releaseAcquisition: vi.fn(async () => true), + closeSession: vi.fn(async () => true), readOptions: async () => ({ models: [], current: { model: 'test-model', effort: 'high' } }) } - host = new StructuredAgentSessionHost({ - store, - adapter, - journalRoot: directory, - claimKeyId: 'key', - now: () => HOST_TEST_NOW, - mintSpawnToken: () => `spawn-${acquisitions}` - }) + await openHost() expect( await host.attach(caller, hostTestAttachParams(null, { options: { effort: 'low' } })) ).toMatchObject({ ok: true }) @@ -100,54 +142,13 @@ beforeEach(async () => { }) afterEach(async () => { - await host.flushAllStreamedEvents() + for (const each of hosts) { + await each.flushAllStreamedEvents() + } await rm(directory, { recursive: true, force: true }) }) describe('host conversation commands', () => { - it('compacts once without an ordinary message submission and replays its receipt', async () => { - const params = commandParams('compact') - expect(await host.conversationCommand(caller, params)).toMatchObject({ - ok: true, - value: { state: 'completed' } - }) - expect(await host.conversationCommand(caller, params)).toMatchObject({ - ok: true, - replayed: true - }) - expect(compact).toHaveBeenCalledTimes(1) - expect(adapter.dispatch).not.toHaveBeenCalled() - const history = host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) - expect(history.page.submissions).toEqual([]) - expect( - history.page.items.some( - (item) => item.body.kind === 'status' && item.body.turnLifecycle?.state === 'running' - ) - ).toBe(false) - }) - - it('reports provider compaction failure without a stuck lifecycle', async () => { - compact.mockResolvedValue({ error: 'Not enough messages to compact.' }) - expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ - ok: true, - value: { state: 'completed', error: 'Not enough messages to compact.' } - }) - expect(store.getRecord(HOST_TEST_SESSION)?.conversationCommand?.state).toBe('completed') - }) - - it('keeps an unknown compaction from being executed again', async () => { - compact.mockRejectedValue(new Error('connection lost')) - const params = commandParams('compact') - await expect(host.conversationCommand(caller, params)).rejects.toThrow('connection lost') - expect(await host.conversationCommand(caller, params)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_unknown' } - }) - expect(compact).toHaveBeenCalledTimes(1) - }) - - /** The replacement seeds from what the provider reports now, not from what the - * retired record happened to store — the same rule acquire and handoff apply. */ it('adopts the reported Fast preference into the replacement record', async () => { adapter.readOptions = async () => ({ models: [], @@ -204,7 +205,7 @@ describe('host conversation commands', () => { }) expect(store.getRecord(HOST_TEST_SESSION)).not.toBeNull() expect(store.listVisibleSessionIds()).toEqual([nextId]) - expect(host.history({ sessionId: nextId, direction: 'tail' }).page.items).toEqual([]) + expect((await host.history({ sessionId: nextId, direction: 'tail' })).page.items).toEqual([]) expect(await host.conversationCommand(caller, params)).toMatchObject({ ok: true, replayed: true, @@ -236,7 +237,13 @@ describe('host conversation commands', () => { }) expect(await host.conversationCommand(caller, commandParams('clear'))).toMatchObject({ ok: true, - value: { state: 'completed', replacementSessionId: undefined, error: expect.any(String) } + value: { + state: 'completed', + replacementSessionId: undefined, + // The refusal's message is Orca's log text; the result words its situation. + error: "Codex couldn't start. Start a new chat to continue.", + failure: { kind: 'startFailed', refusal: { code: 'structured_agent_session_unsupported' } } + } }) expect(store.listVisibleSessionIds()).toEqual([HOST_TEST_SESSION]) expect(acquisitions).toBe(1) @@ -245,48 +252,37 @@ describe('host conversation commands', () => { }) }) - it('rejects stale fences before provider execution', async () => { - const params = commandParams('compact') - params.envelope.expectedRuntimeFence++ - expect(await host.conversationCommand(caller, params)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_checkpoint_stale' } + it('tells the user to run /clear again when the replacement could not start', async () => { + vi.mocked(adapter.acquire).mockRejectedValueOnce(new Error('spawn codex ENOENT')) + expect(await host.conversationCommand(caller, commandParams('clear'))).toMatchObject({ + ok: true, + value: { state: 'completed', error: "Codex couldn't start. Run /clear again." } }) - expect(compact).not.toHaveBeenCalled() - }) - it('allows cancellation while compaction is awaiting completion and refuses a second client', async () => { - let finish!: (value: {}) => void - compact.mockImplementation( - () => - new Promise((resolve) => { - finish = resolve - }) - ) - const params = commandParams('compact') - const running = host.conversationCommand(caller, params) - await vi.waitFor(() => expect(compact).toHaveBeenCalled()) - expect( - await host.conversationCommand({ callerKey: 'mobile' }, commandParams('clear')) - ).toMatchObject({ ok: false }) - const turnId = `compact:${params.envelope.clientOperationId}` - const cancel = await host.cancel(caller, { - turnId, - envelope: { - ...params.envelope, - clientOperationId: hostTestOperationId(), - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.cancel', - sessionId: HOST_TEST_SESSION, - fields: { turnId } - }) - } - }) - expect(cancel).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(adapter.cancelTurn).toHaveBeenCalled() - finish({}) - await running }) + it('keeps the situation a refused replacement start named', async () => { + vi.mocked(adapter.acquire).mockRejectedValueOnce( + new AgentSessionAcquisitionRefusal('Codex is not signed in.', 'notSignedIn') + ) + expect(await host.conversationCommand(caller, commandParams('clear'))).toMatchObject({ + ok: true, + value: { + state: 'completed', + replacementSessionId: undefined, + // The next step is the command the user ran, not a message into the old conversation. + error: 'Codex is not signed in for the selected account. Sign in, then run /clear again.', + failure: { kind: 'notSignedIn' } + } + }) + expect(store.listVisibleSessionIds()).toEqual([HOST_TEST_SESSION]) + }) + + it('runs a command whose fence the client has not caught up to', async () => { + const params = commandParams('compact') + params.envelope.expectedRuntimeFence++ + expect(await host.conversationCommand(caller, params)).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(compact).toHaveBeenCalledTimes(1)) + }) it('reconstructs a committed replacement after the ledger settlement is lost', async () => { const persist = store.recordOperationOutcome.bind(store) vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { @@ -305,19 +301,6 @@ describe('host conversation commands', () => { expect(acquisitions).toBe(2) }) - it('repairs an unknown receipt when the provider completes late', async () => { - compact.mockRejectedValue(new Error('connection lost')) - const params = commandParams('compact') - await expect(host.conversationCommand(caller, params)).rejects.toThrow() - await compact.mock.calls[0]![0].onLateResult?.({}) - expect(await host.conversationCommand(caller, params)).toMatchObject({ - ok: true, - replayed: true, - value: { state: 'completed' } - }) - expect(compact).toHaveBeenCalledTimes(1) - }) - it('keeps explicitly revealed history and closed replacement tabs out of automatic restoration', async () => { const result = await host.conversationCommand(caller, commandParams('clear')) if (!result.ok) { @@ -330,26 +313,383 @@ describe('host conversation commands', () => { await host.setSessionTabVisibility(result.value.replacementSessionId!, false) expect(host.conversationReplacements()).toEqual([]) }) - it('keeps the old compact outcome unknown but restores usability after verified reacquisition', async () => { - compact.mockRejectedValue(new Error('lost response')) - const params = commandParams('compact') - await expect(host.conversationCommand(caller, params)).rejects.toThrow() - await host.close(HOST_TEST_SESSION) - const fence = store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence - expect(await host.attach(caller, hostTestAttachParams(fence))).toMatchObject({ ok: true }) - expect(store.getRecord(HOST_TEST_SESSION)?.conversationCommand).toMatchObject({ - phase: 'committed', - state: 'unknown' +}) + +describe('a clear that never committed', () => { + /** The replacement's start answers with a refusal that proves nothing either way. */ + function refuseReplacementStartOnce() { + vi.spyOn(host, 'attach').mockResolvedValueOnce({ + ok: false, + refusal: { code: 'agent_session_operation_capacity', message: 'Too many operations.' } }) + } + + async function clearCommits(params = commandParams('clear')) { + const result = await host.conversationCommand(caller, params) + expect(result).toMatchObject({ + ok: true, + value: { state: 'completed', replacementSessionId: expect.any(String) } + }) + return result.ok ? result.value.replacementSessionId! : '' + } + + it('refuses nothing afterwards: a rewind, a compaction and a send all run', async () => { + adapter.rewindSupport = () => ({ supported: true }) + refuseReplacementStartOnce() + await expect(host.conversationCommand(caller, commandParams('clear'))).rejects.toThrow( + 'Too many operations.' + ) + // Past every conversation check: only the stale epoch it names stops it. + expect( + await host.rewind(caller, { + envelope: envelope('agentSession.rewind', { + itemId: 'item-1', + expectedEpoch: 'stale-epoch' + }), + itemId: 'item-1', + expectedEpoch: 'stale-epoch' + }) + ).toMatchObject({ ok: false, refusal: { rewindReason: 'stale-epoch' } }) + expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ + ok: true + }) + expect(await host.send(caller, sendParams('still here'))).toMatchObject({ ok: true }) + }) + + it('lets a clear under a new operation id commit', async () => { + refuseReplacementStartOnce() + await expect(host.conversationCommand(caller, commandParams('clear'))).rejects.toThrow() + const replacement = await clearCommits() + expect(store.listVisibleSessionIds()).toEqual([replacement]) + }) + + // Its id is too old to start anything now, and it started nothing to finish. + it('lets a clear under a new operation id commit a day after a try that started nothing', async () => { + refuseReplacementStartOnce() + await expect(host.conversationCommand(caller, commandParams('clear'))).rejects.toThrow() + clock += AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + 60_000 + const params = commandParams('clear') + params.envelope.clientOperationId = `${clock}-${'a'.repeat(32)}` + await clearCommits(params) + }) + + it('reruns under the same operation id and starts exactly one replacement', async () => { + refuseReplacementStartOnce() + const params = commandParams('clear') + await expect(host.conversationCommand(caller, params)).rejects.toThrow() + expect(acquisitions).toBe(1) + await clearCommits(params) + expect(acquisitions).toBe(2) + }) + + /** What an older build left when its clear's outcome was lost: gated every write until now. */ + async function restartOverAnOlderBuildsUnconfirmedClear() { + const record = store.getRecord(HOST_TEST_SESSION)! + await store.setConversationCommand(HOST_TEST_SESSION, record.lease.runtimeFence, { + command: 'clear', + runtimeFence: record.lease.runtimeFence, + operationId: hostTestOperationId(), + callerKey: caller.callerKey, + phase: 'prepared', + state: 'unknown', + replacementSessionId: 'clear-from-an-older-build' + }) + await restartHost() + } + + it("accepts a send on a restarted host holding an older build's unconfirmed clear", async () => { + await restartOverAnOlderBuildsUnconfirmedClear() + expect(await host.send(caller, sendParams('after the restart'))).toMatchObject({ ok: true }) + }) + + it("clears on a restarted host holding an older build's unconfirmed clear", async () => { + await restartOverAnOlderBuildsUnconfirmedClear() + await clearCommits() + }) + + /** A clear whose replacement started but whose commit never landed. */ + async function clearThatDiesBeforeItsCommit(params = commandParams('clear')): Promise { + const commit = store.setConversationCommand.bind(store) + let crashed = false + vi.spyOn(store, 'setConversationCommand').mockImplementation(async (...args) => { + if (!crashed && args[2].phase === 'committed' && args[2].replacementSessionId) { + crashed = true + throw new Error('crash before the commit') + } + return commit(...args) + }) + await expect(host.conversationCommand(caller, params)).rejects.toThrow( + 'crash before the commit' + ) + const [orphan] = store + .listRecords() + .flatMap((record) => (record.sessionId === HOST_TEST_SESSION ? [] : [record.sessionId])) + expect(host.collaboratorsForTests().sessions.get(orphan!)?.child).toBeTruthy() + return orphan! + } + + function otherRecordIds(): string[] { + return store + .listRecords() + .flatMap((record) => (record.sessionId === HOST_TEST_SESSION ? [] : [record.sessionId])) + } + + function startsFor(sessionId: string): number { + return vi + .mocked(adapter.acquire) + .mock.calls.filter(([input]) => input.identity.sessionId === sessionId).length + } + + function sendTo(sessionId: string, text: string) { + const body = hostTestMessage(text) + return host.send(caller, { + body, + envelope: { + sessionId, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(sessionId)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId, + fields: { body } + }) + } + }) + } + + /** The chat reads cleared onto `replacement`, which starts its agent for its first message. */ + async function expectClearedOnto(replacement: string): Promise { + expect(store.listVisibleSessionIds()).toEqual([replacement]) + expect(host.conversationReplacements().map((entry) => entry.sessionId)).toEqual([replacement]) + expect(await host.send(caller, sendParams('into the old chat'))).toMatchObject({ + ok: false, + refusal: { details: { reason: 'conversationCleared' } } + }) + const started = startsFor(replacement) + expect(await sendTo(replacement, 'first message')).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(startsFor(replacement)).toBe(started + 1)) + } + + // A restart stopped the replacement the first try started, which leaves it at rest like any + // quiet chat, so the retry switches to it. + it('retried under the same operation id after a crash, finishes onto the replacement it started', async () => { + const params = commandParams('clear') + const orphan = await clearThatDiesBeforeItsCommit(params) + await restartHost() + await host.restoreReadableSessions(store.listVisibleSessionIds()) + const result = await host.conversationCommand(caller, params) + expect(result).toMatchObject({ + ok: true, + value: { phase: 'committed', state: 'completed', replacementSessionId: orphan } + }) + expect(result.ok && result.value.error).toBeFalsy() + expect(otherRecordIds()).toEqual([orphan]) + expect(startsFor(orphan)).toBe(1) + await expectClearedOnto(orphan) + }) + + it('retried in the same app session once the idle sweep stopped the replacement, finishes onto it', async () => { + const params = commandParams('clear') + const attach = host.attach.bind(host) + // The replacement starts, but the answer the clear gets proves nothing either way. + vi.spyOn(host, 'attach').mockImplementationOnce(async (...args) => { + await attach(...args) + return { + ok: false, + refusal: { code: 'agent_session_operation_capacity', message: 'Too many operations.' } + } + }) + await expect(host.conversationCommand(caller, params)).rejects.toThrow('Too many operations.') + const [orphan] = otherRecordIds() + expect(store.getRecord(orphan!)?.lease.claimStatus).not.toBe('released') + clock += STRUCTURED_AGENT_SESSION_IDLE_MS + 1 + await host.collaboratorsForTests().lifetime.idleSweep.tick() + expect(store.getRecord(orphan!)?.lease).toMatchObject({ claimStatus: 'released' }) + const result = await host.conversationCommand(caller, params) + expect(result).toMatchObject({ + ok: true, + value: { phase: 'committed', state: 'completed', replacementSessionId: orphan } + }) + expect(result.ok && result.value.error).toBeFalsy() + expect(startsFor(orphan!)).toBe(1) + await expectClearedOnto(orphan!) + }) + + it('retried after the replacement definitely failed to start, still reads that failure', async () => { + vi.mocked(adapter.acquire).mockRejectedValueOnce( + new AgentSessionAcquisitionRefusal('Codex is not signed in.', 'notSignedIn') + ) + vi.spyOn(store, 'setConversationCommand').mockRejectedValueOnce( + new Error('crash before the commit') + ) + const params = commandParams('clear') + await expect(host.conversationCommand(caller, params)).rejects.toThrow( + 'crash before the commit' + ) + const [replacement] = otherRecordIds() + expect(store.getRecord(replacement!)?.lease).toMatchObject({ claimStatus: 'released' }) expect(await host.conversationCommand(caller, params)).toMatchObject({ ok: true, - replayed: true, - value: { state: 'unknown' } + value: { + state: 'completed', + replacementSessionId: undefined, + error: 'Codex is not signed in for the selected account. Sign in, then run /clear again.', + failure: { kind: 'notSignedIn' } + } }) - compact.mockResolvedValue({}) - expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ + expect(startsFor(replacement!)).toBe(1) + expect(store.listVisibleSessionIds()).toEqual([HOST_TEST_SESSION]) + }) + + // The client mints a fresh operation id per press; the host still finds that press's earlier try. + it('retried under a fresh operation id while its replacement runs, finishes onto it and starts no other', async () => { + const orphan = await clearThatDiesBeforeItsCommit() + const result = await host.conversationCommand(caller, commandParams('clear')) + expect(result).toMatchObject({ ok: true, - value: { state: 'completed' } + value: { phase: 'committed', state: 'completed', replacementSessionId: orphan } }) + expect(result.ok && result.value.error).toBeFalsy() + expect(otherRecordIds()).toEqual([orphan]) + expect(startsFor(orphan)).toBe(1) + expect(store.listVisibleSessionIds()).toEqual([orphan]) + expect(host.conversationReplacements().map((entry) => entry.sessionId)).toEqual([orphan]) + expect(await sendTo(orphan, 'first message')).toMatchObject({ ok: true }) + }) + + it('retried under a fresh operation id after a restart, finishes onto the replacement it started', async () => { + const orphan = await clearThatDiesBeforeItsCommit() + await restartHost() + await host.restoreReadableSessions(store.listVisibleSessionIds()) + const result = await host.conversationCommand(caller, commandParams('clear')) + expect(result).toMatchObject({ + ok: true, + value: { phase: 'committed', state: 'completed', replacementSessionId: orphan } + }) + expect(otherRecordIds()).toEqual([orphan]) + expect(startsFor(orphan)).toBe(1) + expect(store.getRecord(orphan)?.lease).toMatchObject({ + claimStatus: 'released', + ownerProcess: null + }) + await expectClearedOnto(orphan) + }) + + it("refuses another window's /clear while this one's replacement runs, and runs it once that stops", async () => { + const otherWindow = { callerKey: 'mobile' } + const orphan = await clearThatDiesBeforeItsCommit() + expect(await host.conversationCommand(otherWindow, commandParams('clear'))).toMatchObject({ + ok: false, + refusal: { details: { reason: 'conversationCommandInFlight' } } + }) + expect(otherRecordIds()).toEqual([orphan]) + clock += STRUCTURED_AGENT_SESSION_IDLE_MS + 1 + await host.collaboratorsForTests().lifetime.idleSweep.tick() + const result = await host.conversationCommand(otherWindow, commandParams('clear')) + expect(result).toMatchObject({ + ok: true, + value: { state: 'completed', replacementSessionId: expect.any(String) } + }) + const replacement = result.ok ? result.value.replacementSessionId! : '' + expect(replacement).not.toBe(orphan) + // Nothing points at the first window's replacement, so nothing lists, opens or starts it. + expect(store.listVisibleSessionIds()).toEqual([replacement]) + expect(host.conversationReplacements().map((entry) => entry.sessionId)).toEqual([replacement]) + expect(host.collaboratorsForTests().sessions.has(orphan)).toBe(false) + expect(store.getRecord(orphan)?.lease).toMatchObject({ claimStatus: 'released' }) + expect(startsFor(orphan)).toBe(1) + }) + + function failNextReplacementStart() { + vi.mocked(adapter.acquire).mockRejectedValueOnce( + new AgentSessionAcquisitionRefusal('Codex is not signed in.', 'notSignedIn') + ) + } + + // The other window's failure is the conversation's latest; this window's own still ended its try. + it('starts a new replacement for a /clear after one that committed its failure, in either window', async () => { + for (const each of [caller, { callerKey: 'mobile' }]) { + failNextReplacementStart() + expect(await host.conversationCommand(each, commandParams('clear'))).toMatchObject({ + ok: true, + value: { replacementSessionId: undefined, failure: { kind: 'notSignedIn' } } + }) + } + const failed = otherRecordIds() + expect(failed).toHaveLength(2) + const replacement = await clearCommits() + expect(failed).not.toContain(replacement) + expect(startsFor(replacement)).toBe(1) + expect(store.listVisibleSessionIds()).toEqual([replacement]) + }) + + it('starts a new replacement after a committed failure whose ledger settlement was lost', async () => { + failNextReplacementStart() + const persist = store.recordOperationOutcome.bind(store) + vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { + if (input.outcome.status === 'succeeded' && input.outcome.conversationCommand) { + throw new Error('crash') + } + return persist(input) + }) + await expect(host.conversationCommand(caller, commandParams('clear'))).rejects.toThrow('crash') + vi.mocked(store.recordOperationOutcome).mockRestore() + const [failed] = otherRecordIds() + const replacement = await clearCommits() + expect(replacement).not.toBe(failed) + expect(store.listVisibleSessionIds()).toEqual([replacement]) + }) + + it("does not gate another window's /clear on a replacement whose stop is only unproven", async () => { + const orphan = await clearThatDiesBeforeItsCommit() + ownerProbe = { outcome: 'indeterminate', reason: 'test' } + await restartHost() + await host.restoreReadableSessions(store.listVisibleSessionIds()) + expect(store.getRecord(orphan)?.lease.claimStatus).not.toBe('released') + const result = await host.conversationCommand({ callerKey: 'mobile' }, commandParams('clear')) + expect(result).toMatchObject({ + ok: true, + value: { state: 'completed', replacementSessionId: expect.any(String) } + }) + expect(result.ok && result.value.replacementSessionId).not.toBe(orphan) + }) + + it('starts afresh when a retry under a new operation id follows a start that definitely failed', async () => { + failNextReplacementStart() + vi.spyOn(store, 'setConversationCommand').mockRejectedValueOnce( + new Error('crash before the commit') + ) + await expect(host.conversationCommand(caller, commandParams('clear'))).rejects.toThrow( + 'crash before the commit' + ) + const [failed] = otherRecordIds() + const result = await host.conversationCommand(caller, commandParams('clear')) + expect(result).toMatchObject({ + ok: true, + value: { state: 'completed', replacementSessionId: expect.any(String) } + }) + expect(result.ok && result.value.error).toBeFalsy() + expect(result.ok && result.value.replacementSessionId).not.toBe(failed) + }) + + it('starts afresh when a retry after a restart follows a try whose start never answered', async () => { + let hostDies!: (error: Error) => void + vi.mocked(adapter.acquire).mockImplementationOnce( + () => new Promise((_, reject) => (hostDies = reject)) + ) + const dying = host.conversationCommand(caller, commandParams('clear')).catch(() => {}) + await vi.waitFor(() => expect(otherRecordIds()).toHaveLength(1)) + const [interrupted] = otherRecordIds() + await restartHost() + await host.restoreReadableSessions(store.listVisibleSessionIds()) + const result = await host.conversationCommand(caller, commandParams('clear')) + expect(result).toMatchObject({ + ok: true, + value: { state: 'completed', replacementSessionId: expect.any(String) } + }) + expect(result.ok && result.value.error).toBeFalsy() + expect(result.ok && result.value.replacementSessionId).not.toBe(interrupted) + hostDies(new Error('the host that started it is gone')) + await dying }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index 022343d9407..065ead6e343 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -9,6 +9,10 @@ import type { AgentSessionMutationEnvelope, AgentSessionMutationResult } from '../../../shared/agent-session-wire' +import { + agentSessionLeaseAdmitsWriter, + agentSessionLeaseIsReleased +} from '../../../shared/agent-session-lease-adjudication' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { attachFingerprintFields, @@ -16,9 +20,36 @@ import { } from './structured-agent-session-attach' import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import { + openWithAgent, + structuredAgentSessionFailureWordsContext +} from './structured-agent-session-send-preparation' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import type { StructuredAgentSessionHost } from './structured-agent-session-host' -import { conversationCommandBlocked } from './structured-conversation-command-admission' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { + conversationCommandInFlight, + conversationCommandBlocked +} from './structured-conversation-command-admission' +import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' +import { structuredAgentSessionStartFailureFact } from './structured-agent-session-failure-text' +import { carryQueuedMessagesToClearReplacement } from './structured-agent-session-queued-mutations' + +/** A command's `error` is the sentence its row shows. */ +export function conversationCommandFailure( + failure: AgentSessionFailureFact | undefined, + context: AgentSessionFailureWordsContext = {} +) { + if (!failure) { + return {} + } + const words = agentSessionFailureWords(failure, { ...context, surface: 'row' }) + return { error: words.text, failure: words.failure } +} export type ConversationCommandParams = { envelope: AgentSessionMutationEnvelope @@ -31,6 +62,76 @@ export type ConversationReplacement = { agent: 'claude' | 'codex' } +/** The replacement a /clear try names, and the operation id of its start. */ +function clearReplacementIds(sessionId: string, callerKey: string, operationId: string) { + const digest = createHash('sha256') + .update(JSON.stringify([sessionId, callerKey, operationId])) + .digest('hex') + return { + sessionId: `clear-${digest.slice(0, 40)}`, + attachOperationId: `${parseAgentSessionOperationTimestamp(operationId)}-${digest.slice(0, 32)}` + } +} + +/** + * The try this caller's /clear finishes: the oldest since its last commit whose replacement start + * reached the ledger, else this one. Read from the ledger in admission order, so a retry under a + * fresh operation id finds the replacement an earlier try started. + */ +function clearTryToFinish( + store: AgentSessionRecordStore, + sessionId: string, + callerKey: string, + clearFingerprint: string, + operationId: string +): string { + const committed = store.getRecord(sessionId)?.conversationCommand + let earliest: string | null = null + for (const row of store.listOperationRows()) { + if (row.callerKey !== callerKey || row.fingerprint !== clearFingerprint) { + continue + } + // The record also names a commit whose ledger settlement a crash lost. + if ( + row.outcome.status === 'succeeded' || + (committed?.phase === 'committed' && + committed.callerKey === callerKey && + committed.operationId === row.operationId) + ) { + earliest = null + } else if (earliest === null) { + const start = store.getOperationRow( + callerKey, + clearReplacementIds(sessionId, callerKey, row.operationId).attachOperationId + ) + // Only a start that succeeded left a conversation to finish; replaying any other repeats it. + if (start?.outcome.status === 'succeeded') { + earliest = row.operationId + } + } + } + return earliest ?? operationId +} + +/** Another caller's uncommitted /clear holds a replacement whose agent is running. One whose stop + * is merely unproven gates nothing: only that caller's own start would ever settle it. */ +function otherCallersClearIsLive( + store: AgentSessionRecordStore, + sessionId: string, + callerKey: string, + clearFingerprint: string +): boolean { + return store.listOperationRows().some((row) => { + if (row.callerKey === callerKey || row.fingerprint !== clearFingerprint) { + return false + } + const replacement = store.getRecord( + clearReplacementIds(sessionId, row.callerKey, row.operationId).sessionId + ) + return replacement !== null && agentSessionLeaseAdmitsWriter(replacement.lease) + }) +} + export function runStructuredConversationCommand( context: StructuredAgentSessionMutationContext, host: Pick, @@ -52,6 +153,8 @@ export function runStructuredConversationCommand( adapter: context.deps.adapter, callerKey: caller.callerKey, envelope, + // Only the provider can do this, so an agent at rest is started first. + prepareSession: openWithAgent(context, params.envelope), journal: () => context.sessions.get(sessionId)?.journal, publish: (journal) => context.publish(sessionId, journal), flushStreamedEvents: context.flushStreamedEvents, @@ -66,64 +169,53 @@ export function runStructuredConversationCommand( return outcome.conversationCommand } const prior = matching() - if (prior?.phase === 'committed') { - return prior - } - if (command === 'compact' && prior && outcome.status !== 'unknown') { - return { - command, - state: 'unknown', - error: 'Compaction completion is unconfirmed; it was not run again.' - } - } - return outcome.status === 'succeeded' && command === 'compact' - ? { command, state: 'completed' } - : null + return prior?.phase === 'committed' ? prior : null }, - rerunWhenReplayMissing: () => command === 'clear' && matching()?.phase === 'prepared', + // Nothing the chat reads is written before the commit, so a clear with no committed answer + // changed nothing and runs again, finishing the replacement its earliest try started. + rerunWhenReplayMissing: () => command === 'clear', run: async (ctx) => { await host.flushStreamedEvents(sessionId) const record = store.getRecord(sessionId)! - const prior = matching() - const blocked = - prior?.phase === 'prepared' && command === 'clear' - ? null - : conversationCommandBlocked(ctx, record) + const blocked = conversationCommandBlocked(ctx, record) if (blocked) { - return { - ok: false, - refusal: { code: 'agent_session_operation_invalid', message: blocked } - } + return { ok: false, refusal: blocked } } - const replacementSessionId = - command === 'clear' - ? (prior?.replacementSessionId ?? - `clear-${createHash('sha256') - .update(JSON.stringify([sessionId, caller.callerKey, clientOperationId])) - .digest('hex') - .slice(0, 40)}`) - : undefined - const prepared = { + let ids: ReturnType | undefined + if (command === 'clear') { + const clearFingerprint = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.conversationCommand', + sessionId, + fields: { command } + }) + if (otherCallersClearIsLive(store, sessionId, caller.callerKey, clearFingerprint)) { + return { ok: false, refusal: conversationCommandInFlight() } + } + ids = clearReplacementIds( + sessionId, + caller.callerKey, + clearTryToFinish( + store, + sessionId, + caller.callerKey, + clearFingerprint, + clientOperationId + ) + ) + } + const replacementSessionId = ids?.sessionId + const base = { command, runtimeFence: ctx.fence, operationId: clientOperationId, callerKey: caller.callerKey, - phase: 'prepared' as const, - state: 'unknown' as const, ...(replacementSessionId ? { replacementSessionId } : {}) } - await store.setConversationCommand(sessionId, ctx.fence, prepared) - let error: string | undefined - if (command === 'clear' && replacementSessionId) { + if (ids) { const attach: AgentSessionAttachParams = { envelope: { - sessionId: replacementSessionId, - clientOperationId: `${parseAgentSessionOperationTimestamp(clientOperationId)}-${createHash( - 'sha256' - ) - .update(JSON.stringify([sessionId, caller.callerKey, clientOperationId])) - .digest('hex') - .slice(0, 32)}`, + sessionId: ids.sessionId, + clientOperationId: ids.attachOperationId, expectedRuntimeFence: null, payloadFingerprint: '' }, @@ -138,106 +230,56 @@ export function runStructuredConversationCommand( } attach.envelope.payloadFingerprint = computeAgentSessionPayloadFingerprint({ method: 'agentSession.attach', - sessionId: replacementSessionId, + sessionId: ids.sessionId, fields: attachFingerprintFields(attach) }) const acquired = await host.attach(caller, attach) - if (!acquired.ok) { + const replacement = store.getRecord(ids.sessionId) + // An earlier try started this replacement and a restart or the idle sweep has since + // stopped it: attach can't replay a settled start, but the new conversation is at rest. + const startedAndAtRest = + replacement !== null && + agentSessionLeaseIsReleased(replacement.lease) && + store.getOperationRow(caller.callerKey, attach.envelope.clientOperationId)?.outcome + .status === 'succeeded' + if (!acquired.ok && !startedAndAtRest) { if ( !isDefinitiveAgentSessionCreateRefusal(acquired.refusal.code) && - store.getRecord(replacementSessionId)?.lease.claimStatus !== 'released' + replacement?.lease.claimStatus !== 'released' ) { throw new Error(acquired.refusal.message) } + // The refusal's message is Orca's log text; the result keeps its situation instead. const failed = { - ...prepared, + ...base, replacementSessionId: undefined, phase: 'committed' as const, state: 'completed' as const, - error: acquired.refusal.message.slice(0, 4096) + ...conversationCommandFailure( + structuredAgentSessionStartFailureFact({ + refusal: acquired.refusal, + newSession: true + }), + { ...structuredAgentSessionFailureWordsContext(record), command: 'clear' } + ) } await store.setConversationCommand(sessionId, ctx.fence, failed) return { ok: true, value: failed } } - } else { - if (!ctx.adapter.compact) { - throw new Error('Compaction is unavailable for this provider.') - } - const identity = { - provider: 'orca' as const, - clientMessageId: `compact:${clientOperationId}` - } - await ctx.journal.appendItem( - identity, - { - kind: 'status', - text: 'Compacting conversation…', - turnLifecycle: { turnId: `compact:${clientOperationId}`, state: 'running' } - }, - { fence: ctx.fence } - ) - ctx.publish() - try { - error = ( - await ctx.adapter.compact({ - turnId: `compact:${clientOperationId}`, - sessionId, - fence: ctx.fence, - onLateResult: (result) => - context.serialize(sessionId, async () => { - if ( - matching()?.phase !== 'prepared' || - context.sessions.get(sessionId)?.journal !== ctx.journal - ) { - return - } - await host.flushStreamedEvents(sessionId) - await ctx.journal.appendItem( - identity, - { kind: 'status', text: result.error ?? 'Conversation compacted.' }, - { fence: ctx.fence } - ) - await store.setConversationCommand(sessionId, ctx.fence, { - ...prepared, - phase: 'committed', - state: 'completed', - ...(result.error ? { error: result.error.slice(0, 4096) } : {}) - }) - await store.recordOperationOutcome({ - callerKey: caller.callerKey, - operationId: clientOperationId, - outcome: { - status: 'succeeded', - sessionId, - conversationCommand: matching()! - } - }) - ctx.publish() - }) - }) - ).error - await host.flushStreamedEvents(sessionId) - } catch (cause) { - await ctx.journal.appendItem( - identity, - { kind: 'status', text: 'Compaction completion is unconfirmed.' }, - { fence: ctx.fence } - ) - ctx.publish() - throw cause - } - await ctx.journal.appendItem( - identity, - { kind: 'status', text: error ?? 'Conversation compacted.' }, - { fence: ctx.fence } - ) - ctx.publish() + // Carry the source's drafts to the replacement, the same for every client version: + // the cards stay visible where the user now is, and no text rides the wire. + // Bookkeeping — a failure is reported and never fails the clear. + await carryQueuedMessagesToClearReplacement(ctx, { + replacementSessionId: ids.sessionId, + replacementJournal: context.sessions.get(ids.sessionId)?.journal, + callerKey: caller.callerKey, + operationId: clientOperationId + }) } const completed = { - ...prepared, + ...base, phase: 'committed' as const, - state: 'completed' as const, - ...(error ? { error: error.slice(0, 4096) } : {}) + state: 'completed' as const } await store.setConversationCommand(sessionId, ctx.fence, completed) return { ok: true, value: completed } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts new file mode 100644 index 00000000000..28eae2dff68 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -0,0 +1,751 @@ +// `/compact` travels the send path: accepted as the user's message, carried out by the delivery loop +// as a turn of its own, settled by re-reading the journal. Each case reads what a subscriber that +// was open before the command saw, or the journal a client would load. + +import { beforeEach, expect, it, vi, type Mock } from 'vitest' +import { + AgentJournalSubmissionSchema, + isAdmissibleAgentJournalItemBody +} from '../../../shared/agent-session-journal-schemas' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalRenderItem +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { structuredAgentSessionCommandTurn } from './structured-agent-session-command-turn' +import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' +import { + attach, + CALLER, + envelope, + hostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestMessage +} from './structured-agent-session-host-test-data' +import type { StructuredConversationCommandOutcome } from './structured-conversation-command-outcome' + +let state: ReturnType +let compact: Mock> +let closeSession: Mock> + +beforeEach(() => { + state = hostTestState() + // Codex's ack: the provider took the command, which its translator ends later. + compact = vi.fn(async () => ({ state: 'accepted' as const, providerIdentity: null })) + closeSession = vi.fn(async () => true) + Object.assign(state.host.deps.adapter, { compact, closeSession }) +}) + +/** What the child's journal translator writes when the provider ends the command: the command's + * one result row and its turn's end, in one batch. */ +function finish(result: StructuredConversationCommandOutcome): void { + const { command } = compact.mock.calls.at(-1)![0] + const events = state.acquire.mock.calls.at(-1)![0].events! + const turnScope = { kind: 'turn' as const, turnItemId: agentJournalItemKey(command.identity) } + const row: AgentJournalItemBody | null = + result.outcome === 'success' + ? { kind: 'status', text: 'Context compacted', presentation: 'compaction' } + : result.outcome === 'failure' + ? { + kind: 'status', + ...agentSessionFailureWords( + result.failure ?? agentSessionFailureFact('compactionUnconfirmed'), + { surface: 'row' } + ), + tone: 'error' + } + : null + events.appendLifecycleBatch!( + `turn-completed:${command.clientMessageId}`, + [ + ...(row + ? [{ kind: 'item' as const, identity: command.resultIdentity, body: row, turnScope }] + : []), + { + kind: 'item', + identity: command.identity, + body: { + ...command.running, + state: result.outcome === 'cancellation' ? 'interrupted' : 'completed', + outcome: result.outcome, + completedAt: HOST_TEST_NOW + }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ], + { lifecycle: true } + ) +} + +/** Claude's words for a compaction it refused, as its frames carry them. */ +const NOT_ENOUGH = { text: 'Not enough messages to compact.', audience: 'person' as const } + +function compactParams() { + return { + command: 'compact' as const, + envelope: envelope('agentSession.conversationCommand', { command: 'compact' }) + } +} + +function sendParams(text: string) { + const body = hostTestMessage(text) + return { envelope: envelope('agentSession.send', { body }), body } +} + +async function subscribe(): Promise { + const events: AgentSessionSubscribeEvent[] = [] + await state.host.subscribe({ + id: 'pane', + sessionId: SESSION, + emit: (event) => events.push(event) + }) + return events +} + +/** One line per journal fact a subscriber received, in delivery order. */ +function frames(events: AgentSessionSubscribeEvent[]): string[] { + return events.flatMap((event) => + event.type === 'batch' + ? [ + ...event.batch.items.map(describeItem), + ...event.batch.submissions.map( + (entry) => `submission:${entry.dispatchState}${entry.handedOverAt ? ':handed' : ''}` + ) + ] + : [] + ) +} + +function describeItem(item: AgentJournalRenderItem): string { + const turn = readAgentJournalTurn(item.body) + if (turn) { + return `turn:${turn.state}${turn.outcome ? `:${turn.outcome}` : ''}` + } + return item.body.kind === 'status' ? `status:${item.body.text}` : item.body.kind +} + +async function journal() { + return state.host.journalSnapshot(SESSION) +} + +async function commandTurn(clientMessageId: string) { + const { itemId } = structuredAgentSessionCommandTurn(clientMessageId) + return (await journal()).items.find((item) => item.itemId === itemId) +} + +it('answers at handover, then journals its own entry, turn and result (B1, B16)', async () => { + await attach() + const events = await subscribe() + const params = compactParams() + const cmid = params.envelope.clientOperationId + + // The reply means "started"; the provider has not finished. + await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ + ok: true, + value: { command: 'compact', state: 'completed' } + }) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + finish({ outcome: 'success' }) + + await vi.waitFor(async () => + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('completed') + ) + // A client saw the command working before it saw the command's end. + const facts = frames(events) + const running = facts.indexOf('turn:running') + const ended = facts.indexOf('turn:completed:success') + expect(running, facts.join('\n')).toBeGreaterThanOrEqual(0) + expect(ended).toBeGreaterThan(running) + expect(facts.indexOf('status:Context compacted')).toBeGreaterThan(running) + // No turn row was ever overwritten by another body kind. + const turnKey = structuredAgentSessionCommandTurn(cmid).itemId + for (const event of events) { + for (const item of event.type === 'batch' ? event.batch.items : []) { + expect(item.itemId !== turnKey || item.body.kind === 'turn').toBe(true) + } + } + const snapshot = await journal() + const entry = snapshot.items.find((item) => item.body.kind === 'message') + expect(entry?.body).toMatchObject({ command: { name: 'compact' } }) + expect(entry?.turnScope).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + const result = snapshot.items.find( + (item) => item.body.kind === 'status' && item.body.presentation === 'compaction' + ) + expect(result?.turnScope).toEqual({ kind: 'turn', turnItemId: turnKey }) + const submission = snapshot.submissions.find((item) => item.clientMessageId === cmid) + expect(submission).toMatchObject({ dispatchState: 'accepted', providerItemId: null }) + // An older client's schemas take the accepted submission with no provider item. + expect(AgentJournalSubmissionSchema.safeParse(submission).success).toBe(true) + expect(isAdmissibleAgentJournalItemBody(entry!.body)).toBe(true) + expect(state.dispatch).not.toHaveBeenCalled() +}) + +it('replays the reply for the same operation without running it again (B16)', async () => { + await attach() + const params = compactParams() + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + finish({ outcome: 'success' }) + await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ + ok: true, + replayed: true + }) + expect(compact).toHaveBeenCalledOnce() +}) + +it('holds messages sent during the command and delivers them after it, in order (B2)', async () => { + await attach() + const params = compactParams() + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + await expect(state.host.send(CALLER, sendParams('first'))).resolves.toMatchObject({ ok: true }) + await expect(state.host.send(CALLER, sendParams('second'))).resolves.toMatchObject({ ok: true }) + // Nothing is handed over while the command's turn runs. + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(state.dispatch).not.toHaveBeenCalled() + + finish({ + outcome: 'failure', + failure: agentSessionFailureFact('compactionFailed', { detail: NOT_ENOUGH }) + }) + + // Delivered even though the command failed. + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledTimes(2)) + expect(state.dispatch.mock.calls.map(([input]) => input.body.blocks)).toEqual([ + [{ type: 'text', text: 'first' }], + [{ type: 'text', text: 'second' }] + ]) + const turn = await commandTurn(params.envelope.clientOperationId) + expect(readAgentJournalTurn(turn?.body)).toMatchObject({ state: 'completed', outcome: 'failure' }) + const error = (await journal()).items.find( + (item) => item.body.kind === 'status' && item.body.tone === 'error' + ) + expect(error?.body).toMatchObject({ + text: 'Compaction failed: Not enough messages to compact.', + failure: { kind: 'compactionFailed', detail: NOT_ENOUGH } + }) +}) + +it('hands over a message held behind the command when the command ends just as the loop stops for it', async () => { + await attach() + await state.host.conversationCommand(CALLER, compactParams()) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + const { journal: live } = state.host['sessions'].get(SESSION)! + const activeTurnId = live.activeTurnId + let ended = false + vi.spyOn(live, 'activeTurnId').mockImplementation(() => { + const read = activeTurnId() + // The provider's end lands the moment the loop's own step reads the command as running and + // stops; no other reader's view of it matters here. + if ( + !ended && + read?.startsWith('compact:') && + new Error('who reads').stack?.includes('StructuredAgentSessionDeliveryLoop.prepare') + ) { + ended = true + finish({ outcome: 'success' }) + } + return read + }) + + await expect(state.host.send(CALLER, sendParams('held'))).resolves.toMatchObject({ ok: true }) + + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + expect(ended).toBe(true) +}) + +it('settles a command the provider refused as a failure with its reason, and moves on (B3)', async () => { + await attach() + compact.mockResolvedValue({ + state: 'rejected', + ...agentSessionFailureWords( + agentSessionFailureFact('providerRejected', { detail: NOT_ENOUGH }), + { + surface: 'rejection' + } + ) + }) + const params = compactParams() + const cmid = params.envelope.clientOperationId + await state.host.conversationCommand(CALLER, params) + await state.host.send(CALLER, sendParams('after the refusal')) + + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)).toMatchObject({ + state: 'completed', + outcome: 'failure' + }) + const snapshot = await journal() + // The message was not sent, in the provider's words; the command's row says the compaction failed. + expect(snapshot.submissions.find((entry) => entry.clientMessageId === cmid)).toMatchObject({ + dispatchState: 'rejected', + reason: 'The provider did not accept this message: Not enough messages to compact.', + rejection: { kind: 'providerRejected', detail: NOT_ENOUGH } + }) + expect( + snapshot.items.filter((item) => item.body.kind === 'status' && item.body.tone === 'error') + ).toEqual([ + expect.objectContaining({ + body: { + kind: 'status', + text: 'Compaction failed: Not enough messages to compact.', + failure: { kind: 'compactionFailed', detail: NOT_ENOUGH }, + tone: 'error' + }, + turnScope: { kind: 'turn', turnItemId: structuredAgentSessionCommandTurn(cmid).itemId } + }) + ]) +}) + +it('says only that the compaction failed when the provider refused it without words', async () => { + await attach() + compact.mockResolvedValue({ + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('writeFailed'), { surface: 'rejection' }) + }) + const params = compactParams() + await state.host.conversationCommand(CALLER, params) + + await vi.waitFor(async () => + expect( + (await journal()).items + .filter((item) => item.body.kind === 'status' && item.body.tone === 'error') + .map((item) => item.body) + ).toEqual([ + { + kind: 'status', + text: 'Compaction failed.', + failure: { kind: 'compactionFailed' }, + tone: 'error' + } + ]) + ) +}) + +it('refuses the command at handover when the provider opened a turn meanwhile (B3)', async () => { + await attach() + const events = state.acquire.mock.calls.at(-1)?.[0].events + // The provider starts a turn of its own after acceptance, before the command is handed over. + Object.assign(state.host.deps.adapter, { + awaitStarted: vi.fn(async () => { + events?.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'provider-turn', ordinal: 0 }, + { kind: 'turn', turnId: 'provider-turn', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true } + ) + }) + }) + const params = compactParams() + + const refused = { + kind: 'commandRefused', + refusal: { code: 'agent_session_operation_invalid', details: { reason: 'turnActive' } } + } + await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ + ok: true, + value: { state: 'completed', error: "This command didn't run. Try it again.", failure: refused } + }) + expect(compact).not.toHaveBeenCalled() + expect(await commandTurn(params.envelope.clientOperationId)).toBeUndefined() + expect( + (await journal()).submissions.find( + (entry) => entry.clientMessageId === params.envelope.clientOperationId + ) + ).toMatchObject({ + dispatchState: 'rejected', + reason: "This command didn't run. Try it again.", + rejection: refused + }) +}) + +it('leaves a command whose start failed not sent, beside one start-failure row (B3)', async () => { + await attach() + await state.host.close(SESSION) + state.acquire.mockRejectedValue(new Error('not signed in')) + const params = compactParams() + + // The next step is the command again, not a message. + await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ + ok: true, + value: { + state: 'completed', + error: "Codex couldn't restart. Run /compact again.", + failure: { kind: 'restartFailed' } + } + }) + const snapshot = await journal() + expect(snapshot.items.filter((item) => readAgentJournalTurn(item.body))).toEqual([]) + // The start's own row, in the words the command's message was refused with. + expect( + snapshot.items + .filter((item) => item.body.kind === 'status' && item.body.tone === 'error') + .map((item) => item.body) + ).toEqual([ + { + kind: 'status', + text: "Codex couldn't restart. Run /compact again.", + failure: expect.objectContaining({ kind: 'restartFailed' }), + tone: 'error' + } + ]) + expect( + snapshot.submissions.find( + (entry) => entry.clientMessageId === params.envelope.clientOperationId + ) + ).toMatchObject({ + dispatchState: 'rejected', + reason: "Codex couldn't restart. Run /compact again." + }) + expect(compact).not.toHaveBeenCalled() +}) + +function stop(turnId: string) { + return state.host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', { turnId }), + turnId + }) +} + +it('leaves the command to the provider when it takes the Stop, and ends it as cancelled (B4)', async () => { + await attach() + const params = compactParams() + const cmid = params.envelope.clientOperationId + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + const { turnId } = structuredAgentSessionCommandTurn(cmid) + + await expect(stop(turnId)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + + // The interrupt was taken, not answered: the command runs until the provider ends it. + expect(state.cancelTurn).toHaveBeenCalledWith(expect.objectContaining({ turnId })) + expect(closeSession).not.toHaveBeenCalled() + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('running') + finish({ outcome: 'cancellation' }) + await vi.waitFor(async () => + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)).toMatchObject({ + state: 'interrupted', + outcome: 'cancellation' + }) + ) + // A cancellation writes no result row. + expect((await journal()).items.some((item) => item.itemId.includes('command-result'))).toBe(false) +}) + +it('ends the command by stopping the child at a second Stop the provider never answered (B4)', async () => { + await attach() + const params = compactParams() + const cmid = params.envelope.clientOperationId + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + const { turnId } = structuredAgentSessionCommandTurn(cmid) + + // The provider takes the interrupt and then never answers it. + await expect(stop(turnId)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + expect(closeSession).not.toHaveBeenCalled() + await expect(stop(turnId)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + + expect(state.cancelTurn).toHaveBeenCalledOnce() + expect(closeSession).toHaveBeenCalledOnce() + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('interrupted') +}) + +it('ends the command by stopping the child when the provider cannot take the Stop (B4)', async () => { + await attach() + // Codex before it opened the command's turn, or Claude refusing the interrupt. + state.cancelTurn.mockResolvedValue({ cancelled: false }) + const params = compactParams() + const cmid = params.envelope.clientOperationId + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + + await expect(stop(structuredAgentSessionCommandTurn(cmid).turnId)).resolves.toMatchObject({ + ok: true, + value: { cancelled: true } + }) + + expect(closeSession).toHaveBeenCalledOnce() + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('interrupted') + const notes = (await journal()).items.filter((item) => item.body.kind === 'status') + expect(notes.map((item) => item.body.kind === 'status' && item.body.text)).toEqual([ + 'Cancellation requested.' + ]) + // The next message starts a child of its own. + await state.host.send(CALLER, sendParams('after the stop')) + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + expect(state.acquire).toHaveBeenCalledTimes(2) +}) + +it('does not stop the child for a Stop naming a command that already ended (B4)', async () => { + await attach() + state.cancelTurn.mockResolvedValue({ cancelled: false }) + const params = compactParams() + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + finish({ outcome: 'success' }) + const { itemId, turnId } = structuredAgentSessionCommandTurn(params.envelope.clientOperationId) + await vi.waitFor(async () => + expect( + readAgentJournalTurn((await journal()).items.find((item) => item.itemId === itemId)?.body) + ?.state + ).toBe('completed') + ) + + await expect(stop(turnId)).resolves.toMatchObject({ ok: true, value: { cancelled: false } }) + expect(closeSession).not.toHaveBeenCalled() +}) + +it("answers a refused command's message before ending its turn, so a crash between them leaves a turn the sweep settles (B4)", async () => { + await attach() + // The one command end the host still writes: the provider refused it. A provider's own end is + // one batch its translator writes, with nothing to split. + compact.mockResolvedValue({ + state: 'rejected', + ...agentSessionFailureWords( + agentSessionFailureFact('providerRejected', { detail: NOT_ENOUGH }), + { + surface: 'rejection' + } + ) + }) + const { journal: live } = state.host['sessions'].get(SESSION)! + const appendLifecycleBatch = live.appendLifecycleBatch.bind(live) + const crash = vi + .spyOn(live, 'appendLifecycleBatch') + .mockImplementation((input) => + input.settlementId.startsWith('command-settled:') + ? Promise.reject(new Error('host crashed')) + : appendLifecycleBatch(input) + ) + const params = compactParams() + const cmid = params.envelope.clientOperationId + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(crash).toHaveBeenCalled()) + crash.mockRestore() + + // Never an ended turn whose message still reads as in flight. + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('running') + expect( + (await journal()).submissions.find((entry) => entry.clientMessageId === cmid)?.dispatchState + ).toBe('rejected') + await settleStaleStructuredAgentSessionState({ + journal: live, + sessionId: SESSION, + fence: state.store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: null, + deathEvidence: null + }) + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('unverifiable') +}) + +it('counts a message held behind the command from its handover, not its send', async () => { + await attach() + await state.host.conversationCommand(CALLER, compactParams()) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + const sent = await state.host.send(CALLER, sendParams('held')) + expect(sent.ok).toBe(true) + await new Promise((resolve) => setTimeout(resolve, 20)) + finish({ outcome: 'success' }) + + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + const [dispatched] = state.dispatch.mock.calls[0]! + const submission = (await journal()).submissions.find( + (entry) => entry.clientMessageId === dispatched.clientMessageId + ) + expect(submission?.handedOverAt).toBeGreaterThan(submission!.submittedAt) + expect(dispatched.requestedAt).toBe(submission?.handedOverAt) +}) + +it('settles a command whose adapter call threw after the start as unknown (B4)', async () => { + await attach() + compact.mockImplementation(() => { + throw new Error('codex app-server session is not live') + }) + const params = compactParams() + const cmid = params.envelope.clientOperationId + + await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ ok: true }) + + await vi.waitFor(async () => + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('unverifiable') + ) + expect( + (await journal()).submissions.find((entry) => entry.clientMessageId === cmid) + ).toMatchObject({ dispatchState: 'unknown', reason: 'codex app-server session is not live' }) +}) + +it('writes one exit row when the child dies mid-command, and the loop writes nothing but moves on (B4)', async () => { + state.acquire.mockImplementation(async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + acquisitionGeneration: `generation-${fence}`, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + // The next child resumes the thread, as a real one does. + origin: state.store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: 1 + } + })) + await attach() + const params = compactParams() + const cmid = params.envelope.clientOperationId + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + await expect(state.host.send(CALLER, sendParams('queued behind it'))).resolves.toMatchObject({ + ok: true + }) + + // The adapter never answers the command: the host's own record of the child's end is enough. + const fence = state.store.getRecord(SESSION)!.lease.runtimeFence + await state.host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence, + acquisitionGeneration: `generation-${fence}`, + reason: 'provider exited', + cause: 'unexpected-exit' + }) + + await vi.waitFor(async () => + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('interrupted') + ) + // Released from the command, the loop starts a child and delivers what waited behind it. + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + expect(state.acquire).toHaveBeenCalledTimes(2) + const snapshot = await journal() + expect( + snapshot.items.filter((item) => item.body.kind === 'status' && item.body.tone === 'error') + ).toHaveLength(1) + expect(snapshot.items.some((item) => item.itemId.includes('command-result'))).toBe(false) + // Codex acknowledged the command, so the message was delivered; only its turn was cut short. + expect(snapshot.submissions.find((entry) => entry.clientMessageId === cmid)?.dispatchState).toBe( + 'accepted' + ) +}) + +it('delivers the next message after a command whose child died and whose settlement could not be written', async () => { + state.acquire.mockImplementation(async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + acquisitionGeneration: `generation-${fence}`, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: state.store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: 1 + } + })) + await attach() + const params = compactParams() + const cmid = params.envelope.clientOperationId + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + const { journal: live } = state.host['sessions'].get(SESSION)! + const appendLifecycleBatch = live.appendLifecycleBatch.bind(live) + vi.spyOn(live, 'appendLifecycleBatch').mockImplementation((input) => + input.settlementId.includes('provider-exit:') + ? Promise.reject(new Error('disk full')) + : appendLifecycleBatch(input) + ) + + const fence = state.store.getRecord(SESSION)!.lease.runtimeFence + await state.host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence, + acquisitionGeneration: `generation-${fence}`, + reason: 'provider exited', + cause: 'unexpected-exit' + }) + await vi.waitFor(() => expect(state.host['sessions'].get(SESSION)?.child).toBeNull()) + // The exit it recorded settles what the failed write left running, so the command holds nothing. + await vi.waitFor(async () => + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('interrupted') + ) + + await expect(state.host.send(CALLER, sendParams('after it'))).resolves.toMatchObject({ + ok: true + }) + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + expect(state.acquire).toHaveBeenCalledTimes(2) + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).not.toBe('running') +}) + +it("ignores an older build's unconfirmed compaction record, and answers its operation without rerunning it (B15)", async () => { + await attach() + const older = compactParams() + // An older build admitted this operation and died before recording its outcome. + await state.store.admitMutationOperation({ + callerKey: CALLER.callerKey, + envelope: older.envelope, + hostFingerprint: older.envelope.payloadFingerprint, + now: HOST_TEST_NOW + }) + await state.store.setConversationCommand( + SESSION, + state.store.getRecord(SESSION)!.lease.runtimeFence, + { + command: 'compact', + runtimeFence: state.store.getRecord(SESSION)!.lease.runtimeFence, + operationId: older.envelope.clientOperationId, + callerKey: CALLER.callerKey, + phase: 'prepared', + state: 'unknown' + } + ) + + await expect(state.host.send(CALLER, sendParams('still works'))).resolves.toMatchObject({ + ok: true + }) + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + await expect(state.host.conversationCommand(CALLER, compactParams())).resolves.toMatchObject({ + ok: true, + value: { state: 'completed' } + }) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + finish({ outcome: 'success' }) + await vi.waitFor(async () => + expect((await journal()).submissions.every((entry) => entry.dispatchState === 'accepted')).toBe( + true + ) + ) + + await expect(state.host.conversationCommand(CALLER, older)).resolves.toMatchObject({ + ok: true, + value: { + state: 'unknown', + error: 'Compaction completion is unconfirmed.', + failure: { kind: 'compactionUnconfirmed' } + } + }) + expect(compact).toHaveBeenCalledOnce() +}) + +it('never lets a provider echo alias the command entry', async () => { + await attach() + const params = compactParams() + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + finish({ outcome: 'success' }) + const events = state.acquire.mock.calls.at(-1)?.[0].events + const echo = { provider: 'codex' as const, threadId: THREAD, turnId: 'later', ordinal: 0 } + events?.appendItem( + echo, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: '/compact' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await state.host.flushStreamedEvents(SESSION) + expect((await journal()).items.some((item) => item.itemId === agentJournalItemKey(echo))).toBe( + true + ) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts new file mode 100644 index 00000000000..7ad7f9bb1bc --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts @@ -0,0 +1,160 @@ +// `/compact` sent through the command RPC: accepted into the conversation as the user's message, +// and answered once the delivery loop hands it over. + +import type { AgentSessionConversationCommandResult } from '../../../shared/agent-session-conversation-command' +import { + agentSessionFailureFact, + readAgentSessionFailureFact +} from '../../../shared/agent-session-failure' +import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import type { + AgentSessionMutationEnvelope, + AgentSessionMutationResult +} from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + mutateStructuredAgentSession, + type StructuredAgentSessionMutationContext +} from './structured-agent-session-host-mutations' +import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' +import { + conversationCommandPlan, + type ConversationCommandAcceptance +} from './structured-agent-session-mutation-plans' +import { + sendPreparation, + structuredAgentSessionFailureWordsContext, + structuredAgentSessionSendBlock +} from './structured-agent-session-send-preparation' +import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from './structured-agent-session-send-settlement' +import { + conversationCommandFailure, + type ConversationCommandParams +} from './structured-conversation-command' +import { conversationCommandBlocked } from './structured-conversation-command-admission' + +/** + * `/compact` from a client that asks through the command RPC: accepted into the conversation like + * any message, and answered once it is handed over — the command has started, not finished. Its + * end reaches the chat as its own turn and result row. + */ +export async function runStructuredCompaction( + context: StructuredAgentSessionMutationContext, + host: Pick, + caller: StructuredAgentSessionCaller, + params: ConversationCommandParams +): Promise> { + const { sessionId, clientOperationId } = params.envelope + // An older build ran this operation id and recorded it on the session: answered, never rerun. + const priorRecord = (): AgentSessionConversationCommandResult | null => { + const prior = context.deps.store.getRecord(sessionId)?.conversationCommand + if (prior?.operationId !== clientOperationId || prior.callerKey !== caller.callerKey) { + return null + } + return prior.phase === 'committed' + ? prior + : { + command: 'compact', + state: 'unknown', + ...conversationCommandFailure(agentSessionFailureFact('compactionUnconfirmed')) + } + } + const accepted = await acceptStructuredConversationCommand(context, caller, params, priorRecord) + if (!accepted.ok) { + return accepted + } + if ('recorded' in accepted.value) { + return { ...accepted, value: accepted.value.recorded } + } + const settled = await host.waitForSendSettlement(sessionId, accepted.value.clientMessageId, { + until: 'handed-over', + budgetMs: STRUCTURED_AGENT_SESSION_START_WAIT_MS + }) + return { + ...accepted, + ...(settled ? { cursor: settled.cursor } : {}), + // A /compact is a command send, never a queued draft, so its settlement always carries the submission. + value: compactionReply( + settled && 'submission' in settled.value ? settled.value.submission : undefined, + { + ...structuredAgentSessionFailureWordsContext(context.deps.store.getRecord(sessionId)), + command: 'compact' + } + ) + } +} + +/** The reply shape clients already read: `completed` is now "started". */ +function compactionReply( + submission: AgentJournalSubmission | undefined, + context: AgentSessionFailureWordsContext +): AgentSessionConversationCommandResult { + const error = (reason: string | null, fallback: string) => (reason ?? fallback).slice(0, 4096) + if (!submission || isQueuedAgentJournalSubmission(submission)) { + return { command: 'compact', state: 'unknown', error: 'The command has not started yet.' } + } + if (submission.dispatchState === 'rejected') { + // The message's own fact, in the words its row shows; a row from an older host keeps its reason. + const rejection = readAgentSessionFailureFact(submission.rejection) + return rejection + ? { + command: 'compact', + state: 'completed', + ...conversationCommandFailure(rejection, context) + } + : { + command: 'compact', + state: 'completed', + error: error(submission.reason, 'The command was not run.') + } + } + return submission.dispatchState === 'unknown' + ? { + command: 'compact', + state: 'unknown', + error: error(submission.reason, 'The command may not have run.') + } + : { command: 'compact', state: 'completed' } +} + +/** A conversation command, accepted into the queue as the user's message. */ +function acceptStructuredConversationCommand( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + params: { envelope: AgentSessionMutationEnvelope }, + priorRecord: () => AgentSessionConversationCommandResult | null +): Promise> { + const plan = conversationCommandPlan({ envelope: params.envelope, priorRecord }) + return mutateStructuredAgentSession( + context, + caller, + params.envelope, + { + ...plan, + run: async (ctx) => { + const record = context.deps.store.getRecord(ctx.sessionId) + const refusal = + record && + conversationCommandBlocked( + ctx, + record, + context.sessions.get(ctx.sessionId)?.child ? undefined : 'at-rest' + ) + const blocked = + structuredAgentSessionSendBlock(record) ?? + (refusal ? { ok: false as const, refusal } : null) + if (blocked) { + return blocked + } + const accepted = await plan.run(ctx) + if (accepted.ok) { + context.wakeDelivery(ctx.sessionId) + } + return accepted + } + }, + sendPreparation(context, params.envelope) + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts index 6fa4d32293b..9de1925791f 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts @@ -2,6 +2,13 @@ import { describe, expect, it } from 'vitest' import { AgentSessionRewindRecordSchema } from '../../../shared/agent-session-rewind' import { restoreRewindJournalBody } from './structured-rewind-journal-body' +/** A row this build cannot place: still a row, never its stored JSON, and not a failure — a + * hostFault's "Try again" would be false for a placeholder. */ +const UNPLACEABLE = { + kind: 'status', + text: 'Orca could not show this item after the rewind.' +} + describe('rewind recovery of newer durable records', () => { it('keeps an unknown message role and block readable without discarding the row', () => { expect( @@ -43,16 +50,13 @@ describe('rewind recovery of newer durable records', () => { input: { path: 'file' }, state: 'paused-by-provider' } - expect(restoreRewindJournalBody(body)).toEqual({ kind: 'status', text: JSON.stringify(body) }) + expect(restoreRewindJournalBody(body)).toEqual(UNPLACEABLE) const status = { kind: 'status' as const, text: 'state', turnLifecycle: { turnId: 'turn', state: 'future-state' } } - expect(restoreRewindJournalBody(status)).toEqual({ - kind: 'status', - text: JSON.stringify(status) - }) + expect(restoreRewindJournalBody(status)).toEqual(UNPLACEABLE) }) it.each(['interrupted', 'unverifiable'] as const)( 'keeps a %s turn and its recorded endpoints', @@ -82,10 +86,7 @@ describe('rewind recovery of newer durable records', () => { } expect(restoreRewindJournalBody(turn)).toEqual(turn) const unknown = { ...turn, state: 'future-state' } - expect(restoreRewindJournalBody(unknown)).toEqual({ - kind: 'status', - text: JSON.stringify(unknown) - }) + expect(restoreRewindJournalBody(unknown)).toEqual(UNPLACEABLE) }) it('keeps a known turn outcome across rewind recovery in both journal shapes', () => { const turn = { diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts index addc234e2a1..99489b18769 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts @@ -9,10 +9,15 @@ import { NATIVE_CHAT_ROLES } from '../../../shared/native-chat-types' type StoredBody = AgentSessionRewindRecord['retained'][number]['body'] -/** Unknown future values remain visible evidence, never invented turn or prompt state. */ +/** A row this build cannot place stays visible as a row, never invented turn or prompt state — + * and never as its stored JSON, which is Orca's record, not something a person reads. */ export function restoreRewindJournalBody(body: StoredBody): AgentJournalItemBody { let normalized: unknown = body - const fallback = () => ({ kind: 'status', text: JSON.stringify(body) }) + // A placeholder, not a failure anyone can act on, so it carries no fact. + const fallback = () => ({ + kind: 'status', + text: 'Orca could not show this item after the rewind.' + }) if (body.kind === 'message') { normalized = { ...body, diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts index 42b6e8dd91d..b572e51e5f3 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts @@ -1,5 +1,7 @@ -import { restoreRewindJournalBody } from './structured-rewind-journal-body' -import { mergeRetainedHostLifecycleRows } from './structured-rewind-retained-host-rows' +import { + mergeRetainedHostLifecycleRows, + retainedRowReplacement +} from './structured-rewind-retained-host-rows' import { isDeepStrictEqual } from 'node:util' import { agentJournalItemKey, @@ -83,11 +85,9 @@ export async function recoverStructuredRewind( beforeTurnId: target.turnId }) if (!recovered?.ok) { - if ( - recovered?.reason === 'provider-refused' && - rewind.phase === 'prepared' && - !rewind.providerApplied - ) { + // The target is still in the provider's history, and the journal is replaced only once the + // revert is proven, so both still hold it even when the provider acknowledged the revert. + if (recovered?.reason === 'provider-refused' && rewind.phase === 'prepared') { await persistRewindRecord(store, sessionId, fence, { ...rewind, phase: 'refused', @@ -141,13 +141,7 @@ export async function recoverStructuredRewind( if (rewind.phase !== 'provider-succeeded') { return } - const replacement = rewind.retained.map((item) => { - const identity = parseAgentJournalItemKey(item.itemId) - if (!identity) { - throw new Error('agent_session_rewind:invalid-retained-identity') - } - return { identity, body: restoreRewindJournalBody(item.body), observedAt: item.observedAt } - }) + const replacement = rewind.retained.map(retainedRowReplacement) // A crash after the journal transaction must settle its existing epoch, not replace it twice. const alreadyReplaced = journal.cursor().epoch !== rewind.expectedEpoch if ( diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts b/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts index 5ea205a527c..af309b13f40 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts @@ -2,23 +2,28 @@ import { AGENT_SESSION_REWIND_REASONS, type AgentSessionRewindReason } from '../../../shared/agent-session-rewind' -import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import { refuse, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' export function rewindRefusal(reason: AgentSessionRewindReason): { ok: false refusal: AgentSessionWireRefusal } { - const knownReason = + const rewindReason = AGENT_SESSION_REWIND_REASONS.find((value) => value === reason) ?? 'outcome-unknown' + const message = `agent_session_rewind:${rewindReason}` return { ok: false, - refusal: { - code: - knownReason === 'outcome-unknown' - ? 'agent_session_operation_unknown' - : 'agent_session_operation_invalid', - message: `agent_session_rewind:${knownReason}`, - rewindReason: knownReason - } + refusal: + rewindReason === 'outcome-unknown' + ? refuse( + 'agent_session_operation_unknown', + { reason: 'rewindUnconfirmed', rewindReason }, + message + ) + : refuse( + 'agent_session_operation_invalid', + { reason: 'rewindRefused', rewindReason }, + message + ) } } diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts new file mode 100644 index 00000000000..5b47be7f9f8 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { + AgentSessionRewindRecordSchema, + type AgentSessionRewindRecord +} from '../../../shared/agent-session-rewind' +import { + mergeRetainedHostLifecycleRows, + retainedRowReplacement +} from './structured-rewind-retained-host-rows' +import { restoreRewindJournalBody } from './structured-rewind-journal-body' +import type * as RewindJournalBody from './structured-rewind-journal-body' + +vi.mock('./structured-rewind-journal-body', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, restoreRewindJournalBody: vi.fn(actual.restoreRewindJournalBody) } +}) + +type Retained = AgentSessionRewindRecord['retained'][number] + +const codexKey = (turnId: string, ordinal: number) => + agentJournalItemKey({ provider: 'codex', threadId: 'thread-1', turnId, ordinal }) +const orcaKey = (clientMessageId: string) => + agentJournalItemKey({ provider: 'orca', clientMessageId }) + +function prose(text: string): AgentJournalItemBody { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } +} + +function retained(itemId: string, body: AgentJournalItemBody, extra: Partial = {}) { + return { itemId, body, observedAt: 1, ...extra } +} + +/** Provider history carries no scope or producer of its own. */ +function providerItem(itemId: string, body: AgentJournalItemBody): Retained { + return { itemId, body, observedAt: 2 } +} + +const TURN_RECORD = orcaKey('turn-a') +const inTurnA = { kind: 'turn', turnItemId: TURN_RECORD } + +describe('rewind keeps each retained row attributed', () => { + it("keeps a held row's scope and producer, a subagent's row staying the subagent's", () => { + const own = codexKey('a', 1) + const child = codexKey('a', 2) + const reference = [ + retained(TURN_RECORD, { kind: 'turn', turnId: 'a', state: 'completed' }), + retained(own, prose('own'), { turnScope: inTurnA }), + retained(child, prose('child'), { + turnScope: inTurnA, + agentId: 'child-thread', + parentAgentId: 'root', + producerKind: 'agent' + }) + ] + const merged = mergeRetainedHostLifecycleRows(reference, [ + providerItem(own, prose('own')), + providerItem(child, prose('child')) + ]) + expect(merged.find((item) => item.itemId === own)).toMatchObject({ turnScope: inTurnA }) + expect(merged.find((item) => item.itemId === own)?.agentId).toBeUndefined() + expect(merged.find((item) => item.itemId === child)).toMatchObject({ + turnScope: inTurnA, + agentId: 'child-thread', + parentAgentId: 'root', + producerKind: 'agent', + observedAt: 2 + }) + }) + + it("splices a command's turn, result and entry back, since provider history holds none", () => { + const before = codexKey('a', 1) + const commandTurn = orcaKey('command-turn:cmd-1') + const commandResult = orcaKey('command-result:cmd-1') + const entry = orcaKey('cmd-1') + const reference = [ + retained(before, prose('before')), + retained(entry, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + }), + retained(commandTurn, { kind: 'turn', turnId: 'compact:cmd-1', state: 'completed' }), + retained( + commandResult, + { kind: 'status', text: 'Conversation compacted.', presentation: 'compaction' }, + { turnScope: { kind: 'turn', turnItemId: commandTurn } } + ) + ] + const merged = mergeRetainedHostLifecycleRows(reference, [ + providerItem(before, prose('before')) + ]) + expect(merged.map((item) => item.itemId)).toEqual([before, entry, commandTurn, commandResult]) + expect(merged.at(-1)?.turnScope).toEqual({ kind: 'turn', turnItemId: commandTurn }) + }) + + it('places a provider item the old epoch never held in the turn record for its provider turn', () => { + const commandTurn = orcaKey('command-turn:cmd-1') + const reference = [ + retained(TURN_RECORD, { kind: 'turn', turnId: 'a', state: 'completed' }), + retained(commandTurn, { + kind: 'turn', + turnId: 'compact:cmd-1', + state: 'completed', + providerTurnId: 'b' + }) + ] + const merged = mergeRetainedHostLifecycleRows(reference, [ + providerItem(codexKey('a', 1), prose('in a')), + providerItem(codexKey('b', 1), prose('claimed by the command')), + providerItem(codexKey('c', 1), prose('no record')) + ]) + const scopeOf = (itemId: string) => merged.find((item) => item.itemId === itemId)?.turnScope + expect(scopeOf(codexKey('a', 1))).toEqual(inTurnA) + expect(scopeOf(codexKey('b', 1))).toEqual({ kind: 'turn', turnItemId: commandTurn }) + // No record to join: the rebuilt epoch places it by position. + expect(scopeOf(codexKey('c', 1))).toBeUndefined() + }) +}) + +describe('the rebuilt epoch item for a retained row', () => { + it('passes a placeable scope and a known producer through', () => { + expect( + retainedRowReplacement( + retained(codexKey('a', 1), prose('x'), { + turnScope: inTurnA, + agentId: 'child', + producerKind: 'agent', + attempt: 2 + }) + ) + ).toMatchObject({ turnScope: inTurnA, agentId: 'child', producerKind: 'agent', attempt: 2 }) + }) + + it('drops a scope and a producer kind this build cannot place', () => { + const replacement = retainedRowReplacement( + retained(codexKey('a', 1), prose('x'), { + turnScope: { kind: 'future-kind' }, + producerKind: 'future-producer' + }) + ) + expect(replacement.turnScope).toBeUndefined() + expect(replacement.producerKind).toBeUndefined() + expect( + retainedRowReplacement( + retained(codexKey('a', 1), prose('x'), { turnScope: { kind: 'turn' } }) + ).turnScope + ).toBeUndefined() + }) + + it('leaves a row from an older record unscoped, for the rebuild to derive', () => { + const replacement = retainedRowReplacement(retained(codexKey('a', 1), prose('x'))) + expect(replacement).not.toHaveProperty('turnScope') + expect(replacement).not.toHaveProperty('agentId') + }) + + it('still reads a persisted record written before rows carried scope or producer', () => { + const older = { + operationId: 'op-1', + callerKey: 'desktop', + itemId: codexKey('b', 0), + expectedEpoch: 'epoch-1', + phase: 'prepared', + retained: [{ itemId: codexKey('a', 1), body: prose('x'), observedAt: 1 }] + } + expect(AgentSessionRewindRecordSchema.safeParse(older).success).toBe(true) + }) +}) + +describe('rewind merge cost', () => { + it('reads each merged row once to place the provider items it never held', () => { + const turns = 400 + const providerItems = Array.from({ length: turns }, (_, index) => + providerItem(codexKey(`t${index}`, 1), prose(`answer ${index}`)) + ) + const reference = Array.from({ length: turns }, (_, index) => + retained(orcaKey(`turn-${index}`), { + kind: 'turn', + turnId: `t${index}`, + state: 'completed' + }) + ) + vi.mocked(restoreRewindJournalBody).mockClear() + + const merged = mergeRetainedHostLifecycleRows(reference, providerItems) + + expect(merged.find((item) => item.itemId === codexKey(`t${turns - 1}`, 1))).toMatchObject({ + turnScope: { kind: 'turn', turnItemId: orcaKey(`turn-${turns - 1}`) } + }) + // A scan per provider item reads every merged row for each one: turns² reads. + expect(vi.mocked(restoreRewindJournalBody).mock.calls.length).toBeLessThanOrEqual(merged.length) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts index 35307566599..6496518b50b 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts @@ -1,18 +1,30 @@ // Provider preflight returns provider items only. The host's lifecycle rows are its own record, so // a rewind that takes the provider list as the new epoch must splice those rows back beside the -// provider item each one followed. +// provider item each one followed. Provider items carry neither turn scope nor producer, so each +// keeps the ones its retained row held. import { parseCodexGoalJournalItemId } from '../../codex/codex-goal-journal-identity' -import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalProducerLinkage, + AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' import type { AgentSessionRewindRecord } from '../../../shared/agent-session-rewind' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { restoreRewindJournalBody } from './structured-rewind-journal-body' type RetainedRow = AgentSessionRewindRecord['retained'][number] export function isRetainedHostLifecycleRow(item: RetainedRow): boolean { + const identity = parseAgentJournalItemKey(item.itemId) return ( readAgentJournalTurn(item.body as AgentJournalItemBody) !== null || - parseCodexGoalJournalItemId(item.itemId) !== null + parseCodexGoalJournalItemId(item.itemId) !== null || + // A conversation command's entry and result: the provider's history holds neither. + (item.body.kind === 'message' && item.body.command !== undefined) || + (identity?.provider === 'orca' && identity.clientMessageId.startsWith('command-')) ) } @@ -22,6 +34,7 @@ export function mergeRetainedHostLifecycleRows( providerItems: readonly RetainedRow[] ): RetainedRow[] { const spineIndex = new Map(providerItems.map((item, index) => [item.itemId, index])) + const held = new Map(reference.map((item) => [item.itemId, item])) const rowsAfter = new Map() let anchor = -1 for (const item of reference) { @@ -32,6 +45,77 @@ export function mergeRetainedHostLifecycleRows( } } const merged = [...(rowsAfter.get(-1) ?? [])] - providerItems.forEach((item, index) => merged.push(item, ...(rowsAfter.get(index) ?? []))) - return merged + providerItems.forEach((item, index) => + merged.push(withHeldAttribution(item, held.get(item.itemId)), ...(rowsAfter.get(index) ?? [])) + ) + const turnRecords = turnRecordsByProviderTurn(merged) + return merged.map((item) => + held.has(item.itemId) || item.turnScope ? item : withProviderTurnScope(item, turnRecords) + ) +} + +/** The rebuilt epoch's item for one retained row, with the scope and producer it was written with. */ +export function retainedRowReplacement(row: RetainedRow): AgentJournalProducerLinkage & { + identity: AgentJournalItemIdentity + body: AgentJournalItemBody + observedAt: number + turnScope?: AgentJournalTurnScope +} { + const identity = parseAgentJournalItemKey(row.itemId) + if (!identity) { + throw new Error('agent_session_rewind:invalid-retained-identity') + } + const { agentId, parentAgentId, providerParentRef, producerKind, attempt, turnScope } = row + const scope = + turnScope?.kind === 'turn' && turnScope.turnItemId + ? { kind: 'turn' as const, turnItemId: turnScope.turnItemId } + : turnScope?.kind === 'thread' + ? { kind: 'thread' as const } + : undefined + return { + identity, + body: restoreRewindJournalBody(row.body), + observedAt: row.observedAt, + ...(scope ? { turnScope: scope } : {}), + ...(agentId === undefined ? {} : { agentId }), + ...(parentAgentId === undefined ? {} : { parentAgentId }), + ...(providerParentRef === undefined ? {} : { providerParentRef }), + ...(producerKind === 'agent' || producerKind === 'background' ? { producerKind } : {}), + ...(attempt === undefined ? {} : { attempt }) + } +} + +function withHeldAttribution(item: RetainedRow, held: RetainedRow | undefined): RetainedRow { + if (!held) { + return item + } + const { itemId: _itemId, body: _body, observedAt: _observedAt, ...attribution } = held + return { ...item, ...attribution } +} + +/** Provider turn id → the item id of its turn record: the turn's own, or the command turn that + * claimed it. The first record wins, as a scan from the top would find it. */ +function turnRecordsByProviderTurn(merged: readonly RetainedRow[]): ReadonlyMap { + const records = new Map() + for (const candidate of merged) { + const turn = readAgentJournalTurn(restoreRewindJournalBody(candidate.body)) + for (const turnId of [turn?.turnId, turn?.providerTurnId]) { + if (turnId !== undefined && !records.has(turnId)) { + records.set(turnId, candidate.itemId) + } + } + } + return records +} + +/** A provider item the old epoch never held joins the turn record for its provider turn. + * Otherwise the rebuild places it. */ +function withProviderTurnScope( + item: RetainedRow, + turnRecords: ReadonlyMap +): RetainedRow { + const identity = parseAgentJournalItemKey(item.itemId) + const turnItemId = + identity?.provider === 'codex' && identity.turnId ? turnRecords.get(identity.turnId) : undefined + return turnItemId ? { ...item, turnScope: { kind: 'turn', turnItemId } } : item } diff --git a/src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts deleted file mode 100644 index 13a22b655b3..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { StructuredSessionCompaction } from './structured-session-compaction' - -describe('structured compaction lifecycle', () => { - it('waits beyond the Codex acknowledgment and ignores other threads', async () => { - const tracker = new StructuredSessionCompaction() - const finished = vi.fn() - const result = tracker - .run('session', 'thread', async () => ({})) - .then((value) => { - finished() - return value - }) - await Promise.resolve() - tracker.codex('session', 'turn/started', { threadId: 'other', turn: { id: 'foreign' } }) - tracker.codex('session', 'turn/completed', { - threadId: 'other', - turn: { id: 'foreign', status: 'completed' } - }) - expect(finished).not.toHaveBeenCalled() - tracker.codex('session', 'turn/started', { threadId: 'thread', turn: { id: 'compact-turn' } }) - tracker.codex('session', 'item/completed', { - threadId: 'thread', - item: { type: 'contextCompaction' } - }) - expect(finished).not.toHaveBeenCalled() - tracker.codex('session', 'turn/completed', { - threadId: 'thread', - turn: { id: 'compact-turn', status: 'completed' } - }) - await expect(result).resolves.toEqual({}) - }) - - it('observes notifications arriving before the request acknowledgment', async () => { - const tracker = new StructuredSessionCompaction() - await expect( - tracker.run('s', 't', async () => { - tracker.codex('s', 'turn/started', { threadId: 't', turn: { id: 'c' } }) - tracker.codex('s', 'turn/completed', { - threadId: 't', - turn: { id: 'c', status: 'failed', error: { message: 'Unavailable' } } - }) - }) - ).resolves.toEqual({ error: 'Unavailable' }) - }) - - it.each(['success', 'failed'])( - 'uses Claude compact_result %s rather than result subtype', - async (state) => { - const tracker = new StructuredSessionCompaction() - const result = tracker.run('s', 'provider', async () => {}) - tracker.claude('s', { - type: 'system', - subtype: 'status', - session_id: 'provider', - compact_result: state, - compact_error: 'Not enough messages to compact.' - }) - tracker.claude('s', { - type: 'result', - subtype: 'success', - session_id: 'provider', - result: '' - }) - await expect(result).resolves.toEqual( - state === 'success' ? {} : { error: 'Not enough messages to compact.' } - ) - } - ) - - it('cleans up on provider exit and permits another operation', async () => { - const tracker = new StructuredSessionCompaction() - const pending = tracker.run('s', 'p', async () => {}) - tracker.ended('s') - await expect(pending).resolves.toEqual({ error: 'The provider exited during compaction.' }) - const next = tracker.run('s', 'p', async () => { - tracker.claude('s', { type: 'system', subtype: 'compact_boundary', session_id: 'p' }) - tracker.claude('s', { type: 'result', subtype: 'success', session_id: 'p' }) - }) - await expect(next).resolves.toEqual({}) - }) - it('reconciles a terminal frame after timeout without repeating the provider request', async () => { - vi.useFakeTimers() - try { - const tracker = new StructuredSessionCompaction(10) - const late = vi.fn(async () => {}) - const invoke = vi.fn(async () => ({})) - const result = tracker.run('s', 'p', invoke, late) - const rejected = expect(result).rejects.toThrow('unconfirmed') - await vi.advanceTimersByTimeAsync(11) - await rejected - tracker.claude('s', { type: 'system', subtype: 'compact_boundary', session_id: 'p' }) - tracker.claude('s', { type: 'result', subtype: 'success', session_id: 'p' }) - expect(late).toHaveBeenCalledWith({}) - expect(invoke).toHaveBeenCalledTimes(1) - } finally { - vi.useRealTimers() - } - }) - - it('does not mistake an unrelated completed turn for compaction', async () => { - const tracker = new StructuredSessionCompaction() - const result = tracker.run('s', 't', async () => ({})) - tracker.codex('s', 'turn/started', { threadId: 't', turn: { id: 'c' } }) - tracker.codex('s', 'turn/completed', { threadId: 't', turn: { id: 'c', status: 'completed' } }) - await expect(result).resolves.toEqual({ error: 'Compaction did not complete.' }) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-session-compaction.ts b/src/main/native-chat/agent-session-wire/structured-session-compaction.ts deleted file mode 100644 index 328c283dc11..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-session-compaction.ts +++ /dev/null @@ -1,143 +0,0 @@ -type PendingCompaction = { - identity: string - commandTurnId?: string - turnId?: string - error?: string - compacted: boolean - finish: (result: { error?: string }) => void -} - -function record(value: unknown): Record { - return value && typeof value === 'object' ? (value as Record) : {} -} - -export function isCodexCompactionComplete(method: string, params: unknown): boolean { - return ( - method === 'thread/compacted' || - (method === 'item/completed' && record(record(params).item).type === 'contextCompaction') - ) -} - -/** A receipt is not completion; keep listening through the provider's terminal frame. */ -export class StructuredSessionCompaction { - private readonly pending = new Map() - constructor(private readonly timeoutMs = 180_000) {} - - async run( - sessionId: string, - identity: string, - invoke: () => Promise, - onLateResult?: (result: { error?: string }) => Promise, - commandTurnId?: string - ): Promise<{ error?: string }> { - if (this.pending.has(sessionId)) { - throw new Error('Compaction is already running.') - } - let timer: ReturnType - let expired = false - const completion = new Promise<{ error?: string }>((resolve, reject) => { - const finish = (result: { error?: string }) => { - this.pending.delete(sessionId) - if (expired && onLateResult) { - void onLateResult(result).catch((error) => - console.warn('Could not persist late compaction completion', error) - ) - } - resolve(result) - } - this.pending.set(sessionId, { - identity, - commandTurnId, - compacted: false, - finish - }) - timer = setTimeout(() => { - expired = true - reject(new Error('Compaction completion is unconfirmed.')) - }, this.timeoutMs) - timer.unref?.() - }) - // Observe rejection even while invoke is waiting for its own receipt. - void completion.catch(() => {}) - try { - const admission = record(await invoke()) - if (typeof admission.error === 'string') { - this.pending.get(sessionId)?.finish({ error: admission.error }) - } - return await completion - } catch (error) { - expired = this.pending.has(sessionId) - throw error - } finally { - clearTimeout(timer!) - if (!expired) { - this.pending.delete(sessionId) - } - } - } - - hasPending(sessionId: string): boolean { - return this.pending.has(sessionId) - } - - ownsTurn(sessionId: string, turnId: string): boolean { - return this.pending.get(sessionId)?.commandTurnId === turnId - } - - providerTurnId(sessionId: string, turnId: string): string | undefined { - return this.ownsTurn(sessionId, turnId) ? this.pending.get(sessionId)?.turnId : turnId - } - - ended(sessionId: string): void { - this.pending.get(sessionId)?.finish({ error: 'The provider exited during compaction.' }) - } - - codex(sessionId: string, method: string, value: unknown): void { - const pending = this.pending.get(sessionId) - const params = record(value) - if (!pending || params.threadId !== pending.identity) { - return - } - const turn = record(params.turn) - if (method === 'turn/started' && typeof turn.id === 'string') { - pending.turnId = turn.id - } - if (isCodexCompactionComplete(method, params)) { - pending.compacted = true - } - if (method === 'turn/completed' && turn.id === pending.turnId) { - const error = record(turn.error).message - pending.finish( - turn.status === 'completed' && pending.compacted - ? {} - : { error: typeof error === 'string' ? error : 'Compaction did not complete.' } - ) - } - } - - claude(sessionId: string, message: Record): void { - const pending = this.pending.get(sessionId) - if (!pending || message.session_id !== pending.identity) { - return - } - if (message.compact_result === 'failed') { - pending.error = - typeof message.compact_error === 'string' ? message.compact_error : 'Compaction failed.' - } - if (message.compact_result === 'success' || message.subtype === 'compact_boundary') { - pending.compacted = true - } - if (message.type === 'result') { - if ( - message.is_error === true || - (typeof message.subtype === 'string' && message.subtype.startsWith('error')) - ) { - pending.error ??= 'Compaction did not complete.' - } - const error = - pending.error ?? - (pending.compacted ? undefined : 'Compaction was not confirmed by the provider.') - pending.finish(error ? { error } : {}) - } - } -} diff --git a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts index cbb0857c859..915bbad5e54 100644 --- a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts +++ b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts @@ -1,4 +1,4 @@ -import type { AgentJournalStatusItem } from '../../../shared/agent-session-journal-types' +import type { AgentJournalPlainStatusItem } from '../../../shared/agent-session-journal-types' import { boundInlineText, boundPayload, @@ -12,7 +12,7 @@ import { } from './provider-frame-disposition' export type UnhandledProviderFrameJournalItem = { - body: AgentJournalStatusItem + body: AgentJournalPlainStatusItem /** Why the frame surfaced. Error frames are exempt from generic-row caps. */ classification: 'timeline-substantive' | 'error-surface' } diff --git a/src/main/native-chat/claude-structured-managed-account-support.ts b/src/main/native-chat/claude-structured-managed-account-support.ts index dccf6216bda..3b42c44c443 100644 --- a/src/main/native-chat/claude-structured-managed-account-support.ts +++ b/src/main/native-chat/claude-structured-managed-account-support.ts @@ -43,12 +43,19 @@ export function structuredClaudeMatchesActiveManagedAccount( // apart after the fact: honest deselection, where ambient auth is the truth and the UI names no // identity, and the WSL-only case, where the prune emptied the host slot and persisted null // while the UI still names the WSL account. The presence of any WSL-bound account decides. - return !accounts.some((candidate) => candidate.managedAuthRuntime === 'wsl') + return !hasWslBoundClaudeAccount(settings) } const active = accounts.find((candidate) => candidate.id === activeHostId) return active ? active.managedAuthRuntime !== 'wsl' : false } +/** Whether any managed Claude account lives in WSL, the account shape the gate refuses over. */ +export function hasWslBoundClaudeAccount(settings: ClaudeManagedAccountGateSettings): boolean { + return (settings.claudeManagedAccounts ?? []).some( + (candidate) => candidate.managedAuthRuntime === 'wsl' + ) +} + /** Reads the gate's settings, answering null when they cannot be read so callers refuse. */ export function readClaudeManagedAccountGateSettings( getSettings: () => ClaudeManagedAccountGateSettings diff --git a/src/main/native-chat/structured-agent-session-history-adoption.ts b/src/main/native-chat/structured-agent-session-history-adoption.ts index d470735b032..e64e8f799d8 100644 --- a/src/main/native-chat/structured-agent-session-history-adoption.ts +++ b/src/main/native-chat/structured-agent-session-history-adoption.ts @@ -5,6 +5,7 @@ // journal, and a call site written there would compile however wrong it was. The runtime hands over // the facts it owns — the account homes it recognises, the records it holds — and this decides. +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' import type { AgentSessionProviderHandle } from '../../shared/agent-session-journal-types' import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' @@ -106,10 +107,11 @@ export function findConflictingStructuredAdoption(input: { export function structuredAdoptionConflictError( ownership: StructuredAgentSessionAdoptionOwnership ): Error { - return new Error( + return agentSessionRefusalError( agentSessionLeaseAdmitsWriter(ownership.lease) ? 'agent_session_conflict' - : 'agent_session_ownership_unknown' + : 'agent_session_ownership_unknown', + { reason: 'conversationHeldElsewhere' } ) } @@ -152,5 +154,7 @@ export async function resolveStructuredAgentSessionAdoption(input: { } // Refuse rather than fall back to the default home. Resuming under a home that does not hold the // conversation is how a "resume" silently becomes a blank chat wearing the old chat's name. - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptNotFound' + }) } diff --git a/src/main/native-chat/transcript-read-cache-inflight.test.ts b/src/main/native-chat/transcript-read-cache-inflight.test.ts new file mode 100644 index 00000000000..47abc8b3e42 --- /dev/null +++ b/src/main/native-chat/transcript-read-cache-inflight.test.ts @@ -0,0 +1,215 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ReadTranscriptResult } from './transcript-reader' + +const mocks = vi.hoisted(() => ({ + resolve: vi.fn<(...args: unknown[]) => Promise>(), + stat: vi.fn<() => Promise<{ mtimeMs: number; size: number }>>(), + read: vi.fn<() => Promise>() +})) + +vi.mock('./session-file-resolver', () => ({ resolveSessionFilePath: mocks.resolve })) +vi.mock('./wsl-transcript-fs-access', () => ({ wslGatedStat: mocks.stat })) +vi.mock('./transcript-reader', () => ({ readNativeChatTranscript: mocks.read })) + +import { + clearNativeChatTranscriptCache, + readNativeChatTranscriptCached +} from './transcript-read-cache' +import { + WSL_TRANSCRIPT_FS_SLOW_MESSAGE, + wslTranscriptFsTimeoutError +} from './wsl-transcript-fs-error' + +beforeEach(() => { + clearNativeChatTranscriptCache() + vi.resetAllMocks() + mocks.resolve.mockResolvedValue('/repo/session.jsonl') + mocks.stat.mockResolvedValue({ mtimeMs: 1, size: 10 }) +}) + +describe('concurrent transcript cache reads', () => { + it('parses a shared file generation once across simultaneous clients and session aliases', async () => { + const pending = Promise.withResolvers() + const result: ReadTranscriptResult = { messages: [] } + mocks.read.mockReturnValue(pending.promise) + const calls = Array.from({ length: 8 }, (_, i) => + readNativeChatTranscriptCached('claude', `session-alias-${i}`) + ) + + await vi.waitFor(() => expect(mocks.stat).toHaveBeenCalledTimes(8)) + pending.resolve(result) + + expect((await Promise.all(calls)).every((value) => value === result)).toBe(true) + expect(mocks.read).toHaveBeenCalledTimes(1) + await expect(readNativeChatTranscriptCached('claude', 'session')).resolves.toBe(result) + expect(mocks.read).toHaveBeenCalledTimes(1) + expect(mocks.stat).toHaveBeenCalledTimes(9) + }) + + it('does not share a session id across distinct resolved files or agents', async () => { + const first = Promise.withResolvers() + const second = Promise.withResolvers() + const third = Promise.withResolvers() + mocks.resolve + .mockResolvedValueOnce('/repo-a/session.jsonl') + .mockResolvedValueOnce('/repo-b/session.jsonl') + .mockResolvedValueOnce('/repo-a/session.jsonl') + mocks.read + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + .mockReturnValueOnce(third.promise) + const calls = [ + readNativeChatTranscriptCached('claude', 'session'), + readNativeChatTranscriptCached('claude', 'session'), + readNativeChatTranscriptCached('codex', 'session') + ] + const results: ReadTranscriptResult[] = [{ messages: [] }, { messages: [] }, { messages: [] }] + first.resolve(results[0]) + second.resolve(results[1]) + third.resolve(results[2]) + + const actual = await Promise.all(calls) + actual.forEach((value, i) => expect(value).toBe(results[i])) + expect(mocks.read).toHaveBeenCalledTimes(3) + }) + + it.each([ + { mtimeMs: 2, size: 10 }, + { mtimeMs: 1, size: 20 } + ])('does not let an older parse overwrite changed file stats %j', async (changedStat) => { + const oldRead = Promise.withResolvers() + const oldResult: ReadTranscriptResult = { messages: [] } + const newResult: ReadTranscriptResult = { messages: [] } + mocks.read.mockReturnValueOnce(oldRead.promise).mockResolvedValueOnce(newResult) + const first = readNativeChatTranscriptCached('claude', 'session') + await vi.waitFor(() => expect(mocks.read).toHaveBeenCalledTimes(1)) + + mocks.stat.mockResolvedValue(changedStat) + await expect(readNativeChatTranscriptCached('claude', 'session')).resolves.toBe(newResult) + oldRead.resolve(oldResult) + await expect(first).resolves.toBe(oldResult) + await expect(readNativeChatTranscriptCached('claude', 'session')).resolves.toBe(newResult) + expect(mocks.read).toHaveBeenCalledTimes(2) + }) + + it('invalidates a completed parse when size changes without an mtime change', async () => { + const first: ReadTranscriptResult = { messages: [] } + const second: ReadTranscriptResult = { messages: [] } + mocks.read.mockResolvedValueOnce(first).mockResolvedValueOnce(second) + await expect(readNativeChatTranscriptCached('claude', 'session')).resolves.toBe(first) + mocks.stat.mockResolvedValue({ mtimeMs: 1, size: 20 }) + + await expect(readNativeChatTranscriptCached('claude', 'session')).resolves.toBe(second) + expect(mocks.read).toHaveBeenCalledTimes(2) + }) + + it('rechecks the cache when another read completes during stat', async () => { + const slowStat = Promise.withResolvers<{ mtimeMs: number; size: number }>() + const result: ReadTranscriptResult = { messages: [] } + mocks.stat.mockReturnValueOnce(slowStat.promise) + mocks.read.mockResolvedValue(result) + const delayed = readNativeChatTranscriptCached('claude', 'session') + await vi.waitFor(() => expect(mocks.stat).toHaveBeenCalledTimes(1)) + await expect(readNativeChatTranscriptCached('claude', 'session')).resolves.toBe(result) + slowStat.resolve({ mtimeMs: 1, size: 10 }) + + await expect(delayed).resolves.toBe(result) + expect(mocks.read).toHaveBeenCalledTimes(1) + }) + + it('serves the latest completed parse if a concurrent stat is refused', async () => { + const first: ReadTranscriptResult = { messages: [] } + const latest: ReadTranscriptResult = { messages: [] } + mocks.read.mockResolvedValueOnce(first).mockResolvedValueOnce(latest) + await readNativeChatTranscriptCached('claude', 'session') + + const slowStat = Promise.withResolvers<{ mtimeMs: number; size: number }>() + mocks.stat.mockReturnValueOnce(slowStat.promise).mockResolvedValue({ mtimeMs: 2, size: 20 }) + const delayed = readNativeChatTranscriptCached('claude', 'session') + await vi.waitFor(() => expect(mocks.stat).toHaveBeenCalledTimes(2)) + await expect(readNativeChatTranscriptCached('claude', 'session')).resolves.toBe(latest) + slowStat.reject(wslTranscriptFsTimeoutError()) + + await expect(delayed).resolves.toBe(latest) + await expect(readNativeChatTranscriptCached('claude', 'session')).resolves.toBe(latest) + expect(mocks.read).toHaveBeenCalledTimes(2) + }) + + it.each(['resolve', 'stat', 'read'] as const)( + 'clear fences work waiting on %s', + async (stage) => { + const oldResult: ReadTranscriptResult = { messages: [] } + const freshResult: ReadTranscriptResult = { messages: [] } + const pending = Promise.withResolvers() + if (stage === 'resolve') { + mocks.resolve.mockImplementationOnce(async () => { + await pending.promise + return '/repo/session.jsonl' + }) + } else if (stage === 'stat') { + mocks.stat.mockImplementationOnce(async () => { + await pending.promise + return { mtimeMs: 1, size: 10 } + }) + } else { + mocks.read.mockImplementationOnce(async () => { + await pending.promise + return oldResult + }) + } + const oldCall = readNativeChatTranscriptCached('claude', 'session') + await vi.waitFor(() => expect(mocks[stage]).toHaveBeenCalledTimes(1)) + clearNativeChatTranscriptCache() + mocks.read.mockResolvedValue(freshResult) + await expect(readNativeChatTranscriptCached('claude', 'session')).resolves.toBe(freshResult) + mocks.read.mockResolvedValue(oldResult) + pending.resolve() + + await expect(oldCall).resolves.toBe(oldResult) + await expect(readNativeChatTranscriptCached('claude', 'session')).resolves.toBe(freshResult) + expect(mocks.read).toHaveBeenCalledTimes(2) + } + ) + + it.each([ + { error: WSL_TRANSCRIPT_FS_SLOW_MESSAGE }, + { error: 'File was rotated', notFound: true } as const + ])('shares a retryable result only while in flight: %j', async (error) => { + const pending = Promise.withResolvers() + mocks.read.mockReturnValueOnce(pending.promise).mockResolvedValue({ messages: [] }) + const first = readNativeChatTranscriptCached('claude', 'session') + const second = readNativeChatTranscriptCached('claude', 'session') + await vi.waitFor(() => expect(mocks.stat).toHaveBeenCalledTimes(2)) + pending.resolve(error) + + expect(await Promise.all([first, second])).toEqual([error, error]) + expect(mocks.read).toHaveBeenCalledTimes(1) + await expect(readNativeChatTranscriptCached('claude', 'session')).resolves.toEqual({ + messages: [] + }) + expect(mocks.read).toHaveBeenCalledTimes(2) + }) + + it('retries a rejected body read', async () => { + mocks.read.mockRejectedValueOnce(new Error('read failure')).mockResolvedValue({ messages: [] }) + await expect(readNativeChatTranscriptCached('claude', 'session')).rejects.toThrow( + 'read failure' + ) + await expect(readNativeChatTranscriptCached('claude', 'session')).resolves.toEqual({ + messages: [] + }) + expect(mocks.read).toHaveBeenCalledTimes(2) + }) + + it('does not share or cache reads whose file generation could not be determined', async () => { + mocks.stat.mockRejectedValue(new Error('stat failed')) + mocks.read.mockImplementation(async () => ({ messages: [] })) + const [first, second] = await Promise.all([ + readNativeChatTranscriptCached('claude', 'session'), + readNativeChatTranscriptCached('claude', 'session') + ]) + expect(first).not.toBe(second) + await readNativeChatTranscriptCached('claude', 'session') + expect(mocks.read).toHaveBeenCalledTimes(3) + }) +}) diff --git a/src/main/native-chat/transcript-read-cache.ts b/src/main/native-chat/transcript-read-cache.ts index ab5c220bfd1..828521e2e3d 100644 --- a/src/main/native-chat/transcript-read-cache.ts +++ b/src/main/native-chat/transcript-read-cache.ts @@ -1,4 +1,5 @@ import type { AgentType } from '../../shared/native-chat-types' +import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe' import { resolveSessionFilePath } from './session-file-resolver' import { readNativeChatTranscript, type ReadTranscriptResult } from './transcript-reader' import { wslGatedStat } from './wsl-transcript-fs-access' @@ -24,14 +25,18 @@ import { type CachedTranscript = { result: ReadTranscriptResult - /** mtime of the resolved file when cached; a newer mtime invalidates it. */ + /** mtime of the resolved file when cached; changed mtime or size invalidates it. */ mtimeMs: number /** On-disk byte size of the resolved file — a cheap, monotonic proxy for this * entry's parsed memory footprint, used to bound the cache by total bytes. */ bytes: number + readOrder: number } const cache = new Map() +const inFlightReads = new InFlightPromiseDedupe() +let cacheEpoch = 0 +let nextReadOrder = 0 // Why: cap the cache so a long-lived process browsing many sessions can't grow // it unbounded. Map preserves insertion order, so evicting the first key drops @@ -90,7 +95,7 @@ async function fileStat(filePath: string): Promise<{ mtimeMs: number; bytes: num /** * Read the full transcript for an agent + session, returning the cached parse on - * an mtime hit and re-reading (and re-caching) when the file changed. Returns the + * an mtime/size hit and re-reading (and re-caching) when the file changed. Returns the * canonical, unwindowed result; callers apply their own windowing/truncation. */ export async function readNativeChatTranscriptCached( @@ -99,6 +104,7 @@ export async function readNativeChatTranscriptCached( /** Hook-reported authoritative transcript path, preferred over the id glob. */ transcriptPath?: string ): Promise { + const epoch = cacheEpoch let filePath: string | null try { filePath = await resolveSessionFilePath(agent, sessionId, { transcriptPath }) @@ -114,7 +120,7 @@ export async function readNativeChatTranscriptCached( } const key = cacheKey(agent, filePath) - const cached = cache.get(key) + let cached = epoch === cacheEpoch ? cache.get(key) : undefined let mtimeMs: number let bytes: number try { @@ -123,6 +129,7 @@ export async function readNativeChatTranscriptCached( // Why: the refusal says the distro stalled, not that this parse went stale — // a complete cached transcript beats a retry banner. With nothing cached the // error stands, and no `notFound`, so the next call re-stats a woken distro. + cached = epoch === cacheEpoch ? (cache.get(key) ?? cached) : undefined if (cached) { // Bump recency so a session read through a stall survives eviction. setCached(key, cached) @@ -130,30 +137,43 @@ export async function readNativeChatTranscriptCached( } return { error: wslTranscriptFsRefusal(err).message } } - if (cached && Number.isFinite(mtimeMs) && cached.mtimeMs === mtimeMs) { + // Another reader can populate the cache while this caller waits for stat. + cached = epoch === cacheEpoch ? cache.get(key) : undefined + if (cached && Number.isFinite(mtimeMs) && cached.mtimeMs === mtimeMs && cached.bytes === bytes) { // Bump recency so a frequently-read session survives eviction. setCached(key, cached) return cached.result } - const result = await readNativeChatTranscript(agent, sessionId, { filePath }) - // Why: a body-read refusal is transient unavailability, but the file's mtime - // is unchanged by it — caching it would serve the retryable error to every - // later call until the transcript itself changes, even after the distro woke. - if (Number.isFinite(mtimeMs) && !isGateRefusal(result)) { - setCached(key, { result, mtimeMs, bytes }) + if (!Number.isFinite(mtimeMs) || epoch !== cacheEpoch) { + return readNativeChatTranscript(agent, sessionId, { filePath }) } - return result + + return inFlightReads.run(stableInFlightKey([key, mtimeMs, bytes, epoch]), async () => { + const readOrder = ++nextReadOrder + const result = await readNativeChatTranscript(agent, sessionId, { filePath }) + // Late parses must not overwrite a newer completed read or repopulate a cleared cache. + if ( + epoch === cacheEpoch && + (cache.get(key)?.readOrder ?? 0) < readOrder && + !isRetryableReadError(result) + ) { + setCached(key, { result, mtimeMs, bytes, readOrder }) + } + return result + }) } -// The reader flattens a refusal into its message, so that is the only handle -// this layer has on one. -function isGateRefusal(result: ReadTranscriptResult): boolean { - return 'error' in result && isWslTranscriptFsRefusalMessage(result.error) +function isRetryableReadError(result: ReadTranscriptResult): boolean { + return ( + 'error' in result && (result.notFound === true || isWslTranscriptFsRefusalMessage(result.error)) + ) } /** Test-only: drop the transcript parse cache between runs. */ export function clearNativeChatTranscriptCache(): void { + cacheEpoch += 1 + inFlightReads.clear() cache.clear() } diff --git a/src/main/network/macos-tailscale-dns-diagnostic.ts b/src/main/network/macos-tailscale-dns-diagnostic.ts index a4dc948c0bd..a8e5a92ef58 100644 --- a/src/main/network/macos-tailscale-dns-diagnostic.ts +++ b/src/main/network/macos-tailscale-dns-diagnostic.ts @@ -14,8 +14,28 @@ type CacheEntry = { let cache: CacheEntry | null = null +// Symbolic errno/Chromium codes carry the weight because they are locale-independent; the +// English phrases only add coverage, so a localized or reworded string costs the hint, never +// a wrong message. Deliberately over-inclusive: a false candidate re-reads cached DNS state, +// a missed one silently drops the diagnostic that explains a broken connection. const NETWORK_LOOKUP_FAILURE_RE = - /\b(?:ENOTFOUND|EAI_AGAIN|ESERVFAIL|ERR_NAME_NOT_RESOLVED)\b|lookup address|nodename nor servname|name resolution|dns|websocket|connection refused/i + /\b(?:ENOTFOUND|ENODATA|EAI_AGAIN|EAI_FAIL|EAI_NODATA|EAI_NONAME|ESERVFAIL|ERR_NAME_NOT_RESOLVED|ERR_NAME_RESOLUTION_FAILED|getaddrinfo)\b|lookup address|nodename nor servname|name resolution|name or service not known|no address associated with hostname|(?:could not|couldn't|cannot|can't|unable to|failed to) resolve|dns|websocket|connection refused/i + +const MAGIC_DNS_HINT = + 'macOS is using Tailscale MagicDNS (100.100.100.100) as the only global DNS resolver; add an upstream DNS server to the active network service or configure Tailscale global nameservers, then retry.' + +/** + * Single source of truth for "could a DNS sample explain this failure?". The probe-admission + * gate and the hint decision must never disagree — a gate stricter than the hint test silently + * loses the diagnostic for a genuine DNS failure. + */ +export function isMacTailscaleDnsHintCandidate(message: string, detail?: string | null): boolean { + // Already hinted: re-wrapping a hinted message would match on its own "DNS" text. + if (message.endsWith(MAGIC_DNS_HINT)) { + return false + } + return NETWORK_LOOKUP_FAILURE_RE.test(`${message}\n${detail ?? ''}`) +} function globalDnsSection(scutilOutput: string): string { const scopedStart = scutilOutput.indexOf('\nDNS configuration (for scoped queries)') @@ -66,26 +86,31 @@ function readMacTailscaleDnsDiagnostic(now = Date.now()): DnsDiagnostic | null { return diagnostic } +// Why: Claude/Codex own the failing API transports, so Orca can only point +// users at the macOS resolver configuration that makes those transports fail. +function appendMagicDnsHint(message: string, diagnostic: DnsDiagnostic | null): string { + return diagnostic ? `${message} ${MAGIC_DNS_HINT}` : message +} + export function withMacTailscaleDnsHintForDiagnostic( message: string, detail: string | null | undefined, diagnostic: DnsDiagnostic | null ): string { - const probeText = `${message}\n${detail ?? ''}` - if (!NETWORK_LOOKUP_FAILURE_RE.test(probeText)) { - return message - } - if (!diagnostic) { - return message - } - - // Why: Claude/Codex own the failing API transports, so Orca can only point - // users at the macOS resolver configuration that makes those transports fail. - return `${message} macOS is using Tailscale MagicDNS (100.100.100.100) as the only global DNS resolver; add an upstream DNS server to the active network service or configure Tailscale global nameservers, then retry.` + return isMacTailscaleDnsHintCandidate(message, detail) + ? appendMagicDnsHint(message, diagnostic) + : message } export function withMacTailscaleDnsHint(message: string, detail?: string | null): string { - return withMacTailscaleDnsHintForDiagnostic(message, detail, readMacTailscaleDnsDiagnostic()) + // The hint only describes macOS resolver state, so nothing off darwin can produce it. + if (process.platform !== 'darwin') { + return message + } + if (!isMacTailscaleDnsHintCandidate(message, detail)) { + return message + } + return appendMagicDnsHint(message, readMacTailscaleDnsDiagnostic()) } export function __resetMacTailscaleDnsDiagnosticCacheForTests(): void { diff --git a/src/main/network/macos-tailscale-dns-probe-admission.test.ts b/src/main/network/macos-tailscale-dns-probe-admission.test.ts new file mode 100644 index 00000000000..9297c356f13 --- /dev/null +++ b/src/main/network/macos-tailscale-dns-probe-admission.test.ts @@ -0,0 +1,160 @@ +import { execFileSync } from 'node:child_process' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { formatAgentCliFailureMessage } from '../text-generation/source-control-agent-failure' +import { + __resetMacTailscaleDnsDiagnosticCacheForTests, + parseMacTailscaleDnsDiagnostic, + withMacTailscaleDnsHint, + withMacTailscaleDnsHintForDiagnostic +} from './macos-tailscale-dns-diagnostic' + +vi.mock('node:child_process', () => ({ execFileSync: vi.fn() })) + +const MAGIC_DNS = 'DNS configuration\n nameserver[0] : 100.100.100.100\n' +const PUBLIC_DNS = 'DNS configuration\n nameserver[0] : 1.1.1.1\n' + +beforeEach(() => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + vi.spyOn(Date, 'now').mockReturnValue(1_000) + vi.mocked(execFileSync).mockReset().mockReturnValue(MAGIC_DNS) + __resetMacTailscaleDnsDiagnosticCacheForTests() +}) + +afterEach(() => { + vi.restoreAllMocks() + __resetMacTailscaleDnsDiagnosticCacheForTests() +}) + +describe('macOS DNS probe admission', () => { + it.each(['permission denied', 'authentication failed', 'PTY timeout', '', 'invalid JSON'])( + 'does not probe for an unrelated error: %s', + (detail) => { + expect(withMacTailscaleDnsHint('Codex failed.', detail)).toBe('Codex failed.') + expect(execFileSync).not.toHaveBeenCalled() + } + ) + + it.each([ + 'ENOTFOUND', + 'eai_again', + 'lookup address', + 'DNS failure', + 'websocket', + 'connection refused' + ])('still diagnoses a relevant detail: %s', (detail) => { + expect(withMacTailscaleDnsHint('Codex failed.', detail)).toContain('Tailscale MagicDNS') + expect(execFileSync).toHaveBeenCalledOnce() + }) + + it('recognizes a relevant message without detail', () => { + expect(withMacTailscaleDnsHint('ERR_NAME_NOT_RESOLVED')).toContain('Tailscale MagicDNS') + expect(execFileSync).toHaveBeenCalledOnce() + }) + + it.each(['linux', 'win32'] as const)('never probes on %s', (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + expect(withMacTailscaleDnsHint('ENOTFOUND')).toBe('ENOTFOUND') + expect(execFileSync).not.toHaveBeenCalled() + }) + + it('does not warm the diagnostic cache for an unrelated failure', () => { + vi.mocked(execFileSync).mockReturnValue(PUBLIC_DNS) + withMacTailscaleDnsHint('permission denied') + expect(execFileSync).not.toHaveBeenCalled() + + vi.mocked(execFileSync).mockReturnValue(MAGIC_DNS) + expect(withMacTailscaleDnsHint('ENOTFOUND')).toContain('Tailscale MagicDNS') + expect(execFileSync).toHaveBeenCalledOnce() + }) + + it('reuses the sample inside the five-minute window and refreshes it after', () => { + expect(withMacTailscaleDnsHint('ENOTFOUND')).toContain('Tailscale MagicDNS') + + vi.mocked(Date.now).mockReturnValue(300_999) + vi.mocked(execFileSync).mockReturnValue(PUBLIC_DNS) + expect(withMacTailscaleDnsHint('ENOTFOUND')).toContain('Tailscale MagicDNS') + + vi.mocked(Date.now).mockReturnValue(301_000) + expect(withMacTailscaleDnsHint('permission denied')).toBe('permission denied') + // The invariant is the resolver state the next relevant error reports, not the probe count. + expect(withMacTailscaleDnsHint('ENOTFOUND')).toBe('ENOTFOUND') + }) + + it.each(['empty output', 'failed command'])('retains negative caching for %s', (failure) => { + vi.mocked(execFileSync).mockImplementation(() => { + if (failure === 'failed command') { + throw new Error('probe failed') + } + return '' + }) + expect(withMacTailscaleDnsHint('ENOTFOUND')).toBe('ENOTFOUND') + expect(withMacTailscaleDnsHint('EAI_AGAIN')).toBe('EAI_AGAIN') + expect(execFileSync).toHaveBeenCalledOnce() + }) + + it.each([ + 'EAI_NONAME', + 'EAI_FAIL', + 'ENODATA', + 'getaddrinfo failed', + 'could not resolve host orca.example', + 'Name or service not known', + 'ERR_NAME_RESOLUTION_FAILED', + 'Temporary failure in name resolution' + ])('diagnoses the resolution failure wording %s', (detail) => { + expect(withMacTailscaleDnsHint('Codex failed.', detail)).toContain('Tailscale MagicDNS') + }) + + it('never lets probe admission change the message the hint decision would produce', () => { + const diagnostic = parseMacTailscaleDnsDiagnostic(MAGIC_DNS) + const details = [ + 'permission denied', + 'authentication failed', + 'PTY timeout', + 'invalid JSON', + '', + 'ENOTFOUND', + 'EAI_AGAIN', + 'EAI_NONAME', + 'getaddrinfo failed', + 'could not resolve host orca.example', + 'connection refused', + 'websocket closed' + ] + + for (const detail of details) { + __resetMacTailscaleDnsDiagnosticCacheForTests() + expect(withMacTailscaleDnsHint('Codex failed.', detail)).toBe( + withMacTailscaleDnsHintForDiagnostic('Codex failed.', detail, diagnostic) + ) + } + }) + + it('does not append or re-probe for a message that already carries the hint', () => { + const hinted = withMacTailscaleDnsHint('Codex failed.', 'ENOTFOUND') + expect(execFileSync).toHaveBeenCalledOnce() + // Past the cache window, so a second probe would run if the hint were re-admitted. + vi.mocked(Date.now).mockReturnValue(301_000) + + expect(withMacTailscaleDnsHint(hinted, 'ENOTFOUND')).toBe(hinted) + expect(execFileSync).toHaveBeenCalledOnce() + }) + + it('avoids probing while formatting a local CLI permission failure', () => { + expect(formatAgentCliFailureMessage('Codex', '', 'permission denied', 1)).toBe( + 'Codex CLI command failed with code 1: permission denied' + ) + expect(execFileSync).not.toHaveBeenCalled() + }) + + it('keeps local network hints and honors the remote host opt-out', () => { + expect( + formatAgentCliFailureMessage('Codex', '', 'ENOTFOUND', 1, { includeLocalMacDnsHint: false }) + ).toBe('Codex CLI command failed with code 1: ENOTFOUND') + expect(execFileSync).not.toHaveBeenCalled() + expect(formatAgentCliFailureMessage('Codex', '', 'ENOTFOUND', 1)).toContain( + 'Tailscale MagicDNS' + ) + expect(execFileSync).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/notebook/notebook-kernel.test.ts b/src/main/notebook/notebook-kernel.test.ts index 899205dc2fe..1ed290e3f9e 100644 --- a/src/main/notebook/notebook-kernel.test.ts +++ b/src/main/notebook/notebook-kernel.test.ts @@ -8,21 +8,189 @@ vi.mock('../../../resources/notebook/kernel-bridge.py?asset&asarUnpack', () => ( import { createFrameReader, startNotebookKernel } from './notebook-kernel' +function collect(chunks: string[]): unknown[] { + const frames: unknown[] = [] + const read = createFrameReader((frame) => frames.push(frame)) + for (const chunk of chunks) { + read(chunk) + } + return frames +} + describe('createFrameReader', () => { it('reassembles frames split across chunks and skips stray lines', () => { - const frames: unknown[] = [] - const read = createFrameReader((frame) => frames.push(frame)) - read('warning: something printed\n{"type": "rea') - read('dy"}\n{"type": "stream", "content": {"name": "stdout", "text": "hi"}}\n[1, 2]\n') - read('{"type": "missing", "externallyManaged": true}\n{"type": "missing"}\n') - read('{"type": "unknown"}\n{"type": "done", "status": "ok", "execution_count": 3}\n{"partial') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + expect( + collect([ + 'warning: something printed\n{"type": "rea', + 'dy"}\n{"type": "stream", "content": {"name": "stdout", "text": "hi"}}\n[1, 2]\n', + '{"type": "missing", "externallyManaged": true}\n{"type": "missing"}\n', + '{"type": "unknown"}\n{"type": "done", "status": "ok", "execution_count": 3}\n{"partial' + ]) + ).toEqual([ + { type: 'ready' }, + { type: 'stream', content: { name: 'stdout', text: 'hi' } }, + { type: 'missing', externallyManaged: true }, + { type: 'missing', externallyManaged: false }, + { type: 'done', status: 'ok', execution_count: 3 } + ]) + } finally { + warn.mockRestore() + } + }) + + it('avoids repeatedly scanning an incomplete image record for newlines', () => { + const frame = { type: 'display_data', content: { data: { 'image/png': 'x'.repeat(524_288) } } } + const wire = `${JSON.stringify(frame)}\n` + const chunks: string[] = [] + for (let offset = 0; offset < wire.length; offset += 4096) { + chunks.push(wire.slice(offset, offset + 4096)) + } + const originalSplit: { split(separator: unknown, limit?: number): string[] }['split'] = + String.prototype.split + const originalIndexOf = String.prototype.indexOf + let scannedCharacters = 0 + const split = vi.spyOn(String.prototype, 'split').mockImplementation(function ( + this: string, + separator: unknown, + limit?: number + ) { + if (separator === '\n') { + scannedCharacters += this.length + } + return originalSplit.call(this, separator, limit) + }) + const indexOf = vi.spyOn(String.prototype, 'indexOf').mockImplementation(function ( + this: string, + search: string, + position = 0 + ) { + const found = originalIndexOf.call(this, search, position) + if (search === '\n') { + scannedCharacters += (found === -1 ? this.length : found + 1) - position + } + return found + }) + let frames: unknown[] + try { + frames = collect(chunks) + } finally { + split.mockRestore() + indexOf.mockRestore() + } + expect(frames).toEqual([frame]) + expect(scannedCharacters).toBeLessThanOrEqual(wire.length * 2) + }) + + it('does not measure record bytes when no size limit applies', () => { + const frame = { type: 'display_data', content: { data: { 'image/png': 'x'.repeat(65_536) } } } + const byteLength = vi.spyOn(Buffer, 'byteLength') + let frames: unknown[] + let measurements: number + try { + frames = collect([`${JSON.stringify(frame)}\n`]) + measurements = byteLength.mock.calls.length + } finally { + byteLength.mockRestore() + } + expect(frames).toEqual([frame]) + expect(measurements).toBe(0) + }) + + it('preserves code units at every split boundary', () => { + const frames = [ + { type: 'stream', content: { text: 'café 漢字 🐋\n\u0000' } }, + { type: 'execute_result', content: { data: { 'text/plain': '42' } } }, + { type: 'done', status: 'ok', execution_count: 3 } + ] + const wire = `${frames.map((frame) => JSON.stringify(frame)).join('\n')}\n` + for (let offset = 0; offset <= wire.length; offset++) { + expect(collect([wire.slice(0, offset), '', wire.slice(offset)])).toEqual(frames) + } + expect(collect(wire.split(''))).toEqual(frames) + }) + + it('skips malformed and unsupported records without losing valid neighbors', () => { + const wire = [ + 'startup warning', + '', + ' ', + 'null', + '[]', + '{"type":"ready"}', + '{"type":"missing","externallyManaged":true}', + '{"type":"missing","externallyManaged":"true"}', + '{"type":"stream","content":[]}', + '{"type":"unknown","content":{}}', + '{"type":"done","status":12,"execution_count":"3"}', + '' + ].join('\r\n') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + let frames: unknown[] + let reported: number + try { + frames = collect([wire]) + reported = warn.mock.calls.length + } finally { + warn.mockRestore() + } expect(frames).toEqual([ { type: 'ready' }, - { type: 'stream', content: { name: 'stdout', text: 'hi' } }, { type: 'missing', externallyManaged: true }, { type: 'missing', externallyManaged: false }, - { type: 'done', status: 'ok', execution_count: 3 } + { type: 'done', status: '12', execution_count: null } ]) + // The unreadable line is reported; a parsed record of an unknown shape is not. + expect(reported).toBe(1) + }) + + it('does not apply the shared transport size limit to notebook display data', () => { + const frame = { + type: 'display_data', + content: { data: { 'image/png': 'x'.repeat(16 * 1024 * 1024 + 1) } } + } + expect(collect([JSON.stringify(frame), '\n'])).toEqual([frame]) + }) + + it('waits for the final newline before delivering a record', () => { + const frames: unknown[] = [] + const read = createFrameReader((frame) => frames.push(frame)) + read('{"type":"rea') + expect(frames).toEqual([]) + read('dy"}\n{"type":"missing"}') + expect(frames).toEqual([{ type: 'ready' }]) + read('') + expect(frames).toHaveLength(1) + read('\n') + expect(frames).toEqual([{ type: 'ready' }, { type: 'missing', externallyManaged: false }]) + }) + + it('holds a never-terminated record without emitting or rejecting it', () => { + const frames: unknown[] = [] + const text = 'x'.repeat(200_000) + const wire = JSON.stringify({ type: 'stream', content: { name: 'stdout', text } }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const read = createFrameReader((frame) => frames.push(frame)) + try { + for (let offset = 0; offset < wire.length; offset += 4096) { + read(wire.slice(offset, offset + 4096)) + } + expect(frames).toEqual([]) + expect(warn).not.toHaveBeenCalled() + read('\n') + } finally { + warn.mockRestore() + } + expect(frames).toEqual([{ type: 'stream', content: { name: 'stdout', text } }]) + }) + + it('does not swallow a consumer exception as malformed JSON', () => { + const error = new Error('consumer failed') + const read = createFrameReader(() => { + throw error + }) + expect(() => read('{"type":"ready"}\n')).toThrow(error) }) }) diff --git a/src/main/notebook/notebook-kernel.ts b/src/main/notebook/notebook-kernel.ts index 6e50542f1f9..413b3bfeb65 100644 --- a/src/main/notebook/notebook-kernel.ts +++ b/src/main/notebook/notebook-kernel.ts @@ -1,6 +1,7 @@ import bridgePath from '../../../resources/notebook/kernel-bridge.py?asset&asarUnpack' import { spawnProcess } from '../../shared/child-process/run-process' import { forceTerminateProcessTree } from '../../shared/child-process/process-tree-termination' +import { createNdjsonParser } from '../../shared/main-process-ndjson-framer' import { KERNEL_OUTPUT_TYPES, type KernelFrame, @@ -16,13 +17,7 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -function parseFrame(line: string): BridgeFrame | null { - let value: unknown - try { - value = JSON.parse(line) - } catch { - return null - } +function parseFrame(value: unknown): BridgeFrame | null { if (!isRecord(value)) { return null } @@ -46,17 +41,20 @@ function parseFrame(line: string): BridgeFrame | null { /** Splits bridge stdout into frames, skipping any line that is not one. */ export function createFrameReader(onFrame: (frame: BridgeFrame) => void): (text: string) => void { - let partial = '' - return (text) => { - const lines = (partial + text).split('\n') - partial = lines.pop() ?? '' - for (const line of lines) { - const frame = parseFrame(line) + // Each frame ships as its line completes; a consumer throw escapes the stdout listener and is fatal anyway. + const parser = createNdjsonParser( + (value) => { + const frame = parseFrame(value) if (frame) { onFrame(frame) } - } - } + }, + // The bridge keeps fd 1 to itself, so an unreadable line means the frame channel is damaged. + (error) => console.warn('[notebook-kernel] Dropped an unreadable bridge record:', error), + // Notebook display frames can contain large images; preserve the existing unrestricted size. + { maxLineBytes: Number.POSITIVE_INFINITY } + ) + return (text) => parser.feed(text) } export type NotebookKernel = { diff --git a/src/main/notebook/python-environments.test.ts b/src/main/notebook/python-environments.test.ts index 05088d8236b..eb69d981493 100644 --- a/src/main/notebook/python-environments.test.ts +++ b/src/main/notebook/python-environments.test.ts @@ -9,7 +9,8 @@ vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProces import { createNotebookVenv, findWorkspaceInterpreters, - installIpykernel + installIpykernel, + listPythonEnvironments } from './python-environments' function existing(...paths: string[]): (path: string) => boolean { @@ -47,6 +48,96 @@ describe('findWorkspaceInterpreters', () => { }) }) +describe('listPythonEnvironments', () => { + let root = '' + const windows = process.platform === 'win32' + const venvPython = (): string => + join(root, '.venv', ...(windows ? ['Scripts', 'python.exe'] : ['bin', 'python'])) + const condaPython = (): string => + join(root, 'nb', '.conda', ...(windows ? ['python.exe'] : ['bin', 'python'])) + const programs = (): string[] => runProcessMock.mock.calls.map(([spec]) => spec.program) + + beforeEach(() => { + runProcessMock.mockReset() + runProcessMock.mockImplementation(async ({ program }: { program: string }) => ({ + code: 0, + stdout: `${program}\n3.13.0\n`, + stderr: '' + })) + root = mkdtempSync(join(tmpdir(), 'orca-pyenvs-')) + for (const interpreter of [venvPython(), condaPython()]) { + mkdirSync(dirname(interpreter), { recursive: true }) + writeFileSync(interpreter, '') + } + writeFileSync( + join(root, '.venv', 'pyvenv.cfg'), + 'home = /usr/bin\nversion_info = 3.12.1.final.0\n' + ) + const condaMeta = join(root, 'nb', '.conda', 'conda-meta') + mkdirSync(condaMeta) + writeFileSync(join(condaMeta, 'python-dateutil-2.9.0-pyhd8ed1ab_0.json'), '{}') + writeFileSync(join(condaMeta, 'python-3.11.9-h8d4a6d2_0.json'), '{}') + return () => rmSync(root, { recursive: true, force: true }) + }) + + it('lists workspace envs from disk without running them before trust', async () => { + const found = await listPythonEnvironments(join(root, 'nb', 'a.ipynb'), root, { + runWorkspaceInterpreters: false + }) + expect(programs()).not.toContain(venvPython()) + expect(programs()).not.toContain(condaPython()) + expect(programs().length).toBeGreaterThan(0) + expect(found.workspace).toEqual([ + { path: condaPython(), name: '.conda', version: '3.11.9' }, + { path: venvPython(), name: '.venv', version: '3.12.1' } + ]) + }) + + it('reads the stdlib venv version key and omits a version it cannot find', async () => { + writeFileSync(join(root, '.venv', 'pyvenv.cfg'), 'home = /usr/bin\nversion = 3.10.4\n') + const found = await listPythonEnvironments(join(root, 'a.ipynb'), root, { + runWorkspaceInterpreters: false + }) + expect(found.workspace).toEqual([{ path: venvPython(), name: '.venv', version: '3.10.4' }]) + + writeFileSync(join(root, '.venv', 'pyvenv.cfg'), 'home = /usr/bin\n') + const unversioned = await listPythonEnvironments(join(root, 'a.ipynb'), root, { + runWorkspaceInterpreters: false + }) + expect(unversioned.workspace).toEqual([{ path: venvPython(), name: '.venv' }]) + }) + + it('reads only the head of pyvenv.cfg, whatever size it reports', async () => { + // procfs files report size 0 yet never end, so the read must be bounded, not size-gated. + const padding = `# ${'x'.repeat(1022)}\n`.repeat(128) + const cfg = join(root, '.venv', 'pyvenv.cfg') + const list = () => + listPythonEnvironments(join(root, 'a.ipynb'), root, { runWorkspaceInterpreters: false }) + + writeFileSync(cfg, `version_info = 3.12.1.final.0\n${padding}`) + expect((await list()).workspace).toEqual([ + { path: venvPython(), name: '.venv', version: '3.12.1' } + ]) + + writeFileSync(cfg, `${padding}version_info = 3.12.1.final.0\n`) + expect((await list()).workspace).toEqual([{ path: venvPython(), name: '.venv' }]) + }) + + it('probes workspace envs once the notebook is trusted, dropping ones that fail', async () => { + runProcessMock.mockImplementation(async ({ program }: { program: string }) => + program === condaPython() + ? { code: 1, stdout: '', stderr: 'broken' } + : { code: 0, stdout: `${program}\n3.13.0\n`, stderr: '' } + ) + const found = await listPythonEnvironments(join(root, 'nb', 'a.ipynb'), root, { + runWorkspaceInterpreters: true + }) + expect(programs()).toContain(venvPython()) + expect(programs()).toContain(condaPython()) + expect(found.workspace).toEqual([{ path: venvPython(), name: '.venv', version: '3.13.0' }]) + }) +}) + describe('installIpykernel', () => { beforeEach(() => runProcessMock.mockReset()) const result = (code: number | null, stderr = '', extra = {}) => ({ diff --git a/src/main/notebook/python-environments.ts b/src/main/notebook/python-environments.ts index 4270f9ca62d..116e20e9e1b 100644 --- a/src/main/notebook/python-environments.ts +++ b/src/main/notebook/python-environments.ts @@ -1,4 +1,4 @@ -import { existsSync } from 'node:fs' +import { closeSync, existsSync, openSync, readdirSync, readSync, statSync } from 'node:fs' import { basename, dirname, isAbsolute, join, relative } from 'node:path' import { runProcess } from '../../shared/child-process/run-process' import type { ProcessResult } from '../../shared/child-process/process-spec' @@ -15,6 +15,7 @@ const WORKSPACE_ENV_DIRS = ['.venv', '.conda'] const INSTALL_TIMEOUT_MS = 10 * 60_000 const INSTALL_DETAIL_CHARS = 4000 const VENV_TIMEOUT_MS = 2 * 60_000 +const PYVENV_CFG_MAX_BYTES = 64 * 1024 /** `.venv`/`.conda` interpreters from the notebook's folder up to the workspace root, nearest first. */ export function findWorkspaceInterpreters( @@ -62,27 +63,86 @@ async function probe( } } -/** Names an interpreter after its environment folder when it lives in one. */ -function environmentName(executable: string): string { +/** The environment folder an interpreter lives in, when it lives in one. */ +function environmentDir(executable: string): string | undefined { // venvs keep python in bin/ or Scripts\; Windows conda envs keep it at the env root. - const envDir = [dirname(dirname(executable)), dirname(executable)].find( + return [dirname(dirname(executable)), dirname(executable)].find( (dir) => existsSync(join(dir, 'pyvenv.cfg')) || existsSync(join(dir, 'conda-meta')) ) - return basename(envDir ?? executable) +} + +/** Names an interpreter after its environment folder when it lives in one. */ +function environmentName(executable: string): string { + return basename(environmentDir(executable) ?? executable) +} + +/** The head of a regular file, read once into a fixed buffer. */ +function readFileHead(path: string, maxBytes: number): string | undefined { + // Why not a FIFO or device: opening one can block; a symlinked procfs file passes and reports size 0. + if (!existsSync(path) || !statSync(path).isFile()) { + return undefined + } + const fd = openSync(path, 'r') + try { + const buffer = Buffer.alloc(maxBytes) + return buffer.toString('utf8', 0, readSync(fd, buffer, 0, maxBytes, 0)) + } finally { + closeSync(fd) + } +} + +/** The Python version an environment records on disk: venv `pyvenv.cfg`, else conda's `conda-meta`. */ +function recordedVersion(envDir: string): string | undefined { + try { + // Why bounded, not size-checked: a hostile repo can point pyvenv.cfg at an endless file. + const cfg = readFileHead(join(envDir, 'pyvenv.cfg'), PYVENV_CFG_MAX_BYTES) + const match = cfg && /^\s*version(?:_info)?\s*=\s*(\d+\.\d+(?:\.\d+)?)/m.exec(cfg) + if (match) { + return match[1] + } + const condaMeta = join(envDir, 'conda-meta') + if (existsSync(condaMeta)) { + for (const entry of readdirSync(condaMeta)) { + const match = /^python-(\d+\.\d+(?:\.\d+)?)-.*\.json$/.exec(entry) + if (match) { + return match[1] + } + } + } + } catch { + // Unreadable metadata just leaves the version unknown. + } + return undefined +} + +/** Describes a workspace interpreter from its files alone, without running it. */ +function describeWithoutRunning(interpreter: string): PythonEnvironment { + const envDir = environmentDir(interpreter) + const version = envDir === undefined ? undefined : recordedVersion(envDir) + const name = basename(envDir ?? interpreter) + return version ? { path: interpreter, name, version } : { path: interpreter, name } } export function describePython(path: string): Promise { return probe(path, [], environmentName) } +/** + * Pythons a notebook can use. `runWorkspaceInterpreters` is false until the notebook is trusted: + * a repo can ship its own `.venv/bin/python`, so those are then read from disk, never run. + */ export async function listPythonEnvironments( notebookPath: string, - rootPath: string | null + rootPath: string | null, + { runWorkspaceInterpreters }: { runWorkspaceInterpreters: boolean } ): Promise { const pathCommands = process.platform === 'win32' ? [['py', '-3'], ['python']] : [['python3'], ['python']] + const workspaceInterpreters = findWorkspaceInterpreters(notebookPath, rootPath) const [workspace, onPath] = await Promise.all([ - Promise.all(findWorkspaceInterpreters(notebookPath, rootPath).map(describePython)), + runWorkspaceInterpreters + ? Promise.all(workspaceInterpreters.map(describePython)) + : workspaceInterpreters.map(describeWithoutRunning), Promise.all( pathCommands.map(([program, ...args]) => probe(program, args, () => [program, ...args].join(' ')) diff --git a/src/main/observability/agent-session-instrumentation.ts b/src/main/observability/agent-session-instrumentation.ts index dc57bd69d0e..693d96a28c2 100644 --- a/src/main/observability/agent-session-instrumentation.ts +++ b/src/main/observability/agent-session-instrumentation.ts @@ -3,7 +3,6 @@ import { withSpan, type ActiveSpan } from './tracer' export type AgentSessionCreatePhase = | 'reconcile_leases' | 'resolve_recovery' - | 'settlement_retry' | 'probe_owner' | 'reserve_owner' | 'acquire_owner' diff --git a/src/main/observability/instrumentation.test.ts b/src/main/observability/instrumentation.test.ts index 9bdbfd91d02..139bb2494dd 100644 --- a/src/main/observability/instrumentation.test.ts +++ b/src/main/observability/instrumentation.test.ts @@ -261,8 +261,7 @@ describe('agentSession.create tracing', () => { totalDurationMs: 57, phases: [ { phase: 'reconcile_leases', startedAtMs: 0, durationMs: 1 }, - { phase: 'resolve_recovery', startedAtMs: 1, durationMs: 2 }, - { phase: 'settlement_retry', startedAtMs: 3, durationMs: 3 }, + { phase: 'resolve_recovery', startedAtMs: 1, durationMs: 5 }, { phase: 'probe_owner', startedAtMs: 6, durationMs: 4 }, { phase: 'reserve_owner', startedAtMs: 10, durationMs: 5 }, { phase: 'acquire_owner', startedAtMs: 15, durationMs: 6 }, diff --git a/src/main/opencode-usage/store.ts b/src/main/opencode-usage/store.ts index 5dc8a87c67b..3bdcefeb0a7 100644 --- a/src/main/opencode-usage/store.ts +++ b/src/main/opencode-usage/store.ts @@ -1,3 +1,4 @@ +import { codexOpenCodeTokenSessions } from '../usage/agent-token-usage' import { app } from 'electron' import { join } from 'node:path' import type { @@ -50,6 +51,10 @@ export class OpenCodeUsageStore extends UsageProviderStoreLifecycle< > { constructor(store: Pick) { super(store, { + tokenUsage: { + provider: 'opencode', + selectSessions: (state) => codexOpenCodeTokenSessions(state.sessions) + }, logTag: '[opencode-usage]', resolveCacheFile: getOpenCodeUsageFile, createDefaultState: getDefaultState, diff --git a/src/main/opencode/hook-plugin-dispose-host.test.ts b/src/main/opencode/hook-plugin-dispose-host.test.ts new file mode 100644 index 00000000000..179714d8c83 --- /dev/null +++ b/src/main/opencode/hook-plugin-dispose-host.test.ts @@ -0,0 +1,165 @@ +/** + * Executes the generated plugin through each OpenCode host entry point, because + * what disposal means differs by host: OpenCode 1 disposes only on instance + * teardown (which cancels every run), OpenCode 2 also on a plugin hot reload. + */ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>() +})) + +vi.mock('electron', () => ({ + app: { getPath: getPathMock } +})) + +import { _internals } from './hook-service' + +type PluginHooks = { + event: (input: { event: unknown }) => Promise + dispose?: () => Promise +} +type HostEvent = { type: string; data: Record } +type PluginModule = { + default?: { + server?: (ctx: unknown) => Promise + setup?: (ctx: unknown) => Promise<() => Promise> + } +} + +const ENV_KEYS = [ + 'ORCA_PANE_KEY', + 'ORCA_OPENCODE_AGENT', + 'ORCA_AGENT_HOOK_ENDPOINT', + 'ORCA_AGENT_HOOK_PORT', + 'ORCA_AGENT_HOOK_TOKEN' +] as const + +// A live OpenCode 2 event bus: stays open until the subscriber aborts. +function createEventBus(): { + push: (event: HostEvent) => void + subscribe: (input: { signal: AbortSignal }) => AsyncGenerator +} { + const queue: HostEvent[] = [] + let wake: (() => void) | null = null + return { + push(event) { + queue.push(event) + wake?.() + }, + async *subscribe({ signal }) { + while (!signal.aborted) { + const next = queue.shift() + if (next) { + yield next + continue + } + await new Promise((resolve) => { + wake = resolve + signal.addEventListener('abort', () => resolve(), { once: true }) + }) + wake = null + } + } + } +} + +describe.each(['opencode', 'opencode2'] as const)('%s plugin disposal by host', (agent) => { + let tempDir: string + let savedEnv: Record + let names: string[] + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-opencode-dispose-host-')) + savedEnv = {} + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key] + } + process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' + process.env.ORCA_OPENCODE_AGENT = agent + delete process.env.ORCA_AGENT_HOOK_ENDPOINT + process.env.ORCA_AGENT_HOOK_PORT = '59999' + process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token' + names = [] + vi.stubGlobal( + 'fetch', + vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + names.push(String(JSON.parse(String(init?.body)).payload?.hook_event_name)) + return new Response('{}', { status: 200 }) + }) + ) + }) + + afterEach(() => { + vi.unstubAllGlobals() + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = savedEnv[key] + } + } + rmSync(tempDir, { recursive: true, force: true }) + }) + + async function loadPluginModule(): Promise { + const pluginPath = join(tempDir, `orca-${agent}-status.mjs`) + writeFileSync( + pluginPath, + agent === 'opencode2' + ? _internals.getOpenCode2PluginSource() + : _internals.getOpenCodePluginSource() + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the generated plugin module exports this shape. + return (await import(pathToFileURL(pluginPath).href)) as PluginModule + } + + function setupContext(bus: ReturnType): unknown { + return { + session: { + get: async ({ sessionID }: { sessionID: string }) => ({ id: sessionID }), + hook: async () => ({ dispose: async () => {} }) + }, + event: { subscribe: bus.subscribe } + } + } + + it('publishes a final Idle when the OpenCode 1 instance tears down mid-turn', async () => { + const module = await loadPluginModule() + const hooks = await module.default?.server?.({ + client: { session: { get: async () => ({ data: { id: 'ses_root' } }) } } + }) + await hooks?.event({ + event: { + type: 'session.status', + properties: { sessionID: 'ses_root', status: { type: 'busy' } } + } + }) + await hooks?.dispose?.() + + expect(names).toEqual(['SessionBusy', 'SessionIdle']) + }) + + it('keeps the pane Working when OpenCode 2 reloads the plugin mid-turn', async () => { + const module = await loadPluginModule() + const firstBus = createEventBus() + const firstCleanup = await module.default?.setup?.(setupContext(firstBus)) + firstBus.push({ type: 'session.execution.started', data: { sessionID: 'ses_root' } }) + await vi.waitFor(() => expect(names).toEqual(['SessionBusy'])) + + await firstCleanup?.() + expect(names).toEqual(['SessionBusy']) + + // The turn kept running; its end reaches the reloaded plugin. + const secondBus = createEventBus() + const secondCleanup = await module.default?.setup?.(setupContext(secondBus)) + secondBus.push({ type: 'session.execution.succeeded', data: { sessionID: 'ses_root' } }) + await vi.waitFor(() => expect(names).toEqual(['SessionBusy', 'SessionIdle'])) + await secondCleanup?.() + expect(names).toEqual(['SessionBusy', 'SessionIdle']) + }) +}) diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index 920c0b5e392..c8941d83313 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -1,13 +1,17 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { existsSync, + openSync, + closeSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, + statSync, symlinkSync, + utimesSync, writeFileSync } from 'node:fs' import { createHash } from 'node:crypto' @@ -45,26 +49,6 @@ beforeEach(() => { const { isUsableId, toSafeDirName } = _internals describe('OpenCode hook plugin source', () => { - it('preserves the public module surface', async () => { - const module = await import('./hook-service') - - expect(Object.keys(module).sort()).toEqual([ - 'OpenCodeHookService', - '_internals', - 'getOpenCode2PluginSource', - 'getOpenCodeFamilyPluginSource', - 'getOpenCodePluginSource', - 'openCode2HookService', - 'openCodeHookService' - ]) - expect(Object.keys(module._internals).sort()).toEqual([ - 'getOpenCode2PluginSource', - 'getOpenCodePluginSource', - 'isUsableId', - 'toSafeDirName' - ]) - }) - it('keeps family routing and session-start policy separate', () => { const primarySource = getOpenCodePluginSource() const familySource = getOpenCodeFamilyPluginSource('/hook/mimo-code', { @@ -93,11 +77,11 @@ describe('OpenCode hook plugin source', () => { const digest = (source: string): string => createHash('sha256').update(source).digest('hex') expect(digest(getOpenCodePluginSource())).toBe( - 'f2c469ff2d360ed94955d715705b9d4dd633dd334887906fbb534c1925ec81c9' + '655d603f31c3e4462b4c600861833cbdba9dd541f693e11a349e7a57b8c059d7' ) expect( digest(getOpenCodeFamilyPluginSource('/hook/mimo-code', { emitSessionStart: false })) - ).toBe('2267e2ab6e854e71c9bae12afed97e464f93b2b14f3e25dca133ca6666c98752') + ).toBe('d36d869823cc3dd506565e0512beb278fe4fd6533bd12be30bd98db140ca9f3e') }) it('filters child sessions via parentID lookup before forwarding events', () => { @@ -290,6 +274,51 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => { expect(pluginSource).toContain('messageID: part.messageID') }) + // Why: OpenCode 2 reloads a plugin whose file mtime changed, which restarted status mid-turn. + it('leaves a current installed plugin untouched and replaces a stale one', () => { + const service = new OpenCodeHookService() + service.buildPtyEnv(daemonSessionId) + const pluginPath = join(resolveOpenCodeConfigDirectory(), 'plugins', 'orca-opencode-status.js') + const past = new Date('2020-01-01T00:00:00Z') + utimesSync(pluginPath, past, past) + + service.buildPtyEnv(daemonSessionId) + expect(statSync(pluginPath).mtimeMs).toBe(past.getTime()) + + writeFileSync(pluginPath, 'stale plugin') + service.buildPtyEnv(daemonSessionId) + expect(readFileSync(pluginPath, 'utf8')).toBe(_internals.getOpenCodePluginSource()) + }) + + // Why: OpenCode 2 loads through a file-level symlink (dotfile managers) and stats its target. + it.skipIf(process.platform === 'win32')( + 'compares a symlinked plugin by its target and writes through only when stale', + () => { + const service = new OpenCodeHookService() + const pluginPath = join( + resolveOpenCodeConfigDirectory(), + 'plugins', + 'orca-opencode-status.js' + ) + const targetPath = join(userDataDir, 'dotfiles-orca-opencode-status.js') + writeFileSync(targetPath, _internals.getOpenCodePluginSource()) + rmSync(pluginPath, { force: true }) + symlinkSync(targetPath, pluginPath) + const past = new Date('2020-01-01T00:00:00Z') + utimesSync(targetPath, past, past) + + service.buildPtyEnv(daemonSessionId) + expect(statSync(targetPath).mtimeMs).toBe(past.getTime()) + + writeFileSync(targetPath, 'stale plugin') + service.buildPtyEnv(daemonSessionId) + expect(lstatSync(pluginPath).isSymbolicLink()).toBe(true) + expect(readFileSync(targetPath, 'utf8')).toBe(_internals.getOpenCodePluginSource()) + rmSync(pluginPath, { force: true }) + rmSync(targetPath, { force: true }) + } + ) + // Why: #22234 — OpenCode 2 installs under the plain `opencode` name, and its loader // rejects a default export that only has server(). Asserting the emitted *source* is // not enough; the installed file is what the v2 server validates, so load it. @@ -313,6 +342,93 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => { expect(module.default?.setup).toBeTypeOf('function') }) + // Why: pre-1.4.209 Orca wrote a server()-only plugin into /opencode-hooks/shared and + // stopped maintaining it; shells and OpenCode 2 background services still pointing there got + // "Plugin must export a default definition with an id and an effect or setup function." + it('refreshes a stale plugin left in the retired shared hooks dir', async () => { + const legacyPluginPath = join( + userDataDir, + 'opencode-hooks', + 'shared', + 'plugins', + 'orca-opencode-status.js' + ) + mkdirSync(join(legacyPluginPath, '..'), { recursive: true }) + writeFileSync( + legacyPluginPath, + 'export default { id: "orca-opencode-status", server: async () => ({}) };\n' + ) + + new OpenCodeHookService().buildPtyEnv(daemonSessionId) + + const modulePath = join(userDataDir, `legacy-opencode-plugin-${Date.now()}.mjs`) + writeFileSync(modulePath, readFileSync(legacyPluginPath, 'utf8')) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the assertions below validate the shape this names. + const module = (await import(pathToFileURL(modulePath).href)) as { + default?: { id?: unknown; server?: unknown; setup?: unknown } + } + expect(module.default?.id).toBe('orca-opencode-status') + expect(module.default?.server).toBeTypeOf('function') + expect(module.default?.setup).toBeTypeOf('function') + }) + + it('repairs late and overwritten legacy plugins atomically on the same service', () => { + const service = new OpenCodeHookService() + service.refreshLegacySharedPlugin() + const path = join(userDataDir, 'opencode-hooks', 'shared', 'plugins', 'orca-opencode-status.js') + mkdirSync(join(path, '..'), { recursive: true }) + for (const stale of ['// late old install', '// old process overwrote repair']) { + writeFileSync(path, stale) + const reader = openSync(path, 'r') + try { + service.refreshLegacySharedPlugin() + expect(readFileSync(path, 'utf8')).toBe(getOpenCodePluginSource()) + expect(readFileSync(reader, 'utf8')).toBe(stale) + expect(readdirSync(join(path, '..'))).toEqual(['orca-opencode-status.js']) + } finally { + closeSync(reader) + } + } + }) + + it('reports repair failures and retries after the obstruction is removed', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const path = join(userDataDir, 'opencode-hooks', 'shared', 'plugins', 'orca-opencode-status.js') + const service = new OpenCodeHookService() + try { + service.refreshLegacySharedPlugin() + expect(warn).not.toHaveBeenCalled() + mkdirSync(path, { recursive: true }) + service.refreshLegacySharedPlugin() + expect(warn).toHaveBeenCalledWith( + '[OpenCode] Failed to repair legacy status plugin:', + path, + expect.any(Error) + ) + rmSync(path, { recursive: true }) + writeFileSync(path, '// stale') + service.refreshLegacySharedPlugin() + expect(readFileSync(path, 'utf8')).toBe(getOpenCodePluginSource()) + } finally { + warn.mockRestore() + } + }) + + it('leaves an up-to-date legacy plugin untouched and never creates the retired dir', () => { + const legacyDir = join(userDataDir, 'opencode-hooks') + new OpenCodeHookService().buildPtyEnv(daemonSessionId) + expect(existsSync(legacyDir)).toBe(false) + + const legacyPluginPath = join(legacyDir, 'shared', 'plugins', 'orca-opencode-status.js') + mkdirSync(join(legacyPluginPath, '..'), { recursive: true }) + writeFileSync(legacyPluginPath, getOpenCodePluginSource()) + const past = new Date('2020-01-01T00:00:00Z') + utimesSync(legacyPluginPath, past, past) + new OpenCodeHookService().buildPtyEnv(daemonSessionId) + // Why: a running OpenCode 2 service re-runs its plugin load on every write to a watched plugin file. + expect(statSync(legacyPluginPath).mtimeMs).toBe(past.getTime()) + }) + // Why: #22506 — both variants install side by side in one global plugins dir, and // OpenCode 2 kills every plugin after the first that reuses an id ("Duplicate plugin // ID"). Discovery sorts by path, so orca-opencode-status.js always wins and the @@ -609,6 +725,19 @@ describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () = expectUserConfigIntact() }) + it('leaves a current overlay plugin file in place across spawns', () => { + const service = new OpenCodeHookService() + const overlayDir = service.buildPtyEnv(ptyId, userConfigDir).OPENCODE_CONFIG_DIR! + const pluginPath = join(overlayDir, 'plugins', 'orca-opencode-status.js') + const past = new Date('2020-01-01T00:00:00Z') + utimesSync(pluginPath, past, past) + + service.buildPtyEnv(ptyId, userConfigDir) + + expect(statSync(pluginPath).mtimeMs).toBe(past.getTime()) + expect(readFileSync(pluginPath, 'utf8')).toBe(_internals.getOpenCodePluginSource()) + }) + it('reconciles stale mirrored entries while preserving OpenCode runtime files', () => { const service = new OpenCodeHookService() const firstEnv = service.buildPtyEnv(ptyId, userConfigDir) diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index f46667ed1b8..74f24577c6e 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -1,3 +1,4 @@ +import { writeFileAtomically } from '../codex-accounts/fs-utils' import { getAppEnvironment } from '../../shared/app-environment' import { join } from 'node:path' import { @@ -22,11 +23,18 @@ import { getStatusPluginOwnershipSource } from './status-plugin-ownership-source import { getStatusPluginLifecycleSource } from './status-plugin-lifecycle-source' import { getStatusPluginFactorySource } from './status-plugin-factory-source' import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory' +import { + getOpenCodeLegacySharedConfigDir, + OPENCODE2_LEGACY_HOOKS_DIR, + OPENCODE_LEGACY_HOOKS_DIR +} from './legacy-shared-config-dir' +import { + isInstalledOpenCodePluginCurrent, + isOverlayOpenCodePluginCurrent +} from '../../shared/opencode-installed-plugin' const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' -const OPENCODE_LEGACY_HOOKS_DIR = 'opencode-hooks' const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays' -const OPENCODE_SHARED_CONFIG_DIR = 'shared' const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json' type OpenCodeOverlayManifest = { @@ -127,6 +135,7 @@ export class OpenCodeHookService { return existingConfigDir ? { OPENCODE_CONFIG_DIR: existingConfigDir } : {} } + this.refreshLegacySharedPlugin() const managedConfigDir = this.getSharedConfigDir() if (!existingConfigDir || existingConfigDir === managedConfigDir) { try { @@ -150,6 +159,23 @@ export class OpenCodeHookService { } } + // Why: pre-1.4.209 Orca left a server()-only plugin here that OpenCode 2 rejects. Only helps + // processes that load it later; a running OpenCode 2 service keeps its cached module until restarted. + refreshLegacySharedPlugin(): void { + const pluginPath = join(this.getSharedConfigDir(), 'plugins', this.pluginFileName) + try { + const source = this.pluginSource() + if (readFileSync(pluginPath, 'utf8') !== source) { + writeFileAtomically(pluginPath, source) + } + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return + } + console.warn('[OpenCode] Failed to repair legacy status plugin:', pluginPath, error) + } + } + private getOverlayRoot(): string { return join(getAppEnvironment().getPath('userData'), this.overlayDir) } @@ -159,10 +185,9 @@ export class OpenCodeHookService { } private getSharedConfigDir(): string { - return join( + return getOpenCodeLegacySharedConfigDir( getAppEnvironment().getPath('userData'), - this.legacyHooksDir, - OPENCODE_SHARED_CONFIG_DIR + this.legacyHooksDir ) } @@ -257,25 +282,32 @@ export class OpenCodeHookService { const pluginsDir = join(overlayDir, 'plugins') mkdirSync(pluginsDir, { recursive: true }) const pluginPath = join(pluginsDir, this.pluginFileName) - try { - unlinkSync(pluginPath) - } catch { - // File may not exist on a fresh overlay; a real failure surfaces on writeFileSync below. + const source = this.pluginSource() + if (!isOverlayOpenCodePluginCurrent(pluginPath, source)) { + try { + unlinkSync(pluginPath) + } catch { + // File may not exist on a fresh overlay; a real failure surfaces on writeFileSync below. + } + writeFileSync(pluginPath, source) } - writeFileSync(pluginPath, this.pluginSource()) } private writePluginToConfigDir(configDir: string): void { const pluginsDir = join(configDir, 'plugins') mkdirSync(pluginsDir, { recursive: true }) - writeFileSync(join(pluginsDir, this.pluginFileName), this.pluginSource()) + const pluginPath = join(pluginsDir, this.pluginFileName) + const source = this.pluginSource() + if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) { + writeFileSync(pluginPath, source) + } } } export const openCodeHookService = new OpenCodeHookService() export const openCode2HookService = new OpenCodeHookService({ pluginFileName: 'orca-opencode2-status.js', - legacyHooksDir: 'opencode2-hooks', + legacyHooksDir: OPENCODE2_LEGACY_HOOKS_DIR, overlayDir: 'opencode2-config-overlays', pluginSource: getOpenCode2PluginSource }) diff --git a/src/main/opencode/legacy-shared-config-dir.test.ts b/src/main/opencode/legacy-shared-config-dir.test.ts new file mode 100644 index 00000000000..f4a20713e8b --- /dev/null +++ b/src/main/opencode/legacy-shared-config-dir.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { join } from 'node:path' +import { isOpenCodeLegacySharedConfigDir } from './legacy-shared-config-dir' + +describe('retired config directory recognition', () => { + const root = join(process.cwd(), 'user-data') + it.each(['opencode-hooks', 'opencode2-hooks'])('normalizes %s paths', (hooks) => { + const path = join(root, hooks, 'shared') + expect(isOpenCodeLegacySharedConfigDir(`${path}/`, root)).toBe(true) + expect(isOpenCodeLegacySharedConfigDir(`${path}/../shared`, root)).toBe(true) + expect(isOpenCodeLegacySharedConfigDir(`${path}/../mine`, root)).toBe(false) + expect(isOpenCodeLegacySharedConfigDir(`${path}-custom`, root)).toBe(false) + expect(isOpenCodeLegacySharedConfigDir(undefined, root)).toBe(false) + }) + it.skipIf(process.platform !== 'win32')('handles Windows casing and separators', () => { + expect(isOpenCodeLegacySharedConfigDir('C:/ORCA/opencode-hooks/shared/', 'c:\\orca')).toBe(true) + }) +}) + +describe('isOpenCodeLegacySharedConfigDir', () => { + const userData = join('fixture', 'user-data') + + it('matches only the retired shared dirs of both OpenCode variants', () => { + expect( + isOpenCodeLegacySharedConfigDir(join(userData, 'opencode-hooks', 'shared'), userData) + ).toBe(true) + expect( + isOpenCodeLegacySharedConfigDir(join(userData, 'opencode2-hooks', 'shared'), userData) + ).toBe(true) + expect( + isOpenCodeLegacySharedConfigDir(join(userData, 'opencode-hooks', 'mine'), userData) + ).toBe(false) + expect( + isOpenCodeLegacySharedConfigDir(join('other', 'opencode-hooks', 'shared'), userData) + ).toBe(false) + expect(isOpenCodeLegacySharedConfigDir(undefined, userData)).toBe(false) + }) +}) diff --git a/src/main/opencode/legacy-shared-config-dir.ts b/src/main/opencode/legacy-shared-config-dir.ts new file mode 100644 index 00000000000..8b0c2b842e4 --- /dev/null +++ b/src/main/opencode/legacy-shared-config-dir.ts @@ -0,0 +1,48 @@ +import { join, resolve } from 'node:path' + +export const OPENCODE_LEGACY_HOOKS_DIR = 'opencode-hooks' +export const OPENCODE2_LEGACY_HOOKS_DIR = 'opencode2-hooks' + +// Why: before 1.4.209 Orca pointed OPENCODE_CONFIG_DIR at this dir; shells and OpenCode 2 background services from then can still load it. +export function getOpenCodeLegacySharedConfigDir( + userDataPath: string, + legacyHooksDir: string +): string { + return join(userDataPath, legacyHooksDir, 'shared') +} + +export function isOpenCodeLegacySharedConfigDir( + configDir: string | undefined, + userDataPath: string +): boolean { + return ( + configDir !== undefined && + [OPENCODE_LEGACY_HOOKS_DIR, OPENCODE2_LEGACY_HOOKS_DIR].some( + (hooksDir) => + normalizeConfigPath(configDir) === + normalizeConfigPath(getOpenCodeLegacySharedConfigDir(userDataPath, hooksDir)) + ) + ) +} + +function normalizeConfigPath(path: string): string { + const resolved = resolve(path) + return process.platform === 'win32' ? resolved.toLowerCase() : resolved +} + +export const OPENCODE_CONFIG_DIR_ENV_KEYS = [ + 'OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_SOURCE_CONFIG_DIR' +] as const + +/** Carries retired inherited paths through providers that merge their environment later. */ +export function getLegacyOpenCodeEnvKeysToDelete( + env: Record | undefined, + userDataPath: string, + inherited: Record = process.env +): string[] { + return OPENCODE_CONFIG_DIR_ENV_KEYS.filter((key) => + isOpenCodeLegacySharedConfigDir(env?.[key] ?? inherited[key], userDataPath) + ) +} diff --git a/src/main/opencode/status-plugin-factory-source.ts b/src/main/opencode/status-plugin-factory-source.ts index 8348cc968eb..218d15c3e41 100644 --- a/src/main/opencode/status-plugin-factory-source.ts +++ b/src/main/opencode/status-plugin-factory-source.ts @@ -23,6 +23,7 @@ export function getStatusPluginFactorySource(options: { 'export const OrcaOpenCodeStatusPlugin = async (_ctx) => {', ` if (process.env.ORCA_OPENCODE_AGENT && process.env.ORCA_OPENCODE_AGENT !== '${expectedAgent}') return {};`, ' const client = _ctx?.client;', + ' const sessionsOutliveDispose = _ctx?.sessionsOutliveDispose === true;', ' const factoryID = ++nextFactoryID;', ' activeFactoryIDs.add(factoryID);', ' let disposed = false;', @@ -242,7 +243,14 @@ export function getStatusPluginFactorySource(options: { ' pendingAssistantPart = null;', ' }', ' const ownsDeliveredMessagePart = deliveredMessagePartFactoryID === factoryID;', - ' if (desiredFactoryID === factoryID || ownsDeliveredMessagePart) {', + ' // Why: OpenCode 1 disposes only on instance teardown, which cancels every run, so a final', + ' // Idle is true. OpenCode 2 also disposes on a hot reload mid-turn, so it publishes nothing.', + ' if (sessionsOutliveDispose) {', + ' if (desiredFactoryID === factoryID) {', + ' clearStatusRetry();', + ' statusRevision += 1;', + ' }', + ' } else if (desiredFactoryID === factoryID || ownsDeliveredMessagePart) {', ' clearStatusRetry();', ' statusRevision += 1;', ' // A MessagePart may have changed the listener to Working after the', @@ -250,17 +258,13 @@ export function getStatusPluginFactorySource(options: { ' statusDeliveryDirty = ownsDeliveredMessagePart;', ' busyRecoveryUsed = false;', ' busyRecoveryEndpointKey = "";', - ' const fallbackFactoryID = Array.from(activeFactoryIDs).find(', - ' (id) => id !== factoryID', - ' );', + ' const fallbackFactoryID = Array.from(activeFactoryIDs).find((id) => id !== factoryID);', ' if (fallbackFactoryID !== undefined) {', ' await publishAggregateStatus(', ' fallbackFactoryID,', ' desiredStatusProperties?.sessionID', ' );', ' } else {', - ' // Why: Instance disposal can happen while the PTY stays alive;', - ' // publish a final idle so Orca does not retain a dead owner.', ' if (!deliveredStatusKey.startsWith("idle:") || ownsDeliveredMessagePart) {', ' await setStatus(', ' "idle",', diff --git a/src/main/opencode2/status-plugin-setup-lifetime.test.ts b/src/main/opencode2/status-plugin-setup-lifetime.test.ts new file mode 100644 index 00000000000..5a2c4280ced --- /dev/null +++ b/src/main/opencode2/status-plugin-setup-lifetime.test.ts @@ -0,0 +1,204 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getOpenCode2SetupSource } from './status-plugin-setup-source' + +type Hooks = { event: (event: unknown) => Promise; dispose?: () => Promise } + +function createSetup(factory: () => Promise) { + const setup: unknown = new Function( + 'OrcaOpenCodeStatusPlugin', + 'AbortController', + 'console', + `${getOpenCode2SetupSource().join('\n')}\nreturn setupOpenCode2Status;` + )(factory, AbortController, { warn: vi.fn() }) + if (typeof setup !== 'function') { + throw new Error('Missing generated setup') + } + return async (context: unknown) => { + const cleanup: unknown = await setup(context) + if (typeof cleanup !== 'function') { + throw new Error('Missing generated cleanup') + } + return cleanup + } +} + +function deferred() { + let finish = () => {} + const promise = new Promise((resolve) => { + finish = resolve + }) + return { promise, finish } +} + +beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('{}', { status: 200 })) + ) +}) +afterEach(() => { + expect(fetch).not.toHaveBeenCalled() + vi.unstubAllGlobals() +}) + +describe('generated OpenCode 2 setup ownership', () => { + it.each(['throw', 'reject'] as const)( + 'disposes the factory after prompt registration %s', + async (failure) => { + const hooks = { event: vi.fn(async () => {}), dispose: vi.fn(async () => {}) } + const subscribe = vi.fn(async function* () {}) + const hook = () => { + if (failure === 'throw') { + throw new Error('Registration failed') + } + return Promise.reject(new Error('Registration failed')) + } + const cleanup = await createSetup(async () => hooks)({ + session: { hook }, + event: { subscribe } + }) + expect(hooks.dispose).toHaveBeenCalledOnce() + expect(subscribe).not.toHaveBeenCalled() + await cleanup() + expect(hooks.dispose).toHaveBeenCalledOnce() + } + ) + + it.each(['throw', 'reject'] as const)( + 'disposes the factory after prompt cleanup %s', + async (failure) => { + const hooks = { event: vi.fn(async () => {}), dispose: vi.fn(async () => {}) } + const promptDispose = vi.fn(() => { + if (failure === 'throw') { + throw new Error('Prompt cleanup failed') + } + return Promise.reject(new Error('Prompt cleanup failed')) + }) + let signal: AbortSignal | undefined + const cleanup = await createSetup(async () => hooks)({ + session: { hook: async () => ({ dispose: promptDispose }) }, + event: { + subscribe: (options: { signal: AbortSignal }) => { + signal = options.signal + return [] + } + } + }) + await cleanup() + expect(signal?.aborted).toBe(true) + expect(promptDispose).toHaveBeenCalledOnce() + expect(hooks.dispose).toHaveBeenCalledOnce() + } + ) + + it('waits for in-flight delivery before factory cleanup after a prompt cleanup failure', async () => { + const delivery = deferred() + const started = deferred() + const order: string[] = [] + const hooks = { + event: vi.fn(async () => { + started.finish() + await delivery.promise + order.push('event') + }), + dispose: vi.fn(async () => { + order.push('factory') + }) + } + const cleanup = await createSetup(async () => hooks)({ + session: { + hook: async () => ({ + dispose: async () => { + order.push('prompt') + throw new Error('Failed') + } + }) + }, + event: { + subscribe: async function* ({ signal }: { signal: AbortSignal }) { + try { + yield { type: 'session.execution.started', data: { sessionID: 'synthetic' } } + if (!signal.aborted) { + yield { type: 'session.execution.succeeded', data: { sessionID: 'synthetic' } } + } + } finally { + order.push('subscription') + } + } + } + }) + await started.promise + const disposing = cleanup() + await Promise.resolve() + expect(hooks.dispose).not.toHaveBeenCalled() + delivery.finish() + await disposing + expect(order).toEqual(['prompt', 'event', 'subscription', 'factory']) + expect(hooks.event).toHaveBeenCalledOnce() + expect(hooks.dispose).toHaveBeenCalledOnce() + }) + + it.each(['setup', 'unload'] as const)( + 'keeps %s fail-open when factory cleanup also rejects', + async (stage) => { + const hooks = { + event: vi.fn(async () => {}), + dispose: vi.fn(async () => { + throw new Error('Factory cleanup failed') + }) + } + const cleanup = await createSetup(async () => hooks)({ + session: { + hook: async () => { + if (stage === 'setup') { + throw new Error('Registration failed') + } + return { + dispose: async () => { + throw new Error('Prompt cleanup failed') + } + } + } + }, + event: { subscribe: async function* () {} } + }) + await cleanup() + expect(hooks.dispose).toHaveBeenCalledOnce() + } + ) + + it('disposes both owners once on ordinary unload', async () => { + const hooks = { event: vi.fn(async () => {}), dispose: vi.fn(async () => {}) } + const promptDispose = vi.fn(async () => {}) + const cleanup = await createSetup(async () => hooks)({ + session: { hook: async () => ({ dispose: promptDispose }) }, + event: { subscribe: async function* () {} } + }) + expect(hooks.dispose).not.toHaveBeenCalled() + await cleanup() + expect(promptDispose).toHaveBeenCalledOnce() + expect(hooks.dispose).toHaveBeenCalledOnce() + }) + + it('accepts absent optional disposers', async () => { + const cleanup = await createSetup(async () => ({ event: async () => {} }))({ + session: { hook: async () => undefined }, + event: { subscribe: async function* () {} } + }) + await cleanup() + }) + + it('skips allocation for an unusable context and tolerates factory rejection', async () => { + const factory = vi.fn(async (): Promise => { + throw new Error('Factory failed') + }) + await ( + await createSetup(factory)(undefined) + )() + expect(factory).not.toHaveBeenCalled() + await ( + await createSetup(factory)({ session: { hook: vi.fn() }, event: { subscribe: vi.fn() } }) + )() + expect(factory).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/opencode2/status-plugin-setup-source.ts b/src/main/opencode2/status-plugin-setup-source.ts index c4bf8d0ea72..7b825056ecd 100644 --- a/src/main/opencode2/status-plugin-setup-source.ts +++ b/src/main/opencode2/status-plugin-setup-source.ts @@ -10,6 +10,7 @@ const NON_SESSION_FORM_OWNERS = new Set(["global"]); async function setupOpenCode2Status(ctx) { const noop = async () => {}; + let hooks; // Why: OpenCode may probe setup() with no context during startup, and the setup // API shape can drift between releases. Never throw from setup — a throw surfaces // as an 'orca-opencode-status' plugin failed error in the TUI, which is worse @@ -23,7 +24,8 @@ async function setupOpenCode2Status(ctx) { // Without it, resolveRootSessionID returns null for every session and a // subagent's work publishes as if it were the root's. const client = { session: { get: async (input, options) => { const result = await ctx.session.get(input, options); return result && typeof result.id === "string" ? { data: result } : result; } } }; - const hooks = await OrcaOpenCodeStatusPlugin({ client }); + // Why: this host disposes plugins on a hot reload while turns keep running. + hooks = await OrcaOpenCodeStatusPlugin({ client, sessionsOutliveDispose: true }); if (!hooks || typeof hooks.event !== "function") return noop; const promptRegistration = await ctx.session.hook("prompt", async (properties) => { await hooks.event({ event: { type: "session.next.prompt.admitted", properties } }); @@ -79,14 +81,22 @@ async function setupOpenCode2Status(ctx) { return async () => { try { controller.abort(); - await promptRegistration?.dispose?.(); - await consuming; - await hooks.dispose?.(); + // Each owner must finish cleanup even when an earlier disposer rejects. + try { + await promptRegistration?.dispose?.(); + } finally { + try { + await consuming; + } finally { + await hooks.dispose?.(); + } + } } catch { // Why: cleanup runs during plugin unload; a throw here also fails the plugin. } }; } catch { + try { await hooks?.dispose?.(); } catch {} return noop; } } diff --git a/src/main/orca-profiles/profile-active-transfer-worker.test.ts b/src/main/orca-profiles/profile-active-transfer-worker.test.ts new file mode 100644 index 00000000000..2e19eaf73f0 --- /dev/null +++ b/src/main/orca-profiles/profile-active-transfer-worker.test.ts @@ -0,0 +1,127 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { getDefaultPersistedState } from '../../shared/constants' +import { ORCA_PROFILE_INDEX_SCHEMA_VERSION } from '../../shared/orca-profiles' +import { createWorkerMaintenanceFixture } from '../persistence/loading-store/profile-state-maintenance-fixture' +import { ProfileStateSqliteAuthority } from '../persistence/profile-state/profile-state-sqlite-authority' +import { transferActiveProfileProject } from './profile-active-transfer' +import * as domainState from './profile-project-domain-state' +import { + profileHasPendingProjectMove, + recoverPendingProfileProjectMoves +} from './profile-project-move-intent' +import { readProfileStateWithRevision } from './profile-project-state-file' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +async function fixture() { + const root = mkdtempSync(join(tmpdir(), 'orca-worker-profile-transfer-')) + writeFileSync( + join(root, 'orca-profile-index.json'), + JSON.stringify({ + schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION, + activeProfileId: 'source', + profiles: ['source', 'target'].map((id) => ({ + id, + name: id, + avatar: { kind: 'initials', initials: id[0], color: 'neutral' }, + kind: 'local', + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1 + })) + }) + ) + const current = await createWorkerMaintenanceFixture({ + directory: join(root, 'profiles', 'source'), + profileId: 'source', + cleanupRoot: root + }) + const target = new ProfileStateSqliteAuthority( + join(root, 'profiles', 'target', 'profile-state.db'), + 'target' + ) + try { + target.writeSerializedState(Buffer.from(JSON.stringify(getDefaultPersistedState(root)))) + } finally { + target.close() + } + const args = { + sourceProfileId: 'source', + targetProfileId: 'target', + repoId: 'repo-remote', + mode: 'move' + } as const + const read = (id: string) => readProfileStateWithRevision(id, root) + return { ...current, root, args, read } +} + +describe('active profile transfers with the live writer', () => { + it('resumes the exact source revision after a validation failure', async () => { + const { store, root, args, read } = await fixture() + const reopen = vi.fn(async () => {}) + await expect( + transferActiveProfileProject({ ...args, repoId: 'missing' }, root, store, reopen) + ).rejects.toThrow('unknown_source_repo') + expect(reopen).not.toHaveBeenCalled() + store.updateSettings({ theme: 'dark' }) + await store.flushPendingOrThrowAsync() + expect(read('source').state.settings.theme).toBe('dark') + }) + + it('keeps the source frozen after moving a remote project and its persisted state', async () => { + const { store, root, args, read } = await fixture() + const result = await transferActiveProfileProject(args, root, store, async () => {}) + expect(result.status).toBe('transferred') + expect(read('source').state.repos.some((repo) => repo.id === args.repoId)).toBe(false) + expect(read('target').state.repos.some((repo) => repo.id === args.repoId)).toBe(true) + const source = read('source') + store.updateSettings({ theme: 'dark' }) + await expect(store.flushPendingOrThrowAsync()).rejects.toThrow('finalized') + expect(read('source')).toEqual(source) + }) + + it('resumes when a copy makes a later move a duplicate', async () => { + const { store, root, args, read } = await fixture() + await transferActiveProfileProject({ ...args, mode: 'copy' }, root, store, async () => {}) + const result = await transferActiveProfileProject(args, root, store, async () => {}) + expect(result.status).toBe('duplicate-target') + store.updateSettings({ theme: 'dark' }) + await store.flushPendingOrThrowAsync() + expect(read('source').state.settings.theme).toBe('dark') + expect(read('source').state.repos.some((repo) => repo.id === args.repoId)).toBe(true) + }) + + it('leaves an interrupted move frozen until journal recovery runs with the writer closed', async () => { + const { store, root, args, read } = await fixture() + const original = domainState.writeProfileProjectDomainChanges + const fault = vi + .spyOn(domainState, 'writeProfileProjectDomainChanges') + .mockImplementation((id, ...rest) => { + if (id === args.sourceProfileId) { + throw new Error('source commit interrupted') + } + return original(id, ...rest) + }) + const reopen = vi.fn(async () => {}) + await expect(transferActiveProfileProject(args, root, store, reopen)).rejects.toThrow( + 'source commit interrupted' + ) + expect(reopen).toHaveBeenCalledOnce() + expect(profileHasPendingProjectMove('source', root)).toBe(true) + await expect(store.flushPendingOrThrowAsync()).rejects.toThrow('finalized') + fault.mockRestore() + expect(recoverPendingProfileProjectMoves(root)).toBe(1) + expect(read('source').state.repos.some((repo) => repo.id === args.repoId)).toBe(false) + expect(read('target').state.repos.some((repo) => repo.id === args.repoId)).toBe(true) + }) +}) diff --git a/src/main/orca-profiles/profile-active-transfer.test.ts b/src/main/orca-profiles/profile-active-transfer.test.ts new file mode 100644 index 00000000000..0cfd80d7eef --- /dev/null +++ b/src/main/orca-profiles/profile-active-transfer.test.ts @@ -0,0 +1,232 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultPersistedState } from '../../shared/constants' +import { ORCA_PROFILE_INDEX_SCHEMA_VERSION } from '../../shared/orca-profiles' +import { openProfileStateDatabase } from '../persistence/profile-state/profile-state-database' +import { importProfileStateJson } from '../persistence/profile-state/profile-state-documents' +import { ProfileStateSqliteAuthority } from '../persistence/profile-state/profile-state-sqlite-authority' +import * as stateFiles from './profile-project-state-file' +import * as domainState from './profile-project-domain-state' +import * as moveIntents from './profile-project-move-intent' +import { transferActiveProfileProject } from './profile-active-transfer' +import { transferOrcaProfileProject } from './profile-project-transfer' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { isEncryptionAvailable: () => false }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) +})) +vi.mock('../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(() => ({ hosts: [] })), + sshConfigHostsToTargets: vi.fn(() => []) +})) + +const { Store } = await import('../persistence/loading-store/store') +const stores: InstanceType[] = [] +let directory: string +const args = { + sourceProfileId: 'source', + targetProfileId: 'target', + repoId: 'repo-1', + mode: 'move' +} as const + +function snapshot(profileId: string) { + return stateFiles.readProfileStateWithRevision(profileId, directory) +} + +function openStore() { + const profileDirectory = join(directory, 'profiles', 'source') + const store = new Store({ + dataFile: join(profileDirectory, 'orca-data.json'), + profileStateAuthority: new ProfileStateSqliteAuthority( + join(profileDirectory, 'profile-state.db'), + 'source' + ) + }) + stores.push(store) + store.flushOrThrow() + return store +} + +function interruptSourceCommit() { + const originalWrite = domainState.writeProfileProjectDomainChanges + return vi + .spyOn(domainState, 'writeProfileProjectDomainChanges') + .mockImplementation((profileId, ...rest) => { + if (profileId === 'source') { + throw new Error('source commit interrupted') + } + return originalWrite(profileId, ...rest) + }) +} + +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'orca-active-profile-transfer-')) + vi.spyOn(ProfileStateSqliteAuthority.prototype, 'scheduleBackup').mockImplementation(() => {}) + writeFileSync( + join(directory, 'orca-profile-index.json'), + JSON.stringify({ + schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION, + activeProfileId: 'source', + profiles: ['source', 'target'].map((id) => ({ + id, + name: id, + avatar: { kind: 'initials', initials: id[0], color: 'neutral' }, + kind: 'local', + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1 + })) + }) + ) + for (const profileId of ['source', 'target']) { + const profileDirectory = join(directory, 'profiles', profileId) + mkdirSync(profileDirectory, { recursive: true }) + const db = openProfileStateDatabase(join(profileDirectory, 'profile-state.db'), profileId).db + try { + importProfileStateJson( + db, + JSON.stringify({ + ...getDefaultPersistedState('/home/test'), + repos: + profileId === 'source' + ? [{ id: 'repo-1', path: '/projects/folder', kind: 'folder', addedAt: 1 }] + : [] + }) + ) + } finally { + db.close() + } + } +}) + +afterEach(() => { + for (const store of stores.splice(0)) { + store.freezeWrites() + } + vi.restoreAllMocks() + rmSync(directory, { recursive: true, force: true }) +}) + +describe('active profile transfer recovery', () => { + it.each(['source commit', 'intent cleanup'] as const)( + 'fences an existing SQLite Store after interrupted %s until recovery and reopen', + async (failure) => { + const store = openStore() + const before = snapshot('source') + const interrupted = + failure === 'source commit' + ? interruptSourceCommit() + : vi.spyOn(moveIntents, 'removeProfileProjectMoveIntent').mockImplementation(() => { + throw new Error('intent cleanup interrupted') + }) + const reopen = vi.fn(async () => { + const retained = snapshot('source') + store.updateSettings({ theme: 'light' }) + expect(() => store.flushOrThrow()).toThrow('final persistence') + expect(snapshot('source')).toEqual(retained) + }) + + await expect(transferActiveProfileProject(args, directory, store, reopen)).rejects.toThrow( + `${failure} interrupted` + ) + expect(reopen).toHaveBeenCalledOnce() + expect(snapshot('source').revision).toBe( + (before.revision ?? 0) + (failure === 'source commit' ? 0 : 1) + ) + expect(snapshot('target').state.repos).toHaveLength(1) + interrupted.mockRestore() + + expect(moveIntents.recoverPendingProfileProjectMoves(directory)).toBe(1) + expect(moveIntents.recoverPendingProfileProjectMoves(directory)).toBe(0) + expect(snapshot('source').state.repos).toHaveLength(0) + expect(snapshot('target').state.repos).toHaveLength(1) + const reloaded = openStore() + reloaded.updateSettings({ theme: 'light' }) + reloaded.flushOrThrow() + expect(snapshot('source').state.settings.theme).toBe('light') + expect(moveIntents.recoverPendingProfileProjectMoves(directory)).toBe(0) + } + ) + + it('leaves an unchanged SQLite Store writable after validation fails', async () => { + const store = openStore() + const before = snapshot('source') + const reopen = vi.fn(async () => {}) + await expect( + transferActiveProfileProject({ ...args, repoId: 'missing' }, directory, store, reopen) + ).rejects.toThrow('unknown_source_repo') + expect(reopen).not.toHaveBeenCalled() + store.updateSettings({ theme: 'light' }) + store.flushOrThrow() + expect(snapshot('source').revision).toBe((before.revision ?? 0) + 1) + expect(snapshot('source').state.settings.theme).toBe('light') + }) + + it('keeps writes fenced when reopening after a partial transfer fails', async () => { + const store = openStore() + const before = snapshot('source') + const interrupted = interruptSourceCommit() + await expect( + transferActiveProfileProject(args, directory, store, async () => { + throw new Error('reopen failed') + }) + ).rejects.toThrow('reopen failed') + store.updateSettings({ theme: 'light' }) + expect(() => store.flushOrThrow()).toThrow('final persistence') + expect(snapshot('source')).toEqual(before) + interrupted.mockRestore() + expect(moveIntents.recoverPendingProfileProjectMoves(directory)).toBe(1) + }) + + it('reopens before an outstanding move can change the active Store behind its revision', async () => { + const store = openStore() + const interrupted = interruptSourceCommit() + expect(() => transferOrcaProfileProject(args, directory)).toThrow('source commit interrupted') + interrupted.mockRestore() + const reopen = vi.fn(async () => { + expect(moveIntents.recoverPendingProfileProjectMoves(directory)).toBe(1) + }) + await expect(transferActiveProfileProject(args, directory, store, reopen)).rejects.toThrow( + 'active_source_orca_profile_move_requires_recovery' + ) + const recovered = snapshot('source') + store.updateSettings({ theme: 'light' }) + expect(() => store.flushOrThrow()).toThrow('final persistence') + expect(snapshot('source')).toEqual(recovered) + expect(reopen).toHaveBeenCalledOnce() + }) + + it('keeps the Store frozen when an unreadable intent cannot identify its participants', async () => { + const store = openStore() + const before = snapshot('source') + const intentDirectory = join(directory, 'profile-move-intents') + mkdirSync(intentDirectory) + writeFileSync(join(intentDirectory, '11111111-1111-4111-8111-111111111111.json'), '{') + const reopen = vi.fn(async () => { + moveIntents.recoverPendingProfileProjectMoves(directory) + }) + await expect(transferActiveProfileProject(args, directory, store, reopen)).rejects.toThrow( + 'Profile move intent is unreadable' + ) + store.updateSettings({ theme: 'light' }) + expect(() => store.flushOrThrow()).toThrow('final persistence') + expect(snapshot('source')).toEqual(before) + expect(reopen).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/orca-profiles/profile-active-transfer.ts b/src/main/orca-profiles/profile-active-transfer.ts new file mode 100644 index 00000000000..53ba8481da5 --- /dev/null +++ b/src/main/orca-profiles/profile-active-transfer.ts @@ -0,0 +1,39 @@ +import type { + TransferOrcaProfileProjectArgs, + TransferOrcaProfileProjectResult +} from '../../shared/orca-profiles' +import type { Store } from '../persistence/loading-store/store' +import { transferOrcaProfileProject } from './profile-project-transfer' +import { hasOrcaProfileStateDatabase } from './profile-storage-paths' +import { profileHasPendingProjectMove } from './profile-project-move-intent' +import { flushActiveProfileBeforeFileMutation } from './profile-persistence-deadline' + +/** Keep the active Store stopped until file mutation either succeeds or proves unchanged. */ +export async function transferActiveProfileProject( + args: TransferOrcaProfileProjectArgs, + userDataPath: string, + store: Pick, + reopenSource: () => Promise +): Promise { + const hadDatabase = hasOrcaProfileStateDatabase(args.sourceProfileId, userDataPath) + const pendingMove = profileHasPendingProjectMove(args.sourceProfileId, userDataPath) + const maintenance = await flushActiveProfileBeforeFileMutation(store, { flush: !pendingMove }) + let result: TransferOrcaProfileProjectResult + try { + if (profileHasPendingProjectMove(args.sourceProfileId, userDataPath)) { + throw new Error('active_source_orca_profile_move_requires_recovery') + } + result = transferOrcaProfileProject(args, userDataPath) + } catch (error) { + const needsRecovery = + (!hadDatabase && hasOrcaProfileStateDatabase(args.sourceProfileId, userDataPath)) || + profileHasPendingProjectMove(args.sourceProfileId, userDataPath) + // Further writes would invalidate a retained move's recovery revision. + await (needsRecovery ? reopenSource() : maintenance.resume()) + throw error + } + if (result.status !== 'transferred' || args.mode !== 'move') { + await maintenance.resume() + } + return result +} diff --git a/src/main/orca-profiles/profile-cloud-auth-status.test.ts b/src/main/orca-profiles/profile-cloud-auth-status.test.ts index 7a28783e9a9..3db3ccb92e9 100644 --- a/src/main/orca-profiles/profile-cloud-auth-status.test.ts +++ b/src/main/orca-profiles/profile-cloud-auth-status.test.ts @@ -38,6 +38,7 @@ function activeProfile(linked: boolean): ActiveOrcaProfileState { profile, index: { schemaVersion: 1, activeProfileId: profile.id, profiles: [profile] }, dataFile: '', + stateDatabaseFile: '', profileDirectory: '' } } diff --git a/src/main/orca-profiles/profile-index-store.test.ts b/src/main/orca-profiles/profile-index-store.test.ts index 1d02c4d7dcc..84a6927a8a7 100644 --- a/src/main/orca-profiles/profile-index-store.test.ts +++ b/src/main/orca-profiles/profile-index-store.test.ts @@ -1,9 +1,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' -import { existsSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs' +import { + existsSync, + mkdtempSync, + readFileSync, + writeFileSync, + mkdirSync, + renameSync +} from 'node:fs' import { removeTreeSync } from '../../shared/windows-transient-lock-removal' import { join } from 'node:path' import { tmpdir } from 'node:os' +import { openProfileStateDatabase } from '../persistence/profile-state/profile-state-database' import { createDefaultLocalOrcaProfile, DEFAULT_LOCAL_ORCA_PROFILE_ID, @@ -66,6 +74,9 @@ describe('profile index store', () => { expect(activeProfile.dataFile).toBe( join(testState.dir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json') ) + expect(activeProfile.stateDatabaseFile).toBe( + join(testState.dir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'profile-state.db') + ) expect(readJson(activeProfile.dataFile)).toEqual(legacyState) expect(readJson(`${activeProfile.dataFile}.bak.0`)).toEqual(legacyBackup) expect( @@ -115,9 +126,59 @@ describe('profile index store', () => { expect(activeProfile.profile.id).toBe(profileId) expect(activeProfile.dataFile).toBe(join(profileDirectory, 'orca-data.json')) + expect(activeProfile.stateDatabaseFile).toBe(join(profileDirectory, 'profile-state.db')) expect(readJson(activeProfile.dataFile)).toEqual(profileData) }) + it('does not copy legacy JSON into a database-only default profile', async () => { + writeFileSync( + join(testState.dir, 'orca-data.json'), + JSON.stringify({ settings: { theme: 'legacy' } }), + 'utf-8' + ) + const profileDirectory = join(testState.dir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID) + mkdirSync(profileDirectory, { recursive: true }) + const database = openProfileStateDatabase( + join(profileDirectory, 'profile-state.db'), + DEFAULT_LOCAL_ORCA_PROFILE_ID + ) + database.db.close() + + const { ensureActiveOrcaProfile } = await loadProfileIndexStore() + const activeProfile = ensureActiveOrcaProfile() + + expect(activeProfile.stateDatabaseFile).toBe(join(profileDirectory, 'profile-state.db')) + expect(existsSync(activeProfile.dataFile)).toBe(false) + expect(readFileSync(join(testState.dir, 'orca-data.json'), 'utf-8')).toContain('legacy') + }) + + it.each([ + 'orca-data.json.sqlite-export.1.json', + 'profile-state.db.backup.1789999999999-00000000-0000-4000-8000-000000000000.db', + 'profile-state.db-wal', + 'profile-state.db-shm', + 'profile-state.db-journal' + ])('does not seed a stale mirror when %s exists without the database', async (artifact) => { + writeFileSync( + join(testState.dir, 'orca-data.json'), + JSON.stringify({ settings: { theme: 'legacy' } }), + 'utf-8' + ) + const profileDirectory = join(testState.dir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID) + mkdirSync(profileDirectory, { recursive: true }) + writeFileSync( + join(profileDirectory, artifact), + JSON.stringify({ settings: { theme: 'migrated' } }), + 'utf-8' + ) + + const { ensureActiveOrcaProfile } = await loadProfileIndexStore() + const activeProfile = ensureActiveOrcaProfile() + + expect(existsSync(activeProfile.dataFile)).toBe(false) + expect(readFileSync(join(testState.dir, 'orca-data.json'), 'utf-8')).toContain('legacy') + }) + it('creates an empty local profile without copying legacy state into it', async () => { writeFileSync( join(testState.dir, 'orca-data.json'), @@ -195,6 +256,45 @@ describe('profile index store', () => { expect(recovered.profiles.length).toBeGreaterThanOrEqual(2) }) + it('retains the selected profile when only its backup index remains', async () => { + const store = await loadProfileIndexStore() + store.ensureActiveOrcaProfile() + const created = store.createLocalOrcaProfile({ name: 'Work' }) + store.setActiveOrcaProfile(created.profile.id) + const indexPath = store.getOrcaProfileIndexPath() + renameSync(indexPath, `${indexPath}.bak`) + + expect(store.loadOrCreateProfileIndex(testState.dir).activeProfileId).toBe(created.profile.id) + expect(store.ensureActiveOrcaProfile().profile.id).toBe(created.profile.id) + expect(readJson(indexPath)).toMatchObject({ activeProfileId: created.profile.id }) + }) + + it.each(['primary', 'backup', 'both'] as const)( + 'refuses an unreadable %s index without replacing it', + async (source) => { + const store = await loadProfileIndexStore() + const indexPath = store.getOrcaProfileIndexPath() + if (source !== 'backup') { + writeFileSync(indexPath, '{broken-primary') + } + if (source !== 'primary') { + writeFileSync(`${indexPath}.bak`, '{broken-backup') + } + + expect(() => store.loadOrCreateProfileIndex(testState.dir)).toThrow( + 'Could not read active profile index' + ) + expect(() => store.ensureActiveOrcaProfile()).toThrow('Could not read active profile index') + if (source !== 'backup') { + expect(readFileSync(indexPath, 'utf8')).toBe('{broken-primary') + } + if (source !== 'primary') { + expect(readFileSync(`${indexPath}.bak`, 'utf8')).toBe('{broken-backup') + } + expect(existsSync(join(testState.dir, 'profiles'))).toBe(false) + } + ) + it('rejects profile ids that are not safe path segments', async () => { const store = await loadProfileIndexStore() const indexPath = store.getOrcaProfileIndexPath() @@ -216,8 +316,7 @@ describe('profile index store', () => { mkdirSync(testState.dir, { recursive: true }) writeFileSync(indexPath, JSON.stringify(index), 'utf-8') - // The tampered entry is filtered; startup falls back to a fresh default. - const state = store.ensureActiveOrcaProfile() - expect(state.profile.id).toBe(DEFAULT_LOCAL_ORCA_PROFILE_ID) + expect(() => store.ensureActiveOrcaProfile()).toThrow('Could not read active profile index') + expect(readJson(indexPath)).toEqual(index) }) }) diff --git a/src/main/orca-profiles/profile-index-store.ts b/src/main/orca-profiles/profile-index-store.ts index 7897e299a21..8f852581ce8 100644 --- a/src/main/orca-profiles/profile-index-store.ts +++ b/src/main/orca-profiles/profile-index-store.ts @@ -9,7 +9,6 @@ import { import { randomUUID } from 'node:crypto' import { dirname } from 'node:path' import { bestEffortFsyncDirectorySync, fsyncFileSync } from '../../shared/secure-file' -import type { GlobalSettings } from '../../shared/global-settings-types' import { createDefaultLocalOrcaProfile, DEFAULT_LOCAL_ORCA_PROFILE_ID, @@ -22,23 +21,24 @@ import { type OrcaProfileSummary } from '../../shared/orca-profiles' import { - getOrcaProfileBrowserSessionMetaFile, getOrcaProfileDataFile, getOrcaProfileDirectory, getOrcaProfileIndexPath, - getProfileUserDataPath, - LEGACY_BACKUP_COUNT, - legacyBackupPath, - legacyBrowserSessionMetaPath, - legacyDataFilePath, - profileBackupPath + getOrcaProfileStateDatabaseFile, + hasOrcaProfileStateDatabase, + getProfileUserDataPath } from './profile-storage-paths' +import { copyLegacyStateToProfile } from './profile-legacy-state-import' +import { profileStateJsonExportPaths } from '../persistence/profile-state/legacy-json/profile-state-export-path' +import { profileStateDatabaseBackups } from '../persistence/profile-state/profile-state-backup-path' export { getOrcaProfileBrowserSessionMetaFile, getOrcaProfileDataFile, getOrcaProfileDirectory, getOrcaProfileIndexPath, + getOrcaProfileStateDatabaseFile, + hasOrcaProfileStateDatabase, getOrcaProfilesDirectory, initOrcaProfilePaths } from './profile-storage-paths' @@ -47,6 +47,7 @@ export type ActiveOrcaProfileState = { index: OrcaProfileIndex profile: OrcaProfileSummary dataFile: string + stateDatabaseFile: string profileDirectory: string } @@ -118,6 +119,14 @@ export function readProfileIndex(indexPath: string): OrcaProfileIndex | null { return readProfileIndexFile(indexPath) ?? readProfileIndexFile(`${indexPath}.bak`) } +function readExistingProfileIndex(indexPath: string): OrcaProfileIndex | null { + const index = readProfileIndex(indexPath) + if (!index && (existsSync(indexPath) || existsSync(`${indexPath}.bak`))) { + throw new Error(`Could not read active profile index ${indexPath}`) + } + return index +} + export function writeProfileIndex(indexPath: string, index: OrcaProfileIndex): void { mkdirSync(dirname(indexPath), { recursive: true }) // Why: only a still-parseable current index may refresh the backup; @@ -136,51 +145,7 @@ export function writeProfileIndex(indexPath: string, index: OrcaProfileIndex): v bestEffortFsyncDirectorySync(dirname(indexPath)) } -function copyIfPresent(source: string, target: string): void { - if (!existsSync(source) || existsSync(target)) { - return - } - mkdirSync(dirname(target), { recursive: true }) - // Why: tmp+rename so a crash mid-copy cannot leave a truncated target that - // the exists() guard above would then treat as a completed migration. - const tmpTarget = `${target}.tmp` - copyFileSync(source, tmpTarget) - renameSync(tmpTarget, target) -} - -function copyLegacyStateToProfile(userDataPath: string, profileId: string): void { - const profileDataFile = getOrcaProfileDataFile(profileId, userDataPath) - copyIfPresent(legacyDataFilePath(userDataPath), profileDataFile) - copyIfPresent( - legacyBrowserSessionMetaPath(userDataPath), - getOrcaProfileBrowserSessionMetaFile(profileId, userDataPath) - ) - for (let i = 0; i < LEGACY_BACKUP_COUNT; i++) { - copyIfPresent(legacyBackupPath(userDataPath, i), profileBackupPath(profileDataFile, i)) - } -} - -// Why: a brand-new profile has no data file, which the telemetry cohort -// migration reads as a fresh install and defaults to opted-in. Copying the -// active profile's consent block keeps an opted-out user opted out (and keeps -// one installId per install) when they create additional profiles. -export function seedNewOrcaProfileTelemetryConsent( - profileId: string, - telemetry: GlobalSettings['telemetry'], - userDataPath = getProfileUserDataPath() -): void { - if (!telemetry) { - return - } - const dataFile = getOrcaProfileDataFile(profileId, userDataPath) - if (existsSync(dataFile)) { - return - } - mkdirSync(dirname(dataFile), { recursive: true }) - const tmpPath = `${dataFile}.tmp` - writeFileSync(tmpPath, JSON.stringify({ settings: { telemetry } }, null, 2), 'utf-8') - renameSync(tmpPath, dataFile) -} +export { seedNewOrcaProfileTelemetryConsent } from './profile-telemetry-consent-seed' function createInitialProfileIndex(now = Date.now()): OrcaProfileIndex { const profile = createDefaultLocalOrcaProfile(now) @@ -193,7 +158,7 @@ function createInitialProfileIndex(now = Date.now()): OrcaProfileIndex { export function loadOrCreateProfileIndex(userDataPath: string): OrcaProfileIndex { const indexPath = getOrcaProfileIndexPath(userDataPath) - const index = existsSync(indexPath) ? readProfileIndex(indexPath) : null + const index = readExistingProfileIndex(indexPath) if (index) { return index } @@ -214,8 +179,8 @@ export function ensureActiveOrcaProfile( userDataPath = getProfileUserDataPath() ): ActiveOrcaProfileState { const indexPath = getOrcaProfileIndexPath(userDataPath) - let index = existsSync(indexPath) ? readProfileIndex(indexPath) : null - let shouldWriteIndex = false + let index = readExistingProfileIndex(indexPath) + let shouldWriteIndex = !existsSync(indexPath) if (!index) { index = createInitialProfileIndex() @@ -230,7 +195,22 @@ export function ensureActiveOrcaProfile( const profileDirectory = getOrcaProfileDirectory(activeProfile.id, userDataPath) mkdirSync(profileDirectory, { recursive: true }) - if (activeProfile.id === DEFAULT_LOCAL_ORCA_PROFILE_ID) { + const profileDatabaseFile = getOrcaProfileStateDatabaseFile(activeProfile.id, userDataPath) + const profileDataFile = getOrcaProfileDataFile(activeProfile.id, userDataPath) + let hasRetainedProfileStateExport = false + try { + hasRetainedProfileStateExport = + profileStateJsonExportPaths(profileDataFile).length > 0 || + profileStateDatabaseBackups(profileDatabaseFile).length > 0 + } catch { + // An unreadable profile directory must never trigger a fallback copy of legacy state. + hasRetainedProfileStateExport = true + } + if ( + activeProfile.id === DEFAULT_LOCAL_ORCA_PROFILE_ID && + !hasOrcaProfileStateDatabase(activeProfile.id, userDataPath) && + !hasRetainedProfileStateExport + ) { copyLegacyStateToProfile(userDataPath, activeProfile.id) } @@ -241,7 +221,8 @@ export function ensureActiveOrcaProfile( return { index, profile: activeProfile, - dataFile: getOrcaProfileDataFile(activeProfile.id, userDataPath), + dataFile: profileDataFile, + stateDatabaseFile: profileDatabaseFile, profileDirectory } } diff --git a/src/main/orca-profiles/profile-legacy-state-import.ts b/src/main/orca-profiles/profile-legacy-state-import.ts new file mode 100644 index 00000000000..03500f74e26 --- /dev/null +++ b/src/main/orca-profiles/profile-legacy-state-import.ts @@ -0,0 +1,35 @@ +import { copyFileSync, existsSync, mkdirSync, renameSync } from 'node:fs' +import { dirname } from 'node:path' +import { + getOrcaProfileBrowserSessionMetaFile, + getOrcaProfileDataFile, + LEGACY_BACKUP_COUNT, + legacyBackupPath, + legacyBrowserSessionMetaPath, + legacyDataFilePath, + profileBackupPath +} from './profile-storage-paths' + +function copyIfPresent(source: string, target: string): void { + if (!existsSync(source) || existsSync(target)) { + return + } + mkdirSync(dirname(target), { recursive: true }) + // Why: tmp+rename so a crash mid-copy cannot leave a truncated target that + // the exists() guard above would then treat as a completed migration. + const tmpTarget = `${target}.tmp` + copyFileSync(source, tmpTarget) + renameSync(tmpTarget, target) +} + +export function copyLegacyStateToProfile(userDataPath: string, profileId: string): void { + const profileDataFile = getOrcaProfileDataFile(profileId, userDataPath) + copyIfPresent(legacyDataFilePath(userDataPath), profileDataFile) + copyIfPresent( + legacyBrowserSessionMetaPath(userDataPath), + getOrcaProfileBrowserSessionMetaFile(profileId, userDataPath) + ) + for (let i = 0; i < LEGACY_BACKUP_COUNT; i++) { + copyIfPresent(legacyBackupPath(userDataPath, i), profileBackupPath(profileDataFile, i)) + } +} diff --git a/src/main/orca-profiles/profile-persistence-deadline.test.ts b/src/main/orca-profiles/profile-persistence-deadline.test.ts new file mode 100644 index 00000000000..87e0507c507 --- /dev/null +++ b/src/main/orca-profiles/profile-persistence-deadline.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ProfileStateMaintenance } from '../persistence/loading-store/profile-state-authority' +import type { ProfileStateMaintenanceOptions } from '../persistence/loading-store/profile-state-maintenance' +import { flushActiveProfileBeforeFileMutation } from './profile-persistence-deadline' + +afterEach(() => vi.useRealTimers()) + +describe('profile persistence deadline', () => { + it('allows the writer request deadline to finish before imposing maintenance cancellation', async () => { + vi.useFakeTimers() + const result = Promise.withResolvers() + const beginProfileMaintenance = vi.fn( + (_options?: ProfileStateMaintenanceOptions) => result.promise + ) + const pending = flushActiveProfileBeforeFileMutation({ beginProfileMaintenance }) + await vi.advanceTimersByTimeAsync(30_000) + expect(beginProfileMaintenance.mock.calls[0]?.[0]?.signal?.aborted).not.toBe(true) + const handle = { resume: vi.fn(async () => {}) } + result.resolve(handle) + await expect(pending).resolves.toBe(handle) + expect(handle.resume).not.toHaveBeenCalled() + }) + + it('resumes a clean pause that finishes after the caller times out', async () => { + vi.useFakeTimers() + const result = Promise.withResolvers() + const beginProfileMaintenance = vi.fn(() => result.promise) + const pending = flushActiveProfileBeforeFileMutation({ beginProfileMaintenance }) + const rejected = expect(pending).rejects.toThrow('orca_profile_persistence_timeout') + await vi.advanceTimersByTimeAsync(60_000) + await rejected + const handle = { resume: vi.fn(async () => {}) } + result.resolve(handle) + await vi.advanceTimersByTimeAsync(0) + expect(handle.resume).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/orca-profiles/profile-persistence-deadline.ts b/src/main/orca-profiles/profile-persistence-deadline.ts index 084a53c40fe..73280914e4a 100644 --- a/src/main/orca-profiles/profile-persistence-deadline.ts +++ b/src/main/orca-profiles/profile-persistence-deadline.ts @@ -1,8 +1,33 @@ import type { Store } from '../persistence' +import type { ProfileStateMaintenance } from '../persistence/loading-store/profile-state-authority' +import type { ProfileStateMaintenanceOptions } from '../persistence/loading-store/profile-state-maintenance' -const PROFILE_PERSISTENCE_TIMEOUT_MS = 20_000 +const PROFILE_PERSISTENCE_TIMEOUT_MS = 60_000 -export async function flushActiveProfileBeforeFileMutation(store: Store): Promise { +export async function flushActiveProfileBeforeFileMutation( + store: Pick, + options: Pick = {} +): Promise { + return withinProfilePersistenceDeadline((signal) => + store.beginProfileMaintenance({ ...options, signal }).then(async (handle) => { + if (signal.aborted && options.flush !== false) { + await handle.resume() + throw new Error('orca_profile_persistence_timeout') + } + return handle + }) + ) +} + +export function flushActiveProfileBeforeRelaunch( + store: Pick +): Promise { + return withinProfilePersistenceDeadline((signal) => store.flushPendingOrThrowAsync({ signal })) +} + +async function withinProfilePersistenceDeadline( + operation: (signal: AbortSignal) => Promise +): Promise { const controller = new AbortController() let timeout: ReturnType | null = null const deadline = new Promise((_resolve, reject) => { @@ -12,7 +37,7 @@ export async function flushActiveProfileBeforeFileMutation(store: Store): Promis }, PROFILE_PERSISTENCE_TIMEOUT_MS) }) try { - await Promise.race([store.flushPendingOrThrowAsync({ signal: controller.signal }), deadline]) + return await Promise.race([operation(controller.signal), deadline]) } finally { if (timeout) { clearTimeout(timeout) diff --git a/src/main/orca-profiles/profile-project-domain-changes.ts b/src/main/orca-profiles/profile-project-domain-changes.ts new file mode 100644 index 00000000000..30f5aef805e --- /dev/null +++ b/src/main/orca-profiles/profile-project-domain-changes.ts @@ -0,0 +1,144 @@ +import { hashProfileStateJson } from '../persistence/profile-state/profile-state-documents' +import { + isRecord, + type ProfileStateParsedDocument +} from '../persistence/profile-state/profile-state-document-validation' +import { prepareProfileStateDomainMutation } from '../persistence/profile-state/profile-state-domain-write-validation' +import type { ProfileStateDomainMutation } from '../persistence/profile-state/profile-state-domain-writes' +import type { PersistedState } from '../../shared/persisted-state-types' + +export type ProfileProjectDomainDigest = { domain: string; hash: string } + +export type ProfileProjectDomainChanges = { + expectedRevision: number + before: ProfileProjectDomainDigest[] + afterHash: string + replacements: { domain: string; payload: string | null }[] +} + +export function profileProjectDomainFingerprint( + domains: readonly ProfileProjectDomainDigest[] +): string { + const pairs = domains.map(({ domain, hash }) => [domain, hash]) + pairs.sort(([left = ''], [right = '']) => (left < right ? -1 : left > right ? 1 : 0)) + return hashProfileStateJson(`orca-profile-move-domains-v2:${JSON.stringify(pairs)}`) +} + +export function profileProjectDomainDigests( + documents: readonly ProfileStateParsedDocument[] +): ProfileProjectDomainDigest[] { + return documents.map(({ domain, contentHash }) => ({ domain, hash: contentHash })) +} + +export function prepareProfileProjectDomainChanges( + revision: number, + documents: readonly ProfileStateParsedDocument[], + state: PersistedState +): ProfileProjectDomainChanges { + const originals = new Map(documents.map((document) => [document.domain, document])) + const replacements: ProfileProjectDomainChanges['replacements'] = [] + for (const [domain, value] of Object.entries(state)) { + const original = originals.get(domain) + // Transfer projections retain unchanged values; do not serialize unrelated history/output. + if (original && Object.is(original.value, value)) { + continue + } + const payload = JSON.stringify(value) ?? null + if ( + payload === null + ? original !== undefined + : hashProfileStateJson(payload) !== original?.contentHash + ) { + replacements.push({ domain, payload }) + } + } + for (const domain of originals.keys()) { + if (!Object.hasOwn(state, domain)) { + replacements.push({ domain, payload: null }) + } + } + const before = profileProjectDomainDigests(documents) + return { + expectedRevision: revision, + before, + afterHash: profileProjectDomainFingerprint(applyDomainDigests(before, replacements)), + replacements + } +} + +function applyDomainDigests( + before: readonly ProfileProjectDomainDigest[], + replacements: readonly ProfileStateDomainMutation[] +): ProfileProjectDomainDigest[] { + const digests = new Map(before.map(({ domain, hash }) => [domain, hash])) + for (const { domain, payload } of replacements) { + if (payload === null) { + digests.delete(domain) + } else { + digests.set(domain, hashProfileStateJson(payload)) + } + } + return [...digests].map(([domain, hash]) => ({ domain, hash })) +} + +export function validateProfileProjectDomainChanges( + value: unknown +): asserts value is ProfileProjectDomainChanges { + if ( + !isRecord(value) || + typeof value.expectedRevision !== 'number' || + !Number.isSafeInteger(value.expectedRevision) || + value.expectedRevision < 0 || + !Number.isSafeInteger(value.expectedRevision + 1) || + !Array.isArray(value.before) || + !Array.isArray(value.replacements) || + value.replacements.length === 0 || + !isHash(value.afterHash) + ) { + throw new Error('Profile move domain changes are malformed') + } + const before: ProfileProjectDomainDigest[] = [] + const domains = new Set() + for (const digest of value.before) { + if ( + !isRecord(digest) || + typeof digest.domain !== 'string' || + !isHash(digest.hash) || + domains.has(digest.domain) + ) { + throw new Error('Profile move domain manifest is malformed') + } + domains.add(digest.domain) + before.push({ domain: digest.domain, hash: digest.hash }) + } + const replacements: ProfileStateDomainMutation[] = [] + domains.clear() + for (const replacement of value.replacements) { + if ( + !isRecord(replacement) || + !isDomain(replacement.domain) || + (replacement.payload !== null && typeof replacement.payload !== 'string') || + domains.has(replacement.domain) + ) { + throw new Error('Profile move domain replacement is malformed') + } + domains.add(replacement.domain) + const mutation = { domain: replacement.domain, payload: replacement.payload } + prepareProfileStateDomainMutation(mutation) + replacements.push(mutation) + } + if ( + profileProjectDomainFingerprint(applyDomainDigests(before, replacements)) !== value.afterHash || + profileProjectDomainFingerprint(before) === value.afterHash + ) { + throw new Error('Profile move domain changes do not match their fingerprint') + } +} + +function isDomain(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 +} + +function isHash(value: unknown): value is string { + return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value) +} diff --git a/src/main/orca-profiles/profile-project-domain-move-intent.ts b/src/main/orca-profiles/profile-project-domain-move-intent.ts new file mode 100644 index 00000000000..3441b2d8f3a --- /dev/null +++ b/src/main/orca-profiles/profile-project-domain-move-intent.ts @@ -0,0 +1,58 @@ +import { randomUUID } from 'node:crypto' +import type { ProfileProjectDomainMoveIntent } from './profile-project-move-record' +export type { ProfileProjectDomainMoveIntent } from './profile-project-move-record' +import { + profileProjectDomainDigests, + profileProjectDomainFingerprint, + type ProfileProjectDomainChanges +} from './profile-project-domain-changes' +import { + readProfileProjectTransferState, + type ReadProfileProjectTransferResult +} from './profile-project-domain-state' + +export function createProfileProjectDomainMoveIntent(args: { + sourceProfileId: string + targetProfileId: string + source: ProfileProjectDomainChanges + target: ProfileProjectDomainChanges +}): ProfileProjectDomainMoveIntent { + return { version: 2, id: randomUUID(), ...args } +} + +export function readProfileProjectDomainMoveState( + userDataPath: string, + intent: ProfileProjectDomainMoveIntent +): { sourceBefore: boolean; sourceAfter: boolean; targetBefore: boolean; targetAfter: boolean } { + const source = readProfileProjectTransferState(intent.sourceProfileId, userDataPath) + const target = readProfileProjectTransferState(intent.targetProfileId, userDataPath) + if (source.documents === undefined || target.documents === undefined) { + throw new Error(`Profile move ${intent.id} no longer has two SQLite participants`) + } + return { + sourceBefore: matches( + source, + intent.source.expectedRevision, + profileProjectDomainFingerprint(intent.source.before) + ), + targetBefore: matches( + target, + intent.target.expectedRevision, + profileProjectDomainFingerprint(intent.target.before) + ), + sourceAfter: matches(source, intent.source.expectedRevision + 1, intent.source.afterHash), + targetAfter: matches(target, intent.target.expectedRevision + 1, intent.target.afterHash) + } +} + +function matches( + snapshot: ReadProfileProjectTransferResult, + revision: number, + hash: string +): boolean { + return ( + snapshot.revision === revision && + snapshot.documents !== undefined && + profileProjectDomainFingerprint(profileProjectDomainDigests(snapshot.documents)) === hash + ) +} diff --git a/src/main/orca-profiles/profile-project-domain-state.ts b/src/main/orca-profiles/profile-project-domain-state.ts new file mode 100644 index 00000000000..e788b4b0def --- /dev/null +++ b/src/main/orca-profiles/profile-project-domain-state.ts @@ -0,0 +1,84 @@ +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from '../persistence/profile-state/profile-state-database' +import { + readProfileStateDocuments, + readProfileStateRevision +} from '../persistence/profile-state/profile-state-documents' +import type { ProfileStateParsedDocument } from '../persistence/profile-state/profile-state-document-validation' +import { writeProfileStateDomains } from '../persistence/profile-state/profile-state-domain-writes' +import { withProfileStateReadSnapshot } from '../persistence/profile-state/profile-state-read-snapshot' +import { getOrcaProfileStateDatabaseFile } from './profile-storage-paths' +import { + normalizeProfileProjectState, + profileStateStorage, + readProfileStateWithRevision, + type ReadProfileStateResult +} from './profile-project-state-file' +import { + validateProfileProjectDomainChanges, + type ProfileProjectDomainChanges +} from './profile-project-domain-changes' + +export type ReadProfileProjectTransferResult = ReadProfileStateResult & { + documents?: readonly ProfileStateParsedDocument[] +} + +/** Keep checked domain values for transfer without joining and reparsing the complete profile. */ +export function readProfileProjectTransferState( + profileId: string, + userDataPath: string +): ReadProfileProjectTransferResult { + if (profileStateStorage(profileId, userDataPath) === 'json') { + return readProfileStateWithRevision(profileId, userDataPath) + } + const opened = openProfileStateDatabaseReadOnly( + getOrcaProfileStateDatabaseFile(profileId, userDataPath), + profileId + ) + try { + return withProfileStateReadSnapshot(opened.db, () => { + const revision = readProfileStateRevision(opened.db) + const documents = readProfileStateDocuments(opened.db, { + profileRevision: revision, + representation: 'parsed' + }) + return { + revision, + documents, + state: normalizeProfileProjectState( + Object.fromEntries(documents.map(({ domain, value }) => [domain, value])) + ) + } + }) + } finally { + opened.db.close() + } +} + +export function writeProfileProjectDomainChanges( + profileId: string, + userDataPath: string, + changes: ProfileProjectDomainChanges +): void { + validateProfileProjectDomainChanges(changes) + if (profileStateStorage(profileId, userDataPath) !== 'sqlite') { + throw new Error('Profile domain transfer requires an established SQLite participant') + } + const opened = openProfileStateDatabase( + getOrcaProfileStateDatabaseFile(profileId, userDataPath), + profileId + ) + try { + const result = writeProfileStateDomains(opened.db, { + expectedRevision: changes.expectedRevision, + replacements: changes.replacements.map(({ domain, payload }) => ({ domain, payload })) + }) + if (!result.changed || result.revision !== changes.expectedRevision + 1) { + throw new Error('Profile domain transfer did not commit its expected revision') + } + } finally { + opened.db.close() + } +} diff --git a/src/main/orca-profiles/profile-project-domain-transfer.test.ts b/src/main/orca-profiles/profile-project-domain-transfer.test.ts new file mode 100644 index 00000000000..00ceb64455e --- /dev/null +++ b/src/main/orca-profiles/profile-project-domain-transfer.test.ts @@ -0,0 +1,546 @@ +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { buildSync } from 'esbuild' +import { runProcess } from '../../shared/child-process/run-process' +import { getDefaultPersistedState } from '../../shared/constants' +import { ORCA_PROFILE_INDEX_SCHEMA_VERSION } from '../../shared/orca-profiles' +import type { Repo } from '../../shared/repo-types' +import { openProfileStateDatabase } from '../persistence/profile-state/profile-state-database' +import { + hashProfileStateJson, + importProfileStateJson, + readProfileStateSnapshot +} from '../persistence/profile-state/profile-state-documents' +import { writeProfileStateDomain } from '../persistence/profile-state/profile-state-domain-writes' +import { + prepareProfileProjectDomainChanges, + profileProjectDomainFingerprint, + validateProfileProjectDomainChanges +} from './profile-project-domain-changes' +import * as domainState from './profile-project-domain-state' +import { createProfileProjectDomainMoveIntent } from './profile-project-domain-move-intent' +import { + persistProfileProjectMoveIntent, + recoverPendingProfileProjectMoves +} from './profile-project-move-intent' +import { normalizeProfileProjectState } from './profile-project-state-file' +import { removeSourceRepo } from './profile-project-source-removal' +import { + applyPayloadToTarget, + createTargetRepo, + createTransferPayload +} from './profile-project-transfer-payload' +import { transferOrcaProfileProject } from './profile-project-transfer' + +let root: string +let crashRoot: string +let crashBundle: string +let crashScript: string +const repo: Repo = { + id: 'repo-1', + path: '/project', + displayName: 'Project', + badgeColor: 'neutral', + addedAt: 1, + kind: 'git', + connectionId: null +} + +function dbPath(id: string): string { + return join(root, 'profiles', id, 'profile-state.db') +} + +function withDatabase( + id: string, + action: (db: ReturnType['db']) => T +): T { + const opened = openProfileStateDatabase(dbPath(id), id) + try { + return action(opened.db) + } finally { + opened.db.close() + } +} + +function seed(id: string, repos: Repo[] = []): void { + mkdirSync(join(root, 'profiles', id), { recursive: true }) + withDatabase(id, (db) => + importProfileStateJson( + db, + JSON.stringify({ + futureOpaque: { z: ['\ud800', null, id], a: 'x'.repeat(100_000) }, + ['']: { keep: true }, + ['__proto__']: { inert: true }, + settings: { opencodeSessionCookie: 'enc:v1:sealed-inactive', unknownSetting: [2, 1] }, + repos, + projects: null, + projectHostSetups: null, + workspaceSessionsByHostId: null, + automationRuns: [], + futureNull: null, + futureDelete: { remove: true } + }) + ) + ) +} + +function raw(id: string) { + return withDatabase(id, (db) => readProfileStateSnapshot(db)) +} + +function transfer(mode: 'copy' | 'move' = 'move') { + return transferOrcaProfileProject( + { sourceProfileId: 'source', targetProfileId: 'target', repoId: repo.id, mode }, + root + ) +} + +function frozen(value: T): T { + if (value !== null && typeof value === 'object') { + for (const nested of Object.values(value)) { + frozen(nested) + } + Object.freeze(value) + } + return value +} + +function preparedMove() { + const source = domainState.readProfileProjectTransferState('source', root) + const target = domainState.readProfileProjectTransferState('target', root) + if ( + source.revision === undefined || + target.revision === undefined || + !source.documents || + !target.documents + ) { + throw new Error('Missing SQL fixture') + } + const sourceRepo = source.state.repos[0] + if (!sourceRepo) { + throw new Error('Missing source repo') + } + const targetRepo = createTargetRepo(sourceRepo, target.state, false) + const payload = createTransferPayload({ + sourceState: source.state, + sourceRepo, + targetRepo, + includeSessions: true + }) + const sourceAfter = removeSourceRepo(source.state, sourceRepo.id) + const targetAfter = applyPayloadToTarget(target.state, payload) + const intent = createProfileProjectDomainMoveIntent({ + sourceProfileId: 'source', + targetProfileId: 'target', + source: prepareProfileProjectDomainChanges(source.revision, source.documents, sourceAfter), + target: prepareProfileProjectDomainChanges(target.revision, target.documents, targetAfter) + }) + return { intent, sourceAfter, targetAfter } +} + +beforeAll(() => { + crashRoot = mkdtempSync(join(tmpdir(), 'orca-domain-move-crash-api-')) + crashBundle = join(crashRoot, 'api.cjs') + crashScript = join(crashRoot, 'crash.cjs') + buildSync({ + stdin: { + contents: + "export { transferOrcaProfileProject } from './src/main/orca-profiles/profile-project-transfer'", + loader: 'ts', + resolveDir: process.cwd() + }, + outfile: crashBundle, + bundle: true, + platform: 'node', + format: 'cjs', + packages: 'external' + }) + writeFileSync( + crashScript, + ` +const fs = require('node:fs') +const path = require('node:path') +const [bundle, root, stage] = process.argv.slice(2) +const barrier = label => { + if (label !== stage) return + fs.writeSync(1, label + '\\n') + process.kill(process.pid, 'SIGKILL') + throw new Error('SIGKILL returned') +} +const sqlite = require('node:sqlite') +const exec = sqlite.DatabaseSync.prototype.exec +const writers = new WeakSet() +sqlite.DatabaseSync.prototype.exec = function(sql) { + const writing = writers.has(this) + const participant = writing ? this.prepare("SELECT value FROM profile_state_meta WHERE key = 'profile_id'").get().value : '' + if (writing && sql === 'COMMIT') barrier(participant + '-before-commit') + const result = exec.call(this, sql) + if (sql === 'BEGIN IMMEDIATE') writers.add(this) + if (sql === 'COMMIT' || sql === 'ROLLBACK') writers.delete(this) + if (writing && sql === 'COMMIT') barrier(participant + '-committed') + return result +} +let published = false +let removed = false +const rename = fs.renameSync +fs.renameSync = (from, to) => { + const intent = path.dirname(to) === path.join(root, 'profile-move-intents') && to.endsWith('.json') + if (intent) barrier('intent-before-publish') + rename(from, to) + if (intent) { published = true; barrier('intent-published') } +} +const rm = fs.rmSync +fs.rmSync = (target, ...rest) => { + const intent = path.dirname(target) === path.join(root, 'profile-move-intents') && target.endsWith('.json') + if (intent) barrier('cleanup-before-remove') + rm(target, ...rest) + if (intent) { removed = true; barrier('cleanup-removed') } +} +const fsync = fs.fsyncSync +fs.fsyncSync = fd => { + fsync(fd) + if (fs.fstatSync(fd).isDirectory()) { + if (removed) barrier('cleanup-durable') + else if (published) barrier('intent-durable') + } +} +require(bundle).transferOrcaProfileProject({ sourceProfileId: 'source', targetProfileId: 'target', repoId: 'repo-1', mode: 'move' }, root) +throw new Error('Crash boundary not reached: ' + stage) +` + ) +}) + +afterAll(() => rmSync(crashRoot, { recursive: true, force: true })) + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-domain-transfer-')) + writeFileSync( + join(root, 'orca-profile-index.json'), + JSON.stringify({ + schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION, + activeProfileId: 'source', + profiles: ['source', 'target'].map((id) => ({ + id, + name: id, + avatar: { kind: 'initials', initials: id[0], color: 'neutral' }, + kind: 'local', + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1 + })) + }) + ) + seed('source', [repo]) + seed('target') +}) + +afterEach(() => { + vi.restoreAllMocks() + rmSync(root, { recursive: true, force: true }) +}) + +describe('profile domain transfers', () => { + it.each(['copy', 'move'] as const)( + '%s preserves the full normalized projection and unchanged physical rows', + (mode) => { + const beforeSource = domainState.readProfileProjectTransferState('source', root).state + const beforeTarget = domainState.readProfileProjectTransferState('target', root).state + const physicalBefore = withDatabase('target', (db) => + db + .prepare( + "SELECT * FROM profile_state_documents WHERE domain IN ('futureOpaque', '', '__proto__', 'futureNull') ORDER BY domain" + ) + .all() + ) + const result = transfer(mode) + expect(result.status).toBe('transferred') + const afterTarget = JSON.parse(raw('target').json) + const targetRepo: Repo = afterTarget.repos[0] + const payload = createTransferPayload({ + sourceState: beforeSource, + sourceRepo: repo, + targetRepo, + includeSessions: mode === 'move' + }) + expect(afterTarget).toEqual( + JSON.parse(JSON.stringify(applyPayloadToTarget(beforeTarget, payload))) + ) + expect(afterTarget.settings.opencodeSessionCookie).toBe('enc:v1:sealed-inactive') + expect( + withDatabase('target', (db) => + db + .prepare( + "SELECT * FROM profile_state_documents WHERE domain IN ('futureOpaque', '', '__proto__', 'futureNull') ORDER BY domain" + ) + .all() + ) + ).toEqual(physicalBefore) + expect(raw('target').revision).toBe(2) + expect(raw('source').revision).toBe(mode === 'move' ? 2 : 1) + if (mode === 'move') { + expect(JSON.parse(raw('source').json)).toEqual( + JSON.parse(JSON.stringify(removeSourceRepo(beforeSource, repo.id))) + ) + } + } + ) + + it.each(['git', 'folder', 'ssh'] as const)( + 'normalization and %s projections do not mutate raw nested values', + (kind) => { + const snapshot = domainState.readProfileProjectTransferState('source', root) + const input = Object.fromEntries( + (snapshot.documents ?? []).map(({ domain, value }) => [domain, value]) + ) + input.repos = [ + { + ...repo, + kind: kind === 'folder' ? 'folder' : 'git', + connectionId: kind === 'ssh' ? 'remote' : null + } + ] + input.projects = [ + { + id: 'old-project', + displayName: 'Previous', + badgeColor: 'neutral', + sourceRepoIds: ['repo-1'], + createdAt: 1, + updatedAt: 1, + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + } + ] + input.workspaceSession = { + ...getDefaultPersistedState('/test').workspaceSession, + tabsByWorktree: { + 'repo-1::/project/branch': [ + { + id: 'tab', + ptyId: 'pty', + worktreeId: 'repo-1::/project/branch', + title: 'Shell', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + } + if (kind === 'ssh') { + input.workspaceSessionsByHostId = { 'runtime:remote': input.workspaceSession } + } + const before = JSON.stringify(input) + frozen(input) + const source = frozen(normalizeProfileProjectState(input)) + const target = frozen(normalizeProfileProjectState({ repos: [] })) + const sourceRepo = source.repos[0] + if (!sourceRepo) { + throw new Error('Missing source repo') + } + const payload = frozen( + createTransferPayload({ + sourceState: source, + sourceRepo, + targetRepo: createTargetRepo(sourceRepo, target, false), + includeSessions: true + }) + ) + expect(() => applyPayloadToTarget(target, payload)).not.toThrow() + expect(() => removeSourceRepo(source, sourceRepo.id)).not.toThrow() + expect(JSON.stringify(input)).toBe(before) + expect(Object.entries(source).find(([domain]) => domain === 'futureOpaque')?.[1]).toBe( + input.futureOpaque + ) + } + ) + + it('journals only changed domains and replays target-first with exact revisions', () => { + const { intent, sourceAfter, targetAfter } = preparedMove() + expect(intent.source.replacements.map(({ domain }) => domain)).not.toContain('futureOpaque') + expect(JSON.stringify(intent).length).toBeLessThan(40_000) + persistProfileProjectMoveIntent(root, intent) + domainState.writeProfileProjectDomainChanges('target', root, intent.target) + expect(recoverPendingProfileProjectMoves(root)).toBe(1) + expect(JSON.parse(raw('source').json)).toEqual(JSON.parse(JSON.stringify(sourceAfter))) + expect(JSON.parse(raw('target').json)).toEqual(JSON.parse(JSON.stringify(targetAfter))) + expect(raw('source').revision).toBe(2) + expect(raw('target').revision).toBe(2) + expect(recoverPendingProfileProjectMoves(root)).toBe(0) + }) + + it.each(['source', 'target'] as const)( + 'refuses an unrelated %s write and retains the move intent', + (participant) => { + const { intent } = preparedMove() + persistProfileProjectMoveIntent(root, intent) + domainState.writeProfileProjectDomainChanges('target', root, intent.target) + withDatabase(participant, (db) => + writeProfileStateDomain(db, { + expectedRevision: participant === 'source' ? 1 : 2, + domain: 'unrelated', + payload: 'true' + }) + ) + expect(() => recoverPendingProfileProjectMoves(root)).toThrow(/conflicts|unrecognized/) + expect(readdirSync(join(root, 'profile-move-intents'))).toContain(`${intent.id}.json`) + expect(JSON.parse(raw('source').json).repos).toHaveLength(1) + } + ) + + it('leaves conflicted moves between inactive profiles for those profiles to recover', () => { + const { intent } = preparedMove() + persistProfileProjectMoveIntent(root, intent) + domainState.writeProfileProjectDomainChanges('target', root, intent.target) + withDatabase('source', (db) => + writeProfileStateDomain(db, { + expectedRevision: 1, + domain: 'unrelated', + payload: 'true' + }) + ) + expect(recoverPendingProfileProjectMoves(root, 'third-profile')).toBe(0) + expect(() => recoverPendingProfileProjectMoves(root, 'source')).toThrow(/conflicts/) + expect(readdirSync(join(root, 'profile-move-intents'))).toContain(`${intent.id}.json`) + }) + + it('refuses a malformed move record even when its header names inactive profiles', () => { + const { intent } = preparedMove() + persistProfileProjectMoveIntent(root, intent) + const path = join(root, 'profile-move-intents', `${intent.id}.json`) + writeFileSync(path, JSON.stringify({ ...intent, source: null })) + expect(() => recoverPendingProfileProjectMoves(root, 'third-profile')).toThrow('malformed') + expect(JSON.parse(readFileSync(path, 'utf8')).source).toBeNull() + }) + + it('refuses independently hashed malformed unrelated data before a copy writes anything', () => { + const before = raw('target').json + withDatabase('source', (db) => + db + .prepare( + 'UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = ?' + ) + .run('null,"extra":true', hashProfileStateJson('null,"extra":true'), 'futureNull') + ) + expect(() => transfer('copy')).toThrow(/JSON/) + expect(raw('target').json).toBe(before) + }) + + it('distinguishes deletions and null while ignoring extra executable mutation options', () => { + const snapshot = domainState.readProfileProjectTransferState('target', root) + if (!snapshot.documents || snapshot.revision === undefined) { + throw new Error('Missing SQL fixture') + } + const after = { ...snapshot.state, futureDelete: undefined, futureNull: null, addedNull: null } + const changes = prepareProfileProjectDomainChanges(snapshot.revision, snapshot.documents, after) + const poisoned = { + ...changes, + automationRunsAfter: [{}], + replacements: changes.replacements.map((replacement) => ({ + ...replacement, + domainVersion: -1 + })) + } + domainState.writeProfileProjectDomainChanges('target', root, poisoned) + const saved = JSON.parse(raw('target').json) + expect(saved.futureNull).toBeNull() + expect(saved.addedNull).toBeNull() + expect(saved).not.toHaveProperty('futureDelete') + expect(saved.automationRuns).toEqual([]) + }) + + it.each(['payload', 'afterHash', 'duplicate', 'revision', 'before'] as const)( + 'rejects %s tampering before recovery modifies either participant', + (kind) => { + const { intent } = preparedMove() + persistProfileProjectMoveIntent(root, intent) + domainState.writeProfileProjectDomainChanges('target', root, intent.target) + const before = raw('source').json + if (kind === 'payload') { + intent.source.replacements[0]!.payload = 'null' + } + if (kind === 'afterHash') { + intent.source.afterHash = 'a'.repeat(64) + } + if (kind === 'duplicate') { + intent.source.replacements.push(intent.source.replacements[0]!) + } + if (kind === 'revision') { + intent.source.expectedRevision = Number.MAX_SAFE_INTEGER + } + if (kind === 'before') { + intent.source.before.push(intent.source.before[0]!) + } + writeFileSync(join(root, 'profile-move-intents', `${intent.id}.json`), JSON.stringify(intent)) + expect(() => recoverPendingProfileProjectMoves(root)).toThrow() + expect(raw('source').json).toBe(before) + expect(readFileSync(join(root, 'profile-move-intents', `${intent.id}.json`), 'utf8')).toBe( + JSON.stringify(intent) + ) + } + ) + + it('canonicalizes only domain ordering in fingerprints', () => { + const a = { domain: 'a', hash: hashProfileStateJson('{"x":1,"y":2}') } + const b = { domain: 'b', hash: hashProfileStateJson('[2,1]') } + expect(profileProjectDomainFingerprint([a, b])).toBe(profileProjectDomainFingerprint([b, a])) + expect(profileProjectDomainFingerprint([a, b])).not.toBe( + profileProjectDomainFingerprint([{ ...a, hash: hashProfileStateJson('{"y":2,"x":1}') }, b]) + ) + const { intent } = preparedMove() + expect(() => validateProfileProjectDomainChanges(intent.source)).not.toThrow() + }) + + const crashStages = [ + 'intent-before-publish', + 'intent-published', + ...(process.platform === 'win32' ? [] : ['intent-durable']), + 'target-before-commit', + 'target-committed', + 'source-before-commit', + 'source-committed', + 'cleanup-before-remove', + 'cleanup-removed', + ...(process.platform === 'win32' ? [] : ['cleanup-durable']) + ] + it.each(crashStages)('recovers exact state after actual SIGKILL at %s', async (stage) => { + const sourceBefore = raw('source').json + const targetBefore = raw('target').json + const { sourceAfter, targetAfter } = preparedMove() + const child = await runProcess({ + program: process.execPath, + args: [crashScript, crashBundle, root, stage], + env: { + ...process.env, + ORCA_BACKGROUND_LAUNCH: '1', + NODE_PATH: join(process.cwd(), 'node_modules') + }, + timeoutMs: 10_000, + maxOutputBytes: 16_384 + }) + expect(child.timedOut, child.stderr).toBe(false) + expect(child.stdout, child.stderr).toBe(`${stage}\n`) + expect(child.code).not.toBe(0) + if (process.platform !== 'win32') { + expect(child.signal).toBe('SIGKILL') + } + recoverPendingProfileProjectMoves(root) + const targetCommitted = ![ + 'intent-before-publish', + 'intent-published', + 'intent-durable', + 'target-before-commit' + ].includes(stage) + expect(JSON.parse(raw('source').json)).toEqual( + targetCommitted ? JSON.parse(JSON.stringify(sourceAfter)) : JSON.parse(sourceBefore) + ) + expect(JSON.parse(raw('target').json)).toEqual( + targetCommitted ? JSON.parse(JSON.stringify(targetAfter)) : JSON.parse(targetBefore) + ) + expect(raw('source').revision).toBe(targetCommitted ? 2 : 1) + expect(raw('target').revision).toBe(targetCommitted ? 2 : 1) + expect(recoverPendingProfileProjectMoves(root)).toBe(0) + }) +}) diff --git a/src/main/orca-profiles/profile-project-move-intent.ts b/src/main/orca-profiles/profile-project-move-intent.ts new file mode 100644 index 00000000000..51fd796e5ff --- /dev/null +++ b/src/main/orca-profiles/profile-project-move-intent.ts @@ -0,0 +1,124 @@ +import { randomUUID } from 'node:crypto' +import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { bestEffortFsyncDirectorySync, fsyncFileSync } from '../../shared/secure-file' +import { hashProfileStateJson } from '../persistence/profile-state/profile-state-documents' +import { + readProfileStateWithRevision, + writeSerializedProfileState, + type ReadProfileStateResult +} from './profile-project-state-file' +import { getOrcaProfileMoveIntentDirectory } from './profile-storage-paths' +import { readProfileProjectDomainMoveState } from './profile-project-domain-move-intent' +import { writeProfileProjectDomainChanges } from './profile-project-domain-state' +import { + profileProjectMoveIntentPath, + readPendingProfileProjectMoveIntents, + validateProfileProjectMoveIntent, + type ProfileProjectMoveIntent, + type ProfileProjectMoveIntentV1 +} from './profile-project-move-record' +export { profileHasPendingProjectMove } from './profile-project-move-record' +export type { + ProfileProjectMoveIdentity, + ProfileProjectMoveIntent +} from './profile-project-move-record' + +export function persistProfileProjectMoveIntent( + userDataPath: string, + intent: ProfileProjectMoveIntent +): void { + validateProfileProjectMoveIntent(intent) + const directory = getOrcaProfileMoveIntentDirectory(userDataPath) + mkdirSync(directory, { recursive: true, mode: 0o700 }) + const path = profileProjectMoveIntentPath(userDataPath, intent.id) + const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp` + writeFileSync(temporaryPath, JSON.stringify(intent), { encoding: 'utf8', mode: 0o600 }) + fsyncFileSync(temporaryPath) + renameSync(temporaryPath, path) + bestEffortFsyncDirectorySync(directory) +} + +export function removeProfileProjectMoveIntent(userDataPath: string, intentId: string): void { + rmSync(profileProjectMoveIntentPath(userDataPath, intentId), { force: true }) + bestEffortFsyncDirectorySync(getOrcaProfileMoveIntentDirectory(userDataPath)) +} + +export function recoverPendingProfileProjectMoves( + userDataPath: string, + profileId?: string +): number { + const intents = readPendingProfileProjectMoveIntents(userDataPath).filter( + (intent) => + profileId === undefined || + intent.sourceProfileId === profileId || + intent.targetProfileId === profileId + ) + for (const intent of intents) { + recoverProfileProjectMoveIntent(userDataPath, intent) + } + return intents.length +} + +function recoverProfileProjectMoveIntent( + userDataPath: string, + intent: ProfileProjectMoveIntent +): void { + const { sourceBefore, targetBefore, sourceAfter, targetAfter } = + intent.version === 2 + ? readProfileProjectDomainMoveState(userDataPath, intent) + : readLegacyMoveState(userDataPath, intent) + + if (sourceAfter && targetAfter) { + removeProfileProjectMoveIntent(userDataPath, intent.id) + return + } + if (sourceBefore && targetBefore) { + removeProfileProjectMoveIntent(userDataPath, intent.id) + return + } + if (sourceBefore && targetAfter) { + if (intent.version === 2) { + writeProfileProjectDomainChanges(intent.sourceProfileId, userDataPath, intent.source) + } else { + writeSerializedProfileState(intent.sourceProfileId, userDataPath, intent.sourceAfterJson, { + expectedRevision: intent.expectedSourceRevision + }) + } + removeProfileProjectMoveIntent(userDataPath, intent.id) + return + } + if (sourceBefore && !targetAfter && !targetBefore) { + throw new Error(`Profile move ${intent.id} has an unrecognized target state`) + } + if (targetAfter && !sourceAfter) { + // Preserve the journal when an independent write makes replay unsafe. + throw new Error(`Profile move ${intent.id} conflicts with a source profile write`) + } + if (sourceAfter && targetBefore) { + throw new Error(`Profile move ${intent.id} has a source commit without its target commit`) + } + throw new Error(`Profile move ${intent.id} has an unrecognized participant state`) +} + +function readLegacyMoveState(userDataPath: string, intent: ProfileProjectMoveIntentV1) { + const source = readProfileStateWithRevision(intent.sourceProfileId, userDataPath) + const target = readProfileStateWithRevision(intent.targetProfileId, userDataPath) + if (source.revision === undefined || target.revision === undefined) { + throw new Error(`Profile move ${intent.id} no longer has two SQLite participants`) + } + + return { + sourceBefore: matches(source, intent.expectedSourceRevision, intent.sourceBeforeHash), + targetBefore: matches(target, intent.expectedTargetRevision, intent.targetBeforeHash), + sourceAfter: matches(source, intent.expectedSourceRevision + 1, intent.sourceAfterHash), + targetAfter: matches(target, intent.expectedTargetRevision + 1, intent.targetAfterHash) + } +} + +function matches(snapshot: ReadProfileStateResult, revision: number, hash: string): boolean { + return ( + snapshot.revision === revision && + snapshot.serialized !== undefined && + hashProfileStateJson(snapshot.serialized) === hash + ) +} diff --git a/src/main/orca-profiles/profile-project-move-record.ts b/src/main/orca-profiles/profile-project-move-record.ts new file mode 100644 index 00000000000..68391cd117d --- /dev/null +++ b/src/main/orca-profiles/profile-project-move-record.ts @@ -0,0 +1,141 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { basename, join } from 'node:path' +import { hashProfileStateJson } from '../persistence/profile-state/profile-state-documents' +import { isRecord } from '../persistence/profile-state/profile-state-document-validation' +import { getOrcaProfileMoveIntentDirectory } from './profile-storage-paths' +import { + validateProfileProjectDomainChanges, + type ProfileProjectDomainChanges +} from './profile-project-domain-changes' + +const PROFILE_MOVE_INTENT_VERSION = 1 +const INTENT_FILE_PATTERN = /^[0-9a-f-]{36}\.json$/ +const PROFILE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/ + +export type ProfileProjectMoveIdentity = { + id: string + sourceProfileId: string + targetProfileId: string +} + +export type ProfileProjectMoveIntentV1 = ProfileProjectMoveIdentity & { + version: typeof PROFILE_MOVE_INTENT_VERSION + expectedSourceRevision: number + expectedTargetRevision: number + sourceBeforeHash: string + targetBeforeHash: string + sourceAfterHash: string + targetAfterHash: string + sourceAfterJson: string + targetAfterJson: string +} + +export type ProfileProjectDomainMoveIntent = ProfileProjectMoveIdentity & { + version: 2 + source: ProfileProjectDomainChanges + target: ProfileProjectDomainChanges +} + +export type ProfileProjectMoveIntent = ProfileProjectMoveIntentV1 | ProfileProjectDomainMoveIntent + +export function profileHasPendingProjectMove(profileId: string, userDataPath: string): boolean { + try { + return readPendingProfileProjectMoveIntents(userDataPath).some( + (intent) => intent.sourceProfileId === profileId || intent.targetProfileId === profileId + ) + } catch { + // An unreadable intent cannot rule this profile out as a participant. + return true + } +} + +export function readPendingProfileProjectMoveIntents( + userDataPath: string +): ProfileProjectMoveIntent[] { + const directory = getOrcaProfileMoveIntentDirectory(userDataPath) + return existsSync(directory) + ? readdirSync(directory) + .filter((file) => INTENT_FILE_PATTERN.test(file)) + .map((file) => readProfileProjectMoveIntent(join(directory, file))) + : [] +} + +export function profileProjectMoveIntentPath(userDataPath: string, intentId: string): string { + if (!/^[0-9a-f-]{36}$/.test(intentId)) { + throw new Error('Invalid profile move intent ID') + } + return join(getOrcaProfileMoveIntentDirectory(userDataPath), `${intentId}.json`) +} + +function readProfileProjectMoveIntent(path: string): ProfileProjectMoveIntent { + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(path, 'utf8')) + } catch (error) { + throw new Error( + `Profile move intent is unreadable: ${path}: ${error instanceof Error ? error.message : String(error)}` + ) + } + if (!isRecord(parsed)) { + throw new Error(`Profile move intent is malformed: ${path}`) + } + validateProfileProjectMoveIntent(parsed) + if (basename(path) !== `${parsed.id}.json`) { + throw new Error(`Profile move intent ID does not match its file: ${path}`) + } + return parsed +} + +export function validateProfileProjectMoveIntent( + value: unknown +): asserts value is ProfileProjectMoveIntent { + validateMoveIdentity(value) + if (value.version === 2) { + validateProfileProjectDomainChanges(value.source) + validateProfileProjectDomainChanges(value.target) + return + } + const intent = value + const expectedSourceRevision = intent.expectedSourceRevision + const expectedTargetRevision = intent.expectedTargetRevision + if ( + intent.version !== PROFILE_MOVE_INTENT_VERSION || + !Number.isSafeInteger(expectedSourceRevision) || + !Number.isSafeInteger(expectedTargetRevision) || + typeof expectedSourceRevision !== 'number' || + typeof expectedTargetRevision !== 'number' || + expectedSourceRevision < 0 || + expectedTargetRevision < 0 || + !isHash(intent.sourceBeforeHash) || + !isHash(intent.targetBeforeHash) || + !isHash(intent.sourceAfterHash) || + !isHash(intent.targetAfterHash) || + typeof intent.sourceAfterJson !== 'string' || + typeof intent.targetAfterJson !== 'string' || + hashProfileStateJson(intent.sourceAfterJson) !== intent.sourceAfterHash || + hashProfileStateJson(intent.targetAfterJson) !== intent.targetAfterHash + ) { + throw new Error('Profile move intent is malformed') + } +} + +function validateMoveIdentity( + value: unknown +): asserts value is ProfileProjectMoveIdentity & Record { + if ( + !isRecord(value) || + typeof value.id !== 'string' || + !/^[0-9a-f-]{36}$/.test(value.id) || + typeof value.sourceProfileId !== 'string' || + typeof value.targetProfileId !== 'string' || + !PROFILE_ID_PATTERN.test(value.sourceProfileId) || + !PROFILE_ID_PATTERN.test(value.targetProfileId) || + value.sourceProfileId === value.targetProfileId + ) { + throw new Error('Profile move intent is malformed') + } +} + +function isHash(value: unknown): value is string { + return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value) +} diff --git a/src/main/orca-profiles/profile-project-session-field-disposition.ts b/src/main/orca-profiles/profile-project-session-field-disposition.ts index 49e4cb9ea98..02a7ce77ca6 100644 --- a/src/main/orca-profiles/profile-project-session-field-disposition.ts +++ b/src/main/orca-profiles/profile-project-session-field-disposition.ts @@ -128,9 +128,9 @@ export const WORKSPACE_SESSION_FIELD_DISPOSITION = { onRepoRemoval: 'prunedByBespokeRule', onTransfer: 'copiedByBespokeRule' }, - // Residue: keyed by tab id and pruned by neither path, like remoteSessionIdsByTabId. Bounded - // anyway -- every write and every pull merge runs it through the TTL and cap in - // shared/closed-terminal-tab-tombstones.ts, and a resolved host retires its own entries. + // Owner-scoped after all, just not by this path: removing a workspace's session rows prunes these + // by the record's worktreeId. A project moved to another profile leaves them in the source profile + // until its next load, whose deregistered-repo sweep takes them. closedTerminalTabTombstonesByTabId: { onRepoRemoval: 'notRepoScoped', onTransfer: 'notTransferred' diff --git a/src/main/orca-profiles/profile-project-source-removal.ts b/src/main/orca-profiles/profile-project-source-removal.ts index 37480164ebc..8bf401762f2 100644 --- a/src/main/orca-profiles/profile-project-source-removal.ts +++ b/src/main/orca-profiles/profile-project-source-removal.ts @@ -10,6 +10,7 @@ import { } from './profile-project-session-state' import { isRepoWorktreeId, removeRepoWorktreeRecord } from './profile-project-worktree-identity' +/** Removes repository-owned sessions, lineage, and UI preferences from the source profile after transfer. */ export function removeSourceRepo( state: TransferProfileState, repoId: string @@ -35,6 +36,10 @@ export function removeSourceRepo( ? null : state.ui.lastActiveWorktreeId, filterRepoIds: state.ui.filterRepoIds?.filter((id) => id !== repoId) ?? [], + explorerDisplayRootByWorktree: removeRepoWorktreeRecord( + state.ui.explorerDisplayRootByWorktree, + repoId + ), showDotfilesByWorktree: removeRepoWorktreeRecord(state.ui.showDotfilesByWorktree, repoId) } } diff --git a/src/main/orca-profiles/profile-project-state-file.ts b/src/main/orca-profiles/profile-project-state-file.ts index d4f291a09e4..87d3cdd36c8 100644 --- a/src/main/orca-profiles/profile-project-state-file.ts +++ b/src/main/orca-profiles/profile-project-state-file.ts @@ -1,7 +1,5 @@ -import { randomUUID } from 'node:crypto' -import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { homedir } from 'node:os' -import { dirname } from 'node:path' import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../shared/constants' import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection' import { @@ -15,29 +13,116 @@ import type { Repo } from '../../shared/repo-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import type { SparsePreset } from '../../shared/worktree/create-types' import type { RetiredNameRegistry } from '../../shared/worktree/retired-name-registry' -import { getOrcaProfileDataFile } from './profile-index-store' +import { getOrcaProfileDataFile, getOrcaProfileStateDatabaseFile } from './profile-index-store' +import { + importProfileStateJson, + profileStateJsonMatchesAcceptance, + readProfileStateRevision, + readProfileStateSnapshot +} from '../persistence/profile-state/profile-state-documents' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from '../persistence/profile-state/profile-state-database' +import { parseProfileStateRoot } from '../persistence/profile-state/profile-state-document-validation' +import { assertProfileStateCanInitialize } from '../persistence/profile-state/profile-state-recovery-required' +import { hasProfileStateDatabaseFiles } from '../persistence/profile-state/profile-state-storage-classification' export type TransferProfileState = PersistedState -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) +export type ReadProfileStateResult = { + state: TransferProfileState + /** SQLite profile revision observed with the state snapshot; absent for legacy JSON. */ + revision?: number + /** Exact compact JSON projection observed with the state snapshot. */ + serialized?: string } -function arrayOrEmpty(value: unknown): T[] { - return Array.isArray(value) ? value : [] -} +/** A profile must have one unambiguous transfer source. */ +export class AmbiguousProfileStateStorageError extends Error { + readonly code = 'ambiguous_profile_state_storage' as const -function recordOrEmpty(value: unknown): Record { - return isRecord(value) ? value : {} -} - -export function readProfileState(profileId: string, userDataPath: string): TransferProfileState { - const defaults = getDefaultPersistedState(homedir()) - const dataFile = getOrcaProfileDataFile(profileId, userDataPath) - if (!existsSync(dataFile)) { - return structuredClone(defaults) + constructor(profileId: string, message?: string) { + super(message ?? `Profile ${profileId} has both SQLite and legacy JSON state`) + this.name = 'AmbiguousProfileStateStorageError' } - const parsed: Partial = JSON.parse(readFileSync(dataFile, 'utf-8')) +} + +export type ProfileStateStorage = 'json' | 'sqlite' + +export function profileStateStorage(profileId: string, userDataPath: string): ProfileStateStorage { + const dataFile = getOrcaProfileDataFile(profileId, userDataPath) + const databaseFile = getOrcaProfileStateDatabaseFile(profileId, userDataPath) + const hasJson = existsSync(dataFile) + const hasDatabase = hasProfileStateDatabaseFiles(databaseFile) + if (hasJson && hasDatabase) { + assertAcceptedLegacyJsonMirror(profileId, dataFile, databaseFile) + return 'sqlite' + } + if (!hasDatabase) { + assertProfileStateCanInitialize({ dataFile, databaseFile, profileId }) + } + return hasDatabase ? 'sqlite' : 'json' +} + +/** + * A migrated profile may retain its JSON export during the rollback window. + * Select SQLite only when its acceptance marker still names the exact export; + * any edit, missing marker, or corrupt database remains fail-closed. + */ +function assertAcceptedLegacyJsonMirror( + profileId: string, + dataFile: string, + databaseFile: string +): void { + const rawJson = readFileSync(dataFile, 'utf-8') + const opened = openProfileStateDatabaseReadOnly(databaseFile, profileId) + try { + if (!profileStateJsonMatchesAcceptance(opened.db, rawJson)) { + throw new AmbiguousProfileStateStorageError(profileId) + } + } finally { + opened.db.close() + } +} + +/** Read one profile state and retain the SQLite revision that fenced that snapshot. */ +export function readProfileStateWithRevision( + profileId: string, + userDataPath: string +): ReadProfileStateResult { + const storage = profileStateStorage(profileId, userDataPath) + if (storage === 'json') { + const dataFile = getOrcaProfileDataFile(profileId, userDataPath) + const serialized = existsSync(dataFile) ? readFileSync(dataFile, 'utf-8') : undefined + return { state: parseProfileState(serialized), ...(serialized ? { serialized } : {}) } + } + + const databaseFile = getOrcaProfileStateDatabaseFile(profileId, userDataPath) + const opened = openProfileStateDatabaseReadOnly(databaseFile, profileId) + try { + const snapshot = readProfileStateSnapshot(opened.db) + return { + state: parseProfileState(snapshot.json), + revision: snapshot.revision, + serialized: snapshot.json + } + } finally { + opened.db.close() + } +} + +function parseProfileState(rawJson: string | undefined): TransferProfileState { + if (rawJson === undefined) { + return structuredClone(getDefaultPersistedState(homedir())) + } + return normalizeProfileProjectState(parseProfileStateRoot(rawJson)) +} + +export function normalizeProfileProjectState( + parsed: Partial +): TransferProfileState { + const defaults = getDefaultPersistedState(homedir()) return rebuildRepoBackedProjectState({ ...defaults, ...parsed, @@ -91,16 +176,50 @@ export function readProfileState(profileId: string, userDataPath: string): Trans }) } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function arrayOrEmpty(value: unknown): T[] { + return Array.isArray(value) ? value : [] +} + +function recordOrEmpty(value: unknown): Record { + return isRecord(value) ? value : {} +} + +export function readProfileState(profileId: string, userDataPath: string): TransferProfileState { + return readProfileStateWithRevision(profileId, userDataPath).state +} + export function writeProfileState( profileId: string, userDataPath: string, - state: TransferProfileState + state: TransferProfileState, + options: { expectedRevision?: number } = {} ): void { - const dataFile = getOrcaProfileDataFile(profileId, userDataPath) - mkdirSync(dirname(dataFile), { recursive: true }) - const tmpPath = `${dataFile}.${process.pid}.${randomUUID()}.tmp` - writeFileSync(tmpPath, JSON.stringify(state, null, 2), 'utf-8') - renameSync(tmpPath, dataFile) + writeSerializedProfileState(profileId, userDataPath, JSON.stringify(state), options) +} + +/** Write an already validated JSON projection while preserving its exact bytes in SQLite. */ +export function writeSerializedProfileState( + profileId: string, + userDataPath: string, + serialized: string, + options: { expectedRevision?: number } = {} +): void { + if (profileStateStorage(profileId, userDataPath) !== 'sqlite') { + throw new Error('Profile transfer write requires an established SQLite participant') + } + const databaseFile = getOrcaProfileStateDatabaseFile(profileId, userDataPath) + const opened = openProfileStateDatabase(databaseFile, profileId) + try { + importProfileStateJson(opened.db, serialized, { + expectedRevision: options.expectedRevision ?? readProfileStateRevision(opened.db) + }) + } finally { + opened.db.close() + } } function isRepoBackedProjectHostSetup( diff --git a/src/main/orca-profiles/profile-project-transfer-migration.test.ts b/src/main/orca-profiles/profile-project-transfer-migration.test.ts new file mode 100644 index 00000000000..46bef280333 --- /dev/null +++ b/src/main/orca-profiles/profile-project-transfer-migration.test.ts @@ -0,0 +1,366 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultPersistedState } from '../../shared/constants' +import { ORCA_PROFILE_INDEX_SCHEMA_VERSION } from '../../shared/orca-profiles' +import type { Repo } from '../../shared/repo-types' +import * as profileStateDocuments from '../persistence/profile-state/profile-state-documents' +import { openProfileStateDatabase } from '../persistence/profile-state/profile-state-database' +import { ProfileStateSqliteAuthority } from '../persistence/profile-state/profile-state-sqlite-authority' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath +} from '../persistence/profile-state/profile-state-backup-path' +import { transferOrcaProfileProject } from './profile-project-transfer' +import { + readProfileStateWithRevision, + writeSerializedProfileState +} from './profile-project-state-file' +import { recoverPendingProfileProjectMoves } from './profile-project-move-intent' +import * as profileProjectDomainState from './profile-project-domain-state' + +vi.mock('../persistence/loading-store/store', () => { + throw new Error('Profile transfers must not load inactive Stores') +}) + +const repo: Repo = { + id: 'repo-1', + path: '/projects/folder', + displayName: 'Folder', + badgeColor: 'neutral', + addedAt: 1, + kind: 'folder', + connectionId: null +} +let directory: string + +function paths(profileId: string): { dataFile: string; databaseFile: string } { + return { + dataFile: join(directory, 'profiles', profileId, 'orca-data.json'), + databaseFile: join(directory, 'profiles', profileId, 'profile-state.db') + } +} + +function writeState(profileId: string, sqlite: boolean, repos: Repo[] = []): string { + const defaults = getDefaultPersistedState('/home/test') + const source = JSON.stringify( + { + ...defaults, + repos, + futureDomain: { profileId }, + settings: { ...defaults.settings, opencodeSessionCookie: 'enc:v1:sealed-inactive-secret' } + }, + null, + 2 + ) + const location = paths(profileId) + mkdirSync(join(location.dataFile, '..'), { recursive: true }) + if (sqlite) { + const opened = openProfileStateDatabase(location.databaseFile, profileId) + try { + profileStateDocuments.importProfileStateJson(opened.db, source) + } finally { + opened.db.close() + } + } else { + writeFileSync(location.dataFile, source) + } + return source +} + +function transfer(mode: 'copy' | 'move' = 'move') { + return transferOrcaProfileProject( + { + sourceProfileId: 'source', + targetProfileId: 'target', + repoId: repo.id, + mode + }, + directory + ) +} + +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'orca-profile-transfer-migration-')) + writeFileSync( + join(directory, 'orca-profile-index.json'), + JSON.stringify({ + schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION, + activeProfileId: 'source', + profiles: ['source', 'target'].map((id) => ({ + id, + name: id, + avatar: { kind: 'initials', initials: id[0], color: 'neutral' }, + kind: 'local', + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1 + })) + }) + ) +}) + +afterEach(() => { + vi.restoreAllMocks() + rmSync(directory, { recursive: true, force: true }) +}) + +describe('profile transfer migration', () => { + it.each(['copy', 'move'] as const)( + '%s adopts an unopened JSON target without loading Store', + (mode) => { + writeState('source', true, [repo]) + const targetJson = writeState('target', false) + expect(transfer(mode)).toMatchObject({ status: 'transferred', mode }) + + const target = readProfileStateWithRevision('target', directory) + expect(target.revision).toBe(2) + expect(target.state.repos).toHaveLength(1) + expect(target.state.repos[0]).toMatchObject({ kind: 'folder', path: repo.path }) + expect(target.state.settings.opencodeSessionCookie).toBe('enc:v1:sealed-inactive-secret') + expect(JSON.parse(target.serialized ?? '{}').futureDomain).toEqual({ profileId: 'target' }) + expect(readFileSync(paths('target').dataFile, 'utf8')).toBe(targetJson) + expect(existsSync(`${paths('target').dataFile}.sqlite-export.1.json`)).toBe(true) + expect(readProfileStateWithRevision('source', directory).state.repos).toHaveLength( + mode === 'move' ? 0 : 1 + ) + } + ) + + it('initializes a target with no saved JSON before its move intent is created', () => { + writeState('source', true, [repo]) + expect(transfer()).toMatchObject({ status: 'transferred' }) + expect(readProfileStateWithRevision('target', directory).state.repos).toHaveLength(1) + expect(existsSync(paths('target').dataFile)).toBe(false) + expect(existsSync(paths('target').databaseFile)).toBe(true) + }) + + it.each(['copy', 'move'] as const)( + '%s refuses an apparently empty target that retains a legacy JSON backup', + (mode) => { + writeState('source', true, [repo]) + const sourceRevision = readProfileStateWithRevision('source', directory).revision + const targetJson = writeState('target', false) + const target = paths('target') + writeFileSync(`${target.dataFile}.bak.0`, targetJson) + rmSync(target.dataFile) + + expect(() => transfer(mode)).toThrow('restore a selected backup') + expect(readProfileStateWithRevision('source', directory)).toMatchObject({ + revision: sourceRevision, + state: { repos: [repo] } + }) + expect(existsSync(target.dataFile)).toBe(false) + expect(existsSync(target.databaseFile)).toBe(false) + expect(readFileSync(`${target.dataFile}.bak.0`, 'utf8')).toBe(targetJson) + } + ) + + it.each(['[]', '7', '"invalid"', 'true'])( + 'refuses a non-object JSON target (%s) without replacing its contents', + (raw) => { + writeState('source', false, [repo]) + writeState('target', false) + const target = paths('target') + writeFileSync(target.dataFile, raw) + + expect(() => transfer()).toThrow('Profile state JSON root must be an object') + expect(readFileSync(target.dataFile, 'utf8')).toBe(raw) + expect(existsSync(target.databaseFile)).toBe(false) + expect(readProfileStateWithRevision('source', directory).state.repos).toHaveLength(1) + } + ) + + it('migrates a JSON source before moving into SQLite and retains its exact rollback bytes', () => { + const sourceJson = writeState('source', false, [repo]) + writeState('target', true) + expect(transfer()).toMatchObject({ status: 'transferred' }) + expect(readProfileStateWithRevision('source', directory)).toMatchObject({ + revision: 2, + state: { repos: [] } + }) + expect(readProfileStateWithRevision('target', directory).state.repos).toHaveLength(1) + expect(readFileSync(paths('source').dataFile, 'utf8')).toBe(sourceJson) + }) + + it('copies from JSON into SQLite without migrating or changing the source', () => { + const sourceJson = writeState('source', false, [repo]) + writeState('target', true) + expect(transfer('copy')).toMatchObject({ status: 'transferred' }) + expect(readFileSync(paths('source').dataFile, 'utf8')).toBe(sourceJson) + expect(existsSync(paths('source').databaseFile)).toBe(false) + expect(readProfileStateWithRevision('target', directory).state.repos).toHaveLength(1) + }) + + it.each(['copy', 'move'] as const)( + '%s migrates every mutated JSON participant while preserving storage-form values', + (mode) => { + const sourceJson = writeState('source', false, [repo]) + const targetJson = writeState('target', false) + expect(transfer(mode)).toMatchObject({ status: 'transferred', mode }) + expect(existsSync(paths('source').databaseFile)).toBe(mode === 'move') + expect(existsSync(paths('target').databaseFile)).toBe(true) + expect(readProfileStateWithRevision('source', directory).state.repos).toHaveLength( + mode === 'move' ? 0 : 1 + ) + expect(readProfileStateWithRevision('target', directory)).toMatchObject({ + revision: 2, + state: { repos: [expect.objectContaining({ path: repo.path })] } + }) + for (const [profileId, rawJson] of [ + ['source', sourceJson], + ['target', targetJson] + ]) { + expect(readFileSync(paths(profileId).dataFile, 'utf8')).toBe(rawJson) + const current = readProfileStateWithRevision(profileId, directory) + expect(current.state.settings.opencodeSessionCookie).toBe('enc:v1:sealed-inactive-secret') + expect(JSON.parse(current.serialized ?? '{}').futureDomain).toEqual({ profileId }) + } + } + ) + + it.each(['copy', 'move'] as const)( + '%s rejects JSON-only mutation without SQLite and preserves both legacy sources', + (mode) => { + const sourceJson = writeState('source', false, [repo]) + const targetJson = writeState('target', false) + const getBuiltin = process.getBuiltinModule + vi.spyOn(process, 'getBuiltinModule').mockImplementation((name) => + name === 'node:sqlite' ? undefined : getBuiltin(name) + ) + expect(() => transfer(mode)).toThrow('Unable to open profile state database') + expect(existsSync(paths('source').databaseFile)).toBe(false) + expect(existsSync(paths('target').databaseFile)).toBe(false) + expect(readFileSync(paths('source').dataFile, 'utf8')).toBe(sourceJson) + expect(readFileSync(paths('target').dataFile, 'utf8')).toBe(targetJson) + expect(readProfileStateWithRevision('source', directory).state.repos).toHaveLength(1) + } + ) + + it('refuses a legacy JSON write outside participant migration', () => { + const sourceJson = writeState('source', false, [repo]) + expect(() => writeSerializedProfileState('source', directory, '{}')).toThrow( + 'established SQLite participant' + ) + expect(readFileSync(paths('source').dataFile, 'utf8')).toBe(sourceJson) + expect(() => writeSerializedProfileState('target', directory, '{}')).toThrow( + 'established SQLite participant' + ) + expect(existsSync(paths('target').dataFile)).toBe(false) + expect(existsSync(paths('target').databaseFile)).toBe(false) + }) + + it('refuses mixed storage on a runtime without SQLite before migrating or editing JSON', () => { + const sourceJson = writeState('source', false, [repo]) + writeState('target', true) + const getBuiltin = process.getBuiltinModule + vi.spyOn(process, 'getBuiltinModule').mockImplementation((name) => + name === 'node:sqlite' ? undefined : getBuiltin(name) + ) + expect(() => transfer()).toThrow('Unable to open profile state database') + expect(readFileSync(paths('source').dataFile, 'utf8')).toBe(sourceJson) + expect(existsSync(paths('source').databaseFile)).toBe(false) + }) + + it('refuses to adopt stale target JSON when a retained database backup proves missing authority', () => { + writeState('source', true, [repo]) + writeState('target', false) + const backupPath = profileStateDatabaseBackupPath( + paths('target').databaseFile, + createProfileStateDatabaseBackupId(1) + ) + writeFileSync(backupPath, 'retained recovery evidence') + expect(() => transfer()).toThrowError( + expect.objectContaining({ + code: 'profile-state-recovery-required', + backupPaths: [backupPath] + }) + ) + expect(existsSync(paths('target').databaseFile)).toBe(false) + expect(readProfileStateWithRevision('source', directory).state.repos).toHaveLength(1) + }) + + it('does not migrate a duplicate target', () => { + writeState('source', true, [repo]) + writeState('target', false, [repo]) + expect(transfer()).toMatchObject({ status: 'duplicate-target' }) + expect(existsSync(paths('target').databaseFile)).toBe(false) + }) + + it('leaves both participants untouched when the import fails before publication', () => { + writeState('source', true, [repo]) + const targetJson = writeState('target', false) + vi.spyOn(profileStateDocuments, 'importProfileStateJson').mockImplementation(() => { + throw new Error('import failure') + }) + expect(() => transfer()).toThrow('import failure') + expect(readProfileStateWithRevision('source', directory).state.repos).toHaveLength(1) + expect(readFileSync(paths('target').dataFile, 'utf8')).toBe(targetJson) + expect(existsSync(paths('target').databaseFile)).toBe(false) + expect(readdirSync(join(paths('target').dataFile, '..'))).toEqual(['orca-data.json']) + }) + + it('rejects a JSON edit during migration before publishing SQLite', () => { + writeState('source', true, [repo]) + writeState('target', false) + const originalImport = profileStateDocuments.importProfileStateJson + vi.spyOn(profileStateDocuments, 'importProfileStateJson').mockImplementation((...args) => { + const revision = originalImport(...args) + writeFileSync(paths('target').dataFile, '{"settings":{"theme":"light"}}') + return revision + }) + expect(() => transfer()).toThrow('JSON changed while importing') + expect(existsSync(paths('target').databaseFile)).toBe(false) + expect(readProfileStateWithRevision('source', directory).state.repos).toHaveLength(1) + }) + + it('keeps a valid migrated participant after export failure without moving the project', () => { + writeState('source', true, [repo]) + const targetJson = writeState('target', false) + vi.spyOn(ProfileStateSqliteAuthority.prototype, 'writeJsonExport').mockImplementation(() => { + throw new Error('export failure') + }) + expect(() => transfer()).toThrow('export failure') + expect(readProfileStateWithRevision('source', directory).state.repos).toHaveLength(1) + expect(readProfileStateWithRevision('target', directory).state.repos).toHaveLength(0) + expect(readFileSync(paths('target').dataFile, 'utf8')).toBe(targetJson) + }) + + it.each([ + [true, false], + [false, true], + [false, false] + ])( + 'replays an interrupted move with initial SQLite source=%s target=%s', + (sourceSqlite, targetSqlite) => { + writeState('source', sourceSqlite, [repo]) + writeState('target', targetSqlite) + const originalWrite = profileProjectDomainState.writeProfileProjectDomainChanges + const write = vi + .spyOn(profileProjectDomainState, 'writeProfileProjectDomainChanges') + .mockImplementation((profileId, ...rest) => { + if (profileId === 'source') { + throw new Error('source commit interrupted') + } + return originalWrite(profileId, ...rest) + }) + expect(() => transfer()).toThrow('source commit interrupted') + expect(readProfileStateWithRevision('source', directory).state.repos).toHaveLength(1) + expect(readProfileStateWithRevision('target', directory).state.repos).toHaveLength(1) + write.mockRestore() + expect(recoverPendingProfileProjectMoves(directory)).toBe(1) + expect(readProfileStateWithRevision('source', directory).state.repos).toHaveLength(0) + expect(readProfileStateWithRevision('target', directory).state.repos).toHaveLength(1) + expect(recoverPendingProfileProjectMoves(directory)).toBe(0) + } + ) +}) diff --git a/src/main/orca-profiles/profile-project-transfer-migration.ts b/src/main/orca-profiles/profile-project-transfer-migration.ts new file mode 100644 index 00000000000..1b380030c65 --- /dev/null +++ b/src/main/orca-profiles/profile-project-transfer-migration.ts @@ -0,0 +1,27 @@ +import { migrateProfileStateToSqlite } from '../persistence/profile-state/profile-state-migration' +import { getOrcaProfileDataFile, getOrcaProfileStateDatabaseFile } from './profile-storage-paths' +import type { ReadProfileStateResult } from './profile-project-state-file' +import { + readProfileProjectTransferState, + type ReadProfileProjectTransferResult +} from './profile-project-domain-state' + +/** Adopt inactive storage without running Store's active-profile listeners or secret transforms. */ +export function migrateProfileProjectTransferParticipant( + profileId: string, + userDataPath: string, + snapshot: ReadProfileStateResult +): ReadProfileProjectTransferResult { + const migrated = migrateProfileStateToSqlite({ + dataFile: getOrcaProfileDataFile(profileId, userDataPath), + databaseFile: getOrcaProfileStateDatabaseFile(profileId, userDataPath), + profileId, + expectedLegacyJson: snapshot.serialized, + serializedState: snapshot.serialized ?? '{}' + }) + try { + return readProfileProjectTransferState(profileId, userDataPath) + } finally { + migrated.authority.close() + } +} diff --git a/src/main/orca-profiles/profile-project-transfer-worktree-ids.ts b/src/main/orca-profiles/profile-project-transfer-worktree-ids.ts index 485861d748d..d8ad76ea675 100644 --- a/src/main/orca-profiles/profile-project-transfer-worktree-ids.ts +++ b/src/main/orca-profiles/profile-project-transfer-worktree-ids.ts @@ -7,6 +7,7 @@ import { isWorktreeHostIdentity } from '../../shared/worktree/host-qualified-identity' +/** Includes worktrees referenced only by sessions or UI preferences so profile transfer cannot strand their state. */ export function collectTransferWorktreeIds( state: TransferProfileState, repoId: string @@ -36,6 +37,7 @@ export function collectTransferWorktreeIds( for (const session of Object.values(state.workspaceSessionsByHostId ?? {})) { collectSessionWorktreeIds(session, repoId, ids) } + Object.keys(state.ui?.explorerDisplayRootByWorktree ?? {}).forEach(add) Object.keys(state.ui?.showDotfilesByWorktree ?? {}).forEach(add) return ids } diff --git a/src/main/orca-profiles/profile-project-transfer.test.ts b/src/main/orca-profiles/profile-project-transfer.test.ts index fc40cd03bb6..06c3cb74c63 100644 --- a/src/main/orca-profiles/profile-project-transfer.test.ts +++ b/src/main/orca-profiles/profile-project-transfer.test.ts @@ -1,4 +1,12 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' @@ -11,6 +19,50 @@ import type { PersistedState } from '../../shared/persisted-state-types' import type { Repo } from '../../shared/repo-types' import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { SshTarget } from '../../shared/ssh-types' +import { + exportProfileStateJson, + hashProfileStateJson, + importProfileStateJson +} from '../persistence/profile-state/profile-state-documents' +import { openProfileStateDatabase } from '../persistence/profile-state/profile-state-database' +import { + persistProfileProjectMoveIntent, + recoverPendingProfileProjectMoves, + type ProfileProjectMoveIntent +} from './profile-project-move-intent' +import type { ReadProfileStateResult } from './profile-project-state-file' + +function createProfileProjectMoveIntent(args: { + sourceProfileId: string + targetProfileId: string + source: ReadProfileStateResult + target: ReadProfileStateResult + sourceAfterJson: string + targetAfterJson: string +}): Extract { + if ( + args.source.revision === undefined || + args.target.revision === undefined || + args.source.serialized === undefined || + args.target.serialized === undefined + ) { + throw new Error('Legacy move fixture requires two serialized SQLite snapshots') + } + return { + version: 1, + id: '11111111-1111-1111-1111-111111111111', + sourceProfileId: args.sourceProfileId, + targetProfileId: args.targetProfileId, + expectedSourceRevision: args.source.revision, + expectedTargetRevision: args.target.revision, + sourceBeforeHash: hashProfileStateJson(args.source.serialized), + targetBeforeHash: hashProfileStateJson(args.target.serialized), + sourceAfterHash: hashProfileStateJson(args.sourceAfterJson), + targetAfterHash: hashProfileStateJson(args.targetAfterJson), + sourceAfterJson: args.sourceAfterJson, + targetAfterJson: args.targetAfterJson + } +} const testState = { dir: '' } @@ -50,13 +102,53 @@ function profileDataPath(profileId: string): string { return join(testState.dir, 'profiles', profileId, 'orca-data.json') } +function profileDatabasePath(profileId: string): string { + return join(testState.dir, 'profiles', profileId, 'profile-state.db') +} + function writeProfileState(profileId: string, state: PersistedState): void { const dataFile = profileDataPath(profileId) mkdirSync(join(dataFile, '..'), { recursive: true }) writeFileSync(dataFile, JSON.stringify(state, null, 2), 'utf-8') } +function writeProfileStateDatabase(profileId: string, state: PersistedState): void { + const databasePath = profileDatabasePath(profileId) + mkdirSync(join(databasePath, '..'), { recursive: true }) + const opened = openProfileStateDatabase(databasePath, profileId) + try { + importProfileStateJson(opened.db, JSON.stringify(state)) + } finally { + opened.db.close() + } +} + +function writeProfileStateDatabaseWithAcceptedLegacyJson(profileId: string, rawJson: string): void { + const databasePath = profileDatabasePath(profileId) + mkdirSync(join(databasePath, '..'), { recursive: true }) + const opened = openProfileStateDatabase(databasePath, profileId) + try { + importProfileStateJson(opened.db, rawJson, { + acceptedLegacyJsonHash: hashProfileStateJson(rawJson) + }) + } finally { + opened.db.close() + } +} + +function readProfileStateDatabase(profileId: string): PersistedState { + const opened = openProfileStateDatabase(profileDatabasePath(profileId), profileId) + try { + return JSON.parse(exportProfileStateJson(opened.db)) + } finally { + opened.db.close() + } +} + function readProfileState(profileId: string): PersistedState { + if (existsSync(profileDatabasePath(profileId))) { + return readProfileStateDatabase(profileId) + } return JSON.parse(readFileSync(profileDataPath(profileId), 'utf-8')) as PersistedState } @@ -324,4 +416,353 @@ describe('profile project transfer', () => { }) expect(readProfileState('work').repos.map((repo) => repo.id)).toEqual(['repo-existing']) }) + + it('transfers between SQLite-backed profiles without creating legacy JSON or touching sidecars', async () => { + const sourceState = makeState({ repos: [makeRepo()] }) + writeProfileStateDatabase('personal', sourceState) + writeProfileStateDatabase('work', makeState()) + const sidecarPath = join(testState.dir, 'profiles', 'work', 'browser-session-meta.json') + writeFileSync(sidecarPath, '{"preserve":true}', 'utf-8') + + const { transferOrcaProfileProject } = await loadTransferModule() + const result = transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'copy' + }, + testState.dir + ) + + expect(result.status).toBe('transferred') + expect(readProfileStateDatabase('work').repos).toEqual([ + expect.objectContaining({ path: '/workspace/orca' }) + ]) + expect(existsSync(profileDataPath('personal'))).toBe(false) + expect(existsSync(profileDataPath('work'))).toBe(false) + expect(readFileSync(sidecarPath, 'utf-8')).toBe('{"preserve":true}') + }) + + it('migrates the mutated target when both participants have only legacy JSON', async () => { + writeProfileState('personal', makeState({ repos: [makeRepo()] })) + writeProfileState('work', makeState()) + + const { transferOrcaProfileProject } = await loadTransferModule() + const result = transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'copy' + }, + testState.dir + ) + + expect(result.status).toBe('transferred') + expect(readProfileState('work').repos).toEqual([ + expect.objectContaining({ path: '/workspace/orca' }) + ]) + expect(existsSync(profileDatabasePath('personal'))).toBe(false) + expect(existsSync(profileDatabasePath('work'))).toBe(true) + }) + + it('fails closed when a profile has both database and legacy JSON state', async () => { + const sourceState = makeState({ repos: [makeRepo()] }) + writeProfileState('personal', sourceState) + writeProfileStateDatabase('personal', sourceState) + writeProfileState('work', makeState()) + + const { transferOrcaProfileProject } = await loadTransferModule() + expect(() => + transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'copy' + }, + testState.dir + ) + ).toThrowError(expect.objectContaining({ code: 'ambiguous_profile_state_storage' })) + }) + + it('uses SQLite when the retained legacy JSON is the accepted migration export', async () => { + const sourceState = makeState({ repos: [makeRepo()] }) + writeProfileState('personal', sourceState) + const sourceJson = readFileSync(profileDataPath('personal'), 'utf-8') + writeProfileStateDatabaseWithAcceptedLegacyJson('personal', sourceJson) + writeProfileState('work', makeState()) + + const { transferOrcaProfileProject } = await loadTransferModule() + const result = transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'copy' + }, + testState.dir + ) + + expect(result.status).toBe('transferred') + expect(readProfileStateDatabase('work').repos).toEqual([ + expect.objectContaining({ path: '/workspace/orca' }) + ]) + expect(readFileSync(profileDataPath('personal'), 'utf-8')).toBe(sourceJson) + expect(readProfileStateDatabase('personal').repos).toEqual([ + expect.objectContaining({ path: '/workspace/orca' }) + ]) + }) + + it('rejects a missing SQLite database when a retained export proves JSON is stale', async () => { + const sourceState = makeState({ repos: [makeRepo()] }) + writeProfileState('personal', sourceState) + const sourceJson = readFileSync(profileDataPath('personal'), 'utf-8') + writeProfileStateDatabaseWithAcceptedLegacyJson('personal', sourceJson) + rmSync(profileDatabasePath('personal')) + writeFileSync(`${profileDataPath('personal')}.sqlite-export.1.json`, sourceJson) + writeProfileState('work', makeState()) + + const { transferOrcaProfileProject } = await loadTransferModule() + expect(() => + transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'copy' + }, + testState.dir + ) + ).toThrowError(expect.objectContaining({ code: 'profile-state-recovery-required' })) + }) + + it.each(['-wal', '-shm', '-journal'])( + 'refuses a transfer from an orphaned %s', + async (suffix) => { + writeProfileState('personal', makeState({ repos: [makeRepo()] })) + writeProfileState('work', makeState()) + const sourceJson = readFileSync(profileDataPath('personal'), 'utf8') + const targetJson = readFileSync(profileDataPath('work'), 'utf8') + const sidecar = `${profileDatabasePath('personal')}${suffix}` + writeFileSync(sidecar, 'orphaned recovery evidence') + + const { transferOrcaProfileProject } = await loadTransferModule() + expect(() => + transferOrcaProfileProject( + { sourceProfileId: 'personal', targetProfileId: 'work', repoId: 'repo-1', mode: 'move' }, + testState.dir + ) + ).toThrow() + expect(readFileSync(profileDataPath('personal'), 'utf8')).toBe(sourceJson) + expect(readFileSync(profileDataPath('work'), 'utf8')).toBe(targetJson) + expect(readFileSync(sidecar, 'utf8')).toBe('orphaned recovery evidence') + expect(existsSync(profileDatabasePath('personal'))).toBe(false) + expect(existsSync(profileDatabasePath('work'))).toBe(false) + } + ) + + it.each(['copy', 'move'] as const)( + '%s transfers between SQLite-only profiles while retaining rollback exports', + async (mode) => { + const sourceState = makeState({ repos: [makeRepo()] }) + const targetState = makeState() + writeProfileStateDatabase('personal', sourceState) + writeProfileStateDatabase('work', targetState) + const sourceExport = JSON.stringify(sourceState) + const targetExport = JSON.stringify(targetState) + const sourceExportPath = `${profileDataPath('personal')}.sqlite-export.1.json` + const targetExportPath = `${profileDataPath('work')}.sqlite-export.1.json` + writeFileSync(sourceExportPath, sourceExport) + writeFileSync(targetExportPath, targetExport) + + const { transferOrcaProfileProject } = await loadTransferModule() + const result = transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode + }, + testState.dir + ) + + expect(result.status).toBe('transferred') + expect(readProfileStateDatabase('personal').repos).toHaveLength(mode === 'move' ? 0 : 1) + expect(readProfileStateDatabase('work').repos).toEqual([ + expect.objectContaining({ path: '/workspace/orca' }) + ]) + expect(existsSync(profileDataPath('personal'))).toBe(false) + expect(existsSync(profileDataPath('work'))).toBe(false) + expect(readFileSync(sourceExportPath, 'utf8')).toBe(sourceExport) + expect(readFileSync(targetExportPath, 'utf8')).toBe(targetExport) + } + ) + + it('fences a SQLite transfer write against the revision that was read', async () => { + writeProfileStateDatabase('work', makeState()) + + await loadTransferModule() + const stateFile = await import('./profile-project-state-file') + const observed = stateFile.readProfileStateWithRevision('work', testState.dir) + expect(observed.revision).toBeGreaterThan(0) + + const opened = openProfileStateDatabase(profileDatabasePath('work'), 'work') + try { + importProfileStateJson( + opened.db, + JSON.stringify(makeState({ settings: { ...makeState().settings, theme: 'dark' } })) + ) + } finally { + opened.db.close() + } + + expect(() => + stateFile.writeProfileState('work', testState.dir, makeState(), { + expectedRevision: observed.revision + }) + ).toThrowError(expect.objectContaining({ code: 'profile-state-revision-conflict' })) + expect(readProfileStateDatabase('work').settings.theme).toBe('dark') + }) + + it('moves between SQLite-backed profiles through a durable cross-profile intent', async () => { + writeProfileStateDatabase('personal', makeState({ repos: [makeRepo()] })) + writeProfileStateDatabase('work', makeState()) + + const { transferOrcaProfileProject } = await loadTransferModule() + const result = transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'move' + }, + testState.dir + ) + expect(result.status).toBe('transferred') + expect(readProfileStateDatabase('personal').repos).toHaveLength(0) + expect(readProfileStateDatabase('work').repos).toHaveLength(1) + expect( + readdirSync(join(testState.dir, 'profile-move-intents')).filter((file) => + file.endsWith('.json') + ) + ).toEqual([]) + }) + + it('moves between rollback-window profiles while retaining their JSON exports', async () => { + const sourceState = makeState({ repos: [makeRepo()] }) + writeProfileState('personal', sourceState) + writeProfileState('work', makeState()) + const sourceJson = readFileSync(profileDataPath('personal'), 'utf-8') + const targetJson = readFileSync(profileDataPath('work'), 'utf-8') + writeProfileStateDatabaseWithAcceptedLegacyJson('personal', sourceJson) + writeProfileStateDatabaseWithAcceptedLegacyJson('work', targetJson) + + const { transferOrcaProfileProject } = await loadTransferModule() + const result = transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'move' + }, + testState.dir + ) + + expect(result.status).toBe('transferred') + expect(readProfileStateDatabase('personal').repos).toHaveLength(0) + expect(readProfileStateDatabase('work').repos).toHaveLength(1) + expect(readFileSync(profileDataPath('personal'), 'utf-8')).toBe(sourceJson) + expect(readFileSync(profileDataPath('work'), 'utf-8')).toBe(targetJson) + }) + + it('migrates a legacy JSON target before moving a SQLite-backed project', async () => { + const sourceState = makeState({ repos: [makeRepo()] }) + writeProfileStateDatabase('personal', sourceState) + writeProfileState('work', makeState()) + const targetJson = readFileSync(profileDataPath('work'), 'utf-8') + + const { transferOrcaProfileProject } = await loadTransferModule() + expect( + transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'move' + }, + testState.dir + ) + ).toMatchObject({ status: 'transferred', mode: 'move' }) + expect(readProfileStateDatabase('personal').repos).toHaveLength(0) + expect(readProfileStateDatabase('work').repos).toHaveLength(1) + expect(readFileSync(profileDataPath('work'), 'utf-8')).toBe(targetJson) + }) + + it.each([undefined, 'prepared', 'target-committed'])( + 'replays a move after the target commit with legacy phase=%s', + async (phase) => { + writeProfileStateDatabase('personal', makeState({ repos: [makeRepo()] })) + writeProfileStateDatabase('work', makeState()) + const stateFile = await import('./profile-project-state-file') + const source = stateFile.readProfileStateWithRevision('personal', testState.dir) + const target = stateFile.readProfileStateWithRevision('work', testState.dir) + const sourceAfter = makeState() + const targetAfter = makeState({ repos: [makeRepo()] }) + const intent = createProfileProjectMoveIntent({ + sourceProfileId: 'personal', + targetProfileId: 'work', + source, + target, + sourceAfterJson: JSON.stringify(sourceAfter), + targetAfterJson: JSON.stringify(targetAfter) + }) + const historicalIntent = phase === undefined ? intent : { ...intent, phase } + persistProfileProjectMoveIntent(testState.dir, historicalIntent) + stateFile.writeProfileState('work', testState.dir, targetAfter, { + expectedRevision: target.revision + }) + + expect(recoverPendingProfileProjectMoves(testState.dir)).toBe(1) + expect(recoverPendingProfileProjectMoves(testState.dir)).toBe(0) + expect(readProfileStateDatabase('personal').repos).toHaveLength(0) + expect(readProfileStateDatabase('work').repos).toHaveLength(1) + expect( + readdirSync(join(testState.dir, 'profile-move-intents')).filter((file) => + file.endsWith('.json') + ) + ).toEqual([]) + } + ) + + it('refuses a move intent whose after-state bytes no longer match their hashes', async () => { + writeProfileStateDatabase('personal', makeState({ repos: [makeRepo()] })) + writeProfileStateDatabase('work', makeState()) + const stateFile = await import('./profile-project-state-file') + const source = stateFile.readProfileStateWithRevision('personal', testState.dir) + const target = stateFile.readProfileStateWithRevision('work', testState.dir) + const intent = createProfileProjectMoveIntent({ + sourceProfileId: 'personal', + targetProfileId: 'work', + source, + target, + sourceAfterJson: JSON.stringify(makeState()), + targetAfterJson: JSON.stringify(makeState({ repos: [makeRepo()] })) + }) + persistProfileProjectMoveIntent(testState.dir, intent) + stateFile.writeProfileState('work', testState.dir, makeState({ repos: [makeRepo()] }), { + expectedRevision: target.revision + }) + const intentPath = join(testState.dir, 'profile-move-intents', `${intent.id}.json`) + const tampered = JSON.parse(readFileSync(intentPath, 'utf8')) + tampered.sourceAfterJson = JSON.stringify( + makeState({ settings: { ...makeState().settings, theme: 'light' } }) + ) + writeFileSync(intentPath, JSON.stringify(tampered), 'utf8') + + expect(() => recoverPendingProfileProjectMoves(testState.dir)).toThrow(/intent is malformed/) + expect(readProfileStateDatabase('personal').repos).toHaveLength(1) + expect(existsSync(intentPath)).toBe(true) + }) }) diff --git a/src/main/orca-profiles/profile-project-transfer.ts b/src/main/orca-profiles/profile-project-transfer.ts index 75022d3ad65..ed7d51e6acf 100644 --- a/src/main/orca-profiles/profile-project-transfer.ts +++ b/src/main/orca-profiles/profile-project-transfer.ts @@ -3,7 +3,15 @@ import type { TransferOrcaProfileProjectResult } from '../../shared/orca-profiles' import { getOrcaProfileListState } from './profile-index-store' -import { readProfileState, writeProfileState } from './profile-project-state-file' +import { + readProfileProjectTransferState, + writeProfileProjectDomainChanges +} from './profile-project-domain-state' +import { + prepareProfileProjectDomainChanges, + type ProfileProjectDomainChanges +} from './profile-project-domain-changes' +import { createProfileProjectDomainMoveIntent } from './profile-project-domain-move-intent' import { removeSourceRepo } from './profile-project-source-removal' import { applyPayloadToTarget, @@ -11,6 +19,13 @@ import { createTransferPayload } from './profile-project-transfer-payload' import { repoPhysicalKey } from './profile-project-worktree-identity' +import { migrateProfileProjectTransferParticipant } from './profile-project-transfer-migration' +import { + persistProfileProjectMoveIntent, + recoverPendingProfileProjectMoves, + removeProfileProjectMoveIntent, + type ProfileProjectMoveIntent +} from './profile-project-move-intent' function assertKnownProfiles(args: TransferOrcaProfileProjectArgs, userDataPath: string): void { const profiles = getOrcaProfileListState(userDataPath).profiles @@ -30,14 +45,15 @@ export function transferOrcaProfileProject( args: TransferOrcaProfileProjectArgs, userDataPath: string ): TransferOrcaProfileProjectResult { + recoverPendingProfileProjectMoves(userDataPath) assertKnownProfiles(args, userDataPath) - const sourceState = readProfileState(args.sourceProfileId, userDataPath) - const targetState = readProfileState(args.targetProfileId, userDataPath) - const sourceRepo = sourceState.repos.find((repo) => repo.id === args.repoId) + let sourceSnapshot = readProfileProjectTransferState(args.sourceProfileId, userDataPath) + let targetSnapshot = readProfileProjectTransferState(args.targetProfileId, userDataPath) + const sourceRepo = sourceSnapshot.state.repos.find((repo) => repo.id === args.repoId) if (!sourceRepo) { throw new Error('unknown_source_repo') } - const duplicate = targetState.repos.find( + const duplicate = targetSnapshot.state.repos.find( (repo) => repoPhysicalKey(repo) === repoPhysicalKey(sourceRepo) ) if (duplicate) { @@ -50,6 +66,24 @@ export function transferOrcaProfileProject( } } + let moveIntent: ProfileProjectMoveIntent | undefined + if (targetSnapshot.revision === undefined) { + targetSnapshot = migrateProfileProjectTransferParticipant( + args.targetProfileId, + userDataPath, + targetSnapshot + ) + } + if (args.mode === 'move' && sourceSnapshot.revision === undefined) { + sourceSnapshot = migrateProfileProjectTransferParticipant( + args.sourceProfileId, + userDataPath, + sourceSnapshot + ) + } + + const sourceState = sourceSnapshot.state + const targetState = targetSnapshot.state const targetRepo = createTargetRepo(sourceRepo, targetState, args.mode === 'copy') const payload = createTransferPayload({ sourceState, @@ -57,14 +91,44 @@ export function transferOrcaProfileProject( targetRepo, includeSessions: args.mode === 'move' }) - writeProfileState(args.targetProfileId, userDataPath, applyPayloadToTarget(targetState, payload)) - if (args.mode === 'move') { - writeProfileState( - args.sourceProfileId, - userDataPath, - removeSourceRepo(sourceState, sourceRepo.id) + const targetAfterState = applyPayloadToTarget(targetState, payload) + const sourceAfterState = + args.mode === 'move' ? removeSourceRepo(sourceState, sourceRepo.id) : undefined + if (targetSnapshot.documents === undefined || targetSnapshot.revision === undefined) { + throw new Error('Profile transfer requires an established SQLite target') + } + const targetChanges = prepareProfileProjectDomainChanges( + targetSnapshot.revision, + targetSnapshot.documents, + targetAfterState + ) + let sourceChanges: ProfileProjectDomainChanges | undefined + if (sourceAfterState !== undefined) { + if (sourceSnapshot.documents === undefined || sourceSnapshot.revision === undefined) { + throw new Error('Profile move requires an established SQLite source') + } + sourceChanges = prepareProfileProjectDomainChanges( + sourceSnapshot.revision, + sourceSnapshot.documents, + sourceAfterState ) } + if (sourceChanges !== undefined) { + moveIntent = createProfileProjectDomainMoveIntent({ + sourceProfileId: args.sourceProfileId, + targetProfileId: args.targetProfileId, + source: sourceChanges, + target: targetChanges + }) + persistProfileProjectMoveIntent(userDataPath, moveIntent) + } + writeProfileProjectDomainChanges(args.targetProfileId, userDataPath, targetChanges) + if (sourceChanges !== undefined) { + writeProfileProjectDomainChanges(args.sourceProfileId, userDataPath, sourceChanges) + if (moveIntent) { + removeProfileProjectMoveIntent(userDataPath, moveIntent.id) + } + } return { status: 'transferred', mode: args.mode, diff --git a/src/main/orca-profiles/profile-storage-paths.ts b/src/main/orca-profiles/profile-storage-paths.ts index 81dc990b0e1..1e8de1a367e 100644 --- a/src/main/orca-profiles/profile-storage-paths.ts +++ b/src/main/orca-profiles/profile-storage-paths.ts @@ -1,12 +1,17 @@ import { getAppEnvironment } from '../../shared/app-environment' +import { + getOrcaProfileDataFile as getSharedOrcaProfileDataFile, + getOrcaProfileStateDatabaseFile as getSharedOrcaProfileStateDatabaseFile +} from '../../shared/profile-state-storage-paths' +import { hasProfileStateDatabaseFiles } from '../persistence/profile-state/profile-state-storage-classification' import { join } from 'node:path' const LEGACY_DATA_FILE_NAME = 'orca-data.json' const LEGACY_BROWSER_SESSION_META_FILE_NAME = 'browser-session-meta.json' const PROFILE_INDEX_FILE_NAME = 'orca-profile-index.json' -const PROFILE_DATA_FILE_NAME = 'orca-data.json' const PROFILE_BROWSER_SESSION_META_FILE_NAME = 'browser-session-meta.json' const PROFILE_DIRECTORY_NAME = 'profiles' +const PROFILE_MOVE_INTENT_DIRECTORY_NAME = 'profile-move-intents' export const LEGACY_BACKUP_COUNT = 5 @@ -31,6 +36,11 @@ export function getOrcaProfilesDirectory(userDataPath = getProfileUserDataPath() return join(userDataPath, PROFILE_DIRECTORY_NAME) } +/** Durable cross-profile move intents live outside either profile database. */ +export function getOrcaProfileMoveIntentDirectory(userDataPath = getProfileUserDataPath()): string { + return join(userDataPath, PROFILE_MOVE_INTENT_DIRECTORY_NAME) +} + export function getOrcaProfileDirectory( profileId: string, userDataPath = getProfileUserDataPath() @@ -42,7 +52,26 @@ export function getOrcaProfileDataFile( profileId: string, userDataPath = getProfileUserDataPath() ): string { - return join(getOrcaProfileDirectory(profileId, userDataPath), PROFILE_DATA_FILE_NAME) + return getSharedOrcaProfileDataFile(profileId, userDataPath) +} + +/** + * Return the future profile-state database path without changing the legacy + * JSON path used by the current Store and its sidecars. + */ +export function getOrcaProfileStateDatabaseFile( + profileId: string, + userDataPath = getProfileUserDataPath() +): string { + return getSharedOrcaProfileStateDatabaseFile(profileId, userDataPath) +} + +export function hasOrcaProfileStateDatabase( + profileId: string, + userDataPath = getProfileUserDataPath() +): boolean { + const databaseFile = getOrcaProfileStateDatabaseFile(profileId, userDataPath) + return hasProfileStateDatabaseFiles(databaseFile) } export function getOrcaProfileBrowserSessionMetaFile( diff --git a/src/main/orca-profiles/profile-telemetry-consent-seed.test.ts b/src/main/orca-profiles/profile-telemetry-consent-seed.test.ts new file mode 100644 index 00000000000..aad1bd9c812 --- /dev/null +++ b/src/main/orca-profiles/profile-telemetry-consent-seed.test.ts @@ -0,0 +1,118 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { removeTreeSync } from '../../shared/windows-transient-lock-removal' +import { + acquireProfileStateMaintenance, + acquireProfileStateRuntimeAdmission +} from '../persistence/profile-state/profile-state-access' +import * as database from '../persistence/profile-state/profile-state-database' +import { exportProfileStateJson } from '../persistence/profile-state/profile-state-documents' +import { + getOrcaProfileDataFile, + getOrcaProfileStateDatabaseFile, + seedNewOrcaProfileTelemetryConsent +} from './profile-index-store' + +const telemetry = { + optedIn: false, + installId: 'retained-install-id', + existedBeforeTelemetryRelease: true +} +let root: string +const profileId = 'new-profile' + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-profile-consent-')) +}) + +afterEach(() => { + vi.restoreAllMocks() + removeTreeSync(root) +}) + +describe('new profile consent seeding', () => { + it('inherits consent and install identity directly into SQLite alongside the active runtime', () => { + const runtime = acquireProfileStateRuntimeAdmission(root) + try { + seedNewOrcaProfileTelemetryConsent(profileId, telemetry, root) + const opened = database.openProfileStateDatabaseReadOnly( + getOrcaProfileStateDatabaseFile(profileId, root), + profileId + ) + try { + expect(JSON.parse(exportProfileStateJson(opened.db))).toEqual({ settings: { telemetry } }) + } finally { + opened.db.close() + } + expect(existsSync(getOrcaProfileDataFile(profileId, root))).toBe(false) + expect(() => runtime.assertActive()).not.toThrow() + } finally { + runtime.release() + } + const maintenance = acquireProfileStateMaintenance(root) + maintenance.release() + }) + + it('preserves established SQLite consent on a repeated seed', () => { + seedNewOrcaProfileTelemetryConsent(profileId, telemetry, root) + seedNewOrcaProfileTelemetryConsent(profileId, { ...telemetry, installId: 'replacement' }, root) + const opened = database.openProfileStateDatabaseReadOnly( + getOrcaProfileStateDatabaseFile(profileId, root), + profileId + ) + try { + expect(JSON.parse(exportProfileStateJson(opened.db))).toEqual({ settings: { telemetry } }) + } finally { + opened.db.close() + } + }) + + it('preserves existing JSON as input for its later import', () => { + const path = getOrcaProfileDataFile(profileId, root) + const original = '{"settings":{"telemetry":{"installId":"older"}}}' + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, original) + + seedNewOrcaProfileTelemetryConsent(profileId, telemetry, root) + + expect(readFileSync(path, 'utf8')).toBe(original) + expect(existsSync(getOrcaProfileStateDatabaseFile(profileId, root))).toBe(false) + }) + + it('refuses an incapable runtime before admission or profile creation', () => { + vi.spyOn(database, 'isProfileStateSqliteAvailable').mockReturnValue(false) + expect(() => seedNewOrcaProfileTelemetryConsent(profileId, telemetry, root)).toThrow( + 'bundled Orca runtime' + ) + expect(existsSync(join(root, '.profile-state-access'))).toBe(false) + expect(existsSync(join(root, 'profiles'))).toBe(false) + }) + + it('refuses seeding while maintenance owns the root', () => { + const maintenance = acquireProfileStateMaintenance(root) + try { + expect(() => seedNewOrcaProfileTelemetryConsent(profileId, telemetry, root)).toThrow() + expect(existsSync(getOrcaProfileStateDatabaseFile(profileId, root))).toBe(false) + } finally { + maintenance.release() + } + }) + + it.each(['orca-data.json.bak.0', 'orca-data.json.sqlite-export.1.json', 'profile-state.db-wal'])( + 'retains missing-authority evidence in %s', + (name) => { + const directory = dirname(getOrcaProfileDataFile(profileId, root)) + mkdirSync(directory, { recursive: true }) + const evidence = join(directory, name) + writeFileSync(evidence, 'retained recovery evidence') + + expect(() => seedNewOrcaProfileTelemetryConsent(profileId, telemetry, root)).toThrow() + + expect(existsSync(getOrcaProfileDataFile(profileId, root))).toBe(false) + expect(existsSync(getOrcaProfileStateDatabaseFile(profileId, root))).toBe(false) + expect(readFileSync(evidence, 'utf8')).toBe('retained recovery evidence') + } + ) +}) diff --git a/src/main/orca-profiles/profile-telemetry-consent-seed.ts b/src/main/orca-profiles/profile-telemetry-consent-seed.ts new file mode 100644 index 00000000000..c73a6de63fa --- /dev/null +++ b/src/main/orca-profiles/profile-telemetry-consent-seed.ts @@ -0,0 +1,42 @@ +import { existsSync } from 'node:fs' +import type { GlobalSettings } from '../../shared/global-settings-types' +import { acquireProfileStateRuntimeAdmission } from '../persistence/profile-state/profile-state-access' +import { isProfileStateSqliteAvailable } from '../persistence/profile-state/profile-state-database' +import { migrateProfileStateToSqlite } from '../persistence/profile-state/profile-state-migration' +import { + getOrcaProfileDataFile, + getOrcaProfileStateDatabaseFile, + getProfileUserDataPath +} from './profile-storage-paths' + +// Keep the active install's consent and anonymous identity when creating another profile. +export function seedNewOrcaProfileTelemetryConsent( + profileId: string, + telemetry: GlobalSettings['telemetry'], + userDataPath = getProfileUserDataPath() +): void { + if (!telemetry) { + return + } + if (!isProfileStateSqliteAvailable()) { + throw new Error('Creating profile state requires the bundled Orca runtime.') + } + const admission = acquireProfileStateRuntimeAdmission(userDataPath) + try { + const dataFile = getOrcaProfileDataFile(profileId, userDataPath) + const databaseFile = getOrcaProfileStateDatabaseFile(profileId, userDataPath) + if (existsSync(dataFile) || existsSync(databaseFile)) { + return + } + const migrated = migrateProfileStateToSqlite({ + dataFile, + databaseFile, + profileId, + expectedLegacyJson: undefined, + serializedState: JSON.stringify({ settings: { telemetry } }) + }) + migrated.authority.close() + } finally { + admission.release() + } +} diff --git a/src/main/orcad/main-preflight-order.test.ts b/src/main/orcad/main-preflight-order.test.ts index bd130bfb33f..e746be8cc5e 100644 --- a/src/main/orcad/main-preflight-order.test.ts +++ b/src/main/orcad/main-preflight-order.test.ts @@ -1,11 +1,35 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + ORCAD_PROFILE_PREFLIGHT_FLAG, + ORCAD_STARTUP_PREFLIGHT_FLAG +} from '../../shared/orcad-profile-preflight' /** * The precondition is only worth anything if it runs first. A loader failure is not * catchable, so a preflight that lands after `main()` has already reached * `await import('../ipc/pty')` prevents nothing. */ -const order: string[] = [] +const { order, profileProbe } = vi.hoisted(() => { + const order: string[] = [] + return { order, profileProbe: vi.fn(async () => {}) } +}) + +vi.mock('./orcad-bundled-runtime', () => ({ handoffToBundledOrcad: () => false })) +vi.mock('./orcad-profile-preflight', () => ({ + preflightBundledOrcadStartup: async () => { + order.push('profile-admission') + }, + runOrcadProfilePreflight: profileProbe +})) + +beforeEach(() => { + vi.resetModules() + order.length = 0 +}) +afterEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() +}) vi.mock('./orcad-native-preflight', () => ({ runOrcadNativePreflight: () => { @@ -21,10 +45,23 @@ vi.mock('./orcad-entry', () => ({ })) describe('orcad entry', () => { + it.each([ + { flag: ORCAD_PROFILE_PREFLIGHT_FLAG, nativeFeatures: true }, + { flag: ORCAD_STARTUP_PREFLIGHT_FLAG, nativeFeatures: false } + ])( + 'runs the selected disposable probe without starting a server: $flag', + async ({ flag, nativeFeatures }) => { + vi.spyOn(process, 'argv', 'get').mockReturnValue(['runtime', 'orcad.js', flag, 'nonce']) + await import('./main') + expect(profileProbe).toHaveBeenCalledExactlyOnceWith('nonce', { nativeFeatures }) + expect(order).toEqual([]) + } + ) + it('runs the native preflight before starting the runtime', async () => { await import('./main') await vi.waitFor(() => expect(order).toContain('main')) - expect(order).toEqual(['preflight', 'main']) + expect(order).toEqual(['profile-admission', 'preflight', 'main']) }) }) diff --git a/src/main/orcad/main.ts b/src/main/orcad/main.ts index 73a973c7b24..79fc667fd22 100644 --- a/src/main/orcad/main.ts +++ b/src/main/orcad/main.ts @@ -2,6 +2,12 @@ import process from 'node:process' import { main, resolveOrcadExitCode } from './orcad-entry' import { runOrcadNativePreflight } from './orcad-native-preflight' +import { + ORCAD_PROFILE_PREFLIGHT_FLAG, + ORCAD_STARTUP_PREFLIGHT_FLAG +} from '../../shared/orcad-profile-preflight' +import { preflightBundledOrcadStartup, runOrcadProfilePreflight } from './orcad-profile-preflight' +import { handoffToBundledOrcad } from './orcad-bundled-runtime' // Why exit before the preflight: reaching this line means the whole module graph resolved // under plain Node, which is all the build guard needs to prove. Probing natives or @@ -16,12 +22,33 @@ if (process.argv.includes('--orcad-smoke-load-check')) { // evaluated before this statement, so the guarantee is that no module in the graph // requires node-pty at import time — which the bundle's lazy `require("node-pty")` in // local-pty-provider satisfies. See ./node-pty-precondition.ts for why a child process. -runOrcadNativePreflight() - -main().catch((error: unknown) => { +function failStartup(error: unknown): void { console.error('orcad: failed to start:', error) // Why a resolved code and not a bare 1: a data-root or bind-address refusal is a // configuration fault that restarting cannot fix, and a supervisor needs to tell the two // apart to avoid restart-spinning on it. process.exit(resolveOrcadExitCode(error)) -}) +} + +try { + if (!handoffToBundledOrcad()) { + const flag = process.argv[2] + if ( + (flag === ORCAD_PROFILE_PREFLIGHT_FLAG || flag === ORCAD_STARTUP_PREFLIGHT_FLAG) && + process.argv.length === 4 + ) { + void runOrcadProfilePreflight(process.argv[3], { + nativeFeatures: flag === ORCAD_PROFILE_PREFLIGHT_FLAG + }).catch(failStartup) + } else { + void preflightBundledOrcadStartup() + .then(() => { + runOrcadNativePreflight() + return main() + }) + .catch(failStartup) + } + } +} catch (error) { + failStartup(error) +} diff --git a/src/main/orcad/orcad-artifact-identity.ts b/src/main/orcad/orcad-artifact-identity.ts new file mode 100644 index 00000000000..0049ef10ffa --- /dev/null +++ b/src/main/orcad/orcad-artifact-identity.ts @@ -0,0 +1,41 @@ +import { createHash } from 'node:crypto' +import { createReadStream, existsSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { z } from 'zod' +import { + ORCAD_BUILD_TARGET_FILENAME, + ORCAD_VERSION, + orcadArtifactFilenames, + orcadArtifactHashPrefix +} from '../../shared/orcad-artifacts' +import { ORCAD_BUN_TARGETS } from '../../shared/orcad-bun-runtime' +import { orcadAgentBrowserNativeName } from '../../shared/orcad-agent-browser-name' + +/** Hash installed bytes in the build's order; a version marker is not proof of delivery. */ +export async function readOrcadArtifactIdentity(directory: string): Promise { + const target = z + .enum(ORCAD_BUN_TARGETS) + .parse((await readFile(join(directory, ORCAD_BUILD_TARGET_FILENAME), 'utf8')).trim()) + const platform = target.startsWith('win32-') + ? 'win32' + : target.startsWith('darwin-') + ? 'darwin' + : 'linux' + const browser = orcadAgentBrowserNativeName( + platform, + target.split('-')[1] ?? '', + target.endsWith('-musl') ? 'musl' : 'glibc' + ) + const filenames = orcadArtifactFilenames(target) + if (existsSync(join(directory, browser))) { + filenames.push(browser) + } + const hash = createHash('sha256').update(orcadArtifactHashPrefix(target)) + for (const filename of filenames) { + for await (const chunk of createReadStream(join(directory, filename))) { + hash.update(chunk) + } + } + return `${ORCAD_VERSION}+${hash.digest('hex').slice(0, 12)}` +} diff --git a/src/main/orcad/orcad-artifact-preflight.test.ts b/src/main/orcad/orcad-artifact-preflight.test.ts new file mode 100644 index 00000000000..f48a1436a92 --- /dev/null +++ b/src/main/orcad/orcad-artifact-preflight.test.ts @@ -0,0 +1,66 @@ +import { randomUUID } from 'node:crypto' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { orcadArtifactFilenames } from '../../shared/orcad-artifacts' +import { runOrcadProfilePreflight } from './orcad-profile-preflight' +import { readOrcadArtifactIdentity } from './orcad-artifact-identity' +import { resolveOrcadExitCode } from './orcad-exit-code' + +const fixture = vi.hoisted(() => ({ + directory: '', + sqlite: vi.fn(async () => ({ sqliteVersion: '3.53.2', revision: 1 })) +})) +vi.mock('./orcad-app-paths', () => ({ resolveOrcadInstallRoot: () => fixture.directory })) +vi.mock('../persistence/profile-state/profile-state-runtime-preflight', () => ({ + preflightProfileStateRuntime: fixture.sqlite +})) +vi.mock('./orcad-bun-native-preflight', () => ({ + preflightOrcadBunNativeRuntime: vi.fn(async () => {}) +})) + +beforeEach(async () => { + fixture.directory = await mkdtemp(join(tmpdir(), 'orcad-artifact-preflight-')) + for (const filename of orcadArtifactFilenames('linux-x64-glibc')) { + const path = join(fixture.directory, filename) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, filename === '.build-target' ? 'linux-x64-glibc\n' : filename) + } + vi.spyOn(console, 'log').mockImplementation(() => {}) +}) +afterEach(async () => { + vi.restoreAllMocks() + vi.clearAllMocks() + await rm(fixture.directory, { recursive: true, force: true }) +}) + +describe('installed artifact admission', () => { + it('qualifies build output before its version marker is published', async () => { + await runOrcadProfilePreflight(randomUUID()) + expect(fixture.sqlite).toHaveBeenCalledOnce() + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining(await readOrcadArtifactIdentity(fixture.directory)) + ) + }) + + it.each(['.build-target', 'node_modules/@parcel/watcher/watcher.node', 'bun-runtime'])( + 'refuses a missing %s as configuration before any profile probe', + async (filename) => { + await rm(join(fixture.directory, filename)) + const error = await runOrcadProfilePreflight(randomUUID()).catch( + (failure: unknown) => failure + ) + expect(resolveOrcadExitCode(error)).toBe(78) + expect(fixture.sqlite).not.toHaveBeenCalled() + expect(console.log).not.toHaveBeenCalled() + } + ) + + it('refuses a malformed target even though all named files exist', async () => { + await writeFile(join(fixture.directory, '.build-target'), 'not-a-runtime-target') + const error = await runOrcadProfilePreflight(randomUUID()).catch((failure: unknown) => failure) + expect(resolveOrcadExitCode(error)).toBe(78) + expect(fixture.sqlite).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/orcad/orcad-bun-launcher.integration.test.ts b/src/main/orcad/orcad-bun-launcher.integration.test.ts new file mode 100644 index 00000000000..8ee18d53293 --- /dev/null +++ b/src/main/orcad/orcad-bun-launcher.integration.test.ts @@ -0,0 +1,298 @@ +import { build } from 'esbuild' +import { existsSync, readFileSync } from 'node:fs' +import { copyFile, mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { spawnProcess } from '../../shared/child-process/run-process' +import { orcadBunRuntimeFilename } from '../../shared/orcad-artifacts' +import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' +import { removeTreeSync } from '../../shared/windows-transient-lock-removal' + +const runtimePath = + process.env.BUN_EXECUTABLE ?? resolve('out/orcad', orcadBunRuntimeFilename(process.platform)) +const nodePath = + process.env.ORCA_TEST_NODE_EXECUTABLE ?? (process.versions.bun ? 'node' : process.execPath) +let directory = '' +const children = new Set>() +const runtimes = new Set() + +describe.skipIf(!existsSync(runtimePath))('real Bun launcher lifecycle', () => { + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-bun-launcher-')) + await copyFile(runtimePath, join(directory, orcadBunRuntimeFilename(process.platform))) + await writeFile(join(directory, '.build-target'), `${process.platform}-${process.arch}\n`) + await build({ + // Record entry before imports without relying on either process's stdout. + banner: { + js: ` + function traceLauncherPhase(phase) { + const runtime = process.versions.bun ? 'bun' : 'node' + try { + require('node:fs').appendFileSync(process.env.ORCA_TEST_PHASES + '.' + runtime, + JSON.stringify({ at: Date.now(), phase, pid: process.pid, runtime, arch: process.arch }) + '\\n') + } catch (error) { + console.error('launcher fixture trace failed:', error) + } + } + traceLauncherPhase('entry') + process.once('exit', code => traceLauncherPhase('exit:' + code)) + ` + }, + stdin: { + contents: ` + import {handoffToBundledOrcad, OrcadBundledRuntimeError} from './src/main/orcad/orcad-bundled-runtime' + import {installOrcadShutdownSignals} from './src/main/orcad/orcad-lifecycle' + import {resolveOrcadExitCode} from './src/main/orcad/orcad-exit-code' + import {writeFile} from 'node:fs/promises' + if (!process.versions.bun) { + traceLauncherPhase('before-handoff') + if (!handoffToBundledOrcad()) throw new Error('Missing bundled runtime') + traceLauncherPhase('after-handoff') + process.on('message', signal => process.emit(signal)) + } else { + traceLauncherPhase('before-booting') + console.log('booting:' + process.pid) + console.log('runtime:' + process.versions.bun) + console.log('channel-env:' + (process.env.ORCA_BUNDLED_LAUNCHER_CHANNEL ?? 'absent')) + traceLauncherPhase('booting-written') + process.on('exit', code => console.log('runtime-exit:' + code)) + const keepalive = setInterval(() => {}, 1_000) + const install = async () => { + if (process.env.ORCA_TEST_FAIL_STARTUP === '1') { + const startup = new Promise((_, reject) => setTimeout(() => + reject(new OrcadBundledRuntimeError('startup configuration failed')), 100)) + installOrcadShutdownSignals(async () => (await startup).stop()) + await startup + return + } + installOrcadShutdownSignals(async () => { + console.log('flushing') + clearInterval(keepalive) + if (process.env.ORCA_TEST_STALL === '1') await new Promise(() => {}) + await new Promise(resolve => setTimeout(resolve, 150)) + await writeFile(process.env.ORCA_TEST_DONE, 'flushed') + }, process.env.ORCA_TEST_STALL === '1' ? 100 : undefined) + console.log('ready') + } + // Exercise the shutdown observer before the outer startup-failure reporter. + const start = () => Promise.resolve().then(install) + .catch(error => setImmediate(() => process.exit(resolveOrcadExitCode(error)))) + if (process.env.ORCA_TEST_DELAY_INSTALL === '1') setTimeout(start, 300) + else start() + } + `, + resolveDir: process.cwd(), + loader: 'ts' + }, + outfile: join(directory, 'orcad.js'), + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs' + }) + }) + + afterEach(() => { + for (const child of children) { + child.kill('SIGKILL') + } + children.clear() + for (const pid of runtimes) { + try { + process.kill(pid, 'SIGKILL') + } catch {} + } + runtimes.clear() + removeTreeSync(directory) + }) + + function launch( + options: { + direct?: boolean + nohup?: boolean + delay?: boolean + stall?: boolean + failStartup?: boolean + } = {} + ) { + const runtime = options.direct + ? join(directory, orcadBunRuntimeFilename(process.platform)) + : nodePath + const startedAt = Date.now() + const events: { at: number; event: string }[] = [] + const record = (event: string): void => { + if (events.length < 16) { + events.push({ at: Date.now(), event }) + } + } + const child = spawnProcess({ + program: options.nohup ? 'nohup' : runtime, + args: [...(options.nohup ? [runtime] : []), join(directory, 'orcad.js')], + env: { + ...process.env, + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_TEST_DONE: join(directory, 'done'), + ORCA_TEST_PHASES: join(directory, 'phases'), + ORCA_TEST_DELAY_INSTALL: options.delay ? '1' : '0', + ORCA_TEST_STALL: options.stall ? '1' : '0', + ORCA_TEST_FAIL_STARTUP: options.failStartup ? '1' : '0' + }, + detached: process.platform !== 'win32', + stdio: ['ignore', 'pipe', 'pipe', 'ipc'] + }) + children.add(child) + child.once('spawn', () => record('spawn')) + let closed = false + child.once('close', () => { + record('close') + closed = true + }) + let output = '' + const capture = (chunk: Buffer): void => { + output += chunk.toString() + const pid = /booting:(\d+)/.exec(output)?.[1] + if (pid && !output.includes('runtime-exit:')) { + runtimes.add(Number(pid)) + } else if (pid) { + runtimes.delete(Number(pid)) + } + } + const streams = [ + ['stdout', child.stdout], + ['stderr', child.stderr] + ] as const + for (const [name, stream] of streams) { + stream.on('data', capture) + stream.once('data', () => record(`${name}:data`)) + stream.once('end', () => record(`${name}:end`)) + stream.once('close', () => record(`${name}:close`)) + } + const exit = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve, reject) => { + child.once('error', (error) => { + record(`error:${error.message}`) + reject(error) + }) + child.once('exit', (code, signal) => { + record(`exit:${code}:${signal}`) + children.delete(child) + resolve({ code, signal }) + }) + } + ) + const diagnostics = () => ({ + startedAt, + elapsedMs: Date.now() - startedAt, + events, + pid: child.pid, + exitCode: child.exitCode, + signalCode: child.signalCode, + connected: child.connected, + closed, + streams: streams.map(([name, stream]) => ({ + name, + ended: stream.readableEnded, + destroyed: stream.destroyed, + error: stream.errored?.message, + bufferedBytes: stream.readableLength + })), + phases: ['node', 'bun'].map((runtime) => { + try { + return { + runtime, + trace: readFileSync(join(directory, `phases.${runtime}`), 'utf8').slice(0, 4096) + } + } catch (error) { + return { runtime, error: String(error) } + } + }) + }) + return { child, output: () => output, exit, isClosed: () => closed, diagnostics } + } + + it.each([false, true])( + 'drains Bun after its launcher is killed (startup pending: %s)', + async (delay) => { + const h = launch({ delay }) + await vi.waitFor( + () => { + expect(h.output()).toContain(delay ? 'booting:' : 'ready') + expect(h.output()).toContain(`runtime:${ORCAD_BUN_VERSION}`) + expect(h.output()).toContain('channel-env:absent') + }, + { timeout: 5_000 } + ) + h.child.kill('SIGKILL') + await h.exit + await vi.waitFor( + async () => expect(await readFile(join(directory, 'done'), 'utf8')).toBe('flushed'), + { timeout: 5_000 } + ) + expect(h.output().match(/flushing/g)).toHaveLength(1) + await vi.waitFor(() => expect(h.output()).toContain('runtime-exit:0')) + await vi.waitFor(() => expect(h.isClosed()).toBe(true), { timeout: 5_000 }) + } + ) + + it.skipIf(process.platform === 'win32').each([false, true])( + 'survives nohup hangups and drains on TERM (direct Bun: %s)', + async (direct) => { + const h = launch({ direct, nohup: true }) + await vi.waitFor(() => expect(h.output()).toContain('ready'), { timeout: 5_000 }) + if (!h.child.pid) { + throw new Error('Missing launcher pid') + } + process.kill(-h.child.pid, 'SIGHUP') + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(h.child.exitCode).toBeNull() + expect(h.child.signalCode).toBeNull() + expect(h.output()).not.toContain('flushing') + h.child.kill('SIGTERM') + expect(await h.exit).toEqual({ code: 0, signal: null }) + expect(await readFile(join(directory, 'done'), 'utf8')).toBe('flushed') + await vi.waitFor(() => expect(h.isClosed()).toBe(true), { timeout: 5_000 }) + } + ) + + it('keeps an unfinished shutdown alive until its failure deadline', async () => { + const h = launch({ stall: true }) + await vi.waitFor(() => expect(h.output()).toContain('ready'), { timeout: 5_000 }) + h.child.kill('SIGKILL') + await h.exit + await vi.waitFor(() => expect(h.output()).toContain('runtime-exit:1')) + expect(h.output()).toContain('exceeded 100ms') + await vi.waitFor(() => expect(h.isClosed()).toBe(true), { timeout: 5_000 }) + }) + + it.each(process.platform === 'win32' ? [false, true] : [false])( + 'forwards launcher stop requests and drains once (startup pending: %s)', + async (delay) => { + const h = launch({ delay }) + await vi.waitFor(() => expect(h.output()).toContain(delay ? 'booting:' : 'ready'), { + timeout: 5_000 + }) + h.child.send('SIGINT') + h.child.send('SIGTERM') + expect(await h.exit).toEqual({ code: 0, signal: null }) + expect(await readFile(join(directory, 'done'), 'utf8')).toBe('flushed') + expect(h.output().match(/flushing/g)).toHaveLength(1) + await vi.waitFor(() => expect(h.isClosed()).toBe(true), { timeout: 5_000 }) + } + ) + + it('preserves a startup configuration verdict after early launcher loss', async () => { + const h = launch({ delay: true, failStartup: true }) + try { + await vi.waitFor(() => expect(h.output()).toContain('booting:'), { timeout: 5_000 }) + } catch (error) { + console.error('Bun launcher startup diagnostics:', JSON.stringify(h.diagnostics())) + throw error + } + h.child.kill('SIGKILL') + await h.exit + await vi.waitFor(() => expect(h.output()).toContain('runtime-exit:78'), { timeout: 5_000 }) + expect(h.output()).toContain('shutdown after launcher disconnect failed') + await vi.waitFor(() => expect(h.isClosed()).toBe(true), { timeout: 5_000 }) + }) +}) diff --git a/src/main/orcad/orcad-bun-native-preflight.test.ts b/src/main/orcad/orcad-bun-native-preflight.test.ts new file mode 100644 index 00000000000..3e6df8402c1 --- /dev/null +++ b/src/main/orcad/orcad-bun-native-preflight.test.ts @@ -0,0 +1,165 @@ +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + WatcherProcessCallback, + WatcherProcessHooks +} from '../ipc/parcel-watcher-process-subscription' +import { preflightOrcadBunNativeRuntime } from './orcad-bun-native-preflight' + +const fixture = vi.hoisted(() => ({ + temp: vi.fn(), + pty: vi.fn(), + available: vi.fn(), + startTime: vi.fn(), + rows: vi.fn(), + subscribe: vi.fn(), + unsubscribe: vi.fn(), + dispose: vi.fn(), + write: vi.fn(), + remove: vi.fn() +})) +vi.mock('../daemon/pty-subprocess/spawn-preflight', () => ({ runPtySpawnHealthProbe: fixture.pty })) +vi.mock('../windows/windows-process-table', () => ({ + isWindowsProcessTableAvailable: fixture.available, + isWindowsProcessStartTimeAvailable: fixture.startTime, + readWindowsProcessIdentityTableFresh: fixture.rows +})) +vi.mock('node:fs/promises', () => ({ + mkdtemp: fixture.temp, + writeFile: fixture.write, + rm: fixture.remove +})) +vi.mock('../ipc/parcel-watcher-process-supervisor', () => ({ + WatcherProcessSupervisor: class { + subscribe = fixture.subscribe + dispose = fixture.dispose + } +})) + +beforeEach(() => { + vi.useFakeTimers() + fixture.temp.mockResolvedValue('/temp/probe') + fixture.pty.mockResolvedValue(undefined) + fixture.available.mockReturnValue(true) + fixture.startTime.mockReturnValue(true) + fixture.rows.mockResolvedValue([{ pid: process.pid, creationTimeMs: Date.now() - 1_000 }]) + fixture.unsubscribe.mockResolvedValue(undefined) + fixture.remove.mockResolvedValue(undefined) + fixture.subscribe.mockImplementation( + async (directory: string, callback: WatcherProcessCallback) => { + fixture.write.mockImplementation(async () => + callback(null, [{ path: join(directory, 'ready'), type: 'create' }]) + ) + return { unsubscribe: fixture.unsubscribe } + } + ) +}) +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + vi.resetAllMocks() +}) + +describe('bundled native readiness', () => { + it('keeps runtime startup independent of PTY or watcher probe availability', async () => { + fixture.pty.mockRejectedValue(new Error('PTY spawn health check timed out')) + fixture.subscribe.mockRejectedValue(new Error('ENOSPC: watch limit reached')) + await preflightOrcadBunNativeRuntime({ nativeFeatures: false }) + expect(fixture.pty).not.toHaveBeenCalled() + expect(fixture.subscribe).not.toHaveBeenCalled() + }) + + it('still requires Windows ownership support on normal startup', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + fixture.startTime.mockReturnValue(false) + await expect(preflightOrcadBunNativeRuntime({ nativeFeatures: false })).rejects.toThrow( + 'Windows process table' + ) + }) + + it('does not admit a failed PTY in explicit qualification', async () => { + fixture.pty.mockRejectedValue(new Error('PTY spawn health check timed out')) + await expect(preflightOrcadBunNativeRuntime()).rejects.toThrow( + 'PTY spawn health check timed out' + ) + }) + + it('awaits actual watcher delivery and unsubscribe before disposing temporary state', async () => { + await preflightOrcadBunNativeRuntime() + expect(fixture.pty).toHaveBeenCalledOnce() + expect(fixture.unsubscribe).toHaveBeenCalledOnce() + expect(fixture.dispose).toHaveBeenCalledOnce() + expect(fixture.remove).toHaveBeenCalledWith('/temp/probe', { recursive: true, force: true }) + expect(vi.getTimerCount()).toBe(0) + }) + + it('cancels a subscribe blocked on capacity by the same readiness deadline', async () => { + fixture.subscribe.mockImplementation( + ( + _directory: string, + _callback: WatcherProcessCallback, + _options: unknown, + hooks: WatcherProcessHooks + ) => + new Promise((_resolve, reject) => { + hooks.signal?.addEventListener('abort', () => reject(hooks.signal?.reason), { + once: true + }) + }) + ) + const readiness = preflightOrcadBunNativeRuntime() + const rejected = expect(readiness).rejects.toThrow('readiness timed out') + await vi.advanceTimersByTimeAsync(5_000) + await rejected + expect(fixture.dispose).toHaveBeenCalledOnce() + expect(fixture.remove).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + }) + + it('cleans up when native delivery fails before subscribe resolves', async () => { + fixture.subscribe.mockImplementation( + async (_directory: string, callback: WatcherProcessCallback) => { + callback(new Error('native watcher failed'), []) + return { unsubscribe: fixture.unsubscribe } + } + ) + await expect(preflightOrcadBunNativeRuntime()).rejects.toThrow('native watcher failed') + expect(fixture.unsubscribe).toHaveBeenCalledOnce() + expect(fixture.dispose).toHaveBeenCalledOnce() + }) + + it('still disposes temporary state when unsubscribe fails', async () => { + fixture.unsubscribe.mockRejectedValue(new Error('watcher did not exit')) + await expect(preflightOrcadBunNativeRuntime()).rejects.toThrow('watcher did not exit') + expect(fixture.dispose).toHaveBeenCalledOnce() + expect(fixture.remove).toHaveBeenCalledOnce() + }) + + it.each(['missing-addon', 'missing-creation-time', 'invalid-self-row'])( + 'refuses %s on Windows before spawning a PTY or allowing CIM fallback', + async (reason) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + if (reason === 'missing-addon') { + fixture.available.mockReturnValue(false) + } + if (reason === 'missing-creation-time') { + fixture.startTime.mockReturnValue(false) + } + if (reason === 'invalid-self-row') { + fixture.rows.mockResolvedValue([{ pid: process.pid }]) + } + await expect(preflightOrcadBunNativeRuntime()).rejects.toThrow('Windows process table') + expect(fixture.pty).not.toHaveBeenCalled() + if (reason !== 'invalid-self-row') { + expect(fixture.rows).not.toHaveBeenCalled() + } + } + ) + + it('reads a fresh self identity on Windows before qualifying the PTY', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + await preflightOrcadBunNativeRuntime() + expect(fixture.rows).toHaveBeenCalledOnce() + expect(fixture.pty).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/orcad/orcad-bun-native-preflight.ts b/src/main/orcad/orcad-bun-native-preflight.ts new file mode 100644 index 00000000000..163f6ce2987 --- /dev/null +++ b/src/main/orcad/orcad-bun-native-preflight.ts @@ -0,0 +1,83 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runPtySpawnHealthProbe } from '../daemon/pty-subprocess/spawn-preflight' +import { WatcherProcessSupervisor } from '../ipc/parcel-watcher-process-supervisor' +import { resolveWatcherProcessEntryPath } from '../ipc/parcel-watcher-entry-path' +import { resolveOrcadInstallRoot } from './orcad-app-paths' +import { + isWindowsProcessTableAvailable, + isWindowsProcessStartTimeAvailable, + readWindowsProcessIdentityTableFresh +} from '../windows/windows-process-table' + +/** The candidate process owns disposable PTY and watcher probes before it touches user state. */ +export async function preflightOrcadBunNativeRuntime( + options: { nativeFeatures?: boolean } = {} +): Promise { + if (process.platform === 'win32') { + await preflightWindowsProcessIdentity() + } + // Runtime health checks can degrade independently; artifact qualification remains strict. + if (options.nativeFeatures === false) { + return + } + await runPtySpawnHealthProbe() + const directory = await mkdtemp(join(tmpdir(), 'orca-native-ready-')) + const supervisor = new WatcherProcessSupervisor({ + entryPath: resolveWatcherProcessEntryPath(resolveOrcadInstallRoot(), false), + useInProcessVitestFallback: false + }) + const cancellation = new AbortController() + let subscription: { unsubscribe(): Promise } | undefined + let timer: ReturnType | undefined + try { + let resolveDelivery: () => void = () => {} + let rejectDelivery: (error: unknown) => void = () => {} + const delivered = new Promise((resolve, reject) => { + resolveDelivery = resolve + rejectDelivery = reject + }) + // A native callback can fail while subscribe is pending. + void delivered.catch(() => {}) + timer = setTimeout(() => { + const error = new Error('Bun file watcher readiness timed out') + cancellation.abort(error) + rejectDelivery(error) + }, 5_000) + subscription = await supervisor.subscribe( + directory, + (error, events) => { + if (error) { + rejectDelivery(error) + } else if (events.some((event) => event.path === join(directory, 'ready'))) { + resolveDelivery() + } + }, + process.platform === 'win32' ? { backend: 'windows' } : {}, + { signal: cancellation.signal, subscribeTimeoutMs: 5_000, onTerminalError: rejectDelivery } + ) + await writeFile(join(directory, 'ready'), '') + await delivered + } finally { + clearTimeout(timer) + try { + await subscription?.unsubscribe() + } finally { + supervisor.dispose() + await rm(directory, { recursive: true, force: true }) + } + } +} + +async function preflightWindowsProcessIdentity(): Promise { + if (!isWindowsProcessTableAvailable() || !isWindowsProcessStartTimeAvailable()) { + throw new Error('The bundled Windows process table must support process creation times') + } + const rows = await readWindowsProcessIdentityTableFresh() + const self = rows.find((row) => row.pid === process.pid) + const created = self?.creationTimeMs + if (created === undefined || !Number.isFinite(created) || created <= 0 || created > Date.now()) { + throw new Error('The bundled Windows process table could not identify this process') + } +} diff --git a/src/main/orcad/orcad-bundle-native-load-order.test.ts b/src/main/orcad/orcad-bundle-native-load-order.test.ts index 1c26501b6a6..5ed9085fa36 100644 --- a/src/main/orcad/orcad-bundle-native-load-order.test.ts +++ b/src/main/orcad/orcad-bundle-native-load-order.test.ts @@ -1,91 +1,70 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { pathToFileURL } from 'node:url' +import { afterEach, expect, it } from 'vitest' import { runProcessSync } from '../../shared/child-process/run-process' -/** - * The preflight only prevents a loader crash if nothing in the bundle's import graph has - * already required node-pty by the time it runs. esbuild wraps `local-pty-provider` in a - * lazy initializer because `orcad-entry` reaches it through `await import('../ipc/pty')`, - * and that laziness is load-bearing rather than incidental — a single top-level static - * import anywhere in the graph would hoist `require("node-pty")` above every statement in - * `main.ts`, including the preflight. - * - * Why this builds the bundle instead of skipping without one: no CI job builds orcad and - * runs vitest. `smoke:orcad-terminal` builds it in the static-analysis job, which never - * runs vitest; the `orcad_browser` job runs vitest but deliberately does not build orcad. - * A `runIf(existsSync(...))` guard therefore skips in every shard, forever — the same way - * an unset ORCA_BROWSER_EXECUTABLE kept the browser provider uncovered. - * - * Why not fail-when-CI instead: the wiring that would satisfy it lives in `.github/`, so - * that turns a silent gap into a red build someone else has to fix. Building costs well - * under a second (esbuild), works in every shard and on every machine, and needs no job - * to cooperate. What it must never do is skip. - */ const REPO_ROOT = join(__dirname, '..', '..', '..') -const BUNDLE = join(REPO_ROOT, 'out', 'orcad', 'orcad.js') -const BUILD_SCRIPT = join(REPO_ROOT, 'config', 'scripts', 'build-orcad.mjs') +const directories: string[] = [] -/** - * Why it throws rather than skipping when the build fails: a bundle that cannot be built - * is a louder problem than the one this test checks, and swallowing it here is exactly - * how the assertion would go missing. - */ -function ensureOrcadBundle(): void { - if (existsSync(BUNDLE)) { - return +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) } - const build = runProcessSync({ - program: process.execPath, - args: [BUILD_SCRIPT], - cwd: REPO_ROOT, - timeoutMs: 300_000 - }) - if (!existsSync(BUNDLE)) { - const output = `${build.stdout}${build.stderr}`.slice(0, 4000) - throw new Error( - `could not build ${BUNDLE} (exit ${build.code}); the load-order assertion cannot run:\n${output}` - ) - } -} - -describe('orcad bundle native load order', () => { - const dirs: string[] = [] - afterEach(() => { - for (const dir of dirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }) - } - }) - - it('does not require node-pty in-process before the entry rejects its argv', () => { - ensureOrcadBundle() - const dir = mkdtempSync(join(tmpdir(), 'orcad-load-order-')) - dirs.push(dir) - const harness = join(dir, 'harness.cjs') - writeFileSync( - harness, - [ - "const Module = require('module')", - 'const original = Module._load', - 'Module._load = function (request, ...rest) {', - " if (request === 'node-pty') { console.log('IN_PROCESS_NODE_PTY_REQUIRE') }", - ' return original.call(this, request, ...rest)', - '}', - "process.argv.push('--orcad-load-order-check')", - `require(${JSON.stringify(BUNDLE)})` - ].join('\n') - ) - - const result = runProcessSync({ - program: process.execPath, - args: [harness], - timeoutMs: 120_000 - }) - const output = `${result.stdout}${result.stderr}` - - // Proof the graph fully loaded and reached argv parsing rather than dying early. - expect(output).toContain('Unknown argument: --orcad-load-order-check') - expect(output).not.toContain('IN_PROCESS_NODE_PTY_REQUIRE') - }, 360_000) }) + +it('loads a fresh production import graph before requiring native PTY code', () => { + const directory = mkdtempSync(join(tmpdir(), 'orcad-load-order-')) + directories.push(directory) + const bundle = join(directory, 'orcad.js') + const builder = pathToFileURL(join(REPO_ROOT, 'config/scripts/orcad-entry-build.mjs')).href + const built = runProcessSync({ + program: process.execPath, + args: [ + '--input-type=module', + '-e', + `import { buildOrcadEntry } from ${JSON.stringify(builder)}; await buildOrcadEntry(${JSON.stringify(bundle)})` + ], + cwd: REPO_ROOT, + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 60_000 + }) + expect(built.code, built.stderr.slice(0, 2_000)).toBe(0) + expect(existsSync(bundle)).toBe(true) + + const marker = join(directory, 'premature-native-load') + const preload = join(directory, 'preload.cjs') + writeFileSync( + preload, + [ + "const Module = require('node:module')", + 'const original = Module._load', + 'Module._load = function (request, ...rest) {', + " if (request === 'node-pty') {", + ` require('node:fs').writeFileSync(${JSON.stringify(marker)}, request)`, + " throw new Error('native PTY required before preflight')", + ' }', + ' return original.call(this, request, ...rest)', + '}' + ].join('\n') + ) + const run = (extraArgs: string[] = []) => + runProcessSync({ + program: process.execPath, + // The production load-check exits after module evaluation, before runtime handoff or probes. + args: ['--require', preload, ...extraArgs, bundle, '--orcad-smoke-load-check'], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 30_000 + }) + + const loaded = run() + expect(loaded.code, loaded.stderr.slice(0, 2_000)).toBe(0) + expect(existsSync(marker)).toBe(false) + + // Prove the interception works without relying on minified source echoed in an error. + const eagerNative = join(directory, 'eager-native.cjs') + writeFileSync(eagerNative, "require('node-pty')") + expect(run(['--require', eagerNative]).code).not.toBe(0) + expect(existsSync(marker)).toBe(true) +}, 90_000) diff --git a/src/main/orcad/orcad-bundled-runtime.integration.test.ts b/src/main/orcad/orcad-bundled-runtime.integration.test.ts new file mode 100644 index 00000000000..9db0d5000c9 --- /dev/null +++ b/src/main/orcad/orcad-bundled-runtime.integration.test.ts @@ -0,0 +1,221 @@ +import { build } from 'esbuild' +import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { runProcess, spawnProcess } from '../../shared/child-process/run-process' +import { shellEscape } from '../ssh/ssh-connection-utils' + +let directory = '' +const children = new Set>() + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-handoff-')) + await build({ + stdin: { + contents: ` + import { handoffToBundledOrcad, OrcadBundledRuntimeError } from './src/main/orcad/orcad-bundled-runtime' + import { installOrcadShutdownSignals, flushOrcadProfileStoreForShutdown } from './src/main/orcad/orcad-lifecycle' + import { writeFile } from 'node:fs/promises' + if (process.env.ORCA_TEST_HANDOFF_CHILD === '1') { + if (process.env.ORCA_TEST_HANDOFF_DURABLE === '1') { + installOrcadShutdownSignals(() => flushOrcadProfileStoreForShutdown({ + flushFinalOrThrowAsync: async () => { + console.log('flushing') + await new Promise(resolve => setTimeout(resolve, 250)) + await writeFile(process.env.ORCA_TEST_SHUTDOWN_FILE, 'flushed') + }, + freezeWritesAsync: async () => console.log('closed') + })) + } + for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => { + console.log('received:' + signal) + if (process.env.ORCA_TEST_HANDOFF_DURABLE !== '1') process.exit(29) + }) + } + console.log('ready:' + JSON.stringify(process.argv.slice(2))) + console.log('child-pid:' + process.pid) + setTimeout(() => process.exit(99), 4_000) + } else { + try { + if (!handoffToBundledOrcad()) throw new Error('handoff failed') + } catch (error) { + console.error(error.message) + process.exit(error instanceof OrcadBundledRuntimeError ? 78 : 1) + } + } + `, + resolveDir: process.cwd(), + loader: 'ts' + }, + outfile: join(directory, 'orcad.js'), + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs' + }) + await writeFile(join(directory, '.build-target'), 'darwin-arm64\n') + const runtime = join(directory, 'bun-runtime') + await writeFile( + runtime, + `#!/bin/sh\nORCA_TEST_HANDOFF_CHILD=1 exec ${shellEscape(process.execPath)} "$@"\n` + ) + await chmod(runtime, 0o700) +}) + +afterEach(async () => { + for (const child of children) { + child.kill('SIGKILL') + } + children.clear() + await rm(directory, { recursive: true, force: true }) +}) + +function launch( + args: string[], + env: NodeJS.ProcessEnv = {}, + options: { entry?: string; nohup?: boolean } = {} +) { + const child = spawnProcess({ + program: options.nohup ? 'nohup' : process.execPath, + args: [ + ...(options.nohup ? [process.execPath] : []), + options.entry ?? join(directory, 'orcad.js'), + ...args + ], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', ...env }, + stdio: ['ignore', 'pipe', 'pipe'], + detached: true + }) + children.add(child) + let output = '' + child.stdout.on('data', (data: Buffer) => { + output += data.toString() + }) + child.stderr.on('data', (data: Buffer) => { + output += data.toString() + }) + const exit = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve, reject) => { + child.once('error', reject) + child.once('exit', (code, signal) => { + children.delete(child) + resolve({ code, signal }) + }) + } + ) + return { child, output: () => output, exit } +} + +describe.skipIf(process.platform === 'win32')('bundled handoff process lifecycle', () => { + it('refuses a partial installation before launching its adjacent runtime', async () => { + await rm(join(directory, '.build-target')) + const result = await runProcess({ + program: process.execPath, + args: [join(directory, 'orcad.js')], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 5_000 + }) + expect(result.code).toBe(78) + expect(result.stderr).toContain('bundled Orca runtime target is missing') + expect(result.stdout).not.toContain('ready:') + }) + + it.each(['SIGINT', 'SIGTERM'] as const)( + 'forwards %s to the actual child and mirrors its exit', + async (signal) => { + const args = ['--label', 'two words', 'quote"$literal'] + const { child, output, exit } = launch(args) + await vi.waitFor(() => expect(output()).toContain(`ready:${JSON.stringify(args)}`), { + timeout: 2_000 + }) + child.kill(signal) + expect(await exit).toEqual({ code: 29, signal: null }) + expect(output()).toContain(`received:${signal}`) + } + ) + + it.each( + (['SIGINT', 'SIGTERM'] as const).flatMap((signal) => + (['process group', 'separate service deliveries'] as const).map((delivery) => ({ + signal, + delivery + })) + ) + )( + 'finishes a pending durable flush after duplicate $signal from $delivery', + async ({ signal, delivery }) => { + const shutdownFile = join(directory, 'shutdown-complete') + const { child, output, exit } = launch([], { + ORCA_TEST_HANDOFF_DURABLE: '1', + ORCA_TEST_SHUTDOWN_FILE: shutdownFile + }) + await vi.waitFor(() => expect(output()).toContain('child-pid:'), { timeout: 2_000 }) + const runtimePid = Number(output().match(/child-pid:(\d+)/)?.[1]) + expect(runtimePid).toBeGreaterThan(0) + if (!child.pid) { + throw new Error('Launcher has no process ID') + } + if (delivery === 'process group') { + process.kill(-child.pid, signal) + } else { + process.kill(runtimePid, signal) + await vi.waitFor(() => expect(output()).toContain('flushing')) + child.kill(signal) + } + expect(await exit).toEqual({ code: 0, signal: null }) + expect(await readFile(shutdownFile, 'utf8')).toBe('flushed') + expect(output().match(/flushing/g)).toHaveLength(1) + expect(output()).toContain('closed') + if (delivery === 'separate service deliveries') { + expect(output().match(new RegExp(`received:${signal}`, 'g'))).toHaveLength(2) + } + } + ) + + it('hands off a symlinked entry to its adjacent runtime', async () => { + const aliases = join(directory, 'aliases') + await mkdir(aliases) + const entry = join(aliases, 'orcad.js') + await symlink(join(directory, 'orcad.js'), entry) + const { child, output, exit } = launch([], {}, { entry }) + await vi.waitFor(() => expect(output()).toContain('child-pid:'), { timeout: 2_000 }) + child.kill('SIGTERM') + expect(await exit).toEqual({ code: 29, signal: null }) + }) + + it('drains the child after its launcher is force-killed', async () => { + const shutdownFile = join(directory, 'shutdown-complete') + const { child, output, exit } = launch([], { + ORCA_TEST_HANDOFF_DURABLE: '1', + ORCA_TEST_SHUTDOWN_FILE: shutdownFile + }) + await vi.waitFor(() => expect(output()).toContain('child-pid:'), { timeout: 2_000 }) + child.kill('SIGKILL') + expect(await exit).toEqual({ code: null, signal: 'SIGKILL' }) + await vi.waitFor(async () => expect(await readFile(shutdownFile, 'utf8')).toBe('flushed')) + expect(output().match(/flushing/g)).toHaveLength(1) + }) + + it('preserves nohup across a terminal hangup and still stops gracefully on SIGTERM', async () => { + const shutdownFile = join(directory, 'shutdown-complete') + const { child, output, exit } = launch( + [], + { ORCA_TEST_HANDOFF_DURABLE: '1', ORCA_TEST_SHUTDOWN_FILE: shutdownFile }, + { nohup: true } + ) + await vi.waitFor(() => expect(output()).toContain('child-pid:'), { timeout: 2_000 }) + if (!child.pid) { + throw new Error('Launcher has no process ID') + } + process.kill(-child.pid, 'SIGHUP') + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(child.exitCode).toBeNull() + expect(child.signalCode).toBeNull() + expect(output()).not.toContain('flushing') + child.kill('SIGTERM') + expect(await exit).toEqual({ code: 0, signal: null }) + expect(await readFile(shutdownFile, 'utf8')).toBe('flushed') + }) +}) diff --git a/src/main/orcad/orcad-bundled-runtime.test.ts b/src/main/orcad/orcad-bundled-runtime.test.ts new file mode 100644 index 00000000000..5541314057e --- /dev/null +++ b/src/main/orcad/orcad-bundled-runtime.test.ts @@ -0,0 +1,176 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { handoffToBundledOrcad } from './orcad-bundled-runtime' +import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' +import { ORCAD_VERSION_FILENAME } from '../../shared/orcad-artifacts' + +const fixture = vi.hoisted(() => ({ + exists: vi.fn<(path: string) => boolean>(), + realpath: vi.fn<(path: string) => string>(), + spawn: vi.fn() +})) +vi.mock('node:fs', () => ({ existsSync: fixture.exists, realpathSync: fixture.realpath })) +vi.mock('../../shared/child-process/run-process', () => ({ spawnProcess: fixture.spawn })) + +class RuntimeChild extends EventEmitter { + kill = vi.fn() + disconnect = vi.fn() + connected = true +} + +let child: RuntimeChild +const signalNames = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const +let oldListeners: Map> + +beforeEach(() => { + oldListeners = new Map(signalNames.map((signal) => [signal, process.rawListeners(signal)])) + child = new RuntimeChild() + fixture.exists.mockReturnValue(true) + fixture.realpath.mockImplementation((path) => path) + fixture.spawn.mockReturnValue(child) + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('test process exit') + }) + vi.spyOn(process, 'kill').mockReturnValue(true) + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(process, 'argv', 'get').mockReturnValue(['/node', '/slot/orcad.js', '--port', '0']) +}) + +afterEach(() => { + for (const signal of signalNames) { + for (const listener of process.rawListeners(signal)) { + if (!oldListeners.get(signal)?.includes(listener)) { + process.off(signal, listener) + } + } + } + vi.restoreAllMocks() + vi.clearAllMocks() +}) + +describe('bundled Orca runtime handoff', () => { + it('leaves nonpackaged entries on their existing runtime', () => { + fixture.exists.mockReturnValue(false) + expect(handoffToBundledOrcad()).toBe(false) + expect(fixture.spawn).not.toHaveBeenCalled() + }) + + it('refuses an incomplete slot before starting a process', () => { + fixture.exists.mockImplementation((path) => path.endsWith('.build-target')) + expect(() => handoffToBundledOrcad()).toThrow('bundled Orca runtime is missing') + expect(fixture.spawn).not.toHaveBeenCalled() + }) + + it('refuses a versioned slot missing both its runtime and target marker', () => { + fixture.exists.mockImplementation((path) => path.endsWith(ORCAD_VERSION_FILENAME)) + expect(() => handoffToBundledOrcad()).toThrow('bundled Orca runtime target is missing') + expect(fixture.spawn).not.toHaveBeenCalled() + }) + + it('refuses a remaining bundled runtime without its target marker', () => { + fixture.exists.mockImplementation((path) => !path.endsWith('.build-target')) + expect(() => handoffToBundledOrcad()).toThrow('bundled Orca runtime target is missing') + expect(fixture.realpath).toHaveBeenCalledExactlyOnceWith('/slot/orcad.js') + expect(fixture.spawn).not.toHaveBeenCalled() + }) + + it('accepts only the pinned version when already executing the bundled runtime', () => { + fixture.realpath.mockReturnValue('/real/runtime') + vi.spyOn(process, 'versions', 'get').mockReturnValue({ + ...process.versions, + bun: ORCAD_BUN_VERSION + }) + expect(handoffToBundledOrcad()).toBe(false) + expect(fixture.spawn).not.toHaveBeenCalled() + }) + + it('refuses an adjacent runtime that reports the wrong Bun version', () => { + fixture.realpath.mockReturnValue('/real/runtime') + vi.spyOn(process, 'versions', 'get').mockReturnValue({ ...process.versions, bun: '0.0.0' }) + expect(() => handoffToBundledOrcad()).toThrow(`must be Bun ${ORCAD_BUN_VERSION}`) + }) + + it.each(['linux', 'darwin', 'win32'] as const)( + 'hands off arguments and respects %s signal delivery', + (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + expect(handoffToBundledOrcad()).toBe(true) + expect(fixture.spawn).toHaveBeenCalledWith({ + program: expect.stringMatching(/bun-runtime(?:\.exe)?$/), + args: ['/slot/orcad.js', '--port', '0'], + env: expect.objectContaining({ ORCA_BUNDLED_LAUNCHER_CHANNEL: '1' }), + detached: true, + stdio: ['inherit', 'inherit', 'inherit', 'ipc'] + }) + for (const signal of signalNames) { + const listener = process + .rawListeners(signal) + .find((candidate) => !oldListeners.get(signal)?.includes(candidate)) + if (signal === 'SIGHUP' && platform === 'win32') { + expect(listener).toBeUndefined() + continue + } + expect(listener).toBeDefined() + if (listener) { + listener.call(process, signal) + } + if (signal === 'SIGHUP') { + expect(child.kill).not.toHaveBeenCalledWith('SIGHUP') + } else if (platform === 'win32') { + expect(child.kill).not.toHaveBeenCalled() + expect(child.disconnect).toHaveBeenCalled() + } else { + expect(child.kill).toHaveBeenLastCalledWith(signal) + } + } + } + ) + + it('propagates a child exit code and removes every signal listener', () => { + handoffToBundledOrcad() + expect(() => child.emit('exit', 23, null)).toThrow('test process exit') + expect(process.exit).toHaveBeenCalledWith(23) + expect(process.kill).not.toHaveBeenCalled() + for (const signal of signalNames) { + expect(process.rawListeners(signal)).toEqual(oldListeners.get(signal)) + } + }) + + it('locates the runtime beside the resolved entry rather than its symlink', () => { + fixture.realpath.mockImplementation((path) => + path === '/slot/orcad.js' ? '/real/slot/orcad.js' : path + ) + handoffToBundledOrcad() + expect(fixture.spawn).toHaveBeenCalledWith( + expect.objectContaining({ + program: expect.stringMatching(/real\/slot\/bun-runtime(?:\.exe)?$/), + args: ['/real/slot/orcad.js', '--port', '0'] + }) + ) + }) + + it('reports failed spawn as a configuration failure and removes listeners', () => { + handoffToBundledOrcad() + expect(() => child.emit('error', new Error('ENOENT'))).toThrow('test process exit') + expect(process.exit).toHaveBeenCalledWith(78) + for (const signal of signalNames) { + expect(process.rawListeners(signal)).toEqual(oldListeners.get(signal)) + } + }) + + it('mirrors a POSIX signal exit without exiting before the signal is delivered', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + handoffToBundledOrcad() + child.emit('exit', null, 'SIGTERM') + expect(process.kill).toHaveBeenCalledWith(process.pid, 'SIGTERM') + expect(process.exit).not.toHaveBeenCalled() + }) + + it('preserves a signal exit without sending unsupported signals on Windows', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + handoffToBundledOrcad() + expect(() => child.emit('exit', null, 'SIGTERM')).toThrow('test process exit') + expect(process.exit).toHaveBeenCalledWith(143) + expect(process.kill).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/orcad/orcad-bundled-runtime.ts b/src/main/orcad/orcad-bundled-runtime.ts new file mode 100644 index 00000000000..05b86387124 --- /dev/null +++ b/src/main/orcad/orcad-bundled-runtime.ts @@ -0,0 +1,90 @@ +import { existsSync, realpathSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { constants } from 'node:os' +import { spawnProcess } from '../../shared/child-process/run-process' +import { + ORCAD_BUILD_TARGET_FILENAME, + ORCAD_VERSION_FILENAME, + orcadBunRuntimeFilename +} from '../../shared/orcad-artifacts' +import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' + +export class OrcadBundledRuntimeError extends Error {} +export const ORCAD_BUNDLED_LAUNCHER_ENV = 'ORCA_BUNDLED_LAUNCHER_CHANNEL' + +/** Keep old Node service commands usable without letting Node open the profile. */ +export function handoffToBundledOrcad(): boolean { + const script = process.argv[1] + if (!script) { + return false + } + const entry = realpathSync(script) + const directory = dirname(entry) + const runtime = join(directory, orcadBunRuntimeFilename(process.platform)) + const hasTarget = existsSync(join(directory, ORCAD_BUILD_TARGET_FILENAME)) + const hasRuntime = existsSync(runtime) + if (!hasTarget && !hasRuntime && !existsSync(join(directory, ORCAD_VERSION_FILENAME))) { + return false + } + if (!hasTarget) { + throw new OrcadBundledRuntimeError('The bundled Orca runtime target is missing') + } + if (!hasRuntime) { + throw new OrcadBundledRuntimeError('The bundled Orca runtime is missing') + } + if (realpathSync(process.execPath) === realpathSync(runtime)) { + if (process.versions.bun !== ORCAD_BUN_VERSION) { + throw new OrcadBundledRuntimeError( + `The bundled Orca runtime must be Bun ${ORCAD_BUN_VERSION}` + ) + } + return false + } + const child = spawnProcess({ + program: runtime, + args: [entry, ...process.argv.slice(2)], + env: { ...process.env, [ORCAD_BUNDLED_LAUNCHER_ENV]: '1' }, + // Windows' default child job kills Bun before it can drain on launcher disconnect. + detached: true, + stdio: ['inherit', 'inherit', 'inherit', 'ipc'] + }) + // Node resets nohup's disposition; headless runtimes stop through INT/TERM or owner loss. + const ignoreHangup = (): void => {} + if (process.platform !== 'win32') { + process.on('SIGHUP', ignoreHangup) + } + const forwards = (['SIGINT', 'SIGTERM'] as const).map((signal) => { + const forward = (): void => { + if (process.platform === 'win32') { + // Detached Windows children have a separate console; kill() skips durable shutdown. + if (child.connected) { + child.disconnect() + } + } else { + child.kill(signal) + } + } + process.on(signal, forward) + return { signal, forward } + }) + const cleanup = (): void => { + process.off('SIGHUP', ignoreHangup) + for (const { signal, forward } of forwards) { + process.off(signal, forward) + } + } + child.once('error', (error) => { + cleanup() + console.error('orcad: could not start the bundled runtime:', error.message) + process.exit(78) + }) + child.once('exit', (code, signal) => { + cleanup() + if (signal && process.platform !== 'win32') { + process.kill(process.pid, signal) + return + } + process.exit(code ?? (signal ? 128 + constants.signals[signal] : 1)) + }) + return true +} diff --git a/src/main/orcad/orcad-entry.test.ts b/src/main/orcad/orcad-entry.test.ts new file mode 100644 index 00000000000..5b0fe6fa560 --- /dev/null +++ b/src/main/orcad/orcad-entry.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from 'vitest' +import { + flushOrcadProfileStoreForShutdown, + installOrcadShutdownSignals, + ORCAD_SHUTDOWN_DEADLINE_MS +} from './orcad-lifecycle' + +describe('orcad profile-state shutdown', () => { + it('keeps one bounded shutdown even when stop signals repeat', () => { + vi.useFakeTimers() + let signal: (() => void) | undefined + vi.spyOn(process, 'on').mockImplementation((event, listener) => { + if (event === 'SIGTERM') { + signal = listener + } + return process + }) + const exit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('shutdown deadline') + }) + vi.spyOn(console, 'error').mockImplementation(() => {}) + const stop = vi.fn(() => new Promise(() => {})) + try { + installOrcadShutdownSignals(stop) + signal?.() + signal?.() + expect(stop).toHaveBeenCalledOnce() + expect(exit).not.toHaveBeenCalled() + expect(() => vi.advanceTimersByTime(ORCAD_SHUTDOWN_DEADLINE_MS)).toThrow('shutdown deadline') + expect(exit).toHaveBeenCalledWith(1) + } finally { + vi.restoreAllMocks() + vi.useRealTimers() + } + }) + + it('flushes durably before closing the profile store', async () => { + const events: string[] = [] + const store = { + flushFinalOrThrowAsync: vi.fn(async () => { + events.push('flush') + }), + freezeWritesAsync: vi.fn(async () => { + events.push('freeze') + }) + } + + await flushOrcadProfileStoreForShutdown(store) + + expect(store.flushFinalOrThrowAsync).toHaveBeenCalledExactlyOnceWith({ + exportJsonCompatibility: true + }) + expect(store.freezeWritesAsync).toHaveBeenCalledOnce() + expect(events).toEqual(['flush', 'freeze']) + }) + + it('closes the profile store even when the durable flush fails', async () => { + const flushError = new Error('profile flush failed') + const freezeWritesAsync = vi.fn(async () => {}) + const store = { + flushFinalOrThrowAsync: vi.fn(async () => { + throw flushError + }), + freezeWritesAsync + } + + await expect(flushOrcadProfileStoreForShutdown(store)).rejects.toBe(flushError) + expect(freezeWritesAsync).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index edc4426a2a9..02533588e9d 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -1,13 +1,12 @@ /** - * `orcad` — the Orca runtime served from plain Node, with no Electron. + * `orcad` — the Orca runtime served without Electron. * * Installs the Node host adapters, constructs the same `OrcaRuntimeService` the * desktop uses, installs a PTY controller via `registerHeadlessPtyRuntime`, and * serves runtime RPC. See docs/design/node-only-runtime-backend.html. * - * Desktop UI surfaces stay uninstalled: no native notifications, no renderer window. The - * renderer window is faked as a destroyed one because `registerPtyHandlers` takes a - * non-null `BrowserWindow`. Browser automation is different — it is installed through + * Desktop UI surfaces stay uninstalled: no native notifications or renderer delivery. + * Browser automation is installed through * the runtime factory, but only when an Electron serve sidecar or an operator-supplied * Chromium proves available at startup. */ @@ -15,16 +14,13 @@ import process from 'node:process' import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment' import { setSecretStore, type SecretStore } from '../../shared/secret-store' import type { ServeReadiness } from '../server/serve-readiness' -import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' -import { resolveOrcadBrowserProvider } from './orcad-browser-provider' import { resolveOrcadInstallRoot, resolveOrcadPath, resolveUserDataPath } from './orcad-app-paths' +import { describeOrcadBindExposure, resolveOrcadBindHost } from './orcad-bind-address' import { - describeOrcadBindExposure, - OrcadBindAddressError, - resolveOrcadBindHost -} from './orcad-bind-address' -import { acquireOrcadInstanceLock, OrcadInstanceLockError } from './orcad-instance-lock' -import { startOrcadWithLifecycle } from './orcad-lifecycle' + flushOrcadProfileStoreForShutdown, + installOrcadShutdownSignals, + startOrcadWithHost +} from './orcad-lifecycle' import { parseArgs } from './orcad-command-arguments' import { changedAiVaultSearchSettings, @@ -110,27 +106,10 @@ export type OrcadHandle = { */ export async function startOrcad(options: OrcadOptions = {}): Promise { installOrcadHostAdapters() - const userDataPath = resolveUserDataPath() - // Why before anything else touches the root: the profile index, the store and the daemon - // runtime dir all live under it, and two orcads sharing them corrupt state silently. This - // is also the last point at which refusing costs nothing. - const instanceLock = acquireOrcadInstanceLock(userDataPath) - const browserProvider = await resolveOrcadBrowserProvider({ userDataPath }) - setRuntimeBrowserCommandsFactory(browserProvider?.factory ?? null, { - headless: browserProvider !== null, - ...(browserProvider ? { isAvailable: () => browserProvider.isAvailable() } : {}) - }) - return startOrcadWithLifecycle( + return startOrcadWithHost( + resolveUserDataPath(), (registerCleanup) => startOrcadRuntime(options, registerCleanup), - async () => { - try { - await browserProvider?.stop() - } finally { - setRuntimeBrowserCommandsFactory(null) - runOrcadQuitHandlers() - instanceLock.release() - } - } + () => runOrcadQuitHandlers() ) } @@ -145,10 +124,7 @@ async function startOrcadRuntime( const { getAppEnvironment } = await import('../../shared/app-environment') const { resolveAdvertisedPairingEndpoint } = await import('../runtime/pairing-endpoint') const { ServeReadinessPublisher } = await import('../server/serve-readiness') - const { Store } = await import('../persistence/loading-store/store') - const { ensureActiveOrcaProfile, initOrcaProfilePaths } = - await import('../orca-profiles/profile-index-store') - const { initSshHostKeyStoreFile } = await import('../ssh/ssh-host-key-store') + const { createOrcadProfileStateStartup } = await import('./orcad-profile-state-startup') const { startOrcadDaemon, stopOrcadDaemon } = await import('./orcad-daemon-supervision') const { daemonOwnsFreshPersistentPtys } = await import('../daemon/daemon-init') const { collectOrcadHealth } = await import('./orcad-health') @@ -162,6 +138,9 @@ async function startOrcadRuntime( await import('../runtime/agent-status-observed-pane-identity') let rpc: InstanceType | null = null + let profileStoreForShutdown: + | { flushFinalOrThrowAsync(): Promise; freezeWritesAsync(): Promise } + | undefined let uninstallHookStatusRepublish = (): void => {} let uninstallObservedStatusIdentity = (): void => {} registerCleanup(async () => { @@ -169,13 +148,21 @@ async function startOrcadRuntime( await rpc?.stop() } finally { try { - // Why disconnect and not shut down: the daemon must outlive this process, or an - // orcad restart goes back to killing every running terminal. - await stopOrcadDaemon() + // Stop accepting RPC writes before the final persistence barrier. A SQLite-backed + // orcad has no JSON mirror to absorb a debounced write after SIGTERM. + if (profileStoreForShutdown) { + await flushOrcadProfileStoreForShutdown(profileStoreForShutdown) + } } finally { - uninstallObservedStatusIdentity() - uninstallHookStatusRepublish() - agentHookServer.stop() + try { + // Why disconnect and not shut down: the daemon must outlive this process, or an + // orcad restart goes back to killing every running terminal. + await stopOrcadDaemon() + } finally { + uninstallObservedStatusIdentity() + uninstallHookStatusRepublish() + agentHookServer.stop() + } } } }) @@ -183,8 +170,8 @@ async function startOrcadRuntime( const { resolvePushGatewayOrigin } = await import('../runtime/push/push-gateway-origin') const runtimeUserDataPath = getAppEnvironment().getPath('userData') - initOrcaProfilePaths() - const profile = ensureActiveOrcaProfile(runtimeUserDataPath) + const { store: profileStore, authority: profileStateAuthority } = + await createOrcadProfileStateStartup(runtimeUserDataPath) const observedPaneIdentities = new AgentStatusObservedPaneIdentities() const observedStatusCapture = new AgentStatusObservedPaneIdentityCapture(observedPaneIdentities) // Why a real Store: without one every persistence-backed RPC throws `runtime_unavailable` @@ -192,15 +179,14 @@ async function startOrcadRuntime( // a server that pairs and lists nothing looks healthy and is not. // Why: orcad IS the runtime authority — loading as 'desktop' would classify its // own runtime-scheduled automations as ambiguous mirrors and orphan them. - const store = new Store({ dataFile: profile.dataFile, storageAuthority: 'runtime' }) + profileStoreForShutdown = profileStore // Why: every SSH connect consults this sidecar. Left unbound it reports nothing trusted, // which is safe but silently discards accept records on every launch. - initSshHostKeyStoreFile(profile.dataFile) uninstallObservedStatusIdentity = agentHookServer.subscribeEnrichedStatus((enriched) => observedStatusCapture.observe(enriched) ) - if (isAgentStatusHooksEnabled(store.getSettings())) { + if (isAgentStatusHooksEnabled(profileStore.getSettings())) { await agentHookServer.start({ env: 'production', userDataPath: runtimeUserDataPath }) } @@ -213,7 +199,7 @@ async function startOrcadRuntime( // constructed, and the deps hook is only ever called later, from an RPC. let sessionSearch: { apply(settings: AiVaultSearchSettings): void; dispose(): void } | null = null - const runtime = new OrcaRuntimeService(store, undefined, { + const runtime = new OrcaRuntimeService(profileStore, undefined, { // Why lazy: a daemon swap replaces the provider after construction, so an eager // reference would freeze the pre-daemon one. getLocalProvider: () => getLocalPtyProvider(), @@ -252,7 +238,7 @@ async function startOrcadRuntime( reconcileAgentStatusForEndedProcess: (paneKeys) => agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), buildAgentHookPtyEnv: () => - isAgentStatusHooksEnabled(store.getSettings()) ? agentHookServer.buildPtyEnv() : {}, + isAgentStatusHooksEnabled(profileStore.getSettings()) ? agentHookServer.buildPtyEnv() : {}, // Why the dedupe here and not in the instance: `apply` closes and reconstructs // unconditionally, so an unchanged value would restart a healthy index. applySessionSearchSettings: (before, after) => { @@ -266,7 +252,7 @@ async function startOrcadRuntime( const { installOrcadSessionSearchService } = await import('./orcad-session-search') sessionSearch = await installOrcadSessionSearchService({ userDataPath: runtimeUserDataPath, - getSettings: () => store.getSettings() + getSettings: () => profileStore.getSettings() }) getAppEnvironment().onWillQuit(() => sessionSearch?.dispose()) @@ -284,7 +270,13 @@ async function startOrcadRuntime( // Codex-home and Claude-auth preparation are left unset: both are desktop account // flows. A launch that needs one fails with its own message rather than silently // spawning an unauthenticated agent. - await registerHeadlessPtyRuntime(runtime, undefined, () => store.getSettings(), undefined, store) + await registerHeadlessPtyRuntime( + runtime, + undefined, + () => profileStore.getSettings(), + undefined, + profileStore + ) // Why: same post-registration reconciliation `--serve` performs. Skipping it leaves // restored orchestration rows claiming an authority this host never took over. @@ -356,7 +348,7 @@ async function startOrcadRuntime( // Why in the readiness payload: this is the one message a supervisor and a deploy // transaction both read, and a green orcad with a dead daemon is exactly the // looks-healthy-but-useless state they must not activate. - health: await collectOrcadHealth(getAppEnvironment().getVersion()) + health: await collectOrcadHealth(getAppEnvironment().getVersion(), profileStateAuthority) } await new ServeReadinessPublisher().publish(readiness, { @@ -374,50 +366,18 @@ async function startOrcadRuntime( * supervision contract has to prevent, so systemd's `RestartPreventExitStatus` needs a code * that means "do not retry" and nothing else does. */ -export const ORCAD_EXIT_OK = 0 -export const ORCAD_EXIT_FAILED = 1 -export const ORCAD_EXIT_CONFIGURATION = 78 +export { + ORCAD_EXIT_OK, + ORCAD_EXIT_FAILED, + ORCAD_EXIT_CONFIGURATION, + resolveOrcadExitCode +} from './orcad-exit-code' /** Bounded so a wedged transport cannot hold a supervisor's stop past its own deadline. */ -export const ORCAD_SHUTDOWN_DEADLINE_MS = 15_000 - -export function resolveOrcadExitCode(error: unknown): number { - return error instanceof OrcadInstanceLockError || error instanceof OrcadBindAddressError - ? ORCAD_EXIT_CONFIGURATION - : ORCAD_EXIT_FAILED -} +export { ORCAD_SHUTDOWN_DEADLINE_MS } from './orcad-lifecycle' export async function main(argv: string[] = process.argv.slice(2)): Promise { - const handle = await startOrcad(parseArgs(argv)) - let stopping = false - const shutdown = (signal: NodeJS.Signals): void => { - if (stopping) { - // Why escalate rather than ignore: a supervisor's second signal means the first - // deadline elapsed. Continuing to wait silently is what makes a stop hang until - // SIGKILL, which is the one teardown that skips the daemon handoff entirely. - console.error(`orcad: second ${signal} during shutdown — exiting immediately`) - process.exit(ORCAD_EXIT_FAILED) - } - stopping = true - // Why a self-imposed deadline as well: the supervisor's SIGKILL leaves no exit code and - // no log line. Exiting ourselves keeps the failure attributable. - const deadline = setTimeout(() => { - console.error( - `orcad: shutdown after ${signal} exceeded ${ORCAD_SHUTDOWN_DEADLINE_MS}ms — exiting` - ) - process.exit(ORCAD_EXIT_FAILED) - }, ORCAD_SHUTDOWN_DEADLINE_MS) - deadline.unref() - handle - .stop() - .then(() => process.exit(ORCAD_EXIT_OK)) - // Why not rethrow: we are already tearing down on a signal, and an exit code is - // the only thing a supervisor can act on. - .catch((error) => { - console.error(`orcad: shutdown after ${signal} failed:`, error) - process.exit(ORCAD_EXIT_FAILED) - }) - } - process.on('SIGINT', () => shutdown('SIGINT')) - process.on('SIGTERM', () => shutdown('SIGTERM')) + const startup = startOrcad(parseArgs(argv)) + installOrcadShutdownSignals(async () => (await startup).stop()) + await startup } diff --git a/src/main/orcad/orcad-exit-code.ts b/src/main/orcad/orcad-exit-code.ts new file mode 100644 index 00000000000..8b07dd2f5c9 --- /dev/null +++ b/src/main/orcad/orcad-exit-code.ts @@ -0,0 +1,18 @@ +import { OrcadBindAddressError } from './orcad-bind-address' +import { OrcadBundledRuntimeError } from './orcad-bundled-runtime' +import { OrcadInstanceLockError } from './orcad-instance-lock' +import { ProfileStateAccessError } from '../persistence/profile-state/profile-state-access' + +export const ORCAD_EXIT_OK = 0 +export const ORCAD_EXIT_FAILED = 1 +export const ORCAD_EXIT_CONFIGURATION = 78 + +/** Configuration faults cannot be repaired by a supervisor restart. */ +export function resolveOrcadExitCode(error: unknown): number { + return error instanceof OrcadInstanceLockError || + error instanceof OrcadBindAddressError || + error instanceof OrcadBundledRuntimeError || + error instanceof ProfileStateAccessError + ? ORCAD_EXIT_CONFIGURATION + : ORCAD_EXIT_FAILED +} diff --git a/src/main/orcad/orcad-health.test.ts b/src/main/orcad/orcad-health.test.ts index c77f18477f0..70e02b37e87 100644 --- a/src/main/orcad/orcad-health.test.ts +++ b/src/main/orcad/orcad-health.test.ts @@ -134,6 +134,24 @@ describe('collectOrcadHealth', () => { expect(health.platform).toBe(process.platform) expect(health.terminalDaemon.state).toBe('live') }) + + it('includes bounded profile-state authority metadata when supplied', async () => { + const health = await collectOrcadHealth('1.2.3', { + backend: 'sqlite', + classification: 'sqlite-only', + authority_mode: 'sqlite-established', + runtime: 'orcad', + migrated: false + }) + + expect(health.profileStateAuthority).toEqual({ + backend: 'sqlite', + classification: 'sqlite-only', + authority_mode: 'sqlite-established', + runtime: 'orcad', + migrated: false + }) + }) }) describe('computeOrcadBuildHash', () => { diff --git a/src/main/orcad/orcad-health.ts b/src/main/orcad/orcad-health.ts index d9a567531ac..0c3a3525171 100644 --- a/src/main/orcad/orcad-health.ts +++ b/src/main/orcad/orcad-health.ts @@ -17,6 +17,7 @@ import { getDaemonEndpointFacts, readDaemonPidRecord } from '../daemon/daemon-init' +import type { OrcadProfileStateAuthoritySelection } from './orcad-profile-state-telemetry' /** * How much a green self-test actually proves. @@ -64,6 +65,8 @@ export type OrcadHealth = { arch: string pid: number terminalDaemon: TerminalDaemonHealth + /** The low-cardinality profile-state authority selected during startup, when available. */ + profileStateAuthority?: OrcadProfileStateAuthoritySelection } /** @@ -146,7 +149,10 @@ export async function collectTerminalDaemonHealth(): Promise { +export async function collectOrcadHealth( + buildVersion: string, + profileStateAuthority?: OrcadProfileStateAuthoritySelection +): Promise { return { buildHash: computeOrcadBuildHash(), buildVersion, @@ -155,6 +161,7 @@ export async function collectOrcadHealth(buildVersion: string): Promise { it('accepts --bind and leaves it unset when absent', () => { @@ -38,7 +40,13 @@ describe('resolveOrcadExitCode', () => { resolveOrcadExitCode(new OrcadInstanceLockError('orcad_instance_lock_held', 'held')) ).toBe(ORCAD_EXIT_CONFIGURATION) expect(resolveOrcadExitCode(new OrcadBindAddressError('bad'))).toBe(ORCAD_EXIT_CONFIGURATION) + expect(resolveOrcadExitCode(new ProfileStateAccessError('recovery interrupted'))).toBe( + ORCAD_EXIT_CONFIGURATION + ) expect(resolveOrcadExitCode(new Error('port in use'))).toBe(ORCAD_EXIT_FAILED) + expect(resolveOrcadExitCode(new OrcadBundledRuntimeError('partial installation'))).toBe( + ORCAD_EXIT_CONFIGURATION + ) expect(ORCAD_EXIT_CONFIGURATION).not.toBe(ORCAD_EXIT_FAILED) }) }) @@ -57,7 +65,7 @@ describe('orcad lifecycle cleanup', () => { ).rejects.toThrow('startup failed') expect(cleanupRuntime).toHaveBeenCalledOnce() - expect(cleanupHost).toHaveBeenCalledOnce() + expect(cleanupHost).toHaveBeenCalledExactlyOnceWith(true) }) it('preserves the startup error when rollback also fails', async () => { @@ -96,4 +104,17 @@ describe('orcad lifecycle cleanup', () => { expect(cleanupRuntime).toHaveBeenCalledOnce() expect(cleanupHost).toHaveBeenCalledOnce() }) + + it('keeps the host aware of failed runtime teardown so it cannot release profile admission', async () => { + const failure = new Error('profile writer still running') + const cleanupHost = vi.fn(async () => {}) + const handle = await startOrcadWithLifecycle(async (registerCleanup) => { + registerCleanup(async () => { + throw failure + }) + return {} + }, cleanupHost) + await expect(handle.stop()).rejects.toBe(failure) + expect(cleanupHost).toHaveBeenCalledExactlyOnceWith(false) + }) }) diff --git a/src/main/orcad/orcad-lifecycle.ts b/src/main/orcad/orcad-lifecycle.ts index 913a21c4874..bfff3b7f14e 100644 --- a/src/main/orcad/orcad-lifecycle.ts +++ b/src/main/orcad/orcad-lifecycle.ts @@ -1,3 +1,16 @@ +import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' +import { resolveOrcadBrowserProvider } from './orcad-browser-provider' +import { acquireOrcadInstanceLock } from './orcad-instance-lock' +import { ORCAD_BUNDLED_LAUNCHER_ENV } from './orcad-bundled-runtime' +import { resolveOrcadExitCode } from './orcad-exit-code' +import { + acquireProfileStateRuntimeAdmission, + type ProfileStateRuntimeAdmission +} from '../persistence/profile-state/profile-state-access' + +const bundledLauncherChannel = process.env[ORCAD_BUNDLED_LAUNCHER_ENV] === '1' +delete process.env[ORCAD_BUNDLED_LAUNCHER_ENV] + function createIdempotentOrcadCleanup(cleanup: () => Promise): () => Promise { let completion: Promise | null = null return () => { @@ -6,16 +19,56 @@ function createIdempotentOrcadCleanup(cleanup: () => Promise): () => Promi } } +export const ORCAD_SHUTDOWN_DEADLINE_MS = 15_000 + +/** A launcher and its child can both receive the same process-group or service stop signal. */ +export function installOrcadShutdownSignals( + stop: () => Promise, + deadlineMs = ORCAD_SHUTDOWN_DEADLINE_MS +): void { + let stopping = false + const shutdown = (signal: string): void => { + if (stopping) { + return + } + stopping = true + setTimeout(() => { + console.error(`orcad: shutdown after ${signal} exceeded ${deadlineMs}ms — exiting`) + process.exit(1) + }, deadlineMs) + stop() + .then(() => process.exit(0)) + .catch((error) => { + console.error(`orcad: shutdown after ${signal} failed:`, error) + process.exit(resolveOrcadExitCode(error)) + }) + } + process.on('SIGINT', () => shutdown('SIGINT')) + process.on('SIGTERM', () => shutdown('SIGTERM')) + // Headless runtimes survive terminal hangups; INT/TERM are the graceful stop contract. + if (process.platform !== 'win32') { + process.on('SIGHUP', () => {}) + } + if (bundledLauncherChannel && typeof process.send === 'function') { + process.once('disconnect', () => shutdown('launcher disconnect')) + if (!process.connected) { + shutdown('launcher disconnect') + } + } +} + export async function startOrcadWithLifecycle( start: (registerRuntimeCleanup: (cleanup: () => Promise) => void) => Promise, - cleanupHost: () => Promise + cleanupHost: (runtimeCleanupSucceeded: boolean) => Promise ): Promise }> { let cleanupRuntime = async (): Promise => {} const cleanup = createIdempotentOrcadCleanup(async () => { + let runtimeCleanupSucceeded = false try { await cleanupRuntime() + runtimeCleanupSucceeded = true } finally { - await cleanupHost() + await cleanupHost(runtimeCleanupSucceeded) } }) try { @@ -33,3 +86,53 @@ export async function startOrcadWithLifecycle( throw error } } + +/** Keep profile admission until every runtime writer has stopped. */ +export async function startOrcadWithHost( + userDataPath: string, + start: (registerCleanup: (cleanup: () => Promise) => void) => Promise, + runQuitHandlers: () => void +): Promise }> { + const instanceLock = acquireOrcadInstanceLock(userDataPath) + let admission: ProfileStateRuntimeAdmission | undefined + let browserProvider: Awaited> | undefined + return startOrcadWithLifecycle( + async (registerCleanup) => { + admission = acquireProfileStateRuntimeAdmission(userDataPath) + browserProvider = await resolveOrcadBrowserProvider({ userDataPath }) + const provider = browserProvider + setRuntimeBrowserCommandsFactory(provider?.factory ?? null, { + headless: provider !== null, + ...(provider ? { isAvailable: () => provider.isAvailable() } : {}) + }) + return start(registerCleanup) + }, + async (runtimeCleanupSucceeded) => { + try { + await browserProvider?.stop() + } finally { + setRuntimeBrowserCommandsFactory(null) + runQuitHandlers() + try { + // Failed teardown excludes recovery until the process actually exits. + if (runtimeCleanupSucceeded) { + admission?.release() + } + } finally { + instanceLock.release() + } + } + } + ) +} + +export async function flushOrcadProfileStoreForShutdown(store: { + flushFinalOrThrowAsync(options?: { exportJsonCompatibility?: boolean }): Promise + freezeWritesAsync(): Promise +}): Promise { + try { + await store.flushFinalOrThrowAsync({ exportJsonCompatibility: true }) + } finally { + await store.freezeWritesAsync() + } +} diff --git a/src/main/orcad/orcad-native-preflight.ts b/src/main/orcad/orcad-native-preflight.ts index 0f3c2cf8970..ee2f323dbe5 100644 --- a/src/main/orcad/orcad-native-preflight.ts +++ b/src/main/orcad/orcad-native-preflight.ts @@ -5,6 +5,7 @@ * testable apart from the detection (what the verdict is). */ import process from 'node:process' +import { canUseBunPty } from '../daemon/pty-subprocess/bun-pty-process-capabilities' import { setRuntimeTerminalUnavailableCause } from '../runtime/native-terminal-availability' import { terminalUnavailableMessage } from '../../shared/runtime-types' import { @@ -37,6 +38,10 @@ export type NativePreflightHooks = { * sentence printed here. */ export function runOrcadNativePreflight(hooks: NativePreflightHooks = {}): boolean { + if (!hooks.check && canUseBunPty()) { + setRuntimeTerminalUnavailableCause(null) + return true + } const check = hooks.check ?? checkNodePtyPrecondition const warn = hooks.warn ?? ((message: string) => console.warn(message)) const fail = hooks.fail ?? ((message: string) => console.error(message)) diff --git a/src/main/orcad/orcad-profile-preflight.test.ts b/src/main/orcad/orcad-profile-preflight.test.ts new file mode 100644 index 00000000000..9c51e1a349e --- /dev/null +++ b/src/main/orcad/orcad-profile-preflight.test.ts @@ -0,0 +1,197 @@ +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProcessResult, ProcessSpec } from '../../shared/child-process/run-process' +import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' +import { ORCAD_STARTUP_PREFLIGHT_FLAG } from '../../shared/orcad-profile-preflight' +import { OrcadBundledRuntimeError } from './orcad-bundled-runtime' +import { resolveOrcadExitCode } from './orcad-exit-code' +import { preflightBundledOrcadStartup, runOrcadProfilePreflight } from './orcad-profile-preflight' + +const fixture = vi.hoisted(() => ({ + identity: vi.fn(), + readVersion: vi.fn(), + sql: vi.fn(), + native: vi.fn(), + run: vi.fn<(spec: ProcessSpec) => Promise>() +})) +vi.mock('./orcad-artifact-identity', () => ({ readOrcadArtifactIdentity: fixture.identity })) +vi.mock('./orcad-app-paths', () => ({ resolveOrcadInstallRoot: () => '/slot' })) +vi.mock('node:fs/promises', () => ({ readFile: fixture.readVersion })) +vi.mock('../persistence/profile-state/profile-state-runtime-preflight', () => ({ + preflightProfileStateRuntime: fixture.sql +})) +vi.mock('./orcad-bun-native-preflight', () => ({ + preflightOrcadBunNativeRuntime: fixture.native +})) +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: fixture.run })) + +const identity = '0.1.0+aaaaaaaaaaaa' +const nonce = '743bf9c8-2e58-4c79-a0ac-52c8d3e8e103' + +function readyResult(challenge: string | undefined): ProcessResult { + return { + code: 0, + signal: null, + timedOut: false, + stderr: '', + stdout: JSON.stringify({ + type: 'orca_profile_state_ready', + nonce: challenge, + runtime: 'bun', + runtimeVersion: ORCAD_BUN_VERSION, + artifactVersion: identity, + sqliteVersion: '3.53.2', + revision: 1 + }) + } +} + +beforeEach(() => { + vi.spyOn(process, 'versions', 'get').mockReturnValue({ + ...process.versions, + bun: ORCAD_BUN_VERSION + }) + fixture.identity.mockResolvedValue(identity) + fixture.readVersion.mockResolvedValue(`${identity}\n`) + fixture.sql.mockResolvedValue({ sqliteVersion: '3.53.2', revision: 1 }) + fixture.native.mockResolvedValue(undefined) + fixture.run.mockImplementation(async (spec) => readyResult(spec.args?.[2])) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.resetAllMocks() +}) + +describe('bundled Orca startup readiness', () => { + it.each(['win32', 'darwin', 'linux'] as const)( + 'isolates native process state in the exact bundled %s executable', + async (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + await preflightBundledOrcadStartup() + expect(fixture.run).toHaveBeenCalledOnce() + expect(fixture.run).toHaveBeenCalledWith({ + program: join('/slot', platform === 'win32' ? 'bun-runtime.exe' : 'bun-runtime'), + args: [join('/slot', 'orcad.js'), ORCAD_STARTUP_PREFLIGHT_FLAG, expect.any(String)], + env: expect.objectContaining({ ORCA_BACKGROUND_LAUNCH: '1' }), + timeoutMs: 90_000, + maxOutputBytes: 64 * 1024, + terminationBarrier: true + }) + expect(fixture.sql).not.toHaveBeenCalled() + expect(fixture.native).not.toHaveBeenCalled() + } + ) + + it('leaves legacy Node startup on its existing readiness path', async () => { + const { bun: _bun, ...versions } = process.versions + vi.spyOn(process, 'versions', 'get').mockReturnValue(versions) + await preflightBundledOrcadStartup() + expect(fixture.identity).not.toHaveBeenCalled() + expect(fixture.run).not.toHaveBeenCalled() + }) + + it('hashes installed bytes only in the isolated child', async () => { + await preflightBundledOrcadStartup() + expect(fixture.identity).not.toHaveBeenCalled() + vi.spyOn(console, 'log').mockImplementation(() => {}) + await runOrcadProfilePreflight(nonce, { nativeFeatures: false }) + expect(fixture.identity).toHaveBeenCalledOnce() + }) + + it.each(['missing artifact', 'corrupt build target'])( + 'classifies %s as a configuration fault before testing SQLite', + async (message) => { + fixture.identity.mockRejectedValue(new Error(message)) + const failure = await runOrcadProfilePreflight(nonce).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(OrcadBundledRuntimeError) + expect(resolveOrcadExitCode(failure)).toBe(78) + expect(fixture.sql).not.toHaveBeenCalled() + } + ) + + it('classifies changed artifact bytes as configuration faults on normal startup', async () => { + fixture.readVersion.mockResolvedValue('0.1.0+bbbbbbbbbbbb') + await expect(preflightBundledOrcadStartup()).rejects.toThrow(OrcadBundledRuntimeError) + }) + + it.each([undefined, 'broken-version'])( + 'classifies an unreadable or malformed version marker as configuration: %s', + async (version) => { + if (version === undefined) { + fixture.readVersion.mockRejectedValue(new Error('ENOENT')) + } else { + fixture.readVersion.mockResolvedValue(version) + } + await expect(preflightBundledOrcadStartup()).rejects.toThrow(OrcadBundledRuntimeError) + expect(fixture.run).not.toHaveBeenCalled() + } + ) + + it('awaits probe termination before permitting server startup', async () => { + const exit = Promise.withResolvers() + fixture.run.mockReturnValue(exit.promise) + let admitted = false + const startup = preflightBundledOrcadStartup().then(() => { + admitted = true + }) + await vi.waitFor(() => expect(fixture.run).toHaveBeenCalledOnce()) + expect(admitted).toBe(false) + exit.resolve(readyResult(fixture.run.mock.calls[0]?.[0].args?.[2])) + await startup + expect(admitted).toBe(true) + }) + + it.each([{ code: 78 }, { timedOut: true }, { outputTruncated: true }])( + 'refuses a failed child even if it emitted a valid readiness reply: %j', + async (failure) => { + fixture.run.mockImplementation(async (spec) => ({ + ...readyResult(spec.args?.[2]), + ...failure, + stderr: 'native probe failed' + })) + await expect(preflightBundledOrcadStartup()).rejects.toThrow('native probe failed') + } + ) + + it('preserves configuration exit status from the isolated child', async () => { + fixture.run.mockImplementation(async (spec) => ({ ...readyResult(spec.args?.[2]), code: 78 })) + const failure = await preflightBundledOrcadStartup().catch((error: unknown) => error) + expect(resolveOrcadExitCode(failure)).toBe(78) + }) + + it('keeps transient SQLite readiness failures retryable', async () => { + fixture.sql.mockRejectedValue(new Error('SQLITE_BUSY')) + const failure = await runOrcadProfilePreflight(nonce).catch((error: unknown) => error) + expect(resolveOrcadExitCode(failure)).toBe(1) + }) + + it('leaves optional native probes to runtime health on normal startup', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + await runOrcadProfilePreflight(nonce, { nativeFeatures: false }) + expect(fixture.sql).toHaveBeenCalledOnce() + expect(fixture.native).toHaveBeenCalledWith({ nativeFeatures: false }) + }) + + it('rejects stale output from a different challenge', async () => { + fixture.run.mockResolvedValue(readyResult(nonce)) + await expect(preflightBundledOrcadStartup()).rejects.toThrow('invalid readiness identity') + }) + + it('rechecks the child artifact identity against the verified installed version', async () => { + fixture.run.mockImplementation(async (spec) => { + const result = readyResult(spec.args?.[2]) + return { ...result, stdout: result.stdout.replace(identity, '0.1.0+bbbbbbbbbbbb') } + }) + await expect(preflightBundledOrcadStartup()).rejects.toThrow('invalid readiness identity') + }) + + it('runs disposable probes directly in the command child without recursive spawning', async () => { + const output = vi.spyOn(console, 'log').mockImplementation(() => {}) + await runOrcadProfilePreflight(nonce) + expect(fixture.sql).toHaveBeenCalledOnce() + expect(fixture.native).toHaveBeenCalledOnce() + expect(fixture.run).not.toHaveBeenCalled() + expect(output).toHaveBeenCalledWith(readyResult(nonce).stdout) + }) +}) diff --git a/src/main/orcad/orcad-profile-preflight.ts b/src/main/orcad/orcad-profile-preflight.ts new file mode 100644 index 00000000000..eeb5bb611dd --- /dev/null +++ b/src/main/orcad/orcad-profile-preflight.ts @@ -0,0 +1,93 @@ +import { z } from 'zod' +import { randomUUID } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { preflightProfileStateRuntime } from '../persistence/profile-state/profile-state-runtime-preflight' +import { + ORCAD_STARTUP_PREFLIGHT_FLAG, + ORCAD_PROFILE_PREFLIGHT_TIMEOUT_MS, + parseOrcadProfilePreflight, + orcadProfilePreflightResponseSchema, + type OrcadProfilePreflightResponse +} from '../../shared/orcad-profile-preflight' +import { readOrcadArtifactIdentity } from './orcad-artifact-identity' +import { resolveOrcadInstallRoot } from './orcad-app-paths' +import { ORCAD_VERSION_FILENAME, orcadBunRuntimeFilename } from '../../shared/orcad-artifacts' +import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' +import { runProcess } from '../../shared/child-process/run-process' +import { preflightOrcadBunNativeRuntime } from './orcad-bun-native-preflight' +import { OrcadBundledRuntimeError } from './orcad-bundled-runtime' + +/** Check every packaged start before a profile index, data-root lock or import is touched. */ +export async function preflightBundledOrcadStartup(): Promise { + if (!process.versions.bun) { + return + } + const directory = resolveOrcadInstallRoot() + const identity = await readInstalledVersion(directory) + const nonce = randomUUID() + // Keep disposable SQLite ownership and native state out of the serving process. + const result = await runProcess({ + program: join(directory, orcadBunRuntimeFilename(process.platform)), + args: [join(directory, 'orcad.js'), ORCAD_STARTUP_PREFLIGHT_FLAG, nonce], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: ORCAD_PROFILE_PREFLIGHT_TIMEOUT_MS, + maxOutputBytes: 64 * 1024, + terminationBarrier: true + }) + if (result.code !== 0 || result.timedOut || result.outputTruncated) { + const Failure = result.code === 78 ? OrcadBundledRuntimeError : Error + throw new Failure(`The bundled Orca runtime failed readiness: ${result.stderr}`) + } + try { + parseOrcadProfilePreflight(result.stdout, nonce, ORCAD_BUN_VERSION, identity) + } catch (cause) { + throw new OrcadBundledRuntimeError('The bundled runtime returned invalid readiness identity', { + cause + }) + } +} + +/** Only disposable state is opened; no server, profile index or host adapters are installed. */ +export async function runOrcadProfilePreflight( + nonce: string | undefined, + options: { nativeFeatures?: boolean } = {} +): Promise { + const checkedNonce = z.string().uuid().parse(nonce) + let artifactVersion: string + try { + artifactVersion = await readOrcadArtifactIdentity(resolveOrcadInstallRoot()) + } catch (cause) { + throw new OrcadBundledRuntimeError('The bundled Orca artifacts are incomplete or altered', { + cause + }) + } + const result = await preflightProfileStateRuntime() + if (process.versions.bun) { + await preflightOrcadBunNativeRuntime(options) + } + const response: OrcadProfilePreflightResponse = { + type: 'orca_profile_state_ready', + nonce: checkedNonce, + runtime: process.versions.bun ? 'bun' : 'node', + runtimeVersion: process.versions.bun ?? process.versions.node, + artifactVersion, + ...result + } + console.log(JSON.stringify(response)) +} + +async function readInstalledVersion(directory: string): Promise { + try { + return orcadProfilePreflightResponseSchema.shape.artifactVersion.parse( + (await readFile(join(directory, ORCAD_VERSION_FILENAME), 'utf8')).trim() + ) + } catch (cause) { + throw new OrcadBundledRuntimeError( + 'The installed Orca artifact version is missing or invalid', + { + cause + } + ) + } +} diff --git a/src/main/orcad/orcad-profile-state-startup.test.ts b/src/main/orcad/orcad-profile-state-startup.test.ts new file mode 100644 index 00000000000..32939c2e078 --- /dev/null +++ b/src/main/orcad/orcad-profile-state-startup.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + createProfileStateStoreForStartupMock, + emitMock, + ensureActiveOrcaProfileMock, + initOrcaProfilePathsMock, + initSshHostKeyStoreFileMock +} = vi.hoisted(() => ({ + createProfileStateStoreForStartupMock: vi.fn(), + emitMock: vi.fn(), + ensureActiveOrcaProfileMock: vi.fn(), + initOrcaProfilePathsMock: vi.fn(), + initSshHostKeyStoreFileMock: vi.fn() +})) + +vi.mock('../persistence/profile-state/profile-state-startup-authority', () => ({ + createProfileStateStoreForStartup: createProfileStateStoreForStartupMock +})) +vi.mock('../orca-profiles/profile-index-store', () => ({ + ensureActiveOrcaProfile: ensureActiveOrcaProfileMock, + initOrcaProfilePaths: initOrcaProfilePathsMock +})) +vi.mock('../ssh/ssh-host-key-store', () => ({ + initSshHostKeyStoreFile: initSshHostKeyStoreFileMock +})) +vi.mock('./orcad-profile-state-telemetry', () => ({ + emitOrcadProfileStateAuthoritySelected: emitMock +})) + +const { createOrcadProfileStateStartup } = await import('./orcad-profile-state-startup') + +beforeEach(() => { + vi.resetAllMocks() + ensureActiveOrcaProfileMock.mockReturnValue({ + dataFile: '/tmp/profile/orca-data.json', + stateDatabaseFile: '/tmp/profile/profile-state.db', + profile: { id: 'profile-1' } + }) +}) + +describe('orcad profile-state startup', () => { + it('selects the capable authority once and publishes bounded metadata', async () => { + const store = { getSettings: vi.fn() } + createProfileStateStoreForStartupMock.mockReturnValue({ + store, + authority: { readSerializedState: vi.fn() }, + backend: 'sqlite', + classification: 'json-only', + migrated: true + }) + + const result = await createOrcadProfileStateStartup('/tmp/user-data') + + expect(initOrcaProfilePathsMock).toHaveBeenCalledOnce() + expect(ensureActiveOrcaProfileMock).toHaveBeenCalledWith('/tmp/user-data') + expect(initSshHostKeyStoreFileMock).toHaveBeenCalledWith('/tmp/profile/orca-data.json') + + expect(createProfileStateStoreForStartupMock).toHaveBeenCalledWith({ + dataFile: '/tmp/profile/orca-data.json', + databaseFile: '/tmp/profile/profile-state.db', + profileId: 'profile-1', + runtime: 'orcad', + storageAuthority: 'runtime' + }) + expect(result.store).toBe(store) + expect(result.authority).toEqual({ + backend: 'sqlite', + classification: 'json-only', + authority_mode: 'sqlite-established', + runtime: 'orcad', + migrated: true + }) + expect(emitMock).toHaveBeenCalledWith(result.authority) + }) + + it('publishes nothing before the profile writer is ready', async () => { + let refuse = (_error: Error) => {} + createProfileStateStoreForStartupMock.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + refuse = reject + }) + ) + const startup = createOrcadProfileStateStartup('/tmp/user-data') + const failure = new Error('writer startup refused') + const rejected = expect(startup).rejects.toBe(failure) + expect(initSshHostKeyStoreFileMock).not.toHaveBeenCalled() + expect(emitMock).not.toHaveBeenCalled() + refuse(failure) + await rejected + }) + + it('closes a ready writer if sidecar initialization fails', async () => { + const store = { freezeWritesAsync: vi.fn(async () => {}) } + createProfileStateStoreForStartupMock.mockResolvedValueOnce({ + store, + backend: 'sqlite', + classification: 'sqlite-only', + migrated: false + }) + const failure = new Error('sidecar initialization refused') + initSshHostKeyStoreFileMock.mockImplementationOnce(() => { + throw failure + }) + await expect(createOrcadProfileStateStartup('/tmp/user-data')).rejects.toBe(failure) + expect(store.freezeWritesAsync).toHaveBeenCalledOnce() + expect(emitMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/orcad/orcad-profile-state-startup.ts b/src/main/orcad/orcad-profile-state-startup.ts new file mode 100644 index 00000000000..dba09365993 --- /dev/null +++ b/src/main/orcad/orcad-profile-state-startup.ts @@ -0,0 +1,59 @@ +import { createProfileStateStoreForStartup } from '../persistence/profile-state/profile-state-startup-authority' +import type { ProfileStateStoreFactoryResult } from '../persistence/profile-state/profile-state-store-factory' +import { ensureActiveOrcaProfile, initOrcaProfilePaths } from '../orca-profiles/profile-index-store' +import { initSshHostKeyStoreFile } from '../ssh/ssh-host-key-store' +import { emitOrcadProfileStateAuthoritySelected } from './orcad-profile-state-telemetry' + +export type OrcadProfileStateProfile = { + dataFile: string + stateDatabaseFile: string + profile: { id: string } +} + +export type OrcadProfileStateStartup = { + store: ProfileStateStoreFactoryResult['store'] + authority: { + backend: ProfileStateStoreFactoryResult['backend'] + classification: ProfileStateStoreFactoryResult['classification'] + authority_mode: 'sqlite-established' + runtime: 'orcad' + migrated: boolean + } +} + +/** Build the headless Store and publish its authority selection at one Node-only seam. */ +export async function createOrcadProfileStateStartup( + userDataPath: string +): Promise { + initOrcaProfilePaths() + const profile = ensureActiveOrcaProfile(userDataPath) + const result = await createProfileStateStoreForStartup({ + dataFile: profile.dataFile, + databaseFile: profile.stateDatabaseFile, + profileId: profile.profile.id, + runtime: 'orcad', + storageAuthority: 'runtime' + }) + const authority = { + backend: result.backend, + classification: result.classification, + authority_mode: 'sqlite-established' as const, + runtime: 'orcad' as const, + migrated: result.migrated + } + try { + initSshHostKeyStoreFile(profile.dataFile) + emitOrcadProfileStateAuthoritySelected(authority) + return { store: result.store, authority } + } catch (error) { + try { + await result.store.freezeWritesAsync() + } catch (closeError) { + console.error( + '[persistence] Failed to close profile persistence after startup failure:', + closeError + ) + } + throw error + } +} diff --git a/src/main/orcad/orcad-profile-state-telemetry.test.ts b/src/main/orcad/orcad-profile-state-telemetry.test.ts new file mode 100644 index 00000000000..9302990de1b --- /dev/null +++ b/src/main/orcad/orcad-profile-state-telemetry.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest' +import { + emitOrcadProfileStateAuthoritySelected, + formatOrcadProfileStateAuthoritySelected, + type OrcadProfileStateAuthoritySelection +} from './orcad-profile-state-telemetry' + +const selection: OrcadProfileStateAuthoritySelection = { + backend: 'sqlite', + classification: 'json-only', + authority_mode: 'sqlite-established', + runtime: 'orcad', + migrated: true +} + +describe('orcad profile-state telemetry', () => { + it('formats a bounded machine-readable authority selection event', () => { + expect(JSON.parse(formatOrcadProfileStateAuthoritySelected(selection).slice(18))).toEqual({ + event: 'profile_state_authority_selected', + ...selection + }) + }) + + it('strips unexpected runtime fields before writing the record', () => { + const selectionWithRuntimeFields = Object.assign({}, selection, { + database_path: '/private/profile-state.db' + }) + const line = formatOrcadProfileStateAuthoritySelected(selectionWithRuntimeFields) + expect(line).not.toContain('database_path') + }) + + it('sends the event to the supplied sink', () => { + const sink = vi.fn() + emitOrcadProfileStateAuthoritySelected(selection, sink) + expect(sink).toHaveBeenCalledOnce() + expect(sink).toHaveBeenCalledWith(formatOrcadProfileStateAuthoritySelected(selection)) + }) + + it('never lets a failing sink block startup', () => { + expect(() => + emitOrcadProfileStateAuthoritySelected(selection, () => { + throw new Error('closed stderr') + }) + ).not.toThrow() + }) +}) diff --git a/src/main/orcad/orcad-profile-state-telemetry.ts b/src/main/orcad/orcad-profile-state-telemetry.ts new file mode 100644 index 00000000000..0e53dc31a67 --- /dev/null +++ b/src/main/orcad/orcad-profile-state-telemetry.ts @@ -0,0 +1,46 @@ +import type { ProfileStateStorageClassification } from '../persistence/profile-state/profile-state-storage-classification' + +/** + * The low-cardinality profile-state selection facts that a headless host can publish safely. + * Paths, profile IDs, and serialized state deliberately stay out of this record. + */ +export type OrcadProfileStateAuthoritySelection = { + backend: 'sqlite' + classification: ProfileStateStorageClassification + authority_mode: 'sqlite-established' + runtime: 'orcad' + migrated: boolean +} + +export type OrcadProfileStateTelemetrySink = (line: string) => void + +/** Render one machine-readable stderr line for fleet log collection. */ +export function formatOrcadProfileStateAuthoritySelected( + selection: OrcadProfileStateAuthoritySelection +): string { + // Keep the wire shape explicit even if a future caller passes a structurally-compatible + // object with extra runtime fields. Paths, IDs, and serialized state must never leak here. + return `[orcad-telemetry] ${JSON.stringify({ + event: 'profile_state_authority_selected', + backend: selection.backend, + classification: selection.classification, + authority_mode: selection.authority_mode, + runtime: selection.runtime, + migrated: selection.migrated + })}` +} + +/** + * Publish authority selection without importing Electron or the desktop PostHog client. + * Logging is best-effort: observability must never prevent an orcad host from serving. + */ +export function emitOrcadProfileStateAuthoritySelected( + selection: OrcadProfileStateAuthoritySelection, + sink: OrcadProfileStateTelemetrySink = (line) => console.error(line) +): void { + try { + sink(formatOrcadProfileStateAuthoritySelected(selection)) + } catch { + // A closed stderr or custom supervisor sink cannot turn a successful startup into a failure. + } +} diff --git a/src/main/orcad/orcad-push-startup.test.ts b/src/main/orcad/orcad-push-startup.test.ts index a8fbbc9fe16..21ad507c016 100644 --- a/src/main/orcad/orcad-push-startup.test.ts +++ b/src/main/orcad/orcad-push-startup.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdtempSync, readdirSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' @@ -6,12 +6,15 @@ import { DeviceRegistry } from '../runtime/device-registry' import { RuntimeMobileNotificationController } from '../runtime/runtime-mobile-notification-controller' import { PushUnregisterOutbox } from '../runtime/push/push-unregister-outbox' import { createPushHostKeypair } from '../runtime/push/push-host-challenge-fixtures' +import { acquireProfileStateMaintenance } from '../persistence/profile-state/profile-state-access' +import { profileStateAccessPaths } from '../persistence/profile-state/profile-state-access-owner' const state = vi.hoisted(() => ({ root: '', controller: null as RuntimeMobileNotificationController | null, registry: null as DeviceRegistry | null, rpcStarted: false, + browserProvider: vi.fn(async () => null), register: vi.fn(async () => ({ ok: true, registrationId: 'headless-registration' })), send: vi.fn(async () => ({ ok: true, results: [] })) })) @@ -20,7 +23,7 @@ vi.mock('./orcad-app-paths', () => ({ resolveOrcadPath: () => state.root, resolveUserDataPath: () => state.root })) -vi.mock('./orcad-browser-provider', () => ({ resolveOrcadBrowserProvider: async () => null })) +vi.mock('./orcad-browser-provider', () => ({ resolveOrcadBrowserProvider: state.browserProvider })) vi.mock('./orcad-instance-lock', () => ({ acquireOrcadInstanceLock: () => ({ release() {} }) })) vi.mock('./orcad-daemon-supervision', () => ({ startOrcadDaemon: async () => {}, @@ -33,16 +36,29 @@ vi.mock('../ipc/pty', () => ({ getLocalPtyProvider: () => null, getSshPtyProvider: () => null })) -vi.mock('../persistence/loading-store/store', () => ({ - Store: class { - getSettings() { - return {} +vi.mock('./orcad-profile-state-startup', () => ({ + createOrcadProfileStateStartup: async () => ({ + store: { + getSettings: () => ({}), + flushFinalOrThrowAsync: async () => {}, + freezeWritesAsync: async () => {} + }, + authority: { + backend: 'sqlite', + classification: 'neither', + authority_mode: 'sqlite-candidate', + runtime: 'orcad', + migrated: false } - } + }) })) vi.mock('../orca-profiles/profile-index-store', () => ({ initOrcaProfilePaths() {}, - ensureActiveOrcaProfile: () => ({ dataFile: join(state.root, 'profile.json') }) + ensureActiveOrcaProfile: () => ({ + dataFile: join(state.root, 'profile.json'), + stateDatabaseFile: join(state.root, 'profile-state.db'), + profile: { id: 'headless-profile' } + }) })) vi.mock('../ssh/ssh-host-key-store', () => ({ initSshHostKeyStoreFile() {} })) vi.mock('../server/serve-readiness', () => ({ @@ -109,6 +125,19 @@ afterEach(() => { vi.clearAllMocks() }) +it('refuses recovery overlap before initializing the browser provider or runtime', async () => { + state.root = mkdtempSync(join(tmpdir(), 'orca-headless-recovery-')) + const maintenance = acquireProfileStateMaintenance(state.root) + const { startOrcad } = await import('./orcad-entry') + try { + await expect(startOrcad({ noPairing: true, json: true })).rejects.toThrow() + expect(state.browserProvider).not.toHaveBeenCalled() + expect(state.rpcStarted).toBe(false) + } finally { + maintenance.release() + } +}) + it('starts push after RPC identity is available and stops dispatch on shutdown', async () => { state.root = mkdtempSync(join(tmpdir(), 'orca-headless-push-')) state.controller = new RuntimeMobileNotificationController() @@ -140,8 +169,19 @@ it('starts push after RPC identity is available and stops dispatch on shutdown', } finally { await host.stop() } + expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) + acquireProfileStateMaintenance(state.root).release() expect(state.controller.getListenerCount()).toBe(0) expect(await state.controller.registerPushDevice({} as never)).toMatchObject({ registered: false }) }) + +it('releases admission when host setup fails before a runtime exists', async () => { + state.root = mkdtempSync(join(tmpdir(), 'orca-headless-setup-failure-')) + state.browserProvider.mockRejectedValueOnce(new Error('browser setup failed')) + const { startOrcad } = await import('./orcad-entry') + await expect(startOrcad()).rejects.toThrow('browser setup failed') + expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) + acquireProfileStateMaintenance(state.root).release() +}) diff --git a/src/main/orcad/orcad-sidecar-runtime-client.test.ts b/src/main/orcad/orcad-sidecar-runtime-client.test.ts index 1ec2055c0d2..64d81444c54 100644 --- a/src/main/orcad/orcad-sidecar-runtime-client.test.ts +++ b/src/main/orcad/orcad-sidecar-runtime-client.test.ts @@ -39,14 +39,16 @@ describe('sidecar response framing', () => { const wire = `${JSON.stringify({ id, ok: true, result: 'x'.repeat(1024 * 1024) })}\n` const originalIndexOf = String.prototype.indexOf let searchedCharacters = 0 - const search = vi - .spyOn(String.prototype, 'indexOf') - .mockImplementation(function (this: string, value, position) { - if (value === '\n') { - searchedCharacters += this.length - (position ?? 0) - } - return originalIndexOf.call(this, value, position) - }) + const search = vi.spyOn(String.prototype, 'indexOf').mockImplementation(function ( + this: string, + value, + position + ) { + if (value === '\n') { + searchedCharacters += this.length - (position ?? 0) + } + return originalIndexOf.call(this, value, position) + }) try { for (let offset = 0; offset < wire.length; offset += 256) { socket.emit('data', wire.slice(offset, offset + 256)) diff --git a/src/main/persistence-async-write-syscalls.test.ts b/src/main/persistence-async-write-syscalls.test.ts index 282dc1e2988..27943f12fac 100644 --- a/src/main/persistence-async-write-syscalls.test.ts +++ b/src/main/persistence-async-write-syscalls.test.ts @@ -1,166 +1,106 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { - existsSync, - mkdtempSync, - readFileSync, - readdirSync, - rmSync, - utimesSync, - writeFileSync -} from 'node:fs' +import { existsSync } from 'node:fs' import type * as NodeFs from 'node:fs' import type * as NodeFsPromises from 'node:fs/promises' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import type { SshRemotePtyLeaseState } from '../shared/ssh-types' -import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' +import { + createWorkerMaintenanceFixture, + createSqliteMaintenanceFixture, + maintenanceBarrier +} from './persistence/loading-store/profile-state-maintenance-fixture' +import { profileStateDatabaseBackups } from './persistence/profile-state/profile-state-backup-path' +import { ProfileStateSqliteAuthority } from './persistence/profile-state/profile-state-sqlite-authority' +import type { ProfileStateWorkerAuthority } from './persistence/profile-state/profile-state-worker-authority' -const testState = { dir: '' } +type FilesystemCalls = { + directory: string + recording: boolean + sync: string[] + waitAsync: ((name: string, path: string) => Promise | undefined) | undefined +} -const fsCalls = vi.hoisted(() => { - const blocker = new Int32Array(new SharedArrayBuffer(4)) - const calls = { - recording: false, - dirPrefix: '', - /** When > 0, every recorded sync call parks the main thread this long — a stalled mount in miniature. */ - stallMs: 0, - syncCalls: [] as string[], - asyncCalls: [] as string[], - failAsync: null as ((fn: string, target: string) => NodeJS.ErrnoException | null) | null, - beforeAsync: null as ((fn: string, target: string) => void) | null, - waitAsync: null as ((fn: string, target: string) => Promise | null) | null, - reset(): void { - calls.syncCalls.length = 0 - calls.asyncCalls.length = 0 - calls.stallMs = 0 - calls.failAsync = null - calls.beforeAsync = null - calls.waitAsync = null - }, - inScope(target: unknown): target is string { - return ( - calls.recording && - typeof target === 'string' && - calls.dirPrefix !== '' && - target.startsWith(calls.dirPrefix) - ) - }, - recordSync(fn: string, target: unknown): void { - if (!calls.inScope(target)) { - return - } - calls.syncCalls.push(`${fn}:${target}`) - if (calls.stallMs > 0) { - // Atomics.wait blocks the thread the way an uninterruptible syscall does. - Atomics.wait(blocker, 0, 0, calls.stallMs) - } - }, - recordAsync(fn: string, target: unknown): NodeJS.ErrnoException | null { - if (!calls.inScope(target)) { - return null - } - calls.asyncCalls.push(`${fn}:${target}`) - calls.beforeAsync?.(fn, target) - return calls.failAsync?.(fn, target) ?? null - } - } - return calls -}) +const fsCalls = vi.hoisted((): FilesystemCalls => ({ + directory: '', + recording: false, + sync: [], + waitAsync: undefined +})) vi.mock('node:fs', async (importOriginal) => { + function isSynchronousFilesystemCall( + name: string, + value: unknown + ): value is (...args: unknown[]) => unknown { + return name.endsWith('Sync') && typeof value === 'function' + } + const actual = await importOriginal() const patched: Record = { ...actual } - for (const name of Object.keys(actual)) { - const original = (actual as unknown as Record)[name] - if (!name.endsWith('Sync') || typeof original !== 'function') { + for (const [name, original] of Object.entries(actual)) { + if (!isSynchronousFilesystemCall(name, original)) { continue } - const fn = original as (...args: unknown[]) => unknown - const wrapper = (...args: unknown[]): unknown => { - fsCalls.recordSync(name, args[0]) - return fn(...args) - } - patched[name] = Object.assign(wrapper, fn) + patched[name] = Object.assign((...args: unknown[]) => { + const path = args[0] + if (fsCalls.recording && typeof path === 'string' && path.startsWith(fsCalls.directory)) { + fsCalls.sync.push(`${name}:${path}`) + } + return original(...args) + }, original) } return { ...patched, default: patched } }) vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal() - const patched: Record = { ...actual } - for (const name of ['stat', 'access', 'rename', 'copyFile', 'rm', 'mkdir', 'open']) { - const fn = (actual as unknown as Record unknown>)[name] - patched[name] = async (...args: unknown[]): Promise => { - const failure = fsCalls.recordAsync(name, args[0]) - if (failure) { - throw failure - } + return { + ...actual, + rename: async (...args: Parameters) => { if (typeof args[0] === 'string') { - await fsCalls.waitAsync?.(name, args[0]) + await fsCalls.waitAsync?.('rename', args[0]) } - return fn(...args) + return actual.rename(...args) } } - return { ...patched, default: patched } +}) +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({ nth_repo_added: 2 }) })) +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +beforeEach(() => { + fsCalls.recording = false + fsCalls.directory = '' + fsCalls.sync = [] + fsCalls.waitAsync = undefined +}) +afterEach(() => { + fsCalls.recording = false }) -vi.mock('./ssh/ssh-config-parser', () => ({ - loadUserSshConfig: vi.fn(), - sshConfigHostsToTargets: vi.fn() -})) +function record(directory: string): void { + fsCalls.directory = directory + fsCalls.sync = [] + fsCalls.recording = true +} -vi.mock('./telemetry/client', () => ({ track: vi.fn() })) - -vi.mock('./telemetry/cohort-classifier', () => ({ - getCohortAtEmit: vi.fn().mockReturnValue({ nth_repo_added: 2 }) -})) - -// Deterministic cipher so two stores driven through identical mutations produce identical bytes. -vi.mock('electron', () => ({ - app: { getPath: () => testState.dir }, - safeStorage: { - isEncryptionAvailable: () => true, - encryptString: (plaintext: string) => Buffer.from(`enc:${plaintext}`, 'utf-8'), - decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('enc:'.length) +function pauseCommit(authority: ProfileStateWorkerAuthority) { + const started = maintenanceBarrier() + const finish = maintenanceBarrier() + let held = false + for (const name of ['writeSerializedDomains', 'writeCompleteSerializedDomains'] as const) { + const original = authority[name].bind(authority) + vi.spyOn(authority, name).mockImplementation(async (replacements) => { + if (!held) { + held = true + started.resolve() + await finish.promise + } + await original(replacements) + }) } -})) - -const BACKUP_COUNT = 5 -const BACKUP_MIN_INTERVAL_MS = 60 * 60 * 1000 -const PAST_ROTATION_INTERVAL_MS = BACKUP_MIN_INTERVAL_MS * 2 -const SAVE_DEBOUNCE_MS = 1_000 - -const ROTATION_INTERLEAVE_CASES = [ - ['initial access', 'access', ''], - ['oldest removal', 'rm', '.bak.4'], - ['slot access', 'access', '.bak.0'], - ['slot rename', 'rename', '.bak.0'], - ['final copy', 'copyFile', ''] -] as const - -type TestStore = { - updateUI(updates: { sidebarWidth: number }): void - setGitHubCache(cache: { pr: Record; issue: Record }): void - waitForPendingWrite(): Promise - flushOrThrow(): void - flushPendingAsync(): Promise - flushPendingOrThrowAsync(options?: { drainToStableGeneration?: boolean }): Promise - upsertSshPtyConsumerRecovery(record: { - targetId: string - clientInstanceId: string - serverBuildId: string - clientGeneration: number - ownerGeneration: number - ownerLease: string - }): Promise - removeSshPtyConsumerRecovery(targetId: string): Promise - upsertSshRemotePtyLease(lease: { - targetId: string - ptyId: string - state: SshRemotePtyLeaseState - }): void - markSshRemotePtyLeasesAsync(targetId: string, state: SshRemotePtyLeaseState): Promise - markSshRemotePtyLeasesAttachedAsync(targetId: string, ptyIds: readonly string[]): Promise + return { started: started.promise, release: finish.resolve } } function consumerRecovery(clientInstanceId: string) { @@ -174,655 +114,221 @@ function consumerRecovery(clientInstanceId: string) { } } -async function createStore(dir: string): Promise { - testState.dir = dir - vi.resetModules() - const { Store, initDataPath } = await import('./persistence') - // Why here: userData resolves through AppEnvironment, and this must point at this - // file's temp dir rather than the global fake's shared one, after resetModules. - installFakeAppEnvironment({ getPath: () => testState.dir }) - initDataPath() - return new Store() as unknown as TestStore -} - -function dataFile(dir: string): string { - return join(dir, 'orca-data.json') -} - -function deferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void - const promise = new Promise((next) => { - resolve = next - }) - return { promise, resolve } -} - -function recordFsCalls(dir: string): void { - fsCalls.dirPrefix = dir - fsCalls.recording = true -} - -function delayNextDataFileRename(dir: string): ReturnType & { - started: Promise -} { - const release = deferred() - const started = deferred() - let held = false - fsCalls.waitAsync = (fn, target) => { - if (held || fn !== 'rename' || !target.startsWith(dataFile(dir))) { - return null - } - held = true - started.resolve() - return release.promise +function readBackup(path: string) { + const reader = new ProfileStateSqliteAuthority(path, 'maintenance-test') + try { + return JSON.parse(reader.readSerializedState() ?? '{}') + } finally { + reader.close() } - return { ...release, started: started.promise } } -function seedStaleBackup(dir: string): void { - const path = `${dataFile(dir)}.bak.0` - writeFileSync(path, '{"stale":true}', 'utf-8') - const staleSeconds = (Date.now() - PAST_ROTATION_INTERVAL_MS) / 1000 - utimesSync(path, staleSeconds, staleSeconds) -} - -function ringSnapshot(dir: string): Record { - const snapshot: Record = {} - for (const name of readdirSync(dir).sort()) { - if (name === 'orca-data.json' || name.startsWith('orca-data.json.bak.')) { - snapshot[name] = readFileSync(join(dir, name), 'utf-8') - } - } - return snapshot -} - -describe('async persistence write path avoids synchronous fs syscalls', () => { - const dirs: string[] = [] - - function makeDir(): string { - const dir = mkdtempSync(join(tmpdir(), 'orca-async-write-')) - dirs.push(dir) - return dir - } - - beforeEach(() => { +describe('worker persistence avoids synchronous profile filesystem calls', () => { + it('commits and creates a real SQLite backup without synchronous profile syscalls', async () => { + const { store, authority, directory, databaseFile, dataFile, readState } = + await createWorkerMaintenanceFixture() + record(directory) + store.updateUI({ sidebarWidth: 301 }) + await store.flushPendingOrThrowAsync() + await authority.drainBackups() fsCalls.recording = false - fsCalls.dirPrefix = '' - fsCalls.reset() - vi.useFakeTimers() + expect(fsCalls.sync).toEqual([]) + expect(readState().ui.sidebarWidth).toBe(301) + const backups = profileStateDatabaseBackups(databaseFile) + expect(backups).toHaveLength(1) + expect(readBackup(backups[0].path).ui.sidebarWidth).toBe(301) + expect(existsSync(dataFile)).toBe(false) + expect(existsSync(`${dataFile}.bak.0`)).toBe(false) }) - afterEach(() => { + it('keeps five hourly SQL backups and skips rotation within the hour without sync syscalls', async () => { + const start = Date.now() + vi.spyOn(Date, 'now').mockReturnValue(start) + const { store, authority, directory, databaseFile } = await createWorkerMaintenanceFixture() + record(directory) + for (let index = 0; index < 6; index++) { + vi.mocked(Date.now).mockReturnValue(start + index * 3_600_001) + store.updateUI({ sidebarWidth: 310 + index }) + await store.flushPendingOrThrowAsync() + await authority.drainBackups() + } + store.updateUI({ sidebarWidth: 399 }) + await store.flushPendingOrThrowAsync() + await authority.drainBackups() fsCalls.recording = false - vi.useRealTimers() - while (dirs.length > 0) { - rmSync(dirs.pop() as string, { recursive: true, force: true }) - } + expect(fsCalls.sync).toEqual([]) + const backups = profileStateDatabaseBackups(databaseFile) + expect(backups).toHaveLength(5) + expect(backups.map((backup) => readBackup(backup.path).ui.sidebarWidth)).toEqual([ + 315, 314, 313, 312, 311 + ]) }) - async function recordAsyncSave( - store: TestStore, - dir: string, - sidebarWidth: number - ): Promise { - store.updateUI({ sidebarWidth }) - recordFsCalls(dir) - try { - vi.advanceTimersByTime(PAST_ROTATION_INTERVAL_MS) - await store.waitForPendingWrite() - } finally { - fsCalls.recording = false - } - } - - it('issues no sync fs syscall under the profile dir when rotation runs with an empty ring', async () => { - const dir = makeDir() - const store = await createStore(dir) - - await recordAsyncSave(store, dir, 301) - - expect(fsCalls.syncCalls).toEqual([]) - // Rotation must actually have happened, else the assertion above is vacuous. - expect(ringSnapshot(dir)['orca-data.json.bak.0']).toBe(readFileSync(dataFile(dir), 'utf-8')) - }) - - it('uses an awaited stat, not statSync, for the backup rotation interval check', async () => { - const dir = makeDir() - const store = await createStore(dir) - seedStaleBackup(dir) - - await recordAsyncSave(store, dir, 302) - - expect(fsCalls.syncCalls).toEqual([]) - expect(fsCalls.asyncCalls).toContain(`stat:${dataFile(dir)}.bak.0`) - }) - - it('issues no sync fs syscall while rotating a saturated ring', async () => { - const dir = makeDir() - const store = await createStore(dir) - - // One more save than the ring holds, so the oldest slot is evicted and every slot renames. - for (let i = 0; i <= BACKUP_COUNT; i++) { - await recordAsyncSave(store, dir, 310 + i) - } - - expect(fsCalls.syncCalls).toEqual([]) - const ring = ringSnapshot(dir) - expect(Object.keys(ring)).toContain(`orca-data.json.bak.${BACKUP_COUNT - 1}`) - expect(Object.keys(ring)).not.toContain(`orca-data.json.bak.${BACKUP_COUNT}`) - }) - - it('lets a main-thread timer keep firing while a rotating save is in flight', async () => { - // A recorded sync call stalls long enough for the heartbeat to catch it. - vi.useRealTimers() - const dir = makeDir() - const store = await createStore(dir) - seedStaleBackup(dir) - // Generous stall so ordinary scheduler/GC jitter can't reach the threshold on a loaded CI box. - const stallMs = 500 - - let lastTick = Date.now() - let worstGapMs = 0 - const heartbeat = setInterval(() => { - const now = Date.now() - worstGapMs = Math.max(worstGapMs, now - lastTick) - lastTick = now - }, 10) - - try { - store.updateUI({ sidebarWidth: 341 }) - fsCalls.dirPrefix = dir - fsCalls.stallMs = stallMs - fsCalls.recording = true - lastTick = Date.now() - await new Promise((resolve) => setTimeout(resolve, SAVE_DEBOUNCE_MS + 200)) - await store.waitForPendingWrite() - } finally { - fsCalls.recording = false - clearInterval(heartbeat) - } - - expect(worstGapMs).toBeLessThan(stallMs) - // The save really happened, so a small gap isn't just an absent write. - expect(ringSnapshot(dir)['orca-data.json.bak.0']).toBe(readFileSync(dataFile(dir), 'utf-8')) - }, 20_000) - - it('skips rotation when a sync flush rotated during the rotation-interval await', async () => { - // The acquired owner keeps rotation when the flush skips the ring. - const dir = makeDir() - const store = await createStore(dir) - seedStaleBackup(dir) - const staleBackup = readFileSync(`${dataFile(dir)}.bak.0`, 'utf-8') - - store.updateUI({ sidebarWidth: 361 }) - recordFsCalls(dir) - let flushed = false - fsCalls.beforeAsync = (fn, target) => { - if (flushed || fn !== 'stat' || !target.endsWith('.bak.0')) { - return - } - flushed = true - store.updateUI({ sidebarWidth: 362 }) - store.flushOrThrow() - } - try { - vi.advanceTimersByTime(PAST_ROTATION_INTERVAL_MS) - await store.waitForPendingWrite() - } finally { - fsCalls.recording = false - fsCalls.beforeAsync = null - } - - expect(flushed).toBe(true) - const ring = ringSnapshot(dir) - expect(ring['orca-data.json.bak.0']).toBe(ring['orca-data.json']) - expect(ring['orca-data.json.bak.1']).toBe(staleBackup) - expect(ring['orca-data.json.bak.2']).toBeUndefined() - }) - - it('a sync checkpoint vetoes an async write already parked on rename', async () => { - const dir = makeDir() - const store = await createStore(dir) - const rename = delayNextDataFileRename(dir) - - recordFsCalls(dir) - store.updateUI({ sidebarWidth: 501 }) - vi.advanceTimersByTime(SAVE_DEBOUNCE_MS) - const pending = store.waitForPendingWrite() - await rename.started - - store.updateUI({ sidebarWidth: 502 }) - store.flushOrThrow() - expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(502) - - rename.resolve() + it('keeps the event loop live while a SQL acknowledgement is stalled', async () => { + const { store, authority, directory, readState } = await createWorkerMaintenanceFixture() + const gate = pauseCommit(authority) + record(directory) + const pending = store.upsertSshPtyConsumerRecovery(consumerRecovery('client-1')) + await gate.started + let complete = false + void pending.then(() => { + complete = true + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(complete).toBe(false) + expect(fsCalls.sync).toEqual([]) + gate.release() await pending fsCalls.recording = false - fsCalls.waitAsync = null - - expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(502) - expect(readdirSync(dir).filter((name) => name.endsWith('.tmp'))).toHaveLength(0) + expect(readState().sshPtyConsumerRecoveries[0].clientInstanceId).toBe('client-1') }) - it('retries a genuine ENOENT instead of marking the state persisted', async () => { - const dir = makeDir() - const store = await createStore(dir) - const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) - recordFsCalls(dir) - fsCalls.failAsync = (fn, target) => - fn === 'rename' && target.startsWith(dataFile(dir)) && !target.includes('.bak.') - ? Object.assign(new Error('mount disappeared'), { code: 'ENOENT' }) - : null - - store.updateUI({ sidebarWidth: 511 }) - await store.flushPendingAsync() - expect(existsSync(dataFile(dir))).toBe(false) - - fsCalls.failAsync = null - await store.flushPendingAsync() - fsCalls.recording = false - errors.mockRestore() - - expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(511) - }) - - it('the throwing async barrier drains mutations made during its write', async () => { - const dir = makeDir() - const store = await createStore(dir) - const rename = delayNextDataFileRename(dir) - recordFsCalls(dir) - + it('drains a newer mutation before resolving a stable-generation barrier', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const gate = pauseCommit(authority) store.updateUI({ sidebarWidth: 601 }) const barrier = store.flushPendingOrThrowAsync() - await rename.started + await gate.started store.updateUI({ sidebarWidth: 602 }) - rename.resolve() + gate.release() await barrier - fsCalls.recording = false - - expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(602) + expect(readState().ui.sidebarWidth).toBe(602) }) - it('bounds a best-effort flush to one state generation', async () => { - const dir = makeDir() - const store = await createStore(dir) - const rename = delayNextDataFileRename(dir) - recordFsCalls(dir) - + it('bounds a best-effort flush to its captured generation and retains later dirtiness', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const gate = pauseCommit(authority) store.updateUI({ sidebarWidth: 621 }) - const flush = store.flushPendingAsync() - await rename.started + const pending = store.flushPendingAsync() + await gate.started store.updateUI({ sidebarWidth: 622 }) - rename.resolve() - await flush - fsCalls.recording = false - - expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(621) - + gate.release() + await pending + expect(readState().ui.sidebarWidth).toBe(621) await store.flushPendingOrThrowAsync() - expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(622) + expect(readState().ui.sidebarWidth).toBe(622) }) - it('keeps a bounded barrier open when its staged writer is superseded before rename', async () => { - const dir = makeDir() - const store = await createStore(dir) - const openRelease = deferred() - const openStarted = deferred() - const renameRelease = deferred() - const renameStarted = deferred() - let heldOpen = false - let heldRename = false - fsCalls.waitAsync = (fn, target) => { - if ( - !heldOpen && - fn === 'open' && - target.startsWith(dataFile(dir)) && - target.endsWith('.tmp') - ) { - heldOpen = true - openStarted.resolve() - return openRelease.promise - } - if (!heldRename && fn === 'rename' && target.startsWith(dataFile(dir))) { - heldRename = true - renameStarted.resolve() - return renameRelease.promise - } - return null - } - recordFsCalls(dir) - - store.updateUI({ sidebarWidth: 631 }) - const barrier = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - await openStarted.promise - store.updateUI({ sidebarWidth: 632 }) - openRelease.resolve() - const firstOutcome = await Promise.race([ - barrier.then(() => 'settled' as const), - renameStarted.promise.then(() => 'retrying' as const) - ]) - - expect(firstOutcome).toBe('retrying') - renameRelease.resolve() - await barrier - fsCalls.recording = false - - expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(632) - }) - - it('rewrites a matching hash after a superseded rename installed stale state', async () => { - const dir = makeDir() - const store = await createStore(dir) + it('rewrites an earlier value after a captured stale generation commits', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() store.updateUI({ sidebarWidth: 641 }) await store.flushPendingOrThrowAsync() - const rename = delayNextDataFileRename(dir) - recordFsCalls(dir) - + const gate = pauseCommit(authority) store.updateUI({ sidebarWidth: 642 }) - vi.advanceTimersByTime(SAVE_DEBOUNCE_MS) - const staleWrite = store.waitForPendingWrite() - await rename.started + const pending = store.flushPendingAsync() + await gate.started store.updateUI({ sidebarWidth: 641 }) - rename.resolve() - await staleWrite - - expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(642) + gate.release() + await pending + expect(readState().ui.sidebarWidth).toBe(642) await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - fsCalls.recording = false - - expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(641) + expect(readState().ui.sidebarWidth).toBe(641) }) - it('the throwing async barrier drains mutations made during sidecar I/O', async () => { - const dir = makeDir() - const store = await createStore(dir) - const renameRelease = deferred() - const renameStarted = deferred() - fsCalls.waitAsync = (fn, target) => { - if (fn !== 'rename' || !target.includes('orca-github-cache.json.')) { - return null - } - renameStarted.resolve() - return renameRelease.promise - } - recordFsCalls(dir) + it('retains dirty state when a SQL commit fails and persists an explicit retry', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(authority, 'writeCompleteSerializedDomains').mockRejectedValueOnce( + new Error('disk refused') + ) + const original = readState().ui.sidebarWidth + store.updateUI({ sidebarWidth: 511 }) + await store.flushPendingAsync() + expect(readState().ui.sidebarWidth).toBe(original) + await store.flushPendingOrThrowAsync() + expect(readState().ui.sidebarWidth).toBe(511) + }) + it('drains mutations made while a sidecar rename is stalled', async () => { + const { store, readState } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const finish = maintenanceBarrier() + fsCalls.waitAsync = (name, path) => { + if (name !== 'rename' || !path.includes('orca-github-cache.json.')) { + return + } + started.resolve() + return finish.promise + } store.updateUI({ sidebarWidth: 611 }) store.setGitHubCache({ pr: {}, issue: {} }) const barrier = store.flushPendingOrThrowAsync() - await renameStarted.promise + await started.promise store.updateUI({ sidebarWidth: 612 }) - renameRelease.resolve() + finish.resolve() await barrier - fsCalls.recording = false - - expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(612) + expect(readState().ui.sidebarWidth).toBe(612) }) - it('serializes a second writer behind the owned rotation', async () => { - const dir = makeDir() - const store = await createStore(dir) - seedStaleBackup(dir) - const almostDueSeconds = (Date.now() - (BACKUP_MIN_INTERVAL_MS - SAVE_DEBOUNCE_MS - 100)) / 1000 - utimesSync(`${dataFile(dir)}.bak.0`, almostDueSeconds, almostDueSeconds) - const staleBackup = readFileSync(`${dataFile(dir)}.bak.0`, 'utf-8') - const statCall = `stat:${dataFile(dir)}.bak.0` - const rotationRelease = deferred() - const rotationStarted = deferred() - let held = false - fsCalls.waitAsync = (fn, target) => { - if (held || fn !== 'stat' || target !== `${dataFile(dir)}.bak.0`) { - return null - } - held = true - rotationStarted.resolve() - return rotationRelease.promise - } - - recordFsCalls(dir) - store.updateUI({ sidebarWidth: 371 }) - vi.advanceTimersByTime(SAVE_DEBOUNCE_MS) - const firstWrite = store.waitForPendingWrite() - let allWrites = firstWrite - try { - await rotationStarted.promise - store.updateUI({ sidebarWidth: 372 }) - store.flushOrThrow() - store.updateUI({ sidebarWidth: 373 }) - vi.advanceTimersByTime(SAVE_DEBOUNCE_MS) - allWrites = store.waitForPendingWrite() - expect(fsCalls.asyncCalls.filter((call) => call === statCall)).toHaveLength(1) - rotationRelease.resolve() - await Promise.all([firstWrite, allWrites]) - } finally { - rotationRelease.resolve() - await allWrites - fsCalls.recording = false - fsCalls.waitAsync = null - } - const ring = ringSnapshot(dir) - expect(JSON.parse(ring['orca-data.json']).ui.sidebarWidth).toBe(373) - expect(JSON.parse(ring['orca-data.json.bak.0']).ui.sidebarWidth).toBe(372) - expect(ring['orca-data.json.bak.1']).toBe(staleBackup) - expect(ring['orca-data.json.bak.2']).toBeUndefined() - }) - - it.each(ROTATION_INTERLEAVE_CASES)( - 'keeps one rotation owner when a sync flush lands during %s', - async (_phase, expectedFn, targetSuffix) => { - const dir = makeDir() - const store = await createStore(dir) - seedStaleBackup(dir) - const staleBackup = readFileSync(`${dataFile(dir)}.bak.0`, 'utf-8') - const expectedTarget = `${dataFile(dir)}${targetSuffix}` - - store.updateUI({ sidebarWidth: 363 }) - recordFsCalls(dir) - let flushed = false - fsCalls.beforeAsync = (fn, target) => { - if (flushed || fn !== expectedFn || target !== expectedTarget) { - return - } - flushed = true - store.updateUI({ sidebarWidth: 364 }) - store.flushOrThrow() - } - try { - vi.advanceTimersByTime(PAST_ROTATION_INTERVAL_MS) - await store.waitForPendingWrite() - } finally { - fsCalls.recording = false - fsCalls.beforeAsync = null - } - - expect(flushed).toBe(true) - const ring = ringSnapshot(dir) - expect(ring['orca-data.json.bak.0']).toBe(ring['orca-data.json']) - expect(ring['orca-data.json.bak.1']).toBe(staleBackup) - expect(ring['orca-data.json.bak.2']).toBeUndefined() - } - ) - - it('does not log for absent ring slots even when the mount rejects renames non-ENOENT', async () => { - // Probing keeps degraded mounts from logging one error per absent slot. - const dir = makeDir() - const store = await createStore(dir) - const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) - fsCalls.failAsync = (fn, target) => - fn === 'rename' && target.includes('.bak.') - ? Object.assign(new Error('stale NFS file handle'), { code: 'ESTALE' }) - : null - - try { - await recordAsyncSave(store, dir, 351) - expect(errors).not.toHaveBeenCalled() - } finally { - errors.mockRestore() - } - }) - - it('logs when a ring slot that exists fails to rotate', async () => { - const dir = makeDir() - const store = await createStore(dir) - seedStaleBackup(dir) - const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) - fsCalls.failAsync = (fn, target) => - fn === 'rename' && target.endsWith('.bak.0') - ? Object.assign(new Error('permission denied'), { code: 'EPERM' }) - : null - - try { - await recordAsyncSave(store, dir, 352) - expect(errors).toHaveBeenCalledWith( - '[persistence] Failed to rotate backup', - `${dataFile(dir)}.bak.0`, - '->', - `${dataFile(dir)}.bak.1`, - expect.objectContaining({ code: 'EPERM' }) - ) - } finally { - errors.mockRestore() - } - }) - - it('produces a .bak ring byte-identical to the sync path, at capacity and with ring holes', async () => { - const asyncDir = makeDir() - const syncDir = makeDir() - // Match generated IDs so only rotation behavior can differ. - const seedDir = makeDir() - const seedStore = await createStore(seedDir) - seedStore.updateUI({ sidebarWidth: 320 }) - seedStore.flushOrThrow() - const seed = readFileSync(dataFile(seedDir), 'utf-8') - for (const dir of [asyncDir, syncDir]) { - writeFileSync(dataFile(dir), seed, 'utf-8') - // Holes: slots 1 and 3 occupied, 0 and 2 missing — exercises the per-slot existence branch. - writeFileSync(`${dataFile(dir)}.bak.1`, '{"old":1}', 'utf-8') - writeFileSync(`${dataFile(dir)}.bak.3`, '{"old":3}', 'utf-8') - } - - const widths = [321, 322, 323, 324, 325, 326] - const asyncStore = await createStore(asyncDir) - for (const width of widths) { - asyncStore.updateUI({ sidebarWidth: width }) - vi.advanceTimersByTime(PAST_ROTATION_INTERVAL_MS) - await asyncStore.waitForPendingWrite() - } - - const syncStore = await createStore(syncDir) - for (const width of widths) { - syncStore.updateUI({ sidebarWidth: width }) - syncStore.flushOrThrow() - vi.advanceTimersByTime(PAST_ROTATION_INTERVAL_MS) - } - - const ring = ringSnapshot(asyncDir) - expect(ring).toEqual(ringSnapshot(syncDir)) - expect(Object.keys(ring)).toContain(`orca-data.json.bak.${BACKUP_COUNT - 1}`) - }) - - it('persists SSH PTY consumer recovery without a sync syscall, durable once awaited', async () => { - const dir = makeDir() - const store = await createStore(dir) - - recordFsCalls(dir) - try { - await store.upsertSshPtyConsumerRecovery(consumerRecovery('client-1')) - } finally { - fsCalls.recording = false - } - - expect(fsCalls.syncCalls).toEqual([]) - // Durability is awaited, not merely debounced: the record is on disk when the promise resolves. - const persisted = JSON.parse(readFileSync(dataFile(dir), 'utf-8')) as { - sshPtyConsumerRecoveries: { clientInstanceId: string }[] - } - expect(persisted.sshPtyConsumerRecoveries).toHaveLength(1) - expect(persisted.sshPtyConsumerRecoveries[0]?.clientInstanceId).toBe('client-1') - }) - - it('rejects the consumer-recovery durability barrier when the primary write fails', async () => { - const dir = makeDir() - const store = await createStore(dir) - const writeError = Object.assign(new Error('profile mount rejected write'), { code: 'EIO' }) - const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) - recordFsCalls(dir) - fsCalls.failAsync = (fn, target) => - fn === 'open' && target.startsWith(`${dataFile(dir)}.`) ? writeError : null - - try { - await expect(store.upsertSshPtyConsumerRecovery(consumerRecovery('client-1'))).rejects.toBe( - writeError - ) - } finally { - fsCalls.recording = false - errors.mockRestore() - } - }) - - it('removes SSH PTY consumer recovery without a sync syscall, durable once awaited', async () => { - const dir = makeDir() - const store = await createStore(dir) + it('persists SSH consumer recovery without sync syscalls before acknowledging it', async () => { + const { store, authority, directory, readState } = await createWorkerMaintenanceFixture() + record(directory) await store.upsertSshPtyConsumerRecovery(consumerRecovery('client-1')) - - recordFsCalls(dir) - try { - await store.removeSshPtyConsumerRecovery('ssh-1') - } finally { - fsCalls.recording = false - } - - expect(fsCalls.syncCalls).toEqual([]) - const persisted = JSON.parse(readFileSync(dataFile(dir), 'utf-8')) as { - sshPtyConsumerRecoveries: unknown[] - } - expect(persisted.sshPtyConsumerRecoveries).toEqual([]) + await authority.drainBackups() + fsCalls.recording = false + expect(fsCalls.sync).toEqual([]) + expect(readState().sshPtyConsumerRecoveries).toEqual([ + expect.objectContaining({ clientInstanceId: 'client-1' }) + ]) }) - it('persists failed-session lease detachment without a sync syscall', async () => { - const dir = makeDir() - const store = await createStore(dir) - store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'pty-1', state: 'attached' }) - - recordFsCalls(dir) - try { - await store.markSshRemotePtyLeasesAsync('ssh-1', 'detached') - } finally { - fsCalls.recording = false - } - - expect(fsCalls.syncCalls).toEqual([]) - const persisted = JSON.parse(readFileSync(dataFile(dir), 'utf-8')) as { - sshRemotePtyLeases: { state: string }[] - } - expect(persisted.sshRemotePtyLeases[0]?.state).toBe('detached') + it('rejects failed consumer recovery and preserves the prior durable state', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const before = readState().sshPtyConsumerRecoveries + const failure = new Error('profile mount rejected write') + vi.spyOn(authority, 'writeCompleteSerializedDomains').mockRejectedValueOnce(failure) + await expect(store.upsertSshPtyConsumerRecovery(consumerRecovery('client-1'))).rejects.toBe( + failure + ) + expect(readState().sshPtyConsumerRecoveries).toEqual(before) }) - it('persists selected reattach leases in one async write', async () => { - const dir = makeDir() - const store = await createStore(dir) - store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'pty-1', state: 'detached' }) + it('durably removes SSH consumer recovery without sync syscalls', async () => { + const { store, authority, directory, readState } = await createWorkerMaintenanceFixture() + await store.upsertSshPtyConsumerRecovery(consumerRecovery('client-1')) + await authority.drainBackups() + record(directory) + await store.removeSshPtyConsumerRecovery('ssh-1') + await authority.drainBackups() + fsCalls.recording = false + expect(fsCalls.sync).toEqual([]) + expect(readState().sshPtyConsumerRecoveries).toEqual([]) + }) + + it('persists lease detachment and selected reattachments without sync syscalls', async () => { + const { store, authority, directory, readState } = await createWorkerMaintenanceFixture() + for (const [ptyId, state] of [ + ['pty-1', 'attached'], + ['pty-2', 'expired'], + ['pty-3', 'detached'], + ['pty-4', 'terminated'] + ] as const) { + store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId, state }) + } + record(directory) + await store.markSshRemotePtyLeasesAsync('ssh-1', 'detached') + await authority.drainBackups() + fsCalls.recording = false + expect(fsCalls.sync).toEqual([]) + expect( + readState().sshRemotePtyLeases.filter( + (lease: { targetId: string }) => lease.targetId === 'ssh-1' + ) + ).toEqual( + expect.arrayContaining([expect.objectContaining({ ptyId: 'pty-1', state: 'detached' })]) + ) store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'pty-2', state: 'expired' }) - store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'pty-3', state: 'detached' }) - // Why: a PTY that exits mid-reattach is terminated before the batch write lands; it must stay dead. store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'pty-4', state: 'terminated' }) - - recordFsCalls(dir) - try { - await store.markSshRemotePtyLeasesAttachedAsync('ssh-1', ['pty-1', 'pty-2', 'pty-4']) - } finally { - fsCalls.recording = false - } - - expect(fsCalls.syncCalls).toEqual([]) - const persisted = JSON.parse(readFileSync(dataFile(dir), 'utf-8')) as { - sshRemotePtyLeases: { ptyId: string; state: string }[] - } - expect(persisted.sshRemotePtyLeases).toEqual( + record(directory) + const write = vi.spyOn(authority, 'writeSerializedDomains') + await store.markSshRemotePtyLeasesAttachedAsync('ssh-1', ['pty-1', 'pty-2', 'pty-4']) + await authority.drainBackups() + fsCalls.recording = false + expect(fsCalls.sync).toEqual([]) + expect(write).toHaveBeenCalledOnce() + expect(readState().sshRemotePtyLeases).toEqual( expect.arrayContaining([ expect.objectContaining({ ptyId: 'pty-1', state: 'attached' }), - // An id-qualified reattach named this pty and succeeded, which is the one thing that can - // settle what `expired` meant: the client had lost its route, not that the shell died. expect.objectContaining({ ptyId: 'pty-2', state: 'attached' }), expect.objectContaining({ ptyId: 'pty-3', state: 'detached' }), expect.objectContaining({ ptyId: 'pty-4', state: 'terminated' }) @@ -830,97 +336,30 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { ) }) - it('keeps async writers serialized across a synchronous shutdown flush', async () => { - const dir = makeDir() - const store = await createStore(dir) - const firstOpen = deferred() - const firstOpenRelease = deferred() - let held = false - fsCalls.waitAsync = (fn, target) => { - if (held || fn !== 'open' || !target.endsWith('.tmp')) { - return null - } - held = true - firstOpen.resolve() - return firstOpenRelease.promise - } - - recordFsCalls(dir) - try { - const firstWrite = store.upsertSshPtyConsumerRecovery(consumerRecovery('client-1')) - await firstOpen.promise - store.flushOrThrow() - const secondWrite = store.upsertSshPtyConsumerRecovery(consumerRecovery('client-2')) - await Promise.resolve() - await Promise.resolve() - - expect(fsCalls.asyncCalls.filter((call) => call.startsWith('open:'))).toHaveLength(1) - firstOpenRelease.resolve() - await Promise.all([firstWrite, secondWrite]) - } finally { - firstOpenRelease.resolve() - fsCalls.recording = false - fsCalls.waitAsync = null - } - - const persisted = JSON.parse(readFileSync(dataFile(dir), 'utf-8')) as { - sshPtyConsumerRecoveries: { clientInstanceId: string }[] - } - expect(persisted.sshPtyConsumerRecoveries[0]?.clientInstanceId).toBe('client-2') + it('serializes queued durable mutations and refuses a synchronous live flush', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const gate = pauseCommit(authority) + const first = store.upsertSshPtyConsumerRecovery(consumerRecovery('client-1')) + await gate.started + expect(() => store.flushOrThrow()).toThrow('awaited flush') + const second = store.upsertSshPtyConsumerRecovery(consumerRecovery('client-2')) + await Promise.resolve() + expect( + vi.mocked(authority.writeSerializedDomains).mock.calls.length + + vi.mocked(authority.writeCompleteSerializedDomains).mock.calls.length + ).toBe(1) + gate.release() + await Promise.all([first, second]) + expect(readState().sshPtyConsumerRecoveries[0].clientInstanceId).toBe('client-2') }) - it('lets a main-thread timer keep firing while a consumer-recovery write is in flight', async () => { - // The P1-A freeze itself: a stalled profile mount must not park the main thread on establish. - vi.useRealTimers() - const dir = makeDir() - const store = await createStore(dir) - const stallMs = 1_000 - // Why half: a sync write parks the loop for at least stallMs while the async path ticks every - // ~10ms, so this leaves room for scheduler jitter on a loaded runner without going vacuous. - const maxAcceptableGapMs = stallMs / 2 - - let lastTick = Date.now() - let worstGapMs = 0 - const heartbeat = setInterval(() => { - const now = Date.now() - worstGapMs = Math.max(worstGapMs, now - lastTick) - lastTick = now - }, 10) - - try { - fsCalls.dirPrefix = dir - fsCalls.stallMs = stallMs - fsCalls.recording = true - lastTick = Date.now() - const write = store.upsertSshPtyConsumerRecovery(consumerRecovery('client-1')) - // Why not await first: a fully synchronous write finishes before the interval can fire, so the - // heartbeat would never observe the stall it exists to detect. - await new Promise((resolve) => setTimeout(resolve, stallMs + 200)) - await write - } finally { - fsCalls.recording = false - clearInterval(heartbeat) - } - - expect(worstGapMs).toBeLessThan(maxAcceptableGapMs) - expect(readFileSync(dataFile(dir), 'utf-8')).toContain('client-1') - }, 20_000) - - it('keeps the sync quit/crash fallback on synchronous syscalls', async () => { - const dir = makeDir() - const store = await createStore(dir) - seedStaleBackup(dir) - + it('keeps admitted offline SQL checkpoints synchronous and durable', () => { + const { store, authority, dataFile } = createSqliteMaintenanceFixture() + const write = vi.spyOn(authority, 'writeCompleteSerializedDomains') store.updateUI({ sidebarWidth: 331 }) - recordFsCalls(dir) - try { - store.flushOrThrow() - } finally { - fsCalls.recording = false - } - - expect(fsCalls.syncCalls).toContain(`statSync:${dataFile(dir)}.bak.0`) - expect(fsCalls.syncCalls).toContain(`existsSync:${dataFile(dir)}`) - expect(fsCalls.asyncCalls).toEqual([]) + store.flushOrThrow() + expect(write).toHaveBeenCalledOnce() + expect(JSON.parse(authority.readSerializedState() ?? '{}').ui.sidebarWidth).toBe(331) + expect(existsSync(dataFile)).toBe(false) }) }) diff --git a/src/main/persistence-automations.test.ts b/src/main/persistence-automations.test.ts index 9db16b7e681..fcd61b77ec3 100644 --- a/src/main/persistence-automations.test.ts +++ b/src/main/persistence-automations.test.ts @@ -1,16 +1,17 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { rmSync, mkdtempSync } from 'node:fs' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import type { PersistedState } from '../shared/persisted-state-types' -import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../shared/execution-host' import { + closeTestStores, testState, createStore, writeDataFile, readDataFile, makeRepo } from './persistence-test-harness' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { rmSync, mkdtempSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { PersistedState } from '../shared/persisted-state-types' +import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../shared/execution-host' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -60,7 +61,8 @@ describe('Store', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) it('can clear an automation back to the project default branch', async () => { diff --git a/src/main/persistence-clipboard-selection-migration.test.ts b/src/main/persistence-clipboard-selection-migration.test.ts index c04b4d5bdd6..552aa10b6f7 100644 --- a/src/main/persistence-clipboard-selection-migration.test.ts +++ b/src/main/persistence-clipboard-selection-migration.test.ts @@ -1,14 +1,15 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { rmSync, mkdtempSync } from 'node:fs' -import { join } from 'node:path' -import { tmpdir } from 'node:os' import { + closeTestStores, testState, createStore, withPlatform, writeDataFile, readDataFile } from './persistence-test-harness' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { rmSync, mkdtempSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -58,7 +59,8 @@ describe('Store', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) it('migrates the legacy floating terminal disabled default to enabled', async () => { diff --git a/src/main/persistence-cohort-and-identity-migration.test.ts b/src/main/persistence-cohort-and-identity-migration.test.ts index a894b8a4c63..02afa7e3da5 100644 --- a/src/main/persistence-cohort-and-identity-migration.test.ts +++ b/src/main/persistence-cohort-and-identity-migration.test.ts @@ -1,11 +1,5 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { writeFileSync, rmSync, mkdtempSync, mkdirSync } from 'node:fs' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' -import { getDefaultWorkspaceSession } from '../shared/constants' -import { folderWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope' import { + closeTestStores, testState, createStore, dataFile, @@ -15,6 +9,14 @@ import { makeWorktreeLineage, makeWorkspaceLineage } from './persistence-test-harness' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { writeFileSync, rmSync, mkdtempSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' +import { getDefaultWorkspaceSession } from '../shared/constants' +import { folderWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope' +import { createProfileStateStore } from './persistence/profile-state/profile-state-store-factory' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -64,7 +66,8 @@ describe('Store', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) // ── Telemetry cohort migration ───────────────────────────────────── @@ -105,19 +108,16 @@ describe('Store', () => { expect(store.getSettings().theme).toBe('dark') }) - it('still classifies as existing-user cohort when the data file is corrupt', async () => { - // Load-bearing: the corrupt-file catch path keeps `fileExistedOnLoad` true so a corrupted install isn't silently opted in as fresh. - mkdirSync(testState.dir, { recursive: true }) - writeFileSync(dataFile(), '{{{corrupt json', 'utf-8') - const store = await createStore() - const t = store.getSettings().telemetry - expect(t).toBeDefined() - expect(t!.existedBeforeTelemetryRelease).toBe(true) - expect(t!.optedIn).toBeNull() - expect(t!.installId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ - ) - expect(store.getSettings().experimentalNewWorktreeCardStyle).toBe(false) + it('retains the existing-user cohort when a corrupt legacy primary recovers from backup', async () => { + await withRecoveredLegacyProfile((store) => { + const telemetry = store.getSettings().telemetry + expect(telemetry?.existedBeforeTelemetryRelease).toBe(true) + expect(telemetry?.optedIn).toBeNull() + expect(telemetry?.installId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + ) + expect(store.getSettings().experimentalNewWorktreeCardStyle).toBe(false) + }) }) it('preserves an already-migrated telemetry block on subsequent launches', async () => { @@ -152,7 +152,8 @@ describe('Store.migrateTabSwitchKeybindings', () => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) @@ -176,11 +177,10 @@ describe('Store.migrateTabSwitchKeybindings', () => { expect(store.getSettings().theme).toBe('dark') }) - it('treats a corrupt data file as a pre-existing install', async () => { - mkdirSync(testState.dir, { recursive: true }) - writeFileSync(dataFile(), '{{{corrupt json', 'utf-8') - const store = await createStore() - expect(store.getSettings().tabSwitchKeybindingSeed).toBe('pending') + it('retains the existing keybinding cohort when recovering a legacy backup', async () => { + await withRecoveredLegacyProfile((store) => { + expect(store.getSettings().tabSwitchKeybindingSeed).toBe('pending') + }) }) it('preserves an already-frozen cohort on subsequent launches', async () => { @@ -209,7 +209,8 @@ describe('Store.migrateWorktreeIdentity', () => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) @@ -442,3 +443,21 @@ describe('Store.migrateWorktreeIdentity', () => { expect(store.getWorktreeMeta(OLD)?.priorWorktreeIds).toBeUndefined() }) }) + +async function withRecoveredLegacyProfile( + verify: (store: ReturnType['store']) => void +): Promise { + writeDataFile({ schemaVersion: 1, repos: [], worktreeMeta: {}, settings: {}, ui: {} }) + writeFileSync(`${dataFile()}.bak.0`, readFileSync(dataFile())) + writeFileSync(dataFile(), '{{{corrupt json') + const { store } = createProfileStateStore({ + dataFile: dataFile(), + databaseFile: join(testState.dir, 'profile-state.db'), + profileId: 'cohort-recovery' + }) + try { + verify(store) + } finally { + await store.freezeWritesAsync() + } +} diff --git a/src/main/persistence-cross-host-pane-identity.test.ts b/src/main/persistence-cross-host-pane-identity.test.ts index 479d3727837..d3bdab23d6b 100644 --- a/src/main/persistence-cross-host-pane-identity.test.ts +++ b/src/main/persistence-cross-host-pane-identity.test.ts @@ -1,11 +1,5 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { rmSync, mkdtempSync } from 'node:fs' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' -import { getDefaultWorkspaceSession } from '../shared/constants' -import { isTerminalLeafId } from '../shared/stable-pane-id' import { + closeTestStores, testState, createStore, writeDataFile, @@ -13,6 +7,13 @@ import { makeRepo, makeTerminalTab } from './persistence-test-harness' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { rmSync, mkdtempSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' +import { getDefaultWorkspaceSession } from '../shared/constants' +import { isTerminalLeafId } from '../shared/stable-pane-id' vi.mock('electron', () => ({ app: { getPath: () => testState.dir }, @@ -47,7 +48,8 @@ describe('cross-host pane identity migration', () => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) diff --git a/src/main/persistence-deregistered-repo-residue.test.ts b/src/main/persistence-deregistered-repo-residue.test.ts index a7fb4d7353f..b519b04cad8 100644 --- a/src/main/persistence-deregistered-repo-residue.test.ts +++ b/src/main/persistence-deregistered-repo-residue.test.ts @@ -1,3 +1,12 @@ +import { + closeTestStores, + testState, + createStore, + writeDataFile, + readDataFile, + makeRepo, + makeTerminalTab +} from './persistence-test-harness' // Why this file exists: deregistering a project used to strand every row it owned. No sweeper could // reach them because the missing-directory prune is gated on the repo still being registered. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' @@ -8,14 +17,6 @@ import { getDefaultWorkspaceSession } from '../shared/constants' import { composeWorktreeHostIdentity } from '../shared/worktree/host-qualified-identity' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope' import type { PersistedState } from '../shared/persisted-state-types' -import { - testState, - createStore, - writeDataFile, - readDataFile, - makeRepo, - makeTerminalTab -} from './persistence-test-harness' vi.mock('./ssh/ssh-config-parser', () => ({ loadUserSshConfig: vi.fn(), @@ -67,7 +68,8 @@ describe('deregistered repo residue', () => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-orphan-sweep-')) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) @@ -186,6 +188,31 @@ describe('deregistered repo residue', () => { expect(reloaded.sweepDeregisteredRepoResidue()).toEqual([]) }) + // A close record names its workspace only in its value, so like a sleeping agent it must seed the + // sweep itself or a removed project's records wait out the TTL. + it("drops a close record that is the orphan repo's only residue, and self-clears", async () => { + writeDataFile({ + schemaVersion: 1, + repos: [makeRepo({ id: LIVE_REPO, path: '/workspace/live' })], + worktreeMeta: {}, + workspaceSession: { + ...getDefaultWorkspaceSession(), + closedTerminalTabTombstonesByTabId: { + 'tab-gone': { worktreeId: GONE_WORKTREE, closedAt: Date.now(), reason: 'user' }, + 'tab-live': { worktreeId: LIVE_WORKTREE, closedAt: Date.now(), reason: 'user' } + } + } + }) + + const store = await createStore() + store.flush() + expect( + Object.keys(store.getWorkspaceSession('local').closedTerminalTabTombstonesByTabId ?? {}) + ).toEqual(['tab-live']) + const reloaded = await createStore() + expect(reloaded.sweepDeregisteredRepoResidue()).toEqual([]) + }) + // The session scalars are pruned by bespoke rules, not by owner key, so no owner-key loop reaches // them. Each has to be able to seed the sweep on its own or an orphan named only there is stuck. it.each([ diff --git a/src/main/persistence-duplicate-repo-id-host-scope.test.ts b/src/main/persistence-duplicate-repo-id-host-scope.test.ts index 0da93c3a979..48cb826f909 100644 --- a/src/main/persistence-duplicate-repo-id-host-scope.test.ts +++ b/src/main/persistence-duplicate-repo-id-host-scope.test.ts @@ -1,3 +1,4 @@ +import { closeTestStores, createSqliteTestStore } from './persistence-test-harness' /** * The same repo id may be registered on two execution hosts (see `removeProjectForHost`). * Every deletion that resolves a *row* must therefore delete only that row: `removeProject` @@ -57,7 +58,7 @@ async function createStoreFromState(state: Record) { // file's temp dir rather than the global fake's shared one, after resetModules. installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() - return new Store() + return createSqliteTestStore(Store, { dataFile: join(testState.dir, 'orca-data.json') }) } function createStoreWithDuplicateRepoId() { @@ -98,7 +99,8 @@ beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-dup-repo-id-')) }) -afterEach(() => { +afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) diff --git a/src/main/persistence-feature-interaction-broadcast.benchmark.test.ts b/src/main/persistence-feature-interaction-broadcast.benchmark.test.ts index cfb7f555b08..95f4836d692 100644 --- a/src/main/persistence-feature-interaction-broadcast.benchmark.test.ts +++ b/src/main/persistence-feature-interaction-broadcast.benchmark.test.ts @@ -1,4 +1,9 @@ -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { + closeTestStores, + createSqliteTestStore, + readPersistedStateJson +} from './persistence-test-harness' +import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { performance } from 'node:perf_hooks' @@ -27,11 +32,12 @@ function createStore(name: string): { dataFile: string; store: Store } { const dir = mkdtempSync(join(tmpdir(), `orca-ui-broadcast-${name}-`)) tempDirs.push(dir) const dataFile = join(dir, 'orca-data.json') - return { dataFile, store: new Store({ dataFile }) } + return { dataFile, store: createSqliteTestStore(Store, { dataFile }) } } describe('feature interaction UI broadcast benchmark', () => { - afterEach(() => { + afterEach(async () => { + await closeTestStores() for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }) } @@ -76,9 +82,12 @@ describe('feature interaction UI broadcast benchmark', () => { ).toBe(INTERACTIONS) optimizedStore.flush() expect( - new Store({ dataFile }).getUI().featureInteractions?.['agent-orchestration']?.interactionCount + createSqliteTestStore(Store, { dataFile }).getUI().featureInteractions?.[ + 'agent-orchestration' + ]?.interactionCount ).toBe(INTERACTIONS) - const persisted = JSON.parse(readFileSync(dataFile, 'utf8')) as PersistedState + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The preceding Store save produced the PersistedState snapshot read by this test. + const persisted = JSON.parse(readPersistedStateJson(dataFile)) as PersistedState expect(persisted.featureInteractionTelemetryBuckets?.['agent-orchestration']).toBe( 'count_200_499' ) diff --git a/src/main/persistence-floating-terminal-trust.test.ts b/src/main/persistence-floating-terminal-trust.test.ts index 630a1c16559..49ea6457e6d 100644 --- a/src/main/persistence-floating-terminal-trust.test.ts +++ b/src/main/persistence-floating-terminal-trust.test.ts @@ -1,15 +1,16 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { rmSync, mkdtempSync, mkdirSync, realpathSync } from 'node:fs' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import type { PersistedState } from '../shared/persisted-state-types' import { + closeTestStores, testState, createStore, writeDataFile, readDataFile, symlinkDirectorySync } from './persistence-test-harness' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { rmSync, mkdtempSync, mkdirSync, realpathSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { PersistedState } from '../shared/persisted-state-types' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -59,7 +60,8 @@ describe('Store', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) it('seeds trusted floating workspace directories from legacy explicit cwd values', async () => { diff --git a/src/main/persistence-flush-and-save-scheduling.test.ts b/src/main/persistence-flush-and-save-scheduling.test.ts index 2d6180d5fa8..dbe4ce8262f 100644 --- a/src/main/persistence-flush-and-save-scheduling.test.ts +++ b/src/main/persistence-flush-and-save-scheduling.test.ts @@ -1,10 +1,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { rmSync, mkdtempSync, mkdirSync, existsSync, statSync } from 'node:fs' +import { rmSync, mkdtempSync, mkdirSync, existsSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import type { PersistedState } from '../shared/persisted-state-types' import type { Repo } from '../shared/repo-types' import { + closeTestStores, + createSqliteTestStore, + readPersistedStateJson, testState, createStore, withPlatform, @@ -68,9 +71,10 @@ describe('Store', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) }) - afterEach(() => { + afterEach(async () => { vi.restoreAllMocks() _resetPtyBindingSpanSamplingForTests() + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) // ── 10. flush writes synchronously ───────────────────────────────── @@ -85,6 +89,26 @@ describe('Store', () => { expect(persisted.repos[0].id).toBe('r1') }) + it('durably commits an exact JSON operation before a following microtask changes generation', async () => { + const store = await createStore() + const originalTabId = store.getWorkspaceSession().activeTabId + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + queueMicrotask(() => { + store.setWorkspaceSession({ ...store.getWorkspaceSession(), activeTabId: 'newer-tab' }) + }) + return { value: undefined } + }) + expect(readDataFile()).toMatchObject({ + settings: { theme: 'dark' }, + workspaceSession: { activeTabId: originalTabId } + }) + expect(store.getWorkspaceSession().activeTabId).toBe('newer-tab') + await store.flushPendingOrThrowAsync() + expect(readDataFile()).toHaveProperty(['workspaceSession', 'activeTabId'], 'newer-tab') + store.freezeWrites() + }) + it('flush remains safe when a debounced save is also pending', async () => { vi.useFakeTimers() try { @@ -126,7 +150,7 @@ describe('Store', () => { }) // ── Content-hash write skipping ──────────────────────────────────── - // Why inode comparison: every real write is a tmp+rename (new inode), so an unchanged inode proves no write happened. + // Retained ciphertext makes an unintended secret rewrite visible in the SQL projection. it('skips the disk write when a mutation burst nets out to already-persisted state', async () => { vi.useFakeTimers() @@ -135,14 +159,14 @@ describe('Store', () => { store.updateUI({ sidebarWidth: 400 }) vi.advanceTimersByTime(1000) await store.waitForPendingWrite() - const inoBefore = statSync(dataFile()).ino + const stateBefore = readPersistedStateJson(dataFile()) store.updateUI({ sidebarWidth: 500 }) store.updateUI({ sidebarWidth: 400 }) vi.advanceTimersByTime(2000) await store.waitForPendingWrite() - expect(statSync(dataFile()).ino).toBe(inoBefore) + expect(readPersistedStateJson(dataFile())).toBe(stateBefore) } finally { vi.useRealTimers() } @@ -155,11 +179,11 @@ describe('Store', () => { store.updateUI({ sidebarWidth: 420 }) vi.advanceTimersByTime(1000) await store.waitForPendingWrite() - const inoBefore = statSync(dataFile()).ino + const stateBefore = readPersistedStateJson(dataFile()) store.flush() - expect(statSync(dataFile()).ino).toBe(inoBefore) + expect(readPersistedStateJson(dataFile())).toBe(stateBefore) } finally { vi.useRealTimers() } @@ -177,7 +201,7 @@ describe('Store', () => { } await store.waitForPendingWrite() - expect(existsSync(dataFile())).toBe(true) + expect(existsSync(join(testState.dir, 'profile-state.db'))).toBe(true) const persisted = readDataFile() as { ui: { sidebarWidth: number } } expect(persisted.ui.sidebarWidth).toBeGreaterThanOrEqual(400) } finally { @@ -220,13 +244,13 @@ describe('Store', () => { leafId: TEST_LEAF_1, ptyId: 'daemon-pty' } - store.persistPtyBinding(binding) - const inoBefore = statSync(dataFile()).ino + await store.persistPtyBinding(binding) + const stateBefore = readPersistedStateJson(dataFile()) // Warm-restart re-bind storm: an identical binding re-asserted with a sync flush must not rewrite. - store.persistPtyBinding(binding) + await store.persistPtyBinding(binding) - expect(statSync(dataFile()).ino).toBe(inoBefore) + expect(readPersistedStateJson(dataFile())).toBe(stateBefore) }) // ── worktreeMeta startup GC ──────────────────────────────────────── @@ -326,14 +350,14 @@ describe('Store', () => { store.updateUI({ sidebarWidth: 411 }) vi.advanceTimersByTime(1000) await store.waitForPendingWrite() - const inoBefore = statSync(dataFile()).ino + const stateBefore = readPersistedStateJson(dataFile()) expect((readDataFile() as { githubCache?: unknown }).githubCache).toBeUndefined() store.setGitHubCache({ pr: { 'o/r#1': { fetchedAt: 123 } as never }, issue: {} }) vi.advanceTimersByTime(6000) await store.waitForPendingWrite() - expect(statSync(dataFile()).ino).toBe(inoBefore) + expect(readPersistedStateJson(dataFile())).toBe(stateBefore) } finally { vi.useRealTimers() } @@ -360,17 +384,17 @@ describe('Store', () => { vi.resetModules() const { Store, initDataPath } = await import('./persistence') initDataPath() - const profileAStore = new Store({ dataFile: profileADataFile }) + const profileAStore = createSqliteTestStore(Store, { dataFile: profileADataFile }) profileAStore.setGitHubCache({ pr: { 'o/r#a': { fetchedAt: 10 } as never }, issue: {} }) profileAStore.flush() - const profileBStore = new Store({ dataFile: profileBDataFile }) + const profileBStore = createSqliteTestStore(Store, { dataFile: profileBDataFile }) expect(profileBStore.getGitHubCache().pr['o/r#a']).toBeUndefined() profileBStore.setGitHubCache({ pr: { 'o/r#b': { fetchedAt: 20 } as never }, issue: {} }) profileBStore.flush() - const restartedProfileA = new Store({ dataFile: profileADataFile }) - const restartedProfileB = new Store({ dataFile: profileBDataFile }) + const restartedProfileA = createSqliteTestStore(Store, { dataFile: profileADataFile }) + const restartedProfileB = createSqliteTestStore(Store, { dataFile: profileBDataFile }) expect(restartedProfileA.getGitHubCache().pr['o/r#a']).toEqual({ fetchedAt: 10 }) expect(restartedProfileA.getGitHubCache().pr['o/r#b']).toBeUndefined() expect(restartedProfileB.getGitHubCache().pr['o/r#b']).toEqual({ fetchedAt: 20 }) @@ -423,7 +447,7 @@ describe('Store', () => { } } - afterEach(() => { + afterEach(async () => { _resetTracerForTests() }) @@ -432,47 +456,53 @@ describe('Store', () => { async (hostId) => { const store = await createStore() store.setWorkspaceSession(boundSession(), hostId) - expect(store.persistPtyBinding(binding, hostId)).toBe(true) - const inoBefore = statSync(dataFile()).ino - const flushSpy = vi.spyOn(store, 'flushOrThrow') + expect(await store.persistPtyBinding(binding, hostId)).toBe(true) + const stateBefore = readPersistedStateJson(dataFile()) + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration const cloneSpy = vi.spyOn(globalThis, 'structuredClone') - expect(store.persistPtyBinding(binding, hostId)).toBe(true) + expect(await store.persistPtyBinding(binding, hostId)).toBe(true) - expect(flushSpy).not.toHaveBeenCalled() + expect(runtimeCounters(store).lastDurableWriteGeneration).toBe(durableGenerationBefore) expect(cloneSpy).not.toHaveBeenCalled() - expect(statSync(dataFile()).ino).toBe(inoBefore) + expect(readPersistedStateJson(dataFile())).toBe(stateBefore) } ) it('flushes while a save is pending, and the sync hash match makes the next call durable', async () => { const store = await createStore() store.setWorkspaceSession(boundSession()) - store.persistPtyBinding(binding) - const inoBefore = statSync(dataFile()).ino + await store.persistPtyBinding(binding) + const stateBefore = readPersistedStateJson(dataFile()) // Bumps the write generation without changing any binding. store.setWorkspaceSession({ ...store.getWorkspaceSession() }) - const flushSpy = vi.spyOn(store, 'flushOrThrow') + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration - expect(store.persistPtyBinding(binding)).toBe(true) - expect(flushSpy).toHaveBeenCalledTimes(1) - expect(statSync(dataFile()).ino).toBe(inoBefore) + expect(await store.persistPtyBinding(binding)).toBe(true) + expect(runtimeCounters(store).lastDurableWriteGeneration).toBeGreaterThan( + durableGenerationBefore + ) + expect(readPersistedStateJson(dataFile())).toBe(stateBefore) // Without the writeToDiskSync counter fix the hash-match flush leaves the durable // generation one behind and this third bind would flush again. - expect(store.persistPtyBinding(binding)).toBe(true) - expect(flushSpy).toHaveBeenCalledTimes(1) + expect(await store.persistPtyBinding(binding)).toBe(true) + expect(runtimeCounters(store).lastDurableWriteGeneration).toBeGreaterThan( + durableGenerationBefore + ) }) it('falls through on an incarnation change and persists the new incarnation', async () => { const store = await createStore() store.setWorkspaceSession(boundSession()) - store.persistPtyBinding({ ...binding, incarnationId: 'a' }) - const flushSpy = vi.spyOn(store, 'flushOrThrow') + await store.persistPtyBinding({ ...binding, incarnationId: 'a' }) + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration - expect(store.persistPtyBinding({ ...binding, incarnationId: 'b' })).toBe(true) + expect(await store.persistPtyBinding({ ...binding, incarnationId: 'b' })).toBe(true) - expect(flushSpy).toHaveBeenCalledTimes(1) + expect(runtimeCounters(store).lastDurableWriteGeneration).toBeGreaterThan( + durableGenerationBefore + ) expect(readDataFile()).toHaveProperty( ['workspaceSession', 'terminalPtyIncarnationsByPaneKey', paneKey], 'b' @@ -482,18 +512,19 @@ describe('Store', () => { it('does not acknowledge an unpersisted binding published after the final flush', async () => { const store = await createStore() store.setWorkspaceSession(boundSession()) - store.persistPtyBinding(binding) + await store.persistPtyBinding(binding) await store.flushAsync() const next = boundSession() next.tabsByWorktree[WORKTREE][0].ptyId = 'pty-after-quit' next.terminalLayoutsByTabId.tab1.ptyIdsByLeafId = { [TEST_LEAF_1]: 'pty-after-quit' } - store.setWorkspaceSession(next) + expect(() => store.setWorkspaceSession(next)).toThrow('finalization') + Object.assign(store.getWorkspaceSession(), next) expect(store.getWorkspaceSession().tabsByWorktree[WORKTREE][0].ptyId).toBe('pty-after-quit') - expect(() => store.persistPtyBinding({ ...binding, ptyId: 'pty-after-quit' })).toThrow( - 'Cannot synchronously flush after final persistence has started' - ) + await expect( + store.persistPtyBinding({ ...binding, ptyId: 'pty-after-quit' }) + ).rejects.toThrow('Cannot mutate finalized profile persistence') expect(readDataFile()).toHaveProperty( ['workspaceSession', 'tabsByWorktree', WORKTREE, '0', 'ptyId'], 'pty-1' @@ -503,12 +534,14 @@ describe('Store', () => { it('treats an undefined incarnation against a recorded one as a miss', async () => { const store = await createStore() store.setWorkspaceSession(boundSession()) - store.persistPtyBinding({ ...binding, incarnationId: 'a' }) - const flushSpy = vi.spyOn(store, 'flushOrThrow') + await store.persistPtyBinding({ ...binding, incarnationId: 'a' }) + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration - expect(store.persistPtyBinding(binding)).toBe(true) + expect(await store.persistPtyBinding(binding)).toBe(true) - expect(flushSpy).toHaveBeenCalledTimes(1) + expect(runtimeCounters(store).lastDurableWriteGeneration).toBeGreaterThan( + durableGenerationBefore + ) }) it('falls through on a tombstone and lets the write path clear it', async () => { @@ -532,11 +565,13 @@ describe('Store', () => { expect( store.getWorkspaceSession().terminalSurfaceTombstonesByPaneKey?.[paneKey] ).toBeDefined() - const flushSpy = vi.spyOn(store, 'flushOrThrow') + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration - expect(store.persistPtyBinding({ ...binding, incarnationId: 'inc-1' })).toBe(true) + expect(await store.persistPtyBinding({ ...binding, incarnationId: 'inc-1' })).toBe(true) - expect(flushSpy).toHaveBeenCalledTimes(1) + expect(runtimeCounters(store).lastDurableWriteGeneration).toBeGreaterThan( + durableGenerationBefore + ) expect( store.getWorkspaceSession().terminalSurfaceTombstonesByPaneKey?.[paneKey] ).toBeUndefined() @@ -547,20 +582,22 @@ describe('Store', () => { store.setWorkspaceSession( boundSession({ terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-stale' } }) ) - store.persistPtyBinding({ ...binding, incarnationId: 'inc-stale' }) + await store.persistPtyBinding({ ...binding, incarnationId: 'inc-stale' }) const revisionBefore = store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.repo1 ?? 0 - const flushSpy = vi.spyOn(store, 'flushOrThrow') + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration expect( - store.persistPtyBinding({ + await store.persistPtyBinding({ ...binding, incarnationId: 'inc-live', expectedBinding: { ptyId: 'pty-1', incarnationId: 'inc-stale' } }) ).toBe(true) - expect(flushSpy).toHaveBeenCalledTimes(1) + expect(runtimeCounters(store).lastDurableWriteGeneration).toBeGreaterThan( + durableGenerationBefore + ) expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.repo1).toBe( revisionBefore + 1 ) @@ -571,8 +608,8 @@ describe('Store', () => { store.setWorkspaceSession( boundSession({ terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-1' } }) ) - store.persistPtyBinding({ ...binding, incarnationId: 'inc-1' }) - const flushSpy = vi.spyOn(store, 'flushOrThrow') + await store.persistPtyBinding({ ...binding, incarnationId: 'inc-1' }) + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration const refusals = [ { @@ -583,9 +620,9 @@ describe('Store', () => { { ...binding, tabId: 'missing-tab', mayCreate: false } ] for (const refusal of refusals) { - expect(store.persistPtyBinding(refusal)).toBe(false) + expect(await store.persistPtyBinding(refusal)).toBe(false) } - expect(flushSpy).not.toHaveBeenCalled() + expect(runtimeCounters(store).lastDurableWriteGeneration).toBe(durableGenerationBefore) }) it.each([undefined, 'ssh:ssh-1', 'runtime:runtime-1'])( @@ -593,14 +630,16 @@ describe('Store', () => { async (hostId) => { const store = await createStore() store.setWorkspaceSession(boundSession(), hostId) - expect(store.persistPtyBinding(binding, hostId)).toBe(true) + expect(await store.persistPtyBinding(binding, hostId)).toBe(true) store.addRepo(makeRepo({ id: 'r-dirty', path: '/dirty' })) - const flushSpy = vi.spyOn(store, 'flushOrThrow') + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration - expect(store.persistPtyBinding(binding, hostId)).toBe(true) - expect(store.persistPtyBinding(binding, hostId)).toBe(true) + expect(await store.persistPtyBinding(binding, hostId)).toBe(true) + expect(await store.persistPtyBinding(binding, hostId)).toBe(true) - expect(flushSpy).toHaveBeenCalledTimes(1) + expect(runtimeCounters(store).lastDurableWriteGeneration).toBeGreaterThan( + durableGenerationBefore + ) expect(readDataFile()).toMatchObject({ repos: expect.arrayContaining([expect.objectContaining({ id: 'r-dirty' })]) }) @@ -610,27 +649,31 @@ describe('Store', () => { it('flushes again once the session object is replaced', async () => { const store = await createStore() store.setWorkspaceSession(boundSession()) - store.persistPtyBinding(binding) + await store.persistPtyBinding(binding) // A renderer publish schedules another save, so global durability must be re-established. store.setWorkspaceSession({ ...store.getWorkspaceSession() }) store.addRepo(makeRepo({ id: 'r-dirty', path: '/dirty' })) - const flushSpy = vi.spyOn(store, 'flushOrThrow') + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration - expect(store.persistPtyBinding(binding)).toBe(true) + expect(await store.persistPtyBinding(binding)).toBe(true) - expect(flushSpy).toHaveBeenCalledTimes(1) + expect(runtimeCounters(store).lastDurableWriteGeneration).toBeGreaterThan( + durableGenerationBefore + ) }) it('flushes a changed pty for a pane whose old binding was durable', async () => { const store = await createStore() store.setWorkspaceSession(boundSession()) - store.persistPtyBinding(binding) + await store.persistPtyBinding(binding) store.addRepo(makeRepo({ id: 'r-dirty', path: '/dirty' })) - const flushSpy = vi.spyOn(store, 'flushOrThrow') + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration - expect(store.persistPtyBinding({ ...binding, ptyId: 'pty-next' })).toBe(true) + expect(await store.persistPtyBinding({ ...binding, ptyId: 'pty-next' })).toBe(true) - expect(flushSpy).toHaveBeenCalledTimes(1) + expect(runtimeCounters(store).lastDurableWriteGeneration).toBeGreaterThan( + durableGenerationBefore + ) expect(readDataFile()).toHaveProperty( ['workspaceSession', 'terminalLayoutsByTabId', 'tab1', 'ptyIdsByLeafId', TEST_LEAF_1], 'pty-next' @@ -658,16 +701,16 @@ describe('Store', () => { ) const sibling = { ...binding, leafId: TEST_LEAF_2, ptyId: 'pty-2' } // First remount after a cold park: both panes reattach back to back. - expect(store.persistPtyBinding(binding)).toBe(true) - expect(store.persistPtyBinding(sibling)).toBe(true) + expect(await store.persistPtyBinding(binding)).toBe(true) + expect(await store.persistPtyBinding(sibling)).toBe(true) expect(store.getWorkspaceSession().tabsByWorktree?.[WORKTREE]?.[0]?.ptyId).toBe('pty-1') - const flushSpy = vi.spyOn(store, 'flushOrThrow') + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration // Second remount: neither pane may rewrite the tab row, so neither flushes. - expect(store.persistPtyBinding(sibling)).toBe(true) - expect(store.persistPtyBinding(binding)).toBe(true) + expect(await store.persistPtyBinding(sibling)).toBe(true) + expect(await store.persistPtyBinding(binding)).toBe(true) - expect(flushSpy).not.toHaveBeenCalled() + expect(runtimeCounters(store).lastDurableWriteGeneration).toBe(durableGenerationBefore) expect(store.getWorkspaceSession().tabsByWorktree?.[WORKTREE]?.[0]?.ptyId).toBe('pty-1') }) @@ -675,14 +718,14 @@ describe('Store', () => { const store = await createStore() const hostId = 'ssh:ssh-1' store.setWorkspaceSession(boundSession(), hostId) - expect(store.persistPtyBinding(binding, hostId)).toBe(true) + expect(await store.persistPtyBinding(binding, hostId)).toBe(true) const partitionBefore = store.getWorkspaceSession(hostId) const partitionsBefore = store['runtime'].state.workspaceSessionsByHostId - const flushSpy = vi.spyOn(store, 'flushOrThrow') + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration - expect(store.persistPtyBinding(binding, hostId)).toBe(true) + expect(await store.persistPtyBinding(binding, hostId)).toBe(true) - expect(flushSpy).not.toHaveBeenCalled() + expect(runtimeCounters(store).lastDurableWriteGeneration).toBe(durableGenerationBefore) expect(store.getWorkspaceSession(hostId)).toBe(partitionBefore) expect(store['runtime'].state.workspaceSessionsByHostId).toBe(partitionsBefore) expect(store.getWorkspaceSession().tabsByWorktree?.[WORKTREE]).toBeUndefined() @@ -692,13 +735,13 @@ describe('Store', () => { const store = await createStore() store.addRepo(makeRepo()) store.flushOrThrow() - const inoBefore = statSync(dataFile()).ino + const stateBefore = readPersistedStateJson(dataFile()) const after = runtimeCounters(store) expect(after.lastDurableWriteGeneration).toBe(after.writeGeneration) store.flushOrThrow() - expect(statSync(dataFile()).ino).toBe(inoBefore) + expect(readPersistedStateJson(dataFile())).toBe(stateBefore) const counters = runtimeCounters(store) expect(counters.writeGeneration).toBe(after.writeGeneration + 1) expect(counters.lastDurableWriteGeneration).toBe(counters.writeGeneration) @@ -716,9 +759,9 @@ describe('Store', () => { const store = await createStore() store.setWorkspaceSession(boundSession()) - store.persistPtyBinding(binding) - store.persistPtyBinding(binding) - store.persistPtyBinding({ ...binding, tabId: 'missing-tab', mayCreate: false }) + await store.persistPtyBinding(binding) + await store.persistPtyBinding(binding) + await store.persistPtyBinding({ ...binding, tabId: 'missing-tab', mayCreate: false }) const spans = records.filter( (record) => diff --git a/src/main/persistence-folder-workspace-notes.test.ts b/src/main/persistence-folder-workspace-notes.test.ts index 762cdb2df01..43abe5b7412 100644 --- a/src/main/persistence-folder-workspace-notes.test.ts +++ b/src/main/persistence-folder-workspace-notes.test.ts @@ -1,3 +1,10 @@ +import { + closeTestStores, + testState, + createStore, + writeDataFile, + readDataFile +} from './persistence-test-harness' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' @@ -5,7 +12,6 @@ import { tmpdir } from 'node:os' import type { PersistedState } from '../shared/persisted-state-types' import { folderWorkspaceKey } from '../shared/workspace-scope' import { folderWorkspaceToWorktree } from '../shared/folder-workspace-worktree' -import { testState, createStore, writeDataFile, readDataFile } from './persistence-test-harness' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -55,7 +61,8 @@ describe('Store', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) // ── 8b. Folder-workspace review notes across a build rollback ── diff --git a/src/main/persistence-host-admitted-terminal-membership.test.ts b/src/main/persistence-host-admitted-terminal-membership.test.ts index d71dbc9882c..83a6898c667 100644 --- a/src/main/persistence-host-admitted-terminal-membership.test.ts +++ b/src/main/persistence-host-admitted-terminal-membership.test.ts @@ -1,3 +1,9 @@ +import { + closeTestStores, + createStore, + makeTerminalTab, + testState +} from './persistence-test-harness' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -6,7 +12,6 @@ import { getDefaultWorkspaceSession } from '../shared/constants' import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' import { retireTerminalSurfaceFromPersistence } from './runtime/mobile-session-terminal-persistence-retirement' import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' -import { createStore, makeTerminalTab, testState } from './persistence-test-harness' vi.mock('electron', () => ({ app: { getPath: () => testState.dir }, @@ -47,7 +52,8 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( testState.dir = mkdtempSync(join(tmpdir(), 'orca-host-membership-')) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) @@ -57,7 +63,7 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( // `orca terminal create`: the host mints a tab the renderer has never seen. expect( - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: WORKTREE, tabId: 'host-tab', leafId: TEST_LEAF_2, @@ -77,7 +83,7 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( const store = await createStore() store.setWorkspaceSession(rendererSession()) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: OTHER_WORKTREE, tabId: 'host-tab-other', leafId: TEST_LEAF_2, @@ -94,7 +100,7 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( store.setWorkspaceSession(rendererSession()) expect( - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: WORKTREE, tabId: 'host-tab', leafId: TEST_LEAF_2, @@ -118,7 +124,7 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( const store = await createStore() store.setWorkspaceSession(rendererSession()) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: WORKTREE, tabId: 'renderer-second-tab', leafId: TEST_LEAF_2, @@ -130,12 +136,12 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( }) // Closing must still work afterwards. Closes are host-driven: the retirement is - // computed from the store's own session (see persistTerminalSurfaceRetirements), + // computed from the store's own session (see stageTerminalSurfaceRetirements), // which is what outranks the fence this create just raised. it('still lets the authoritative retirement path close the host-admitted tab', async () => { const store = await createStore() store.setWorkspaceSession(rendererSession()) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: WORKTREE, tabId: 'host-tab', leafId: TEST_LEAF_2, diff --git a/src/main/persistence-host-partitioned-sessions.test.ts b/src/main/persistence-host-partitioned-sessions.test.ts index d831ec85883..9c3aad9620f 100644 --- a/src/main/persistence-host-partitioned-sessions.test.ts +++ b/src/main/persistence-host-partitioned-sessions.test.ts @@ -1,11 +1,5 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { readFileSync, rmSync, mkdtempSync } from 'node:fs' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' -import { getDefaultWorkspaceSession } from '../shared/constants' -import { isTerminalLeafId } from '../shared/stable-pane-id' import { + closeTestStores, testState, createStore, dataFile, @@ -16,6 +10,14 @@ import { makeWorktreeLineage, makeWorkspaceLineage } from './persistence-test-harness' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { readFileSync, rmSync, mkdtempSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' +import { getDefaultWorkspaceSession } from '../shared/constants' +import { isTerminalLeafId } from '../shared/stable-pane-id' + import { worktreeWorkspaceKey } from '../shared/workspace-scope' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. @@ -63,7 +65,8 @@ describe('Store host-partitioned workspace sessions', () => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) diff --git a/src/main/persistence-host-partitioned-ssh-pty-bindings.test.ts b/src/main/persistence-host-partitioned-ssh-pty-bindings.test.ts index ff819432c54..9010f11285a 100644 --- a/src/main/persistence-host-partitioned-ssh-pty-bindings.test.ts +++ b/src/main/persistence-host-partitioned-ssh-pty-bindings.test.ts @@ -1,10 +1,13 @@ +import { closeTestStores, testState, createStore } from './persistence-test-harness' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' +import { ProfileStateSqliteAuthority } from './persistence/profile-state/profile-state-sqlite-authority' import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' import { getDefaultWorkspaceSession } from '../shared/constants' -import { testState, createStore } from './persistence-test-harness' + import { TEST_LEAF_1 } from './persistence-session-fixtures' const { trackMock, getCohortAtEmitMock } = vi.hoisted(() => ({ @@ -42,7 +45,8 @@ describe('Store SSH remote PTY bindings across host partitions', () => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) @@ -80,7 +84,7 @@ describe('Store SSH remote PTY bindings across host partitions', () => { store.setWorkspaceSession(makeBoundHostSession(null), 'local') store.setWorkspaceSession(makeBoundHostSession(null), 'ssh:ssh-1') - store.persistPtyBinding( + await store.persistPtyBinding( { worktreeId: 'repo-1::/worktree', tabId: 'tab-1', @@ -102,11 +106,14 @@ describe('Store SSH remote PTY bindings across host partitions', () => { const store = await createStore() store.setWorkspaceSession(makeBoundHostSession(null), 'local') store.setWorkspaceSession(makeBoundHostSession(null), 'ssh:ssh-1') - const flush = vi.spyOn(store, 'flushOrThrow').mockImplementationOnce(() => { - throw new Error('disk unavailable') - }) + store.flushOrThrow() + const flush = vi + .spyOn(ProfileStateSqliteAuthority.prototype, 'writeSerializedDomains') + .mockImplementationOnce(() => { + throw new Error('disk unavailable') + }) - expect(() => + await expect( store.persistPtyBinding( { worktreeId: 'repo-1::/worktree', @@ -116,7 +123,7 @@ describe('Store SSH remote PTY bindings across host partitions', () => { }, 'ssh:ssh-1' ) - ).toThrow('disk unavailable') + ).rejects.toThrow('disk unavailable') flush.mockRestore() expect( diff --git a/src/main/persistence-initial-load.test.ts b/src/main/persistence-initial-load.test.ts index 934ab44e1cb..13beae7932c 100644 --- a/src/main/persistence-initial-load.test.ts +++ b/src/main/persistence-initial-load.test.ts @@ -1,3 +1,14 @@ +import { + closeTestStores, + testState, + createStore, + writeDataFile, + readDataFile, + makeRepo, + makeProject, + makeProjectHostSetup, + createSqliteTestStore +} from './persistence-test-harness' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { writeFileSync, rmSync, mkdtempSync, mkdirSync } from 'node:fs' import { join } from 'node:path' @@ -6,16 +17,10 @@ import type { PersistedState } from '../shared/persisted-state-types' import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../shared/constants' import { closeTerminalTabInWorkspaceSession } from '../shared/workspace-session-terminal-tab-close' -import { - testState, - createStore, - writeDataFile, - readDataFile, - makeRepo, - makeProject, - makeProjectHostSetup -} from './persistence-test-harness' + import { TEST_LEAF_1 } from './persistence-session-fixtures' +import { ProfileStateSqliteAuthority } from './persistence/profile-state/profile-state-sqlite-authority' + import { getLocalWorktreeScanGeneration, isLocalWorktreeScanGenerationCurrent @@ -69,7 +74,8 @@ describe('Store', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) // ── 1. Defaults when no file exists ────────────────────────────────── @@ -79,7 +85,7 @@ describe('Store', () => { expect(store.getRepos()).toEqual([]) }, 15_000) - it('clone-reads and synchronously persists the main-owned Codex reset ledger', async () => { + it('clone-reads and durably persists the main-owned Codex reset ledger', async () => { const store = await createStore() const ledger = { version: 1 as const, @@ -97,7 +103,7 @@ describe('Store', () => { ] } - store.replaceCodexResetCreditAttemptLedgerAndFlush(ledger) + await store.replaceCodexResetCreditAttemptLedgerAndFlush(ledger) const firstRead = store.getCodexResetCreditAttemptLedger() firstRead.attempts.splice(0, 1) @@ -105,32 +111,37 @@ describe('Store', () => { expect((readDataFile() as PersistedState).codexResetCreditAttemptLedger).toEqual(ledger) }) - it('rolls the in-memory Codex reset ledger back when its sync flush fails', async () => { + it('rolls the in-memory Codex reset ledger back when its durable write fails', async () => { const store = await createStore() const before = store.getCodexResetCreditAttemptLedger() - vi.spyOn(store, 'flushOrThrow').mockImplementationOnce(() => { - throw new Error('disk full') - }) - - expect(() => - store.replaceCodexResetCreditAttemptLedgerAndFlush({ - version: 1, - attempts: [ - { - idempotencyKey: '11111111-1111-4111-8111-111111111111', - expectedScope: { - target: { runtime: 'host', wslDistro: null }, - accountId: 'account-host', - accountRevision: 42, - offerRevision: 'v1:offer' - }, - state: 'providerPending' - } - ] + const write = vi + .spyOn(ProfileStateSqliteAuthority.prototype, 'writeCompleteSerializedDomains') + .mockImplementationOnce(() => { + throw new Error('disk full') }) - ).toThrow('disk full') - expect(store.getCodexResetCreditAttemptLedger()).toEqual(before) + try { + await expect( + store.replaceCodexResetCreditAttemptLedgerAndFlush({ + version: 1, + attempts: [ + { + idempotencyKey: '11111111-1111-4111-8111-111111111111', + expectedScope: { + target: { runtime: 'host', wslDistro: null }, + accountId: 'account-host', + accountRevision: 42, + offerRevision: 'v1:offer' + }, + state: 'providerPending' + } + ] + }) + ).rejects.toThrow('disk full') + expect(store.getCodexResetCreditAttemptLedger()).toEqual(before) + } finally { + write.mockRestore() + } }) it('preserves a corrupt Codex reset ledger as a fail-closed read error', async () => { @@ -222,7 +233,7 @@ describe('Store', () => { vi.resetModules() const { Store, initDataPath } = await import('./persistence') initDataPath() - const store = new Store({ dataFile: profileDataFile }) + const store = createSqliteTestStore(Store, { dataFile: profileDataFile }) expect(store.getRepos().map((repo) => repo.id)).toEqual(['profile-repo']) }, 15_000) diff --git a/src/main/persistence-layout-binding-recovery.test.ts b/src/main/persistence-layout-binding-recovery.test.ts index c539f3dd47b..7f3998eb58b 100644 --- a/src/main/persistence-layout-binding-recovery.test.ts +++ b/src/main/persistence-layout-binding-recovery.test.ts @@ -1,9 +1,10 @@ +import { closeTestStores, testState, createStore, makeRepo } from './persistence-test-harness' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { isTerminalLeafId } from '../shared/stable-pane-id' -import { testState, createStore, makeRepo } from './persistence-test-harness' + import { TEST_LEAF_1, TEST_LEAF_2, @@ -59,7 +60,8 @@ describe('Store', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) it('drops legacy leaf-keyed records from mixed-version writes before binding preservation', async () => { diff --git a/src/main/persistence-load-repair-durability.test.ts b/src/main/persistence-load-repair-durability.test.ts index 31ed5d5b451..5cb2bbe8f4f 100644 --- a/src/main/persistence-load-repair-durability.test.ts +++ b/src/main/persistence-load-repair-durability.test.ts @@ -1,3 +1,10 @@ +import { + closeTestStores, + testState, + createStore, + writeDataFile, + readDataFile +} from './persistence-test-harness' /** * Load-time normalization repairs the in-memory state; without a matching dirty mark the bad value * stays on disk and the repair reruns on every launch. Each case here reloads a profile the current @@ -10,7 +17,6 @@ import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import type { PersistedState } from '../shared/persisted-state-types' -import { testState, createStore, writeDataFile, readDataFile } from './persistence-test-harness' const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ loadUserSshConfigMock: vi.fn(), @@ -81,7 +87,8 @@ describe('load-time normalization durability', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) diff --git a/src/main/persistence-loading-store-extraction.test.ts b/src/main/persistence-loading-store-extraction.test.ts index a3c5e248909..3acec0d56a4 100644 --- a/src/main/persistence-loading-store-extraction.test.ts +++ b/src/main/persistence-loading-store-extraction.test.ts @@ -1,23 +1,34 @@ +import { + closeTestStores, + createSqliteTestStore, + readPersistedStateJson, + writePersistedStateJson, + createStore, + dataFile, + makeRepo, + testState +} from './persistence-test-harness' import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createProfileStateStore } from './persistence/profile-state/profile-state-store-factory' +import type * as FsModule from 'node:fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, + writeSync +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../shared/constants' import type { PersistedState } from '../shared/persisted-state-types' -import type * as StartupDiagnosticsModule from './startup/startup-diagnostics' import type { Store as PersistenceStore } from './persistence/loading-store/store' -import { - createStore, - dataFile, - makeRepo, - testState, - writeDataFile -} from './persistence-test-harness' -const { trackMock, getCohortAtEmitMock, logStartupDiagnosticMock } = vi.hoisted(() => ({ +const { trackMock, getCohortAtEmitMock } = vi.hoisted(() => ({ trackMock: vi.fn(), - getCohortAtEmitMock: vi.fn(() => ({ nth_repo_added: 2 })), - logStartupDiagnosticMock: vi.fn() + getCohortAtEmitMock: vi.fn(() => ({ nth_repo_added: 2 })) })) vi.mock('electron', () => ({ @@ -41,19 +52,30 @@ vi.mock('./ssh/ssh-config-parser', () => ({ loadUserSshConfig: vi.fn(() => ({ hosts: [] })), sshConfigHostsToTargets: vi.fn(() => []) })) -vi.mock('./startup/startup-diagnostics', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, logStartupDiagnostic: logStartupDiagnosticMock } +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, writeSync: vi.fn(actual.writeSync) } }) +function getLoadDoneLines(): string[] { + return vi + .mocked(writeSync) + .mock.calls.flatMap(([fd, text]) => + fd === 2 && typeof text === 'string' && text.startsWith('[startup] persistence-load-done ') + ? [text] + : [] + ) +} + describe('loading Store extraction seams', () => { beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-loading-store-')) }) - afterEach(() => { + afterEach(async () => { vi.unstubAllEnvs() - logStartupDiagnosticMock.mockReset() + vi.mocked(writeSync).mockClear() + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) @@ -62,7 +84,7 @@ describe('loading Store extraction seams', () => { vi.stubEnv('ORCA_STARTUP_DIAGNOSTICS', '') const state = getDefaultPersistedState(testState.dir) state.workspaceSession = { ...state.workspaceSession, activeTabId: sentinel } - writeDataFile(state) + writePersistedStateJson(dataFile(), JSON.stringify(state)) const stringifySpy = vi.spyOn(JSON, 'stringify') const store = createStore() @@ -78,9 +100,7 @@ describe('loading Store extraction seams', () => { expect(store.getWorkspaceSession().activeTabId).toBe(sentinel) expect(workspaceSessionStringifyCalls).toHaveLength(0) - expect( - logStartupDiagnosticMock.mock.calls.some(([event]) => event === 'persistence-load-done') - ).toBe(false) + expect(getLoadDoneLines()).toEqual([]) }) it('reports the unchanged workspace-session byte count when startup diagnostics are enabled', () => { @@ -88,7 +108,7 @@ describe('loading Store extraction seams', () => { vi.stubEnv('ORCA_STARTUP_DIAGNOSTICS', '1') const state = getDefaultPersistedState(testState.dir) state.workspaceSession = { ...state.workspaceSession, activeTabId: sentinel } - writeDataFile(state) + writePersistedStateJson(dataFile(), JSON.stringify(state)) const stringifySpy = vi.spyOn(JSON, 'stringify') const store = createStore() @@ -104,16 +124,14 @@ describe('loading Store extraction seams', () => { expect(store.getWorkspaceSession().activeTabId).toBe(sentinel) expect(workspaceSessionStringifyCalls).toHaveLength(1) - const loadDoneCall = logStartupDiagnosticMock.mock.calls.find( - ([event]) => event === 'persistence-load-done' - ) - expect(loadDoneCall).toBeDefined() - const details = loadDoneCall?.[1] as Record | undefined - expect(details).toEqual({ - t: expect.any(Number), - repos: state.repos.length, - workspaceSessionBytes: Buffer.byteLength(JSON.stringify(store.getWorkspaceSession())) - }) + const expectedBytes = Buffer.byteLength(JSON.stringify(store.getWorkspaceSession())) + expect(getLoadDoneLines()).toEqual([ + expect.stringMatching( + new RegExp( + `^\\[startup\\] persistence-load-done t=\\d+ repos=${state.repos.length} workspaceSessionBytes=${expectedBytes}\\n$` + ) + ) + ]) }) it('timestamps persistence-load-done before resolving its details closure', () => { @@ -121,7 +139,7 @@ describe('loading Store extraction seams', () => { vi.stubEnv('ORCA_STARTUP_DIAGNOSTICS', '1') const state = getDefaultPersistedState(testState.dir) state.workspaceSession = { ...state.workspaceSession, activeTabId: sentinel } - writeDataFile(state) + writePersistedStateJson(dataFile(), JSON.stringify(state)) // Fake clock only the details closure advances, so a post-closure timestamp is unambiguous. let clock = 0 @@ -149,16 +167,14 @@ describe('loading Store extraction seams', () => { nowSpy.mockRestore() } - const loadDoneCall = logStartupDiagnosticMock.mock.calls.find( - ([event]) => event === 'persistence-load-done' - ) - const details = loadDoneCall?.[1] as Record | undefined - expect(details?.workspaceSessionBytes).toEqual(expect.any(Number)) - expect(details?.t).toBe(0) + expect(getLoadDoneLines()).toEqual([ + expect.stringMatching( + /^\[startup\] persistence-load-done t=0 repos=\d+ workspaceSessionBytes=\d+\n$/ + ) + ]) }) - it('accepts the first JSON-parseable backup even when an older backup has richer state', async () => { - mkdirSync(testState.dir, { recursive: true }) + it('imports the first usable legacy backup without overwriting the damaged source', () => { writeFileSync(dataFile(), '{{corrupt-primary', 'utf-8') writeFileSync(`${dataFile()}.bak.0`, '{}', 'utf-8') writeFileSync( @@ -166,31 +182,33 @@ describe('loading Store extraction seams', () => { JSON.stringify({ repos: [makeRepo({ id: 'older-complete-profile' })] }), 'utf-8' ) - - const store = await createStore() - - expect(store.getRepos()).toEqual([]) - expect(readFileSync(dataFile(), 'utf-8')).toBe('{}') + const { store } = createProfileStateStore({ + dataFile: dataFile(), + databaseFile: join(testState.dir, 'profile-state.db'), + profileId: 'backup-import' + }) + try { + expect(store.getRepos()).toEqual([]) + expect(readFileSync(dataFile(), 'utf-8')).toBe('{{corrupt-primary') + expect(readPersistedStateJson(dataFile(), 'backup-import')).toContain('"repos":[]') + } finally { + store.freezeWrites() + } }) - it('leaves backup bytes reusable when publishing recovery to the primary path fails', async () => { + it('leaves backup bytes reusable when the legacy source cannot be read', () => { mkdirSync(dataFile(), { recursive: true }) - writeFileSync( - `${dataFile()}.bak.0`, - JSON.stringify({ repos: [makeRepo({ id: 'recovery-survives-publish-failure' })] }), - 'utf-8' - ) - - const failedRecovery = await createStore() - expect(failedRecovery.getRepos()).toEqual([]) - failedRecovery.freezeWrites() - expect(existsSync(`${dataFile()}.bak.0`)).toBe(true) - - rmSync(dataFile(), { recursive: true, force: true }) - const recovered = await createStore() - expect(recovered.getRepos().map((repo) => repo.id)).toEqual([ - 'recovery-survives-publish-failure' - ]) + const backup = JSON.stringify({ repos: [makeRepo({ id: 'recovery-survives-read-failure' })] }) + writeFileSync(`${dataFile()}.bak.0`, backup, 'utf-8') + expect(() => + createProfileStateStore({ + dataFile: dataFile(), + databaseFile: join(testState.dir, 'profile-state.db'), + profileId: 'backup-import' + }) + ).toThrow() + expect(readFileSync(`${dataFile()}.bak.0`, 'utf-8')).toBe(backup) + expect(existsSync(join(testState.dir, 'profile-state.db'))).toBe(false) }) it('aliases blank host reads, writes, and patches to the local disk partition', async () => { @@ -205,7 +223,8 @@ describe('loading Store extraction seams', () => { expect(store.getWorkspaceSession(' ').activeRepoId).toBe('from-blank-patch') expect(store.getWorkspaceSessionHostIds()).toEqual(['local']) - const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as PersistedState + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The preceding Store save produced the PersistedState snapshot read by this test. + const persisted = JSON.parse(readPersistedStateJson(dataFile())) as PersistedState expect(persisted.workspaceSession?.activeRepoId).toBe('from-blank-patch') expect(persisted.workspaceSessionsByHostId).toEqual({}) }) @@ -216,7 +235,7 @@ describe('loading Store extraction seams', () => { const { setMigrationUnsupportedPty } = await import('./agent-hooks/migration-unsupported-pty-state') const secondDataFile = join(testState.dir, 'second-profile', 'orca-data.json') - const second = new Store({ dataFile: secondDataFile }) + const second = createSqliteTestStore(Store, { dataFile: secondDataFile }) setMigrationUnsupportedPty({ ptyId: 'listener-owner-pty', @@ -227,8 +246,10 @@ describe('loading Store extraction seams', () => { first.flushOrThrow() second.flushOrThrow() - const firstState = JSON.parse(readFileSync(dataFile(), 'utf-8')) as PersistedState - const secondState = JSON.parse(readFileSync(secondDataFile, 'utf-8')) as PersistedState + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The preceding Store save produced the PersistedState snapshot read by this test. + const firstState = JSON.parse(readPersistedStateJson(dataFile())) as PersistedState + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The preceding Store save produced the PersistedState snapshot read by this test. + const secondState = JSON.parse(readPersistedStateJson(secondDataFile)) as PersistedState expect( firstState.migrationUnsupportedPtyEntries?.some( (entry) => entry.ptyId === 'listener-owner-pty' @@ -249,7 +270,8 @@ describe('loading Store extraction seams', () => { store.updateUI({ sidebarWidth: 732 }) expect(store.flushAsync()).toBe(finalFlush) - const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as PersistedState + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The preceding Store save produced the PersistedState snapshot read by this test. + const persisted = JSON.parse(readPersistedStateJson(dataFile())) as PersistedState expect(persisted.ui.sidebarWidth).toBe(731) expect(store.getUI().sidebarWidth).toBe(732) }) @@ -262,7 +284,8 @@ describe('loading Store extraction seams', () => { store.updateUI({ sidebarWidth: 742 }) await finalFlush - const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as PersistedState + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The preceding Store save produced the PersistedState snapshot read by this test. + const persisted = JSON.parse(readPersistedStateJson(dataFile())) as PersistedState expect(persisted.ui.sidebarWidth).toBe(742) }) @@ -300,7 +323,7 @@ describe('loading Store extraction seams', () => { return 47 } } - const overridden = new StoreWithRepoCountOverride({ + const overridden = createSqliteTestStore(StoreWithRepoCountOverride, { dataFile: join(testState.dir, 'override-profile', 'orca-data.json') }) expect(overridden.getRepoCount()).toBe(47) diff --git a/src/main/persistence-loading-store-write-risks.test.ts b/src/main/persistence-loading-store-write-risks.test.ts index 422573516f1..d7dd84f35a5 100644 --- a/src/main/persistence-loading-store-write-risks.test.ts +++ b/src/main/persistence-loading-store-write-risks.test.ts @@ -1,122 +1,63 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' -import type * as NodeFsPromises from 'node:fs/promises' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' -import { Store } from './persistence/loading-store/store' -import { initDataPath } from './persistence/loading-store/user-data-path' +import { describe, expect, it, vi } from 'vitest' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from './persistence/loading-store/profile-state-maintenance-fixture' -const testState = { dir: '' } - -const writeControl = vi.hoisted(() => { - let releaseRename: (() => void) | null = null - let markRenameStarted: (() => void) | null = null - return { - blockPrimaryRename: false, - failPrimaryOpen: false, - renameStarted: Promise.resolve(), - renameRelease: Promise.resolve(), - reset(): void { - this.blockPrimaryRename = false - this.failPrimaryOpen = false - this.renameStarted = new Promise((resolve) => { - markRenameStarted = resolve - }) - this.renameRelease = new Promise((resolve) => { - releaseRename = resolve - }) - }, - markRenameStarted(): void { - markRenameStarted?.() - }, - releaseRename(): void { - releaseRename?.() - } - } -}) - -vi.mock('node:fs/promises', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - open: async (...args: Parameters) => { - const target = String(args[0]) - if ( - writeControl.failPrimaryOpen && - target.includes('orca-data.json.') && - target.endsWith('.tmp') - ) { - throw Object.assign(new Error('profile mount rejected write'), { code: 'EIO' }) - } - return actual.open(...args) - }, - rename: async (...args: Parameters) => { - const target = String(args[1]) - if (writeControl.blockPrimaryRename && target.endsWith('orca-data.json')) { - writeControl.markRenameStarted() - await writeControl.renameRelease - } - return actual.rename(...args) - } - } -}) - -vi.mock('electron', () => ({ - app: { getPath: () => testState.dir }, - safeStorage: { - isEncryptionAvailable: () => true, - encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), - decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length) - } -})) vi.mock('./ssh/ssh-config-parser', () => ({ loadUserSshConfig: vi.fn(), sshConfigHostsToTargets: vi.fn() })) vi.mock('./telemetry/client', () => ({ track: vi.fn() })) vi.mock('./telemetry/cohort-classifier', () => ({ - getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) + getCohortAtEmit: () => ({ nth_repo_added: 2 }) })) -function createStore(): Store { - installFakeAppEnvironment({ getPath: () => testState.dir }) - initDataPath() - return new Store({ dataFile: join(testState.dir, 'orca-data.json') }) -} - describe('loading Store write-risk characterization', () => { - beforeEach(() => { - testState.dir = mkdtempSync(join(tmpdir(), 'orca-write-risk-')) - writeControl.reset() - vi.useFakeTimers() - }) + it('awaits an accepted worker commit before freezing and rejects later writes', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const committed = maintenanceBarrier() + const acknowledge = maintenanceBarrier() + const writeDomains = authority.writeCompleteSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeCompleteSerializedDomains').mockImplementationOnce( + async (replacements) => { + await writeDomains(replacements) + committed.resolve() + await acknowledge.promise + } + ) + const close = vi.spyOn(authority, 'close') - afterEach(() => { - vi.useRealTimers() - rmSync(testState.dir, { recursive: true, force: true }) - }) - - it('allows an already-started primary rename to publish after writes are frozen', async () => { - const store = await createStore() - writeControl.blockPrimaryRename = true store.updateUI({ sidebarWidth: 712 }) - vi.advanceTimersByTime(1_000) - await writeControl.renameStarted + const writing = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await committed.promise + const frozen = store.freezeWritesAsync() + await Promise.resolve() + expect(close).not.toHaveBeenCalled() + await expect(store.runDurableMutation(() => ({ value: undefined }))).rejects.toThrow( + 'finalized' + ) - store.freezeWrites() - writeControl.releaseRename() - await store.waitForPendingWrite() - - const persisted = JSON.parse(readFileSync(join(testState.dir, 'orca-data.json'), 'utf-8')) as { - ui: { sidebarWidth: number } - } - expect(persisted.ui.sidebarWidth).toBe(712) + acknowledge.resolve() + await writing + await frozen + expect(close).toHaveBeenCalledOnce() + expect(readState().ui.sidebarWidth).toBe(712) + store.updateUI({ sidebarWidth: 999 }) + await expect(store.flushPendingOrThrowAsync()).rejects.toThrow('finalized') + expect(readState().ui.sidebarWidth).toBe(712) }) it('keeps a rejected durable mutation in memory for a later unrelated flush', async () => { - const store = await createStore() - writeControl.failPrimaryOpen = true + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const before = readState().sshPtyConsumerRecoveries + const writeDomains = authority.writeCompleteSerializedDomains.bind(authority) + const rejectedWrite = vi + .spyOn(authority, 'writeCompleteSerializedDomains') + .mockImplementationOnce(() => + writeDomains([{ domain: 'sshPtyConsumerRecoveries', payload: '{' }]) + ) + vi.spyOn(console, 'error').mockImplementation(() => {}) await expect( store.upsertSshPtyConsumerRecovery({ targetId: 'ssh-1', @@ -126,14 +67,16 @@ describe('loading Store write-risk characterization', () => { ownerGeneration: 5, ownerLease: 'secret-owner-lease' }) - ).rejects.toThrow('profile mount rejected write') + ).rejects.toMatchObject({ outcome: 'known-failure' }) + expect(readState().sshPtyConsumerRecoveries).toEqual(before) + expect(store.getSshPtyConsumerRecovery('ssh-1')?.clientInstanceId).toBe('client-1') - writeControl.failPrimaryOpen = false + rejectedWrite.mockRestore() store.updateUI({ sidebarWidth: 713 }) await store.flushPendingOrThrowAsync() - const persisted = JSON.parse(readFileSync(join(testState.dir, 'orca-data.json'), 'utf-8')) as { - sshPtyConsumerRecoveries: { clientInstanceId: string }[] - } - expect(persisted.sshPtyConsumerRecoveries[0]?.clientInstanceId).toBe('client-1') + expect(readState()).toMatchObject({ + ui: { sidebarWidth: 713 }, + sshPtyConsumerRecoveries: [expect.objectContaining({ clientInstanceId: 'client-1' })] + }) }) }) diff --git a/src/main/persistence-native-chat-tab-view-mode.test.ts b/src/main/persistence-native-chat-tab-view-mode.test.ts index 9bcaab15494..96de2f21f67 100644 --- a/src/main/persistence-native-chat-tab-view-mode.test.ts +++ b/src/main/persistence-native-chat-tab-view-mode.test.ts @@ -1,8 +1,14 @@ +import { + closeTestStores, + testState, + createStore, + writeDataFile, + makeRepo +} from './persistence-test-harness' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { testState, createStore, writeDataFile, makeRepo } from './persistence-test-harness' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -49,7 +55,8 @@ describe('Store native-chat tab viewMode persistence', () => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) diff --git a/src/main/persistence-pane-identity-migration.test.ts b/src/main/persistence-pane-identity-migration.test.ts index c9a6fc4e592..d93244cbc42 100644 --- a/src/main/persistence-pane-identity-migration.test.ts +++ b/src/main/persistence-pane-identity-migration.test.ts @@ -1,10 +1,17 @@ +import { + closeTestStores, + testState, + createStore, + writeDataFile, + readDataFile +} from './persistence-test-harness' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { writeFileSync, rmSync, mkdtempSync, mkdirSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import type { PersistedState } from '../shared/persisted-state-types' import { isTerminalLeafId, makePaneKey } from '../shared/stable-pane-id' -import { testState, createStore, writeDataFile, readDataFile } from './persistence-test-harness' + import { TEST_LEAF_1, TEST_LEAF_2, @@ -59,7 +66,8 @@ describe('Store', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) it('hydrates split-pane legacy numeric agent status rows onto the matching remapped leaves', async () => { diff --git a/src/main/persistence-protected-secret-fail-closed.test.ts b/src/main/persistence-protected-secret-fail-closed.test.ts index 26f49ea2e44..1bd42aa6ef3 100644 --- a/src/main/persistence-protected-secret-fail-closed.test.ts +++ b/src/main/persistence-protected-secret-fail-closed.test.ts @@ -1,5 +1,10 @@ +import { + closeTestStores, + createSqliteTestStore, + readPersistedStateJson +} from './persistence-test-harness' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { randomUUID } from 'node:crypto' @@ -61,7 +66,7 @@ async function createStore() { // file's temp dir rather than the global fake's shared one, after resetModules. installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() - return new Store() + return createSqliteTestStore(Store, { dataFile: join(testState.dir, 'orca-data.json') }) } function dataFile(): string { @@ -79,7 +84,7 @@ type ProtectedState = { } function readState(path = dataFile()): ProtectedState { - return JSON.parse(readFileSync(path, 'utf-8')) + return JSON.parse(readPersistedStateJson(path)) } const ORIGINAL = { @@ -142,7 +147,8 @@ describe('protected persistence when safeStorage fails', () => { vi.useFakeTimers() }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() vi.useRealTimers() rmSync(testState.dir, { recursive: true, force: true }) }) @@ -154,7 +160,7 @@ describe('protected persistence when safeStorage fails', () => { const store = await createStore() await writeProtectedState(store, PENDING, 'non-secret-saved') - const raw = readFileSync(dataFile(), 'utf-8') + const raw = readPersistedStateJson(dataFile()) const persisted = readState() expectPlaintextsAbsent(raw, PENDING) expect(persisted.settings.httpProxyUrl).toBe('') @@ -384,14 +390,14 @@ describe('protected persistence when safeStorage fails', () => { const store = await createStore() await writeProtectedState(store, ORIGINAL, 'before') const originalCiphertext = readState() - expectPlaintextsAbsent(readFileSync(dataFile(), 'utf-8'), ORIGINAL) + expectPlaintextsAbsent(readPersistedStateJson(dataFile()), ORIGINAL) vi.advanceTimersByTime(60 * 60 * 1_000 + 1) setFailure(failureMode) await writeProtectedState(store, PENDING, 'during-failure') - const primaryRaw = readFileSync(dataFile(), 'utf-8') - const backupRaw = readFileSync(`${dataFile()}.bak.0`, 'utf-8') + const primaryRaw = readPersistedStateJson(dataFile()) + const backupRaw = store.prepareProfileStateExport().json const persisted = readState() expectPlaintextsAbsent(primaryRaw, PENDING) expectPlaintextsAbsent(backupRaw, PENDING) @@ -410,7 +416,7 @@ describe('protected persistence when safeStorage fails', () => { const loadedDuringFailure = await createStore() expect(loadedDuringFailure.getSettings().httpProxyBypassRules).toBe('during-failure') await settleSave(loadedDuringFailure) - expectPlaintextsAbsent(readFileSync(dataFile(), 'utf-8'), PENDING) + expectPlaintextsAbsent(readPersistedStateJson(dataFile()), PENDING) cipherState.availability = 'available' cipherState.encryptionThrows = false diff --git a/src/main/persistence-protected-secret-write-race.test.ts b/src/main/persistence-protected-secret-write-race.test.ts index 21aa20d13b8..8388a3ba1de 100644 --- a/src/main/persistence-protected-secret-write-race.test.ts +++ b/src/main/persistence-protected-secret-write-race.test.ts @@ -1,112 +1,78 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync } from 'node:fs' -import type * as NodeFsPromises from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' - -const testState = { dir: '' } -const cipherState = { available: true } - -const renameGate = vi.hoisted(() => ({ - sourcePrefix: '', - release: null as Promise | null, - started: null as (() => void) | null -})) - -vi.mock('node:fs/promises', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - rename: async (source: string, destination: string) => { - if (renameGate.release && source.startsWith(renameGate.sourcePrefix)) { - const release = renameGate.release - renameGate.release = null - renameGate.started?.() - await release - } - return actual.rename(source, destination) - } - } -}) +import { getSecretStore, setSecretStore } from '../shared/secret-store' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from './persistence/loading-store/profile-state-maintenance-fixture' +import { Store } from './persistence/loading-store/store' vi.mock('./ssh/ssh-config-parser', () => ({ loadUserSshConfig: vi.fn(), sshConfigHostsToTargets: vi.fn() })) - vi.mock('./telemetry/client', () => ({ track: vi.fn() })) vi.mock('./telemetry/cohort-classifier', () => ({ - getCohortAtEmit: vi.fn().mockReturnValue({ nth_repo_added: 2 }) + getCohortAtEmit: () => ({ nth_repo_added: 2 }) })) -vi.mock('electron', () => ({ - app: { getPath: () => testState.dir } -})) +let encryptionAvailable = true +let previousSecretStore: ReturnType -async function createStore() { - vi.resetModules() - const { setSecretStore } = await import('../shared/secret-store') +beforeEach(() => { + encryptionAvailable = true + previousSecretStore = getSecretStore() setSecretStore({ - isEncryptionAvailable: () => cipherState.available, - encryptString: (plaintext) => Buffer.from(`enc:${plaintext}`, 'utf-8'), - decryptString: (ciphertext) => ciphertext.toString('utf-8').slice('enc:'.length), + isEncryptionAvailable: () => encryptionAvailable, + encryptString: (plaintext) => Buffer.from(`enc:${plaintext}`, 'utf8'), + decryptString: (ciphertext) => ciphertext.toString('utf8').slice('enc:'.length), describeProtectionGap: () => null }) - const { Store, initDataPath } = await import('./persistence') - // Why here: userData resolves through AppEnvironment, and this must point at this - // file's temp dir rather than the global fake's shared one, after resetModules. - installFakeAppEnvironment({ getPath: () => testState.dir }) - initDataPath() - return new Store() -} - -function deferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void - const promise = new Promise((next) => { - resolve = next - }) - return { promise, resolve } -} +}) +afterEach(() => setSecretStore(previousSecretStore)) describe('protected-secret async write retention', () => { - beforeEach(() => { - testState.dir = mkdtempSync(join(tmpdir(), 'orca-protected-secret-write-race-')) - cipherState.available = true - renameGate.sourcePrefix = join(testState.dir, 'orca-data.json') - renameGate.release = null - renameGate.started = null - vi.useFakeTimers() - }) - - afterEach(() => { - vi.useRealTimers() - rmSync(testState.dir, { recursive: true, force: true }) - }) - - it('does not retain ciphertext from a superseded async secret write', async () => { - const store = await createStore() + it('does not retain ciphertext from a rejected worker write after a newer secret arrives', async () => { + const { store, authority, peer, dataFile, readState } = await createWorkerMaintenanceFixture() store.updateSettings({ opencodeSessionCookie: 'durable-cookie' }) - vi.advanceTimersByTime(1_000) - await store.waitForPendingWrite() - - const renameRelease = deferred() - const renameStarted = deferred() - renameGate.release = renameRelease.promise - renameGate.started = renameStarted.resolve + await store.flushPendingOrThrowAsync() + const durableCiphertext = readState().settings.opencodeSessionCookie + const entered = maintenanceBarrier() + const release = maintenanceBarrier() + const writeDomains = authority.writeSerializedDomains.bind(authority) + const pendingWrite = vi + .spyOn(authority, 'writeSerializedDomains') + .mockImplementationOnce(async () => { + entered.resolve() + await release.promise + await writeDomains([{ domain: 'settings', payload: '{' }]) + }) + vi.spyOn(console, 'error').mockImplementation(() => {}) store.updateSettings({ opencodeSessionCookie: 'intermediate-cookie' }) - vi.advanceTimersByTime(1_000) - await renameStarted.promise - + const writing = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + const rejected = expect(writing).rejects.toMatchObject({ outcome: 'known-failure' }) + await entered.promise store.updateSettings({ opencodeSessionCookie: 'replacement-cookie' }) - cipherState.available = false - vi.advanceTimersByTime(1_000) - renameRelease.resolve() - await store.waitForPendingWrite() + encryptionAvailable = false + release.resolve() + await rejected + pendingWrite.mockRestore() + await store.flushPendingOrThrowAsync() - cipherState.available = true - const restarted = await createStore() - expect(restarted.getSettings().opencodeSessionCookie).toBe('durable-cookie') + expect(readState().settings.opencodeSessionCookie).toBe(durableCiphertext) + expect(store.getSettings().opencodeSessionCookie).toBe('replacement-cookie') + encryptionAvailable = true + const restarted = new Store({ dataFile, profileStateAuthority: peer() }) + try { + expect(restarted.getSettings().opencodeSessionCookie).toBe('durable-cookie') + } finally { + restarted.freezeWrites() + } + + store.updateSettings({ theme: 'dark' }) + await store.flushPendingOrThrowAsync() + expect(readState().settings.opencodeSessionCookie).toBe( + Buffer.from('enc:replacement-cookie').toString('base64') + ) }) }) diff --git a/src/main/persistence-proxy-secret-recovery.test.ts b/src/main/persistence-proxy-secret-recovery.test.ts index 176113192c7..ee75c72c5a0 100644 --- a/src/main/persistence-proxy-secret-recovery.test.ts +++ b/src/main/persistence-proxy-secret-recovery.test.ts @@ -1,3 +1,8 @@ +import { + closeTestStores, + createSqliteTestStore, + readPersistedStateJson +} from './persistence-test-harness' // STA-3442: httpProxyUrl is the only network setting stored via safeStorage. // On macOS a keychain reset/denial makes decryptString throw at load, and the // raw ciphertext then masqueraded as a configured proxy: applyElectronProxySettings @@ -6,7 +11,7 @@ // or destroyed), plaintext values survive as the upgrade path, and safeStorage // failures cannot expose the proxy secret or kill unrelated saves. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { existsSync, mkdirSync, readFileSync, rmSync, mkdtempSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, rmSync, mkdtempSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { tmpdir } from 'node:os' import { randomUUID } from 'node:crypto' @@ -68,7 +73,7 @@ async function createStore() { // file's temp dir rather than the global fake's shared one, after resetModules. installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() - return new Store() + return createSqliteTestStore(Store, { dataFile: join(testState.dir, 'orca-data.json') }) } function dataFile(): string { @@ -87,7 +92,8 @@ describe('httpProxyUrl secret recovery (STA-3442)', () => { vi.useFakeTimers() }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() vi.useRealTimers() rmSync(testState.dir, { recursive: true, force: true }) }) @@ -103,7 +109,8 @@ describe('httpProxyUrl secret recovery (STA-3442)', () => { it('persists the configured proxy across a restart and applies it as fixed_servers', async () => { await seedConfiguredProxy() - const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The preceding Store save produced the PersistedState snapshot read by this test. + const persisted = JSON.parse(readPersistedStateJson(dataFile())) as { settings: { httpProxyUrl: string; httpProxyBypassRules: string } } // On disk the URL is ciphertext (base64 of the mock's enc: payload), never plaintext. @@ -139,7 +146,7 @@ describe('httpProxyUrl secret recovery (STA-3442)', () => { it('seals an undecryptable httpProxyUrl without destroying its ciphertext', async () => { await seedConfiguredProxy() - const originalCiphertext = JSON.parse(readFileSync(dataFile(), 'utf-8')).settings.httpProxyUrl + const originalCiphertext = JSON.parse(readPersistedStateJson(dataFile())).settings.httpProxyUrl // Keychain reset/denial: every decrypt now fails. cipherState.decryptAlwaysThrows = true @@ -153,7 +160,8 @@ describe('httpProxyUrl secret recovery (STA-3442)', () => { reloaded.updateSettings({ httpProxyBypassRules: 'localhost' }) vi.advanceTimersByTime(2000) await reloaded.waitForPendingWrite() - const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The preceding Store save produced the PersistedState snapshot read by this test. + const persisted = JSON.parse(readPersistedStateJson(dataFile())) as { settings: { httpProxyUrl: string } } expect(persisted.settings.httpProxyUrl).toBe(originalCiphertext) @@ -180,8 +188,9 @@ describe('httpProxyUrl secret recovery (STA-3442)', () => { vi.advanceTimersByTime(1000) await store.waitForPendingWrite() - expect(existsSync(dataFile())).toBe(true) - const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as { + expect(existsSync(join(testState.dir, 'profile-state.db'))).toBe(true) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The preceding Store save produced the PersistedState snapshot read by this test. + const persisted = JSON.parse(readPersistedStateJson(dataFile())) as { settings: { httpProxyUrl: string; httpProxyBypassRules: string } } expect(persisted.settings.httpProxyUrl).toBe('') diff --git a/src/main/persistence-pty-binding-leaf-tab-resolution.test.ts b/src/main/persistence-pty-binding-leaf-tab-resolution.test.ts index 2f0dc41daaf..14ac4de0a12 100644 --- a/src/main/persistence-pty-binding-leaf-tab-resolution.test.ts +++ b/src/main/persistence-pty-binding-leaf-tab-resolution.test.ts @@ -1,10 +1,16 @@ +import { + closeTestStores, + testState, + createStore, + makeTerminalTab +} from './persistence-test-harness' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { getDefaultWorkspaceSession } from '../shared/constants' import { findTerminalTabIdForLeaf } from './runtime/workspace-session-terminal-membership-authority' -import { testState, createStore, makeTerminalTab } from './persistence-test-harness' + import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' vi.mock('electron', () => ({ @@ -19,7 +25,8 @@ describe('findTerminalTabIdForLeaf after persistPtyBinding grafts a leaf', () => beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) @@ -46,7 +53,7 @@ describe('findTerminalTabIdForLeaf after persistPtyBinding grafts a leaf', () => expect(findTerminalTabIdForLeaf(store.getWorkspaceSession(), TEST_LEAF_1)).toBe('tab1') expect( - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_2, @@ -73,7 +80,7 @@ describe('findTerminalTabIdForLeaf after persistPtyBinding grafts a leaf', () => expect(findTerminalTabIdForLeaf(store.getWorkspaceSession(), TEST_LEAF_1)).toBeUndefined() expect( - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_1, diff --git a/src/main/persistence-pty-binding-reconciliation.test.ts b/src/main/persistence-pty-binding-reconciliation.test.ts index f72234b6823..c4fbf61f07b 100644 --- a/src/main/persistence-pty-binding-reconciliation.test.ts +++ b/src/main/persistence-pty-binding-reconciliation.test.ts @@ -1,9 +1,16 @@ +import { + closeTestStores, + testState, + createStore, + writeDataFile, + makeTerminalTab +} from './persistence-test-harness' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { getDefaultWorkspaceSession } from '../shared/constants' -import { testState, createStore, writeDataFile, makeTerminalTab } from './persistence-test-harness' + import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. @@ -54,7 +61,8 @@ describe('Store', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) it('remaps legacy SSH lease leaf ids by PTY when the layout is already normalized', async () => { @@ -204,7 +212,7 @@ describe('Store', () => { } }) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_1, @@ -228,7 +236,7 @@ describe('Store', () => { terminalTopologyRevisionByRepoId: { wt1: 1 } }) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'fresh-tab', leafId: TEST_LEAF_1, @@ -330,7 +338,7 @@ describe('Store', () => { }) expect( - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_1, @@ -370,7 +378,7 @@ describe('Store', () => { const staleRendererSession = structuredClone(store.getWorkspaceSession(hostId)) expect( - store.persistPtyBinding( + await store.persistPtyBinding( { worktreeId: 'wt1', tabId: 'different-target-tab', @@ -393,7 +401,7 @@ describe('Store', () => { ).toBe(false) expect( - store.persistPtyBinding( + await store.persistPtyBinding( { worktreeId: 'wt-canonical', tabId: 'tab1', @@ -425,7 +433,7 @@ describe('Store', () => { }) expect( - store.persistPtyBinding( + await store.persistPtyBinding( { worktreeId: 'wt1', tabId: 'rejected-tab', diff --git a/src/main/persistence-remote-session-startup.test.ts b/src/main/persistence-remote-session-startup.test.ts index ddb3f57827b..c46f50699bb 100644 --- a/src/main/persistence-remote-session-startup.test.ts +++ b/src/main/persistence-remote-session-startup.test.ts @@ -1,10 +1,10 @@ +import { closeTestStores, createStore, makeRepo, testState } from './persistence-test-harness' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { getDefaultWorkspaceSession } from '../shared/constants' import type { BrowserPage, BrowserWorkspace } from '../shared/browser-workspace-types' -import { createStore, makeRepo, testState } from './persistence-test-harness' vi.mock('./ssh/ssh-config-parser', () => ({ loadUserSshConfig: vi.fn(), @@ -65,7 +65,8 @@ describe('remote session startup ownership', () => { beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-remote-session-')) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) diff --git a/src/main/persistence-repo-lifecycle.test.ts b/src/main/persistence-repo-lifecycle.test.ts index 150f1106d14..f70e1825348 100644 --- a/src/main/persistence-repo-lifecycle.test.ts +++ b/src/main/persistence-repo-lifecycle.test.ts @@ -1,11 +1,5 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { rmSync, mkdtempSync } from 'node:fs' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import type { PersistedState } from '../shared/persisted-state-types' -import type { ProjectGroup } from '../shared/project-group-types' -import { getDefaultWorkspaceSession } from '../shared/constants' import { + closeTestStores, testState, createStore, writeDataFile, @@ -14,6 +8,14 @@ import { makeTerminalTab, makeWorktreeLineage } from './persistence-test-harness' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { rmSync, mkdtempSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { PersistedState } from '../shared/persisted-state-types' +import type { ProjectGroup } from '../shared/project-group-types' +import { getDefaultWorkspaceSession } from '../shared/constants' + import { advanceSshConnectionGeneration, assertSshMutationExpectation, @@ -74,7 +76,8 @@ describe('Store', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) // ── 5. addRepo and getRepo ────────────────────────────────────────── diff --git a/src/main/persistence-settings-ui-defaults.test.ts b/src/main/persistence-settings-ui-defaults.test.ts index 42dc42423b7..3e75cc7d021 100644 --- a/src/main/persistence-settings-ui-defaults.test.ts +++ b/src/main/persistence-settings-ui-defaults.test.ts @@ -1,21 +1,19 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { rmSync, mkdtempSync } from 'node:fs' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import type { GlobalSettings } from '../shared/global-settings-types' -import type { PersistedState } from '../shared/persisted-state-types' -import { - getDefaultPersistedState, - ONBOARDING_FINAL_STEP, - ONBOARDING_FLOW_VERSION -} from '../shared/constants' import { + closeTestStores, testState, createStore, withPlatform, writeDataFile, readDataFile } from './persistence-test-harness' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { rmSync, mkdtempSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { GlobalSettings } from '../shared/global-settings-types' +import type { PersistedState } from '../shared/persisted-state-types' +import { getDefaultPersistedState } from '../shared/constants' +import { ONBOARDING_FINAL_STEP, ONBOARDING_FLOW_VERSION } from '../shared/onboarding-defaults' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -65,7 +63,8 @@ describe('Store', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) it('returns default settings when no data file exists', async () => { diff --git a/src/main/persistence-settings-update.test.ts b/src/main/persistence-settings-update.test.ts index 9af63ca7ccd..aced61e0ac4 100644 --- a/src/main/persistence-settings-update.test.ts +++ b/src/main/persistence-settings-update.test.ts @@ -1,11 +1,5 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { writeFileSync, rmSync, mkdtempSync } from 'node:fs' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import type { GlobalSettings } from '../shared/global-settings-types' -import type { PersistedState } from '../shared/persisted-state-types' -import { getDefaultWorkspaceSession } from '../shared/constants' import { + closeTestStores, testState, createStore, writeDataFile, @@ -14,6 +8,14 @@ import { makeRepo, makeTerminalTab } from './persistence-test-harness' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { writeFileSync, rmSync, mkdtempSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { GlobalSettings } from '../shared/global-settings-types' +import type { PersistedState } from '../shared/persisted-state-types' +import { getDefaultWorkspaceSession } from '../shared/constants' + import { getLocalWorktreeScanGeneration, isLocalWorktreeScanGenerationCurrent @@ -67,7 +69,8 @@ describe('Store', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) // ── 9. Settings: get/update ──────────────────────────────────────── diff --git a/src/main/persistence-single-serialize.test.ts b/src/main/persistence-single-serialize.test.ts index 1293bb30f48..b70c2c01573 100644 --- a/src/main/persistence-single-serialize.test.ts +++ b/src/main/persistence-single-serialize.test.ts @@ -1,10 +1,15 @@ +import { + closeTestStores, + createSqliteTestStore, + readPersistedStateJson +} from './persistence-test-harness' // Why this file exists: persistence.test.ts mocks safeStorage.encryptString // deterministically, which cannot catch the real-world hazard the single- // stringify save guard must survive — encrypt() uses a random IV, so identical // state produces different on-disk bytes each save. These tests mock a // nondeterministic cipher and pin the guard + payload invariants. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { readFileSync, rmSync, mkdtempSync, statSync } from 'node:fs' +import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { randomUUID } from 'node:crypto' @@ -63,7 +68,7 @@ async function createStore() { // file's temp dir rather than the global fake's shared one, after resetModules. installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() - return new Store() + return createSqliteTestStore(Store, { dataFile: join(testState.dir, 'orca-data.json') }) } function dataFile(): string { @@ -84,7 +89,8 @@ describe('persistence single-serialize save guard', () => { vi.useFakeTimers() }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() vi.useRealTimers() rmSync(testState.dir, { recursive: true, force: true }) }) @@ -100,7 +106,7 @@ describe('persistence single-serialize save guard', () => { it('skips the disk write when state is identical, even with secrets set (random-IV cipher)', async () => { const store = await seedStoreWithSecrets() - const inoBefore = statSync(dataFile()).ino + const stateBefore = readPersistedStateJson(dataFile()) // Net no-op mutation burst: the encrypted payload bytes would differ // (random IV), but the normalized guard hash must not. @@ -111,29 +117,29 @@ describe('persistence single-serialize save guard', () => { vi.advanceTimersByTime(2000) await store.waitForPendingWrite() - expect(statSync(dataFile()).ino).toBe(inoBefore) + expect(readPersistedStateJson(dataFile())).toBe(stateBefore) }) it('still writes when state actually changes (including a secret change)', async () => { const store = await seedStoreWithSecrets() - const inoBefore = statSync(dataFile()).ino + const stateBefore = readPersistedStateJson(dataFile()) store.updateSettings({ opencodeSessionCookie: 'rotated-cookie' }) vi.advanceTimersByTime(2000) await store.waitForPendingWrite() - const inoAfter = statSync(dataFile()).ino - expect(inoAfter).not.toBe(inoBefore) + const stateAfter = readPersistedStateJson(dataFile()) + expect(stateAfter).not.toBe(stateBefore) store.updateUI({ sidebarWidth: 777 }) vi.advanceTimersByTime(2000) await store.waitForPendingWrite() - expect(statSync(dataFile()).ino).not.toBe(inoAfter) + expect(readPersistedStateJson(dataFile())).not.toBe(stateAfter) }) it('writes encrypted secrets to disk and round-trips them through a reload', async () => { await seedStoreWithSecrets() - const raw = readFileSync(dataFile(), 'utf-8') + const raw = readPersistedStateJson(dataFile()) const persisted = JSON.parse(raw) as { settings: { opencodeSessionCookie: string; httpProxyUrl: string } ui: { browserKagiSessionLink: string } @@ -158,29 +164,30 @@ describe('persistence single-serialize save guard', () => { vi.advanceTimersByTime(1000) await store.waitForPendingWrite() - const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The preceding Store save produced the PersistedState snapshot read by this test. + const persisted = JSON.parse(readPersistedStateJson(dataFile())) as { settings: { opencodeSessionCookie: string; httpProxyUrl: string } } expect(persisted.settings.opencodeSessionCookie).toBe('') expect(persisted.settings.httpProxyUrl).toBe('') - const inoBefore = statSync(dataFile()).ino + const stateBefore = readPersistedStateJson(dataFile()) store.updateSettings({ httpProxyUrl: SECRETS.httpProxyUrl }) vi.advanceTimersByTime(2000) await store.waitForPendingWrite() - expect(statSync(dataFile()).ino).toBe(inoBefore) + expect(readPersistedStateJson(dataFile())).toBe(stateBefore) }) it('sync flush also skips on identical state with secrets set', async () => { const store = await seedStoreWithSecrets() - const inoBefore = statSync(dataFile()).ino + const stateBefore = readPersistedStateJson(dataFile()) store.flushOrThrow() - expect(statSync(dataFile()).ino).toBe(inoBefore) + expect(readPersistedStateJson(dataFile())).toBe(stateBefore) }) - it('performs exactly one full-state JSON.stringify per save (was two)', async () => { + it('does not serialize the complete profile for a UI domain save', async () => { const store = await seedStoreWithSecrets() // Count full-state serializations: only the durable-state payload is @@ -188,17 +195,20 @@ describe('persistence single-serialize save guard', () => { // stay far below the threshold). const original = JSON.stringify.bind(JSON) let fullStateSerializations = 0 - const spy = vi.spyOn(JSON, 'stringify').mockImplementation((( - value: unknown, - ...rest: unknown[] - ) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const out = original(value as any, ...(rest as [any?, any?])) - if (typeof out === 'string' && out.length > 1_000) { + const spy = vi.spyOn(JSON, 'stringify').mockImplementation((...args) => { + const [value] = args + const out = original(...args) + if ( + value && + typeof value === 'object' && + 'repos' in value && + 'settings' in value && + 'ui' in value + ) { fullStateSerializations++ } return out - }) as typeof JSON.stringify) + }) try { store.updateUI({ sidebarWidth: 640 }) vi.advanceTimersByTime(2000) @@ -207,7 +217,7 @@ describe('persistence single-serialize save guard', () => { spy.mockRestore() } - expect(fullStateSerializations).toBe(1) + expect(fullStateSerializations).toBe(0) }) // Regression (adversarial review, gpt-5.6-sol round 1): the guard hash @@ -227,11 +237,7 @@ describe('persistence single-serialize save guard', () => { store.updateSettings({ opencodeSessionCookie: P }) vi.advanceTimersByTime(1000) await store.waitForPendingWrite() - const C = ( - JSON.parse(readFileSync(dataFile(), 'utf-8')) as { - settings: { opencodeSessionCookie: string } - } - ).settings.opencodeSessionCookie + const C: string = JSON.parse(readPersistedStateJson(dataFile())).settings.opencodeSessionCookie expect(C).not.toBe(P) // C is ciphertext // State 1: the plaintext bypass-rules field literally holds ciphertext C; @@ -239,13 +245,13 @@ describe('persistence single-serialize save guard', () => { store.updateSettings({ httpProxyBypassRules: C, opencodeSessionCookie: P }) vi.advanceTimersByTime(2000) await store.waitForPendingWrite() - const inoState1 = statSync(dataFile()).ino + const inoState1 = readPersistedStateJson(dataFile()) // State 2 (distinct): swap the two values. Must be written, not skipped. store.updateSettings({ httpProxyBypassRules: P, opencodeSessionCookie: C }) vi.advanceTimersByTime(2000) await store.waitForPendingWrite() - expect(statSync(dataFile()).ino).not.toBe(inoState1) + expect(readPersistedStateJson(dataFile())).not.toBe(inoState1) // The swap round-trips through a reload — nothing was lost. const reloaded = await createStore() @@ -268,11 +274,7 @@ describe('persistence single-serialize save guard', () => { store.updateUI({ browserKagiSessionLink: K }) vi.advanceTimersByTime(1000) await store.waitForPendingWrite() - const C = ( - JSON.parse(readFileSync(dataFile(), 'utf-8')) as { - ui: { browserKagiSessionLink: string } - } - ).ui.browserKagiSessionLink + const C: string = JSON.parse(readPersistedStateJson(dataFile())).ui.browserKagiSessionLink expect(C).not.toBe(K) // C is ciphertext // State 1: env var literally named after the secret field, value = C; the @@ -281,14 +283,14 @@ describe('persistence single-serialize save guard', () => { store.updateUI({ browserKagiSessionLink: K }) vi.advanceTimersByTime(2000) await store.waitForPendingWrite() - const inoState1 = statSync(dataFile()).ino + const inoState1 = readPersistedStateJson(dataFile()) // State 2 (distinct): swap — env var value = K, ui secret = C. Must write. store.updateSettings({ agentDefaultEnv: { claude: { browserKagiSessionLink: K } } }) store.updateUI({ browserKagiSessionLink: C }) vi.advanceTimersByTime(2000) await store.waitForPendingWrite() - expect(statSync(dataFile()).ino).not.toBe(inoState1) + expect(readPersistedStateJson(dataFile())).not.toBe(inoState1) const reloaded = await createStore() expect(reloaded.getSettings().agentDefaultEnv?.claude?.browserKagiSessionLink).toBe(K) diff --git a/src/main/persistence-source-control-ai-migration.test.ts b/src/main/persistence-source-control-ai-migration.test.ts index ffc7d5b6eba..f3969da6ef0 100644 --- a/src/main/persistence-source-control-ai-migration.test.ts +++ b/src/main/persistence-source-control-ai-migration.test.ts @@ -1,17 +1,19 @@ +import { + closeTestStores, + testState, + createStore, + dataFile, + writeDataFile, + readDataFile, + readPersistedStateJson, + makeRepo +} from './persistence-test-harness' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { writeFileSync, readFileSync, rmSync, mkdtempSync, mkdirSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import type { PersistedState } from '../shared/persisted-state-types' import { setSourceControlActionDefault } from '../shared/source-control-ai-actions' -import { - testState, - createStore, - dataFile, - writeDataFile, - readDataFile, - makeRepo -} from './persistence-test-harness' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -61,19 +63,18 @@ describe('Store', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) - // ── 3. Corrupt JSON → falls back to defaults ──────────────────────── + // Invalid profile state must not silently replace user settings with defaults. - it('falls back to defaults when data file contains invalid JSON', async () => { + it('refuses invalid legacy JSON without replacing it with defaults', () => { mkdirSync(testState.dir, { recursive: true }) writeFileSync(dataFile(), '{{{invalid json', 'utf-8') - const store = await createStore() - expect(store.getRepos()).toEqual([]) - expect(store.getSettings().theme).toBe('system') - expect(store.getSettings().experimentalNewWorktreeCardStyle).toBe(false) + expect(() => createStore()).toThrow('Profile state JSON is invalid') + expect(readFileSync(dataFile(), 'utf8')).toBe('{{{invalid json') }) // ── 4. Schema migration: merges with defaults ─────────────────────── @@ -347,7 +348,7 @@ describe('Store', () => { customAgentCommand: 'claude' }) store.flush() - const persisted = JSON.parse(readFileSync(join(testState.dir, 'orca-data.json'), 'utf-8')) + const persisted = JSON.parse(readPersistedStateJson(dataFile())) expect(persisted.settings.sourceControlAi.actions.commitMessage).toEqual({ agentId: 'claude', commandInputTemplate: '{basePrompt}\n\nRollback commit prompt' diff --git a/src/main/persistence-split-pane-incarnation.test.ts b/src/main/persistence-split-pane-incarnation.test.ts index b092c3d7d9e..b628c28384b 100644 --- a/src/main/persistence-split-pane-incarnation.test.ts +++ b/src/main/persistence-split-pane-incarnation.test.ts @@ -1,10 +1,18 @@ +import { + closeTestStores, + testState, + createStore, + makeTerminalTab +} from './persistence-test-harness' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' +import { ProfileStateSqliteAuthority } from './persistence/profile-state/profile-state-sqlite-authority' import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' import { getDefaultWorkspaceSession } from '../shared/constants' -import { testState, createStore, makeTerminalTab } from './persistence-test-harness' + import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. @@ -55,7 +63,8 @@ describe('Store', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) it('rejects a split source incarnation mismatch', async () => { @@ -77,7 +86,7 @@ describe('Store', () => { }) expect( - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_2, @@ -127,7 +136,7 @@ describe('Store', () => { store.setWorkspaceSession(sourceSession, hostId) expect( - store.persistPtyBinding( + await store.persistPtyBinding( { worktreeId: 'wt1', tabId: 'tab1', @@ -174,7 +183,7 @@ describe('Store', () => { ) expect( - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_2, @@ -213,7 +222,7 @@ describe('Store', () => { { ptyId: 'pty-current', expectedIncarnationId: 'inc-replaced' } ]) { expect( - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_1, @@ -253,7 +262,7 @@ describe('Store', () => { store.setWorkspaceSession(structuredClone(session), 'ssh:ssh-1') expect( - store.persistPtyBinding( + await store.persistPtyBinding( { worktreeId: 'wt1', tabId: 'tab1', @@ -302,7 +311,7 @@ describe('Store', () => { ) expect( - store.persistPtyBinding( + await store.persistPtyBinding( { worktreeId: 'wt1', tabId: 'tab1', @@ -348,11 +357,15 @@ describe('Store', () => { }, terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-stale' } }) - vi.spyOn(store, 'flushOrThrow').mockImplementationOnce(() => { + store.flushOrThrow() + vi.spyOn( + ProfileStateSqliteAuthority.prototype, + 'writeSerializedDomains' + ).mockImplementationOnce(() => { throw new Error('disk full') }) - expect(() => + await expect( store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', @@ -361,7 +374,7 @@ describe('Store', () => { incarnationId: 'inc-live', expectedBinding: { ptyId: 'pty-1', incarnationId: 'inc-stale' } }) - ).toThrow('disk full') + ).rejects.toThrow('disk full') expect(store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe( 'inc-stale' ) diff --git a/src/main/persistence-ssh-lease-reattach-reclaim.test.ts b/src/main/persistence-ssh-lease-reattach-reclaim.test.ts index b2088fa616b..adc6946725b 100644 --- a/src/main/persistence-ssh-lease-reattach-reclaim.test.ts +++ b/src/main/persistence-ssh-lease-reattach-reclaim.test.ts @@ -1,8 +1,9 @@ +import { closeTestStores, createStore, testState } from './persistence-test-harness' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { createStore, testState } from './persistence-test-harness' + import { sshRemotePtyLeaseAllowsReattach } from '../shared/ssh-types' vi.mock('electron', () => ({ @@ -24,7 +25,8 @@ describe('ssh remote pty lease reclaim after a proven reattach', () => { beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) diff --git a/src/main/persistence-ssh-lease-tombstone-retention.test.ts b/src/main/persistence-ssh-lease-tombstone-retention.test.ts index feafdd766e4..1e52c6ad41c 100644 --- a/src/main/persistence-ssh-lease-tombstone-retention.test.ts +++ b/src/main/persistence-ssh-lease-tombstone-retention.test.ts @@ -1,8 +1,9 @@ +import { closeTestStores, createStore, testState } from './persistence-test-harness' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { createStore, testState } from './persistence-test-harness' + import { TEST_LEAF_1 } from './persistence-session-fixtures' vi.mock('electron', () => ({ @@ -18,7 +19,8 @@ describe('operator-closed SSH lease tombstones', () => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) diff --git a/src/main/persistence-ssh-pending-pty-kill.test.ts b/src/main/persistence-ssh-pending-pty-kill.test.ts index 627d9f38de0..19298b3288f 100644 --- a/src/main/persistence-ssh-pending-pty-kill.test.ts +++ b/src/main/persistence-ssh-pending-pty-kill.test.ts @@ -1,8 +1,9 @@ +import { closeTestStores, testState, createStore } from './persistence-test-harness' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { testState, createStore } from './persistence-test-harness' + import { MAX_SSH_PENDING_PTY_KILLS_PER_TARGET, SSH_PENDING_PTY_KILL_TTL_MS @@ -27,7 +28,8 @@ describe('Store SSH pending PTY kills', () => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) @@ -48,6 +50,19 @@ describe('Store SSH pending PTY kills', () => { ]) }) + it('keeps an epoch-scoped intent with no incarnation across a restart, and drops a legacy one', async () => { + const store = await createStore() + for (const ptyId of ['pty2:epoch-a:1', 'pty-2']) { + store.recordSshRemotePtyKillIntent('ssh-1', ptyId, { requestedAt: NOW, attempts: 0 }) + } + store.flush() + + const reloaded = await createStore() + expect(reloaded.getSshRemotePtyKillIntents('ssh-1', NOW)).toEqual([ + { ptyId: 'pty2:epoch-a:1', intent: { requestedAt: NOW, attempts: 0 } } + ]) + }) + // A kill issued while the provider was already unregistered writes no lease of its own, and that // offline close is the case most likely to strand a remote shell. it('records an intent for a PTY that has no lease row yet', async () => { diff --git a/src/main/persistence-ssh-readoption-automation-migration.test.ts b/src/main/persistence-ssh-readoption-automation-migration.test.ts index 172c854cfd0..886b5475983 100644 --- a/src/main/persistence-ssh-readoption-automation-migration.test.ts +++ b/src/main/persistence-ssh-readoption-automation-migration.test.ts @@ -1,3 +1,4 @@ +import { closeTestStores, createSqliteTestStore } from './persistence-test-harness' /** * SSH re-adoption has to repair the automation with the workspace. * @@ -145,7 +146,7 @@ async function createStoreFromState(state: Record) { installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('./persistence') initDataPath() - return new Store() + return createSqliteTestStore(Store, { dataFile: join(testState.dir, 'orca-data.json') }) } /** Re-read whatever is on disk now — no fixture rewrite. */ @@ -154,7 +155,7 @@ async function reloadStore() { installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('./persistence') initDataPath() - return new Store() + return createSqliteTestStore(Store, { dataFile: join(testState.dir, 'orca-data.json') }) } async function createSshStore(state: Record) { @@ -172,7 +173,8 @@ beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-readopt-')) }) -afterEach(() => { +afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) vi.resetModules() }) diff --git a/src/main/persistence-ssh-remote-pty-binding-replay.test.ts b/src/main/persistence-ssh-remote-pty-binding-replay.test.ts index 8cfbd7b2057..f32f3836827 100644 --- a/src/main/persistence-ssh-remote-pty-binding-replay.test.ts +++ b/src/main/persistence-ssh-remote-pty-binding-replay.test.ts @@ -1,8 +1,9 @@ +import { closeTestStores, testState, createStore } from './persistence-test-harness' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { testState, createStore } from './persistence-test-harness' + import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' @@ -54,7 +55,8 @@ describe('Store', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) it('retains an SSH host binding when a stale renderer clears its pty map', async () => { diff --git a/src/main/persistence-ssh-remote-pty-leases.test.ts b/src/main/persistence-ssh-remote-pty-leases.test.ts index d4cdc79285c..ea0d8fe350d 100644 --- a/src/main/persistence-ssh-remote-pty-leases.test.ts +++ b/src/main/persistence-ssh-remote-pty-leases.test.ts @@ -1,8 +1,9 @@ +import { closeTestStores, testState, createStore, writeDataFile } from './persistence-test-harness' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { testState, createStore, writeDataFile } from './persistence-test-harness' + import { getDefaultPersistedState } from '../shared/constants' import { sshRemotePtyLeaseAllowsReattach } from '../shared/ssh-types' import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' @@ -96,7 +97,8 @@ describe('Store', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) it('merges missing prior layout bindings into partial renderer snapshots', async () => { @@ -794,7 +796,8 @@ describe('ssh remote pty lease route-retirement marks survive the disk round tri beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) diff --git a/src/main/persistence-ssh-targets-and-pane-keys.test.ts b/src/main/persistence-ssh-targets-and-pane-keys.test.ts index c11ecbf0bc2..4a1da77dc10 100644 --- a/src/main/persistence-ssh-targets-and-pane-keys.test.ts +++ b/src/main/persistence-ssh-targets-and-pane-keys.test.ts @@ -1,11 +1,5 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { rmSync, mkdtempSync } from 'node:fs' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import { isTerminalLeafId, makePaneKey } from '../shared/stable-pane-id' -import { SshConnectionStore } from './ssh/ssh-connection-store' -import { LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' import { + closeTestStores, testState, createStore, writeDataFile, @@ -13,6 +7,14 @@ import { makeRepo, makeTerminalTab } from './persistence-test-harness' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { rmSync, mkdtempSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { isTerminalLeafId, makePaneKey } from '../shared/stable-pane-id' +import { SshConnectionStore } from './ssh/ssh-connection-store' +import { LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' + import { TEST_LEAF_1 } from './persistence-session-fixtures' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. @@ -63,7 +65,8 @@ describe('Store', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) // ── 2. Load from existing valid file ───────────────────────────────── diff --git a/src/main/persistence-terminal-option-key-migration.test.ts b/src/main/persistence-terminal-option-key-migration.test.ts index e9219c41bfa..9d171178322 100644 --- a/src/main/persistence-terminal-option-key-migration.test.ts +++ b/src/main/persistence-terminal-option-key-migration.test.ts @@ -1,15 +1,16 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { rmSync, mkdtempSync } from 'node:fs' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import type { PersistedState } from '../shared/persisted-state-types' import { + closeTestStores, testState, createStore, withPlatform, writeDataFile, readDataFile } from './persistence-test-harness' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { rmSync, mkdtempSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { PersistedState } from '../shared/persisted-state-types' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -59,7 +60,8 @@ describe('Store', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) // ── terminalMacOptionAsAlt migration (issue #903) ─────────────────── diff --git a/src/main/persistence-test-harness.ts b/src/main/persistence-test-harness.ts index e2c04495e92..5a146da6e1d 100644 --- a/src/main/persistence-test-harness.ts +++ b/src/main/persistence-test-harness.ts @@ -1,5 +1,5 @@ -import { mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' +import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' import type { Project, ProjectHostSetup } from '../shared/project-types' import type { Repo } from '../shared/repo-types' @@ -9,16 +9,84 @@ import { folderWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-sc import type { PersistedState } from '../shared/persisted-state-types' import { hydrateWorktreeMetaAliasProjection } from './persistence/loading-store/worktree-meta-alias-projection' import { Store } from './persistence/loading-store/store' -import { initDataPath } from './persistence/loading-store/user-data-path' +import { getDataFile, initDataPath } from './persistence/loading-store/user-data-path' +import type { StoreRuntimeOptions } from './persistence/loading-store/store-runtime-state' +import { ProfileStateSqliteAuthority } from './persistence/profile-state/profile-state-sqlite-authority' +import { + openProfileStateDatabaseReadOnly, + profileStateDatabaseFile +} from './persistence/profile-state/profile-state-database' +import { exportProfileStateJson } from './persistence/profile-state/profile-state-documents' // Shared mutable state so the electron mock can reference a per-test directory export const testState = { dir: '' } +const stores = new Set() + +class UnitTestProfileStateAuthority extends ProfileStateSqliteAuthority { + // Worker backup coverage uses the dedicated real-worker fixtures. + override scheduleBackup(): void {} +} + +export function createSqliteTestStore( + StoreConstructor: typeof Store, + options: Omit = {} +): Store { + const path = options.dataFile ?? getDataFile() + const databaseFile = profileStateDatabaseFile(dirname(path)) + mkdirSync(dirname(path), { recursive: true }) + const authority = new UnitTestProfileStateAuthority(databaseFile, 'persistence-test') + try { + if (!existsSync(databaseFile) && existsSync(path)) { + authority.writeSerializedState(readFileSync(path)) + } + const store = new StoreConstructor({ + ...options, + dataFile: path, + profileStateAuthority: authority + }) + stores.add(store) + return store + } catch (error) { + authority.close() + throw error + } +} + +export function writePersistedStateJson(path: string, json: string): void { + mkdirSync(dirname(path), { recursive: true }) + const databaseFile = profileStateDatabaseFile(dirname(path)) + const authority = new UnitTestProfileStateAuthority(databaseFile, 'persistence-test') + try { + authority.writeSerializedState(Buffer.from(json)) + } finally { + authority.close() + } +} + +export async function closeTestStores(): Promise { + const opened = [...stores] + stores.clear() + await Promise.all(opened.map((store) => store.freezeWritesAsync())) +} + +export function readPersistedStateJson(path = dataFile(), profileId = 'persistence-test'): string { + const databaseFile = profileStateDatabaseFile(dirname(path)) + if (!existsSync(databaseFile)) { + return readFileSync(path, 'utf8') + } + const opened = openProfileStateDatabaseReadOnly(databaseFile, profileId) + try { + return exportProfileStateJson(opened.db) + } finally { + opened.db.close() + } +} /** Create a profile store without rebuilding its large module graph inside each test timeout. */ export function createStore(): Store { installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() - return new Store({ dataFile: dataFile() }) + return createSqliteTestStore(Store, { dataFile: dataFile() }) } export async function withPlatform(platform: NodeJS.Platform, fn: () => Promise): Promise { @@ -37,7 +105,12 @@ export function dataFile(): string { export function writeDataFile(data: unknown): void { mkdirSync(testState.dir, { recursive: true }) - writeFileSync(dataFile(), JSON.stringify(data, null, 2), 'utf-8') + const json = JSON.stringify(data, null, 2) + if (existsSync(profileStateDatabaseFile(testState.dir))) { + writePersistedStateJson(dataFile(), json) + return + } + writeFileSync(dataFile(), json, 'utf-8') } /** @@ -47,7 +120,8 @@ export function writeDataFile(data: unknown): void { * the literal bytes parse the file themselves (see `worktree-meta-alias-projection.test.ts`). */ export function readDataFile(): unknown { - const parsed = JSON.parse(readFileSync(dataFile(), 'utf-8')) as PersistedState + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Store exports use PersistedState; this fixture reader restores only its omitted aliases. + const parsed = JSON.parse(readPersistedStateJson()) as PersistedState hydrateWorktreeMetaAliasProjection(parsed) return parsed } diff --git a/src/main/persistence-ui-state.test.ts b/src/main/persistence-ui-state.test.ts index 1b092293c68..10bea531533 100644 --- a/src/main/persistence-ui-state.test.ts +++ b/src/main/persistence-ui-state.test.ts @@ -1,11 +1,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { readFileSync, rmSync, mkdtempSync, existsSync } from 'node:fs' +import { rmSync, mkdtempSync, existsSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import type { PersistedState } from '../shared/persisted-state-types' import { getDefaultPersistedState } from '../shared/constants' import { createDefaultWorkspaceCleanupBrowseState } from '../shared/workspace-cleanup-browse-state' import { + closeTestStores, + createSqliteTestStore, + readPersistedStateJson, testState, dataFile, writeDataFile, @@ -55,7 +58,7 @@ async function createStore() { // file's temp dir rather than the global fake's shared one, after resetModules. installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() - return new Store() + return createSqliteTestStore(Store, { dataFile: join(testState.dir, 'orca-data.json') }) } vi.mock('./telemetry/client', () => ({ @@ -74,7 +77,8 @@ describe('Store', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) // ── UI state ─────────────────────────────────────────────────────── @@ -132,6 +136,25 @@ describe('Store', () => { expect(store.getUI().sidebarWidth).toBe(400) }) + it('updateUI persists sanitized per-worktree explorer roots', async () => { + const store = await createStore() + store.updateUI({ + explorerDisplayRootByWorktree: { + 'repo-1::/repo': '/', + 'repo-2::/repo': 'packages/app', + // @ts-expect-error Deliberately malformed input exercises runtime sanitization. + 'repo-3::/repo': false, + // @ts-expect-error Deliberately malformed prototype key exercises runtime sanitization. + constructor: false + } + }) + + expect(store.getUI().explorerDisplayRootByWorktree).toEqual({ + 'repo-1::/repo': '/', + 'repo-2::/repo': 'packages/app' + }) + }) + it('updateUI persists sanitized per-worktree dotfile visibility', async () => { const store = await createStore() store.updateUI({ @@ -165,7 +188,7 @@ describe('Store', () => { }) vi.advanceTimersByTime(1000) await store.waitForPendingWrite() - const persistedBefore = readFileSync(dataFile(), 'utf-8') + const persistedBefore = readPersistedStateJson(dataFile()) store.onUIChanged((ui) => notifications.push(ui)) store.updateUI({ @@ -181,7 +204,7 @@ describe('Store', () => { await store.waitForPendingWrite() expect(notifications).toEqual([]) - expect(readFileSync(dataFile(), 'utf-8')).toBe(persistedBefore) + expect(readPersistedStateJson(dataFile())).toBe(persistedBefore) } finally { vi.useRealTimers() } @@ -193,7 +216,7 @@ describe('Store', () => { store.updateUI({ sidebarWidth: 321 }) store.flush() - const raw = readFileSync(dataFile(), 'utf-8') + const raw = readPersistedStateJson(dataFile()) // Compact payload: no newline-plus-indentation from JSON.stringify(_, null, 2). expect(raw).not.toMatch(/\n\s+"/) const parsed = JSON.parse(raw) as PersistedState @@ -782,4 +805,39 @@ describe('Store', () => { const store = await createStore() expect(store.getUI().browserKagiSessionLink).toBe(sessionLink) }) + + it.each(['shutdown', 'freeze', 'maintenance'] as const)( + 'rejects legacy SSH mutations before changing memory during %s', + async (gate) => { + const store = await createStore() + const recovery = { + targetId: 'ssh-1', + clientInstanceId: 'client-1', + serverBuildId: 'relay-build-1', + clientGeneration: 3, + ownerGeneration: 5, + ownerLease: 'secret-owner-lease' + } + await store.upsertSshPtyConsumerRecovery(recovery) + store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'pty-1', state: 'detached' }) + await store.flushPendingOrThrowAsync() + const closing = + gate === 'shutdown' + ? store.flushAsync() + : gate === 'freeze' + ? store.freezeWritesAsync() + : store.beginProfileMaintenance() + await Promise.all( + [ + store.upsertSshPtyConsumerRecovery({ ...recovery, clientInstanceId: 'refused-owner' }), + store.removeSshPtyConsumerRecovery('ssh-1'), + store.markSshRemotePtyLeasesAsync('ssh-1', 'terminated'), + store.markSshRemotePtyLeasesAttachedAsync('ssh-1', ['pty-1']) + ].map((operation) => expect(operation).rejects.toThrow('finalized profile persistence')) + ) + expect(store.getSshPtyConsumerRecovery('ssh-1')).toEqual(recovery) + expect(store.getSshRemotePtyLeases('ssh-1')[0]?.state).toBe('detached') + await closing + } + ) }) diff --git a/src/main/persistence-update-repo.test.ts b/src/main/persistence-update-repo.test.ts index d2426729658..b60e43df840 100644 --- a/src/main/persistence-update-repo.test.ts +++ b/src/main/persistence-update-repo.test.ts @@ -1,9 +1,5 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { rmSync, mkdtempSync } from 'node:fs' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import { getDefaultPersistedState } from '../shared/constants' import { + closeTestStores, testState, createStore, writeDataFile, @@ -11,6 +7,12 @@ import { makeProject, makeProjectHostSetup } from './persistence-test-harness' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { rmSync, mkdtempSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { getDefaultPersistedState } from '../shared/constants' + import { getLocalWorktreeScanGeneration, isLocalWorktreeScanGenerationCurrent @@ -64,7 +66,8 @@ describe('Store', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) // ── 7. updateRepo ────────────────────────────────────────────────── diff --git a/src/main/persistence-workspace-pinned-automation-fence.test.ts b/src/main/persistence-workspace-pinned-automation-fence.test.ts index 977aed0692d..1eaf04739a9 100644 --- a/src/main/persistence-workspace-pinned-automation-fence.test.ts +++ b/src/main/persistence-workspace-pinned-automation-fence.test.ts @@ -1,3 +1,9 @@ +import { + closeTestStores, + createSqliteTestStore, + readPersistedStateJson, + writePersistedStateJson +} from './persistence-test-harness' /** * A `local`-typed automation whose folder workspace pins it to an SSH host is * projected as SSH-owned, so it must be fenceable like any other SSH-owned row. @@ -8,7 +14,7 @@ * different machine. These tests drive the real Store through create and reload. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import type { Automation, AutomationCreateInput } from '../shared/automations-types' @@ -116,7 +122,7 @@ async function createStoreFromState(state: Record) { installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('./persistence') initDataPath() - return new Store() + return createSqliteTestStore(Store, { dataFile: join(testState.dir, 'orca-data.json') }) } async function reloadStore() { @@ -124,16 +130,16 @@ async function reloadStore() { installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('./persistence') initDataPath() - return new Store() + return createSqliteTestStore(Store, { dataFile: join(testState.dir, 'orca-data.json') }) } /** The same target id now carries a different registration incarnation. */ function replaceStoredTargetGeneration(generation: number): void { const file = join(testState.dir, 'orca-data.json') - const state = JSON.parse(readFileSync(file, 'utf-8')) + const state = JSON.parse(readPersistedStateJson(file)) state.sshTargets = [prodTarget(generation)] state.sshTargetGenerationCounter = generation - writeFileSync(file, JSON.stringify(state), 'utf-8') + writePersistedStateJson(file, JSON.stringify(state)) } const PINNED_CREATE_INPUT: AutomationCreateInput = { @@ -158,7 +164,8 @@ beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-pinned-fence-')) }) -afterEach(() => { +afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) vi.resetModules() }) diff --git a/src/main/persistence-workspace-repin-automation-owner.test.ts b/src/main/persistence-workspace-repin-automation-owner.test.ts index 14f33c5db29..4f17c14ec47 100644 --- a/src/main/persistence-workspace-repin-automation-owner.test.ts +++ b/src/main/persistence-workspace-repin-automation-owner.test.ts @@ -1,3 +1,9 @@ +import { + closeTestStores, + createSqliteTestStore, + readPersistedStateJson, + writePersistedStateJson +} from './persistence-test-harness' /** * A folder workspace can be re-pointed at another SSH host outside the automation * editor — the workspace's scope connection moves, and every record inside it @@ -10,7 +16,7 @@ * a host removed and re-added under the same id still cannot re-adopt the record. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import type { FolderWorkspace } from '../shared/folder-workspace-types' @@ -140,25 +146,26 @@ async function loadStore(state: Record) { installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('./persistence') initDataPath() - return new Store() + return createSqliteTestStore(Store, { dataFile: join(testState.dir, 'orca-data.json') }) } /** The workspace's execution host changes without any automation being edited. */ function repinWorkspace(connectionId: string, sshTargets?: SshTarget[]): void { const file = join(testState.dir, 'orca-data.json') - const state = JSON.parse(readFileSync(file, 'utf-8')) + const state = JSON.parse(readPersistedStateJson(file)) state.folderWorkspaces = [folderWorkspace(connectionId)] if (sshTargets) { state.sshTargets = sshTargets } - writeFileSync(file, JSON.stringify(state), 'utf-8') + writePersistedStateJson(file, JSON.stringify(state)) } beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-workspace-repin-')) }) -afterEach(() => { +afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) vi.resetModules() }) @@ -183,7 +190,9 @@ describe('a workspace re-pinned outside the automation editor', () => { installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('./persistence') initDataPath() - const reloaded = new Store() + const reloaded = createSqliteTestStore(Store, { + dataFile: join(testState.dir, 'orca-data.json') + }) expect(reloaded.listAutomations()[0].executionTargetGeneration).toBe(STAGING_GENERATION) expect(reloaded.listAutomationsForScope().items[0].selector).toEqual({ @@ -205,7 +214,9 @@ describe('a workspace re-pinned outside the automation editor', () => { installFakeAppEnvironment({ getPath: () => testState.dir }) const { Store, initDataPath } = await import('./persistence') initDataPath() - const reloaded = new Store() + const reloaded = createSqliteTestStore(Store, { + dataFile: join(testState.dir, 'orca-data.json') + }) expect(reloaded.listAutomations()[0].executionTargetGeneration).toBe(PROD_GENERATION) expect(reloaded.listAutomationsForScope().items[0].selector).toEqual({ diff --git a/src/main/persistence-workspace-session-scrollback.test.ts b/src/main/persistence-workspace-session-scrollback.test.ts index 29c664be49b..8afd69d70b0 100644 --- a/src/main/persistence-workspace-session-scrollback.test.ts +++ b/src/main/persistence-workspace-session-scrollback.test.ts @@ -1,12 +1,15 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { writeFileSync, readFileSync, rmSync, mkdtempSync, mkdirSync, existsSync } from 'node:fs' -import type * as NodeFsPromises from 'node:fs/promises' +import { writeFileSync, rmSync, mkdtempSync, mkdirSync, existsSync } from 'node:fs' +import { ProfileStateSqliteAuthority } from './persistence/profile-state/profile-state-sqlite-authority' import { join } from 'node:path' import { tmpdir } from 'node:os' import { isTerminalLeafId, makePaneKey } from '../shared/stable-pane-id' import { TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT } from '../shared/terminal-scrollback-limits' import { MAX_BROWSER_HISTORY_ENTRIES } from '../shared/workspace-session-browser-history' import { + closeTestStores, + createSqliteTestStore, + readPersistedStateJson, testState, createStore, dataFile, @@ -29,29 +32,6 @@ const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => sshConfigHostsToTargetsMock: vi.fn() })) -const asyncPrimaryWriteFailure = vi.hoisted(() => ({ - error: null as Error | null, - targetPrefix: '' -})) - -vi.mock('node:fs/promises', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - open: async (...args: Parameters) => { - const target = args[0] - if ( - asyncPrimaryWriteFailure.error && - typeof target === 'string' && - target.startsWith(asyncPrimaryWriteFailure.targetPrefix) - ) { - throw asyncPrimaryWriteFailure.error - } - return actual.open(...args) - } - } -}) - vi.mock('./ssh/ssh-config-parser', () => ({ loadUserSshConfig: loadUserSshConfigMock, sshConfigHostsToTargets: sshConfigHostsToTargetsMock @@ -92,14 +72,11 @@ describe('Store', () => { trackMock.mockReset() getCohortAtEmitMock.mockReset() getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) - asyncPrimaryWriteFailure.error = null - asyncPrimaryWriteFailure.targetPrefix = '' }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) - asyncPrimaryWriteFailure.error = null - asyncPrimaryWriteFailure.targetPrefix = '' }) // ── GitHub Cache ─────────────────────────────────────────────────── @@ -222,7 +199,7 @@ describe('Store', () => { // AppEnvironment — without this it points at the global fake's shared dir. installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() - const store = new Store({ dataFile: profileDataFile }) + const store = createSqliteTestStore(Store, { dataFile: profileDataFile }) store.addRepo(makeRepo({ id: 'remote-repo', connectionId: 'ssh-target-1' })) const session = makeSessionWithTerminalBuffers() store.setWorkspaceSession({ @@ -253,7 +230,7 @@ describe('Store', () => { // AppEnvironment — without this it points at the global fake's shared dir. installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() - const store = new Store({ dataFile: profileDataFile }) + const store = createSqliteTestStore(Store, { dataFile: profileDataFile }) expect(store.readTerminalScrollbackSnapshot(ref)).toBe('legacy-scrollback') }) @@ -342,7 +319,8 @@ describe('Store', () => { }) expect(existsSync(join(testState.dir, 'terminal-scrollback', `${ref}.bin`))).toBe(false) - const stillPublished = JSON.parse(readFileSync(dataFile(), 'utf-8')) as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The preceding Store save produced the PersistedState snapshot read by this test. + const stillPublished = JSON.parse(readPersistedStateJson(dataFile())) as { workspaceSession: { terminalLayoutsByTabId: Record }> } @@ -353,7 +331,7 @@ describe('Store', () => { ).toBe(ref) }) - it('leaves durable JSON pointing at deleted scrollback when the replacement write fails', async () => { + it('leaves durable state pointing at deleted scrollback when the replacement write fails', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'remote-repo', connectionId: 'ssh-target-1' })) const session = makeSessionWithTerminalBuffers() @@ -375,14 +353,17 @@ describe('Store', () => { } const snapshotPath = join(testState.dir, 'terminal-scrollback', `${ref}.bin`) store.flushOrThrow() - const durableBeforeRemoval = readFileSync(dataFile(), 'utf-8') + const durableBeforeRemoval = readPersistedStateJson(dataFile()) expect(existsSync(snapshotPath)).toBe(true) const writeError = Object.assign(new Error('profile mount rejected replacement write'), { code: 'EIO' }) - asyncPrimaryWriteFailure.error = writeError - asyncPrimaryWriteFailure.targetPrefix = `${dataFile()}.` + const failure = vi + .spyOn(ProfileStateSqliteAuthority.prototype, 'writeSerializedDomains') + .mockImplementation(() => { + throw writeError + }) const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) try { store.setWorkspaceSession({ @@ -397,10 +378,11 @@ describe('Store', () => { expect(store.getWorkspaceSession().terminalLayoutsByTabId).toEqual({}) await expect(store.flushPendingOrThrowAsync()).rejects.toBe(writeError) } finally { + failure.mockRestore() errors.mockRestore() } - expect(readFileSync(dataFile(), 'utf-8')).toBe(durableBeforeRemoval) + expect(readPersistedStateJson(dataFile())).toBe(durableBeforeRemoval) const stillPublished = JSON.parse(durableBeforeRemoval) as { workspaceSession: { terminalLayoutsByTabId: Record }> diff --git a/src/main/persistence-workspace-status-workflow.test.ts b/src/main/persistence-workspace-status-workflow.test.ts index 9b6d5d4c9bb..1804d04dc41 100644 --- a/src/main/persistence-workspace-status-workflow.test.ts +++ b/src/main/persistence-workspace-status-workflow.test.ts @@ -1,9 +1,16 @@ +import { + closeTestStores, + testState, + createStore, + writeDataFile, + readDataFile +} from './persistence-test-harness' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import type { PersistedState } from '../shared/persisted-state-types' -import { testState, createStore, writeDataFile, readDataFile } from './persistence-test-harness' + import { REORDERED_DEFAULT_WORKSPACE_STATUSES, REORDERED_DONE_DEFAULT_WORKSPACE_STATUSES, @@ -59,7 +66,8 @@ describe('Store', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) it('preserves persisted smart sort value', async () => { diff --git a/src/main/persistence-worktree-card-properties.test.ts b/src/main/persistence-worktree-card-properties.test.ts index 37a6fbe5e1f..e370168af4f 100644 --- a/src/main/persistence-worktree-card-properties.test.ts +++ b/src/main/persistence-worktree-card-properties.test.ts @@ -1,8 +1,8 @@ +import { closeTestStores, testState, createStore, writeDataFile } from './persistence-test-harness' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { testState, createStore, writeDataFile } from './persistence-test-harness' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -52,7 +52,8 @@ describe('Store', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) // ── worktree-card property migration ─────────────────────────────── diff --git a/src/main/persistence-worktree-deletion-fencing.test.ts b/src/main/persistence-worktree-deletion-fencing.test.ts index 62619bbd05b..40a4c50e9f4 100644 --- a/src/main/persistence-worktree-deletion-fencing.test.ts +++ b/src/main/persistence-worktree-deletion-fencing.test.ts @@ -1,9 +1,15 @@ +import { + closeTestStores, + testState, + createStore, + makeTerminalTab +} from './persistence-test-harness' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { getDefaultWorkspaceSession } from '../shared/constants' -import { testState, createStore, makeTerminalTab } from './persistence-test-harness' + import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. @@ -54,7 +60,8 @@ describe('Store', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) it('adds a missing split leaf to the durable root when a new pane spawns before layout debounce', async () => { @@ -87,7 +94,7 @@ describe('Store', () => { } }) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_2, @@ -132,7 +139,7 @@ describe('Store', () => { terminalTopologyRevisionByRepoId: { wt1: 1 } }) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_2, @@ -179,7 +186,7 @@ describe('Store', () => { store.setWorkspaceSession(stale) expect(store.getWorkspaceSession().tabsByWorktree.wt1).toEqual([]) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'fresh-tab', leafId: TEST_LEAF_2, @@ -261,7 +268,7 @@ describe('Store', () => { expect(store.getWorkspaceSession().tabsByWorktree[worktreeA]?.[0]?.id).toBe('tab-a') expect(store.getWorkspaceSession().tabsByWorktree[worktreeB]?.[0]?.id).toBe('tab-b') - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: worktreeB, tabId: 'fresh-tab', leafId: TEST_LEAF_1, @@ -281,7 +288,7 @@ describe('Store', () => { for (let index = 0; index < 25; index += 1) { const worktreeId = `repo::/worktree-${index}` store.setWorktreeMeta(worktreeId, { displayName: `Worktree ${index}` }) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId, tabId: `tab-${index}`, leafId: TEST_LEAF_1, @@ -364,7 +371,7 @@ describe('Store', () => { } }) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_1, diff --git a/src/main/persistence-worktree-lineage-and-backups.test.ts b/src/main/persistence-worktree-lineage-and-backups.test.ts index 372e8b0e33b..0c38ca2f32f 100644 --- a/src/main/persistence-worktree-lineage-and-backups.test.ts +++ b/src/main/persistence-worktree-lineage-and-backups.test.ts @@ -1,10 +1,6 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { writeFileSync, readFileSync, rmSync, mkdtempSync, mkdirSync, existsSync } from 'node:fs' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import type { Repo } from '../shared/repo-types' -import { folderWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope' import { + closeTestStores, + readPersistedStateJson, testState, createStore, dataFile, @@ -14,6 +10,16 @@ import { makeWorktreeLineage, makeWorkspaceLineage } from './persistence-test-harness' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { writeFileSync, readFileSync, rmSync, mkdtempSync, mkdirSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { createProfileStateStore } from './persistence/profile-state/profile-state-store-factory' +import { ProfileStateAuthorityBootstrapError } from './persistence/profile-state/profile-state-authority-bootstrap' +import { restoreProfileStateJsonExport } from './persistence/profile-state/legacy-json/profile-state-recovery' +import { acquireProfileStateMaintenance } from './persistence/profile-state/profile-state-access' +import type { Repo } from '../shared/repo-types' +import { folderWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -63,7 +69,8 @@ describe('Store', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) // ── getAllWorktreeMeta ───────────────────────────────────────────── @@ -331,282 +338,129 @@ describe('Store', () => { }) }) - // ── Rolling backups (issue #1158) ────────────────────────────────── - - describe('rolling backups', () => { - function backupFile(index: number): string { - return `${dataFile()}.bak.${index}` - } - - function readBackup(index: number): { repos: Repo[] } { - return JSON.parse(readFileSync(backupFile(index), 'utf-8')) - } - - function advanceMockedTime(advanceFn: () => void, ms: number): void { - vi.setSystemTime(new Date(Date.now() + ms)) - advanceFn() - } - - it('snapshots the just-written file to .bak.0 on the very first write', async () => { - const s = await createStore() - s.addRepo(makeRepo()) - s.flush() - expect(existsSync(dataFile())).toBe(true) - expect(existsSync(backupFile(0))).toBe(true) - expect(readBackup(0).repos.map((r) => r.id)).toEqual(['r1']) - }) - - it('rotates older .bak.0 to .bak.1 when the interval elapses', async () => { - vi.useFakeTimers() - try { - const first = await createStore() - first.addRepo(makeRepo({ id: 'r1' })) - first.flush() - expect(readBackup(0).repos.map((r) => r.id)).toEqual(['r1']) - - vi.setSystemTime(new Date(Date.now() + 61 * 60 * 1000)) - - const second = await createStore() - second.addRepo(makeRepo({ id: 'r2', path: '/repo2' })) - second.flush() - - const current = readDataFile() as { repos: Repo[] } - expect(current.repos.map((r) => r.id).sort()).toEqual(['r1', 'r2']) - expect( - readBackup(0) - .repos.map((r) => r.id) - .sort() - ).toEqual(['r1', 'r2']) - expect(readBackup(1).repos.map((r) => r.id)).toEqual(['r1']) - } finally { - vi.useRealTimers() - } - }) - - it('keeps at most 5 rotating backups', async () => { - vi.useFakeTimers() - try { - writeDataFile({ - schemaVersion: 1, - repos: [makeRepo({ id: 'seed' })], - worktreeMeta: {}, - settings: {}, - ui: {}, - githubCache: { pr: {}, issue: {} }, - workspaceSession: {} - }) - - for (let i = 0; i < 6; i++) { - vi.setSystemTime(new Date(Date.now() + 61 * 60 * 1000)) - const s = await createStore() - s.addRepo(makeRepo({ id: `gen-${i}`, path: `/gen-${i}` })) - s.flush() + describe('retired JSON backups', () => { + it.each(['sync', 'async'] as const)( + 'leaves legacy JSON and backups unchanged during %s SQL writes', + async (flush) => { + writeDataFile({ repos: [makeRepo({ id: 'seed' })] }) + const retained = readFileSync(dataFile()) + const backup = `${dataFile()}.bak.0` + writeFileSync(backup, retained) + const store = createStore() + store.addRepo(makeRepo({ id: 'updated', path: '/updated' })) + if (flush === 'sync') { + store.flushOrThrow() + } else { + await store.flushPendingOrThrowAsync() } - for (let i = 0; i < 5; i++) { - expect(existsSync(backupFile(i))).toBe(true) - } - expect(existsSync(backupFile(5))).toBe(false) - } finally { - vi.useRealTimers() + expect(readFileSync(dataFile())).toEqual(retained) + expect(readFileSync(backup)).toEqual(retained) + expect(existsSync(`${dataFile()}.bak.1`)).toBe(false) + const persisted = readDataFile() as { repos: Repo[] } + expect(persisted.repos.map((repo) => repo.id).sort()).toEqual(['seed', 'updated']) } + ) + + it('does not create a JSON primary or backup for a fresh SQL profile', () => { + const store = createStore() + store.addRepo(makeRepo()) + store.flushOrThrow() + expect(existsSync(dataFile())).toBe(false) + expect(existsSync(`${dataFile()}.bak.0`)).toBe(false) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture registered r1 before flushing; its exported repos are Repo records. + expect((readDataFile() as { repos: Repo[] }).repos.map((repo) => repo.id)).toEqual(['r1']) + }) + }) + + describe('legacy backup import and recovery', () => { + const recoveredStores: ReturnType['store'][] = [] + afterEach(async () => { + await Promise.all(recoveredStores.splice(0).map((store) => store.freezeWritesAsync())) }) - it('does not rotate more than once per hour', async () => { - vi.useFakeTimers() - try { - writeDataFile({ - schemaVersion: 1, - repos: [makeRepo({ id: 'seed' })], - worktreeMeta: {}, - settings: {}, - ui: {}, - githubCache: { pr: {}, issue: {} }, - workspaceSession: {} - }) - - const store = await createStore() - store.addRepo(makeRepo({ id: 'after-seed' })) - store.flush() - - const bak0After1 = readBackup(0) - expect(bak0After1.repos.map((r) => r.id).sort()).toEqual(['after-seed', 'seed']) - - advanceMockedTime( - () => { - store.addRepo(makeRepo({ id: 'within-hour', path: '/within' })) - store.flush() - }, - 5 * 60 * 1000 - ) - - const bak0After2 = readBackup(0) - expect(bak0After2.repos.map((r) => r.id).sort()).toEqual(['after-seed', 'seed']) - } finally { - vi.useRealTimers() + function recoveryOptions() { + const directory = join(testState.dir, 'profiles', 'recovery-test') + mkdirSync(directory, { recursive: true }) + return { + dataFile: join(directory, 'orca-data.json'), + databaseFile: join(directory, 'profile-state.db'), + profileId: 'recovery-test' } - }) - - it('does not rotate on the async write path within the 1-hour window', async () => { - vi.useFakeTimers() - try { - writeDataFile({ - schemaVersion: 1, - repos: [makeRepo({ id: 'seed' })], - worktreeMeta: {}, - settings: {}, - ui: {}, - githubCache: { pr: {}, issue: {} }, - workspaceSession: {} - }) - - const store = await createStore() - store.addRepo(makeRepo({ id: 'first-async' })) - vi.advanceTimersByTime(1000) - await store.waitForPendingWrite() - - const bak0AfterFirst = readBackup(0) - expect(bak0AfterFirst.repos.map((r) => r.id).sort()).toEqual(['first-async', 'seed']) - - vi.setSystemTime(new Date(Date.now() + 5 * 60 * 1000)) - store.addRepo(makeRepo({ id: 'within-hour-async', path: '/within-async' })) - vi.advanceTimersByTime(1000) - await store.waitForPendingWrite() - - const bak0AfterSecond = readBackup(0) - expect(bak0AfterSecond.repos.map((r) => r.id).sort()).toEqual(['first-async', 'seed']) - } finally { - vi.useRealTimers() - } - }) - - it('rotates on the async write path after the 1-hour window elapses', async () => { - vi.useFakeTimers() - try { - writeDataFile({ - schemaVersion: 1, - repos: [makeRepo({ id: 'seed' })], - worktreeMeta: {}, - settings: {}, - ui: {}, - githubCache: { pr: {}, issue: {} }, - workspaceSession: {} - }) - - const store = await createStore() - store.addRepo(makeRepo({ id: 'first-async' })) - vi.advanceTimersByTime(1000) - await store.waitForPendingWrite() - - expect( - readBackup(0) - .repos.map((r) => r.id) - .sort() - ).toEqual(['first-async', 'seed']) - - vi.setSystemTime(new Date(Date.now() + 61 * 60 * 1000)) - store.addRepo(makeRepo({ id: 'after-hour-async', path: '/after-async' })) - vi.advanceTimersByTime(1000) - await store.waitForPendingWrite() - - expect( - readBackup(0) - .repos.map((r) => r.id) - .sort() - ).toEqual(['after-hour-async', 'first-async', 'seed']) - expect(existsSync(backupFile(1))).toBe(true) - expect( - readBackup(1) - .repos.map((r) => r.id) - .sort() - ).toEqual(['first-async', 'seed']) - } finally { - vi.useRealTimers() - } - }) - - function writeBackup(index: number, data: unknown): void { - mkdirSync(testState.dir, { recursive: true }) - writeFileSync(backupFile(index), JSON.stringify(data, null, 2), 'utf-8') } - it('recovers from .bak.0 when the primary file is corrupt', async () => { - mkdirSync(testState.dir, { recursive: true }) - writeFileSync(dataFile(), '{{{corrupt-json', 'utf-8') - writeBackup(0, { - schemaVersion: 1, - repos: [makeRepo({ id: 'recovered' })], - worktreeMeta: {}, - settings: {}, - ui: {}, - githubCache: { pr: {}, issue: {} }, - workspaceSession: {} - }) + function importRecoveredProfile(options: ReturnType) { + const { store } = createProfileStateStore(options) + recoveredStores.push(store) + return store + } - const store = await createStore() - expect(store.getRepos().map((r) => r.id)).toEqual(['recovered']) - }) - - it('falls through to .bak.1 when both primary and .bak.0 are corrupt', async () => { - mkdirSync(testState.dir, { recursive: true }) - writeFileSync(dataFile(), '{{{corrupt-json', 'utf-8') - writeFileSync(backupFile(0), '{{also-corrupt', 'utf-8') - writeBackup(1, { - schemaVersion: 1, - repos: [makeRepo({ id: 'from-bak1' })], - worktreeMeta: {}, - settings: {}, - ui: {}, - githubCache: { pr: {}, issue: {} }, - workspaceSession: {} - }) - - const store = await createStore() - expect(store.getRepos().map((r) => r.id)).toEqual(['from-bak1']) - }) - - it('falls back to defaults only when every backup is also unusable', async () => { - mkdirSync(testState.dir, { recursive: true }) - writeFileSync(dataFile(), '{{{corrupt', 'utf-8') - for (let i = 0; i < 5; i++) { - writeFileSync(backupFile(i), `{{slot-${i}-corrupt`, 'utf-8') + function restoreSelectedBackup(options: ReturnType, index: number) { + expect(() => createProfileStateStore(options)).toThrow(ProfileStateAuthorityBootstrapError) + const maintenance = acquireProfileStateMaintenance(testState.dir) + try { + restoreProfileStateJsonExport({ + ...options, + databasePath: options.databaseFile, + exportPath: `${options.dataFile}.bak.${index}`, + maintenance + }) + } finally { + maintenance.release() } + return importRecoveredProfile(options) + } - const store = await createStore() - expect(store.getRepos()).toEqual([]) + it.each([ + { primary: 'corrupt', index: 0, repoId: 'recovered' }, + { primary: 'corrupt', index: 1, repoId: 'from-bak1' }, + { primary: 'missing', index: 0, repoId: 'rescued' } + ] as const)('imports backup $index with a $primary primary', (scenario) => { + const options = recoveryOptions() + if (scenario.primary === 'corrupt') { + writeFileSync(options.dataFile, '{{corrupt') + } + if (scenario.index === 1) { + writeFileSync(`${options.dataFile}.bak.0`, '{{also-corrupt') + } + const backup = `${options.dataFile}.bak.${scenario.index}` + const retained = JSON.stringify({ repos: [makeRepo({ id: scenario.repoId })] }) + writeFileSync(backup, retained) + const store = + scenario.primary === 'missing' + ? restoreSelectedBackup(options, scenario.index) + : importRecoveredProfile(options) + expect(store.getRepos().map((repo) => repo.id)).toEqual([scenario.repoId]) + expect(readFileSync(backup, 'utf8')).toBe(retained) + if (scenario.primary === 'corrupt') { + expect(readFileSync(options.dataFile, 'utf8')).toBe('{{corrupt') + } }) - it('uses .bak.0 even when primary file is missing entirely', async () => { - mkdirSync(testState.dir, { recursive: true }) - writeBackup(0, { - schemaVersion: 1, - repos: [makeRepo({ id: 'rescued' })], - worktreeMeta: {}, - settings: {}, - ui: {}, - githubCache: { pr: {}, issue: {} }, - workspaceSession: {} - }) - - const store = await createStore() - expect(store.getRepos().map((r) => r.id)).toEqual(['rescued']) + it('refuses defaults when the primary and every backup are unusable', () => { + const options = recoveryOptions() + writeFileSync(options.dataFile, '{{corrupt') + for (let index = 0; index < 5; index++) { + writeFileSync(`${options.dataFile}.bak.${index}`, `{{slot-${index}-corrupt`) + } + expect(() => createProfileStateStore(options)).toThrow(ProfileStateAuthorityBootstrapError) + expect(existsSync(options.databaseFile)).toBe(false) + expect(readFileSync(options.dataFile, 'utf8')).toBe('{{corrupt') }) - it('still recovers repos/worktrees from a backup with corrupt workspaceSession', async () => { - mkdirSync(testState.dir, { recursive: true }) - writeFileSync(dataFile(), '{{{corrupt', 'utf-8') - writeBackup(0, { - schemaVersion: 1, - repos: [makeRepo({ id: 'survives' })], - worktreeMeta: {}, - settings: { theme: 'dark' }, - ui: {}, - githubCache: { pr: {}, issue: {} }, - workspaceSession: { activeRepoId: 12345 } - }) - - const store = await createStore() - expect(store.getRepos().map((r) => r.id)).toEqual(['survives']) + it('recovers repos and settings from a selected backup with a malformed session', () => { + const options = recoveryOptions() + writeFileSync(options.dataFile, '{{corrupt') + writeFileSync( + `${options.dataFile}.bak.0`, + JSON.stringify({ + repos: [makeRepo({ id: 'survives' })], + settings: { theme: 'dark' }, + workspaceSession: { activeRepoId: 12345 } + }) + ) + const store = importRecoveredProfile(options) + expect(store.getRepos().map((repo) => repo.id)).toEqual(['survives']) expect(store.getSettings().theme).toBe('dark') }) }) @@ -624,7 +478,8 @@ describe('Store', () => { vi.advanceTimersByTime(1000) await store.waitForPendingWrite() - const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as { repos: Repo[] } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The preceding Store save produced the PersistedState snapshot read by this test. + const persisted = JSON.parse(readPersistedStateJson()) as { repos: Repo[] } expect(persisted.repos.map((r) => r.id).sort()).toEqual(['first', 'second']) } finally { vi.useRealTimers() diff --git a/src/main/persistence-worktree-meta-and-folder-workspaces.test.ts b/src/main/persistence-worktree-meta-and-folder-workspaces.test.ts index 85795ac0c98..632e4ebf25a 100644 --- a/src/main/persistence-worktree-meta-and-folder-workspaces.test.ts +++ b/src/main/persistence-worktree-meta-and-folder-workspaces.test.ts @@ -1,11 +1,5 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { rmSync, mkdtempSync } from 'node:fs' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import type { PersistedState } from '../shared/persisted-state-types' -import { getDefaultWorkspaceSession } from '../shared/constants' -import { folderWorkspaceKey } from '../shared/workspace-scope' import { + closeTestStores, testState, createStore, writeDataFile, @@ -13,6 +7,13 @@ import { makeRepo, makeTerminalTab } from './persistence-test-harness' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { rmSync, mkdtempSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { PersistedState } from '../shared/persisted-state-types' +import { getDefaultWorkspaceSession } from '../shared/constants' +import { folderWorkspaceKey } from '../shared/workspace-scope' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -62,7 +63,8 @@ describe('Store', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) // ── 8. setWorktreeMeta and getWorktreeMeta ───────────────────────── diff --git a/src/main/persistence-worktree-name-retirement.test.ts b/src/main/persistence-worktree-name-retirement.test.ts index 2f89775ce87..ebe4db5feec 100644 --- a/src/main/persistence-worktree-name-retirement.test.ts +++ b/src/main/persistence-worktree-name-retirement.test.ts @@ -1,3 +1,4 @@ +import { closeTestStores, createSqliteTestStore } from './persistence-test-harness' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -50,7 +51,7 @@ async function reloadStore() { // file's temp dir rather than the global fake's shared one, after resetModules. installFakeAppEnvironment({ getPath: () => testState.dir }) initDataPath() - return new Store() + return createSqliteTestStore(Store, { dataFile: join(testState.dir, 'orca-data.json') }) } async function createStore(persisted: Record = {}) { @@ -67,7 +68,8 @@ beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-worktree-name-retirement-')) }) -afterEach(() => { +afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { force: true, recursive: true }) }) diff --git a/src/main/persistence-worktree-visibility.test.ts b/src/main/persistence-worktree-visibility.test.ts index 55f81617c03..ab593396b72 100644 --- a/src/main/persistence-worktree-visibility.test.ts +++ b/src/main/persistence-worktree-visibility.test.ts @@ -1,3 +1,10 @@ +import { + closeTestStores, + testState, + createStore, + writeDataFile, + makeRepo +} from './persistence-test-harness' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' @@ -5,7 +12,6 @@ import { tmpdir } from 'node:os' import type { GlobalSettings } from '../shared/global-settings-types' import type { ExternalWorktreeVisibility } from '../shared/repo-types' import { getDefaultPersistedState } from '../shared/constants' -import { testState, createStore, writeDataFile, makeRepo } from './persistence-test-harness' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -55,7 +61,8 @@ describe('Store', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) it('updateRepo stamps legacy external-worktree visibility before changing old repos', async () => { diff --git a/src/main/persistence/applying-settings/feature-interaction-recording.ts b/src/main/persistence/applying-settings/feature-interaction-recording.ts index 2a29bc64f66..662e79bd40f 100644 --- a/src/main/persistence/applying-settings/feature-interaction-recording.ts +++ b/src/main/persistence/applying-settings/feature-interaction-recording.ts @@ -12,7 +12,7 @@ import { getCohortAtEmit } from '../../telemetry/cohort-classifier' export type FeatureInteractionOperations = { state: PersistedState - scheduleSave: () => void + scheduleSave: (domains?: readonly string[]) => void notifyUIChanged: () => void getUI: () => PersistedState['ui'] } @@ -49,7 +49,7 @@ export function recordFeatureInteraction( operations.state.featureInteractionTelemetryBuckets = shouldEmit ? { ...telemetryBuckets, [id]: nextBucket } : telemetryBuckets - operations.scheduleSave() + operations.scheduleSave(['ui', 'featureInteractionTelemetryBuckets']) // Why: live UI only consumes the seen transition; count-only telemetry must not re-hydrate the renderer. if (!existing) { operations.notifyUIChanged() diff --git a/src/main/persistence/applying-settings/onboarding-normalization.ts b/src/main/persistence/applying-settings/onboarding-normalization.ts index a778463566e..7c8b17badd1 100644 --- a/src/main/persistence/applying-settings/onboarding-normalization.ts +++ b/src/main/persistence/applying-settings/onboarding-normalization.ts @@ -5,12 +5,12 @@ import type { } from '../../../shared/onboarding-state-types' import type { NotificationSettings } from '../../../shared/notification-settings-types' import type { PersistedState } from '../../../shared/persisted-state-types' +import { getDefaultNotificationSettings } from '../../../shared/notification-settings-defaults' import { - getDefaultNotificationSettings, getDefaultOnboardingState, ONBOARDING_FINAL_STEP, ONBOARDING_FLOW_VERSION -} from '../../../shared/constants' +} from '../../../shared/onboarding-defaults' export function normalizeNotificationSettings(value: unknown): NotificationSettings { const defaults = getDefaultNotificationSettings() diff --git a/src/main/persistence/applying-settings/ui-state-read.ts b/src/main/persistence/applying-settings/ui-state-read.ts index a640f971f89..ffe122efce0 100644 --- a/src/main/persistence/applying-settings/ui-state-read.ts +++ b/src/main/persistence/applying-settings/ui-state-read.ts @@ -1,3 +1,4 @@ +import { normalizeExplorerDisplayRootByWorktree } from '../../../shared/file-explorer-display-root' import type { PersistedState } from '../../../shared/persisted-state-types' import { getDefaultUIState, @@ -32,6 +33,7 @@ import { } from './ui-selection-normalization' import { stripMainOwnedTelemetryMarkerFromUI } from './ui-interaction-merge' +/** Returns normalized UI state with the authoritative active view and without the main-owned telemetry marker. */ export function getPersistedUI( state: PersistedState, activeView: PersistedState['ui']['activeView'] @@ -66,6 +68,9 @@ export function getPersistedUI( workspaceHostOrder: normalizeExecutionHostOrder(state.ui?.workspaceHostOrder), manualRepoOrder: normalizeManualRepoOrder(state.ui?.manualRepoOrder), browserDefaultZoomLevel: normalizeBrowserPageZoomLevel(state.ui?.browserDefaultZoomLevel), + explorerDisplayRootByWorktree: normalizeExplorerDisplayRootByWorktree( + state.ui?.explorerDisplayRootByWorktree + ), showDotfilesByWorktree: normalizeShowDotfilesByWorktree(state.ui?.showDotfilesByWorktree), featureTipsSeenIds: normalizeFeatureTipIds(state.ui?.featureTipsSeenIds), contextualToursSeenIds: normalizeContextualTourIds(state.ui?.contextualToursSeenIds), diff --git a/src/main/persistence/applying-settings/ui-state-update.ts b/src/main/persistence/applying-settings/ui-state-update.ts index db06a2738fd..a89c189f499 100644 --- a/src/main/persistence/applying-settings/ui-state-update.ts +++ b/src/main/persistence/applying-settings/ui-state-update.ts @@ -1,3 +1,4 @@ +import { normalizeExplorerDisplayRootByWorktree } from '../../../shared/file-explorer-display-root' import type { PersistedState } from '../../../shared/persisted-state-types' import { getDefaultUIState, @@ -53,11 +54,14 @@ export type UIUpdateOperations = { notifyUIChanged: () => void } +/** Applies a sanitized partial update while keeping active-view persistence separate from durable UI fields. */ export function updatePersistedUI( operations: UIUpdateOperations, updates: Partial ): void { - if ('browserKagiSessionLink' in updates && !updates.browserKagiSessionLink) { + const clearsProtectedSecret = + 'browserKagiSessionLink' in updates && !updates.browserKagiSessionLink + if (clearsProtectedSecret) { operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.browserKagiSessionLink) } const sanitizedUpdates = stripMainOwnedTelemetryMarkerFromUI(updates) @@ -167,6 +171,12 @@ export function updatePersistedUI( browserDefaultZoomLevel: normalizeBrowserPageZoomLevel( sanitizedUpdates.browserDefaultZoomLevel ?? operations.state.ui?.browserDefaultZoomLevel ), + explorerDisplayRootByWorktree: + sanitizedUpdates.explorerDisplayRootByWorktree !== undefined + ? normalizeExplorerDisplayRootByWorktree(sanitizedUpdates.explorerDisplayRootByWorktree) + : normalizeExplorerDisplayRootByWorktree( + operations.state.ui?.explorerDisplayRootByWorktree + ), showDotfilesByWorktree: sanitizedUpdates.showDotfilesByWorktree !== undefined ? normalizeShowDotfilesByWorktree(sanitizedUpdates.showDotfilesByWorktree) @@ -192,7 +202,8 @@ export function updatePersistedUI( ) : normalizeFeatureInteractions(operations.state.ui?.featureInteractions) } - if (persistedUIValuesEqual(previousUI, nextUI)) { + // A sealed secret looks empty in memory; an explicit clear must still reach disk. + if (!clearsProtectedSecret && persistedUIValuesEqual(previousUI, nextUI)) { if (activeViewChanged) { operations.notifyUIChanged() } diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts b/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts index 58b0cd0473a..a23bd421dd5 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts @@ -61,7 +61,7 @@ export function normalizeSshRemotePtyLease(value: unknown): SshRemotePtyLease | return null } const now = Date.now() - const pendingKill = normalizeSshPendingPtyKill(raw.pendingKill) + const pendingKill = normalizeSshPendingPtyKill(raw.pendingKill, raw.ptyId) return { targetId: raw.targetId, ptyId: raw.ptyId, diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts index 3b259989906..07770fe70b9 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts @@ -1,3 +1,4 @@ +import type { StoreRuntimeState } from '../loading-store/store-runtime-state' import type { SshPtyConsumerRecovery } from '../../../shared/ssh-types' import type { PersistedState } from '../../../shared/persisted-state-types' import type { ProtectedSecretPersistence } from '../../protected-secret-persistence' @@ -7,16 +8,7 @@ import { normalizeSshPtyConsumerRecovery } from './ssh-normalization' export type SshPtyConsumerRecoveryOperations = { state: PersistedState protectedSecrets: Pick - flushDurableStateOrThrowAsync: () => Promise -} - -async function flushSshPtyConsumerRecovery( - operations: SshPtyConsumerRecoveryOperations -): Promise { - // Why: ownership must be durable before relay setup continues, but this runs on the live - // establish/reconnect path — a sync flush would park the main thread on a stalled profile mount. - // Why not caught here: the failure must reach the awaiting caller. - await operations.flushDurableStateOrThrowAsync() + runDurableMutation: StoreRuntimeState['runDurableMutation'] } export function getSshPtyConsumerRecovery( @@ -46,24 +38,37 @@ export async function upsertSshPtyConsumerRecovery( if (!normalized) { throw new Error('Invalid SSH PTY consumer recovery record') } - const recoveries = operations.state.sshPtyConsumerRecoveries ?? [] - operations.state.sshPtyConsumerRecoveries = [ - ...recoveries.filter((candidate) => candidate.targetId !== normalized.targetId), - normalized - ] - await flushSshPtyConsumerRecovery(operations) + await operations.runDurableMutation(() => { + const recoveries = operations.state.sshPtyConsumerRecoveries ?? [] + operations.state.sshPtyConsumerRecoveries = [ + ...recoveries.filter((candidate) => candidate.targetId !== normalized.targetId), + normalized + ] + return { value: undefined } + }) } export async function removeSshPtyConsumerRecovery( operations: SshPtyConsumerRecoveryOperations, - targetId: string + targetId: string, + expectedClientInstanceId?: string ): Promise { - const recoveries = operations.state.sshPtyConsumerRecoveries ?? [] - const next = recoveries.filter((record) => record.targetId !== targetId) - if (next.length === recoveries.length) { - return + await operations.runDurableMutation(() => { + const recoveries = operations.state.sshPtyConsumerRecoveries ?? [] + const current = recoveries.find((record) => record.targetId === targetId) + if ( + expectedClientInstanceId !== undefined && + current && + current.clientInstanceId !== expectedClientInstanceId + ) { + return { value: undefined, persist: false } + } + operations.state.sshPtyConsumerRecoveries = recoveries.filter( + (record) => record.targetId !== targetId + ) + return { value: undefined } + }) + if (!operations.state.sshPtyConsumerRecoveries?.some((record) => record.targetId === targetId)) { + operations.protectedSecrets.removeRetainedBlob(sshPtyOwnerLeaseSecretSlot(targetId)) } - operations.state.sshPtyConsumerRecoveries = next - operations.protectedSecrets.removeRetainedBlob(sshPtyOwnerLeaseSecretSlot(targetId)) - await flushSshPtyConsumerRecovery(operations) } diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-kill-intent-operations.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-kill-intent-operations.ts index 52d1c51e9f2..a499ba10253 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-pty-kill-intent-operations.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-kill-intent-operations.ts @@ -126,7 +126,7 @@ export function recordSshRemotePtyKillIntent( const prior = existing.pendingKill // Same incarnation means a repeated close; a recycled relay id starts a new intent lifetime. existing.pendingKill = - prior?.incarnationId === intent.incarnationId + prior && prior.incarnationId === intent.incarnationId ? { ...intent, requestedAt: Math.min(prior.requestedAt, now), diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts index f06e5b0ece7..b2e3cef030b 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts @@ -1,3 +1,4 @@ +import type { StoreRuntimeState } from '../loading-store/store-runtime-state' import type { PersistedState } from '../../../shared/persisted-state-types' import type { SshRemotePtyLease } from '../../../shared/ssh-types' import { isTerminalLeafId } from '../../../shared/stable-pane-id' @@ -12,7 +13,7 @@ export type SshPtyLeaseOperations = { clearBindingsForTarget: (targetId: string) => void clearBindingsForLeases: (targetId: string, leases: SshRemotePtyLease[]) => boolean flush: () => void - flushDurableStateOrThrowAsync: () => Promise + runDurableMutation: StoreRuntimeState['runDurableMutation'] } /** @@ -93,7 +94,8 @@ function updateSshRemotePtyLeaseStates( operations: SshPtyLeaseOperations, targetId: string, state: SshRemotePtyLease['state'], - ptyIds?: ReadonlySet + ptyIds?: ReadonlySet, + admittedLeases?: ReadonlySet ): boolean { const now = Date.now() let changed = false @@ -101,7 +103,11 @@ function updateSshRemotePtyLeaseStates( const leasesToClear: SshRemotePtyLease[] = [] operations.state.sshRemotePtyLeases ??= [] for (const lease of operations.state.sshRemotePtyLeases) { - if (lease.targetId !== targetId || (ptyIds && !ptyIds.has(lease.ptyId))) { + if ( + lease.targetId !== targetId || + (ptyIds && !ptyIds.has(lease.ptyId)) || + (admittedLeases && !admittedLeases.has(lease)) + ) { continue } if (state === 'attached' && lease.state === 'terminated') { @@ -179,9 +185,12 @@ export async function markSshRemotePtyLeasesAsync( targetId: string, state: SshRemotePtyLease['state'] ): Promise { - if (updateSshRemotePtyLeaseStates(operations, targetId, state)) { - await operations.flushDurableStateOrThrowAsync() - } + // A newer connection can replace a lease while this operation waits for the writer. + const admittedLeases = new Set(getSshRemotePtyLeases(operations.state, targetId)) + await operations.runDurableMutation(() => { + updateSshRemotePtyLeaseStates(operations, targetId, state, undefined, admittedLeases) + return { value: undefined } + }) } export async function markSshRemotePtyLeasesAttachedAsync( @@ -190,9 +199,12 @@ export async function markSshRemotePtyLeasesAttachedAsync( ptyIds: readonly string[] ): Promise { const relayPtyIds = new Set(ptyIds.map((ptyId) => operations.toStoredPtyId(targetId, ptyId))) - if (updateSshRemotePtyLeaseStates(operations, targetId, 'attached', relayPtyIds)) { - await operations.flushDurableStateOrThrowAsync() - } + // A newer connection can replace a lease while this operation waits for the writer. + const admittedLeases = new Set(getSshRemotePtyLeases(operations.state, targetId)) + await operations.runDurableMutation(() => { + updateSshRemotePtyLeaseStates(operations, targetId, 'attached', relayPtyIds, admittedLeases) + return { value: undefined } + }) } /** `relayIdRecycled` is the pending-stop replay's evidence that the host now lists this id under a diff --git a/src/main/persistence/loading-store/automation-persistence.ts b/src/main/persistence/loading-store/automation-persistence.ts index 2bed89ff09b..64abd8ed2a9 100644 --- a/src/main/persistence/loading-store/automation-persistence.ts +++ b/src/main/persistence/loading-store/automation-persistence.ts @@ -53,7 +53,11 @@ import type { ProfilePreferences } from './profile-preferences' type AutomationPersistenceRuntime = Pick< StoreRuntimeState, - 'automationListProjectionCache' | 'state' | 'storageAuthority' + | 'automationListProjectionCache' + | 'dirtyProfileStateDomains' + | 'pendingAutomationRunsAfter' + | 'state' + | 'storageAuthority' > const automationPersistenceContext = Symbol('AutomationPersistence') @@ -195,7 +199,10 @@ export class AutomationPersistence { advanceAutomationNextRun(id: string, now = Date.now()): Automation { return advanceAutomationNextRunOperation( this[automationPersistenceContext].runtime.state, - () => this[automationPersistenceContext].flushBarriers.flush(), + () => { + markAutomationDefinitionDomain(this) + this[automationPersistenceContext].flushBarriers.flush() + }, id, now ) @@ -212,16 +219,31 @@ export function getAutomationDefinitionOperations( return { state: owner[automationPersistenceContext].runtime.state, storageAuthority: owner[automationPersistenceContext].runtime.storageAuthority, - flush: () => owner[automationPersistenceContext].flushBarriers.flush(), + flush: () => { + markAutomationDefinitionDomain(owner) + owner[automationPersistenceContext].flushBarriers.flush() + }, recordCreated: () => - owner[automationPersistenceContext].preferences.recordFeatureInteraction('automation-created') + owner[automationPersistenceContext].preferences.recordFeatureInteraction( + 'automation-created' + ), + recordAutomationRunsMutation: (runs) => { + owner[automationPersistenceContext].runtime.pendingAutomationRunsAfter = runs + owner[automationPersistenceContext].runtime.dirtyProfileStateDomains?.add('automationRuns') + } } } export function getAutomationRunOperations(owner: AutomationPersistence): AutomationRunOperations { return { state: owner[automationPersistenceContext].runtime.state, - flush: () => owner[automationPersistenceContext].flushBarriers.flush(), + flush: () => { + markAutomationDomains(owner) + owner[automationPersistenceContext].flushBarriers.flush() + }, + recordAutomationRunsMutation: (runs) => { + owner[automationPersistenceContext].runtime.pendingAutomationRunsAfter = runs + }, recordManualRun: () => owner[automationPersistenceContext].preferences.recordFeatureInteraction('automation-run'), getWorkspaceDisplayName: (workspaceId) => @@ -242,6 +264,18 @@ export function getAutomationRunWorkspaceDisplayName( ) } +function markAutomationDomains(owner: AutomationPersistence): void { + const dirtyDomains = owner[automationPersistenceContext].runtime.dirtyProfileStateDomains + if (dirtyDomains !== null) { + dirtyDomains.add('automations') + dirtyDomains.add('automationRuns') + } +} + +function markAutomationDefinitionDomain(owner: AutomationPersistence): void { + owner[automationPersistenceContext].runtime.dirtyProfileStateDomains?.add('automations') +} + export function installAutomationPersistenceContext( target: AutomationPersistence, source: AutomationPersistence diff --git a/src/main/persistence/loading-store/backup-recovery-rotation.ts b/src/main/persistence/loading-store/backup-recovery-rotation.ts deleted file mode 100644 index 7cf0a7a8078..00000000000 --- a/src/main/persistence/loading-store/backup-recovery-rotation.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { - copyFileSync, - existsSync, - mkdirSync, - readFileSync, - renameSync, - statSync, - unlinkSync, - writeFileSync -} from 'node:fs' -import { access, copyFile, rename, rm, stat } from 'node:fs/promises' -import { dirname } from 'node:path' - -const BACKUP_COUNT = 5 -const BACKUP_MIN_INTERVAL_MS = 60 * 60 * 1000 - -function backupPath(dataFile: string, index: number): string { - return `${dataFile}.bak.${index}` -} - -/** existsSync's non-blocking twin: existsSync is an access(F_OK) probe, so access() is the exact analogue. */ -async function exists(path: string): Promise { - return access(path).then( - () => true, - () => false - ) -} - -export function hasStateBackup(dataFile: string): boolean { - for (let index = 0; index < BACKUP_COUNT; index += 1) { - if (existsSync(backupPath(dataFile, index))) { - return true - } - } - return false -} - -import type { StoreRuntimeState } from './store-runtime-state' - -type BackupRecoveryRotationOperationsRuntime = Pick - -export class BackupRecoveryRotationOperations { - constructor(private readonly runtime: BackupRecoveryRotationOperationsRuntime) {} - - shouldRotateBackups(now: number, dataFile: string): boolean { - try { - const mtime = statSync(backupPath(dataFile, 0)).mtimeMs - return now - mtime >= BACKUP_MIN_INTERVAL_MS - } catch { - return true - } - } - - async shouldRotateBackupsAsync(dataFile: string): Promise { - try { - const mtime = (await stat(backupPath(dataFile, 0))).mtimeMs - return Date.now() - mtime >= BACKUP_MIN_INTERVAL_MS - } catch { - return true - } - } - - async rotateBackupsAsync(dataFile: string): Promise { - if (this.runtime.backupRotationInFlight) { - return - } - this.runtime.backupRotationInFlight = true - try { - if (!(await this.shouldRotateBackupsAsync(dataFile))) { - return - } - if (!(await exists(dataFile))) { - return - } - await rm(backupPath(dataFile, BACKUP_COUNT - 1)).catch((err: unknown) => { - if (err && (err as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error('[persistence] Failed to remove oldest backup:', err) - } - }) - for (let i = BACKUP_COUNT - 2; i >= 0; i--) { - const src = backupPath(dataFile, i) - const dst = backupPath(dataFile, i + 1) - // Why probe instead of rename-then-swallow-ENOENT: a degraded mount rejects a rename of an - // absent slot with ESTALE/EIO, which would log once per empty slot on every debounced save. - if (await exists(src)) { - await rename(src, dst).catch((err) => { - console.error('[persistence] Failed to rotate backup', src, '->', dst, err) - }) - } - } - await copyFile(dataFile, backupPath(dataFile, 0)).catch((err) => { - console.error('[persistence] Failed to snapshot current file to .bak.0:', err) - }) - } finally { - this.runtime.backupRotationInFlight = false - } - } - - rotateBackupsSync(dataFile: string): void { - if (!existsSync(dataFile)) { - return - } - try { - unlinkSync(backupPath(dataFile, BACKUP_COUNT - 1)) - } catch (err) { - if (err && (err as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error('[persistence] Failed to remove oldest backup:', err) - } - } - for (let i = BACKUP_COUNT - 2; i >= 0; i--) { - const src = backupPath(dataFile, i) - const dst = backupPath(dataFile, i + 1) - if (existsSync(src)) { - try { - renameSync(src, dst) - } catch (err) { - console.error('[persistence] Failed to rotate backup', src, '->', dst, err) - } - } - } - try { - copyFileSync(dataFile, backupPath(dataFile, 0)) - } catch (err) { - console.error('[persistence] Failed to snapshot current file to .bak.0:', err) - } - } - - restoreFromBackup(dataFile: string): boolean { - for (let i = 0; i < BACKUP_COUNT; i++) { - const path = backupPath(dataFile, i) - if (!existsSync(path)) { - continue - } - try { - const raw = readFileSync(path, 'utf-8') - JSON.parse(raw) - mkdirSync(dirname(dataFile), { recursive: true }) - writeFileSync(dataFile, raw, 'utf-8') - console.warn(`[persistence] Recovered state from backup slot ${i}: ${path}`) - return true - } catch (err) { - console.error(`[persistence] Backup slot ${i} unusable, trying next:`, err) - } - } - return false - } -} diff --git a/src/main/persistence/loading-store/loaded-state-parsing.ts b/src/main/persistence/loading-store/loaded-state-parsing.ts index d2a31419a03..b93dc251500 100644 --- a/src/main/persistence/loading-store/loaded-state-parsing.ts +++ b/src/main/persistence/loading-store/loaded-state-parsing.ts @@ -1,4 +1,3 @@ -import { readFileSync, existsSync } from 'node:fs' import { homedir } from 'node:os' import { normalizeProxyUrl } from '../../../shared/network-proxy' import { normalizeKagiSessionLink } from '../../../shared/browser-url' @@ -9,10 +8,7 @@ import { pruneLocalTerminalScrollbackBuffers } from '../../../shared/workspace-s import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-session-browser-history' import { clearMissingProjectGroupMemberships } from '../../../shared/project-groups' import { migrateWorkspaceSessionTerminalScrollbackSnapshots } from '../../terminal-scrollback-snapshots' -import { - isStartupDiagnosticsEnabled, - logStartupDiagnostic -} from '../../startup/startup-diagnostics' +import { logStartupMilestone } from '../../startup/startup-diagnostics' import { PROTECTED_SECRET_SLOT, sshPtyOwnerLeaseSecretSlot @@ -38,28 +34,11 @@ import { projectHostSetupCompatibilityStateEqual } from '../tracking-repos/project-host-compatibility' import { backfillFolderScopeConnectionIds } from '../restoring-sessions/folder-scope-migration' -import { hasStateBackup } from './backup-recovery-rotation' import { prepareLoadedTerminalSettings } from './prepare-loaded-terminal-settings' import { prepareLoadedProfileSettings } from './prepare-loaded-profile-settings' import { normalizeLoadedProfileState } from './normalize-loaded-profile-state' -type PersistenceStartupDetails = Record | (() => Record) - -function logPersistenceStartupMilestone( - event: string, - details: PersistenceStartupDetails = {} -): void { - if (!isStartupDiagnosticsEnabled()) { - return - } - // Why: snapshot `t` before resolving lazy details — otherwise an expensive details closure is billed to the milestone it measures. - const t = Math.round(performance.now()) - const resolvedDetails = typeof details === 'function' ? details() : details - logStartupDiagnostic(event, { t, ...resolvedDetails }) -} - import type { StoreRuntimeState } from './store-runtime-state' -import type { BackupRecoveryRotationOperations } from './backup-recovery-rotation' import type { LoadedCohortMigrationOperations } from './loaded-cohort-migrations' type LoadedStateParsingOperationsRuntime = Pick< @@ -75,31 +54,57 @@ type LoadedStateParsingOperationsRuntime = Pick< export class LoadedStateParsingOperations { constructor( private readonly runtime: LoadedStateParsingOperationsRuntime, - private readonly backups: BackupRecoveryRotationOperations, private readonly cohorts: LoadedCohortMigrationOperations ) {} - load(allowBackupRecovery = true): PersistedState { + /** + * Load the legacy storage representation supplied by a migration/importer. + * + * This deliberately uses the same decrypt, normalization, migration, and + * sidecar handling as a file load. An invalid imported document must fail + * closed instead of falling back to an unrelated on-disk backup. + */ + loadSerialized(raw: string): PersistedState { + return this.loadInternal(raw) + } + + /** Load only from an injected authority; never consult the legacy JSON path. */ + loadFromAuthority(raw: string | undefined): PersistedState { + return this.loadInternal(raw) + } + + loadParsedFromAuthority(parsed: Record | undefined): PersistedState { + return this.loadInternal(undefined, parsed) + } + + private loadInternal(serialized?: string, parsedInput?: Record): PersistedState { // Capture "has run Orca before?" for telemetry cohort; the telemetry field is new, so field inference misclassifies old users as fresh. - const dataFile = this.runtime.dataFile - const fileExistedOnLoad = existsSync(dataFile) - logPersistenceStartupMilestone('persistence-load-start', { + const fileExistedOnLoad = serialized !== undefined || parsedInput !== undefined + logStartupMilestone('persistence-load-start', { fileExists: fileExistedOnLoad }) let result: PersistedState | null = null + let parsed: PersistedState | undefined try { if (fileExistedOnLoad) { const readStartedAt = performance.now() - const raw = readFileSync(dataFile, 'utf-8') - logPersistenceStartupMilestone('persistence-read-done', { - bytes: Buffer.byteLength(raw), - durationMs: Math.round(performance.now() - readStartedAt) - }) - logPersistenceStartupMilestone('persistence-json-parse-start') - const parsed = JSON.parse(raw) as PersistedState - logPersistenceStartupMilestone('persistence-json-parse-done') - + const raw = parsedInput === undefined ? serialized : undefined + if (raw !== undefined) { + logStartupMilestone('persistence-read-done', { + bytes: Buffer.byteLength(raw), + durationMs: Math.round(performance.now() - readStartedAt) + }) + logStartupMilestone('persistence-json-parse-start') + parsed = JSON.parse(raw) + logStartupMilestone('persistence-json-parse-done') + } else { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Legacy partial records enter the existing domain normalizers through this loader type. + parsed = parsedInput as PersistedState + } + if (parsed === undefined) { + throw new Error('Profile state startup snapshot is missing') + } // Why: secrets are stored encrypted via safeStorage; decrypt at the load boundary so the app sees plaintext. if (parsed.settings?.opencodeSessionCookie) { parsed.settings.opencodeSessionCookie = this.runtime.protectedSecrets.decrypt( @@ -188,18 +193,8 @@ export class LoadedStateParsingOperations { }) } } catch (err) { - console.error('[persistence] Failed to load primary state, trying backups:', err) - } - - // Corrupt-file and no-file paths converge here; a corrupted install counts as existing, so it sees the opt-in banner. - if (result === null && allowBackupRecovery) { - const hasBackup = hasStateBackup(dataFile) - if (fileExistedOnLoad || hasBackup) { - if (this.backups.restoreFromBackup(dataFile)) { - return this.load(false) - } - console.error('[persistence] No usable state file or backup found, using defaults') - } + console.error('[persistence] Failed to load imported profile state:', err) + throw new Error('Failed to load imported profile state', { cause: err }) } if (result === null) { @@ -216,7 +211,6 @@ export class LoadedStateParsingOperations { if (migratedScrollback.changed) { this.runtime.loadNeedsSave = true } - const repos = clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []) const projectHostSetupCompatibility = mergeProjectHostSetupCompatibilityState(result, repos) if (!projectHostSetupCompatibilityStateEqual(result, projectHostSetupCompatibility)) { @@ -293,7 +287,7 @@ export class LoadedStateParsingOperations { migrated.githubCache = readGithubCacheSnapshot(this.runtime.dataFile) ?? migrated.githubCache } - logPersistenceStartupMilestone('persistence-load-done', () => ({ + logStartupMilestone('persistence-load-done', () => ({ repos: migrated.repos.length, workspaceSessionBytes: Buffer.byteLength(JSON.stringify(migrated.workspaceSession)) })) diff --git a/src/main/persistence/loading-store/metadata-lineage-operations.ts b/src/main/persistence/loading-store/metadata-lineage-operations.ts index 2532ff3b2d3..9800b541373 100644 --- a/src/main/persistence/loading-store/metadata-lineage-operations.ts +++ b/src/main/persistence/loading-store/metadata-lineage-operations.ts @@ -27,7 +27,8 @@ import { getWorktreeMetaForHost as getWorktreeMetaForHostOperation, migrateWorktreeMetadataLocator, removeWorktreeMetadataForHost, - setWorktreeMetaForHost as setWorktreeMetaForHostOperation + setWorktreeMetaForHost as setWorktreeMetaForHostOperation, + WORKTREE_METADATA_DOMAINS } from './worktree-identity-metadata' import { mergeWorktreeMetaForWrite } from './worktree-meta-write-normalization' import { @@ -122,7 +123,7 @@ export class MetadataLineageOperations { } const updated = mergeWorktreeMetaForWrite(stored, meta) state.worktreeMeta[worktreeId] = updated - scheduleSave(this[metadataLineageOperationsContext].scheduling) + scheduleSave(this[metadataLineageOperationsContext].scheduling, ['worktreeMeta']) return updated } @@ -269,7 +270,11 @@ export class MetadataLineageOperations { mover ) if (legacyChanged || canonicalChanged) { - scheduleSave(this[metadataLineageOperationsContext].scheduling) + // Legacy identity moves also re-key sessions, lineage, mobile selections, and UI state. + scheduleSave( + this[metadataLineageOperationsContext].scheduling, + legacyChanged ? undefined : WORKTREE_METADATA_DOMAINS + ) } } diff --git a/src/main/persistence/loading-store/normalize-loaded-explorer-root-migration.test.ts b/src/main/persistence/loading-store/normalize-loaded-explorer-root-migration.test.ts new file mode 100644 index 00000000000..3ec0020ad15 --- /dev/null +++ b/src/main/persistence/loading-store/normalize-loaded-explorer-root-migration.test.ts @@ -0,0 +1,45 @@ +import { homedir } from 'node:os' +import { expect, it, vi } from 'vitest' +import { getDefaultPersistedState } from '../../../shared/constants' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { normalizeLoadedUiState } from './normalize-loaded-ui-state' + +it.each([undefined, false, null, 1, 'true', {}, true])( + 'persists the root migration unless the raw marker is boolean true (%j)', + (marker) => { + const defaults = getDefaultPersistedState(homedir()) + const onboarding = defaults.onboarding! + const normalizedUI = normalizeLoadedUiState( + defaults, + defaults, + onboarding, + false, + false, + vi.fn() + ) + const parsed: PersistedState = JSON.parse( + JSON.stringify({ + ...defaults, + ui: { ...normalizedUI, _explorerDisplayRootMigrated: marker }, + worktreeMeta: { existing: { sparseDirectories: ['src'] } } + }) + ) + const markNeedsSave = vi.fn() + const ui = normalizeLoadedUiState(parsed, defaults, onboarding, false, false, markNeedsSave) + expect(ui._explorerDisplayRootMigrated).toBe(true) + expect(ui.explorerDisplayRootByWorktree).toEqual(marker === true ? {} : { existing: '/' }) + expect(markNeedsSave).toHaveBeenCalledTimes(marker === true ? 0 : 1) + + markNeedsSave.mockClear() + const reloaded = normalizeLoadedUiState( + { ...parsed, ui }, + defaults, + onboarding, + false, + false, + markNeedsSave + ) + expect(reloaded.explorerDisplayRootByWorktree).toEqual(ui.explorerDisplayRootByWorktree) + expect(markNeedsSave).not.toHaveBeenCalled() + } +) diff --git a/src/main/persistence/loading-store/normalize-loaded-ui-state.ts b/src/main/persistence/loading-store/normalize-loaded-ui-state.ts index d26f4e68430..4b208a6fa72 100644 --- a/src/main/persistence/loading-store/normalize-loaded-ui-state.ts +++ b/src/main/persistence/loading-store/normalize-loaded-ui-state.ts @@ -1,3 +1,4 @@ +import { migrateExplorerDisplayRoots } from '../../../shared/file-explorer-display-root' import { getWorktreeCardModeProperties, isDefaultedCompactWorktreeCardProperties, @@ -20,6 +21,7 @@ import { import type { PersistedState } from '../../../shared/persisted-state-types' import type { OnboardingState } from '../../../shared/onboarding-state-types' +/** Normalizes legacy UI payloads and marks one-time migrations for saving without replacing explicit user choices. */ export function normalizeLoadedUiState( parsed: PersistedState, defaults: PersistedState, @@ -28,6 +30,14 @@ export function normalizeLoadedUiState( osc52ClipboardNoticePending: boolean, markNeedsSave: () => void ): PersistedState['ui'] { + const explorerDisplayRootByWorktree = migrateExplorerDisplayRoots( + parsed.ui?.explorerDisplayRootByWorktree, + parsed.ui?._explorerDisplayRootMigrated === true, + parsed.worktreeMeta ?? {} + ) + if (parsed.ui?._explorerDisplayRootMigrated !== true) { + markNeedsSave() + } const rawSort = parsed.ui?.sortBy const sort = normalizeSortBy(rawSort) const migrate = !parsed.ui?._sortBySmartMigrated && rawSort === 'recent' @@ -189,6 +199,8 @@ export function normalizeLoadedUiState( // window exists, and it must survive a crash before the user ever sees the notice. osc52ClipboardDefaultOnNoticePending: osc52ClipboardNoticePending, sortBy: migrate ? ('smart' as const) : sort, + _explorerDisplayRootMigrated: true, + explorerDisplayRootByWorktree, showDotfilesByWorktree: normalizeShowDotfilesByWorktree(parsed.ui?.showDotfilesByWorktree), workspaceStatuses, _workspaceStatusesDefaultOrderMigrated: true, diff --git a/src/main/persistence/loading-store/persisted-state-redundancy.test.ts b/src/main/persistence/loading-store/persisted-state-redundancy.test.ts index 9a30b26da38..8b9414f4fb8 100644 --- a/src/main/persistence/loading-store/persisted-state-redundancy.test.ts +++ b/src/main/persistence/loading-store/persisted-state-redundancy.test.ts @@ -1,3 +1,8 @@ +import { + closeTestStores, + createSqliteTestStore, + readPersistedStateJson +} from '../../persistence-test-harness' /** * The store file re-serializes in full on a 1s debounce and re-parses in full at launch, so every * byte it carries is paid for on both. Two kinds of byte were provably redundant on a 4.2 MB real @@ -8,7 +13,7 @@ * rows, 200 browser history entries) and pin the only property that makes the omission safe: a file * written by the OLD serializer and a file written by the NEW one load to the same in-memory state. */ -import { mkdtempSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' +import { mkdtempSync, realpathSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -48,15 +53,16 @@ const LINKED_ROW_STRIDE = 40 const RECENTLY = Date.now() const stores: InstanceType[] = [] -afterEach(() => { +afterEach(async () => { for (const store of stores.splice(0)) { store.freezeWrites() } + await closeTestStores() vi.restoreAllMocks() }) function openStore(dataFile: string): InstanceType { - const store = new Store({ dataFile }) + const store = createSqliteTestStore(Store, { dataFile }) stores.push(store) return store } @@ -191,7 +197,7 @@ describe('persisted-state redundancy', () => { } loaded.flush() - const rewritten = readFileSync(dataFile, 'utf-8') + const rewritten = readPersistedStateJson(dataFile) // Only rows that actually hold a value still carry a slot: linkedPR + isPinned, 1 row in 40. expect(defaultedSlotOccurrences(rewritten)).toBe( @@ -220,7 +226,7 @@ describe('persisted-state redundancy', () => { // A quiet app does not rewrite the file with new content on the next flush. reloaded.flush() - expect(readFileSync(dataFile, 'utf-8')).toBe(rewritten) + expect(readPersistedStateJson(dataFile)).toBe(rewritten) }) it('loads an old-serializer file and a new-serializer file to the same state', () => { @@ -231,7 +237,7 @@ describe('persisted-state redundancy', () => { fromLegacy.flush() const compactFile = tempDataFile() - writeFileSync(compactFile, readFileSync(legacyFile)) + writeFileSync(compactFile, readPersistedStateJson(legacyFile)) const fromCompact = openStore(compactFile) expect(fromCompact.getAllWorktreeMeta()).toEqual(fromLegacy.getAllWorktreeMeta()) diff --git a/src/main/persistence/loading-store/primary-state-write-context.ts b/src/main/persistence/loading-store/primary-state-write-context.ts new file mode 100644 index 00000000000..bd7fbc737cb --- /dev/null +++ b/src/main/persistence/loading-store/primary-state-write-context.ts @@ -0,0 +1,15 @@ +import type { PrimaryStateWriteOperationsRuntime } from './primary-state-write-runtime' +import type { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' + +export type PrimaryStateWriteOperationsContext = { + runtime: PrimaryStateWriteOperationsRuntime + serialization: StateSerializationSecretHandlingOperations + queuedSnapshot?: { + completion: Promise + capture: { + skipIfClean: boolean + fullCheckpoint: boolean + pendingSnapshotFileWork: Promise | null + } + } +} diff --git a/src/main/persistence/loading-store/primary-state-write-runtime.ts b/src/main/persistence/loading-store/primary-state-write-runtime.ts new file mode 100644 index 00000000000..b538831a995 --- /dev/null +++ b/src/main/persistence/loading-store/primary-state-write-runtime.ts @@ -0,0 +1,66 @@ +import type { StoreRuntimeState } from './store-runtime-state' + +export type PrimaryStateWriteOperationsRuntime = Pick< + StoreRuntimeState, + | 'activeViewPreference' + | 'dataFile' + | 'dirtyProfileStateDomains' + | 'durableMutationPhase' + | 'fatalMutationError' + | 'flushOrThrow' + | 'runDurableMutation' + | 'firstPendingSaveAt' + | 'lastDurableWriteGeneration' + | 'lastWrittenStateHash' + | 'pendingSnapshotFileWork' + | 'pendingAutomationRunsAfter' + | 'pendingWrite' + | 'profileMaintenancePending' + | 'profileStateAuthority' + | 'protectedSecrets' + | 'quitFlushStarted' + | 'state' + | 'writeGeneration' + | 'writeTimer' + | 'writesFrozen' +> + +export function markPrimaryStateWriteDurable( + runtime: Pick, + generation: number +): void { + runtime.lastDurableWriteGeneration = Math.max(runtime.lastDurableWriteGeneration, generation) +} + +export function canReuseDurableProfileState( + runtime: Pick, + stateHash: string +): boolean { + if (stateHash !== runtime.lastWrittenStateHash) { + return false + } + const authority = runtime.profileStateAuthority + if (authority?.asynchronous) { + throw new Error('Live profile persistence requires an awaited revision check') + } + if (authority && !authority.assertCurrentRevision) { + return false + } + authority?.assertCurrentRevision?.() + return true +} + +export async function stopAfterFailedPrimaryStateMutation( + runtime: PrimaryStateWriteOperationsRuntime, + error: unknown +): Promise { + // A throwing callback never returns its rollback; do not persist a partial edit. + runtime.writesFrozen = true + runtime.quitFlushStarted = true + runtime.fatalMutationError = error instanceof Error ? error : new Error(String(error)) + if (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) + runtime.writeTimer = null + } + await runtime.profileStateAuthority?.close?.() +} diff --git a/src/main/persistence/loading-store/primary-state-write-sync.ts b/src/main/persistence/loading-store/primary-state-write-sync.ts new file mode 100644 index 00000000000..c1e8480063d --- /dev/null +++ b/src/main/persistence/loading-store/primary-state-write-sync.ts @@ -0,0 +1,80 @@ +import { writeSelectiveProfileState } from './profile-state-selective-write' +import type { PrimaryStateWriteOperationsContext } from './primary-state-write-context' +import { + canReuseDurableProfileState, + markPrimaryStateWriteDurable +} from './primary-state-write-runtime' + +export function writeToDiskSync( + context: PrimaryStateWriteOperationsContext, + opts: { expectedGeneration?: number } = {} +): boolean { + const { runtime, serialization } = context + if (runtime.fatalMutationError) { + throw runtime.fatalMutationError + } + if (runtime.writesFrozen) { + return false + } + const authority = runtime.profileStateAuthority + if (!authority) { + throw new Error('Writable Store construction requires a SQLite profile-state authority') + } + if (authority.asynchronous) { + throw new Error('Live profile persistence requires an awaited flush') + } + const isCurrent = + opts.expectedGeneration === undefined + ? undefined + : () => runtime.writeGeneration === opts.expectedGeneration + const selective = writeSelectiveProfileState( + authority, + serialization, + runtime.dirtyProfileStateDomains, + runtime.pendingAutomationRunsAfter, + isCurrent + ) + if (selective.handled) { + if (selective.aborted) { + return false + } + if (selective.consumedAutomationRuns) { + runtime.pendingAutomationRunsAfter = undefined + } + runtime.lastWrittenStateHash = null + runtime.protectedSecrets.commitRetentionUpdates(selective.protectedSecretUpdates) + markPrimaryStateWriteDurable(runtime, opts.expectedGeneration ?? runtime.writeGeneration) + authority.scheduleBackup?.() + return true + } + const built = serialization.buildStateToSave( + authority.writeCompleteSerializedDomains !== undefined + ) + const { stateHash, protectedSecretUpdates } = built + if (isCurrent && !isCurrent()) { + return false + } + // The authority fences the revision even when the complete state is unchanged. + if (canReuseDurableProfileState(runtime, stateHash)) { + runtime.dirtyProfileStateDomains = new Set() + runtime.pendingAutomationRunsAfter = undefined + markPrimaryStateWriteDurable(runtime, opts.expectedGeneration ?? runtime.writeGeneration) + return true + } + if (built.domains && authority.writeCompleteSerializedDomains) { + authority.writeCompleteSerializedDomains(built.domains) + } else { + authority.writeSerializedState(built.payload) + } + runtime.dirtyProfileStateDomains = new Set() + runtime.pendingAutomationRunsAfter = undefined + if (!isCurrent || isCurrent()) { + runtime.lastWrittenStateHash = stateHash + runtime.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates) + } else { + runtime.lastWrittenStateHash = null + } + markPrimaryStateWriteDurable(runtime, opts.expectedGeneration ?? runtime.writeGeneration) + authority.scheduleBackup?.() + return true +} diff --git a/src/main/persistence/loading-store/primary-state-write-worker.ts b/src/main/persistence/loading-store/primary-state-write-worker.ts new file mode 100644 index 00000000000..71862bc3b44 --- /dev/null +++ b/src/main/persistence/loading-store/primary-state-write-worker.ts @@ -0,0 +1,106 @@ +import type { AsyncProfileStateAuthority } from './profile-state-authority' +import type { PrimaryStateWriteOperationsContext } from './primary-state-write-context' +import { markPrimaryStateWriteDurable } from './primary-state-write-runtime' +import { prepareSelectiveProfileStateWrite } from './profile-state-selective-write' + +/** The queued operation owns its captured intent; later edits belong to the next write. */ +export async function writeProfileStateInWorker( + { runtime, serialization }: PrimaryStateWriteOperationsContext, + authority: AsyncProfileStateAuthority +): Promise { + authority.assertWritable() + const generation = runtime.writeGeneration + const dirtyDomains = runtime.dirtyProfileStateDomains + const automationRuns = runtime.pendingAutomationRunsAfter + runtime.dirtyProfileStateDomains = new Set() + runtime.pendingAutomationRunsAfter = undefined + + const restoreIntent = (): void => { + if (dirtyDomains === null) { + runtime.dirtyProfileStateDomains = null + } else if (runtime.dirtyProfileStateDomains !== null) { + for (const domain of dirtyDomains) { + runtime.dirtyProfileStateDomains.add(domain) + } + } + runtime.pendingAutomationRunsAfter ??= automationRuns + } + + try { + const prepared = beginWrite( + authority, + serialization, + dirtyDomains, + automationRuns, + runtime.lastWrittenStateHash, + () => runtime.writeGeneration === generation + ) + if (!prepared) { + restoreIntent() + return false + } + await prepared.completion + runtime.protectedSecrets.commitRetentionUpdates(prepared.protectedSecretUpdates) + runtime.lastWrittenStateHash = + runtime.writeGeneration === generation ? prepared.stateHash : null + markPrimaryStateWriteDurable(runtime, generation) + if (runtime.writeGeneration === generation) { + if (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) + runtime.writeTimer = null + } + runtime.firstPendingSaveAt = null + } + } catch (error) { + restoreIntent() + throw error + } + try { + authority.scheduleBackup?.() + } catch (error) { + console.error('[persistence] Failed to schedule profile state backup:', error) + } + return true +} + +/** Release copied payloads before the acknowledgement; retain only commit bookkeeping. */ +function beginWrite( + authority: AsyncProfileStateAuthority, + serialization: PrimaryStateWriteOperationsContext['serialization'], + dirtyDomains: ReadonlySet | null, + automationRuns: PrimaryStateWriteOperationsContext['runtime']['pendingAutomationRunsAfter'], + lastWrittenStateHash: string | null, + isCurrent: () => boolean +) { + const selective = prepareSelectiveProfileStateWrite( + authority, + serialization, + dirtyDomains, + automationRuns + ) + if (selective) { + if (!isCurrent()) { + return undefined + } + const completion = + selective.automationRuns !== undefined + ? authority.writeSerializedAutomationRuns(selective.replacements, selective.automationRuns) + : authority.writeSerializedDomains(selective.replacements) + return { completion, stateHash: null, protectedSecretUpdates: selective.protectedSecretUpdates } + } + const complete = serialization.buildStateToSave(true) + if (!isCurrent()) { + return undefined + } + const unchanged = complete.stateHash === lastWrittenStateHash + const completion = unchanged + ? authority.assertCurrentRevision() + : complete.domains + ? authority.writeCompleteSerializedDomains(complete.domains) + : authority.writeSerializedState(complete.payload) + return { + completion, + stateHash: complete.stateHash, + protectedSecretUpdates: unchanged ? [] : complete.protectedSecretUpdates + } +} diff --git a/src/main/persistence/loading-store/primary-state-writes.ts b/src/main/persistence/loading-store/primary-state-writes.ts index 3820723fffb..014cda30958 100644 --- a/src/main/persistence/loading-store/primary-state-writes.ts +++ b/src/main/persistence/loading-store/primary-state-writes.ts @@ -1,282 +1,246 @@ -import { mkdirSync, existsSync, unlinkSync } from 'node:fs' -import { mkdir, open, rm } from 'node:fs/promises' -import { durableWriteTempPath, renameDurable, writeFileDurableSync } from '../../durable-file-write' -import { dirname } from 'node:path' +import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' import { parseCodexResetCreditAttemptLedger, type CodexResetCreditAttemptLedger } from '../../../shared/codex-reset-credit-attempt-ledger' - -import type { StoreRuntimeState } from './store-runtime-state' +import { + stopAfterFailedPrimaryStateMutation, + type PrimaryStateWriteOperationsRuntime +} from './primary-state-write-runtime' import type { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' -import type { BackupRecoveryRotationOperations } from './backup-recovery-rotation' - -type PrimaryStateWriteOperationsRuntime = Pick< - StoreRuntimeState, - | 'activeViewPreference' - | 'backupRotationInFlight' - | 'dataFile' - | 'flushOrThrow' - | 'firstPendingSaveAt' - | 'inFlightAsyncTmpFile' - | 'lastDurableWriteGeneration' - | 'lastWrittenStateHash' - | 'pendingSnapshotFileWork' - | 'pendingWrite' - | 'protectedSecrets' - | 'quitFlushStarted' - | 'staleTempCleanup' - | 'state' - | 'writeGeneration' - | 'writeTimer' - | 'writesFrozen' -> +import type { PrimaryStateWriteOperationsContext } from './primary-state-write-context' +import { writeToDiskSync } from './primary-state-write-sync' +import { writeProfileStateInWorker } from './primary-state-write-worker' +import type { DurableProfileStateMutation } from './store-runtime-state' +import { profileStateWriterFailureOutcome } from '../profile-state/profile-state-writer-errors' const primaryStateWriteOperationsContext = Symbol('PrimaryStateWriteOperations') -type PrimaryStateWriteOperationsContext = { - runtime: PrimaryStateWriteOperationsRuntime - serialization: StateSerializationSecretHandlingOperations - backups: BackupRecoveryRotationOperations -} - export class PrimaryStateWriteOperations { readonly [primaryStateWriteOperationsContext]: PrimaryStateWriteOperationsContext constructor( runtime: PrimaryStateWriteOperationsRuntime, - serialization: StateSerializationSecretHandlingOperations, - backups: BackupRecoveryRotationOperations + serialization: StateSerializationSecretHandlingOperations ) { - this[primaryStateWriteOperationsContext] = { runtime, serialization, backups } + this[primaryStateWriteOperationsContext] = { runtime, serialization } } flushOrThrow(): void { - if (this[primaryStateWriteOperationsContext].runtime.quitFlushStarted) { + const context = this[primaryStateWriteOperationsContext] + const { runtime } = context + if (runtime.quitFlushStarted || runtime.profileMaintenancePending) { throw new Error('Cannot synchronously flush after final persistence has started') } - if (this[primaryStateWriteOperationsContext].runtime.writeTimer) { - clearTimeout(this[primaryStateWriteOperationsContext].runtime.writeTimer) - this[primaryStateWriteOperationsContext].runtime.writeTimer = null + if (runtime.profileStateAuthority?.asynchronous) { + throw new Error('Live profile persistence requires an awaited flush') } - this[primaryStateWriteOperationsContext].runtime.firstPendingSaveAt = null - const asyncWriteWasInFlight = - this[primaryStateWriteOperationsContext].runtime.pendingWrite !== null - // Why: bump writeGeneration so an in-flight async write skips its rename and can't overwrite this sync write. - this[primaryStateWriteOperationsContext].runtime.writeGeneration++ - if (this[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile) { - try { - unlinkSync(this[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile) - this[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile = null - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - void enqueueWrite(this).catch(() => {}) - throw error - } - } + if (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) + runtime.writeTimer = null } - // Why: later async flushes must remain serialized behind the invalidated writer. - writeToDiskSync(this, { - force: asyncWriteWasInFlight, - skipBackupRotation: this[primaryStateWriteOperationsContext].runtime.backupRotationInFlight - }) + runtime.firstPendingSaveAt = null + runtime.writeGeneration++ + writeToDiskSync(context) } flushActiveViewPreferenceOrThrow(): void { + if (this[primaryStateWriteOperationsContext].runtime.profileMaintenancePending) { + throw new Error('Cannot flush active-view persistence during profile maintenance') + } this[primaryStateWriteOperationsContext].runtime.activeViewPreference.flushOrThrow() } + /** Expected refusals return persist: false; thrown callbacks stop saving to protect partial state. */ + runDurableMutation(mutate: () => DurableProfileStateMutation): Promise { + const { runtime } = this[primaryStateWriteOperationsContext] + if (runtime.writesFrozen || runtime.quitFlushStarted || runtime.profileMaintenancePending) { + return Promise.reject(new Error('Cannot mutate finalized profile persistence')) + } + return enqueuePrimaryStateOperation(this, async () => { + if (runtime.profileStateAuthority?.asynchronous) { + runtime.profileStateAuthority.assertWritable() + } + let mutation: DurableProfileStateMutation + try { + mutation = this.runAdmittedMutationCallback('mutate', mutate) + } catch (error) { + await stopAfterFailedPrimaryStateMutation(runtime, error) + throw error + } + if ( + mutation.persist === false || + (mutation.persist === 'if-dirty' && + runtime.lastDurableWriteGeneration >= runtime.writeGeneration) + ) { + return mutation.value + } + runtime.writeGeneration++ + const requiredGeneration = runtime.writeGeneration + try { + const captured = runtime.profileStateAuthority?.asynchronous + ? await writeToDiskAsync(this) + : writeToDiskSync(this[primaryStateWriteOperationsContext], { + expectedGeneration: requiredGeneration + }) + if (!captured || runtime.lastDurableWriteGeneration < requiredGeneration) { + throw new Error('Profile mutation changed while preparing its durable snapshot') + } + } catch (error) { + if (profileStateWriterFailureOutcome(error) !== 'indeterminate') { + if (mutation.rollback) { + try { + this.runAdmittedMutationCallback('rollback', mutation.rollback) + } catch (rollbackError) { + await stopAfterFailedPrimaryStateMutation(runtime, rollbackError) + throw rollbackError + } + } + } + throw error + } + return mutation.value + }) + } + + private runAdmittedMutationCallback(phase: 'mutate' | 'rollback', callback: () => T): T { + const { runtime } = this[primaryStateWriteOperationsContext] + // Finalization drains admitted mutations; the disk wait must not admit new snapshots. + runtime.durableMutationPhase = phase + try { + return callback() + } finally { + runtime.durableMutationPhase = null + } + } + getCodexResetCreditAttemptLedger(): CodexResetCreditAttemptLedger { return parseCodexResetCreditAttemptLedger( this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger ) } - replaceCodexResetCreditAttemptLedgerAndFlush(ledger: CodexResetCreditAttemptLedger): void { - if (this[primaryStateWriteOperationsContext].runtime.writesFrozen) { - throw new Error('Cannot persist Codex reset-credit attempts while writes are frozen') - } + replaceCodexResetCreditAttemptLedgerAndFlush( + ledger: CodexResetCreditAttemptLedger + ): Promise { + const { runtime } = this[primaryStateWriteOperationsContext] const next = parseCodexResetCreditAttemptLedger(ledger) - const previous = this[primaryStateWriteOperationsContext].runtime.state - .codexResetCreditAttemptLedger - ? structuredClone( - this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger - ) - : undefined - this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger = next - try { - this[primaryStateWriteOperationsContext].runtime.flushOrThrow() - } catch (error) { - // Why: callers use a successful return as the durability barrier before - // handing a scarce-credit mutation to the provider. - this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger = - previous - throw error - } + return this.runDurableMutation(() => { + const previous = runtime.state.codexResetCreditAttemptLedger + runtime.state.codexResetCreditAttemptLedger = next + runtime.dirtyProfileStateDomains?.add('codexResetCreditAttemptLedger') + return { + value: undefined, + rollback: () => { + if (runtime.state.codexResetCreditAttemptLedger === next) { + runtime.state.codexResetCreditAttemptLedger = previous + } + } + } + }) } } -export function enqueueWrite(owner: PrimaryStateWriteOperations): Promise { +export function enqueueWrite( + owner: PrimaryStateWriteOperations, + options: { fullCheckpoint?: boolean; skipIfClean?: boolean; signal?: AbortSignal } = {} +): Promise { + const context = owner[primaryStateWriteOperationsContext] + const { runtime } = context + const batchable = + runtime.profileStateAuthority?.asynchronous && !options.fullCheckpoint && !options.signal + const queued = context.queuedSnapshot + if (batchable && queued) { + queued.capture.skipIfClean &&= options.skipIfClean === true + // Merged explicit flushes must retain the full capture that covered untracked getter edits. + queued.capture.fullCheckpoint ||= !queued.capture.skipIfClean + queued.capture.pendingSnapshotFileWork = runtime.pendingSnapshotFileWork + return queued.completion + } + const capture = { + skipIfClean: options.skipIfClean === true, + fullCheckpoint: options.fullCheckpoint === true, + pendingSnapshotFileWork: runtime.pendingSnapshotFileWork + } + const completion = enqueuePrimaryStateOperation(owner, async () => { + // Later flushes must capture edits made after this batch starts, even without a new generation. + if (context.queuedSnapshot?.capture === capture) { + context.queuedSnapshot = undefined + } + if (batchable) { + await capture.pendingSnapshotFileWork + } + const { signal } = options + if (signal?.aborted) { + throw new Error('Persistence flush aborted') + } + if ( + capture.skipIfClean && + runtime.dirtyProfileStateDomains?.size === 0 && + runtime.pendingAutomationRunsAfter === undefined && + runtime.lastDurableWriteGeneration >= runtime.writeGeneration + ) { + return + } + // A queued predecessor can clear dirty domains before this checkpoint runs. + if (capture.fullCheckpoint) { + runtime.dirtyProfileStateDomains = null + } + await writeToDiskAsync(owner) + }) + if (batchable) { + context.queuedSnapshot = { completion, capture } + } + // A caller may stop waiting; the admitted write must retain its acknowledgement and ordering. + return waitForPromiseWithSignal(completion, options.signal) +} + +export function enqueuePrimaryStateOperation( + owner: PrimaryStateWriteOperations, + operation: () => Promise +): Promise { + const context = owner[primaryStateWriteOperationsContext] + const { runtime } = context + // A durable mutation, export, or independent checkpoint separates adjacent snapshot batches. + context.queuedSnapshot = undefined const previousWrite = Promise.all([ - owner[primaryStateWriteOperationsContext].runtime.pendingWrite ?? - owner[primaryStateWriteOperationsContext].runtime.staleTempCleanup, - owner[primaryStateWriteOperationsContext].runtime.pendingSnapshotFileWork ?? Promise.resolve() + runtime.pendingWrite, + runtime.pendingSnapshotFileWork ?? Promise.resolve() ]).then(() => {}) - const write = previousWrite.then(() => writeToDiskAsync(owner)) + const write = previousWrite.then(operation).finally(() => { + if (context.queuedSnapshot?.completion === write) { + context.queuedSnapshot = undefined + } + }) const trackedWrite = write + .then(() => {}) .catch((err) => { console.error('[persistence] Failed to write state:', err) }) .finally(() => { - if (owner[primaryStateWriteOperationsContext].runtime.pendingWrite === trackedWrite) { - owner[primaryStateWriteOperationsContext].runtime.pendingWrite = null + if (runtime.pendingWrite === trackedWrite) { + runtime.pendingWrite = null } }) - owner[primaryStateWriteOperationsContext].runtime.pendingWrite = trackedWrite + runtime.pendingWrite = trackedWrite return write } -export async function writeToDiskAsync(owner: PrimaryStateWriteOperations): Promise { - if (owner[primaryStateWriteOperationsContext].runtime.writesFrozen) { - return +export async function writeToDiskAsync(owner: PrimaryStateWriteOperations): Promise { + const { runtime } = owner[primaryStateWriteOperationsContext] + if (runtime.fatalMutationError) { + throw runtime.fatalMutationError } - const gen = owner[primaryStateWriteOperationsContext].runtime.writeGeneration - const { payload, stateHash, protectedSecretUpdates } = - owner[primaryStateWriteOperationsContext].serialization.buildStateToSave() - // Why: don't rewrite a byte-identical multi-MB file when state nets out to already-persisted. - if (stateHash === owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash) { - owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration = Math.max( - owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration, - gen + if (runtime.writesFrozen) { + return false + } + const gen = runtime.writeGeneration + if (runtime.profileStateAuthority?.asynchronous) { + return writeProfileStateInWorker( + owner[primaryStateWriteOperationsContext], + runtime.profileStateAuthority ) - return - } - const dataFile = owner[primaryStateWriteOperationsContext].runtime.dataFile - const dir = dirname(dataFile) - await mkdir(dir, { recursive: true }).catch(() => {}) - const tmpFile = durableWriteTempPath(dataFile) - - // Why: on any write/rename failure, remove the tmp file so it doesn't leave a multi-MB orphan. - let renamed = false - try { - // Why: fsync before rename, then fsync the directory; see writeFileDurable. - const handle = await open(tmpFile, 'w') - try { - // Already UTF-8 bytes: passing the string here would re-encode the whole state on the main thread. - await handle.writeFile(payload) - await handle.sync() - } finally { - await handle.close() - } - // Why: if flush() bumped writeGeneration mid-write, it already wrote fresher state; don't overwrite it. - if (owner[primaryStateWriteOperationsContext].runtime.writeGeneration !== gen) { - return - } - owner[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile = tmpFile - try { - await renameDurable(tmpFile, dataFile) - renamed = true - } catch (error) { - if ( - (error as NodeJS.ErrnoException).code !== 'ENOENT' || - owner[primaryStateWriteOperationsContext].runtime.writeGeneration === gen - ) { - throw error - } - } finally { - if (owner[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile === tmpFile) { - owner[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile = null - } - } - // Why re-check gen: a mutation or sync flush during rename makes the installed hash ambiguous; invalidate the no-op guard. - if (renamed && owner[primaryStateWriteOperationsContext].runtime.writeGeneration === gen) { - owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash = stateHash - owner[primaryStateWriteOperationsContext].runtime.protectedSecrets.commitRetentionUpdates( - protectedSecretUpdates - ) - } else if (renamed) { - owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash = null - } - if (renamed) { - owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration = Math.max( - owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration, - gen - ) - } - } finally { - if (!renamed) { - await rm(tmpFile).catch(() => {}) - } - } - if (!renamed) { - return - } - // Why (#1158): rotate only after the primary rename while this write still owns its generation. - if (owner[primaryStateWriteOperationsContext].runtime.writeGeneration !== gen) { - return - } - await owner[primaryStateWriteOperationsContext].backups.rotateBackupsAsync(dataFile) -} - -export function writeToDiskSync( - owner: PrimaryStateWriteOperations, - opts: { force?: boolean; skipBackupRotation?: boolean } = {} -): void { - if (owner[primaryStateWriteOperationsContext].runtime.writesFrozen) { - return - } - const { payload, stateHash, protectedSecretUpdates } = - owner[primaryStateWriteOperationsContext].serialization.buildStateToSave() - // Why: matching hash means the file already holds this state; force overrides when an async rename may be racing past the gen check. - if ( - !opts.force && - stateHash === owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash - ) { - // Why: flushOrThrow already bumped writeGeneration; the file holds this state, so record it - // durable or persistPtyBinding's fast lane stays parked one generation behind forever. - owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration = Math.max( - owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration, - owner[primaryStateWriteOperationsContext].runtime.writeGeneration - ) - return - } - const dataFile = owner[primaryStateWriteOperationsContext].runtime.dataFile - const dir = dirname(dataFile) - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } - const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` - - // Why: on any write/rename failure, remove the tmp file so shutdown crashes don't leak orphans. - let renamed = false - try { - // Why: fsync the temp file and the directory; a bare rename can survive as stale or empty - // content after power loss, losing projects/tabs back to the newest usable .bak slot. - writeFileDurableSync(tmpFile, dataFile, payload) - renamed = true - owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash = stateHash - owner[primaryStateWriteOperationsContext].runtime.protectedSecrets.commitRetentionUpdates( - protectedSecretUpdates - ) - owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration = Math.max( - owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration, - owner[primaryStateWriteOperationsContext].runtime.writeGeneration - ) - } finally { - if (!renamed) { - try { - unlinkSync(tmpFile) - } catch { - // Best-effort cleanup; the write already failed, swallow secondary error. - } - } - } - const now = Date.now() - if ( - !opts.skipBackupRotation && - owner[primaryStateWriteOperationsContext].backups.shouldRotateBackups(now, dataFile) - ) { - owner[primaryStateWriteOperationsContext].backups.rotateBackupsSync(dataFile) } + return writeToDiskSync(owner[primaryStateWriteOperationsContext], { expectedGeneration: gen }) } export function installPrimaryStateWriteOperationsContext( diff --git a/src/main/persistence/loading-store/profile-preferences.ts b/src/main/persistence/loading-store/profile-preferences.ts index 8e910ed235d..15315518961 100644 --- a/src/main/persistence/loading-store/profile-preferences.ts +++ b/src/main/persistence/loading-store/profile-preferences.ts @@ -1,7 +1,7 @@ import type { GlobalSettings } from '../../../shared/global-settings-types' import type { OnboardingChecklistState } from '../../../shared/onboarding-state-types' import type { PersistedState } from '../../../shared/persisted-state-types' -import { getDefaultOnboardingState } from '../../../shared/constants' +import { getDefaultOnboardingState } from '../../../shared/onboarding-defaults' import type { FeatureInteractionId } from '../../../shared/feature-interactions' import { updateSettings as updateSettingsOperation, @@ -159,7 +159,7 @@ export function getSettingsMutationOperations( bumpLocalWorktreeScanGeneration, removeRetainedBlob: (slot) => owner[profilePreferencesContext].runtime.protectedSecrets.removeRetainedBlob(slot), - scheduleSave: () => scheduleSave(owner[profilePreferencesContext].scheduling), + scheduleSave: () => scheduleSave(owner[profilePreferencesContext].scheduling, ['settings']), notifySettingsChanged: (updates, originWebContentsId) => notifySettingsChanged(owner, updates, originWebContentsId) } @@ -183,7 +183,7 @@ export function getFeatureInteractionOperations( ): FeatureInteractionOperations { return { state: owner[profilePreferencesContext].runtime.state, - scheduleSave: () => scheduleSave(owner[profilePreferencesContext].scheduling), + scheduleSave: (domains) => scheduleSave(owner[profilePreferencesContext].scheduling, domains), notifyUIChanged: () => notifyUIChanged(owner), getUI: () => owner.getUI() } diff --git a/src/main/persistence/loading-store/profile-state-authority-writes.ts b/src/main/persistence/loading-store/profile-state-authority-writes.ts new file mode 100644 index 00000000000..0628dbb7607 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-authority-writes.ts @@ -0,0 +1,84 @@ +import { createHash } from 'node:crypto' +import { types } from 'node:util' +import { + applySecretSentinelSubstitutions, + type SecretSentinelSubstitution +} from './secret-sentinel-substitution' +import type { ProfileStateDomainReplacement } from './profile-state-authority' + +export function serializeSelectiveProfileStateDomains( + state: Record, + dirtyDomains: ReadonlySet +): ProfileStateDomainReplacement[] { + const payloads = new Map() + // Capture keys up front, but read values after preceding getters and toJSON hooks. + for (const domain of Object.keys(state)) { + const fragment = serializeSelectiveProfileStateDomainFragment(domain, state[domain]) + if (fragment !== '{}') { + payloads.set(domain, extractProfileStateDomainPayload(domain, fragment)) + } + } + return [...dirtyDomains].map((domain) => { + return { domain, payload: payloads.get(domain) ?? null } + }) +} + +export function serializeCompleteProfileStateDomains( + state: Record, + substitutions: readonly SecretSentinelSubstitution[], + degradedPrefix: string +): { payload: Buffer; stateHash: string; domains: readonly ProfileStateDomainReplacement[] } { + const domains: ProfileStateDomainReplacement[] = [] + const hash = createHash('sha1').update(degradedPrefix) + for (const [domain, value] of Object.entries(state)) { + const fragment = serializeProfileStateDomainFragment(domain, value) + if (fragment === '{}') { + continue + } + const serialized = applySecretSentinelSubstitutions(fragment, substitutions, '', 'text') + hash.update(serialized.stateHash) + domains.push({ + domain, + payload: extractProfileStateDomainPayload(domain, serialized.payload) + }) + } + return { + domains, + stateHash: hash.digest('hex'), + get payload() { + return Buffer.from( + `{${domains.map(({ domain, payload }) => `${JSON.stringify(domain)}:${payload}`).join(',')}}`, + 'utf8' + ) + } + } +} + +function serializeSelectiveProfileStateDomainFragment(domain: string, value: unknown): string { + let needsNormalization = false + const fragment = serializeProfileStateDomainFragment(domain, value, (_key, entry) => { + if (entry !== null && typeof entry === 'object') { + needsNormalization ||= + types.isProxy(entry) || + ('isRawJSON' in JSON && + typeof JSON.isRawJSON === 'function' && + JSON.isRawJSON(entry) === true) + } + return entry + }) + // Raw JSON and proxy key order still need the old UTF-8/parse/stringify normalization. + return needsNormalization ? JSON.stringify(JSON.parse(fragment.toWellFormed())) : fragment +} + +function serializeProfileStateDomainFragment( + domain: string, + value: unknown, + replacer?: (key: string, value: unknown) => unknown +): string { + // The wrapper preserves the original property name passed to a value's toJSON. + return JSON.stringify({ [domain]: value }, replacer) +} + +function extractProfileStateDomainPayload(domain: string, fragment: string): string { + return fragment.slice(JSON.stringify(domain).length + 2, -1) +} diff --git a/src/main/persistence/loading-store/profile-state-authority.ts b/src/main/persistence/loading-store/profile-state-authority.ts new file mode 100644 index 00000000000..0ced5ba3248 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-authority.ts @@ -0,0 +1,127 @@ +import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types' +import type { AutomationRun } from '../../../shared/automations-types' +import type { ProfileStateDatabaseQuarantine } from '../profile-state/profile-state-database-quarantine' + +export type ProfileStateMaintenance = { + /** Re-admit the unchanged profile before permitting any new persistence work. */ + resume(): Promise +} + +/** Keep offline/compatibility persistence loadable without eagerly importing SQLite. */ +export type ProfileStateAuthority = { + readonly asynchronous?: false + /** Return storage-form JSON, or undefined when this authority has no state yet. */ + readSerializedState(): string | undefined + + /** Fence a hash-identical checkpoint without rereading or rewriting its payload. */ + assertCurrentRevision?: () => void + + /** + * Optionally commit only the explicitly dirty top-level domains. Authorities + * without this capability retain the complete-document fallback below. + */ + writeSerializedDomains?: (replacements: readonly ProfileStateDomainReplacement[]) => void + + /** + * Commit automation definition replacements and the changed run projection + * in one profile-revision transaction without serializing unrelated domains. + */ + writeSerializedAutomationRuns?: ( + replacements: readonly ProfileStateDomainReplacement[], + runs: readonly AutomationRun[] + ) => void + + /** + * Durably replace the complete storage-form document. Implementations may + * reject a stale read instead of allowing last-writer-wins replacement. + */ + writeSerializedState(payload: Buffer): void + + /** Replace the whole profile; omitted domains and null payloads are deleted. */ + writeCompleteSerializedDomains?: (replacements: readonly ProfileStateDomainReplacement[]) => void + + /** Schedule bounded recovery protection after a successful primary commit. */ + scheduleBackup?: () => void + + /** Drain owned backup handles before shutdown or profile file mutations. */ + drainBackups?: (cancel?: boolean) => Promise + + /** Optionally publish a durable JSON export for rollback or a compatibility runtime. */ + writeJsonExport?: (targetPath: string) => number + + /** Publish canonical JSON for an older build and advance its SQLite acceptance marker. */ + writeJsonCompatibilityExport?: (targetPath: string) => number | undefined + + /** Refresh compatibility JSON after the final flush with asynchronous JSON file writes. */ + writeJsonCompatibilityExportAsync?: (targetPath: string) => Promise + + /** Optionally preserve the database family before an explicit recovery decision. */ + quarantineDatabase?: (quarantineRoot?: string, reason?: string) => ProfileStateDatabaseQuarantine + + /** Release any process-local database handle before a profile is switched or removed. */ + close?: () => void + + /** Only a clean maintenance close may provide an explicit resume capability. */ + pauseForMaintenance?: () => Promise +} + +export type AsyncProfileStateAuthority = Omit< + ProfileStateAuthority, + | 'asynchronous' + | 'assertCurrentRevision' + | 'writeSerializedDomains' + | 'writeSerializedAutomationRuns' + | 'writeSerializedState' + | 'writeCompleteSerializedDomains' + | 'writeJsonExport' + | 'writeJsonCompatibilityExport' + | 'quarantineDatabase' + | 'close' +> & { + readonly asynchronous: true + assertWritable(): void + abort(): Promise + assertCurrentRevision(): Promise + writeSerializedDomains(replacements: readonly ProfileStateDomainReplacement[]): Promise + writeSerializedAutomationRuns( + replacements: readonly ProfileStateDomainReplacement[], + runs: readonly AutomationRun[] + ): Promise + writeSerializedState(payload: Buffer): Promise + writeCompleteSerializedDomains( + replacements: readonly ProfileStateDomainReplacement[] + ): Promise + writeJsonExport(targetPath: string): Promise + writeLatestJsonExport(dataFile: string): Promise + writeJsonCompatibilityExport(targetPath: string): Promise + quarantineDatabase( + quarantineRoot?: string, + reason?: string + ): Promise + close(): Promise +} + +export type ProfileStatePersistenceAuthority = ProfileStateAuthority | AsyncProfileStateAuthority + +export type ProfileStateDomainReplacement = { + domain: string + /** Storage-form JSON for the domain, or null to remove its row. */ + payload: string | null +} + +export type ProfileStateStartupPaneAlias = Omit + +/** A startup read paired with the authority that observed its revision. */ +export type ProfileStateAuthorityInitialState< + Authority extends ProfileStatePersistenceAuthority = ProfileStateAuthority +> = { + readonly authority: Authority + readonly unboundPaneAliases?: readonly ProfileStateStartupPaneAlias[] +} & ( + | { readonly serializedState: string | undefined; readonly takeParsedState?: never } + | { + readonly serializedState?: never + /** Transfer this storage-form object once, before the loader can decrypt or mutate it. */ + readonly takeParsedState: () => Record | undefined + } +) diff --git a/src/main/persistence/loading-store/profile-state-caller-cancellation.test.ts b/src/main/persistence/loading-store/profile-state-caller-cancellation.test.ts new file mode 100644 index 00000000000..728c63f6b8d --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-caller-cancellation.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from './profile-state-maintenance-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +describe('profile flush caller cancellation', () => { + it('releases a canceled waiter while its admitted write completes and later saving continues', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const write = authority.writeSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async (domains) => { + await write(domains) + started.resolve() + await release.promise + }) + const abort = vi.spyOn(authority, 'abort') + const controller = new AbortController() + store.updateSettings({ theme: 'dark' }) + const pending = store.flushPendingOrThrowAsync({ signal: controller.signal }) + const rejected = expect(pending).rejects.toThrow('aborted') + let settled = false + void pending.catch(() => { + settled = true + }) + await started.promise + controller.abort() + try { + await new Promise((resolve) => setImmediate(resolve)) + expect(abort).not.toHaveBeenCalled() + expect(settled).toBe(true) + expect(() => authority.assertWritable()).not.toThrow() + } finally { + release.resolve() + await rejected + } + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'light' }) + return { value: undefined } + }) + expect(readState().settings.theme).toBe('light') + }) + + it('keeps an abandoned write ordered before the final checkpoint', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const write = authority.writeSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async (domains) => { + started.resolve() + await release.promise + await write(domains) + }) + const controller = new AbortController() + store.updateSettings({ theme: 'dark' }) + const abandoned = store.flushPendingOrThrowAsync({ signal: controller.signal }) + const rejected = expect(abandoned).rejects.toThrow('aborted') + await started.promise + controller.abort() + store.getWorkspaceSession().activeTabId = 'shutdown-edit' + const capture = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const final = store.flushFinalOrThrowAsync() + const result = final.catch((error: unknown) => error) + try { + await new Promise((resolve) => setImmediate(resolve)) + expect(capture).not.toHaveBeenCalled() + } finally { + release.resolve() + await rejected + } + await expect(result).resolves.toBeUndefined() + expect(readState()).toMatchObject({ + settings: { theme: 'dark' }, + workspaceSession: { activeTabId: 'shutdown-edit' } + }) + }) +}) diff --git a/src/main/persistence/loading-store/profile-state-checkpoints.test.ts b/src/main/persistence/loading-store/profile-state-checkpoints.test.ts new file mode 100644 index 00000000000..3b32f7702aa --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-checkpoints.test.ts @@ -0,0 +1,238 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import { openProfileStateDatabaseReadOnly } from '../profile-state/profile-state-database' +import { parseProfileStateRoot } from '../profile-state/profile-state-document-validation' +import { readProfileStateSnapshot } from '../profile-state/profile-state-documents' +import { profileStateJsonExportPath } from '../profile-state/legacy-json/profile-state-export-path' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { Store } from './store' +import { scheduleSave } from './write-scheduling' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const PROFILE_ID = 'checkpoint-test' +const fixtures: { directory: string; store: Store }[] = [] + +afterEach(async () => { + for (const { directory, store } of fixtures.splice(0)) { + store.freezeWrites() + await store.flushAsync() + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-checkpoint-')) + const databasePath = join(directory, 'profile-state.db') + const dataFile = join(directory, 'orca-data.json') + const authority = new ProfileStateSqliteAuthority(databasePath, PROFILE_ID) + const backup = vi.spyOn(authority, 'scheduleBackup').mockImplementation(() => {}) + authority.writeSerializedState( + Buffer.from(JSON.stringify(buildProfileStateCutoverFixture(directory))) + ) + const store = new Store({ dataFile, profileStateAuthority: authority }) + fixtures.push({ directory, store }) + store.flushOrThrow() + return { + directory, + dataFile, + store, + authority, + backup, + readState: () => { + const opened = openProfileStateDatabaseReadOnly(databasePath, PROFILE_ID) + try { + return parseProfileStateRoot(readProfileStateSnapshot(opened.db).json) + } finally { + opened.db.close() + } + } + } +} + +function mutateThroughGetters(store: Store): void { + store.getWorkspaceSession().activeTabId = 'direct-local-tab' + store.getWorkspaceSession('ssh:build-host').activeTabId = 'direct-remote-tab' +} + +const EXPECTED_CHECKPOINT = { + settings: { theme: 'dark' }, + workspaceSession: { activeTabId: 'direct-local-tab' }, + workspaceSessionsByHostId: { 'ssh:build-host': { activeTabId: 'direct-remote-tab' } } +} + +describe('complete profile state checkpoints', () => { + it('does not retain a save timer after writes are frozen', () => { + const { store } = fixture() + store.freezeWrites() + const setTimer = vi.spyOn(globalThis, 'setTimeout') + scheduleSave(store) + expect(setTimer).not.toHaveBeenCalled() + }) + + it.each(['sync', 'async'] as const)( + 'rejects a stale %s checkpoint even when the local hash is unchanged', + async (mode) => { + const { store, directory, readState } = fixture() + const other = new ProfileStateSqliteAuthority(join(directory, 'profile-state.db'), PROFILE_ID) + try { + other.readSerializedState() + other.writeSerializedDomains([{ domain: 'futureDomain', payload: '{"external":true}' }]) + scheduleSave(store) + if (mode === 'sync') { + expect(() => store.flushOrThrow()).toThrow(/Profile state revision changed/) + } else { + await expect(store.flushPendingOrThrowAsync()).rejects.toThrow( + /Profile state revision changed/ + ) + } + expect(readState()).toMatchObject({ futureDomain: { external: true } }) + } finally { + other.close() + } + } + ) + + it('captures nested history mutations after an unchanged checkpoint', async () => { + const { store, directory, readState } = fixture() + store.writeProfileStateJsonExport(join(directory, 'before.json')) + const run = store.listAutomationRuns()[0] + if (!run?.outputSnapshot) { + throw new Error('Expected a fixture run with output') + } + run.outputSnapshot.content = 'changed through a nested getter' + store.updateSettings({ theme: 'dark' }) + await store.flushPendingOrThrowAsync() + + await store.flushAsync() + + expect(readState()).toMatchObject({ + settings: { theme: 'dark' }, + automationRuns: expect.arrayContaining([ + expect.objectContaining({ + id: run.id, + outputSnapshot: expect.objectContaining({ content: 'changed through a nested getter' }) + }) + ]) + }) + }) + + it.each([false, true])( + 'captures getter mutations alongside pending settings on quit (compatibility export: %s)', + async (exportJsonCompatibility) => { + const { store, dataFile, readState } = fixture() + mutateThroughGetters(store) + store.updateSettings({ theme: 'dark' }) + + await store.flushAsync({ exportJsonCompatibility }) + + expect(readState()).toMatchObject(EXPECTED_CHECKPOINT) + if (exportJsonCompatibility) { + expect(parseProfileStateRoot(readFileSync(dataFile, 'utf8'))).toMatchObject( + EXPECTED_CHECKPOINT + ) + } + } + ) + + it.each(['explicit', 'revisioned', 'compatibility'] as const)( + 'includes getter mutations in the %s JSON export', + (mode) => { + const { store, directory, dataFile, readState } = fixture() + mutateThroughGetters(store) + store.updateSettings({ theme: 'dark' }) + let exportPath = join(directory, 'rollback.json') + + if (mode === 'explicit') { + store.writeProfileStateJsonExport(exportPath) + } else if (mode === 'revisioned') { + const revision = store.writeLatestProfileStateJsonExport() + if (revision === undefined) { + throw new Error('Expected a revisioned export') + } + exportPath = profileStateJsonExportPath(dataFile, revision) + } else { + store.writeLatestProfileStateJsonCompatibilityExport() + exportPath = dataFile + } + + expect(readState()).toMatchObject(EXPECTED_CHECKPOINT) + expect(parseProfileStateRoot(readFileSync(exportPath, 'utf8'))).toMatchObject( + EXPECTED_CHECKPOINT + ) + } + ) + + it('takes the final checkpoint after an earlier queued writer clears its dirty domains', async () => { + const { store, backup, readState } = fixture() + store.updateSettings({ theme: 'light' }) + backup.mockImplementationOnce(() => { + queueMicrotask(() => { + mutateThroughGetters(store) + store.updateSettings({ theme: 'dark' }) + }) + }) + + const previous = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + const final = store.flushAsync() + await Promise.all([previous, final]) + + expect(readState()).toMatchObject(EXPECTED_CHECKPOINT) + }) + + it('keeps normal pending and synchronous writes selective', async () => { + const { store, authority } = fixture() + const fullWrite = vi.spyOn(authority, 'writeSerializedState') + const selectiveWrite = vi.spyOn(authority, 'writeSerializedDomains') + + store.updateSettings({ theme: 'dark' }) + await store.flushPendingOrThrowAsync() + store.patchWorkspaceSession({ activeTabId: 'scheduled-tab' }) + store.flushOrThrow() + + expect(fullWrite).not.toHaveBeenCalled() + expect( + selectiveWrite.mock.calls.map(([domains]) => domains.map(({ domain }) => domain)) + ).toEqual([['settings'], ['workspaceSession']]) + }) + + it('clears full-checkpoint mode after a synchronous hash no-op', () => { + const { store, authority } = fixture() + store.writeLatestProfileStateJsonExport() + + const completeWrite = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const selectiveWrite = vi.spyOn(authority, 'writeSerializedDomains') + store.updateSettings({ theme: 'light' }) + store.flushOrThrow() + + expect(completeWrite).not.toHaveBeenCalled() + expect( + selectiveWrite.mock.calls.map(([domains]) => domains.map(({ domain }) => domain)) + ).toEqual([['settings']]) + }) + + it('does not write a frozen profile when final persistence requests a checkpoint', async () => { + const { store, authority, readState } = fixture() + const before = readState() + const fullWrite = vi.spyOn(authority, 'writeSerializedState') + mutateThroughGetters(store) + store.updateSettings({ theme: 'dark' }) + store.freezeWrites() + + await store.flushAsync() + + expect(fullWrite).not.toHaveBeenCalled() + expect(readState()).toEqual(before) + }) +}) diff --git a/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts b/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts new file mode 100644 index 00000000000..26709b33e3f --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts @@ -0,0 +1,143 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, vi } from 'vitest' +import type { AutomationRun } from '../../../shared/automations-types' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { writeVersionedProfileStateExport } from '../profile-state/legacy-json/profile-state-versioned-export' +import type { + AsyncProfileStateAuthority, + ProfileStateDomainReplacement +} from './profile-state-authority' +import { Store } from './store' + +export function deferred() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((accept, refuse) => { + resolve = accept + reject = refuse + }) + return { promise, resolve, reject } +} + +/** Delay the authority boundary while retaining real SQLite and Store serialization. */ +export class DelayedAuthority implements AsyncProfileStateAuthority { + readonly asynchronous = true + private next: + | { started: ReturnType>; finish: ReturnType> } + | undefined + readonly captures: ProfileStateDomainReplacement[][] = [] + private failNext = false + readonly close = vi.fn(async () => { + this.inner.close() + }) + + constructor(readonly inner: ProfileStateSqliteAuthority) {} + + /** Fail the next profile write the way a full disk would, after any paused gate opens. */ + failNextWrite() { + this.failNext = true + } + + pause() { + const gate = { started: deferred(), finish: deferred() } + this.next = gate + return gate + } + + assertWritable() {} + async abort() {} + readSerializedState() { + return this.inner.readSerializedState() + } + async assertCurrentRevision() { + await this.dispatch(() => this.inner.assertCurrentRevision()) + } + async writeSerializedState(payload: Buffer) { + const captured = Buffer.from(payload) + await this.dispatch(() => this.inner.writeSerializedState(captured), true) + } + async writeSerializedDomains(replacements: readonly ProfileStateDomainReplacement[]) { + const captured = structuredClone(replacements) + this.captures.push([...captured]) + await this.dispatch(() => this.inner.writeSerializedDomains(captured), true) + } + async writeCompleteSerializedDomains(replacements: readonly ProfileStateDomainReplacement[]) { + const captured = structuredClone(replacements) + this.captures.push([...captured]) + await this.dispatch(() => this.inner.writeCompleteSerializedDomains(captured), true) + } + async writeSerializedAutomationRuns( + replacements: readonly ProfileStateDomainReplacement[], + runs: readonly AutomationRun[] + ) { + const captured = structuredClone(replacements) + const capturedRuns = structuredClone(runs) + await this.dispatch( + () => this.inner.writeSerializedAutomationRuns(captured, capturedRuns), + true + ) + } + async writeJsonExport(path: string) { + return this.inner.writeJsonExport(path) + } + async writeLatestJsonExport(path: string) { + return writeVersionedProfileStateExport(path, this.inner.writeJsonExport.bind(this.inner)) + } + async writeJsonCompatibilityExport(path: string) { + return this.inner.writeJsonCompatibilityExportAsync(path) + } + async quarantineDatabase(root?: string, reason?: string) { + return this.inner.quarantineDatabase(root, reason) + } + private async dispatch(operation: () => void, write = false) { + const gate = this.next + this.next = undefined + gate?.started.resolve() + await gate?.finish.promise + if (write && this.failNext) { + this.failNext = false + throw new Error('profile_state_write_failed') + } + operation() + } +} + +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) { + await cleanup() + } + vi.restoreAllMocks() +}) + +export async function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-worker-coordination-')) + const path = join(directory, 'profile-state.db') + const inner = new ProfileStateSqliteAuthority(path, 'coordination-test') + inner.writeSerializedState( + Buffer.from(JSON.stringify(buildProfileStateCutoverFixture(directory))) + ) + const authority = new DelayedAuthority(inner) + const store = new Store({ + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: authority + }) + cleanups.push(async () => { + await store.freezeWritesAsync() + rmSync(directory, { recursive: true, force: true }) + }) + await store.flushPendingOrThrowAsync() + authority.captures.length = 0 + const readState = () => { + const reader = new ProfileStateSqliteAuthority(path, 'coordination-test') + try { + return JSON.parse(reader.readSerializedState() ?? '{}') + } finally { + reader.close() + } + } + return { store, authority, readState } +} diff --git a/src/main/persistence/loading-store/profile-state-direct-flush.test.ts b/src/main/persistence/loading-store/profile-state-direct-flush.test.ts new file mode 100644 index 00000000000..9cf3d1ec2d1 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-direct-flush.test.ts @@ -0,0 +1,188 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { CodexResetCreditAttemptLedger } from '../../../shared/codex-reset-credit-attempt-ledger' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { readProfileStateDomain } from '../profile-state/profile-state-domain-reader' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import { Store } from './store' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`), + decryptString: (value: Buffer) => value.toString().slice('encrypted:'.length) + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const PROFILE_ID = 'direct-flush-test' +const fixtures: { directory: string; store: Store }[] = [] + +afterEach(async () => { + for (const fixture of fixtures.splice(0)) { + fixture.store.freezeWrites() + await fixture.store.flushAsync() + rmSync(fixture.directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +function fixture(seedState?: unknown) { + const directory = mkdtempSync(join(tmpdir(), 'orca-direct-flush-')) + const databasePath = join(directory, 'profile-state.db') + const authority = new ProfileStateSqliteAuthority(databasePath, PROFILE_ID) + vi.spyOn(authority, 'scheduleBackup').mockImplementation(() => {}) + if (seedState !== undefined) { + authority.writeSerializedState(Buffer.from(JSON.stringify(seedState))) + } + const store = new Store({ + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: authority + }) + fixtures.push({ directory, store }) + store.flushOrThrow() + return { + store, + read: (domain: string) => readProfileStateDomain(databasePath, PROFILE_ID, domain), + pendSession: () => store.patchWorkspaceSession({ activeWorktreeId: 'pending-workspace' }) + } +} + +describe('SQLite durability barriers with another selective write pending', () => { + it('commits a reset-credit claim before returning to its provider caller', async () => { + const state = fixture() + const ledger: CodexResetCreditAttemptLedger = { + version: 1, + attempts: [ + { + idempotencyKey: '158a0d86-8d8e-4589-b9c5-f53a59bdcdd8', + expectedScope: { + target: { runtime: 'host', wslDistro: null }, + accountId: 'account', + accountRevision: 1, + offerRevision: 'v1:offer' + }, + state: 'providerPending' + } + ] + } + state.pendSession() + await state.store.replaceCodexResetCreditAttemptLedgerAndFlush(ledger) + expect(state.read('codexResetCreditAttemptLedger')).toMatchObject({ + kind: 'value', + value: ledger + }) + }) + + it('commits Claude live-PTY admission before returning', () => { + const state = fixture() + state.pendSession() + state.store.addClaudeLivePtySessionId('claude-session') + expect(state.read('claudeLivePtySessionIds')).toMatchObject({ + kind: 'value', + value: ['claude-session'] + }) + }) + + it('commits SSH lease admission before returning', () => { + const state = fixture() + state.pendSession() + state.store.upsertSshRemotePtyLease({ targetId: 'ssh-test', ptyId: 'pty-1', state: 'attached' }) + expect(state.read('sshRemotePtyLeases')).toMatchObject({ + kind: 'value', + value: [{ targetId: 'ssh-test', ptyId: 'pty-1', state: 'attached' }] + }) + }) + + it.each(['async', 'shutdown'] as const)( + 'persists SSH detachment through the %s barrier', + async (barrier) => { + const state = fixture() + state.store.upsertSshRemotePtyLease({ + targetId: 'ssh-test', + ptyId: 'pty-1', + state: 'attached' + }) + state.pendSession() + if (barrier === 'async') { + await state.store.markSshRemotePtyLeasesAsync('ssh-test', 'detached') + } else { + state.store.markSshRemotePtyLeasesForShutdown('ssh-test', 'detached') + await state.store.flushAsync() + } + expect(state.read('sshRemotePtyLeases')).toMatchObject({ + kind: 'value', + value: [{ targetId: 'ssh-test', ptyId: 'pty-1', state: 'detached' }] + }) + } + ) + + it('persists sealed SSH consumer recovery before relay setup continues', async () => { + const state = fixture() + state.pendSession() + await state.store.upsertSshPtyConsumerRecovery({ + targetId: 'ssh-test', + clientInstanceId: 'client-test', + serverBuildId: 'build-test', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'secret-owner-lease' + }) + const stored = state.read('sshPtyConsumerRecoveries') + expect(stored).toMatchObject({ kind: 'value', value: [{ targetId: 'ssh-test' }] }) + expect(JSON.stringify(stored)).not.toContain('secret-owner-lease') + state.pendSession() + await state.store.removeSshPtyConsumerRecovery('ssh-test') + expect(state.read('sshPtyConsumerRecoveries')).toMatchObject({ kind: 'value', value: [] }) + }) + + it('deletes an automation and its retained runs in the same commit', () => { + const state = fixture(buildProfileStateCutoverFixture()) + const automation = state.store.listAutomations()[0] + if (!automation) { + throw new Error('Fixture automation is absent') + } + expect(state.store.listAutomationRuns(automation.id)).not.toHaveLength(0) + state.store.deleteAutomation(automation.id) + expect(state.read('automations')).toMatchObject({ kind: 'value', value: [] }) + expect(state.read('automationRuns')).toMatchObject({ kind: 'value', value: [] }) + }) + + it('persists the session and UI identities moved with worktree metadata', () => { + const seed = buildProfileStateCutoverFixture() + const oldId = 'repo-local::/fixture/local' + const newId = 'repo-local::/fixture/renamed' + seed.workspaceSession.activeWorktreeId = oldId + seed.ui.showDotfilesByWorktree = { [oldId]: true } + const state = fixture(seed) + state.store.migrateWorktreeIdentity(oldId, newId) + state.store.flushOrThrow() + expect(state.read('workspaceSession')).toMatchObject({ + kind: 'value', + value: { activeWorktreeId: newId } + }) + expect(state.read('ui')).toMatchObject({ + kind: 'value', + value: { showDotfilesByWorktree: { [newId]: true } } + }) + }) +}) diff --git a/src/main/persistence/loading-store/profile-state-flush-lifetime.ts b/src/main/persistence/loading-store/profile-state-flush-lifetime.ts new file mode 100644 index 00000000000..215c1babe85 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-flush-lifetime.ts @@ -0,0 +1,34 @@ +import type { StoreRuntimeState } from './store-runtime-state' + +/** Lifecycle cleanup must join every accepted operation before closing its writer. */ +export async function drainProfileStateOperations( + operations: Iterable | null | undefined> +): Promise { + const settled = await Promise.allSettled( + Array.from(operations, (operation) => Promise.resolve(operation)) + ) + for (const result of settled) { + if (result.status === 'rejected') { + throw result.reason + } + } +} + +/** A flush may dispatch again after SQL completes while its sidecars are still pending. */ +export async function runProfileStateFlush( + runtime: Pick, + operation: () => Promise +): Promise { + let finish!: () => void + const pending = new Promise((resolve) => { + finish = resolve + }) + runtime.pendingProfileFlushes.add(pending) + try { + await operation() + } finally { + // Each caller owns its result; lifecycle barriers may retry a known failed capture. + runtime.pendingProfileFlushes.delete(pending) + finish() + } +} diff --git a/src/main/persistence/loading-store/profile-state-import-lifetime.test.ts b/src/main/persistence/loading-store/profile-state-import-lifetime.test.ts new file mode 100644 index 00000000000..dd201c81cd3 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-import-lifetime.test.ts @@ -0,0 +1,124 @@ +import { createSqliteTestStore } from '../../persistence-test-harness' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { agentHookServer } from '../../agent-hooks/server' +import { + clearMigrationUnsupportedPty, + setMigrationUnsupportedPty, + setMigrationUnsupportedPtyPersistenceListener +} from '../../agent-hooks/migration-unsupported-pty-state' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import * as composition from './store-domain-composition' +import { scheduleSave } from './write-scheduling' +import { Store } from './store' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const directories: string[] = [] +const stores: Store[] = [] +const livePaneKey = 'live-tab:11111111-1111-4111-8111-111111111111' + +it('refuses a writable Store before constructing domains without an authority', () => { + const createDomains = vi.spyOn(composition, 'createStoreDomains') + expect(() => new Store()).toThrow('requires a SQLite profile-state authority') + expect(createDomains).not.toHaveBeenCalled() +}) + +afterEach(async () => { + agentHookServer.setPaneKeyAliasPersistenceListener(null) + setMigrationUnsupportedPtyPersistenceListener(null) + agentHookServer.clearPaneKeyAliasesForPty('later-live-pty') + clearMigrationUnsupportedPty('later-live-pty') + for (const store of stores.splice(0)) { + await store.freezeWritesAsync() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() + vi.useRealTimers() +}) + +it.each([false, true])('isolates imported aliases and live listeners (load failure=%s)', (fail) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const directory = mkdtempSync(join(tmpdir(), 'orca-import-lifetime-')) + directories.push(directory) + const live = createSqliteTestStore(Store, { dataFile: join(directory, 'live', 'orca-data.json') }) + stores.push(live) + const source = buildProfileStateCutoverFixture(directory) + for (const session of [ + source.workspaceSession, + ...Object.values(source.workspaceSessionsByHostId ?? {}) + ]) { + if (!session) { + continue + } + for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId)) { + layout.root = { type: 'leaf', leafId: 'pane:1' } + layout.activeLeafId = 'pane:1' + layout.ptyIdsByLeafId = { 'pane:1': `imported-${tabId}` } + } + } + const registerAlias = vi.spyOn(agentHookServer, 'registerPaneKeyAlias') + const replaceListener = vi.spyOn(agentHookServer, 'setPaneKeyAliasPersistenceListener') + if (fail) { + const createDomains = composition.createStoreDomains + vi.spyOn(composition, 'createStoreDomains').mockImplementationOnce((runtime) => { + const domains = createDomains(runtime) + vi.spyOn(domains.adaptation, 'hydrateFolderWorkspaceDiffComments').mockImplementationOnce( + () => { + scheduleSave(domains.scheduling) + throw new Error('normalization refused') + } + ) + return domains + }) + } + const pendingTimers = vi.getTimerCount() + const createImport = () => + new Store({ + dataFile: join(directory, 'imported', 'orca-data.json'), + serializedState: JSON.stringify(source) + }) + if (fail) { + expect(createImport).toThrow('normalization refused') + } else { + const imported = createImport() + stores.push(imported) + expect(JSON.parse(imported.prepareProfileStateExport().json).legacyPaneKeyAliasEntries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ legacyPaneKey: 'tab-local:1', ptyId: 'imported-tab-local' }), + expect.objectContaining({ legacyPaneKey: 'tab-remote:1', ptyId: 'imported-tab-remote' }) + ]) + ) + } + expect(registerAlias).not.toHaveBeenCalled() + expect(replaceListener).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(pendingTimers) + agentHookServer.registerPaneKeyAlias('live-tab:1', livePaneKey, 'later-live-pty') + setMigrationUnsupportedPty({ + ptyId: 'later-live-pty', + paneKey: livePaneKey, + tabId: 'live-tab', + worktreeId: 'live-worktree', + reason: 'legacy-numeric-pane-key', + source: 'local', + updatedAt: 1 + }) + const persisted = JSON.parse(live.prepareProfileStateExport().json) + expect(persisted.legacyPaneKeyAliasEntries).toEqual([ + expect.objectContaining({ legacyPaneKey: 'live-tab:1', ptyId: 'later-live-pty' }) + ]) + expect(persisted.migrationUnsupportedPtyEntries).toEqual([ + expect.objectContaining({ ptyId: 'later-live-pty', paneKey: livePaneKey }) + ]) +}) diff --git a/src/main/persistence/loading-store/profile-state-maintenance-compatibility.test.ts b/src/main/persistence/loading-store/profile-state-maintenance-compatibility.test.ts new file mode 100644 index 00000000000..e3313f60eb5 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-maintenance-compatibility.test.ts @@ -0,0 +1,125 @@ +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import { createWorkerMaintenanceFixture } from './profile-state-maintenance-fixture' +import { profileStateJsonExportPaths } from '../profile-state/legacy-json/profile-state-export-path' +import { openProfileStateDatabase } from '../profile-state/profile-state-database' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +describe('maintenance compatibility checkpoint', () => { + it('exports the current worker state before a clean profile switch releases its writer', async () => { + const { store, dataFile, readState } = await createWorkerMaintenanceFixture() + expect(existsSync(dataFile)).toBe(false) + store.updateSettings({ theme: 'dark' }) + store.getWorkspaceSession().activeTabId = 'latest-tab' + const maintenance = await store.beginProfileMaintenance() + const snapshot = JSON.parse(readFileSync(dataFile, 'utf8')) + expect(snapshot).toEqual(readState()) + expect(snapshot.settings.theme).toBe('dark') + expect(snapshot.workspaceSession.activeTabId).toBe('latest-tab') + const [retained] = profileStateJsonExportPaths(dataFile) + expect(JSON.parse(readFileSync(retained, 'utf8'))).toEqual(snapshot) + await maintenance.resume() + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'light' }) + return { value: undefined } + }) + expect(readState().settings.theme).toBe('light') + }) + + it('does not publish compatibility files for a recovery pause', async () => { + const { store, dataFile } = await createWorkerMaintenanceFixture() + store.updateSettings({ theme: 'dark' }) + await store.beginProfileMaintenance({ flush: false }) + expect(existsSync(dataFile)).toBe(false) + expect(profileStateJsonExportPaths(dataFile)).toEqual([]) + }) + + it('resumes admission after a known export failure while preserving the committed state', async () => { + const { store, authority, dataFile, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(authority, 'writeJsonCompatibilityExportAsync').mockRejectedValueOnce( + new Error('export disk refused') + ) + store.updateSettings({ theme: 'dark' }) + await expect(store.beginProfileMaintenance()).rejects.toThrow('export disk refused') + expect(existsSync(dataFile)).toBe(false) + expect(readState().settings.theme).toBe('dark') + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'light' }) + return { value: undefined } + }) + expect(readState().settings.theme).toBe('light') + await (await store.beginProfileMaintenance()).resume() + expect(JSON.parse(readFileSync(dataFile, 'utf8')).settings.theme).toBe('light') + }) + + it('resumes after the worker cannot read JSON before staging compatibility acceptance', async () => { + const { store, dataFile, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + mkdirSync(dataFile) + store.updateSettings({ theme: 'dark' }) + + await expect(store.beginProfileMaintenance()).rejects.toMatchObject({ + outcome: 'known-failure' + }) + expect(readState().settings.theme).toBe('dark') + rmSync(dataFile, { recursive: true }) + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'light' }) + return { value: undefined } + }) + expect(readState().settings.theme).toBe('light') + await (await store.beginProfileMaintenance()).resume() + expect(JSON.parse(readFileSync(dataFile, 'utf8')).settings.theme).toBe('light') + }) + + it.each(['maintenance', 'update-preflight'] as const)( + 'resumes saving after a rolled-back %s export leaves both JSON versions accepted', + async (phase) => { + const { store, authority, dataFile, databaseFile, profileId, readState } = + await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await authority.writeJsonCompatibilityExportAsync(dataFile) + const opened = openProfileStateDatabase(databaseFile, profileId) + try { + opened.db.exec(` + CREATE TRIGGER reject_acceptance BEFORE INSERT ON profile_state_meta + WHEN NEW.key = 'legacy_json_acceptance' + AND json_type(NEW.value, '$.pending') IS NULL + BEGIN SELECT RAISE(ABORT, 'injected promotion failure'); END + `) + } finally { + opened.db.close() + } + store.updateSettings({ theme: 'dark' }) + + await expect( + phase === 'maintenance' + ? store.beginProfileMaintenance() + : store.writeLatestProfileStateJsonCompatibilityExportAsync() + ).rejects.toMatchObject({ outcome: 'known-failure' }) + expect(JSON.parse(readFileSync(dataFile, 'utf8')).settings.theme).toBe('dark') + expect(readState().settings.theme).toBe('dark') + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'light' }) + return { value: undefined } + }) + expect(readState().settings.theme).toBe('light') + const repaired = openProfileStateDatabase(databaseFile, profileId) + try { + repaired.db.exec('DROP TRIGGER reject_acceptance') + } finally { + repaired.db.close() + } + await (await store.beginProfileMaintenance()).resume() + expect(JSON.parse(readFileSync(dataFile, 'utf8')).settings.theme).toBe('light') + } + ) +}) diff --git a/src/main/persistence/loading-store/profile-state-maintenance-final-failure.test.ts b/src/main/persistence/loading-store/profile-state-maintenance-final-failure.test.ts new file mode 100644 index 00000000000..7b9a5e16fa9 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-maintenance-final-failure.test.ts @@ -0,0 +1,89 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from './profile-state-maintenance-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +describe('quit during failed profile maintenance', () => { + it('retries a known failed checkpoint and persists shutdown edits before closing', async () => { + const { store, authority, readState, dataFile } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + store.upsertSshRemotePtyLease({ targetId: 'remote', ptyId: 'pty', state: 'attached' }) + await store.flushPendingOrThrowAsync() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const checkpoint = vi + .spyOn(authority, 'writeCompleteSerializedDomains') + .mockImplementationOnce(async () => { + started.resolve() + await release.promise + throw new Error('SQLITE_BUSY') + }) + store.updateSettings({ theme: 'dark' }) + const failed = expect(store.beginProfileMaintenance()).rejects.toThrow('SQLITE_BUSY') + await started.promise + store.markSshRemotePtyLeasesForShutdown('remote', 'detached') + const final = store.flushFinalOrThrowAsync({ exportJsonCompatibility: true }) + const result = final.catch((error: unknown) => error) + release.resolve() + await failed + await expect(result).resolves.toBeUndefined() + expect(checkpoint).toHaveBeenCalledTimes(2) + expect(readState()).toMatchObject({ + settings: { theme: 'dark' }, + sshRemotePtyLeases: [expect.objectContaining({ state: 'detached' })] + }) + expect(JSON.parse(readFileSync(dataFile, 'utf8'))).toEqual(readState()) + }) + + it.each(['indeterminate', 'changed-source'] as const)( + 'keeps %s maintenance fenced during quit', + async (kind) => { + const { store, authority, readState, peer } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const durable = readState() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const failure = + kind === 'indeterminate' + ? new ProfileStateWriterError('test-unknown-commit', 'commit outcome unknown', kind) + : new Error('SQLITE_BUSY') + const checkpoint = vi + .spyOn(authority, 'writeCompleteSerializedDomains') + .mockImplementationOnce(async () => { + started.resolve() + await release.promise + if (kind === 'changed-source') { + const other = peer() + try { + other.writeSerializedDomains([{ domain: 'peer', payload: '{"preserved":true}' }]) + } finally { + other.close() + } + } + throw failure + }) + store.updateSettings({ theme: 'dark' }) + const failed = expect(store.beginProfileMaintenance()).rejects.toBe(failure) + await started.promise + const final = expect(store.flushFinalOrThrowAsync()).rejects.toBe(failure) + release.resolve() + await Promise.all([failed, final]) + expect(checkpoint).toHaveBeenCalledOnce() + expect(readState()).toEqual( + kind === 'changed-source' ? { ...durable, peer: { preserved: true } } : durable + ) + } + ) +}) diff --git a/src/main/persistence/loading-store/profile-state-maintenance-fixture.ts b/src/main/persistence/loading-store/profile-state-maintenance-fixture.ts new file mode 100644 index 00000000000..ba36635cc9f --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-maintenance-fixture.ts @@ -0,0 +1,112 @@ +import { build } from 'esbuild' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, afterEach, beforeAll, vi } from 'vitest' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { ProfileStateWorkerAuthority } from '../profile-state/profile-state-worker-authority' +import { Store } from './store' + +let bundleRoot: string +let workerOptions: { workerPath: string; backupWorkerPath: string } +const stores: Store[] = [] +const roots: string[] = [] +const releases: (() => void)[] = [] +type ProfileFixtureLocation = { directory: string; profileId: string; cleanupRoot?: string } + +beforeAll(async () => { + bundleRoot = mkdtempSync(join(tmpdir(), 'orca-maintenance-worker-')) + workerOptions = { + workerPath: join(bundleRoot, 'profile-state-writer-worker-entry.js'), + backupWorkerPath: join(bundleRoot, 'profile-state-backup-worker-entry.js') + } + await build({ + entryPoints: [ + resolve('src/main/persistence/profile-state/profile-state-writer-worker-entry.ts'), + resolve('src/main/persistence/profile-state/profile-state-backup-worker-entry.ts') + ], + outdir: bundleRoot, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) +}) + +afterEach(async () => { + for (const release of releases.splice(0)) { + release() + } + await Promise.all(stores.splice(0).map((store) => store.freezeWritesAsync().catch(() => {}))) + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } + vi.restoreAllMocks() + vi.useRealTimers() +}) +afterAll(() => rmSync(bundleRoot, { recursive: true, force: true })) + +export function maintenanceBarrier() { + const gate = Promise.withResolvers() + releases.push(gate.resolve) + return gate +} + +function paths(profile?: ProfileFixtureLocation) { + const directory = profile?.directory ?? mkdtempSync(join(tmpdir(), 'orca-maintenance-profile-')) + roots.push(profile?.cleanupRoot ?? directory) + return { + directory, + dataFile: join(directory, 'orca-data.json'), + databaseFile: join(directory, 'profile-state.db'), + profileId: profile?.profileId ?? 'maintenance-test' + } +} + +export async function createWorkerMaintenanceFixture( + profile?: ProfileFixtureLocation, + onFailure?: (error: Error) => void +) { + const input = paths(profile) + const bootstrap = new ProfileStateSqliteAuthority(input.databaseFile, input.profileId) + bootstrap.writeSerializedState( + Buffer.from(JSON.stringify(buildProfileStateCutoverFixture(input.directory))) + ) + const state = bootstrap.readInitialState().takeParsedState?.() + const authority = new ProfileStateWorkerAuthority(bootstrap.retireForWorker(), { + ...workerOptions, + onFailure + }) + await authority.ready + const store = new Store({ + dataFile: input.dataFile, + profileStateAuthority: authority, + initialAuthorityState: { authority, takeParsedState: () => state } + }) + stores.push(store) + const backup = vi.spyOn(authority, 'scheduleBackup').mockImplementation(() => {}) + await store.flushPendingOrThrowAsync() + backup.mockRestore() + const peer = () => new ProfileStateSqliteAuthority(input.databaseFile, input.profileId) + const readState = () => { + const reader = peer() + try { + return JSON.parse(reader.readSerializedState() ?? '{}') + } finally { + reader.close() + } + } + return { ...input, store, authority, peer, readState } +} + +export function createSqliteMaintenanceFixture() { + const input = paths() + const authority = new ProfileStateSqliteAuthority(input.databaseFile, input.profileId) + authority.writeSerializedState( + Buffer.from(JSON.stringify(buildProfileStateCutoverFixture(input.directory))) + ) + const store = new Store({ dataFile: input.dataFile, profileStateAuthority: authority }) + stores.push(store) + return { ...input, store, authority } +} diff --git a/src/main/persistence/loading-store/profile-state-maintenance-recovery.test.ts b/src/main/persistence/loading-store/profile-state-maintenance-recovery.test.ts new file mode 100644 index 00000000000..84a849b3b26 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-maintenance-recovery.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from 'vitest' +import * as backupWorker from '../profile-state/profile-state-backup-worker' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from './profile-state-maintenance-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +describe('failed maintenance recovery', () => { + it.each(['maintenance', 'final', 'freeze'] as const)( + '%s cancels an active backup after a routine flush completes', + async (kind) => { + const { store } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const canceled = maintenanceBarrier() + vi.spyOn(backupWorker, 'runProfileStateBackupWorker').mockImplementationOnce( + async (_job, options) => { + started.resolve() + await new Promise((resolve) => + options?.signal?.addEventListener('abort', () => resolve(), { once: true }) + ) + canceled.resolve() + throw new Error('backup aborted') + } + ) + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await started.promise + await store.flushPendingOrThrowAsync() + const stop = + kind === 'maintenance' + ? store.beginProfileMaintenance() + : kind === 'final' + ? store.flushFinalOrThrowAsync() + : store.freezeWritesAsync() + await canceled.promise + await stop + } + ) + + it('cancels between checkpoints without aborting an acknowledged write', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const write = authority.writeCompleteSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeCompleteSerializedDomains').mockImplementationOnce( + async (domains) => { + started.resolve() + await release.promise + await write(domains) + } + ) + const abort = vi.spyOn(authority, 'abort') + const controller = new AbortController() + store.updateSettings({ theme: 'dark' }) + const pending = store.beginProfileMaintenance({ signal: controller.signal }) + const rejected = expect(pending).rejects.toThrow('aborted') + await started.promise + controller.abort() + release.resolve() + await rejected + expect(abort).not.toHaveBeenCalled() + store.setWorkspaceSession({ ...store.getWorkspaceSession(), activeTabId: 'after-cancellation' }) + await store.flushPendingOrThrowAsync() + expect(readState()).toMatchObject({ + settings: { theme: 'dark' }, + workspaceSession: { activeTabId: 'after-cancellation' } + }) + }) + + it('restores the writer and snapshot admission after a known failed checkpoint', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(authority, 'writeCompleteSerializedDomains').mockRejectedValueOnce( + new Error('disk refused') + ) + store.updateSettings({ theme: 'dark' }) + await expect(store.beginProfileMaintenance()).rejects.toThrow('disk refused') + + store.setWorkspaceSession({ ...store.getWorkspaceSession(), activeTabId: 'after-failure' }) + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'light' }) + return { value: undefined } + }) + expect(readState()).toMatchObject({ + settings: { theme: 'light' }, + workspaceSession: { activeTabId: 'after-failure' } + }) + await (await store.beginProfileMaintenance()).resume() + }) + + it('keeps changed storage fenced when recovering a known failure', async () => { + const { store, authority, peer, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + store.updateSettings({ theme: 'dark' }) + vi.spyOn(authority, 'writeCompleteSerializedDomains').mockImplementationOnce(async () => { + const writer = peer() + writer.writeSerializedDomains([{ domain: 'peer', payload: '{"preserved":true}' }]) + writer.close() + throw new Error('disk refused') + }) + await expect(store.beginProfileMaintenance()).rejects.toThrow('disk refused') + await expect(store.runDurableMutation(() => ({ value: undefined }))).rejects.toThrow( + 'finalized' + ) + expect(readState().peer).toEqual({ preserved: true }) + }) + + it('does not extend the maintenance checkpoint for unadmitted saves', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + store.updateSettings({ theme: 'dark' }) + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const write = authority.writeCompleteSerializedDomains.bind(authority) + const checkpoint = vi + .spyOn(authority, 'writeCompleteSerializedDomains') + .mockImplementationOnce(async (domains) => { + started.resolve() + await release.promise + await write(domains) + }) + const pending = store.beginProfileMaintenance() + await started.promise + for (let terminalFontSize = 12; terminalFontSize < 32; terminalFontSize++) { + store.updateSettings({ terminalFontSize }) + } + release.resolve() + const maintenance = await pending + expect(checkpoint).toHaveBeenCalledOnce() + await maintenance.resume() + await store.flushPendingOrThrowAsync() + expect(readState().settings.terminalFontSize).toBe(31) + }) +}) diff --git a/src/main/persistence/loading-store/profile-state-maintenance.test.ts b/src/main/persistence/loading-store/profile-state-maintenance.test.ts new file mode 100644 index 00000000000..62b785ce7f9 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-maintenance.test.ts @@ -0,0 +1,301 @@ +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { readFileSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import { ActiveViewPreference, getActiveViewPreferenceFile } from '../../active-view-preference' +import * as backupWorker from '../profile-state/profile-state-backup-worker' +import { profileStateDatabaseBackups } from '../profile-state/profile-state-backup-path' +import { + createSqliteMaintenanceFixture, + createWorkerMaintenanceFixture, + maintenanceBarrier +} from './profile-state-maintenance-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +describe('profile maintenance admission', () => { + it('drains accepted writes and captures newer edits after blocking new durable operations', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const gate = maintenanceBarrier() + const started = maintenanceBarrier() + const write = authority.writeSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async (domains) => { + started.resolve() + await gate.promise + await write(domains) + }) + const accepted = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: 'accepted' } + }) + await started.promise + const stopped = vi.spyOn(authority, 'close') + const paused = store.beginProfileMaintenance() + const refused = vi.fn(() => ({ value: undefined })) + await expect(store.runDurableMutation(refused)).rejects.toThrow('finalized') + expect(refused).not.toHaveBeenCalled() + await expect(store.flushPendingOrThrowAsync()).rejects.toThrow('finalized') + await expect(store.writeLatestProfileStateJsonExportAsync()).rejects.toThrow('finalized') + expect(() => store.stageWorkspaceSessionBeforeUnload(store.getWorkspaceSession())).toThrow( + 'maintenance' + ) + store.updateSettings({ theme: 'light' }) + store.getWorkspaceSession().activeTabId = 'during-maintenance' + expect(stopped).not.toHaveBeenCalled() + gate.resolve() + await expect(accepted).resolves.toBe('accepted') + const maintenance = await paused + expect(stopped).toHaveBeenCalledOnce() + expect(readState()).toMatchObject({ + settings: { theme: 'light' }, + workspaceSession: { activeTabId: 'during-maintenance' } + }) + store.updateSettings({ theme: 'dark' }) + await maintenance.resume() + await store.flushPendingOrThrowAsync() + expect(readState().settings.theme).toBe('dark') + await expect(maintenance.resume()).rejects.toThrow('finalization') + }) + + it('refuses re-admission after a competing write without adopting its revision', async () => { + const { store, peer, readState } = await createWorkerMaintenanceFixture() + store.updateSettings({ theme: 'dark' }) + const maintenance = await store.beginProfileMaintenance() + const writer = peer() + try { + writer.readSerializedState() + writer.writeSerializedDomains([{ domain: 'peer', payload: '{"preserved":true}' }]) + } finally { + writer.close() + } + await expect(maintenance.resume()).rejects.toThrow('Profile state revision changed') + const mutate = vi.fn(() => ({ value: undefined })) + await expect(store.runDurableMutation(mutate)).rejects.toThrow('finalized') + expect(mutate).not.toHaveBeenCalled() + expect(readState().peer).toEqual({ preserved: true }) + }) + + it.each(['maintenance', 'freeze', 'final'] as const)( + '%s waits for an admitted flush between SQL passes', + async (kind) => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + vi.spyOn(ActiveViewPreference.prototype, 'flushPendingAsync').mockImplementationOnce( + async () => { + started.resolve() + await release.promise + } + ) + store.updateSettings({ theme: 'dark' }) + const older = store.flushPendingOrThrowAsync() + await started.promise + store.updateSettings({ theme: 'light' }) + const capture = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const close = vi.spyOn(authority, 'close') + const paused = + kind === 'maintenance' + ? store.beginProfileMaintenance() + : kind === 'freeze' + ? store.freezeWritesAsync() + : store.flushFinalOrThrowAsync() + await new Promise((resolve) => setImmediate(resolve)) + expect(capture).not.toHaveBeenCalled() + expect(close).not.toHaveBeenCalled() + release.resolve() + await older + await paused + expect(close).toHaveBeenCalled() + expect(readState().settings.theme).toBe('light') + } + ) + + it.each(['maintenance', 'freeze', 'final'] as const)( + '%s waits for accepted retries after another flush reports a known failure', + async (kind) => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const started = maintenanceBarrier() + const release = maintenanceBarrier() + vi.spyOn(ActiveViewPreference.prototype, 'flushPendingAsync').mockImplementationOnce( + async () => { + started.resolve() + await release.promise + } + ) + store.updateSettings({ theme: 'dark' }) + const accepted = store.flushPendingOrThrowAsync() + await started.promise + store.updateSettings({ theme: 'light' }) + const failureStarted = maintenanceBarrier() + const fail = maintenanceBarrier() + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async () => { + failureStarted.resolve() + await fail.promise + throw new Error('disk refused') + }) + const failed = expect( + store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + ).rejects.toThrow('disk refused') + await failureStarted.promise + const close = vi.spyOn(authority, 'close') + const paused = + kind === 'maintenance' + ? store.beginProfileMaintenance() + : kind === 'freeze' + ? store.freezeWritesAsync() + : store.flushFinalOrThrowAsync() + fail.resolve() + await failed + await new Promise((resolve) => setImmediate(resolve)) + expect(close).not.toHaveBeenCalled() + release.resolve() + await accepted + await paused + expect(close).toHaveBeenCalledOnce() + expect(readState().settings.theme).toBe('light') + } + ) + + it('cancels a backup and waits for its worker to exit before releasing maintenance', async () => { + const { store, authority, databaseFile } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const canceled = maintenanceBarrier() + const release = maintenanceBarrier() + vi.spyOn(backupWorker, 'runProfileStateBackupWorker').mockImplementationOnce( + async (_job, options) => { + started.resolve() + await new Promise((resolve) => + options?.signal?.addEventListener('abort', () => resolve(), { once: true }) + ) + canceled.resolve() + await release.promise + throw new Error('backup aborted') + } + ) + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await started.promise + const close = vi.spyOn(authority, 'close') + const paused = store.beginProfileMaintenance() + let done = false + void paused.then(() => { + done = true + }) + await canceled.promise + expect(done).toBe(false) + expect(close).not.toHaveBeenCalled() + release.resolve() + await paused + expect(close).toHaveBeenCalledOnce() + expect(profileStateDatabaseBackups(databaseFile)).toHaveLength(0) + }) + + it('drains an accepted flush before rejecting canceled maintenance', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + vi.spyOn(ActiveViewPreference.prototype, 'flushPendingAsync').mockImplementationOnce( + async () => { + started.resolve() + await release.promise + } + ) + store.updateSettings({ theme: 'dark' }) + const accepted = store.flushPendingOrThrowAsync() + await started.promise + store.updateSettings({ theme: 'light' }) + const close = vi.spyOn(authority, 'close') + const controller = new AbortController() + controller.abort() + const rejected = expect( + store.beginProfileMaintenance({ signal: controller.signal }) + ).rejects.toThrow('aborted') + await new Promise((resolve) => setImmediate(resolve)) + expect(close).not.toHaveBeenCalled() + release.resolve() + await accepted + await rejected + expect(close).toHaveBeenCalledOnce() + expect(readState().settings.theme).toBe('light') + }) + + it('joins an ongoing maintenance close at final shutdown without reopening or rewriting', async () => { + const { store, authority } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const close = authority.close.bind(authority) + vi.spyOn(authority, 'close').mockImplementationOnce(async () => { + started.resolve() + await release.promise + await close() + }) + const paused = store.beginProfileMaintenance() + await started.promise + const write = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const final = store.flushFinalOrThrowAsync() + release.resolve() + const maintenance = await paused + await final + expect(write).not.toHaveBeenCalled() + await expect(maintenance.resume()).rejects.toThrow('finalization') + }) + + it('quarantines a faulted worker only after closing it, without writing current memory', async () => { + const { store, authority, directory, readState } = await createWorkerMaintenanceFixture() + const durable = readState() + store.updateSettings({ theme: durable.settings.theme === 'dark' ? 'light' : 'dark' }) + await authority.abort() + const result = await store.quarantineProfileStateDatabaseAsync(directory, 'worker-failure') + expect(result.copiedFiles.some((path) => path.endsWith('profile-state.db'))).toBe(true) + expect(readState()).toEqual(durable) + await expect(store.flushPendingOrThrowAsync()).rejects.toThrow('finalized') + }) + + it('never provides a resume token after a faulted normal-maintenance attempt', async () => { + const { store, authority } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await authority.abort() + await expect(store.beginProfileMaintenance()).rejects.toThrow('aborted') + await expect(store.beginProfileMaintenance()).rejects.toThrow('already stopped') + }) + + it('pauses SQLite and preference timers, then persists edits after unchanged-source resume', async () => { + const { store, dataFile, authority } = createSqliteMaintenanceFixture() + const maintenance = await store.beginProfileMaintenance() + const before = authority.readSerializedState() + const preference = getActiveViewPreferenceFile(dataFile) + const preferenceBefore = readFileSync(preference) + vi.useFakeTimers() + store.updateSettings({ theme: 'dark' }) + store.updateUI({ activeView: 'settings' }) + await vi.advanceTimersByTimeAsync(6_000) + expect(authority.readSerializedState()).toEqual(before) + expect(readFileSync(preference)).toEqual(preferenceBefore) + vi.useRealTimers() + await maintenance.resume() + await store.flushPendingOrThrowAsync() + expect(JSON.parse(authority.readSerializedState() ?? '{}').settings.theme).toBe('dark') + expect(JSON.parse(readFileSync(preference, 'utf8')).activeView).toBe('settings') + }) + + it('refuses synchronous SQL resume if the source changed during maintenance', async () => { + const { store, databaseFile, profileId } = createSqliteMaintenanceFixture() + const maintenance = await store.beginProfileMaintenance() + const peer = new ProfileStateSqliteAuthority(databaseFile, profileId) + peer.readSerializedState() + peer.writeSerializedDomains([{ domain: 'peer', payload: 'true' }]) + peer.close() + await expect(maintenance.resume()).rejects.toThrow('Profile state revision changed') + await expect(store.flushPendingOrThrowAsync()).rejects.toThrow('finalized') + expect(JSON.parse(peer.readSerializedState() ?? '{}').peer).toBe(true) + }) +}) diff --git a/src/main/persistence/loading-store/profile-state-maintenance.ts b/src/main/persistence/loading-store/profile-state-maintenance.ts new file mode 100644 index 00000000000..fe1c0f79d80 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-maintenance.ts @@ -0,0 +1,172 @@ +import { profileStateWriterFailureOutcome } from '../profile-state/profile-state-writer-errors' +import type { ProfileStateMaintenance } from './profile-state-authority' +import type { StoreDomains } from './store-domain-composition' +import type { StoreRuntimeState } from './store-runtime-state' +import { drainProfileStateOperations } from './profile-state-flush-lifetime' +import { flushCurrentStateAsync } from './write-flush-barriers' +import { scheduleSave } from './write-scheduling' + +export type ProfileStateMaintenanceOptions = { + signal?: AbortSignal + /** Recovery must preserve the existing database even when its state cannot be flushed. */ + flush?: boolean +} + +export function freezeProfileStateWrites(runtime: StoreRuntimeState): void { + if (runtime.profileStateAuthority?.asynchronous) { + throw new Error('Live profile persistence requires an awaited close') + } + runtime.writesFrozen = true + if (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) + runtime.writeTimer = null + } + runtime.profileStateAuthority?.close?.() +} + +export async function freezeProfileStateWritesAsync(runtime: StoreRuntimeState): Promise { + runtime.quitFlushStarted = true + if (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) + runtime.writeTimer = null + } + try { + await drainProfileStateOperations([ + runtime.pendingProfileMaintenance, + runtime.activeViewPreference.flushAsync(), + drainProfileFileWork(runtime) + ]) + } finally { + runtime.writesFrozen = true + await runtime.profileStateAuthority?.close?.() + } +} + +/** Stop admission before the first await; only unchanged-source maintenance may resume. */ +export function beginProfileStateMaintenance( + runtime: StoreRuntimeState, + domains: StoreDomains, + options: ProfileStateMaintenanceOptions = {} +): Promise { + if (runtime.profileMaintenancePending || runtime.quitFlushStarted || runtime.writesFrozen) { + return Promise.reject(new Error('Profile persistence is already stopped for maintenance')) + } + runtime.profileMaintenancePending = true + if (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) + runtime.writeTimer = null + } + const resumePreference = runtime.activeViewPreference.pauseForMaintenance() + const resumeScheduling = () => { + runtime.writesFrozen = false + runtime.profileMaintenancePending = false + runtime.pendingProfileMaintenance = null + resumePreference() + scheduleSave(domains.scheduling) + } + const paused = pauseProfileState(runtime, domains, options).then((authority) => { + let consumed = false + return { + resume: async () => { + if (consumed || runtime.quitFlushStarted || !runtime.profileMaintenancePending) { + throw new Error('Profile persistence cannot resume after finalization') + } + consumed = true + await authority.resume() + if (runtime.quitFlushStarted) { + await runtime.profileStateAuthority?.close?.() + throw new Error('Profile persistence finalized during maintenance admission') + } + resumeScheduling() + } + } + }) + const recoverable = paused.catch(async (error: unknown) => { + if (await canResumeFailedMaintenance(runtime, options, error)) { + resumeScheduling() + } else { + runtime.writesFrozen = true + await runtime.profileStateAuthority?.close?.() + } + throw error + }) + runtime.pendingProfileMaintenance = recoverable.then(() => {}) + void runtime.pendingProfileMaintenance.catch(() => {}) + return recoverable +} + +async function canResumeFailedMaintenance( + runtime: StoreRuntimeState, + options: ProfileStateMaintenanceOptions, + error: unknown +): Promise { + try { + await drainProfileFileWork(runtime) + if ( + options.flush === false || + runtime.writesFrozen || + profileStateWriterFailureOutcome(error) === 'indeterminate' + ) { + return false + } + const authority = runtime.profileStateAuthority + if (authority?.asynchronous) { + authority.assertWritable() + } + await (authority?.pauseForMaintenance + ? (await authority.pauseForMaintenance()).resume() + : authority?.assertCurrentRevision?.()) + return true + } catch { + return false + } +} + +async function pauseProfileState( + runtime: StoreRuntimeState, + domains: StoreDomains, + { signal, flush = true }: ProfileStateMaintenanceOptions +): Promise { + const authority = runtime.profileStateAuthority + signal?.throwIfAborted() + await drainProfileFileWork(runtime) + signal?.throwIfAborted() + if (flush) { + // Cancel between commands so a dispatched commit retains a known outcome. + await flushCurrentStateAsync(domains.flushBarriers, { + requireInitialGenerationDurable: true, + fullCheckpoint: true + }) + signal?.throwIfAborted() + await authority?.writeJsonCompatibilityExportAsync?.(runtime.dataFile) + } + signal?.throwIfAborted() + runtime.writesFrozen = true + if (!flush) { + await authority?.close?.() + return { + resume: async () => { + throw new Error('Recovery maintenance requires reloading the profile') + } + } + } + if (authority?.pauseForMaintenance) { + return authority.pauseForMaintenance() + } + await authority?.close?.() + throw new Error('Profile authority cannot safely resume from maintenance') +} + +async function drainProfileFileWork(runtime: StoreRuntimeState): Promise { + await drainProfileStateOperations([ + ...runtime.pendingProfileFlushes, + runtime.staleProfileStateTempCleanup, + runtime.pendingWrite, + runtime.pendingSnapshotFileWork, + runtime.pendingGithubCacheWrite, + runtime.activeViewPreference.waitForPendingWrite(), + runtime.profileStateAuthority?.drainBackups?.( + runtime.profileMaintenancePending || runtime.quitFlushStarted + ) + ]) +} diff --git a/src/main/persistence/loading-store/profile-state-pty-retirement-finalization.test.ts b/src/main/persistence/loading-store/profile-state-pty-retirement-finalization.test.ts new file mode 100644 index 00000000000..3fe64562997 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-pty-retirement-finalization.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it, vi } from 'vitest' +import { toSshExecutionHostId } from '../../../shared/execution-host' +import { retirePersistedStablePaneOwner } from '../../ipc/pty/pane/stable-owner' +import { TEST_LEAF_1 } from '../../persistence-session-fixtures' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from './profile-state-maintenance-fixture' +import type { Store } from './store' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'finalizing-retirement-tab', + leafId: TEST_LEAF_1, + ptyId: 'finalizing-retirement-pty', + incarnationId: 'finalizing-retirement-incarnation' +} + +function retire(store: Store, connectionId?: string) { + return retirePersistedStablePaneOwner( + store, + { ...binding, persistedIncarnationId: binding.incarnationId }, + binding.worktreeId, + connectionId + ) +} + +function assertNewSnapshotsRefused(store: Store) { + const session = store.getWorkspaceSession() + expect(() => store.setWorkspaceSession(session)).toThrow('blocking new terminal snapshot work') + expect(() => store.stageWorkspaceSessionBeforeUnload(session)).toThrow( + 'blocking new terminal snapshot work' + ) +} + +describe.each([ + ['running', undefined], + ['maintenance', undefined], + ['freeze', undefined], + ['final', undefined], + ['running', 'retirement-ssh'], + ['maintenance', 'retirement-ssh'], + ['freeze', 'retirement-ssh'], + ['final', 'retirement-ssh'] +] as const)('admitted terminal retirement during %s on host %s', (kind, connectionId) => { + const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined + const persistedSession = ( + readState: Awaited>['readState'] + ) => { + const state = readState() + return hostId ? state.workspaceSessionsByHostId[hostId] : state.workspaceSession + } + const stop = (store: Store) => + kind === 'maintenance' + ? store.beginProfileMaintenance() + : kind === 'freeze' + ? store.freezeWritesAsync() + : kind === 'final' + ? store.flushFinalOrThrowAsync() + : Promise.resolve() + const assertSnapshotAdmission = (store: Store) => { + if (kind !== 'running') { + assertNewSnapshotsRefused(store) + } + } + + it('persists an accepted queued retirement while refusing new snapshots', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await store.persistPtyBinding(binding, hostId) + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const write = authority.writeSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async (domains) => { + started.resolve() + await release.promise + await write(domains) + }) + store.updateSettings({ theme: 'dark' }) + const previous = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await started.promise + const accepted = retire(store, connectionId).then( + (value) => ({ value }), + (error: unknown) => ({ error }) + ) + const stopping = stop(store) + assertSnapshotAdmission(store) + release.resolve() + await previous + await stopping + expect(await accepted).toEqual({ value: true }) + expect(persistedSession(readState).terminalLayoutsByTabId[binding.tabId]).toBeUndefined() + assertSnapshotAdmission(store) + }) + + it('finishes a failed retirement rollback before closing its writer', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await store.persistPtyBinding(binding, hostId) + const original = structuredClone(store.getWorkspaceSession(hostId)) + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const failure = new ProfileStateWriterError( + 'test-disk-failure', + 'retirement disk refused', + 'known-failure' + ) + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async () => { + started.resolve() + await release.promise + throw failure + }) + const rejected = retire(store, connectionId).catch((error: unknown) => error) + await started.promise + expect(store.getWorkspaceSession(hostId).terminalLayoutsByTabId[binding.tabId]).toBeUndefined() + store.getWorkspaceSession(hostId).activeTabId = 'newer-active-tab' + const stopping = stop(store) + assertSnapshotAdmission(store) + release.resolve() + await stopping + expect(await rejected).toBe(failure) + expect(store.getWorkspaceSession(hostId)).toEqual({ + ...original, + activeTabId: 'newer-active-tab' + }) + expect(persistedSession(readState).terminalLayoutsByTabId[binding.tabId]).toEqual( + original.terminalLayoutsByTabId[binding.tabId] + ) + assertSnapshotAdmission(store) + }) +}) + +describe.each(['mutate', 'rollback'] as const)('admitted %s scope', (phase) => { + it('preserves durable state and refuses later saves after a callback partially mutates then throws', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const original = readState() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const failure = new Error('callback failed') + if (phase === 'rollback') { + vi.spyOn(authority, 'writeSerializedDomains').mockRejectedValueOnce(new Error('disk refused')) + } + await expect( + store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + if (phase === 'mutate') { + throw failure + } + return { + value: undefined, + rollback: () => { + throw failure + } + } + }) + ).rejects.toBe(failure) + assertNewSnapshotsRefused(store) + await expect(store.runDurableMutation(() => ({ value: undefined }))).rejects.toThrow( + 'finalized' + ) + await expect(store.flushFinalOrThrowAsync()).rejects.toBe(failure) + expect(readState()).toEqual(original) + }) +}) + +it('rejects an already admitted final flush after a queued callback partially mutates then throws', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const write = authority.writeSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async (domains) => { + started.resolve() + await release.promise + await write(domains) + }) + store.updateSettings({ theme: 'dark' }) + const previous = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await started.promise + const failure = new Error('partial edit failed') + const mutation = store.runDurableMutation(() => { + store.updateSettings({ theme: 'light' }) + throw failure + }) + const rejectedMutation = expect(mutation).rejects.toBe(failure) + const rejectedFinal = expect(store.flushFinalOrThrowAsync()).rejects.toBe(failure) + release.resolve() + await Promise.all([previous, rejectedMutation, rejectedFinal]) + expect(readState().settings.theme).toBe('dark') +}) diff --git a/src/main/persistence/loading-store/profile-state-selective-serialization.test.ts b/src/main/persistence/loading-store/profile-state-selective-serialization.test.ts new file mode 100644 index 00000000000..acb8c0f3105 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-selective-serialization.test.ts @@ -0,0 +1,272 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultPersistedState } from '../../../shared/constants' +import { ProtectedSecretPersistence } from '../../protected-secret-persistence' +import { serializeSelectiveProfileStateDomains } from './profile-state-authority-writes' +import { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' + +function previousReplacements(state: Record, domains: ReadonlySet) { + const parsed: unknown = JSON.parse(Buffer.from(JSON.stringify(state), 'utf8').toString('utf8')) + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Profile state payload must be a JSON object') + } + const entries = new Map(Object.entries(parsed)) + return [...domains].map((domain) => ({ + domain, + payload: entries.has(domain) ? JSON.stringify(entries.get(domain)) : null + })) +} + +afterEach(() => vi.restoreAllMocks()) + +describe('selective profile domain serialization', () => { + it('preserves deletion, JSON null, primitive roots, and escaped property values', () => { + const escapedDomain = '雪"\\\ud800' + const state = Object.fromEntries([ + ['undefined', undefined], + ['function', () => 'omitted'], + ['symbol', Symbol('omitted')], + ['null', null], + ['string', '雪😀\ud800\n"\\'], + ['number', -0], + ['nan', Number.NaN], + ['infinity', Infinity], + ['false', false], + ['array', [undefined, null, Number.NaN, Symbol('omitted'), () => 'omitted']], + ['date', new Date('2026-09-26T00:00:00Z')], + ['__proto__', { own: true }], + [escapedDomain, { '2': 'two', '1': 'one', omitted: undefined }] + ]) + const domains = new Set([...Object.keys(state).toReversed(), 'missing', 'constructor']) + + expect(serializeSelectiveProfileStateDomains(state, domains)).toEqual( + previousReplacements(state, domains) + ) + expect(serializeSelectiveProfileStateDomains(state, new Set(['undefined', 'null']))).toEqual([ + { domain: 'undefined', payload: null }, + { domain: 'null', payload: 'null' } + ]) + }) + + it('passes the same keys and receiver to toJSON exactly once in state property order', () => { + const calls: string[] = [] + const first = { + text: 'before', + toJSON(key: string) { + expect(this).toBe(first) + calls.push(key) + second.text = 'after' + return { + text: this.text, + child: { toJSON: (childKey: string) => childKey } + } + } + } + const second = { + text: 'before', + toJSON(key: string) { + expect(this).toBe(second) + calls.push(key) + return this.text + } + } + const omitted = { toJSON: (key: string) => void calls.push(key) } + const state = { first, second, omitted } + const domains = new Set(['second', 'omitted', 'first']) + const expected = previousReplacements(state, domains) + calls.length = 0 + second.text = 'before' + + expect(serializeSelectiveProfileStateDomains(state, domains)).toEqual(expected) + expect(calls).toEqual(['first', 'second', 'omitted']) + }) + + it('still rejects cyclic and BigInt state before handing anything to the authority', () => { + const cyclic: Record = {} + cyclic.self = cyclic + for (const value of [cyclic, 1n]) { + expect(() => + serializeSelectiveProfileStateDomains({ session: value }, new Set(['session'])) + ).toThrow(TypeError) + } + }) + + it('reads later domains after earlier toJSON hooks replace or delete them', () => { + const buildState = () => { + const state: Record = { + first: { + toJSON() { + state.replaced = { text: 'after' } + delete state.deleted + state.added = 'outside the captured keys' + return 'first' + } + }, + replaced: { text: 'before' }, + deleted: 'before' + } + return state + } + const domains = new Set(['deleted', 'replaced', 'first', 'added']) + const expected = previousReplacements(buildState(), domains) + + expect(expected).toEqual([ + { domain: 'deleted', payload: null }, + { domain: 'replaced', payload: '{"text":"after"}' }, + { domain: 'first', payload: '"first"' }, + { domain: 'added', payload: null } + ]) + expect(serializeSelectiveProfileStateDomains(buildState(), domains)).toEqual(expected) + }) + + it('interleaves domain getters and toJSON hooks in JSON property order', () => { + const buildState = (calls: string[]) => { + let text = 'before' + return { + get first() { + calls.push('get first') + return { + toJSON() { + calls.push('serialize first') + text = 'after' + return 'first' + } + } + }, + get second() { + calls.push('get second') + return text + } + } + } + const domains = new Set(['first', 'second']) + const expectedCalls: string[] = [] + const expected = previousReplacements(buildState(expectedCalls), domains) + const actualCalls: string[] = [] + + expect(serializeSelectiveProfileStateDomains(buildState(actualCalls), domains)).toEqual( + expected + ) + expect(actualCalls).toEqual(expectedCalls) + expect(actualCalls).toEqual(['get first', 'serialize first', 'get second']) + }) + + it.each(['1.0', '1e999', '9007199254740993', '"\\u0061"', '"\ud800"', '"\\uD800"'])( + 'normalizes raw JSON from a production domain hook like the previous path: %s', + (raw) => { + if (!('rawJSON' in JSON) || typeof JSON.rawJSON !== 'function') { + throw new Error('This test requires native JSON.rawJSON support') + } + const rawValue: unknown = JSON.rawJSON(raw) + const state = getDefaultPersistedState('/synthetic-profile') + const serialize = vi.fn(() => ({ ...state.workspaceSession, extension: rawValue })) + Object.defineProperty(state.workspaceSession, 'toJSON', { value: serialize }) + const domains = new Set(['workspaceSession']) + const expected = previousReplacements({ workspaceSession: state.workspaceSession }, domains) + serialize.mockClear() + const serialization = new StateSerializationSecretHandlingOperations({ + state, + protectedSecrets: new ProtectedSecretPersistence() + }) + + expect(serialization.buildStateDomainsToSave(domains)?.replacements).toEqual(expected) + expect(serialize).toHaveBeenCalledExactlyOnceWith('workspaceSession') + } + ) + + it('normalizes proxy key order without re-running its getters or toJSON', () => { + const read = vi.fn(() => 'one') + const value = new Proxy( + { + get 1() { + return read() + }, + 2: 'two' + }, + { ownKeys: () => ['2', '1'] } + ) + const serialize = vi.fn(() => value) + const state = { workspaceSession: { child: { toJSON: serialize } } } + const domains = new Set(['workspaceSession']) + const expected = previousReplacements(state, domains) + read.mockClear() + serialize.mockClear() + + expect(serializeSelectiveProfileStateDomains(state, domains)).toEqual(expected) + expect(read).toHaveBeenCalledOnce() + expect(serialize).toHaveBeenCalledExactlyOnceWith('child') + }) + + it('captures production domain references before hooks replace runtime fields', () => { + const state = getDefaultPersistedState('/synthetic-profile') + state.worktreeIdentityAliases = { captured: ['identity'] } + const capturedAliases = state.worktreeIdentityAliases + const capturedAutomations = state.automations + Object.defineProperty(state.workspaceSession, 'toJSON', { + value: () => { + state.automations = [] + delete state.worktreeIdentityAliases + return 'session' + } + }) + const domains = new Set(['workspaceSession', 'automations', 'worktreeIdentityAliases']) + const serialization = new StateSerializationSecretHandlingOperations({ + state, + protectedSecrets: new ProtectedSecretPersistence() + }) + + expect(serialization.buildStateDomainsToSave(domains)?.replacements).toEqual([ + { domain: 'workspaceSession', payload: '"session"' }, + { domain: 'automations', payload: JSON.stringify(capturedAutomations) }, + { domain: 'worktreeIdentityAliases', payload: JSON.stringify(capturedAliases) } + ]) + expect(state.automations).not.toBe(capturedAutomations) + expect(state.worktreeIdentityAliases).toBeUndefined() + }) + + it('serializes each selected domain once without an aggregate parse or UTF-8 buffer', () => { + const state = getDefaultPersistedState('/synthetic-profile') + state.workspaceSession.activeTabId = 'x'.repeat(200_000) + const serialization = new StateSerializationSecretHandlingOperations({ + state, + protectedSecrets: new ProtectedSecretPersistence() + }) + const domains = new Set(['workspaceSession', 'automations', 'worktreeIdentityAliases']) + const expected = previousReplacements( + { + workspaceSession: state.workspaceSession, + automations: state.automations + }, + domains + ) + const stringify = vi.spyOn(JSON, 'stringify') + const parse = vi.spyOn(JSON, 'parse') + const encode = vi.spyOn(Buffer, 'from') + + const built = serialization.buildStateDomainsToSave(domains) + const stringifiedObjects = stringify.mock.calls + .map(([value]) => value) + .filter((value) => value !== null && typeof value === 'object') + const parseCount = parse.mock.calls.length + const encodeCount = encode.mock.calls.length + vi.restoreAllMocks() + + expect(built?.replacements).toEqual(expected) + expect(stringifiedObjects.map((value) => Object.keys(value))).toEqual([ + ['workspaceSession'], + ['automations'] + ]) + expect(parseCount).toBe(0) + expect(encodeCount).toBe(0) + }) + + it('retains the full-write fallback for unknown domains or pending secret encryption', () => { + const protectedSecrets = new ProtectedSecretPersistence() + const serialization = new StateSerializationSecretHandlingOperations({ + state: getDefaultPersistedState('/synthetic-profile'), + protectedSecrets + }) + expect(serialization.buildStateDomainsToSave(new Set(['future-domain']))).toBeUndefined() + vi.spyOn(protectedSecrets, 'hasPendingEncryption').mockReturnValue(true) + expect(serialization.buildStateDomainsToSave(new Set(['workspaceSession']))).toBeUndefined() + }) +}) diff --git a/src/main/persistence/loading-store/profile-state-selective-write.ts b/src/main/persistence/loading-store/profile-state-selective-write.ts new file mode 100644 index 00000000000..286626c33c2 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-selective-write.ts @@ -0,0 +1,98 @@ +import type { ProtectedSecretRetentionUpdate } from '../../protected-secret-persistence' +import type { + ProfileStateAuthority, + ProfileStateDomainReplacement, + ProfileStatePersistenceAuthority +} from './profile-state-authority' +import type { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' +import type { AutomationRun } from '../../../shared/automations-types' + +export type SelectiveProfileStateWriteResult = { + handled: boolean + aborted: boolean + consumedAutomationRuns: boolean + protectedSecretUpdates: ProtectedSecretRetentionUpdate[] +} + +export type PreparedSelectiveProfileStateWrite = { + replacements: ProfileStateDomainReplacement[] + automationRuns: readonly AutomationRun[] | undefined + consumedAutomationRuns: boolean + protectedSecretUpdates: ProtectedSecretRetentionUpdate[] +} + +export function writeSelectiveProfileState( + authority: ProfileStateAuthority | undefined, + serialization: StateSerializationSecretHandlingOperations, + dirtyDomains: Set | null, + pendingAutomationRunsAfter: readonly AutomationRun[] | undefined, + isCurrent?: () => boolean +): SelectiveProfileStateWriteResult { + const prepared = prepareSelectiveProfileStateWrite( + authority, + serialization, + dirtyDomains, + pendingAutomationRunsAfter + ) + if (!prepared) { + return { + handled: false, + aborted: false, + consumedAutomationRuns: false, + protectedSecretUpdates: [] + } + } + if (isCurrent && !isCurrent()) { + return { + handled: true, + aborted: true, + consumedAutomationRuns: false, + protectedSecretUpdates: [] + } + } + if (prepared.automationRuns !== undefined) { + authority?.writeSerializedAutomationRuns?.(prepared.replacements, prepared.automationRuns) + } else { + authority?.writeSerializedDomains?.(prepared.replacements) + } + dirtyDomains?.clear() + return { + handled: true, + aborted: false, + consumedAutomationRuns: prepared.consumedAutomationRuns, + protectedSecretUpdates: prepared.protectedSecretUpdates + } +} + +export function prepareSelectiveProfileStateWrite( + authority: ProfileStatePersistenceAuthority | undefined, + serialization: StateSerializationSecretHandlingOperations, + dirtyDomains: ReadonlySet | null, + pendingAutomationRunsAfter: readonly AutomationRun[] | undefined +): PreparedSelectiveProfileStateWrite | undefined { + if ( + !authority || + dirtyDomains === null || + dirtyDomains.size === 0 || + !authority.writeSerializedDomains + ) { + return undefined + } + const useAutomationDelta = + pendingAutomationRunsAfter !== undefined && + authority.writeSerializedAutomationRuns !== undefined + const serializableDomains = useAutomationDelta + ? new Set([...dirtyDomains].filter((domain) => domain !== 'automationRuns')) + : dirtyDomains + const built = serialization.buildStateDomainsToSave(serializableDomains) + if (built === undefined) { + return undefined + } + const { replacements, protectedSecretUpdates } = built + return { + replacements, + automationRuns: useAutomationDelta ? pendingAutomationRunsAfter : undefined, + consumedAutomationRuns: pendingAutomationRunsAfter !== undefined, + protectedSecretUpdates + } +} diff --git a/src/main/persistence/loading-store/profile-state-settings-writes.test.ts b/src/main/persistence/loading-store/profile-state-settings-writes.test.ts new file mode 100644 index 00000000000..e7f64b6278a --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-settings-writes.test.ts @@ -0,0 +1,320 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultPersistedState } from '../../../shared/constants' +import { setSecretStore } from '../../../shared/secret-store' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import { openProfileStateDatabaseReadOnly } from '../profile-state/profile-state-database' +import { readProfileStateSnapshot } from '../profile-state/profile-state-documents' +import { parseProfileStateRoot } from '../profile-state/profile-state-document-validation' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { Store } from './store' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const ORIGINAL = { + opencodeSessionCookie: 'original-cookie', + httpProxyUrl: 'http://original:password@proxy.test:8080' +} +type Failure = 'none' | 'unavailable' | 'availability' | 'encryption' | 'decryption' +let failure: Failure = 'none' +let nonce = 0 +const directories: string[] = [] +const stores: Store[] = [] + +beforeEach(() => { + failure = 'none' + nonce = 0 + setSecretStore({ + isEncryptionAvailable: () => { + if (failure === 'availability') { + throw new Error('keychain unavailable') + } + return failure !== 'unavailable' + }, + encryptString: (plaintext) => { + if (failure === 'encryption') { + throw new Error('encryption failed') + } + return Buffer.from(`cipher:${++nonce}:${plaintext}`) + }, + decryptString: (ciphertext) => { + if (failure === 'decryption') { + throw new Error('decryption failed') + } + const value = ciphertext.toString() + if (!value.startsWith('cipher:')) { + throw new Error('invalid ciphertext') + } + return value.slice(value.indexOf(':', 'cipher:'.length) + 1) + }, + describeProtectionGap: () => null + }) + vi.spyOn(ProfileStateSqliteAuthority.prototype, 'scheduleBackup').mockImplementation(() => {}) +}) + +afterEach(async () => { + for (const store of stores.splice(0)) { + store.freezeWrites() + await store.flushAsync() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-settings-domain-')) + directories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const dataFile = join(directory, 'orca-data.json') + const profileId = 'settings-domain' + const authority = new ProfileStateSqliteAuthority(databasePath, profileId) + const fixtureState = buildProfileStateCutoverFixture(directory) + authority.writeSerializedState( + Buffer.from( + JSON.stringify({ + ...getDefaultPersistedState(directory), + automationRuns: fixtureState.automationRuns, + futureTopLevelExtension: fixtureState.futureTopLevelExtension + }) + ) + ) + const createStore = (storage = new ProfileStateSqliteAuthority(databasePath, profileId)) => { + const store = new Store({ dataFile, profileStateAuthority: storage }) + stores.push(store) + return store + } + const store = createStore(authority) + store.updateSettings(ORIGINAL) + store.flushOrThrow() + const read = () => { + const opened = openProfileStateDatabaseReadOnly(databasePath, profileId) + try { + return { + state: parseProfileStateRoot(readProfileStateSnapshot(opened.db).json), + otherDocuments: opened.db + .prepare( + "SELECT * FROM profile_state_documents WHERE domain <> 'settings' ORDER BY rowid" + ) + .all(), + runs: opened.db.prepare('SELECT * FROM profile_state_automation_runs ORDER BY run_id').all() + } + } finally { + opened.db.close() + } + } + return { store, authority, read, reopen: () => createStore() } +} + +describe('selective SQLite settings persistence', () => { + it.each(['sync', 'async'] as const)( + 'saves settings through the %s barrier without rewriting history or unknown domains', + async (mode) => { + const state = fixture() + const before = state.read() + const wholeWrite = vi.spyOn(state.authority, 'writeCompleteSerializedDomains') + state.store.updateSettings({ terminalFontSize: 19, httpProxyBypassRules: '*.internal' }) + if (mode === 'sync') { + state.store.flushOrThrow() + } else { + await state.store.flushPendingOrThrowAsync() + } + expect(wholeWrite).not.toHaveBeenCalled() + const after = state.read() + expect(after.otherDocuments).toEqual(before.otherDocuments) + expect(after.runs).toEqual(before.runs) + expect(after.state).toMatchObject({ + settings: { terminalFontSize: 19, httpProxyBypassRules: '*.internal' }, + futureTopLevelExtension: before.state.futureTopLevelExtension + }) + expect(JSON.stringify(after.state)).not.toContain(ORIGINAL.opencodeSessionCookie) + expect(JSON.stringify(after.state)).not.toContain(ORIGINAL.httpProxyUrl) + expect(state.reopen().getSettings()).toMatchObject({ ...ORIGINAL, terminalFontSize: 19 }) + } + ) + + it.each(['unavailable', 'availability', 'encryption'] as const)( + 'retains committed secrets while %s and retries them after recovery', + async (mode) => { + const state = fixture() + const before = state.read() + failure = mode + state.store.updateSettings({ opencodeSessionCookie: 'pending-cookie', terminalFontSize: 18 }) + await state.store.flushPendingOrThrowAsync() + expect(state.read().state.settings).toEqual({ + ...getSettingsRecord(before.state), + terminalFontSize: 18 + }) + failure = 'none' + state.store.updateSettings({ terminalFontSize: 20 }) + await state.store.flushPendingOrThrowAsync() + expect(state.reopen().getSettings()).toMatchObject({ + ...ORIGINAL, + opencodeSessionCookie: 'pending-cookie', + terminalFontSize: 20 + }) + } + ) + + it('preserves sealed settings on unrelated saves and permits an explicit clear', () => { + const state = fixture() + const before = state.read() + state.store.freezeWrites() + failure = 'decryption' + const sealed = state.reopen() + sealed.flushOrThrow() + expect(sealed.getSettings().opencodeSessionCookie).toBe('') + sealed.updateSettings({ terminalFontSize: 21 }) + sealed.flushOrThrow() + expect(state.read().state.settings).toEqual({ + ...getSettingsRecord(before.state), + terminalFontSize: 21 + }) + sealed.updateSettings({ opencodeSessionCookie: '', httpProxyUrl: '' }) + sealed.flushOrThrow() + failure = 'none' + expect(state.reopen().getSettings()).toMatchObject({ + opencodeSessionCookie: '', + httpProxyUrl: '', + terminalFontSize: 21 + }) + }) + + it('does not retain ciphertext from a failed selective commit', () => { + const state = fixture() + const before = state.read() + vi.spyOn(state.authority, 'writeSerializedDomains').mockImplementationOnce(() => { + throw new Error('commit failed') + }) + state.store.updateSettings({ opencodeSessionCookie: 'uncommitted-cookie' }) + expect(() => state.store.flushOrThrow()).toThrow('commit failed') + expect(state.read()).toEqual(before) + failure = 'unavailable' + state.store.updateSettings({ terminalFontSize: 22 }) + state.store.flushOrThrow() + failure = 'none' + expect(state.reopen().getSettings()).toMatchObject({ ...ORIGINAL, terminalFontSize: 22 }) + }) + + it('commits pending session/settings domains together and falls back for unclassified updates', () => { + const state = fixture() + state.store.patchWorkspaceSession({ activeTabId: 'pending-tab' }) + state.store.updateSettings({ terminalFontSize: 23 }) + state.store.flushOrThrow() + expect(state.read().state).toMatchObject({ + settings: { terminalFontSize: 23 }, + workspaceSession: { activeTabId: 'pending-tab' } + }) + const wholeWrite = vi.spyOn(state.authority, 'writeCompleteSerializedDomains') + state.store.updateSettings({ terminalFontSize: 24 }) + state.store.updateOnboarding({ outcome: 'completed' }) + state.store.flushOrThrow() + expect(wholeWrite).toHaveBeenCalledOnce() + expect(state.read().state).toMatchObject({ + settings: { terminalFontSize: 24 }, + onboarding: { outcome: 'completed' } + }) + }) + + it.each(['unavailable', 'encryption'] as const)( + 'retries deferred UI and SSH secrets on a settings save after %s recovers', + async (mode) => { + const state = fixture() + const recovery = { + targetId: 'ssh-test', + clientInstanceId: 'client-test', + serverBuildId: 'build-test', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'original-lease' + } + state.store.updateUI({ browserKagiSessionLink: 'original-link' }) + await state.store.upsertSshPtyConsumerRecovery(recovery) + const before = state.read().state + + failure = mode + state.store.updateUI({ browserKagiSessionLink: 'pending-link' }) + await state.store.flushPendingOrThrowAsync() + await state.store.upsertSshPtyConsumerRecovery({ ...recovery, ownerLease: 'pending-lease' }) + expect(state.read().state).toMatchObject({ + ui: before.ui, + sshPtyConsumerRecoveries: before.sshPtyConsumerRecoveries + }) + + failure = 'none' + const wholeWrite = vi.spyOn(state.authority, 'writeCompleteSerializedDomains') + wholeWrite.mockImplementationOnce(() => { + throw new Error('recovery commit failed') + }) + state.store.updateSettings({ terminalFontSize: 25 }) + await expect(state.store.flushPendingOrThrowAsync()).rejects.toThrow('recovery commit failed') + expect(state.read().state).toMatchObject({ + ui: before.ui, + sshPtyConsumerRecoveries: before.sshPtyConsumerRecoveries + }) + state.store.updateSettings({ terminalFontSize: 26 }) + await state.store.flushPendingOrThrowAsync() + expect(wholeWrite).toHaveBeenCalledTimes(2) + const reopened = state.reopen() + expect(reopened.getUI().browserKagiSessionLink).toBe('pending-link') + expect(reopened.getSshPtyConsumerRecovery('ssh-test')?.ownerLease).toBe('pending-lease') + expect(JSON.stringify(state.read().state)).not.toMatch(/pending-link|pending-lease/) + + state.store.updateSettings({ terminalFontSize: 27 }) + await state.store.flushPendingOrThrowAsync() + expect(wholeWrite).toHaveBeenCalledTimes(2) + } + ) + + it('keeps an explicit UI secret clear after an unavailable write and later settings save', async () => { + const state = fixture() + state.store.updateUI({ browserKagiSessionLink: 'original-link' }) + await state.store.flushPendingOrThrowAsync() + failure = 'unavailable' + state.store.updateUI({ browserKagiSessionLink: 'pending-link' }) + await state.store.flushPendingOrThrowAsync() + state.store.updateUI({ browserKagiSessionLink: null }) + await state.store.flushPendingOrThrowAsync() + failure = 'none' + state.store.updateSettings({ terminalFontSize: 28 }) + await state.store.flushPendingOrThrowAsync() + expect(state.reopen().getUI().browserKagiSessionLink).toBeNull() + }) + + it.each(['unavailable', 'decryption'] as const)( + 'persists an explicit empty UI secret clear after reopening with %s secrets', + async (mode) => { + const state = fixture() + state.store.updateUI({ browserKagiSessionLink: 'original-link' }) + await state.store.flushPendingOrThrowAsync() + state.store.freezeWrites() + failure = mode + const sealed = state.reopen() + await sealed.flushPendingOrThrowAsync() + expect(sealed.getUI().browserKagiSessionLink).toBe('') + sealed.updateUI({ browserKagiSessionLink: '' }) + sealed.updateSettings({ terminalFontSize: 29 }) + await sealed.flushPendingOrThrowAsync() + failure = 'none' + expect(state.reopen().getUI().browserKagiSessionLink).toBeNull() + } + ) +}) + +function getSettingsRecord(state: Record): Record { + const settings = state.settings + if (typeof settings !== 'object' || settings === null || Array.isArray(settings)) { + throw new Error('Expected persisted settings') + } + return Object.fromEntries(Object.entries(settings)) +} diff --git a/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts b/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts new file mode 100644 index 00000000000..5f679d3128d --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts @@ -0,0 +1,877 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MAX_AUTOMATION_RUNS_PER_AUTOMATION } from '../../../shared/automation-run-retention' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { + profileStateJsonMatchesAcceptance, + exportProfileStateJson, + hashProfileStateJson, + importProfileStateJson, + readProfileStateJsonAcceptance, + readProfileStateSnapshot +} from '../profile-state/profile-state-documents' +import { parseProfileStateRoot } from '../profile-state/profile-state-document-validation' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from '../profile-state/profile-state-database' +import { profileStateJsonExportPath } from '../profile-state/legacy-json/profile-state-export-path' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8').slice('encrypted:'.length) + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(() => ({ hosts: [] })), + sshConfigHostsToTargets: vi.fn(() => []) +})) + +const { Store } = await import('./store') +const { createProfileStateStore } = await import('../profile-state/profile-state-store-factory') + +const temporaryDirectories: string[] = [] +const backupAuthorities = new Set() +const authorities = new Set() +const scheduleBackup = ProfileStateSqliteAuthority.prototype.scheduleBackup + +function createAuthority(databasePath: string, profileId: string): ProfileStateSqliteAuthority { + const authority = new ProfileStateSqliteAuthority(databasePath, profileId) + authorities.add(authority) + return authority +} + +beforeEach(() => { + vi.spyOn(ProfileStateSqliteAuthority.prototype, 'scheduleBackup').mockImplementation(function ( + this: ProfileStateSqliteAuthority + ) { + backupAuthorities.add(this) + scheduleBackup.call(this) + }) +}) + +afterEach(async () => { + for (const authority of authorities) { + authority.close() + await authority.drainBackups() + } + authorities.clear() + backupAuthorities.clear() + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +describe('Store with an injected SQLite profile-state authority', () => { + it('rejects profile-state buffers that are not valid UTF-8', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-invalid-utf8-')) + temporaryDirectories.push(directory) + const authority = createAuthority(join(directory, 'profile-state.db'), 'profile-authority-test') + + authority.writeSerializedState(Buffer.from('{"settings":{"theme":"dark"}}')) + const before = authority.readSerializedState() + const malformed = Buffer.concat([ + Buffer.from('{"settings":{"theme":"'), + Buffer.from([0xff]), + Buffer.from('"}}') + ]) + expect(() => authority.writeSerializedState(malformed)).toThrow( + 'Profile state payload is not valid UTF-8' + ) + expect(authority.readSerializedState()).toBe(before) + authority.close() + }) + + it('mutates, flushes, and reloads without writing the legacy JSON file', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-authority-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databaseFile = join(directory, 'profile-state.db') + writeFileSync(dataFile, '{"settings":{"theme":"light"}}', 'utf8') + const legacyBytes = readFileSync(dataFile) + const authority = createAuthority(databaseFile, 'profile-authority-test') + + const store = new Store({ dataFile, profileStateAuthority: authority }) + store.updateSettings({ theme: 'dark', opencodeSessionCookie: 'authority-secret' }) + store.flushOrThrow() + + store.updateSettings({ terminalFontSize: store.getSettings().terminalFontSize + 1 }) + await store.flushPendingOrThrowAsync() + + expect(readFileSync(dataFile)).toEqual(legacyBytes) + expect(existsSync(databaseFile)).toBe(true) + + const reloaded = new Store({ dataFile, profileStateAuthority: authority }) + expect(reloaded.getSettings().theme).toBe('dark') + expect(reloaded.getSettings().terminalFontSize).toBe(store.getSettings().terminalFontSize) + expect(reloaded.getSettings().opencodeSessionCookie).toBe('authority-secret') + expect(readFileSync(dataFile)).toEqual(legacyBytes) + reloaded.freezeWrites() + }) + + it('rejects a corrupt ordering placeholder when normalized rows exist', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-normalized-read-')) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const fixture = buildProfileStateCutoverFixture() + const authority = createAuthority(databasePath, 'profile-authority-test') + authority.writeSerializedState(Buffer.from(JSON.stringify(fixture))) + authority.close() + + const opened = openProfileStateDatabase(databasePath, 'profile-authority-test') + opened.db + .prepare('UPDATE profile_state_documents SET payload = ? WHERE domain = ?') + .run('{invalid', 'automationRuns') + opened.db.close() + + const runtime = createAuthority(databasePath, 'profile-authority-test') + expect(() => runtime.readSerializedState()).toThrow(/hash mismatch: automationRuns/) + runtime.close() + + const strict = openProfileStateDatabaseReadOnly(databasePath, 'profile-authority-test') + try { + expect(() => readProfileStateSnapshot(strict.db)).toThrow(/hash mismatch: automationRuns/) + } finally { + strict.db.close() + } + }) + + it('rejects invalid domain JSON before handing it to the Store', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-runtime-parse-')) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const authority = createAuthority(databasePath, 'profile-authority-test') + authority.writeSerializedState(Buffer.from(JSON.stringify({ settings: { theme: 'dark' } }))) + authority.close() + + const opened = openProfileStateDatabase(databasePath, 'profile-authority-test') + opened.db + .prepare('UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = ?') + .run('{invalid', hashProfileStateJson('{invalid'), 'settings') + opened.db.close() + + expect( + () => + new Store({ + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: createAuthority(databasePath, 'profile-authority-test') + }) + ).toThrow('Profile state document payload is invalid JSON: settings') + }) + + it('rejects documents whose revision metadata was removed or reset', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-authority-')) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const opened = openProfileStateDatabase(databasePath, 'profile-authority-test') + importProfileStateJson(opened.db, JSON.stringify({ settings: { theme: 'dark' } })) + opened.db.prepare("DELETE FROM profile_state_meta WHERE key = 'revision'").run() + opened.db.close() + + const authority = createAuthority(databasePath, 'profile-authority-test') + expect(() => authority.readSerializedState()).toThrow() + }) + + it('fences a complete-document writer that read before another authority committed', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-authority-cas-')) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const first = createAuthority(databasePath, 'profile-authority-test') + const second = createAuthority(databasePath, 'profile-authority-test') + + first.writeSerializedState(Buffer.from(JSON.stringify({ settings: { theme: 'light' } }))) + expect(second.readSerializedState()).toBe(JSON.stringify({ settings: { theme: 'light' } })) + + first.readSerializedState() + first.writeSerializedState(Buffer.from(JSON.stringify({ settings: { theme: 'dark' } }))) + expect(() => + second.writeSerializedState( + Buffer.from(JSON.stringify({ settings: { theme: 'stale-writer' } })) + ) + ).toThrowError( + expect.objectContaining({ + code: 'profile-state-revision-conflict', + expectedRevision: 1, + actualRevision: 2 + }) + ) + + const verifier = createAuthority(databasePath, 'profile-authority-test') + expect(verifier.readSerializedState()).toBe(JSON.stringify({ settings: { theme: 'dark' } })) + }) + + it('fences a first commit after another authority creates the database', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-authority-create-cas-')) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const first = createAuthority(databasePath, 'profile-authority-test') + const second = createAuthority(databasePath, 'profile-authority-test') + + expect(first.readSerializedState()).toBeUndefined() + second.writeSerializedState(Buffer.from(JSON.stringify({ settings: { theme: 'other' } }))) + + expect(() => + first.writeSerializedState(Buffer.from(JSON.stringify({ settings: { theme: 'stale' } }))) + ).toThrowError( + expect.objectContaining({ + code: 'profile-state-revision-conflict', + expectedRevision: 0, + actualRevision: 1 + }) + ) + }) + + it('keeps normalized rows stable during a complete document replacement', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-complete-write-')) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const fixture = buildProfileStateCutoverFixture(directory) + const authority = createAuthority(databasePath, 'profile-authority-test') + authority.writeSerializedState(Buffer.from(JSON.stringify(fixture))) + + const before = openProfileStateDatabaseReadOnly(databasePath, 'profile-authority-test') + const beforeAutomationMeta = before.db + .prepare( + 'SELECT revision, content_hash FROM profile_state_automation_runs_meta WHERE domain = ?' + ) + .get('automationRuns') + const beforeAutomationRows = before.db + .prepare('SELECT COUNT(*) AS count FROM profile_state_automation_runs') + .get() + before.db.close() + + const replacement = parseProfileStateRoot(authority.readSerializedState() ?? '{}') + replacement.settings = { theme: 'complete-replacement' } + replacement.unknownDomain = { preserved: true } + delete replacement.ui + authority.writeSerializedState(Buffer.from(JSON.stringify(replacement))) + + expect(JSON.parse(authority.readSerializedState() ?? '{}')).toMatchObject({ + settings: { theme: 'complete-replacement' }, + unknownDomain: { preserved: true } + }) + const after = openProfileStateDatabaseReadOnly(databasePath, 'profile-authority-test') + try { + expect( + after.db.prepare('SELECT value FROM profile_state_meta WHERE key = ?').get('revision') + ).toEqual({ value: '2' }) + expect( + after.db + .prepare( + 'SELECT revision, content_hash FROM profile_state_automation_runs_meta WHERE domain = ?' + ) + .get('automationRuns') + ).toEqual(beforeAutomationMeta) + expect( + after.db.prepare('SELECT COUNT(*) AS count FROM profile_state_automation_runs').get() + ).toEqual(beforeAutomationRows) + expect( + after.db.prepare('SELECT 1 FROM profile_state_documents WHERE domain = ?').get('ui') + ).toBe(undefined) + } finally { + after.db.close() + } + }) + + it('reopens its writer after an explicit close without losing the revision fence', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-authority-close-')) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const authority = createAuthority(databasePath, 'profile-authority-test') + + authority.writeSerializedState(Buffer.from(JSON.stringify({ settings: { theme: 'light' } }))) + authority.close() + authority.writeSerializedState(Buffer.from(JSON.stringify({ settings: { theme: 'dark' } }))) + + const verifier = createAuthority(databasePath, 'profile-authority-test') + expect(verifier.readSerializedState()).toBe(JSON.stringify({ settings: { theme: 'dark' } })) + verifier.close() + }) + + it('keeps a newer Store commit when a stale Store flushes afterward', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-store-cas-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databasePath = join(directory, 'profile-state.db') + const seed = createAuthority(databasePath, 'profile-authority-test') + seed.writeSerializedState(Buffer.from(JSON.stringify({ settings: { theme: 'light' } }))) + + const first = new Store({ + dataFile, + profileStateAuthority: createAuthority(databasePath, 'profile-authority-test') + }) + const stale = new Store({ + dataFile, + profileStateAuthority: createAuthority(databasePath, 'profile-authority-test') + }) + const initialTerminalFontSize = first.getSettings().terminalFontSize + first.updateSettings({ theme: 'dark' }) + first.flushOrThrow() + + stale.updateSettings({ terminalFontSize: stale.getSettings().terminalFontSize + 1 }) + expect(() => stale.flushOrThrow()).toThrowError( + expect.objectContaining({ + code: 'profile-state-revision-conflict', + expectedRevision: 1, + actualRevision: 2 + }) + ) + + const verifier = createAuthority(databasePath, 'profile-authority-test') + expect(JSON.parse(verifier.readSerializedState() ?? '{}')).toMatchObject({ + settings: { theme: 'dark', terminalFontSize: initialTerminalFontSize } + }) + }) + + it('writes a local session mutation as dirty domains and preserves unrelated rows', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-domain-write-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databasePath = join(directory, 'profile-state.db') + const seedDataFile = join(directory, 'seed-orca-data.json') + const seedStore = new Store({ + dataFile: seedDataFile, + serializedState: existsSync(seedDataFile) ? readFileSync(seedDataFile, 'utf8') : '{}' + }) + seedStore.updateSettings({ theme: 'light' }) + seedStore.flushOrThrow() + const seed = createAuthority(databasePath, 'profile-authority-test') + seed.writeSerializedState(Buffer.from(seedStore.prepareProfileStateExport().json)) + seedStore.freezeWrites() + + const authority = createAuthority(databasePath, 'profile-authority-test') + const writeDomains = vi.spyOn(authority, 'writeSerializedDomains') + const store = new Store({ dataFile, profileStateAuthority: authority }) + store.flushOrThrow() + store.setWorkspaceSession({ + ...store.getWorkspaceSession(), + activeTabId: 'after' + }) + store.flushOrThrow() + + expect(writeDomains).toHaveBeenCalledTimes(1) + expect(writeDomains.mock.calls[0]?.[0].map(({ domain }) => domain)).toEqual([ + 'workspaceSession' + ]) + const reloaded = new Store({ + dataFile, + profileStateAuthority: createAuthority(databasePath, 'profile-authority-test') + }) + expect(reloaded.getWorkspaceSession().activeTabId).toBe('after') + expect(reloaded.getSettings().theme).toBe('light') + reloaded.freezeWrites() + }) + + it('writes a PTY rebind through the workspace-session domain', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-pty-domain-write-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databasePath = join(directory, 'profile-state.db') + const fixtureFile = join(directory, 'fixture-orca-data.json') + writeFileSync(fixtureFile, JSON.stringify(buildProfileStateCutoverFixture(directory))) + const seedStore = new Store({ + dataFile: fixtureFile, + serializedState: existsSync(fixtureFile) ? readFileSync(fixtureFile, 'utf8') : '{}' + }) + seedStore.flushOrThrow() + const seed = createAuthority(databasePath, 'profile-authority-test') + seed.writeSerializedState(Buffer.from(seedStore.prepareProfileStateExport().json)) + seedStore.freezeWrites() + + const authority = createAuthority(databasePath, 'profile-authority-test') + const writeDomains = vi.spyOn(authority, 'writeSerializedDomains') + const store = new Store({ dataFile, profileStateAuthority: authority }) + store.flushOrThrow() + const session = store.getWorkspaceSession() + const worktreeId = session.activeWorktreeId + const tabId = session.activeTabId + const layout = tabId ? session.terminalLayoutsByTabId[tabId] : undefined + const leafId = layout ? Object.keys(layout.ptyIdsByLeafId ?? {})[0] : undefined + const previousPtyId = leafId ? layout?.ptyIdsByLeafId?.[leafId] : undefined + if (!worktreeId || !tabId || !layout || !leafId || !previousPtyId) { + throw new Error('fixture did not produce a normalized PTY binding') + } + + expect( + await store.persistPtyBinding({ + worktreeId, + tabId, + leafId, + ptyId: 'pty-rebound', + expectedBinding: { ptyId: previousPtyId } + }) + ).toBe(true) + expect(writeDomains).toHaveBeenCalledTimes(1) + expect(writeDomains.mock.calls[0]?.[0].map(({ domain }) => domain)).toEqual([ + 'workspaceSession' + ]) + + const remoteSession = store.getWorkspaceSession('ssh:build-host') + const remoteWorktreeId = remoteSession.activeWorktreeId + const remoteTabId = remoteSession.activeTabId + const remoteLayout = remoteTabId ? remoteSession.terminalLayoutsByTabId[remoteTabId] : undefined + const remoteLeafId = remoteLayout + ? Object.keys(remoteLayout.ptyIdsByLeafId ?? {})[0] + : undefined + const remotePtyId = remoteLeafId ? remoteLayout?.ptyIdsByLeafId?.[remoteLeafId] : undefined + if (!remoteWorktreeId || !remoteTabId || !remoteLayout || !remoteLeafId || !remotePtyId) { + throw new Error('fixture did not produce a normalized remote PTY binding') + } + expect( + await store.persistPtyBinding( + { + worktreeId: remoteWorktreeId, + tabId: remoteTabId, + leafId: remoteLeafId, + ptyId: 'pty-remote-rebound', + expectedBinding: { ptyId: remotePtyId } + }, + 'ssh:build-host' + ) + ).toBe(true) + expect(writeDomains).toHaveBeenCalledTimes(2) + expect(writeDomains.mock.calls[1]?.[0].map(({ domain }) => domain)).toEqual([ + 'workspaceSessionsByHostId' + ]) + + const reloaded = new Store({ + dataFile, + profileStateAuthority: createAuthority(databasePath, 'profile-authority-test') + }) + expect( + reloaded.getWorkspaceSession().terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId + ).toMatchObject({ + [leafId]: 'pty-rebound' + }) + reloaded.freezeWrites() + }) + + it('writes scheduled automation changes as automations and automationRuns domains', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-automation-write-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const seedDataFile = join(directory, 'seed-orca-data.json') + const databasePath = join(directory, 'profile-state.db') + writeFileSync(seedDataFile, JSON.stringify(buildProfileStateCutoverFixture(directory))) + const seedStore = new Store({ + dataFile: seedDataFile, + serializedState: existsSync(seedDataFile) ? readFileSync(seedDataFile, 'utf8') : '{}' + }) + seedStore.flushOrThrow() + const seed = createAuthority(databasePath, 'profile-authority-test') + seed.writeSerializedState(Buffer.from(seedStore.prepareProfileStateExport().json)) + seedStore.freezeWrites() + + const authority = createAuthority(databasePath, 'profile-authority-test') + const writeAutomationRuns = vi.spyOn(authority, 'writeSerializedAutomationRuns') + const store = new Store({ dataFile, profileStateAuthority: authority }) + store.flushOrThrow() + const automation = store.listAutomations()[0] + if (!automation) { + throw new Error('fixture automation missing') + } + store.createAutomationRun(automation, Date.now(), 'scheduled') + + expect(writeAutomationRuns).toHaveBeenCalledTimes(1) + expect(writeAutomationRuns.mock.calls[0]?.[0].map(({ domain }) => domain)).toEqual([ + 'automations' + ]) + expect(writeAutomationRuns.mock.calls[0]?.[1]).toHaveLength(2) + const reloaded = new Store({ + dataFile, + profileStateAuthority: createAuthority(databasePath, 'profile-authority-test') + }) + expect(reloaded.listAutomationRuns()).toHaveLength(2) + reloaded.freezeWrites() + }) + + it('persists an automation run lifecycle through normalized rows', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-automation-lifecycle-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const seedDataFile = join(directory, 'seed-orca-data.json') + const databasePath = join(directory, 'profile-state.db') + writeFileSync(seedDataFile, JSON.stringify(buildProfileStateCutoverFixture(directory))) + const seedStore = new Store({ + dataFile: seedDataFile, + serializedState: existsSync(seedDataFile) ? readFileSync(seedDataFile, 'utf8') : '{}' + }) + seedStore.flushOrThrow() + const seed = createAuthority(databasePath, 'profile-authority-test') + seed.writeSerializedState(Buffer.from(seedStore.prepareProfileStateExport().json)) + seedStore.freezeWrites() + + const authority = createAuthority(databasePath, 'profile-authority-test') + const writeAutomationRuns = vi.spyOn(authority, 'writeSerializedAutomationRuns') + const store = new Store({ dataFile, profileStateAuthority: authority }) + store.flushOrThrow() + const automation = store.listAutomations()[0] + if (!automation) { + throw new Error('fixture automation missing') + } + + store.updateUI({ browserKagiSessionLink: 'https://kagi.com/session?t=authority' }) + store.flushOrThrow() + const pending = store.createAutomationRun(automation, 30, 'manual') + store.flushOrThrow() + expect(pending.status).toBe('pending') + const completed = store.updateAutomationRun({ + runId: pending.id, + status: 'completed', + outputSnapshot: { + format: 'plain_text', + content: 'completed through sqlite', + capturedAt: 31, + truncated: false + } + }) + store.flushOrThrow() + expect(completed.status).toBe('completed') + expect(writeAutomationRuns).toHaveBeenCalledTimes(2) + expect(writeAutomationRuns.mock.calls[1]?.[0].map(({ domain }) => domain)).toEqual([ + 'automations' + ]) + + const reloaded = new Store({ + dataFile, + profileStateAuthority: createAuthority(databasePath, 'profile-authority-test') + }) + expect(reloaded.listAutomationRuns('automation-fixture')).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: pending.id, + status: 'completed', + outputSnapshot: expect.objectContaining({ content: 'completed through sqlite' }) + }) + ]) + ) + expect(reloaded.getUI().featureInteractions?.['automation-run']?.interactionCount).toBe(1) + expect(reloaded.getUI().browserKagiSessionLink).toBe('https://kagi.com/session?t=authority') + const stored = openProfileStateDatabaseReadOnly(databasePath, 'profile-authority-test') + try { + const uiRow = stored.db + .prepare('SELECT payload FROM profile_state_documents WHERE domain = ?') + .get('ui') + expect(uiRow).toEqual( + expect.objectContaining({ payload: expect.not.stringContaining('authority') }) + ) + } finally { + stored.db.close() + } + reloaded.freezeWrites() + }) + + it('prunes normalized automation rows and reloads the retained window', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-automation-retention-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const seedDataFile = join(directory, 'seed-orca-data.json') + const databasePath = join(directory, 'profile-state.db') + writeFileSync(seedDataFile, JSON.stringify(buildProfileStateCutoverFixture(directory))) + const seedStore = new Store({ + dataFile: seedDataFile, + serializedState: existsSync(seedDataFile) ? readFileSync(seedDataFile, 'utf8') : '{}' + }) + seedStore.flushOrThrow() + const seed = createAuthority(databasePath, 'profile-authority-test') + seed.writeSerializedState(Buffer.from(seedStore.prepareProfileStateExport().json)) + seedStore.freezeWrites() + + const store = new Store({ + dataFile, + profileStateAuthority: createAuthority(databasePath, 'profile-authority-test') + }) + store.flushOrThrow() + const automation = store.listAutomations()[0] + if (!automation) { + throw new Error('fixture automation missing') + } + for (let index = 0; index < MAX_AUTOMATION_RUNS_PER_AUTOMATION + 2; index += 1) { + const run = store.createAutomationRun(automation, 100 + index, 'scheduled') + store.updateAutomationRun({ runId: run.id, status: 'completed' }) + } + + const reloaded = new Store({ + dataFile, + profileStateAuthority: createAuthority(databasePath, 'profile-authority-test') + }) + const retained = reloaded.listAutomationRuns('automation-fixture') + expect(retained).toHaveLength(MAX_AUTOMATION_RUNS_PER_AUTOMATION) + expect(retained.some((run) => run.id === 'automation-run-fixture')).toBe(false) + reloaded.flushOrThrow() + const stored = openProfileStateDatabaseReadOnly(databasePath, 'profile-authority-test') + try { + expect( + stored.db.prepare('SELECT COUNT(*) AS count FROM profile_state_automation_runs').get() + ).toEqual({ count: MAX_AUTOMATION_RUNS_PER_AUTOMATION }) + } finally { + stored.db.close() + } + reloaded.freezeWrites() + }) + + it('writes host-qualified worktree metadata as projected domain rows', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-worktree-write-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const seedDataFile = join(directory, 'seed-orca-data.json') + const databasePath = join(directory, 'profile-state.db') + writeFileSync(seedDataFile, JSON.stringify(buildProfileStateCutoverFixture(directory))) + const seedStore = new Store({ + dataFile: seedDataFile, + serializedState: existsSync(seedDataFile) ? readFileSync(seedDataFile, 'utf8') : '{}' + }) + seedStore.flushOrThrow() + const seed = createAuthority(databasePath, 'profile-authority-test') + seed.writeSerializedState(Buffer.from(seedStore.prepareProfileStateExport().json)) + seedStore.freezeWrites() + + const authority = createAuthority(databasePath, 'profile-authority-test') + const writeDomains = vi.spyOn(authority, 'writeSerializedDomains') + const store = new Store({ dataFile, profileStateAuthority: authority }) + store.flushOrThrow() + store.setWorktreeMetaForHost('repo-local::/fixture/local', 'local', { + displayName: 'Updated fixture local' + }) + store.flushOrThrow() + + expect(writeDomains).toHaveBeenCalledTimes(1) + expect(writeDomains.mock.calls[0]?.[0].map(({ domain }) => domain)).toEqual( + expect.arrayContaining(['worktreeMeta', 'worktreeMetaByIdentity', 'worktreeIdentityAliases']) + ) + const reloaded = new Store({ + dataFile, + profileStateAuthority: createAuthority(databasePath, 'profile-authority-test') + }) + expect( + reloaded.getWorktreeMetaForHost('repo-local::/fixture/local', 'local')?.displayName + ).toBe('Updated fixture local') + reloaded.freezeWrites() + }) + + it('publishes a durable JSON rollback export without changing the SQLite authority', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-json-export-')) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const authority = createAuthority(databasePath, 'profile-authority-test') + authority.writeSerializedState( + Buffer.from(JSON.stringify({ settings: { theme: 'dark' }, unknownDomain: { keep: true } })) + ) + + const exportPath = join(directory, 'rollback', 'orca-data.json.r3') + const revision = authority.writeJsonExport(exportPath) + + expect(revision).toBe(1) + expect(JSON.parse(readFileSync(exportPath, 'utf8'))).toEqual({ + settings: { theme: 'dark' }, + unknownDomain: { keep: true } + }) + const reopened = openProfileStateDatabaseReadOnly(databasePath, 'profile-authority-test') + try { + expect(exportProfileStateJson(reopened.db)).toBe(readFileSync(exportPath, 'utf8')) + } finally { + reopened.db.close() + } + }) + + it('publishes the Store export after flushing pending SQLite state', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-store-export-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databasePath = join(directory, 'profile-state.db') + const seed = new Store({ dataFile, serializedState: '{}' }) + seed.flushOrThrow() + const authority = createAuthority(databasePath, 'profile-authority-test') + authority.writeSerializedState(Buffer.from(seed.prepareProfileStateExport().json, 'utf8')) + seed.freezeWrites() + + const store = new Store({ dataFile, profileStateAuthority: authority }) + store.updateSettings({ theme: 'dark' }) + const exportPath = join(directory, 'rollback', 'orca-data.json.current') + const revision = store.writeProfileStateJsonExport(exportPath) + + expect(revision).toBe(2) + expect(JSON.parse(readFileSync(exportPath, 'utf8')).settings.theme).toBe('dark') + store.freezeWrites() + }) + + it('publishes the latest SQLite revision as an idempotent versioned export', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-latest-export-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databasePath = join(directory, 'profile-state.db') + const seed = new Store({ dataFile, serializedState: '{}' }) + seed.updateSettings({ theme: 'light' }) + seed.flushOrThrow() + const authority = createAuthority(databasePath, 'profile-authority-test') + authority.writeSerializedState(Buffer.from(seed.prepareProfileStateExport().json, 'utf8')) + seed.freezeWrites() + + const store = new Store({ dataFile, profileStateAuthority: authority }) + store.updateSettings({ theme: 'dark' }) + + const firstRevision = store.writeLatestProfileStateJsonExport() + expect(firstRevision).toBe(2) + if (firstRevision === undefined) { + throw new Error('Expected the SQLite authority to publish a revisioned export') + } + const firstPath = profileStateJsonExportPath(dataFile, firstRevision) + expect(JSON.parse(readFileSync(firstPath, 'utf8')).settings.theme).toBe('dark') + + expect(store.writeLatestProfileStateJsonExport()).toBe(firstRevision) + expect(readFileSync(profileStateJsonExportPath(dataFile, 2), 'utf8')).toBe( + readFileSync(firstPath, 'utf8') + ) + expect( + readdirSync(directory).some((name) => + name.startsWith('orca-data.json.sqlite-export.pending.') + ) + ).toBe(false) + store.freezeWrites() + }) + + it('publishes canonical JSON for an older build and advances its acceptance marker', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-compat-export-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databasePath = join(directory, 'profile-state.db') + const seed = new Store({ dataFile, serializedState: '{}' }) + seed.updateSettings({ theme: 'light' }) + seed.flushOrThrow() + const authority = createAuthority(databasePath, 'profile-authority-test') + authority.writeSerializedState(Buffer.from(seed.prepareProfileStateExport().json, 'utf8')) + seed.freezeWrites() + + const store = new Store({ dataFile, profileStateAuthority: authority }) + store.updateSettings({ theme: 'dark' }) + const revision = store.writeLatestProfileStateJsonCompatibilityExport() + + expect(revision).toBe(2) + const canonical = readFileSync(dataFile, 'utf8') + expect(JSON.parse(canonical).settings.theme).toBe('dark') + const opened = openProfileStateDatabaseReadOnly(databasePath, 'profile-authority-test') + try { + expect(readProfileStateJsonAcceptance(opened.db)).toEqual({ + jsonHash: hashProfileStateJson(canonical), + acceptedRevision: revision + }) + expect(profileStateJsonMatchesAcceptance(opened.db, canonical)).toBe(true) + } finally { + opened.db.close() + } + authority.close() + const reopened = createProfileStateStore({ + dataFile, + databaseFile: databasePath, + profileId: 'profile-authority-test' + }) + expect(reopened.backend).toBe('sqlite') + expect(reopened.store.getSettings().theme).toBe('dark') + reopened.store.freezeWrites() + store.freezeWrites() + }) + + it('refuses to overwrite a conflicting export for the same SQLite revision', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-export-conflict-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databasePath = join(directory, 'profile-state.db') + const authority = createAuthority(databasePath, 'profile-authority-test') + authority.writeSerializedState(Buffer.from(JSON.stringify({ settings: { theme: 'dark' } }))) + const store = new Store({ dataFile, profileStateAuthority: authority }) + const revision = store.writeLatestProfileStateJsonExport() + if (revision === undefined) { + throw new Error('Expected the SQLite authority to publish a revisioned export') + } + const exportPath = profileStateJsonExportPath(dataFile, revision) + mkdirSync(dirname(exportPath), { recursive: true }) + writeFileSync(exportPath, '{"settings":{"theme":"tampered"}}', 'utf8') + + expect(() => store.writeLatestProfileStateJsonExport()).toThrow( + 'already exists with different content' + ) + expect(readFileSync(exportPath, 'utf8')).toContain('tampered') + expect( + readdirSync(directory).some((name) => + name.startsWith('orca-data.json.sqlite-export.pending.') + ) + ).toBe(false) + store.freezeWrites() + }) + + it('freezes Store writes before quarantining the SQLite database family', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-quarantine-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databasePath = join(directory, 'profile-state.db') + const authority = createAuthority(databasePath, 'profile-authority-test') + authority.writeSerializedState(Buffer.from(JSON.stringify({ settings: { theme: 'light' } }))) + + const store = new Store({ dataFile, profileStateAuthority: authority }) + store.updateSettings({ theme: 'dark' }) + store.flushOrThrow() + await authority.drainBackups() + const sourceBytes = readFileSync(databasePath) + writeFileSync(`${databasePath}-wal`, 'wal-preservation-sentinel') + + const result = store.quarantineProfileStateDatabase( + join(directory, 'quarantine'), + 'store-recovery-test' + ) + + expect(readFileSync(join(result.directory, 'profile-state.db'))).toEqual(sourceBytes) + expect(readFileSync(join(result.directory, 'profile-state.db-wal'), 'utf8')).toBe( + 'wal-preservation-sentinel' + ) + expect(JSON.parse(readFileSync(result.manifestPath, 'utf8'))).toMatchObject({ + profileId: 'profile-authority-test', + reason: 'store-recovery-test' + }) + expect(readFileSync(databasePath)).toEqual(sourceBytes) + }) + + it('prepares frozen JSON imports without permitting file publication', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-legacy-export-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const store = new Store({ dataFile, serializedState: '{"settings":{"theme":"light"}}' }) + store.updateSettings({ theme: 'dark' }) + + const exportPath = join(directory, 'rollback', 'orca-data.json.legacy.json') + expect(() => store.writeProfileStateJsonExport(exportPath)).toThrow('require a SQLite') + expect(JSON.parse(store.prepareProfileStateExport().json).settings.theme).toBe('dark') + expect(existsSync(exportPath)).toBe(false) + expect(existsSync(dataFile)).toBe(false) + expect(existsSync(join(directory, 'profile-state.db'))).toBe(false) + store.freezeWrites() + }) +}) diff --git a/src/main/persistence/loading-store/profile-state-store-backups.test.ts b/src/main/persistence/loading-store/profile-state-store-backups.test.ts new file mode 100644 index 00000000000..aa60618cc42 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-store-backups.test.ts @@ -0,0 +1,250 @@ +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushActiveProfileBeforeFileMutation } from '../../orca-profiles/profile-persistence-deadline' +import { openProfileStateDatabaseReadOnly } from '../profile-state/profile-state-database' +import { readProfileStateSnapshot } from '../profile-state/profile-state-documents' +import { profileStateDatabaseBackups } from '../profile-state/profile-state-backup-path' +import * as backupExecution from '../profile-state/profile-state-backup-worker' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { Store } from './store' +import { scheduleSave } from './write-scheduling' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`), + decryptString: (value: Buffer) => value.toString().slice('encrypted:'.length) + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const PROFILE_ID = 'store-backup-test' +const HOUR = 60 * 60 * 1000 +const fixtures: { directory: string; store: Store; authority: ProfileStateSqliteAuthority }[] = [] +const releases: (() => void)[] = [] + +beforeEach(() => { + vi.spyOn(backupExecution, 'runProfileStateBackup') +}) + +afterEach(async () => { + for (const release of releases.splice(0)) { + release() + } + for (const fixture of fixtures.splice(0)) { + await fixture.authority.drainBackups() + fixture.store.freezeWrites() + await fixture.store.flushAsync() + rmSync(fixture.directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +async function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-backup-')) + const databasePath = join(directory, 'profile-state.db') + const dataFile = join(directory, 'orca-data.json') + const legacyBytes = '{"settings":{"theme":"system"},"legacy":"retained"}' + writeFileSync(dataFile, legacyBytes) + const beginning = Date.now() + const clock = vi.spyOn(Date, 'now').mockReturnValue(beginning) + const authority = new ProfileStateSqliteAuthority(databasePath, PROFILE_ID) + const store = new Store({ dataFile, profileStateAuthority: authority }) + fixtures.push({ directory, store, authority }) + store.updateSettings({ theme: 'light' }) + store.flushOrThrow() + await authority.drainBackups() + const retained = profileStateDatabaseBackups(databasePath) + expect(retained).toHaveLength(1) + const retainedBytes = readFileSync(retained[0].path) + clock.mockReturnValue(beginning + HOUR + 1) + return { + directory, + databasePath, + dataFile, + legacyBytes, + store, + authority, + retained, + retainedBytes + } +} + +function readSnapshot(path: string) { + const opened = openProfileStateDatabaseReadOnly(path, PROFILE_ID) + try { + const snapshot = readProfileStateSnapshot(opened.db) + return { revision: snapshot.revision, state: JSON.parse(snapshot.json) } + } finally { + opened.db.close() + } +} + +describe('Store automatic SQLite recovery snapshots', () => { + it.each([ + ['selective', 'sync'], + ['selective', 'async'], + ['complete', 'sync'], + ['complete', 'async'] + ] as const)('backs up a %s %s commit without rewriting retained JSON', async (scope, flush) => { + const state = await fixture() + const fullWrite = vi.spyOn(state.authority, 'writeCompleteSerializedDomains') + const selectiveWrite = vi.spyOn(state.authority, 'writeSerializedDomains') + state.store.patchWorkspaceSession({ activeWorktreeId: 'backup-worktree' }) + if (scope === 'complete') { + scheduleSave(state.store) + } + if (flush === 'sync') { + state.store.flushOrThrow() + await state.authority.drainBackups() + } else { + await state.store.flushPendingOrThrowAsync() + } + + await state.authority.drainBackups() + expect(scope === 'complete' ? fullWrite : selectiveWrite).toHaveBeenCalledOnce() + expect(scope === 'complete' ? selectiveWrite : fullWrite).not.toHaveBeenCalled() + const backups = profileStateDatabaseBackups(state.databasePath) + expect(backups).toHaveLength(2) + expect(readSnapshot(backups[0].path)).toEqual(readSnapshot(state.databasePath)) + expect(readSnapshot(backups[0].path).state.workspaceSession.activeWorktreeId).toBe( + 'backup-worktree' + ) + expect(readFileSync(state.retained[0].path)).toEqual(state.retainedBytes) + expect(readFileSync(state.dataFile, 'utf8')).toBe(state.legacyBytes) + expect( + readdirSync(state.directory) + .filter((name) => name.includes('.backup.')) + .sort() + ).toEqual(backups.map((backup) => basename(backup.path)).sort()) + }) + + it('acknowledges a routine flush while the previous recovery backup is still running', async () => { + const state = await fixture() + const realSnapshot = backupExecution.runProfileStateBackup + const started = Promise.withResolvers() + const gate = Promise.withResolvers() + releases.push(gate.resolve) + vi.spyOn(backupExecution, 'runProfileStateBackup').mockImplementationOnce(async (job) => { + started.resolve() + await gate.promise + await realSnapshot(job) + }) + state.store.updateSettings({ theme: 'dark' }) + state.store.flushOrThrow() + await started.promise + state.store.patchWorkspaceSession({ activeWorktreeId: 'newer-than-backup' }) + let settled = false + const flush = state.store.flushPendingOrThrowAsync().then(() => { + settled = true + }) + await vi.waitFor(() => expect(settled).toBe(true)) + expect(readSnapshot(state.databasePath).state.workspaceSession.activeWorktreeId).toBe( + 'newer-than-backup' + ) + expect(profileStateDatabaseBackups(state.databasePath)).toHaveLength(1) + gate.resolve() + await Promise.all([flush, state.authority.drainBackups()]) + }) + + it.each(['quit', 'profile mutation'] as const)( + '%s waits for its owned backup across Store close', + async (kind) => { + const state = await fixture() + const realSnapshot = backupExecution.runProfileStateBackup + let begin: () => void = () => {} + let release: () => void = () => {} + const started = new Promise((resolve) => { + begin = resolve + }) + const gate = new Promise((resolve) => { + release = resolve + }) + releases.push(release) + vi.spyOn(backupExecution, 'runProfileStateBackup').mockImplementationOnce(async (job) => { + begin() + await gate + await realSnapshot(job) + }) + state.store.updateSettings({ theme: 'dark' }) + state.store.flushOrThrow() + await started + const drain = vi.spyOn(state.authority, 'drainBackups') + let settled = false + const barrier = ( + kind === 'quit' + ? state.store.flushAsync({ exportJsonCompatibility: true }) + : flushActiveProfileBeforeFileMutation(state.store) + ).then(() => { + settled = true + }) + await vi.waitFor(() => expect(drain).toHaveBeenCalled()) + expect(settled).toBe(false) + state.store.freezeWrites() + expect(profileStateDatabaseBackups(state.databasePath)).toHaveLength(1) + release() + await barrier + + expect(settled).toBe(true) + const backups = profileStateDatabaseBackups(state.databasePath) + expect(backups).toHaveLength(2) + expect(readSnapshot(backups[0].path).state.settings.theme).toBe('dark') + expect(readFileSync(state.retained[0].path)).toEqual(state.retainedBytes) + expect(JSON.parse(readFileSync(state.dataFile, 'utf8'))).toEqual( + readSnapshot(state.databasePath).state + ) + } + ) + + it.each(['sync', 'async'] as const)( + 'does not reject a committed %s flush when its backup fails', + async (flush) => { + const state = await fixture() + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + const failure = new Error('injected backup disk failure') + const snapshot = vi + .spyOn(backupExecution, 'runProfileStateBackup') + .mockClear() + .mockRejectedValueOnce(failure) + state.store.updateSettings({ theme: 'dark' }) + if (flush === 'sync') { + expect(() => state.store.flushOrThrow()).not.toThrow() + await expect(state.authority.drainBackups()).resolves.toBeUndefined() + } else { + await expect(state.store.flushPendingOrThrowAsync()).resolves.toBeUndefined() + } + + await state.authority.drainBackups() + expect(snapshot).toHaveBeenCalledOnce() + expect(log).toHaveBeenCalledWith( + '[persistence] Failed to back up profile state database:', + failure + ) + expect(readSnapshot(state.databasePath).state.settings.theme).toBe('dark') + expect(profileStateDatabaseBackups(state.databasePath)).toEqual(state.retained) + expect(readFileSync(state.retained[0].path)).toEqual(state.retainedBytes) + expect(readSnapshot(state.retained[0].path).state.settings.theme).toBe('light') + expect(readFileSync(state.dataFile, 'utf8')).toBe(state.legacyBytes) + expect(existsSync(`${state.dataFile}.bak.0`)).toBe(false) + } + ) +}) diff --git a/src/main/persistence/loading-store/profile-state-temp-cleanup-lifetime.test.ts b/src/main/persistence/loading-store/profile-state-temp-cleanup-lifetime.test.ts new file mode 100644 index 00000000000..2cbdd1c59ff --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-temp-cleanup-lifetime.test.ts @@ -0,0 +1,87 @@ +import { mkdtempSync, readFileSync, utimesSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { removeTreeSync } from '../../../shared/windows-transient-lock-removal' +import * as durableFileWrite from '../../durable-file-write' +import { STALE_DURABLE_WRITE_TEMP_AGE_MS } from '../tracking-repos/worktree-metadata-normalization' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from './profile-state-maintenance-fixture' +import { Store } from './store' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const directories: string[] = [] +afterEach(() => { + for (const directory of directories.splice(0)) { + removeTreeSync(directory) + } +}) + +it.each(['maintenance', 'freeze', 'final'] as const)( + '%s drains startup temp cleanup without delaying ordinary SQL writes', + async (kind) => { + const gate = maintenanceBarrier() + const cleanup = durableFileWrite.removeStaleDurableWriteTempFiles + vi.spyOn(durableFileWrite, 'removeStaleDurableWriteTempFiles').mockImplementation( + (file, options) => + basename(file) === 'orca-data.json' ? gate.promise : cleanup(file, options) + ) + const { store, authority, readState } = await createWorkerMaintenanceFixture() + store.updateSettings({ theme: 'dark' }) + await store.flushPendingOrThrowAsync() + expect(readState().settings.theme).toBe('dark') + + const close = vi.spyOn(authority, 'close') + const checkpoint = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const finished = vi.fn() + const pending = + kind === 'maintenance' + ? store.beginProfileMaintenance() + : kind === 'freeze' + ? store.freezeWritesAsync() + : store.flushFinalOrThrowAsync() + void pending.then(finished, finished) + await new Promise((resolve) => setImmediate(resolve)) + expect(finished).not.toHaveBeenCalled() + expect(close).not.toHaveBeenCalled() + expect(checkpoint).not.toHaveBeenCalled() + + gate.resolve() + await pending + expect(close).toHaveBeenCalledOnce() + } +) + +it('leaves legacy source temp files untouched when constructing a frozen importer', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-import-temp-cleanup-')) + directories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const source = '{"settings":{"theme":"dark"}}' + const staleTempFile = `${dataFile}.99999999.0.import.tmp` + writeFileSync(dataFile, source) + writeFileSync(staleTempFile, 'retained temp bytes') + const staleSeconds = (Date.now() - STALE_DURABLE_WRITE_TEMP_AGE_MS - 60_000) / 1000 + utimesSync(staleTempFile, staleSeconds, staleSeconds) + const cleanup = vi.spyOn(durableFileWrite, 'removeStaleDurableWriteTempFiles') + + const imported = new Store({ dataFile, serializedState: source }) + expect(JSON.parse(imported.prepareProfileStateExport().json).settings.theme).toBe('dark') + imported.freezeWrites() + await Promise.all( + cleanup.mock.results.map((result) => (result.type === 'return' ? result.value : undefined)) + ) + + expect(cleanup.mock.calls.some(([file]) => file === dataFile)).toBe(false) + expect(readFileSync(dataFile, 'utf8')).toBe(source) + expect(readFileSync(staleTempFile, 'utf8')).toBe('retained temp bytes') +}) diff --git a/src/main/persistence/loading-store/profile-state-update-quit.test.ts b/src/main/persistence/loading-store/profile-state-update-quit.test.ts new file mode 100644 index 00000000000..cadbdbe754a --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-update-quit.test.ts @@ -0,0 +1,241 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as durableFiles from '../../durable-file-write' +import { settleTeardownWithinDeadline } from '../../quit-teardown-deadline' +import { openProfileStateDatabaseReadOnly } from '../profile-state/profile-state-database' +import { + hashProfileStateJson, + readProfileStateJsonAcceptance, + readProfileStateSnapshot +} from '../profile-state/profile-state-documents' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { createProfileStateStore } from '../profile-state/profile-state-store-factory' +import { Store } from './store' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { isEncryptionAvailable: () => false }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const PROFILE_ID = 'update-quit-test' +const TARGET_ID = 'remote-host' +const fixtures: { directory: string; store: Store; authority: ProfileStateSqliteAuthority }[] = [] +const releases: (() => void)[] = [] + +afterEach(async () => { + for (const release of releases.splice(0)) { + release() + } + for (const { store, authority, directory } of fixtures.splice(0)) { + await store.flushAsync() + await authority.drainBackups() + store.freezeWrites() + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +async function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-update-quit-')) + const databasePath = join(directory, 'profile-state.db') + const dataFile = join(directory, 'orca-data.json') + const authority = new ProfileStateSqliteAuthority(databasePath, PROFILE_ID) + const store = new Store({ dataFile, profileStateAuthority: authority }) + fixtures.push({ directory, store, authority }) + store.upsertSshRemotePtyLease({ targetId: TARGET_ID, ptyId: 'remote-pty', state: 'attached' }) + await store.flushPendingOrThrowAsync() + store.writeLatestProfileStateJsonExport() + store.writeLatestProfileStateJsonCompatibilityExport() + return { store, authority, databasePath, dataFile } +} + +function persistedState(databasePath: string) { + const opened = openProfileStateDatabaseReadOnly(databasePath, PROFILE_ID) + try { + return { + ...readProfileStateSnapshot(opened.db), + acceptance: readProfileStateJsonAcceptance(opened.db) + } + } finally { + opened.db.close() + } +} + +function gate() { + let release: () => void = () => {} + const promise = new Promise((resolve) => { + release = resolve + }) + releases.push(release) + return { promise, release } +} + +describe('SQLite profile state during an update quit', () => { + it('exports final SSH shutdown writes and reloads them through the frozen JSON importer', async () => { + const { store, dataFile, databasePath } = await fixture() + store.markSshRemotePtyLeasesForShutdown(TARGET_ID, 'detached') + + await store.flushAsync({ exportJsonCompatibility: true }) + + const json = readFileSync(dataFile, 'utf8') + const snapshot = persistedState(databasePath) + expect(json).toBe(snapshot.json) + expect(snapshot.acceptance).toEqual({ + jsonHash: hashProfileStateJson(json), + acceptedRevision: snapshot.revision + }) + const legacy = new Store({ dataFile, serializedState: json }) + try { + expect(legacy.getSshRemotePtyLeases(TARGET_ID)).toEqual([ + expect.objectContaining({ state: 'detached', lastDetachedAt: expect.any(Number) }) + ]) + } finally { + legacy.freezeWrites() + } + }) + + it('exports a normal quit for an older build after sessions and settings changed', async () => { + const { store, dataFile, databasePath } = await fixture() + store.updateSettings({ theme: 'dark' }) + store.markSshRemotePtyLeasesForShutdown(TARGET_ID, 'detached') + + await store.flushFinalOrThrowAsync({ exportJsonCompatibility: true }) + + const json = readFileSync(dataFile, 'utf8') + expect(json).toBe(persistedState(databasePath).json) + const legacy = new Store({ dataFile, serializedState: json }) + try { + expect(legacy.getSettings().theme).toBe('dark') + expect(legacy.getSshRemotePtyLeases(TARGET_ID)[0]?.state).toBe('detached') + } finally { + legacy.freezeWrites() + } + }) + + it('does not publish or accept a snapshot when final persistence fails', async () => { + const { store, authority, dataFile, databasePath } = await fixture() + const retainedJson = readFileSync(dataFile, 'utf8') + const before = persistedState(databasePath) + const failure = new Error('injected final commit failure') + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(authority, 'writeCompleteSerializedDomains').mockImplementation(() => { + throw failure + }) + const exportJson = vi.spyOn(authority, 'writeJsonCompatibilityExportAsync') + store.markSshRemotePtyLeasesForShutdown(TARGET_ID, 'detached') + + await store.flushAsync({ exportJsonCompatibility: true }) + + expect(exportJson).not.toHaveBeenCalled() + expect(readFileSync(dataFile, 'utf8')).toBe(retainedJson) + expect(persistedState(databasePath)).toEqual(before) + }) + + it('retains the preflight export and acceptance if the final file write fails', async () => { + const { store, dataFile, databasePath } = await fixture() + const retainedJson = readFileSync(dataFile, 'utf8') + const before = persistedState(databasePath) + const failure = new Error('injected final export failure') + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(durableFiles, 'writeFileDurable').mockRejectedValueOnce(failure) + store.markSshRemotePtyLeasesForShutdown(TARGET_ID, 'detached') + + await store.flushAsync({ exportJsonCompatibility: true }) + + expect(readFileSync(dataFile, 'utf8')).toBe(retainedJson) + expect(persistedState(databasePath).acceptance).toMatchObject(before.acceptance ?? {}) + expect(persistedState(databasePath).revision).toBeGreaterThan(before.revision) + expect(log).toHaveBeenCalledWith('[persistence] Failed to flush final state:', failure) + }) + + it('joins the final barrier and permits its deadline while the JSON file write stalls', async () => { + const { store, dataFile, databasePath } = await fixture() + const retainedJson = readFileSync(dataFile, 'utf8') + const writing = gate() + const held = gate() + const writeFileDurable = durableFiles.writeFileDurable + const exportWrite = vi + .spyOn(durableFiles, 'writeFileDurable') + .mockImplementationOnce(async (...args) => { + writing.release() + await held.promise + await writeFileDurable(...args) + }) + store.markSshRemotePtyLeasesForShutdown(TARGET_ID, 'detached') + const pending = store.flushAsync({ exportJsonCompatibility: true }) + expect(store.flushAsync()).toBe(pending) + await writing.promise + + await expect( + settleTeardownWithinDeadline([{ name: 'state', promise: pending }], 25) + ).resolves.toEqual(['state']) + expect(readFileSync(dataFile, 'utf8')).toBe(retainedJson) + held.release() + await pending + + expect(exportWrite).toHaveBeenCalledOnce() + expect(readFileSync(dataFile, 'utf8')).toBe(persistedState(databasePath).json) + }) + + it('reopens the latest SQLite state when a concurrent commit prevents export promotion', async () => { + const { store, dataFile, databasePath } = await fixture() + const before = persistedState(databasePath) + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + const writeFileDurable = durableFiles.writeFileDurable + vi.spyOn(durableFiles, 'writeFileDurable').mockImplementationOnce(async (...args) => { + await writeFileDurable(...args) + const competitor = new ProfileStateSqliteAuthority(databasePath, PROFILE_ID) + try { + competitor.readSerializedState() + competitor.writeSerializedDomains([{ domain: 'settings', payload: '{"theme":"dark"}' }]) + } finally { + competitor.close() + } + }) + store.markSshRemotePtyLeasesForShutdown(TARGET_ID, 'detached') + + await store.flushAsync({ exportJsonCompatibility: true }) + + const snapshot = persistedState(databasePath) + expect(snapshot.acceptance).toMatchObject(before.acceptance ?? {}) + expect(snapshot.acceptance?.pending?.jsonHash).toBe( + hashProfileStateJson(readFileSync(dataFile, 'utf8')) + ) + expect(log).toHaveBeenCalledWith( + '[persistence] Failed to flush final state:', + expect.objectContaining({ code: 'profile-state-revision-conflict' }) + ) + store.freezeWrites() + const reopened = createProfileStateStore({ + dataFile, + databaseFile: databasePath, + profileId: PROFILE_ID + }) + try { + expect(reopened.backend).toBe('sqlite') + expect(reopened.store.getSettings().theme).toBe('dark') + expect(reopened.store.getSshRemotePtyLeases(TARGET_ID)[0]?.state).toBe('detached') + } finally { + reopened.store.freezeWrites() + } + }) +}) diff --git a/src/main/persistence/loading-store/profile-state-worker-coordination.test.ts b/src/main/persistence/loading-store/profile-state-worker-coordination.test.ts new file mode 100644 index 00000000000..7144abe0789 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-worker-coordination.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, it, vi } from 'vitest' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' +import { fixture } from './profile-state-delayed-authority-fixture' +import { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +describe('worker-owned Store writes', () => { + it('consumes the debounce timer and avoids full checkpoints after a selective durable write', async () => { + const { store, authority, readState } = await fixture() + const full = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const selective = vi.spyOn(authority, 'writeSerializedDomains') + vi.useFakeTimers() + try { + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await vi.advanceTimersByTimeAsync(6_000) + await store.waitForPendingWrite() + expect(selective).toHaveBeenCalledOnce() + expect(full).not.toHaveBeenCalled() + expect(readState().settings.theme).toBe('dark') + } finally { + vi.useRealTimers() + } + }) + + it('still captures direct durable mutations that do not identify dirty domains', async () => { + const { store, readState } = await fixture() + await store.runDurableMutation(() => { + store.getWorkspaceSession().activeTabId = 'direct-mutation' + return { value: undefined } + }) + expect(readState().workspaceSession.activeTabId).toBe('direct-mutation') + }) + + it('skips a debounce callback already queued behind the write that consumed its changes', async () => { + const { store, authority } = await fixture() + const full = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const gate = authority.pause() + vi.useFakeTimers() + try { + const durable = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await gate.started.promise + await vi.advanceTimersByTimeAsync(1_000) + gate.finish.resolve() + await durable + await store.waitForPendingWrite() + expect(full).not.toHaveBeenCalled() + } finally { + gate.finish.resolve() + vi.useRealTimers() + } + }) + + it('handles a rejected debounced save and retains it for an explicit retry', async () => { + const { store, authority, readState } = await fixture() + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + vi.useFakeTimers() + try { + store.updateSettings({ theme: 'dark' }) + await vi.advanceTimersByTimeAsync(1000) + await gate.started.promise + gate.finish.reject(new Error('disk refused')) + await store.waitForPendingWrite() + expect(log).toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + await store.flushPendingOrThrowAsync() + expect(readState().settings.theme).toBe('dark') + }) + + it('refuses an export when serialization invalidates its full checkpoint', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const publish = vi.spyOn(authority, 'writeLatestJsonExport') + const session = store.getWorkspaceSession() + Object.defineProperty(session, 'toJSON', { + configurable: true, + value: () => { + store.updateSettings({ theme: 'light' }) + return { ...session } + } + }) + try { + await expect(store.writeLatestProfileStateJsonExportAsync()).rejects.toThrow( + 'changed while preparing its export' + ) + expect(publish).not.toHaveBeenCalled() + } finally { + Reflect.deleteProperty(session, 'toJSON') + } + await store.writeLatestProfileStateJsonExportAsync() + expect(readState().settings.theme).toBe('light') + expect(publish).toHaveBeenCalledOnce() + }) + + it('retains selective write intent when a toJSON hook changes the captured generation', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const selective = vi.spyOn(authority, 'writeSerializedDomains') + const serialize = vi.fn((key: string) => { + expect(key).toBe('workspaceSession') + store.updateSettings({ theme: 'light' }) + return { ...store.getWorkspaceSession() } + }) + try { + await expect( + store.runDurableMutation(() => { + store.patchWorkspaceSession({ activeTabId: 'captured-tab' }) + Object.defineProperty(store.getWorkspaceSession(), 'toJSON', { + configurable: true, + value: serialize + }) + return { value: undefined } + }) + ).rejects.toThrow('changed while preparing its durable snapshot') + expect(serialize).toHaveBeenCalledOnce() + expect(selective).not.toHaveBeenCalled() + } finally { + Reflect.deleteProperty(store.getWorkspaceSession(), 'toJSON') + } + await store.flushPendingOrThrowAsync() + expect(readState()).toMatchObject({ + settings: { theme: 'light' }, + workspaceSession: { activeTabId: 'captured-tab' } + }) + expect(selective).toHaveBeenCalledOnce() + }) + + it('preserves canonical domain bytes and revisions for raw JSON and proxy-valued hooks', async () => { + if (!('rawJSON' in JSON) || typeof JSON.rawJSON !== 'function') { + throw new Error('This test requires native JSON.rawJSON support') + } + const overflow: unknown = JSON.rawJSON('1e999') + const escaped: unknown = JSON.rawJSON('"\\u0061"') + const { store, authority, readState } = await fixture() + store.patchWorkspaceSession({ activeTabId: 'pending-tab' }) + const session = store.getWorkspaceSession() + const keyOrder = new Proxy({ 1: 'one', 2: 'two' }, { ownKeys: () => ['2', '1'] }) + const serialize = vi.fn(() => ({ ...session, activeTabId: escaped, overflow, keyOrder })) + Object.defineProperty(session, 'toJSON', { configurable: true, value: serialize }) + const expected = JSON.stringify({ + ...session, + activeTabId: 'a', + overflow: null, + keyOrder: { + 1: 'one', + 2: 'two' + } + }) + try { + await store.flushPendingOrThrowAsync() + expect(authority.captures.at(-1)).toContainEqual({ + domain: 'workspaceSession', + payload: expected + }) + expect(readState().workspaceSession).toMatchObject({ activeTabId: 'a', overflow: null }) + expect(serialize).toHaveBeenCalledOnce() + const revision = authority.inner.revision + store.setWorkspaceSession(session) + await store.flushPendingOrThrowAsync() + expect(authority.inner.revision).toBe(revision) + expect(serialize).toHaveBeenCalledTimes(2) + } finally { + Reflect.deleteProperty(session, 'toJSON') + } + }) + + it('retains a newer edit after an older write is acknowledged', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + store.updateSettings({ theme: 'light' }) + gate.finish.resolve() + await first + expect(readState().settings.theme).toBe('dark') + await store.flushPendingOrThrowAsync() + expect(readState().settings.theme).toBe('light') + }) + + it('merges a failed older write with dirty state added while it was in flight', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + const rejected = expect(first).rejects.toThrow('disk refused') + await gate.started.promise + store.getWorkspaceSession().activeTabId = 'newer-tab' + store.setWorkspaceSession(store.getWorkspaceSession()) + gate.finish.reject(new Error('disk refused')) + await rejected + await store.flushPendingOrThrowAsync() + expect(readState()).toMatchObject({ + settings: { theme: 'dark' }, + workspaceSession: { activeTabId: 'newer-tab' } + }) + }) + + it('captures a full checkpoint after an older save even without a new generation', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + store.getWorkspaceSession().activeTabId = 'getter-only-tab' + const final = store.flushAsync() + gate.finish.resolve() + await first + await final + expect(readState().workspaceSession.activeTabId).toBe('getter-only-tab') + expect(authority.captures.at(-1)?.some(({ domain }) => domain === 'workspaceSession')).toBe( + true + ) + }) + + it('reserves ordering before an exact mutation changes live state', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + const mutate = vi.fn(() => { + store.updateSettings({ theme: 'light' }) + return { value: 'durable' } + }) + const exact = store.runDurableMutation(mutate) + await Promise.resolve() + expect(mutate).not.toHaveBeenCalled() + expect(store.getSettings().theme).toBe('dark') + gate.finish.resolve() + await first + await expect(exact).resolves.toBe('durable') + expect(readState().settings.theme).toBe('light') + }) + + it('retains dirty intent while many durability waiters share an active snapshot', async () => { + const { store, authority, readState } = await fixture() + const fullCapture = vi.spyOn( + StateSerializationSecretHandlingOperations.prototype, + 'buildStateToSave' + ) + const gate = authority.pause() + store.updateSettings({ terminalFontSize: 12 }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + const waiters: Promise[] = [first] + for (let size = 13; size <= 32; size++) { + store.updateSettings({ terminalFontSize: size }) + waiters.push(store.flushPendingOrThrowAsync({ drainToStableGeneration: false })) + } + await Promise.resolve() + expect(authority.captures).toHaveLength(1) + gate.finish.resolve() + await Promise.all(waiters) + expect(readState().settings.terminalFontSize).toBe(32) + expect(fullCapture).toHaveBeenCalledOnce() + expect(authority.captures).toHaveLength(2) + }) + + it('cancels a queued checkpoint without aborting the preceding writer or losing edits', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const abortWriter = vi.spyOn(authority, 'abort') + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + const controller = new AbortController() + store.updateSettings({ theme: 'light' }) + const canceled = store.flushPendingOrThrowAsync({ signal: controller.signal }) + const refused = expect(canceled).rejects.toThrow('aborted') + controller.abort() + gate.finish.resolve() + await first + await refused + expect(abortWriter).not.toHaveBeenCalled() + await store.flushPendingOrThrowAsync() + expect(readState().settings.theme).toBe('light') + }) + + it.each(['known-failure', 'indeterminate'] as const)( + 'only rolls back a mutation with a known failure (%s)', + async (outcome) => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + const rollback = vi.fn() + const exact = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined, rollback } + }) + const rejected = expect(exact).rejects.toThrow('write failed') + await gate.started.promise + gate.finish.reject(new ProfileStateWriterError('test', 'write failed', outcome)) + await rejected + expect(rollback).toHaveBeenCalledTimes(outcome === 'known-failure' ? 1 : 0) + } + ) + + it('awaits accepted operations before closing and refuses new exact mutations', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + const first = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await gate.started.promise + const closing = store.freezeWritesAsync() + await expect(store.runDurableMutation(() => ({ value: undefined }))).rejects.toThrow( + 'finalized' + ) + expect(authority.close).not.toHaveBeenCalled() + gate.finish.resolve() + await first + await closing + expect(authority.close).toHaveBeenCalledTimes(1) + expect(readState().settings.theme).toBe('dark') + }) +}) diff --git a/src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts b/src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts new file mode 100644 index 00000000000..4d72ce144c6 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getSecretStore, setSecretStore } from '../../../shared/secret-store' +import { fixture } from './profile-state-delayed-authority-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +let encryptionAvailable = true +let previousSecretStore: ReturnType +const ciphertext = (plaintext: string) => Buffer.from(`sealed:${plaintext}`).toString('base64') + +beforeEach(() => { + encryptionAvailable = true + previousSecretStore = getSecretStore() + setSecretStore({ + isEncryptionAvailable: () => encryptionAvailable, + encryptString: (value) => Buffer.from(`sealed:${value}`), + decryptString: (value) => value.toString().slice('sealed:'.length), + describeProtectionGap: () => null + }) +}) +afterEach(() => setSecretStore(previousSecretStore)) + +describe.each(['opencodeSessionCookie', 'opencodeGoApiKey'] as const)( + 'Store %s retention across worker acknowledgements', + (setting) => { + it('does not restore ciphertext cleared while its commit acknowledgement was pending', async () => { + const { store, authority, readState } = await fixture() + store.updateSettings({ [setting]: 'durable' }) + await store.flushPendingOrThrowAsync() + const gate = authority.pause() + store.updateSettings({ [setting]: 'in-flight' }) + const write = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + store.updateSettings({ [setting]: '' }) + encryptionAvailable = false + gate.finish.resolve() + await write + await store.flushPendingOrThrowAsync() + expect(readState().settings[setting]).toBe('') + expect(store.getSettings()[setting]).toBe('') + }) + + it('retains confirmed ciphertext until a newer secret can be encrypted', async () => { + const { store, authority, readState } = await fixture() + store.updateSettings({ [setting]: 'durable' }) + await store.flushPendingOrThrowAsync() + const gate = authority.pause() + store.updateSettings({ [setting]: 'in-flight' }) + const write = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + store.updateSettings({ [setting]: 'newer' }) + encryptionAvailable = false + gate.finish.resolve() + await write + await store.flushPendingOrThrowAsync() + expect(readState().settings[setting]).toBe(ciphertext('in-flight')) + expect(store.getSettings()[setting]).toBe('newer') + encryptionAvailable = true + store.updateSettings({ theme: 'dark' }) + await store.flushPendingOrThrowAsync() + expect(readState().settings[setting]).toBe(ciphertext('newer')) + }) + + it('retains the earlier ciphertext after a failed write and retries newer plaintext', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + store.updateSettings({ [setting]: 'durable' }) + await store.flushPendingOrThrowAsync() + const gate = authority.pause() + store.updateSettings({ [setting]: 'failed' }) + const write = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + const failure = expect(write).rejects.toThrow('disk refused') + await gate.started.promise + store.updateSettings({ [setting]: 'newer' }) + encryptionAvailable = false + gate.finish.reject(new Error('disk refused')) + await failure + await store.flushPendingOrThrowAsync() + expect(readState().settings[setting]).toBe(ciphertext('durable')) + encryptionAvailable = true + store.updateSettings({ theme: 'dark' }) + await store.flushPendingOrThrowAsync() + expect(readState().settings[setting]).toBe(ciphertext('newer')) + }) + } +) + +describe('worker protected settings serialization', () => { + it.each(['selective', 'complete'] as const)( + 'encrypts both protected credentials in a %s write to SQLite', + async (mode) => { + const { store, authority, readState } = await fixture() + const selectiveWrite = vi.spyOn(authority, 'writeSerializedDomains') + const completeWrite = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const secrets = { + opencodeSessionCookie: 'cookie-only-plaintext', + opencodeGoApiKey: 'api-key-only-plaintext' + } + store.updateSettings(secrets) + if (mode === 'complete') { + store.updateOnboarding({ outcome: 'completed' }) + } + await store.flushPendingOrThrowAsync() + expect(selectiveWrite).toHaveBeenCalledTimes(mode === 'selective' ? 1 : 0) + expect(completeWrite).toHaveBeenCalledTimes(mode === 'complete' ? 1 : 0) + const persisted = readState() + expect(persisted.settings).toMatchObject({ + opencodeSessionCookie: ciphertext(secrets.opencodeSessionCookie), + opencodeGoApiKey: ciphertext(secrets.opencodeGoApiKey) + }) + for (const plaintext of Object.values(secrets)) { + expect(JSON.stringify(persisted)).not.toContain(plaintext) + } + expect(store.getSettings()).toMatchObject(secrets) + } + ) +}) diff --git a/src/main/persistence/loading-store/profile-state-write-batching.test.ts b/src/main/persistence/loading-store/profile-state-write-batching.test.ts new file mode 100644 index 00000000000..fb6f44a5d8a --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-write-batching.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it, vi } from 'vitest' +import { deferred, fixture } from './profile-state-delayed-authority-fixture' +import { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' +import * as composition from './store-domain-composition' +import type { StoreRuntimeState } from './store-runtime-state' +import { ProfileStateRevisionConflictError } from '../profile-state/profile-state-document-validation' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +async function snapshotFixture() { + let captured: StoreRuntimeState | undefined + const createDomains = composition.createStoreDomains + vi.spyOn(composition, 'createStoreDomains').mockImplementationOnce((runtime) => { + captured = runtime + return createDomains(runtime) + }) + const state = await fixture() + if (!captured) { + throw new Error('Store runtime was not initialized') + } + return { ...state, runtime: captured } +} + +describe('queued worker snapshot batching', () => { + it.each(['explicit', 'debounce'] as const)( + 'preserves getter-only edits when an explicit flush joins a dirty %s batch', + async (firstFlush) => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + vi.useFakeTimers() + try { + const mutation = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await gate.started.promise + const first = + firstFlush === 'explicit' + ? store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + : await vi.advanceTimersByTimeAsync(1_000) + store.getWorkspaceSession().activeTabId = 'getter-only-edit' + store.updateSettings({ theme: 'light' }) + const second = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + gate.finish.resolve() + await Promise.all([mutation, first, second]) + expect(readState()).toMatchObject({ + settings: { theme: 'light' }, + workspaceSession: { activeTabId: 'getter-only-edit' } + }) + expect(authority.captures).toHaveLength(2) + } finally { + gate.finish.resolve() + vi.useRealTimers() + } + } + ) + + it('preserves a queued explicit capture when a later debounce joins it', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + vi.useFakeTimers() + try { + const mutation = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await gate.started.promise + store.getWorkspaceSession().activeTabId = 'getter-only-edit' + const explicit = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + store.updateSettings({ theme: 'light' }) + await vi.advanceTimersByTimeAsync(1_000) + gate.finish.resolve() + await Promise.all([mutation, explicit]) + expect(readState()).toMatchObject({ + settings: { theme: 'light' }, + workspaceSession: { activeTabId: 'getter-only-edit' } + }) + expect(authority.captures).toHaveLength(2) + } finally { + gate.finish.resolve() + vi.useRealTimers() + } + }) + + it('keeps batches of only debounced saves selective', async () => { + const { store, authority, readState } = await fixture() + const full = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const gate = authority.pause() + vi.useFakeTimers() + try { + const mutation = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await gate.started.promise + await vi.advanceTimersByTimeAsync(1_000) + store.updateSettings({ theme: 'light' }) + await vi.advanceTimersByTimeAsync(1_000) + gate.finish.resolve() + await mutation + await store.waitForPendingWrite() + expect(readState().settings.theme).toBe('light') + expect(authority.captures).toHaveLength(2) + expect(full).not.toHaveBeenCalled() + } finally { + gate.finish.resolve() + vi.useRealTimers() + } + }) + + it('captures getter-only edits in a flush requested after the preceding capture started', async () => { + const { store, authority, readState } = await fixture() + const fullCapture = vi.spyOn( + StateSerializationSecretHandlingOperations.prototype, + 'buildStateToSave' + ) + const firstGate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await firstGate.started.promise + store.getWorkspaceSession().activeTabId = 'getter-only-edit' + const second = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + firstGate.finish.resolve() + await Promise.all([first, second]) + expect(readState().workspaceSession.activeTabId).toBe('getter-only-edit') + expect(fullCapture).toHaveBeenCalledOnce() + }) + + it('upgrades a queued debounce to capture an explicit getter-only flush', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + vi.useFakeTimers() + try { + const first = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await gate.started.promise + await vi.advanceTimersByTimeAsync(1_000) + store.getWorkspaceSession().activeTabId = 'explicit-edit' + const explicit = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + gate.finish.resolve() + await Promise.all([first, explicit]) + expect(readState().workspaceSession.activeTabId).toBe('explicit-edit') + } finally { + gate.finish.resolve() + vi.useRealTimers() + } + }) + + it('keeps a later flush ordered after an intervening durable mutation', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + const before = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + const mutation = store.runDurableMutation(() => { + store.updateSettings({ theme: 'light' }) + return { value: undefined } + }) + const after = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + const verifyAfter = after.then(() => expect(readState().settings.theme).toBe('light')) + gate.finish.resolve() + await Promise.all([first, before, mutation, verifyAfter]) + }) + + it('shares a queued write failure with its waiters and allows a fresh retry', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + store.updateSettings({ theme: 'light' }) + const failed = [ + store.flushPendingOrThrowAsync({ drainToStableGeneration: false }), + store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + ].map((waiter) => expect(waiter).rejects.toThrow('disk refused')) + vi.spyOn(authority, 'writeCompleteSerializedDomains').mockRejectedValueOnce( + new Error('disk refused') + ) + gate.finish.resolve() + await Promise.all([first, ...failed]) + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + expect(readState().settings.theme).toBe('light') + }) + + it('checks the disk revision for a clean batch and rejects every waiter on conflict', async () => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const conflict = new ProfileStateRevisionConflictError(1, 2) + const revisionCheck = vi + .spyOn(authority, 'assertCurrentRevision') + .mockRejectedValueOnce(conflict) + const waiters = [ + store.flushPendingOrThrowAsync({ drainToStableGeneration: false }), + store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + ] + await Promise.all(waiters.map((waiter) => expect(waiter).rejects.toBe(conflict))) + expect(revisionCheck).toHaveBeenCalledOnce() + }) + + it('waits for snapshot files admitted by a later member of the queued batch', async () => { + const { store, authority, readState, runtime } = await snapshotFixture() + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + const second = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + const snapshot = deferred() + runtime.pendingSnapshotFileWork = snapshot.promise + store.updateSettings({ theme: 'light' }) + const third = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + try { + gate.finish.resolve() + await first + await new Promise((resolve) => setImmediate(resolve)) + expect(authority.captures).toHaveLength(1) + } finally { + snapshot.resolve() + runtime.pendingSnapshotFileWork = null + await Promise.all([second, third]) + } + expect(readState().settings.theme).toBe('light') + }) + + it('discards a queued batch when its predecessor dependency rejects', async () => { + const { store, readState, runtime } = await snapshotFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + runtime.pendingSnapshotFileWork = Promise.reject(new Error('snapshot preparation failed')) + const waiters = [ + store.flushPendingOrThrowAsync({ drainToStableGeneration: false }), + store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + ] + await Promise.all( + waiters.map((waiter) => expect(waiter).rejects.toThrow('snapshot preparation failed')) + ) + runtime.pendingSnapshotFileWork = null + store.updateSettings({ theme: 'light' }) + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + expect(readState().settings.theme).toBe('light') + }) +}) diff --git a/src/main/persistence/loading-store/pty-binding-async-durability.test.ts b/src/main/persistence/loading-store/pty-binding-async-durability.test.ts new file mode 100644 index 00000000000..066cea3cce5 --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-async-durability.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it, vi } from 'vitest' +import { TEST_LEAF_1, TEST_LEAF_2 } from '../../persistence-session-fixtures' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' +import { fixture } from './profile-state-delayed-authority-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'async-binding-tab', + leafId: TEST_LEAF_1, + ptyId: 'async-binding-pty', + incarnationId: 'async-binding-incarnation' +} + +describe('durable asynchronous PTY binding', () => { + it('acknowledges only after the binding reaches SQLite', async () => { + const { store, authority, readState } = await fixture() + store.getWorkspaceSession().activeWorktreeIdsOnShutdown = [] + await store.flushPendingOrThrowAsync() + const gate = authority.pause() + let acknowledged = false + const pending = store.persistPtyBinding(binding).then((result) => { + acknowledged = true + return result + }) + await gate.started.promise + expect(acknowledged).toBe(false) + expect(readState().workspaceSession.terminalLayoutsByTabId[binding.tabId]).toBeUndefined() + expect(readState().workspaceSession.activeWorktreeIdsOnShutdown).toEqual([]) + gate.finish.resolve() + expect(await pending).toBe(true) + expect( + readState().workspaceSession.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId + ).toEqual({ + [binding.leafId]: binding.ptyId + }) + expect(readState().workspaceSession.activeWorktreeIdsOnShutdown).toEqual([binding.worktreeId]) + }) + + it('repairs activity on an otherwise matching durable reattach', async () => { + const { store, authority, readState } = await fixture() + await store.persistPtyBinding(binding) + store.getWorkspaceSession().activeWorktreeIdsOnShutdown = [] + await store.flushPendingOrThrowAsync() + authority.captures.length = 0 + await store.persistPtyBinding(binding) + expect(readState().workspaceSession.activeWorktreeIdsOnShutdown).toEqual([binding.worktreeId]) + expect(authority.captures).toHaveLength(1) + await store.persistPtyBinding(binding) + expect(authority.captures).toHaveLength(1) + }) + + it('evaluates membership refusal after an older write finishes', async () => { + const { store, authority } = await fixture() + await store.persistPtyBinding(binding) + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const older = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + const resolveBinding = vi.fn(() => ({ ...binding, mayCreate: false })) + const queued = store.persistPtyBinding(resolveBinding) + expect(resolveBinding).not.toHaveBeenCalled() + const session = store.getWorkspaceSession() + session.tabsByWorktree[binding.worktreeId] = [] + delete session.terminalLayoutsByTabId[binding.tabId] + store.setWorkspaceSession(session) + gate.finish.resolve() + await older + expect(await queued).toBe(false) + expect(resolveBinding).toHaveBeenCalledTimes(1) + }) + + it('restores the binding after a known write failure', async () => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + store.getWorkspaceSession().activeWorktreeIdsOnShutdown = [] + const before = structuredClone(store.getWorkspaceSession()) + const gate = authority.pause() + const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('disk refused') + await gate.started.promise + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + expect(store.getWorkspaceSession()).toEqual(before) + }) + + it('preserves newer getter edits when an older binding write fails', async () => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('disk refused') + await gate.started.promise + store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId = { + [binding.leafId]: 'newer-pty' + } + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + expect( + store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId + ).toEqual({ + [binding.leafId]: 'newer-pty' + }) + }) + + it.each(['tab title', 'pane title'] as const)( + 'rolls back a failed replacement while preserving a newer %s', + async (edit) => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await store.persistPtyBinding(binding) + const gate = authority.pause() + const rejected = expect( + store.persistPtyBinding({ + ...binding, + ptyId: 'failed-replacement', + incarnationId: 'failed-incarnation', + expectedBinding: binding + }) + ).rejects.toThrow('disk refused') + await gate.started.promise + const session = store.getWorkspaceSession() + const tab = session.tabsByWorktree[binding.worktreeId].find( + (candidate) => candidate.id === binding.tabId + ) + if (!tab) { + throw new Error('binding did not create its terminal row') + } + if (edit === 'tab title') { + tab.customTitle = 'new title' + } else { + session.terminalLayoutsByTabId[binding.tabId].titlesByLeafId = { + [binding.leafId]: 'new title' + } + } + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + await store.flushPendingOrThrowAsync() + const persisted = readState().workspaceSession + expect(persisted.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId).toEqual({ + [binding.leafId]: binding.ptyId + }) + expect(persisted.terminalPtyIncarnationsByPaneKey[`${binding.tabId}:${binding.leafId}`]).toBe( + binding.incarnationId + ) + if (edit === 'tab title') { + expect(persisted.tabsByWorktree[binding.worktreeId]).toContainEqual( + expect.objectContaining({ id: binding.tabId, customTitle: 'new title' }) + ) + } else { + expect(persisted.terminalLayoutsByTabId[binding.tabId].titlesByLeafId).toEqual({ + [binding.leafId]: 'new title' + }) + } + } + ) + + it('rolls back a failed binding while retaining an unrelated newer navigation edit', async () => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('disk refused') + await gate.started.promise + store.getWorkspaceSession().activeRepoId = 'newer-repo' + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + expect(store.getWorkspaceSession().activeRepoId).toBe('newer-repo') + expect(store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId]).toBeUndefined() + }) + + it('retains a newer root sibling when rolling back a failed leaf replacement', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await store.persistPtyBinding(binding) + const gate = authority.pause() + const rejected = expect( + store.persistPtyBinding({ ...binding, ptyId: 'failed-replacement' }) + ).rejects.toThrow('disk refused') + await gate.started.promise + const session = store.getWorkspaceSession() + const tab = session.tabsByWorktree[binding.worktreeId].find( + (candidate) => candidate.id === binding.tabId + ) + if (!tab) { + throw new Error('binding did not create its terminal row') + } + const layout = session.terminalLayoutsByTabId[binding.tabId] + layout.root = { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: TEST_LEAF_2 }, + second: { type: 'leaf', leafId: binding.leafId } + } + layout.ptyIdsByLeafId = { ...layout.ptyIdsByLeafId, [TEST_LEAF_2]: 'new-root-pty' } + tab.ptyId = 'new-root-pty' + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + await store.flushPendingOrThrowAsync() + const persisted = readState().workspaceSession + expect(persisted.terminalLayoutsByTabId[binding.tabId].root).toEqual(layout.root) + expect(persisted.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId).toEqual({ + [binding.leafId]: binding.ptyId, + [TEST_LEAF_2]: 'new-root-pty' + }) + expect(persisted.tabsByWorktree[binding.worktreeId]).toContainEqual( + expect.objectContaining({ id: binding.tabId, ptyId: 'new-root-pty' }) + ) + }) + + it('removes a failed new binding while retaining a newer sibling tab', async () => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('disk refused') + await gate.started.promise + const tabs = store.getWorkspaceSession().tabsByWorktree[binding.worktreeId] + const boundTab = tabs.find((tab) => tab.id === binding.tabId) + if (!boundTab) { + throw new Error('binding did not create its terminal row') + } + tabs.push({ ...boundTab, id: 'newer-sibling', ptyId: 'newer-sibling-pty' }) + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + expect( + store.getWorkspaceSession().tabsByWorktree[binding.worktreeId].map((tab) => tab.id) + ).toContain('newer-sibling') + expect( + store.getWorkspaceSession().tabsByWorktree[binding.worktreeId].map((tab) => tab.id) + ).not.toContain(binding.tabId) + expect(store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId]).toBeUndefined() + }) + + it('retains a binding whose commit outcome is indeterminate', async () => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('worker exited') + await gate.started.promise + gate.finish.reject( + new ProfileStateWriterError('test-worker-exit', 'worker exited', 'indeterminate') + ) + await rejected + expect( + store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId + ).toEqual({ + [binding.leafId]: binding.ptyId + }) + }) +}) diff --git a/src/main/persistence/loading-store/pty-binding-created-surface-rollback.test.ts b/src/main/persistence/loading-store/pty-binding-created-surface-rollback.test.ts new file mode 100644 index 00000000000..0feed681bf0 --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-created-surface-rollback.test.ts @@ -0,0 +1,135 @@ +import { expect, it, vi } from 'vitest' +import { TEST_LEAF_1, TEST_LEAF_2 } from '../../persistence-session-fixtures' +import { collectLayoutLeafIdsInOrder } from '../restoring-sessions/terminal-layout-normalization' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' +import { fixture } from './profile-state-delayed-authority-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +it.each(['tab title', 'pane title', 'new sibling'] as const)( + 'retains a valid unbound new surface after a failed binding and newer %s', + async (edit) => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'new-binding-tab', + leafId: TEST_LEAF_1, + ptyId: 'failed-pty', + incarnationId: 'failed-incarnation' + } + const gate = authority.pause() + const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('disk refused') + await gate.started.promise + const session = store.getWorkspaceSession() + const tab = session.tabsByWorktree[binding.worktreeId].find( + (candidate) => candidate.id === binding.tabId + ) + if (!tab) { + throw new Error('binding did not create its terminal row') + } + const layout = session.terminalLayoutsByTabId[binding.tabId] + if (edit === 'tab title') { + tab.customTitle = 'new title' + } else if (edit === 'pane title') { + layout.titlesByLeafId = { [binding.leafId]: 'new title' } + } else { + layout.root = { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: binding.leafId }, + second: { type: 'leaf', leafId: TEST_LEAF_2 } + } + layout.ptyIdsByLeafId = { ...layout.ptyIdsByLeafId, [TEST_LEAF_2]: 'sibling-pty' } + session.terminalPtyIncarnationsByPaneKey = { + ...session.terminalPtyIncarnationsByPaneKey, + [`${binding.tabId}:${TEST_LEAF_2}`]: 'sibling-incarnation' + } + tab.ptyId = 'sibling-pty' + } + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + await store.flushPendingOrThrowAsync() + const persisted = readState().workspaceSession + expect(JSON.stringify(persisted)).not.toContain('failed-pty') + expect(JSON.stringify(persisted)).not.toContain('failed-incarnation') + expect(persisted.tabsByWorktree[binding.worktreeId]).toContainEqual( + expect.objectContaining({ + id: binding.tabId, + worktreeId: binding.worktreeId, + createdAt: expect.any(Number), + ptyId: edit === 'new sibling' ? 'sibling-pty' : null, + ...(edit === 'tab title' ? { customTitle: 'new title' } : {}) + }) + ) + expect(persisted.terminalLayoutsByTabId[binding.tabId]).toMatchObject({ + root: layout.root, + ...(edit === 'pane title' ? { titlesByLeafId: { [binding.leafId]: 'new title' } } : {}) + }) + if (edit === 'new sibling') { + expect(persisted.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId).toEqual({ + [TEST_LEAF_2]: 'sibling-pty' + }) + expect(persisted.terminalPtyIncarnationsByPaneKey).toEqual({ + [`${binding.tabId}:${TEST_LEAF_2}`]: 'sibling-incarnation' + }) + } + } +) + +it('preserves the valid newer tree after a failed split insertion', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'split-binding-tab', + leafId: TEST_LEAF_1, + ptyId: 'original-pty', + incarnationId: 'original-incarnation' + } + await store.persistPtyBinding(binding) + const gate = authority.pause() + const rejected = expect( + store.persistPtyBinding({ + ...binding, + leafId: TEST_LEAF_2, + ptyId: 'failed-pty', + incarnationId: 'failed-incarnation' + }) + ).rejects.toThrow('disk refused') + await gate.started.promise + const layout = store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId] + if (!layout.root) { + throw new Error('binding did not create its layout') + } + const laterLeaf = '33333333-3333-4333-8333-333333333333' + layout.root = { + type: 'split', + direction: 'horizontal', + first: layout.root, + second: { type: 'leaf', leafId: laterLeaf } + } + layout.ptyIdsByLeafId = { ...layout.ptyIdsByLeafId, [laterLeaf]: 'later-sibling-pty' } + const expectedRoot = structuredClone(layout.root) + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + await store.flushPendingOrThrowAsync() + const persisted = readState().workspaceSession.terminalLayoutsByTabId[binding.tabId] + expect(persisted.root).toEqual(expectedRoot) + expect(collectLayoutLeafIdsInOrder(persisted.root)).toContain(laterLeaf) + expect(persisted.ptyIdsByLeafId).toEqual({ + [TEST_LEAF_1]: 'original-pty', + [laterLeaf]: 'later-sibling-pty' + }) +}) diff --git a/src/main/persistence/loading-store/pty-binding-fast-lane.test.ts b/src/main/persistence/loading-store/pty-binding-fast-lane.test.ts index 8ac6de70a0d..3f8cc1c7e7d 100644 --- a/src/main/persistence/loading-store/pty-binding-fast-lane.test.ts +++ b/src/main/persistence/loading-store/pty-binding-fast-lane.test.ts @@ -71,6 +71,7 @@ describe('evaluatePtyBindingFastLane', () => { ) ).toEqual(['layout_missing']) expect(miss({ incarnationId: 'a' })).toEqual(['incarnation']) + expect(miss({}, session({ activeWorktreeIdsOnShutdown: [] }))).toEqual(['inactive_worktree']) expect(miss({}, session({ terminalPtyIncarnationsByPaneKey: { [paneKey]: 'a' } }))).toEqual([ 'incarnation' ]) diff --git a/src/main/persistence/loading-store/pty-binding-fast-lane.ts b/src/main/persistence/loading-store/pty-binding-fast-lane.ts index 9ae6304ed0f..3f0763c883f 100644 --- a/src/main/persistence/loading-store/pty-binding-fast-lane.ts +++ b/src/main/persistence/loading-store/pty-binding-fast-lane.ts @@ -18,6 +18,7 @@ export type PtyBindingFastLaneMiss = | 'leaf_pty' | 'incarnation' | 'tombstone' + | 'inactive_worktree' | 'not_durable' export type PtyBindingFastLaneRequest = { @@ -80,6 +81,12 @@ export function evaluatePtyBindingFastLane( if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { misses.push('tombstone') } + if ( + session.activeWorktreeIdsOnShutdown && + !session.activeWorktreeIdsOnShutdown.includes(bindingWorktreeId) + ) { + misses.push('inactive_worktree') + } if (!durable) { misses.push('not_durable') } diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index c3cbff0796a..c90544f7502 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -1,15 +1,9 @@ +import { isDeepStrictEqual } from 'node:util' import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../shared/execution-host' import { isTerminalLeafId } from '../../../shared/stable-pane-id' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' -import { - cloneLayoutNode, - layoutContainsLeafId -} from '../restoring-sessions/terminal-layout-normalization' -import { - cloneWorkspaceSessionState, - createMinimalPersistedTerminalTab -} from '../restoring-sessions/session-owner-fields' +import { rollbackFailedPtyBinding } from './pty-binding-write-rollback' +import { cloneWorkspaceSessionState } from '../restoring-sessions/session-owner-fields' import type { PtyBindingSourceExpectation } from './store' @@ -18,21 +12,22 @@ import type { SessionHostPartitionOperations } from './session-host-partitions' import { resolveHostId } from './session-host-partitions' import { evaluatePtyBindingFastLane } from './pty-binding-fast-lane' import { ptyBindingIsRefused } from './pty-binding-refusals' -import { startPtyBindingSpan, type PtyBindingOrigin } from './pty-binding-span' -import { tabRowPtyIdAfterLeafBinding } from './terminal-tab-pty-ownership' +import { startPtyBindingSpan, type PtyBindingOrigin, type PtyBindingSpan } from './pty-binding-span' +import { applyPtyBinding } from './pty-binding-session-update' type PtyBindingPersistenceOperationsRuntime = Pick< StoreRuntimeState, - | 'flushOrThrow' + | 'runDurableMutation' | 'lastDurableWriteGeneration' | 'pendingWrite' | 'quitFlushStarted' + | 'dirtyProfileStateDomains' | 'state' | 'writeGeneration' | 'writeTimer' > -type PersistPtyBindingArgs = { +export type PersistPtyBindingArgs = { worktreeId: string tabId: string leafId: string @@ -72,43 +67,60 @@ export class PtyBindingPersistenceOperations { this[ptyBindingPersistenceOperationsContext] = { runtime, sessions } } - persistPtyBinding(args: PersistPtyBindingArgs, hostId?: string | null): boolean { - const runtime = this[ptyBindingPersistenceOperationsContext].runtime + async persistPtyBinding( + input: PersistPtyBindingArgs | (() => PersistPtyBindingArgs | null), + hostId?: string | null + ): Promise { + const { runtime, sessions } = this[ptyBindingPersistenceOperationsContext] const resolvedHostId = resolveHostId(hostId) - const session = - this[ptyBindingPersistenceOperationsContext].sessions.getWorkspaceSession(resolvedHostId) - const paneKey = `${args.tabId}:${args.leafId}` - const bindingWorktreeId = args.expectedSourceBinding?.worktreeId ?? args.worktreeId - const span = startPtyBindingSpan({ - hostKind: parseExecutionHostId(resolvedHostId)?.kind ?? 'local', - origin: args.origin ?? 'unknown', - savePending: runtime.writeTimer !== null || runtime.pendingWrite !== null, - generationGap: runtime.writeGeneration - runtime.lastDurableWriteGeneration - }) - if (ptyBindingIsRefused(args, session, bindingWorktreeId, paneKey)) { - span.finish('refused') - return false - } - // A durable reattach needs neither a session clone nor whole-state serialization. - const verdict = evaluatePtyBindingFastLane( - args, - session, - bindingWorktreeId, - !runtime.quitFlushStarted && runtime.lastDurableWriteGeneration >= runtime.writeGeneration - ) - span.setEligibility(verdict) - if (verdict.eligible) { - span.finish('fast_lane') - return true - } + const savePending = runtime.writeTimer !== null || runtime.pendingWrite !== null + let span: PtyBindingSpan | undefined + let outcome: 'refused' | 'fast_lane' | 'flushed' = 'flushed' try { - writePtyBinding(this, args, session, resolvedHostId, bindingWorktreeId, paneKey) - } catch (err) { - span.finish('threw', err) - throw err + const persisted = await runtime.runDurableMutation(() => { + const args = typeof input === 'function' ? input() : input + if (!args) { + return { value: false, persist: false } + } + // Measure the admitted binding operation; queue time precedes its current-state checks. + span = startPtyBindingSpan({ + hostKind: parseExecutionHostId(resolvedHostId)?.kind ?? 'local', + origin: args.origin ?? 'unknown', + savePending, + generationGap: runtime.writeGeneration - runtime.lastDurableWriteGeneration + }) + const paneKey = `${args.tabId}:${args.leafId}` + const bindingWorktreeId = args.expectedSourceBinding?.worktreeId ?? args.worktreeId + const session = sessions.getWorkspaceSession(resolvedHostId) + const partitions = sessions + .getWorkspaceSessionHostIds() + .map((hostId) => sessions.getWorkspaceSession(hostId)) + if (ptyBindingIsRefused(args, session, bindingWorktreeId, paneKey, partitions)) { + outcome = 'refused' + return { value: false, persist: false } + } + const verdict = evaluatePtyBindingFastLane( + args, + session, + bindingWorktreeId, + !runtime.quitFlushStarted && runtime.lastDurableWriteGeneration >= runtime.writeGeneration + ) + span.setEligibility(verdict) + if (verdict.eligible) { + outcome = 'fast_lane' + return { value: true, persist: false } + } + return { + value: true, + rollback: writePtyBinding(this, args, session, resolvedHostId, bindingWorktreeId, paneKey) + } + }) + span?.finish(outcome) + return persisted + } catch (error) { + span?.finish('threw', error) + throw error } - span.finish('flushed') - return true } } @@ -119,9 +131,19 @@ function writePtyBinding( resolvedHostId: ReturnType, bindingWorktreeId: string, paneKey: string -): void { - const runtime = owner[ptyBindingPersistenceOperationsContext].runtime +): () => void { + const { runtime, sessions } = owner[ptyBindingPersistenceOperationsContext] const sessionBeforeBinding = cloneWorkspaceSessionState(session) + const restore = (restoredSession = sessionBeforeBinding): void => { + if (resolvedHostId === LOCAL_EXECUTION_HOST_ID) { + runtime.state.workspaceSession = restoredSession + } else { + runtime.state.workspaceSessionsByHostId = { + ...runtime.state.workspaceSessionsByHostId, + [resolvedHostId]: restoredSession + } + } + } try { if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) { runtime.state.workspaceSessionsByHostId = { @@ -130,144 +152,44 @@ function writePtyBinding( } } applyPtyBinding(args, session, bindingWorktreeId, paneKey) - runtime.flushOrThrow() - } catch (err) { - if (resolvedHostId === LOCAL_EXECUTION_HOST_ID) { - runtime.state.workspaceSession = sessionBeforeBinding - } else { - runtime.state.workspaceSessionsByHostId = { - ...runtime.state.workspaceSessionsByHostId, - [resolvedHostId]: sessionBeforeBinding - } - } - throw err - } -} - -function applyPtyBinding( - args: PersistPtyBindingArgs, - session: WorkspaceSessionState, - bindingWorktreeId: string, - paneKey: string -): void { - const reconciledIncarnation = - args.expectedBinding !== undefined && args.incarnationId !== args.expectedBinding.incarnationId - let terminalMembershipChanged = false - let hostAdmittedTabCreated = false - const advanceTopologyFence = (): void => { - const repoId = getRepoIdFromWorktreeId(bindingWorktreeId) - const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 - // Why: a split, or a host-admitted tab the renderer has never seen, is itself - // the authority — with no fence the renderer's pre-create tab list replays - // over it and the tab is lost even on the repo's first such change. - const establishesMembershipAuthority = - args.expectedSourceBinding !== undefined || hostAdmittedTabCreated - if ( - !reconciledIncarnation && - (!terminalMembershipChanged || (currentRevision <= 0 && !establishesMembershipAuthority)) - ) { - return - } - // Why: host-admitted membership or incarnation changes must outrank a stale renderer replay. - session.terminalTopologyRevisionByRepoId = { - ...session.terminalTopologyRevisionByRepoId, - [repoId]: currentRevision + 1 - } - } - if (args.incarnationId) { - session.terminalPtyIncarnationsByPaneKey = { - ...session.terminalPtyIncarnationsByPaneKey, - [paneKey]: args.incarnationId - } - if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { - session.terminalSurfaceTombstonesByPaneKey = { - ...session.terminalSurfaceTombstonesByPaneKey - } - delete session.terminalSurfaceTombstonesByPaneKey[paneKey] - } - } - const tabs = session.tabsByWorktree?.[bindingWorktreeId] - const tab = tabs?.find((t) => t.id === args.tabId) - if (tab) { - tab.ptyId = tabRowPtyIdAfterLeafBinding( - tab, - session.terminalLayoutsByTabId?.[args.tabId]?.ptyIdsByLeafId, - args.leafId, - args.ptyId + runtime.dirtyProfileStateDomains?.add( + resolvedHostId === LOCAL_EXECUTION_HOST_ID ? 'workspaceSession' : 'workspaceSessionsByHostId' ) - } else { - terminalMembershipChanged = true - hostAdmittedTabCreated = args.hostAdmittedMembership === true - // Why: pty:spawn can beat the debounced writer; persist a minimal tab so hydration won't prune the binding as orphaned. - const nextTabs = [ - ...(tabs ?? []), - createMinimalPersistedTerminalTab({ - ...args, - worktreeId: bindingWorktreeId, - existingTabCount: tabs?.length ?? 0 - }) - ] - session.tabsByWorktree = { - ...session.tabsByWorktree, - [bindingWorktreeId]: nextTabs - } - session.activeWorktreeId ??= bindingWorktreeId - session.activeTabId ??= args.tabId - session.activeTabIdByWorktree = { - ...session.activeTabIdByWorktree, - [bindingWorktreeId]: session.activeTabIdByWorktree?.[bindingWorktreeId] ?? args.tabId - } - } - // Why: host-initiated persist snapshots used to omit this write-once guard, so every launch or reattach treated the worktree as never having default terminals applied. - session.defaultTerminalTabsAppliedByWorktreeId = { - ...session.defaultTerminalTabsAppliedByWorktreeId, - [bindingWorktreeId]: true - } - if (!isTerminalLeafId(args.leafId)) { - // Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration. - advanceTopologyFence() - return - } - const layout = session.terminalLayoutsByTabId?.[args.tabId] - if (layout) { - if (!layout.root) { - terminalMembershipChanged = true - // Why: createTab can persist an empty layout before TerminalPane mounts; the sync binding still needs a durable root. - layout.root = { type: 'leaf', leafId: args.leafId } - layout.activeLeafId = args.leafId - layout.expandedLeafId = null - } else if (!layoutContainsLeafId(layout.root, args.leafId)) { - terminalMembershipChanged = true - // Why: splitPane spawns before its snapshot reaches main; add a minimal leaf so a crash can't strand the pane's binding. - layout.root = { - type: 'split', - direction: 'vertical', - first: cloneLayoutNode(layout.root), - second: { type: 'leaf', leafId: args.leafId } + const boundSession = cloneWorkspaceSessionState(session) + return () => { + const current = sessions.getWorkspaceSession(resolvedHostId) + const ownerState = (value: WorkspaceSessionState) => { + const tab = value.tabsByWorktree[bindingWorktreeId]?.find((tab) => tab.id === args.tabId) + return { + createdAt: tab?.createdAt, + generation: tab?.generation, + worktreeId: tab?.worktreeId, + ptyId: isTerminalLeafId(args.leafId) + ? value.terminalLayoutsByTabId[args.tabId]?.ptyIdsByLeafId?.[args.leafId] + : tab?.ptyId, + incarnation: value.terminalPtyIncarnationsByPaneKey?.[paneKey] + } } - layout.activeLeafId = args.leafId - if (layout.expandedLeafId && !layoutContainsLeafId(layout.root, layout.expandedLeafId)) { - layout.expandedLeafId = null + // Presentation edits do not replace the binding that must be rolled back. + if (!isDeepStrictEqual(ownerState(current), ownerState(boundSession))) { + return } - } - layout.ptyIdsByLeafId = { - ...layout.ptyIdsByLeafId, - [args.leafId]: args.ptyId - } - } else { - terminalMembershipChanged = true - // Why: first tab spawn — persist a minimal layout so a SIGKILL before the renderer snapshot can't lose ptyIdsByLeafId. - session.terminalLayoutsByTabId = { - ...session.terminalLayoutsByTabId, - [args.tabId]: { - root: { type: 'leaf', leafId: args.leafId }, - activeLeafId: args.leafId, - expandedLeafId: null, - ptyIdsByLeafId: { [args.leafId]: args.ptyId } + const rolledBack = rollbackFailedPtyBinding( + sessionBeforeBinding, + boundSession, + current, + bindingWorktreeId, + args.tabId, + args.leafId + ) + if (rolledBack !== current) { + restore(rolledBack) } } + } catch (error) { + restore() + throw error } - advanceTopologyFence() } export function installPtyBindingPersistenceOperationsContext( diff --git a/src/main/persistence/loading-store/pty-binding-refusals.ts b/src/main/persistence/loading-store/pty-binding-refusals.ts index 3c61056bfca..636e69bd936 100644 --- a/src/main/persistence/loading-store/pty-binding-refusals.ts +++ b/src/main/persistence/loading-store/pty-binding-refusals.ts @@ -1,3 +1,4 @@ +import { hasClosedTerminalTabRecord } from '../../../shared/closed-terminal-tab-tombstones' import { isTerminalLeafId } from '../../../shared/stable-pane-id' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { layoutContainsLeafId } from '../restoring-sessions/terminal-layout-normalization' @@ -13,7 +14,7 @@ export type PtyBindingRefusalRequest = { } /** - * The four fences a binding must clear before anything is mutated, so a refusal leaves nothing + * The five fences a binding must clear before anything is mutated, so a refusal leaves nothing * half-written. Order matters: every `false` here is returned before the write path or the * fast lane can run, which is what the relay's lease expiry and the stable-owner throw rely on. */ @@ -21,7 +22,10 @@ export function ptyBindingIsRefused( args: PtyBindingRefusalRequest, session: WorkspaceSessionState, bindingWorktreeId: string, - paneKey: string + paneKey: string, + /** Every host partition: a close is recorded where the tab lived, which need not be where this + * binding lands (a relay reattach binds into `local`). */ + partitions: readonly WorkspaceSessionState[] = [session] ): boolean { if (args.expectedSourceBinding) { const expected = args.expectedSourceBinding @@ -56,6 +60,19 @@ export function ptyBindingIsRefused( return true } } + const existingTab = session.tabsByWorktree?.[bindingWorktreeId]?.find( + (candidate) => candidate.id === args.tabId + ) + // Why: a closed tab's spawn can commit after the close, even after a crash and relaunch; tab + // ids are uuids, so a recorded id is never a new tab. + if ( + !existingTab && + partitions.some((partition) => + hasClosedTerminalTabRecord(partition.closedTerminalTabTombstonesByTabId, args.tabId) + ) + ) { + return true + } // Mirrors the four creating branches of the write path — mint a tab, mint a root leaf, split // the root and graft a leaf, mint a layout — each of which sets `terminalMembershipChanged`. if ( @@ -65,9 +82,6 @@ export function ptyBindingIsRefused( return true } if (args.mayCreate === false) { - const existingTab = session.tabsByWorktree?.[bindingWorktreeId]?.find( - (candidate) => candidate.id === args.tabId - ) const existingLayout = session.terminalLayoutsByTabId?.[args.tabId] const wouldCreateTopology = !existingTab || diff --git a/src/main/persistence/loading-store/pty-binding-session-update.ts b/src/main/persistence/loading-store/pty-binding-session-update.ts new file mode 100644 index 00000000000..3024543505a --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-session-update.ts @@ -0,0 +1,146 @@ +import { isTerminalLeafId } from '../../../shared/stable-pane-id' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' +import { + cloneLayoutNode, + layoutContainsLeafId +} from '../restoring-sessions/terminal-layout-normalization' +import { createMinimalPersistedTerminalTab } from '../restoring-sessions/session-owner-fields' +import { tabRowPtyIdAfterLeafBinding } from './terminal-tab-pty-ownership' +import type { PersistPtyBindingArgs } from './pty-binding-persistence' + +export function applyPtyBinding( + args: PersistPtyBindingArgs, + session: WorkspaceSessionState, + bindingWorktreeId: string, + paneKey: string +): void { + const reconciledIncarnation = + args.expectedBinding !== undefined && args.incarnationId !== args.expectedBinding.incarnationId + let terminalMembershipChanged = false + let hostAdmittedTabCreated = false + const advanceTopologyFence = (): void => { + const repoId = getRepoIdFromWorktreeId(bindingWorktreeId) + const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 + // Why: a split, or a host-admitted tab the renderer has never seen, is itself + // the authority — with no fence the renderer's pre-create tab list replays + // over it and the tab is lost even on the repo's first such change. + const establishesMembershipAuthority = + args.expectedSourceBinding !== undefined || hostAdmittedTabCreated + if ( + !reconciledIncarnation && + (!terminalMembershipChanged || (currentRevision <= 0 && !establishesMembershipAuthority)) + ) { + return + } + // Why: host-admitted membership or incarnation changes must outrank a stale renderer replay. + session.terminalTopologyRevisionByRepoId = { + ...session.terminalTopologyRevisionByRepoId, + [repoId]: currentRevision + 1 + } + } + if (args.incarnationId) { + session.terminalPtyIncarnationsByPaneKey = { + ...session.terminalPtyIncarnationsByPaneKey, + [paneKey]: args.incarnationId + } + if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { + session.terminalSurfaceTombstonesByPaneKey = { + ...session.terminalSurfaceTombstonesByPaneKey + } + delete session.terminalSurfaceTombstonesByPaneKey[paneKey] + } + } + const tabs = session.tabsByWorktree?.[bindingWorktreeId] + const tab = tabs?.find((t) => t.id === args.tabId) + if (tab) { + tab.ptyId = tabRowPtyIdAfterLeafBinding( + tab, + session.terminalLayoutsByTabId?.[args.tabId]?.ptyIdsByLeafId, + args.leafId, + args.ptyId + ) + } else { + terminalMembershipChanged = true + hostAdmittedTabCreated = args.hostAdmittedMembership === true + // Why: pty:spawn can beat the debounced writer; persist a minimal tab so hydration won't prune the binding as orphaned. + const nextTabs = [ + ...(tabs ?? []), + createMinimalPersistedTerminalTab({ + ...args, + worktreeId: bindingWorktreeId, + existingTabCount: tabs?.length ?? 0 + }) + ] + session.tabsByWorktree = { + ...session.tabsByWorktree, + [bindingWorktreeId]: nextTabs + } + session.activeWorktreeId ??= bindingWorktreeId + session.activeTabId ??= args.tabId + session.activeTabIdByWorktree = { + ...session.activeTabIdByWorktree, + [bindingWorktreeId]: session.activeTabIdByWorktree?.[bindingWorktreeId] ?? args.tabId + } + } + // Why: host-initiated persist snapshots used to omit this write-once guard, so every launch or reattach treated the worktree as never having default terminals applied. + session.defaultTerminalTabsAppliedByWorktreeId = { + ...session.defaultTerminalTabsAppliedByWorktreeId, + [bindingWorktreeId]: true + } + // Acknowledged spawns must survive a crash before the renderer records their activity. + if ( + session.activeWorktreeIdsOnShutdown && + !session.activeWorktreeIdsOnShutdown.includes(bindingWorktreeId) + ) { + session.activeWorktreeIdsOnShutdown = [ + ...session.activeWorktreeIdsOnShutdown, + bindingWorktreeId + ] + } + if (!isTerminalLeafId(args.leafId)) { + // Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration. + advanceTopologyFence() + return + } + const layout = session.terminalLayoutsByTabId?.[args.tabId] + if (layout) { + if (!layout.root) { + terminalMembershipChanged = true + // Why: createTab can persist an empty layout before TerminalPane mounts; the sync binding still needs a durable root. + layout.root = { type: 'leaf', leafId: args.leafId } + layout.activeLeafId = args.leafId + layout.expandedLeafId = null + } else if (!layoutContainsLeafId(layout.root, args.leafId)) { + terminalMembershipChanged = true + // Why: splitPane spawns before its snapshot reaches main; add a minimal leaf so a crash can't strand the pane's binding. + layout.root = { + type: 'split', + direction: 'vertical', + first: cloneLayoutNode(layout.root), + second: { type: 'leaf', leafId: args.leafId } + } + layout.activeLeafId = args.leafId + if (layout.expandedLeafId && !layoutContainsLeafId(layout.root, layout.expandedLeafId)) { + layout.expandedLeafId = null + } + } + layout.ptyIdsByLeafId = { + ...layout.ptyIdsByLeafId, + [args.leafId]: args.ptyId + } + } else { + terminalMembershipChanged = true + // Why: first tab spawn — persist a minimal layout so a SIGKILL before the renderer snapshot can't lose ptyIdsByLeafId. + session.terminalLayoutsByTabId = { + ...session.terminalLayoutsByTabId, + [args.tabId]: { + root: { type: 'leaf', leafId: args.leafId }, + activeLeafId: args.leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [args.leafId]: args.ptyId } + } + } + } + advanceTopologyFence() +} diff --git a/src/main/persistence/loading-store/pty-binding-span.ts b/src/main/persistence/loading-store/pty-binding-span.ts index 58bed424a51..903d93c4967 100644 --- a/src/main/persistence/loading-store/pty-binding-span.ts +++ b/src/main/persistence/loading-store/pty-binding-span.ts @@ -10,11 +10,13 @@ export type PtyBindingSpanOutcome = 'fast_lane' | 'flushed' | 'refused' | 'threw */ export type PtyBindingOrigin = 'reattach' | 'spawn' | 'relay_reattach' | 'split' | 'unknown' +export type PtySpawnCommitOrigin = Extract + /** The spawn-commit paths share one rule: a split outranks a reattach, a reattach outranks a spawn. */ export function spawnCommitBindingOrigin( commit: { isReattach?: boolean; agentSessionEnsure?: { disposition: string } }, expectedSourceBinding?: unknown -): PtyBindingOrigin { +): PtySpawnCommitOrigin { if (expectedSourceBinding !== undefined) { return 'split' } diff --git a/src/main/persistence/loading-store/pty-binding-write-rollback.ts b/src/main/persistence/loading-store/pty-binding-write-rollback.ts new file mode 100644 index 00000000000..af7cb63d7b0 --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-write-rollback.ts @@ -0,0 +1,94 @@ +import { isDeepStrictEqual } from 'node:util' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from '../restoring-sessions/workspace-session-write-rollback' + +function restoreBindingSlot( + original: Record | undefined, + staged: Record | undefined, + current: Record | undefined, + key: string +): Record | undefined { + if (current?.[key] !== staged?.[key] || original?.[key] === staged?.[key]) { + return current + } + const restored = { ...current } + const previous = original?.[key] + if (previous === undefined) { + delete restored[key] + } else { + restored[key] = previous + } + return original === undefined && Object.keys(restored).length === 0 ? undefined : restored +} + +export function rollbackFailedPtyBinding( + original: WorkspaceSessionState, + staged: WorkspaceSessionState, + current: WorkspaceSessionState, + worktreeId: string, + tabId: string, + leafId: string +): WorkspaceSessionState { + const tab = (session: WorkspaceSessionState) => + session.tabsByWorktree[worktreeId]?.find((candidate) => candidate.id === tabId) + const stagedTab = tab(staged) + const originalLayout = original.terminalLayoutsByTabId[tabId] + const stagedLayout = staged.terminalLayoutsByTabId[tabId] + let baseline = original + if ( + stagedTab && + (!isDeepStrictEqual(tab(current), stagedTab) || + !isDeepStrictEqual(current.terminalLayoutsByTabId[tabId], stagedLayout)) + ) { + // Keep edited new surfaces structurally valid, without the failed process binding. + baseline = { + ...original, + tabsByWorktree: tab(original) + ? original.tabsByWorktree + : { + ...original.tabsByWorktree, + [worktreeId]: [ + ...(original.tabsByWorktree[worktreeId] ?? []), + { ...stagedTab, ptyId: null } + ] + }, + terminalLayoutsByTabId: + originalLayout || !stagedLayout + ? original.terminalLayoutsByTabId + : { + ...original.terminalLayoutsByTabId, + [tabId]: { ...stagedLayout, ptyIdsByLeafId: {} } + } + } + } + const restored = rollbackWorkspaceSessionAfterFailedAsyncWrite(baseline, staged, current) + const layout = restored.terminalLayoutsByTabId[tabId] + const currentRoot = current.terminalLayoutsByTabId[tabId]?.root + return { + ...restored, + ...(layout + ? { + terminalLayoutsByTabId: { + ...restored.terminalLayoutsByTabId, + [tabId]: { + ...layout, + // A tree is one value; fieldwise rollback can mix leaf and split node shapes. + root: isDeepStrictEqual(currentRoot, stagedLayout?.root) ? layout.root : currentRoot, + ptyIdsByLeafId: restoreBindingSlot( + originalLayout?.ptyIdsByLeafId, + stagedLayout?.ptyIdsByLeafId, + layout.ptyIdsByLeafId, + leafId + ) + } + } + } + : {}), + terminalPtyIncarnationsByPaneKey: restoreBindingSlot( + original.terminalPtyIncarnationsByPaneKey, + staged.terminalPtyIncarnationsByPaneKey, + restored.terminalPtyIncarnationsByPaneKey, + `${tabId}:${leafId}` + ) + } +} diff --git a/src/main/persistence/loading-store/pty-reattach-failure-routing.test.ts b/src/main/persistence/loading-store/pty-reattach-failure-routing.test.ts new file mode 100644 index 00000000000..16f2978e04b --- /dev/null +++ b/src/main/persistence/loading-store/pty-reattach-failure-routing.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { fixture } from './profile-state-delayed-authority-fixture' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' +import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit' +import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state' +import { commitRuntimePtySpawn } from '../../ipc/pty/runtime/spawn-commit' +import { createRuntimePtySpawnState } from '../../ipc/pty/runtime/spawn-state' +import { createPtySpawnCommitDependencies } from './pty-spawn-commit-dependencies-fixture' +import { clearProviderPtyState } from '../../ipc/pty/provider/state-cleanup' +import { ptyIncarnationById, ptyOwnership } from '../../ipc/pty/provider/ownership-state' +import { toSshExecutionHostId } from '../../../shared/execution-host' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const connectionId = 'reattach-host' +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + ptyId: 'ssh:reattach-host@@surviving-pty' +} +const incarnation = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +const successorIncarnation = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' + +afterEach(() => { + clearProviderPtyState(binding.ptyId) + ptyOwnership.delete(binding.ptyId) +}) + +describe.each(['ipc', 'runtime'])('%s failed reattach routing', (controller) => { + it.each( + [undefined, incarnation].flatMap((incarnationId) => + ['live', 'exited', 'replaced'].map((outcome) => ({ incarnationId, outcome })) + ) + )( + 'preserves host evidence after a failed save: $outcome, $incarnationId', + async ({ incarnationId, outcome }) => { + const { store, authority, readState } = await fixture() + const runtime = new OrcaRuntimeService(store) + runtime.onPtySpawned(binding.ptyId, incarnationId) + const deps = createPtySpawnCommitDependencies(runtime, store) + const publish = vi.spyOn(deps, 'sendPtySpawnedToRenderer') + const result = { id: binding.ptyId, incarnationId, isReattach: true } + let commit: () => Promise + let shutdown: ReturnType + if (controller === 'ipc') { + const ctx = createPtyIpcSpawnState(deps, { + ...binding, + connectionId, + cols: 80, + rows: 24 + }) + ctx.result = result + ctx.metadataLeafId = binding.leafId + ctx.validatedLeafId = binding.leafId + shutdown = vi.spyOn(ctx.provider, 'shutdown') + commit = () => commitPtyIpcSpawn(ctx) + } else { + const ctx = createRuntimePtySpawnState(deps, { + ...binding, + connectionId, + cols: 80, + rows: 24 + }) + ctx.result = result + ctx.metadataLeafId = binding.leafId + ctx.hostSessionBinding = { store, ...binding } + shutdown = vi.spyOn(ctx.provider, 'shutdown') + commit = () => commitRuntimePtySpawn(ctx) + } + const gate = authority.pause() + const pending = expect(commit()).rejects.toThrow('ORCA_TERMINAL_SESSION_STATE_SAVE_FAILED') + await gate.started.promise + const ownerWhileSaving = ptyOwnership.get(binding.ptyId) + if (outcome !== 'live') { + clearProviderPtyState(binding.ptyId) + ptyOwnership.delete(binding.ptyId) + await runtime.onPtyExit(binding.ptyId, 0, incarnationId, { providerExitObserved: true }) + if (outcome === 'replaced') { + runtime.onPtySpawned(binding.ptyId, successorIncarnation) + ptyOwnership.set(binding.ptyId, 'successor-host') + ptyIncarnationById.set(binding.ptyId, successorIncarnation) + } + } + gate.finish.reject(new Error('disk full')) + await pending + expect(ownerWhileSaving).toBe(connectionId) + expect(ptyOwnership.get(binding.ptyId)).toBe( + outcome === 'live' ? connectionId : outcome === 'replaced' ? 'successor-host' : undefined + ) + expect(ptyIncarnationById.get(binding.ptyId)).toBe( + outcome === 'live' + ? incarnationId + : outcome === 'replaced' + ? successorIncarnation + : undefined + ) + expect(shutdown).not.toHaveBeenCalled() + expect(publish).not.toHaveBeenCalled() + expect(store.getSshRemotePtyLeases(connectionId)).toEqual([]) + const session = readState().workspaceSessionsByHostId[toSshExecutionHostId(connectionId)] + expect( + session?.terminalLayoutsByTabId[binding.tabId]?.ptyIdsByLeafId?.[binding.leafId] + ).toBeUndefined() + if (outcome !== 'exited') { + await runtime.onPtyExit( + binding.ptyId, + 0, + outcome === 'replaced' ? successorIncarnation : incarnationId, + { providerExitObserved: true } + ) + } + } + ) +}) diff --git a/src/main/persistence/loading-store/pty-retirement-async-durability.test.ts b/src/main/persistence/loading-store/pty-retirement-async-durability.test.ts new file mode 100644 index 00000000000..6751cc3a010 --- /dev/null +++ b/src/main/persistence/loading-store/pty-retirement-async-durability.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from 'vitest' +import { toSshExecutionHostId } from '../../../shared/execution-host' +import { retirePersistedStablePaneOwner } from '../../ipc/pty/pane/stable-owner' +import { TEST_LEAF_1 } from '../../persistence-session-fixtures' +import { retireTerminalSurfaceFromPersistence } from '../../runtime/mobile-session-terminal-persistence-retirement' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' +import { fixture } from './profile-state-delayed-authority-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'retirement-tab', + leafId: TEST_LEAF_1, + ptyId: 'retirement-pty', + incarnationId: 'retirement-incarnation' +} + +async function retirementFixture(connectionId: string | undefined) { + const result = await fixture() + const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined + await result.store.persistPtyBinding(binding, hostId) + return { + ...result, + retire: () => + retirePersistedStablePaneOwner( + result.store, + { ...binding, persistedIncarnationId: binding.incarnationId }, + binding.worktreeId, + connectionId + ), + removeInMemory: () => { + result.store.setWorkspaceSession( + retireTerminalSurfaceFromPersistence(result.store.getWorkspaceSession(hostId), { + ...binding, + parentTabId: binding.tabId + }), + hostId + ) + }, + persistedLayout: () => { + const state = result.readState() + const session = hostId ? state.workspaceSessionsByHostId[hostId] : state.workspaceSession + return session.terminalLayoutsByTabId[binding.tabId] + } + } +} + +describe.each([undefined, 'retirement-ssh'])( + 'durable PTY retirement on host %s', + (connectionId) => { + it('waits for an already removed in-memory pane to reach SQLite', async () => { + const { authority, retire, removeInMemory, persistedLayout } = + await retirementFixture(connectionId) + removeInMemory() + expect(persistedLayout()?.ptyIdsByLeafId).toEqual({ [binding.leafId]: binding.ptyId }) + const gate = authority.pause() + let acknowledged = false + const pending = retire().then((accepted) => { + acknowledged = true + return accepted + }) + try { + await Promise.race([gate.started.promise, pending]) + expect(acknowledged).toBe(false) + expect(persistedLayout()?.ptyIdsByLeafId).toEqual({ [binding.leafId]: binding.ptyId }) + } finally { + gate.finish.resolve() + } + await expect(pending).resolves.toBe(true) + expect(persistedLayout()).toBeUndefined() + }) + + it('rejects when a pending removal cannot reach SQLite and retains it for retry', async () => { + const { authority, retire, removeInMemory, persistedLayout } = + await retirementFixture(connectionId) + vi.spyOn(console, 'error').mockImplementation(() => {}) + removeInMemory() + const gate = authority.pause() + const rejected = expect(retire()).rejects.toThrow('retirement disk refused') + try { + await Promise.race([gate.started.promise, rejected]) + gate.finish.reject( + new ProfileStateWriterError( + 'test-disk-failure', + 'retirement disk refused', + 'known-failure' + ) + ) + await rejected + } finally { + gate.finish.resolve() + } + expect(persistedLayout()?.ptyIdsByLeafId).toEqual({ [binding.leafId]: binding.ptyId }) + await expect(retire()).resolves.toBe(true) + expect(persistedLayout()).toBeUndefined() + }) + + it('skips disk work when the pane removal is already durable', async () => { + const { authority, retire, persistedLayout } = await retirementFixture(connectionId) + await expect(retire()).resolves.toBe(true) + expect(persistedLayout()).toBeUndefined() + authority.captures.length = 0 + const revisionCheck = vi.spyOn(authority, 'assertCurrentRevision') + const fullStateWrite = vi.spyOn(authority, 'writeSerializedState') + await expect(retire()).resolves.toBe(true) + expect(authority.captures).toEqual([]) + expect(revisionCheck).not.toHaveBeenCalled() + expect(fullStateWrite).not.toHaveBeenCalled() + }) + } +) diff --git a/src/main/persistence/loading-store/pty-retirement-publication-during-read.test.ts b/src/main/persistence/loading-store/pty-retirement-publication-during-read.test.ts new file mode 100644 index 00000000000..34b6e742804 --- /dev/null +++ b/src/main/persistence/loading-store/pty-retirement-publication-during-read.test.ts @@ -0,0 +1,141 @@ +import { expect, it, vi } from 'vitest' +import { fixture } from './profile-state-delayed-authority-fixture' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' +import type { RuntimeMobileSessionTabsSnapshot } from '../../../shared/runtime-types' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + ptyId: 'retiring-pty', + incarnationId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +} +class RetirementRuntime extends OrcaRuntimeService { + readVisibleState() { + return this.readVisibleTerminalState(binding.ptyId) + } + snapshot(): RuntimeMobileSessionTabsSnapshot | undefined { + return this.mobileSessionTabsByWorktree.get(binding.worktreeId) + } + generations(): number { + return this.ptyLifecycleGenerationById.size + } + async closeTab(): Promise { + const snapshot = this.snapshot() + const tab = snapshot?.tabs[0] + if (!snapshot || tab?.type !== 'terminal') { + throw new Error('missing test tab') + } + await this.closeHeadlessMobileTerminalTab(binding.worktreeId, snapshot, tab) + } + publish(layoutOnly = false): void { + this.storeMobileSessionSnapshot(binding.worktreeId, { + worktree: binding.worktreeId, + publicationEpoch: 'retirement-test', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: `${binding.tabId}::${binding.leafId}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `${binding.tabId}::${binding.leafId}`, + parentTabId: binding.tabId, + leafId: binding.leafId, + ptyId: layoutOnly ? null : binding.ptyId, + ...(layoutOnly + ? { + parentLayout: { + root: { type: 'leaf' as const, leafId: binding.leafId }, + activeLeafId: binding.leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [binding.leafId]: binding.ptyId } + } + } + : {}), + title: 'Terminal', + isActive: true + } + ] + }) + } +} + +it.each(['read', 'replacement', 'legacy-replacement'] as const)( + 'publishes an exited terminal retirement with concurrent %s', + async (action) => { + const { store, authority, readState } = await fixture() + await store.persistPtyBinding(binding) + const runtime = new RetirementRuntime(store) + runtime.registerPty(binding.ptyId, binding.worktreeId, null, binding) + runtime.publish() + const gate = authority.pause() + const exiting = runtime.onPtyExit(binding.ptyId, 0, binding.incarnationId, { + providerExitObserved: true + }) + await gate.started.promise + if (action === 'read') { + await runtime.readVisibleState() + } else if (action === 'legacy-replacement') { + runtime.onPtySpawned(binding.ptyId) + } else { + runtime.onPtySpawned(binding.ptyId, 'dddddddd-dddd-4ddd-8ddd-dddddddddddd') + } + gate.finish.resolve() + await exiting + expect(readState().workspaceSession.terminalLayoutsByTabId[binding.tabId]).toBeUndefined() + // Why every action: the exit retired the leaf in memory before any of them could run. + expect(runtime.snapshot()?.tabs).toHaveLength(0) + } +) + +it('publishes an exit whose only live ownership is the mobile parent layout', async () => { + const { store, authority, readState } = await fixture() + await store.persistPtyBinding(binding) + const runtime = new RetirementRuntime(store) + runtime.publish(true) + const gate = authority.pause() + const exiting = runtime.onPtyExit(binding.ptyId, 0, binding.incarnationId, { + providerExitObserved: true + }) + await gate.started.promise + gate.finish.resolve() + await exiting + expect(readState().workspaceSession.terminalLayoutsByTabId[binding.tabId]).toBeUndefined() + expect(runtime.snapshot()?.tabs).toEqual([]) + expect(runtime.generations()).toBe(0) +}) + +it('completes a durable close and refuses a split queued behind it', async () => { + const { store, authority, readState } = await fixture() + await store.persistPtyBinding(binding) + const runtime = new RetirementRuntime(store) + const kill = vi.fn(() => true) + runtime.setPtyController({ write: () => true, kill, getForegroundProcess: async () => null }) + runtime.registerPty(binding.ptyId, binding.worktreeId, null, binding) + runtime.publish() + const gate = authority.pause() + const close = runtime.closeTab() + await gate.started.promise + const split = store.persistPtyBinding({ + ...binding, + leafId: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', + ptyId: 'concurrent-split', + expectedSourceBinding: binding + }) + gate.finish.resolve() + await close + await expect(split).resolves.toBe(false) + expect(kill).toHaveBeenCalledExactlyOnceWith(binding.ptyId) + expect(runtime.snapshot()?.tabs).toEqual([]) + expect(readState().workspaceSession.terminalLayoutsByTabId[binding.tabId]).toBeUndefined() +}) diff --git a/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts b/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts new file mode 100644 index 00000000000..2777fdfde4b --- /dev/null +++ b/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts @@ -0,0 +1,44 @@ +import type { PtySpawnIpcDeps } from '../../ipc/pty/ipc/spawn-types' +import type { PtyRuntimeControllerDeps } from '../../ipc/pty/runtime/controller-deps' +import type { OrcaRuntimeService } from '../../runtime/orca-runtime' +import type { Store } from './store' + +function unexpectedPreflight(): never { + throw new Error('Spawn commit must not rerun preflight') +} + +export function createPtySpawnCommitDependencies( + runtime: OrcaRuntimeService, + store: Store +): PtySpawnIpcDeps & PtyRuntimeControllerDeps { + return { + runtime, + store, + sendPtySpawnedToRenderer: () => {}, + getLocalPtyStartupPromise: unexpectedPreflight, + getLocalPtyProviderStartupPromise: unexpectedPreflight, + adoptStablePane: unexpectedPreflight, + assertFolderWorkspacePtyPathUsable: unexpectedPreflight, + resolvePtySpawnStartupCwd: unexpectedPreflight, + localStartupCwdDirectoryExists: unexpectedPreflight, + prepareCodexResumeHome: unexpectedPreflight, + noCodexResumeLaunch: unexpectedPreflight, + resolveCodexResumeLaunch: unexpectedPreflight, + reconcileSharedRuntimeResumeHome: unexpectedPreflight, + stripSequencedStartupResumeArgv: unexpectedPreflight, + transitionSpawnHiddenRendererPtyDeliveryState: unexpectedPreflight, + trustedTerminalHandleEnv: new Set(), + syncPtyBackgroundedDelivery: unexpectedPreflight, + stopReplacedPty: unexpectedPreflight, + requestSerializedBuffer: unexpectedPreflight, + shutdownProviderAndDetectExit: unexpectedPreflight, + rememberSyntheticKillExit: unexpectedPreflight, + rememberRetiredRejectedPty: unexpectedPreflight, + sendPtyExitToRenderer: unexpectedPreflight, + finishPtyShutdown: unexpectedPreflight, + retiredRejectedPtyIds: new Map(), + get mainWindow() { + return unexpectedPreflight() + } + } +} diff --git a/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts b/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts new file mode 100644 index 00000000000..ef146779b4b --- /dev/null +++ b/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts @@ -0,0 +1,238 @@ +import { expect, it, vi } from 'vitest' +import { fixture } from './profile-state-delayed-authority-fixture' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' +import { commitRuntimePtySpawn } from '../../ipc/pty/runtime/spawn-commit' +import { createRuntimePtySpawnState } from '../../ipc/pty/runtime/spawn-state' +import type { PtyRuntimeControllerDeps } from '../../ipc/pty/runtime/controller-deps' +import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit' +import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state' +import type { PtySpawnIpcDeps } from '../../ipc/pty/ipc/spawn-types' +import { registerPersistedPtySpawn } from '../../ipc/pty/pane/spawn-registration' +import { toSshExecutionHostId } from '../../../shared/execution-host' +import { clearProviderPtyState } from '../../ipc/pty/provider/state-cleanup' +import { ptyOwnership, ptyIncarnationById } from '../../ipc/pty/provider/ownership-state' +import { ptySizes } from '../../ipc/pty/delivery/visibility-state' +import { + paneSpawnReservationsByOwnerKey, + reservePaneSpawn, + reserveIdlePaneSpawn, + resolvePaneSpawnReservation, + type PaneSpawnReservation +} from '../../ipc/pty/pane/spawn-reservation' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +it.each( + [ + { controller: 'runtime', connectionId: null, exitCode: 0 }, + { controller: 'runtime', connectionId: 'test-host', exitCode: 0 }, + { controller: 'ipc', connectionId: null, exitCode: -1 }, + { controller: 'ipc', connectionId: 'test-host', exitCode: 0 } + ].flatMap((test) => [ + { ...test, stableOwner: false }, + { ...test, stableOwner: true } + ]) +)( + 'retires an exited $controller binding on $connectionId after disk finishes (stable: $stableOwner)', + async ({ controller, connectionId, exitCode, stableOwner }) => { + const { store, authority, readState } = await fixture() + const runtime = new OrcaRuntimeService(store) + const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + ptyId: connectionId + ? `ssh:${connectionId}@@pty-exited-during-durable-bind` + : 'pty-exited-during-durable-bind', + incarnationId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' + } + const owner = stableOwner + ? { + ...binding, + hasPersistedBinding: true as const, + persistedIncarnationId: 'previous-incarnation' + } + : null + if (owner) { + await store.persistPtyBinding( + { ...binding, incarnationId: owner.persistedIncarnationId }, + connectionId ? toSshExecutionHostId(connectionId) : undefined + ) + } + runtime.onPtySpawned(binding.ptyId, binding.incarnationId) + runtime.beginPtyRegistration(binding.ptyId, binding.incarnationId) + runtime.assertPtyRegistrationAllowed(binding.ptyId, binding.incarnationId) + let commit: () => Promise + const reservationKey = JSON.stringify([connectionId, binding.worktreeId, binding.leafId]) + let reservation: PaneSpawnReservation | undefined + if (controller === 'runtime') { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only commit runs; its runtime/store are real and preflight-only dependencies are unreachable. + const deps = { runtime, store, options: {} } as PtyRuntimeControllerDeps + const ctx = createRuntimePtySpawnState(deps, { ...binding, connectionId, cols: 80, rows: 24 }) + ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } + ctx.stablePaneOwner = owner + ctx.hostSessionBinding = { store, ...binding } + ctx.metadataLeafId = binding.leafId + commit = () => commitRuntimePtySpawn(ctx) + } else { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only commit runs; its runtime/store are real and preflight-only dependencies are unreachable. + const deps = { runtime, store } as PtySpawnIpcDeps + const ctx = createPtyIpcSpawnState(deps, { ...binding, connectionId, cols: 80, rows: 24 }) + ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } + ctx.stablePaneOwner = owner + ctx.metadataLeafId = binding.leafId + ctx.validatedLeafId = binding.leafId + reservation = reservePaneSpawn(reservationKey) + ctx.paneSpawnReservationKey = reservationKey + ctx.paneSpawnReservation = reservation + commit = () => commitPtyIpcSpawn(ctx) + } + const gate = authority.pause() + const pending = + controller === 'ipc' + ? expect(commit()).resolves.toMatchObject({ + id: binding.ptyId, + incarnationId: binding.incarnationId + }) + : expect(commit()).rejects.toThrow('agent_session_exited_during_start') + await gate.started.promise + const nextReservation = reservation ? reserveIdlePaneSpawn(reservationKey) : undefined + clearProviderPtyState(binding.ptyId) + ptyOwnership.delete(binding.ptyId) + await runtime.onPtyExit(binding.ptyId, exitCode, binding.incarnationId, { + providerExitObserved: true + }) + gate.finish.resolve() + await pending + expect(ptyOwnership.has(binding.ptyId)).toBe(false) + expect(ptyIncarnationById.has(binding.ptyId)).toBe(false) + expect(ptySizes.has(binding.ptyId)).toBe(false) + expect( + store + .getSshRemotePtyLeases(connectionId ?? undefined) + .some((lease) => lease.ptyId.includes('pty-exited-during-durable-bind')) + ).toBe(false) + if (reservation) { + await expect(reservation.promise).resolves.toMatchObject({ id: binding.ptyId }) + const next = await nextReservation + expect(next).not.toBe(reservation) + resolvePaneSpawnReservation(reservationKey, next, { id: 'next-spawn' }) + expect(paneSpawnReservationsByOwnerKey.has(reservationKey)).toBe(false) + } + const state = readState() + const session = connectionId + ? state.workspaceSessionsByHostId[toSshExecutionHostId(connectionId)] + : state.workspaceSession + expect( + session.terminalLayoutsByTabId[binding.tabId]?.ptyIdsByLeafId?.[binding.leafId] + ).toBeUndefined() + } +) + +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + ptyId: 'exited-pty', + incarnationId: 'old-incarnation' +} + +it.each(['replacement-pty', binding.ptyId])( + 'preserves a replacement binding to %s while retirement waits', + async (ptyId) => { + const { store, authority, readState } = await fixture() + const runtime = new OrcaRuntimeService(store) + runtime.beginPtyRegistration(binding.ptyId, binding.incarnationId) + await store.persistPtyBinding(binding) + await runtime.onPtyExit(binding.ptyId, 0, binding.incarnationId, { providerExitObserved: true }) + const gate = authority.pause() + const replacement = store.persistPtyBinding({ + ...binding, + ptyId, + incarnationId: 'new-incarnation' + }) + await gate.started.promise + const registration = expect( + registerPersistedPtySpawn(runtime, store, binding.ptyId, binding.worktreeId, null, binding) + ).rejects.toThrow('agent_session_exited_during_start') + gate.finish.resolve() + await Promise.all([replacement, registration]) + const session = readState().workspaceSession + expect(session.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId[binding.leafId]).toBe(ptyId) + expect(session.terminalPtyIncarnationsByPaneKey[`${binding.tabId}:${binding.leafId}`]).toBe( + 'new-incarnation' + ) + } +) + +it('retains the binding when loss of contact supplies no process-exit proof', async () => { + const { store, readState } = await fixture() + const runtime = new OrcaRuntimeService(store) + runtime.beginPtyRegistration(binding.ptyId, binding.incarnationId) + await store.persistPtyBinding(binding) + await runtime.onPtyExit(binding.ptyId, -1, binding.incarnationId) + expect(() => + registerPersistedPtySpawn(runtime, store, binding.ptyId, binding.worktreeId, null, binding) + ).toThrow('agent_session_exited_during_start') + expect( + readState().workspaceSession.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId[ + binding.leafId + ] + ).toBe(binding.ptyId) +}) + +it('does not settle rejected registration until its exit cleanup reaches SQLite', async () => { + const { store, authority, readState } = await fixture() + const runtime = new OrcaRuntimeService(store) + runtime.beginPtyRegistration(binding.ptyId, binding.incarnationId) + await store.persistPtyBinding(binding) + await runtime.onPtyExit(binding.ptyId, 0, binding.incarnationId, { providerExitObserved: true }) + const gate = authority.pause() + let rejected = false + const cleanup = registerPersistedPtySpawn( + runtime, + store, + binding.ptyId, + binding.worktreeId, + null, + binding + ) + if (!cleanup) { + throw new Error('exited registration did not start durable cleanup') + } + const pending = cleanup.catch((error: unknown) => { + rejected = true + throw error + }) + const failure = expect(pending).rejects.toThrow('agent_session_exited_during_start') + await gate.started.promise + expect(rejected).toBe(false) + expect( + readState().workspaceSession.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId[ + binding.leafId + ] + ).toBe(binding.ptyId) + gate.finish.resolve() + await failure + expect( + readState().workspaceSession.terminalLayoutsByTabId[binding.tabId]?.ptyIdsByLeafId?.[ + binding.leafId + ] + ).toBeUndefined() +}) + +it('keeps successful registration synchronous through the remaining spawn publication', async () => { + const { store } = await fixture() + const runtime = new OrcaRuntimeService(store) + await store.persistPtyBinding(binding) + expect( + registerPersistedPtySpawn(runtime, store, binding.ptyId, binding.worktreeId, null, binding) + ).toBeUndefined() +}) diff --git a/src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts b/src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts new file mode 100644 index 00000000000..9fb0d65a2cb --- /dev/null +++ b/src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts @@ -0,0 +1,77 @@ +import { expect, it, vi } from 'vitest' +import { fixture } from './profile-state-delayed-authority-fixture' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' +import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit' +import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state' +import { commitRuntimePtySpawn } from '../../ipc/pty/runtime/spawn-commit' +import { createRuntimePtySpawnState } from '../../ipc/pty/runtime/spawn-state' +import { createPtySpawnCommitDependencies } from './pty-spawn-commit-dependencies-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + ptyId: 'mobile-pending-spawn', + incarnationId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +} +class MobileCreateRuntime extends OrcaRuntimeService { + waitForCreatedSurface() { + this.pendingMobileTerminalCreatesByKey.set(`${binding.worktreeId}::${binding.tabId}`, { + activate: true, + paired: true, + selectIfNoActiveTab: true + }) + return this.waitForMobileTerminalSurface(binding.worktreeId, binding.tabId, { + requireReady: true + }) + } +} + +it.each(['ipc', 'runtime'])( + 'publishes the preallocated handle to a pending mobile %s create', + async (controller) => { + const { store, authority } = await fixture() + const runtime = new MobileCreateRuntime(store) + const preAllocatedHandle = runtime.createPreAllocatedTerminalHandle() + const surface = runtime.waitForCreatedSurface() + runtime.onPtySpawned(binding.ptyId, binding.incarnationId) + const deps = createPtySpawnCommitDependencies(runtime, store) + let commit: () => Promise + if (controller === 'ipc') { + const ctx = createPtyIpcSpawnState(deps, { ...binding, cols: 80, rows: 24 }) + ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } + ctx.metadataLeafId = binding.leafId + ctx.validatedLeafId = binding.leafId + ctx.preAllocatedHandle = preAllocatedHandle + commit = () => commitPtyIpcSpawn(ctx) + } else { + const ctx = createRuntimePtySpawnState(deps, { + ...binding, + cols: 80, + rows: 24, + preAllocatedHandle + }) + ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } + ctx.metadataLeafId = binding.leafId + ctx.hostSessionBinding = { store, ...binding } + commit = () => commitRuntimePtySpawn(ctx) + } + const gate = authority.pause() + const pending = commit() + await gate.started.promise + gate.finish.resolve() + await pending + const result = await surface + expect(result.tab.terminal).toBe(preAllocatedHandle) + await runtime.onPtyExit(binding.ptyId, 0, binding.incarnationId, { providerExitObserved: true }) + } +) diff --git a/src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts b/src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts new file mode 100644 index 00000000000..202ad5e6a2d --- /dev/null +++ b/src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts @@ -0,0 +1,172 @@ +import { isTerminalSessionStorageCapacityFailure } from '../../../shared/terminal-session-state-save-failure' +import { afterEach, expect, it, vi } from 'vitest' +import { deferred, fixture } from './profile-state-delayed-authority-fixture' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' +import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit' +import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state' +import type { PtySpawnIpcDeps } from '../../ipc/pty/ipc/spawn-types' +import { commitRuntimePtySpawn } from '../../ipc/pty/runtime/spawn-commit' +import { createRuntimePtySpawnState } from '../../ipc/pty/runtime/spawn-state' +import type { PtyRuntimeControllerDeps } from '../../ipc/pty/runtime/controller-deps' +import { ptyIncarnationById, ptyOwnership } from '../../ipc/pty/provider/ownership-state' +import { clearProviderPtyState } from '../../ipc/pty/provider/state-cleanup' +import { createPtySpawnCommitDependencies } from './pty-spawn-commit-dependencies-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + ptyId: 'replaced-during-save', + incarnationId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +} +const replacementIncarnation = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' + +afterEach(() => { + clearProviderPtyState(binding.ptyId) + ptyOwnership.delete(binding.ptyId) +}) + +it.each( + ['ipc', 'runtime'].flatMap((controller) => + ['save', 'shutdown'].flatMap((replacementDuring) => + ['profile-state-writer-exit', 'ENOSPC'].map((code) => ({ + controller, + replacementDuring, + code + })) + ) + ) +)( + 'preserves a successor during $replacementDuring when the predecessor $controller save fails with $code', + async ({ controller, replacementDuring, code }) => { + const { store, authority } = await fixture() + const runtime = new OrcaRuntimeService(store) + runtime.onPtySpawned(binding.ptyId, binding.incarnationId) + ptyIncarnationById.set(binding.ptyId, binding.incarnationId) + const deps = createPtySpawnCommitDependencies(runtime, store) + const shutdownStarted = deferred() + const shutdownFinished = deferred() + const holdShutdown = async () => { + shutdownStarted.resolve() + await shutdownFinished.promise + } + let commit: () => Promise + let shutdown: ReturnType + if (controller === 'ipc') { + const ctx = createPtyIpcSpawnState(deps, { ...binding, cols: 80, rows: 24 }) + ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } + ctx.metadataLeafId = binding.leafId + ctx.validatedLeafId = binding.leafId + shutdown = vi.spyOn(ctx.provider, 'shutdown').mockImplementation(holdShutdown) + commit = () => commitPtyIpcSpawn(ctx) + } else { + const ctx = createRuntimePtySpawnState(deps, { ...binding, cols: 80, rows: 24 }) + ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } + ctx.metadataLeafId = binding.leafId + ctx.hostSessionBinding = { store, ...binding } + shutdown = vi.spyOn(ctx.provider, 'shutdown').mockImplementation(holdShutdown) + commit = () => commitRuntimePtySpawn(ctx) + } + const gate = authority.pause() + const pending = commit().catch((error: unknown) => { + expect(error).toBeInstanceOf(Error) + if (!(error instanceof Error)) { + throw error + } + expect(error.message).toContain('ORCA_TERMINAL_SESSION_STATE_SAVE_FAILED') + expect(isTerminalSessionStorageCapacityFailure(error.message)).toBe(code === 'ENOSPC') + return 'rejected' + }) + await gate.started.promise + if (replacementDuring === 'shutdown') { + gate.finish.reject(Object.assign(new Error('worker exited'), { code })) + await shutdownStarted.promise + } + await runtime.onPtyExit(binding.ptyId, 0, binding.incarnationId, { providerExitObserved: true }) + runtime.onPtySpawned(binding.ptyId, replacementIncarnation) + ptyIncarnationById.set(binding.ptyId, replacementIncarnation) + ptyOwnership.set(binding.ptyId, 'successor-host') + if (replacementDuring === 'save') { + gate.finish.reject(Object.assign(new Error('worker exited'), { code })) + } + shutdownFinished.resolve() + expect(await pending).toBe('rejected') + if (replacementDuring === 'save') { + expect(shutdown).not.toHaveBeenCalled() + } else { + expect(shutdown).toHaveBeenCalledExactlyOnceWith(binding.ptyId, { + immediate: true, + expectedIncarnationId: binding.incarnationId + }) + } + expect(ptyIncarnationById.get(binding.ptyId)).toBe(replacementIncarnation) + expect(ptyOwnership.get(binding.ptyId)).toBe('successor-host') + await runtime.onPtyExit(binding.ptyId, 0, replacementIncarnation, { + providerExitObserved: true + }) + } +) + +it.each(['ipc', 'runtime'])( + 'keeps replacement provider identity when an exited %s spawn finishes saving', + async (controller) => { + const { store, authority } = await fixture() + const runtime = new OrcaRuntimeService(store) + runtime.onPtySpawned(binding.ptyId, binding.incarnationId) + let commit: () => Promise + if (controller === 'ipc') { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only commit runs; the runtime/store are real and preflight-only dependencies are unreachable. + const deps = { runtime, store } as PtySpawnIpcDeps + const ctx = createPtyIpcSpawnState(deps, { ...binding, cols: 80, rows: 24 }) + ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } + ctx.metadataLeafId = binding.leafId + ctx.validatedLeafId = binding.leafId + commit = () => commitPtyIpcSpawn(ctx) + } else { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only commit runs; the runtime/store are real and preflight-only dependencies are unreachable. + const deps = { runtime, store } as PtyRuntimeControllerDeps + const ctx = createRuntimePtySpawnState(deps, { ...binding, cols: 80, rows: 24 }) + ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } + ctx.metadataLeafId = binding.leafId + ctx.hostSessionBinding = { store, ...binding } + commit = () => commitRuntimePtySpawn(ctx) + } + const gate = authority.pause() + const pending = expect(commit()).rejects.toThrow('agent_session_exited_during_start') + await gate.started.promise + await runtime.onPtyExit(binding.ptyId, 0, binding.incarnationId, { providerExitObserved: true }) + runtime.onPtySpawned(binding.ptyId, replacementIncarnation) + runtime.seedHeadlessTerminal(binding.ptyId, 'replacement history', { cols: 112, rows: 37 }) + ptyIncarnationById.set(binding.ptyId, replacementIncarnation) + gate.finish.resolve() + await pending + expect(ptyIncarnationById.get(binding.ptyId)).toBe(replacementIncarnation) + expect(await runtime.serializeMainTerminalBuffer(binding.ptyId)).toMatchObject({ + cols: 112, + rows: 37 + }) + await runtime.onPtyExit(binding.ptyId, 0, replacementIncarnation, { + providerExitObserved: true + }) + } +) + +it('does not label an IPC binding ownership refusal as a storage failure', async () => { + const { store } = await fixture() + const runtime = new OrcaRuntimeService(store) + const deps = createPtySpawnCommitDependencies(runtime, store) + const ctx = createPtyIpcSpawnState(deps, { ...binding, cols: 80, rows: 24 }) + ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } + ctx.validatedLeafId = binding.leafId + vi.spyOn(store, 'persistPtyBinding').mockResolvedValue(false) + await expect(commitPtyIpcSpawn(ctx)).rejects.toThrow('terminal_pane_owner_changed') +}) diff --git a/src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts b/src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts new file mode 100644 index 00000000000..26737df9dd0 --- /dev/null +++ b/src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest' +import { fixture } from './profile-state-delayed-authority-fixture' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' +import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit' +import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state' +import type { PtySpawnResult } from '../../providers/types' +import { commitRuntimePtySpawn } from '../../ipc/pty/runtime/spawn-commit' +import { createRuntimePtySpawnState } from '../../ipc/pty/runtime/spawn-state' +import { createPtySpawnCommitDependencies } from './pty-spawn-commit-dependencies-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + ptyId: 'restoring-pty', + incarnationId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +} +const restores: Pick[] = [ + { snapshot: 'restored history\r\n' }, + { + coldRestore: { + scrollback: 'restored history\r\n', + lastTitle: 'Restored', + cwd: '/fixture/local' + } + }, + { replay: 'restored history\r\n' } +] + +describe.each(['ipc', 'runtime'])('%s restored scrollback', (controller) => { + it.each(restores)( + 'keeps restored history before output during the binding save: %j', + async (restore) => { + const { store, authority } = await fixture() + const runtime = new OrcaRuntimeService(store) + runtime.onPtySpawned(binding.ptyId, binding.incarnationId) + const deps = createPtySpawnCommitDependencies(runtime, store) + let commit: () => Promise + const result = { id: binding.ptyId, incarnationId: binding.incarnationId, ...restore } + if (controller === 'ipc') { + const ctx = createPtyIpcSpawnState(deps, { ...binding, cols: 80, rows: 24 }) + ctx.result = result + ctx.metadataLeafId = binding.leafId + ctx.validatedLeafId = binding.leafId + commit = () => commitPtyIpcSpawn(ctx) + } else { + const ctx = createRuntimePtySpawnState(deps, { ...binding, cols: 80, rows: 24 }) + ctx.result = result + ctx.metadataLeafId = binding.leafId + ctx.hostSessionBinding = { store, ...binding } + commit = () => commitRuntimePtySpawn(ctx) + } + const gate = authority.pause() + const pending = commit() + await gate.started.promise + runtime.onPtyData(binding.ptyId, 'live output\r\n', Date.now()) + gate.finish.resolve() + await pending + const snapshot = await runtime.serializeMainTerminalBuffer(binding.ptyId) + expect(snapshot?.data).toContain('restored history') + expect(snapshot?.data).toContain('live output') + expect(snapshot?.data.indexOf('restored history')).toBeLessThan( + snapshot?.data.indexOf('live output') ?? -1 + ) + await runtime.onPtyExit(binding.ptyId, 0, binding.incarnationId, { + providerExitObserved: true + }) + } + ) +}) diff --git a/src/main/persistence/loading-store/repo-lifecycle-ui-residue.ts b/src/main/persistence/loading-store/repo-lifecycle-ui-residue.ts index d1c2ed76d7b..cf33a0255dd 100644 --- a/src/main/persistence/loading-store/repo-lifecycle-ui-residue.ts +++ b/src/main/persistence/loading-store/repo-lifecycle-ui-residue.ts @@ -1,6 +1,7 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' +/** Mutates UI state to remove selections and preferences whose owning repositories were deregistered. */ export function pruneDeregisteredRepoUiResidue( ui: PersistedState['ui'], orphanRepoIds: ReadonlySet @@ -14,9 +15,11 @@ export function pruneDeregisteredRepoUiResidue( ui.lastActiveWorktreeId = null } ui.filterRepoIds = ui.filterRepoIds?.filter((repoId) => !orphanRepoIds.has(repoId)) ?? [] - for (const worktreeId of Object.keys(ui.showDotfilesByWorktree ?? {})) { - if (isOrphanWorktree(worktreeId)) { - delete ui.showDotfilesByWorktree?.[worktreeId] + for (const record of [ui.explorerDisplayRootByWorktree, ui.showDotfilesByWorktree]) { + for (const worktreeId of Object.keys(record ?? {})) { + if (isOrphanWorktree(worktreeId)) { + delete record?.[worktreeId] + } } } } diff --git a/src/main/persistence/loading-store/secret-sentinel-substitution.test.ts b/src/main/persistence/loading-store/secret-sentinel-substitution.test.ts index e58d0280bae..eaae559e77f 100644 --- a/src/main/persistence/loading-store/secret-sentinel-substitution.test.ts +++ b/src/main/persistence/loading-store/secret-sentinel-substitution.test.ts @@ -1,11 +1,7 @@ -/** - * The bar for this change is "the bytes on disk did not move". Every case below runs the exact - * loop `applySecretSentinelSubstitutions` replaced — reproduced in `previousImplementation` — and - * compares payload bytes and guard hash, because a drifting hash silently disables the no-op write - * guard and a drifting payload is corrupted persisted state. - */ +/** Full serialization retains its bytes/hash; domain serialization preserves bytes and equality. */ import { createHash, randomUUID } from 'node:crypto' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import { serializeCompleteProfileStateDomains } from './profile-state-authority-writes' import { applySecretSentinelSubstitutions, type SecretSentinelSubstitution @@ -36,14 +32,101 @@ function expectIdenticalToPrevious( ): void { const before = previousImplementation(serialized, subs, degradedPrefix) const after = applySecretSentinelSubstitutions(serialized, subs, degradedPrefix) + const text = applySecretSentinelSubstitutions(serialized, subs, degradedPrefix, 'text') expect(after.payload.equals(before.payload)).toBe(true) expect(after.stateHash).toBe(before.stateHash) + expect(text.payload).toBe(before.payload.toString('utf8')) + expect(text.stateHash).toBe(before.stateHash) } function sentinel(): string { return `orca-secret-slot-${randomUUID()}` } +describe('complete profile domain serialization', () => { + it('preserves escaped domain names, omission and the keys passed to toJSON', () => { + const domain = '雪"\\\ud800' + const state = { + [domain]: { + toJSON(key: string) { + return { key, nested: { toJSON: (nestedKey: string) => nestedKey } } + } + }, + omitted: undefined, + nullable: null, + history: [{ id: 'z' }, { id: 'a' }] + } + + const serialized = serializeCompleteProfileStateDomains(state, [], '') + + expect(serialized.payload.toString('utf8')).toBe(JSON.stringify(state)) + expect(serialized.domains.map(({ domain }) => domain)).toEqual([domain, 'nullable', 'history']) + expect(JSON.parse(serialized.payload.toString('utf8'))).toMatchObject({ + [domain]: { key: domain, nested: 'nested' }, + nullable: null + }) + }) + + it('keeps the complete hash stable across ciphertext changes and sensitive to plaintext and absence', () => { + const slot = sentinel() + const state = { + settings: { cookie: slot }, + future: { shadow: 'ciphertext-one' }, + nullable: null + } + const firstSub = [{ sentinel: slot, blob: 'ciphertext-one', hashValue: 'secret' }] + const first = serializeCompleteProfileStateDomains(state, firstSub, '') + const second = serializeCompleteProfileStateDomains( + state, + [{ sentinel: slot, blob: 'ciphertext-two', hashValue: 'secret' }], + '' + ) + + expect(first.payload).toEqual( + applySecretSentinelSubstitutions(JSON.stringify(state), firstSub, '').payload + ) + expect(second.payload).not.toEqual(first.payload) + expect(second.stateHash).toBe(first.stateHash) + expect(JSON.parse(second.payload.toString('utf8')).future).toEqual({ shadow: 'ciphertext-one' }) + expect( + serializeCompleteProfileStateDomains( + state, + [{ sentinel: slot, blob: 'ciphertext-one', hashValue: 'changed-secret' }], + '' + ).stateHash + ).not.toBe(first.stateHash) + expect( + serializeCompleteProfileStateDomains({ ...state, nullable: undefined }, firstSub, '') + .stateHash + ).not.toBe(first.stateHash) + }) + + it('retains unknown own keys and encodes bytes only when a complete payload is requested', () => { + const state = Object.fromEntries([ + ['9', 9], + ['3', 3], + ['z', null], + ['__proto__', { own: true }], + ['constructor', false], + ['future', { text: '雪😀\ud800', absent: undefined }] + ]) + const expected = JSON.stringify(state) + const encode = vi.spyOn(Buffer, 'from') + try { + const serialized = serializeCompleteProfileStateDomains(state, [], 'safeStorage-degraded\0') + expect(serialized.domains.map(({ domain }) => domain)).toEqual(Object.keys(state)) + expect(serialized.domains.find(({ domain }) => domain === '__proto__')?.payload).toBe( + '{"own":true}' + ) + expect(encode).not.toHaveBeenCalled() + expect(serialized.payload.toString('utf8')).toBe(expected) + expect(encode).toHaveBeenCalledExactlyOnceWith(expected, 'utf8') + } finally { + encode.mockRestore() + } + }) +}) + describe('applySecretSentinelSubstitutions', () => { it('produces bytes and a hash identical to the previous implementation', () => { const subs: SecretSentinelSubstitution[] = [ @@ -117,6 +200,39 @@ describe('applySecretSentinelSubstitutions', () => { expect(payload.toString('utf8')).not.toContain(subs[0].sentinel) }) + it.each(['', 'safeStorage-degraded\0'])( + 'keeps the first duplicate and handles adjacent escaped sentinels with prefix %j', + (prefix) => { + const slot = 'orca-$a/.*+?^${}()|[]\\"雪' + const subs = [ + { sentinel: slot, blob: 'cipher-other-token-"\\\n雪\ud800', hashValue: 'plain-😀' }, + { sentinel: slot, blob: 'duplicate-must-not-win', hashValue: 'wrong-plain' }, + { sentinel: 'other-token', blob: 'last', hashValue: 'last-plain' } + ] + const serialized = JSON.stringify({ nested: { [slot]: `${slot}${slot}other-token` } }) + const expectedPayload = JSON.stringify({ + nested: { [subs[0].blob]: subs[0].blob + subs[0].blob + subs[2].blob } + }) + const expectedHashInput = JSON.stringify({ + nested: { [subs[0].hashValue]: subs[0].hashValue + subs[0].hashValue + subs[2].hashValue } + }) + const expectedHash = createHash('sha1').update(prefix).update(expectedHashInput).digest('hex') + for (const actual of [ + applySecretSentinelSubstitutions(serialized, subs, prefix), + applySecretSentinelSubstitutions(serialized, subs, prefix, 'text') + ]) { + expect(actual.payload.toString()).toBe(expectedPayload) + expect(actual.stateHash).toBe(expectedHash) + } + } + ) + + it('leaves domains without matching sentinels byte-identical', () => { + const serialized = JSON.stringify({ future: { output: '雪😀\ud800', present: null } }) + const subs = [{ sentinel: 'missing-slot', blob: 'cipher', hashValue: 'plain' }] + expectIdenticalToPrevious(serialized, subs, 'safeStorage-degraded\0') + }) + it('copies and UTF-8 encodes the full state once, not once per sentinel per side', () => { const subs: SecretSentinelSubstitution[] = Array.from({ length: 3 }, () => ({ sentinel: sentinel(), @@ -172,13 +288,17 @@ describe('applySecretSentinelSubstitutions', () => { const before = counted(() => previousImplementation(serialized, subs, '')) const after = counted(() => applySecretSentinelSubstitutions(serialized, subs, '')) + const text = counted(() => applySecretSentinelSubstitutions(serialized, subs, '', 'text')) // Two `String.replace` calls over the whole state per sentinel — payload and hash input. expect(before.fullStateReplaces).toBe(subs.length * 2) expect(after.fullStateReplaces).toBe(0) + expect(text.fullStateReplaces).toBe(0) // The old path encoded the state twice: once for sha1, once for the file write. expect(before.encodedChars).toBeGreaterThan(serialized.length * 1.9) expect(after.encodedChars).toBeLessThan(serialized.length * 1.1) expect(after.encodedChars).toBeGreaterThan(serialized.length * 0.9) + expect(text.encodedChars).toBeLessThan(serialized.length * 1.1) + expect(text.encodedChars).toBeGreaterThan(serialized.length * 0.9) }) }) diff --git a/src/main/persistence/loading-store/secret-sentinel-substitution.ts b/src/main/persistence/loading-store/secret-sentinel-substitution.ts index afcdddafa77..30ec43eaa1d 100644 --- a/src/main/persistence/loading-store/secret-sentinel-substitution.ts +++ b/src/main/persistence/loading-store/secret-sentinel-substitution.ts @@ -10,69 +10,92 @@ export type SecretSentinelSubstitution = { hashValue: string } -/** - * Replace every secret sentinel in `serialized` in ONE pass, producing the on-disk bytes and the - * guard hash from the same encoded segments. - * - * Why not the obvious `payload.replace(...)` / `hashInput.replace(...)` loop it replaces: each - * `String.replace` returns a rope that the *next* `replace` has to flatten before it can search, so - * N sentinels cost 2N-1 flattened copies of the whole multi-MB state, plus one more per side when - * `hash.update` and the file write finally consume them. Measured on a 4.65 MB store with three - * sentinels: 7 full-state string allocations, 62 MB of V8 heap, 27 MB of it in large_object_space. - * - * Here the state is walked once, each literal run is UTF-8 encoded exactly once, and those same - * buffers feed both the payload and the hash — 1 full-state string, 1 encode. - * - * Byte-for-byte identical output to the loop: both sides read the sentinel in its JSON-escaped - * form, the replacements are the JSON-escaped `blob`/`hashValue`, and the hash sees the same byte - * sequence it saw when it was handed one concatenated string. - */ +type SecretSubstitutionOutput = { + encode: (value: string) => T + concat: (chunks: T[]) => T +} + +const bufferOutput: SecretSubstitutionOutput = { + encode: (value) => Buffer.from(value, 'utf8'), + concat: (chunks) => Buffer.concat(chunks) +} + +const textOutput: SecretSubstitutionOutput = { + encode: (value) => value, + concat: (chunks) => chunks.join('') +} + +/** One traversal keeps ciphertext and guard hashes aligned without copying once per secret. */ export function applySecretSentinelSubstitutions( serialized: string, substitutions: readonly SecretSentinelSubstitution[], - degradedPrefix: string -): { payload: Buffer; stateHash: string } { + degradedPrefix: string, + output?: 'buffer' +): { payload: Buffer; stateHash: string } +export function applySecretSentinelSubstitutions( + serialized: string, + substitutions: readonly SecretSentinelSubstitution[], + degradedPrefix: string, + output: 'text' +): { payload: string; stateHash: string } +export function applySecretSentinelSubstitutions( + serialized: string, + substitutions: readonly SecretSentinelSubstitution[], + degradedPrefix: string, + output: 'buffer' | 'text' = 'buffer' +): { payload: Buffer | string; stateHash: string } { + return output === 'text' + ? substituteSentinels(serialized, substitutions, degradedPrefix, textOutput) + : substituteSentinels(serialized, substitutions, degradedPrefix, bufferOutput) +} + +function substituteSentinels( + serialized: string, + substitutions: readonly SecretSentinelSubstitution[], + degradedPrefix: string, + output: SecretSubstitutionOutput +): { payload: T; stateHash: string } { const hash = createHash('sha1').update(degradedPrefix) if (substitutions.length === 0) { - const payload = Buffer.from(serialized, 'utf8') + const payload = output.encode(serialized) return { payload, stateHash: hash.update(payload).digest('hex') } } - const replacementBySentinel = new Map() + const replacementBySentinel = new Map() const alternatives: string[] = [] for (const { sentinel, blob, hashValue } of substitutions) { - // Preserved from the loop this replaces: both the search key and the replacements are the - // JSON-escaped forms, because that is what `serialized` actually contains. + // Match escaped JSON contents, including quotes and backslashes inside a secret. const escapedSentinel = JSON.stringify(sentinel).slice(1, -1) if (replacementBySentinel.has(escapedSentinel)) { continue } alternatives.push(escapeRegex(escapedSentinel)) replacementBySentinel.set(escapedSentinel, { - blob: Buffer.from(JSON.stringify(blob).slice(1, -1), 'utf8'), - hashValue: Buffer.from(JSON.stringify(hashValue).slice(1, -1), 'utf8') + blob: output.encode(JSON.stringify(blob).slice(1, -1)), + hashValue: output.encode(JSON.stringify(hashValue).slice(1, -1)) }) } - // Global, though a sentinel is a UUID minted after the state was assembled and so occurs exactly - // once: a single pass that substitutes every occurrence cannot leave one behind on disk. + // Substitute every occurrence so a repeated sentinel cannot survive on disk. const pattern = new RegExp(alternatives.join('|'), 'g') - const chunks: Buffer[] = [] + const chunks: T[] = [] let cursor = 0 let match: RegExpExecArray | null while ((match = pattern.exec(serialized)) !== null) { - // Non-null: the alternation is built from exactly the map's keys. - const replacement = replacementBySentinel.get(match[0])! - // A sliced substring, so this does not copy the state; the encode below is its only pass. - const literal = Buffer.from(serialized.slice(cursor, match.index), 'utf8') + const replacement = replacementBySentinel.get(match[0]) + if (replacement === undefined) { + throw new Error('Secret substitution matched an unregistered sentinel') + } + // The Buffer output reuses each literal's UTF-8 bytes for both the payload and hash. + const literal = output.encode(serialized.slice(cursor, match.index)) chunks.push(literal, replacement.blob) hash.update(literal) hash.update(replacement.hashValue) cursor = match.index + match[0].length } - const tail = Buffer.from(serialized.slice(cursor), 'utf8') + const tail = output.encode(serialized.slice(cursor)) chunks.push(tail) hash.update(tail) - return { payload: Buffer.concat(chunks), stateHash: hash.digest('hex') } + return { payload: output.concat(chunks), stateHash: hash.digest('hex') } } diff --git a/src/main/persistence/loading-store/session-host-partitions.ts b/src/main/persistence/loading-store/session-host-partitions.ts index d358f4c8f62..12efd45a19e 100644 --- a/src/main/persistence/loading-store/session-host-partitions.ts +++ b/src/main/persistence/loading-store/session-host-partitions.ts @@ -146,7 +146,10 @@ export function removeWorkspaceSessionOwnerInPartition( [resolved]: session } } - scheduleSave(owner[sessionHostPartitionOperationsContext].scheduling) + scheduleSave( + owner[sessionHostPartitionOperationsContext].scheduling, + resolved === LOCAL_EXECUTION_HOST_ID ? ['workspaceSession'] : ['workspaceSessionsByHostId'] + ) } export function partitionOwnsWorktreeTabs( @@ -206,7 +209,9 @@ export function setHostWorkspaceSession( ...owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId, [hostId]: pruned } - scheduleSave(owner[sessionHostPartitionOperationsContext].scheduling) + scheduleSave(owner[sessionHostPartitionOperationsContext].scheduling, [ + 'workspaceSessionsByHostId' + ]) } export function installSessionHostPartitionOperationsContext( diff --git a/src/main/persistence/loading-store/session-snapshot-operations.ts b/src/main/persistence/loading-store/session-snapshot-operations.ts index 37ffd9d366b..9c3cf23be7b 100644 --- a/src/main/persistence/loading-store/session-snapshot-operations.ts +++ b/src/main/persistence/loading-store/session-snapshot-operations.ts @@ -3,7 +3,7 @@ import type { WorkspaceSessionPatch, WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-session-browser-history' import { workspaceSessionPatchNeedsFullNormalization } from './terminal-session-cleanup' @@ -18,7 +18,13 @@ import { scheduleSave } from './write-scheduling' type SessionSnapshotOperationsRuntime = Pick< StoreRuntimeState, - 'pendingSnapshotFileWork' | 'state' | 'terminalScrollbackSnapshotStorage' + | 'durableMutationPhase' + | 'pendingSnapshotFileWork' + | 'profileMaintenancePending' + | 'quitFlushStarted' + | 'state' + | 'terminalScrollbackSnapshotStorage' + | 'writesFrozen' > const sessionSnapshotOperationsContext = Symbol('SessionSnapshotOperations') @@ -43,7 +49,15 @@ export class SessionSnapshotOperations { setWorkspaceSession(session: PersistedState['workspaceSession'], hostId?: string | null): void { const resolved = resolveHostId(hostId) + const { runtime } = this[sessionSnapshotOperationsContext] + if (runtime.durableMutationPhase === 'rollback') { + this.assertSnapshotAdmission(true) + // The fieldwise rollback already preserves newer edits; renderer rebasing would undo it. + this.publishSession(session, resolved) + return + } if (resolved === LOCAL_EXECUTION_HOST_ID) { + this.assertSnapshotAdmission(true) setLocalWorkspaceSession(this, session) return } @@ -56,6 +70,7 @@ export class SessionSnapshotOperations { ): void { const resolved = resolveHostId(hostId) if (resolved === LOCAL_EXECUTION_HOST_ID) { + this.assertSnapshotAdmission() setLocalWorkspaceSession(this, session, true) return } @@ -76,15 +91,34 @@ export class SessionSnapshotOperations { if (Object.hasOwn(patch, 'browserUrlHistory')) { next = pruneWorkspaceSessionBrowserHistory(next) } - if (resolved === LOCAL_EXECUTION_HOST_ID) { - this[sessionSnapshotOperationsContext].runtime.state.workspaceSession = next + this.publishSession(next, resolved) + } + + private publishSession(session: WorkspaceSessionState, hostId: ExecutionHostId): void { + const { runtime, scheduling } = this[sessionSnapshotOperationsContext] + if (hostId === LOCAL_EXECUTION_HOST_ID) { + runtime.state.workspaceSession = session } else { - this[sessionSnapshotOperationsContext].runtime.state.workspaceSessionsByHostId = { - ...this[sessionSnapshotOperationsContext].runtime.state.workspaceSessionsByHostId, - [resolved]: next + runtime.state.workspaceSessionsByHostId = { + ...runtime.state.workspaceSessionsByHostId, + [hostId]: session } } - scheduleSave(this[sessionSnapshotOperationsContext].scheduling) + scheduleSave( + scheduling, + hostId === LOCAL_EXECUTION_HOST_ID ? ['workspaceSession'] : ['workspaceSessionsByHostId'] + ) + } + + private assertSnapshotAdmission(allowAdmittedMutation = false): void { + const { runtime } = this[sessionSnapshotOperationsContext] + if ( + runtime.writesFrozen || + ((runtime.profileMaintenancePending || runtime.quitFlushStarted) && + !(allowAdmittedMutation && runtime.durableMutationPhase !== null)) + ) { + throw new Error('Profile maintenance or finalization is blocking new terminal snapshot work') + } } } diff --git a/src/main/persistence/loading-store/ssh-lease-async-durability.test.ts b/src/main/persistence/loading-store/ssh-lease-async-durability.test.ts new file mode 100644 index 00000000000..6c646285d4b --- /dev/null +++ b/src/main/persistence/loading-store/ssh-lease-async-durability.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from 'vitest' +import { fixture } from './profile-state-delayed-authority-fixture' +import { removeSshPtyConsumerOwnerRecovery } from '../../ssh/ssh-pty-consumer-recovery' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const recovery = { + targetId: 'async-target', + clientInstanceId: 'first-owner', + serverBuildId: 'build', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'owner-lease' +} + +describe('reserved SSH persistence', () => { + it('reserves consumer replacement before mutation and durably writes both exact owners', async () => { + const { store, authority } = await fixture() + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const older = store.flushPendingOrThrowAsync() + await gate.started.promise + const first = store.upsertSshPtyConsumerRecovery(recovery) + const second = store.upsertSshPtyConsumerRecovery({ + ...recovery, + clientInstanceId: 'next-owner' + }) + expect(store.getSshPtyConsumerRecovery(recovery.targetId)).toBeNull() + gate.finish.resolve() + await Promise.all([older, first, second]) + const ownerWrites = authority.captures + .flat() + .filter(({ domain }) => domain === 'sshPtyConsumerRecoveries') + expect(ownerWrites).toHaveLength(2) + expect(ownerWrites[0]?.payload).toContain('first-owner') + expect(ownerWrites[1]?.payload).toContain('next-owner') + }) + + it('retries a failed consumer removal through durability even after memory is already empty', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await store.upsertSshPtyConsumerRecovery(recovery) + const gate = authority.pause() + const removal = expect( + removeSshPtyConsumerOwnerRecovery(recovery.targetId, recovery.clientInstanceId, store) + ).rejects.toThrow('disk refused') + await gate.started.promise + gate.finish.reject(new Error('disk refused')) + await removal + expect(readState().sshPtyConsumerRecoveries).toHaveLength(1) + await removeSshPtyConsumerOwnerRecovery(recovery.targetId, recovery.clientInstanceId, store) + expect(readState().sshPtyConsumerRecoveries).toEqual([]) + }) + + it('does not let an old consumer remove the newer owner ahead of it in the write queue', async () => { + const { store, authority, readState } = await fixture() + await store.upsertSshPtyConsumerRecovery(recovery) + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const older = store.flushPendingOrThrowAsync() + await gate.started.promise + const replacement = store.upsertSshPtyConsumerRecovery({ + ...recovery, + clientInstanceId: 'next-owner' + }) + const removal = removeSshPtyConsumerOwnerRecovery( + recovery.targetId, + recovery.clientInstanceId, + store + ) + gate.finish.resolve() + await Promise.all([older, replacement, removal]) + expect(readState().sshPtyConsumerRecoveries[0]?.clientInstanceId).toBe('next-owner') + }) + + it('does not detach a newer replacement lease while waiting for the writer', async () => { + const { store, authority, readState } = await fixture() + const lease = { targetId: recovery.targetId, ptyId: 'relay-pty', state: 'attached' as const } + store.upsertSshRemotePtyLease(lease) + await store.flushPendingOrThrowAsync() + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const older = store.flushPendingOrThrowAsync() + await gate.started.promise + const detach = store.markSshRemotePtyLeasesAsync(recovery.targetId, 'detached') + expect(store.getSshRemotePtyLeases(recovery.targetId)[0]?.state).toBe('attached') + store.upsertSshRemotePtyLease({ ...lease, worktreeId: 'new-worktree' }) + gate.finish.resolve() + await Promise.all([older, detach]) + expect(readState().sshRemotePtyLeases).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ptyId: 'relay-pty', + worktreeId: 'new-worktree', + state: 'attached' + }) + ]) + ) + }) + + it('does not acknowledge a failed attachment retry before its lease reaches disk', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + store.upsertSshRemotePtyLease({ + targetId: recovery.targetId, + ptyId: 'relay-pty', + state: 'expired' + }) + await store.flushPendingOrThrowAsync() + const gate = authority.pause() + const attachment = expect( + store.markSshRemotePtyLeasesAttachedAsync(recovery.targetId, ['relay-pty']) + ).rejects.toThrow('disk refused') + await gate.started.promise + gate.finish.reject(new Error('disk refused')) + await attachment + expect(readState().sshRemotePtyLeases[0].state).toBe('expired') + await store.markSshRemotePtyLeasesAttachedAsync(recovery.targetId, ['relay-pty']) + expect(readState().sshRemotePtyLeases[0].state).toBe('attached') + }) +}) diff --git a/src/main/persistence/loading-store/ssh-lease-durable-mutation.ts b/src/main/persistence/loading-store/ssh-lease-durable-mutation.ts new file mode 100644 index 00000000000..28f361a12cd --- /dev/null +++ b/src/main/persistence/loading-store/ssh-lease-durable-mutation.ts @@ -0,0 +1,42 @@ +import type { DurableProfileStateMutation, StoreRuntimeState } from './store-runtime-state' +import { + flushDurableStateOrThrowAsync, + type WriteFlushBarrierOperations +} from './write-flush-barriers' + +export type SshLeaseDurableMutationRuntime = Pick< + StoreRuntimeState, + | 'dirtyProfileStateDomains' + | 'profileMaintenancePending' + | 'profileStateAuthority' + | 'quitFlushStarted' + | 'runDurableMutation' + | 'writesFrozen' +> + +export async function runSshLeaseDurableMutation( + runtime: SshLeaseDurableMutationRuntime, + barriers: WriteFlushBarrierOperations, + domain: 'sshPtyConsumerRecoveries' | 'sshRemotePtyLeases', + mutate: () => DurableProfileStateMutation +): Promise { + const writeMutation = (): DurableProfileStateMutation => { + const mutation = mutate() + if (mutation.persist !== false) { + runtime.dirtyProfileStateDomains?.add(domain) + } + return mutation + } + if (runtime.profileStateAuthority?.asynchronous) { + return runtime.runDurableMutation(writeMutation) + } + if (runtime.writesFrozen || runtime.quitFlushStarted || runtime.profileMaintenancePending) { + throw new Error('Cannot mutate finalized profile persistence') + } + // Legacy SSH recovery keeps its existing asynchronous disk barrier on older runtimes. + const mutation = writeMutation() + if (mutation.persist !== false) { + await flushDurableStateOrThrowAsync(barriers) + } + return mutation.value +} diff --git a/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts index 7da5144898e..7188b4efb66 100644 --- a/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts +++ b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts @@ -43,10 +43,14 @@ import type { StoreRuntimeState } from './store-runtime-state' import type { WriteFlushBarrierOperations } from './write-flush-barriers' import type { TerminalBindingRecoveryOperations } from './terminal-binding-recovery' import type { WriteSchedulingOperations } from './write-scheduling' -import { flushDurableStateOrThrowAsync } from './write-flush-barriers' import { scheduleSave } from './write-scheduling' +import { + runSshLeaseDurableMutation, + type SshLeaseDurableMutationRuntime +} from './ssh-lease-durable-mutation' -type SshLeaseRecoveryOperationsRuntime = Pick +type SshLeaseRecoveryOperationsRuntime = SshLeaseDurableMutationRuntime & + Pick const sshLeaseRecoveryOperationsContext = Symbol('SshLeaseRecoveryOperations') type SshLeaseRecoveryOperationsContext = { @@ -81,8 +85,15 @@ export class SshLeaseRecoveryOperations { await upsertSshPtyConsumerRecoveryOperation(getSshPtyConsumerRecoveryOperations(this), record) } - async removeSshPtyConsumerRecovery(targetId: string): Promise { - await removeSshPtyConsumerRecoveryOperation(getSshPtyConsumerRecoveryOperations(this), targetId) + async removeSshPtyConsumerRecovery( + targetId: string, + expectedClientInstanceId?: string + ): Promise { + await removeSshPtyConsumerRecoveryOperation( + getSshPtyConsumerRecoveryOperations(this), + targetId, + expectedClientInstanceId + ) } getSshRemotePtyLeases(targetId?: string): SshRemotePtyLease[] { @@ -127,6 +138,9 @@ export class SshLeaseRecoveryOperations { markSshRemotePtyLeasesForShutdown(targetId: string, state: SshRemotePtyLease['state']): void { markSshRemotePtyLeasesForShutdownOperation(getSshPtyLeaseOperations(this), targetId, state) + this[sshLeaseRecoveryOperationsContext].runtime.dirtyProfileStateDomains?.add( + 'sshRemotePtyLeases' + ) } async markSshRemotePtyLeasesAsync( @@ -195,8 +209,13 @@ export function getSshPtyConsumerRecoveryOperations( return { state: owner[sshLeaseRecoveryOperationsContext].runtime.state, protectedSecrets: owner[sshLeaseRecoveryOperationsContext].runtime.protectedSecrets, - flushDurableStateOrThrowAsync: () => - flushDurableStateOrThrowAsync(owner[sshLeaseRecoveryOperationsContext].flushBarriers) + runDurableMutation: (mutate) => + runSshLeaseDurableMutation( + owner[sshLeaseRecoveryOperationsContext].runtime, + owner[sshLeaseRecoveryOperationsContext].flushBarriers, + 'sshPtyConsumerRecoveries', + mutate + ) } } @@ -238,9 +257,19 @@ export function getSshPtyLeaseOperations(owner: SshLeaseRecoveryOperations): Ssh targetId, leases ), - flush: () => owner[sshLeaseRecoveryOperationsContext].flushBarriers.flush(), - flushDurableStateOrThrowAsync: () => - flushDurableStateOrThrowAsync(owner[sshLeaseRecoveryOperationsContext].flushBarriers) + flush: () => { + owner[sshLeaseRecoveryOperationsContext].runtime.dirtyProfileStateDomains?.add( + 'sshRemotePtyLeases' + ) + owner[sshLeaseRecoveryOperationsContext].flushBarriers.flush() + }, + runDurableMutation: (mutate) => + runSshLeaseDurableMutation( + owner[sshLeaseRecoveryOperationsContext].runtime, + owner[sshLeaseRecoveryOperationsContext].flushBarriers, + 'sshRemotePtyLeases', + mutate + ) } } diff --git a/src/main/persistence/loading-store/ssh-profile-operations.ts b/src/main/persistence/loading-store/ssh-profile-operations.ts index d21ab5f04da..ce81cea373b 100644 --- a/src/main/persistence/loading-store/ssh-profile-operations.ts +++ b/src/main/persistence/loading-store/ssh-profile-operations.ts @@ -32,7 +32,10 @@ import { syncProjectHostSetupCompatibilityState } from './repo-lifecycle-operati import { scheduleSave } from './write-scheduling' import { forgetSshConnectionGeneration } from '../../ssh/ssh-connection-generation' -type SshProfileOperationsRuntime = Pick +type SshProfileOperationsRuntime = Pick< + StoreRuntimeState, + 'dirtyProfileStateDomains' | 'protectedSecrets' | 'state' +> const sshProfileOperationsContext = Symbol('SshProfileOperations') type SshProfileOperationsContext = { @@ -146,7 +149,12 @@ export function getSshTargetStateOperations(owner: SshProfileOperations): SshTar state: owner[sshProfileOperationsContext].runtime.state, protectedSecrets: owner[sshProfileOperationsContext].runtime.protectedSecrets, scheduleSave: () => scheduleSave(owner[sshProfileOperationsContext].scheduling), - flush: () => owner[sshProfileOperationsContext].flushBarriers.flush() + flush: () => { + owner[sshProfileOperationsContext].runtime.dirtyProfileStateDomains?.add( + 'claudeLivePtySessionIds' + ) + owner[sshProfileOperationsContext].flushBarriers.flush() + } } } diff --git a/src/main/persistence/loading-store/state-serialization-secret-handling.ts b/src/main/persistence/loading-store/state-serialization-secret-handling.ts index 026afd12c01..40d40818343 100644 --- a/src/main/persistence/loading-store/state-serialization-secret-handling.ts +++ b/src/main/persistence/loading-store/state-serialization-secret-handling.ts @@ -1,3 +1,8 @@ +import { + serializeCompleteProfileStateDomains, + serializeSelectiveProfileStateDomains +} from './profile-state-authority-writes' +import type { ProfileStateDomainReplacement } from './profile-state-authority' import { randomUUID } from 'node:crypto' import type { PersistedState } from '../../../shared/persisted-state-types' import { collectFolderWorkspaceDiffComments } from '../../folder-workspace-diff-comments' @@ -30,7 +35,92 @@ export class StateSerializationSecretHandlingOperations { return durable } - buildStateToSave(): { + /** Serialize domains with complete secret handling; unknown domains fall back to a full write. */ + buildStateDomainsToSave(domains: ReadonlySet): + | { + replacements: ProfileStateDomainReplacement[] + protectedSecretUpdates: ProtectedSecretRetentionUpdate[] + } + | undefined { + // A later save must retry secrets deferred by any domain, until a durable commit succeeds. + if (this.runtime.protectedSecrets.hasPendingEncryption()) { + return undefined + } + const stateToSave: Record = {} + const protectedSecretUpdates: ProtectedSecretRetentionUpdate[] = [] + const encrypt = (slot: string, plaintext: string): string => { + const encrypted = this.runtime.protectedSecrets.encrypt(slot, plaintext) + if (encrypted.retentionUpdate) { + protectedSecretUpdates.push(encrypted.retentionUpdate) + } + return encrypted.blob + } + for (const domain of domains) { + switch (domain) { + case 'settings': + stateToSave[domain] = this.buildSettingsToSave(encrypt) + break + case 'workspaceSession': + stateToSave[domain] = this.runtime.state.workspaceSession + break + case 'automations': + case 'automationRuns': + stateToSave[domain] = this.runtime.state[domain] + break + case 'featureInteractionTelemetryBuckets': + stateToSave[domain] = this.runtime.state.featureInteractionTelemetryBuckets + break + case 'ui': + stateToSave[domain] = { + ...this.runtime.state.ui, + browserKagiSessionLink: + encrypt( + PROTECTED_SECRET_SLOT.browserKagiSessionLink, + this.runtime.state.ui.browserKagiSessionLink ?? '' + ) || null + } + break + case 'worktreeMeta': + stateToSave[domain] = omitDefaultWorktreeMetaFieldsInMap(this.runtime.state.worktreeMeta) + break + case 'worktreeMetaByIdentity': + if (this.runtime.state.worktreeMetaByIdentity !== undefined) { + stateToSave[domain] = omitDefaultWorktreeMetaFieldsInMap( + projectWorktreeMetaByIdentityOntoLocators( + this.runtime.state.worktreeMetaByIdentity, + this.runtime.state + ) + ) + } + break + case 'worktreeIdentityAliases': + if (this.runtime.state.worktreeIdentityAliases !== undefined) { + stateToSave[domain] = this.runtime.state.worktreeIdentityAliases + } + break + case 'workspaceSessionsByHostId': + if (this.runtime.state.workspaceSessionsByHostId !== undefined) { + stateToSave[domain] = withoutRedundantPartitionGlobals( + this.runtime.state.workspaceSessionsByHostId, + this.runtime.state.workspaceSession + ) + } + break + case 'sshRemotePtyLeases': + stateToSave[domain] = this.runtime.state.sshRemotePtyLeases + break + default: + return undefined + } + } + return { + replacements: serializeSelectiveProfileStateDomains(stateToSave, domains), + protectedSecretUpdates + } + } + + buildStateToSave(serializeDomains = false): { + domains?: readonly ProfileStateDomainReplacement[] payload: Buffer stateHash: string protectedSecretUpdates: ProtectedSecretRetentionUpdate[] @@ -122,21 +212,7 @@ export class StateSerializationSecretHandlingOperations { ) }) ), - settings: { - ...stripRetiredGlobalSettings(this.runtime.state.settings), - opencodeSessionCookie: encryptToSentinel( - PROTECTED_SECRET_SLOT.opencodeSessionCookie, - this.runtime.state.settings.opencodeSessionCookie - ), - opencodeGoApiKey: encryptToSentinel( - PROTECTED_SECRET_SLOT.opencodeGoApiKey, - this.runtime.state.settings.opencodeGoApiKey ?? '' - ), - httpProxyUrl: encryptToSentinel( - PROTECTED_SECRET_SLOT.httpProxyUrl, - this.runtime.state.settings.httpProxyUrl ?? '' - ) - }, + settings: this.buildSettingsToSave(encryptToSentinel), ui: { ...this.runtime.state.ui, browserKagiSessionLink: encryptOptionalToSentinel( @@ -145,6 +221,24 @@ export class StateSerializationSecretHandlingOperations { ) } } + if ( + serializeDomains && + !('toJSON' in stateToSave && typeof stateToSave.toJSON === 'function') + ) { + const serialized = serializeCompleteProfileStateDomains( + stateToSave, + secretSubs, + protectedStorageDegraded ? 'safeStorage-degraded\0' : '' + ) + return { + domains: serialized.domains, + stateHash: serialized.stateHash, + get payload() { + return serialized.payload + }, + protectedSecretUpdates + } + } // Why compact: ~20% fewer bytes and less serialize time; all readers JSON.parse so formatting is irrelevant. // One full-state stringify; secret slots currently hold sentinels. const serialized = JSON.stringify(stateToSave) @@ -158,4 +252,22 @@ export class StateSerializationSecretHandlingOperations { ) return { payload, stateHash, protectedSecretUpdates } } + + private buildSettingsToSave(encrypt: (slot: string, plaintext: string) => string) { + return { + ...stripRetiredGlobalSettings(this.runtime.state.settings), + opencodeSessionCookie: encrypt( + PROTECTED_SECRET_SLOT.opencodeSessionCookie, + this.runtime.state.settings.opencodeSessionCookie + ), + opencodeGoApiKey: encrypt( + PROTECTED_SECRET_SLOT.opencodeGoApiKey, + this.runtime.state.settings.opencodeGoApiKey ?? '' + ), + httpProxyUrl: encrypt( + PROTECTED_SECRET_SLOT.httpProxyUrl, + this.runtime.state.settings.httpProxyUrl ?? '' + ) + } + } } diff --git a/src/main/persistence/loading-store/state-write-round-trip.test.ts b/src/main/persistence/loading-store/state-write-round-trip.test.ts index ee7f26feb3e..1d543fb5b5f 100644 --- a/src/main/persistence/loading-store/state-write-round-trip.test.ts +++ b/src/main/persistence/loading-store/state-write-round-trip.test.ts @@ -1,3 +1,8 @@ +import { + closeTestStores, + createSqliteTestStore, + readPersistedStateJson +} from '../../persistence-test-harness' /** * The write path now hands the file a Buffer it built in one pass instead of a string it rebuilt * per secret. Drives the real `Store` end to end — encrypted settings, a local session and a remote @@ -5,7 +10,7 @@ * against (a mis-sliced segment, a re-encoded payload, a dropped sentinel) is invisible until * something reads the bytes back. */ -import { mkdtempSync, readFileSync, realpathSync } from 'node:fs' +import { mkdtempSync, realpathSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -35,15 +40,16 @@ const { Store } = await import('./store') const HOST_ID = 'ssh:user@host' const stores: InstanceType[] = [] -afterEach(() => { +afterEach(async () => { for (const store of stores.splice(0)) { - store.flush() + store.freezeWrites() } + await closeTestStores() vi.restoreAllMocks() }) function openStore(dataFile: string): InstanceType { - const store = new Store({ dataFile }) + const store = createSqliteTestStore(Store, { dataFile }) stores.push(store) return store } @@ -95,7 +101,7 @@ describe('persisted state survives a save/load round trip', () => { } // The file is valid UTF-8 JSON and holds ciphertext, not the plaintext secrets. - const bytes = readFileSync(dataFile) + const bytes = Buffer.from(readPersistedStateJson(dataFile)) const onDisk = JSON.parse(bytes.toString('utf8')) expect(onDisk.settings.opencodeSessionCookie).not.toBe('cookie-é-value') expect(Buffer.from(onDisk.settings.opencodeSessionCookie, 'base64').toString('utf8')).toContain( @@ -118,7 +124,7 @@ describe('persisted state survives a save/load round trip', () => { // Deep equality of the whole reloaded state, taken across a second round trip so the assertion // is not comparing against the first load's one-time settings migrations. reloaded.flush() - const bytesAfterReload = readFileSync(dataFile) + const bytesAfterReload = Buffer.from(readPersistedStateJson(dataFile)) const again = openStore(dataFile) expect(again.getSettings()).toEqual(reloaded.getSettings()) expect(again.getUI()).toEqual(reloaded.getUI()) @@ -126,6 +132,6 @@ describe('persisted state survives a save/load round trip', () => { expect(again.getWorkspaceSession(HOST_ID)).toEqual(reloaded.getWorkspaceSession(HOST_ID)) // ...and the bytes are stable, so a quiet app is not rewriting a 4 MB file with new content. again.flush() - expect(readFileSync(dataFile).equals(bytesAfterReload)).toBe(true) + expect(Buffer.from(readPersistedStateJson(dataFile)).equals(bytesAfterReload)).toBe(true) }) }) diff --git a/src/main/persistence/loading-store/store-domain-composition.ts b/src/main/persistence/loading-store/store-domain-composition.ts index c3f2059efe1..70f22e90c5c 100644 --- a/src/main/persistence/loading-store/store-domain-composition.ts +++ b/src/main/persistence/loading-store/store-domain-composition.ts @@ -1,7 +1,6 @@ import type { StoreRuntimeState } from './store-runtime-state' import type { Store } from './store' import { LoadedStateAdaptationOperations } from './loaded-state-adaptation' -import { BackupRecoveryRotationOperations } from './backup-recovery-rotation' import { LoadedCohortMigrationOperations } from './loaded-cohort-migrations' import { LoadedStateParsingOperations } from './loaded-state-parsing' import { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' @@ -65,9 +64,25 @@ import { installSshLeaseRecoveryOperationsContext } from './ssh-lease-recovery-operations' +export type StoreDomainOperations = WriteSchedulingOperations & + PrimaryStateWriteOperations & + ProjectCollectionOperations & + RepoLifecycleOperations & + MobileTabSelectionPersistence & + SparsePresetPersistence & + AutomationPersistence & + MetadataLineageOperations & + ProfilePreferences & + SessionHostPartitionOperations & + SessionSnapshotOperations & + PtyBindingPersistenceOperations & + SshProfileOperations & + RetiredWorktreeNamePersistence & + SshLeaseRecoveryOperations & + WriteFlushBarrierOperations + export type StoreDomains = { adaptation: LoadedStateAdaptationOperations - backups: BackupRecoveryRotationOperations cohorts: LoadedCohortMigrationOperations loader: LoadedStateParsingOperations serialization: StateSerializationSecretHandlingOperations @@ -130,11 +145,10 @@ export function installStoreDomainContexts(target: Store, domains: StoreDomains) export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { const adaptation = new LoadedStateAdaptationOperations(runtime) - const backups = new BackupRecoveryRotationOperations(runtime) const cohorts = new LoadedCohortMigrationOperations(runtime) - const loader = new LoadedStateParsingOperations(runtime, backups, cohorts) + const loader = new LoadedStateParsingOperations(runtime, cohorts) const serialization = new StateSerializationSecretHandlingOperations(runtime) - const writes = new PrimaryStateWriteOperations(runtime, serialization, backups) + const writes = new PrimaryStateWriteOperations(runtime, serialization) const scheduling = new WriteSchedulingOperations(runtime, writes) const flushBarriers = new WriteFlushBarrierOperations(runtime, writes) const preferences = new ProfilePreferences(runtime, scheduling) @@ -163,7 +177,6 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { ) return { adaptation, - backups, cohorts, loader, serialization, diff --git a/src/main/persistence/loading-store/store-prune-gate-signals.test.ts b/src/main/persistence/loading-store/store-prune-gate-signals.test.ts index 9c9aa2eb176..ca459ec3982 100644 --- a/src/main/persistence/loading-store/store-prune-gate-signals.test.ts +++ b/src/main/persistence/loading-store/store-prune-gate-signals.test.ts @@ -1,3 +1,4 @@ +import { closeTestStores, createSqliteTestStore } from '../../persistence-test-harness' /** * Drives the real `Store`, because the value of the prune gate is entirely in whether the shipping * write paths signal it. A mutation that unwires the call site survives any test that pokes the gate @@ -43,17 +44,18 @@ beforeEach(() => { __resetLocalWorktreeMetadataPruneGateForTests() }) -afterEach(() => { +afterEach(async () => { // Leaving a debounced save armed would write into a temp dir after the test file finishes. for (const store of stores.splice(0)) { - store.flush() + store.freezeWrites() } + await closeTestStores() vi.restoreAllMocks() }) function createStore(): InstanceType { const dir = realpathSync(mkdtempSync(join(tmpdir(), 'orca-store-prune-gate-'))) - const store = new Store({ dataFile: join(dir, 'orca-data.json') }) + const store = createSqliteTestStore(Store, { dataFile: join(dir, 'orca-data.json') }) stores.push(store) return store } diff --git a/src/main/persistence/loading-store/store-runtime-authored-session-writes.test.ts b/src/main/persistence/loading-store/store-runtime-authored-session-writes.test.ts index 9bb2af980bf..8007bfb7ece 100644 --- a/src/main/persistence/loading-store/store-runtime-authored-session-writes.test.ts +++ b/src/main/persistence/loading-store/store-runtime-authored-session-writes.test.ts @@ -1,3 +1,4 @@ +import { closeTestStores, createSqliteTestStore } from '../../persistence-test-harness' /** * Drives the real `Store`, not the helper and not a fake. * @@ -38,17 +39,18 @@ const WT = 'repo-1::/tmp/worktree-a' const stores: InstanceType[] = [] -afterEach(() => { +afterEach(async () => { // Leaving a debounced save armed would write into a temp dir after the test file finishes. for (const store of stores.splice(0)) { - store.flush() + store.freezeWrites() } + await closeTestStores() vi.restoreAllMocks() }) function createStore(): InstanceType { const dir = realpathSync(mkdtempSync(join(tmpdir(), 'orca-store-runtime-authored-'))) - const store = new Store({ dataFile: join(dir, 'orca-data.json') }) + const store = createSqliteTestStore(Store, { dataFile: join(dir, 'orca-data.json') }) stores.push(store) return store } diff --git a/src/main/persistence/loading-store/store-runtime-state.ts b/src/main/persistence/loading-store/store-runtime-state.ts index b14ea8ce3a4..b29f49f95f9 100644 --- a/src/main/persistence/loading-store/store-runtime-state.ts +++ b/src/main/persistence/loading-store/store-runtime-state.ts @@ -19,10 +19,20 @@ import type { AutomationListProjectionCache, AutomationStorageAuthority } from '../scheduling-automations/automation-owner-projection' +import type { ProfileStatePersistenceAuthority } from './profile-state-authority' +import type { AutomationRun } from '../../../shared/automations-types' + +export type DurableProfileStateMutation = { + value: T + /** 'if-dirty' fences an existing change without rewriting an already durable generation. */ + persist?: boolean | 'if-dirty' + rollback?: () => void +} export type StoreRuntimeOptions = { dataFile?: string storageAuthority?: AutomationStorageAuthority + profileStateAuthority?: ProfileStatePersistenceAuthority } /** Mutable coordination state shared only with this Store's private collaborators. */ @@ -30,30 +40,39 @@ export class StoreRuntimeState { state!: PersistedState readonly dataFile: string readonly storageAuthority: AutomationStorageAuthority + readonly profileStateAuthority: ProfileStatePersistenceAuthority | undefined automationListProjectionCache: AutomationListProjectionCache | null = null activeViewPreference!: ActiveViewPreference readonly terminalScrollbackSnapshotStorage: TerminalScrollbackSnapshotStorage writeTimer: ReturnType | null = null pendingWrite: Promise | null = null pendingSnapshotFileWork: Promise | null = null - readonly staleTempCleanup: Promise writeGeneration = 0 - inFlightAsyncTmpFile: string | null = null - backupRotationInFlight = false writesFrozen = false + fatalMutationError: Error | null = null + durableMutationPhase: 'mutate' | 'rollback' | null = null + profileMaintenancePending = false + pendingProfileMaintenance: Promise | null = null + readonly pendingProfileFlushes = new Set>() quitFlushStarted = false quitFlushPromise: Promise | null = null lastWrittenStateHash: string | null = null lastDurableWriteGeneration = -1 firstPendingSaveAt: number | null = null + /** Known dirty domains, or null when a caller requires a complete-document fallback. */ + dirtyProfileStateDomains: Set | null = new Set() + pendingAutomationRunsAfter: readonly AutomationRun[] | undefined githubCacheDirty = false githubCacheGeneration = 0 pendingGithubCacheWrite: Promise | null = null readonly staleGithubCacheTempCleanup: Promise + /** Reclaim compatibility-export temps left by a process killed during rename. */ + readonly staleProfileStateTempCleanup: Promise readonly gitUsernameCache = new Map() readonly protectedSecrets = new ProtectedSecretPersistence() loadNeedsSave = false flushOrThrow!: () => void + runDurableMutation!: (mutate: () => DurableProfileStateMutation) => Promise settingsChangeListeners = new Set< ( updates: Partial, @@ -72,9 +91,12 @@ export class StoreRuntimeState { constructor(options: StoreRuntimeOptions = {}) { this.dataFile = options.dataFile ?? getDataFile() this.storageAuthority = options.storageAuthority ?? 'desktop' - this.staleTempCleanup = removeStaleDurableWriteTempFiles(this.dataFile, { - minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS - }) + this.profileStateAuthority = options.profileStateAuthority + this.staleProfileStateTempCleanup = this.profileStateAuthority + ? removeStaleDurableWriteTempFiles(this.dataFile, { + minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS + }) + : Promise.resolve() this.staleGithubCacheTempCleanup = removeStaleDurableWriteTempFiles( getGithubCacheFile(this.dataFile), { minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS } diff --git a/src/main/persistence/loading-store/store.ts b/src/main/persistence/loading-store/store.ts index 017583e8f86..d60f1a7e3e1 100644 --- a/src/main/persistence/loading-store/store.ts +++ b/src/main/persistence/loading-store/store.ts @@ -12,28 +12,40 @@ import { createStoreDomains, installStoreDomainContexts, STORE_DOMAIN_OPERATION_CLASSES, - type StoreDomains + type StoreDomains, + type StoreDomainOperations } from './store-domain-composition' import type { PersistedState } from '../../../shared/persisted-state-types' import { scheduleSave } from './write-scheduling' -import type { WriteSchedulingOperations } from './write-scheduling' -import type { PrimaryStateWriteOperations } from './primary-state-writes' -import type { ProjectCollectionOperations } from './project-collection-operations' -import type { RepoLifecycleOperations } from './repo-lifecycle-operations' -import type { MobileTabSelectionPersistence } from './mobile-tab-selection-persistence' -import type { SparsePresetPersistence } from './sparse-preset-persistence' -import type { AutomationPersistence } from './automation-persistence' -import type { MetadataLineageOperations } from './metadata-lineage-operations' -import type { ProfilePreferences } from './profile-preferences' -import type { SessionHostPartitionOperations } from './session-host-partitions' -import type { SessionSnapshotOperations } from './session-snapshot-operations' -import type { PtyBindingPersistenceOperations } from './pty-binding-persistence' -import type { SshProfileOperations } from './ssh-profile-operations' -import type { RetiredWorktreeNamePersistence } from './retired-worktree-name-persistence' -import type { SshLeaseRecoveryOperations } from './ssh-lease-recovery-operations' -import type { WriteFlushBarrierOperations } from './write-flush-barriers' +import { enqueuePrimaryStateOperation, writeToDiskAsync } from './primary-state-writes' +import type { ProfileStateDatabaseQuarantine } from '../profile-state/profile-state-database-quarantine' +import { writeVersionedProfileStateExport } from '../profile-state/legacy-json/profile-state-versioned-export' +import { + beginProfileStateMaintenance, + freezeProfileStateWrites, + freezeProfileStateWritesAsync, + type ProfileStateMaintenanceOptions +} from './profile-state-maintenance' +import type { + AsyncProfileStateAuthority, + ProfileStateAuthorityInitialState, + ProfileStateStartupPaneAlias, + ProfileStatePersistenceAuthority, + ProfileStateMaintenance +} from './profile-state-authority' -export type StoreOptions = StoreRuntimeOptions +export type StoreOptions = StoreRuntimeOptions & { + /** Storage-form JSON supplied by a read-only profile migration/import boundary. */ + serializedState?: string + collectUnboundPaneAlias?: (entry: ProfileStateStartupPaneAlias) => void + /** Reuse the authority's validated startup read without retaining a cached copy. */ + initialAuthorityState?: ProfileStateAuthorityInitialState +} + +export type PreparedProfileStateExport = { + readonly json: string + commit(): void +} export type PtyBindingSourceExpectation = { worktreeId?: string tabId: string @@ -50,12 +62,49 @@ export class Store { private readonly state: PersistedState constructor(options: StoreOptions = {}) { + if (options.profileStateAuthority !== undefined && options.serializedState !== undefined) { + throw new Error('Store cannot use both a profile-state authority and serialized state') + } + if ( + options.initialAuthorityState !== undefined && + (options.profileStateAuthority === undefined || + options.initialAuthorityState.authority !== options.profileStateAuthority) + ) { + throw new Error('Store initial authority state must belong to its profile-state authority') + } + if (options.profileStateAuthority === undefined && options.serializedState === undefined) { + throw new Error('Writable Store construction requires a SQLite profile-state authority') + } + const initial = options.initialAuthorityState + const parsedState = initial?.takeParsedState?.() + const imported = options.serializedState !== undefined this.runtime = new StoreRuntimeState(options) + this.runtime.writesFrozen = imported this.domains = createStoreDomains(this.runtime) installStoreDomainContexts(this, this.domains) this.runtime.flushOrThrow = () => this.flushOrThrow() - const loaded = this.domains.loader.load() - const normalized = normalizePersistedPaneIdentityState(loaded) + this.runtime.runDurableMutation = (mutate) => this.runDurableMutation(mutate) + let loaded: PersistedState + if (options.profileStateAuthority !== undefined) { + if (initial !== undefined) { + loaded = + initial.takeParsedState !== undefined + ? this.domains.loader.loadParsedFromAuthority(parsedState) + : this.domains.loader.loadFromAuthority(initial.serializedState) + } else { + loaded = this.domains.loader.loadFromAuthority( + options.profileStateAuthority.readSerializedState() + ) + } + } else if (options.serializedState !== undefined) { + loaded = this.domains.loader.loadSerialized(options.serializedState) + } else { + throw new Error('Store requires an authority or a frozen serialized import') + } + const normalized = normalizePersistedPaneIdentityState(loaded, { + registerAliases: !imported, + collectUnboundPaneAlias: options.collectUnboundPaneAlias + }) this.state = normalized.state this.runtime.state = this.state this.runtime.activeViewPreference = new ActiveViewPreference( @@ -67,27 +116,38 @@ export class Store { // Load is the only place an orphaned repo id can be swept: every removal path needs the repo to // still be registered, so rows outlive their owner without one (#17776). const sweptRepoIds = this.domains.repos.sweepDeregisteredRepoResidue() - for (const entry of normalized.migrationUnsupportedEntries) { - setMigrationUnsupportedPty(entry) - } - for (const entry of normalized.legacyPaneKeyAliasEntries) { - registerPersistedPaneKeyAlias(entry) - } - setMigrationUnsupportedPtyPersistenceListener((entries) => { - this.state.migrationUnsupportedPtyEntries = entries - scheduleSave(this.domains.scheduling) - }) - agentHookServer.setPaneKeyAliasPersistenceListener((entries) => { - this.state.legacyPaneKeyAliasEntries = entries - scheduleSave(this.domains.scheduling) - }) - if ( - normalized.changed || - this.runtime.loadNeedsSave || - adaptedProjectGroups || - sweptRepoIds.length > 0 - ) { - scheduleSave(this.domains.scheduling) + // Imported snapshots cannot own the live hook server or write their source file. + if (!imported) { + for (const entry of initial?.unboundPaneAliases ?? []) { + agentHookServer.registerPaneKeyAlias( + entry.legacyPaneKey, + entry.stablePaneKey, + undefined, + entry.updatedAt + ) + } + for (const entry of normalized.migrationUnsupportedEntries) { + setMigrationUnsupportedPty(entry) + } + for (const entry of normalized.legacyPaneKeyAliasEntries) { + registerPersistedPaneKeyAlias(entry) + } + setMigrationUnsupportedPtyPersistenceListener((entries) => { + this.state.migrationUnsupportedPtyEntries = entries + scheduleSave(this.domains.scheduling) + }) + agentHookServer.setPaneKeyAliasPersistenceListener((entries) => { + this.state.legacyPaneKeyAliasEntries = entries + scheduleSave(this.domains.scheduling) + }) + if ( + normalized.changed || + this.runtime.loadNeedsSave || + adaptedProjectGroups || + sweptRepoIds.length > 0 + ) { + scheduleSave(this.domains.scheduling) + } } } @@ -95,34 +155,159 @@ export class Store { return dirname(this.runtime.dataFile) } - freezeWrites(): void { - this.runtime.writesFrozen = true - if (this.runtime.writeTimer) { - clearTimeout(this.runtime.writeTimer) - this.runtime.writeTimer = null + /** + * Prepare a storage-form export for a database importer. + * + * Secret retention is committed only after the caller durably accepts the + * export. This keeps a failed migration from discarding the prior sealed + * value from the in-memory fallback store. + */ + prepareProfileStateExport(): PreparedProfileStateExport { + const built = this.domains.serialization.buildStateToSave() + let committed = false + return { + json: built.payload.toString('utf8'), + commit: () => { + if (committed) { + return + } + this.runtime.protectedSecrets.commitRetentionUpdates(built.protectedSecretUpdates) + committed = true + } } } + + /** Publish an explicit rollback/compatibility export after flushing current state. */ + writeProfileStateJsonExport(targetPath: string): number { + this.runtime.dirtyProfileStateDomains = null + this.flushOrThrow() + const authority = this.runtime.profileStateAuthority + if (authority?.asynchronous) { + throw new Error('Live profile exports require an awaited export') + } + if (authority?.writeJsonExport) { + return authority.writeJsonExport(targetPath) + } + + throw new Error('Profile state exports require a SQLite profile-state authority') + } + + /** Publish the latest SQLite revision as a durable, versioned rollback export. */ + writeLatestProfileStateJsonExport(): number | undefined { + const authority = this.runtime.profileStateAuthority + if (authority?.asynchronous) { + throw new Error('Live profile exports require an awaited export') + } + if (!authority?.writeJsonExport) { + return undefined + } + this.runtime.dirtyProfileStateDomains = null + this.flushOrThrow() + + const writeExport = authority.writeJsonExport.bind(authority) + return writeVersionedProfileStateExport(this.runtime.dataFile, writeExport) + } + + /** Publish recovery and canonical JSON checkpoints for older builds. */ + writeLatestProfileStateJsonCompatibilityExport(): number | undefined { + const authority = this.runtime.profileStateAuthority + if (authority?.asynchronous) { + throw new Error('Live profile exports require an awaited export') + } + if (!authority?.writeJsonCompatibilityExport) { + return undefined + } + this.runtime.dirtyProfileStateDomains = null + this.flushOrThrow() + return authority.writeJsonCompatibilityExport(this.runtime.dataFile) + } + + writeLatestProfileStateJsonExportAsync(): Promise { + if (!this.runtime.profileStateAuthority?.asynchronous) { + return Promise.resolve(this.writeLatestProfileStateJsonExport()) + } + return this.enqueueProfileExport((authority) => + authority.writeLatestJsonExport(this.runtime.dataFile) + ) + } + + writeLatestProfileStateJsonCompatibilityExportAsync(): Promise { + if (!this.runtime.profileStateAuthority?.asynchronous) { + return Promise.resolve(this.writeLatestProfileStateJsonCompatibilityExport()) + } + return this.enqueueProfileExport((authority) => + authority.writeJsonCompatibilityExport(this.runtime.dataFile) + ) + } + + private enqueueProfileExport( + exportState: (authority: AsyncProfileStateAuthority) => Promise + ): Promise { + if ( + this.runtime.writesFrozen || + this.runtime.quitFlushStarted || + this.runtime.profileMaintenancePending + ) { + return Promise.reject(new Error('Cannot export finalized profile persistence')) + } + const authority = this.runtime.profileStateAuthority + if (!authority?.asynchronous) { + return Promise.resolve(undefined) + } + return enqueuePrimaryStateOperation(this.domains.writes, async () => { + this.runtime.dirtyProfileStateDomains = null + if (!(await writeToDiskAsync(this.domains.writes))) { + throw new Error('Profile state changed while preparing its export') + } + return exportState(authority) + }) + } + + /** Freeze writes, then preserve the SQLite family for an explicit recovery decision. */ + quarantineProfileStateDatabase( + quarantineRoot?: string, + reason?: string + ): ProfileStateDatabaseQuarantine { + this.freezeWrites() + const authority = this.runtime.profileStateAuthority + if (authority?.asynchronous) { + throw new Error('Live profile quarantine requires an awaited close') + } + if (!authority?.quarantineDatabase) { + throw new Error('SQLite profile-state quarantine is unavailable') + } + return authority.quarantineDatabase(quarantineRoot, reason) + } + + freezeWrites(): void { + freezeProfileStateWrites(this.runtime) + } + + beginProfileMaintenance( + options?: ProfileStateMaintenanceOptions + ): Promise { + return beginProfileStateMaintenance(this.runtime, this.domains, options) + } + + freezeWritesAsync(): Promise { + return freezeProfileStateWritesAsync(this.runtime) + } + + async quarantineProfileStateDatabaseAsync( + quarantineRoot?: string, + reason?: string + ): Promise { + await this.beginProfileMaintenance({ flush: false }) + const authority = this.runtime.profileStateAuthority + if (!authority?.quarantineDatabase) { + throw new Error('SQLite profile-state quarantine is unavailable') + } + return authority.quarantineDatabase(quarantineRoot, reason) + } } // oxlint-disable-next-line typescript-eslint/consistent-type-definitions -- declaration merging derives Store's prototype API directly from the exact concrete domain classes installed below -export interface Store - extends - WriteSchedulingOperations, - PrimaryStateWriteOperations, - ProjectCollectionOperations, - RepoLifecycleOperations, - MobileTabSelectionPersistence, - SparsePresetPersistence, - AutomationPersistence, - MetadataLineageOperations, - ProfilePreferences, - SessionHostPartitionOperations, - SessionSnapshotOperations, - PtyBindingPersistenceOperations, - SshProfileOperations, - RetiredWorktreeNamePersistence, - SshLeaseRecoveryOperations, - WriteFlushBarrierOperations {} +export interface Store extends StoreDomainOperations {} for (const OperationClass of STORE_DOMAIN_OPERATION_CLASSES) { const descriptors = Object.getOwnPropertyDescriptors(OperationClass.prototype) diff --git a/src/main/persistence/loading-store/terminal-close-async-durability.test.ts b/src/main/persistence/loading-store/terminal-close-async-durability.test.ts new file mode 100644 index 00000000000..090d2340932 --- /dev/null +++ b/src/main/persistence/loading-store/terminal-close-async-durability.test.ts @@ -0,0 +1,227 @@ +import { expect, it, vi } from 'vitest' +import { fixture } from './profile-state-delayed-authority-fixture' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' +import type { RuntimeMobileSessionTabsSnapshot } from '../../../shared/runtime-types' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + ptyId: 'closing-pty', + incarnationId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +} +const siblingLeafId = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' + +class CloseRuntime extends OrcaRuntimeService { + async closeHeadlessTab(): Promise { + const snapshot = this.mobileSessionTabsByWorktree.get(binding.worktreeId) + const tab = snapshot?.tabs[0] + if (!snapshot || tab?.type !== 'terminal') { + throw new Error('missing test tab') + } + await this.closeHeadlessMobileTerminalTab(binding.worktreeId, snapshot, tab) + } + publish(): void { + const snapshot: RuntimeMobileSessionTabsSnapshot = { + worktree: binding.worktreeId, + publicationEpoch: 'close-test', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: `${binding.tabId}::${binding.leafId}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `${binding.tabId}::${binding.leafId}`, + parentTabId: binding.tabId, + leafId: binding.leafId, + ptyId: binding.ptyId, + title: 'Terminal', + isActive: true + } + ] + } + this.storeMobileSessionSnapshot(binding.worktreeId, snapshot) + } +} + +async function closeFixture(options: { split?: boolean } = {}) { + const result = await fixture() + await result.store.persistPtyBinding(binding) + if (options.split) { + await result.store.persistPtyBinding({ + ...binding, + leafId: siblingLeafId, + ptyId: 'sibling-pty', + incarnationId: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', + expectedSourceBinding: binding + }) + } + const runtime = new CloseRuntime(result.store) + const kill = vi.fn(() => true) + runtime.setPtyController({ write: () => true, kill, getForegroundProcess: async () => null }) + return { + ...result, + runtime, + kill, + persistedLeafIds: () => + Object.keys( + result.readState().workspaceSession.terminalLayoutsByTabId[binding.tabId]?.ptyIdsByLeafId ?? + {} + ), + liveLeafIds: () => + Object.keys( + result.store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId]?.ptyIdsByLeafId ?? + {} + ) + } +} + +it.each([ + ['tab', { kind: 'tab' as const, tabId: binding.tabId }, []], + [ + 'split pane', + { kind: 'pane' as const, tabId: binding.tabId, leafId: binding.leafId }, + [siblingLeafId] + ] +])('acknowledges a renderer %s close only once it is durable', async (kind, target, remaining) => { + const { authority, runtime, persistedLeafIds } = await closeFixture({ split: kind !== 'tab' }) + const before = persistedLeafIds() + const gate = authority.pause() + let acknowledged = false + const closing = runtime + .closeTerminalSurfaceFromRenderer({ worktreeId: binding.worktreeId, target: target }) + .then(() => { + acknowledged = true + }) + try { + await Promise.race([gate.started.promise, closing]) + expect(acknowledged).toBe(false) + expect(persistedLeafIds()).toEqual(before) + } finally { + gate.finish.resolve() + } + await closing + expect(persistedLeafIds()).toEqual(remaining) +}) + +it('keeps a phone close and still kills when its durable write fails', async () => { + const { authority, store, runtime, kill, persistedLeafIds, liveLeafIds } = await closeFixture() + runtime.registerPty(binding.ptyId, binding.worktreeId, null, binding) + runtime.publish() + const failure = vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + const closing = runtime.closeHeadlessTab() + await Promise.race([gate.started.promise, closing]) + gate.finish.reject(new Error('close disk refused')) + + await expect(closing).resolves.toBeUndefined() + expect(kill).toHaveBeenCalledExactlyOnceWith(binding.ptyId) + expect(liveLeafIds()).toEqual([]) + expect(persistedLeafIds()).toEqual([binding.leafId]) + expect(failure).toHaveBeenCalledWith( + '[runtime] failed to persist terminal close:', + expect.objectContaining({ message: 'close disk refused' }) + ) + + // Nothing rolled the removal back, so the next write makes it durable. + await store.flushPendingOrThrowAsync() + expect(persistedLeafIds()).toEqual([]) +}) + +it('keeps a renderer close when its durable write fails', async () => { + const { authority, runtime, persistedLeafIds, liveLeafIds } = await closeFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + const closing = runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: binding.worktreeId, + target: { + kind: 'tab', + tabId: binding.tabId + } + }) + await Promise.race([gate.started.promise, closing]) + gate.finish.reject(new Error('close disk refused')) + + await expect(closing).resolves.toBeUndefined() + expect(liveLeafIds()).toEqual([]) + expect(persistedLeafIds()).toEqual([binding.leafId]) +}) + +it('commits nothing for a pane that restarted while its close waited for the writer', async () => { + const { authority, store, runtime, liveLeafIds } = await closeFixture({ split: true }) + const gate = authority.pause() + const session = store.getWorkspaceSession() + store.setWorkspaceSession({ ...session, activeTabIdByWorktree: { [binding.worktreeId]: null } }) + const earlierWrite = store.flushPendingOrThrowAsync() + await gate.started.promise + // The restart is queued first, so it lands after the close captured the pane it meant. + const restart = store.persistPtyBinding({ + ...binding, + ptyId: 'restarted-pty', + incarnationId: 'ffffffff-ffff-4fff-8fff-ffffffffffff' + }) + const closing = runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: binding.worktreeId, + target: { + kind: 'pane', + tabId: binding.tabId, + leafId: binding.leafId + } + }) + gate.finish.resolve() + await earlierWrite + await expect(restart).resolves.toBe(true) + await closing + + expect(liveLeafIds()).toEqual([binding.leafId, siblingLeafId]) +}) + +it('commits a renderer tab close whose split pane bound while the close waited for the writer', async () => { + const { authority, store, runtime, persistedLeafIds, liveLeafIds } = await closeFixture() + const gate = authority.pause() + const session = store.getWorkspaceSession() + store.setWorkspaceSession({ ...session, activeTabIdByWorktree: { [binding.worktreeId]: null } }) + const earlierWrite = store.flushPendingOrThrowAsync() + await gate.started.promise + // The split's binding is queued first, so it grows the tab after the close was asked. + const split = store.persistPtyBinding({ + ...binding, + leafId: siblingLeafId, + ptyId: 'sibling-pty', + incarnationId: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', + expectedSourceBinding: binding + }) + const closing = runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: binding.worktreeId, + target: { + kind: 'tab', + tabId: binding.tabId + } + }) + gate.finish.resolve() + await earlierWrite + await expect(split).resolves.toBe(true) + await expect(closing).resolves.toBeUndefined() + + expect(persistedLeafIds()).toEqual([]) + expect(liveLeafIds()).toEqual([]) + expect( + store + .getWorkspaceSession() + .tabsByWorktree[binding.worktreeId]?.some((tab) => tab.id === binding.tabId) + ).toBe(false) + // Why: skipping the owner fence for the layout owner's own close must not skip its record. + expect(store.getWorkspaceSession().closedTerminalTabTombstonesByTabId?.[binding.tabId]).toEqual( + expect.objectContaining({ worktreeId: binding.worktreeId, reason: 'user' }) + ) +}) diff --git a/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts index 5b7f90cbb8f..96834ffbcf9 100644 --- a/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts +++ b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts @@ -111,7 +111,19 @@ export function setLocalWorkspaceSession( if (deferSnapshotFiles) { enqueueTerminalScrollbackSnapshotWork(owner, prior, session) } - scheduleSave(context.scheduling) + if ( + remappedAcknowledgements.changed || + remappedActivityCutoffs.changed || + remappedManualUnread.changed + ) { + // UI remaps include protected fields, so keep the complete serializer boundary. + scheduleSave(context.scheduling) + } else { + scheduleSave( + context.scheduling, + remappedLeases.changed ? ['workspaceSession', 'sshRemotePtyLeases'] : ['workspaceSession'] + ) + } } export function enqueueTerminalScrollbackSnapshotWork( diff --git a/src/main/persistence/loading-store/worktree-identity-metadata.ts b/src/main/persistence/loading-store/worktree-identity-metadata.ts index 21fea4ac15c..9515986f86a 100644 --- a/src/main/persistence/loading-store/worktree-identity-metadata.ts +++ b/src/main/persistence/loading-store/worktree-identity-metadata.ts @@ -15,6 +15,13 @@ import { mergeWorktreeMetaForWrite } from './worktree-meta-write-normalization' type MetadataRuntime = Pick +/** Storage rows changed together by host-qualified metadata writes. */ +export const WORKTREE_METADATA_DOMAINS = [ + 'worktreeMeta', + 'worktreeMetaByIdentity', + 'worktreeIdentityAliases' +] as const + /** Select one readable row without discarding competing alias candidates. */ function resolveAliasIdentityKey(state: PersistedState, alias: string): string | undefined { const identityKeys = state.worktreeIdentityAliases?.[alias] ?? [] @@ -161,7 +168,7 @@ export function getWorktreeMetaForHost( const alias = composeWorktreeHostIdentity(executionHostId, worktreeId) const identityKey = resolveAliasIdentityKey(state, alias) if (changed) { - scheduleSave(scheduling) + scheduleSave(scheduling, WORKTREE_METADATA_DOMAINS) } if (identityKey) { return state.worktreeMetaByIdentity?.[identityKey] @@ -238,6 +245,6 @@ export function setWorktreeMetaForHost( if (!legacy || legacy.hostId === executionHostId) { state.worktreeMeta[worktreeId] = updated } - scheduleSave(scheduling) + scheduleSave(scheduling, WORKTREE_METADATA_DOMAINS) return updated } diff --git a/src/main/persistence/loading-store/worktree-meta-alias-projection.test.ts b/src/main/persistence/loading-store/worktree-meta-alias-projection.test.ts index f324787cdf3..1ec0bb077a3 100644 --- a/src/main/persistence/loading-store/worktree-meta-alias-projection.test.ts +++ b/src/main/persistence/loading-store/worktree-meta-alias-projection.test.ts @@ -1,3 +1,8 @@ +import { + closeTestStores, + createSqliteTestStore, + readPersistedStateJson +} from '../../persistence-test-harness' /** * `setWorktreeMetaForHost` puts one object in both `worktreeMeta` and `worktreeMetaByIdentity`, so * a heavy profile serializes every metadata row twice. On a measured 3.64 MB install 1,347 of @@ -11,7 +16,7 @@ * to the same state, the locator map is never reduced (which is what makes a downgrade lossless), * and a build with no rebuild at all recovers every row from the file the new build wrote. */ -import { mkdtempSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' +import { mkdtempSync, realpathSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -54,15 +59,16 @@ function seededRandom(seed: number): () => number { } const stores: InstanceType[] = [] -afterEach(() => { +afterEach(async () => { for (const store of stores.splice(0)) { store.freezeWrites() } + await closeTestStores() vi.restoreAllMocks() }) function openStore(dataFile: string): InstanceType { - const store = new Store({ dataFile }) + const store = createSqliteTestStore(Store, { dataFile }) stores.push(store) return store } @@ -229,7 +235,7 @@ describe('worktree meta alias projection', () => { const loaded = openStore(dataFile) const before = snapshot(loaded) loaded.flush() - const rewritten = readFileSync(dataFile, 'utf-8') + const rewritten = readPersistedStateJson(dataFile) const onDisk = JSON.parse(rewritten) as PersistedState // The counter this change exists for: 401 regenerable identity rows leave the file. @@ -257,7 +263,7 @@ describe('worktree meta alias projection', () => { // A quiet app does not rewrite the file with new content on the next flush. reloaded.flush() - expect(readFileSync(dataFile, 'utf-8')).toBe(rewritten) + expect(readPersistedStateJson(dataFile)).toBe(rewritten) }) /** @@ -277,17 +283,19 @@ describe('worktree meta alias projection', () => { // What a build without this change does with that file: parse it, run the metadata normalizer // it already ships (untouched here), write the result back. - const downgraded = JSON.parse(readFileSync(dataFile, 'utf-8')) as PersistedState + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The preceding Store save produced the PersistedState snapshot read by this test. + const downgraded = JSON.parse(readPersistedStateJson(dataFile)) as PersistedState normalizeWorktreeLinkedItemMetadata(downgraded) expect(Object.keys(downgraded.worktreeMeta).sort()).toEqual(Object.keys(before.meta).sort()) // It drops the aliases whose identity row is not there; it never touches a locator row. expect(downgraded.worktreeIdentityAliases).not.toHaveProperty( composeWorktreeHostIdentity(LOCAL, worktreeId(0)) ) - writeFileSync(dataFile, JSON.stringify(downgraded), 'utf-8') + const rollbackDataFile = tempDataFile() + writeFileSync(rollbackDataFile, JSON.stringify(downgraded), 'utf-8') // Every reader is where it started, with no rebuild and without touching a row first. - const rolledBack = openStore(dataFile) + const rolledBack = openStore(rollbackDataFile) expect(rolledBack.getAllWorktreeMeta()).toEqual(before.meta) expect(rolledBack.getAllWorktreeMetaForHost(LOCAL)).toEqual(before.local) expect(rolledBack.getAllWorktreeMetaForHost(REMOTE as never)).toEqual(before.remote) @@ -298,7 +306,8 @@ describe('worktree meta alias projection', () => { before.meta[worktreeId(0)] ) rolledBack.flush() - const reminted = JSON.parse(readFileSync(dataFile, 'utf-8')) as PersistedState + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The preceding Store save produced the PersistedState snapshot read by this test. + const reminted = JSON.parse(readPersistedStateJson(rollbackDataFile)) as PersistedState expect( reminted.worktreeIdentityAliases?.[composeWorktreeHostIdentity(LOCAL, worktreeId(0))] ).toEqual([ @@ -338,7 +347,7 @@ describe('worktree meta alias projection', () => { fromLegacy.flush() const compactFile = tempDataFile() - writeFileSync(compactFile, readFileSync(legacyFile)) + writeFileSync(compactFile, readPersistedStateJson(legacyFile)) const fromCompact = openStore(compactFile) expect(fromCompact.getAllWorktreeMeta()).toEqual(fromLegacy.getAllWorktreeMeta()) @@ -366,7 +375,8 @@ describe('worktree meta alias projection', () => { ) expect(store.getAllWorktreeMeta()[worktreeId(0)]?.displayName).toBe('workspace-0') store.flush() - const onDisk = JSON.parse(readFileSync(dataFile, 'utf-8')) as PersistedState + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The preceding Store save produced the PersistedState snapshot read by this test. + const onDisk = JSON.parse(readPersistedStateJson(dataFile)) as PersistedState expect(Object.keys(onDisk.worktreeMeta).length).toBe( Object.keys(fixture.state.worktreeMeta).length ) @@ -409,7 +419,8 @@ describe('worktree meta alias projection', () => { expect(Object.keys(store.getAllWorktreeMeta())).toEqual([worktreeId(0)]) store.flush() - const onDisk = JSON.parse(readFileSync(dataFile, 'utf-8')) as PersistedState + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The preceding Store save produced the PersistedState snapshot read by this test. + const onDisk = JSON.parse(readPersistedStateJson(dataFile)) as PersistedState expect(Object.hasOwn(onDisk, 'worktreeMetaByIdentity')).toBe(false) // The locator map and its lineage companions are all still there, untouched by the projection. expect(Object.keys(onDisk.worktreeMeta)).toEqual([worktreeId(0)]) diff --git a/src/main/persistence/loading-store/write-flush-barriers.ts b/src/main/persistence/loading-store/write-flush-barriers.ts index c08d4367989..b4c583666dc 100644 --- a/src/main/persistence/loading-store/write-flush-barriers.ts +++ b/src/main/persistence/loading-store/write-flush-barriers.ts @@ -6,6 +6,7 @@ import { getGithubCacheFile } from './user-data-path' import type { StoreRuntimeState } from './store-runtime-state' import type { PrimaryStateWriteOperations } from './primary-state-writes' import { enqueueWrite } from './primary-state-writes' +import { drainProfileStateOperations, runProfileStateFlush } from './profile-state-flush-lifetime' type WriteFlushBarrierOperationsRuntime = Pick< StoreRuntimeState, @@ -17,9 +18,14 @@ type WriteFlushBarrierOperationsRuntime = Pick< | 'githubCacheGeneration' | 'lastDurableWriteGeneration' | 'pendingGithubCacheWrite' + | 'pendingProfileFlushes' + | 'pendingProfileMaintenance' + | 'profileMaintenancePending' + | 'profileStateAuthority' | 'quitFlushPromise' | 'quitFlushStarted' | 'staleGithubCacheTempCleanup' + | 'staleProfileStateTempCleanup' | 'state' | 'writeGeneration' | 'writeTimer' @@ -30,6 +36,7 @@ const writeFlushBarrierOperationsContext = Symbol('WriteFlushBarrierOperations') type WriteFlushBarrierOperationsContext = { runtime: WriteFlushBarrierOperationsRuntime writes: PrimaryStateWriteOperations + bestEffortFinalFlush?: Promise } export class WriteFlushBarrierOperations { @@ -40,136 +47,184 @@ export class WriteFlushBarrierOperations { } flush(): void { - this[writeFlushBarrierOperationsContext].runtime.automationListProjectionCache = null - if (this[writeFlushBarrierOperationsContext].runtime.quitFlushStarted) { + const { runtime, writes } = this[writeFlushBarrierOperationsContext] + runtime.automationListProjectionCache = null + if (runtime.quitFlushStarted || runtime.profileMaintenancePending) { + return + } + if (runtime.profileStateAuthority?.asynchronous) { + runtime.writeGeneration++ + void flushCurrentStateAsync(this, { drainToStableGeneration: false }).catch((error) => + console.error('[persistence] Failed to flush state:', error) + ) return } try { - this[writeFlushBarrierOperationsContext].writes.flushOrThrow() + writes.flushOrThrow() } catch (err) { console.error('[persistence] Failed to flush state:', err) } try { - this[writeFlushBarrierOperationsContext].writes.flushActiveViewPreferenceOrThrow() + writes.flushActiveViewPreferenceOrThrow() } catch (err) { console.error('[active-view] Failed to flush preference:', err) } writeGithubCacheSnapshotSync(this) } - flushAsync(): Promise { - if (this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise) { - return this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise + flushAsync(options: { exportJsonCompatibility?: boolean } = {}): Promise { + const context = this[writeFlushBarrierOperationsContext] + context.bestEffortFinalFlush ??= this.flushFinalOrThrowAsync(options).catch((error) => + console.error('[persistence] Failed to flush final state:', error) + ) + return context.bestEffortFinalFlush + } + + flushFinalOrThrowAsync(options: { exportJsonCompatibility?: boolean } = {}): Promise { + const { runtime } = this[writeFlushBarrierOperationsContext] + if (runtime.quitFlushPromise) { + return runtime.quitFlushPromise } - this[writeFlushBarrierOperationsContext].runtime.quitFlushStarted = true - this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise = flushCurrentStateAsync( - this, - true - ).catch(() => {}) - return this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise + runtime.quitFlushStarted = true + runtime.quitFlushPromise = Promise.resolve(runtime.pendingProfileMaintenance) + .catch((error: unknown) => { + // Failed maintenance may re-admit unchanged storage before this final checkpoint. + if (runtime.profileMaintenancePending || runtime.writesFrozen) { + throw error + } + }) + .then(async () => { + if (runtime.profileMaintenancePending) { + return + } + await drainProfileStateOperations([ + ...runtime.pendingProfileFlushes, + runtime.staleProfileStateTempCleanup, + runtime.profileStateAuthority?.drainBackups?.(true) + ]) + await flushCurrentStateAsync(this, { final: true }) + if (options.exportJsonCompatibility) { + await runtime.profileStateAuthority?.writeJsonCompatibilityExportAsync?.(runtime.dataFile) + } + }) + .finally(async () => { + if (runtime.profileStateAuthority?.asynchronous) { + runtime.writesFrozen = true + await runtime.profileStateAuthority.close() + } + }) + return runtime.quitFlushPromise } flushPendingAsync(): Promise { + const { runtime } = this[writeFlushBarrierOperationsContext] + if (runtime.writesFrozen || runtime.quitFlushStarted || runtime.profileMaintenancePending) { + return Promise.resolve() + } // Best-effort callers must not livelock while the live app keeps mutating state. - return flushCurrentStateAsync(this, false, undefined, false).catch(() => {}) + return flushCurrentStateAsync(this, { drainToStableGeneration: false }).catch(() => {}) } flushPendingOrThrowAsync( options: { signal?: AbortSignal; drainToStableGeneration?: boolean } = {} ): Promise { - if ( - this[writeFlushBarrierOperationsContext].runtime.writesFrozen || - this[writeFlushBarrierOperationsContext].runtime.quitFlushStarted - ) { + const { runtime } = this[writeFlushBarrierOperationsContext] + if (runtime.writesFrozen || runtime.profileMaintenancePending || runtime.quitFlushStarted) { return Promise.reject(new Error('Cannot flush while persistence is finalized')) } - return flushCurrentStateAsync( - this, - false, - options.signal, - options.drainToStableGeneration, - true - ) + return flushCurrentStateAsync(this, { + signal: options.signal, + drainToStableGeneration: options.drainToStableGeneration, + requireInitialGenerationDurable: true + }) } } export async function flushDurableStateOrThrowAsync( owner: WriteFlushBarrierOperations ): Promise { - if ( - owner[writeFlushBarrierOperationsContext].runtime.writesFrozen || - owner[writeFlushBarrierOperationsContext].runtime.quitFlushStarted - ) { + const { runtime, writes } = owner[writeFlushBarrierOperationsContext] + if (runtime.writesFrozen || runtime.profileMaintenancePending || runtime.quitFlushStarted) { throw new Error('Cannot flush while persistence is finalized') } - for (;;) { - if (owner[writeFlushBarrierOperationsContext].runtime.writeTimer) { - clearTimeout(owner[writeFlushBarrierOperationsContext].runtime.writeTimer) - owner[writeFlushBarrierOperationsContext].runtime.writeTimer = null + return runProfileStateFlush(runtime, async () => { + for (;;) { + if (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) + runtime.writeTimer = null + } + runtime.firstPendingSaveAt = null + const generation = runtime.writeGeneration + await enqueueWrite(writes) + if (generation === runtime.writeGeneration) { + break + } } - owner[writeFlushBarrierOperationsContext].runtime.firstPendingSaveAt = null - const generation = owner[writeFlushBarrierOperationsContext].runtime.writeGeneration - await enqueueWrite(owner[writeFlushBarrierOperationsContext].writes) - if (generation === owner[writeFlushBarrierOperationsContext].runtime.writeGeneration) { - break - } - } + }) } export async function flushCurrentStateAsync( owner: WriteFlushBarrierOperations, - final: boolean, - signal?: AbortSignal, - drainToStableGeneration = true, - requireInitialGenerationDurable = false + { + final = false, + signal, + drainToStableGeneration = true, + requireInitialGenerationDurable = false, + fullCheckpoint = final + }: { + final?: boolean + signal?: AbortSignal + drainToStableGeneration?: boolean + requireInitialGenerationDurable?: boolean + fullCheckpoint?: boolean + } ): Promise { - const requiredDurableGeneration = requireInitialGenerationDurable - ? owner[writeFlushBarrierOperationsContext].runtime.writeGeneration - : null - for (;;) { - if (signal?.aborted) { - throw new Error('Persistence flush aborted') - } - if (owner[writeFlushBarrierOperationsContext].runtime.writeTimer) { - clearTimeout(owner[writeFlushBarrierOperationsContext].runtime.writeTimer) - owner[writeFlushBarrierOperationsContext].runtime.writeTimer = null - } - owner[writeFlushBarrierOperationsContext].runtime.firstPendingSaveAt = null - const generation = owner[writeFlushBarrierOperationsContext].runtime.writeGeneration - try { - await enqueueWrite(owner[writeFlushBarrierOperationsContext].writes) - } catch (error) { - await (final - ? owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushAsync() - : owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushPendingAsync( - signal - )) - await writeGithubCacheSnapshotAsync(owner, final, signal) - throw error - } - await (final - ? owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushAsync() - : owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushPendingAsync( + const { runtime, writes } = owner[writeFlushBarrierOperationsContext] + return runProfileStateFlush(runtime, async () => { + const requiredDurableGeneration = requireInitialGenerationDurable + ? runtime.writeGeneration + : null + for (;;) { + if (signal?.aborted) { + throw new Error('Persistence flush aborted') + } + if (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) + runtime.writeTimer = null + } + runtime.firstPendingSaveAt = null + const generation = runtime.writeGeneration + try { + await enqueueWrite(writes, { + fullCheckpoint, signal - )) - await writeGithubCacheSnapshotAsync(owner, final, signal) - if (signal?.aborted) { - throw new Error('Persistence flush aborted') - } - if (!drainToStableGeneration) { - if ( - requiredDurableGeneration === null || - owner[writeFlushBarrierOperationsContext].runtime.lastDurableWriteGeneration >= - requiredDurableGeneration - ) { + }) + } finally { + await (final + ? runtime.activeViewPreference.flushAsync() + : runtime.activeViewPreference.flushPendingAsync(signal)) + await writeGithubCacheSnapshotAsync(owner, final, signal) + if (final || runtime.profileMaintenancePending) { + await runtime.profileStateAuthority?.drainBackups?.(true) + } + } + if (signal?.aborted) { + throw new Error('Persistence flush aborted') + } + if (!drainToStableGeneration) { + if ( + requiredDurableGeneration === null || + runtime.lastDurableWriteGeneration >= requiredDurableGeneration + ) { + break + } + continue + } + if (generation === runtime.writeGeneration) { break } - continue } - if (generation === owner[writeFlushBarrierOperationsContext].runtime.writeGeneration) { - break - } - } + }) } export async function writeGithubCacheSnapshotAsync( @@ -177,39 +232,28 @@ export async function writeGithubCacheSnapshotAsync( drainToStableGeneration = true, signal?: AbortSignal ): Promise { - if (!owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty) { + const { runtime } = owner[writeFlushBarrierOperationsContext] + if (!runtime.githubCacheDirty) { return } - const previousWrite = - owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite ?? - owner[writeFlushBarrierOperationsContext].runtime.staleGithubCacheTempCleanup + const previousWrite = runtime.pendingGithubCacheWrite ?? runtime.staleGithubCacheTempCleanup const nextWrite = previousWrite .then(async () => { - while (owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty) { + while (runtime.githubCacheDirty) { if (signal?.aborted) { throw new Error('GitHub cache flush aborted') } - const generation = owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration - const cacheFile = getGithubCacheFile( - owner[writeFlushBarrierOperationsContext].runtime.dataFile - ) + const generation = runtime.githubCacheGeneration + const cacheFile = getGithubCacheFile(runtime.dataFile) const tmpFile = durableWriteTempPath(cacheFile) let renamed = false try { - await writeFile( - tmpFile, - JSON.stringify(owner[writeFlushBarrierOperationsContext].runtime.state.githubCache), - 'utf-8' - ) - if ( - generation === owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration - ) { + await writeFile(tmpFile, JSON.stringify(runtime.state.githubCache), 'utf-8') + if (generation === runtime.githubCacheGeneration) { await rename(tmpFile, cacheFile) renamed = true - if ( - generation === owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration - ) { - owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty = false + if (generation === runtime.githubCacheGeneration) { + runtime.githubCacheDirty = false } } } finally { @@ -229,41 +273,36 @@ export async function writeGithubCacheSnapshotAsync( console.warn('[persistence] Failed to write github cache snapshot:', err) }) .finally(() => { - if (owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite === nextWrite) { - owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite = null + if (runtime.pendingGithubCacheWrite === nextWrite) { + runtime.pendingGithubCacheWrite = null } }) - owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite = nextWrite + runtime.pendingGithubCacheWrite = nextWrite await nextWrite } export function writeGithubCacheSnapshotSync(owner: WriteFlushBarrierOperations): void { - if (!owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty) { + const { runtime } = owner[writeFlushBarrierOperationsContext] + if (!runtime.githubCacheDirty) { return } - if (owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite) { + if (runtime.pendingGithubCacheWrite) { void writeGithubCacheSnapshotAsync(owner) return } - const cacheFile = getGithubCacheFile(owner[writeFlushBarrierOperationsContext].runtime.dataFile) - const generation = owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration + const cacheFile = getGithubCacheFile(runtime.dataFile) + const generation = runtime.githubCacheGeneration const tmpFile = durableWriteTempPath(cacheFile) try { - writeFileSync( - tmpFile, - JSON.stringify(owner[writeFlushBarrierOperationsContext].runtime.state.githubCache), - 'utf-8' - ) + writeFileSync(tmpFile, JSON.stringify(runtime.state.githubCache), 'utf-8') renameSync(tmpFile, cacheFile) - if (generation === owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration) { - owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty = false + if (generation === runtime.githubCacheGeneration) { + runtime.githubCacheDirty = false } } catch (err) { try { unlinkSync(tmpFile) - } catch { - // Best-effort cleanup. - } + } catch {} console.warn('[persistence] Failed to write github cache snapshot:', err) } } diff --git a/src/main/persistence/loading-store/write-scheduling.ts b/src/main/persistence/loading-store/write-scheduling.ts index 0301da34a7e..30019c5c456 100644 --- a/src/main/persistence/loading-store/write-scheduling.ts +++ b/src/main/persistence/loading-store/write-scheduling.ts @@ -9,11 +9,14 @@ type WriteSchedulingOperationsRuntime = Pick< StoreRuntimeState, | 'activeViewPreference' | 'automationListProjectionCache' + | 'dirtyProfileStateDomains' | 'firstPendingSaveAt' | 'pendingWrite' + | 'profileMaintenancePending' | 'quitFlushStarted' | 'writeGeneration' | 'writeTimer' + | 'writesFrozen' > const writeSchedulingOperationsContext = Symbol('WriteSchedulingOperations') @@ -30,36 +33,44 @@ export class WriteSchedulingOperations { } async waitForPendingWrite(): Promise { - await Promise.all([ - this[writeSchedulingOperationsContext].runtime.pendingWrite, - this[writeSchedulingOperationsContext].runtime.activeViewPreference.waitForPendingWrite() - ]) + const { runtime } = this[writeSchedulingOperationsContext] + await Promise.all([runtime.pendingWrite, runtime.activeViewPreference.waitForPendingWrite()]) } } -export function scheduleSave(owner: WriteSchedulingOperations): void { - owner[writeSchedulingOperationsContext].runtime.automationListProjectionCache = null - // Why: once the quit flush has snapshotted, a newly debounced write would fire during - // teardown with nothing awaiting it, and the process can exit mid-rename. The quit - // flush is the last write by construction. - if (owner[writeSchedulingOperationsContext].runtime.quitFlushStarted) { +export function scheduleSave( + owner: WriteSchedulingOperations, + dirtyDomains?: readonly string[] +): void { + const { runtime, writes } = owner[writeSchedulingOperationsContext] + runtime.automationListProjectionCache = null + const trackedDomains = runtime.dirtyProfileStateDomains + if (dirtyDomains === undefined) { + runtime.dirtyProfileStateDomains = null + } else if (trackedDomains !== null) { + for (const domain of dirtyDomains) { + trackedDomains.add(domain) + } + } + // A timer admitted after the final snapshot could outlive the awaited shutdown work. + if (runtime.quitFlushStarted || runtime.profileMaintenancePending) { return } - owner[writeSchedulingOperationsContext].runtime.writeGeneration += 1 - const now = Date.now() - owner[writeSchedulingOperationsContext].runtime.firstPendingSaveAt ??= now - if (owner[writeSchedulingOperationsContext].runtime.writeTimer) { - clearTimeout(owner[writeSchedulingOperationsContext].runtime.writeTimer) + runtime.writeGeneration += 1 + if (runtime.writesFrozen) { + return } - const untilMaxWait = Math.max( - 0, - owner[writeSchedulingOperationsContext].runtime.firstPendingSaveAt + SAVE_MAX_WAIT_MS - now - ) + const now = Date.now() + runtime.firstPendingSaveAt ??= now + if (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) + } + const untilMaxWait = Math.max(0, runtime.firstPendingSaveAt + SAVE_MAX_WAIT_MS - now) const delay = Math.min(SAVE_DEBOUNCE_MS, untilMaxWait) - owner[writeSchedulingOperationsContext].runtime.writeTimer = setTimeout(() => { - owner[writeSchedulingOperationsContext].runtime.writeTimer = null - owner[writeSchedulingOperationsContext].runtime.firstPendingSaveAt = null - void enqueueWrite(owner[writeSchedulingOperationsContext].writes) + runtime.writeTimer = setTimeout(() => { + runtime.writeTimer = null + runtime.firstPendingSaveAt = null + void enqueueWrite(writes, { skipIfClean: true }).catch(() => {}) }, delay) } diff --git a/src/main/persistence/profile-state-cutover-fixture.test.ts b/src/main/persistence/profile-state-cutover-fixture.test.ts new file mode 100644 index 00000000000..311c15ae450 --- /dev/null +++ b/src/main/persistence/profile-state-cutover-fixture.test.ts @@ -0,0 +1,172 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdtempSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { removeTreeSync } from '../../shared/windows-transient-lock-removal' +import { + buildProfileStateCutoverFixture, + canonicalProfileStateJson +} from './profile-state-cutover-fixture' +import { + exportProfileStateJson, + importProfileStateJson +} from './profile-state/profile-state-documents' +import { + openProfileStateDatabase, + profileStateDatabaseFile +} from './profile-state/profile-state-database' +import { Store } from './loading-store/store' +import { + closeTestStores, + createStore, + dataFile, + testState, + writeDataFile +} from '../persistence-test-harness' + +const { trackMock, getCohortAtEmitMock } = vi.hoisted(() => ({ + trackMock: vi.fn(), + getCohortAtEmitMock: vi.fn(() => ({ nth_repo_added: 2 })) +})) + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length) + } +})) + +vi.mock('../telemetry/client', () => ({ track: trackMock })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock })) +vi.mock('../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(() => ({ hosts: [] })), + sshConfigHostsToTargets: vi.fn(() => []) +})) + +describe('profile-state cutover fixture', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-profile-cutover-fixture-')) + }) + + afterEach(async () => { + await closeTestStores() + removeTreeSync(testState.dir) + }) + + it('keeps object insertion order out of semantic comparisons while preserving array order', () => { + expect(canonicalProfileStateJson({ b: 2, a: { d: 4, c: 3 }, rows: ['first', 'second'] })).toBe( + canonicalProfileStateJson({ rows: ['first', 'second'], a: { c: 3, d: 4 }, b: 2 }) + ) + expect(canonicalProfileStateJson({ rows: ['first', 'second'] })).not.toBe( + canonicalProfileStateJson({ rows: ['second', 'first'] }) + ) + expect(canonicalProfileStateJson({ missing: undefined, nullable: null })).toBe( + canonicalProfileStateJson({ nullable: null }) + ) + }) + + it('loads the cross-domain fixture through the legacy Store contract', () => { + const fixture = buildProfileStateCutoverFixture(testState.dir) + writeDataFile(fixture) + + const store = createStore() + store.flushOrThrow() + store.freezeWrites() + + expect(store.getRepos().map((repo) => repo.id)).toEqual(['repo-local', 'repo-remote']) + expect(store.getProjects().map((project) => project.id)).toEqual([ + 'repo:repo-local', + 'repo:repo-remote' + ]) + expect(store.getProjectHostSetups().map((setup) => setup.id)).toEqual([ + 'repo-local', + 'repo-remote' + ]) + expect(store.getWorktreeMeta('repo-local::/fixture/local')).toMatchObject({ + instanceId: 'instance-local', + linkedPR: 42, + comment: 'Preserve this comment' + }) + expect(store.getWorkspaceSession().activeTabId).toBe('tab-local') + expect(store.getWorkspaceSession('ssh:build-host').activeTabId).toBe('tab-remote') + expect(store.listAutomations().map((automation) => automation.id)).toEqual([ + 'automation-fixture' + ]) + expect(store.listAutomationRuns('automation-fixture').map((run) => run.id)).toEqual([ + 'automation-run-fixture' + ]) + expect(store.getSettings().opencodeSessionCookie).toBe('fixture-secret') + + const persisted: unknown = JSON.parse(readFileSync(dataFile(), 'utf-8')) + expect(persisted).toHaveProperty('futureTopLevelExtension', { + keep: 'forward-compatible', + nullable: null + }) + expect(persisted).toHaveProperty( + 'settings.opencodeSessionCookie', + fixture.settings.opencodeSessionCookie + ) + + const reloaded = createStore() + reloaded.freezeWrites() + expect(reloaded.getWorkspaceSession().activeTabId).toBe('tab-local') + expect(reloaded.getWorkspaceSession('ssh:build-host').activeTabId).toBe('tab-remote') + expect(reloaded.listAutomationRuns('automation-fixture')[0]?.outputSnapshot?.content).toBe( + 'fixture output' + ) + expect(reloaded.getSettings().opencodeSessionCookie).toBe('fixture-secret') + }) + + it('imports and exports through the real Store normalization and secret boundaries', () => { + const fixture = buildProfileStateCutoverFixture(testState.dir) + writeDataFile(fixture) + + const source = createStore() + source.flushOrThrow() + const prepared = source.prepareProfileStateExport() + const databaseDirectory = mkdtempSync(join(testState.dir, 'profile-state-db-')) + const opened = openProfileStateDatabase( + profileStateDatabaseFile(databaseDirectory), + 'profile-cutover' + ) + try { + importProfileStateJson(opened.db, prepared.json, { now: () => 456 }) + const exported = exportProfileStateJson(opened.db) + prepared.commit() + + const candidateDirectory = mkdtempSync(join(testState.dir, 'candidate-')) + const candidate = new Store({ + dataFile: join(candidateDirectory, 'orca-data.json'), + serializedState: exported + }) + + expect(candidate.getSettings().opencodeSessionCookie).toBe('fixture-secret') + expect(candidate.getWorkspaceSession().activeTabId).toBe('tab-local') + expect(candidate.getWorkspaceSession('ssh:build-host').activeTabId).toBe('tab-remote') + expect(candidate.listAutomationRuns('automation-fixture')[0]?.outputSnapshot?.content).toBe( + 'fixture output' + ) + expect(candidate.getWorktreeMeta('repo-local::/fixture/local')).toMatchObject({ + linkedPR: 42, + comment: 'Preserve this comment' + }) + + const persisted: unknown = JSON.parse(candidate.prepareProfileStateExport().json) + expect(persisted).toHaveProperty('futureTopLevelExtension', fixture.futureTopLevelExtension) + expect(persisted).toHaveProperty('settings.opencodeSessionCookie') + expect(existsSync(join(candidateDirectory, 'orca-data.json'))).toBe(false) + } finally { + opened.db.close() + } + }) + + it('fails closed for an invalid serialized import instead of reading a fallback file', () => { + writeDataFile(buildProfileStateCutoverFixture(testState.dir)) + + expect(() => new Store({ dataFile: dataFile(), serializedState: '{not valid json' })).toThrow( + 'Failed to load imported profile state' + ) + }) +}) diff --git a/src/main/persistence/profile-state-cutover-fixture.ts b/src/main/persistence/profile-state-cutover-fixture.ts new file mode 100644 index 00000000000..9d3bdf7ea39 --- /dev/null +++ b/src/main/persistence/profile-state-cutover-fixture.ts @@ -0,0 +1,291 @@ +import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../shared/constants' +import type { Automation, AutomationRun } from '../../shared/automations-types' +import type { PersistedState } from '../../shared/persisted-state-types' +import type { Project, ProjectHostSetup } from '../../shared/project-types' +import type { Repo } from '../../shared/repo-types' +import type { TerminalTab, TerminalLayoutSnapshot } from '../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' + +const LOCAL_WORKTREE_ID = 'repo-local::/fixture/local' +const REMOTE_WORKTREE_ID = 'repo-remote::/fixture/remote' +const LOCAL_TAB_ID = 'tab-local' +const REMOTE_TAB_ID = 'tab-remote' +const LOCAL_LEAF_ID = 'leaf-local' +const REMOTE_LEAF_ID = 'leaf-remote' +const REMOTE_HOST_ID = 'ssh:build-host' + +export type ProfileStateCutoverFixture = PersistedState & { + futureTopLevelExtension: { + keep: string + nullable: null + } +} + +function fixtureRepo(overrides: Partial): Repo { + return { + id: 'repo-local', + path: '/fixture/local', + displayName: 'Fixture local', + badgeColor: '#737373', + addedAt: 1, + ...overrides + } +} + +function fixtureProject(overrides: Partial): Project { + return { + id: 'project-fixture', + displayName: 'Fixture project', + badgeColor: '#737373', + sourceRepoIds: ['repo-local', 'repo-remote'], + createdAt: 1, + updatedAt: 2, + ...overrides + } +} + +function fixtureSetup(overrides: Partial): ProjectHostSetup { + return { + id: 'setup-local', + projectId: 'project-fixture', + hostId: 'local', + repoId: 'repo-local', + path: '/fixture/local', + displayName: 'Fixture local', + setupState: 'ready', + setupMethod: 'imported-existing-folder', + createdAt: 1, + updatedAt: 2, + ...overrides + } +} + +function fixtureTab(overrides: Partial): TerminalTab { + return { + id: LOCAL_TAB_ID, + ptyId: 'pty-local', + worktreeId: LOCAL_WORKTREE_ID, + title: 'Fixture terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...overrides + } +} + +function fixtureLayout(leafId: string, ptyId: string): TerminalLayoutSnapshot { + return { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId }, + titlesByLeafId: { [leafId]: 'Fixture pane' } + } +} + +function fixtureSession(args: { + repoId: string + worktreeId: string + tab: TerminalTab + layout: TerminalLayoutSnapshot +}): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + activeRepoId: args.repoId, + activeWorktreeId: args.worktreeId, + activeTabId: args.tab.id, + tabsByWorktree: { [args.worktreeId]: [args.tab] }, + terminalLayoutsByTabId: { [args.tab.id]: args.layout }, + activeTabIdByWorktree: { [args.worktreeId]: args.tab.id }, + activeWorktreeIdsOnShutdown: [args.worktreeId], + browserUrlHistory: [ + { + url: 'https://fixture.test/é😀', + normalizedUrl: 'https://fixture.test/é😀', + title: 'Fixture', + lastVisitedAt: 3, + visitCount: 2 + } + ] + } +} + +function fixtureAutomation(): Automation { + return { + id: 'automation-fixture', + name: 'Fixture automation', + prompt: 'Keep the fixture valid', + precheck: null, + agentId: 'claude', + projectId: 'project-fixture', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'existing', + workspaceId: LOCAL_WORKTREE_ID, + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: 10, + enabled: true, + nextRunAt: 20, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 5, + createdAt: 1, + updatedAt: 2 + } +} + +function fixtureAutomationRun(): AutomationRun { + return { + id: 'automation-run-fixture', + automationId: 'automation-fixture', + title: 'Fixture run', + scheduledFor: 20, + status: 'completed', + trigger: 'manual', + workspaceId: LOCAL_WORKTREE_ID, + workspaceDisplayName: 'Fixture local', + sessionKind: 'terminal', + chatSessionId: null, + terminalSessionId: 'terminal-fixture', + terminalPaneKey: 'pane-fixture', + terminalPtyId: 'pty-local', + outputSnapshot: { + format: 'plain_text', + content: 'fixture output', + capturedAt: 21, + truncated: false + }, + precheckResult: null, + usage: null, + error: null, + startedAt: 20, + dispatchedAt: 20, + createdAt: 20, + runNumber: 1 + } +} + +function fixtureWorktreeMeta(): WorktreeMeta { + const now = Date.now() + return { + instanceId: 'instance-local', + projectId: 'project-fixture', + hostId: 'local', + projectHostSetupId: 'setup-local', + displayName: 'Fixture local', + comment: 'Preserve this comment', + linkedIssue: null, + linkedPR: 42, + linkedLinearIssue: null, + isArchived: false, + isUnread: true, + isPinned: true, + sortOrder: 1, + lastActivityAt: now, + createdAt: now + } +} + +export function buildProfileStateCutoverFixture( + homedir = '/fixture/home' +): ProfileStateCutoverFixture { + const localTab = fixtureTab({}) + const remoteTab = fixtureTab({ + id: REMOTE_TAB_ID, + ptyId: 'pty-remote', + worktreeId: REMOTE_WORKTREE_ID + }) + const localSession = fixtureSession({ + repoId: 'repo-local', + worktreeId: LOCAL_WORKTREE_ID, + tab: localTab, + layout: fixtureLayout(LOCAL_LEAF_ID, 'pty-local') + }) + const remoteSession = fixtureSession({ + repoId: 'repo-remote', + worktreeId: REMOTE_WORKTREE_ID, + tab: remoteTab, + layout: fixtureLayout(REMOTE_LEAF_ID, 'pty-remote') + }) + const state = getDefaultPersistedState(homedir) + state.repos = [ + fixtureRepo({}), + fixtureRepo({ + id: 'repo-remote', + path: '/fixture/remote', + displayName: 'Fixture remote', + connectionId: 'build-host', + executionHostId: REMOTE_HOST_ID + }) + ] + state.projects = [fixtureProject({})] + state.projectHostSetups = [ + fixtureSetup({}), + fixtureSetup({ + id: 'setup-remote', + hostId: REMOTE_HOST_ID, + repoId: 'repo-remote', + path: '/fixture/remote', + displayName: 'Fixture remote', + connectionId: 'build-host', + executionHostId: REMOTE_HOST_ID + }) + ] + state.worktreeMeta = { + [LOCAL_WORKTREE_ID]: fixtureWorktreeMeta(), + [REMOTE_WORKTREE_ID]: { + ...fixtureWorktreeMeta(), + instanceId: 'instance-remote', + hostId: REMOTE_HOST_ID, + projectHostSetupId: 'setup-remote', + displayName: 'Fixture remote' + } + } + state.workspaceSession = localSession + state.workspaceSessionsByHostId = { [REMOTE_HOST_ID]: remoteSession } + state.sshTargets = [ + { + id: 'build-host', + label: 'Build host', + host: 'build.example.test', + port: 22, + username: 'builder', + source: 'manual', + generation: 3 + } + ] + state.automations = [fixtureAutomation()] + state.automationRuns = [fixtureAutomationRun()] + state.settings = { + ...state.settings, + opencodeSessionCookie: Buffer.from('vitest-sealed:fixture-secret', 'utf-8').toString('base64') + } + state.ui = { ...state.ui, activeView: 'tasks', browserKagiSessionLink: null } + return Object.assign(state, { + futureTopLevelExtension: { keep: 'forward-compatible', nullable: null } + }) +} + +function sortForStableJson(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortForStableJson) + } + if (!value || typeof value !== 'object') { + return value + } + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [key, sortForStableJson(child)]) + ) +} + +/** Compares state semantics while ignoring object insertion order and preserving array order. */ +export function canonicalProfileStateJson(state: unknown): string { + return JSON.stringify(sortForStableJson(state)) +} diff --git a/src/main/persistence/profile-state-cutover-soak.test.ts b/src/main/persistence/profile-state-cutover-soak.test.ts new file mode 100644 index 00000000000..2a46f6aedbf --- /dev/null +++ b/src/main/persistence/profile-state-cutover-soak.test.ts @@ -0,0 +1,352 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + buildProfileStateCutoverFixture, + canonicalProfileStateJson +} from './profile-state-cutover-fixture' +import { + readAgentHookSettingsFromProfileState, + updateAgentHookSettingsInProfileState, + type ProfileStateOfflineLocation +} from './profile-state/profile-state-offline-settings' +import { + createProfileStateStore, + type ProfileStateStoreFactoryOptions, + type ProfileStateStoreFactoryResult +} from './profile-state/profile-state-store-factory' +import { profileStateDatabaseFile } from './profile-state/profile-state-database' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8').slice('encrypted:'.length) + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +vi.mock('../../telemetry/client', () => ({ track: () => {} })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const { Store } = await import('./loading-store/store') + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +type MigratedProfile = { + options: ProfileStateStoreFactoryOptions + location: ProfileStateOfflineLocation + first: ProfileStateStoreFactoryResult +} + +function createProfile(profileId: string, theme: string): MigratedProfile { + const directory = mkdtempSync(join(tmpdir(), `orca-profile-state-cutover-${profileId}-`)) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databaseFile = profileStateDatabaseFile(directory) + const fixture = buildProfileStateCutoverFixture(directory) + writeFileSync( + dataFile, + JSON.stringify({ + ...fixture, + settings: { ...fixture.settings, theme }, + // Keep enough unrelated data to make repeated complete-document commits meaningful. + soakExtension: { bytes: 'x'.repeat(96 * 1024), nullable: null } + }) + ) + const options: ProfileStateStoreFactoryOptions = { + dataFile, + databaseFile, + profileId + } + const first = createProfileStateStore(options) + expect(first.backend).toBe('sqlite') + expect(first.migrated).toBe(true) + return { + options, + location: { dataFile, databaseFile, profileId }, + first + } +} + +function removeLegacyJson(profile: MigratedProfile): void { + profile.first.store.freezeWrites() + rmSync(profile.options.dataFile, { force: true }) + expect(existsSync(profile.options.dataFile)).toBe(false) + expect(existsSync(profile.options.databaseFile)).toBe(true) +} + +function reopen(profile: MigratedProfile): ProfileStateStoreFactoryResult { + return createProfileStateStore(profile.options) +} + +function exportJson(store: ProfileStateStoreFactoryResult['store']): unknown { + return JSON.parse(store.prepareProfileStateExport().json) +} + +describe('profile-state candidate cutover soak', () => { + it('keeps the retained JSON export usable for legacy rollback', () => { + const profile = createProfile('rollback-window', 'light') + const retainedJson = readFileSync(profile.options.dataFile) + + profile.first.store.updateSettings({ theme: 'dark', terminalFontSize: 123 }) + profile.first.store.flushOrThrow() + expect(readFileSync(profile.options.dataFile)).toEqual(retainedJson) + + const legacyStore = new Store({ + dataFile: profile.options.dataFile, + serializedState: retainedJson.toString('utf8') + }) + expect(legacyStore.getSettings().theme).toBe('light') + expect(legacyStore.getSettings().terminalFontSize).not.toBe(123) + legacyStore.freezeWrites() + profile.first.store.freezeWrites() + }) + + it('migrates a complete profile, removes JSON, and survives restart/domain/offline churn', () => { + const profile = createProfile('soak-primary', 'light') + const initial = exportJson(profile.first.store) + const folderGroup = profile.first.store.createProjectGroup({ + name: 'Fixture folders', + createdFrom: 'manual', + parentPath: '/fixture/folder', + connectionId: 'build-host' + }) + const folderWorkspace = profile.first.store.createFolderWorkspace({ + projectGroupId: folderGroup.id, + name: 'Remote folder fixture', + folderPath: '/fixture/folder', + connectionId: 'build-host' + }) + const remoteAutomation = profile.first.store.createAutomation({ + name: 'Remote fixture automation', + prompt: 'Keep the remote fixture valid', + agentId: 'claude', + projectId: 'repo-remote', + workspaceMode: 'existing', + workspaceId: 'repo-remote::/fixture/remote', + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: 10, + enabled: true, + missedRunGraceMinutes: 5 + }) + profile.first.store.createAutomationRun(remoteAutomation, 30, 'manual') + profile.first.store.flushOrThrow() + removeLegacyJson(profile) + + for (let round = 0; round < 8; round += 1) { + const reopened = reopen(profile) + const currentSession = reopened.store.getWorkspaceSession() + const currentAutomation = reopened.store.listAutomations()[0] + if (!currentAutomation) { + throw new Error('cutover fixture lost its automation') + } + + reopened.store.updateSettings({ + theme: round % 2 === 0 ? 'dark' : 'light', + terminalFontSize: reopened.store.getSettings().terminalFontSize + 1 + }) + reopened.store.updateUI({ activeView: round % 2 === 0 ? 'tasks' : 'terminal' }) + reopened.store.patchWorkspaceSession({ + browserUrlHistory: [ + ...(currentSession.browserUrlHistory ?? []), + { + url: `https://fixture.test/soak/${round}`, + normalizedUrl: `https://fixture.test/soak/${round}`, + title: `Soak ${round}`, + lastVisitedAt: round + 10, + visitCount: 1 + } + ] + }) + reopened.store.setWorktreeMeta('repo-local::/fixture/local', { + comment: `soak-${round}`, + linkedPR: 100 + round + }) + reopened.store.createAutomationRun(currentAutomation, 100 + round, 'manual') + reopened.store.flushOrThrow() + reopened.store.freezeWrites() + + const restarted = reopen(profile) + expect(restarted.store.getWorkspaceSession().activeTabId).toBe('tab-local') + expect(restarted.store.getWorkspaceSession('ssh:build-host').activeTabId).toBe('tab-remote') + expect(restarted.store.getFolderWorkspace(folderWorkspace.id)).toMatchObject({ + name: 'Remote folder fixture', + folderPath: '/fixture/folder', + connectionId: 'build-host' + }) + expect(restarted.store.getWorktreeMeta('repo-local::/fixture/local')).toMatchObject({ + comment: `soak-${round}`, + linkedPR: 100 + round + }) + expect(restarted.store.listAutomationRuns('automation-fixture').length).toBeGreaterThan( + round + 1 + ) + const persistedRemoteAutomation = restarted.store + .listAutomations() + .find((automation) => automation.id === remoteAutomation.id) + expect(persistedRemoteAutomation).toMatchObject({ + executionTargetType: 'ssh', + executionTargetId: 'build-host', + schedulerOwner: 'ssh_bridge', + workspaceId: 'repo-remote::/fixture/remote' + }) + expect( + restarted.store + .listAutomationRuns(remoteAutomation.id) + .some((run) => run.trigger === 'manual') + ).toBe(true) + restarted.store.freezeWrites() + } + + const beforeOffline = readAgentHookSettingsFromProfileState(profile.location) + const offlineUpdate = updateAgentHookSettingsInProfileState(profile.location, false) + expect(offlineUpdate.settingsPath).toBe(profile.options.databaseFile) + expect(readAgentHookSettingsFromProfileState(profile.location).agentStatusHooksEnabled).toBe( + false + ) + + const afterOffline = reopen(profile) + const persisted = exportJson(afterOffline.store) + expect(afterOffline.store.getSettings().agentStatusHooksEnabled).toBe(false) + expect(afterOffline.store.getSettings().opencodeSessionCookie).toBe('fixture-secret') + expect(afterOffline.store.getWorkspaceSession('ssh:build-host').activeTabId).toBe('tab-remote') + expect(afterOffline.store.getFolderWorkspace(folderWorkspace.id)).toMatchObject({ + folderPath: '/fixture/folder', + connectionId: 'build-host' + }) + expect(afterOffline.store.listAutomationRuns('automation-fixture').length).toBeGreaterThan(8) + expect( + afterOffline.store + .listAutomations() + .find((automation) => automation.id === remoteAutomation.id) + ).toMatchObject({ + executionTargetType: 'ssh', + executionTargetId: 'build-host', + schedulerOwner: 'ssh_bridge' + }) + expect(afterOffline.store.listAutomationRuns(remoteAutomation.id)).toHaveLength(1) + expect(persisted).toHaveProperty('soakExtension', { + bytes: 'x'.repeat(96 * 1024), + nullable: null + }) + expect(beforeOffline.agentStatusHooksEnabled).toBe(true) + expect(existsSync(profile.options.dataFile)).toBe(false) + expect(canonicalProfileStateJson(persisted)).not.toBe(canonicalProfileStateJson(initial)) + afterOffline.store.freezeWrites() + }) + + it('switches between independent SQLite profiles without crossing state', () => { + const first = createProfile('switch-first', 'dark') + const second = createProfile('switch-second', 'light') + removeLegacyJson(first) + removeLegacyJson(second) + const firstInitial = reopen(first) + const firstInitialFontSize = firstInitial.store.getSettings().terminalFontSize + firstInitial.store.freezeWrites() + const secondInitial = reopen(second) + const secondInitialFontSize = secondInitial.store.getSettings().terminalFontSize + secondInitial.store.freezeWrites() + + for (let round = 0; round < 6; round += 1) { + const active = round % 2 === 0 ? first : second + const inactive = active === first ? second : first + const activeStore = reopen(active) + activeStore.store.updateSettings({ + theme: active === first ? 'dark' : 'light', + terminalFontSize: (active === first ? 100 : 200) + round + }) + activeStore.store.flushOrThrow() + activeStore.store.freezeWrites() + + const inactiveStore = reopen(inactive) + expect(inactiveStore.store.getSettings().terminalFontSize).toBe( + round === 0 + ? inactive === first + ? firstInitialFontSize + : secondInitialFontSize + : (inactive === first ? 100 : 200) + round - 1 + ) + expect(inactiveStore.store.getWorkspaceSession().activeTabId).toBe('tab-local') + inactiveStore.store.freezeWrites() + } + + const firstFinal = reopen(first) + const secondFinal = reopen(second) + expect(firstFinal.store.getSettings().terminalFontSize).toBe(104) + expect(secondFinal.store.getSettings().terminalFontSize).toBe(205) + expect(firstFinal.store.getSettings().opencodeSessionCookie).toBe('fixture-secret') + expect(secondFinal.store.getSettings().opencodeSessionCookie).toBe('fixture-secret') + firstFinal.store.freezeWrites() + secondFinal.store.freezeWrites() + }) + + it('allows one stale complete-document writer and rejects the rest', () => { + const profile = createProfile('soak-cas', 'light') + removeLegacyJson(profile) + const staleWriters = Array.from({ length: 7 }, () => reopen(profile)) + + for (const [index, writer] of staleWriters.entries()) { + writer.store.updateSettings({ + theme: index % 2 === 0 ? 'dark' : 'light', + terminalFontSize: 100 + index + }) + } + + let commits = 0 + let conflicts = 0 + for (const writer of staleWriters) { + try { + writer.store.flushOrThrow() + commits += 1 + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + error.code === 'profile-state-revision-conflict' + ) { + conflicts += 1 + } else { + throw error + } + } finally { + writer.store.freezeWrites() + } + } + + expect(commits).toBe(1) + expect(conflicts).toBe(staleWriters.length - 1) + const verifier = reopen(profile) + expect(verifier.store.getSettings().terminalFontSize).toBe(100) + expect(verifier.store.getSettings().opencodeSessionCookie).toBe('fixture-secret') + expect(verifier.store.getWorkspaceSession('ssh:build-host').activeTabId).toBe('tab-remote') + expect(readFileSync(profile.options.databaseFile)).toBeTruthy() + verifier.store.freezeWrites() + }) +}) diff --git a/src/main/persistence/profile-state/legacy-json/README.md b/src/main/persistence/profile-state/legacy-json/README.md new file mode 100644 index 00000000000..3dfa5937815 --- /dev/null +++ b/src/main/persistence/profile-state/legacy-json/README.md @@ -0,0 +1,34 @@ +# Legacy profile JSON + +SQLite is the only ordinary writable profile backend. This folder retains the +`orca-data.json` compatibility boundary; it does not provide a second live store. + +| Code | Purpose | +| ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `profile-state-legacy-import.ts` | Normalize an old profile through a frozen Store, trying the old backup ring if the primary JSON is unreadable. Publication and secret-retention commit stay with the SQLite bootstrap. | +| `profile-state-legacy-backup-path.ts` | Recognize the five old `.bak` files for import and missing-authority checks. No live JSON backup rotation remains. | +| `profile-state-authority-exports.ts` | Export a consistent SQLite snapshot for explicit export, clean shutdown, or profile maintenance. | +| `profile-state-json-acceptance.ts` | Record which exact JSON bytes SQLite accepts, including both sides of an interrupted compatibility export. | +| `profile-state-versioned-export.ts` and `profile-state-export-path.ts` | Publish immutable recovery exports and retain the latest five. | +| `profile-state-recovery.ts` | Restore explicitly selected JSON after archiving the database and recovery evidence. The next capable startup imports it back into SQLite. | + +Old profiles may upgrade directly; no intermediate release is required. A copy +source can still be read as JSON, but every profile changed by a transfer or an +offline settings command must first establish SQLite authority. + +Clean shutdown and maintenance refresh compatibility JSON for older builds. A +crash or failed export can leave an older snapshot. If an older build edits that +file, startup refuses to choose silently between it and SQLite. The explicit +`orca profile state rollback --current-json` command selects those edits and +archives both copies; it does not merge divergent histories. + +Keep import and recovery while old profiles or backups remain supported. Removing +automatic compatibility exports is a separate compatibility decision requiring a +policy for older builds and recovery when a database is missing. Deleting the +ordinary JSON writer does not justify deleting these safeguards. + +SQLite admission, transactions, row serialization, backups, and recovery command +dispatch stay in the parent folder. In particular, JSON payloads in SQLite are +its document representation, not a legacy file backend. Profile-index metadata, +move journals, caches, wire messages, and external-tool settings are also outside +this boundary. diff --git a/src/main/persistence/profile-state/legacy-json/profile-state-authority-exports.ts b/src/main/persistence/profile-state/legacy-json/profile-state-authority-exports.ts new file mode 100644 index 00000000000..0dc6f0d6e90 --- /dev/null +++ b/src/main/persistence/profile-state/legacy-json/profile-state-authority-exports.ts @@ -0,0 +1,89 @@ +import { existsSync, mkdirSync, readFileSync } from 'node:fs' +import { mkdir, readFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { + durableWriteTempPath, + writeFileDurable, + writeFileDurableSync +} from '../../../durable-file-write' +import { + readProfileStateSnapshot, + stageProfileStateJsonCompatibility, + acceptProfileStateJsonCompatibility +} from '../profile-state-documents' +import type Database from '../../../sqlite/sync-database' +import { writeVersionedProfileStateExport } from './profile-state-versioned-export' +import { ProfileStateRevisionConflictError } from '../profile-state-document-validation' + +function readExportSnapshot(db: Database.Database, expectedRevision?: number) { + const snapshot = readProfileStateSnapshot(db) + if (expectedRevision !== undefined && snapshot.revision !== expectedRevision) { + throw new ProfileStateRevisionConflictError(expectedRevision, snapshot.revision) + } + return snapshot +} + +/** Publish a durable JSON rollback/compatibility export without changing authority. */ +export function writeProfileStateAuthorityJsonExport( + db: Database.Database, + targetPath: string, + expectedRevision?: number +): number { + const snapshot = readExportSnapshot(db, expectedRevision) + mkdirSync(dirname(targetPath), { recursive: true }) + writeFileDurableSync(durableWriteTempPath(targetPath), targetPath, snapshot.json) + return snapshot.revision +} + +/** Stage both accepted versions before replacing canonical JSON for an older build. */ +export function writeProfileStateAuthorityCompatibilityExport( + db: Database.Database, + targetPath: string, + expectedRevision?: number +): number | undefined { + const snapshot = readExportSnapshot(db, expectedRevision) + if (snapshot.revision === 0) { + return undefined + } + writeCompatibilityRecoveryExport(targetPath, snapshot) + const retained = existsSync(targetPath) ? readFileSync(targetPath, 'utf8') : undefined + mkdirSync(dirname(targetPath), { recursive: true }) + stageProfileStateJsonCompatibility(db, snapshot.json, snapshot.revision, retained) + writeFileDurableSync(durableWriteTempPath(targetPath), targetPath, snapshot.json) + acceptProfileStateJsonCompatibility(db, snapshot.json, snapshot.revision) + return snapshot.revision +} + +export async function writeProfileStateAuthorityCompatibilityExportAsync( + db: Database.Database, + targetPath: string, + expectedRevision?: number +): Promise { + const snapshot = readExportSnapshot(db, expectedRevision) + if (snapshot.revision === 0) { + return undefined + } + writeCompatibilityRecoveryExport(targetPath, snapshot) + const retained = await readFile(targetPath, 'utf8').catch((error: unknown) => { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return undefined + } + throw error + }) + await mkdir(dirname(targetPath), { recursive: true }) + stageProfileStateJsonCompatibility(db, snapshot.json, snapshot.revision, retained) + await writeFileDurable(durableWriteTempPath(targetPath), targetPath, snapshot.json) + acceptProfileStateJsonCompatibility(db, snapshot.json, snapshot.revision) + return snapshot.revision +} + +function writeCompatibilityRecoveryExport( + dataFile: string, + snapshot: { json: string; revision: number } +): void { + writeVersionedProfileStateExport(dataFile, (path) => { + mkdirSync(dirname(path), { recursive: true }) + writeFileDurableSync(durableWriteTempPath(path), path, snapshot.json) + return snapshot.revision + }) +} diff --git a/src/main/persistence/profile-state/legacy-json/profile-state-export-path.ts b/src/main/persistence/profile-state/legacy-json/profile-state-export-path.ts new file mode 100644 index 00000000000..34332e86fa0 --- /dev/null +++ b/src/main/persistence/profile-state/legacy-json/profile-state-export-path.ts @@ -0,0 +1,36 @@ +import { existsSync, readdirSync } from 'node:fs' +import { basename, dirname, join } from 'node:path' + +/** Return the immutable JSON artifact created when a profile first enters SQLite authority. */ +export function profileStateJsonExportPath(dataFile: string, revision: number): string { + if (!Number.isSafeInteger(revision) || revision < 1) { + throw new Error('Profile state export revision must be a positive safe integer') + } + return `${dataFile}.sqlite-export.${revision}.json` +} + +/** List retained rollback exports newest-first without opening SQLite. */ +export function profileStateJsonExportPaths(dataFile: string): readonly string[] { + const directory = dirname(dataFile) + const prefix = `${basename(dataFile)}.sqlite-export.` + if (!existsSync(directory)) { + return [] + } + return readdirSync(directory) + .flatMap((name) => { + const match = new RegExp(`^${escapeRegExp(prefix)}(\\d+)\\.json$`).exec(name) + if (match === null) { + return [] + } + const revision = Number(match[1]) + return Number.isSafeInteger(revision) && revision > 0 + ? [{ path: join(directory, name), revision }] + : [] + }) + .sort((left, right) => right.revision - left.revision) + .map(({ path }) => path) +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} diff --git a/src/main/persistence/profile-state/legacy-json/profile-state-json-acceptance.ts b/src/main/persistence/profile-state/legacy-json/profile-state-json-acceptance.ts new file mode 100644 index 00000000000..9c297485e0f --- /dev/null +++ b/src/main/persistence/profile-state/legacy-json/profile-state-json-acceptance.ts @@ -0,0 +1,153 @@ +import { withProfileStateWriteTransaction } from '../profile-state-write-transaction' +import type Database from '../../../sqlite/sync-database' +import { PROFILE_STATE_META_LEGACY_JSON_ACCEPTANCE } from '../profile-state-database-schema' +import { + hashProfileStatePayload, + isRecord, + parseProfileStateRoot, + ProfileStateDocumentCorruptionError, + ProfileStateRevisionConflictError +} from '../profile-state-document-validation' +import { readProfileStateRevision } from '../profile-state-revision' + +type ProfileStateJsonAcceptanceVersion = { + jsonHash: string + acceptedRevision: number +} + +export type ProfileStateJsonAcceptance = ProfileStateJsonAcceptanceVersion & { + pending?: ProfileStateJsonAcceptanceVersion +} + +/** Read the source acceptance marker, if this database has one. */ +export function readProfileStateJsonAcceptance( + db: Database.Database +): ProfileStateJsonAcceptance | undefined { + const row = db + .prepare('SELECT value FROM profile_state_meta WHERE key = ?') + .get(PROFILE_STATE_META_LEGACY_JSON_ACCEPTANCE) + if (row === undefined) { + return undefined + } + if (!isRecord(row) || typeof row.value !== 'string') { + throw new ProfileStateDocumentCorruptionError('Legacy JSON acceptance marker is invalid') + } + let parsed: unknown + try { + parsed = JSON.parse(row.value) + } catch { + throw new ProfileStateDocumentCorruptionError('Legacy JSON acceptance marker is invalid') + } + if (!isJsonAcceptanceVersion(parsed)) { + throw new ProfileStateDocumentCorruptionError('Legacy JSON acceptance marker is invalid') + } + let pending: ProfileStateJsonAcceptanceVersion | undefined + if ('pending' in parsed) { + if ( + !isJsonAcceptanceVersion(parsed.pending) || + parsed.pending.acceptedRevision < parsed.acceptedRevision + ) { + throw new ProfileStateDocumentCorruptionError('Legacy JSON acceptance marker is invalid') + } + pending = parsed.pending + } + return { + jsonHash: parsed.jsonHash, + acceptedRevision: parsed.acceptedRevision, + ...(pending === undefined ? {} : { pending }) + } +} + +function isJsonAcceptanceVersion(value: unknown): value is ProfileStateJsonAcceptanceVersion { + return ( + isRecord(value) && + typeof value.jsonHash === 'string' && + /^[a-f0-9]{64}$/.test(value.jsonHash) && + typeof value.acceptedRevision === 'number' && + Number.isSafeInteger(value.acceptedRevision) && + value.acceptedRevision >= 1 + ) +} + +/** Accept either side of the upcoming JSON replacement before publishing it. */ +export function stageProfileStateJsonCompatibility( + db: Database.Database, + rawJson: string, + expectedRevision: number, + retainedJson?: string +): void { + const retainedHash = + retainedJson === undefined ? undefined : hashProfileStatePayload(retainedJson) + updateProfileStateJsonAcceptance(db, rawJson, expectedRevision, (previous, next) => { + if (previous === undefined) { + return next + } + const retained = + retainedHash === undefined || retainedHash === previous.jsonHash + ? previous + : previous.pending?.jsonHash === retainedHash + ? previous.pending + : undefined + if (retained === undefined) { + throw new ProfileStateDocumentCorruptionError('Compatibility JSON changed before export') + } + return { + jsonHash: retained.jsonHash, + acceptedRevision: retained.acceptedRevision, + pending: next + } + }) +} + +/** Promote the staged JSON after publication; failures leave both versions accepted. */ +export function acceptProfileStateJsonCompatibility( + db: Database.Database, + rawJson: string, + expectedRevision: number +): void { + updateProfileStateJsonAcceptance(db, rawJson, expectedRevision, (previous, next) => { + const staged = previous?.pending ?? previous + if (staged?.jsonHash !== next.jsonHash || staged.acceptedRevision !== next.acceptedRevision) { + throw new ProfileStateDocumentCorruptionError('Compatibility JSON export was not staged') + } + return next + }) +} + +function updateProfileStateJsonAcceptance( + db: Database.Database, + rawJson: string, + expectedRevision: number, + update: ( + previous: ProfileStateJsonAcceptance | undefined, + next: ProfileStateJsonAcceptanceVersion + ) => ProfileStateJsonAcceptance +): void { + parseProfileStateRoot(rawJson) + if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) { + throw new ProfileStateDocumentCorruptionError( + 'Compatibility JSON acceptance revision is invalid' + ) + } + return withProfileStateWriteTransaction(db, () => { + const actualRevision = readProfileStateRevision(db) + if (actualRevision !== expectedRevision) { + throw new ProfileStateRevisionConflictError(expectedRevision, actualRevision) + } + const previous = readProfileStateJsonAcceptance(db) + if ( + previous && + (previous.pending?.acceptedRevision ?? previous.acceptedRevision) > actualRevision + ) { + throw new ProfileStateDocumentCorruptionError('Legacy JSON acceptance marker is invalid') + } + const marker = update(previous, { + jsonHash: hashProfileStatePayload(rawJson), + acceptedRevision: expectedRevision + }) + db.prepare( + `INSERT INTO profile_state_meta (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value` + ).run(PROFILE_STATE_META_LEGACY_JSON_ACCEPTANCE, JSON.stringify(marker)) + }) +} diff --git a/src/main/persistence/profile-state/legacy-json/profile-state-legacy-backup-path.ts b/src/main/persistence/profile-state/legacy-json/profile-state-legacy-backup-path.ts new file mode 100644 index 00000000000..ba222b61fd7 --- /dev/null +++ b/src/main/persistence/profile-state/legacy-json/profile-state-legacy-backup-path.ts @@ -0,0 +1,16 @@ +import { existsSync } from 'node:fs' + +export const PROFILE_STATE_LEGACY_BACKUP_COUNT = 5 + +export function profileStateLegacyBackupPath(dataFile: string, index: number): string { + return `${dataFile}.bak.${index}` +} + +export function hasStateBackup(dataFile: string): boolean { + for (let index = 0; index < PROFILE_STATE_LEGACY_BACKUP_COUNT; index += 1) { + if (existsSync(profileStateLegacyBackupPath(dataFile, index))) { + return true + } + } + return false +} diff --git a/src/main/persistence/profile-state/legacy-json/profile-state-legacy-import.ts b/src/main/persistence/profile-state/legacy-json/profile-state-legacy-import.ts new file mode 100644 index 00000000000..9eb5219ee6b --- /dev/null +++ b/src/main/persistence/profile-state/legacy-json/profile-state-legacy-import.ts @@ -0,0 +1,42 @@ +import { existsSync, readFileSync } from 'node:fs' +import { Store } from '../../loading-store/store' +import type { ProfileStateStartupPaneAlias } from '../../loading-store/profile-state-authority' +import { ProfileStateAuthorityBootstrapError } from '../profile-state-recovery-required' +import { + PROFILE_STATE_LEGACY_BACKUP_COUNT, + profileStateLegacyBackupPath +} from './profile-state-legacy-backup-path' + +export function prepareLegacyProfileState(dataFile: string, rawJson: string) { + try { + return prepareLegacySnapshot(dataFile, rawJson) + } catch (error) { + // Import the first usable legacy backup without overwriting the damaged source. + for (let index = 0; index < PROFILE_STATE_LEGACY_BACKUP_COUNT; index += 1) { + const path = profileStateLegacyBackupPath(dataFile, index) + if (!existsSync(path)) { + continue + } + try { + const prepared = prepareLegacySnapshot(dataFile, readFileSync(path, 'utf8')) + console.warn(`[profile-state] Recovered legacy state from ${path}`) + return prepared + } catch { + // A corrupt backup must not prevent trying the remaining legacy ring. + } + } + throw new ProfileStateAuthorityBootstrapError( + `Failed to load imported profile state or its legacy backups: ${dataFile}. ${error instanceof Error ? error.message : String(error)}` + ) + } +} + +function prepareLegacySnapshot(dataFile: string, serializedState: string) { + const unboundPaneAliases: ProfileStateStartupPaneAlias[] = [] + const store = new Store({ + dataFile, + serializedState, + collectUnboundPaneAlias: (entry) => unboundPaneAliases.push(entry) + }) + return { prepared: store.prepareProfileStateExport(), unboundPaneAliases } +} diff --git a/src/main/persistence/profile-state/legacy-json/profile-state-recovery.ts b/src/main/persistence/profile-state/legacy-json/profile-state-recovery.ts new file mode 100644 index 00000000000..c5ee4454298 --- /dev/null +++ b/src/main/persistence/profile-state/legacy-json/profile-state-recovery.ts @@ -0,0 +1,88 @@ +import { profileStateDatabaseFiles } from '../profile-state-storage-classification' +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { dirname } from 'node:path' +import { parseProfileStateRoot } from '../profile-state-document-validation' +import { durableWriteTempPath, writeFileDurableSync } from '../../../durable-file-write' +import { bestEffortFsyncDirectorySync } from '../../../../shared/secure-file' +import { + quarantineProfileStateDatabase, + type ProfileStateDatabaseQuarantine +} from '../profile-state-database-quarantine' +import { profileStateJsonExportPaths } from './profile-state-export-path' +import { profileStateDatabaseBackupFiles } from '../profile-state-backup-path' +import { + assertProfileStateMaintenance, + type ProfileStateMaintenance +} from '../profile-state-access' + +export type ProfileStateJsonRecoveryOptions = { + maintenance: ProfileStateMaintenance + databasePath: string + dataFile: string + exportPath: string + profileId: string + quarantineRoot?: string + reason?: string + beforeRestore?: () => void +} + +export type ProfileStateJsonRecovery = { + quarantine: ProfileStateDatabaseQuarantine + removedDatabaseFiles: readonly string[] +} + +/** Restore an explicitly selected JSON export after preserving a failed SQLite authority. */ +export function restoreProfileStateJsonExport( + options: ProfileStateJsonRecoveryOptions +): ProfileStateJsonRecovery { + assertProfileStateMaintenance(options.maintenance, options) + const rawJson = readRecoveryExport(options.exportPath) + const retainedExports = profileStateJsonExportPaths(options.dataFile) + const retainedBackups = profileStateDatabaseBackupFiles(options.databasePath) + const recoveryFiles = [options.exportPath, ...retainedExports, ...retainedBackups] + if (existsSync(options.dataFile)) { + recoveryFiles.push(options.dataFile) + } + const quarantine = quarantineProfileStateDatabase( + options.databasePath, + options.profileId, + options.quarantineRoot, + options.reason ?? 'profile-state-json-rollback', + recoveryFiles + ) + + // Invalidate authority-dependent caches while the database and recovery exports still exist. + options.beforeRestore?.() + mkdirSync(dirname(options.dataFile), { recursive: true }) + writeFileDurableSync(durableWriteTempPath(options.dataFile), options.dataFile, rawJson) + + const removedDatabaseFiles = profileStateDatabaseFiles(options.databasePath).filter((path) => + existsSync(path) + ) + for (const path of removedDatabaseFiles) { + rmSync(path) + } + // Removing the reserved exports completes the authority transition back to JSON. Keeping one + // would make established startup correctly reject the restored legacy state as a stale mirror. + const retainedArtifacts = [...retainedBackups, ...retainedExports] + for (const path of retainedArtifacts) { + if (path !== options.exportPath) { + rmSync(path) + } + } + // Keep the selected revision retryable until no reserved artifact can block JSON startup. + if (retainedArtifacts.includes(options.exportPath)) { + rmSync(options.exportPath) + } + bestEffortFsyncDirectorySync(dirname(options.databasePath)) + return { quarantine, removedDatabaseFiles } +} + +function readRecoveryExport(exportPath: string): string { + if (exportPath.length === 0 || exportPath.includes('\0')) { + throw new Error('Profile state recovery export path is invalid') + } + const rawJson = readFileSync(exportPath, 'utf8') + parseProfileStateRoot(rawJson) + return rawJson +} diff --git a/src/main/persistence/profile-state/legacy-json/profile-state-versioned-export.test.ts b/src/main/persistence/profile-state/legacy-json/profile-state-versioned-export.test.ts new file mode 100644 index 00000000000..edf3050c976 --- /dev/null +++ b/src/main/persistence/profile-state/legacy-json/profile-state-versioned-export.test.ts @@ -0,0 +1,113 @@ +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { durableWriteTempPath, writeFileDurableSync } from '../../../durable-file-write' +import { profileStateJsonExportPath } from './profile-state-export-path' +import { writeVersionedProfileStateExport } from './profile-state-versioned-export' + +vi.mock('node:fs', async (original) => ({ ...(await original()) })) + +const roots: string[] = [] +afterEach(() => { + vi.restoreAllMocks() + for (const root of roots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +function fixture() { + const root = fs.mkdtempSync(join(tmpdir(), 'orca-versioned-export-')) + roots.push(root) + const dataFile = join(root, 'orca-data.json') + const target = profileStateJsonExportPath(dataFile, 4) + const source = '{"settings":{"theme":"dark"}}' + const write = (revision = 4) => + writeVersionedProfileStateExport(dataFile, (staging) => { + writeFileDurableSync(durableWriteTempPath(staging), staging, source) + return revision + }) + return { root, target, source, write } +} + +describe('immutable versioned profile exports', () => { + it('publishes once and accepts repeated identical exports', () => { + const { root, target, source, write } = fixture() + expect(write()).toBe(4) + expect(write()).toBe(4) + expect(fs.readFileSync(target, 'utf8')).toBe(source) + expect(fs.readdirSync(root)).toEqual([basename(target)]) + }) + + it('retains only five successfully published exports and leaves unrelated entries alone', () => { + const { root, write } = fixture() + for (let revision = 1; revision <= 7; revision++) { + expect(write(revision)).toBe(revision) + } + const reservedDirectory = join(root, 'orca-data.json.sqlite-export.1.json') + fs.mkdirSync(reservedDirectory) + const unrelated = join(root, 'orca-data.json.sqlite-export.notes.json') + fs.writeFileSync(unrelated, 'keep') + write(8) + expect(fs.readdirSync(root).sort()).toEqual([ + 'orca-data.json.sqlite-export.1.json', + ...[4, 5, 6, 7, 8].map((revision) => `orca-data.json.sqlite-export.${revision}.json`), + 'orca-data.json.sqlite-export.notes.json' + ]) + expect(fs.lstatSync(reservedDirectory).isDirectory()).toBe(true) + }) + + it('preserves every recovery point when the next export fails', () => { + const { root, write } = fixture() + for (let revision = 1; revision <= 5; revision++) { + write(revision) + } + const retained = fs.readdirSync(root) + expect(() => + writeVersionedProfileStateExport(join(root, 'orca-data.json'), () => { + throw new Error('disk full') + }) + ).toThrow('disk full') + expect(fs.readdirSync(root)).toEqual(retained) + }) + + it('does not retain an empty profile export', () => { + const { root, write } = fixture() + expect(write(0)).toBeUndefined() + expect(fs.readdirSync(root)).toEqual([]) + }) + + it.each(['identical', 'divergent'] as const)( + 'preserves a concurrently published %s revision', + (kind) => { + const { root, target, source, write } = fixture() + const competing = kind === 'identical' ? source : '{"settings":{"theme":"light"}}' + let raced = false + const publishCompetitor = (path: fs.PathLike) => { + if (!raced && path === target) { + raced = true + fs.writeFileSync(target, competing) + } + } + const rename = fs.renameSync + vi.spyOn(fs, 'renameSync').mockImplementation((from, to) => { + publishCompetitor(to) + rename(from, to) + }) + const link = fs.linkSync + vi.spyOn(fs, 'linkSync').mockImplementation((from, to) => { + publishCompetitor(to) + link(from, to) + }) + + if (kind === 'identical') { + expect(write()).toBe(4) + } else { + expect(write).toThrow('already exists with different content') + } + expect(raced).toBe(true) + expect(fs.readFileSync(target, 'utf8')).toBe(competing) + expect(fs.readdirSync(root)).toEqual([basename(target)]) + } + ) +}) diff --git a/src/main/persistence/profile-state/legacy-json/profile-state-versioned-export.ts b/src/main/persistence/profile-state/legacy-json/profile-state-versioned-export.ts new file mode 100644 index 00000000000..8947fb5c5b8 --- /dev/null +++ b/src/main/persistence/profile-state/legacy-json/profile-state-versioned-export.ts @@ -0,0 +1,52 @@ +import { lstatSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { dirname } from 'node:path' +import { bestEffortFsyncDirectorySync, fsyncFileSync } from '../../../../shared/secure-file' +import { durableWriteTempPath, publishFileDurableSync } from '../../../durable-file-write' +import { + profileStateJsonExportPath, + profileStateJsonExportPaths +} from './profile-state-export-path' + +/** An existing revision must never be replaced with different content. */ +export function writeVersionedProfileStateExport( + dataFile: string, + writeExport: (targetPath: string) => number +): number | undefined { + const stagingPath = durableWriteTempPath(`${dataFile}.sqlite-export.pending`) + try { + const revision = writeExport(stagingPath) + if (revision === 0) { + return undefined + } + const targetPath = profileStateJsonExportPath(dataFile, revision) + mkdirSync(dirname(targetPath), { recursive: true }) + if (!publishFileDurableSync(stagingPath, targetPath)) { + const staged = readFileSync(stagingPath) + const existing = readFileSync(targetPath) + if (!staged.equals(existing)) { + throw new Error( + `Profile state export revision ${revision} already exists with different content` + ) + } + fsyncFileSync(targetPath) + bestEffortFsyncDirectorySync(dirname(targetPath)) + } + pruneProfileStateJsonExports(dataFile) + return revision + } finally { + rmSync(stagingPath, { force: true }) + } +} + +function pruneProfileStateJsonExports(dataFile: string): void { + try { + const regularExports = profileStateJsonExportPaths(dataFile).filter((path) => + lstatSync(path).isFile() + ) + for (const path of regularExports.slice(5)) { + rmSync(path, { force: true }) + } + } catch (error) { + console.warn('[persistence] Failed to prune retained JSON exports:', error) + } +} diff --git a/src/main/persistence/profile-state/profile-state-access-identity.test.ts b/src/main/persistence/profile-state/profile-state-access-identity.test.ts new file mode 100644 index 00000000000..a55e8610444 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-access-identity.test.ts @@ -0,0 +1,101 @@ +import * as fs from 'node:fs' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const native = vi.hoisted(() => ({ + registry: vi.fn(), + creationTime: vi.fn() +})) +vi.mock('../../windows-native-registry', () => ({ + WINDOWS_REG_SZ: 1, + loadWindowsNativeRegistry: () => ({ HK: { LM: 123 }, getRegistryKey: native.registry }) +})) +vi.mock('../../windows/windows-process-table', () => ({ + readWindowsProcessCreationTime: native.creationTime +})) + +vi.mock('node:fs', async (importOriginal) => ({ ...(await importOriginal()) })) + +const platform = Object.getOwnPropertyDescriptor(process, 'platform') + +beforeEach(() => { + vi.resetModules() + native.registry.mockReset() + native.creationTime.mockReset().mockReturnValue(null) +}) + +afterEach(() => { + vi.restoreAllMocks() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } +}) + +it.each([ + [ + 'AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE', + 'win32-machine-guid:aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + ], + ['00000000-0000-0000-0000-000000000000', null], + ['invalid-machine-id', null], + ['', null] +] as const)('validates the Windows machine GUID %j', async (value, expected) => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + native.registry.mockReturnValue({ MACHINEGUID: { type: 1, value } }) + const { profileStateAccessMachineIdentity } = await import('./profile-state-access-identity') + expect(profileStateAccessMachineIdentity()).toBe(expected) + expect(profileStateAccessMachineIdentity()).toBe(expected) + expect(native.registry).toHaveBeenCalledExactlyOnceWith(123, 'SOFTWARE\\Microsoft\\Cryptography') +}) + +it.each([ + null, + {}, + { MachineGuid: { type: 4, value: 123 } }, + { MachineGuid: { type: 1, value: [] } } +])('keeps unavailable Windows machine identity unverifiable', async (values) => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + native.registry.mockReturnValue(values) + const { profileStateAccessMachineIdentity } = await import('./profile-state-access-identity') + expect(profileStateAccessMachineIdentity()).toBeNull() +}) + +it('tolerates a denied registry query', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + native.registry.mockImplementation(() => { + throw new Error('access denied') + }) + const { profileStateAccessMachineIdentity } = await import('./profile-state-access-identity') + expect(profileStateAccessMachineIdentity()).toBeNull() +}) + +it('caches only its own Windows creation time and leaves boot identity unavailable', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + native.creationTime.mockReturnValue(1_700_000_000_000) + const { profileStateAccessProcessIdentity, profileStateAccessBootIdentity } = + await import('./profile-state-access-identity') + expect(profileStateAccessBootIdentity()).toBeNull() + expect(profileStateAccessProcessIdentity(process.pid)).toBe('win32-creation-ms:1700000000000') + native.creationTime.mockReturnValue(1_800_000_000_000) + expect(profileStateAccessProcessIdentity(process.pid)).toBe('win32-creation-ms:1700000000000') + expect(profileStateAccessProcessIdentity(process.pid + 1)).toBe('win32-creation-ms:1800000000000') + native.creationTime.mockReturnValue(null) + expect(profileStateAccessProcessIdentity(process.pid + 1)).toBeNull() + expect(native.creationTime).toHaveBeenCalledTimes(3) +}) + +it.each([ + ['8de277067b3544d4b65c267d0edab928\n', '8de277067b3544d4b65c267d0edab928'], + ['00000000000000000000000000000000', null], + ['uninitialized\n', null], + ['invalid-machine-id', null], + ['', null] +] as const)('validates the Linux machine identity %j', async (contents, expected) => { + vi.resetModules() + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + const read = fs.readFileSync + vi.spyOn(fs, 'readFileSync').mockImplementation((path, options) => + path === '/etc/machine-id' ? contents : read(path, options) + ) + const { profileStateAccessMachineIdentity } = await import('./profile-state-access-identity') + expect(profileStateAccessMachineIdentity()).toBe(expected) +}) diff --git a/src/main/persistence/profile-state/profile-state-access-identity.ts b/src/main/persistence/profile-state/profile-state-access-identity.ts new file mode 100644 index 00000000000..a36f220923b --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-access-identity.ts @@ -0,0 +1,105 @@ +import { readFileSync } from 'node:fs' +import { runProcessSync } from '../../../shared/child-process/run-process' +import { parseLinuxProcStartTicks } from '../../daemon/daemon-process-start-time' +import { getPsProcessIdentity } from '../../daemon/daemon-process-identity-query' +import { loadWindowsNativeRegistry, WINDOWS_REG_SZ } from '../../windows-native-registry' +import { readWindowsProcessCreationTime } from '../../windows/windows-process-table' + +let bootIdentity: string | null | undefined +let machineIdentity: string | null | undefined +let ownProcessIdentity: string | null | undefined + +/** A boot change proves exit only when the record belongs to this machine. */ +export function profileStateAccessMachineIdentity(): string | null { + if (machineIdentity !== undefined) { + return machineIdentity + } + machineIdentity = null + try { + if (process.platform === 'linux') { + const value = readFileSync('/etc/machine-id', 'utf8').trim() + machineIdentity = /^[a-f0-9]{32}$/.test(value) && !/^0+$/.test(value) ? value : null + } else if (process.platform === 'win32') { + const registry = loadWindowsNativeRegistry() + const values = registry.getRegistryKey(registry.HK.LM, 'SOFTWARE\\Microsoft\\Cryptography') + const entry = Object.entries(values ?? {}).find( + ([name]) => name.toLowerCase() === 'machineguid' + )?.[1] + const value = + entry?.type === WINDOWS_REG_SZ && typeof entry.value === 'string' + ? entry.value.trim().toLowerCase() + : '' + machineIdentity = + /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(value) && + value !== '00000000-0000-0000-0000-000000000000' + ? `win32-machine-guid:${value}` + : null + } else if (process.platform === 'darwin') { + const result = runProcessSync({ + program: '/usr/sbin/sysctl', + args: ['-n', 'kern.hostuuid'], + timeoutMs: 1_000, + maxOutputBytes: 1024 + }) + machineIdentity = result.code === 0 ? result.stdout.trim() || null : null + } + } catch { + // Missing machine identity cannot establish ownership across a reboot. + } + return machineIdentity +} + +/** A kernel boot UUID survives hostname changes without conflating machines sharing a profile. */ +export function profileStateAccessBootIdentity(): string | null { + if (bootIdentity !== undefined) { + return bootIdentity + } + bootIdentity = null + try { + if (process.platform === 'linux') { + bootIdentity = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim() || null + } else if (process.platform === 'darwin') { + const result = runProcessSync({ + program: '/usr/sbin/sysctl', + args: ['-n', 'kern.bootsessionuuid'], + timeoutMs: 1_000, + maxOutputBytes: 1024 + }) + bootIdentity = result.code === 0 ? result.stdout.trim() || null : null + } + } catch { + // Unavailable identity leaves the conservative hostname and PID checks in force. + } + return bootIdentity +} + +export function profileStateAccessProcessIdentity(pid: number): string | null { + if (pid !== process.pid) { + return readProcessIdentity(pid) + } + if (ownProcessIdentity === undefined) { + ownProcessIdentity = readProcessIdentity(pid) + } + return ownProcessIdentity +} + +function readProcessIdentity(pid: number): string | null { + if (process.platform === 'win32') { + const startedAtMs = readWindowsProcessCreationTime(pid) + return startedAtMs === null ? null : `win32-creation-ms:${startedAtMs}` + } + if (process.platform === 'linux') { + try { + const ticks = parseLinuxProcStartTicks(readFileSync(`/proc/${pid}/stat`, 'utf8')) + return Number.isSafeInteger(ticks) && ticks >= 0 ? `linux-start-ticks:${ticks}` : null + } catch { + return null + } + } + if (process.platform !== 'darwin') { + return null + } + const startedAtMs = getPsProcessIdentity(pid, { utc: true })?.startedAtMs + // Local wall times are ambiguous during daylight-saving transitions. + return startedAtMs == null ? null : `darwin-utc-start-ms:${startedAtMs}` +} diff --git a/src/main/persistence/profile-state/profile-state-access-owner.ts b/src/main/persistence/profile-state/profile-state-access-owner.ts new file mode 100644 index 00000000000..49d2960c6a3 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-access-owner.ts @@ -0,0 +1,318 @@ +import { randomUUID } from 'node:crypto' +import { + lstatSync, + mkdirSync, + readFileSync, + readlinkSync, + readdirSync, + realpathSync, + rmdirSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { hostname } from 'node:os' +import { dirname, join } from 'node:path' +import { bestEffortFsyncDirectorySync, fsyncFileSync } from '../../../shared/secure-file' +import { renameFileWithWindowsRetry } from '../../codex-accounts/fs-utils' +import { + START_TIME_TOLERANCE_MS, + startTimesWithinTolerance +} from '../../daemon/daemon-process-start-time' +import { + profileStateAccessBootIdentity, + profileStateAccessMachineIdentity, + profileStateAccessProcessIdentity +} from './profile-state-access-identity' + +export class ProfileStateAccessError extends Error { + readonly code = 'profile-state-access-refused' as const + + constructor(message: string) { + super(message) + this.name = 'ProfileStateAccessError' + } +} + +export type ProfileStateAccessPaths = ReturnType + +export function profileStateAccessPaths(userDataPath: string) { + mkdirSync(userDataPath, { recursive: true, mode: 0o700 }) + const root = join(realpathSync(userDataPath), '.profile-state-access') + const paths = { + root, + participants: join(root, 'participants'), + candidates: join(root, 'candidates'), + maintenance: join(root, 'maintenance') + } + for (const path of [root, paths.participants, paths.candidates]) { + mkdirSync(path, { recursive: true, mode: 0o700 }) + } + return paths +} + +export const PROFILE_STATE_ACCESS_TOKEN = /^[a-f0-9-]{36}$/ + +type AccessOwner = { + token: string + pid: number + host: string + platform: string + pidNamespace: string | null + bootIdentity?: string | null + machineIdentity?: string | null + processStartIdentity?: string | null +} + +export function profileStateAccessPidNamespace(): string | null { + if (process.platform !== 'linux') { + return null + } + try { + return readlinkSync('/proc/self/ns/pid') + } catch { + return null + } +} + +function readOwner(path: string): AccessOwner | undefined { + try { + if (!lstatSync(path).isFile()) { + throw new ProfileStateAccessError(`Profile state owner is not a regular file: ${path}`) + } + const owner: unknown = JSON.parse(readFileSync(path, 'utf8')) + if ( + typeof owner === 'object' && + owner !== null && + 'token' in owner && + typeof owner.token === 'string' && + PROFILE_STATE_ACCESS_TOKEN.test(owner.token) && + 'pid' in owner && + typeof owner.pid === 'number' && + Number.isSafeInteger(owner.pid) && + owner.pid > 0 && + 'host' in owner && + typeof owner.host === 'string' && + owner.host.length > 0 && + 'platform' in owner && + typeof owner.platform === 'string' && + 'pidNamespace' in owner && + (owner.pidNamespace === null || typeof owner.pidNamespace === 'string') + ) { + return { + token: owner.token, + pid: owner.pid, + host: owner.host, + platform: owner.platform, + pidNamespace: owner.pidNamespace, + bootIdentity: + 'bootIdentity' in owner && typeof owner.bootIdentity === 'string' + ? owner.bootIdentity + : null, + machineIdentity: + 'machineIdentity' in owner && typeof owner.machineIdentity === 'string' + ? owner.machineIdentity + : null, + processStartIdentity: + 'processStartIdentity' in owner && + typeof owner.processStartIdentity === 'string' && + /^(?:(?:linux-start-ticks|darwin-utc-start-ms|wall-time-ms):\d+|win32-creation-ms:[1-9]\d*)$/.test( + owner.processStartIdentity + ) && + Number.isSafeInteger(Number(owner.processStartIdentity.split(':')[1])) + ? owner.processStartIdentity + : null + } + } + } catch (error) { + if (hasCode(error, 'ENOENT')) { + return undefined + } + throw new ProfileStateAccessError(`Profile state ownership is unverifiable: ${path}`) + } + throw new ProfileStateAccessError(`Profile state ownership is malformed: ${path}`) +} + +function ownerExited(owner: AccessOwner): boolean { + const currentBoot = profileStateAccessBootIdentity() + const currentMachine = profileStateAccessMachineIdentity() + const sameBoot = Boolean(owner.bootIdentity && owner.bootIdentity === currentBoot) + const sameMachine = Boolean(owner.machineIdentity && owner.machineIdentity === currentMachine) + const sameHost = owner.host === hostname() + // Windows has no boot UUID to verify a renamed host. + if ( + (process.platform === 'win32' && !sameHost) || + (!sameBoot && !sameHost) || + owner.platform !== process.platform || + (!sameBoot && owner.machineIdentity && currentMachine && !sameMachine) + ) { + return false + } + if ( + sameHost && + sameMachine && + owner.bootIdentity && + currentBoot && + owner.bootIdentity !== currentBoot + ) { + return true + } + // Windows/WSL and Linux PID namespaces cannot establish each other's process absence. + if ( + process.platform === 'linux' && + (owner.pidNamespace === null || owner.pidNamespace !== profileStateAccessPidNamespace()) + ) { + return false + } + try { + process.kill(owner.pid, 0) + } catch (error) { + return hasCode(error, 'ESRCH') + } + const recordedStart = owner.processStartIdentity + // GetProcessTimes records an absolute creation time, so it also detects reuse after reboot. + const canCompareStart = sameBoot || (process.platform === 'win32' && sameMachine && sameHost) + const actualStart = + !canCompareStart || recordedStart == null ? null : profileStateAccessProcessIdentity(owner.pid) + return ( + actualStart !== null && + recordedStart != null && + actualStart.split(':')[0] === recordedStart.split(':')[0] && + (actualStart.startsWith('darwin-utc-start-ms:') + ? !startTimesWithinTolerance( + Number(actualStart.split(':')[1]), + Number(recordedStart.split(':')[1]), + START_TIME_TOLERANCE_MS + ) + : actualStart !== recordedStart) + ) +} + +export function hasCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code +} + +export function removeOwnerEntry(path: string): void { + try { + unlinkSync(path) + } catch (error) { + if (!hasCode(error, 'ENOENT')) { + throw error + } + } +} + +function removeEmptyOwnerDirectory(path: string): void { + try { + rmdirSync(path) + } catch (error) { + if (!['ENOENT', 'ENOTEMPTY', 'EEXIST', 'EBUSY'].some((code) => hasCode(error, code))) { + throw error + } + } +} + +/** Only remove immutable entries whose owner is positively known to have exited. */ +export function reclaimExitedOwner(path: string): void { + let entries: string[] + try { + if (!lstatSync(path).isDirectory()) { + throw new ProfileStateAccessError(`Profile state owner is not a directory: ${path}`) + } + entries = readdirSync(path) + } catch (error) { + if (hasCode(error, 'ENOENT')) { + return + } + throw error + } + for (const entry of entries) { + const token = entry.endsWith('.owner') ? entry.slice(0, -6) : '' + if (!PROFILE_STATE_ACCESS_TOKEN.test(token)) { + throw new ProfileStateAccessError(`Profile state ownership is unverifiable: ${path}`) + } + const owner = readOwner(join(path, entry)) + if (owner === undefined) { + continue + } + if (owner.token !== token || !ownerExited(owner)) { + throw new ProfileStateAccessError( + `Profile state is in use or its owner is unverifiable: ${path}. Stop Orca and orcad on every host using this profile, then retry. If this remains, verify PID ${owner.pid} on ${owner.host} has exited before removing its owner entry ${join(path, entry)}.` + ) + } + removeOwnerEntry(join(path, entry)) + } + // A replacement owner keeps the directory nonempty, even if our observation is stale. + removeEmptyOwnerDirectory(path) +} + +export function publishAccessOwner(paths: ProfileStateAccessPaths, exclusive: boolean) { + const token = randomUUID() + const candidate = join(paths.candidates, token) + const target = exclusive ? paths.maintenance : join(paths.participants, token) + const entry = `${token}.owner` + mkdirSync(candidate, { mode: 0o700 }) + let published = false + try { + writeFileSync( + join(candidate, entry), + JSON.stringify({ + token, + pid: process.pid, + host: hostname(), + platform: process.platform, + pidNamespace: profileStateAccessPidNamespace(), + bootIdentity: profileStateAccessBootIdentity(), + machineIdentity: profileStateAccessMachineIdentity(), + processStartIdentity: profileStateAccessProcessIdentity(process.pid) + }), + { + flag: 'wx', + mode: 0o600 + } + ) + fsyncFileSync(join(candidate, entry)) + bestEffortFsyncDirectorySync(candidate) + for (let attempt = 0; ; attempt += 1) { + try { + renameFileWithWindowsRetry(candidate, target) + published = true + break + } catch (error) { + if (!exclusive || attempt >= 2) { + throw error + } + reclaimExitedOwner(target) + } + } + bestEffortFsyncDirectorySync(dirname(target)) + bestEffortFsyncDirectorySync(paths.candidates) + } catch (error) { + if (published) { + removeOwnerEntry(join(target, entry)) + removeEmptyOwnerDirectory(target) + } + throw error + } finally { + if (!published) { + removeOwnerEntry(join(candidate, entry)) + removeEmptyOwnerDirectory(candidate) + } + } + let released = false + return { + token, + assertActive(): void { + if (released || readOwner(join(target, entry))?.token !== token) { + throw new ProfileStateAccessError('Profile state access has already been released') + } + }, + release(): void { + if (released) { + return + } + removeOwnerEntry(join(target, entry)) + removeEmptyOwnerDirectory(target) + released = true + } + } +} diff --git a/src/main/persistence/profile-state/profile-state-access-process.test.ts b/src/main/persistence/profile-state/profile-state-access-process.test.ts new file mode 100644 index 00000000000..eef48007bf1 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-access-process.test.ts @@ -0,0 +1,150 @@ +import { once } from 'node:events' +import { mkdtempSync, readdirSync, rmSync, utimesSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { buildSync } from 'esbuild' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { spawnProcess } from '../../../shared/child-process/run-process' +import { + acquireProfileStateMaintenance, + acquireProfileStateRuntimeAdmission +} from './profile-state-access' +import { profileStateAccessPaths } from './profile-state-access-owner' + +const fixture = mkdtempSync(join(tmpdir(), 'orca-state-access-process-')) +const bundle = join(fixture, 'access.cjs') +const children = new Set>() + +beforeAll(() => { + buildSync({ + entryPoints: [resolve(__dirname, 'profile-state-access.ts')], + outfile: bundle, + bundle: true, + platform: 'node', + format: 'cjs', + packages: 'external' + }) +}) + +afterEach(async () => { + await Promise.all([...children].map(stopChild)) +}) + +afterAll(() => rmSync(fixture, { recursive: true, force: true })) + +async function stopChild(child: ReturnType): Promise { + children.delete(child) + if (child.exitCode !== null || child.signalCode !== null) { + return + } + const closed = once(child, 'close') + child.kill('SIGKILL') + await closed +} + +const CHILD_SOURCE = ` +const fs = require('node:fs') +const [root, bundle, mode] = process.argv.slice(1) +const rename = fs.renameSync +if (mode === 'candidate' || mode === 'published') { + fs.renameSync = (from, to) => { + if (mode === 'candidate' && String(to).endsWith('maintenance')) { + fs.writeSync(1, 'barrier\\n') + fs.readSync(0, Buffer.alloc(1), 0, 1) + } + rename(from, to) + if (mode === 'published' && String(to).includes('participants')) { + fs.writeSync(1, 'barrier\\n') + fs.readSync(0, Buffer.alloc(1), 0, 1) + } + } +} +const access = require(bundle) +const owner = mode === 'runtime' || mode === 'published' + ? access.acquireProfileStateRuntimeAdmission(root) + : access.acquireProfileStateMaintenance(root) +fs.writeSync(1, 'ready\\n') +process.stdin.resume() +` + +async function startChild(root: string, mode: string): Promise> { + const child = spawnProcess({ + program: process.execPath, + args: ['-e', CHILD_SOURCE, root, bundle, mode], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + }) + children.add(child) + let stderr = '' + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString() + }) + await new Promise((resolveReady, reject) => { + let stdout = '' + const onData = (chunk: Buffer) => { + stdout += chunk.toString() + if (stdout.includes('ready\n') || stdout.includes('barrier\n')) { + cleanup() + resolveReady() + } + } + const onExit = () => { + cleanup() + reject(new Error(`Owner child exited before its barrier: ${stderr}`)) + } + const onError = (error: Error) => { + cleanup() + reject(error) + } + const cleanup = () => { + child.stdout.off('data', onData) + child.off('exit', onExit) + child.off('error', onError) + } + child.stdout.on('data', onData) + child.once('exit', onExit) + child.once('error', onError) + }) + return child +} + +describe('profile state owners across actual process death', () => { + it('excludes recovery while a runtime lives and reclaims its registration after SIGKILL', async () => { + const root = join(fixture, 'runtime') + const child = await startChild(root, 'runtime') + expect(() => acquireProfileStateMaintenance(root)).toThrow('in use') + await stopChild(child) + const maintenance = acquireProfileStateMaintenance(root) + expect(readdirSync(profileStateAccessPaths(root).participants)).toEqual([]) + maintenance.release() + }) + + it('never steals a live maintenance owner with arbitrarily old timestamps', async () => { + const root = join(fixture, 'maintenance') + const child = await startChild(root, 'maintenance') + const gate = profileStateAccessPaths(root).maintenance + for (const file of readdirSync(gate)) { + utimesSync(join(gate, file), 0, 0) + } + utimesSync(gate, 0, 0) + expect(() => acquireProfileStateRuntimeAdmission(root)).toThrow('in use') + expect(() => acquireProfileStateMaintenance(root)).toThrow('in use') + await stopChild(child) + acquireProfileStateRuntimeAdmission(root).release() + }) + + it('treats a crash before complete owner publication as an inert candidate', async () => { + const root = join(fixture, 'candidate') + const child = await startChild(root, 'candidate') + acquireProfileStateRuntimeAdmission(root).release() + await stopChild(child) + acquireProfileStateMaintenance(root).release() + }) + + it('excludes recovery after participant publication even before runtime admission finishes', async () => { + const root = join(fixture, 'published') + const child = await startChild(root, 'published') + expect(() => acquireProfileStateMaintenance(root)).toThrow('in use') + await stopChild(child) + acquireProfileStateMaintenance(root).release() + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-access-windows-native.win32.test.ts b/src/main/persistence/profile-state/profile-state-access-windows-native.win32.test.ts new file mode 100644 index 00000000000..963bd5d48b7 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-access-windows-native.win32.test.ts @@ -0,0 +1,56 @@ +import { randomUUID } from 'node:crypto' +import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { hostname, tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { readWindowsProcessCreationTime } from '../../windows/windows-process-table' +import { + acquireProfileStateMaintenance, + acquireProfileStateRuntimeAdmission +} from './profile-state-access' +import { profileStateAccessMachineIdentity } from './profile-state-access-identity' +import { profileStateAccessPaths } from './profile-state-access-owner' +import { removeTreeSync } from '../../../shared/windows-transient-lock-removal' + +it.runIf(process.platform === 'win32')( + 'reclaims a previous Windows PID incarnation and retains the live owner using native identities', + () => { + const startedAt = readWindowsProcessCreationTime(process.pid) + const machine = profileStateAccessMachineIdentity() + expect(startedAt).toBeGreaterThan(0) + expect(machine).toMatch(/^win32-machine-guid:/) + if (startedAt === null) { + throw new Error('Native process creation-time capability is required') + } + const root = mkdtempSync(join(tmpdir(), 'orca-profile-owner-native-')) + try { + const maintenance = profileStateAccessPaths(root).maintenance + mkdirSync(maintenance) + const token = randomUUID() + const entry = join(maintenance, `${token}.owner`) + writeFileSync( + entry, + JSON.stringify({ + token, + pid: process.pid, + host: hostname(), + platform: 'win32', + pidNamespace: null, + bootIdentity: null, + machineIdentity: machine, + processStartIdentity: `win32-creation-ms:${startedAt - 1}` + }) + ) + const admission = acquireProfileStateRuntimeAdmission(root) + try { + expect(existsSync(entry)).toBe(false) + expect(() => acquireProfileStateMaintenance(root)).toThrow('unverifiable') + } finally { + admission.release() + } + acquireProfileStateMaintenance(root).release() + } finally { + removeTreeSync(root) + } + } +) diff --git a/src/main/persistence/profile-state/profile-state-access-windows.test.ts b/src/main/persistence/profile-state/profile-state-access-windows.test.ts new file mode 100644 index 00000000000..02d210523e0 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-access-windows.test.ts @@ -0,0 +1,228 @@ +import { randomUUID } from 'node:crypto' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' +import { hostname, tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { + acquireProfileStateMaintenance, + acquireProfileStateRuntimeAdmission +} from './profile-state-access' +import { profileStateAccessPaths } from './profile-state-access-owner' + +const identity = vi.hoisted(() => ({ + boot: vi.fn(), + machine: vi.fn(), + process: vi.fn() +})) +vi.mock('./profile-state-access-identity', () => ({ + profileStateAccessBootIdentity: identity.boot, + profileStateAccessMachineIdentity: identity.machine, + profileStateAccessProcessIdentity: identity.process +})) + +const platform = Object.getOwnPropertyDescriptor(process, 'platform') +let root: string + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-state-access-windows-')) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + identity.boot.mockReset().mockReturnValue(null) + identity.machine.mockReset().mockReturnValue('win32-machine-guid:this-machine') + identity.process.mockReset().mockReturnValue('win32-creation-ms:2000') + vi.spyOn(process, 'kill').mockReturnValue(true) +}) + +afterEach(() => { + vi.restoreAllMocks() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + rmSync(root, { recursive: true, force: true }) +}) + +function writeOwner(overrides: Record = {}, exclusive = true): string { + const paths = profileStateAccessPaths(root) + const token = randomUUID() + const directory = exclusive ? paths.maintenance : join(paths.participants, token) + mkdirSync(directory) + const path = join(directory, `${token}.owner`) + writeFileSync( + path, + JSON.stringify({ + token, + pid: 12345, + host: hostname(), + platform: 'win32', + pidNamespace: null, + bootIdentity: null, + machineIdentity: 'win32-machine-guid:this-machine', + processStartIdentity: 'win32-creation-ms:1000', + ...overrides + }) + ) + return path +} + +it('records the machine and native creation time before publishing a runtime admission', () => { + const admission = acquireProfileStateRuntimeAdmission(root) + const participants = profileStateAccessPaths(root).participants + const token = readdirSync(participants)[0] + expect( + JSON.parse(readFileSync(join(participants, token, `${token}.owner`), 'utf8')) + ).toMatchObject({ + pid: process.pid, + platform: 'win32', + machineIdentity: 'win32-machine-guid:this-machine', + processStartIdentity: 'win32-creation-ms:2000', + bootIdentity: null + }) + admission.release() +}) + +it.each([true, false])( + 'reclaims a reused PID on the same machine without a boot UUID: maintenance=%s', + (exclusive) => { + const path = writeOwner({}, exclusive) + acquireProfileStateMaintenance(root).release() + expect(existsSync(path)).toBe(false) + expect(identity.process).toHaveBeenCalledWith(12345) + } +) + +it('lets runtime startup recover a stale maintenance owner', () => { + const path = writeOwner() + acquireProfileStateRuntimeAdmission(root).release() + expect(existsSync(path)).toBe(false) +}) + +it('uses exact creation times even for reuse within the POSIX timestamp tolerance', () => { + const path = writeOwner({ processStartIdentity: 'win32-creation-ms:1999' }) + acquireProfileStateMaintenance(root).release() + expect(existsSync(path)).toBe(false) +}) + +it('keeps an unchanged process alive across wall-clock changes', () => { + const path = writeOwner({ processStartIdentity: 'win32-creation-ms:2000' }) + vi.spyOn(Date, 'now').mockReturnValue(9_000_000) + expect(() => acquireProfileStateMaintenance(root)).toThrow('unverifiable') + expect(existsSync(path)).toBe(true) +}) + +it('can reclaim after reboot using the stored absolute creation time', () => { + const path = writeOwner({ processStartIdentity: 'win32-creation-ms:1700000000000' }) + identity.process.mockReturnValue('win32-creation-ms:1800000000000') + acquireProfileStateMaintenance(root).release() + expect(existsSync(path)).toBe(false) +}) + +it.each([ + { host: 'previous-hostname' }, + { machineIdentity: 'win32-machine-guid:another-machine' }, + { platform: 'linux' } +])( + 'keeps another or unverifiable execution host even when the PID is absent locally: %j', + (record) => { + const path = writeOwner(record) + vi.mocked(process.kill).mockImplementation(() => { + throw Object.assign(new Error('absent'), { code: 'ESRCH' }) + }) + expect(() => acquireProfileStateMaintenance(root)).toThrow('unverifiable') + expect(process.kill).not.toHaveBeenCalled() + expect(existsSync(path)).toBe(true) + } +) + +it('cannot identify another host from a cloned machine GUID and a renamed hostname', () => { + const path = writeOwner({ host: 'other-host-with-cloned-guid' }) + expect(() => acquireProfileStateMaintenance(root)).toThrow('unverifiable') + expect(process.kill).not.toHaveBeenCalled() + expect(existsSync(path)).toBe(true) +}) + +it('does not infer PID reuse from an unavailable local machine identity', () => { + const path = writeOwner() + identity.machine.mockReturnValue(null) + expect(() => acquireProfileStateMaintenance(root)).toThrow('unverifiable') + expect(process.kill).toHaveBeenCalledWith(12345, 0) + expect(identity.process).not.toHaveBeenCalledWith(12345) + expect(existsSync(path)).toBe(true) +}) + +it.each([null, undefined])( + 'reclaims a legacy same-host owner with an absent PID and machine identity %s', + (machineIdentity) => { + const path = writeOwner({ machineIdentity }) + vi.mocked(process.kill).mockImplementation(() => { + throw Object.assign(new Error('absent'), { code: 'ESRCH' }) + }) + acquireProfileStateMaintenance(root).release() + expect(existsSync(path)).toBe(false) + } +) + +it('reclaims an absent same-host PID when the reader cannot load machine identity', () => { + const path = writeOwner() + identity.machine.mockReturnValue(null) + vi.mocked(process.kill).mockImplementation(() => { + throw Object.assign(new Error('absent'), { code: 'ESRCH' }) + }) + acquireProfileStateMaintenance(root).release() + expect(existsSync(path)).toBe(false) +}) + +it.each([null, undefined])( + 'does not compare creation times for a legacy live PID without machine identity %s', + (machineIdentity) => { + const path = writeOwner({ machineIdentity }) + expect(() => acquireProfileStateMaintenance(root)).toThrow('unverifiable') + expect(identity.process).not.toHaveBeenCalledWith(12345) + expect(existsSync(path)).toBe(true) + } +) + +it.each([ + undefined, + null, + 'wall-time-ms:1000', + 'win32-creation-ms:0', + 'win32-creation-ms:02000', + 'win32-creation-ms:invalid', + 'win32-creation-ms:99999999999999999' +])('keeps legacy, incompatible or malformed creation identities: %s', (processStartIdentity) => { + const path = writeOwner({ processStartIdentity }) + expect(() => acquireProfileStateMaintenance(root)).toThrow('unverifiable') + expect(existsSync(path)).toBe(true) +}) + +it('does not infer PID reuse when the native identity getter is unavailable', () => { + const path = writeOwner() + identity.process.mockReturnValue(null) + expect(() => acquireProfileStateMaintenance(root)).toThrow('unverifiable') + expect(existsSync(path)).toBe(true) +}) + +it.each(['EPERM', 'EACCES', 'EINVAL'])('retains owners on signal query failure %s', (code) => { + const path = writeOwner() + vi.mocked(process.kill).mockImplementation(() => { + throw Object.assign(new Error(code), { code }) + }) + expect(() => acquireProfileStateMaintenance(root)).toThrow('unverifiable') + expect(existsSync(path)).toBe(true) +}) + +it('reclaims a positively absent PID from the same machine even without a start timestamp', () => { + const path = writeOwner({ processStartIdentity: null }) + vi.mocked(process.kill).mockImplementation(() => { + throw Object.assign(new Error('absent'), { code: 'ESRCH' }) + }) + acquireProfileStateMaintenance(root).release() + expect(existsSync(path)).toBe(false) +}) diff --git a/src/main/persistence/profile-state/profile-state-access.test.ts b/src/main/persistence/profile-state/profile-state-access.test.ts new file mode 100644 index 00000000000..b551a44f053 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-access.test.ts @@ -0,0 +1,531 @@ +import * as fs from 'node:fs' +import { randomUUID } from 'node:crypto' +import { tmpdir, hostname } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + acquireProfileStateMaintenance, + acquireProfileStateRuntimeAdmission, + assertProfileStateMaintenance, + type ProfileStateMaintenance +} from './profile-state-access' +import { profileStateAccessPaths, reclaimExitedOwner } from './profile-state-access-owner' +import * as identity from './profile-state-access-identity' +import * as processStart from '../../daemon/daemon-process-start-time' + +vi.mock('node:fs', async (importOriginal) => ({ ...(await importOriginal()) })) +vi.mock('../../daemon/daemon-process-start-time', async (importOriginal) => ({ + ...(await importOriginal()) +})) +vi.mock('./profile-state-access-identity', async (importOriginal) => ({ + ...(await importOriginal()) +})) + +const roots: string[] = [] +function root(): string { + const path = fs.mkdtempSync(join(tmpdir(), 'orca-state-access-')) + roots.push(path) + return path +} + +afterEach(() => { + vi.restoreAllMocks() + for (const path of roots.splice(0)) { + fs.rmSync(path, { recursive: true, force: true }) + } +}) + +describe('profile state admission and maintenance', () => { + it.skipIf(process.platform === 'win32')( + 'releases its published owner when directory sync fails', + () => { + const path = root() + const syncFile = fs.fsyncSync + let syncCount = 0 + const sync = vi.spyOn(fs, 'fsyncSync').mockImplementation((fd) => { + if (++syncCount === 3) { + throw new Error('directory sync failed') + } + syncFile(fd) + }) + expect(() => acquireProfileStateRuntimeAdmission(path)).toThrow('directory sync failed') + const paths = profileStateAccessPaths(path) + expect(fs.readdirSync(paths.participants)).toEqual([]) + expect(fs.readdirSync(paths.candidates)).toEqual([]) + sync.mockRestore() + acquireProfileStateMaintenance(path).release() + } + ) + + it('does not publish an owner whose contents could not be synced', () => { + const path = root() + const sync = vi.spyOn(fs, 'fsyncSync').mockImplementationOnce(() => { + throw new Error('owner sync failed') + }) + expect(() => acquireProfileStateRuntimeAdmission(path)).toThrow('owner sync failed') + const paths = profileStateAccessPaths(path) + expect(fs.readdirSync(paths.participants)).toEqual([]) + expect(fs.readdirSync(paths.candidates)).toEqual([]) + sync.mockRestore() + acquireProfileStateMaintenance(path).release() + }) + + it('allows concurrent normal writers and refuses maintenance until every admission releases', () => { + const path = root() + const first = acquireProfileStateRuntimeAdmission(path) + const second = acquireProfileStateRuntimeAdmission(path) + expect(() => acquireProfileStateMaintenance(path)).toThrow('in use') + first.release() + expect(() => acquireProfileStateMaintenance(path)).toThrow('in use') + second.release() + const maintenance = acquireProfileStateMaintenance(path) + expect(() => acquireProfileStateRuntimeAdmission(path)).toThrow('in use') + expect(() => acquireProfileStateMaintenance(path)).toThrow('in use') + maintenance.release() + acquireProfileStateRuntimeAdmission(path).release() + }) + + it('refuses a runtime publishing after maintenance has acquired and scanned', () => { + const path = root() + const rename = fs.renameSync + let maintenance: ProfileStateMaintenance | undefined + vi.spyOn(fs, 'renameSync').mockImplementation((from, to) => { + if (String(to).includes('participants')) { + maintenance = acquireProfileStateMaintenance(path) + } + rename(from, to) + }) + expect(() => acquireProfileStateRuntimeAdmission(path)).toThrow('in use') + expect(maintenance).toBeDefined() + maintenance?.release() + expect(fs.readdirSync(profileStateAccessPaths(path).participants)).toEqual([]) + }) + + it('refuses maintenance when runtime publication precedes its final admission check', () => { + const path = root() + const rename = fs.renameSync + let refused = false + vi.spyOn(fs, 'renameSync').mockImplementation((from, to) => { + rename(from, to) + if (String(to).includes('participants')) { + expect(() => acquireProfileStateMaintenance(path)).toThrow('in use') + refused = true + } + }) + const admission = acquireProfileStateRuntimeAdmission(path) + expect(refused).toBe(true) + admission.release() + }) + + it('rejects released and fabricated maintenance owners and binds valid owners to exact profile paths', () => { + const path = root() + const other = root() + const profileId = 'profile-test' + const profileDir = join(path, 'profiles', profileId) + fs.mkdirSync(profileDir, { recursive: true }) + const files = { + profileId, + dataFile: join(profileDir, 'orca-data.json'), + databasePath: join(profileDir, 'profile-state.db') + } + const owner = acquireProfileStateMaintenance(path) + assertProfileStateMaintenance(owner, files) + expect(() => assertProfileStateMaintenance({ ...owner }, files)).toThrow('acquired') + const wrong = acquireProfileStateMaintenance(other) + expect(() => assertProfileStateMaintenance(wrong, files)).toThrow('paths') + expect(() => + assertProfileStateMaintenance(owner, { ...files, profileId: '../escape' }) + ).toThrow('acquired') + owner.release() + expect(() => assertProfileStateMaintenance(owner, files)).toThrow('released') + wrong.release() + }) + + it.skipIf(process.platform === 'win32')( + 'refuses symlinked profile directories outside the protected root', + () => { + const path = root() + const outside = root() + fs.mkdirSync(join(path, 'profiles')) + fs.symlinkSync(outside, join(path, 'profiles', 'escaped')) + const owner = acquireProfileStateMaintenance(path) + expect(() => + assertProfileStateMaintenance(owner, { + profileId: 'escaped', + dataFile: join(outside, 'orca-data.json'), + databasePath: join(outside, 'profile-state.db') + }) + ).toThrow('paths') + owner.release() + } + ) + + it.each(['', '../outside', 'x'.repeat(129)])( + 'rejects invalid recovery profile IDs: %s', + (profileId) => { + const path = root() + const owner = acquireProfileStateMaintenance(path) + expect(() => owner.assertProfile(profileId, path, path)).toThrow('acquired') + owner.release() + acquireProfileStateRuntimeAdmission(path).release() + } + ) +}) + +function staleGate( + path: string, + pid = 12345, + host = hostname(), + extra: { + bootIdentity?: string + machineIdentity?: string + startedAtMs?: number + processStartIdentity?: string + } = {} +): string { + const gate = profileStateAccessPaths(path).maintenance + fs.mkdirSync(gate) + const token = randomUUID() + const record = join(gate, `${token}.owner`) + fs.writeFileSync( + record, + JSON.stringify({ + token, + pid, + host, + platform: process.platform, + pidNamespace: process.platform === 'linux' ? fs.readlinkSync('/proc/self/ns/pid') : null, + machineIdentity: identity.profileStateAccessMachineIdentity(), + ...extra + }) + ) + return record +} + +describe('profile state owner reclamation', () => { + it('reclaims a local owner from a previous boot even when its PID is now live', () => { + const path = root() + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('current-boot') + vi.spyOn(identity, 'profileStateAccessMachineIdentity').mockReturnValue('same-machine') + const owner = staleGate(path, process.pid, hostname(), { + bootIdentity: 'previous-boot', + machineIdentity: 'same-machine' + }) + const kill = vi.spyOn(process, 'kill') + acquireProfileStateMaintenance(path).release() + expect(kill).not.toHaveBeenCalled() + expect(fs.existsSync(owner)).toBe(false) + }) + + it('keeps a differently booted machine with the same hostname unverifiable', () => { + const path = root() + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('current-boot') + vi.spyOn(identity, 'profileStateAccessMachineIdentity').mockReturnValue('this-machine') + const owner = staleGate(path, process.pid, hostname(), { + bootIdentity: 'another-boot', + machineIdentity: 'another-machine' + }) + const kill = vi.spyOn(process, 'kill') + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + expect(kill).not.toHaveBeenCalled() + expect(fs.existsSync(owner)).toBe(true) + }) + + it.each(['current-boot', null])( + 'does not reclaim another host with a cloned machine identity (current boot: %s)', + (currentBoot) => { + const path = root() + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue(currentBoot) + vi.spyOn(identity, 'profileStateAccessMachineIdentity').mockReturnValue('cloned-machine') + const owner = staleGate(path, 12345, 'another-host', { + bootIdentity: 'another-boot', + machineIdentity: 'cloned-machine' + }) + const kill = vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('absent on this host'), { code: 'ESRCH' }) + }) + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + expect(kill).not.toHaveBeenCalled() + expect(fs.existsSync(owner)).toBe(true) + } + ) + + it('reclaims a reused PID only when its recorded process start differs on the same boot', () => { + const path = root() + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('same-boot') + vi.spyOn(identity, 'profileStateAccessProcessIdentity').mockReturnValue( + 'linux-start-ticks:2000' + ) + const owner = staleGate(path, process.pid, hostname(), { + bootIdentity: 'same-boot', + processStartIdentity: 'linux-start-ticks:1000' + }) + acquireProfileStateMaintenance(path).release() + expect(fs.existsSync(owner)).toBe(false) + }) + + it('cannot reclaim a live Linux owner when wall-clock time changes', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + if (!platform) { + throw new Error('Missing platform descriptor') + } + const path = root() + const read = fs.readFileSync + const fields = Array.from({ length: 20 }, () => '0') + fields[0] = 'S' + fields[19] = '987654' + let clock = 1_700_000_000_000 + const wallStart = vi + .spyOn(processStart, 'getProcessStartedAtMs') + .mockImplementation(() => clock) + vi.spyOn(fs, 'readFileSync').mockImplementation((file, options) => { + if (file === '/proc/12345/stat') { + return `12345 (orca daemon) ${fields.join(' ')}` + } + return read(file, options) + }) + vi.spyOn(fs, 'readlinkSync').mockReturnValue('pid:[same-namespace]') + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('same-boot') + vi.spyOn(process, 'kill').mockReturnValue(true) + try { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + const processStartIdentity = identity.profileStateAccessProcessIdentity(12345) + expect(processStartIdentity).toBe('linux-start-ticks:987654') + if (processStartIdentity === null) { + throw new Error('Expected process identity') + } + const owner = staleGate(path, 12345, hostname(), { + bootIdentity: 'same-boot', + processStartIdentity + }) + clock += 60_000 + // Linux identity emulation must not change the host filesystem's publication/fsync flags. + expect(() => reclaimExitedOwner(profileStateAccessPaths(path).maintenance)).toThrow( + 'unverifiable' + ) + expect(fs.existsSync(owner)).toBe(true) + expect(wallStart).not.toHaveBeenCalled() + } finally { + Object.defineProperty(process, 'platform', platform) + } + }) + + it('does not compare legacy epoch timestamps with raw process identity', () => { + const path = root() + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('same-boot') + vi.spyOn(identity, 'profileStateAccessProcessIdentity').mockReturnValue( + 'linux-start-ticks:2000' + ) + const owner = staleGate(path, process.pid, hostname(), { + bootIdentity: 'same-boot', + startedAtMs: 1000 + }) + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + expect(fs.existsSync(owner)).toBe(true) + }) + + it.each([ + 'wall-time-ms:1000', + 'linux-start-ticks:invalid', + 'linux-start-ticks:99999999999999999' + ])('does not compare incompatible or malformed identity %s', (recorded) => { + const path = root() + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('same-boot') + vi.spyOn(identity, 'profileStateAccessProcessIdentity').mockReturnValue( + 'linux-start-ticks:2000' + ) + const owner = staleGate(path, process.pid, hostname(), { + bootIdentity: 'same-boot', + processStartIdentity: recorded + }) + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + expect(fs.existsSync(owner)).toBe(true) + }) + + it('does not interpret an unavailable process start as proof of PID reuse', () => { + const path = root() + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('same-boot') + vi.spyOn(identity, 'profileStateAccessProcessIdentity').mockReturnValue(null) + const owner = staleGate(path, process.pid, hostname(), { + bootIdentity: 'same-boot', + processStartIdentity: 'linux-start-ticks:1000' + }) + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + expect(fs.existsSync(owner)).toBe(true) + }) + + it.each([ + ['darwin-utc-start-ms:100000', 'darwin-utc-start-ms:101000', false], + ['darwin-utc-start-ms:100000', 'darwin-utc-start-ms:101501', true], + ['wall-time-ms:100000', 'darwin-utc-start-ms:3700000', false] + ] as const)('compares macOS start identities safely: %s / %s', (recorded, actual, exited) => { + const path = root() + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('same-boot') + vi.spyOn(identity, 'profileStateAccessProcessIdentity').mockReturnValue(actual) + const owner = staleGate(path, process.pid, hostname(), { + bootIdentity: 'same-boot', + processStartIdentity: recorded + }) + if (exited) { + acquireProfileStateMaintenance(path).release() + expect(fs.existsSync(owner)).toBe(false) + } else { + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + expect(fs.existsSync(owner)).toBe(true) + } + }) + + it('retries a transient Windows owner publication lock', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + if (!platform) { + throw new Error('Missing platform descriptor') + } + const path = root() + const rename = fs.renameSync + const publish = vi + .spyOn(fs, 'renameSync') + .mockImplementationOnce(() => { + throw Object.assign(new Error('scanner holds directory'), { code: 'EPERM' }) + }) + .mockImplementation(rename) + vi.spyOn(Atomics, 'wait').mockReturnValue('timed-out') + try { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const admission = acquireProfileStateRuntimeAdmission(path) + expect(publish).toHaveBeenCalledTimes(2) + admission.release() + expect(fs.readdirSync(profileStateAccessPaths(path).participants)).toEqual([]) + } finally { + Object.defineProperty(process, 'platform', platform) + } + }) + + it.skipIf(process.platform === 'win32')( + 'recognizes an exited owner after a hostname change on the same kernel boot', + () => { + const path = root() + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('same-boot') + const owner = staleGate(path, 12345, 'previous-hostname', { bootIdentity: 'same-boot' }) + vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('exited'), { code: 'ESRCH' }) + }) + acquireProfileStateMaintenance(path).release() + expect(fs.existsSync(owner)).toBe(false) + } + ) + + it('keeps a differently booted host unverifiable after a hostname change', () => { + const path = root() + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('different-boot') + const owner = staleGate(path, 12345, 'previous-hostname', { bootIdentity: 'owner-boot' }) + const kill = vi.spyOn(process, 'kill') + expect(() => acquireProfileStateMaintenance(path)).toThrow('verify PID 12345') + expect(kill).not.toHaveBeenCalled() + expect(fs.existsSync(owner)).toBe(true) + }) + + it('does not infer exit from a PID in another platform on a shared root', () => { + const path = root() + const owner = staleGate(path) + const record: unknown = JSON.parse(fs.readFileSync(owner, 'utf8')) + if (typeof record !== 'object' || record === null) { + throw new Error('Owner fixture missing') + } + fs.writeFileSync( + owner, + JSON.stringify({ ...record, platform: process.platform === 'win32' ? 'linux' : 'win32' }) + ) + const kill = vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('absent here'), { code: 'ESRCH' }) + }) + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + expect(kill).not.toHaveBeenCalled() + expect(fs.existsSync(owner)).toBe(true) + }) + + it.skipIf(process.platform !== 'linux')( + 'refuses a different Linux PID namespace even with the same hostname', + () => { + const path = root() + const owner = staleGate(path) + const record: unknown = JSON.parse(fs.readFileSync(owner, 'utf8')) + if (typeof record !== 'object' || record === null) { + throw new Error('Owner fixture missing') + } + fs.writeFileSync(owner, JSON.stringify({ ...record, pidNamespace: 'pid:[foreign]' })) + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + expect(fs.existsSync(owner)).toBe(true) + } + ) + + it.each(['EPERM', 'EINVAL', 'EACCES'])('refuses unverifiable process query %s', (code) => { + const path = root() + const owner = staleGate(path) + vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error(code), { code }) + }) + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + expect(fs.existsSync(owner)).toBe(true) + }) + + it('refuses live reused PIDs regardless of old timestamps', () => { + const path = root() + const owner = staleGate(path, process.pid) + fs.utimesSync(owner, 0, 0) + expect(() => acquireProfileStateMaintenance(path)).toThrow('in use') + expect(fs.existsSync(owner)).toBe(true) + }) + + it('refuses foreign host and malformed owners without reclaiming them', () => { + const path = root() + const owner = staleGate(path, process.pid, `${hostname()}-other`) + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + fs.writeFileSync(owner, '{}') + expect(() => acquireProfileStateMaintenance(path)).toThrow('malformed') + expect(fs.existsSync(owner)).toBe(true) + }) + + it('cannot remove a replacement published while it releases its own token', () => { + const path = root() + const original = acquireProfileStateMaintenance(path) + const unlink = fs.unlinkSync + let replacement: ProfileStateMaintenance | undefined + let intercepted = false + vi.spyOn(fs, 'unlinkSync').mockImplementation((entry) => { + unlink(entry) + if (!intercepted && String(entry).includes('maintenance')) { + intercepted = true + replacement = acquireProfileStateMaintenance(path) + } + }) + original.release() + expect(replacement).toBeDefined() + replacement?.assertActive() + expect(() => acquireProfileStateRuntimeAdmission(path)).toThrow('in use') + replacement?.release() + }) + + it('cannot remove a replacement published after stale-owner observation', () => { + const path = root() + const old = staleGate(path) + const kill = process.kill + vi.spyOn(process, 'kill').mockImplementation((pid, signal) => { + if (pid === 12345) { + throw Object.assign(new Error('exited'), { code: 'ESRCH' }) + } + return kill(pid, signal) + }) + const unlink = fs.unlinkSync + let replacement: ProfileStateMaintenance | undefined + vi.spyOn(fs, 'unlinkSync').mockImplementation((entry) => { + unlink(entry) + if (entry === old) { + replacement = acquireProfileStateMaintenance(path) + } + }) + expect(() => acquireProfileStateMaintenance(path)).toThrow('in use') + replacement?.assertActive() + expect(replacement).toBeDefined() + replacement?.release() + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-access.ts b/src/main/persistence/profile-state/profile-state-access.ts new file mode 100644 index 00000000000..724549dc7de --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-access.ts @@ -0,0 +1,99 @@ +import { lstatSync, readdirSync, realpathSync } from 'node:fs' +import { basename, dirname, join } from 'node:path' +import { + ProfileStateAccessError, + hasCode, + profileStateAccessPaths, + publishAccessOwner, + reclaimExitedOwner +} from './profile-state-access-owner' +export { ProfileStateAccessError } from './profile-state-access-owner' + +export type ProfileStateRuntimeAdmission = { + assertActive(): void + release(): void +} + +export type ProfileStateMaintenance = ProfileStateRuntimeAdmission & { + assertProfile(profileId: string, dataFile: string, databasePath: string): void +} + +const maintenanceRoots = new WeakMap() + +/** Bind destructive operations to a genuine, live maintenance owner for these exact profile paths. */ +export function assertProfileStateMaintenance( + maintenance: ProfileStateMaintenance, + profile: { profileId: string; dataFile: string; databasePath: string } +): void { + const root = maintenanceRoots.get(maintenance) + if (root === undefined || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(profile.profileId)) { + throw new ProfileStateAccessError( + 'Profile state recovery requires an acquired maintenance owner' + ) + } + maintenance.assertActive() + const expectedDirectory = join(root, 'profiles', profile.profileId) + for (const [path, expectedName] of [ + [profile.dataFile, 'orca-data.json'], + [profile.databasePath, 'profile-state.db'] + ] as const) { + if ( + !samePath(realpathSync(dirname(path)), expectedDirectory) || + !samePath(basename(path), expectedName) + ) { + throw new ProfileStateAccessError( + 'Profile state recovery paths do not belong to the maintenance root' + ) + } + try { + if (lstatSync(path).isSymbolicLink()) { + throw new ProfileStateAccessError('Profile state recovery cannot replace a symbolic link') + } + } catch (error) { + if (!hasCode(error, 'ENOENT')) { + throw error + } + } + } +} + +function samePath(left: string, right: string): boolean { + return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right +} + +/** Admit before any profile read, and retain until every Store and worker has stopped. */ +export function acquireProfileStateRuntimeAdmission( + userDataPath: string +): ProfileStateRuntimeAdmission { + const paths = profileStateAccessPaths(userDataPath) + const owner = publishAccessOwner(paths, false) + try { + reclaimExitedOwner(paths.maintenance) + return owner + } catch (error) { + owner.release() + throw error + } +} + +/** Exclude startup and all participating readers/writers through durable recovery publication. */ +export function acquireProfileStateMaintenance(userDataPath: string): ProfileStateMaintenance { + const paths = profileStateAccessPaths(userDataPath) + const owner = publishAccessOwner(paths, true) + try { + for (const entry of readdirSync(paths.participants)) { + reclaimExitedOwner(join(paths.participants, entry)) + } + const maintenance: ProfileStateMaintenance = { + ...owner, + assertProfile(profileId, dataFile, databasePath): void { + assertProfileStateMaintenance(maintenance, { profileId, dataFile, databasePath }) + } + } + maintenanceRoots.set(maintenance, dirname(paths.root)) + return maintenance + } catch (error) { + owner.release() + throw error + } +} diff --git a/src/main/persistence/profile-state/profile-state-active-location.ts b/src/main/persistence/profile-state/profile-state-active-location.ts new file mode 100644 index 00000000000..15c38fe3ac1 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-active-location.ts @@ -0,0 +1,49 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { + getOrcaProfileDataFile, + getOrcaProfileStateDatabaseFile +} from '../../../shared/profile-state-storage-paths' +import type { ProfileStateOfflineLocation } from './profile-state-offline-settings' +import { ProfileStateRecoveryCommandError } from '../../../shared/profile-state-recovery-command' + +/** Resolve the active profile files for offline profile-state commands. */ +export function getActiveProfileStateLocation( + userDataPath: string +): ProfileStateOfflineLocation | undefined { + const indexPath = join(userDataPath, 'orca-profile-index.json') + const candidates = [indexPath, `${indexPath}.bak`].filter(existsSync) + if (candidates.length === 0) { + return undefined + } + for (const candidate of candidates) { + try { + const parsed: unknown = JSON.parse(readFileSync(candidate, 'utf-8')) + if (!isRecord(parsed) || !Array.isArray(parsed.profiles)) { + continue + } + const profileId = parsed.activeProfileId + if ( + typeof profileId === 'string' && + /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(profileId) && + parsed.profiles.some((profile) => isRecord(profile) && profile.id === profileId) + ) { + return { + dataFile: getOrcaProfileDataFile(profileId, userDataPath), + databaseFile: getOrcaProfileStateDatabaseFile(profileId, userDataPath), + profileId + } + } + } catch { + // Try the profile-index backup before failing closed. + } + } + throw new ProfileStateRecoveryCommandError( + 'runtime_error', + `Could not read active profile index ${indexPath}` + ) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts b/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts new file mode 100644 index 00000000000..8d7b7f72674 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts @@ -0,0 +1,544 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import Database from '../../sqlite/sync-database' +import { openProfileStateDatabase, profileStateDatabaseFile } from './profile-state-database' +import * as profileStateDocuments from './profile-state-documents' +import { profileStateJsonExportPath } from './legacy-json/profile-state-export-path' +import { acquireProfileStateMaintenance } from './profile-state-access' +import { restoreProfileStateJsonExport } from './legacy-json/profile-state-recovery' +import { + bootstrapProfileStateAuthority as bootstrapProfileStateAuthorityImpl, + classifyProfileStateStorage, + ProfileStateAuthorityBootstrapError, + ProfileStateRecoveryRequiredError +} from './profile-state-authority-bootstrap' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8').slice('encrypted:'.length) + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(() => ({ hosts: [] })), + sshConfigHostsToTargets: vi.fn(() => []) +})) + +const { Store } = await import('../loading-store/store') + +const temporaryDirectories: string[] = [] +const authoritiesToClose: NonNullable< + ReturnType['authority'] +>[] = [] + +function bootstrapProfileStateAuthority( + options: Parameters[0] +): ReturnType { + const result = bootstrapProfileStateAuthorityImpl(options) + if (result.authority !== undefined) { + authoritiesToClose.push(result.authority) + } + return result +} + +afterEach(() => { + for (const authority of authoritiesToClose.splice(0)) { + authority.close?.() + } + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +function createDirectory(): string { + const root = mkdtempSync(join(tmpdir(), 'orca-profile-state-bootstrap-')) + temporaryDirectories.push(root) + const directory = join(root, 'profiles', 'profile-bootstrap-test') + mkdirSync(directory, { recursive: true }) + return directory +} + +function paths(directory: string): { + dataFile: string + databaseFile: string + profileId: string +} { + return { + dataFile: join(directory, 'orca-data.json'), + databaseFile: profileStateDatabaseFile(directory), + profileId: 'profile-bootstrap-test' + } +} + +describe('profile state authority bootstrap', () => { + it('classifies all four storage-presence states without opening SQLite', () => { + const directory = createDirectory() + const { dataFile, databaseFile } = paths(directory) + + expect(classifyProfileStateStorage(dataFile, databaseFile)).toBe('neither') + writeFileSync(dataFile, '{}') + expect(classifyProfileStateStorage(dataFile, databaseFile)).toBe('json-only') + + const opened = openProfileStateDatabase(databaseFile, 'profile-bootstrap-test') + opened.db.close() + expect(classifyProfileStateStorage(dataFile, databaseFile)).toBe('both') + + rmSync(dataFile) + expect(classifyProfileStateStorage(dataFile, databaseFile)).toBe('sqlite-only') + }) + + it('imports JSON-only state through Store export and returns the SQLite authority', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync( + options.dataFile, + JSON.stringify({ settings: { theme: 'dark' }, futureExtension: { keep: true } }) + ) + + const result = bootstrapProfileStateAuthority(options) + + expect(result.classification).toBe('json-only') + expect(result.migrated).toBe(true) + expect(result.authority?.readSerializedState()).toContain('futureExtension') + expect(existsSync(options.dataFile)).toBe(true) + expect(existsSync(profileStateJsonExportPath(options.dataFile, 1))).toBe(true) + expect(readFileSync(profileStateJsonExportPath(options.dataFile, 1), 'utf8')).toContain( + 'futureExtension' + ) + expect(classifyProfileStateStorage(options.dataFile, options.databaseFile)).toBe('both') + + if (result.authority === undefined) { + throw new Error('JSON migration did not return a SQLite authority') + } + const store = new Store({ + dataFile: options.dataFile, + profileStateAuthority: result.authority + }) + expect(store.getSettings().theme).toBe('dark') + + const repeated = bootstrapProfileStateAuthority(options) + expect(repeated.authority?.readSerializedState()).toContain('futureExtension') + }) + + it('fails closed when both files are present without a matching acceptance marker', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + const opened = openProfileStateDatabase(options.databaseFile, options.profileId) + opened.db.close() + + expect(() => bootstrapProfileStateAuthority(options)).toThrowError( + ProfileStateAuthorityBootstrapError + ) + }) + + it('rejects a legacy JSON edit after migration instead of selecting stale SQLite state', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + bootstrapProfileStateAuthority(options) + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'light' } })) + + expect(() => bootstrapProfileStateAuthority(options)).toThrowError( + ProfileStateAuthorityBootstrapError + ) + }) + + it.each([1, 2])( + 'requires explicit recovery for unreleased schema %s without changing its bytes', + (version) => { + const options = paths(createDirectory()) + const raw = JSON.stringify({ settings: { theme: 'dark' }, futureDomain: { retained: true } }) + const opened = openProfileStateDatabase(options.databaseFile, options.profileId) + profileStateDocuments.importProfileStateJson(opened.db, raw, { + acceptedLegacyJsonHash: profileStateDocuments.hashProfileStateJson(raw) + }) + if (version === 1) { + opened.db.exec( + 'DROP TABLE profile_state_automation_runs; DROP TABLE profile_state_automation_runs_meta' + ) + } else { + opened.db.exec('DELETE FROM profile_state_automation_runs_meta') + } + opened.db.pragma(`user_version = ${version}`) + opened.db.close() + const before = readFileSync(options.databaseFile) + for (const withJson of [false, true]) { + if (withJson) { + writeFileSync(options.dataFile, raw) + } + expect(() => bootstrapProfileStateAuthority(options)).toThrow( + ProfileStateRecoveryRequiredError + ) + expect(readFileSync(options.databaseFile)).toEqual(before) + } + const exportPath = profileStateJsonExportPath(options.dataFile, 1) + writeFileSync(exportPath, raw) + restoreProfileStateJsonExport({ + maintenance: acquireProfileStateMaintenance(dirname(dirname(dirname(options.dataFile)))), + databasePath: options.databaseFile, + dataFile: options.dataFile, + profileId: options.profileId, + exportPath + }) + const recovered = bootstrapProfileStateAuthority(options) + expect(recovered.migrated).toBe(true) + expect(JSON.parse(recovered.authority?.readSerializedState() ?? '{}')).toMatchObject({ + futureDomain: { retained: true } + }) + } + ) + + it('returns no authority for a brand-new profile', () => { + const directory = createDirectory() + const result = bootstrapProfileStateAuthority(paths(directory)) + + expect(result).toEqual({ classification: 'neither', authority: undefined, migrated: false }) + }) + + it('cleans failed empty-profile initialization before a successful retry', () => { + const directory = createDirectory() + const options = { ...paths(directory), allowEmptyProfileState: true } + const initializationFailure = new Error('injected initial schema failure') + const originalExec = Database.prototype.exec + const execSpy = vi.spyOn(Database.prototype, 'exec').mockImplementation(function ( + this: Database, + sql + ) { + originalExec.call(this, sql) + if (sql.includes('CREATE TABLE')) { + const row = this.prepare('PRAGMA database_list').get() + if (typeof row?.file !== 'string') { + throw new Error('Expected a file-backed database during initialization') + } + expect(existsSync(`${row.file}-journal`)).toBe(true) + for (const suffix of ['-wal', '-shm']) { + writeFileSync(`${row.file}${suffix}`, 'interrupted schema initialization') + } + throw initializationFailure + } + }) + + expect(() => bootstrapProfileStateAuthority(options)).toThrowError( + expect.objectContaining({ cause: initializationFailure }) + ) + expect(existsSync(options.databaseFile)).toBe(false) + expect(classifyProfileStateStorage(options.dataFile, options.databaseFile)).toBe('neither') + expect(readdirSync(directory)).toEqual([]) + + execSpy.mockRestore() + const retry = bootstrapProfileStateAuthority(options) + expect(retry.migrated).toBe(false) + expect(retry.authority).toBeDefined() + expect(retry.authority?.readSerializedState()).toBeUndefined() + expect(bootstrapProfileStateAuthority(options).classification).toBe('sqlite-only') + }) + + it('preserves JSON created while an empty database is being initialized', () => { + const options = { ...paths(createDirectory()), allowEmptyProfileState: true } + const source = '{"settings":{"theme":"dark"}}' + const originalClose = Database.prototype.close + vi.spyOn(Database.prototype, 'close').mockImplementationOnce(function (this: Database) { + originalClose.call(this) + writeFileSync(options.dataFile, source) + }) + + expect(() => bootstrapProfileStateAuthority(options)).toThrow( + 'Profile state storage changed while creating an empty database' + ) + expect(readFileSync(options.dataFile, 'utf8')).toBe(source) + expect(readdirSync(dirname(options.dataFile))).toEqual(['orca-data.json']) + }) + + it.each(['legacy-backup', 'sqlite-export'])( + 'preserves a %s created while an empty database is being initialized', + (artifact) => { + const options = { ...paths(createDirectory()), allowEmptyProfileState: true } + const path = + artifact === 'legacy-backup' + ? `${options.dataFile}.bak.0` + : profileStateJsonExportPath(options.dataFile, 1) + const source = '{"settings":{"theme":"dark"}}' + const originalClose = Database.prototype.close + vi.spyOn(Database.prototype, 'close').mockImplementationOnce(function (this: Database) { + originalClose.call(this) + writeFileSync(path, source) + }) + + expect(() => bootstrapProfileStateAuthority(options)).toThrow() + expect(existsSync(options.databaseFile)).toBe(false) + expect(existsSync(options.dataFile)).toBe(false) + expect(readFileSync(path, 'utf8')).toBe(source) + expect(readdirSync(dirname(path))).toEqual([basename(path)]) + } + ) + + it.each(['-wal', '-shm', '-journal'])( + 'treats an orphaned SQLite %s sidecar as authority evidence with or without JSON', + (suffix) => { + const options = paths(createDirectory()) + const sidecar = `${options.databaseFile}${suffix}` + writeFileSync(sidecar, 'orphaned recovery evidence') + + expect(classifyProfileStateStorage(options.dataFile, options.databaseFile)).toBe( + 'sqlite-only' + ) + expect(() => bootstrapProfileStateAuthority(options)).toThrow() + writeFileSync(options.dataFile, '{"settings":{"theme":"dark"}}') + expect(classifyProfileStateStorage(options.dataFile, options.databaseFile)).toBe('both') + expect(() => bootstrapProfileStateAuthority(options)).toThrow() + expect(existsSync(options.databaseFile)).toBe(false) + expect(readFileSync(sidecar, 'utf8')).toBe('orphaned recovery evidence') + expect(readFileSync(options.dataFile, 'utf8')).toBe('{"settings":{"theme":"dark"}}') + } + ) + + it('validates and returns an existing SQLite-only authority', () => { + const directory = createDirectory() + const options = paths(directory) + const opened = openProfileStateDatabase(options.databaseFile, options.profileId) + opened.db.close() + + const result = bootstrapProfileStateAuthority(options) + + expect(result.classification).toBe('sqlite-only') + expect(result.migrated).toBe(false) + expect(result.authority?.readSerializedState()).toBeUndefined() + }) + + it('rejects malformed SQLite-only state before Store can select it', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync(options.databaseFile, 'not sqlite') + + expect(() => bootstrapProfileStateAuthority(options)).toThrow() + }) + + it('reports retained exports when an established SQLite profile needs recovery', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + const bootstrap = bootstrapProfileStateAuthority(options) + bootstrap.authority?.close?.() + const exportPath = profileStateJsonExportPath(options.dataFile, 1) + const exportPathRevision2 = profileStateJsonExportPath(options.dataFile, 2) + const exportPathRevision10 = profileStateJsonExportPath(options.dataFile, 10) + writeFileSync(exportPathRevision2, readFileSync(exportPath)) + writeFileSync(exportPathRevision10, readFileSync(exportPath)) + writeFileSync( + `${options.dataFile}.sqlite-export.9007199254740992.json`, + readFileSync(exportPath) + ) + writeFileSync(options.databaseFile, 'not sqlite') + + try { + bootstrapProfileStateAuthority(options) + throw new Error('expected recovery-required startup failure') + } catch (error) { + expect(error).toBeInstanceOf(ProfileStateRecoveryRequiredError) + if (!(error instanceof ProfileStateRecoveryRequiredError)) { + throw error + } + expect(error.dataFile).toBe(options.dataFile) + expect(error.databaseFile).toBe(options.databaseFile) + expect(error.exportPaths).toEqual([exportPathRevision10, exportPathRevision2, exportPath]) + } + }) + + it('does not create a database when the legacy JSON cannot be imported', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync(options.dataFile, '{ malformed') + + expect(() => bootstrapProfileStateAuthority(options)).toThrow( + 'Failed to load imported profile state' + ) + expect(existsSync(options.databaseFile)).toBe(false) + }) + + it.each([0, 1, 2, 3, 4])( + 'migrates usable legacy backup slot %s after a corrupt primary', + (slot) => { + const options = paths(createDirectory()) + const damaged = '{ malformed primary' + const recovered = JSON.stringify({ settings: { theme: 'dark' }, retainedBackup: slot }) + writeFileSync(options.dataFile, damaged) + for (let index = 0; index < slot; index += 1) { + writeFileSync(`${options.dataFile}.bak.${index}`, '{ damaged backup') + } + writeFileSync(`${options.dataFile}.bak.${slot}`, recovered) + + const result = bootstrapProfileStateAuthority(options) + expect(result.migrated).toBe(true) + expect(JSON.parse(result.authority?.readSerializedState() ?? '{}')).toMatchObject({ + settings: { theme: 'dark' }, + retainedBackup: slot + }) + expect(readFileSync(options.dataFile, 'utf8')).toBe(damaged) + expect(readFileSync(`${options.dataFile}.bak.${slot}`, 'utf8')).toBe(recovered) + expect(bootstrapProfileStateAuthority(options).migrated).toBe(false) + } + ) + + it('preserves every source when legacy primary and backups are unusable', () => { + const options = paths(createDirectory()) + writeFileSync(options.dataFile, '{ malformed primary') + writeFileSync(`${options.dataFile}.bak.0`, '{ malformed backup') + expect(() => bootstrapProfileStateAuthority(options)).toThrow( + ProfileStateAuthorityBootstrapError + ) + expect(readFileSync(options.dataFile, 'utf8')).toBe('{ malformed primary') + expect(readFileSync(`${options.dataFile}.bak.0`, 'utf8')).toBe('{ malformed backup') + expect(existsSync(options.databaseFile)).toBe(false) + }) + + it('cleans a temporary database when import fails after opening SQLite', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + const importFailure = new Error('injected import failure') + const importSpy = vi + .spyOn(profileStateDocuments, 'importProfileStateJson') + .mockImplementation(() => { + throw importFailure + }) + + expect(() => bootstrapProfileStateAuthority(options)).toThrow(importFailure) + expect(classifyProfileStateStorage(options.dataFile, options.databaseFile)).toBe('json-only') + expect(existsSync(options.databaseFile)).toBe(false) + expect( + readdirSync(directory).some((name) => name.includes('.migration.') && name.endsWith('.tmp')) + ).toBe(false) + + importSpy.mockRestore() + const retry = bootstrapProfileStateAuthority(options) + expect(retry.migrated).toBe(true) + expect(retry.authority?.readSerializedState()).toContain('"dark"') + }) + + it('refuses a pre-existing retained export before migrating JSON again', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + const exportPath = profileStateJsonExportPath(options.dataFile, 1) + mkdirSync(exportPath) + + expect(() => bootstrapProfileStateAuthority(options)).toThrow() + expect(existsSync(options.dataFile)).toBe(true) + expect(existsSync(options.databaseFile)).toBe(false) + + rmSync(exportPath, { recursive: true }) + const retry = bootstrapProfileStateAuthority(options) + expect(retry.classification).toBe('json-only') + expect(retry.migrated).toBe(true) + expect(retry.authority?.readSerializedState()).toContain('"dark"') + }) + + it('boots the revisioned export through the legacy Store after SQLite corruption', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + bootstrapProfileStateAuthority(options) + const exportPath = profileStateJsonExportPath(options.dataFile, 1) + + writeFileSync(options.databaseFile, 'corrupt sqlite primary') + writeFileSync(options.dataFile, readFileSync(exportPath)) + const rollbackStore = new Store({ + dataFile: options.dataFile, + serializedState: readFileSync(options.dataFile, 'utf8') + }) + expect(rollbackStore.getSettings().theme).toBe('dark') + rollbackStore.freezeWrites() + }) + + it('quarantines SQLite and restores a selected export for legacy rollback', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + const bootstrap = bootstrapProfileStateAuthority(options) + bootstrap.authority?.close?.() + const exportPath = profileStateJsonExportPath(options.dataFile, 1) + writeFileSync(options.databaseFile, 'corrupt sqlite primary') + writeFileSync(`${options.databaseFile}-wal`, 'corrupt wal sidecar') + const restoredJson = readFileSync(exportPath) + + const result = restoreProfileStateJsonExport({ + maintenance: acquireProfileStateMaintenance(dirname(dirname(dirname(options.dataFile)))), + databasePath: options.databaseFile, + dataFile: options.dataFile, + profileId: options.profileId, + exportPath, + quarantineRoot: join(directory, 'quarantine') + }) + + expect(result.removedDatabaseFiles).toEqual( + expect.arrayContaining([options.databaseFile, `${options.databaseFile}-wal`]) + ) + expect(existsSync(options.databaseFile)).toBe(false) + expect(readFileSync(options.dataFile)).toEqual(restoredJson) + expect(existsSync(exportPath)).toBe(false) + expect(readFileSync(join(result.quarantine.directory, 'profile-state.db'), 'utf8')).toBe( + 'corrupt sqlite primary' + ) + expect(readFileSync(join(result.quarantine.directory, 'profile-state.db-wal'), 'utf8')).toBe( + 'corrupt wal sidecar' + ) + + const rollbackStore = new Store({ + dataFile: options.dataFile, + serializedState: readFileSync(options.dataFile, 'utf8') + }) + expect(rollbackStore.getSettings().theme).toBe('dark') + rollbackStore.freezeWrites() + }) + + it('validates the selected export before quarantining or replacing anything', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + bootstrapProfileStateAuthority(options) + const exportPath = profileStateJsonExportPath(options.dataFile, 1) + writeFileSync(exportPath, '{ malformed') + const databaseBytes = readFileSync(options.databaseFile) + const dataBytes = readFileSync(options.dataFile) + + expect(() => + restoreProfileStateJsonExport({ + maintenance: acquireProfileStateMaintenance(dirname(dirname(dirname(options.dataFile)))), + databasePath: options.databaseFile, + dataFile: options.dataFile, + profileId: options.profileId, + exportPath + }) + ).toThrow('Profile state JSON is invalid') + expect(readFileSync(options.databaseFile)).toEqual(databaseBytes) + expect(readFileSync(options.dataFile)).toEqual(dataBytes) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts b/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts new file mode 100644 index 00000000000..7056b8aec9f --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts @@ -0,0 +1,152 @@ +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { randomUUID } from 'node:crypto' +import { dirname } from 'node:path' +import { publishProfileStateDatabase } from './profile-state-database-publication' +import type { ProfileStateAuthorityInitialState } from '../loading-store/profile-state-authority' +import { isProfileStateSqliteAvailable, openProfileStateDatabase } from './profile-state-database' +import { ProfileStateDatabaseOpenError } from './profile-state-database-errors' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { migrateProfileStateToSqlite } from './profile-state-migration' +import { prepareLegacyProfileState } from './legacy-json/profile-state-legacy-import' +import { + assertProfileStateCanInitialize, + ProfileStateAuthorityBootstrapError, + ProfileStateRecoveryRequiredError +} from './profile-state-recovery-required' +export { + ProfileStateAuthorityBootstrapError, + ProfileStateRecoveryRequiredError +} from './profile-state-recovery-required' +import { + classifyProfileStateStorage, + profileStateDatabaseFiles, + type ProfileStateStorageClassification +} from './profile-state-storage-classification' + +export { classifyProfileStateStorage } from './profile-state-storage-classification' +export type { ProfileStateStorageClassification } from './profile-state-storage-classification' + +export type ProfileStateAuthorityBootstrapResult = { + classification: ProfileStateStorageClassification + migrated: boolean +} & ( + | { + authority: ProfileStateSqliteAuthority + initialState: ProfileStateAuthorityInitialState + } + | { authority: undefined; initialState?: never } +) + +export type ProfileStateAuthorityBootstrapOptions = { + dataFile: string + databaseFile: string + profileId: string + /** Establish empty profiles before their first Store write. */ + allowEmptyProfileState?: boolean +} + +/** Normalize legacy state once, then hand one validated authority to Store. */ +export function bootstrapProfileStateAuthority( + options: ProfileStateAuthorityBootstrapOptions +): ProfileStateAuthorityBootstrapResult { + const classification = classifyProfileStateStorage(options.dataFile, options.databaseFile) + if (classification === 'neither' && options.allowEmptyProfileState !== true) { + return { classification, authority: undefined, migrated: false } + } + if (!isProfileStateSqliteAvailable()) { + if (classification === 'neither' || classification === 'json-only') { + return { classification, authority: undefined, migrated: false } + } + throw new ProfileStateAuthorityBootstrapError( + 'SQLite profile state is present but this runtime cannot validate it' + ) + } + if (classification === 'json-only' || classification === 'neither') { + assertProfileStateCanInitialize(options) + } + if (classification === 'json-only') { + return migrateJsonOnlyProfile(options) + } + if (classification === 'neither') { + mkdirSync(dirname(options.databaseFile), { recursive: true }) + createEmptyProfileStateDatabase(options) + } else if (classification === 'sqlite-only' && !existsSync(options.databaseFile)) { + throw new ProfileStateAuthorityBootstrapError( + 'SQLite profile state has an orphaned database sidecar' + ) + } + + const authority = new ProfileStateSqliteAuthority(options.databaseFile, options.profileId) + try { + const initialState = + classification === 'both' + ? authority.readAcceptedState(readFileSync(options.dataFile, 'utf8')) + : authority.readInitialState() + if (initialState === undefined) { + throw new ProfileStateAuthorityBootstrapError( + 'Profile state has both JSON and SQLite storage without a matching acceptance marker', + 'diverged-json' + ) + } + return { classification, authority, initialState, migrated: false } + } catch (error) { + authority.close() + if ( + error instanceof ProfileStateAuthorityBootstrapError || + (error instanceof ProfileStateDatabaseOpenError && error.code === 'newer-schema') + ) { + throw error + } + throw new ProfileStateRecoveryRequiredError(options, error) + } +} + +function createEmptyProfileStateDatabase({ + dataFile, + databaseFile, + profileId +}: ProfileStateAuthorityBootstrapOptions): void { + const temporaryDatabaseFile = `${databaseFile}.empty.${process.pid}.${randomUUID()}.tmp` + let published = false + try { + const opened = openProfileStateDatabase(temporaryDatabaseFile, profileId) + opened.db.close() + if (classifyProfileStateStorage(dataFile, databaseFile) !== 'neither') { + throw new ProfileStateAuthorityBootstrapError( + 'Profile state storage changed while creating an empty database' + ) + } + assertProfileStateCanInitialize({ dataFile, databaseFile, profileId }) + if (!publishProfileStateDatabase(temporaryDatabaseFile, databaseFile)) { + throw new ProfileStateAuthorityBootstrapError( + 'Profile state storage changed while creating an empty database' + ) + } + published = true + } finally { + if (!published) { + for (const path of profileStateDatabaseFiles(temporaryDatabaseFile)) { + rmSync(path, { force: true }) + } + } + } +} + +function migrateJsonOnlyProfile( + options: ProfileStateAuthorityBootstrapOptions +): ProfileStateAuthorityBootstrapResult { + const rawJson = readFileSync(options.dataFile, 'utf8') + const { prepared, unboundPaneAliases } = prepareLegacyProfileState(options.dataFile, rawJson) + const migrated = migrateProfileStateToSqlite({ + ...options, + expectedLegacyJson: rawJson, + serializedState: prepared.json + }) + prepared.commit() + return { + classification: 'json-only', + ...migrated, + initialState: { ...migrated.initialState, unboundPaneAliases }, + migrated: true + } +} diff --git a/src/main/persistence/profile-state/profile-state-authority-export-fencing.test.ts b/src/main/persistence/profile-state/profile-state-authority-export-fencing.test.ts new file mode 100644 index 00000000000..b53dc25fd69 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-authority-export-fencing.test.ts @@ -0,0 +1,43 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { ProfileStateRevisionConflictError } from './profile-state-document-validation' + +describe('profile export snapshot revision', () => { + it.each(['json', 'compatibility-sync', 'compatibility-async'] as const)( + 'refuses a competing revision before publishing %s', + async (kind) => { + const root = mkdtempSync(join(tmpdir(), 'orca-export-fence-')) + const database = join(root, 'profile-state.db') + const target = join(root, 'retained.json') + const owner = new ProfileStateSqliteAuthority(database, 'export-fence') + const peer = new ProfileStateSqliteAuthority(database, 'export-fence') + try { + owner.writeSerializedState(Buffer.from('{"settings":{"theme":"dark"}}')) + writeFileSync(target, 'retained export') + owner.assertCurrentRevision() + peer.readSerializedState() + peer.writeSerializedDomains([{ domain: 'settings', payload: '{"theme":"light"}' }]) + await expect( + Promise.resolve().then(() => { + if (kind === 'json') { + return owner.writeJsonExport(target) + } + if (kind === 'compatibility-sync') { + return owner.writeJsonCompatibilityExport(target) + } + return owner.writeJsonCompatibilityExportAsync(target) + }) + ).rejects.toBeInstanceOf(ProfileStateRevisionConflictError) + expect(readFileSync(target, 'utf8')).toBe('retained export') + expect(JSON.parse(peer.readSerializedState() ?? '{}').settings.theme).toBe('light') + } finally { + owner.close() + peer.close() + rmSync(root, { recursive: true, force: true }) + } + } + ) +}) diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-equality.test.ts b/src/main/persistence/profile-state/profile-state-automation-runs-equality.test.ts new file mode 100644 index 00000000000..8ac387db9aa --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-equality.test.ts @@ -0,0 +1,177 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { clearProfileStateAutomationRuns } from './profile-state-automation-runs' +import { openProfileStateDatabase } from './profile-state-database' +import { + hashProfileStateJson, + importProfileStateJson, + readProfileStateRevision, + readProfileStateSnapshot +} from './profile-state-documents' +import { writeProfileStateDomains } from './profile-state-domain-writes' + +const databases: { db: ReturnType['db']; directory: string }[] = [] + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-history-equality-')) + const { db } = openProfileStateDatabase(join(directory, 'state.db'), 'profile') + const runs = [{ id: 'run', extension: { content: '雪 🐋' } }] + const payload = JSON.stringify(runs) + importProfileStateJson(db, JSON.stringify({ settings: {}, automationRuns: runs })) + const result = { db, directory, runs, payload } + databases.push(result) + return result +} + +afterEach(() => { + for (const { db, directory } of databases.splice(0)) { + db.close() + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('automation history replacement equality', () => { + it.each([false, true])( + 'keeps revision and timestamp for equal history (whitespace: %s)', + (spaces) => { + const { db, payload, runs } = fixture() + const before = readProfileStateSnapshot(db) + + expect( + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [ + { + domain: 'automationRuns', + payload: spaces ? JSON.stringify(runs, null, 2) : payload, + now: () => { + throw new Error('An unchanged replacement must not request a timestamp') + } + } + ] + }) + ).toEqual({ changed: false, revision: 1, changedDomains: [] }) + expect(readProfileStateSnapshot(db)).toEqual(before) + } + ) + + it.each([ + { + payload: '[{"id":"ignored","id":"run","extension":{"value":1,"value":2}}, {"id":"next"}]', + presence: 'array' + }, + { payload: '[{"id":"same"},{"id":"same","extension":true}]', presence: 'document' }, + { payload: '[{"extension":true}]', presence: 'document' }, + { payload: '[]', presence: 'array' }, + { payload: 'null', presence: 'null' }, + { payload: null, presence: 'absent' } + ])('preserves canonical JSON and storage transitions for $payload', ({ payload, presence }) => { + const { db } = fixture() + + expect( + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [{ domain: 'automationRuns', payload }] + }) + ).toEqual({ changed: true, revision: 2, changedDomains: ['automationRuns'] }) + expect(JSON.parse(readProfileStateSnapshot(db).json)).toEqual({ + settings: {}, + ...(payload === null ? {} : { automationRuns: JSON.parse(payload) }) + }) + expect(db.prepare('SELECT presence FROM profile_state_automation_runs_meta').get()).toEqual({ + presence + }) + if (presence === 'array' || presence === 'null') { + expect( + db.prepare('SELECT content_hash FROM profile_state_automation_runs_meta').get() + ).toEqual({ + content_hash: hashProfileStateJson(JSON.stringify(JSON.parse(payload ?? 'null'))) + }) + } + }) + + it('derives prepared history from the checked payload instead of caller metadata', () => { + const { db } = fixture() + const replacement = { + domain: 'automationRuns', + payload: '[{"id":"actual"}]', + automationRunsValue: [{ id: 'forged' }] + } + + writeProfileStateDomains(db, { expectedRevision: 1, replacements: [replacement] }) + + expect(JSON.parse(readProfileStateSnapshot(db).json)).toEqual({ + settings: {}, + automationRuns: [{ id: 'actual' }] + }) + }) + + it('fences a stale caller even when its history still matches', () => { + const { db, payload } = fixture() + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [{ domain: 'settings', payload: '{"theme":"dark"}' }] + }) + + expect(() => + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [{ domain: 'automationRuns', payload }] + }) + ).toThrow(expect.objectContaining({ code: 'profile-state-revision-conflict' })) + expect(readProfileStateRevision(db)).toBe(2) + }) + + it('rejects malformed JSON before trusting an equal stored hash', () => { + const { db } = fixture() + db.prepare('UPDATE profile_state_automation_runs_meta SET content_hash = ?').run( + hashProfileStateJson('[') + ) + + expect(() => + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [{ domain: 'automationRuns', payload: '[' }] + }) + ).toThrow('Profile state domain payload is invalid JSON: automationRuns') + expect(db.isTransaction).toBe(false) + expect(readProfileStateRevision(db)).toBe(1) + }) + + it('normalizes an equal document payload when normalized storage is not established', () => { + const { db, payload } = fixture() + clearProfileStateAutomationRuns(db) + db.prepare( + "UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = 'automationRuns'" + ).run(payload, hashProfileStateJson(payload)) + const before = readProfileStateSnapshot(db) + + expect( + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [{ domain: 'automationRuns', payload }] + }) + ).toEqual({ changed: true, revision: 2, changedDomains: ['automationRuns'] }) + expect(readProfileStateSnapshot(db).json).toBe(before.json) + expect(db.prepare('SELECT presence FROM profile_state_automation_runs_meta').get()).toEqual({ + presence: 'array' + }) + }) + + it('rejects a corrupt document placeholder before accepting equal normalized history', () => { + const { db, payload } = fixture() + db.prepare( + "UPDATE profile_state_documents SET payload = 'true' WHERE domain = 'automationRuns'" + ).run() + + expect(() => + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [{ domain: 'automationRuns', payload }] + }) + ).toThrow('Profile state document hash mismatch: automationRuns') + expect(readProfileStateRevision(db)).toBe(1) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-migration.ts b/src/main/persistence/profile-state/profile-state-automation-runs-migration.ts new file mode 100644 index 00000000000..adf99619242 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-migration.ts @@ -0,0 +1,46 @@ +import type Database from '../../sqlite/sync-database' +import { + readProfileStateRevision, + assertProfileStateDocumentRevision +} from './profile-state-revision' +import { readProfileStateAutomationRunsDocument } from './profile-state-automation-runs-reader' +import { + ProfileStateDocumentCorruptionError, + validateProfileStateDocumentRow +} from './profile-state-document-validation' +import { + markAutomationRunsDocumentStorage, + compactAutomationRunsDocument +} from './profile-state-automation-runs-storage' + +export function migrateAutomationRunsStorage(db: Database.Database, storedVersion: number): void { + if (storedVersion < 2) { + markAutomationRunsDocumentStorage(db) + } else { + const meta = db + .prepare('SELECT domain FROM profile_state_automation_runs_meta WHERE domain = ?') + .get('automationRuns') + if (meta === undefined) { + // Schema 2 cannot distinguish cleared history from a lost projection marker. + if ( + readProfileStateRevision(db) !== 0 || + db.prepare('SELECT 1 FROM profile_state_documents LIMIT 1').get() !== undefined || + db.prepare('SELECT 1 FROM profile_state_automation_runs LIMIT 1').get() !== undefined + ) { + throw new ProfileStateDocumentCorruptionError( + 'Schema 2 automationRuns storage is ambiguous; restore a validated backup or JSON export', + 'automationRuns' + ) + } + markAutomationRunsDocumentStorage(db) + } + } + const revision = readProfileStateRevision(db) + for (const row of db.prepare('SELECT * FROM profile_state_documents').all()) { + const document = validateProfileStateDocumentRow(row) + assertProfileStateDocumentRevision(document.revision, revision, document.domain) + } + if (readProfileStateAutomationRunsDocument(db, revision) !== undefined) { + compactAutomationRunsDocument(db) + } +} diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-model.ts b/src/main/persistence/profile-state/profile-state-automation-runs-model.ts new file mode 100644 index 00000000000..b02418cf8ea --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-model.ts @@ -0,0 +1,49 @@ +export const PROFILE_STATE_AUTOMATION_RUNS_TABLE = 'profile_state_automation_runs' +export const PROFILE_STATE_AUTOMATION_RUNS_META_TABLE = 'profile_state_automation_runs_meta' + +export const AUTOMATION_RUNS_DOMAIN = 'automationRuns' +export const AUTOMATION_RUNS_ABSENT = 'absent' +export const AUTOMATION_RUNS_NULL = 'null' +export const AUTOMATION_RUNS_ARRAY = 'array' +export const AUTOMATION_RUNS_DOCUMENT = 'document' + +export type AutomationRunsPresence = + | typeof AUTOMATION_RUNS_DOCUMENT + | typeof AUTOMATION_RUNS_ABSENT + | typeof AUTOMATION_RUNS_NULL + | typeof AUTOMATION_RUNS_ARRAY + +export type AutomationRunsMeta = { + presence: AutomationRunsPresence + domainVersion: number + revision: number + updatedAt: number + contentHash: string +} + +export type AutomationRunPayload = { + id: string + ordinal: number + payload: string + contentHash: string +} + +export type NormalizedAutomationRunRow = AutomationRunPayload & { + revision: number + updatedAt: number +} + +export type AutomationRunIdentity = { + id: string + ordinal: number + contentHash: string +} + +export type ParsedAutomationRunsReplacement = + | { presence: typeof AUTOMATION_RUNS_ABSENT; contentHash: ''; runs?: undefined } + | { presence: typeof AUTOMATION_RUNS_NULL; contentHash: string; runs?: undefined } + | { + presence: typeof AUTOMATION_RUNS_ARRAY + contentHash: string + runs: readonly AutomationRunPayload[] + } diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-payload.ts b/src/main/persistence/profile-state/profile-state-automation-runs-payload.ts new file mode 100644 index 00000000000..32da30b8784 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-payload.ts @@ -0,0 +1,72 @@ +import { createHash } from 'node:crypto' +import { hashProfileStatePayload, isRecord } from './profile-state-document-validation' +import { + AUTOMATION_RUNS_ABSENT, + AUTOMATION_RUNS_ARRAY, + AUTOMATION_RUNS_NULL, + type AutomationRunPayload, + type ParsedAutomationRunsReplacement +} from './profile-state-automation-runs-model' + +export function parseAutomationRunsReplacement( + payload: string | null +): ParsedAutomationRunsReplacement | undefined { + if (payload === null) { + return parseAutomationRunsValue(undefined) + } + let parsed: unknown + try { + parsed = JSON.parse(payload) + } catch { + return undefined + } + return parseAutomationRunsValue(parsed) +} + +export function parseAutomationRunsValue( + value: unknown +): ParsedAutomationRunsReplacement | undefined { + if (value === undefined) { + return { presence: AUTOMATION_RUNS_ABSENT, contentHash: '' } + } + if (value === null) { + return { presence: AUTOMATION_RUNS_NULL, contentHash: hashProfileStatePayload('null') } + } + if (!Array.isArray(value)) { + return undefined + } + return parseAutomationRunValues(value) +} + +export function parseAutomationRunValues( + values: readonly unknown[] +): ParsedAutomationRunsReplacement | undefined { + const ids = new Set() + const runs: AutomationRunPayload[] = [] + const aggregate = createHash('sha256').update('[') + for (const [ordinal, value] of values.entries()) { + if (!isRecord(value) || typeof value.id !== 'string' || ids.has(value.id)) { + return undefined + } + const runPayload = JSON.stringify(value) + if (runPayload === undefined) { + return undefined + } + if (ordinal > 0) { + aggregate.update(',') + } + aggregate.update(runPayload, 'utf8') + ids.add(value.id) + runs.push({ + id: value.id, + ordinal, + payload: runPayload, + contentHash: hashProfileStatePayload(runPayload) + }) + } + return { + presence: AUTOMATION_RUNS_ARRAY, + contentHash: aggregate.update(']').digest('hex'), + runs + } +} diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-reader.ts b/src/main/persistence/profile-state/profile-state-automation-runs-reader.ts new file mode 100644 index 00000000000..c8e4087a1a8 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-reader.ts @@ -0,0 +1,151 @@ +import { createHash } from 'node:crypto' +import type Database from '../../sqlite/sync-database' +import { readProfileStateRevision } from './profile-state-revision' +import { + hashProfileStatePayload, + ProfileStateDocumentCorruptionError, + type ProfileStateDocument, + type ProfileStateParsedDocument, + type ProfileStateValidatedDocument +} from './profile-state-document-validation' +import { + AUTOMATION_RUNS_ABSENT, + AUTOMATION_RUNS_DOCUMENT, + AUTOMATION_RUNS_DOMAIN, + AUTOMATION_RUNS_NULL, + PROFILE_STATE_AUTOMATION_RUNS_TABLE, + type AutomationRunsMeta +} from './profile-state-automation-runs-model' +import { + assertNoNormalizedAutomationRuns, + parseNormalizedAutomationRunRow +} from './profile-state-automation-runs-validation' + +import { readCurrentAutomationRunsState } from './profile-state-automation-runs-storage' + +type Representation = 'serialized' | 'parsed' | 'validated' +type ReadDocument = + | ProfileStateDocument + | ProfileStateParsedDocument + | ProfileStateValidatedDocument + +/** Undefined means explicit document storage; null means the domain is absent. */ +export function readProfileStateAutomationRunsDocument( + db: Database.Database, + profileRevision?: number +): ProfileStateDocument | null | undefined +export function readProfileStateAutomationRunsDocument( + db: Database.Database, + profileRevision: number, + representation: 'parsed' +): ProfileStateParsedDocument | null | undefined +export function readProfileStateAutomationRunsDocument( + db: Database.Database, + profileRevision: number, + representation: 'validated' +): ProfileStateValidatedDocument | null | undefined +export function readProfileStateAutomationRunsDocument( + db: Database.Database, + profileRevision = readProfileStateRevision(db), + representation: Representation = 'serialized' +): ReadDocument | null | undefined { + const meta = readCurrentAutomationRunsState(db, profileRevision) + if (meta.presence === AUTOMATION_RUNS_DOCUMENT) { + return undefined + } + if (meta.presence === AUTOMATION_RUNS_ABSENT) { + assertNoNormalizedAutomationRuns(db) + return null + } + if (meta.presence === AUTOMATION_RUNS_NULL) { + if (meta.contentHash !== hashProfileStatePayload('null')) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns null hash mismatch', + AUTOMATION_RUNS_DOMAIN + ) + } + assertNoNormalizedAutomationRuns(db) + return makeAutomationRunsDocument( + meta, + representation === 'validated' + ? {} + : representation === 'parsed' + ? { value: null } + : { payload: 'null' } + ) + } + + // Sort only stable keys; payloads stay outside the sorter and extra columns cannot shadow the key. + const references = db.prepare( + `SELECT run_id FROM ${PROFILE_STATE_AUTOMATION_RUNS_TABLE} ORDER BY ordinal` + ) + const row = db.prepare( + `SELECT run_id, ordinal, payload, content_hash, revision, updated_at FROM ${PROFILE_STATE_AUTOMATION_RUNS_TABLE} WHERE run_id = ?` + ) + const payloads: string[] = [] + const values: unknown[] = [] + const aggregate = createHash('sha256').update('[') + const ids = new Set() + let index = 0 + for (const reference of references.iterate()) { + const parsed = parseNormalizedAutomationRunRow( + row.get(reference.run_id), + representation === 'parsed' + ) + if (parsed.id !== reference.run_id || parsed.ordinal !== index || ids.has(parsed.id)) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns ordering is corrupt', + AUTOMATION_RUNS_DOMAIN + ) + } + if ( + parsed.revision > meta.revision || + (parsed.revision === meta.revision && parsed.updatedAt !== meta.updatedAt) + ) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns row metadata is inconsistent', + AUTOMATION_RUNS_DOMAIN + ) + } + ids.add(parsed.id) + if (index > 0) { + aggregate.update(',') + } + aggregate.update(parsed.payload, 'utf8') + if (representation === 'parsed') { + values.push(parsed.value) + } else if (representation === 'serialized') { + payloads.push(parsed.payload) + } + index++ + } + const contentHash = aggregate.update(']').digest('hex') + if (contentHash !== meta.contentHash) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns aggregate hash mismatch', + AUTOMATION_RUNS_DOMAIN + ) + } + return makeAutomationRunsDocument( + meta, + representation === 'validated' + ? {} + : representation === 'parsed' + ? { value: values } + : { payload: `[${payloads.join(',')}]` } + ) +} + +function makeAutomationRunsDocument( + meta: AutomationRunsMeta, + content: { payload: string } | { value: unknown } | Record +): ReadDocument { + return { + domain: AUTOMATION_RUNS_DOMAIN, + ...content, + domainVersion: meta.domainVersion, + revision: meta.revision, + updatedAt: meta.updatedAt, + contentHash: meta.contentHash + } +} diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-serialization.test.ts b/src/main/persistence/profile-state/profile-state-automation-runs-serialization.test.ts new file mode 100644 index 00000000000..389498aa364 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-serialization.test.ts @@ -0,0 +1,65 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import { openProfileStateDatabase } from './profile-state-database' +import { importProfileStateJson, readProfileStateSnapshot } from './profile-state-documents' +import { writeProfileStateDomains } from './profile-state-domain-writes' + +describe('automation history serialization', () => { + it.each(['import', 'replacement', 'delta'] as const)( + 'preserves JSON ordering, escaping and unknown fields through %s', + (operation) => { + const directory = mkdtempSync(join(tmpdir(), 'orca-automation-run-serialization-')) + const { db } = openProfileStateDatabase(join(directory, 'state.db'), 'profile') + try { + const fixtureRun = buildProfileStateCutoverFixture().automationRuns[0] + if (!fixtureRun) { + throw new Error('Expected an automation run fixture') + } + const runs = [ + { + ...fixtureRun, + id: 'second', + extension: { + '3': 3, + '1': 1, + z: '雪 🐋\ud800', + a: ['\\', '\n', '"', null], + omitted: undefined + } + }, + { ...fixtureRun, id: 'first', extension: { fractional: -0 } } + ] + const settings = { note: 'unchanged' } + const expected = JSON.stringify({ settings, automationRuns: runs }) + importProfileStateJson(db, JSON.stringify({ settings, automationRuns: runs.toReversed() })) + if (operation === 'import') { + importProfileStateJson(db, expected, { expectedRevision: 1 }) + } else { + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: + operation === 'replacement' + ? [{ domain: 'automationRuns', payload: JSON.stringify(runs) }] + : [], + ...(operation === 'delta' ? { automationRunsAfter: runs } : {}) + }) + } + + expect(readProfileStateSnapshot(db)).toMatchObject({ revision: 2, json: expected }) + expect( + writeProfileStateDomains(db, { + expectedRevision: 2, + replacements: [], + automationRunsAfter: runs + }) + ).toEqual({ changed: false, revision: 2, changedDomains: [] }) + } finally { + db.close() + rmSync(directory, { recursive: true, force: true }) + } + } + ) +}) diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-storage.ts b/src/main/persistence/profile-state/profile-state-automation-runs-storage.ts new file mode 100644 index 00000000000..c9ee590d369 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-storage.ts @@ -0,0 +1,49 @@ +import type Database from '../../sqlite/sync-database' +import { hashProfileStatePayload } from './profile-state-document-validation' +import { assertProfileStateDocumentRevision } from './profile-state-revision' +import { + AUTOMATION_RUNS_DOCUMENT, + AUTOMATION_RUNS_DOMAIN, + PROFILE_STATE_AUTOMATION_RUNS_META_TABLE, + type AutomationRunsMeta +} from './profile-state-automation-runs-model' +import { + assertNoNormalizedAutomationRuns, + parseAutomationRunsMeta +} from './profile-state-automation-runs-validation' + +export function readCurrentAutomationRunsState( + db: Database.Database, + actualRevision: number +): AutomationRunsMeta { + const row = db + .prepare( + `SELECT domain, presence, domain_version, revision, updated_at, content_hash + FROM ${PROFILE_STATE_AUTOMATION_RUNS_META_TABLE} WHERE domain = ?` + ) + .get(AUTOMATION_RUNS_DOMAIN) + const meta = parseAutomationRunsMeta(row) + assertProfileStateDocumentRevision(meta.revision, actualRevision, AUTOMATION_RUNS_DOMAIN) + if (meta.presence === AUTOMATION_RUNS_DOCUMENT) { + assertNoNormalizedAutomationRuns(db) + } + return meta +} + +export function markAutomationRunsDocumentStorage(db: Database.Database): void { + db.prepare( + `INSERT INTO ${PROFILE_STATE_AUTOMATION_RUNS_META_TABLE} + (domain, presence, domain_version, revision, updated_at, content_hash) + VALUES (?, ?, 1, 0, 0, '') + ON CONFLICT(domain) DO UPDATE SET presence = excluded.presence, + domain_version = 1, revision = 0, updated_at = 0, content_hash = ''` + ).run(AUTOMATION_RUNS_DOMAIN, AUTOMATION_RUNS_DOCUMENT) +} + +/** Retain the key's JSON position without retaining a second history payload. */ +export function compactAutomationRunsDocument(db: Database.Database): void { + db.prepare( + `UPDATE profile_state_documents SET payload = 'null', content_hash = ? + WHERE domain = ? AND payload <> 'null'` + ).run(hashProfileStatePayload('null'), AUTOMATION_RUNS_DOMAIN) +} diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-validation.ts b/src/main/persistence/profile-state/profile-state-automation-runs-validation.ts new file mode 100644 index 00000000000..98d0f79cc4b --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-validation.ts @@ -0,0 +1,154 @@ +import type Database from '../../sqlite/sync-database' +import { + hashProfileStatePayload, + isRecord, + ProfileStateDocumentCorruptionError +} from './profile-state-document-validation' +import { + AUTOMATION_RUNS_ABSENT, + AUTOMATION_RUNS_ARRAY, + AUTOMATION_RUNS_DOMAIN, + AUTOMATION_RUNS_DOCUMENT, + AUTOMATION_RUNS_NULL, + PROFILE_STATE_AUTOMATION_RUNS_TABLE, + type AutomationRunIdentity, + type AutomationRunsMeta, + type NormalizedAutomationRunRow +} from './profile-state-automation-runs-model' + +export function assertNoNormalizedAutomationRuns(db: Database.Database): void { + const row = db + .prepare(`SELECT COUNT(*) AS count FROM ${PROFILE_STATE_AUTOMATION_RUNS_TABLE}`) + .get() + if (isRecord(row) && row.count !== 0) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns rows exist for an empty domain', + AUTOMATION_RUNS_DOMAIN + ) + } +} + +export function parseAutomationRunsMeta(row: unknown): AutomationRunsMeta { + if ( + !isRecord(row) || + row.domain !== AUTOMATION_RUNS_DOMAIN || + (row.presence !== AUTOMATION_RUNS_ABSENT && + row.presence !== AUTOMATION_RUNS_DOCUMENT && + row.presence !== AUTOMATION_RUNS_NULL && + row.presence !== AUTOMATION_RUNS_ARRAY) || + typeof row.domain_version !== 'number' || + typeof row.revision !== 'number' || + typeof row.updated_at !== 'number' || + typeof row.content_hash !== 'string' + ) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns metadata is malformed', + AUTOMATION_RUNS_DOMAIN + ) + } + if ( + !Number.isSafeInteger(row.domain_version) || + row.domain_version < 1 || + !Number.isSafeInteger(row.revision) || + row.revision < (row.presence === AUTOMATION_RUNS_DOCUMENT ? 0 : 1) || + !Number.isSafeInteger(row.updated_at) || + row.updated_at < 0 || + (row.presence === AUTOMATION_RUNS_ABSENT || row.presence === AUTOMATION_RUNS_DOCUMENT + ? row.content_hash !== '' + : !/^[a-f0-9]{64}$/.test(row.content_hash)) + ) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns metadata is invalid', + AUTOMATION_RUNS_DOMAIN + ) + } + if ( + row.presence === AUTOMATION_RUNS_DOCUMENT && + (row.revision !== 0 || row.updated_at !== 0 || row.domain_version !== 1) + ) { + throw new ProfileStateDocumentCorruptionError( + 'AutomationRuns document storage marker is invalid', + AUTOMATION_RUNS_DOMAIN + ) + } + return { + presence: row.presence, + domainVersion: row.domain_version, + revision: row.revision, + updatedAt: row.updated_at, + contentHash: row.content_hash + } +} + +export function parseNormalizedAutomationRunRow( + row: unknown, + retainParsedValue = false +): NormalizedAutomationRunRow & { value?: unknown } { + if ( + !isRecord(row) || + typeof row.run_id !== 'string' || + typeof row.ordinal !== 'number' || + typeof row.payload !== 'string' || + typeof row.content_hash !== 'string' || + typeof row.revision !== 'number' || + typeof row.updated_at !== 'number' || + !Number.isSafeInteger(row.ordinal) || + row.ordinal < 0 || + !Number.isSafeInteger(row.revision) || + row.revision < 1 || + !Number.isSafeInteger(row.updated_at) || + row.updated_at < 0 || + hashProfileStatePayload(row.payload) !== row.content_hash + ) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns row is corrupt', + AUTOMATION_RUNS_DOMAIN + ) + } + // Individually invalid fragments can splice into a valid aggregate with matching IDs. + let parsed: unknown + try { + parsed = JSON.parse(row.payload) + } catch { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns row is invalid JSON', + AUTOMATION_RUNS_DOMAIN + ) + } + if (!isRecord(parsed) || parsed.id !== row.run_id) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns row identity is corrupt', + AUTOMATION_RUNS_DOMAIN + ) + } + return { + id: row.run_id, + ordinal: row.ordinal, + payload: row.payload, + contentHash: row.content_hash, + revision: row.revision, + updatedAt: row.updated_at, + ...(retainParsedValue ? { value: parsed } : {}) + } +} + +export function parseAutomationRunIdentity(row: unknown): AutomationRunIdentity { + if ( + !isRecord(row) || + typeof row.run_id !== 'string' || + typeof row.ordinal !== 'number' || + typeof row.content_hash !== 'string' || + !Number.isSafeInteger(row.ordinal) || + row.ordinal < 0 + ) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns row is malformed', + AUTOMATION_RUNS_DOMAIN + ) + } + return { + id: row.run_id, + ordinal: row.ordinal, + contentHash: row.content_hash + } +} diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-writer.ts b/src/main/persistence/profile-state/profile-state-automation-runs-writer.ts new file mode 100644 index 00000000000..31f4eb4f1d1 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-writer.ts @@ -0,0 +1,158 @@ +import type Database from '../../sqlite/sync-database' +import type { PreparedProfileStateMutation } from './profile-state-domain-write-validation' +import { + AUTOMATION_RUNS_ARRAY, + AUTOMATION_RUNS_DOMAIN, + PROFILE_STATE_AUTOMATION_RUNS_META_TABLE, + PROFILE_STATE_AUTOMATION_RUNS_TABLE, + type AutomationRunIdentity, + type ParsedAutomationRunsReplacement +} from './profile-state-automation-runs-model' +import { + parseAutomationRunValues, + parseAutomationRunsReplacement, + parseAutomationRunsValue +} from './profile-state-automation-runs-payload' +import { parseAutomationRunIdentity } from './profile-state-automation-runs-validation' +import { + readCurrentAutomationRunsState, + compactAutomationRunsDocument +} from './profile-state-automation-runs-storage' + +export type AutomationRunsWritePreparation = { + changed: boolean + incoming: ParsedAutomationRunsReplacement + domainVersion: number + now?: () => number +} + +export function rebuildProfileStateAutomationRunsProjection( + db: Database.Database, + payload: string, + domainVersion: number, + updatedAt: number, + revision: number +): boolean { + const incoming = parseAutomationRunsReplacement(payload) + if (incoming === undefined) { + return false + } + const now = resolveAutomationRunsTimestamp(() => updatedAt) + applyIncomingAutomationRuns(db, incoming, domainVersion, now, revision) + return true +} + +export function prepareProfileStateAutomationRunsReplacement( + db: Database.Database, + replacement: PreparedProfileStateMutation, + actualRevision: number +): AutomationRunsWritePreparation | undefined { + const current = readCurrentAutomationRunsState(db, actualRevision) + const incoming = parseAutomationRunsValue(replacement.automationRunsValue) + if (incoming === undefined) { + return undefined + } + return { + changed: current.presence !== incoming.presence || current.contentHash !== incoming.contentHash, + incoming, + domainVersion: replacement.domainVersion, + now: replacement.now + } +} + +export function prepareProfileStateAutomationRunsDelta( + db: Database.Database, + after: readonly unknown[], + domainVersion: number, + now: () => number, + actualRevision: number +): AutomationRunsWritePreparation | undefined { + const incoming = parseAutomationRunValues(after) + if (incoming === undefined) { + return undefined + } + const current = readCurrentAutomationRunsState(db, actualRevision) + return { + changed: current.presence !== incoming.presence || current.contentHash !== incoming.contentHash, + incoming, + domainVersion, + now + } +} + +export function applyProfileStateAutomationRuns( + db: Database.Database, + preparation: AutomationRunsWritePreparation, + nextRevision: number +): void { + const now = resolveAutomationRunsTimestamp(preparation.now ?? Date.now) + applyIncomingAutomationRuns( + db, + preparation.incoming, + preparation.domainVersion, + now, + nextRevision + ) +} + +function applyIncomingAutomationRuns( + db: Database.Database, + incoming: ParsedAutomationRunsReplacement, + domainVersion: number, + now: number, + nextRevision: number +): void { + const existingRows = new Map() + for (const row of db + .prepare(`SELECT run_id, ordinal, content_hash FROM ${PROFILE_STATE_AUTOMATION_RUNS_TABLE}`) + .all()) { + const parsed = parseAutomationRunIdentity(row) + existingRows.set(parsed.id, parsed) + } + + const upsert = db.prepare( + `INSERT INTO ${PROFILE_STATE_AUTOMATION_RUNS_TABLE} + (run_id, ordinal, payload, content_hash, revision, updated_at) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(run_id) DO UPDATE SET ordinal = excluded.ordinal, + payload = excluded.payload, content_hash = excluded.content_hash, + revision = excluded.revision, updated_at = excluded.updated_at` + ) + if (incoming.presence === AUTOMATION_RUNS_ARRAY) { + for (const run of incoming.runs) { + const existing = existingRows.get(run.id) + existingRows.delete(run.id) + if (existing?.ordinal === run.ordinal && existing.contentHash === run.contentHash) { + continue + } + upsert.run(run.id, run.ordinal, run.payload, run.contentHash, nextRevision, now) + } + } + const remove = db.prepare(`DELETE FROM ${PROFILE_STATE_AUTOMATION_RUNS_TABLE} WHERE run_id = ?`) + for (const id of existingRows.keys()) { + remove.run(id) + } + + db.prepare( + `INSERT INTO ${PROFILE_STATE_AUTOMATION_RUNS_META_TABLE} + (domain, presence, domain_version, revision, updated_at, content_hash) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(domain) DO UPDATE SET presence = excluded.presence, + domain_version = excluded.domain_version, revision = excluded.revision, + updated_at = excluded.updated_at, content_hash = excluded.content_hash` + ).run( + AUTOMATION_RUNS_DOMAIN, + incoming.presence, + domainVersion, + nextRevision, + now, + incoming.contentHash + ) + compactAutomationRunsDocument(db) +} + +function resolveAutomationRunsTimestamp(nowFactory: () => number): number { + const now = nowFactory() + if (!Number.isSafeInteger(now) || now < 0) { + throw new Error('Profile state domain update timestamp is invalid: automationRuns') + } + return now +} diff --git a/src/main/persistence/profile-state/profile-state-automation-runs.ts b/src/main/persistence/profile-state/profile-state-automation-runs.ts new file mode 100644 index 00000000000..8b81cf07019 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs.ts @@ -0,0 +1,22 @@ +import type Database from '../../sqlite/sync-database' +import { markAutomationRunsDocumentStorage } from './profile-state-automation-runs-storage' +import { PROFILE_STATE_AUTOMATION_RUNS_TABLE } from './profile-state-automation-runs-model' + +export { + PROFILE_STATE_AUTOMATION_RUNS_META_TABLE, + PROFILE_STATE_AUTOMATION_RUNS_TABLE +} from './profile-state-automation-runs-model' +export type { AutomationRunPayload } from './profile-state-automation-runs-model' +export type { AutomationRunsWritePreparation } from './profile-state-automation-runs-writer' +export { + applyProfileStateAutomationRuns, + prepareProfileStateAutomationRunsDelta, + prepareProfileStateAutomationRunsReplacement, + rebuildProfileStateAutomationRunsProjection +} from './profile-state-automation-runs-writer' +export { readProfileStateAutomationRunsDocument } from './profile-state-automation-runs-reader' + +export function clearProfileStateAutomationRuns(db: Database.Database): void { + db.prepare(`DELETE FROM ${PROFILE_STATE_AUTOMATION_RUNS_TABLE}`).run() + markAutomationRunsDocumentStorage(db) +} diff --git a/src/main/persistence/profile-state/profile-state-automation-storage-upgrade.test.ts b/src/main/persistence/profile-state/profile-state-automation-storage-upgrade.test.ts new file mode 100644 index 00000000000..bb28c491f0e --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-storage-upgrade.test.ts @@ -0,0 +1,380 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import Database from '../../sqlite/sync-database' +import { openProfileStateDatabase, profileStatePragmaNumber } from './profile-state-database' +import { PROFILE_STATE_DATABASE_SCHEMA_VERSION } from './profile-state-database-schema' +import { hashProfileStatePayload } from './profile-state-document-validation' +import { exportProfileStateJson, importProfileStateJson } from './profile-state-documents' +import { readProfileStateDomain } from './profile-state-domain-reader' +import { writeProfileStateDomain } from './profile-state-domain-writes' + +const directories: string[] = [] +const connections: Database.Database[] = [] +const staleRuns = [{ id: 'deleted-run', status: 'running', output: 'stale history'.repeat(1_000) }] +const liveRuns = [{ id: 'live-run', status: 'completed', future: { retained: true } }] + +afterEach(() => { + for (const db of connections.splice(0)) { + db.close() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function databasePath(): string { + const directory = mkdtempSync(join(tmpdir(), 'orca-automation-upgrade-')) + directories.push(directory) + return join(directory, 'profile-state.db') +} + +function openDatabase(path: string): Database.Database { + const { db } = openProfileStateDatabase(path, 'profile-a') + connections.push(db) + return db +} + +function expectRejectedDatabaseUntouched(path: string, profileId = 'profile-a'): void { + const before = readFileSync(path) + expect(() => { + const { db } = openProfileStateDatabase(path, profileId) + connections.push(db) + }).toThrowError( + expect.objectContaining({ + code: profileId === 'profile-a' ? 'unreadable' : 'identity-mismatch' + }) + ) + expect(readFileSync(path)).toEqual(before) +} + +type LegacyProjection = { + presence: 'array' | 'null' | 'absent' + runs?: readonly { id: string; [key: string]: unknown }[] +} + +function seedLegacyDatabase( + version: 1 | 2, + root: Record, + projection?: LegacyProjection, + revision = projection ? 2 : 1 +): string { + const path = databasePath() + const db = new Database(path) + try { + db.exec(` + CREATE TABLE profile_state_meta (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL); + CREATE TABLE profile_state_documents ( + domain TEXT PRIMARY KEY NOT NULL, payload TEXT NOT NULL, + domain_version INTEGER NOT NULL, revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL, content_hash TEXT NOT NULL + ); + `) + db.prepare('INSERT INTO profile_state_meta VALUES (?, ?)').run('profile_id', 'profile-a') + db.prepare('INSERT INTO profile_state_meta VALUES (?, ?)').run('revision', String(revision)) + for (const [domain, value] of Object.entries(root)) { + const payload = JSON.stringify(value) + db.prepare('INSERT INTO profile_state_documents VALUES (?, ?, 1, 1, 100, ?)').run( + domain, + payload, + hashProfileStatePayload(payload) + ) + } + if (version === 2) { + db.exec(` + CREATE TABLE profile_state_automation_runs_meta ( + domain TEXT PRIMARY KEY NOT NULL, presence TEXT NOT NULL, + domain_version INTEGER NOT NULL, revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL, content_hash TEXT NOT NULL + ); + CREATE TABLE profile_state_automation_runs ( + run_id TEXT PRIMARY KEY NOT NULL, ordinal INTEGER NOT NULL, + payload TEXT NOT NULL, content_hash TEXT NOT NULL, + revision INTEGER NOT NULL, updated_at INTEGER NOT NULL + ); + `) + if (projection) { + const runs = projection.runs ?? [] + const payload = projection.presence === 'array' ? JSON.stringify(runs) : 'null' + db.prepare( + 'INSERT INTO profile_state_automation_runs_meta VALUES (?, ?, 1, 2, 200, ?)' + ).run( + 'automationRuns', + projection.presence, + projection.presence === 'absent' ? '' : hashProfileStatePayload(payload) + ) + for (const [ordinal, run] of runs.entries()) { + const runPayload = JSON.stringify(run) + db.prepare('INSERT INTO profile_state_automation_runs VALUES (?, ?, ?, ?, 2, 200)').run( + run.id, + ordinal, + runPayload, + hashProfileStatePayload(runPayload) + ) + } + } + } + db.pragma(`user_version = ${version}`) + } finally { + db.close() + } + return path +} + +describe('automation storage schema upgrades', () => { + it.each([ + { label: 'supported runs', value: liveRuns }, + { label: 'explicit null', value: null }, + { label: 'unknown object', value: { futureFormat: [1, 2] } }, + { label: 'duplicate identifiers', value: [{ id: 'same' }, { id: 'same', future: true }] } + ])('preserves version 1 $label and domain ordering', ({ value }) => { + const root = { settings: { theme: 'dark' }, automationRuns: value, futureDomain: [2, 1] } + const db = openDatabase(seedLegacyDatabase(1, root)) + + expect(profileStatePragmaNumber(db, 'user_version')).toBe(PROFILE_STATE_DATABASE_SCHEMA_VERSION) + expect(exportProfileStateJson(db)).toBe(JSON.stringify(root)) + expect(db.prepare('SELECT presence FROM profile_state_automation_runs_meta').get()).toEqual({ + presence: 'document' + }) + expect(db.prepare('SELECT COUNT(*) AS count FROM profile_state_automation_runs').get()).toEqual( + { + count: 0 + } + ) + }) + + it('normalizes a version 1 document on replacement without duplicating its payload', () => { + const root = { settings: {}, automationRuns: liveRuns, futureDomain: [2, 1] } + const db = openDatabase(seedLegacyDatabase(1, root)) + + expect( + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: JSON.stringify(liveRuns), + expectedRevision: 1 + }) + ).toEqual({ changed: true, revision: 2 }) + + expect(exportProfileStateJson(db)).toBe(JSON.stringify(root)) + expect( + db + .prepare('SELECT payload FROM profile_state_documents WHERE domain = ?') + .get('automationRuns') + ).toEqual({ payload: 'null' }) + }) + + it.each([ + { presence: 'array', runs: liveRuns, expected: liveRuns }, + { presence: 'array', runs: [], expected: [] }, + { presence: 'null', expected: null }, + { presence: 'absent', expected: undefined } + ] as const)( + 'keeps version 2 $presence authority while removing stale retained history', + (value) => { + const root = { settings: {}, automationRuns: staleRuns, futureDomain: { kept: true } } + const path = seedLegacyDatabase(2, root, value) + const db = openDatabase(path) + const expected = { ...root, automationRuns: value.expected } + + expect(exportProfileStateJson(db)).toBe(JSON.stringify(expected)) + expect(readProfileStateDomain(path, 'profile-a', 'automationRuns')).toEqual( + value.expected === undefined + ? { kind: 'missing' } + : { kind: 'value', value: value.expected } + ) + expect( + db + .prepare('SELECT payload FROM profile_state_documents WHERE domain = ?') + .get('automationRuns') + ).toEqual({ payload: 'null' }) + expect(profileStatePragmaNumber(db, 'user_version')).toBe( + PROFILE_STATE_DATABASE_SCHEMA_VERSION + ) + } + ) + + it.each([ + { label: 'stale run array', root: { automationRuns: staleRuns } }, + { label: 'empty array', root: { automationRuns: [] } }, + { label: 'explicit null', root: { automationRuns: null } }, + { label: 'unknown value', root: { automationRuns: { futureFormat: true } } }, + { label: 'absent domain', root: {} } + ])('refuses ambiguous version 2 $label without rewriting it', ({ root }) => { + const path = seedLegacyDatabase(2, root) + expectRejectedDatabaseUntouched(path) + const db = new Database(path) + connections.push(db) + expect(profileStatePragmaNumber(db, 'user_version')).toBe(2) + expect(db.prepare('SELECT domain FROM profile_state_automation_runs_meta').all()).toEqual([]) + expect( + db.prepare('SELECT domain, payload FROM profile_state_documents ORDER BY rowid').all() + ).toEqual( + Object.entries(root).map(([domain, value]) => ({ domain, payload: JSON.stringify(value) })) + ) + }) + + it('upgrades a genuinely empty version 2 database', () => { + const db = openDatabase(seedLegacyDatabase(2, {}, undefined, 0)) + expect(exportProfileStateJson(db)).toBe('{}') + expect(importProfileStateJson(db, JSON.stringify({ automationRuns: liveRuns }))).toBe(1) + expect(JSON.parse(exportProfileStateJson(db))).toEqual({ automationRuns: liveRuns }) + }) + + it.each(['legacy document', 'normalized row', 'projection metadata'])( + 'rolls back version 2 migration with corrupt %s', + (target) => { + const path = seedLegacyDatabase( + 2, + { automationRuns: staleRuns }, + { presence: 'array', runs: liveRuns } + ) + const tampered = new Database(path) + try { + if (target === 'legacy document') { + tampered.exec("UPDATE profile_state_documents SET content_hash = 'broken'") + } else if (target === 'normalized row') { + tampered.exec("UPDATE profile_state_automation_runs SET content_hash = 'broken'") + } else { + tampered.exec("UPDATE profile_state_automation_runs_meta SET content_hash = 'broken'") + } + } finally { + tampered.close() + } + + expectRejectedDatabaseUntouched(path) + const db = new Database(path) + connections.push(db) + expect(profileStatePragmaNumber(db, 'user_version')).toBe(2) + expect( + db + .prepare('SELECT payload FROM profile_state_documents WHERE domain = ?') + .get('automationRuns') + ).toEqual({ payload: JSON.stringify(staleRuns) }) + } + ) +}) + +describe('rejected schema upgrades preserve source bytes', () => { + it.each([1, 2] as const)('rejects another profile in schema %s before migration', (version) => { + const path = seedLegacyDatabase( + version, + { automationRuns: staleRuns }, + { presence: 'array', runs: liveRuns } + ) + expectRejectedDatabaseUntouched(path, 'profile-b') + }) + + it.each([1, 2] as const)( + 'rejects an incompatible schema %s document column before migration', + (version) => { + const path = seedLegacyDatabase( + version, + { automationRuns: staleRuns }, + { presence: 'array', runs: liveRuns } + ) + const db = new Database(path) + try { + db.exec(` + ALTER TABLE profile_state_documents RENAME TO old_documents; + CREATE TABLE profile_state_documents ( + domain TEXT PRIMARY KEY NOT NULL, payload BLOB NOT NULL, + domain_version INTEGER NOT NULL, revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL, content_hash TEXT NOT NULL + ); + INSERT INTO profile_state_documents SELECT * FROM old_documents; + DROP TABLE old_documents; + `) + } finally { + db.close() + } + expectRejectedDatabaseUntouched(path) + } + ) + + it.each([ + { label: 'negative schema version', sql: 'PRAGMA user_version = -1' }, + { label: 'missing run table', sql: 'DROP TABLE profile_state_automation_runs' }, + { label: 'missing metadata table', sql: 'DROP TABLE profile_state_automation_runs_meta' } + ])('rejects a version 2 database with $label before migration', ({ sql }) => { + const path = seedLegacyDatabase( + 2, + { automationRuns: staleRuns }, + { presence: 'array', runs: liveRuns } + ) + const db = new Database(path) + try { + db.exec(sql) + } finally { + db.close() + } + expectRejectedDatabaseUntouched(path) + }) +}) + +describe('required automation storage metadata', () => { + it.each([ + { label: 'cleared array', payload: '[]' }, + { label: 'explicit null', payload: 'null' }, + { label: 'removed domain', payload: null } + ])('refuses lost metadata after $label instead of resurrecting retained state', ({ payload }) => { + const path = databasePath() + const db = openDatabase(path) + importProfileStateJson(db, JSON.stringify({ automationRuns: staleRuns })) + writeProfileStateDomain(db, { domain: 'automationRuns', payload, expectedRevision: 1 }) + db.exec('DELETE FROM profile_state_automation_runs_meta') + + expect(() => exportProfileStateJson(db)).toThrow() + expect(readProfileStateDomain(path, 'profile-a', 'automationRuns')).toMatchObject({ + kind: 'unreadable' + }) + expect(() => + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: JSON.stringify(liveRuns), + expectedRevision: 2 + }) + ).toThrow() + expect(db.prepare('SELECT COUNT(*) AS count FROM profile_state_automation_runs').get()).toEqual( + { count: 0 } + ) + }) + + it.each(['revision = 1', 'updated_at = 1', 'domain_version = 2', "content_hash = 'unexpected'"])( + 'rejects a malformed document marker (%s)', + (assignment) => { + const db = openDatabase(databasePath()) + importProfileStateJson(db, JSON.stringify({ automationRuns: { futureFormat: true } })) + db.exec(`UPDATE profile_state_automation_runs_meta SET ${assignment}`) + expect(() => exportProfileStateJson(db)).toThrow() + } + ) + + it('keeps one authority through complete imports of supported, unknown, and absent history', () => { + const db = openDatabase(databasePath()) + for (const root of [ + { settings: {}, automationRuns: liveRuns }, + { settings: {}, automationRuns: { futureFormat: [2, 1] } }, + { settings: {} }, + { settings: {}, automationRuns: null }, + { settings: {}, automationRuns: [] } + ]) { + importProfileStateJson(db, JSON.stringify(root)) + expect(exportProfileStateJson(db)).toBe(JSON.stringify(root)) + expect( + db.prepare('SELECT COUNT(*) AS count FROM profile_state_automation_runs_meta').get() + ).toEqual({ count: 1 }) + } + }) + + it('rejects reopening a current database with lost metadata before changing its bytes', () => { + const path = databasePath() + const { db } = openProfileStateDatabase(path, 'profile-a') + db.exec('DELETE FROM profile_state_automation_runs_meta') + db.close() + const before = readFileSync(path) + + expect(() => openDatabase(path)).toThrow() + expect(readFileSync(path)).toEqual(before) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-backup-job.ts b/src/main/persistence/profile-state/profile-state-backup-job.ts new file mode 100644 index 00000000000..f10ef15368c --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-job.ts @@ -0,0 +1,33 @@ +import { openProfileStateDatabaseReadOnly } from './profile-state-database' +import { writeProfileStateDatabaseSnapshotAsync } from './profile-state-database-snapshot' +import { validateProfileStateSnapshot } from './profile-state-documents' + +export type ProfileStateBackupJob = { + databasePath: string + profileId: string + targetPath: string + temporaryPath?: string +} + +/** Own every connection until the copy and its strict validation finish. */ +export async function writeProfileStateBackup(job: ProfileStateBackupJob): Promise { + const opened = openProfileStateDatabaseReadOnly(job.databasePath, job.profileId) + try { + await writeProfileStateDatabaseSnapshotAsync(opened.db, job.targetPath, { + temporaryPath: job.temporaryPath, + validateStagedSnapshot: (stagingPath) => + validateProfileStateBackup(stagingPath, job.profileId) + }) + } finally { + opened.db.close() + } +} + +function validateProfileStateBackup(path: string, profileId: string): void { + const snapshot = openProfileStateDatabaseReadOnly(path, profileId) + try { + validateProfileStateSnapshot(snapshot.db) + } finally { + snapshot.db.close() + } +} diff --git a/src/main/persistence/profile-state/profile-state-backup-path.test.ts b/src/main/persistence/profile-state/profile-state-backup-path.test.ts new file mode 100644 index 00000000000..593d490ce11 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-path.test.ts @@ -0,0 +1,76 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupFiles, + profileStateDatabaseBackupPath, + profileStateDatabaseBackups +} from './profile-state-backup-path' + +const directories: string[] = [] +afterEach(() => { + for (const path of directories.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) + +function location(): string { + const directory = mkdtempSync(join(tmpdir(), 'orca-backup-path-')) + directories.push(directory) + return join(directory, 'profile-state.db') +} + +describe('profile state database backup discovery', () => { + it('lists immutable backup IDs newest-first without opening SQLite', () => { + const path = location() + const first = createProfileStateDatabaseBackupId(1_000) + const last = createProfileStateDatabaseBackupId(2_000) + writeFileSync(profileStateDatabaseBackupPath(path, first), 'first') + writeFileSync(profileStateDatabaseBackupPath(path, last), 'second') + writeFileSync(`${path}.backup.${last}.db.tmp`, 'incomplete staging') + writeFileSync(`${path}.backup.invalid.db`, 'unrelated') + expect(profileStateDatabaseBackups(path)).toEqual([ + { id: last, path: profileStateDatabaseBackupPath(path, last), createdAtMs: 2_000 }, + { id: first, path: profileStateDatabaseBackupPath(path, first), createdAtMs: 1_000 } + ]) + }) + + it('keeps reserved artifact names visible when their type needs recovery', () => { + const path = location() + const id = createProfileStateDatabaseBackupId() + mkdirSync(profileStateDatabaseBackupPath(path, id)) + expect(profileStateDatabaseBackups(path).map((backup) => backup.id)).toEqual([id]) + }) + + it('includes every existing backup sidecar in the recovery archive inventory', () => { + const path = location() + const backup = profileStateDatabaseBackupPath(path, createProfileStateDatabaseBackupId()) + const files = [backup, `${backup}-wal`, `${backup}-shm`, `${backup}-journal`] + for (const file of files) { + writeFileSync(file, 'recovery evidence') + } + expect(profileStateDatabaseBackupFiles(path)).toEqual(files) + }) + + it.each([ + '../profile-state.db', + '1-../../outside', + '0-00000000-0000-4000-8000-000000000000', + '9007199254740992-00000000-0000-4000-8000-000000000000' + ])('rejects invalid or escaping IDs: %s', (id) => + expect(() => profileStateDatabaseBackupPath(location(), id)).toThrow('ID is invalid') + ) + + it.each([0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1])('rejects unsafe backup times: %s', (time) => { + expect(() => createProfileStateDatabaseBackupId(time)).toThrow('positive safe integer') + }) + + it('treats only a missing directory as an empty recovery inventory', () => { + const path = location() + expect(profileStateDatabaseBackups(join(path, 'profile-state.db'))).toEqual([]) + writeFileSync(path, 'not a directory') + expect(() => profileStateDatabaseBackups(join(path, 'profile-state.db'))).toThrow() + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-backup-path.ts b/src/main/persistence/profile-state/profile-state-backup-path.ts new file mode 100644 index 00000000000..c85029947c6 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-path.ts @@ -0,0 +1,88 @@ +import { profileStateDatabaseFiles } from './profile-state-storage-classification' +import { randomUUID } from 'node:crypto' +import { existsSync, readdirSync } from 'node:fs' +import { readdir } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' + +const BACKUP_ID_PATTERN = + /^([1-9]\d*)-([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/ + +export type ProfileStateDatabaseBackup = { + id: string + path: string + createdAtMs: number +} + +export function createProfileStateDatabaseBackupId(now = Date.now()): string { + if (!Number.isSafeInteger(now) || now <= 0) { + throw new Error('Profile state backup timestamp must be a positive safe integer') + } + return `${now}-${randomUUID()}` +} + +export function profileStateDatabaseBackupPath(databaseFile: string, id: string): string { + if (parseBackupCreatedAt(id) === undefined) { + throw new Error('Profile state backup ID is invalid') + } + return `${databaseFile}.backup.${id}.db` +} + +/** Enumerate immutable recovery artifacts without opening the possibly damaged primary. */ +export function profileStateDatabaseBackups( + databaseFile: string +): readonly ProfileStateDatabaseBackup[] { + let entries: string[] + try { + entries = readdirSync(dirname(databaseFile)) + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return [] + } + throw error + } + return parseBackupEntries(databaseFile, entries) +} + +export async function profileStateDatabaseBackupsAsync( + databaseFile: string +): Promise { + try { + return parseBackupEntries(databaseFile, await readdir(dirname(databaseFile))) + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return [] + } + throw error + } +} + +function parseBackupEntries( + databaseFile: string, + entries: readonly string[] +): readonly ProfileStateDatabaseBackup[] { + const directory = dirname(databaseFile) + const prefix = `${basename(databaseFile)}.backup.` + return entries + .flatMap((name) => { + if (typeof name !== 'string' || !name.startsWith(prefix) || !name.endsWith('.db')) { + return [] + } + const id = name.slice(prefix.length, -3) + const createdAtMs = parseBackupCreatedAt(id) + return createdAtMs === undefined ? [] : [{ id, path: join(directory, name), createdAtMs }] + }) + .sort((left, right) => right.createdAtMs - left.createdAtMs || right.id.localeCompare(left.id)) +} + +function parseBackupCreatedAt(id: string): number | undefined { + const match = BACKUP_ID_PATTERN.exec(id) + const timestamp = match ? Number(match[1]) : 0 + return Number.isSafeInteger(timestamp) && timestamp > 0 ? timestamp : undefined +} + +/** Preserve sidecars too if an external writer has opened an otherwise immutable backup. */ +export function profileStateDatabaseBackupFiles(databaseFile: string): readonly string[] { + return profileStateDatabaseBackups(databaseFile).flatMap(({ path }) => + profileStateDatabaseFiles(path).filter(existsSync) + ) +} diff --git a/src/main/persistence/profile-state/profile-state-backup-rotation.test.ts b/src/main/persistence/profile-state/profile-state-backup-rotation.test.ts new file mode 100644 index 00000000000..eb99b1fd128 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-rotation.test.ts @@ -0,0 +1,278 @@ +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import * as fsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ProfileStateBackupRotation } from './profile-state-backup-rotation' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath, + profileStateDatabaseBackups +} from './profile-state-backup-path' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from './profile-state-database' +import { importProfileStateJson, readProfileStateSnapshot } from './profile-state-documents' +import * as backupExecution from './profile-state-backup-worker' + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual } +}) + +const directories: string[] = [] +const rotations: ProfileStateBackupRotation[] = [] +const databases: ReturnType[] = [] +const HOUR = 60 * 60 * 1000 + +beforeEach(() => { + vi.spyOn(backupExecution, 'runProfileStateBackup') +}) + +afterEach(async () => { + for (const rotation of rotations.splice(0)) { + rotation.stop() + await rotation.drain() + } + for (const opened of databases.splice(0)) { + opened.db.close() + } + vi.restoreAllMocks() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-sqlite-backup-')) + directories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const opened = openProfileStateDatabase(databasePath, 'backup-profile') + databases.push(opened) + const clock = { now: Date.now() } + const rotation = createRotation(databasePath, () => clock.now) + const write = (generation: number) => { + importProfileStateJson(opened.db, JSON.stringify({ settings: { generation } })) + } + write(1) + return { directory, databasePath, opened, rotation, write, clock } +} + +function createRotation(databasePath: string, now: () => number = Date.now) { + const rotation = new ProfileStateBackupRotation(databasePath, 'backup-profile', now) + rotations.push(rotation) + return rotation +} + +function readBackup(path: string) { + const opened = openProfileStateDatabaseReadOnly(path, 'backup-profile') + try { + return JSON.parse(readProfileStateSnapshot(opened.db).json) + } finally { + opened.db.close() + } +} + +describe('automatic SQLite recovery generations', () => { + it('copies committed WAL state once, without a live JSON writer or snapshot sidecars', async () => { + const { directory, databasePath, rotation, write } = fixture() + write(2) + rotation.schedule() + rotation.schedule() + await rotation.drain() + const backups = profileStateDatabaseBackups(databasePath) + expect(backups).toHaveLength(1) + expect(readBackup(backups[0].path)).toEqual({ settings: { generation: 2 } }) + expect(existsSync(join(directory, 'orca-data.json'))).toBe(false) + expect(readdirSync(directory).filter((name) => name.includes('.backup.'))).toEqual([ + backups[0].path.slice(directory.length + 1) + ]) + }) + + it('keeps five hourly generations and remembers cadence across reopen', async () => { + const { databasePath, rotation, write, clock } = fixture() + const beginning = Date.now() + clock.now = beginning + for (let generation = 1; generation <= 7; generation++) { + clock.now = beginning + (generation - 1) * HOUR + write(generation) + rotation.schedule() + await rotation.drain() + } + const backups = profileStateDatabaseBackups(databasePath) + expect(backups).toHaveLength(5) + expect(backups.map(({ path }) => readBackup(path).settings.generation)).toEqual([7, 6, 5, 4, 3]) + const reopened = createRotation(databasePath, () => clock.now) + reopened.schedule() + await reopened.drain() + expect(profileStateDatabaseBackups(databasePath)).toEqual(backups) + clock.now = beginning + 7 * HOUR - 1 + reopened.schedule() + await reopened.drain() + expect(profileStateDatabaseBackups(databasePath)).toEqual(backups) + clock.now = beginning + 7 * HOUR + write(8) + reopened.schedule() + await reopened.drain() + expect( + profileStateDatabaseBackups(databasePath).map( + ({ path }) => readBackup(path).settings.generation + ) + ).toEqual([8, 7, 6, 5, 4]) + }) + + it('preserves all earlier backups on failure and retries without rejecting a committed write', async () => { + const { databasePath, rotation, opened, write, clock } = fixture() + const beginning = Date.now() + clock.now = beginning + rotation.schedule() + await rotation.drain() + const retained = profileStateDatabaseBackups(databasePath) + const snapshot = vi + .spyOn(backupExecution, 'runProfileStateBackup') + .mockClear() + .mockRejectedValueOnce(new Error('disk full')) + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + clock.now = beginning + HOUR + write(2) + rotation.schedule() + await expect(rotation.drain()).resolves.toBeUndefined() + expect(JSON.parse(readProfileStateSnapshot(opened.db).json).settings.generation).toBe(2) + expect(profileStateDatabaseBackups(databasePath)).toEqual(retained) + expect(log).toHaveBeenCalledOnce() + rotation.schedule() + await rotation.drain() + expect(snapshot).toHaveBeenCalledOnce() + clock.now = beginning + HOUR + 60_000 + rotation.schedule() + await rotation.drain() + expect(profileStateDatabaseBackups(databasePath)).toHaveLength(2) + }) + + it('does not prune previous generations when the new copy fails strict validation', async () => { + const { databasePath, rotation, clock } = fixture() + const beginning = Date.now() + clock.now = beginning + rotation.schedule() + await rotation.drain() + const retained = profileStateDatabaseBackups(databasePath) + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(backupExecution, 'runProfileStateBackup') + .mockClear() + .mockRejectedValueOnce(new Error('staged validation failed')) + clock.now = beginning + HOUR + rotation.schedule() + await rotation.drain() + expect(profileStateDatabaseBackups(databasePath)).toEqual(retained) + }) + + it('cancels a queued backup before opening a source after Store close', async () => { + const { databasePath, rotation } = fixture() + rotation.schedule() + expect(() => rotation.assertIdle()).toThrow('Flush pending') + rotation.stop() + expect(() => rotation.assertIdle()).toThrow('Flush pending') + await rotation.drain() + expect(() => rotation.assertIdle()).not.toThrow() + expect(profileStateDatabaseBackups(databasePath)).toEqual([]) + }) + + it('blocks synchronous quarantine until retention pruning finishes', async () => { + const { databasePath, rotation, clock } = fixture() + for (let generation = 0; generation < 5; generation++) { + rotation.schedule() + await rotation.drain() + clock.now += HOUR + } + const oldest = profileStateDatabaseBackups(databasePath)[4].path + const remove = fsPromises.rm + let begin: () => void = () => {} + let release: () => void = () => {} + const started = new Promise((resolve) => { + begin = resolve + }) + const gate = new Promise((resolve) => { + release = resolve + }) + vi.spyOn(fsPromises, 'rm').mockImplementation(async (path, options) => { + if (path === oldest) { + begin() + await gate + } + await remove(path, options) + }) + rotation.schedule() + try { + await started + rotation.stop() + expect(profileStateDatabaseBackups(databasePath)).toHaveLength(6) + expect(() => rotation.assertIdle()).toThrow('Flush pending') + } finally { + release() + await rotation.drain() + } + expect(() => rotation.assertIdle()).not.toThrow() + expect(profileStateDatabaseBackups(databasePath)).toHaveLength(5) + }) + + it('owns an in-flight source until completion and blocks synchronous quarantine', async () => { + const { databasePath, rotation, opened } = fixture() + const realSnapshot = backupExecution.runProfileStateBackup + let begin: () => void = () => {} + let release: () => void = () => {} + const started = new Promise((resolve) => { + begin = resolve + }) + const gate = new Promise((resolve) => { + release = resolve + }) + vi.spyOn(backupExecution, 'runProfileStateBackup').mockImplementationOnce(async (job) => { + begin() + await gate + await realSnapshot(job) + }) + rotation.schedule() + await started + rotation.stop() + opened.db.close() + databases.splice(databases.indexOf(opened), 1) + expect(() => rotation.assertIdle()).toThrow('Flush pending') + release() + await rotation.drain() + expect(() => rotation.assertIdle()).not.toThrow() + expect(readBackup(profileStateDatabaseBackups(databasePath)[0].path)).toEqual({ + settings: { generation: 1 } + }) + }) + + it('does not treat a reserved-name directory as a recent successful backup', async () => { + const { databasePath, rotation } = fixture() + const invalid = profileStateDatabaseBackupPath( + databasePath, + createProfileStateDatabaseBackupId() + ) + mkdirSync(invalid) + rotation.schedule() + await rotation.drain() + expect(profileStateDatabaseBackups(databasePath)).toHaveLength(2) + expect(existsSync(invalid)).toBe(true) + }) + + it('preserves a backup with sidecars without counting it toward cadence or retention', async () => { + const { databasePath, rotation, clock } = fixture() + rotation.schedule() + await rotation.drain() + const original = profileStateDatabaseBackups(databasePath)[0].path + writeFileSync(`${original}-journal`, 'external unfinished write') + const reopened = createRotation(databasePath, () => clock.now) + for (let generation = 0; generation < 6; generation++) { + reopened.schedule() + await reopened.drain() + clock.now += HOUR + } + expect(existsSync(original)).toBe(true) + expect(existsSync(`${original}-journal`)).toBe(true) + expect(profileStateDatabaseBackups(databasePath)).toHaveLength(6) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-backup-rotation.ts b/src/main/persistence/profile-state/profile-state-backup-rotation.ts new file mode 100644 index 00000000000..d13ce4265da --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-rotation.ts @@ -0,0 +1,131 @@ +import { lstat, rm } from 'node:fs/promises' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath, + profileStateDatabaseBackupsAsync, + type ProfileStateDatabaseBackup +} from './profile-state-backup-path' +import { runProfileStateBackup } from './profile-state-backup-worker' +import { removeAbandonedProfileStateBackupFiles } from './profile-state-backup-temporary-files' + +const BACKUP_COUNT = 5 +const BACKUP_INTERVAL_MS = 60 * 60 * 1000 +const BACKUP_RETRY_MS = 60 * 1000 + +/** Immutable generations keep every previous recovery point until publication succeeds. */ +export class ProfileStateBackupRotation { + private pending: Promise | undefined + private stopped = false + private readonly cancellation = new AbortController() + private nextAttemptAt = 0 + + constructor( + private readonly databasePath: string, + private readonly profileId: string, + private readonly now: () => number = Date.now, + private readonly runBackup = runProfileStateBackup + ) {} + + schedule(): void { + if (this.stopped || this.pending || this.now() < this.nextAttemptAt) { + return + } + const pending = Promise.resolve() + .then(() => this.rotate()) + .catch((error: unknown) => { + this.nextAttemptAt = this.now() + BACKUP_RETRY_MS + if (this.stopped && (this.cancellation.signal.aborted || isMissingPath(error))) { + return + } + console.error('[persistence] Failed to back up profile state database:', error) + }) + .finally(() => { + if (this.pending === pending) { + this.pending = undefined + } + }) + this.pending = pending + } + + async drain(): Promise { + await this.pending + } + + stop(): void { + this.stopped = true + this.cancellation.abort() + } + + assertIdle(): void { + if (this.pending) { + throw new Error('Flush pending profile state backups before quarantining the database') + } + } + + private async rotate(): Promise { + if (this.stopped) { + return + } + const now = this.now() + await removeAbandonedProfileStateBackupFiles(this.databasePath, now) + const latest = (await this.regularBackups())[0] + if (this.stopped) { + return + } + if (latest && now - latest.createdAtMs < BACKUP_INTERVAL_MS) { + this.nextAttemptAt = Math.min(latest.createdAtMs, now) + BACKUP_INTERVAL_MS + return + } + const target = profileStateDatabaseBackupPath( + this.databasePath, + createProfileStateDatabaseBackupId(now) + ) + await this.runBackup( + { + databasePath: this.databasePath, + profileId: this.profileId, + targetPath: target + }, + this.cancellation.signal + ) + this.nextAttemptAt = this.now() + BACKUP_INTERVAL_MS + for (const backup of (await this.regularBackups()).slice(BACKUP_COUNT)) { + await rm(backup.path, { force: true }) + } + } + + private async regularBackups(): Promise { + const backups = await profileStateDatabaseBackupsAsync(this.databasePath) + const candidates = await Promise.all( + backups.map(async (backup) => { + try { + if (!(await lstat(backup.path)).isFile()) { + return undefined + } + for (const suffix of ['-wal', '-shm', '-journal']) { + const sidecar = await lstat(`${backup.path}${suffix}`).catch((error: unknown) => { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return undefined + } + throw error + }) + if (sidecar !== undefined) { + return undefined + } + } + return backup + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return undefined + } + throw error + } + }) + ) + return candidates.filter((backup) => backup !== undefined) + } +} + +function isMissingPath(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} diff --git a/src/main/persistence/profile-state/profile-state-backup-temporary-files.test.ts b/src/main/persistence/profile-state/profile-state-backup-temporary-files.test.ts new file mode 100644 index 00000000000..a37de695fa5 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-temporary-files.test.ts @@ -0,0 +1,91 @@ +import { mkdtempSync, readdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as identity from './profile-state-access-identity' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath +} from './profile-state-backup-path' +import { + profileStateBackupTemporaryPath, + removeAbandonedProfileStateBackupFiles +} from './profile-state-backup-temporary-files' + +vi.mock('./profile-state-access-identity', () => ({ + profileStateAccessBootIdentity: () => 'test-boot' +})) +vi.mock('./profile-state-access-owner', () => ({ + profileStateAccessPidNamespace: () => 'test-namespace' +})) + +const roots: string[] = [] +afterEach(() => { + vi.restoreAllMocks() + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('abandoned profile database backups', () => { + it('does not infer ownership when kernel identity is unavailable', async () => { + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue(null) + const probe = vi.spyOn(process, 'kill') + await removeAbandonedProfileStateBackupFiles('/missing/profile-state.db', Date.now()) + expect(probe).not.toHaveBeenCalled() + expect(profileStateBackupTemporaryPath('/profile/backup.db')).not.toContain('.owner-') + }) + + it('removes only old temporary files whose owner is proven exited', async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-backup-orphans-')) + roots.push(root) + const databasePath = join(root, 'profile-state.db') + const backup = profileStateDatabaseBackupPath( + databasePath, + createProfileStateDatabaseBackupId() + ) + const now = Date.now() + const old = new Date(now - 2 * 60 * 60_000) + const temporary = profileStateBackupTemporaryPath(backup) + const orphan = temporary.replace(`.${process.pid}.`, '.12345.') + const live = temporary.replace(`.${process.pid}.`, '.12346.') + const unknown = temporary.replace(`.${process.pid}.`, '.12347.') + const recent = profileStateBackupTemporaryPath(backup).replace(`.${process.pid}.`, '.12345.') + const remote = orphan.replace(/owner-[a-f0-9]{64}/, `owner-${'0'.repeat(64)}`) + const legacy = `${backup}.12345.${now}.ab12.tmp` + const unrelated = `${databasePath}.unrelated.tmp` + for (const path of [ + backup, + orphan, + `${orphan}-wal`, + `${orphan}-shm`, + `${orphan}-journal`, + live, + unknown, + recent, + remote, + legacy, + unrelated + ]) { + writeFileSync(path, 'retained') + if (path !== recent) { + utimesSync(path, old, old) + } + } + vi.spyOn(process, 'kill').mockImplementation((pid) => { + if (pid === 12345) { + throw Object.assign(new Error('exited'), { code: 'ESRCH' }) + } + if (pid === 12347) { + throw Object.assign(new Error('denied'), { code: 'EPERM' }) + } + return true + }) + await removeAbandonedProfileStateBackupFiles(databasePath, now) + expect(readdirSync(root).sort()).toEqual( + [backup, live, unknown, recent, remote, legacy, unrelated] + .map((path) => basename(path)) + .sort() + ) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-backup-temporary-files.ts b/src/main/persistence/profile-state/profile-state-backup-temporary-files.ts new file mode 100644 index 00000000000..eb0424abbc2 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-temporary-files.ts @@ -0,0 +1,71 @@ +import { createHash } from 'node:crypto' +import { lstat, readdir, rm } from 'node:fs/promises' +import { durableWriteTempPath } from '../../durable-file-write' +import { profileStateAccessBootIdentity } from './profile-state-access-identity' +import { profileStateAccessPidNamespace } from './profile-state-access-owner' +import { basename, dirname, join } from 'node:path' + +const ORPHAN_AGE_MS = 60 * 60_000 +const BACKUP_TEMP_PATTERN = + /^([1-9]\d*)-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.db\.owner-([a-f0-9]{64})\.([1-9]\d*)\.\d+\.[0-9a-f]+\.tmp(?:-(?:wal|shm|journal))?$/ + +export function profileStateBackupTemporaryPath(targetPath: string): string { + const scope = currentProcessScope() + return durableWriteTempPath(scope ? `${targetPath}.owner-${scope}` : targetPath) +} + +function currentProcessScope(): string | undefined { + const boot = profileStateAccessBootIdentity() + const namespace = profileStateAccessPidNamespace() + if (!boot || (process.platform === 'linux' && namespace === null)) { + return undefined + } + return createHash('sha256') + .update(JSON.stringify([process.platform, boot, namespace])) + .digest('hex') +} + +/** A dead process proves the temporary database and its journals cannot still be in use. */ +export async function removeAbandonedProfileStateBackupFiles( + databasePath: string, + now: number +): Promise { + const scope = currentProcessScope() + if (!scope) { + return + } + const directory = dirname(databasePath) + const prefix = `${basename(databasePath)}.backup.` + for (const name of await readdir(directory)) { + if (!name.startsWith(prefix)) { + continue + } + const match = BACKUP_TEMP_PATTERN.exec(name.slice(prefix.length)) + if (!match || match[2] !== scope || !ownerExited(Number(match[3]))) { + continue + } + const path = join(directory, name) + try { + const info = await lstat(path) + if (info.isFile() && now - info.mtimeMs >= ORPHAN_AGE_MS) { + await rm(path, { force: true }) + } + } catch (error) { + if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) { + throw error + } + } + } +} + +function ownerExited(pid: number): boolean { + if (!Number.isSafeInteger(pid) || pid <= 0) { + return false + } + try { + process.kill(pid, 0) + return false + } catch (error) { + return error instanceof Error && 'code' in error && error.code === 'ESRCH' + } +} diff --git a/src/main/persistence/profile-state/profile-state-backup-worker-entry.ts b/src/main/persistence/profile-state/profile-state-backup-worker-entry.ts new file mode 100644 index 00000000000..9213e4e0042 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-worker-entry.ts @@ -0,0 +1,30 @@ +import { parentPort, workerData } from 'node:worker_threads' +import { writeProfileStateBackup } from './profile-state-backup-job' +import { isRecord } from './profile-state-document-validation' + +if (!parentPort) { + throw new Error('Profile state backup must run on a worker thread') +} +const port = parentPort +const request: unknown = workerData +if ( + !isRecord(request) || + typeof request.databasePath !== 'string' || + typeof request.profileId !== 'string' || + typeof request.targetPath !== 'string' || + (request.temporaryPath !== undefined && typeof request.temporaryPath !== 'string') +) { + throw new Error('Invalid profile state backup request') +} + +void writeProfileStateBackup({ + databasePath: request.databasePath, + profileId: request.profileId, + targetPath: request.targetPath, + temporaryPath: request.temporaryPath +}) + .then( + () => port.postMessage({ ok: true }), + (error: unknown) => port.postMessage({ ok: false, error: String(error) }) + ) + .finally(() => port.close()) diff --git a/src/main/persistence/profile-state/profile-state-backup-worker.test.ts b/src/main/persistence/profile-state/profile-state-backup-worker.test.ts new file mode 100644 index 00000000000..310c1471420 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-worker.test.ts @@ -0,0 +1,236 @@ +import { build } from 'esbuild' +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { ProfileStateBackupRotation } from './profile-state-backup-rotation' +import { profileStateDatabaseBackups } from './profile-state-backup-path' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from './profile-state-database' +import { + hashProfileStateJson, + importProfileStateJson, + readProfileStateSnapshot +} from './profile-state-documents' +import { + runProfileStateBackupWorker, + resolveProfileStateBackupWorkerPath +} from './profile-state-backup-worker' +import * as backupWorker from './profile-state-backup-worker' + +const directories: string[] = [] +let workerDirectory: string +let workerPath: string + +beforeAll(async () => { + workerDirectory = mkdtempSync(join(tmpdir(), 'orca-backup-worker-entry-')) + workerPath = join(workerDirectory, 'profile-state-backup-worker-entry.js') + await build({ + entryPoints: [ + resolve('src/main/persistence/profile-state/profile-state-backup-worker-entry.ts') + ], + outfile: workerPath, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) +}) + +afterEach(() => { + vi.restoreAllMocks() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +afterAll(() => rmSync(workerDirectory, { recursive: true, force: true })) + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-backup-worker-')) + directories.push(directory) + const job = { + databasePath: join(directory, 'profile-state.db'), + targetPath: join(directory, 'backup.db'), + profileId: 'worker-test' + } + const opened = openProfileStateDatabase(job.databasePath, job.profileId) + importProfileStateJson(opened.db, '{"settings":{"theme":"dark"}}') + opened.db.close() + return { directory, job } +} + +function script(directory: string, source: string): string { + const path = join(directory, 'worker.cjs') + writeFileSync(path, source) + return path +} + +describe('profile state backup worker', () => { + it('runs the built entry and releases every handle before recovery can move its files', async () => { + const { directory, job } = fixture() + await runProfileStateBackupWorker(job, { workerPath }) + const snapshot = openProfileStateDatabaseReadOnly(job.targetPath, job.profileId) + try { + expect(JSON.parse(readProfileStateSnapshot(snapshot.db).json)).toEqual({ + settings: { theme: 'dark' } + }) + } finally { + snapshot.db.close() + } + expect(readdirSync(directory).filter((name) => name.startsWith('backup.db'))).toEqual([ + 'backup.db' + ]) + rmSync(directory, { recursive: true }) + expect(existsSync(directory)).toBe(false) + }) + + it.each([ + [ + 'a mismatched stored hash', + (db: ReturnType['db']) => { + db.prepare('UPDATE profile_state_documents SET content_hash = ?').run('0'.repeat(64)) + }, + 'hash mismatch' + ], + [ + 'an independently hashed invalid domain fragment', + (db: ReturnType['db']) => { + db.prepare( + "UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = 'settings'" + ).run('{', hashProfileStateJson('{')) + }, + 'invalid JSON' + ], + [ + 'an independently hashed invalid normalized history fragment', + (db: ReturnType['db']) => { + importProfileStateJson(db, '{"automationRuns":[{"id":"run-1","output":"ok"}]}') + db.prepare( + 'UPDATE profile_state_automation_runs SET payload = ?, content_hash = ? WHERE run_id = ?' + ).run('{', hashProfileStateJson('{'), 'run-1') + }, + 'invalid JSON' + ] + ] as const)('%s', async (_description, corrupt, expectedError) => { + const { job } = fixture() + const primary = openProfileStateDatabase(job.databasePath, job.profileId) + corrupt(primary.db) + primary.db.close() + const before = readFileSync(job.databasePath) + const retained = `${job.targetPath}.prior` + writeFileSync(retained, 'previous recovery point') + + await expect(runProfileStateBackupWorker(job, { workerPath })).rejects.toThrow(expectedError) + expect(existsSync(job.targetPath)).toBe(false) + expect(readFileSync(retained, 'utf8')).toBe('previous recovery point') + expect(readFileSync(job.databasePath)).toEqual(before) + }) + + it.each(['true', 'false'])('waits for actual exit after an ok=%s response', async (ok) => { + const { directory, job } = fixture() + const delayedWorker = script( + directory, + ` + const { parentPort, workerData } = require('node:worker_threads') + parentPort.postMessage({ ok: ${ok}, error: 'backup failed' }) + setTimeout(() => { + require('node:fs').writeFileSync(workerData.targetPath, 'handles released') + parentPort.close() + }, 50) + ` + ) + const result = runProfileStateBackupWorker(job, { workerPath: delayedWorker }) + await (ok === 'true' ? result : expect(result).rejects.toThrow('backup failed')) + expect(readFileSync(job.targetPath, 'utf8')).toBe('handles released') + }) + + it.each([ + ['throw new Error("worker boot failed")', 'worker boot failed'], + ['process.exit(0)', 'without completion'], + ['require("node:worker_threads").parentPort.postMessage({ wrong: true })', 'Invalid profile'], + ['setInterval(() => {}, 1000)', 'timed out'] + ])('fails closed for worker failure: %s', async (source, error) => { + const { directory, job } = fixture() + const failedWorker = script(directory, source) + await expect( + runProfileStateBackupWorker(job, { workerPath: failedWorker, timeoutMs: 500 }) + ).rejects.toThrow(error) + expect(existsSync(job.targetPath)).toBe(false) + rmSync(directory, { recursive: true }) + }) + + it.each(['timeout', 'cancel'] as const)( + 'cleans a terminated %s worker only after exit', + async (mode) => { + const { directory, job } = fixture() + const ready = join(directory, 'ready') + const worker = script( + directory, + ` + const { workerData } = require('node:worker_threads') + const fs = require('node:fs') + for (const suffix of ['', '-wal', '-shm', '-journal']) fs.writeFileSync(workerData.temporaryPath + suffix, 'incomplete') + fs.writeFileSync(${JSON.stringify(ready)}, 'ready') + setInterval(() => {}, 1000) + ` + ) + const cancellation = new AbortController() + const pending = runProfileStateBackupWorker(job, { + workerPath: worker, + timeoutMs: 500, + signal: cancellation.signal + }) + const failed = expect(pending).rejects.toThrow(mode === 'cancel' ? 'cancelled' : 'timed out') + await vi.waitFor(() => expect(existsSync(ready)).toBe(true)) + expect(readdirSync(directory).filter((name) => name.startsWith('backup.db.'))).toHaveLength(4) + if (mode === 'cancel') { + cancellation.abort() + } + await failed + expect(readdirSync(directory).filter((name) => name.startsWith('backup.db.'))).toEqual([]) + expect(existsSync(job.databasePath)).toBe(true) + } + ) + + it('reports a missing bundle and leaves the primary untouched', async () => { + const { directory, job } = fixture() + const before = readFileSync(job.databasePath) + await expect( + runProfileStateBackupWorker(job, { workerPath: join(directory, 'missing.js') }) + ).rejects.toThrow() + expect(readFileSync(job.databasePath)).toEqual(before) + }) + + it('coalesces desktop work and drains a started backup before allowing quarantine', async () => { + const { directory, job } = fixture() + let started: () => void = () => {} + const beginning = new Promise((resolve) => { + started = resolve + }) + const dispatch = vi + .spyOn(backupWorker, 'runProfileStateBackup') + .mockImplementation((request) => { + started() + return runProfileStateBackupWorker(request, { workerPath }) + }) + const rotation = new ProfileStateBackupRotation(job.databasePath, job.profileId) + rotation.schedule() + rotation.schedule() + await beginning + rotation.stop() + expect(() => rotation.assertIdle()).toThrow('Flush pending') + await rotation.drain() + expect(() => rotation.assertIdle()).not.toThrow() + expect(dispatch).toHaveBeenCalledOnce() + expect(profileStateDatabaseBackups(job.databasePath)).toHaveLength(1) + rmSync(directory, { recursive: true }) + }) + + it('finds entries beside the launcher and above Rollup shared chunks', () => { + expect(resolveProfileStateBackupWorkerPath(workerDirectory)).toBe(workerPath) + expect(resolveProfileStateBackupWorkerPath(join(workerDirectory, 'chunks'))).toBe(workerPath) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-backup-worker.ts b/src/main/persistence/profile-state/profile-state-backup-worker.ts new file mode 100644 index 00000000000..b671df94112 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-worker.ts @@ -0,0 +1,87 @@ +import { existsSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { profileStateBackupTemporaryPath } from './profile-state-backup-temporary-files' +import { profileStateDatabaseFiles } from './profile-state-storage-classification' +import { dirname, join } from 'node:path' +import { Worker } from 'node:worker_threads' +import { + currentWorkerEntryLayout, + resolveWorkerThreadEntryPath +} from '../../worker-thread-entry-path' +import { type ProfileStateBackupJob, writeProfileStateBackup } from './profile-state-backup-job' +import { isRecord } from './profile-state-document-validation' + +const WORKER_FILENAME = 'profile-state-backup-worker-entry.js' +const BACKUP_TIMEOUT_MS = 10 * 60_000 + +export function resolveProfileStateBackupWorkerPath(moduleDir = __dirname): string { + const entry = resolveWorkerThreadEntryPath(currentWorkerEntryLayout(moduleDir), WORKER_FILENAME) + // Rollup can place this launcher in a shared chunk beside the worker entries. + return [entry, join(dirname(entry), '..', WORKER_FILENAME)].find(existsSync) ?? entry +} + +/** Bun snapshot copying and desktop validation run off the owning runtime thread. */ +export function runProfileStateBackup( + job: ProfileStateBackupJob, + signal?: AbortSignal +): Promise { + return process.versions.electron || process.versions.bun + ? runProfileStateBackupWorker(job, { signal }) + : writeProfileStateBackup(job) +} + +export async function runProfileStateBackupWorker( + job: ProfileStateBackupJob, + options: { workerPath?: string; timeoutMs?: number; signal?: AbortSignal } = {} +): Promise { + options.signal?.throwIfAborted() + const temporaryPath = profileStateBackupTemporaryPath(job.targetPath) + try { + await new Promise((resolve, reject) => { + const workerPath = options.workerPath ?? resolveProfileStateBackupWorkerPath() + const worker = new Worker(workerPath, { workerData: { ...job, temporaryPath }, execArgv: [] }) + let completed = false + let failure: Error | undefined + const terminate = (reason: Error): void => { + failure ??= reason + void worker.terminate().catch((error: unknown) => { + failure = error instanceof Error ? error : new Error(String(error)) + }) + } + const abort = (): void => terminate(new Error('Profile state backup cancelled')) + options.signal?.addEventListener('abort', abort, { once: true }) + const timer = setTimeout( + () => terminate(new Error('Profile state backup worker timed out')), + options.timeoutMs ?? BACKUP_TIMEOUT_MS + ) + worker.on('message', (response: unknown) => { + if (!isRecord(response) || typeof response.ok !== 'boolean') { + failure = new Error('Invalid profile state backup worker response') + } else if (!response.ok) { + failure = new Error(String(response.error)) + } else { + completed = true + } + }) + worker.on('error', (error) => { + failure = error instanceof Error ? error : new Error(String(error)) + }) + // Even an error response leaves handles open until the worker actually exits. + worker.once('exit', (code) => { + clearTimeout(timer) + options.signal?.removeEventListener('abort', abort) + if (failure || code !== 0 || !completed) { + reject( + failure ?? new Error(`Profile state backup worker exited without completion (${code})`) + ) + } else { + resolve() + } + }) + }) + } finally { + await Promise.all( + profileStateDatabaseFiles(temporaryPath).map((path) => rm(path, { force: true })) + ) + } +} diff --git a/src/main/persistence/profile-state/profile-state-bootstrap-publication-race.test.ts b/src/main/persistence/profile-state/profile-state-bootstrap-publication-race.test.ts new file mode 100644 index 00000000000..b93b05b1b31 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-bootstrap-publication-race.test.ts @@ -0,0 +1,178 @@ +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { bootstrapProfileStateAuthority } from './profile-state-authority-bootstrap' +import { migrateProfileStateToSqlite } from './profile-state-migration' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { profileStateJsonExportPath } from './legacy-json/profile-state-export-path' +import type { ProfileStateAuthority } from '../loading-store/profile-state-authority' +import { formatProfileStateStartupFailure } from './profile-state-startup-failure' + +vi.mock('node:fs', async (original) => ({ ...(await original()) })) +vi.mock('../../telemetry/client', () => ({ track: () => {} })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const roots: string[] = [] +const authorities: ProfileStateAuthority[] = [] +afterEach(() => { + vi.restoreAllMocks() + for (const authority of authorities.splice(0)) { + authority.close?.() + } + for (const root of roots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +describe('first database publication with competing startup', () => { + it.each(['empty', 'legacy'])( + 'explains unavailable hard links without publishing a partial %s database', + (kind) => { + const root = fs.mkdtempSync(join(tmpdir(), 'orca-bootstrap-no-hardlinks-')) + roots.push(root) + const options = { + dataFile: join(root, 'orca-data.json'), + databaseFile: join(root, 'profile-state.db'), + profileId: 'unsupported-publication', + allowEmptyProfileState: true + } + const json = '{"settings":{"theme":"dark"}}' + if (kind === 'legacy') { + fs.writeFileSync(options.dataFile, json) + } + const link = vi.spyOn(fs, 'linkSync').mockImplementation(() => { + throw Object.assign(new Error('hard links unsupported'), { + code: 'ENOTSUP', + syscall: 'link' + }) + }) + let failure: unknown + try { + bootstrapProfileStateAuthority(options) + } catch (error) { + failure = error + } + expect(failure).toMatchObject({ code: 'profile-state-publication-unavailable' }) + const message = formatProfileStateStartupFailure(failure) + expect(message).toContain('hard links') + expect(message).toContain(root) + expect(message).toContain('complete Orca data directory') + expect(message).not.toContain('rollback') + expect(fs.existsSync(options.databaseFile)).toBe(false) + expect(fs.readdirSync(root)).toEqual(kind === 'legacy' ? ['orca-data.json'] : []) + if (kind === 'legacy') { + expect(fs.readFileSync(options.dataFile, 'utf8')).toBe(json) + } + link.mockRestore() + const retry = bootstrapProfileStateAuthority(options) + expect(retry.authority).toBeDefined() + if (retry.authority) { + authorities.push(retry.authority) + } + } + ) + + it('names its migration export after the snapshot actually captured', () => { + const root = fs.mkdtempSync(join(tmpdir(), 'orca-migration-export-race-')) + roots.push(root) + const options = { + dataFile: join(root, 'orca-data.json'), + databaseFile: join(root, 'profile-state.db'), + profileId: 'migration-export-race', + expectedLegacyJson: '{"settings":{"theme":"dark"}}', + serializedState: '{"settings":{"theme":"dark"}}' + } + fs.writeFileSync(options.dataFile, options.expectedLegacyJson) + const link = fs.linkSync + vi.spyOn(fs, 'linkSync').mockImplementation((from, to) => { + link(from, to) + if (to === options.databaseFile) { + const peer = new ProfileStateSqliteAuthority(options.databaseFile, options.profileId) + try { + peer.writeSerializedDomains([{ domain: 'settings', payload: '{"theme":"light"}' }]) + } finally { + peer.close() + } + } + }) + + const migrated = migrateProfileStateToSqlite(options) + authorities.push(migrated.authority) + expect(migrated.authority.revision).toBe(2) + expect(fs.existsSync(profileStateJsonExportPath(options.dataFile, 1))).toBe(false) + expect( + JSON.parse(fs.readFileSync(profileStateJsonExportPath(options.dataFile, 2), 'utf8')) + ).toEqual({ + settings: { theme: 'light' } + }) + expect(fs.readFileSync(options.dataFile, 'utf8')).toBe(options.expectedLegacyJson) + }) + + it.each(['empty', 'legacy'])('cannot replace an acknowledged competing %s profile', (kind) => { + const root = fs.mkdtempSync(join(tmpdir(), 'orca-bootstrap-publication-race-')) + roots.push(root) + const options = { + dataFile: join(root, 'orca-data.json'), + databaseFile: join(root, 'profile-state.db'), + profileId: 'publication-race', + allowEmptyProfileState: true + } + if (kind === 'legacy') { + fs.writeFileSync(options.dataFile, '{"settings":{"theme":"dark"}}') + } + const committed = { + settings: { theme: 'light' }, + extension: { acknowledged: true, value: null } + } + let competing = false + const open = () => { + const result = bootstrapProfileStateAuthority(options) + if (result.authority) { + authorities.push(result.authority) + } + return result + } + const race = (target: fs.PathLike) => { + if (competing || target !== options.databaseFile) { + return + } + competing = true + const winner = open().authority + if (!winner) { + throw new Error('Competing startup did not establish SQLite') + } + winner.writeSerializedState(Buffer.from(JSON.stringify(committed))) + expect(JSON.parse(winner.readSerializedState() ?? 'null')).toEqual(committed) + winner.close?.() + authorities.splice(authorities.indexOf(winner), 1) + } + const rename = fs.renameSync + vi.spyOn(fs, 'renameSync').mockImplementation((from, to) => { + race(to) + rename(from, to) + }) + const link = fs.linkSync + vi.spyOn(fs, 'linkSync').mockImplementation((from, to) => { + race(to) + link(from, to) + }) + + let publicationError: unknown + try { + open() + } catch (error) { + publicationError = error + } + expect(competing).toBe(true) + expect(JSON.parse(open().authority?.readSerializedState() ?? 'null')).toEqual(committed) + expect(publicationError).toMatchObject({ message: expect.stringContaining('storage changed') }) + expect(fs.readdirSync(root).some((name) => name.endsWith('.tmp'))).toBe(false) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-complete-domain-writes.test.ts b/src/main/persistence/profile-state/profile-state-complete-domain-writes.test.ts new file mode 100644 index 00000000000..177c79dd874 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-complete-domain-writes.test.ts @@ -0,0 +1,122 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + openProfileStateDatabaseReadOnly, + openProfileStateDatabase +} from './profile-state-database' +import { readProfileStateRevision } from './profile-state-documents' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' + +const fixtures: { authority: ProfileStateSqliteAuthority; directory: string }[] = [] + +function fixture(established: boolean) { + const directory = mkdtempSync(join(tmpdir(), 'orca-complete-domain-write-')) + const databasePath = join(directory, 'state.db') + openProfileStateDatabase(databasePath, 'profile').db.close() + const authority = new ProfileStateSqliteAuthority(databasePath, 'profile') + fixtures.push({ authority, directory }) + if (established) { + authority.writeSerializedState( + Buffer.from('{"settings":{"theme":"light"},"automationRuns":[{"id":"old"}],"removeMe":true}') + ) + } + const readRevision = () => { + const { db } = openProfileStateDatabaseReadOnly(databasePath, 'profile') + try { + return readProfileStateRevision(db) + } finally { + db.close() + } + } + return { authority, databasePath, readRevision } +} + +afterEach(() => { + for (const { authority, directory } of fixtures.splice(0)) { + authority.close() + rmSync(directory, { recursive: true, force: true }) + } +}) + +const invalidReplacements = [ + { + name: 'sibling-key injection', + value: [{ domain: 'extension', payload: 'null,"settings":{"theme":"dark"}' }] + }, + { + name: 'duplicate domains', + value: [ + { domain: 'settings', payload: '{}' }, + { domain: 'settings', payload: 'null' } + ] + }, + { name: 'non-string payload', value: [{ domain: 'settings', payload: true }] }, + { name: 'missing payload', value: [{ domain: 'settings' }] }, + { name: 'empty domain', value: [{ domain: '', payload: '{}' }] }, + { name: 'non-array replacements', value: { settings: '{}' } } +] + +describe.each([false, true])('complete domain writes (established: %s)', (established) => { + it.each(invalidReplacements)('rejects $name without changing state or revision', ({ value }) => { + const { authority, readRevision } = fixture(established) + const before = authority.readSerializedState() + const revision = readRevision() + + expect(() => { + // @ts-expect-error Intentionally malformed input must fail runtime validation. + authority.writeCompleteSerializedDomains(value) + }).toThrow() + + expect(authority.readSerializedState()).toBe(before) + expect(readRevision()).toBe(revision) + }) + + it('preserves null, history order and unknown fields while deleting omitted domains', () => { + const { authority } = fixture(established) + const future = { '3': 3, '1': 1, unicode: '雪 🐋\ud800', nested: { z: null, a: [] } } + const runs = [{ id: 'z', extension: future }, { id: 'a' }] + + authority.writeCompleteSerializedDomains([ + { domain: 'settings', payload: 'null' }, + { domain: 'automationRuns', payload: JSON.stringify(runs) }, + { domain: 'future', payload: JSON.stringify(future) }, + { domain: 'deleted', payload: null } + ]) + + expect(authority.readSerializedState()).toBe( + JSON.stringify({ settings: null, automationRuns: runs, future }) + ) + authority.writeCompleteSerializedDomains([]) + expect(authority.readSerializedState()).toBe('{}') + }) + + it('accepts an empty complete profile', () => { + const { authority } = fixture(established) + + authority.writeCompleteSerializedDomains([]) + + expect(authority.readSerializedState()).toBe('{}') + }) +}) + +describe('complete domain revision fencing', () => { + it('rejects an older complete replacement after a concurrent writer commits', () => { + const { authority, databasePath } = fixture(true) + authority.readSerializedState() + const other = new ProfileStateSqliteAuthority(databasePath, 'profile') + try { + other.writeCompleteSerializedDomains([{ domain: 'settings', payload: '{"theme":"dark"}' }]) + } finally { + other.close() + } + + expect(() => + authority.writeCompleteSerializedDomains([ + { domain: 'settings', payload: '{"theme":"light"}' } + ]) + ).toThrow(expect.objectContaining({ code: 'profile-state-revision-conflict' })) + expect(authority.readSerializedState()).toBe('{"settings":{"theme":"dark"}}') + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-complete-replacements.ts b/src/main/persistence/profile-state/profile-state-complete-replacements.ts new file mode 100644 index 00000000000..2f7ea20f537 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-complete-replacements.ts @@ -0,0 +1,36 @@ +import type { ProfileStateDomainReplacement } from '../loading-store/profile-state-authority' +import type { openProfileStateDatabase } from './profile-state-database' + +export function buildCompleteDocumentReplacements( + db: ReturnType['db'], + replacements: readonly ProfileStateDomainReplacement[] +): ProfileStateDomainReplacement[] { + const domains = new Set(replacements.map(({ domain }) => domain)) + const incoming = new Set(domains) + for (const row of db + .prepare(`SELECT domain FROM profile_state_documents + UNION SELECT domain FROM profile_state_automation_runs_meta WHERE presence <> 'document'`) + .all()) { + if (isDomainRow(row)) { + domains.add(row.domain) + } + } + + return [ + ...replacements, + ...[...domains] + .filter((domain) => !incoming.has(domain)) + .map((domain) => ({ domain, payload: null })) + ] +} + +function isDomainRow(value: unknown): value is { domain: string } { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + 'domain' in value && + typeof value.domain === 'string' && + value.domain.length > 0 + ) +} diff --git a/src/main/persistence/profile-state/profile-state-crash-recovery.test.ts b/src/main/persistence/profile-state/profile-state-crash-recovery.test.ts new file mode 100644 index 00000000000..e9d6a54e0e5 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-crash-recovery.test.ts @@ -0,0 +1,210 @@ +import { spawnProcess } from '../../../shared/child-process/run-process' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + buildProfileStateCutoverFixture, + canonicalProfileStateJson +} from '../profile-state-cutover-fixture' +import { + exportProfileStateJson, + importProfileStateJson, + readProfileStateRevision +} from './profile-state-documents' +import { openProfileStateDatabase, profileStateDatabaseFile } from './profile-state-database' + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +const crashDuringWriteScript = ` + const { DatabaseSync } = process.getBuiltinModule('node:sqlite') + const db = new DatabaseSync(process.argv[1]) + db.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; BEGIN IMMEDIATE') + db.prepare('DELETE FROM profile_state_documents').run() + db.prepare('DELETE FROM profile_state_automation_runs').run() + db.prepare('DELETE FROM profile_state_automation_runs_meta').run() + db.prepare(\` + UPDATE profile_state_meta + SET value = ? + WHERE key = 'revision' + \`).run('999') + process.stdout.write('transaction-ready\\n') + setInterval(() => {}, 1_000) +` + +const crashAfterCommittedWriteScript = ` + const { DatabaseSync } = process.getBuiltinModule('node:sqlite') + const { createHash } = require('node:crypto') + const db = new DatabaseSync(process.argv[1]) + const payload = '{"theme":"committed"}' + const hash = createHash('sha256').update(payload).digest('hex') + db.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; BEGIN IMMEDIATE') + db.prepare('DELETE FROM profile_state_documents').run() + db.prepare('DELETE FROM profile_state_automation_runs').run() + db.prepare("UPDATE profile_state_automation_runs_meta SET presence = 'document', domain_version = 1, revision = 0, updated_at = 0, content_hash = ''").run() + db.prepare(\` + INSERT INTO profile_state_documents + (domain, payload, domain_version, revision, updated_at, content_hash) + VALUES (?, ?, ?, ?, ?, ?)\` + ).run('settings', payload, 1, 999, 999, hash) + db.prepare(\` + UPDATE profile_state_meta + SET value = ? + WHERE key = 'revision' + \`).run('999') + db.exec('COMMIT') + process.stdout.write('transaction-committed\\n') + setInterval(() => {}, 1_000) +` + +const crashDuringCheckpointScript = ` + const { DatabaseSync } = process.getBuiltinModule('node:sqlite') + const dbPath = process.argv[1] + const writer = new DatabaseSync(dbPath, { timeout: 5_000 }) + const reader = new DatabaseSync(dbPath, { timeout: 5_000 }) + writer.exec('PRAGMA wal_autocheckpoint = 0') + writer.exec('BEGIN IMMEDIATE') + writer.prepare( + \`UPDATE profile_state_meta SET value = value WHERE key = 'revision'\` + ).run() + writer.exec('COMMIT') + reader.exec('BEGIN') + reader.prepare("SELECT value FROM profile_state_meta WHERE key = 'revision'").get() + // SQLITE_PRAGMA is action code 19; the reader keeps TRUNCATE checkpointing active after this callback returns. + writer.setAuthorizer((action) => { + if (action === 19) { + process.stdout.write('checkpoint-started\\n') + } + return 0 + }) + writer.prepare('PRAGMA wal_checkpoint(TRUNCATE)').all() + process.stdout.write('checkpoint-complete\\n') +` + +async function killAfterChildReady(dbPath: string, script: string, marker: string): Promise { + const child = spawnProcess({ + program: process.execPath, + args: ['-e', script, dbPath], + timeoutMs: null + }) + for (const stream of [child.stdin, child.stdout, child.stderr]) { + stream?.on('error', () => {}) + } + + try { + await new Promise((resolve, reject) => { + let output = '' + const onData = (chunk: Buffer | string): void => { + output += String(chunk) + if (output.includes(marker)) { + resolve() + } + } + child.stdout.on('data', onData) + child.once('error', reject) + }) + child.kill('SIGKILL') + await new Promise((resolve, reject) => { + child.once('close', () => resolve()) + child.once('error', reject) + }) + } finally { + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL') + } + } +} + +async function killAfterUncommittedWrite(dbPath: string): Promise { + await killAfterChildReady(dbPath, crashDuringWriteScript, 'transaction-ready') +} + +describe('profile state crash recovery', () => { + it('rolls back an uncommitted SQLite write and accepts the next import', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-crash-')) + temporaryDirectories.push(directory) + const dbPath = profileStateDatabaseFile(directory) + const fixture = buildProfileStateCutoverFixture() + + const initial = openProfileStateDatabase(dbPath, 'profile-a') + importProfileStateJson(initial.db, JSON.stringify(fixture), { now: () => 100 }) + initial.db.close() + + await killAfterUncommittedWrite(dbPath) + + const recovered = openProfileStateDatabase(dbPath, 'profile-a') + try { + expect(recovered.readOnly).toBe(false) + expect(readProfileStateRevision(recovered.db)).toBe(1) + expect(canonicalProfileStateJson(JSON.parse(exportProfileStateJson(recovered.db)))).toBe( + canonicalProfileStateJson(fixture) + ) + + const replacement = { + ...fixture, + futureTopLevelExtension: { keep: 'replacement', nullable: null } + } + expect( + importProfileStateJson(recovered.db, JSON.stringify(replacement), { now: () => 200 }) + ).toBe(2) + expect(canonicalProfileStateJson(JSON.parse(exportProfileStateJson(recovered.db)))).toBe( + canonicalProfileStateJson(replacement) + ) + } finally { + recovered.db.close() + } + }) + + it('preserves a committed SQLite write after process termination', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-crash-')) + temporaryDirectories.push(directory) + const dbPath = profileStateDatabaseFile(directory) + const initial = openProfileStateDatabase(dbPath, 'profile-a') + importProfileStateJson(initial.db, JSON.stringify(buildProfileStateCutoverFixture()), { + now: () => 100 + }) + initial.db.close() + + await killAfterChildReady(dbPath, crashAfterCommittedWriteScript, 'transaction-committed') + + const recovered = openProfileStateDatabase(dbPath, 'profile-a') + try { + expect(recovered.readOnly).toBe(false) + expect(readProfileStateRevision(recovered.db)).toBe(999) + expect(JSON.parse(exportProfileStateJson(recovered.db))).toEqual({ + settings: { theme: 'committed' } + }) + } finally { + recovered.db.close() + } + }) + + it('recovers a committed database when checkpointing is interrupted', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-checkpoint-crash-')) + temporaryDirectories.push(directory) + const dbPath = profileStateDatabaseFile(directory) + const fixture = buildProfileStateCutoverFixture() + const initial = openProfileStateDatabase(dbPath, 'profile-a') + importProfileStateJson(initial.db, JSON.stringify(fixture), { now: () => 100 }) + initial.db.close() + + await killAfterChildReady(dbPath, crashDuringCheckpointScript, 'checkpoint-started') + + const recovered = openProfileStateDatabase(dbPath, 'profile-a') + try { + expect(recovered.readOnly).toBe(false) + expect(readProfileStateRevision(recovered.db)).toBe(1) + expect(canonicalProfileStateJson(JSON.parse(exportProfileStateJson(recovered.db)))).toBe( + canonicalProfileStateJson(fixture) + ) + } finally { + recovered.db.close() + } + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-current-json-command.test.ts b/src/main/persistence/profile-state/profile-state-current-json-command.test.ts new file mode 100644 index 00000000000..0b1592bc9fc --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-current-json-command.test.ts @@ -0,0 +1,171 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + acquireProfileStateMaintenance, + acquireProfileStateRuntimeAdmission +} from './profile-state-access' +import { rollbackProfileState } from './profile-state-recovery-command' +import { bootstrapProfileStateAuthority } from './profile-state-authority-bootstrap' +import { migrateProfileStateToSqlite } from './profile-state-migration' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath +} from './profile-state-backup-path' +import { profileStateJsonExportPath } from './legacy-json/profile-state-export-path' +const roots: string[] = [] +const profileId = 'current-json-recovery' +const originalState = { settings: { theme: 'dark', httpProxyUrl: 'sealed:original' } } +const editedState = { + settings: { + theme: 'light', + httpProxyUrl: 'sealed:older-build', + electronHttp1CompatibilityMode: true + }, + futureDomain: { opaque: [null, '\ud800', { futureKey: 'keep me' }] }, + accounts: { token: 'sealed:account-token' } +} +const editedJson = `${JSON.stringify(editedState, null, 2)}\n` + +beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}) +}) +afterEach(() => { + vi.restoreAllMocks() + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'orca-current-json-')) + roots.push(root) + const directory = join(root, 'profiles', profileId) + mkdirSync(directory, { recursive: true }) + writeFileSync( + join(root, 'orca-profile-index.json'), + JSON.stringify({ activeProfileId: profileId, profiles: [{ id: profileId }] }) + ) + const dataFile = join(directory, 'orca-data.json') + const databaseFile = join(directory, 'profile-state.db') + const exportPath = profileStateJsonExportPath(dataFile, 1) + const originalJson = JSON.stringify(originalState) + writeFileSync(dataFile, originalJson) + const migration = migrateProfileStateToSqlite({ + dataFile, + databaseFile, + profileId, + expectedLegacyJson: originalJson, + serializedState: originalJson + }) + migration.authority.close() + const backupPath = profileStateDatabaseBackupPath( + databaseFile, + createProfileStateDatabaseBackupId() + ) + writeFileSync(backupPath, readFileSync(databaseFile)) + writeFileSync(dataFile, editedJson) + return { root, directory, dataFile, databaseFile, exportPath, backupPath, profileId } +} + +function rollback(profile: ReturnType) { + const maintenance = acquireProfileStateMaintenance(profile.root) + try { + expect(() => acquireProfileStateRuntimeAdmission(profile.root)).toThrow('in use') + return rollbackProfileState(profile.root, { kind: 'current-json' }, maintenance) + } finally { + maintenance.release() + } +} + +function snapshot(profile: ReturnType) { + return [profile.dataFile, profile.databaseFile, profile.exportPath, profile.backupPath].map( + (path) => readFileSync(path) + ) +} + +describe('adopting JSON edited by an older build', () => { + it('preserves both authorities and retained exports before adopting exact JSON bytes', async () => { + const profile = fixture() + const before = snapshot(profile) + const previousQuarantine = join(profile.directory, 'profile-state-corrupt-earlier') + mkdirSync(previousQuarantine) + writeFileSync(join(previousQuarantine, 'evidence'), 'preserve earlier recovery') + expect(() => bootstrapProfileStateAuthority(profile)).toThrow('acceptance marker') + const result = rollback(profile) + expect(result).toMatchObject({ + revision: null, + storage: 'json', + restoredPath: profile.dataFile + }) + expect(readFileSync(profile.dataFile, 'utf8')).toBe(editedJson) + expect(existsSync(profile.databaseFile)).toBe(false) + expect(existsSync(profile.exportPath)).toBe(false) + expect(existsSync(profile.backupPath)).toBe(false) + expect(readFileSync(join(previousQuarantine, 'evidence'), 'utf8')).toBe( + 'preserve earlier recovery' + ) + for (const [index, path] of [ + profile.dataFile, + profile.databaseFile, + profile.exportPath, + profile.backupPath + ].entries()) { + expect(readFileSync(join(result.quarantineDirectory, basename(path)))).toEqual(before[index]) + } + const reopened = bootstrapProfileStateAuthority(profile) + expect(reopened.migrated).toBe(true) + try { + const restored: unknown = JSON.parse(reopened.authority?.readSerializedState() ?? 'null') + expect(restored).toMatchObject(editedState) + } finally { + reopened.authority?.close() + } + acquireProfileStateRuntimeAdmission(profile.root).release() + }) + + it.each(['invalid JSON', '[]', 'null'])( + 'refuses invalid current JSON without changing either authority: %s', + async (raw) => { + const profile = fixture() + writeFileSync(profile.dataFile, raw) + const before = snapshot(profile) + expect(() => rollback(profile)).toThrow('Profile state JSON') + expect(snapshot(profile)).toEqual(before) + } + ) + + it('refuses a missing canonical JSON without choosing a retained export', async () => { + const profile = fixture() + rmSync(profile.dataFile) + const before = readFileSync(profile.databaseFile) + expect(() => rollback(profile)).toThrow('ENOENT') + expect(readFileSync(profile.databaseFile)).toEqual(before) + expect(existsSync(profile.exportPath)).toBe(true) + }) + + it('refuses while a profile owner holds admission', async () => { + const profile = fixture() + const before = snapshot(profile) + const admission = acquireProfileStateRuntimeAdmission(profile.root) + try { + expect(() => rollback(profile)).toThrow('in use') + expect(snapshot(profile)).toEqual(before) + } finally { + admission.release() + } + }) + + it('refuses an unresolved move without removing its journal', async () => { + const profile = fixture() + const before = snapshot(profile) + const moves = join(profile.root, 'profile-move-intents') + mkdirSync(moves) + const journal = join(moves, '00000000-0000-0000-0000-000000000001.json') + writeFileSync(journal, '{"partial":true}') + expect(() => rollback(profile)).toThrow('pending project move') + expect(snapshot(profile)).toEqual(before) + expect(readFileSync(journal, 'utf8')).toBe('{"partial":true}') + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-current-sqlite-command.test.ts b/src/main/persistence/profile-state/profile-state-current-sqlite-command.test.ts new file mode 100644 index 00000000000..53aa67120e9 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-current-sqlite-command.test.ts @@ -0,0 +1,147 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + acquireProfileStateMaintenance, + acquireProfileStateRuntimeAdmission +} from './profile-state-access' +import { rollbackProfileState } from './profile-state-recovery-command' +import { + bootstrapProfileStateAuthority, + ProfileStateAuthorityBootstrapError +} from './profile-state-authority-bootstrap' +import { migrateProfileStateToSqlite } from './profile-state-migration' +import { profileStateJsonExportPath } from './legacy-json/profile-state-export-path' +import { isDivergedProfileStateFailure } from './profile-state-startup-failure' + +const roots: string[] = [] +const profileId = 'current-sqlite-recovery' +const sqliteState = { settings: { theme: 'dark', electronHttp1CompatibilityMode: true } } +const editedJson = JSON.stringify({ settings: { theme: 'light' } }) + +beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}) +}) +afterEach(() => { + vi.restoreAllMocks() + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'orca-current-sqlite-')) + roots.push(root) + const directory = join(root, 'profiles', profileId) + mkdirSync(directory, { recursive: true }) + writeFileSync( + join(root, 'orca-profile-index.json'), + JSON.stringify({ activeProfileId: profileId, profiles: [{ id: profileId }] }) + ) + const dataFile = join(directory, 'orca-data.json') + const databaseFile = join(directory, 'profile-state.db') + const originalJson = JSON.stringify(sqliteState) + writeFileSync(dataFile, originalJson) + migrateProfileStateToSqlite({ + dataFile, + databaseFile, + profileId, + expectedLegacyJson: originalJson, + serializedState: originalJson + }).authority.close() + writeFileSync(dataFile, editedJson) + return { root, dataFile, databaseFile, profileId } +} + +function rollback(profile: ReturnType) { + const maintenance = acquireProfileStateMaintenance(profile.root) + try { + return rollbackProfileState(profile.root, { kind: 'current-sqlite' }, maintenance) + } finally { + maintenance.release() + } +} + +describe('keeping SQLite over JSON edited by an older build', () => { + it('tags the startup failure as a user-resolvable divergence', () => { + const profile = fixture() + let failure: unknown + try { + bootstrapProfileStateAuthority(profile) + } catch (error) { + failure = error + } + expect(failure).toBeInstanceOf(ProfileStateAuthorityBootstrapError) + expect(isDivergedProfileStateFailure(failure)).toBe(true) + expect(isDivergedProfileStateFailure(new ProfileStateAuthorityBootstrapError('other'))).toBe( + false + ) + }) + + it('archives the diverged JSON and republishes JSON that SQLite accepts', () => { + const profile = fixture() + const databaseBefore = readFileSync(profile.databaseFile) + const result = rollback(profile) + expect(result).toMatchObject({ + storage: 'sqlite', + restoredPath: profile.databaseFile, + revision: 1, + removedDatabaseFiles: [] + }) + expect(result.backupId).toBeUndefined() + expect(readFileSync(join(result.quarantineDirectory, 'orca-data.json'), 'utf8')).toBe( + editedJson + ) + expect(readFileSync(join(result.quarantineDirectory, 'profile-state.db'))).toEqual( + databaseBefore + ) + expect(JSON.parse(readFileSync(profile.dataFile, 'utf8'))).toEqual(sqliteState) + + const reopened = bootstrapProfileStateAuthority(profile) + try { + expect(reopened.migrated).toBe(false) + expect(JSON.parse(reopened.authority?.readSerializedState() ?? 'null')).toEqual(sqliteState) + } finally { + reopened.authority?.close() + } + acquireProfileStateRuntimeAdmission(profile.root).release() + }) + + it('replaces JSON that an older build left unparseable', () => { + const profile = fixture() + writeFileSync(profile.dataFile, 'not json') + const result = rollback(profile) + expect(readFileSync(join(result.quarantineDirectory, 'orca-data.json'), 'utf8')).toBe( + 'not json' + ) + expect(JSON.parse(readFileSync(profile.dataFile, 'utf8'))).toEqual(sqliteState) + }) + + it('leaves both copies untouched when SQLite is unreadable', () => { + const profile = fixture() + writeFileSync(profile.databaseFile, 'broken database') + expect(() => rollback(profile)).toThrow() + expect(readFileSync(profile.databaseFile, 'utf8')).toBe('broken database') + expect(readFileSync(profile.dataFile, 'utf8')).toBe(editedJson) + }) + + it('restores the diverged JSON when republishing fails', () => { + const profile = fixture() + writeFileSync(profileStateJsonExportPath(profile.dataFile, 1), '{"conflicting":true}') + expect(() => rollback(profile)).toThrow('already exists with different content') + expect(readFileSync(profile.dataFile, 'utf8')).toBe(editedJson) + }) + + it('refuses while a profile owner holds admission', () => { + const profile = fixture() + const admission = acquireProfileStateRuntimeAdmission(profile.root) + try { + expect(() => rollback(profile)).toThrow('in use') + expect(readFileSync(profile.dataFile, 'utf8')).toBe(editedJson) + expect(existsSync(profile.databaseFile)).toBe(true) + } finally { + admission.release() + } + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-database-errors.ts b/src/main/persistence/profile-state/profile-state-database-errors.ts new file mode 100644 index 00000000000..65b58470a5e --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-errors.ts @@ -0,0 +1,17 @@ +export type ProfileStateDatabaseOpenErrorCode = + | 'unreadable' + | 'identity-mismatch' + | 'invalid-profile-id' + | 'newer-schema' + +export class ProfileStateDatabaseOpenError extends Error { + readonly code: ProfileStateDatabaseOpenErrorCode + readonly cause: unknown + + constructor(code: ProfileStateDatabaseOpenErrorCode, message: string, cause?: unknown) { + super(message) + this.name = 'ProfileStateDatabaseOpenError' + this.code = code + this.cause = cause + } +} diff --git a/src/main/persistence/profile-state/profile-state-database-export-crash.test.ts b/src/main/persistence/profile-state/profile-state-database-export-crash.test.ts new file mode 100644 index 00000000000..4f82cd61a46 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-export-crash.test.ts @@ -0,0 +1,112 @@ +import { spawnProcess } from '../../../shared/child-process/run-process' +import { mkdirSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { build } from 'esbuild' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { importProfileStateJson } from './profile-state-documents' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly, + profileStateDatabaseFile +} from './profile-state-database' +import { writeProfileStateDatabaseSnapshotAsync } from './profile-state-database-snapshot' + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +const interruptedExportScript = ` + import { openProfileStateDatabaseReadOnly } from './profile-state-database' + import { writeProfileStateDatabaseSnapshotAsync } from './profile-state-database-snapshot' + const { db } = openProfileStateDatabaseReadOnly(process.argv[2], 'profile-a') + writeProfileStateDatabaseSnapshotAsync(db, process.argv[3], { + validateStagedSnapshot: async (path) => { + process.stdout.write(path + '\\n') + await new Promise(() => setInterval(() => {}, 1_000)) + } + }).catch((error) => { console.error(error); process.exit(1) }) +` + +async function killBeforePublication(sourcePath: string, targetPath: string): Promise { + const childEntry = join(dirname(sourcePath), 'interrupted-export.cjs') + await build({ + stdin: { + contents: interruptedExportScript, + resolveDir: resolve('src/main/persistence/profile-state'), + loader: 'ts' + }, + outfile: childEntry, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) + const child = spawnProcess({ + program: process.execPath, + args: [childEntry, sourcePath, targetPath], + timeoutMs: 10_000 + }) + for (const stream of [child.stdin, child.stdout, child.stderr]) { + stream?.on('error', () => {}) + } + const exited = new Promise((resolve, reject) => { + child.once('close', () => resolve()) + child.once('error', reject) + }) + const temporaryPath = await new Promise((resolve, reject) => { + let output = '' + child.stdout.on('data', (chunk: Buffer | string) => { + output += String(chunk) + if (output.includes('\n')) { + resolve(output.trim()) + } + }) + child.once('close', () => reject(new Error('Export exited before staging completed'))) + child.once('error', reject) + }) + child.kill('SIGKILL') + await exited + return temporaryPath +} + +describe('profile state database export crash recovery', () => { + it('leaves the destination intact when the production backup dies before publication', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-export-crash-')) + temporaryDirectories.push(directory) + const sourcePath = profileStateDatabaseFile(directory) + const source = openProfileStateDatabase(sourcePath, 'profile-a') + importProfileStateJson(source.db, JSON.stringify({ settings: { theme: 'dark' } }), { + now: () => 100 + }) + source.db.close() + + const targetPath = join(directory, 'recovery', 'profile-state.db') + mkdirSync(join(directory, 'recovery'), { recursive: true }) + writeFileSync(targetPath, 'known-good-destination') + const interruptedPath = await killBeforePublication(sourcePath, targetPath) + + expect(readFileSync(targetPath, 'utf8')).toBe('known-good-destination') + expect(existsSync(interruptedPath)).toBe(true) + rmSync(interruptedPath, { force: true }) + + const recoveredSource = openProfileStateDatabase(sourcePath, 'profile-a') + try { + await writeProfileStateDatabaseSnapshotAsync(recoveredSource.db, targetPath) + } finally { + recoveredSource.db.close() + } + const snapshot = openProfileStateDatabaseReadOnly(targetPath, 'profile-a') + try { + expect( + snapshot.db.prepare('SELECT value FROM profile_state_meta WHERE key = ?').get('revision') + ).toEqual({ value: '1' }) + } finally { + snapshot.db.close() + } + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-database-publication.ts b/src/main/persistence/profile-state/profile-state-database-publication.ts new file mode 100644 index 00000000000..eb265b43398 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-publication.ts @@ -0,0 +1,38 @@ +import { dirname } from 'node:path' +import { publishFileDurableSync } from '../../durable-file-write' + +class ProfileStateDatabasePublicationError extends Error { + readonly code = 'profile-state-publication-unavailable' as const + + constructor(databaseFile: string, cause: unknown) { + super( + [ + `Orca could not safely publish profile state in ${dirname(databaseFile)}.`, + 'This location must support hard links, and Orca needs permission to create them.', + 'Close Orca and orcad before checking folder permissions or moving the complete Orca data directory to a writable local filesystem that supports hard links, such as APFS, NTFS, or ext4.', + 'Keep the original directory and all recovery files.' + ].join('\n'), + { cause } + ) + this.name = 'ProfileStateDatabasePublicationError' + } +} + +/** Preserve atomic no-overwrite publication while explaining filesystem refusals. */ +export function publishProfileStateDatabase(stagingFile: string, databaseFile: string): boolean { + try { + return publishFileDurableSync(stagingFile, databaseFile) + } catch (error) { + if ( + error instanceof Error && + 'syscall' in error && + error.syscall === 'link' && + 'code' in error && + typeof error.code === 'string' && + ['ENOTSUP', 'EOPNOTSUPP', 'ENOSYS', 'EPERM', 'EACCES', 'EXDEV'].includes(error.code) + ) { + throw new ProfileStateDatabasePublicationError(databaseFile, error) + } + throw error + } +} diff --git a/src/main/persistence/profile-state/profile-state-database-quarantine.ts b/src/main/persistence/profile-state/profile-state-database-quarantine.ts new file mode 100644 index 00000000000..8e15f9fcf0d --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-quarantine.ts @@ -0,0 +1,89 @@ +import { profileStateDatabaseFiles } from './profile-state-storage-classification' +import { randomUUID } from 'node:crypto' +import { existsSync, mkdirSync, rmSync } from 'node:fs' +import { basename, dirname, join } from 'node:path' +import { bestEffortFsyncDirectorySync, fsyncFileSync } from '../../../shared/secure-file' +import { durableWriteTempPath, writeFileDurableSync } from '../../durable-file-write' +import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files' +import { copyProfileStateRecoveryFiles } from './profile-state-recovery-copy' + +export type ProfileStateDatabaseQuarantine = { + directory: string + manifestPath: string + copiedFiles: readonly string[] +} + +/** + * Preserve a damaged profile database family before a caller attempts repair or fallback. + * Originals remain in place so this operation cannot turn a recovery failure into data loss. + */ +export function quarantineProfileStateDatabase( + databasePath: string, + profileId: string, + quarantineRoot = dirname(databasePath), + reason = 'profile-state-database-recovery', + recoveryFiles: readonly string[] = [] +): ProfileStateDatabaseQuarantine { + if (databasePath.length === 0 || databasePath.includes('\0') || profileId.length === 0) { + throw new Error('Profile state quarantine arguments are invalid') + } + const sourceFiles = profileStateDatabaseFiles(databasePath).filter(existsSync) + if (sourceFiles.length === 0 && recoveryFiles.length === 0) { + throw new Error('Profile state database family does not exist') + } + + const directory = join(quarantineRoot, `profile-state-corrupt-${Date.now()}-${randomUUID()}`) + const targets = new Set() + mkdirSync(directory, { recursive: true, mode: 0o700 }) + try { + const copies: { source: string; target: string }[] = [] + for (const sourcePath of sourceFiles) { + const targetName = + sourcePath === databasePath + ? 'profile-state.db' + : `profile-state.db${sourcePath.slice(databasePath.length)}` + const targetPath = join(directory, targetName) + copies.push({ source: sourcePath, target: targetPath }) + targets.add(targetPath) + } + for (const sourcePath of new Set(recoveryFiles)) { + const targetPath = join(directory, basename(sourcePath)) + if ( + targets.has(targetPath) || + existsSync(targetPath) || + basename(sourcePath) === 'manifest.json' + ) { + throw new Error('Profile recovery artifact name conflicts with the quarantine manifest') + } + copies.push({ source: sourcePath, target: targetPath }) + targets.add(targetPath) + } + copyProfileStateRecoveryFiles(copies) + const manifestPath = join(directory, 'manifest.json') + // Let the destination filesystem detect case or Unicode aliases of the manifest name. + if (existsSync(manifestPath)) { + throw new Error('Profile recovery artifact name conflicts with the quarantine manifest') + } + for (const { target } of copies) { + hardenSqliteDatabaseFiles(target) + fsyncFileSync(target) + } + writeFileDurableSync( + durableWriteTempPath(manifestPath), + manifestPath, + JSON.stringify({ + schemaVersion: 1, + profileId, + reason, + capturedAt: new Date().toISOString(), + sourceFiles: sourceFiles.map((sourcePath) => sourcePath.slice(databasePath.length)), + recoveryFiles: [...new Set(recoveryFiles)].map((sourcePath) => basename(sourcePath)) + }) + ) + bestEffortFsyncDirectorySync(directory) + return { directory, manifestPath, copiedFiles: [...targets] } + } catch (error) { + rmSync(directory, { recursive: true, force: true }) + throw error + } +} diff --git a/src/main/persistence/profile-state/profile-state-database-recovery.test.ts b/src/main/persistence/profile-state/profile-state-database-recovery.test.ts new file mode 100644 index 00000000000..34f61113c0a --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-recovery.test.ts @@ -0,0 +1,296 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import Database from '../../sqlite/sync-database' +import * as durableFileWrite from '../../durable-file-write' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from './profile-state-database' +import { exportProfileStateJson, importProfileStateJson } from './profile-state-documents' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath +} from './profile-state-backup-path' +import { writeProfileStateDatabaseSnapshotAsync } from './profile-state-database-snapshot' +import { restoreProfileStateDatabaseBackup } from './profile-state-database-recovery' +import { restoreProfileStateJsonExport } from './legacy-json/profile-state-recovery' +import { acquireProfileStateMaintenance } from './profile-state-access' +import { profileStateJsonExportPath } from './legacy-json/profile-state-export-path' + +const directories: string[] = [] +const profileId = 'profile-recovery-test' +const savedJson = JSON.stringify({ + settings: { theme: 'dark', httpProxyUrl: 'safe-storage-sealed-ciphertext' }, + ui: { unknownField: { keep: true } }, + opaqueExtension: { sequence: 71 } +}) + +afterEach(() => { + vi.restoreAllMocks() + for (const path of directories.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) + +async function fixture(options: { profileId?: string; empty?: boolean; json?: string } = {}) { + const root = mkdtempSync(join(tmpdir(), 'orca-database-recovery-')) + directories.push(root) + const directory = join(root, 'profiles', profileId) + mkdirSync(directory, { recursive: true }) + const maintenance = acquireProfileStateMaintenance(root) + const databasePath = join(directory, 'profile-state.db') + const dataFile = join(directory, 'orca-data.json') + const exportPath = profileStateJsonExportPath(dataFile, 1) + const backupPath = profileStateDatabaseBackupPath( + databasePath, + createProfileStateDatabaseBackupId() + ) + const source = openProfileStateDatabase( + join(directory, 'source.db'), + options.profileId ?? profileId + ) + try { + if (!options.empty) { + importProfileStateJson(source.db, options.json ?? savedJson) + } + await writeProfileStateDatabaseSnapshotAsync(source.db, backupPath) + } finally { + source.db.close() + } + writeFileSync(dataFile, '{"settings":{"theme":"stale"}}') + writeFileSync(exportPath, '{"settings":{"theme":"migration"}}') + for (const suffix of ['', '-wal', '-shm', '-journal']) { + writeFileSync(`${databasePath}${suffix}`, `damaged ${suffix || 'primary'}`) + } + return { databasePath, dataFile, backupPath, exportPath, profileId, maintenance } +} + +function readRestored(databasePath: string): string { + const opened = openProfileStateDatabaseReadOnly(databasePath, profileId) + try { + return exportProfileStateJson(opened.db) + } finally { + opened.db.close() + } +} + +function expectOriginals(options: Awaited>): void { + expect(readFileSync(options.databasePath, 'utf8')).toBe('damaged primary') + expect(readFileSync(options.dataFile, 'utf8')).toContain('stale') + expect(existsSync(options.backupPath)).toBe(true) + expect(readdirSync(dirname(options.databasePath)).some((name) => name.endsWith('.tmp'))).toBe( + false + ) +} + +describe('profile state database backup recovery', () => { + it('rejects corruption in a large cloned staging file before changing recovery state', async () => { + const options = await fixture({ + json: JSON.stringify({ opaqueExtension: 'x'.repeat(8 * 1024 * 1024) }) + }) + const backup = new Database(options.backupPath) + try { + backup.exec("UPDATE profile_state_documents SET content_hash = printf('%064d', 0)") + } finally { + backup.close() + } + const originalBackup = readFileSync(options.backupPath) + expect(() => restoreProfileStateDatabaseBackup(options)).toThrow() + expectOriginals(options) + expect(readFileSync(options.backupPath).equals(originalBackup)).toBe(true) + expect( + readdirSync(dirname(options.databasePath)).some((name) => + name.startsWith('.orca-recovery-clone-') + ) + ).toBe(false) + }) + + it.each([true, false])( + 'restores SQLite authority with the old database present=%s', + async (hasDatabase) => { + const options = await fixture() + const backupBytes = readFileSync(options.backupPath) + if (!hasDatabase) { + for (const suffix of ['', '-wal', '-shm', '-journal']) { + rmSync(`${options.databasePath}${suffix}`) + } + } + const beforeRestore = vi.fn() + + const result = restoreProfileStateDatabaseBackup({ ...options, beforeRestore }) + + expect(result.revision).toBe(1) + expect(beforeRestore).toHaveBeenCalledOnce() + expect(readRestored(options.databasePath)).toBe(savedJson) + expect(existsSync(options.dataFile)).toBe(false) + expect(readFileSync(options.backupPath)).toEqual(backupBytes) + expect(existsSync(options.exportPath)).toBe(false) + expect(readFileSync(join(result.quarantine.directory, basename(options.backupPath)))).toEqual( + backupBytes + ) + expect( + readFileSync(join(result.quarantine.directory, basename(options.dataFile)), 'utf8') + ).toContain('stale') + expect(existsSync(join(result.quarantine.directory, basename(options.exportPath)))).toBe(true) + if (hasDatabase) { + for (const suffix of ['', '-wal', '-shm', '-journal']) { + expect( + readFileSync(join(result.quarantine.directory, `profile-state.db${suffix}`), 'utf8') + ).toBe(`damaged ${suffix || 'primary'}`) + } + } + for (const suffix of ['-wal', '-shm', '-journal']) { + expect(existsSync(`${options.databasePath}${suffix}`)).toBe(false) + } + } + ) + + it.each(['foreign identity', 'empty profile'])( + 'rejects a %s backup before touching recovery state', + async (kind) => { + const options = await fixture( + kind === 'foreign identity' ? { profileId: 'other-profile' } : { empty: true } + ) + const beforeRestore = vi.fn() + expect(() => restoreProfileStateDatabaseBackup({ ...options, beforeRestore })).toThrow() + expect(beforeRestore).not.toHaveBeenCalled() + expectOriginals(options) + } + ) + + it.each(['corrupt hash', 'future schema', 'WAL mode'])( + 'rejects a backup with %s', + async (kind) => { + const options = await fixture() + const backup = new Database(options.backupPath) + try { + if (kind === 'corrupt hash') { + backup.exec("UPDATE profile_state_documents SET payload = '{}' WHERE domain = 'settings'") + } + if (kind === 'future schema') { + backup.pragma('user_version = 999') + } + if (kind === 'WAL mode') { + backup.pragma('journal_mode = WAL') + } + } finally { + backup.close() + } + const beforeRestore = vi.fn() + expect(() => restoreProfileStateDatabaseBackup({ ...options, beforeRestore })).toThrow() + expect(beforeRestore).not.toHaveBeenCalled() + expectOriginals(options) + } + ) + + it.each(['-wal', '-shm', '-journal'])( + 'rejects a selected backup with a %s sidecar', + async (suffix) => { + const options = await fixture() + writeFileSync(`${options.backupPath}${suffix}`, 'external writer evidence') + expect(() => restoreProfileStateDatabaseBackup(options)).toThrow('not self-contained') + expectOriginals(options) + } + ) + + it.skipIf(process.platform === 'win32')( + 'rejects a reserved-name symlink to a valid snapshot', + async () => { + const options = await fixture() + const alias = profileStateDatabaseBackupPath( + options.databasePath, + createProfileStateDatabaseBackupId() + ) + symlinkSync(options.backupPath, alias) + expect(() => restoreProfileStateDatabaseBackup({ ...options, backupPath: alias })).toThrow( + 'regular file' + ) + expectOriginals(options) + } + ) + + it('refuses a backup path outside the retained profile inventory', async () => { + const options = await fixture() + expect(() => + restoreProfileStateDatabaseBackup({ ...options, backupPath: options.databasePath }) + ).toThrow('not retained') + expectOriginals(options) + }) + + it('keeps live state untouched when an older artifact cannot be archived', async () => { + const options = await fixture() + mkdirSync(profileStateJsonExportPath(options.dataFile, 2)) + expect(() => restoreProfileStateDatabaseBackup(options)).toThrow() + expectOriginals(options) + }) + + it('keeps live state untouched when the pre-restore cache invalidation fails', async () => { + const options = await fixture() + expect(() => + restoreProfileStateDatabaseBackup({ + ...options, + beforeRestore: () => { + throw new Error('injected pre-restore failure') + } + }) + ).toThrow('injected pre-restore failure') + expectOriginals(options) + }) + + it('preserves recoverable evidence when publication fails after removing a damaged family', async () => { + const options = await fixture() + const renameDurableSync = durableFileWrite.renameDurableSync + vi.spyOn(durableFileWrite, 'renameDurableSync').mockImplementation((source, target) => { + if (target === options.databasePath) { + throw new Error('injected snapshot publication failure') + } + return renameDurableSync(source, target) + }) + expect(() => restoreProfileStateDatabaseBackup(options)).toThrow( + 'injected snapshot publication failure' + ) + expect(existsSync(options.databasePath)).toBe(false) + expect(existsSync(options.backupPath)).toBe(true) + const archives = readdirSync(dirname(options.databasePath)).filter((name) => + name.startsWith('profile-state-corrupt-') + ) + expect(archives).toHaveLength(1) + expect( + readFileSync(join(dirname(options.databasePath), archives[0], 'profile-state.db-wal'), 'utf8') + ).toBe('damaged -wal') + vi.restoreAllMocks() + options.maintenance.release() + options.maintenance = acquireProfileStateMaintenance( + dirname(dirname(dirname(options.dataFile))) + ) + restoreProfileStateDatabaseBackup(options) + expect(readRestored(options.databasePath)).toBe(savedJson) + }) + + it('archives and removes SQLite backups and sidecars when explicitly rolling back to JSON', async () => { + const options = await fixture() + writeFileSync(`${options.backupPath}-journal`, 'backup recovery evidence') + const recovered = restoreProfileStateJsonExport(options) + expect(existsSync(options.databasePath)).toBe(false) + expect(existsSync(options.backupPath)).toBe(false) + expect(existsSync(`${options.backupPath}-journal`)).toBe(false) + expect( + readFileSync( + join(recovered.quarantine.directory, `${basename(options.backupPath)}-journal`), + 'utf8' + ) + ).toBe('backup recovery evidence') + expect(readFileSync(options.dataFile, 'utf8')).toContain('migration') + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-database-recovery.ts b/src/main/persistence/profile-state/profile-state-database-recovery.ts new file mode 100644 index 00000000000..f561c976680 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-recovery.ts @@ -0,0 +1,113 @@ +import { profileStateDatabaseFiles } from './profile-state-storage-classification' +import { existsSync, lstatSync, mkdirSync, rmSync } from 'node:fs' +import { dirname } from 'node:path' +import { durableWriteTempPath, renameDurableSync } from '../../durable-file-write' +import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files' +import { bestEffortFsyncDirectorySync, fsyncFileSync } from '../../../shared/secure-file' +import { openProfileStateDatabaseReadOnly } from './profile-state-database' +import { validateProfileStateSnapshot } from './profile-state-documents' +import { copyProfileStateRecoveryFile } from './profile-state-recovery-copy' +import { + profileStateDatabaseBackups, + profileStateDatabaseBackupFiles +} from './profile-state-backup-path' +import { profileStateJsonExportPaths } from './legacy-json/profile-state-export-path' +import { assertProfileStateMaintenance, type ProfileStateMaintenance } from './profile-state-access' +import { + quarantineProfileStateDatabase, + type ProfileStateDatabaseQuarantine +} from './profile-state-database-quarantine' + +export type ProfileStateDatabaseRecoveryOptions = { + maintenance: ProfileStateMaintenance + databasePath: string + dataFile: string + backupPath: string + profileId: string + quarantineRoot?: string + reason?: string + beforeRestore?: () => void +} + +export type ProfileStateDatabaseRecovery = { + revision: number + quarantine: ProfileStateDatabaseQuarantine + removedDatabaseFiles: readonly string[] +} + +/** Replace the database family only while startup and offline access are excluded. */ +export function restoreProfileStateDatabaseBackup( + options: ProfileStateDatabaseRecoveryOptions +): ProfileStateDatabaseRecovery { + assertProfileStateMaintenance(options.maintenance, options) + const backups = profileStateDatabaseBackups(options.databasePath) + if (!backups.some((backup) => backup.path === options.backupPath)) { + throw new Error('Selected profile state database backup is not retained by this profile') + } + if (!lstatSync(options.backupPath).isFile()) { + throw new Error('Profile state database backup must be a regular file') + } + if (['-wal', '-shm', '-journal'].some((suffix) => existsSync(`${options.backupPath}${suffix}`))) { + throw new Error('Profile state database backup has sidecars and is not self-contained') + } + + mkdirSync(dirname(options.databasePath), { recursive: true }) + const stagingPath = durableWriteTempPath(options.databasePath) + try { + copyProfileStateRecoveryFile(options.backupPath, stagingPath) + hardenSqliteDatabaseFiles(stagingPath) + const revision = validateRecoverySnapshot(stagingPath, options.profileId) + fsyncFileSync(stagingPath) + const recoveryFiles = [ + ...profileStateDatabaseBackupFiles(options.databasePath), + ...profileStateJsonExportPaths(options.dataFile), + ...(existsSync(options.dataFile) ? [options.dataFile] : []) + ] + const quarantine = quarantineProfileStateDatabase( + options.databasePath, + options.profileId, + options.quarantineRoot, + options.reason ?? 'profile-state-database-rollback', + recoveryFiles + ) + options.beforeRestore?.() + + // JSON exports are revisioned for the legacy authority. Remove them after + // archiving so a later SQLite revision can publish a fresh export at the + // same number without colliding with an older divergent payload. + const retainedJsonExports = profileStateJsonExportPaths(options.dataFile) + for (const exportPath of retainedJsonExports) { + rmSync(exportPath) + } + + const removedDatabaseFiles = profileStateDatabaseFiles(options.databasePath).filter(existsSync) + // Remove the primary first: interruption must fail closed on retained backups, never replay old WAL. + for (const path of removedDatabaseFiles) { + rmSync(path) + } + rmSync(options.dataFile, { force: true }) + bestEffortFsyncDirectorySync(dirname(options.databasePath)) + renameDurableSync(stagingPath, options.databasePath) + return { revision, quarantine, removedDatabaseFiles } + } finally { + for (const path of profileStateDatabaseFiles(stagingPath)) { + rmSync(path, { force: true }) + } + } +} + +function validateRecoverySnapshot(path: string, profileId: string): number { + const opened = openProfileStateDatabaseReadOnly(path, profileId) + try { + if (opened.db.pragma('journal_mode', { simple: true }) !== 'delete') { + throw new Error('Profile state database backup must use a self-contained journal mode') + } + const revision = validateProfileStateSnapshot(opened.db) + if (revision === 0) { + throw new Error('Profile state database backup contains no committed profile state') + } + return revision + } finally { + opened.db.close() + } +} diff --git a/src/main/persistence/profile-state/profile-state-database-rollback-export.test.ts b/src/main/persistence/profile-state/profile-state-database-rollback-export.test.ts new file mode 100644 index 00000000000..29f1af65389 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-rollback-export.test.ts @@ -0,0 +1,159 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { expect, it, vi } from 'vitest' +import { Store } from '../loading-store/store' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { profileStateDatabaseBackups } from './profile-state-backup-path' +import { profileStateJsonExportPath } from './legacy-json/profile-state-export-path' +import { acquireProfileStateMaintenance } from './profile-state-access' +import { restoreProfileStateJsonExport } from './legacy-json/profile-state-recovery' +import { openProfileStateDatabase } from './profile-state-database' +import { + bootstrapProfileStateAuthority, + ProfileStateAuthorityBootstrapError +} from './profile-state-authority-bootstrap' +import { restoreProfileStateDatabaseBackup } from './profile-state-database-recovery' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`), + decryptString: (value: Buffer) => value.toString().slice('encrypted:'.length) + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +it('can publish an updater JSON export at a reused revision after SQLite rollback', async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-database-rollback-export-')) + const directory = join(root, 'profiles', 'rollback-export') + mkdirSync(directory, { recursive: true }) + const databasePath = join(directory, 'profile-state.db') + const dataFile = join(directory, 'orca-data.json') + const profileId = 'rollback-export' + const stores: Store[] = [] + try { + const originalAuthority = new ProfileStateSqliteAuthority(databasePath, profileId) + const original = new Store({ + dataFile, + profileStateAuthority: originalAuthority + }) + stores.push(original) + original.updateSettings({ theme: 'light' }) + await original.flushPendingOrThrowAsync() + await originalAuthority.drainBackups() + const backup = profileStateDatabaseBackups(databasePath)[0] + expect(backup).toBeDefined() + original.updateSettings({ theme: 'dark' }) + const formerRevision = original.writeLatestProfileStateJsonExport() + expect(formerRevision).toBeTypeOf('number') + if (!backup || formerRevision === undefined) { + throw new Error('Missing recovery fixture') + } + const exportPath = profileStateJsonExportPath(dataFile, formerRevision) + const previousExport = readFileSync(exportPath) + original.freezeWrites() + await original.flushAsync() + + const restored = restoreProfileStateDatabaseBackup({ + maintenance: acquireProfileStateMaintenance(root), + databasePath, + dataFile, + profileId, + backupPath: backup.path + }) + expect(existsSync(exportPath)).toBe(false) + expect(readFileSync(join(restored.quarantine.directory, basename(exportPath)))).toEqual( + previousExport + ) + const recovered = new Store({ + dataFile, + profileStateAuthority: new ProfileStateSqliteAuthority(databasePath, profileId) + }) + stores.push(recovered) + recovered.updateSettings({ theme: 'system' }) + const newRevision = recovered.writeLatestProfileStateJsonExport() + + expect(newRevision).toBe(formerRevision) + expect(JSON.parse(readFileSync(exportPath, 'utf8')).settings.theme).toBe('system') + expect(readFileSync(exportPath)).not.toEqual(previousExport) + } finally { + for (const store of stores) { + store.freezeWrites() + await store.flushAsync() + } + rmSync(root, { recursive: true, force: true }) + } +}) + +it('leaves an explicit rollback path if compatibility publication fails before acceptance', async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-compat-failure-')) + const directory = join(root, 'profiles', 'profile-authority-test') + mkdirSync(directory, { recursive: true }) + const dataFile = join(directory, 'orca-data.json') + const databasePath = join(directory, 'profile-state.db') + writeFileSync(dataFile, JSON.stringify({ settings: { theme: 'light' } }), 'utf8') + + const authority = new ProfileStateSqliteAuthority(databasePath, 'profile-authority-test') + const store = new Store({ dataFile, profileStateAuthority: authority }) + try { + store.updateSettings({ theme: 'dark' }) + store.flushOrThrow() + const revisionOne = store.writeLatestProfileStateJsonExport() + expect(revisionOne).toBe(1) + + const opened = openProfileStateDatabase(databasePath, 'profile-authority-test') + opened.db.exec( + `CREATE TRIGGER fail_compatibility_acceptance + BEFORE INSERT ON profile_state_meta + WHEN NEW.key = 'legacy_json_acceptance' + BEGIN SELECT RAISE(ABORT, 'injected compatibility failure'); END` + ) + opened.db.close() + + store.updateSettings({ theme: 'light' }) + expect(() => store.writeLatestProfileStateJsonCompatibilityExport()).toThrow( + 'injected compatibility failure' + ) + expect(JSON.parse(readFileSync(dataFile, 'utf8')).settings.theme).toBe('light') + expect(() => + bootstrapProfileStateAuthority({ + dataFile, + databaseFile: databasePath, + profileId: 'profile-authority-test' + }) + ).toThrow(ProfileStateAuthorityBootstrapError) + + store.freezeWrites() + await store.flushAsync() + restoreProfileStateJsonExport({ + maintenance: acquireProfileStateMaintenance(root), + databasePath, + dataFile, + exportPath: profileStateJsonExportPath(dataFile, revisionOne ?? 1), + profileId: 'profile-authority-test' + }) + expect(JSON.parse(readFileSync(dataFile, 'utf8')).settings.theme).toBe('dark') + } finally { + store.freezeWrites() + await store.flushAsync() + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/src/main/persistence/profile-state/profile-state-database-schema.ts b/src/main/persistence/profile-state/profile-state-database-schema.ts new file mode 100644 index 00000000000..4f6cd453825 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-schema.ts @@ -0,0 +1,41 @@ +// Schema 3 requires explicit automation storage metadata even when history is empty. + +import { + PROFILE_STATE_AUTOMATION_RUNS_META_TABLE, + PROFILE_STATE_AUTOMATION_RUNS_TABLE +} from './profile-state-automation-runs-model' + +export const PROFILE_STATE_DATABASE_SCHEMA_VERSION = 3 +export const PROFILE_STATE_DOCUMENT_VERSION = 1 + +export const PROFILE_STATE_META_PROFILE_ID = 'profile_id' +export const PROFILE_STATE_META_REVISION = 'revision' +/** Records the legacy JSON bytes accepted by the SQLite authority bootstrap. */ +export const PROFILE_STATE_META_LEGACY_JSON_ACCEPTANCE = 'legacy_json_acceptance' + +export function createProfileStateTablesSql(): string { + return `CREATE TABLE IF NOT EXISTS profile_state_meta ( + key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS profile_state_documents ( + domain TEXT PRIMARY KEY NOT NULL, payload TEXT NOT NULL, + domain_version INTEGER NOT NULL, revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL, content_hash TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS ${PROFILE_STATE_AUTOMATION_RUNS_META_TABLE} ( + domain TEXT PRIMARY KEY NOT NULL, + presence TEXT NOT NULL, + domain_version INTEGER NOT NULL, + revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + content_hash TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS ${PROFILE_STATE_AUTOMATION_RUNS_TABLE} ( + run_id TEXT PRIMARY KEY NOT NULL, + ordinal INTEGER NOT NULL, + payload TEXT NOT NULL, + content_hash TEXT NOT NULL, + revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL + );` +} diff --git a/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts b/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts new file mode 100644 index 00000000000..e0147cb937f --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts @@ -0,0 +1,328 @@ +import { + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly, + profileStateDatabaseFile +} from './profile-state-database' +import { exportProfileStateJson, importProfileStateJson } from './profile-state-documents' +import { writeProfileStateDatabaseSnapshotAsync } from './profile-state-database-snapshot' +import type Database from '../../sqlite/sync-database' +import * as durableFileWrite from '../../durable-file-write' +import * as fsPromises from 'node:fs/promises' + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual } +}) + +const directories: string[] = [] +const databases: Database.Database[] = [] + +afterEach(() => { + vi.restoreAllMocks() + for (const db of databases.splice(0)) { + db.close() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-async-snapshot-')) + directories.push(directory) + const databasePath = profileStateDatabaseFile(directory) + const { db } = openProfileStateDatabase(databasePath, 'profile-a') + databases.push(db) + const originalJson = JSON.stringify({ settings: { theme: 'light' } }) + importProfileStateJson(db, originalJson) + return { directory, databasePath, db, targetPath: join(directory, 'snapshot.db'), originalJson } +} + +function readSnapshot(path: string): string { + const { db } = openProfileStateDatabaseReadOnly(path, 'profile-a') + try { + return exportProfileStateJson(db) + } finally { + db.close() + } +} + +function expectNoTemporaryFiles(directory: string): void { + expect(readdirSync(directory).filter((name) => name.includes('.tmp'))).toEqual([]) +} + +describe('asynchronous profile-state database snapshots', () => { + it('publishes a hardened self-contained snapshot including committed WAL pages', async () => { + const { directory, databasePath, db, targetPath, originalJson } = fixture() + expect(existsSync(`${databasePath}-wal`)).toBe(true) + const sourceFile = readFileSync(databasePath) + + await writeProfileStateDatabaseSnapshotAsync(db, targetPath) + + expect(readSnapshot(targetPath)).toBe(originalJson) + expect(existsSync(`${targetPath}-wal`)).toBe(false) + expect(existsSync(`${targetPath}-shm`)).toBe(false) + if (process.platform !== 'win32') { + expect(statSync(targetPath).mode & 0o777).toBe(0o600) + } + expect(exportProfileStateJson(db)).toBe(originalJson) + expect(db.pragma('journal_mode', { simple: true })).toBe('wal') + expect(readFileSync(databasePath)).toEqual(sourceFile) + importProfileStateJson(db, JSON.stringify({ settings: { theme: 'dark' } })) + expect(readSnapshot(targetPath)).toBe(originalJson) + expectNoTemporaryFiles(directory) + }) + + it('never removes an existing staging file when exclusive creation fails', async () => { + const { db, databasePath, targetPath, originalJson } = fixture() + await expect( + writeProfileStateDatabaseSnapshotAsync(db, targetPath, { temporaryPath: databasePath }) + ).rejects.toThrow() + expect(existsSync(databasePath)).toBe(true) + expect(exportProfileStateJson(db)).toBe(originalJson) + expect(existsSync(targetPath)).toBe(false) + }) + + // Node's incremental backup callback is not part of Bun's worker snapshot contract. + it.skipIf(!!process.versions.bun).each(['same connection', 'another connection'] as const)( + 'keeps a consistent complete revision while writes occur from %s', + async (connection) => { + const { directory, databasePath, db, targetPath } = fixture() + const writer = + connection === 'same connection' + ? db + : openProfileStateDatabase(databasePath, 'profile-a').db + if (writer !== db) { + databases.push(writer) + } + const padding = 'x'.repeat(256_000) + importProfileStateJson( + db, + JSON.stringify({ settings: { value: 1 }, ui: { value: 1 }, padding }) + ) + const nextJson = JSON.stringify({ settings: { value: 2 }, ui: { value: 2 }, padding }) + const nativeBackup = db.backup.bind(db) + let wroteDuringBackup = false + vi.spyOn(db, 'backup').mockImplementation((path) => + nativeBackup(path, { + rate: 1, + progress: ({ remainingPages }) => { + if (remainingPages > 0 && !wroteDuringBackup) { + wroteDuringBackup = true + importProfileStateJson(writer, nextJson) + } + } + }) + ) + + await writeProfileStateDatabaseSnapshotAsync(db, targetPath) + + expect(wroteDuringBackup).toBe(true) + expect(readSnapshot(targetPath)).toBe(nextJson) + expect(exportProfileStateJson(db)).toBe(nextJson) + expectNoTemporaryFiles(directory) + } + ) + + it('preserves the previous destination and removes staging after native backup fails', async () => { + const { directory, db, targetPath, originalJson } = fixture() + await writeProfileStateDatabaseSnapshotAsync(db, targetPath) + const previous = readFileSync(targetPath) + vi.spyOn(db, 'backup').mockImplementation(async (path) => { + writeFileSync(path, 'incomplete backup') + writeFileSync(`${path}-journal`, 'incomplete journal') + throw new Error('injected native backup failure') + }) + + await expect(writeProfileStateDatabaseSnapshotAsync(db, targetPath)).rejects.toThrow( + 'injected native backup failure' + ) + + expect(readFileSync(targetPath)).toEqual(previous) + expect(exportProfileStateJson(db)).toBe(originalJson) + expectNoTemporaryFiles(directory) + }) + + it('validates staged content before replacing the previous destination', async () => { + const { directory, db, targetPath } = fixture() + writeFileSync(targetPath, 'previous recovery artifact') + + await expect( + writeProfileStateDatabaseSnapshotAsync(db, targetPath, { + validateStagedSnapshot: () => { + throw new Error('staged validation failed') + } + }) + ).rejects.toThrow('staged validation failed') + + expect(readFileSync(targetPath, 'utf8')).toBe('previous recovery artifact') + expectNoTemporaryFiles(directory) + }) + + it('preserves the previous destination when publication fails', async () => { + const { directory, db, targetPath } = fixture() + await writeProfileStateDatabaseSnapshotAsync(db, targetPath) + const previous = readFileSync(targetPath) + importProfileStateJson(db, JSON.stringify({ settings: { theme: 'dark' } })) + vi.spyOn(durableFileWrite, 'renameDurable').mockRejectedValue( + new Error('injected rename failure') + ) + + await expect(writeProfileStateDatabaseSnapshotAsync(db, targetPath)).rejects.toThrow( + 'injected rename failure' + ) + + expect(readFileSync(targetPath)).toEqual(previous) + expectNoTemporaryFiles(directory) + }) + + it('rejects active transactions without publishing uncommitted state', async () => { + const { directory, db, targetPath, originalJson } = fixture() + await writeProfileStateDatabaseSnapshotAsync(db, targetPath) + const previous = readFileSync(targetPath) + db.exec('BEGIN IMMEDIATE') + try { + db.exec("UPDATE profile_state_documents SET payload = '{}' WHERE domain = 'settings'") + await expect(writeProfileStateDatabaseSnapshotAsync(db, targetPath)).rejects.toThrow( + 'idle database connection' + ) + } finally { + db.exec('ROLLBACK') + } + + expect(readFileSync(targetPath)).toEqual(previous) + expect(exportProfileStateJson(db)).toBe(originalJson) + expectNoTemporaryFiles(directory) + }) + + it('checks again if a transaction starts while the backup is staging', async () => { + const { directory, db, targetPath } = fixture() + const nativeBackup = db.backup.bind(db) + vi.spyOn(db, 'backup').mockImplementation((path) => { + db.exec('BEGIN IMMEDIATE') + return nativeBackup(path) + }) + try { + await expect(writeProfileStateDatabaseSnapshotAsync(db, targetPath)).rejects.toThrow( + 'idle database connection' + ) + } finally { + db.exec('ROLLBACK') + } + expect(existsSync(targetPath)).toBe(false) + expectNoTemporaryFiles(directory) + }) + + it('leaves the previous destination intact when the staged file cannot be fsynced', async () => { + const { directory, db, targetPath } = fixture() + writeFileSync(targetPath, 'previous recovery artifact') + const nativeOpen = fsPromises.open + vi.spyOn(fsPromises, 'open').mockImplementation(async (...args) => { + const file = await nativeOpen(...args) + if (args[1] === 'r+') { + vi.spyOn(file, 'sync').mockRejectedValue(new Error('injected fsync failure')) + } + return file + }) + + await expect(writeProfileStateDatabaseSnapshotAsync(db, targetPath)).rejects.toThrow( + 'injected fsync failure' + ) + + expect(readFileSync(targetPath, 'utf8')).toBe('previous recovery artifact') + expectNoTemporaryFiles(directory) + }) + + it.skipIf(!!process.versions.bun)( + 'fails clearly when native backup is unsupported without replacing the destination', + async () => { + const { directory, db, targetPath } = fixture() + writeFileSync(targetPath, 'previous recovery artifact') + const getBuiltinModule = process.getBuiltinModule.bind(process) + vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => + id === 'node:sqlite' ? {} : getBuiltinModule(id) + ) + + await expect(writeProfileStateDatabaseSnapshotAsync(db, targetPath)).rejects.toThrow( + 'Asynchronous SQLite backup is unavailable' + ) + + expect(readFileSync(targetPath, 'utf8')).toBe('previous recovery artifact') + expectNoTemporaryFiles(directory) + } + ) + + it.each(['', 'invalid\0path'])('rejects the invalid target %j', async (path) => { + const { db } = fixture() + await expect(writeProfileStateDatabaseSnapshotAsync(db, path)).rejects.toThrow( + 'snapshot path is invalid' + ) + }) + + it.each(['', '-wal', '-shm', '-journal'])( + 'refuses to replace the source database%s', + async (suffix) => { + const { databasePath, db, originalJson } = fixture() + await expect( + writeProfileStateDatabaseSnapshotAsync(db, `${databasePath}${suffix}`) + ).rejects.toThrow('cannot replace a source database') + expect(exportProfileStateJson(db)).toBe(originalJson) + } + ) + + it('rejects a source database reached through a directory alias', async () => { + const { directory, db, originalJson } = fixture() + const alias = join(directory, 'alias') + symlinkSync(directory, alias, 'junction') + await expect( + writeProfileStateDatabaseSnapshotAsync(db, join(alias, 'profile-state.db')) + ).rejects.toThrow('cannot replace a source database') + expect(exportProfileStateJson(db)).toBe(originalJson) + }) + + it.for(['', '-WAL', '-SHM', '-JOURNAL'])( + 'refuses source database%s case aliases on case-insensitive filesystems', + async (suffix, { skip }) => { + const { directory, db, originalJson } = fixture() + const alias = join(directory, 'PROFILE-STATE.DB') + if (!existsSync(alias)) { + skip() + return + } + await expect(writeProfileStateDatabaseSnapshotAsync(db, `${alias}${suffix}`)).rejects.toThrow( + 'cannot replace a source database' + ) + expect(exportProfileStateJson(db)).toBe(originalJson) + } + ) + + it.each(['-wal', '-shm', '-journal'])( + 'preserves a destination with an existing %s sidecar', + async (suffix) => { + const { directory, db, targetPath } = fixture() + writeFileSync(targetPath, 'previous recovery artifact') + writeFileSync(`${targetPath}${suffix}`, 'retained SQLite sidecar') + + await expect(writeProfileStateDatabaseSnapshotAsync(db, targetPath)).rejects.toThrow( + 'destination has SQLite sidecars' + ) + + expect(readFileSync(targetPath, 'utf8')).toBe('previous recovery artifact') + expect(readFileSync(`${targetPath}${suffix}`, 'utf8')).toBe('retained SQLite sidecar') + expectNoTemporaryFiles(directory) + } + ) +}) diff --git a/src/main/persistence/profile-state/profile-state-database-snapshot.ts b/src/main/persistence/profile-state/profile-state-database-snapshot.ts new file mode 100644 index 00000000000..9cc89d58b59 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-snapshot.ts @@ -0,0 +1,123 @@ +import { profileStateDatabaseFiles } from './profile-state-storage-classification' +import { access, mkdir, open, realpath, rm, stat } from 'node:fs/promises' +import { basename, dirname, resolve } from 'node:path' +import Database from '../../sqlite/sync-database' +import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files' +import { durableWriteTempPath, renameDurable } from '../../durable-file-write' + +/** The caller owns the destination and keeps the source open until this backup settles. */ +export async function writeProfileStateDatabaseSnapshotAsync( + db: Database.Database, + targetPath: string, + options: { + temporaryPath?: string + validateStagedSnapshot?: (stagingPath: string) => Promise | void + } = {} +): Promise { + if (targetPath.length === 0 || targetPath.includes('\0')) { + throw new Error('Profile state snapshot path is invalid') + } + if (db.isTransaction) { + throw new Error('Profile state database snapshot requires an idle database connection') + } + await mkdir(dirname(targetPath), { recursive: true }) + await assertSnapshotTargetIsSeparate(db, targetPath) + await assertNoSnapshotSidecars(targetPath) + const temporaryPath = options.temporaryPath ?? durableWriteTempPath(targetPath) + let published = false + let created = false + try { + // Pre-create privately: the native backup otherwise creates a world-readable temporary file. + const temporary = await open(temporaryPath, 'wx', 0o600) + created = true + await temporary.close() + await db.backup(temporaryPath) + // The native copy preserves WAL mode; snapshots must not create sidecars when opened read-only. + const snapshot = new Database(temporaryPath, { fileMustExist: true }) + try { + if (snapshot.pragma('journal_mode = DELETE', { simple: true }) !== 'delete') { + throw new Error('Profile state snapshot could not become a self-contained database') + } + } finally { + snapshot.close() + } + hardenSqliteDatabaseFiles(temporaryPath) + const completed = await open(temporaryPath, 'r+') + try { + await completed.sync() + } finally { + await completed.close() + } + await options.validateStagedSnapshot?.(temporaryPath) + await assertNoSnapshotSidecars(targetPath) + await renameDurable(temporaryPath, targetPath) + published = true + } finally { + if (created && !published) { + await rm(temporaryPath, { force: true }) + } + if (created) { + await Promise.all( + ['-wal', '-shm', '-journal'].map((suffix) => + rm(`${temporaryPath}${suffix}`, { force: true }) + ) + ) + } + } +} + +async function assertSnapshotTargetIsSeparate( + db: Database.Database, + targetPath: string +): Promise { + const target = await realpath(targetPath).catch(async (error: unknown) => { + if (!isMissingPath(error)) { + throw error + } + return resolve(await realpath(dirname(targetPath)), basename(targetPath)) + }) + const databases = db.prepare('PRAGMA database_list').all() + for (const database of databases) { + if (typeof database.file !== 'string' || database.file.length === 0) { + continue + } + const source = await realpath(database.file) + if (profileStateDatabaseFiles(source).includes(target)) { + throw new Error('Profile state snapshot cannot replace a source database or its sidecars') + } + // realpath preserves case aliases on macOS; file identity also protects absent sidecar names. + const sourceInfo = await stat(source, { bigint: true }) + for (const candidate of new Set([target, target.replace(/-(?:wal|shm|journal)$/i, '')])) { + const targetInfo = await stat(candidate, { bigint: true }).catch((error: unknown) => { + if (!isMissingPath(error)) { + throw error + } + return undefined + }) + if (targetInfo?.dev === sourceInfo.dev && targetInfo.ino === sourceInfo.ino) { + throw new Error('Profile state snapshot cannot replace a source database or its sidecars') + } + } + } +} + +async function assertNoSnapshotSidecars(targetPath: string): Promise { + for (const suffix of ['-wal', '-shm', '-journal']) { + const exists = await access(`${targetPath}${suffix}`).then( + () => true, + (error: unknown) => { + if (!isMissingPath(error)) { + throw error + } + return false + } + ) + if (exists) { + throw new Error('Profile state snapshot destination has SQLite sidecars') + } + } +} + +function isMissingPath(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} diff --git a/src/main/persistence/profile-state/profile-state-database-validation.ts b/src/main/persistence/profile-state/profile-state-database-validation.ts new file mode 100644 index 00000000000..3bd74421965 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-validation.ts @@ -0,0 +1,146 @@ +import { withProfileStateReadSnapshot } from './profile-state-read-snapshot' +import type Database from '../../sqlite/sync-database' +import { readCurrentAutomationRunsState } from './profile-state-automation-runs-storage' +import { readProfileStateRevision } from './profile-state-revision' +import { + PROFILE_STATE_META_PROFILE_ID, + PROFILE_STATE_DATABASE_SCHEMA_VERSION +} from './profile-state-database-schema' +import { + PROFILE_STATE_AUTOMATION_RUNS_META_TABLE, + PROFILE_STATE_AUTOMATION_RUNS_TABLE +} from './profile-state-automation-runs' +import { ProfileStateDatabaseOpenError } from './profile-state-database-errors' + +export function verifyProfileStateSchema( + db: Database.Database, + profileId: string, + schemaVersion = PROFILE_STATE_DATABASE_SCHEMA_VERSION +): void { + withProfileStateReadSnapshot(db, () => { + const tables = profileStateTableNames(db) + if ( + !tables.has('profile_state_meta') || + !tables.has('profile_state_documents') || + (schemaVersion >= 2 && + (!tables.has(PROFILE_STATE_AUTOMATION_RUNS_META_TABLE) || + !tables.has(PROFILE_STATE_AUTOMATION_RUNS_TABLE))) + ) { + throw new Error('Profile state database schema is incomplete') + } + + verifyProfileStateColumns(db, 'profile_state_meta', { + key: { type: 'TEXT', notNull: true, primaryKey: true }, + value: { type: 'TEXT', notNull: true, primaryKey: false } + }) + verifyProfileStateColumns(db, 'profile_state_documents', { + domain: { type: 'TEXT', notNull: true, primaryKey: true }, + payload: { type: 'TEXT', notNull: true, primaryKey: false }, + domain_version: { type: 'INTEGER', notNull: true, primaryKey: false }, + revision: { type: 'INTEGER', notNull: true, primaryKey: false }, + updated_at: { type: 'INTEGER', notNull: true, primaryKey: false }, + content_hash: { type: 'TEXT', notNull: true, primaryKey: false } + }) + if (schemaVersion >= 2) { + verifyProfileStateColumns(db, PROFILE_STATE_AUTOMATION_RUNS_META_TABLE, { + domain: { type: 'TEXT', notNull: true, primaryKey: true }, + presence: { type: 'TEXT', notNull: true, primaryKey: false }, + domain_version: { type: 'INTEGER', notNull: true, primaryKey: false }, + revision: { type: 'INTEGER', notNull: true, primaryKey: false }, + updated_at: { type: 'INTEGER', notNull: true, primaryKey: false }, + content_hash: { type: 'TEXT', notNull: true, primaryKey: false } + }) + verifyProfileStateColumns(db, PROFILE_STATE_AUTOMATION_RUNS_TABLE, { + run_id: { type: 'TEXT', notNull: true, primaryKey: true }, + ordinal: { type: 'INTEGER', notNull: true, primaryKey: false }, + payload: { type: 'TEXT', notNull: true, primaryKey: false }, + content_hash: { type: 'TEXT', notNull: true, primaryKey: false }, + revision: { type: 'INTEGER', notNull: true, primaryKey: false }, + updated_at: { type: 'INTEGER', notNull: true, primaryKey: false } + }) + } + + verifyProfileStateIdentity(db, profileId) + if (schemaVersion >= 3) { + readCurrentAutomationRunsState(db, readProfileStateRevision(db)) + } + }) +} + +export function verifyEmptyProfileStateSchema(db: Database.Database): void { + if (profileStateTableNames(db).size > 0) { + throw new Error('Profile state database has an unexpected version-0 schema') + } +} + +function verifyProfileStateColumns( + db: Database.Database, + table: string, + expected: Readonly> +): void { + const rows = db.pragma(`table_info(${table})`) + if (!Array.isArray(rows)) { + throw new Error(`Profile state table has no readable columns: ${table}`) + } + const columns = new Map() + for (const row of rows) { + if ( + !isRecord(row) || + typeof row.name !== 'string' || + typeof row.type !== 'string' || + typeof row.notnull !== 'number' || + typeof row.pk !== 'number' + ) { + throw new Error(`Profile state table has malformed column metadata: ${table}`) + } + columns.set(row.name, { + type: row.type.toUpperCase(), + notNull: row.notnull === 1, + primaryKey: row.pk === 1 + }) + } + for (const [name, definition] of Object.entries(expected)) { + const actual = columns.get(name) + if ( + actual === undefined || + actual.type !== definition.type || + actual.notNull !== definition.notNull || + actual.primaryKey !== definition.primaryKey + ) { + throw new Error(`Profile state table has an incompatible column: ${table}.${name}`) + } + } +} + +function profileStateTableNames(db: Database.Database): Set { + return new Set( + db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all() + .map((row) => (isRecord(row) && typeof row.name === 'string' ? row.name : undefined)) + .filter((name): name is string => name !== undefined) + ) +} + +function verifyProfileStateIdentity(db: Database.Database, profileId: string): void { + const storedProfileIdRow = db + .prepare('SELECT value FROM profile_state_meta WHERE key = ?') + .get(PROFILE_STATE_META_PROFILE_ID) + const storedProfileId = + isRecord(storedProfileIdRow) && typeof storedProfileIdRow.value === 'string' + ? storedProfileIdRow.value + : undefined + if (storedProfileId === undefined) { + throw new Error('Profile state database is missing its profile identity') + } + if (storedProfileId !== profileId) { + throw new ProfileStateDatabaseOpenError( + 'identity-mismatch', + 'Profile state database belongs to a different profile' + ) + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/persistence/profile-state/profile-state-database.test.ts b/src/main/persistence/profile-state/profile-state-database.test.ts new file mode 100644 index 00000000000..c3cb63fb114 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database.test.ts @@ -0,0 +1,339 @@ +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import Database from '../../sqlite/sync-database' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly, + profileStateDatabaseFile, + profileStatePragmaNumber, + PROFILE_STATE_BUSY_TIMEOUT_MS, + PROFILE_STATE_DATABASE_FILE_NAME +} from './profile-state-database' +import { PROFILE_STATE_DATABASE_SCHEMA_VERSION } from './profile-state-database-schema' +import { importProfileStateJson } from './profile-state-documents' +import { quarantineProfileStateDatabase } from './profile-state-database-quarantine' + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function createDirectory(): string { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-db-')) + temporaryDirectories.push(directory) + return directory +} + +describe('profile state database', () => { + it('creates an isolated per-profile schema with durable pragmas', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const opened = openProfileStateDatabase(dbPath, 'profile-a') + try { + expect(dbPath).toBe(join(directory, PROFILE_STATE_DATABASE_FILE_NAME)) + expect(opened.readOnly).toBe(false) + expect(opened.profileId).toBe('profile-a') + expect(profileStatePragmaNumber(opened.db, 'user_version')).toBe( + PROFILE_STATE_DATABASE_SCHEMA_VERSION + ) + expect(opened.db.pragma('journal_mode', { simple: true })).toBe('wal') + expect(profileStatePragmaNumber(opened.db, 'synchronous')).toBe(2) + expect(profileStatePragmaNumber(opened.db, 'busy_timeout')).toBe( + PROFILE_STATE_BUSY_TIMEOUT_MS + ) + expect(profileStatePragmaNumber(opened.db, 'foreign_keys')).toBe(1) + expect( + opened.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name").all() + ).toEqual([ + { name: 'profile_state_automation_runs' }, + { name: 'profile_state_automation_runs_meta' }, + { name: 'profile_state_documents' }, + { name: 'profile_state_meta' } + ]) + expect( + opened.db.prepare('SELECT value FROM profile_state_meta WHERE key = ?').get('profile_id') + ).toEqual({ value: 'profile-a' }) + } finally { + opened.db.close() + } + }) + + it('migrates the version-1 document schema by adding normalized run tables', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const seeded = new Database(dbPath) + seeded.exec(` + PRAGMA user_version = 1; + CREATE TABLE profile_state_meta (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL); + CREATE TABLE profile_state_documents ( + domain TEXT PRIMARY KEY NOT NULL, + payload TEXT NOT NULL, + domain_version INTEGER NOT NULL, + revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + content_hash TEXT NOT NULL + ); + INSERT INTO profile_state_meta (key, value) VALUES ('profile_id', 'profile-a'); + INSERT INTO profile_state_meta (key, value) VALUES ('revision', '1'); + INSERT INTO profile_state_documents + (domain, payload, domain_version, revision, updated_at, content_hash) + VALUES ('settings', '{"theme":"dark"}', 1, 1, 100, '0f4f87db4567232a7f1756aa1534ec1314777b39c3bf5209f87cf9739321cddc'); + `) + seeded.close() + + const opened = openProfileStateDatabase(dbPath, 'profile-a') + try { + expect(profileStatePragmaNumber(opened.db, 'user_version')).toBe( + PROFILE_STATE_DATABASE_SCHEMA_VERSION + ) + expect( + opened.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name").all() + ).toEqual([ + { name: 'profile_state_automation_runs' }, + { name: 'profile_state_automation_runs_meta' }, + { name: 'profile_state_documents' }, + { name: 'profile_state_meta' } + ]) + expect( + opened.db + .prepare('SELECT payload FROM profile_state_documents WHERE domain = ?') + .get('settings') + ).toEqual({ payload: '{"theme":"dark"}' }) + } finally { + opened.db.close() + } + }) + + it('validates normalized tables created during migration', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const seeded = new Database(dbPath) + seeded.exec(` + PRAGMA user_version = 1; + CREATE TABLE profile_state_meta (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL); + CREATE TABLE profile_state_documents ( + domain TEXT PRIMARY KEY NOT NULL, + payload TEXT NOT NULL, + domain_version INTEGER NOT NULL, + revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + content_hash TEXT NOT NULL + ); + CREATE TABLE profile_state_automation_runs ( + run_id TEXT PRIMARY KEY NOT NULL, + ordinal INTEGER NOT NULL, + payload BLOB NOT NULL, + content_hash TEXT NOT NULL, + revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + INSERT INTO profile_state_meta (key, value) VALUES ('profile_id', 'profile-a'); + `) + seeded.close() + + expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( + expect.objectContaining({ code: 'unreadable' }) + ) + }) + + it('latches a future schema read-only without changing the database file', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const seeded = openProfileStateDatabase(dbPath, 'profile-a') + seeded.db.pragma(`user_version = ${PROFILE_STATE_DATABASE_SCHEMA_VERSION + 9}`) + seeded.db.close() + const before = statSync(dbPath) + const beforeBytes = readFileSync(dbPath) + + const opened = openProfileStateDatabase(dbPath, 'profile-a') + try { + expect(opened.readOnly).toBe(true) + expect(profileStatePragmaNumber(opened.db, 'user_version')).toBe( + PROFILE_STATE_DATABASE_SCHEMA_VERSION + 9 + ) + expect(() => opened.db.exec("INSERT INTO profile_state_meta VALUES ('x', 'y')")).toThrow() + } finally { + opened.db.close() + } + const after = statSync(dbPath) + expect(after.size).toBe(before.size) + expect(readFileSync(dbPath)).toEqual(beforeBytes) + }) + + it('opens the current schema read-only without changing its bytes', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const seeded = openProfileStateDatabase(dbPath, 'profile-a') + seeded.db.close() + const before = readFileSync(dbPath) + + const opened = openProfileStateDatabaseReadOnly(dbPath, 'profile-a') + try { + expect(opened.readOnly).toBe(true) + expect(() => opened.db.exec("INSERT INTO profile_state_meta VALUES ('x', 'y')")).toThrow() + } finally { + opened.db.close() + } + expect(readFileSync(dbPath)).toEqual(before) + }) + + it('rejects a database whose profile identity does not match', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const opened = openProfileStateDatabase(dbPath, 'profile-a') + opened.db.close() + const before = readFileSync(dbPath) + + expect(() => openProfileStateDatabase(dbPath, 'profile-b')).toThrowError( + expect.objectContaining({ code: 'identity-mismatch' }) + ) + expect(readFileSync(dbPath)).toEqual(before) + }) + + it('rejects malformed database bytes without replacing them', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const bytes = Buffer.from('not a sqlite database') + writeFileSync(dbPath, bytes) + + expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( + expect.objectContaining({ code: 'unreadable' }) + ) + expect(readFileSync(dbPath)).toEqual(bytes) + }) + + it('quarantines the database family without touching live recovery sources', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const opened = openProfileStateDatabase(dbPath, 'profile-a') + importProfileStateJson(opened.db, JSON.stringify({ settings: { theme: 'dark' } })) + opened.db.close() + writeFileSync(`${dbPath}-wal`, 'wal-preservation-sentinel') + + const sourceBytes = new Map( + [dbPath, `${dbPath}-wal`].map((path) => [path, readFileSync(path).toString('hex')]) + ) + const result = quarantineProfileStateDatabase( + dbPath, + 'profile-a', + join(directory, 'quarantine'), + 'test-corruption' + ) + + expect(result.copiedFiles).toHaveLength(2) + expect(JSON.parse(readFileSync(result.manifestPath, 'utf8'))).toMatchObject({ + schemaVersion: 1, + profileId: 'profile-a', + reason: 'test-corruption', + sourceFiles: expect.arrayContaining(['', '-wal']) + }) + expect(readFileSync(join(result.directory, 'profile-state.db')).toString('hex')).toBe( + sourceBytes.get(dbPath) + ) + expect(readFileSync(join(result.directory, 'profile-state.db-wal')).toString('hex')).toBe( + sourceBytes.get(`${dbPath}-wal`) + ) + for (const [path, bytes] of sourceBytes) { + expect(readFileSync(path).toString('hex')).toBe(bytes) + } + }) + + it('rejects an unexpected version-0 schema without mutating it', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const seeded = new Database(dbPath) + seeded.exec('CREATE TABLE unrelated (value TEXT)') + seeded.close() + const before = readFileSync(dbPath) + + expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( + expect.objectContaining({ code: 'unreadable' }) + ) + expect(readFileSync(dbPath)).toEqual(before) + }) + + it('rejects an incomplete current schema without mutating it', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const seeded = openProfileStateDatabase(dbPath, 'profile-a') + seeded.db.exec('DROP TABLE profile_state_documents') + seeded.db.close() + const before = readFileSync(dbPath) + + expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( + expect.objectContaining({ code: 'unreadable' }) + ) + expect(readFileSync(dbPath)).toEqual(before) + }) + + it('rejects current-version tables with incompatible columns without mutating them', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const seeded = new Database(dbPath) + seeded.exec(` + PRAGMA user_version = ${PROFILE_STATE_DATABASE_SCHEMA_VERSION}; + CREATE TABLE profile_state_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE profile_state_documents ( + domain TEXT PRIMARY KEY, + payload BLOB NOT NULL, + revision INTEGER NOT NULL + ); + `) + seeded.close() + const before = readFileSync(dbPath) + + expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( + expect.objectContaining({ code: 'unreadable' }) + ) + expect(readFileSync(dbPath)).toEqual(before) + }) + + it('does not create an empty database when the parent directory is absent', () => { + const directory = createDirectory() + const dbPath = join(directory, 'missing', PROFILE_STATE_DATABASE_FILE_NAME) + + expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( + expect.objectContaining({ code: 'unreadable' }) + ) + }) + + it('rejects an empty profile identity before opening SQLite', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + + expect(() => openProfileStateDatabase(dbPath, '')).toThrowError( + expect.objectContaining({ code: 'invalid-profile-id' }) + ) + }) + + it('restricts the database and WAL sidecars on POSIX', () => { + if (process.platform === 'win32') { + return + } + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const opened = openProfileStateDatabase(dbPath, 'profile-a') + opened.db + .prepare('INSERT INTO profile_state_documents VALUES (?, ?, ?, ?, ?, ?)') + .run('settings', '{}', 1, 1, Date.now(), 'hash') + try { + for (const path of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) { + expect(statSync(path).mode & 0o777).toBe(0o600) + } + } finally { + opened.db.close() + } + }) +}) + +describe('profile state database does not reuse orchestration state', () => { + it('uses a profile-local filename', () => { + const directory = createDirectory() + expect(profileStateDatabaseFile(directory)).not.toBe(join(directory, 'orchestration.db')) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-database.ts b/src/main/persistence/profile-state/profile-state-database.ts new file mode 100644 index 00000000000..e09c8c87869 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database.ts @@ -0,0 +1,246 @@ +import { withProfileStateWriteTransaction } from './profile-state-write-transaction' +import Database, { isSqliteAvailable } from '../../sqlite/sync-database' +import { migrateAutomationRunsStorage } from './profile-state-automation-runs-migration' +import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files' +import { + createProfileStateTablesSql, + PROFILE_STATE_DATABASE_SCHEMA_VERSION, + PROFILE_STATE_META_PROFILE_ID +} from './profile-state-database-schema' +import { existsSync, mkdirSync } from 'node:fs' +import { dirname } from 'node:path' +import { + PROFILE_STATE_DATABASE_FILE_NAME, + profileStateDatabaseFile +} from '../../../shared/profile-state-storage-paths' +import { + ProfileStateDatabaseOpenError, + type ProfileStateDatabaseOpenErrorCode +} from './profile-state-database-errors' +import { + verifyEmptyProfileStateSchema, + verifyProfileStateSchema +} from './profile-state-database-validation' + +export const PROFILE_STATE_BUSY_TIMEOUT_MS = 5_000 + +// Keep relay-only Node 18 imports safe while selecting the actual database driver. +export const isProfileStateSqliteAvailable = isSqliteAvailable + +export { ProfileStateDatabaseOpenError } +export type { ProfileStateDatabaseOpenErrorCode } + +export type OpenProfileStateDatabase = { + db: Database.Database + readOnly: boolean + profileId: string +} + +export { PROFILE_STATE_DATABASE_FILE_NAME, profileStateDatabaseFile } + +export function openWritableProfileStateDatabase( + databasePath: string, + profileId: string +): OpenProfileStateDatabase { + mkdirSync(dirname(databasePath), { recursive: true }) + const opened = openProfileStateDatabase(databasePath, profileId) + if (opened.readOnly) { + opened.db.close() + throw new ProfileStateDatabaseOpenError( + 'newer-schema', + 'This profile requires a newer version of Orca' + ) + } + return opened +} + +/** + * Open the database belonging to one profile. + * + * The schema version is read before WAL, busy-timeout, or DDL configuration so + * a newer build can leave the database byte-for-byte untouched and read it + * without accidentally writing through an unknown schema. + */ +export function openProfileStateDatabase( + dbPath: string, + profileId: string +): OpenProfileStateDatabase { + if (profileId.length === 0) { + throw new ProfileStateDatabaseOpenError('invalid-profile-id', 'Profile ID cannot be empty') + } + + let probe: Database.Database + try { + probe = new Database(dbPath) + } catch (error) { + throw new ProfileStateDatabaseOpenError( + 'unreadable', + `Unable to open profile state database: ${dbPath}`, + error + ) + } + + let transferred = false + try { + const storedVersion = profileStatePragmaNumber(probe, 'user_version') + verifyProfileStateIntegrity(probe) + if (storedVersion > PROFILE_STATE_DATABASE_SCHEMA_VERSION) { + probe.close() + transferred = true + try { + return { + db: new Database(dbPath, { + readonly: true, + fileMustExist: true, + timeout: PROFILE_STATE_BUSY_TIMEOUT_MS + }), + readOnly: true, + profileId + } + } catch (error) { + throw new ProfileStateDatabaseOpenError( + 'unreadable', + `Unable to open future profile state database read-only: ${dbPath}`, + error + ) + } + } + + if (storedVersion > 0) { + // Validate the complete current shape before WAL setup. A structurally + // valid but incomplete database should fail without changing its header. + verifyProfileStateSchema(probe, profileId, storedVersion) + } else if (storedVersion === 0) { + verifyEmptyProfileStateSchema(probe) + } else { + throw new Error(`Unsupported profile state database schema: ${storedVersion}`) + } + + // The journal-mode pragma can update the SQLite header even when no state + // row is written, so all known-shape validation happens before this point. + migrateProfileStateSchema(probe, storedVersion, profileId) + configureProfileStatePragmas(probe) + hardenSqliteDatabaseFiles(dbPath) + transferred = true + return { db: probe, readOnly: false, profileId } + } catch (error) { + if (error instanceof ProfileStateDatabaseOpenError) { + throw error + } + throw new ProfileStateDatabaseOpenError( + 'unreadable', + `Unable to initialize profile state database: ${dbPath}`, + error + ) + } finally { + if (!transferred) { + probe.close() + } + } +} + +/** + * Open an existing profile database without applying migrations or changing + * its journal mode. Callers use this for best-effort reads during GC, where a + * present database is authoritative and any failure must fail closed. + */ +export function openProfileStateDatabaseReadOnly( + dbPath: string, + profileId: string +): OpenProfileStateDatabase { + if (profileId.length === 0) { + throw new ProfileStateDatabaseOpenError('invalid-profile-id', 'Profile ID cannot be empty') + } + if (!existsSync(dbPath)) { + throw new ProfileStateDatabaseOpenError( + 'unreadable', + `Profile state database does not exist: ${dbPath}` + ) + } + + let db: Database.Database + try { + db = new Database(dbPath, { + readonly: true, + fileMustExist: true, + timeout: PROFILE_STATE_BUSY_TIMEOUT_MS + }) + } catch (error) { + throw new ProfileStateDatabaseOpenError( + 'unreadable', + `Unable to open profile state database read-only: ${dbPath}`, + error + ) + } + + try { + const storedVersion = profileStatePragmaNumber(db, 'user_version') + verifyProfileStateIntegrity(db) + if (storedVersion > PROFILE_STATE_DATABASE_SCHEMA_VERSION) { + throw new ProfileStateDatabaseOpenError( + 'newer-schema', + `Profile state database schema is newer than this runtime: ${storedVersion}` + ) + } + if (storedVersion !== PROFILE_STATE_DATABASE_SCHEMA_VERSION) { + throw new Error(`Unsupported profile state database schema: ${storedVersion}`) + } + verifyProfileStateSchema(db, profileId) + return { db, readOnly: true, profileId } + } catch (error) { + db.close() + if (error instanceof ProfileStateDatabaseOpenError) { + throw error + } + throw new ProfileStateDatabaseOpenError( + 'unreadable', + `Unable to read profile state database: ${dbPath}`, + error + ) + } +} + +export function profileStatePragmaNumber(db: Database.Database, name: string): number { + return Number(db.pragma(name, { simple: true }) ?? 0) +} + +function verifyProfileStateIntegrity(db: Database.Database): void { + const result = db.pragma('quick_check', { simple: true }) + if (result !== 'ok') { + throw new Error(`Profile state database integrity check failed: ${String(result)}`) + } +} + +function configureProfileStatePragmas(db: Database.Database): void { + const journalMode = db.pragma('journal_mode = WAL', { simple: true }) + if (typeof journalMode !== 'string' || journalMode.toLowerCase() !== 'wal') { + throw new Error(`Profile state database does not support WAL (mode: ${String(journalMode)})`) + } + db.pragma(`busy_timeout = ${PROFILE_STATE_BUSY_TIMEOUT_MS}`) + db.pragma('foreign_keys = ON') + // Profile commits are user-visible state. Keep the same power-loss contract + // as the current temp-file + fsync writer rather than the orchestration DB's + // cache-oriented NORMAL setting. + db.pragma('synchronous = FULL') +} + +function migrateProfileStateSchema( + db: Database.Database, + storedVersion: number, + profileId: string +): void { + if (storedVersion >= PROFILE_STATE_DATABASE_SCHEMA_VERSION) { + return + } + + return withProfileStateWriteTransaction(db, () => { + db.exec(createProfileStateTablesSql()) + db.prepare('INSERT OR IGNORE INTO profile_state_meta (key, value) VALUES (?, ?)').run( + PROFILE_STATE_META_PROFILE_ID, + profileId + ) + migrateAutomationRunsStorage(db, storedVersion) + verifyProfileStateSchema(db, profileId) + db.pragma(`user_version = ${PROFILE_STATE_DATABASE_SCHEMA_VERSION}`) + }) +} diff --git a/src/main/persistence/profile-state/profile-state-document-reader.ts b/src/main/persistence/profile-state/profile-state-document-reader.ts new file mode 100644 index 00000000000..075e4f6dc06 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-document-reader.ts @@ -0,0 +1,99 @@ +import type Database from '../../sqlite/sync-database' +import { readProfileStateAutomationRunsDocument } from './profile-state-automation-runs' +import { + assertProfileStateDocumentRevision, + readProfileStateRevision +} from './profile-state-revision' +import { + ProfileStateDocumentCorruptionError, + validateProfileStateDocumentRow, + type ProfileStateDocument, + type ProfileStateParsedDocument, + type ProfileStateValidatedDocument +} from './profile-state-document-validation' + +export type ReadProfileStateDocumentsOptions = { + /** The profile revision already read by a surrounding snapshot. */ + profileRevision?: number +} + +/** Read the authoritative rows after checking their hash, shape, and JSON payload. */ +export function readProfileStateDocuments( + db: Database.Database, + options: ReadProfileStateDocumentsOptions & { representation: 'parsed' } +): readonly ProfileStateParsedDocument[] +export function readProfileStateDocuments( + db: Database.Database, + options?: ReadProfileStateDocumentsOptions +): readonly ProfileStateDocument[] +export function readProfileStateDocuments( + db: Database.Database, + options: ReadProfileStateDocumentsOptions & { representation: 'validated' } +): void +export function readProfileStateDocuments( + db: Database.Database, + options: ReadProfileStateDocumentsOptions & { representation?: 'parsed' | 'validated' } = {} +): + | readonly (ProfileStateDocument | ProfileStateParsedDocument | ProfileStateValidatedDocument)[] + | void { + const profileRevision = options.profileRevision ?? readProfileStateRevision(db) + const normalized = + options.representation === 'validated' + ? readProfileStateAutomationRunsDocument(db, profileRevision, 'validated') + : options.representation === 'parsed' + ? readProfileStateAutomationRunsDocument(db, profileRevision, 'parsed') + : readProfileStateAutomationRunsDocument(db, profileRevision) + const rows = db + .prepare( + `SELECT domain, payload, domain_version, revision, updated_at, content_hash + FROM profile_state_documents ORDER BY rowid` + ) + .iterate() + const documents: ( + | ProfileStateDocument + | ProfileStateParsedDocument + | ProfileStateValidatedDocument + )[] = [] + for (const row of rows) { + const document = validateProfileStateDocumentRow(row, { + retainParsedValue: options.representation === 'parsed' + }) + assertProfileStateDocumentRevision(document.revision, profileRevision, document.domain) + if ( + normalized !== undefined && + document.domain === 'automationRuns' && + document.payload !== 'null' + ) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns placeholder is invalid', + document.domain + ) + } + if (options.representation === 'validated') { + continue + } + if (options.representation === 'parsed') { + const { payload: _payload, ...parsedDocument } = document + documents.push({ ...parsedDocument, value: document.value }) + } else { + documents.push(document) + } + } + if (options.representation === 'validated') { + return + } + if (normalized === undefined) { + return documents + } + const withoutAutomationRuns = documents.filter((document) => document.domain !== 'automationRuns') + if (normalized === null) { + return withoutAutomationRuns + } + const originalIndex = documents.findIndex((document) => document.domain === 'automationRuns') + withoutAutomationRuns.splice( + originalIndex === -1 ? withoutAutomationRuns.length : originalIndex, + 0, + normalized + ) + return withoutAutomationRuns +} diff --git a/src/main/persistence/profile-state/profile-state-document-validation.ts b/src/main/persistence/profile-state/profile-state-document-validation.ts new file mode 100644 index 00000000000..52638353921 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-document-validation.ts @@ -0,0 +1,117 @@ +import { createHash } from 'node:crypto' + +export type ProfileStateDocument = { + domain: string + payload: string + domainVersion: number + revision: number + updatedAt: number + contentHash: string +} + +export type ProfileStateValidatedDocument = Omit +export type ProfileStateParsedDocument = ProfileStateValidatedDocument & { value: unknown } + +export class ProfileStateDocumentCorruptionError extends Error { + readonly code = 'corrupt-document' as const + readonly domain: string | null + + constructor(message: string, domain: string | null = null) { + super(message) + this.name = 'ProfileStateDocumentCorruptionError' + this.domain = domain + } +} + +export class ProfileStateRevisionConflictError extends Error { + readonly code = 'profile-state-revision-conflict' as const + readonly expectedRevision: number + readonly actualRevision: number + + constructor(expectedRevision: number, actualRevision: number) { + super( + `Profile state revision changed while importing a document (expected ${expectedRevision}, found ${actualRevision})` + ) + this.name = 'ProfileStateRevisionConflictError' + this.expectedRevision = expectedRevision + this.actualRevision = actualRevision + } +} + +export function hashProfileStatePayload(payload: string): string { + return createHash('sha256').update(payload, 'utf8').digest('hex') +} + +export function parseProfileStateRoot(rawJson: string): Record { + let parsed: unknown + try { + parsed = JSON.parse(rawJson) + } catch { + throw new ProfileStateDocumentCorruptionError('Profile state JSON is invalid', null) + } + if (!isRecord(parsed)) { + throw new ProfileStateDocumentCorruptionError('Profile state JSON root must be an object', null) + } + return parsed +} + +export function validateProfileStateDocumentRow( + row: unknown, + options: { validateJson?: boolean; retainParsedValue?: boolean } = {} +): ProfileStateDocument & { value?: unknown } { + if ( + !isRecord(row) || + typeof row.domain !== 'string' || + typeof row.payload !== 'string' || + typeof row.domain_version !== 'number' || + typeof row.revision !== 'number' || + typeof row.updated_at !== 'number' || + typeof row.content_hash !== 'string' + ) { + throw new ProfileStateDocumentCorruptionError('Profile state document row has invalid fields') + } + if ( + !Number.isSafeInteger(row.domain_version) || + row.domain_version < 1 || + !Number.isSafeInteger(row.revision) || + row.revision < 1 || + !Number.isSafeInteger(row.updated_at) || + row.updated_at < 0 || + !/^[a-f0-9]{64}$/.test(row.content_hash) + ) { + throw new ProfileStateDocumentCorruptionError( + `Profile state document row metadata is invalid: ${row.domain}`, + row.domain + ) + } + if (hashProfileStatePayload(row.payload) !== row.content_hash) { + throw new ProfileStateDocumentCorruptionError( + `Profile state document hash mismatch: ${row.domain}`, + row.domain + ) + } + let value: unknown + if (options.retainParsedValue || (options.validateJson ?? true)) { + try { + value = JSON.parse(row.payload) + } catch { + throw new ProfileStateDocumentCorruptionError( + `Profile state document payload is invalid JSON: ${row.domain}`, + row.domain + ) + } + } + return { + domain: row.domain, + payload: row.payload, + domainVersion: row.domain_version, + revision: row.revision, + updatedAt: row.updated_at, + contentHash: row.content_hash, + ...(options.retainParsedValue ? { value } : {}) + } +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/persistence/profile-state/profile-state-documents.test.ts b/src/main/persistence/profile-state/profile-state-documents.test.ts new file mode 100644 index 00000000000..1afdae3d60c --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-documents.test.ts @@ -0,0 +1,370 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + buildProfileStateCutoverFixture, + canonicalProfileStateJson +} from '../profile-state-cutover-fixture' +import { + exportProfileStateJson, + hashProfileStateJson, + importProfileStateJson, + readProfileStateJsonAcceptance, + readProfileStateDocuments, + readProfileStateRevision, + readProfileStateSnapshot, + readProfileStateParsedSnapshot +} from './profile-state-documents' +import { openProfileStateDatabase, profileStateDatabaseFile } from './profile-state-database' +import { readProfileStateDomains } from './profile-state-domain-reader' +import { writeProfileStateDomain } from './profile-state-domain-writes' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function openTestDatabase(): { + directory: string + db: ReturnType['db'] +} { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-documents-')) + temporaryDirectories.push(directory) + return { + directory, + db: openProfileStateDatabase(profileStateDatabaseFile(directory), 'profile-a').db + } +} + +describe('profile state document adapter', () => { + it('round-trips every top-level domain, including unknown keys, nulls, arrays, and sealed bytes', () => { + const { db } = openTestDatabase() + try { + const fixture = buildProfileStateCutoverFixture() + const raw = JSON.stringify(fixture) + expect(importProfileStateJson(db, raw, { now: () => 123 })).toBe(1) + const exported = exportProfileStateJson(db) + + expect(canonicalProfileStateJson(JSON.parse(exported))).toBe( + canonicalProfileStateJson(fixture) + ) + expect(JSON.parse(exported).settings.opencodeSessionCookie).toBe( + fixture.settings.opencodeSessionCookie + ) + expect(readProfileStateRevision(db)).toBe(1) + expect(readProfileStateDocuments(db)).toHaveLength(Object.keys(fixture).length) + expect(readProfileStateDocuments(db).find((row) => row.domain === 'settings')).toMatchObject({ + revision: 1, + updatedAt: 123 + }) + } finally { + db.close() + } + }) + + it('preserves missing domains versus explicit null values and array order', () => { + const { db } = openTestDatabase() + try { + importProfileStateJson( + db, + JSON.stringify({ + explicitNull: null, + ordered: ['first', 'second'], + unknown: { keep: true } + }) + ) + const exported = JSON.parse(exportProfileStateJson(db)) + expect(exported).toEqual({ + explicitNull: null, + ordered: ['first', 'second'], + unknown: { keep: true } + }) + expect(Object.hasOwn(exported, 'missing')).toBe(false) + } finally { + db.close() + } + }) + + it('advances revision and replaces the complete document set atomically', () => { + const { db } = openTestDatabase() + try { + expect(importProfileStateJson(db, JSON.stringify({ first: 1, old: 2 }))).toBe(1) + expect(importProfileStateJson(db, JSON.stringify({ second: 3 }), { now: () => 456 })).toBe(2) + expect(exportProfileStateJson(db)).toBe(JSON.stringify({ second: 3 })) + expect(readProfileStateRevision(db)).toBe(2) + expect(readProfileStateDocuments(db)[0]).toMatchObject({ + domain: 'second', + revision: 2, + updatedAt: 456 + }) + } finally { + db.close() + } + }) + + it('rejects a stale complete-document replacement before deleting rows', () => { + const { db } = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ keep: true })) + expect(() => + importProfileStateJson(db, JSON.stringify({ replacement: true }), { + expectedRevision: 0 + }) + ).toThrowError( + expect.objectContaining({ + code: 'profile-state-revision-conflict', + expectedRevision: 0, + actualRevision: 1 + }) + ) + expect(exportProfileStateJson(db)).toBe(JSON.stringify({ keep: true })) + expect(readProfileStateRevision(db)).toBe(1) + } finally { + db.close() + } + }) + + it('commits the legacy JSON acceptance marker with the imported revision', () => { + const { db } = openTestDatabase() + try { + const raw = JSON.stringify({ settings: { theme: 'dark' } }) + expect( + importProfileStateJson(db, raw, { + acceptedLegacyJsonHash: hashProfileStateJson(raw), + now: () => 123 + }) + ).toBe(1) + expect(readProfileStateJsonAcceptance(db)).toEqual({ + jsonHash: hashProfileStateJson(raw), + acceptedRevision: 1 + }) + } finally { + db.close() + } + }) + + it('rolls back every row and the revision when one insert fails', () => { + const { db } = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ keep: { value: 1 } }), { now: () => 10 }) + db.exec( + `CREATE TRIGGER fail_profile_state_insert + BEFORE INSERT ON profile_state_documents + WHEN NEW.domain = 'second' + BEGIN SELECT RAISE(ABORT, 'injected document failure'); END` + ) + + expect(() => importProfileStateJson(db, JSON.stringify({ first: 1, second: 2 }))).toThrow( + 'injected document failure' + ) + db.exec('DROP TRIGGER fail_profile_state_insert') + expect(exportProfileStateJson(db)).toBe(JSON.stringify({ keep: { value: 1 } })) + expect(readProfileStateRevision(db)).toBe(1) + } finally { + db.close() + } + }) + + it('does not roll back a transaction owned by the caller', () => { + const { db } = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ keep: true })) + db.exec('BEGIN IMMEDIATE') + + expect(() => importProfileStateJson(db, JSON.stringify({ replacement: true }))).toThrow( + 'requires an idle database connection' + ) + expect(db.isTransaction).toBe(true) + db.exec('ROLLBACK') + expect(exportProfileStateJson(db)).toBe(JSON.stringify({ keep: true })) + } finally { + if (db.isTransaction) { + db.exec('ROLLBACK') + } + db.close() + } + }) + + it('rejects a tampered payload when its hash no longer matches', () => { + const { db } = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ ui: { active: 'terminal' } })) + db.prepare('UPDATE profile_state_documents SET payload = ? WHERE domain = ?').run( + JSON.stringify({ active: 'tasks' }), + 'ui' + ) + + expect(() => readProfileStateDocuments(db)).toThrowError( + expect.objectContaining({ domain: 'ui' }) + ) + expect(() => exportProfileStateJson(db)).toThrowError(/hash mismatch: ui/) + } finally { + db.close() + } + }) + + it('rejects invalid domain JSON even when its hash is correct', () => { + const { db } = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ settings: { theme: 'dark' } })) + db.prepare( + 'UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = ?' + ).run('{invalid', hashProfileStateJson('{invalid'), 'settings') + + expect(() => readProfileStateDocuments(db)).toThrow(/invalid JSON: settings/) + expect(() => readProfileStateSnapshot(db)).toThrow(/invalid JSON: settings/) + expect(() => readProfileStateParsedSnapshot(db)).toThrow(/invalid JSON: settings/) + } finally { + db.close() + } + }) + + it.each(['[]', ' null '])( + 'rejects the noncanonical normalized history placeholder %s', + (payload) => { + const { db } = openTestDatabase() + try { + const original = { + settings: { theme: 'dark' }, + automationRuns: [{ id: 'run-1', status: 'pending' }], + ui: { sidebarWidth: 280 } + } + importProfileStateJson(db, JSON.stringify(original)) + expect( + db + .prepare('SELECT payload FROM profile_state_documents WHERE domain = ?') + .get('automationRuns') + ).toEqual({ payload: 'null' }) + expect(readProfileStateSnapshot(db).json).toBe(JSON.stringify(original)) + db.prepare( + 'UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = ?' + ).run(payload, hashProfileStateJson(payload), 'automationRuns') + expect(() => readProfileStateSnapshot(db)).toThrow(/placeholder is invalid/) + expect(() => readProfileStateParsedSnapshot(db)).toThrow(/placeholder is invalid/) + } finally { + db.close() + } + } + ) + + it('rejects a retained legacy document whose revision is ahead of the profile', () => { + const { db } = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ automationRuns: [{ id: 'run-1' }] })) + db.prepare('UPDATE profile_state_documents SET revision = ? WHERE domain = ?').run( + 999, + 'automationRuns' + ) + + expect(() => readProfileStateDocuments(db)).toThrow( + /document revision 999 exceeds profile revision 1/ + ) + expect(() => readProfileStateParsedSnapshot(db)).toThrow( + /document revision 999 exceeds profile revision 1/ + ) + } finally { + db.close() + } + }) + + it.each(['null', 'absent'] as const)( + 'rejects normalized %s metadata whose revision is ahead of the profile', + (presence) => { + const { directory, db } = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ automationRuns: [{ id: 'run-1' }] })) + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: presence === 'null' ? 'null' : null, + expectedRevision: 1 + }) + expect(db.prepare('SELECT presence FROM profile_state_automation_runs_meta').get()).toEqual( + { + presence + } + ) + db.prepare('UPDATE profile_state_automation_runs_meta SET revision = ?').run(999) + + expect(() => readProfileStateSnapshot(db)).toThrow( + /document revision 999 exceeds profile revision 2/ + ) + expect(() => readProfileStateParsedSnapshot(db)).toThrow( + /document revision 999 exceeds profile revision 2/ + ) + expect( + readProfileStateDomains(profileStateDatabaseFile(directory), 'profile-a', [ + 'automationRuns' + ]) + ).toMatchObject({ + kind: 'unreadable' + }) + } finally { + db.close() + } + } + ) + + it.each(['missing', 'absent', 'null'] as const)( + 'rejects %s automation metadata with remaining normalized runs on every read path', + (presence) => { + const { directory, db } = openTestDatabase() + try { + importProfileStateJson( + db, + JSON.stringify({ automationRuns: [{ id: 'run-1', status: 'pending' }] }) + ) + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1', status: 'completed' }]), + expectedRevision: 1 + }) + expect(JSON.parse(readProfileStateSnapshot(db).json)).toEqual({ + automationRuns: [{ id: 'run-1', status: 'completed' }] + }) + if (presence === 'missing') { + db.exec('DELETE FROM profile_state_automation_runs_meta') + } else { + db.prepare( + 'UPDATE profile_state_automation_runs_meta SET presence = ?, content_hash = ?' + ).run(presence, presence === 'absent' ? '' : hashProfileStateJson('null')) + } + + const expectedError = + presence === 'missing' + ? 'Normalized automationRuns metadata is malformed' + : 'Normalized automationRuns rows exist for an empty domain' + expect(() => readProfileStateSnapshot(db)).toThrow(expectedError) + expect(() => readProfileStateParsedSnapshot(db)).toThrow(expectedError) + const databasePath = profileStateDatabaseFile(directory) + const authority = new ProfileStateSqliteAuthority(databasePath, 'profile-a') + expect(() => authority.readSerializedState()).toThrow( + presence === 'missing' ? /Unable to read profile state database/ : expectedError + ) + expect( + readProfileStateDomains(databasePath, 'profile-a', ['automationRuns']) + ).toMatchObject({ + kind: 'unreadable' + }) + } finally { + db.close() + } + } + ) + + it('rejects malformed input without changing an existing revision', () => { + const { db } = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ keep: true })) + expect(() => importProfileStateJson(db, '{invalid')).toThrow('Profile state JSON is invalid') + expect(exportProfileStateJson(db)).toBe(JSON.stringify({ keep: true })) + expect(readProfileStateRevision(db)).toBe(1) + } finally { + db.close() + } + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-documents.ts b/src/main/persistence/profile-state/profile-state-documents.ts new file mode 100644 index 00000000000..59e700cfab9 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-documents.ts @@ -0,0 +1,232 @@ +import { withProfileStateWriteTransaction } from './profile-state-write-transaction' +import { withProfileStateReadSnapshot } from './profile-state-read-snapshot' +import type Database from '../../sqlite/sync-database' +import { readCurrentAutomationRunsState } from './profile-state-automation-runs-storage' +import { + PROFILE_STATE_META_LEGACY_JSON_ACCEPTANCE, + PROFILE_STATE_DOCUMENT_VERSION, + PROFILE_STATE_META_REVISION +} from './profile-state-database-schema' +import { + hashProfileStatePayload, + parseProfileStateRoot, + ProfileStateDocumentCorruptionError, + ProfileStateRevisionConflictError, + type ProfileStateDocument +} from './profile-state-document-validation' +import { + clearProfileStateAutomationRuns, + rebuildProfileStateAutomationRunsProjection +} from './profile-state-automation-runs' + +import { readProfileStateDocuments } from './profile-state-document-reader' + +import { readProfileStateRevision } from './profile-state-revision' +import { readProfileStateJsonAcceptance } from './legacy-json/profile-state-json-acceptance' + +export { + acceptProfileStateJsonCompatibility, + readProfileStateJsonAcceptance, + stageProfileStateJsonCompatibility, + type ProfileStateJsonAcceptance +} from './legacy-json/profile-state-json-acceptance' + +export { readProfileStateRevision } from './profile-state-revision' +export { readProfileStateDocuments } from './profile-state-document-reader' +export type { ReadProfileStateDocumentsOptions } from './profile-state-document-reader' +export { ProfileStateDocumentCorruptionError, ProfileStateRevisionConflictError } +export type { ProfileStateDocument } from './profile-state-document-validation' + +export type ImportProfileStateOptions = { + now?: () => number + /** Hash of the exact legacy JSON bytes accepted by this import. */ + acceptedLegacyJsonHash?: string + /** Revision observed by the caller before constructing this replacement. */ + expectedRevision?: number +} + +export type ProfileStateSnapshot = { + revision: number + documents: readonly ProfileStateDocument[] + json: string +} + +export type ProfileStateParsedSnapshot = { + revision: number + state: Record +} + +/** Import a complete JSON document set atomically into one profile database. */ +export function importProfileStateJson( + db: Database.Database, + rawJson: string, + options: ImportProfileStateOptions = {} +): number { + const parsed = parseProfileStateRoot(rawJson) + const entries = Object.entries(parsed).map(([domain, value]) => { + const payload = JSON.stringify(value) + if (payload === undefined) { + throw new ProfileStateDocumentCorruptionError( + `Profile state domain cannot be serialized: ${domain}`, + domain + ) + } + return { domain, payload } + }) + + const now = options.now ?? Date.now + if ( + options.acceptedLegacyJsonHash !== undefined && + !/^[a-f0-9]{64}$/.test(options.acceptedLegacyJsonHash) + ) { + throw new ProfileStateDocumentCorruptionError('Legacy JSON acceptance hash is invalid') + } + if ( + options.expectedRevision !== undefined && + (!Number.isSafeInteger(options.expectedRevision) || options.expectedRevision < 0) + ) { + throw new ProfileStateDocumentCorruptionError('Expected profile state revision is invalid') + } + const updatedAt = now() + if (!Number.isSafeInteger(updatedAt) || updatedAt < 0) { + throw new ProfileStateDocumentCorruptionError('Profile state update timestamp is invalid') + } + return withProfileStateWriteTransaction(db, () => { + const actualRevision = readProfileStateRevision(db) + if (options.expectedRevision !== undefined && actualRevision !== options.expectedRevision) { + throw new ProfileStateRevisionConflictError(options.expectedRevision, actualRevision) + } + readCurrentAutomationRunsState(db, actualRevision) + const revision = actualRevision + 1 + clearProfileStateAutomationRuns(db) + db.exec('DELETE FROM profile_state_documents') + const automationRuns = entries.find((entry) => entry.domain === 'automationRuns') + const normalizedRuns = + automationRuns !== undefined && + rebuildProfileStateAutomationRunsProjection( + db, + automationRuns.payload, + PROFILE_STATE_DOCUMENT_VERSION, + updatedAt, + revision + ) + const insert = db.prepare( + `INSERT INTO profile_state_documents + (domain, payload, domain_version, revision, updated_at, content_hash) + VALUES (?, ?, ?, ?, ?, ?)` + ) + for (const entry of entries) { + const payload = normalizedRuns && entry.domain === 'automationRuns' ? 'null' : entry.payload + insert.run( + entry.domain, + payload, + PROFILE_STATE_DOCUMENT_VERSION, + revision, + updatedAt, + hashProfileStatePayload(payload) + ) + } + db.prepare( + `INSERT INTO profile_state_meta (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value` + ).run(PROFILE_STATE_META_REVISION, String(revision)) + if (options.acceptedLegacyJsonHash !== undefined) { + db.prepare( + `INSERT INTO profile_state_meta (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value` + ).run( + PROFILE_STATE_META_LEGACY_JSON_ACCEPTANCE, + JSON.stringify({ jsonHash: options.acceptedLegacyJsonHash, acceptedRevision: revision }) + ) + } + return revision + }) +} + +export function hashProfileStateJson(rawJson: string): string { + return hashProfileStatePayload(rawJson) +} + +/** Validate that retained legacy JSON is the exact export accepted by this database. */ +export function profileStateJsonMatchesAcceptance(db: Database.Database, rawJson: string): boolean { + return readAcceptedProfileStateSnapshot(db, rawJson) !== undefined +} + +/** Validate the retained JSON and return the same database snapshot used for acceptance. */ +export function readAcceptedProfileStateSnapshot( + db: Database.Database, + rawJson: string +): ProfileStateSnapshot | undefined { + return readAcceptedSnapshot(db, rawJson, () => readProfileStateSnapshot(db)) +} + +export function readAcceptedProfileStateParsedSnapshot( + db: Database.Database, + rawJson: string +): ProfileStateParsedSnapshot | undefined { + return readAcceptedSnapshot(db, rawJson, () => readProfileStateParsedSnapshot(db)) +} + +function readAcceptedSnapshot( + db: Database.Database, + rawJson: string, + read: () => T +): T | undefined { + return withProfileStateReadSnapshot(db, () => { + const marker = readProfileStateJsonAcceptance(db) + const snapshot = read() + const jsonHash = hashProfileStateJson(rawJson) + return marker !== undefined && + (marker.jsonHash === jsonHash || marker.pending?.jsonHash === jsonHash) && + snapshot.revision >= (marker.pending?.acceptedRevision ?? marker.acceptedRevision) + ? snapshot + : undefined + }) +} + +/** Transfer independently validated values without constructing another whole-profile string. */ +export function readProfileStateParsedSnapshot(db: Database.Database): ProfileStateParsedSnapshot { + return withProfileStateReadSnapshot(db, () => { + const revision = readProfileStateRevision(db) + const documents = readProfileStateDocuments(db, { + profileRevision: revision, + representation: 'parsed' + }) + return { + revision, + state: Object.fromEntries(documents.map((document) => [document.domain, document.value])) + } + }) +} + +/** Export the row set as JSON accepted by the current loader. */ +export function exportProfileStateJson(db: Database.Database): string { + return readProfileStateSnapshot(db).json +} + +/** Read revision, rows, and their JSON projection under one SQLite snapshot. */ +export function readProfileStateSnapshot(db: Database.Database): ProfileStateSnapshot { + return withProfileStateReadSnapshot(db, () => { + const revision = readProfileStateRevision(db) + const documents = readProfileStateDocuments(db, { profileRevision: revision }) + return { + revision, + documents, + // Every payload was already hash- and JSON-validated above. Reusing the + // validated fragments avoids parsing and stringifying the full profile a + // second time before Store parses it at its domain boundary. + json: `{${documents + .map((document) => `${JSON.stringify(document.domain)}:${document.payload}`) + .join(',')}}` + } + }) +} + +/** Validate the complete snapshot without retaining state that recovery callers discard. */ +export function validateProfileStateSnapshot(db: Database.Database): number { + return withProfileStateReadSnapshot(db, () => { + const revision = readProfileStateRevision(db) + readProfileStateDocuments(db, { profileRevision: revision, representation: 'validated' }) + return revision + }) +} diff --git a/src/main/persistence/profile-state/profile-state-domain-equality.test.ts b/src/main/persistence/profile-state/profile-state-domain-equality.test.ts new file mode 100644 index 00000000000..df9dfe3efb9 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-domain-equality.test.ts @@ -0,0 +1,144 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { openProfileStateDatabase } from './profile-state-database' +import { + hashProfileStateJson, + importProfileStateJson, + readProfileStateRevision, + readProfileStateSnapshot +} from './profile-state-documents' +import { writeProfileStateDomains } from './profile-state-domain-writes' + +const databases: { db: ReturnType['db']; directory: string }[] = [] + +function fixture(value: unknown = { future: { content: '雪 🐋', nullable: null } }) { + const directory = mkdtempSync(join(tmpdir(), 'orca-domain-equality-')) + const { db } = openProfileStateDatabase(join(directory, 'state.db'), 'profile') + importProfileStateJson(db, JSON.stringify({ settings: {}, extension: value })) + databases.push({ db, directory }) + return { db, payload: JSON.stringify(value) } +} + +afterEach(() => { + for (const { db, directory } of databases.splice(0)) { + db.close() + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('validated domain replacement equality', () => { + it.each([{ nested: { content: '雪 🐋', nullable: null } }, null])( + 'preserves equal payload, revision and timestamp for %j', + (value) => { + const { db, payload } = fixture(value) + const before = readProfileStateSnapshot(db) + const rows = db.prepare('SELECT * FROM profile_state_documents').all() + + expect( + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [ + { + domain: 'extension', + payload, + now: () => { + throw new Error('An unchanged replacement must not request a timestamp') + } + } + ] + }) + ).toEqual({ changed: false, revision: 1, changedDomains: [] }) + expect(readProfileStateSnapshot(db)).toEqual(before) + expect(db.prepare('SELECT * FROM profile_state_documents').all()).toEqual(rows) + + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [{ domain: 'extension', payload: null }] + }) + expect(JSON.parse(readProfileStateSnapshot(db).json)).toEqual({ settings: {} }) + } + ) + + it.each([ + ["content_hash = 'invalid'", /metadata is invalid/], + [`content_hash = '${'a'.repeat(64)}'`, /hash mismatch/], + ['domain_version = 0', /metadata is invalid/], + ['updated_at = -1', /metadata is invalid/], + ['revision = 0', /metadata is invalid/], + ['revision = 2', /exceeds profile revision/] + ] as const)('rejects equal payload with corrupt %s', (assignment, error) => { + const { db, payload } = fixture() + db.exec(`UPDATE profile_state_documents SET ${assignment} WHERE domain = 'extension'`) + const before = db.prepare('SELECT * FROM profile_state_documents').all() + + expect(() => + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [ + { domain: 'settings', payload: '{"changed":true}' }, + { domain: 'extension', payload } + ] + }) + ).toThrow(error) + expect(readProfileStateRevision(db)).toBe(1) + expect(db.isTransaction).toBe(false) + expect(db.prepare('SELECT * FROM profile_state_documents').all()).toEqual(before) + }) + + it.each(['{', '{"valid":true}', null])( + 'rejects malformed stored JSON with a matching hash on replacement %j', + (payload) => { + const { db } = fixture() + db.prepare( + "UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = 'extension'" + ).run('{', hashProfileStateJson('{')) + + expect(() => + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [{ domain: 'extension', payload }] + }) + ).toThrow(/invalid JSON: extension/) + expect(readProfileStateRevision(db)).toBe(1) + expect(db.isTransaction).toBe(false) + expect( + db.prepare("SELECT payload FROM profile_state_documents WHERE domain = 'extension'").get() + ).toEqual({ payload: '{' }) + } + ) + + it('ignores caller-supplied prepared history when writing another domain', () => { + const { db } = fixture() + const replacement = { + domain: 'settings', + payload: '{"changed":true}', + automationRuns: { incoming: { presence: 'absent', contentHash: '' }, domainVersion: 1 } + } + + writeProfileStateDomains(db, { expectedRevision: 1, replacements: [replacement] }) + + expect(JSON.parse(readProfileStateSnapshot(db).json)).toEqual({ + settings: { changed: true }, + extension: { future: { content: '雪 🐋', nullable: null } } + }) + }) + + it('fences a stale writer even when its payload remains equal', () => { + const { db, payload } = fixture() + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [{ domain: 'settings', payload: '{"changed":true}' }] + }) + const before = readProfileStateSnapshot(db) + + expect(() => + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [{ domain: 'extension', payload }] + }) + ).toThrow(expect.objectContaining({ code: 'profile-state-revision-conflict' })) + expect(readProfileStateSnapshot(db)).toEqual(before) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-domain-reader.test.ts b/src/main/persistence/profile-state/profile-state-domain-reader.test.ts new file mode 100644 index 00000000000..c6c9d89c6db --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-domain-reader.test.ts @@ -0,0 +1,152 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { importProfileStateJson } from './profile-state-documents' +import { openProfileStateDatabase, profileStateDatabaseFile } from './profile-state-database' +import { + readProfileStateDomains, + readProfileStateDomainsWithRevisionFromDatabase +} from './profile-state-domain-reader' +import { writeProfileStateDomains } from './profile-state-domain-writes' + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function createDatabase(): { directory: string; databasePath: string } { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-domain-reader-')) + temporaryDirectories.push(directory) + return { directory, databasePath: profileStateDatabaseFile(directory) } +} + +describe('profile state domain reader', () => { + it('does not parse unrelated domains', () => { + const { databasePath } = createDatabase() + const opened = openProfileStateDatabase(databasePath, 'profile-a') + importProfileStateJson( + opened.db, + JSON.stringify({ settings: { theme: 'dark' }, unrelated: { keep: true } }) + ) + opened.db + .prepare('UPDATE profile_state_documents SET payload = ? WHERE domain = ?') + .run('{invalid', 'unrelated') + opened.db.close() + + const result = readProfileStateDomains(databasePath, 'profile-a', ['settings']) + expect(result).toEqual({ + kind: 'values', + revision: 1, + values: new Map([['settings', { theme: 'dark' }]]) + }) + }) + + it('fails closed when a selected domain is corrupt', () => { + const { databasePath } = createDatabase() + const opened = openProfileStateDatabase(databasePath, 'profile-a') + importProfileStateJson(opened.db, JSON.stringify({ settings: { theme: 'dark' } })) + opened.db + .prepare('UPDATE profile_state_documents SET payload = ? WHERE domain = ?') + .run('{invalid', 'settings') + opened.db.close() + + const result = readProfileStateDomains(databasePath, 'profile-a', ['settings']) + expect(result.kind).toBe('unreadable') + }) + + it('reads the normalized automation projection when selected', () => { + const { databasePath } = createDatabase() + const opened = openProfileStateDatabase(databasePath, 'profile-a') + importProfileStateJson( + opened.db, + JSON.stringify({ automationRuns: [{ id: 'run-1', status: 'pending' }] }) + ) + opened.db.close() + + const result = readProfileStateDomains(databasePath, 'profile-a', ['automationRuns']) + expect(result).toEqual({ + kind: 'values', + revision: 1, + values: new Map([['automationRuns', [{ id: 'run-1', status: 'pending' }]]]) + }) + }) + + it('preserves missing versus explicit null automation runs', () => { + const missing = createDatabase() + const missingOpened = openProfileStateDatabase(missing.databasePath, 'profile-a') + importProfileStateJson(missingOpened.db, JSON.stringify({ settings: { theme: 'dark' } })) + missingOpened.db.close() + const missingResult = readProfileStateDomains(missing.databasePath, 'profile-a', [ + 'automationRuns' + ]) + expect(missingResult.kind).toBe('values') + expect( + missingResult.kind === 'values' ? missingResult.values.has('automationRuns') : true + ).toBe(false) + + const explicitNull = createDatabase() + const nullOpened = openProfileStateDatabase(explicitNull.databasePath, 'profile-a') + importProfileStateJson( + nullOpened.db, + JSON.stringify({ settings: { theme: 'dark' }, automationRuns: null }) + ) + nullOpened.db.close() + const nullResult = readProfileStateDomains(explicitNull.databasePath, 'profile-a', [ + 'automationRuns' + ]) + expect(nullResult).toEqual({ + kind: 'values', + revision: 1, + values: new Map([['automationRuns', null]]) + }) + }) + + it('does not resurrect a stale legacy automation row after normalized deletion', () => { + const { databasePath } = createDatabase() + const opened = openProfileStateDatabase(databasePath, 'profile-a') + importProfileStateJson( + opened.db, + JSON.stringify({ automationRuns: [{ id: 'run-1', status: 'pending' }] }) + ) + writeProfileStateDomains(opened.db, { + expectedRevision: 1, + replacements: [{ domain: 'automationRuns', payload: null }] + }) + opened.db.close() + + const result = readProfileStateDomains(databasePath, 'profile-a', ['automationRuns']) + expect(result.kind).toBe('values') + expect(result.kind === 'values' ? result.values.has('automationRuns') : true).toBe(false) + }) +}) + +it('reuses independently parsed values for selected domains and history rows', () => { + const { databasePath } = createDatabase() + const { db } = openProfileStateDatabase(databasePath, 'profile-a') + const settings = '{"theme":"dark"}' + const row = '{"id":"run-a","output":"retained"}' + importProfileStateJson(db, `{"settings":${settings},"automationRuns":[${row}]}`) + const parse = vi.spyOn(JSON, 'parse') + try { + expect( + readProfileStateDomainsWithRevisionFromDatabase(db, ['settings', 'automationRuns']) + ).toEqual({ + kind: 'values', + revision: 1, + values: new Map([ + ['settings', { theme: 'dark' }], + ['automationRuns', [{ id: 'run-a', output: 'retained' }]] + ]) + }) + expect(parse.mock.calls.filter(([input]) => input === settings)).toHaveLength(1) + expect(parse.mock.calls.filter(([input]) => input === row)).toHaveLength(1) + expect(parse.mock.calls.some(([input]) => input === `[${row}]`)).toBe(false) + } finally { + parse.mockRestore() + db.close() + } +}) diff --git a/src/main/persistence/profile-state/profile-state-domain-reader.ts b/src/main/persistence/profile-state/profile-state-domain-reader.ts new file mode 100644 index 00000000000..2bd3a642e8e --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-domain-reader.ts @@ -0,0 +1,115 @@ +import { withProfileStateReadSnapshot } from './profile-state-read-snapshot' +import { + assertProfileStateDocumentRevision, + readProfileStateRevision +} from './profile-state-revision' +import { readProfileStateAutomationRunsDocument } from './profile-state-automation-runs' +import { openProfileStateDatabaseReadOnly } from './profile-state-database' +import { + validateProfileStateDocumentRow, + type ProfileStateParsedDocument +} from './profile-state-document-validation' + +export type ProfileStateDomainReadResult = + | { kind: 'value'; value: unknown } + | { kind: 'missing' } + | { kind: 'unreadable'; error: unknown } + +export type ProfileStateDomainsReadResult = + | { kind: 'values'; revision: number; values: ReadonlyMap } + | { kind: 'unreadable'; error: unknown } + +/** + * Read one domain from an existing profile database without opening a write + * handle, applying migrations, or creating the database. Any malformed selected + * row makes the whole read unusable so callers that prune state can fail closed. + */ +export function readProfileStateDomain( + databasePath: string, + profileId: string, + domain: string +): ProfileStateDomainReadResult { + const result = readProfileStateDomains(databasePath, profileId, [domain]) + if (result.kind === 'unreadable') { + return result + } + if (!result.values.has(domain)) { + return { kind: 'missing' } + } + return { kind: 'value', value: result.values.get(domain) } +} + +/** Read several domains and the fencing revision under one SQLite snapshot. */ +export function readProfileStateDomains( + databasePath: string, + profileId: string, + domains: readonly string[] +): ProfileStateDomainsReadResult { + let opened: ReturnType | undefined + try { + opened = openProfileStateDatabaseReadOnly(databasePath, profileId) + return readProfileStateDomainsWithRevisionFromDatabase(opened.db, domains) + } catch (error) { + return { kind: 'unreadable', error } + } finally { + opened?.db.close() + } +} + +/** Read several domains from an already-open database, keeping the caller's handle alive. */ +export function readProfileStateDomainsWithRevisionFromDatabase( + db: Parameters[0], + domains: readonly string[] +): ProfileStateDomainsReadResult { + const wanted = new Set(domains) + const values = new Map() + try { + return withProfileStateReadSnapshot(db, () => { + const revision = readProfileStateRevision(db) + if (wanted.size === 0) { + return { kind: 'values', revision, values } + } + + const normalized = wanted.has('automationRuns') + ? readProfileStateAutomationRunsDocument(db, revision, 'parsed') + : undefined + const legacyDomains = [...wanted].filter( + (domain) => domain !== 'automationRuns' || normalized === undefined + ) + for (const document of readSelectedDocuments(db, legacyDomains)) { + assertProfileStateDocumentRevision(document.revision, revision, document.domain) + values.set(document.domain, document.value) + } + if (normalized !== undefined && normalized !== null) { + values.set(normalized.domain, normalized.value) + } + return { kind: 'values', revision, values } + }) + } catch (error) { + return { kind: 'unreadable', error } + } +} + +function readSelectedDocuments( + db: Parameters[0], + domains: readonly string[] +): readonly ProfileStateParsedDocument[] { + if (domains.length === 0) { + return [] + } + const placeholders = domains.map(() => '?').join(',') + const rows = db + .prepare( + `SELECT domain, payload, domain_version, revision, updated_at, content_hash + FROM profile_state_documents + WHERE domain IN (${placeholders}) + ORDER BY rowid` + ) + .iterate(...domains) + return Array.from(rows, (row) => { + const { payload: _payload, ...document } = validateProfileStateDocumentRow(row, { + retainParsedValue: true + }) + return { ...document, value: document.value } + }) +} diff --git a/src/main/persistence/profile-state/profile-state-domain-write-validation.ts b/src/main/persistence/profile-state/profile-state-domain-write-validation.ts new file mode 100644 index 00000000000..bac86a1610a --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-domain-write-validation.ts @@ -0,0 +1,78 @@ +import { PROFILE_STATE_DOCUMENT_VERSION } from './profile-state-database-schema' +import { hashProfileStateJson } from './profile-state-documents' +import type { AutomationRunsWritePreparation } from './profile-state-automation-runs' +import type { + ProfileStateDomainMutation, + ProfileStateDomainTransaction +} from './profile-state-domain-writes' + +export type PreparedProfileStateMutation = ProfileStateDomainMutation & { + domainVersion: number + payloadHash: string | null + // Keep the checked history value only for this write so normalization does not parse it again. + automationRunsValue?: unknown + automationRuns?: AutomationRunsWritePreparation +} + +export function validateProfileStateDomainTransaction( + transaction: ProfileStateDomainTransaction +): void { + if (!Number.isSafeInteger(transaction.expectedRevision) || transaction.expectedRevision < 0) { + throw new Error('Profile state expected revision is invalid') + } + if ( + !Array.isArray(transaction.replacements) || + (transaction.replacements.length === 0 && transaction.automationRunsAfter === undefined) + ) { + throw new Error('Profile state domain transaction requires at least one replacement') + } + const domains = new Set() + for (const replacement of transaction.replacements) { + validateProfileStateDomainMutation(replacement) + if (domains.has(replacement.domain)) { + throw new Error(`Profile state domain transaction repeats domain: ${replacement.domain}`) + } + domains.add(replacement.domain) + } + if (transaction.automationRunsAfter !== undefined && domains.has('automationRuns')) { + throw new Error('Profile state automationRuns delta repeats domain: automationRuns') + } +} + +export function prepareProfileStateDomainMutation( + replacement: ProfileStateDomainMutation +): PreparedProfileStateMutation { + const payloadHash = + replacement.payload === null ? null : hashProfileStateJson(replacement.payload) + let parsed: unknown + if (replacement.payload !== null) { + try { + parsed = JSON.parse(replacement.payload) + } catch { + throw new Error(`Profile state domain payload is invalid JSON: ${replacement.domain}`) + } + } + return { + domain: replacement.domain, + payload: replacement.payload, + now: replacement.now, + domainVersion: replacement.domainVersion ?? PROFILE_STATE_DOCUMENT_VERSION, + payloadHash, + automationRunsValue: replacement.domain === 'automationRuns' ? parsed : undefined + } +} + +function validateProfileStateDomainMutation(replacement: ProfileStateDomainMutation): void { + if (typeof replacement.domain !== 'string' || replacement.domain.length === 0) { + throw new Error('Profile state domain name cannot be empty') + } + if (replacement.payload !== null && typeof replacement.payload !== 'string') { + throw new Error(`Profile state domain payload is invalid: ${replacement.domain}`) + } + if ( + replacement.domainVersion !== undefined && + (!Number.isSafeInteger(replacement.domainVersion) || replacement.domainVersion < 1) + ) { + throw new Error('Profile state domain version is invalid') + } +} diff --git a/src/main/persistence/profile-state/profile-state-domain-writes.test.ts b/src/main/persistence/profile-state/profile-state-domain-writes.test.ts new file mode 100644 index 00000000000..8c54ee2360f --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-domain-writes.test.ts @@ -0,0 +1,585 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + exportProfileStateJson, + importProfileStateJson, + readProfileStateDocuments, + readProfileStateRevision +} from './profile-state-documents' +import { openProfileStateDatabase, profileStateDatabaseFile } from './profile-state-database' +import { + ProfileStateRevisionConflictError, + writeProfileStateDomain, + writeProfileStateDomains +} from './profile-state-domain-writes' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function openTestDatabase(): ReturnType['db'] { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-domain-write-')) + temporaryDirectories.push(directory) + return openProfileStateDatabase(profileStateDatabaseFile(directory), 'profile-a').db +} + +describe('profile state dirty-domain writes', () => { + it('retains logical history order when existing runs change position', () => { + const db = openTestDatabase() + const runs = [{ id: 'first' }, { id: 'second' }, { id: 'third' }] as const + const reordered = [runs[2], runs[0], runs[1]] + try { + importProfileStateJson(db, JSON.stringify({ automationRuns: runs })) + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: JSON.stringify(reordered), + expectedRevision: 1 + }) + + expect( + db.prepare('SELECT run_id FROM profile_state_automation_runs ORDER BY rowid').all() + ).toEqual(runs.map((run) => ({ run_id: run.id }))) + expect(JSON.parse(exportProfileStateJson(db)).automationRuns).toEqual(reordered) + + db.prepare('UPDATE profile_state_automation_runs SET ordinal = 0 WHERE run_id = ?').run( + 'first' + ) + expect(() => exportProfileStateJson(db)).toThrow( + 'Normalized automationRuns ordering is corrupt' + ) + } finally { + db.close() + } + }) + + it('updates automationRuns without rewriting unrelated domains', () => { + const db = openTestDatabase() + try { + importProfileStateJson( + db, + JSON.stringify({ + settings: { theme: 'dark' }, + automationRuns: [{ id: 'run-1', status: 'pending' }] + }), + { now: () => 100 } + ) + const settingsBefore = readProfileStateDocuments(db).find( + (document) => document.domain === 'settings' + ) + + const result = writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1', status: 'completed' }]), + expectedRevision: 1, + now: () => 200 + }) + + expect(result).toEqual({ changed: true, revision: 2 }) + expect(JSON.parse(exportProfileStateJson(db))).toEqual({ + settings: { theme: 'dark' }, + automationRuns: [{ id: 'run-1', status: 'completed' }] + }) + expect(readProfileStateDocuments(db)).toEqual([ + expect.objectContaining({ domain: 'settings', revision: 1, updatedAt: 100 }), + expect.objectContaining({ domain: 'automationRuns', revision: 2, updatedAt: 200 }) + ]) + const settingsAfter = readProfileStateDocuments(db).find( + (document) => document.domain === 'settings' + ) + expect(settingsAfter).toMatchObject({ + payload: settingsBefore?.payload, + contentHash: settingsBefore?.contentHash, + updatedAt: settingsBefore?.updatedAt + }) + } finally { + db.close() + } + }) + + it('commits changed automation runs as rows without rewriting the legacy document blob', () => { + const db = openTestDatabase() + try { + const fixtureRun = buildProfileStateCutoverFixture().automationRuns[0] + if (!fixtureRun) { + throw new Error('Expected automation run fixture') + } + const initialRuns = [ + { ...fixtureRun, id: 'run-1', status: 'pending' as const }, + { ...fixtureRun, id: 'run-2', status: 'completed' as const } + ] + importProfileStateJson( + db, + JSON.stringify({ + settings: { theme: 'dark' }, + automationRuns: initialRuns + }), + { now: () => 100 } + ) + const legacyDocument = db + .prepare('SELECT payload, content_hash FROM profile_state_documents WHERE domain = ?') + .get('automationRuns') + + const result = writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [], + automationRunsAfter: [{ ...initialRuns[0], status: 'dispatched' as const }, initialRuns[1]] + }) + + expect(result).toEqual({ + changed: true, + revision: 2, + changedDomains: ['automationRuns'] + }) + expect( + db.prepare('SELECT COUNT(*) AS count FROM profile_state_automation_runs').get() + ).toEqual({ count: 2 }) + expect( + db + .prepare('SELECT payload, content_hash FROM profile_state_documents WHERE domain = ?') + .get('automationRuns') + ).toEqual(legacyDocument) + expect(JSON.parse(exportProfileStateJson(db))).toEqual({ + settings: { theme: 'dark' }, + automationRuns: [{ ...initialRuns[0], status: 'dispatched' }, initialRuns[1]] + }) + } finally { + db.close() + } + }) + + it('rolls back a direct automation-run delta and revision when normalized metadata rejects it', () => { + const db = openTestDatabase() + try { + const fixtureRun = buildProfileStateCutoverFixture().automationRuns[0] + if (!fixtureRun) { + throw new Error('Expected automation run fixture') + } + const initialRuns = [{ ...fixtureRun, id: 'run-1', status: 'pending' as const }] + importProfileStateJson(db, JSON.stringify({ automationRuns: initialRuns }), { + now: () => 100 + }) + const normalizedRuns = [{ ...initialRuns[0], status: 'dispatched' as const }] + expect( + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [], + automationRunsAfter: normalizedRuns + }) + ).toEqual({ changed: true, revision: 2, changedDomains: ['automationRuns'] }) + db.exec( + `CREATE TRIGGER fail_profile_state_automation_run_delta + BEFORE UPDATE ON profile_state_automation_runs_meta + WHEN NEW.domain = 'automationRuns' + BEGIN SELECT RAISE(ABORT, 'injected automation delta failure'); END` + ) + + expect(() => + writeProfileStateDomains(db, { + expectedRevision: 2, + replacements: [], + automationRunsAfter: [{ ...normalizedRuns[0], status: 'completed' as const }] + }) + ).toThrow('injected automation delta failure') + db.exec('DROP TRIGGER fail_profile_state_automation_run_delta') + expect(readProfileStateRevision(db)).toBe(2) + expect(JSON.parse(exportProfileStateJson(db)).automationRuns).toEqual(normalizedRuns) + } finally { + db.close() + } + }) + + it('fails closed when a normalized automation-run row is corrupt', () => { + const db = openTestDatabase() + try { + const fixtureRun = buildProfileStateCutoverFixture().automationRuns[0] + if (!fixtureRun) { + throw new Error('Expected automation run fixture') + } + const runs = [{ ...fixtureRun, id: 'run-1', status: 'pending' as const }] + importProfileStateJson(db, JSON.stringify({ automationRuns: runs })) + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [], + automationRunsAfter: runs + }) + db.prepare('UPDATE profile_state_automation_runs SET payload = ? WHERE run_id = ?').run( + JSON.stringify({ ...runs[0], status: 'tampered' }), + 'run-1' + ) + + expect(() => exportProfileStateJson(db)).toThrow('Normalized automationRuns row is corrupt') + } finally { + db.close() + } + }) + + it('retains unchanged run row revisions while updating the aggregate projection', () => { + const db = openTestDatabase() + try { + const fixtureRuns = buildProfileStateCutoverFixture().automationRuns + const first = fixtureRuns[0] + if (!first) { + throw new Error('Expected automation run fixture') + } + const initialRuns = [ + { ...first, id: 'run-1', status: 'pending' as const }, + { ...first, id: 'run-2', status: 'dispatched' as const } + ] + importProfileStateJson(db, JSON.stringify({ automationRuns: initialRuns })) + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [], + automationRunsAfter: [{ ...initialRuns[0], status: 'completed' as const }, initialRuns[1]] + }) + writeProfileStateDomains(db, { + expectedRevision: 2, + replacements: [], + automationRunsAfter: [ + { ...initialRuns[0], status: 'dispatch_failed' as const }, + initialRuns[1] + ] + }) + + expect( + db + .prepare('SELECT revision FROM profile_state_automation_runs WHERE run_id = ?') + .get('run-2') + ).toEqual({ revision: 1 }) + expect(readProfileStateRevision(db)).toBe(3) + expect(JSON.parse(exportProfileStateJson(db)).automationRuns).toEqual([ + { ...initialRuns[0], status: 'dispatch_failed' }, + initialRuns[1] + ]) + } finally { + db.close() + } + }) + + it('commits several changed domains at one shared revision', () => { + const db = openTestDatabase() + try { + importProfileStateJson( + db, + JSON.stringify({ + settings: { theme: 'dark' }, + automationRuns: [{ id: 'run-1', status: 'pending' }], + ui: { activeView: 'terminal' } + }), + { now: () => 100 } + ) + + expect( + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [ + { + domain: 'settings', + payload: JSON.stringify({ theme: 'light' }), + now: () => 200 + }, + { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1', status: 'completed' }]), + now: () => 201 + } + ] + }) + ).toEqual({ + changed: true, + revision: 2, + changedDomains: ['settings', 'automationRuns'] + }) + expect(readProfileStateRevision(db)).toBe(2) + expect(JSON.parse(exportProfileStateJson(db))).toEqual({ + settings: { theme: 'light' }, + automationRuns: [{ id: 'run-1', status: 'completed' }], + ui: { activeView: 'terminal' } + }) + expect(readProfileStateDocuments(db)).toEqual([ + expect.objectContaining({ domain: 'settings', revision: 2, updatedAt: 200 }), + expect.objectContaining({ domain: 'automationRuns', revision: 2, updatedAt: 201 }), + expect.objectContaining({ domain: 'ui', revision: 1, updatedAt: 100 }) + ]) + } finally { + db.close() + } + }) + + it('rolls back every domain when a later mutation fails', () => { + const db = openTestDatabase() + try { + importProfileStateJson( + db, + JSON.stringify({ + settings: { theme: 'dark' }, + automationRuns: [{ id: 'run-1', status: 'pending' }] + }), + { now: () => 100 } + ) + db.exec( + `CREATE TRIGGER fail_second_profile_state_domain_update + BEFORE UPDATE ON profile_state_automation_runs_meta + WHEN NEW.domain = 'automationRuns' + BEGIN SELECT RAISE(ABORT, 'injected domain failure'); END` + ) + + expect(() => + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [ + { domain: 'settings', payload: JSON.stringify({ theme: 'light' }) }, + { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1', status: 'completed' }]) + } + ] + }) + ).toThrow('injected domain failure') + db.exec('DROP TRIGGER fail_second_profile_state_domain_update') + expect(readProfileStateRevision(db)).toBe(1) + expect(JSON.parse(exportProfileStateJson(db))).toEqual({ + settings: { theme: 'dark' }, + automationRuns: [{ id: 'run-1', status: 'pending' }] + }) + } finally { + db.close() + } + }) + + it('fences a stale multi-domain transaction before changing either row', () => { + const db = openTestDatabase() + try { + importProfileStateJson( + db, + JSON.stringify({ settings: { theme: 'dark' }, ui: { activeView: 'terminal' } }) + ) + writeProfileStateDomain(db, { + domain: 'settings', + payload: JSON.stringify({ theme: 'light' }), + expectedRevision: 1 + }) + + expect(() => + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [ + { domain: 'settings', payload: JSON.stringify({ theme: 'blue' }) }, + { domain: 'ui', payload: JSON.stringify({ activeView: 'browser' }) } + ] + }) + ).toThrowError(ProfileStateRevisionConflictError) + expect(readProfileStateRevision(db)).toBe(2) + expect(JSON.parse(exportProfileStateJson(db))).toEqual({ + settings: { theme: 'light' }, + ui: { activeView: 'terminal' } + }) + } finally { + db.close() + } + }) + + it('rejects duplicate domains before opening a transaction', () => { + const db = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ settings: { theme: 'dark' } })) + expect(() => + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [ + { domain: 'settings', payload: JSON.stringify({ theme: 'light' }) }, + { domain: 'settings', payload: JSON.stringify({ theme: 'blue' }) } + ] + }) + ).toThrow('repeats domain: settings') + expect(readProfileStateRevision(db)).toBe(1) + expect(JSON.parse(exportProfileStateJson(db))).toEqual({ settings: { theme: 'dark' } }) + } finally { + db.close() + } + }) + + it('fences a stale Store and leaves the database unchanged', () => { + const db = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ automationRuns: [{ id: 'run-1' }] })) + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1', status: 'completed' }]), + expectedRevision: 1 + }) + + expect(() => + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1', status: 'failed' }]), + expectedRevision: 1 + }) + ).toThrowError( + expect.objectContaining({ + code: 'profile-state-revision-conflict', + expectedRevision: 1, + actualRevision: 2 + }) + ) + expect(readProfileStateRevision(db)).toBe(2) + expect(JSON.parse(exportProfileStateJson(db)).automationRuns).toEqual([ + { id: 'run-1', status: 'completed' } + ]) + } finally { + db.close() + } + }) + + it('fences two independently opened database writers with the shared revision', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-domain-writer-fence-')) + temporaryDirectories.push(directory) + const databasePath = profileStateDatabaseFile(directory) + const first = openProfileStateDatabase(databasePath, 'profile-a').db + const second = openProfileStateDatabase(databasePath, 'profile-a').db + try { + importProfileStateJson(first, JSON.stringify({ automationRuns: [{ id: 'run-1' }] })) + expect(readProfileStateRevision(second)).toBe(1) + writeProfileStateDomain(first, { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1', status: 'completed' }]), + expectedRevision: 1 + }) + + expect(() => + writeProfileStateDomain(second, { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1', status: 'failed' }]), + expectedRevision: 1 + }) + ).toThrowError(ProfileStateRevisionConflictError) + expect(readProfileStateRevision(second)).toBe(2) + } finally { + first.close() + second.close() + } + }) + + it('does not advance revision for an identical replacement or absent delete', () => { + const db = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ automationRuns: [{ id: 'run-1' }] })) + expect( + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1' }]), + expectedRevision: 1 + }) + ).toEqual({ changed: false, revision: 1 }) + expect( + writeProfileStateDomain(db, { + domain: 'missingDomain', + payload: null, + expectedRevision: 1 + }) + ).toEqual({ changed: false, revision: 1 }) + expect(readProfileStateRevision(db)).toBe(1) + } finally { + db.close() + } + }) + + it('distinguishes explicit JSON null from deleting a domain', () => { + const db = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ automationRuns: [{ id: 'run-1' }] })) + expect( + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: 'null', + expectedRevision: 1 + }) + ).toEqual({ changed: true, revision: 2 }) + expect(JSON.parse(exportProfileStateJson(db))).toEqual({ automationRuns: null }) + + expect( + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: null, + expectedRevision: 2 + }) + ).toEqual({ changed: true, revision: 3 }) + expect(JSON.parse(exportProfileStateJson(db))).toEqual({}) + } finally { + db.close() + } + }) + + it('rolls back the row and revision when SQLite rejects the replacement', () => { + const db = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ automationRuns: [{ id: 'run-1' }] })) + db.exec( + `CREATE TRIGGER fail_profile_state_domain_update + BEFORE UPDATE ON profile_state_automation_runs_meta + WHEN NEW.domain = 'automationRuns' + BEGIN SELECT RAISE(ABORT, 'injected domain failure'); END` + ) + + expect(() => + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1', status: 'failed' }]), + expectedRevision: 1 + }) + ).toThrow('injected domain failure') + db.exec('DROP TRIGGER fail_profile_state_domain_update') + expect(readProfileStateRevision(db)).toBe(1) + expect(JSON.parse(exportProfileStateJson(db))).toEqual({ + automationRuns: [{ id: 'run-1' }] + }) + } finally { + db.close() + } + }) + + it('rejects malformed payloads before opening a transaction', () => { + const db = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ automationRuns: [{ id: 'run-1' }] })) + expect(() => + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: '{invalid', + expectedRevision: 1 + }) + ).toThrow('Profile state domain payload is invalid JSON: automationRuns') + expect(readProfileStateRevision(db)).toBe(1) + } finally { + db.close() + } + }) + + it('fails closed when the target row hash is already corrupt', () => { + const db = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ automationRuns: [{ id: 'run-1' }] })) + db.prepare('UPDATE profile_state_documents SET payload = ? WHERE domain = ?').run( + JSON.stringify([{ id: 'tampered' }]), + 'automationRuns' + ) + + expect(() => + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1', status: 'completed' }]), + expectedRevision: 1 + }) + ).toThrow('Profile state document hash mismatch: automationRuns') + expect(readProfileStateRevision(db)).toBe(1) + } finally { + db.close() + } + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-domain-writes.ts b/src/main/persistence/profile-state/profile-state-domain-writes.ts new file mode 100644 index 00000000000..792487ef3b1 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-domain-writes.ts @@ -0,0 +1,231 @@ +import { withProfileStateWriteTransaction } from './profile-state-write-transaction' +import type Database from '../../sqlite/sync-database' +import { readCurrentAutomationRunsState } from './profile-state-automation-runs-storage' +import { + PROFILE_STATE_DOCUMENT_VERSION, + PROFILE_STATE_META_REVISION +} from './profile-state-database-schema' +import { + ProfileStateRevisionConflictError, + readProfileStateRevision +} from './profile-state-documents' +import { + applyProfileStateAutomationRuns, + clearProfileStateAutomationRuns, + prepareProfileStateAutomationRunsDelta, + prepareProfileStateAutomationRunsReplacement +} from './profile-state-automation-runs' +import { isRecord, validateProfileStateDocumentRow } from './profile-state-document-validation' +import { assertProfileStateDocumentRevision } from './profile-state-revision' +import { + prepareProfileStateDomainMutation, + validateProfileStateDomainTransaction, + type PreparedProfileStateMutation +} from './profile-state-domain-write-validation' + +export { ProfileStateRevisionConflictError } from './profile-state-documents' + +/** A storage-form replacement for one top-level profile-state domain. */ +export type ProfileStateDomainReplacement = { + /** Non-empty top-level domain name, for example `automationRuns`. */ + domain: string + /** Canonical JSON payload, or null to remove the domain row. */ + payload: string | null + /** Revision observed by the caller before it built this replacement. */ + expectedRevision: number + domainVersion?: number + now?: () => number +} + +/** One row mutation inside a transaction that may update several domains. */ +export type ProfileStateDomainMutation = Omit + +/** + * A set of domain mutations guarded by one profile revision. + * + * A profile revision is shared by every row, so checking it once at the start + * of the transaction gives callers an atomic cross-domain compare-and-swap. + */ +export type ProfileStateDomainTransaction = { + expectedRevision: number + replacements: readonly ProfileStateDomainMutation[] + /** Changed run projection supplied by Store for selective row updates. */ + automationRunsAfter?: readonly unknown[] +} + +export type ProfileStateDomainWriteResult = { + changed: boolean + revision: number +} + +export type ProfileStateDomainTransactionResult = ProfileStateDomainWriteResult & { + changedDomains: readonly string[] +} + +/** + * Replace one domain without serializing or rewriting the other domains. + * + * The caller supplies the revision it read with the domain. SQLite's + * `BEGIN IMMEDIATE` plus the exact revision check fences stale Store instances + * before the row and profile revision are changed. A null payload deletes the + * row; the JSON literal `null` remains an explicit domain value. + */ +export function writeProfileStateDomain( + db: Database.Database, + replacement: ProfileStateDomainReplacement +): ProfileStateDomainWriteResult { + const result = writeProfileStateDomains(db, { + expectedRevision: replacement.expectedRevision, + replacements: [ + { + domain: replacement.domain, + payload: replacement.payload, + domainVersion: replacement.domainVersion, + now: replacement.now + } + ] + }) + return { changed: result.changed, revision: result.revision } +} + +/** + * Replace one or more domains in one SQLite transaction. + * + * Every changed row receives the same next profile revision. If any row + * validation, payload write, or commit step fails, SQLite rolls back all row + * changes and the profile revision remains unchanged. + */ +export function writeProfileStateDomains( + db: Database.Database, + transaction: ProfileStateDomainTransaction +): ProfileStateDomainTransactionResult { + validateProfileStateDomainTransaction(transaction) + + const prepared = transaction.replacements.map(prepareProfileStateDomainMutation) + + return withProfileStateWriteTransaction(db, () => { + const actualRevision = readProfileStateRevision(db) + if (actualRevision !== transaction.expectedRevision) { + throw new ProfileStateRevisionConflictError(transaction.expectedRevision, actualRevision) + } + const currentAutomationRuns = readCurrentAutomationRunsState(db, actualRevision) + + const updates: PreparedProfileStateMutation[] = [] + for (const mutation of prepared) { + const existing = db + .prepare( + `SELECT domain, payload, domain_version, revision, updated_at, content_hash + FROM profile_state_documents WHERE domain = ?` + ) + .get(mutation.domain) + const existingRow = + existing === undefined + ? undefined + : validateProfileStateDocumentRow(existing, { + // An identical incoming payload has already passed JSON validation. + validateJson: !(isRecord(existing) && existing.payload === mutation.payload) + }) + if (existingRow) { + assertProfileStateDocumentRevision(existingRow.revision, actualRevision, mutation.domain) + } + if (mutation.domain === 'automationRuns') { + // Canonical history already has this hash; unchanged rows need no new projection. + if ( + currentAutomationRuns.presence === 'array' && + mutation.payload?.startsWith('[') && + currentAutomationRuns.contentHash === mutation.payloadHash + ) { + continue + } + const normalized = prepareProfileStateAutomationRunsReplacement( + db, + mutation, + actualRevision + ) + if (normalized !== undefined) { + if (normalized.changed) { + updates.push({ ...mutation, automationRuns: normalized }) + } + continue + } + } + const unchanged = + (mutation.payload === null && existingRow === undefined) || + (mutation.payload !== null && existingRow?.payload === mutation.payload) + if (!unchanged) { + updates.push(mutation) + } + } + if (transaction.automationRunsAfter !== undefined) { + const delta = prepareProfileStateAutomationRunsDelta( + db, + transaction.automationRunsAfter, + PROFILE_STATE_DOCUMENT_VERSION, + Date.now, + actualRevision + ) + if (delta === undefined) { + throw new Error('Normalized automationRuns delta is unsupported') + } + if (delta.changed) { + updates.push({ + domain: 'automationRuns', + payload: null, + domainVersion: PROFILE_STATE_DOCUMENT_VERSION, + payloadHash: null, + automationRuns: delta + }) + } + } + + if (updates.length === 0) { + return { changed: false, revision: actualRevision, changedDomains: [] } + } + + const nextRevision = actualRevision + 1 + for (const mutation of updates) { + if (mutation.automationRuns) { + applyProfileStateAutomationRuns(db, mutation.automationRuns, nextRevision) + continue + } + if (mutation.domain === 'automationRuns') { + clearProfileStateAutomationRuns(db) + } + if (mutation.payload === null) { + db.prepare('DELETE FROM profile_state_documents WHERE domain = ?').run(mutation.domain) + } else { + const updatedAt = (mutation.now ?? Date.now)() + if (!Number.isSafeInteger(updatedAt) || updatedAt < 0) { + throw new Error(`Profile state domain update timestamp is invalid: ${mutation.domain}`) + } + db.prepare( + `INSERT INTO profile_state_documents + (domain, payload, domain_version, revision, updated_at, content_hash) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(domain) DO UPDATE SET + payload = excluded.payload, + domain_version = excluded.domain_version, + revision = excluded.revision, + updated_at = excluded.updated_at, + content_hash = excluded.content_hash` + ).run( + mutation.domain, + mutation.payload, + mutation.domainVersion, + nextRevision, + updatedAt, + mutation.payloadHash + ) + } + } + db.prepare( + `INSERT INTO profile_state_meta (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value` + ).run(PROFILE_STATE_META_REVISION, String(nextRevision)) + return { + changed: true, + revision: nextRevision, + changedDomains: updates.map(({ domain }) => domain) + } + }) +} diff --git a/src/main/persistence/profile-state/profile-state-fragment-validation.test.ts b/src/main/persistence/profile-state/profile-state-fragment-validation.test.ts new file mode 100644 index 00000000000..1fcb85eb108 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-fragment-validation.test.ts @@ -0,0 +1,132 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Store } from '../loading-store/store' +import { openProfileStateDatabase } from './profile-state-database' +import { + hashProfileStateJson, + importProfileStateJson, + readProfileStateSnapshot, + readProfileStateParsedSnapshot, + validateProfileStateSnapshot +} from './profile-state-documents' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { createProfileStateStore } from './profile-state-store-factory' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const directories: string[] = [] + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture(keepJson = false) { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-fragments-')) + directories.push(directory) + const paths = { + dataFile: join(directory, 'orca-data.json'), + databaseFile: join(directory, 'profile-state.db'), + profileId: 'fragment-validation' + } + const source = JSON.stringify({ + settings: { theme: 'dark' }, + futureDomain: null, + automationRuns: [{ id: 'a' }, { id: 'b' }] + }) + if (keepJson) { + writeFileSync(paths.dataFile, source) + } + const { db } = openProfileStateDatabase(paths.databaseFile, paths.profileId) + importProfileStateJson(db, source, { acceptedLegacyJsonHash: hashProfileStateJson(source) }) + return { paths, db } +} + +describe('independent profile state JSON fragments', () => { + it.each([false, true])( + 'rejects a domain that injects a valid sibling into startup JSON (retained JSON: %s)', + (keepJson) => { + const { paths, db } = fixture(keepJson) + const payload = 'null,"settings":{"theme":"light"}' + db.prepare( + 'UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = ?' + ).run(payload, hashProfileStateJson(payload), 'futureDomain') + expect(() => readProfileStateParsedSnapshot(db)).toThrow(/invalid JSON: futureDomain/) + expect(() => validateProfileStateSnapshot(db)).toThrow(/invalid JSON: futureDomain/) + db.close() + + expect(() => { + const result = createProfileStateStore({ ...paths }) + result.store.freezeWrites() + }).toThrow() + } + ) + + it('rejects a spliced domain through direct authority and Store reads', () => { + const { paths, db } = fixture() + const payload = 'null,"settings":{"theme":"light"}' + db.prepare( + 'UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = ?' + ).run(payload, hashProfileStateJson(payload), 'futureDomain') + db.close() + const authority = new ProfileStateSqliteAuthority(paths.databaseFile, paths.profileId) + try { + expect(() => { + const store = new Store({ dataFile: paths.dataFile, profileStateAuthority: authority }) + store.freezeWrites() + }).toThrow(/invalid JSON: futureDomain/) + expect(() => authority.readSerializedState()).toThrow(/invalid JSON: futureDomain/) + } finally { + authority.close() + } + }) + + it.each(['snapshot', 'parsed', 'validated', 'authority', 'store'] as const)( + 'rejects history rows that form a valid array only when spliced together (%s)', + (boundary) => { + const { paths, db } = fixture() + const payloads = ['{"id":"a","text":"', 'b"},{"id":"b"}'] + const aggregate = `[${payloads.join(',')}]` + expect(JSON.parse(aggregate)).toEqual([{ id: 'a', text: ',b' }, { id: 'b' }]) + for (const [ordinal, payload] of payloads.entries()) { + expect(() => JSON.parse(payload)).toThrow() + db.prepare( + 'UPDATE profile_state_automation_runs SET payload = ?, content_hash = ? WHERE ordinal = ?' + ).run(payload, hashProfileStateJson(payload), ordinal) + } + db.prepare('UPDATE profile_state_automation_runs_meta SET content_hash = ?').run( + hashProfileStateJson(aggregate) + ) + const authority = new ProfileStateSqliteAuthority(paths.databaseFile, paths.profileId) + try { + expect(() => { + if (boundary === 'snapshot') { + readProfileStateSnapshot(db) + } else if (boundary === 'parsed') { + readProfileStateParsedSnapshot(db) + } else if (boundary === 'validated') { + validateProfileStateSnapshot(db) + } else if (boundary === 'authority') { + authority.readSerializedState() + } else { + const store = new Store({ dataFile: paths.dataFile, profileStateAuthority: authority }) + store.freezeWrites() + } + }).toThrow('Normalized automationRuns row is invalid JSON') + } finally { + authority.close() + db.close() + } + } + ) +}) diff --git a/src/main/persistence/profile-state/profile-state-json-compatibility-recovery.test.ts b/src/main/persistence/profile-state/profile-state-json-compatibility-recovery.test.ts new file mode 100644 index 00000000000..ed02b9ba43e --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-json-compatibility-recovery.test.ts @@ -0,0 +1,239 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as durableFiles from '../../durable-file-write' +import { openProfileStateDatabase } from './profile-state-database' +import { + hashProfileStateJson, + importProfileStateJson, + readProfileStateJsonAcceptance +} from './profile-state-documents' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { createProfileStateStore } from './profile-state-store-factory' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { isEncryptionAvailable: () => false }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const fixtures: { directory: string; authority: ProfileStateSqliteAuthority }[] = [] + +afterEach(() => { + vi.restoreAllMocks() + for (const { authority, directory } of fixtures.splice(0)) { + authority.close() + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-json-compatibility-recovery-')) + const paths = { + dataFile: join(directory, 'orca-data.json'), + databaseFile: join(directory, 'profile-state.db'), + profileId: 'compatibility-recovery' + } + const retainedJson = '{"settings":{"theme":"light"}}' + writeFileSync(paths.dataFile, retainedJson) + const withDatabase = ( + run: (db: ReturnType['db']) => T + ): T => { + const opened = openProfileStateDatabase(paths.databaseFile, paths.profileId) + try { + return run(opened.db) + } finally { + opened.db.close() + } + } + withDatabase((db) => + importProfileStateJson(db, retainedJson, { + acceptedLegacyJsonHash: hashProfileStateJson(retainedJson) + }) + ) + const authority = new ProfileStateSqliteAuthority(paths.databaseFile, paths.profileId) + authority.readSerializedState() + authority.writeSerializedState(Buffer.from('{"settings":{"theme":"dark"}}')) + fixtures.push({ directory, authority }) + return { + paths, + authority, + retainedJson, + withDatabase, + acceptance: () => withDatabase(readProfileStateJsonAcceptance), + publish: async (mode: 'sync' | 'async') => + mode === 'sync' + ? authority.writeJsonCompatibilityExport(paths.dataFile) + : authority.writeJsonCompatibilityExportAsync(paths.dataFile), + reopen: () => { + const result = createProfileStateStore({ ...paths }) + try { + expect(result.backend).toBe('sqlite') + return result.store.getSettings().theme + } finally { + result.store.freezeWrites() + } + } + } +} + +describe.each(['sync', 'async'] as const)('%s compatibility export recovery', (mode) => { + it.each(['staging', 'publication', 'promotion'] as const)( + 'reopens SQLite and permits a later export after failed %s', + async (phase) => { + const state = fixture() + if (phase === 'publication') { + if (mode === 'sync') { + const write = durableFiles.writeFileDurableSync + vi.spyOn(durableFiles, 'writeFileDurableSync').mockImplementation((...args) => { + if (args[1] === state.paths.dataFile) { + throw new Error('injected publication failure') + } + write(...args) + }) + } else { + vi.spyOn(durableFiles, 'writeFileDurable').mockRejectedValueOnce( + new Error('injected publication failure') + ) + } + } else { + state.withDatabase((db) => + db.exec( + `CREATE TRIGGER reject_acceptance BEFORE INSERT ON profile_state_meta + WHEN NEW.key = 'legacy_json_acceptance' + ${phase === 'promotion' ? "AND json_type(NEW.value, '$.pending') IS NULL" : ''} + BEGIN SELECT RAISE(ABORT, 'injected acceptance failure'); END` + ) + ) + } + + await expect(state.publish(mode)).rejects.toThrow('injected') + + const published = readFileSync(state.paths.dataFile, 'utf8') + expect(JSON.parse(published).settings.theme).toBe(phase === 'promotion' ? 'dark' : 'light') + expect(state.reopen()).toBe('dark') + if (phase !== 'staging') { + expect(state.acceptance()?.pending).toEqual({ + jsonHash: hashProfileStateJson('{"settings":{"theme":"dark"}}'), + acceptedRevision: 2 + }) + } + vi.restoreAllMocks() + state.withDatabase((db) => db.exec('DROP TRIGGER IF EXISTS reject_acceptance')) + state.authority.writeSerializedState(Buffer.from('{"settings":{"theme":"system"}}')) + + await state.publish(mode) + + expect(state.reopen()).toBe('system') + expect(state.acceptance()).toEqual({ + jsonHash: hashProfileStateJson(readFileSync(state.paths.dataFile, 'utf8')), + acceptedRevision: 3 + }) + } + ) + + it('keeps the published JSON accepted when a concurrent commit prevents promotion', async () => { + const state = fixture() + const compete = () => { + const other = new ProfileStateSqliteAuthority(state.paths.databaseFile, state.paths.profileId) + try { + other.readSerializedState() + other.writeSerializedState(Buffer.from('{"settings":{"theme":"system"}}')) + } finally { + other.close() + } + } + if (mode === 'sync') { + const write = durableFiles.writeFileDurableSync + vi.spyOn(durableFiles, 'writeFileDurableSync').mockImplementation((...args) => { + write(...args) + if (args[1] === state.paths.dataFile) { + compete() + } + }) + } else { + const write = durableFiles.writeFileDurable + vi.spyOn(durableFiles, 'writeFileDurable').mockImplementationOnce(async (...args) => { + await write(...args) + compete() + }) + } + + await expect(state.publish(mode)).rejects.toMatchObject({ + code: 'profile-state-revision-conflict' + }) + + expect(JSON.parse(readFileSync(state.paths.dataFile, 'utf8')).settings.theme).toBe('dark') + expect(state.reopen()).toBe('system') + expect(state.acceptance()?.pending?.acceptedRevision).toBe(2) + }) + + it('refuses an unrelated edit even while a previous export remains staged', async () => { + const state = fixture() + if (mode === 'sync') { + const write = durableFiles.writeFileDurableSync + vi.spyOn(durableFiles, 'writeFileDurableSync').mockImplementation((...args) => { + if (args[1] === state.paths.dataFile) { + throw new Error('injected publication failure') + } + write(...args) + }) + } else { + vi.spyOn(durableFiles, 'writeFileDurable').mockRejectedValueOnce( + new Error('injected publication failure') + ) + } + await expect(state.publish(mode)).rejects.toThrow('injected') + expect(state.acceptance()?.pending).toEqual({ + jsonHash: hashProfileStateJson('{"settings":{"theme":"dark"}}'), + acceptedRevision: 2 + }) + const unrelatedJson = '{"settings":{"theme":"system"},"unrelatedEdit":true}' + writeFileSync(state.paths.dataFile, unrelatedJson) + + expect(state.reopen).toThrow('without a matching acceptance marker') + await expect(state.publish(mode)).rejects.toThrow('Compatibility JSON changed before export') + expect(readFileSync(state.paths.dataFile, 'utf8')).toBe(unrelatedJson) + }) +}) + +it.each([ + null, + [], + { jsonHash: 'invalid', acceptedRevision: 2 }, + { jsonHash: 'a'.repeat(64), acceptedRevision: 0 }, + { jsonHash: 'a'.repeat(64), acceptedRevision: 1.5 }, + { jsonHash: 'a'.repeat(64), acceptedRevision: 3 } +])('refuses malformed or impossible pending acceptance %#', (pending) => { + const state = fixture() + state.withDatabase((db) => + db.prepare('UPDATE profile_state_meta SET value = ? WHERE key = ?').run( + JSON.stringify({ + jsonHash: hashProfileStateJson(state.retainedJson), + acceptedRevision: 1, + pending + }), + 'legacy_json_acceptance' + ) + ) + expect(state.reopen).toThrow() + expect(() => state.authority.writeJsonCompatibilityExport(state.paths.dataFile)).toThrow() + expect(readFileSync(state.paths.dataFile, 'utf8')).toBe(state.retainedJson) +}) diff --git a/src/main/persistence/profile-state/profile-state-large-recovery-crash.test.ts b/src/main/persistence/profile-state/profile-state-large-recovery-crash.test.ts new file mode 100644 index 00000000000..3c0096b1849 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-large-recovery-crash.test.ts @@ -0,0 +1,149 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { acquireProfileStateMaintenance } from './profile-state-access' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath +} from './profile-state-backup-path' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from './profile-state-database' +import { restoreProfileStateDatabaseBackup } from './profile-state-database-recovery' +import { writeProfileStateDatabaseSnapshotAsync } from './profile-state-database-snapshot' +import { importProfileStateJson, readProfileStateSnapshot } from './profile-state-documents' +import { profileStateJsonExportPath } from './legacy-json/profile-state-export-path' +import { buildRecoveryCrashProcess, killRecoveryAt } from './profile-state-recovery-crash-process' + +const suite = mkdtempSync(join(tmpdir(), 'orca-large-recovery-crash-')) +const roots: string[] = [] +const profileId = 'large-recovery' +const oldJson = JSON.stringify({ opaque: 'x'.repeat(8 * 1024 * 1024), revision: 'old' }) +const selectedJson = JSON.stringify({ opaque: 'y'.repeat(8 * 1024 * 1024), revision: 'selected' }) +let bundle: string +beforeAll(() => { + bundle = buildRecoveryCrashProcess(suite) +}) +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) +afterAll(() => rmSync(suite, { recursive: true, force: true })) + +async function fixture() { + const root = mkdtempSync(join(suite, 'profile-')) + roots.push(root) + const directory = join(root, 'profiles', profileId) + mkdirSync(directory, { recursive: true }) + const databasePath = join(directory, 'profile-state.db') + const dataFile = join(directory, 'orca-data.json') + const backupPath = profileStateDatabaseBackupPath( + databasePath, + createProfileStateDatabaseBackupId() + ) + const db = openProfileStateDatabase(databasePath, profileId).db + try { + importProfileStateJson(db, oldJson) + importProfileStateJson(db, oldJson) + importProfileStateJson(db, selectedJson) + await writeProfileStateDatabaseSnapshotAsync(db, backupPath) + } finally { + db.close() + } + const options = { + root, + directory, + profileId, + databasePath, + dataFile, + backupPath, + exportPath: profileStateJsonExportPath(dataFile, 3), + markerPath: join(root, 'http1-compatibility.json'), + kind: 'sqlite' as const + } + await killRecoveryAt(bundle, options, 'seed', oldJson) + expect(existsSync(`${databasePath}-wal`)).toBe(true) + const originalFamily = new Map( + ['', '-wal', '-shm'].map((suffix) => [suffix, readFileSync(databasePath + suffix)]) + ) + return { ...options, originalFamily, backupBytes: readFileSync(backupPath) } +} + +function snapshot(path: string) { + const db = openProfileStateDatabaseReadOnly(path, profileId).db + try { + const { json, revision } = readProfileStateSnapshot(db) + return { json, revision } + } finally { + db.close() + } +} + +const boundaries = [ + ...(process.platform === 'darwin' + ? [ + 'clone:1:before', + 'clone:1:after', + 'clone:2:before', + 'clone:2:after', + 'clone:3:after', + 'clone:4:after' + ] + : []), + 'primary', + 'sqlite-publish:before', + 'sqlite-publish:after' +] + +describe('large independent recovery copies under process death', () => { + it.each(boundaries)( + 'preserves exact backup and retry state after %s', + async (boundary) => { + const profile = await fixture() + const stage = boundary === 'primary' ? `removed:${profile.databasePath}` : boundary + await killRecoveryAt(bundle, profile, stage) + expect(readFileSync(profile.backupPath).equals(profile.backupBytes)).toBe(true) + if (boundary.startsWith('clone:')) { + for (const [suffix, bytes] of profile.originalFamily) { + expect(readFileSync(profile.databasePath + suffix).equals(bytes)).toBe(true) + } + } else { + const directory = readdirSync(profile.directory).find((name) => + name.startsWith('profile-state-corrupt-') + ) + if (directory === undefined) { + throw new Error('Recovery did not preserve a quarantine') + } + for (const [suffix, bytes] of profile.originalFamily) { + expect( + readFileSync(join(profile.directory, directory, `profile-state.db${suffix}`)).equals( + bytes + ) + ).toBe(true) + } + expect( + readFileSync(join(profile.directory, directory, basename(profile.backupPath))).equals( + profile.backupBytes + ) + ).toBe(true) + if (boundary === 'sqlite-publish:after') { + expect(snapshot(profile.databasePath)).toEqual({ json: selectedJson, revision: 3 }) + } else { + expect(existsSync(profile.databasePath)).toBe(false) + } + } + const maintenance = acquireProfileStateMaintenance(profile.root) + try { + restoreProfileStateDatabaseBackup({ ...profile, maintenance }) + } finally { + maintenance.release() + } + expect(snapshot(profile.databasePath)).toEqual({ json: selectedJson, revision: 3 }) + expect(readFileSync(profile.backupPath).equals(profile.backupBytes)).toBe(true) + }, + 30_000 + ) +}) diff --git a/src/main/persistence/profile-state/profile-state-live-store-factory.test.ts b/src/main/persistence/profile-state/profile-state-live-store-factory.test.ts new file mode 100644 index 00000000000..b96d09d44f3 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-live-store-factory.test.ts @@ -0,0 +1,215 @@ +import { build } from 'esbuild' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { agentHookServer } from '../../agent-hooks/server' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import type { Store } from '../loading-store/store' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { createLiveProfileStateStore } from './profile-state-live-store-factory' +import { + profileStateJsonExportPath, + profileStateJsonExportPaths +} from './legacy-json/profile-state-export-path' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +let bundleRoot: string +let workerOptions: { workerPath: string; backupWorkerPath: string } +const roots: string[] = [] +const stores: Store[] = [] + +beforeAll(async () => { + bundleRoot = mkdtempSync(join(tmpdir(), 'orca-live-writer-bundle-')) + workerOptions = { + workerPath: join(bundleRoot, 'profile-state-writer-worker-entry.js'), + backupWorkerPath: join(bundleRoot, 'profile-state-backup-worker-entry.js') + } + await build({ + entryPoints: [ + resolve('src/main/persistence/profile-state/profile-state-writer-worker-entry.ts'), + resolve('src/main/persistence/profile-state/profile-state-backup-worker-entry.ts') + ], + outdir: bundleRoot, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) +}) + +afterEach(async () => { + await Promise.all(stores.splice(0).map((store) => store.freezeWritesAsync())) + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) +afterAll(() => rmSync(bundleRoot, { recursive: true, force: true })) + +function options() { + const root = mkdtempSync(join(tmpdir(), 'orca-live-profile-')) + roots.push(root) + return { + dataFile: join(root, 'orca-data.json'), + databaseFile: join(root, 'profile-state.db'), + profileId: 'live-profile-test' + } +} + +async function open(input: ReturnType) { + const result = await createLiveProfileStateStore(input, workerOptions) + stores.push(result.store) + return result +} + +function readState(input: ReturnType) { + const reader = new ProfileStateSqliteAuthority(input.databaseFile, input.profileId) + try { + return JSON.parse(reader.readSerializedState() ?? '{}') + } finally { + reader.close() + } +} + +describe('live profile authority admission', () => { + it.each([false, true])( + 'hands unbound aliases to admitted startup only (worker refused=%s)', + async (refused) => { + const input = options() + const source = buildProfileStateCutoverFixture(join(input.dataFile, '..')) + const session = source.workspaceSession + for (const tab of Object.values(session.tabsByWorktree).flat()) { + tab.ptyId = null + } + session.terminalLayoutsByTabId['tab-local'] = { + root: { type: 'leaf', leafId: 'pane:1' }, + activeLeafId: 'pane:1', + expandedLeafId: null, + ptyIdsByLeafId: {} + } + writeFileSync(input.dataFile, JSON.stringify(source)) + const register = vi.spyOn(agentHookServer, 'registerPaneKeyAlias') + if (refused) { + await expect( + createLiveProfileStateStore(input, { + workerPath: join(input.dataFile, '..', 'missing-worker.js') + }) + ).rejects.toThrow() + expect(register).not.toHaveBeenCalled() + return + } + const { store } = await open(input) + const leafId = store.getWorkspaceSession().terminalLayoutsByTabId['tab-local'].activeLeafId + const userDataPath = join(input.dataFile, '..') + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync( + join(userDataPath, 'agent-hooks', 'last-status.json'), + JSON.stringify({ + version: 2, + entries: { + 'tab-local:1': { + paneKey: 'tab-local:1', + tabId: 'tab-local', + worktreeId: 'repo-local::/fixture/local', + connectionId: null, + receivedAt: Date.now(), + stateStartedAt: Date.now(), + payload: { state: 'working', prompt: 'legacy cached', agentType: 'claude' } + } + } + }) + ) + try { + await agentHookServer.start({ env: 'production', userDataPath }) + expect(agentHookServer.getStatusSnapshot()).toContainEqual( + expect.objectContaining({ + paneKey: `tab-local:${leafId}`, + prompt: 'legacy cached' + }) + ) + } finally { + agentHookServer.stop() + } + } + ) + + it('migrates once, loads admitted state and reopens worker-acknowledged writes', async () => { + const input = options() + writeFileSync( + input.dataFile, + JSON.stringify(buildProfileStateCutoverFixture(join(input.dataFile, '..'))) + ) + const { store, migrated, backend } = await open(input) + expect({ migrated, backend }).toEqual({ migrated: true, backend: 'sqlite' }) + store.updateSettings({ theme: 'dark' }) + await store.flushPendingOrThrowAsync() + await store.freezeWritesAsync() + const reopened = await open(input) + expect(reopened.migrated).toBe(false) + expect(reopened.store.getSettings().theme).toBe('dark') + expect(readState(input).automationRuns).toHaveLength(1) + }) + + it('never adopts a competing revision between bootstrap and worker readiness', async () => { + const input = options() + const original = ProfileStateSqliteAuthority.prototype.retireForWorker + vi.spyOn(ProfileStateSqliteAuthority.prototype, 'retireForWorker').mockImplementation(function ( + this: ProfileStateSqliteAuthority + ) { + const handoff = original.call(this) + const peer = new ProfileStateSqliteAuthority(input.databaseFile, input.profileId) + try { + peer.readSerializedState() + peer.writeSerializedDomains([{ domain: 'peer', payload: '{"retained":true}' }]) + } finally { + peer.close() + } + return handoff + }) + await expect(open(input)).rejects.toThrow('Profile state revision changed') + expect(readState(input).peer).toEqual({ retained: true }) + }) + + it('refuses startup when the worker is unavailable without selecting JSON', async () => { + const input = options() + await expect( + createLiveProfileStateStore(input, { workerPath: join(bundleRoot, 'missing.js') }) + ).rejects.toThrow('Profile state writer') + expect(() => readFileSync(input.dataFile)).toThrow() + const reopened = await open(input) + expect(reopened.backend).toBe('sqlite') + }) + + it('orders exports with full checkpoints and closes backup and writer handles on final flush', async () => { + const input = options() + const { store } = await open(input) + store.getWorkspaceSession().activeTabId = 'getter-export' + store.updateSettings({ theme: 'dark' }) + const revision = await store.writeLatestProfileStateJsonExportAsync() + expect(revision).toBeTypeOf('number') + if (revision === undefined) { + throw new Error('Expected a persisted profile revision') + } + expect( + JSON.parse(readFileSync(profileStateJsonExportPath(input.dataFile, revision), 'utf8')) + .workspaceSession.activeTabId + ).toBe('getter-export') + store.updateSettings({ theme: 'light' }) + await store.flushFinalOrThrowAsync({ exportJsonCompatibility: true }) + expect(JSON.parse(readFileSync(input.dataFile, 'utf8')).settings.theme).toBe('light') + expect(profileStateJsonExportPaths(input.dataFile).length).toBeGreaterThan(0) + expect(readState(input).settings.theme).toBe('light') + await expect(store.flushPendingOrThrowAsync()).rejects.toThrow('finalized') + const reopened = await open(input) + expect(reopened.store.getSettings().theme).toBe('light') + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-live-store-factory.ts b/src/main/persistence/profile-state/profile-state-live-store-factory.ts new file mode 100644 index 00000000000..8a5e29c46e8 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-live-store-factory.ts @@ -0,0 +1,52 @@ +import { Store } from '../loading-store/store' +import { + prepareProfileStateStore, + type ProfileStateStoreFactoryOptions, + type ProfileStateStoreFactoryResult +} from './profile-state-store-factory' +import { ProfileStateWorkerAuthority } from './profile-state-worker-authority' + +/** Publish live state only after its exact bootstrap revision has a worker owner. */ +export async function createLiveProfileStateStore( + options: ProfileStateStoreFactoryOptions, + workerOptions: { + workerPath?: string + backupWorkerPath?: string + onFailure?: (error: Error) => void + } = {} +): Promise { + const { initialState: initial, ...prepared } = prepareProfileStateStore(options) + + // Consume before retirement: the bootstrap snapshot carries the original revision fence. + const parsed = initial.takeParsedState?.() + const serializedState = initial.serializedState + const authority = new ProfileStateWorkerAuthority( + initial.authority.retireForWorker(), + workerOptions + ) + try { + await authority.ready + const initialAuthorityState = initial.takeParsedState + ? { authority, takeParsedState: () => parsed } + : { authority, serializedState } + return { + ...prepared, + store: new Store({ + dataFile: options.dataFile, + storageAuthority: options.storageAuthority, + profileStateAuthority: authority, + initialAuthorityState: { + ...initialAuthorityState, + unboundPaneAliases: initial.unboundPaneAliases + } + }) + } + } catch (error) { + try { + await authority.close() + } catch (closeError) { + console.error('[persistence] Failed to close a refused profile writer:', closeError) + } + throw error + } +} diff --git a/src/main/persistence/profile-state/profile-state-migration.ts b/src/main/persistence/profile-state/profile-state-migration.ts new file mode 100644 index 00000000000..43705c44dee --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-migration.ts @@ -0,0 +1,86 @@ +import { randomUUID } from 'node:crypto' +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { dirname } from 'node:path' +import { publishProfileStateDatabase } from './profile-state-database-publication' +import { openProfileStateDatabase } from './profile-state-database' +import { hashProfileStateJson, importProfileStateJson } from './profile-state-documents' +import { writeVersionedProfileStateExport } from './legacy-json/profile-state-versioned-export' +import { assertProfileStateCanInitialize } from './profile-state-recovery-required' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { + classifyProfileStateStorage, + profileStateDatabaseFiles +} from './profile-state-storage-classification' +import type { ProfileStateAuthorityInitialState } from '../loading-store/profile-state-authority' + +type ProfileStateMigrationOptions = { + dataFile: string + databaseFile: string + profileId: string + expectedLegacyJson: string | undefined + /** Storage-form state; inactive profiles retain sealed secrets without decrypting them. */ + serializedState: string +} + +/** Publish an imported database only after its complete source has committed durably. */ +export function migrateProfileStateToSqlite(options: ProfileStateMigrationOptions): { + authority: ProfileStateSqliteAuthority + initialState: ProfileStateAuthorityInitialState +} { + assertMigrationSourceUnchanged(options) + mkdirSync(dirname(options.databaseFile), { recursive: true }) + const temporaryDatabaseFile = `${options.databaseFile}.migration.${process.pid}.${randomUUID()}.tmp` + let published = false + try { + const opened = openProfileStateDatabase(temporaryDatabaseFile, options.profileId) + try { + importProfileStateJson( + opened.db, + options.serializedState, + options.expectedLegacyJson === undefined + ? {} + : { acceptedLegacyJsonHash: hashProfileStateJson(options.expectedLegacyJson) } + ) + } finally { + opened.db.close() + } + + // Closing checkpoints the temporary database before its canonical path becomes visible. + assertMigrationSourceUnchanged(options) + if (!publishProfileStateDatabase(temporaryDatabaseFile, options.databaseFile)) { + throw new Error('Profile state storage changed while importing legacy JSON') + } + published = true + const authority = new ProfileStateSqliteAuthority(options.databaseFile, options.profileId) + try { + writeVersionedProfileStateExport(options.dataFile, (path) => authority.writeJsonExport(path)) + const initialState = authority.readInitialState() + return { authority, initialState } + } catch (error) { + authority.close() + throw error + } + } finally { + if (!published) { + for (const path of profileStateDatabaseFiles(temporaryDatabaseFile)) { + rmSync(path, { force: true }) + } + } + } +} + +function assertMigrationSourceUnchanged(options: ProfileStateMigrationOptions): void { + assertProfileStateCanInitialize(options) + const expectedClassification = options.expectedLegacyJson === undefined ? 'neither' : 'json-only' + if ( + classifyProfileStateStorage(options.dataFile, options.databaseFile) !== expectedClassification + ) { + throw new Error('Profile state storage changed while importing legacy JSON') + } + const currentJson = existsSync(options.dataFile) + ? readFileSync(options.dataFile, 'utf8') + : undefined + if (currentJson !== options.expectedLegacyJson) { + throw new Error('Profile state JSON changed while importing legacy JSON') + } +} diff --git a/src/main/persistence/profile-state/profile-state-offline-settings.test.ts b/src/main/persistence/profile-state/profile-state-offline-settings.test.ts new file mode 100644 index 00000000000..56f5cf0177c --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-offline-settings.test.ts @@ -0,0 +1,135 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + readAgentHookSettingsFromProfileState, + updateAgentHookSettingsFromProfileState +} from './profile-state-offline-settings' +import * as exportPaths from './legacy-json/profile-state-export-path' +import { ProfileStateRecoveryRequiredError } from './profile-state-recovery-required' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' + +const directories: string[] = [] +afterEach(() => { + vi.restoreAllMocks() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function createLocation() { + const directory = mkdtempSync(join(tmpdir(), 'orca-offline-settings-')) + directories.push(directory) + return { + dataFile: join(directory, 'orca-data.json'), + databaseFile: join(directory, 'orca-state.db'), + profileId: 'profile-offline' + } +} + +describe.each(['json', 'sqlite'] as const)('offline settings %s updates', (backend) => { + it('filters malformed hook settings for callers while preserving their stored values', () => { + const location = createLocation() + const original = { + settings: { + agentStatusHooksEnabled: true, + agentCmdOverrides: { codex: 42, claude: 'claude --model opus', gemini: null }, + disabledTuiAgents: ['codex', false, 'future-agent', 'codex'], + futureSetting: { preserved: true } + }, + futureDomain: { preserved: ['雪', null] } + } + const authority = new ProfileStateSqliteAuthority(location.databaseFile, location.profileId) + try { + if (backend === 'sqlite') { + authority.writeSerializedState(Buffer.from(JSON.stringify(original))) + authority.close() + } else { + writeFileSync(location.dataFile, JSON.stringify(original)) + } + + expect(updateAgentHookSettingsFromProfileState(location, false)).toEqual({ + settingsPath: location.databaseFile, + settings: { + agentCmdOverrides: { claude: 'claude --model opus' }, + disabledTuiAgents: ['codex', 'future-agent', 'codex'] + } + }) + const persisted = authority.readSerializedState() + if (backend === 'json') { + expect(readFileSync(location.dataFile, 'utf8')).toBe(JSON.stringify(original)) + } + expect(JSON.parse(persisted ?? 'null')).toMatchObject({ + ...original, + settings: { ...original.settings, agentStatusHooksEnabled: false } + }) + } finally { + authority.close() + } + }) +}) + +describe.each(['read', 'update'] as const)('offline settings %s recovery', (operation) => { + function run(location: ReturnType) { + return operation === 'read' + ? readAgentHookSettingsFromProfileState(location) + : updateAgentHookSettingsFromProfileState(location, false) + } + + it.each([true, false])('rejects retained exports with JSON present=%s', (hasJson) => { + const location = createLocation() + const source = JSON.stringify({ settings: { agentStatusHooksEnabled: true } }) + if (hasJson) { + writeFileSync(location.dataFile, source) + } + const exportFile = exportPaths.profileStateJsonExportPath(location.dataFile, 1) + writeFileSync(exportFile, source) + // Recovery detection must also work in the Node 18 fallback without SQLite. + vi.spyOn(process, 'getBuiltinModule').mockReturnValue(undefined) + + expect(() => run(location)).toThrowError(ProfileStateRecoveryRequiredError) + expect(existsSync(location.databaseFile)).toBe(false) + expect(existsSync(location.dataFile)).toBe(hasJson) + expect(readFileSync(exportFile, 'utf8')).toBe(source) + if (hasJson) { + expect(readFileSync(location.dataFile, 'utf8')).toBe(source) + } + }) + + it.each([0, 1, 2, 3, 4])('refuses defaults when only legacy backup %s remains', (slot) => { + const location = createLocation() + const backup = `${location.dataFile}.bak.${slot}` + const source = '{"settings":{"agentStatusHooksEnabled":false}}' + writeFileSync(backup, source) + + expect(() => run(location)).toThrow('restore a selected backup') + expect(existsSync(location.dataFile)).toBe(false) + expect(existsSync(location.databaseFile)).toBe(false) + expect(readFileSync(backup, 'utf8')).toBe(source) + }) + + it('fails closed when retained exports cannot be enumerated', () => { + const location = createLocation() + vi.spyOn(exportPaths, 'profileStateJsonExportPaths').mockImplementation(() => { + throw new Error('permission denied') + }) + + expect(() => run(location)).toThrowError(ProfileStateRecoveryRequiredError) + expect(existsSync(location.dataFile)).toBe(false) + expect(existsSync(location.databaseFile)).toBe(false) + }) + + it.each(['-wal', '-shm', '-journal'])('rejects stale JSON when only %s remains', (suffix) => { + const location = createLocation() + const source = JSON.stringify({ settings: { agentStatusHooksEnabled: true } }) + writeFileSync(location.dataFile, source) + const sidecar = `${location.databaseFile}${suffix}` + writeFileSync(sidecar, 'orphaned recovery evidence') + + expect(() => run(location)).toThrow() + expect(readFileSync(location.dataFile, 'utf8')).toBe(source) + expect(existsSync(location.databaseFile)).toBe(false) + expect(readFileSync(sidecar, 'utf8')).toBe('orphaned recovery evidence') + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-offline-settings.ts b/src/main/persistence/profile-state/profile-state-offline-settings.ts new file mode 100644 index 00000000000..1c7c85c3980 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-offline-settings.ts @@ -0,0 +1,190 @@ +import { existsSync, readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import { getDefaultPersistedState } from '../../../shared/constants' +import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' +import { profileStateJsonMatchesAcceptance } from './profile-state-documents' +import { isRecord, parseProfileStateRoot } from './profile-state-document-validation' +import { + isProfileStateSqliteAvailable, + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from './profile-state-database' +import { + readProfileStateDomains, + readProfileStateDomainsWithRevisionFromDatabase +} from './profile-state-domain-reader' +import { writeProfileStateDomain } from './profile-state-domain-writes' +import { assertProfileStateCanInitialize } from './profile-state-recovery-required' +import { classifyProfileStateStorage } from './profile-state-storage-classification' +import { migrateProfileStateToSqlite } from './profile-state-migration' + +export type ProfileStateOfflineLocation = { + dataFile: string + databaseFile: string + profileId: string +} + +export type AgentHookSettings = Pick< + GlobalSettings, + 'agentStatusHooksEnabled' | 'disabledTuiAgents' +> + +export type AgentHookSettingsUpdate = { + settings: Pick + settingsPath: string +} + +/** Read the settings domain without creating SQLite for a JSON-only profile. */ +export function readAgentHookSettingsFromProfileState( + location: ProfileStateOfflineLocation +): AgentHookSettings { + const classification = classifyProfileStateStorage(location.dataFile, location.databaseFile) + if (classification === 'json-only' || classification === 'neither') { + assertProfileStateCanInitialize(location) + return readAgentHookSettingsFromJson(location.dataFile) + } + + assertSqliteCapability() + assertAcceptedLegacyJson(location, classification) + const result = readProfileStateDomains(location.databaseFile, location.profileId, ['settings']) + if (result.kind === 'unreadable') { + throw result.error + } + return readAgentHookSettingsFromSettingsValue(result.values.get('settings')) +} + +/** + * Update only the settings row in an existing SQLite authority. + * + * The snapshot revision is checked again by BEGIN IMMEDIATE, so a runtime + * writer between read and update produces a conflict instead of clobbering it. + */ +export function updateAgentHookSettingsInProfileState( + location: ProfileStateOfflineLocation, + enabled: boolean +): AgentHookSettingsUpdate { + const classification = classifyProfileStateStorage(location.dataFile, location.databaseFile) + if (classification === 'json-only' || classification === 'neither') { + throw new Error('SQLite profile state is not established for this profile') + } + + assertSqliteCapability() + assertAcceptedLegacyJson(location, classification) + const opened = openProfileStateDatabase(location.databaseFile, location.profileId) + try { + const domains = readProfileStateDomainsWithRevisionFromDatabase(opened.db, ['settings']) + if (domains.kind === 'unreadable') { + throw domains.error + } + const persistedSettings = domains.values.get('settings') + const settings = { + ...getDefaultPersistedState(homedir()).settings, + ...(isRecord(persistedSettings) ? persistedSettings : {}), + agentStatusHooksEnabled: enabled + } + writeProfileStateDomain(opened.db, { + domain: 'settings', + payload: JSON.stringify(settings), + expectedRevision: domains.revision + }) + return { + settingsPath: location.databaseFile, + settings: projectAgentHookSettings(settings) + } + } finally { + opened.db.close() + } +} + +/** The caller holds maintenance ownership through import and the fenced settings write. */ +export function updateAgentHookSettingsFromProfileState( + location: ProfileStateOfflineLocation, + enabled: boolean +): AgentHookSettingsUpdate { + const classification = classifyProfileStateStorage(location.dataFile, location.databaseFile) + if (classification === 'json-only' || classification === 'neither') { + assertProfileStateCanInitialize(location) + assertOfflineProfileStateMutationRuntime() + const rawJson = existsSync(location.dataFile) + ? readFileSync(location.dataFile, 'utf8') + : undefined + const migrated = migrateProfileStateToSqlite({ + ...location, + expectedLegacyJson: rawJson, + serializedState: rawJson ?? JSON.stringify(getDefaultPersistedState(homedir())) + }) + migrated.authority.close() + } + return updateAgentHookSettingsInProfileState(location, enabled) +} + +export function assertOfflineProfileStateMutationRuntime(): void { + if (!isProfileStateSqliteAvailable()) { + throw new Error( + 'Changing agent hooks offline requires the bundled Orca CLI. Run that launcher, or start Orca and retry this command.' + ) + } +} + +function projectAgentHookSettings( + settings: AgentHookSettingsUpdate['settings'] +): AgentHookSettingsUpdate['settings'] { + return { + agentCmdOverrides: isRecord(settings.agentCmdOverrides) + ? Object.fromEntries( + Object.entries(settings.agentCmdOverrides).filter( + ([, value]) => typeof value === 'string' + ) + ) + : {}, + disabledTuiAgents: Array.isArray(settings.disabledTuiAgents) + ? settings.disabledTuiAgents.filter((value) => typeof value === 'string') + : [] + } +} + +function assertSqliteCapability(): void { + if (!isProfileStateSqliteAvailable()) { + throw new Error('SQLite profile state is present but this runtime cannot validate it') + } +} + +function assertAcceptedLegacyJson( + location: ProfileStateOfflineLocation, + classification: 'sqlite-only' | 'both' +): void { + if (classification === 'sqlite-only') { + if (!existsSync(location.databaseFile)) { + throw new Error('SQLite profile state has an orphaned database sidecar') + } + return + } + + const rawJson = readFileSync(location.dataFile, 'utf8') + const opened = openProfileStateDatabaseReadOnly(location.databaseFile, location.profileId) + try { + if (!profileStateJsonMatchesAcceptance(opened.db, rawJson)) { + throw new Error( + 'Profile state has both JSON and SQLite storage without a matching acceptance marker' + ) + } + } finally { + opened.db.close() + } +} + +function readAgentHookSettingsFromJson(dataFile: string): AgentHookSettings { + const settings = existsSync(dataFile) + ? parseProfileStateRoot(readFileSync(dataFile, 'utf8')).settings + : getDefaultPersistedState(homedir()).settings + return readAgentHookSettingsFromSettingsValue(settings) +} + +function readAgentHookSettingsFromSettingsValue(value: unknown): AgentHookSettings { + const settings = isRecord(value) ? value : {} + return { + agentStatusHooksEnabled: settings.agentStatusHooksEnabled !== false, + disabledTuiAgents: normalizeDisabledTuiAgents(settings.disabledTuiAgents) + } +} diff --git a/src/main/persistence/profile-state/profile-state-parsed-snapshot.test.ts b/src/main/persistence/profile-state/profile-state-parsed-snapshot.test.ts new file mode 100644 index 00000000000..428622f8c6d --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-parsed-snapshot.test.ts @@ -0,0 +1,158 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { openProfileStateDatabase } from './profile-state-database' +import { + hashProfileStateJson, + importProfileStateJson, + readAcceptedProfileStateParsedSnapshot, + readProfileStateDocuments, + readProfileStateParsedSnapshot, + readProfileStateSnapshot, + validateProfileStateSnapshot +} from './profile-state-documents' + +const directories: string[] = [] +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-parsed-profile-')) + directories.push(directory) + return openProfileStateDatabase(join(directory, 'profile-state.db'), 'parsed-profile').db +} + +describe('checked profile state values', () => { + it.each([ + [ + 'row hash', + "UPDATE profile_state_automation_runs SET payload = '{}' WHERE ordinal = 0", + 'row is corrupt' + ], + [ + 'ordering', + 'UPDATE profile_state_automation_runs SET ordinal = 3 WHERE ordinal = 0', + 'ordering is corrupt' + ], + [ + 'revision', + 'UPDATE profile_state_automation_runs SET revision = 99 WHERE ordinal = 0', + 'metadata is inconsistent' + ], + [ + 'timestamp', + 'UPDATE profile_state_automation_runs SET updated_at = updated_at + 1 WHERE ordinal = 0', + 'metadata is inconsistent' + ] + ])('rejects corrupt normalized %s in both representations', (_, sql, message) => { + const db = fixture() + try { + importProfileStateJson(db, '{"automationRuns":[{"id":"a"},{"id":"b"}]}') + db.exec(sql) + expect(() => readProfileStateParsedSnapshot(db)).toThrow(message) + expect(() => readProfileStateSnapshot(db)).toThrow(message) + expect(() => validateProfileStateSnapshot(db)).toThrow(message) + } finally { + db.close() + } + }) + + it('rejects a normalized identity mismatch even when the payload hash is valid', () => { + const db = fixture() + try { + importProfileStateJson(db, '{"automationRuns":[{"id":"a"}]}') + const payload = '{"id":"other"}' + db.prepare('UPDATE profile_state_automation_runs SET payload = ?, content_hash = ?').run( + payload, + hashProfileStateJson(payload) + ) + expect(() => readProfileStateParsedSnapshot(db)).toThrow('identity is corrupt') + expect(() => readProfileStateSnapshot(db)).toThrow('identity is corrupt') + expect(() => validateProfileStateSnapshot(db)).toThrow('identity is corrupt') + } finally { + db.close() + } + }) + + it.each([ + undefined, + null, + [], + [ + { id: 'second', unknown: '雪 🐋\ud800' }, + { id: 'first', unknown: null } + ], + { futureHistoryFormat: true }, + [{ id: 'duplicate' }, { id: 'duplicate' }] + ])('matches serialized semantics for history %j', (automationRuns) => { + const db = fixture() + try { + const source = JSON.stringify( + Object.fromEntries([ + ['z', { sealed: 'safeStorage:unchanged' }], + ['__proto__', { own: true }], + ['10', 'ten'], + ['2', 'two'], + ['constructor', 'own constructor'], + ['automationRuns', automationRuns], + ['a', null] + ]) + ) + importProfileStateJson(db, source, { acceptedLegacyJsonHash: hashProfileStateJson(source) }) + const serialized = readProfileStateSnapshot(db) + const expected: unknown = JSON.parse(serialized.json) + const parsed = readProfileStateParsedSnapshot(db) + expect(parsed).toStrictEqual({ revision: serialized.revision, state: expected }) + expect(Object.keys(parsed.state)).toEqual(Object.keys(JSON.parse(source))) + expect(Object.hasOwn(parsed.state, '__proto__')).toBe(true) + expect(Object.getPrototypeOf(parsed.state)).toBe(Object.prototype) + expect(readAcceptedProfileStateParsedSnapshot(db, source)).toStrictEqual(parsed) + expect(readAcceptedProfileStateParsedSnapshot(db, '{}')).toBeUndefined() + expect( + readProfileStateDocuments(db).every((document) => !Object.hasOwn(document, 'value')) + ).toBe(true) + expect(readProfileStateSnapshot(db).json).toBe(serialized.json) + } finally { + db.close() + } + }) + + it('validates original bytes while reusing noncanonical JSON values', () => { + const db = fixture() + try { + importProfileStateJson(db, '{"future":null,"automationRuns":[{"id":"a"},{"id":"b"}]}') + const future = + ' {"negativeZero":-0,"large":1e400,"duplicate":1,"duplicate":2,"text":"雪\\ud800"} ' + db.prepare( + 'UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = ?' + ).run(future, hashProfileStateJson(future), 'future') + const payloads = [' {"id":"a","number":-0,"large":1e400} ', '{"id":"b","text":"雪\\ud800"}'] + for (const [ordinal, payload] of payloads.entries()) { + db.prepare( + 'UPDATE profile_state_automation_runs SET payload = ?, content_hash = ? WHERE ordinal = ?' + ).run(payload, hashProfileStateJson(payload), ordinal) + } + const aggregate = `[${payloads.join(',')}]` + db.prepare('UPDATE profile_state_automation_runs_meta SET content_hash = ?').run( + hashProfileStateJson(aggregate) + ) + const serialized = readProfileStateSnapshot(db) + expect(serialized.json).toBe(`{"future":${future},"automationRuns":${aggregate}}`) + expect(readProfileStateParsedSnapshot(db)).toStrictEqual({ + revision: serialized.revision, + state: JSON.parse(serialized.json) + }) + db.prepare('UPDATE profile_state_automation_runs_meta SET content_hash = ?').run( + hashProfileStateJson(JSON.stringify(JSON.parse(aggregate))) + ) + expect(() => readProfileStateParsedSnapshot(db)).toThrow('aggregate hash mismatch') + expect(() => readProfileStateSnapshot(db)).toThrow('aggregate hash mismatch') + } finally { + db.close() + } + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-read-concurrency.test.ts b/src/main/persistence/profile-state/profile-state-read-concurrency.test.ts new file mode 100644 index 00000000000..0f6307e47d3 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-read-concurrency.test.ts @@ -0,0 +1,174 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from './profile-state-database' +import { verifyProfileStateSchema } from './profile-state-database-validation' +import { + importProfileStateJson, + readProfileStateSnapshot, + readProfileStateParsedSnapshot, + validateProfileStateSnapshot +} from './profile-state-documents' +import { readProfileStateDomainsWithRevisionFromDatabase } from './profile-state-domain-reader' +import { writeProfileStateDomains } from './profile-state-domain-writes' +import * as revisions from './profile-state-revision' + +const directories: string[] = [] + +afterEach(() => { + vi.restoreAllMocks() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-read-concurrency-')) + directories.push(directory) + const path = join(directory, 'profile-state.db') + const { db } = openProfileStateDatabase(path, 'profile-a') + importProfileStateJson(db, '{"automationRuns":[{"id":"run-1","status":"pending"}]}') + return { path, db } +} + +describe('profile state reads during concurrent commits', () => { + it.each([readProfileStateSnapshot, readProfileStateParsedSnapshot, validateProfileStateSnapshot])( + 'keeps revision and documents together when a writer commits during %s', + (read) => { + const { path, db: writer } = fixture() + const { db: reader } = openProfileStateDatabaseReadOnly(path, 'profile-a') + const readRevision = revisions.readProfileStateRevision + let committed = false + vi.spyOn(revisions, 'readProfileStateRevision').mockImplementation((db) => { + const revision = readRevision(db) + if (db === reader && !committed) { + committed = true + writeProfileStateDomains(writer, { + expectedRevision: 1, + replacements: [{ domain: 'automationRuns', payload: '[]' }] + }) + } + return revision + }) + try { + const snapshot = read(reader) + expect(committed).toBe(true) + if (typeof snapshot === 'number') { + expect(snapshot).toBe(1) + } else { + expect(snapshot).toMatchObject({ revision: 1 }) + expect(snapshot).toMatchObject( + read === readProfileStateSnapshot + ? { json: '{"automationRuns":[{"id":"run-1","status":"pending"}]}' } + : { state: { automationRuns: [{ id: 'run-1', status: 'pending' }] } } + ) + } + expect(reader.isTransaction).toBe(false) + const next = read(reader) + expect(typeof next === 'number' ? next : next.revision).toBe(2) + } finally { + reader.close() + writer.close() + } + } + ) + + it.each([ + ['read-only', openProfileStateDatabaseReadOnly], + ['writable', openProfileStateDatabase] + ] as const)( + 'opens a healthy %s database when automation state changes between validation reads', + (_, open) => { + const { path, db: writer } = fixture() + const readRevision = revisions.readProfileStateRevision + let committed = false + vi.spyOn(revisions, 'readProfileStateRevision').mockImplementation((reader) => { + const revision = readRevision(reader) + if (reader !== writer && !committed) { + committed = true + writeProfileStateDomains(writer, { + expectedRevision: 1, + replacements: [{ domain: 'automationRuns', payload: '[]' }] + }) + } + return revision + }) + try { + const opened = open(path, 'profile-a') + try { + expect(committed).toBe(true) + expect(opened.db.isTransaction).toBe(false) + expect(readProfileStateParsedSnapshot(opened.db)).toEqual({ + revision: 2, + state: { automationRuns: [] } + }) + expect(readProfileStateSnapshot(opened.db)).toMatchObject({ + revision: 2, + json: '{"automationRuns":[]}' + }) + } finally { + opened.db.close() + } + } finally { + writer.close() + } + } + ) + + it('keeps caller-owned writes uncommitted through schema, full and selected reads', () => { + const { db } = fixture() + try { + db.exec('BEGIN IMMEDIATE') + db.prepare('INSERT INTO profile_state_meta (key, value) VALUES (?, ?)').run('probe', 'value') + verifyProfileStateSchema(db, 'profile-a') + expect(readProfileStateSnapshot(db).revision).toBe(1) + expect(readProfileStateParsedSnapshot(db).revision).toBe(1) + expect(readProfileStateDomainsWithRevisionFromDatabase(db, ['automationRuns'])).toEqual({ + kind: 'values', + revision: 1, + values: new Map([['automationRuns', [{ id: 'run-1', status: 'pending' }]]]) + }) + expect(db.isTransaction).toBe(true) + db.exec('ROLLBACK') + expect( + db.prepare('SELECT value FROM profile_state_meta WHERE key = ?').get('probe') + ).toBeUndefined() + } finally { + db.close() + } + }) + + it.each(['owned', 'caller'] as const)( + 'preserves corrupt state and releases only %s read transactions', + (ownership) => { + const { db } = fixture() + try { + if (ownership === 'caller') { + db.exec('BEGIN IMMEDIATE') + } + db.exec('DELETE FROM profile_state_automation_runs_meta') + expect(() => verifyProfileStateSchema(db, 'profile-a')).toThrow('metadata is malformed') + expect(() => readProfileStateSnapshot(db)).toThrow('metadata is malformed') + expect(() => readProfileStateParsedSnapshot(db)).toThrow('metadata is malformed') + expect(readProfileStateDomainsWithRevisionFromDatabase(db, ['automationRuns']).kind).toBe( + 'unreadable' + ) + expect(db.isTransaction).toBe(ownership === 'caller') + expect(db.prepare('SELECT domain FROM profile_state_automation_runs_meta').all()).toEqual( + [] + ) + if (ownership === 'caller') { + db.exec('ROLLBACK') + expect(readProfileStateSnapshot(db).revision).toBe(1) + expect(readProfileStateParsedSnapshot(db).revision).toBe(1) + } + } finally { + db.close() + } + } + ) +}) diff --git a/src/main/persistence/profile-state/profile-state-read-snapshot.ts b/src/main/persistence/profile-state/profile-state-read-snapshot.ts new file mode 100644 index 00000000000..415c1721071 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-read-snapshot.ts @@ -0,0 +1,37 @@ +import type Database from '../../sqlite/sync-database' + +export class ProfileStateReadRollbackError extends Error { + readonly code = 'profile-state-read-rollback-failed' as const + + constructor( + cause: unknown, + readonly rollbackError: unknown + ) { + super('Profile state read failed without releasing its transaction', { cause }) + this.name = 'ProfileStateReadRollbackError' + } +} + +/** Reuse a caller's transaction without committing or rolling it back. */ +export function withProfileStateReadSnapshot(db: Database.Database, read: () => T): T { + const ownsTransaction = !db.isTransaction + if (ownsTransaction) { + db.exec('BEGIN') + } + try { + const result = read() + if (ownsTransaction) { + db.exec('COMMIT') + } + return result + } catch (error) { + if (ownsTransaction && db.isTransaction) { + try { + db.exec('ROLLBACK') + } catch (rollbackError) { + throw new ProfileStateReadRollbackError(error, rollbackError) + } + } + throw error + } +} diff --git a/src/main/persistence/profile-state/profile-state-recovery-batch.test.ts b/src/main/persistence/profile-state/profile-state-recovery-batch.test.ts new file mode 100644 index 00000000000..82e4ccf708e --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-recovery-batch.test.ts @@ -0,0 +1,227 @@ +import { + closeSync, + copyFileSync, + existsSync, + ftruncateSync, + mkdirSync, + mkdtempSync, + openSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, + writeSync +} from 'node:fs' +import type * as FileSystem from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, isAbsolute, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as processes from '../../../shared/child-process/run-process' +import { copyProfileStateRecoveryFiles } from './profile-state-recovery-copy' +import { quarantineProfileStateDatabase } from './profile-state-database-quarantine' + +const publication = vi.hoisted(() => ({ unsupportedTarget: '' })) +vi.mock('node:fs', async (original) => { + const actual = await original() + return { + ...actual, + linkSync: (...args: Parameters) => { + if (args[1] === publication.unsupportedTarget) { + throw Object.assign(new Error('hardlinks unavailable'), { code: 'ENOTSUP' }) + } + return actual.linkSync(...args) + } + } +}) + +const directories: string[] = [] +afterEach(() => { + publication.unsupportedTarget = '' + vi.restoreAllMocks() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture(names = ['primary.db', 'backup.db'], large = true) { + const directory = mkdtempSync(join(tmpdir(), 'orca-recovery-batch-')) + directories.push(directory) + const files = names.map((name, index) => { + const sourceDirectory = join(directory, `source-${index}`) + mkdirSync(sourceDirectory) + const source = join(sourceDirectory, name) + const descriptor = openSync(source, 'wx', 0o600) + try { + ftruncateSync(descriptor, large ? 8 * 1024 * 1024 + 1 : 128) + writeSync(descriptor, Buffer.from(`retained-${index}`)) + } finally { + closeSync(descriptor) + } + return { source, target: join(directory, `restored-${index}.db`) } + }) + return { directory, files } +} + +function expectIndependent(files: ReturnType['files']): void { + for (const { source, target } of files) { + const original = readFileSync(source) + expect(readFileSync(target).equals(original)).toBe(true) + expect(statSync(source, { bigint: true }).ino).not.toBe(statSync(target, { bigint: true }).ino) + writeFileSync(source, 'changed source') + expect(readFileSync(target).equals(original)).toBe(true) + } +} + +function expectNoTemporary(directory: string): void { + expect(readdirSync(directory).some((name) => name.startsWith('.orca-recovery-clone-'))).toBe( + false + ) +} + +describe('batched profile recovery copies', () => { + it.each(['manifest.json', 'MANIFEST.JSON'])( + 'preserves a recovery artifact named %s without overwriting it with a manifest', + (name) => { + const { directory, files } = fixture(['primary.db', name], false) + const artifact = files[1].source + const original = readFileSync(artifact) + const quarantine = () => + quarantineProfileStateDatabase(files[0].source, 'profile', directory, 'test', [artifact]) + if (existsSync(join(dirname(artifact), 'manifest.json'))) { + expect(quarantine).toThrow('conflicts with the quarantine manifest') + expect( + readdirSync(directory).some((entry) => entry.startsWith('profile-state-corrupt-')) + ).toBe(false) + } else { + const result = quarantine() + expect(readFileSync(join(result.directory, name)).equals(original)).toBe(true) + } + expect(readFileSync(artifact).equals(original)).toBe(true) + expect(existsSync(files[0].source)).toBe(true) + } + ) + + it.each([false, true])('preserves every independent file with large copies=%s', (large) => { + const { directory, files } = fixture(undefined, large) + copyProfileStateRecoveryFiles(files) + expectIndependent(files) + expectNoTemporary(directory) + }) + + it('does nothing for an empty batch', () => { + const run = vi.spyOn(processes, 'runProcessSync') + copyProfileStateRecoveryFiles([]) + expect(run).not.toHaveBeenCalled() + }) + + describe.skipIf(process.platform !== 'darwin')('Darwin batch boundaries', () => { + it('shares one process with absolute arguments and the per-file timeout budget', () => { + const { directory, files } = fixture() + const run = vi.spyOn(processes, 'runProcessSync') + copyProfileStateRecoveryFiles(files) + expect(run).toHaveBeenCalledOnce() + const spec = run.mock.calls[0]?.[0] + expect(spec?.args?.slice(1).every(isAbsolute)).toBe(true) + expect(spec?.args).toHaveLength(4) + expect(spec?.timeoutMs).toBe(60_000) + expectIndependent(files) + expectNoTemporary(directory) + }) + + it('bounds a shared process when many large artifacts are retained', () => { + const { directory, files } = fixture( + Array.from({ length: 17 }, (_, index) => `backup-${index}.db`) + ) + const run = vi.spyOn(processes, 'runProcessSync') + copyProfileStateRecoveryFiles(files) + expect(run).toHaveBeenCalledTimes(2) + expect(run.mock.calls.every(([spec]) => (spec.args?.length ?? 0) <= 18)).toBe(true) + expectIndependent(files) + expectNoTemporary(directory) + }) + + it.each([ + ['same.db', 'same.db'], + ['CASE.db', 'case.db'], + ['é.db', 'e\u0301.db'], + ['suffix.db.', 'suffix.db'], + ['- leading space.db', 'plain.db'] + ])('isolates ambiguous source names %s and %s', (...names) => { + const { directory, files } = fixture(names) + const run = vi.spyOn(processes, 'runProcessSync') + copyProfileStateRecoveryFiles(files) + expect(run).toHaveBeenCalledTimes(2) + expectIndependent(files) + expectNoTemporary(directory) + }) + + it.each(['timeout', 'signal', 'spawn'] as const)( + 'publishes no cloned files after a batch %s failure', + (failure) => { + const { directory, files } = fixture() + vi.spyOn(processes, 'runProcessSync').mockImplementation(() => { + if (failure === 'spawn') { + throw new Error('copy process could not start') + } + return { + code: null, + signal: 'SIGTERM', + timedOut: failure === 'timeout', + stdout: '', + stderr: '' + } + }) + expect(() => copyProfileStateRecoveryFiles(files)).toThrow() + for (const { source, target } of files) { + expect(existsSync(source)).toBe(true) + expect(existsSync(target)).toBe(false) + } + expectNoTemporary(directory) + } + ) + + it('discards partial process output before falling back for every source', () => { + const { directory, files } = fixture() + vi.spyOn(processes, 'runProcessSync').mockImplementation((spec) => { + const temporaryDirectory = spec.args?.at(-1) + if (!temporaryDirectory) { + throw new Error('Missing clone directory') + } + expect(statSync(temporaryDirectory).mode & 0o777).toBe(0o700) + writeFileSync(join(temporaryDirectory, basename(files[0].source)), 'incomplete') + return { code: 1, signal: null, timedOut: false, stdout: '', stderr: 'clone unavailable' } + }) + copyProfileStateRecoveryFiles(files) + expectIndependent(files) + expectNoTemporary(directory) + }) + + it('preserves a destination created during the batch process', () => { + const { directory, files } = fixture() + vi.spyOn(processes, 'runProcessSync').mockImplementation((spec) => { + const temporaryDirectory = spec.args?.at(-1) + if (!temporaryDirectory) { + throw new Error('Missing clone directory') + } + for (const { source } of files) { + copyFileSync(source, join(temporaryDirectory, basename(source))) + } + writeFileSync(files[1].target, 'concurrent destination') + return { code: 0, signal: null, timedOut: false, stdout: '', stderr: '' } + }) + expect(() => copyProfileStateRecoveryFiles(files)).toThrow() + expect(readFileSync(files[1].target, 'utf8')).toBe('concurrent destination') + expect(readFileSync(files[0].target).equals(readFileSync(files[0].source))).toBe(true) + expectNoTemporary(directory) + }) + + it('falls back independently when one destination cannot publish hardlinks', () => { + const { directory, files } = fixture() + publication.unsupportedTarget = files[1].target + copyProfileStateRecoveryFiles(files) + expectIndependent(files) + expectNoTemporary(directory) + }) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-recovery-command.ts b/src/main/persistence/profile-state/profile-state-recovery-command.ts new file mode 100644 index 00000000000..750df221f1c --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-recovery-command.ts @@ -0,0 +1,206 @@ +import { existsSync, readFileSync, rmSync } from 'node:fs' +import { + ProfileStateRecoveryCommandError, + type ProfileStateRecoverySelector, + type ProfileStateExportsResult, + type ProfileStateRollbackResult +} from '../../../shared/profile-state-recovery-command' +import { getActiveProfileStateLocation } from './profile-state-active-location' +import { + profileStateJsonExportPath, + profileStateJsonExportPaths +} from './legacy-json/profile-state-export-path' +import { profileStateDatabaseBackups } from './profile-state-backup-path' +import { restoreProfileStateJsonExport } from './legacy-json/profile-state-recovery' +import { restoreProfileStateDatabaseBackup } from './profile-state-database-recovery' +import { quarantineProfileStateDatabase } from './profile-state-database-quarantine' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { durableWriteTempPath, writeFileDurableSync } from '../../durable-file-write' +import type { ProfileStateMaintenance } from './profile-state-access' +import { readProfileStateDomain } from './profile-state-domain-reader' +import { isRecord } from './profile-state-document-validation' +import { profileHasPendingProjectMove } from '../../orca-profiles/profile-project-move-record' +import { + invalidateHttp1CompatibilityMarker, + writeHttp1CompatibilityMarker +} from '../../startup/http1-compatibility-marker' + +export function getProfileStateExports(userDataPath: string): ProfileStateExportsResult { + const location = getActiveProfileStateLocation(userDataPath) + if (location === undefined) { + throw new ProfileStateRecoveryCommandError( + 'runtime_error', + 'No active profile is available for recovery.' + ) + } + return { + profileId: location.profileId, + dataFile: location.dataFile, + databaseFile: location.databaseFile, + exportPaths: profileStateJsonExportPaths(location.dataFile), + backups: profileStateDatabaseBackups(location.databaseFile) + } +} + +export function rollbackProfileState( + userDataPath: string, + selector: ProfileStateRecoverySelector, + maintenance: ProfileStateMaintenance +): ProfileStateRollbackResult { + const result = getProfileStateExports(userDataPath) + if (profileHasPendingProjectMove(result.profileId, userDataPath)) { + throw new ProfileStateRecoveryCommandError( + 'runtime_error', + 'This profile has a pending project move. Resolve the move with both profiles preserved before restoring a single profile.' + ) + } + if (selector.kind === 'sqlite') { + return restoreDatabaseBackup(userDataPath, result, selector.backupId, maintenance) + } + if (selector.kind === 'current-sqlite') { + return adoptCurrentDatabase(userDataPath, result, maintenance) + } + const revision = selector.kind === 'json' ? selector.revision : null + const exportPath = + revision === null ? result.dataFile : profileStateJsonExportPath(result.dataFile, revision) + if (revision !== null && !result.exportPaths.includes(exportPath)) { + throw new ProfileStateRecoveryCommandError( + 'invalid_argument', + `Profile-state export revision ${revision} is unavailable. Use profile state exports to inspect retained revisions.` + ) + } + const recovered = restoreProfileStateJsonExport({ + maintenance, + databasePath: result.databaseFile, + dataFile: result.dataFile, + exportPath, + profileId: result.profileId, + ...(revision === null ? { reason: 'profile-state-adopt-current-json' } : {}), + beforeRestore: () => invalidateHttp1CompatibilityMarker(userDataPath) + }) + syncHttp1CompatibilityMarkerAfterRollback(userDataPath, result.dataFile, result.profileId) + return { + ...result, + storage: 'json', + restoredPath: result.dataFile, + revision, + quarantineDirectory: recovered.quarantine.directory, + removedDatabaseFiles: recovered.removedDatabaseFiles + } +} + +function restoreDatabaseBackup( + userDataPath: string, + result: ProfileStateExportsResult, + backupId: string, + maintenance: ProfileStateMaintenance +): ProfileStateRollbackResult { + const backup = result.backups.find((entry) => entry.id === backupId) + if (!backup) { + throw new ProfileStateRecoveryCommandError( + 'invalid_argument', + 'Profile-state backup is unavailable. Use profile state exports to inspect retained backups.' + ) + } + const recovered = restoreProfileStateDatabaseBackup({ + maintenance, + databasePath: result.databaseFile, + dataFile: result.dataFile, + backupPath: backup.path, + profileId: result.profileId, + beforeRestore: () => invalidateHttp1CompatibilityMarker(userDataPath) + }) + syncHttp1CompatibilityMarkerFromDatabase(userDataPath, result) + return { + ...result, + storage: 'sqlite', + restoredPath: result.databaseFile, + backupId: backup.id, + revision: recovered.revision, + quarantineDirectory: recovered.quarantine.directory, + removedDatabaseFiles: recovered.removedDatabaseFiles + } +} + +/** Keep SQLite and replace diverged JSON (e.g. edited by an older build) with its export. */ +function adoptCurrentDatabase( + userDataPath: string, + result: ProfileStateExportsResult, + maintenance: ProfileStateMaintenance +): ProfileStateRollbackResult { + maintenance.assertProfile(result.profileId, result.dataFile, result.databaseFile) + const authority = new ProfileStateSqliteAuthority(result.databaseFile, result.profileId) + try { + // Validate before archiving so an unreadable database leaves both copies untouched. + authority.readInitialState() + if (authority.revision === 0) { + throw new ProfileStateRecoveryCommandError( + 'runtime_error', + 'SQLite profile state is empty. Keep the current JSON instead.' + ) + } + const quarantine = quarantineProfileStateDatabase( + result.databaseFile, + result.profileId, + undefined, + 'profile-state-adopt-current-sqlite', + existsSync(result.dataFile) ? [result.dataFile] : [] + ) + invalidateHttp1CompatibilityMarker(userDataPath) + // A retained JSON the marker never accepted would fail the compatibility export's fence. + const divergedJson = existsSync(result.dataFile) ? readFileSync(result.dataFile) : undefined + rmSync(result.dataFile, { force: true }) + let revision: number + try { + revision = authority.writeJsonCompatibilityExport(result.dataFile) ?? authority.revision + } catch (error) { + if (divergedJson !== undefined && !existsSync(result.dataFile)) { + writeFileDurableSync(durableWriteTempPath(result.dataFile), result.dataFile, divergedJson) + } + throw error + } + syncHttp1CompatibilityMarkerFromDatabase(userDataPath, result) + return { + ...result, + storage: 'sqlite', + restoredPath: result.databaseFile, + revision, + quarantineDirectory: quarantine.directory, + removedDatabaseFiles: [] + } + } finally { + authority.close() + } +} + +function syncHttp1CompatibilityMarkerFromDatabase( + userDataPath: string, + result: ProfileStateExportsResult +): void { + const settings = readProfileStateDomain(result.databaseFile, result.profileId, 'settings') + if (settings.kind !== 'unreadable') { + const enabled = + settings.kind === 'value' && + isRecord(settings.value) && + settings.value.electronHttp1CompatibilityMode === true + writeHttp1CompatibilityMarker(userDataPath, enabled, result.profileId) + } +} + +function syncHttp1CompatibilityMarkerAfterRollback( + userDataPath: string, + dataFile: string, + profileId: string +): void { + let enabled = false + try { + const parsed: unknown = JSON.parse(readFileSync(dataFile, 'utf8')) + if (isRecord(parsed) && isRecord(parsed.settings)) { + enabled = parsed.settings.electronHttp1CompatibilityMode === true + } + } catch { + // Leave the invalidated marker absent so startup reads the restored JSON itself. + return + } + writeHttp1CompatibilityMarker(userDataPath, enabled, profileId) +} diff --git a/src/main/persistence/profile-state/profile-state-recovery-copy.test.ts b/src/main/persistence/profile-state/profile-state-recovery-copy.test.ts new file mode 100644 index 00000000000..d7bc8796be2 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-recovery-copy.test.ts @@ -0,0 +1,193 @@ +import { + closeSync, + copyFileSync, + existsSync, + ftruncateSync, + mkdtempSync, + openSync, + readFileSync, + readdirSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, + writeSync +} from 'node:fs' +import type * as FileSystem from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, isAbsolute, join, relative } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as processes from '../../../shared/child-process/run-process' +import { copyProfileStateRecoveryFile } from './profile-state-recovery-copy' + +const cloneLink = vi.hoisted(() => ({ unsupported: false })) +vi.mock('node:fs', async (original) => { + const actual = await original() + return { + ...actual, + linkSync: (...args: Parameters) => { + if (cloneLink.unsupported) { + throw Object.assign(new Error('hardlinks unavailable'), { code: 'ENOTSUP' }) + } + return actual.linkSync(...args) + } + } +}) + +const directories: string[] = [] +afterEach(() => { + cloneLink.unsupported = false + vi.restoreAllMocks() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture(large = true) { + const directory = mkdtempSync(join(tmpdir(), 'orca-recovery-copy-')) + directories.push(directory) + const source = join(directory, '- source with spaces') + const target = join(directory, 'target') + const descriptor = openSync(source, 'wx', 0o600) + try { + ftruncateSync(descriptor, large ? 8 * 1024 * 1024 + 1 : 100) + writeSync(descriptor, Buffer.from('retained source')) + } finally { + closeSync(descriptor) + } + return { directory, source, target } +} + +function expectNoTemporary(directory: string): void { + expect(readdirSync(directory).some((name) => name.startsWith('.orca-recovery-clone-'))).toBe( + false + ) +} + +describe('independent profile recovery copies', () => { + it.each([false, true])('preserves independent bytes with a large file=%s', (large) => { + const { directory, source, target } = fixture(large) + const before = readFileSync(source) + copyProfileStateRecoveryFile(source, target) + expect(readFileSync(target).equals(before)).toBe(true) + expect(statSync(target, { bigint: true }).ino).not.toBe(statSync(source, { bigint: true }).ino) + writeFileSync(source, 'changed source') + expect(readFileSync(target).equals(before)).toBe(true) + writeFileSync(target, 'changed target') + expect(readFileSync(source, 'utf8')).toBe('changed source') + expectNoTemporary(directory) + }) + + it.each([false, true])('never replaces an existing destination with a large file=%s', (large) => { + const { directory, source, target } = fixture(large) + writeFileSync(target, 'do not replace') + expect(() => copyProfileStateRecoveryFile(source, target)).toThrow() + expect(readFileSync(target, 'utf8')).toBe('do not replace') + expectNoTemporary(directory) + }) + + it('does not start a copy process for small files', () => { + const { source, target } = fixture(false) + const run = vi.spyOn(processes, 'runProcessSync') + copyProfileStateRecoveryFile(source, target) + expect(run).not.toHaveBeenCalled() + }) + + describe.skipIf(process.platform !== 'darwin')('Darwin clone failure boundaries', () => { + it('resolves both process arguments while preserving relative path behavior', () => { + const { directory, source, target } = fixture() + const run = vi.spyOn(processes, 'runProcessSync') + copyProfileStateRecoveryFile(relative(process.cwd(), source), relative(process.cwd(), target)) + const args = run.mock.calls[0]?.[0].args + expect(args?.[0]).toBe('-c') + expect(isAbsolute(args?.[1] ?? '')).toBe(true) + expect(isAbsolute(args?.[2] ?? '')).toBe(true) + expect(readFileSync(target).equals(readFileSync(source))).toBe(true) + expectNoTemporary(directory) + }) + + it('keeps ordinary-copy semantics for symlinks to large recovery artifacts', () => { + const { directory, source, target } = fixture() + const alias = join(directory, 'alias') + symlinkSync(source, alias) + const run = vi.spyOn(processes, 'runProcessSync') + copyProfileStateRecoveryFile(alias, target) + expect(run).not.toHaveBeenCalled() + expect(readFileSync(target).equals(readFileSync(source))).toBe(true) + expect(statSync(target, { bigint: true }).ino).not.toBe( + statSync(source, { bigint: true }).ino + ) + expectNoTemporary(directory) + }) + + it('cleans a failed partial clone and falls back to an independent ordinary copy', () => { + const { directory, source, target } = fixture() + vi.spyOn(processes, 'runProcessSync').mockImplementation((spec) => { + const temporary = spec.args?.[2] + if (typeof temporary !== 'string') { + throw new Error('Missing clone destination') + } + expect(statSync(dirname(temporary)).mode & 0o777).toBe(0o700) + writeFileSync(temporary, 'incomplete') + return { code: 1, signal: null, timedOut: false, stdout: '', stderr: 'clone unavailable' } + }) + copyProfileStateRecoveryFile(source, target) + expect(readFileSync(target).equals(readFileSync(source))).toBe(true) + expect(statSync(target, { bigint: true }).ino).not.toBe( + statSync(source, { bigint: true }).ino + ) + expectNoTemporary(directory) + }) + + it.each(['timeout', 'signal', 'spawn'] as const)( + 'preserves originals and removes temporary copies after %s failure', + (failure) => { + const { directory, source, target } = fixture() + const before = readFileSync(source) + vi.spyOn(processes, 'runProcessSync').mockImplementation(() => { + if (failure === 'spawn') { + throw new Error('copy process could not start') + } + return { + code: null, + signal: 'SIGTERM', + timedOut: failure === 'timeout', + stdout: '', + stderr: '' + } + }) + expect(() => copyProfileStateRecoveryFile(source, target)).toThrow() + expect(readFileSync(source).equals(before)).toBe(true) + expect(existsSync(target)).toBe(false) + expectNoTemporary(directory) + } + ) + + it('preserves a destination created while the clone process runs', () => { + const { directory, source, target } = fixture() + vi.spyOn(processes, 'runProcessSync').mockImplementation((spec) => { + const temporary = spec.args?.[2] + if (typeof temporary !== 'string') { + throw new Error('Missing clone destination') + } + copyFileSync(source, temporary) + writeFileSync(target, 'concurrent destination') + return { code: 0, signal: null, timedOut: false, stdout: '', stderr: '' } + }) + expect(() => copyProfileStateRecoveryFile(source, target)).toThrow() + expect(readFileSync(target, 'utf8')).toBe('concurrent destination') + expectNoTemporary(directory) + }) + + it('falls back when the destination filesystem does not support hardlinks', () => { + const { directory, source, target } = fixture() + cloneLink.unsupported = true + copyProfileStateRecoveryFile(source, target) + expect(readFileSync(target).equals(readFileSync(source))).toBe(true) + expect(statSync(target, { bigint: true }).ino).not.toBe( + statSync(source, { bigint: true }).ino + ) + expectNoTemporary(directory) + }) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-recovery-copy.ts b/src/main/persistence/profile-state/profile-state-recovery-copy.ts new file mode 100644 index 00000000000..cb7a1f9315b --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-recovery-copy.ts @@ -0,0 +1,81 @@ +import { constants, copyFileSync, linkSync, lstatSync, mkdtempSync, rmSync } from 'node:fs' +import { basename, dirname, join, resolve } from 'node:path' +import { runProcessSync } from '../../../shared/child-process/run-process' + +// Keep process startup overhead off small recovery copies. +const MINIMUM_CLONE_BYTES = 8 * 1024 * 1024 +const MAXIMUM_CLONE_FILES = 16 + +type RecoveryCopy = { source: string; target: string } + +/** Copy quiescent recovery artifacts independently; never copy an active WAL database this way. */ +export function copyProfileStateRecoveryFile(source: string, target: string): void { + copyProfileStateRecoveryFiles([{ source, target }]) +} + +/** Targets are staging files; callers validate, fsync and publish. */ +export function copyProfileStateRecoveryFiles(files: readonly RecoveryCopy[]): void { + const clones: RecoveryCopy[] = [] + const names = new Set() + for (const file of files) { + const info = process.platform === 'darwin' ? lstatSync(file.source) : undefined + const name = basename(file.source) + if (!info?.isFile() || info.size < MINIMUM_CLONE_BYTES) { + copyFileSync(file.source, file.target, constants.COPYFILE_EXCL) + } else if ( + files.length > 1 && + (clones.length === MAXIMUM_CLONE_FILES || + !/^[a-z0-9][a-z0-9._-]*[a-z0-9]$/i.test(name) || + names.has(name.toLowerCase())) + ) { + // cp derives temporary basenames; ambiguous names need their own private directory. + copyProfileStateRecoveryFile(file.source, file.target) + } else { + clones.push(file) + names.add(name.toLowerCase()) + } + } + if (clones.length === 0) { + return + } + const directory = mkdtempSync(join(dirname(clones[0].target), '.orca-recovery-clone-')) + const temporary = (source: string) => + join(directory, clones.length === 1 ? 'copy' : basename(source)) + let cloned = false + try { + // Node's clone flag is unsupported on Darwin; cp -c falls back to ordinary copying. + const result = runProcessSync({ + program: '/bin/cp', + args: [ + '-c', + ...clones.map(({ source }) => resolve(source)), + resolve(clones.length === 1 ? temporary(clones[0].source) : directory) + ], + timeoutMs: 30_000 * clones.length, + maxOutputBytes: 16_384 + }) + if (result.timedOut || result.signal !== null) { + throw new Error('Profile recovery file copy was interrupted') + } + if (result.code === 0) { + for (const { source, target } of clones) { + try { + linkSync(temporary(source), target) + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'EEXIST') { + throw error + } + copyFileSync(source, target, constants.COPYFILE_EXCL) + } + } + cloned = true + } + } finally { + rmSync(directory, { recursive: true, force: true }) + } + if (!cloned) { + for (const { source, target } of clones) { + copyFileSync(source, target, constants.COPYFILE_EXCL) + } + } +} diff --git a/src/main/persistence/profile-state/profile-state-recovery-crash-boundaries.test.ts b/src/main/persistence/profile-state/profile-state-recovery-crash-boundaries.test.ts new file mode 100644 index 00000000000..75e498b8d70 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-recovery-crash-boundaries.test.ts @@ -0,0 +1,380 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { setSecretStore } from '../../../shared/secret-store' +import { profileStateStorage } from '../../orca-profiles/profile-project-state-file' +import { + acquireProfileStateMaintenance, + acquireProfileStateRuntimeAdmission +} from './profile-state-access' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from './profile-state-database' +import { + hashProfileStateJson, + importProfileStateJson, + readProfileStateSnapshot +} from './profile-state-documents' +import { + profileStateJsonExportPath, + profileStateJsonExportPaths +} from './legacy-json/profile-state-export-path' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath, + profileStateDatabaseBackups +} from './profile-state-backup-path' +import { writeProfileStateDatabaseSnapshotAsync } from './profile-state-database-snapshot' +import { createProfileStateStore } from './profile-state-store-factory' +import { restoreProfileStateJsonExport } from './legacy-json/profile-state-recovery' +import { restoreProfileStateDatabaseBackup } from './profile-state-database-recovery' +import { + buildRecoveryCrashProcess, + killRecoveryAt, + type RecoveryCrashOptions +} from './profile-state-recovery-crash-process' + +vi.mock('../../telemetry/client', () => ({ track: () => {} })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const suiteRoot = mkdtempSync(join(tmpdir(), 'orca-recovery-crash-boundaries-')) +const fixtureRoots: string[] = [] +let bundle: string +const profileId = 'crash-recovery' +const selectedState = { + settings: { + theme: 'dark', + httpProxyUrl: Buffer.from('sealed-selected').toString('base64'), + electronHttp1CompatibilityMode: true + }, + ui: { extension: { origin: 'selected', value: null } }, + extension: { nested: [null, '\ud800', 'selected'], ['__proto__']: { inert: true } }, + opaque: null, + repos: [], + automationRuns: [], + automations: [] +} +const oldState = { + ...selectedState, + settings: { + ...selectedState.settings, + theme: 'light', + httpProxyUrl: Buffer.from('sealed-old').toString('base64') + }, + ui: { extension: { origin: 'old', value: null } }, + extension: { nested: [null, '\ud800', 'old'], ['__proto__']: { inert: true } } +} +const selectedJson = JSON.stringify(selectedState) +const oldJson = JSON.stringify(oldState) + +beforeAll(() => { + bundle = buildRecoveryCrashProcess(suiteRoot) +}) +beforeEach(() => { + setSecretStore({ + isEncryptionAvailable: () => false, + encryptString: () => { + throw new Error('Keychain unavailable') + }, + decryptString: () => { + throw new Error('Keychain unavailable') + }, + describeProtectionGap: () => null + }) +}) +afterEach(() => { + for (const root of fixtureRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) +afterAll(() => rmSync(suiteRoot, { recursive: true, force: true })) + +type Fixture = RecoveryCrashOptions & { backupBytes: Buffer; originalFamily: Map } + +async function fixture(kind: 'json' | 'sqlite', accepted: boolean): Promise { + const root = mkdtempSync(join(suiteRoot, 'profile-')) + fixtureRoots.push(root) + const directory = join(root, 'profiles', profileId) + mkdirSync(directory, { recursive: true }) + writeFileSync( + join(root, 'orca-profile-index.json'), + JSON.stringify({ activeProfileId: profileId, profiles: [{ id: profileId }] }) + ) + const dataFile = join(directory, 'orca-data.json') + const databasePath = join(directory, 'profile-state.db') + const exportPath = profileStateJsonExportPath(dataFile, 3) + const backupPath = profileStateDatabaseBackupPath( + databasePath, + createProfileStateDatabaseBackupId() + ) + const options = { + root, + profileId, + dataFile, + databasePath, + exportPath, + backupPath, + markerPath: join(root, 'http1-compatibility.json'), + kind + } + const source = openProfileStateDatabase(databasePath, profileId) + try { + importProfileStateJson(source.db, oldJson) + importProfileStateJson(source.db, oldJson) + importProfileStateJson( + source.db, + selectedJson, + accepted ? { acceptedLegacyJsonHash: hashProfileStateJson(selectedJson) } : {} + ) + await writeProfileStateDatabaseSnapshotAsync(source.db, backupPath) + } finally { + source.db.close() + } + await killRecoveryAt(bundle, options, 'seed', oldJson) + expect(existsSync(`${databasePath}-wal`)).toBe(true) + expect(existsSync(`${databasePath}-shm`)).toBe(true) + // An empty abandoned rollback journal is harmless, but must be removed before publication. + writeFileSync(`${databasePath}-journal`, '') + if (accepted) { + writeFileSync(dataFile, selectedJson) + } + writeFileSync(exportPath, selectedJson) + writeFileSync( + profileStateJsonExportPath(dataFile, 1), + JSON.stringify({ ...oldState, exportRevision: 1 }) + ) + writeFileSync( + profileStateJsonExportPath(dataFile, 2), + JSON.stringify({ ...oldState, exportRevision: 2 }) + ) + writeFileSync(options.markerPath, JSON.stringify({ schemeVersion: 2, enabled: false, profileId })) + const originalFamily = new Map( + ['', '-wal', '-shm', '-journal'].map((suffix) => [ + suffix, + readFileSync(`${databasePath}${suffix}`) + ]) + ) + return { ...options, backupBytes: readFileSync(backupPath), originalFamily } +} + +function readSqlite(path: string): unknown { + const opened = openProfileStateDatabaseReadOnly(path, profileId) + try { + return JSON.parse(readProfileStateSnapshot(opened.db).json) + } finally { + opened.db.close() + } +} + +function assertQuarantine(profile: Fixture): void { + const quarantine = readdirSync(dirname(profile.databasePath)).find((name) => + name.startsWith('profile-state-corrupt-') + ) + if (quarantine === undefined) { + throw new Error('Recovery did not preserve a quarantine') + } + const directory = join(dirname(profile.databasePath), quarantine) + // Check exact family bytes before opening the copied WAL snapshot. + for (const [suffix, bytes] of profile.originalFamily) { + expect(readFileSync(join(directory, `profile-state.db${suffix}`))).toEqual(bytes) + } + expect(readFileSync(join(directory, basename(profile.exportPath)), 'utf8')).toBe(selectedJson) + expect(readFileSync(join(directory, basename(profile.backupPath)))).toEqual(profile.backupBytes) + expect(readSqlite(join(directory, 'profile-state.db'))).toEqual(oldState) +} + +function assertRestart(profile: Fixture, expected: 'old' | 'selected' | 'refused'): void { + const admission = acquireProfileStateRuntimeAdmission(profile.root) + try { + const open = () => + createProfileStateStore({ + dataFile: profile.dataFile, + databaseFile: profile.databasePath, + profileId + }) + if (expected === 'refused') { + expect(open).toThrow() + expect(() => profileStateStorage(profileId, profile.root)).toThrow() + return + } + const expectedState = expected === 'old' ? oldState : selectedState + const storage = profileStateStorage(profileId, profile.root) + const raw = + storage === 'sqlite' + ? readSqlite(profile.databasePath) + : JSON.parse(readFileSync(profile.dataFile, 'utf8')) + // Full raw-state equality is checked before Store normalization can hide a lost domain. + expect(raw).toEqual(expectedState) + const reopened = open() + try { + expect(reopened.backend).toBe('sqlite') + expect(reopened.migrated).toBe(storage === 'json') + expect(profileStateStorage(profileId, profile.root)).toBe('sqlite') + const projected: unknown = JSON.parse(reopened.store.prepareProfileStateExport().json) + expect(projected).toMatchObject(expectedState) + } finally { + reopened.store.freezeWrites() + } + } finally { + admission.release() + } +} + +function retry(profile: Fixture): void { + const maintenance = acquireProfileStateMaintenance(profile.root) + try { + if (profile.kind === 'json') { + expect(profileStateJsonExportPaths(profile.dataFile)).toContain(profile.exportPath) + restoreProfileStateJsonExport({ ...profile, maintenance }) + } else { + expect( + profileStateDatabaseBackups(profile.databasePath).some( + (backup) => backup.path === profile.backupPath + ) + ).toBe(true) + restoreProfileStateDatabaseBackup({ ...profile, maintenance }) + } + } finally { + maintenance.release() + } + assertRestart(profile, 'selected') +} + +const JSON_BOUNDARIES = [ + 'marker-invalidated', + 'json-publish:before', + 'json-publish:after', + 'primary', + 'wal', + 'shm', + 'journal', + 'other-export', + 'first-export', + 'backup', + 'selected-export', + 'restore-returned', + 'marker-publish:before', + 'marker-publish:after', + 'marker-refreshed' +] as const + +function stage(profile: Fixture, name: string): string { + const paths: Record = { + primary: profile.databasePath, + wal: `${profile.databasePath}-wal`, + shm: `${profile.databasePath}-shm`, + journal: `${profile.databasePath}-journal`, + 'other-export': profileStateJsonExportPath(profile.dataFile, 2), + 'first-export': profileStateJsonExportPath(profile.dataFile, 1), + 'marker-invalidated': profile.markerPath, + backup: profile.backupPath, + 'selected-export': profile.exportPath, + json: profile.dataFile + } + const target = paths[name] + return target === undefined ? name : `removed:${target}` +} + +describe.each([false, true])('JSON recovery process death, accepted prior JSON=%s', (accepted) => { + it.each(JSON_BOUNDARIES)( + 'preserves a complete authority or exact retry at %s', + async (boundary) => { + const profile = await fixture('json', accepted) + await killRecoveryAt(bundle, profile, stage(profile, boundary)) + assertQuarantine(profile) + const finished = [ + 'selected-export', + 'restore-returned', + 'marker-publish:before', + 'marker-publish:after', + 'marker-refreshed' + ].includes(boundary) + const expected = finished + ? 'selected' + : ['marker-invalidated', 'json-publish:before'].includes(boundary) || + (boundary === 'json-publish:after' && accepted) + ? 'old' + : 'refused' + if (finished) { + expect(readFileSync(profile.dataFile, 'utf8')).toBe(selectedJson) + expect(profileStateJsonExportPaths(profile.dataFile)).toEqual([]) + expect(profileStateDatabaseBackups(profile.databasePath)).toEqual([]) + } + assertRestart(profile, expected) + if (!finished) { + expect(readFileSync(profile.exportPath, 'utf8')).toBe(selectedJson) + retry(profile) + } + } + ) +}) + +describe('SQLite recovery process death', () => { + it.each([ + 'marker-invalidated', + 'selected-export', + 'other-export', + 'first-export', + 'primary', + 'wal', + 'shm', + 'journal', + 'json', + 'sqlite-publish:before', + 'sqlite-publish:after', + 'restore-returned', + 'marker-publish:before', + 'marker-publish:after', + 'marker-refreshed' + ])('preserves full state and its immutable retry backup at %s', async (boundary) => { + const profile = await fixture('sqlite', true) + await killRecoveryAt(bundle, profile, stage(profile, boundary)) + assertQuarantine(profile) + expect(readFileSync(profile.backupPath)).toEqual(profile.backupBytes) + const expected = [ + 'marker-invalidated', + 'selected-export', + 'other-export', + 'first-export' + ].includes(boundary) + ? 'old' + : [ + 'sqlite-publish:after', + 'restore-returned', + 'marker-publish:before', + 'marker-publish:after', + 'marker-refreshed' + ].includes(boundary) + ? 'selected' + : 'refused' + assertRestart(profile, expected) + retry(profile) + expect(readFileSync(profile.backupPath)).toEqual(profile.backupBytes) + }) + + it.skipIf(process.platform === 'win32')( + 'allows clean JSON startup after final artifact cleanup is directory-synced', + async () => { + const profile = await fixture('json', true) + await killRecoveryAt(bundle, profile, 'cleanup-directory-synced') + assertQuarantine(profile) + assertRestart(profile, 'selected') + } + ) +}) diff --git a/src/main/persistence/profile-state/profile-state-recovery-crash-process.ts b/src/main/persistence/profile-state/profile-state-recovery-crash-process.ts new file mode 100644 index 00000000000..5b542fff161 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-recovery-crash-process.ts @@ -0,0 +1,178 @@ +import { once } from 'node:events' +import { writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { buildSync } from 'esbuild' +import { spawnProcess } from '../../../shared/child-process/run-process' + +export type RecoveryCrashOptions = { + root: string + profileId: string + dataFile: string + databasePath: string + exportPath: string + backupPath: string + markerPath: string + kind: 'json' | 'sqlite' +} + +/** Build only the recovery graph into an isolated test directory, never shared out/. */ +export function buildRecoveryCrashProcess(directory: string): string { + const bundle = join(directory, 'recovery-crash-api.cjs') + buildSync({ + stdin: { + contents: ` + export { acquireProfileStateMaintenance } from './src/main/persistence/profile-state/profile-state-access' + export { restoreProfileStateJsonExport } from './src/main/persistence/profile-state/legacy-json/profile-state-recovery' + export { restoreProfileStateDatabaseBackup } from './src/main/persistence/profile-state/profile-state-database-recovery' + export { openProfileStateDatabase } from './src/main/persistence/profile-state/profile-state-database' + export { importProfileStateJson } from './src/main/persistence/profile-state/profile-state-documents' + export { invalidateHttp1CompatibilityMarker, writeHttp1CompatibilityMarker } from './src/main/startup/http1-compatibility-marker' + `, + loader: 'ts', + resolveDir: process.cwd() + }, + outfile: bundle, + bundle: true, + platform: 'node', + format: 'cjs', + packages: 'external' + }) + return bundle +} + +const CHILD_SOURCE = ` +const fs = require('node:fs') +const [bundle, raw, stage, payloadPath] = process.argv.slice(2) +const options = JSON.parse(raw) +const payload = fs.readFileSync(payloadPath, 'utf8') +const api = require(bundle) +const barrier = label => { + if (label !== stage) return + fs.writeSync(1, label + '\\n') + fs.readSync(0, Buffer.alloc(1), 0, 1) + throw new Error('Crash barrier unexpectedly resumed') +} +if (stage === 'seed') { + const source = api.openProfileStateDatabase(options.databasePath, options.profileId) + api.importProfileStateJson(source.db, payload, { expectedRevision: 3 }) + barrier('seed') +} +const rename = fs.renameSync +fs.renameSync = (from, to) => { + if (to === options.dataFile) barrier('json-publish:before') + if (to === options.databasePath) barrier('sqlite-publish:before') + if (to === options.markerPath) barrier('marker-publish:before') + rename(from, to) + if (to === options.dataFile) barrier('json-publish:after') + if (to === options.databasePath) barrier('sqlite-publish:after') + if (to === options.markerPath) barrier('marker-publish:after') +} +const rm = fs.rmSync +fs.rmSync = (target, ...rest) => { + rm(target, ...rest) + barrier('removed:' + target) +} +let clone = 0 +const link = fs.linkSync +fs.linkSync = (from, to) => { + const isClone = from.includes('.orca-recovery-clone-') + if (isClone) barrier('clone:' + (++clone) + ':before') + link(from, to) + if (isClone) barrier('clone:' + clone + ':after') +} +const fsync = fs.fsyncSync +fs.fsyncSync = descriptor => { + fsync(descriptor) + if (fs.fstatSync(descriptor).isDirectory() && !fs.existsSync(options.exportPath)) { + barrier('cleanup-directory-synced') + } +} +const maintenance = api.acquireProfileStateMaintenance(options.root) +const recovered = (options.kind === 'json' + ? api.restoreProfileStateJsonExport + : api.restoreProfileStateDatabaseBackup)({ + ...options, maintenance, + beforeRestore: () => api.invalidateHttp1CompatibilityMarker(options.root) + }) +barrier('restore-returned') +api.writeHttp1CompatibilityMarker(options.root, true, options.profileId) +barrier('marker-refreshed') +maintenance.release() +throw new Error('Requested crash boundary was not reached: ' + stage) +` + +/** A pipe barrier proves the syscall completed before the parent sends SIGKILL. */ +export async function killRecoveryAt( + bundle: string, + options: RecoveryCrashOptions, + stage: string, + payload = '' +): Promise { + const childScript = join(dirname(bundle), 'recovery-crash-child.cjs') + const payloadPath = join(dirname(bundle), 'recovery-crash-payload.json') + writeFileSync(childScript, CHILD_SOURCE, 'utf8') + writeFileSync(payloadPath, payload, 'utf8') + const childEnv = { + ORCA_BACKGROUND_LAUNCH: '1', + ...(process.env.PATH ? { PATH: process.env.PATH } : {}), + ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}), + ...(process.env.TEMP ? { TEMP: process.env.TEMP } : {}), + ...(process.env.TMP ? { TMP: process.env.TMP } : {}) + } + const recoveryOptions: RecoveryCrashOptions = { + root: options.root, + profileId: options.profileId, + dataFile: options.dataFile, + databasePath: options.databasePath, + exportPath: options.exportPath, + backupPath: options.backupPath, + markerPath: options.markerPath, + kind: options.kind + } + const child = spawnProcess({ + program: process.execPath, + args: [childScript, bundle, JSON.stringify(recoveryOptions), stage, payloadPath], + env: childEnv + }) + let stderr = '' + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString() + }) + try { + await new Promise((resolve, reject) => { + let stdout = '' + const timer = setTimeout( + () => finish(new Error(`Crash boundary timed out: ${stage}; ${stderr}`)), + 10_000 + ) + const onData = (chunk: Buffer) => { + stdout += chunk.toString() + if (stdout.includes(`${stage}\n`)) { + finish() + } + } + const onExit = () => finish(new Error(`Recovery exited before ${stage}: ${stderr}`)) + const onError = (error: Error) => finish(error) + const finish = (error?: Error) => { + clearTimeout(timer) + child.stdout.off('data', onData) + child.off('exit', onExit) + child.off('error', onError) + if (error) { + reject(error) + } else { + resolve() + } + } + child.stdout.on('data', onData) + child.once('exit', onExit) + child.once('error', onError) + }) + } finally { + if (child.exitCode === null && child.signalCode === null) { + const closed = once(child, 'close') + child.kill('SIGKILL') + await closed + } + } +} diff --git a/src/main/persistence/profile-state/profile-state-recovery-required.ts b/src/main/persistence/profile-state/profile-state-recovery-required.ts new file mode 100644 index 00000000000..12a254f7f1e --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-recovery-required.ts @@ -0,0 +1,85 @@ +import { existsSync } from 'node:fs' +import { hasStateBackup } from './legacy-json/profile-state-legacy-backup-path' +import { profileStateJsonExportPaths } from './legacy-json/profile-state-export-path' +import { profileStateDatabaseBackups } from './profile-state-backup-path' + +type ProfileStateRecoveryLocation = { + dataFile: string + databaseFile: string + profileId: string +} + +export class ProfileStateAuthorityBootstrapError extends Error { + readonly code = 'ambiguous-profile-state' as const + + /** `diverged-json`: both copies are readable, so the user can pick one at startup. */ + constructor( + message: string, + readonly divergence?: 'diverged-json' + ) { + super(message) + this.name = 'ProfileStateAuthorityBootstrapError' + } +} + +/** Never establish a new authority over evidence that the primary was lost. */ +export function assertProfileStateCanInitialize(options: ProfileStateRecoveryLocation): void { + assertNoRetainedProfileStateExports(options) + if (!existsSync(options.dataFile) && hasStateBackup(options.dataFile)) { + throw new ProfileStateAuthorityBootstrapError( + `Legacy profile JSON is missing while its .bak.0–.bak.4 backups remain. Stop Orca and restore a selected backup to ${options.dataFile} before reopening.` + ) + } +} + +/** Startup can surface this error with the exact artifacts an explicit rollback may use. */ +export class ProfileStateRecoveryRequiredError extends Error { + readonly code = 'profile-state-recovery-required' as const + readonly dataFile: string + readonly databaseFile: string + readonly exportPaths: readonly string[] + readonly backupPaths: readonly string[] + + constructor(options: ProfileStateRecoveryLocation, cause: unknown) { + super( + `SQLite profile state could not be read; choose a retained backup or JSON export to recover the profile`, + { cause } + ) + this.name = 'ProfileStateRecoveryRequiredError' + this.dataFile = options.dataFile + this.databaseFile = options.databaseFile + // Recovery guidance must survive a permissions failure while enumerating the directory. + // The startup error still names the canonical paths and remains typed for fail-closed handling. + try { + this.exportPaths = profileStateJsonExportPaths(options.dataFile) + } catch { + this.exportPaths = [] + } + try { + this.backupPaths = profileStateDatabaseBackups(options.databaseFile).map(({ path }) => path) + } catch { + this.backupPaths = [] + } + } +} + +/** A retained migration export proves that absent SQLite is not a fresh profile. */ +export function assertNoRetainedProfileStateExports(options: ProfileStateRecoveryLocation): void { + let hasRetainedExport: boolean + try { + hasRetainedExport = + profileStateJsonExportPaths(options.dataFile).length > 0 || + profileStateDatabaseBackups(options.databaseFile).length > 0 + } catch { + throw new ProfileStateRecoveryRequiredError( + options, + new Error('Could not enumerate retained profile state exports') + ) + } + if (hasRetainedExport) { + throw new ProfileStateRecoveryRequiredError( + options, + new Error('SQLite profile state is missing while retained migration exports exist') + ) + } +} diff --git a/src/main/persistence/profile-state/profile-state-revision-readmission.ts b/src/main/persistence/profile-state/profile-state-revision-readmission.ts new file mode 100644 index 00000000000..8a3c3df338d --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-revision-readmission.ts @@ -0,0 +1,20 @@ +import { openProfileStateDatabaseReadOnly } from './profile-state-database' +import { readProfileStateRevision } from './profile-state-documents' +import { ProfileStateRevisionConflictError } from './profile-state-document-validation' + +/** Reopening a live snapshot cannot adopt another writer's intervening revision. */ +export function assertProfileStateRevisionOnDisk( + databasePath: string, + profileId: string, + revision: number +): void { + const admitted = openProfileStateDatabaseReadOnly(databasePath, profileId) + try { + const actual = readProfileStateRevision(admitted.db) + if (actual !== revision) { + throw new ProfileStateRevisionConflictError(revision, actual) + } + } finally { + admitted.db.close() + } +} diff --git a/src/main/persistence/profile-state/profile-state-revision.ts b/src/main/persistence/profile-state/profile-state-revision.ts new file mode 100644 index 00000000000..6c64714606f --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-revision.ts @@ -0,0 +1,31 @@ +import type Database from '../../sqlite/sync-database' +import { PROFILE_STATE_META_REVISION } from './profile-state-database-schema' +import { isRecord, ProfileStateDocumentCorruptionError } from './profile-state-document-validation' + +export function readProfileStateRevision(db: Database.Database): number { + const row = db + .prepare('SELECT value FROM profile_state_meta WHERE key = ?') + .get(PROFILE_STATE_META_REVISION) + if (!isRecord(row) || typeof row.value !== 'string') { + return 0 + } + const revision = Number(row.value) + if (!Number.isSafeInteger(revision) || revision < 0) { + throw new ProfileStateDocumentCorruptionError('Profile state revision is invalid') + } + return revision +} + +/** Unchanged domains may lag the profile revision, but cannot lead it. */ +export function assertProfileStateDocumentRevision( + revision: number, + profileRevision: number, + domain: string +): void { + if (revision > profileRevision) { + throw new ProfileStateDocumentCorruptionError( + `Profile state document revision ${revision} exceeds profile revision ${profileRevision}`, + domain + ) + } +} diff --git a/src/main/persistence/profile-state/profile-state-runtime-preflight.test.ts b/src/main/persistence/profile-state/profile-state-runtime-preflight.test.ts new file mode 100644 index 00000000000..34a4b02c0a4 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-runtime-preflight.test.ts @@ -0,0 +1,60 @@ +import { build } from 'esbuild' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { preflightProfileStateRuntime } from './profile-state-runtime-preflight' + +let directory: string +let workerPath: string +let backupWorkerPath: string + +beforeAll(async () => { + directory = mkdtempSync(join(tmpdir(), 'orca-preflight-test-')) + workerPath = join(directory, 'profile-state-writer-worker-entry.js') + backupWorkerPath = join(directory, 'profile-state-backup-worker-entry.js') + await build({ + entryPoints: [ + resolve('src/main/persistence/profile-state/profile-state-writer-worker-entry.ts'), + resolve('src/main/persistence/profile-state/profile-state-backup-worker-entry.ts') + ], + outdir: directory, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) +}) + +afterAll(() => rmSync(directory, { recursive: true, force: true })) + +describe('profile runtime preflight', () => { + it('requires a worker commit and an independently readable backup', async () => { + const result = await preflightProfileStateRuntime({ workerPath, backupWorkerPath }) + expect(result.revision).toBe(1) + expect(result.sqliteVersion).toMatch(/^\d+\.\d+\.\d+$/) + }) + + it('refuses readiness when a required worker artifact is absent', async () => { + await expect( + preflightProfileStateRuntime({ + workerPath: join(directory, 'missing.js'), + backupWorkerPath + }) + ).rejects.toThrow('Profile state writer') + }) + + it('does not trust a backup success reply without a valid database', async () => { + const corruptBackup = join(directory, 'corrupt-backup.cjs') + writeFileSync( + corruptBackup, + `const { parentPort, workerData } = require('node:worker_threads') + require('node:fs').writeFileSync(workerData.targetPath, 'not a database') + parentPort.postMessage({ ok: true }) + parentPort.close()` + ) + await expect( + preflightProfileStateRuntime({ workerPath, backupWorkerPath: corruptBackup }) + ).rejects.toThrow() + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-runtime-preflight.ts b/src/main/persistence/profile-state/profile-state-runtime-preflight.ts new file mode 100644 index 00000000000..90a8ab1fab1 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-runtime-preflight.ts @@ -0,0 +1,67 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { randomUUID } from 'node:crypto' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from './profile-state-database' +import { readProfileStateSnapshot } from './profile-state-documents' +import { runProfileStateBackupWorker } from './profile-state-backup-worker' +import { ProfileStateWriteWorkerClient } from './profile-state-writer-worker-client' + +export type ProfileStateRuntimePreflightResult = { + sqliteVersion: string + revision: number +} + +/** Qualify the installed writer and backup without opening an existing user profile. */ +export async function preflightProfileStateRuntime( + options: { workerPath?: string; backupWorkerPath?: string; timeoutMs?: number } = {} +): Promise { + const directory = await mkdtemp(join(tmpdir(), 'orca-profile-preflight-')) + const databasePath = join(directory, 'profile.db') + const targetPath = join(directory, 'backup.db') + const profileId = randomUUID() + const payload = JSON.stringify({ witness: profileId, unicode: '雪 🐋', surrogate: '\ud800' }) + let writer: ProfileStateWriteWorkerClient | undefined + try { + const initial = openProfileStateDatabase(databasePath, profileId) + let revision: number + try { + revision = readProfileStateSnapshot(initial.db).revision + } finally { + initial.db.close() + } + writer = new ProfileStateWriteWorkerClient({ databasePath, profileId, revision }, options) + await writer.ready + const committedRevision = await writer.writeCompleteSerializedDomains([ + { domain: 'preflight', payload } + ]) + await writer.close() + await runProfileStateBackupWorker( + { databasePath, profileId, targetPath }, + { workerPath: options.backupWorkerPath, timeoutMs: options.timeoutMs } + ) + const backup = openProfileStateDatabaseReadOnly(targetPath, profileId) + try { + const snapshot = readProfileStateSnapshot(backup.db) + if (snapshot.revision !== committedRevision || snapshot.json !== `{"preflight":${payload}}`) { + throw new Error('Profile runtime backup did not preserve the acknowledged state') + } + const row = backup.db.prepare('SELECT sqlite_version() AS version').get() + if (typeof row?.version !== 'string') { + throw new Error('Profile runtime did not report its SQLite version') + } + return { sqliteVersion: row.version, revision: committedRevision } + } finally { + backup.db.close() + } + } finally { + try { + await writer?.close() + } finally { + await rm(directory, { recursive: true, force: true }) + } + } +} diff --git a/src/main/persistence/profile-state/profile-state-sqlite-authority.ts b/src/main/persistence/profile-state/profile-state-sqlite-authority.ts new file mode 100644 index 00000000000..8c1408ec511 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-sqlite-authority.ts @@ -0,0 +1,339 @@ +import { existsSync } from 'node:fs' +import type { + ProfileStateAuthority, + ProfileStateAuthorityInitialState, + ProfileStateDomainReplacement, + ProfileStateMaintenance +} from '../loading-store/profile-state-authority' +import { + importProfileStateJson, + readAcceptedProfileStateParsedSnapshot, + readProfileStateParsedSnapshot, + readProfileStateRevision, + readProfileStateSnapshot +} from './profile-state-documents' +import { + openWritableProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from './profile-state-database' +import { writeProfileStateDomains } from './profile-state-domain-writes' +import { + prepareProfileStateDomainMutation, + validateProfileStateDomainTransaction +} from './profile-state-domain-write-validation' +import { + parseProfileStateRoot, + ProfileStateRevisionConflictError +} from './profile-state-document-validation' +import { assertProfileStateRevisionOnDisk } from './profile-state-revision-readmission' +import { + quarantineProfileStateDatabase, + type ProfileStateDatabaseQuarantine +} from './profile-state-database-quarantine' +import { + writeProfileStateAuthorityJsonExport, + writeProfileStateAuthorityCompatibilityExport, + writeProfileStateAuthorityCompatibilityExportAsync +} from './legacy-json/profile-state-authority-exports' +import { buildCompleteDocumentReplacements } from './profile-state-complete-replacements' +import { ProfileStateBackupRotation } from './profile-state-backup-rotation' +import type { ProfileStateWriterInitialization } from './profile-state-writer-protocol' + +/** + * Complete-document authority for the Store cutover. + * + * A Store authority keeps one writable handle for its lifetime so repeated + * domain commits do not pay connection and pragma setup costs. Store teardown + * calls {@link close} before profile switches or process removal. Complete + * payloads use one fenced domain transaction, so unchanged normalized rows are + * not rebuilt; Store callers can still opt into narrower dirty-domain writes. + */ +export class ProfileStateSqliteAuthority implements ProfileStateAuthority { + private retired = false + private observedRevision: number | undefined + private writableDatabase: ReturnType | undefined + private backupRotation: ProfileStateBackupRotation | undefined + + constructor( + private readonly databasePath: string, + private readonly profileId: string + ) {} + + retireForWorker(): ProfileStateWriterInitialization { + this.assertActive() + if (this.observedRevision === undefined || this.backupRotation !== undefined) { + throw new Error('Profile state worker handoff requires an admitted bootstrap authority') + } + const initialization = { + databasePath: this.databasePath, + profileId: this.profileId, + revision: this.observedRevision + } + this.close() + this.retired = true + return initialization + } + + initializeFromRevision(revision: number): void { + this.assertActive() + if (this.observedRevision !== undefined || !Number.isSafeInteger(revision) || revision < 0) { + throw new Error('Invalid profile state worker revision handoff') + } + assertProfileStateRevisionOnDisk(this.databasePath, this.profileId, revision) + this.observedRevision = revision + this.assertCurrentRevision() + } + + get revision(): number { + this.assertActive() + if (this.observedRevision === undefined) { + throw new Error('Profile state authority has no admitted revision') + } + return this.observedRevision + } + + /** Keep the startup payload and write fence on the same accepted revision. */ + readAcceptedState( + rawJson: string + ): ProfileStateAuthorityInitialState | undefined { + this.assertActive() + const opened = openProfileStateDatabaseReadOnly(this.databasePath, this.profileId) + try { + const snapshot = readAcceptedProfileStateParsedSnapshot(opened.db, rawJson) + if (snapshot === undefined) { + return undefined + } + return this.createInitialState(snapshot.revision, snapshot.state) + } finally { + opened.db.close() + } + } + + readInitialState(): ProfileStateAuthorityInitialState { + this.assertActive() + if (!this.writableDatabase && !existsSync(this.databasePath)) { + return this.createInitialState(0, undefined) + } + const opened = + this.writableDatabase ?? openProfileStateDatabaseReadOnly(this.databasePath, this.profileId) + try { + const snapshot = readProfileStateParsedSnapshot(opened.db) + return this.createInitialState( + snapshot.revision, + snapshot.revision === 0 ? undefined : snapshot.state + ) + } finally { + if (opened !== this.writableDatabase) { + opened.db.close() + } + } + } + + readSerializedState(): string | undefined { + this.assertActive() + if (!this.writableDatabase && !existsSync(this.databasePath)) { + // Treat an absent database as the empty revision so a concurrent creator + // cannot race this authority's first commit. + this.observedRevision = 0 + return undefined + } + const opened = + this.writableDatabase ?? openProfileStateDatabaseReadOnly(this.databasePath, this.profileId) + try { + const snapshot = readProfileStateSnapshot(opened.db) + this.observedRevision = snapshot.revision + return snapshot.revision === 0 ? undefined : snapshot.json + } finally { + if (opened !== this.writableDatabase) { + opened.db.close() + } + } + } + + writeSerializedDomains( + replacements: readonly ProfileStateDomainReplacement[], + automationRunsAfter?: readonly unknown[] + ): void { + this.assertActive() + if (this.observedRevision === undefined) { + // Store normally reads before its first write. Establishing the revision + // here keeps direct authority callers fenced too. + this.readSerializedState() + } + const opened = this.openWritableDatabase() + this.observedRevision = writeProfileStateDomains(opened.db, { + expectedRevision: this.observedRevision ?? 0, + replacements, + automationRunsAfter + }).revision + } + + assertCurrentRevision(): void { + const actualRevision = readProfileStateRevision(this.openWritableDatabase().db) + if (this.observedRevision === undefined || actualRevision !== this.observedRevision) { + throw new ProfileStateRevisionConflictError(this.observedRevision ?? 0, actualRevision) + } + } + + writeSerializedAutomationRuns( + replacements: readonly ProfileStateDomainReplacement[], + runs: readonly unknown[] + ): void { + this.writeSerializedDomains(replacements, runs) + } + + writeSerializedState(payload: Buffer): void { + this.assertActive() + const serialized = payload.toString('utf8') + if (!Buffer.from(serialized, 'utf8').equals(payload)) { + throw new Error('Profile state payload is not valid UTF-8') + } + const parsed = parseProfileStateRoot(serialized) + this.writeCompleteSerializedDomains( + Object.entries(parsed).map(([domain, value]) => { + const payload = JSON.stringify(value) + if (payload === undefined) { + throw new Error(`Profile state domain payload is not serializable: ${domain}`) + } + return { domain, payload } + }) + ) + } + + writeCompleteSerializedDomains(replacements: readonly ProfileStateDomainReplacement[]): void { + this.assertActive() + if (this.observedRevision === undefined) { + this.readSerializedState() + } + if (!Array.isArray(replacements) || replacements.length > 0) { + validateProfileStateDomainTransaction({ + expectedRevision: this.observedRevision ?? 0, + replacements + }) + } + const opened = this.openWritableDatabase() + const currentRevision = readProfileStateRevision(opened.db) + const complete = buildCompleteDocumentReplacements(opened.db, replacements) + if (currentRevision === 0 || complete.length === 0) { + // Each fragment must be valid independently before it can become part of a root object. + for (const replacement of replacements) { + prepareProfileStateDomainMutation(replacement) + } + const rawJson = `{${replacements + .filter(({ payload }) => payload !== null) + .map(({ domain, payload }) => `${JSON.stringify(domain)}:${payload}`) + .join(',')}}` + this.observedRevision = importProfileStateJson(opened.db, rawJson, { + expectedRevision: this.observedRevision + }) + return + } + this.observedRevision = writeProfileStateDomains(opened.db, { + expectedRevision: this.observedRevision ?? currentRevision, + replacements: complete + }).revision + } + + scheduleBackup(): void { + this.assertActive() + this.backupRotation ??= new ProfileStateBackupRotation(this.databasePath, this.profileId) + this.backupRotation.schedule() + } + + async drainBackups(): Promise { + this.assertActive() + await this.backupRotation?.drain() + } + + writeJsonExport(targetPath: string): number { + return writeProfileStateAuthorityJsonExport( + this.openWritableDatabase().db, + targetPath, + this.observedRevision + ) + } + + writeJsonCompatibilityExport(targetPath: string): number | undefined { + return writeProfileStateAuthorityCompatibilityExport( + this.openWritableDatabase().db, + targetPath, + this.observedRevision + ) + } + + writeJsonCompatibilityExportAsync(targetPath: string): Promise { + return writeProfileStateAuthorityCompatibilityExportAsync( + this.openWritableDatabase().db, + targetPath, + this.observedRevision + ) + } + + quarantineDatabase(quarantineRoot?: string, reason?: string): ProfileStateDatabaseQuarantine { + this.assertActive() + this.backupRotation?.assertIdle() + this.close() + return quarantineProfileStateDatabase(this.databasePath, this.profileId, quarantineRoot, reason) + } + + close(): void { + this.assertActive() + this.backupRotation?.stop() + this.writableDatabase?.db.close() + this.writableDatabase = undefined + } + + async pauseForMaintenance(): Promise { + const revision = this.revision + await this.drainBackups() + this.close() + let consumed = false + return { + resume: async () => { + if (consumed) { + throw new Error('Profile maintenance resume has already been consumed') + } + consumed = true + assertProfileStateRevisionOnDisk(this.databasePath, this.profileId, revision) + this.assertCurrentRevision() + this.backupRotation = undefined + } + } + } + + private createInitialState( + revision: number, + value: Record | undefined + ): ProfileStateAuthorityInitialState { + let pending: { revision: number; value: Record | undefined } | undefined = { + revision, + value + } + this.observedRevision = revision + return { + authority: this, + takeParsedState: () => { + this.assertActive() + const snapshot = pending + if (snapshot === undefined) { + throw new Error('Profile state startup snapshot has already been consumed') + } + pending = undefined + this.observedRevision = snapshot.revision + return snapshot.value + } + } + } + + private assertActive(): void { + if (this.retired) { + throw new Error('Profile state authority was retired for worker ownership') + } + } + + private openWritableDatabase(): NonNullable { + this.assertActive() + this.writableDatabase ??= openWritableProfileStateDatabase(this.databasePath, this.profileId) + return this.writableDatabase + } +} diff --git a/src/main/persistence/profile-state/profile-state-startup-authority.test.ts b/src/main/persistence/profile-state/profile-state-startup-authority.test.ts new file mode 100644 index 00000000000..539556d788f --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-startup-authority.test.ts @@ -0,0 +1,292 @@ +import { build } from 'esbuild' +import type * as WorkerEntryPath from '../../worker-thread-entry-path' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import { + createProfileStateStoreForStartup, + ProfileStateStartupAuthorityError, + type ProfileStateStartupAuthorityOptions +} from './profile-state-startup-authority' +import { openProfileStateDatabase, profileStateDatabaseFile } from './profile-state-database' + +const bundle = vi.hoisted(() => ({ directory: '' })) +vi.mock('../../worker-thread-entry-path', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + resolveWorkerThreadEntryPath: ( + layout: Parameters[0], + name: string + ) => + name.startsWith('profile-state-') + ? join(bundle.directory, name) + : original.resolveWorkerThreadEntryPath(layout, name) + } +}) + +beforeAll(async () => { + bundle.directory = mkdtempSync(join(tmpdir(), 'orca-startup-writers-')) + await build({ + entryPoints: [ + resolve('src/main/persistence/profile-state/profile-state-writer-worker-entry.ts'), + resolve('src/main/persistence/profile-state/profile-state-backup-worker-entry.ts') + ], + outdir: bundle.directory, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) +}) +afterAll(() => rmSync(bundle.directory, { recursive: true, force: true })) + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8').slice('encrypted:'.length) + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +vi.mock('../../telemetry/client', () => ({ track: () => {} })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +describe('profile-state startup authority boundary', () => { + it('imports legacy desktop state by default and reopens acknowledged SQLite state', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-startup-authority-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databaseFile = profileStateDatabaseFile(directory) + writeFileSync(dataFile, JSON.stringify(buildProfileStateCutoverFixture(directory))) + + const base: Omit = { + dataFile, + databaseFile, + profileId: 'startup-authority-test', + storageAuthority: 'desktop' + } + const candidate = await createProfileStateStoreForStartup({ + ...base, + runtime: 'desktop' + }) + expect(candidate.backend).toBe('sqlite') + expect(candidate.migrated).toBe(true) + candidate.store.updateSettings({ theme: 'dark' }) + await candidate.store.flushPendingOrThrowAsync() + await candidate.store.freezeWritesAsync() + rmSync(dataFile) + + const restarted = await createProfileStateStoreForStartup({ + ...base, + runtime: 'desktop' + }) + expect(restarted.backend).toBe('sqlite') + expect(restarted.classification).toBe('sqlite-only') + expect(restarted.store.getSettings().theme).toBe('dark') + await restarted.store.freezeWritesAsync() + + const packaged = await createProfileStateStoreForStartup({ + ...base, + runtime: 'desktop' + }) + expect(packaged.backend).toBe('sqlite') + expect(packaged.classification).toBe('sqlite-only') + expect(packaged.store.getSettings().theme).toBe('dark') + await packaged.store.freezeWritesAsync() + + const orcad = await createProfileStateStoreForStartup({ + ...base, + runtime: 'orcad', + storageAuthority: 'runtime' + }) + expect(orcad.backend).toBe('sqlite') + await orcad.store.freezeWritesAsync() + }) + + it('rejects direct orcad startup on an incapable runtime', async () => { + const original = process.getBuiltinModule + vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => { + if (id === 'node:sqlite' || id === 'bun:sqlite') { + return undefined + } + return original(id) + }) + + await expect( + createProfileStateStoreForStartup({ + dataFile: join(tmpdir(), 'missing-orca-data.json'), + databaseFile: join(tmpdir(), 'missing-profile-state.db'), + profileId: 'startup-authority-node18-test', + runtime: 'orcad', + storageAuthority: 'runtime' + }) + ).rejects.toThrowError(ProfileStateStartupAuthorityError) + }) + + it('creates an empty desktop profile directly in SQLite and preserves its first acknowledged write', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-default-empty-profile-')) + temporaryDirectories.push(directory) + const options: ProfileStateStartupAuthorityOptions = { + dataFile: join(directory, 'orca-data.json'), + databaseFile: profileStateDatabaseFile(directory), + profileId: 'default-empty', + runtime: 'desktop', + storageAuthority: 'desktop' + } + const first = await createProfileStateStoreForStartup(options) + try { + expect(first.backend).toBe('sqlite') + expect(first.classification).toBe('neither') + first.store.updateSettings({ terminalFontSize: 19 }) + await first.store.flushPendingOrThrowAsync() + expect(existsSync(options.databaseFile)).toBe(true) + expect(existsSync(options.dataFile)).toBe(false) + } finally { + await first.store.freezeWritesAsync() + } + const reopened = await createProfileStateStoreForStartup(options) + try { + expect(reopened.backend).toBe('sqlite') + expect(reopened.migrated).toBe(false) + expect(reopened.store.getSettings().terminalFontSize).toBe(19) + } finally { + await reopened.store.freezeWritesAsync() + } + }) + + it.each(['corrupt', 'future-schema', 'ambiguous'] as const)( + 'refuses %s storage under the desktop default without replacing the authority', + async (kind) => { + const directory = mkdtempSync(join(tmpdir(), 'orca-default-invalid-profile-')) + temporaryDirectories.push(directory) + const options: ProfileStateStartupAuthorityOptions = { + dataFile: join(directory, 'orca-data.json'), + databaseFile: profileStateDatabaseFile(directory), + profileId: 'default-invalid', + runtime: 'desktop', + storageAuthority: 'desktop' + } + if (kind === 'corrupt') { + writeFileSync(options.databaseFile, 'not a SQLite database') + } else { + const opened = openProfileStateDatabase(options.databaseFile, options.profileId) + try { + if (kind === 'future-schema') { + opened.db.exec('PRAGMA user_version = 999') + } else { + writeFileSync(options.dataFile, '{"settings":{"theme":"dark"}}') + } + } finally { + opened.db.close() + } + } + const before = readFileSync(options.databaseFile) + await expect(createProfileStateStoreForStartup(options)).rejects.toMatchObject({ + code: + kind === 'future-schema' + ? 'newer-schema' + : kind === 'ambiguous' + ? 'ambiguous-profile-state' + : 'profile-state-recovery-required' + }) + expect(readFileSync(options.databaseFile)).toEqual(before) + if (kind === 'ambiguous') { + expect(readFileSync(options.dataFile, 'utf8')).toBe('{"settings":{"theme":"dark"}}') + } + } + ) + + it('migrates a JSON-only orcad profile when the runtime exposes SQLite', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-orcad-capable-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databaseFile = profileStateDatabaseFile(directory) + writeFileSync(dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + + const result = await createProfileStateStoreForStartup({ + dataFile, + databaseFile, + profileId: 'orcad-capable-test', + runtime: 'orcad', + storageAuthority: 'runtime' + }) + + expect(result.backend).toBe('sqlite') + expect(result.migrated).toBe(true) + expect(result.store.getSettings().theme).toBe('dark') + await result.store.freezeWritesAsync() + }) + + it('refuses a runtime with SQLite but no native backup', async () => { + const original = process.getBuiltinModule + vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => { + if (id === 'bun:sqlite') { + return undefined + } + return id === 'node:sqlite' ? { DatabaseSync: class {} } : original(id) + }) + await expect( + createProfileStateStoreForStartup({ + dataFile: join(tmpdir(), 'missing-backup-orca-data.json'), + databaseFile: join(tmpdir(), 'missing-backup-profile-state.db'), + profileId: 'missing-native-backup', + runtime: 'orcad', + storageAuthority: 'runtime' + }) + ).rejects.toThrowError(ProfileStateStartupAuthorityError) + }) + + it('leaves legacy JSON untouched when the runtime cannot own SQLite', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-orcad-node18-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databaseFile = profileStateDatabaseFile(directory) + const source = JSON.stringify({ settings: { theme: 'dark' } }) + writeFileSync(dataFile, source) + const original = process.getBuiltinModule + vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => + id === 'node:sqlite' || id === 'bun:sqlite' ? undefined : original(id) + ) + + await expect( + createProfileStateStoreForStartup({ + dataFile, + databaseFile, + profileId: 'orcad-node18-test', + runtime: 'orcad', + storageAuthority: 'runtime' + }) + ).rejects.toThrowError(ProfileStateStartupAuthorityError) + + expect(readFileSync(dataFile, 'utf8')).toBe(source) + expect(existsSync(databaseFile)).toBe(false) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-startup-authority.ts b/src/main/persistence/profile-state/profile-state-startup-authority.ts new file mode 100644 index 00000000000..55c9a031455 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-startup-authority.ts @@ -0,0 +1,45 @@ +import type { AutomationStorageAuthority } from '../scheduling-automations/automation-owner-projection' +import { isProfileStateSqliteAvailable } from './profile-state-database' +import type { + ProfileStateStoreFactoryOptions, + ProfileStateStoreFactoryResult +} from './profile-state-store-factory' +import { createLiveProfileStateStore } from './profile-state-live-store-factory' + +/** Runtime roots sharing the profile-state selection boundary. */ +export type ProfileStateStartupRuntime = 'desktop' | 'orcad' + +export type ProfileStateStartupAuthorityOptions = Omit< + ProfileStateStoreFactoryOptions, + 'storageAuthority' +> & { + runtime: ProfileStateStartupRuntime + storageAuthority: AutomationStorageAuthority + onPersistenceFailure?: (error: Error) => void +} + +export class ProfileStateStartupAuthorityError extends Error { + readonly code = 'orcad-sqlite-authority-unsupported' as const + + constructor() { + super( + 'orcad requires SQLite database and backup support. Launch through its bundled Bun runtime.' + ) + this.name = 'ProfileStateStartupAuthorityError' + } +} + +/** Construct both runtimes through the same validated authority boundary. */ +export async function createProfileStateStoreForStartup( + options: ProfileStateStartupAuthorityOptions +): Promise { + if (options.runtime === 'orcad' && !isProfileStateSqliteAvailable()) { + throw new ProfileStateStartupAuthorityError() + } + return createLiveProfileStateStore(options, { + onFailure: + options.onPersistenceFailure ?? + ((error) => + console.error('[persistence] Saving has stopped. Restart Orca before continuing.', error)) + }) +} diff --git a/src/main/persistence/profile-state/profile-state-startup-failure.test.ts b/src/main/persistence/profile-state/profile-state-startup-failure.test.ts new file mode 100644 index 00000000000..e82ee6ba56f --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-startup-failure.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import { + ProfileStateAuthorityBootstrapError, + ProfileStateRecoveryRequiredError +} from './profile-state-authority-bootstrap' +import { + formatProfileStateStartupFailure, + profileStateStartupFailureClass +} from './profile-state-startup-failure' +import { ProfileStateWriterError } from './profile-state-writer-errors' +import { ProfileStateRevisionConflictError } from './profile-state-document-validation' +import { ProfileStateDatabaseOpenError } from './profile-state-database-errors' + +describe('profile-state startup failure formatting', () => { + it('asks for a newer build instead of rollback when the schema is newer', () => { + const error = new ProfileStateDatabaseOpenError('newer-schema', 'Newer schema: 999') + expect(profileStateStartupFailureClass(error)).toBe('newer-schema') + const message = formatProfileStateStartupFailure(error) + expect(message).toContain('newer version of Orca') + expect(message).not.toContain('rollback') + expect(message).not.toContain('unreadable') + }) + + it('prints recovery paths and the offline rollback command', () => { + const error = new ProfileStateRecoveryRequiredError( + { + dataFile: '/profile/orca-data.json', + databaseFile: '/profile/profile-state.db', + profileId: 'profile-a' + }, + new Error('database is corrupt') + ) + + expect(formatProfileStateStartupFailure(error)).toContain( + 'orca profile state rollback --revision ' + ) + expect(formatProfileStateStartupFailure(error)).toContain('/profile/profile-state.db') + expect(profileStateStartupFailureClass(error)).toBe('recovery-required') + }) + + it('explains ambiguity and offers explicit inspection before choosing a recovery point', () => { + const message = formatProfileStateStartupFailure( + new ProfileStateAuthorityBootstrapError('both profile stores are present') + ) + + expect(message).toContain( + 'Orca cannot safely choose a profile-state authority: both profile stores are present' + ) + expect(message).toContain('neither is selected automatically') + expect(message).toContain('orca profile state rollback --current-json') + expect(message).toContain('does not merge') + expect(message).toContain('orca profile state exports') + expect(message).toContain('orca profile state rollback --backup ') + expect( + profileStateStartupFailureClass(new ProfileStateAuthorityBootstrapError('ambiguous')) + ).toBe('ambiguous-authority') + }) + + it('shows retained SQLite backups and their explicit recovery command without JSON exports', () => { + const message = formatProfileStateStartupFailure({ + code: 'profile-state-recovery-required', + dataFile: '/profile/orca-data.json', + databaseFile: '/profile/profile-state.db', + exportPaths: [], + backupPaths: ['/profile/profile-state.db.backup.latest.db'] + }) + expect(message).toContain( + 'Retained SQLite backups:\n /profile/profile-state.db.backup.latest.db' + ) + expect(message).toContain('orca profile state rollback --backup ') + }) + + it('leaves unrelated startup errors on the existing fatal path', () => { + expect(formatProfileStateStartupFailure(new Error('unrelated startup failure'))).toBeUndefined() + expect(profileStateStartupFailureClass(new Error('unrelated startup failure'))).toBeUndefined() + }) + + it('reports a missing writer without exposing its cause or suggesting database rollback', () => { + const error = new ProfileStateWriterError( + 'profile-state-writer-unavailable', + 'Profile state writer could not start', + 'known-failure', + { cause: new Error('private runtime path') } + ) + expect(profileStateStartupFailureClass(error)).toBe('writer-unavailable') + expect(formatProfileStateStartupFailure(error)).toBe( + 'Orca could not start profile persistence. Restart Orca; if the problem continues, repair or reinstall this build.' + ) + }) + + it('reports an admission race as a conflicting writer', () => { + const error = new ProfileStateRevisionConflictError(2, 3) + expect(profileStateStartupFailureClass(error)).toBe('revision-conflict') + expect(formatProfileStateStartupFailure(error)).toContain('Close other Orca processes') + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-startup-failure.ts b/src/main/persistence/profile-state/profile-state-startup-failure.ts new file mode 100644 index 00000000000..4c8710cbca2 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-startup-failure.ts @@ -0,0 +1,133 @@ +type ProfileStateRecoveryFailure = { + code: 'profile-state-recovery-required' + dataFile: string + databaseFile: string + exportPaths: readonly string[] + backupPaths?: readonly string[] +} + +type ProfileStateAuthorityFailure = { + code: 'ambiguous-profile-state' + message: string + divergence?: unknown +} + +export type ProfileStateStartupFailureClass = + | 'recovery-required' + | 'ambiguous-authority' + | 'revision-conflict' + | 'writer-unavailable' + | 'newer-schema' + | 'publication-unavailable' + +/** Return the bounded failure class used by startup breadcrumbs and support diagnostics. */ +export function profileStateStartupFailureClass( + error: unknown +): ProfileStateStartupFailureClass | undefined { + if (isProfileStateRecoveryFailure(error)) { + return 'recovery-required' + } + if (isProfileStateAuthorityFailure(error)) { + return 'ambiguous-authority' + } + if (isRecord(error) && typeof error.code === 'string') { + if (error.code === 'profile-state-publication-unavailable') { + return 'publication-unavailable' + } + if (error.code === 'newer-schema') { + return 'newer-schema' + } + if (error.code === 'profile-state-revision-conflict') { + return 'revision-conflict' + } + if (error.code.startsWith('profile-state-writer-')) { + return 'writer-unavailable' + } + } + return undefined +} + +/** Both copies are readable and differ only because JSON changed outside SQLite. */ +export function isDivergedProfileStateFailure(error: unknown): boolean { + return isProfileStateAuthorityFailure(error) && error.divergence === 'diverged-json' +} + +/** Format profile-state startup failures without exposing a generic fatal-error path. */ +export function formatProfileStateStartupFailure(error: unknown): string | undefined { + if (isProfileStateRecoveryFailure(error)) { + const retainedBackups = !error.backupPaths?.length + ? ' (none found)' + : error.backupPaths.map((path) => ` ${path}`).join('\n') + const retainedExports = + error.exportPaths.length === 0 + ? ' (none found)' + : error.exportPaths.map((path) => ` ${path}`).join('\n') + return [ + 'Orca cannot safely open the active profile because its SQLite state is unreadable.', + `Legacy JSON path: ${error.dataFile}`, + `SQLite path: ${error.databaseFile}`, + 'Retained SQLite backups:', + retainedBackups, + 'Retained JSON exports:', + retainedExports, + 'Stop Orca, then run `orca profile state exports` and choose a known-good recovery artifact.', + 'Restore SQLite with `orca profile state rollback --backup `, or restore a JSON export with', + '`orca profile state rollback --revision `.' + ].join('\n') + } + + if (isProfileStateAuthorityFailure(error)) { + return [ + `Orca cannot safely choose a profile-state authority: ${error.message}`, + 'An older build may have changed the JSON file. Both copies are preserved; neither is selected automatically.', + 'Stop Orca and copy the profile directory before choosing which state to keep.', + 'To keep the current JSON, including edits from an older build, run `orca profile state rollback --current-json`. This archives both copies and does not merge their contents.', + 'Run `orca profile state exports` to inspect retained recovery points.', + 'Use `orca profile state rollback --backup ` or `orca profile state rollback --revision ` only after selecting the state you want to restore.' + ].join('\n') + } + + const failureClass = profileStateStartupFailureClass(error) + if ( + failureClass === 'publication-unavailable' && + isRecord(error) && + typeof error.message === 'string' + ) { + return error.message + } + if (failureClass === 'newer-schema') { + return 'This profile was saved by a newer version of Orca. Open it with that version or a newer release. Your profile has not been changed.' + } + if (failureClass === 'revision-conflict') { + return 'The active profile changed while Orca was starting. Close other Orca processes using this profile, then restart Orca.' + } + if (failureClass === 'writer-unavailable') { + return 'Orca could not start profile persistence. Restart Orca; if the problem continues, repair or reinstall this build.' + } + + return undefined +} + +function isProfileStateRecoveryFailure(error: unknown): error is ProfileStateRecoveryFailure { + return ( + isRecord(error) && + error.code === 'profile-state-recovery-required' && + typeof error.dataFile === 'string' && + typeof error.databaseFile === 'string' && + Array.isArray(error.exportPaths) && + error.exportPaths.every((path) => typeof path === 'string') && + (error.backupPaths === undefined || + (Array.isArray(error.backupPaths) && + error.backupPaths.every((path) => typeof path === 'string'))) + ) +} + +function isProfileStateAuthorityFailure(error: unknown): error is ProfileStateAuthorityFailure { + return ( + isRecord(error) && error.code === 'ambiguous-profile-state' && typeof error.message === 'string' + ) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} diff --git a/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.test.ts b/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.test.ts new file mode 100644 index 00000000000..80c8687bbcc --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from 'vitest' +import { + chooseProfileStateCopy, + presentProfileStateStartupRecoveryDialog +} from './profile-state-startup-recovery-dialog' + +describe('profile state startup recovery dialog', () => { + it('offers a copyable offline export command and does not mutate state', async () => { + const showMessageBox = vi.fn().mockResolvedValue({ response: 0 }) + const copyToClipboard = vi.fn() + + await presentProfileStateStartupRecoveryDialog({ + message: 'SQLite state is unreadable.\nSQLite path: /tmp/profile-state.db', + recoveryCommand: 'orca profile state exports', + showMessageBox, + copyToClipboard + }) + + expect(showMessageBox).toHaveBeenCalledWith({ + type: 'error', + buttons: ['Copy recovery command', 'Quit'], + defaultId: 1, + cancelId: 1, + title: 'Orca profile state cannot be opened', + message: 'Orca cannot safely open this profile.', + detail: + 'SQLite state is unreadable.\nSQLite path: /tmp/profile-state.db\n\nCopy the recovery command, then run it after Orca closes.' + }) + expect(copyToClipboard).toHaveBeenCalledWith('orca profile state exports') + }) + + it('leaves the clipboard untouched when the user quits', async () => { + const showMessageBox = vi.fn().mockResolvedValue({ response: 1 }) + const copyToClipboard = vi.fn() + + await presentProfileStateStartupRecoveryDialog({ + message: 'ambiguous profile state', + recoveryCommand: 'orca profile state exports', + showMessageBox, + copyToClipboard + }) + + expect(copyToClipboard).not.toHaveBeenCalled() + }) + + it('does not offer rollback for an authority ambiguity', async () => { + const showMessageBox = vi.fn().mockResolvedValue({ response: 0 }) + const copyToClipboard = vi.fn() + + await presentProfileStateStartupRecoveryDialog({ + message: 'both profile authorities are present', + showMessageBox, + copyToClipboard + }) + + expect(showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ + buttons: ['Quit'], + defaultId: 0, + cancelId: 0, + detail: + 'both profile authorities are present\n\nQuit Orca and resolve the profile-state authority before retrying.' + }) + ) + expect(copyToClipboard).not.toHaveBeenCalled() + }) + + it.each([ + [0, 'current-sqlite'], + [1, 'current-json'], + [2, undefined] + ] as const)('maps choice button %i to %s', async (response, expected) => { + const showMessageBox = vi.fn().mockResolvedValue({ response }) + await expect( + chooseProfileStateCopy({ + sqliteSavedAt: new Date(1), + jsonSavedAt: new Date(2), + formatTime: (time) => `t${time.getTime()}`, + showMessageBox + }) + ).resolves.toBe(expected) + expect(showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ + buttons: ['Use SQLite (Recommended)', 'Use JSON', 'Quit'], + defaultId: 0, + cancelId: 2 + }) + ) + const { detail } = showMessageBox.mock.calls[0][0] + expect(detail).toContain('Last saved t1.') + expect(detail).toContain('Last saved t2.') + }) + + it('omits save times it could not read', async () => { + const showMessageBox = vi.fn().mockResolvedValue({ response: 2 }) + await chooseProfileStateCopy({ showMessageBox }) + expect(showMessageBox.mock.calls[0][0].detail).not.toContain('Last saved') + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.ts b/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.ts new file mode 100644 index 00000000000..9201ab54e17 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.ts @@ -0,0 +1,100 @@ +import { statSync } from 'node:fs' +import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron' +import { getActiveProfileStateLocation } from './profile-state-active-location' +import { profileStateDatabaseFiles } from './profile-state-storage-classification' + +export type ProfileStateStartupRecoveryDialogDeps = { + message: string + recoveryCommand?: string + showMessageBox: (options: MessageBoxOptions) => Promise + copyToClipboard: (text: string) => void +} + +/** Present the only safe desktop recovery action without changing the failed authority. */ +export async function presentProfileStateStartupRecoveryDialog( + deps: ProfileStateStartupRecoveryDialogDeps +): Promise { + const buttons = deps.recoveryCommand ? ['Copy recovery command', 'Quit'] : ['Quit'] + const detail = deps.recoveryCommand + ? `${deps.message}\n\nCopy the recovery command, then run it after Orca closes.` + : `${deps.message}\n\nQuit Orca and resolve the profile-state authority before retrying.` + const { response } = await deps.showMessageBox({ + type: 'error', + buttons, + defaultId: buttons.length - 1, + cancelId: buttons.length - 1, + title: 'Orca profile state cannot be opened', + message: 'Orca cannot safely open this profile.', + detail + }) + if (response === 0 && deps.recoveryCommand) { + deps.copyToClipboard(deps.recoveryCommand) + } +} + +export type ProfileStateCopyChoice = 'current-sqlite' | 'current-json' + +export type ProfileStateCopyChoiceDialogDeps = { + sqliteSavedAt?: Date + jsonSavedAt?: Date + formatTime?: (time: Date) => string + showMessageBox: (options: MessageBoxOptions) => Promise +} + +/** Best-effort save times; SQLite's latest commit may live only in its WAL, and -shm changes on every open. */ +export function readProfileStateCopySavedTimes(userDataPath: string): { + sqliteSavedAt?: Date + jsonSavedAt?: Date +} { + try { + const location = getActiveProfileStateLocation(userDataPath) + if (location === undefined) { + return {} + } + const sqliteTimes = profileStateDatabaseFiles(location.databaseFile) + .filter((path) => !path.endsWith('-shm')) + .map(modifiedAt) + .filter((time) => time !== undefined) + const sqliteSavedAt = + sqliteTimes.length === 0 ? undefined : new Date(Math.max(...sqliteTimes.map(Number))) + const jsonSavedAt = modifiedAt(location.dataFile) + return { + ...(sqliteSavedAt === undefined ? {} : { sqliteSavedAt }), + ...(jsonSavedAt === undefined ? {} : { jsonSavedAt }) + } + } catch { + return {} + } +} + +function modifiedAt(path: string): Date | undefined { + return statSync(path, { throwIfNoEntry: false })?.mtime +} + +/** Ask which diverged copy to keep; undefined means quit without changing either. */ +export async function chooseProfileStateCopy( + deps: ProfileStateCopyChoiceDialogDeps +): Promise { + const format = deps.formatTime ?? ((time: Date) => time.toLocaleString()) + const savedAt = (time: Date | undefined): string => + time === undefined ? '' : ` Last saved ${format(time)}.` + const { response } = await deps.showMessageBox({ + type: 'warning', + buttons: ['Use SQLite (Recommended)', 'Use JSON', 'Quit'], + defaultId: 0, + cancelId: 2, + noLink: true, + title: 'Choose profile state', + message: 'This profile has two saved copies that don’t match.', + detail: [ + 'This usually happens after opening the profile in an older version of Orca.', + '', + `SQLite: what this version of Orca saved. Changes made in the older version are discarded.${savedAt(deps.sqliteSavedAt)}`, + '', + `JSON: includes changes made in the older version. Changes this version saved since then are discarded.${savedAt(deps.jsonSavedAt)}`, + '', + 'Orca archives both copies before switching, then restarts.' + ].join('\n') + }) + return response === 0 ? 'current-sqlite' : response === 1 ? 'current-json' : undefined +} diff --git a/src/main/persistence/profile-state/profile-state-startup-secrets.test.ts b/src/main/persistence/profile-state/profile-state-startup-secrets.test.ts new file mode 100644 index 00000000000..e9fbd395bd0 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-startup-secrets.test.ts @@ -0,0 +1,184 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + getSecretStore, + hasSecretStore, + setSecretStore, + _resetSecretStoreForTests +} from '../../../shared/secret-store' +import { Store } from '../loading-store/store' +import * as storeDomains from '../loading-store/store-domain-composition' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' + +let keyState: 'available' | 'unavailable' | 'decrypt-fails' = 'available' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('../../telemetry/client', () => ({ track: () => {} })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +let originalSecretStore: ReturnType | undefined +beforeEach(() => { + originalSecretStore = hasSecretStore() ? getSecretStore() : undefined + setSecretStore({ + isEncryptionAvailable: () => keyState !== 'unavailable', + encryptString: (value) => Buffer.from(`encrypted:${value}`, 'utf8'), + decryptString: (value) => { + if (keyState === 'decrypt-fails') { + throw new Error('keychain denied decryption') + } + return value.toString('utf8').slice('encrypted:'.length) + }, + describeProtectionGap: () => null + }) +}) + +const directories: string[] = [] +const stores: Store[] = [] +afterEach(async () => { + vi.restoreAllMocks() + for (const store of stores) { + store.freezeWrites() + } + for (const store of stores.splice(0)) { + await store.flushAsync() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + keyState = 'available' + if (originalSecretStore) { + setSecretStore(originalSecretStore) + } else { + _resetSecretStoreForTests() + } +}) + +const secrets = { + proxy: 'http://user:password@proxy.test:8080', + cookie: 'startup-secret-cookie', + kagi: 'https://kagi.com/session?t=startup-secret', + lease: `startup-owner-lease-${'x'.repeat(480)}` +} +const sealed = (value: string) => Buffer.from(`encrypted:${value}`, 'utf8').toString('base64') + +it.each([false, true])( + 'rejects reused input before Store context installation (secret=%s)', + (hasSecret) => { + const directory = mkdtempSync(join(tmpdir(), 'orca-consumed-startup-')) + directories.push(directory) + const authority = new ProfileStateSqliteAuthority(join(directory, 'profile-state.db'), 'once') + if (hasSecret) { + authority.writeSerializedState( + Buffer.from( + JSON.stringify({ + settings: { opencodeSessionCookie: sealed(secrets.cookie) } + }) + ) + ) + } + const options = { + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: authority, + initialAuthorityState: authority.readInitialState() + } + const store = new Store(options) + stores.push(store) + if (hasSecret) { + expect(store.getSettings().opencodeSessionCookie).toBe(secrets.cookie) + } + const install = vi.spyOn(storeDomains, 'installStoreDomainContexts') + + expect(() => new Store(options)).toThrow('already been consumed') + expect(install).not.toHaveBeenCalled() + } +) + +describe.each(['serialized', 'parsed'] as const)( + '%s startup secret retention', + (representation) => { + it.each(['available', 'unavailable', 'decrypt-fails'] as const)( + 'preserves every protected slot through an unrelated save when keys are %s', + async (failure) => { + const directory = mkdtempSync(join(tmpdir(), 'orca-startup-secrets-')) + directories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databaseFile = join(directory, 'profile-state.db') + function open() { + const authority = new ProfileStateSqliteAuthority(databaseFile, 'startup-secrets') + const store = new Store({ + dataFile, + profileStateAuthority: authority, + ...(representation === 'parsed' + ? { initialAuthorityState: authority.readInitialState() } + : {}) + }) + stores.push(store) + return { store, authority } + } + + const seeded = open() + seeded.store.updateSettings({ + httpProxyUrl: secrets.proxy, + opencodeSessionCookie: secrets.cookie + }) + seeded.store.updateUI({ browserKagiSessionLink: secrets.kagi }) + await seeded.store.upsertSshPtyConsumerRecovery({ + targetId: 'ssh-1', + clientInstanceId: 'client-1', + serverBuildId: 'server-1', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: secrets.lease + }) + await seeded.store.flushAsync() + seeded.store.freezeWrites() + + keyState = failure + const reopened = open() + reopened.store.updateSettings({ terminalFontSize: 19 }) + await reopened.store.flushAsync() + const persisted = reopened.authority.readSerializedState() + expect(persisted).toBeDefined() + for (const value of Object.values(secrets)) { + expect(persisted).not.toContain(value) + } + expect(JSON.parse(persisted ?? 'null')).toMatchObject({ + settings: { + terminalFontSize: 19, + httpProxyUrl: sealed(secrets.proxy), + opencodeSessionCookie: sealed(secrets.cookie) + }, + ui: { browserKagiSessionLink: sealed(secrets.kagi) }, + sshPtyConsumerRecoveries: [{ ownerLease: sealed(secrets.lease) }] + }) + reopened.store.freezeWrites() + + keyState = 'available' + const restored = open().store + expect(restored.getSettings().httpProxyUrl).toBe(secrets.proxy) + expect(restored.getSettings().opencodeSessionCookie).toBe(secrets.cookie) + expect(restored.getUI().browserKagiSessionLink).toBe(secrets.kagi) + expect(restored.getSshPtyConsumerRecovery('ssh-1')?.ownerLease).toBe(secrets.lease) + } + ) + } +) diff --git a/src/main/persistence/profile-state/profile-state-startup-snapshot.test.ts b/src/main/persistence/profile-state/profile-state-startup-snapshot.test.ts new file mode 100644 index 00000000000..e53c425465d --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-startup-snapshot.test.ts @@ -0,0 +1,309 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ProfileStateAuthority } from '../loading-store/profile-state-authority' +import { Store } from '../loading-store/store' +import { bootstrapProfileStateAuthority } from './profile-state-authority-bootstrap' +import * as profileStateDatabase from './profile-state-database' +import * as profileStateDocumentReader from './profile-state-document-reader' +import { hashProfileStateJson, importProfileStateJson } from './profile-state-documents' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { createProfileStateStore } from './profile-state-store-factory' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8').slice('encrypted:'.length) + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +vi.mock('../../telemetry/client', () => ({ track: () => {} })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const directories: string[] = [] +const stores: Store[] = [] +const authorities: ProfileStateAuthority[] = [] + +afterEach(async () => { + vi.restoreAllMocks() + const openedStores = stores.splice(0) + const openedAuthorities = authorities.splice(0) + for (const store of openedStores) { + store.freezeWrites() + } + for (const authority of openedAuthorities) { + authority.close?.() + } + for (const store of openedStores) { + await store.flushAsync() + } + await Promise.all(openedAuthorities.map((authority) => authority.drainBackups?.())) + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function createPaths(): { dataFile: string; databaseFile: string; profileId: string } { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-startup-snapshot-')) + directories.push(directory) + return { + dataFile: join(directory, 'orca-data.json'), + databaseFile: join(directory, 'profile-state.db'), + profileId: 'startup-snapshot-test' + } +} + +function createEstablishedProfile(keepJson: boolean): ReturnType { + const paths = createPaths() + const source = JSON.stringify({ settings: { theme: 'dark' }, futureDomain: { keep: true } }) + if (keepJson) { + writeFileSync(paths.dataFile, source) + } + const opened = profileStateDatabase.openProfileStateDatabase(paths.databaseFile, paths.profileId) + try { + importProfileStateJson(opened.db, source, { + acceptedLegacyJsonHash: hashProfileStateJson(source) + }) + } finally { + opened.db.close() + } + return paths +} + +describe('profile state startup snapshot handoff', () => { + it('keeps serialized-only authorities usable without SQLite capability', () => { + const paths = createPaths() + const original = process.getBuiltinModule + vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => + id === 'node:sqlite' ? undefined : original(id) + ) + let serialized = '{"settings":{"theme":"dark"},"futureDomain":{"keep":true}}' + const authority: ProfileStateAuthority = { + readSerializedState: () => serialized, + writeSerializedState: (payload) => { + serialized = payload.toString('utf8') + } + } + const store = new Store({ dataFile: paths.dataFile, profileStateAuthority: authority }) + stores.push(store) + expect(store.getSettings().theme).toBe('dark') + expect(store.getSettings().terminalFontSize).toBeGreaterThan(0) + store.updateSettings({ theme: 'light' }) + store.flushOrThrow() + expect(JSON.parse(serialized)).toMatchObject({ + settings: { theme: 'light' }, + futureDomain: { keep: true } + }) + }) + + it.each([false, true])('reads established storage once with retained JSON=%s', (keepJson) => { + const paths = createEstablishedProfile(keepJson) + const open = vi.spyOn(profileStateDatabase, 'openProfileStateDatabaseReadOnly') + const documentRead = vi.spyOn(profileStateDocumentReader, 'readProfileStateDocuments') + const initialRead = vi.spyOn(ProfileStateSqliteAuthority.prototype, 'readInitialState') + const serializedRead = vi.spyOn(ProfileStateSqliteAuthority.prototype, 'readSerializedState') + const acceptedRead = vi.spyOn(ProfileStateSqliteAuthority.prototype, 'readAcceptedState') + + const result = createProfileStateStore({ ...paths }) + stores.push(result.store) + + expect(open).toHaveBeenCalledTimes(1) + expect(documentRead).toHaveBeenCalledTimes(1) + expect(initialRead).toHaveBeenCalledTimes(keepJson ? 0 : 1) + expect(serializedRead).not.toHaveBeenCalled() + expect(acceptedRead).toHaveBeenCalledTimes(keepJson ? 1 : 0) + expect(result.store.getSettings().theme).toBe('dark') + expect(JSON.parse(result.store.prepareProfileStateExport().json)).toMatchObject({ + futureDomain: { keep: true } + }) + }) + + it('uses the validated empty snapshot without rereading SQLite or consulting JSON', () => { + const paths = createPaths() + const opened = profileStateDatabase.openProfileStateDatabase( + paths.databaseFile, + paths.profileId + ) + opened.db.close() + const bootstrapped = bootstrapProfileStateAuthority(paths) + if (bootstrapped.authority === undefined) { + throw new Error('Expected SQLite authority') + } + authorities.push(bootstrapped.authority) + expect(bootstrapped.initialState.serializedState).toBeUndefined() + const read = vi.spyOn(bootstrapped.authority, 'readSerializedState') + writeFileSync(paths.dataFile, '{"settings":{"opencodeSessionCookie":"stale-json"}}') + + const store = new Store({ + dataFile: paths.dataFile, + profileStateAuthority: bootstrapped.authority, + initialAuthorityState: bootstrapped.initialState + }) + stores.push(store) + + expect(read).not.toHaveBeenCalled() + expect(store.getSettings().opencodeSessionCookie).not.toBe('stale-json') + store.updateSettings({ theme: 'dark' }) + store.flushOrThrow() + expect(readFileSync(paths.dataFile, 'utf8')).toContain('stale-json') + expect(bootstrapped.authority.readSerializedState()).toContain('"dark"') + }) + + it('consumes a parsed startup snapshot once, including an empty revision', () => { + const paths = createPaths() + const opened = profileStateDatabase.openProfileStateDatabase( + paths.databaseFile, + paths.profileId + ) + opened.db.close() + const authority = new ProfileStateSqliteAuthority(paths.databaseFile, paths.profileId) + authorities.push(authority) + const initial = authority.readInitialState() + expect(initial.takeParsedState?.()).toBeUndefined() + expect(() => initial.takeParsedState?.()).toThrow('already been consumed') + }) + + it.each([false, true])( + 'fences a writer between bootstrap and Store construction with retained JSON=%s', + (keepJson) => { + const paths = createEstablishedProfile(keepJson) + const bootstrapped = bootstrapProfileStateAuthority(paths) + if (bootstrapped.authority === undefined) { + throw new Error('Expected SQLite authority') + } + authorities.push(bootstrapped.authority) + const opened = profileStateDatabase.openProfileStateDatabase( + paths.databaseFile, + paths.profileId + ) + try { + importProfileStateJson(opened.db, '{"settings":{"theme":"light"}}', { + expectedRevision: 1 + }) + } finally { + opened.db.close() + } + + const store = new Store({ + dataFile: paths.dataFile, + profileStateAuthority: bootstrapped.authority, + initialAuthorityState: bootstrapped.initialState + }) + stores.push(store) + + expect(store.getSettings().theme).toBe('dark') + store.updateSettings({ theme: 'system' }) + expect(() => store.flushOrThrow()).toThrowError( + expect.objectContaining({ + code: 'profile-state-revision-conflict', + expectedRevision: 1, + actualRevision: 2 + }) + ) + expect(bootstrapped.authority.readSerializedState()).toBe('{"settings":{"theme":"light"}}') + } + ) + + it('restores the captured fence after a same-authority refresh', () => { + const paths = createEstablishedProfile(true) + const authority = new ProfileStateSqliteAuthority(paths.databaseFile, paths.profileId) + authorities.push(authority) + const initial = authority.readInitialState() + const writer = new ProfileStateSqliteAuthority(paths.databaseFile, paths.profileId) + authorities.push(writer) + writer.writeSerializedState(Buffer.from('{"settings":{"theme":"light"}}')) + expect(authority.readSerializedState()).toContain('"light"') + + const store = new Store({ + dataFile: paths.dataFile, + profileStateAuthority: authority, + initialAuthorityState: initial + }) + stores.push(store) + store.updateSettings({ theme: 'system' }) + expect(() => store.flushOrThrow()).toThrowError( + expect.objectContaining({ code: 'profile-state-revision-conflict', actualRevision: 2 }) + ) + expect(writer.readSerializedState()).toContain('"light"') + }) + + it('keeps direct authority reads fresh after bootstrap', () => { + const paths = createEstablishedProfile(true) + const bootstrapped = bootstrapProfileStateAuthority(paths) + if (bootstrapped.authority === undefined) { + throw new Error('Expected SQLite authority') + } + authorities.push(bootstrapped.authority) + const writer = new ProfileStateSqliteAuthority(paths.databaseFile, paths.profileId) + authorities.push(writer) + writer.writeSerializedState(Buffer.from('{"settings":{"theme":"light"}}')) + + expect(bootstrapped.authority.readSerializedState()).toBe('{"settings":{"theme":"light"}}') + writer.writeSerializedState(Buffer.from('{"settings":{"theme":"system"}}')) + expect(bootstrapped.authority.readSerializedState()).toBe('{"settings":{"theme":"system"}}') + }) + + it('decrypts and normalizes the startup snapshot through the existing Store loader', () => { + const paths = createPaths() + writeFileSync(paths.dataFile, '{"settings":{"theme":"dark"}}') + const first = createProfileStateStore({ ...paths }).store + stores.push(first) + first.updateSettings({ opencodeSessionCookie: 'startup-secret' }) + first.flushOrThrow() + first.freezeWrites() + + const reopened = createProfileStateStore({ + ...paths + }).store + stores.push(reopened) + expect(reopened.getSettings().opencodeSessionCookie).toBe('startup-secret') + expect(reopened.getSettings().terminalFontSize).toBeGreaterThan(0) + reopened.updateSettings({ theme: 'light' }) + reopened.flushOrThrow() + expect(reopened.prepareProfileStateExport().json).not.toContain('startup-secret') + const again = createProfileStateStore({ ...paths }).store + stores.push(again) + expect(again.getSettings().opencodeSessionCookie).toBe('startup-secret') + expect(again.getSettings().theme).toBe('light') + }) + + it('rejects initial state without its authority or alongside migration input', () => { + const paths = createPaths() + const authority = new ProfileStateSqliteAuthority(paths.databaseFile, paths.profileId) + const otherAuthority = new ProfileStateSqliteAuthority(paths.databaseFile, paths.profileId) + const initialAuthorityState = { authority, serializedState: '{}' } + + expect(() => new Store({ dataFile: paths.dataFile, initialAuthorityState })).toThrow( + 'must belong to its profile-state authority' + ) + expect( + () => new Store({ profileStateAuthority: otherAuthority, initialAuthorityState }) + ).toThrow('must belong to its profile-state authority') + expect( + () => + new Store({ + profileStateAuthority: authority, + initialAuthorityState, + serializedState: '{}' + }) + ).toThrow('cannot use both a profile-state authority and serialized state') + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-storage-classification.ts b/src/main/persistence/profile-state/profile-state-storage-classification.ts new file mode 100644 index 00000000000..987f2a7bbe7 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-storage-classification.ts @@ -0,0 +1,32 @@ +import { existsSync } from 'node:fs' + +export type ProfileStateStorageClassification = 'json-only' | 'sqlite-only' | 'both' | 'neither' + +/** Primary first: recovery must remove it before any journal can be replayed. */ +export function profileStateDatabaseFiles(databaseFile: string): string[] { + return ['', '-wal', '-shm', '-journal'].map((suffix) => `${databaseFile}${suffix}`) +} + +/** Any surviving database-family file rules out a fresh profile or JSON fallback. */ +export function hasProfileStateDatabaseFiles(databaseFile: string): boolean { + return profileStateDatabaseFiles(databaseFile).some(existsSync) +} + +/** Classify storage without opening SQLite or changing either representation. */ +export function classifyProfileStateStorage( + dataFile: string, + databaseFile: string +): ProfileStateStorageClassification { + const hasJson = existsSync(dataFile) + const hasDatabase = hasProfileStateDatabaseFiles(databaseFile) + if (hasJson && hasDatabase) { + return 'both' + } + if (hasJson) { + return 'json-only' + } + if (hasDatabase) { + return 'sqlite-only' + } + return 'neither' +} diff --git a/src/main/persistence/profile-state/profile-state-store-factory.test.ts b/src/main/persistence/profile-state/profile-state-store-factory.test.ts new file mode 100644 index 00000000000..45ceade8698 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-store-factory.test.ts @@ -0,0 +1,413 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + isProfileStateSqliteAvailable, + openProfileStateDatabase, + profileStateDatabaseFile +} from './profile-state-database' +import { + createProfileStateStore as createProfileStateStoreImpl, + type ProfileStateStoreFactoryOptions +} from './profile-state-store-factory' +import { + profileStateJsonExportPath, + profileStateJsonExportPaths +} from './legacy-json/profile-state-export-path' +import { ProfileStateRecoveryRequiredError } from './profile-state-authority-bootstrap' +import { acquireProfileStateMaintenance } from './profile-state-access' +import { restoreProfileStateJsonExport } from './legacy-json/profile-state-recovery' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { LoadedStateParsingOperations } from '../loading-store/loaded-state-parsing' +import { hashProfileStateJson, importProfileStateJson } from './profile-state-documents' +import { PROFILE_STATE_DATABASE_SCHEMA_VERSION } from './profile-state-database-schema' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8').slice('encrypted:'.length) + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +vi.mock('../../telemetry/client', () => ({ track: () => {} })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const temporaryDirectories: string[] = [] +const storesToClose: ReturnType['store'][] = [] + +function createProfileStateStore( + options: ProfileStateStoreFactoryOptions +): ReturnType { + const result = createProfileStateStoreImpl(options) + storesToClose.push(result.store) + return result +} + +afterEach(async () => { + vi.restoreAllMocks() + const openedStores = storesToClose.splice(0) + for (const store of openedStores) { + store.freezeWrites() + } + for (const store of openedStores) { + await store.flushAsync() + } + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function createOptions(): ProfileStateStoreFactoryOptions & { directory: string } { + const root = mkdtempSync(join(tmpdir(), 'orca-profile-state-store-factory-')) + temporaryDirectories.push(root) + const directory = join(root, 'profiles', 'profile-factory-test') + mkdirSync(directory, { recursive: true }) + return { + directory, + dataFile: join(directory, 'orca-data.json'), + databaseFile: profileStateDatabaseFile(directory), + profileId: 'profile-factory-test' + } +} + +describe('profile state Store authority factory', () => { + it.each([false, true])( + 'refuses future schemas without changing storage (retained JSON=%s)', + (keepJson) => { + const options = createOptions() + const source = '{"settings":{"theme":"dark"},"futureDomain":{"keep":true}}' + const { db } = openProfileStateDatabase(options.databaseFile, options.profileId) + try { + importProfileStateJson(db, source, { acceptedLegacyJsonHash: hashProfileStateJson(source) }) + db.pragma(`user_version = ${PROFILE_STATE_DATABASE_SCHEMA_VERSION + 1}`) + } finally { + db.close() + } + if (keepJson) { + writeFileSync(options.dataFile, source) + } + const databaseBefore = readFileSync(options.databaseFile) + + expect(() => createProfileStateStore({ ...options })).toThrow( + expect.objectContaining({ code: 'newer-schema' }) + ) + expect(readFileSync(options.databaseFile)).toEqual(databaseBefore) + expect(existsSync(options.dataFile)).toBe(keepJson) + if (keepJson) { + expect(readFileSync(options.dataFile, 'utf8')).toBe(source) + } + } + ) + + it('closes a migrated authority when Store normalization fails', () => { + const options = createOptions() + writeFileSync(options.dataFile, '{"settings":{"theme":"dark"}}') + const close = vi.spyOn(ProfileStateSqliteAuthority.prototype, 'close') + const readInitialState = vi.spyOn(ProfileStateSqliteAuthority.prototype, 'readInitialState') + vi.spyOn(LoadedStateParsingOperations.prototype, 'loadParsedFromAuthority').mockImplementation( + () => { + throw new Error('injected normalization failure') + } + ) + + expect(() => createProfileStateStore({ ...options })).toThrow('injected normalization failure') + expect(close).toHaveBeenCalledOnce() + expect(readInitialState).toHaveBeenCalledOnce() + const initial = readInitialState.mock.results[0] + if (initial?.type !== 'return') { + throw new Error('Expected a startup token before normalization') + } + expect(() => initial.value.takeParsedState?.()).toThrow('already been consumed') + }) + + it('uses a capability probe that remains false on a Node 18-style host', () => { + const original = process.getBuiltinModule + vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => { + if (id === 'node:sqlite' || id === 'bun:sqlite') { + return undefined + } + return original(id) + }) + + expect(isProfileStateSqliteAvailable()).toBe(false) + }) + + it('imports legacy state by default and preserves its original bytes', () => { + const options = createOptions() + const source = JSON.stringify({ settings: { theme: 'dark' }, unknownDomain: { keep: true } }) + writeFileSync(options.dataFile, source) + + const result = createProfileStateStore(options) + + expect(result.backend).toBe('sqlite') + expect(result.classification).toBe('json-only') + expect(result.migrated).toBe(true) + expect(result.store.getSettings().theme).toBe('dark') + expect(existsSync(options.databaseFile)).toBe(true) + expect(readFileSync(options.dataFile, 'utf8')).toBe(source) + }) + + it.each([0, 1, 2, 3, 4])( + 'requires selected recovery when only legacy backup slot %s remains', + (slot) => { + const options = createOptions() + const backup = `${options.dataFile}.bak.${slot}` + const source = '{"settings":{"theme":"dark"},"futureDomain":{"preserved":true}}' + writeFileSync(backup, source) + + expect(() => createProfileStateStore({ ...options })).toThrow('restore a selected backup') + expect(existsSync(options.databaseFile)).toBe(false) + expect(existsSync(options.dataFile)).toBe(false) + expect(readFileSync(backup, 'utf8')).toBe(source) + + restoreProfileStateJsonExport({ + maintenance: acquireProfileStateMaintenance(dirname(dirname(options.directory))), + databasePath: options.databaseFile, + dataFile: options.dataFile, + profileId: options.profileId, + exportPath: backup + }) + const recovered = createProfileStateStore({ ...options }) + expect(recovered.backend).toBe('sqlite') + expect(JSON.parse(recovered.store.prepareProfileStateExport().json)).toMatchObject({ + settings: { theme: 'dark' }, + futureDomain: { preserved: true } + }) + expect(readFileSync(backup, 'utf8')).toBe(source) + } + ) + + it('migrates JSON and constructs a SQLite Store', () => { + const options = createOptions() + const source = JSON.stringify({ settings: { theme: 'dark' }, unknownDomain: { keep: true } }) + writeFileSync(options.dataFile, source) + + const result = createProfileStateStore({ ...options }) + + expect(result.backend).toBe('sqlite') + expect(result.classification).toBe('json-only') + expect(result.migrated).toBe(true) + expect(result.store.getSettings().theme).toBe('dark') + expect(existsSync(options.databaseFile)).toBe(true) + expect(readFileSync(options.dataFile, 'utf8')).toBe(source) + }) + + it('reuses the candidate authority after the legacy export is removed', () => { + const options = createOptions() + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + createProfileStateStore({ ...options }) + rmSync(options.dataFile) + + const result = createProfileStateStore({ ...options }) + + expect(result.backend).toBe('sqlite') + expect(result.classification).toBe('sqlite-only') + expect(result.migrated).toBe(false) + expect(result.store.getSettings().theme).toBe('dark') + }) + + it('reopens an established SQLite profile without the migration switch', () => { + const options = createOptions() + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + createProfileStateStore({ ...options }) + rmSync(options.dataFile) + + const result = createProfileStateStore({ ...options }) + + expect(result.backend).toBe('sqlite') + expect(result.classification).toBe('sqlite-only') + expect(result.migrated).toBe(false) + expect(result.store.getSettings().theme).toBe('dark') + }) + + it('fails closed instead of falling back to a stale JSON mirror when SQLite is missing', () => { + const options = createOptions() + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + const migrated = createProfileStateStore({ ...options }) + const exportPath = profileStateJsonExportPath(options.dataFile, 1) + expect(existsSync(exportPath)).toBe(true) + migrated.store.freezeWrites() + rmSync(options.databaseFile) + + expect(() => createProfileStateStore({ ...options })).toThrow(ProfileStateRecoveryRequiredError) + }) + + it('does not let candidate mode re-import JSON after SQLite was established', () => { + const options = createOptions() + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + const migrated = createProfileStateStore({ ...options }) + migrated.store.freezeWrites() + rmSync(options.databaseFile) + + expect(() => createProfileStateStore({ ...options })).toThrow(ProfileStateRecoveryRequiredError) + }) + + it('reopens JSON after an explicit rollback removes the SQLite export marker', () => { + const options = createOptions() + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + const migrated = createProfileStateStore({ ...options }) + const exportPath = profileStateJsonExportPath(options.dataFile, 1) + migrated.store.freezeWrites() + + restoreProfileStateJsonExport({ + maintenance: acquireProfileStateMaintenance(dirname(dirname(options.directory))), + databasePath: options.databaseFile, + dataFile: options.dataFile, + exportPath, + profileId: options.profileId + }) + + const result = createProfileStateStore({ ...options }) + expect(result.backend).toBe('sqlite') + expect(result.store.getSettings().theme).toBe('dark') + }) + + it('keeps a migrated profile on SQLite across mutation and restart', () => { + const options = createOptions() + const source = JSON.stringify({ + settings: { theme: 'light' }, + unknownDomain: { preserved: true } + }) + writeFileSync(options.dataFile, source) + const first = createProfileStateStore({ ...options }) + + first.store.updateSettings({ theme: 'dark' }) + first.store.flushOrThrow() + + expect(readFileSync(options.dataFile, 'utf8')).toBe(source) + rmSync(options.dataFile) + const restarted = createProfileStateStore({ ...options }) + + expect(restarted.store.getSettings().theme).toBe('dark') + expect(JSON.parse(restarted.store.prepareProfileStateExport().json)).toMatchObject({ + settings: { theme: 'dark' }, + unknownDomain: { preserved: true } + }) + }) + + it('initializes a valid empty SQLite profile instead of falling back to legacy JSON', () => { + const options = createOptions() + const opened = openProfileStateDatabase(options.databaseFile, options.profileId) + opened.db.close() + + const result = createProfileStateStore({ ...options }) + + expect(result.backend).toBe('sqlite') + expect(result.classification).toBe('sqlite-only') + expect(result.store.getSettings()).toBeDefined() + result.store.updateSettings({ theme: 'dark' }) + result.store.flushOrThrow() + + const verifier = createProfileStateStore({ ...options }) + expect(verifier.store.getSettings().theme).toBe('dark') + }) + + it('establishes SQLite for a fresh candidate profile before its first write', () => { + const options = createOptions() + + const result = createProfileStateStore({ ...options }) + + expect(result.backend).toBe('sqlite') + expect(result.classification).toBe('neither') + expect(result.migrated).toBe(false) + expect(existsSync(options.databaseFile)).toBe(true) + result.store.updateSettings({ theme: 'dark' }) + result.store.flushOrThrow() + result.store.freezeWrites() + + const restarted = createProfileStateStore({ ...options }) + expect(restarted.backend).toBe('sqlite') + expect(restarted.store.getSettings().theme).toBe('dark') + restarted.store.freezeWrites() + }) + + it('refuses a stale JSON mirror when candidate mode sees both files', () => { + const options = createOptions() + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + createProfileStateStore({ ...options }) + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'light' } })) + + expect(() => createProfileStateStore({ ...options })).toThrow('matching acceptance marker') + }) + + it.each([false, true])( + 'refuses an incapable first writer before mutation (JSON=%s)', + (hasJson) => { + const options = createOptions() + const source = '{"settings":{"theme":"dark"}}' + if (hasJson) { + writeFileSync(options.dataFile, source) + } + const original = process.getBuiltinModule + vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => + id === 'node:sqlite' || id === 'bun:sqlite' ? undefined : original(id) + ) + expect(() => createProfileStateStore(options)).toThrow('Writable profiles require SQLite') + expect(existsSync(options.databaseFile)).toBe(false) + expect(existsSync(options.dataFile)).toBe(hasJson) + if (hasJson) { + expect(readFileSync(options.dataFile, 'utf8')).toBe(source) + } + } + ) + + describe('missing database recovery', () => { + it.each([ + { hasJson: true, artifact: 'export' }, + { hasJson: false, artifact: 'export' }, + { hasJson: true, artifact: 'backup' }, + { hasJson: false, artifact: 'backup' } + ])( + 'fails closed with a retained $artifact and JSON present=$hasJson', + ({ hasJson, artifact }) => { + const options = createOptions() + const source = JSON.stringify({ settings: { theme: 'dark' } }) + writeFileSync(options.dataFile, source) + const migrated = createProfileStateStore({ + ...options + }) + migrated.store.freezeWrites() + rmSync(options.databaseFile) + if (artifact === 'backup') { + for (const path of profileStateJsonExportPaths(options.dataFile)) { + rmSync(path) + } + writeFileSync( + `${options.databaseFile}.backup.1789999999999-00000000-0000-4000-8000-000000000000.db`, + 'reserved recovery artifact' + ) + } + if (!hasJson) { + rmSync(options.dataFile) + } + + expect(() => createProfileStateStore(options)).toThrowError( + ProfileStateRecoveryRequiredError + ) + expect(existsSync(options.databaseFile)).toBe(false) + expect(existsSync(options.dataFile)).toBe(hasJson) + if (hasJson) { + expect(readFileSync(options.dataFile, 'utf8')).toBe(source) + } + } + ) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-store-factory.ts b/src/main/persistence/profile-state/profile-state-store-factory.ts new file mode 100644 index 00000000000..a40b19a1726 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-store-factory.ts @@ -0,0 +1,78 @@ +import type { AutomationStorageAuthority } from '../scheduling-automations/automation-owner-projection' +import type { ProfileStateAuthorityInitialState } from '../loading-store/profile-state-authority' +import type { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { Store } from '../loading-store/store' +import { bootstrapProfileStateAuthority } from './profile-state-authority-bootstrap' +import type { ProfileStateStorageClassification } from './profile-state-storage-classification' + +export type ProfileStateStoreFactoryOptions = { + dataFile: string + databaseFile: string + profileId: string + storageAuthority?: AutomationStorageAuthority +} + +export class ProfileStateStoreFactoryError extends Error { + readonly code = 'profile-state-authority-required' as const + + constructor(message: string) { + super(message) + this.name = 'ProfileStateStoreFactoryError' + } +} + +export type ProfileStateStoreFactoryResult = { + store: Store + backend: 'sqlite' + classification: ProfileStateStorageClassification + migrated: boolean +} + +/** Centralize authority selection for desktop, orcad and offline callers. */ +export function createProfileStateStore( + options: ProfileStateStoreFactoryOptions +): ProfileStateStoreFactoryResult { + const { initialState, ...prepared } = prepareProfileStateStore(options) + try { + return { + ...prepared, + store: new Store({ + dataFile: options.dataFile, + storageAuthority: options.storageAuthority, + profileStateAuthority: initialState.authority, + initialAuthorityState: initialState + }) + } + } catch (error) { + // Store construction owns the authority only after its load boundary succeeds. + initialState.authority.close?.() + throw error + } +} + +type PreparedProfileStateStore = Omit & { + initialState: ProfileStateAuthorityInitialState +} + +/** Admission is shared by live worker startup and synchronous offline operations. */ +export function prepareProfileStateStore( + options: ProfileStateStoreFactoryOptions +): PreparedProfileStateStore { + const bootstrap = bootstrapProfileStateAuthority({ + ...options, + allowEmptyProfileState: true + }) + const authority = bootstrap.authority + if (authority === undefined) { + throw new ProfileStateStoreFactoryError( + 'Writable profiles require SQLite database and backup support. Use Orca or its bundled Bun runtime.' + ) + } + + return { + initialState: bootstrap.initialState, + backend: 'sqlite', + classification: bootstrap.classification, + migrated: bootstrap.migrated + } +} diff --git a/src/main/persistence/profile-state/profile-state-streaming-validation.test.ts b/src/main/persistence/profile-state/profile-state-streaming-validation.test.ts new file mode 100644 index 00000000000..537023ac45a --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-streaming-validation.test.ts @@ -0,0 +1,145 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { openProfileStateDatabase } from './profile-state-database' +import { verifyProfileStateSchema } from './profile-state-database-validation' +import { + hashProfileStateJson, + importProfileStateJson, + readProfileStateParsedSnapshot, + readProfileStateSnapshot, + validateProfileStateSnapshot +} from './profile-state-documents' + +const fixtures: { directory: string; db: ReturnType['db'] }[] = [] +afterEach(() => { + for (const { directory, db } of fixtures.splice(0)) { + db.close() + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture(source = '{"automationRuns":[{"id":"a"},{"id":"b"}],"last":null}') { + const directory = mkdtempSync(join(tmpdir(), 'orca-streamed-profile-')) + const { db } = openProfileStateDatabase(join(directory, 'profile-state.db'), 'stream-test') + fixtures.push({ directory, db }) + importProfileStateJson(db, source) + return db +} + +const readers = [ + readProfileStateSnapshot, + readProfileStateParsedSnapshot, + validateProfileStateSnapshot +] + +describe('complete streaming profile validation', () => { + it('preserves history order and bytes with rowids at both SQLite integer limits', () => { + const source = + '{"before":null,"automationRuns":[{"id":"a","text":"雪\\ud800"},{"id":"b"}],"after":true}' + const db = fixture(source) + const before = readProfileStateSnapshot(db) + const update = db.prepare('UPDATE profile_state_automation_runs SET rowid = ? WHERE run_id = ?') + update.run(9223372036854775807n, 'a') + update.run(-9223372036854775808n, 'b') + expect(readProfileStateSnapshot(db)).toEqual(before) + expect(readProfileStateParsedSnapshot(db)).toEqual({ + revision: before.revision, + state: JSON.parse(source) + }) + expect(validateProfileStateSnapshot(db)).toBe(before.revision) + expect(db.isTransaction).toBe(false) + }) + + it('accepts extra columns that shadow every SQLite rowid alias', () => { + const db = fixture() + const before = readProfileStateSnapshot(db) + db.exec("ALTER TABLE profile_state_automation_runs ADD COLUMN rowid TEXT DEFAULT 'shadow'") + db.exec("ALTER TABLE profile_state_automation_runs ADD COLUMN _rowid_ TEXT DEFAULT 'shadow'") + db.exec("ALTER TABLE profile_state_automation_runs ADD COLUMN oid TEXT DEFAULT 'shadow'") + verifyProfileStateSchema(db, 'stream-test') + expect(readProfileStateSnapshot(db)).toEqual(before) + expect(readProfileStateParsedSnapshot(db).state).toEqual(JSON.parse(before.json)) + expect(validateProfileStateSnapshot(db)).toBe(before.revision) + }) + + it.each([ + '{}', + '{"automationRuns":null}', + '{"automationRuns":[]}', + '{"automationRuns":{"future":true}}' + ])('validates history presence without constructing a returned state: %s', (source) => { + const db = fixture(source) + expect(validateProfileStateSnapshot(db)).toBe(readProfileStateSnapshot(db).revision) + expect(readProfileStateParsedSnapshot(db).state).toEqual(JSON.parse(source)) + }) + + it.each([ + [ + 'row hash', + "UPDATE profile_state_automation_runs SET content_hash = printf('%064d', 0) WHERE ordinal = 1" + ], + ['ordering', 'UPDATE profile_state_automation_runs SET ordinal = 0 WHERE ordinal = 1'], + ['row revision', 'UPDATE profile_state_automation_runs SET revision = 99 WHERE ordinal = 1'], + [ + 'row timestamp', + 'UPDATE profile_state_automation_runs SET updated_at = updated_at + 1 WHERE ordinal = 1' + ], + [ + 'aggregate hash', + "UPDATE profile_state_automation_runs_meta SET content_hash = printf('%064d', 0)" + ], + ['domain hash', "UPDATE profile_state_documents SET payload = 'true' WHERE domain = 'last'"], + ['domain revision', "UPDATE profile_state_documents SET revision = 99 WHERE domain = 'last'"] + ])('rejects late %s corruption in every representation', (_, sql) => { + const db = fixture() + db.exec(sql) + for (const read of readers) { + expect(() => read(db)).toThrow() + expect(db.isTransaction).toBe(false) + } + }) + + it.each([false, true])( + 'closes failed iterators while preserving caller transaction ownership (%s)', + (ownsTransaction) => { + const db = fixture() + if (ownsTransaction) { + db.exec('BEGIN') + } + const payload = 'null,"injected":true' + const update = db.prepare( + 'UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = ?' + ) + update.run(payload, hashProfileStateJson(payload), 'last') + expect(() => validateProfileStateSnapshot(db)).toThrow('invalid JSON') + expect(db.isTransaction).toBe(ownsTransaction) + update.run('null', hashProfileStateJson('null'), 'last') + expect(validateProfileStateSnapshot(db)).toBe(1) + expect(db.isTransaction).toBe(ownsTransaction) + if (ownsTransaction) { + db.exec('ROLLBACK') + } + expect(readProfileStateSnapshot(db).json).toBe( + '{"automationRuns":[{"id":"a"},{"id":"b"}],"last":null}' + ) + } + ) + + it('closes the ordered-history iterator when the inner lookup fails validation', () => { + const db = fixture() + const update = db.prepare( + 'UPDATE profile_state_automation_runs SET payload = ?, content_hash = ? WHERE ordinal = 1' + ) + const invalid = '{"id":"wrong"}' + update.run(invalid, hashProfileStateJson(invalid)) + for (const read of readers) { + expect(() => read(db)).toThrow('identity is corrupt') + expect(db.isTransaction).toBe(false) + } + const valid = '{"id":"b"}' + update.run(valid, hashProfileStateJson(valid)) + expect(validateProfileStateSnapshot(db)).toBe(1) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-worker-authority.test.ts b/src/main/persistence/profile-state/profile-state-worker-authority.test.ts new file mode 100644 index 00000000000..cc42f350581 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-worker-authority.test.ts @@ -0,0 +1,76 @@ +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { ProfileStateBackupRotation } from './profile-state-backup-rotation' +import { ProfileStateWriteWorkerClient } from './profile-state-writer-worker-client' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from '../loading-store/profile-state-maintenance-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +describe('worker authority close admission', () => { + it('reports a failed maintenance resume without accepting a changed database', async () => { + const notify = vi.fn() + const { authority, peer } = await createWorkerMaintenanceFixture(undefined, notify) + const maintenance = await authority.pauseForMaintenance() + const other = peer() + other.writeSerializedDomains([{ domain: 'ui', payload: '{"external":true}' }]) + other.close() + + await expect(maintenance.resume()).rejects.toMatchObject({ + code: 'profile-state-revision-conflict' + }) + expect(notify).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ code: 'profile-state-revision-conflict' }) + ) + expect(() => authority.assertWritable()).toThrow() + }) + + it('refuses new commands while its existing backup drains', async () => { + const { authority, directory, readState } = await createWorkerMaintenanceFixture() + const before = readState() + const release = maintenanceBarrier() + vi.spyOn(ProfileStateBackupRotation.prototype, 'drain').mockReturnValueOnce(release.promise) + const closeWriter = vi.spyOn(ProfileStateWriteWorkerClient.prototype, 'close') + + const closing = authority.close() + expect(authority.close()).toBe(closing) + expect(closeWriter).toHaveBeenCalledOnce() + expect(() => authority.assertWritable()).toThrow('closing') + await expect( + authority.writeSerializedDomains([{ domain: 'settings', payload: '{}' }]) + ).rejects.toMatchObject({ code: 'profile-state-writer-closed', outcome: 'known-failure' }) + await expect( + authority.writeJsonExport(join(directory, 'late-export.json')) + ).rejects.toMatchObject({ + code: 'profile-state-writer-closed' + }) + + release.resolve() + await closing + expect(closeWriter).toHaveBeenCalledOnce() + expect(readState()).toEqual(before) + }) + + it('finishes an accepted write before releasing its database', async () => { + const { authority, readState } = await createWorkerMaintenanceFixture() + const accepted = authority.writeSerializedDomains([ + { domain: 'ui', payload: '{"accepted":true}' } + ]) + const closing = authority.close() + await expect(accepted).resolves.toBeUndefined() + await closing + expect(readState().ui).toEqual({ accepted: true }) + await expect(authority.assertCurrentRevision()).rejects.toMatchObject({ + code: 'profile-state-writer-closed' + }) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-worker-authority.ts b/src/main/persistence/profile-state/profile-state-worker-authority.ts new file mode 100644 index 00000000000..efd93652c17 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-worker-authority.ts @@ -0,0 +1,168 @@ +import type { AutomationRun } from '../../../shared/automations-types' +import type { + AsyncProfileStateAuthority, + ProfileStateDomainReplacement, + ProfileStateMaintenance +} from '../loading-store/profile-state-authority' +import { ProfileStateBackupRotation } from './profile-state-backup-rotation' +import { runProfileStateBackupWorker } from './profile-state-backup-worker' +import { quarantineProfileStateDatabase } from './profile-state-database-quarantine' +import { + ProfileStateWriteWorkerClient, + type ProfileStateWriterInitialization +} from './profile-state-writer-worker-client' + +/** Main owns backup scheduling; the persistent worker owns every live SQL command. */ +export class ProfileStateWorkerAuthority implements AsyncProfileStateAuthority { + readonly asynchronous = true + private writer: ProfileStateWriteWorkerClient + private backups: ProfileStateBackupRotation + private closing: Promise | undefined + + constructor( + private readonly initialization: ProfileStateWriterInitialization, + private readonly options: { + workerPath?: string + backupWorkerPath?: string + onFailure?: (error: Error) => void + } = {} + ) { + this.writer = new ProfileStateWriteWorkerClient(initialization, options) + this.backups = this.createBackups() + } + + get ready(): Promise { + return this.writer.ready + } + + readSerializedState(): never { + throw new Error('Live profile state requires its admitted startup snapshot') + } + + assertWritable(): void { + this.writer.assertWritable() + } + + abort(): Promise { + return this.writer.abort() + } + + assertCurrentRevision(): Promise { + return this.writer.assertCurrentRevision().then(() => {}) + } + + writeSerializedDomains(replacements: readonly ProfileStateDomainReplacement[]): Promise { + return this.writer.writeSerializedDomains(replacements).then(() => {}) + } + + writeSerializedAutomationRuns( + replacements: readonly ProfileStateDomainReplacement[], + runs: readonly AutomationRun[] + ): Promise { + return this.writer.writeSerializedAutomationRuns(replacements, runs).then(() => {}) + } + + writeCompleteSerializedDomains( + replacements: readonly ProfileStateDomainReplacement[] + ): Promise { + return this.writer.writeCompleteSerializedDomains(replacements).then(() => {}) + } + + writeSerializedState(payload: Buffer): Promise { + return this.writer.writeSerializedState(payload).then(() => {}) + } + + writeJsonExport(targetPath: string): Promise { + return this.writer.writeJsonExport(targetPath) + } + + writeLatestJsonExport(dataFile: string): Promise { + return this.writer.writeLatestJsonExport(dataFile) + } + + writeJsonCompatibilityExport(targetPath: string): Promise { + return this.writer.writeJsonCompatibilityExportAsync(targetPath) + } + + writeJsonCompatibilityExportAsync(targetPath: string): Promise { + return this.writeJsonCompatibilityExport(targetPath) + } + + scheduleBackup(): void { + if (!this.closing) { + this.backups.schedule() + } + } + + drainBackups(cancel = false): Promise { + if (cancel) { + this.backups.stop() + } + return this.backups.drain() + } + + close(): Promise { + this.writer.stopAdmission() + this.closing ??= this.finishClose() + return this.closing + } + + async pauseForMaintenance(): Promise { + this.assertWritable() + const writer = this.writer + await this.close() + const revision = writer.acknowledgedRevision + let consumed = false + return { + resume: async () => { + if (consumed || this.writer !== writer) { + throw new Error('Profile maintenance resume has already been consumed') + } + consumed = true + this.writer = new ProfileStateWriteWorkerClient( + { ...this.initialization, revision }, + { ...this.options, reportInitializationFailure: true } + ) + this.backups = this.createBackups() + this.closing = undefined + try { + await this.writer.ready + this.assertWritable() + } catch (error) { + await this.close() + throw error + } + } + } + } + + async quarantineDatabase(quarantineRoot?: string, reason?: string) { + await this.close() + return quarantineProfileStateDatabase( + this.initialization.databasePath, + this.initialization.profileId, + quarantineRoot, + reason + ) + } + + private async finishClose(): Promise { + this.backups.stop() + const settled = await Promise.allSettled([this.backups.drain(), this.writer.close()]) + for (const result of settled) { + if (result.status === 'rejected') { + throw result.reason + } + } + } + + private createBackups(): ProfileStateBackupRotation { + return new ProfileStateBackupRotation( + this.initialization.databasePath, + this.initialization.profileId, + Date.now, + (job, signal) => + runProfileStateBackupWorker(job, { workerPath: this.options.backupWorkerPath, signal }) + ) + } +} diff --git a/src/main/persistence/profile-state/profile-state-worker-export-failures.test.ts b/src/main/persistence/profile-state/profile-state-worker-export-failures.test.ts new file mode 100644 index 00000000000..56e21da43e5 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-worker-export-failures.test.ts @@ -0,0 +1,177 @@ +import { build } from 'esbuild' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, afterEach, beforeAll, expect, it, vi } from 'vitest' +import { openProfileStateDatabase } from './profile-state-database' +import { + hashProfileStateJson, + importProfileStateJson, + readProfileStateJsonAcceptance +} from './profile-state-documents' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { ProfileStateWriteWorkerClient } from './profile-state-writer-worker-client' + +let bundleRoot: string +let workerPath: string +const fixtures: { root: string; client: ProfileStateWriteWorkerClient }[] = [] +beforeAll(async () => { + bundleRoot = mkdtempSync(join(tmpdir(), 'orca-export-worker-bundle-')) + workerPath = join(bundleRoot, 'writer.cjs') + await build({ + entryPoints: [ + resolve('src/main/persistence/profile-state/profile-state-writer-worker-entry.ts') + ], + outfile: workerPath, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) +}) +afterEach(async () => { + for (const { root, client } of fixtures.splice(0)) { + await client.close().catch(() => {}) + rmSync(root, { recursive: true, force: true }) + } +}) +afterAll(() => rmSync(bundleRoot, { recursive: true, force: true })) + +async function fixture(fault: 'rename' | 'commit' | 'exit' | 'read-rollback' | 'none') { + const root = mkdtempSync(join(tmpdir(), 'orca-export-worker-')) + const databasePath = join(root, 'profile-state.db') + const dataFile = join(root, 'orca-data.json') + const profileId = 'export-failure' + const original = '{"settings":{"theme":"light"}}' + writeFileSync(dataFile, original) + const withDatabase = ( + run: (db: ReturnType['db']) => T + ): T => { + const { db } = openProfileStateDatabase(databasePath, profileId) + try { + return run(db) + } finally { + db.close() + } + } + withDatabase((db) => + importProfileStateJson(db, original, { + acceptedLegacyJsonHash: hashProfileStateJson(original) + }) + ) + const bootstrap = new ProfileStateSqliteAuthority(databasePath, profileId) + bootstrap.readSerializedState() + bootstrap.writeSerializedDomains([{ domain: 'settings', payload: '{"theme":"dark"}' }]) + const wrapper = join(root, 'fault-worker.cjs') + const faultSource = + fault === 'commit' || fault === 'read-rollback' + ? ` + const DatabaseSync = process.versions.bun + ? require('bun:sqlite').Database + : require('node:sqlite').DatabaseSync + const { existsSync } = require('node:fs') + let writing = false + const exec = DatabaseSync.prototype.exec + DatabaseSync.prototype.exec = function(sql) { + if (${JSON.stringify(fault)} === 'read-rollback' && + existsSync(${JSON.stringify(join(root, 'armed'))}) && + (sql === 'COMMIT' || sql === 'ROLLBACK')) { + throw new Error('injected read transaction release failure') + } + const result = exec.call(this, sql) + if (sql === 'BEGIN IMMEDIATE' && existsSync(${JSON.stringify(join(root, 'armed'))})) writing = true + if (writing && sql === 'COMMIT') throw new Error('injected post-COMMIT failure') + return result + } + ` + : fault === 'none' + ? '' + : ` + const fs = require('node:fs/promises') + const rename = fs.rename + let injected = false + fs.rename = async function(source, target) { + if (!injected && target === ${JSON.stringify(dataFile)}) { + injected = true + if (${JSON.stringify(fault)} === 'exit') { + await rename(source, target) + process.exit(19) + } + throw Object.assign(new Error('injected publication failure'), { code: 'ENOSPC' }) + } + return rename(source, target) + } + ` + writeFileSync(wrapper, `${faultSource}\nrequire(${JSON.stringify(workerPath)})`) + const onFailure = vi.fn() + const client = new ProfileStateWriteWorkerClient(bootstrap.retireForWorker(), { + workerPath: wrapper, + onFailure + }) + fixtures.push({ root, client }) + await client.ready + writeFileSync(join(root, 'armed'), '') + const readAccepted = () => { + const reader = new ProfileStateSqliteAuthority(databasePath, profileId) + try { + return reader.readAcceptedState(readFileSync(dataFile, 'utf8'))?.takeParsedState?.() + } finally { + reader.close() + } + } + return { client, dataFile, original, withDatabase, readAccepted, onFailure } +} + +it.each(['rename', 'staging', 'promotion'] as const)( + 'keeps the real worker usable after known compatibility %s failure', + async (phase) => { + const f = await fixture(phase === 'rename' ? phase : 'none') + if (phase !== 'rename') { + f.withDatabase((db) => + db.exec(` + CREATE TRIGGER reject_acceptance BEFORE INSERT ON profile_state_meta + WHEN NEW.key = 'legacy_json_acceptance' + ${phase === 'promotion' ? "AND json_type(NEW.value, '$.pending') IS NULL" : ''} + BEGIN SELECT RAISE(ABORT, 'injected marker failure'); END + `) + ) + } + await expect(f.client.writeJsonCompatibilityExportAsync(f.dataFile)).rejects.toMatchObject({ + outcome: 'known-failure' + }) + expect(JSON.parse(readFileSync(f.dataFile, 'utf8')).settings.theme).toBe( + phase === 'promotion' ? 'dark' : 'light' + ) + expect(f.readAccepted()).toEqual({ settings: { theme: 'dark' } }) + expect(f.onFailure).not.toHaveBeenCalled() + expect(await f.client.assertCurrentRevision()).toBe(2) + f.withDatabase((db) => db.exec('DROP TRIGGER IF EXISTS reject_acceptance')) + await f.client.writeSerializedDomains([{ domain: 'settings', payload: '{"theme":"system"}' }]) + await f.client.writeJsonCompatibilityExportAsync(f.dataFile) + expect(f.readAccepted()).toEqual({ settings: { theme: 'system' } }) + expect(f.withDatabase(readProfileStateJsonAcceptance)).toEqual({ + jsonHash: hashProfileStateJson(readFileSync(f.dataFile, 'utf8')), + acceptedRevision: 3 + }) + } +) + +it.each(['commit', 'exit', 'read-rollback'] as const)( + 'keeps an unacknowledged export %s fenced even when its files remain recoverable', + async (fault) => { + const f = await fixture(fault) + const failure = await f.client + .writeJsonCompatibilityExportAsync(f.dataFile) + .catch((error: unknown) => error) + expect(failure).toMatchObject({ outcome: 'indeterminate' }) + await expect( + f.client.writeSerializedDomains([{ domain: 'settings', payload: '{}' }]) + ).rejects.toBe(failure) + await f.client.close() + expect(f.onFailure).toHaveBeenCalledExactlyOnceWith(failure) + expect(f.readAccepted()).toEqual({ settings: { theme: 'dark' } }) + expect(JSON.parse(readFileSync(f.dataFile, 'utf8')).settings.theme).toBe( + fault === 'exit' ? 'dark' : 'light' + ) + } +) diff --git a/src/main/persistence/profile-state/profile-state-write-transaction.test.ts b/src/main/persistence/profile-state/profile-state-write-transaction.test.ts new file mode 100644 index 00000000000..2f4dade139c --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-write-transaction.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest' +import Database from '../../sqlite/sync-database' +import { + ProfileStateIndeterminateWriteError, + withProfileStateWriteTransaction +} from './profile-state-write-transaction' + +describe('profile state write transaction ownership', () => { + it('preserves SQLITE_FULL after SQLite rolls back the transaction itself', () => { + const db = new Database(':memory:') + try { + db.exec('PRAGMA page_size=512; CREATE TABLE writes (data BLOB); PRAGMA max_page_count=2') + const rollback = vi.spyOn(db, 'exec') + expect(() => + withProfileStateWriteTransaction(db, () => { + db.exec('INSERT INTO writes VALUES (zeroblob(4096))') + }) + ).toThrow(/database or disk is full/) + expect(db.isTransaction).toBe(false) + expect(rollback).not.toHaveBeenCalledWith('ROLLBACK') + expect(db.prepare('SELECT COUNT(*) AS count FROM writes').get()).toMatchObject({ count: 0 }) + withProfileStateWriteTransaction(db, () => db.exec("INSERT INTO writes VALUES ('small')")) + expect(db.prepare('SELECT COUNT(*) AS count FROM writes').get()).toMatchObject({ count: 1 }) + } finally { + db.close() + } + }) + + it('rolls back a failed deferred commit and leaves the connection usable', () => { + const db = new Database(':memory:') + try { + db.exec(` + PRAGMA foreign_keys = ON; + CREATE TABLE parent (id INTEGER PRIMARY KEY); + CREATE TABLE child (parent_id INTEGER REFERENCES parent(id) DEFERRABLE INITIALLY DEFERRED); + `) + expect(() => + withProfileStateWriteTransaction(db, () => { + db.exec('INSERT INTO child VALUES (1)') + }) + ).toThrow(/FOREIGN KEY/) + expect(db.isTransaction).toBe(false) + expect(db.prepare('SELECT COUNT(*) AS count FROM child').get()).toMatchObject({ count: 0 }) + withProfileStateWriteTransaction(db, () => { + db.exec('INSERT INTO parent VALUES (1); INSERT INTO child VALUES (1)') + }) + expect(db.prepare('SELECT COUNT(*) AS count FROM child').get()).toMatchObject({ count: 1 }) + } finally { + db.close() + } + }) + + it('leaves a caller-owned transaction intact when a nested write is refused', () => { + const db = new Database(':memory:') + try { + db.exec('CREATE TABLE pending (id INTEGER); BEGIN; INSERT INTO pending VALUES (1)') + expect(() => + withProfileStateWriteTransaction(db, () => db.exec('DELETE FROM pending')) + ).toThrow(/idle database/) + expect(db.isTransaction).toBe(true) + db.exec('COMMIT') + expect(db.prepare('SELECT id FROM pending').get()).toMatchObject({ id: 1 }) + } finally { + db.close() + } + }) + it.each([false, true])( + 'reports failed rollback as indeterminate after commit=%s', + (commitFirst) => { + const db = new Database(':memory:') + db.exec('CREATE TABLE writes (id INTEGER)') + const exec = db.exec.bind(db) + const injected = vi.spyOn(db, 'exec').mockImplementation((sql) => { + if (sql === 'ROLLBACK') { + throw new Error('injected rollback failure') + } + if (sql === 'COMMIT') { + if (commitFirst) { + exec(sql) + } + throw new Error('injected commit failure') + } + exec(sql) + }) + try { + expect(() => + withProfileStateWriteTransaction(db, () => db.exec('INSERT INTO writes VALUES (1)')) + ).toThrow(ProfileStateIndeterminateWriteError) + expect(db.isTransaction).toBe(!commitFirst) + if (db.isTransaction) { + exec('ROLLBACK') + } + expect(db.prepare('SELECT COUNT(*) AS count FROM writes').get()).toMatchObject({ + count: commitFirst ? 1 : 0 + }) + } finally { + injected.mockRestore() + db.close() + } + } + ) +}) diff --git a/src/main/persistence/profile-state/profile-state-write-transaction.ts b/src/main/persistence/profile-state/profile-state-write-transaction.ts new file mode 100644 index 00000000000..1dbf10d5bed --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-write-transaction.ts @@ -0,0 +1,42 @@ +import type Database from '../../sqlite/sync-database' + +export class ProfileStateIndeterminateWriteError extends Error { + readonly code = 'profile-state-write-indeterminate' as const + + constructor( + cause: unknown, + readonly rollbackError: unknown + ) { + super('Profile state write failed without a confirmed rollback', { cause }) + this.name = 'ProfileStateIndeterminateWriteError' + } +} + +/** Own the write transaction; joining a caller's transaction would weaken its revision fence. */ +export function withProfileStateWriteTransaction(db: Database.Database, write: () => T): T { + if (db.isTransaction) { + throw new Error('Profile state write requires an idle database connection') + } + db.exec('BEGIN IMMEDIATE') + let committing = false + try { + const result = write() + committing = true + db.exec('COMMIT') + return result + } catch (error) { + if (!db.isTransaction) { + // SQLite can roll back a failed statement itself; a failed COMMIT is ambiguous. + if (committing) { + throw new ProfileStateIndeterminateWriteError(error, undefined) + } + throw error + } + try { + db.exec('ROLLBACK') + } catch (rollbackError) { + throw new ProfileStateIndeterminateWriteError(error, rollbackError) + } + throw error + } +} diff --git a/src/main/persistence/profile-state/profile-state-writer-capacity-errors.test.ts b/src/main/persistence/profile-state/profile-state-writer-capacity-errors.test.ts new file mode 100644 index 00000000000..f537af8833c --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-capacity-errors.test.ts @@ -0,0 +1,47 @@ +import Database from '../../sqlite/sync-database' +import { expect, it } from 'vitest' +import { + decodeProfileStateWriterError, + encodeProfileStateWriterError +} from './profile-state-writer-errors' +import { + createTerminalSessionStateSaveFailureMessage, + isTerminalSessionStorageCapacityFailure +} from '../../../shared/terminal-session-state-save-failure' + +it.each(['ENOSPC', 'EDQUOT', 'SQLITE_FULL', 'EACCES', 'SQLITE_BUSY'])( + 'preserves capacity evidence across the writer boundary for %s', + (code) => { + const error = new Error('private path must not cross the worker boundary', { + cause: Object.assign(new Error('private details'), { code }) + }) + const encoded = encodeProfileStateWriterError(error) + expect(encoded.message).toBe('Profile state persistence failed') + const decoded = decodeProfileStateWriterError(JSON.parse(JSON.stringify(encoded))) + const capacity = ['ENOSPC', 'EDQUOT', 'SQLITE_FULL'].includes(code) + expect( + isTerminalSessionStorageCapacityFailure(createTerminalSessionStateSaveFailureMessage(decoded)) + ).toBe(capacity) + } +) + +it('classifies a real SQLite capacity failure after worker serialization', () => { + const db = new Database(':memory:') + try { + db.exec('PRAGMA page_size=512; CREATE TABLE writes(data BLOB); PRAGMA max_page_count=2') + let failure: unknown + try { + db.exec('INSERT INTO writes VALUES (zeroblob(4096))') + } catch (error) { + failure = error + } + expect(failure).toBeDefined() + const decoded = decodeProfileStateWriterError(encodeProfileStateWriterError(failure)) + expect( + isTerminalSessionStorageCapacityFailure(createTerminalSessionStateSaveFailureMessage(decoded)) + ).toBe(true) + db.exec("INSERT INTO writes VALUES ('small')") + } finally { + db.close() + } +}) diff --git a/src/main/persistence/profile-state/profile-state-writer-connection.ts b/src/main/persistence/profile-state/profile-state-writer-connection.ts new file mode 100644 index 00000000000..bd956f48592 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-connection.ts @@ -0,0 +1,302 @@ +import { Worker } from 'node:worker_threads' +import { resolveProfileStateWriterWorkerPath } from './profile-state-writer-worker-path' +import { + createProfileStateWriterRequest, + isExpectedProfileStateWriterSuccess, + type PendingProfileStateWriterRequest, + type SuccessfulProfileStateWriterResponse +} from './profile-state-writer-request' +import { + decodeProfileStateWriterError, + ProfileStateWriterError +} from './profile-state-writer-errors' +import { + isProfileStateWriterResponse, + type ProfileStateWriterCommand, + type ProfileStateWriterInitialization +} from './profile-state-writer-protocol' + +const REQUEST_TIMEOUT_MS = 30_000 + +/** One materialized command; Store owns coalescing and never queues snapshots here. */ +export class ProfileStateWriterConnection { + readonly ready: Promise + private worker: Worker | undefined + private active: PendingProfileStateWriterRequest | undefined + private nextId = 1 + private failure: Error | undefined + private draining = false + private closePromise: Promise | undefined + private readonly exit = Promise.withResolvers() + private didExit = false + private closeAcknowledged = false + private readonly timeoutMs: number + private readonly initialRevision: number + private latestRevision: number | undefined + + constructor( + initialization: ProfileStateWriterInitialization, + private readonly options: { + workerPath?: string + timeoutMs?: number + onFailure?: (error: Error) => void + reportInitializationFailure?: boolean + } = {} + ) { + this.initialRevision = initialization.revision + this.timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS + const pending = this.createPending(0, 'initialize') + this.active = pending + this.ready = pending.promise.then(() => {}) + // Initialization failures remain observable through ready without an unhandled rejection. + void this.ready.catch(() => {}) + try { + const worker = new Worker(options.workerPath ?? resolveProfileStateWriterWorkerPath(), { + workerData: initialization, + execArgv: [] + }) + this.worker = worker + worker.on('message', (response: unknown) => this.receive(response)) + worker.on('error', (cause: Error) => + this.fault( + new ProfileStateWriterError( + 'profile-state-writer-exit', + 'Profile state writer failed', + this.dispatchedOutcome(), + { cause } + ) + ) + ) + worker.once('exit', (code) => { + this.didExit = true + this.exit.resolve() + if (!this.closeAcknowledged || code !== 0) { + this.fault( + new ProfileStateWriterError( + 'profile-state-writer-exit', + `Profile state writer exited without a completed close (${code})`, + this.dispatchedOutcome() + ) + ) + } + }) + } catch (cause) { + this.didExit = true + this.exit.resolve() + this.fault( + new ProfileStateWriterError( + 'profile-state-writer-unavailable', + 'Profile state writer could not start', + 'known-failure', + { cause } + ) + ) + } + } + + /** Abandoning an active wait cannot establish whether SQLite committed. */ + async abort(): Promise { + this.fault( + new ProfileStateWriterError( + 'profile-state-writer-aborted', + 'Profile state writer was aborted', + this.dispatchedOutcome() + ) + ) + await this.exit.promise + } + + stopAdmission(): void { + this.draining = true + } + + close(): Promise { + this.stopAdmission() + this.closePromise ??= this.finishClose() + return this.closePromise + } + + get acknowledgedRevision(): number { + if (this.failure) { + throw this.failure + } + if (this.latestRevision === undefined) { + throw new Error('Profile state writer has no acknowledged revision') + } + return this.latestRevision + } + + private async finishClose(): Promise { + await this.active?.promise.catch(() => {}) + if (!this.failure && !this.didExit) { + try { + await this.dispatch({ command: 'close' }) + } finally { + const timer = setTimeout( + () => + this.fault( + new ProfileStateWriterError( + 'profile-state-writer-close-timeout', + 'Profile state writer did not exit after close', + 'indeterminate' + ) + ), + this.timeoutMs + ) + try { + await this.exit.promise + } finally { + clearTimeout(timer) + } + } + if (this.failure) { + throw this.failure + } + } else { + await this.exit.promise + } + } + + protected assertDispatchable(closing = false): void { + if (this.failure) { + throw this.failure + } + if (this.didExit || (this.draining && !closing)) { + throw new ProfileStateWriterError( + 'profile-state-writer-closed', + 'Profile state writer is closing', + 'known-failure' + ) + } + if (this.active) { + throw new ProfileStateWriterError( + 'profile-state-writer-busy', + 'Await the active profile state command before dispatching another snapshot', + 'known-failure' + ) + } + } + + protected dispatch( + command: ProfileStateWriterCommand + ): Promise { + try { + this.assertDispatchable(command.command === 'close') + } catch (error) { + return Promise.reject(error) + } + const pending = this.createPending(this.nextId++, command.command) + this.active = pending + try { + this.worker?.postMessage({ ...command, id: pending.id }) + } catch (cause) { + // postMessage did not dispatch a message when serialization fails. + this.settle( + undefined, + new ProfileStateWriterError( + 'profile-state-writer-message', + 'Profile state command could not be transferred', + 'known-failure', + { cause } + ) + ) + } + return pending.promise + } + + private createPending( + id: number, + command: PendingProfileStateWriterRequest['command'] + ): PendingProfileStateWriterRequest { + return createProfileStateWriterRequest(id, command, this.timeoutMs, () => + this.fault( + new ProfileStateWriterError( + 'profile-state-writer-timeout', + 'Profile state writer command timed out', + this.dispatchedOutcome() + ) + ) + ) + } + + private receive(value: unknown): void { + if (this.failure) { + return + } + const pending = this.active + if (!isProfileStateWriterResponse(value) || !pending || value.id !== pending.id) { + this.invalidResponse() + return + } + if (!value.ok) { + const error = decodeProfileStateWriterError(value.error) + if (pending.command === 'initialize' || value.error.outcome === 'indeterminate') { + this.fault(error) + } else { + this.settle(undefined, error) + } + return + } + if ( + !isExpectedProfileStateWriterSuccess( + pending.command, + value, + this.latestRevision ?? this.initialRevision + ) + ) { + this.invalidResponse() + return + } + if (pending.command === 'close') { + this.closeAcknowledged = true + } + this.latestRevision = value.revision + this.settle(value) + } + + private settle(response?: SuccessfulProfileStateWriterResponse, error?: Error): void { + const pending = this.active + this.active = undefined + if (!pending) { + return + } + clearTimeout(pending.timer) + if (response) { + pending.resolve(response) + } else { + pending.reject(error ?? new Error('Profile state request failed')) + } + } + + private dispatchedOutcome(): 'known-failure' | 'indeterminate' { + return this.active?.command === 'initialize' ? 'known-failure' : 'indeterminate' + } + + private invalidResponse(): void { + const error = new ProfileStateWriterError( + 'profile-state-writer-protocol', + 'Invalid profile state writer response', + this.dispatchedOutcome() + ) + this.fault(error) + } + + private fault(error: Error): void { + if (this.failure) { + return + } + this.failure = error + this.settle(undefined, this.failure) + if (!this.didExit) { + void this.worker?.terminate().catch(() => {}) + } + // Startup failures already reject ready; admitted writers must also alert idle callers. + if (this.latestRevision !== undefined || this.options.reportInitializationFailure) { + try { + this.options.onFailure?.(error) + } catch (notificationError) { + console.error('[persistence] Could not report stopped saving:', notificationError) + } + } + } +} diff --git a/src/main/persistence/profile-state/profile-state-writer-errors.ts b/src/main/persistence/profile-state/profile-state-writer-errors.ts new file mode 100644 index 00000000000..afb30d520c8 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-errors.ts @@ -0,0 +1,95 @@ +import { storageCapacityErrorCode } from '../../../shared/storage-capacity-error' +import { + ProfileStateDocumentCorruptionError, + ProfileStateRevisionConflictError +} from './profile-state-document-validation' +import { ProfileStateDatabaseOpenError } from './profile-state-database-errors' +import { ProfileStateIndeterminateWriteError } from './profile-state-write-transaction' +import { ProfileStateReadRollbackError } from './profile-state-read-snapshot' +import type { + ProfileStateWriterErrorData, + ProfileStateWriterFailureOutcome +} from './profile-state-writer-protocol' + +export class ProfileStateWriterError extends Error { + constructor( + readonly code: string, + message: string, + readonly outcome: ProfileStateWriterFailureOutcome, + options?: ErrorOptions + ) { + super(message, options) + this.name = 'ProfileStateWriterError' + } +} + +export function profileStateWriterFailureOutcome(error: unknown): ProfileStateWriterFailureOutcome { + if ( + error instanceof ProfileStateIndeterminateWriteError || + error instanceof ProfileStateReadRollbackError + ) { + return 'indeterminate' + } + if (error instanceof ProfileStateWriterError) { + return error.outcome + } + if (error instanceof Error && error.cause !== undefined) { + return profileStateWriterFailureOutcome(error.cause) + } + return 'known-failure' +} + +export function encodeProfileStateWriterError( + error: unknown, + forceIndeterminate = false +): ProfileStateWriterErrorData { + const outcome = forceIndeterminate ? 'indeterminate' : profileStateWriterFailureOutcome(error) + if (error instanceof ProfileStateRevisionConflictError) { + return { + code: error.code, + message: error.message, + outcome, + expectedRevision: error.expectedRevision, + actualRevision: error.actualRevision + } + } + if (error instanceof ProfileStateDocumentCorruptionError) { + return { code: error.code, message: error.message, outcome, domain: error.domain } + } + if ( + error instanceof ProfileStateDatabaseOpenError || + error instanceof ProfileStateWriterError || + error instanceof ProfileStateIndeterminateWriteError || + error instanceof ProfileStateReadRollbackError + ) { + return { code: error.code, message: error.message, outcome } + } + return { + code: storageCapacityErrorCode(error) ?? 'profile-state-write-failed', + message: 'Profile state persistence failed', + outcome + } +} + +export function decodeProfileStateWriterError(data: ProfileStateWriterErrorData): Error { + if (data.outcome === 'known-failure') { + if ( + data.code === 'profile-state-revision-conflict' && + data.expectedRevision !== undefined && + data.actualRevision !== undefined + ) { + return new ProfileStateRevisionConflictError(data.expectedRevision, data.actualRevision) + } + if (data.code === 'corrupt-document') { + return new ProfileStateDocumentCorruptionError(data.message, data.domain ?? null) + } + if ( + data.code === 'unreadable' || + data.code === 'identity-mismatch' || + data.code === 'invalid-profile-id' + ) { + return new ProfileStateDatabaseOpenError(data.code, data.message) + } + } + return new ProfileStateWriterError(data.code, data.message, data.outcome) +} diff --git a/src/main/persistence/profile-state/profile-state-writer-protocol-faults.test.ts b/src/main/persistence/profile-state/profile-state-writer-protocol-faults.test.ts new file mode 100644 index 00000000000..80a92f00118 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-protocol-faults.test.ts @@ -0,0 +1,148 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ProfileStateWriteWorkerClient } from './profile-state-writer-worker-client' + +const clients: ProfileStateWriteWorkerClient[] = [] +const roots: string[] = [] +afterEach(async () => { + await Promise.all(clients.splice(0).map((client) => client.close())) + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function clientFor( + response: string, + initialization = '{ id: 0, ok: true, revision: 1 }', + onFailure?: (error: Error) => void, + startup = '' +) { + const root = mkdtempSync(join(tmpdir(), 'orca-writer-protocol-')) + roots.push(root) + const workerPath = join(root, 'writer.cjs') + writeFileSync( + workerPath, + ` + const { parentPort } = require('node:worker_threads') + parentPort.postMessage(${initialization}) + parentPort.on('message', (request) => { ${response} }) + ${startup} + ` + ) + const client = new ProfileStateWriteWorkerClient( + { databasePath: join(root, 'unused.db'), profileId: 'protocol-test', revision: 1 }, + { workerPath, timeoutMs: 1000, onFailure } + ) + clients.push(client) + return client +} + +describe('writer protocol refuses uncertain acknowledgements', () => { + it.each([ + '{ id: request.id + 1, ok: true, revision: 2 }', + '{ id: request.id, ok: true, revision: 0 }', + '{ id: request.id, ok: true, revision: 3 }', + '{ id: request.id, ok: true, revision: 2, exportedRevision: 2 }', + '{ id: request.id, ok: true, revision: "2" }' + ])('faults instead of acknowledging malformed write response %s', async (response) => { + const client = clientFor(`parentPort.postMessage(${response})`) + await client.ready + await expect( + client.writeSerializedDomains([{ domain: 'settings', payload: '{}' }]) + ).rejects.toMatchObject({ code: 'profile-state-writer-protocol', outcome: 'indeterminate' }) + await expect(client.assertCurrentRevision()).rejects.toMatchObject({ + code: 'profile-state-writer-protocol' + }) + }) + + it.each(['undefined', 'null', '0', '2'])( + 'refuses a compatibility export with revision %s for an admitted revision of one', + async (exportedRevision) => { + const client = clientFor(`parentPort.postMessage({ + id: request.id, ok: true, revision: 1, exportedRevision: ${exportedRevision} + })`) + await client.ready + await expect(client.writeJsonCompatibilityExportAsync('unused.json')).rejects.toMatchObject({ + code: 'profile-state-writer-protocol', + outcome: 'indeterminate' + }) + } + ) + + it('refuses a revision jump at an unchanged-state fence', async () => { + const client = clientFor('parentPort.postMessage({ id: request.id, ok: true, revision: 2 })') + await client.ready + await expect(client.assertCurrentRevision()).rejects.toMatchObject({ + code: 'profile-state-writer-protocol', + outcome: 'indeterminate' + }) + }) + + it('refuses an initialization acknowledgement for a different revision', async () => { + const client = clientFor('', '{ id: 0, ok: true, revision: 2 }') + await expect(client.ready).rejects.toMatchObject({ + code: 'profile-state-writer-protocol', + outcome: 'known-failure' + }) + }) + + it('faults an unanswered request and rejects later work without retry', async () => { + const notify = vi.fn() + const client = clientFor('', undefined, notify) + await client.ready + await expect( + client.writeSerializedDomains([{ domain: 'settings', payload: '{}' }]) + ).rejects.toMatchObject({ code: 'profile-state-writer-timeout', outcome: 'indeterminate' }) + await expect(client.assertCurrentRevision()).rejects.toMatchObject({ + code: 'profile-state-writer-timeout' + }) + await client.close() + expect(notify).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ code: 'profile-state-writer-timeout' }) + ) + }) + + it('reports an idle writer exit even without a subsequent save', async () => { + const notify = vi.fn() + const client = clientFor('', undefined, notify, 'setTimeout(() => process.exit(1), 50)') + await client.ready + await vi.waitFor(() => expect(notify).toHaveBeenCalledOnce()) + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ code: 'profile-state-writer-exit' }) + ) + await client.close() + expect(notify).toHaveBeenCalledOnce() + }) + + it('leaves startup failure reporting to the startup caller', async () => { + const notify = vi.fn() + const client = clientFor('', '{ id: 0, ok: true, revision: 2 }', notify) + await expect(client.ready).rejects.toThrow() + await client.close() + expect(notify).not.toHaveBeenCalled() + }) + + it('does not report saving stopped after a recoverable request failure or clean close', async () => { + const notify = vi.fn() + const client = clientFor( + ` + if (request.command === 'close') { + parentPort.postMessage({ id: request.id, ok: true, revision: 1 }) + parentPort.close() + } else { + parentPort.postMessage({ id: request.id, ok: false, + error: { code: 'SQLITE_BUSY', message: 'busy', outcome: 'known-failure' } }) + } + `, + undefined, + notify + ) + await client.ready + await expect(client.assertCurrentRevision()).rejects.toThrow('busy') + expect(() => client.assertWritable()).not.toThrow() + await client.close() + expect(notify).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-writer-protocol.ts b/src/main/persistence/profile-state/profile-state-writer-protocol.ts new file mode 100644 index 00000000000..1a7dc665abf --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-protocol.ts @@ -0,0 +1,115 @@ +import type { ProfileStateDomainReplacement } from '../loading-store/profile-state-authority' +import { isRecord } from './profile-state-document-validation' + +export type ProfileStateWriterInitialization = { + databasePath: string + profileId: string + revision: number +} + +export type ProfileStateWriterCommand = + | { command: 'write-state'; payload: Uint8Array } + | { + command: 'write-complete' | 'write-domains' + replacements: readonly ProfileStateDomainReplacement[] + } + | { + command: 'write-automation' + replacements: readonly ProfileStateDomainReplacement[] + runPayloads: readonly string[] + } + | { command: 'assert-revision' | 'close' } + | { command: 'export-json' | 'export-latest' | 'export-compatibility'; targetPath: string } + +export type ProfileStateWriterRequest = ProfileStateWriterCommand & { id: number } +export type ProfileStateWriterFailureOutcome = 'known-failure' | 'indeterminate' +export type ProfileStateWriterErrorData = { + code: string + message: string + outcome: ProfileStateWriterFailureOutcome + expectedRevision?: number + actualRevision?: number + domain?: string | null +} +export type ProfileStateWriterResponse = + | { id: number; ok: true; revision: number; exportedRevision?: number | null } + | { id: number; ok: false; error: ProfileStateWriterErrorData } + +export function isProfileStateRevision(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 +} + +export function isProfileStateWriterInitialization( + value: unknown +): value is ProfileStateWriterInitialization { + return ( + isRecord(value) && + typeof value.databasePath === 'string' && + value.databasePath.length > 0 && + typeof value.profileId === 'string' && + value.profileId.length > 0 && + isProfileStateRevision(value.revision) + ) +} + +function isReplacement(value: unknown): value is ProfileStateDomainReplacement { + return ( + isRecord(value) && + typeof value.domain === 'string' && + value.domain.length > 0 && + (typeof value.payload === 'string' || value.payload === null) + ) +} + +export function isProfileStateWriterRequest(value: unknown): value is ProfileStateWriterRequest { + if (!isRecord(value) || !isProfileStateRevision(value.id) || value.id === 0) { + return false + } + switch (value.command) { + case 'write-state': + return value.payload instanceof Uint8Array + case 'assert-revision': + case 'close': + return true + case 'export-json': + case 'export-latest': + case 'export-compatibility': + return typeof value.targetPath === 'string' && value.targetPath.length > 0 + case 'write-complete': + case 'write-domains': + return Array.isArray(value.replacements) && value.replacements.every(isReplacement) + case 'write-automation': + return ( + Array.isArray(value.replacements) && + value.replacements.every(isReplacement) && + Array.isArray(value.runPayloads) && + value.runPayloads.every((payload: unknown) => typeof payload === 'string') + ) + default: + return false + } +} + +function isErrorData(value: unknown): value is ProfileStateWriterErrorData { + return ( + isRecord(value) && + typeof value.code === 'string' && + typeof value.message === 'string' && + (value.outcome === 'known-failure' || value.outcome === 'indeterminate') && + (value.expectedRevision === undefined || isProfileStateRevision(value.expectedRevision)) && + (value.actualRevision === undefined || isProfileStateRevision(value.actualRevision)) && + (value.domain === undefined || value.domain === null || typeof value.domain === 'string') + ) +} + +export function isProfileStateWriterResponse(value: unknown): value is ProfileStateWriterResponse { + if (!isRecord(value) || !isProfileStateRevision(value.id)) { + return false + } + return value.ok === true + ? isProfileStateRevision(value.revision) && + (value.exportedRevision === undefined || + value.exportedRevision === null || + isProfileStateRevision(value.exportedRevision)) + : value.ok === false && isErrorData(value.error) +} diff --git a/src/main/persistence/profile-state/profile-state-writer-request.ts b/src/main/persistence/profile-state/profile-state-writer-request.ts new file mode 100644 index 00000000000..8db4c4f6580 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-request.ts @@ -0,0 +1,59 @@ +import type { + ProfileStateWriterCommand, + ProfileStateWriterResponse +} from './profile-state-writer-protocol' + +export type SuccessfulProfileStateWriterResponse = Extract +export type PendingProfileStateWriterRequest = { + id: number + command: ProfileStateWriterCommand['command'] | 'initialize' + promise: Promise + resolve: (response: SuccessfulProfileStateWriterResponse) => void + reject: (error: Error) => void + timer: ReturnType +} + +export function isExpectedProfileStateWriterSuccess( + command: PendingProfileStateWriterRequest['command'], + response: SuccessfulProfileStateWriterResponse, + previousRevision: number +): boolean { + const mayWrite = command.startsWith('write-') + if ( + response.revision < previousRevision || + response.revision > previousRevision + (mayWrite ? 1 : 0) + ) { + return false + } + if ( + response.exportedRevision !== undefined && + response.exportedRevision !== null && + response.exportedRevision !== response.revision + ) { + return false + } + if (command === 'export-json') { + return response.exportedRevision !== undefined && response.exportedRevision !== null + } + if (command === 'export-compatibility' || command === 'export-latest') { + return ( + response.exportedRevision !== undefined && + (response.exportedRevision !== null || response.revision === 0) + ) + } + return response.exportedRevision === undefined +} + +export function createProfileStateWriterRequest( + id: number, + command: PendingProfileStateWriterRequest['command'], + timeoutMs: number, + onTimeout: () => void +): PendingProfileStateWriterRequest { + return { + id, + command, + ...Promise.withResolvers(), + timer: setTimeout(onTimeout, timeoutMs) + } +} diff --git a/src/main/persistence/profile-state/profile-state-writer-worker-client.ts b/src/main/persistence/profile-state/profile-state-writer-worker-client.ts new file mode 100644 index 00000000000..215af1f3499 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-worker-client.ts @@ -0,0 +1,75 @@ +import type { ProfileStateDomainReplacement } from '../loading-store/profile-state-authority' +import { ProfileStateWriterConnection } from './profile-state-writer-connection' +export { resolveProfileStateWriterWorkerPath } from './profile-state-writer-worker-path' +export { + ProfileStateWriterError, + profileStateWriterFailureOutcome +} from './profile-state-writer-errors' +export type { ProfileStateWriterInitialization } from './profile-state-writer-protocol' + +export class ProfileStateWriteWorkerClient extends ProfileStateWriterConnection { + assertWritable(): void { + this.assertDispatchable() + } + + writeSerializedState(payload: Buffer): Promise { + return this.dispatch({ command: 'write-state', payload }).then((response) => response.revision) + } + + writeCompleteSerializedDomains( + replacements: readonly ProfileStateDomainReplacement[] + ): Promise { + return this.dispatch({ command: 'write-complete', replacements }).then( + (response) => response.revision + ) + } + + writeSerializedDomains(replacements: readonly ProfileStateDomainReplacement[]): Promise { + return this.dispatch({ command: 'write-domains', replacements }).then( + (response) => response.revision + ) + } + + writeSerializedAutomationRuns( + replacements: readonly ProfileStateDomainReplacement[], + runs: readonly unknown[] + ): Promise { + try { + this.assertDispatchable() + const runPayloads = runs.map((run) => { + const payload = JSON.stringify(run) + if (payload === undefined) { + throw new Error('Automation run is not serializable') + } + return payload + }) + return this.dispatch({ command: 'write-automation', replacements, runPayloads }).then( + (response) => response.revision + ) + } catch (error) { + return Promise.reject(error) + } + } + + assertCurrentRevision(): Promise { + return this.dispatch({ command: 'assert-revision' }).then((response) => response.revision) + } + + writeJsonExport(targetPath: string): Promise { + return this.dispatch({ command: 'export-json', targetPath }).then( + (response) => response.exportedRevision ?? response.revision + ) + } + + writeLatestJsonExport(dataFile: string): Promise { + return this.dispatch({ command: 'export-latest', targetPath: dataFile }).then( + (response) => response.exportedRevision ?? undefined + ) + } + + writeJsonCompatibilityExportAsync(targetPath: string): Promise { + return this.dispatch({ command: 'export-compatibility', targetPath }).then( + (response) => response.exportedRevision ?? undefined + ) + } +} diff --git a/src/main/persistence/profile-state/profile-state-writer-worker-entry.ts b/src/main/persistence/profile-state/profile-state-writer-worker-entry.ts new file mode 100644 index 00000000000..f7917031286 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-worker-entry.ts @@ -0,0 +1,148 @@ +import { parentPort, workerData } from 'node:worker_threads' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { writeVersionedProfileStateExport } from './legacy-json/profile-state-versioned-export' +import { + encodeProfileStateWriterError, + ProfileStateWriterError +} from './profile-state-writer-errors' +import { + isProfileStateWriterInitialization, + isProfileStateWriterRequest, + type ProfileStateWriterRequest, + type ProfileStateWriterResponse +} from './profile-state-writer-protocol' + +if (!parentPort) { + throw new Error('Profile state writer requires a worker thread') +} +const port = parentPort +let authority: ProfileStateSqliteAuthority | undefined +let busy = false +let stopping = false +let previousId = 0 + +function reply(response: ProfileStateWriterResponse): void { + port.postMessage(response) +} + +function close(): void { + stopping = true + try { + authority?.close() + } finally { + port.close() + } +} + +async function execute(request: ProfileStateWriterRequest): Promise { + if (!authority) { + throw new Error('Profile state writer is not initialized') + } + let exportedRevision: number | null | undefined + switch (request.command) { + case 'write-state': + authority.writeSerializedState(Buffer.from(request.payload)) + break + case 'write-complete': + authority.writeCompleteSerializedDomains(request.replacements) + break + case 'write-domains': + authority.writeSerializedDomains(request.replacements) + break + case 'write-automation': + authority.writeSerializedAutomationRuns( + request.replacements, + request.runPayloads.map((payload): unknown => JSON.parse(payload)) + ) + break + case 'assert-revision': + authority.assertCurrentRevision() + break + case 'export-json': + authority.assertCurrentRevision() + exportedRevision = authority.writeJsonExport(request.targetPath) + break + case 'export-latest': + authority.assertCurrentRevision() + exportedRevision = + writeVersionedProfileStateExport( + request.targetPath, + authority.writeJsonExport.bind(authority) + ) ?? null + break + case 'export-compatibility': + authority.assertCurrentRevision() + exportedRevision = + (await authority.writeJsonCompatibilityExportAsync(request.targetPath)) ?? null + break + case 'close': + authority.close() + stopping = true + break + } + return { + id: request.id, + ok: true, + revision: authority.revision, + ...(exportedRevision === undefined ? {} : { exportedRevision }) + } +} + +async function accept(value: unknown): Promise { + if (stopping) { + return + } + if (!isProfileStateWriterRequest(value) || busy || value.id <= previousId) { + stopping = true + reply({ + id: 0, + ok: false, + error: encodeProfileStateWriterError( + new ProfileStateWriterError( + 'profile-state-writer-protocol', + 'Invalid profile state writer request', + 'indeterminate' + ) + ) + }) + if (!busy) { + close() + } + return + } + busy = true + previousId = value.id + try { + reply(await execute(value)) + } catch (error) { + // Export staging accepts both JSON versions; only uncertain SQL outcomes retire the writer. + const failure = encodeProfileStateWriterError(error, value.command === 'close') + reply({ id: value.id, ok: false, error: failure }) + stopping ||= failure.outcome === 'indeterminate' + } finally { + busy = false + if (stopping) { + close() + } + } +} + +try { + const initialization: unknown = workerData + if (!isProfileStateWriterInitialization(initialization)) { + throw new ProfileStateWriterError( + 'profile-state-writer-initialization', + 'Invalid profile state writer initialization', + 'known-failure' + ) + } + authority = new ProfileStateSqliteAuthority(initialization.databasePath, initialization.profileId) + authority.initializeFromRevision(initialization.revision) + reply({ id: 0, ok: true, revision: authority.revision }) + port.on('message', (value: unknown) => { + void accept(value) + }) +} catch (error) { + reply({ id: 0, ok: false, error: encodeProfileStateWriterError(error) }) + close() +} diff --git a/src/main/persistence/profile-state/profile-state-writer-worker-path.ts b/src/main/persistence/profile-state/profile-state-writer-worker-path.ts new file mode 100644 index 00000000000..d1068eb26b0 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-worker-path.ts @@ -0,0 +1,17 @@ +import { existsSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { + currentWorkerEntryLayout, + resolveWorkerThreadEntryPath +} from '../../worker-thread-entry-path' + +export function resolveProfileStateWriterWorkerPath(moduleDir = __dirname): string { + const entry = resolveWorkerThreadEntryPath( + currentWorkerEntryLayout(moduleDir), + 'profile-state-writer-worker-entry.js' + ) + return ( + [entry, join(dirname(entry), '..', 'profile-state-writer-worker-entry.js')].find(existsSync) ?? + entry + ) +} diff --git a/src/main/persistence/profile-state/profile-state-writer-worker.test.ts b/src/main/persistence/profile-state/profile-state-writer-worker.test.ts new file mode 100644 index 00000000000..bcf2f54ee51 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-worker.test.ts @@ -0,0 +1,355 @@ +import { build } from 'esbuild' +import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { ProfileStateRevisionConflictError } from './profile-state-document-validation' +import { openProfileStateDatabase } from './profile-state-database' +import { readProfileStateSnapshot } from './profile-state-documents' +import { + ProfileStateWriteWorkerClient, + profileStateWriterFailureOutcome, + resolveProfileStateWriterWorkerPath +} from './profile-state-writer-worker-client' + +let bundleRoot: string +let workerPath: string +const roots: string[] = [] +const clients: ProfileStateWriteWorkerClient[] = [] + +beforeAll(async () => { + bundleRoot = mkdtempSync(join(tmpdir(), 'orca-writer-bundle-')) + workerPath = join(bundleRoot, 'profile-state-writer-worker-entry.js') + await build({ + entryPoints: [ + resolve('src/main/persistence/profile-state/profile-state-writer-worker-entry.ts') + ], + outfile: workerPath, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) +}) + +afterEach(async () => { + await Promise.all(clients.splice(0).map((client) => client.close().catch(() => {}))) + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) +afterAll(() => rmSync(bundleRoot, { recursive: true, force: true })) + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'orca-writer-')) + roots.push(root) + const path = join(root, 'profile-state.db') + const profileId = 'writer-test' + const db = openProfileStateDatabase(path, profileId) + db.db.close() + const authority = new ProfileStateSqliteAuthority(path, profileId) + authority.readInitialState().takeParsedState?.() + authority.writeCompleteSerializedDomains([{ domain: 'settings', payload: '{"theme":"dark"}' }]) + const initialization = authority.retireForWorker() + return { root, path, profileId, authority, initialization } +} + +function clientFor( + initialization: ReturnType['initialization'], + path = workerPath, + timeoutMs = 5000 +) { + const client = new ProfileStateWriteWorkerClient(initialization, { workerPath: path, timeoutMs }) + clients.push(client) + return client +} + +function readState(path: string, profileId: string): unknown { + const db = openProfileStateDatabase(path, profileId) + try { + return JSON.parse(readProfileStateSnapshot(db.db).json) + } finally { + db.db.close() + } +} + +function script(root: string, source: string): string { + const path = join(root, 'fault-worker.cjs') + writeFileSync(path, source) + return path +} + +describe('persistent profile state write worker', () => { + it('commits full, selective, automation and byte payloads, exports and releases the database', async () => { + const f = fixture() + const client = clientFor(f.initialization) + await client.ready + expect( + await client.writeSerializedDomains([{ domain: 'ui', payload: '{"note":"hi \\ud800"}' }]) + ).toBe(2) + const run = { + id: 'run-1', + output: 'first', + toJSON() { + return { id: this.id, output: this.output } + } + } + const write = client.writeSerializedAutomationRuns([], [run]) + run.output = 'changed after capture' + expect(await write).toBe(3) + const exported = join(f.root, 'state.json') + expect(await client.writeJsonExport(exported)).toBe(3) + expect(JSON.parse(readFileSync(exported, 'utf8'))).toEqual({ + settings: { theme: 'dark' }, + ui: { note: 'hi \ud800' }, + automationRuns: [{ id: 'run-1', output: 'first' }] + }) + const compatibility = join(f.root, 'compatibility.json') + expect(await client.writeJsonCompatibilityExportAsync(compatibility)).toBe(3) + expect(readFileSync(compatibility, 'utf8')).toBe(readFileSync(exported, 'utf8')) + expect(await client.writeSerializedState(Buffer.from('{"settings":{"theme":"light"}}'))).toBe(4) + expect(await client.assertCurrentRevision()).toBe(4) + await client.close() + await client.close() + expect(readState(f.path, f.profileId)).toEqual({ settings: { theme: 'light' } }) + rmSync(f.root, { recursive: true }) + expect(existsSync(f.root)).toBe(false) + }) + + it('requires a present admitted database and refuses startup revision races', async () => { + const f = fixture() + const peer = new ProfileStateSqliteAuthority(f.path, f.profileId) + peer.readInitialState() + peer.writeSerializedDomains([{ domain: 'settings', payload: '{"peer":true}' }]) + peer.close() + const client = clientFor(f.initialization) + await expect(client.ready).rejects.toBeInstanceOf(ProfileStateRevisionConflictError) + await client.close() + expect(readState(f.path, f.profileId)).toEqual({ settings: { peer: true } }) + const missing = clientFor({ ...f.initialization, databasePath: join(f.root, 'missing.db') }) + await expect(missing.ready).rejects.toMatchObject({ code: 'unreadable' }) + await missing.close() + expect(existsSync(join(f.root, 'missing.db'))).toBe(false) + }) + + it('permanently retires bootstrap authority and refuses subsequent calls', () => { + const f = fixture() + for (const call of [ + () => f.authority.readInitialState(), + () => f.authority.readAcceptedState('{}'), + () => f.authority.readSerializedState(), + () => f.authority.writeSerializedDomains([]), + () => f.authority.writeCompleteSerializedDomains([]), + () => f.authority.writeSerializedAutomationRuns([], []), + () => f.authority.writeSerializedState(Buffer.from('{}')), + () => f.authority.writeJsonExport(join(f.root, 'export.json')), + () => f.authority.scheduleBackup(), + () => f.authority.assertCurrentRevision(), + () => f.authority.initializeFromRevision(1), + () => f.authority.retireForWorker(), + () => f.authority.close() + ]) { + expect(call).toThrow(/retired/) + } + }) + + it('preserves known validation failures and rejects a peer at the no-op fence', async () => { + const f = fixture() + const client = clientFor(f.initialization) + await client.ready + await expect( + client.writeSerializedDomains([{ domain: 'settings', payload: '{' }]) + ).rejects.toMatchObject({ code: 'profile-state-write-failed', outcome: 'known-failure' }) + expect(await client.assertCurrentRevision()).toBe(1) + const peer = new ProfileStateSqliteAuthority(f.path, f.profileId) + peer.readInitialState() + peer.writeSerializedDomains([{ domain: 'ui', payload: '{"peer":true}' }]) + peer.close() + const failure = await client.assertCurrentRevision().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(ProfileStateRevisionConflictError) + expect(profileStateWriterFailureOutcome(failure)).toBe('known-failure') + await expect( + client.writeSerializedDomains([{ domain: 'settings', payload: '{}' }]) + ).rejects.toBeInstanceOf(ProfileStateRevisionConflictError) + }) + + it('rejects a second materialized write and waits for the first before close', async () => { + const f = fixture() + const delayed = script( + f.root, + ` + const { MessagePort } = require('node:worker_threads') + const send = MessagePort.prototype.postMessage + MessagePort.prototype.postMessage = function(value, ...rest) { + if (value?.id === 1 && value.ok) { setTimeout(() => send.call(this, value, ...rest), 60); return } + return send.call(this, value, ...rest) + } + require(${JSON.stringify(workerPath)}) + ` + ) + const client = clientFor(f.initialization, delayed) + await client.ready + const first = client.writeSerializedDomains([{ domain: 'settings', payload: '{"first":true}' }]) + await expect( + client.writeSerializedDomains([{ domain: 'settings', payload: '{"second":true}' }]) + ).rejects.toMatchObject({ code: 'profile-state-writer-busy' }) + const closing = client.close() + expect(await first).toBe(2) + await closing + expect(readState(f.path, f.profileId)).toEqual({ settings: { first: true } }) + await expect(client.assertCurrentRevision()).rejects.toMatchObject({ + code: 'profile-state-writer-closed' + }) + }) + + it('waits for actual worker exit after receiving its close acknowledgement', async () => { + const f = fixture() + const marker = join(f.root, 'worker-exited.txt') + const delayedExit = script( + f.root, + ` + const { MessagePort } = require('node:worker_threads') + const send = MessagePort.prototype.postMessage + MessagePort.prototype.postMessage = function(value, ...rest) { + if (value?.id === 1 && value.ok) { + setTimeout(() => require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'released'), 60) + } + return send.call(this, value, ...rest) + } + require(${JSON.stringify(workerPath)}) + ` + ) + const client = clientFor(f.initialization, delayedExit) + await client.ready + await client.close() + expect(readFileSync(marker, 'utf8')).toBe('released') + }) + + it('reports post-commit worker death as indeterminate without replaying the committed write', async () => { + const f = fixture() + const crashAfterCommit = script( + f.root, + ` + const { MessagePort } = require('node:worker_threads') + const send = MessagePort.prototype.postMessage + MessagePort.prototype.postMessage = function(value, ...rest) { + if (value?.id === 1 && value.ok) process.exit(13) + return send.call(this, value, ...rest) + } + require(${JSON.stringify(workerPath)}) + ` + ) + const client = clientFor(f.initialization, crashAfterCommit) + await client.ready + const failure = await client + .writeSerializedDomains([{ domain: 'settings', payload: '{"committed":true}' }]) + .catch((error: unknown) => error) + expect(profileStateWriterFailureOutcome(failure)).toBe('indeterminate') + await expect(client.assertCurrentRevision()).rejects.toBe(failure) + await client.close() + expect(readState(f.path, f.profileId)).toEqual({ settings: { committed: true } }) + }) + + it.each(['mismatch', 'timeout', 'exit'])( + 'fails closed after a dispatched %s and awaits actual exit', + async (mode) => { + const f = fixture() + const broken = script( + f.root, + ` + const { parentPort } = require('node:worker_threads') + parentPort.postMessage({ id: 0, ok: true, revision: 1 }) + parentPort.on('message', request => { + if (${JSON.stringify(mode)} === 'exit') process.exit(0) + if (${JSON.stringify(mode)} === 'mismatch') parentPort.postMessage({ id: request.id + 1, ok: true, revision: 2 }) + }) + ` + ) + const client = clientFor(f.initialization, broken) + await client.ready + if (mode === 'timeout') { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + } + try { + const failure = client + .writeSerializedDomains([{ domain: 'settings', payload: '{}' }]) + .catch((error: unknown) => error) + if (mode === 'timeout') { + await vi.advanceTimersByTimeAsync(5000) + } + const observedFailure = await failure + expect(profileStateWriterFailureOutcome(observedFailure)).toBe('indeterminate') + if (mode === 'timeout') { + expect(observedFailure).toMatchObject({ code: 'profile-state-writer-timeout' }) + } + } finally { + vi.useRealTimers() + } + await client.close() + expect(readState(f.path, f.profileId)).toEqual({ settings: { theme: 'dark' } }) + rmSync(f.root, { recursive: true }) + } + ) + + it('recovers the prior committed revision after the worker exits inside a transaction', async () => { + const f = fixture() + const interruptedPath = join(f.root, 'interrupted-worker.cjs') + await build({ + entryPoints: [ + resolve('src/main/persistence/profile-state/profile-state-writer-worker-entry.ts') + ], + outfile: interruptedPath, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent', + plugins: [ + { + name: 'interrupt-transaction', + setup(builder) { + builder.onLoad({ filter: /profile-state-write-transaction\.ts$/ }, (args) => ({ + contents: readFileSync(args.path, 'utf8').replace( + "db.exec('COMMIT')", + 'process.exit(17)' + ), + loader: 'ts' + })) + } + } + ] + }) + const client = clientFor(f.initialization, interruptedPath) + await client.ready + const failure = await client + .writeSerializedDomains([{ domain: 'settings', payload: '{"uncommitted":true}' }]) + .catch((error: unknown) => error) + expect(profileStateWriterFailureOutcome(failure)).toBe('indeterminate') + await client.close() + expect(readState(f.path, f.profileId)).toEqual({ settings: { theme: 'dark' } }) + }) + + it('aborts an active request without treating the pending write as rolled back', async () => { + const f = fixture() + const hung = script( + f.root, + ` + const { parentPort } = require('node:worker_threads') + parentPort.postMessage({ id: 0, ok: true, revision: 1 }) + parentPort.on('message', () => {}) + ` + ) + const client = clientFor(f.initialization, hung) + await client.ready + const write = client.writeSerializedDomains([{ domain: 'settings', payload: '{}' }]) + const failed = expect(write).rejects.toMatchObject({ outcome: 'indeterminate' }) + await client.abort() + await failed + await client.close() + }) + + it('resolves flat and shared-chunk entry layouts', () => { + expect(resolveProfileStateWriterWorkerPath(bundleRoot)).toBe(workerPath) + expect(resolveProfileStateWriterWorkerPath(join(bundleRoot, 'chunks'))).toBe(workerPath) + }) +}) diff --git a/src/main/persistence/restoring-sessions/pane-identity-migration.ts b/src/main/persistence/restoring-sessions/pane-identity-migration.ts index 98d97a649a3..6cd2bbf129f 100644 --- a/src/main/persistence/restoring-sessions/pane-identity-migration.ts +++ b/src/main/persistence/restoring-sessions/pane-identity-migration.ts @@ -1,8 +1,8 @@ +import type { ProfileStateStartupPaneAlias } from '../loading-store/profile-state-authority' import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' -import { agentHookServer } from '../../agent-hooks/server' import { collectLayoutLeafIdsInOrder, firstLayoutLeafId } from './terminal-layout-normalization' export function findWorktreeIdForTab( @@ -67,47 +67,49 @@ export function createLazyTerminalTabLookup(session: WorkspaceSessionState): Ter } } -/** Bridges a tab's legacy numeric pane keys to stable ones; returns the alias rows worth persisting. */ -export function registerLegacyPaneKeyAliasesForTab(args: { +export type PaneAliasNormalizationOptions = { + registerAliases?: boolean + collectUnboundPaneAlias?: (entry: ProfileStateStartupPaneAlias) => void +} + +type LegacyPaneKeyAlias = Omit & { ptyId?: string } + +/** Includes unbound aliases needed by the live hook server, even though they are not persisted. */ +export function collectLegacyPaneKeyAliasesForTab(args: { tabId: string tab: TerminalTab | undefined inputLayout: TerminalLayoutSnapshot normalizedLayout: TerminalLayoutSnapshot leafIdByInputLeafId: Map -}): LegacyPaneKeyAliasEntry[] { - const legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = [] +}): LegacyPaneKeyAlias[] { + const legacyPaneKeyAliasEntries: LegacyPaneKeyAlias[] = [] const registeredLegacyPaneKeys = new Set() const hasLeafPtyBindings = Object.keys(args.inputLayout.ptyIdsByLeafId ?? {}).length > 0 const fallbackPtyId = !hasLeafPtyBindings && typeof args.tab?.ptyId === 'string' ? args.tab.ptyId : undefined - const registerLegacyAlias = (inputLeafId: string, leafId: string, ptyId?: string): boolean => { + const collectLegacyAlias = (inputLeafId: string, leafId: string, ptyId?: string): void => { if (!isTerminalLeafId(leafId)) { - return false + return } let paneKey: string try { paneKey = makePaneKey(args.tabId, leafId) } catch { - return false + return } const numeric = /^(?:pane:)?(\d+)$/.exec(inputLeafId)?.[1] if (!numeric) { - return false + return } // Why: PaneManager ids are 1-based; a zero-based alias in split layouts makes tab:1 ambiguous and misroutes panes. const legacyPaneKey = `${args.tabId}:${numeric}` - agentHookServer.registerPaneKeyAlias(legacyPaneKey, paneKey, ptyId) registeredLegacyPaneKeys.add(legacyPaneKey) - if (ptyId) { - legacyPaneKeyAliasEntries.push({ - ptyId, - legacyPaneKey, - stablePaneKey: paneKey, - updatedAt: Date.now() - }) - return true - } - return false + legacyPaneKeyAliasEntries.push({ + ptyId, + legacyPaneKey, + stablePaneKey: paneKey, + updatedAt: Date.now() + }) } const inputLeafIds = new Set([ ...collectLayoutLeafIdsInOrder(args.inputLayout.root), @@ -119,7 +121,7 @@ export function registerLegacyPaneKeyAliasesForTab(args: { } const leafId = args.leafIdByInputLeafId.get(inputLeafId) if (leafId) { - registerLegacyAlias( + collectLegacyAlias( inputLeafId, leafId, args.inputLayout.ptyIdsByLeafId?.[inputLeafId] ?? fallbackPtyId @@ -142,7 +144,6 @@ export function registerLegacyPaneKeyAliasesForTab(args: { if (registeredLegacyPaneKeys.has(legacyPaneKey)) { continue } - agentHookServer.registerPaneKeyAlias(legacyPaneKey, paneKey, args.tab.ptyId) legacyPaneKeyAliasEntries.push({ ptyId: args.tab.ptyId, legacyPaneKey, diff --git a/src/main/persistence/restoring-sessions/session-owner-removal.ts b/src/main/persistence/restoring-sessions/session-owner-removal.ts index 81b18ae2fe6..5fac53546c4 100644 --- a/src/main/persistence/restoring-sessions/session-owner-removal.ts +++ b/src/main/persistence/restoring-sessions/session-owner-removal.ts @@ -8,9 +8,9 @@ import { import { workspaceSessionPartitionHostId } from '../../../shared/workspace-session-partition-owner' import { cloneWorkspaceSessionState, deleteOwnerKeyedSessionFields } from './session-owner-fields' -// Scans the pane-key-keyed maps and the shutdown list once, removing every entry -// owned by a key matched by `isRemovedOwner` (or, for pty incarnations, whose tab -// was removed). Kept separate from the O(1) deletes so a batch prune scans each +// Scans the pane-key-keyed maps, the close records and the shutdown list once, removing +// every entry owned by a key matched by `isRemovedOwner` (or, for pty incarnations, whose +// tab was removed). Kept separate from the O(1) deletes so a batch prune scans each // collection a single time regardless of how many owners are being removed. export function deleteScannedSessionFieldsForOwners( next: WorkspaceSessionState, @@ -39,6 +39,14 @@ export function deleteScannedSessionFieldsForOwners( } } } + // Why: a close record answers for its workspace; once the workspace's rows go, so does it. + if (next.closedTerminalTabTombstonesByTabId) { + for (const [tabId, record] of Object.entries(next.closedTerminalTabTombstonesByTabId)) { + if (isRemovedOwner(record.worktreeId)) { + delete next.closedTerminalTabTombstonesByTabId[tabId] + } + } + } next.activeWorktreeIdsOnShutdown = next.activeWorktreeIdsOnShutdown?.filter( (worktreeId) => !isRemovedOwner(worktreeId) ) diff --git a/src/main/persistence/restoring-sessions/terminal-layout-normalization.ts b/src/main/persistence/restoring-sessions/terminal-layout-normalization.ts index c2cd72247f6..551d57e6e35 100644 --- a/src/main/persistence/restoring-sessions/terminal-layout-normalization.ts +++ b/src/main/persistence/restoring-sessions/terminal-layout-normalization.ts @@ -218,6 +218,10 @@ export function normalizeTerminalLayoutSnapshotForPersistence( inputSnapshot.expandedLeafId && !duplicatedInputLeafIds.has(inputSnapshot.expandedLeafId) ? (leafIdByInputLeafId.get(inputSnapshot.expandedLeafId) ?? null) : null + const chatLeafId = + inputSnapshot.chatLeafId && !duplicatedInputLeafIds.has(inputSnapshot.chatLeafId) + ? (leafIdByInputLeafId.get(inputSnapshot.chatLeafId) ?? null) + : null const ptyIdsByLeafId = remapLeafRecordForPersistence( inputSnapshot.ptyIdsByLeafId, leafIdByInputLeafId, @@ -244,7 +248,9 @@ export function normalizeTerminalLayoutSnapshotForPersistence( !leafRecordEquivalent(inputSnapshot.scrollbackRefsByLeafId, scrollbackRefsByLeafId) || !leafRecordEquivalent(inputSnapshot.titlesByLeafId, titlesByLeafId) const metadataChanged = - activeLeafId !== inputSnapshot.activeLeafId || expandedLeafId !== inputSnapshot.expandedLeafId + activeLeafId !== inputSnapshot.activeLeafId || + expandedLeafId !== inputSnapshot.expandedLeafId || + chatLeafId !== (inputSnapshot.chatLeafId ?? null) if (!changed && !recordsChanged && !metadataChanged) { return { snapshot, changed: false, leafIdByInputLeafId } } @@ -253,6 +259,7 @@ export function normalizeTerminalLayoutSnapshotForPersistence( buffersByLeafId: _oldBuffersByLeafId, scrollbackRefsByLeafId: _oldScrollbackRefsByLeafId, titlesByLeafId: _oldTitlesByLeafId, + chatLeafId: _oldChatLeafId, ...snapshotWithoutLeafRecords } = inputSnapshot return { @@ -261,6 +268,7 @@ export function normalizeTerminalLayoutSnapshotForPersistence( root, activeLeafId, expandedLeafId, + ...(chatLeafId ? { chatLeafId } : {}), ...(ptyIdsByLeafId ? { ptyIdsByLeafId } : {}), ...(buffersByLeafId ? { buffersByLeafId } : {}), ...(scrollbackRefsByLeafId ? { scrollbackRefsByLeafId } : {}), diff --git a/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts b/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts index cfc26c7bb8a..38ec014c7c9 100644 --- a/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts +++ b/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts @@ -2,6 +2,7 @@ import type { LegacyPaneKeyAliasEntry, PersistedState } from '../../../shared/pe import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import type { MigrationUnsupportedPtyEntry } from '../../../shared/agent-status-types' +import { agentHookServer } from '../../agent-hooks/server' import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId, @@ -12,7 +13,8 @@ import { isTerminalLeafId, parsePaneKey } from '../../../shared/stable-pane-id' import { findCrossHostPaneTabIds, withoutPaneTabIds } from './cross-host-pane-tab-ids' import { createLazyTerminalTabLookup, - registerLegacyPaneKeyAliasesForTab + collectLegacyPaneKeyAliasesForTab, + type PaneAliasNormalizationOptions } from './pane-identity-migration' import { normalizeTerminalLayoutSnapshotForPersistence } from './terminal-layout-normalization' import { @@ -37,7 +39,7 @@ export { export function normalizeWorkspaceSessionPaneIdentities( session: WorkspaceSessionState, priorLayoutsByTabId: Record = {}, - options: { skipAliasTabIds?: ReadonlySet } = {} + options: PaneAliasNormalizationOptions & { skipAliasTabIds?: ReadonlySet } = {} ): { session: WorkspaceSessionState changed: boolean @@ -49,9 +51,6 @@ export function normalizeWorkspaceSessionPaneIdentities( let changed = false const leafIdByInputLeafIdByTabId = new Map>() const leafIdByPtyIdByTabId = new Map>() - // Why always empty: legacy numeric pane keys are bridged by aliases now, not persisted as - // restart-required rows; the field stays so callers keep clearing stale rows written by old builds. - const migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] = [] const legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = [] const terminalLayoutsByTabId: Record = {} let tabsById: ReturnType | null = null @@ -64,7 +63,7 @@ export function normalizeWorkspaceSessionPaneIdentities( leafIdByInputLeafIdByTabId.set(tabId, normalized.leafIdByInputLeafId) if (!options.skipAliasTabIds?.has(tabId)) { tabsById ??= createLazyTerminalTabLookup(session) - const tabAliasEntries = registerLegacyPaneKeyAliasesForTab({ + const tabAliasEntries = collectLegacyPaneKeyAliasesForTab({ tabId, tab: tabsById.get(tabId), inputLayout: layout, @@ -73,7 +72,18 @@ export function normalizeWorkspaceSessionPaneIdentities( }) // Why: old split layouts can generate enough alias rows to exceed V8's argument limit if spread into push(). for (const entry of tabAliasEntries) { - legacyPaneKeyAliasEntries.push(entry) + if (options.registerAliases !== false) { + agentHookServer.registerPaneKeyAlias( + entry.legacyPaneKey, + entry.stablePaneKey, + entry.ptyId + ) + } + if (entry.ptyId) { + legacyPaneKeyAliasEntries.push({ ...entry, ptyId: entry.ptyId }) + } else { + options.collectUnboundPaneAlias?.(entry) + } } } const leafIdByPtyId = new Map() @@ -97,7 +107,8 @@ export function normalizeWorkspaceSessionPaneIdentities( changed, leafIdByInputLeafIdByTabId, leafIdByPtyIdByTabId, - migrationUnsupportedEntries, + // Aliases replace old restart-required rows; callers still clear that legacy field. + migrationUnsupportedEntries: [], legacyPaneKeyAliasEntries } } @@ -163,7 +174,10 @@ function mergeAcknowledgementLeafIdMapsByTabId( return merged } -export function normalizePersistedPaneIdentityState(state: PersistedState): { +export function normalizePersistedPaneIdentityState( + state: PersistedState, + options: PaneAliasNormalizationOptions = {} +): { state: PersistedState changed: boolean migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] @@ -174,6 +188,7 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): { state.workspaceSession, {}, { + ...options, skipAliasTabIds: crossHostTabIds } ) @@ -200,6 +215,7 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): { hostSession, {}, { + ...options, skipAliasTabIds: crossHostTabIds } ) diff --git a/src/main/persistence/restoring-sessions/workspace-session-write-rollback.ts b/src/main/persistence/restoring-sessions/workspace-session-write-rollback.ts new file mode 100644 index 00000000000..bdca21f7bbe --- /dev/null +++ b/src/main/persistence/restoring-sessions/workspace-session-write-rollback.ts @@ -0,0 +1,132 @@ +import { isDeepStrictEqual } from 'node:util' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' + +const MISSING = Symbol('missing') + +/** A JSON-shaped slot of persisted session state, or the absent-key sentinel. */ +type RollbackSlot = + | string + | number + | boolean + | null + | undefined + | typeof MISSING + | readonly RollbackSlot[] + | RollbackRecord + +type RollbackRecord = { readonly [key: string]: RollbackSlot } + +function isRecord(value: RollbackSlot): value is RollbackRecord { + return ( + value !== MISSING && + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ) +} + +type IdentifiedRecord = RollbackRecord & { readonly id: string } + +function identifiedRows(value: RollbackSlot): readonly IdentifiedRecord[] | null { + if (value === MISSING) { + return [] + } + if ( + !Array.isArray(value) || + !value.every((row): row is IdentifiedRecord => isRecord(row) && typeof row.id === 'string') + ) { + return null + } + return new Set(value.map((row) => row.id)).size === value.length ? value : null +} + +function rollbackIdentifiedRows( + original: RollbackSlot, + staged: RollbackSlot, + current: RollbackSlot +): readonly RollbackSlot[] | null { + if (!Array.isArray(original) && !Array.isArray(staged) && !Array.isArray(current)) { + return null + } + const before = identifiedRows(original) + const written = identifiedRows(staged) + const latest = identifiedRows(current) + if (!before || !written || !latest) { + return null + } + const beforeById = new Map(before.map((row) => [row.id, row])) + const writtenById = new Map(written.map((row) => [row.id, row])) + const latestById = new Map(latest.map((row) => [row.id, row])) + const restored = new Map() + for (const id of new Set([...beforeById.keys(), ...writtenById.keys(), ...latestById.keys()])) { + const value = rollbackValue( + beforeById.get(id) ?? MISSING, + writtenById.get(id) ?? MISSING, + latestById.get(id) ?? MISSING + ) + if (value !== MISSING) { + restored.set(id, value) + } + } + const order = latest.map((row) => row.id).filter((id) => restored.has(id)) + for (const [index, row] of before.entries()) { + if (restored.has(row.id) && !order.includes(row.id)) { + order.splice(Math.min(index, order.length), 0, row.id) + } + } + return order.map((id) => restored.get(id)) +} + +function rollbackValue( + original: RollbackSlot, + staged: RollbackSlot, + current: RollbackSlot +): RollbackSlot { + if (isDeepStrictEqual(original, staged)) { + return current + } + if (isDeepStrictEqual(current, staged)) { + return original + } + // Terminal and unified tab rows have stable ids; unrelated row edits must survive rollback. + const rows = rollbackIdentifiedRows(original, staged, current) + if (rows) { + return rows + } + if (!isRecord(original) || !isRecord(staged) || !isRecord(current)) { + return current + } + let changed = false + const next: Record = { ...current } + for (const key of new Set([ + ...Object.keys(original), + ...Object.keys(staged), + ...Object.keys(current) + ])) { + const value = rollbackValue( + Object.hasOwn(original, key) ? original[key] : MISSING, + Object.hasOwn(staged, key) ? staged[key] : MISSING, + Object.hasOwn(current, key) ? current[key] : MISSING + ) + if (value === MISSING) { + if (Object.hasOwn(next, key)) { + delete next[key] + changed = true + } + } else if (!Object.hasOwn(current, key) || !isDeepStrictEqual(current[key], value)) { + next[key] = value + changed = true + } + } + return changed ? next : current +} + +export function rollbackWorkspaceSessionAfterFailedAsyncWrite( + original: WorkspaceSessionState, + staged: WorkspaceSessionState, + current: WorkspaceSessionState +): WorkspaceSessionState { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Each restored field retains a value from the same field of a typed session. + return rollbackValue(original, staged, current) as WorkspaceSessionState +} diff --git a/src/main/persistence/runtime-authored-workspace-session-fields.ts b/src/main/persistence/runtime-authored-workspace-session-fields.ts index e87b47cd535..80a9d30b532 100644 --- a/src/main/persistence/runtime-authored-workspace-session-fields.ts +++ b/src/main/persistence/runtime-authored-workspace-session-fields.ts @@ -4,9 +4,9 @@ import type { WorkspaceSessionState } from '../../shared/workspace-session-state * Keeps session fields the renderer persist snapshot does not author across a full write. * * A session write replaces the stored object. Zustand-built payloads omit runtime-owned - * client-hosted pages, and they omit-when-empty the write-once default-terminal-tab marker. - * Without this, those slices vanish on the next desktop write and only show up missing after - * restart. + * client-hosted pages and terminal close records, and they omit-when-empty the write-once + * default-terminal-tab marker. Without this, those slices vanish on the next desktop write and + * only show up missing after restart. * * Callers do not opt in: the Store applies this inside setLocalWorkspaceSession and * setHostWorkspaceSession, so the before-unload stage path inherits it too. @@ -44,6 +44,16 @@ export function preserveRuntimeAuthoredWorkspaceSessionFields( clientHostedBrowserPagesByWorktree: prior.clientHostedBrowserPagesByWorktree } } + // Why: close records are main's alone (its close transaction); a renderer save never carries them. + if ( + next.closedTerminalTabTombstonesByTabId === undefined && + prior?.closedTerminalTabTombstonesByTabId !== undefined + ) { + result = { + ...result, + closedTerminalTabTombstonesByTabId: prior.closedTerminalTabTombstonesByTabId + } + } // Why union: persist snapshots omit this write-once map (empty Zustand slice, omit-when-empty // payload). Treating omission as "never applied" re-spawns default terminals on every attach. return unionWriteOnceDefaultTerminalTabsApplied(result, prior) diff --git a/src/main/persistence/scheduling-automations/automation-definition-operations.ts b/src/main/persistence/scheduling-automations/automation-definition-operations.ts index c25315e4986..1192b7aaf39 100644 --- a/src/main/persistence/scheduling-automations/automation-definition-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-definition-operations.ts @@ -3,6 +3,7 @@ import { invalidateLocalWorktreeMetadataPruneInputs } from '../../local-worktree import type { Automation, AutomationCreateInput, + AutomationRun, AutomationUpdateInput } from '../../../shared/automations-types' import type { PersistedState } from '../../../shared/persisted-state-types' @@ -38,6 +39,7 @@ export type AutomationDefinitionOperations = { storageAuthority: AutomationStorageAuthority flush: () => void recordCreated: () => void + recordAutomationRunsMutation?: (runs: readonly AutomationRun[]) => void } export function listAutomations(state: PersistedState): Automation[] { @@ -263,6 +265,7 @@ export function deleteAutomation( operations.state.automationRuns = (operations.state.automationRuns ?? []).filter( (entry) => entry.automationId !== id ) + operations.recordAutomationRunsMutation?.(operations.state.automationRuns) // Why: the automation and its unfinished runs were pinning their workspace; both are gone (#17775). invalidateLocalWorktreeMetadataPruneInputs() operations.flush() diff --git a/src/main/persistence/scheduling-automations/automation-run-operations.ts b/src/main/persistence/scheduling-automations/automation-run-operations.ts index 0dc9e61731a..7415fd12cf1 100644 --- a/src/main/persistence/scheduling-automations/automation-run-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-run-operations.ts @@ -28,6 +28,7 @@ import { export type AutomationRunOperations = { state: PersistedState flush: () => void + recordAutomationRunsMutation?: (runs: readonly AutomationRun[]) => void recordManualRun: () => void getWorkspaceDisplayName: (workspaceId: string | null | undefined) => string | null } @@ -110,6 +111,7 @@ export function createAutomationRun( ...(operations.state.automationRuns ?? []), run ]) + operations.recordAutomationRunsMutation?.(operations.state.automationRuns ?? []) if (trigger === 'manual') { operations.recordManualRun() } @@ -149,6 +151,7 @@ export function recordRepeatedAutomationSkip( } // Replaced, not patched in place: the list projection caches on array identity. operations.state.automationRuns = runs.map((run) => (run.id === latest.id ? updated : run)) + operations.recordAutomationRunsMutation?.(operations.state.automationRuns) touchAutomation(operations.state, automationId, now) operations.flush() return updated @@ -202,6 +205,7 @@ export function updateAutomationRun( operations.state.automationRuns = operations.state.automationRuns.map((run) => run.id === result.runId ? updated : run ) + operations.recordAutomationRunsMutation?.(operations.state.automationRuns) if (!isFinalAutomationRunStatus(current.status) && isFinalAutomationRunStatus(updated.status)) { // Why: only a non-final run pins its workspace, so finishing releases the claim (#17775). invalidateLocalWorktreeMetadataPruneInputs() @@ -229,6 +233,7 @@ export function snapshotAutomationRunWorkspaceDisplayName( return { ...run, workspaceDisplayName: normalizedDisplayName } }) if (updatedCount > 0) { + operations.recordAutomationRunsMutation?.(operations.state.automationRuns ?? []) operations.flush() } return updatedCount diff --git a/src/main/persistence/tracking-repos/deregistered-repo-residue.ts b/src/main/persistence/tracking-repos/deregistered-repo-residue.ts index cf97adc11ae..e26d43c6cc7 100644 --- a/src/main/persistence/tracking-repos/deregistered-repo-residue.ts +++ b/src/main/persistence/tracking-repos/deregistered-repo-residue.ts @@ -142,6 +142,9 @@ export function collectDeregisteredRepoIds(state: PersistedState): Set { for (const tombstone of Object.values(session.terminalSurfaceTombstonesByPaneKey ?? {})) { addWorktreeId(tombstone.worktreeId) } + for (const record of Object.values(session.closedTerminalTabTombstonesByTabId ?? {})) { + addWorktreeId(record.worktreeId) + } } return orphanRepoIds } diff --git a/src/main/persistence/tracking-repos/repo-update-host-guard.test.ts b/src/main/persistence/tracking-repos/repo-update-host-guard.test.ts new file mode 100644 index 00000000000..101cbc2e2b2 --- /dev/null +++ b/src/main/persistence/tracking-repos/repo-update-host-guard.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../shared/repo-types' +import { RepoUpdatePersistenceOperations } from './repo-update-operations' + +function makeRepo(overrides: Partial & Pick): Repo { + return { + path: '/repos/example', + displayName: 'Example', + badgeColor: '#000000', + addedAt: 0, + kind: 'git', + ...overrides + } +} + +function makeOperations(repos: Repo[]) { + const state = { repos, projectGroups: [] } + const scheduleSave = vi.fn() + const operations = new RepoUpdatePersistenceOperations({ + state, + bumpLocalWorktreeScanGeneration: vi.fn(), + syncProjectHostSetupCompatibilityState: vi.fn(), + scheduleSave, + hydrateRepo: (repo) => repo + }) + return { operations, scheduleSave, state } +} + +describe('updateRepo host guard', () => { + let errors: string[] + + beforeEach(() => { + errors = [] + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + errors.push(args.map(String).join(' ')) + }) + }) + + it('reports a host mismatch instead of returning the same silent null as a missing row', () => { + // #22421's exact shape: the row exists under `runtime:env-1`, the caller addressed the probe host. + const { operations, scheduleSave } = makeOperations([ + makeRepo({ id: 'repo-1', executionHostId: 'runtime:env-1' }) + ]) + + const result = operations.updateRepo('repo-1', { displayName: 'Renamed' }, 'local') + + expect(result).toBeNull() + expect(scheduleSave).not.toHaveBeenCalled() + expect(errors).toHaveLength(1) + expect(errors[0]).toContain('repo-1') + expect(errors[0]).toContain('requested host local') + expect(errors[0]).toContain('runtime:env-1') + }) + + it('stays silent when the row genuinely does not exist', () => { + const { operations } = makeOperations([ + makeRepo({ id: 'repo-1', executionHostId: 'runtime:env-1' }) + ]) + + expect(operations.updateRepo('repo-missing', { displayName: 'Renamed' }, 'local')).toBeNull() + expect(errors).toEqual([]) + }) + + it('writes when the host argument matches the row stamp', () => { + const { operations, scheduleSave, state } = makeOperations([ + makeRepo({ id: 'repo-1', executionHostId: 'runtime:env-1' }) + ]) + + const result = operations.updateRepo('repo-1', { displayName: 'Renamed' }, 'runtime:env-1') + + expect(result?.displayName).toBe('Renamed') + expect(state.repos[0].displayName).toBe('Renamed') + expect(scheduleSave).toHaveBeenCalledTimes(1) + expect(errors).toEqual([]) + }) + + it('still refuses a cross-host write when both hosts hold a row with the same id', () => { + const local = makeRepo({ id: 'repo-1', displayName: 'Local', executionHostId: 'local' }) + const peer = makeRepo({ id: 'repo-1', displayName: 'Peer', executionHostId: 'ssh:peer' }) + const { operations, state } = makeOperations([local, peer]) + + const result = operations.updateRepo('repo-1', { displayName: 'Local edit' }, 'local') + + expect(result?.displayName).toBe('Local edit') + // The peer's row is untouched: a client-local write must never repair another host's metadata. + expect(state.repos[1].displayName).toBe('Peer') + }) + + it('writes without a host argument exactly as before', () => { + const { operations, state } = makeOperations([ + makeRepo({ id: 'repo-1', executionHostId: 'runtime:env-1' }) + ]) + + expect(operations.updateRepo('repo-1', { displayName: 'Renamed' })?.displayName).toBe('Renamed') + expect(state.repos[0].displayName).toBe('Renamed') + expect(errors).toEqual([]) + }) +}) diff --git a/src/main/persistence/tracking-repos/repo-update-operations.ts b/src/main/persistence/tracking-repos/repo-update-operations.ts index 399d05ac653..79444a32a19 100644 --- a/src/main/persistence/tracking-repos/repo-update-operations.ts +++ b/src/main/persistence/tracking-repos/repo-update-operations.ts @@ -10,17 +10,50 @@ import { invalidateGhAccountTokenCache } from '../../github/gh-account-token' import { sanitizeRepoUpdatesForPersistence } from './repo-sanitization' export type RepoUpdateMutationOperations = { - state: PersistedState + state: Pick bumpLocalWorktreeScanGeneration: (repoId: string) => void syncProjectHostSetupCompatibilityState: () => void scheduleSave: () => void hydrateRepo: (repo: Repo) => Repo } +/** + * Resolve the row a host-scoped write may touch, and report the one failure the `Repo | null` return + * cannot express: the row exists, but under a different host stamp. + * + * `hostId` is the row's own `executionHostId`, never the host a caller probed or a user selected. A + * caller that passes anything else gets the same `null` as a deleted row, so its write is discarded + * with no error and no failing test — how #22421 shipped an enrichment pass that never persisted. + */ +export function findRepoRowForHostScopedWrite( + repos: readonly Repo[], + id: string, + hostId: ExecutionHostId | undefined +): Repo | undefined { + if (!hostId) { + return repos.find((candidate) => candidate.id === id) + } + const matched = repos.find( + (candidate) => candidate.id === id && getRepoExecutionHostId(candidate) === hostId + ) + if (matched) { + return matched + } + const storedHostIds = repos + .filter((candidate) => candidate.id === id) + .map((candidate) => getRepoExecutionHostId(candidate)) + if (storedHostIds.length > 0) { + console.error( + `[persistence] Discarded a repo update for ${id}: requested host ${hostId}, but the row is stored on ${storedHostIds.join(', ')}. Address updateRepo by the row's own executionHostId stamp.` + ) + } + return undefined +} + export class RepoUpdatePersistenceOperations { constructor(private readonly operations: RepoUpdateMutationOperations) {} - private get state(): PersistedState { + private get state(): Pick { return this.operations.state } @@ -77,10 +110,7 @@ export class RepoUpdatePersistenceOperations { }, hostId?: ExecutionHostId ): Repo | null { - const repo = this.state.repos.find( - (candidate) => - candidate.id === id && (!hostId || getRepoExecutionHostId(candidate) === hostId) - ) + const repo = findRepoRowForHostScopedWrite(this.state.repos, id, hostId) if (!repo) { return null } diff --git a/src/main/persistence/tracking-repos/repo-worktree-pruning.ts b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts index a41f7c6319d..2382cb6a371 100644 --- a/src/main/persistence/tracking-repos/repo-worktree-pruning.ts +++ b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts @@ -74,6 +74,11 @@ export function pruneWorktreeStateForRepo( (tombstone) => tombstone.worktreeId ) ) + collectPrefixedKeys( + Object.values(session?.closedTerminalTabTombstonesByTabId ?? {}).map( + (record) => record.worktreeId + ) + ) } collectPrefixedKeys(Object.keys(state.worktreeMeta)) collectScannedRecordOwners(state.workspaceSession) diff --git a/src/main/persistence/tracking-repos/worktree-identity-migration.ts b/src/main/persistence/tracking-repos/worktree-identity-migration.ts index 31b318b3143..2bc7fa95e6f 100644 --- a/src/main/persistence/tracking-repos/worktree-identity-migration.ts +++ b/src/main/persistence/tracking-repos/worktree-identity-migration.ts @@ -330,6 +330,9 @@ export function migrateWorktreeIdentity( )) { changed = moveKey(selectionsByWorktree) || changed } + if (state.ui?.explorerDisplayRootByWorktree) { + changed = moveKey(state.ui.explorerDisplayRootByWorktree) || changed + } const showDotfiles = state.ui?.showDotfilesByWorktree if (showDotfiles) { changed = moveKey(showDotfiles) || changed diff --git a/src/main/pi/agent-status-extension-source.ts b/src/main/pi/agent-status-extension-source.ts index bdf9061779c..58a18b78fa5 100644 --- a/src/main/pi/agent-status-extension-source.ts +++ b/src/main/pi/agent-status-extension-source.ts @@ -46,7 +46,7 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin ' const sessionFile = sessionManager?.getSessionFile?.()', " runtimeOmpSessionMetadata = typeof sessionId === 'string' && sessionId && typeof sessionFile === 'string' && sessionFile ? { session_id: sessionId, session_file: sessionFile } : {}", ' trackModelSession(runtimeOmpSessionMetadata.session_id)', - ' updateModelMetadata(ctx)', + ' updateModelMetadata(ctx)', '}', '', 'function getPostSessionMetadata(ompRuntime: boolean): Record {', @@ -75,7 +75,7 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin ' const sessionId = sessionManager?.getSessionId?.()', ' const sessionFile = sessionManager?.getSessionFile?.()', " sessionMetadata = typeof sessionId === 'string' && sessionId && typeof sessionFile === 'string' && sessionFile ? { session_id: sessionId, session_file: sessionFile } : {}", - ' trackModelSession(sessionMetadata.session_id)', + ' trackModelSession(sessionMetadata.session_id)', '}', '', 'function updateRuntimeOmpSessionMetadata(ctx: unknown): void {', diff --git a/src/main/pi/titlebar-extension-service.test.ts b/src/main/pi/titlebar-extension-service.test.ts index 1240d1859a8..bc259e893a9 100644 --- a/src/main/pi/titlebar-extension-service.test.ts +++ b/src/main/pi/titlebar-extension-service.test.ts @@ -156,9 +156,14 @@ describe('PiTitlebarExtensionService', () => { homedirOverride.current = fakeHome vi.stubEnv('PI_CONFIG_DIR', 'host-profile') try { - const env = new PiTitlebarExtensionService().buildPtyEnv('pty-ambient-root', undefined, 'omp', { - materializeDefaultHome: true - }) + const env = new PiTitlebarExtensionService().buildPtyEnv( + 'pty-ambient-root', + undefined, + 'omp', + { + materializeDefaultHome: true + } + ) expect(env.ORCA_OMP_SOURCE_AGENT_DIR).toBe(join(fakeHome, '.omp', 'agent')) expect(existsSync(join(fakeHome, 'host-profile'))).toBe(false) } finally { @@ -272,7 +277,6 @@ describe('PiTitlebarExtensionService', () => { const env = svc.buildPtyEnv('pty-omp-sqlite', piHome, 'omp') const sourcePath = join(piHome, 'agent.db') - const content = 'agent.db credentials' expect(env.PI_CODING_AGENT_DIR).toBeUndefined() expect(readFileSync(env.ORCA_OMP_FRESH_CONFIG, 'utf8')).toBe('autoResume: false\n') @@ -282,9 +286,6 @@ describe('PiTitlebarExtensionService', () => { expect(existsSync(sourcePath)).toBe(false) expect(existsSync(join(userDataDir, 'omp-agent-overlays'))).toBe(false) expect(existsSync(join(piHome, 'history.db'))).toBe(false) - writeFileSync(sourcePath, content) - - expect(readFileSync(sourcePath, 'utf-8')).toBe(content) }) it('migrates missing OMP state from the old source overlay without overwriting source files', () => { diff --git a/src/main/pi/titlebar-extension-service.ts b/src/main/pi/titlebar-extension-service.ts index 64a021603f7..4b698c02f09 100644 --- a/src/main/pi/titlebar-extension-service.ts +++ b/src/main/pi/titlebar-extension-service.ts @@ -191,8 +191,7 @@ export class PiTitlebarExtensionService { ): Record { const freshConfigEnv = kind === 'omp' ? this.buildFreshOmpEnv() : {} // The caller resolves the effective launch environment before this point. - const sourceAgentDir = - existingAgentDir || getDefaultPiAgentDir(kind, options?.configDirName) + const sourceAgentDir = existingAgentDir || getDefaultPiAgentDir(kind, options?.configDirName) if (kind !== 'prime-agent') { try { this.safeRemoveOverlay(this.getPtyOverlayDir(ptyId, kind), kind) diff --git a/src/main/plugins/plugin-host-methods.test.ts b/src/main/plugins/plugin-host-methods.test.ts index ee6e7a5b9d0..7f196c9eb81 100644 --- a/src/main/plugins/plugin-host-methods.test.ts +++ b/src/main/plugins/plugin-host-methods.test.ts @@ -194,10 +194,14 @@ describe('terminal.sendText explicit worktree routing', () => { { includeVisualLayouts: false } ) expect(delegate.sendTerminal).toHaveBeenCalledTimes(1) - expect(delegate.sendTerminal).toHaveBeenCalledWith(terminalId, { - text: 'echo hi', - enter: true - }) + expect(delegate.sendTerminal).toHaveBeenCalledWith( + terminalId, + { + text: 'echo hi', + enter: true + }, + { inputKind: 'driving' } + ) expect(vi.mocked(delegate.listTerminals).mock.invocationCallOrder[0]!).toBeLessThan( vi.mocked(delegate.sendTerminal).mock.invocationCallOrder[0]! ) diff --git a/src/main/plugins/plugin-host-service-bindings.ts b/src/main/plugins/plugin-host-service-bindings.ts index 266b819dbae..731934d5595 100644 --- a/src/main/plugins/plugin-host-service-bindings.ts +++ b/src/main/plugins/plugin-host-service-bindings.ts @@ -3,6 +3,7 @@ import { PLUGIN_WORKSPACE_TERMINAL_LIMIT } from '../../shared/plugins/plugin-hos import type { PluginHostServices } from './plugin-host-methods' import { PluginSecretsStore } from './plugin-secrets-store' import { PluginKvStore } from './plugin-storage-store' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' /** Structural subset of OrcaRuntimeService exposed to plugin facade bindings. */ export type PluginRuntimeDelegate = { @@ -19,7 +20,8 @@ export type PluginRuntimeDelegate = { ): Promise<{ terminals: { handle: string; title: string | null }[] }> sendTerminal( handle: string, - action: { text?: string; enter?: boolean } + action: { text?: string; enter?: boolean }, + options: { inputKind: TerminalInputKind } ): Promise<{ accepted: boolean }> dispatchPluginNotification(input: { pluginId: string @@ -59,7 +61,7 @@ export function bindPluginHostServices(input: { .map((terminal) => ({ id: terminal.handle })) }, sendTerminalText: async (terminalId, action) => { - const result = await delegate.sendTerminal(terminalId, action) + const result = await delegate.sendTerminal(terminalId, action, { inputKind: 'driving' }) return { accepted: result.accepted } }, dispatchPluginNotification: (notification) => delegate.dispatchPluginNotification(notification), diff --git a/src/main/ports/port-scan-command-client.test.ts b/src/main/ports/port-scan-command-client.test.ts index 37e8451a4da..d79fe80fbbd 100644 --- a/src/main/ports/port-scan-command-client.test.ts +++ b/src/main/ports/port-scan-command-client.test.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { join, sep } from 'node:path' import { Worker } from 'node:worker_threads' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -244,17 +243,6 @@ describe('resolveWorkerEntryPath', () => { expect(resolved).toBe(join(moduleDir, WORKER_ENTRY_FILENAME)) expect(resolved).not.toContain('app.asar') }) - - // A rename in the build config would leave both branches pointing at a file - // that is never emitted, and only the packaged one fails silently. - it('names the entry the main build actually emits', () => { - const config = readFileSync( - join(import.meta.dirname, '..', '..', '..', 'electron.vite.config.ts'), - 'utf8' - ) - - expect(config).toContain("'port-scan-command-worker-entry': resolve(") - }) }) const REAL_WORKER_BLOCK_MS = 800 diff --git a/src/main/ports/port-scan-command-import-boundary.test.ts b/src/main/ports/port-scan-command-import-boundary.test.ts deleted file mode 100644 index bd9b9d05fb8..00000000000 --- a/src/main/ports/port-scan-command-import-boundary.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -// Why (#11161): libuv runs process creation inline on the calling event loop, -// so the port scan only stays off CrBrowserMain while these main-thread modules -// spawn nothing themselves. Spawning belongs to port-scan-command-execution.ts, -// which only the worker entry imports. -const MAIN_THREAD_MODULES = ['local-workspace-port-scanner.ts', 'port-scan-command-client.ts'] - -describe('port scan main-thread spawn boundary', () => { - it.each(MAIN_THREAD_MODULES)('keeps %s free of child_process', (fileName) => { - const source = readFileSync(join(import.meta.dirname, fileName), 'utf8') - - expect(source).not.toMatch(/from\s+['"](node:)?child_process['"]/) - expect(source).not.toMatch(/require\(\s*['"](node:)?child_process['"]\s*\)/) - }) -}) diff --git a/src/main/preflight/freebuff-remote-detection.test.ts b/src/main/preflight/freebuff-remote-detection.test.ts new file mode 100644 index 00000000000..de8408da088 --- /dev/null +++ b/src/main/preflight/freebuff-remote-detection.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { getActiveMultiplexerMock } = vi.hoisted(() => ({ + getActiveMultiplexerMock: vi.fn() +})) + +vi.mock('../ssh/ssh-target-registry', () => ({ + getActiveMultiplexer: getActiveMultiplexerMock +})) + +import { detectRemoteAgents } from './agent-detection' + +afterEach(() => vi.resetAllMocks()) + +describe('Freebuff SSH detection', () => { + it('asks the execution host to detect Freebuff separately from Codebuff', async () => { + const request = vi.fn().mockResolvedValue({ agents: ['freebuff'] }) + getActiveMultiplexerMock.mockReturnValue({ isDisposed: () => false, request }) + + await expect(detectRemoteAgents({ connectionId: 'ssh-freebuff' })).resolves.toEqual([ + 'freebuff' + ]) + expect(getActiveMultiplexerMock).toHaveBeenCalledWith('ssh-freebuff') + expect(request).toHaveBeenCalledWith('preflight.detectAgents', { + commands: expect.arrayContaining([ + { id: 'freebuff', cmd: 'freebuff' }, + { id: 'codebuff', cmd: 'codebuff' } + ]) + }) + }) +}) diff --git a/src/main/project-groups/nested-repo-glob-budget.test.ts b/src/main/project-groups/nested-repo-glob-budget.test.ts new file mode 100644 index 00000000000..41b051a0134 --- /dev/null +++ b/src/main/project-groups/nested-repo-glob-budget.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest' +import { + isIgnoredNestedRepoDirectory, + readNestedRepoGitignoreRules, + readNestedRepoGlobMatchSteps +} from './nested-repo-scan-rules' + +async function readRules(content: string) { + return readNestedRepoGitignoreRules({ + folderPath: '/synthetic', + entries: [{ name: '.gitignore', isDirectory: false }], + baseSegments: [], + filesystem: { + readDirectory: async () => [], + readTextFile: async () => content, + joinPath: (parent, child) => `${parent}/${child}`, + basename: (path) => path, + hasGitMarker: () => false, + isSelectedPathGitRepo: () => false + } + }) +} + +function strings(alphabet: string[], maxLength: number): string[] { + const result = [''] + let layer = [''] + for (let length = 1; length <= maxLength; length++) { + layer = layer.flatMap((prefix) => alphabet.map((character) => prefix + character)) + result.push(...layer) + } + return result +} + +function segmentLists(alphabet: string[], maxLength: number): string[][] { + const result: string[][] = [] + let layer: string[][] = [[]] + for (let length = 1; length <= maxLength; length++) { + layer = layer.flatMap((prefix) => alphabet.map((token) => [...prefix, token])) + result.push(...layer) + } + return result +} + +// Pre-change oracle: a regular expression per wildcard segment, plus an unmemoized `**` walk over +// uncollapsed segments. Differential cases hold the shipped matcher to exactly this behaviour. +function referenceSegment(pattern: string): string | RegExp { + if (!pattern.includes('*') && !pattern.includes('?')) { + return pattern + } + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&') + return new RegExp(`^${escaped.replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]')}$`) +} + +function referenceSegmentMatches(pattern: string | RegExp, value: string): boolean { + return typeof pattern === 'string' ? pattern === value : pattern.test(value) +} + +function referenceIgnored(line: string, candidateSegments: string[]): boolean { + const anchored = line.startsWith('/') + const pattern = line.replace(/^\/+/, '').replace(/\/+$/, '') + if (!anchored && !pattern.includes('/')) { + const segment = referenceSegment(pattern) + return candidateSegments.some((candidate) => referenceSegmentMatches(segment, candidate)) + } + const patternSegments = pattern + .split('/') + .map((segment) => (segment === '**' ? segment : referenceSegment(segment))) + const matchFrom = (patternIndex: number, candidateIndex: number): boolean => { + if (patternIndex >= patternSegments.length) { + return candidateIndex >= candidateSegments.length + } + const segment = patternSegments[patternIndex] + if (segment === '**') { + return ( + matchFrom(patternIndex + 1, candidateIndex) || + (candidateIndex < candidateSegments.length && matchFrom(patternIndex, candidateIndex + 1)) + ) + } + return ( + candidateIndex < candidateSegments.length && + referenceSegmentMatches(segment, candidateSegments[candidateIndex] ?? '') && + matchFrom(patternIndex + 1, candidateIndex + 1) + ) + } + return matchFrom(0, 0) +} + +function stepsSpentOn(run: () => void): number { + const before = readNestedRepoGlobMatchSteps() + run() + return readNestedRepoGlobMatchSteps() - before +} + +describe('nested repository wildcard work budget', () => { + it('bounds wildcard segment work for fifty adverse names', async () => { + const rules = await readRules(`${'*a'.repeat(12)}b`) + const name = `${'a'.repeat(24)}c` + let ignored = 0 + const steps = stepsSpentOn(() => { + for (let index = 0; index < 50; index++) { + ignored += Number(isIgnoredNestedRepoDirectory(name, [name], rules)) + } + }) + expect(ignored).toBe(0) + // O(pattern length x name length) per name, ~51 steps in practice; the pre-change expression + // revisited earlier stars and needed ~25 ms for each of these names. + expect(steps).toBeLessThan(50 * 100) + }) + + it('bounds ** path work for a long run of double stars', async () => { + const rules = await readRules(`${'**/'.repeat(24)}z`) + const candidate = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] + let ignored = true + // Parse-time collapse of the `**` run; uncollapsed this shape cost ~49M recursive calls. + const steps = stepsSpentOn(() => { + ignored = isIgnoredNestedRepoDirectory('h', candidate, rules) + }) + expect(ignored).toBe(false) + expect(steps).toBeLessThan(100) + }) + + it('bounds ** path work when non-collapsible segments separate the double stars', async () => { + const rules = await readRules(Array.from({ length: 12 }, () => '**/*').join('/')) + const candidate = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] + let ignored = true + // Memoization; without it this shape revisits pairs and costs over a thousand calls. + const steps = stepsSpentOn(() => { + ignored = isIgnoredNestedRepoDirectory('h', candidate, rules) + }) + expect(ignored).toBe(false) + expect(steps).toBeLessThan(400) + }) + + it('accepts exactly the same paths for a collapsed and an expanded ** run', async () => { + const collapsed = await readRules('**/x') + const expanded = await readRules('**/**/**/x') + for (const candidate of segmentLists(['a', 'x'], 4)) { + const name = candidate.at(-1) ?? '' + const label = candidate.join('/') + const expectedMatch = referenceIgnored('**/x', candidate) + expect(isIgnoredNestedRepoDirectory(name, candidate, collapsed), label).toBe(expectedMatch) + expect(isIgnoredNestedRepoDirectory(name, candidate, expanded), label).toBe(expectedMatch) + expect(referenceIgnored('**/**/**/x', candidate), label).toBe(expectedMatch) + } + }) + + it('preserves wildcard results for every short pattern and name', async () => { + const names = strings(['a', 'b', '?'], 4) + for (const pattern of strings(['a', 'b', '*', '?'], 4).slice(1)) { + const rules = await readRules(pattern) + for (const name of names) { + expect(isIgnoredNestedRepoDirectory(name, [name], rules), `${pattern} / ${name}`).toBe( + referenceIgnored(pattern, [name]) + ) + } + } + }) + + it('preserves path results for every short multi-segment pattern and candidate path', async () => { + const candidates = segmentLists(['a', 'b', 'ab'], 3) + for (const patternSegments of segmentLists(['a', 'b', '*', '?', '**'], 3)) { + const joined = patternSegments.join('/') + // The leading slash anchors the rule, which is the non-basenameOnly path even at one segment. + for (const line of [joined, `/${joined}`]) { + const rules = await readRules(line) + for (const candidate of candidates) { + expect( + isIgnoredNestedRepoDirectory(candidate.at(-1) ?? '', candidate, rules), + `${line} / ${candidate.join('/')}` + ).toBe(referenceIgnored(line, candidate)) + } + } + } + }) + + it.each([ + ['[literal]+.*', '[literal]+.suffix', true], + ['[literal]+.*', 'literal-suffix', false], + ['a\\*', 'a\\suffix', true], + ['*', 'a/b', false], + ['a?', 'a\n', true], + ['a?b', 'a\nb', true], + ['a*b', 'ab\n', false], + ['?', '😀', false], + ['??', '😀', true], + ['*😀?', 'x😀a', true], + ['*?*a*', 'ba', true], + ['**b**', 'abca', true] + ])('preserves literal and code-unit matching for %s / %s', async (pattern, name, expected) => { + const rules = await readRules(pattern) + expect(isIgnoredNestedRepoDirectory(name, [name], rules)).toBe(expected) + }) +}) diff --git a/src/main/project-groups/nested-repo-scan-rules.ts b/src/main/project-groups/nested-repo-scan-rules.ts index 3a839917252..d38bd1f0983 100644 --- a/src/main/project-groups/nested-repo-scan-rules.ts +++ b/src/main/project-groups/nested-repo-scan-rules.ts @@ -17,12 +17,15 @@ export type NestedRepoScanFilesystem = { type IgnoreRule = { pattern: string - segmentPatterns: (string | RegExp)[] + segmentPatterns: GlobSegment[] + memoizePathWalk: boolean negate: boolean basenameOnly: boolean baseSegments: string[] } +type GlobSegment = string | { pattern: string } + export type TraversalFolder = { path: string depth: number @@ -83,42 +86,107 @@ function shouldSkipDirectory(name: string, depth: number): boolean { return depth > 0 && name.startsWith('.') } -function compileGlobSegment(pattern: string): string | RegExp { +function compileGlobSegment(pattern: string): GlobSegment { if (!pattern.includes('*') && !pattern.includes('?')) { return pattern } - const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&') - return new RegExp(`^${escaped.replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]')}$`) + return { pattern } } -function globSegmentMatches(pattern: string | RegExp, value: string): boolean { - return typeof pattern === 'string' ? pattern === value : pattern.test(value) +let globMatchSteps = 0 + +/** + * Monotonic count of matcher steps taken since process start. + * + * Budget tests assert on a delta so an algorithmic regression fails deterministically instead of + * riding on how fast the machine running the suite happens to be. + */ +export function readNestedRepoGlobMatchSteps(): number { + return globMatchSteps +} + +function globSegmentMatches(segment: GlobSegment, value: string): boolean { + globMatchSteps++ + if (typeof segment === 'string') { + return segment === value + } + const { pattern } = segment + let patternIndex = 0 + let valueIndex = 0 + let starIndex = -1 + let starMatchIndex = 0 + // Retry only the latest star, avoiding combinatorial regular-expression backtracking. + while (valueIndex < value.length) { + globMatchSteps++ + const token = pattern[patternIndex] + if (token === '*') { + starIndex = patternIndex++ + starMatchIndex = valueIndex + } else if (token === value[valueIndex] || (token === '?' && value[valueIndex] !== '/')) { + patternIndex++ + valueIndex++ + } else if (starIndex !== -1 && value[starMatchIndex] !== '/') { + patternIndex = starIndex + 1 + valueIndex = ++starMatchIndex + } else { + return false + } + } + while (pattern[patternIndex] === '*') { + patternIndex++ + } + return patternIndex === pattern.length } function pathSegmentsMatch( - patternSegments: (string | RegExp)[], - candidateSegments: string[] + patternSegments: GlobSegment[], + candidateSegments: string[], + memoize: boolean ): boolean { + const memoStride = candidateSegments.length + 1 + // Allocating the table costs more than the walk it would save on the overwhelmingly common + // shapes, so only rules that can actually revisit a pair pay for it. + const visited = memoize ? new Map() : undefined const matchFrom = (patternIndex: number, candidateIndex: number): boolean => { + globMatchSteps++ if (patternIndex >= patternSegments.length) { return candidateIndex >= candidateSegments.length } - const pattern = patternSegments[patternIndex] - if (pattern === '**') { - return ( - matchFrom(patternIndex + 1, candidateIndex) || - (candidateIndex < candidateSegments.length && matchFrom(patternIndex, candidateIndex + 1)) - ) + const memoKey = patternIndex * memoStride + candidateIndex + const memoized = visited?.get(memoKey) + if (memoized !== undefined) { + return memoized } - return ( - candidateIndex < candidateSegments.length && - globSegmentMatches(pattern, candidateSegments[candidateIndex] ?? '') && - matchFrom(patternIndex + 1, candidateIndex + 1) - ) + const pattern = patternSegments[patternIndex] + const matched = + pattern === '**' + ? matchFrom(patternIndex + 1, candidateIndex) || + (candidateIndex < candidateSegments.length && matchFrom(patternIndex, candidateIndex + 1)) + : candidateIndex < candidateSegments.length && + globSegmentMatches(pattern, candidateSegments[candidateIndex] ?? '') && + matchFrom(patternIndex + 1, candidateIndex + 1) + visited?.set(memoKey, matched) + return matched } return matchFrom(0, 0) } +function compilePathSegments(pattern: string): GlobSegment[] { + const segments: GlobSegment[] = [] + for (const segment of pattern.split('/')) { + // `**` spans zero or more segments, so `**/**` accepts exactly what `**` accepts: collapsing a + // run keeps one adversarial line from handing the path matcher dozens of forking segments. + if (segment === '**') { + if (segments.at(-1) !== '**') { + segments.push(segment) + } + continue + } + segments.push(compileGlobSegment(segment)) + } + return segments +} + function parseGitignoreRules(content: string, baseSegments: string[]): IgnoreRule[] { return content .split(/\r?\n/) @@ -130,13 +198,15 @@ function parseGitignoreRules(content: string, baseSegments: string[]): IgnoreRul const anchored = unprefixed.startsWith('/') const pattern = unprefixed.replace(/^\/+/, '').replace(/\/+$/, '') const basenameOnly = !anchored && !pattern.includes('/') + const segmentPatterns = basenameOnly + ? [compileGlobSegment(pattern)] + : compilePathSegments(pattern) return { pattern, - segmentPatterns: basenameOnly - ? [compileGlobSegment(pattern)] - : pattern - .split('/') - .map((segment) => (segment === '**' ? segment : compileGlobSegment(segment))), + segmentPatterns, + // One `**` walks the candidate segments once; two or more reach the same (pattern, + // candidate) pair by many routes, which is what turns the walk exponential. + memoizePathWalk: segmentPatterns.filter((segment) => segment === '**').length > 1, negate, basenameOnly, baseSegments @@ -158,7 +228,7 @@ export function isIgnoredNestedRepoDirectory( const relativeSegments = segments.slice(rule.baseSegments.length) const matches = rule.basenameOnly ? relativeSegments.some((segment) => globSegmentMatches(rule.segmentPatterns[0], segment)) - : pathSegmentsMatch(rule.segmentPatterns, relativeSegments) + : pathSegmentsMatch(rule.segmentPatterns, relativeSegments, rule.memoizePathWalk) if (matches) { ignored = !rule.negate } diff --git a/src/main/protected-secret-persistence-concurrency.test.ts b/src/main/protected-secret-persistence-concurrency.test.ts new file mode 100644 index 00000000000..1b429cdaa8b --- /dev/null +++ b/src/main/protected-secret-persistence-concurrency.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { setSecretStore } from '../shared/secret-store' +import { + ProtectedSecretPersistence, + type ProtectedSecretRetentionUpdate +} from './protected-secret-persistence' + +const cipherState = { available: true, fails: false } +const ciphertext = (plaintext: string): string => + Buffer.from(`encrypted:${plaintext}`).toString('base64') + +function prepare( + secrets: ProtectedSecretPersistence, + slot: string, + plaintext: string +): ProtectedSecretRetentionUpdate { + const { retentionUpdate } = secrets.encrypt(slot, plaintext) + if (!retentionUpdate) { + throw new Error('Expected a prepared retention update') + } + return retentionUpdate +} + +describe('protected secret acknowledgements', () => { + beforeEach(() => { + cipherState.available = true + cipherState.fails = false + setSecretStore({ + isEncryptionAvailable: () => cipherState.available, + encryptString: (plaintext) => { + if (cipherState.fails) { + throw new Error('Keyring encryption failed') + } + return Buffer.from(`encrypted:${plaintext}`) + }, + decryptString: (encrypted) => encrypted.toString().slice('encrypted:'.length), + describeProtectionGap: () => null + }) + }) + + afterEach(() => vi.restoreAllMocks()) + + it.each(['remove', 'empty encryption', 'empty decryption'])( + 'does not revive a secret cleared by %s while its save was in flight', + (clear) => { + const secrets = new ProtectedSecretPersistence() + const update = prepare(secrets, 'slot', 'first') + if (clear === 'remove') { + secrets.removeRetainedBlob('slot') + } else if (clear === 'empty encryption') { + secrets.encrypt('slot', '') + } else { + secrets.decrypt('slot', '') + } + + secrets.commitRetentionUpdates([update]) + + cipherState.available = false + expect(secrets.encrypt('slot', 'replacement').blob).toBe('') + } + ) + + it.each(['replacement', ''])('preserves a reloaded sealed slot after an old %j save', (value) => { + const secrets = new ProtectedSecretPersistence() + secrets.decrypt('slot', ciphertext('original')) + const update = prepare(secrets, 'slot', value) + const reloaded = ciphertext('reloaded') + cipherState.available = false + secrets.decrypt('slot', reloaded) + + secrets.commitRetentionUpdates([update]) + + expect(secrets.isSealed('slot', reloaded)).toBe(true) + expect(secrets.encrypt('slot', '').blob).toBe(reloaded) + expect(secrets.hasPendingEncryption()).toBe(false) + }) + + it('preserves a successfully decrypted replacement after an older save acknowledges', () => { + const secrets = new ProtectedSecretPersistence() + const update = prepare(secrets, 'slot', 'first') + const reloaded = ciphertext('reloaded') + expect(secrets.decrypt('slot', reloaded)).toBe('reloaded') + + secrets.commitRetentionUpdates([update]) + + cipherState.available = false + expect(secrets.encrypt('slot', 'reloaded').blob).toBe(reloaded) + }) + + it.each(['unavailable', 'throws'])( + 'keeps a newer %s encryption pending after an old ack', + (failure) => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const secrets = new ProtectedSecretPersistence() + const original = ciphertext('original') + secrets.decrypt('slot', original) + const update = prepare(secrets, 'slot', 'first') + cipherState.available = failure !== 'unavailable' + cipherState.fails = failure === 'throws' + expect(secrets.encrypt('slot', 'newer')).toEqual({ blob: original, degraded: true }) + + secrets.commitRetentionUpdates([update]) + + expect(secrets.hasPendingEncryption()).toBe(true) + cipherState.available = false + expect(secrets.encrypt('slot', 'newer').blob).toBe(original) + cipherState.available = true + cipherState.fails = false + const retry = prepare(secrets, 'slot', 'newer') + secrets.commitRetentionUpdates([retry]) + expect(secrets.hasPendingEncryption()).toBe(false) + cipherState.available = false + expect(secrets.encrypt('slot', 'newer').blob).toBe(ciphertext('newer')) + } + ) + + it.each([false, true])( + 'retains only the latest prepared ciphertext, reverse ack order: %s', + (reverse) => { + const secrets = new ProtectedSecretPersistence() + const older = prepare(secrets, 'slot', 'older') + const newer = prepare(secrets, 'slot', 'newer') + for (const update of reverse ? [newer, older] : [older, newer]) { + secrets.commitRetentionUpdates([update]) + } + + cipherState.available = false + expect(secrets.encrypt('slot', 'replacement').blob).toBe(ciphertext('newer')) + } + ) + + it('retains the last same-slot value in a single acknowledged preparation batch', () => { + const secrets = new ProtectedSecretPersistence() + const updates = [prepare(secrets, 'slot', 'older'), prepare(secrets, 'slot', 'newer')] + + secrets.commitRetentionUpdates(updates) + + cipherState.available = false + expect(secrets.encrypt('slot', 'replacement').blob).toBe(ciphertext('newer')) + }) + + it('does not reuse an old acknowledgement when a removed dynamic slot is recreated', () => { + const secrets = new ProtectedSecretPersistence() + const removed = prepare(secrets, 'dynamic-slot', 'removed') + secrets.removeRetainedBlob('dynamic-slot') + const recreated = prepare(secrets, 'dynamic-slot', 'recreated') + + secrets.commitRetentionUpdates([removed, recreated, removed]) + + cipherState.available = false + expect(secrets.encrypt('dynamic-slot', 'replacement').blob).toBe(ciphertext('recreated')) + }) + + it('invalidates only the changed slot in an acknowledged batch', () => { + const secrets = new ProtectedSecretPersistence() + const updates = [prepare(secrets, 'keep', 'keep'), prepare(secrets, 'remove', 'remove')] + secrets.removeRetainedBlob('remove') + + secrets.commitRetentionUpdates(updates) + + cipherState.available = false + expect(secrets.encrypt('keep', 'replacement').blob).toBe(ciphertext('keep')) + expect(secrets.encrypt('remove', 'replacement').blob).toBe('') + }) + + it('does not accept a prepared update from a replaced persistence instance', () => { + const previous = new ProtectedSecretPersistence() + const current = new ProtectedSecretPersistence() + const older = prepare(previous, 'slot', 'older') + const newer = prepare(current, 'slot', 'newer') + + current.commitRetentionUpdates([older, newer]) + + cipherState.available = false + expect(current.encrypt('slot', 'replacement').blob).toBe(ciphertext('newer')) + }) + + it('keeps the previous ciphertext and pending retry when a prepared save has no confirmed ack', () => { + const secrets = new ProtectedSecretPersistence() + const original = ciphertext('original') + secrets.decrypt('slot', original) + cipherState.available = false + secrets.encrypt('slot', 'replacement') + cipherState.available = true + + prepare(secrets, 'slot', 'replacement') + + expect(secrets.hasPendingEncryption()).toBe(true) + cipherState.available = false + expect(secrets.encrypt('slot', 'replacement').blob).toBe(original) + }) +}) diff --git a/src/main/protected-secret-persistence.test.ts b/src/main/protected-secret-persistence.test.ts index 28a9ca6cf43..cbd1561f344 100644 --- a/src/main/protected-secret-persistence.test.ts +++ b/src/main/protected-secret-persistence.test.ts @@ -62,6 +62,7 @@ describe('ProtectedSecretPersistence', () => { degraded: true, hashValue: ciphertext }) + expect(secrets.hasPendingEncryption()).toBe(false) cipherState.available = true expect(secrets.encrypt(slot, '')).toEqual({ @@ -74,4 +75,34 @@ describe('ProtectedSecretPersistence', () => { secrets.removeRetainedBlob(slot) expect(secrets.encrypt(slot, '')).toEqual({ blob: '', degraded: false }) }) + + it('keeps deferred encryption pending until its retention update commits', async () => { + const { ProtectedSecretPersistence } = await import('./protected-secret-persistence') + const secrets = new ProtectedSecretPersistence() + cipherState.available = false + secrets.encrypt('slot', 'pending') + expect(secrets.hasPendingEncryption()).toBe(true) + cipherState.available = true + const encrypted = secrets.encrypt('slot', 'pending') + expect(secrets.hasPendingEncryption()).toBe(true) + if (!encrypted.retentionUpdate) { + throw new Error('Expected a retention update') + } + secrets.commitRetentionUpdates([encrypted.retentionUpdate]) + expect(secrets.hasPendingEncryption()).toBe(false) + }) + + it('retires a deferred empty secret without retaining a phantom retry', async () => { + const { ProtectedSecretPersistence } = await import('./protected-secret-persistence') + const secrets = new ProtectedSecretPersistence() + cipherState.available = false + secrets.encrypt('slot', 'pending') + const cleared = secrets.encrypt('slot', '') + expect(secrets.hasPendingEncryption()).toBe(true) + if (!cleared.retentionUpdate) { + throw new Error('Expected a retention update') + } + secrets.commitRetentionUpdates([cleared.retentionUpdate]) + expect(secrets.hasPendingEncryption()).toBe(false) + }) }) diff --git a/src/main/protected-secret-persistence.ts b/src/main/protected-secret-persistence.ts index 90bb2ee334d..c72af096619 100644 --- a/src/main/protected-secret-persistence.ts +++ b/src/main/protected-secret-persistence.ts @@ -19,6 +19,7 @@ export type ProtectedSecretDecryption = { export type ProtectedSecretRetentionUpdate = { slot: string blob: string | null + epoch: symbol } export type LegacyPlaintextValidator = (value: string) => boolean @@ -34,10 +35,18 @@ type ProtectedSecretEncryption = { export class ProtectedSecretPersistence { private readonly retainedBlobs = new Map() private readonly sealedSlots = new Set() + private readonly pendingEncryption = new Set() + private readonly retentionEpochs = new Map() + + hasPendingEncryption(): boolean { + return this.pendingEncryption.size > 0 + } removeRetainedBlob(slot: string): void { + this.retentionEpochs.delete(slot) this.retainedBlobs.delete(slot) this.sealedSlots.delete(slot) + this.pendingEncryption.delete(slot) } isSealed(slot: string, value: string): boolean { @@ -46,6 +55,12 @@ export class ProtectedSecretPersistence { commitRetentionUpdates(updates: readonly ProtectedSecretRetentionUpdate[]): void { for (const update of updates) { + // A delayed save must not overwrite a newer secret decision. + if (this.retentionEpochs.get(update.slot) !== update.epoch) { + continue + } + this.retentionEpochs.delete(update.slot) + this.pendingEncryption.delete(update.slot) if (update.blob === null) { this.removeRetainedBlob(update.slot) } else { @@ -56,11 +71,21 @@ export class ProtectedSecretPersistence { } encrypt(slot: string, plaintext: string): ProtectedSecretEncryption { + this.retentionEpochs.delete(slot) const retained = this.retainedBlobs.get(slot) ?? '' if (!plaintext && !retained) { - return { blob: '', degraded: false } + return { + blob: '', + degraded: false, + ...(this.pendingEncryption.has(slot) + ? { retentionUpdate: this.prepareRetentionUpdate(slot, null) } + : {}) + } } if (!this.encryptionAvailable()) { + if (!this.isSealed(slot, plaintext) && (plaintext || !this.sealedSlots.has(slot))) { + this.pendingEncryption.add(slot) + } return { blob: retained, degraded: true, @@ -74,7 +99,7 @@ export class ProtectedSecretPersistence { return { blob: '', degraded: false, - retentionUpdate: { slot, blob: null } + retentionUpdate: this.prepareRetentionUpdate(slot, null) } } try { @@ -82,9 +107,10 @@ export class ProtectedSecretPersistence { return { blob, degraded: false, - retentionUpdate: { slot, blob } + retentionUpdate: this.prepareRetentionUpdate(slot, blob) } } catch (err) { + this.pendingEncryption.add(slot) console.error('[persistence] Encryption failed; retaining the prior protected value:', err) return { blob: retained, degraded: true } } @@ -99,6 +125,7 @@ export class ProtectedSecretPersistence { ciphertext: string, isLegacyPlaintext?: LegacyPlaintextValidator ): ProtectedSecretDecryption { + this.retentionEpochs.delete(slot) if (!ciphertext) { this.removeRetainedBlob(slot) return { plaintext: '', status: 'decrypted' } @@ -129,6 +156,15 @@ export class ProtectedSecretPersistence { } } + private prepareRetentionUpdate( + slot: string, + blob: string | null + ): ProtectedSecretRetentionUpdate { + const epoch = Symbol() + this.retentionEpochs.set(slot, epoch) + return { slot, blob, epoch } + } + private encryptionAvailable(): boolean { // Why getSecretStore() sits outside the try: an uninstalled store is a startup bug, // not a keyring failure. Swallowing it would degrade to an empty blob and report diff --git a/src/main/providers/agent-foreground-process-git-bash.win32.test.ts b/src/main/providers/agent-foreground-process-git-bash.win32.test.ts index 3cd4f07c003..4133375180f 100644 --- a/src/main/providers/agent-foreground-process-git-bash.win32.test.ts +++ b/src/main/providers/agent-foreground-process-git-bash.win32.test.ts @@ -1,19 +1,97 @@ -import { mkdtempSync } from 'node:fs' +import { mkdtempSync, rmdirSync, symlinkSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { removeTreeSync } from '../../shared/windows-transient-lock-removal' import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell' import { confirmPtyShellForeground } from '../daemon/pty-subprocess/pty-shell-foreground-confirmation' import { createPtyShellLaunchPlan } from '../daemon/pty-subprocess/shell-launch-plan' import { spawnNativeDaemonPty } from '../daemon/pty-subprocess/native-pty-spawn' +import { canUseBunPty, spawnBunPty } from '../daemon/pty-subprocess/bun-pty-process' +import { createWindowsBunPtyLaunch } from '../daemon/pty-subprocess/windows-bun-pty-launch' import { createDaemonPtyEnvironment } from '../daemon/pty-subprocess/spawn-environment' +import type { PtyShellLaunchPlan } from '../daemon/pty-subprocess/shell-launch-plan' import type { PtySubprocessOptions } from '../daemon/pty-subprocess' -import { isGitForWindowsBashLauncherPath } from '../git-bash' +import { isGitForWindowsBashLauncherPath, resolveGitBashPath } from '../git-bash' import { readWindowsPtyJobProcessIds } from './windows-pty-job-membership' const describeOnWindows = process.platform === 'win32' ? describe : describe.skip +async function spawnPlannedPane( + plan: PtyShellLaunchPlan, + env: Record, + opts: PtySubprocessOptions +): ReturnType { + return spawnNativeDaemonPty( + { ...plan, env, cols: opts.cols, rows: opts.rows }, + { + canUseBunPty, + spawnBunPty: (args) => + spawnBunPty(args, { + // Source tests use the TS worker; packaged hosts resolve their adjacent JS worker. + createWindowsLaunch: (launch) => + createWindowsBunPtyLaunch(launch, { + workerPath: join(__dirname, '../daemon/pty-subprocess/windows-bun-pty-gate-entry.ts') + }) + }) + } + ) +} + +/** Drives a live pane through prompt -> foreground command -> interrupt -> background job. */ +async function proveIdlePromptCycle( + spawned: Awaited>, + expectedJobSize: number +): Promise { + const proc = spawned.process + let output = '' + let dead = false + proc.onData((chunk) => { + output += chunk + }) + proc.onExit(() => { + dead = true + }) + const confirm = (): Promise => + confirmPtyShellForeground({ + process: proc, + shellPath: spawned.shellPath, + isDead: () => dead + }) + try { + await vi.waitFor(() => expect(output).toContain('$'), { timeout: 20_000 }) + await vi.waitFor(() => expect(readWindowsPtyJobProcessIds(proc)?.size).toBe(expectedJobSize), { + timeout: 5_000 + }) + await vi.waitFor(async () => expect(await confirm(), 'initial prompt').toBe(true), { + timeout: 5_000 + }) + + // Interrupt only after the child is ready, not during a transient shell fork. + proc.write( + "node -e \"console.log(['ORCA','FOREGROUND_READY'].join('_')); setInterval(() => {}, 1000)\"\r" + ) + await vi.waitFor(() => expect(output).toContain('ORCA_FOREGROUND_READY'), { + timeout: 10_000 + }) + await vi.waitFor(async () => expect(await confirm()).toBe(false), { timeout: 10_000 }) + + proc.write('\x03') + await vi.waitFor( + async () => { + expect(dead, 'terminal survived foreground interrupt').toBe(false) + expect(await confirm(), 'prompt after interrupt').toBe(true) + }, + { timeout: 10_000 } + ) + + proc.write('sleep 60 &\r') + await vi.waitFor(async () => expect(await confirm()).toBe(false), { timeout: 10_000 }) + } finally { + proc.kill() + } +} + describeOnWindows("Git Bash launcher shell proof with Orca's real launch", () => { let userData: string const previousUserData = process.env.ORCA_USER_DATA_PATH @@ -52,42 +130,40 @@ describeOnWindows("Git Bash launcher shell proof with Orca's real launch", () => const plan = createPtyShellLaunchPlan(opts, env) expect(isGitForWindowsBashLauncherPath(plan.shellPath)).toBe(true) expect(plan.shellArgs.join(' ')).toContain('exec "$BASH"') - const spawned = spawnNativeDaemonPty({ ...plan, env, cols: opts.cols, rows: opts.rows }) - const proc = spawned.process - let output = '' - let dead = false - proc.onData((chunk) => { - output += chunk - }) - proc.onExit(() => { - dead = true - }) - const confirm = (): Promise => - confirmPtyShellForeground({ - process: proc, - shellPath: spawned.shellPath, - isDead: () => dead - }) - try { - await vi.waitFor(() => expect(output).toContain('$'), { timeout: 20_000 }) - // Launcher, exec stub, interactive bash: the shape that a size-1 or size-2 rule never matches. - await vi.waitFor(() => expect(readWindowsPtyJobProcessIds(proc)?.size).toBe(3), { - timeout: 5_000 - }) - await vi.waitFor(async () => expect(await confirm()).toBe(true), { timeout: 5_000 }) - - proc.write('sleep 60\r') - await vi.waitFor(async () => expect(await confirm()).toBe(false), { timeout: 10_000 }) - - proc.write('\x03') - await vi.waitFor(async () => expect(await confirm()).toBe(true), { timeout: 10_000 }) - - proc.write('sleep 60 &\r') - await vi.waitFor(async () => expect(await confirm()).toBe(false), { timeout: 10_000 }) - } finally { - proc.kill() - } + // Launcher, exec stub, interactive bash: the shape that a size-1 or size-2 rule never matches. + await proveIdlePromptCycle(await spawnPlannedPane(plan, env, opts), 3) }, 60_000 ) + + it('confirms an idle prompt for an install folder named neither Git nor PortableGit', async () => { + const discovered = resolveGitBashPath() + if (!discovered) { + throw new Error('this host has no Git for Windows install to stage') + } + expect(isGitForWindowsBashLauncherPath(discovered)).toBe(true) + // A junction, not a copy: the operator's install is neither modified nor duplicated. + const stagingRoot = mkdtempSync(join(tmpdir(), 'orca-git-renamed-')) + const stagedInstall = join(stagingRoot, 'Git-2.55') + symlinkSync(dirname(dirname(discovered)), stagedInstall, 'junction') + const opts: PtySubprocessOptions = { + sessionId: 'git-bash-renamed-shell-proof', + cols: 120, + rows: 30, + cwd: tmpdir(), + shellOverride: join(stagedInstall, 'bin', 'bash.exe') + } + try { + const env = createDaemonPtyEnvironment(opts) + const plan = createPtyShellLaunchPlan(opts, env) + expect(plan.shellPath).toBe(opts.shellOverride) + expect(isGitForWindowsBashLauncherPath(plan.shellPath)).toBe(true) + // No Git Bash startup args for an unrecognized folder, so no exec stub: launcher -> bash. + await proveIdlePromptCycle(await spawnPlannedPane(plan, env, opts), 2) + } finally { + // rmdir, never a recursive remove: it detaches the junction and cannot reach the target. + rmdirSync(stagedInstall) + removeTreeSync(stagingRoot) + } + }, 60_000) }) diff --git a/src/main/providers/local-pty-bun-artifact.integration.test.ts b/src/main/providers/local-pty-bun-artifact.integration.test.ts new file mode 100644 index 00000000000..487127887a3 --- /dev/null +++ b/src/main/providers/local-pty-bun-artifact.integration.test.ts @@ -0,0 +1,107 @@ +import { build } from 'esbuild' +import { existsSync } from 'node:fs' +import { copyFile, mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { orcadBunRuntimeFilename } from '../../shared/orcad-artifacts' +import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' +import { removeTreeSync } from '../../shared/windows-transient-lock-removal' + +const runtime = + process.env.BUN_EXECUTABLE ?? resolve('out/orcad', orcadBunRuntimeFilename(process.platform)) + +describe.skipIf(!existsSync(runtime))('isolated Bun in-process PTY artifact', () => { + it('spawns, reattaches, delivers data and retires a shell without node-pty installed', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-bun-local-pty-')) + try { + const entry = join(directory, 'local-pty.cjs') + const external = ['node-pty', 'electron', 'bun:ffi', '@parcel/watcher', '*.node'] + await build({ + stdin: { + contents: ` + import { LocalPtyProvider } from './src/main/providers/local-pty-provider' + import { setAppEnvironment } from './src/shared/app-environment' + import { getCmdExePath } from './src/shared/windows-batch-spawn' + try { require.resolve('node-pty'); throw new Error('node-pty unexpectedly available') } + catch (error) { if (error.code !== 'MODULE_NOT_FOUND') throw error } + setAppEnvironment({ + getPath: () => process.cwd(), getAppPath: () => process.cwd(), + getVersion: () => 'test', isPackaged: () => true, + onWillQuit() {}, exit: code => process.exit(code), getAppMetrics: () => [] + }) + const provider = new LocalPtyProvider() + let output = '', resolveExit + const exit = new Promise(resolve => { resolveExit = resolve }) + provider.onData(event => { output += event.data }) + provider.onExit(event => resolveExit(event.code)) + const deadline = setTimeout(() => { provider.killAll(); process.exit(98) }, 10_000) + ;(async () => { + const first = await provider.spawn({ + sessionId: 'isolated-bun-fallback', cols: 80, rows: 24, cwd: process.cwd(), + shellOverride: process.platform === 'win32' ? getCmdExePath() : '/bin/sh' + }) + const again = await provider.spawn({sessionId:first.id, cols:100, rows:30}) + provider.write(first.id, process.platform === 'win32' + ? 'echo ORCA_BUN_FALLBACK_READY & exit 17\\r' + : 'printf ORCA_BUN_FALLBACK_READY; exit 17\\r') + const code = await exit + clearTimeout(deadline) + console.log(JSON.stringify({ + version:process.versions.bun, code, output:output.includes('ORCA_BUN_FALLBACK_READY'), + reattached:again.isReattach === true && again.pid === first.pid, + retired:provider.getPtyProcess(first.id) === undefined + })) + })().catch(error => { clearTimeout(deadline); provider.killAll(); console.error(error);process.exitCode=1 }) + `, + resolveDir: process.cwd(), + loader: 'ts' + }, + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node18', + external, + outfile: entry, + logLevel: 'silent' + }) + if (process.platform === 'win32') { + await build({ + entryPoints: ['src/main/daemon/pty-subprocess/windows-bun-pty-gate-entry.ts'], + bundle: true, + platform: 'node', + format: 'cjs', + external, + outfile: join(directory, 'windows-bun-pty-gate-entry.js'), + logLevel: 'silent' + }) + } + const isolatedRuntime = join(directory, orcadBunRuntimeFilename(process.platform)) + await copyFile(runtime, isolatedRuntime) + const result = await runProcess({ + program: isolatedRuntime, + args: ['--no-install', entry], + cwd: directory, + env: { + ...process.env, + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_DISABLE_MACOS_LOGIN_SHELL: '1', + ORCA_USER_DATA_PATH: directory + }, + timeoutMs: 15_000, + terminationBarrier: true + }) + expect(result.code, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toEqual({ + version: ORCAD_BUN_VERSION, + code: 17, + output: true, + reattached: true, + retired: true + }) + } finally { + removeTreeSync(directory) + } + }, 20_000) +}) diff --git a/src/main/providers/local-pty-bun-posix-inspection.test.ts b/src/main/providers/local-pty-bun-posix-inspection.test.ts new file mode 100644 index 00000000000..c71e4a3fa6c --- /dev/null +++ b/src/main/providers/local-pty-bun-posix-inspection.test.ts @@ -0,0 +1,153 @@ +import type { IPty } from 'node-pty' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ProcessTableReader from '../../shared/process-table-snapshot-reader' +import type { ProcessTableRow } from '../../shared/process-table-snapshot' +import { + confirmLocalPtyForegroundProcess, + getLocalPtyForegroundProcess, + hasLocalPtyChildProcesses, + inspectLocalPtyChildProcesses +} from './local-pty-foreground-inspection' +import { LocalPtyProvider } from './local-pty-provider' +import { ptyLastRecognizedForeground, ptyProcesses, ptyShellPath } from './local-pty-provider-state' + +const scans = vi.hoisted(() => ({ full: vi.fn(), fresh: vi.fn(), strict: vi.fn() })) +vi.mock('../../shared/process-table-snapshot-reader', async (importOriginal) => ({ + ...(await importOriginal()), + getProcessTableSnapshot: scans.full, + getFreshProcessTableSnapshot: scans.fresh, + getStrictProcessTableSnapshotWithAge: scans.strict +})) +const platform = Object.getOwnPropertyDescriptor(process, 'platform')! +const id = 'bun-pane' +const provider = new LocalPtyProvider() +let rows: ProcessTableRow[] + +function row(pid: number, ppid: number, command: string, foregroundPid: number): ProcessTableRow { + return { + pid, + ppid, + command, + pgid: pid, + tpgid: foregroundPid, + stat: pid === foregroundPid ? 'Ss+' : 'Ss', + tty: 'ttys002' + } +} + +function pane(): IPty & { processNameIsSpawnFile: true } { + return { + pid: 100, + process: '/bin/zsh', + processNameIsSpawnFile: true, + cols: 80, + rows: 24, + handleFlowControl: false, + onData: () => ({ dispose() {} }), + onExit: () => ({ dispose() {} }), + resize() {}, + clear() {}, + write() {}, + kill() {}, + pause() {}, + resume() {} + } +} + +beforeEach(() => { + vi.resetAllMocks() + scans.full.mockImplementation(async () => rows) + scans.fresh.mockImplementation(async () => rows) + scans.strict.mockImplementation(async () => ({ rows, capturedAgeMs: 0 })) + ptyProcesses.set(id, pane()) + ptyShellPath.set(id, '/bin/zsh') +}) +afterEach(() => { + ptyProcesses.clear() + ptyShellPath.clear() + ptyLastRecognizedForeground.clear() + Object.defineProperty(process, 'platform', platform) + vi.restoreAllMocks() +}) + +describe.each(['darwin', 'linux'])('Bun in-process %s inspection', (host) => { + beforeEach(() => Object.defineProperty(process, 'platform', { value: host, configurable: true })) + + it('reports the actual foreground command and warns before closing a busy pane', async () => { + rows = [row(100, 1, '-zsh', 101), row(101, 100, 'vim notes.md', 101)] + expect(await provider.inspectProcess(id)).toEqual({ + foregroundProcess: 'vim', + hasChildProcesses: true, + childProcessEvidence: 'children' + }) + expect(await hasLocalPtyChildProcesses(id)).toBe(true) + expect(await confirmLocalPtyForegroundProcess(id)).toBe('vim') + expect(scans.fresh).toHaveBeenCalledOnce() + }) + + it('proves an idle shell has no children', async () => { + rows = [row(100, 1, '-zsh', 100)] + expect(await provider.inspectProcess(id)).toEqual({ + foregroundProcess: 'zsh', + hasChildProcesses: false, + childProcessEvidence: 'no-children' + }) + expect(await hasLocalPtyChildProcesses(id)).toBe(false) + }) + + it('does not mistake the login wrapper for a running user job', async () => { + ptyProcesses.set(id, { ...pane(), process: '/usr/bin/login' }) + rows = [row(100, 1, '/usr/bin/login -fp test', 101), row(101, 100, '-zsh', 101)] + expect(await provider.inspectProcess(id)).toEqual({ + foregroundProcess: 'zsh', + hasChildProcesses: false, + childProcessEvidence: 'no-children' + }) + }) + + it('preserves a cached agent when the foreground scan cannot verify it', async () => { + rows = [] + scans.full.mockRejectedValue(new Error('process table unavailable')) + scans.strict.mockRejectedValue(new Error('process table unavailable')) + ptyLastRecognizedForeground.set(id, { name: 'claude', pid: 101, at: Date.now() }) + expect(await provider.inspectProcess(id)).toEqual({ + foregroundProcess: 'claude', + hasChildProcesses: true, + childProcessEvidence: 'unverifiable' + }) + expect(await hasLocalPtyChildProcesses(id)).toBe(true) + expect(await confirmLocalPtyForegroundProcess(id)).toBeNull() + }) + + it('returns uncertainty when the process table has no pane root', async () => { + rows = [row(900, 1, '-zsh', 900)] + expect(await getLocalPtyForegroundProcess(id)).toBeNull() + expect(await inspectLocalPtyChildProcesses(id)).toBe('unverifiable') + expect(await hasLocalPtyChildProcesses(id)).toBe(true) + }) + + it('does not resurrect an agent cache after replacement during fingerprint capture', async () => { + rows = [row(100, 1, '-zsh', 101), row(101, 100, 'node /usr/local/bin/claude', 101)] + scans.full + .mockImplementationOnce(async () => rows) + .mockImplementationOnce(async () => { + ptyProcesses.set(id, { ...pane(), pid: 999 }) + return rows + }) + expect(await getLocalPtyForegroundProcess(id)).toBeNull() + expect(ptyLastRecognizedForeground.has(id)).toBe(false) + }) + + it('does not combine an old foreground with a replacement pane during a child scan', async () => { + rows = [row(100, 1, '-zsh', 101), row(101, 100, 'vim notes.md', 101)] + scans.strict.mockImplementationOnce(async () => { + ptyProcesses.set(id, { ...pane(), pid: 999 }) + return { rows, capturedAgeMs: 0 } + }) + expect(await provider.inspectProcess(id)).toEqual({ + foregroundProcess: null, + hasChildProcesses: true, + childProcessEvidence: 'unverifiable' + }) + }) +}) diff --git a/src/main/providers/local-pty-bun-windows-identity.test.ts b/src/main/providers/local-pty-bun-windows-identity.test.ts new file mode 100644 index 00000000000..43444579046 --- /dev/null +++ b/src/main/providers/local-pty-bun-windows-identity.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ptyProcesses, ptyShellPath } from './local-pty-provider-state' +import { + confirmLocalPtyShellForeground, + inspectLocalPtyChildProcesses +} from './local-pty-foreground-inspection' +import { getLocalPtyCwd, sendLocalPtySignal } from './local-pty-session-operations' + +const { confirm, cwd, membership } = vi.hoisted(() => ({ + confirm: vi.fn(), + cwd: vi.fn(), + membership: vi.fn() +})) +vi.mock('./agent-foreground-process', () => ({ + confirmShellForegroundProcess: confirm, + resolveAgentForegroundProcessWithAvailability: vi.fn() +})) +vi.mock('./process-cwd', () => ({ resolveProcessCwd: cwd })) +vi.mock('./windows-pty-job-membership', () => ({ + readWindowsPtyJobProcessIds: membership, + isWindowsPtyJobReadable: () => true +})) + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! +beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + confirm.mockResolvedValue(true) + cwd.mockResolvedValue('C:\\work') +}) +afterEach(() => { + ptyProcesses.clear() + ptyShellPath.clear() + Object.defineProperty(process, 'platform', originalPlatform) + vi.restoreAllMocks() +}) + +describe('Bun in-process Windows shell identity', () => { + it('uses the child shell for cwd and foreground checks while signaling the owned job', async () => { + const proc = { + pid: 1200, + shellProcessId: 1201, + jobRootProcessIsWrapper: true as const, + processNameIsSpawnFile: true as const, + process: 'cmd.exe', + cols: 80, + rows: 24, + handleFlowControl: false, + onData: () => ({ dispose() {} }), + onExit: () => ({ dispose() {} }), + write() {}, + clear() {}, + pause() {}, + resume() {}, + resize() {}, + kill() {}, + signalProcess: vi.fn() + } + ptyProcesses.set('gated-shell', proc) + const shellPath = 'C:\\Windows\\System32\\cmd.exe' + ptyShellPath.set('gated-shell', shellPath) + const kill = vi.spyOn(process, 'kill').mockReturnValue(true) + expect(await getLocalPtyCwd('gated-shell')).toBe('C:\\work') + expect(cwd).toHaveBeenCalledWith(1201) + expect(await confirmLocalPtyShellForeground('gated-shell')).toBe(true) + expect(confirm).toHaveBeenCalledWith(1201, shellPath, expect.any(Object)) + await sendLocalPtySignal('gated-shell', 'SIGTERM') + expect(proc.signalProcess).toHaveBeenCalledWith('SIGTERM') + expect(kill).not.toHaveBeenCalled() + membership.mockReturnValue(new Set([1201])) + expect(await inspectLocalPtyChildProcesses('gated-shell')).toBe('no-children') + membership.mockReturnValue(new Set([1201, 1202])) + expect(await inspectLocalPtyChildProcesses('gated-shell')).toBe('children') + membership.mockReturnValue(null) + expect(await inspectLocalPtyChildProcesses('gated-shell')).toBe('unverifiable') + Reflect.deleteProperty(proc, 'shellProcessId') + cwd.mockClear() + expect(await getLocalPtyCwd('gated-shell')).toBe('') + expect(cwd).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/providers/local-pty-child-process-verdict.test.ts b/src/main/providers/local-pty-child-process-verdict.test.ts index 0338b38aaa4..857017b6671 100644 --- a/src/main/providers/local-pty-child-process-verdict.test.ts +++ b/src/main/providers/local-pty-child-process-verdict.test.ts @@ -99,7 +99,7 @@ describe('confirmLocalPtyShellForeground', () => { const describeOnPosix = process.platform === 'win32' ? describe.skip : describe describe('inspectLocalPtyChildProcesses', () => { - it('reports unverifiable when the pty fd cannot be read', () => { + it('reports unverifiable when the pty fd cannot be read', async () => { registerPane( 'pty-closed', () => { @@ -107,24 +107,24 @@ describe('inspectLocalPtyChildProcesses', () => { }, '/bin/zsh' ) - expect(inspectLocalPtyChildProcesses('pty-closed')).toBe('unverifiable') + expect(await inspectLocalPtyChildProcesses('pty-closed')).toBe('unverifiable') }) - it('still answers no-children when the shell itself is in the foreground', () => { + it('still answers no-children when the shell itself is in the foreground', async () => { registerPane('pty-idle', 'zsh', '/bin/zsh') - expect(inspectLocalPtyChildProcesses('pty-idle')).toBe('no-children') + expect(await inspectLocalPtyChildProcesses('pty-idle')).toBe('no-children') }) - it('answers children when something else is in the foreground', () => { + it('answers children when something else is in the foreground', async () => { registerPane('pty-busy', 'vim', '/bin/zsh') - expect(inspectLocalPtyChildProcesses('pty-busy')).toBe('children') + expect(await inspectLocalPtyChildProcesses('pty-busy')).toBe('children') }) - it('treats a pane this provider does not hold as a real negative', () => { - expect(inspectLocalPtyChildProcesses('pty-absent')).toBe('no-children') + it('treats a pane this provider does not hold as a real negative', async () => { + expect(await inspectLocalPtyChildProcesses('pty-absent')).toBe('no-children') }) - it('collapses uncertainty to false only in the boolean adapter', async () => { + it('preserves uncertainty conservatively in the boolean adapter', async () => { let reads = 0 registerPane( 'pty-closed', @@ -134,8 +134,8 @@ describe('inspectLocalPtyChildProcesses', () => { }, '/bin/zsh' ) - await expect(hasLocalPtyChildProcesses('pty-closed')).resolves.toBe(false) - // The `false` has to come from the failed read, not from an earlier short-circuit. + await expect(hasLocalPtyChildProcesses('pty-closed')).resolves.toBe(true) + // The result must come from the failed read, not from an earlier short-circuit. expect(reads).toBe(1) }) }) @@ -147,14 +147,14 @@ describeOnPosix('inspectLocalPtyChildProcesses on a retired master', () => { // The mechanism is silent: this is the same string an idle pane reports. expect(term.process).toBe(POSIX_SHELL) // Not `no-children`: the close guard reads that as "nothing is running here" and kills the pane. - expect(inspectLocalPtyChildProcesses('pty-retired')).toBe('unverifiable') + expect(await inspectLocalPtyChildProcesses('pty-retired')).toBe('unverifiable') }, 15000) - it('collapses uncertainty to false only in the boolean adapter', async () => { + it('preserves uncertainty conservatively in the boolean adapter', async () => { await registerRetiredPane('pty-retired') // The adapter exists for `IPtyProvider.hasChildProcesses`, which has no third slot. - await expect(hasLocalPtyChildProcesses('pty-retired')).resolves.toBe(false) + await expect(hasLocalPtyChildProcesses('pty-retired')).resolves.toBe(true) }, 15000) }) @@ -176,7 +176,7 @@ describe('inspectPtyProviderProcess child-process evidence', () => { ) await expect(inspectPtyProviderProcess(provider, 'pty-closing')).resolves.toEqual({ foregroundProcess: '/bin/zsh', - hasChildProcesses: false, + hasChildProcesses: true, childProcessEvidence: 'unverifiable' }) }) @@ -216,7 +216,7 @@ describe('inspectPtyProviderProcess child-process evidence', () => { await expect(inspectPtyProviderProcess(provider, 'pty-swapped')).resolves.toEqual({ foregroundProcess: null, - hasChildProcesses: false, + hasChildProcesses: true, childProcessEvidence: 'unverifiable' }) }) @@ -229,7 +229,7 @@ describeOnPosix('inspectPtyProviderProcess on a retired master', () => { await registerRetiredPane('pty-retired') const inspection = await inspectPtyProviderProcess(provider, 'pty-retired') - expect(inspection.hasChildProcesses).toBe(false) + expect(inspection.hasChildProcesses).toBe(true) expect(inspection.childProcessEvidence).toBe('unverifiable') }, 15000) }) diff --git a/src/main/providers/local-pty-foreground-inspection.ts b/src/main/providers/local-pty-foreground-inspection.ts index 1eb4876cfd0..8c3b0a6dcce 100644 --- a/src/main/providers/local-pty-foreground-inspection.ts +++ b/src/main/providers/local-pty-foreground-inspection.ts @@ -1,13 +1,22 @@ import type { PtyChildProcessVerdict } from '../../shared/terminal-process-inspection' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import { getCheapProcessTableSnapshot } from '../../shared/cheap-process-table-snapshot-reader' -import { getProcessTableSnapshot } from '../../shared/process-table-snapshot-reader' import { - confirmShellForegroundProcess, - resolveAgentForegroundProcessWithAvailability -} from './agent-foreground-process' + getProcessTableSnapshot, + getStrictProcessTableSnapshotWithAge +} from '../../shared/process-table-snapshot-reader' +import { confirmShellForegroundProcess } from './agent-foreground-process' +import { + createPtyForegroundResolver, + ptyProcessNameIsSpawnFile +} from '../daemon/pty-subprocess/spawn-file-foreground-process' +import { + inspectSpawnFileChildProcessesFromRows, + inspectSpawnFileWindowsChildProcesses +} from '../daemon/pty-subprocess/spawn-file-child-processes' import { buildPaneProcessFingerprint } from './posix-pane-foreground-fingerprint' import { isRetiredPtyMaster } from '../pty/node-pty-master-fd-retirement' +import { ptyShellProcessId } from '../windows/windows-pty-job' import { resolveForegroundFallbackProcess } from './local-pty-launch-helpers' import { ptyAgentForegroundContextPaths, @@ -29,7 +38,7 @@ import { isWindowsPtyJobReadable, readWindowsPtyJobProcessIds } from './windows- * equals the recorded shell and would otherwise read as a real "nothing is running here". Ask the * descriptor before the name, because an unreadable PTY is not evidence that its children exited. */ -export function inspectLocalPtyChildProcesses(id: string): PtyChildProcessVerdict { +export async function inspectLocalPtyChildProcesses(id: string): Promise { const proc = ptyProcesses.get(id) if (!proc) { return 'no-children' @@ -38,6 +47,19 @@ export function inspectLocalPtyChildProcesses(id: string): PtyChildProcessVerdic return 'unverifiable' } try { + if (ptyProcessNameIsSpawnFile(proc)) { + if (process.platform === 'win32') { + return inspectSpawnFileWindowsChildProcesses(proc) + } + const snapshot = await getStrictProcessTableSnapshotWithAge() + return ptyProcesses.get(id) === proc + ? inspectSpawnFileChildProcessesFromRows( + snapshot.rows, + proc.pid, + getPtyShellName(id) ?? null + ) + : 'unverifiable' + } const foreground = proc.process const shell = getPtyShellName(id) if (!shell) { @@ -51,7 +73,7 @@ export function inspectLocalPtyChildProcesses(id: string): PtyChildProcessVerdic } export async function hasLocalPtyChildProcesses(id: string): Promise { - return inspectLocalPtyChildProcesses(id) === 'children' + return (await inspectLocalPtyChildProcesses(id)) !== 'no-children' } /** @@ -89,7 +111,7 @@ export async function getLocalPtyForegroundProcess(id: string): Promise readWindowsPtyJobProcessIds(proc) } diff --git a/src/main/providers/local-pty-launch-plan.ts b/src/main/providers/local-pty-launch-plan.ts index c8b784e7cb3..17351325300 100644 --- a/src/main/providers/local-pty-launch-plan.ts +++ b/src/main/providers/local-pty-launch-plan.ts @@ -1,4 +1,5 @@ import { win32 as pathWin32 } from 'node:path' +import { canUseBunPty } from '../daemon/pty-subprocess/bun-pty-process-capabilities' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell' import { resolveWindowsGitBashShellPath } from '../git-bash' @@ -76,7 +77,9 @@ function finalizeLocalPtyLaunchPlan( windowsFallbackAttempts?: ReturnType } ): LocalPtyLaunchPlan { - ensureNodePtySpawnHelperExecutable() + if (!canUseBunPty()) { + ensureNodePtySpawnHelperExecutable() + } if (seed.args.prevalidatedCwd !== shell.validationCwd) { validateWorkingDirectory(shell.validationCwd) } diff --git a/src/main/providers/local-pty-pending-native-spawn.test.ts b/src/main/providers/local-pty-pending-native-spawn.test.ts new file mode 100644 index 00000000000..7f292e41273 --- /dev/null +++ b/src/main/providers/local-pty-pending-native-spawn.test.ts @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { spawnLocalPty } from './local-pty-spawn' +import { cancelPendingLocalPtySpawns } from './local-pty-spawn-state' +import { pendingLocalPtySpawns, ptyProcesses } from './local-pty-provider-state' + +const { spawn, activate, destroy } = vi.hoisted(() => ({ + spawn: vi.fn(), + activate: vi.fn(), + destroy: vi.fn() +})) +vi.mock('./local-pty-runtime-spawn', () => ({ loadLocalPtyRuntimeSpawn: async () => spawn })) +vi.mock('./macos-tcc-login-shell', () => ({ prepareMacosTccLoginShell: async () => {} })) +vi.mock('./local-pty-finalize-environment', () => ({ + finalizeLocalPtySpawnEnvironment: () => null +})) +vi.mock('./local-pty-spawn-environment', () => ({ + buildLocalPtySpawnEnvironment: () => ({}), + enforceLocalPtySpawnEnvironmentOverrides() {} +})) +vi.mock('./local-pty-launch-plan', () => ({ + DeferredLocalPtyLaunchPlan: class {}, + createLocalPtyLaunchPlan: () => ({ + shellPath: '/bin/sh', + shellArgs: [], + effectiveCwd: '/tmp', + cwd: '/tmp', + windowsFallbackAttempts: [] + }) +})) +vi.mock('./local-pty-session-activation', () => ({ activateLocalPtySession: activate })) +vi.mock('./local-pty-termination', () => ({ destroyPtyProcess: destroy })) + +function createProcess() { + return { + pid: 12345, + process: '/bin/sh', + cols: 80, + rows: 24, + handleFlowControl: false, + onData: () => ({ dispose() {} }), + onExit: () => ({ dispose() {} }), + write() {}, + clear() {}, + pause() {}, + resume() {}, + resize: vi.fn(), + kill: vi.fn() + } +} + +function start(id = 'pending-bun-shell') { + return spawnLocalPty({ sessionId: id, cols: 80, rows: 24 }, () => ({})) +} + +beforeEach(() => { + vi.clearAllMocks() + activate.mockImplementation(({ id, proc }) => { + ptyProcesses.set(id, proc) + return { id, pid: proc.pid } + }) +}) +afterEach(() => { + ptyProcesses.clear() + expect(pendingLocalPtySpawns.size).toBe(0) +}) + +describe('local PTY native spawn admission', () => { + it('aborts a pending receipt when the requesting client disconnects', async () => { + const controller = new AbortController() + spawn.mockImplementationOnce( + ({ signal }: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + ) + const result = spawnLocalPty( + { sessionId: 'disconnected-shell', cols: 80, rows: 24, signal: controller.signal }, + () => ({}) + ) + const rejected = expect(result).rejects.toThrow('client disconnected') + await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce()) + controller.abort(new Error('client disconnected')) + await rejected + expect(activate).not.toHaveBeenCalled() + }) + + it('reserves the same session until a delayed shell receipt is activated', async () => { + const proc = createProcess() + let release!: () => void + spawn.mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => resolve({ process: proc, shellPath: '/bin/sh' }) + }) + ) + const first = start() + await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce()) + const second = start() + await new Promise((resolve) => setImmediate(resolve)) + expect(spawn).toHaveBeenCalledOnce() + release() + expect(await first).toEqual({ id: 'pending-bun-shell', pid: proc.pid }) + expect(await second).toMatchObject({ id: 'pending-bun-shell', pid: proc.pid, isReattach: true }) + expect(activate).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() + }) + + it('aborts a pending receipt on shutdown and cancels queued same-session launches', async () => { + spawn.mockImplementationOnce( + ({ signal }: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + ) + const first = start() + await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce()) + const second = start() + const results = Promise.allSettled([first, second]) + cancelPendingLocalPtySpawns('pending-bun-shell') + expect(await results).toEqual([ + { status: 'rejected', reason: new Error('PTY spawn canceled: pending-bun-shell') }, + { status: 'rejected', reason: new Error('PTY spawn canceled: pending-bun-shell') } + ]) + expect(spawn).toHaveBeenCalledOnce() + expect(activate).not.toHaveBeenCalled() + }) + + it('cleans a confirmed process when shutdown races the receipt continuation', async () => { + const proc = createProcess() + let release!: () => void + spawn.mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => resolve({ process: proc, shellPath: '/bin/sh' }) + }) + ) + const first = start() + const rejected = expect(first).rejects.toThrow('PTY spawn canceled: pending-bun-shell') + await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce()) + release() + cancelPendingLocalPtySpawns('pending-bun-shell') + await rejected + expect(proc.kill).toHaveBeenCalledWith('SIGKILL') + expect(destroy).toHaveBeenCalledWith(proc) + expect(activate).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/providers/local-pty-provider-spawn-session.test.ts b/src/main/providers/local-pty-provider-spawn-session.test.ts index 9dfa08d81bf..cf523a469fc 100644 --- a/src/main/providers/local-pty-provider-spawn-session.test.ts +++ b/src/main/providers/local-pty-provider-spawn-session.test.ts @@ -53,6 +53,10 @@ vi.mock('node-pty', () => ({ spawn: spawnMock })) +vi.mock('../daemon/pty-subprocess/bun-pty-process-capabilities', () => ({ + canUseBunPty: () => false +})) + vi.mock('./macos-tcc-login-shell', async (importOriginal) => ({ ...(await importOriginal()), prepareMacosTccLoginShell: prepareMacosTccLoginShellMock @@ -115,6 +119,13 @@ vi.mock('../shell-prompt-readiness-probe', () => ({ })) import { LocalPtyProvider } from './local-pty-provider' +import { + pendingLocalPtySpawns, + ptyDisposables, + ptyExitDisposables, + ptyPhysicalExits, + startupIngressByPty +} from './local-pty-provider-state' import { applyLocalPtyProviderMockDefaults, createLocalPtyMockProcess, @@ -164,6 +175,42 @@ describe('LocalPtyProvider', () => { expect(typeof result.id).toBe('string') }) + it('retires buffered output and synchronous Bun exit before replying to spawn', async () => { + const disposeData = vi.fn() + const disposeExit = vi.fn() + const onExit = vi.fn() + provider.configure({ onExit }) + mockProc.onData.mockImplementation((listener: (data: string) => void) => { + listener('last output') + return { dispose: disposeData } + }) + mockProc.onExit.mockImplementation((listener: (event: { exitCode: number }) => void) => { + listener({ exitCode: 17 }) + return { dispose: disposeExit } + }) + const result = await provider.spawn({ + cols: 80, + rows: 24, + sessionId: 'already-exited-bun-shell', + command: 'must-not-run' + }) + expect(result.exitedBeforeSpawnReply).toBe(true) + expect(provider.getPtyProcess(result.id)).toBeUndefined() + for (const map of [ + ptyDisposables, + ptyExitDisposables, + ptyPhysicalExits, + startupIngressByPty + ]) { + expect(map.has(result.id)).toBe(false) + } + expect(disposeData).toHaveBeenCalledOnce() + expect(disposeExit).toHaveBeenCalledOnce() + expect(onExit).toHaveBeenCalledOnce() + await Promise.resolve() + expect(mockProc.write).not.toHaveBeenCalled() + }) + it('reattaches to an existing caller-supplied session id without spawning', async () => { const first = await provider.spawn({ cols: 80, rows: 24, sessionId: 'serve-session-1' }) spawnMock.mockClear() @@ -363,41 +410,154 @@ describe('LocalPtyProvider', () => { expect(spawnMock).not.toHaveBeenCalled() }) - it('registers post-build preflight before a nested-microtask shutdown', async () => { - spawnMock.mockClear() - let finishEnvBuild!: () => void - const envProvider = new LocalPtyProvider({ - buildSpawnEnv: (_id, baseEnv) => - new Promise>((resolve) => { - finishEnvBuild = () => resolve(baseEnv) - }) - }) - const spawn = envProvider.spawn({ - cols: 80, - rows: 24, - sessionId: 'resolved-env-build-session' - }) - const canceledSpawn = expect(spawn).rejects.toThrow( - 'PTY spawn canceled: resolved-env-build-session' - ) - await vi.waitFor(() => expect(finishEnvBuild).toBeTypeOf('function')) + it.each([1, 2])( + 'keeps cancellation registered across %i environment-resume microtasks', + async (microtasks) => { + spawnMock.mockClear() + let finishEnvBuild!: () => void + const envProvider = new LocalPtyProvider({ + buildSpawnEnv: (_id, baseEnv) => + new Promise>((resolve) => { + finishEnvBuild = () => resolve(baseEnv) + }) + }) + const spawn = envProvider.spawn({ + cols: 80, + rows: 24, + sessionId: 'resolved-env-build-session' + }) + const canceledSpawn = expect(spawn).rejects.toThrow( + 'PTY spawn canceled: resolved-env-build-session' + ) + await vi.waitFor(() => expect(finishEnvBuild).toBeTypeOf('function')) - finishEnvBuild() - const shutdown = new Promise((resolve, reject) => { - queueMicrotask(() => { - queueMicrotask(() => { + finishEnvBuild() + const shutdown = new Promise((resolve, reject) => { + const shutdown = () => { envProvider .shutdown('resolved-env-build-session', { immediate: true }) .then(resolve, reject) - }) + } + queueMicrotask(() => (microtasks === 1 ? shutdown() : queueMicrotask(shutdown))) }) - }) - await shutdown - await canceledSpawn - expect(spawnMock).not.toHaveBeenCalled() + await shutdown + await canceledSpawn + expect(spawnMock).not.toHaveBeenCalled() + } + ) + + it.each([1, 2])( + 'settles final-preflight shutdown without a late PTY (%i microtasks)', + async (microtasks) => { + spawnMock.mockClear() + let finishPreparation!: () => void + prepareMacosTccLoginShellMock.mockImplementationOnce( + () => + new Promise((resolve) => { + finishPreparation = resolve + }) + ) + const id = 'preflight-resume-session' + const kill = mockProc.kill + let committed = false + const outcome = provider + .spawn({ + cols: 80, + rows: 24, + sessionId: id, + onPtySpawnCommitted: () => { + committed = true + } + }) + .then( + (result) => ({ ok: true as const, result }), + (error: unknown) => ({ ok: false as const, error }) + ) + await import('node-pty') + await vi.waitFor(() => expect(finishPreparation).toBeTypeOf('function')) + finishPreparation() + let committedAtShutdown = false + await new Promise((resolve, reject) => { + const shutdown = () => { + committedAtShutdown = committed + provider.shutdown(id, { immediate: true }).then(resolve, reject) + } + queueMicrotask(() => (microtasks === 1 ? shutdown() : queueMicrotask(shutdown))) + }) + const settled = await outcome + if (committedAtShutdown) { + expect(settled.ok).toBe(true) + expect(kill).toHaveBeenCalled() + } else { + expect(settled).toEqual({ ok: false, error: new Error(`PTY spawn canceled: ${id}`) }) + expect(spawnMock.mock.calls.length).toBe(0) + } + expect(provider.getPtyProcess(id)).toBeUndefined() + expect(pendingLocalPtySpawns.has(id)).toBe(false) + } + ) + + it('cancels deferred shell availability before finalizing a launch plan', async () => { + spawnMock.mockClear() + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + let finishAvailability!: (available: boolean) => void + const buildSpawnEnv = vi.fn((_id: string, env: Record) => env) + provider.configure({ + getWindowsShell: () => 'powershell.exe', + getWindowsPowerShellImplementation: () => 'auto', + pwshAvailable: () => + new Promise((resolve) => { + finishAvailability = resolve + }), + buildSpawnEnv + }) + const id = 'deferred-availability-session' + const spawn = provider.spawn({ cols: 80, rows: 24, sessionId: id }) + const rejected = expect(spawn).rejects.toThrow(`PTY spawn canceled: ${id}`) + await provider.shutdown(id, { immediate: true }) + finishAvailability(true) + await rejected + expect(buildSpawnEnv).not.toHaveBeenCalled() + expect(spawnMock.mock.calls.length).toBe(0) + expect(pendingLocalPtySpawns.has(id)).toBe(false) }) + it.each(['synchronous environment', 'async environment', 'preflight'])( + 'releases cancellation state after failed %s and allows a fresh retry', + async (phase) => { + spawnMock.mockClear() + const id = 'failed-preparation-session' + let fail = true + provider.configure({ + buildSpawnEnv: (_id, env) => { + if (fail && phase === 'synchronous environment') { + throw new Error('preparation failed') + } + if (fail && phase === 'async environment') { + return Promise.reject(new Error('preparation failed')) + } + return env + } + }) + if (phase === 'preflight') { + prepareMacosTccLoginShellMock.mockRejectedValueOnce(new Error('preparation failed')) + } + await expect(provider.spawn({ cols: 80, rows: 24, sessionId: id })).rejects.toThrow( + 'preparation failed' + ) + expect(pendingLocalPtySpawns.has(id)).toBe(false) + expect(spawnMock.mock.calls.length).toBe(0) + await provider.shutdown(id, { immediate: true }) + fail = false + await expect(provider.spawn({ cols: 80, rows: 24, sessionId: id })).resolves.toMatchObject({ + id + }) + expect(spawnMock.mock.calls.length).toBe(1) + expect(pendingLocalPtySpawns.has(id)).toBe(false) + } + ) + it('coalesces a concurrent same-session-id spawn before launching a redundant shell (F3)', async () => { spawnMock.mockClear() const procA = { ...mockProc, pid: 1001 } diff --git a/src/main/providers/local-pty-provider-state.ts b/src/main/providers/local-pty-provider-state.ts index d38175c0d2b..10cef331a62 100644 --- a/src/main/providers/local-pty-provider-state.ts +++ b/src/main/providers/local-pty-provider-state.ts @@ -12,7 +12,7 @@ export type PtyShutdownOperation = { } export type PendingLocalPtySpawn = { - canceled: boolean + cancellation: AbortController } export type DataCallback = (payload: { diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 8dad9843ab6..728e8a1273a 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -118,6 +118,8 @@ export class LocalPtyProvider implements IPtyProvider { clearBuffer(id: string): Promise { return clearLocalPtyBuffer(id) } + // A direct PTY keeps no terminal model of its own. + async resetInputModes(_id: string): Promise {} closeStartupQueryAuthority(id: string): number { return closeLocalPtyStartupQueryAuthority(id) } @@ -132,20 +134,18 @@ export class LocalPtyProvider implements IPtyProvider { async inspectProcess(id: string): Promise { const proc = ptyProcesses.get(id) const foregroundProcess = await getLocalPtyForegroundProcess(id) - // Both fields have to describe one PTY: cleanup plus reactivation across the await above would - // otherwise pair the old pane's identity with the replacement's children. The child read below - // is synchronous, so this recheck is the last point either answer can drift. + const childProcessEvidence = await inspectLocalPtyChildProcesses(id) + // Neither asynchronous inspection may publish a replacement pane's identity. if (ptyProcesses.get(id) !== proc) { return { foregroundProcess: null, - hasChildProcesses: false, + hasChildProcesses: true, childProcessEvidence: 'unverifiable' } } - const childProcessEvidence = inspectLocalPtyChildProcesses(id) return { foregroundProcess, - hasChildProcesses: childProcessEvidence === 'children', + hasChildProcesses: childProcessEvidence !== 'no-children', childProcessEvidence } } diff --git a/src/main/providers/local-pty-runtime-spawn.ts b/src/main/providers/local-pty-runtime-spawn.ts new file mode 100644 index 00000000000..7d3c26d90ad --- /dev/null +++ b/src/main/providers/local-pty-runtime-spawn.ts @@ -0,0 +1,44 @@ +import { canUseBunPty, spawnBunPty } from '../daemon/pty-subprocess/bun-pty-process' +import { spawnNativeDaemonPty } from '../daemon/pty-subprocess/native-pty-spawn' +import { + spawnShellWithFallback, + type ShellSpawnParams, + type ShellSpawnResult +} from './local-pty-utils' + +type LocalPtySpawn = ( + params: Omit & { signal?: AbortSignal } +) => ShellSpawnResult | Promise + +/** Degraded daemon routing uses the same runtime as the packaged host. */ +export async function loadLocalPtyRuntimeSpawn(): Promise { + if (!canUseBunPty()) { + const pty = await import('node-pty') + return (params) => spawnShellWithFallback({ ...params, ptySpawn: pty.spawn }) + } + if (process.platform === 'win32') { + return (params) => + spawnNativeDaemonPty({ + ...params, + spawnCwd: params.cwd, + windowsFallbackAttempts: params.windowsFallbackAttempts ?? [] + }) + } + return (params) => + spawnShellWithFallback({ + ...params, + ptySpawn(file, args = [], options = {}) { + if (!Array.isArray(args)) { + throw new Error('POSIX PTY arguments must be an array') + } + return spawnBunPty({ + file, + args, + cwd: options.cwd ?? params.cwd, + env: params.env, + cols: options.cols ?? params.cols, + rows: options.rows ?? params.rows + }) + } + }) +} diff --git a/src/main/providers/local-pty-session-activation.ts b/src/main/providers/local-pty-session-activation.ts index 71c633483db..e89f0c43316 100644 --- a/src/main/providers/local-pty-session-activation.ts +++ b/src/main/providers/local-pty-session-activation.ts @@ -122,8 +122,11 @@ export function activateLocalPtySession(args: { if (onDataDisposable) { disposables.push(onDataDisposable) } + ptyDisposables.set(id, disposables) + let exitedBeforeSpawnReply = false const onExitDisposable = proc.onExit(({ exitCode, signal }) => { + exitedBeforeSpawnReply = true // Why: node-pty reports a signalled death as {exitCode: 0, signal: N}; the // cause is built here, where the signal and the spawn's trustworthiness // are both still in hand. @@ -151,14 +154,18 @@ export function activateLocalPtySession(args: { } }) if (onExitDisposable) { - ptyExitDisposables.set(id, onExitDisposable) + if (exitedBeforeSpawnReply) { + onExitDisposable.dispose() + } else { + ptyExitDisposables.set(id, onExitDisposable) + } } - ptyDisposables.set(id, disposables) const startupCommandDeliveredByWrapper = spawn.command !== undefined && plan.shellReadyLaunch?.env[POSIX_SHELL_STARTUP_COMMAND_ENV] === spawn.command if ( + !exitedBeforeSpawnReply && spawn.command && !plan.startupCommandDeliveredInShellArgs && !startupCommandDeliveredByWrapper @@ -191,6 +198,7 @@ export function activateLocalPtySession(args: { id, incarnationId, pid, + ...(exitedBeforeSpawnReply ? { exitedBeforeSpawnReply: true } : {}), ...(spawnedWslDistro !== undefined ? { wslDistro: spawnedWslDistro } : {}) } } diff --git a/src/main/providers/local-pty-session-operations.ts b/src/main/providers/local-pty-session-operations.ts index 37e77081206..27a151580ce 100644 --- a/src/main/providers/local-pty-session-operations.ts +++ b/src/main/providers/local-pty-session-operations.ts @@ -3,6 +3,7 @@ import { basename } from 'node:path' import type * as pty from 'node-pty' import { readPtsName } from '../pty/node-pty-pts-name' import { signalPosixPtyForegroundGroup } from '../pty/posix-pty-foreground-group' +import { ptyShellProcessId } from '../windows/windows-pty-job' import { isWslAvailableAsync } from '../wsl' import { resolveGitBashPath } from '../git-bash' import { resolveProcessCwd } from './process-cwd' @@ -74,6 +75,10 @@ export async function sendLocalPtySignal(id: string, signal: string): Promise { try { process.kill(proc.pid, signal) @@ -97,7 +102,8 @@ export async function getLocalPtyCwd(id: string): Promise { return '' } // Why: let resolveProcessCwd's '' surface for the renderer fallback chain; a fabricated cwd would short-circuit it. - return resolveProcessCwd(proc.pid) + const shellPid = ptyShellProcessId(proc) + return shellPid === undefined ? '' : resolveProcessCwd(shellPid) } export async function clearLocalPtyBuffer(id: string): Promise { diff --git a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts index f752b75f67a..877fdda3bcc 100644 --- a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts +++ b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts @@ -5,6 +5,7 @@ * startup-file chain, OSC 133 hooks, and the shell-ready marker all live here. */ import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition' +import { WSL_MANAGED_CLI_PATH_RESTORE } from '../wsl-managed-cli-path-restore' import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' import { getPosixCodexShellLaunchPreflight } from '../pty/codex-shell-launch-preflight' import { getBashStartupCommandPromptBlock } from '../pty/posix-shell-startup-command' @@ -45,6 +46,7 @@ __orca_restore_agent_teams_path() { export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" } __orca_restore_agent_teams_path +${WSL_MANAGED_CLI_PATH_RESTORE} # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the Orca-managed config dir before the first prompt. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" diff --git a/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts b/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts index f0e3f792fc0..528dd6ee149 100644 --- a/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts +++ b/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts @@ -24,10 +24,10 @@ export function getLocalZshWrapperSpec(): ZshStartupHookSpec { overlayRestoreComment: "# Why: ~/.zshrc can export the user's default OpenCode config after spawn.", restores: { + managedWslCli: true, agentTeamsPath: true, remoteCliBinDir: false, - codexHome: true, - codexLaunchPreflight: true + codexHome: true } } } diff --git a/src/main/providers/local-pty-spawn-environment.ts b/src/main/providers/local-pty-spawn-environment.ts index 8290baa3dc3..645fcacb2d0 100644 --- a/src/main/providers/local-pty-spawn-environment.ts +++ b/src/main/providers/local-pty-spawn-environment.ts @@ -8,10 +8,10 @@ import { stripInheritedBuildModeEnv } from '../pty/build-mode-env' import { stripPiProcessOwnerEnv } from '../pty/pi-process-owner-env' import { removeInheritedNoColor } from '../pty/terminal-color-env' import { isWindowsGitBashShellPath } from '../git-bash' +import { applyScrubSafeAgentEnvAliases } from '../../shared/agent-hook-scrub-safe-env' import { removeUnspecifiedPaneIdentityEnv } from './local-pty-launch-helpers' import type { LocalPtyLaunchPlan } from './local-pty-launch-plan' import type { LocalPtyProviderOptions } from './local-pty-provider-types' -import { awaitCancelableLocalPtySpawn } from './local-pty-spawn-state' import type { PtySpawnOptions } from './types' /** Pane ownership must be fresh even when Orca itself was launched inside an agent. */ @@ -43,6 +43,8 @@ export function buildLocalPtySpawnEnvironment(args: { if (spawn.env?.TERM) { spawnEnv.TERM = spawn.env.TERM } + // Why after the strips and deletes: an alias must never outlive the value it mirrors. + applyScrubSafeAgentEnvAliases(spawnEnv) spawnEnv.LANG ??= 'en_US.UTF-8' @@ -58,21 +60,16 @@ export function buildLocalPtySpawnEnvironment(args: { if (!getOptions().buildSpawnEnv) { return spawnEnv } - // Why (#16441): building the env now awaits Codex hook installs and trust - // grants, so shutdown must be able to cancel this session id here too. - return awaitCancelableLocalPtySpawn( - id, - getOptions().buildSpawnEnv!(id, spawnEnv, { - explicitEnv: spawn.env ?? {}, - command: spawn.command, - launchAgent: spawn.launchAgent, - codexHomePathOverride: spawn.codexHomePathOverride, - cwd: plan.cwd, - shellPath: plan.shellPath, - isWsl: plan.isWslShell, - wslDistro: plan.launchWslDistro - }) - ) + return getOptions().buildSpawnEnv!(id, spawnEnv, { + explicitEnv: spawn.env ?? {}, + command: spawn.command, + launchAgent: spawn.launchAgent, + codexHomePathOverride: spawn.codexHomePathOverride, + cwd: plan.cwd, + shellPath: plan.shellPath, + isWsl: plan.isWslShell, + wslDistro: plan.launchWslDistro + }) } /** App-level env builders can reintroduce deleted keys; enforce isolation after they finish. */ diff --git a/src/main/providers/local-pty-spawn-state.ts b/src/main/providers/local-pty-spawn-state.ts index 2ab145f6c39..d73d230f68b 100644 --- a/src/main/providers/local-pty-spawn-state.ts +++ b/src/main/providers/local-pty-spawn-state.ts @@ -7,22 +7,34 @@ import { type PendingLocalPtySpawn } from './local-pty-provider-state' -/** Awaits pre-launch work that shutdown must be able to cancel: no node-pty - * process exists yet, so cancellation can only be observed after the await. */ -export async function awaitCancelableLocalPtySpawn( +const spawnReservations = new Map>() + +/** A Windows shell receipt can arrive after another request reaches the same native spawn. */ +export async function reserveLocalPtySpawn(id: string, operation: () => Promise): Promise { + const previous = spawnReservations.get(id) + const pending = previous ? previous.catch(() => {}).then(operation) : operation() + spawnReservations.set(id, pending) + try { + return await pending + } finally { + if (spawnReservations.get(id) === pending) { + spawnReservations.delete(id) + } + } +} + +/** Keep shutdown visible between awaits until the native process is registered. */ +export async function runCancelableLocalPtySpawn( id: string, - operation: T | Promise + operation: (throwIfCanceled: () => void, signal: AbortSignal) => Promise ): Promise { - const pendingSpawn: PendingLocalPtySpawn = { canceled: false } + const cancellation = new AbortController() + const pendingSpawn: PendingLocalPtySpawn = { cancellation } const pending = pendingLocalPtySpawns.get(id) ?? new Set() pending.add(pendingSpawn) pendingLocalPtySpawns.set(id, pending) try { - const result = await operation - if (pendingSpawn.canceled) { - throw new Error(`PTY spawn canceled: ${id}`) - } - return result + return await operation(() => cancellation.signal.throwIfAborted(), cancellation.signal) } finally { pending.delete(pendingSpawn) if (pending.size === 0) { @@ -37,7 +49,7 @@ export function cancelPendingLocalPtySpawns(id: string): void { return } for (const pendingSpawn of pending) { - pendingSpawn.canceled = true + pendingSpawn.cancellation.abort(new Error(`PTY spawn canceled: ${id}`)) } } diff --git a/src/main/providers/local-pty-spawn.ts b/src/main/providers/local-pty-spawn.ts index 19436a8c405..cc3c39979a8 100644 --- a/src/main/providers/local-pty-spawn.ts +++ b/src/main/providers/local-pty-spawn.ts @@ -1,6 +1,5 @@ import { randomUUID } from 'node:crypto' import { win32 as pathWin32 } from 'node:path' -import * as pty from 'node-pty' import { SessionNotFoundError } from '../daemon/daemon-errors' import { prepareMacosTccLoginShell } from './macos-tcc-login-shell' import { finalizeLocalPtySpawnEnvironment } from './local-pty-finalize-environment' @@ -13,9 +12,14 @@ import { buildLocalPtySpawnEnvironment, enforceLocalPtySpawnEnvironmentOverrides } from './local-pty-spawn-environment' -import { awaitCancelableLocalPtySpawn, reattachLocalPty } from './local-pty-spawn-state' -import { spawnShellWithFallback } from './local-pty-utils' -import { updateHistoryEnvForFallback, type HistoryInjectionResult } from '../terminal-history' +import { + runCancelableLocalPtySpawn, + reattachLocalPty, + reserveLocalPtySpawn +} from './local-pty-spawn-state' +import { loadLocalPtyRuntimeSpawn } from './local-pty-runtime-spawn' +import { destroyPtyProcess } from './local-pty-termination' +import { updateHistoryEnvForFallback } from '../terminal-history' import type { PtySpawnOptions, PtySpawnResult } from './types' export async function spawnLocalPty( @@ -37,86 +41,108 @@ export async function spawnLocalPty( throw new SessionNotFoundError(args.sessionId ?? '') } const id = allocatePtyId(reattachId ?? undefined) - const incarnationId = randomUUID() - const planResult = createLocalPtyLaunchPlan(args, getOptions) - const plan = - planResult instanceof DeferredLocalPtyLaunchPlan - ? planResult.finish(await planResult.availability) - : planResult - const envResult = buildLocalPtySpawnEnvironment({ - id, - spawn: args, - getOptions, - plan - }) - const finalEnv = envResult instanceof Promise ? await envResult : envResult - enforceLocalPtySpawnEnvironmentOverrides(args, finalEnv) - const historyResult = finalizeLocalPtySpawnEnvironment({ - spawn: args, - getOptions, - plan, - env: finalEnv - }) + return runCancelableLocalPtySpawn(id, async (throwIfCanceled, cancellation) => { + const incarnationId = randomUUID() + let plan = createLocalPtyLaunchPlan(args, getOptions) + if (plan instanceof DeferredLocalPtyLaunchPlan) { + const available = await plan.availability + throwIfCanceled() + plan = plan.finish(available) + } + throwIfCanceled() + const envResult = buildLocalPtySpawnEnvironment({ + id, + spawn: args, + getOptions, + plan + }) + const finalEnv = envResult instanceof Promise ? await envResult : envResult + throwIfCanceled() + enforceLocalPtySpawnEnvironmentOverrides(args, finalEnv) + const historyResult = finalizeLocalPtySpawnEnvironment({ + spawn: args, + getOptions, + plan, + env: finalEnv + }) - // Why: the async macOS capability probe runs before node-pty exists. - await awaitCancelableLocalPtySpawn(id, prepareMacosTccLoginShell()) - if (args.signal?.aborted) { - throw new Error('client_disconnected') - } - // Why: another same-id request can win while this one awaits preflight; attach before launching a redundant shell. - const concurrentWinner = reattachId ? reattachLocalPty(id, args.cols, args.rows) : null - if (concurrentWinner) { - return concurrentWinner - } - const spawnResult = spawnShellWithFallback({ - shellPath: plan.shellPath, - shellArgs: plan.shellArgs, - cols: args.cols, - rows: args.rows, - cwd: plan.effectiveCwd, - env: finalEnv, - termName: finalEnv.TERM, - ptySpawn: pty.spawn, - getShellReadyConfig: plan.getFallbackShellReadyConfig, - launchEnvKeys: plan.primaryLaunchEnvKeys, - // Why: on zsh→bash fallback HISTFILE still points to zsh_history; update before spawn so the child inherits it (design doc §8). - onBeforeFallbackSpawn: historyResult?.historyDir - ? (env, fallbackShell) => - updateHistoryEnvForFallback(env, fallbackShell, historyResult as HistoryInjectionResult) - : undefined, - windowsFallbackAttempts: plan.windowsFallbackAttempts - }) - args.onPtySpawnCommitted?.() - plan.shellPath = spawnResult.shellPath - // Why: a Windows fallback embeds its startup command in argv; honor the winning shell's delivery flag to avoid a double write. - if (spawnResult.startupCommandDeliveredInShellArgs !== undefined) { - plan.startupCommandDeliveredInShellArgs = spawnResult.startupCommandDeliveredInShellArgs - } - if (args.command && plan.getFallbackShellReadyConfig) { - plan.shellReadyLaunch = plan.getFallbackShellReadyConfig(plan.shellPath) - } + const fallbackHistory = historyResult?.historyDir ? historyResult : undefined + const [spawn] = await Promise.all([loadLocalPtyRuntimeSpawn(), prepareMacosTccLoginShell()]) + return reserveLocalPtySpawn(id, async () => { + const checkCanceled = (): void => { + throwIfCanceled() + if (args.signal?.aborted) { + throw new Error('client_disconnected') + } + } + checkCanceled() + // Why: another same-id request can win while this one awaits preflight; attach before launching a redundant shell. + const concurrentWinner = reattachId ? reattachLocalPty(id, args.cols, args.rows) : null + if (concurrentWinner) { + return concurrentWinner + } + const pendingSpawn = spawn({ + shellPath: plan.shellPath, + shellArgs: plan.shellArgs, + cols: args.cols, + rows: args.rows, + cwd: plan.effectiveCwd, + env: finalEnv, + termName: finalEnv.TERM, + signal: args.signal ? AbortSignal.any([args.signal, cancellation]) : cancellation, + getShellReadyConfig: plan.getFallbackShellReadyConfig, + launchEnvKeys: plan.primaryLaunchEnvKeys, + // Why: on zsh→bash fallback HISTFILE still points to zsh_history; update before spawn so the child inherits it (design doc §8). + onBeforeFallbackSpawn: fallbackHistory + ? (env, fallbackShell) => updateHistoryEnvForFallback(env, fallbackShell, fallbackHistory) + : undefined, + windowsFallbackAttempts: plan.windowsFallbackAttempts + }) + const spawnResult = pendingSpawn instanceof Promise ? await pendingSpawn : pendingSpawn + try { + checkCanceled() + } catch (error) { + try { + spawnResult.process.kill('SIGKILL') + } finally { + destroyPtyProcess(spawnResult.process) + } + throw error + } + args.onPtySpawnCommitted?.() + plan.shellPath = spawnResult.shellPath + // Why: a Windows fallback embeds its startup command in argv; honor the winning shell's delivery flag to avoid a double write. + if (spawnResult.startupCommandDeliveredInShellArgs !== undefined) { + plan.startupCommandDeliveredInShellArgs = spawnResult.startupCommandDeliveredInShellArgs + } + if (args.command && plan.getFallbackShellReadyConfig) { + plan.shellReadyLaunch = plan.getFallbackShellReadyConfig(plan.shellPath) + } - if (process.platform !== 'win32') { - finalEnv.SHELL = plan.shellPath - } + if (process.platform !== 'win32') { + finalEnv.SHELL = plan.shellPath + } - const proc = spawnResult.process - const spawnedShellIsWsl = - process.platform === 'win32' && pathWin32.basename(plan.shellPath).toLowerCase() === 'wsl.exe' - const spawnedWslDistro = spawnedShellIsWsl - ? (plan.launchWslDistro ?? undefined) - : process.platform === 'win32' - ? null - : undefined - return activateLocalPtySession({ - id, - incarnationId, - spawn: args, - getOptions, - plan, - env: finalEnv, - proc, - reportsChildExitStatus: spawnResult.reportsChildExitStatus !== false, - spawnedWslDistro + const proc = spawnResult.process + const spawnedShellIsWsl = + process.platform === 'win32' && + pathWin32.basename(plan.shellPath).toLowerCase() === 'wsl.exe' + const spawnedWslDistro = spawnedShellIsWsl + ? (plan.launchWslDistro ?? undefined) + : process.platform === 'win32' + ? null + : undefined + return activateLocalPtySession({ + id, + incarnationId, + spawn: args, + getOptions, + plan, + env: finalEnv, + proc, + reportsChildExitStatus: spawnResult.reportsChildExitStatus !== false, + spawnedWslDistro + }) + }) }) } diff --git a/src/main/providers/local-pty-utils.ts b/src/main/providers/local-pty-utils.ts index 735393449f2..8009260923d 100644 --- a/src/main/providers/local-pty-utils.ts +++ b/src/main/providers/local-pty-utils.ts @@ -2,6 +2,7 @@ import { basename, isAbsolute, join } from 'node:path' import { existsSync, accessSync, statSync, chmodSync, constants as fsConstants } from 'node:fs' import type * as pty from 'node-pty' import { usesNodePtySpawnHelper } from '../../shared/node-pty-spawn-helper' +import { TERMINAL_SPAWN_ISSUE_REQUEST } from '../../shared/terminal-spawn-error-copy' import { hostReportsChildExitStatus, wrapShellSpawnForMacosTccAttribution @@ -307,7 +308,6 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu const diag = formatLocalPtyEnvironmentDiag({ shell: shellPath, cwd }) throw new Error( - `Failed to spawn shell "${shellPath}": ${primaryError ?? 'unknown error'} (${diag}). ` + - `If this persists, please file an issue.` + `Failed to spawn shell "${shellPath}": ${primaryError ?? 'unknown error'} (${diag}). ${TERMINAL_SPAWN_ISSUE_REQUEST}` ) } diff --git a/src/main/providers/provider-dispatch.test.ts b/src/main/providers/provider-dispatch.test.ts index cc63cfb9d09..7ac956b70fd 100644 --- a/src/main/providers/provider-dispatch.test.ts +++ b/src/main/providers/provider-dispatch.test.ts @@ -49,7 +49,16 @@ vi.mock('node-pty', () => ({ })) vi.mock('../opencode/hook-service', () => ({ - openCodeHookService: { buildPtyEnv: () => ({}), clearPty: vi.fn() } + openCodeHookService: { + buildPtyEnv: () => ({}), + refreshLegacySharedPlugin: vi.fn(), + clearPty: vi.fn() + }, + openCode2HookService: { + buildPtyEnv: () => ({}), + refreshLegacySharedPlugin: vi.fn(), + clearPty: vi.fn() + } })) vi.mock('../pi/titlebar-extension-service', () => ({ @@ -109,6 +118,7 @@ describe('PTY provider dispatch', () => { getCwd: vi.fn(), getInitialCwd: vi.fn(), clearBuffer: vi.fn(), + resetInputModes: vi.fn(), acknowledgeDataEvent: vi.fn(), hasChildProcesses: vi.fn(), getForegroundProcess: vi.fn(), @@ -155,8 +165,8 @@ describe('PTY provider dispatch', () => { })) as { id: string } expect(result.id).toBe('ssh-pty-1') - // Why: the relay host can be launched from a Claude session too, so the stamps are - // stripped on the SSH path as well. Compared as a set — envToDelete is consumed by + // Why: the relay host can be launched from a Claude or structured session too, so the + // stamps are stripped on the SSH path as well; a remote pane never names a local session. Compared as a set — envToDelete is consumed by // membership only, so a reordering of the merge sources must not fail this. const sshSpawnArgs = vi.mocked(mockSshProvider.spawn).mock.calls.at(-1)![0] expect([...(sshSpawnArgs.envToDelete ?? [])].sort()).toEqual( @@ -167,7 +177,9 @@ describe('PTY provider dispatch', () => { 'CLAUDE_CODE_BRIDGE_SESSION_ID', 'ORCA_PI_STATUS_OWNED', 'ORCA_PRIME_AGENT_STATUS_OWNED', - 'ORCA_PI_TITLE_MARKER_OWNED' + 'ORCA_PI_TITLE_MARKER_OWNED', + 'ORCA_AGENT_SESSION_ID', + 'ORCA_STRUCTURED_SESSION' ].sort() ) expect(mockSshProvider.spawn).toHaveBeenCalledWith( diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 4b31fbbb2c8..0d9b525e88d 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -1,4 +1,5 @@ import type { TuiAgent } from '../../shared/tui-agent' +import type { AgentWorkspaceTrustSpawnRequest } from '../../shared/agent-workspace-trust-spawn-request' import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' @@ -77,6 +78,8 @@ export type PtySpawnOptions = { isNewSession?: boolean /** Host setting forwarded additively to the process owner; old owners ignore it. */ historyIsolationEnabled?: boolean + /** SSH only: workspace the relay pre-trusts for `launchAgent` before spawning; old relays ignore it. */ + agentWorkspaceTrust?: AgentWorkspaceTrustSpawnRequest /** Attach the named session atomically or fail without creating a process. */ attachOnly?: boolean /** Exact persisted owner expected by an attach-only routing decision. */ @@ -224,6 +227,8 @@ export type IPtyProvider = { getCwd(id: string): Promise getInitialCwd(id: string): Promise clearBuffer(id: string): Promise + /** Grounds the host's own terminal models (Reset Terminal); renderers ground themselves. */ + resetInputModes(id: string): Promise /** Ordered handoff from startup source authority to the live/hidden view authority. */ closeStartupQueryAuthority?: (id: string) => Promise | number acknowledgeDataEvent(id: string, charCount: number): void diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 90b41d9656a..2886a00e8b6 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -52,8 +52,8 @@ export type PtySpawnResult = { } /** Kitty keyboard flags persisted in the daemon snapshot, threaded so the * re-seeded runtime emulator answers hidden `CSI ? u` with the real flags - * (terminal-query-authority.md §kitty). Never replayed into a renderer - * xterm — POST_REPLAY_REATTACH_RESET's kitty reset stays authoritative. */ + * (terminal-query-authority.md §kitty). Renderers re-assert them in their + * replay epilogue, after the payload's screen switches. */ snapshotKittyKeyboardFlags?: number /** Renderer-domain sequence main reconciled for the attach boundary those * flags describe. Set by main, not the provider. */ diff --git a/src/main/providers/settled-pty-writer-census.test.ts b/src/main/providers/settled-pty-writer-census.test.ts deleted file mode 100644 index 08dd9989400..00000000000 --- a/src/main/providers/settled-pty-writer-census.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { execFileSync } from 'node:child_process' -import { describe, expect, it, vi } from 'vitest' -import { LocalPtyProvider } from './local-pty-provider' -import { SshPtyProvider } from './ssh-pty-provider' -import { createMockMux } from './ssh-pty-provider-mock-multiplexer' -import { DaemonPtyRouter } from '../daemon/daemon-pty-router' -import { DegradedDaemonPtyProvider } from '../daemon/degraded-daemon-pty-provider' -import { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter' - -vi.mock('electron', () => ({ - app: { getPath: vi.fn(() => '/tmp'), isPackaged: false }, - BrowserWindow: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - webContents: { fromId: vi.fn(() => null) } -})) - -const REPO_ROOT = join(__dirname, '..', '..', '..') - -/** - * Requiring the method is satisfiable by a lie: the degraded daemon router used to answer it - * with `provider.write(...) !== false`, reproducing the fire-and-forget bug through the fix. - * The census pins the producers and reads their bodies, so a new provider or a revived - * fabricated handoff fails here rather than silently clearing a mailbox reservation. - */ -const SETTLED_PTY_WRITER_FILES = [ - 'src/main/providers/local-pty-provider.ts', - 'src/main/providers/ssh-pty-provider.ts', - 'src/main/daemon/daemon-pty-router.ts', - 'src/main/daemon/degraded-daemon-pty-provider.ts', - 'src/main/daemon/daemon-pty-adapter.ts' -] - -/** Where the provider-side settlement is actually decided; the adapter inherits its own. */ -const SETTLED_WRITER_DECLARATIONS = [ - 'src/main/providers/local-pty-provider.ts', - 'src/main/providers/ssh-pty-provider.ts', - 'src/main/providers/ssh-pty-provider-rpc-operations.ts', - 'src/main/daemon/daemon-pty-router.ts', - 'src/main/daemon/degraded-daemon-pty-provider.ts', - 'src/main/daemon/daemon-pty-session-input.ts' -] - -function declaredProviderFiles(): string[] { - const output = execFileSync('git', ['grep', '-l', '--', 'implements IPtyProvider', 'src/main'], { - cwd: REPO_ROOT, - encoding: 'utf8' - }) - // Tests may name the clause while pinning it; only production declarations count. - return output - .split('\n') - .filter((file) => file && !file.endsWith('.test.ts')) - .sort() -} - -function settledWriterBody(file: string): string { - const source = readFileSync(join(REPO_ROOT, file), 'utf8') - const start = source.indexOf('writeWithSettlement') - expect(start, `${file} declares no settled writer`).toBeGreaterThan(-1) - const end = source.indexOf('\n }', start) - return source.slice(start, end === -1 ? source.length : end) -} - -describe('settled PTY writer census', () => { - it('covers every production provider class that declares IPtyProvider', () => { - expect(declaredProviderFiles()).toEqual([...SETTLED_PTY_WRITER_FILES].sort()) - }) - - it('exposes a settled writer on every production provider instance', () => { - const daemonClient = { isConnected: () => false, onEvent: vi.fn(() => vi.fn()) } - const adapter = new DaemonPtyAdapter(daemonClient as never) - const instances = [ - new LocalPtyProvider({} as never), - new SshPtyProvider('conn-census', createMockMux() as never), - new DaemonPtyRouter({ current: adapter, legacy: [] }), - new DegradedDaemonPtyProvider({ - current: adapter, - legacy: [], - fallback: new LocalPtyProvider({} as never) - }), - adapter - ] - for (const provider of instances) { - expect(typeof provider.writeWithSettlement, provider.constructor.name).toBe('function') - } - }) - - it('never synthesizes a settlement from the fire-and-forget write', () => { - for (const file of SETTLED_WRITER_DECLARATIONS) { - expect(settledWriterBody(file), file).not.toMatch(/\.write\(/) - } - }) -}) diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index a20dfb98d9b..0917c4cf81a 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -527,18 +527,6 @@ describe('SshFilesystemProvider', () => { }) describe('watch', () => { - it('sends fs.watch request and returns unsubscribe', async () => { - const callback = vi.fn() - const unsub = await provider.watch('/home/user/project', callback) - - expect(mux.request).toHaveBeenCalledWith( - 'fs.watch', - { rootPath: '/home/user/project', watchId: expect.any(Number) }, - { signal: expect.any(AbortSignal) } - ) - expect(typeof unsub).toBe('function') - }) - it('uses a registration-owned cancellation signal for the mux fs.watch request', async () => { mux.request.mockResolvedValue(undefined) const controller = new AbortController() diff --git a/src/main/providers/ssh-git-noninteractive-provider.ts b/src/main/providers/ssh-git-noninteractive-provider.ts index ee60b254134..0550b1bba39 100644 --- a/src/main/providers/ssh-git-noninteractive-provider.ts +++ b/src/main/providers/ssh-git-noninteractive-provider.ts @@ -27,6 +27,7 @@ export class SshGitNoninteractiveProvider extends SshGitReadProvider { args: plan.args, cwd, stdin: plan.stdinPayload, + ...(plan.env ? { env: plan.env } : {}), timeoutMs, operation }, diff --git a/src/main/providers/ssh-git-provider-commit-message.test.ts b/src/main/providers/ssh-git-provider-commit-message.test.ts index 41a5003946d..be024b643c8 100644 --- a/src/main/providers/ssh-git-provider-commit-message.test.ts +++ b/src/main/providers/ssh-git-provider-commit-message.test.ts @@ -163,7 +163,8 @@ describe('SshGitProvider', () => { binary: 'codex', args: ['exec', 'PROMPT'], stdinPayload: null, - label: 'Codex' + label: 'Codex', + env: { FLAG: 'literal $HOME' } }, '/home/user/repo', 60_000 @@ -176,6 +177,7 @@ describe('SshGitProvider', () => { args: ['exec', 'PROMPT'], cwd: '/home/user/repo', stdin: null, + env: { FLAG: 'literal $HOME' }, timeoutMs: 60_000, operation: 'commit-message' }, diff --git a/src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts b/src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts index e088b21f025..61b0293f25b 100644 --- a/src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts +++ b/src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts @@ -57,18 +57,30 @@ function requestPayloads(transport: ReturnType): Record< }) } +// Why a macrotask yield: FrameDecoder stops after FRAME_DECODER_MAX_TURN_MS of decoding and +// resumes the rest of the fed bytes from setImmediate, and feed() refuses to drain while that +// continuation is pending. Under load a delivered response can therefore only be decoded a +// macrotask later, which a microtask-only poll can never reach. Matches waitForRequestCount in +// ssh-git-provider-test-harness. +async function waitForValue(produce: () => T | undefined, what: string): Promise { + for (let turn = 0; turn < 20; turn += 1) { + const value = produce() + if (value !== undefined) { + return value + } + await new Promise((resolve) => setTimeout(resolve, 0)) + } + throw new Error(`never observed: ${what}`) +} + async function waitForRequest( transport: ReturnType, method: string ): Promise> { - for (let turn = 0; turn < 10; turn += 1) { - const request = requestPayloads(transport).find((payload) => payload.method === method) - if (request) { - return request - } - await Promise.resolve() - } - throw new Error(`request not dispatched: ${method}`) + return waitForValue( + () => requestPayloads(transport).find((payload) => payload.method === method), + `request ${method}` + ) } describe('SSH fresh agent-session create operations', () => { @@ -377,6 +389,8 @@ describe('SSH fresh agent-session create operations', () => { ]) ) await expect(replacement).resolves.toMatchObject({ incarnationId: 'incarnation-new' }) + // The same-buffer data frame can decode a decoder turn after the spawn response resolves. + await waitForValue(() => onData.mock.calls.at(0), 'replacement source data') expect(onData.mock.calls.map(([payload]) => payload.data)).toEqual(['new']) transport.deliver( diff --git a/src/main/providers/ssh-pty-provider-rpc-operations.ts b/src/main/providers/ssh-pty-provider-rpc-operations.ts index bcd847de27b..361bdc15715 100644 --- a/src/main/providers/ssh-pty-provider-rpc-operations.ts +++ b/src/main/providers/ssh-pty-provider-rpc-operations.ts @@ -34,6 +34,9 @@ export function createSshPtyProviderRpcOperations({ mux, toRelayPtyId }: SshPtyP clearBuffer: async (id: string): Promise => { await mux.request('pty.clearBuffer', { id: toRelayPtyId(id) }) }, + resetInputModes: async (id: string): Promise => { + await mux.request('pty.resetInputModes', { id: toRelayPtyId(id) }) + }, closeStartupQueryAuthority: async (id: string): Promise => { const result = (await mux.request('pty.closeStartupQueryAuthority', { id: toRelayPtyId(id) diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 76b5d9f85e4..854e076cec5 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -55,6 +55,7 @@ export class SshPtyProvider implements IPtyProvider { getCwd = (id: string): Promise => this.rpcOperations.getCwd(id) getInitialCwd = (id: string): Promise => this.rpcOperations.getInitialCwd(id) clearBuffer = (id: string): Promise => this.rpcOperations.clearBuffer(id) + resetInputModes = (id: string): Promise => this.rpcOperations.resetInputModes(id) closeStartupQueryAuthority = (id: string): Promise => this.rpcOperations.closeStartupQueryAuthority(id) acknowledgeDataEvent = (id: string, charCount: number): void => diff --git a/src/main/providers/ssh-pty-spawn-request.ts b/src/main/providers/ssh-pty-spawn-request.ts index 9f493d9e778..170a50bb7bd 100644 --- a/src/main/providers/ssh-pty-spawn-request.ts +++ b/src/main/providers/ssh-pty-spawn-request.ts @@ -26,6 +26,7 @@ export function buildSshPtySpawnRequest(args: { ...(options.historyIsolationEnabled !== undefined ? { historyIsolationEnabled: options.historyIsolationEnabled } : {}), + ...(options.agentWorkspaceTrust ? { agentWorkspaceTrust: options.agentWorkspaceTrust } : {}), ...(options.shellOverride !== undefined ? { shellOverride: options.shellOverride } : {}), ...(options.terminalWindowsWslDistro !== undefined ? { terminalWindowsWslDistro: options.terminalWindowsWslDistro } diff --git a/src/main/providers/windows-pty-job-membership.ts b/src/main/providers/windows-pty-job-membership.ts index 99bcf7ef2c2..7bb37323b39 100644 --- a/src/main/providers/windows-pty-job-membership.ts +++ b/src/main/providers/windows-pty-job-membership.ts @@ -1,5 +1,9 @@ import type { IPty } from 'node-pty' -import { isPtyJobOwnershipAvailable, listPtyJobProcessIds } from '../windows/windows-pty-job' +import { + isPtyJobOwnershipAvailable, + listPtyJobProcessIds, + ptyShellProcessId +} from '../windows/windows-pty-job' /** * Processes still running under a pane, or null when there is no answer. @@ -30,7 +34,14 @@ export function readWindowsPtyJobProcessIds( const membership = new Set(pids.filter((pid) => Number.isSafeInteger(pid) && pid > 0)) // Without the shell, a size-1 set would read as "shell alone, retire" when it // means the opposite. The forked probe this replaced refused the same way. - return membership.has(proc.pid) ? membership : null + const shellPid = ptyShellProcessId(proc) + if (shellPid === undefined || !membership.has(proc.pid) || !membership.has(shellPid)) { + return null + } + if (shellPid !== proc.pid) { + membership.delete(proc.pid) + } + return membership } /** diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index c71b9b06722..38352a1b6d4 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -308,6 +308,31 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() }) + it('keeps a plain Git Bash tab a login shell and wraps one with a startup command', () => { + const plain = resolveWindowsShellLaunchArgs( + 'C:\\Program Files\\Git\\bin\\bash.exe', + 'C:\\Users\\alice', + 'C:\\Users\\alice' + ) + const launched = resolveWindowsShellLaunchArgs( + 'C:\\Program Files\\Git\\bin\\bash.exe', + 'C:\\Users\\alice', + 'C:\\Users\\alice', + undefined, + "codex 'fix the bug'" + ) + + expect(plain.shellArgs).toEqual([ + '-c', + 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i' + ]) + // Why: without a preflight, only the rcfile carries the codex --no-daemon wrapper. + expect(readFileSync(getGitBashRcfilePath(launched.shellArgs[1]), 'utf8')).toContain( + 'set -- --no-daemon "$@"' + ) + expect(launched.startupCommandDeliveredInShellArgs).toBeUndefined() + }) + it('quotes a spaced preflight path through each shell environment', () => { const cmd = resolveWindowsShellLaunchArgs( 'cmd.exe', diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 70fd22a06ad..c5ceade00a4 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -29,8 +29,9 @@ const CMD_CODEX_LAUNCH_PREFLIGHT = `if defined ORCA_CODEX_LAUNCH_PREFLIGHT call // `&&`) keeps startup working even if chcp.com is missing. const GIT_BASH_UTF8_LOGIN_COMMAND = 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i' -function getGitBashLaunchCommand(codexLaunchPreflightCommand?: string): string { - if (!codexLaunchPreflightCommand) { +// Why the rcfile for a startup command: it defines the codex wrapper Orca's launches need. +function getGitBashLaunchCommand(useWrapper: boolean): string { + if (!useWrapper) { return GIT_BASH_UTF8_LOGIN_COMMAND } @@ -216,7 +217,10 @@ export function resolveWindowsShellLaunchArgs( if (isWindowsGitBashShellPath(shellPath)) { return { - shellArgs: ['-c', getGitBashLaunchCommand(codexLaunchPreflightCommand)], + shellArgs: [ + '-c', + getGitBashLaunchCommand(Boolean(codexLaunchPreflightCommand) || Boolean(startupCommand)) + ], effectiveCwd: nativeCwd, validationCwd: nativeCwd } diff --git a/src/main/pty-descendant-exit-verification.test.ts b/src/main/pty-descendant-exit-verification.test.ts index 4b92a6444c0..72b331492ad 100644 --- a/src/main/pty-descendant-exit-verification.test.ts +++ b/src/main/pty-descendant-exit-verification.test.ts @@ -32,9 +32,9 @@ describe('descendant exit verification across partial process-table reads', () = const snapshot = collectDescendantRows(10, [row(10, 1), first, later], CAPTURED_AT) const readTable = vi .fn() - .mockResolvedValueOnce(capture([first])) - .mockResolvedValueOnce(capture([first, later])) - .mockResolvedValue(capture([])) + .mockImplementationOnce(async () => capture([first])) + .mockImplementationOnce(async () => capture([first, later])) + .mockImplementation(async () => capture([])) const sendSignal = vi.fn() const pending = terminateDescendantSnapshotWithVerdict(snapshot, { readTable, @@ -52,16 +52,37 @@ describe('descendant exit verification across partial process-table reads', () = ]) }) - it('keeps an omitted target unverifiable when partial reads never show its identity', async () => { - const first = row(20) - const omitted = row(30) - const snapshot = collectDescendantRows(10, [row(10, 1), first, omitted], CAPTURED_AT) + it('proves a target gone that only the snapshot saw, once two later reads miss it', async () => { + // A root that exits on its own takes a short-lived child with it before the first poll. + const snapshot = collectDescendantRows(10, [row(10, 1), row(20)], CAPTURED_AT) const sendSignal = vi.fn() const pending = terminateDescendantSnapshotWithVerdict(snapshot, { - readTable: vi - .fn() - .mockResolvedValueOnce(capture([first])) - .mockResolvedValue(capture([])), + readTable: vi.fn().mockImplementation(async () => capture([])), + sendSignal, + requireIdentityBeforeSignal: true, + graceMs: 0, + verifyMs: 3_500 + }) + let verdict: string | undefined + void pending.then((value) => { + verdict = value + }) + // Proven within a few polls, not by waiting out the verification window. + await vi.advanceTimersByTimeAsync(200) + + expect(verdict).toBe('exited') + expect(sendSignal).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(4_000) + }) + + it('does not count an absence from a read that started before the target was seen', async () => { + const unseen = row(30) + const snapshot = collectDescendantRows(10, [row(10, 1), unseen], CAPTURED_AT) + const sendSignal = vi.fn() + // A shared read already in flight when the snapshot ran cannot list a descendant forked since. + const staleRead = { rows: [], capturedAtMs: CAPTURED_AT - 1 } + const pending = terminateDescendantSnapshotWithVerdict(snapshot, { + readTable: vi.fn().mockResolvedValue(staleRead), sendSignal, requireIdentityBeforeSignal: true, graceMs: 0, @@ -70,8 +91,45 @@ describe('descendant exit verification across partial process-table reads', () = await vi.advanceTimersByTimeAsync(200) await expect(pending).resolves.toBe('unverifiable') - expect(sendSignal).toHaveBeenCalledWith(first.pid, 'SIGTERM') - expect(sendSignal).not.toHaveBeenCalledWith(omitted.pid, 'SIGTERM') + expect(sendSignal).not.toHaveBeenCalled() + }) + + it('keeps the latest sighting when an earlier-started read that matches resolves later', async () => { + const target = row(20) + const snapshot = collectDescendantRows(10, [row(10, 1), target], CAPTURED_AT) + const readTable = vi + .fn() + .mockResolvedValueOnce({ rows: [target], capturedAtMs: CAPTURED_AT + 300 }) + .mockResolvedValueOnce({ rows: [target], capturedAtMs: CAPTURED_AT + 100 }) + // Began between the two sightings, so it cannot prove the later one gone. + .mockResolvedValue({ rows: [], capturedAtMs: CAPTURED_AT + 200 }) + const pending = terminateDescendantSnapshotWithVerdict(snapshot, { + readTable, + sendSignal: vi.fn(), + requireIdentityBeforeSignal: true, + graceMs: 10_000, + verifyMs: 300 + }) + await vi.advanceTimersByTimeAsync(400) + + await expect(pending).resolves.toBe('unverifiable') + }) + + it('counts the read after the deadline as an absence', async () => { + const snapshot = collectDescendantRows(10, [row(10, 1), row(20)], CAPTURED_AT) + const readTable = vi.fn().mockImplementation(async () => capture([])) + // One poll fits in the window; the final read supplies the second absence. + const pending = terminateDescendantSnapshotWithVerdict(snapshot, { + readTable, + sendSignal: vi.fn(), + requireIdentityBeforeSignal: true, + graceMs: 0, + verifyMs: 50 + }) + await vi.advanceTimersByTimeAsync(100) + + await expect(pending).resolves.toBe('exited') + expect(readTable).toHaveBeenCalledTimes(2) }) it('escalates a survivor omitted at the first force-kill read when it reappears', async () => { @@ -80,10 +138,10 @@ describe('descendant exit verification across partial process-table reads', () = const snapshot = collectDescendantRows(10, [row(10, 1), first, later], CAPTURED_AT) const readTable = vi .fn() - .mockResolvedValueOnce(capture([first, later])) - .mockResolvedValueOnce(capture([first])) - .mockResolvedValueOnce(capture([first, later])) - .mockResolvedValue(capture([])) + .mockImplementationOnce(async () => capture([first, later])) + .mockImplementationOnce(async () => capture([first])) + .mockImplementationOnce(async () => capture([first, later])) + .mockImplementation(async () => capture([])) const sendSignal = vi.fn() const pending = terminateDescendantSnapshotWithVerdict(snapshot, { readTable, diff --git a/src/main/pty-descendant-exit-verification.ts b/src/main/pty-descendant-exit-verification.ts index 27ed62ca0a0..bc23faf2958 100644 --- a/src/main/pty-descendant-exit-verification.ts +++ b/src/main/pty-descendant-exit-verification.ts @@ -124,7 +124,32 @@ export async function terminateDescendantSnapshotWithVerdict( const forced = new Set() const signalled = new Set() const missingObservations = new Map(snapshot.descendants.map((row) => [row.pid, 0])) - const observedPids = new Set() + // The snapshot is itself a table read that saw each target alive. An absence is evidence only + // from a read that started after the target was last seen: a shared or in-flight read begun + // earlier can miss a descendant forked since, but a later full scan cannot miss a live one. + const lastSeenAtMs = new Map( + snapshot.descendants.map((row) => [ + row.pid, + snapshot.capturedAtMsByPid?.[String(row.pid)] ?? snapshot.capturedAtMs + ]) + ) + const recordIdentityObservation = ( + capture: ProcessTableCapture, + live: readonly ProcessTableRow[] + ): void => { + for (const row of snapshot.descendants) { + const lastSeen = lastSeenAtMs.get(row.pid) ?? Infinity + if (live.some((current) => current.pid === row.pid)) { + missingObservations.set(row.pid, 0) + // A read that started earlier but resolved later must not move the sighting back. + lastSeenAtMs.set(row.pid, Math.max(lastSeen, capture.capturedAtMs)) + } else if (capture.capturedAtMs > lastSeen) { + missingObservations.set(row.pid, (missingObservations.get(row.pid) ?? 0) + 1) + } + } + } + const provenAbsent = (): boolean => + snapshot.descendants.every((row) => (missingObservations.get(row.pid) ?? 0) >= 2) if (!deps.requireIdentityBeforeSignal) { for (const row of snapshot.descendants) { sendSignal(row.pid, 'SIGTERM') @@ -148,30 +173,14 @@ export async function terminateDescendantSnapshotWithVerdict( } const live = matchingSnapshotRows(snapshot, capture.rows, deps.requireIdentityBeforeSignal) if (deps.requireIdentityBeforeSignal) { - const rowsByPid = new Set(capture.rows.map((row) => row.pid)) - for (const row of snapshot.descendants) { - if (rowsByPid.has(row.pid)) { - observedPids.add(row.pid) - } - if (live.some((current) => current.pid === row.pid)) { - missingObservations.set(row.pid, 0) - } else { - missingObservations.set(row.pid, (missingObservations.get(row.pid) ?? 0) + 1) - } - } + recordIdentityObservation(capture, live) } if (live.length === 0) { // Before a signal has been sent, an empty identity match means the // snapshotted descendants already exited or were replaced. Signalling - // those old numeric pids would be unsafe. Partial reads are not proof - // that a never-observed target exited, so every identity needs two - // bounded absences after it has appeared in a table. - if ( - deps.requireIdentityBeforeSignal && - !snapshot.descendants.every( - (row) => observedPids.has(row.pid) && (missingObservations.get(row.pid) ?? 0) >= 2 - ) - ) { + // those old numeric pids would be unsafe. Every identity needs two + // absences from reads that started after it was last seen. + if (deps.requireIdentityBeforeSignal && !provenAbsent()) { await waitForDelay(50, deps.keepAlive) continue } @@ -235,10 +244,8 @@ export async function terminateDescendantSnapshotWithVerdict( return 'live' } if (deps.requireIdentityBeforeSignal) { - const everyTargetAbsent = snapshot.descendants.every( - (row) => observedPids.has(row.pid) && (missingObservations.get(row.pid) ?? 0) >= 2 - ) - return everyTargetAbsent ? 'exited' : 'unverifiable' + recordIdentityObservation(finalCapture, finalLive) + return provenAbsent() ? 'exited' : 'unverifiable' } return 'exited' } diff --git a/src/main/pty-descendant-termination.test.ts b/src/main/pty-descendant-termination.test.ts index f6b8e066983..6261ec92fc2 100644 --- a/src/main/pty-descendant-termination.test.ts +++ b/src/main/pty-descendant-termination.test.ts @@ -453,7 +453,8 @@ describe('terminateDescendantSnapshotAndWait', () => { snapshot([row(20, 10, 20, 'Tue Jul 14 12:00:00 2026')]), { sendSignal, - readTable: vi.fn().mockResolvedValue(tableCapture([recycled])), + // Reads begin after the walk that produced the snapshot, as every fresh scan does. + readTable: vi.fn().mockResolvedValue(tableCapture([recycled], CAPTURED_AT_MS + 1_000)), requireIdentityBeforeSignal: true, verifyMs: 100 } diff --git a/src/main/pty/codex-launch-shell-wrapping.test.ts b/src/main/pty/codex-launch-shell-wrapping.test.ts new file mode 100644 index 00000000000..02d6c223a2f --- /dev/null +++ b/src/main/pty/codex-launch-shell-wrapping.test.ts @@ -0,0 +1,80 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' +import { selectShellStartupFeatures } from '../shell-startup-features' + +// Why: the codex --no-daemon wrapper only reaches shells Orca wraps. Orca's own +// Codex launches carry a startup command, so each launch shell must be wrapped. +const COMMAND = "codex 'fix the bug'" +const NO_DAEMON = 'set -- --no-daemon "$@"' +const FISH_NO_DAEMON = 'set argv --no-daemon $argv' + +function codexLaunchFeatures(shellPath: string) { + // Why an empty env: a system-default Codex home, with hooks off, carries no overlay key. + const waitsForShellReady = shouldUseShellReadyStartupDelivery({ command: COMMAND, shellPath }) + return selectShellStartupFeatures({ + shellPath, + env: {}, + hasStartupCommand: true, + waitsForShellReady, + emitsStartupIdentity: waitsForShellReady + }) +} + +function wrapperText(config: { args: string[] | null; env: Record }): string { + if (config.env.ZDOTDIR) { + return readFileSync(join(config.env.ZDOTDIR, '.zshenv'), 'utf8') + } + const rcfile = config.args?.[config.args.indexOf('--rcfile') + 1] + return rcfile && config.args?.includes('--rcfile') + ? readFileSync(rcfile, 'utf8') + : (config.args ?? []).join('\n') +} + +describe.skipIf(process.platform === 'win32')('Orca Codex launch shells carry the wrapper', () => { + let userData: string + const original = process.env.ORCA_USER_DATA_PATH + + beforeEach(() => { + userData = mkdtempSync(join(tmpdir(), 'orca-codex-launch-wrap-')) + process.env.ORCA_USER_DATA_PATH = userData + vi.resetModules() + }) + + afterEach(() => { + if (original === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = original + } + rmSync(userData, { recursive: true, force: true }) + }) + + it.each([ + ['/bin/bash', NO_DAEMON], + ['/bin/zsh', NO_DAEMON], + ['/usr/bin/fish', FISH_NO_DAEMON] + ])('daemon transport wraps %s', async (shell, marker) => { + const { getShellLaunchConfig } = await import('../daemon/shell-ready') + + expect( + wrapperText( + getShellLaunchConfig(shell, codexLaunchFeatures(shell), { hasStartupCommand: true }) + ) + ).toContain(marker) + }) + + it.each([ + ['/bin/bash', NO_DAEMON], + ['/bin/zsh', NO_DAEMON], + ['/usr/bin/fish', FISH_NO_DAEMON] + ])('local transport wraps %s', async (shell, marker) => { + const { getShellLaunchConfig } = await import('../providers/local-pty-shell-ready') + + expect(wrapperText(getShellLaunchConfig(shell, codexLaunchFeatures(shell), COMMAND))).toContain( + marker + ) + }) +}) diff --git a/src/main/pty/codex-no-daemon-binary-contract.test.ts b/src/main/pty/codex-no-daemon-binary-contract.test.ts new file mode 100644 index 00000000000..e02301fa6a2 --- /dev/null +++ b/src/main/pty/codex-no-daemon-binary-contract.test.ts @@ -0,0 +1,75 @@ +import { execFile } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { CODEX_SHARED_SERVER_ARGS } from './codex-shell-launch-preflight' + +// Why: Orca's codex shell wrapper puts --no-daemon first for every subcommand but +// agents/queue (codex-shell-launch-preflight.ts). Its tests use a fake codex, so +// only the real binary can catch a renamed flag or a new subcommand rejecting it. + +const execFileAsync = promisify(execFile) +const binary = process.env.ORCA_CODEX_NO_DAEMON_CONTRACT_BINARY +const expectedVersion = process.env.ORCA_CODEX_NO_DAEMON_CONTRACT_VERSION +const WRAPPER_SKIPPED_SUBCOMMANDS: ReadonlySet = new Set(CODEX_SHARED_SERVER_ARGS) +const TIMEOUT_MS = 30_000 + +describe.runIf(process.env.ORCA_CODEX_NO_DAEMON_CONTRACT_REQUIRED === '1' && !binary)( + 'codex --no-daemon contract prerequisites', + () => { + it('was given a Codex binary to run against', () => { + expect.fail('ORCA_CODEX_NO_DAEMON_CONTRACT_REQUIRED=1 but no binary was given') + }) + } +) + +describe.runIf(binary)('codex --no-daemon binary contract', { timeout: 120_000 }, () => { + let home: string + let help: string + + beforeAll(async () => { + // Why a disposable home: never read or start anything under the user's ~/.codex. + home = mkdtempSync(join(tmpdir(), 'orca-codex-no-daemon-contract-')) + const version = await run(['--version']) + expect(version.stdout.trim()).toBe(`codex-cli ${expectedVersion}`) + help = (await run(['--help'])).stdout + }) + + afterAll(() => { + rmSync(home, { recursive: true, force: true }) + }) + + function run(args: string[]): Promise<{ stdout: string; stderr: string }> { + return execFileAsync(binary!, args, { + timeout: TIMEOUT_MS, + env: { ...process.env, CODEX_HOME: home } + }) + } + + function listedSubcommands(): string[] { + const section = help.split(/^Commands:\n/m)[1]?.split(/\n\s*\n/)[0] ?? '' + return [...section.matchAll(/^ {2}([a-z][a-z0-9-]*)\s/gm)].map((match) => match[1]) + } + + it('lists --no-daemon in --help, which is what the wrapper probes', () => { + expect(help).toContain('--no-daemon') + }) + + it('accepts --no-daemon first for every listed subcommand the wrapper does not skip', async () => { + const subcommands = listedSubcommands() + // Why a floor: a help layout change must fail here, not shrink the check to nothing. + expect(subcommands.length).toBeGreaterThan(20) + expect(subcommands).toEqual(expect.arrayContaining(['exec', 'resume', 'agents', 'queue'])) + const rejected: string[] = [] + for (const subcommand of subcommands.filter((name) => !WRAPPER_SKIPPED_SUBCOMMANDS.has(name))) { + // Why bare `help`: clap's help subcommand treats `--help` as a command name. + const args = subcommand === 'help' ? ['help'] : [subcommand, '--help'] + await run(['--no-daemon', ...args]).catch((error: { stderr?: string }) => + rejected.push(`${subcommand}: ${error.stderr?.trim() ?? String(error)}`) + ) + } + expect(rejected).toEqual([]) + }) +}) diff --git a/src/main/pty/codex-shell-launch-preflight.test.ts b/src/main/pty/codex-shell-launch-preflight.test.ts index f0673fc9c95..3056deeac78 100644 --- a/src/main/pty/codex-shell-launch-preflight.test.ts +++ b/src/main/pty/codex-shell-launch-preflight.test.ts @@ -309,9 +309,8 @@ describe.skipIf(process.platform === 'win32')('Codex shell launch preflight', () // Regression for #16893: an unquoted `(type -t codex)` expands to zero words when // codex is absent, so `test` saw `= file` (2 args) and printed "Missing argument - // at index 3" on every fish pane launch. Needs a valid executable - // ORCA_CODEX_LAUNCH_PREFLIGHT so the `and` chain reaches the second `test`, and - // the real `-l -C` launch shape both shell-ready call sites use. + // at index 3" on every fish pane launch. Uses the real `-l -C` launch shape both + // shell-ready call sites use. it.skipIf(!fishAvailable)('stays silent and installs no wrapper when codex is absent', () => { const { bin, preflight } = createFishSandbox('orca-codex-fish-absent-') diff --git a/src/main/pty/codex-shell-launch-preflight.ts b/src/main/pty/codex-shell-launch-preflight.ts index 87e5df75827..0fcd0c37c07 100644 --- a/src/main/pty/codex-shell-launch-preflight.ts +++ b/src/main/pty/codex-shell-launch-preflight.ts @@ -65,6 +65,11 @@ function isExecutableFileOnDisk(path: string, platform: NodeJS.Platform): boolea } } +// Why --no-daemon: Codex 0.156+ otherwise shares one server per CODEX_HOME that runs every tab's +// hooks with the first tab's Orca env and dies with it (#22873). These args need that server or exit 2. +export const CODEX_SHARED_SERVER_ARGS = ['agents', 'queue', '--no-daemon', '--remote'] as const +const CODEX_SHARED_SERVER_ARG_PATTERN = `^(${CODEX_SHARED_SERVER_ARGS.join('|')}|--remote=.*)$` + export function getPosixCodexShellLaunchPreflight(): string { return `# Why: a typed alias expands inside the shell, after pane launch prep. # Why unalias inside the substitution: an alias named codex makes command -v @@ -72,11 +77,22 @@ export function getPosixCodexShellLaunchPreflight(): string { # Why || : twice — zsh alone aborts inside the substitution, but every shell's # assignment adopts its exit status, so an absent codex trips set -e in bash too. __orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" -if [[ -n "\${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "\${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "\${__orca_codex_binary:-}" && -x "\${__orca_codex_binary}" ]]; then +if [[ -n "\${__orca_codex_binary:-}" && -x "\${__orca_codex_binary}" ]]; then # Why the function reserved word: it suppresses alias expansion of the name, # which otherwise rewrites this header at parse time and aborts the whole file. function codex { - "\${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || : + # Why local: zsh's warn_create_global warns for each global a function creates. + local __orca_codex_arg __orca_codex_isolate="\${ORCA_CODEX_ISOLATE:-1}" + if [[ -n "\${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "\${ORCA_CODEX_LAUNCH_PREFLIGHT}" ]]; then + "\${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || : + fi + for __orca_codex_arg in "$@"; do + case "$__orca_codex_arg" in ${CODEX_SHARED_SERVER_ARGS.join('|')}|--remote=*) __orca_codex_isolate=0 ;; esac + done + # Why probe every launch: a cached answer goes stale across an upgrade, and 0.155 and older exit 2 on the flag. + if [[ "$__orca_codex_isolate" != 0 ]]; then + case "$(command codex --help 2>/dev/null /dev/null) -if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test "$__orca_codex_type" = file +if test "$__orca_codex_type" = file function codex - command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true + if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT" + command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true + end + if test "$ORCA_CODEX_ISOLATE" != 0; and not string match -qr -- '${CODEX_SHARED_SERVER_ARG_PATTERN}' $argv; and command codex --help 2>/dev/null $null - } catch { - } $orcaCodexExecutable = Get-Command codex -CommandType Application,ExternalScript -ErrorAction SilentlyContinue | Select-Object -First 1 if (-not $orcaCodexExecutable) { Write-Error "codex executable not found" $global:LASTEXITCODE = 127 return } - & $orcaCodexExecutable.Source @args + $orcaCodexFlags = @() + # Why try/catch: under the user's $ErrorActionPreference = 'Stop', a failing prep or probe must not abort the launch. + if ($env:ORCA_CODEX_LAUNCH_PREFLIGHT) { + try { + & $env:ORCA_CODEX_LAUNCH_PREFLIGHT agent hooks prepare-codex *> $null + } catch { + } + } + if ($env:ORCA_CODEX_ISOLATE -ne '0' -and -not (@($args) -cmatch '${CODEX_SHARED_SERVER_ARG_PATTERN}')) { + try { + if ((& $orcaCodexExecutable.Source --help 2>$null) -match '--no-daemon') { + $orcaCodexFlags = @('--no-daemon') + } + } catch { + } + } + # Why: a native command inside a function never sees the function's pipeline input on its own. + if ($MyInvocation.ExpectingInput) { + $input | & $orcaCodexExecutable.Source @orcaCodexFlags @args + } else { + & $orcaCodexExecutable.Source @orcaCodexFlags @args + } $global:LASTEXITCODE = $LASTEXITCODE } } diff --git a/src/main/pty/codex-shell-no-daemon.test.ts b/src/main/pty/codex-shell-no-daemon.test.ts new file mode 100644 index 00000000000..53f978e8360 --- /dev/null +++ b/src/main/pty/codex-shell-no-daemon.test.ts @@ -0,0 +1,353 @@ +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { afterEach, describe, expect, it } from 'vitest' +import { + getFishCodexShellLaunchPreflight, + getPosixCodexShellLaunchPreflight, + getPowerShellCodexShellLaunchPreflight +} from './codex-shell-launch-preflight' +import { resolveFishBinary } from '../../shared/fish-binary-requirement' + +const isWindows = process.platform === 'win32' +const fishLookup = resolveFishBinary() +const canRun = (command: string): boolean => + spawnSync(command, ['-NoLogo', '-NoProfile', '-Command', 'exit 0']).status === 0 +const pwshAvailable = canRun('pwsh') + +const HELP_WITH_FLAG = 'Usage: codex [OPTIONS] [PROMPT]\n --no-daemon Run in-process\n' +const HELP_WITHOUT_FLAG = 'Usage: codex [OPTIONS] [PROMPT]\n --no-alt-screen\n' + +// Why argv as whole words: the rule is a whole-argument denylist (plan §4). +const ADDED: string[][] = [ + [], + ['fix the bug'], + ['exec the plan'], + ['-m', 'gpt-5', '--yolo', 'x'], + ['resume'], + ['resume', '--last'], + ['--yolo', 'resume', '--last'], + ['fork', '--last'], + ['-a', 'never', 'resume'], + ['-c', 'model=o3'], + ['archive', 'S'], + ['delete', 'S'], + ['exec', 'x'], + ['e', 'x'], + ['-m', 'x', 'exec', 'x'], + ['review'], + ['login'], + ['mcp', 'list'] +] +const UNCHANGED: string[][] = [ + ['agents'], + ['-m', 'x', 'agents'], + ['-c', 'k=v', 'agents'], + ['--image=a.png', 'agents'], + ['agents', '--remote', 'X'], + ['queue', '--thread', 'T', '--message', 'M'], + ['-c', 'k=v', 'queue'], + ['--no-daemon'], + ['resume', '--no-daemon'], + ['-m', 'x', '--no-daemon'], + ['--remote', 'unix://'], + ['--remote=ws://h:1'], + ['resume', '--remote', 'X'], + ['-m', 'agents'], + ['--', 'agents'], + ['--', '--remote'] +] +const ALL = [...ADDED, ...UNCHANGED] + +type Shell = 'bash' | 'zsh' | 'fish' | 'pwsh' | 'powershell' +const isPowerShell = (shell: Shell): boolean => shell === 'pwsh' || shell === 'powershell' +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function writeExecutable(path: string, content: string): void { + writeFileSync(path, content) + chmodSync(path, 0o755) +} + +type Sandbox = { bin: string; codex: string; helpFile: string; helpLog: string } + +/** Fake codex: `--help` prints the help file and logs the probe; any other call prints its argv. */ +function makeSandbox(help: string): Sandbox { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-no-daemon-')) + roots.push(root) + const bin = join(root, 'bin') + mkdirSync(bin) + const helpFile = join(root, 'help.txt') + const helpLog = join(root, 'help.log') + writeFileSync(helpFile, help) + writeFileSync(helpLog, '') + if (isWindows) { + // Why a .cmd shim over node: the shape npm installs, and what Get-Command resolves. + writeFileSync( + join(bin, 'fake.js'), + `const fs = require('fs') +const a = process.argv.slice(2) +if (a.length === 1 && a[0] === '--help') { + fs.appendFileSync(${JSON.stringify(helpLog)}, 'help\\n') + process.stdout.write(fs.readFileSync(${JSON.stringify(helpFile)}, 'utf8')) + process.exit(0) +} +let out = ['ARGV', ...a].join('|') +if (process.env.FAKE_CODEX_READ_STDIN === '1') out += '|stdin=' + fs.readFileSync(0, 'utf8').trim() +console.log(out) +process.exit(Number(process.env.FAKE_CODEX_EXIT || 0)) +` + ) + const codex = join(bin, 'codex.cmd') + writeFileSync(codex, '@node "%~dp0fake.js" %*\r\n') + return { bin, codex, helpFile, helpLog } + } + const codex = join(bin, 'codex') + writeExecutable( + codex, + `#!/bin/sh +if [ "$#" -eq 1 ] && [ "$1" = --help ]; then echo help >> ${JSON.stringify(helpLog)}; cat ${JSON.stringify(helpFile)}; exit 0; fi +out=ARGV +for a in "$@"; do out="$out|$a"; done +[ "\${FAKE_CODEX_READ_STDIN:-}" = 1 ] && out="$out|stdin=$(cat)" +printf '%s\\n' "$out" +exit "\${FAKE_CODEX_EXIT:-0}" +` + ) + return { bin, codex, helpFile, helpLog } +} + +function helpProbes(sandbox: Sandbox): number { + return readFileSync(sandbox.helpLog, 'utf8').split('\n').filter(Boolean).length +} + +function quote(shell: Shell, word: string): string { + if (isPowerShell(shell)) { + return `'${word.replace(/'/g, "''")}'` + } + if (shell === 'fish') { + return `'${word.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'` + } + return `'${word.replace(/'/g, `'\\''`)}'` +} + +function codexCall(shell: Shell, argv: string[]): string { + return ['codex', ...argv.map((word) => quote(shell, word))].join(' ') +} + +function run( + shell: Shell, + script: string, + sandbox: Sandbox, + env: Record = {}, + preamble = '' +): { status: number | null; stdout: string; stderr: string } { + const template = + shell === 'fish' + ? getFishCodexShellLaunchPreflight() + : isPowerShell(shell) + ? getPowerShellCodexShellLaunchPreflight() + : getPosixCodexShellLaunchPreflight() + // Why the guard: rows include `login`, which a real codex would run against the host's account. + const guard = isPowerShell(shell) + ? `if ((Get-Command codex -CommandType Application | Select-Object -First 1).Source -ne ${quote(shell, sandbox.codex)}) { exit 97 }` + : shell === 'fish' + ? `test (command -s codex) = ${quote(shell, sandbox.codex)}; or exit 97` + : `[ "$(command -v codex)" = ${quote(shell, sandbox.codex)} ] || exit 97` + const body = `${guard}\n${preamble}\n${template}\n${script}` + // Why a file for bash/zsh: it is read line by line like a startup file, so an alias it defines applies. + const scriptFile = join(sandbox.bin, '..', 'script.sh') + writeFileSync(scriptFile, body) + const [command, args]: [string, string[]] = + shell === 'bash' + ? ['/bin/bash', ['--noprofile', '--norc', scriptFile]] + : shell === 'zsh' + ? ['/bin/zsh', ['-f', scriptFile]] + : shell === 'fish' + ? [String(fishLookup.path), ['--no-config', '-c', body]] + : [shell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', body]] + const result = spawnSync(command, args, { + encoding: 'utf-8', + env: { + ...process.env, + PATH: `${sandbox.bin}${delimiter}${process.env.PATH ?? ''}`, + CODEX_HOME: join(sandbox.bin, '..', 'codex-home'), + ...env + } + }) + return { status: result.status, stdout: result.stdout, stderr: result.stderr } +} + +function lines(output: string): string[] { + return output.trimEnd().split(/\r?\n/) +} + +function expectedLine(argv: string[], added: boolean): string { + return ['ARGV', ...(added ? ['--no-daemon'] : []), ...argv].join('|') +} + +const shells: [Shell, boolean][] = [ + ['bash', !isWindows && existsSync('/bin/bash')], + ['zsh', !isWindows && existsSync('/bin/zsh')], + ['fish', fishLookup.available], + ['pwsh', pwshAvailable], + // Why: Windows PowerShell 5.1 turns redirected native stderr into errors, unlike pwsh. + ['powershell', isWindows && canRun('powershell')] +] + +describe('codex wrapper --no-daemon rule', () => { + it('has pwsh when CI demanded it', () => { + expect(process.env.ORCA_REQUIRE_PWSH !== '1' || pwshAvailable).toBe(true) + }) + + for (const [shell, available] of shells) { + describe.skipIf(!available)(shell, () => { + it('adds --no-daemon first unless an argument is denylisted', () => { + const sandbox = makeSandbox(HELP_WITH_FLAG) + const result = run(shell, ALL.map((argv) => codexCall(shell, argv)).join('\n'), sandbox) + + expect(result.stderr).toBe('') + expect(lines(result.stdout)).toEqual([ + ...ADDED.map((argv) => expectedLine(argv, true)), + ...UNCHANGED.map((argv) => expectedLine(argv, false)) + ]) + // Why: a denylisted launch must not pay for, or depend on, the --help probe. + expect(helpProbes(sandbox)).toBe(ADDED.length) + }) + + it('adds nothing when --help does not list the flag (0.155 and older)', () => { + const sandbox = makeSandbox(HELP_WITHOUT_FLAG) + const result = run(shell, ALL.map((argv) => codexCall(shell, argv)).join('\n'), sandbox) + + expect(lines(result.stdout)).toEqual(ALL.map((argv) => expectedLine(argv, false))) + }) + + it('adds nothing with ORCA_CODEX_ISOLATE=0, read on every call', () => { + const sandbox = makeSandbox(HELP_WITH_FLAG) + const setIsolate = (value: string): string => + isPowerShell(shell) + ? `$env:ORCA_CODEX_ISOLATE = '${value}'` + : shell === 'fish' + ? `set -gx ORCA_CODEX_ISOLATE ${value}` + : `export ORCA_CODEX_ISOLATE=${value}` + const result = run( + shell, + ['codex a', setIsolate('1'), 'codex b', setIsolate('0'), 'codex c'].join('\n'), + sandbox, + { ORCA_CODEX_ISOLATE: '0' } + ) + + expect(lines(result.stdout)).toEqual(['ARGV|a', 'ARGV|--no-daemon|b', 'ARGV|c']) + }) + + it('re-probes --help when Codex changes version mid-shell', () => { + const sandbox = makeSandbox(HELP_WITH_FLAG) + const swap = (help: string): string => + isPowerShell(shell) + ? `Set-Content -NoNewline -LiteralPath ${quote(shell, sandbox.helpFile)} -Value ${quote(shell, help)}` + : `printf '%s' ${quote(shell, help)} > ${quote(shell, sandbox.helpFile)}` + const result = run( + shell, + ['codex a', swap(HELP_WITHOUT_FLAG), 'codex b', swap(HELP_WITH_FLAG), 'codex c'].join( + '\n' + ), + sandbox + ) + + expect(lines(result.stdout)).toEqual(['ARGV|--no-daemon|a', 'ARGV|b', 'ARGV|--no-daemon|c']) + }) + + it("keeps piped stdin for Codex and returns Codex's exit status", () => { + const sandbox = makeSandbox(HELP_WITH_FLAG) + const script = isPowerShell(shell) + ? `'piped' | codex exec -\n"status=$LASTEXITCODE"` + : shell === 'fish' + ? `printf piped | codex exec -\necho status=$status` + : `printf piped | codex exec -\necho status=$?` + const result = run(shell, script, sandbox, { + FAKE_CODEX_READ_STDIN: '1', + FAKE_CODEX_EXIT: '3' + }) + + expect(lines(result.stdout)).toEqual(['ARGV|--no-daemon|exec|-|stdin=piped', 'status=3']) + }) + }) + } + + for (const [shell, enableAliases] of [ + ['bash', 'shopt -s expand_aliases'], + ['zsh', 'setopt aliases'] + ] as const) { + it.skipIf(isWindows || !existsSync(`/bin/${shell}`))( + `applies a user alias named codex defined before the wrapper in ${shell}`, + () => { + const sandbox = makeSandbox(HELP_WITH_FLAG) + const result = run( + shell, + 'codex x', + sandbox, + {}, + `${enableAliases}\nalias codex='codex --alias-flag'` + ) + + expect(result.status, result.stderr).toBe(0) + expect(result.stdout.trim()).toBe('ARGV|--no-daemon|--alias-flag|x') + } + ) + } + + it.skipIf(isWindows || !existsSync('/bin/zsh'))( + 'creates no globals under warn_create_global', + () => { + const sandbox = makeSandbox(HELP_WITH_FLAG) + const result = run('zsh', 'setopt warn_create_global no_unset\ncodex x', sandbox) + + expect(result.stderr).toBe('') + expect(result.stdout.trim()).toBe('ARGV|--no-daemon|x') + } + ) + + for (const [shell, available] of shells.filter(([name]) => isPowerShell(name))) { + it.skipIf(!available)( + `${shell} runs under StrictMode and Stop with a failing, noisy hook prep`, + () => { + const sandbox = makeSandbox(HELP_WITH_FLAG) + const prep = join(sandbox.bin, isWindows ? 'orca-prep.cmd' : 'orca-prep') + writeExecutable( + prep, + isWindows + ? '@echo prep-noise 1>&2\r\n@exit /b 7\r\n' + : '#!/bin/sh\necho prep-noise >&2\nexit 7\n' + ) + const result = run( + shell, + 'codex x\n"status=$LASTEXITCODE"', + sandbox, + { ORCA_CODEX_LAUNCH_PREFLIGHT: prep }, + [ + 'Set-StrictMode -Version Latest', + '$ErrorActionPreference = "Stop"', + '$PSNativeCommandUseErrorActionPreference = $true' + ].join('\n') + ) + + expect(result.status, result.stderr).toBe(0) + expect(lines(result.stdout)).toEqual(['ARGV|--no-daemon|x', 'status=0']) + } + ) + } +}) diff --git a/src/main/pty/node-pty-self-exit-pseudoconsole-close.test.ts b/src/main/pty/node-pty-self-exit-pseudoconsole-close.test.ts index 1f9cae275c3..6d8b6411ba5 100644 --- a/src/main/pty/node-pty-self-exit-pseudoconsole-close.test.ts +++ b/src/main/pty/node-pty-self-exit-pseudoconsole-close.test.ts @@ -84,7 +84,7 @@ describe('node-pty patch: pseudoconsole close on the self-exit path', () => { [ '+ baton->shellExited = true;', '+ if (baton->consoleClosed) {', - '+ const bool removed = remove_pty_baton(baton->id);', + '+ const bool removed = remove_pty_baton_locked(baton->id);', '+ assert(removed);', '+ (void)removed;', '+ }' @@ -138,7 +138,7 @@ describe('node-pty patch: pseudoconsole close on the self-exit path', () => { expect(ptyKillHunk).toContain( [ '+ if (handle->shellExited) {', - '+ const bool removed = remove_pty_baton(id);', + '+ const bool removed = remove_pty_baton_locked(id);', '+ assert(removed);', '+ (void)removed;' ].join('\n') diff --git a/src/main/pty/posix-pty-process-groups.test.ts b/src/main/pty/posix-pty-process-groups.test.ts index ef9acf05030..f0b3727c6ee 100644 --- a/src/main/pty/posix-pty-process-groups.test.ts +++ b/src/main/pty/posix-pty-process-groups.test.ts @@ -1,19 +1,33 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProcessResult } from '../../shared/child-process/run-process' -const { recordSelfInitiatedTreeKillMock } = vi.hoisted(() => ({ - recordSelfInitiatedTreeKillMock: vi.fn() +const { recordSelfInitiatedTreeKillMock, runProcessMock, runProcessSyncMock } = vi.hoisted(() => ({ + recordSelfInitiatedTreeKillMock: vi.fn(), + runProcessMock: vi.fn(), + runProcessSyncMock: vi.fn() })) vi.mock('../crash-reporting/self-initiated-tree-kill-log', () => ({ recordSelfInitiatedTreeKill: recordSelfInitiatedTreeKillMock })) +vi.mock('../../shared/child-process/run-process', () => ({ + runProcess: runProcessMock, + runProcessSync: runProcessSyncMock +})) import { forceKillPosixPtyProcessGroups, - getPosixPtyProcessGroups + getPosixPtyProcessGroups, + isPosixPtyRootStopped, + readPosixPtyProcessTable, + resetPosixPtyProcessTableDialectForTests, + signalPosixPtyProcessGroups } from './posix-pty-process-groups' beforeEach(() => { recordSelfInitiatedTreeKillMock.mockReset() + runProcessMock.mockReset() + runProcessSyncMock.mockReset() + resetPosixPtyProcessTableDialectForTests() }) const TABLE = ` @@ -25,6 +39,265 @@ const TABLE = ` 300 300 ?? ` +const ALL_PROCESS_ARGS = [ + '-e', + '-o', + 'pid=PROCESS_ID,pgid=PROCESS_GID,tty=TERMINAL_DEVICE_NUMBER,stat=PROCESS_STATE' +] +const BUSYBOX_TABLE = ` +PROCESS_ID PROCESS_GID TERMINAL_DEVICE_NUMBER +100 100 136,100 +101 101 136,100 +200 200 136,10 +201 201 136,10 +999 999 ? +` +const unsupportedSelection = (stderr = 'ps: unrecognized option: p\n'): ProcessResult => ({ + code: 1, + signal: null, + stdout: '', + stderr, + timedOut: false +}) + +describe('ps selection compatibility', () => { + it.each([ + ['p', 'ps: unrecognized option: p\nBusyBox v1.37\nUsage: ps'], + ['p', "ps: invalid option -- 'p'\n"], + ['p', 'ps: illegal option -- p\n'], + ['t', 'ps: unrecognized option: t\n'] + ])( + 'falls back after a rejected %s selector and caches only the dialect (%s)', + async (option, stderr) => { + if (option === 't') { + runProcessMock.mockResolvedValueOnce({ code: 0, stdout: '100 100 pts/100' }) + } + runProcessMock + .mockResolvedValueOnce(unsupportedSelection(stderr)) + .mockResolvedValueOnce({ code: 0, stdout: BUSYBOX_TABLE }) + .mockResolvedValueOnce({ + code: 0, + stdout: BUSYBOX_TABLE.replace('201 201 136,10', '202 202 136,10') + }) + + const first = await readPosixPtyProcessTable(100) + const second = await readPosixPtyProcessTable(200) + expect(first).toBe(BUSYBOX_TABLE) + expect(getPosixPtyProcessGroups(first, 100, 999)).toEqual([101, 100]) + expect(getPosixPtyProcessGroups(second, 200, 999)).toEqual([202, 200]) + expect(runProcessMock.mock.calls.map(([spec]) => spec.args)).toEqual([ + ['-p', '100', '-o', 'pid=,pgid=,tty=,stat='], + ...(option === 't' ? [['-t', 'pts/100', '-o', 'pid=,pgid=,tty=,stat=']] : []), + ALL_PROCESS_ARGS, + ALL_PROCESS_ARGS + ]) + expect( + runProcessMock.mock.calls.every( + ([spec]) => spec.maxOutputBytes === 1048576 && spec.timeoutMs === 1000 + ) + ).toBe(true) + } + ) + + it('shares the initial unsupported probe across concurrent callers and cancels waiting independently', async () => { + let resolveProbe: (result: ProcessResult) => void = () => {} + runProcessMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveProbe = resolve + }) + ) + .mockResolvedValue({ code: 0, stdout: BUSYBOX_TABLE }) + const first = readPosixPtyProcessTable(100) + const second = readPosixPtyProcessTable(200) + const controller = new AbortController() + const cancelled = readPosixPtyProcessTable(300, controller.signal) + expect(runProcessMock).toHaveBeenCalledOnce() + controller.abort() + await expect(cancelled).rejects.toThrow() + resolveProbe(unsupportedSelection()) + + expect(getPosixPtyProcessGroups(await first, 100, 999)).toEqual([101, 100]) + expect(getPosixPtyProcessGroups(await second, 200, 999)).toEqual([201, 200]) + expect(runProcessMock.mock.calls.map(([spec]) => spec.args)).toEqual([ + ['-p', '100', '-o', 'pid=,pgid=,tty=,stat='], + ALL_PROCESS_ARGS, + ALL_PROCESS_ARGS + ]) + }) + + it('uses the cached async dialect for synchronous teardown with fresh membership', async () => { + runProcessMock + .mockResolvedValueOnce(unsupportedSelection()) + .mockResolvedValueOnce({ code: 0, stdout: BUSYBOX_TABLE }) + await readPosixPtyProcessTable(100) + runProcessSyncMock.mockReturnValue({ + code: 0, + stdout: BUSYBOX_TABLE.replace('101 101 136,100', '102 102 136,100') + }) + const signalProcessGroup = vi.fn() + const fallback = vi.fn() + forceKillPosixPtyProcessGroups(100, fallback, { + platform: 'linux', + currentPid: 999, + signalProcessGroup + }) + expect(runProcessSyncMock.mock.calls.map(([spec]) => spec.args)).toEqual([ALL_PROCESS_ARGS]) + expect(signalProcessGroup.mock.calls).toEqual([[102], [100]]) + expect(fallback).not.toHaveBeenCalled() + }) + + it('discovers unsupported selection during teardown and shares it with async readers', async () => { + runProcessSyncMock + .mockReturnValueOnce(unsupportedSelection()) + .mockReturnValueOnce({ code: 0, stdout: BUSYBOX_TABLE }) + const signalProcessGroup = vi.fn() + const fallback = vi.fn() + forceKillPosixPtyProcessGroups(100, fallback, { + platform: 'linux', + currentPid: 999, + signalProcessGroup + }) + expect(signalProcessGroup.mock.calls).toEqual([[101], [100]]) + expect(fallback).not.toHaveBeenCalled() + runProcessMock.mockResolvedValueOnce({ code: 0, stdout: BUSYBOX_TABLE }) + await readPosixPtyProcessTable(200) + expect(runProcessMock.mock.calls.map(([spec]) => spec.args)).toEqual([ALL_PROCESS_ARGS]) + }) + + it.each([ + { stderr: 'ps: permission denied' }, + { stderr: 'ps: unrecognized option: o' }, + { stderr: 'ps: unrecognized option: t' }, + { timedOut: true }, + { outputTruncated: true }, + { code: null, signal: 'SIGTERM' } + ])('does not turn an unrelated failure into a full-host scan: %j', async (failure) => { + runProcessMock.mockResolvedValueOnce({ ...unsupportedSelection(), ...failure }) + await expect(readPosixPtyProcessTable(100)).rejects.toThrow('unavailable') + expect(runProcessMock).toHaveBeenCalledOnce() + runProcessMock + .mockResolvedValueOnce({ code: 0, stdout: '100 100 ttys001' }) + .mockResolvedValueOnce({ code: 0, stdout: TABLE }) + await readPosixPtyProcessTable(100) + expect(runProcessMock.mock.calls[1][0].args).toEqual([ + '-p', + '100', + '-o', + 'pid=,pgid=,tty=,stat=' + ]) + }) + + it.each([{ code: 1 }, { code: 0, timedOut: true }, { code: 0, outputTruncated: true }])( + 'rejects incomplete fallback snapshots for async discovery and sync teardown: %j', + async (failure) => { + runProcessMock + .mockResolvedValueOnce(unsupportedSelection()) + .mockResolvedValueOnce({ stdout: BUSYBOX_TABLE, ...failure }) + await expect(readPosixPtyProcessTable(100)).rejects.toThrow('unavailable') + runProcessSyncMock.mockReturnValue({ stdout: BUSYBOX_TABLE, ...failure }) + const signalProcessGroup = vi.fn() + const fallback = vi.fn() + forceKillPosixPtyProcessGroups(100, fallback, { + platform: 'linux', + currentPid: 999, + signalProcessGroup + }) + expect(fallback).toHaveBeenCalledOnce() + expect(signalProcessGroup).not.toHaveBeenCalled() + } + ) + + it('does not cache a rejected selector if the caller has already cancelled', async () => { + const controller = new AbortController() + runProcessMock.mockImplementationOnce(async () => { + controller.abort() + return unsupportedSelection() + }) + await expect(readPosixPtyProcessTable(100, controller.signal)).rejects.toThrow() + expect(runProcessMock).toHaveBeenCalledOnce() + runProcessMock.mockResolvedValueOnce({ code: 0, stdout: '100 100 ?' }) + await readPosixPtyProcessTable(100) + expect(runProcessMock.mock.calls[1][0].args[0]).toBe('-p') + }) + + it.each(['?', '??', '-', '0', '0,0'])( + 'refuses to group processes without a controlling terminal (%s)', + (tty) => { + expect(getPosixPtyProcessGroups(`100 100 ${tty}\n101 101 ${tty}`, 100, 999)).toBeNull() + } + ) + + it('preserves full numeric terminal identity and the daemon terminal guard', () => { + expect(getPosixPtyProcessGroups(BUSYBOX_TABLE, 100, 999)).toEqual([101, 100]) + expect(getPosixPtyProcessGroups(BUSYBOX_TABLE, 200, 999)).toEqual([201, 200]) + expect(getPosixPtyProcessGroups(BUSYBOX_TABLE, 100, 101)).toBeNull() + }) +}) + +describe('asynchronous PTY process discovery', () => { + it('requires a stopped state for the exact shell process', () => { + expect(isPosixPtyRootStopped('100 100 pts/test Ts\n101 101 pts/test R+', 100)).toBe(true) + expect(isPosixPtyRootStopped('100 100 pts/test S\n101 101 pts/test T', 100)).toBe(false) + expect(isPosixPtyRootStopped('101 101 pts/test T', 100)).toBe(false) + expect(isPosixPtyRootStopped('100 100 pts/test\n101 101 pts/test T', 100)).toBe(false) + }) + + it('bounds each lookup and selects the root terminal without synchronous subprocesses', async () => { + const controller = new AbortController() + runProcessMock + .mockResolvedValueOnce({ code: 0, stdout: '100 100 ttys001' }) + .mockResolvedValueOnce({ code: 0, stdout: TABLE }) + + expect(await readPosixPtyProcessTable(100, controller.signal)).toBe(`100 100 ttys001\n${TABLE}`) + expect( + runProcessMock.mock.calls.map(([spec]) => [{ ...spec, env: { LC_ALL: spec.env.LC_ALL } }]) + ).toEqual([ + [ + { + program: 'ps', + env: { LC_ALL: 'C' }, + args: ['-p', '100', '-o', 'pid=,pgid=,tty=,stat='], + timeoutMs: 1000, + maxOutputBytes: 1048576, + signal: controller.signal + } + ], + [ + { + program: 'ps', + env: { LC_ALL: 'C' }, + args: ['-t', 'ttys001', '-o', 'pid=,pgid=,tty=,stat='], + timeoutMs: 1000, + maxOutputBytes: 1048576, + signal: controller.signal + } + ] + ]) + expect(runProcessSyncMock).not.toHaveBeenCalled() + }) + + it.each([{ code: 1 }, { code: 0, timedOut: true }, { code: 0, outputTruncated: true }])( + 'rejects incomplete process evidence: %j', + async (result) => { + runProcessMock.mockResolvedValue({ stdout: TABLE, ...result }) + await expect(readPosixPtyProcessTable(100)).rejects.toThrow('unavailable') + expect(runProcessMock).toHaveBeenCalledOnce() + } + ) + + it('does not start the second lookup after cancellation', async () => { + const controller = new AbortController() + runProcessMock.mockImplementation(async () => { + controller.abort() + return { code: 0, stdout: '100 100 ttys001' } + }) + await expect(readPosixPtyProcessTable(100, controller.signal)).rejects.toThrow() + expect(runProcessMock).toHaveBeenCalledOnce() + }) +}) + describe('POSIX PTY process-group termination', () => { it('returns every group attached to the root PTY with the root group last', () => { expect(getPosixPtyProcessGroups(TABLE, 100, 999)).toEqual([101, 103, 100]) @@ -51,6 +324,43 @@ describe('POSIX PTY process-group termination', () => { expect(fallback).not.toHaveBeenCalled() }) + it.each([ + ['SIGSTOP', [99, 101, 103]], + ['SIGCONT', [101, 103, 99]] + ] as const)('orders shell and job groups safely for %s', (signal, expected) => { + const signalProcessGroup = vi.fn() + signalPosixPtyProcessGroups(100, signal, vi.fn(), { + platform: 'linux', + currentPid: 999, + readProcessTable: () => TABLE.replace('100 100', '100 99'), + signalProcessGroup + }) + expect(signalProcessGroup.mock.calls.map(([pgid]) => pgid)).toEqual(expected) + }) + + it.each(['EPERM', 'ESRCH'])( + 'does not stop jobs when stopping the shell fails with %s', + (code) => { + const error = Object.assign(new Error('stop failed'), { code }) + const signalProcessGroup = vi.fn(() => { + throw error + }) + const stop = () => + signalPosixPtyProcessGroups(100, 'SIGSTOP', vi.fn(), { + platform: 'linux', + currentPid: 999, + readProcessTable: () => TABLE, + signalProcessGroup + }) + if (code === 'ESRCH') { + expect(stop).not.toThrow() + } else { + expect(stop).toThrow(error) + } + expect(signalProcessGroup.mock.calls).toEqual([[100]]) + } + ) + it('falls back when the process table cannot prove PTY ownership', () => { const fallback = vi.fn() diff --git a/src/main/pty/posix-pty-process-groups.ts b/src/main/pty/posix-pty-process-groups.ts index c34e7ff8123..1f7f05f73a1 100644 --- a/src/main/pty/posix-pty-process-groups.ts +++ b/src/main/pty/posix-pty-process-groups.ts @@ -1,13 +1,35 @@ -import { execFileSync } from 'node:child_process' import { recordSelfInitiatedTreeKill } from '../crash-reporting/self-initiated-tree-kill-log' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { + runProcess, + runProcessSync, + type ProcessResult +} from '../../shared/child-process/run-process' const PROCESS_TABLE_TIMEOUT_MS = 1_000 const PROCESS_TABLE_MAX_BYTES = 1024 * 1024 +const SELECTED_COLUMNS = 'pid=,pgid=,tty=,stat=' +// Explicit widths prevent BusyBox from truncating device numbers into another terminal's identity. +const ALL_PROCESS_ARGS = [ + '-e', + '-o', + 'pid=PROCESS_ID,pgid=PROCESS_GID,tty=TERMINAL_DEVICE_NUMBER,stat=PROCESS_STATE' +] +let psDialect: 'selected' | 'all' | undefined +let dialectProbe: Promise | undefined + +class UnsupportedPsSelectionError extends Error {} + +export function resetPosixPtyProcessTableDialectForTests(): void { + psDialect = undefined + dialectProbe = undefined +} type ProcessRow = { pid: number pgid: number tty: string + state?: string } export type PosixPtyProcessGroupTerminationDeps = { @@ -17,41 +39,149 @@ export type PosixPtyProcessGroupTerminationDeps = { signalProcessGroup?: (pgid: number) => void } -function runPs(args: string[]): string { - return execFileSync('ps', args, { - encoding: 'utf8', - timeout: PROCESS_TABLE_TIMEOUT_MS, - maxBuffer: PROCESS_TABLE_MAX_BYTES - }) +function readProcessTableResult(result: ProcessResult): string { + if (result.code !== 0 || result.timedOut || result.outputTruncated) { + throw new Error('PTY process table is unavailable') + } + return result.stdout +} + +function readSelectionResult(result: ProcessResult, option: 'p' | 't'): string { + const rejectedOption = + /^ps: (?:invalid|illegal|unrecognized) option(?: -- |: | )['"]?-?([pt])['"]?\s*$/m.exec( + result.stderr ?? '' + )?.[1] + if ( + result.code !== null && + result.code !== 0 && + !result.signal && + !result.timedOut && + !result.outputTruncated && + rejectedOption === option + ) { + throw new UnsupportedPsSelectionError() + } + const output = readProcessTableResult(result) + if (psDialect === 'all') { + throw new UnsupportedPsSelectionError() + } + return output +} + +function hasControllingTty(tty: string): boolean { + return tty !== '?' && tty !== '??' && tty !== '-' && tty !== '0' && !/^0,\d+$/.test(tty) +} + +function* processTableQueries(rootPid: number): Generator { + if (psDialect !== 'all') { + try { + const root = readSelectionResult(yield ['-p', String(rootPid), '-o', SELECTED_COLUMNS], 'p') + const rootRow = parseProcessRows(root).find((row) => row.pid === rootPid) + if (!rootRow || !hasControllingTty(rootRow.tty)) { + return root + } + const terminal = readSelectionResult(yield ['-t', rootRow.tty, '-o', SELECTED_COLUMNS], 't') + psDialect ??= 'selected' + return `${root}\n${terminal}` + } catch (error) { + if (!(error instanceof UnsupportedPsSelectionError)) { + throw error + } + psDialect = 'all' + } + } + return readProcessTableResult(yield ALL_PROCESS_ARGS) +} + +function processTableSpec(args: string[]) { + return { + program: 'ps', + args, + env: { ...process.env, LC_ALL: 'C' }, + timeoutMs: PROCESS_TABLE_TIMEOUT_MS, + maxOutputBytes: PROCESS_TABLE_MAX_BYTES + } } function readPtyProcessTable(rootPid: number): string { - const root = runPs(['-p', String(rootPid), '-o', 'pid=,pgid=,tty=']) - const rootRow = parseProcessRows(root).find((row) => row.pid === rootPid) - if (!rootRow || rootRow.tty === '?' || rootRow.tty === '??') { - return root + const queries = processTableQueries(rootPid) + let next = queries.next() + while (!next.done) { + next = queries.next(runProcessSync(processTableSpec(next.value))) + } + return next.value +} + +export async function readPosixPtyProcessTable( + rootPid: number, + signal?: AbortSignal +): Promise { + while (dialectProbe) { + await waitForPromiseWithSignal(dialectProbe, signal) + } + signal?.throwIfAborted() + let releaseProbe: (() => void) | undefined + if (psDialect === undefined) { + dialectProbe = new Promise((resolve) => { + releaseProbe = resolve + }) + } + try { + const queries = processTableQueries(rootPid) + let next = queries.next() + while (!next.done) { + signal?.throwIfAborted() + const result = await runProcess({ ...processTableSpec(next.value), signal }) + signal?.throwIfAborted() + next = queries.next(result) + } + return next.value + } finally { + if (releaseProbe) { + dialectProbe = undefined + releaseProbe() + } } - // Why: a whole-host `ps -ax` takes nearly a second on large machines. TTY - // selection keeps forced terminal teardown proportional to one terminal. - return `${root}\n${runPs(['-t', rootRow.tty, '-o', 'pid=,pgid=,tty='])}` } function parseProcessRows(output: string): ProcessRow[] { const rows: ProcessRow[] = [] for (const line of output.split(/\r?\n/)) { - const match = /^\s*(\d+)\s+(\d+)\s+(\S+)/.exec(line) + const match = /^\s*(\d+)\s+(\d+)\s+(\S+)(?:\s+(\S+))?/.exec(line) if (!match) { continue } const pid = Number(match[1]) const pgid = Number(match[2]) if (pid > 0 && pgid > 1) { - rows.push({ pid, pgid, tty: match[3] }) + rows.push({ pid, pgid, tty: match[3], state: match[4] }) } } return rows } +export function isPosixPtyRootStopped(output: string, rootPid: number): boolean { + return ( + parseProcessRows(output) + .find((row) => row.pid === rootPid) + ?.state?.startsWith('T') === true + ) +} + +/** The root was stopped by flow control; preserve independently stopped jobs. */ +export function getPosixPtyStoppedJobGroups(output: string, rootPid: number): Set { + const rows = parseProcessRows(output) + const root = rows.find((row) => row.pid === rootPid) + return new Set( + rows + .filter( + (row) => + root && row.tty === root.tty && row.pgid !== root.pgid && /^[Tt]/.test(row.state ?? '') + ) + .map((row) => row.pgid) + ) +} + export function getPosixPtyProcessGroups( output: string, rootPid: number, @@ -59,7 +189,7 @@ export function getPosixPtyProcessGroups( ): number[] | null { const rows = parseProcessRows(output) const root = rows.find((row) => row.pid === rootPid) - if (!root || root.tty === '?' || root.tty === '??') { + if (!root || !hasControllingTty(root.tty)) { return null } // Why: a development daemon can inherit its launch TTY. Never group-signal @@ -91,6 +221,16 @@ export function forceKillPosixPtyProcessGroups( rootPid: number, fallback: () => void, deps: PosixPtyProcessGroupTerminationDeps = {} +): void { + signalPosixPtyProcessGroups(rootPid, 'SIGKILL', fallback, deps) +} + +/** Signal every process group proven to belong to one POSIX PTY. */ +export function signalPosixPtyProcessGroups( + rootPid: number, + signal: NodeJS.Signals, + fallback: () => void, + deps: PosixPtyProcessGroupTerminationDeps = {} ): void { if ((deps.platform ?? process.platform) === 'win32') { fallback() @@ -110,14 +250,24 @@ export function forceKillPosixPtyProcessGroups( fallback() return } + if (signal === 'SIGSTOP') { + // Stop the shell before its jobs so it cannot treat their suspension as completion. + groups.unshift(...groups.splice(-1)) + } const signalProcessGroup = - deps.signalProcessGroup ?? ((pgid: number) => process.kill(-pgid, 'SIGKILL')) + deps.signalProcessGroup ?? ((pgid: number) => process.kill(-pgid, signal)) let firstError: unknown for (const pgid of groups) { try { signalProcessGroup(pgid) } catch (error) { + if (signal === 'SIGSTOP' && pgid === groups[0]) { + if (isProcessAlreadyGone(error)) { + return + } + throw error + } // Why: the PTY exit callback may reap a group between `ps` and killpg. // ESRCH is proof that this captured owner is already gone, not failure. if (!isProcessAlreadyGone(error) && firstError === undefined) { @@ -127,11 +277,13 @@ export function forceKillPosixPtyProcessGroups( } // Outside the try: this catch is the ESRCH contract, and a throw from the // breadcrumb path would be rethrown as a failed kill. - recordSelfInitiatedTreeKill({ - pid: pgid, - site: 'posix-pty-process-group-sweep', - scope: 'posix-process-group' - }) + if (signal === 'SIGKILL') { + recordSelfInitiatedTreeKill({ + pid: pgid, + site: 'posix-pty-process-group-sweep', + scope: 'posix-process-group' + }) + } } if (firstError !== undefined) { throw firstError diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index e56146ea991..1df30613076 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -62,6 +62,7 @@ describe('addOrcaWslInteropEnv', () => { ORCA_TERMINAL_HANDLE: 'term_wsl', ORCA_USER_DATA_PATH: 'C:\\Users\\jin\\AppData\\Roaming\\Orca', ORCA_CLI_COMMAND: 'orca-ide', + ORCA_WSL_CLI_DIR: 'C:\\Users\\jin\\AppData\\Roaming\\Orca\\wsl-managed-cli\\hash', ORCA_CODEX_LAUNCH_PREFLIGHT: 'C:\\Program Files\\Orca\\resources\\bin\\orca.exe', ORCA_OMP_FRESH_CONFIG: 'C:\\Orca\\fresh-session.yml', ORCA_OMP_STATUS_EXTENSION: 'C:\\Users\\jin\\.omp\\agent\\extensions\\orca-agent-status.ts', @@ -87,6 +88,7 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV).toContain('ORCA_TERMINAL_HANDLE/u') expect(env.WSLENV).toContain('ORCA_USER_DATA_PATH/p') expect(env.WSLENV).toContain('ORCA_CLI_COMMAND/u') + expect(env.WSLENV).toContain('ORCA_WSL_CLI_DIR/p') expect(env.WSLENV).toContain('ORCA_CODEX_LAUNCH_PREFLIGHT/p') expect(env.WSLENV).toContain('ORCA_OMP_STATUS_EXTENSION/p') expect(env.WSLENV).toContain('ORCA_OMP_FRESH_CONFIG/p') diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index 04035a957ca..0044399009f 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -79,7 +79,10 @@ export function addOrcaWslInteropEnv(env: Record): void { // and it cannot derive the hash segment from ORCA_USER_DATA_PATH alone. 'ORCA_SHELL_READY_ROOT/p', 'ORCA_CLI_COMMAND/u', + // Why /p: the managed CLI launcher lives in the host's userData tree. + 'ORCA_WSL_CLI_DIR/p', 'ORCA_CODEX_LAUNCH_PREFLIGHT/p', + 'ORCA_CODEX_ISOLATE/u', 'ORCA_PANE_KEY/u', 'ORCA_TAB_ID/u', 'ORCA_WORKTREE_ID/u', diff --git a/src/main/qoder/hook-service.test.ts b/src/main/qoder/hook-service.test.ts new file mode 100644 index 00000000000..3af282ecd76 --- /dev/null +++ b/src/main/qoder/hook-service.test.ts @@ -0,0 +1,102 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import type * as OsModule from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +const sandbox = vi.hoisted(() => ({ home: '' })) +vi.mock('node:os', async (importOriginal) => ({ + ...(await importOriginal()), + homedir: () => sandbox.home +})) +vi.mock('electron', () => ({ app: { getPath: () => sandbox.home } })) +import { getManagedLifecycleHook, hasSameManagedHookInvocation } from '../claude/hook-settings' +import { createManagedCommandMatcher } from '../agent-hooks/installer-utils' +import { qoderHookService, QODER_HOOK_EVENTS } from './hook-service' +import { markQoderWorkspaceTrusted, withQoderTrustedWorkspace } from './workspace-trust' + +beforeAll(() => { + sandbox.home = mkdtempSync(join(tmpdir(), 'orca-qoder-test-')) + mkdirSync(join(sandbox.home, '.qoder')) +}) +afterAll(() => { + rmSync(sandbox.home, { recursive: true, force: true }) +}) + +describe('Qoder managed configuration', () => { + it('preserves user hooks and trust, installs idempotently, then removes only Orca hooks', () => { + const path = join(sandbox.home, '.qoder', 'settings.json') + const userHook = { hooks: [{ type: 'command', command: 'echo user-hook' }] } + writeFileSync( + path, + JSON.stringify({ + model: 'custom', + hooks: { Stop: [userHook] }, + permissions: { trustDirectories: ['/existing'] } + }) + ) + expect(qoderHookService.install().state).toBe('installed') + expect(qoderHookService.install().state).toBe('installed') + const installed = JSON.parse(readFileSync(path, 'utf8')) + for (const event of QODER_HOOK_EVENTS) { + expect(installed.hooks[event]).toHaveLength(event === 'Stop' ? 2 : 1) + } + expect(installed.hooks.TeammateIdle).toBeUndefined() + expect(installed.statusLine).toBeUndefined() + markQoderWorkspaceTrusted('/new-workspace') + const trusted = JSON.parse(readFileSync(path, 'utf8')) + expect(trusted.permissions.trustDirectories).toEqual(['/existing', '/new-workspace']) + expect(trusted.hooks).toEqual(installed.hooks) + expect(qoderHookService.remove().state).toBe('not_installed') + expect(JSON.parse(readFileSync(path, 'utf8'))).toMatchObject({ + model: 'custom', + hooks: { Stop: [userHook] } + }) + }) + + it("writes every Qoder event whatever Claude version is passed, since Claude's table never applies", () => { + const path = join(sandbox.home, '.qoder', 'settings.json') + writeFileSync(path, JSON.stringify({ hooks: {} })) + expect(qoderHookService.install({ claudeVersion: '1.0.62' }).state).toBe('installed') + const installed = JSON.parse(readFileSync(path, 'utf8')) + expect(Object.keys(installed.hooks).sort()).toEqual([...QODER_HOOK_EVENTS].sort()) + expect(installed.statusLine).toBeUndefined() + expect(qoderHookService.remove().state).toBe('not_installed') + }) + + it('refuses malformed settings instead of overwriting them', () => { + const path = join(sandbox.home, '.qoder', 'settings.json') + writeFileSync(path, '{broken') + expect(qoderHookService.install().state).toBe('error') + markQoderWorkspaceTrusted('/new-workspace') + expect(readFileSync(path, 'utf8')).toBe('{broken') + expect(withQoderTrustedWorkspace({ permissions: 'invalid' }, '/workspace')).toBeNull() + expect( + withQoderTrustedWorkspace({ permissions: { trustDirectories: true } }, '/workspace') + ).toBeNull() + }) +}) + +describe('Qoder Windows hook shell', () => { + it('uses the documented explicit shell without relying on Git Bash or another PowerShell hop', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + try { + const hook = getManagedLifecycleHook( + 'C:\\Users\\a b\\.orca\\agent-hooks\\qoder-hook.cmd', + { + configDirName: '.qoder', + scriptBaseName: 'qoder-hook', + usesWindowsCompatLauncher: true, + windowsHookShell: 'powershell' + }, + { gitBashAvailable: true } + ) + expect(hook.shell).toBe('powershell') + expect(hook.command).toContain('$env:USERPROFILE') + expect(hook.command).not.toMatch(/EncodedCommand|ExecutionPolicy|\|\|/) + expect(createManagedCommandMatcher('qoder-hook.cmd')(hook.command)).toBe(true) + expect(hasSameManagedHookInvocation({ ...hook, shell: undefined }, hook)).toBe(false) + } finally { + vi.restoreAllMocks() + } + }) +}) diff --git a/src/main/qoder/hook-service.ts b/src/main/qoder/hook-service.ts new file mode 100644 index 00000000000..ac9e869638c --- /dev/null +++ b/src/main/qoder/hook-service.ts @@ -0,0 +1,38 @@ +import type { ClaudeManagedHookPlan } from '../claude/claude-managed-hook-events' +import { ClaudeHookService } from '../claude/hook-service' + +// Qoder documents Claude-shaped hooks at https://docs.qoder.com/cli/hooks. +export const QODER_HOOK_EVENTS = [ + 'SessionStart', + 'SessionEnd', + 'UserPromptSubmit', + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'PermissionRequest', + 'Stop', + 'StopFailure', + 'Notification', + 'PostCompact' +] as const + +// Why: Qoder's own CLI reads these events, so Claude's version table never applies; the statusline +// usage feed is Claude-only. +export const QODER_MANAGED_HOOK_PLAN: ClaudeManagedHookPlan = { + install: QODER_HOOK_EVENTS.map((eventName) => ({ eventName, definition: {} })), + retire: [], + statusLine: 'leave' +} + +export const qoderHookService = new ClaudeHookService({ + agent: 'qoder', + source: 'qoder', + displayName: 'Qoder CLI', + settings: { + configDirName: '.qoder', + scriptBaseName: 'qoder-hook', + usesWindowsCompatLauncher: true, + windowsHookShell: 'powershell' + }, + hookPlan: QODER_MANAGED_HOOK_PLAN +}) diff --git a/src/main/qoder/workspace-trust.ts b/src/main/qoder/workspace-trust.ts new file mode 100644 index 00000000000..8862a76dd75 --- /dev/null +++ b/src/main/qoder/workspace-trust.ts @@ -0,0 +1,44 @@ +import { realpathSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { isPlainObject, readHooksJson, writeHooksJson } from '../agent-hooks/installer-utils' + +// Qoder 1.1.64 writes this exact shape after accepting its folder-trust prompt. +export function withQoderTrustedWorkspace( + config: Record, + workspacePath: string +): Record | null { + if (config.permissions !== undefined && !isPlainObject(config.permissions)) { + return null + } + const permissions = isPlainObject(config.permissions) ? config.permissions : {} + if (permissions.trustDirectories !== undefined && !Array.isArray(permissions.trustDirectories)) { + return null + } + const existing = Array.isArray(permissions.trustDirectories) ? permissions.trustDirectories : [] + if (existing.includes(workspacePath)) { + return config + } + return { + ...config, + permissions: { ...permissions, trustDirectories: [...existing, workspacePath] } + } +} + +export function markQoderWorkspaceTrusted(workspacePath: string): void { + let canonicalPath = workspacePath + try { + canonicalPath = realpathSync.native(workspacePath) + } catch { + /* Keep the supplied path when absent. */ + } + const configPath = join(homedir(), '.qoder', 'settings.json') + const config = readHooksJson(configPath) + if (!config) { + return + } + const updated = withQoderTrustedWorkspace(config, canonicalPath) + if (updated && updated !== config) { + writeHooksJson(configPath, updated) + } +} diff --git a/src/main/quit-path-durable-write-blocking.test.ts b/src/main/quit-path-durable-write-blocking.test.ts index 5d09e2057a7..11bea685c7e 100644 --- a/src/main/quit-path-durable-write-blocking.test.ts +++ b/src/main/quit-path-durable-write-blocking.test.ts @@ -1,10 +1,11 @@ +import { createWorkerMaintenanceFixture } from './persistence/loading-store/profile-state-maintenance-fixture' +import type { Store } from './persistence/loading-store/store' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { mkdtempSync, readFileSync, existsSync, rmSync, utimesSync, writeFileSync } from 'node:fs' import type * as NodeFs from 'node:fs' import type * as NodeFsPromises from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' // Why these tests exist: will-quit used to run stats.flush() and store.flush() synchronously, // before preventDefault(). On a stalled network profile mount those fsync/rename syscalls park @@ -108,37 +109,68 @@ vi.mock('electron', () => ({ } })) -type TestStore = { - updateUI(updates: { sidebarWidth?: number; activeView?: string }): void - setGitHubCache(cache: unknown): void - waitForPendingWrite(): Promise - flushAsync(): Promise - flushOrThrow(): void - stageWorkspaceSessionBeforeUnload(session: Record): void -} - type TestStatsCollector = { onAgentStart(ptyId: string, at: number, repo?: string, worktree?: string): void getSummary(): { totalAgentsSpawned: number; totalAgentTimeMs: number } flushAsync(): Promise } -async function createStore(dir: string): Promise { +const profiles = new Map>>() +const stores: Store[] = [] +const statsCollectors: TestStatsCollector[] = [] +const releases: (() => void)[] = [] + +async function createStore(dir: string): Promise { testState.dir = dir - vi.resetModules() - const { Store, initDataPath } = await import('./persistence') - // Why: userData resolves through AppEnvironment; point it at this file's temp dir. - installFakeAppEnvironment({ getPath: () => testState.dir }) - initDataPath() - return new Store() as unknown as TestStore + const fixture = await createWorkerMaintenanceFixture({ directory: dir, profileId: 'quit-test' }) + profiles.set(dir, fixture) + stores.push(fixture.store) + fixture.store.updateUI({ activeView: 'terminal' }) + await fixture.store.flushPendingOrThrowAsync() + await fixture.authority.drainBackups() + return fixture.store +} + +function readState(dir: string) { + const profile = profiles.get(dir) + if (!profile) { + throw new Error('Missing test profile') + } + return profile.readState() +} + +function stallFilesystem(): void { + const gate = Promise.withResolvers() + releases.push(gate.resolve) + fsCalls.holdAsync = gate.promise +} + +function stallCommit(dir: string) { + const profile = profiles.get(dir) + if (!profile) { + throw new Error('Missing test profile') + } + const started = Promise.withResolvers() + const finish = Promise.withResolvers() + releases.push(finish.resolve) + const original = profile.authority.writeCompleteSerializedDomains.bind(profile.authority) + vi.spyOn(profile.authority, 'writeCompleteSerializedDomains').mockImplementationOnce( + async (rows) => { + started.resolve() + await finish.promise + await original(rows) + } + ) + return { started: started.promise, release: finish.resolve } } async function createStatsCollector(dir: string): Promise { testState.dir = dir - vi.resetModules() const { StatsCollector, initStatsPath } = await import('./stats/collector') initStatsPath() - return new StatsCollector() as unknown as TestStatsCollector + const stats = new StatsCollector() + statsCollectors.push(stats) + return stats } const dataFile = (dir: string): string => join(dir, 'orca-data.json') @@ -165,8 +197,17 @@ describe('quit-path durable writes never park the main thread', () => { fsCalls.reset() }) - afterEach(() => { + afterEach(async () => { + fsCalls.recording = false + for (const release of releases.splice(0)) { + release() + } + fsCalls.holdAsync = null + fsCalls.waitAsync = null vi.useRealTimers() + await Promise.all(stores.splice(0).map((store) => store.freezeWritesAsync())) + await Promise.all(statsCollectors.splice(0).map((stats) => stats.flushAsync())) + profiles.clear() for (const dir of dirs.splice(0)) { rmSync(dir, { recursive: true, force: true }) } @@ -183,7 +224,7 @@ describe('quit-path durable writes never park the main thread', () => { fsCalls.recording = false expect(fsCalls.syncCalls).toEqual([]) - expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(321) + expect(readState(dir).ui.sidebarWidth).toBe(321) }) it('renderer unload staging issues no synchronous fs syscalls', async () => { @@ -194,6 +235,9 @@ describe('quit-path durable writes never park the main thread', () => { fsCalls.recording = true const leafId = '11111111-1111-4111-8111-111111111111' store.stageWorkspaceSessionBeforeUnload({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, tabsByWorktree: { 'remote-repo::/remote': [ { @@ -222,8 +266,7 @@ describe('quit-path durable writes never park the main thread', () => { expect(fsCalls.syncCalls).toEqual([]) await store.flushAsync() - const layout = JSON.parse(readFileSync(dataFile(dir), 'utf-8')).workspaceSession - .terminalLayoutsByTabId['remote-tab'] + const layout = readState(dir).workspaceSession.terminalLayoutsByTabId['remote-tab'] const ref = layout.scrollbackRefsByLeafId[leafId] expect(layout.buffersByLeafId).toBeUndefined() expect(readFileSync(join(dir, 'terminal-scrollback', `${ref}.bin`), 'utf-8')).toBe( @@ -239,7 +282,7 @@ describe('quit-path durable writes never park the main thread', () => { utimesSync(staleCacheTemp, staleSeconds, staleSeconds) const store = await createStore(dir) store.updateUI({ activeView: 'activity' }) - store.setGitHubCache({ pullRequestsByWorktree: {} }) + store.setGitHubCache({ pr: {}, issue: {} }) fsCalls.dirPrefix = dir fsCalls.recording = true @@ -270,7 +313,6 @@ describe('quit-path durable writes never park the main thread', () => { const dir = makeDir() const previousGrokHome = process.env.GROK_HOME process.env.GROK_HOME = dir - vi.resetModules() const { GrokHookService } = await import('./grok/hook-service') const service = new GrokHookService() try { @@ -314,7 +356,7 @@ describe('quit-path durable writes never park the main thread', () => { stats.onAgentStart('pty-1', Date.now() - 4_000) fsCalls.dirPrefix = dir - fsCalls.holdAsync = new Promise(() => {}) + stallFilesystem() const pending = stats.flushAsync() expect(stats.getSummary().totalAgentTimeMs).toBeGreaterThan(0) @@ -327,9 +369,9 @@ describe('quit-path durable writes never park the main thread', () => { store.updateUI({ sidebarWidth: 654 }) fsCalls.dirPrefix = dir - // Every fs call from here on never resolves — the mount is gone. - fsCalls.holdAsync = new Promise(() => {}) + const gate = stallCommit(dir) const pending = store.flushAsync() + await gate.started // The whole point: timers still fire, so the app still repaints and the quit // deadline below can still be reached. A sync flush would have blocked here. @@ -344,8 +386,10 @@ describe('quit-path durable writes never park the main thread', () => { store.updateUI({ sidebarWidth: 999 }) fsCalls.dirPrefix = dir - fsCalls.holdAsync = new Promise(() => {}) + const before = readState(dir).ui.sidebarWidth + const gate = stallCommit(dir) const pending = store.flushAsync() + await gate.started const outstanding = await settleTeardownWithinDeadline( [{ name: 'state', promise: pending }], @@ -353,23 +397,28 @@ describe('quit-path durable writes never park the main thread', () => { ) expect(outstanding).toEqual(['state']) - // Cut short before the rename, so the previous file is still whole — bounded loss, no corruption. - expect(existsSync(dataFile(dir))).toBe(false) + expect(readState(dir).ui.sidebarWidth).toBe(before) + gate.release() + await pending + expect(readState(dir).ui.sidebarWidth).toBe(999) }) it('store.flushAsync() drains an in-flight debounced write before writing', async () => { - vi.useFakeTimers() const dir = makeDir() const store = await createStore(dir) + const gate = stallCommit(dir) + vi.useFakeTimers() store.updateUI({ sidebarWidth: 100 }) await vi.advanceTimersByTimeAsync(1_000) + await gate.started store.updateUI({ sidebarWidth: 200 }) const flushed = store.flushAsync() - await vi.runAllTimersAsync() + vi.useRealTimers() + gate.release() await flushed - expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(200) + expect(readState(dir).ui.sidebarWidth).toBe(200) }) it('store.flushAsync() is one idempotent final barrier', async () => { @@ -383,7 +432,7 @@ describe('quit-path durable writes never park the main thread', () => { expect(second).toBe(first) expect(() => store.flushOrThrow()).toThrow('final persistence') await first - expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(777) + expect(readState(dir).ui.sidebarWidth).toBe(777) }) it('serializes github-cache snapshots and keeps the newest generation', async () => { @@ -394,6 +443,7 @@ describe('quit-path durable writes never park the main thread', () => { releaseWrite = resolve }) let signalWrite!: () => void + releases.push(releaseWrite) const writeStarted = new Promise((resolve) => { signalWrite = resolve }) @@ -409,17 +459,18 @@ describe('quit-path durable writes never park the main thread', () => { return writeRelease } - store.setGitHubCache({ version: 1 }) + store.setGitHubCache({ pr: { test: { data: null, fetchedAt: 1 } }, issue: {} }) const finalFlush = store.flushAsync() await writeStarted - store.setGitHubCache({ version: 2 }) + store.setGitHubCache({ pr: { test: { data: null, fetchedAt: 2 } }, issue: {} }) releaseWrite() await finalFlush fsCalls.recording = false expect(JSON.parse(readFileSync(join(dir, 'orca-github-cache.json'), 'utf-8'))).toEqual({ - version: 2 + pr: { test: { data: null, fetchedAt: 2 } }, + issue: {} }) }) @@ -444,9 +495,9 @@ describe('quit-path durable writes never park the main thread', () => { it('the quit flush is the last write — later mutations do not schedule another', async () => { // A teardown step that touches the store would otherwise arm a debounced write with // nothing awaiting it, leaving a rename to race the process exit. - vi.useFakeTimers() const dir = makeDir() const store = await createStore(dir) + vi.useFakeTimers() store.updateUI({ sidebarWidth: 10 }) await store.flushAsync() @@ -457,11 +508,11 @@ describe('quit-path durable writes never park the main thread', () => { fsCalls.recording = false expect(fsCalls.syncCalls).toEqual([]) - expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(10) + expect(readState(dir).ui.sidebarWidth).toBe(10) expect(JSON.parse(readFileSync(activeViewFile(dir), 'utf-8')).activeView).toBe('terminal') }) - it('sweeps orphaned state and stats temp files before the final write', async () => { + it('sweeps orphaned state and stats temps while retaining fresh process temps', async () => { const dir = makeDir() const stateTemp = `${dataFile(dir)}.999999.1.orphan.tmp` const statsTemp = `${statsFile(dir)}.999999.1.orphan.tmp` @@ -480,6 +531,7 @@ describe('quit-path durable writes never park the main thread', () => { await Promise.all([store.flushAsync(), stats.flushAsync()]) expect(existsSync(stateTemp)).toBe(false) + expect(existsSync(dataFile(dir))).toBe(false) expect(existsSync(statsTemp)).toBe(false) expect(existsSync(freshOtherProcessTemp)).toBe(true) }) @@ -488,7 +540,13 @@ describe('quit-path durable writes never park the main thread', () => { const dir = makeDir() const store = await createStore(dir) store.updateUI({ sidebarWidth: 42 }) - rmSync(dir, { recursive: true, force: true }) + const profile = profiles.get(dir) + if (!profile) { + throw new Error('Missing test profile') + } + vi.spyOn(profile.authority, 'writeCompleteSerializedDomains').mockRejectedValueOnce( + new Error('profile mount rejected write') + ) // It joins the teardown barrier; a rejection there is noise that must not cancel the quit. await expect(store.flushAsync()).resolves.toBeUndefined() diff --git a/src/main/quit-teardown-agent-browser-daemons.test.ts b/src/main/quit-teardown-agent-browser-daemons.test.ts deleted file mode 100644 index 315eba73aa0..00000000000 --- a/src/main/quit-teardown-agent-browser-daemons.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -/** - * agent-browser forks a daemon per browser tab that Orca holds no handle on, and - * `destroyAllSessions` closes each one by spawning another agent-browser child — - * hundreds of ms apiece. Left off the will-quit barrier, `app.quit()` fired first and - * every open tab's daemon outlived the app (#16367). - */ -const source = readFileSync(join(__dirname, 'startup', 'main-process-quit.ts'), 'utf8') - -function teardownBarrierMembers(): string { - const start = source.indexOf('settleTeardownWithinDeadline([') - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf('])', start) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) -} - -describe('quit teardown of agent-browser daemons', () => { - it('joins the will-quit teardown barrier', () => { - expect(teardownBarrierMembers()).toContain("{ name: 'browser', promise: browserShutdown }") - }) - - it('captures the destroyAllSessions promise instead of firing and forgetting', () => { - expect(source).toMatch( - /const browserShutdown = \(async \(\): Promise => \{[\s\S]*?await state\.runtime\?\.getAgentBrowserBridge\(\)\?\.destroyAllSessions\(\)\s+\}\)\(\)/ - ) - // Why: a second, uncaptured call site is the pre-fix shape — it loses the race to app.quit(). - expect(source.match(/getAgentBrowserBridge\(\)\?\.destroyAllSessions\(\)/g)).toHaveLength(1) - }) -}) diff --git a/src/main/rate-limits/cursor-auth-paths.ts b/src/main/rate-limits/cursor-auth-paths.ts new file mode 100644 index 00000000000..72a3006a8ae --- /dev/null +++ b/src/main/rate-limits/cursor-auth-paths.ts @@ -0,0 +1,34 @@ +import os from 'node:os' +import { join } from 'node:path' + +/** Where a Cursor credential was found. */ +export type CursorAuthSource = 'keychain' | 'cli' | 'desktop' + +function cursorConfigRoot(source: Exclude): string { + if (process.platform === 'darwin') { + return source === 'desktop' + ? join(os.homedir(), 'Library', 'Application Support', 'Cursor') + : join(os.homedir(), '.cursor') + } + if (process.platform === 'win32') { + const root = process.env.APPDATA?.trim() || join(os.homedir(), 'AppData', 'Roaming') + return join(root, 'Cursor') + } + const root = process.env.XDG_CONFIG_HOME?.trim() || join(os.homedir(), '.config') + return join(root, source === 'desktop' ? 'Cursor' : 'cursor') +} + +/** Cursor IDE global storage; holds `cursorAuth/*` keys in an `ItemTable` row. */ +export function getCursorDesktopStateDbPath(): string { + return join(cursorConfigRoot('desktop'), 'User', 'globalStorage', 'state.vscdb') +} + +/** Pre-2026.06 `cursor-agent` token file; newer CLIs keep the token in the OS keychain. */ +export function getCursorCliAuthPath(): string { + return join(cursorConfigRoot('cli'), 'auth.json') +} + +/** `cursor-agent` settings file; its `authInfo` block carries the signed-in identity, never a token. */ +export function getCursorCliConfigPath(): string { + return join(cursorConfigRoot('cli'), 'cli-config.json') +} diff --git a/src/main/rate-limits/cursor-auth.test.ts b/src/main/rate-limits/cursor-auth.test.ts new file mode 100644 index 00000000000..8280bf984cd --- /dev/null +++ b/src/main/rate-limits/cursor-auth.test.ts @@ -0,0 +1,230 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const keychainState = vi.hoisted<{ token: string | null; error: Error | null }>(() => ({ + token: null, + error: null +})) +const files = vi.hoisted<() => Map>(() => { + const map = new Map() + return () => map +}) +const desktopState = vi.hoisted<{ + result: { status: string; profile?: unknown; error?: string } +}>(() => ({ result: { status: 'missing' } })) + +vi.mock('../macos-keychain/generic-password', () => ({ + readKeychainPassword: async () => { + if (keychainState.error) { + throw keychainState.error + } + return keychainState.token + } +})) + +vi.mock('node:fs', () => ({ + existsSync: (path: string) => files().has(path), + readFileSync: (path: string) => { + const entry = files().get(path) + if (entry instanceof Error) { + throw entry + } + if (entry === undefined) { + throw new Error('ENOENT') + } + return entry + } +})) + +vi.mock('./cursor-desktop-state-db', () => ({ + readCursorDesktopProfile: () => desktopState.result +})) + +import { readCursorAuthSession, readCursorCliIdentity } from './cursor-auth' + +type JwtSegment = Record + +function expiredJwt(sub: string): string { + const encode = (value: JwtSegment): string => + Buffer.from(JSON.stringify(value)).toString('base64url') + return `${encode({ alg: 'RS256' })}.${encode({ sub, exp: 1_000 })}.signature` +} + +function jwt(sub: string): string { + const encode = (value: JwtSegment): string => + Buffer.from(JSON.stringify(value)).toString('base64url') + return `${encode({ alg: 'RS256' })}.${encode({ sub, exp: 2_000_000_000 })}.signature` +} + +const CLI_AUTH = '/cli/auth.json' +const CLI_CONFIG = '/cli/cli-config.json' +const DESKTOP_DB = '/desktop/state.vscdb' +const options = { + cliAuthPath: CLI_AUTH, + cliConfigPath: CLI_CONFIG, + desktopStateDbPath: DESKTOP_DB +} + +const originalPlatform = process.platform + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) +} + +beforeEach(() => { + // Why: the keychain source is macOS-only by an explicit platform check, so + // these cases must pin the platform rather than inherit the CI runner's. + setPlatform('darwin') + keychainState.token = null + keychainState.error = null + files().clear() + desktopState.result = { status: 'missing' } +}) + +afterEach(() => { + setPlatform(originalPlatform) + vi.restoreAllMocks() +}) + +describe('readCursorAuthSession', () => { + it('prefers the keychain session cursor-agent 2026.06+ writes', async () => { + keychainState.token = jwt('auth0|user_keychain') + files().set(CLI_AUTH, JSON.stringify({ accessToken: jwt('auth0|user_file') })) + const result = await readCursorAuthSession(options) + expect(result.status).toBe('ok') + expect(result.status === 'ok' && result.session.source).toBe('keychain') + expect(result.status === 'ok' && result.session.token.subject).toBe('auth0|user_keychain') + }) + + it('names the signed-in account from cli-config.json, which holds no token', async () => { + keychainState.token = jwt('auth0|user_1') + files().set( + CLI_CONFIG, + JSON.stringify({ authInfo: { email: 'dev@example.com', displayName: 'Dev' } }) + ) + const result = await readCursorAuthSession(options) + expect(result.status === 'ok' && result.session.email).toBe('dev@example.com') + expect(result.status === 'ok' && result.session.displayName).toBe('Dev') + }) + + it('falls back to the legacy CLI auth file when the keychain holds nothing', async () => { + files().set(CLI_AUTH, JSON.stringify({ accessToken: jwt('auth0|user_file') })) + const result = await readCursorAuthSession(options) + expect(result.status === 'ok' && result.session.source).toBe('cli') + }) + + it('falls back to Cursor IDE when no CLI session exists', async () => { + desktopState.result = { + status: 'ok', + profile: { + accessToken: jwt('auth0|user_ide'), + email: 'ide@example.com', + membershipType: 'pro', + subscriptionStatus: 'active' + } + } + const result = await readCursorAuthSession(options) + expect(result.status === 'ok' && result.session.source).toBe('desktop') + expect(result.status === 'ok' && result.session.membershipType).toBe('pro') + }) + + it('does not let a locked keychain hide a readable CLI session', async () => { + keychainState.error = new Error('User interaction is not allowed') + files().set(CLI_AUTH, JSON.stringify({ accessToken: jwt('auth0|user_file') })) + const result = await readCursorAuthSession(options) + expect(result.status === 'ok' && result.session.source).toBe('cli') + }) + + it('reports the first read failure when no source yields a session', async () => { + keychainState.error = new Error('User interaction is not allowed') + const result = await readCursorAuthSession(options) + expect(result).toEqual({ + status: 'error', + error: 'Unable to read the Cursor login from the macOS Keychain' + }) + }) + + it('reports signed out when nothing is stored anywhere', async () => { + expect(await readCursorAuthSession(options)).toEqual({ status: 'missing' }) + }) + + it('treats a signed-out CLI auth file as missing, not as an error', async () => { + files().set(CLI_AUTH, JSON.stringify({})) + expect(await readCursorAuthSession(options)).toEqual({ status: 'missing' }) + }) + + it('keeps the local auth path out of the surfaced error', async () => { + files().set( + CLI_AUTH, + new Error('EACCES: permission denied, open /Users/someone/.cursor/auth.json') + ) + const result = await readCursorAuthSession(options) + expect(result).toEqual({ status: 'error', error: 'Unable to read the Cursor CLI auth file' }) + }) + + it('reports invalid JSON distinctly from an unreadable file', async () => { + files().set(CLI_AUTH, '{ not json') + const result = await readCursorAuthSession(options) + expect(result).toEqual({ status: 'error', error: 'Cursor CLI auth file is invalid' }) + }) + + it('prefers a live desktop session over an expired keychain one', async () => { + // Why: a user who signed the CLI in once and now works only in the IDE would + // otherwise be told "sign-in expired" forever while a usable session sat below. + keychainState.token = expiredJwt('auth0|user_stale') + desktopState.result = { + status: 'ok', + profile: { + accessToken: jwt('auth0|user_ide'), + email: 'ide@example.com', + membershipType: 'pro', + subscriptionStatus: 'active' + } + } + const result = await readCursorAuthSession(options) + expect(result.status === 'ok' && result.session.source).toBe('desktop') + expect(result.status === 'ok' && result.session.token.subject).toBe('auth0|user_ide') + }) + + it('still returns the expired session when no live one exists anywhere', async () => { + // Why: the expiry message is the actionable answer in that case. + keychainState.token = expiredJwt('auth0|user_stale') + const result = await readCursorAuthSession(options) + expect(result.status === 'ok' && result.session.source).toBe('keychain') + }) + + it('never reads the keychain off macOS and falls through to the CLI file', async () => { + setPlatform('linux') + keychainState.token = jwt('auth0|user_keychain') + files().set(CLI_AUTH, JSON.stringify({ accessToken: jwt('auth0|user_file') })) + const result = await readCursorAuthSession(options) + expect(result.status === 'ok' && result.session.source).toBe('cli') + }) + + it('reports signed out off macOS when only a keychain session exists', async () => { + setPlatform('win32') + keychainState.token = jwt('auth0|user_keychain') + expect(await readCursorAuthSession(options)).toEqual({ status: 'missing' }) + }) + + it('skips a stored token that carries no subject', async () => { + keychainState.token = 'not-a-jwt' + expect(await readCursorAuthSession(options)).toEqual({ status: 'missing' }) + }) +}) + +describe('readCursorCliIdentity', () => { + it('returns empty identity for a config with no authInfo', () => { + files().set(CLI_CONFIG, JSON.stringify({ version: 1 })) + expect(readCursorCliIdentity(CLI_CONFIG)).toEqual({ + email: null, + displayName: null, + membershipType: null, + subscriptionStatus: null + }) + }) + + it('survives a corrupt config file', () => { + files().set(CLI_CONFIG, '{{{') + expect(readCursorCliIdentity(CLI_CONFIG).email).toBeNull() + }) +}) diff --git a/src/main/rate-limits/cursor-auth.ts b/src/main/rate-limits/cursor-auth.ts new file mode 100644 index 00000000000..8ef86f0ba73 --- /dev/null +++ b/src/main/rate-limits/cursor-auth.ts @@ -0,0 +1,211 @@ +import { existsSync, readFileSync } from 'node:fs' +import { z } from 'zod' +import { readKeychainPassword } from '../macos-keychain/generic-password' +import { + getCursorCliAuthPath, + getCursorCliConfigPath, + getCursorDesktopStateDbPath, + type CursorAuthSource +} from './cursor-auth-paths' +import { readCursorDesktopProfile, type CursorDesktopProfile } from './cursor-desktop-state-db' +import { + isCursorSessionTokenExpired, + parseCursorSessionToken, + type CursorSessionToken +} from './cursor-session-token' + +// Why: cursor-agent 2026.06+ stores the session in the login keychain, not auth.json. +const KEYCHAIN_SERVICE = 'cursor-access-token' +const KEYCHAIN_ACCOUNT = 'cursor-user' + +export type CursorIdentity = { + email: string | null + displayName: string | null + membershipType: string | null + subscriptionStatus: string | null +} + +export type CursorAuthSession = CursorIdentity & { + token: CursorSessionToken + source: CursorAuthSource +} + +export type CursorAuthReadResult = + | { status: 'missing' } + | { status: 'error'; error: string } + | { status: 'ok'; session: CursorAuthSession } + +function emptyIdentity(): CursorIdentity { + return { email: null, displayName: null, membershipType: null, subscriptionStatus: null } +} + +function nonEmpty(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null +} + +const cliConfigSchema = z.object({ + authInfo: z.object({ email: z.unknown(), displayName: z.unknown() }).partial().optional() +}) + +const cliAuthFileSchema = z.object({ accessToken: z.unknown() }).partial() + +/** `cli-config.json` carries the signed-in identity for keychain-backed CLI sessions. */ +export function readCursorCliIdentity(configPath = getCursorCliConfigPath()): CursorIdentity { + if (!existsSync(configPath)) { + return emptyIdentity() + } + try { + const parsed = cliConfigSchema.safeParse(JSON.parse(readFileSync(configPath, 'utf8'))) + if (!parsed.success) { + return emptyIdentity() + } + return { + ...emptyIdentity(), + email: nonEmpty(parsed.data.authInfo?.email), + displayName: nonEmpty(parsed.data.authInfo?.displayName) + } + } catch { + return emptyIdentity() + } +} + +type TokenReadResult = + | { status: 'missing' } + | { status: 'error'; error: string } + | { status: 'ok'; token: string } + +async function readKeychainToken(): Promise { + if (process.platform !== 'darwin') { + return { status: 'missing' } + } + try { + const token = await readKeychainPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) + return token ? { status: 'ok', token } : { status: 'missing' } + } catch { + // Why: a denied or locked keychain must not mask a readable auth.json below it. + return { status: 'error', error: 'Unable to read the Cursor login from the macOS Keychain' } + } +} + +function readCliFileToken(authPath: string): TokenReadResult { + if (!existsSync(authPath)) { + return { status: 'missing' } + } + try { + const parsed = cliAuthFileSchema.safeParse(JSON.parse(readFileSync(authPath, 'utf8'))) + const token = parsed.success ? parsed.data.accessToken : null + return typeof token === 'string' && token.length > 0 + ? { status: 'ok', token } + : { status: 'missing' } + } catch (error) { + // Why: filesystem errors quote the full path; account state must not leak local usernames. + return { + status: 'error', + error: + error instanceof SyntaxError + ? 'Cursor CLI auth file is invalid' + : 'Unable to read the Cursor CLI auth file' + } + } +} + +function sessionFrom( + raw: string, + source: CursorAuthSource, + identity: CursorIdentity +): CursorAuthSession | null { + const token = parseCursorSessionToken(raw) + return token ? { ...identity, token, source } : null +} + +function desktopIdentity(profile: CursorDesktopProfile): CursorIdentity { + return { + email: profile.email, + displayName: null, + membershipType: profile.membershipType, + subscriptionStatus: profile.subscriptionStatus + } +} + +export type CursorAuthReadOptions = { + cliAuthPath?: string + cliConfigPath?: string + desktopStateDbPath?: string +} + +/** + * Resolve the Cursor session Orca should poll with, preferring the CLI login + * (keychain, then legacy auth.json) over the Cursor IDE's own session. Read-only: + * Orca never writes, refreshes, or rotates the user's Cursor credentials. + */ +export async function readCursorAuthSession( + options: CursorAuthReadOptions = {} +): Promise { + const cliAuthPath = options.cliAuthPath ?? getCursorCliAuthPath() + const cliConfigPath = options.cliConfigPath ?? getCursorCliConfigPath() + const desktopDbPath = options.desktopStateDbPath ?? getCursorDesktopStateDbPath() + const errors: string[] = [] + // Why a live session wins over precedence: a user who signed the CLI in once and + // now works only in the IDE has an expired keychain token in front of a fresh + // desktop one. Returning the first token that parses would report "sign-in + // expired" forever while a usable session sat one source below. + let expired: CursorAuthSession | null = null + const takeLive = (session: CursorAuthSession | null): CursorAuthSession | null => { + if (!session) { + return null + } + if (isCursorSessionTokenExpired(session.token)) { + expired ??= session + return null + } + return session + } + + const keychainRead = await readKeychainToken() + if (keychainRead.status === 'error') { + errors.push(keychainRead.error) + } + if (keychainRead.status === 'ok') { + const session = takeLive( + sessionFrom(keychainRead.token, 'keychain', readCursorCliIdentity(cliConfigPath)) + ) + if (session) { + return { status: 'ok', session } + } + } + + const cliRead = readCliFileToken(cliAuthPath) + if (cliRead.status === 'error') { + errors.push(cliRead.error) + } + if (cliRead.status === 'ok') { + const session = takeLive( + sessionFrom(cliRead.token, 'cli', readCursorCliIdentity(cliConfigPath)) + ) + if (session) { + return { status: 'ok', session } + } + } + + // Why not pushed to `errors`: the IDE holds a lock on state.vscdb while it runs, + // so a busy open is transient. Reporting it would pin an alert-triangle bar on + // every Cursor IDE user who never set Cursor up in Orca. + const desktopRead = readCursorDesktopProfile(desktopDbPath) + if (desktopRead.status === 'ok' && desktopRead.profile.accessToken) { + const session = takeLive( + sessionFrom(desktopRead.profile.accessToken, 'desktop', desktopIdentity(desktopRead.profile)) + ) + if (session) { + return { status: 'ok', session } + } + } + + // Why still returned: with no live session anywhere, the expired one is what the + // user must act on, and the fetcher turns it into "run cursor-agent login". + if (expired) { + return { status: 'ok', session: expired } + } + + const firstError = errors[0] + return firstError === undefined ? { status: 'missing' } : { status: 'error', error: firstError } +} diff --git a/src/main/rate-limits/cursor-desktop-state-db.ts b/src/main/rate-limits/cursor-desktop-state-db.ts new file mode 100644 index 00000000000..bdc50c7390d --- /dev/null +++ b/src/main/rate-limits/cursor-desktop-state-db.ts @@ -0,0 +1,73 @@ +import { existsSync } from 'node:fs' +import { z } from 'zod' +import SyncDatabase from '../sqlite/sync-database' + +const TOKEN_KEY = 'cursorAuth/accessToken' +const EMAIL_KEY = 'cursorAuth/cachedEmail' +const MEMBERSHIP_KEY = 'cursorAuth/stripeMembershipType' +const SUBSCRIPTION_KEY = 'cursorAuth/stripeSubscriptionStatus' +const OPEN_TIMEOUT_MS = 250 + +export type CursorDesktopProfile = { + accessToken: string | null + email: string | null + membershipType: string | null + subscriptionStatus: string | null +} + +export type CursorDesktopProfileReadResult = + | { status: 'missing' } + | { status: 'error'; error: string } + | { status: 'ok'; profile: CursorDesktopProfile } + +const rowsSchema = z.array(z.object({ key: z.unknown(), value: z.unknown() }).partial()) + +function valueAsString(value: unknown): string | null { + if (typeof value === 'string' && value.length > 0) { + return value + } + if (value instanceof Uint8Array) { + return Buffer.from(value).toString('utf8').trim() || null + } + return null +} + +/** Reads the Cursor IDE's stored session. Opens read-only in place; state.vscdb can be multi-GB. */ +export function readCursorDesktopProfile(dbPath: string): CursorDesktopProfileReadResult { + if (!existsSync(dbPath)) { + return { status: 'missing' } + } + let db: SyncDatabase | null = null + try { + db = new SyncDatabase(dbPath, { + readonly: true, + fileMustExist: true, + timeout: OPEN_TIMEOUT_MS + }) + const rows = rowsSchema.parse( + db + .prepare('SELECT key, value FROM ItemTable WHERE key IN (?, ?, ?, ?)') + .all(TOKEN_KEY, EMAIL_KEY, MEMBERSHIP_KEY, SUBSCRIPTION_KEY) + ) + const byKey = new Map() + for (const row of rows) { + const value = valueAsString(row.value) + if (typeof row.key === 'string' && value) { + byKey.set(row.key, value) + } + } + return { + status: 'ok', + profile: { + accessToken: byKey.get(TOKEN_KEY) ?? null, + email: byKey.get(EMAIL_KEY) ?? null, + membershipType: byKey.get(MEMBERSHIP_KEY) ?? null, + subscriptionStatus: byKey.get(SUBSCRIPTION_KEY) ?? null + } + } + } catch { + return { status: 'error', error: 'Unable to read the Cursor desktop login' } + } finally { + db?.close() + } +} diff --git a/src/main/rate-limits/cursor-fetcher.test.ts b/src/main/rate-limits/cursor-fetcher.test.ts new file mode 100644 index 00000000000..b334713a9af --- /dev/null +++ b/src/main/rate-limits/cursor-fetcher.test.ts @@ -0,0 +1,217 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const netFetchMock = vi.hoisted(() => vi.fn()) + +vi.mock('electron', () => ({ net: { fetch: netFetchMock } })) + +import { fetchCursorRateLimits } from './cursor-fetcher' +import type { CursorAuthReadResult } from './cursor-auth' +import { parseCursorSessionToken } from './cursor-session-token' + +type JwtSegment = Record + +function jwt(exp: number): string { + const encode = (value: JwtSegment): string => + Buffer.from(JSON.stringify(value)).toString('base64url') + return `${encode({ alg: 'RS256' })}.${encode({ sub: 'auth0|user_1', exp })}.signature` +} + +function session(expSeconds = 4_000_000_000): CursorAuthReadResult { + return { + status: 'ok', + session: { + token: parseCursorSessionToken(jwt(expSeconds))!, + source: 'keychain', + email: 'dev@example.com', + displayName: 'Dev', + membershipType: null, + subscriptionStatus: null + } + } +} + +type FakeResponse = Pick & { + headers: Pick + json: () => Promise +} + +function fakeResponse(response: FakeResponse): Response { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fetcher only reads ok/status/headers.get/json, all of which FakeResponse provides. + return response as unknown as Response +} + +function jsonResponse(body: unknown, status = 200, headers: Record = {}): Response { + return fakeResponse({ + ok: status >= 200 && status < 300, + status, + headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, + json: async () => body + }) +} + +const SUMMARY = { + billingCycleStart: '2026-09-01T00:00:00.000Z', + billingCycleEnd: '2026-10-01T00:00:00.000Z', + membershipType: 'pro', + individualUsage: { + plan: { enabled: true, used: 2_500, limit: 5_000, autoPercentUsed: 40, apiPercentUsed: 12 } + } +} + +beforeEach(() => { + netFetchMock.mockReset() +}) + +describe('fetchCursorRateLimits', () => { + it('publishes both plan pools and the cycle reset', async () => { + netFetchMock.mockResolvedValueOnce(jsonResponse(SUMMARY)) + const limits = await fetchCursorRateLimits({ authReadResult: session() }) + expect(limits.status).toBe('ok') + expect(limits.monthly?.usedPercent).toBe(50) + expect(limits.buckets?.map((bucket) => bucket.name)).toEqual(['Cursor Models', 'Other Models']) + expect(limits.planType).toBe('pro') + expect(limits.usageMetadata).toMatchObject({ source: 'cli', credentialSource: 'keychain' }) + }) + + it('sends the origin headers the dashboard checks as CSRF defence', async () => { + netFetchMock.mockResolvedValueOnce(jsonResponse(SUMMARY)) + await fetchCursorRateLimits({ authReadResult: session() }) + const [url, init] = netFetchMock.mock.calls[0] ?? [] + expect(url).toBe('https://cursor.com/api/usage-summary') + expect(init?.headers).toMatchObject({ + Origin: 'https://cursor.com', + Referer: 'https://cursor.com/dashboard', + Cookie: expect.stringContaining('WorkosCursorSessionToken=auth0%7Cuser_1%3A%3A') + }) + }) + + it('reports an expired session without spending a request that must 401', async () => { + const limits = await fetchCursorRateLimits({ authReadResult: session(1_000) }) + expect(netFetchMock).not.toHaveBeenCalled() + expect(limits.status).toBe('error') + expect(limits.usageMetadata?.failureKind).toBe('stale-token') + expect(limits.error).toContain('cursor-agent login') + }) + + it('tells a signed-out user how to sign in, without painting an error bar', async () => { + const limits = await fetchCursorRateLimits({ authReadResult: { status: 'missing' } }) + expect(limits.status).toBe('unavailable') + expect(limits.usageMetadata?.failureKind).toBe('missing-credentials') + }) + + it('surfaces a credential read failure', async () => { + const limits = await fetchCursorRateLimits({ + authReadResult: { status: 'error', error: 'Unable to read the Cursor CLI auth file' } + }) + expect(limits.status).toBe('error') + expect(limits.error).toBe('Unable to read the Cursor CLI auth file') + }) + + it('treats a rejected session as expired sign-in and records Retry-After on 429', async () => { + netFetchMock.mockResolvedValueOnce(jsonResponse({}, 401)) + expect( + (await fetchCursorRateLimits({ authReadResult: session() })).usageMetadata?.failureKind + ).toBe('stale-token') + + netFetchMock.mockResolvedValueOnce(jsonResponse({}, 429, { 'retry-after': '60' })) + const limited = await fetchCursorRateLimits({ authReadResult: session() }) + expect(limited.usageMetadata?.failureKind).toBe('rate-limited') + expect(limited.usageMetadata?.retryAtMs).toBeGreaterThan(Date.now()) + }) + + it('treats a redirect to the login page as an expired sign-in', async () => { + // Why: the dashboard bounces an unusable session to /login. With + // redirect:'error' that surfaced as a generic network failure, hiding the + // one message that tells the user what to do. + netFetchMock.mockResolvedValueOnce(jsonResponse({}, 302, { location: '/login' })) + const limits = await fetchCursorRateLimits({ authReadResult: session() }) + expect(limits.usageMetadata?.failureKind).toBe('stale-token') + expect(limits.error).toContain('cursor-agent login') + }) + + it('names the account on failures too, so an account switch can clear stale figures', async () => { + // Why: the service drops a previous account's numbers only when the fresh + // result names an account. A switch whose first refresh fails is exactly when + // that matters, so every failure holding a readable session carries it. + const ok = jsonResponse(SUMMARY) + netFetchMock.mockResolvedValueOnce(ok) + const success = await fetchCursorRateLimits({ authReadResult: session() }) + const fingerprint = success.usageMetadata?.authProvenance + expect(fingerprint).toBeTruthy() + + for (const [status, headers] of [ + [401, {}], + [429, { 'retry-after': '60' }], + [503, {}] + ] as const) { + netFetchMock.mockResolvedValueOnce(jsonResponse({}, status, headers)) + const failure = await fetchCursorRateLimits({ authReadResult: session() }) + expect(failure.status).toBe('error') + expect(failure.usageMetadata?.authProvenance).toBe(fingerprint) + } + + const expired = await fetchCursorRateLimits({ authReadResult: session(1_000) }) + expect(expired.usageMetadata?.authProvenance).toBe(fingerprint) + }) + + it('leaves the account unnamed when no session could be read', async () => { + const missing = await fetchCursorRateLimits({ authReadResult: { status: 'missing' } }) + expect(missing.usageMetadata?.authProvenance).toBeUndefined() + }) + + it('reports a server failure with its status code', async () => { + netFetchMock.mockResolvedValueOnce(jsonResponse({}, 503)) + const limits = await fetchCursorRateLimits({ authReadResult: session() }) + expect(limits.error).toBe('Cursor usage request failed (HTTP 503)') + expect(limits.usageMetadata?.failureKind).toBe('server') + }) + + it('classifies an unparseable body as a parse failure, not as usage', async () => { + netFetchMock.mockResolvedValueOnce( + fakeResponse({ + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => { + throw new Error('invalid json') + } + }) + ) + const limits = await fetchCursorRateLimits({ authReadResult: session() }) + expect(limits.usageMetadata?.failureKind).toBe('parse') + }) + + it('publishes an unlimited plan with no misleading bar', async () => { + netFetchMock.mockResolvedValueOnce(jsonResponse({ isUnlimited: true, membershipType: 'ultra' })) + const limits = await fetchCursorRateLimits({ authReadResult: session() }) + expect(limits.status).toBe('ok') + expect(limits.monthly).toBeUndefined() + expect(limits.planType).toBe('ultra') + }) + + it('falls back to the request-quota endpoint before declaring no allowance', async () => { + netFetchMock + .mockResolvedValueOnce(jsonResponse({ membershipType: 'free' })) + .mockResolvedValueOnce(jsonResponse({ 'gpt-4': { numRequests: 25, maxRequestUsage: 50 } })) + const limits = await fetchCursorRateLimits({ authReadResult: session() }) + expect(limits.status).toBe('ok') + expect(limits.monthly?.usedPercent).toBe(50) + expect(netFetchMock.mock.calls[1]?.[0]).toBe('https://cursor.com/api/usage?user=auth0%7Cuser_1') + }) + + it('hides the bar for an account with no quota rather than alerting forever', async () => { + netFetchMock + .mockResolvedValueOnce(jsonResponse({ membershipType: 'free' })) + .mockResolvedValueOnce(jsonResponse({})) + const limits = await fetchCursorRateLimits({ authReadResult: session() }) + expect(limits.status).toBe('unavailable') + expect(limits.usageMetadata?.failureKind).toBe('usage-unavailable') + }) + + it('reports a network failure without leaking the request internals', async () => { + netFetchMock.mockRejectedValueOnce(new Error('getaddrinfo ENOTFOUND cursor.com')) + const limits = await fetchCursorRateLimits({ authReadResult: session() }) + expect(limits.error).toBe('Cursor usage request failed') + expect(limits.usageMetadata?.failureKind).toBe('network') + }) +}) diff --git a/src/main/rate-limits/cursor-fetcher.ts b/src/main/rate-limits/cursor-fetcher.ts new file mode 100644 index 00000000000..d188127a19a --- /dev/null +++ b/src/main/rate-limits/cursor-fetcher.ts @@ -0,0 +1,251 @@ +import { createHash } from 'node:crypto' +import { net } from 'electron' +import type { ProviderRateLimits, UsageRateLimitSource } from '../../shared/rate-limit-types' +import { + readCursorAuthSession, + type CursorAuthReadResult, + type CursorAuthSession +} from './cursor-auth' +import { cursorSessionCookie, isCursorSessionTokenExpired } from './cursor-session-token' +import { + mapCursorLegacyRequestQuota, + mapCursorUsageSummary, + parseCursorUsageSummary +} from './cursor-usage-mapping' + +const DASHBOARD_ORIGIN = 'https://cursor.com' +const USAGE_SUMMARY_URL = `${DASHBOARD_ORIGIN}/api/usage-summary` +const LEGACY_USAGE_URL = `${DASHBOARD_ORIGIN}/api/usage` +const API_TIMEOUT_MS = 10_000 + +const SIGNED_OUT_MESSAGE = + 'No Cursor sign-in on this computer — sign in with Cursor IDE or `cursor-agent login`' +const EXPIRED_MESSAGE = 'Cursor sign-in expired — run `cursor-agent login` again' + +function usageSource(session: CursorAuthSession): UsageRateLimitSource { + return session.source === 'desktop' ? 'web' : 'cli' +} + +/** + * Stable, non-secret fingerprint of the signed-in account. Hashed because this + * rides `usageMetadata` into the renderer and mobile snapshots, where the raw + * WorkOS subject would be an account identifier nobody needs to see. Readers + * only compare it, so a digest is enough. + */ +function accountFingerprint(session: CursorAuthSession): string { + return createHash('sha256').update(session.token.subject).digest('hex').slice(0, 12) +} + +function result( + status: ProviderRateLimits['status'], + error: string | null, + extra: Partial = {} +): ProviderRateLimits { + return { + provider: 'cursor', + session: null, + weekly: null, + updatedAt: Date.now(), + error, + status, + ...extra + } +} + +/** + * Dashboard routes answer 403 to a bare session cookie — they check the request + * origin as CSRF defence. Sending what the dashboard itself sends is what they + * expect; without these headers every /api route fails on a valid session. + */ +function requestHeaders(session: CursorAuthSession): Record { + return { + Cookie: cursorSessionCookie(session.token), + Accept: 'application/json', + Origin: DASHBOARD_ORIGIN, + Referer: `${DASHBOARD_ORIGIN}/dashboard` + } +} + +type FetchOutcome = { kind: 'data'; data: unknown } | { kind: 'result'; result: ProviderRateLimits } + +async function fetchDashboardJson( + url: string, + session: CursorAuthSession, + signal?: AbortSignal +): Promise { + const source = usageSource(session) + // Why on failures too: the account-switch guard downstream can only drop a + // previous account's figures when the fresh result names an account, and a + // switch whose first refresh 401s or 5xxs is exactly when it must. + const provenance = accountFingerprint(session) + const requestSignal = signal + ? AbortSignal.any([signal, AbortSignal.timeout(API_TIMEOUT_MS)]) + : AbortSignal.timeout(API_TIMEOUT_MS) + const res = await net.fetch(url, { + // Why manual: the dashboard bounces an unusable session to /login, and + // 'error' would surface that as a generic network failure instead of the + // actionable sign-in message below. + redirect: 'manual', + headers: requestHeaders(session), + signal: requestSignal + }) + if (res.status === 401 || res.status === 403 || (res.status >= 300 && res.status < 400)) { + return { + kind: 'result', + result: result('error', EXPIRED_MESSAGE, { + usageMetadata: { + source, + credentialSource: session.source, + authProvenance: provenance, + failureKind: 'stale-token' + } + }) + } + } + if (res.status === 429) { + const retryAfterSeconds = Number(res.headers.get('retry-after')) + return { + kind: 'result', + result: result('error', 'Cursor usage is rate limited right now', { + usageMetadata: { + source, + credentialSource: session.source, + authProvenance: provenance, + failureKind: 'rate-limited', + ...(Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0 + ? { retryAtMs: Date.now() + retryAfterSeconds * 1000 } + : {}) + } + }) + } + } + if (!res.ok) { + return { + kind: 'result', + result: result('error', `Cursor usage request failed (HTTP ${res.status})`, { + usageMetadata: { + source, + credentialSource: session.source, + authProvenance: provenance, + failureKind: 'server' + } + }) + } + } + try { + return { kind: 'data', data: await res.json() } + } catch { + return { + kind: 'result', + result: result('error', 'Cursor usage response could not be parsed', { + usageMetadata: { + source, + credentialSource: session.source, + authProvenance: provenance, + failureKind: 'parse' + } + }) + } + } +} + +function credentialFailure(readResult: CursorAuthReadResult): ProviderRateLimits | null { + if (readResult.status === 'missing') { + return result('unavailable', SIGNED_OUT_MESSAGE, { + usageMetadata: { failureKind: 'missing-credentials' } + }) + } + if (readResult.status === 'error') { + return result('error', readResult.error, { + usageMetadata: { failureKind: 'missing-credentials' } + }) + } + // Why: cursor-agent refreshes its own token on use; Orca only reads, so a + // lapsed session is reported instead of spending a request that must 401. + if (isCursorSessionTokenExpired(readResult.session.token)) { + return result('error', EXPIRED_MESSAGE, { + usageMetadata: { + source: usageSource(readResult.session), + credentialSource: readResult.session.source, + authProvenance: accountFingerprint(readResult.session), + failureKind: 'stale-token' + } + }) + } + return null +} + +/** + * Reads the Cursor plan allowance for the account this machine is signed into. + * Orca never runs `cursor-agent login` and never writes Cursor's credentials. + */ +export async function fetchCursorRateLimits( + options: { signal?: AbortSignal; authReadResult?: CursorAuthReadResult } = {} +): Promise { + const readResult = options.authReadResult ?? (await readCursorAuthSession()) + const failure = credentialFailure(readResult) + if (failure || readResult.status !== 'ok') { + return failure ?? result('unavailable', SIGNED_OUT_MESSAGE) + } + const session = readResult.session + const source = usageSource(session) + const metadata = { + source, + credentialSource: session.source, + authProvenance: accountFingerprint(session) + } + + try { + const outcome = await fetchDashboardJson(USAGE_SUMMARY_URL, session, options.signal) + if (outcome.kind === 'result') { + return outcome.result + } + const mapped = mapCursorUsageSummary(parseCursorUsageSummary(outcome.data)) + + // Why: an unlimited plan has no ceiling to divide by. Publish the plan so the + // roster still lists the account, with no misleading bar. + if (mapped.isUnlimited) { + return result('ok', null, { planType: mapped.planType, usageMetadata: metadata }) + } + if (mapped.monthly || mapped.buckets.length > 0) { + return result('ok', null, { + ...(mapped.monthly ? { monthly: mapped.monthly } : {}), + ...(mapped.buckets.length > 0 ? { buckets: mapped.buckets } : {}), + planType: mapped.planType, + usageMetadata: metadata + }) + } + + // Why: accounts still on request-quota billing report nothing under + // individualUsage, so try the older per-model endpoint before concluding + // the account has no visible quota. + const legacy = await fetchDashboardJson( + `${LEGACY_USAGE_URL}?user=${encodeURIComponent(session.token.subject)}`, + session, + options.signal + ) + if (legacy.kind === 'result') { + return legacy.result + } + const legacyWindow = mapCursorLegacyRequestQuota(legacy.data) + if (legacyWindow) { + return result('ok', null, { + monthly: legacyWindow, + planType: mapped.planType, + usageMetadata: metadata + }) + } + + // Why: a 200 with no allowance means the plan exposes no quota (API-key or + // team-billed accounts). 'unavailable' hides the bar the way Claude does on + // API-key billing; 'error' would paint a permanent alert for a healthy account. + return result('unavailable', 'Cursor reported no usage allowance for this account', { + planType: mapped.planType, + usageMetadata: { ...metadata, failureKind: 'usage-unavailable' } + }) + } catch { + return result('error', 'Cursor usage request failed', { + usageMetadata: { ...metadata, failureKind: 'network' } + }) + } +} diff --git a/src/main/rate-limits/cursor-session-token.test.ts b/src/main/rate-limits/cursor-session-token.test.ts new file mode 100644 index 00000000000..61bb7d8221a --- /dev/null +++ b/src/main/rate-limits/cursor-session-token.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { + cursorSessionCookie, + isCursorSessionTokenExpired, + parseCursorSessionToken +} from './cursor-session-token' + +type JwtSegment = Record + +function jwt(payload: Record): string { + const encode = (value: JwtSegment): string => + Buffer.from(JSON.stringify(value)).toString('base64url') + return `${encode({ alg: 'RS256' })}.${encode(payload)}.signature` +} + +describe('parseCursorSessionToken', () => { + it('reads the WorkOS subject and expiry from a cursor-agent session token', () => { + const token = parseCursorSessionToken( + jwt({ sub: 'auth0|user_01ABC', exp: 1_800_000_000, aud: 'https://cursor.com' }) + ) + expect(token?.subject).toBe('auth0|user_01ABC') + expect(token?.expiresAtMs).toBe(1_800_000_000_000) + }) + + it('rejects a token without a subject, which cannot address the dashboard', () => { + expect(parseCursorSessionToken(jwt({ exp: 1_800_000_000 }))).toBeNull() + }) + + it('rejects blank and malformed tokens instead of throwing', () => { + expect(parseCursorSessionToken(' ')).toBeNull() + expect(parseCursorSessionToken('not-a-jwt')).toBeNull() + expect(parseCursorSessionToken('a.!!!.c')).toBeNull() + }) + + it('leaves expiry null when the token omits exp', () => { + expect(parseCursorSessionToken(jwt({ sub: 'auth0|user_1' }))?.expiresAtMs).toBeNull() + }) +}) + +describe('isCursorSessionTokenExpired', () => { + const token = parseCursorSessionToken(jwt({ sub: 'auth0|user_1', exp: 2_000 })) + + it('reports a lapsed session', () => { + expect(isCursorSessionTokenExpired(token!, 2_000_001)).toBe(true) + }) + + it('reports a live session', () => { + expect(isCursorSessionTokenExpired(token!, 1_999_999)).toBe(false) + }) + + it('never expires a token with no exp claim', () => { + const noExpiry = parseCursorSessionToken(jwt({ sub: 'auth0|user_1' })) + expect(isCursorSessionTokenExpired(noExpiry!, Number.MAX_SAFE_INTEGER)).toBe(false) + }) +}) + +describe('cursorSessionCookie', () => { + it('escapes the subject and joins it to the token the way the dashboard does', () => { + const token = parseCursorSessionToken(jwt({ sub: 'auth0|user_01ABC' })) + expect(cursorSessionCookie(token!)).toBe( + `WorkosCursorSessionToken=auth0%7Cuser_01ABC%3A%3A${token!.raw}` + ) + }) +}) diff --git a/src/main/rate-limits/cursor-session-token.ts b/src/main/rate-limits/cursor-session-token.ts new file mode 100644 index 00000000000..8db148c61ff --- /dev/null +++ b/src/main/rate-limits/cursor-session-token.ts @@ -0,0 +1,57 @@ +import { z } from 'zod' + +const jwtPayloadSchema = z.object({ sub: z.unknown(), exp: z.unknown() }).partial() + +export type CursorSessionToken = { + raw: string + /** WorkOS subject (`auth0|user_…`), the first half of the dashboard session cookie. */ + subject: string + expiresAtMs: number | null +} + +function decodeJwtPayload(token: string): z.infer | null { + const parts = token.split('.') + if (parts.length < 2 || !parts[1]) { + return null + } + try { + const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/') + const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4) + const parsed = jwtPayloadSchema.safeParse( + JSON.parse(Buffer.from(padded, 'base64').toString('utf8')) + ) + return parsed.success ? parsed.data : null + } catch { + return null + } +} + +export function parseCursorSessionToken(raw: string): CursorSessionToken | null { + const trimmed = raw.trim() + if (!trimmed) { + return null + } + const payload = decodeJwtPayload(trimmed) + const subject = payload?.sub + if (typeof subject !== 'string' || subject.trim().length === 0) { + return null + } + const exp = payload?.exp + return { + raw: trimmed, + subject: subject.trim(), + expiresAtMs: typeof exp === 'number' && Number.isFinite(exp) ? exp * 1000 : null + } +} + +export function isCursorSessionTokenExpired( + token: CursorSessionToken, + nowMs: number = Date.now() +): boolean { + return token.expiresAtMs !== null && token.expiresAtMs <= nowMs +} + +/** Cookie the cursor.com dashboard sends: `::`, URL-escaped. */ +export function cursorSessionCookie(token: CursorSessionToken): string { + return `WorkosCursorSessionToken=${encodeURIComponent(token.subject)}%3A%3A${token.raw}` +} diff --git a/src/main/rate-limits/cursor-usage-mapping.test.ts b/src/main/rate-limits/cursor-usage-mapping.test.ts new file mode 100644 index 00000000000..8e1b5192fde --- /dev/null +++ b/src/main/rate-limits/cursor-usage-mapping.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest' +import { + mapCursorLegacyRequestQuota, + mapCursorUsageSummary, + parseCursorUsageSummary +} from './cursor-usage-mapping' + +const CYCLE = { + billingCycleStart: '2026-09-01T00:00:00.000Z', + billingCycleEnd: '2026-10-01T00:00:00.000Z' +} + +describe('mapCursorUsageSummary', () => { + it('maps both plan pools Cursor bills individual accounts from', () => { + const mapped = mapCursorUsageSummary({ + ...CYCLE, + membershipType: 'pro', + individualUsage: { + plan: { enabled: true, used: 1_250, limit: 5_000, autoPercentUsed: 40, apiPercentUsed: 10 } + } + }) + expect(mapped.planType).toBe('pro') + expect(mapped.buckets.map((bucket) => [bucket.name, bucket.usedPercent])).toEqual([ + ['Cursor Models', 40], + ['Other Models', 10] + ]) + expect(mapped.monthly?.usedPercent).toBe(25) + expect(mapped.monthly?.resetsAt).toBe(Date.parse(CYCLE.billingCycleEnd)) + }) + + it('prefers the used/limit pair over the rounded percentage Cursor renders', () => { + const mapped = mapCursorUsageSummary({ + ...CYCLE, + individualUsage: { plan: { enabled: true, used: 1_000, limit: 3_000, totalPercentUsed: 33 } } + }) + expect(mapped.monthly?.usedPercent).toBeCloseTo(33.333, 3) + }) + + it('falls back to the percentage when no cents allowance is reported', () => { + const mapped = mapCursorUsageSummary({ + ...CYCLE, + individualUsage: { plan: { enabled: true, totalPercentUsed: 62 } } + }) + expect(mapped.monthly?.usedPercent).toBe(62) + }) + + it('adds an on-demand bucket only once the user has enabled on-demand spend', () => { + const disabled = mapCursorUsageSummary({ + ...CYCLE, + individualUsage: { + plan: { enabled: true, totalPercentUsed: 5 }, + onDemand: { used: 900, limit: 1_000 } + } + }) + expect(disabled.buckets).toHaveLength(0) + + const enabled = mapCursorUsageSummary({ + ...CYCLE, + individualUsage: { + plan: { enabled: true, totalPercentUsed: 5 }, + onDemand: { enabled: true, used: 900, limit: 1_000 } + } + }) + expect(enabled.buckets).toEqual([ + expect.objectContaining({ name: 'On-demand', usedPercent: 90 }) + ]) + }) + + it('clamps an over-consumed pool to 100% instead of overflowing the bar', () => { + const mapped = mapCursorUsageSummary({ + ...CYCLE, + individualUsage: { plan: { enabled: true, used: 7_000, limit: 5_000 } } + }) + expect(mapped.monthly?.usedPercent).toBe(100) + }) + + it('publishes no pools for a plan the account does not own', () => { + // Why: a team-billed account still reports 0% pools. Publishing them would + // paint a healthy 0% meter and skip the request-quota fallback. + const mapped = mapCursorUsageSummary({ + ...CYCLE, + individualUsage: { + plan: { enabled: false, autoPercentUsed: 0, apiPercentUsed: 0, totalPercentUsed: 0 } + } + }) + expect(mapped.buckets).toHaveLength(0) + expect(mapped.monthly).toBeNull() + }) + + it('reports an unlimited plan without inventing a percentage', () => { + const mapped = mapCursorUsageSummary({ ...CYCLE, isUnlimited: true, membershipType: 'ultra' }) + expect(mapped.isUnlimited).toBe(true) + expect(mapped.monthly).toBeNull() + expect(mapped.buckets).toHaveLength(0) + }) + + it('accepts epoch seconds and milliseconds for the billing cycle', () => { + const seconds = mapCursorUsageSummary({ + billingCycleEnd: 1_790_000_000, + individualUsage: { plan: { enabled: true, totalPercentUsed: 1 } } + }) + const millis = mapCursorUsageSummary({ + billingCycleEnd: 1_790_000_000_000, + individualUsage: { plan: { enabled: true, totalPercentUsed: 1 } } + }) + expect(seconds.monthly?.resetsAt).toBe(1_790_000_000_000) + expect(millis.monthly?.resetsAt).toBe(1_790_000_000_000) + }) + + it('keeps the plan when a sibling pool arrives as null', () => { + // Why: the route sends `null` for an absent sub-object, and a stricter schema + // would drop the whole body — plan pools and billing cycle included. + const mapped = mapCursorUsageSummary( + parseCursorUsageSummary({ + ...CYCLE, + membershipType: 'pro', + individualUsage: { plan: { enabled: true, totalPercentUsed: 40 }, onDemand: null } + }) + ) + expect(mapped.monthly?.usedPercent).toBe(40) + expect(mapped.planType).toBe('pro') + }) + + it('survives a null individualUsage block', () => { + const mapped = mapCursorUsageSummary( + parseCursorUsageSummary({ ...CYCLE, membershipType: 'free', individualUsage: null }) + ) + expect(mapped.planType).toBe('free') + expect(mapped.monthly).toBeNull() + }) + + it('returns nothing to publish for an empty payload', () => { + const mapped = mapCursorUsageSummary({}) + expect(mapped.monthly).toBeNull() + expect(mapped.buckets).toHaveLength(0) + expect(mapped.planType).toBeNull() + }) + + it('derives the window from the reported cycle rather than assuming 30 days', () => { + const mapped = mapCursorUsageSummary({ + billingCycleStart: '2026-09-01T00:00:00.000Z', + billingCycleEnd: '2026-09-08T00:00:00.000Z', + individualUsage: { plan: { enabled: true, totalPercentUsed: 10 } } + }) + expect(mapped.monthly?.windowMinutes).toBe(7 * 24 * 60) + }) +}) + +describe('mapCursorLegacyRequestQuota', () => { + it('uses the premium gpt-4 bucket on request-quota plans', () => { + const window = mapCursorLegacyRequestQuota({ + 'gpt-4': { numRequests: 250, maxRequestUsage: 500 }, + 'gpt-3.5-turbo': { numRequests: 10, maxRequestUsage: 9_999 }, + startOfMonth: '2026-09-01T00:00:00.000Z' + }) + expect(window?.usedPercent).toBe(50) + expect(window?.resetsAt).toBe(new Date('2026-10-01T00:00:00.000Z').getTime()) + }) + + it('falls back to the largest ceiling when no gpt-4 bucket is present', () => { + const window = mapCursorLegacyRequestQuota({ + small: { numRequests: 1, maxRequestUsage: 10 }, + large: { numRequests: 20, maxRequestUsage: 100 } + }) + expect(window?.usedPercent).toBe(20) + }) + + it('clamps a month-end cycle start instead of overflowing into the next month', () => { + const window = mapCursorLegacyRequestQuota({ + 'gpt-4': { numRequests: 1, maxRequestUsage: 10 }, + startOfMonth: '2026-01-31T00:00:00.000Z' + }) + expect(window?.resetsAt).toBe(Date.parse('2026-02-28T00:00:00.000Z')) + }) + + it('ignores unmetered buckets and returns null when nothing is metered', () => { + expect( + mapCursorLegacyRequestQuota({ free: { numRequests: 3, maxRequestUsage: null } }) + ).toBeNull() + expect(mapCursorLegacyRequestQuota(null)).toBeNull() + }) +}) diff --git a/src/main/rate-limits/cursor-usage-mapping.ts b/src/main/rate-limits/cursor-usage-mapping.ts new file mode 100644 index 00000000000..66a29d46469 --- /dev/null +++ b/src/main/rate-limits/cursor-usage-mapping.ts @@ -0,0 +1,234 @@ +import { z } from 'zod' +import { + CURSOR_MODELS_BUCKET_NAME, + CURSOR_ON_DEMAND_BUCKET_NAME, + CURSOR_OTHER_MODELS_BUCKET_NAME +} from '../../shared/cursor-usage-buckets' +import type { RateLimitBucket, RateLimitWindow } from '../../shared/rate-limit-types' + +const MONTHLY_WINDOW_MINUTES = 43_200 + +const cursorPoolSchema = z + .object({ + enabled: z.unknown(), + used: z.unknown(), + limit: z.unknown(), + totalPercentUsed: z.unknown(), + autoPercentUsed: z.unknown(), + apiPercentUsed: z.unknown() + }) + .partial() + +const cursorUsageSummarySchema = z + .object({ + billingCycleStart: z.unknown(), + billingCycleEnd: z.unknown(), + membershipType: z.unknown(), + isUnlimited: z.unknown(), + // Why nullish and not optional: this route is undocumented and sends `null` + // for an absent sub-object. With `.optional()` one null pool fails the parse + // for the whole body, discarding valid pools and the billing cycle with it. + individualUsage: z + .object({ plan: cursorPoolSchema.nullish(), onDemand: cursorPoolSchema.nullish() }) + .partial() + .nullish() + }) + .partial() + +type CursorPool = z.infer +export type CursorUsageSummary = z.infer + +/** Accepts the raw dashboard body; an unrecognised shape maps to "nothing reported". */ +export function parseCursorUsageSummary(data: unknown): CursorUsageSummary { + const parsed = cursorUsageSummarySchema.safeParse(data) + return parsed.success ? parsed.data : {} +} + +export type CursorUsageMapping = { + monthly: RateLimitWindow | null + buckets: RateLimitBucket[] + planType: string | null + isUnlimited: boolean +} + +function finiteNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + if (typeof value !== 'string' || !value.trim()) { + return null + } + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : null +} + +export function parseCursorTimestampMs(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + // Why: Cursor mixes epoch seconds and milliseconds across billing fields. + return value > 1e12 ? value : value * 1000 + } + if (typeof value !== 'string' || !value.trim()) { + return null + } + const trimmed = value.trim() + if (/^\d+$/.test(trimmed)) { + return parseCursorTimestampMs(Number(trimmed)) + } + const parsed = Date.parse(trimmed) + return Number.isFinite(parsed) ? parsed : null +} + +function clampPercent(value: number): number { + return Math.min(100, Math.max(0, value)) +} + +function resetDescription(resetsAtMs: number | null): string | null { + if (resetsAtMs === null) { + return null + } + const date = new Date(resetsAtMs) + if (Number.isNaN(date.getTime())) { + return null + } + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) +} + +function windowMinutesFor(startMs: number | null, endMs: number | null): number { + if (startMs === null || endMs === null || endMs <= startMs) { + return MONTHLY_WINDOW_MINUTES + } + return Math.max(1, Math.round((endMs - startMs) / 60_000)) +} + +/** + * Percent consumed for one pool. `used / limit` wins over the sibling percentage + * fields: the raw pair is internally consistent, while the percentages are + * pre-rounded for the dashboard's own copy and disagree with it on real accounts. + */ +function poolPercent( + pool: CursorPool | null | undefined, + percentField: keyof CursorPool +): number | null { + if (!pool) { + return null + } + const used = finiteNumber(pool.used) + const limit = finiteNumber(pool.limit) + if (used !== null && limit !== null && limit > 0) { + return (used / limit) * 100 + } + return finiteNumber(pool[percentField]) +} + +export function mapCursorUsageSummary(summary: CursorUsageSummary): CursorUsageMapping { + const startMs = parseCursorTimestampMs(summary.billingCycleStart) + const endMs = parseCursorTimestampMs(summary.billingCycleEnd) + const windowMinutes = windowMinutesFor(startMs, endMs) + const description = resetDescription(endMs) + const toWindow = (percent: number): RateLimitWindow => ({ + usedPercent: clampPercent(percent), + windowMinutes, + resetsAt: endMs, + resetDescription: description + }) + + const plan = summary.individualUsage?.plan + // Why: a team-billed account still reports 0% pools it does not own. Publishing + // them as buckets would paint a healthy 0% meter and skip the legacy fallback. + const planEnabled = plan?.enabled !== false + const buckets: RateLimitBucket[] = [] + const cursorModels = planEnabled ? finiteNumber(plan?.autoPercentUsed) : null + if (cursorModels !== null) { + buckets.push({ name: CURSOR_MODELS_BUCKET_NAME, ...toWindow(cursorModels) }) + } + const otherModels = planEnabled ? finiteNumber(plan?.apiPercentUsed) : null + if (otherModels !== null) { + buckets.push({ name: CURSOR_OTHER_MODELS_BUCKET_NAME, ...toWindow(otherModels) }) + } + + const onDemand = summary.individualUsage?.onDemand + const onDemandPercent = + onDemand?.enabled === true ? poolPercent(onDemand, 'totalPercentUsed') : null + if (onDemandPercent !== null) { + buckets.push({ name: CURSOR_ON_DEMAND_BUCKET_NAME, ...toWindow(onDemandPercent) }) + } + + const planPercent = planEnabled ? poolPercent(plan, 'totalPercentUsed') : null + const membership = summary.membershipType + return { + monthly: planPercent === null ? null : toWindow(planPercent), + buckets, + planType: typeof membership === 'string' && membership.trim() ? membership.trim() : null, + isUnlimited: summary.isUnlimited === true + } +} + +/** + * Advances a cycle start by one month in UTC, clamping the day so a Jan 31 start + * lands on Feb 28/29 instead of overflowing into March. + */ +function addOneMonthUtc(startMs: number): number { + const start = new Date(startMs) + const year = start.getUTCFullYear() + const month = start.getUTCMonth() + const lastDayOfNextMonth = new Date(Date.UTC(year, month + 2, 0)).getUTCDate() + return Date.UTC( + year, + month + 1, + Math.min(start.getUTCDate(), lastDayOfNextMonth), + start.getUTCHours(), + start.getUTCMinutes(), + start.getUTCSeconds(), + start.getUTCMilliseconds() + ) +} + +const legacyBucketSchema = z + .object({ numRequests: z.unknown(), maxRequestUsage: z.unknown() }) + .partial() + +const legacyQuotaSchema = z.record(z.string(), z.union([legacyBucketSchema, z.unknown()])) + +/** + * Request-quota plans predate spend-based billing and report a per-model ceiling + * instead of a cents allowance, so those accounts still get a bar. + */ +export function mapCursorLegacyRequestQuota(payload: unknown): RateLimitWindow | null { + const parsed = legacyQuotaSchema.safeParse(payload) + if (!parsed.success) { + return null + } + const quotas: { name: string; used: number; limit: number }[] = [] + for (const [name, value] of Object.entries(parsed.data)) { + const entry = legacyBucketSchema.safeParse(value) + if (!entry.success) { + continue + } + const limit = finiteNumber(entry.data.maxRequestUsage) + const used = finiteNumber(entry.data.numRequests) + if (limit === null || limit <= 0 || used === null) { + continue + } + quotas.push({ name, used, limit }) + } + if (quotas.length === 0) { + return null + } + // Why: 'gpt-4' is the premium bucket on legacy plans; otherwise the largest + // ceiling is the headline quota on every shape seen so far. + const quota = + quotas.find((entry) => entry.name === 'gpt-4') ?? + quotas.sort((left, right) => right.limit - left.limit)[0] + if (!quota) { + return null + } + + const startOfMonth = parseCursorTimestampMs(parsed.data.startOfMonth) + const resetsAt = startOfMonth === null ? null : addOneMonthUtc(startOfMonth) + return { + usedPercent: clampPercent((quota.used / quota.limit) * 100), + windowMinutes: MONTHLY_WINDOW_MINUTES, + resetsAt, + resetDescription: resetDescription(resetsAt) + } +} diff --git a/src/main/rate-limits/grok-fetcher.ts b/src/main/rate-limits/grok-fetcher.ts index 33c4fac9aec..b776613a335 100644 --- a/src/main/rate-limits/grok-fetcher.ts +++ b/src/main/rate-limits/grok-fetcher.ts @@ -109,12 +109,11 @@ function usageScalars(config: GrokBillingConfig): (GrokMoneyVal | undefined)[] { ] } -// Why: proto3 JSON drops default zeros, so an omitted percent can mean zero — -// but only an explicitly-emitted zero proves this encoder keeps them. #15740 -// ships `onDemandUsed: {val: 0}`, so there the omission means "not reported" -// and must never render as 0%. Non-zero money fields prove nothing either way, -// so #9214/#9219 accounts that carry only those keep their genuine 0%. -function emitsExplicitZeroScalar(config: GrokBillingConfig): boolean { +function omittedPercentIsUnreported(config: GrokBillingConfig): boolean { + // A strict zero cap rejects numeric prefixes like "0invalid" accepted by parseMoneyVal. + if (parseMoneyVal(config.onDemandCap) === 0 && Number(config.onDemandCap?.val) === 0) { + return [config.onDemandUsed, config.used].some((value) => (parseMoneyVal(value) ?? 0) > 0) + } return usageScalars(config).some((value) => parseMoneyVal(value) === 0) } @@ -130,10 +129,8 @@ function resolveWeeklyPercent(config: GrokBillingConfig): number | null { if (reported !== undefined) { return null } - // Why: infer the dropped zero only when nothing else in the payload speaks - // for consumption — an explicit zero proves the encoder keeps defaults, and a - // computable budget pair is a real monthly number this must not shadow. - if (emitsExplicitZeroScalar(config) || mapMonthlyUsage(config) !== null) { + // A computable monthly budget must not be relabelled as weekly zero usage. + if (omittedPercentIsUnreported(config) || mapMonthlyUsage(config) !== null) { return null } return hasConfirmedWeeklyPeriod(config) ? 0 : null diff --git a/src/main/rate-limits/grok-weekly-zero.test.ts b/src/main/rate-limits/grok-weekly-zero.test.ts new file mode 100644 index 00000000000..f8c8b098ebf --- /dev/null +++ b/src/main/rate-limits/grok-weekly-zero.test.ts @@ -0,0 +1,225 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const netFetchMock = vi.hoisted(() => vi.fn()) +vi.mock('electron', () => ({ net: { fetch: netFetchMock } })) +vi.mock('./grok-auth', () => ({ + readGrokAuthSession: () => { + throw new Error('Tests must supply synthetic auth') + }, + isGrokAccessTokenFresh: () => true +})) + +import { fetchGrokRateLimits } from './grok-fetcher' + +const START = '2026-09-13T16:33:14.392197+00:00' +const END = '2026-09-20T16:33:14.392197+00:00' +const PERIOD = { type: 'USAGE_PERIOD_TYPE_WEEKLY', start: START, end: END } +const CONFIRMED_PERIOD = { + currentPeriod: PERIOD, + billingPeriodStart: START, + billingPeriodEnd: END +} +// Reporter deminit02-hue's #20657 payload; authentication is synthetic. +const REPORTER_CONFIG = { + ...CONFIRMED_PERIOD, + onDemandCap: { val: 0 }, + onDemandUsed: { val: 0 }, + prepaidBalance: { val: 0 }, + isUnifiedBillingUser: true +} +const AUTH = { + status: 'ok', + session: { + accessToken: 'fixture-only', + userId: 'fixture-user', + email: 'grok@example.invalid', + teamId: null, + oidcClientId: null, + expiresAtMs: Date.parse('2099-01-01T00:00:00Z') + } +} as const + +function jsonResponse(body: unknown, status = 200) { + return { ok: status >= 200 && status < 300, status, json: async () => body } +} + +async function fetchConfig(config: Record, flat = false) { + netFetchMock + .mockResolvedValueOnce(jsonResponse(flat ? config : { config })) + .mockResolvedValueOnce(jsonResponse({ config: {} })) + return fetchGrokRateLimits({ authReadResult: AUTH }) +} + +describe('Grok weekly zero evidence', () => { + beforeEach(() => netFetchMock.mockReset()) + + it.each([false, true])('maps the reporter payload with flat=%s', async (flat) => { + const result = await fetchConfig(REPORTER_CONFIG, flat) + expect(result.status).toBe('ok') + expect(result.weekly).toMatchObject({ + usedPercent: 0, + windowMinutes: 10_080, + resetsAt: Date.parse(END) + }) + expect(result.monthly).toBeUndefined() + expect(netFetchMock).toHaveBeenCalledTimes(1) + }) + + it.each([0, '0', '0.00'])('accepts money zero encoded as %s', async (val) => { + const result = await fetchConfig({ + ...REPORTER_CONFIG, + onDemandCap: { val }, + onDemandUsed: { val }, + prepaidBalance: { val } + }) + expect(result.weekly?.usedPercent).toBe(0) + expect(netFetchMock).toHaveBeenCalledTimes(1) + }) + + it.each([ + { name: 'absent', cap: undefined }, + { name: 'empty', cap: {} }, + { name: 'malformed', cap: { val: 'unknown' } }, + { name: 'zero prefix', cap: { val: '0invalid' } }, + { name: 'negative', cap: { val: -1 } }, + { name: 'nonfinite', cap: { val: Infinity } }, + { name: 'positive', cap: { val: 250 } } + ])('keeps $name cap with explicit money zeros unknown', async ({ cap }) => { + const result = await fetchConfig({ ...REPORTER_CONFIG, onDemandCap: cap }) + expect(result.status).toBe('unavailable') + expect(result.weekly).toBeNull() + expect(netFetchMock).toHaveBeenCalledTimes(2) + }) + + it.each(['onDemandUsed', 'used'])('does not infer zero beside positive %s', async (field) => { + const result = await fetchConfig({ ...REPORTER_CONFIG, [field]: { val: '37.5' } }) + expect(result.status).toBe('unavailable') + expect(result.weekly).toBeNull() + expect(netFetchMock).toHaveBeenCalledTimes(2) + }) + + it.each([ + { name: 'absent cap and no money', config: CONFIRMED_PERIOD }, + { name: 'prepaid only', config: { ...CONFIRMED_PERIOD, prepaidBalance: { val: 25 } } }, + { name: 'zero cap and prepaid', config: { ...REPORTER_CONFIG, prepaidBalance: { val: 25 } } } + ])('preserves $name zero inference', async ({ config }) => { + const result = await fetchConfig(config) + expect(result.weekly?.usedPercent).toBe(0) + expect(netFetchMock).toHaveBeenCalledTimes(1) + }) + + it.each([0, 23, -10, 150])( + 'honors finite explicit percent %s before money evidence', + async (percent) => { + const result = await fetchConfig({ + ...REPORTER_CONFIG, + creditUsagePercent: percent, + onDemandUsed: { val: 37.5 }, + monthlyLimit: { val: 100 }, + used: { val: 25 }, + billingPeriodStart: undefined + }) + expect(result.weekly?.usedPercent).toBe(Math.min(100, Math.max(0, percent))) + expect(result.monthly).toBeUndefined() + expect(netFetchMock).toHaveBeenCalledTimes(1) + } + ) + + it.each([null, '0', 'invalid', Number.NaN, Infinity, -Infinity])( + 'does not infer an invalid explicit percent %s', + async (percent) => { + const result = await fetchConfig({ ...REPORTER_CONFIG, creditUsagePercent: percent }) + expect(result.status).toBe('unavailable') + expect(result.weekly).toBeNull() + expect(netFetchMock).toHaveBeenCalledTimes(2) + } + ) + + it.each([ + { billingPeriodStart: undefined }, + { billingPeriodEnd: undefined }, + { billingPeriodStart: 'invalid' }, + { billingPeriodEnd: 'invalid' }, + { billingPeriodStart: END }, + { billingPeriodEnd: START }, + { currentPeriod: undefined }, + { currentPeriod: { ...PERIOD, type: 'USAGE_PERIOD_TYPE_MONTHLY' } }, + { currentPeriod: { ...PERIOD, start: 'invalid' } }, + { currentPeriod: { ...PERIOD, end: 'invalid' } } + ])('requires confirmed weekly bounds: %o', async (period) => { + const result = await fetchConfig({ ...REPORTER_CONFIG, ...period }) + expect(result.status).toBe('unavailable') + expect(result.weekly).toBeNull() + expect(netFetchMock).toHaveBeenCalledTimes(2) + }) + + it.each([0, 25])('keeps a computable monthly pair with used=%s monthly', async (used) => { + const result = await fetchConfig({ + ...REPORTER_CONFIG, + monthlyLimit: { val: '100' }, + used: { val: used } + }) + expect(result.status).toBe('ok') + expect(result.weekly).toBeNull() + expect(result.monthly).toMatchObject({ usedPercent: used, windowMinutes: 43_200 }) + expect(netFetchMock).toHaveBeenCalledTimes(1) + }) + + it.each([0, '0', -1, 'invalid', null, Infinity, Number.NaN])( + 'rejects invalid monthly denominator %s', + async (val) => { + netFetchMock + .mockResolvedValueOnce(jsonResponse({ config: { subscriptionTier: 'Fixture' } })) + .mockResolvedValueOnce( + jsonResponse({ config: { monthlyLimit: { val }, used: { val: 12 } } }) + ) + const result = await fetchGrokRateLimits({ authReadResult: AUTH }) + expect(result.status).toBe('unavailable') + expect(result.weekly).toBeNull() + expect(result.monthly).toBeUndefined() + expect(netFetchMock).toHaveBeenCalledTimes(2) + } + ) + + it.each([false, true])('preserves monthly fallback with flat=%s', async (flat) => { + const config = { monthlyLimit: { val: 200 }, used: { val: 50 }, billingPeriodEnd: END } + netFetchMock + .mockResolvedValueOnce( + jsonResponse({ config: { ...REPORTER_CONFIG, onDemandCap: { val: 250 } } }) + ) + .mockResolvedValueOnce(jsonResponse(flat ? config : { config })) + const result = await fetchGrokRateLimits({ authReadResult: AUTH }) + expect(result.status).toBe('ok') + expect(result.weekly).toBeNull() + expect(result.monthly).toMatchObject({ usedPercent: 25, resetsAt: Date.parse(END) }) + expect(netFetchMock).toHaveBeenCalledTimes(2) + }) + + it.each([401, 403, 500])('preserves fallback HTTP %s errors', async (status) => { + netFetchMock + .mockResolvedValueOnce(jsonResponse({ config: { subscriptionTier: 'Fixture' } })) + .mockResolvedValueOnce(jsonResponse({}, status)) + const result = await fetchGrokRateLimits({ authReadResult: AUTH }) + expect(result.status).toBe('error') + expect(result.error).toContain(`HTTP ${status}`) + expect(netFetchMock).toHaveBeenCalledTimes(2) + }) + + it('aborts the default billing fallback with the caller signal', async () => { + const controller = new AbortController() + let signal: AbortSignal | null | undefined + netFetchMock + .mockResolvedValueOnce(jsonResponse({ config: { subscriptionTier: 'Fixture' } })) + .mockImplementationOnce((_url, init: RequestInit) => { + signal = init.signal + return new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true }) + }) + }) + const pending = fetchGrokRateLimits({ authReadResult: AUTH, signal: controller.signal }) + await vi.waitFor(() => expect(netFetchMock).toHaveBeenCalledTimes(2)) + controller.abort() + expect(signal?.aborted).toBe(true) + expect(await pending).toMatchObject({ status: 'error', error: 'aborted' }) + }) +}) diff --git a/src/main/rate-limits/minimax/minimax-fetcher.test.ts b/src/main/rate-limits/minimax/minimax-fetcher.test.ts index d587c6b9256..d03c6a7c589 100644 --- a/src/main/rate-limits/minimax/minimax-fetcher.test.ts +++ b/src/main/rate-limits/minimax/minimax-fetcher.test.ts @@ -19,12 +19,7 @@ vi.mock('electron', () => ({ session: { fromPartition: sessionFromPartitionMock } })) -import { - extractMiniMaxCookieValue, - fetchMiniMaxRateLimits, - normalizeMiniMaxCookieHeader, - redactMiniMaxSecret -} from './minimax-fetcher' +import { fetchMiniMaxRateLimits } from './minimax-fetcher' const MINIMAX_URL = 'https://platform.minimax.io/v1/api/openplatform/coding_plan/remains' @@ -646,54 +641,3 @@ describe('fetchMiniMaxRateLimits', () => { }) }) }) - -describe('normalizeMiniMaxCookieHeader', () => { - it('preserves all cookie pairs from a browser Cookie header', () => { - const normalized = normalizeMiniMaxCookieHeader( - '_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz' - ) - expect(normalized).toBe( - '_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz' - ) - }) - - it('normalizes quoted MiniMax cookie storage syntax', () => { - expect(normalizeMiniMaxCookieHeader('_token:"tok" minimax_group_id_v2:"42"')).toBe( - '_token=tok; minimax_group_id_v2=42' - ) - }) - - it('accepts a copied Cookie header line', () => { - expect(normalizeMiniMaxCookieHeader('Cookie: session=abc; other=xyz')).toBe( - 'session=abc; other=xyz' - ) - }) -}) - -describe('extractMiniMaxCookieValue', () => { - it('returns the value for a given cookie name', () => { - expect(extractMiniMaxCookieValue(FULL_COOKIE, 'minimax_group_id_v2')).toBe('12345') - }) - it('returns null when the name is absent', () => { - expect(extractMiniMaxCookieValue('_token=tok', 'minimax_group_id_v2')).toBeNull() - }) -}) - -describe('redactMiniMaxSecret', () => { - it('redacts _token values', () => { - expect(redactMiniMaxSecret('cookie _token=eyJhABCDEF')).toContain('_token=[REDACTED]') - expect(redactMiniMaxSecret('cookie _token=eyJhABCDEF')).not.toContain('eyJhABCDEF') - }) - it('redacts minimax_group_id_v2 values', () => { - expect(redactMiniMaxSecret('minimax_group_id_v2=99999 trailing')).not.toContain('99999') - }) - it('redacts MiniMax anti-bot cookie values', () => { - const redacted = redactMiniMaxSecret('ak_bmsc=secret bm_sv:"secret2"') - expect(redacted).not.toContain('secret') - expect(redacted).toContain('ak_bmsc=[REDACTED]') - expect(redacted).toContain('bm_sv:[REDACTED]') - }) - it('redacts Cookie: header lines', () => { - expect(redactMiniMaxSecret('X-Cookie: _token=secret')).toContain('[REDACTED]') - }) -}) diff --git a/src/main/rate-limits/rate-limit-service-test-harness.ts b/src/main/rate-limits/rate-limit-service-test-harness.ts index 25f7baeb78d..f51839d108b 100644 --- a/src/main/rate-limits/rate-limit-service-test-harness.ts +++ b/src/main/rate-limits/rate-limit-service-test-harness.ts @@ -8,7 +8,10 @@ import { fetchKimiRateLimits } from './kimi-fetcher' import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher' import { fetchGrokRateLimits } from './grok-fetcher' import { readGrokAuthSession } from './grok-auth' +import { fetchCursorRateLimits } from './cursor-fetcher' +import { readCursorAuthSession } from './cursor-auth' import { fetchOpenCodeGoUsage } from './opencode-go-usage-source-selection' +import { fetchZcodeRateLimits } from './zcode-usage-fetcher' import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' export type Deferred = { @@ -89,6 +92,8 @@ export function mockFreshBackgroundProviderFetches(): void { vi.mocked(fetchKimiRateLimits).mockImplementation(async () => okProvider('kimi', 0)) vi.mocked(fetchMiniMaxRateLimits).mockImplementation(async () => okProvider('minimax', 0)) vi.mocked(fetchGrokRateLimits).mockImplementation(async () => unavailableProvider('grok')) + vi.mocked(fetchCursorRateLimits).mockImplementation(async () => unavailableProvider('cursor')) + vi.mocked(fetchZcodeRateLimits).mockImplementation(async () => unavailableProvider('zcode')) } /** Shared `beforeEach` body: healthy stubs for every provider the service polls. */ @@ -106,8 +111,11 @@ export function resetRateLimitProviderMocks(): void { error: null, status: 'unavailable' }) + vi.mocked(fetchCursorRateLimits).mockResolvedValue(unavailableProvider('cursor')) + vi.mocked(fetchZcodeRateLimits).mockResolvedValue(unavailableProvider('zcode')) vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(false) vi.mocked(readGrokAuthSession).mockReturnValue({ status: 'missing' }) + vi.mocked(readCursorAuthSession).mockResolvedValue({ status: 'missing' }) } type RateLimitWindow = Parameters[0] diff --git a/src/main/rate-limits/service-account-target-selection.test.ts b/src/main/rate-limits/service-account-target-selection.test.ts index 16373024614..bb5de4b6485 100644 --- a/src/main/rate-limits/service-account-target-selection.test.ts +++ b/src/main/rate-limits/service-account-target-selection.test.ts @@ -32,6 +32,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./zcode-usage-fetcher', () => ({ + fetchZcodeRateLimits: vi.fn() +})) + vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) @@ -40,6 +44,14 @@ vi.mock('./grok-fetcher', () => ({ fetchGrokRateLimits: vi.fn() })) +vi.mock('./cursor-fetcher', () => ({ + fetchCursorRateLimits: vi.fn() +})) + +vi.mock('./cursor-auth', () => ({ + readCursorAuthSession: vi.fn() +})) + vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) diff --git a/src/main/rate-limits/service-antigravity-usage.test.ts b/src/main/rate-limits/service-antigravity-usage.test.ts index 9f5afe9423d..3696f434dec 100644 --- a/src/main/rate-limits/service-antigravity-usage.test.ts +++ b/src/main/rate-limits/service-antigravity-usage.test.ts @@ -39,6 +39,16 @@ vi.mock('./grok-fetcher', () => ({ fetchGrokRateLimits: vi.fn() })) +vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() })) + +vi.mock('./cursor-fetcher', () => ({ + fetchCursorRateLimits: vi.fn() +})) + +vi.mock('./cursor-auth', () => ({ + readCursorAuthSession: vi.fn() +})) + vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) diff --git a/src/main/rate-limits/service-cursor-usage.test.ts b/src/main/rate-limits/service-cursor-usage.test.ts new file mode 100644 index 00000000000..3646f9f8586 --- /dev/null +++ b/src/main/rate-limits/service-cursor-usage.test.ts @@ -0,0 +1,165 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { RateLimitService } from './service' +import { fetchCursorRateLimits } from './cursor-fetcher' +import { readCursorAuthSession } from './cursor-auth' +import { parseCursorSessionToken } from './cursor-session-token' +import { fetchClaudeRateLimits } from './claude-fetcher' +import { fetchCodexRateLimits } from './codex-fetcher' +import { okProvider, resetRateLimitProviderMocks } from './rate-limit-service-test-harness' + +vi.mock('./claude-fetcher', () => ({ + fetchClaudeRateLimits: vi.fn(), + fetchManagedAccountUsage: vi.fn() +})) + +vi.mock('./codex-fetcher', () => ({ + consumeCodexRateLimitResetCredit: vi.fn(), + fetchCodexRateLimits: vi.fn() +})) + +vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) +vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) +vi.mock('./grok-fetcher', () => ({ fetchGrokRateLimits: vi.fn() })) +vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) +vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() })) + +vi.mock('./cursor-fetcher', () => ({ fetchCursorRateLimits: vi.fn() })) +vi.mock('./cursor-auth', () => ({ readCursorAuthSession: vi.fn() })) +vi.mock('../minimax/minimax-cookie-store', () => ({ hasMiniMaxSessionCookie: vi.fn(() => false) })) + +type JwtSegment = Record + +function jwt(): string { + const encode = (value: JwtSegment): string => + Buffer.from(JSON.stringify(value)).toString('base64url') + return `${encode({ alg: 'RS256' })}.${encode({ sub: 'auth0|user_1', exp: 4_000_000_000 })}.sig` +} + +function signedInResult(): Awaited> { + return { + status: 'ok', + session: { + token: parseCursorSessionToken(jwt())!, + source: 'keychain', + email: 'dev@example.com', + displayName: 'Dev', + membershipType: 'pro', + subscriptionStatus: 'active' + } + } +} + +describe('RateLimitService Cursor usage', () => { + beforeEach(() => { + resetRateLimitProviderMocks() + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 0)) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 0)) + }) + + it('does not probe the Cursor keychain while callers read state snapshots', () => { + const service = new RateLimitService() + service.getState() + service.getState() + expect(readCursorAuthSession).not.toHaveBeenCalled() + }) + + it('reports Cursor as unconfigured until a fetch cycle finds a session', async () => { + const service = new RateLimitService() + expect(service.getState().cursorAuthConfigured).toBe(false) + + vi.mocked(readCursorAuthSession).mockResolvedValue(signedInResult()) + vi.mocked(fetchCursorRateLimits).mockResolvedValue(okProvider('cursor', 42)) + await service.refresh() + + expect(service.getState().cursorAuthConfigured).toBe(true) + expect(service.getState().cursor?.status).toBe('ok') + }) + + it('passes the resolved session to the fetcher instead of reading it twice', async () => { + const authReadResult = signedInResult() + vi.mocked(readCursorAuthSession).mockResolvedValue(authReadResult) + vi.mocked(fetchCursorRateLimits).mockResolvedValue(okProvider('cursor', 7)) + + await new RateLimitService().refresh() + + expect(readCursorAuthSession).toHaveBeenCalledTimes(1) + expect(fetchCursorRateLimits).toHaveBeenCalledWith(expect.objectContaining({ authReadResult })) + }) + + it('publishes an error snapshot instead of dropping the cycle when the fetch throws', async () => { + vi.mocked(readCursorAuthSession).mockResolvedValue({ status: 'missing' }) + vi.mocked(fetchCursorRateLimits).mockRejectedValue(new Error('boom')) + + const service = new RateLimitService() + await service.refresh() + + const state = service.getState() + expect(state.cursor).toMatchObject({ provider: 'cursor', status: 'error', error: 'boom' }) + // Why: a Cursor failure must not take the rest of the cycle down with it. + expect(state.gemini?.status).toBe('ok') + }) + + it("drops a previous account's usage when the signed-in account changes", async () => { + // Why: the stale policy keeps a recent snapshot through a failed refresh. Across + // an account switch that would name the new account beside the old one's figures. + vi.mocked(readCursorAuthSession).mockResolvedValue(signedInResult()) + vi.mocked(fetchCursorRateLimits).mockResolvedValue({ + ...okProvider('cursor', 80), + usageMetadata: { source: 'cli', authProvenance: 'account-a' } + }) + const service = new RateLimitService() + await service.refresh() + expect(service.getState().cursor?.session?.usedPercent).toBe(80) + + vi.mocked(fetchCursorRateLimits).mockResolvedValue({ + provider: 'cursor', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Cursor sign-in expired — run `cursor-agent login` again', + status: 'error', + // Why this exact shape: it is what cursor-fetcher returns for a readable but + // rejected session, which is the failure an account switch actually hits. + usageMetadata: { source: 'cli', failureKind: 'stale-token', authProvenance: 'account-b' } + }) + await service.refresh() + expect(service.getState().cursor?.session).toBeNull() + expect(service.getState().cursor?.status).toBe('error') + }) + + it('keeps the last reading when a refresh fails without naming an account', async () => { + vi.mocked(readCursorAuthSession).mockResolvedValue(signedInResult()) + vi.mocked(fetchCursorRateLimits).mockResolvedValue({ + ...okProvider('cursor', 60), + usageMetadata: { source: 'cli', authProvenance: 'account-a' } + }) + const service = new RateLimitService() + await service.refresh() + + vi.mocked(fetchCursorRateLimits).mockResolvedValue({ + provider: 'cursor', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Cursor usage request failed', + status: 'error', + usageMetadata: { source: 'cli' } + }) + await service.refresh() + expect(service.getState().cursor?.session?.usedPercent).toBe(60) + }) + + it('clears cursorAuthConfigured once the local session goes away', async () => { + vi.mocked(readCursorAuthSession).mockResolvedValue(signedInResult()) + vi.mocked(fetchCursorRateLimits).mockResolvedValue(okProvider('cursor', 5)) + const service = new RateLimitService() + await service.refresh() + expect(service.getState().cursorAuthConfigured).toBe(true) + + vi.mocked(readCursorAuthSession).mockResolvedValue({ status: 'missing' }) + await service.refresh() + expect(service.getState().cursorAuthConfigured).toBe(false) + }) +}) diff --git a/src/main/rate-limits/service-inactive-account-previews.test.ts b/src/main/rate-limits/service-inactive-account-previews.test.ts index 1dee94e8c99..23f0a0e6bba 100644 --- a/src/main/rate-limits/service-inactive-account-previews.test.ts +++ b/src/main/rate-limits/service-inactive-account-previews.test.ts @@ -39,6 +39,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./zcode-usage-fetcher', () => ({ + fetchZcodeRateLimits: vi.fn() +})) + vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) @@ -47,6 +51,14 @@ vi.mock('./grok-fetcher', () => ({ fetchGrokRateLimits: vi.fn() })) +vi.mock('./cursor-fetcher', () => ({ + fetchCursorRateLimits: vi.fn() +})) + +vi.mock('./cursor-auth', () => ({ + readCursorAuthSession: vi.fn() +})) + vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) diff --git a/src/main/rate-limits/service-live-claude-usage.test.ts b/src/main/rate-limits/service-live-claude-usage.test.ts index 9b9137568fb..07f6ccf0d27 100644 --- a/src/main/rate-limits/service-live-claude-usage.test.ts +++ b/src/main/rate-limits/service-live-claude-usage.test.ts @@ -36,6 +36,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./zcode-usage-fetcher', () => ({ + fetchZcodeRateLimits: vi.fn() +})) + vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) @@ -44,6 +48,14 @@ vi.mock('./grok-fetcher', () => ({ fetchGrokRateLimits: vi.fn() })) +vi.mock('./cursor-fetcher', () => ({ + fetchCursorRateLimits: vi.fn() +})) + +vi.mock('./cursor-auth', () => ({ + readCursorAuthSession: vi.fn() +})) + vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) diff --git a/src/main/rate-limits/service-minimax-usage.test.ts b/src/main/rate-limits/service-minimax-usage.test.ts index 89b311827b6..93f95de7847 100644 --- a/src/main/rate-limits/service-minimax-usage.test.ts +++ b/src/main/rate-limits/service-minimax-usage.test.ts @@ -33,6 +33,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./zcode-usage-fetcher', () => ({ + fetchZcodeRateLimits: vi.fn() +})) + vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) @@ -41,6 +45,14 @@ vi.mock('./grok-fetcher', () => ({ fetchGrokRateLimits: vi.fn() })) +vi.mock('./cursor-fetcher', () => ({ + fetchCursorRateLimits: vi.fn() +})) + +vi.mock('./cursor-auth', () => ({ + readCursorAuthSession: vi.fn() +})) + vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) diff --git a/src/main/rate-limits/service-refresh-orchestration.test.ts b/src/main/rate-limits/service-refresh-orchestration.test.ts index 9b70fa0ea51..9577a87d6a3 100644 --- a/src/main/rate-limits/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service-refresh-orchestration.test.ts @@ -7,6 +7,7 @@ import { fetchGeminiRateLimits } from './gemini-usage-fetcher' import { fetchKimiRateLimits } from './kimi-fetcher' import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher' import { fetchGrokRateLimits } from './grok-fetcher' +import { fetchZcodeRateLimits } from './zcode-usage-fetcher' import { readGrokAuthSession } from './grok-auth' import { fetchOpenCodeGoUsage } from './opencode-go-usage-source-selection' import { @@ -40,6 +41,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./zcode-usage-fetcher', () => ({ + fetchZcodeRateLimits: vi.fn() +})) + vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) @@ -48,6 +53,14 @@ vi.mock('./grok-fetcher', () => ({ fetchGrokRateLimits: vi.fn() })) +vi.mock('./cursor-fetcher', () => ({ + fetchCursorRateLimits: vi.fn() +})) + +vi.mock('./cursor-auth', () => ({ + readCursorAuthSession: vi.fn() +})) + vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) @@ -65,6 +78,57 @@ describe('RateLimitService', () => { resetRateLimitProviderMocks() }) + it('publishes a ZCode quota snapshot alongside the other providers', async () => { + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 7)) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 8)) + vi.mocked(fetchZcodeRateLimits).mockResolvedValue(okProvider('zcode', 42)) + const service = new RateLimitService() + + await service.refresh() + + expect(fetchZcodeRateLimits).toHaveBeenCalledTimes(1) + expect(service.getState().zcode?.session?.usedPercent).toBe(42) + expect(service.getState().codex?.session?.usedPercent).toBe(8) + }) + + it('does not keep a previous ZCode account quota after a failed account switch', async () => { + vi.mocked(fetchZcodeRateLimits) + .mockResolvedValueOnce({ + ...okProvider('zcode', 42), + usageMetadata: { source: 'web', authProvenance: 'account-a' } + }) + .mockResolvedValueOnce({ + ...errorProvider('zcode', 'request failed'), + usageMetadata: { source: 'web', authProvenance: 'account-b', failureKind: 'network' } + }) + const service = new RateLimitService() + + await service.refresh() + await service.refresh() + + expect(service.getState().zcode?.status).toBe('error') + expect(service.getState().zcode?.session).toBeNull() + }) + + it('keeps a recent ZCode quota after a failed retry for the same account', async () => { + vi.mocked(fetchZcodeRateLimits) + .mockResolvedValueOnce({ + ...okProvider('zcode', 42), + usageMetadata: { source: 'web', authProvenance: 'account-a' } + }) + .mockResolvedValueOnce({ + ...errorProvider('zcode', 'request failed'), + usageMetadata: { source: 'web', authProvenance: 'account-a', failureKind: 'network' } + }) + const service = new RateLimitService() + + await service.refresh() + await service.refresh() + + expect(service.getState().zcode?.status).toBe('error') + expect(service.getState().zcode?.session?.usedPercent).toBe(42) + }) + it('does not reread Grok auth when callers read state snapshots', () => { vi.mocked(readGrokAuthSession).mockReturnValue({ status: 'ok', diff --git a/src/main/rate-limits/service-window-activation.test.ts b/src/main/rate-limits/service-window-activation.test.ts index 2b53c0919a1..54296c396b9 100644 --- a/src/main/rate-limits/service-window-activation.test.ts +++ b/src/main/rate-limits/service-window-activation.test.ts @@ -41,6 +41,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./zcode-usage-fetcher', () => ({ + fetchZcodeRateLimits: vi.fn() +})) + vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) @@ -49,6 +53,14 @@ vi.mock('./grok-fetcher', () => ({ fetchGrokRateLimits: vi.fn() })) +vi.mock('./cursor-fetcher', () => ({ + fetchCursorRateLimits: vi.fn() +})) + +vi.mock('./cursor-auth', () => ({ + readCursorAuthSession: vi.fn() +})) + vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) diff --git a/src/main/rate-limits/service/service-configuration.ts b/src/main/rate-limits/service/service-configuration.ts index f2629aa4fea..2a788b1cf93 100644 --- a/src/main/rate-limits/service/service-configuration.ts +++ b/src/main/rate-limits/service/service-configuration.ts @@ -127,6 +127,7 @@ export abstract class RateLimitServiceConfiguration extends RateLimitServiceAcco minimaxApiKeyConfigured: hasMiniMaxApiKey(), opencodeGoApiKeyConfigured: this.openCodeGoApiKeyConfigured, grokAuthConfigured: this.grokAuthConfigured, + cursorAuthConfigured: this.cursorAuthConfigured, claudeTarget: this.claudeFetchTarget, codexTarget: this.codexFetchTarget, inactiveClaudeAccounts: this.buildInactiveArray( diff --git a/src/main/rate-limits/service/service-full-cycle-application.ts b/src/main/rate-limits/service/service-full-cycle-application.ts index 7104face38f..70b58933c16 100644 --- a/src/main/rate-limits/service/service-full-cycle-application.ts +++ b/src/main/rate-limits/service/service-full-cycle-application.ts @@ -1,5 +1,6 @@ import { RateLimitServiceFullCyclePreparation } from './service-full-cycle-preparation' import { deriveAntigravityRateLimits } from '../antigravity-usage-mirror' +import { settleSiblingProviderResult } from './service-sibling-provider-result' import type { ProviderRateLimits } from './service-types' export abstract class RateLimitServiceFullCycleApplication extends RateLimitServiceFullCyclePreparation { @@ -34,7 +35,9 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ kimiResult, miniMaxResult ], - grokResultPromise + grokResultPromise, + cursorResultPromise, + zcodeResultPromise } = prepared if (signal.aborted) { return @@ -191,25 +194,45 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ : this.state.minimax }) - const grokResult = await grokResultPromise + const [grokSettled, cursorSettled, zcodeSettled] = await Promise.all([ + grokResultPromise, + cursorResultPromise, + zcodeResultPromise + ]) if (signal.aborted) { return } - const grok = - grokResult.status === 'fulfilled' - ? grokResult.value - : ({ - provider: 'grok', - session: null, - weekly: null, - updatedAt: Date.now(), - error: grokResult.reason instanceof Error ? grokResult.reason.message : 'Unknown error', - status: 'error' - } satisfies ProviderRateLimits) + const grok = settleSiblingProviderResult('grok', grokSettled) + const cursor = settleSiblingProviderResult('cursor', cursorSettled) + const zcode = settleSiblingProviderResult('zcode', zcodeSettled) + // Why: the stale policy keeps a recent snapshot through a failed refresh, but + // a snapshot belonging to a different Cursor account must not survive the + // switch — the Accounts pane would name the new account beside the old + // account's figures. Only a known-and-changed identity clears it, so an + // errored refresh that reports no account still keeps its own last reading. + const previousCursorAccount = previousState.cursor?.usageMetadata?.authProvenance + const cursorAccount = cursor.usageMetadata?.authProvenance + const cursorAccountChanged = + previousCursorAccount !== undefined && + cursorAccount !== undefined && + previousCursorAccount !== cursorAccount + const previousZcodeAccount = previousState.zcode?.usageMetadata?.authProvenance + const zcodeAccount = zcode.usageMetadata?.authProvenance + const sameZcodeAccount = + previousZcodeAccount !== undefined && + zcodeAccount !== undefined && + previousZcodeAccount === zcodeAccount this.trackActiveFailureStreak('grok', grok) + this.trackActiveFailureStreak('cursor', cursor) + this.trackActiveFailureStreak('zcode', zcode) this.updateState({ ...this.state, - grok: this.applyStalePolicy(grok, previousState.grok) + grok: this.applyStalePolicy(grok, previousState.grok), + cursor: cursorAccountChanged ? cursor : this.applyStalePolicy(cursor, previousState.cursor), + zcode: + zcode.status === 'error' && !sameZcodeAccount + ? zcode + : this.applyStalePolicy(zcode, previousState.zcode) }) } } diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts index 3e4f77b906d..55a2cc2a062 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -3,10 +3,14 @@ import { fetchCodexRateLimits } from '../codex-fetcher' import { fetchGeminiRateLimits } from '../gemini-usage-fetcher' import { fetchGrokRateLimits } from '../grok-fetcher' import { readGrokAuthSession } from '../grok-auth' +import { fetchCursorRateLimits } from '../cursor-fetcher' +import { readCursorAuthSession } from '../cursor-auth' +import { fetchZcodeRateLimits } from '../zcode-usage-fetcher' import { fetchMiniMaxRateLimits } from '../minimax/minimax-fetcher' import { createHash } from 'node:crypto' import { fetchOpenCodeGoUsage } from '../opencode-go-usage-source-selection' import { RateLimitServiceFetchPolicy } from './service-fetch-policy' +import type { SettledProviderResult } from './service-sibling-provider-result' import type { ClaudeRuntimeAuthPreparation, InternalRateLimitState, @@ -39,9 +43,9 @@ export type FetchAllCyclePrepared = { PromiseSettledResult, PromiseSettledResult ] - grokResultPromise: Promise< - { status: 'fulfilled'; value: ProviderRateLimits } | { status: 'rejected'; reason: unknown } - > + grokResultPromise: Promise + cursorResultPromise: Promise + zcodeResultPromise: Promise } export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServiceFetchPolicy { @@ -127,9 +131,28 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ minimax: miniMaxConfigChanged ? this.withFetchingStatus(null, 'minimax') : this.withFetchingStatus(previousState.minimax, 'minimax'), - grok: this.withFetchingStatus(previousState.grok, 'grok') + grok: this.withFetchingStatus(previousState.grok, 'grok'), + cursor: this.withFetchingStatus(previousState.cursor, 'cursor'), + zcode: this.withFetchingStatus(previousState.zcode, 'zcode') }) + // Why: the Cursor probe reads the macOS Keychain, so it is awaited inside the + // provider's own promise instead of blocking the rest of the cycle on it. + const cursorResultPromise = readCursorAuthSession() + .then((authReadResult) => { + this.cursorAuthConfigured = authReadResult.status === 'ok' + return fetchCursorRateLimits({ signal, authReadResult }) + }) + .then( + (value) => ({ status: 'fulfilled', value }) as const, + (reason) => ({ status: 'rejected', reason }) as const + ) + + const zcodeResultPromise = fetchZcodeRateLimits({ signal }).then( + (value) => ({ status: 'fulfilled', value }) as const, + (reason) => ({ status: 'rejected', reason }) as const + ) + const missingWslCodexHome = codexFetchGated || codexHomePath ? null : this.getMissingWslCodexHomeResult(codexTarget) const grokResultPromise = fetchGrokRateLimits({ @@ -215,7 +238,9 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ kimiResult, miniMaxResult ], - grokResultPromise + grokResultPromise, + cursorResultPromise, + zcodeResultPromise } } } diff --git a/src/main/rate-limits/service/service-polling.ts b/src/main/rate-limits/service/service-polling.ts index c861726cc52..402495080c2 100644 --- a/src/main/rate-limits/service/service-polling.ts +++ b/src/main/rate-limits/service/service-polling.ts @@ -78,7 +78,9 @@ export abstract class RateLimitServicePolling extends RateLimitServiceFetchQueue kimi: this.state.kimi, minimax: this.state.minimax, grok: this.state.grok, - antigravity: this.state.antigravity + antigravity: this.state.antigravity, + cursor: this.state.cursor, + zcode: this.state.zcode } return Object.entries(byProvider).map(([provider, limits]) => ({ provider: provider as ActiveRateLimitProvider, diff --git a/src/main/rate-limits/service/service-result-policy.ts b/src/main/rate-limits/service/service-result-policy.ts index db00053fdcf..75f032159fa 100644 --- a/src/main/rate-limits/service/service-result-policy.ts +++ b/src/main/rate-limits/service/service-result-policy.ts @@ -83,15 +83,7 @@ export abstract class RateLimitServiceResultPolicy extends RateLimitServiceFetch protected withFetchingStatus( current: ProviderRateLimits | null, - provider: - | 'claude' - | 'codex' - | 'gemini' - | 'opencode-go' - | 'kimi' - | 'minimax' - | 'grok' - | 'antigravity' + provider: ActiveRateLimitProvider ): ProviderRateLimits { if (!current) { return { diff --git a/src/main/rate-limits/service/service-sibling-provider-result.ts b/src/main/rate-limits/service/service-sibling-provider-result.ts new file mode 100644 index 00000000000..ff5337c7f70 --- /dev/null +++ b/src/main/rate-limits/service/service-sibling-provider-result.ts @@ -0,0 +1,26 @@ +import type { ProviderRateLimits } from './service-types' + +export type SettledProviderResult = + | { status: 'fulfilled'; value: ProviderRateLimits } + | { status: 'rejected'; reason: unknown } + +/** + * Collapses a provider that resolves on its own promise (outside the main + * `Promise.allSettled` tuple) into a publishable snapshot. + */ +export function settleSiblingProviderResult( + provider: ProviderRateLimits['provider'], + settled: SettledProviderResult +): ProviderRateLimits { + if (settled.status === 'fulfilled') { + return settled.value + } + return { + provider, + session: null, + weekly: null, + updatedAt: Date.now(), + error: settled.reason instanceof Error ? settled.reason.message : 'Unknown error', + status: 'error' + } +} diff --git a/src/main/rate-limits/service/service-state.ts b/src/main/rate-limits/service/service-state.ts index 5cea2a26338..7af82c6c313 100644 --- a/src/main/rate-limits/service/service-state.ts +++ b/src/main/rate-limits/service/service-state.ts @@ -31,9 +31,14 @@ export abstract class RateLimitServiceState { kimi: null, antigravity: null, minimax: null, - grok: null + grok: null, + cursor: null, + zcode: null } protected grokAuthConfigured = readGrokAuthSession().status === 'ok' + // Why: the Cursor probe reads the macOS Keychain, so it cannot run synchronously + // at construction the way Grok's auth-file probe does; each fetch cycle sets it. + protected cursorAuthConfigured = false protected openCodeGoApiKeyConfigured = false protected pollInterval: number = DEFAULT_POLL_MS protected timer: ReturnType | null = null @@ -47,7 +52,9 @@ export abstract class RateLimitServiceState { kimi: 0, minimax: 0, grok: 0, - antigravity: 0 + antigravity: 0, + cursor: 0, + zcode: 0 } // Why: consecutive failures drive exponential backoff of the fast activation-retry lane; reset on any success/unavailable result. protected activeFailureStreakByProvider: Record = { @@ -58,7 +65,9 @@ export abstract class RateLimitServiceState { kimi: 0, minimax: 0, grok: 0, - antigravity: 0 + antigravity: 0, + cursor: 0, + zcode: 0 } protected mainWindow: BrowserWindow | null = null protected detachWindowListeners: (() => void) | null = null diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts index 92204150d9a..a3998c13abe 100644 --- a/src/main/rate-limits/service/service-types.ts +++ b/src/main/rate-limits/service/service-types.ts @@ -109,6 +109,8 @@ export type InternalRateLimitState = { antigravity: ProviderRateLimits | null minimax: ProviderRateLimits | null grok: ProviderRateLimits | null + cursor: ProviderRateLimits | null + zcode: ProviderRateLimits | null } export function normalizePollingInterval(ms: number): number { diff --git a/src/main/rate-limits/zcode-usage-fetcher.test.ts b/src/main/rate-limits/zcode-usage-fetcher.test.ts new file mode 100644 index 00000000000..b587d196be0 --- /dev/null +++ b/src/main/rate-limits/zcode-usage-fetcher.test.ts @@ -0,0 +1,292 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fetchZcodeRateLimits } from './zcode-usage-fetcher' + +let dir: string +let configPath: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-zcode-usage-')) + configPath = join(dir, 'config.json') + vi.stubGlobal('fetch', vi.fn()) + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-12T06:00:00.000Z')) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() + rmSync(dir, { recursive: true, force: true }) +}) + +function writeConfig(overrides: Record = {}): void { + mkdirSync(dir, { recursive: true }) + writeFileSync( + configPath, + JSON.stringify({ + model: { main: 'bigmodel-coding-plan/GLM-5.2' }, + provider: { + other: { options: { apiKey: 'ignored', baseURL: 'https://example.com/v1' } }, + 'bigmodel-coding-plan': { + options: { + apiKey: 'test-secret', + baseURL: 'https://open.bigmodel.cn/api/anthropic' + } + } + }, + ...overrides + }) + ) +} + +describe('fetchZcodeRateLimits', () => { + it('returns unavailable without a supported Coding Plan credential', async () => { + writeFileSync(configPath, JSON.stringify({ provider: {} })) + + const result = await fetchZcodeRateLimits({ configPath }) + + expect(result).toMatchObject({ provider: 'zcode', status: 'unavailable' }) + expect(fetch).not.toHaveBeenCalled() + }) + + it('does not infer an account from the only configured provider without a selected model', async () => { + writeConfig({ + model: {}, + provider: { + zai: { options: { apiKey: 'sole-account', baseURL: 'https://api.z.ai/v1' } } + } + }) + + const result = await fetchZcodeRateLimits({ configPath }) + + expect(result.status).toBe('unavailable') + expect(fetch).not.toHaveBeenCalled() + }) + + it('queries the matching quota endpoint and maps rolling, weekly, and MCP limits', async () => { + writeConfig() + vi.mocked(fetch).mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: { + level: 'max', + limits: [ + { + type: 'TIME_LIMIT', + unit: 5, + number: 1, + percentage: 3, + nextResetTime: 1_787_000_000_000 + }, + { + type: 'TOKENS_LIMIT', + unit: 6, + number: 1, + percentage: 44, + nextResetTime: 1_786_600_000_000 + }, + { + type: 'TOKENS_LIMIT', + unit: 3, + number: 5, + percentage: 12, + nextResetTime: 1_786_500_000_000 + } + ] + } + }) + ) + ) + + const result = await fetchZcodeRateLimits({ configPath }) + + expect(fetch).toHaveBeenCalledWith( + 'https://open.bigmodel.cn/api/monitor/usage/quota/limit', + expect.objectContaining({ + method: 'GET', + redirect: 'error', + headers: expect.objectContaining({ Authorization: 'test-secret' }) + }) + ) + expect(result).toMatchObject({ provider: 'zcode', status: 'ok', planType: 'max' }) + expect(result.session).toEqual({ + usedPercent: 12, + windowMinutes: 300, + resetsAt: 1_786_500_000_000, + resetDescription: null + }) + expect(result.weekly?.usedPercent).toBe(44) + expect(result.monthly?.usedPercent).toBe(3) + }) + + it('uses CREDIT_LIMIT counts when the reported percentage is stale', async () => { + writeConfig() + vi.mocked(fetch).mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + code: 200, + data: { + limits: [ + { + type: 'CREDIT_LIMIT', + unit: 3, + number: 5, + usage: 2_000, + currentValue: 500, + remaining: 1_500, + percentage: 1 + }, + { + type: 'CREDIT_LIMIT', + unit: 6, + number: 1, + usage: 10_000, + remaining: 8_000, + percentage: 0 + } + ] + } + }) + ) + ) + + const result = await fetchZcodeRateLimits({ configPath }) + + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(25) + expect(result.weekly?.usedPercent).toBe(20) + expect(result.monthly).toBeNull() + }) + + it('drops an implausible five-hour reset without discarding the quota value', async () => { + writeConfig() + vi.mocked(fetch).mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: { + limits: [ + { + type: 'CREDIT_LIMIT', + unit: 3, + number: 5, + percentage: 25, + nextResetTime: Date.now() + 10 * 60 * 60_000 + } + ] + } + }) + ) + ) + + const result = await fetchZcodeRateLimits({ configPath }) + + expect(result.session?.usedPercent).toBe(25) + expect(result.session?.resetsAt).toBeNull() + }) + + it('selects the legacy string model provider when several accounts are configured', async () => { + writeConfig({ + model: 'zai/GLM-5.3', + provider: { + zai: { options: { apiKey: 'selected-key', baseURL: 'https://api.z.ai/v1' } }, + bigmodel: { options: { apiKey: 'other-key', baseURL: 'https://open.bigmodel.cn/v1' } } + } + }) + vi.mocked(fetch).mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: { limits: [{ type: 'CREDIT_LIMIT', unit: 3, number: 5, percentage: 20 }] } + }) + ) + ) + + const result = await fetchZcodeRateLimits({ configPath }) + + expect(result.status).toBe('ok') + expect(fetch).toHaveBeenCalledWith( + 'https://api.z.ai/api/monitor/usage/quota/limit', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'selected-key' }) + }) + ) + }) + + it('does not substitute a different account when the selected provider lacks a key', async () => { + writeConfig({ + model: { main: 'unconfigured/GLM-5.3' }, + provider: { zai: { options: { apiKey: 'other-account', baseURL: 'https://api.z.ai/v1' } } } + }) + + const result = await fetchZcodeRateLimits({ configPath }) + + expect(result.status).toBe('unavailable') + expect(fetch).not.toHaveBeenCalled() + }) + + it('rejects a nonstandard HTTPS port before sending the key', async () => { + writeConfig({ + provider: { zai: { options: { apiKey: 'secret', baseURL: 'https://api.z.ai:4444/v1' } } } + }) + + const result = await fetchZcodeRateLimits({ configPath }) + + expect(result.status).toBe('unavailable') + expect(fetch).not.toHaveBeenCalled() + }) + + it('supports the Z.AI endpoint without exposing credentials in errors', async () => { + writeConfig({ + model: { main: 'zai/GLM-5.2' }, + provider: { + zai: { options: { apiKey: 'never-log-me', baseURL: 'https://api.z.ai/api/anthropic' } } + } + }) + vi.mocked(fetch).mockResolvedValue(new Response('denied', { status: 401 })) + + const result = await fetchZcodeRateLimits({ configPath }) + + expect(fetch).toHaveBeenCalledWith( + 'https://api.z.ai/api/monitor/usage/quota/limit', + expect.any(Object) + ) + expect(result.status).toBe('error') + expect(result.error).toBe('ZCode quota request failed (401)') + expect(JSON.stringify(result)).not.toContain('never-log-me') + }) + + it('changes the non-secret account identity when the selected key changes', async () => { + writeConfig() + vi.mocked(fetch).mockResolvedValue(new Response('denied', { status: 401 })) + + const first = await fetchZcodeRateLimits({ configPath }) + writeConfig({ + provider: { + 'bigmodel-coding-plan': { + options: { apiKey: 'new-key', baseURL: 'https://open.bigmodel.cn/api/anthropic' } + } + } + }) + const second = await fetchZcodeRateLimits({ configPath }) + + expect(first.usageMetadata?.authProvenance).toMatch(/^[a-f0-9]{64}$/) + expect(second.usageMetadata?.authProvenance).not.toBe(first.usageMetadata?.authProvenance) + expect(JSON.stringify(first)).not.toContain('test-secret') + expect(JSON.stringify(second)).not.toContain('new-key') + }) + + it('rejects malformed successful responses', async () => { + writeConfig() + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ success: true, data: {} }))) + + const result = await fetchZcodeRateLimits({ configPath }) + + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('parse') + }) +}) diff --git a/src/main/rate-limits/zcode-usage-fetcher.ts b/src/main/rate-limits/zcode-usage-fetcher.ts new file mode 100644 index 00000000000..852def603e3 --- /dev/null +++ b/src/main/rate-limits/zcode-usage-fetcher.ts @@ -0,0 +1,277 @@ +import { createHmac, randomBytes } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { cancelUnreadResponseBody } from '../lib/unread-response-body' +import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' + +const API_TIMEOUT_MS = 15_000 +const SUPPORTED_HOSTS = new Set(['api.z.ai', 'open.bigmodel.cn', 'dev.bigmodel.cn']) +const CREDENTIAL_IDENTITY_KEY = randomBytes(32) + +type QuotaLimit = { + type?: unknown + unit?: unknown + number?: unknown + usage?: unknown + currentValue?: unknown + remaining?: unknown + percentage?: unknown + nextResetTime?: unknown +} + +type ZcodeUsageCredentials = { + apiKey: string + quotaUrl: string + authProvenance: string +} + +// Why readers and not casts: both JSON sources are outside our control — a user-edited +// config file and a remote response — so their shape is a guess until something checks it. +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function readRecord(value: unknown): Record | null { + return isRecord(value) ? value : null +} + +function readMainProvider(model: unknown): string | null { + const name = typeof model === 'string' ? model : readRecord(model)?.main + if (typeof name !== 'string') { + return null + } + const delimiter = name.indexOf('/') + return delimiter > 0 && delimiter < name.length - 1 ? name.slice(0, delimiter) : null +} + +function unavailable(error: string): ProviderRateLimits { + return { + provider: 'zcode', + session: null, + weekly: null, + monthly: null, + updatedAt: Date.now(), + error, + status: 'unavailable', + usageMetadata: { source: 'web', failureKind: 'missing-credentials' } + } +} + +function failed( + error: string, + failureKind: 'network' | 'server' | 'parse', + authProvenance: string +): ProviderRateLimits { + return { + provider: 'zcode', + session: null, + weekly: null, + monthly: null, + updatedAt: Date.now(), + error, + status: 'error', + usageMetadata: { source: 'web', failureKind, authProvenance } + } +} + +function readCredentials(configPath: string): ZcodeUsageCredentials | null { + let config: Record | null + try { + config = readRecord(JSON.parse(readFileSync(configPath, 'utf8'))) + } catch { + return null + } + + if (!config) { + return null + } + // A quota from another configured account must never appear as the selected model's quota. + const mainProvider = readMainProvider(config.model) + if (!mainProvider) { + return null + } + const options = readRecord(readRecord(readRecord(config.provider)?.[mainProvider])?.options) + const apiKey = options?.apiKey + const baseURL = options?.baseURL + if ( + typeof apiKey !== 'string' || + !apiKey.trim() || + /[\r\n]/.test(apiKey) || + typeof baseURL !== 'string' + ) { + return null + } + try { + const parsed = new URL(baseURL) + if ( + parsed.protocol !== 'https:' || + !SUPPORTED_HOSTS.has(parsed.hostname) || + (parsed.port !== '' && parsed.port !== '443') + ) { + return null + } + return { + apiKey: apiKey.trim(), + quotaUrl: `${parsed.origin}/api/monitor/usage/quota/limit`, + authProvenance: createHmac('sha256', CREDENTIAL_IDENTITY_KEY) + .update(JSON.stringify([mainProvider, parsed.origin, apiKey.trim()])) + .digest('hex') + } + } catch { + return null + } +} + +function asNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function asUsedPercent(limit: QuotaLimit): number | null { + const total = asNumber(limit.usage) + if (total !== null && total > 0) { + const current = asNumber(limit.currentValue) + const remaining = asNumber(limit.remaining) + if (current !== null || remaining !== null) { + const used = current ?? total - (remaining ?? 0) + return Math.min(100, Math.max(0, (used / total) * 100)) + } + } + const reported = asNumber(limit.percentage) + return reported === null ? null : Math.min(100, Math.max(0, reported)) +} + +function asResetTime(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null +} + +function asWindowMinutes(limit: QuotaLimit): number | null { + if (limit.type === 'TIME_LIMIT' && limit.unit === 5 && limit.number === 1) { + // Z.ai's monthly MCP marker is encoded as one minute. + return 30 * 24 * 60 + } + const multipliers: Record = { 1: 1440, 3: 60, 5: 1, 6: 10080 } + const unit = asNumber(limit.unit) + const count = asNumber(limit.number) + if (unit === null || count === null || !Number.isInteger(count) || count <= 0) { + return null + } + const multiplier = multipliers[unit] + return multiplier ? count * multiplier : null +} + +function asWindow(limit: QuotaLimit | undefined): RateLimitWindow | null { + if (!limit) { + return null + } + const usedPercent = asUsedPercent(limit) + const windowMinutes = asWindowMinutes(limit) + if (usedPercent === null || windowMinutes === null) { + return null + } + const reset = asResetTime(limit.nextResetTime) + return { + usedPercent, + windowMinutes, + resetsAt: + windowMinutes === 300 && reset !== null && reset > Date.now() + 301 * 60_000 ? null : reset, + resetDescription: null + } +} + +export async function fetchZcodeRateLimits( + options: { + configPath?: string + signal?: AbortSignal + } = {} +): Promise { + const configPath = options.configPath ?? join(homedir(), '.zcode', 'cli', 'config.json') + const credentials = readCredentials(configPath) + if (!credentials) { + return unavailable('ZCode Coding Plan credentials are not configured') + } + + let response: Response + try { + const signal = options.signal + ? AbortSignal.any([options.signal, AbortSignal.timeout(API_TIMEOUT_MS)]) + : AbortSignal.timeout(API_TIMEOUT_MS) + response = await fetch(credentials.quotaUrl, { + method: 'GET', + redirect: 'error', + headers: { + Authorization: credentials.apiKey, + 'Accept-Language': 'en-US,en', + 'Content-Type': 'application/json' + }, + signal + }) + } catch (error) { + return failed( + error instanceof Error ? error.message : 'ZCode quota request failed', + 'network', + credentials.authProvenance + ) + } + + if (!response.ok) { + await cancelUnreadResponseBody(response) + return failed( + `ZCode quota request failed (${response.status})`, + 'server', + credentials.authProvenance + ) + } + + let payload: Record | null + try { + payload = readRecord(await response.json()) + } catch { + return failed('Could not parse ZCode quota response', 'parse', credentials.authProvenance) + } + const data = readRecord(payload?.data) + const code = payload?.code + const reported = data?.limits + if ( + payload?.success !== true || + (code !== undefined && code !== 0 && code !== 200) || + !Array.isArray(reported) + ) { + const msg = payload?.msg + const message = typeof msg === 'string' ? msg : 'Invalid ZCode quota response' + return failed(message, 'parse', credentials.authProvenance) + } + + const limits = reported.filter((value): value is QuotaLimit => isRecord(value)) + const planLimits = limits + .filter((limit) => limit.type === 'TOKENS_LIMIT' || limit.type === 'CREDIT_LIMIT') + .map(asWindow) + .filter((limit): limit is RateLimitWindow => limit !== null) + .sort((left, right) => left.windowMinutes - right.windowMinutes) + const session = planLimits.find((limit) => limit.windowMinutes === 300) ?? null + const weekly = planLimits.find((limit) => limit.windowMinutes === 10080) ?? null + const monthly = asWindow(limits.find((limit) => limit.type === 'TIME_LIMIT')) + if (!session && !weekly && !monthly) { + return failed( + 'ZCode quota response contained no usable limits', + 'parse', + credentials.authProvenance + ) + } + + return { + provider: 'zcode', + session, + weekly, + monthly, + planType: typeof data?.level === 'string' ? data.level : null, + updatedAt: Date.now(), + error: null, + status: 'ok', + usageMetadata: { + source: 'web', + credentialSource: configPath, + authProvenance: credentials.authProvenance + } + } +} diff --git a/src/main/remote-agent-trust-presets.test.ts b/src/main/remote-agent-trust-presets.test.ts deleted file mode 100644 index d1ff9ee24ca..00000000000 --- a/src/main/remote-agent-trust-presets.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - getActiveMultiplexer: vi.fn(), - getSshFilesystemProvider: vi.fn() -})) - -vi.mock('./ssh/ssh-target-registry', () => ({ - getActiveMultiplexer: mocks.getActiveMultiplexer -})) - -vi.mock('./providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: mocks.getSshFilesystemProvider -})) - -const { markRemoteAgentWorkspaceTrusted } = await import('./remote-agent-trust-presets') - -function makeFsProvider(overrides: Record = {}) { - return { - realpath: vi.fn(async (path: string) => `/real${path}`), - readFile: vi.fn(async () => ({ content: '', isBinary: false })), - createDir: vi.fn(async () => undefined), - writeFile: vi.fn(async () => undefined), - stat: vi.fn(async () => { - throw new Error('missing') - }), - ...overrides - } -} - -describe('markRemoteAgentWorkspaceTrusted', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.getActiveMultiplexer.mockReturnValue({ - request: vi.fn(async () => ({ resolvedPath: '/home/u/' })) - }) - }) - - it('writes Codex trust to the remote home and canonicalized workspace path', async () => { - const fsProvider = makeFsProvider() - mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) - - await markRemoteAgentWorkspaceTrusted({ - preset: 'codex', - connectionId: 'ssh-1', - workspacePath: '/repo/worktree' - }) - - expect(mocks.getActiveMultiplexer).toHaveBeenCalledWith('ssh-1') - expect(fsProvider.realpath).toHaveBeenCalledWith('/repo/worktree') - expect(fsProvider.createDir).toHaveBeenCalledWith('/home/u/.codex') - expect(fsProvider.writeFile).toHaveBeenCalledWith( - '/home/u/.codex/config.toml', - expect.stringContaining('[projects."/real/repo/worktree"]') - ) - }) - - it('writes Codex trust when the remote home is a Windows absolute path', async () => { - const fsProvider = makeFsProvider({ - realpath: vi.fn(async () => 'C:/Users/alice/platform') - }) - mocks.getActiveMultiplexer.mockReturnValue({ - request: vi.fn(async () => ({ resolvedPath: 'C:\\Users\\alice\\' })) - }) - mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) - - await markRemoteAgentWorkspaceTrusted({ - preset: 'codex', - connectionId: 'ssh-windows', - workspacePath: 'C:\\Users\\alice\\platform' - }) - - expect(fsProvider.createDir).toHaveBeenCalledWith('C:/Users/alice/.codex') - expect(fsProvider.writeFile).toHaveBeenCalledWith( - 'C:/Users/alice/.codex/config.toml', - expect.stringContaining('[projects."C:/Users/alice/platform"]') - ) - }) - - it('writes Cursor trust marker on the remote host', async () => { - const fsProvider = makeFsProvider() - mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) - - await markRemoteAgentWorkspaceTrusted({ - preset: 'cursor', - connectionId: 'ssh-1', - workspacePath: '/repo/worktree' - }) - - expect(fsProvider.createDir).toHaveBeenCalledWith('/home/u/.cursor/projects/real-repo-worktree') - expect(fsProvider.writeFile).toHaveBeenCalledWith( - '/home/u/.cursor/projects/real-repo-worktree/.workspace-trusted', - expect.stringContaining('"workspacePath": "/real/repo/worktree"') - ) - }) - - it('sanitizes Windows path characters in remote Cursor trust marker paths', async () => { - const fsProvider = makeFsProvider({ - realpath: vi.fn(async () => 'C:/Users/alice/platform') - }) - mocks.getActiveMultiplexer.mockReturnValue({ - request: vi.fn(async () => ({ resolvedPath: 'C:/Users/alice/' })) - }) - mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) - - await markRemoteAgentWorkspaceTrusted({ - preset: 'cursor', - connectionId: 'ssh-windows', - workspacePath: 'C:\\Users\\alice\\platform' - }) - - expect(fsProvider.createDir).toHaveBeenCalledWith( - 'C:/Users/alice/.cursor/projects/C-Users-alice-platform' - ) - expect(fsProvider.writeFile).toHaveBeenCalledWith( - 'C:/Users/alice/.cursor/projects/C-Users-alice-platform/.workspace-trusted', - expect.stringContaining('"workspacePath": "C:/Users/alice/platform"') - ) - }) - - it('appends Copilot trusted folder remotely without clobbering config keys', async () => { - const writeFile = vi.fn(async (_filePath: string, _content: string) => undefined) - const fsProvider = makeFsProvider({ - readFile: vi.fn(async () => ({ - content: JSON.stringify({ firstLaunchAt: '2026-01-01', trustedFolders: ['/old'] }), - isBinary: false - })), - writeFile - }) - mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) - - await markRemoteAgentWorkspaceTrusted({ - preset: 'copilot', - connectionId: 'ssh-1', - workspacePath: '/repo/worktree' - }) - - expect(fsProvider.createDir).toHaveBeenCalledWith('/home/u/.copilot') - const written = writeFile.mock.calls[0]?.[1] - expect(typeof written).toBe('string') - expect(JSON.parse(written as string)).toEqual({ - firstLaunchAt: '2026-01-01', - trustedFolders: ['/old', '/real/repo/worktree'] - }) - }) - - it('does nothing when the SSH home cannot be resolved safely', async () => { - const fsProvider = makeFsProvider() - mocks.getActiveMultiplexer.mockReturnValue({ - request: vi.fn(async () => ({ resolvedPath: 'relative/home' })) - }) - mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) - - await markRemoteAgentWorkspaceTrusted({ - preset: 'codex', - connectionId: 'ssh-1', - workspacePath: '/repo/worktree' - }) - - expect(fsProvider.writeFile).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/remote-agent-trust-presets.ts b/src/main/remote-agent-trust-presets.ts deleted file mode 100644 index a9ccb382c09..00000000000 --- a/src/main/remote-agent-trust-presets.ts +++ /dev/null @@ -1,154 +0,0 @@ -import type { AgentTrustPreset } from './agent-trust-presets' -import { upsertProjectTrustLevelInContent } from './codex/config-toml-trust' -import { getActiveMultiplexer } from './ssh/ssh-target-registry' -import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' -import type { IFilesystemProvider } from './providers/types' -import { - isWindowsAbsolutePathLike, - normalizeRuntimePathSeparators -} from '../shared/cross-platform-path' - -export async function markRemoteAgentWorkspaceTrusted(args: { - preset: AgentTrustPreset - connectionId: string - workspacePath: string -}): Promise { - const home = await resolveRemoteHome(args.connectionId) - const fsProvider = getSshFilesystemProvider(args.connectionId) - if (!home || !fsProvider) { - return - } - - const workspacePath = await canonicalizeRemoteWorkspacePath(fsProvider, args.workspacePath) - if (args.preset === 'codex') { - await markRemoteCodexProjectTrusted(fsProvider, home, workspacePath) - } else if (args.preset === 'cursor') { - await markRemoteCursorWorkspaceTrusted(fsProvider, home, workspacePath) - } else if (args.preset === 'copilot') { - await markRemoteCopilotFolderTrusted(fsProvider, home, workspacePath) - } - // KNOWN GAP: 'antigravity' is deliberately absent. The local preset writes - // ~/.gemini/antigravity-cli/settings.json, and the remote equivalent has not been verified - // against an SSH execution host, so an agy worker launched over SSH still raises its - // first-launch trust prompt and will stall at agent_readiness. Falling through silently - // matches the pre-existing behaviour for agy; it is recorded here rather than left as an - // unexplained omission. Mirror markRemoteCopilotFolderTrusted once it can be tested. -} - -async function resolveRemoteHome(connectionId: string): Promise { - const mux = getActiveMultiplexer(connectionId) - if (!mux || mux.isDisposed?.()) { - return null - } - const result = (await mux.request('session.resolveHome', { path: '~' })) as { - resolvedPath?: unknown - } - const home = - typeof result.resolvedPath === 'string' - ? normalizeRuntimePathSeparators(result.resolvedPath.trim()) - : '' - return home && - (home.startsWith('/') || isWindowsAbsolutePathLike(home)) && - !hasRemotePathControlCharacter(home) - ? home.replace(/\/$/, '') - : null -} - -function hasRemotePathControlCharacter(value: string): boolean { - return value.includes(String.fromCharCode(0)) || value.includes('\r') || value.includes('\n') -} - -async function canonicalizeRemoteWorkspacePath( - fsProvider: IFilesystemProvider, - workspacePath: string -): Promise { - try { - return await fsProvider.realpath(workspacePath) - } catch { - return workspacePath - } -} - -async function readRemoteTextFile( - fsProvider: IFilesystemProvider, - filePath: string -): Promise { - try { - const result = await fsProvider.readFile(filePath) - return result.isBinary ? '' : result.content - } catch { - return '' - } -} - -async function markRemoteCodexProjectTrusted( - fsProvider: IFilesystemProvider, - remoteHome: string, - workspacePath: string -): Promise { - const codexDir = `${remoteHome}/.codex` - const configPath = `${codexDir}/config.toml` - const existing = await readRemoteTextFile(fsProvider, configPath) - const updated = upsertProjectTrustLevelInContent(existing, workspacePath, 'trusted', { - // Why: workspacePath was resolved by the remote filesystem provider; local - // realpath would canonicalize the wrong machine on SSH. - alreadyCanonical: true - }) - if (updated === existing) { - return - } - await fsProvider.createDir(codexDir) - await fsProvider.writeFile(configPath, updated) -} - -async function markRemoteCursorWorkspaceTrusted( - fsProvider: IFilesystemProvider, - remoteHome: string, - workspacePath: string -): Promise { - const slug = workspacePath.replace(/^[\\/]+/, '').replace(/[\\/:*?"<>|]+/g, '-') - if (!slug) { - return - } - const trustDir = `${remoteHome}/.cursor/projects/${slug}` - const trustFile = `${trustDir}/.workspace-trusted` - try { - await fsProvider.stat(trustFile) - return - } catch { - // Missing marker: write the same shape the local trust preset writes. - } - await fsProvider.createDir(trustDir) - await fsProvider.writeFile( - trustFile, - `${JSON.stringify({ trustedAt: new Date().toISOString(), workspacePath }, null, 2)}\n` - ) -} - -async function markRemoteCopilotFolderTrusted( - fsProvider: IFilesystemProvider, - remoteHome: string, - workspacePath: string -): Promise { - const configDir = `${remoteHome}/.copilot` - const configPath = `${configDir}/config.json` - const raw = await readRemoteTextFile(fsProvider, configPath) - let config: Record = {} - if (raw.trim()) { - try { - const parsed = JSON.parse(raw) - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - config = parsed as Record - } - } catch { - return - } - } - const existing = Array.isArray(config.trustedFolders) ? (config.trustedFolders as unknown[]) : [] - if (existing.includes(workspacePath)) { - return - } - config.trustedFolders = [...existing.filter((entry) => typeof entry === 'string'), workspacePath] - await fsProvider.createDir(configDir) - await fsProvider.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`) -} diff --git a/src/main/repo-execution-host.ts b/src/main/repo-execution-host.ts new file mode 100644 index 00000000000..dae5b0e48d4 --- /dev/null +++ b/src/main/repo-execution-host.ts @@ -0,0 +1,21 @@ +import { + getRepoExecutionHostId, + getSshTargetIdForExecutionHost, + LOCAL_EXECUTION_HOST_ID, + type ExecutionHostId +} from '../shared/execution-host' +import type { Repo } from '../shared/repo-types' + +/** Only for rows from this process's Store: runtime stamps address files registered here. */ +export function getStoredRepoExecutionHostId( + repo: Pick +): ExecutionHostId { + const hostId = getRepoExecutionHostId(repo) + return getSshTargetIdForExecutionHost(hostId) ? hostId : LOCAL_EXECUTION_HOST_ID +} + +export function getStoredRepoSshConnectionId( + repo: Pick +): string | null { + return getSshTargetIdForExecutionHost(getStoredRepoExecutionHostId(repo)) +} diff --git a/src/main/repo-git-remote-avatar-refresh.test.ts b/src/main/repo-git-remote-avatar-refresh.test.ts new file mode 100644 index 00000000000..a787f5501bf --- /dev/null +++ b/src/main/repo-git-remote-avatar-refresh.test.ts @@ -0,0 +1,339 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { getRepoExecutionHostId, type ExecutionHostId } from '../shared/execution-host' +import { deriveGitRemoteIdentity } from '../shared/git-remote-identity' +import { projectHostSetupProjectionFromRepos } from '../shared/project-host-setup-projection' +import { githubAvatarIcon, type RepoIcon } from '../shared/repo-icon' +import type { Repo } from '../shared/repo-types' +import { probeGitRemoteIdentity, type GitRemoteIdentityProbe } from './repo-git-remote-identity' +import { + enrichMissingRepoGitRemoteIdentities, + flushRepoGitRemoteIdentityEnrichmentForTests, + resetRepoGitRemoteIdentityEnrichmentForTests +} from './repo-git-remote-identity-enrichment' + +vi.mock('./repo-git-remote-identity', () => ({ probeGitRemoteIdentity: vi.fn() })) + +function identity(remote = 'https://github.com/org-b/app.git') { + const parsed = deriveGitRemoteIdentity(`origin\t${remote} (fetch)`) + if (!parsed) { + throw new Error('Fixture remote must parse') + } + return parsed +} + +function repo(overrides: Partial = {}): Repo { + return { + id: 'app', + path: '/workspace/app', + displayName: 'app', + kind: 'git', + badgeColor: '', + addedAt: 1, + upstream: null, + gitRemoteIdentity: identity(), + repoIcon: githubAvatarIcon({ owner: 'owner-a', repo: 'app' }), + ...overrides + } +} + +function storeFor(repos: Repo[]) { + const getRepo = (id: string, hostId?: ExecutionHostId) => + repos.find((row) => row.id === id && (!hostId || getRepoExecutionHostId(row) === hostId)) + const updateRepo = vi.fn((id: string, updates: Partial, hostId?: ExecutionHostId) => { + const current = getRepo(id, hostId) + if (!current) { + return null + } + Object.assign(current, updates) + return current + }) + return { getRepos: () => repos, getRepo, updateRepo } +} + +async function sweep(store: ReturnType, onChanged = vi.fn()) { + enrichMissingRepoGitRemoteIdentities(store, { onChanged }) + for (let i = 0; i < 8; i++) { + await flushRepoGitRemoteIdentityEnrichmentForTests() + } +} + +async function refresh(store: ReturnType, onChanged = vi.fn()) { + vi.useFakeTimers() + vi.setSystemTime(1_000) + await sweep(store, onChanged) + expect(probeGitRemoteIdentity).not.toHaveBeenCalled() + vi.setSystemTime(301_001) + await sweep(store, onChanged) +} + +afterEach(() => { + resetRepoGitRemoteIdentityEnrichmentForTests() + vi.useRealTimers() + vi.clearAllMocks() +}) + +it.each(['already-current', 'changed'])( + 'repairs a stale avatar when the canonical key is %s', + async (mode) => { + const local = repo({ + gitRemoteIdentity: + mode === 'changed' ? identity('https://github.com/owner-a/app.git') : identity() + }) + const peer = repo({ + id: 'peer', + path: '/workspace/app', + connectionId: 'build', + repoIcon: githubAvatarIcon({ owner: 'org-b', repo: 'app' }) + }) + const store = storeFor([local, peer]) + const onChanged = vi.fn() + vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ + status: 'resolved', + identity: identity() + }) + + expect(projectHostSetupProjectionFromRepos(store.getRepos()).projects).toHaveLength(2) + await refresh(store, onChanged) + + expect(local.repoIcon).toEqual(githubAvatarIcon({ owner: 'org-b', repo: 'app' })) + expect(store.updateRepo).toHaveBeenCalledTimes(1) + expect(onChanged).toHaveBeenCalledTimes(1) + const projected = projectHostSetupProjectionFromRepos(store.getRepos()) + expect(projected.projects.map(({ id }) => id)).toEqual(['github:org-b/app']) + expect(projected.setups.map(({ hostId, path }) => ({ hostId, path }))).toEqual([ + { hostId: 'local', path: '/workspace/app' }, + { hostId: 'ssh:build', path: '/workspace/app' } + ]) + vi.setSystemTime(301_001 + 6 * 60 * 60 * 1000) + await sweep(store, onChanged) + expect(store.updateRepo).toHaveBeenCalledTimes(1) + } +) + +it.each([ + 'https://github.company.test:8443/org-b/app.git', + 'ssh://git@ssh.github.com:443/org-b/app.git' +])('preserves GitHub endpoint identity for %s', async (remote) => { + const row = repo({ gitRemoteIdentity: identity(remote) }) + const store = storeFor([row]) + vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ + status: 'resolved', + identity: identity(remote) + }) + await refresh(store) + expect(projectHostSetupProjectionFromRepos([row]).projects[0]?.id).toBe( + remote.includes('company') ? 'github:github.company.test:8443/org-b/app' : 'github:org-b/app' + ) +}) + +it.each([ + 'git@github-work:org/app.git', + 'ssh://git@ghe-work/org/app.git', + 'https://gitlab.com/team/sub/app.git', + 'https://forgejo.test/team/app.git', + 'https://gitea.test/team/app.git', + 'https://code.company.test/team/app.git' +])('leaves provider metadata intact for unresolved or other-provider remote %s', async (remote) => { + const row = repo({ gitRemoteIdentity: identity(remote) }) + const original = row.repoIcon + const store = storeFor([row]) + vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ + status: 'resolved', + identity: identity(remote) + }) + await refresh(store) + expect(row.repoIcon).toBe(original) + expect(store.updateRepo).not.toHaveBeenCalled() +}) + +const customIcons: (RepoIcon | null | undefined)[] = [ + { type: 'emoji', emoji: '🐙' }, + { type: 'lucide', name: 'Folder' }, + { type: 'image', source: 'upload', src: 'data:image/png;base64,fixture', label: 'custom' }, + { type: 'image', source: 'file', src: 'data:image/png;base64,fixture', label: 'custom' }, + { type: 'image', source: 'favicon', src: 'https://website.test/favicon.png', label: 'custom' }, + null, + undefined +] + +it.each(customIcons)('preserves custom or cleared icon %j', async (repoIcon) => { + const row = repo({ repoIcon }) + const store = storeFor([row]) + vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ status: 'resolved', identity: identity() }) + await refresh(store) + expect(row.repoIcon).toBe(repoIcon) + expect(store.updateRepo).not.toHaveBeenCalled() +}) + +it('preserves explicit upstream even when the remote disagrees', async () => { + const row = repo({ upstream: { owner: 'parent', repo: 'app', host: 'github.parent.test' } }) + const original = row.repoIcon + const store = storeFor([row]) + vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ status: 'resolved', identity: identity() }) + await refresh(store) + expect(row.repoIcon).toBe(original) + expect(projectHostSetupProjectionFromRepos([row]).projects[0]?.id).toBe( + 'github:github.parent.test/parent/app' + ) + expect(store.updateRepo).not.toHaveBeenCalled() +}) + +it.each(['unavailable', 'no-remote'] as const)( + 'preserves the last avatar when a refresh is %s', + async (status) => { + const row = repo() + const original = row.repoIcon + const store = storeFor([row]) + vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ status }) + await refresh(store) + expect(row.repoIcon).toBe(original) + expect(store.updateRepo).not.toHaveBeenCalled() + } +) + +it.each(['same', 'different', 'missing'] as const)( + 'never probes or writes peer-owned metadata when identity is %s', + async (kind) => { + const row = repo({ + executionHostId: 'runtime:peer', + connectionId: 'nested', + gitRemoteIdentity: + kind === 'missing' + ? undefined + : identity(kind === 'different' ? 'https://github.com/peer-only/app.git' : undefined) + }) + const originalIcon = row.repoIcon + const originalIdentity = row.gitRemoteIdentity + const store = storeFor([row]) + vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ + status: 'resolved', + identity: identity() + }) + await (kind === 'missing' ? sweep(store) : refresh(store)) + expect(probeGitRemoteIdentity).not.toHaveBeenCalled() + expect(row.repoIcon).toBe(originalIcon) + expect(row.gitRemoteIdentity).toBe(originalIdentity) + expect(store.updateRepo).not.toHaveBeenCalled() + } +) + +it('repairs only the matching owner when repo IDs and paths collide across hosts', async () => { + const local = repo({ repoIcon: githubAvatarIcon({ owner: 'org-b', repo: 'app' }) }) + const ssh = repo({ executionHostId: 'ssh:build' }) + const store = storeFor([local, ssh]) + vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ status: 'resolved', identity: identity() }) + await refresh(store) + expect(store.updateRepo).toHaveBeenCalledWith( + 'app', + { repoIcon: githubAvatarIcon({ owner: 'org-b', repo: 'app' }) }, + 'ssh:build' + ) + expect(projectHostSetupProjectionFromRepos([local, ssh]).projects).toHaveLength(1) +}) + +it('keeps local metadata writes scoped when a same-id ssh:build row comes first', async () => { + const foreignIdentity = identity('https://github.com/foreign/app.git') + const foreignIcon = githubAvatarIcon({ owner: 'foreign', repo: 'app' }) + const foreign = repo({ + executionHostId: 'ssh:build', + gitRemoteIdentity: foreignIdentity, + repoIcon: foreignIcon + }) + const local = repo({ gitRemoteIdentity: identity('https://github.com/old-local/app.git') }) + const store = storeFor([foreign, local]) + vi.mocked(probeGitRemoteIdentity).mockImplementation(async (_path, probeHostId) => ({ + status: 'resolved', + identity: probeHostId === 'local' ? identity() : foreignIdentity + })) + await refresh(store) + expect(foreign.gitRemoteIdentity).toBe(foreignIdentity) + expect(foreign.repoIcon).toBe(foreignIcon) + expect(local.gitRemoteIdentity).toEqual(identity()) + expect(local.repoIcon).toEqual(githubAvatarIcon({ owner: 'org-b', repo: 'app' })) + expect(store.updateRepo).toHaveBeenCalledExactlyOnceWith( + 'app', + { gitRemoteIdentity: identity(), repoIcon: local.repoIcon }, + 'local' + ) +}) + +// Why this row is repaired where the `ssh:build` one above is not: a `runtime:` stamp reaches this +// store only from a client addressing *this* host, so the files are here and `local` is the only +// host that can answer for them (`getStoredRepoExecutionHostId`). Both rows are written, each +// addressed by its own stamp, because that is what the store matches a write against. +it('repairs a same-id runtime-addressed row under its own stamp', async () => { + const runtimeRow = repo({ + executionHostId: 'runtime:env-a', + gitRemoteIdentity: identity('https://github.com/old-runtime/app.git'), + repoIcon: githubAvatarIcon({ owner: 'old-runtime', repo: 'app' }) + }) + const local = repo({ gitRemoteIdentity: identity('https://github.com/old-local/app.git') }) + const store = storeFor([runtimeRow, local]) + vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ status: 'resolved', identity: identity() }) + await refresh(store) + expect(probeGitRemoteIdentity).toHaveBeenCalledWith( + '/workspace/app', + 'local', + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ) + const repaired = { + gitRemoteIdentity: identity(), + repoIcon: githubAvatarIcon({ owner: 'org-b', repo: 'app' }) + } + expect(runtimeRow).toMatchObject(repaired) + expect(local).toMatchObject(repaired) + expect(store.updateRepo).toHaveBeenCalledWith('app', repaired, 'runtime:env-a') + expect(store.updateRepo).toHaveBeenCalledWith('app', repaired, 'local') +}) + +it('does not write after a pending local probe becomes peer-owned', async () => { + let answer: ((value: GitRemoteIdentityProbe) => void) | undefined + const row = repo({ gitRemoteIdentity: undefined }) + const originalIcon = row.repoIcon + const store = storeFor([row]) + vi.mocked(probeGitRemoteIdentity).mockImplementation( + () => + new Promise((resolve) => { + answer = resolve + }) + ) + enrichMissingRepoGitRemoteIdentities(store) + row.executionHostId = 'runtime:peer' + if (!answer) { + throw new Error('Expected pending probe') + } + answer({ status: 'resolved', identity: identity() }) + await flushRepoGitRemoteIdentityEnrichmentForTests() + expect(row.gitRemoteIdentity).toBeUndefined() + expect(row.repoIcon).toBe(originalIcon) + expect(store.updateRepo).not.toHaveBeenCalled() +}) + +it('does not overwrite a custom icon selected while the probe is pending', async () => { + let answer: ((value: GitRemoteIdentityProbe) => void) | undefined + const row = repo({ gitRemoteIdentity: undefined }) + const store = storeFor([row]) + vi.mocked(probeGitRemoteIdentity).mockImplementation( + () => + new Promise((resolve) => { + answer = resolve + }) + ) + enrichMissingRepoGitRemoteIdentities(store) + const selected: RepoIcon = { type: 'emoji', emoji: '🐙' } + row.repoIcon = selected + if (!answer) { + throw new Error('Expected pending probe') + } + answer({ status: 'resolved', identity: identity() }) + await flushRepoGitRemoteIdentityEnrichmentForTests() + expect(row.repoIcon).toBe(selected) + expect(store.updateRepo).toHaveBeenCalledWith('app', { gitRemoteIdentity: identity() }, 'local') +}) + +it('never probes folder workspaces for an avatar repair', async () => { + const store = storeFor([repo({ kind: 'folder' })]) + await refresh(store) + expect(probeGitRemoteIdentity).not.toHaveBeenCalled() + expect(store.updateRepo).not.toHaveBeenCalled() +}) diff --git a/src/main/repo-git-remote-identity-enrichment.test.ts b/src/main/repo-git-remote-identity-enrichment.test.ts index a52d110aa02..d1e54e11de8 100644 --- a/src/main/repo-git-remote-identity-enrichment.test.ts +++ b/src/main/repo-git-remote-identity-enrichment.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { getRepoExecutionHostId, type ExecutionHostId } from '../shared/execution-host' import type { GitRemoteIdentity } from '../shared/git-remote-identity' import type { Repo } from '../shared/repo-types' import { type GitRemoteIdentityProbe, probeGitRemoteIdentity } from './repo-git-remote-identity' @@ -15,7 +16,11 @@ vi.mock('./repo-git-remote-identity', () => ({ type RepoIdentityStore = { getRepos: () => Repo[] getRepo: (id: string) => Repo | undefined - updateRepo: (id: string, updates: Pick, 'gitRemoteIdentity'>) => Repo | null + updateRepo: ( + id: string, + updates: Pick, 'gitRemoteIdentity'>, + hostId?: ExecutionHostId + ) => Repo | null } const remoteIdentity: GitRemoteIdentity = { @@ -42,8 +47,13 @@ function makeStore(...repos: Repo[]): RepoIdentityStore & { updateRepo: ReturnTy return { getRepos: () => repos, getRepo: (id) => repos.find((candidate) => candidate.id === id), - updateRepo: vi.fn((id, updates) => { - const target = repos.find((candidate) => candidate.id === id) + // Mirrors the real store: `hostId` is matched against the row's own stamp, so a write + // addressed to the wrong host finds no row (src/main/persistence/tracking-repos). + updateRepo: vi.fn((id, updates, hostId) => { + const target = repos.find( + (candidate) => + candidate.id === id && (!hostId || getRepoExecutionHostId(candidate) === hostId) + ) if (!target) { return null } @@ -129,23 +139,43 @@ describe('enrichMissingRepoGitRemoteIdentities', () => { await flushRepoGitRemoteIdentityEnrichmentForTests() }) - it('never hands a runtime row nested SSH target to this client dispatch table', async () => { + it('never probes a runtime row that names a nested SSH target', async () => { // Why: `connectionId` on a `runtime:` row names a target inside that server's namespace, so - // dialing it here reaches a same-named box of ours. The row keeps the probe this process has - // always run for it; what it must never do is dial our same-named target. + // dialing it here reaches a same-named box of ours, and the same path on this machine is a + // different checkout. Neither host is probeable from here, so the row is skipped outright. vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ status: 'unavailable' }) const store = makeStore( makeRepo({ connectionId: 'nested-1', executionHostId: 'runtime:env-a' }) ) - enrichMissingRepoGitRemoteIdentities(store) + await sweep(store) + + expect(probeGitRemoteIdentity).not.toHaveBeenCalled() + expect(store.updateRepo).not.toHaveBeenCalled() + }) + + it('probes a self-addressed runtime row here and writes it back under its own stamp', async () => { + // Why: a bare `runtime:` stamp is how a paired client addresses a repo registered in *this* + // process, so its files are local. Skipping it left `gitRemoteIdentity` unset forever, which + // consumers read as pending. The write must carry the row's stamp, not the probe host — the + // store matches that argument against the stamp, so `local` would match no row. + vi.mocked(probeGitRemoteIdentity).mockResolvedValue(resolvedProbe) + const repo = makeRepo({ executionHostId: 'runtime:env-a' }) + const store = makeStore(repo) + + await sweep(store) expect(probeGitRemoteIdentity).toHaveBeenCalledWith( '/workspace/sample-app', 'local', expect.objectContaining({ signal: expect.any(AbortSignal) }) ) - await flushRepoGitRemoteIdentityEnrichmentForTests() + expect(store.updateRepo).toHaveBeenCalledWith( + 'repo-1', + { gitRemoteIdentity: remoteIdentity }, + 'runtime:env-a' + ) + expect(repo.gitRemoteIdentity).toEqual(remoteIdentity) }) it('keeps same-path rows on two different SSH hosts from sharing one backoff', async () => { @@ -208,7 +238,7 @@ describe('enrichMissingRepoGitRemoteIdentities', () => { enrichMissingRepoGitRemoteIdentities(store) await flushRepoGitRemoteIdentityEnrichmentForTests() - expect(store.updateRepo).toHaveBeenCalledWith('repo-1', { gitRemoteIdentity: null }) + expect(store.updateRepo).toHaveBeenCalledWith('repo-1', { gitRemoteIdentity: null }, 'local') expect(repo.gitRemoteIdentity).toBeNull() }) @@ -243,7 +273,11 @@ describe('enrichMissingRepoGitRemoteIdentities', () => { enrichMissingRepoGitRemoteIdentities(store) await flushRepoGitRemoteIdentityEnrichmentForTests() - expect(store.updateRepo).toHaveBeenCalledWith('repo-1', { gitRemoteIdentity: remoteIdentity }) + expect(store.updateRepo).toHaveBeenCalledWith( + 'repo-1', + { gitRemoteIdentity: remoteIdentity }, + 'local' + ) }) it('does not re-probe a resolved identity before the refresh window elapses', async () => { @@ -296,7 +330,11 @@ describe('enrichMissingRepoGitRemoteIdentities', () => { enrichMissingRepoGitRemoteIdentities(store, { onChanged }) await drainEnrichmentSweep() - expect(store.updateRepo).toHaveBeenCalledWith('repo-1', { gitRemoteIdentity: movedIdentity }) + expect(store.updateRepo).toHaveBeenCalledWith( + 'repo-1', + { gitRemoteIdentity: movedIdentity }, + 'local' + ) expect(repo.gitRemoteIdentity).toEqual(movedIdentity) expect(onChanged).toHaveBeenCalledTimes(1) }) diff --git a/src/main/repo-git-remote-identity-enrichment.ts b/src/main/repo-git-remote-identity-enrichment.ts index a97961fe59f..f1cea725efc 100644 --- a/src/main/repo-git-remote-identity-enrichment.ts +++ b/src/main/repo-git-remote-identity-enrichment.ts @@ -1,10 +1,14 @@ import { getRepoExecutionHostId, - getSshTargetIdForExecutionHost, + getRepoSshConnectionId, LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../shared/execution-host' import type { Repo } from '../shared/repo-types' +import { githubAvatarIcon, type RepoIcon } from '../shared/repo-icon' +import { isUnresolvedSshHostAlias } from '../shared/git-remote-host-alias' +import { getProjectProviderIdentity } from '../shared/project-host-setup-projection' +import { getStoredRepoExecutionHostId } from './repo-execution-host' import { probeGitRemoteIdentity } from './repo-git-remote-identity' const NO_IDENTITY_RETRY_TTL_MS = 5 * 60 * 1000 @@ -21,8 +25,12 @@ const MAX_IDENTITY_REFRESHES_PER_SWEEP = 4 type RepoIdentityStore = { getRepos(): Repo[] - getRepo?(id: string): Repo | undefined - updateRepo(id: string, updates: Pick, 'gitRemoteIdentity'>): Repo | null + getRepo?(id: string, hostId?: ExecutionHostId): Repo | undefined + updateRepo( + id: string, + updates: Pick, 'gitRemoteIdentity' | 'repoIcon'>, + hostId?: ExecutionHostId + ): Repo | null } type EnrichmentOptions = { @@ -51,18 +59,23 @@ function getRepoLocationKey(repo: Pick repo.id === id) +function getCurrentRepo(store: RepoIdentityStore, snapshot: Repo): Repo | undefined { + const hostId = getRepoExecutionHostId(snapshot) + const found = store.getRepo?.(snapshot.id, hostId) + return found && getRepoExecutionHostId(found) === hostId + ? found + : store + .getRepos() + .find((repo) => repo.id === snapshot.id && getRepoExecutionHostId(repo) === hostId) } function isSameProbedRepo(snapshot: Repo, current: Repo | undefined): current is Repo { @@ -87,22 +100,67 @@ function shouldWriteProbedIdentity(current: Repo, probed: Repo['gitRemoteIdentit return !!probed && probed.canonicalKey !== existing.canonicalKey } +function getAutomaticGitHubIconRefresh( + current: Repo, + probed: NonNullable +): RepoIcon | undefined { + if ( + (current.upstream?.owner && current.upstream.repo) || + current.repoIcon?.type !== 'image' || + current.repoIcon.source !== 'github' + ) { + return undefined + } + const identity = getProjectProviderIdentity({ + upstream: null, + repoIcon: undefined, + gitRemoteIdentity: probed + }) + if (!identity || (identity.host && isUnresolvedSshHostAlias(identity.host))) { + return undefined + } + const icon = githubAvatarIcon(identity) + return icon.type === 'image' && + current.repoIcon.src === icon.src && + current.repoIcon.label === icon.label + ? undefined + : icon +} + function writeIdentity( store: RepoIdentityStore, snapshot: Repo, gitRemoteIdentity: Repo['gitRemoteIdentity'] ): boolean { - const current = getCurrentRepo(store, snapshot.id) - if ( - !isSameProbedRepo(snapshot, current) || - !shouldWriteProbedIdentity(current, gitRemoteIdentity) - ) { + // A peer's repo metadata must never be repaired from a client-local probe. + if (!getRepoProbeHostId(snapshot)) { return false } - return !!store.updateRepo(snapshot.id, { gitRemoteIdentity }) + const current = getCurrentRepo(store, snapshot) + if (!isSameProbedRepo(snapshot, current)) { + return false + } + const writeRemote = shouldWriteProbedIdentity(current, gitRemoteIdentity) + const icon = gitRemoteIdentity + ? getAutomaticGitHubIconRefresh(current, gitRemoteIdentity) + : undefined + // The row's own host, not the probe's: the store matches this argument against the row's stamp, + // so a `runtime:` row probed locally is only addressable here under that stamp. + const storeHostId = getRepoExecutionHostId(snapshot) + const update = (updates: Pick, 'gitRemoteIdentity' | 'repoIcon'>): Repo | null => { + return store.updateRepo(snapshot.id, updates, storeHostId) + } + if (icon) { + return !!update({ ...(writeRemote ? { gitRemoteIdentity } : {}), repoIcon: icon }) + } + return writeRemote && !!update({ gitRemoteIdentity }) } async function enrichRepoGitRemoteIdentity(store: RepoIdentityStore, repo: Repo): Promise { + const hostId = getRepoProbeHostId(repo) + if (!hostId) { + return false + } const locationKey = getRepoLocationKey(repo) const retryAfter = probeRetryAfterByLocation.get(locationKey) ?? 0 if (retryAfter > Date.now()) { @@ -119,7 +177,7 @@ async function enrichRepoGitRemoteIdentity(store: RepoIdentityStore, repo: Repo) : NO_IDENTITY_RETRY_TTL_MS const controller = new AbortController() const promise = (async () => { - const result = await probeGitRemoteIdentity(repo.path, getRepoProbeHostId(repo), { + const result = await probeGitRemoteIdentity(repo.path, hostId, { signal: controller.signal }) // Why the signal and not a catch: probeGitRemoteIdentity swallows the AbortError and RESOLVES @@ -187,7 +245,9 @@ function retireRemovedLocations(allRepos: Repo[]): void { function selectEnrichmentCandidates(store: RepoIdentityStore): Repo[] { const now = Date.now() - const repos = store.getRepos().filter((repo) => repo.kind !== 'folder') + const repos = store + .getRepos() + .filter((repo) => repo.kind !== 'folder' && getRepoProbeHostId(repo) !== null) // Why: the settled `null` marker stays a candidate on purpose — a repo that // gains a remote later must still resolve. Do not tighten this to // `=== undefined`; the retry TTL already bounds the cost and `writeIdentity` diff --git a/src/main/runtime/__fixtures__/claude-dialog-trust-workspace-answered.meta.json b/src/main/runtime/__fixtures__/claude-dialog-trust-workspace-answered.meta.json new file mode 100644 index 00000000000..ce80eebc6a7 --- /dev/null +++ b/src/main/runtime/__fixtures__/claude-dialog-trust-workspace-answered.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-25T21:10:00.000Z", + "platform": "darwin", + "command": ["claude"], + "cols": 120, + "rows": 40, + "note": "Claude Code v2.1.280, macOS arm64, first launch in an untrusted folder with an isolated CLAUDE_CONFIG_DIR (the trust answer went to a throwaway config); Down then Enter picked 'Yes, I trust this folder' and the capture ends on the idle composer; API-key auth", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/claude-dialog-trust-workspace-answered.txt b/src/main/runtime/__fixtures__/claude-dialog-trust-workspace-answered.txt new file mode 100644 index 00000000000..80b75267b38 --- /dev/null +++ b/src/main/runtime/__fixtures__/claude-dialog-trust-workspace-answered.txt @@ -0,0 +1,21 @@ +78[?25h[?25l[?2004h[?2031h[?1004h +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +Accessingworkspace: + +/private/tmp/claude-trust-review/proj + +Quicksafetycheck:Isthisaprojectyoucreatedoroneyoutrust?(Likeyourowncode,awell-knownopensource +project,orworkfromyourteam).Ifnot,takeamomenttoreviewwhat'sinthisfolderfirst. + +ClaudeCode'llbeabletoread,edit,andexecutefileshere. + +]8;id=zaxmda;https://code.claude.com/docs/en/securitySecurity guide]8;; + +❯No,exit +Yes,Itrustthisfolder + +Entertoconfirm·Esctocancel +[>0q[?u(B  No, exit ❯Yes, I trust this folder + + +[>4m   ▐▛▛█ ·▜██████· ~~ ▗▟▛███▛█▄ ▜███▘  ▝▝ ▝▝   ▐ ▝▀   ▐▛▛█ ·▜██████· ~~ ▗▟▛███▛█▄ ▜███▘  ▝▝ ▝▝   ▐ ▝▀  \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/claude-dialog-trust-workspace-narrow.meta.json b/src/main/runtime/__fixtures__/claude-dialog-trust-workspace-narrow.meta.json new file mode 100644 index 00000000000..5841c7e6694 --- /dev/null +++ b/src/main/runtime/__fixtures__/claude-dialog-trust-workspace-narrow.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-25T21:03:00.000Z", + "platform": "darwin", + "command": ["claude"], + "cols": 60, + "rows": 30, + "note": "Claude Code v2.1.280, macOS arm64, first launch in an untrusted folder with an isolated CLAUDE_CONFIG_DIR (no operator config touched); a narrow pane, where Claude wraps the dialog's opening question across lines; capture ends with the trust dialog owning the screen", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/claude-dialog-trust-workspace-narrow.txt b/src/main/runtime/__fixtures__/claude-dialog-trust-workspace-narrow.txt new file mode 100644 index 00000000000..9e15b7b1e85 --- /dev/null +++ b/src/main/runtime/__fixtures__/claude-dialog-trust-workspace-narrow.txt @@ -0,0 +1,21 @@ +78[?25h[?25l[?2004h[?2031h[?1004h +──────────────────────────────────────────────────────────── +Accessingworkspace: + +/private/tmp/claude-trust-review/proj + +Quicksafetycheck:Isthisaprojectyoucreatedorone +youtrust?(Likeyourowncode,awell-knownopensource +project,orworkfromyourteam).Ifnot,takeamomentto +reviewwhat'sinthisfolderfirst. + +ClaudeCode'llbeabletoread,edit,andexecutefiles +here. + +]8;id=zaxmda;https://code.claude.com/docs/en/securitySecurity guide]8;; + +❯No,exit +Yes,Itrustthisfolder + +Entertoconfirm·Esctocancel +[>0q[?u \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/claude-dialog-trust-workspace.meta.json b/src/main/runtime/__fixtures__/claude-dialog-trust-workspace.meta.json new file mode 100644 index 00000000000..ecfd36098d2 --- /dev/null +++ b/src/main/runtime/__fixtures__/claude-dialog-trust-workspace.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-25T20:03:23.885Z", + "platform": "darwin", + "command": ["claude", "--dangerously-skip-permissions"], + "cols": 120, + "rows": 40, + "note": "Claude Code v2.1.280, macOS arm64, first launch in an untrusted git worktree; API-key auth; capture ends with the trust dialog owning the screen", + "exitCode": 129 +} diff --git a/src/main/runtime/__fixtures__/claude-dialog-trust-workspace.txt b/src/main/runtime/__fixtures__/claude-dialog-trust-workspace.txt new file mode 100644 index 00000000000..c8eb94c6b5c --- /dev/null +++ b/src/main/runtime/__fixtures__/claude-dialog-trust-workspace.txt @@ -0,0 +1,18 @@ +78[?25h[?25l[?2004h[?2031h[?1004h +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +Accessingworkspace: + +/Users/xxxxxxxxxxxxx/orca/workspaces/repo/qa-untrusted-raw + +Quicksafetycheck:Isthisaprojectyoucreatedoroneyoutrust?(Likeyourowncode,awell-knownopensource +project,orworkfromyourteam).Ifnot,takeamomenttoreviewwhat'sinthisfolderfirst. + +ClaudeCode'llbeabletoread,edit,andexecutefileshere. + +]8;id=zaxmda;https://code.claude.com/docs/en/securitySecurity guide]8;; + +❯No,exit +Yes,Itrustthisfolder + +Entertoconfirm·Esctocancel +[>0q[?u \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0-150-1-turn.meta.json b/src/main/runtime/__fixtures__/codex-0-150-1-turn.meta.json new file mode 100644 index 00000000000..21b39d7625b --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-150-1-turn.meta.json @@ -0,0 +1,16 @@ +{ + "capturedAt": "2026-09-29T01:35:32.695Z", + "platform": "darwin", + "command": [ + "codex", + "-c", + "check_for_update_on_startup=false", + "--dangerously-bypass-approvals-and-sandbox", + "-m", + "gpt-5.6-sol" + ], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.150.1: launch, one short turn, settled idle after it. STA-8834.", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/codex-0-150-1-turn.txt b/src/main/runtime/__fixtures__/codex-0-150-1-turn.txt new file mode 100644 index 00000000000..4d4c5dacccf --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-150-1-turn.txt @@ -0,0 +1,18 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h╭───────────────────────────────────────╮│ >_ OpenAI Codex (v0.150.1) ││ ││ model: loading /model to change ││ directory: loading │╰───────────────────────────────────────╯  › Ask Codex to do anything  ? for shortcuts[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h╭──────────────────────────────────────────────────╮│ >_ OpenAI Codex (v0.150.1) ││ ││ model: loading /model to change ││ directory: ~/orca-lanes/sta8834/corpus/scratch ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯  › Ask Codex to do anything  ? for shortcuts[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;scratch[?2026h[0 q[?25h[?2026l[?2026hgpt-5.6-sol default · ~/orca-lanes/sta8834/corpus/scratch[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠋ scratch]0;⠙ scratch[?2026hMMMMMMMMM +╭────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.150.1) │ +│ │ +│ model: gpt-5.6-sol medium /model to change │ +│ directory: ~/orca-lanes/sta8834/corpus/scratch │ +│ permissions: YOLO mode │ +╰────────────────────────────────────────────────────╯ + + Tip: Try the Desktop app. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true • Booting MCP server: node_repl (0s • esc to interrupt)  › Ask Codex to do anything  gpt-5.6-sol medium · ~/orca-lanes/sta8834/corpus/scratch[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;scratch[?2026h  › Ask Codex to do anything  gpt-5.6-sol medium · ~/orca-lanes/sta8834/corpus/scratch[0 q[?25h[?2026l[?2026hMM + +• You have 3 usage limit resets available. Run /usage to use one.[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026hReply with the single wordOK.[0 q[?25h[?2026l[?2026hMMMM + + +› Reply with the single word OK. +Ask Codex to do anything[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠋ scratch[?2026h • Working (0s • esc to interrupt)  › Ask Codex to do anything  gpt-5.6-sol medium · ~/orca-lanes/sta8834/corpus/scratch[0 q[?25h[?2026l]0;⠙ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠹ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠸ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠼ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠴ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026hW[0 q[?25h[?2026l]0;⠦ scratch[?2026h[0 q[?25h[?2026l[?2026hWo[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠧ scratch[?2026h•Wor[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠇ scratch[?2026h•Work[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h•Worki[0 q[?25h[?2026l]0;⠏ scratch[?2026h[0 q[?25h[?2026l[?2026hWorkin[0 q[?25h[?2026l[?2026h•[0 q[?25h[?2026l]0;⠋ scratch[?2026hWorking[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h•Working1[0 q[?25h[?2026l]0;⠙ scratch[?2026h[0 q[?25h[?2026l[?2026hWorking[0 q[?25h[?2026l[?2026h•[0 q[?25h[?2026l]0;⠹ scratch[?2026h[0 q[?25h[?2026l[?2026hWorking[0 q[?25h[?2026l[?2026h•[0 q[?25h[?2026l]0;⠸ scratch[?2026horking[0 q[?25h[?2026l[?2026h•[0 q[?25h[?2026l[?2026hrking[0 q[?25h[?2026l]0;⠼ scratch[?2026h[0 q[?25h[?2026l[?2026h•king[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠴ scratch[?2026hing[0 q[?25h[?2026l[?2026h•[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠦ scratch[?2026hng[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠧ scratch[?2026hg[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠇ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠏ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠋ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠙ scratch[?2026h2[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠹ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠸ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠼ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠴ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026hW[0 q[?25h[?2026l]0;⠦ scratch[?2026h[0 q[?25h[?2026l[?2026hWo[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠧ scratch[?2026h•Wor[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠇ scratch[?2026h•Work[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h•Worki[0 q[?25h[?2026l]0;⠏ scratch[?2026h[0 q[?25h[?2026l[?2026hWorkin[0 q[?25h[?2026l[?2026h•[0 q[?25h[?2026l]0;⠋ scratch[?2026hWorking[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h•Working3[0 q[?25h[?2026l]0;⠙ scratch[?2026h[0 q[?25h[?2026l[?2026hWorking[0 q[?25h[?2026l[?2026h•[0 q[?25h[?2026l]0;⠹ scratch[?2026h[0 q[?25h[?2026l[?2026hWorking[0 q[?25h[?2026l[?2026h•[0 q[?25h[?2026l]0;⠸ scratch[?2026horking[0 q[?25h[?2026l[?2026h•[0 q[?25h[?2026l[?2026hrk[38;2;167;167;167;49ming[0 q[?25h[?2026l]0;⠼ scratch[?2026h[0 q[?25h[?2026l[?2026h•king[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠴ scratch[?2026hing[0 q[?25h[?2026l[?2026h•[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠦ scratch[?2026hng[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026hg[0 q[?25h[?2026l]0;⠧ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠇ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠏ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠋ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠙ scratch[?2026h4[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠹ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠸ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠼ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠴ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026hW[0 q[?25h[?2026l]0;⠦ scratch[?2026h[0 q[?25h[?2026l[?2026hWo[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠧ scratch[?2026h•Wor[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠇ scratch[?2026h•Work[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026hWorki[0 q[?25h[?2026l]0;⠏ scratch[?2026h•[0 q[?25h[?2026l[?2026hWorkin[0 q[?25h[?2026l[?2026h•[0 q[?25h[?2026l]0;⠋ scratch[?2026hWorking[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h•[0 q[?25h[?2026l]0;⠙ scratch[?2026hWorking5[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h•Working[0 q[?25h[?2026l]0;⠹ scratch[?2026h[0 q[?25h[?2026l[?2026hWorking[0 q[?25h[?2026l[?2026h•[0 q[?25h[?2026l]0;⠸ scratch[?2026horking[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h•rking[0 q[?25h[?2026l]0;⠼ scratch[?2026h[0 q[?25h[?2026l[?2026h•king[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠴ scratch[?2026h[0 q[?25h[?2026l[?2026h•ing[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠦ scratch[?2026hng[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026hg[0 q[?25h[?2026l]0;⠧ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠇ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠏ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠋ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h6[0 q[?25h[?2026l]0;⠙ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠹ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠸ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠼ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠴ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026hW[0 q[?25h[?2026l]0;⠦ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026hWo[0 q[?25h[?2026l]0;⠧ scratch[?2026h•[0 q[?25h[?2026l[?2026hWor[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠇ scratch[?2026h•Work[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026hMM + +• OK  › Ask Codex to do anything  gpt-5.6-sol medium · ~/orca-lanes/sta8834/corpus/scratch[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠏ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;scratch[?2026h[0 q[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0-155-1-timed-turn.meta.json b/src/main/runtime/__fixtures__/codex-0-155-1-timed-turn.meta.json new file mode 100644 index 00000000000..ca6db1a3254 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-155-1-timed-turn.meta.json @@ -0,0 +1,14 @@ +{ + "capturedAt": "2026-09-29T01:44:01.149Z", + "platform": "darwin", + "command": [ + "codex", + "-c", + "check_for_update_on_startup=false", + "--dangerously-bypass-approvals-and-sandbox" + ], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.155.1, default tui animations. One tool-using turn sent once the composer had been quiet 2.5s; cut 20s past the last busy frame, before exit. .timing.json holds each PTY chunk as [ms since spawn, UTF-16 length] and the prompt-submit time. STA-8834.", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/codex-0-155-1-timed-turn.timing.json b/src/main/runtime/__fixtures__/codex-0-155-1-timed-turn.timing.json new file mode 100644 index 00000000000..8258e899ce0 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-155-1-timed-turn.timing.json @@ -0,0 +1 @@ +{"promptSentAtMs":4478.2,"chunks":[[63.6,8],[63.7,47],[164.3,17],[164.5,730],[164.6,8],[164.6,78],[164.8,78],[165.4,78],[171.5,78],[172.5,78],[174.7,17],[174.8,794],[174.9,132],[175.2,79],[175.7,79],[197.3,79],[197.5,79],[203.1,79],[205.8,71],[205.8,8],[206,71],[206.2,87],[636.8,79],[641.5,79],[642.3,12],[642.6,202],[642.8,71],[642.9,8],[671.8,79],[728.1,14],[749.8,31],[749.8,767],[749.9,506],[750.9,77],[792.1,12],[793.2,77],[995.7,72],[995.7,178],[1340.2,77],[3899.1,153],[4478.5,104],[4478.5,94],[4478.6,160],[4483.3,77],[4488.7,77],[4510.6,14],[4511.7,18],[4511.9,509],[4545.3,14],[4545.4,77],[4579.4,77],[4613,77],[4646.2,14],[4646.3,77],[4679.2,77],[4712.4,77],[4721.2,77],[4732.5,77],[4741.8,77],[4755.1,14],[4755.2,77],[4756.4,30],[4763.7,132],[4798.3,77],[4831.8,77],[4865.3,30],[4865.4,77],[4898,77],[4931.8,77],[4965.6,30],[4965.7,77],[4998.3,77],[5032,77],[5066.6,30],[5066.7,77],[5100.6,111],[5134.7,77],[5168.8,30],[5168.8,77],[5202.5,111],[5235.8,77],[5269.8,30],[5269.9,77],[5304,111],[5338,77],[5372.6,30],[5372.7,111],[5406.4,77],[5440.5,77],[5474.5,30],[5474.6,111],[5508.1,77],[5542.1,30],[5542.2,82],[5542.2,8],[5576.7,111],[5610.9,77],[5645.1,30],[5645.3,77],[5678.4,111],[5712.5,77],[5745.7,30],[5745.9,77],[5779,111],[5812.7,77],[5845.4,30],[5845.6,111],[5879.6,77],[5913.5,77],[5945.9,30],[5946,111],[5980.4,77],[6014.5,77],[6048.6,30],[6048.7,77],[6082,77],[6116.5,77],[6150.1,30],[6150.2,77],[6183.5,77],[6217.5,77],[6251.7,30],[6252.1,77],[6285.1,77],[6318.6,77],[6352.7,30],[6352.9,77],[6386.1,77],[6425.6,77],[6459.8,30],[6460,77],[6493.6,77],[6527.3,90],[6561.3,30],[6561.4,77],[6594.4,77],[6627.4,77],[6661,30],[6661.2,77],[6695,77],[6729.1,77],[6763.1,30],[6763.2,77],[6797.2,77],[6831.3,77],[6865.4,30],[6865.5,77],[6898.9,77],[6933.1,77],[6967.1,30],[6967.2,77],[7001.2,77],[7035.3,77],[7068.9,30],[7069,77],[7102.2,111],[7136.6,77],[7169.9,30],[7170.3,77],[7204.3,111],[7238.4,77],[7272.3,30],[7272.3,77],[7305.3,111],[7339,77],[7373,30],[7373.2,111],[7407.1,77],[7441.1,77],[7474.7,30],[7474.9,111],[7508.5,77],[7542.6,30],[7542.8,90],[7576.9,111],[7610,77],[7644.6,30],[7644.7,77],[7679,111],[7712.9,77],[7746.6,30],[7746.8,77],[7779.2,111],[7813,77],[7847,30],[7847.1,111],[7881.2,77],[7915.3,77],[7949.3,30],[7949.4,111],[7982.8,77],[8016.8,77],[8050.1,30],[8050.1,29],[8050.1,48],[8083.8,77],[8117.9,77],[8151.5,30],[8151.5,77],[8185.7,77],[8219.4,77],[8252.2,30],[8252.3,77],[8286.4,77],[8320.4,77],[8354.4,30],[8354.5,77],[8388,77],[8422,77],[8456,30],[8456,77],[8490.2,77],[8524.5,90],[8558.4,30],[8558.6,77],[8592.8,77],[8626.1,77],[8659.8,30],[8660.1,77],[8694.5,77],[8727.9,77],[8761.8,30],[8762,77],[8796.8,77],[8830.8,77],[8864.1,30],[8864.3,77],[8897.6,77],[8931.7,77],[8965.2,30],[8965.3,77],[8999.4,77],[9032.6,77],[9066.7,30],[9066.8,77],[9101.1,111],[9135.1,77],[9169,30],[9169.2,77],[9202.6,111],[9236.7,77],[9270.7,30],[9270.8,77],[9305,111],[9339.3,77],[9373.7,30],[9373.9,111],[9407.8,77],[9441,77],[9474.5,30],[9474.6,111],[9507,77],[9540.9,90],[9574.4,30],[9574.5,111],[9608.6,77],[9642.6,30],[9642.7,77],[9676.7,111],[9710.9,77],[9744.2,30],[9744.2,77],[9778.4,111],[9812.5,77],[9846.5,30],[9846.6,111],[9880.7,77],[9914.9,77],[9948.5,30],[9948.6,111],[9982.6,77],[10015.9,77],[10049.2,30],[10049.2,77],[10082.8,69],[10082.8,8],[10116.3,77],[10149.7,30],[10149.8,77],[10183.7,77],[10218.2,77],[10251,30],[10251.1,77],[10284.1,77],[10317.7,77],[10352.3,30],[10352.3,69],[10352.4,8],[10386.5,77],[10420.5,77],[10454.8,30],[10455.1,77],[10488.6,77],[10521.9,90],[10555.8,30],[10556.1,77],[10590.5,77],[10624.7,77],[10658.7,30],[10658.9,77],[10692.4,69],[10692.5,8],[10726.2,77],[10760.1,30],[10760.2,77],[10794.4,77],[10828.5,77],[10861.6,30],[10861.8,77],[10894.7,77],[10928.6,77],[10962.6,30],[10962.8,77],[10995.4,77],[11029.6,77],[11063.4,30],[11063.5,77],[11097.3,111],[11131.7,77],[11165,30],[11165.1,77],[11199.2,111],[11233.1,77],[11266.3,30],[11266.4,77],[11299.6,111],[11333.2,77],[11367.2,30],[11367.3,77],[11401.5,111],[11435.5,77],[11469.4,30],[11469.5,111],[11503.6,77],[11537.7,90],[11571.7,30],[11571.8,111],[11606,77],[11639.6,77],[11672.8,30],[11672.9,111],[11695.8,77],[11730.1,77],[11764.1,30],[11764.2,111],[11798.4,77],[11832.2,77],[11864.9,30],[11865,111],[11898.6,77],[11932.3,77],[11966.4,30],[11966.5,97],[11966.5,14],[12000.2,77],[12033.5,77],[12067.5,30],[12067.6,77],[12101.2,77],[12135.4,69],[12135.5,8],[12168.8,30],[12168.9,69],[12169,8],[12203.1,77],[12237.2,77],[12270.6,30],[12270.8,63],[12270.8,14],[12304.9,77],[12339.7,77],[12373.6,30],[12373.8,77],[12406.9,77],[12441.1,77],[12475.2,30],[12475.4,77],[12509.1,77],[12543.1,30],[12543.4,90],[12577.4,77],[12611.4,77],[12644.6,30],[12644.8,77],[12678.6,77],[12712.7,77],[12746.3,30],[12746.4,77],[12779.6,77],[12812.5,77],[12846.1,30],[12846.2,77],[12880.3,77],[12914.3,77],[12948.3,30],[12948.4,77],[12982.7,77],[13016.9,77],[13049.9,30],[13050.1,77],[13083.6,111],[13117.1,77],[13151.1,30],[13151.2,77],[13184.2,111],[13217.4,77],[13250.7,30],[13250.8,77],[13284.9,111],[13301.8,77],[13336.6,77],[13370.1,30],[13370.1,111],[13403.8,77],[13438.1,77],[13472,30],[13472.2,111],[13505.2,77],[13539.6,90],[13572.7,30],[13572.8,111],[13607.6,77],[13641.8,77],[13675.5,30],[13675.6,111],[13709.7,77],[13742.2,30],[13742.3,63],[13742.3,14],[13776.5,111],[13810.3,77],[13844.3,30],[13844.6,77],[13877.7,111],[13911.7,77],[13944.4,30],[13944.6,111],[13978,77],[14012.1,77],[14046.6,30],[14046.8,77],[14080.9,77],[14115,77],[14148.5,30],[14148.7,63],[14148.7,14],[14182.8,77],[14217,77],[14250.6,30],[14250.7,77],[14284.9,77],[14319,77],[14353.5,30],[14353.5,77],[14387.7,77],[14421.9,77],[14456,30],[14456.2,77],[14490.3,77],[14523.9,119],[14558.3,30],[14558.3,77],[14592.5,77],[14626.6,77],[14660,30],[14660,77],[14694.1,77],[14728,77],[14761.6,30],[14761.6,69],[14761.7,8],[14771.2,73],[14771.3,192],[14805.5,77],[14839.7,77],[14873.5,30],[14873.7,77],[14907.8,77],[14941.4,77],[14974,30],[14974.1,77],[15007.3,77],[15040.5,77],[15074.5,30],[15074.6,77],[15106.9,111],[15140.6,77],[15172.8,30],[15172.9,77],[15180,14],[15182.8,41],[15183,177],[15192.7,77],[15226.5,77],[15260.2,41],[15260.2,77],[15294.3,111],[15328.5,77],[15361.7,41],[15361.9,63],[15362,14],[15395.9,111],[15429.6,77],[15463.6,41],[15463.8,77],[15496.8,111],[15531.1,90],[15565,41],[15565.2,111],[15599.4,77],[15632.9,77],[15666.2,41],[15666.4,111],[15700.3,77],[15734.5,77],[15768.5,41],[15768.6,111],[15802.6,77],[15835.4,77],[15869.2,41],[15869.3,111],[15903.2,77],[15935.8,77],[15969.9,41],[15970,111],[16003.5,77],[16037.5,77],[16071.6,41],[16071.8,77],[16105.7,77],[16138.7,77],[16172.9,41],[16172.9,77],[16207,77],[16241.4,77],[16275.4,41],[16275.4,69],[16275.5,8],[16309.6,77],[16343.6,41],[16343.8,77],[16377.8,77],[16411.9,77],[16445.8,41],[16445.9,77],[16480.1,77],[16513.3,90],[16547.1,41],[16547.2,77],[16580.9,77],[16615.2,77],[16648.6,41],[16648.7,77],[16682,77],[16715.1,77],[16748.6,41],[16748.7,77],[16781.9,77],[16816.1,77],[16850.1,41],[16850.2,77],[16884.8,77],[16919,77],[16952.9,41],[16953,77],[16987.7,77],[17021.1,77],[17054.5,41],[17054.5,69],[17054.6,8],[17088.6,103],[17088.7,8],[17122.5,77],[17155.9,41],[17156,77],[17190.1,111],[17224.3,77],[17258.3,41],[17258.4,69],[17258.4,8],[17292.3,111],[17326.3,77],[17360.7,41],[17360.8,77],[17395.7,111],[17429.9,77],[17463.9,41],[17464.1,77],[17498,111],[17531.3,90],[17564.9,41],[17565.1,111],[17599.1,77],[17633.1,77],[17666.7,41],[17666.7,111],[17700.9,77],[17734.8,77],[17768.8,41],[17768.9,103],[17769,8],[17803,77],[17837.1,77],[17871.1,41],[17871.1,111],[17904.8,77],[17939,77],[17973.1,41],[17973.2,111],[18006.8,77],[18040.7,77],[18074.5,41],[18074.5,63],[18074.5,14],[18108.6,77],[18142,41],[18142.1,77],[18175.5,77],[18209.1,69],[18209.2,8],[18242.3,41],[18242.4,77],[18276.6,77],[18310.2,77],[18344.2,41],[18344.3,77],[18378.4,77],[18412.6,77],[18446.6,41],[18446.7,77],[18479.9,69],[18479.9,8],[18513.5,90],[18546.9,41],[18547,77],[18576.3,77],[18609.3,77],[18642.9,41],[18643,77],[18677.1,77],[18711.2,77],[18744.2,41],[18744.3,77],[18777.3,77],[18787.6,18],[18787.8,759],[18797.1,77],[18813.8,77],[18823.3,77],[18856.4,41],[18856.4,77],[18890.3,77],[18924.4,77],[18958.4,41],[18958.5,77],[18992.4,77],[19026.6,77],[19060.6,41],[19060.7,77],[19094.9,111],[19129,77],[19162.9,41],[19163,69],[19163,8],[19197.1,111],[19231.5,77],[19264.7,41],[19264.9,69],[19265,8],[19299.2,111],[19332.9,77],[19366.9,41],[19367.1,29],[19367.1,48],[19401.3,111],[19435.4,77],[19469.5,41],[19469.6,63],[19469.6,48],[19503.8,77],[19537,90],[19571,41],[19571.1,111],[19605.3,77],[19639.3,77],[19672.5,41],[19672.5,111],[19716,77],[19746.1,41],[19746.1,77],[19779.7,111],[19813.8,77],[19847.7,41],[19847.7,97],[19847.8,14],[19881.8,77],[19916,77],[19950.1,41],[19950.1,103],[19950.2,8],[19984.4,77],[20018.7,77],[20052.7,41],[20052.9,77],[20087.2,77],[20121.5,77],[20155.6,41],[20155.8,69],[20155.8,8],[20189,77],[20223.5,77],[20257.4,41],[20257.6,63],[20257.6,14],[20291.2,69],[20291.3,8],[20324.7,77],[20358.1,41],[20358.2,77],[20392.4,77],[20426.5,77],[20460.6,41],[20460.7,77],[20494.8,77],[20528.9,90],[20562.9,41],[20563,77],[20597.2,77],[20630.5,77],[20664.6,41],[20664.7,77],[20699.5,77],[20733.6,77],[20766.9,41],[20767,77],[20800.2,77],[20834.6,77],[20867.8,41],[20868,77],[20901.6,77],[20935.6,77],[20968.8,41],[20969,29],[20969.1,48],[21003.3,69],[21003.4,8],[21037.4,77],[21071.4,41],[21071.6,69],[21071.6,8],[21105.8,111],[21139.9,77],[21174,41],[21174.1,77],[21207.6,111],[21218.4,77],[21251.8,41],[21251.9,77],[21286.2,111],[21320.1,77],[21354.2,41],[21354.3,63],[21354.3,14],[21388.7,111],[21422.8,77],[21456.1,41],[21456.2,77],[21490.3,111],[21524.4,90],[21558.6,41],[21558.9,77],[21593.2,111],[21627.5,77],[21661.5,41],[21661.6,63],[21661.6,48],[21695.9,77],[21730,77],[21763.4,41],[21763.4,103],[21763.5,8],[21797.6,77],[21831.4,77],[21865.4,41],[21865.4,97],[21865.4,14],[21899.6,77],[21933.7,77],[21967.8,41],[21967.8,111],[22002,77],[22036.1,77],[22070.2,41],[22070.2,29],[22070.3,48],[22104.2,77],[22138.5,77],[22171.9,41],[22172.1,77],[22206.3,77],[22240.6,77],[22273.1,41],[22273.2,77],[22307.3,77],[22341.1,77],[22375,41],[22375.1,69],[22375.1,8],[22409.4,77],[22443.5,41],[22443.6,63],[22443.7,14],[22477.9,77],[22511.2,90],[22544.7,41],[22544.8,77],[22579,77],[22612.5,77],[22646.4,41],[22646.5,77],[22679.8,77],[22713.9,77],[22748.5,41],[22748.6,69],[22748.6,8],[22782.7,77],[22817,77],[22850.2,41],[22850.3,63],[22850.3,14],[22883.7,77],[22894.2,18],[22894.2,117],[22894.3,673],[22894.3,8],[22928.5,77],[22962,41],[22962,77],[22996.5,77],[23030.5,77],[23064.6,41],[23064.7,29],[23064.7,48],[23098.2,111],[23133,77],[23166.4,41],[23166.5,77],[23193.2,18],[23193.2,65],[23193.3,561],[23193.3,8],[23295.2,41],[23397.5,41],[23499.7,41],[23601.5,41],[23703.1,41],[23805.7,41],[23908.4,41],[24010.6,41],[24048,41],[24048.1,245],[24057.7,77],[24083.3,77],[24083.7,39],[24092.3,73],[24092.4,108],[24092.4,14]]} diff --git a/src/main/runtime/__fixtures__/codex-0-155-1-timed-turn.txt b/src/main/runtime/__fixtures__/codex-0-155-1-timed-turn.txt new file mode 100644 index 00000000000..6064224f724 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-155-1-timed-turn.txt @@ -0,0 +1,26 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h╭───────────────────────────────────────╮│ >_ OpenAI Codex (v0.155.1) ││ ││ model: loading /model to change ││ directory: loading │╰───────────────────────────────────────╯  › Ask Codex to do anything  ? for shortcuts[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l[?2026h╭──────────────────────────────────────────────────╮│ >_ OpenAI Codex (v0.155.1) ││ ││ model: loading /model to change ││ directory: ~/orca-lanes/sta8834/corpus/scratch ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯  › Ask Codex to do anything  ? for shortcuts[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l]0;scratch[?2026hgpt-6-sol default · ~/orca-lanes/sta8834/corpus/scratch[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l]0;⠋ scratch]0;⠙ scratch[?2026hMMMMMMMMM +╭──────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.155.1) │ +│ │ +│ model: gpt-6-sol medium /model to change │ +│ directory: ~/orca-lanes/sta8834/corpus/scratch │ +│ permissions: YOLO mode │ +╰──────────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage.  › Ask Codex to do anything  gpt-6-sol medium · ~/orca-lanes/sta8834/corpus/scratch[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;scratch[?2026h[0 q [?25h[?2026l[?2026hMM + +• You have 3 usage limit resets available. Run /usage to use one.[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026hList the files here and summarizein2bullets.[0 q [?25h[?2026l[?2026hMMMM + + +› List the files here and summarize in 2 bullets. +Ask Codex to do anything[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ scratch[?2026h • Working (0s • esc to interrupt)  › Ask Codex to do anything  gpt-6-sol medium · ~/orca-lanes/sta8834/corpus/scratch[0 q [?25h[?2026l]0;⠏ scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ scratch[?2026h[0 q [?25h[?2026l]0;⠙ renaming... ⠙ | scratch[?2026h · renaming... ⠙[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ renaming... ⠹ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ renaming... ⠸ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ renaming... ⠼ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ renaming... ⠴ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ renaming... ⠦ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ renaming... ⠧ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ renaming... ⠇ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠏ renaming... ⠏ | scratch[?2026h1[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ renaming... ⠋ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ renaming... ⠙ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ renaming... ⠹ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ renaming... ⠸ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ renaming... ⠼ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ renaming... ⠴ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ renaming... ⠦ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ renaming... ⠧ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ renaming... ⠇ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h2[0 q [?25h[?2026l]0;⠏ renaming... ⠏ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ renaming... ⠋ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ renaming... ⠙ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ renaming... ⠹ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ renaming... ⠸ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ renaming... ⠼ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ renaming... ⠴ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ renaming... ⠦ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ renaming... ⠧ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ renaming... ⠇ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠏ renaming... ⠏ | scratch[?2026h3[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ renaming... ⠋ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ renaming... ⠙ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ renaming... ⠹ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ renaming... ⠸ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ renaming... ⠼ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ renaming... ⠴ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ renaming... ⠦ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ renaming... ⠧ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ renaming... ⠇ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h4[0 q [?25h[?2026l]0;⠏ renaming... ⠏ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ renaming... ⠋ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ renaming... ⠙ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ renaming... ⠹ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ renaming... ⠸ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ renaming... ⠼ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ renaming... ⠴ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ renaming... ⠦ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ renaming... ⠧ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ renaming... ⠇ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h5[0 q [?25h[?2026l]0;⠏ renaming... ⠏ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ renaming... ⠋ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ renaming... ⠙ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ renaming... ⠹ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ renaming... ⠸ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ renaming... ⠼ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ renaming... ⠴ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ renaming... ⠦ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ renaming... ⠧ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ renaming... ⠇ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h6[0 q [?25h[?2026l]0;⠏ renaming... ⠏ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ renaming... ⠋ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ renaming... ⠙ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ renaming... ⠹ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ renaming... ⠸ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ renaming... ⠼ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ renaming... ⠴ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ renaming... ⠦ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ renaming... ⠧ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ renaming... ⠇ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h7[0 q [?25h[?2026l]0;⠏ renaming... ⠏ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ renaming... ⠋ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ renaming... ⠙ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ renaming... ⠹ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ renaming... ⠸ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ renaming... ⠼ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ renaming... ⠴ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ renaming... ⠦ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ renaming... ⠧ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ renaming... ⠇ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠏ renaming... ⠏ | scratch[?2026h8[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ renaming... ⠋ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ renaming... ⠙ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ renaming... ⠹ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ renaming... ⠸ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ renaming... ⠼ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ renaming... ⠴ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ renaming... ⠦ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ renaming... ⠧ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ renaming... ⠇ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h9[0 q [?25h[?2026l]0;⠏ renaming... ⠏ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ renaming... ⠋ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ renaming... ⠙ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ renaming... ⠹ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ renaming... ⠸ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ renaming... ⠼ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ renaming... ⠴ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ renaming... ⠦ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ renaming... ⠧ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ renaming... ⠇ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h10s • esc to interupt)[0 q [?25h[?2026l]0;⠏ renaming... ⠏ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ renaming... ⠋ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ renaming... ⠙ | scratch[?2026h[0 q [?25h[?2026l[?2026hMM + +• I’ll inspect the current directory and summarize what’s there in two bullets.[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ renaming... ⠹ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ renaming... ⠸ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ renaming... ⠼ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ renaming... ⠴ | scratch[?2026h[0 q [?25h[?2026l]0;⠴ scratch]0;⠴ List and summarize files | scratch[?2026h•List ad summarize files[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h1[0 q [?25h[?2026l]0;⠏ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h2[0 q [?25h[?2026l]0;⠏ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h3[0 q [?25h[?2026l]0;⠏ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h4[0 q [?25h[?2026l]0;⠏ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h • Explored └ List rg --files -g '*' -g '!*/.git/*' • Working (14s • esc to interrupt)  › Ask Codex to do anything  gpt-6-sol medium · ~/orca-lanes/sta8834/corpus/scratch · List and summarize files[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h5[0 q [?25h[?2026l]0;⠏ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h6[0 q [?25h[?2026l]0;⠏ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h7[0 q [?25h[?2026l]0;⠏ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h8[0 q [?25h[?2026l]0;⠏ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026hMMM + +• Explored + └ List rg --files -g '*' -g '!*/.git/*' • Working (18s • esc to interrupt)  › Ask Codex to do anything  gpt-6-sol medium · ~/orca-lanes/sta8834/corpus/scratch · List and summarize files[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026hMM + +• - README.md and notes.txt are the two working files.  › Ask Codex to do anything  gpt-6-sol medium · ~/orca-lanes/sta8834/corpus/scratch · List and summarize files[0 q [?25h[?2026l]0;⠦ List and summarize files | scratch]0;⠧ List and summarize files | scratch]0;⠇ List and summarize files | scratch]0;⠏ List and summarize files | scratch]0;⠋ List and summarize files | scratch]0;⠙ List and summarize files | scratch]0;⠹ List and summarize files | scratch]0;⠸ List and summarize files | scratch]0;⠼ List and summarize files | scratch[?2026hM + - A .git/ directory is present, containing repository metadata and history.[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;List and summarize files | scratch[?2026hMM + + done 9:43 PM[0 q [?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0-157-1-update-dialog.meta.json b/src/main/runtime/__fixtures__/codex-0-157-1-update-dialog.meta.json new file mode 100644 index 00000000000..f43deccb966 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-157-1-update-dialog.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-29T01:41:43.328Z", + "platform": "darwin", + "command": ["codex", "--no-daemon", "--dangerously-bypass-approvals-and-sandbox"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.157.1: launch with the update check on; the update dialog owns the screen. STA-8834.", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/codex-0-157-1-update-dialog.txt b/src/main/runtime/__fixtures__/codex-0-157-1-update-dialog.txt new file mode 100644 index 00000000000..4bd32773735 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-157-1-update-dialog.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭───────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.1) ││ ││ model: loading /model to change ││ directory: loading │╰───────────────────────────────────────╯›Ask Codex to do anything?forshortcuts[0 q╭[?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l───────────╮ │ │ loading /model to change │ ~/orca-lanes/sta8834/corpus/scratch ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l  Updat available · 0.157.1→0.158.0 Releasenotes:]8;;https://github.com/openai/codex/releases/latesthttps://github.com/openai/codex/releases/latest]8;; › 1. Update now (runs `npm install -g @openai/codex`)  2. Skip 3. Skip until next versionenter continue · esc skip [?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0-158-0-approval.meta.json b/src/main/runtime/__fixtures__/codex-0-158-0-approval.meta.json new file mode 100644 index 00000000000..a333bd29473 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-158-0-approval.meta.json @@ -0,0 +1,18 @@ +{ + "capturedAt": "2026-09-29T01:43:13.919Z", + "platform": "darwin", + "command": [ + "codex", + "--no-daemon", + "-c", + "check_for_update_on_startup=false", + "-a", + "on-request", + "-s", + "read-only" + ], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.158.0: read-only sandbox with on-request approvals; a command approval dialog owns the screen at the end. STA-8834.", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/codex-0-158-0-approval.txt b/src/main/runtime/__fixtures__/codex-0-158-0-approval.txt new file mode 100644 index 00000000000..925a5165fc5 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-158-0-approval.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l>_ OpenAI Codex (v0.158.0)loadingAll right. What have you got?⣀⣤⣤⣤⣀⣀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄⣀⣾⣿⣿⠃⣀⣴⣾⣿⣿⡿⠟⠋⣀⡀⠈⠙⢿⣿⣿⣧⣀⣶⣿⣿⣿⣿⡇⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇⣰⣿⣿⡿⢿⣿⣿⡇⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷⢰⣿⣿⡟⠁⢸⣿⣿⡇⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀⣾⣿⣿⠃⢸⣿⣿⡇⢸⣿⣿⠋⠁⠈⠙⢻⣿⣿⣿⣶⣤⡀⠹⢿⣿⣷⡄⢸⣿⣿⣇⠸⣿⣿⣷⣦⣼⣿⣿⢸⣿⣿⠻⢿⣿⣿⡇⠘⣿⣿⣿⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀⢀⣠⣼⣿⣿⣿⣿⡇⣿⣿⣿⠇⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿⣿⣿⡇⣠⣿⣿⣿⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿⣿⣿⣷⣴⣿⣿⣿⠃⠘⣿⣿⣷⠈⠛⢿⣿⣿⣿⠿⠋⠉⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠻⣿⣿⣿⣤⣀⠈⠉⢀⣤⣶⣿⣿⣿⠿⠟⠁⣰⣿⣿⡟⠋⠁⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁⣠⣼⣿⣿⡟⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏⠈⠙⠛⠿⠿⠿⠿⠛⠉›Ask Codex to do anything?forshortcuts[0 q [?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l~/orca-lanes/sta8834/corpus/scratch[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;scratch[?2026h[?25lGPT-6-Sol default·~/orca-lanes/sta8834/corpus/scratch[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ scratch]0;⠙ scratch[?2026h[?25lmdim · ~/orca-lanes/sta834/corpus/scratch[?25h[?2026l[?2026h[?25h[?2026l]0;scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25lRun `touch approved.txt`.[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l› Run`touchapproved.txt`.Ask Codex to do anything?forshortcuts[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ scratch[?2026h[?25l•Working(0s • esc to interrupt)[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ scratch[?2026h[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25l·⠙[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25l1[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25h[?2026l[?2026h[?25l2[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25l3[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25h[?2026l[?2026h[?25l4[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25h[?2026l[?2026h[?25l5[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25l6[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25h[?2026l[?2026h[?25l7[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l8[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25l9[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25l• I [?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l’ll[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25lcreate[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25l⠴[?25h[?2026l[?2026h[?25lthe[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25l⠦[?25h[?2026l[?2026h[?25lfile[?25h[?2026l[?2026h[?25linthecurrent[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25l⠧[?25h[?2026l[?2026h[?25lworkspace;[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25l⠇[?25h[?2026l[?2026h[?25lthis[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25lrequires[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25l⠋[?25h[?2026l[?2026h[?25lwrite[?25h[?2026l[?2026h[?25laccess[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25l⠙[?25h[?2026l[?2026h[?25lbeyond[?25h[?2026l[?2026h[?25lthe[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25l⠹[?25h[?2026l[?2026h[?25lread-only[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25lsandbox.[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25l⠼[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25l⠴[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25l⠦[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25l⠧[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25l⠇[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25l⠏[?25h[?2026l[?2026h[?25l•Working(11s • esc to interrupt)[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25l2[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25h[?2026l[?2026h[?25l3[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;[ . ] Action Required | ⠸ | scratch[?2026h[?25l•Runningtouch approved.txtWould you like to run the following command?Environment:localReason:May I create approved.txt in the current workspace as requested?$touch approved.txt › 1. Yes, proceed (y)  2. Yes, and don't ask againforcommandsthatstartwith`touchapproved.txt`(p)3.No,andtellCodexwhattododifferently(esc)Press enter to confirm or esc to cancel [?2026l]0;[ . ] Action Required | ⠼ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠴ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠦ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠧ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠇ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠏ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠋ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠙ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠹ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠸ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠼ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠴ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠦ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠧ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠇ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠏ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠋ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠙ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠹ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠸ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠼ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠴ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠦ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠧ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠇ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠏ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠋ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠙ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠹ | scratch]0;[ ! ] Action Required | ⠸ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠼ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠴ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠦ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠧ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠇ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠏ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠋ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠙ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠹ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠸ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠼ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠴ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠦ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠧ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠇ | scratch[?2026h[?2026l]0;[ . ] Action Required | scratch]0;[ . ] Action Required | Create approved.txt | scratch[?2026h[?2026l]0;[ ! ] Action Required | Create approved.txt | scratch]0;[ . ] Action Required | Create approved.txt | scratch]0;[ ! ] Action Required | Create approved.txt | scratch]0;[ . ] Action Required | Create approved.txt | scratch]0;[ ! ] Action Required | Create approved.txt | scratch]0;[ . ] Action Required | Create approved.txt | scratch]0;[ ! ] Action Required | Create approved.txt | scratch]0;[ . ] Action Required | Create approved.txt | scratch]0;[ ! ] Action Required | Create approved.txt | scratch]0;[ . ] Action Required | Create approved.txt | scratch]0;[ ! ] Action Required | Create approved.txt | scratch]0;[ . ] Action Required | Create approved.txt | scratch]0;[ ! ] Action Required | Create approved.txt | scratch \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0-158-0-timed-turn.meta.json b/src/main/runtime/__fixtures__/codex-0-158-0-timed-turn.meta.json new file mode 100644 index 00000000000..2c3defda30a --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-158-0-timed-turn.meta.json @@ -0,0 +1,15 @@ +{ + "capturedAt": "2026-09-29T01:46:36.717Z", + "platform": "darwin", + "command": [ + "codex", + "--no-daemon", + "-c", + "check_for_update_on_startup=false", + "--dangerously-bypass-approvals-and-sandbox" + ], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.158.0, default tui animations. One tool-using turn sent once the composer had been quiet 2.5s; cut 20s past the last busy frame, before exit. .timing.json holds each PTY chunk as [ms since spawn, UTF-16 length] and the prompt-submit time. STA-8834.", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/codex-0-158-0-timed-turn.timing.json b/src/main/runtime/__fixtures__/codex-0-158-0-timed-turn.timing.json new file mode 100644 index 00000000000..02745d043e1 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-158-0-timed-turn.timing.json @@ -0,0 +1 @@ +{"promptSentAtMs":7226.9,"chunks":[[70.2,28],[70.4,27],[321.3,80],[321.5,9],[324.7,14],[324.8,578],[325.8,494],[327,43],[329.1,43],[331.5,43],[333.5,43],[335.5,43],[340.4,216],[340.5,8],[342.3,43],[344,43],[346,43],[372.5,43],[374.1,43],[385.8,43],[1330.3,43],[1332.2,35],[1332.2,8],[1334.4,43],[1336.3,43],[1338.5,43],[1340.5,43],[2820.3,43],[2829.5,43],[2831.2,12],[2833.5,43],[2835.6,169],[2835.7,8],[2842.6,43],[2866,43],[2922.5,14],[2944.6,14],[2946.2,14],[2946.2,180],[2947.8,43],[2949.3,43],[3006.8,12],[3009.4,43],[3941,43],[4089.1,43],[6627.2,43],[6627.3,377],[6627.3,14],[6627.4,35],[6627.4,8],[6627.6,43],[6627.7,43],[6627.8,43],[6627.9,43],[6628,43],[6628.2,43],[6628.3,43],[6628.4,35],[6628.4,8],[6628.5,35],[6628.5,8],[6628.6,43],[6628.7,43],[6628.9,43],[6629,43],[6629.1,43],[6629.2,43],[6629.3,43],[6629.4,35],[6629.4,8],[6629.5,43],[6629.7,43],[6629.8,43],[6629.9,35],[6629.9,8],[6630,35],[6630,8],[6630.1,43],[6630.2,35],[6630.3,8],[6630.4,43],[6630.5,35],[6630.5,8],[6630.6,35],[6630.6,8],[6630.7,43],[6630.8,35],[6630.9,8],[6630.9,43],[6631.1,43],[6631.2,35],[6631.2,8],[6631.3,35],[6631.3,8],[6631.4,35],[6631.4,8],[6631.5,35],[6631.5,8],[6631.6,35],[6631.6,8],[6631.7,35],[6631.7,8],[6631.9,35],[6631.9,8],[6632,35],[6632,8],[6632.1,43],[6632.2,35],[6632.2,8],[6632.3,43],[6632.4,43],[6632.5,43],[6659.2,117],[6659.3,8],[7227.9,44],[7228.6,138],[7228.7,14],[7229,161],[7229.2,8],[7239.4,43],[7247.7,43],[7271.8,14],[7273,163],[7273.1,8],[7306.6,43],[7339.4,14],[7339.5,35],[7339.6,8],[7374.3,43],[7407.9,43],[7441.3,14],[7441.5,43],[7474.6,43],[7490.5,43],[7513.7,43],[7535.2,14],[7535.3,35],[7535.3,8],[7536.4,18],[7545.7,89],[7578.7,43],[7611.6,43],[7646,18],[7646.1,43],[7679.3,43],[7713.6,43],[7747,18],[7747.2,35],[7747.2,8],[7780,43],[7813.5,43],[7846.7,18],[7846.8,75],[7846.9,8],[7879.7,43],[7912.9,43],[7946.5,18],[7946.7,83],[7980.7,43],[8015.1,43],[8048.9,18],[8049.1,83],[8083.6,43],[8116.9,43],[8150.3,18],[8150.5,69],[8150.6,14],[8185.3,43],[8219.4,35],[8219.5,8],[8253.4,18],[8253.5,75],[8253.5,8],[8287.5,62],[8321.7,83],[8354.9,18],[8355,43],[8388.5,43],[8422.7,83],[8456.2,18],[8456.4,35],[8456.4,8],[8489.6,43],[8522.9,83],[8534.4,18],[8534.6,43],[8568.8,43],[8603.1,43],[8637.1,18],[8637.4,69],[8637.4,14],[8672.2,43],[8706.4,83],[8738.4,18],[8738.6,43],[8772.8,43],[8806.7,43],[8840,18],[8840.2,35],[8840.3,8],[8874.6,43],[8908,43],[8941.4,18],[8941.7,35],[8941.7,8],[8976.8,43],[9010.8,43],[9044.4,18],[9044.7,43],[9077.8,43],[9112.4,43],[9146.4,18],[9146.5,35],[9146.5,8],[9179.7,43],[9213.5,43],[9247,18],[9247.2,35],[9247.2,8],[9281.4,54],[9281.4,8],[9315.9,43],[9349.2,18],[9349.4,35],[9349.4,8],[9383.7,43],[9418,43],[9446.6,18],[9446.8,90],[9447,8],[9456.9,43],[9493.4,63],[9502.6,60],[9519.3,82],[9534.9,18],[9535.1,52],[9535.2,8],[9555.5,58],[9555.7,8],[9575.4,61],[9597.6,59],[9619.5,64],[9629.8,51],[9629.9,8],[9643,18],[9643.2,52],[9643.3,8],[9668.1,56],[9668.4,8],[9684.5,58],[9786.6,18],[9788.1,72],[9788.2,8],[9889.3,18],[9890.6,80],[9918.4,171],[9953.3,101],[9985.2,43],[10018.5,43],[10051.6,18],[10051.8,75],[10051.8,8],[10085.6,43],[10119.4,43],[10152.9,18],[10153.1,75],[10153.1,8],[10186.5,43],[10220.8,43],[10254.6,18],[10254.8,69],[10254.9,14],[10289.8,54],[10290,8],[10324.3,83],[10357.6,18],[10357.9,43],[10391.4,43],[10424,83],[10457.5,18],[10457.7,35],[10457.7,8],[10491,43],[10524.9,83],[10558.6,18],[10559,35],[10559.1,8],[10592.5,43],[10626.9,83],[10660.9,18],[10661.1,43],[10694.8,43],[10729.4,83],[10763.4,18],[10763.5,43],[10798.4,43],[10833.1,18],[10833.2,43],[10866.5,43],[10900.5,43],[10933.6,18],[10933.7,43],[10966.9,43],[11000.6,43],[11034.6,18],[11034.9,43],[11069.1,43],[11103,43],[11137.1,18],[11137.2,35],[11137.3,8],[11169.2,43],[11199,43],[11231.3,18],[11231.4,43],[11260.7,338],[11260.8,8],[11270.5,43],[11286.1,62],[11296.4,72],[11296.5,8],[11330.4,43],[11364.5,18],[11364.7,43],[11398.1,43],[11432.1,18],[11432.3,35],[11432.3,8],[11465.9,43],[11498.9,43],[11532.8,18],[11533,35],[11533,8],[11566.1,43],[11598.9,43],[11633,18],[11633.2,35],[11633.2,8],[11667.1,43],[11700.9,43],[11735,18],[11735.2,43],[11769.5,43],[11802.8,43],[11835.7,18],[11835.9,35],[11835.9,8],[11869.6,83],[11903.7,43],[11937.8,18],[11938.1,35],[11938.1,8],[11961.5,119],[11962.2,14],[11965.3,41],[11970.6,103],[12004.5,43],[12038.7,41],[12038.9,75],[12039,8],[12073.3,43],[12107.2,43],[12141,41],[12141.3,14],[12141.4,69],[12174.7,43],[12208.4,43],[12241.5,41],[12241.8,14],[12241.8,69],[12276.1,62],[12309.7,43],[12343.6,41],[12343.7,69],[12343.8,14],[12378,43],[12412.2,43],[12445.3,41],[12445.4,75],[12445.4,8],[12479.1,43],[12512.6,75],[12512.7,8],[12545.6,41],[12545.8,35],[12545.9,8],[12579.3,43],[12612.8,83],[12646.7,41],[12647,35],[12647,8],[12664.5,43],[12698.5,43],[12731.6,41],[12732,14],[12732,69],[12766.1,43],[12799.5,43],[12833.8,41],[12834.3,35],[12834.3,8],[12867.9,43],[12902.4,43],[12935.2,41],[12935.7,35],[12935.8,8],[12969.7,43],[13003.8,43],[13037.5,41],[13037.7,43],[13071.9,43],[13104.8,43],[13137.8,41],[13138,43],[13171.4,43],[13205.7,43],[13240.2,41],[13240.3,43],[13274.4,62],[13307.9,43],[13341.3,41],[13341.5,35],[13341.5,8],[13376.5,43],[13409.8,43],[13443.1,41],[13443.3,43],[13476.8,43],[13510.5,43],[13544.3,41],[13544.5,35],[13544.5,8],[13578.7,43],[13612.2,43],[13645.2,41],[13645.4,35],[13645.4,8],[13679.6,43],[13714.4,43],[13747.5,41],[13747.8,35],[13747.9,8],[13781.8,43],[13814.6,43],[13848,41],[13848.3,69],[13848.3,14],[13881.6,43],[13916.7,43],[13950.2,41],[13950.4,69],[13950.4,14],[13984.6,43],[14018.3,43],[14052.6,41],[14052.7,69],[14052.7,14],[14086.4,43],[14120.6,43],[14154.9,41],[14155,69],[14155,14],[14181.8,43],[14211.3,43],[14244.5,41],[14244.7,75],[14244.7,8],[14277.6,54],[14277.7,8],[14287.3,273],[14287.3,8],[14305.4,43],[14315.6,43],[14349.5,41],[14349.6,75],[14349.7,8],[14383.6,43],[14417.1,75],[14417.2,8],[14451.1,41],[14451.3,35],[14451.3,8],[14485.5,43],[14520.1,75],[14520.2,8],[14554.3,41],[14554.5,35],[14554.6,8],[14589.7,43],[14624.4,83],[14656.7,41],[14657,35],[14657,8],[14691.2,43],[14724.7,83],[14758.2,41],[14758.3,35],[14758.3,8],[14792.3,43],[14826.5,43],[14859.9,41],[14860,35],[14860,8],[14893.7,43],[14927.8,43],[14961.2,41],[14961.4,43],[14971.2,43],[14981.8,89],[14981.8,14],[14992.9,54],[14993,8],[15014.7,52],[15014.8,8],[15038.6,41],[15038.8,93],[15038.9,14],[15060.8,50],[15060.8,8],[15078.9,68],[15078.9,8],[15100.6,56],[15100.7,8],[15120.6,50],[15120.6,8],[15148.5,41],[15148.8,56],[15148.8,8],[15158.6,51],[15158.6,8],[15211.6,65],[15223,53],[15223.1,8],[15232,41],[15232.3,53],[15232.3,8],[15333.7,41],[15435.2,41],[15436.8,14],[15436.9,136],[15447.1,59],[15485.9,43],[15524.5,43],[15534.6,41],[15534.7,43],[15551,43],[15551.2,39],[15559.7,180]]} diff --git a/src/main/runtime/__fixtures__/codex-0-158-0-timed-turn.txt b/src/main/runtime/__fixtures__/codex-0-158-0-timed-turn.txt new file mode 100644 index 00000000000..f589f998bb8 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-158-0-timed-turn.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l>_ OpenAI Codex (v0.158.0)loadingShall we see where this goes?⣀⣤⣤⣤⣀⣀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄⣀⣾⣿⣿⠃⣀⣴⣾⣿⣿⡿⠟⠋⣀⡀⠈⠙⢿⣿⣿⣧⣀⣶⣿⣿⣿⣿⡇⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇⣰⣿⣿⡿⢿⣿⣿⡇⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷⢰⣿⣿⡟⠁⢸⣿⣿⡇⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀⣾⣿⣿⠃⢸⣿⣿⡇⢸⣿⣿⠋⠁⠈⠙⢻⣿⣿⣿⣶⣤⡀⠹⢿⣿⣷⡄⢸⣿⣿⣇⠸⣿⣿⣷⣦⣼⣿⣿⢸⣿⣿⠻⢿⣿⣿⡇⠘⣿⣿⣿⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀⢀⣠⣼⣿⣿⣿⣿⡇⣿⣿⣿⠇⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿⣿⣿⡇⣠⣿⣿⣿⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿⣿⣿⣷⣴⣿⣿⣿⠃⠘⣿⣿⣷⠈⠛⢿⣿⣿⣿⠿⠋⠉⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠻⣿⣿⣿⣤⣀⠈⠉⢀⣤⣶⣿⣿⣿⠿⠟⠁⣰⣿⣿⡟⠋⠁⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁⣠⣼⣿⣿⡟⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏⠈⠙⠛⠿⠿⠿⠿⠛⠉›Ask Codex to do anything?forshortcuts[0 q [?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l~/orca-lanes/sta8834/corpus/scratch permissions: YOLO modeShall we see where this goes?[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;scratch[?2026h[?25h[?2026l[?2026h[?25lGPT-6-Sol default·~/orca-lanes/sta8834/corpus/scratch[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ scratch]0;⠙ scratch[?2026h[?25lmdim · ~/orca-lanes/sta834/corpus/scratch[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25lList the files here and summarizein2bullets.[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25lAsk Codex to do anything?forshortcuts[?25h[?2026l[?2026h[?25l› Listthefileshereandsummarizein2bullets.[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ scratch[?2026h[?25l•Working(0s • esc to interrupt)[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ scratch[?2026h[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25l·⠧[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25l1[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25h[?2026l[?2026h[?25l2[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25l• I’ll [?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25linspect[?25h[?2026l[?2026h[?25lthe[?25h[?2026l[?2026h[?25ldirectorycontents[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25land[?25h[?2026l[?2026h[?25lsummarize[?25h[?2026l[?2026h[?25lwhat[?25h[?2026l[?2026h[?25l’s[?25h[?2026l[?2026h[?25lpresent[?25h[?2026l[?2026h[?25lin[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25ltwo[?25h[?2026l[?2026h[?25lbullets[?25h[?2026l[?2026h[?25l.[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25l⠏[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25l⠋[?25h[?2026l[?2026h[?25l•Working(2s • esc to interrupt)[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25l3[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25h[?2026l[?2026h[?25l•Explored └ Listrg--files-g'*'-g'.*' + Show details [?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l4[?25h[?2026l[?2026h[?25l⠼[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25l•⠙[?25h[?2026l]0;⠙ scratch]0;⠙ List and summarize files | scratch[?2026h[?25lList and summarize files[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ List and summarize files | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ List and summarize files | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ List and summarize files | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25l5[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ List and summarize files | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ List and summarize files | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠧ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠇ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ List and summarize files | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25l6[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ List and summarize files | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ List and summarize files | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ List and summarize files | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ List and summarize files | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ List and summarize files | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25l7[?25h[?2026l[?2026h[?25l Read README.md,notes.txt + Show details [?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ List and summarize files | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠦ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠧ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠇ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠏ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l• •` [?25h[?2026l[?2026h[?25lREADME[?25h[?2026l[?2026h[?25l.md[?25h[?2026l]0;⠹ List and summarize files | scratch[?2026h[?25lREADME.md[?25h[?2026l[?2026h[?25l—[?25h[?2026l[?2026h[?25lcontainsthe[?25h[?2026l[?2026h[?25lheading[?25h[?2026l[?2026h[?25l“[?25h[?2026l]0;⠸ List and summarize files | scratch[?2026h[?25lscratch[?25h[?2026l[?2026h[?25l.”[?25h[?2026l[?2026h[?25l•`[?25h[?2026l[?2026h[?25lnotes[?25h[?2026l]0;⠼ List and summarize files | scratch[?2026h[?25l.txt[?25h[?2026l]0;⠴ List and summarize files | scratch]0;⠦ List and summarize files | scratch[?2026h[?25lnotes.txt —containsthetext“hello[?25h[?2026l[?2026h[?25l.”[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;List and summarize files | scratch[?2026h[?25l 9:46 PM [?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0-158-0-trustprompt.meta.json b/src/main/runtime/__fixtures__/codex-0-158-0-trustprompt.meta.json new file mode 100644 index 00000000000..a80d88f5af9 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-158-0-trustprompt.meta.json @@ -0,0 +1,15 @@ +{ + "capturedAt": "2026-09-29T01:32:55.701Z", + "platform": "darwin", + "command": [ + "codex", + "--no-daemon", + "-c", + "check_for_update_on_startup=false", + "--dangerously-bypass-approvals-and-sandbox" + ], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.158.0: launch in an untrusted folder; the recording ends on the unanswered trust dialog. STA-8834.", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/codex-0-158-0-trustprompt.txt b/src/main/runtime/__fixtures__/codex-0-158-0-trustprompt.txt new file mode 100644 index 00000000000..fd002b0fe04 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-158-0-trustprompt.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l>_ OpenAI Codex (v0.158.0)loadingHello, world. Hello, you.⣀⣤⣤⣤⣀⣀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄⣀⣾⣿⣿⠃⣀⣴⣾⣿⣿⡿⠟⠋⣀⡀⠈⠙⢿⣿⣿⣧⣀⣶⣿⣿⣿⣿⡇⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇⣰⣿⣿⡿⢿⣿⣿⡇⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷⢰⣿⣿⡟⠁⢸⣿⣿⡇⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀⣾⣿⣿⠃⢸⣿⣿⡇⢸⣿⣿⠋⠁⠈⠙⢻⣿⣿⣿⣶⣤⡀⠹⢿⣿⣷⡄⢸⣿⣿⣇⠸⣿⣿⣷⣦⣼⣿⣿⢸⣿⣿⠻⢿⣿⣿⡇⠘⣿⣿⣿⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀⢀⣠⣼⣿⣿⣿⣿⡇⣿⣿⣿⠇⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿⣿⣿⡇⣠⣿⣿⣿⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿⣿⣿⣷⣴⣿⣿⣿⠃⠘⣿⣿⣷⠈⠛⢿⣿⣿⣿⠿⠋⠉⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠻⣿⣿⣿⣤⣀⠈⠉⢀⣤⣶⣿⣿⣿⠿⠟⠁⣰⣿⣿⡟⠋⠁⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁⣠⣼⣿⣿⡟⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏⠈⠙⠛⠿⠿⠿⠿⠛⠉›Ask Codex to do anything?forshortcuts[0 q [?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l~/orca-lanes/sta8834/corpus/scratch permissions: YOLO modeHello, world. Hello, you.[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?1006l[?1015l[?1003l[?1002l[?1000l[?1007h[?2026h[?25l Folder access/Users/xxxxxx/orca-lanes/sta8834/corpus/scratch  Trustthisfolder?Codexcanread,edit,andrunfileshere,subjecttoyourpermissionsettings.Foldersettingscan run code automatically,evenwithoutamodelrequest.Continueonlyifyoutrustthesefiles.Yourtrustdecisionwillbesaved.› 1. Trust and continue 2.Quitenter continue · esc quit [?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0157-config-override-embedded-warning.meta.json b/src/main/runtime/__fixtures__/codex-0157-config-override-embedded-warning.meta.json new file mode 100644 index 00000000000..9adb710aced --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-config-override-embedded-warning.meta.json @@ -0,0 +1,14 @@ +{ + "capturedAt": "2026-09-27T20:00:34.230Z", + "platform": "darwin", + "command": [ + "codex", + "--dangerously-bypass-approvals-and-sandbox", + "-c", + "model_reasoning_effort=high" + ], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.157.1 with only -c model_reasoning_effort. Same embedded-warning cell-diff header repaint as the -m/-c launch (STA-8628). Worktree-path leaf redacted by the recorder scan.", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/codex-0157-config-override-embedded-warning.txt b/src/main/runtime/__fixtures__/codex-0157-config-override-embedded-warning.txt new file mode 100644 index 00000000000..8a5d933c1cc --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-config-override-embedded-warning.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭───────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.1) ││ ││ model: loading /model to change ││ directory: loading │╰───────────────────────────────────────╯›Ask Codex to do anything?forshortcuts[0 q╭[?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l───────────────────╮ │ │ loading /model to change │ ~/orca/…/orca/…start-for-codex-times-out-at ││ permissions: YOLO mode │╰──────────────────────────────────────────────────────────╯[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;sta-8628-bug-worker-s...[?2026h[?25l ╭──────────────────────────────────────────────────────────╮>_OpenAI Codex(v0.157.1)     modl: loading /model to change diectory: ~/orca/…/orca/…start-for-codex-times-out-at│ permissions: YOLO mode │╰──────────────────────────────────────────────────────────╯Tip:Use/inittocreateanAGENTS.mdwithproject-specificguidance.GPT-6-Sol default·~/orca/workspaces/orca/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX⚠1 warning·f2toview[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ sta-8628-bug-worker-s...[?2026h[?25l╭──────────────────────────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.1) │   model:GPT-6-Sol high/modeltochangedirctory:~/orca/…/orca/…start-for-codex-times-out-atpemissions:YOLO mode ╰──────────────────────────────────────────────────────────╯ high · ~/orca/workspaces/orca/sta-862-bug-worke-star-for-codex-times-out-a[?25h[?2026l[?2026h[?25h[?2026l]0;sta-8628-bug-worker-s...[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0157-effort-override-embedded-warning.meta.json b/src/main/runtime/__fixtures__/codex-0157-effort-override-embedded-warning.meta.json new file mode 100644 index 00000000000..602cf71904b --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-effort-override-embedded-warning.meta.json @@ -0,0 +1,16 @@ +{ + "capturedAt": "2026-09-27T19:35:31.761Z", + "platform": "darwin", + "command": [ + "codex", + "--dangerously-bypass-approvals-and-sandbox", + "-m", + "gpt-6-sol", + "-c", + "model_reasoning_effort=high" + ], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.157.1 with -m and -c model_reasoning_effort (the worker-start argv). Embedded mode shows a startup warning and redraws the header by cell diff, so a line-folded tail reads `dirctory:` (STA-8628). Worktree-path leaf redacted by the recorder scan.", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/codex-0157-effort-override-embedded-warning.txt b/src/main/runtime/__fixtures__/codex-0157-effort-override-embedded-warning.txt new file mode 100644 index 00000000000..04777d9a88c --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-effort-override-embedded-warning.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭───────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.1) ││ ││ model: loading /model to change ││ directory: loading │╰───────────────────────────────────────╯›Ask Codex to do anything?forshortcuts[0 q╭[?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l───────────────────╮ │ │ loading /model to change │ ~/orca/…/orca/…start-for-codex-times-out-at ││ permissions: YOLO mode │╰──────────────────────────────────────────────────────────╯[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;sta-8628-bug-worker-s...[?2026h[?25h[?2026l[?2026h[?25l ╭──────────────────────────────────────────────────────────╮>_OpenAI Codex(v0.157.1)     modl: loading /model to change diectory: ~/orca/…/orca/…start-for-codex-times-out-at│ permissions: YOLO mode │╰──────────────────────────────────────────────────────────╯Tip:Use/agentstoopentheagentcommandcenter.GPT-6-Sol default·~/orca/workspaces/orca/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX⚠1 warning·f2toview[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ sta-8628-bug-worker-s...[?2026h[?25l╭──────────────────────────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.1) │   model:GPT-6-Sol high/modeltochangedirctory:~/orca/…/orca/…start-for-codex-times-out-atpemissions:YOLO mode ╰──────────────────────────────────────────────────────────╯ high · ~/orca/workspaces/orca/sta-862-bug-worke-star-for-codex-times-out-a[?25h[?2026l[?2026h[?25h[?2026l]0;sta-8628-bug-worker-s...[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0157-fresh-home-daemon-install.meta.json b/src/main/runtime/__fixtures__/codex-0157-fresh-home-daemon-install.meta.json new file mode 100644 index 00000000000..04203db4fbe --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-fresh-home-daemon-install.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-28T21:45:00.000Z", + "platform": "darwin", + "command": ["codex", "--dangerously-bypass-approvals-and-sandbox"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.157.0, plain launch (no -m/-c) on a fresh CODEX_HOME. The provisional screen reads `model: loading` while Codex installs and starts its shared daemon (`Installing daemon from CLI version 0.157.0`); 0.157 discards typed input during that start. The live header fills in at the end. Cut 6 s after spawn. Home-directory user name redacted by hand, same length.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/codex-0157-fresh-home-daemon-install.txt b/src/main/runtime/__fixtures__/codex-0157-fresh-home-daemon-install.txt new file mode 100644 index 00000000000..a6aa2c9af2c --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-fresh-home-daemon-install.txt @@ -0,0 +1,2 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭───────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.0) ││ ││ model: loading /model to change ││ directory: loading │╰───────────────────────────────────────╯ › Ask Codex to do anything ? for shortcuts[0 q╭[?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l─────────────╮ │ │ loading /model to change │ /Users/user1/cr/rounds/x157fresh/repo ││ permissions: YOLO mode │╰────────────────────────────────────────────────────╯[?25h[?2026l[?2026h[?25h[?2026l[?1006l[?1015l[?1003l[?1002l[?1000l[<1u[?1007l[?1049l[<1u[>4;0m[?2004l[?1004l[0 q[?25hInstalling daemon from CLI version 0.157.0 into /Users/user1/cr/rounds/x157fresh/home/.codex/packages/app-server-daemon... +[?2004h[>4;0m[>7u[?1004h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭────────────────────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.0) ││ ││ model: loading /model to change ││ directory: /Users/user1/cr/rounds/x157fresh/repo ││ permissions: YOLO mode │╰────────────────────────────────────────────────────╯ › Ask Codex to do anything ? for shortcuts[0 q╭[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;repo[?2026h[?25h[?2026l[?2026h[?25lTip: Visit the ]8;;https://community.openai.com/c/codex/37Codex community forum]8;; (]8;;https://community.openai.com/c/codex/37https://community.openai.com/c/codex/37]8;;).GPT-6-Astra default · /Users/user1/cr/rounds/x157fresh/repo←agens · ? for shortcuts[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ repo]0;repo[?2026h[?25lGPT-6-Astra /model to change⠈⢀⠁⠂⠄⠈⠁⠈⠁⠈⢀⠠⠄⠠⢀⠠⡀⠠⠁⢀⠁[?25h[?2026l[?2026h[?25l⠄[?25h[?2026l[?2026h[?25l⠄[?25h[?2026l[?2026h[?25l⠈⢀⠁⠐⠂⠄⠈⠁⠈⠁⠈⢀⠠⠄⠠⢀⠠⠄⡀⠈⠠⠁⢀⠁[?25h[?2026l[?2026h[?25l⠈⢀⠁⠐⠂⠄⠈⠁⠈⠁⠈⢀⠠⠄⠠⢀⠠⠄⡀⠈⠠⠁⢀[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0157-hooks-review-dialog.meta.json b/src/main/runtime/__fixtures__/codex-0157-hooks-review-dialog.meta.json new file mode 100644 index 00000000000..ebc2a471a20 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-hooks-review-dialog.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-29T03:30:27.000Z", + "platform": "darwin", + "command": ["codex", "--dangerously-bypass-approvals-and-sandbox"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.157.0, plain launch on a fresh CODEX_HOME with a hooks.json of 8 command hooks that were never trusted. Over the provisional screen Codex paints `Hooks need review`, `1. Review hooks` / `2. Trust all and continue` / `3. Continue without trusting (hooks won't run)` and the key row `enter confirm · esc skip`, where older builds wrote `Press enter to confirm`. No key was sent (only automatic terminal-query replies). Cut 6 s after spawn. Home-directory user name redacted by hand, same length.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/codex-0157-hooks-review-dialog.txt b/src/main/runtime/__fixtures__/codex-0157-hooks-review-dialog.txt new file mode 100644 index 00000000000..14b1ad743e1 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-hooks-review-dialog.txt @@ -0,0 +1,2 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭───────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.0) ││ ││ model: loading /model to change ││ directory: loading │╰───────────────────────────────────────╯ › Ask Codex to do anything ? for shortcuts[0 q╭[?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l───────────╮ │ │ loading /model to change │ /Users/user1/cr/rounds/dlg-h57/repo ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯[?25h[?2026l[?1006l[?1015l[?1003l[?1002l[?1000l[<1u[?1007l[?1049l[<1u[>4;0m[?2004l[?1004l[0 q[?25hInstalling daemon from CLI version 0.157.0 into /Users/user1/cr/rounds/dlg-h57/home/.codex/packages/app-server-daemon... +[?2004h[>4;0m[>7u[?1004h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭──────────────────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.0) ││ ││ model: loading /model to change ││ directory: /Users/user1/cr/rounds/dlg-h57/repo ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯ › Ask Codex to do anything ? for shortcuts[0 q╭[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l  Hooks need review 8 hooks are new or changed. Hooks can run outside the sandbox after you trust them.  › 1. Review hooks  2. Trust all and continue 3. Continue without trusting (hooks won't run) enter confirm · esc skip    [?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0157-model-retired-dialog.meta.json b/src/main/runtime/__fixtures__/codex-0157-model-retired-dialog.meta.json new file mode 100644 index 00000000000..0e97bbe4715 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-model-retired-dialog.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-29T03:31:06.000Z", + "platform": "darwin", + "command": ["codex", "--dangerously-bypass-approvals-and-sandbox"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.157.0, plain launch on a fresh CODEX_HOME whose config.toml sets model = \"gpt-5.4-mini\", a model missing from 0.157's catalog, so Codex shows its retired-model notice without choices: `GPT-5.4 Mini is no longer available`, `Switch to GPT-6 Luna to continue.` and the key row `enter/esc continue · ctrl+c quit`. Enter or Esc switches the model; the heading comes from the model catalog, not the older `Codex just got an upgrade` wording. No key was sent (only automatic terminal-query replies). Cut 6 s after spawn. Home-directory user name redacted by hand, same length.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/codex-0157-model-retired-dialog.txt b/src/main/runtime/__fixtures__/codex-0157-model-retired-dialog.txt new file mode 100644 index 00000000000..776b261a2d6 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-model-retired-dialog.txt @@ -0,0 +1,2 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭───────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.0) ││ ││ model: loading /model to change ││ directory: loading │╰───────────────────────────────────────╯ › Ask Codex to do anything ? for shortcuts[0 q╭[?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l───────────╮ │ │ loading /model to change │ /Users/user1/cr/rounds/dlg-m57/repo ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯[?25h[?2026l[?1006l[?1015l[?1003l[?1002l[?1000l[<1u[?1007l[?1049l[<1u[>4;0m[?2004l[?1004l[0 q[?25hInstalling daemon from CLI version 0.157.0 into /Users/user1/cr/rounds/dlg-m57/home/.codex/packages/app-server-daemon... +[?2004h[>4;0m[>7u[?1004h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭──────────────────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.0) ││ ││ model: loading /model to change ││ directory: /Users/user1/cr/rounds/dlg-m57/repo ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯ › Ask Codex to do anything ? for shortcuts[0 q╭[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l  GPT-5.4 Mini is no longer available  Codex now uses GPT-6 Luna in place of GPT-5.4 Mini. Switch to GPT-6 Luna to continue. enter/esc continue · ctrl+c quit     [?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0157-no-daemon-effort-override.meta.json b/src/main/runtime/__fixtures__/codex-0157-no-daemon-effort-override.meta.json new file mode 100644 index 00000000000..91a98c0b0c7 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-no-daemon-effort-override.meta.json @@ -0,0 +1,17 @@ +{ + "capturedAt": "2026-09-27T20:48:19.864Z", + "platform": "darwin", + "command": [ + "codex", + "--no-daemon", + "--dangerously-bypass-approvals-and-sandbox", + "-m", + "gpt-6-sol", + "-c", + "model_reasoning_effort=high" + ], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.157.1 with --no-daemon, -m and -c model_reasoning_effort. No startup warning; header fills in from `loading`. Worktree-path leaf redacted by the recorder scan.", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/codex-0157-no-daemon-effort-override.txt b/src/main/runtime/__fixtures__/codex-0157-no-daemon-effort-override.txt new file mode 100644 index 00000000000..e6e8d151af5 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-no-daemon-effort-override.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭───────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.1) ││ ││ model: loading /model to change ││ directory: loading │╰───────────────────────────────────────╯›Ask Codex to do anything?forshortcuts[0 q╭[?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l───────────────────╮ │ │ loading /model to change │ ~/orca/…/orca/…start-for-codex-times-out-at ││ permissions: YOLO mode │╰──────────────────────────────────────────────────────────╯[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;sta-8628-bug-worker-s...[?2026h[?25lTip:NEW:Preventsleepwhilerunningisnowavailablein/experimental.GPT-6-Sol default·~/orca/workspaces/orca/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ sta-8628-bug-worker-s...[?2026h[?25lGPT-6-Sol high /model tochangehigh · ~/orca/workspaces/orca/sta-862-bug-worke-star-for-codex-times-out-a[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ sta-8628-bug-worker-s...]0;sta-8628-bug-worker-s...[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0157-plain-ready.meta.json b/src/main/runtime/__fixtures__/codex-0157-plain-ready.meta.json new file mode 100644 index 00000000000..07d30431d7b --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-plain-ready.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-27T19:35:31.758Z", + "platform": "darwin", + "command": ["codex", "--dangerously-bypass-approvals-and-sandbox"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.157.1, plain launch (no -m/-c). Header paints with `loading` rows, then fills in; the ready baseline for STA-8628. Worktree-path leaf redacted by the recorder scan.", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/codex-0157-plain-ready.txt b/src/main/runtime/__fixtures__/codex-0157-plain-ready.txt new file mode 100644 index 00000000000..c346994bd91 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-plain-ready.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭───────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.1) ││ ││ model: loading /model to change ││ directory: loading │╰───────────────────────────────────────╯›Ask Codex to do anything?forshortcuts[0 q╭[?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l───────────────────╮ │ │ loading /model to change │ ~/orca/…/orca/…start-for-codex-times-out-at ││ permissions: YOLO mode │╰──────────────────────────────────────────────────────────╯[?25h[?2026l[?1006l[?1015l[?1003l[?1002l[?1000l[<1u[?1007l[?1049l[<1u[>4;0m[?2004l[?1004l[0 q[?25h[?2004h[>4;0m[>7u[?1004h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭──────────────────────────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.1) ││ ││ model: loading /model to change ││ directory: ~/orca/…/orca/…start-for-codex-times-out-at ││ permissions: YOLO mode │╰──────────────────────────────────────────────────────────╯›Ask Codex to do anything?forshortcuts[0 q╭[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;sta-8628-bug-worker-s...[?2026h[?25h[?2026l[?2026h[?25lTip:Use/agentstoopentheagentcommandcenter.GPT-6-Sol default·~/orca/workspaces/orca/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX←agens · ?forshortcuts[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ sta-8628-bug-worker-s...[?2026h[?25lGPT-6-Sol medium /model tochangemdim · ~/orca/workspaces/orca/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;sta-8628-bug-worker-s...[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0157-update-available-dialog.meta.json b/src/main/runtime/__fixtures__/codex-0157-update-available-dialog.meta.json new file mode 100644 index 00000000000..a81301d2215 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-update-available-dialog.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-29T03:29:52.000Z", + "platform": "darwin", + "command": ["codex", "--dangerously-bypass-approvals-and-sandbox"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.157.0, plain launch on a fresh CODEX_HOME whose config.toml sets check_for_update_on_startup = true, with a version.json cache naming 0.159.0 checked moments earlier (so no network check) and CODEX_MANAGED_BY_NPM=1 so Codex offers an update command. Over the provisional screen Codex paints `Update available · 0.157.0 → 0.159.0`, `1. Update now (runs `npm install -g @openai/codex`)` / `2. Skip` / `3. Skip until next version` and the key row `enter continue · esc skip`, where older builds wrote `Press enter to continue`. Enter picks Update now. No key was sent (only automatic terminal-query replies). Cut 6 s after spawn. Home-directory user name redacted by hand, same length.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/codex-0157-update-available-dialog.txt b/src/main/runtime/__fixtures__/codex-0157-update-available-dialog.txt new file mode 100644 index 00000000000..4701e63af24 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-update-available-dialog.txt @@ -0,0 +1,2 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭───────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.0) ││ ││ model: loading /model to change ││ directory: loading │╰───────────────────────────────────────╯ › Ask Codex to do anything ? for shortcuts[0 q╭[?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l───────────╮ │ │ loading /model to change │ /Users/user1/cr/rounds/dlg-u57/repo ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯[?25h[?2026l[?2026h[?25h[?2026l[?1006l[?1015l[?1003l[?1002l[?1000l[<1u[?1007l[?1049l[<1u[>4;0m[?2004l[?1004l[0 q[?25hInstalling daemon from CLI version 0.157.0 into /Users/user1/cr/rounds/dlg-u57/home/.codex/packages/app-server-daemon... +[?2004h[>4;0m[>7u[?1004h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭──────────────────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.0) ││ ││ model: loading /model to change ││ directory: /Users/user1/cr/rounds/dlg-u57/repo ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯ › Ask Codex to do anything ? for shortcuts[0 q╭[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l  Update available · 0.157.0 → 0.159.0 Release notes: ]8;;https://github.com/openai/codex/releases/latesthttps://github.com/openai/codex/releases/latest]8;; › 1. Update now (runs `npm install -g @openai/codex`)  2. Skip 3. Skip until next version  enter continue · esc skip    [?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0158-fresh-home-greeting.meta.json b/src/main/runtime/__fixtures__/codex-0158-fresh-home-greeting.meta.json new file mode 100644 index 00000000000..ea7d9513a9f --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0158-fresh-home-greeting.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-28T21:44:00.000Z", + "platform": "darwin", + "command": ["codex", "--dangerously-bypass-approvals-and-sandbox"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.158.0, plain launch (no -m/-c) on a fresh CODEX_HOME. Greeting layout: an unboxed `>_ OpenAI Codex (v0.158.0)` header with no model:/directory: labels, starting from a provisional screen whose directory reads `loading`; the live status row under the composer appears last. Braille logo animation frames included. Cut 4.6 s after spawn. Home-directory user name redacted by hand, same length.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/codex-0158-fresh-home-greeting.txt b/src/main/runtime/__fixtures__/codex-0158-fresh-home-greeting.txt new file mode 100644 index 00000000000..72670b00f4b --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0158-fresh-home-greeting.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l>_ OpenAI Codex (v0.158.0)loadingShall we see where this goes?⣀⣤⣤⣤⣀⣀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄⣀⣾⣿⣿⠃⣀⣴⣾⣿⣿⡿⠟⠋⣀⡀⠈⠙⢿⣿⣿⣧⣀⣶⣿⣿⣿⣿⡇⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇⣰⣿⣿⡿⢿⣿⣿⡇⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷⢰⣿⣿⡟⠁⢸⣿⣿⡇⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀⣾⣿⣿⠃⢸⣿⣿⡇⢸⣿⣿⠋⠁⠈⠙⢻⣿⣿⣿⣶⣤⡀⠹⢿⣿⣷⡄⢸⣿⣿⣇⠸⣿⣿⣷⣦⣼⣿⣿⢸⣿⣿⠻⢿⣿⣿⡇⠘⣿⣿⣿⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀⢀⣠⣼⣿⣿⣿⣿⡇⣿⣿⣿⠇⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿⣿⣿⡇⣠⣿⣿⣿⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿⣿⣿⣷⣴⣿⣿⣿⠃⠘⣿⣿⣷⠈⠛⢿⣿⣿⣿⠿⠋⠉⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠻⣿⣿⣿⣤⣀⠈⠉⢀⣤⣶⣿⣿⣿⠿⠟⠁⣰⣿⣿⡟⠋⠁⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁⣠⣼⣿⣿⡟⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏⠈⠙⠛⠿⠿⠿⠿⠛⠉ › Ask Codex to do anything ? for shortcuts[0 q [?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l/Users/user1/cr/rounds/x158fresh/repo permissions: YOLO modeShall we see where this goes?[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;repo[?2026h[?25h[?2026l[?2026h[?25lGPT-6-Astra default · /Users/user1/cr/rounds/x158fresh/repo←agens · ? for shortcuts[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ repo[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ repo[?2026h[?25h[?2026l]0;repo[?2026h[?25l⠈⢀⠁⠂⠄⠈⠁⠈⠁⠈⢀⠠⠄⠠⢀⠠⡀⠠⠁⢀⠁[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0158-hooks-review-dialog.meta.json b/src/main/runtime/__fixtures__/codex-0158-hooks-review-dialog.meta.json new file mode 100644 index 00000000000..896d3474482 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0158-hooks-review-dialog.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-29T03:30:46.000Z", + "platform": "darwin", + "command": ["codex", "--dangerously-bypass-approvals-and-sandbox"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.158.0, plain launch on a fresh CODEX_HOME with a hooks.json of 8 command hooks that were never trusted. Over the provisional screen Codex paints `Hooks need review`, `1. Review hooks` / `2. Trust all and continue` / `3. Continue without trusting (hooks won't run)` and the key row `enter confirm · esc skip`, where older builds wrote `Press enter to confirm`. No key was sent (only automatic terminal-query replies). Cut 6 s after spawn. Home-directory user name redacted by hand, same length.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/codex-0158-hooks-review-dialog.txt b/src/main/runtime/__fixtures__/codex-0158-hooks-review-dialog.txt new file mode 100644 index 00000000000..ad25e6eda6e --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0158-hooks-review-dialog.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l>_ OpenAI Codex (v0.158.0)loadingBring your unfinished thoughts.⣀⣤⣤⣤⣀⣀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄⣀⣾⣿⣿⠃⣀⣴⣾⣿⣿⡿⠟⠋⣀⡀⠈⠙⢿⣿⣿⣧⣀⣶⣿⣿⣿⣿⡇⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇⣰⣿⣿⡿⢿⣿⣿⡇⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷⢰⣿⣿⡟⠁⢸⣿⣿⡇⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀⣾⣿⣿⠃⢸⣿⣿⡇⢸⣿⣿⠋⠁⠈⠙⢻⣿⣿⣿⣶⣤⡀⠹⢿⣿⣷⡄⢸⣿⣿⣇⠸⣿⣿⣷⣦⣼⣿⣿⢸⣿⣿⠻⢿⣿⣿⡇⠘⣿⣿⣿⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀⢀⣠⣼⣿⣿⣿⣿⡇⣿⣿⣿⠇⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿⣿⣿⡇⣠⣿⣿⣿⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿⣿⣿⣷⣴⣿⣿⣿⠃⠘⣿⣿⣷⠈⠛⢿⣿⣿⣿⠿⠋⠉⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠻⣿⣿⣿⣤⣀⠈⠉⢀⣤⣶⣿⣿⣿⠿⠟⠁⣰⣿⣿⡟⠋⠁⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁⣠⣼⣿⣿⡟⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏⠈⠙⠛⠿⠿⠿⠿⠛⠉ › Ask Codex to do anything ? for shortcuts[0 q [?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l/Users/user1/cr/rounds/dlg-h58/repo permissions: YOLO modeBring your unfinished thoughts.[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l  Hooks need review 8 hooks are new or changed. Hooks can run outside the sandbox after you trust them.  › 1. Review hooks  2. Trust all and continue 3. Continue without trusting (hooks won't run) enter confirm · esc skip    [?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0158-model-announcement-dialog.meta.json b/src/main/runtime/__fixtures__/codex-0158-model-announcement-dialog.meta.json new file mode 100644 index 00000000000..05790b10e95 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0158-model-announcement-dialog.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-28T21:44:30.000Z", + "platform": "darwin", + "command": ["codex", "--dangerously-bypass-approvals-and-sandbox"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.158.0, plain launch on a fresh CODEX_HOME whose config.toml sets an older model with an available upgrade. After the provisional screen, Codex paints its model announcement (`Meet GPT-6 Sol`, `1. Try new model` / `2. Use existing model`, `enter/esc confirm`), which owns Enter. Cut 6 s after spawn. Home-directory user name redacted by hand, same length.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/codex-0158-model-announcement-dialog.txt b/src/main/runtime/__fixtures__/codex-0158-model-announcement-dialog.txt new file mode 100644 index 00000000000..a5c88157d74 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0158-model-announcement-dialog.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l>_ OpenAI Codex (v0.158.0)loadingHere for a quick fix or the extended edition?⣀⣤⣤⣤⣀⣀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄⣀⣾⣿⣿⠃⣀⣴⣾⣿⣿⡿⠟⠋⣀⡀⠈⠙⢿⣿⣿⣧⣀⣶⣿⣿⣿⣿⡇⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇⣰⣿⣿⡿⢿⣿⣿⡇⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷⢰⣿⣿⡟⠁⢸⣿⣿⡇⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀⣾⣿⣿⠃⢸⣿⣿⡇⢸⣿⣿⠋⠁⠈⠙⢻⣿⣿⣿⣶⣤⡀⠹⢿⣿⣷⡄⢸⣿⣿⣇⠸⣿⣿⣷⣦⣼⣿⣿⢸⣿⣿⠻⢿⣿⣿⡇⠘⣿⣿⣿⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀⢀⣠⣼⣿⣿⣿⣿⡇⣿⣿⣿⠇⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿⣿⣿⡇⣠⣿⣿⣿⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿⣿⣿⣷⣴⣿⣿⣿⠃⠘⣿⣿⣷⠈⠛⢿⣿⣿⣿⠿⠋⠉⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠻⣿⣿⣿⣤⣀⠈⠉⢀⣤⣶⣿⣿⣿⠿⠟⠁⣰⣿⣿⡟⠋⠁⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁⣠⣼⣿⣿⡟⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏⠈⠙⠛⠿⠿⠿⠿⠛⠉ › Ask Codex to do anything ? for shortcuts[0 q [?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l/Users/user1/cr/rounds/x158mig/repo permissions: YOLO modeHere for a quick fix or the extended edition?[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l  Meet GPT-6 Sol  Our latest Sol is more intelligent and more efficient so your usage limits go further. This model is a great daily driver for complex tasks, especially coding. › 1. Try new model  2. Use existing model  enter/esc confirm · ctrl+c quit    [?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0158-model-retired-dialog.meta.json b/src/main/runtime/__fixtures__/codex-0158-model-retired-dialog.meta.json new file mode 100644 index 00000000000..92137927416 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0158-model-retired-dialog.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-29T03:31:56.000Z", + "platform": "darwin", + "command": ["codex", "--dangerously-bypass-approvals-and-sandbox"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.158.0, plain launch on a fresh CODEX_HOME whose config.toml sets model_provider = \"amazon-bedrock\" and model = \"openai.gpt-5.4\" (0.158 keeps the notice for a model missing from its catalog only on the OpenAI and Bedrock providers, and the OpenAI provider asks for a sign-in first, which the rig does not have). Codex shows the notice without choices: `GPT-5.4 on Amazon Bedrock is no longer offered in Codex`, `Switch to GPT-6 Sol on Amazon Bedrock to continue.` and the key row `enter/esc continue · ctrl+c quit`. Enter or Esc switches the model. No key was sent (only automatic terminal-query replies). Cut 6 s after spawn. Home-directory user name redacted by hand, same length.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/codex-0158-model-retired-dialog.txt b/src/main/runtime/__fixtures__/codex-0158-model-retired-dialog.txt new file mode 100644 index 00000000000..fd9fac82c43 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0158-model-retired-dialog.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l>_ OpenAI Codex (v0.158.0)loadingBring a question. Bonus points if it’s a weird one.⣀⣤⣤⣤⣀⣀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄⣀⣾⣿⣿⠃⣀⣴⣾⣿⣿⡿⠟⠋⣀⡀⠈⠙⢿⣿⣿⣧⣀⣶⣿⣿⣿⣿⡇⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇⣰⣿⣿⡿⢿⣿⣿⡇⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷⢰⣿⣿⡟⠁⢸⣿⣿⡇⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀⣾⣿⣿⠃⢸⣿⣿⡇⢸⣿⣿⠋⠁⠈⠙⢻⣿⣿⣿⣶⣤⡀⠹⢿⣿⣷⡄⢸⣿⣿⣇⠸⣿⣿⣷⣦⣼⣿⣿⢸⣿⣿⠻⢿⣿⣿⡇⠘⣿⣿⣿⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀⢀⣠⣼⣿⣿⣿⣿⡇⣿⣿⣿⠇⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿⣿⣿⡇⣠⣿⣿⣿⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿⣿⣿⣷⣴⣿⣿⣿⠃⠘⣿⣿⣷⠈⠛⢿⣿⣿⣿⠿⠋⠉⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠻⣿⣿⣿⣤⣀⠈⠉⢀⣤⣶⣿⣿⣿⠿⠟⠁⣰⣿⣿⡟⠋⠁⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁⣠⣼⣿⣿⡟⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏⠈⠙⠛⠿⠿⠿⠿⠛⠉ › Ask Codex to do anything ? for shortcuts[0 q [?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l/Users/user1/cr/rounds/dlg-m58b/repo permissions: YOLO modeBring a question. Bonus points if it’s a weird one.[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l  GPT-5.4 on Amazon Bedrock is no longer offered in Codex  Codex now uses GPT-6 Sol on Amazon Bedrock in place of GPT-5.4 on Amazon Bedrock. Switch to GPT-6 Sol on Amazon Bedrock to continue. enter/esc continue · ctrl+c quit    [?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0158-update-available-dialog.meta.json b/src/main/runtime/__fixtures__/codex-0158-update-available-dialog.meta.json new file mode 100644 index 00000000000..db18029d308 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0158-update-available-dialog.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-29T03:30:10.000Z", + "platform": "darwin", + "command": ["codex", "--dangerously-bypass-approvals-and-sandbox"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.158.0, plain launch on a fresh CODEX_HOME whose config.toml sets check_for_update_on_startup = true, with a version.json cache naming 0.159.0 checked moments earlier (so no network check) and CODEX_MANAGED_BY_NPM=1 so Codex offers an update command. Over the provisional screen Codex paints `Update available · 0.158.0 → 0.159.0`, `1. Update now (runs `npm install -g @openai/codex`)` / `2. Skip` / `3. Skip until next version` and the key row `enter continue · esc skip`, where older builds wrote `Press enter to continue`. Enter picks Update now. No key was sent (only automatic terminal-query replies). Cut 6 s after spawn. Home-directory user name redacted by hand, same length.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/codex-0158-update-available-dialog.txt b/src/main/runtime/__fixtures__/codex-0158-update-available-dialog.txt new file mode 100644 index 00000000000..2c92e766ae2 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0158-update-available-dialog.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l>_ OpenAI Codex (v0.158.0)loadingHere for a quick fix or the extended edition?⣀⣤⣤⣤⣀⣀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄⣀⣾⣿⣿⠃⣀⣴⣾⣿⣿⡿⠟⠋⣀⡀⠈⠙⢿⣿⣿⣧⣀⣶⣿⣿⣿⣿⡇⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇⣰⣿⣿⡿⢿⣿⣿⡇⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷⢰⣿⣿⡟⠁⢸⣿⣿⡇⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀⣾⣿⣿⠃⢸⣿⣿⡇⢸⣿⣿⠋⠁⠈⠙⢻⣿⣿⣿⣶⣤⡀⠹⢿⣿⣷⡄⢸⣿⣿⣇⠸⣿⣿⣷⣦⣼⣿⣿⢸⣿⣿⠻⢿⣿⣿⡇⠘⣿⣿⣿⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀⢀⣠⣼⣿⣿⣿⣿⡇⣿⣿⣿⠇⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿⣿⣿⡇⣠⣿⣿⣿⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿⣿⣿⣷⣴⣿⣿⣿⠃⠘⣿⣿⣷⠈⠛⢿⣿⣿⣿⠿⠋⠉⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠻⣿⣿⣿⣤⣀⠈⠉⢀⣤⣶⣿⣿⣿⠿⠟⠁⣰⣿⣿⡟⠋⠁⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁⣠⣼⣿⣿⡟⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏⠈⠙⠛⠿⠿⠿⠿⠛⠉ › Ask Codex to do anything ? for shortcuts[0 q [?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l/Users/user1/cr/rounds/dlg-u58/repo permissions: YOLO modeHere for a quick fix or the extended edition?[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l  Update available · 0.158.0 → 0.159.0 Release notes: ]8;;https://github.com/openai/codex/releases/latesthttps://github.com/openai/codex/releases/latest]8;; › 1. Update now (runs `npm install -g @openai/codex`)  2. Skip 3. Skip until next version  enter continue · esc skip    [?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/dsh-tui-ready-no-key.meta.json b/src/main/runtime/__fixtures__/dsh-tui-ready-no-key.meta.json new file mode 100644 index 00000000000..5c5284b5056 --- /dev/null +++ b/src/main/runtime/__fixtures__/dsh-tui-ready-no-key.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-23T07:34:39.531Z", + "platform": "darwin", + "command": ["dsh-tui"], + "cols": 120, + "rows": 40, + "note": "dsh-tui 0.10.2 on @deepseek-ai/dsh 0.1.5-rc.1, macOS arm64, no DEEPSEEK_API_KEY, empty workspace, DSH_TUI_LANG=en", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/dsh-tui-ready-no-key.txt b/src/main/runtime/__fixtures__/dsh-tui-ready-no-key.txt new file mode 100644 index 00000000000..34f9432049a --- /dev/null +++ b/src/main/runtime/__fixtures__/dsh-tui-ready-no-key.txt @@ -0,0 +1 @@ +[?25l[?2004h[?1004h]11;?[>0q[?1049h[?1000h[?1002h[?1003h[?1006h]0;✦ 🐋 ]8;; ✦ dsh-TUI v0.10.2 █▀▀▀▄█▀▀▀▀█▀▀▀▀█▀▀▀▄█▀▀▀▀█▀▀▀▀█▀▀▀▀██ ███████████ ▄▀▄▄███▀▀▀█▀▀▀█▄▄▄▀▀▀▀▄█▀▀▀█▀▀▀██ ▀▀▀▀▄▄▄▀▀▀██████████ ▄▄▄▄▄▄▄▄▄▀▀▀▀▀▀▀▀▀▀▀▀█▄▄▄▀█▄▄▄▄█▄▄▄▄██▄▄▄▀█▄▄▄▄█▄▄▄▄██ ▄▀▀▀▀▀▀▀▀▀▀▀▀▄▄▀▀▀▀▀▀▀▀▀██▄▀▄█▀▀▀▄███▀▀▀▀█▀▀▀▀█▀▀▀▀ ▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄▄▀▀▀▀▀████████████ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀█▀▀▀██▀▀▀██▄▄▄▀████▀▀▀▀▀▀▄▀▀▀▄ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀████████████ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀█████████▄▄▄▄█▄▄▄▀█▄▄▄▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀deepseek-flash · Max effort ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄/private/tmp/dsh-ws ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀Tip: Clicktool/thinking/summaryrowstofold;subagentcardsopendetail;… ⚠ The dsh engine (0.1.5-rc.3) is newer than the 0.1.5-rc.1 this UI is validated against, so issues are possible; downgrade via npm i -g @deepseek-ai/dsh@0.1.5-rc.1 for stability, or wait for a dsh-tui update. Explore the uncharted! ▶(Ctrl+P to expand)Contextloaded·Systemprompt18sections·Runtimecontext2items·Skills14·Tools27 ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ ❯  ⛶ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ deepseek-flash · max · dsh-ws]8;; ✦ █ █ █ █ █ █ █ █ █ █ E]8;; ✦ █▀ █ █ █ █▄ █ █ █▀ █ █ Ex]8;; ✦ ds █▀▀▀ █ █ █ █▄▄▄ █ █ █▀▀▀ █ █ Expl]8;; ✦ dsh █▀▀▀▄ ██ ██ ██ █▄▄▄▀ ██ ██ █▀▀▀█ ██ ██ Explo]8;; ✦ dsh- █▀▀▀▄ ██ ██ ██ █▄▄▄▀ ██ ██ █▀▀▀█ ██ ██ Explor]8;; ▀▀]8;; ✦ dsh-T █▀▀▀▄█ ███ ███ ███ █▄▄▄▀█ ██ ███ █▀▀▀██ ███ ███ Explore]8;; ✦ dsh-TU █▀▀▀▄█▀ ███ ███▀ ███ █▄▄▄▀█▄ ██▄ ███ █▀▀▀██▀ ███ ███ Explore ]8;; ✦ dsh-TU █▀▀▀▄█▀ ███ ███▀ ███ █▄▄▄▀█▄ ██▄ ███ █▀▀▀██▀ ███ ███ Explore ]8;; ✦ dsh-TUI ▀▀▄█▀▀▀ ██ ██▀▀▀ ██ ▄▄▀█▄▄▄ █▄▀▄ ██ ▀▀██▀▀▀ ██ ██ Explore th]8;; ▀▀]8;; ✦ dsh-TUI ▀▀▄█▀▀▀▀ ██ ██▀▀▀ ██ ▄▄▀█▄▄▄▄ █▄▀▄ ███ ▀▀██▀▀▀█ ███ ███ Explore the]8;;  dsh-TUI ▀▄█▀▀▀▀ ██ ██▀▀▀ ██ ▄▀█▄▄▄▄ █▄▀▄ ███ ▀██▀▀▀█ ███ ███ xplore the ]8;; dsh-TUI ▄█▀▀▀▀█▀ ███ ██▀▀▀█▀ ███ ▀█▄▄▄▄█▄ █▄▀▄█▀ ████ ██▀▀▀██▄ ████ ████ plore the un]8;; h-TUI █▀▀▀▀█▀▀ ██ █▀▀▀█▀▀ ██ █▄▄▄▄█▄▄ ▄▀▄█▀▀ ███ █▀▀▀██▄▄ ████ ███ ore the unc]8;; - ▀▀▀▀█▀▀▀ █ ▀▀▀█▀▀▀ █ ▄▄▄▄█▄▄▄ ▄▀ ▀▄ re the unch]8;; ▄▄▄ ▀]8;; TUI ▀▀▀█▀▀▀▀ █ ▀▀█▀▀▀ █ ▄▄▄█▄▄▄▄ ▀▄█▀▀▀▄ ███ ▀▀██▄▄▄▀ ███ ███ e the uncha]8;; UI ▀▀█▀▀▀▀ █ ▀█▀▀▀ █ ▄▄█▄▄▄▄ ▄█▀▀▀▄ ███ ▀██▄▄▄▀ ███ ███  the unchar]8;; ▄ ▀▀▀▀▀]8;; I ▀█▀▀▀▀█ ██ █▀▀▀█ ██ ▄█▄▄▄▄█ █▀▀▀▄█ ████ ██▄▄▄▀█ ████ ████ the unchart]8;; █▀▀▀▀█▀ ██ █▀▀▀█▄ ██ █▄▄▄▄█ █▀▀▀▄█ ████ █▄▄▄▀█ ███ ███ he uncharte]8;; ▄▄▀▄▄ ▀▀▀▀]8;; █▀▀▀▀█▀ ██ █▀▀▀█▄ ██ █▄▄▄▄█ █▀▀▀▄█ ████ █▄▄▄▀█ ███ ███ e uncharte]8;; ▀▀█▀▀▀▄ ██ ▀█▄▄▄▀ █ ▄▄█ ▀▄██ ████ ▄▀███ ███ ███ ncharted!]8;; ▄ ▄▄▀▀▀▀▀▀▄▄ ▀ ▀▀ ▀ ▄]8;; ▀▀█▀▀▀▄ ██ ▀█▄▄▄▀ █ ▄▄█ ▀▄██ ████ ▄▀███ ███ ███ ncharted!]8;; ▀█▀▀▀▄█ ███ █▄▄▄▀ █ ▄██ ▄███ █████ ▀████ ████ ████ charted!]8;; █▀▀▀▄█▀ ███ █▄▄▄▀▀ █ ██▄ ███▀ ████ ████▀ ████ ███▄ harted!]8;; ▄▄▄▄ ▄▀▀▀▀▄▄▀▀▀▀▄ ▀  ▀  ▀]8;; █▀▀▀▄█▀▀ ███ █▄▄▄▀▀▀ █ ██▄▄ ███▀▀ ████ ████▀▀ ████ ███▄▄ arted!]8;; ▀▀▀▄█▀▀▀ ██ ▄▄▄▀▀▀▀ █▄▄▄ ██▀▀ ███ ███▀▀ ███ ██▄▄ rted!]8;; ▄  ▄ ▄▀  ▀▄ ▀  ▀]8;; ▀▀▄█▀▀▀▀ ██ ▄▄▀▀▀▀▄ █ █▄▄▄▀ ██▀▀▀▀ ██ ███▀▀▀ ███ ██▄▄▄▄ ted!]8;; ✦ ▀▄█▀▀▀▀ ██ ▄▀▀▀▀▄ █ █▄▄▄▀ ██▀▀▀▀ ██ ██▀▀▀ ███ ██▄▄▄▄ ed!]8;;          ]8;; ✦ d ▄█▀▀▀▀█▀ ███ ▀▀▀▀▄█▀ ██ █▄▄▄▀█▄ ██▀▀▀▀█▀ ███ ██▀▀▀▀ ██ ██▄▄▄▄█▄ d!]8;; ✦ ds █▀▀▀▀█▀▀ ██ ▀▀▀▄█▀▀ ██ █▄▄▄▀█▄▄ █▀▀▀▀█▀ ██ █▀▀▀▀ █ █▄▄▄▄█▄]8;; ✦ ds ▀▀▀▀█▀▀ █ ▀▀▀▄█▀▀ ██ ▄▄▄▀█▄▄ ▀▀▀▀█▀ █ ▀▀▀▀ ▄▄▄▄█▄]8;; ✦ dsh- ▀▀█▀▀▀▀ █ ▀▄█▀▀▀ ██ ▄▀█▄▄▄▄ ▀▀ ▀ ▄▄]8;;  ▄▀▄ ▄  ▀▀▀▄ ▄▀▀▀  ▀▀▀▀▀▀  ▀▀▀▀  ▄▀▀▀ ▀▀▀ ▀▀ ▀▀]8;; ✦ dsh-T ▀▀█▀▀▀▀ █ ▀▄█▀▀▀ ██ ▄▀█▄▄▄▄ ▀▀█▀▀ █ ▀▀▀ ▄▄█▄▄]8;; ✦ dsh-TU ▀█▀▀▀▀█ ██ ▄█▀▀▀█ ███ ▀█▄▄▄▄█ ▀█▀▀█ ██ ▀▄ █ ▄█▄▀█]8;;  ▀▄ ▄  ▀▀▀▀ ▄▀▀▀  ▀▀▄▀▀▀▀▀▀  ▀▀▀▀▀▀ ▄▄▀▀▀▀▀ ▀▀▀▀ ▀▀]8;; ✦ dsh-TUI █▀▀▀▀█▀ ██ █▀▀▀█▀ ██ █▄▄▄▄█▄ █▀▀▀▀█▀ ██ ▀▀▀▄▀ █ █▄▄▄▀█▄]8;; ✦ dsh-TUI █▀▀▀▀█▀▀ ██ █▀▀▀█▀▀ ██ █▄▄▄▄█▄▄ █▀▀█▀▀ ██ ▀▀▀▀ █▄▄█▄▄]8;;  ▄  ▀▀▀  ▀ ▄▀▀▄ ▀▄▀▀▀▀▀  ▀▀▀▀▀ ▀▀▀▀▀ ▀▀ ▀▀ ▀]8;; ✦ dsh-TUI ▀▀▀▀█▀▀▀▀ █ ▀▀▀█▀▀▀ █ ▄▄▄▄█▄▄▄▄ ▀▀▀▀▀ ▀▀▀▀▄ █ ▄▄▄▄▀]8;; dsh-TUI ▀▀█▀▀▀▀ █ ▀█▀▀▀ █ ▄▄█▄▄▄▄ ▀█▀▀▀▀ █ ▀▀▀▀▄ █ ▄█▄▄▄▀]8;; sh-TUI ▀█▀▀▀▀█ ██ █▀▀▀█ ██ ▄█▄▄▄▄█ ▀█▀▀▀▀ █ ▄▀▀▀▄ ██ ▀█▄▄▄▀ E]8;;   ▄▀▄  ▀▀▀▀  ▀▀▀▀ ▄  ▄▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀ ▀▀▀▀ ▀]8;; h-TUI █▀▀▀▀█ ██ █▀▀▀██ ██ █▄▄▄▄█ █▀▀▀▀ █ ▀▀▀▄ █ █▄▄▄▀ Ex]8;; -TUI █▀▀▀▀█ ███ █▀▀▀██ ███ █▄▄▄▄█ █▀▀▀▀ █ ▀▀▀▄ █ █▄▄▄▀ Exp]8;; ▄ ▀▀▀ ▀▀▀▀▄ ▄▀▀▄ ▀▀▀▀▄▀▀▀▀▀ ▄▄▀▀▀▀▀ ▀▀▀▀▀ ▀ ▀▀▀  ]8;; TUI ▀▀▀▀█ ██ ▀▀▀██ ██ ▄▄▄▄█ ▀▀▀▀ ▀▀▀▄ █ ▄▄▄▀ Expl]8;; UI ▀▀▀██ ██ ▀▀██ ██ ▄▄▄██ Explo]8;; I ▀▀██ ██ ▀██ ██ ▄▄██ ▀ ▄ █ ▀ Explor]8;; ▄▀▄ ▄ ▀▀▀▀ ▄▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀ ▄▀▀▀ ▀▀▀ ▀ ▀  ]8;; ▀██ ██ ██ ██ ▄██ ▀ ▄ █ ▀ Explore]8;; ██ ██ ██ ██ ██ Explore t]8;; ▄▀ ▄ ▀▀▀▀▄ ▄▄▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀  ▀▀▀ ▀▀▀ ▀▀]8;; █ █ █ █ █ Explore th]8;; █ █ █ █ Explore the]8;; ▄▀▄ ▄ ▀▀▀▄ ▄▀▀▀ ▀▀▀▀▀▀▀ ▀▀▀▀ ▄▀▀▀ ▀▀ ▀ ▀ ]8;; █ █ xplore the ]8;; █ █ plore the u]8;; lore the un]8;; ore the unc]8;; re the unch]8;; e the unch]8;; ▀▀ ▀▀▀▀▄ ▀ ]8;; ▀ ▀▀▀▄▄ ▀▀▀▀▀  ]8;; ▀ ▀▀▀ ▀▀▀▄ ▀]8;; ▀▀ ▀▀▀▄  ▀]8;;  ▄▀▄ ▄  ▀▀▀▄ ▄▀▀▀  ▀▀▀▀▀▀  ▀▀▀▀  ▄▀▀▀ ▀▀▀ ▀▀ ▀▀]8;;  ▀▄ ▄  ▀▀▀▀ ▄▀▀▀  ▀▀▄▀▀▀▀▀▀  ▀▀▀▀▀▀ ▄▄▀▀▀▀▀ ▀▀▀▀ ▀▀]8;;  ▄  ▀▀▀  ▀ ▄▀▀▄ ▀▄▀▀▀▀▀  ▀▀▀▀▀ ▀▀▀▀▀ ▀▀ ▀▀ ▀]8;;   ▄▀▄  ▀▀▀▀  ▀▀▀▀ ▄  ▄▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀ ▀▀▀▀ ▀]8;; ▄ ▀▀▀ ▀▀▀▀▄ ▄▀▀▄ ▀▀▀▀▄▀▀▀▀▀ ▄▄▀▀▀▀▀ ▀▀▀▀▀ ▀ ▀▀▀  ]8;; ▀▀ ▀▀▀▀▄ ▀ ]8;; ▄▀▄ ▄ ▀▀▀▀ ▄▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀ ▄▀▀▀ ▀▀▀ ▀ ▀▀ ▀▀▀▄▄ ▀▀▀▀▀  ]8;; ▀▀]8;; ▀ ▀▀▀ ▀▀▀▄ ▀]8;; ▀▀]8;; ▄▀ ▄ ▀▀▀▀▄ ▄▄▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀  ▀▀▀ ▀▀▀ ▀▀]8;; ▀▀ ▀▀▀▄  ▀]8;; ▄▀▄ ▄ ▀▀▀▄ ▄▀▀▀ ▀▀▀▀▀▀▀ ▀▀▀▀ ▄▀▀▀ ▀▀ ▀ ▀ ]8;; ▀▀ ▀▀▀▀▄ ▀ ]8;; ▀ ▀▀▀▄▄ ▀▀▀▀▀  ]8;; ▀ ▀▀▀ ▀▀▀▄ ▀]8;; ▀▀ ▀▀▀▄  ▀]8;;  ▄▀▄ ▄  ▀▀▀▄ ▄▀▀▀  ▀▀▀▀▀▀  ▀▀▀▀  ▄▀▀▀ ▀▀▀ ▀▀ ▀▀]8;;  ▀▄ ▄  ▀▀▀▀ ▄▀▀▀  ▀▀▄▀▀▀▀▀▀  ▀▀▀▀▀▀ ▄▄▀▀▀▀▀ ▀▀▀▀ ▀▀]8;;  ▄  ▀▀▀  ▀ ▄▀▀▄ ▀▄▀▀▀▀▀  ▀▀▀▀▀ ▀▀▀▀▀ ▀▀ ▀▀ ▀]8;;   ▄▀▄  ▀▀▀▀  ▀▀▀▀ ▄  ▄▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀ ▀▀▀▀ ▀]8;; ▀▀ ▀▀▀▀▄ ▀ ]8;; ▄ ▀▀▀ ▀▀▀▀▄ ▄▀▀▄ ▀▀▀▀▄▀▀▀▀▀ ▄▄▀▀▀▀▀ ▀▀▀▀▀ ▀ ▀▀▀  ]8;; ▀▀]8;; ▀ ▀▀▀▀▄ ▀▀▀▀▀  ]8;; ▀▀]8;; ▀ ▀▀▀▀ ▀▀▀▄ ▀]8;; ▄▀▄ ▄ ▀▀▀▀ ▄▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀ ▄▀▀▀ ▀▀▀ ▀ ▀  ]8;; ▀▀▀▀ ▀▀▀▀ ▄▀▄▄]8;; ▀▀ ▀▀▀▄  ▀]8;; ▄▀ ▄ ▀▀▀▀▄ ▄▄▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀  ▀▀▀ ▀▀▀ ▀▀]8;; ▄▄▄▄  ▄  ▀▀▀▀ ▀▀▀▀]8;; ▄▀▄ ▄ ▀▀▀▄ ▄▀▀▀ ▀▀▀▀▀▀▀ ▀▀▀▀ ▄▀▀▀ ▀▀ ▀ ▀ ]8;;  ▀▀▀▀ ▀▄▄ ▄▄▄▄ ▄▄▄]8;;  ▄  ▀▀▀▀  ▀▀▀▀]8;; ▀▄▄ ▄▄▄▄  ▄ ▀▀▀]8;;  ▀▀▀▀  ▀▀▀▀ ▀▄▄ ▄▄▄ ]8;; ▀▀ ▀▀▀▀▄ ▀ ]8;; ▄▄▄▄  ▄  ▀▀▀▀ ▀▀▀▀]8;; ▀ ▀▀▀▄▄ ▀▀▀▀▀  ]8;;  ▀▀▀▀ ▀▄▄ ▄▄▄▄ ▄▄▄]8;; ▀ ▀▀▀ ▀▀▀▄ ▀]8;; ▀▀ ▀▀▀▄  ▀]8;;  ▄  ▀▀▀▀  ▀▀▀▀]8;; ▀▄▄ ▄▄▄▄  ▄ ▀▀▀]8;;  ▀▀▀▀  ▀▀▀▀ ▀▄▄ ▄▄▄ ]8;;  ▄▀▄ ▄  ▀▀▀▄ ▄▀▀▀  ▀▀▀▀▀▀  ▀▀▀▀  ▄▀▀▀ ▀▀▀ ▀▀ ▀▀]8;; ▄▄▄▄  ▄  ▀▀▀▀ ▀▀▀▀]8;;  ▀▄ ▄  ▀▀▀▀ ▄▀▀▀  ▀▀▄▀▀▀▀▀▀  ▀▀▀▀▀▀ ▄▄▀▀▀▀▀ ▀▀▀▀ ▀▀]8;;  ▀▀▀▀ ▀▄▄ ▄▄▄▄ ▄▄▄]8;;  ▄  ▀▀▀  ▀ ▄▀▀▄ ▀▄▀▀▀▀▀  ▀▀▀▀▀ ▀▀▀▀▀ ▀▀ ▀▀ ▀]8;;  ▄  ▀▀▀▀  ▀▀▀▀]8;; ▀▀]8;; ▀▀ ▀▀▀▀▄ ▀ ]8;;   ▄▀▄  ▀▀▀▀  ▀▀▀▀ ▄  ▄▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀ ▀▀▀▀ ▀]8;; ▀▀]8;; ▀ ▀▀▀▀▀ ▀▀▀▀▀  ]8;; ▀▄▄ ▄▄▄▄  ▄ ▀▀▀]8;; ▄ ▀▀▀ ▀▀▀▀▄ ▄▀▀▄ ▀▀▀▀▄▀▀▀▀▀ ▄▄▀▀▀▀▀ ▀▀▀▀▀ ▀ ▀▀▀ ▄ \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/freebuff-lifecycle.meta.json b/src/main/runtime/__fixtures__/freebuff-lifecycle.meta.json new file mode 100644 index 00000000000..57f6174485f --- /dev/null +++ b/src/main/runtime/__fixtures__/freebuff-lifecycle.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-28T07:56:49.813Z", + "platform": "darwin", + "command": ["freebuff", "--cwd", "/tmp/orca-freebuff-folder-workspace"], + "cols": 120, + "rows": 40, + "note": "Freebuff 0.1.2 authenticated free plan; choice, answer, tool, interruption, tool error", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/freebuff-lifecycle.txt b/src/main/runtime/__fixtures__/freebuff-lifecycle.txt new file mode 100644 index 00000000000..0f965581bce --- /dev/null +++ b/src/main/runtime/__fixtures__/freebuff-lifecycle.txt @@ -0,0 +1 @@ +]11;?]10;?[?2031h]10;?]11;?[>0q[?25l[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?[?2026h[?25l ███████████ ██ ▄████ ▄██  ███▀▀▀▀▀▀▀▀ ██ ██ ██▀  ███ █████ ██████ ▄█████ ▄████▄ ██████▄ ██ ██ ████████████  ███ ███▀▀ ██▀ ███▄▄▄██ ██▄▄▄███ ██ ▀██ ██ ██ ██ ██  ███ ███ ███ ███▀▀▀▀▀ ██▀▀▀▀▀ ██▄ ▄██ ██▄ ██ ██ ██  ███ ██▀ ██▀ ▀█████ ▀████▀ ██████▀ ▀████▀ ██ ██   ┌──────────────────────────────────────────────────────────────────┐  │ Your first message starts the session. │  │ Some models aren't available on this connection │  └──────────────────────────────────────────────────────────────────┘                       ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ ▍Enter a coding task or / for commands ││ │╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ GLM 5.3 Flash • max · /private/tmp/orca-freebuff-folder-workspace · /model to change · Chat: New chat  ← for history · ? for help [?2026l[?1004h[?2026h[?25l│ 1 day streak │└──────────────────────────────────────────────────────────────────┘[?2026l[?2026h[?25l100/100 Freebucks remaining │ ││✦ Refer friends → earn Freebucks:││││⎘ Copy invite link Learn More ↵│└──────────────────────────────────────────────────────────────────┘[?2026l[?2026h[?25l ███████████ ██ ▄████ ▄██  ███▀▀▀▀▀▀▀▀ ██ ██ ██▀  ███ █████ ██████ ▄█████ ▄████▄ ██████▄ ██ ██ ████████████  ███ ███▀▀ ██▀ ███▄▄▄██ ██▄▄▄███ ██ ▀██ ██ ██ ██ ██  ███ ███ ███ ███▀▀▀▀▀ ██▀▀▀▀▀ ██▄ ▄██ ██▄ ██ ██ ██  ███ ██▀ ██▀ ▀█████ ▀████▀ ██████▀ ▀████▀ ██ ██   ┌──────────────────────────────────────────────────────────────────┐  │ Your first message starts the session. │  │ 100/100 Freebucks remaining │  │ 1 day streak │  │ │  │ ✦ Refer friends → earn Freebucks: │  │ │  │ ⎘ Copy invite link Learn More ↵ │  └──────────────────────────────────────────────────────────────────┘                  ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ ▍Enter a coding task or / for commands ││ │╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ GLM 5.3 Flash • max · /private/tmp/orca-freebuff-folder-workspace · /model to change · Chat: New chat  ← for history · ? for help [?2026l[?2026h[?25l╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│Geo Daniela — La primera IA de remuneraciones de Chile Ad││Liquidaciones PDF, Previred CSV, LRE CSV y portal del trabajador. Demo 30 min para PYME.││ Learn more geodaniela.cl│╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯[?2026l[?2026h[?25lUse ask_user to ask me to choose blue orgreen.Waitformyreply.Donotmodifyfiles.▍[?2026l]0;Freebuff: Use ask_user to ask me to choose blue or green. W…[?2026h[?25l ┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐│Starting your model session… Your message is saved.        ││Esc: back to draft│└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘[?2026l[?2026h[?25lSession active └──────────────────────────────────────────────────────────────────┘    [12:55 AM]  Use ask_user to ask me to choose blue or green. Wait for my reply. Do not modify files. ⎘ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│Geo Daniela — La primera IA de remuneraciones de Chile Ad││ Liquidaciones PDF, Previred CSV, LRE CSV y portal del trabajador. Demo 30 min para PYME. │ Learn more  geodaniela.cl  ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ 44m left  ✕ End session ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│││ ▍Enter a coding task or / for commands ││ │╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ GLM 5.3 Flash • max · /private/tmp/orca-freebuff-folder-workspace · /model to change · Chat: Use ask_user to ask me to choose blue or green.Wait for my reply. Do not modify files. [?2026l[?2026h[?25lthinking... ■ Esc[?2026l[?2026h[?25lhinking...[?2026l[?2026h[?25ltinking...[?2026l[?2026h[?25lthnking...[?2026l[?2026h[?25lthiking...[?2026l[?2026h[?25lthining...[?2026l[?2026h[?25lthinkng...[?2026l[?2026h[?25l1s[?2026l[?2026h[?25lthinkig...[?2026l[?2026h[?25lthinkin...[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lhinking...[?2026l[?2026h[?25l2[?2026l[?2026h[?25ltinking...[?2026l[?2026h[?25lthnking...[?2026l[?2026h[?25lthiking...[?2026l[?2026h[?25lthining...[?2026l[?2026h[?25lthinkng...[?2026l[?2026h[?25lthinkig...[?2026l[?2026h[?25l3[?2026l[?2026h[?25lthinkin...[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lhinking...[?2026l[?2026h[?25ltinking...[?2026l[?2026h[?25l4[?2026l[?2026h[?25lthnking...[?2026l[?2026h[?25lthiking...[?2026l[?2026h[?25lthining...[?2026l[?2026h[?25lthinkng...[?2026l[?2026h[?25lthinkig...[?2026l[?2026h[?25lthinkin...[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25l5[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lhinking...[?2026l[?2026h[?25ltinking...[?2026l[?2026h[?25lthnking...[?2026l[?2026h[?25lthiking...[?2026l[?2026h[?25l6[?2026l[?2026h[?25lthining...[?2026l[?2026h[?25lthinkng...[?2026l[?2026h[?25lworking... [?2026l[?2026h[?25l• ThinkingThe user wants me to ask them to choose between blue and green using the ask_user tool. This is straightforward — I should call ask_user with a single question offering those two options, then wait for their reply. No file modifications needed.[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25l ▀▀ ██▀███▄▄▄████▄▄▄███▀███████ ██  ███ ██████▀▀▀▀▀██▀▀▀▀▀▄▄████▄██ ▀ ▀ ▀▀▀▀ ▀███▀    ┌──────────────────────────────────────────────────────────────────┐│ Session active ││100/100 Freebucks remaining││ 1 day streak │└──────────────────────────────────────────────────────────────────┘    [12:55 AM]    Use ask_user to ask me to choose blue or green. Wait for my reply. Do not modify files. ⎘ • Thinking   The user wants me to ask them to choose between blue and green using the ask_user tool. This is straightforward — I should call ask_user with a single question offering those two options, then wait for  their reply. No file modifications needed. ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│Geo Daniela — La primera IA de remuneraciones de Chile Ad││Liquidaciones PDF, Previred CSV, LRE CSV y portal del trabajador. Demo 30 min para PYME.││ Learn more geodaniela.cl│╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯  6s ╭─────────────────────────────────────────────── Some questions for you ───────────────────────────────────────────────╮│Close ✕││ ││▼ Which color would you like to choose?   ││ ○ Blue ││ Pick blue as the color ││ ○ Green ││ ○ Custom ││ ││╭──────────╮│││ Submit │ ↑↓ navigate • Enter select││╰──────────╯│╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯[?2026l[?2026h[?25l7[?2026l[?2026h[?25l [?2026l[?2026h[?25l█████      ▄███ ▄██▀▀▀▀▀▀▀▀       ▀███████▄▄▄▄██ ████████████ ███▀▀ ██▀ ███▄▄▄██ ██▄▄▄███ ██ ▀██ ██ ██ ██ █████ ███ ███ ███▀▀▀▀▀ ██▀▀▀▀▀ ██▄ ▄██ ██▄ ██ ██ ██ ███ ██▀ ██▀ ▀█████ ▀████▀ ██████▀ ▀████▀ ██ ██    ┌──────────────────────────────────────────────────────────────────┐│ Session active ││100/100 Freebucks remaining││1 day streak│└──────────────────────────────────────────────────────────────────┘   [12:55 AM] Use ask_user to ask me to choose blue or green. Wait for my reply. Do not modify files. ⎘  • Thinking The user wants me to ask them to choose between blue and green using the ask_user tool. This is  straightforward — I should call ask_user with a single question offering those two options, then wait for    their reply. No file modifications needed.      ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ Geo Daniela — La primera IA de remuneraciones de Chile  Ad │ │ Liquidaciones PDF, Previred CSV, LRE CSV y portal del trabajador. Demo 30 min para PYME. │  │  Learn more  geodaniela.cl │  ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯   7s ╭─────────────────────────────────────────────── Some questions for you ───────────────────────────────────────────────╮ Close ✕ ▶ Which color would you like to choose? ↳ "Blue"╭──────────╮│Submit│╰──────────╯[?2026l[?2026h[?25l              ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮  │ Geo Daniela — La primera IA de remuneraciones de Chile  Ad │  │Liquidaciones PDF, Previred CSV, LRE CSV y portal del trabajador. Demo 30 min para PYME. │  │ Learn more geodaniela.cl│  ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯  working...  7s ■ Esc ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ ▍Enter a coding task or / for commands ││ │╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯[?2026l[?2026h[?25l ▀ ██▀▀█████▀████▀████▀▀████▀█ ██  ┌──────────────────────────────────────────────────────────────────┐│ Session active ││ 100/100 Freebucks remaining ││ 1 day streak │└──────────────────────────────────────────────────────────────────┘  [12:55 AM]   Use ask_user to ask me to choose blue or green. Wait for my reply. Do not modify files. ⎘    • Thinking The user wants me to ask them to choose between blue and green using the ask_user tool. This is   straightforward — I should call ask_user with a single question offering those two options, then wait for   their reply. No file modifications needed.  ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││Your answer: ││││1. Which color would you like to choose?││↳ Blue│││╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25l8[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25l9[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25l10[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25l└──────────────────────────────────────────────────────────────────┘  [12:55 AM]  Use ask_user to ask me to choose blue or green. Wait for my reply. Do not modify files. ⎘    • Thinking   The user wants me to ask them to choose between blue and green using the ask_user tool. This is straightforward — I should call ask_user with a single question offering those two options, then wait for   their reply. No file modifications needed.  ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││Your answer: ││ ││1. Which color would you like to choose?││↳ Blue││ │╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯     • Thinking   The user chose Blue. They asked me to wait for their reply and not modify files. I've received their answer.   I should acknowledge it simply and not do anything else.  You chose Blue. Noted — and I haven't modified any files, as requested.[?2026l[?2026h[?25l   [12:55 AM] Use ask_user to ask me to choose blue or green. Wait for my reply. Do not modify files. ⎘    • Thinking  The user wants me to ask them to choose between blue and green using the ask_user tool. This is  straightforward —I should call ask_ur with asingle question offeringthose two ptions, then wait for  their reply. Nofile mdifition needed.     ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ Your answer:   1. Which color would you like to choose?  ↳ Blue    ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯    • Thinking  The user chose Blue. They asked me to wait for their reply and not modify files. I've received their answer.  I shouldacknowledgeit simply andnt do anything els.    You chose Blue. Noted — and I haven't modified any files, as requested.█ ⎘•20s•△▽█44m left · 13.2K (1%)✕ End session[?2026l[?2026h[?25lThe user wants me to ask them to choose between blue and green using the ask_user tool. This is   straightforward — I should call ask_user with a single question offering those two options, then wait for   their reply. No file modifications needed.  ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││Your answer: ││││1. Which color would you like to choose?││ ↳ Blue │╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯     ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Looooooong running AI agentsAd│ │ Build and run AI agents that don't time out on Trigger.dev trigger.dev ↗ │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ [?2026l[?2026h[?25lRun sleep 20 then say done. Do not modify files.▍[?2026l]0;Freebuff: Run sleep 20 then say done. Do not modify files.[?2026h[?25l  ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│││Your answer:││ │1. Which color would you like to choose? ↳ Blue ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯     ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Looooooong running AI agentsAd││Build and run AI agents that don't time out on Trigger.devtrigger.dev ↗│╰╯    • Thinking    The user chose Blue. They asked me to wait for their reply and not modify files. I've received their answer.  I should acknowledge it simply and not do anything else. You chose Blue. Noted — and I haven't modified any files, as requested.  ⎘•20s•△▽  [12:56 AM]  Run sleep 20 then say done. Do not modify files. ⎘     ▍Enter a coding task or / for commands [?2026l[?2026h[?25l▸ Thinking    ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮   Your answer:   1. Which color would you like to choose? ↳ Blue │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯    ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ Looooooong running AI agents  Ad │ Build and run AI agents that don't time out on Trigger.dev trigger.dev ↗ │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯   ▸ Thinking    You chose Blue. Noted — and I haven't modified any files, as requested.  ⎘•20s•△▽        [12:56 AM] Run sleep 20 then say done. Do not modify files. ⎘    thinking...  ■ Esc[?2026l[?2026h[?25lhinking...[?2026l[?2026h[?25ltinking...[?2026l[?2026h[?25lthnking...[?2026l[?2026h[?25lthiking...[?2026l[?2026h[?25lthining...[?2026l[?2026h[?25lthinkng...[?2026l[?2026h[?25l1s[?2026l[?2026h[?25lthinkig...[?2026l[?2026h[?25lthinkin...[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lhinking...[?2026l[?2026h[?25l2[?2026l[?2026h[?25ltinking...[?2026l[?2026h[?25lthnking...[?2026l[?2026h[?25lthiking...[?2026l[?2026h[?25lthining...[?2026l[?2026h[?25lthinkng...[?2026l[?2026h[?25lthinkig...[?2026l[?2026h[?25l3[?2026l[?2026h[?25lthinkin...[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lhinking...[?2026l[?2026h[?25ltinking...[?2026l[?2026h[?25l4[?2026l[?2026h[?25lthnking...[?2026l[?2026h[?25lthiking...[?2026l[?2026h[?25lthining...[?2026l[?2026h[?25lthinkng...[?2026l[?2026h[?25lthinkig...[?2026l[?2026h[?25lthinkin...[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25l5[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lhinking...[?2026l[?2026h[?25ltinking...[?2026l[?2026h[?25l [?2026l[?2026h[?25lthnking...[?2026l[?2026h[?25lthiking...[?2026l[?2026h[?25l6[?2026l[?2026h[?25lthining...[?2026l[?2026h[?25lthinkng...[?2026l[?2026h[?25lthinkig...[?2026l[?2026h[?25lthinkin...[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25l7[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lhinking...[?2026l[?2026h[?25lworking... [?2026l[?2026h[?25l│ Your answer:││││1. Which color would you like to choose?││ ↳ Blue │╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯     ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Looooooong running AI agentsAd│ │ Build and run AI agents that don't time out on Trigger.dev trigger.dev ↗ │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯▸ Thinking     You chose Blue. Noted — and I haven't modified any files, as requested.   ⎘ • 20s • △▽  [12:56 AM] Run sleep 20 then say done. Do not modify files. ⎘ • Thinking     The user wants me to run `sleep 20` in the terminal and then say "done". This is a simple, harmless command —   it just waits 20 seconds. No files modified. The tool guidance says to ask permission for commands other   than the listed use cases, but this is trivially safe and the user explicitly requested it, which is explicit permission itself. Let[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25l me run it with an appropriate timeout (default is[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25l ↳ Blue ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯     ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Looooooong running AI agentsAd││Build and run AI agents that don't time out on Trigger.devtrigger.dev ↗│╰╯    ▸ Thinking    You chose Blue. Noted — and I haven't modified any files, as requested. ⎘•20s•△▽  [12:56 AM]  Run sleep 20 then say done. Do not modify files. ⎘      • Thinking   The user wants me to run `sleep 20` in the terminal and then say "done". This is a simple, harmless command — it just waits 20 seconds. No files modified. The tool guidance says to ask permission for commands other  than the listed use cases, but this is trivially safe and the user explicitly requested it, which is explicit prmission itself. Let me run it withan appopriate timeout (default is 30, which is fine for a 20-second sleep).  $ sleep 20 8[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25l       ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮  │Looooooong running AI agentsAd│ │Build and run AI agents that don't time out on Trigger.devtrigger.dev ↗│ ╰╯      ▸ Thinking      You chose Blue. Noted — and I haven't modified any files, as requested.  ⎘•20s•△▽    [12:56 AM]   Run sleep 20 then say done. Do not modify files. ⎘        • Thinking    The user wants me to run `sleep 20` in the terminal and then say "done". This is a simple, harmless command —  it just waits 20 seconds. No files modified. The tool guidance says to ask permission for commands other   than the listed use cases, but this is trivially safe and the user explicitly requested it, which is explicit  prmission itself. Let me run it withan appopriate timeout (default is 30, which is fine for a 20-second  sleep).    $ sleep 20    [response interrupted]  ⎘•△▽▄44m left · 13.2K (1%)✕ End session[?2026l[?2026h[?25l╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯▸ Thinking     You chose Blue. Noted — and I haven't modified any files, as requested.   ⎘ • 20s • △▽   [12:56 AM] Run sleep 20 then say done. Do not modify files. ⎘ • Thinking     The user wants me to run `sleep 20` in the terminal and then say "done". This is a simple, harmless command —   it just waits 20 seconds. No files modified. The tool guidance says to ask permission for commands other   than the listed use cases, but this is trivially safe and the user explicitly requested it, which is explicit permission itself. Let me run it with an appropriate timeout (default is 30s, which is fine for a 20-second  sleep).  $ sleep 20  ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│Looooooong running AI agentsAd││Build and run AI agents that don't time out on Trigger.devtrigger.dev ↗│╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯[?2026l[?2026h[?25lRun sh -c "exit 7" and report the result. Do not modify files.▍[?2026l]0;Freebuff: Run sh -c "exit 7" and report the result. Do not …[?2026h[?25l You chose Blue. Noted — and I haven't modified any files, as requested. ⎘•20s•△▽  [12:56 AM]  Run sleep 20 then say done. Do not modify files. ⎘      • Thinking   The user wants me to run `sleep 20` in the terminal and then say "done". This is a simple, harmless command — it just waits 20 seconds. No files modified. The tool guidance says to ask permission for commands other  than the listed use cases, but this is trivially safe and the user explicitly requested it, which is explicit prmission itself. Let me run it withan appopriate timeout (default is 30, which is fine for a 20-second sleep).  $ sleep 20  ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ Looooooong running AI agentsAd││Build and run AI agents that don't time out on Trigger.devtrigger.dev ↗│╰╯    [response interrupted]    ⎘ • △▽   [12:56 AM]  Run sh -c "exit 7" and report the result. Do not modify files. ⎘   ▍Enter a coding task or / for commands [?2026l[?2026h[?25l│Build and run AI agents that don't time out on Trigger.devtrigger.dev ↗│ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯       ▸ Thinking     You chose Blue. Noted — and I haven't modified any files, as requested. ⎘•20s•△▽    [12:56 AM]   Run sleep 20 then say done. Do not modify files. ⎘    ▸ Thinking    $ sleep 20   ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Looooooong running AI agents Ad │ Build and run AI agents that don't time out on Trigger.devtrigger.dev ↗ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯   [response interrupted]  ⎘•△▽      [12:56 AM] Run sh -c "exit 7" and report the result. Do not modify files. ⎘    thinking...  ■ Esc[?2026l[?2026h[?25lhinking...[?2026l[?2026h[?25ltinking...[?2026l[?2026h[?25lthnking...[?2026l[?2026h[?25lthiking...[?2026l[?2026h[?25lthining...[?2026l[?2026h[?25lthinkng...[?2026l[?2026h[?25l1s[?2026l[?2026h[?25lthinkig...[?2026l[?2026h[?25lthinkin...[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lhinking...[?2026l[?2026h[?25l2[?2026l[?2026h[?25ltinking...[?2026l[?2026h[?25lthnking...[?2026l[?2026h[?25lthiking...[?2026l[?2026h[?25lthining...[?2026l[?2026h[?25lthinkng...[?2026l[?2026h[?25lManaged Postgresthat keepsupwith your machins Scale your production wokloads withTigerData'smanaged, unfked PstgreSQL ndhigh-availability multi-AZ…SttFreTrial  tigerdata.com[?2026l[?2026h[?25lthinkig...[?2026l[?2026h[?25l3[?2026l[?2026h[?25lthinkin...[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lworking.. [?2026l[?2026h[?25lYou chose Blue. Noted — and I haven't modified any files, as requested.   ⎘ • 20s • △▽  [12:56 AM] Run sleep 20 then say done. Do not modify files. ⎘ ▸ Thinking      $ sleep 20   ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ Looooooong running AI agentsAd││Build and run AI agents that don't time out on Trigger.devtrigger.dev ↗│╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯[response interrupted]   ⎘• △▽      [12:56 AM]  Run sh -c "exit 7" and report the result. Do not modify files. ⎘ • Thinking   The user wants me to run `sh -c "exit 7"` and report the result. This is a harmless command — it just exits   with code 7. It doesn't modify files. Let me run it with the run_terminal_command tool.  $ sh -c "exit 7"[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25l  [12:56 AM]  Run sleep 20 then say done. Do not modify files. ⎘      ▸ Thinking   $ sleep 20 ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ Looooooong running AI agentsAd││Build and run AI agents that don't time out on Trigger.devtrigger.dev ↗│╰╯    [response interrupted]    ⎘ • △▽  [12:56 AM]  Run sh -c "exit 7" and report the result. Do not modify files. ⎘    • Thinking   The user wants me to run `sh -c "exit 7"` and report the result. This is a harmless command — it just exits with code 7. It doesn't modify files. Let me run it with the run_terminal_command tool. $ sh -c "exit 7" command: sh -c "exit 7" stdout: exitCode: 7 [?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25l4[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25l5[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25l6[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25l7[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25l8[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25l9[?2026l[?2026h[?25l   [response interrupted] ⎘•△▽  [12:56 AM]  Run sh -c "exit 7" and report the result. Do not modify files. ⎘• Thinking  The user wants me to run `sh -c "exit 7"` and report the result. This is a harmless command — it just exits    with code 7. It doesn't modify files. Let me run it with the run_terminal_command tool.   $ sh -c "exit 7"command: sh -c "exit 7"stdout:    exitCode: 7   • Thinking The command `sh -c "exit 7"` was run. It exited with code 7, no stdout output. I should report the result. No  files modified. The command ran and completed with: - Exit code: 7 (as expected, since  exit 7  was explicitly called)- stdout: empty - stderr: none  No files were modified.[?2026l[?2026h[?25l[response interrupted]  ⎘•△▽      [12:56 AM] Run sh -c "exit 7" and report the result. Do not modify files. ⎘    • Thinking  The user wants me to run `sh -c "exit 7"` and report the result. This is a harmless command — it just exits  with code 7. It doesn't modify files.Letme run it withthe run_terminal_command tool.    $ sh -c "exit 7" command: sh -c "exit 7" stdout:   exitCode: 7   • Thinking  The command `sh -c "exit 7"` was run. It exited with code 7, no stdout output. I should report the result. No  files odified.    The command ran and completed with: - Exit code: 7 (s expectd, since  exit 7  was explicitly called) stdout: empty  errnone    No files were modified.  ⎘•9s•△▽▄43m left · 13.3K (1%)✕ End session[?2026l[?2026h[?25l • Thinking   The user wants me to run `sh -c "exit 7"` and report the result. This is a harmless command — it just exits   with code 7. It doesn't modify files. Let me run it with the run_terminal_command tool.  $ sh -c "exit 7"command: sh -c "exit 7"stdout:  exitCode: 7 ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ Your AI Agent’s Data Layer, Built on MongoDB AtlasAd││ Power your agentic workflows with MongoDB Atlas for reliable, scalable session memory and real-…mongodb.com ↗│╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯[?2026l[?2026h[?25l [?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/freebuff-login.meta.json b/src/main/runtime/__fixtures__/freebuff-login.meta.json new file mode 100644 index 00000000000..390bcedba90 --- /dev/null +++ b/src/main/runtime/__fixtures__/freebuff-login.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-28T07:20:19.119Z", + "platform": "darwin", + "command": ["freebuff", "--cwd", "/tmp/orca-freebuff-verify"], + "cols": 100, + "rows": 32, + "note": "Freebuff npm 0.1.2, unauthenticated, macOS arm64; no model inference", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/freebuff-login.txt b/src/main/runtime/__fixtures__/freebuff-login.txt new file mode 100644 index 00000000000..d90d8905ceb --- /dev/null +++ b/src/main/runtime/__fixtures__/freebuff-login.txt @@ -0,0 +1 @@ +]11;?]10;?[?2031h]10;?]11;?[>0q[?25l[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?[?2026h[?25l ███████████ ██ ▄████ ▄██  ███▀▀▀▀▀▀▀▀ ██ ██ ██▀  ███ █████ ██████ ▄█████ ▄████▄ ██████▄ ██ ██ ████████████  ███ ███▀▀ ██▀ ███▄▄▄██ ██▄▄▄███ ██ ▀██ ██ ██ ██ ██  ███ ███ ███ ███▀▀▀▀▀ ██▀▀▀▀▀ ██▄ ▄██ ██▄ ██ ██ ██  ███ ██▀ ██▀ ▀█████ ▀████▀ ██████▀ ▀████▀ ██ ██   ┌──────────────────────────────────────────────────────────────────┐  │ Your first message starts the session. │  │ Some models aren't available on this connection │  └──────────────────────────────────────────────────────────────────┘           Try one of these:  → Explain this codebase  → Find opportunities to refactor  → Improve my test coverage  ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ ▍Enter a coding task or / for commands ││ │╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ GLM 5.3 Flash • max · /private/tmp/orca-freebuff-verify · /model to change · Chat: New chat  ← for history · ? for help [?2026l[?1004h[?2026h[?25l             ███████████ ██ ▄████ ▄██  ███▀▀▀▀▀▀▀▀ ██ ██ ██▀  ███ █████ ██████ ▄█████ ▄████▄ ██████▄ ██ ██ ████████████  ███ ███▀▀ ██▀ ███▄▄▄██ ██▄▄▄███ ██ ▀██ ██ ██ ██ ██  ███ ███ ███ ███▀▀▀▀▀ ██▀▀▀▀▀ ██▄ ▄██ ██▄ ██ ██ ██  ███ ██▀ ██▀ ▀█████ ▀████▀ ██████▀ ▀████▀ ██ ██   Press ENTER to login...             [?2026l[?2026h[?25l             ███████████ ██ ▄████ ▄██  ███▀▀▀▀▀▀▀▀ ██ ██ ██▀  ███ █████ ██████ ▄█████ ▄████▄ ██████▄ ██ ██ ████████████  ███ ███▀▀ ██▀ ███▄▄▄██ ██▄▄▄███ ██ ▀██ ██ ██ ██ ██  ███ ███ ███ ███▀▀▀▀▀ ██▀▀▀▀▀ ██▄ ▄██ ██▄ ██ ██ ██  ███ ██▀ ██▀ ▀█████ ▀████▀ ██████▀ ▀████▀ ██ ██   Press ENTER to login...             [?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/freebuff-ready.meta.json b/src/main/runtime/__fixtures__/freebuff-ready.meta.json new file mode 100644 index 00000000000..4555254cd8f --- /dev/null +++ b/src/main/runtime/__fixtures__/freebuff-ready.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-28T08:15:15.356Z", + "platform": "darwin", + "command": ["freebuff", "--cwd", "/tmp/orca-freebuff-trust"], + "cols": 120, + "rows": 40, + "note": "Freebuff 0.1.2 authenticated, declined repository agent files; waiting for first message", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/freebuff-ready.txt b/src/main/runtime/__fixtures__/freebuff-ready.txt new file mode 100644 index 00000000000..150521f88b9 --- /dev/null +++ b/src/main/runtime/__fixtures__/freebuff-ready.txt @@ -0,0 +1,11 @@ +]11;?]10;? +freebuff found agent files in this repository it has not run before: + + /private/tmp/orca-freebuff-trust/.agents + agents: demo.ts + +These run with your permissions and can read your environment. Only load them if you trust this repository. + +Load and run these? [y/N] n +Skipped agents and mcp.json in /private/tmp/orca-freebuff-trust/.agents for this run. Re-run and answer y to trust it, or set CODEBUFF_TRUST_AGENT_DIRS=1 (or pass --trust-agents). +[?2031h]10;?]11;?[>0q[?25l[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?[?2026h[?25l ███████████ ██ ▄████ ▄██  ███▀▀▀▀▀▀▀▀ ██ ██ ██▀  ███ █████ ██████ ▄█████ ▄████▄ ██████▄ ██ ██ ████████████  ███ ███▀▀ ██▀ ███▄▄▄██ ██▄▄▄███ ██ ▀██ ██ ██ ██ ██  ███ ███ ███ ███▀▀▀▀▀ ██▀▀▀▀▀ ██▄ ▄██ ██▄ ██ ██ ██  ███ ██▀ ██▀ ▀█████ ▀████▀ ██████▀ ▀████▀ ██ ██   ┌──────────────────────────────────────────────────────────────────┐  │ Your first message starts the session. │  │ Some models aren't available on this connection │  └──────────────────────────────────────────────────────────────────┘                       ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ ▍Enter a coding task or / for commands ││ │╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ GLM 5.3 Flash • max · /private/tmp/orca-freebuff-trust · /model to change · Chat: New chat  ← for history · ? for help [?2026l[?1004h[?2026h[?25l95/100 Freebucks remaining │ ││✦ Refer friends → earn Freebucks:││││⎘ Copy invite link Learn More ↵│└──────────────────────────────────────────────────────────────────┘[?2026l[?2026h[?25l1 day streak ✦ Refer friends → earn Freebucks: │ ⎘ Copy invite link Learn More ↵ │└──────────────────────────────────────────────────────────────────┘[?2026l[?2026h[?25l ███████████ ██ ▄████ ▄██  ███▀▀▀▀▀▀▀▀ ██ ██ ██▀  ███ █████ ██████ ▄█████ ▄████▄ ██████▄ ██ ██ ████████████  ███ ███▀▀ ██▀ ███▄▄▄██ ██▄▄▄███ ██ ▀██ ██ ██ ██ ██  ███ ███ ███ ███▀▀▀▀▀ ██▀▀▀▀▀ ██▄ ▄██ ██▄ ██ ██ ██  ███ ██▀ ██▀ ▀█████ ▀████▀ ██████▀ ▀████▀ ██ ██   ┌──────────────────────────────────────────────────────────────────┐  │ Your first message starts the session. │  │ 95/100 Freebucks remaining │  │ 1 day streak │  │ │  │ ✦ Refer friends → earn Freebucks: │  │ │  │ ⎘ Copy invite link Learn More ↵ │  └──────────────────────────────────────────────────────────────────┘                  ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ ▍Enter a coding task or / for commands ││ │╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ GLM 5.3 Flash • max · /private/tmp/orca-freebuff-trust · /model to change · Chat: New chat  ← for history · ? for help [?2026l[?2026h[?25l╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│MARS | A robot to build physical AI agents Ad││A mobile robot with an arm to run agents for $995. Start in simulation. Bring it to life.││ Learn more innate.bot│╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/freebuff-trust.meta.json b/src/main/runtime/__fixtures__/freebuff-trust.meta.json new file mode 100644 index 00000000000..d4513cba70e --- /dev/null +++ b/src/main/runtime/__fixtures__/freebuff-trust.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-28T08:09:19.020Z", + "platform": "darwin", + "command": ["freebuff", "--cwd", "/tmp/orca-freebuff-trust"], + "cols": 120, + "rows": 40, + "note": "Freebuff 0.1.2; harmless repository agent file trust prompt, not accepted", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/freebuff-trust.txt b/src/main/runtime/__fixtures__/freebuff-trust.txt new file mode 100644 index 00000000000..48ca2e77160 --- /dev/null +++ b/src/main/runtime/__fixtures__/freebuff-trust.txt @@ -0,0 +1,9 @@ +]11;?]10;? +freebuff found agent files in this repository it has not run before: + + /private/tmp/orca-freebuff-trust/.agents + agents: demo.ts + +These run with your permissions and can read your environment. Only load them if you trust this repository. + +Load and run these? [y/N] \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-fixtures.ts b/src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-fixtures.ts index 94528481622..c0053bc75ae 100644 --- a/src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-fixtures.ts +++ b/src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-fixtures.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { vi, type Mock } from 'vitest' import { makePaneKey } from '../../../shared/stable-pane-id' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' @@ -20,6 +21,7 @@ import { WORKTREE_ID, WORKTREE_PATH, canaryProcess, + makeDeferred, makeSession } from './orca-runtime-terminal-close-continuity-state-fixture' import { createCloseContinuityGraphFixture } from './orca-runtime-terminal-close-continuity-graph-fixture' @@ -45,18 +47,11 @@ export { makeSession } from './orca-runtime-terminal-close-continuity-state-fixture' -function makeDeferred() { - let resolve!: () => void - const promise = new Promise((settle) => { - resolve = settle - }) - return { promise, resolve } -} - export type CloseContinuityHarness = { runtime: OrcaRuntimeService acknowledged: ReturnType closeTerminal: Mock<(...args: unknown[]) => unknown> + closeTerminalPane: Mock<(...args: unknown[]) => unknown> closeTerminalTab: Mock<(...args: unknown[]) => unknown> flushOrThrow: Mock<() => void> kill: Mock<(ptyId: string) => boolean> @@ -67,6 +62,7 @@ export type CloseContinuityHarness = { syncFixtureTabWithoutLeaf: () => void syncSplitFixtureGraph: () => void getSession: () => WorkspaceSessionState + editSession: (edit: (session: WorkspaceSessionState) => WorkspaceSessionState) => void makeSessionUnavailable: () => void removeVictimFromInventory: () => void retirePersistedTab: () => void @@ -101,7 +97,7 @@ function createHarness( badgeColor: '#000000', addedAt: 1 } - const store = { + const store = withDurableRuntimeStore({ getRepos: () => [repo], getRepo: (id: string) => (id === REPO_ID ? repo : undefined), getAllWorktreeMeta: () => ({}), @@ -117,11 +113,12 @@ function createHarness( throw flushError } }) - } + }) const acknowledged = makeDeferred() let closeTerminalTabError: Error | null = null let closeTerminalTabAction: (() => void | Promise) | null = null const closeTerminal = vi.fn() + const closeTerminalPane = vi.fn() const closeTerminalTab = vi.fn(() => { if (closeTerminalTabError) { return Promise.reject(closeTerminalTabError) @@ -162,7 +159,8 @@ function createHarness( ...(options.includeCanary ? [canaryProcess] : []) ]) const runtime = new OrcaRuntimeService(store as never) - runtime.setNotifier({ closeTerminal, closeTerminalTab } as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these close paths call only the listed notifier methods. + runtime.setNotifier({ closeTerminal, closeTerminalPane, closeTerminalTab } as never) runtime.setPtyController({ write: () => true, kill, @@ -201,16 +199,20 @@ function createHarness( } } graph.syncFixtureGraph() - return { + return withDurableRuntimeStore({ runtime, acknowledged, closeTerminal, + closeTerminalPane, closeTerminalTab, flushOrThrow: store.flushOrThrow, kill, stopAndWait, ...graph, getSession: () => session, + editSession: (edit) => { + session = edit(session) + }, makeSessionUnavailable: () => { sessionAvailable = false }, @@ -264,7 +266,7 @@ function createHarness( } } } - } + }) } function createPtyBackedPublishedSurfaceHarness(): CloseContinuityHarness { diff --git a/src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-state-fixture.ts b/src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-state-fixture.ts index d304ff57d23..21013296daf 100644 --- a/src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-state-fixture.ts +++ b/src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-state-fixture.ts @@ -106,3 +106,11 @@ export function makeSession(ptyId = PTY_ID, includeCanary = false): WorkspaceSes } } } + +export function makeDeferred() { + let resolve!: () => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} diff --git a/src/main/runtime/__fixtures__/qoder-no-account.meta.json b/src/main/runtime/__fixtures__/qoder-no-account.meta.json new file mode 100644 index 00000000000..e434627627c --- /dev/null +++ b/src/main/runtime/__fixtures__/qoder-no-account.meta.json @@ -0,0 +1,17 @@ +{ + "capturedAt": "2026-09-28T08:03:27.131Z", + "platform": "darwin", + "command": [ + "qodercli", + "--config-dir", + "/tmp/orca-qoder-probe/config", + "--cwd", + "/tmp/orca-qoder-probe/workspace", + "--prompt-interactive", + "Reply with hello." + ], + "cols": 100, + "rows": 32, + "note": "Qoder CLI 1.1.64; isolated trusted folder; no account; lifecycle hook capture", + "exitCode": 129 +} diff --git a/src/main/runtime/__fixtures__/qoder-no-account.txt b/src/main/runtime/__fixtures__/qoder-no-account.txt new file mode 100644 index 00000000000..9c675342559 --- /dev/null +++ b/src/main/runtime/__fixtures__/qoder-no-account.txt @@ -0,0 +1,66 @@ +[?u]11;?\[>q[>4;?m ]0;◇ Qoder CLI | Ready ]8;;78 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +[?25h[?25l[?1006l[?1002l[?1004h]0;◇ Qoder CLI | Ready [?2004h[?2004h[?1004h + ██████╭─Tipsforgettingstarted────────────────────────────────╮ +████│1.UseAGENTS.mdfilestocustomizeinteractions│ +██████QoderCLIv1.1.64│2./helpformoreinformation│ +████│3.Askcodingquestions,editcodeorruncommands│ + ██████NotLoginPleaseAuth│4.Bespecificforthebestresults│ +╰───────────────────────────────────────────────────────────╯ + + + Authenticating +──────────────────────────────────────────────────────────────────────────────────────────────────── + +⠋Waitingforauthentication... + +Escback +[>0q Welcome to Qoder CLI Signintogetstarted,orexittheapplication.  ❯1.Signintocontinue  2. Exittheapplication + +↑/↓navigate·Enterselect·Escback +]0;✦ Qoder CLI | Working   > Reply with hello.   Welcome to Qoder CLI ──────────────────────────────────────────────────────────────────────────────────────────────────── Sign in toget started, orexittheapplication.  ❯1.Signintocontinue  2. Exit the application + +↑/↓navigate·Enterselect·Escback +]0;✦ Reply with hello. | Working ]0;◇ Reply with hello. | Ready   x Qoder authenticationisnotready.Pleasesigninagain(e.g.via/login)beforesendinga  message.  Welcome to Qoder CLI ──────────────────────────────────────────────────────────────────────────────────────────────────── Sign in toget started, orexittheapplication.  ❯1.Signintocontinue  2. Exit the application + +↑/↓navigate·Enterselect·Escback +  + + + + + + + + + \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/qoder-ready.meta.json b/src/main/runtime/__fixtures__/qoder-ready.meta.json new file mode 100644 index 00000000000..d197b691db4 --- /dev/null +++ b/src/main/runtime/__fixtures__/qoder-ready.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-28T08:15:00.339Z", + "platform": "darwin", + "command": ["qodercli", "--cwd", "/tmp/orca-qoder-probe/workspace"], + "cols": 100, + "rows": 32, + "note": "Qoder CLI 1.1.64; existing browser-login config; credit limit reached; no prompt submitted", + "exitCode": 129 +} diff --git a/src/main/runtime/__fixtures__/qoder-ready.txt b/src/main/runtime/__fixtures__/qoder-ready.txt new file mode 100644 index 00000000000..e198f8ff3bb --- /dev/null +++ b/src/main/runtime/__fixtures__/qoder-ready.txt @@ -0,0 +1,66 @@ +[?u]11;?\[>q[>4;?m ]0;◇ Qoder CLI | Ready ]8;;78 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +[?25h[?25l[?1006l[?1002l[?1004h]0;◇ Qoder CLI | Ready [?2004h[?2004h[?1004h + ██████╭─Tipsforgettingstarted──────────────────────────────╮ +████│1.UseAGENTS.mdfilestocustomizeinteractions│ +██████QoderCLIv1.1.64│2./helpformoreinformation│ +████│3.Askcodingquestions,editcodeorruncommands│ + ██████SignedinBrowserLogin│4.Bespecificforthebestresults│ +╰─────────────────────────────────────────────────────────╯ + + +?forshortcuts +──────────────────────────────────────────────────────────────────────────────────────────────────── +Shift+TabtoAcceptEdits +──────────────────────────────────────────────────────────────────────────────────────────────────── +> Typeyourmessageor@path/to/file +──────────────────────────────────────────────────────────────────────────────────────────────────── +Model·ctx░░░░░░░░░░0%·/private/tmp/orca-qoder-probe/workspace +[>0q Qwen3.8-Max Model · ctx░░░░░░░░░░ 0% · /privte/tmp/oca-qoder-robe/workspace + ●Warning:Truecolor(24-bit)supportnotdetected.Usingaterminalwithtruecolor enabled will  result in a better visual experience.   ? for shortcuts ────────────────────────────────────────────────────────────────────────────────────────────────────  Shift+Tab to Accept Edits 1 MCP server ──────────────────────────────────────────────────────────────────────────────────────────────────── +> Typeyourmessageor@path/to/file +──────────────────────────────────────────────────────────────────────────────────────────────────── +Qwen3.8-MaxModel·ctx░░░░░░░░░░0%·/private/tmp/orca-qoder-probe/workspace + 1MCPserver · 34kills + + + + + Creditsexhausted.Use/usagefordetailsor/upgradefor more. + + + + + + + \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/qoder-trust-dialog.meta.json b/src/main/runtime/__fixtures__/qoder-trust-dialog.meta.json new file mode 100644 index 00000000000..f028a668af3 --- /dev/null +++ b/src/main/runtime/__fixtures__/qoder-trust-dialog.meta.json @@ -0,0 +1,15 @@ +{ + "capturedAt": "2026-09-28T07:56:42.619Z", + "platform": "darwin", + "command": [ + "qodercli", + "--config-dir", + "/tmp/orca-qoder-probe/config", + "--cwd", + "/tmp/orca-qoder-probe/workspace" + ], + "cols": 100, + "rows": 32, + "note": "Qoder CLI 1.1.64; isolated configuration; no account", + "exitCode": 129 +} diff --git a/src/main/runtime/__fixtures__/qoder-trust-dialog.txt b/src/main/runtime/__fixtures__/qoder-trust-dialog.txt new file mode 100644 index 00000000000..039f945f776 --- /dev/null +++ b/src/main/runtime/__fixtures__/qoder-trust-dialog.txt @@ -0,0 +1,58 @@ +[?u]11;?\[>q[>4;?m ]0;◇ Qoder CLI | Ready ]8;;78 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +[?25h[?25l[?1006l[?1002l[?1004h]0;◇ Qoder CLI | Ready [?2004h[?2004h[?1004h + ██████╭─Tipsforgettingstarted────────────────────────────────╮ +████│1.UseAGENTS.mdfilestocustomizeinteractions│ +██████QoderCLIv1.1.64│2./helpformoreinformation│ +████│3.Askcodingquestions,editcodeorruncommands│ + ██████NotLoginPleaseAuth│4.Bespecificforthebestresults│ +╰───────────────────────────────────────────────────────────╯ + + + Doyoutrustthefilesinthisfolder? +──────────────────────────────────────────────────────────────────────────────────────────────────── +Pleaseconfirmthisisyourownprojectorfromatrustedsource.Oncetrusted,QoderCLIcan +read,modify,andexecutefileshere,andwillloadlocalconfigurations(commands,hooks,MCP +servers,agents,skills,settings). + +/private/tmp/orca-qoder-probe/workspace + +❯1.Trustfolder +2.Don'ttrustandexit + +↑/↓navigate·Enterselect·Escback +[>0q  ● Warning: True color (24-bit) supportnotdetected.Usingaterminalwithtruecolorenabledwill  result in a better visual experience.   Do you trust the files in this folder? ──────────────────────────────────────────────────────────────────────────────────────────────────── Pleaseconfirmthisisyourownprojectorfromatrustedsource.Oncetrusted,QoderCLIcan read, modify, and execute files here, andwillloadlocalconfigurations(commands,hooks,MCP servers,agents,skills,settings).  /private/tmp/orca-qoder-probe/workspace  ❯ 1. Trust folder +2.Don'ttrustandexit + +↑/↓navigate·Enterselect·Escback + \ No newline at end of file diff --git a/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts b/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts index 2173d080dcb..12b3340d2ac 100644 --- a/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts +++ b/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts @@ -3,8 +3,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { PersistedState } from '../../shared/persisted-state-types' import type { RuntimeSyncWindowGraph } from '../../shared/runtime-types' import { closeTerminalTabInWorkspaceSession } from '../../shared/workspace-session-terminal-tab-close' +import { ProfileStateSqliteAuthority } from '../persistence/profile-state/profile-state-sqlite-authority' +import { DelayedAuthority } from '../persistence/loading-store/profile-state-delayed-authority-fixture' import { Store } from '../persistence/loading-store/store' import { OrcaRuntimeService } from './orca-runtime' import { buildHeadlessMobileSessionTerminalTabs } from './mobile-session-terminal-projection' @@ -28,7 +31,14 @@ function deferred(): { promise: Promise; resolve: () => void } { export function createAcknowledgedTabRetirementFixture(bound = false) { const directory = mkdtempSync(join(tmpdir(), 'orca-close-ack-')) - const store = new Store({ dataFile: join(directory, 'orca-data.json') }) + const databasePath = join(directory, 'profile-state.db') + const authority = new DelayedAuthority( + new ProfileStateSqliteAuthority(databasePath, 'ack-retirement') + ) + const store = new Store({ + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: authority + }) store.addRepo({ id: 'repo1', path: '/tmp/worktree', @@ -141,6 +151,7 @@ export function createAcknowledgedTabRetirementFixture(bound = false) { store.setWorkspaceSession( advanceTerminalTopologyRevision(store.getWorkspaceSession(), ACK_WORKTREE) ) + let finalFlush: Promise | undefined const entered = deferred() const acknowledgement = deferred() const closeTerminalTab = vi.fn(async () => { @@ -150,7 +161,7 @@ export function createAcknowledgedTabRetirementFixture(bound = false) { ACK_TAB ) store.setWorkspaceSession({ ...closed.session, terminalTopologyRevisionByRepoId: undefined }) - store.flushOrThrow() + await store.flushPendingOrThrowAsync() entered.resolve() await acknowledgement.promise }) @@ -171,20 +182,35 @@ export function createAcknowledgedTabRetirementFixture(bound = false) { return { runtime, store, + authority, entered, acknowledgement, closeTerminalTab, publish, + /** Reads what a relaunch would load, independent of the store's in-memory state. */ + readDisk: (): PersistedState => { + const reader = new ProfileStateSqliteAuthority(databasePath, 'ack-retirement') + try { + return JSON.parse(reader.readSerializedState() ?? '{}') + } finally { + reader.close() + } + }, hasTab: () => store.getWorkspaceSession().tabsByWorktree[ACK_WORKTREE].some((tab) => tab.id === ACK_TAB), close: (options: { force?: boolean } = {}) => runtime.closeMobileSessionTab(`id:${ACK_WORKTREE}`, ACK_TAB, { reason: 'user', ...options }), + /** The app-quit flush; it finalizes persistence, so dispose must not flush again. */ + quit: () => (finalFlush ??= store.flushFinalOrThrowAsync()), dispose: async () => { runtime.setNotifier(null) runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) - store.flush() - store.freezeWrites() - await store.waitForPendingWrite() + if (finalFlush) { + await finalFlush + } else { + await store.flushPendingOrThrowAsync() + await store.freezeWritesAsync() + } setRuntimeDesktopSurface(null) rmSync(directory, { recursive: true, force: true }) } diff --git a/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts b/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts index 91c3cb1b1d6..179dba29bd1 100644 --- a/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts +++ b/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts @@ -8,6 +8,7 @@ import { createAcknowledgedTabRetirementFixture } from './acknowledged-terminal-tab-retirement-fixture' import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' +import { delegatedMobileSessionTabClose } from './mobile-session-tab-close-outcome' const fixtures: ReturnType[] = [] afterEach(async () => { @@ -78,7 +79,7 @@ it.each([false, true])( } const pending = f.close() await f.entered.promise - f.runtime.onPtyExit('pty-a', 0, ACK_INCARNATION, { providerExitObserved: true }) + await f.runtime.onPtyExit('pty-a', 0, ACK_INCARNATION, { providerExitObserved: true }) expect(f.store.getWorkspaceSession().terminalLayoutsByTabId[ACK_TAB].ptyIdsByLeafId).toEqual({ [ACK_SECOND_LEAF]: 'pty-b' }) @@ -103,7 +104,7 @@ it('protects a persisted incarnation replacement on the same leaf and raw PTY ID const f = fixture(true) const pending = f.close() await f.entered.promise - f.store.persistPtyBinding({ + await f.store.persistPtyBinding({ worktreeId: ACK_WORKTREE, tabId: ACK_TAB, leafId: ACK_LEAF, @@ -133,13 +134,27 @@ it('rechecks current pins after renderer acknowledgement', async () => { f.acknowledgement.resolve() await expect(pending).rejects.toThrow('terminal_tab_pinned') expect(f.hasTab()).toBe(true) + await expect(f.store.flushPendingOrThrowAsync()).resolves.toBeUndefined() +}) + +it('keeps persistence writable when worktree teardown finds remaining terminal rows', async () => { + const f = fixture() + f.store.updateRepo('repo1', { executionHostId: 'ssh:target' }) + f.store.setWorktreeMeta(ACK_WORKTREE, { hostId: 'ssh:target' }) + f.store.setWorkspaceSession(f.store.getWorkspaceSession(), 'ssh:target') + vi.spyOn(f.runtime, 'closeMobileSessionTab').mockResolvedValue(delegatedMobileSessionTabClose()) + await expect(f.runtime.closeTerminalsForWorktree(`id:${ACK_WORKTREE}`)).rejects.toThrow( + 'terminal_close_incomplete' + ) + expect(f.hasTab()).toBe(true) + await expect(f.store.flushPendingOrThrowAsync()).resolves.toBeUndefined() }) it('preserves dormant SSH kill IDs when the acknowledged tab becomes headless', async () => { const f = fixture() const visible = 'ssh:target@@visible' const dormant = 'ssh:target@@persisted-only' - f.store.persistPtyBinding({ + await f.store.persistPtyBinding({ worktreeId: ACK_WORKTREE, tabId: ACK_TAB, leafId: ACK_LEAF, diff --git a/src/main/runtime/agent-prompt-line-settle.test.ts b/src/main/runtime/agent-prompt-line-settle.test.ts index 45d8bca53a6..4ac0d9648ee 100644 --- a/src/main/runtime/agent-prompt-line-settle.test.ts +++ b/src/main/runtime/agent-prompt-line-settle.test.ts @@ -37,7 +37,7 @@ describe('agent prompt line-settle scheduling', () => { () => undefined, 'antigravity' ) - const submission = runtime.sendTerminalAgentPrompt(handle, prompt) + const submission = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(submitDelayMs - 1) diff --git a/src/main/runtime/agent-prompt-receipt-correlation.test.ts b/src/main/runtime/agent-prompt-receipt-correlation.test.ts index e6b6d7f2793..77e8967b6d3 100644 --- a/src/main/runtime/agent-prompt-receipt-correlation.test.ts +++ b/src/main/runtime/agent-prompt-receipt-correlation.test.ts @@ -34,6 +34,7 @@ describe('agent prompt receipt correlation', () => { runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) const firstPromise = runtime.sendTerminalAgentPrompt(handle, 'first prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'historical-first', observationTimeoutMs: 0 @@ -41,6 +42,7 @@ describe('agent prompt receipt correlation', () => { await vi.runAllTimersAsync() const first = await firstPromise const secondPromise = runtime.sendTerminalAgentPrompt(handle, 'second prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'historical-second', observationTimeoutMs: 0 diff --git a/src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts b/src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts index c893623a18a..3d0c317a31b 100644 --- a/src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts +++ b/src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts @@ -102,7 +102,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { getForegroundProcess: async () => null }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -130,6 +132,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { }) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', acceptQueued: true, requestId: 'antigravity-pre-invocation', observationTimeoutMs: 20_000 @@ -156,7 +159,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { stateStartedAt: 1_000 }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.runAllTimersAsync() @@ -171,6 +176,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { const { runtime, handle, writes } = await createHookOnlyPromptRuntime(hook, 'codex') const firstPromise = runtime.sendTerminalAgentPrompt(handle, 'first prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'hook-queued-first', observationTimeoutMs: 0 @@ -193,6 +199,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { getForegroundProcess: async () => null }) const secondPromise = runtime.sendTerminalAgentPrompt(handle, 'second prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'hook-queued-second', observationTimeoutMs: 500 @@ -219,7 +226,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { it('does not write Enter after the PTY generation changes during settlement', async () => { vi.useFakeTimers() const { runtime, handle, writes } = await createPromptRuntime(() => undefined) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('terminal_handle_stale') await vi.advanceTimersByTimeAsync(0) @@ -256,6 +265,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { ) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', signal: controller.signal }) const rejected = expect(submission).rejects.toThrow('request_aborted') @@ -291,7 +301,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { sequenceAtSpawnStart ) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -318,9 +330,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { sequenceAtSpawnStart ) - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') expect(writes).toEqual([]) }) @@ -331,7 +343,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) } }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') await vi.runAllTimersAsync() @@ -351,8 +365,10 @@ describe('agent prompt submission runtime hook and generation cases', () => { } }) - const first = runtime.sendTerminalAgentPrompt(handle, 'first prompt') - const second = runtime.sendTerminalAgentPrompt(handle, 'second prompt') + const first = runtime.sendTerminalAgentPrompt(handle, 'first prompt', { inputKind: 'driving' }) + const second = runtime.sendTerminalAgentPrompt(handle, 'second prompt', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await Promise.all([first, second]) @@ -373,6 +389,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) const firstPromise = runtime.sendTerminalAgentPrompt(handle, 'first prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'queued-first', observationTimeoutMs: 0 @@ -380,6 +397,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { await vi.runAllTimersAsync() const first = await firstPromise const secondPromise = runtime.sendTerminalAgentPrompt(handle, 'second prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'queued-second', observationTimeoutMs: 0 @@ -418,6 +436,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { }) const first = runtime.sendTerminalAgentPrompt(handle, 'obsolete prompt', { + inputKind: 'driving', beforeWrite: async () => { firstWriteReached() await firstGate @@ -430,7 +449,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { 0 ) - const replacement = runtime.sendTerminalAgentPrompt(handle, 'replacement prompt') + const replacement = runtime.sendTerminalAgentPrompt(handle, 'replacement prompt', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(replacement).resolves.toMatchObject({ accepted: true }) expect(writes.some((data) => data.includes('replacement prompt'))).toBe(true) @@ -444,6 +465,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { let writeChecks = 0 const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), { + inputKind: 'driving', beforeWrite: () => { writeChecks += 1 if (writeChecks === 2) { @@ -466,6 +488,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { const controller = new AbortController() const { runtime, handle, writes } = await createPromptRuntime(() => undefined) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', signal: controller.signal }) const rejected = expect(submission).rejects.toThrow('request_aborted') @@ -483,6 +506,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { const controller = new AbortController() const { runtime, handle, writes } = await createPromptRuntime(() => undefined) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', signal: controller.signal }) const rejected = expect(submission).rejects.toThrow('request_aborted') diff --git a/src/main/runtime/agent-prompt-submission-runtime.test.ts b/src/main/runtime/agent-prompt-submission-runtime.test.ts index 873863ada75..700445483a9 100644 --- a/src/main/runtime/agent-prompt-submission-runtime.test.ts +++ b/src/main/runtime/agent-prompt-submission-runtime.test.ts @@ -43,7 +43,9 @@ describe('agent prompt submission runtime', () => { } ) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -59,7 +61,9 @@ describe('agent prompt submission runtime', () => { } }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -75,7 +79,9 @@ describe('agent prompt submission runtime', () => { runtime.onPtyData('pty-prompt', '\x1b[2J\x1b[H› review this', Date.now()) } }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.runAllTimersAsync() @@ -93,7 +99,9 @@ describe('agent prompt submission runtime', () => { } }) runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now()) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.runAllTimersAsync() @@ -109,7 +117,9 @@ describe('agent prompt submission runtime', () => { runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) } }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') await vi.runAllTimersAsync() @@ -122,9 +132,9 @@ describe('agent prompt submission runtime', () => { const { runtime, handle, writes } = await createPromptRuntime(() => undefined) runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') expect(writes).toEqual([]) }) @@ -140,9 +150,9 @@ describe('agent prompt submission runtime', () => { Date.now() ) - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') expect(writes).toEqual([]) }) @@ -155,9 +165,9 @@ describe('agent prompt submission runtime', () => { Date.now() ) - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') expect(writes).toEqual([]) }) @@ -175,7 +185,9 @@ describe('agent prompt submission runtime', () => { ) runtime.onPtyData('pty-prompt', '}\x07\x07', Date.now()) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') await vi.runAllTimersAsync() @@ -197,9 +209,9 @@ describe('agent prompt submission runtime', () => { Date.now() ) - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') expect(writes).toEqual([]) }) @@ -214,7 +226,9 @@ describe('agent prompt submission runtime', () => { text: 'Permission required\r\nAllow once\r\nAllow always\r\nReject\r\n' }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -235,7 +249,9 @@ describe('agent prompt submission runtime', () => { } }) runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now()) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') await vi.runAllTimersAsync() @@ -257,7 +273,9 @@ describe('agent prompt submission runtime', () => { runtime.onPtyData('pty-prompt', output, Date.now()) } }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') await vi.runAllTimersAsync() @@ -271,6 +289,7 @@ describe('agent prompt submission runtime', () => { let writeChecks = 0 const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), { + inputKind: 'driving', beforeWrite: () => { writeChecks += 1 if (writeChecks === 2) { @@ -291,6 +310,7 @@ describe('agent prompt submission runtime', () => { let writeChecks = 0 const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), { + inputKind: 'driving', beforeWrite: () => { writeChecks += 1 if (writeChecks === 2) { @@ -319,9 +339,9 @@ describe('agent prompt submission runtime', () => { ) runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') expect(writes).toEqual([]) }) @@ -369,7 +389,9 @@ describe('agent prompt submission runtime', () => { ) vi.setSystemTime(2_000) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -389,7 +411,9 @@ describe('agent prompt submission runtime', () => { Date.now() ) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -408,7 +432,9 @@ describe('agent prompt submission runtime', () => { Date.now() ) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.runAllTimersAsync() @@ -432,7 +458,9 @@ describe('agent prompt submission runtime', () => { Date.now() ) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -453,6 +481,7 @@ describe('agent prompt submission runtime', () => { ) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', acceptQueued: true, requestId: 'queued-output-only', observationTimeoutMs: 0 diff --git a/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts b/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts index f6298df8b17..3bc936c78a5 100644 --- a/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts +++ b/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts @@ -111,7 +111,9 @@ describe('agent prompt submit delay on a ConPTY host', () => { vi.useFakeTimers() const { runtime, handle, writes } = await createPromptRuntime() const delayMs = submitDelayFor('review this', 'win32') - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(delayMs - 1) @@ -131,7 +133,7 @@ describe('agent prompt submit delay on a ConPTY host', () => { // Measured ConPTY ingest for 8 KB is 60-89 ms; the old constant charged 1_500 ms. const delayMs = submitDelayFor(prompt, 'win32') expect(delayMs).toBeLessThan(700) - const submission = runtime.sendTerminalAgentPrompt(handle, prompt) + const submission = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(delayMs) @@ -146,7 +148,7 @@ describe('agent prompt submit delay on a ConPTY host', () => { vi.useFakeTimers() const { runtime, handle, writes, submitTimes } = await createPromptRuntime() const prompt = 'y'.repeat(320_000) - const submission = runtime.sendTerminalAgentPrompt(handle, prompt) + const submission = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') // Every byte is already handed to node-pty here -- the hazard is that the *host* is @@ -167,6 +169,7 @@ describe('agent prompt submit delay on a ConPTY host', () => { const controller = new AbortController() const { runtime, handle, writes } = await createPromptRuntime() const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', signal: controller.signal }) const rejected = expect(submission).rejects.toThrow('request_aborted') @@ -186,7 +189,9 @@ describe('agent prompt submit delay on a ConPTY host', () => { const { runtime, handle, writes } = await createPromptRuntime() const delayMs = submitDelayFor(HOST_PROBE_PROMPT, 'darwin') expect(delayMs).toBeLessThan(submitDelayFor(HOST_PROBE_PROMPT, 'win32')) - const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT) + const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT, { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(delayMs - 1) @@ -215,7 +220,9 @@ describe('agent prompt submit delay follows the execution host', () => { patchPtyRecord(runtime, { isWsl: true, wslDistro: 'Ubuntu' }) const delayMs = submitDelayFor(HOST_PROBE_PROMPT, 'win32') expect(delayMs).toBeGreaterThan(submitDelayFor(HOST_PROBE_PROMPT, 'linux')) - const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT) + const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT, { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(delayMs - 1) @@ -235,7 +242,9 @@ describe('agent prompt submit delay follows the execution host', () => { registerSshRemotePlatform('win32') const clientDelayMs = submitDelayFor(HOST_PROBE_PROMPT, 'darwin') const hostDelayMs = submitDelayFor(HOST_PROBE_PROMPT, 'win32') - const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT) + const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT, { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(clientDelayMs) @@ -255,7 +264,9 @@ describe('agent prompt submit delay follows the execution host', () => { registerSshRemotePlatform('linux') const delayMs = submitDelayFor(HOST_PROBE_PROMPT, 'linux') expect(delayMs).toBeLessThan(submitDelayFor(HOST_PROBE_PROMPT, 'win32')) - const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT) + const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT, { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(delayMs - 1) @@ -277,7 +288,9 @@ describe('agent prompt submit delay follows the execution host', () => { }) registerSshRemotePlatform(undefined) const delayMs = submitDelayFor(HOST_PROBE_PROMPT, 'win32') - const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT) + const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT, { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(delayMs - 1) @@ -341,7 +354,9 @@ describe('agent prompt render gate on a ConPTY host', () => { useHostPlatform('win32') vi.useFakeTimers() const { runtime, handle, writes, submitTimes } = await createSettlementRuntime() - const submission = runtime.sendTerminalAgentPrompt(handle, 'y'.repeat(320_000)) + const submission = runtime.sendTerminalAgentPrompt(handle, 'y'.repeat(320_000), { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') // Marker at 100 ms + a 1_500 ms quiet window would have submitted at ~1_600 ms, while @@ -370,7 +385,7 @@ describe('agent prompt render gate on a ConPTY host', () => { markerDelayMs: ingestMs - 1_000, noiseUntilMs: ingestMs + 20_000 }) - const submission = runtime.sendTerminalAgentPrompt(handle, prompt) + const submission = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(ingestMs + 8_000 - 1) @@ -387,7 +402,9 @@ describe('agent prompt render gate on a ConPTY host', () => { useHostPlatform('win32') vi.useFakeTimers() const { runtime, handle, submitTimes } = await createSettlementRuntime() - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.runAllTimersAsync() @@ -409,7 +426,11 @@ describe('plain terminal send suffix delay', () => { useHostPlatform('win32') vi.useFakeTimers() const { runtime, handle, writes, submitTimes } = await createPromptRuntime() - const send = runtime.sendTerminal(handle, { text: 'z'.repeat(320_000), enter: true }) + const send = runtime.sendTerminal( + handle, + { text: 'z'.repeat(320_000), enter: true }, + { inputKind: 'driving' } + ) // Same hazard as the agent-prompt path: a flat 500 ms wrote Enter mid-paste here. await vi.advanceTimersByTimeAsync(3_342) @@ -429,7 +450,7 @@ describe('plain terminal send suffix delay', () => { const send = runtime.sendTerminal( handle, { text: 'z'.repeat(320_000), enter: true }, - { signal: controller.signal } + { inputKind: 'driving', signal: controller.signal } ) const rejected = expect(send).rejects.toThrow('request_aborted') diff --git a/src/main/runtime/agent-session-acquisition-failure-settlement.ts b/src/main/runtime/agent-session-acquisition-failure-settlement.ts index 9335dd230bb..f6efaa890c3 100644 --- a/src/main/runtime/agent-session-acquisition-failure-settlement.ts +++ b/src/main/runtime/agent-session-acquisition-failure-settlement.ts @@ -15,10 +15,12 @@ import type { AgentSessionStoreState } from './agent-session-record-store-file' * How the failed attempt's provider process was accounted for. * - `exit-proven`: cleanup observed the whole tree gone. * - `root-exit-observed`: the owner root's exit was observed first-hand, so the - * identity this lease is keyed on is dead, but its descendants could not be - * verified. Releases the lease and says exactly that, claiming nothing more. + * identity this lease is keyed on is dead, but its descendants were not proven + * gone. Releases the lease and says exactly that, claiming nothing more. * - `processless`: the attempt failed before a process existed. - * - `unproven`: nothing about the process was observed; the reservation latches. + * - `unproven`: nothing about the process was observed. A recorded owner goes to recovery, which + * concludes about it; a reservation that recorded none is released, since the adapter already + * closed the stdio of anything it spawned. */ export type AgentSessionAcquisitionExitProof = | 'exit-proven' @@ -84,34 +86,32 @@ export function settleFailedAgentSessionPostAcquisitionAttachment( } const next = args.exitProof === 'unproven' - ? withLease(record, { - ...record.lease, - handoffStage: 'recovering', - handoffOperationId: null, - lastRenewedAt: args.now - }) + ? { + ...withLease(record, { + ...record.lease, + handoffStage: 'recovering', + handoffOperationId: null + }), + updatedAt: args.now + } : withLease(record, { ...record.lease, runtimeFence: nextAgentSessionFence(record.lease), handoffStage: null, ownerProcess: null, reservedSpawnToken: null, - processlessAt: null, claimStatus: 'released', lastRenewedAt: args.now, handoffOperationId: null, - deathEvidence: - args.exitProof === 'root-exit-observed' - ? { - kind: 'exit-observed', - detail: 'the provider process exited; its descendants were not verifiable', - observedAt: args.now - } - : { - kind: 'exit-observed', - detail: 'post-acquisition cleanup proved no provider child remains', - observedAt: args.now - } + deathEvidence: { + kind: 'exit-observed', + detail: + args.exitProof === 'root-exit-observed' + ? 'the provider process exited; its descendants were not proven gone' + : 'post-acquisition cleanup proved no provider child remains', + observedAt: args.now, + ownerFence: record.lease.runtimeFence + } }) state.records.set(args.sessionId, next) state.operations = settleAgentSessionOperation(state.operations, args) @@ -131,15 +131,18 @@ function settleFailedLease( ) { throw new Error('agent_session_ownership_unknown') } - if (args.exitProof === 'unproven') { - return withLease(record, { - ...record.lease, - handoffStage: record.lease.ownerProcess ? 'recovering' : 'manual-recovery', - // The operation is durably settled failed below; a lease still naming it would - // read as an in-flight transfer to every consumer that keys on the stage + id pair. - handoffOperationId: null, - lastRenewedAt: args.now - }) + if (args.exitProof === 'unproven' && record.lease.ownerProcess) { + // Parking in recovery proves nothing alive, so `lastRenewedAt` keeps the spawn's proof. + return { + ...withLease(record, { + ...record.lease, + handoffStage: 'recovering', + // The operation is durably settled failed below; a lease still naming it would + // read as an in-flight transfer to every consumer that keys on the stage + id pair. + handoffOperationId: null + }), + updatedAt: args.now + } } return withLease(record, { ...record.lease, @@ -147,33 +150,38 @@ function settleFailedLease( handoffStage: null, ownerProcess: null, reservedSpawnToken: null, - processlessAt: null, claimStatus: 'released', lastRenewedAt: args.now, handoffOperationId: null, - deathEvidence: acquisitionDeathEvidence(args.exitProof, args.now) + deathEvidence: acquisitionDeathEvidence(args.exitProof, args.now, record.lease.runtimeFence) }) } -/** Records only what was observed: never a tree claim the cleanup did not make. */ +/** Records only what was observed: never a tree claim the cleanup did not make, and nothing at all + * when nothing was. */ function acquisitionDeathEvidence( exitProof: AgentSessionAcquisitionExitProof, - observedAt: number -): AgentSessionDeathEvidence { + observedAt: number, + ownerFence: number +): AgentSessionDeathEvidence | null { + if (exitProof === 'unproven') { + return null + } + const proof = { observedAt, ownerFence } if (exitProof === 'processless') { - return { kind: 'pid-absent', detail: 'reservation failed before spawn', observedAt } + return { kind: 'pid-absent', detail: 'reservation failed before spawn', ...proof } } if (exitProof === 'root-exit-observed') { return { kind: 'exit-observed', - detail: 'the provider process exited; its descendants were not verifiable', - observedAt + detail: 'the provider process exited; its descendants were not proven gone', + ...proof } } // Cleanup proved no child of this attempt remains; it may never have spawned. return { kind: 'exit-observed', detail: 'acquisition cleanup proved no provider child remains', - observedAt + ...proof } } diff --git a/src/main/runtime/agent-session-conversation-command-record.ts b/src/main/runtime/agent-session-conversation-command-record.ts index 2e7053e8a1b..297b9dd34e7 100644 --- a/src/main/runtime/agent-session-conversation-command-record.ts +++ b/src/main/runtime/agent-session-conversation-command-record.ts @@ -1,4 +1,6 @@ +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import type { AgentSessionStoreState } from './agent-session-record-store-file' +import { AgentSessionTabTable } from './agent-session-tab-table' import type { AgentSessionConversationCommandRecord } from '../../shared/agent-session-conversation-command' export function commitConversationCommandRecord( @@ -9,7 +11,7 @@ export function commitConversationCommandRecord( ): void { const record = state.records.get(sessionId) if (!record || record.lease.runtimeFence !== fence) { - throw new Error('agent_session_checkpoint_stale') + throw agentSessionRefusalError('agent_session_checkpoint_stale', { reason: 'leaseMoved' }) } state.records.set(sessionId, { ...record, conversationCommand: command }) if ( @@ -18,10 +20,9 @@ export function commitConversationCommandRecord( command.replacementSessionId ) { if (!state.records.has(command.replacementSessionId)) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) } - state.visibleSessionIds.delete(sessionId) - state.visibleSessionIds.add(command.replacementSessionId) - state.visibleSessionIdsIndexPresent = true + state.sessionTabs ??= new AgentSessionTabTable() + state.sessionTabs.move(sessionId, command.replacementSessionId) } } diff --git a/src/main/runtime/agent-session-death-evidence-persistence.test.ts b/src/main/runtime/agent-session-death-evidence-persistence.test.ts new file mode 100644 index 00000000000..6e0bff8098c --- /dev/null +++ b/src/main/runtime/agent-session-death-evidence-persistence.test.ts @@ -0,0 +1,228 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionDeathEvidence } from '../../shared/agent-session-record' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import type { AgentSessionFailedAcquisitionSettlement } from './agent-session-acquisition-failure-settlement' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { agentSessionStorePath } from './agent-session-record-store-file' + +const SESSION = 'session-alpha-1' +/** The fixture lease's last renewal. */ +const LAST_RENEWED_AT = 30_000 + +let directory: string + +async function seed(deathEvidence: AgentSessionDeathEvidence | null): Promise { + const lease = + deathEvidence === null + ? agentSessionLeaseFixture() + : agentSessionLeaseFixture({ + ownerProcess: null, + reservedSpawnToken: null, + claimStatus: 'released', + deathEvidence + }) + await writeFile( + agentSessionStorePath(directory), + JSON.stringify({ + schemaVersion: 2, + hostId: 'local', + records: { [SESSION]: agentSessionRecordFixture(lease) }, + operations: {}, + retiredClaimKeys: [], + unusableRecords: {} + }) + ) +} + +function open(): Promise { + return AgentSessionRecordStore.open({ directory, hostId: 'local' }) +} + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-death-evidence-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +describe('death evidence on disk', () => { + it('records the last renewal before the crash and reads it back after another restart', async () => { + await seed(null) + const crashed = await open() + await crashed.reconcileOnRestart({ + probe: async () => ({ outcome: 'pid-absent' }), + now: 90_000 + }) + const evidence = { + kind: 'pid-absent', + detail: 'recorded pid absent on host', + observedAt: 90_000, + ownerFence: 7, + lastProvenAliveAt: LAST_RENEWED_AT + } + expect(crashed.getRecord(SESSION)?.lease.deathEvidence).toEqual(evidence) + expect((await open()).getRecord(SESSION)?.lease.deathEvidence).toEqual(evidence) + }) + + it('loads evidence an older build wrote without a proven-alive time', async () => { + const olderBuild = { kind: 'pid-absent' as const, detail: 'gone', observedAt: 90_000 } + await seed(olderBuild) + const store = await open() + expect(store.isSessionUnreadable(SESSION)).toBe(false) + expect(store.getRecord(SESSION)?.lease.deathEvidence).toEqual(olderBuild) + }) + + it.each([ + ['proves the owner alive after the probe found it gone', { lastProvenAliveAt: 90_001 }], + ['names no possible owner', { ownerFence: -1 }] + ])('quarantines evidence that %s', async (_why, field) => { + await seed({ kind: 'pid-absent', detail: 'gone', observedAt: 90_000, ...field }) + expect((await open()).isSessionUnreadable(SESSION)).toBe(true) + }) +}) + +describe('who is told a proof of death landed', () => { + it('tells a listener once the proof is committed, and never for a write that proves nothing', async () => { + await seed(null) + const store = await open() + const told: unknown[] = [] + store.onDeathEvidence((sessionId) => told.push(store.getRecord(sessionId)?.lease.deathEvidence)) + const unsubscribed = vi.fn() + store.onDeathEvidence(unsubscribed)() + + await expect( + store.evictProvenDeadOwner({ + sessionId: SESSION, + expectedFence: 6, + probe: { outcome: 'pid-absent' }, + now: 80_000 + }) + ).rejects.toThrow() + await store.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: 90_000 }) + // The proof already on the record is not news to a later write. + await store.setSessionTabVisibility(SESSION, true) + + expect(told).toEqual([expect.objectContaining({ kind: 'pid-absent', ownerFence: 7 })]) + expect(unsubscribed).not.toHaveBeenCalled() + }) +}) + +describe('a failed acquisition', () => { + const NOW = 1_800_000_000_000 + const OPERATION_ID = `${NOW}-${'1'.padStart(32, '0')}` + + /** Reserve and observe the spawn at NOW, as an attach does before it can fail. */ + async function spawnedOwner(store: AgentSessionRecordStore): Promise { + const reserved = await store.reserveOwner({ + sessionId: SESSION, + location: agentSessionRecordFixture().location, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + expectedFence: null, + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: OPERATION_ID, + probe: { outcome: 'indeterminate', reason: 'no answer' }, + operation: { callerKey: 'client-1', operationId: OPERATION_ID, fingerprint: 'fp-1' }, + now: NOW + }) + const fence = reserved.record.lease.runtimeFence + await store.commitProcessIdentity({ + sessionId: SESSION, + fence, + process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken: 'spawn-a' }, + now: NOW + }) + return fence + } + + function unproven(fence: number, now: number): AgentSessionFailedAcquisitionSettlement { + return { + sessionId: SESSION, + fence, + spawnToken: 'spawn-a', + callerKey: 'client-1', + operationId: OPERATION_ID, + outcome: { status: 'failed', code: 'agent_session_operation_invalid', message: 'failed' }, + exitProof: 'unproven', + now + } + } + + it.each([ + ['before the owner proved its handle', false], + ['after the owner proved its handle', true] + ] as const)( + 'parks in recovery keeping the last proof of life, not the failure, %s', + async (_when, proved) => { + const store = await open() + const fence = await spawnedOwner(store) + if (proved) { + await store.proveOwner({ + sessionId: SESSION, + fence, + link: { + linkId: 'link-1', + handle: { provider: 'claude', sessionId: 'provider-session-1', leafUuid: null }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + }, + now: NOW + }) + await store.settleFailedPostAcquisitionAttachment(unproven(fence, NOW + 60_000)) + } else { + await store.settleFailedAcquisition(unproven(fence, NOW + 60_000)) + } + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + handoffStage: 'recovering', + lastRenewedAt: NOW + }) + + const evicted = await store.evictProvenDeadOwner({ + sessionId: SESSION, + expectedFence: fence, + probe: { outcome: 'pid-absent' }, + now: NOW + 120_000 + }) + expect(evicted.lease.deathEvidence).toMatchObject({ lastProvenAliveAt: NOW }) + } + ) + + it.each(['exit-proven', 'root-exit-observed', 'processless'] as const)( + 'names its own fence in the proof when cleanup settles it %s', + async (exitProof) => { + const store = await open() + const fence = await spawnedOwner(store) + const settled = + exitProof === 'processless' + ? await store.settleFailedAcquisition({ ...unproven(fence, NOW), exitProof }) + : await (async () => { + await store.proveOwner({ + sessionId: SESSION, + fence, + link: { + linkId: 'link-1', + handle: { provider: 'claude', sessionId: 'provider-session-1', leafUuid: null }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + }, + now: NOW + }) + return store.settleFailedPostAcquisitionAttachment({ + ...unproven(fence, NOW), + exitProof + }) + })() + expect(settled.lease.deathEvidence).toMatchObject({ ownerFence: fence }) + } + ) +}) diff --git a/src/main/runtime/agent-session-eviction-settlement-latch.test.ts b/src/main/runtime/agent-session-eviction-settlement-latch.test.ts deleted file mode 100644 index ebb893ef7e5..00000000000 --- a/src/main/runtime/agent-session-eviction-settlement-latch.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - agentSessionLeaseFixture, - agentSessionRecordFixture -} from '../../shared/agent-session-record.test-fixture' -import { evictAgentSessionOwner } from './agent-session-lease-transitions' -import { applyAgentSessionRestartAdjudication } from './agent-session-restart-lease-transitions' - -const NOW = 1_800_000_000_000 - -describe('proven-dead agent session eviction settlement', () => { - it('latches restart eviction with a stable id while keeping the lease resumable', () => { - const record = agentSessionRecordFixture( - agentSessionLeaseFixture({ runtimeKind: 'native', unreconciled: true }) - ) - - const evicted = applyAgentSessionRestartAdjudication({ - record, - probe: { outcome: 'pid-absent' }, - now: NOW - }) - - expect(evicted.lease).toMatchObject({ - claimStatus: 'released', - runtimeFence: 8, - handoffStage: null, - settlementRetryRequired: true, - settlementRetryId: 'restart-eviction:session-alpha-1:8', - deathEvidence: { kind: 'pid-absent', detail: 'recorded pid absent on host' } - }) - }) - - it('owes no settlement for a start that died before proving its handle', () => { - const record = agentSessionRecordFixture( - agentSessionLeaseFixture({ - runtimeKind: 'native', - claimStatus: 'reserved', - handoffStage: 'new-owner-proving', - handoffOperationId: 'attach-op-1', - unreconciled: true - }) - ) - - const evicted = applyAgentSessionRestartAdjudication({ - record, - probe: { outcome: 'pid-absent' }, - now: NOW - }) - - expect(evicted.lease).toMatchObject({ - claimStatus: 'released', - runtimeFence: 8, - handoffStage: null, - handoffOperationId: null - }) - expect(evicted.lease).not.toHaveProperty('settlementRetryRequired') - }) - - it('latches recovery eviction from the same evicted disposition', () => { - const record = agentSessionRecordFixture( - agentSessionLeaseFixture({ runtimeKind: 'native', handoffStage: 'recovering' }) - ) - - const evicted = evictAgentSessionOwner({ - record, - expectedFence: 7, - probe: { outcome: 'identity-mismatch', field: 'process-start-time' }, - now: NOW, - journalSettlement: 'required' - }) - - expect(evicted.lease).toMatchObject({ - claimStatus: 'released', - runtimeFence: 8, - handoffStage: null, - settlementRetryRequired: true, - settlementRetryId: 'restart-eviction:session-alpha-1:8', - deathEvidence: { kind: 'identity-mismatch', detail: 'mismatched process-start-time' } - }) - }) - - it('never latches an indeterminate owner', () => { - const restartRecord = agentSessionRecordFixture( - agentSessionLeaseFixture({ runtimeKind: 'native', unreconciled: true }) - ) - const recovered = applyAgentSessionRestartAdjudication({ - record: restartRecord, - probe: { outcome: 'indeterminate', reason: 'remote host unavailable' }, - now: NOW - }) - const recoveryRecord = agentSessionRecordFixture( - agentSessionLeaseFixture({ runtimeKind: 'native', handoffStage: 'recovering' }) - ) - - expect(recovered.lease).toMatchObject({ - handoffStage: 'recovering', - ownerProcess: { pid: 4242 } - }) - expect(recovered.lease).not.toHaveProperty('settlementRetryRequired') - expect(recovered.lease).not.toHaveProperty('settlementRetryId') - expect(() => - evictAgentSessionOwner({ - record: recoveryRecord, - expectedFence: 7, - probe: { outcome: 'indeterminate', reason: 'remote host unavailable' }, - now: NOW, - journalSettlement: 'required' - }) - ).toThrow('agent_session_ownership_unknown') - expect(recoveryRecord.lease).not.toHaveProperty('settlementRetryRequired') - expect(recoveryRecord.lease).not.toHaveProperty('settlementRetryId') - }) - - it('preserves a null handoff stage when the latch survives another restart', () => { - const record = agentSessionRecordFixture( - agentSessionLeaseFixture({ - runtimeKind: 'native', - ownerProcess: null, - reservedSpawnToken: null, - claimStatus: 'released', - handoffStage: null, - settlementRetryRequired: true, - settlementRetryId: 'restart-eviction:session-alpha-1:8', - unreconciled: true - }) - ) - - const restored = applyAgentSessionRestartAdjudication({ - record, - probe: { outcome: 'indeterminate', reason: 'remote host unavailable' }, - now: NOW - }) - - expect(restored.lease).toMatchObject({ - handoffStage: null, - settlementRetryRequired: true, - settlementRetryId: 'restart-eviction:session-alpha-1:8', - unreconciled: false - }) - }) -}) diff --git a/src/main/runtime/agent-session-launch-env-backfill.test.ts b/src/main/runtime/agent-session-launch-env-backfill.test.ts deleted file mode 100644 index 50cf9e6b35c..00000000000 --- a/src/main/runtime/agent-session-launch-env-backfill.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { AgentSessionRecordStore } from './agent-session-record-store' -import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' - -const NOW = 1_800_000_000_000 -const SESSION = 'session-launch-env' -let directory: string - -function request(overrides: Partial = {}): AgentSessionReserveRequest { - return { - sessionId: SESSION, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' - }, - provider: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, - expectedFence: null, - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' }, - operation: { - callerKey: 'client-1', - operationId: `${NOW}-00000000000000000000000000000001`, - fingerprint: 'fp-1' - }, - now: NOW, - ...overrides - } -} - -beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-launch-env-')) -}) - -afterEach(async () => { - await rm(directory, { recursive: true, force: true }) -}) - -describe('legacy agent session launch environment', () => { - it('durably pins the first environment resolved by a current reservation', async () => { - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) - await store.reserveOwner(request()) - await store.reserveOwner( - request({ - expectedFence: 1, - spawnToken: 'spawn-b', - launchEnv: { ANTHROPIC_AUTH_TOKEN: 'pinned-token' }, - operation: { - callerKey: 'client-1', - operationId: `${NOW}-00000000000000000000000000000002`, - fingerprint: 'fp-2' - } - }) - ) - - const reopened = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) - expect( - (reopened.getRecord(SESSION) as { launchEnv?: Record } | null)?.launchEnv - ).toBeUndefined() - }) - - it('rejects an environment that could not be reloaded before writing it', async () => { - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) - const launchEnv = Object.fromEntries( - Array.from({ length: 257 }, (_, index) => [`KEY_${index}`, 'value']) - ) - - await expect(store.reserveOwner(request({ launchEnv }))).rejects.toThrow( - 'agent_session_launch_env_invalid' - ) - expect(store.getRecord(SESSION)).toBeNull() - }) - - it('rejects an overlong environment key before writing it', async () => { - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) - - await expect( - store.reserveOwner(request({ launchEnv: { ['K'.repeat(513)]: 'value' } })) - ).rejects.toThrow('agent_session_launch_env_invalid') - expect(store.getRecord(SESSION)).toBeNull() - }) -}) diff --git a/src/main/runtime/agent-session-lease-transitions.ts b/src/main/runtime/agent-session-lease-transitions.ts index 580fbeda255..41e8d300e1e 100644 --- a/src/main/runtime/agent-session-lease-transitions.ts +++ b/src/main/runtime/agent-session-lease-transitions.ts @@ -6,9 +6,9 @@ * observed process identity, and a proved provider handle — in that order, at one fence. */ +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { adjudicateAgentSessionRestart, - agentSessionRestartEvictionSettlementId, evaluateAgentSessionAcquisition, type AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' @@ -16,7 +16,9 @@ import { appendAgentSessionProviderHandleLink, type AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' import type { + AgentSessionDeathEvidence, AgentSessionJournalCheckpoint, AgentSessionLease, AgentSessionProcessIdentity, @@ -40,10 +42,10 @@ export function withLease( export function assertFence(lease: AgentSessionLease, fence: number): void { if (lease.runtimeFence !== fence) { - throw new Error('agent_session_checkpoint_stale') + throw agentSessionRefusalError('agent_session_checkpoint_stale', { reason: 'leaseMoved' }) } if (lease.unreconciled) { - throw new Error('execution_owner_reconciling') + throw agentSessionRefusalError('execution_owner_reconciling', { reason: 'hostReconciling' }) } } @@ -65,7 +67,7 @@ export function reserveAgentSessionOwner(args: { probe: args.probe }) if (decision.decision === 'refused') { - throw new Error(decision.code) + throw agentSessionRefusalError(decision.code, decision.details) } if (decision.decision === 'retry-reservation') { return { record, disposition: 'retry-reservation' } @@ -81,14 +83,11 @@ export function reserveAgentSessionOwner(args: { provenHandleLinkId: null, ownerProcess: null, reservedSpawnToken: reservation.spawnToken, - processlessAt: null, leaseDeadlineAt: reservation.now + reservation.leaseTtlMs, lastRenewedAt: reservation.now, handoffOperationId: reservation.handoffOperationId, claimKeyId: reservation.claimKeyId, claimStatus: 'reserved', - settlementRetryRequired: undefined, - settlementRetryId: undefined, deathEvidence: null }) } @@ -108,16 +107,19 @@ export function commitAgentSessionProcessIdentity( const { record } = args assertFence(record.lease, args.fence) if (record.lease.claimStatus !== 'reserved' || record.lease.ownerProcess !== null) { - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', { + reason: 'spawnIdentityMismatch' + }) } if (record.lease.reservedSpawnToken !== args.process.spawnToken) { // Why: a child that cannot echo the reserved token is not the process Orca started. - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', { + reason: 'spawnIdentityMismatch' + }) } return withLease(record, { ...record.lease, ownerProcess: args.process, - processlessAt: null, lastRenewedAt: args.now }) } @@ -140,7 +142,9 @@ export function proveAgentSessionOwner(args: { record.lease.handoffStage !== 'new-owner-proving' || record.lease.ownerProcess === null ) { - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', { + reason: 'spawnIdentityMismatch' + }) } if (args.link.handle.provider !== record.provider) { throw new Error('agent_session_provider_handle_provider_mismatch') @@ -205,13 +209,9 @@ export function evictAgentSessionOwner(args: { expectedFence: number probe: AgentSessionOwnerProbe now: number - journalSettlement: 'required' | 'not-required' }): AgentSessionRecord { const { record } = args assertFence(record.lease, args.expectedFence) - if (record.lease.settlementRetryRequired) { - throw new Error('agent_session_ownership_unknown') - } const adjudication = adjudicateAgentSessionRestart({ lease: record.lease, probe: args.probe, @@ -224,29 +224,50 @@ export function evictAgentSessionOwner(args: { ...record.lease, handoffStage: null, handoffOperationId: null, - processlessAt: null, lastRenewedAt: args.now }) } if (adjudication.disposition !== 'evicted') { throw new Error('agent_session_ownership_unknown') } - const settlementRequired = args.journalSettlement === 'required' + return releasedAgentSessionLease(record, adjudication.nextFence, adjudication.evidence, args.now) +} + +/** + * Recovery's conclusion when proof never came: a recorded owner whose identity cannot be verified, + * or that survived the stop ladder. Its transport died with the runtime that held it, so nothing + * can drive it, and a verdict that never arrives must not hold the conversation. Nothing proved it + * gone, so no death evidence is written. + */ +export function releaseUnprovenAgentSessionOwner(args: { + record: AgentSessionRecord + expectedFence: number + now: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.expectedFence) + if (record.lease.handoffStage !== 'recovering') { + throw new Error('agent_session_ownership_unknown') + } + return releasedAgentSessionLease(record, nextAgentSessionFence(record.lease), null, args.now) +} + +function releasedAgentSessionLease( + record: AgentSessionRecord, + runtimeFence: number, + deathEvidence: AgentSessionDeathEvidence | null, + now: number +): AgentSessionRecord { return withLease(record, { ...record.lease, - runtimeFence: adjudication.nextFence, + runtimeFence, handoffStage: null, ownerProcess: null, reservedSpawnToken: null, - processlessAt: null, claimStatus: 'released', - lastRenewedAt: args.now, + lastRenewedAt: now, handoffOperationId: null, - deathEvidence: adjudication.evidence, - settlementRetryRequired: settlementRequired ? true : undefined, - settlementRetryId: settlementRequired - ? agentSessionRestartEvictionSettlementId(record.lease, adjudication) - : undefined + deathEvidence }) } diff --git a/src/main/runtime/agent-session-operation-admission.test.ts b/src/main/runtime/agent-session-operation-admission.test.ts index 0b8b9ff91f2..41df221ad12 100644 --- a/src/main/runtime/agent-session-operation-admission.test.ts +++ b/src/main/runtime/agent-session-operation-admission.test.ts @@ -48,6 +48,10 @@ describe('global agent-session operation admission', () => { fingerprint: 'different-send', now: NOW + 1 }).decision - ).toEqual({ decision: 'refused', code: 'agent_session_operation_conflict' }) + ).toEqual({ + decision: 'refused', + code: 'agent_session_operation_conflict', + details: { reason: 'operationIdReused' } + }) }) }) diff --git a/src/main/runtime/agent-session-operation-admission.ts b/src/main/runtime/agent-session-operation-admission.ts index 49dbab1c90d..f16d48efb9d 100644 --- a/src/main/runtime/agent-session-operation-admission.ts +++ b/src/main/runtime/agent-session-operation-admission.ts @@ -36,6 +36,7 @@ export type AgentSessionMutationOperationAdmission = { hostFingerprint: string now: number operationIdScope?: 'global' + conversationWrite?: true } export type AgentSessionMutationOperationDecision = { @@ -132,7 +133,7 @@ function mutationOperation( } } -/** Admit the ledger row and its lease/fence preconditions in one durable transaction. */ +/** Admit the ledger row and its writer-lease precondition in one durable transaction. */ export function admitAgentSessionMutationOperation( state: AgentSessionStoreState, args: AgentSessionMutationOperationAdmission @@ -149,7 +150,8 @@ export function admitAgentSessionMutationOperation( envelope: args.envelope, hostFingerprint: args.hostFingerprint, ledger: ledger.decision, - lease: record.lease + lease: record.lease, + ...(args.conversationWrite ? { conversationWrite: true } : {}) }) if (ledger.decision.decision === 'admit' && admission.decision === 'refused') { ledger.rows.delete(agentSessionOperationKey(operation.callerKey, operation.operationId)) diff --git a/src/main/runtime/agent-session-orphan-child-reaper.test.ts b/src/main/runtime/agent-session-orphan-child-reaper.test.ts deleted file mode 100644 index eb837ac4a22..00000000000 --- a/src/main/runtime/agent-session-orphan-child-reaper.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -// A child spawned under a reservation whose record was lost is invisible to the lease. Reaping -// is bounded cleanup only; it never replaces the lease proof required to grant another writer. - -import { describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecordStore } from './agent-session-record-store' -import { stopOrphanAgentSessionChildren } from './agent-session-orphan-child-reaper' - -function storeWithLeasedTokens(tokens: readonly string[]) { - return { - listOrphanSpawnTokens: (observed: readonly string[]) => - observed.filter((token) => !tokens.includes(token)) - } as Pick -} - -describe('orphan agent-session child reaper', () => { - it('stops every process whose spawn token no lease claims', async () => { - const stop = vi.fn() - - const stopped = await stopOrphanAgentSessionChildren({ - store: storeWithLeasedTokens(['token-owned']), - scan: async () => - new Map([ - ['token-owned', [101]], - ['token-lost', [202, 203]] - ]), - stop - }) - - expect(stopped).toEqual([202, 203]) - expect(stop).toHaveBeenCalledWith(202, 'SIGTERM') - expect(stop).toHaveBeenCalledWith(203, 'SIGTERM') - expect(stop).not.toHaveBeenCalledWith(101, 'SIGTERM') - }) - - it('stops nothing on a host that cannot enumerate spawn tokens', async () => { - const stop = vi.fn() - - // Null is "cannot answer", never "no tokens" — treating it as an empty scan would be a - // license to signal nothing, but a future empty-map reading would be a license to signal - // whatever the caller guessed. - const stopped = await stopOrphanAgentSessionChildren({ - store: { - listOrphanSpawnTokens: () => { - throw new Error('the reaper must not ask when the host could not answer') - } - }, - scan: async () => null, - stop - }) - - expect(stopped).toEqual([]) - expect(stop).not.toHaveBeenCalled() - }) - - it('surfaces a failure to signal an observed orphan', async () => { - const failure = Object.assign(new Error('not permitted'), { code: 'EPERM' }) - - await expect( - stopOrphanAgentSessionChildren({ - store: storeWithLeasedTokens([]), - scan: async () => new Map([['token-lost', [202]]]), - stop: () => { - throw failure - } - }) - ).rejects.toBe(failure) - }) -}) diff --git a/src/main/runtime/agent-session-orphan-child-reaper.ts b/src/main/runtime/agent-session-orphan-child-reaper.ts deleted file mode 100644 index 68d9c899024..00000000000 --- a/src/main/runtime/agent-session-orphan-child-reaper.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Best-effort stop for provider children that carry an Orca spawn token no lease claims. - * - * A child spawned under a reservation whose record was lost — the primary store file went with it, - * or the crash beat the durable write — is unreachable but still connected to the provider session. - * Only a token match justifies signalling a process; neither age nor CPU is evidence, and a host - * that cannot enumerate tokens stops nothing. This never proves process exit or licenses a new - * owner; lease adjudication remains the single-writer boundary. - */ - -import type { AgentSessionRecordStore } from './agent-session-record-store' -import { - scanAgentSessionSpawnTokenProcesses, - type AgentSessionSpawnTokenScan -} from './agent-session-spawn-token-process-scan' - -export type AgentSessionOrphanStopSignal = 'SIGTERM' | 'SIGKILL' - -function defaultStop(pid: number, signal: AgentSessionOrphanStopSignal): void { - try { - process.kill(pid, signal) - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ESRCH') { - throw error - } - } -} - -/** Returns the pids signalled, so the caller can report what it reaped. */ -export async function stopOrphanAgentSessionChildren(input: { - store: Pick - scan?: () => Promise - stop?: (pid: number, signal: AgentSessionOrphanStopSignal) => void -}): Promise { - const observed = await (input.scan ?? scanAgentSessionSpawnTokenProcesses)() - if (observed === null || observed.size === 0) { - return [] - } - const stop = input.stop ?? defaultStop - const stopped: number[] = [] - for (const token of input.store.listOrphanSpawnTokens([...observed.keys()])) { - for (const pid of observed.get(token) ?? []) { - stop(pid, 'SIGTERM') - stopped.push(pid) - } - } - return stopped -} diff --git a/src/main/runtime/agent-session-process-identity-probe.ts b/src/main/runtime/agent-session-process-identity-probe.ts index 3fa1971b830..0fe0a16f4ee 100644 --- a/src/main/runtime/agent-session-process-identity-probe.ts +++ b/src/main/runtime/agent-session-process-identity-probe.ts @@ -4,8 +4,8 @@ * Pids are reused within minutes on a busy host, and reuse happens precisely in the recovery * case, so a bare pid match is never proof. Every element of the identity tuple is unavailable * somewhere — start time costs a CIM query on Windows and is missing in some containers, /proc - * does not exist on macOS — so an exact but unanswerable identity stays fenced in `recovering`; - * an ownerless, unattributable reservation enters `manual-recovery`. + * does not exist on macOS — so an unanswerable identity reports `indeterminate` and is never read + * as alive or dead. */ import { readFile } from 'node:fs/promises' diff --git a/src/main/runtime/agent-session-processless-reservation.ts b/src/main/runtime/agent-session-processless-reservation.ts deleted file mode 100644 index 9415ba8fcb7..00000000000 --- a/src/main/runtime/agent-session-processless-reservation.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { AgentSessionRecord } from '../../shared/agent-session-record' - -export type AgentSessionReservationProcesslessProof = { - sessionId: string - fence: number - spawnToken: string - now: number -} - -function assertReservation( - record: AgentSessionRecord, - args: AgentSessionReservationProcesslessProof -): void { - if (record.lease.runtimeFence !== args.fence || record.lease.unreconciled) { - throw new Error('agent_session_checkpoint_stale') - } - if ( - record.lease.claimStatus !== 'reserved' || - record.lease.reservedSpawnToken !== args.spawnToken - ) { - throw new Error('agent_session_ownership_unknown') - } -} - -export function setAgentSessionReservationProcesslessProof( - args: AgentSessionReservationProcesslessProof & { - record: AgentSessionRecord - processlessAt: number | null - } -): AgentSessionRecord { - const { record } = args - assertReservation(record, args) - if (args.processlessAt === null && record.lease.processlessAt == null) { - return record - } - if (record.lease.ownerProcess !== null) { - throw new Error('agent_session_ownership_unknown') - } - return { - ...record, - lease: { ...record.lease, processlessAt: args.processlessAt }, - updatedAt: args.now - } -} diff --git a/src/main/runtime/agent-session-provider-handle-transition.ts b/src/main/runtime/agent-session-provider-handle-transition.ts index f0408901868..57d2ecb172c 100644 --- a/src/main/runtime/agent-session-provider-handle-transition.ts +++ b/src/main/runtime/agent-session-provider-handle-transition.ts @@ -1,3 +1,4 @@ +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { agentSessionProviderHandleChainHead, appendAgentSessionProviderHandleLink, @@ -20,7 +21,7 @@ export function recordAgentSessionProviderHandle(args: { throw new Error('agent_session_provider_handle_invalid') } if (record.lease.claimStatus !== 'live' && record.lease.handoffStage !== 'new-owner-proving') { - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'leaseMoved' }) } const providerHandleChain = appendAgentSessionProviderHandleLink( record.providerHandleChain, diff --git a/src/main/runtime/agent-session-record-options.ts b/src/main/runtime/agent-session-record-options.ts index 8cf88d685f3..697b63a0422 100644 --- a/src/main/runtime/agent-session-record-options.ts +++ b/src/main/runtime/agent-session-record-options.ts @@ -1,3 +1,4 @@ +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import type { AgentSessionOptionsReplacement, AgentSessionRecord @@ -7,8 +8,11 @@ export function replaceAgentSessionRecordOptions( record: AgentSessionRecord, replacement: AgentSessionOptionsReplacement ): AgentSessionRecord { - if (record.lease.runtimeFence !== replacement.fence || record.lease.claimStatus !== 'live') { - throw new Error('agent_session_ownership_unknown') + const { lease } = record + // At rest the host is the only writer: a pick is intent the next start replays. + const atRest = lease.claimStatus === 'released' && lease.ownerProcess === null + if (lease.runtimeFence !== replacement.fence || (lease.claimStatus !== 'live' && !atRest)) { + throw agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'leaseMoved' }) } return { ...record, options: { ...replacement.options }, updatedAt: replacement.now } } diff --git a/src/main/runtime/agent-session-record-store-file.ts b/src/main/runtime/agent-session-record-store-file.ts index b721055e2a9..ef0f4b1ab7f 100644 --- a/src/main/runtime/agent-session-record-store-file.ts +++ b/src/main/runtime/agent-session-record-store-file.ts @@ -21,10 +21,9 @@ import { type AgentSessionRecord } from '../../shared/agent-session-record' import { normalizeLegacyHandoffRecord } from '../../shared/agent-session-legacy-handoff-lease' -import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' import { agentSessionStoreBackupPath as backupPath } from './agent-session-record-store-write' export { saveAgentSessionStore } from './agent-session-record-store-write' -import { parseVisibleSessionIds } from './agent-session-visible-tab-index' +import { parseAgentSessionTabTable, type AgentSessionTabTable } from './agent-session-tab-table' import { serializeAgentSessionStoreState } from './agent-session-store-serialization' export const AGENT_SESSION_STORE_SCHEMA_VERSION = 2 as const @@ -41,10 +40,8 @@ export type AgentSessionStoreState = { retiredClaimKeys: RetiredAgentSessionClaimKey[] /** Rows this build cannot validate, kept with a durable refusal reason. */ unreadableRecords: Map - /** Structured sessions that currently have a visible chat tab. */ - visibleSessionIds: Set - /** True once this store has committed the visibility index field. */ - visibleSessionIdsIndexPresent: boolean + /** Chat tab id → the conversation it shows; null until this store first records a tab. */ + sessionTabs: AgentSessionTabTable | null } export type LoadedAgentSessionStore = { @@ -72,33 +69,10 @@ function emptyState(hostId: string): AgentSessionStoreState { operations: new Map(), retiredClaimKeys: [], unreadableRecords: new Map(), - visibleSessionIds: new Set(), - visibleSessionIdsIndexPresent: false + sessionTabs: null } } -/** - * Gives every record written before the host owned a chat's tab id the string clients derived for - * it, so read state, notification ids and worker rows keyed by that id stay valid on upgrade. - * - * Runs once per open, after the disk revision is taken and before the load rewrite: it is not part - * of parsing, because a parsed state must hash to what is on disk or every transaction would read - * the file as externally changed. Returns how many records it filled. - */ -export function backfillAgentSessionSurfaceTabIds(state: AgentSessionStoreState): number { - let filled = 0 - for (const [sessionId, record] of state.records) { - if (record.surfaceTabId === undefined) { - state.records.set(sessionId, { - ...record, - surfaceTabId: structuredAgentSessionTabId(sessionId) - }) - filled += 1 - } - } - return filled -} - export function agentSessionStoreRevision(state: AgentSessionStoreState): string { return createHash('sha256') .update(String(state.schemaVersion)) @@ -131,6 +105,7 @@ function parseState( operations?: unknown retiredClaimKeys?: unknown unusableRecords?: unknown + sessionTabs?: unknown visibleSessionIds?: unknown } if ( @@ -245,16 +220,15 @@ function parseState( state.retiredClaimKeys.push({ keyId: key.keyId, retiredAt: key.retiredAt as number }) } } - const visibleSessionIds = parseVisibleSessionIds( - file.visibleSessionIds, - schemaVersion, - AGENT_SESSION_STORE_SCHEMA_VERSION + const sessionTabs = parseAgentSessionTabTable( + file, + state.records, + schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION ) - if (!visibleSessionIds.valid) { + if (!sessionTabs.valid) { return null } - state.visibleSessionIdsIndexPresent = visibleSessionIds.present - visibleSessionIds.ids.forEach((sessionId) => state.visibleSessionIds.add(sessionId)) + state.sessionTabs = sessionTabs.table return { state, needsRewrite, legacyHandoffLeasesNormalized } } diff --git a/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts b/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts index 3b92eeb5fe7..c614cfa5515 100644 --- a/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts +++ b/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts @@ -125,7 +125,7 @@ describe('a lease the removed terminal handoff wrote', () => { expect(store.getRecord(SESSION)?.lease).toMatchObject({ unreconciled: false, claimStatus: 'conflicted', - handoffStage: 'manual-recovery' + handoffStage: 'recovering' }) const settled = await readFile(agentSessionStorePath(directory), 'utf-8') await open() diff --git a/src/main/runtime/agent-session-record-store-surface-tab-id.test.ts b/src/main/runtime/agent-session-record-store-surface-tab-id.test.ts deleted file mode 100644 index f9603e7a86b..00000000000 --- a/src/main/runtime/agent-session-record-store-surface-tab-id.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * The host-owned id of the tab that shows a structured chat, as the record store persists it. - * Separate from the store's main suite only because that file is at its line cap. - */ - -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' -import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' -import { AgentSessionRecordStore } from './agent-session-record-store' -import { agentSessionStorePath } from './agent-session-record-store-file' -import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' - -const NOW = 1_800_000_000_000 -const NATIVE: AgentSessionExecutionLocation = { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' -} -const INDETERMINATE: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'no answer' } - -let directory: string -let counter = 0 - -beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-surface-tab-id-')) -}) - -afterEach(async () => { - await rm(directory, { recursive: true, force: true }) -}) - -function operationId(now = NOW): string { - counter += 1 - return `${now}-${String(counter) - .padStart(32, '0') - .replaceAll(/[^0-9a-f]/g, '0')}` -} - -function reserveRequest( - overrides: Partial = {} -): AgentSessionReserveRequest { - return { - sessionId: 'session-alpha', - location: NATIVE, - provider: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, - expectedFence: null, - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: INDETERMINATE, - operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-1' }, - now: NOW, - ...overrides - } -} - -async function open(): Promise { - return AgentSessionRecordStore.open({ directory, hostId: 'local' }) -} - -describe('surface tab id', () => { - const LEGACY_TAB_ID = 'structured-agent-session-session-alpha' - - it('records the reserved tab id and refuses a second record under it', async () => { - const store = await open() - await store.reserveOwner(reserveRequest({ surfaceTabId: 'tab-alpha' })) - expect(store.getRecord('session-alpha')?.surfaceTabId).toBe('tab-alpha') - const persisted = JSON.parse(await readFile(agentSessionStorePath(directory), 'utf-8')) - expect(persisted.records['session-alpha'].surfaceTabId).toBe('tab-alpha') - - await expect( - store.reserveOwner( - reserveRequest({ - sessionId: 'session-beta', - surfaceTabId: 'tab-alpha', - operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } - }) - ) - ).rejects.toThrow('agent_session_conflict') - expect(store.getRecord('session-beta')).toBeNull() - }) - - it('refuses a tab id that could not prefix a pane key', async () => { - const store = await open() - await expect( - store.reserveOwner(reserveRequest({ surfaceTabId: 'agent-session:session-alpha' })) - ).rejects.toThrow('agent_session_operation_invalid') - }) - - it('backfills a record written before the field existed with the id clients derived', async () => { - const first = await open() - await first.reserveOwner(reserveRequest()) - const filePath = agentSessionStorePath(directory) - const raw = JSON.parse(await readFile(filePath, 'utf-8')) - delete raw.records['session-alpha'].surfaceTabId - await writeFile(filePath, JSON.stringify(raw)) - - const reopened = await open() - // In memory at once, so every reader of the record sees one. - expect(reopened.getRecord('session-alpha')?.surfaceTabId).toBe(LEGACY_TAB_ID) - // Not on disk yet: an open must not write, or another holder of the file mid-restart would - // read it as an external change. - expect( - JSON.parse(await readFile(filePath, 'utf-8')).records['session-alpha'] - ).not.toHaveProperty('surfaceTabId') - - // The first transaction carries it to disk, and a later open finds nothing left to fill. - await reopened.setSessionTabVisibility('session-alpha', true) - expect( - JSON.parse(await readFile(filePath, 'utf-8')).records['session-alpha'].surfaceTabId - ).toBe(LEGACY_TAB_ID) - const settled = await readFile(filePath, 'utf-8') - await open() - expect(await readFile(filePath, 'utf-8')).toBe(settled) - }) - - it('refills the id when another holder rewrites the file, without forcing a save', async () => { - const store = await open() - await store.reserveOwner(reserveRequest({ surfaceTabId: 'tab-alpha' })) - const filePath = agentSessionStorePath(directory) - const raw = JSON.parse(await readFile(filePath, 'utf-8')) - // An older build's write, which never carries the field. - delete raw.records['session-alpha'].surfaceTabId - raw.visibleSessionIds = [] - const external = JSON.stringify(raw) - await writeFile(filePath, external) - - // A transaction that changes nothing still reloads the externally changed file first. - await store.setSessionTabVisibility('session-alpha', false) - expect(store.getRecord('session-alpha')?.surfaceTabId).toBe(LEGACY_TAB_ID) - // The reload marked every lease unadjudicated; a refill must not persist that verdict. - expect(await readFile(filePath, 'utf-8')).toBe(external) - }) - - it('quarantines a persisted record whose tab id contains a colon', async () => { - const first = await open() - await first.reserveOwner(reserveRequest()) - const filePath = agentSessionStorePath(directory) - const raw = JSON.parse(await readFile(filePath, 'utf-8')) - raw.records['session-alpha'].surfaceTabId = 'agent-session:session-alpha' - await writeFile(filePath, JSON.stringify(raw)) - expect((await open()).isSessionUnreadable('session-alpha')).toBe(true) - }) -}) diff --git a/src/main/runtime/agent-session-record-store-tab-table.test.ts b/src/main/runtime/agent-session-record-store-tab-table.test.ts new file mode 100644 index 00000000000..56bcb6debb6 --- /dev/null +++ b/src/main/runtime/agent-session-record-store-tab-table.test.ts @@ -0,0 +1,277 @@ +/** + * The persisted table from chat tab id to the conversation it shows, as the record store keeps it. + * Separate from the store's main suite only because that file is at its line cap. + */ + +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { isAgentSessionRefusalError } from '../../shared/agent-session-wire-refusals' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { agentSessionStorePath } from './agent-session-record-store-file' +import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' + +const NOW = 1_800_000_000_000 +const NATIVE: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' +} +const INDETERMINATE: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'no answer' } + +let directory: string +let counter = 0 + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-tab-table-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +function operationId(now = NOW): string { + counter += 1 + return `${now}-${String(counter) + .padStart(32, '0') + .replaceAll(/[^0-9a-f]/g, '0')}` +} + +function reserveRequest( + overrides: Partial = {} +): AgentSessionReserveRequest { + return { + sessionId: 'session-alpha', + location: NATIVE, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, + expectedFence: null, + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: INDETERMINATE, + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-1' }, + now: NOW, + ...overrides + } +} + +async function open(): Promise { + return AgentSessionRecordStore.open({ directory, hostId: 'local' }) +} + +describe('chat tab table', () => { + const LEGACY_TAB_ID = 'structured-agent-session-session-alpha' + const filePath = () => agentSessionStorePath(directory) + const readFileJson = async () => JSON.parse(await readFile(filePath(), 'utf-8')) + + it('takes a reserved id only when the tab is shown, and refuses it to a second chat', async () => { + const store = await open() + await store.reserveOwner(reserveRequest({ surfaceTabId: 'tab-alpha' })) + // A create that dies before its tab is shown leaves nothing to restore or release. + expect(store.getSessionTabId('session-alpha')).toBeNull() + expect((await readFileJson()).sessionTabs).toBeUndefined() + + await store.setSessionTabVisibility('session-alpha', true, 'tab-alpha') + expect(store.getSessionTabId('session-alpha')).toBe('tab-alpha') + const persisted = await readFileJson() + expect(persisted.sessionTabs).toEqual([{ tabId: 'tab-alpha', sessionId: 'session-alpha' }]) + // Not copied onto the record: the table is the one place the id lives. + expect(persisted.records['session-alpha']).not.toHaveProperty('surfaceTabId') + + await expect( + store.reserveOwner( + reserveRequest({ + sessionId: 'session-beta', + surfaceTabId: 'tab-alpha', + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } + }) + ) + ).rejects.toThrow('agent_session_conflict') + expect(store.getRecord('session-beta')).toBeNull() + }) + + it('refuses showing a tab with the situation typed, as every chat refusal is', async () => { + const store = await open() + await store.reserveOwner(reserveRequest({ surfaceTabId: 'tab-alpha' })) + await store.setSessionTabVisibility('session-alpha', true, 'tab-alpha') + await store.reserveOwner( + reserveRequest({ + sessionId: 'session-beta', + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } + }) + ) + + // The message stays the code: readers of a thrown refusal treat it as one. + await expect( + store.setSessionTabVisibility('session-beta', true, 'tab-alpha') + ).rejects.toSatisfy( + (error) => + isAgentSessionRefusalError(error) && + error.message === 'agent_session_conflict' && + error.refusal.details?.reason === 'tabIdTaken' + ) + await expect(store.setSessionTabVisibility('session-gone', true)).rejects.toSatisfy( + (error) => + isAgentSessionRefusalError(error) && + error.message === 'agent_session_identity_required' && + error.refusal.details?.reason === 'recordMissing' + ) + }) + + it('frees a reserved id once its chat is hidden', async () => { + const store = await open() + await store.reserveOwner(reserveRequest({ surfaceTabId: 'tab-alpha' })) + await store.setSessionTabVisibility('session-alpha', true, 'tab-alpha') + await store.setSessionTabVisibility('session-alpha', false) + await store.reserveOwner( + reserveRequest({ + sessionId: 'session-beta', + surfaceTabId: 'tab-alpha', + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } + }) + ) + await store.setSessionTabVisibility('session-beta', true, 'tab-alpha') + expect(store.getSessionTabId('session-beta')).toBe('tab-alpha') + }) + + it('refuses a tab id that could not prefix a pane key', async () => { + const store = await open() + await expect( + store.reserveOwner(reserveRequest({ surfaceTabId: 'agent-session:session-alpha' })) + ).rejects.toThrow('agent_session_operation_invalid') + }) + + it('gives a shown chat the id clients derive, and puts a hidden one back under its old id', async () => { + const store = await open() + await store.reserveOwner(reserveRequest()) + await store.setSessionTabVisibility('session-alpha', true) + expect(store.getSessionTabId('session-alpha')).toBe(LEGACY_TAB_ID) + await store.setSessionTabVisibility('session-alpha', false) + await store.setSessionTabVisibility('session-alpha', true, 'tab-restored') + expect(store.getSessionTabId('session-alpha')).toBe('tab-restored') + }) + + it('seeds the table from an older store, keeping each visible chat on the id it has today', async () => { + const first = await open() + await first.reserveOwner(reserveRequest()) + await first.reserveOwner( + reserveRequest({ + sessionId: 'session-beta', + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } + }) + ) + await first.reserveOwner( + reserveRequest({ + sessionId: 'session-gamma', + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-3' } + }) + ) + // What a build before the table wrote: the visible list, and a tab id on some records. + const raw = await readFileJson() + delete raw.sessionTabs + raw.visibleSessionIds = ['session-alpha', 'session-beta'] + raw.records['session-alpha'].surfaceTabId = 'tab-alpha' + raw.records['session-beta'].surfaceTabId = 'structured-agent-session-session-beta' + const legacy = JSON.stringify(raw) + await writeFile(filePath(), legacy) + + const reopened = await open() + expect(reopened.getSessionTabId('session-alpha')).toBe('tab-alpha') + expect(reopened.getSessionTabId('session-beta')).toBe('structured-agent-session-session-beta') + expect(reopened.getSessionTabId('session-gamma')).toBeNull() + // Seeding is part of reading, so an open alone writes nothing. + expect(await readFile(filePath(), 'utf-8')).toBe(legacy) + + await reopened.setSessionTabVisibility('session-gamma', true) + const persisted = await readFileJson() + expect(persisted.sessionTabs).toEqual([ + { tabId: 'tab-alpha', sessionId: 'session-alpha' }, + { tabId: 'structured-agent-session-session-beta', sessionId: 'session-beta' }, + { tabId: 'structured-agent-session-session-gamma', sessionId: 'session-gamma' } + ]) + // Older builds restore from this list; the record field rides along untouched and unread. + expect(persisted.visibleSessionIds).toEqual(['session-alpha', 'session-beta', 'session-gamma']) + expect(persisted.records['session-alpha'].surfaceTabId).toBe('tab-alpha') + }) + + it('seeds a chat cleared before the upgrade under the id its tab opened with', async () => { + const first = await open() + for (const [index, sessionId] of ['session-alpha', 'clear-one', 'clear-two'].entries()) { + await first.reserveOwner( + reserveRequest({ + sessionId, + operation: { + callerKey: 'client-1', + operationId: operationId(), + fingerprint: `fp-chain-${index}` + } + }) + ) + } + // An older build after two clears, with the first conversation reopened from history. + const raw = await readFileJson() + const cleared = (replacementSessionId: string) => ({ + command: 'clear', + state: 'completed', + phase: 'committed', + operationId: operationId(), + callerKey: 'client-1', + replacementSessionId + }) + raw.records['session-alpha'].conversationCommand = cleared('clear-one') + raw.records['clear-one'].conversationCommand = cleared('clear-two') + raw.records['clear-two'].surfaceTabId = 'structured-agent-session-clear-two' + delete raw.sessionTabs + raw.visibleSessionIds = ['session-alpha', 'clear-two'] + await writeFile(filePath(), JSON.stringify(raw)) + + const reopened = await open() + expect(reopened.getSessionTabId('clear-two')).toBe(LEGACY_TAB_ID) + const reopenedTab = reopened.getSessionTabId('session-alpha') + expect(reopenedTab).not.toBe(LEGACY_TAB_ID) + expect(reopenedTab).not.toContain(':') + expect(reopened.listVisibleSessionIds()).toEqual(['session-alpha', 'clear-two']) + // Seeding is part of reading, so it must give the same ids on every read of the same bytes. + expect((await open()).getSessionTabId('session-alpha')).toBe(reopenedTab) + }) + + it('reads the table, never the record field, once the table is on disk', async () => { + const first = await open() + await first.reserveOwner(reserveRequest()) + await first.setSessionTabVisibility('session-alpha', true, 'tab-alpha') + const raw = await readFileJson() + raw.records['session-alpha'].surfaceTabId = 'tab-stale' + await writeFile(filePath(), JSON.stringify(raw)) + expect((await open()).getSessionTabId('session-alpha')).toBe('tab-alpha') + }) + + it('keeps a record whose legacy tab id is malformed, seeding it under the derived id', async () => { + const first = await open() + await first.reserveOwner(reserveRequest()) + const raw = await readFileJson() + delete raw.sessionTabs + raw.visibleSessionIds = ['session-alpha'] + raw.records['session-alpha'].surfaceTabId = 'agent-session:session-alpha' + await writeFile(filePath(), JSON.stringify(raw)) + const reopened = await open() + expect(reopened.isSessionUnreadable('session-alpha')).toBe(false) + expect(reopened.getSessionTabId('session-alpha')).toBe(LEGACY_TAB_ID) + }) + + it('treats a malformed table as a corrupt store rather than guessing', async () => { + const first = await open() + await first.reserveOwner(reserveRequest()) + const raw = await readFileJson() + raw.sessionTabs = [ + { tabId: 'tab-alpha', sessionId: 'session-alpha' }, + { tabId: 'tab-alpha', sessionId: 'session-beta' } + ] + await writeFile(filePath(), JSON.stringify(raw)) + await expect(open()).rejects.toThrow('agent_session_store_corrupt') + }) +}) diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts index 65d689503c2..5ee25d36813 100644 --- a/src/main/runtime/agent-session-record-store.test.ts +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionExecutionLocation, + AgentSessionLease, AgentSessionProcessIdentity, AgentSessionRecord } from '../../shared/agent-session-record' @@ -121,6 +122,26 @@ async function establishOwner( }) } +/** The shape the removed conflict marker wrote, as it decodes. No shipped build called it; a record + * may carry it. */ +async function markLegacyConflicted( + store: AgentSessionRecordStore, + lease: Partial = {} +): Promise { + if (!store.getRecord('session-alpha')) { + await store.reserveOwner(reserveRequest()) + } + await store.transitionHandoff('session-alpha', (record) => ({ + ...record, + lease: { + ...record.lease, + claimStatus: 'conflicted', + handoffStage: 'recovering', + ...lease + } + })) +} + beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-store-')) }) @@ -596,10 +617,26 @@ describe('restart reconciliation', () => { ).rejects.toThrow('agent_session_ownership_unknown') }) - it('keeps a conflict conflicted across a restart that proves nothing', async () => { + it('re-adjudicates a claim an older record marked conflicted by the owner it names', async () => { const first = await open() await establishOwner(first) - await first.markClaimConflicted('session-alpha', NOW) + await markLegacyConflicted(first) + + const reopened = await open() + await reopened.reconcileOnRestart({ + probe: async () => ({ outcome: 'indeterminate', reason: 'no answer' }), + now: NOW + }) + // An unverifiable owner goes to recovery like any other; resolution concludes about it. + expect(reopened.getRecord('session-alpha')?.lease).toMatchObject({ + handoffStage: 'recovering', + ownerProcess: { pid: expect.any(Number) } + }) + }) + + it('releases a claim an older record marked conflicted that names no process', async () => { + const first = await open() + await markLegacyConflicted(first, { ownerProcess: null }) const reopened = await open() await reopened.reconcileOnRestart({ @@ -607,18 +644,10 @@ describe('restart reconciliation', () => { now: NOW }) expect(reopened.getRecord('session-alpha')?.lease).toMatchObject({ - claimStatus: 'conflicted', - handoffStage: 'manual-recovery' + claimStatus: 'released', + handoffStage: null, + deathEvidence: null }) - await expect( - reopened.reserveOwner( - reserveRequest({ - expectedFence: 1, - probe: { outcome: 'pid-absent' }, - operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } - }) - ) - ).rejects.toThrow('agent_session_conflict') }) it('releases a conflict whose named owner is proven gone at restart', async () => { @@ -626,7 +655,7 @@ describe('restart reconciliation', () => { // the process the conflict names has exited leaves no claimant left to protect. const first = await open() await establishOwner(first) - await first.markClaimConflicted('session-alpha', NOW) + await markLegacyConflicted(first) const reopened = await open() await reopened.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: NOW }) @@ -724,13 +753,7 @@ describe('host and workspace isolation', () => { }) }) -describe('orphans, claim keys, checkpoints, and unreadable rows', () => { - it('calls a spawn token with no lease an orphan', async () => { - const store = await open() - await establishOwner(store) - expect(store.listOrphanSpawnTokens(['spawn-a', 'spawn-z'])).toEqual(['spawn-z']) - }) - +describe('claim keys, checkpoints, and unreadable rows', () => { it('keeps a retired claim key verifiable for the retention window', async () => { const store = await open() await store.retireClaimKey('key-1', NOW) diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 58817a7e59c..925fa38d8d1 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -1,4 +1,4 @@ -import { setVisibleSessionId } from './agent-session-visible-tab-index' +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { commitConversationCommandRecord } from './agent-session-conversation-command-record' import { setAgentSessionRecordConversationName } from './agent-session-record-conversation-name' /** Durable single-writer session records and their operation ledger. */ @@ -25,7 +25,6 @@ import { retireAgentSessionClaimKey } from './agent-session-claim-key-retention' import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' -import { classifyObservedAgentSessionSpawnToken } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import { agentSessionScopeKey, @@ -57,10 +56,6 @@ import { type AgentSessionRestartProbeArgs } from './agent-session-restart-reconciliation' import { replaceAgentSessionRecordOptions } from './agent-session-record-options' -import { - setAgentSessionReservationProcesslessProof, - type AgentSessionReservationProcesslessProof -} from './agent-session-processless-reservation' import { commitAgentSessionReservation, type AgentSessionReserveRequest, @@ -69,9 +64,9 @@ import { import { agentSessionStoreRevision, agentSessionStorePath, - type AgentSessionStoreState, - backfillAgentSessionSurfaceTabIds + type AgentSessionStoreState } from './agent-session-record-store-file' +import { setAgentSessionTabVisibility } from './agent-session-tab-table' import { loadProtectedAgentSessionStore } from './agent-session-record-store-security' import { AgentSessionStoreTransactionQueue, @@ -82,6 +77,8 @@ export const AGENT_SESSION_LEASE_TTL_MS = 30_000, AGENT_SESSION_LEASE_RENEW_INTERVAL_MS = 10_000 export class AgentSessionRecordStore { + private readonly deathEvidenceListeners = new Set<(sessionId: string) => void>() + private constructor(private readonly transactions: AgentSessionStoreTransactionQueue) {} static async open(args: { directory: string; hostId: string }): Promise { @@ -90,19 +87,14 @@ export class AgentSessionRecordStore { // Why: every persisted lease is unreconciled until this host adjudicates it, so a restart // grants no writer on the strength of what the previous process wrote. const diskRevision = agentSessionStoreRevision(loaded.state) - // After the revision, so the file still hashes to what was read. The filled ids, like the - // normalized legacy leases, reach disk with this store's first transaction rather than a write - // here: a rewrite at open would read as an external change to any other holder of the file - // mid-restart. - const backfilled = backfillAgentSessionSurfaceTabIds(loaded.state) + // The normalized legacy leases reach disk with this store's first transaction rather than a + // write here: a rewrite at open would read as an external change to any other holder of the + // file mid-restart. markAgentSessionStoreLeasesUnreconciled(loaded.state) const transactions = AgentSessionStoreTransactionQueue.fromLoadedStore( filePath, args.hostId, - { - ...loaded, - needsRewrite: loaded.needsRewrite || backfilled > 0 || loaded.legacyHandoffLeasesNormalized - }, + { ...loaded, needsRewrite: loaded.needsRewrite || loaded.legacyHandoffLeasesNormalized }, diskRevision ) if (loaded.needsRewrite && !loaded.readOnly && !loaded.recoveredFromBackup) { @@ -133,16 +125,25 @@ export class AgentSessionRecordStore { listRecords = (): AgentSessionRecord[] => [...this.state.records.values()] listVisibleSessionIds = (): string[] => - [...this.state.visibleSessionIds].filter((sessionId) => this.state.records.has(sessionId)) + (this.state.sessionTabs?.sessionIds() ?? []).filter((sessionId) => + this.state.records.has(sessionId) + ) getVisibleSessionTabIndex = (): { present: boolean; sessionIds: string[] } => ({ - present: this.state.visibleSessionIdsIndexPresent, + present: this.state.sessionTabs !== null, sessionIds: this.listVisibleSessionIds() }) - /** Persist the user-visible tab reference separately from the rollback-sensitive profile tabs. */ - setSessionTabVisibility(sessionId: string, visible: boolean): Promise { - return this.transact(() => setVisibleSessionId(this.state, sessionId, visible)) + /** The id of the chat tab showing this conversation, if one does. */ + getSessionTabId = (sessionId: string): string | null => + this.state.sessionTabs?.tabIdFor(sessionId) ?? null + + /** + * Persist the user-visible tab reference separately from the rollback-sensitive profile tabs. + * Showing keeps a tab the session already has; `tabId` puts a hidden one back under its old id. + */ + setSessionTabVisibility(sessionId: string, visible: boolean, tabId?: string): Promise { + return this.transact(() => setAgentSessionTabVisibility(this.state, sessionId, visible, tabId)) } listByScope(location: AgentSessionExecutionLocation): AgentSessionRecord[] { @@ -180,14 +181,6 @@ export class AgentSessionRecordStore { isClaimKeyVerifiable = (keyId: string, now: number): boolean => isAgentSessionClaimKeyVerifiable(this.state, keyId, now) - /** Spawn tokens observed on the host with no matching lease. Stop them; never adopt them. */ - listOrphanSpawnTokens(observedTokens: readonly string[]): string[] { - const leases = this.listRecords().map((record) => record.lease) - return observedTokens.filter( - (spawnToken) => classifyObservedAgentSessionSpawnToken({ spawnToken, leases }) === 'orphan' - ) - } - async reserveOwner(request: AgentSessionReserveRequest): Promise { return this.transact(() => commitAgentSessionReservation(this.state, request, AGENT_SESSION_LEASE_TTL_MS) @@ -202,13 +195,6 @@ export class AgentSessionRecordStore { ) } - setReservationProcesslessProof = ( - args: AgentSessionReservationProcesslessProof & { processlessAt: number | null } - ): Promise => - this.mutate(args.sessionId, (record) => - setAgentSessionReservationProcesslessProof({ ...args, record }) - ) - async proveOwner(args: { sessionId: string fence: number @@ -256,9 +242,7 @@ export class AgentSessionRecordStore { probe: AgentSessionOwnerProbe now: number }): Promise { - return this.mutate(args.sessionId, (record) => - evictAgentSessionOwner({ ...args, record, journalSettlement: 'required' }) - ) + return this.mutate(args.sessionId, (record) => evictAgentSessionOwner({ ...args, record })) } async transitionHandoff( @@ -322,16 +306,6 @@ export class AgentSessionRecordStore { await this.transact(() => settleAgentSessionOperationInto(this.state, args)) } - async markClaimConflicted(sessionId: string, now: number): Promise { - return this.mutate(sessionId, (record) => ({ - ...record, - updatedAt: now, - // Why: a conflicted key must stay conflicted across a restart; it cannot resolve to free - // merely because the process that observed the conflict is gone. - lease: { ...record.lease, claimStatus: 'conflicted', handoffStage: 'manual-recovery' } - })) - } - replaceSessionOptions = (args: AgentSessionOptionsReplacement): Promise => this.mutate(args.sessionId, (record) => replaceAgentSessionRecordOptions(record, args)) @@ -346,11 +320,9 @@ export class AgentSessionRecordStore { return this.transact(() => { const record = this.state.records.get(sessionId) if (!record) { - throw new Error( - this.isSessionUnreadable(sessionId) - ? 'execution_owner_reconciling' - : 'agent_session_identity_required' - ) + throw this.isSessionUnreadable(sessionId) + ? agentSessionRefusalError('execution_owner_reconciling', { reason: 'recordUnreadable' }) + : agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) } const next = apply(record) this.state.records.set(sessionId, next) @@ -358,6 +330,32 @@ export class AgentSessionRecordStore { }) } + /** Told, once committed, of each session a transaction wrote a proof of death for — whichever + * transition wrote it, since every one lands here. Must not throw. */ + onDeathEvidence(listener: (sessionId: string) => void): () => void { + this.deathEvidenceListeners.add(listener) + return () => this.deathEvidenceListeners.delete(listener) + } + /** Serialize every mutation against the latest committed disk state. */ - private transact = (apply: () => T): Promise => this.transactions.transact(apply) + private transact = async (apply: () => T): Promise => { + let proven: string[] = [] + const result = await this.transactions.transact(() => { + if (this.deathEvidenceListeners.size === 0) { + return apply() + } + const before = new Map( + [...this.state.records].map(([id, record]) => [id, record.lease.deathEvidence]) + ) + const applied = apply() + proven = [...this.state.records] + .filter(([id, { lease }]) => lease.deathEvidence && lease.deathEvidence !== before.get(id)) + .map(([id]) => id) + return applied + }) + for (const sessionId of proven) { + this.deathEvidenceListeners.forEach((listener) => listener(sessionId)) + } + return result + } } diff --git a/src/main/runtime/agent-session-recovery-capsule-entries.ts b/src/main/runtime/agent-session-recovery-capsule-entries.ts index de8a452c87a..a5d1d899723 100644 --- a/src/main/runtime/agent-session-recovery-capsule-entries.ts +++ b/src/main/runtime/agent-session-recovery-capsule-entries.ts @@ -4,6 +4,10 @@ // — can be read on its own. Every value parsed here re-enters from a file this process did not // necessarily write, including one written by an older or newer build. +import { + readAgentSessionRefusalReference, + type AgentSessionAnyRefusalDetails +} from '../../shared/agent-session-wire-refusals' import { z } from 'zod' import { AGENT_SESSION_RESUME_FAILURE_OUTCOMES, @@ -28,6 +32,7 @@ const failureSchema = z.object({ failedAt: z.number().int().nonnegative(), outcome: z.enum(AGENT_SESSION_RESUME_FAILURE_OUTCOMES), reason: z.string().max(MAX_FAILURE_FIELD_LENGTH), + details: z.unknown().optional(), latestPrompt: z.string().max(MAX_FAILURE_FIELD_LENGTH), latestUserItemId: z.string().max(MAX_FAILURE_FIELD_LENGTH).nullable() }) @@ -47,7 +52,11 @@ export type AgentSessionResumeFailureRecord = { marker: AgentSessionResumeMarker failedAt: number outcome: AgentSessionResumeFailureOutcome + /** The refusal code, as it always was; the renderer's guidance keys on it. */ reason: string + /** The refusal's details beside the code; absent on older records and non-refusals. A record + * an unreleased build wrote with a `cause` instead reads as having none. */ + details?: AgentSessionAnyRefusalDetails /** The prompt the offer quoted, snapshotted because the session may no longer be readable. */ latestPrompt: string /** The chat's newest user message when this was filed, as the marker records it at teardown. A @@ -109,7 +118,16 @@ function parseFailures(value: unknown): AgentSessionResumeFailureRecord[] { return (Array.isArray(value) ? value : []).flatMap((failure: unknown) => { const parsed = failureSchema.safeParse(failure) const marker = parsed.success ? parseAgentSessionResumeMarker(parsed.data.marker) : null - return parsed.success && marker ? [{ ...parsed.data, marker }] : [] + if (!parsed.success || !marker) { + return [] + } + const { details: stored, ...rest } = parsed.data + // `reason` is the refusal code, so the details are read against it. + const details = readAgentSessionRefusalReference({ + code: rest.reason, + details: stored + })?.details + return [{ ...rest, marker, ...(details ? { details } : {}) }] }) } diff --git a/src/main/runtime/agent-session-recovery-capsule.test.ts b/src/main/runtime/agent-session-recovery-capsule.test.ts index 2150e5c7d51..d70fde57af3 100644 --- a/src/main/runtime/agent-session-recovery-capsule.test.ts +++ b/src/main/runtime/agent-session-recovery-capsule.test.ts @@ -342,6 +342,41 @@ describe('durable restart offers', () => { expect(JSON.parse(await readFile(filePath, 'utf8')).failed).toEqual([readable]) }) + it('keeps refusal details beside the code, read back against that code', async () => { + const readable = { + marker: marker(), + failedAt: NOW, + outcome: 'refused', + reason: 'agent_session_conflict', + latestPrompt: '', + latestUserItemId: null + } + await writeFile( + filePath, + JSON.stringify({ + version: 2, + entries: [], + failed: [ + { ...readable, details: { reason: 'claimConflicted', note: 'dropped' } }, + // An unreleased build wrote a cause here; it still parses, naming nothing. + { ...readable, marker: marker({ sessionId: 'older' }), cause: 'claimConflicted' }, + // A reason the code does not list is not this code's. + { + ...readable, + marker: marker({ sessionId: 'foreign' }), + details: { reason: 'promptGone' } + } + ] + }) + ) + + expect(await capsule.listFailed(NOW)).toEqual([ + { ...readable, details: { reason: 'claimConflicted' } }, + { ...readable, marker: marker({ sessionId: 'older' }) }, + { ...readable, marker: marker({ sessionId: 'foreign' }) } + ]) + }) + it('reads a malformed failure list as no failures', async () => { await writeFile( filePath, diff --git a/src/main/runtime/agent-session-recovery-capsule.ts b/src/main/runtime/agent-session-recovery-capsule.ts index 8b54871b9f4..80ed0bb726e 100644 --- a/src/main/runtime/agent-session-recovery-capsule.ts +++ b/src/main/runtime/agent-session-recovery-capsule.ts @@ -201,15 +201,20 @@ export class AgentSessionRecoveryCapsule { /** Forgets the named sessions whatever their state. Unlike `clearAll`, this is not a fence: a * later teardown of the same chat may record a fresh offer. */ - dismiss(sessionIds: readonly string[], now: number): Promise { + dismiss( + sessionIds: readonly string[], + now: number, + /** A record this answers true for stays: read against the stored marker, under the lock. */ + keep: (marker: AgentSessionResumeMarker) => boolean = () => false + ): Promise { return withFileTransactionLock(this.filePath, async () => { const named = new Set(sessionIds) const state = await this.readState() const { entries, failed } = normalizeState(state, now) const dismissed = new Set( [...entries, ...failed] + .filter((record) => named.has(record.marker.sessionId) && !keep(record.marker)) .map((record) => record.marker.sessionId) - .filter((sessionId) => named.has(sessionId)) ) if (dismissed.size > 0) { await this.publish( diff --git a/src/main/runtime/agent-session-recovery-publish-fault.test.ts b/src/main/runtime/agent-session-recovery-publish-fault.test.ts index f3f2ba91d2a..9fdbe393334 100644 --- a/src/main/runtime/agent-session-recovery-publish-fault.test.ts +++ b/src/main/runtime/agent-session-recovery-publish-fault.test.ts @@ -48,8 +48,7 @@ function state(generation: number): AgentSessionStoreState { operations: new Map(), retiredClaimKeys: [{ keyId: `generation-${generation}`, retiredAt: generation }], unreadableRecords: new Map(), - visibleSessionIds: new Set(), - visibleSessionIdsIndexPresent: false + sessionTabs: null } } diff --git a/src/main/runtime/agent-session-released-reservation-replay.test.ts b/src/main/runtime/agent-session-released-reservation-replay.test.ts new file mode 100644 index 00000000000..e3f9bcff74b --- /dev/null +++ b/src/main/runtime/agent-session-released-reservation-replay.test.ts @@ -0,0 +1,171 @@ +// A create the host was running when it died is retried under the same operation id. Recovery has +// released its reservation by then, so the retry continues it at the next fence; a reservation that +// is still held keeps answering exactly as it did. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS +} from '../../shared/agent-session-host-authority' +import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionLease } from '../../shared/agent-session-record' +import { AgentSessionRecordStore } from './agent-session-record-store' +import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-alpha' +const OPERATION = `${NOW}-${'1'.padStart(32, '0')}` +const PAST_EXPIRY = + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS + 60_000 +const INDETERMINATE: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'no answer' } + +let directory: string + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-released-reservation-replay-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +function open(): Promise { + return AgentSessionRecordStore.open({ directory, hostId: 'local' }) +} + +function createRequest( + overrides: Partial = {} +): AgentSessionReserveRequest { + return { + sessionId: SESSION, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + expectedFence: null, + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: OPERATION, + probe: INDETERMINATE, + operation: { callerKey: 'client-1', operationId: OPERATION, fingerprint: 'fp-1' }, + now: NOW, + ...overrides + } +} + +/** The create reserved, then a restart that could prove nothing released it at fence 2. */ +async function releasedByRestart(now: number): Promise { + const first = await open() + await first.reserveOwner(createRequest()) + const store = await open() + await store.reconcileOnRestart({ probe: async () => INDETERMINATE, now }) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + runtimeFence: 2 + }) + return store +} + +describe('a create retried after recovery released its reservation', () => { + it.each([ + ['its operation row is still pending', 1_000], + ['its operation row has expired', PAST_EXPIRY] + ])('continues when %s, and the old reservation can never commit', async (_case, elapsed) => { + const now = NOW + elapsed + const store = await releasedByRestart(now) + + const continued = await store.reserveOwner(createRequest({ spawnToken: () => 'spawn-b', now })) + expect(continued.disposition).toBe('reserved') + expect(continued.record.lease).toMatchObject({ + claimStatus: 'reserved', + handoffStage: 'new-owner-proving', + runtimeFence: 3, + reservedSpawnToken: 'spawn-b', + handoffOperationId: OPERATION + }) + expect(store.listOperationRows()).toEqual([ + expect.objectContaining({ operationId: OPERATION, outcome: { status: 'pending' } }) + ]) + + // A spawn from the first reservation reports in late: refused at its fence, and by token. + const lateSpawn = { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken: 'spawn-a' } + await expect( + store.commitProcessIdentity({ sessionId: SESSION, fence: 1, process: lateSpawn, now }) + ).rejects.toThrow('agent_session_checkpoint_stale') + await expect( + store.commitProcessIdentity({ sessionId: SESSION, fence: 3, process: lateSpawn, now }) + ).rejects.toThrow('agent_session_ownership_unknown') + + // Retried again while that reservation stands: the same reservation, never a second spawn. + const mint = vi.fn(() => 'spawn-c') + const retried = await store.reserveOwner(createRequest({ spawnToken: mint, now })) + expect(retried.disposition).toBe('replayed') + expect(retried.record.lease).toMatchObject({ runtimeFence: 3, reservedSpawnToken: 'spawn-b' }) + expect(mint).not.toHaveBeenCalled() + }) + + it('refuses an expired retry of a session that has no record', async () => { + const store = await open() + await expect(store.reserveOwner(createRequest({ now: NOW + PAST_EXPIRY }))).rejects.toThrow( + 'agent_session_operation_expired' + ) + }) + + it.each([ + ['proven alive', { outcome: 'identity-matched', matchedOn: ['spawn-token'] }, 'conflict'], + ['one nothing could verify', INDETERMINATE, 'ownership_unknown'] + ] as const)('still refuses a retry once its child is live and %s', async (_case, probe, code) => { + const store = await open() + await store.reserveOwner(createRequest()) + await store.commitProcessIdentity({ + sessionId: SESSION, + fence: 1, + process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken: 'spawn-a' }, + now: NOW + }) + await store.proveOwner({ + sessionId: SESSION, + fence: 1, + link: { + linkId: 'link-1', + handle: { provider: 'codex', threadId: 'thread-1' }, + origin: 'created', + mintedAtFence: 1, + observedAt: NOW + }, + now: NOW + }) + + await expect(store.reserveOwner(createRequest({ probe }))).rejects.toThrow( + `agent_session_${code}` + ) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', runtimeFence: 1 }) + }) + + it.each<[string, Partial, string]>([ + ['in recovery', { handoffStage: 'recovering' }, 'agent_session_ownership_unknown'], + [ + 'held by a terminal agent', + { claimStatus: 'conflicted', handoffStage: 'recovering' }, + 'agent_session_conflict' + ] + ])('still refuses a retry whose reservation is %s', async (_case, lease, code) => { + const store = await open() + await store.reserveOwner(createRequest()) + await store.transitionHandoff(SESSION, (record) => ({ + ...record, + lease: { ...record.lease, ...lease } + })) + + await expect(store.reserveOwner(createRequest())).rejects.toThrow(code) + expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(1) + }) +}) diff --git a/src/main/runtime/agent-session-reservation-admission.test.ts b/src/main/runtime/agent-session-reservation-admission.test.ts index 83e9c610056..cad534931e6 100644 --- a/src/main/runtime/agent-session-reservation-admission.test.ts +++ b/src/main/runtime/agent-session-reservation-admission.test.ts @@ -69,8 +69,7 @@ function storeState(records: readonly AgentSessionRecord[] = []): AgentSessionSt operations: new Map(), retiredClaimKeys: [], unreadableRecords: new Map(), - visibleSessionIds: new Set(), - visibleSessionIdsIndexPresent: true + sessionTabs: null } } @@ -205,7 +204,8 @@ describe('re-create over a failed create', () => { provenHandleLinkId: null, ownerProcess: null, reservedSpawnToken: null, - claimStatus: 'released' + claimStatus: 'released', + deathEvidence: { kind: 'exit-observed', detail: 'the create failed', observedAt: 1 } }) function failedCreate(overrides: Partial = {}): AgentSessionRecord { return { @@ -231,10 +231,12 @@ describe('re-create over a failed create', () => { it('refuses when the record bound a conversation, or its attempt may still run', () => { const bound = failedCreate({ providerHandleChain: [adoptedLink()] }) const unproven = failedCreate({ - lease: { ...EXITED, claimStatus: 'reserved', handoffStage: 'manual-recovery' } + lease: { ...EXITED, claimStatus: 'reserved', handoffStage: 'recovering' } }) + // Released so a send can start over, but nothing proved the attempt gone. + const releasedUnproven = failedCreate({ lease: { ...EXITED, deathEvidence: null } }) - for (const record of [bound, unproven]) { + for (const record of [bound, unproven, releasedUnproven]) { expect(() => applyAgentSessionReservation(storeState([record]), reserveRequest(), LEASE_TTL_MS) ).toThrow('agent_session_conflict') diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index 4bd3bf9b4db..d86383ddae7 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -9,14 +9,17 @@ * inside a transaction, which is what makes the record and its operation row land together. */ +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { agentSessionOperationKey, evaluateAgentSessionOperation, + pendingAgentSessionOperationRow, pruneAgentSessionOperationRows, type AgentSessionOperationDecision, type AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' import { + agentSessionLeaseIsReleased, agentSessionLeaseOwnerVerdict, evaluateAgentSessionAcquisition, type AgentSessionOwnerProbe @@ -56,8 +59,8 @@ export type AgentSessionReserveRequest = { launchEnv?: AgentSessionLaunchEnv /** Initial provider options persisted before the first process is acquired. */ options?: Readonly> - /** The tab id this conversation shows under. Pinned on first reservation; a later reservation of - * an existing record keeps the record's own. Refused when another record already holds it. */ + /** The tab id a create reserved for this conversation, taken when its tab is published. An id + * another session's tab holds is refused here, before anything is spawned. */ surfaceTabId?: string /** Set only when this create adopts an existing provider conversation. Seeds the handle chain so * the adapter resumes; without it a new record has never proved a thread and starts a fresh one. */ @@ -110,7 +113,7 @@ export function requireAgentSessionRecordForReplay( if (!record) { // Why: the recorded effect is no longer reconstructable, and re-running it would be a second // spawn rather than a replay. - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'recordMissing' }) } return record } @@ -126,11 +129,13 @@ export function admitPendingAgentSessionReservationReplay( probe: request.probe }) if (decision.decision === 'refused') { - throw new Error(decision.code) + throw agentSessionRefusalError(decision.code, decision.details) } if (decision.decision !== 'retry-reservation') { - // A replay may continue only its still-present reservation; recovery requires a fresh intent. - throw new Error('agent_session_ownership_unknown') + // A replay may continue only its still-present reservation. + throw agentSessionRefusalError('agent_session_ownership_unknown', { + reason: 'replaySuperseded' + }) } return record } @@ -168,12 +173,12 @@ export function applyAgentSessionReservation( const existing = state.records.get(request.sessionId) if (!existing) { if (state.unreadableRecords.has(request.sessionId)) { - throw new Error('execution_owner_reconciling') + throw agentSessionRefusalError('execution_owner_reconciling', { reason: 'recordUnreadable' }) } if (request.expectedFence !== null) { - throw new Error('agent_session_checkpoint_stale') + throw agentSessionRefusalError('agent_session_checkpoint_stale', { reason: 'recordMissing' }) } - assertSurfaceTabIdUnheld(state, request) + assertReservedTabUnheld(state, request) return { record: createAgentSessionRecord(request, reservation), disposition: 'created' } } if ( @@ -183,7 +188,7 @@ export function applyAgentSessionReservation( existing.accountHome.path !== request.accountHome.path ) { // Why: location, provider, and account are the session identity; changing one is a fork. - throw new Error('agent_session_conflict') + throw agentSessionRefusalError('agent_session_conflict', { reason: 'identityMismatch' }) } // A create may take over only a record that never bound a conversation and whose last // attempt is proven gone: that is the same as creating it fresh, under a fresh provider id. @@ -192,8 +197,9 @@ export function applyAgentSessionReservation( !request.adoptedHandleLink && agentSessionLeaseOwnerVerdict(existing.lease) === 'exited' if (request.expectedFence === null && !recreatable) { - throw new Error('agent_session_conflict') + throw agentSessionRefusalError('agent_session_conflict', { reason: 'sessionExists' }) } + assertReservedTabUnheld(state, request) const pinned = { ...existing, ...(!existing.launchArgs && request.launchArgs ? { launchArgs: [...request.launchArgs] } : {}), @@ -237,14 +243,19 @@ function assertAdoptedConversationUnowned( (link) => agentSessionProviderHandleRoot(link.handle) === root ) if (holdsSameConversation) { - throw new Error('agent_session_conflict') + throw agentSessionRefusalError('agent_session_conflict', { + reason: 'conversationHeldElsewhere' + }) } } } -/** A tab id names one conversation. Two records under one id would give two chats one tab, one - * read-state key and one notification id, so the second reservation is refused as a conflict. */ -function assertSurfaceTabIdUnheld( +/** + * A tab id names one conversation, so a reserved id another session's tab holds is a conflict. + * Checked, not claimed: the id is taken when the chat's tab is published, so a create that never + * gets that far leaves nothing in the table to restore or release. + */ +function assertReservedTabUnheld( state: AgentSessionStoreState, request: AgentSessionReserveRequest ): void { @@ -252,12 +263,13 @@ function assertSurfaceTabIdUnheld( return } if (!isAgentSessionSurfaceTabId(request.surfaceTabId)) { - throw new Error('agent_session_operation_invalid') + throw agentSessionRefusalError('agent_session_operation_invalid', { + reason: 'requestMalformed' + }) } - for (const record of state.records.values()) { - if (record.sessionId !== request.sessionId && record.surfaceTabId === request.surfaceTabId) { - throw new Error('agent_session_conflict') - } + const holder = state.sessionTabs?.sessionIdFor(request.surfaceTabId) + if (holder !== undefined && holder !== request.sessionId) { + throw agentSessionRefusalError('agent_session_conflict', { reason: 'tabIdTaken' }) } } @@ -276,7 +288,6 @@ function createAgentSessionRecord( accountHome: request.accountHome, ...(request.options ? { options: { ...request.options } } : {}), ...(request.launchArgs ? { launchArgs: [...request.launchArgs] } : {}), - ...(request.surfaceTabId ? { surfaceTabId: request.surfaceTabId } : {}), createdAt: request.now, updatedAt: request.now, lease: { @@ -310,21 +321,43 @@ export function commitAgentSessionReservation( leaseTtlMs: number ): AgentSessionReserveResult { const decision = evaluateAgentSessionReserveOperation(state, request) + const existing = state.records.get(request.sessionId) + // An unfinished operation whose reservation recovery released continues under its own id at the + // next fence, as a resume does under a new id; the fence move stops the old spawn committing. + const continued = + existing && agentSessionLeaseIsReleased(existing.lease) + ? { ...request, expectedFence: existing.lease.runtimeFence } + : null if (decision.decision === 'refused') { - throw new Error(decision.code) + // An aged-out row proves nothing more: a released reservation runs no effect. + if (decision.code !== 'agent_session_operation_expired' || !continued) { + throw agentSessionRefusalError(decision.code, decision.details) + } + const row = pendingAgentSessionOperationRow({ ...request.operation, now: request.now }) + return reserveWithOperationRow(state, continued, row, leaseTtlMs) } if (decision.decision === 'replay') { - let record = requireAgentSessionRecordForReplay(state, decision.row, request.sessionId) - if (decision.row.outcome.status === 'pending' && request.handoffOperationId !== null) { - record = admitPendingAgentSessionReservationReplay(record, request) + const record = requireAgentSessionRecordForReplay(state, decision.row, request.sessionId) + if (decision.row.outcome.status !== 'pending' || request.handoffOperationId === null) { + return { record, disposition: 'replayed', operationRow: decision.row } } - return { record, disposition: 'replayed' as const, operationRow: decision.row } + if (continued) { + return reserveWithOperationRow(state, continued, decision.row, leaseTtlMs) + } + const retried = admitPendingAgentSessionReservationReplay(record, request) + return { record: retried, disposition: 'replayed', operationRow: decision.row } } + return reserveWithOperationRow(state, request, decision.row, leaseTtlMs) +} + +function reserveWithOperationRow( + state: AgentSessionStoreState, + request: AgentSessionReserveRequest, + row: AgentSessionOperationRow, + leaseTtlMs: number +): AgentSessionReserveResult { const result = applyAgentSessionReservation(state, request, leaseTtlMs) - state.operations.set( - agentSessionOperationKey(request.operation.callerKey, request.operation.operationId), - decision.row - ) + state.operations.set(agentSessionOperationKey(row.callerKey, row.operationId), row) state.records.set(result.record.sessionId, result.record) - return { ...result, operationRow: decision.row } + return { ...result, operationRow: row } } diff --git a/src/main/runtime/agent-session-restart-lease-transitions.ts b/src/main/runtime/agent-session-restart-lease-transitions.ts index 45c59e02744..b4269d21c67 100644 --- a/src/main/runtime/agent-session-restart-lease-transitions.ts +++ b/src/main/runtime/agent-session-restart-lease-transitions.ts @@ -8,13 +8,9 @@ import { adjudicateAgentSessionRestart, - agentSessionRestartEvictionSettlementId, type AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' -import type { - AgentSessionHandoffStage, - AgentSessionRecord -} from '../../shared/agent-session-record' +import type { AgentSessionRecord } from '../../shared/agent-session-record' import { withLease } from './agent-session-lease-transitions' /** Apply one restart adjudication. Never consults deadlines — only proof moves a lease. */ @@ -29,9 +25,6 @@ export function applyAgentSessionRestartAdjudication(args: { probe: args.probe, observedAt: args.now }) - if (adjudication.disposition === 'settlement-pending') { - return withLease(record, { ...record.lease, unreconciled: false, lastRenewedAt: args.now }) - } if (adjudication.disposition === 'free') { // Why: an already-free lease that reloads into `recovering` is unopenable forever; clearing // the stage restores it without moving the fence or touching the recorded death evidence. @@ -39,42 +32,33 @@ export function applyAgentSessionRestartAdjudication(args: { ...record.lease, handoffStage: null, handoffOperationId: null, - processlessAt: null, unreconciled: false, lastRenewedAt: args.now }) } if (adjudication.disposition === 'evicted') { - // A reservation that never proved its handle ran no turn, so no journal settlement is owed. - const settlementOwed = record.lease.handoffStage !== 'new-owner-proving' + // What the dead generation left running is settled from `deathEvidence` when the journal is + // next opened, so nothing about it is owed here. return withLease(record, { ...record.lease, runtimeFence: adjudication.nextFence, handoffStage: null, ownerProcess: null, reservedSpawnToken: null, - processlessAt: null, claimStatus: 'released', unreconciled: false, lastRenewedAt: args.now, handoffOperationId: null, - deathEvidence: adjudication.evidence, - ...(settlementOwed - ? { - settlementRetryRequired: true, - settlementRetryId: agentSessionRestartEvictionSettlementId(record.lease, adjudication) - } - : {}) + deathEvidence: adjudication.evidence }) } - const stage: AgentSessionHandoffStage = - adjudication.disposition === 'conflicted' ? 'manual-recovery' : adjudication.stage - return withLease(record, { - ...record.lease, - handoffStage: stage, - claimStatus: - adjudication.disposition === 'conflicted' ? 'conflicted' : record.lease.claimStatus, - unreconciled: false, - lastRenewedAt: args.now - }) + // Parking in recovery proves nothing alive, so `lastRenewedAt` keeps the pre-crash proof. + return { + ...withLease(record, { + ...record.lease, + handoffStage: adjudication.stage, + unreconciled: false + }), + updatedAt: args.now + } } diff --git a/src/main/runtime/agent-session-spawn-token-process-scan.ts b/src/main/runtime/agent-session-spawn-token-process-scan.ts index 6db1c069110..55e860f9320 100644 --- a/src/main/runtime/agent-session-spawn-token-process-scan.ts +++ b/src/main/runtime/agent-session-spawn-token-process-scan.ts @@ -56,7 +56,7 @@ export async function scanAgentSessionSpawnTokenProcesses( /** * Diagnostic evidence only. A null result is deliberately typed as * `unverifiable`, not as an empty process set; callers must never use this - * Linux read-back as ownership or orphan-reaping proof. + * Linux read-back as ownership proof. */ export async function scanAgentSessionSpawnTokenEvidence( platform: NodeJS.Platform = process.platform, diff --git a/src/main/runtime/agent-session-store-serialization.ts b/src/main/runtime/agent-session-store-serialization.ts index cda3e44d7ff..adc93f85c01 100644 --- a/src/main/runtime/agent-session-store-serialization.ts +++ b/src/main/runtime/agent-session-store-serialization.ts @@ -1,4 +1,5 @@ import type { AgentSessionStoreState } from './agent-session-record-store-file' +import { serializeAgentSessionTabTable } from './agent-session-tab-table' export function serializeAgentSessionStoreState(state: AgentSessionStoreState): string { const records: Record = Object.create(null) @@ -13,8 +14,8 @@ export function serializeAgentSessionStoreState(state: AgentSessionStoreState): retiredClaimKeys: state.retiredClaimKeys, unusableRecords: Object.fromEntries(state.unreadableRecords) } - if (state.visibleSessionIdsIndexPresent) { - serialized.visibleSessionIds = [...state.visibleSessionIds] + if (state.sessionTabs) { + Object.assign(serialized, serializeAgentSessionTabTable(state.sessionTabs)) } return JSON.stringify(serialized) } diff --git a/src/main/runtime/agent-session-store-transaction-queue.ts b/src/main/runtime/agent-session-store-transaction-queue.ts index 08d04996d81..1a4c78e4672 100644 --- a/src/main/runtime/agent-session-store-transaction-queue.ts +++ b/src/main/runtime/agent-session-store-transaction-queue.ts @@ -1,5 +1,5 @@ import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' -import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' import { raiseAgentSessionFencesAfterBackupRecovery } from './agent-session-backup-recovery-fence' import { AGENT_SESSION_STORE_SCHEMA_VERSION, @@ -7,16 +7,30 @@ import { loadAgentSessionStore, saveAgentSessionStore, type AgentSessionStoreState, - type LoadedAgentSessionStore, - backfillAgentSessionSurfaceTabIds + type LoadedAgentSessionStore } from './agent-session-record-store-file' import { withFileTransactionLock } from '../file-transaction-lock' +/** Latch fields older builds wrote. Nothing reads them, and dropping them keeps a lease this build + * writes back from carrying a stale latch to an older build after a downgrade. */ +type RetiredAgentSessionLeaseFields = { + processlessAt?: unknown + settlementRetryRequired?: unknown + settlementRetryId?: unknown +} + function markLoadedLeasesUnreconciled(state: AgentSessionStoreState): void { for (const [sessionId, record] of state.records) { + const lease: AgentSessionLease & RetiredAgentSessionLeaseFields = record.lease + const { + processlessAt: _processlessAt, + settlementRetryRequired: _settlementRetryRequired, + settlementRetryId: _settlementRetryId, + ...current + } = lease state.records.set(sessionId, { ...record, - lease: { ...record.lease, unreconciled: true } + lease: { ...current, unreconciled: true } }) } } @@ -39,16 +53,15 @@ function agentSessionStoreStateChanged( operations: ReadonlyMap, retiredClaimKeys: AgentSessionStoreState['retiredClaimKeys'], unreadableRecords: AgentSessionStoreState['unreadableRecords'], - visibleSessionIds: AgentSessionStoreState['visibleSessionIds'], - visibleSessionIdsIndexPresent: AgentSessionStoreState['visibleSessionIdsIndexPresent'] + sessionTabs: AgentSessionStoreState['sessionTabs'] ): boolean { return ( !mapEntriesMatch(state.records, records) || !mapEntriesMatch(state.operations, operations) || !mapEntriesMatch(state.unreadableRecords, unreadableRecords) || - state.visibleSessionIdsIndexPresent !== visibleSessionIdsIndexPresent || - state.visibleSessionIds.size !== visibleSessionIds.size || - [...state.visibleSessionIds].some((id) => !visibleSessionIds.has(id)) || + (state.sessionTabs && sessionTabs + ? !state.sessionTabs.equals(sessionTabs) + : state.sessionTabs !== sessionTabs) || state.retiredClaimKeys.length !== retiredClaimKeys.length || state.retiredClaimKeys.some((entry, index) => entry !== retiredClaimKeys[index]) ) @@ -100,8 +113,7 @@ export class AgentSessionStoreTransactionQueue { const operations = new Map(this.state.operations) const retiredClaimKeys = [...this.state.retiredClaimKeys] const unreadableRecords = new Map(this.state.unreadableRecords) - const visibleSessionIds = new Set(this.state.visibleSessionIds) - const visibleSessionIdsIndexPresent = this.state.visibleSessionIdsIndexPresent + const sessionTabs = this.state.sessionTabs?.clone() ?? null try { // The lost commit may have granted a higher fence than the backup records show. Rather // than refuse forever, raise every recovered fence clear of anything that commit could @@ -120,8 +132,7 @@ export class AgentSessionStoreTransactionQueue { operations, retiredClaimKeys, unreadableRecords, - visibleSessionIds, - visibleSessionIdsIndexPresent + sessionTabs ) ) { return result @@ -140,8 +151,7 @@ export class AgentSessionStoreTransactionQueue { this.state.operations = operations this.state.retiredClaimKeys = retiredClaimKeys this.state.unreadableRecords = unreadableRecords - this.state.visibleSessionIds = visibleSessionIds - this.state.visibleSessionIdsIndexPresent = visibleSessionIdsIndexPresent + this.state.sessionTabs = sessionTabs throw error } }) @@ -170,11 +180,6 @@ export class AgentSessionStoreTransactionQueue { throw new Error('agent_session_legacy_required') } markLoadedLeasesUnreconciled(loaded.state) - // Why: a reload replaces the state wholesale, so the ids filled at open would vanish from - // memory until the next open; refilling keeps every in-memory record carrying one. It does - // not force a save: a reload marks every lease unadjudicated, and this instance must not - // persist that verdict on the strength of a refill. - backfillAgentSessionSurfaceTabIds(loaded.state) this.state = loaded.state this.diskRevision = diskRevision this.needsRewrite = loaded.needsRewrite diff --git a/src/main/runtime/agent-session-surface-release-transition.test.ts b/src/main/runtime/agent-session-surface-release-transition.test.ts index 5e671acc77f..0ea30d90197 100644 --- a/src/main/runtime/agent-session-surface-release-transition.test.ts +++ b/src/main/runtime/agent-session-surface-release-transition.test.ts @@ -18,5 +18,7 @@ describe('agent session surface release transition', () => { }) expect(released.lease.runtimeFence).toBe(9) + // The proof names the owner it released, not the fence the floor moved to. + expect(released.lease.deathEvidence).toMatchObject({ kind: 'exit-observed', ownerFence: 7 }) }) }) diff --git a/src/main/runtime/agent-session-surface-release-transition.ts b/src/main/runtime/agent-session-surface-release-transition.ts index a6c90d1e01f..fe98a870524 100644 --- a/src/main/runtime/agent-session-surface-release-transition.ts +++ b/src/main/runtime/agent-session-surface-release-transition.ts @@ -5,9 +5,10 @@ // lease-owning provider root through the adapter. Its observed exit is sufficient because the // lease follows that root, even when descendants remain `unverifiable`. // -// The fence still moves. A released lease at the old fence would let a mutation a client queued -// against the dead generation land on the next one. +// The fence still moves, so the next owner is a new generation: an attach or settlement still +// holding the stopped owner's fence is refused as stale rather than acting on its successor. +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' import { assertFence, withLease } from './agent-session-lease-transitions' @@ -31,28 +32,27 @@ export function releaseAgentSessionOwnerAfterSurfaceClose(args: { now: number /** Exit receipt can precede a delayed journal settlement and lease release. */ exitObservedAt?: number - settlementRetry?: { settlementId: string; detail: string } + /** Why the provider exited, when the host saw it die on its own. */ + exitReason?: string }): AgentSessionRecord { const { record } = args assertFence(record.lease, args.expectedFence) if (!isSurfaceReleasableAgentSessionRecord(record)) { - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'leaseMoved' }) } return withLease(record, { ...record.lease, runtimeFence: nextAgentSessionFence(record.lease), ownerProcess: null, reservedSpawnToken: null, - processlessAt: null, claimStatus: 'released', - handoffStage: args.settlementRetry ? 'recovering' : null, - settlementRetryRequired: args.settlementRetry ? true : undefined, - settlementRetryId: args.settlementRetry?.settlementId, + handoffStage: null, lastRenewedAt: args.now, deathEvidence: { kind: 'exit-observed', - detail: args.settlementRetry?.detail ?? 'the last surface holding this session released it', - observedAt: args.exitObservedAt ?? args.now + detail: args.exitReason ?? 'the last surface holding this session released it', + observedAt: args.exitObservedAt ?? args.now, + ownerFence: record.lease.runtimeFence } }) } @@ -65,7 +65,7 @@ export function releaseStoredAgentSessionOwnerAfterSurfaceClose( expectedFence: number now: number exitObservedAt?: number - settlementRetry?: { settlementId: string; detail: string } + exitReason?: string } ): Promise { return store.transitionHandoff(args.sessionId, (record) => diff --git a/src/main/runtime/agent-session-tab-table.ts b/src/main/runtime/agent-session-tab-table.ts new file mode 100644 index 00000000000..6022aac8bf2 --- /dev/null +++ b/src/main/runtime/agent-session-tab-table.ts @@ -0,0 +1,259 @@ +import { isAgentSessionId, type AgentSessionRecord } from '../../shared/agent-session-record' +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' +import { isAgentSessionSurfaceTabId } from '../../shared/agent-session-surface-tab-id' +import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' +import type { AgentSessionStoreState } from './agent-session-record-store-file' + +/** + * Which conversation each structured chat tab shows, keyed by the host tab id. + * + * Membership is visibility: a session with an entry has a chat tab, and the key is that tab's id. + * A /clear moves the tab to the replacement conversation rather than copying its id, so an id names + * one conversation by construction and a session maps back to at most one tab. + */ +export class AgentSessionTabTable { + private readonly sessionByTab = new Map() + private readonly tabBySession = new Map() + + constructor(entries: Iterable = []) { + for (const [tabId, sessionId] of entries) { + this.put(tabId, sessionId) + } + } + + tabIdFor(sessionId: string): string | undefined { + return this.tabBySession.get(sessionId) + } + + sessionIdFor(tabId: string): string | undefined { + return this.sessionByTab.get(tabId) + } + + /** Sessions that have a tab, in the order their tabs were given out. */ + sessionIds(): string[] { + return [...this.sessionByTab.values()] + } + + entries(): [tabId: string, sessionId: string][] { + return [...this.sessionByTab.entries()] + } + + /** + * Gives a session a tab unless it already has one. Without a reserved id it gets the id clients + * derive for it, unless a cleared conversation's tab kept that id. + */ + show(sessionId: string, tabId?: string): void { + if (this.tabBySession.has(sessionId)) { + return + } + const derived = structuredAgentSessionTabId(sessionId) + const held = (candidate: string): boolean => this.sessionByTab.has(candidate) + this.put(tabId ?? (held(derived) ? reopenedTabId(sessionId, held) : derived), sessionId) + } + + /** Returns the id the session's tab had, if it had one. */ + hide(sessionId: string): string | undefined { + const tabId = this.tabBySession.get(sessionId) + if (tabId !== undefined) { + this.tabBySession.delete(sessionId) + this.sessionByTab.delete(tabId) + } + return tabId + } + + /** A /clear: the tab that showed `fromSessionId` shows `toSessionId`, keeping its id and place. */ + move(fromSessionId: string, toSessionId: string): void { + const tabId = this.tabBySession.get(fromSessionId) + this.hide(toSessionId) + if (tabId === undefined) { + this.show(toSessionId) + return + } + this.tabBySession.delete(fromSessionId) + this.sessionByTab.set(tabId, toSessionId) + this.tabBySession.set(toSessionId, tabId) + } + + clone(): AgentSessionTabTable { + return new AgentSessionTabTable(this.sessionByTab) + } + + equals(other: AgentSessionTabTable): boolean { + const left = this.entries() + const right = other.entries() + return ( + left.length === right.length && + left.every(([tabId, sessionId], index) => { + const [otherTabId, otherSessionId] = right[index] + return tabId === otherTabId && sessionId === otherSessionId + }) + ) + } + + private put(tabId: string, sessionId: string): void { + const owner = this.sessionByTab.get(tabId) + if (owner !== undefined && owner !== sessionId) { + throw agentSessionRefusalError('agent_session_conflict', { reason: 'tabIdTaken' }) + } + this.hide(sessionId) + this.sessionByTab.set(tabId, sessionId) + this.tabBySession.set(sessionId, tabId) + } +} + +/** + * The id a cleared conversation reopened from history takes while the tab now showing its + * replacement holds its own. Deterministic, so a table seeded again gives it the same id. + */ +function reopenedTabId(sessionId: string, held: (tabId: string) => boolean): string { + const base = `${structuredAgentSessionTabId(sessionId)}-reopened` + let tabId = base + for (let suffix = 2; held(tabId); suffix++) { + tabId = `${base}-${suffix}` + } + return tabId +} + +export function setAgentSessionTabVisibility( + state: AgentSessionStoreState, + sessionId: string, + visible: boolean, + tabId?: string +): void { + if (visible && !state.records.has(sessionId)) { + throw agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) + } + state.sessionTabs ??= new AgentSessionTabTable() + if (visible) { + state.sessionTabs.show(sessionId, tabId) + } else { + state.sessionTabs.hide(sessionId) + } +} + +export type PersistedAgentSessionTab = { tabId: string; sessionId: string } + +export function serializeAgentSessionTabTable(table: AgentSessionTabTable): { + sessionTabs: PersistedAgentSessionTab[] + visibleSessionIds: string[] +} { + return { + sessionTabs: table.entries().map(([tabId, sessionId]) => ({ tabId, sessionId })), + // Written for older builds, which restore tabs from this list; never read beside the table. + visibleSessionIds: table.sessionIds() + } +} + +/** + * Reads the persisted table, or seeds it from what older builds wrote: the visible session list and, + * for a chat created by a build that recorded one, the tab id on its record. That record field is + * read here and nowhere else, and only when the file carries no table. + * + * Deterministic on purpose: a parsed store must hash the same on every read of the same bytes. + */ +export function parseAgentSessionTabTable( + file: { sessionTabs?: unknown; visibleSessionIds?: unknown }, + records: ReadonlyMap, + strict: boolean +): { valid: boolean; table: AgentSessionTabTable | null } { + if (file.sessionTabs !== undefined) { + return parsePersistedTabs(file.sessionTabs, strict) + } + if (file.visibleSessionIds === undefined) { + return { valid: true, table: null } + } + if (!Array.isArray(file.visibleSessionIds)) { + return { valid: !strict, table: null } + } + return { valid: true, table: seedFromVisibleSessions(file.visibleSessionIds, records) } +} + +/** + * A cleared chat's tab was opened for the first conversation of its /clear chain and kept that id + * through every clear, so the chat now showing the chain's latest conversation seeds under the + * first one's id, as a /clear on this build would have left it. Those chats seed first: a cleared + * conversation reopened from history is the later tab, and takes a fresh id if its own is held. + */ +function seedFromVisibleSessions( + visible: readonly unknown[], + records: ReadonlyMap +): AgentSessionTabTable { + const sessionIds = [...new Set(visible.filter(isAgentSessionId))] + const clearedFrom = new Map() + for (const record of records.values()) { + const command = record.conversationCommand + if ( + command?.command === 'clear' && + command.phase === 'committed' && + command.replacementSessionId && + !clearedFrom.has(command.replacementSessionId) + ) { + clearedFrom.set(command.replacementSessionId, record.sessionId) + } + } + const clearedTo = new Set(clearedFrom.values()) + const chainRoot = (sessionId: string): string => { + const seen = new Set([sessionId]) + let current = sessionId + let prior = clearedFrom.get(current) + while (prior !== undefined && !seen.has(prior)) { + seen.add(prior) + current = prior + prior = clearedFrom.get(current) + } + return current + } + const recordedOrDerived = (sessionId: string): string[] => { + const record = records.get(sessionId) + const recorded = record && 'surfaceTabId' in record ? record.surfaceTabId : undefined + return [recorded, structuredAgentSessionTabId(sessionId)].filter(isAgentSessionSurfaceTabId) + } + const tabIds = new Map() + const taken = new Set() + const held = (tabId: string): boolean => taken.has(tabId) + const assign = (sessionId: string, candidates: readonly string[]): void => { + const tabId = candidates.find((candidate) => !held(candidate)) ?? reopenedTabId(sessionId, held) + taken.add(tabId) + tabIds.set(sessionId, tabId) + } + const holdsChainTab = (sessionId: string): boolean => + !clearedTo.has(sessionId) && chainRoot(sessionId) !== sessionId + for (const sessionId of sessionIds.filter(holdsChainTab)) { + assign(sessionId, [...recordedOrDerived(chainRoot(sessionId)), ...recordedOrDerived(sessionId)]) + } + for (const sessionId of sessionIds.filter((sessionId) => !holdsChainTab(sessionId))) { + assign(sessionId, recordedOrDerived(sessionId)) + } + // In the visible list's order, which is the order older builds restored tabs in. + return new AgentSessionTabTable( + sessionIds.flatMap((sessionId) => { + const tabId = tabIds.get(sessionId) + return tabId === undefined ? [] : [[tabId, sessionId] as const] + }) + ) +} + +function parsePersistedTabs( + raw: unknown, + strict: boolean +): { valid: boolean; table: AgentSessionTabTable | null } { + if (!Array.isArray(raw)) { + return { valid: !strict, table: null } + } + const table = new AgentSessionTabTable() + for (const entry of raw) { + const tabId: unknown = entry?.tabId + const sessionId: unknown = entry?.sessionId + const wellFormed = + isAgentSessionSurfaceTabId(tabId) && + isAgentSessionId(sessionId) && + table.sessionIdFor(tabId) === undefined && + table.tabIdFor(sessionId) === undefined + if (wellFormed) { + table.show(sessionId, tabId) + } else if (strict) { + return { valid: false, table: null } + } + } + return { valid: true, table } +} diff --git a/src/main/runtime/agent-session-visible-tab-index.ts b/src/main/runtime/agent-session-visible-tab-index.ts deleted file mode 100644 index e0f7b9b4c5c..00000000000 --- a/src/main/runtime/agent-session-visible-tab-index.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { AgentSessionStoreState } from './agent-session-record-store-file' -export function parseVisibleSessionIds( - raw: unknown, - schemaVersion: number, - currentSchemaVersion: number -): { ids: string[]; present: boolean; valid: boolean } { - if (raw === undefined) { - return { ids: [], present: false, valid: true } - } - if (!Array.isArray(raw)) { - return { ids: [], present: false, valid: schemaVersion !== currentSchemaVersion } - } - const ids: string[] = [] - for (const value of raw) { - if (typeof value === 'string' && value.length > 0) { - ids.push(value) - } else if (schemaVersion === currentSchemaVersion) { - return { ids: [], present: true, valid: false } - } - } - return { ids, present: true, valid: true } -} - -export function setVisibleSessionId( - state: AgentSessionStoreState, - sessionId: string, - visible: boolean -): void { - if (visible) { - if (!state.records.has(sessionId)) { - throw new Error('agent_session_identity_required') - } - state.visibleSessionIds.add(sessionId) - } else { - state.visibleSessionIds.delete(sessionId) - } - state.visibleSessionIdsIndexPresent = true -} diff --git a/src/main/runtime/agent-startup-input-assembly-census.test.ts b/src/main/runtime/agent-startup-input-assembly-census.test.ts deleted file mode 100644 index ca05ed03648..00000000000 --- a/src/main/runtime/agent-startup-input-assembly-census.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -const REPO_ROOT = join(import.meta.dirname, '../../..') - -/** - * The host modules that turn settings into `buildAgentStartupPlan` inputs. - * - * `buildAgentStartupPlan` was always shared; the input assembly was not, and the hand-rolled - * copies drifted — one dropped `sessionOptionsOverrideAgentArgs` and let configured args defeat a - * picked model. This pins the funnel so a sixth host site cannot re-derive the inputs by hand. - */ -const MIGRATED_HOST_LAUNCH_MODULES = [ - 'src/main/runtime/orca-runtime-create-agent-session.ts', - 'src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts', - 'src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts', - 'src/main/runtime/runtime-worktree-agent-startup.ts' -] - -/** Reading any of these next to a startup plan means the inputs are being assembled by hand. */ -const HAND_ASSEMBLY_MARKERS = [ - 'resolveTuiAgentLaunchArgs(', - 'resolveTuiAgentLaunchEnv(', - 'resolveLocalWindowsAgentStartupShell(' -] - -function read(file: string): string { - return readFileSync(join(REPO_ROOT, file), 'utf8') -} - -describe('host agent-startup input assembly census', () => { - it('routes every migrated host launch site through the shared resolver', () => { - const missing = MIGRATED_HOST_LAUNCH_MODULES.filter( - (file) => !read(file).includes('resolveAgentStartupPlanInputs(') - ) - expect(missing).toEqual([]) - }) - - it('leaves no migrated host site assembling the settings-derived inputs by hand', () => { - const handAssembled = MIGRATED_HOST_LAUNCH_MODULES.filter((file) => { - const source = read(file) - return HAND_ASSEMBLY_MARKERS.some((marker) => source.includes(marker)) - }) - expect(handAssembled).toEqual([]) - }) - - it('detects hand assembly when it is present', () => { - // Positive control: the markers are real names, so an empty result above is a true negative - // rather than a typo that can never match. - const resumeSite = read( - 'src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts' - ) - expect(HAND_ASSEMBLY_MARKERS.some((marker) => resumeSite.includes(marker))).toBe(true) - }) -}) diff --git a/src/main/runtime/agent-terminal-launch-trust-host.test.ts b/src/main/runtime/agent-terminal-launch-host.test.ts similarity index 67% rename from src/main/runtime/agent-terminal-launch-trust-host.test.ts rename to src/main/runtime/agent-terminal-launch-host.test.ts index 949b0c545fc..1925869a4e5 100644 --- a/src/main/runtime/agent-terminal-launch-trust-host.test.ts +++ b/src/main/runtime/agent-terminal-launch-host.test.ts @@ -1,7 +1,5 @@ -// launchAgentTerminal read `store.getRepo(worktree.repoId)?.connectionId` for the trust write — -// host-blind, so the same repo id on two hosts wrote a remote path into the client's agent config -// and the agent on the host never saw the trust (#11163). Every sibling call site already passes -// the resolved `workspace.connectionId`; this was the last one that did not. +// launchAgentTerminal once read the host-blind `store.getRepo(worktree.repoId)`, so the same repo +// id on two hosts built the launch for the wrong one (#11163). import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => ({ @@ -18,11 +16,6 @@ const REMOTE_PATH = '/srv/app-feature' type RuntimeInternals = { resolveWorktreeSelector: (selector: string) => Promise buildStartupForAgent: (repo: unknown, agent: unknown, prompt: string) => unknown - markWorkspaceTrustedForAgent: ( - agent: unknown, - connectionId: string | null | undefined, - path: string - ) => Promise createTerminal: (selector: string, opts: unknown) => Promise } @@ -41,24 +34,22 @@ function makeRuntime(repos: readonly Record[], hostId?: string) path: REMOTE_PATH, ...(hostId ? { hostId } : {}) }) - vi.spyOn(internals, 'buildStartupForAgent').mockReturnValue({ + const buildStartup = vi.spyOn(internals, 'buildStartupForAgent').mockReturnValue({ agent: 'codex', startup: { command: 'codex', env: {}, startupCommandDelivery: 'none', telemetry: {} } }) - const markTrusted = vi.fn(async () => {}) - vi.spyOn(internals, 'markWorkspaceTrustedForAgent').mockImplementation(markTrusted) vi.spyOn(internals, 'createTerminal').mockResolvedValue({ id: 'pty-1' }) - return { runtime, markTrusted } + return { runtime, buildStartup } } -describe('launchAgentTerminal trust write', () => { +describe('launchAgentTerminal execution host', () => { beforeEach(() => { vi.restoreAllMocks() }) - it('writes trust on the host the worktree names, not on a rival row', async () => { + it('builds the launch for the host the worktree names, not a rival row', async () => { // Two SSH hosts publish the same repo id; the worktree is on m4air. - const { runtime, markTrusted } = makeRuntime( + const { runtime, buildStartup } = makeRuntime( [ { id: 'repo-shared', path: '/home/me/app', connectionId: 'openclaw' }, { id: 'repo-shared', path: '/srv/app', connectionId: 'm4air' } @@ -69,13 +60,13 @@ describe('launchAgentTerminal trust write', () => { await runtime.launchAgentTerminal('id:repo-shared::/srv/app-feature', { agent: 'codex', prompt: 'go' - } as never) + }) - expect(markTrusted).toHaveBeenCalledWith('codex', 'm4air', REMOTE_PATH) + expect(buildStartup.mock.calls[0]?.[0]).toMatchObject({ connectionId: 'm4air' }) }) - it('writes trust locally for a local worktree even when a remote row shares the id', async () => { - const { runtime, markTrusted } = makeRuntime( + it('builds a local launch for a local worktree even when a remote row shares the id', async () => { + const { runtime, buildStartup } = makeRuntime( [ { id: 'repo-shared', path: '/srv/app', connectionId: 'm4air' }, { id: 'repo-shared', path: '/home/me/app' } @@ -86,9 +77,10 @@ describe('launchAgentTerminal trust write', () => { await runtime.launchAgentTerminal('id:repo-shared::/srv/app-feature', { agent: 'codex', prompt: 'go' - } as never) + }) - expect(markTrusted).toHaveBeenCalledWith('codex', null, REMOTE_PATH) + expect(buildStartup.mock.calls[0]?.[0]).toMatchObject({ path: '/home/me/app' }) + expect(buildStartup.mock.calls[0]?.[0]).not.toHaveProperty('connectionId') }) it('refuses rather than guessing when rival rows disagree and the worktree names no host', async () => { @@ -101,7 +93,7 @@ describe('launchAgentTerminal trust write', () => { runtime.launchAgentTerminal('id:repo-shared::/srv/app-feature', { agent: 'codex', prompt: 'go' - } as never) + }) ).rejects.toThrow('worktree_execution_host_unresolved') }) }) diff --git a/src/main/runtime/agent-terminal-startup-prompt.test.ts b/src/main/runtime/agent-terminal-startup-prompt.test.ts index 7b73571ba79..8a9f9648147 100644 --- a/src/main/runtime/agent-terminal-startup-prompt.test.ts +++ b/src/main/runtime/agent-terminal-startup-prompt.test.ts @@ -22,11 +22,10 @@ function runtimeWithAgentLaunch(): { spawn: ReturnType } { const runtime = new OrcaRuntimeService() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver under test is a protected member; the assertion names only the three internals this stub replaces, each of which is assigned before the create reaches it. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver under test is a protected member; the assertion names only the two internals this stub replaces, each of which is assigned before the create reaches it. const internal = runtime as unknown as { store: { getSettings: () => Record } resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise - markWorkspaceTrustedForAgent: () => Promise } internal.store = { getSettings: () => ({}) } vi.spyOn(internal, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({ @@ -36,8 +35,6 @@ function runtimeWithAgentLaunch(): { repo: null, folderWorkspace: null }) - // Trust presets touch the real filesystem and are not what this resolver is being asked about. - internal.markWorkspaceTrustedForAgent = async () => undefined const spawn = vi.fn().mockResolvedValue({ id: 'pty-1' }) runtime.setPtyController({ spawn, diff --git a/src/main/runtime/agent-transcript-pane-test-harness.ts b/src/main/runtime/agent-transcript-pane-test-harness.ts index 5f0c20267d9..14ad56a8d9d 100644 --- a/src/main/runtime/agent-transcript-pane-test-harness.ts +++ b/src/main/runtime/agent-transcript-pane-test-harness.ts @@ -18,6 +18,8 @@ export type TranscriptPaneOptions = { /** Simulates a PTY controller whose foreground probe never settles. */ foregroundProbeHangs?: boolean onForegroundProbe?: () => void + /** PTY grid the controller reports; the runtime's emulator otherwise defaults to 80x24. */ + size?: { cols: number; rows: number } } export async function createTranscriptPane( @@ -39,6 +41,7 @@ export async function createTranscriptPane( spawn: vi.fn().mockResolvedValue({ id: TRANSCRIPT_PANE_PTY_ID, incarnationId: 'inc-1' }), write: () => true, kill: () => true, + getSize: () => options.size ?? null, getForegroundProcess: (): Promise => { options.onForegroundProbe?.() return options.foregroundProbeHangs === true diff --git a/src/main/runtime/agent-transcript-replay-test-harness.ts b/src/main/runtime/agent-transcript-replay-test-harness.ts new file mode 100644 index 00000000000..5b57656ab4a --- /dev/null +++ b/src/main/runtime/agent-transcript-replay-test-harness.ts @@ -0,0 +1,56 @@ +// Replays captured PTY bytes the way onPtyData does, for suites asserting a rule on every frame. +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { HeadlessEmulator } from '../daemon/headless-emulator' +import { projectTerminalVisibleLines } from './orca-runtime-terminal-projection' +import { normalizeTerminalChunk } from './terminal-ansi-normalization' +import { appendNormalizedToTailBuffer } from './terminal-tail-buffer' +import { buildPreview } from './terminal-tail-state' +import { buildTerminalWaitText } from './terminal-wait-tail-state' + +const DEFAULT_CHUNK_CHARS = 64 + +export type TranscriptReplayFrame = { screenLines: string[]; waitText: string } + +export function readRuntimeFixture(name: string): string { + return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8') +} + +/** A string is cut into fixed 64-char chunks; an array replays the recorded PTY chunks as-is. */ +export async function* replayTranscript( + data: string | readonly string[], + cols: number, + rows: number +): AsyncGenerator { + const chunks = typeof data === 'string' ? splitIntoChunks(data) : data + const emulator = new HeadlessEmulator({ cols, rows }) + let lines: string[] = [] + let partialLine = '' + let pendingAnsi = '' + let redrawCursor: ReturnType['redrawCursor'] = null + try { + for (const chunk of chunks) { + await emulator.write(chunk) + const normalized = normalizeTerminalChunk(chunk, pendingAnsi) + pendingAnsi = normalized.pendingAnsi + const tail = appendNormalizedToTailBuffer(lines, partialLine, normalized.text, redrawCursor) + lines = tail.lines + partialLine = tail.partialLine + redrawCursor = tail.redrawCursor + yield { + screenLines: projectTerminalVisibleLines(emulator).lines, + waitText: buildTerminalWaitText(lines, partialLine, buildPreview(lines, partialLine)) + } + } + } finally { + emulator.dispose() + } +} + +function splitIntoChunks(data: string): string[] { + const chunks: string[] = [] + for (let offset = 0; offset < data.length; offset += DEFAULT_CHUNK_CHARS) { + chunks.push(data.slice(offset, offset + DEFAULT_CHUNK_CHARS)) + } + return chunks +} diff --git a/src/main/runtime/automation-change-publication.test.ts b/src/main/runtime/automation-change-publication.test.ts index 5cde275d5ea..a63d56efb9b 100644 --- a/src/main/runtime/automation-change-publication.test.ts +++ b/src/main/runtime/automation-change-publication.test.ts @@ -1,3 +1,4 @@ +import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' /** * A definition change must name the host it affected, and a change that moves a * record between hosts must name both — a subscriber that never hears about the @@ -106,7 +107,7 @@ async function makeRuntime() { vi.resetModules() const { Store, initDataPath } = await import('../persistence') initDataPath() - const store = new Store() + const store = createSqliteTestStore(Store, { dataFile: join(testState.dir, 'orca-data.json') }) const { OrcaRuntimeService } = await import('./orca-runtime') const runtime = new OrcaRuntimeService(store as never) const published: AutomationsChangedPayload[] = [] @@ -127,14 +128,42 @@ beforeEach(() => { }) }) -afterEach(() => { +afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) describe('scoped automationsChanged publication', () => { + it.each(['update', 'delete'] as const)( + 'waits for durable %s before publishing success', + async (operation) => { + const { store, runtime, published } = await makeRuntime() + const gate = Promise.withResolvers() + vi.spyOn(store, 'flushPendingOrThrowAsync').mockReturnValue(gate.promise) + const pending = + operation === 'update' + ? runtime.updateAutomation('local-1', { name: 'Changed' }) + : runtime.deleteAutomation('local-1') + await vi.waitFor(() => expect(store.flushPendingOrThrowAsync).toHaveBeenCalledOnce()) + expect(published).toEqual([]) + gate.resolve() + await pending + expect(published).toHaveLength(1) + } + ) + + it('rejects a failed durable definition write without publishing success', async () => { + const { store, runtime, published } = await makeRuntime() + vi.spyOn(store, 'flushPendingOrThrowAsync').mockRejectedValue(new Error('disk full')) + await expect(runtime.updateAutomation('local-1', { name: 'Changed' })).rejects.toThrow( + 'disk full' + ) + expect(published).toEqual([]) + }) + it('names the host a delete removed a row from', async () => { const { runtime, published } = await makeRuntime() - runtime.deleteAutomation('ssh-1-a', { + await runtime.deleteAutomation('ssh-1-a', { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } }) expect(published).toEqual([ @@ -144,7 +173,7 @@ describe('scoped automationsChanged publication', () => { it('names the orphan bucket when an unowned row is deleted', async () => { const { runtime, published } = await makeRuntime() - runtime.deleteAutomation('orphan-1', { selector: { kind: 'orphan' } }) + await runtime.deleteAutomation('orphan-1', { selector: { kind: 'orphan' } }) expect(published).toEqual([{ reason: 'definition', selector: { kind: 'orphan' } }]) }) diff --git a/src/main/runtime/browser-tab-create-publication.test.ts b/src/main/runtime/browser-tab-create-publication.test.ts index c04c2fbdfee..fd5560751b2 100644 --- a/src/main/runtime/browser-tab-create-publication.test.ts +++ b/src/main/runtime/browser-tab-create-publication.test.ts @@ -17,9 +17,7 @@ import { publishCreatedBrowserSessionTab, publishSwitchedBrowserSessionTab, resolveBrowserTabCreateFocus, - type BrowserTabCreatePlacementKind, - type BrowserTabCreatePublicationHost, - type BrowserTabSwitchPlacementKind + type BrowserTabCreatePublicationHost } from './browser-tab-create-publication' const { ipcMainOnMock, waitForTabRegistrationMock } = vi.hoisted(() => ({ @@ -99,12 +97,6 @@ function browserCommandsSource(): string { } describe('publishCreatedBrowserSessionTab', () => { - it('declares a publication rule for every placement kind', () => { - expect(Object.keys(BROWSER_TAB_CREATE_PUBLICATION_RULES).sort()).toEqual( - [...BROWSER_TAB_CREATE_PLACEMENT_KINDS].sort() - ) - }) - // Why: the per-placement cases below read their expectation off this table, so the table itself // needs a literal pin — otherwise dropping a step here would silently rewrite what they assert. it('pins the bookkeeping each placement owns', () => { @@ -150,15 +142,6 @@ describe('publishCreatedBrowserSessionTab', () => { } }) - it('declares a host row source for every placement kind', () => { - for (const placementKind of BROWSER_TAB_CREATE_PLACEMENT_KINDS) { - expect( - ['create-ipc', 'session-notify', 'none'], - `${placementKind} must say where its host tab row comes from` - ).toContain(BROWSER_TAB_CREATE_PUBLICATION_RULES[placementKind].hostRowSource) - } - }) - it.each(BROWSER_TAB_CREATE_PLACEMENT_KINDS)( 'moves a user-created %s tab into the clicked split group when the placement marks focus', (placementKind) => { @@ -312,14 +295,6 @@ describe('browser tab-create activation defaults', () => { expect(browserTabCreateClientPageStartsActive(false)).toBe(false) expect(browserTabCreateClientPageStartsActive(undefined)).toBe(true) }) - - it('agrees with the focus rule for every explicit boolean shipped callers send', () => { - for (const activate of [true, false]) { - expect(browserTabCreateClientPageStartsActive(activate)).toBe( - browserTabCreateTakesFocus(activate) - ) - } - }) }) describe('browser tab-create focus resolution', () => { @@ -549,26 +524,9 @@ describe('browser tab-create placement census', () => { }) }) }) - - it('names every placement kind the command adapter can select', () => { - const source = browserCommandsSource() - const selected = new Set() - for (const [, placementKind] of source.matchAll( - /publishCreatedBrowserSessionTab\(this\.host, \{\s*placementKind: '([a-z]+)'/g - )) { - selected.add(placementKind as BrowserTabCreatePlacementKind) - } - expect([...selected].sort()).toEqual([...BROWSER_TAB_CREATE_PLACEMENT_KINDS].sort()) - }) }) describe('publishSwitchedBrowserSessionTab', () => { - it('declares a publication rule for every switch placement kind', () => { - expect(Object.keys(BROWSER_TAB_SWITCH_PUBLICATION_RULES).sort()).toEqual( - [...BROWSER_TAB_SWITCH_PLACEMENT_KINDS].sort() - ) - }) - // Why: the cases below read their expectation off this table, so the table needs a literal pin. it('pins the bookkeeping each switch placement owns', () => { expect(BROWSER_TAB_SWITCH_PUBLICATION_RULES).toEqual({ @@ -656,14 +614,6 @@ describe('browser tab-switch focus rule', () => { expect(browserTabSwitchTakesFocus(false)).toBe(false) expect(browserTabSwitchTakesFocus(undefined)).toBe(false) }) - - // Why: switch and create are the two ways a tab becomes the session's active tab; if their - // focus gates diverged, the same user intent would move the snapshot on one path only. - it('agrees with the create focus rule', () => { - for (const intent of [true, false, undefined]) { - expect(browserTabSwitchTakesFocus(intent)).toBe(browserTabCreateTakesFocus(intent)) - } - }) }) describe('browser tab-switch placement census', () => { @@ -683,17 +633,6 @@ describe('browser tab-switch placement census', () => { ) }) - it('names every switch placement kind the command adapter can select', () => { - const source = browserCommandsSource() - const selected = new Set() - for (const [, placementKind] of source.matchAll( - /publishSwitchedBrowserSessionTab\(this\.host, \{\s*placementKind: '([a-z]+)'/g - )) { - selected.add(placementKind as BrowserTabSwitchPlacementKind) - } - expect([...selected].sort()).toEqual([...BROWSER_TAB_SWITCH_PLACEMENT_KINDS].sort()) - }) - // Why: the rule-driven cases read their expectation off the table, so each branch also needs // its real bookkeeping observed through browserTabSwitch itself. describe('through browserTabSwitch', () => { diff --git a/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts b/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts index a9a3b4484a8..eb4acb14d5e 100644 --- a/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts +++ b/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts @@ -226,7 +226,7 @@ export class ClaudeAgentTeamsTmuxDispatcher { const pane = this.resolvePane(team, tmuxValue(parsed, '-t') ?? envPane) const text = tmuxSendKeysText(parsed.positional, parsed.flags.has('-l')) if (text) { - await api.sendTerminal(pane.handle, { text }) + await api.sendTerminal(pane.handle, { text }, { inputKind: 'driving' }) } return '' } diff --git a/src/main/runtime/claude-agent-teams-types.ts b/src/main/runtime/claude-agent-teams-types.ts index 10234f5226d..bddfad17d17 100644 --- a/src/main/runtime/claude-agent-teams-types.ts +++ b/src/main/runtime/claude-agent-teams-types.ts @@ -6,6 +6,7 @@ import type { RuntimeTerminalShow, RuntimeTerminalSplit } from '../../shared/runtime-types' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' export type AgentTeamsTmuxCompatRequest = { teamId: string @@ -43,7 +44,8 @@ export type AgentTeamsTerminalApi = { readTerminal(handle: string, opts?: { limit?: number }): Promise sendTerminal( handle: string, - action: { text?: string; enter?: boolean; interrupt?: boolean } + action: { text?: string; enter?: boolean; interrupt?: boolean }, + options: { inputKind: TerminalInputKind } ): Promise focusTerminal(handle: string): Promise closeTerminal(handle: string): Promise diff --git a/src/main/runtime/claude-structured-exit-mid-response.test.ts b/src/main/runtime/claude-structured-exit-mid-response.test.ts new file mode 100644 index 00000000000..fd30df7dad0 --- /dev/null +++ b/src/main/runtime/claude-structured-exit-mid-response.test.ts @@ -0,0 +1,72 @@ +// A Claude CLI that exits on its own after its start landed, with a message handed to it: the chat +// says Claude stopped, by name. Against the production runtime, adapter, record store and host, +// with only the CLI process scripted. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' +import { + createScriptedClaudeRuntime, + scriptedClaudeExitError +} from './structured-claude-scripted-runtime-test-support' + +const SESSION = 'claude-exit-mid-response' +const CALLER = { callerKey: 'client-1' } + +let claude = createScriptedClaudeRuntime([SESSION]) +let operations = 0 + +afterEach(async () => { + vi.restoreAllMocks() + await claude.dispose() + claude = createScriptedClaudeRuntime([SESSION]) +}) + +async function send(host: StructuredAgentSessionHost, text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}`, + expectedRuntimeFence: host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + expect(sent, JSON.stringify(sent)).toMatchObject({ ok: true }) +} + +describe('a started Claude CLI that exits while a response is in progress', () => { + it('says Claude stopped, and that the conversation can continue', async () => { + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + await send(host, 'hello') + await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) + + claude.child(SESSION).exit(scriptedClaudeExitError('claude stream-json exited (code 137)')) + await waitForStructuredAgentSessionRecovery() + + await vi.waitFor(async () => + expect( + (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' && item.body.failure + ? [{ text: item.body.text, kind: item.body.failure.kind }] + : [] + ) + ).toEqual([ + { + text: 'Claude stopped while this response was in progress. You can continue in this conversation.', + kind: 'providerExited' + } + ]) + ) + }) +}) diff --git a/src/main/runtime/claude-structured-failed-start-resume.test.ts b/src/main/runtime/claude-structured-failed-start-resume.test.ts index 32362cbafdc..cd4f44d59f8 100644 --- a/src/main/runtime/claude-structured-failed-start-resume.test.ts +++ b/src/main/runtime/claude-structured-failed-start-resume.test.ts @@ -55,7 +55,7 @@ describe('a Claude chat whose CLI exits the moment it is spawned', () => { // Before the spawn returns, the start time of a dead pid is unreadable; during that read, the // child is found closed afterwards. Both must answer with what the CLI said. it.each(['spawn', 'start-time-read'] as const)( - "refuses the create with the CLI's own diagnostic when it exits at %s", + "refuses the create in a sentence, not the CLI's diagnostic, when it exits at %s", async (at) => { const diagnostic = 'claude stream-json exited (code 1): claude: not signed in' claude.behave(SESSION, { exitsDuringSpawn: { diagnostic, at } }) @@ -65,7 +65,10 @@ describe('a Claude chat whose CLI exits the moment it is spawned', () => { expect(created).toMatchObject({ ok: false, - refusal: { message: expect.stringContaining('not signed in'), ownerVerdict: 'exited' } + refusal: { + message: 'Claude stopped before it finished starting. Send your message to try again.', + ownerVerdict: 'exited' + } }) } ) diff --git a/src/main/runtime/claude-structured-fake-connection-test-fixture.ts b/src/main/runtime/claude-structured-fake-connection-test-fixture.ts index b64f6980689..3404bcbf283 100644 --- a/src/main/runtime/claude-structured-fake-connection-test-fixture.ts +++ b/src/main/runtime/claude-structured-fake-connection-test-fixture.ts @@ -76,7 +76,7 @@ export function fakeClaude(providerSession: string) { connection.calls.push({ subtype: 'interrupt', params: {} }) return undefined }, - cancelAsyncMessage: async () => {}, + cancelAsyncMessage: async () => false, stopTask: async (taskId) => { connection.calls.push({ subtype: 'stop_task', params: { taskId } }) }, diff --git a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts new file mode 100644 index 00000000000..becdc9f2b38 --- /dev/null +++ b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts @@ -0,0 +1,108 @@ +// A Claude send whose write ended in doubt is recorded `unknown`, and a live `unknown` reads as +// work still owed. The CLI reports `session_state_changed idle` only once its queue has drained, +// so that report retires the doubt; a send whose dispatch is still `pending` is left alone. +// Against the production runtime, adapter, record store and host, with only the CLI scripted. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' +import { DISPATCH_DOUBT_WRITE_OUTCOME_UNKNOWN } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' +import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' + +const SESSION = 'claude-idle-release' +const PROVIDER_SESSION = claudeSessionIdForOrcaSession(SESSION) +const CALLER = { callerKey: 'client-1' } + +let claude = createScriptedClaudeRuntime([SESSION]) +let operations = 0 + +afterEach(async () => { + await claude.dispose() + claude = createScriptedClaudeRuntime([SESSION]) +}) + +function fence(host: StructuredAgentSessionHost): number { + return host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0 +} + +async function send(host: StructuredAgentSessionHost, text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}`, + expectedRuntimeFence: fence(host), + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + expect(sent, JSON.stringify(sent)).toMatchObject({ ok: true }) + return sent.ok ? sent.value.clientMessageId : '' +} + +async function submission(host: StructuredAgentSessionHost, clientMessageId: string) { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) +} + +function sessionState(state: 'running' | 'idle'): Record { + return { + type: 'system', + subtype: 'session_state_changed', + state, + uuid: `state-${state}`, + session_id: PROVIDER_SESSION + } +} + +describe('a live Claude chat whose CLI reports idle', () => { + it('releases a send whose write ended in doubt, and leaves a pending one alone', async () => { + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + const child = claude.child(SESSION) + // Handed to the SDK, then the write's outcome was lost. + child.connection.send = async (_message, beforeDispatch) => { + await beforeDispatch?.() + throw new Error('stdin write stalled') + } + const doubted = await send(host, 'first') + await vi.waitFor(async () => + expect(await submission(host, doubted)).toMatchObject({ dispatchState: 'unknown' }) + ) + // Written, never echoed: its dispatch has returned, but nothing has settled it. + child.connection.send = async (_message, beforeDispatch) => { + await beforeDispatch?.() + } + const pending = await send(host, 'second') + await vi.waitFor(async () => + expect(await submission(host, pending)).toMatchObject({ dispatchState: 'pending' }) + ) + + child.handlers.onMessage?.(sessionState('running')) + await new Promise((resolve) => setTimeout(resolve, 50)) + // POSITIVE CONTROL: only idle releases it. + expect((await submission(host, doubted))?.recovered).toBeUndefined() + + child.handlers.onMessage?.(sessionState('idle')) + + await vi.waitFor(async () => + expect(await submission(host, doubted)).toMatchObject({ + dispatchState: 'unknown', + recovered: true, + // The write's own doubt is the sharper fact, so it survives the release. + reason: `${DISPATCH_DOUBT_WRITE_OUTCOME_UNKNOWN}: stdin write stalled` + }) + ) + expect(await submission(host, pending)).toMatchObject({ dispatchState: 'pending' }) + expect((await submission(host, pending))?.recovered).toBeUndefined() + }) +}) diff --git a/src/main/runtime/claude-structured-resumed-start-failure.test.ts b/src/main/runtime/claude-structured-resumed-start-failure.test.ts index 8fcef7e4a48..7a0c66e1b85 100644 --- a/src/main/runtime/claude-structured-resumed-start-failure.test.ts +++ b/src/main/runtime/claude-structured-resumed-start-failure.test.ts @@ -7,11 +7,15 @@ import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-sessio import type { AgentSessionSubscribeEvent } from '../../shared/agent-session-wire' import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' -import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' +import { + createScriptedClaudeRuntime, + scriptedClaudeExitError +} from './structured-claude-scripted-runtime-test-support' const SESSION = 'claude-resumed-start' const CALLER = { callerKey: 'client-1' } const DIAGNOSTIC = 'claude stream-json exited (code 1): claude: not signed in' +const STARTUP_TEXT = 'Claude stopped before it finished starting. Send your message to try again.' let claude = createScriptedClaudeRuntime([SESSION]) @@ -38,11 +42,11 @@ describe('a reopened Claude chat whose CLI dies before initialize', () => { await waitForStructuredAgentSessionRecovery() await host.close(SESSION) - // The user reopens it; this time the CLI never answers, then dies, and its tree is unprovable. + // The user reopens it and sends; this time the CLI never answers, then dies, and its tree is + // unprovable. Opening starts nothing: the send does. claude.behave(SESSION, { initHangs: true, closeUnproven: true }) - await host.hold(SESSION, 'surface-1') const events: AgentSessionSubscribeEvent[] = [] - host.subscribe({ id: 'sub-1', sessionId: SESSION, emit: (event) => events.push(event) }) + await host.subscribe({ id: 'sub-1', sessionId: SESSION, emit: (event) => events.push(event) }) const body = hostTestMessage('hello') const fence = host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0 const sent = await host.send(CALLER, { @@ -59,21 +63,22 @@ describe('a reopened Claude chat whose CLI dies before initialize', () => { body }) expect(sent).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) + // Accepted first; the delivery loop starts the second child after. + await vi.waitFor(() => expect(claude.children(SESSION)).toHaveLength(2)) - claude.child(SESSION).exit(new Error(DIAGNOSTIC)) + claude.child(SESSION).exit(scriptedClaudeExitError(DIAGNOSTIC)) await waitForStructuredAgentSessionRecovery() - await vi.waitFor(() => - expect(statusTexts(events)).toContainEqual( - expect.stringMatching(/stopped before it finished starting: .*not signed in/) - ) - ) + await vi.waitFor(() => expect(statusTexts(events)).toContainEqual(STARTUP_TEXT)) // Never written, so it did not happen: refused, not left in doubt. - const submission = host - .journalSnapshot(SESSION) - .submissions.find( - (entry) => entry.clientMessageId === (sent.ok && sent.value.clientMessageId) - ) - expect(submission).toMatchObject({ dispatchState: 'rejected' }) + const submission = (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === (sent.ok && sent.value.clientMessageId) + ) + // The stderr the exit carried is beside the sentence, as a log detail, and never in it. + expect(submission).toMatchObject({ + dispatchState: 'rejected', + reason: STARTUP_TEXT, + rejection: { kind: 'providerStartFailed', detail: { text: DIAGNOSTIC, audience: 'log' } } + }) }) }) diff --git a/src/main/runtime/claude-structured-send-held-for-startup.test.ts b/src/main/runtime/claude-structured-send-held-for-startup.test.ts index 19e05811055..efb8d3cde16 100644 --- a/src/main/runtime/claude-structured-send-held-for-startup.test.ts +++ b/src/main/runtime/claude-structured-send-held-for-startup.test.ts @@ -1,8 +1,8 @@ // A Claude chat is published the moment its child spawns, before the CLI has answered initialize. -// A send in that window — into a fresh start, or into the restart a send itself asked for after -// a start that failed — is admitted and held until the child proves its start. When the CLI dies -// first, the held message is rejected with the CLI's own diagnostic, the chat shows the cause -// once, and nothing is left as a delivery nobody can confirm. Against the production runtime, +// A send in that window — into a fresh start, or into the restart the delivery loop makes for a +// send after a start that failed — is accepted and stays queued until the child proves its start. +// When the CLI dies first, the queued message is rejected with the CLI's own diagnostic, the chat +// shows the cause once, and nothing is left as a delivery nobody can confirm. Against the production runtime, // adapter, record store and host, with only the CLI process scripted. import { afterEach, describe, expect, it, vi } from 'vitest' @@ -10,11 +10,15 @@ import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-sessio import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' -import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' +import { + createScriptedClaudeRuntime, + scriptedClaudeExitError +} from './structured-claude-scripted-runtime-test-support' const SESSION = 'claude-send-held' const CALLER = { callerKey: 'client-1' } const DIAGNOSTIC = 'claude stream-json exited (code 1): claude: not signed in (rig)' +const STARTUP_TEXT = 'Claude stopped before it finished starting. Send your message to try again.' let claude = createScriptedClaudeRuntime([SESSION]) let operations = 0 @@ -39,7 +43,7 @@ async function send(host: StructuredAgentSessionHost, text: string): Promise (item.body.kind === 'status' ? [item.body.text] : [])) +async function statusRows(host: StructuredAgentSessionHost): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [item.body.text] : [] + ) } -function submission(host: StructuredAgentSessionHost, clientMessageId: string) { - return host - .journalSnapshot(SESSION) - .submissions.find((entry) => entry.clientMessageId === clientMessageId) +async function submission(host: StructuredAgentSessionHost, clientMessageId: string) { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) } /** The CLI keeps dying at startup: the latest child exits with the diagnostic once it exists. */ async function failLatestStart(host: StructuredAgentSessionHost, count: number): Promise { await vi.waitFor(() => expect(claude.children(SESSION)).toHaveLength(count)) - claude.child(SESSION).exit(new Error(DIAGNOSTIC)) + claude.child(SESSION).exit(scriptedClaudeExitError(DIAGNOSTIC)) await waitForStructuredAgentSessionRecovery() await vi.waitFor(() => expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') @@ -82,27 +86,28 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { ok: true }) await failLatestStart(host, 1) - expect(statusRows(host)).toEqual([expect.stringContaining('not signed in')]) + expect(await statusRows(host)).toEqual([STARTUP_TEXT]) const releasedFence = fence(host) - // The send asks for the child back and is held for its start; the CLI dies again first. + // The delivery loop asks for the child back and the message waits for its start; the CLI + // dies again first. const held = await send(host, 'hello?') - expect(claude.children(SESSION)).toHaveLength(2) - expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('live') + await vi.waitFor(() => expect(claude.children(SESSION)).toHaveLength(2)) + await vi.waitFor(() => + expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('live') + ) await failLatestStart(host, 2) // Rejected with the cause, not left in doubt; one row for this attempt names it. - await vi.waitFor(() => - expect(submission(host, held)).toMatchObject({ + await vi.waitFor(async () => + expect(await submission(host, held)).toMatchObject({ dispatchState: 'rejected', // Worded for the user: the red line under the composer shows it as it stands. - reason: `The provider stopped before it finished starting: ${DIAGNOSTIC}.` + reason: STARTUP_TEXT, + rejection: { kind: 'providerStartFailed' } }) ) - expect(statusRows(host)).toEqual([ - expect.stringContaining('not signed in'), - expect.stringMatching(/stopped before it finished starting: .*not signed in \(rig\)/) - ]) + expect(await statusRows(host)).toEqual([STARTUP_TEXT, STARTUP_TEXT]) // The restart moved the fence twice: its acquisition, and the exit that released it. expect(fence(host)).toBe(releasedFence + 2) expect(claude.children(SESSION)).toHaveLength(2) @@ -111,18 +116,18 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { // The user signs in and retries: one restart, proven, written to the CLI. claude.behave(SESSION, {}) await send(host, 'hello again') - expect(claude.children(SESSION)).toHaveLength(3) - expect(fence(host)).toBe(releasedFence + 3) + await vi.waitFor(() => expect(claude.children(SESSION)).toHaveLength(3)) + await vi.waitFor(() => expect(fence(host)).toBe(releasedFence + 3)) await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) await vi.waitFor(() => expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('live') ) - expect(statusRows(host)).toHaveLength(2) + expect(await statusRows(host)).toHaveLength(2) }) }) describe('a send while the first Claude start is still answering initialize', () => { - it('is held, and written once the CLI proves its start', async () => { + it('is queued, and written once the CLI proves its start', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() await host.attach(CALLER, claude.attachParams(SESSION, null)) @@ -130,12 +135,12 @@ describe('a send while the first Claude start is still answering initialize', () await send(host, 'hello') expect(claude.child(SESSION).calls).not.toContain('send') - // The CLI answers: startup lands and the held message is written to the proven child. + // The CLI answers: startup lands and the queued message is written to the proven child. claude.child(SESSION).answerInit() await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) expect(claude.children(SESSION)).toHaveLength(1) - expect(statusRows(host)).toEqual([]) + expect(await statusRows(host)).toEqual([]) }) it('is rejected with the diagnostic when the CLI dies first, and restarts nothing', async () => { @@ -147,16 +152,15 @@ describe('a send while the first Claude start is still answering initialize', () const held = await send(host, 'hello') await failLatestStart(host, 1) - await vi.waitFor(() => - expect(submission(host, held)).toMatchObject({ + await vi.waitFor(async () => + expect(await submission(host, held)).toMatchObject({ dispatchState: 'rejected', // Worded for the user: the red line under the composer shows it as it stands. - reason: `The provider stopped before it finished starting: ${DIAGNOSTIC}.` + reason: STARTUP_TEXT, + rejection: { kind: 'providerStartFailed' } }) ) - expect(statusRows(host)).toEqual([ - expect.stringMatching(/stopped before it finished starting: .*not signed in \(rig\)/) - ]) + expect(await statusRows(host)).toEqual([STARTUP_TEXT]) expect(fence(host)).toBe(startedFence + 1) expect(claude.children(SESSION)).toHaveLength(1) expect(claude.child(SESSION).calls).not.toContain('send') diff --git a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts index 0d4b4909637..c5b21f9027f 100644 --- a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts +++ b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts @@ -1,10 +1,9 @@ -// A send restarts a chat's Claude child and is admitted against it while it is still starting. -// When that child dies before the send's dispatch reaches the adapter, the adapter has no session -// to hold the message for, so the dispatch throws. A child that never proved its start accepted -// nothing — input is only written after initialize — so the send settles `rejected` with the -// child's own diagnostic, never as a delivery nobody can confirm, and a client that was -// subscribed the whole time receives the failure row and the rejected submission over the wire. -// Against the production runtime, adapter, record store and host, with only the CLI scripted. +// A send is accepted into a chat whose Claude child is gone, and its delivery restarts the child. +// When that child dies before it proves its start, the message was never handed to it — delivery +// waits for the start — so the send settles `rejected` with the child's own diagnostic, never as a +// delivery nobody can confirm, and a client that was subscribed the whole time receives the +// failure row and the rejected submission over the wire. Against the production runtime, adapter, +// record store and host, with only the CLI scripted. import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' @@ -12,11 +11,20 @@ import type { AgentSessionSubscribeEvent } from '../../shared/agent-session-wire import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' -import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' +import { + createScriptedClaudeRuntime, + scriptedClaudeExitError +} from './structured-claude-scripted-runtime-test-support' const SESSION = 'claude-send-restart-dies-first' const CALLER = { callerKey: 'client-1' } const DIAGNOSTIC = 'claude stream-json exited (code 1): claude: not signed in (rig)' +const STARTUP_TEXT = 'Claude stopped before it finished starting. Send your message to try again.' + +/** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ +function eventually(assertion: () => unknown): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} let claude = createScriptedClaudeRuntime([SESSION]) let operations = 0 @@ -67,41 +75,31 @@ async function send(host: StructuredAgentSessionHost, text: string): Promise (item.body.kind === 'status' ? [item.body.text] : [])) -} - -function submission(host: StructuredAgentSessionHost, clientMessageId: string) { - return host - .journalSnapshot(SESSION) - .submissions.find((entry) => entry.clientMessageId === clientMessageId) -} - -async function failLatestStart(host: StructuredAgentSessionHost, count: number): Promise { - await vi.waitFor(() => expect(claude.children(SESSION)).toHaveLength(count)) - claude.child(SESSION).exit(new Error(DIAGNOSTIC)) - await waitForStructuredAgentSessionRecovery() - await vi.waitFor(() => - expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') +async function statusRows(host: StructuredAgentSessionHost): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [item.body.text] : [] ) } -/** The restarted child dies the instant the send's dispatch reaches the adapter. */ -function killChildAtDispatch(host: StructuredAgentSessionHost): void { - const adapter = host.deps.adapter - const dispatch = adapter.dispatch.bind(adapter) - vi.spyOn(adapter, 'dispatch').mockImplementationOnce((input) => { - claude.child(SESSION).exit(new Error(DIAGNOSTIC)) - return dispatch(input) - }) +async function submission(host: StructuredAgentSessionHost, clientMessageId: string) { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) +} + +async function failLatestStart(host: StructuredAgentSessionHost, count: number): Promise { + await eventually(() => expect(claude.children(SESSION)).toHaveLength(count)) + claude.child(SESSION).exit(scriptedClaudeExitError(DIAGNOSTIC)) + await waitForStructuredAgentSessionRecovery() + await eventually(() => + expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + ) } /** Everything a subscriber received, flattened to the rows and submissions it was shown. */ @@ -129,7 +127,7 @@ function received(events: AgentSessionSubscribeEvent[]) { return { statusTexts, submissions, fences } } -describe('a send whose restarted Claude child dies before the dispatch reaches the adapter', () => { +describe('a send whose restarted Claude child dies before it proves its start', () => { it('settles rejected with the diagnostic, keeps one failure row, and a Retry is one new attempt', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() @@ -139,28 +137,21 @@ describe('a send whose restarted Claude child dies before the dispatch reaches t await failLatestStart(host, 1) const releasedFence = fence(host) - killChildAtDispatch(host) const sent = await send(host, 'hello?') - // The send's own answer already says it was not delivered; it does not wait for the exit. - expect(answered.get(sent)).toBe('rejected') - expect(claude.children(SESSION)).toHaveLength(2) - await waitForStructuredAgentSessionRecovery() - await vi.waitFor(() => - expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - ) + // Accepted: the answer comes before the restart it needs. + expect(answered.get(sent)).toBe('pending') + await failLatestStart(host, 2) // Provably not delivered, with the cause; not "unconfirmed". - await vi.waitFor(() => - expect(submission(host, sent)).toMatchObject({ + await eventually(async () => + expect(await submission(host, sent)).toMatchObject({ dispatchState: 'rejected', // Worded for the user: the red line under the composer shows it as it stands. - reason: `The provider stopped before it finished starting: ${DIAGNOSTIC}.` + reason: STARTUP_TEXT, + rejection: { kind: 'providerStartFailed' } }) ) - expect(statusRows(host)).toEqual([ - expect.stringContaining('not signed in'), - expect.stringMatching(/stopped before it finished starting: .*not signed in \(rig\)/) - ]) + expect(await statusRows(host)).toEqual([STARTUP_TEXT, STARTUP_TEXT]) expect(fence(host)).toBe(releasedFence + 2) expect(claude.children(SESSION)).toHaveLength(2) expect(claude.child(SESSION).calls).not.toContain('send') @@ -168,40 +159,14 @@ describe('a send whose restarted Claude child dies before the dispatch reaches t // Retry under a new id: one restart, and once the CLI is healthy the message is written. claude.behave(SESSION, {}) await send(host, 'hello again') - expect(claude.children(SESSION)).toHaveLength(3) - await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) + await eventually(() => expect(claude.children(SESSION)).toHaveLength(3)) + await eventually(() => expect(claude.child(SESSION).calls).toContain('send')) expect(claude.child(SESSION).calls.filter((call) => call === 'send')).toHaveLength(1) - expect(statusRows(host)).toHaveLength(2) - }) - - it('names the diagnostic even when the exit was fully processed before the dispatch arrived', async () => { - claude.behave(SESSION, { initHangs: true }) - const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ - ok: true - }) - await failLatestStart(host, 1) - const adapter = host.deps.adapter - const dispatch = adapter.dispatch.bind(adapter) - vi.spyOn(adapter, 'dispatch').mockImplementationOnce(async (input) => { - claude.child(SESSION).exit(new Error(DIAGNOSTIC)) - // The adapter settles and publishes the exit; the host's own settlement waits behind this send. - await new Promise((resolve) => setTimeout(resolve, 300)) - return dispatch(input) - }) - - const sent = await send(host, 'hello?') - expect(answered.get(sent)).toBe('rejected') - await waitForStructuredAgentSessionRecovery() - expect(submission(host, sent)).toMatchObject({ - dispatchState: 'rejected', - reason: `The provider stopped before it finished starting: ${DIAGNOSTIC}.` - }) - expect(statusRows(host)).toHaveLength(2) + expect(await statusRows(host)).toHaveLength(2) }) // A restart refused because its child died before it was handed over leaves one row, from the - // send, in the words any failed start uses. + // delivery, in the words any failed start uses, and rejects the message with them. it.each(['spawn', 'start-time-read'] as const)( 'leaves one row for a restart whose child exits at %s', async (at) => { @@ -213,16 +178,17 @@ describe('a send whose restarted Claude child dies before the dispatch reaches t await failLatestStart(host, 1) claude.behave(SESSION, { exitsDuringSpawn: { diagnostic: DIAGNOSTIC, at } }) - await expect(attempt(host, 'hello?')).resolves.toMatchObject({ - ok: false, - refusal: { code: 'agent_session_owner_restart_failed' } - }) + const sent = await send(host, 'hello?') + await eventually(async () => + expect(await submission(host, sent)).toMatchObject({ + dispatchState: 'rejected', + reason: STARTUP_TEXT, + rejection: { kind: 'providerStartFailed' } + }) + ) await waitForStructuredAgentSessionRecovery() - expect(statusRows(host)).toEqual([ - `The provider stopped before it finished starting: ${DIAGNOSTIC}.`, - `The provider stopped before it finished starting: ${DIAGNOSTIC}.` - ]) + expect(await statusRows(host)).toEqual([STARTUP_TEXT, STARTUP_TEXT]) } ) @@ -233,7 +199,7 @@ describe('a send whose restarted Claude child dies before the dispatch reaches t ok: true }) const events: AgentSessionSubscribeEvent[] = [] - const unsubscribe = host.subscribe({ + const unsubscribe = await host.subscribe({ id: 'pane', sessionId: SESSION, emit: (event) => { @@ -242,19 +208,13 @@ describe('a send whose restarted Claude child dies before the dispatch reaches t }) try { await failLatestStart(host, 1) - killChildAtDispatch(host) const sent = await send(host, 'hello?') - await waitForStructuredAgentSessionRecovery() - await vi.waitFor(() => - expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - ) + await failLatestStart(host, 2) - await vi.waitFor(() => { + await eventually(() => { const seen = received(events) expect(seen.submissions.get(sent)).toBe('rejected') - expect(seen.statusTexts).toContainEqual( - expect.stringMatching(/stopped before it finished starting: .*not signed in \(rig\)/) - ) + expect(seen.statusTexts).toContainEqual(STARTUP_TEXT) // The subscriber ended up on the fence the exit published, not the one the restart did. expect(seen.fences.at(-1)).toBe(fence(host)) }) @@ -265,8 +225,8 @@ describe('a send whose restarted Claude child dies before the dispatch reaches t }) describe('a chat whose Claude CLI keeps failing to start, seen by a subscriber open throughout', () => { - const STARTUP_FAILURE = - 'The provider stopped before it finished starting: claude stream-json exited (code 1): claude: not signed in (rig).' + // The stderr rides beside the sentence as a log detail; the sentence is the same every time. + const STARTUP_FAILURE = STARTUP_TEXT /** Status rows a subscriber has been shown, one per row whatever frame carried it. */ function shownRows(events: AgentSessionSubscribeEvent[]): Map { @@ -285,14 +245,14 @@ describe('a chat whose Claude CLI keeps failing to start, seen by a subscriber o return rows } - it('shows one row naming the cause per failed attempt, admitted or refused, and none once the CLI is fixed', async () => { + it('shows one row naming the cause per failed attempt, however the start died, and none once the CLI is fixed', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() const events: AgentSessionSubscribeEvent[] = [] await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ ok: true }) - const unsubscribe = host.subscribe({ + const unsubscribe = await host.subscribe({ id: 'pane', sessionId: SESSION, emit: (event) => { @@ -301,37 +261,38 @@ describe('a chat whose Claude CLI keeps failing to start, seen by a subscriber o }) try { await failLatestStart(host, 1) - await vi.waitFor(() => expect([...shownRows(events).values()]).toEqual([STARTUP_FAILURE])) + await eventually(() => expect([...shownRows(events).values()]).toEqual([STARTUP_FAILURE])) - // Send: admitted against the restarted child, which dies before starting. - killChildAtDispatch(host) - const sent = await attempt(host, 'hello?') - await waitForStructuredAgentSessionRecovery() - expect(sent).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'rejected', reason: STARTUP_FAILURE } } - }) - await vi.waitFor(() => + // Send: accepted, and its delivery restarts the child, which dies before starting. + const sent = await send(host, 'hello?') + await failLatestStart(host, 2) + await eventually(async () => + expect(await submission(host, sent)).toMatchObject({ + dispatchState: 'rejected', + reason: STARTUP_FAILURE + }) + ) + await eventually(() => expect([...shownRows(events).values()]).toEqual([STARTUP_FAILURE, STARTUP_FAILURE]) ) - // Retry while still broken: this restart dies before its child is handed over, so the send - // is refused before admission. Still one row, saying the same thing. + // Retry while still broken: this restart dies before its child is handed over, so the + // delivery's start is refused. Still one row, saying the same thing, on the rejected message. claude.behave(SESSION, { exitsDuringSpawn: { diagnostic: 'claude stream-json exited (code 1): claude: not signed in (rig)', at: 'start-time-read' } }) - await expect(attempt(host, 'hello?')).resolves.toMatchObject({ - ok: false, - refusal: { - code: 'agent_session_owner_restart_failed', - message: expect.stringMatching(/couldn't restart: .*not signed in \(rig\)/) - } - }) + const retried = await send(host, 'hello?') + await eventually(async () => + expect(await submission(host, retried)).toMatchObject({ + dispatchState: 'rejected', + reason: STARTUP_FAILURE + }) + ) await waitForStructuredAgentSessionRecovery() - await vi.waitFor(() => + await eventually(() => expect([...shownRows(events).values()]).toEqual([ STARTUP_FAILURE, STARTUP_FAILURE, @@ -342,7 +303,7 @@ describe('a chat whose Claude CLI keeps failing to start, seen by a subscriber o // The CLI is fixed: Retry delivers and adds no row. claude.behave(SESSION, {}) await expect(attempt(host, 'hello?')).resolves.toMatchObject({ ok: true }) - await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) + await eventually(() => expect(claude.child(SESSION).calls).toContain('send')) expect(shownRows(events).size).toBe(3) } finally { unsubscribe() diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 480720da93b..82db5d6c542 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -349,6 +349,7 @@ describe('a structured Claude session over agentSession.*', () => { }) it('leaves unlisted shell exports out when inheritance is off', async () => { + vi.stubEnv('CODEX_LB_API_KEY', undefined) shellEnv = { ...shellEnv, CODEX_LB_API_KEY: 'shell-exported', LISTED_ONLY: 'yes' } shellEnvironmentPolicy = { inheritAll: false, names: ['LISTED_ONLY'] } @@ -363,9 +364,28 @@ describe('a structured Claude session over agentSession.*', () => { claudeAuthPolicy = { stripAuthEnv: true } // The default overlay carries ANTHROPIC_AUTH_TOKEN, which the terminal path // refuses at spawn-env.ts:25 rather than letting it beat the pinned account. - const refused = await call('agentSession.create', createIntentParams()) + const params = createIntentParams() + const sentence = + 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.' + const refused = await call('agentSession.create', params) - expect(JSON.stringify(refused)).toContain('explicit Anthropic auth environment') + // The thrown answer keeps its wire code; only its words are the ones its replay reads. + expect(refused).toMatchObject({ + ok: false, + error: { code: 'runtime_error', message: sentence } + }) + // Its replay reads the same sentence, beside the situation it names. + expect(await call('agentSession.create', params)).toMatchObject({ + ok: true, + result: { + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'managedAccountEnvOverride' }, + message: sentence + } + } + }) // Refused before spawn: no provider child was ever opened. expect(claude.connections).toHaveLength(0) }) @@ -378,51 +398,56 @@ describe('a structured Claude session over agentSession.*', () => { await waitForStructuredAgentSessionRecovery() const guidance = itemsOf(await subscribe()).find((item) => item.body?.kind === 'status') + // The adapter typed the refusal, so the row names the situation rather than quoting Orca. expect(guidance?.body).toMatchObject({ kind: 'status', - text: expect.stringMatching( - /stopped before it finished starting: .*not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s - ) + text: 'Claude is not signed in for the selected account. Sign in, then send your message again.', + failure: { kind: 'notSignedIn' } }) expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null }) // A failed start is not auto-resumed into the same failure. expect(claude.connections).toHaveLength(1) }) - it('releases a session whose CLI self-exited during create, with its diagnostic intact', async () => { - claude.setSelfExit({ - message: 'claude stream-json exited (code 1): claude: not signed in', - // The root's death is first-hand; its descendants were never snapshottable. - exitVerdict: { root: 'exited', tree: 'unverifiable' } - }) + // The root's death is first-hand. Its descendants were never snapshottable, or one was seen + // alive; either way the lease follows the root, so the reservation goes with it. + it.each(['unverifiable', 'live'] as const)( + 'releases a session whose CLI self-exited during create with its tree %s, refused in a sentence', + async (tree) => { + claude.setSelfExit({ + message: 'claude stream-json exited (code 1): claude: not signed in', + exitVerdict: { root: 'exited', tree } + }) - const failed = await call('agentSession.create', createIntentParams()) + const failed = await call('agentSession.create', createIntentParams()) - // Answered once, as the refusal a replay of this operation gives, never thrown first. - expect(failed).toMatchObject({ - ok: true, - result: { - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: expect.stringContaining('claude: not signed in'), - ownerVerdict: 'exited' + // Answered once, as the refusal a replay of this operation gives, never thrown first. + expect(failed).toMatchObject({ + ok: true, + result: { + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + // The CLI's stderr is log text; the person reads what the chat's start failure says. + message: 'Claude stopped before it finished starting. Send your message to try again.', + ownerVerdict: 'exited' + } } - } - }) - const lease = leaseOf(SESSION) - // Latching here would refuse every later attach with agent_session_ownership_unknown, - // wedging a user who only needs to sign in. - expect(lease).toMatchObject({ claimStatus: 'released', handoffStage: null }) - expect(lease.deathEvidence).toMatchObject({ - kind: 'exit-observed', - detail: 'the provider process exited; its descendants were not verifiable' - }) + }) + const lease = leaseOf(SESSION) + // Latching here would refuse every later attach with agent_session_ownership_unknown, + // wedging a user who only needs to sign in. + expect(lease).toMatchObject({ claimStatus: 'released', handoffStage: null }) + expect(lease.deathEvidence).toMatchObject({ + kind: 'exit-observed', + detail: 'the provider process exited; its descendants were not proven gone' + }) - claude.setSelfExit(null) - // Signing in and reopening the chat works: the reservation was not latched. - await ok<{ fence: number }>('agentSession.ensure', ensureParams(lease.runtimeFence)) - }) + claude.setSelfExit(null) + // Signing in and reopening the chat works: the reservation was not latched. + await ok<{ fence: number }>('agentSession.ensure', ensureParams(lease.runtimeFence)) + } + ) it('answers a create whose whole CLI tree exited as exited on the first call', async () => { claude.setSelfExit({ @@ -439,7 +464,7 @@ describe('a structured Claude session over agentSession.*', () => { ok: false, refusal: { code: 'agent_session_operation_invalid', - message: expect.stringContaining('claude: not signed in'), + message: 'Claude stopped before it finished starting. Send your message to try again.', ownerVerdict: 'exited' } } @@ -448,42 +473,52 @@ describe('a structured Claude session over agentSession.*', () => { claude.setSelfExit(null) }) - it('keeps a session reserved when a descendant of the failed start was seen alive', async () => { + it('releases a failed start that recorded no owner without claiming it exited', async () => { claude.setSelfExit({ message: 'claude stream-json exited (code 1): claude: not signed in', - exitVerdict: { root: 'exited', tree: 'live' } + // The root was never seen to exit, so nothing proves this start's process gone. + exitVerdict: { root: 'live', tree: 'unverifiable' } }) await call('agentSession.create', createIntentParams()) - - // A live descendant still holds the provider session: releasing would hand a - // second writer to it. - expect(leaseOf(SESSION)).toMatchObject({ - claimStatus: 'reserved', - handoffStage: 'manual-recovery' - }) claude.setSelfExit(null) + + // The adapter closed the stdio of what it spawned, and no owner was recorded to stop. The next + // start goes ahead; with no death evidence nothing reads the failed start as exited. + expect(leaseOf(SESSION)).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + deathEvidence: null + }) }) - it('reopens a chat whose stop saw the Claude root exit but not its descendants', async () => { - await ok('agentSession.create', createIntentParams()) - const first = claude.live() - first.exitVerdict = { root: 'exited', tree: 'unverifiable' } - first.close = async () => { - first.closed = true - return false + it.each(['unverifiable', 'live'] as const)( + 'restarts a chat whose stop saw the Claude root exit with its tree %s', + async (tree) => { + await ok('agentSession.create', createIntentParams()) + const first = claude.live() + first.exitVerdict = { root: 'exited', tree } + first.close = async () => { + first.closed = true + return false + } + const host = getStructuredAgentSessionHost() + // The lease follows the root, so the host lets go. + await host?.close(SESSION) + expect(host?.hasSession(SESSION)).toBe(false) + + // The user comes back and sends: that send is what starts Claude again. + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'back' }] } + await ok('agentSession.send', { + envelope: envelope('agentSession.send', { body }, leaseOf(SESSION).runtimeFence), + body + }) + + await vi.waitFor(() => expect(claude.connections).toHaveLength(2)) + await vi.waitFor(() => expect(claude.live().sent).toHaveLength(1)) + expect(host?.hasSession(SESSION)).toBe(true) } - const host = getStructuredAgentSessionHost() - // The idle release clock's eviction: the lease follows the root, so the host lets go. - await host?.close(SESSION) - expect(host?.hasSession(SESSION)).toBe(false) - - // What the chat surface's `agentSession.hold` does when the user comes back to it. - await host?.hold(SESSION, 'desktop-chat:reopen') - - expect(claude.connections).toHaveLength(2) - expect(host?.hasSession(SESSION)).toBe(true) - }) + ) it('routes a published Claude first-hand exit through fenced host reconciliation', async () => { await ok<{ fence: number }>('agentSession.create', createIntentParams()) @@ -497,46 +532,49 @@ describe('a structured Claude session over agentSession.*', () => { expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null }) }) - it('restarts an open chat after a Claude crash whose descendants could not be verified', async () => { - const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) - // The open chat surface is what asks the host to bring Claude back. - await getStructuredAgentSessionHost()?.hold(SESSION, 'desktop-chat:open') - const connection = claude.live() - connection.exitVerdict = { root: 'exited', tree: 'unverifiable' } - connection.close = async () => { - connection.closed = true - return false - } - // Claude takes the message but crashes before echoing it. - connection.send = async (message) => { - connection.sent.push(message) - } - const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'in flight' }] } - const inFlight = ok<{ submission: { dispatchState: string; reason: string | null } }>( - 'agentSession.send', - { envelope: envelope('agentSession.send', { body }, created.fence), body } - ) - await vi.waitFor(() => expect(connection.sent).toHaveLength(1)) - connection.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed')) + // A descendant seen alive is one that survived the close ladder, such as an MCP server that + // ignores SIGTERM; it no longer holds the chat. + it.each(['unverifiable', 'live'] as const)( + 'restarts a chat on its next send after a Claude crash whose tree was %s', + async (tree) => { + const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) + const connection = claude.live() + connection.exitVerdict = { root: 'exited', tree } + connection.close = async () => { + connection.closed = true + return false + } + // Claude takes the message but crashes before echoing it. + connection.send = async (message) => { + connection.sent.push(message) + } + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'in flight' }] } + const inFlight = ok<{ submission: { dispatchState: string; reason: string | null } }>( + 'agentSession.send', + { envelope: envelope('agentSession.send', { body }, created.fence), body } + ) + await vi.waitFor(() => expect(connection.sent).toHaveLength(1)) + connection.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed')) - expect((await inFlight).submission).toMatchObject({ - dispatchState: 'unknown', - reason: 'provider_exited_before_acknowledgement' - }) - // Held back, sends failed with the crash until the idle clock stopped the chat. - await waitForStructuredAgentSessionRecovery() - expect(claude.connections).toHaveLength(2) - expect(claude.live().launch.options).toMatchObject({ resume: PROVIDER_SESSION }) - const lease = leaseOf(SESSION) - expect(lease).toMatchObject({ claimStatus: 'live', handoffStage: null }) - const next = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'after' }] } - const sent = await ok<{ submission: { dispatchState: string } }>('agentSession.send', { - envelope: envelope('agentSession.send', { body: next }, lease.runtimeFence), - body: next - }) - expect(sent.submission.dispatchState).toBe('accepted') - expect(claude.live().sent).toHaveLength(1) - }) + expect((await inFlight).submission).toMatchObject({ + dispatchState: 'unknown', + reason: 'provider_exited_before_acknowledgement' + }) + // The crash releases the lease; nothing restarts Claude until the chat has work for it. + await waitForStructuredAgentSessionRecovery() + expect(claude.connections).toHaveLength(1) + expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null }) + const next = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'after' }] } + await ok('agentSession.send', { + envelope: envelope('agentSession.send', { body: next }, leaseOf(SESSION).runtimeFence), + body: next + }) + await vi.waitFor(() => expect(claude.connections).toHaveLength(2)) + expect(claude.live().launch.options).toMatchObject({ resume: PROVIDER_SESSION }) + await vi.waitFor(() => expect(claude.live().sent).toHaveLength(1)) + expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'live', handoffStage: null }) + } + ) it('creates, sends, streams, approves, interrupts, and resumes from the chain head', async () => { shellEnv = { ...shellEnv, ANTHROPIC_API_KEY: 'sk-ant-SHELL-LEAK' } diff --git a/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts b/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts new file mode 100644 index 00000000000..863cbaeaebf --- /dev/null +++ b/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts @@ -0,0 +1,139 @@ +// A Claude start can fail on Orca's side while the CLI is still running: a saved option it can't +// restore, or an init frame naming another session. Orca ends that child itself, so the chat must +// not say Claude stopped on its own; only an exit Orca saw says that. Against the production +// runtime, adapter, record store and host, with only the CLI process scripted. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' +import { + createScriptedClaudeRuntime, + scriptedClaudeExitError +} from './structured-claude-scripted-runtime-test-support' + +const SESSION = 'claude-startup-fault' +const CALLER = { callerKey: 'client-1' } +const DIAGNOSTIC = 'claude stream-json exited (code 1): claude: not signed in (rig)' +const STOPPED_TEXT = 'Claude stopped before it finished starting. Send your message to try again.' + +let claude = createScriptedClaudeRuntime([SESSION]) +let operations = 0 + +afterEach(async () => { + vi.restoreAllMocks() + await claude.dispose() + claude = createScriptedClaudeRuntime([SESSION]) +}) + +function fence(host: StructuredAgentSessionHost): number { + return host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0 +} + +async function send(host: StructuredAgentSessionHost, text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}`, + expectedRuntimeFence: fence(host), + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + expect(sent, JSON.stringify(sent)).toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'pending' } } + }) + return sent.ok ? sent.value.clientMessageId : '' +} + +async function failureRows(host: StructuredAgentSessionHost) { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' && item.body.failure + ? [{ text: item.body.text, kind: item.body.failure.kind }] + : [] + ) +} + +async function submission(host: StructuredAgentSessionHost, clientMessageId: string) { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) +} + +async function released(host: StructuredAgentSessionHost): Promise { + await waitForStructuredAgentSessionRecovery() + await vi.waitFor(() => + expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + ) +} + +describe('a Claude start that Orca fails while the CLI is still running', () => { + it('reads as a start that could not happen when a saved option cannot be restored', async () => { + claude.behave(SESSION, { optionWritesFail: true }) + const host = await claude.install() + await expect( + host.attach( + CALLER, + claude.attachParams(SESSION, null, { options: { permissionMode: 'plan' } }) + ) + ).resolves.toMatchObject({ ok: true }) + await released(host) + + expect(claude.child(SESSION).calls).toContain('set_permission_mode') + expect(await failureRows(host)).toEqual([ + { text: expect.stringMatching(/^Claude couldn't start\./), kind: 'startFailed' } + ]) + }) + + it('rejects a held message as a start that could not happen when init names another session', async () => { + claude.behave(SESSION, { initHangs: true, initNamesForeignSession: true }) + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + const held = await send(host, 'hello') + + claude.child(SESSION).answerInit() + await released(host) + + await vi.waitFor(async () => + expect(await submission(host, held)).toMatchObject({ + dispatchState: 'rejected', + reason: expect.stringMatching(/^Claude couldn't start\./), + rejection: { kind: 'startFailed' } + }) + ) + expect(await failureRows(host)).toEqual([ + { text: expect.stringMatching(/^Claude couldn't start\./), kind: 'startFailed' } + ]) + expect(claude.child(SESSION).calls).not.toContain('send') + }) + + it('still says Claude stopped when the CLI exits on its own before its start lands', async () => { + claude.behave(SESSION, { initHangs: true }) + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + const held = await send(host, 'hello') + + claude.child(SESSION).exit(scriptedClaudeExitError(DIAGNOSTIC)) + await released(host) + + await vi.waitFor(async () => + expect(await submission(host, held)).toMatchObject({ + dispatchState: 'rejected', + reason: STOPPED_TEXT, + rejection: { kind: 'providerStartFailed' } + }) + ) + expect(await failureRows(host)).toEqual([{ text: STOPPED_TEXT, kind: 'providerStartFailed' }]) + }) +}) diff --git a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts index 38d0de314d2..ce26647ae85 100644 --- a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts +++ b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts @@ -72,10 +72,10 @@ function turnReportsModel(model: string): void { }) } -function statusRows(host: StructuredAgentSessionHost): string[] { - return host - .journalSnapshot(SESSION) - .items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) +async function statusRows(host: StructuredAgentSessionHost): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [item.body.text] : [] + ) } async function send(host: StructuredAgentSessionHost, text: string): Promise { @@ -119,7 +119,7 @@ describe('a Claude start whose CLI answers initialize but not a control request' timeout: DEADLINE_MS * 40 }) expect(host.deps.adapter.readOptionRestoreFailures?.(SESSION)).toEqual([]) - expect(statusRows(host)).toEqual([]) + expect(await statusRows(host)).toEqual([]) expect(claude.children(SESSION)).toHaveLength(1) // The proven child takes the next message. @@ -245,7 +245,7 @@ describe('a Claude start whose CLI answers initialize but not a control request' timeout: DEADLINE_MS * 40 }) expect(record(host)?.lease.claimStatus).toBe('live') - expect(statusRows(host)).toEqual([]) + expect(await statusRows(host)).toEqual([]) expect(claude.children(SESSION)).toHaveLength(1) }) }) diff --git a/src/main/runtime/claude-trust-dialog-transcript.test.ts b/src/main/runtime/claude-trust-dialog-transcript.test.ts new file mode 100644 index 00000000000..5913c54b019 --- /dev/null +++ b/src/main/runtime/claude-trust-dialog-transcript.test.ts @@ -0,0 +1,252 @@ +/** + * Claude Code's first-launch trust dialog, replayed byte for byte from captured transcripts + * (`__fixtures__/claude-dialog-trust-workspace*.txt`). + * + * The dialog parks the cursor on its highlighted option with a cursor-up, and the host's line tail + * drops every row below the cursor — "Yes, I trust this folder" and "Enter to confirm". The + * tui-idle poll therefore reads the runtime's rendered screen, which still shows them. + */ + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' + +function readCapture(name: string): { data: string; size: { cols: number; rows: number } } { + const base = join(__dirname, '__fixtures__', name) + const meta: { cols: number; rows: number } = JSON.parse(readFileSync(`${base}.meta.json`, 'utf8')) + return { data: readFileSync(`${base}.txt`, 'utf8'), size: { cols: meta.cols, rows: meta.rows } } +} + +async function waitOnReplay( + name: string, + readSize: number | null, + options: { after?: string; timeoutMs?: number } = {} +) { + const { data, size } = readCapture(name) + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Claude Code', + foregroundProcess: 'claude', + launchAgent: 'claude', + size, + data: '' + }) + // Pane creation awaits real timers; replay and polling use the virtual clock. + vi.useFakeTimers() + const bytes = Buffer.from(data, 'utf8') + const step = readSize ?? bytes.length + // Why a streaming decoder: a PTY read can end inside a multi-byte character, as a real one does. + const decoder = new TextDecoder() + for (let offset = 0; offset < bytes.length; offset += step) { + const read = decoder.decode(bytes.subarray(offset, offset + step), { stream: true }) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, read, Date.now()) + } + if (options.after) { + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, options.after, Date.now()) + } + const timeoutMs = options.timeoutMs ?? 15_000 + const promise = runtime.waitForTerminal(handle, { + condition: 'tui-idle', + timeoutMs + }) + // Attach before advancing so the expected timeout is never an unhandled rejection. + promise.catch(() => {}) + await vi.advanceTimersByTimeAsync(timeoutMs) + return promise +} + +describe("Claude's workspace trust dialog, from captured transcripts", () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('reports the dialog as a blocking prompt instead of waiting out the whole budget', async () => { + await expect(waitOnReplay('claude-dialog-trust-workspace', null)).resolves.toMatchObject({ + satisfied: false, + blockedReason: 'agent-trust-workspace' + }) + }) + + it('still reports it when the dialog arrives in the 1024-byte reads a live Claude produced', async () => { + // Why: the tail's plain path blanks each `text\r\r\n` line of a read that carries no + // cursor-up, so the opening question never survives there; the screen is unaffected. + await expect(waitOnReplay('claude-dialog-trust-workspace', 1024)).resolves.toMatchObject({ + satisfied: false, + blockedReason: 'agent-trust-workspace' + }) + }) + + it('reports it on a narrow pane, where Claude wraps the question across lines', async () => { + await expect(waitOnReplay('claude-dialog-trust-workspace-narrow', null)).resolves.toMatchObject( + { satisfied: false, blockedReason: 'agent-trust-workspace' } + ) + }) + + it('reports the agent ready, not blocked, once the user has answered "Yes"', async () => { + const wait = await waitOnReplay('claude-dialog-trust-workspace-answered', 1024) + expect(wait).toMatchObject({ satisfied: true }) + expect(wait).not.toHaveProperty('blockedReason') + }) + + it('does not report a working Claude blocked for quoting the dialog in its own output', async () => { + // Synthetic turn painted over the answered capture: a working title, then a diff of this + // dialog's wording just above the status line, where the rendered screen shows it. + const quotedDiff = [ + '⏺ Update(src/main/runtime/claude-trust-dialog-transcript.test.ts)', + ' ⎿ Added 3 lines', + " 12 + '❯ No, exit',", + " 13 + ' Yes, I trust this folder',", + " 14 + 'Enter to confirm · Esc to cancel'", + '✻ Working… (esc to interrupt)' + ] + .map((line, index) => `\x1b[${27 + index};1H\x1b[2K${line}`) + .join('') + // Why a timeout proves it: three poll ticks pass, and a working agent has nothing else to settle on. + await expect( + waitOnReplay('claude-dialog-trust-workspace-answered', 1024, { + after: `\x1b]0;⠂ Claude Code\x07${quotedDiff}`, + timeoutMs: 6_500 + }) + ).rejects.toThrow('timeout') + }) +}) + +// A shell auto-title names Claude before Claude paints anything: oh-my-zsh sends the command +// line as OSC 2 and then `claude` as OSC 1; fish's default is `claude `. That bare name is +// not a rest signal, so it must never let a launch type into the dialog below it. +const OH_MY_ZSH_TITLES = ['\x1b]2;claude --dangerously-skip-permissions\x07', '\x1b]1;claude\x07'] +const FISH_TITLE = '\x1b]0;claude ~/p/repo\x07' +// eslint-disable-next-line no-control-regex -- OSC title sequences are control characters +const OSC_0_TITLE = /\x1b\]0;[^\x07]*\x07/g +const POLL_INTERVAL_MS = 2_000 +const QUIESCENCE_MS = 3_000 + +describe("Claude's trust dialog under a shell auto-title", () => { + afterEach(() => { + vi.useRealTimers() + }) + + async function createPane(name = 'claude-dialog-trust-workspace') { + const { data, size } = readCapture(name) + const pane = await createTranscriptPane({ + paneTitle: 'claude', + foregroundProcess: 'claude', + launchAgent: 'claude', + size, + data: '' + }) + // Why after creation: the pane's own set-up awaits real timers. + vi.useFakeTimers() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: both resolvers are protected methods on the runtime; the spies only count calls. + const internals = pane.runtime as unknown as { + resolveTuiIdleWaiters: (...args: unknown[]) => void + resolvePtyTuiIdleWaiters: (...args: unknown[]) => void + } + const titleResolves = [ + vi.spyOn(internals, 'resolveTuiIdleWaiters'), + vi.spyOn(internals, 'resolvePtyTuiIdleWaiters') + ] + const write = (chunk: string) => + pane.runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, chunk, Date.now()) + const paint = (bytes = data) => { + const encoded = Buffer.from(bytes, 'utf8') + const decoder = new TextDecoder() + for (let offset = 0; offset < encoded.length; offset += 1024) { + write(decoder.decode(encoded.subarray(offset, offset + 1024), { stream: true })) + } + } + const wait = () => { + const settled = vi.fn() + const promise = pane.runtime.waitForTerminal(pane.handle, { + condition: 'tui-idle', + timeoutMs: 60_000 + }) + promise.then(settled, () => {}) + return { promise, settled } + } + const titleResolveCount = () => + titleResolves.reduce((sum, spy) => sum + spy.mock.calls.length, 0) + return { data, write, paint, wait, titleResolveCount } + } + + it('reports the dialog to a wait registered after the pane has gone quiet', async () => { + const pane = await createPane() + OH_MY_ZSH_TITLES.forEach(pane.write) + pane.paint() + await vi.advanceTimersByTimeAsync(QUIESCENCE_MS + 500) + + const { promise } = pane.wait() + await vi.advanceTimersByTimeAsync(0) + await expect(promise).resolves.toMatchObject({ + satisfied: false, + blockedReason: 'agent-trust-workspace' + }) + }) + + // Why the resolver differs: the command-line title reads as `permission`, and a + // permission-to-idle step is not offered to waiters, so oh-my-zsh reaches the poll instead. + it.each([ + ['oh-my-zsh', OH_MY_ZSH_TITLES, false], + ['fish', [FISH_TITLE], true] + ])( + 'does not settle ready when the %s title arrives before the dialog paints', + async (_, titles, offered) => { + const pane = await createPane() + const { promise, settled } = pane.wait() + titles.forEach(pane.write) + expect(pane.titleResolveCount() > 0).toBe(offered) + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS) + expect(settled).not.toHaveBeenCalled() + + pane.paint() + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS) + await expect(promise).resolves.toMatchObject({ + satisfied: false, + blockedReason: 'agent-trust-workspace' + }) + } + ) + + it('does not settle ready when the exit probe restores the name-only title', async () => { + const pane = await createPane() + const { promise, settled } = pane.wait() + pane.write('\x1b]1;claude\x07') + const resolvesBeforeRestore = pane.titleResolveCount() + // A neutral title reads as the agent exiting; the probe finds `claude` still in front and + // restores the idle status, offering it to the waiters again. + pane.write('\x1b]0;~/p/repo\x07') + await vi.advanceTimersByTimeAsync(0) + expect(pane.titleResolveCount()).toBeGreaterThan(resolvesBeforeRestore) + expect(settled).not.toHaveBeenCalled() + + pane.paint() + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS) + await expect(promise).resolves.toMatchObject({ + satisfied: false, + blockedReason: 'agent-trust-workspace' + }) + }) + + it("reports ready at once when Claude's own idle title follows the answer", async () => { + const pane = await createPane('claude-dialog-trust-workspace-answered') + OH_MY_ZSH_TITLES.forEach(pane.write) + pane.paint() + const { promise } = pane.wait() + await vi.advanceTimersByTimeAsync(0) + await expect(promise).resolves.toMatchObject({ satisfied: true }) + }) + + it('reports ready after the quiet window when Claude paints no title of its own', async () => { + const pane = await createPane('claude-dialog-trust-workspace-answered') + OH_MY_ZSH_TITLES.forEach(pane.write) + pane.paint(pane.data.replace(OSC_0_TITLE, '')) + const { promise, settled } = pane.wait() + await vi.advanceTimersByTimeAsync(QUIESCENCE_MS - 500) + expect(settled).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2) + const wait = await promise + expect(wait).toMatchObject({ satisfied: true }) + expect(wait).not.toHaveProperty('blockedReason') + }) +}) diff --git a/src/main/runtime/client-hosted-browser-row-hydration-census.test.ts b/src/main/runtime/client-hosted-browser-row-hydration-census.test.ts deleted file mode 100644 index e50e066a544..00000000000 --- a/src/main/runtime/client-hosted-browser-row-hydration-census.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { glob } from 'tinyglobby' - -const REPO_ROOT = join(import.meta.dirname, '../../..') -const CENSUS_FILE = 'src/main/runtime/client-hosted-browser-row-hydration-census.test.ts' - -/** - * `deliverHydrationSnapshot` reads like a getter and is not one: it replaces the publisher's record - * of which workspaces the renderer holds rows for. A second caller that reasonably treats it as a - * peek — a status panel, a diagnostic dump, a second window's look-ahead — would clear that record - * out from under the live renderer, and the rows it is showing become unretractable. That is the - * exact defect this round shipped a fix for, so the single-caller shape is pinned rather than - * trusted to the name. - */ -async function countCallSites(pattern: RegExp): Promise> { - const files = await glob(['src/**/*.ts', 'src/**/*.tsx'], { - cwd: REPO_ROOT, - ignore: ['**/node_modules/**', '**/*.test.ts', '**/*.test.tsx', CENSUS_FILE] - }) - const counts: Record = {} - for (const file of files) { - // Counted per file, not merely detected: two call sites in one file mask each other. - const hits = readFileSync(join(REPO_ROOT, file), 'utf8').match(pattern)?.length ?? 0 - if (hits > 0) { - counts[file] = hits - } - } - return counts -} - -// Anchored on the member-call shape rather than the bare name, so the declaration does not count -// itself and prose about the method does not have to be kept out of the file. -const DELIVER_CALL = /\.deliverHydrationSnapshot\(/g -const LIST_CALL = /\.listClientHostedBrowserRows\(/g - -describe('client-hosted row hydration caller census', () => { - it('keeps the hydration delivery to its one runtime caller', async () => { - expect(await countCallSites(DELIVER_CALL)).toEqual({ - 'src/main/runtime/orca-runtime-stored-mobile-snapshot-has-stale-preserved-tab.ts': 1 - }) - }) - - // The IPC handler is the other half of the same invariant: it is what turns one renderer's - // hydration request into one delivery, so a second production reader would arrive through here. - it('keeps the runtime accessor to its one IPC caller', async () => { - expect(await countCallSites(LIST_CALL)).toEqual({ - 'src/main/ipc/runtime.ts': 1 - }) - }) - - // Why: the two censuses above pass by finding what is already there, so a matcher that had - // stopped matching would read the same as a clean repo. Prove it counts a second caller. - it('counts a second caller rather than reporting the same single site', () => { - const source = ` - const a = runtime.deliverHydrationSnapshot() - const b = other.deliverHydrationSnapshot() - ` - expect(source.match(DELIVER_CALL)?.length).toBe(2) - expect('runtime.listClientHostedBrowserRows()'.match(LIST_CALL)?.length).toBe(1) - }) -}) diff --git a/src/main/runtime/codex-header-readiness-transcripts.test.ts b/src/main/runtime/codex-header-readiness-transcripts.test.ts new file mode 100644 index 00000000000..ea8dc03cef2 --- /dev/null +++ b/src/main/runtime/codex-header-readiness-transcripts.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, it, vi } from 'vitest' +import { createTranscriptPane } from './agent-transcript-pane-test-harness' +import { + readRuntimeFixture, + replayTranscript, + type TranscriptReplayFrame +} from './agent-transcript-replay-test-harness' +import { + isKnownReadyPromptBody, + isKnownReadyPromptPreview, + isKnownReadyPromptSettled +} from './terminal-wait-detection' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +// codex-cli 0.157.1 recordings at 120x40 (see each .meta.json); STA-8628. +const PLAIN = 'codex-0157-plain-ready' +const EFFORT_OVERRIDE = 'codex-0157-effort-override-embedded-warning' +const CONFIG_OVERRIDE = 'codex-0157-config-override-embedded-warning' +const NO_DAEMON = 'codex-0157-no-daemon-effort-override' +// Fresh CODEX_HOME: the provisional header stays up while Codex installs and starts its daemon. +const FRESH_HOME = 'codex-0157-fresh-home-daemon-install' +const ALL_FIXTURES = [PLAIN, EFFORT_OVERRIDE, CONFIG_OVERRIDE, NO_DAEMON, FRESH_HOME] + +async function finalFrame( + name: string, + cols: number, + rows: number +): Promise { + let last: TranscriptReplayFrame | null = null + for await (const frame of replayTranscript(readRuntimeFixture(name), cols, rows)) { + last = frame + } + if (!last) { + throw new Error(`empty fixture ${name}`) + } + return last +} + +function screenShowsLoadingHeader(screenLines: string[]): boolean { + const screen = screenLines.join('\n').toLowerCase() + return screen.includes('openai codex') && /(?:model|directory):\s+loading/.test(screen) +} + +// Codex's default status row opens with ` ·`; only the live chat paints it. +const LIVE_STATUS_ROW_RE = /\b(?:default|minimal|low|medium|high|xhigh) ·/ + +function screenShowsProvisionalStartup(screenLines: string[]): boolean { + return ( + screenShowsLoadingHeader(screenLines) && + !screenLines.some((line) => LIVE_STATUS_ROW_RE.test(line.toLowerCase())) + ) +} + +describe('Codex 0.157 header readiness from captured bytes', () => { + it.each([EFFORT_OVERRIDE, CONFIG_OVERRIDE])( + '%s: the line-folded wait text never shows a ready header', + async (name) => { + const { waitText } = await finalFrame(name, 120, 40) + // Why: the cell-diff repaint folds to `dirctory:` — the STA-8628 timeout. + expect(waitText.toLowerCase()).toContain('dirctory:') + expect(isKnownReadyPromptPreview(waitText)).toBe(false) + } + ) + + it.each(ALL_FIXTURES)( + '%s: the screen never adds readiness while loading, and is ready at the final screen', + async (name) => { + let sawLoadingHeader = false + let last: TranscriptReplayFrame | null = null + for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) { + if (screenShowsLoadingHeader(frame.screenLines)) { + sawLoadingHeader = true + expect(isKnownReadyPromptBody('', 'codex', () => frame.screenLines)).toBe(false) + } + last = frame + } + // Presence precondition: a loading frame was actually exercised. + expect(sawLoadingHeader).toBe(true) + expect(last).not.toBeNull() + expect(isKnownReadyPromptBody(last!.waitText, 'codex', () => last!.screenLines)).toBe(true) + } + ) + + // Why: 0.157 discards input typed during its daemon start, behind the provisional header. + it.each(ALL_FIXTURES)( + '%s: never ready while the screen shows the provisional `model: loading` startup screen', + async (name) => { + let sawTextOnlyReadiness = false + for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) { + if (screenShowsProvisionalStartup(frame.screenLines)) { + sawTextOnlyReadiness ||= isKnownReadyPromptPreview(frame.waitText) + expect(isKnownReadyPromptBody(frame.waitText, 'codex', () => frame.screenLines)).toBe( + false + ) + } + } + // Presence precondition for the text-copy fixtures: the text rules alone would say ready here. + if (name === PLAIN || name === FRESH_HOME) { + expect(sawTextOnlyReadiness).toBe(true) + } + } + ) + + // Why these sizes: grids out of step with the 120x40 recording garble the header (review of #23475). + describe.each([ + [120, 40], + [80, 24], + [30, 50], + [108, 30], + [60, 5] + ])('at %ix%i the screen never takes a settled header away from the text rules', (cols, rows) => { + it.each(ALL_FIXTURES)('%s', async (name) => { + let settledFrames = 0 + for await (const frame of replayTranscript(readRuntimeFixture(name), cols, rows)) { + if (isKnownReadyPromptSettled(frame.waitText)) { + settledFrames += 1 + expect(isKnownReadyPromptBody(frame.waitText, 'codex', () => frame.screenLines)).toBe( + true + ) + } + } + // Presence precondition: the text-copy fixtures reach a settled header. + if (name === PLAIN || name === FRESH_HOME || name === NO_DAEMON) { + expect(settledFrames).toBeGreaterThan(0) + } + }) + }) + + it('keeps the text rules when there is no live screen', async () => { + const { waitText } = await finalFrame(PLAIN, 120, 40) + expect(isKnownReadyPromptBody(waitText, 'codex', () => null)).toBe( + isKnownReadyPromptPreview(waitText) + ) + expect(isKnownReadyPromptBody(waitText, 'codex', () => null)).toBe(true) + }) + + it('does not read a mid-turn composer as ready', () => { + const screenLines = [ + '› Summarize the repository layout', + '• Working (12s • esc to interrupt)', + '› Ask Codex to do anything', + ' GPT-6-Sol high · ~/repo/app' + ] + expect(isKnownReadyPromptBody(screenLines.join('\n'), 'codex', () => screenLines)).toBe(false) + }) + + it('does not settle when a blocking dialog is painted below the header', () => { + const screenLines = [ + '│ >_ OpenAI Codex (v0.157.1) │', + '│ model: GPT-6-Sol high /model to change │', + '│ directory: ~/repo/app │', + 'Do you trust the contents of this directory?', + 'Press enter to continue' + ] + expect(isKnownReadyPromptBody('', 'codex', () => screenLines)).toBe(false) + }) + + it('reads only the header box, not chat below it that mentions Codex', () => { + const screenLines = [ + '╭──────────────────────────────────────────────────────────╮', + '│ >_ OpenAI Codex (v0.157.1) │', + '│ model: GPT-6-Sol high /model to change │', + '│ directory: ~/repo/app │', + '╰──────────────────────────────────────────────────────────╯', + '› Why does OpenAI Codex print model: loading at startup?' + ] + expect(isKnownReadyPromptBody('', 'codex', () => screenLines)).toBe(true) + }) + + it('leaves a non-codex pane on the text rules even when its screen shows the Codex header', () => { + const screenLines = [ + '│ >_ OpenAI Codex (v0.157.1) │', + '│ model: GPT-6-Sol high /model to change │', + '│ directory: ~/repo/app │' + ] + const readScreenLines = vi.fn(() => screenLines) + expect(isKnownReadyPromptBody('', 'claude', readScreenLines)).toBe(false) + expect(readScreenLines).not.toHaveBeenCalled() + expect(isKnownReadyPromptBody('', 'codex', readScreenLines)).toBe(true) + }) + + describe('at the 80x24 default grid the header garbles and today’s answer stands', () => { + it.each(ALL_FIXTURES)('%s', async (name) => { + const { screenLines, waitText } = await finalFrame(name, 80, 24) + expect(isKnownReadyPromptBody(waitText, 'codex', () => screenLines)).toBe( + isKnownReadyPromptPreview(waitText) + ) + }) + }) + + describe('through the runtime', () => { + async function codexPane(name: string, size?: { cols: number; rows: number }) { + return createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex', + data: readRuntimeFixture(name), + size + }) + } + + it.each(ALL_FIXTURES)( + '%s: a tui-idle wait settles from the live screen', + async (name) => { + const { runtime, handle } = await codexPane(name, { cols: 120, rows: 40 }) + // Why 5s: the poll re-reads the grid every 2s once the queued emulator write lands. + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) + ).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true }) + }, + 15_000 + ) + + it('does not settle while Codex is still installing its daemon behind the provisional screen', async () => { + const data = readRuntimeFixture(FRESH_HOME) + const install = data.indexOf('Installing daemon') + // Presence precondition: the cut keeps the provisional header and stops before the live chat. + expect(install).toBeGreaterThan(0) + const provisional = data.slice(0, data.indexOf('\n', install) + 1) + expect(provisional).toMatch(/model:.*loading/) + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex', + data: provisional, + size: { cols: 120, rows: 40 } + }) + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_500 }) + ).rejects.toThrow(/timeout/) + }, 15_000) + + // Why no bytes: worker-start waits on a pane that has printed nothing yet, which is when the + // runtime asks for a visible-screen read instead of its own text copy. + it('does not settle from a visible-screen read of the provisional screen', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex', + data: '', + size: { cols: 120, rows: 40 } + }) + const readVisibleScreen = vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ + handle, + status: 'running', + tail: [ + '╭──────────────────────────────────────────╮', + '│ >_ OpenAI Codex (v0.157.0) │', + '│ model: loading /model to change │', + '│ directory: ~/repo/app │', + '╰──────────────────────────────────────────╯', + '› Ask Codex to do anything', + ' ? for shortcuts' + ], + truncated: false, + nextCursor: null, + source: 'screen' + }) + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_500 }) + ).rejects.toThrow(/timeout/) + // Presence precondition: the visible-screen probe actually ran. + expect(readVisibleScreen).toHaveBeenCalled() + }, 15_000) + + it('keeps timing out on the garbled 80x24 default grid, as before', async () => { + const { runtime, handle } = await codexPane(EFFORT_OVERRIDE) + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_500 }) + ).rejects.toThrow(/timeout/) + }, 15_000) + }) +}) diff --git a/src/main/runtime/codex-quiet-ready-screen.test.ts b/src/main/runtime/codex-quiet-ready-screen.test.ts new file mode 100644 index 00000000000..50052365905 --- /dev/null +++ b/src/main/runtime/codex-quiet-ready-screen.test.ts @@ -0,0 +1,372 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TuiAgent } from '../../shared/tui-agent' +import { createTranscriptPane } from './agent-transcript-pane-test-harness' +import { + readRuntimeFixture, + replayTranscript, + type TranscriptReplayFrame +} from './agent-transcript-replay-test-harness' +import { isCodexComposerReadyScreen } from './codex-terminal-readiness' +import { + detectTerminalWaitBlockedReason, + isKnownReadyPromptBody, + isQuietReadyScreenBody +} from './terminal-wait-detection' +import { + evaluateTuiIdle, + hasQuietReadyScreen, + isTuiIdleReadyVerdict, + type TuiIdleEvaluationInput +} from './tui-idle-evidence' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +const QUIESCENCE_MS = 3000 + +// codex-cli recordings at 120x40 (see each .meta.json); STA-8834. Each launches, runs a turn, +// and ends idle; the timed ones also replay as plain strings here. 0.157 idles via STA-8628's. +const TIMED_FIXTURES = ['codex-0-155-1-timed-turn', 'codex-0-158-0-timed-turn'] +const SETTLED_IDLE_FIXTURES = ['codex-0-150-1-turn', ...TIMED_FIXTURES] +const DIALOG_FIXTURES = [ + 'codex-0-157-1-update-dialog', + 'codex-0-158-0-approval', + 'codex-0-158-0-trustprompt' +] +const STA_8628_FIXTURES = [ + 'codex-0157-plain-ready', + 'codex-0157-effort-override-embedded-warning', + 'codex-0157-config-override-embedded-warning', + 'codex-0157-no-daemon-effort-override' +] + +const BUSY_STATUS_RE = /to interrupt\)/ +const EMPTY_COMPOSER_RE = /^› ask codex to do anything\s*$/m +const HEADER_LOADING_RE = /(?:model|directory):\s+loading|^\s*loading\s*$/m +const DIALOG_RE = + /update available|do you trust|trust this folder|would you like to run|press enter to confirm|enter continue/ + +function screenOf(frame: TranscriptReplayFrame): string { + return frame.screenLines.join('\n').toLowerCase() +} + +async function collectFrames( + data: string | readonly string[], + cols = 120, + rows = 40 +): Promise { + const frames: TranscriptReplayFrame[] = [] + for await (const frame of replayTranscript(data, cols, rows)) { + frames.push(frame) + } + return frames +} + +describe('Codex composer ready screen, frame by frame', () => { + it.each([...SETTLED_IDLE_FIXTURES, ...DIALOG_FIXTURES])( + '%s: never ready while loading, mid-turn, or under a drawn dialog', + async (name) => { + const seen = { loading: 0, busy: 0, dialog: 0 } + for (const frame of await collectFrames(readRuntimeFixture(name))) { + const screen = screenOf(frame) + // Why only once the composer is gone: a half-drawn dialog keeps it for a few frames, + // which quiescence absorbs; a drawn dialog replaces it. + const kind = HEADER_LOADING_RE.test(screen) + ? 'loading' + : BUSY_STATUS_RE.test(screen) + ? 'busy' + : DIALOG_RE.test(screen) && !EMPTY_COMPOSER_RE.test(screen) + ? 'dialog' + : null + if (kind) { + seen[kind] += 1 + expect({ kind, ready: isCodexComposerReadyScreen(screenOf(frame)) }).toEqual({ + kind, + ready: false + }) + } + } + // Presence preconditions: each fixture exercises the states it was recorded for. + expect(seen.loading).toBeGreaterThan(0) + if (DIALOG_FIXTURES.includes(name)) { + expect(seen.dialog).toBeGreaterThan(0) + } else { + expect(seen.busy).toBeGreaterThan(0) + } + }, + 30_000 + ) + + it.each([...SETTLED_IDLE_FIXTURES, ...STA_8628_FIXTURES])( + '%s: ready on the final idle screen', + async (name) => { + const frames = await collectFrames(readRuntimeFixture(name)) + expect(isCodexComposerReadyScreen(screenOf(frames.at(-1)!))).toBe(true) + } + ) + + it.each(DIALOG_FIXTURES)('%s: not ready while the dialog owns the screen', async (name) => { + const frames = await collectFrames(readRuntimeFixture(name)) + expect(isCodexComposerReadyScreen(screenOf(frames.at(-1)!))).toBe(false) + }) + + it('is not ready without a live screen', () => { + expect(isQuietReadyScreenBody('', 'codex', () => null)).toBe(false) + }) + + it('reads the placeholder only as the composer line, not quoted mid-line', () => { + const quoted = ['$ grep placeholder notes.txt', 'the box says › Ask Codex to do anything', '$'] + expect(isQuietReadyScreenBody('', 'codex', () => quoted)).toBe(false) + }) + + it('reads the busy row only above the composer, not a status row quoted in the answer', () => { + const screenLines = [ + '>_ OpenAI Codex (v0.158.0)', + ' ~/repo', + '', + '› what does the status row look like mid-turn?', + '', + '• It reads like this:', + '', + ' • Working (0s • esc to interrupt)', + '', + ' The timer counts up until the turn ends, and the row', + ' disappears once the answer is complete.', + '', + '› Ask Codex to do anything', + '', + ' gpt-5.6-sol medium · ~/repo', + ' ? for shortcuts' + ] + expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(true) + const composer = screenLines.indexOf('› Ask Codex to do anything') + const busy = [ + ...screenLines.slice(0, composer), + '• Working (3s • esc to interrupt)', + ' └ Tip: start a fresh idea with /new', + '', + ...screenLines.slice(composer) + ] + expect(isQuietReadyScreenBody('', 'codex', () => busy)).toBe(false) + }) + + it("reads 0.158's turn timestamp above the composer as the end of a quoted status row", () => { + // Live 0.158 screen after a worker answered with a status row verbatim (STA-8834). + const screenLines = [ + ' === TASK ===', + ' Reply with exactly this line and nothing else: • Working (0s • esc to interrupt) — then call worker_done.', + '• • Working (0s • esc to interrupt)', + '• Ran orca-dev orchestration send --from term_f496fcd2-a874-4fa6-a9bd-550a515ac928 --dispatch-capability dcap_PTgPpvf-B…', + ' └ Sent msg_ff7be3f25d7e', + ' + Show details', + '• • Working (0s • esc to interrupt)', + ' 11:15 PM', + '› Ask Codex to do anything', + ' GPT-6-Sol medium · ~/orca-lanes/sta8834/live3/scratch · Report task outcome', + ' ? for shortcuts' + ] + expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(true) + }) + + it('settles a quiet composer under an answer asking "Would you like to proceed?"', () => { + const screenLines = [ + '>_ OpenAI Codex (v0.158.0)', + ' ~/repo', + '', + '› tidy the README', + '', + '• Done. Would you like to proceed with the changelog too?', + '', + '› Ask Codex to do anything', + '', + ' ? for shortcuts' + ] + const waitText = screenLines.join('\n') + const verdict = evaluateTuiIdle({ + record: { lastAgentStatus: null, lastOutputAt: 0, lastOscTitle: null }, + readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText), + readPositiveBodyEvidence: () => isKnownReadyPromptBody(waitText, 'codex', () => screenLines), + readQuietReadyBodyEvidence: () => + isQuietReadyScreenBody(waitText, 'codex', () => screenLines), + agent: 'codex', + firstPartyStatus: null, + quiescenceMs: QUIESCENCE_MS + }) + expect(verdict.kind).toBe('ready-strong') + }) +}) + +type Timing = { promptSentAtMs: number; chunks: [number, number][] } + +function readTimedFixture(name: string): { chunks: string[]; times: number[]; promptAt: number } { + const data = readRuntimeFixture(name) + const timing: Timing = JSON.parse( + readFileSync(join(__dirname, '__fixtures__', `${name}.timing.json`), 'utf8') + ) + const chunks: string[] = [] + let offset = 0 + for (const [, length] of timing.chunks) { + chunks.push(data.slice(offset, offset + length)) + offset += length + } + expect(offset).toBe(data.length) + return { chunks, times: timing.chunks.map(([at]) => at), promptAt: timing.promptSentAtMs } +} + +describe('the quiet lane over recorded chunk timing (default animations)', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it.each(TIMED_FIXTURES)( + '%s: never settles mid-turn, and settles once the finished turn is quiet', + async (name) => { + const { chunks, times, promptAt } = readTimedFixture(name) + const frames = await collectFrames(chunks) + // Why after the replay: the emulator's write flush runs on real timers. + vi.useFakeTimers() + const lastBusy = frames.findLastIndex((frame) => BUSY_STATUS_RE.test(screenOf(frame))) + const firstTurnChunk = times.findIndex((at) => at >= promptAt) + expect(lastBusy).toBeGreaterThan(firstTurnChunk) + // The latest moment each frame stays on screen: just before the next chunk lands. + const settlesAt = (index: number, now: number): boolean => { + vi.setSystemTime(now) + return hasQuietReadyScreen( + { lastAgentStatus: null, lastOutputAt: times[index]!, lastOscTitle: null }, + 'codex', + () => + isQuietReadyScreenBody( + frames[index]!.waitText, + 'codex', + () => frames[index]!.screenLines + ), + QUIESCENCE_MS + ) + } + let readyBodyMidTurn = 0 + for (let index = firstTurnChunk; index <= lastBusy; index += 1) { + if (isCodexComposerReadyScreen(screenOf(frames[index]!))) { + readyBodyMidTurn += 1 + } + expect({ index, settles: settlesAt(index, times[index + 1]! - 1) }).toEqual({ + index, + settles: false + }) + } + // Presence precondition: the body alone does show mid-turn, so quiescence is load-bearing. + expect(readyBodyMidTurn).toBeGreaterThan(0) + const last = frames.length - 1 + // Why +1: the fake clock keeps whole milliseconds; the recorded times do not. + expect(settlesAt(last, times[last]! + QUIESCENCE_MS + 1)).toBe(true) + }, + 120_000 + ) +}) + +describe('never less ready than origin/main', () => { + const records = [ + { lastAgentStatus: null, lastOutputAt: 0, lastOscTitle: null }, + { lastAgentStatus: 'idle' as const, lastOutputAt: 0, lastOscTitle: 'codex' }, + { lastAgentStatus: 'working' as const, lastOutputAt: 0, lastOscTitle: '⠋ repo' } + ] + describe.each([ + [120, 40], + [80, 24] + ])('at %ix%i', (cols, rows) => { + it.each([...SETTLED_IDLE_FIXTURES, ...DIALOG_FIXTURES, ...STA_8628_FIXTURES])( + '%s', + async (name) => { + for (const frame of await collectFrames(readRuntimeFixture(name), cols, rows)) { + for (const agent of ['codex', null] as const) { + const base = { + readTailBlockedReason: () => detectTerminalWaitBlockedReason(frame.waitText), + readPositiveBodyEvidence: () => + isKnownReadyPromptBody(frame.waitText, agent, () => frame.screenLines), + agent, + firstPartyStatus: null, + quiescenceMs: QUIESCENCE_MS + } satisfies Omit + for (const record of records) { + const before = evaluateTuiIdle({ + ...base, + record, + // Why a withheld screen: that leaves exactly main's Muse lane, the only widening. + readQuietReadyBodyEvidence: () => + isQuietReadyScreenBody(frame.waitText, agent, () => null) + }) + const after = evaluateTuiIdle({ + ...base, + record, + readQuietReadyBodyEvidence: () => + isQuietReadyScreenBody(frame.waitText, agent, () => frame.screenLines) + }) + if (isTuiIdleReadyVerdict(before)) { + expect(isTuiIdleReadyVerdict(after)).toBe(true) + } + } + } + } + }, + 60_000 + ) + }) +}) + +describe('agent gate', () => { + const placeholderScreen = ['› Ask Codex to do anything', ' ? for shortcuts'] + + it("never reads another agent's screen, even one showing Codex's placeholder", () => { + const readScreenLines = vi.fn(() => placeholderScreen) + for (const agent of ['claude', 'muse', 'gemini'] as const) { + expect(isQuietReadyScreenBody('', agent, readScreenLines)).toBe(false) + } + expect(readScreenLines).not.toHaveBeenCalled() + expect(isQuietReadyScreenBody('', 'codex', readScreenLines)).toBe(true) + }) + + it('leaves an agent-unknown pane showing a cat-ed Codex transcript pending', () => { + const catted = ['$ cat session.log', '› Ask Codex to do anything', ' ? for shortcuts', '$ '] + const readScreenLines = vi.fn(() => catted) + expect(isQuietReadyScreenBody(catted.join('\n'), null, readScreenLines)).toBe(false) + expect(readScreenLines).not.toHaveBeenCalled() + expect(isQuietReadyScreenBody('', 'codex', readScreenLines)).toBe(true) + }) +}) + +describe('through the runtime', () => { + // Why 0.158 alone: its header carries no `model:`, so only this lane settles it. + const CODEX_0158 = 'codex-0-158-0-timed-turn' + async function pane(name: string, launchAgent: TuiAgent, size?: { cols: number; rows: number }) { + return createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: launchAgent, + launchAgent, + data: readRuntimeFixture(name), + size + }) + } + + // Why 8s: quiescence (3s) plus the 2s poll re-reading the grid. + it('codex 0.158: a tui-idle wait settles once the composer is quiet', async () => { + const { runtime, handle } = await pane(CODEX_0158, 'codex', { cols: 120, rows: 40 }) + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 8_000 }) + ).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true }) + }, 15_000) + + it('keeps a Claude pane showing the same screen pending', async () => { + const { runtime, handle } = await pane(CODEX_0158, 'claude', { + cols: 120, + rows: 40 + }) + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 6_000 }) + ).rejects.toThrow(/timeout/) + }, 15_000) +}) diff --git a/src/main/runtime/codex-startup-dialog-transcripts.test.ts b/src/main/runtime/codex-startup-dialog-transcripts.test.ts new file mode 100644 index 00000000000..01dbe447304 --- /dev/null +++ b/src/main/runtime/codex-startup-dialog-transcripts.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' +import { createTranscriptPane } from './agent-transcript-pane-test-harness' +import { + readRuntimeFixture, + replayTranscript, + type TranscriptReplayFrame +} from './agent-transcript-replay-test-harness' +import { detectTerminalWaitBlockedReason, isKnownReadyPromptBody } from './terminal-wait-detection' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +type StartupDialog = { + name: string + reason: RuntimeTerminalWaitBlockedReason + heading: string + keyRow: string +} + +// codex-cli 0.157.0 / 0.158.0 recordings at 120x40 on a fresh CODEX_HOME (see each .meta.json). +// Each dialog owns Enter, and none prints the `Press enter to …` wording older builds did. +const DIALOGS: StartupDialog[] = [ + { + // Recorded separately on 0.157.1 (STA-8834). + name: 'codex-0-157-1-update-dialog', + reason: 'agent-update-prompt', + heading: 'Update available ·', + keyRow: 'enter continue · esc skip' + }, + { + name: 'codex-0157-update-available-dialog', + reason: 'agent-update-prompt', + heading: 'Update available ·', + keyRow: 'enter continue · esc skip' + }, + { + name: 'codex-0158-update-available-dialog', + reason: 'agent-update-prompt', + heading: 'Update available ·', + keyRow: 'enter continue · esc skip' + }, + { + name: 'codex-0157-hooks-review-dialog', + reason: 'agent-hooks-review-prompt', + heading: 'Hooks need review', + keyRow: 'enter confirm · esc skip' + }, + { + name: 'codex-0158-hooks-review-dialog', + reason: 'agent-hooks-review-prompt', + heading: 'Hooks need review', + keyRow: 'enter confirm · esc skip' + }, + { + name: 'codex-0157-model-retired-dialog', + reason: 'codex-model-migration-prompt', + heading: 'is no longer available', + keyRow: 'enter/esc continue · ctrl+c quit' + }, + { + name: 'codex-0158-model-retired-dialog', + reason: 'codex-model-migration-prompt', + heading: 'is no longer offered', + keyRow: 'enter/esc continue · ctrl+c quit' + }, + { + name: 'codex-0158-model-announcement-dialog', + reason: 'codex-model-migration-prompt', + heading: 'Try new model', + keyRow: 'enter/esc confirm · ctrl+c quit' + } +] +const DIALOGS_0158 = DIALOGS.filter((dialog) => dialog.name.startsWith('codex-0158-')) +const LIVE_CHAT_FIXTURES = [ + 'codex-0157-plain-ready', + 'codex-0157-effort-override-embedded-warning', + 'codex-0157-config-override-embedded-warning', + 'codex-0157-no-daemon-effort-override', + 'codex-0157-fresh-home-daemon-install', + 'codex-0158-fresh-home-greeting' +] + +function screenText(frame: TranscriptReplayFrame): string { + return frame.screenLines.join('\n') +} + +async function lastFrame(data: string): Promise { + let last: TranscriptReplayFrame | null = null + for await (const frame of replayTranscript(data, 120, 40)) { + last = frame + } + return last +} + +// Why stitched: no capture spans answering a dialog. Codex repaints every cell once a startup +// dialog closes, so the 0.158 greeting capture's paints stand in for that repaint. +function answered(name: string): string { + const greeting = readRuntimeFixture('codex-0158-fresh-home-greeting') + return readRuntimeFixture(name) + greeting.slice(greeting.indexOf('\x1b[?2026h')) +} + +describe('Codex 0.157/0.158 startup dialogs from captured bytes', () => { + it.each(DIALOGS)( + '$name: reports $reason from its key row on, and never reads ready while it is up', + async ({ name, reason, heading, keyRow }) => { + let headingFrames = 0 + let keyRowFrames = 0 + for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) { + const screen = screenText(frame) + if (screen.includes(heading)) { + headingFrames += 1 + expect(isKnownReadyPromptBody(frame.waitText, 'codex', () => frame.screenLines)).toBe( + false + ) + } + if (keyRowFrames > 0 || screen.includes(keyRow)) { + keyRowFrames += 1 + expect(detectTerminalWaitBlockedReason(frame.waitText)).toBe(reason) + } + } + // Presence precondition: the dialog and its key row were painted, not just parsed. + expect(headingFrames).toBeGreaterThan(0) + expect(keyRowFrames).toBeGreaterThan(0) + } + ) + + it.each(DIALOGS_0158)( + '$name: stops reporting once Codex repaints its chat after it', + async ({ name, heading }) => { + const last = await lastFrame(answered(name)) + // Presence precondition: the answered dialog is still in the text copy. + expect(last?.waitText).toContain(heading.replace(' ·', '')) + expect(detectTerminalWaitBlockedReason(last?.waitText ?? '')).toBeNull() + } + ) + + it.each(DIALOGS)( + '$name: still reports $reason when Codex is relaunched in the same pane and shows it again', + async ({ name, reason }) => { + // Why: quitting Codex from a dialog leaves that copy in the text copy ahead of the relaunch. + const dialog = readRuntimeFixture(name) + const last = await lastFrame(`${dialog}\x1b[?1049l\r\n% codex\r\n${dialog}`) + expect(detectTerminalWaitBlockedReason(last?.waitText ?? '')).toBe(reason) + expect( + isKnownReadyPromptBody(last?.waitText ?? '', 'codex', () => last?.screenLines ?? null) + ).toBe(false) + } + ) + + it.each(LIVE_CHAT_FIXTURES)('%s: a live chat reports no dialog', async (name) => { + const waitText = (await lastFrame(readRuntimeFixture(name)))?.waitText ?? '' + expect(detectTerminalWaitBlockedReason(waitText)).toBeNull() + // Why these lines: chat can name a dialog, and Codex's own update notice and footer draw `·`. + const chat = [ + '› is there an update available, and do my hooks need review?', + '✨ Update available! 0.158.0 -> 0.159.0', + 'Run npm install -g @openai/codex to update.', + ' gpt-6-astra default · ~/repo', + ' ← for agents · ? for shortcuts' + ].join('\n') + expect(detectTerminalWaitBlockedReason(`${waitText}\n${chat}`)).toBeNull() + }) + + it('does not name a mid-session Codex popup a hooks review', () => { + // Why: Codex's rate-limit reset popup (and other pickers) ends `enter confirm · esc back`. + const popup = [ + ' Use this reset?', + ' 1. Yes, use reset Reset your weekly and 5-hour usage limits.', + '› 2. No, go back Choose a different reset', + ' enter confirm · esc back' + ].join('\n') + expect(detectTerminalWaitBlockedReason(popup)).not.toBe('agent-hooks-review-prompt') + }) + + describe('through the runtime', () => { + it.each(DIALOGS)( + '$name: stops a tui-idle wait as $reason instead of typing into it', + async ({ name, reason }) => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex', + data: readRuntimeFixture(name), + size: { cols: 120, rows: 40 } + }) + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_500 }) + ).resolves.toMatchObject({ satisfied: false, blockedReason: reason }) + }, + 15_000 + ) + }) +}) diff --git a/src/main/runtime/codex-terminal-readiness.ts b/src/main/runtime/codex-terminal-readiness.ts new file mode 100644 index 00000000000..cdbc8903d21 --- /dev/null +++ b/src/main/runtime/codex-terminal-readiness.ts @@ -0,0 +1,84 @@ +// Why both shapes: 0.150-0.157 paint `model: loading` in a box, 0.158 a bare `loading` under the title. +const CODEX_HEADER_LOADING_RE = /(?:model|directory):\s+loading|^\s*loading\s*$/m +// Why a line cap: 0.158 draws no box, so nothing else ends its header before the chat. +const CODEX_HEADER_LINES = 6 +// Why the whole line: a pager, a `cat`ed transcript, or chat can quote the placeholder mid-line. +const CODEX_EMPTY_COMPOSER_RE = /^› ask codex to do anything\s*$/ +// Why not "working": reasoning summaries replace it, and a remapped key still ends this way. +const CODEX_BUSY_STATUS_MARKER = 'to interrupt)' +// Why only a tip: it is the one line Codex draws between its status row and the composer (120x40 corpus). +const CODEX_STATUS_TIP_PREFIX = '└ tip:' + +// Why the header only: chat below it can mention "OpenAI Codex" or `model: loading`. +function findCodexHeader(screen: string): { index: number; text: string } | null { + const index = screen.indexOf('openai codex') + if (index === -1) { + return null + } + const boxEnd = screen.indexOf('╰', index) + const text = screen + .slice(index, boxEnd === -1 ? undefined : boxEnd) + .split('\n', CODEX_HEADER_LINES) + .join('\n') + return { index, text } +} + +/** Tier 1: the 0.150-0.157 header, which only a grid reassembles (see isKnownReadyPromptBody). */ +export function findCodexScreenReadyPromptIndex(screen: string): number | null { + const header = findCodexHeader(screen) + return header !== null && + header.text.includes('model:') && + header.text.includes('directory:') && + !CODEX_HEADER_LOADING_RE.test(header.text) + ? header.index + : null +} + +// Why the text copy: 0.157 leaves its alternate screen while it starts its daemon, so the live +// screen shows no header then, while the text copy keeps the provisional one until the live chat +// paints its footer after it (a later model repaint rewrites only the value, never the label). +// Why `·`: every live footer row draws one (status row, `← for agents · ?`, `⚠ N warning · f2`); +// startup dialogs draw one too, which is why startup-dialog-blocked-signals.ts matches them first. +export function isCodexProvisionalStartupText(normalized: string): boolean { + const headerIndex = normalized.lastIndexOf('openai codex') + if (headerIndex === -1) { + return false + } + const loading = /model:\s+loading/.exec(normalized.slice(headerIndex)) + return loading !== null && !normalized.includes('·', headerIndex + loading.index) +} + +// Why: Codex repaints its whole screen, header included, once a startup dialog closes, and the +// dialog never draws the header; 0.158's header has no labels, so the header alone marks it answered. +export function findCodexHeaderIndex(normalized: string): number | null { + const index = normalized.lastIndexOf('openai codex (v') + return index === -1 ? null : index +} + +/** + * Tier 1b, codex panes only: the empty composer with no busy status row just above it and no + * header load. Codex 0.158 dropped `model:`/`directory:`, and a long session scrolls the header + * away, so this is its only version-stable rest body. No dialog check: every Codex dialog + * replaces the composer, while an answer ending "Would you like to…?" must not block the lane. + * The mid-turn guard is the caller's quiescence, fed by the ~100 ms title spinner and status + * timer; `tui.animations=false` (set by a screen reader), `tui.effects.progress=false`, or a + * `tui.terminal_title` without activity/spinner removes it. + */ +export function isCodexComposerReadyScreen(screen: string): boolean { + const lines = screen.split('\n') + const composer = lines.findLastIndex((line) => CODEX_EMPTY_COMPOSER_RE.test(line)) + if (composer === -1 || hasBusyStatusRowAbove(lines, composer)) { + return false + } + const header = findCodexHeader(screen) + return header === null || !CODEX_HEADER_LOADING_RE.test(header.text) +} + +// Why only the row above the composer: a finished answer (or one above 0.158's timestamp) can quote it. +function hasBusyStatusRowAbove(lines: readonly string[], composer: number): boolean { + const above = lines.slice(0, composer).filter((line) => line.trim() !== '') + const row = above.at(-1)?.trimStart().startsWith(CODEX_STATUS_TIP_PREFIX) + ? above.at(-2) + : above.at(-1) + return row?.includes(CODEX_BUSY_STATUS_MARKER) ?? false +} diff --git a/src/main/runtime/created-mobile-session-tab-caller-selection.test.ts b/src/main/runtime/created-mobile-session-tab-caller-selection.test.ts new file mode 100644 index 00000000000..65fc065a5a3 --- /dev/null +++ b/src/main/runtime/created-mobile-session-tab-caller-selection.test.ts @@ -0,0 +1,114 @@ +/** + * A tab a create just published becomes one paired client's selection: the shared snapshot the host + * and unselected clients follow stays put, and no PTY work runs — unlike a tap, which may respawn. + */ +import { expect, it, vi } from 'vitest' +import { parsePaneKey } from '../../shared/stable-pane-id' +import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' + +// Fragments stay side-effect ordered: mocks, then lifecycle, then fixtures. +const { OrcaRuntimeService } = await import('./orca-runtime-test-mocks.spec') +await import('./orca-runtime-test-lifecycle.spec') +const { store, TEST_WORKTREE_ID, HEADLESS_LEAF_ID } = + await import('./orca-runtime-test-fixtures.spec') +const { makePendingAgentTabActivationRuntime } = + await import('./orca-runtime-test-scenario-builders.spec') + +const WT = TEST_WORKTREE_ID + +function pairedRuntime() { + let next = 0 + const spawn = vi.fn(async () => ({ id: `pty-${++next}` })) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const caller: RuntimeMobileSessionTabsResult[] = [] + runtime.onMobileSessionTabsChanged((snapshot) => caller.push(snapshot), 'device-caller') + // A subscribed second device: selection that fans out to live clients would move it too. + runtime.onMobileSessionTabsChanged(() => {}, 'device-bystander') + return { runtime, spawn, caller } +} + +async function createPane(runtime: InstanceType) { + const created = await runtime.createTerminal(`id:${WT}`) + const pane = parsePaneKey(created.paneKey ?? '') + if (!pane) { + throw new Error(`createTerminal returned no pane key: ${created.paneKey}`) + } + return { tabId: pane.tabId, leafId: pane.leafId, id: `${pane.tabId}::${pane.leafId}` } +} + +it('selects a launched terminal for the caller only, without spawning again', async () => { + const { runtime, spawn, caller } = pairedRuntime() + const first = await createPane(runtime) + const launched = await createPane(runtime) + expect((await runtime.listMobileSessionTabs(`id:${WT}`)).activeTabId).toBe(first.id) + + expect(runtime.selectCreatedMobileSessionTabForClient(WT, launched, 'device-caller')).toBe(true) + + expect(caller.at(-1)?.activeTabId).toBe(launched.id) + expect((await runtime.listMobileSessionTabs(`id:${WT}`, 'device-caller')).activeTabId).toBe( + launched.id + ) + expect((await runtime.listMobileSessionTabs(`id:${WT}`)).activeTabId).toBe(first.id) + expect((await runtime.listMobileSessionTabs(`id:${WT}`, 'device-bystander')).activeTabId).toBe( + first.id + ) + expect(spawn).toHaveBeenCalledTimes(2) +}) + +it('selects a launched chat by its session for the caller only', async () => { + const { runtime, caller } = pairedRuntime() + const first = await createPane(runtime) + await runtime.publishStructuredAgentSessionTab({ + workspaceId: WT, + sessionId: 'sess-1', + agent: 'claude', + activate: false + }) + + expect( + runtime.selectCreatedMobileSessionTabForClient(WT, { sessionId: 'sess-1' }, 'device-caller') + ).toBe(true) + + expect(caller.at(-1)?.activeTabId).toBe('agent-session:sess-1') + expect((await runtime.listMobileSessionTabs(`id:${WT}`)).activeTabId).toBe(first.id) + expect((await runtime.listMobileSessionTabs(`id:${WT}`, 'device-bystander')).activeTabId).toBe( + first.id + ) +}) + +it('reports a tab that is not published instead of selecting something else', async () => { + const { runtime, caller } = pairedRuntime() + await createPane(runtime) + const before = caller.length + + expect( + runtime.selectCreatedMobileSessionTabForClient(WT, { sessionId: 'missing' }, 'device-caller') + ).toBe(false) + expect(caller).toHaveLength(before) +}) + +it('never materializes a pane that is not ready, as a tap would', async () => { + const { runtime, spawn } = makePendingAgentTabActivationRuntime() + const listed = await runtime.listMobileSessionTabs(`id:${WT}`) + expect(listed.tabs[0]).toMatchObject({ launchAgent: 'claude', status: 'pending-handle' }) + + expect( + runtime.selectCreatedMobileSessionTabForClient( + WT, + { tabId: 'host-tab', leafId: HEADLESS_LEAF_ID }, + 'device-caller' + ) + ).toBe(true) + + const selected = await runtime.listMobileSessionTabs(`id:${WT}`, 'device-caller') + expect(selected.activeTabId).toBe(`host-tab::${HEADLESS_LEAF_ID}`) + // Why wait: a tap's respawn lands several awaits later, so an immediate check cannot see one. + await new Promise((resolve) => setTimeout(resolve, 200)) + expect(spawn).not.toHaveBeenCalled() +}) diff --git a/src/main/runtime/dsh-readiness-transcript.test.ts b/src/main/runtime/dsh-readiness-transcript.test.ts new file mode 100644 index 00000000000..36608b987b0 --- /dev/null +++ b/src/main/runtime/dsh-readiness-transcript.test.ts @@ -0,0 +1,71 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' +import { + getAgentLabel, + isGeminiTerminalTitle, + resolveExplicitTerminalTitleAgentType +} from '../../shared/terminal-title-agent-type' + +// The committed capture of a real `dsh-tui` 0.10.2 launch on @deepseek-ai/dsh 0.1.5-rc.1. +// Every rule below is written against these bytes rather than a remembered screen; see +// docs/reference/agent-pty-transcript-capture.md. +const TRANSCRIPT = readFileSync(join(__dirname, '__fixtures__', 'dsh-tui-ready-no-key.txt'), 'utf8') + +const ESC = String.fromCharCode(27) +const BEL = String.fromCharCode(7) + +/** The OSC 0 title DSH-TUI sets, read back out of the capture. */ +function readFirstOscTitle(data: string): string { + // Indexed scan rather than a regex: the delimiters are control characters. + const open = data.indexOf(`${ESC}]0;`) + const bodyStart = open === -1 ? -1 : open + 4 + const terminator = bodyStart === -1 ? -1 : data.indexOf(BEL, bodyStart) + if (terminator === -1) { + throw new Error('the captured transcript carries no BEL-terminated OSC 0 title') + } + return data.slice(bodyStart, terminator) +} + +describe('DSH-TUI readiness from captured terminal bytes', () => { + it('fires the composer-ready signal well before the transcript ends', () => { + const scanner = createDraftPasteReadyScanner('dsh-composer-prompt') + let readyAt = -1 + // Feed it in PTY-sized chunks so a marker split across chunk boundaries is exercised. + for (let offset = 0; offset < TRANSCRIPT.length; offset += 1024) { + const chunk = TRANSCRIPT.slice(offset, offset + 1024) + if (scanner.observe(chunk).ready && readyAt === -1) { + readyAt = offset + chunk.length + } + } + // The composer glyph lands at byte 5910 of ~70KB: readiness must not wait out the + // whale intro that keeps painting behind it (the grok failure mode this signal fixes). + expect(readyAt).toBeGreaterThan(0) + expect(readyAt).toBeLessThan(8192) + }) + + it('arms the quiet-window fallback from DECSET 2004 as well', () => { + const scanner = createDraftPasteReadyScanner('dsh-composer-prompt') + expect(scanner.observe(TRANSCRIPT.slice(0, 40)).armQuietTimer).toBe(true) + }) + + it('identifies the pane from DSH’s own title, not Gemini’s', () => { + const title = readFirstOscTitle(TRANSCRIPT) + // DSH-TUI's idle prefix is `✦`, which is Gemini CLI's WORKING glyph. + expect(title).toContain('✦') + expect(title).toContain('\u{1F40B}') + expect(isGeminiTerminalTitle(title)).toBe(false) + expect(getAgentLabel(title)).toBe('DeepSeek Harness') + expect(resolveExplicitTerminalTitleAgentType(title)).toBe('dsh') + }) + + it('keeps a working DSH title out of Claude’s braille-spinner lane', () => { + // `titlePrefix` cycles through `⠂`/`⠐` while a turn runs (Chat.js + // TITLE_SPINNER_FRAMES), both of which are in the braille block Claude claims. + for (const frame of ['⠂', '⠐']) { + const working = `${frame} \u{1F40B} fix the flaky test` + expect(getAgentLabel(working)).toBe('DeepSeek Harness') + } + }) +}) diff --git a/src/main/runtime/folder-workspace-pty-identity.test.ts b/src/main/runtime/folder-workspace-pty-identity.test.ts index db14f74bad8..43eb15f66b3 100644 --- a/src/main/runtime/folder-workspace-pty-identity.test.ts +++ b/src/main/runtime/folder-workspace-pty-identity.test.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' import { describe, expect, it } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' import type { RuntimeClientEvent } from '../../shared/runtime-client-events' @@ -66,7 +67,8 @@ function createRuntimeInternals( [WORKSPACE_A]: { hostId: 'local' }, [WORKSPACE_B]: { hostId: 'local' } } - const store = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This runtime fixture supplies the persistence and graph methods exercised by the test. + const store = withDurableRuntimeStore({ getRepos: () => [REPO], getRepo: (id: string) => (id === REPO_ID ? REPO : undefined), getAllWorktreeMeta: () => meta, @@ -78,7 +80,7 @@ function createRuntimeInternals( getWorkspaceSession: () => options.session ?? getDefaultWorkspaceSession(), setWorkspaceSession: () => {}, flushOrThrow: () => {} - } as never + }) as never const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ write: () => true, diff --git a/src/main/runtime/freebuff-status-transcript.test.ts b/src/main/runtime/freebuff-status-transcript.test.ts new file mode 100644 index 00000000000..04aa1e0a2ab --- /dev/null +++ b/src/main/runtime/freebuff-status-transcript.test.ts @@ -0,0 +1,146 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' +import { makeAgentStatusStoreWiring } from './agent-status-store-wiring.test-fixture' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +const transcript = readFileSync( + join(import.meta.dirname, '__fixtures__/freebuff-lifecycle.txt'), + 'utf8' +) + +describe('Freebuff execution-host status', () => { + it('publishes real running, question, and settled screens into the canonical store', async () => { + const wiring = makeAgentStatusStoreWiring() + const { runtime } = await createTranscriptPane( + { + data: '', + paneTitle: 'Freebuff', + foregroundProcess: 'freebuff', + launchAgent: 'freebuff', + size: { cols: 120, rows: 40 } + }, + wiring.deps + ) + const states: string[] = [] + const questions: string[] = [] + try { + // oxlint-disable-next-line no-control-regex -- Terminal protocol delimiters contain ESC and BEL. + for (const frame of transcript.split(/(?<=\x1b\[\?2026l)/)) { + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, frame, Date.now()) + await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) + const row = wiring.statusStore.getStatusSnapshot()[0] + if (row && states.at(-1) !== row.state) { + states.push(row.state) + } + if (row?.interactivePrompt) { + questions.push(row.interactivePrompt) + } + } + expect(states).toContain('working') + expect(states).toContain('waiting') + expect(states.at(-1)).toBe('done') + expect(questions.join('\n')).toMatch(/blue|green/i) + } finally { + runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) + } + }) + it.each([ + { name: 'login', cols: 100, rows: 32, input: 'Sign in to Freebuff' }, + { name: 'trust', cols: 120, rows: 40, input: 'Trust repository agent files? [y/N]' } + ])('publishes captured $name startup as blocked', async ({ name, cols, rows, input }) => { + const wiring = makeAgentStatusStoreWiring() + const { runtime } = await createTranscriptPane( + { + data: '', + paneTitle: 'Freebuff', + foregroundProcess: 'freebuff', + launchAgent: 'freebuff', + size: { cols, rows } + }, + wiring.deps + ) + try { + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + readFileSync(join(import.meta.dirname, `__fixtures__/freebuff-${name}.txt`), 'utf8'), + Date.now() + ) + await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) + expect(wiring.statusStore.getStatusSnapshot()[0]).toMatchObject({ + state: 'blocked', + toolInput: input, + agentType: 'freebuff' + }) + } finally { + runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) + } + }) + + it('clears startup blocks and starts a fresh session after an earlier completed turn', async () => { + const wiring = makeAgentStatusStoreWiring() + const { runtime } = await createTranscriptPane( + { + data: '', + paneTitle: 'Freebuff', + foregroundProcess: 'freebuff', + launchAgent: 'freebuff', + size: { cols: 120, rows: 40 } + }, + wiring.deps + ) + try { + for (const name of ['trust', 'ready', 'lifecycle', 'ready']) { + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + readFileSync(join(import.meta.dirname, `__fixtures__/freebuff-${name}.txt`), 'utf8'), + Date.now() + ) + await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) + expect(wiring.statusStore.getStatusSnapshot()[0]?.state).toBe( + name === 'trust' ? 'blocked' : 'done' + ) + if (name === 'ready') { + expect(wiring.statusStore.getStatusSnapshot()[0]).toMatchObject({ + sessionBoundary: true, + prompt: '' + }) + } + if (name === 'lifecycle') { + expect(wiring.statusStore.getStatusSnapshot()[0]?.sessionBoundary).not.toBe(true) + } + } + expect(wiring.statusStore.getStatusSnapshot()[0]?.toolInput).toBeUndefined() + } finally { + runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) + } + }) + + it('does not infer remote status from the client screen', async () => { + const wiring = makeAgentStatusStoreWiring() + const { runtime } = await createTranscriptPane( + { + data: '', + paneTitle: 'Freebuff', + foregroundProcess: 'freebuff', + connectionId: 'ssh-test', + size: { cols: 120, rows: 40 } + }, + wiring.deps + ) + try { + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, transcript, Date.now()) + await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) + expect(wiring.statusStore.getStatusSnapshot()).toEqual([]) + } finally { + runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) + } + }) +}) diff --git a/src/main/runtime/freebuff-terminal-status.ts b/src/main/runtime/freebuff-terminal-status.ts new file mode 100644 index 00000000000..09ffee3d50d --- /dev/null +++ b/src/main/runtime/freebuff-terminal-status.ts @@ -0,0 +1,25 @@ +import { FreebuffScreenStatusTracker } from '../../shared/freebuff-screen-status' +import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' +import type { HeadlessEmulator } from '../daemon/headless-emulator' + +const trackers = new WeakMap() + +export function observeFreebuffTerminalStatus( + emulator: HeadlessEmulator, + data: string, + startupCommand: string | undefined, + launchAgent?: string | null +) { + let tracker = trackers.get(emulator) + if (!tracker) { + const identified = + launchAgent === 'freebuff' || + recognizeAgentProcessFromCommandLine(startupCommand)?.agent === 'freebuff' + tracker = new FreebuffScreenStatusTracker(identified) + trackers.set(emulator, tracker) + } + return tracker.observe(data, () => ({ + lines: emulator.getVisibleLines(), + alternate: emulator.isAlternateScreen + })) +} diff --git a/src/main/runtime/graph-sync-live-daemon-pty-tab-preservation.test.ts b/src/main/runtime/graph-sync-live-daemon-pty-tab-preservation.test.ts index e91f18e8c6d..a824191bb90 100644 --- a/src/main/runtime/graph-sync-live-daemon-pty-tab-preservation.test.ts +++ b/src/main/runtime/graph-sync-live-daemon-pty-tab-preservation.test.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' /** * shouldPreserveHeadlessMobileSessionTab excludes the daemon ptyId form * @@ from its runtime-owned checks, so a host-created terminal @@ -51,7 +52,7 @@ function createHarness() { // Starts empty: only the create path may put this terminal in the session. let session: WorkspaceSessionState = { ...getDefaultWorkspaceSession() } const repo = makeRepo() - const store = { + const store = withDurableRuntimeStore({ getRepos: () => [repo], getRepo: (id: string) => (id === REPO_ID ? repo : undefined), getAllWorktreeMeta: () => ({}), @@ -63,7 +64,7 @@ function createHarness() { session = next }, flushOrThrow: () => {} - } + }) const runtime = new OrcaRuntimeService(store as never) runtime.setNotifier({ closeTerminal: vi.fn(), diff --git a/src/main/runtime/headless-close-keeps-publication-epoch.test.ts b/src/main/runtime/headless-close-keeps-publication-epoch.test.ts index b33579b783c..1a0344f393b 100644 --- a/src/main/runtime/headless-close-keeps-publication-epoch.test.ts +++ b/src/main/runtime/headless-close-keeps-publication-epoch.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import type { DurableProfileStateMutation } from '../persistence/loading-store/store-runtime-state' import { OrcaRuntimeService } from './orca-runtime' import { getDefaultWorkspaceSession } from '../../shared/constants' import type { @@ -25,7 +26,7 @@ function makeStore() { return { getWorkspaceSession: vi.fn(() => session), setWorkspaceSession: vi.fn(), - flushOrThrow: vi.fn(), + runDurableMutation: async (mutate: () => DurableProfileStateMutation) => mutate().value, getRepos: vi.fn(() => [ { id: 'repo-1', @@ -68,7 +69,7 @@ function storedSnapshot(tabs: RuntimeMobileSessionTerminalTab[]): RuntimeMobileS } } -function closeOneTab(): RuntimeMobileSessionTabsSnapshot { +async function closeOneTab(): Promise { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: makeStore covers the reads this suite drives. const runtime = new OrcaRuntimeService(makeStore() as never) const closedTab = terminalTab('tab-a', LEAF_ID) @@ -80,11 +81,11 @@ function closeOneTab(): RuntimeMobileSessionTabsSnapshot { snapshot: RuntimeMobileSessionTabsSnapshot, tab: RuntimeMobileSessionTerminalTab, options?: Record - ) => void + ) => Promise mobileSessionTabsByWorktree: Map } internals.mobileSessionTabsByWorktree.set(WORKTREE_ID, snapshot) - internals.closeHeadlessMobileTerminalTab(WORKTREE_ID, snapshot, closedTab, { + await internals.closeHeadlessMobileTerminalTab(WORKTREE_ID, snapshot, closedTab, { allowMissingPersistedTab: true, killPtys: false }) @@ -96,12 +97,12 @@ function closeOneTab(): RuntimeMobileSessionTabsSnapshot { } describe('closing a headless mobile terminal tab', () => { - it('keeps the worktree under the epoch that was already publishing it', () => { - expect(closeOneTab().publicationEpoch).toBe(LIVE_EPOCH) + it('keeps the worktree under the epoch that was already publishing it', async () => { + expect((await closeOneTab()).publicationEpoch).toBe(LIVE_EPOCH) }) - it('still advances the version so clients accept the frame', () => { - const published = closeOneTab() + it('still advances the version so clients accept the frame', async () => { + const published = await closeOneTab() expect(published.snapshotVersion).toBe(5) expect(published.tabs.map((tab) => tab.id)).toEqual([`tab-b::${LEAF_ID}`]) }) diff --git a/src/main/runtime/headless-seed-ownership.test.ts b/src/main/runtime/headless-seed-ownership.test.ts index 39963e531d2..42fe9c91660 100644 --- a/src/main/runtime/headless-seed-ownership.test.ts +++ b/src/main/runtime/headless-seed-ownership.test.ts @@ -184,13 +184,15 @@ it.each([false, true])( const started = makeDeferred() const release = makeDeferred() const original = HeadlessEmulator.prototype.write - vi.spyOn(HeadlessEmulator.prototype, 'write').mockImplementationOnce( - async function (this: HeadlessEmulator, data, options) { - started.resolve() - await release.promise - return original.call(this, data, options) - } - ) + vi.spyOn(HeadlessEmulator.prototype, 'write').mockImplementationOnce(async function ( + this: HeadlessEmulator, + data, + options + ) { + started.resolve() + await release.promise + return original.call(this, data, options) + }) const read = runtime.providerTail(visibleOnly) snapshot.resolve(PROVIDER_SNAPSHOT) await started.promise diff --git a/src/main/runtime/headless-terminal-dispose-write-ordering.test.ts b/src/main/runtime/headless-terminal-dispose-write-ordering.test.ts index b26c7bfb62c..13efbb1e80a 100644 --- a/src/main/runtime/headless-terminal-dispose-write-ordering.test.ts +++ b/src/main/runtime/headless-terminal-dispose-write-ordering.test.ts @@ -71,12 +71,12 @@ describe('disposeHeadlessTerminal write ordering', () => { events.push(`write:${emulators.indexOf(this)}:${data.trim()}`) return write.call(this, data, opts) }) - vi.spyOn(HeadlessEmulator.prototype, 'dispose').mockImplementation( - function (this: HeadlessEmulator) { - events.push(`dispose:${emulators.indexOf(this)}`) - return dispose.call(this) - } - ) + vi.spyOn(HeadlessEmulator.prototype, 'dispose').mockImplementation(function ( + this: HeadlessEmulator + ) { + events.push(`dispose:${emulators.indexOf(this)}`) + return dispose.call(this) + }) const runtime = createRuntime() // Queued but not yet parsed: the chain link is still pending here. diff --git a/src/main/runtime/host-terminal-close-persistence-durability.test.ts b/src/main/runtime/host-terminal-close-persistence-durability.test.ts index 66a34f16ea6..642edd6cf17 100644 --- a/src/main/runtime/host-terminal-close-persistence-durability.test.ts +++ b/src/main/runtime/host-terminal-close-persistence-durability.test.ts @@ -62,7 +62,7 @@ describe('host-created terminal close durability', () => { it('a host-created terminal does NOT push a fresh repo into host-authoritative membership', async () => { const store = await makeStore() - store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) + await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) const session = store.getWorkspaceSession() expect(session.tabsByWorktree?.[WT]?.map((t) => t.id)).toContain(TAB) // advanceTopologyFence (store.ts:3284) deliberately declines to arm the @@ -73,14 +73,14 @@ describe('host-created terminal close durability', () => { it('a renderer close write durably removes the row it persisted', async () => { const store = await makeStore() - store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) + await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) store.setWorkspaceSession(rendererWriteWithout(store.getWorkspaceSession(), TAB)) expect(store.getWorkspaceSession().tabsByWorktree?.[WT] ?? []).toEqual([]) }) it('stays removed with the PTY still connected and no exit ever delivered', async () => { const store = await makeStore() - store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) + await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) store.setWorkspaceSession(rendererWriteWithout(store.getWorkspaceSession(), TAB)) // Kill-failure shape: no retirement, no exit, just more renderer writes. for (let i = 0; i < 3; i += 1) { @@ -96,7 +96,7 @@ describe('host-created terminal close durability', () => { ...store.getWorkspaceSession(), terminalTopologyRevisionByRepoId: { [REPO_ID]: 1 } }) - store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) + await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) store.setWorkspaceSession(rendererWriteWithout(store.getWorkspaceSession(), TAB)) // Documents PRE-EXISTING behavior: with the fence already armed, a renderer // write that omits a row is treated as a stale replay and the row survives @@ -125,7 +125,7 @@ describe('topology fence census', () => { { startupCwd: '/tmp/wt-cli' } ].entries()) { const store = await makeStore() - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: WT, tabId: `${TAB}-${index}`, leafId: LEAF, @@ -138,8 +138,8 @@ describe('topology fence census', () => { it('a second pane in the same tab still does not arm the fence', async () => { const store = await makeStore() - store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) + await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: '22222222-2222-4222-8222-222222222222', @@ -154,7 +154,7 @@ describe('topology fence census', () => { ...store.getWorkspaceSession(), terminalTopologyRevisionByRepoId: { [REPO_ID]: 1 } }) - store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) + await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) expect( store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.[REPO_ID] ?? 0 ).toBeGreaterThan(1) diff --git a/src/main/runtime/managed-worktree-create-execution-host.test.ts b/src/main/runtime/managed-worktree-create-execution-host.test.ts index af1a73dc9cc..d33f8525741 100644 --- a/src/main/runtime/managed-worktree-create-execution-host.test.ts +++ b/src/main/runtime/managed-worktree-create-execution-host.test.ts @@ -1,7 +1,6 @@ // createManagedWorktree used to pick remote-vs-local from the raw `connectionId` field, so a repo // stamped only `executionHostId: 'ssh:*'` ran `git worktree add` on the client against a remote -// path — and the folder branch, which returns before that check, wrote agent trust locally for a -// remote workspace. Both are the #11163 shape: read the execution host, never one spelling of it. +// path. That is the #11163 shape: read the execution host, never one spelling of it. import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => ({ @@ -21,16 +20,6 @@ vi.mock('./runtime-local-worktree-create', () => ({ createRuntimeLocalManagedWorktree: createRuntimeLocalManagedWorktreeMock })) -const trustMocks = vi.hoisted(() => ({ - local: vi.fn(async () => {}), - remote: vi.fn(async () => {}) -})) -vi.mock('./runtime-worktree-agent-startup', async (importOriginal) => ({ - ...(await importOriginal>()), - markLocalWorktreeTrusted: trustMocks.local, - markRemoteWorktreeTrusted: trustMocks.remote -})) - import { OrcaRuntimeService } from './orca-runtime' const TARGET_ID = 'remote-1' @@ -77,8 +66,6 @@ describe('createManagedWorktree execution-host routing', () => { createRuntimeLocalManagedWorktreeMock.mockRejectedValue( new Error('local_worktree_create_ran_for_remote_repo') ) - trustMocks.local.mockClear() - trustMocks.remote.mockClear() }) it('creates on the SSH host for a repo stamped executionHostId only', async () => { @@ -115,41 +102,4 @@ describe('createManagedWorktree execution-host routing', () => { expect.anything() ) }) - - it('marks a folder workspace trusted on its SSH host, not on the client', async () => { - const { runtime } = makeRuntime({ - id: 'repo-folder', - path: REMOTE_PATH, - kind: 'folder', - connectionId: TARGET_ID, - executionHostId: `ssh:${TARGET_ID}` - }) - - await runtime.createManagedWorktree({ repoSelector: 'repo-folder', name: 'notes' } as never) - - const deps = createRuntimeFolderWorktreeMock.mock.calls[0]?.[0]?.deps - await deps.markTrusted('codex', '/srv/app') - - expect(trustMocks.remote).toHaveBeenCalledWith('codex', TARGET_ID, '/srv/app') - expect(trustMocks.local).not.toHaveBeenCalled() - }) - - it('keeps a local folder workspace trusted on the client', async () => { - const { runtime } = makeRuntime({ - id: 'repo-folder-local', - path: '/Users/me/notes', - kind: 'folder' - }) - - await runtime.createManagedWorktree({ - repoSelector: 'repo-folder-local', - name: 'notes' - } as never) - - const deps = createRuntimeFolderWorktreeMock.mock.calls[0]?.[0]?.deps - await deps.markTrusted('codex', '/Users/me/notes') - - expect(trustMocks.local).toHaveBeenCalledWith('codex', '/Users/me/notes') - expect(trustMocks.remote).not.toHaveBeenCalled() - }) }) diff --git a/src/main/runtime/mobile-rpc-allowlist.test.ts b/src/main/runtime/mobile-rpc-allowlist.test.ts index 06f99c594fb..841c22ae925 100644 --- a/src/main/runtime/mobile-rpc-allowlist.test.ts +++ b/src/main/runtime/mobile-rpc-allowlist.test.ts @@ -161,6 +161,9 @@ describe('mobile RPC allowlist', () => { 'agentSession.reveal', 'agentSession.send', 'agentSession.cancel', + 'agentSession.queuedMessageSend', + 'agentSession.queuedMessageDelete', + 'agentSession.queuedMessagesResume', 'agentSession.close', 'agentSession.respondToApproval', 'agentSession.respondToQuestion', diff --git a/src/main/runtime/mobile-session-layout-projection.ts b/src/main/runtime/mobile-session-layout-projection.ts index e495116e81e..3dd92091c77 100644 --- a/src/main/runtime/mobile-session-layout-projection.ts +++ b/src/main/runtime/mobile-session-layout-projection.ts @@ -63,7 +63,8 @@ export function cloneTerminalLayoutSnapshot( const cloned: TerminalLayoutSnapshot = { root: layout.root, activeLeafId: layout.activeLeafId, - expandedLeafId: layout.expandedLeafId + expandedLeafId: layout.expandedLeafId, + ...(layout.chatLeafId ? { chatLeafId: layout.chatLeafId } : {}) } if (layout.ptyIdsByLeafId) { cloned.ptyIdsByLeafId = { ...layout.ptyIdsByLeafId } diff --git a/src/main/runtime/mobile-session-terminal-retirement.ts b/src/main/runtime/mobile-session-terminal-retirement.ts index e8caba4ddf3..0f027e8c68d 100644 --- a/src/main/runtime/mobile-session-terminal-retirement.ts +++ b/src/main/runtime/mobile-session-terminal-retirement.ts @@ -115,7 +115,15 @@ function chooseGroupActiveTab( if (group.activeTabId && retainedTabIds.has(group.activeTabId)) { return group.activeTabId } - const recent = (group.recentTabIds ?? []).toReversed().find((tabId) => retainedTabIds.has(tabId)) + // Node 18 is the orcad floor and does not provide Array.prototype.toReversed. + let recent: string | undefined + for (let index = (group.recentTabIds?.length ?? 0) - 1; index >= 0; index -= 1) { + const tabId = group.recentTabIds?.[index] + if (tabId && retainedTabIds.has(tabId)) { + recent = tabId + break + } + } return recent ?? group.tabOrder.find((tabId) => retainedTabIds.has(tabId)) ?? null } diff --git a/src/main/runtime/orca-runtime-activate-managed-worktree.ts b/src/main/runtime/orca-runtime-activate-managed-worktree.ts index 6b94e5d0ea2..1629c529921 100644 --- a/src/main/runtime/orca-runtime-activate-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-activate-managed-worktree.ts @@ -12,9 +12,7 @@ import type { } from './runtime-worktree-agent-startup' import { buildWorktreeStartupForAgent, - buildWorktreeStartupForDraft, - markLocalWorktreeTrusted, - markRemoteWorktreeTrusted + buildWorktreeStartupForDraft } from './runtime-worktree-agent-startup' import type { AgentLaunchPreferences } from '../../shared/agent-session-host-authority' import type { Worktree } from '../../shared/worktree/types' @@ -27,7 +25,8 @@ import type { import { recordCreatedWorktreeLineage as recordCreatedWorktreeLineageState } from './runtime-worktree-lineage-recording' import { pasteWorktreeStartupDraftWhenReady, - sendWorktreeStartupFollowupWhenReady + sendWorktreeStartupFollowupWhenReady, + waitForWorktreeStartupDraft } from './runtime-worktree-startup-readiness' import type { CreateWorktreeResult } from '../../shared/worktree/create-types' import { provisionWorktreeTerminals } from './runtime-worktree-terminal-provisioning' @@ -117,7 +116,8 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM protected async buildStartupForDraft( repo: Repo, draft: string, - requestedAgent?: TuiAgent + requestedAgent?: TuiAgent, + launchSource?: string ): Promise<{ agent: TuiAgent startup: WorktreeStartupLaunch @@ -130,6 +130,7 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM repo, draft, ...(requestedAgent ? { requestedAgent } : {}), + ...(launchSource ? { launchSource } : {}), settings: this.store.getSettings(), getLaunchPlatform: () => this.getAgentLaunchPlatformForRepo(repo) }) @@ -161,33 +162,6 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM }) } - protected async markLocalWorkspaceTrustedForAgent( - agent: TuiAgent, - workspacePath: string - ): Promise { - await markLocalWorktreeTrusted(agent, workspacePath) - } - - protected async markWorkspaceTrustedForAgent( - agent: TuiAgent, - connectionId: string | null | undefined, - workspacePath: string - ): Promise { - if (connectionId) { - await this.markRemoteWorkspaceTrustedForAgent(agent, connectionId, workspacePath) - return - } - await this.markLocalWorkspaceTrustedForAgent(agent, workspacePath) - } - - protected async markRemoteWorkspaceTrustedForAgent( - agent: TuiAgent, - connectionId: string, - workspacePath: string - ): Promise { - await markRemoteWorktreeTrusted(agent, connectionId, workspacePath) - } - protected recordCreatedWorktreeLineage( worktree: Pick, lineageResolution: WorktreeLineageResolution @@ -203,6 +177,29 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM pasteWorktreeStartupDraftWhenReady(this.getWorktreeStartupReadinessHost(), handle, draft) } + /** Only for a newly launched worker, before its first dispatch input. */ + async waitForFreshWorkerComposer( + handle: string, + agent: TuiAgent, + timeoutMs: number + ): Promise { + const initialPtyId = + this.getLivePtyForHandle(handle)?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId + const ptyId = await waitForWorktreeStartupDraft( + { ...this.getWorktreeStartupReadinessHost(), getPtyId: () => initialPtyId }, + handle, + agent, + { timeoutMs, requireComposerMarker: true } + ) + if (!ptyId) { + throw new Error('timeout') + } + this.assertLiveTerminalHandleTargetsPty(handle, ptyId) + if (!this.ptysById.get(ptyId)?.connected) { + throw new Error('terminal_handle_stale') + } + } + protected sendStartupFollowupWhenReady(handle: string, followup: WorktreeStartupFollowup): void { sendWorktreeStartupFollowupWhenReady(this.getWorktreeStartupReadinessHost(), handle, followup) } diff --git a/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts b/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts index 5dd2908413b..3bc5b570704 100644 --- a/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts +++ b/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts @@ -4,10 +4,7 @@ import { spawnSurfaceClaimSequence, SURFACE_CLAIM_WITHOUT_STANDING } from './pty-recorded-surface-topology' -import { - observeStructuredWorker, - resolveStructuredWorkerAuthority -} from './structured-worker-authority' +import { resolveStructuredWorkerAuthority } from './structured-worker-authority' import type { RuntimeLeafRecord } from './runtime-terminal-state-records' import { OrcaRuntimeWithSubscribeToTerminalResize } from './orca-runtime-subscribe-to-terminal-resize' import type { @@ -210,12 +207,10 @@ export class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends OrcaRuntim this.getOrchestrationDbIfAvailable?.() ?? null ) if (structured) { - // `resolveBareOrchestrationRecipient` routes direct mail through this, not through - // getTerminalPaneKey. The connected-gate below exists so mail is never routed to a corpse, - // so the structured answer needs a real liveness proof too, not just a registry hit. - return observeStructuredWorker(structured.identity).status === 'live' - ? structured.identity.paneKey - : null + // `resolveBareOrchestrationRecipient` routes direct mail through this. A structured worker is + // a recipient while this runtime owns it, running or at rest: mail to one at rest is a send, + // and the send starts its agent. + return structured.identity.paneKey } const runtimePty = this.getLivePtyForHandle(handle) if (runtimePty) { diff --git a/src/main/runtime/orca-runtime-agent-session-operation.test.ts b/src/main/runtime/orca-runtime-agent-session-operation.test.ts index 8d7d6fa6812..37b329670d2 100644 --- a/src/main/runtime/orca-runtime-agent-session-operation.test.ts +++ b/src/main/runtime/orca-runtime-agent-session-operation.test.ts @@ -57,16 +57,12 @@ function createRuntime(provider?: { ) const internal = runtime as unknown as { resolveTerminalWorkspaceLaunchScope: ReturnType - markLocalWorkspaceTrustedForAgent: ReturnType - markRemoteWorkspaceTrustedForAgent: ReturnType } internal.resolveTerminalWorkspaceLaunchScope = vi.fn(async () => ({ id: 'worktree-1', path: '/tmp/worktree-1', connectionId: null })) - internal.markLocalWorkspaceTrustedForAgent = vi.fn() - internal.markRemoteWorkspaceTrustedForAgent = vi.fn() return runtime } @@ -84,7 +80,6 @@ function installRemoteReclaimHarness( connectionId: 'ssh-1' })), executionOwnerSupportsAgentSessionOperation: vi.fn(async () => true), - markWorkspaceTrustedForAgent: vi.fn(async () => {}), adoptControllerTerminalHandle: vi.fn((ptyId: string, handle: string) => { handleByPtyId.set(ptyId, handle) }), @@ -149,16 +144,13 @@ describe('agent-session create operation ledger', () => { expect(createTerminal).toHaveBeenCalledOnce() }) - it('selects legacy before trust, spawn, or ledger state for an old daemon', async () => { + it('selects legacy before spawn or ledger state for an old daemon', async () => { const provider = { supportsAgentSessionClaims: vi.fn(() => false), supportsAgentSessionCreateOperations: vi.fn(() => false) } const runtime = createRuntime(provider) const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal()) - const internal = runtime as unknown as { - markLocalWorkspaceTrustedForAgent: ReturnType - } const id = operationId() await expect(runtime.createAgentSession(request(id))).rejects.toThrow( @@ -174,7 +166,6 @@ describe('agent-session create operation ledger', () => { ).rejects.toThrow('agent_session_legacy_required') expect(createTerminal).not.toHaveBeenCalled() - expect(internal.markLocalWorkspaceTrustedForAgent).not.toHaveBeenCalled() provider.supportsAgentSessionCreateOperations.mockReturnValue(true) await expect(runtime.createAgentSession(request(id))).resolves.toMatchObject({ @@ -270,7 +261,6 @@ describe('agent-session create operation ledger', () => { const runtime = createRuntime() const internal = runtime as unknown as { resolveTerminalWorkspaceLaunchScope: ReturnType - markRemoteWorkspaceTrustedForAgent: ReturnType } internal.resolveTerminalWorkspaceLaunchScope.mockResolvedValue({ id: 'worktree-1', @@ -293,7 +283,6 @@ describe('agent-session create operation ledger', () => { ).rejects.toThrow('agent_session_legacy_required') expect(createTerminal).not.toHaveBeenCalled() - expect(internal.markRemoteWorkspaceTrustedForAgent).not.toHaveBeenCalled() }) it('replays the same completed operation without spawning again', async () => { diff --git a/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts b/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts index a2552ef12c3..68101c80960 100644 --- a/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts +++ b/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts @@ -72,6 +72,31 @@ export class OrcaRuntimeWithApplyMobileSessionTabNavigation extends OrcaRuntimeW return snapshot } + /** + * Records a tab a create just published as one paired client's selection, as a create does for + * its own tab. Not the tap path: a tap is a wake gesture that may respawn a non-ready pane. + * Returns false when the tab is not in the snapshot. + */ + selectCreatedMobileSessionTabForClient( + worktreeId: string, + surface: { tabId: string; leafId: string } | { sessionId: string }, + clientNavigationId: string + ): boolean { + const snapshot = this.getMobileSessionTabsForWorktree(worktreeId) + const tab = snapshot.tabs.find((candidate) => + 'sessionId' in surface + ? candidate.type === 'agent-session' && candidate.sessionId === surface.sessionId + : candidate.type === 'terminal' && + candidate.parentTabId === surface.tabId && + candidate.leafId === surface.leafId + ) + if (!tab) { + return false + } + this.applyMobileSessionTabNavigation(snapshot, tab.id, 'caller', clientNavigationId) + return true + } + /** * Whether persistence proves this pane's PTY was deliberately taken down and parked * (workspace sleep or completed-agent hibernation) rather than lost and awaiting reconnect. diff --git a/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts index 3459c7c8b6e..f4f8826e6e7 100644 --- a/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts +++ b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts @@ -62,10 +62,9 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper // parked on its poll, so the later explicit idle is an idle→idle step that still has // to be offered. The resolve helper re-ranks and returns early when it is not yet // satisfying evidence, which is what the old edge guard was really protecting. - // Why also gated on a change: re-ranking an unchanged idle title cannot reach a - // different verdict. Tier 1 and 2 depend only on the title and the status; tier 3 - // needs the stream to go quiet, which cannot happen on the frame that just wrote to - // it. Repainted frames would otherwise re-scan the pane tail for nothing. + // Why also gated on a change: the resolver settles only a blocked tail or strong + // ready, and the poll catches either between title changes; repainted frames would + // otherwise re-scan the pane tail for nothing. if (agentStatus === 'idle' && prevStatus !== 'permission' && ptyRecordChanged) { this.resolvePtyTuiIdleWaiters(pty, ptyId) } @@ -123,8 +122,7 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper // which isn't a task-completion signal. // Why not `prevStatus !== 'idle'`: see the pty branch — the resolve helper re-ranks, // so an idle→idle step that upgrades weak evidence to explicit must still be offered. - // Why the change gate: see the pty branch — an unchanged idle title re-ranks to the - // same verdict, so repainted frames must not re-scan the tail. + // Why the change gate: see the pty branch — repainted frames must not re-scan the tail. if ( agentStatus === 'idle' && prevStatus !== 'permission' && @@ -193,6 +191,7 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper pty.managementTitleAt = null pty.waitBlockedAt = null pty.tailWaitState = undefined + pty.commandPaint = undefined } for (const leaf of this.getLeavesForPty(ptyId)) { leaf.lastOscTitle = null diff --git a/src/main/runtime/orca-runtime-attach-remote-terminal-source-range-consumer.ts b/src/main/runtime/orca-runtime-attach-remote-terminal-source-range-consumer.ts index f433c75793d..7cb440b4bac 100644 --- a/src/main/runtime/orca-runtime-attach-remote-terminal-source-range-consumer.ts +++ b/src/main/runtime/orca-runtime-attach-remote-terminal-source-range-consumer.ts @@ -182,6 +182,7 @@ export class OrcaRuntimeWithAttachRemoteTerminalSourceRangeConsumer extends Orca try { await assertTerminalInputWithinLimitWithYield(data) await this.writeTerminalInputChunks(ptyId, data, { + inputKind: 'driving', // Why: a phone can claim the floor while a paste yields between chunks. beforeWrite: () => { if (this.getDriver(ptyId).kind === 'mobile') { diff --git a/src/main/runtime/orca-runtime-automation-operations.ts b/src/main/runtime/orca-runtime-automation-operations.ts index fe65979ed1e..5064944215b 100644 --- a/src/main/runtime/orca-runtime-automation-operations.ts +++ b/src/main/runtime/orca-runtime-automation-operations.ts @@ -17,6 +17,7 @@ import { } from '../../shared/automation-list-scope' import { OrchestrationDb } from './orchestration/db' import { join } from 'node:path' +import { existsSync } from 'node:fs' import { getAppEnvironment } from '../../shared/app-environment' import type { LegacyWorkerTerminalRecoveryPlan } from './orchestration/orchestration-legacy-worker-terminal-recovery' import type { LegacyWorkerTerminalRecoveryResult } from './runtime-legacy-worker-terminal-recovery-types' @@ -120,12 +121,13 @@ export class OrcaRuntimeWithAutomationOperations extends OrcaRuntimeWithPtyForeg deleteAutomation( id: string, expectedOwner?: AutomationOwnerPrecondition - ): { removed: boolean; id: string } { + ): Promise<{ removed: boolean; id: string }> { return this.automation.withExternalProbePriority(() => { const selector = this.automationChangeSelector(id) - const result = this.automation.delete(id, expectedOwner as never) - this.publishAutomationDefinitionChange(selector, selector) - return result + return this.automation.delete(id, expectedOwner).then((result) => { + this.publishAutomationDefinitionChange(selector, selector) + return result + }) }) } @@ -151,14 +153,24 @@ export class OrcaRuntimeWithAutomationOperations extends OrcaRuntimeWithPtyForeg // to inject an in-memory DB without touching the filesystem. getOrchestrationDb(): OrchestrationDb { if (!this._orchestrationDb) { - const dbPath = join(getAppEnvironment().getPath('userData'), 'orchestration.db') - this._orchestrationDb = new OrchestrationDb(dbPath) + this._orchestrationDb = new OrchestrationDb(this.orchestrationDbPath()) this.ensureOrchestrationFederationRelay() this.scheduleRestoredMessageRepoints() } return this._orchestrationDb } + /** The database, opened only if it already exists: a profile without one has no mail to redrive. */ + getExistingOrchestrationDb(): OrchestrationDb | null { + return this._orchestrationDb || existsSync(this.orchestrationDbPath()) + ? this.getOrchestrationDb() + : null + } + + private orchestrationDbPath(): string { + return join(getAppEnvironment().getPath('userData'), 'orchestration.db') + } + setOrchestrationDb(db: OrchestrationDb): void { this.orchestrationFederation.resetForDatabaseChange() this.mailPointerRepointScheduler.clear() diff --git a/src/main/runtime/orca-runtime-automations.test.ts b/src/main/runtime/orca-runtime-automations.test.ts index 9c2ea907c91..26cbdec3d13 100644 --- a/src/main/runtime/orca-runtime-automations.test.ts +++ b/src/main/runtime/orca-runtime-automations.test.ts @@ -187,7 +187,7 @@ describe('OrcaRuntimeService automation methods', () => { const runtime = new OrcaRuntimeService(store as never) const updated = await runtime.updateAutomation('auto-1', { enabled: false }) - const removed = runtime.deleteAutomation('auto-1') + const removed = await runtime.deleteAutomation('auto-1') expect(store.updateAutomation).toHaveBeenCalledWith('auto-1', { enabled: false }, undefined) expect(updated).toMatchObject({ diff --git a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts index 5aa6e46cc58..73d0e44499f 100644 --- a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts @@ -3,15 +3,27 @@ import { OrcaRuntimeWithPersistTerminalSurfaceRetirements } from './orca-runtime import type { RuntimeMobileSessionBrowserTab, RuntimeMobileSessionTabsResult, - RuntimeMobileSessionTabsSnapshot + RuntimeMobileSessionTabsSnapshot, + RuntimeMobileSessionTerminalTab } from '../../shared/runtime-types' +import { parseAppSshPtyId } from '../../shared/ssh-pty-id' import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry' import type { Tab } from '../../shared/tab-types' -import { closeTerminalTabInWorkspaceSession } from '../../shared/workspace-session-terminal-tab-close' -import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' +import { + resolveTerminalCloseTarget, + terminalSurfaceCloseMutation, + type PaneCloseResolution, + type RendererTerminalClose, + type TerminalSurfaceCloseOptions +} from './terminal-surface-close' +import type { + TerminalPaneCloseTarget, + TerminalSurfaceCloseTarget +} from '../../shared/terminal-surface-close-target' +import { retireTerminalSurfacesFromSnapshot } from './mobile-session-terminal-retirement' import type { PtyControllerInventory } from './runtime-pty-controller-contract' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' -import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from './workspace-session-failed-write-rollback' +import { captureAcknowledgedTerminalTabRetirement } from './workspace-session-terminal-tab-retirement-identity' export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRuntimeWithPersistTerminalSurfaceRetirements { // Why: headless serve backs browser panes with offscreen WebContents that live @@ -78,44 +90,166 @@ export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRu return tab ? { color: tab.color, isPinned: tab.isPinned } : null } - protected commitHeadlessTerminalTabRetirement( + protected captureTerminalTabRetirement(worktreeId: string, tabId: string) { + const originalHostId = this.getWorkspaceSessionHostIdForWorktree(worktreeId) + return captureAcknowledgedTerminalTabRetirement(worktreeId, tabId, () => { + const resolvedHostId = this.getWorkspaceSessionHostIdForWorktree(worktreeId) + const resolvedSession = this.store?.getWorkspaceSession?.(resolvedHostId) + // Emptying the last tab may reroute the worktree to its catalog host. + const hostId = resolvedSession?.tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId) + ? resolvedHostId + : originalHostId + return { + hostId, + session: this.store?.getWorkspaceSession?.(hostId) ?? null, + snapshot: this.mobileSessionTabsByWorktree.get(worktreeId), + incarnationOf: (ptyId) => this.ptysById.get(ptyId)?.incarnationId + } + }) + } + + /** + * The one close transaction every explicit terminal close reaches: durably commits the membership + * removal in the owning host's partition. Callers publish and kill afterwards. + */ + protected async closeTerminalSurface( worktreeId: string, - parentTabId: string, - options: { allowMissing?: boolean; force?: boolean } = {} - ): string[] { - const session = this.getWorkspaceSessionForWorktree(worktreeId) - if (!session || !this.store?.setWorkspaceSession || !this.store.flushOrThrow) { + target: TerminalSurfaceCloseTarget, + options: TerminalSurfaceCloseOptions = {} + ): Promise { + const store = this.store + if (!store?.getWorkspaceSession || !store.setWorkspaceSession || !store.runDurableMutation) { throw new Error('workspace_session_unavailable') } - const result = closeTerminalTabInWorkspaceSession(session, worktreeId, parentTabId, { - force: options.force - }) - if (result.pinned) { - throw new Error('terminal_tab_pinned') - } - if (!result.closed) { - if (!options.allowMissing) { - throw new Error('tab_not_found') - } - } - const persisted = result.closed - ? advanceTerminalTopologyRevision(result.session, worktreeId) - : session - this.setWorkspaceSessionForWorktree(worktreeId, persisted) - const staged = this.getWorkspaceSessionForWorktree(worktreeId) + // Why: a tab its layout owner already removed has no newer owner; refusing would only strand it. + const acknowledgeTabRetirement = + target.kind === 'tab' && !options.closedByLayoutOwner + ? this.captureTerminalTabRetirement(worktreeId, target.tabId) + : null + let ptyIdsToKill: string[] = [] + let refusal: Error | undefined try { - this.store.flushOrThrow() + refusal = await store.runDurableMutation( + terminalSurfaceCloseMutation({ + worktreeId, + target, + options, + requestedSession: this.getWorkspaceSessionForWorktree(worktreeId), + ownerMatches: () => !acknowledgeTabRetirement || acknowledgeTabRetirement().matches, + hostId: () => this.getWorkspaceSessionHostIdForWorktree(worktreeId), + getSession: (hostId) => store.getWorkspaceSession(hostId), + setSession: (session, hostId) => store.setWorkspaceSession(session, hostId), + onClosed: (closedPtyIds) => { + ptyIdsToKill = closedPtyIds + } + }) + ) } catch (error) { - const current = this.getWorkspaceSessionForWorktree(worktreeId) - if (staged && current) { - const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(session, staged, current) - if (rolledBack !== current) { - this.setWorkspaceSessionForWorktree(worktreeId, rolledBack) - } + console.error('[runtime] failed to persist terminal close:', error) + } + if (refusal) { + throw refusal + } + return ptyIdsToKill + } + + /** The desktop renderer's close intent: it already guarded, removed and killed; this only reports. */ + async closeTerminalSurfaceFromRenderer({ worktreeId, target, reason }: RendererTerminalClose) { + const options = { allowMissing: true, force: true, closedByLayoutOwner: true, reason } + await this.closeTerminalSurface(worktreeId, target, options) + } + + /** Resolves a close main started against the copy of the tab's panes its layout owner holds. */ + protected resolveTerminalCloseTarget( + worktreeId: string, + target: TerminalSurfaceCloseTarget + ): PaneCloseResolution | 'tab' { + const graphLeafIds: string[] = [] + for (const leaf of this.leaves.values()) { + if (leaf.tabId === target.tabId) { + graphLeafIds.push(leaf.leafId) } - throw error + } + return resolveTerminalCloseTarget(target, { + rendererListsTab: this.tabs.has(target.tabId), + snapshotRows: (this.mobileSessionTabsByWorktree.get(worktreeId)?.tabs ?? []).filter( + (row) => row.type === 'terminal' && row.parentTabId === target.tabId + ), + graphLeafIds, + sessionLayout: + this.getWorkspaceSessionForWorktree(worktreeId)?.terminalLayoutsByTabId?.[target.tabId] + }) + } + + /** + * Commits a split pane's close that main started, then tells the desktop renderer to drop that + * pane. Never touches the tab: a pane the session no longer lists commits nothing. + */ + protected async closeTerminalPane( + worktreeId: string, + target: TerminalPaneCloseTarget + ): Promise { + try { + await this.closeTerminalSurface(worktreeId, target, { allowMissing: true }) + } catch (error) { + if (!(error instanceof Error) || error.message !== 'workspace_session_unavailable') { + throw error + } + } + // Why: no exit may ever arrive to remove the pane. The notice is leaf-addressed, so it and the + // renderer's exit handling are each a no-op after the other. + this.retireClosedTerminalLeafFromMobileSnapshot(worktreeId, target.tabId, target.leafId) + this.notifier?.closeTerminalPane?.(target.tabId, target.leafId) + } + + /** A paired client's close of one pane: stops only that pane's process, commits only that pane. */ + protected async closeMobileSessionTerminalPane( + worktreeId: string, + tab: RuntimeMobileSessionTerminalTab + ): Promise { + // Why best-effort, as for a tab: a failed kill must not keep a pane the user closed. + const pty = this.findPtyForMobileTerminalTab(worktreeId, tab) + if (pty) { + this.ptyController?.kill(pty.ptyId) + } else if (!this.tabs.has(tab.parentTabId) && tab.ptyId && parseAppSshPtyId(tab.ptyId)) { + // Why: with no renderer to own the kill, a dormant SSH pane's durable id is its stop order. + this.ptyController?.kill(tab.ptyId) + } + await this.closeTerminalPane(worktreeId, { + kind: 'pane', + tabId: tab.parentTabId, + leafId: tab.leafId + }) + } + + private retireClosedTerminalLeafFromMobileSnapshot( + worktreeId: string, + tabId: string, + leafId: string + ): void { + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + const tab = snapshot?.tabs.find( + (candidate) => + candidate.type === 'terminal' && + candidate.parentTabId === tabId && + candidate.leafId === leafId + ) + // Why: a renderer that lists the tab republishes its own snapshot once it drops the pane. + if (!snapshot || !tab || this.tabs.has(tabId)) { + return + } + const proof = this.getMobileSessionTerminalRetirementProof(worktreeId, tab) + const retired = retireTerminalSurfacesFromSnapshot({ + snapshot, + ptyId: tab.ptyId ?? tab.parentLayout?.ptyIdsByLeafId?.[leafId] ?? '', + exactSurfaces: [{ parentTabId: tabId, leafId }], + exactOnly: true, + ...(proof ? { retirementProofs: [proof] } : {}) + }) + if (retired) { + this.storeMobileSessionSnapshot(worktreeId, retired.snapshot) + this.notifyMobileSessionTabsChanged(worktreeId) } - return result.ptyIdsToKill } protected persistHeadlessTerminalTabOrder(worktreeId: string, tabOrder: readonly string[]): void { diff --git a/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts b/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts index 159a709fd3c..d7a169d00e1 100644 --- a/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts +++ b/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts @@ -1,7 +1,7 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithGetPtyRecordForPaneKey } from './orca-runtime-get-pty-record-for-pane-key' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' -import type { ResolvedWorktree } from './runtime-worktree-path-identity' +import { runtimeWorktreeIdentityKey, type ResolvedWorktree } from './runtime-worktree-path-identity' import type { RuntimeTerminalRead, RuntimeTerminalSummary } from '../../shared/runtime-types' import { getLatestPtyTitle } from './runtime-worktree-status-projection' import { parsePaneKey } from '../../shared/stable-pane-id' @@ -28,6 +28,14 @@ export class OrcaRuntimeWithBuildPtyTerminalSummary extends OrcaRuntimeWithGetPt const title = getLatestPtyTitle(pty) const pane = parsePaneKey(pty.paneKey ?? '') const orphaned = !ptyHoldsRecordedSurface(pty, this.ptySurfaceTopology()) + // A live process awaiting its pane binding is not evidence of an orphan. + if ( + orphaned && + (this.pendingPtyRegistrationIncarnations.has(pty.ptyId) || + this.terminalMutationLock.hasActiveSpawns(runtimeWorktreeIdentityKey(pty.worktreeId))) + ) { + throw new Error('terminal_surface_ownership_unavailable') + } return { handle: this.issuePtyHandle(pty), ptyId: pty.ptyId, diff --git a/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts b/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts index b326d00ec6c..5ef34314e42 100644 --- a/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts +++ b/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts @@ -11,9 +11,10 @@ import { buildHeadlessMobileSessionTabGroups } from './mobile-session-layout-pro import { appendRetiredTerminalSurfaceProofs } from './mobile-session-terminal-retirement-proof' import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import type { TerminalPaneLayoutNode } from '../../shared/terminal-tab-types' +import type { RuntimeSessionTabCloseReason } from '../../shared/runtime-session-contracts' export class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends OrcaRuntimeWithCloseStructuredAgentSessionTab { - protected closeHeadlessMobileTerminalTab( + protected async closeHeadlessMobileTerminalTab( worktreeId: string, snapshot: RuntimeMobileSessionTabsSnapshot, tab: RuntimeMobileSessionTerminalTab, @@ -22,8 +23,9 @@ export class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends OrcaRuntimeWi killPtys?: boolean authorizedPty?: RuntimePtyWorktreeRecord force?: boolean + reason?: RuntimeSessionTabCloseReason } = {} - ): void { + ): Promise { const closedParentTabId = tab.parentTabId const retirementProofs = snapshot.tabs.flatMap((candidate) => { if (candidate.type !== 'terminal' || candidate.parentTabId !== closedParentTabId) { @@ -36,11 +38,21 @@ export class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends OrcaRuntimeWi ) return proof ? [proof] : [] }) - const projectedPtyIds = this.commitHeadlessTerminalTabRetirement( + const acknowledgeRetirement = this.captureTerminalTabRetirement(worktreeId, closedParentTabId) + const projectedPtyIds = await this.closeTerminalSurface( worktreeId, - closedParentTabId, - { allowMissing: options.allowMissingPersistedTab, force: options.force } + { kind: 'tab', tabId: closedParentTabId }, + { + allowMissing: options.allowMissingPersistedTab, + force: options.force, + reason: options.reason + } ) + if (!acknowledgeRetirement().matches) { + throw new Error('terminal_pane_owner_changed') + } + // Renderer frames may add other tabs while the durable close is in flight. + snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) ?? snapshot this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, closedParentTabId) if (options.authorizedPty) { options.authorizedPty.runtimeSessionOwned = false @@ -172,6 +184,7 @@ export class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends OrcaRuntimeWi tabId: string root: TerminalPaneLayoutNode | null expandedLeafId: string | null + chatLeafId?: string | null titlesByLeafId?: Record } ): Promise<{ updated: true }> { @@ -197,6 +210,7 @@ export class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends OrcaRuntimeWi tabId: hostTabId, root: acceptedLayout.root, expandedLeafId: acceptedLayout.expandedLeafId, + chatLeafId: acceptedLayout.chatLeafId ?? null, ...(acceptedLayout.titlesByLeafId ? { titlesByLeafId: acceptedLayout.titlesByLeafId } : {}) }) } diff --git a/src/main/runtime/orca-runtime-close-mobile-session-tab.ts b/src/main/runtime/orca-runtime-close-mobile-session-tab.ts index 2ef5b77069d..3f7047c6487 100644 --- a/src/main/runtime/orca-runtime-close-mobile-session-tab.ts +++ b/src/main/runtime/orca-runtime-close-mobile-session-tab.ts @@ -18,9 +18,7 @@ import { } from './mobile-session-tab-close-outcome' import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry' import type { RuntimeCommandSurfaceHost } from './orca-runtime-core' -import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' import { SESSION_TAB_NOT_FOUND_ERROR } from '../../shared/session-tab-close' -import { captureAcknowledgedTerminalTabRetirement } from './workspace-session-terminal-tab-retirement-identity' import { rendererPublicationThrottle } from '../window/renderer-publication-throttle' export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseUnattributedMobileSessionTabClose { @@ -120,10 +118,14 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU closedSelectionTabIds ) if (tab.type === 'terminal') { - const parentLeafCount = snapshot.tabs.filter( - (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId - ).length - const closingWholeParent = tab.id !== tabId || parentLeafCount <= 1 + // Why: the wire id carries the intent — `parent::leaf` names a pane, `parent` its tab. + const resolution = this.resolveTerminalCloseTarget( + worktreeId, + tab.id === tabId + ? { kind: 'pane', tabId: tab.parentTabId, leafId: tab.leafId } + : { kind: 'tab', tabId: tab.parentTabId } + ) + const closingWholeParent = resolution === 'tab' || resolution === 'last-pane' if (closingWholeParent) { closedSelectionTabIds = snapshot.tabs.flatMap((candidate) => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId @@ -167,9 +169,10 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU // the relay when no renderer owns the parent: an adopted tab needs the // renderer's live pin guard and durable close transaction. if (closingWholeParent && !this.tabs.has(tab.parentTabId)) { - this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab, { + await this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab, { allowMissingPersistedTab: Boolean(ptyCloseAuthority), force: options.force, + reason: options.reason, killPtys: options.localPtyTeardownOwnedExternally !== true && (options.reason === undefined || options.reason === 'user'), @@ -180,16 +183,7 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU } if (closingWholeParent && this.notifier?.closeTerminalTab) { // The renderer flush can rebase its omission; the host commits the acknowledged identity. - const acknowledgeRetirement = captureAcknowledgedTerminalTabRetirement( - worktreeId, - tab.parentTabId, - () => ({ - hostId: this.getWorkspaceSessionHostIdForWorktree(worktreeId), - session: this.getWorkspaceSessionForWorktree(worktreeId), - snapshot: this.mobileSessionTabsByWorktree.get(worktreeId), - incarnationOf: (ptyId) => this.ptysById.get(ptyId)?.incarnationId - }) - ) + const acknowledgeRetirement = this.captureTerminalTabRetirement(worktreeId, tab.parentTabId) // Wait for the renderer's pin guard, retirement and forced session flush. const win = this.getAvailableAuthoritativeWindow() if (win?.webContents.isDestroyed?.()) { @@ -230,7 +224,7 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU ? this.resolvePtyTabCloseSurfaceAuthority(options.expectedPtyCloseAuthority) : null // Why: after relay recovery the renderer can acknowledge a tab it no longer mirrors; the HUB must still retire its SSH-owned surface. - this.closeHeadlessMobileTerminalTab(worktreeId, remainingSnapshot, remainingTab, { + await this.closeHeadlessMobileTerminalTab(worktreeId, remainingSnapshot, remainingTab, { // Why: the renderer may already have durably removed the tab before acknowledging. allowMissingPersistedTab: true, force: options.force, @@ -238,47 +232,42 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU }) this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId) } else if (retirement.hasPersistedTab) { - this.commitHeadlessTerminalTabRetirement(worktreeId, tab.parentTabId, { - force: options.force - }) + // Why: the renderer's close normally commits this through its own intent; this covers + // a renderer that acknowledged a tab it no longer listed. Missing is fine: that intent's + // durable write can land between this check and this commit. + await this.closeTerminalSurface( + worktreeId, + { kind: 'tab', tabId: tab.parentTabId }, + { allowMissing: true, force: options.force } + ) + } + if (!acknowledgeRetirement().matches) { + this.republishMobileSessionTabsSnapshot(worktreeId) + return refusedMobileSessionTabClose('stale-terminal', { snapshotRepublished: true }) } this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, tab.parentTabId) return finishCommittedClose() } - // Why: notifier implementations without the acknowledged relay may expose - // only raw pane close. Runtime-owned parents still need de-persist + kill. - if (closingWholeParent && this.isRuntimeOwnedHeadlessMobileTab(worktreeId, tab)) { - this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab, { - force: options.force, - ...(ptyCloseAuthority ? { authorizedPty: ptyCloseAuthority.pty } : {}) - }) - this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId) - return finishCommittedClose() - } - if (!this.notifier?.closeTerminal) { - this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab, { - force: options.force, - ...(ptyCloseAuthority ? { authorizedPty: ptyCloseAuthority.pty } : {}) - }) - return finishCommittedClose() - } - if (tab.id === tabId) { - const pty = this.findPtyForMobileTerminalTab(worktreeId, tab) - if (pty) { - if (this.ptyController?.kill(pty.ptyId) !== true) { - throw new Error('terminal_close_failed') - } + if (closingWholeParent) { + // Why: notifier implementations without the acknowledged relay may expose + // only raw pane close. Runtime-owned parents still need de-persist + kill. + if ( + !this.notifier?.closeTerminal || + this.isRuntimeOwnedHeadlessMobileTab(worktreeId, tab) + ) { + await this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab, { + force: options.force, + ...(ptyCloseAuthority ? { authorizedPty: ptyCloseAuthority.pty } : {}) + }) + this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId) return finishCommittedClose() } this.notifier.closeTerminal(tab.parentTabId) + this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, tab.parentTabId) return delegatedMobileSessionTabClose() } - // Why: paired web tab bars represent a split terminal with one local - // parent tab id. Closing that parent should close the desktop tab, not - // just whichever leaf happened to be first in the session snapshot. - this.notifier.closeTerminal(tab.parentTabId) - this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, tab.parentTabId) - return delegatedMobileSessionTabClose() + await this.closeMobileSessionTerminalPane(worktreeId, tab) + return finishCommittedClose() } else if (tab.type === 'browser') { // Why: a browser tab can be hosted by a client, by the offscreen backend, // or by the renderer; each surface owns a different retirement path. @@ -302,10 +291,8 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU } else if (tab.type === 'agent-session') { if (this.notifier?.closeSessionTab) { try { - await this.notifier.closeSessionTab( - structuredAgentSessionTabId(tab.sessionId), - worktreeId - ) + // Why: a reopened chat's window tab id is not derivable from its session; the window maps ours. + await this.notifier.closeSessionTab(tab.id, worktreeId) } catch (error) { // The renderer already having removed the tab is an idempotent close, not a veto. if (!(error instanceof Error && error.message === SESSION_TAB_NOT_FOUND_ERROR)) { diff --git a/src/main/runtime/orca-runtime-cold-restore-terminal-membership.test.ts b/src/main/runtime/orca-runtime-cold-restore-terminal-membership.test.ts new file mode 100644 index 00000000000..321f4d40ae6 --- /dev/null +++ b/src/main/runtime/orca-runtime-cold-restore-terminal-membership.test.ts @@ -0,0 +1,577 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' +import type { + RuntimeMobileSessionSnapshotTab, + RuntimeMobileSessionTabsResult, + RuntimeMobileSessionTabsSnapshot +} from '../../shared/runtime-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { OrcaRuntimeService } from './orca-runtime' +import { setRuntimeDesktopSurface } from './runtime-desktop-surface' +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' + +const WORKTREE_ID = 'repo::/worktree' +const REPO_ID = 'repo' +const LEFT = '11111111-1111-4111-8111-111111111111' +const RIGHT = '22222222-2222-4222-8222-222222222222' +const SPLIT_ROOT = { + type: 'split' as const, + direction: 'vertical' as const, + first: { type: 'leaf' as const, leafId: LEFT }, + second: { type: 'leaf' as const, leafId: RIGHT } +} + +type RestoreHost = { + repo: { + id: string + path: string + displayName: string + badgeColor: string + addedAt: number + connectionId?: string + executionHostId?: ExecutionHostId + } + hostId: ExecutionHostId + connectionId: string | null + ptyIds: { left: string; right: string } + /** An SSH relaunch has not bound its PTY into the saved session yet. */ + persistsPtyBindings: boolean +} + +const LOCAL_HOST: RestoreHost = { + repo: { id: REPO_ID, path: '/worktree', displayName: 'repo', badgeColor: 'blue', addedAt: 1 }, + hostId: LOCAL_EXECUTION_HOST_ID, + connectionId: null, + ptyIds: { left: 'pty-left', right: 'pty-right' }, + persistsPtyBindings: true +} +const SSH_HOST: RestoreHost = { + repo: { ...LOCAL_HOST.repo, connectionId: 'ssh-1' }, + hostId: 'ssh:ssh-1', + connectionId: 'ssh-1', + ptyIds: { left: 'ssh:ssh-1@@pty-left', right: 'ssh:ssh-1@@pty-right' }, + persistsPtyBindings: false +} +const RUNTIME_HOST: RestoreHost = { + ...LOCAL_HOST, + repo: { ...LOCAL_HOST.repo, executionHostId: 'runtime:env-1' }, + hostId: 'runtime:env-1' +} + +// A cold restore: the saved split survives, and an earlier incarnation change left the repo's +// terminal membership host-authoritative, but no PTY has registered yet. +function makeColdRestoredSession(host: RestoreHost): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WORKTREE_ID]: [ + { + id: 'tab', + ptyId: host.persistsPtyBindings ? host.ptyIds.left : null, + worktreeId: WORKTREE_ID, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + tab: { + root: SPLIT_ROOT, + activeLeafId: LEFT, + expandedLeafId: null, + ptyIdsByLeafId: host.persistsPtyBindings + ? { [LEFT]: host.ptyIds.left, [RIGHT]: host.ptyIds.right } + : {} + } + }, + terminalTopologyRevisionByRepoId: { [REPO_ID]: 2 } + } +} + +function makeRendererFrame( + host: RestoreHost, + options: { + version?: number + leaves?: readonly string[] + extraTabs?: readonly RuntimeMobileSessionSnapshotTab[] + } = {} +): RuntimeMobileSessionTabsSnapshot { + const leaves = options.leaves ?? [LEFT, RIGHT] + const ptyIdByLeaf: Record = { + [LEFT]: host.ptyIds.left, + [RIGHT]: host.ptyIds.right + } + const parentLayout = { + root: leaves.length === 2 ? SPLIT_ROOT : { type: 'leaf' as const, leafId: leaves[0] }, + activeLeafId: leaves[0], + expandedLeafId: leaves[0], + ptyIdsByLeafId: Object.fromEntries(leaves.map((leafId) => [leafId, ptyIdByLeaf[leafId]])) + } + const extraTabs = options.extraTabs ?? [] + return { + worktree: WORKTREE_ID, + publicationEpoch: 'renderer', + snapshotVersion: options.version ?? 1, + activeGroupId: 'group', + activeTabId: `tab::${leaves[0]}`, + activeTabType: 'terminal', + tabGroups: [ + { id: 'group', activeTabId: 'tab', tabOrder: ['tab', ...extraTabs.map((tab) => tab.id)] } + ], + tabs: [ + ...leaves.map((leafId, index) => ({ + type: 'terminal' as const, + id: `tab::${leafId}`, + parentTabId: 'tab', + leafId, + ptyId: ptyIdByLeaf[leafId], + title: leafId === LEFT ? 'Left' : 'Right', + parentLayout, + isActive: index === 0 + })), + ...extraTabs + ] + } +} + +const RENDERER_TABS = [ + { + tabId: 'tab', + worktreeId: WORKTREE_ID, + title: 'Terminal', + activeLeafId: LEFT, + layout: SPLIT_ROOT + } +] + +function publishRendererFrame( + runtime: OrcaRuntimeService, + frame: RuntimeMobileSessionTabsSnapshot +): ReturnType { + return runtime.syncWindowGraph(1, { tabs: RENDERER_TABS, leaves: [], mobileSessionTabs: [frame] }) +} + +function coldRestoredRuntime(host: RestoreHost = LOCAL_HOST): { + runtime: OrcaRuntimeService + sessions: Map +} { + const sessions = new Map([ + [host.hostId, makeColdRestoredSession(host)] + ]) + // The desktop window is live, so main must not rebuild the list from the saved session. + const liveWindow = { + isDestroyed: () => false, + webContents: { isDestroyed: () => false, send: () => {} } + } + setRuntimeDesktopSurface({ + showNotification: () => false, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime reads only liveness and send off its authoritative window on these paths. + findWindowById: () => liveWindow as never, + onIpc: () => {}, + removeIpcListener: () => {} + }) + const runtime = new OrcaRuntimeService( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the list, fence, exit and close paths read only repos and the workspace session; the rest of Store is unreached. + withDurableRuntimeStore({ + getRepos: () => [host.repo], + getRepo: (id: string) => (id === REPO_ID ? host.repo : undefined), + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + getSettings: () => ({ workspaceDir: '/tmp/workspaces' }), + getProjects: () => [], + getWorkspaceSessionHostIds: () => [...sessions.keys()], + getWorkspaceSession: (hostId?: ExecutionHostId) => + sessions.get(hostId ?? LOCAL_EXECUTION_HOST_ID) ?? getDefaultWorkspaceSession(), + setWorkspaceSession: (next: WorkspaceSessionState, hostId?: ExecutionHostId) => { + sessions.set(hostId ?? LOCAL_EXECUTION_HOST_ID, next) + }, + flushPendingOrThrowAsync: async () => {}, + // The production store commits asynchronously; a synchronous flush on these paths is a bug. + flushOrThrow: () => { + throw new Error('synchronous flush') + } + }) as never + ) + runtime.attachWindow(1) + return { runtime, sessions } +} + +function registerLeaf(runtime: OrcaRuntimeService, host: RestoreHost, leaf: 'left' | 'right') { + runtime.registerPty(host.ptyIds[leaf], WORKTREE_ID, host.connectionId, { + tabId: 'tab', + leafId: leaf === 'left' ? LEFT : RIGHT, + incarnationId: `incarnation-${leaf}` + }) +} + +function surfaces(result: RuntimeMobileSessionTabsResult | undefined): string[] { + return (result?.tabs ?? []).map((tab) => `${tab.id}:${'status' in tab ? tab.status : ''}`) +} + +async function listedSurfaces(runtime: OrcaRuntimeService): Promise { + return surfaces(await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)) +} + +// The session-tabs notify coalescer's max wait (mobile-session-tabs-notify-coalescer.ts). +const COALESCED_PUSH_MS = 250 + +describe('a restored terminal published before its PTY registers', () => { + afterEach(() => { + setRuntimeDesktopSurface(null) + vi.useRealTimers() + }) + + it.each([ + ['local', LOCAL_HOST], + ['SSH without a saved PTY binding', SSH_HOST], + ['runtime host', RUNTIME_HOST] + ])('%s: is listed pending, then pushed ready when its PTY registers', async (_label, host) => { + vi.useFakeTimers() + const { runtime } = coldRestoredRuntime(host) + publishRendererFrame(runtime, makeRendererFrame(host)) + expect(await listedSurfaces(runtime)).toEqual([ + `tab::${LEFT}:pending-handle`, + `tab::${RIGHT}:pending-handle` + ]) + vi.advanceTimersByTime(COALESCED_PUSH_MS) + const published: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((event) => published.push(event)) + + registerLeaf(runtime, host, 'left') + vi.advanceTimersByTime(COALESCED_PUSH_MS) + + // Registration alone reaches clients: no renderer frame or graph change follows it here. + expect(surfaces(published.at(-1))).toEqual([ + `tab::${LEFT}:ready`, + `tab::${RIGHT}:pending-handle` + ]) + const pushes = published.length + expect( + runtime.syncWindowGraph(1, { + tabs: RENDERER_TABS, + leaves: [], + mobileSessionTabs: [], + unchangedMobileSessionWorktrees: [WORKTREE_ID] + }).mobileSessionResyncWorktrees + ).toBeUndefined() + publishRendererFrame(runtime, makeRendererFrame(host)) + vi.advanceTimersByTime(COALESCED_PUSH_MS) + expect(published).toHaveLength(pushes) + expect(await listedSurfaces(runtime)).toEqual([ + `tab::${LEFT}:ready`, + `tab::${RIGHT}:pending-handle` + ]) + + registerLeaf(runtime, host, 'right') + vi.advanceTimersByTime(COALESCED_PUSH_MS) + + expect(surfaces(published.at(-1))).toEqual([`tab::${LEFT}:ready`, `tab::${RIGHT}:ready`]) + unsubscribe() + }) + + it('pushes a restore of several panes once, not once per registering pane', () => { + vi.useFakeTimers() + const { runtime } = coldRestoredRuntime() + publishRendererFrame(runtime, makeRendererFrame(LOCAL_HOST)) + vi.advanceTimersByTime(COALESCED_PUSH_MS) + const published: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((event) => published.push(event)) + + registerLeaf(runtime, LOCAL_HOST, 'left') + registerLeaf(runtime, LOCAL_HOST, 'right') + vi.advanceTimersByTime(COALESCED_PUSH_MS) + + expect(published.map(surfaces)).toEqual([[`tab::${LEFT}:ready`, `tab::${RIGHT}:ready`]]) + unsubscribe() + }) + + it('keeps listing a restored pane whose PTY never returns as pending', async () => { + const { runtime } = coldRestoredRuntime() + publishRendererFrame(runtime, makeRendererFrame(LOCAL_HOST)) + + registerLeaf(runtime, LOCAL_HOST, 'left') + runtime.registerPty('pty-other', WORKTREE_ID, null, { + tabId: 'tab-other', + leafId: RIGHT, + incarnationId: 'incarnation-other' + }) + + expect(await listedSurfaces(runtime)).toEqual([ + `tab::${LEFT}:ready`, + `tab::${RIGHT}:pending-handle` + ]) + }) +}) + +describe('a retired restored terminal stays out of a later renderer frame', () => { + afterEach(() => setRuntimeDesktopSurface(null)) + + it('after its process exits and the retirement is saved', async () => { + const { runtime, sessions } = coldRestoredRuntime() + registerLeaf(runtime, LOCAL_HOST, 'left') + publishRendererFrame(runtime, makeRendererFrame(LOCAL_HOST)) + expect(await listedSurfaces(runtime)).toContain(`tab::${LEFT}:ready`) + + await runtime.onPtyExit('pty-left', 0, 'incarnation-left') + + expect(sessions.get(LOCAL_EXECUTION_HOST_ID)?.terminalLayoutsByTabId.tab?.root).toEqual({ + type: 'leaf', + leafId: RIGHT + }) + // A lagging renderer still lists the exited pane, at a newer version. + publishRendererFrame(runtime, makeRendererFrame(LOCAL_HOST, { version: 2 })) + expect(await listedSurfaces(runtime)).not.toContain(`tab::${LEFT}:ready`) + expect(await listedSurfaces(runtime)).not.toContain(`tab::${LEFT}:pending-handle`) + }) + + it('after its last pane exits from a runtime-host partition an older copy still lists', async () => { + const { runtime, sessions } = coldRestoredRuntime(RUNTIME_HOST) + // Left behind in another partition before the catalog owner rotated. + sessions.set(LOCAL_EXECUTION_HOST_ID, makeColdRestoredSession(RUNTIME_HOST)) + registerLeaf(runtime, RUNTIME_HOST, 'left') + registerLeaf(runtime, RUNTIME_HOST, 'right') + publishRendererFrame(runtime, makeRendererFrame(RUNTIME_HOST)) + + await runtime.onPtyExit('pty-left', 0, 'incarnation-left') + await runtime.onPtyExit('pty-right', 0, 'incarnation-right') + // Emptying the owner's partition re-routes reads to the older copy. + expect(sessions.get(RUNTIME_HOST.hostId)?.tabsByWorktree[WORKTREE_ID]).toEqual([]) + + publishRendererFrame(runtime, makeRendererFrame(RUNTIME_HOST, { version: 2 })) + expect(await listedSurfaces(runtime)).toEqual([]) + }) + + it('after the user closes it on the desktop', async () => { + const { runtime, sessions } = coldRestoredRuntime() + registerLeaf(runtime, LOCAL_HOST, 'left') + registerLeaf(runtime, LOCAL_HOST, 'right') + publishRendererFrame(runtime, makeRendererFrame(LOCAL_HOST)) + expect(await listedSurfaces(runtime)).toEqual([`tab::${LEFT}:ready`, `tab::${RIGHT}:ready`]) + + publishRendererFrame(runtime, makeRendererFrame(LOCAL_HOST, { version: 2, leaves: [LEFT] })) + await runtime.onPtyExit('pty-right', 0, 'incarnation-right') + expect(sessions.get(LOCAL_EXECUTION_HOST_ID)?.terminalLayoutsByTabId.tab?.root).toEqual({ + type: 'leaf', + leafId: LEFT + }) + + publishRendererFrame(runtime, makeRendererFrame(LOCAL_HOST, { version: 3 })) + expect(await listedSurfaces(runtime)).toEqual([`tab::${LEFT}:ready`]) + }) +}) + +// An SSH worktree: the phone closes a tab while a relaunched sibling tab has not registered yet. +const SSH_PTY_X = 'ssh:ssh-1@@pty-x' +const SSH_PTY_Y = 'ssh:ssh-1@@pty-y' + +function makeSshSession(tabIds: readonly ('tab-x' | 'tab-y')[]): WorkspaceSessionState { + // tab-x's relaunched PTY has not bound yet, so only tab-y saves a relay binding. + const specs = { + 'tab-x': { leafId: LEFT, ptyId: null }, + 'tab-y': { leafId: RIGHT, ptyId: SSH_PTY_Y } + } + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WORKTREE_ID]: tabIds.map((id, index) => ({ + id, + ptyId: specs[id].ptyId, + worktreeId: WORKTREE_ID, + title: id, + customTitle: null, + color: null, + sortOrder: index, + createdAt: index + 1 + })) + }, + terminalLayoutsByTabId: Object.fromEntries( + tabIds.map((id) => [ + id, + { + root: { type: 'leaf' as const, leafId: specs[id].leafId }, + activeLeafId: specs[id].leafId, + expandedLeafId: null, + ptyIdsByLeafId: specs[id].ptyId ? { [specs[id].leafId]: specs[id].ptyId } : {} + } + ]) + ), + terminalTopologyRevisionByRepoId: { [REPO_ID]: 2 } + } +} + +function publishSshRendererFrame(runtime: OrcaRuntimeService): void { + const tabs = [ + { tabId: 'tab-x', leafId: LEFT, ptyId: SSH_PTY_X }, + { tabId: 'tab-y', leafId: RIGHT, ptyId: SSH_PTY_Y } + ] + runtime.syncWindowGraph(1, { + tabs: tabs.map(({ tabId, leafId }) => ({ + tabId, + worktreeId: WORKTREE_ID, + title: tabId, + activeLeafId: leafId, + layout: { type: 'leaf' as const, leafId } + })), + leaves: [], + mobileSessionTabs: [ + { + worktree: WORKTREE_ID, + publicationEpoch: 'renderer', + snapshotVersion: 1, + activeGroupId: 'group', + activeTabId: `tab-y::${RIGHT}`, + activeTabType: 'terminal', + tabGroups: [{ id: 'group', activeTabId: 'tab-y', tabOrder: ['tab-x', 'tab-y'] }], + tabs: tabs.map(({ tabId, leafId, ptyId }) => ({ + type: 'terminal' as const, + id: `${tabId}::${leafId}`, + parentTabId: tabId, + leafId, + ptyId, + title: tabId, + isActive: tabId === 'tab-y' + })) + } + ] + }) +} + +describe('a surface the host retired stays retired when a restored sibling registers', () => { + afterEach(() => setRuntimeDesktopSurface(null)) + + it('keeps a phone-closed SSH terminal closed while its remote PTY is still exiting', async () => { + const { runtime, sessions } = coldRestoredRuntime(SSH_HOST) + sessions.set(SSH_HOST.hostId, makeSshSession(['tab-x', 'tab-y'])) + const kill = vi.fn(() => true) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the close path uses only these two relays. + runtime.setNotifier({ + closeTerminal: vi.fn(), + // The renderer durably retires the tab and acks; its pruned frame is still in flight. + closeTerminalTab: vi.fn(async () => { + sessions.set(SSH_HOST.hostId, makeSshSession(['tab-x'])) + }) + } as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the close path kills and inventories only; the remote kill lands asynchronously. + runtime.setPtyController({ + write: () => true, + kill, + listProcesses: vi.fn(async () => + [SSH_PTY_X, SSH_PTY_Y].map((id) => ({ id, cwd: '/worktree', title: 'shell' })) + ), + getForegroundProcess: async () => null + } as never) + publishSshRendererFrame(runtime) + runtime.registerPty(SSH_PTY_Y, WORKTREE_ID, 'ssh-1', { + tabId: 'tab-y', + leafId: RIGHT, + incarnationId: 'incarnation-y' + }) + expect(await listedSurfaces(runtime)).toContain(`tab-y::${RIGHT}:ready`) + + await runtime.closeMobileSessionTab(`id:${WORKTREE_ID}`, 'tab-y', { reason: 'user' }) + expect(kill).toHaveBeenCalledWith(SSH_PTY_Y) + runtime.registerPty(SSH_PTY_X, WORKTREE_ID, 'ssh-1', { + tabId: 'tab-x', + leafId: LEFT, + incarnationId: 'incarnation-x' + }) + + expect(await listedSurfaces(runtime)).not.toContain(`tab-y::${RIGHT}:ready`) + }) + + it('keeps a phone-closed chat tab closed', async () => { + const { runtime } = coldRestoredRuntime() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the chat close path uses only this relay. + runtime.setNotifier({ closeSessionTab: vi.fn(async () => {}) } as never) + publishRendererFrame( + runtime, + makeRendererFrame(LOCAL_HOST, { + extraTabs: [ + { + type: 'agent-session', + id: 'chat', + title: 'Chat', + sessionId: 'session-chat', + agent: 'claude', + isActive: false + } + ] + }) + ) + expect(await listedSurfaces(runtime)).toContain('chat:') + + await runtime.closeMobileSessionTab(`id:${WORKTREE_ID}`, 'chat', { reason: 'user' }) + registerLeaf(runtime, LOCAL_HOST, 'left') + + expect(await listedSurfaces(runtime)).not.toContain('chat:') + }) +}) + +// A phone creates a terminal while the desktop's frame still lacks it: the spawn registered and the +// host published the tab itself, as it does for a create the desktop has not published yet. +const PHONE_LEAF = '33333333-3333-4333-8333-333333333333' + +async function runtimeWithUnpublishedPhoneCreate(): Promise { + const { runtime } = coldRestoredRuntime() + const handlers = new Map void>() + const webContents = { + isDestroyed: () => false, + setBackgroundThrottling: () => {}, + send: (channel: string, payload: { requestId: string }) => { + if (channel !== 'terminal:requestTabCreate') { + return + } + runtime.registerPty('pty-phone', WORKTREE_ID, null, { + tabId: 'tab-phone', + leafId: PHONE_LEAF, + incarnationId: 'incarnation-phone' + }) + handlers.get('terminal:tabCreateReply')?.( + { sender: webContents }, + { requestId: payload.requestId, tabId: 'tab-phone', title: 'Terminal' } + ) + } + } + setRuntimeDesktopSurface({ + showNotification: () => false, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the create path reads only liveness and these webContents members off its authoritative window. + findWindowById: () => ({ isDestroyed: () => false, webContents }) as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the tab-create reply channel is registered on this path. + onIpc: (channel, listener) => handlers.set(channel, listener as never), + removeIpcListener: (channel) => handlers.delete(channel) + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the create path focuses only when activating, which this create does not. + runtime.setNotifier({ focusTerminal: vi.fn() } as never) + Object.assign(runtime, { + resolveTerminalWorkspaceLaunchScope: vi.fn(async () => ({ + id: WORKTREE_ID, + path: '/worktree', + connectionId: null, + repo: LOCAL_HOST.repo, + folderWorkspace: null + })) + }) + publishRendererFrame(runtime, makeRendererFrame(LOCAL_HOST)) + await runtime.createMobileSessionTerminal(`id:${WORKTREE_ID}`, { + activate: false, + clientNavigationId: 'phone' + }) + return runtime +} + +describe('a surface the host added stays listed when a restored sibling registers', () => { + afterEach(() => setRuntimeDesktopSurface(null)) + + it('keeps a phone-created terminal listed', async () => { + const runtime = await runtimeWithUnpublishedPhoneCreate() + expect(await listedSurfaces(runtime)).toContain(`tab-phone::${PHONE_LEAF}:ready`) + + registerLeaf(runtime, LOCAL_HOST, 'left') + + expect(await listedSurfaces(runtime)).toContain(`tab-phone::${PHONE_LEAF}:ready`) + }) +}) diff --git a/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts b/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts index 391b55ac5b0..3c9c8b7eab9 100644 --- a/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts +++ b/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts @@ -3,6 +3,7 @@ import { OrcaRuntimeWithResolveTerminalPane } from './orca-runtime-resolve-termi import { PROVEN_ABSENT_LEAF_PTY_TTL_MS } from './orca-runtime-core' import { pruneExpiredProvenAbsentLeafPtyVerdicts } from './proven-absent-leaf-pty-verdicts' import type { RuntimeTerminalSend } from '../../shared/runtime-types' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' import type { RuntimeAgentPromptWriteOptions } from './runtime-terminal-contracts' import { assertTerminalInputWithinLimitWithYield, @@ -103,7 +104,8 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso reserveWrite?: (ptyId: string) => void afterWrite?: (ptyId: string) => void | Promise suffixFailureError?: string - } = {} + inputKind: TerminalInputKind + } ): Promise { const pty = this.getLivePtyForHandle(handle) if (pty) { @@ -152,7 +154,7 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso async sendTerminalAgentPrompt( handle: string, prompt: string, - options: RuntimeAgentPromptWriteOptions = {} + options: RuntimeAgentPromptWriteOptions ): Promise { // Why the consuming agent: the foreground process reads the bytes; launchAgent covers startup. const payloadFor = (ptyId: string): string => { diff --git a/src/main/runtime/orca-runtime-create-agent-session.ts b/src/main/runtime/orca-runtime-create-agent-session.ts index 6c5d11b8349..36e4d3f41a1 100644 --- a/src/main/runtime/orca-runtime-create-agent-session.ts +++ b/src/main/runtime/orca-runtime-create-agent-session.ts @@ -27,6 +27,7 @@ import { deterministicAgentSessionUuid, isAgentSessionOperationOutcomeUnknown } from './runtime-agent-launch-resolution' +import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSessionExecutionNamespace { async createAgentSession( @@ -169,7 +170,6 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe if (!startup) { throw new Error('agent_session_identity_required') } - await this.markWorkspaceTrustedForAgent(request.agent, workspace.connectionId, workspace.path) if (caller.signal?.aborted) { throw new Error('client_disconnected') } @@ -201,6 +201,8 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe launchAgent: request.agent, terminalKittyKeyboardProtocol: request.terminalKittyKeyboardProtocol, startupCommandDelivery: startup.startupCommandDelivery, + // A fresh agent this host built; the request has no surface field, so it counts as `unknown`. + telemetry: agentStartedTelemetry(request.agent, undefined), cwd: startupCwd, presentation: request.presentation ?? 'background', tabId: operationTabId, diff --git a/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts b/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts index 5d2cea95b5e..a62527e7071 100644 --- a/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-remote-worktree.ts @@ -28,8 +28,6 @@ export class OrcaRuntimeWithCreateManagedRemoteWorktree extends OrcaRuntimeWithC return createRuntimeRemoteManagedWorktree(repo, args, { store: this.store, canSpawn: () => Boolean(this.ptyController?.spawn), - markTrusted: (agent, connectionId, path) => - this.markRemoteWorkspaceTrustedForAgent(agent, connectionId, path), createTerminal: (selector, options) => this.createTerminal(selector, options), pasteDraft: (handle, draft) => this.pasteStartupDraftWhenReady(handle, draft), sendFollowup: (handle, followup) => this.sendStartupFollowupWhenReady(handle, followup), diff --git a/src/main/runtime/orca-runtime-create-managed-worktree.ts b/src/main/runtime/orca-runtime-create-managed-worktree.ts index 79c0b612c05..e7fb519239a 100644 --- a/src/main/runtime/orca-runtime-create-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-worktree.ts @@ -68,7 +68,12 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork : null const draftStartup = !args.startup && !agentStartup && args.startupDraft - ? await this.buildStartupForDraft(repo, args.startupDraft, requestedAgent) + ? await this.buildStartupForDraft( + repo, + args.startupDraft, + requestedAgent, + args.startupLaunchSource + ) : null const effectiveStartup = args.startup ?? agentStartup?.startup ?? draftStartup?.startup const effectiveStartupFollowup = agentStartup?.followup @@ -83,12 +88,8 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork // an `executionHostId: 'ssh:*'`-only repo down the local path, which runs `git worktree add` on // the client against a remote path. const createRoute = resolveWorktreeCreateRoute(repo) - // `null` on a `runtime:` host is deliberate: its nested target is addressable only inside that - // environment, so the trust write must not go to a same-named target in this client's table. - const sshConnectionId = createRoute.kind === 'ssh' ? createRoute.connectionId : null if (isFolderRepo(repo)) { - // A folder workspace is a registration, not a filesystem create, so it is host-agnostic — - // except for the agent trust write, which must land on the host that will run the agent. + // A folder workspace is a registration, not a filesystem create, so it is host-agnostic. return createRuntimeFolderWorktree({ request: args, repo, @@ -100,8 +101,6 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork store: this.store, ptySpawnAvailable: Boolean(this.ptyController?.spawn), createTerminal: (selector, options) => this.createTerminal(selector, options), - markTrusted: (agent, path) => - this.markWorkspaceTrustedForAgent(agent, sshConnectionId, path), pasteDraft: (handle, draft) => this.pasteStartupDraftWhenReady(handle, draft), sendFollowup: (handle, followup) => this.sendStartupFollowupWhenReady(handle, followup), invalidateResolvedWorktrees: () => this.invalidateResolvedWorktreeCache(), @@ -238,7 +237,6 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork warning, ports: { canSpawn: Boolean(this.ptyController?.spawn), - markTrusted: (agent, path) => this.markLocalWorkspaceTrustedForAgent(agent, path), createTerminal: (selector, options) => this.createTerminal(selector, options), pasteDraft: (handle, draft) => this.pasteStartupDraftWhenReady(handle, draft), sendFollowup: (handle, followup) => this.sendStartupFollowupWhenReady(handle, followup), diff --git a/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts b/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts index b9f35478505..4ebeed55f90 100644 --- a/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts +++ b/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts @@ -6,6 +6,7 @@ import { shouldForwardHeadlessTerminalQueryReply } from './headless-terminal-que import { isNativeWindowsConptyPty } from './terminal-model-query-authority' import { getTerminalViewAttributes } from './terminal-view-attribute-store' import { PtyShellOwnershipMirror } from './pty-shell-ownership-mirror' +import { PROCESS_BOUNDARY_GROUND } from '../../shared/terminal-mode-reset-profiles' export class OrcaRuntimeWithCreatePtyHeadlessTerminalState extends OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer { /** Shared factory for the per-PTY runtime emulators (seed, hydration, and @@ -45,7 +46,7 @@ export class OrcaRuntimeWithCreatePtyHeadlessTerminalState extends OrcaRuntimeWi // pending and flushes at the ready marker or the 15s // SHELL_READY_TIMEOUT_MS bound (session.ts) — a spawn-time query // reply is delayed at most that bound, not lost. - this.ptyController?.write(ptyId, reply) + this.ptyController?.write(ptyId, reply, 'query-reply') } } }) @@ -195,4 +196,21 @@ export class OrcaRuntimeWithCreatePtyHeadlessTerminalState extends OrcaRuntimeWi state.writeChain = state.writeChain.then(() => state.emulator.clearScrollback()) await state.writeChain } + + // Public: Reset Terminal must ground this model too; park/reveal and mobile restore from it. + async resetHeadlessTerminalInputModes(ptyId: string): Promise { + // Why now, not on the chain: onPtyData scans live bytes into these on arrival. + // Focus is outside their model, so the plain ground is exact. + this.scanProviderModeTrackers(ptyId, PROCESS_BOUNDARY_GROUND) + const state = this.headlessTerminals.get(ptyId) + if (!state) { + return + } + // Why on the chain: the ground must land after every PTY chunk already queued. + const completion = state.writeChain.then(async () => { + await state.emulator.write(state.ownership.groundInputModes()) + }) + state.writeChain = completion.catch(() => {}) + await completion + } } diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index c366b6e57ef..e95431dd51c 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -124,6 +124,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate } let result: Awaited>> try { + launchOpts.onPtySpawnDispatched?.() result = await this.ptyController.spawn({ cols: 120, rows: 40, @@ -292,6 +293,8 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate releaseStablePaneCreate() } } + // The renderer owns this spawn, so this process cannot see when it is requested. + opts.onPtySpawnDispatched?.() return createDesktopTerminal(this, worktreeSelector, opts, presentation, rendererWindow) } } diff --git a/src/main/runtime/orca-runtime-deliver-pending-messages.ts b/src/main/runtime/orca-runtime-deliver-pending-messages.ts index 55c6a8dad5e..2cd1cc69c00 100644 --- a/src/main/runtime/orca-runtime-deliver-pending-messages.ts +++ b/src/main/runtime/orca-runtime-deliver-pending-messages.ts @@ -137,7 +137,7 @@ export class OrcaRuntimeWithDeliverPendingMessages extends OrcaRuntimeWithResolv let settlesInEnterCallback = false try { const payload = formatMessagePointer(unread.length, mailboxHandle) - const wrote = this.ptyController?.write(deliveryPtyId, payload) ?? false + const wrote = this.ptyController?.write(deliveryPtyId, payload, 'driving') ?? false if (!wrote) { return } @@ -171,7 +171,7 @@ export class OrcaRuntimeWithDeliverPendingMessages extends OrcaRuntimeWithResolv if (!currentLeaf || currentLeaf.ptyId !== deliveryPtyId || !currentLeaf.writable) { return } - this.ptyController?.write(deliveryPtyId, '\r') + this.ptyController?.write(deliveryPtyId, '\r', 'driving') } catch { // Terminal may have closed during the delay; mail remains queued for check. } finally { diff --git a/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts b/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts index 635d14e60d3..cc112a78ade 100644 --- a/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts +++ b/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts @@ -51,6 +51,7 @@ export class OrcaRuntimeWithEmitDaemonPtyTransientFact extends OrcaRuntimeWithSc const pty = this.getOrCreatePtyWorktreeRecord(ptyId) if (pty) { pty.tailPendingAnsi = '' + pty.commandPaint = undefined } for (const leaf of this.getLeavesForPty(ptyId)) { leaf.tailPendingAnsi = '' diff --git a/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts b/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts index a15be97f8a4..7f968a6ba7d 100644 --- a/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts +++ b/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts @@ -144,7 +144,6 @@ export class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends OrcaRuntim if (!startup) { throw new Error('agent_session_identity_required') } - await this.markWorkspaceTrustedForAgent(request.agent, workspace.connectionId, workspace.path) if (_caller.signal?.aborted) { throw new Error('client_disconnected') } diff --git a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts index d61374c3466..2e52e6f0748 100644 --- a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts +++ b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts @@ -16,12 +16,13 @@ import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtim import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { resolveTerminalOrchestrationCliCommand, + runtimeOrchestrationCliCommand, type OrchestrationCliCommand } from './orchestration/cli-command' -import { getAppEnvironment } from '../../shared/app-environment' import type { FleetAgentStatusEvidence } from '../../shared/orchestration-fleet-agent-status-evidence' import { readOrchestrationFleetAgentStatusSnapshot } from './orchestration-fleet-agent-status-snapshot' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' +import { matchesProcessIncarnation } from './orchestration/worker-terminal-process-liveness' export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntimeWithVerifyOrchestrationCompatibilityCaller { /** Every pane key this PTY could be addressed by, including restored receipts. */ @@ -263,11 +264,44 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim connectionId: pty.connectionId, isWsl: pty.isWsl, worktreeId: pty.worktreeId, - // Dev builds run the CLI as `orca-dev`; a packaged app must not advertise it. - runtimeCliCommand: getAppEnvironment().isPackaged() ? undefined : 'orca-dev', + runtimeCliCommand: runtimeOrchestrationCliCommand(), projectRuntime: this.store ? resolveLocalProjectRuntimeForWorktreeId(this.requireStore(), pty.worktreeId) : undefined }) } + /** + * Recover a live terminal handle for a worker whose durable handle stopped resolving + * (renderer graph epoch bump / handle invalidation) while its PTY is still tracked. Fences on + * the recorded process incarnation EXACTLY — never a bare ptyId, worktree, or pane — so a + * reused ptyId belonging to a different process can never be closed, and fails closed on an + * unknown host scope (this is also consumed by workerShow, which does no lease re-check). + * Returns a freshly minted live handle, or null when no live PTY carries that exact incarnation. + */ + resolveTerminalHandleByProcessIncarnation( + processIncarnation: string, + serializedHostScope: string | null + ): string | null { + if (!processIncarnation || !serializedHostScope) { + return null + } + // Scan by the incarnation itself (startsWith + exact equality, mirroring + // classifyWorkerTerminalProcessIncarnation) rather than splitting on a colon, so relay/SSH + // ptyIds and colon-bearing incarnationIds still match. A pty with no incarnationId can never + // match, so the legacy `${runtimeId}:${ptyId}:${ptyGeneration}` fence stays fail-closed. + for (const [ptyId, pty] of this.ptysById) { + if (!matchesProcessIncarnation(ptyId, pty.incarnationId, processIncarnation)) { + continue + } + const hostScope = this.getOrchestrationCompatibilityHostScope(pty) + if (!hostScope || JSON.stringify(hostScope) !== serializedHostScope) { + // Keep scanning: a colon-ambiguous decoy pty in a different host scope that this + // incarnation string happens to prefix-match must not suppress the genuine same-scope + // pty later in ptysById. The scope check still fences the real match below. + continue + } + return this.issuePtyHandle(pty) + } + return null + } } diff --git a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts index 56455551349..5906ee91866 100644 --- a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts +++ b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts @@ -7,6 +7,13 @@ import { recognizeAgentProcess } from '../../shared/agent-process-recognition' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' import { structuredWorkerIdentities } from './structured-worker-identity' import type { StructuredPointerTarget } from './orchestration/structured-mailbox-pointer-delivery' +import { + handleLessCoordinatorSessionId, + structuredSessionAddressTarget, + structuredSessionMailTarget, + structuredSessionIdleEdgeMailboxes, + structuredWorkerMailSessionId +} from './orchestration/structured-session-mail-target' import { resolveTerminalIdentityFromProbes, type RuntimeTerminalIdentity @@ -187,6 +194,32 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM this.orchestrationStructuredMailboxPointerDelivery.onJournalActivity(sessionId) } + /** + * Every structured session's status change reaches here. At its idle edge, retry what is parked + * on it and re-derive the mailboxes it owns, so mail it could not take earlier (mid-turn, closed) + * is pointed again. Workers and chats alike: this is not per-dispatch. + */ + onStructuredSessionStatusForMail(summary: { + sessionId: string + status: 'working' | 'attention' | 'idle' | null + }): void { + if (summary.status === 'working' || summary.status === 'attention') { + return + } + // Logged, never thrown: the same status callback goes on to the first-turn workspace rename. + try { + this.notifyStructuredSessionJournalActivity(summary.sessionId) + const openDb = () => this.getExistingOrchestrationDb() + const deliver = (mailbox: string) => this.deliverPendingMessagesForHandle(mailbox) + structuredSessionIdleEdgeMailboxes(summary.sessionId, openDb).forEach(deliver) + } catch (error) { + console.warn('[orchestration] structured session mail redrive failed', { + sessionId: summary.sessionId, + error: error instanceof Error ? error.message : String(error) + }) + } + } + /** Settlement drops anything parked for the session; nothing will ever redrive it again. */ forgetStructuredSessionMail(sessionId: string): void { this.orchestrationStructuredMailboxPointerDelivery.forgetSession(sessionId) @@ -205,6 +238,10 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM if (mailboxHandle.startsWith('run:')) { return this.resolveStructuredCoordinatorMailboxTarget(mailboxHandle.slice('run:'.length)) } + const addressed = structuredSessionAddressTarget(mailboxHandle, this._orchestrationDb) + if (addressed !== undefined) { + return addressed + } if (!mailboxHandle.startsWith('dispatch:')) { return this.resolveStructuredWorkerDirectMailboxTarget(mailboxHandle) } @@ -213,8 +250,8 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM if (!assignee) { return null } - const identity = resolveStructuredWorkerAuthority(assignee, this._orchestrationDb)?.identity - return identity ? { sessionId: identity.sessionId, dispatchId } : null + const sessionId = this.liveStructuredWorkerSessionId(assignee) + return sessionId ? { sessionId, dispatchId } : null } /** @@ -228,12 +265,17 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM protected resolveStructuredCoordinatorMailboxTarget( runId: string ): StructuredPointerTarget | null { - const coordinator = this._orchestrationDb?.getRun?.(runId)?.coordinator_handle + const run = this._orchestrationDb?.getRun?.(runId) + const sessionId = run ? handleLessCoordinatorSessionId(run) : null + if (sessionId) { + return structuredSessionMailTarget(sessionId, this._orchestrationDb) + } + const coordinator = run?.coordinator_handle if (!coordinator) { return null } - const identity = resolveStructuredWorkerAuthority(coordinator, this._orchestrationDb)?.identity - return identity ? { sessionId: identity.sessionId, dispatchId: null } : null + const workerSessionId = this.liveStructuredWorkerSessionId(coordinator) + return workerSessionId ? { sessionId: workerSessionId, dispatchId: null } : null } /** @@ -246,7 +288,7 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM * The worker's ACTIVE dispatch is preferred when it has one, so peer and coordinator nudges share * one operation-ledger budget and one set of retain rules. A worker BETWEEN dispatches is still * nudged, under a session-scoped budget: the mail is durable, the session is live, and a dispatch - * says nothing about whether delivery is safe — the idle gate and the lease fence do that. + * says nothing about whether delivery is safe — the idle gate and the writer lease do that. */ protected resolveStructuredWorkerDirectMailboxTarget( handle: string @@ -255,11 +297,19 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM // Answers null for anything that is not a live structured worker of THIS runtime, so `run:` // and PTY handles fall through to the PTY lane exactly as before. const identity = resolveStructuredWorkerAuthority(handle, db)?.identity - if (!identity) { + const sessionId = identity ? structuredWorkerMailSessionId(identity.sessionId) : null + if (!identity || !sessionId) { return null } const dispatchId = db?.findActiveDispatchForAssignee?.(handle, identity.paneKey)?.id ?? null - return { sessionId: identity.sessionId, dispatchId } + return { sessionId, dispatchId } + } + + /** The live session behind a structured worker handle of this runtime; see + * `structuredWorkerMailSessionId`. */ + private liveStructuredWorkerSessionId(handle: string): string | null { + const identity = resolveStructuredWorkerAuthority(handle, this._orchestrationDb)?.identity + return identity ? structuredWorkerMailSessionId(identity.sessionId) : null } protected scheduleRestoredMessageRepoints(): void { diff --git a/src/main/runtime/orca-runtime-get-runtime-id.ts b/src/main/runtime/orca-runtime-get-runtime-id.ts index a56825a4a85..ee7fe60e85e 100644 --- a/src/main/runtime/orca-runtime-get-runtime-id.ts +++ b/src/main/runtime/orca-runtime-get-runtime-id.ts @@ -180,6 +180,10 @@ export class OrcaRuntimeWithGetRuntimeId extends OrcaRuntimeWithHasExactPersiste return this.workspaceSessions.get(worktreeId) } + protected getOwnWorkspaceSessionForWorktree(worktreeId: string): WorkspaceSessionState | null { + return this.workspaceSessions.getOwnPartition(worktreeId) + } + protected setWorkspaceSessionForWorktree( worktreeId: string, session: WorkspaceSessionState diff --git a/src/main/runtime/orca-runtime-get-status.ts b/src/main/runtime/orca-runtime-get-status.ts index 4d2219e3e56..39b8dbf0dcf 100644 --- a/src/main/runtime/orca-runtime-get-status.ts +++ b/src/main/runtime/orca-runtime-get-status.ts @@ -15,6 +15,7 @@ import { RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, + TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY, TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../shared/protocol-version' import { @@ -46,6 +47,17 @@ type RuntimeStatusHost = { ): string[] } +function supportsDurableTerminalPromptDelivery(): boolean { + if (typeof process.getBuiltinModule !== 'function') { + return false + } + try { + return process.getBuiltinModule('node:sqlite') !== undefined + } catch { + return false + } +} + export class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntimeId { private asRuntimeStatusHost(): RuntimeStatusHost { return this as unknown as RuntimeStatusHost @@ -76,7 +88,9 @@ export class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntimeId { (process.env.ORCA_E2E_DISABLE_PAIRED_TERMINAL_PARKING !== '1' || capability !== TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY) && (process.env.ORCA_E2E_DISABLE_AUTHORITATIVE_SESSION_TABS_INVENTORY !== '1' || - capability !== SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY) + capability !== SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY) && + (capability !== TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY || + supportsDurableTerminalPromptDelivery()) ) if (hasOffscreen || hasHeadlessCommands) { capabilities.push(BROWSER_HEADLESS_RUNTIME_CAPABILITY) diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index f2143b9772c..e2f1bd34d45 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -1,4 +1,5 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { OrcaRuntimeWithGetWorktreePs } from './orca-runtime-get-worktree-ps' import { supportsCodexStructuredLocation } from '../codex/codex-structured-location-support' import { supportsClaudeStructuredLocation } from '../claude/claude-structured-location-support' @@ -21,6 +22,7 @@ import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentS import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { parseWslUncPath } from '../../shared/wsl-paths' import { parseWorkspaceKey } from '../../shared/workspace-scope' +import { applyStructuredCodexWorkspaceTrust } from '../agent-workspace-trust-spawn' export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaRuntimeWithGetWorktreePs { async getStructuredAgentSessionCreateSupport( @@ -92,13 +94,17 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR }) ) } - return this.resolveStructuredAgentSessionIntent(input, ({ workspacePath, launchEnv }) => - resolveStructuredCodexAccountHomePath({ + return this.resolveStructuredAgentSessionIntent(input, async ({ launchEnv }) => { + await applyStructuredCodexWorkspaceTrust({ + workspacePath: (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path, launchEnv, - resolveLaunchHome: this.prepareCodexStructuredLaunchFn, - workspacePath + settings: this.requireStore().getSettings() }) - ) + return resolveStructuredCodexAccountHomePath({ + launchEnv, + resolveLaunchHome: this.prepareCodexStructuredLaunchFn + }) + }) } /** @@ -129,8 +135,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR // must not sync homes, start bridges, or clear an account selection. path: await resolveStructuredCodexAccountHomePath({ launchEnv, - resolveLaunchHome: this.resolveCodexStructuredLaunchHomeFn, - workspacePath: '' + resolveLaunchHome: this.resolveCodexStructuredLaunchHomeFn }) } } @@ -144,7 +149,6 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR resumeFrom?: { providerSessionId: string } }, resolveAccountHomePath: (context: { - workspacePath: string launchEnv: NodeJS.ProcessEnv location: { executionHostId: string @@ -156,7 +160,9 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR ): Promise { const support = await this.getStructuredAgentSessionCreateSupport(input.worktree, input.agent) if (!support.supported) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) } const settings = this.requireStore().getSettings() const launchEnv = resolveTuiAgentLaunchEnv(input.agent, settings.agentDefaultEnv) @@ -165,7 +171,6 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR input.agent ) const location = await this.resolveStructuredAgentSessionLocation(input.worktree) - const workspacePath = (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path const host = getStructuredAgentSessionHost() const committedReplay = resolveCommittedStructuredAgentSessionAdoptionIntent({ host, @@ -176,11 +181,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR if (committedReplay) { return committedReplay } - const selectedAccountHomePath = await resolveAccountHomePath({ - workspacePath, - launchEnv, - location - }) + const selectedAccountHomePath = await resolveAccountHomePath({ launchEnv, location }) // Adopting pins the account home to wherever the conversation actually lives, which is not // necessarily the one a fresh create would pick: Codex resolves its rollout under // `accountHome.path`, and Claude reads its transcript under `/projects`. Resuming under diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index cf7e8e56db1..adcd47baad3 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -19,6 +19,7 @@ import { firstWorkRenameDeps } from '../agent-hooks/first-work-rename-runtime' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { buildWorktreeListingPage } from './worktree-listing-host-scope' +import { structuredWorkerOwesWork } from './structured-worker-custody' import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' import { nativeChatShellEnvironmentPolicy } from '../../shared/native-chat-shell-environment' import { claudeStructuredPermissionModeForSettings } from '../claude/claude-structured-permission-mode' @@ -165,13 +166,18 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis // Structured chat has no agent CLI hooks, so this projection is what the first-work // workspace rename listens to instead of `agentStatus:set`. onSessionStatusChanged: (summary, options) => { + this.onStructuredSessionStatusForMail(summary) void maybeAutoRenameWorkspaceOnFirstStructuredTurn( summary, options, firstWorkRenameDeps(this.requireStore(), this) ) }, - ...(this.structuredAgentStatusSinkFn ? { statusSink: this.structuredAgentStatusSinkFn } : {}) + ...(this.structuredAgentStatusSinkFn ? { statusSink: this.structuredAgentStatusSinkFn } : {}), + // Read per sweep tick from the orchestration database: a worker whose dispatch is open keeps + // its agent running. No database answers no. + hasOpenDispatch: (record) => + structuredWorkerOwesWork(this.getOrchestrationDbIfAvailable?.() ?? null, record) }) } } diff --git a/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts b/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts index d9d6b2acfb9..7f42304eb8b 100644 --- a/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts +++ b/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts @@ -36,7 +36,7 @@ export class OrcaRuntimeWithGetWorktreeTerminalProvisioningHost extends OrcaRunt this.ptyController!.hasChildProcesses?.(ptyId) ?? Promise.resolve(false), subscribeToData: (ptyId, listener) => this.subscribeToTerminalData(ptyId, listener), readRecentOutput: (ptyId) => this.recentPtyOutputById.get(ptyId)?.read(), - write: (ptyId, data) => this.ptyController?.write(ptyId, data) + write: (ptyId, data, inputKind) => this.ptyController?.write(ptyId, data, inputKind) } } diff --git a/src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts b/src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts index d0425cdc1f9..7aea8a0d0d7 100644 --- a/src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts +++ b/src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts @@ -5,6 +5,8 @@ import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity' import { makePaneKey } from '../../shared/stable-pane-id' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { layoutContainsLeafId } from '../persistence/restoring-sessions/terminal-layout-normalization' import type { LegacyWorkerTerminalRecoveryPlan } from './orchestration/orchestration-legacy-worker-terminal-recovery' import { retireTerminalSurfacesFromSnapshot } from './mobile-session-terminal-retirement' import type { @@ -27,6 +29,35 @@ import type { } from '../../shared/artifacts' export class OrcaRuntimeWithHasExactPersistedTerminalSurfaceIdentity extends OrcaRuntimeWithAutomationOperations { + /** The host's saved session, when it still lists `tabId` under this worktree. */ + protected getPersistedSessionListingTerminalTab( + worktreeId: string, + tabId: string, + session = this.getWorkspaceSessionForWorktree(worktreeId) + ): WorkspaceSessionState | null { + const sessionWorktreeId = session ? resolveTerminalSessionWorktreeId(session, worktreeId) : null + return session && + sessionWorktreeId && + session.tabsByWorktree[sessionWorktreeId]?.some((candidate) => candidate.id === tabId) + ? session + : null + } + + protected hasPersistedTerminalSurfaceMembership( + worktreeId: string, + tabId: string, + leafId: string + ): boolean { + // Why own partition: emptying a rotated runtime partition re-routes reads to an older copy + // that can still list a surface retirement just removed. + const layout = this.getPersistedSessionListingTerminalTab( + worktreeId, + tabId, + this.getOwnWorkspaceSessionForWorktree(worktreeId) + )?.terminalLayoutsByTabId[tabId] + return Boolean(layout && layoutContainsLeafId(layout.root, leafId)) + } + protected hasExactPersistedTerminalSurfaceIdentity(expected: { worktreeId: string tabId: string @@ -34,19 +65,10 @@ export class OrcaRuntimeWithHasExactPersistedTerminalSurfaceIdentity extends Orc ptyId: string incarnationId: string }): boolean { - const session = this.getWorkspaceSessionForWorktree(expected.worktreeId) - const sessionWorktreeId = session - ? resolveTerminalSessionWorktreeId(session, expected.worktreeId) - : null - if (!session || !sessionWorktreeId) { - return false - } - const tab = session.tabsByWorktree[sessionWorktreeId]?.find( - (candidate) => candidate.id === expected.tabId - ) + const session = this.getPersistedSessionListingTerminalTab(expected.worktreeId, expected.tabId) const paneKey = makePaneKey(expected.tabId, expected.leafId) return Boolean( - tab && + session && session.terminalLayoutsByTabId[expected.tabId]?.ptyIdsByLeafId?.[expected.leafId] === expected.ptyId && session.terminalPtyIncarnationsByPaneKey?.[paneKey] === expected.incarnationId diff --git a/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts b/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts index 63135f261b7..3e8d618e8a4 100644 --- a/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts +++ b/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts @@ -1,5 +1,7 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. +import { splitFreebuffScreenUpdates } from '../../shared/freebuff-screen-status' import { OrcaRuntimeWithSerializeMainTerminalBuffer } from './orca-runtime-serialize-main-terminal-buffer' +import { observeFreebuffTerminalStatus } from './freebuff-terminal-status' import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' import { detectAgentStatusFromTitle, normalizeTerminalTitle } from '../../shared/agent-detection' import { shouldModelAnswerHiddenPtyQueries } from './terminal-model-query-authority' @@ -171,7 +173,26 @@ export class OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer extends OrcaRuntime // Why inside the chain: the ownership mirror must observe live bytes in // the same total order as seeds (seedOwner also runs on this chain). state.ownership.scan(data) - await state.emulator.write(data, { forwardQueryReplies }) + for (const chunk of splitFreebuffScreenUpdates(data, state.emulator.partialEscapeTailAnsi)) { + await state.emulator.write(chunk, { forwardQueryReplies }) + const pty = this.ptysById.get(ptyId) + if (pty && !pty.connectionId && this.headlessTerminals.get(ptyId) === state) { + const payload = observeFreebuffTerminalStatus( + state.emulator, + chunk, + this.terminalSpawnCommandsByPtyId.get(ptyId), + pty.launchAgent + ) + if (payload) { + pty.lastExplicitAgentStatus = { state: payload.state, updatedAt: Date.now() } + this.emitTerminalAgentStatusEvents(ptyId, { + cleanData: '', + payloads: [payload], + lastPayloadCleanOffset: null + }) + } + } + } state.outputSequence = outputSequence }) // Legacy callers remain best-effort; bounded SSH admission observes the raw receipt. diff --git a/src/main/runtime/orca-runtime-mobile-close-preserved-resurrection.test.ts b/src/main/runtime/orca-runtime-mobile-close-preserved-resurrection.test.ts index 5780b353944..4260c8213ef 100644 --- a/src/main/runtime/orca-runtime-mobile-close-preserved-resurrection.test.ts +++ b/src/main/runtime/orca-runtime-mobile-close-preserved-resurrection.test.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' /** * STA-4593 incident: closing paired-remote tabs "worked briefly" but the tabs * returned seconds later and after workspace switches, on a host whose PTYs @@ -103,7 +104,7 @@ function createHarness() { badgeColor: '#000000', addedAt: 1 } - const store = { + const store = withDurableRuntimeStore({ getRepos: () => [repo], getRepo: (id: string) => (id === REPO_ID ? repo : undefined), getAllWorktreeMeta: () => ({}), @@ -115,7 +116,7 @@ function createHarness() { session = next }, flushOrThrow: () => {} - } + }) const relayAck = makeDeferred() const closeTerminal = vi.fn() const closeTerminalTab = vi.fn(() => relayAck.promise) @@ -277,7 +278,7 @@ function createSplitHarness() { badgeColor: '#000000', addedAt: 1 } - const store = { + const store = withDurableRuntimeStore({ getRepos: () => [repo], getRepo: (id: string) => (id === REPO_ID ? repo : undefined), getAllWorktreeMeta: () => ({}), @@ -289,7 +290,7 @@ function createSplitHarness() { session = next }, flushOrThrow: () => {} - } + }) const runtime = new OrcaRuntimeService(store as never) runtime.setNotifier({ closeTerminal: vi.fn(), closeTerminalTab: vi.fn(async () => {}) } as never) runtime.setPtyController({ diff --git a/src/main/runtime/orca-runtime-on-pty-data.ts b/src/main/runtime/orca-runtime-on-pty-data.ts index 160c99f2071..52c1fb32551 100644 --- a/src/main/runtime/orca-runtime-on-pty-data.ts +++ b/src/main/runtime/orca-runtime-on-pty-data.ts @@ -4,6 +4,7 @@ import type { TerminalOutputSourceRange } from '../../shared/terminal-output-sou import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' import { appendNormalizedToTailBuffer } from './terminal-tail-buffer' import { normalizeTerminalChunk } from './terminal-ansi-normalization' +import { observeTerminalCommandPaint } from './terminal-command-paint' import { appendCompletedTerminalTranscript, buildPreview, @@ -16,6 +17,14 @@ import { import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail' export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecutionContext { + /** Arrival-order mode scan: the settled tracker plus any in-flight snapshot capture's. */ + protected scanProviderModeTrackers(ptyId: string, data: string): void { + this.providerModeTrackersByPtyId.get(ptyId)?.scan(data) + for (const tracker of this.providerModeSnapshotScansByPtyId.get(ptyId) ?? []) { + tracker.scan(data) + } + } + onPtyData( ptyId: string, data: string, @@ -27,10 +36,7 @@ export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecution ): number { const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + sequenceChars this.ptyOutputSequenceById.set(ptyId, outputSequence) - this.providerModeTrackersByPtyId.get(ptyId)?.scan(data) - for (const tracker of this.providerModeSnapshotScansByPtyId.get(ptyId) ?? []) { - tracker.scan(data) - } + this.scanProviderModeTrackers(ptyId, data) const osc7Metadata = this.recordOsc7MetadataForPty(ptyId, data) const cwd = osc7Metadata.cwd const cwdChanged = osc7Metadata.cwdChanged @@ -86,6 +92,7 @@ export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecution pty.lastOutputAt = at const normalized = normalizeTerminalChunk(data, pty.tailPendingAnsi) pty.tailPendingAnsi = normalized.pendingAnsi + observeTerminalCommandPaint(pty, data, normalized.text) const nextTail = appendNormalizedToTailBuffer( pty.tailBuffer, pty.tailPartialLine, diff --git a/src/main/runtime/orca-runtime-on-pty-exit.ts b/src/main/runtime/orca-runtime-on-pty-exit.ts index 588a834d6e6..abcf2fcf57f 100644 --- a/src/main/runtime/orca-runtime-on-pty-exit.ts +++ b/src/main/runtime/orca-runtime-on-pty-exit.ts @@ -24,7 +24,7 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte * as -1, so the numeric code alone cannot tell a dead process from a failed stop. */ providerExitObserved?: boolean } = {} - ): void { + ): void | Promise { const pty = this.ptysById.get(ptyId) if (exitIncarnationId && pty?.incarnationId && exitIncarnationId !== pty.incarnationId) { return @@ -68,161 +68,172 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte pty?.incarnationId ?? `runtime:${this.runtimeId}:${this.getPtyLifecycleGeneration(ptyId)}` this.advancePtyLifecycleGeneration(ptyId) - this.notifyPtyExitListeners(ptyId) - const exactSurfaceByKey = new Map< - string, - Pick - >() - for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { - for (const tab of snapshot.tabs) { - if (tab.type === 'terminal' && tab.ptyId === ptyId) { - exactSurfaceByKey.set(`${worktreeId}\0${tab.parentTabId}\0${tab.leafId}`, { - worktreeId, - parentTabId: tab.parentTabId, - leafId: tab.leafId - }) + let retirement: Promise | undefined + try { + const exactSurfaceByKey = new Map< + string, + Pick + >() + for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { + for (const tab of snapshot.tabs) { + if ( + tab.type === 'terminal' && + (tab.ptyId === ptyId || tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] === ptyId) + ) { + exactSurfaceByKey.set(`${worktreeId}\0${tab.parentTabId}\0${tab.leafId}`, { + worktreeId, + parentTabId: tab.parentTabId, + leafId: tab.leafId + }) + } } } - } - for (const leaf of this.getLeavesForPty(ptyId)) { - exactSurfaceByKey.set(`${leaf.worktreeId}\0${leaf.tabId}\0${leaf.leafId}`, { - worktreeId: leaf.worktreeId, - parentTabId: leaf.tabId, - leafId: leaf.leafId - }) - } - const parsedPaneKey = parsePaneKey(pty?.paneKey ?? '') - if (pty?.tabId && parsedPaneKey) { - exactSurfaceByKey.set(`${pty.worktreeId}\0${pty.tabId}\0${parsedPaneKey.leafId}`, { - worktreeId: pty.worktreeId, - parentTabId: pty.tabId, - leafId: parsedPaneKey.leafId - }) - } - const exactSurfaces = [...exactSurfaceByKey.values()] - const pendingIncarnation = this.pendingPtyRegistrationIncarnations.get(ptyId) - const exitMatchesPendingRegistration = - this.pendingPtyRegistrationIncarnations.has(ptyId) && - (pendingIncarnation === null || - exitIncarnationId === null || - exitIncarnationId === undefined || - pendingIncarnation === exitIncarnationId) - if (exitMatchesPendingRegistration) { - // Why: reused surfaces can look registered while their replacement incarnation still awaits admission. - this.earlyExitedPtyIncarnations.set( - ptyId, - exitIncarnationId ?? pendingIncarnation ?? pty?.incarnationId ?? null - ) - } - const intentionalStopIncarnation = this.intentionalHandlelessPtyStops.get(ptyId) - const preservesIntentionalHandlelessSurface = - this.intentionalHandlelessPtyStops.has(ptyId) && - (intentionalStopIncarnation === null || intentionalStopIncarnation === incarnationId) - advertisedUrlWatcher.unbindPty(ptyId) - // Clean up new mobile state for this PTY - this.mobileSubscribers.delete(ptyId) - this.terminalViewSubscribers.clearSubscribers(ptyId) - this.mobileDisplayModes.delete(ptyId) - this.resizeListeners.delete(ptyId) - this.lastRendererSizes.delete(ptyId) - this.recentPtyOutputById.delete(ptyId) - this.setupCompletionTokenByPtyId.delete(ptyId) - this.clearWaitBlockedCheckState(ptyId) - this.recentPtyPathCandidatesById.delete(ptyId) - this.ptyOutputSequenceById.delete(ptyId) - this.providerSequenceInitializedPtys.delete(ptyId) - this.providerSequenceOffsetByPtyId.delete(ptyId) - this.providerSnapshotPreferredPtys.delete(ptyId) - this.providerModeTrackersByPtyId.delete(ptyId) - this.providerModeSnapshotScansByPtyId.delete(ptyId) - this.providerBufferAcquisitionsByPtyId.delete(ptyId) - this.providerVisibleStateByPtyId.delete(ptyId) - this.providerVisibleRetryAtByPtyId.delete(ptyId) - this.agentPromptExplicitStatusFloorByPtyId.delete(ptyId) - // Safe against respawn: `getPtyLifecycleGeneration` lazily mints from the - // monotonic `nextPtyLifecycleGeneration`, so a re-read after this delete - // returns a strictly newer number — never a reused one. Every comparison a - // stale frame makes therefore still fails, exactly as the advance above intends. - this.ptyLifecycleGenerationById.delete(ptyId) - this.agentStatusOscProcessorsByPtyId.delete(ptyId) - this.terminalSpawnCommandsByPtyId.delete(ptyId) - this.disposePtyTitleTracker(ptyId) - this.oscTitleScanTailByPtyId.delete(ptyId) - this.osc7ScanTailByPtyId.delete(ptyId) - this.terminalCwdByPtyId.delete(ptyId) - this.terminalFileUriHostnameByPtyId.delete(ptyId) - this.wslDistroByPtyId.delete(ptyId) - // Why: a Claude agent-team leader whose PTY exits naturally (agent finished, - // process died, renderer reload) must release its team + nested panes map. - // Previously only explicit closeTerminal evicted it, so natural exits leaked - // one team per never-reused teamId for the runtime's lifetime. - const exitedTeamLeaderHandle = this.handleByPtyId.get(ptyId) - if (exitedTeamLeaderHandle) { - this.claudeAgentTeams.removeTeamForLeaderHandle(exitedTeamLeaderHandle) - } - // Layout state machine: clear `layouts` and `layoutQueues`. Any - // already-queued applyLayout work for this ptyId will run, but every - // applyLayout re-checks `layouts.has(ptyId)` (or fresh-subscribe) and - // short-circuits with `pty-exited`. - this.layouts.delete(ptyId) - this.layoutQueues.delete(ptyId) - this.freshSubscribeGuard.delete(ptyId) - this.cancelPendingDriverMutations(ptyId) - // Why: a cold restore can respawn under the same session id within the - // delayed-Enter window; the armed Enter would inject \r into the - // replacement and stamp rows it never received. - this.orchestrationMailboxNotifications.retirePty(ptyId) - // Why: the dead pty's terminal handle and any run bound to its panes still carry mailbox - // pointers; schedule a debounced repoint so they do not stay aimed at a retired session. - for (const leaf of this.getLeavesForPty(ptyId)) { - const mailboxHandle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) - if (mailboxHandle) { - this.mailPointerRepointScheduler.schedule(mailboxHandle) + for (const leaf of this.getLeavesForPty(ptyId)) { + exactSurfaceByKey.set(`${leaf.worktreeId}\0${leaf.tabId}\0${leaf.leafId}`, { + worktreeId: leaf.worktreeId, + parentTabId: leaf.tabId, + leafId: leaf.leafId + }) } - const boundRun = this._orchestrationDb?.getCurrentRunForPane?.(`${leaf.tabId}:${leaf.leafId}`) - if (boundRun) { - this.mailPointerRepointScheduler.schedule(`run:${boundRun.id}`) + const parsedPaneKey = parsePaneKey(pty?.paneKey ?? '') + if (pty?.tabId && parsedPaneKey) { + exactSurfaceByKey.set(`${pty.worktreeId}\0${pty.tabId}\0${parsedPaneKey.leafId}`, { + worktreeId: pty.worktreeId, + parentTabId: pty.tabId, + leafId: parsedPaneKey.leafId + }) + } + const exactSurfaces = [...exactSurfaceByKey.values()] + const pendingIncarnation = this.pendingPtyRegistrationIncarnations.get(ptyId) + const exitMatchesPendingRegistration = + this.pendingPtyRegistrationIncarnations.has(ptyId) && + (pendingIncarnation === null || + exitIncarnationId === null || + exitIncarnationId === undefined || + pendingIncarnation === exitIncarnationId) + if (exitMatchesPendingRegistration) { + // Why: reused surfaces can look registered while their replacement incarnation still awaits admission. + this.earlyExitedPtyIncarnations.set( + ptyId, + exitIncarnationId ?? pendingIncarnation ?? pty?.incarnationId ?? null + ) + } + // Why both kinds: a sleep keeps its wake hint, and a restart's replacement takes the pane. + const preservesIntentionallyStoppedSurface = + this.intentionalPtyStops.claimExit(ptyId, exitIncarnationId ?? pty?.incarnationId).length > + 0 + advertisedUrlWatcher.unbindPty(ptyId) + // Clean up new mobile state for this PTY + this.mobileSubscribers.delete(ptyId) + this.terminalViewSubscribers.clearSubscribers(ptyId) + this.mobileDisplayModes.delete(ptyId) + this.resizeListeners.delete(ptyId) + this.lastRendererSizes.delete(ptyId) + this.recentPtyOutputById.delete(ptyId) + this.setupCompletionTokenByPtyId.delete(ptyId) + this.clearWaitBlockedCheckState(ptyId) + this.recentPtyPathCandidatesById.delete(ptyId) + this.ptyOutputSequenceById.delete(ptyId) + this.providerSequenceInitializedPtys.delete(ptyId) + this.providerSequenceOffsetByPtyId.delete(ptyId) + this.providerSnapshotPreferredPtys.delete(ptyId) + this.providerModeTrackersByPtyId.delete(ptyId) + this.providerModeSnapshotScansByPtyId.delete(ptyId) + this.providerBufferAcquisitionsByPtyId.delete(ptyId) + this.providerVisibleStateByPtyId.delete(ptyId) + this.providerVisibleRetryAtByPtyId.delete(ptyId) + this.agentPromptExplicitStatusFloorByPtyId.delete(ptyId) + this.ptyLifecycleGenerationById.delete(ptyId) + this.agentStatusOscProcessorsByPtyId.delete(ptyId) + this.terminalSpawnCommandsByPtyId.delete(ptyId) + this.disposePtyTitleTracker(ptyId) + this.oscTitleScanTailByPtyId.delete(ptyId) + this.osc7ScanTailByPtyId.delete(ptyId) + this.terminalCwdByPtyId.delete(ptyId) + this.terminalFileUriHostnameByPtyId.delete(ptyId) + this.wslDistroByPtyId.delete(ptyId) + // Why: a Claude agent-team leader whose PTY exits naturally (agent finished, + // process died, renderer reload) must release its team + nested panes map. + // Previously only explicit closeTerminal evicted it, so natural exits leaked + // one team per never-reused teamId for the runtime's lifetime. + const exitedTeamLeaderHandle = this.handleByPtyId.get(ptyId) + if (exitedTeamLeaderHandle) { + this.claudeAgentTeams.removeTeamForLeaderHandle(exitedTeamLeaderHandle) + } + // Layout state machine: clear `layouts` and `layoutQueues`. Any + // already-queued applyLayout work for this ptyId will run, but every + // applyLayout re-checks `layouts.has(ptyId)` (or fresh-subscribe) and + // short-circuits with `pty-exited`. + this.layouts.delete(ptyId) + this.layoutQueues.delete(ptyId) + this.freshSubscribeGuard.delete(ptyId) + this.cancelPendingDriverMutations(ptyId) + // Why: a cold restore can respawn under the same session id within the + // delayed-Enter window; the armed Enter would inject \r into the + // replacement and stamp rows it never received. + this.orchestrationMailboxNotifications.retirePty(ptyId) + // Why: the dead pty's terminal handle and any run bound to its panes still carry mailbox + // pointers; schedule a debounced repoint so they do not stay aimed at a retired session. + for (const leaf of this.getLeavesForPty(ptyId)) { + const mailboxHandle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) + if (mailboxHandle) { + this.mailPointerRepointScheduler.schedule(mailboxHandle) + } + const boundRun = this._orchestrationDb?.getCurrentRunForPane?.( + `${leaf.tabId}:${leaf.leafId}` + ) + if (boundRun) { + this.mailPointerRepointScheduler.schedule(`run:${boundRun.id}`) + } } - } - if (this.terminalFitOverrides.has(ptyId)) { - this.terminalFitOverrides.delete(ptyId) - this.notifier?.terminalFitOverrideChanged(ptyId, 'desktop-fit', 0, 0) - this.notifyFitOverrideListeners(ptyId, 'desktop-fit', 0, 0) - } - // Why: clear driver state and notify the renderer so any lock banner on - // this dead pane unmounts. Without this, the pane shows a stuck banner - // until tab teardown, and `getDriver(deadPtyId)` would keep returning a - // stale `mobile{X}` to any caller that hasn't yet seen the exit IPC. - this.terminalDrivers.clear(ptyId) - this.remoteDesktopFloor.clearPty(ptyId) - this.disposeHeadlessTerminal(ptyId) - if (processDeathCertified) { - // The bounded verdict register also fences late graphs after the PTY record was pruned. - this.rememberPtyLivenessVerdict(ptyId, { status: 'exited' }) - } - if (pty) { - pty.connected = false - pty.runtimeSessionOwned = false - this.setPairedRendererSessionOwnership(pty.ptyId, false) - pty.disconnectedAt = Date.now() - pty.lastExitCode = exitCode - pty.lastExitCause = exitCause - // Why: the exited process's live frames say nothing about a replacement. - // A same-id respawn makes the leaf writable again before any new title, - // so leaving this true would let push delivery type into the new process - // on the dead one's idle. lastAgentStatus itself stays for `ps` display. - pty.lastAgentStatusObservedLive = false - this.resolvePtyExitWaiters(pty, ptyId) - this.pruneDisconnectedPtyTranscript(pty) - } - if (preservesIntentionalHandlelessSurface || preservesAbnormalSshSurface) { - // Why: relay loss is recoverable; keep the HUB-owned pane addressable through the bounded reconnect grace. - this.touchMobileSessionSnapshotsForPty(ptyId, { immediate: true }) - } else { - // Why: permanent process exit is absence, not a starting/sleeping tab. - // Retire before publishing so paired clients never persist a ghost. - this.retireMobileSessionSurfacesForPty(ptyId, incarnationId, exactSurfaces) + if (this.terminalFitOverrides.has(ptyId)) { + this.terminalFitOverrides.delete(ptyId) + this.notifier?.terminalFitOverrideChanged(ptyId, 'desktop-fit', 0, 0) + this.notifyFitOverrideListeners(ptyId, 'desktop-fit', 0, 0) + } + // Why: clear driver state and notify the renderer so any lock banner on + // this dead pane unmounts. Without this, the pane shows a stuck banner + // until tab teardown, and `getDriver(deadPtyId)` would keep returning a + // stale `mobile{X}` to any caller that hasn't yet seen the exit IPC. + this.terminalDrivers.clear(ptyId) + this.remoteDesktopFloor.clearPty(ptyId) + this.disposeHeadlessTerminal(ptyId) + if (processDeathCertified) { + // The bounded verdict register also fences late graphs after the PTY record was pruned. + this.rememberPtyLivenessVerdict(ptyId, { status: 'exited' }) + } + if (pty) { + pty.connected = false + pty.runtimeSessionOwned = false + this.setPairedRendererSessionOwnership(pty.ptyId, false) + pty.disconnectedAt = Date.now() + pty.lastExitCode = exitCode + pty.lastExitCause = exitCause + // Why: the exited process's live frames say nothing about a replacement. + // A same-id respawn makes the leaf writable again before any new title, + // so leaving this true would let push delivery type into the new process + // on the dead one's idle. lastAgentStatus itself stays for `ps` display. + pty.lastAgentStatusObservedLive = false + this.resolvePtyExitWaiters(pty, ptyId) + this.pruneDisconnectedPtyTranscript(pty) + } + if (preservesIntentionallyStoppedSurface || preservesAbnormalSshSurface) { + // Why: relay loss is recoverable; keep the HUB-owned pane addressable through the bounded reconnect grace. + this.touchMobileSessionSnapshotsForPty(ptyId, { immediate: true }) + } else { + // Why: permanent process exit is absence, not a starting/sleeping tab. + // Retire before publishing so paired clients never persist a ghost. + try { + retirement = this.retireMobileSessionSurfacesForPty(ptyId, incarnationId, exactSurfaces) + } catch (error) { + console.error('[runtime] failed to publish terminal retirement:', error) + } + } + } finally { + // Why last: a stream end cues clients to re-activate the pane it ended, so the retirement + // must precede it; a cleanup fault must still end the stream. + this.notifyPtyExitListeners(ptyId) } const exitedSurfaces: { handle: string; paneKey: string | null }[] = [] @@ -253,6 +264,7 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte } } this.pruneDisconnectedPtyRecords() + return retirement } private notifyPtyExitListeners(ptyId: string): void { diff --git a/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts b/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts index 10489c6c701..7c21c025654 100644 --- a/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts +++ b/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts @@ -112,6 +112,7 @@ export class OrcaRuntimeWithPersistHeadlessSessionTabProps extends OrcaRuntimeWi tabId: string root: TerminalPaneLayoutNode | null expandedLeafId: string | null + chatLeafId?: string | null titlesByLeafId?: Record } ): TerminalLayoutSnapshot | undefined { @@ -131,6 +132,7 @@ export class OrcaRuntimeWithPersistHeadlessSessionTabProps extends OrcaRuntimeWi ...cloneTerminalLayoutSnapshot(existing), root: args.root ?? existing.root, expandedLeafId: args.expandedLeafId, + ...(args.chatLeafId !== undefined ? { chatLeafId: args.chatLeafId ?? undefined } : {}), ...(args.titlesByLeafId ? { titlesByLeafId: args.titlesByLeafId } : {}) } } @@ -149,6 +151,7 @@ export class OrcaRuntimeWithPersistHeadlessSessionTabProps extends OrcaRuntimeWi tabId: string root: TerminalPaneLayoutNode | null expandedLeafId: string | null + chatLeafId?: string | null titlesByLeafId?: Record } ): void { @@ -168,6 +171,7 @@ export class OrcaRuntimeWithPersistHeadlessSessionTabProps extends OrcaRuntimeWi ...tab.parentLayout, root: args.root ?? tab.parentLayout.root, expandedLeafId: args.expandedLeafId, + ...(args.chatLeafId !== undefined ? { chatLeafId: args.chatLeafId ?? undefined } : {}), ...(args.titlesByLeafId ? { titlesByLeafId: args.titlesByLeafId } : {}) } } diff --git a/src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts b/src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts index 6b9887d2cf8..5458543815a 100644 --- a/src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts +++ b/src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts @@ -1,109 +1,69 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithTouchMobileSessionTabsForWorktree } from './orca-runtime-touch-mobile-session-tabs-for-worktree' import type { RetiredTerminalSurface } from './mobile-session-terminal-retirement' -import type { ExecutionHostId } from '../../shared/execution-host' import type { RuntimeMobileSessionRetiredTerminalSurface } from '../../shared/runtime-types' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement' import { retireTerminalSurfacesFromSnapshot } from './mobile-session-terminal-retirement' import { attachRetirementProofsToSnapshot } from './mobile-session-terminal-retirement-proof' -import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from './workspace-session-failed-write-rollback' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' export class OrcaRuntimeWithPersistTerminalSurfaceRetirements extends OrcaRuntimeWithTouchMobileSessionTabsForWorktree { /** - * Retires each surface in the session partition of the host that owns its worktree. + * Retires each surface in the in-memory session partition of the host that owns its worktree. * Why: an SSH pane's durable surface lives in that connection's partition; retiring it * against the local partition strands the real ghost and bumps a foreign host's epoch. - * Returns null when nothing may be published because persistence is unavailable or failed. + * `accepted` held the surface; `unpersisted` had no partition to hold it (or refused the write). */ - protected persistTerminalSurfaceRetirements( - retiredSurfaces: readonly RetiredTerminalSurface[] - ): { accepted: RetiredTerminalSurface[]; unpersisted: RetiredTerminalSurface[] } | null { - const surfacesByHostId = new Map() + protected stageTerminalSurfaceRetirements(retiredSurfaces: readonly RetiredTerminalSurface[]): { + accepted: RetiredTerminalSurface[] + unpersisted: RetiredTerminalSurface[] + } { + const accepted: RetiredTerminalSurface[] = [] + const unpersisted: RetiredTerminalSurface[] = [] for (const surface of retiredSurfaces) { const hostId = this.tryGetWorkspaceSessionHostIdForWorktree(surface.worktreeId) ?? LOCAL_EXECUTION_HOST_ID - const bucket = surfacesByHostId.get(hostId) - if (bucket) { - bucket.push(surface) - } else { - surfacesByHostId.set(hostId, [surface]) - } - } - const accepted: RetiredTerminalSurface[] = [] - const unpersisted: RetiredTerminalSurface[] = [] - const pendingWrites: { hostId: ExecutionHostId; session: WorkspaceSessionState }[] = [] - const originalSessions = new Map() - const stagedSessions = new Map() - for (const [hostId, surfaces] of surfacesByHostId) { - const session = this.store?.getWorkspaceSession?.(hostId) - if (!session) { - unpersisted.push(...surfaces) + const current = this.store?.getWorkspaceSession?.(hostId) + if (!current) { + unpersisted.push(surface) continue } - // Why: publishing absence before its host membership fence is durable lets a crash or - // stale renderer write resurrect the retired surface. - if (!this.store?.setWorkspaceSession || !this.store.flushOrThrow) { - return null - } - originalSessions.set(hostId, session) - let nextSession = session - const acceptedForHost: RetiredTerminalSurface[] = [] - for (const surface of surfaces) { - const candidate = retireTerminalSurfaceFromPersistence(nextSession, surface) - if (candidate !== nextSession) { - acceptedForHost.push(surface) - nextSession = candidate - } - } - if (acceptedForHost.length === 0) { + const next = retireTerminalSurfaceFromPersistence(current, surface) + if (next === current) { continue } - accepted.push(...acceptedForHost) - pendingWrites.push({ hostId, session: nextSession }) - } - if (pendingWrites.length > 0) { try { - for (const write of pendingWrites) { - this.store?.setWorkspaceSession?.(write.session, write.hostId) - const staged = this.store?.getWorkspaceSession?.(write.hostId) - if (staged) { - stagedSessions.set(write.hostId, staged) - } - } - this.store?.flushOrThrow?.() + this.store.setWorkspaceSession(next, hostId) + accepted.push(surface) } catch (error) { - // setWorkspaceSession mutates the in-memory partition before the flush. Restore only - // fields still equal to our staged write so concurrent renderer updates survive. - for (const [hostId, original] of originalSessions) { - const staged = stagedSessions.get(hostId) - const current = this.store?.getWorkspaceSession?.(hostId) - if (!staged || !current) { - continue - } - const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite( - original, - staged, - current - ) - if (rolledBack !== current) { - this.store?.setWorkspaceSession?.(rolledBack, hostId) - } - } - console.error('[runtime] failed to persist terminal retirement:', error) - return null + // Why: the process is gone whether or not the profile admits the write (quit, maintenance). + console.error('[runtime] could not stage terminal retirement:', error) + unpersisted.push(surface) } } return { accepted, unpersisted } } + // Why no rollback: the process is gone, so a failed write leaves the retirement for the next one. + protected async persistStagedTerminalSurfaceRetirements(): Promise { + if (!this.store?.runDurableMutation) { + return + } + try { + // Why if-dirty: an earlier write, or another exit's, may already carry this retirement. + await this.store.runDurableMutation(() => ({ value: undefined, persist: 'if-dirty' })) + } catch (error) { + console.error('[runtime] terminal retirement is not yet durable:', error) + } + } + + // Why synchronous: the exit's stream end cues clients to re-activate, which must find the leaf gone. protected retireMobileSessionSurfacesForPty( ptyId: string, incarnationId: string, exactSurfaces: readonly Pick[] - ): void { + ): Promise | undefined { const terminalHandle = this.handleByPtyId.get(ptyId) ?? this.findHandleForPtyRecord(ptyId) ?? undefined const retiredSurfaceByKey = new Map() @@ -133,21 +93,18 @@ export class OrcaRuntimeWithPersistTerminalSurfaceRetirements extends OrcaRuntim } const retiredSurfaces = [...retiredSurfaceByKey.values()] if (retiredSurfaces.length === 0) { - return + return undefined } - const persisted = this.persistTerminalSurfaceRetirements(retiredSurfaces) - if (!persisted) { - return - } - for (const surface of persisted.unpersisted) { + const staged = this.stageTerminalSurfaceRetirements(retiredSurfaces) + for (const surface of staged.unpersisted) { const repoId = getRepoIdFromWorktreeId(surface.worktreeId) this.terminalTopologyRevisionByRepoId.set( repoId, (this.terminalTopologyRevisionByRepoId.get(repoId) ?? 0) + 1 ) } - // Why: one repo epoch can cover multiple exits, but only surfaces individually accepted by persistence may disappear. - const removableRetiredSurfaces = [...persisted.accepted, ...persisted.unpersisted] + // Why: one repo epoch can cover multiple exits; a surface the session binds to another PTY or incarnation stays. + const removableRetiredSurfaces = [...staged.accepted, ...staged.unpersisted] for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { // Why proofs aren't gated on `removable`: the exit is the attestation, and a surface the // renderer already de-persisted leaves persistence nothing to accept. Withholding the proof @@ -172,7 +129,7 @@ export class OrcaRuntimeWithPersistTerminalSurfaceRetirements extends OrcaRuntim snapshot, ptyId, exactSurfaces: removableSurfaces, - // Why: discovery is broad by PTY id, but publication may remove only surfaces whose durable retirement was accepted. + // Why: discovery is broad by PTY id, but publication may remove only surfaces the session retired. exactOnly: true, ...(retirementProofs.length > 0 ? { retirementProofs } : {}) }) @@ -184,6 +141,7 @@ export class OrcaRuntimeWithPersistTerminalSurfaceRetirements extends OrcaRuntim } this.publishRetiredTerminalSurfaceProofs(worktreeId, retirementProofs) } + return staged.accepted.length > 0 ? this.persistStagedTerminalSurfaceRetirements() : undefined } /** Ships durable retirement proofs on their own frame when no surface removal carries them. */ diff --git a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts index 7280f10a91f..404e09bf085 100644 --- a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts @@ -107,17 +107,11 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin | null protected readonly prepareCodexStructuredLaunchFn: - | ((input: { - workspacePath: string - launchEnv: NodeJS.ProcessEnv - }) => string | null | Promise) + | ((input: { launchEnv: NodeJS.ProcessEnv }) => string | null | Promise) | null protected readonly resolveCodexStructuredLaunchHomeFn: - | ((input: { - workspacePath: string - launchEnv: NodeJS.ProcessEnv - }) => string | null | Promise) + | ((input: { launchEnv: NodeJS.ProcessEnv }) => string | null | Promise) | null protected readonly agentSessionClaimSigner: AgentSessionClaimSigner diff --git a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts index 50b1f52d238..75005726c2d 100644 --- a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts +++ b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts @@ -216,9 +216,10 @@ export class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends OrcaRuntime } // Why: group address resolution (Section 4.5) queries per-handle status and must not throw on stale handles; return null on any error. - getAgentStatusForHandle(handle: string): string | null { + async getAgentStatusForHandle(handle: string): Promise { // A structured worker has no pane and no title, so every PTY probe below answers null and - // `@idle` would enumerate it and then silently drop it. Its status is the journal's. + // `@idle` would enumerate it and then silently drop it. Its status is the journal's, read + // through a conversation the idle sweep may have closed. const structured = resolveStructuredWorkerAuthority(handle, this._orchestrationDb) if (structured) { return structuredWorkerAgentStatus(structured.identity.sessionId) diff --git a/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts b/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts index de98831cdd5..b09ca3b7b8d 100644 --- a/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts +++ b/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts @@ -146,6 +146,7 @@ export class OrcaRuntimeWithRefreshFloatingWorkspacePtyLiveness extends OrcaRunt this.providerVisibleRetryAtByPtyId.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) this.terminalSpawnCommandsByPtyId.delete(ptyId) + this.terminalRunFacts.delete(ptyId) this.disposePtyTitleTracker(ptyId) this.invalidatePtyIncarnationHandle(ptyId) this.oscTitleScanTailByPtyId.delete(ptyId) diff --git a/src/main/runtime/orca-runtime-register-pty.ts b/src/main/runtime/orca-runtime-register-pty.ts index 37a5434e5c5..bd9c96a5c0f 100644 --- a/src/main/runtime/orca-runtime-register-pty.ts +++ b/src/main/runtime/orca-runtime-register-pty.ts @@ -140,9 +140,17 @@ export class OrcaRuntimeWithRegisterPty extends OrcaRuntimeWithInvalidateAllHand currentFence.pendingRegistration = false } } + // Why: a listed surface's pending-handle → ready flip must not wait on a later renderer graph change. + this.touchMobileSessionSnapshotsForPty(ptyId) // Why: the renderer's own PTY spawn is the reliable signal that the pending // mobile create's tab is live; publish its surface main-side (#7587). if (binding && paneKey) { + if ( + replacementHandle?.startsWith('term_') && + this.handleByPtyId.get(ptyId) !== replacementHandle + ) { + this.registerPreAllocatedHandleForPty(ptyId, replacementHandle) + } this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, binding.tabId) } } diff --git a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts index ad7c81f2e87..04cb91e0281 100644 --- a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts +++ b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts @@ -61,7 +61,7 @@ export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends O ptyId: string, action: { text?: string; enter?: boolean; interrupt?: boolean }, payload: string, - options: RuntimeTerminalWriteOptions = {} + options: RuntimeTerminalWriteOptions ): Promise { return this.terminalWriter.writeAction(ptyId, action, payload, options) } @@ -69,7 +69,7 @@ export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends O protected writeTerminalInputChunks( ptyId: string, text: string, - options: RuntimeTerminalWriteOptions = {} + options: RuntimeTerminalWriteOptions ): Promise { return this.terminalWriter.writeChunks(ptyId, text, options) } diff --git a/src/main/runtime/orca-runtime-resolve-exit-waiters.ts b/src/main/runtime/orca-runtime-resolve-exit-waiters.ts index 9d2ea33a6b8..f7ead603e02 100644 --- a/src/main/runtime/orca-runtime-resolve-exit-waiters.ts +++ b/src/main/runtime/orca-runtime-resolve-exit-waiters.ts @@ -2,15 +2,22 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithBindPtyIncarnationHandle } from './orca-runtime-bind-pty-incarnation-handle' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' -import { buildPtyTerminalWaitResult, buildTerminalWaitResult } from './terminal-wait-results' -import type { AgentStatus } from '../../shared/agent-detection' import { - detectExplicitIdleStatusFromTitle, - isKnownReadyPromptPreview, - isMuseReadyPromptPreview -} from './terminal-wait-detection' + buildPtyTerminalWaitBlockedResult, + buildPtyTerminalWaitResult, + buildTerminalWaitBlockedResult, + buildTerminalWaitResult +} from './terminal-wait-results' +import type { AgentStatus } from '../../shared/agent-detection' +import { detectExplicitIdleStatusFromTitle } from './terminal-wait-detection' import { buildTerminalWaitText } from './terminal-wait-tail-state' -import { isTuiIdleSatisfied } from './tui-idle-evidence' +import { + evaluateTuiIdle, + isTuiIdleReadyVerdict, + leafTuiIdleEvidence, + ptyTuiIdleEvidence, + type TuiIdleVerdict +} from './tui-idle-evidence' import { TUI_IDLE_QUIESCENCE_MS } from './orca-runtime-postlude' export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyIncarnationHandle { @@ -50,14 +57,20 @@ export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyInc if (!waiters || waiters.size === 0) { return } - // Why re-rank rather than resolve outright: the transition that brought us here is - // only a title sample, and a name-only title arriving mid-turn is the weakest tier - // there is (#6011). Leave such a waiter on its poll to be corroborated instead. - if (!this.isTuiIdleSatisfiedForLeaf(leaf)) { - return - } + // Why re-rank rather than resolve outright: the transition that brought us here is only a + // title sample. Weak ready (a name-only title, #6011) cannot see a dialog the tail lost, + // so it is left to the poll, which settles it only after a rendered-screen read. + const verdict = this.evaluateTuiIdleForLeaf(leaf) for (const waiter of [...waiters]) { - if (waiter.condition === 'tui-idle') { + if (waiter.condition !== 'tui-idle') { + continue + } + if (verdict.kind === 'blocked') { + this.resolveWaiter( + waiter, + buildTerminalWaitBlockedResult(handle, 'tui-idle', leaf, verdict.reason) + ) + } else if (verdict.kind === 'ready-strong') { this.resolveWaiter(waiter, buildTerminalWaitResult(handle, 'tui-idle', leaf)) } } @@ -92,34 +105,28 @@ export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyInc return } // Why: same re-ranking as resolveTuiIdleWaiters above. - if (!this.isTuiIdleSatisfiedForPty(pty)) { - return - } + const verdict = this.evaluateTuiIdleForPty(pty) for (const waiter of [...waiters]) { - if (waiter.condition === 'tui-idle') { + if (waiter.condition !== 'tui-idle') { + continue + } + if (verdict.kind === 'blocked') { + this.resolveWaiter( + waiter, + buildPtyTerminalWaitBlockedResult(handle, 'tui-idle', pty, verdict.reason) + ) + } else if (verdict.kind === 'ready-strong') { this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, 'tui-idle', pty)) } } } - // Why: the primary OSC-title signal can't fire for daemon-hosted terminals (no PTY data through the runtime), so this fallback polls the renderer-synced tab title + foreground-process quiescence; self-cancels when the OSC path fires. - protected isTuiIdleSatisfiedForLeaf(leaf: RuntimeLeafRecord): boolean { - return isTuiIdleSatisfied({ - record: leaf, - rendererTitle: leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title ?? null, - readPositiveBodyEvidence: () => - isKnownReadyPromptPreview( - buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview) - ), - readMuseReadyBodyEvidence: () => - isMuseReadyPromptPreview( - buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview) - ), - agent: this.getPaneAgentForTuiIdle(leaf.ptyId), - firstPartyStatus: - (leaf.ptyId ? this.ptysById.get(leaf.ptyId)?.lastExplicitAgentStatus : null) ?? null, - quiescenceMs: TUI_IDLE_QUIESCENCE_MS - }) + protected evaluateTuiIdleForLeaf(leaf: RuntimeLeafRecord): TuiIdleVerdict { + return evaluateTuiIdle( + leafTuiIdleEvidence(this.tuiIdleEvidenceSource, leaf, () => + buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview) + ) + ) } /** @@ -189,25 +196,25 @@ export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyInc */ protected isAgentSettledForDelivery(leaf: { tabId: string; leafId: string }): boolean { const live = this.leaves.get(this.getLeafKey(leaf.tabId, leaf.leafId)) - return live ? this.isTuiIdleSatisfiedForLeaf(live) : false + if (!live) { + return false + } + const evidence = leafTuiIdleEvidence(this.tuiIdleEvidenceSource, live, () => + buildTerminalWaitText(live.tailBuffer, live.tailPartialLine, live.preview) + ) + // Why no blocked reader: a refusal here re-arms a recheck that a lingering tail prompt + // would spin, so delivery keeps its own, blocked-blind, reading of the same ranking. + return isTuiIdleReadyVerdict( + evaluateTuiIdle({ ...evidence, readTailBlockedReason: () => null }) + ) } - protected isTuiIdleSatisfiedForPty(pty: RuntimePtyWorktreeRecord): boolean { - return isTuiIdleSatisfied({ - record: pty, - readPositiveBodyEvidence: () => - this.getAdoptedPtyExplicitIdleStatus(pty) === 'idle' || - isKnownReadyPromptPreview( - buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) - ), - readMuseReadyBodyEvidence: () => - isMuseReadyPromptPreview( - buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) - ), - agent: this.getPaneAgentForTuiIdle(pty.ptyId), - firstPartyStatus: pty.lastExplicitAgentStatus ?? null, - quiescenceMs: TUI_IDLE_QUIESCENCE_MS - }) + protected evaluateTuiIdleForPty(pty: RuntimePtyWorktreeRecord): TuiIdleVerdict { + return evaluateTuiIdle( + ptyTuiIdleEvidence(this.tuiIdleEvidenceSource, pty, () => + buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) + ) + ) } protected getAdoptedPtyExplicitIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null { diff --git a/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts b/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts index 281185e51dc..095ee58a37f 100644 --- a/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts +++ b/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts @@ -64,7 +64,6 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt if (opts.agentPrompt && startupPlan.followupPrompt) { throw new Error(`Agent ${opts.agent} does not support startup prompt quick commands.`) } - await this.markWorkspaceTrustedForAgent(opts.agent, workspace.connectionId, workspace.path) return { command: startupPlan.launchCommand, env: startupPlan.env, diff --git a/src/main/runtime/orca-runtime-resolve-terminal-pane.ts b/src/main/runtime/orca-runtime-resolve-terminal-pane.ts index 1093319783a..60047604e43 100644 --- a/src/main/runtime/orca-runtime-resolve-terminal-pane.ts +++ b/src/main/runtime/orca-runtime-resolve-terminal-pane.ts @@ -184,7 +184,7 @@ export class OrcaRuntimeWithResolveTerminalPane extends OrcaRuntimeWithGetTermin ): Promise { // Before the PTY lookup, because a structured worker has no PTY and no leaf: without this the // only peer read verb answers `terminal_handle_stale` for a perfectly live worker. - const structured = readStructuredWorkerTerminal({ + const structured = await readStructuredWorkerTerminal({ handle, db: this.getOrchestrationDbIfAvailable?.() ?? null, ...(opts.cursor === undefined ? {} : { cursor: opts.cursor }), diff --git a/src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts b/src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts index 49d55d96c17..167aee3e21b 100644 --- a/src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts +++ b/src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts @@ -54,12 +54,13 @@ export class OrcaRuntimeWithResolveTerminalSplitSourceAuthority extends OrcaRunt ) const rendererTab = this.tabs.get(tabId) const rendererLeaf = this.leaves.get(this.getLeafKey(tabId, leafId)) + // A mounted pane can publish before its PTY binds; persisted identity fences that gap. const rendererMounted = Boolean( rendererTab && rendererLeaf && runtimeWorktreeIdsEqual(rendererTab.worktreeId, worktreeId) && runtimeWorktreeIdsEqual(rendererLeaf.worktreeId, worktreeId) && - rendererLeaf.ptyId === ptyId + (rendererLeaf.ptyId === ptyId || (persisted && rendererLeaf.ptyId === null)) ) if (persisted && persistedLayout) { return { @@ -100,7 +101,7 @@ export class OrcaRuntimeWithResolveTerminalSplitSourceAuthority extends OrcaRunt return await this.claudeAgentTeams.handleTmuxCompat(request, { splitTerminal: (handle, opts) => this.splitTerminal(handle, opts), readTerminal: (handle, opts) => this.readTerminal(handle, opts), - sendTerminal: (handle, action) => this.sendTerminal(handle, action), + sendTerminal: (handle, action, options) => this.sendTerminal(handle, action, options), focusTerminal: (handle) => this.focusTerminal(handle), closeTerminal: (handle) => this.closeTerminal(handle), showTerminal: (handle) => this.showTerminal(handle) diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index 94dba6bdc83..0aeacdc0e99 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -21,6 +21,7 @@ import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtim import { resolveBareAgentLaunchCommand } from './runtime-agent-launch-resolution' import { buildAgentStartupPlan } from '../../shared/tui-agent-startup' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' +import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWithRemoveManagedWorktree { protected async resolveWorktreeRemovalTarget( @@ -242,15 +243,15 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith throw new Error(`Agent ${agent} does not take a startup prompt on its launch command.`) } - await this.markWorkspaceTrustedForAgent(agent, workspace.connectionId, workspace.path) - return { ...opts, command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), launchConfig: startupPlan.launchConfig, launchAgent: agent, - startupCommandDelivery: startupPlan.startupCommandDelivery + startupCommandDelivery: startupPlan.startupCommandDelivery, + // A bare command the user typed stays out of launch accounting, as before. + ...(opts.startupAgent ? { telemetry: agentStartedTelemetry(agent, opts.launchSource) } : {}) } } } diff --git a/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts b/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts index bfab8f0ba9a..8e9ad65dc32 100644 --- a/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts +++ b/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts @@ -123,9 +123,9 @@ export class OrcaRuntimeWithRestoreLivePairedRendererSessionOwnedMobileTerminals if (!pty || this.terminalSpawnCommandsByPtyId.has(pty.ptyId)) { return } - if (this.ptyController?.write(pty.ptyId, command)) { + if (this.ptyController?.write(pty.ptyId, command, 'launch')) { // Why: Enter rides its own write so a long command cannot swallow it. - this.ptyController.write(pty.ptyId, '\r') + this.ptyController.write(pty.ptyId, '\r', 'launch') this.noteTerminalSpawnCommand(pty.ptyId, command) } } diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index d53b465d97d..f22a09bac5e 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -95,10 +95,16 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu activate: boolean notify?: boolean replacesSessionId?: string + /** The host tab id a create reserved; a session that already has a tab keeps its own. */ + tabId?: string }): Promise { const host = getStructuredAgentSessionHost() if (typeof host?.setSessionTabVisibility === 'function') { - await host.setSessionTabVisibility(input.sessionId, true) + await host.setSessionTabVisibility( + input.sessionId, + true, + ...(input.tabId ? [input.tabId] : []) + ) } const existing = this.mobileSessionTabsByWorktree.get(input.workspaceId) const id = `agent-session:${input.sessionId}` diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index 7b1811d9255..d724207f921 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -45,6 +45,10 @@ import { MailPointerRepointScheduler } from './orchestration/mail-pointer-repoin import { RuntimeTerminalWaiterRegistry } from './runtime-terminal-waiter-registry' import { RuntimeTerminalWriter } from './runtime-terminal-writer' import { RuntimeTerminalIdlePolls } from './runtime-terminal-idle-polls' +import { TerminalIntentionalStops } from './terminal-intentional-stops' +import { TerminalRunFactsRegister, type TerminalSpawnCommit } from './terminal-run-facts' +import type { TuiIdleEvidenceSource } from './tui-idle-evidence' +import { hasTerminalCommandPainted } from './terminal-command-paint' import { TUI_IDLE_DEFAULT_TIMEOUT_MS, TUI_IDLE_POLL_INTERVAL_MS, @@ -232,9 +236,16 @@ export class OrcaRuntimeWithRuntimeId { protected pendingPtyRegistrationIncarnations = new Map() - // Why: exact-stop is the current sleep transaction boundary; its exit must - // leave the renderer's intentional sleeping surface available for wake. - protected intentionalHandlelessPtyStops = new Map() + // Why public: the PTY IPC layer's stop paths write it and its exit delivery reads it. + readonly intentionalPtyStops = new TerminalIntentionalStops() + + readonly terminalRunFacts = new TerminalRunFactsRegister() + + /** Both spawn-commit funnels report each committed process here, once. */ + noteTerminalSpawnCommit(commit: TerminalSpawnCommit, expectedSourceBinding?: unknown): void { + this.terminalRunFacts.recordSpawnCommit(commit, expectedSourceBinding) + this.intentionalPtyStops.noteSpawnCommit(commit.id) + } // Why: coalesces title/status-driven session.tabs emits so spinner churn // doesn't fan out (and per-client JSON.stringify) a snapshot several times a @@ -321,35 +332,47 @@ export class OrcaRuntimeWithRuntimeId { protected readonly terminalWaiters = new RuntimeTerminalWaiterRegistry() protected readonly terminalWriter = new RuntimeTerminalWriter( - (ptyId, data) => this.ptyController?.write(ptyId, data) ?? false, + (ptyId, data, inputKind) => this.ptyController?.write(ptyId, data, inputKind) ?? false, (ptyId) => this.getPtyWriteHostPlatform(ptyId), (ptyId) => this.getPtyAgent(ptyId) ) - protected readonly terminalIdlePolls = new RuntimeTerminalIdlePolls({ - intervalMs: TUI_IDLE_POLL_INTERVAL_MS, + // Why one source: every tui-idle site must read the same evidence, or they rank one pane differently. + protected readonly tuiIdleEvidenceSource: TuiIdleEvidenceSource = { quiescenceMs: TUI_IDLE_QUIESCENCE_MS, getTabTitle: (tabId) => this.tabs.get(tabId)?.title ?? null, - getForegroundProcess: (ptyId) => this.ptyController?.getForegroundProcess(ptyId) ?? null, getAdoptedPtyIdleStatus: (pty) => this.getAdoptedPtyExplicitIdleStatus(pty), getPaneAgent: (ptyId) => this.getPaneAgentForTuiIdle(ptyId), getFirstPartyAgentStatus: (ptyId) => (ptyId ? this.ptysById.get(ptyId)?.lastExplicitAgentStatus : null) ?? null, + readScreenLines: (ptyId) => this.readLiveTerminalScreenLines(ptyId) + } + + protected readonly terminalIdlePolls = new RuntimeTerminalIdlePolls({ + ...this.tuiIdleEvidenceSource, + intervalMs: TUI_IDLE_POLL_INTERVAL_MS, + getForegroundProcess: (ptyId) => this.ptyController?.getForegroundProcess(ptyId) ?? null, + hasCommandPainted: (ptyId) => { + const pty = this.ptysById.get(ptyId) + return pty === undefined || hasTerminalCommandPainted(pty) + }, + // Why the runtime's own emulator: a provider snapshot would be a host round trip per tick. + readVisibleScreen: (ptyId) => + this.headlessTerminals.has(ptyId) + ? this.readHeadlessVisibleTerminalState(ptyId).then( + (screen) => screen?.lines.join('\n') ?? null + ) + : null, getLiveLeaf: (leaf) => this.leaves.get(this.getLeafKey(leaf.tabId, leaf.leafId)) ?? leaf, resolve: (waiter, result) => this.terminalWaiters.resolve(waiter, result) }) protected readonly terminalWait = new RuntimeTerminalWaitController( { + ...this.tuiIdleEvidenceSource, defaultTimeoutMs: TUI_IDLE_DEFAULT_TIMEOUT_MS, getLivePty: (handle) => this.getLivePtyForHandle(handle), getLiveLeaf: (handle) => this.getLiveLeafForHandle(handle), - getAdoptedPtyIdleStatus: (pty) => this.getAdoptedPtyExplicitIdleStatus(pty), - getTabTitle: (tabId) => this.tabs.get(tabId)?.title ?? null, - quiescenceMs: TUI_IDLE_QUIESCENCE_MS, - getPaneAgent: (ptyId) => this.getPaneAgentForTuiIdle(ptyId), - getFirstPartyAgentStatus: (ptyId) => - (ptyId ? this.ptysById.get(ptyId)?.lastExplicitAgentStatus : null) ?? null, startVisibleReadProbe: (waiter, waiterTimeoutMs, agent) => this.startTuiIdleVisibleReadProbe(waiter, waiterTimeoutMs, agent) }, diff --git a/src/main/runtime/orca-runtime-serialize-main-terminal-buffer.ts b/src/main/runtime/orca-runtime-serialize-main-terminal-buffer.ts index 7994f7d8857..53a9b4ffc59 100644 --- a/src/main/runtime/orca-runtime-serialize-main-terminal-buffer.ts +++ b/src/main/runtime/orca-runtime-serialize-main-terminal-buffer.ts @@ -80,6 +80,16 @@ export class OrcaRuntimeWithSerializeMainTerminalBuffer extends OrcaRuntimeWithA return { handle, cleared: true } } + async resetTerminalInputModes(handle: string): Promise<{ handle: string; reset: boolean }> { + const leaf = this.resolveLeafForHandle(handle) + if (!leaf?.ptyId) { + throw new Error('terminal_not_found') + } + await this.ptyController?.resetInputModes?.(leaf.ptyId) + await this.resetHeadlessTerminalInputModes(leaf.ptyId) + return { handle, reset: true } + } + getTerminalSize(ptyId: string): { cols: number; rows: number } | null { return this.ptyController?.getSize?.(ptyId) ?? null } diff --git a/src/main/runtime/orca-runtime-sleep-resolved-worktree-terminals.ts b/src/main/runtime/orca-runtime-sleep-resolved-worktree-terminals.ts index 7d3645473bc..0f0a856bda2 100644 --- a/src/main/runtime/orca-runtime-sleep-resolved-worktree-terminals.ts +++ b/src/main/runtime/orca-runtime-sleep-resolved-worktree-terminals.ts @@ -64,7 +64,7 @@ export class OrcaRuntimeWithSleepResolvedWorktreeTerminals extends OrcaRuntimeWi const pendingPtyIds = new Set() let generation = 0 let fullyCommitted = false - let releaseReversibleRendererStops = (): void => {} + const settleReversibleStops = new Map void>() try { const resolvedWorktrees = includeTargetResolvedWorktree( [...(await this.getResolvedWorktreeMap()).values()], @@ -148,16 +148,25 @@ export class OrcaRuntimeWithSleepResolvedWorktreeTerminals extends OrcaRuntimeWi const stopAndWait = ptyController.stopAndWait.bind(ptyController) const orderedLivePtyIds = [...livePtyIds].sort() - releaseReversibleRendererStops = - ptyController.markReversibleStops?.(orderedLivePtyIds) ?? (() => {}) - const stopResults = await Promise.allSettled( - orderedLivePtyIds.map(async (ptyId) => ({ + for (const ptyId of orderedLivePtyIds) { + settleReversibleStops.set( ptyId, - stopped: await stopAndWait(ptyId, { + this.intentionalPtyStops.mark( + ptyId, + 'reversible', + this.ptysById.get(ptyId)?.incarnationId ?? null + ) + ) + } + const stopResults = await Promise.allSettled( + orderedLivePtyIds.map(async (ptyId) => { + const stopped = await stopAndWait(ptyId, { keepHistory: true, deadlineMs: teardownRpcDeadline(sleepDeadline) }) - })) + settleReversibleStops.get(ptyId)?.(stopped) + return { ptyId, stopped } + }) ) const successfulStopPtyIds = orderedLivePtyIds.filter((_, index) => { const result = stopResults[index] @@ -251,7 +260,9 @@ export class OrcaRuntimeWithSleepResolvedWorktreeTerminals extends OrcaRuntimeWi postStopVerified: true } } finally { - releaseReversibleRendererStops() + for (const settleStop of settleReversibleStops.values()) { + settleStop(false) + } if (!fullyCommitted && generation > 0) { const cancelledPtyIds = [...pendingPtyIds].sort() if (cancelledPtyIds.length > 0) { diff --git a/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts b/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts index bb03b17eca3..4d70572eb2a 100644 --- a/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts +++ b/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts @@ -11,7 +11,7 @@ import { import { withTimeout } from './runtime-async-boundaries' import { detectTerminalWaitBlockedReason, - isKnownReadyPromptPreview + isKnownReadyPromptSettled } from './terminal-wait-detection' import type { RuntimeTerminalWait, @@ -80,7 +80,7 @@ export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWith const ready = agent === 'antigravity' ? isAntigravityReadyPromptSnapshot(snapshotText) - : isKnownReadyPromptPreview(snapshotText) + : isKnownReadyPromptSettled(snapshotText) if (!blockedReason && !ready) { return } diff --git a/src/main/runtime/orca-runtime-state-fields.ts b/src/main/runtime/orca-runtime-state-fields.ts index 30093c36413..ef54b73252c 100644 --- a/src/main/runtime/orca-runtime-state-fields.ts +++ b/src/main/runtime/orca-runtime-state-fields.ts @@ -96,14 +96,12 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { args: AiVaultPrepareSessionResumeArgs ) => Promise prepareCodexStructuredLaunch?: (input: { - workspacePath: string launchEnv: NodeJS.ProcessEnv }) => string | null | Promise // Why a sibling of prepare: record-less catalog reads must resolve the // same launch home with none of launch prep's side effects (no sync, no // bridge, no cleared selection). resolveCodexStructuredLaunchHome?: (input: { - workspacePath: string launchEnv: NodeJS.ProcessEnv }) => string | null | Promise buildAgentHookPtyEnv?: () => Record diff --git a/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts b/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts index a96ae64e596..d854be6ec68 100644 --- a/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts +++ b/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts @@ -47,18 +47,22 @@ export class OrcaRuntimeWithStopExactTerminalsForWorktree extends OrcaRuntimeWit const stoppedPtyIds: string[] = [] for (const ptyId of [...expected].sort()) { - if (opts.keepHistory) { - this.intentionalHandlelessPtyStops.set( - ptyId, - this.ptysById.get(ptyId)?.incarnationId ?? null - ) - } + // Why: exact-stop is the sleep transaction boundary; its exit must leave the sleeping surface for wake. + const settleStop = opts.keepHistory + ? this.intentionalPtyStops.mark( + ptyId, + 'reversible', + this.ptysById.get(ptyId)?.incarnationId ?? null + ) + : null + let stopped = false try { - if (!(await this.ptyController.stopAndWait(ptyId, { keepHistory: opts.keepHistory }))) { - throw Object.assign(new Error('terminal_exact_stop_failed'), { ptyId }) - } + stopped = await this.ptyController.stopAndWait(ptyId, { keepHistory: opts.keepHistory }) } finally { - this.intentionalHandlelessPtyStops.delete(ptyId) + settleStop?.(stopped) + } + if (!stopped) { + throw Object.assign(new Error('terminal_exact_stop_failed'), { ptyId }) } stoppedPtyIds.push(ptyId) } diff --git a/src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts b/src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts index 196845d09ef..a243d5ea0a6 100644 --- a/src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts +++ b/src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts @@ -3,20 +3,26 @@ import { OrcaRuntimeWithFocusTerminal } from './orca-runtime-focus-terminal' import { EXPLICIT_TERMINAL_CLOSE_STOP_TIMEOUT_MS } from './orca-runtime-core' import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../shared/pty-liveness-verdict' import type { RuntimeTerminalClose } from '../../shared/runtime-types' -import { countTerminalLayoutLeaves } from './headless-terminal-split-layout' import type { RuntimePtyTabCloseAuthority } from './runtime-terminal-state-records' +import { parsePaneKey } from '../../shared/stable-pane-id' + +/** How an explicit close's stop of its addressed PTY ended. */ +type ExplicitCloseStop = { stopped: boolean; pendingKillRecorded: boolean } + +const NO_STOP: ExplicitCloseStop = { stopped: false, pendingKillRecorded: false } export class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRuntimeWithFocusTerminal { protected async stopExplicitlyClosedTabPtys( ptyIds: readonly string[], addressedPtyId: string - ): Promise { - let addressedPtyStopped = false + ): Promise { + let addressedPtyStop = NO_STOP const deadlineMs = Date.now() + EXPLICIT_TERMINAL_CLOSE_STOP_TIMEOUT_MS for (const ptyId of ptyIds) { this.markPtyStopRequested(ptyId) const expectedIncarnationId = this.ptysById.get(ptyId)?.incarnationId let stopped = false + let pendingKillRecorded = false if (this.ptyController?.stopAndWait) { try { stopped = await this.ptyController.stopAndWait(ptyId, { deadlineMs }) @@ -41,6 +47,8 @@ export class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRuntimeWithF verdict?.status === 'unverifiable' && verdict.reason === SSH_PROVIDER_UNREGISTERED_REASON if (!providerAlreadyRetiredPty) { + // Why before the kill: its own failure is recorded only once its RPC settles. + pendingKillRecorded = this.ptyController.recordUnconfirmedStop?.(ptyId) === true this.ptyController.kill(ptyId) if (!verdict || verdict.status === 'live') { this.markPtyLivenessUnverifiable( @@ -54,35 +62,35 @@ export class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRuntimeWithF stopped = this.ptyController?.kill(ptyId) ?? false } if (ptyId === addressedPtyId) { - addressedPtyStopped = stopped + addressedPtyStop = { stopped, pendingKillRecorded } } } - return addressedPtyStopped + return addressedPtyStop } protected describeTerminalClose( handle: string, tabId: string, ptyId: string | null, - ptyKilled: boolean + stop: ExplicitCloseStop ): RuntimeTerminalClose { - if (ptyKilled || !ptyId) { - return { handle, tabId, ptyKilled } + const close: RuntimeTerminalClose = { + handle, + tabId, + ptyKilled: stop.stopped, + ...(stop.pendingKillRecorded ? { pendingKillRecorded: true as const } : {}) + } + if (stop.stopped || !ptyId) { + return close } const verdict = this.getPtyLivenessVerdict(ptyId) if (verdict?.status === 'unverifiable') { - return { - handle, - tabId, - ptyKilled, - ptyStopVerdict: 'unverifiable', - ptyStopReason: verdict.reason - } + return { ...close, ptyStopVerdict: 'unverifiable', ptyStopReason: verdict.reason } } if (verdict?.status === 'live') { - return { handle, tabId, ptyKilled, ptyStopVerdict: 'live' } + return { ...close, ptyStopVerdict: 'live' } } - return { handle, tabId, ptyKilled } + return close } async closeTerminal(handle: string): Promise { @@ -106,11 +114,13 @@ export class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRuntimeWithF ? spawnSurface : null) const tabId = surface?.tab.parentTabId ?? pty.pty.tabId ?? pty.record.tabId - // Why: relay recovery can leave stale renderer leaves; the persisted HUB layout defines whether closing this PTY closes the whole surface. - const siblingCount = surface?.tab.parentLayout - ? countTerminalLayoutLeaves(surface.tab.parentLayout.root) - : this.countLeavesInTab(tabId) - if (siblingCount <= 1 && surface && this.tabs.has(tabId) && this.notifier?.closeTerminalTab) { + const leafId = surface?.tab.leafId ?? parsePaneKey(pty.pty.paneKey ?? '')?.leafId + const paneTarget = leafId ? { kind: 'pane' as const, tabId, leafId } : null + // Why: a PTY with no pane identity cannot be placed in any tab's layout, so it closes nothing. + const closesTab = + paneTarget !== null && + this.resolveTerminalCloseTarget(pty.pty.worktreeId, paneTarget) === 'last-pane' + if (closesTab && surface && this.tabs.has(tabId) && this.notifier?.closeTerminalTab) { const ptyIdsToKill = this.getPtyIdsForExplicitTabClose(pty.pty.worktreeId, tabId) try { await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId, { @@ -122,15 +132,10 @@ export class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRuntimeWithF } this.notifier.closeTerminal?.(tabId) } - const ptyKilled = await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, pty.pty.ptyId) - return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, ptyKilled) + const stop = await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, pty.pty.ptyId) + return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) } - if ( - siblingCount <= 1 && - surface && - ptyCloseAuthority && - !this.tabs.has(surface.tab.parentTabId) - ) { + if (closesTab && surface && ptyCloseAuthority && !this.tabs.has(surface.tab.parentTabId)) { try { await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId, { reason: 'user', @@ -141,59 +146,64 @@ export class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRuntimeWithF if (!(error instanceof Error) || error.message !== 'workspace_session_unavailable') { throw error } - const ptyKilled = await this.stopExplicitlyClosedTabPtys([pty.pty.ptyId], pty.pty.ptyId) + const stop = await this.stopExplicitlyClosedTabPtys([pty.pty.ptyId], pty.pty.ptyId) this.notifier?.closeTerminal(tabId) - return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, ptyKilled) + return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) } - const ptyKilled = await this.stopExplicitlyClosedTabPtys([pty.pty.ptyId], pty.pty.ptyId) - return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, ptyKilled) + const stop = await this.stopExplicitlyClosedTabPtys([pty.pty.ptyId], pty.pty.ptyId) + return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) } - if (siblingCount <= 1 && !surface && pty.pty.tabId && this.notifier?.closeTerminalTab) { + if (closesTab && !surface && pty.pty.tabId && this.notifier?.closeTerminalTab) { const ptyIdsToKill = this.getPtyIdsForExplicitTabClose(pty.pty.worktreeId, tabId) await this.notifier.closeTerminalTab(tabId, { localPtyTeardownOwnedExternally: true }) - const ptyKilled = await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, pty.pty.ptyId) - return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, ptyKilled) + const stop = await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, pty.pty.ptyId) + return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) } - const ptyKilled = await this.stopExplicitlyClosedTabPtys([pty.pty.ptyId], pty.pty.ptyId) - if (!ptyKilled || siblingCount <= 1) { - if (surface) { - // Why: paired viewers keep ended streams mounted until the HUB publishes removal, so explicit close uses the durable host-tab transaction instead of viewer-local exit handling. - try { - await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId, { - localPtyTeardownOwnedExternally: true - }) - } catch (error) { - if (!(error instanceof Error) || error.message !== 'workspace_session_unavailable') { - throw error - } - this.notifier?.closeTerminal(tabId) + const stop = await this.stopExplicitlyClosedTabPtys([pty.pty.ptyId], pty.pty.ptyId) + if (!closesTab) { + // Why: the pane's removal is this close's own commit, not a side effect of its exit. An + // unconfirmed stop is unverifiable, never a reason to close the live siblings with it. + if (paneTarget) { + await this.closeTerminalPane(pty.pty.worktreeId, paneTarget) + } + } else if (surface) { + // Why: paired viewers keep ended streams mounted until the HUB publishes removal, so explicit close uses the durable host-tab transaction instead of viewer-local exit handling. + try { + await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId, { + localPtyTeardownOwnedExternally: true + }) + } catch (error) { + if (!(error instanceof Error) || error.message !== 'workspace_session_unavailable') { + throw error } - } else { this.notifier?.closeTerminal(tabId) } + } else { + this.notifier?.closeTerminal(tabId) } - return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, ptyKilled) + return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) } this.assertGraphReady() const { leaf } = this.getLiveLeafForHandle(handle) - // Why: in a multi-pane tab, killing the PTY is enough (renderer's exit handler closes the pane); an extra IPC close would race it and close the whole tab. - const siblingCount = this.countLeavesInTab(leaf.tabId) - const ptyIdsToKill = - siblingCount <= 1 - ? this.getPtyIdsForExplicitTabClose(leaf.worktreeId, leaf.tabId) - : leaf.ptyId - ? [leaf.ptyId] - : [] - if (siblingCount <= 1 && this.notifier?.closeTerminalTab) { + const paneTarget = { kind: 'pane' as const, tabId: leaf.tabId, leafId: leaf.leafId } + const closesTab = this.resolveTerminalCloseTarget(leaf.worktreeId, paneTarget) === 'last-pane' + const ptyIdsToKill = closesTab + ? this.getPtyIdsForExplicitTabClose(leaf.worktreeId, leaf.tabId) + : leaf.ptyId + ? [leaf.ptyId] + : [] + if (closesTab && this.notifier?.closeTerminalTab) { await this.notifier.closeTerminalTab(leaf.tabId, { localPtyTeardownOwnedExternally: true }) } - const ptyKilled = leaf.ptyId + const stop = leaf.ptyId ? await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, leaf.ptyId) - : false - if (siblingCount > 1 ? !ptyKilled : !this.notifier?.closeTerminalTab) { - this.notifier?.closeTerminal(leaf.tabId, leaf.paneRuntimeId) + : NO_STOP + if (!closesTab) { + await this.closeTerminalPane(leaf.worktreeId, paneTarget) + } else if (!this.notifier?.closeTerminalTab) { + this.notifier?.closeTerminal(leaf.tabId) } - return this.describeTerminalClose(handle, leaf.tabId, leaf.ptyId ?? null, ptyKilled) + return this.describeTerminalClose(handle, leaf.tabId, leaf.ptyId ?? null, stop) } async closeTerminalTab(handle: string): Promise { diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index 4989245fdf6..a7175383df0 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -1,6 +1,7 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrchestrationStructuredMailboxPointerDelivery } from './orchestration/structured-mailbox-pointer-delivery' import { createStructuredMailboxPointerHost } from './orchestration/structured-mailbox-pointer-host' +import { localOrchestrationCliCommand } from './orchestration/cli-command' import { isStructuredWorkerHandle } from './structured-worker-identity' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' import { OrcaRuntimeWithRuntimeId } from './orca-runtime-runtime-id' @@ -221,6 +222,7 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId getMessageWaiters: (mailboxHandle) => this.messageWaiters.get(mailboxHandle), resolveStructuredTarget: (mailboxHandle) => this.resolveStructuredMailboxTarget(mailboxHandle), + getCliCommand: localOrchestrationCliCommand, host: createStructuredMailboxPointerHost() }) diff --git a/src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts b/src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts index 768448e2ac9..747384d2103 100644 --- a/src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts +++ b/src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts @@ -11,7 +11,8 @@ import type { } from '../../shared/runtime-types' import type { WorktreeTerminalMutationKind } from './worktree-terminal-mutation-lock' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from './workspace-session-failed-write-rollback' +import { cloneWorkspaceSessionState } from '../persistence/restoring-sessions/session-owner-fields' +import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from '../persistence/restoring-sessions/workspace-session-write-rollback' import { getWorktreeExecutionHostId, parseExecutionHostId, @@ -88,7 +89,7 @@ export class OrcaRuntimeWithStopTerminalsForWorktree extends OrcaRuntimeWithReso } closed += 1 } - this.clearWorktreeTerminalResumeRecords(worktree.id, sessionHostId, parentTabIds) + await this.clearWorktreeTerminalResumeRecords(worktree.id, sessionHostId, parentTabIds) const { stopped } = await this.stopTerminalsForWorktree(`id:${worktree.id}`, { resolvedWorktreeId: worktree.id, ...hostFence @@ -109,60 +110,65 @@ export class OrcaRuntimeWithStopTerminalsForWorktree extends OrcaRuntimeWithReso }) } - private clearWorktreeTerminalResumeRecords( + private async clearWorktreeTerminalResumeRecords( worktreeId: string, hostId: ExecutionHostId, closedTabIds: readonly string[] - ): void { + ): Promise { if ( !this.store?.getWorkspaceSession || !this.store.setWorkspaceSession || - !this.store.flushOrThrow + !this.store.runDurableMutation ) { throw new Error('workspace_session_unavailable') } - const session = this.store.getWorkspaceSession(hostId) - const sleepingAgentSessionsByPaneKey = Object.fromEntries( - Object.entries(session.sleepingAgentSessionsByPaneKey ?? {}).filter( - ([, record]) => record.worktreeId !== worktreeId + const refusal = await this.store.runDurableMutation(() => { + const session = cloneWorkspaceSessionState(this.store.getWorkspaceSession(hostId)) + const sleepingAgentSessionsByPaneKey = Object.fromEntries( + Object.entries(session.sleepingAgentSessionsByPaneKey ?? {}).filter( + ([, record]) => record.worktreeId !== worktreeId + ) ) - ) - const terminalPtyIncarnationsByPaneKey = Object.fromEntries( - Object.entries(session.terminalPtyIncarnationsByPaneKey ?? {}).filter( - ([paneKey]) => !closedTabIds.some((tabId) => paneKey.startsWith(`${tabId}:`)) + const terminalPtyIncarnationsByPaneKey = Object.fromEntries( + Object.entries(session.terminalPtyIncarnationsByPaneKey ?? {}).filter( + ([paneKey]) => !closedTabIds.some((tabId) => paneKey.startsWith(`${tabId}:`)) + ) ) - ) - const remainingTerminalRows = session.tabsByWorktree[worktreeId] ?? [] - const remainingUnifiedTerminalTabs = (session.unifiedTabs?.[worktreeId] ?? []).filter( - (tab) => tab.contentType === 'terminal' - ) - if (remainingTerminalRows.length > 0 || remainingUnifiedTerminalTabs.length > 0) { - throw new Error('terminal_close_incomplete') - } - const hasChanges = - Object.keys(sleepingAgentSessionsByPaneKey).length !== - Object.keys(session.sleepingAgentSessionsByPaneKey ?? {}).length || - Object.keys(terminalPtyIncarnationsByPaneKey).length !== - Object.keys(session.terminalPtyIncarnationsByPaneKey ?? {}).length - if (!hasChanges) { - return - } - const next: WorkspaceSessionState = { - ...session, - sleepingAgentSessionsByPaneKey, - terminalPtyIncarnationsByPaneKey - } - this.store.setWorkspaceSession(next, hostId) - const staged = this.store.getWorkspaceSession(hostId) - try { - this.store.flushOrThrow() - } catch (error) { - const current = this.store.getWorkspaceSession(hostId) - const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(session, staged, current) - if (rolledBack !== current) { - this.store.setWorkspaceSession(rolledBack, hostId) + const remainingTerminalRows = session.tabsByWorktree[worktreeId] ?? [] + const remainingUnifiedTerminalTabs = (session.unifiedTabs?.[worktreeId] ?? []).filter( + (tab) => tab.contentType === 'terminal' + ) + if (remainingTerminalRows.length > 0 || remainingUnifiedTerminalTabs.length > 0) { + return { value: new Error('terminal_close_incomplete'), persist: false } } - throw error + const hasChanges = + Object.keys(sleepingAgentSessionsByPaneKey).length !== + Object.keys(session.sleepingAgentSessionsByPaneKey ?? {}).length || + Object.keys(terminalPtyIncarnationsByPaneKey).length !== + Object.keys(session.terminalPtyIncarnationsByPaneKey ?? {}).length + if (!hasChanges) { + return { value: undefined, persist: false } + } + const next: WorkspaceSessionState = { + ...session, + sleepingAgentSessionsByPaneKey, + terminalPtyIncarnationsByPaneKey + } + this.store.setWorkspaceSession(next, hostId) + const staged = cloneWorkspaceSessionState(this.store.getWorkspaceSession(hostId)) + return { + value: undefined, + rollback: () => { + const current = this.store.getWorkspaceSession(hostId) + const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(session, staged, current) + if (rolledBack !== current) { + this.store.setWorkspaceSession(rolledBack, hostId) + } + } + } + }) + if (refusal) { + throw refusal } } diff --git a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts index ac120a80406..c5cdf188cd4 100644 --- a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts +++ b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts @@ -1,6 +1,72 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const applyAgentWorkspaceTrust = vi.hoisted(() => vi.fn(async () => ({}))) +vi.mock('../agent-workspace-trust', () => ({ applyAgentWorkspaceTrust })) + import { OrcaRuntimeService } from './orca-runtime' +beforeEach(() => { + applyAgentWorkspaceTrust.mockClear() +}) + +function createCodexIntentRuntime(settings: Record) { + const prepareCodexStructuredLaunch = vi.fn(() => '/accounts/selected/home') + const runtime = new OrcaRuntimeService( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: create intent only reads getSettings from the store. + { getSettings: () => ({ agentDefaultEnv: { codex: {} }, ...settings }) } as never, + undefined, + { prepareCodexStructuredLaunch } + ) + vi.spyOn(runtime, 'getStructuredAgentSessionCreateSupport').mockResolvedValue({ + supported: true + }) + Object.assign(runtime, { + resolveStructuredAgentSessionLocation: vi.fn(async () => ({ + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' as const + })), + resolveRuntimeFileTarget: vi.fn(async () => ({ worktree: { path: '/repos/workspace-1' } })) + }) + const createIntent = () => + runtime.resolveStructuredAgentSessionCreateIntent({ + envelope: { sessionId: 'session-1', clientOperationId: 'operation-1' }, + worktree: 'id:workspace-1', + agent: 'codex' + }) + return { prepareCodexStructuredLaunch, createIntent } +} + +describe('structured Codex folder trust', () => { + it('pre-trusts the chat folder before launch preparation, as a Codex terminal launch does', async () => { + const { prepareCodexStructuredLaunch, createIntent } = createCodexIntentRuntime({}) + + await createIntent() + + expect(applyAgentWorkspaceTrust).toHaveBeenCalledWith('codex', '/repos/workspace-1', { + env: expect.any(Object), + claudeAuth: null, + wslDistro: null, + connectionId: null + }) + expect(applyAgentWorkspaceTrust.mock.invocationCallOrder[0]).toBeLessThan( + prepareCodexStructuredLaunch.mock.invocationCallOrder[0] + ) + }) + + it('writes nothing with the setting off, and still prepares the launch', async () => { + const { prepareCodexStructuredLaunch, createIntent } = createCodexIntentRuntime({ + agentWorkspaceTrustEnabled: false + }) + + await createIntent() + + expect(applyAgentWorkspaceTrust).not.toHaveBeenCalled() + expect(prepareCodexStructuredLaunch).toHaveBeenCalledTimes(1) + }) +}) + describe('structured agent-session create intent', () => { it('pins the selected Codex launch home after normal launch preparation', async () => { const prepareCodexStructuredLaunch = vi.fn(() => '/accounts/selected/home') @@ -52,7 +118,6 @@ describe('structured agent-session create intent', () => { }) expect(prepareCodexStructuredLaunch).toHaveBeenCalledWith({ - workspacePath: '/repos/workspace-1', launchEnv: expect.objectContaining({ CODEX_HOME: '/configured/home' }) }) expect(intent.accountHome).toEqual({ @@ -82,7 +147,6 @@ describe('structured agent-session create intent', () => { // no cleared account selection — the read-only sibling answers instead. expect(prepareCodexStructuredLaunch).not.toHaveBeenCalled() expect(resolveCodexStructuredLaunchHome).toHaveBeenCalledWith({ - workspacePath: '', launchEnv: expect.objectContaining({ CODEX_HOME: '/configured/home' }) }) expect(accountHome).toEqual({ variable: 'CODEX_HOME', path: '/accounts/selected/home' }) @@ -138,6 +202,8 @@ describe('structured agent-session create intent', () => { }) expect(prepareCodexStructuredLaunch).not.toHaveBeenCalled() + // Claude was never pre-trusted for a structured chat. + expect(applyAgentWorkspaceTrust).not.toHaveBeenCalled() expect(intent.accountHome).toEqual({ variable: 'CLAUDE_CONFIG_DIR', path: '/configured/claude-home' diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index d6ec1e22782..1d8105d07f8 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -307,10 +307,7 @@ describe('structured session cold restoration', () => { reason: 'user' }) - expect(closeSessionTab).toHaveBeenCalledWith( - 'structured-agent-session-restored-session', - 'workspace-1' - ) + expect(closeSessionTab).toHaveBeenCalledWith('agent-session:restored-session', 'workspace-1') expect(closeStructuredSession).toHaveBeenCalledWith('restored-session') expect(setSessionTabVisibility).toHaveBeenCalledWith('restored-session', false) expect(setSessionTabVisibility.mock.invocationCallOrder[0]).toBeLessThan( diff --git a/src/main/runtime/orca-runtime-terminal-close-continuity.test.ts b/src/main/runtime/orca-runtime-terminal-close-continuity.test.ts index ef24301c7cf..5e100dd4dbd 100644 --- a/src/main/runtime/orca-runtime-terminal-close-continuity.test.ts +++ b/src/main/runtime/orca-runtime-terminal-close-continuity.test.ts @@ -72,25 +72,27 @@ describe('terminal close and handle incarnation continuity', () => { unsubscribe() }) - it('publishes no retirement or absence when the durable headless close fails', async () => { + it('still kills and keeps the removal when the durable headless close fails to flush', async () => { const harness = await createStaleTabCloseHarness({ headless: true }) - const published = vi.fn() - const unsubscribe = harness.runtime.onMobileSessionTabsChanged(published) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) harness.rejectPersistenceFlush(new Error('disk-full')) - await expect(harness.runtime.closeTerminalTab(harness.terminal.handle)).rejects.toThrow( - 'disk-full' - ) - - expect(harness.kill).not.toHaveBeenCalled() - expect(published).not.toHaveBeenCalled() - expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toHaveLength(1) - const snapshot = await harness.runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) - expect(snapshot).toMatchObject({ - tabs: [expect.objectContaining({ parentTabId: TAB_ID, leafId: LEAF_ID })] + await expect(harness.runtime.closeTerminalTab(harness.terminal.handle)).resolves.toMatchObject({ + tabId: TAB_ID, + closeMode: 'tab' }) - expect(snapshot.retiredTerminalSurfaces).toBeUndefined() - unsubscribe() + + // Why: a failed flush is bookkeeping; the user's close still stops its process and the + // in-memory removal stays for the next flush to write. + expect(harness.kill).toHaveBeenCalledWith(RUNTIME_OWNED_PTY_ID) + expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toEqual([]) + const snapshot = await harness.runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + expect(snapshot.tabs).toEqual([]) + expect(errorSpy).toHaveBeenCalledWith( + '[runtime] failed to persist terminal close:', + expect.any(Error) + ) + errorSpy.mockRestore() }) it('publishes each split leaf retirement with its own terminal handle', async () => { @@ -366,6 +368,36 @@ describe('terminal close and handle incarnation continuity', () => { expect(harness.kill).toHaveBeenCalledWith(PTY_ID) }) + it.each([true, false])( + 'promises a retry only when the stop recorded a replayable kill (recorded: %s)', + async (recorded) => { + const harness = createHarness() + const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals + harness.setVerifiedStopResult(false) + const recordUnconfirmedStop = vi.fn(() => { + // The order must exist before the follow-up kill, whose own failure lands only later. + expect(harness.kill).not.toHaveBeenCalled() + return recorded + }) + const controller = harness.runtime['ptyController'] + if (!controller) { + throw new Error('fixture has no PTY controller') + } + Object.assign(controller, { recordUnconfirmedStop }) + + const closing = harness.runtime.closeTerminal(handle) + await vi.waitFor(() => expect(harness.closeTerminalTab).toHaveBeenCalled()) + harness.retirePersistedTab() + harness.acknowledged.resolve() + + const close = await closing + expect(recordUnconfirmedStop).toHaveBeenCalledWith(PTY_ID) + expect(harness.kill).toHaveBeenCalledWith(PTY_ID) + expect(close.ptyStopVerdict).toBe('unverifiable') + expect(close.pendingKillRecorded).toBe(recorded ? true : undefined) + } + ) + it('leaves a confirmed kill receipt free of any stop verdict', async () => { const harness = createHarness() const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals diff --git a/src/main/runtime/orca-runtime-terminal-close-no-widening.test.ts b/src/main/runtime/orca-runtime-terminal-close-no-widening.test.ts new file mode 100644 index 00000000000..1b98c67b8be --- /dev/null +++ b/src/main/runtime/orca-runtime-terminal-close-no-widening.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, it } from 'vitest' +import { + LEAF_ID, + PTY_ID, + SIBLING_LEAF_ID, + SIBLING_PTY_ID, + TAB_ID, + WORKTREE_ID, + createHarness, + type CloseContinuityHarness +} from './__fixtures__/orca-runtime-terminal-close-continuity-fixtures' +import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement' + +const emptyLayout = { root: null, activeLeafId: null, expandedLeafId: null } + +/** + * Every explicit close of one pane, from every entry point, under every condition that has ever + * widened one into a whole-tab close: the live sibling survives and no tab-level close goes out. + */ + +type Entry = 'renderer' | 'cli-graph' | 'cli-pty' | 'phone-desktop' | 'phone-headless' +type Condition = + | 'normal' + | 'stop-unconfirmed' + | 'stop-throws' + | 'leaf-already-retired' + | 'unbound-sibling' + | 'pinned' + | 'no-saved-layout' + | 'no-live-pty' + | 'kill-fails' + | 'nothing-published' + +/** The desktop renderer's split tab as it publishes it, with the given PTY bindings. */ +function syncRendererSplit( + harness: CloseContinuityHarness, + ptyIdsByLeafId: Record, + livePtyIdsByLeafId = ptyIdsByLeafId +): void { + const parentLayout = { + root: { + type: 'split' as const, + direction: 'horizontal' as const, + first: { type: 'leaf' as const, leafId: LEAF_ID }, + second: { type: 'leaf' as const, leafId: SIBLING_LEAF_ID } + }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId + } + const leaves = [LEAF_ID, SIBLING_LEAF_ID].map((leafId, index) => ({ + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + leafId, + paneRuntimeId: 7 + index, + ptyId: livePtyIdsByLeafId[leafId] ?? null + })) + harness.runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + title: 'Fixture shell', + activeLeafId: LEAF_ID, + layout: parentLayout.root + } + ], + leaves, + mobileSessionTabs: [ + { + worktree: WORKTREE_ID, + publicationEpoch: 'renderer:unbound-pane', + snapshotVersion: 3, + activeGroupId: null, + activeTabId: `${TAB_ID}::${LEAF_ID}`, + activeTabType: 'terminal' as const, + tabs: leaves.map((leaf) => ({ + type: 'terminal' as const, + id: `${TAB_ID}::${leaf.leafId}`, + parentTabId: TAB_ID, + leafId: leaf.leafId, + ...(ptyIdsByLeafId[leaf.leafId] ? { ptyId: ptyIdsByLeafId[leaf.leafId] } : {}), + title: 'Fixture shell', + parentLayout, + isActive: leaf.leafId === LEAF_ID + })) + } + ] + }) +} + +function arrange(entry: Entry, condition: Condition): CloseContinuityHarness { + const usesPtyRecord = entry !== 'cli-graph' && entry !== 'renderer' && condition !== 'no-live-pty' + const harness = createHarness({ publishMobileSurface: true, registerPtyBacked: usesPtyRecord }) + if (condition === 'unbound-sibling') { + // A pane the renderer just split: no PTY bound yet, so main's saved layout lacks it. + syncRendererSplit(harness, { [LEAF_ID]: PTY_ID }) + } else { + harness.syncSplitFixtureGraph() + } + if (condition === 'no-live-pty') { + // The closed pane's process already exited, so main holds no record of its PTY. + syncRendererSplit( + harness, + { [LEAF_ID]: 'pty-exited', [SIBLING_LEAF_ID]: SIBLING_PTY_ID }, + { [SIBLING_LEAF_ID]: SIBLING_PTY_ID } + ) + } + if (entry === 'cli-pty') { + // Graph without the leaf: the handle resolves through the PTY, as for a runtime-owned pane. + harness.syncFixtureTabWithoutLeaf() + } + if (condition === 'nothing-published') { + // The renderer lists the tab before its panes register and before it publishes any row. + harness.runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + title: 'Fixture shell', + activeLeafId: null, + layout: null + } + ], + leaves: [], + mobileSessionTabs: [ + { + worktree: WORKTREE_ID, + publicationEpoch: 'renderer:nothing-published', + snapshotVersion: 4, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + } + if (condition === 'kill-fails') { + harness.kill.mockReturnValue(false) + } + if (condition === 'stop-unconfirmed') { + harness.setVerifiedStopResult(false) + } else if (condition === 'stop-throws') { + harness.setVerifiedStopResult(new Error('ssh_host_unreachable')) + } else { + harness.setVerifiedStopResult(true) + } + if (condition === 'leaf-already-retired') { + harness.editSession((session) => + retireTerminalSurfaceFromPersistence(session, { + worktreeId: WORKTREE_ID, + parentTabId: TAB_ID, + leafId: LEAF_ID, + ptyId: PTY_ID + }) + ) + } else if (condition === 'pinned') { + harness.editSession((session) => ({ + ...session, + tabsByWorktree: { + [WORKTREE_ID]: (session.tabsByWorktree[WORKTREE_ID] ?? []).map((tab) => ({ + ...tab, + isPinned: true + })) + } + })) + } else if (condition === 'no-saved-layout') { + harness.editSession((session) => ({ ...session, terminalLayoutsByTabId: {} })) + } + // A renderer that honours a whole-tab close, so a widened close shows as the lost sibling. + harness.setCloseTerminalTabAction(() => harness.retirePersistedTab()) + return harness +} + +async function closePane(entry: Entry, harness: CloseContinuityHarness): Promise { + if (entry === 'renderer') { + await harness.runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: WORKTREE_ID, + target: { + kind: 'pane', + tabId: TAB_ID, + leafId: LEAF_ID + } + }) + return + } + if (entry === 'cli-graph' || entry === 'cli-pty') { + const terminal = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals.find( + (candidate) => candidate.ptyId === PTY_ID + ) + if (!terminal) { + throw new Error('fixture pane has no terminal handle') + } + await harness.runtime.closeTerminal(terminal.handle) + return + } + if (entry === 'phone-headless') { + harness.runtime.setNotifier(null) + harness.syncEmptyGraph() + } + await harness.runtime.closeMobileSessionTab(`id:${WORKTREE_ID}`, `${TAB_ID}::${LEAF_ID}`, { + reason: 'user' + }) +} + +const rows: [Entry, Condition][] = [ + // e.g. the exited-pane overlay's Close after main's exit handling retired that pane (fc5cac5c32). + ['renderer', 'leaf-already-retired'], + ['renderer', 'unbound-sibling'], + ['renderer', 'pinned'], + ['renderer', 'no-saved-layout'], + ['cli-graph', 'normal'], + ['cli-graph', 'stop-unconfirmed'], + ['cli-graph', 'leaf-already-retired'], + ['cli-graph', 'unbound-sibling'], + ['cli-pty', 'normal'], + // An unconfirmed stop once chose the tab close (bc8bbd7abf). + ['cli-pty', 'stop-unconfirmed'], + ['cli-pty', 'stop-throws'], + ['cli-pty', 'unbound-sibling'], + // An owner copy with no panes once read as "one pane", so the close took the whole tab. + ['cli-pty', 'nothing-published'], + ['phone-desktop', 'normal'], + ['phone-desktop', 'pinned'], + ['phone-desktop', 'unbound-sibling'], + ['phone-desktop', 'no-saved-layout'], + // A pane with no live process record once sent a tab-level close notice. + ['phone-desktop', 'no-live-pty'], + ['phone-desktop', 'kill-fails'], + // A host with no desktop window once closed the whole tab for one pane. + ['phone-headless', 'normal'], + ['phone-headless', 'kill-fails'], + ['phone-headless', 'leaf-already-retired'], + ['phone-headless', 'pinned'], + ['phone-headless', 'no-saved-layout'] +] + +describe('an explicit close of one pane never widens into its tab', () => { + it.each(rows)('%s close, %s', async (entry, condition) => { + const harness = arrange(entry, condition) + const siblingWasPersisted = Boolean( + harness.getSession().terminalLayoutsByTabId[TAB_ID]?.ptyIdsByLeafId?.[SIBLING_LEAF_ID] + ) + + await closePane(entry, harness) + + const session = harness.getSession() + expect(session.tabsByWorktree[WORKTREE_ID]).toEqual([expect.objectContaining({ id: TAB_ID })]) + if (siblingWasPersisted) { + expect(session.terminalLayoutsByTabId[TAB_ID]?.root).toEqual({ + type: 'leaf', + leafId: SIBLING_LEAF_ID + }) + } + expect(harness.closeTerminalTab).not.toHaveBeenCalled() + expect(harness.closeTerminal).not.toHaveBeenCalled() + expect(harness.kill).not.toHaveBeenCalledWith(SIBLING_PTY_ID) + if (entry === 'renderer') { + return + } + if (entry === 'phone-headless') { + const snapshot = await harness.runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + expect(snapshot.tabs.map((tab) => tab.id)).toEqual([`${TAB_ID}::${SIBLING_LEAF_ID}`]) + expect(harness.kill).toHaveBeenCalledWith(PTY_ID) + return + } + expect(harness.closeTerminalPane).toHaveBeenCalledExactlyOnceWith(TAB_ID, LEAF_ID) + }) +}) + +describe("a close of a tab's last pane still closes the tab", () => { + it('on a host with no desktop window, closes the whole tab in main and for paired clients', async () => { + const harness = createHarness({ publishMobileSurface: true, registerPtyBacked: true }) + harness.runtime.setNotifier(null) + harness.syncEmptyGraph() + + await harness.runtime.closeMobileSessionTab(`id:${WORKTREE_ID}`, `${TAB_ID}::${LEAF_ID}`, { + reason: 'user' + }) + + expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect((await harness.runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).tabs).toEqual([]) + expect(harness.kill).toHaveBeenCalledWith(PTY_ID) + }) + + it.each([ + ['no saved layout', () => ({})], + ['a layout saved before its pane mounted', () => ({ [TAB_ID]: emptyLayout })] + ] as const)( + 'on a host with no desktop window, closes an unsplit tab with %s', + async (_name, layouts) => { + const harness = createHarness({ publishMobileSurface: true, registerPtyBacked: true }) + harness.runtime.setNotifier(null) + harness.syncEmptyGraph() + harness.editSession((session) => ({ ...session, terminalLayoutsByTabId: layouts() })) + + await harness.runtime.closeMobileSessionTab(`id:${WORKTREE_ID}`, `${TAB_ID}::${LEAF_ID}`, { + reason: 'user' + }) + + expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(harness.kill).toHaveBeenCalledWith(PTY_ID) + } + ) + + it('from the CLI by PTY, closes an unsplit tab whose renderer graph lists no panes yet', async () => { + const harness = createHarness({ publishMobileSurface: true, registerPtyBacked: true }) + harness.syncFixtureTabWithoutLeaf() + harness.setVerifiedStopResult(true) + harness.setCloseTerminalTabAction(() => harness.retirePersistedTab()) + + await closePane('cli-pty', harness) + + expect(harness.closeTerminalTab.mock.calls.map((call) => call[0])).toEqual([TAB_ID]) + expect(harness.closeTerminalPane).not.toHaveBeenCalled() + }) + + it.each(['phone-desktop', 'cli-graph'] as const)( + 'from %s, goes through the renderer tab close so its pin guard still runs', + async (entry) => { + const harness = createHarness({ + publishMobileSurface: true, + registerPtyBacked: entry === 'phone-desktop' + }) + harness.setVerifiedStopResult(true) + harness.setCloseTerminalTabAction(() => harness.retirePersistedTab()) + + await closePane(entry, harness) + + expect(harness.closeTerminalTab.mock.calls.map((call) => call[0])).toEqual([TAB_ID]) + expect(harness.closeTerminalPane).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/main/runtime/orca-runtime-terminal-close-records.test.ts b/src/main/runtime/orca-runtime-terminal-close-records.test.ts new file mode 100644 index 00000000000..f05ac78957a --- /dev/null +++ b/src/main/runtime/orca-runtime-terminal-close-records.test.ts @@ -0,0 +1,309 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import { + CLOSED_TERMINAL_TAB_TOMBSTONE_TTL_MS, + MAX_CLOSED_TERMINAL_TAB_TOMBSTONES +} from '../../shared/closed-terminal-tab-tombstones' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { Store } from '../persistence/loading-store/store' +import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' +import { OrcaRuntimeService } from './orca-runtime' +import { + LEAF_ID, + REPO_ID, + TAB_ID, + WORKTREE_ID, + WORKTREE_PATH, + makeSession +} from './__fixtures__/orca-runtime-terminal-close-continuity-fixtures' +import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' + +const SSH_REPO_ID = 'ssh-repo' +const SSH_HOST_ID = 'ssh:target-1' +const SSH_WORKTREE_ID = `${SSH_REPO_ID}::/srv/app` +const LATE_TAB_ID = '6f0a5c8e-2b1d-4c3e-9f7a-1d2e3f4a5b6c' + +const directories: string[] = [] +afterEach(async () => { + await closeTestStores() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function createPersistedRuntime(session: WorkspaceSessionState = makeSession()) { + const directory = mkdtempSync(join(tmpdir(), 'orca-close-records-')) + directories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const store = createSqliteTestStore(Store, { dataFile }) + store.addRepo({ + id: REPO_ID, + path: WORKTREE_PATH, + displayName: 'Fixture', + badgeColor: 'gray', + addedAt: 1 + }) + store.addRepo({ + id: SSH_REPO_ID, + path: '/srv/app', + displayName: 'Remote', + badgeColor: 'gray', + addedAt: 1, + connectionId: 'target-1' + }) + store.setWorkspaceSession(advanceTerminalTopologyRevision(session, WORKTREE_ID)) + store.flushOrThrow() + return { + store, + runtime: new OrcaRuntimeService(store), + reload: async () => { + store.flush() + store.freezeWrites() + await store.waitForPendingWrite() + return createSqliteTestStore(Store, { dataFile }) + } + } +} + +/** A renderer save: membership as the renderer holds it, and no close records, which it never sends. */ +function rendererSave(session: WorkspaceSessionState): WorkspaceSessionState { + const { + terminalTopologyRevisionByRepoId: _hostPrivate, + closedTerminalTabTombstonesByTabId: _mainOwned, + ...rendererView + } = session + return { ...rendererView, tabsByWorktree: { [WORKTREE_ID]: [] }, terminalLayoutsByTabId: {} } +} + +describe('close records', () => { + it.each(['user', 'cleanup'] as const)( + 'records a %s close in main and keeps it across a renderer save and a reload', + async (reason) => { + const { store, runtime, reload } = createPersistedRuntime() + + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: WORKTREE_ID, + target: { kind: 'tab', tabId: TAB_ID }, + reason + }) + store.setWorkspaceSession(rendererSave(store.getWorkspaceSession())) + + const reloaded = (await reload()).getWorkspaceSession() + expect(reloaded.tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(reloaded.closedTerminalTabTombstonesByTabId?.[TAB_ID]).toEqual({ + closedAt: expect.any(Number), + worktreeId: WORKTREE_ID, + reason + }) + } + ) + + // The store keeps main's map only when a write omits it; main's own writes carry it and win. + it("keeps main's own record writes across later store writes", async () => { + const { store, runtime } = createPersistedRuntime() + + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: WORKTREE_ID, + target: { kind: 'tab', tabId: TAB_ID } + }) + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: WORKTREE_ID, + target: { kind: 'tab', tabId: LATE_TAB_ID } + }) + store.setWorkspaceSession(rendererSave(store.getWorkspaceSession())) + + expect( + Object.keys(store.getWorkspaceSession().closedTerminalTabTombstonesByTabId ?? {}).sort() + ).toEqual([LATE_TAB_ID, TAB_ID].sort()) + }) + + it("keeps a close's first reason when the renderer's echo closes the same tab again", async () => { + const { store, runtime } = createPersistedRuntime() + + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: WORKTREE_ID, + target: { kind: 'tab', tabId: LATE_TAB_ID }, + reason: 'cleanup' + }) + const first = store.getWorkspaceSession().closedTerminalTabTombstonesByTabId?.[LATE_TAB_ID] + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: WORKTREE_ID, + target: { kind: 'tab', tabId: LATE_TAB_ID }, + reason: 'user' + }) + + expect(first?.reason).toBe('cleanup') + expect(store.getWorkspaceSession().closedTerminalTabTombstonesByTabId?.[LATE_TAB_ID]).toEqual( + first + ) + }) + + it('lets a close that removes a listed tab replace a record that tab already had', async () => { + const earlier = Date.now() - 24 * 60 * 60 * 1000 + const { store, runtime } = createPersistedRuntime({ + ...makeSession(), + closedTerminalTabTombstonesByTabId: { + [TAB_ID]: { closedAt: earlier, worktreeId: WORKTREE_ID, reason: 'cleanup' } + } + }) + + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: WORKTREE_ID, + target: { kind: 'tab', tabId: TAB_ID }, + reason: 'user' + }) + + const record = store.getWorkspaceSession().closedTerminalTabTombstonesByTabId?.[TAB_ID] + expect(store.getWorkspaceSession().tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(record?.reason).toBe('user') + expect(record?.closedAt).toBeGreaterThan(earlier) + }) + + it('admits a late spawn for a tab whose close record is past the TTL', async () => { + const expired = { + [LATE_TAB_ID]: { + closedAt: Date.now() - CLOSED_TERMINAL_TAB_TOMBSTONE_TTL_MS - 60_000, + worktreeId: WORKTREE_ID, + reason: 'user' as const + } + } + const { store } = createPersistedRuntime({ + ...makeSession(), + closedTerminalTabTombstonesByTabId: expired + }) + + expect( + await store.persistPtyBinding({ + worktreeId: WORKTREE_ID, + tabId: LATE_TAB_ID, + leafId: LEAF_ID, + ptyId: 'late-pty', + incarnationId: 'late-incarnation' + }) + ).toBe(true) + }) + + it('records nothing for a split pane close, which leaves its tab open', async () => { + const { store, runtime } = createPersistedRuntime() + + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: WORKTREE_ID, + target: { + kind: 'pane', + tabId: 'unknown-tab', + leafId: LEAF_ID + } + }) + + expect(store.getWorkspaceSession().closedTerminalTabTombstonesByTabId).toBeUndefined() + }) + + // Why: only a resolved tab close records; a pane target never widens here, even on the last pane. + it("records nothing for a pane close aimed at its tab's only pane", async () => { + const { store, runtime } = createPersistedRuntime() + + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: WORKTREE_ID, + target: { + kind: 'pane', + tabId: TAB_ID, + leafId: LEAF_ID + } + }) + + expect(store.getWorkspaceSession().closedTerminalTabTombstonesByTabId).toBeUndefined() + expect(store.getWorkspaceSession().tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([ + TAB_ID + ]) + }) + + // The durable half of refusing a late graft: the close lands while the tab's spawn is in + // flight, so main has never listed the tab, and the spawn commits only after a relaunch. + it('refuses a closed tab whose spawn commits after a crash and reload', async () => { + const { runtime, reload } = createPersistedRuntime() + + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: WORKTREE_ID, + target: { kind: 'tab', tabId: LATE_TAB_ID } + }) + const relaunched = await reload() + + expect( + await relaunched.persistPtyBinding({ + worktreeId: WORKTREE_ID, + tabId: LATE_TAB_ID, + leafId: LEAF_ID, + ptyId: 'late-pty', + incarnationId: 'late-incarnation' + }) + ).toBe(false) + expect( + relaunched.getWorkspaceSession().tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id) + ).not.toContain(LATE_TAB_ID) + }) + + it('refuses the graft when the close was recorded in another host partition', async () => { + const { store, runtime } = createPersistedRuntime() + store.setWorkspaceSession( + { ...getDefaultWorkspaceSession(), tabsByWorktree: { [SSH_WORKTREE_ID]: [] } }, + SSH_HOST_ID + ) + + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: SSH_WORKTREE_ID, + target: { + kind: 'tab', + tabId: LATE_TAB_ID + } + }) + + expect( + store.getWorkspaceSession(SSH_HOST_ID).closedTerminalTabTombstonesByTabId?.[LATE_TAB_ID] + ).toBeDefined() + // A relay reattach binds into `local`, not the partition the close was recorded in. + expect( + await store.persistPtyBinding({ + worktreeId: SSH_WORKTREE_ID, + tabId: LATE_TAB_ID, + leafId: LEAF_ID, + ptyId: 'ssh:target-1@@pty2:epoch:1', + incarnationId: 'relay-incarnation' + }) + ).toBe(false) + }) + + it('prunes each host partition alone, so local churn evicts no SSH record', async () => { + const { store, runtime } = createPersistedRuntime() + store.setWorkspaceSession( + { ...getDefaultWorkspaceSession(), tabsByWorktree: { [SSH_WORKTREE_ID]: [] } }, + SSH_HOST_ID + ) + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: SSH_WORKTREE_ID, + target: { + kind: 'tab', + tabId: 'ssh-tab' + } + }) + + for (let index = 0; index <= MAX_CLOSED_TERMINAL_TAB_TOMBSTONES; index += 1) { + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: WORKTREE_ID, + target: { + kind: 'tab', + tabId: `local-${index}` + } + }) + } + + expect( + Object.keys(store.getWorkspaceSession().closedTerminalTabTombstonesByTabId ?? {}) + ).toHaveLength(MAX_CLOSED_TERMINAL_TAB_TOMBSTONES) + expect( + store.getWorkspaceSession(SSH_HOST_ID).closedTerminalTabTombstonesByTabId?.['ssh-tab'] + ).toBeDefined() + }) +}) diff --git a/src/main/runtime/orca-runtime-terminal-create-deduplication.ts b/src/main/runtime/orca-runtime-terminal-create-deduplication.ts index 5e196c32c71..59e13676e63 100644 --- a/src/main/runtime/orca-runtime-terminal-create-deduplication.ts +++ b/src/main/runtime/orca-runtime-terminal-create-deduplication.ts @@ -146,10 +146,8 @@ export class OrcaRuntimeWithTerminalCreateDeduplication extends OrcaRuntimeWithC opts: { agent: TuiAgent; prompt: string; title?: string } ): Promise { const worktree = await this.resolveWorktreeSelector(worktreeSelector) - // Why: the trust write lands in an agent's config on the machine that runs it, keyed by the - // workspace path. `getRepo(id)` is host-blind, so reading `connectionId` off it wrote a remote - // path into the *client's* config — the agent on the host never sees the trust (#11163). - // Same shape as the folder-create trust write fixed alongside this; the agent-launch half. + // Why: `getRepo(id)` is host-blind; the same repo id on two hosts must build the launch for the + // host that owns this worktree (#11163). const resolution = resolveWorktreeLaunchHost(this.store?.getRepos() ?? [], worktree) if (resolution.kind === 'ambiguous') { throw new Error('worktree_execution_host_unresolved') @@ -159,7 +157,6 @@ export class OrcaRuntimeWithTerminalCreateDeduplication extends OrcaRuntimeWithC throw new Error('Repository for the selected workspace is no longer available.') } const startup = this.buildStartupForAgent(repo, opts.agent, opts.prompt) - await this.markWorkspaceTrustedForAgent(opts.agent, resolution.connectionId, worktree.path) return await this.createTerminal(`id:${worktree.id}`, { command: startup.startup.command, env: startup.startup.env, diff --git a/src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts b/src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts index bac59e614b2..101ad7d4e4e 100644 --- a/src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts +++ b/src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' +import { makePaneKey } from '../../shared/stable-pane-id' const PTY_ID = 'ssh:target@@relay-pty' const WORKTREE_ID = 'repo::/worktree' const TAB_ID = 'tab-terminal' const LEAF_ID = '11111111-1111-4111-8111-111111111111' +/** A runtime whose pty controller captures every write for assertion. */ function makeRuntime(): { runtime: OrcaRuntimeService; writes: string[] } { const writes: string[] = [] const runtime = new OrcaRuntimeService(null) @@ -20,6 +22,7 @@ function makeRuntime(): { runtime: OrcaRuntimeService; writes: string[] } { return { runtime, writes } } +/** Attach a window and publish a one-tab, one-leaf terminal graph. */ function syncGraph(runtime: OrcaRuntimeService): void { runtime.attachWindow(1) runtime.syncWindowGraph(1, { @@ -44,6 +47,7 @@ function syncGraph(runtime: OrcaRuntimeService): void { }) } +/** (Re)register the fixture pty leaf under the given incarnation id. */ function register(runtime: OrcaRuntimeService, incarnationId: string): void { runtime.registerPty(PTY_ID, WORKTREE_ID, 'target', { tabId: TAB_ID, @@ -66,6 +70,8 @@ describe('runtime terminal handle incarnation fencing', () => { inspectProcess }) expect(runtime.markRendererReloading(1)).not.toBeNull() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. expect((runtime as unknown as { handles: Map }).handles.has(handle)).toBe( false ) @@ -89,6 +95,8 @@ describe('runtime terminal handle incarnation fencing', () => { }) }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. it('treats a null-to-known incarnation as the same un-fenced PTY', async () => { const { runtime } = makeRuntime() const handle = runtime.preAllocateHandleForPty(PTY_ID) @@ -132,6 +140,8 @@ describe('runtime terminal handle incarnation fencing', () => { // Rotate the record directly so reconcile is the only fence exercised. // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test reaches the runtime's protected pty record map to bypass the registerPty fence. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. const internals = runtime as unknown as { ptysById: Map } @@ -159,12 +169,16 @@ describe('runtime terminal handle incarnation fencing', () => { }) expect(replacement?.handle).not.toBe(staleHandle) await expect(runtime.readTerminal(staleHandle)).rejects.toThrow('terminal_handle_stale') - await expect(runtime.sendTerminal(staleHandle, { text: 'stale input' })).rejects.toThrow( - 'terminal_handle_stale' - ) + await expect( + runtime.sendTerminal(staleHandle, { text: 'stale input' }, { inputKind: 'driving' }) + ).rejects.toThrow('terminal_handle_stale') await expect( - runtime.sendTerminal(replacement!.handle, { text: 'replacement input' }) + runtime.sendTerminal( + replacement!.handle, + { text: 'replacement input' }, + { inputKind: 'driving' } + ) ).resolves.toMatchObject({ accepted: true, handle: replacement!.handle @@ -215,6 +229,8 @@ describe('runtime terminal handle incarnation fencing', () => { runtime.registerPreAllocatedHandleForPty(PTY_ID, replacementHandle) syncGraph(runtime) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. const internals = runtime as unknown as { handles: Map handleByLeafKey: Map @@ -237,3 +253,130 @@ describe('runtime terminal handle incarnation fencing', () => { }) }) }) + +describe('resolveTerminalHandleByProcessIncarnation direct fencing', () => { + const LOCAL_SCOPE = JSON.stringify({ kind: 'local', hostId: 'local' }) + // PTY_ID ('ssh:target@@relay-pty') derives connectionId 'target' via parseAppSshPtyId, so its + // host scope is ssh:target rather than local. + const SSH_TARGET_SCOPE = JSON.stringify({ kind: 'ssh', targetId: 'target' }) + + /** Register a pty leaf directly, optionally under a connection and incarnation. */ + function seedPty( + runtime: OrcaRuntimeService, + ptyId: string, + incarnationId: string | null, + connectionId: string | null = null + ): void { + runtime.registerPty(ptyId, WORKTREE_ID, connectionId, { + tabId: TAB_ID, + leafId: LEAF_ID, + ...(incarnationId ? { incarnationId } : {}) + }) + } + + function resolve( + runtime: OrcaRuntimeService, + processIncarnation: string, + serializedHostScope: string | null + ): string | null { + return runtime.resolveTerminalHandleByProcessIncarnation( + processIncarnation, + serializedHostScope + ) + } + + it('mints a live handle for a pty whose exact incarnation and host scope match', () => { + const { runtime } = makeRuntime() + seedPty(runtime, PTY_ID, 'incarnation-1') + const handle = resolve(runtime, `${PTY_ID}:incarnation-1`, SSH_TARGET_SCOPE) + expect(handle).toMatch(/^term_/) + // Re-minting the same live pty is idempotent. + expect(resolve(runtime, `${PTY_ID}:incarnation-1`, SSH_TARGET_SCOPE)).toBe(handle) + }) + + it('returns null when the recorded incarnationId no longer matches the live pty', () => { + const { runtime } = makeRuntime() + seedPty(runtime, PTY_ID, 'incarnation-1') + expect(resolve(runtime, `${PTY_ID}:incarnation-2`, LOCAL_SCOPE)).toBeNull() + }) + + it('returns null when the live pty has no incarnationId (legacy runtimeId:ptyId:gen never reaps)', () => { + const { runtime } = makeRuntime() + seedPty(runtime, PTY_ID, null) + // A modern-shaped probe cannot match a pty that carries no incarnationId... + expect(resolve(runtime, `${PTY_ID}:incarnation-1`, LOCAL_SCOPE)).toBeNull() + // ...and the legacy `${runtimeId}:${ptyId}:${ptyGeneration}` shape stays fail-closed rather + // than reap on a ptyGeneration guess. + expect(resolve(runtime, `runtime_test:${PTY_ID}:0`, LOCAL_SCOPE)).toBeNull() + }) + + it('returns null when the host scope does not match', () => { + const { runtime } = makeRuntime() + seedPty(runtime, PTY_ID, 'incarnation-1') + expect( + resolve(runtime, `${PTY_ID}:incarnation-1`, JSON.stringify({ kind: 'ssh', targetId: 'nope' })) + ).toBeNull() + }) + + it('returns null when no host scope is supplied (fails closed)', () => { + const { runtime } = makeRuntime() + seedPty(runtime, PTY_ID, 'incarnation-1') + expect(resolve(runtime, `${PTY_ID}:incarnation-1`, null)).toBeNull() + }) + + it('resolves a colon-bearing SSH/relay incarnationId that lastIndexOf would mis-split', () => { + const { runtime } = makeRuntime() + // PTY_ID already carries ':' and '@@'; the incarnationId itself also carries colons. + seedPty(runtime, PTY_ID, 'relay:conn-3:incarnation-9', 'target') + const handle = resolve( + runtime, + `${PTY_ID}:relay:conn-3:incarnation-9`, + JSON.stringify({ kind: 'ssh', targetId: 'target' }) + ) + expect(handle).toMatch(/^term_/) + }) + + it('resolves a Windows repo::C:\\path@@1 ptyId', () => { + const { runtime } = makeRuntime() + const windowsPtyId = 'repo::C:\\path@@1' + seedPty(runtime, windowsPtyId, 'incarnation-win') + const handle = resolve(runtime, `${windowsPtyId}:incarnation-win`, LOCAL_SCOPE) + expect(handle).toMatch(/^term_/) + }) + + it('keeps scanning past a colon-ambiguous decoy in another host scope to the genuine match', () => { + const { runtime } = makeRuntime() + // One process-incarnation string, two colon-ambiguous pty ids that both satisfy the exact + // matcher: 'relay' + 'conn:incarnation-1' AND 'relay:conn' + 'incarnation-1' both stringify to + // 'relay:conn:incarnation-1'. The decoy is registered FIRST and lives in a different host scope + // (ssh:decoy); the genuine pty is local and registered later. + const processIncarnation = 'relay:conn:incarnation-1' + const DECOY_TAB_ID = 'tab-decoy' + const DECOY_LEAF_ID = '22222222-2222-4222-8222-222222222222' + const GENUINE_TAB_ID = 'tab-genuine' + const GENUINE_LEAF_ID = '33333333-3333-4333-8333-333333333333' + runtime.registerPty('relay', WORKTREE_ID, 'decoy', { + tabId: DECOY_TAB_ID, + leafId: DECOY_LEAF_ID, + incarnationId: 'conn:incarnation-1' + }) + runtime.registerPty('relay:conn', WORKTREE_ID, null, { + tabId: GENUINE_TAB_ID, + leafId: GENUINE_LEAF_ID, + incarnationId: 'incarnation-1' + }) + + // Before the fix, the earlier decoy's host-scope mismatch returned null and suppressed the + // genuine same-scope pty entirely. `continue` lets the scan reach it. + const handle = resolve(runtime, processIncarnation, LOCAL_SCOPE) + expect(handle).toMatch(/^term_/) + // getTerminalProcessIncarnation cannot separate the two (both stringify to + // 'relay:conn:incarnation-1'), so pin the remint to the GENUINE pane. The paneKey is the only + // terminal-specific observable that distinguishes the genuine leaf from the same-incarnation + // decoy; this fails if the resolver ever mints the decoy's handle instead of the genuine pty's. + const genuinePaneKey = makePaneKey(GENUINE_TAB_ID, GENUINE_LEAF_ID) + const decoyPaneKey = makePaneKey(DECOY_TAB_ID, DECOY_LEAF_ID) + expect(runtime.getTerminalPaneKey(handle!)).toBe(genuinePaneKey) + expect(runtime.getTerminalPaneKey(handle!)).not.toBe(decoyPaneKey) + }) +}) diff --git a/src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts b/src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts index 3079ce1f4da..3a91794f441 100644 --- a/src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts +++ b/src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' @@ -123,7 +124,8 @@ function partitionedStore(): PartitionedStoreHarness { ]) const writes: { hostId: ExecutionHostId | undefined; session: WorkspaceSessionState }[] = [] const reads: (ExecutionHostId | undefined)[] = [] - const store = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This runtime fixture supplies the persistence and graph methods exercised by the test. + const store = withDurableRuntimeStore({ getRepos: () => [SSH_REPO], getRepo: (id: string) => (id === SSH_REPO_ID ? SSH_REPO : undefined), getWorkspaceSessionHostIds: () => [...sessions.keys()], @@ -136,7 +138,7 @@ function partitionedStore(): PartitionedStoreHarness { sessions.set(hostId ?? LOCAL_EXECUTION_HOST_ID, session) }, flushOrThrow: vi.fn() - } as never + }) as never return { store, sessions, writes, reads } } @@ -210,7 +212,8 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', } ] ]) - const store = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This runtime fixture supplies the persistence and graph methods exercised by the test. + const store = withDurableRuntimeStore({ getRepos: () => [{ ...SSH_REPO, executionHostId: staleHostId }], getRepo: () => ({ ...SSH_REPO, executionHostId: staleHostId }), getWorktreeMeta: () => undefined, @@ -221,7 +224,7 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', setWorkspaceSession: (session: WorkspaceSessionState, hostId?: ExecutionHostId) => sessions.set(hostId ?? LOCAL_EXECUTION_HOST_ID, session), flushOrThrow: vi.fn() - } as never + }) as never const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ write: () => true, @@ -285,7 +288,8 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', // The SSH copy of the same `repoId::path` currently has no terminals. [SSH_HOST_ID, { ...getDefaultWorkspaceSession(), tabsByWorktree: { [SSH_WORKTREE_ID]: [] } }] ]) - const store = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This runtime fixture supplies the persistence and graph methods exercised by the test. + const store = withDurableRuntimeStore({ getRepos: () => [SSH_REPO], getRepo: (id: string) => (id === SSH_REPO_ID ? SSH_REPO : undefined), getWorktreeMeta: () => ({ hostId: SSH_HOST_ID }), @@ -298,7 +302,7 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', sessions.set(hostId ?? LOCAL_EXECUTION_HOST_ID, session), flushOrThrow: vi.fn(), persistPtyBinding: vi.fn() - } as never + }) as never const runtime = new OrcaRuntimeService(store) const stopAndWait = vi.fn(async () => true) runtime.setPtyController({ @@ -334,7 +338,8 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', ], [staleHostId, { ...getDefaultWorkspaceSession(), tabsByWorktree: { [SSH_WORKTREE_ID]: [] } }] ]) - const store = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This runtime fixture supplies the persistence and graph methods exercised by the test. + const store = withDurableRuntimeStore({ getRepos: () => [{ ...SSH_REPO, executionHostId: staleHostId }], getRepo: () => ({ ...SSH_REPO, executionHostId: staleHostId }), getWorktreeMeta: () => ({}), @@ -347,7 +352,7 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', sessions.set(hostId ?? LOCAL_EXECUTION_HOST_ID, session), flushOrThrow: vi.fn(), persistPtyBinding: vi.fn() - } as never + }) as never const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ write: () => true, @@ -408,6 +413,62 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', expect(targets.get(folderWorktreeId)).toBe(localSession) }) + it.each<[string, string, Record, ExecutionHostId]>([ + ['local repo', 'repo::/wt', { id: 'repo' }, LOCAL_EXECUTION_HOST_ID], + ['SSH repo', 'repo::/wt', { id: 'repo', connectionId: 'c1' }, 'ssh:c1'], + ['runtime repo', 'repo::/wt', { id: 'repo', executionHostId: 'runtime:e1' }, 'runtime:e1'], + ['local folder', 'folder:f1', { folderId: 'f1' }, LOCAL_EXECUTION_HOST_ID], + ['SSH folder', 'folder:f1', { folderId: 'f1', connectionId: 'c1' }, 'ssh:c1'], + ['explicit SSH folder', 'folder:f1', { folderId: 'f1', executionHostId: 'ssh:c2' }, 'ssh:c2'], + ['runtime folder', 'folder:f1', { folderId: 'f1', executionHostId: 'runtime:e1' }, 'runtime:e1'] + ])('reads a %s own saved partition', (_label, worktreeId, owner, ownerHostId) => { + const listing = (id: string) => ({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [worktreeId]: [ + { + id, + ptyId: null, + worktreeId, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + }) + const sessions = new Map([ + [ownerHostId, listing('own')], + ['runtime:e0', listing('rotated')] + ]) + const { folderId, ...repoOrFolder } = owner + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the controller reads only repos, folder workspaces and workspace sessions. + const store = { + getRepos: () => (folderId ? [] : [repoOrFolder]), + getRepo: () => (folderId ? undefined : repoOrFolder), + getFolderWorkspaces: () => (folderId ? [{ ...repoOrFolder, id: folderId }] : []), + getWorkspaceSessionHostIds: () => [...sessions.keys()], + getWorkspaceSession: (hostId: ExecutionHostId) => + sessions.get(hostId) ?? getDefaultWorkspaceSession() + } as never + const controller = new RuntimeWorkspaceSessionController({ + getStore: () => store, + resolveFolderConnectionId: (workspace) => workspace.connectionId ?? null, + hasRuntimeOwnedPtyCandidate: () => false + }) + + expect(controller.getOwnPartition(worktreeId)).toBe(sessions.get(ownerHostId)) + expect(controller.get(worktreeId)).toBe(sessions.get(ownerHostId)) + + sessions.set(ownerHostId, getDefaultWorkspaceSession()) + const fellBack = controller.get(worktreeId) === sessions.get('runtime:e0') + // Only a runtime owner rotates onto another copy; that copy is never read as its own. + expect(fellBack).toBe(ownerHostId.startsWith('runtime:')) + expect(controller.getOwnPartition(worktreeId)?.tabsByWorktree[worktreeId]).toBeUndefined() + }) + it('waits for provider retirement on a direct worktree stop', async () => { const harness = partitionedStore() const runtime = new OrcaRuntimeService(harness.store) @@ -491,7 +552,7 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', incarnationId: 'incarnation-a' }) - runtime.onPtyExit(SSH_PTY_LEFT, 0, 'incarnation-a') + await runtime.onPtyExit(SSH_PTY_LEFT, 0, 'incarnation-a') // The durable retirement must land in the pane's own host partition. expect(harness.writes.map((write) => write.hostId)).toEqual([SSH_HOST_ID]) @@ -624,7 +685,7 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', incarnationId: 'incarnation-a' }) - runtime.onPtyExit('pty-left', 0, 'incarnation-a') + await runtime.onPtyExit('pty-left', 0, 'incarnation-a') expect(harness.writes.map((write) => write.hostId ?? LOCAL_EXECUTION_HOST_ID)).toEqual([ LOCAL_EXECUTION_HOST_ID diff --git a/src/main/runtime/orca-runtime-terminal-retirement.test.ts b/src/main/runtime/orca-runtime-terminal-retirement.test.ts index e1a2c68242c..d6f70fd4c6e 100644 --- a/src/main/runtime/orca-runtime-terminal-retirement.test.ts +++ b/src/main/runtime/orca-runtime-terminal-retirement.test.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' @@ -148,7 +149,7 @@ function makePersistedSplitSession(): WorkspaceSessionState { } describe('OrcaRuntimeService terminal surface retirement', () => { - it('releases each early-exit fence after its matching registration is rejected', () => { + it('releases each early-exit fence after its matching registration is rejected', async () => { const runtime = new OrcaRuntimeService() const internals = runtime as unknown as { earlyExitedPtyIncarnations: Map @@ -158,7 +159,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { const ptyId = `pty-early-${index}` const incarnationId = `incarnation-${index}` runtime.beginPtyRegistration(ptyId, incarnationId) - runtime.onPtyExit(ptyId, 0, incarnationId) + await runtime.onPtyExit(ptyId, 0, incarnationId) expect(() => runtime.assertPtyRegistrationAllowed(ptyId, incarnationId)).toThrow( 'agent_session_exited_during_start' ) @@ -168,7 +169,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { expect(internals.earlyExitedPtyIncarnations.size).toBe(0) }) - it('does not retain fences for completed surface-less lifecycles', () => { + it('does not retain fences for completed surface-less lifecycles', async () => { const runtime = new OrcaRuntimeService() const internals = runtime as unknown as { earlyExitedPtyIncarnations: Map @@ -179,14 +180,14 @@ describe('OrcaRuntimeService terminal surface retirement', () => { runtime.onPtySpawned(`pty-headless-${index}`, `incarnation-${index}`, { awaitsRegistration: false }) - runtime.onPtyExit(`pty-headless-${index}`, 0, `incarnation-${index}`) + await runtime.onPtyExit(`pty-headless-${index}`, 0, `incarnation-${index}`) } expect(internals.earlyExitedPtyIncarnations.size).toBe(0) expect(internals.pendingPtyRegistrationIncarnations.size).toBe(0) }) - it('fences an early-exited replacement even when its pane already exists', () => { + it('fences an early-exited replacement even when its pane already exists', async () => { const runtime = new OrcaRuntimeService() runtime.attachWindow(1) syncSplit(runtime) @@ -197,7 +198,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { }) runtime.onPtySpawned('pty-left', 'incarnation-replacement') - runtime.onPtyExit('pty-left', 0, 'incarnation-replacement') + await runtime.onPtyExit('pty-left', 0, 'incarnation-replacement') expect(() => runtime.assertPtyRegistrationAllowed('pty-left', 'incarnation-replacement') @@ -224,7 +225,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { ? leftBeforeExit.terminal : null - runtime.onPtyExit('pty-left', 0) + await runtime.onPtyExit('pty-left', 0) expect(await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).toMatchObject({ activeTabId: 'tab::right', @@ -467,16 +468,18 @@ describe('OrcaRuntimeService terminal surface retirement', () => { sleepingAgentSessionsByPaneKey: { 'tab:left': {} as never } } const runtime = new OrcaRuntimeService( - runtimeStore({ - getWorkspaceSession: () => session, - setWorkspaceSession: vi.fn(), - flushOrThrow: vi.fn() - }) + runtimeStore( + withDurableRuntimeStore({ + getWorkspaceSession: () => session, + setWorkspaceSession: vi.fn(), + flushOrThrow: vi.fn() + }) + ) ) runtime.attachWindow(1) syncSplit(runtime) - runtime.onPtyExit('pty-left', 0) + await runtime.onPtyExit('pty-left', 0) const result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) expect(result.tabs.find((tab) => tab.id === 'tab::left')).toBeUndefined() @@ -506,7 +509,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { incarnationId: 'incarnation-b' }) - runtime.onPtyExit('pty-left', 0, 'incarnation-a') + await runtime.onPtyExit('pty-left', 0, 'incarnation-a') expect((await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).tabs).toEqual([ expect.objectContaining({ id: 'tab::left', status: 'ready' }), @@ -519,11 +522,13 @@ describe('OrcaRuntimeService terminal surface retirement', () => { const session = makePersistedSplitSession() const setWorkspaceSession = vi.fn() const runtime = new OrcaRuntimeService( - runtimeStore({ - getWorkspaceSession: () => session, - setWorkspaceSession, - flushOrThrow: vi.fn() - }) + runtimeStore( + withDurableRuntimeStore({ + getWorkspaceSession: () => session, + setWorkspaceSession, + flushOrThrow: vi.fn() + }) + ) ) runtime.attachWindow(1) syncSplit(runtime) @@ -534,7 +539,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { }) runtime.acceptPtyIncarnationForExit('pty-left', 'incarnation-after-reconnect') - runtime.onPtyExit('pty-left', 0, 'incarnation-after-reconnect') + await runtime.onPtyExit('pty-left', 0, 'incarnation-after-reconnect') expect((await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).tabs).toEqual([ expect.objectContaining({ id: 'tab::right', status: 'ready' }) @@ -567,11 +572,13 @@ describe('OrcaRuntimeService terminal surface retirement', () => { session = next }) const runtime = new OrcaRuntimeService( - runtimeStore({ - getWorkspaceSession: () => session, - setWorkspaceSession, - flushOrThrow: vi.fn() - }) + runtimeStore( + withDurableRuntimeStore({ + getWorkspaceSession: () => session, + setWorkspaceSession, + flushOrThrow: vi.fn() + }) + ) ) runtime.attachWindow(1) const snapshot = makeSplitSnapshot() @@ -601,7 +608,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { const published: RuntimeMobileSessionTabsResult[] = [] const unsubscribe = runtime.onMobileSessionTabsChanged((event) => published.push(event)) - runtime.onPtyExit('pty-shared', 0, 'incarnation-exiting') + await runtime.onPtyExit('pty-shared', 0, 'incarnation-exiting') expect(session.terminalLayoutsByTabId.tab).toMatchObject({ root: { type: 'leaf', leafId: 'right' }, @@ -626,16 +633,18 @@ describe('OrcaRuntimeService terminal surface retirement', () => { unsubscribe() }) - it('de-persists an exact surface even when there is no mobile snapshot', () => { + it('de-persists an exact surface even when there is no mobile snapshot', async () => { const session = makePersistedSplitSession() const setWorkspaceSession = vi.fn() const flushOrThrow = vi.fn() const runtime = new OrcaRuntimeService( - runtimeStore({ - getWorkspaceSession: () => session, - setWorkspaceSession, - flushOrThrow - }) + runtimeStore( + withDurableRuntimeStore({ + getWorkspaceSession: () => session, + setWorkspaceSession, + flushOrThrow + }) + ) ) runtime.attachWindow(1) runtime.syncWindowGraph(1, { @@ -664,7 +673,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { incarnationId: 'incarnation-a' }) - runtime.onPtyExit('pty-left', 0, 'incarnation-a') + await runtime.onPtyExit('pty-left', 0, 'incarnation-a') expect(setWorkspaceSession).toHaveBeenCalledWith( expect.objectContaining({ @@ -682,17 +691,19 @@ describe('OrcaRuntimeService terminal surface retirement', () => { expect(flushOrThrow).toHaveBeenCalledOnce() }) - it('does not publish absence when the durable retirement flush fails', async () => { + it('publishes absence and reports when the durable retirement flush fails', async () => { const session = makePersistedSplitSession() const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) const runtime = new OrcaRuntimeService( - runtimeStore({ - getWorkspaceSession: () => session, - setWorkspaceSession: vi.fn(), - flushOrThrow: vi.fn(() => { - throw new Error('disk unavailable') + runtimeStore( + withDurableRuntimeStore({ + getWorkspaceSession: () => session, + setWorkspaceSession: vi.fn(), + flushOrThrow: vi.fn(() => { + throw new Error('disk unavailable') + }) }) - }) + ) ) runtime.attachWindow(1) syncSplit(runtime) @@ -704,35 +715,37 @@ describe('OrcaRuntimeService terminal surface retirement', () => { const events: unknown[] = [] const unsubscribe = runtime.onMobileSessionTabsChanged((event) => events.push(event)) - runtime.onPtyExit('pty-left', 0, 'incarnation-a') + await runtime.onPtyExit('pty-left', 0, 'incarnation-a') + // Why: the process is gone either way; a failed write is reported, not reinstated. expect((await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).tabs).toEqual([ - expect.objectContaining({ id: 'tab::left' }), expect.objectContaining({ id: 'tab::right' }) ]) - expect(events).toEqual([]) + expect(events).not.toEqual([]) expect(errorSpy).toHaveBeenCalledWith( - '[runtime] failed to persist terminal retirement:', + '[runtime] terminal retirement is not yet durable:', expect.any(Error) ) unsubscribe() errorSpy.mockRestore() }) - it('rolls back an in-memory retirement when the durable flush fails', async () => { + it('keeps the in-memory retirement when the durable flush fails', async () => { let session = makePersistedSplitSession() const original = structuredClone(session) const setWorkspaceSession = vi.fn((next: WorkspaceSessionState) => { session = next }) const runtime = new OrcaRuntimeService( - runtimeStore({ - getWorkspaceSession: () => session, - setWorkspaceSession, - flushOrThrow: vi.fn(() => { - throw new Error('disk unavailable') + runtimeStore( + withDurableRuntimeStore({ + getWorkspaceSession: () => session, + setWorkspaceSession, + flushOrThrow: vi.fn(() => { + throw new Error('disk unavailable') + }) }) - }) + ) ) runtime.attachWindow(1) syncSplit(runtime) @@ -742,10 +755,14 @@ describe('OrcaRuntimeService terminal surface retirement', () => { incarnationId: 'incarnation-a' }) - runtime.onPtyExit('pty-left', 0, 'incarnation-a') + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) - expect(session).toEqual(original) - expect(setWorkspaceSession).toHaveBeenLastCalledWith(original, LOCAL_EXECUTION_HOST_ID) - expect(setWorkspaceSession).toHaveBeenCalledTimes(2) + await runtime.onPtyExit('pty-left', 0, 'incarnation-a') + + // Why: the next profile write carries the staged retirement; nothing may reinstate the leaf. + expect(session).not.toEqual(original) + expect(session.terminalLayoutsByTabId.tab?.ptyIdsByLeafId).toEqual({ right: 'pty-right' }) + expect(setWorkspaceSession).toHaveBeenCalledOnce() + errorSpy.mockRestore() }) }) diff --git a/src/main/runtime/orca-runtime-terminal-split-authority.test.ts b/src/main/runtime/orca-runtime-terminal-split-authority.test.ts index 9da61e59072..8369a219331 100644 --- a/src/main/runtime/orca-runtime-terminal-split-authority.test.ts +++ b/src/main/runtime/orca-runtime-terminal-split-authority.test.ts @@ -78,6 +78,7 @@ function createHarness( deferSpawn?: boolean includePairedSnapshot?: boolean rendererMounted?: boolean + rendererPtyId?: string | null graphOnlySource?: boolean sourceIncarnationId?: string stopAndWaitResult?: boolean @@ -169,7 +170,7 @@ function createHarness( worktreeId: WORKTREE_ID, leafId: SOURCE_LEAF_ID, paneRuntimeId: 1, - ptyId: SOURCE_PTY_ID + ptyId: options.rendererPtyId === undefined ? SOURCE_PTY_ID : options.rendererPtyId } ] : [], @@ -283,6 +284,19 @@ describe('remote runtime terminal split authority', () => { }) }) + it('reveals a persisted split while its mounted source is still publishing its PTY binding', async () => { + const harness = createHarness(true, { rendererMounted: true, rendererPtyId: null }) + await harness.runtime.splitTerminal(harness.handle, { direction: 'vertical' }) + expect(harness.revealTerminalSession).toHaveBeenCalledOnce() + expect(harness.getSession().terminalLayoutsByTabId[TAB_ID]?.root?.type).toBe('split') + }) + + it('does not reveal a persisted split into a renderer bound to a different PTY', async () => { + const harness = createHarness(true, { rendererMounted: true, rendererPtyId: 'replacement' }) + await harness.runtime.splitTerminal(harness.handle, { direction: 'vertical' }) + expect(harness.revealTerminalSession).not.toHaveBeenCalled() + }) + it('splits a persisted tab without consulting an unmounted host renderer', async () => { const harness = createHarness() diff --git a/src/main/runtime/orca-runtime-terminal-surface-close.test.ts b/src/main/runtime/orca-runtime-terminal-surface-close.test.ts new file mode 100644 index 00000000000..c9bd30b7199 --- /dev/null +++ b/src/main/runtime/orca-runtime-terminal-surface-close.test.ts @@ -0,0 +1,353 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { makePaneKey } from '../../shared/stable-pane-id' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { Store } from '../persistence/loading-store/store' +import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' +import { OrcaRuntimeService } from './orca-runtime' +import { + LEAF_ID, + PTY_ID, + REPO_ID, + SIBLING_LEAF_ID, + SIBLING_PTY_ID, + TAB_ID, + WORKTREE_ID, + WORKTREE_PATH, + createHarness, + makeSession +} from './__fixtures__/orca-runtime-terminal-close-continuity-fixtures' +import { + retireTerminalSurfaceFromPersistence, + sanitizeWorkspaceSessionTerminalRetirements +} from './mobile-session-terminal-persistence-retirement' +import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' + +const splitLayout = { + root: { + type: 'split' as const, + direction: 'horizontal' as const, + first: { type: 'leaf' as const, leafId: LEAF_ID }, + second: { type: 'leaf' as const, leafId: SIBLING_LEAF_ID } + }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SIBLING_LEAF_ID]: SIBLING_PTY_ID } +} + +function splitSession(): WorkspaceSessionState { + return { ...makeSession(), terminalLayoutsByTabId: { [TAB_ID]: splitLayout } } +} + +function splitSessionAfterExitRetired(): WorkspaceSessionState { + return retireTerminalSurfaceFromPersistence(splitSession(), { + worktreeId: WORKTREE_ID, + parentTabId: TAB_ID, + leafId: LEAF_ID, + ptyId: PTY_ID + }) +} + +function withPinnedTab(session: WorkspaceSessionState): WorkspaceSessionState { + return { + ...session, + tabsByWorktree: { + [WORKTREE_ID]: (session.tabsByWorktree[WORKTREE_ID] ?? []).map((tab) => ({ + ...tab, + isPinned: true + })) + } + } +} + +const directories: string[] = [] +afterEach(async () => { + await closeTestStores() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +/** A real store whose repo already has host-authoritative membership, as any repo with an exit does. */ +function createPersistedRuntime(session: WorkspaceSessionState) { + const directory = mkdtempSync(join(tmpdir(), 'orca-surface-close-')) + directories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const store = createSqliteTestStore(Store, { dataFile }) + store.addRepo({ + id: REPO_ID, + path: WORKTREE_PATH, + displayName: 'Fixture', + badgeColor: 'gray', + addedAt: 1 + }) + store.setWorkspaceSession(advanceTerminalTopologyRevision(session, WORKTREE_ID)) + store.flushOrThrow() + return { + store, + runtime: new OrcaRuntimeService(store), + reload: async () => { + store.flush() + store.freezeWrites() + await store.waitForPendingWrite() + return createSqliteTestStore(Store, { dataFile }).getWorkspaceSession() + } + } +} + +/** What a renderer save carries after its own close: membership without the closed surface. */ +function rendererSaveWithout( + session: WorkspaceSessionState, + leafId?: string +): WorkspaceSessionState { + const { terminalTopologyRevisionByRepoId: _hostPrivate, ...rendererView } = session + if (!leafId) { + return { ...rendererView, tabsByWorktree: { [WORKTREE_ID]: [] }, terminalLayoutsByTabId: {} } + } + return { + ...rendererView, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: SIBLING_LEAF_ID }, + activeLeafId: SIBLING_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [SIBLING_LEAF_ID]: SIBLING_PTY_ID } + } + } + } +} + +describe('renderer close intents', () => { + it('keeps a closed tab closed across a renderer save and a reload', async () => { + const { store, runtime, reload } = createPersistedRuntime(makeSession()) + + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: WORKTREE_ID, + target: { kind: 'tab', tabId: TAB_ID } + }) + store.setWorkspaceSession(rendererSaveWithout(store.getWorkspaceSession())) + + expect((await reload()).tabsByWorktree[WORKTREE_ID]).toEqual([]) + }) + + it('keeps a closed split pane closed across a renderer save and a reload', async () => { + const { store, runtime, reload } = createPersistedRuntime(splitSession()) + + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: WORKTREE_ID, + target: { + kind: 'pane', + tabId: TAB_ID, + leafId: LEAF_ID + } + }) + store.setWorkspaceSession(rendererSaveWithout(store.getWorkspaceSession(), LEAF_ID)) + + const reloaded = await reload() + expect(reloaded.tabsByWorktree[WORKTREE_ID]).toEqual([expect.objectContaining({ id: TAB_ID })]) + expect(reloaded.terminalLayoutsByTabId[TAB_ID]?.root).toEqual({ + type: 'leaf', + leafId: SIBLING_LEAF_ID + }) + expect(reloaded.terminalPtyIncarnationsByPaneKey?.[makePaneKey(TAB_ID, LEAF_ID)]).toBe( + undefined + ) + }) + + // e.g. the exited-pane overlay's Close, after main's exit handling already retired that leaf. + it.each([ + ['its exit already retired the pane', () => splitSessionAfterExitRetired()], + ['the tab is pinned', () => withPinnedTab(splitSessionAfterExitRetired())], + ['the tab has no saved layout', () => ({ ...makeSession(), terminalLayoutsByTabId: {} })] + ])('never widens a pane close into a tab close when %s', async (_case, session) => { + const { runtime, reload } = createPersistedRuntime(session()) + + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: WORKTREE_ID, + target: { + kind: 'pane', + tabId: TAB_ID, + leafId: LEAF_ID + } + }) + + expect((await reload()).tabsByWorktree[WORKTREE_ID]).toEqual([ + expect.objectContaining({ id: TAB_ID }) + ]) + }) +}) + +describe('CLI close of one pane in a split tab', () => { + it('commits the pane removal without waiting for its process exit', async () => { + const harness = createHarness() + harness.syncSplitFixtureGraph() + // A verified stop that never reports an exit: membership must not ride on one. + harness.setVerifiedStopResult(true) + const terminal = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals.find( + (candidate) => candidate.ptyId === PTY_ID + )! + + await expect(harness.runtime.closeTerminal(terminal.handle)).resolves.toMatchObject({ + tabId: TAB_ID, + ptyKilled: true + }) + + const session = harness.getSession() + expect(session.tabsByWorktree[WORKTREE_ID]).toEqual([expect.objectContaining({ id: TAB_ID })]) + expect(session.terminalLayoutsByTabId[TAB_ID]).toMatchObject({ + root: { type: 'leaf', leafId: SIBLING_LEAF_ID }, + ptyIdsByLeafId: { [SIBLING_LEAF_ID]: SIBLING_PTY_ID } + }) + // No exit arrives to remove the pane, so the desktop renderer is told to drop that leaf. + expect(harness.closeTerminalPane).toHaveBeenCalledExactlyOnceWith(TAB_ID, LEAF_ID) + expect(harness.closeTerminalTab).not.toHaveBeenCalled() + }) + + it('commits the pane removal when the handle names a live PTY', async () => { + const harness = createHarness({ publishMobileSurface: true, registerPtyBacked: true }) + harness.syncSplitFixtureGraph() + // Graph without the leaf: the handle resolves through the PTY, as for a runtime-owned pane. + harness.syncFixtureTabWithoutLeaf() + harness.setVerifiedStopResult(true) + const terminal = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals.find( + (candidate) => candidate.ptyId === PTY_ID + )! + + await expect(harness.runtime.closeTerminal(terminal.handle)).resolves.toMatchObject({ + tabId: TAB_ID, + ptyKilled: true + }) + + expect(harness.getSession().terminalLayoutsByTabId[TAB_ID]?.root).toEqual({ + type: 'leaf', + leafId: SIBLING_LEAF_ID + }) + expect(harness.closeTerminalPane).toHaveBeenCalledExactlyOnceWith(TAB_ID, LEAF_ID) + expect(harness.closeTerminalTab).not.toHaveBeenCalled() + }) + + it.each([ + ['reports no exit', false], + ['throws, as an unreachable SSH host does', new Error('ssh_host_unreachable')] + ] as const)( + 'closes only that pane, never the live sibling, when its stop %s', + async (_case, stopResult) => { + const harness = createHarness({ publishMobileSurface: true, registerPtyBacked: true }) + harness.syncSplitFixtureGraph() + harness.syncFixtureTabWithoutLeaf() + harness.setVerifiedStopResult(stopResult) + // A renderer that honours a whole-tab close, so a widened close shows as the lost sibling. + harness.setCloseTerminalTabAction(() => harness.retirePersistedTab()) + const terminal = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals.find( + (candidate) => candidate.ptyId === PTY_ID + )! + + const receipt = await harness.runtime.closeTerminal(terminal.handle) + + // A stale renderer save that still lists the pane can neither restore it nor drop the sibling. + const saved = sanitizeWorkspaceSessionTerminalRetirements( + { ...harness.getSession(), terminalLayoutsByTabId: { [TAB_ID]: splitLayout } }, + harness.getSession() + ) + expect(saved.tabsByWorktree[WORKTREE_ID]).toEqual([expect.objectContaining({ id: TAB_ID })]) + expect(saved.terminalLayoutsByTabId[TAB_ID]?.root).toEqual({ + type: 'leaf', + leafId: SIBLING_LEAF_ID + }) + expect(harness.closeTerminalPane).toHaveBeenCalledExactlyOnceWith(TAB_ID, LEAF_ID) + expect(harness.closeTerminalTab).not.toHaveBeenCalled() + // The owed stop still goes out through the controller's kill, which records SSH pending kills. + expect(harness.kill).toHaveBeenCalledWith(PTY_ID) + expect(harness.kill).not.toHaveBeenCalledWith(SIBLING_PTY_ID) + expect(receipt).toMatchObject({ + tabId: TAB_ID, + ptyKilled: false, + ptyStopVerdict: 'unverifiable' + }) + } + ) + + it('drops only that pane from paired clients on a host with no renderer listing the tab', async () => { + const harness = createHarness({ publishMobileSurface: true, registerPtyBacked: true }) + harness.syncSplitFixtureGraph() + harness.syncFixtureTabWithoutLeaf() + const terminal = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals.find( + (candidate) => candidate.ptyId === PTY_ID + )! + harness.syncEmptyGraph() + // No exit arrives to retire the pane from the published snapshot. + harness.setVerifiedStopResult(new Error('ssh_host_unreachable')) + + await harness.runtime.closeTerminal(terminal.handle) + + const snapshot = await harness.runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + expect(snapshot.tabs).toEqual([ + expect.objectContaining({ parentTabId: TAB_ID, leafId: SIBLING_LEAF_ID }) + ]) + expect(snapshot.retiredTerminalSurfaces).toEqual([ + expect.objectContaining({ parentTabId: TAB_ID, leafId: LEAF_ID, ptyId: PTY_ID }) + ]) + expect(harness.getSession().terminalLayoutsByTabId[TAB_ID]?.root).toEqual({ + type: 'leaf', + leafId: SIBLING_LEAF_ID + }) + }) + + it('keeps the sibling when the stop delivers the exit before the pane commit', async () => { + const harness = createHarness() + harness.syncSplitFixtureGraph() + harness.setStopAndWaitAction((stoppingPtyId) => harness.runtime.onPtyExit(stoppingPtyId, 0)) + harness.setVerifiedStopResult(true) + const terminal = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals.find( + (candidate) => candidate.ptyId === PTY_ID + )! + + await harness.runtime.closeTerminal(terminal.handle) + + const session = harness.getSession() + expect(session.tabsByWorktree[WORKTREE_ID]).toEqual([expect.objectContaining({ id: TAB_ID })]) + expect(session.terminalLayoutsByTabId[TAB_ID]?.root).toEqual({ + type: 'leaf', + leafId: SIBLING_LEAF_ID + }) + }) +}) + +describe('mobile close of one pane in a split tab', () => { + it('commits the pane removal alongside its kill', async () => { + const harness = createHarness({ publishMobileSurface: true, registerPtyBacked: true }) + harness.syncSplitFixtureGraph() + + await expect( + harness.runtime.closeMobileSessionTab(`id:${WORKTREE_ID}`, `${TAB_ID}::${LEAF_ID}`, { + reason: 'user' + }) + ).resolves.toMatchObject({ closed: true }) + + expect(harness.kill).toHaveBeenCalledWith(PTY_ID) + expect(harness.getSession().terminalLayoutsByTabId[TAB_ID]).toMatchObject({ + root: { type: 'leaf', leafId: SIBLING_LEAF_ID } + }) + expect(harness.closeTerminalPane).toHaveBeenCalledExactlyOnceWith(TAB_ID, LEAF_ID) + }) +}) + +describe('mobile close of a tab the desktop renderer lists', () => { + // Pins are renderer presentation that main's session can lag, so only the renderer can refuse. + it('still asks the renderer, whose pin guard can refuse it', async () => { + const harness = createHarness({ publishMobileSurface: true, registerPtyBacked: true }) + harness.rejectTerminalTabClose(new Error('terminal_tab_pinned')) + + await expect( + harness.runtime.closeMobileSessionTab(`id:${WORKTREE_ID}`, TAB_ID, { reason: 'user' }) + ).rejects.toThrow('terminal_tab_pinned') + + expect(harness.closeTerminalTab).toHaveBeenCalledWith(TAB_ID) + expect(harness.kill).not.toHaveBeenCalled() + expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toEqual([ + expect.objectContaining({ id: TAB_ID }) + ]) + }) +}) diff --git a/src/main/runtime/orca-runtime-test-fixtures.spec.ts b/src/main/runtime/orca-runtime-test-fixtures.spec.ts index c72bde039b9..78a6089e3ed 100644 --- a/src/main/runtime/orca-runtime-test-fixtures.spec.ts +++ b/src/main/runtime/orca-runtime-test-fixtures.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' import { expect, vi } from 'vitest' import { createHash } from 'node:crypto' import { HeadlessEmulator } from '../daemon/headless-emulator' @@ -25,6 +26,7 @@ import type { import { InMemoryOrchestrationMessages } from './orca-runtime-test-orchestration-messages.spec' import type { OrchestrationDb } from './orchestration/db' import type { PtyProcessInspection } from '../providers/pty-process-inspection' +import type { Store } from '../persistence' type RuntimeService = InstanceType type HeadlessTerminal = InstanceType @@ -558,9 +560,9 @@ function makeRuntimeStoreWithWorkspaceSession( ): { runtimeStore: typeof store & { getWorkspaceSession: (hostId?: string) => WorkspaceSessionState - setWorkspaceSession: ReturnType - flushOrThrow: ReturnType - persistPtyBinding: ReturnType + setWorkspaceSession: ReturnType> + flushOrThrow: ReturnType void>> + persistPtyBinding: ReturnType> } getSession: () => WorkspaceSessionState setSession: (next: WorkspaceSessionState) => void @@ -569,7 +571,7 @@ function makeRuntimeStoreWithWorkspaceSession( const setSession = (next: WorkspaceSessionState): void => { session = next } - const runtimeStore = { + const runtimeStore = withDurableRuntimeStore({ ...store, getWorkspaceSession: (hostId?: string) => hostId === undefined || hostId === ownerHostId ? session : getDefaultWorkspaceSession(), @@ -577,36 +579,38 @@ function makeRuntimeStoreWithWorkspaceSession( // Headless close is a durable transaction; keep the in-memory fixture's // persistence contract equivalent to the production store. flushOrThrow: vi.fn(), - persistPtyBinding: vi.fn( - (args: { worktreeId: string; tabId: string; leafId: string; ptyId: string }) => { - const tabs = session.tabsByWorktree[args.worktreeId] ?? [] - session = { - ...session, - tabsByWorktree: { - ...session.tabsByWorktree, - [args.worktreeId]: tabs.map((tab) => - tab.id === args.tabId ? { ...tab, ptyId: args.ptyId } : tab - ) - }, - terminalLayoutsByTabId: { - ...session.terminalLayoutsByTabId, - [args.tabId]: { - ...(session.terminalLayoutsByTabId[args.tabId] ?? { - root: { type: 'leaf', leafId: args.leafId }, - activeLeafId: args.leafId, - expandedLeafId: null - }), - ptyIdsByLeafId: { - ...session.terminalLayoutsByTabId[args.tabId]?.ptyIdsByLeafId, - [args.leafId]: args.ptyId - } + persistPtyBinding: vi.fn(async (input) => { + const args = typeof input === 'function' ? input() : input + if (!args) { + return false + } + const tabs = session.tabsByWorktree[args.worktreeId] ?? [] + session = { + ...session, + tabsByWorktree: { + ...session.tabsByWorktree, + [args.worktreeId]: tabs.map((tab) => + tab.id === args.tabId ? { ...tab, ptyId: args.ptyId } : tab + ) + }, + terminalLayoutsByTabId: { + ...session.terminalLayoutsByTabId, + [args.tabId]: { + ...(session.terminalLayoutsByTabId[args.tabId] ?? { + root: { type: 'leaf', leafId: args.leafId }, + activeLeafId: args.leafId, + expandedLeafId: null + }), + ptyIdsByLeafId: { + ...session.terminalLayoutsByTabId[args.tabId]?.ptyIdsByLeafId, + [args.leafId]: args.ptyId } } } - return true } - ) - } + return true + }) + }) return { runtimeStore, getSession: () => session, setSession } } diff --git a/src/main/runtime/orca-runtime-test-mocks/setup.spec.ts b/src/main/runtime/orca-runtime-test-mocks/setup.spec.ts index f85c353575e..c25dc33df87 100644 --- a/src/main/runtime/orca-runtime-test-mocks/setup.spec.ts +++ b/src/main/runtime/orca-runtime-test-mocks/setup.spec.ts @@ -1,6 +1,7 @@ import { expect, vi } from 'vitest' import type { Mock } from 'vitest' import type * as GitUsernameModule from '../../git/git-username' +import type * as FilesystemPathContainmentModule from '../../ipc/filesystem-path-containment' import { reviewHeadRemoteRefComponent } from '../../../shared/review-head-tracking-ref' // Why: durable review-head refs are scoped by remote identity (name + URL hash). @@ -420,10 +421,13 @@ vi.mock('../../ipc/registered-worktree-roots-cache', () => ({ invalidateAuthorizedRootsCache: invalidateAuthorizedRootsCacheMock })) -vi.mock('../../ipc/filesystem-path-containment', () => ({ - isENOENT: (error: unknown) => - Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') -})) +// Why: the real check also matches relay-rebuilt errors, which carry only the ENOENT message. +vi.mock('../../ipc/filesystem-path-containment', async () => { + const actual = await vi.importActual( + '../../ipc/filesystem-path-containment' + ) + return { isENOENT: actual.isENOENT } +}) vi.mock('../../worktree-root-preparation', () => ({ prepareLocalWorktreeRootForRepo: prepareLocalWorktreeRootForRepoMock diff --git a/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts b/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts index 1f87d901c81..1d63e09c4c7 100644 --- a/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts +++ b/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' import * as mocks from './orca-runtime-test-mocks.spec' import type { Mock } from 'vitest' @@ -34,7 +35,7 @@ type MobileCreateTestNotifier = { revealTerminalSession: TestMock splitTerminal: TestMock renameTerminal: TestMock - closeTerminal: (tabId: string, paneRuntimeId?: number) => void + closeTerminal: (tabId: string, leafId?: string) => void closeSessionTab: TestMock sleepWorktree: TestMock terminalFitOverrideChanged: TestMock @@ -211,7 +212,7 @@ function makePostRevealWorkerRecoveryHarness( } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) const runtime = new OrcaRuntimeService( - { ...runtimeStore, flushOrThrow: vi.fn() } as never, + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }), undefined, { canRecoverPersistentLocalPtys: () => true } ) @@ -326,7 +327,7 @@ function makePendingAgentTabActivationRuntime(opts: { disabledTuiAgents?: string // Why: the five #7587 mobile-create tests share one notifier factory so interface changes live in one place. function createMobileCreateTestNotifier( - closeTerminal: (tabId: string, paneRuntimeId?: number) => void + closeTerminal: (tabId: string, leafId?: string) => void ): MobileCreateTestNotifier { return { focusTerminal: vi.fn(), diff --git a/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-02.spec.ts index 1fafc4f7a9d..b7a784e6282 100644 --- a/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-02.spec.ts @@ -357,11 +357,11 @@ describe('OrcaRuntimeService', () => { it('calls foreground confirmation with its controller receiver', async () => { const getForegroundProcess = vi.fn(async () => 'powershell.exe') - const confirmForegroundProcess = vi.fn( - async function (this: { getForegroundProcess: typeof getForegroundProcess }) { - return this.getForegroundProcess === getForegroundProcess ? 'codex' : null - } - ) + const confirmForegroundProcess = vi.fn(async function (this: { + getForegroundProcess: typeof getForegroundProcess + }) { + return this.getForegroundProcess === getForegroundProcess ? 'codex' : null + }) const { runtime, handle } = await createExplicitAgentStatusHarness({ getForegroundProcess, confirmForegroundProcess diff --git a/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-03.spec.ts index 2501d0141dd..a1bcdf4b287 100644 --- a/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-03.spec.ts @@ -85,7 +85,7 @@ describe('OrcaRuntimeService', () => { runtime.sendTerminal( terminal.handle, { text: 'notes', enter: true }, - { beforeWrite, afterWrite } + { inputKind: 'driving', beforeWrite, afterWrite } ) ).rejects.toThrow('terminal_not_writable') expect(writes).toEqual(['notes', '\r']) diff --git a/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts b/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts index 23312f49420..310a1e12c81 100644 --- a/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts +++ b/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts @@ -150,10 +150,14 @@ describe('OrcaRuntimeService', () => { nextCursor: expect.any(String) }) - const send = await runtime.sendTerminal(terminal.handle, { - text: 'continue', - enter: true - }) + const send = await runtime.sendTerminal( + terminal.handle, + { + text: 'continue', + enter: true + }, + { inputKind: 'driving' } + ) expect(send).toMatchObject({ handle: terminal.handle, accepted: true diff --git a/src/main/runtime/orca-runtime-tests/exit-retirement-activation.spec.ts b/src/main/runtime/orca-runtime-tests/exit-retirement-activation.spec.ts new file mode 100644 index 00000000000..7da878ca2f7 --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/exit-retirement-activation.spec.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec' +import { + HEADLESS_LEAF_ID, + HEADLESS_SECOND_LEAF_ID, + TEST_WORKTREE_ID, + makeDeferred, + makeHeadlessTerminalLayout, + makeRuntimeStoreWithWorkspaceSession, + makeWorkspaceSessionWithHeadlessTerminal +} from '../orca-runtime-test-fixtures.spec' + +async function startSplitHost() { + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId: 'pty-a', + worktreeId: TEST_WORKTREE_ID, + title: 'Split Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ + [HEADLESS_LEAF_ID]: 'pty-a', + [HEADLESS_SECOND_LEAF_ID]: 'pty-b' + }) + } + }) + ) + const spawn = vi.fn(async (options: { sessionId?: string }) => ({ + id: options.sessionId ?? 'fresh-pty' + })) + const adoptStablePane = vi.fn(async () => null) + const runtime = new OrcaRuntimeService(runtimeStore) + runtime.setPtyController({ + spawn, + adoptStablePane, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + const activate = (leafId: string, intent: 'user' | 'automatic') => + runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', leafId, { + notifyClients: false, + navigation: 'caller', + intent + }) + const terminalLeafIds = async (): Promise => + (await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs.flatMap((tab) => + tab.type === 'terminal' ? [tab.leafId] : [] + ) + await activate(HEADLESS_LEAF_ID, 'user') + await activate(HEADLESS_SECOND_LEAF_ID, 'user') + expect(spawn).toHaveBeenCalledTimes(2) + expect(adoptStablePane).toHaveBeenCalledTimes(2) + return { runtime, runtimeStore, getSession, spawn, adoptStablePane, activate, terminalLeafIds } +} + +describe('OrcaRuntimeService', () => { + // Why: a paired mirror answers an exit's stream end by re-activating its pane. An activation + // that still finds the leaf respawns the exited session, and the retirement never publishes. + it('retires an exited split leaf before its stream end, while the durable write is pending', async () => { + const { runtime, runtimeStore, getSession, spawn, adoptStablePane, activate, terminalLeafIds } = + await startSplitHost() + + const disk = makeDeferred() + Object.assign(runtimeStore, { flushPendingOrThrowAsync: () => disk.promise }) + const published = vi.fn() + runtime.onMobileSessionTabsChanged(published) + // The stream end: the mirror's re-activation starts the moment the host releases it. + let reactivation: Promise | undefined + let observedAtStreamEnd: { binding?: string; publications: number } | undefined + runtime.subscribeToPtyExit('pty-b', () => { + observedAtStreamEnd = { + binding: + getSession().terminalLayoutsByTabId['host-tab']?.ptyIdsByLeafId?.[ + HEADLESS_SECOND_LEAF_ID + ], + publications: published.mock.calls.length + } + reactivation = activate(HEADLESS_SECOND_LEAF_ID, 'automatic').catch((error) => error) + }) + const exiting = runtime.onPtyExit('pty-b', 0, undefined, { providerExitObserved: true }) + + // Why: whatever answers the stream end, over any transport, must already see the leaf retired. + expect(observedAtStreamEnd).toEqual({ binding: undefined, publications: 1 }) + expect(reactivation).toBeDefined() + // Why: the refusal must come from the lookup, before any stable-pane adoption can revive it. + expect(await reactivation).toEqual(new Error('tab_not_found')) + expect(adoptStablePane).toHaveBeenCalledTimes(2) + expect(spawn).toHaveBeenCalledTimes(2) + expect(await terminalLeafIds()).toEqual([HEADLESS_LEAF_ID]) + expect( + getSession().terminalLayoutsByTabId['host-tab']?.ptyIdsByLeafId?.[HEADLESS_SECOND_LEAF_ID] + ).toBeUndefined() + + disk.resolve() + await exiting + expect(await terminalLeafIds()).toEqual([HEADLESS_LEAF_ID]) + }) + + // Why: the process is gone whether or not the profile admits the write (quit, maintenance). + it('retires the pane and ends the stream when the profile refuses the staging write', async () => { + const { runtime, runtimeStore, activate, terminalLeafIds } = await startSplitHost() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + runtimeStore.setWorkspaceSession.mockImplementation(() => { + throw new Error('Profile maintenance or finalization is blocking new terminal snapshot work') + }) + const streamEnd = vi.fn() + runtime.subscribeToPtyExit('pty-b', streamEnd) + + await runtime.onPtyExit('pty-b', 0, undefined, { providerExitObserved: true }) + + expect(streamEnd).toHaveBeenCalledOnce() + expect(await terminalLeafIds()).toEqual([HEADLESS_LEAF_ID]) + await expect(activate(HEADLESS_SECOND_LEAF_ID, 'automatic')).rejects.toThrow('tab_not_found') + expect(errorSpy).toHaveBeenCalledWith( + '[runtime] could not stage terminal retirement:', + expect.any(Error) + ) + errorSpy.mockRestore() + }) + + // Why: the stream end now waits behind the exit cleanup; a cleanup fault must not strand it. + it('still ends the stream when exit cleanup throws before the retirement', () => { + const runtime = new OrcaRuntimeService() + Object.assign(runtime, { + disposeHeadlessTerminal: () => { + throw new Error('dispose_failed') + } + }) + const streamEnd = vi.fn() + runtime.subscribeToPtyExit('pty-a', streamEnd) + + expect(() => runtime.onPtyExit('pty-a', 0)).toThrow('dispose_failed') + expect(streamEnd).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-04.spec.ts index 6f5410e82a8..8b6806aec2b 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-04.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, @@ -177,13 +178,15 @@ describe('OrcaRuntimeService', () => { const getWorkspaceSession = vi.fn((hostId?: string | null) => hostId === 'ssh:ssh-1' ? sshSession : localSession ) - const runtime = new OrcaRuntimeService({ - ...store, - flushOrThrow: vi.fn(), - getRepos: () => [remoteRepo], - getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined), - getWorkspaceSession - } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ + ...store, + flushOrThrow: vi.fn(), + getRepos: () => [remoteRepo], + getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined), + getWorkspaceSession + }) + ) runtime.setPtyController({ write: () => true, kill: () => true, @@ -229,15 +232,17 @@ describe('OrcaRuntimeService', () => { sshSession = session }) const kill = vi.fn(() => true) - const runtime = new OrcaRuntimeService({ - ...store, - getRepos: () => [remoteRepo], - getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined), - getWorkspaceSession: (hostId?: string | null) => - hostId === 'ssh:ssh-1' ? sshSession : localSession, - setWorkspaceSession, - flushOrThrow: vi.fn() - } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ + ...store, + getRepos: () => [remoteRepo], + getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined), + getWorkspaceSession: (hostId?: string | null) => + hostId === 'ssh:ssh-1' ? sshSession : localSession, + setWorkspaceSession, + flushOrThrow: vi.fn() + }) + ) runtime.setPtyController({ write: () => true, kill, diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-05.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-05.spec.ts index eb2af5c0a1b..d2861c21400 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-05.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-05.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, electronMocks } from '../orca-runtime-test-mocks.spec' import type { RuntimeMobileSessionTabsResult } from '../orca-runtime-test-mocks.spec' @@ -181,7 +182,7 @@ describe('OrcaRuntimeService', () => { }) events.length = 0 - runtime.onPtyExit('laptop-created-pty', 0) + await runtime.onPtyExit('laptop-created-pty', 0) expect(events).toEqual([ expect.objectContaining({ @@ -374,7 +375,9 @@ describe('OrcaRuntimeService', () => { const acknowledged = makeDeferred() const closeTerminalTab = vi.fn(() => acknowledged.promise) const kill = vi.fn(() => true) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) runtime.setNotifier({ closeTerminal: vi.fn(), closeTerminalTab } as never) runtime.setPtyController({ write: () => true, @@ -510,7 +513,9 @@ describe('OrcaRuntimeService', () => { .mockResolvedValueOnce({ id: 'headless-left' }) .mockResolvedValueOnce({ id: 'headless-right' }) const kill = vi.fn(() => true) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow }) + ) runtime.setPtyController({ spawn, write: () => true, diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-08.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-08.spec.ts index 518d6805c5b..4ac0faf4d58 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-08.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-08.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import type { AgentStatusIpcPayload } from '../../../shared/agent-status-types' import { OrcaRuntimeService, electronMocks } from '../orca-runtime-test-mocks.spec' @@ -442,7 +443,9 @@ describe('OrcaRuntimeService', () => { }) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const closeTerminalTab = vi.fn(async () => {}) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow }) + ) runtime.setPtyController({ write: () => true, kill, diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-10.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-10.spec.ts index b37fa4963d4..6974a6f7f8f 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-10.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-10.spec.ts @@ -478,6 +478,7 @@ describe('OrcaRuntimeService', () => { ) const kill = vi.fn(() => true) const closeTerminal = vi.fn() + const closeTerminalPane = vi.fn() const runtime = new OrcaRuntimeService(runtimeStore as never) runtime.setPtyController({ write: () => true, @@ -485,7 +486,8 @@ describe('OrcaRuntimeService', () => { getForegroundProcess: async () => null, listProcesses: async () => [] }) - runtime.setNotifier({ closeTerminal } as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these close paths call only the listed notifier methods. + runtime.setNotifier({ closeTerminal, closeTerminalPane } as never) runtime.syncWindowGraph(1, { tabs: [ { @@ -524,8 +526,13 @@ describe('OrcaRuntimeService', () => { // Exact split leaf: kill only that leaf's PTY, keep the sibling, don't tear down the parent. expect(kill).toHaveBeenCalledWith('serve-right') expect(kill).not.toHaveBeenCalledWith('serve-left') + // Only the leaf-addressed notice for the closed pane; never a whole-tab close. + expect(closeTerminalPane).toHaveBeenCalledExactlyOnceWith('host-tab', HEADLESS_SECOND_LEAF_ID) expect(closeTerminal).not.toHaveBeenCalled() expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1) - expect(getSession().terminalLayoutsByTabId['host-tab']).toBeDefined() + expect(getSession().terminalLayoutsByTabId['host-tab']?.root).toEqual({ + type: 'leaf', + leafId: HEADLESS_LEAF_ID + }) }) }) diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-11.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-11.spec.ts index 81d78d4e308..88c988929f7 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-11.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-11.spec.ts @@ -409,7 +409,7 @@ describe('OrcaRuntimeService', () => { // republish would re-add the dead leaf on the echoing client and feed a // refuse→republish→re-echo loop. const { runtime, getSession, kill, closeTerminal } = makeSplitLeafRuntime() - runtime.onPtyExit('serve-right', 0) + await runtime.onPtyExit('serve-right', 0) const events: { worktree: string }[] = [] const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-13.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-13.spec.ts index 27421489aba..a47f037d40e 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-13.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-13.spec.ts @@ -602,7 +602,7 @@ describe('OrcaRuntimeService', () => { expect(write).toHaveBeenCalledTimes(2) expect(write.mock.calls[0][0]).toBe('pty-bare') expect(String(write.mock.calls[0][1])).toMatch(/codex/) - expect(write.mock.calls[1]).toEqual(['pty-bare', '\r']) + expect(write.mock.calls[1]).toEqual(['pty-bare', '\r', 'launch']) } finally { vi.useRealTimers() } diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs.spec.ts index c3000e90bd2..fe8f7e08fab 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs.spec.ts @@ -235,6 +235,7 @@ describe('OrcaRuntimeService', () => { it('closes the matching mobile terminal UUID leaf without closing the whole tab', async () => { const closeTerminal = vi.fn() + const closeTerminalPane = vi.fn() const kill = vi.fn(() => true) const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ @@ -253,6 +254,7 @@ describe('OrcaRuntimeService', () => { renameTerminal: vi.fn(), focusTerminal: vi.fn(), closeTerminal, + closeTerminalPane, sleepWorktree: vi.fn(), terminalFitOverrideChanged: vi.fn(), terminalDriverChanged: vi.fn() @@ -313,6 +315,8 @@ describe('OrcaRuntimeService', () => { await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, `tab-1::${rightLeafId}`) expect(kill).toHaveBeenCalledWith('pty-right') + // Only the leaf-addressed notice for the closed pane; never a whole-tab close. + expect(closeTerminalPane).toHaveBeenCalledExactlyOnceWith('tab-1', rightLeafId) expect(closeTerminal).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts index 68479f85e4a..03b9414451e 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts @@ -4,8 +4,6 @@ import { getDefaultWorkspaceSession, join, makePaneKey, - markCodexProjectTrustedMock, - markCursorWorkspaceTrustedMock, mkdtemp, setPlatform, setTerminalViewAttributes, @@ -374,7 +372,7 @@ describe('OrcaRuntimeService', () => { }) const spawnCall = spawn.mock.calls[0]?.[0] as - | { command?: string; env?: Record } + | { command?: string; launchAgent?: string; env?: Record } | undefined expect(spawnCall?.command).toBe("codex '--dangerously-bypass-approvals-and-sandbox'") expect(spawnCall?.env).toMatchObject({ @@ -382,10 +380,8 @@ describe('OrcaRuntimeService', () => { ORCA_WORKTREE_ID: TEST_WORKTREE_ID }) expect(spawnCall?.env?.ORCA_AGENT_LAUNCH_TOKEN).toMatch(UUID_RE) - expect(markCodexProjectTrustedMock).toHaveBeenCalledWith(TEST_WORKTREE_PATH) - expect(markCodexProjectTrustedMock.mock.invocationCallOrder[0]).toBeLessThan( - spawn.mock.invocationCallOrder[0]! - ) + // The spawn builder pre-trusts the workspace for the declared launch agent. + expect(spawnCall?.launchAgent).toBe('codex') }) // Why: `cursor` on PATH is the Cursor desktop launcher; only `cursor-agent` is @@ -421,7 +417,6 @@ describe('OrcaRuntimeService', () => { expect(spawnCall?.command).toBe("cursor-agent '--force'") expect(spawnCall?.launchAgent).toBe('cursor') expect(spawnCall?.env).toMatchObject({ CURSOR_PROFILE: 'captured' }) - expect(markCursorWorkspaceTrustedMock).toHaveBeenCalledWith(TEST_WORKTREE_PATH) }) it('resolves a startupAgent to the CLI binary on Windows, where `cursor` is the IDE', async () => { diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts index bf3e98020e5..47d7236dd9d 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts @@ -7,7 +7,6 @@ import { homedir, ipcMain, join, - markCodexProjectTrustedMock, mkdtemp, randomUUID, registerSshGitProvider, @@ -242,10 +241,6 @@ describe('OrcaRuntimeService', () => { } }) ) - expect(markCodexProjectTrustedMock).toHaveBeenCalledWith(TEST_WORKTREE_PATH) - expect(markCodexProjectTrustedMock.mock.invocationCallOrder[0]).toBeLessThan( - webContents.send.mock.invocationCallOrder[0]! - ) }) it('injects runtime hook receiver env into terminal sessions', async () => { diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-05.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-05.spec.ts index 591f4e7ca97..2aba44299f2 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-05.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-05.spec.ts @@ -333,7 +333,7 @@ describe('OrcaRuntimeService', () => { await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce()) setSession(getDefaultWorkspaceSession()) - runtimeStore.persistPtyBinding.mockReturnValue(false) + runtimeStore.persistPtyBinding.mockResolvedValue(false) resolveSpawn({ id: 'rejected-split-pty' }) await expect(split).rejects.toThrow('terminal_split_source_not_found') diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts index 9145bea46f6..d90dc63031b 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts @@ -467,7 +467,7 @@ describe('OrcaRuntimeService', () => { }) const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) - await runtime.sendTerminal(handle, { text: 'continue', enter: true }) + await runtime.sendTerminal(handle, { text: 'continue', enter: true }, { inputKind: 'driving' }) expect(writes).toEqual(['continue', '\r']) }) @@ -490,7 +490,7 @@ describe('OrcaRuntimeService', () => { const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) const prompt = 'line one\nline two\x1b[201~' - const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt) + const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) await vi.runAllTimersAsync() const result = await sendPromise @@ -535,6 +535,7 @@ describe('OrcaRuntimeService', () => { ) const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'the brief', { + inputKind: 'driving', leadLine: ORCA_DISPATCH_PROMPT_LEAD_LINE }) await vi.runAllTimersAsync() @@ -601,6 +602,7 @@ describe('OrcaRuntimeService', () => { const assertAuthority = vi.fn() const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving', beforeWrite: assertAuthority }) await vi.advanceTimersByTimeAsync(500) @@ -659,7 +661,9 @@ describe('OrcaRuntimeService', () => { 'review this change', agent ) - const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') + const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) if (agent === 'omp') { await sendPromise expect(writes).toEqual([`${buildAgentPromptPasteBytes('review this change')}\r`]) diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-08.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-08.spec.ts index 8b27ce9bb5d..fe3687aab0a 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-08.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-08.spec.ts @@ -44,7 +44,9 @@ describe('OrcaRuntimeService', () => { const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) await expect(runtime.isTerminalRunningSettledPromptAgent(handle)).resolves.toBe(true) - const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') + const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) await vi.advanceTimersByTimeAsync(1_199) expect(writes).not.toContain('\r') await vi.advanceTimersByTimeAsync(1_500) @@ -78,7 +80,9 @@ describe('OrcaRuntimeService', () => { launchAgent: 'claude' }) - const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') + const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) await vi.advanceTimersByTimeAsync(renderGateCapMs('review this change') - 1) expect(writes).not.toContain('\r') @@ -117,7 +121,9 @@ describe('OrcaRuntimeService', () => { launchAgent: 'codex' }) - const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') + const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) await vi.advanceTimersByTimeAsync(8_000) expect(writes).not.toContain('\r') await vi.advanceTimersByTimeAsync(1_599) @@ -159,7 +165,9 @@ describe('OrcaRuntimeService', () => { launchAgent: 'claude' }) - const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') + const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) // The marker at 100 ms re-arms the cap, but the ingest term is absolute: a prompt this // small is already ingested by then, so the fallback is one flat render timeout later. await vi.advanceTimersByTimeAsync(100 + 8_000 - 1) @@ -193,7 +201,7 @@ describe('OrcaRuntimeService', () => { }) const prompt = `${'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES)}\ntail` - const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt) + const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) await vi.runAllTimersAsync() const result = await sendPromise @@ -230,7 +238,7 @@ describe('OrcaRuntimeService', () => { }) const prompt = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES + 1) - const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt) + const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) const sendRejection = expect(sendPromise).rejects.toThrow('terminal_not_writable') await vi.runAllTimersAsync() @@ -258,7 +266,7 @@ describe('OrcaRuntimeService', () => { const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) const text = ['x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES), 'tail'].join('') - const result = await runtime.sendTerminal(handle, { text }) + const result = await runtime.sendTerminal(handle, { text }, { inputKind: 'driving' }) expect(result).toMatchObject({ handle, @@ -285,7 +293,7 @@ describe('OrcaRuntimeService', () => { const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) const text = `${'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES)}\nline two\nline three` - await runtime.sendTerminal(handle, { text, enter: true }) + await runtime.sendTerminal(handle, { text, enter: true }, { inputKind: 'driving' }) expect(writes.at(-1)).toBe('\r') expect(writes.slice(0, -1).join('')).toBe(text) @@ -312,7 +320,7 @@ describe('OrcaRuntimeService', () => { vi.useFakeTimers() try { - const sendPromise = runtime.sendTerminal(handle, { text }) + const sendPromise = runtime.sendTerminal(handle, { text }, { inputKind: 'driving' }) expect(writes).toEqual([]) @@ -346,7 +354,11 @@ describe('OrcaRuntimeService', () => { const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) await expect( - runtime.sendTerminal(handle, { text: 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1) }) + runtime.sendTerminal( + handle, + { text: 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1) }, + { inputKind: 'driving' } + ) ).rejects.toThrow(TERMINAL_INPUT_TOO_LARGE_ERROR) expect(writes).toEqual([]) }) diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-09.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-09.spec.ts index ef0a1a96de5..ef895661c10 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-09.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-09.spec.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec' +import type { PtyProviderBufferSnapshot } from '../../providers/pty-provider-contract' +import { TerminalKittyKeyboardModeTracker } from '../../../shared/terminal-kitty-keyboard-mode-tracker' import { HEADLESS_LEAF_ID, TEST_WORKTREE_ID, @@ -199,6 +201,79 @@ describe('OrcaRuntimeService', () => { expect(snapshot?.data).not.toContain('line-0') }) + it('resets input modes through the PTY controller and the headless model', async () => { + const resetInputModes = vi.fn().mockResolvedValue(undefined) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + resetInputModes + }) + syncSinglePty(runtime, 'pty-1') + // An app armed these and crashed with no command end. + runtime.onPtyData('pty-1', 'prompt$ app\r\n\x1b[>31u\x1b[?1000h\x1b[?2004h', 123) + const [terminal] = (await runtime.listTerminals()).terminals + const armed = await runtime.serializeTerminalBuffer('pty-1') + expect(armed?.kittyKeyboardFlags).toBe(31) + + await expect(runtime.resetTerminalInputModes(terminal.handle)).resolves.toEqual({ + handle: terminal.handle, + reset: true + }) + + expect(resetInputModes).toHaveBeenCalledWith('pty-1') + const snapshot = await runtime.serializeTerminalBuffer('pty-1') + expect(snapshot?.kittyKeyboardFlags).toBe(0) + expect(snapshot?.data).not.toContain('\x1b[?1000h') + expect(snapshot?.data).not.toContain('\x1b[?2004h') + }) + + it('grounds the provider mode tracker before a later chunk, in the emulator order', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime, 'pty-1') + runtime.onPtyData('pty-1', 'prompt$ ', 123) + const tracker = new TerminalKittyKeyboardModeTracker() + runtime['providerModeTrackersByPtyId'].set('pty-1', tracker) + + // A TUI starts while the reset still waits on the headless write chain. + const reset = runtime.resetHeadlessTerminalInputModes('pty-1') + runtime.onPtyData('pty-1', '\x1b[?1049h', 124) + await reset + await runtime['headlessTerminals'].get('pty-1')?.writeChain + + expect(runtime['headlessTerminals'].get('pty-1')?.emulator.isAlternateScreen).toBe(true) + expect(tracker.isAlternateScreen).toBe(true) + }) + + it('grounds an in-flight provider snapshot capture so it cannot publish the pre-reset screen', async () => { + let resolveSnapshot: (snapshot: PtyProviderBufferSnapshot) => void = () => {} + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeProviderBuffer: () => new Promise((resolve) => (resolveSnapshot = resolve)) + }) + syncSinglePty(runtime, 'pty-1') + const generation = runtime['getPtyLifecycleGeneration']('pty-1') + + // The capture's daemon request left before the reset; its answer predates the ground. + const capture = runtime['captureProviderTerminalBuffer']('pty-1', {}, generation) + await runtime.resetHeadlessTerminalInputModes('pty-1') + resolveSnapshot({ + data: '', + cols: 80, + rows: 24, + seq: 1, + source: 'headless', + alternateScreen: true + }) + await capture + + expect(runtime['providerModeTrackersByPtyId'].get('pty-1')?.isAlternateScreen).toBe(false) + }) + it('waits for terminal exit and resolves with the exit status', async () => { const runtime = new OrcaRuntimeService(store) diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-12.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-12.spec.ts index 6aa0c6bd70f..92612c2e763 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-12.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-12.spec.ts @@ -223,4 +223,74 @@ describe('OrcaRuntimeService', () => { expect(spawn.mock.calls[0]?.[0]?.command).not.toMatch(/--model/) }) + + // Every agent the runtime builds is attributed, so a launch whose caller named no surface still + // counts; only the caller's surface is taken, the rest is derived from the agent it resolved. + it.each([ + ['orchestration', 'orchestration'], + [undefined, 'unknown'], + ['a_surface_added_later', 'unknown'] + ])('attributes a startup-agent launch named %s as %s', async (launchSource, expected) => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-attributed' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + startupAgent: 'claude', + ...(launchSource ? { launchSource } : {}) + }) + + expect(spawn.mock.calls[0]?.[0]?.telemetry).toEqual({ + agent_kind: 'claude-code', + launch_source: expected, + request_kind: 'new' + }) + }) + + it('attributes a fresh agent session the host builds as unknown', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-session' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.createAgentSession( + { + clientOperationId: `${Date.now()}-${'cd'.repeat(16)}`, + worktree: `id:${TEST_WORKTREE_ID}`, + agent: 'claude' + }, + { clientId: 'remote-desktop', clientKind: 'runtime' } + ) + + expect(spawn.mock.calls[0]?.[0]?.telemetry).toEqual({ + agent_kind: 'claude-code', + launch_source: 'unknown', + request_kind: 'new' + }) + }) + + it('leaves a bare agent command the user typed out of launch attribution', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-bare' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { command: 'claude' }) + + expect(spawn.mock.calls[0]?.[0]?.launchAgent).toBe('claude') + expect(spawn.mock.calls[0]?.[0]?.telemetry).toBeUndefined() + }) }) diff --git a/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status-part-02.spec.ts index 237afc57bc9..be454a5bde8 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status-part-02.spec.ts @@ -142,7 +142,7 @@ describe('OrcaRuntimeService', () => { syncSinglePty(runtime, 'pty-1', { paneTitle: 'claude agents' }) const [terminal] = (await runtime.listTerminals()).terminals - expect(runtime.getAgentStatusForHandle(terminal.handle)).toBeNull() + await expect(runtime.getAgentStatusForHandle(terminal.handle)).resolves.toBeNull() }) it('lists live terminals with fresh pane titles over stale tab titles', async () => { diff --git a/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status.spec.ts index e85aeb461c6..e122effd17d 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, @@ -20,7 +21,9 @@ describe('OrcaRuntimeService', () => { ...getDefaultWorkspaceSession(), tabsByWorktree: { [TEST_WORKTREE_ID]: [] } }) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) let process = { id: 'reused-pty-id', incarnationId: 'inc-old', @@ -66,7 +69,9 @@ describe('OrcaRuntimeService', () => { ...getDefaultWorkspaceSession(), tabsByWorktree: { [TEST_WORKTREE_ID]: [] } }) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) runtime.syncWindowGraph(1, { tabs: [ { @@ -156,7 +161,9 @@ describe('OrcaRuntimeService', () => { [`duplicate-b:${HEADLESS_SECOND_LEAF_ID}`]: 'inc-duplicate' } }) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) runtime.setPtyController({ write: () => true, kill: () => true, @@ -223,7 +230,7 @@ describe('OrcaRuntimeService', () => { expect(new Set(handles).size).toBe(handles.length) await expect( - runtime.sendTerminal('term_victim', { text: 'for victim' }) + runtime.sendTerminal('term_victim', { text: 'for victim' }, { inputKind: 'driving' }) ).resolves.toMatchObject({ accepted: true }) expect(writesByPty.get('pty-victim')).toEqual(['for victim']) expect(writesByPty.has('pty-imposter')).toBe(false) @@ -253,7 +260,7 @@ describe('OrcaRuntimeService', () => { const listed = await runtime.listTerminals() expect(listed.terminals[0]?.handle).toBe('term_already_bound') await expect( - runtime.sendTerminal('term_already_bound', { text: 'still routed' }) + runtime.sendTerminal('term_already_bound', { text: 'still routed' }, { inputKind: 'driving' }) ).resolves.toMatchObject({ accepted: true }) expect(writes).toEqual(['still routed']) // the reported-but-not-adopted handle must not resolve to the live pty @@ -340,7 +347,9 @@ describe('OrcaRuntimeService', () => { handle, tail: ['after unavailable'] }) - await expect(runtime.sendTerminal(handle, { text: 'still writable' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'still writable' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ handle, accepted: true }) diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts index ecf2ab53678..dffd6e971eb 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, @@ -53,7 +54,7 @@ describe('OrcaRuntimeService', () => { tail: ['after restart'] }) await expect( - runtime.sendTerminal('term_exported', { text: 'still writable' }) + runtime.sendTerminal('term_exported', { text: 'still writable' }, { inputKind: 'driving' }) ).resolves.toMatchObject({ handle: 'term_exported', accepted: true @@ -76,7 +77,9 @@ describe('OrcaRuntimeService', () => { ['pty-setup', 'inc-setup', 'term_setup', 'Setup'], ['pty-shell', 'inc-shell', 'term_shell', 'Shell'] ] as const - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) const listProcesses = vi.fn(async () => processes.map(([id, incarnationId, terminalHandle, title]) => ({ id, @@ -176,7 +179,7 @@ describe('OrcaRuntimeService', () => { ]) expect(getSession().terminalTopologyRevisionByRepoId?.[TEST_REPO_ID]).toBe(1) - await runtime.sendTerminal('term_agent', { text: 'input' }) + await runtime.sendTerminal('term_agent', { text: 'input' }, { inputKind: 'driving' }) await runtime.updateRemoteDesktopViewer('pty-agent', 'viewer', 'client', 132, 41) expect(writes).toEqual([['pty-agent', 'input']]) expect(resize).toHaveBeenCalledWith('pty-agent', 132, 41) @@ -247,13 +250,15 @@ describe('OrcaRuntimeService', () => { const { runtimeStore, getSession, setSession } = makeRuntimeStoreWithWorkspaceSession(session) const durableWrite = deferred() const durableWriteStarted = deferred() - const runtime = new OrcaRuntimeService({ - ...runtimeStore, - flushPendingOrThrowAsync: vi.fn(() => { - durableWriteStarted.resolve() - return durableWrite.promise + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ + ...runtimeStore, + flushPendingOrThrowAsync: vi.fn(() => { + durableWriteStarted.resolve() + return durableWrite.promise + }) }) - } as never) + ) runtime.setPtyController({ write: vi.fn(() => true), kill: vi.fn(() => true), @@ -332,10 +337,12 @@ describe('OrcaRuntimeService', () => { wslDistro: null } ]) - const runtime = new OrcaRuntimeService({ - ...runtimeStore, - flushPendingOrThrowAsync - } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ + ...runtimeStore, + flushPendingOrThrowAsync + }) + ) runtime.setPtyController({ write: vi.fn(() => true), kill: vi.fn(() => true), @@ -447,13 +454,17 @@ describe('OrcaRuntimeService', () => { } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) const flushOrThrow = vi.fn() - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow } as never, undefined, { - canRecoverPersistentLocalPtys: () => true, - attestAgentHookCompatibilityAuthority: ({ paneKey, launchTokenHash }) => - paneKey === workerPaneKey && launchTokenHash === RESTORED_AUTHORITY_TOKEN_HASH - ? { paneKey, source: 'hydrated_commitment' } - : null - }) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow }), + undefined, + { + canRecoverPersistentLocalPtys: () => true, + attestAgentHookCompatibilityAuthority: ({ paneKey, launchTokenHash }) => + paneKey === workerPaneKey && launchTokenHash === RESTORED_AUTHORITY_TOKEN_HASH + ? { paneKey, source: 'hydrated_commitment' } + : null + } + ) runtime.setOrchestrationDb({ getActiveDispatchForTerminal: () => undefined, listLegacyWorkerTerminalRecoveryRows: () => [ @@ -598,7 +609,10 @@ describe('OrcaRuntimeService', () => { condition: 'tui-idle', timeoutMs: 50 }) - await vi.waitFor(() => expect(serializeProviderBuffer).toHaveBeenCalledTimes(4)) + // The probe's read starts a microtask late; a 50 ms poll would race the wait's 50 ms timeout. + await vi.waitFor(() => expect(serializeProviderBuffer).toHaveBeenCalledTimes(4), { + interval: 1 + }) runtime.onPtyData('pty-legacy', '\x1b[H', Date.now()) lateReadySnapshot.resolve({ data: READY_SCREEN, diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-03.spec.ts index 00a08310877..51edcad7373 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-03.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, getDefaultWorkspaceSession } from '../orca-runtime-test-mocks.spec' import type { OrchestrationDb } from '../orchestration/db' @@ -40,7 +41,7 @@ describe('OrcaRuntimeService', () => { } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) const runtime = new OrcaRuntimeService( - { ...runtimeStore, flushOrThrow: vi.fn() } as never, + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }), undefined, { canRecoverPersistentLocalPtys: () => true } ) @@ -451,7 +452,7 @@ describe('OrcaRuntimeService', () => { return durableWrite.promise }) const runtime = new OrcaRuntimeService( - { ...runtimeStore, flushPendingOrThrowAsync } as never, + withDurableRuntimeStore({ ...runtimeStore, flushPendingOrThrowAsync }), undefined, { canRecoverPersistentLocalPtys: () => true } ) diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-04.spec.ts index ea70b730388..6c439089396 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-04.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, @@ -50,7 +51,7 @@ describe('OrcaRuntimeService', () => { throw new Error('synchronous persistence must not run') }) const runtime = new OrcaRuntimeService( - { ...runtimeStore, flushOrThrow, flushPendingOrThrowAsync } as never, + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow, flushPendingOrThrowAsync }), undefined, { canRecoverPersistentLocalPtys: () => true } ) @@ -160,7 +161,7 @@ describe('OrcaRuntimeService', () => { return retryDurableWrite.promise }) const runtime = new OrcaRuntimeService( - { ...runtimeStore, flushPendingOrThrowAsync } as never, + withDurableRuntimeStore({ ...runtimeStore, flushPendingOrThrowAsync }), undefined, { canRecoverPersistentLocalPtys: () => true } ) @@ -272,7 +273,7 @@ describe('OrcaRuntimeService', () => { } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) const runtime = new OrcaRuntimeService( - { ...runtimeStore, flushOrThrow: vi.fn() } as never, + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }), undefined, { canRecoverPersistentLocalPtys: () => true } ) @@ -383,7 +384,7 @@ describe('OrcaRuntimeService', () => { } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) const runtime = new OrcaRuntimeService( - { ...runtimeStore, flushOrThrow: vi.fn() } as never, + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }), undefined, { canRecoverPersistentLocalPtys: () => true } ) diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts index 389c70d4f42..756f87611f6 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, @@ -74,7 +75,7 @@ describe('OrcaRuntimeService', () => { } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) const runtime = new OrcaRuntimeService( - { ...runtimeStore, flushOrThrow: vi.fn() } as never, + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }), undefined, { canRecoverPersistentLocalPtys: () => true } ) @@ -187,12 +188,12 @@ describe('OrcaRuntimeService', () => { const folderWorkspace = makeFolderWorkspace({ folderPath }) const projectGroup = makeFolderProjectGroup({ parentPath: folderPath }) const runtime = new OrcaRuntimeService( - { + withDurableRuntimeStore({ ...runtimeStore, getFolderWorkspaces: () => [folderWorkspace], getProjectGroups: () => [projectGroup], flushOrThrow: vi.fn() - } as never, + }), undefined, { canRecoverPersistentLocalPtys: () => true } ) @@ -313,14 +314,14 @@ describe('OrcaRuntimeService', () => { const folderWorkspace = makeFolderWorkspace({ folderPath, connectionId }) const projectGroup = makeFolderProjectGroup({ parentPath: folderPath }) const runtime = new OrcaRuntimeService( - { + withDurableRuntimeStore({ ...runtimeStore, getFolderWorkspaces: () => [folderWorkspace], getProjectGroups: () => [projectGroup], getWorkspaceSession, setWorkspaceSession, flushOrThrow: vi.fn() - } as never, + }), undefined, { canRecoverPersistentLocalPtys: () => true } ) diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-06.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-06.spec.ts index 1907b2bb450..2100c708043 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-06.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-06.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, @@ -83,14 +84,14 @@ describe('OrcaRuntimeService', () => { rows: 24 }) const runtime = new OrcaRuntimeService( - { + withDurableRuntimeStore({ ...runtimeStore, getRepos: () => [remoteRepo], getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined), getWorkspaceSession, setWorkspaceSession, flushOrThrow: vi.fn() - } as never, + }), undefined, { canRecoverPersistentLocalPtys: () => true } ) @@ -220,7 +221,7 @@ describe('OrcaRuntimeService', () => { } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) const runtime = new OrcaRuntimeService( - { + withDurableRuntimeStore({ ...runtimeStore, getProjects: () => [ { @@ -228,17 +229,17 @@ describe('OrcaRuntimeService', () => { displayName: 'repo', badgeColor: 'blue', sourceRepoIds: [TEST_REPO_ID], - localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + localWindowsRuntimePreference: { kind: 'wsl' as const, distro: 'Ubuntu' }, createdAt: 0, updatedAt: 0 } ], getSettings: () => ({ ...store.getSettings(), - localWindowsRuntimeDefault: { kind: 'windows-host' } + localWindowsRuntimeDefault: { kind: 'windows-host' as const } }), flushOrThrow: vi.fn() - } as never, + }), undefined, { canRecoverPersistentLocalPtys: () => true } ) @@ -353,7 +354,9 @@ describe('OrcaRuntimeService', () => { } } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) runtime.setPtyController({ write: () => true, kill: () => true, @@ -524,7 +527,9 @@ describe('OrcaRuntimeService', () => { } } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) runtime.setPtyController({ write: () => true, kill: () => true, diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts index 79bdf13909e..b9f605ccf91 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, getDefaultWorkspaceSession } from '../orca-runtime-test-mocks.spec' import { @@ -64,7 +65,7 @@ describe('OrcaRuntimeService', () => { const afterRestart = await restarted.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) const listed = await restarted.listTerminals(`id:${TEST_WORKTREE_ID}`) restarted.onPtyData('persisted-pty', 'after restart\n', 1) - await restarted.sendTerminal('term_current', { text: 'input' }) + await restarted.sendTerminal('term_current', { text: 'input' }, { inputKind: 'driving' }) await restarted.updateRemoteDesktopViewer('persisted-pty', 'viewer', 'client', 132, 41) expect(beforeRestart.tabs[0]).toMatchObject({ @@ -108,7 +109,9 @@ describe('OrcaRuntimeService', () => { terminalTopologyRevisionByRepoId: { [TEST_REPO_ID]: 7 } } const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(session) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) runtime.setPtyController({ write: () => true, kill: () => true, @@ -143,12 +146,66 @@ describe('OrcaRuntimeService', () => { ).rejects.toThrow('terminal_topology_conflict') }) + // The client's retirement proofs die with a host restart; the close record does not. + it('refuses to adopt an orphan under a tab the user closed', async () => { + const session = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [TEST_WORKTREE_ID]: [] }, + terminalTopologyRevisionByRepoId: { [TEST_REPO_ID]: 7 }, + closedTerminalTabTombstonesByTabId: { + 'tab-closed': { + closedAt: Date.now(), + worktreeId: TEST_WORKTREE_ID, + reason: 'user' as const + } + } + } + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(session) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'pty-closed', + incarnationId: 'inc-closed', + terminalHandle: 'term_closed', + title: 'shell', + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID, + wslDistro: null + } + ] + }) + + await expect( + runtime.adoptTerminalOrphans({ + worktree: `id:${TEST_WORKTREE_ID}`, + expectedTopologyRevision: 7, + claims: [ + { + terminal: 'term_closed', + ptyId: 'pty-closed', + incarnationId: 'inc-closed', + tabId: 'tab-closed', + leafId: HEADLESS_LEAF_ID + } + ] + }) + ).rejects.toThrow('terminal_orphan_surface_retired') + }) + it('keeps orphaned list and show writability aligned with the send gate', async () => { const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ ...getDefaultWorkspaceSession(), tabsByWorktree: { [TEST_WORKTREE_ID]: [] } }) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) const writes: [string, string][] = [] runtime.setPtyController({ write: (ptyId: string, data: string) => { @@ -178,7 +235,9 @@ describe('OrcaRuntimeService', () => { const shown = await runtime.showTerminal(entry!.handle) expect(shown.writable).toBe(true) - await expect(runtime.sendTerminal(entry!.handle, { text: 'hi' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(entry!.handle, { text: 'hi' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ accepted: true }) expect(writes).toEqual([['pty-orphan', 'hi']]) @@ -190,7 +249,9 @@ describe('OrcaRuntimeService', () => { ...getDefaultWorkspaceSession(), tabsByWorktree: { [TEST_WORKTREE_ID]: [] } }) - return new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + return new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) } const ownerMismatch = makeRuntime() ownerMismatch.registerPty('pty-wrong-owner', TEST_WORKTREE_ID, 'ssh-other-host') @@ -282,25 +343,27 @@ describe('OrcaRuntimeService', () => { ...getDefaultWorkspaceSession(), tabsByWorktree: { [TEST_WORKTREE_ID]: [] } }) - const wsl = new OrcaRuntimeService({ - ...runtimeStore, - flushOrThrow: vi.fn(), - getProjects: () => [ - { - id: 'project-wsl', - displayName: 'WSL', - badgeColor: 'blue', - sourceRepoIds: [TEST_REPO_ID], - localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, - createdAt: 1, - updatedAt: 1 - } - ], - getSettings: () => ({ - ...store.getSettings(), - localWindowsRuntimeDefault: { kind: 'windows-host' } + const wsl = new OrcaRuntimeService( + withDurableRuntimeStore({ + ...runtimeStore, + flushOrThrow: vi.fn(), + getProjects: () => [ + { + id: 'project-wsl', + displayName: 'WSL', + badgeColor: 'blue', + sourceRepoIds: [TEST_REPO_ID], + localWindowsRuntimePreference: { kind: 'wsl' as const, distro: 'Ubuntu' }, + createdAt: 1, + updatedAt: 1 + } + ], + getSettings: () => ({ + ...store.getSettings(), + localWindowsRuntimeDefault: { kind: 'windows-host' as const } + }) }) - } as never) + ) wsl.registerPty('pty-wsl', TEST_WORKTREE_ID, null, undefined, true) wsl.onPtySpawned('pty-wsl', 'inc-wsl', { awaitsRegistration: false }) wsl.setPtyController({ @@ -356,7 +419,9 @@ describe('OrcaRuntimeService', () => { tabsByWorktree: { [TEST_WORKTREE_ID]: [] } } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) const processes = [ ['pty-left', 'inc-left', 'term_left'], ['pty-right', 'inc-right', 'term_right'], diff --git a/src/main/runtime/orca-runtime-tests/terminal-spawn-dispatch.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-spawn-dispatch.spec.ts new file mode 100644 index 00000000000..ff340199770 --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/terminal-spawn-dispatch.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec' +import { TEST_WORKTREE_PATH, store } from '../orca-runtime-test-fixtures.spec' + +// `agent.launch` settles a launch as failed only when its create threw before this hook ran. +describe('OrcaRuntimeService createTerminal spawn dispatch', () => { + it('reports the spawn request before it leaves for the pty controller', async () => { + const dispatched = vi.fn() + const spawn = vi.fn(async () => { + expect(dispatched).toHaveBeenCalledOnce() + return { id: 'pty-dispatch' } + }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'codex', + onPtySpawnDispatched: dispatched + }) + + expect(spawn).toHaveBeenCalledOnce() + }) + + it('reports it even when the spawn itself then fails', async () => { + const dispatched = vi.fn() + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn(async () => { + throw new Error('ssh_channel_closed') + }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await expect( + runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'codex', + onPtySpawnDispatched: dispatched + }) + ).rejects.toThrow('ssh_channel_closed') + expect(dispatched).toHaveBeenCalledOnce() + }) + + it('does not report it when the create fails before any spawn request', async () => { + const dispatched = vi.fn() + const spawn = vi.fn() + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await expect( + runtime.createTerminal('path:/no/such/workspace', { + command: 'codex', + onPtySpawnDispatched: dispatched + }) + ).rejects.toThrow() + expect(spawn).not.toHaveBeenCalled() + expect(dispatched).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-04.spec.ts index 78c4f072bb3..10cf9b4fbb9 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-04.spec.ts @@ -14,14 +14,18 @@ import { listWorktrees, listWorktreesStrict, lstat, + localWorktreeFilesystem, + mkdir, mkdtemp, + restoreLocalWatcherAfterFailedRemovalMock, registerSshGitProvider, removeWorktree, removeWorktreeLinkedPathsMock, rm, runHook, tmpdir, - unregisterSshGitProvider + unregisterSshGitProvider, + writeFile } from '../orca-runtime-test-mocks.spec' import type { WorktreeMeta } from '../orca-runtime-test-mocks.spec' import { @@ -344,6 +348,158 @@ describe('OrcaRuntimeService', () => { expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(TEST_WORKTREE_ID) }) + it('retains metadata when an unproven orphan directory survives cleanup', async () => { + const parentDir = await mkdtemp(join(tmpdir(), 'orca-runtime-orphan-retention-')) + const repoPath = join(parentDir, 'repo') + const orphanPath = join(parentDir, 'orphan') + const worktreeId = `${TEST_REPO_ID}::${orphanPath}` + await mkdir(orphanPath, { recursive: true }) + const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(worktreeId) + const runtimeStoreWithRepoPath = { + ...runtimeStore, + getRepos: () => [ + { + id: TEST_REPO_ID, + path: repoPath, + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + } + ], + getRepo: (id: string) => + id === TEST_REPO_ID + ? { + id: TEST_REPO_ID, + path: repoPath, + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + } + : undefined + } + const runtime = createWorktreeRemovalRuntime(runtimeStoreWithRepoPath) + const registeredWorktree = { + path: orphanPath, + head: 'abc', + branch: 'feature/orphan', + isBare: false, + isMainWorktree: false + } + vi.mocked(listWorktrees).mockResolvedValue([registeredWorktree]) + vi.mocked(listWorktreesStrict).mockResolvedValue([registeredWorktree]) + vi.mocked(removeWorktree).mockRejectedValue( + Object.assign(new Error('git worktree remove failed'), { + stderr: `fatal: '${orphanPath}' is not a working tree` + }) + ) + vi.mocked(assertWorktreeCleanForRemoval).mockRejectedValue( + Object.assign(new Error('status failed'), { + stderr: 'fatal: not a git repository (or any of the parent directories): .git\n' + }) + ) + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockResolvedValue({ + stdout: '', + stderr: '' + }) + const pruneCallsBefore = gitSpy.mock.calls.filter(([args]) => args[0] === 'worktree').length + + try { + await expect(runtime.removeManagedWorktree(worktreeId)).rejects.toThrow( + 'Worktree is no longer registered with Git but its directory remains.' + ) + await expect(lstat(orphanPath)).resolves.toBeTruthy() + expect(removeWorktreeMeta).not.toHaveBeenCalled() + expect(restoreLocalWatcherAfterFailedRemovalMock).toHaveBeenCalledWith(orphanPath) + expect(gitSpy.mock.calls.filter(([args]) => args[0] === 'worktree')).toHaveLength( + pruneCallsBefore + ) + + await rm(orphanPath, { recursive: true, force: true }) + vi.mocked(listWorktrees).mockResolvedValue([]) + await expect(runtime.removeManagedWorktree(worktreeId)).resolves.toEqual({}) + expect(removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + } finally { + gitSpy.mockRestore() + await rm(parentDir, { recursive: true, force: true }) + } + }) + + it('retains metadata when proven orphan cleanup cannot remove the directory', async () => { + const parentDir = await mkdtemp(join(tmpdir(), 'orca-runtime-orphan-removal-failure-')) + const repoPath = join(parentDir, 'repo') + const orphanPath = join(parentDir, 'orphan') + const adminWorktreePath = join(repoPath, '.git', 'worktrees', 'orphan') + const worktreeId = `${TEST_REPO_ID}::${orphanPath}` + await mkdir(orphanPath, { recursive: true }) + await mkdir(adminWorktreePath, { recursive: true }) + await writeFile(join(orphanPath, '.git'), `gitdir: ${adminWorktreePath}\n`) + await writeFile(join(adminWorktreePath, 'gitdir'), `${join(orphanPath, '.git')}\n`) + const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(worktreeId) + const runtimeStoreWithRepoPath = { + ...runtimeStore, + getRepos: () => [ + { + id: TEST_REPO_ID, + path: repoPath, + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + } + ], + getRepo: (id: string) => + id === TEST_REPO_ID + ? { + id: TEST_REPO_ID, + path: repoPath, + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + } + : undefined + } + const runtime = createWorktreeRemovalRuntime(runtimeStoreWithRepoPath) + const registeredWorktree = { + path: orphanPath, + head: 'abc', + branch: 'feature/orphan', + isBare: false, + isMainWorktree: false + } + vi.mocked(listWorktrees).mockResolvedValue([registeredWorktree]) + vi.mocked(listWorktreesStrict).mockResolvedValue([registeredWorktree]) + vi.mocked(removeWorktree).mockRejectedValue( + Object.assign(new Error('git worktree remove failed'), { + stderr: `fatal: '${orphanPath}' is not a working tree` + }) + ) + vi.mocked(assertWorktreeCleanForRemoval).mockRejectedValue( + Object.assign(new Error('status failed'), { + stderr: 'fatal: not a git repository (or any of the parent directories): .git\n' + }) + ) + const removePathSpy = vi + .spyOn(localWorktreeFilesystem, 'removeLocalWorktreePath') + .mockRejectedValue(new Error('injected removal failure')) + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockResolvedValue({ + stdout: '', + stderr: '' + }) + + try { + await expect(runtime.removeManagedWorktree(worktreeId)).rejects.toThrow( + 'Worktree is no longer registered with Git but its directory remains.' + ) + await expect(lstat(orphanPath)).resolves.toBeTruthy() + expect(removePathSpy).toHaveBeenCalledWith(orphanPath, {}) + expect(removeWorktreeMeta).not.toHaveBeenCalled() + expect(gitSpy).not.toHaveBeenCalledWith(['worktree', 'prune'], expect.anything()) + } finally { + removePathSpy.mockRestore() + gitSpy.mockRestore() + await rm(parentDir, { recursive: true, force: true }) + } + }) + it('drops the bounded scan cache when orphan-cleanup removal completes', async () => { const runtime = createWorktreeRemovalRuntime() vi.mocked(getEffectiveHooks).mockReturnValue(null) diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts index cc73ffa2718..5a5c9521403 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts @@ -418,7 +418,11 @@ describe('OrcaRuntimeService', () => { await Promise.resolve() await Promise.resolve() - expect(write).toHaveBeenCalledWith('pty-startup-draft', `\x1b[200~${draftUrl}\x1b[201~`) + expect(write).toHaveBeenCalledWith( + 'pty-startup-draft', + `\x1b[200~${draftUrl}\x1b[201~`, + 'launch' + ) }) it('keeps the 8s main-runtime startup readiness budget for agents without an override', async () => { @@ -554,7 +558,11 @@ describe('OrcaRuntimeService', () => { await Promise.resolve() await Promise.resolve() - expect(write).toHaveBeenCalledWith('pty-opencode-draft-budget', `\x1b[200~${draftUrl}\x1b[201~`) + expect(write).toHaveBeenCalledWith( + 'pty-opencode-draft-budget', + `\x1b[200~${draftUrl}\x1b[201~`, + 'launch' + ) }) it('rejects explicit startup commands for disabled selected agents', async () => { diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts index 2ec8e73f800..d3d2ee8b44a 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts @@ -83,7 +83,7 @@ describe('OrcaRuntimeService', () => { }) ) await vi.waitFor(() => { - expect(write).toHaveBeenCalledWith('pty-cli-aider-startup', 'fix it\r') + expect(write).toHaveBeenCalledWith('pty-cli-aider-startup', 'fix it\r', 'launch') }) }) @@ -509,6 +509,7 @@ describe('OrcaRuntimeService', () => { repoSelector: 'id:repo-1', name: 'runtime-explicit-draft', startupDraft: draftUrl, + startupLaunchSource: 'cli', createdWithAgent: 'codex', activate: true }) @@ -519,14 +520,20 @@ describe('OrcaRuntimeService', () => { expect.objectContaining({ cwd: '/tmp/workspaces/runtime-explicit-draft', command: "codex '--dangerously-bypass-approvals-and-sandbox'", - worktreeId: result.worktree.id + worktreeId: result.worktree.id, + // The host picked and launched this agent, so it carries the caller's surface too. + telemetry: { agent_kind: 'codex', launch_source: 'cli', request_kind: 'new' } }) ) expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'codex' }) runtime.onPtyData('pty-explicit-draft', '\x1b[?2004h›', Date.now()) await vi.waitFor(() => { - expect(write).toHaveBeenCalledWith('pty-explicit-draft', `\x1b[200~${draftUrl}\x1b[201~`) + expect(write).toHaveBeenCalledWith( + 'pty-explicit-draft', + `\x1b[200~${draftUrl}\x1b[201~`, + 'launch' + ) }) }) diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-05.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-05.spec.ts index c4ac5ea9a84..1658c8690c7 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-05.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-05.spec.ts @@ -122,7 +122,7 @@ describe('OrcaRuntimeService', () => { expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'claude' }) }) - it('pre-marks remote Codex workspaces trusted before pasting startup drafts', async () => { + it('launches a remote Codex startup draft with its declared agent on the SSH host', async () => { detectRemoteAgentsMock.mockResolvedValue(['codex']) muxRequestMock.mockResolvedValue({ resolvedPath: '/home/dev' }) const created = { @@ -189,7 +189,11 @@ describe('OrcaRuntimeService', () => { } const fsProvider = { realpath: vi.fn().mockResolvedValue('/remote/mobile-codex-draft'), - readFile: vi.fn().mockRejectedValue(new Error('missing config')), + readFile: vi + .fn() + .mockRejectedValue( + new Error("ENOENT: no such file or directory, stat '/home/dev/.codex/config.toml'") + ), createDir: vi.fn().mockResolvedValue(undefined), writeFile: vi.fn().mockResolvedValue(undefined) } @@ -212,27 +216,16 @@ describe('OrcaRuntimeService', () => { }) expect(detectRemoteAgentsMock).not.toHaveBeenCalled() - expect(muxRequestMock).toHaveBeenCalledWith('session.resolveHome', { path: '~' }) - expect(fsProvider.createDir).toHaveBeenCalledWith('/home/dev/.codex') - expect(fsProvider.writeFile).toHaveBeenCalledWith( - '/home/dev/.codex/config.toml', - expect.stringContaining('[projects."/remote/mobile-codex-draft"]') - ) - expect(fsProvider.writeFile).toHaveBeenCalledWith( - '/home/dev/.codex/config.toml', - expect.stringContaining('trust_level = "trusted"') - ) expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ cwd: '/remote/mobile-codex-draft', command: "codex '--dangerously-bypass-approvals-and-sandbox'", connectionId: 'ssh-1', - worktreeId: result.worktree.id + worktreeId: result.worktree.id, + // The spawn builder pre-trusts the workspace on the SSH host for this agent. + launchAgent: 'codex' }) ) - expect(fsProvider.writeFile.mock.invocationCallOrder[0]).toBeLessThan( - spawn.mock.invocationCallOrder[0]! - ) expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'codex' }) } finally { unregisterSshFilesystemProvider('ssh-1') @@ -240,7 +233,7 @@ describe('OrcaRuntimeService', () => { } }) - it('pre-marks remote Codex workspaces trusted before explicit startup commands', async () => { + it('launches a remote explicit Codex startup command with its declared agent on the SSH host', async () => { muxRequestMock.mockResolvedValue({ resolvedPath: '/home/dev' }) const created = { path: '/remote/mobile-codex-command', @@ -302,7 +295,11 @@ describe('OrcaRuntimeService', () => { } const fsProvider = { realpath: vi.fn().mockResolvedValue('/remote/mobile-codex-command'), - readFile: vi.fn().mockRejectedValue(new Error('missing config')), + readFile: vi + .fn() + .mockRejectedValue( + new Error("ENOENT: no such file or directory, stat '/home/dev/.codex/config.toml'") + ), createDir: vi.fn().mockResolvedValue(undefined), writeFile: vi.fn().mockResolvedValue(undefined) } @@ -326,26 +323,16 @@ describe('OrcaRuntimeService', () => { }) expect(detectRemoteAgentsMock).not.toHaveBeenCalled() - expect(muxRequestMock).toHaveBeenCalledWith('session.resolveHome', { path: '~' }) - expect(fsProvider.writeFile).toHaveBeenCalledWith( - '/home/dev/.codex/config.toml', - expect.stringContaining('[projects."/remote/mobile-codex-command"]') - ) - expect(fsProvider.writeFile).toHaveBeenCalledWith( - '/home/dev/.codex/config.toml', - expect.stringContaining('trust_level = "trusted"') - ) expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ cwd: '/remote/mobile-codex-command', command: 'codex', connectionId: 'ssh-1', - worktreeId: result.worktree.id + worktreeId: result.worktree.id, + // The spawn builder pre-trusts the workspace on the SSH host for this agent. + launchAgent: 'codex' }) ) - expect(fsProvider.writeFile.mock.invocationCallOrder[0]).toBeLessThan( - spawn.mock.invocationCallOrder[0]! - ) expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'codex' }) } finally { unregisterSshFilesystemProvider('ssh-1') diff --git a/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts b/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts index 7a08f8f3bac..3c18eb68def 100644 --- a/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts +++ b/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts @@ -27,8 +27,8 @@ export class OrcaRuntimeWithTouchMobileSessionTabsForWorktree extends OrcaRuntim snapshotVersion: snapshot.snapshotVersion + 1 }) if (options.immediate) { - // Why: readiness/lifecycle changes are structural and must not wait - // behind the title/status coalescing window. + // Why: an exit is structural and must not wait behind the title/status window. A + // registration's ready flip coalesces so it merges with the spawn's graph update. this.notifyMobileSessionTabsChanged(worktreeId) return } @@ -126,7 +126,11 @@ export class OrcaRuntimeWithTouchMobileSessionTabsForWorktree extends OrcaRuntim tab.parentTabId, tab.leafId, tab.ptyId - ) + ) || + // Why: after a cold restore the saved session is the only membership record until the PTY + // registers. Frame-only; once listed, the surface's graph leaves pass the shared predicate + // like any listed surface's. Host-side single-writer membership absorbs this. + this.hasPersistedTerminalSurfaceMembership(snapshot.worktree, tab.parentTabId, tab.leafId) ) { continue } diff --git a/src/main/runtime/orca-runtime-visible-snapshot-preview.ts b/src/main/runtime/orca-runtime-visible-snapshot-preview.ts index ac9eb99a67b..be14a92c319 100644 --- a/src/main/runtime/orca-runtime-visible-snapshot-preview.ts +++ b/src/main/runtime/orca-runtime-visible-snapshot-preview.ts @@ -140,6 +140,24 @@ export class OrcaRuntimeWithVisibleSnapshotPreview extends OrcaRuntimeWithCaptur } } + /** Synchronous visible grid of the live emulator, for tui-idle body evidence. Null when the + * model is not the whole screen: a provider-restored partial suffix or a pending hydration. */ + protected readLiveTerminalScreenLines(ptyId: string | null | undefined): string[] | null { + if (!ptyId) { + return null + } + const state = this.headlessTerminals.get(ptyId) + if ( + !state || + this.providerSnapshotPreferredPtys.has(ptyId) || + this.headlessHydrationState.get(ptyId) === 'pending' + ) { + return null + } + // Why unawaited writeChain: callers are synchronous; a grid one chunk behind is re-read next poll. + return projectTerminalVisibleLines(state.emulator).lines + } + protected async parseVisibleSnapshot(snapshot: { data: string cols: number diff --git a/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts b/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts index 37ac8c5a839..b3749ec6dba 100644 --- a/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts +++ b/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts @@ -142,16 +142,6 @@ export class OrcaRuntimeWithWaitForLeafPtyId extends OrcaRuntimeWithRestoreLiveP } // Why: a leaf exists before its PTY spawns; a handle issued while ptyId is null gets invalidated on the next sync, so wait for a connected PTY. - protected countLeavesInTab(tabId: string): number { - let count = 0 - for (const leaf of this.leaves.values()) { - if (leaf.tabId === tabId) { - count++ - } - } - return count - } - protected resolveHandleForTab(tabId: string): string | null { for (const leaf of this.leaves.values()) { if (leaf.tabId === tabId && leaf.ptyId !== null) { diff --git a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts index 5e90602755c..1597084eee1 100644 --- a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts +++ b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts @@ -27,7 +27,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso ptyId: string, generation: number, pastePayload: string, - options: RuntimeAgentPromptWriteOptions = {} + options: RuntimeAgentPromptWriteOptions ): Promise<{ submits: number; prompt?: RuntimeTerminalPromptDelivery }> { assertAgentPromptRequestActive(options.signal) this.assertAgentPromptGeneration(ptyId, generation) @@ -62,7 +62,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso // beginning when a large frame is split into independently processed chunks. renderGate?.arm() const initialWrite = submitWithPaste ? pastePayload + AGENT_PROMPT_SUBMIT : pastePayload - if (!this.ptyController?.write(ptyId, initialWrite)) { + if (!this.ptyController?.write(ptyId, initialWrite, options.inputKind)) { throw new Error('terminal_not_writable') } } catch (error) { @@ -103,7 +103,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso const baseline = preSubmitBaseline ?? this.getAgentPromptActivity(handle, ptyId, waitTextCache) this.assertAgentPromptPermissionSafe(permissionBaseline, baseline) if (!submitWithPaste) { - if (!this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT)) { + if (!this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT, options.inputKind)) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') } } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 13796169be0..070363fdb3f 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -51,6 +51,7 @@ await import('./orca-runtime-tests/terminal-creation-and-readiness-part-09.spec' await import('./orca-runtime-tests/terminal-creation-and-readiness-part-10.spec') await import('./orca-runtime-tests/terminal-creation-and-readiness-part-11.spec') await import('./orca-runtime-tests/terminal-creation-and-readiness-part-12.spec') +await import('./orca-runtime-tests/terminal-spawn-dispatch.spec') await import('./orca-runtime-tests/terminal-output-and-worker-recovery.spec') await import('./orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec') await import('./orca-runtime-tests/terminal-output-and-worker-recovery-part-03.spec') @@ -78,6 +79,7 @@ await import('./orca-runtime-tests/mobile-session-tabs-part-10.spec') await import('./orca-runtime-tests/mobile-session-tabs-part-11.spec') await import('./orca-runtime-tests/mobile-session-tabs-part-12.spec') await import('./orca-runtime-tests/mobile-session-tabs-part-13.spec') +await import('./orca-runtime-tests/exit-retirement-activation.spec') await import('./orca-runtime-tests/mobile-creation-and-orchestration.spec') await import('./orca-runtime-tests/mobile-creation-and-orchestration-part-02.spec') await import('./orca-runtime-tests/mobile-creation-and-orchestration-part-03.spec') diff --git a/src/main/runtime/orchestration/canonical-orca-session-id.ts b/src/main/runtime/orchestration/canonical-orca-session-id.ts new file mode 100644 index 00000000000..4aee8cbea15 --- /dev/null +++ b/src/main/runtime/orchestration/canonical-orca-session-id.ts @@ -0,0 +1,38 @@ +import { isOrcaSessionId, type OrcaSessionId } from '../../../shared/orca-session-address' +import { + clearedInto, + readAgentSessionRecordStore, + type AgentSessionRecordReader +} from './structured-session-lineage' + +/** + * The Orca session id orchestration addresses a session by: the first session of its `/clear` + * lineage, so a cleared chat keeps the address, Runs and mail it had. Every session-to-party step + * calls this. Without a record store there is no lineage to read, and the id stands for itself. + */ +export function canonicalOrcaSessionId( + orcaSessionId: OrcaSessionId, + store: AgentSessionRecordReader | null = readAgentSessionRecordStore() +): OrcaSessionId { + if (!store) { + return orcaSessionId + } + const clearedFrom = new Map() + for (const record of store.listRecords()) { + const next = clearedInto(record) + if (next) { + clearedFrom.set(next, record.sessionId) + } + } + // A clear chain is acyclic by construction; the visited set only bounds a corrupt store. + let root: string = orcaSessionId + const earlier = new Set([root]) + let prior = clearedFrom.get(root) + while (prior && !earlier.has(prior)) { + earlier.add(prior) + root = prior + prior = clearedFrom.get(root) + } + // Record ids are minted as Orca session ids; one that is not cannot name the conversation. + return isOrcaSessionId(root) ? root : orcaSessionId +} diff --git a/src/main/runtime/orchestration/cli-command.ts b/src/main/runtime/orchestration/cli-command.ts index 809be9a4b88..af38b95a350 100644 --- a/src/main/runtime/orchestration/cli-command.ts +++ b/src/main/runtime/orchestration/cli-command.ts @@ -1,9 +1,20 @@ import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' import { isWslUncPath } from '../../../shared/wsl-paths' import { splitWorktreeIdForFilesystem } from '../../../shared/worktree/id' +import { getAppEnvironment, hasAppEnvironment } from '../../../shared/app-environment' export type OrchestrationCliCommand = 'orca' | 'orca-dev' | 'orca-ide' +/** Dev builds run the CLI as `orca-dev`; a packaged app, or a process with no app, must not advertise it. */ +export function runtimeOrchestrationCliCommand(): OrchestrationCliCommand | undefined { + return hasAppEnvironment() && !getAppEnvironment().isPackaged() ? 'orca-dev' : undefined +} + +/** What a local, non-WSL terminal is told to run; a structured session is always one. */ +export function localOrchestrationCliCommand(): OrchestrationCliCommand { + return runtimeOrchestrationCliCommand() ?? 'orca' +} + export function resolveTerminalOrchestrationCliCommand(args: { connectionId: string | null isWsl: boolean | null | undefined diff --git a/src/main/runtime/orchestration/coordinator-runtime-contract.ts b/src/main/runtime/orchestration/coordinator-runtime-contract.ts index 2f4f5bf9b5a..3c98fa30200 100644 --- a/src/main/runtime/orchestration/coordinator-runtime-contract.ts +++ b/src/main/runtime/orchestration/coordinator-runtime-contract.ts @@ -11,7 +11,7 @@ export type CoordinatorRuntime = { sendTerminalAgentPrompt( handle: string, prompt: string, - options?: DispatchPreambleSendOptions + options: DispatchPreambleSendOptions ): Promise listTerminals( worktreeSelector?: string, diff --git a/src/main/runtime/orchestration/coordinator-task-dispatch.ts b/src/main/runtime/orchestration/coordinator-task-dispatch.ts index bb64f6dd98b..a305afedf40 100644 --- a/src/main/runtime/orchestration/coordinator-task-dispatch.ts +++ b/src/main/runtime/orchestration/coordinator-task-dispatch.ts @@ -32,7 +32,7 @@ export async function listAvailableWorkerTerminals( runtime: CoordinatorRuntime, coordinatorHandle: string, worktree: string | undefined -): Promise { +): Promise { try { const result = await runtime.listTerminals(worktree, undefined, { includeVisualLayouts: false @@ -55,7 +55,8 @@ export async function listAvailableWorkerTerminals( ) .map((t) => t.handle) } catch { - return [] + // A failed census cannot authorize creating another worker. + return null } } diff --git a/src/main/runtime/orchestration/coordinator-terminal-census-unavailable.test.ts b/src/main/runtime/orchestration/coordinator-terminal-census-unavailable.test.ts new file mode 100644 index 00000000000..49b34814144 --- /dev/null +++ b/src/main/runtime/orchestration/coordinator-terminal-census-unavailable.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Coordinator } from './coordinator' +import type { CoordinatorRuntime } from './coordinator-runtime-contract' +import { OrchestrationDb } from './db' + +afterEach(() => vi.useRealTimers()) + +describe('coordinator terminal census availability', () => { + it.each(['terminal_surface_ownership_unavailable', 'terminal_liveness_unavailable'])( + 'defers dispatch on %s and reuses the existing worker after recovery', + async (error) => { + vi.useFakeTimers() + const db = new OrchestrationDb(':memory:') + const task = db.createTask({ runId: 'run_legacy_local', spec: 'implement the feature' }) + const listTerminals = vi + .fn() + .mockRejectedValueOnce(new Error(error)) + .mockResolvedValue({ + terminals: [ + { handle: 'term_existing', worktreeId: 'wt1', connected: true, writable: true } + ] + }) + const createTerminal = vi.fn(async () => ({ + handle: 'term_unnecessary', + worktreeId: 'wt1' + })) + const sendTerminalAgentPrompt = vi.fn( + async () => ({ accepted: true }) + ) + const runtime: CoordinatorRuntime = { + listTerminals, + createTerminal, + sendTerminalAgentPrompt, + waitForTerminal: async (handle) => ({ handle, condition: 'exit' }), + probeWorktreeDrift: async () => null + } + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 1000, + worktree: 'wt1' + }) + const run = coordinator.run() + try { + await vi.advanceTimersByTimeAsync(0) + expect(listTerminals).toHaveBeenCalledTimes(1) + expect(createTerminal).not.toHaveBeenCalled() + expect(sendTerminalAgentPrompt).not.toHaveBeenCalled() + expect(db.getTask(task.id)?.status).toBe('ready') + expect(db.listTasks({ status: 'dispatched' })).toEqual([]) + + await vi.advanceTimersByTimeAsync(1000) + expect(listTerminals).toHaveBeenCalledTimes(2) + expect(createTerminal).not.toHaveBeenCalled() + expect(sendTerminalAgentPrompt).toHaveBeenCalledTimes(1) + const dispatch = db.getDispatchContext(task.id) + expect(dispatch?.assignee_handle).toBe('term_existing') + expect(db.getTask(task.id)?.status).toBe('dispatched') + db.insertMessage({ + runId: 'run_legacy_local', + from: 'term_existing', + to: 'coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ + taskId: task.id, + dispatchId: dispatch?.id, + outcome: 'succeeded' + }) + }) + await vi.advanceTimersByTimeAsync(1000) + await expect(run).resolves.toMatchObject({ status: 'completed', completedTasks: [task.id] }) + } finally { + coordinator.stop() + try { + await vi.runOnlyPendingTimersAsync() + await run + } finally { + db.close() + } + } + } + ) +}) diff --git a/src/main/runtime/orchestration/coordinator.ts b/src/main/runtime/orchestration/coordinator.ts index 252c9f89ea9..915dc9e16a4 100644 --- a/src/main/runtime/orchestration/coordinator.ts +++ b/src/main/runtime/orchestration/coordinator.ts @@ -245,6 +245,9 @@ export class Coordinator { this.opts.coordinatorHandle, this.opts.worktree ) + if (terminals === null) { + return + } if (terminals.length === 0 && slotsAvailable > 0) { // Why: create at most one terminal per tick to avoid spawning many at once. try { diff --git a/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts b/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts index 38905f30434..b51e5b4a8aa 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts @@ -9,6 +9,7 @@ import { recordedCreatorIdentity, type DispatchCreator } from '../dispatch-depth import type { OrchestrationDb } from '../orchestration-db' import { transitionLifecycleWithDb } from '../lifecycle-transition' import { taskNotFoundError, taskNotStartableError } from '../../task-dispatch-refusal' +import { dispatchAssigneeOrcaSessionId } from '../../dispatch-assignee-orca-session-id' export function createDispatchContext( this: OrchestrationDb, @@ -65,6 +66,7 @@ export function createDispatchContext( launchTokenHash: launchTokenHash ?? null, assigneeHandle, assigneePaneKey: assigneePaneKey ?? null, + assigneeOrcaSessionId: dispatchAssigneeOrcaSessionId(processIncarnation), processIncarnation: processIncarnation ?? null, creatorDispatchId, ...recordedCreatorIdentity(params.creator), diff --git a/src/main/runtime/orchestration/db/dispatch-depth.ts b/src/main/runtime/orchestration/db/dispatch-depth.ts index cc4ba026db9..93c5bd65fb6 100644 --- a/src/main/runtime/orchestration/db/dispatch-depth.ts +++ b/src/main/runtime/orchestration/db/dispatch-depth.ts @@ -7,6 +7,7 @@ import { import { OrchestrationError } from '../orchestration-error' import { isEquivalentPaneKey } from './pane-key-match' import type { OrchestrationDb } from './orchestration-db' +import type { OrcaSessionId } from '../../../../shared/orca-session-address' import type { DispatchContextRow, RemoteDispatchAttachmentRow } from '../types' import { potentiallyLiveRemoteAttachmentSql } from './federation/remote-attachment-liveness' @@ -26,17 +27,33 @@ export type DispatchCreator = paneKey?: string /** Remote attachment matching requires the exact incarnation; local rows do not. */ processIncarnation?: string + /** A structured worker's bare Orca session id, recorded beside its handle. */ + orcaSessionId?: OrcaSessionId | null } + /** A structured session with no terminal handle, identified by its Orca session id alone. */ + | { kind: 'session'; orcaSessionId: OrcaSessionId } /** Creator identity to persist on a new row, so depth can later tell delegation from bookkeeping. */ export function recordedCreatorIdentity(creator: DispatchCreator): { creatorHandle: string | null creatorPaneKey: string | null + creatorOrcaSessionId: OrcaSessionId | null } { if (creator.kind === 'system') { - return { creatorHandle: null, creatorPaneKey: null } + return { creatorHandle: null, creatorPaneKey: null, creatorOrcaSessionId: null } + } + if (creator.kind === 'session') { + return { + creatorHandle: null, + creatorPaneKey: null, + creatorOrcaSessionId: creator.orcaSessionId + } + } + return { + creatorHandle: creator.handle, + creatorPaneKey: creator.paneKey ?? null, + creatorOrcaSessionId: creator.orcaSessionId ?? null } - return { creatorHandle: creator.handle, creatorPaneKey: creator.paneKey ?? null } } /** @@ -84,14 +101,14 @@ export function resolveCreatorDepth(this: OrchestrationDb, creator: DispatchCrea // Local rows match on handle/pane as they always have. process_incarnation is // nullable here and context-only dispatch stores null deliberately, so // requiring it would drop real parents. - const local = this.findActiveDispatchForAssignee(creator.handle, creator.paneKey) as - | DispatchContextRow - | undefined + const local = findActiveDispatchForCreator.call(this, creator) if (local && !isSelfCreatedDispatch(local)) { depths.push(local.depth) } - for (const attachment of findPotentiallyLiveAttachmentsForCreator.call(this, creator)) { + const attachments = + creator.kind === 'terminal' ? findPotentiallyLiveAttachmentsForCreator.call(this, creator) : [] + for (const attachment of attachments) { depths.push(attachment.depth) } @@ -109,16 +126,36 @@ export function resolveCreatorDispatchId( if (creator.kind === 'system') { return null } - const own = this.findActiveDispatchForAssignee(creator.handle, creator.paneKey) + const own = findActiveDispatchForCreator.call(this, creator) // Why: a self-dispatch is not a parent Attempt, so it must not be stamped as the child's creator. const local = own && !isSelfCreatedDispatch(own) ? own : undefined - const remote = findPotentiallyLiveAttachmentsForCreator.call(this, creator) + const remote = + creator.kind === 'terminal' ? findPotentiallyLiveAttachmentsForCreator.call(this, creator) : [] if ((local ? 1 : 0) + remote.length !== 1) { return null } return local?.id ?? remote[0]?.dispatch_id ?? null } +/** The live Dispatch this creator is itself working on, found the way its identity is recorded. */ +function findActiveDispatchForCreator( + this: OrchestrationDb, + creator: Exclude +): DispatchContextRow | undefined { + if (creator.kind === 'terminal') { + return this.findActiveDispatchForAssignee(creator.handle, creator.paneKey) + } + const row = this.db + .prepare( + `SELECT * FROM dispatch_contexts + WHERE assignee_orca_session_id = ? AND status IN ('pending', 'dispatched') + ORDER BY rowid DESC LIMIT 1` + ) + .get(creator.orcaSessionId) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT * over this table returns the row shape its schema and row type define, like every row cast in db/. + return row as DispatchContextRow | undefined +} + /** * Remote attachments matching this caller's pane AND exact process incarnation. * diff --git a/src/main/runtime/orchestration/db/dispatch-row-writer.ts b/src/main/runtime/orchestration/db/dispatch-row-writer.ts index 84862ee343d..e8e4192e137 100644 --- a/src/main/runtime/orchestration/db/dispatch-row-writer.ts +++ b/src/main/runtime/orchestration/db/dispatch-row-writer.ts @@ -1,5 +1,6 @@ import type Database from '../../../sqlite/sync-database' import { DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL } from './pane-key-match' +import type { OrcaSessionId } from '../../../../shared/orca-session-address' /** * The only place that inserts rows representing a live supervised worker. @@ -14,11 +15,11 @@ import { DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL } from './pane-key-match' export const DISPATCH_CONTEXT_CLAIM_SQL = `INSERT INTO dispatch_contexts ( id, run_id, task_id, contract_version, launch_token_hash, - assignee_handle, assignee_pane_key, process_incarnation, - creator_dispatch_id, creator_handle, creator_pane_key, + assignee_handle, assignee_pane_key, assignee_orca_session_id, process_incarnation, + creator_dispatch_id, creator_handle, creator_pane_key, creator_orca_session_id, status, failure_count, depth, dispatched_at ) -SELECT ?, run_id, id, ?, ?, ?, ?, ?, ?, ?, ?, 'dispatched', ?, ?, datetime('now') +SELECT ?, run_id, id, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'dispatched', ?, ?, datetime('now') FROM tasks WHERE id = ? AND status = 'ready' AND NOT EXISTS ( @@ -45,8 +46,9 @@ WHERE id = ? AND status = 'ready' const STARTING_DISPATCH_CONTEXT_SQL = `INSERT INTO dispatch_contexts ( id, run_id, task_id, contract_version, launch_token_hash, retry_of_dispatch_id, - creator_dispatch_id, creator_handle, creator_pane_key, depth, status, dispatched_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', datetime('now'))` + creator_dispatch_id, creator_handle, creator_pane_key, creator_orca_session_id, depth, status, + dispatched_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', datetime('now'))` const REMOTE_DISPATCH_ATTACHMENT_SQL = `INSERT INTO remote_dispatch_attachments ( dispatch_id, home_run_id, task_id, home_peer_fingerprint, protocol_version, runtime_epoch, depth @@ -70,10 +72,12 @@ export function claimDispatchContextRow( launchTokenHash: string | null assigneeHandle: string assigneePaneKey: string | null + assigneeOrcaSessionId?: OrcaSessionId | null processIncarnation: string | null creatorDispatchId?: string | null creatorHandle?: string | null creatorPaneKey?: string | null + creatorOrcaSessionId?: OrcaSessionId | null priorFailures: number depth: number taskId: string @@ -89,10 +93,12 @@ export function claimDispatchContextRow( params.launchTokenHash, params.assigneeHandle, params.assigneePaneKey, + params.assigneeOrcaSessionId ?? null, params.processIncarnation, params.creatorDispatchId ?? null, params.creatorHandle ?? null, params.creatorPaneKey ?? null, + params.creatorOrcaSessionId ?? null, params.priorFailures, params.depth, params.taskId, @@ -118,6 +124,7 @@ export function insertStartingDispatchContextRow( creatorDispatchId?: string | null creatorHandle?: string | null creatorPaneKey?: string | null + creatorOrcaSessionId?: OrcaSessionId | null } ): void { assertStampedDepth(params.depth) @@ -131,6 +138,7 @@ export function insertStartingDispatchContextRow( params.creatorDispatchId ?? null, params.creatorHandle ?? null, params.creatorPaneKey ?? null, + params.creatorOrcaSessionId ?? null, params.depth ) } diff --git a/src/main/runtime/orchestration/db/messages/direct-mailbox-routing.ts b/src/main/runtime/orchestration/db/messages/direct-mailbox-routing.ts index aa7d93fbea2..c1e5ee99562 100644 --- a/src/main/runtime/orchestration/db/messages/direct-mailbox-routing.ts +++ b/src/main/runtime/orchestration/db/messages/direct-mailbox-routing.ts @@ -1,6 +1,7 @@ import type { MessageType } from '../../types' import type { OrchestrationDb } from '../orchestration-db' import { ORCHESTRATION_DELIVERY_BATCH_LIMIT, type MailboxRoutingPage } from './mailbox-routing-page' +import { activeDispatchOwnsAddressSql } from '../runs/run-coordinator-mail-routing' export function hasUndeliveredDirectMessageForRun( this: OrchestrationDb, @@ -48,12 +49,7 @@ export function routeDirectMessagePage( try { const throughClause = throughSequence === undefined ? '' : ' AND sequence <= ?' const dispatchOwnershipClause = preserveActiveDispatchOwnership - ? ` AND NOT EXISTS ( - SELECT 1 FROM dispatch_contexts - WHERE dispatch_contexts.run_id = messages.run_id - AND dispatch_contexts.assignee_handle = messages.to_handle - AND dispatch_contexts.status IN ('pending', 'dispatched') - )` + ? ` AND NOT ${activeDispatchOwnsAddressSql('messages.run_id', 'messages.to_handle')}` : '' const params: (string | number)[] = [runId, directHandle] if (throughSequence !== undefined) { diff --git a/src/main/runtime/orchestration/db/orca-session-address-sql.ts b/src/main/runtime/orchestration/db/orca-session-address-sql.ts new file mode 100644 index 00000000000..83bb7ad2c56 --- /dev/null +++ b/src/main/runtime/orchestration/db/orca-session-address-sql.ts @@ -0,0 +1,10 @@ +import { ORCA_SESSION_ADDRESS_PREFIX } from '../../../../shared/orca-session-address' + +/** + * The `session:` address of a bare Orca session id column or expression, NULL when it is NULL. + * The only way SQL compares a stored id with a mail address: the id side is formatted, never the + * address side stripped, so a handle or `run:` address can never equal a bare id. + */ +export function orcaSessionAddressSql(orcaSessionIdSql: string): string { + return `('${ORCA_SESSION_ADDRESS_PREFIX}' || ${orcaSessionIdSql})` +} diff --git a/src/main/runtime/orchestration/db/runs/run-binding.ts b/src/main/runtime/orchestration/db/runs/run-binding.ts index c5e6f34abfa..b396b79f1ec 100644 --- a/src/main/runtime/orchestration/db/runs/run-binding.ts +++ b/src/main/runtime/orchestration/db/runs/run-binding.ts @@ -3,13 +3,21 @@ import { OrchestrationError } from '../../orchestration-error' import { LEGACY_CONTRACT_VERSION } from '../contract-constants' import { isEquivalentPaneKey } from '../pane-key-match' import type { OrchestrationDb } from '../orchestration-db' +import type { OrcaSessionId } from '../../../../../shared/orca-session-address' +import { + mailboxAddressOf, + runBoundToCoordinator, + runCoordinatorKey +} from '../../orchestration-caller-identity' export function bindRun( this: OrchestrationDb, params: { runId: string - coordinatorHandle: string - coordinatorPaneKey: string + coordinatorHandle: string | null + coordinatorPaneKey: string | null + /** The coordinator's bare Orca session id when it is a structured session; see orca-session-address. */ + coordinatorOrcaSessionId?: OrcaSessionId | null takeoverLegacy?: boolean legacyCoordinatorAuthority?: { runId: string @@ -20,6 +28,11 @@ export function bindRun( } } ): RunRow | undefined { + const coordinator = { + terminalHandle: params.coordinatorHandle, + paneKey: params.coordinatorPaneKey, + orcaSessionId: params.coordinatorOrcaSessionId ?? null + } this.db.exec('BEGIN IMMEDIATE') try { const run = this.getRunRaw(params.runId) @@ -27,9 +40,7 @@ export function bindRun( this.db.exec('ROLLBACK') return undefined } - const sameBinding = - run.coordinator_pane_key !== null && - isEquivalentPaneKey(run.coordinator_pane_key, params.coordinatorPaneKey) + const sameBinding = runBoundToCoordinator(run, coordinator) const adoption = this.getLegacyAdoption() const adoptedRun = adoption?.adopted_run_id === params.runId const legacyAuthority = params.legacyCoordinatorAuthority @@ -49,6 +60,7 @@ export function bindRun( legacyPrincipal.terminal_handle === legacyAuthority.terminalHandle && isEquivalentPaneKey(legacyPrincipal.pane_key, legacyAuthority.paneKey) && params.coordinatorHandle === legacyAuthority.terminalHandle && + params.coordinatorPaneKey !== null && isEquivalentPaneKey(params.coordinatorPaneKey, legacyAuthority.paneKey) ) if (legacyAuthority && !provenLegacyBinding) { @@ -109,14 +121,17 @@ export function bindRun( } ) } - this.unbindOtherRunsForPane(params.coordinatorPaneKey, params.runId) - for (const handle of new Set( - [run.coordinator_handle, params.coordinatorHandle].filter((value): value is string => - Boolean(value) - ) - )) { - this.rememberRunCoordinatorHandle(params.runId, handle) - this.routeAllUnreadDirectMessagesToRunMailbox(params.runId, handle) + this.unbindOtherRunsForCoordinator(coordinator, params.runId) + // The mailbox address of the coordinator being replaced and of the one binding now. + for (const address of new Set([ + mailboxAddressOf(runCoordinatorKey(run)), + mailboxAddressOf(coordinator) + ])) { + if (address === null) { + continue + } + this.rememberRunCoordinatorHandle(params.runId, address) + this.routeAllUnreadDirectMessagesToRunMailbox(params.runId, address) } if ( (params.takeoverLegacy && !takeoverAlreadyApplied) || @@ -128,26 +143,40 @@ export function bindRun( coordinatorPrincipal?.status === 'committed' && (params.takeoverLegacy || coordinatorPrincipal.terminal_handle !== params.coordinatorHandle || + params.coordinatorPaneKey === null || !isEquivalentPaneKey(coordinatorPrincipal.pane_key, params.coordinatorPaneKey)) ) { this.setLegacyCompatibilityPrincipalStatus(coordinatorPrincipal.id, 'revoked') } } - // The Orca session id belongs to the coordinator being replaced; nothing here resolves the new one's. this.db .prepare( `UPDATE runs - SET coordinator_handle = ?, coordinator_pane_key = ?, coordinator_orca_session_id = NULL, - coordinator_orca_session_id_generation = NULL, + SET coordinator_handle = ?, coordinator_pane_key = ?, coordinator_orca_session_id = ?, + coordinator_orca_session_id_generation = consumer_generation + 1, consumer_generation = consumer_generation + 1, updated_at = datetime('now') WHERE id = ?` ) - .run(params.coordinatorHandle, params.coordinatorPaneKey, params.runId) + .run( + coordinator.terminalHandle, + coordinator.paneKey, + coordinator.orcaSessionId, + params.runId + ) this.fenceUnacknowledgedMailboxDeliveries(`run:${params.runId}`) if (params.takeoverLegacy || replacesLegacyCoordinator) { this.promoteLegacyCoordinatorMailForTakeover(params.runId, retainedCoordinatorHandle) } + } else if (runCoordinatorKey(run).orcaSessionId !== coordinator.orcaSessionId) { + // Same coordinator, so no new consumer: correct an Orca session id a writer without the column left. + this.db + .prepare( + `UPDATE runs SET coordinator_orca_session_id = ?, + coordinator_orca_session_id_generation = consumer_generation + WHERE id = ?` + ) + .run(coordinator.orcaSessionId, params.runId) } this.db.exec('COMMIT') } catch (error) { diff --git a/src/main/runtime/orchestration/db/runs/run-coordinator-mail-routing.ts b/src/main/runtime/orchestration/db/runs/run-coordinator-mail-routing.ts index 07a6044d5aa..d42b93dfdd9 100644 --- a/src/main/runtime/orchestration/db/runs/run-coordinator-mail-routing.ts +++ b/src/main/runtime/orchestration/db/runs/run-coordinator-mail-routing.ts @@ -1,6 +1,16 @@ import type { OrchestrationDb } from '../orchestration-db' import { currentRunCoordinatorSessionAddressSql } from './run-coordinator-orca-session' +/** Mail to an active Dispatch assignee's address in the same Run is that worker's, not coordinator mail. */ +export function activeDispatchOwnsAddressSql(runIdSql: string, addressSql: string): string { + return `EXISTS ( + SELECT 1 FROM dispatch_contexts + WHERE dispatch_contexts.run_id = ${runIdSql} + AND dispatch_contexts.assignee_handle = ${addressSql} + AND dispatch_contexts.status IN ('pending', 'dispatched') + )` +} + export function rememberRunCoordinatorHandle( this: OrchestrationDb, runId: string, @@ -43,11 +53,7 @@ export function createCoordinatorMailRoutingTrigger(this: OrchestrationDb): void SELECT 1 FROM run_coordinator_handles WHERE run_id = NEW.run_id AND terminal_handle = NEW.to_handle ) - AND NOT EXISTS ( - SELECT 1 FROM dispatch_contexts - WHERE run_id = NEW.run_id AND assignee_handle = NEW.to_handle - AND status IN ('pending', 'dispatched') - ) + AND NOT ${activeDispatchOwnsAddressSql('NEW.run_id', 'NEW.to_handle')} BEGIN UPDATE messages SET to_handle = 'run:' || NEW.run_id WHERE sequence = NEW.sequence; END; @@ -69,12 +75,7 @@ export function routeAllUnreadDirectMessagesToRunMailbox( `UPDATE messages SET to_handle = ? WHERE run_id = ? AND to_handle = ? AND read = 0 AND delivery_contract = 'current_delivery' - AND NOT EXISTS ( - SELECT 1 FROM dispatch_contexts - WHERE dispatch_contexts.run_id = messages.run_id - AND dispatch_contexts.assignee_handle = messages.to_handle - AND dispatch_contexts.status IN ('pending', 'dispatched') - )` + AND NOT ${activeDispatchOwnsAddressSql('messages.run_id', 'messages.to_handle')}` ) .run(`run:${runId}`, runId, directHandle) } diff --git a/src/main/runtime/orchestration/db/runs/run-coordinator-orca-session-binding.test.ts b/src/main/runtime/orchestration/db/runs/run-coordinator-orca-session-binding.test.ts new file mode 100644 index 00000000000..3340f625b66 --- /dev/null +++ b/src/main/runtime/orchestration/db/runs/run-coordinator-orca-session-binding.test.ts @@ -0,0 +1,405 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerProcessIncarnation +} from '../../../structured-worker-identity' +import { OrchestrationDb } from '../../db' +import { + formatOrcaSessionAddress, + type OrcaSessionId +} from '../../../../../shared/orca-session-address' +import { testOrcaSessionId } from '../../../../../shared/orca-session-address-test-fixture' + +const CHAT_X_ID = testOrcaSessionId('1b6f0c3a-7d2e-4a91-8c55-2e9d4b7a0f13') +const CHAT_X = formatOrcaSessionAddress(CHAT_X_ID) +const CHAT_Y_ID = testOrcaSessionId('6d2a9e41-0c7b-4f38-9a15-b3e8c1d57f20') +const CHAT_Y = formatOrcaSessionAddress(CHAT_Y_ID) +const WORKER_SESSION = testOrcaSessionId('9c3e5a17-4b2d-4f60-8e91-0d7a6c2b5e48') +const WORKER_ADDRESS = formatOrcaSessionAddress(WORKER_SESSION) +const OTHER_WORKER_SESSION = testOrcaSessionId('2e8b4d61-5a3c-4e97-b0f2-7c1d9a6e3b54') +const PTY_PANE = 'tab_pty:11111111-1111-4111-8111-111111111111' +const OTHER_PANE = 'tab_other:22222222-2222-4222-8222-222222222222' +const UNCAPPED = Number.MAX_SAFE_INTEGER + +function chat(orcaSessionId: OrcaSessionId) { + return { terminalHandle: null, paneKey: null, orcaSessionId } +} + +describe('Run binding by Orca session id', () => { + let db: OrchestrationDb + + afterEach(() => { + db?.close() + }) + + function createChatRun(orcaSessionId: OrcaSessionId, objective = 'chat run') { + return db.createRun({ + objective, + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorOrcaSessionId: orcaSessionId + }) + } + + function directMail(runId: string, to: string, subject = 'direct') { + return db.insertMessage({ from: 'term_sender', to, subject, body: '', runId }) + } + + function structuredWorker(sessionId = WORKER_SESSION) { + return { + terminalHandle: mintStructuredWorkerHandle(), + paneKey: mintStructuredWorkerPaneKey(sessionId), + orcaSessionId: sessionId + } + } + + // A binary without the Orca session id column: its bindRun and unbindOtherRunsForPane statements. + function olderBinaryRebind(runId: string, handle: string, paneKey: string) { + db.db + .prepare( + `UPDATE runs SET coordinator_handle = ?, coordinator_pane_key = ?, + consumer_generation = consumer_generation + 1, updated_at = datetime('now') + WHERE id = ?` + ) + .run(handle, paneKey, runId) + } + + function olderBinaryUnbind(runId: string) { + db.db + .prepare( + `UPDATE runs SET coordinator_handle = NULL, coordinator_pane_key = NULL, + consumer_generation = consumer_generation + 1, updated_at = datetime('now') + WHERE id = ?` + ) + .run(runId) + } + + /** Unread mail addressed straight to `to`, as a row written before the address was cached. */ + function strayMail(runId: string, to: string) { + const message = directMail(runId, 'term_late', `to ${to}`) + db.db.prepare('UPDATE messages SET to_handle = ? WHERE id = ?').run(to, message.id) + return message.id + } + + it('binds a handle-less session by its Orca session id and remembers its session address', () => { + db = new OrchestrationDb(':memory:') + const run = createChatRun(CHAT_X_ID) + + expect(db.getRunRaw(run.id)).toMatchObject({ + coordinator_handle: null, + coordinator_pane_key: null, + coordinator_orca_session_id: CHAT_X_ID + }) + expect(db.getCurrentRunForCoordinator(chat(CHAT_X_ID))?.id).toBe(run.id) + expect(db.getRunMailboxOwnerIdsForHandle(CHAT_X)).toEqual([run.id]) + // Mail to the session's address reaches the Run mailbox, as mail to a coordinator handle does. + expect(directMail(run.id, CHAT_X).to_handle).toBe(`run:${run.id}`) + }) + + it("never unbinds another session's Run, and unbinds only the same session's other Runs", () => { + db = new OrchestrationDb(':memory:') + const ptyRun = db.createRun({ + objective: 'pty', + coordinatorHandle: 'term_pty', + coordinatorPaneKey: PTY_PANE + }) + const yRun = createChatRun(CHAT_Y_ID, 'y') + const xFirst = createChatRun(CHAT_X_ID, 'x first') + const pending = db.insertMessage({ + from: 'term_sender', + to: 'term_late', + subject: 'queued before the rebind', + body: '', + runId: xFirst.id + }) + // Mail addressed to the session that the cache did not reroute on insert, as a pre-cache row. + db.db.prepare('UPDATE messages SET to_handle = ? WHERE id = ?').run(CHAT_X, pending.id) + const generation = db.getRunRaw(xFirst.id)?.consumer_generation ?? 0 + + const xSecond = createChatRun(CHAT_X_ID, 'x second') + + expect(db.getCurrentRunForCoordinator(chat(CHAT_X_ID))?.id).toBe(xSecond.id) + expect(db.getCurrentRunForCoordinator(chat(CHAT_Y_ID))?.id).toBe(yRun.id) + expect(db.getRunRaw(yRun.id)?.coordinator_orca_session_id).toBe(CHAT_Y_ID) + expect(db.getRunRaw(ptyRun.id)?.coordinator_pane_key).toBe(PTY_PANE) + expect(db.getRunRaw(xFirst.id)).toMatchObject({ + coordinator_handle: null, + coordinator_orca_session_id: null, + consumer_generation: generation + 1 + }) + // Pending coordinator mail follows the Run, as it does when a pane is unbound. + expect(db.getMessageById(pending.id)?.to_handle).toBe(`run:${xFirst.id}`) + }) + + it('stops counting an Orca session id once a binary without the column rebinds the Run to a terminal', () => { + db = new OrchestrationDb(':memory:') + const run = createChatRun(CHAT_X_ID) + olderBinaryRebind(run.id, 'term_taker', PTY_PANE) + + expect(db.getCurrentRunForCoordinator(chat(CHAT_X_ID))).toBeUndefined() + expect( + db.getCurrentRunForCoordinator({ + terminalHandle: 'term_taker', + paneKey: PTY_PANE, + orcaSessionId: null + })?.id + ).toBe(run.id) + createChatRun(CHAT_X_ID, 'next') + expect(db.getRunRaw(run.id)?.coordinator_handle).toBe('term_taker') + }) + + it('does not hand a chat back a Run an older binary rebound and then unbound', () => { + db = new OrchestrationDb(':memory:') + const run = createChatRun(CHAT_X_ID) + olderBinaryRebind(run.id, 'term_taker', PTY_PANE) + olderBinaryUnbind(run.id) + + // Handle and pane are gone and the id is still there: the shape of a live chat binding. + expect(db.getRunRaw(run.id)).toMatchObject({ + coordinator_handle: null, + coordinator_pane_key: null, + coordinator_orca_session_id: CHAT_X_ID + }) + expect(db.getCurrentRunForCoordinator(chat(CHAT_X_ID))).toBeUndefined() + const next = createChatRun(CHAT_X_ID, 'next') + expect(db.getCurrentRunForCoordinator(chat(CHAT_X_ID))?.id).toBe(next.id) + }) + + it("stops counting a structured worker's Orca session id once an older binary unbinds its Run", () => { + db = new OrchestrationDb(':memory:') + const worker = structuredWorker() + const run = db.createRun({ + objective: 'worker coordinates', + coordinatorHandle: worker.terminalHandle, + coordinatorPaneKey: worker.paneKey, + coordinatorOrcaSessionId: worker.orcaSessionId + }) + expect(db.getCurrentRunForCoordinator(worker)?.id).toBe(run.id) + olderBinaryUnbind(run.id) + expect(db.getCurrentRunForCoordinator(worker)).toBeUndefined() + expect(db.getCurrentRunForCoordinator(chat(worker.orcaSessionId))).toBeUndefined() + }) + + it('remembers a coordinating structured worker at its handle, and the v42 trigger its inert session address', () => { + db = new OrchestrationDb(':memory:') + const worker = structuredWorker() + const run = db.createRun({ + objective: 'worker coordinates', + coordinatorHandle: worker.terminalHandle, + coordinatorPaneKey: worker.paneKey, + coordinatorOrcaSessionId: worker.orcaSessionId + }) + + expect(db.getRunMailboxOwnerIdsForHandle(worker.terminalHandle)).toEqual([run.id]) + expect(db.getRunMailboxOwnerIdsForHandle(WORKER_ADDRESS)).toEqual([run.id]) + expect(directMail(run.id, WORKER_ADDRESS).to_handle).toBe(`run:${run.id}`) + }) + + it('hands a Run to a different session like a terminal takeover: fenced, rerouted, remembered', () => { + db = new OrchestrationDb(':memory:') + const run = createChatRun(CHAT_X_ID) + const pending = directMail(run.id, 'term_late') + db.db.prepare('UPDATE messages SET to_handle = ? WHERE id = ?').run(CHAT_X, pending.id) + const before = db.getRunRaw(run.id)?.consumer_generation ?? 0 + + db.bindRun({ + runId: run.id, + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorOrcaSessionId: CHAT_Y_ID + }) + + expect(db.getRunRaw(run.id)).toMatchObject({ + coordinator_orca_session_id: CHAT_Y_ID, + consumer_generation: before + 1 + }) + expect(db.getCurrentRunForCoordinator(chat(CHAT_X_ID))).toBeUndefined() + expect(db.getCurrentRunForCoordinator(chat(CHAT_Y_ID))?.id).toBe(run.id) + expect(db.getMessageById(pending.id)?.to_handle).toBe(`run:${run.id}`) + expect(db.getRunMailboxOwnerIdsForHandle(CHAT_Y)).toEqual([run.id]) + }) + + it('rebinding the same session is not a new consumer, and fills a missing Orca session id in place', () => { + db = new OrchestrationDb(':memory:') + const worker = structuredWorker() + const run = db.createRun({ + objective: 'worker coordinates', + coordinatorHandle: worker.terminalHandle, + coordinatorPaneKey: worker.paneKey + }) + // As an older binary writes the row: no Orca session id, and no generation for one. + db.db + .prepare('UPDATE runs SET coordinator_orca_session_id_generation = NULL WHERE id = ?') + .run(run.id) + const before = db.getRunRaw(run.id)?.consumer_generation + + db.bindRun({ + runId: run.id, + coordinatorHandle: worker.terminalHandle, + coordinatorPaneKey: worker.paneKey, + coordinatorOrcaSessionId: worker.orcaSessionId + }) + + expect(db.getRunRaw(run.id)).toMatchObject({ + coordinator_orca_session_id: WORKER_SESSION, + consumer_generation: before + }) + // Written at the current generation, so the filled id counts on its own. + expect(db.getCurrentRunForCoordinator(chat(WORKER_SESSION))?.id).toBe(run.id) + }) + + it("reroutes and remembers each worker's one mailbox address when one takes a Run from another", () => { + db = new OrchestrationDb(':memory:') + const first = structuredWorker() + const second = structuredWorker(OTHER_WORKER_SESSION) + const run = db.createRun({ + objective: 'first worker coordinates', + coordinatorHandle: first.terminalHandle, + coordinatorPaneKey: first.paneKey, + coordinatorOrcaSessionId: first.orcaSessionId + }) + // A worker's mailbox address is its handle; nothing writes mail to its session address. + const addresses = [first.terminalHandle, second.terminalHandle] + const stray = addresses.map((address) => strayMail(run.id, address)) + + db.bindRun({ + runId: run.id, + coordinatorHandle: second.terminalHandle, + coordinatorPaneKey: second.paneKey, + coordinatorOrcaSessionId: second.orcaSessionId + }) + + for (const id of stray) { + expect(db.getMessageById(id)?.to_handle).toBe(`run:${run.id}`) + } + for (const address of addresses) { + expect(db.getRunMailboxOwnerIdsForHandle(address)).toEqual([run.id]) + } + }) + + it("reroutes a worker's mailbox address when its next Run unbinds the last", () => { + db = new OrchestrationDb(':memory:') + const worker = structuredWorker() + const bind = { + coordinatorHandle: worker.terminalHandle, + coordinatorPaneKey: worker.paneKey, + coordinatorOrcaSessionId: worker.orcaSessionId + } + const last = db.createRun({ objective: 'last', ...bind }) + const stray = [strayMail(last.id, worker.terminalHandle)] + + db.createRun({ objective: 'next', ...bind }) + + for (const id of stray) { + expect(db.getMessageById(id)?.to_handle).toBe(`run:${last.id}`) + } + }) +}) + +describe('Dispatch Orca session ids recorded by every writer', () => { + let db: OrchestrationDb + + afterEach(() => { + db?.close() + }) + + it('records the assignee Orca session id from a structured incarnation and a creator one', () => { + db = new OrchestrationDb(':memory:') + const run = db.createRun({ + objective: 'r', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorOrcaSessionId: CHAT_X_ID + }) + const handle = mintStructuredWorkerHandle() + const assigned = db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'structured' }).id, + assigneeHandle: handle, + assigneePaneKey: mintStructuredWorkerPaneKey(WORKER_SESSION), + processIncarnation: structuredWorkerProcessIncarnation(WORKER_SESSION), + creator: { kind: 'session', orcaSessionId: CHAT_X_ID }, + maxDepth: UNCAPPED + }) + const pty = db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'pty' }).id, + assigneeHandle: 'term_pty', + assigneePaneKey: PTY_PANE, + processIncarnation: 'pty_proc:1', + creator: { kind: 'terminal', handle: 'term_c', paneKey: OTHER_PANE }, + maxDepth: UNCAPPED + }) + + expect(assigned).toMatchObject({ + assignee_orca_session_id: WORKER_SESSION, + creator_handle: null, + creator_pane_key: null, + creator_orca_session_id: CHAT_X_ID + }) + expect(pty).toMatchObject({ assignee_orca_session_id: null, creator_orca_session_id: null }) + }) + + it('nests under the Dispatch a handle-less creator is assigned by its Orca session id', () => { + db = new OrchestrationDb(':memory:') + const run = db.createRun({ + objective: 'r', + coordinatorHandle: 'term_c', + coordinatorPaneKey: OTHER_PANE + }) + const parent = db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'parent' }).id, + assigneeHandle: mintStructuredWorkerHandle(), + assigneePaneKey: mintStructuredWorkerPaneKey(WORKER_SESSION), + processIncarnation: structuredWorkerProcessIncarnation(WORKER_SESSION), + creator: { kind: 'system' }, + maxDepth: UNCAPPED + }) + + const child = db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'child' }).id, + assigneeHandle: 'term_child', + assigneePaneKey: PTY_PANE, + processIncarnation: 'pty_proc:2', + creator: { kind: 'session', orcaSessionId: WORKER_SESSION }, + maxDepth: UNCAPPED + }) + + expect(child).toMatchObject({ creator_dispatch_id: parent.id, depth: parent.depth + 1 }) + }) + + it('records the starting creator and the attached assignee of a worker-start', () => { + db = new OrchestrationDb(':memory:') + const run = db.createRun({ + objective: 'r', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorOrcaSessionId: CHAT_X_ID + }) + const started = db.createStartingWorkerDispatch({ + creator: { kind: 'session', orcaSessionId: CHAT_X_ID }, + maxDepth: UNCAPPED, + taskSpec: 'work', + taskRunId: run.id, + startOptions: {} + }) + expect(started.dispatch).toMatchObject({ + creator_orca_session_id: CHAT_X_ID, + assignee_orca_session_id: null + }) + + const handle = mintStructuredWorkerHandle() + db.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle, + paneKey: mintStructuredWorkerPaneKey(WORKER_SESSION), + processIncarnation: structuredWorkerProcessIncarnation(WORKER_SESSION), + worktreeId: 'wt_1', + effects: [], + setupState: 'not_applicable' + }) + + expect(db.getDispatchContextById(started.dispatch.id)?.assignee_orca_session_id).toBe( + WORKER_SESSION + ) + }) +}) diff --git a/src/main/runtime/orchestration/db/runs/run-coordinator-orca-session.ts b/src/main/runtime/orchestration/db/runs/run-coordinator-orca-session.ts index 2ed9d9809a2..007e979df4d 100644 --- a/src/main/runtime/orchestration/db/runs/run-coordinator-orca-session.ts +++ b/src/main/runtime/orchestration/db/runs/run-coordinator-orca-session.ts @@ -1,4 +1,5 @@ -import { ORCA_SESSION_ADDRESS_PREFIX } from '../../../../../shared/orca-session-address' +import { orcaSessionAddressSql } from '../orca-session-address-sql' +import type { OrcaSessionId } from '../../../../../shared/orca-session-address' import type { RunRow } from '../../types' type RunCoordinatorOrcaSessionFields = Pick< @@ -13,7 +14,7 @@ type RunCoordinatorOrcaSessionFields = Pick< */ export function currentRunCoordinatorOrcaSessionId( run: RunCoordinatorOrcaSessionFields -): string | null { +): OrcaSessionId | null { return run.coordinator_orca_session_id_generation === run.consumer_generation ? run.coordinator_orca_session_id : null @@ -27,5 +28,5 @@ export function currentRunCoordinatorOrcaSessionIdSql(row: string): string { /** The coordinator's `session:` address in SQL; NULL when it has no current Orca session id. */ export function currentRunCoordinatorSessionAddressSql(row: string): string { - return `('${ORCA_SESSION_ADDRESS_PREFIX}' || ${currentRunCoordinatorOrcaSessionIdSql(row)})` + return orcaSessionAddressSql(currentRunCoordinatorOrcaSessionIdSql(row)) } diff --git a/src/main/runtime/orchestration/db/runs/run-create.ts b/src/main/runtime/orchestration/db/runs/run-create.ts index 3f99e018936..9b7eab6ab0d 100644 --- a/src/main/runtime/orchestration/db/runs/run-create.ts +++ b/src/main/runtime/orchestration/db/runs/run-create.ts @@ -1,6 +1,8 @@ import type { RunRow } from '../../types' import { generateId } from '../generated-id' import type { OrchestrationDb } from '../orchestration-db' +import type { OrcaSessionId } from '../../../../../shared/orca-session-address' +import { mailboxAddressOf } from '../../orchestration-caller-identity' // ── Runs ── @@ -8,23 +10,39 @@ export function createRun( this: OrchestrationDb, params: { objective: string - coordinatorHandle: string - coordinatorPaneKey: string + coordinatorHandle: string | null + coordinatorPaneKey: string | null + /** The coordinator's bare Orca session id when it is a structured session; see orca-session-address. */ + coordinatorOrcaSessionId?: OrcaSessionId | null } ): RunRow { + const coordinator = { + terminalHandle: params.coordinatorHandle, + paneKey: params.coordinatorPaneKey, + orcaSessionId: params.coordinatorOrcaSessionId ?? null + } const id = generateId('run') this.db.exec('BEGIN IMMEDIATE') try { - this.unbindOtherRunsForPane(params.coordinatorPaneKey) + this.unbindOtherRunsForCoordinator(coordinator) this.db .prepare( `INSERT INTO runs ( - id, objective, coordinator_handle, coordinator_pane_key, - consumer_generation, legacy - ) VALUES (?, ?, ?, ?, 1, 0)` + id, objective, coordinator_handle, coordinator_pane_key, coordinator_orca_session_id, + coordinator_orca_session_id_generation, consumer_generation, legacy + ) VALUES (?, ?, ?, ?, ?, 1, 1, 0)` ) - .run(id, params.objective, params.coordinatorHandle, params.coordinatorPaneKey) - this.rememberRunCoordinatorHandle(id, params.coordinatorHandle) + .run( + id, + params.objective, + coordinator.terminalHandle, + coordinator.paneKey, + coordinator.orcaSessionId + ) + const address = mailboxAddressOf(coordinator) + if (address !== null) { + this.rememberRunCoordinatorHandle(id, address) + } this.db.exec('COMMIT') } catch (error) { this.db.exec('ROLLBACK') diff --git a/src/main/runtime/orchestration/db/runs/run-lookup.ts b/src/main/runtime/orchestration/db/runs/run-lookup.ts index 0a60b4e7f9d..94a57c345a3 100644 --- a/src/main/runtime/orchestration/db/runs/run-lookup.ts +++ b/src/main/runtime/orchestration/db/runs/run-lookup.ts @@ -1,4 +1,10 @@ import type { RunRow } from '../../types' +import { + mailboxAddressOf, + runBoundToCoordinator, + runCoordinatorKey, + type OrchestrationCoordinatorKey +} from '../../orchestration-caller-identity' import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../../../../shared/orchestration-run-pagination' import { isEquivalentPaneKey, @@ -22,6 +28,13 @@ const RUNS_BOUND_TO_PANE_SQL = `SELECT ${RUN_COLUMN_LIST} FROM runs WHERE coordinator_pane_key IS NOT NULL AND legacy = 0 AND ${RUN_PANE_KEY_MATCH_SUFFIX_SQL} = ? ORDER BY rowid` +// Why: one statement so pane and Orca session id matches keep a single rowid order; the JS predicate decides. +const RUNS_BOUND_TO_COORDINATOR_SQL = `SELECT ${RUN_COLUMN_LIST} FROM runs + WHERE legacy = 0 AND ( + (coordinator_pane_key IS NOT NULL AND ${RUN_PANE_KEY_MATCH_SUFFIX_SQL} = ?) + OR coordinator_orca_session_id = ? + ) + ORDER BY rowid` export function getRun(this: OrchestrationDb, id: string): RunRow | undefined { const run = this.getRunRaw(id) @@ -122,15 +135,38 @@ export function getRunRaw(this: OrchestrationDb, id: string): RunRow | undefined return this.db.prepare(RUN_BY_ID_SQL).get(id) as RunRow | undefined } -export function unbindOtherRunsForPane( +export function getCurrentRunForCoordinator( this: OrchestrationDb, - paneKey: string, + caller: OrchestrationCoordinatorKey +): RunRow | undefined { + const run = this.runsBoundToCoordinator(caller)[0] + return run ? exposeRunTimestamps(run) : undefined +} + +/** Runs bound to this caller by pane or by Orca session id; a caller without one matches as before. */ +export function runsBoundToCoordinator( + this: OrchestrationDb, + caller: OrchestrationCoordinatorKey +): RunRow[] { + if (caller.orcaSessionId === null) { + return caller.paneKey === null ? [] : this.runsBoundToPane(caller.paneKey) + } + const suffix = caller.paneKey === null ? null : paneKeyMatchSuffix(caller.paneKey) + const rows = this.db.prepare(RUNS_BOUND_TO_COORDINATOR_SQL).all(suffix, caller.orcaSessionId) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT * over this table returns the row shape its schema and row type define, like every row cast in db/. + return (rows as RunRow[]).filter((run) => runBoundToCoordinator(run, caller)) +} + +export function unbindOtherRunsForCoordinator( + this: OrchestrationDb, + caller: OrchestrationCoordinatorKey, exceptRunId?: string ): void { - for (const run of this.runsBoundToPane(paneKey)) { + for (const run of this.runsBoundToCoordinator(caller)) { if (run.id !== exceptRunId) { - if (run.coordinator_handle) { - this.routeAllUnreadDirectMessagesToRunMailbox(run.id, run.coordinator_handle) + const address = mailboxAddressOf(runCoordinatorKey(run)) + if (address !== null) { + this.routeAllUnreadDirectMessagesToRunMailbox(run.id, address) } this.db .prepare( @@ -160,8 +196,10 @@ export type RunLookupMethods = { listRuns: typeof listRuns getCurrentRunForPane: typeof getCurrentRunForPane runsBoundToPane: typeof runsBoundToPane + getCurrentRunForCoordinator: typeof getCurrentRunForCoordinator + runsBoundToCoordinator: typeof runsBoundToCoordinator getRunRaw: typeof getRunRaw - unbindOtherRunsForPane: typeof unbindOtherRunsForPane + unbindOtherRunsForCoordinator: typeof unbindOtherRunsForCoordinator requireRun: typeof requireRun } @@ -173,8 +211,10 @@ export function attachRunLookup(ctor: { prototype: object }): void { listRuns, getCurrentRunForPane, runsBoundToPane, + getCurrentRunForCoordinator, + runsBoundToCoordinator, getRunRaw, - unbindOtherRunsForPane, + unbindOtherRunsForCoordinator, requireRun }) } diff --git a/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts b/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts index 57bf53e7026..273d7a09aca 100644 --- a/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts +++ b/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts @@ -96,6 +96,10 @@ describe('structured worker Orca session id backfill', () => { processIncarnation: structuredWorkerProcessIncarnation(SESSION_B), ownership: 'owned' }) + // Rows a writer without the column left; a current writer records the incarnation's Orca session id. + db.db.exec( + 'UPDATE dispatch_contexts SET assignee_orca_session_id = NULL, creator_orca_session_id = NULL' + ) backfillStructuredWorkerOrcaSessionIds(db.db) diff --git a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts index 166ccd7193e..733833c9aca 100644 --- a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts +++ b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts @@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto' import { OrchestrationError } from '../../orchestration-error' import { hashDispatchCapability } from '../dispatch-capability-hash' import type { OrchestrationDb } from '../orchestration-db' +import { dispatchAssigneeOrcaSessionId } from '../../dispatch-assignee-orca-session-id' export function prepareStartingWorkerAuthority( this: OrchestrationDb, @@ -53,8 +54,8 @@ export function prepareStartingWorkerAuthority( const contextUpdate = this.db .prepare( `UPDATE dispatch_contexts - SET assignee_handle = ?, assignee_pane_key = ?, process_incarnation = ?, - assignee_orca_session_id = NULL, host_scope = ?, + SET assignee_handle = ?, assignee_pane_key = ?, assignee_orca_session_id = ?, + process_incarnation = ?, host_scope = ?, capability_hash = ?, launch_token_hash = COALESCE(launch_token_hash, ?), capability_revoked_at = NULL, consumer_generation = consumer_generation + 1 @@ -63,6 +64,7 @@ export function prepareStartingWorkerAuthority( .run( params.handle, params.paneKey, + dispatchAssigneeOrcaSessionId(params.processIncarnation), params.processIncarnation, params.hostScope ?? null, hashDispatchCapability(capability), diff --git a/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts b/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts index c9b9b5d0afd..3d45c9a8630 100644 --- a/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts +++ b/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts @@ -1,5 +1,6 @@ import type { WorkerDispatchRow } from '../../types' import type { OrchestrationDb } from '../orchestration-db' +import { dispatchAssigneeOrcaSessionId } from '../../dispatch-assignee-orca-session-id' /** * A start that dies before `prepareStartingWorkerAuthority` never filled the Dispatch context in, @@ -21,13 +22,14 @@ export function recordFailedStartDispatchIdentity( db.db .prepare( `UPDATE dispatch_contexts - SET assignee_handle = ?, assignee_pane_key = ?, process_incarnation = ?, host_scope = ?, - assignee_orca_session_id = NULL + SET assignee_handle = ?, assignee_pane_key = ?, assignee_orca_session_id = ?, + process_incarnation = ?, host_scope = ? WHERE id = ? AND status = 'failed' AND capability_hash IS NULL` ) .run( resource.terminal_handle, resource.pane_key, + dispatchAssigneeOrcaSessionId(resource.process_incarnation), resource.process_incarnation, resource.host_scope, worker.dispatch_id diff --git a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts index e6942503dbf..fe26380d539 100644 --- a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts +++ b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts @@ -123,6 +123,20 @@ export function getWorkerTerminalResourceByHandle( .get(terminalHandle) as WorkerTerminalResourceRow | undefined } +export function getWorkerTerminalResourceByProcessIncarnation( + this: OrchestrationDb, + processIncarnation: string +): WorkerTerminalResourceRow | undefined { + const row = this.db + .prepare( + `SELECT * FROM worker_terminal_resources + WHERE process_incarnation = ? ORDER BY updated_at DESC LIMIT 1` + ) + .get(processIncarnation) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT * over this table returns the row shape its schema and row type define, like every row cast in db/. + return row as WorkerTerminalResourceRow | undefined +} + export function getWorkerTerminalResourceFormerlyOwnedBy( this: OrchestrationDb, dispatchId: string @@ -238,6 +252,7 @@ export type WorkerTerminalResourceStoreMethods = { createWorkerTerminalResourceStatement: typeof createWorkerTerminalResourceStatement getWorkerTerminalResource: typeof getWorkerTerminalResource getWorkerTerminalResourceByHandle: typeof getWorkerTerminalResourceByHandle + getWorkerTerminalResourceByProcessIncarnation: typeof getWorkerTerminalResourceByProcessIncarnation getWorkerTerminalResourceByOwner: typeof getWorkerTerminalResourceByOwner getWorkerTerminalResourceFormerlyOwnedBy: typeof getWorkerTerminalResourceFormerlyOwnedBy recordWorkerTerminalRecoveryAttempt: typeof recordWorkerTerminalRecoveryAttempt @@ -251,6 +266,7 @@ export function attachWorkerTerminalResourceStore(ctor: { prototype: object }): createWorkerTerminalResourceStatement, getWorkerTerminalResource, getWorkerTerminalResourceByHandle, + getWorkerTerminalResourceByProcessIncarnation, getWorkerTerminalResourceByOwner, getWorkerTerminalResourceFormerlyOwnedBy, recordWorkerTerminalRecoveryAttempt, diff --git a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-transfer.ts b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-transfer.ts index 439aca59b01..064ae577e0f 100644 --- a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-transfer.ts +++ b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-transfer.ts @@ -81,14 +81,30 @@ export function workerTerminalResourceHasIdentityConflict( ) } +/** Every resource whose process incarnation starts with `prefix`, newest first. */ +export function listWorkerTerminalResourcesByIncarnationPrefix( + this: OrchestrationDb, + prefix: string +): WorkerTerminalResourceRow[] { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT * over this table is exactly its row shape. + return this.db + .prepare( + `SELECT * FROM worker_terminal_resources + WHERE substr(process_incarnation, 1, ?) = ? ORDER BY updated_at DESC` + ) + .all(prefix.length, prefix) as WorkerTerminalResourceRow[] +} + export type WorkerTerminalTransferMethods = { findTransferableWorkerTerminalResource: typeof findTransferableWorkerTerminalResource workerTerminalResourceHasIdentityConflict: typeof workerTerminalResourceHasIdentityConflict + listWorkerTerminalResourcesByIncarnationPrefix: typeof listWorkerTerminalResourcesByIncarnationPrefix } export function attachWorkerTerminalTransfer(ctor: { prototype: object }): void { Object.assign(ctor.prototype, { findTransferableWorkerTerminalResource, - workerTerminalResourceHasIdentityConflict + workerTerminalResourceHasIdentityConflict, + listWorkerTerminalResourcesByIncarnationPrefix }) } diff --git a/src/main/runtime/orchestration/dispatch-assignee-orca-session-id.ts b/src/main/runtime/orchestration/dispatch-assignee-orca-session-id.ts new file mode 100644 index 00000000000..52c75ece732 --- /dev/null +++ b/src/main/runtime/orchestration/dispatch-assignee-orca-session-id.ts @@ -0,0 +1,11 @@ +import type { OrcaSessionId } from '../../../shared/orca-session-address' +import { structuredWorkerOrcaSessionIdForIncarnation } from '../structured-worker-identity' +import { canonicalOrcaSessionId } from './canonical-orca-session-id' + +/** The Orca session id a Dispatch row stores for the structured worker a process incarnation names. */ +export function dispatchAssigneeOrcaSessionId( + processIncarnation: string | null | undefined +): OrcaSessionId | null { + const orcaSessionId = structuredWorkerOrcaSessionIdForIncarnation(processIncarnation) + return orcaSessionId === null ? null : canonicalOrcaSessionId(orcaSessionId) +} diff --git a/src/main/runtime/orchestration/mailbox-pointer-pty-write.ts b/src/main/runtime/orchestration/mailbox-pointer-pty-write.ts index 8dbedc9b33c..52016aa1465 100644 --- a/src/main/runtime/orchestration/mailbox-pointer-pty-write.ts +++ b/src/main/runtime/orchestration/mailbox-pointer-pty-write.ts @@ -24,7 +24,8 @@ export function writeOrchestrationPointerWithSettlement( return writeRefused('provider_cannot_settle') } try { - return settledWrite.call(args.controller, args.ptyId, args.data) + // Why driving: a pointer is input that tells a running agent to read its mail. + return settledWrite.call(args.controller, args.ptyId, args.data, 'driving') } catch { // A partial write that then threw cannot prove the transport took nothing. return writeUnverifiable('provider_threw_after_handoff', true) diff --git a/src/main/runtime/orchestration/orchestration-caller-identity.ts b/src/main/runtime/orchestration/orchestration-caller-identity.ts new file mode 100644 index 00000000000..cce3cecd548 --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-caller-identity.ts @@ -0,0 +1,76 @@ +import type { RunRow } from './types' +import { isEquivalentPaneKey } from './db/pane-key-match' +import { currentRunCoordinatorOrcaSessionId } from './db/runs/run-coordinator-orca-session' +import { formatOrcaSessionAddress, type OrcaSessionId } from '../../../shared/orca-session-address' + +/** + * Who an orchestration caller is, as Run binding and mail routing match it. + * + * A PTY agent is its terminal: a handle and a pane key, no Orca session id. An agent that is a + * structured session is its Orca session id, addressed as `session:`; a structured worker also + * has the handle and pane key it was minted, and an ordinary chat has neither. Methods pass this + * through whole and never branch on which fields are set; the lookups below own that. + */ +export type OrchestrationCallerIdentity = Readonly<{ + /** Mailbox address the caller sends from and reads: its terminal handle, else its session address. */ + address: string + terminalHandle: string | null + paneKey: string | null + /** The bare Orca session id the caller is addressed by; mail spells it `session:`. */ + orcaSessionId: OrcaSessionId | null +}> + +/** The part of a caller a Run binding stores and matches. */ +export type OrchestrationCoordinatorKey = Pick< + OrchestrationCallerIdentity, + 'terminalHandle' | 'paneKey' | 'orcaSessionId' +> + +/** A caller the dispatch entry resolved from the Orca session id in its injected environment. */ +export type OrchestrationSessionCaller = OrchestrationCallerIdentity & + Readonly<{ + orcaSessionId: OrcaSessionId + /** The session record the request came from. */ + sessionId: OrcaSessionId + /** Where the session runs, from its record; `worker-start --worktree current` places here. */ + workspaceId: string + }> + +/** A caller with neither a pane nor an Orca session id can never be bound to a Run. */ +export function hasRunBindingKey(caller: OrchestrationCoordinatorKey): boolean { + return caller.paneKey !== null || caller.orcaSessionId !== null +} + +/** The one address a party reads mail at and is sent mail at; null for a key naming nobody. */ +export function mailboxAddressOf( + party: Pick +): string | null { + // Handle first because a worker's mail and Dispatch rows are keyed by it today; flips when sessions become canonical. + if (party.terminalHandle !== null) { + return party.terminalHandle + } + return party.orcaSessionId === null ? null : formatOrcaSessionAddress(party.orcaSessionId) +} + +/** Who a Run's binding names now; an Orca session id an older binding left behind is not part of it. */ +export function runCoordinatorKey(run: RunRow): OrchestrationCoordinatorKey { + return { + terminalHandle: run.coordinator_handle, + paneKey: run.coordinator_pane_key, + orcaSessionId: currentRunCoordinatorOrcaSessionId(run) + } +} + +export function runBoundToCoordinator(run: RunRow, caller: OrchestrationCoordinatorKey): boolean { + if ( + caller.paneKey !== null && + run.coordinator_pane_key !== null && + isEquivalentPaneKey(run.coordinator_pane_key, caller.paneKey) + ) { + return true + } + return ( + caller.orcaSessionId !== null && + currentRunCoordinatorOrcaSessionId(run) === caller.orcaSessionId + ) +} diff --git a/src/main/runtime/orchestration/orchestration-orca-session-column-migration.test.ts b/src/main/runtime/orchestration/orchestration-orca-session-column-migration.test.ts index 69186334828..b721dcc166e 100644 --- a/src/main/runtime/orchestration/orchestration-orca-session-column-migration.test.ts +++ b/src/main/runtime/orchestration/orchestration-orca-session-column-migration.test.ts @@ -11,6 +11,7 @@ import { import { OrchestrationDb } from './db' import { SCHEMA_VERSION } from './db/contract-constants' import { formatOrcaSessionAddress } from '../../../shared/orca-session-address' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' import { RUN_PANE_KEY_MATCH_SUFFIX_SQL } from './db/pane-key-match' import { currentRunCoordinatorOrcaSessionId, @@ -18,8 +19,8 @@ import { } from './db/runs/run-coordinator-orca-session' import { resolveOrchestrationMigrationStartVersion } from './orchestration-schema-version-skew' -const SESSION_ID = '5f0c1d9e-2b7a-4c3e-8f61-0a9d2e7b4c11' -const CHAT_SESSION_ID = '9a4e7c1b-3d2f-4b6a-8e5c-7f1d0b2a6c93' +const SESSION_ID = testOrcaSessionId('5f0c1d9e-2b7a-4c3e-8f61-0a9d2e7b4c11') +const CHAT_SESSION_ID = testOrcaSessionId('9a4e7c1b-3d2f-4b6a-8e5c-7f1d0b2a6c93') const CHAT_SESSION_ADDRESS = formatOrcaSessionAddress(CHAT_SESSION_ID) const ORCA_SESSION_ID_COLUMNS = [ 'assignee_orca_session_id', @@ -49,6 +50,25 @@ const HANDLE_ONLY_COORDINATOR_TRIGGERS_SQL = ` VALUES (NEW.id, NEW.coordinator_handle); END;` +// The mail routing trigger as main recreated it on every open at v41 and before: handle-only. +const HANDLE_ONLY_MAIL_ROUTING_TRIGGER_SQL = ` + CREATE TRIGGER trg_messages_route_coordinator_mail + AFTER INSERT ON messages + WHEN NEW.read = 0 AND NEW.delivery_contract = 'current_delivery' + AND EXISTS (SELECT 1 FROM runs WHERE runs.id = NEW.run_id AND runs.legacy = 0) + AND EXISTS ( + SELECT 1 FROM run_coordinator_handles + WHERE run_id = NEW.run_id AND terminal_handle = NEW.to_handle + ) + AND NOT EXISTS ( + SELECT 1 FROM dispatch_contexts + WHERE run_id = NEW.run_id AND assignee_handle = NEW.to_handle + AND status IN ('pending', 'dispatched') + ) + BEGIN + UPDATE messages SET to_handle = 'run:' || NEW.run_id WHERE sequence = NEW.sequence; + END;` + const V41_RUN_COLUMNS = 'id, objective, home_database, coordinator_handle, coordinator_pane_key, consumer_generation, legacy, created_at, updated_at' @@ -117,11 +137,13 @@ function stripOrcaSessionSchema(path: string, version: number): void { DROP INDEX idx_dispatch_assignee_orca_session_id; DROP TRIGGER trg_runs_remember_coordinator_insert; DROP TRIGGER trg_runs_remember_coordinator_update; + DROP TRIGGER trg_messages_route_coordinator_mail; ALTER TABLE runs DROP COLUMN coordinator_orca_session_id; ALTER TABLE runs DROP COLUMN coordinator_orca_session_id_generation; ALTER TABLE dispatch_contexts DROP COLUMN assignee_orca_session_id; ALTER TABLE dispatch_contexts DROP COLUMN creator_orca_session_id; ${HANDLE_ONLY_COORDINATOR_TRIGGERS_SQL} + ${HANDLE_ONLY_MAIL_ROUTING_TRIGGER_SQL} `) raw.pragma(`user_version = ${version}`) raw.close() @@ -419,8 +441,11 @@ describe('orchestration Orca session id column migration', () => { it('fills structured-worker rows written after the stamp reached v42 on the next open', () => { const path = tempDbPath() const first = new OrchestrationDb(path) - // No writer records an Orca session id yet, which is also the shape a binary rolled back past v42 writes. const rows = seedStructuredAndPtyRows(first) + // The shape a binary rolled back past v42 writes: its INSERTs name no Orca session id column. + first.db.exec( + 'UPDATE dispatch_contexts SET assignee_orca_session_id = NULL, creator_orca_session_id = NULL; UPDATE runs SET coordinator_orca_session_id = NULL' + ) expect( first.getDispatchContextById(rows.structuredDispatchId)?.assignee_orca_session_id ).toBeNull() diff --git a/src/main/runtime/orchestration/orchestration-party-cli-address.test.ts b/src/main/runtime/orchestration/orchestration-party-cli-address.test.ts new file mode 100644 index 00000000000..f6f1c1e2574 --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-party-cli-address.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { injectedSessionAddress } from '../../../shared/agent-session-caller-env' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerIdentities, + structuredWorkerProcessIncarnation +} from '../structured-worker-identity' +import { resolveOrcaSessionParty } from './orchestration-party' + +// The CLI spells its own address without the host resolver (it runs before the codec's module +// graph), so it must land on the one mailbox address the resolver gives the same session. +const CHAT = testOrcaSessionId('f7a1c0de-1111-4222-8333-444455556666') +const WORKER = testOrcaSessionId('a0b1c2d3-0000-4000-8000-00000000abcd') + +afterEach(() => { + structuredWorkerIdentities.clear() +}) + +describe("the CLI's own address", () => { + it("is a chat's session address, even beside a pane handle it inherited", () => { + const hostAddress = resolveOrcaSessionParty(CHAT, null).address + expect(injectedSessionAddress({ ORCA_AGENT_SESSION_ID: CHAT })).toBe(hostAddress) + expect( + injectedSessionAddress({ + ORCA_AGENT_SESSION_ID: CHAT, + ORCA_TERMINAL_HANDLE: 'term_inherited' + }) + ).toBe(hostAddress) + }) + + it("is a structured worker's minted handle, as the host resolves it", () => { + const handle = mintStructuredWorkerHandle() + structuredWorkerIdentities.register({ + handle, + sessionId: WORKER, + agent: 'claude', + paneKey: mintStructuredWorkerPaneKey(WORKER), + processIncarnation: structuredWorkerProcessIncarnation(WORKER), + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } + }) + const hostAddress = resolveOrcaSessionParty(WORKER, null).address + expect(hostAddress).toBe(handle) + expect( + injectedSessionAddress({ ORCA_AGENT_SESSION_ID: WORKER, ORCA_TERMINAL_HANDLE: handle }) + ).toBe(hostAddress) + }) +}) diff --git a/src/main/runtime/orchestration/orchestration-party.ts b/src/main/runtime/orchestration/orchestration-party.ts new file mode 100644 index 00000000000..65b6d7a5c5a --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-party.ts @@ -0,0 +1,108 @@ +// The one place an orchestration address becomes a party, so no two sites can disagree on who it names. +import { + formatOrcaSessionAddress, + isOrcaSessionId, + parseOrcaSessionAddress, + type OrcaSessionId +} from '../../../shared/orca-session-address' +import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration-session-caller-codes' +import { + isRecordedStructuredWorkerSession, + resolveStructuredWorkerIdentity, + resolveStructuredWorkerIdentityForSession +} from '../structured-worker-authority' +import { canonicalOrcaSessionId } from './canonical-orca-session-id' +import type { OrchestrationDb } from './db' +import { mailboxAddressOf, type OrchestrationCallerIdentity } from './orchestration-caller-identity' +import { OrchestrationError } from './orchestration-error' + +/** A party as Run binding and mail routing match it; `address` is its one mailbox address. */ +export type OrchestrationParty = OrchestrationCallerIdentity + +/** A party named by an Orca session id: a structured worker, or a chat. */ +export type OrchestrationSessionParty = OrchestrationParty & + Readonly<{ orcaSessionId: OrcaSessionId }> + +const NO_EFFECTS = { effectsApplied: false } as const + +/** Every param naming a party other than the caller; a new one is added here with its own test. */ +export const ORCHESTRATION_TARGET_PARAM: Readonly> = { + 'orchestration.send': 'to', + 'orchestration.ask': 'to', + 'orchestration.dispatch': 'to', + 'orchestration.inbox': 'terminal' +} + +/** The party an Orca session id names. Throws when it is a worker this host lost the identity of. */ +export function resolveOrcaSessionParty( + orcaSessionId: OrcaSessionId, + db: OrchestrationDb | null | undefined +): OrchestrationSessionParty { + const id = canonicalOrcaSessionId(orcaSessionId) + const worker = resolveStructuredWorkerIdentityForSession(id, db) + if (!worker && db && isRecordedStructuredWorkerSession(id, db)) { + // Why: handle-less, it would split one worker into two parties and bind like a chat. + throw new OrchestrationError( + CODES.notLive, + `Agent session ${id} is a structured worker whose worker identity this host no longer has, so orchestration cannot address it. No effects were applied.`, + NO_EFFECTS + ) + } + const key = { terminalHandle: worker?.handle ?? null, orcaSessionId: id } + return { + ...key, + address: mailboxAddressOf(key) ?? formatOrcaSessionAddress(id), + paneKey: worker?.paneKey ?? null + } +} + +/** The party an address names; a PTY handle is itself and costs no lookup. */ +export function resolveOrchestrationParty( + address: string, + db: OrchestrationDb | null | undefined +): OrchestrationParty { + const orcaSessionId = parseOrcaSessionAddress(address) + if (orcaSessionId) { + return resolveOrcaSessionParty(orcaSessionId, db) + } + const worker = resolveStructuredWorkerIdentity(address, db) + return { + address, + terminalHandle: address, + paneKey: worker?.paneKey ?? null, + orcaSessionId: + worker && isOrcaSessionId(worker.sessionId) ? canonicalOrcaSessionId(worker.sessionId) : null + } +} + +/** A caller named by a param: naming a chat's address proves nothing, unlike its own session id. */ +export function resolveDeclaredCallerParty( + address: string, + db: OrchestrationDb | null | undefined +): OrchestrationParty { + const party = resolveOrchestrationParty(address, db) + if (party.terminalHandle === null) { + throw new OrchestrationError( + CODES.chatNotDeclarable, + `Agent session ${party.orcaSessionId} is a chat, and a chat is identified only by the session id its own environment sends, never by naming its address. No effects were applied.`, + NO_EFFECTS + ) + } + return party +} + +/** A Dispatch assignee: a terminal or a structured worker, never a chat yet. */ +export function resolveDispatchAssigneeParty( + address: string, + db: OrchestrationDb | null | undefined +): OrchestrationParty { + const party = resolveOrchestrationParty(address, db) + if (party.terminalHandle === null) { + throw new OrchestrationError( + CODES.chatNotDispatchable, + `Agent session ${party.orcaSessionId} is a chat, and a chat can't receive a dispatch yet. Start a worker with worker-start instead. No effects were applied.`, + NO_EFFECTS + ) + } + return party +} diff --git a/src/main/runtime/orchestration/preamble.ts b/src/main/runtime/orchestration/preamble.ts index 208868ce45b..a9fbb528939 100644 --- a/src/main/runtime/orchestration/preamble.ts +++ b/src/main/runtime/orchestration/preamble.ts @@ -147,12 +147,13 @@ ${params.taskSpec}` export type DispatchPreambleSendOptions = Pick< RuntimeAgentPromptWriteOptions, - 'leadLine' | 'acceptQueued' | 'observationTimeoutMs' | 'requestId' + 'leadLine' | 'acceptQueued' | 'observationTimeoutMs' | 'requestId' | 'inputKind' > export function dispatchPreambleSendOptions(requestId: string): DispatchPreambleSendOptions { // Why: a delayed provider hook must not revoke an accepted Dispatch. return { + inputKind: 'driving', leadLine: ORCA_DISPATCH_PROMPT_LEAD_LINE, acceptQueued: true, observationTimeoutMs: 0, diff --git a/src/main/runtime/orchestration/run-coordinator-orca-session-address.test.ts b/src/main/runtime/orchestration/run-coordinator-orca-session-address.test.ts index f93e1e4a4b5..4dd07052d6f 100644 --- a/src/main/runtime/orchestration/run-coordinator-orca-session-address.test.ts +++ b/src/main/runtime/orchestration/run-coordinator-orca-session-address.test.ts @@ -6,6 +6,7 @@ import { formatOrcaSessionAddress, parseOrcaSessionAddress } from '../../../shared/orca-session-address' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' import { mintStructuredWorkerHandle, mintStructuredWorkerPaneKey, @@ -14,9 +15,9 @@ import { import { OrchestrationDb } from './db' import { backfillStructuredWorkerOrcaSessionIds } from './db/schema/structured-worker-orca-session-backfill' -const CHAT_SESSION_ID = '3a5c7e9b-1d4f-4a6c-8b0e-2f4a6c8e0b14' +const CHAT_SESSION_ID = testOrcaSessionId('3a5c7e9b-1d4f-4a6c-8b0e-2f4a6c8e0b14') const CHAT_ADDRESS = formatOrcaSessionAddress(CHAT_SESSION_ID) -const WORKER_SESSION_ID = '4b6d8f0c-2e5a-4b7d-9c1f-3a5b7d9f1c25' +const WORKER_SESSION_ID = testOrcaSessionId('4b6d8f0c-2e5a-4b7d-9c1f-3a5b7d9f1c25') const WORKER_ADDRESS = formatOrcaSessionAddress(WORKER_SESSION_ID) const PTY_PANE = 'tab_pty:66666666-6666-4666-8666-666666666666' diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts index 048fc7732e4..9b40bc08b0d 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts @@ -4,6 +4,11 @@ import { OrchestrationStructuredMailboxPointerDelivery, type StructuredMailboxPointerHost } from './structured-mailbox-pointer-delivery' +import type { StructuredPointerOperationRow } from './db/messages/structured-pointer-operation-store' +import { + structuredPointerBatchFingerprint, + type StructuredPointerSubmission +} from './structured-pointer-operation-id' import { structuredSessionGateFacts } from './structured-session-pointer-delivery' import type { StructuredWorkerIdentity } from '../structured-worker-identity' @@ -84,13 +89,15 @@ function harness(options: { const mailbox = options.mailbox ?? 'dispatch:d1' const dispatchId = options.dispatchId === undefined ? 'd1' : options.dispatchId let journal = options.journal + // The session's recorded sends, as its journal reports them. + let submissions: StructuredPointerSubmission[] = [] const markAsDelivered = vi.fn() const send: StructuredMailboxPointerHost['send'] = vi.fn(async () => ({ kind: 'sent' as const, state: options.dispatchState ?? ('accepted' as const) })) const sendMock = vi.mocked(send) - const stored = new Map() + const stored = new Map() const db = { getDispatchContextById: () => ({ run_id: 'run_1' }), hasOutstandingMailboxDelivery: (handle: string) => @@ -99,7 +106,7 @@ function harness(options: { getUndeliveredUnreadMessages: () => [{ id: 'm1', type: 'status', sequence: 3 }], markAsDelivered, getStructuredPointerOperation: (key: string) => stored.get(key), - putStructuredPointerOperation: (row: { mailbox_handle: string }) => + putStructuredPointerOperation: (row: StructuredPointerOperationRow) => stored.set(row.mailbox_handle, row), deleteStructuredPointerOperation: (key: string) => stored.delete(key) } @@ -108,8 +115,10 @@ function harness(options: { getMessageWaiters: () => undefined, resolveStructuredTarget: (mailboxHandle) => mailboxHandle === mailbox ? { sessionId: IDENTITY.sessionId, dispatchId } : null, + getCliCommand: () => 'orca-dev', host: { - readGateFacts: () => (journal === null ? null : structuredSessionGateFacts(journal)), + readGateFacts: async () => + journal === null ? null : { ...structuredSessionGateFacts(journal), submissions }, currentFence: () => 4, send } @@ -121,6 +130,9 @@ function harness(options: { stored, setJournal: (next: AgentJournalRenderItem[] | null) => { journal = next + }, + setSubmissions: (next: StructuredPointerSubmission[]) => { + submissions = next } } } @@ -261,9 +273,10 @@ describe('structured mailbox pointer delivery', () => { expect(send).not.toHaveBeenCalled() }) - it('retries a rejected nudge on the next journal edge', async () => { + it('retries a rejected nudge on the next journal edge, under the same id', async () => { // A rejection consumes no mail and nothing else redrives this mailbox, so leaving it unparked - // stranded the worker until unrelated mail happened to arrive. + // stranded the worker until unrelated mail happened to arrive. The retry keeps the id: the host + // replays a recorded refusal rather than starting the agent again. const { delivery, send, markAsDelivered } = harness({ journal: idleJournal(), dispatchState: 'rejected' @@ -276,9 +289,143 @@ describe('structured mailbox pointer delivery', () => { delivery.onJournalActivity('session-1') await flush() expect(send).toHaveBeenCalledTimes(2) + expect(send.mock.calls[1]![0].operationId).toBe(first) + }) + + it('points again under a new id once a later send ran', async () => { + const { delivery, send, setSubmissions } = harness({ + journal: idleJournal(), + dispatchState: 'unknown' + }) + delivery.deliverForHandle('dispatch:d1') + await flush() + const first = send.mock.calls[0]![0].operationId + setSubmissions([ + { clientMessageId: first, dispatchState: 'unknown', submittedAt: Date.now() }, + { clientMessageId: 'user-turn', dispatchState: 'accepted', submittedAt: Date.now() + 1 } + ]) + delivery.onJournalActivity('session-1') + await flush() + expect(send).toHaveBeenCalledTimes(2) expect(send.mock.calls[1]![0].operationId).not.toBe(first) }) + it('points once more under a new id for a send an earlier process left in doubt', async () => { + const { delivery, send, stored, setSubmissions } = harness({ + journal: idleJournal(), + dispatchState: 'unknown' + }) + stored.set('dispatch:d1', { + mailbox_handle: 'dispatch:d1', + session_id: 'session-1', + operation_id: 'earlier-process-op', + batch_fingerprint: structuredPointerBatchFingerprint('session-1', ['m1']), + minted_at_ms: 0 + }) + setSubmissions([ + { clientMessageId: 'earlier-process-op', dispatchState: 'unknown', submittedAt: Date.now() } + ]) + delivery.deliverForHandle('dispatch:d1') + await flush() + expect(send).toHaveBeenCalledTimes(1) + const reminted = send.mock.calls[0]![0].operationId + expect(reminted).not.toBe('earlier-process-op') + // Minted by this process, the new id replays from here on. + setSubmissions([ + { clientMessageId: 'earlier-process-op', dispatchState: 'unknown', submittedAt: Date.now() }, + { clientMessageId: reminted, dispatchState: 'unknown', submittedAt: Date.now() } + ]) + delivery.onJournalActivity('session-1') + await flush() + expect(send.mock.calls[1]![0].operationId).toBe(reminted) + }) + + it('keeps replaying its own send across a clock step, and re-mints only for a rewind that ran a turn', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + try { + const { delivery, send, setSubmissions } = harness({ + journal: idleJournal(), + dispatchState: 'unknown' + }) + // The wall clock steps back an hour after the lane started: its own row is still its own. + vi.setSystemTime(Date.now() - 60 * 60 * 1000) + delivery.deliverForHandle('dispatch:d1') + await flush() + const first = send.mock.calls[0]![0].operationId + setSubmissions([ + { clientMessageId: first, dispatchState: 'unknown', submittedAt: Date.now() } + ]) + delivery.onJournalActivity('session-1') + await flush() + expect(send.mock.calls[1]![0].operationId).toBe(first) + // A rewind dropped that send from the journal, and the person's turn ran after it. + setSubmissions([ + { clientMessageId: 'user-turn', dispatchState: 'accepted', submittedAt: Date.now() + 1 } + ]) + delivery.onJournalActivity('session-1') + await flush() + expect(send.mock.calls[2]![0].operationId).not.toBe(first) + } finally { + vi.useRealTimers() + } + }) + + it('does not read a turn from before a backward clock step as one that ran after its pointer', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + try { + const { delivery, send, setSubmissions } = harness({ + journal: idleJournal(), + dispatchState: 'unknown' + }) + const personTurn = { + clientMessageId: 'user-turn', + dispatchState: 'accepted' as const, + submittedAt: Date.now() + } + setSubmissions([personTurn]) + vi.setSystemTime(Date.now() - 2 * 60 * 1000) + delivery.deliverForHandle('dispatch:d1') + await flush() + const first = send.mock.calls[0]![0].operationId + setSubmissions([ + personTurn, + { clientMessageId: first, dispatchState: 'unknown', submittedAt: Date.now() } + ]) + for (let edge = 0; edge < 3; edge++) { + delivery.onJournalActivity('session-1') + await flush() + } + expect(send.mock.calls.map(([input]) => input.operationId)).toEqual([ + first, + first, + first, + first + ]) + } finally { + vi.useRealTimers() + } + }) + + it('stamps a pointer whose echo arrived after the lane stopped waiting, sending nothing more', async () => { + const { delivery, send, markAsDelivered, stored, setSubmissions } = harness({ + journal: idleJournal(), + dispatchState: 'unknown' + }) + delivery.deliverForHandle('dispatch:d1') + await flush() + const first = send.mock.calls[0]![0].operationId + setSubmissions([{ clientMessageId: first, dispatchState: 'pending', submittedAt: Date.now() }]) + delivery.onJournalActivity('session-1') + await flush() + expect(send).toHaveBeenCalledTimes(1) + setSubmissions([{ clientMessageId: first, dispatchState: 'accepted', submittedAt: Date.now() }]) + delivery.onJournalActivity('session-1') + await flush() + expect(send).toHaveBeenCalledTimes(1) + expect(markAsDelivered).toHaveBeenCalledWith(['m1']) + expect(stored.has('dispatch:d1')).toBe(false) + }) + it('reuses one operation id for the same batch and re-mints when it grows', async () => { const { delivery, send, stored } = harness({ journal: idleJournal(), @@ -328,8 +475,9 @@ describe('forgetting one settled worker', () => { ? { sessionId, dispatchId: mailboxHandle.slice('dispatch:'.length) } : null }, + getCliCommand: () => 'orca', host: { - readGateFacts: () => structuredSessionGateFacts(journal), + readGateFacts: async () => ({ ...structuredSessionGateFacts(journal), submissions: [] }), currentFence: () => 4, send } diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts index 62a555d4119..2a5e75a4ef8 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts @@ -9,17 +9,21 @@ * * Coordinators are in scope here, unlike the PTY lane's reasoning: a PTY coordinator blocks in * `check --wait`, where a waiter preempts pointer delivery, but a structured coordinator is a chat - * session whose turn ends — so nothing else would ever wake it for its own `run:` mail. + * session whose turn ends — so nothing else would ever prompt it for its own `run:` mail. */ import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { OrchestrationDb } from './db' import { formatMessagePointer } from './formatter' +import type { OrchestrationCliCommand } from './cli-command' import { selectOrchestrationPointerBatch, type OrchestrationMessageWaiter } from './mailbox-pointer-eligibility' -import { resolveStructuredPointerOperation } from './structured-pointer-operation-id' +import { + resolveStructuredPointerOperation, + type StructuredPointerSubmission +} from './structured-pointer-operation-id' import { decideStructuredSessionPointerDelivery, retainReasonForDispatch, @@ -48,9 +52,14 @@ export type StructuredPointerSendOutcome = | { kind: 'sent'; state: StructuredDispatchState } | { kind: 'unattached' } +export type StructuredPointerGateFacts = StructuredSessionGateFacts & { + /** Every send the session recorded, oldest first: what the lane's own sends settled as. */ + submissions: readonly StructuredPointerSubmission[] +} + export type StructuredMailboxPointerHost = { - /** The idle gate, read off the session's full reduced timeline; `null` when it is not attached. */ - readGateFacts: (sessionId: string) => StructuredSessionGateFacts | null + /** The idle gate, read off the session's full reduced timeline; `null` when it cannot be read. */ + readGateFacts: (sessionId: string) => Promise send: (input: { sessionId: string dispatchId: string | null @@ -73,6 +82,8 @@ type StructuredPointerDeliveryDependencies StructuredPointerTarget | null + /** The CLI name the PTY lane types for a local agent, so both lanes send the same pointer. */ + getCliCommand: () => OrchestrationCliCommand host: StructuredMailboxPointerHost onRetain?: (input: { mailboxHandle: string @@ -95,6 +106,9 @@ export class OrchestrationStructuredMailboxPointerDelivery< * edge until the next explicit check. */ private readonly parkedUntilJournalEdge = new Map() + /** The operation id this lane last sent per mailbox: a row holding any other id outlived the + * process that minted it. A fact, not a clock reading, so no clock step can fake it. */ + private readonly sentOperationIds = new Map() constructor(private readonly deps: StructuredPointerDeliveryDependencies) {} @@ -176,6 +190,7 @@ export class OrchestrationStructuredMailboxPointerDelivery< } } + // A session whose agent is not running needs nothing first: an accepted send starts it. private async attempt( db: OrchestrationDb, mailboxHandle: string, @@ -184,7 +199,7 @@ export class OrchestrationStructuredMailboxPointerDelivery< reservedTypes: ReadonlySet | undefined ): Promise { const sessionId = target.sessionId - const session = this.deps.host.readGateFacts(sessionId) + const session = await this.deps.host.readGateFacts(sessionId) const decision = decideStructuredSessionPointerDelivery({ session }) if (!decision.deliver) { this.retain(mailboxHandle, sessionId, decision.retain, reservedTypes) @@ -198,7 +213,12 @@ export class OrchestrationStructuredMailboxPointerDelivery< const body: AgentJournalMessageItem = { kind: 'message', role: 'user', - blocks: [{ type: 'text', text: formatMessagePointer(unread.length, mailboxHandle).trim() }] + blocks: [ + { + type: 'text', + text: formatMessagePointer(unread.length, mailboxHandle, this.deps.getCliCommand()).trim() + } + ] } const staged = unread.map((message) => message.id) const operation = resolveStructuredPointerOperation({ @@ -206,8 +226,22 @@ export class OrchestrationStructuredMailboxPointerDelivery< mailboxHandle, sessionId, body, - messageIds: staged + messageIds: staged, + submissions: session?.submissions ?? [], + sentByThisProcess: this.sentOperationIds.get(mailboxHandle) }) + if (operation.kind === 'stamp') { + // A send this lane gave up waiting on ran after all. + db.markAsDelivered(staged) + db.deleteStructuredPointerOperation(mailboxHandle) + this.sentOperationIds.delete(mailboxHandle) + return + } + if (operation.kind === 'park') { + this.retain(mailboxHandle, sessionId, 'turn-unsettled', reservedTypes) + return + } + this.sentOperationIds.set(mailboxHandle, operation.operationId) const outcome = await this.deps.host.send({ sessionId, dispatchId: target.dispatchId, @@ -221,21 +255,15 @@ export class OrchestrationStructuredMailboxPointerDelivery< return } if (!structuredDispatchDelivered(outcome.state)) { - if (outcome.state === 'rejected') { - db.deleteStructuredPointerOperation(mailboxHandle) - } - this.retain( - mailboxHandle, - sessionId, - retainReasonForDispatch(outcome.state as Exclude), - reservedTypes - ) + // The row stays: resending under its id replays this verdict and starts nothing. + this.retain(mailboxHandle, sessionId, retainReasonForDispatch(outcome.state), reservedTypes) return } db.markAsDelivered(staged) // The nudge landed as its own turn, so the next settle edge is the natural retry point for // anything that arrives while it runs. db.deleteStructuredPointerOperation(mailboxHandle) + this.sentOperationIds.delete(mailboxHandle) } /** diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts index 91bf1158e07..fee8909a445 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts @@ -38,30 +38,33 @@ describe('structured mailbox pointer host', () => { hostRef.current = null }) - it('reads the gate facts from the FULL timeline, never a bounded tail', () => { + it('reads the gate facts from the FULL timeline, never a bounded tail', async () => { // The defect this pins: a running turn is announced by ONE lifecycle item, and settlement // tombstones it rather than rewriting it. A long tool-calling turn pushes that item arbitrarily // far from the tail, so any page-sized read reports a busy worker as idle — and the pointer is - // then delivered mid-turn, which Codex coalesces into the running turn and Claude queues behind + // then delivered mid-turn, which Codex coalesces into the running turn and Claude folds into // it -- either way folded into work already in flight rather than read as a new instruction. const items = [runningTurn(), ...transcript(500)] - hostRef.current = { journalSnapshot: () => ({ items }) } - expect(createStructuredMailboxPointerHost().readGateFacts('s1')).toEqual({ + const submissions = [{ clientMessageId: 'op1', dispatchState: 'unknown' }] + hostRef.current = { journalSnapshot: () => ({ items, submissions }) } + // The recorded sends ride along: the lane reads what its own operation id settled as. + expect(await createStructuredMailboxPointerHost().readGateFacts('s1')).toEqual({ turnRunning: true, - awaitingHuman: false + awaitingHuman: false, + submissions }) }) - it('answers null rather than idle when the session cannot be read', () => { + it('answers null rather than idle when the session cannot be read', async () => { // Null retains the pointer; `{turnRunning:false}` would deliver a nudge into a session this // runtime cannot see at all. - expect(createStructuredMailboxPointerHost().readGateFacts('s1')).toBeNull() + expect(await createStructuredMailboxPointerHost().readGateFacts('s1')).toBeNull() hostRef.current = { journalSnapshot: () => { throw new Error('agent_session_ownership_unknown') } } - expect(createStructuredMailboxPointerHost().readGateFacts('s1')).toBeNull() + expect(await createStructuredMailboxPointerHost().readGateFacts('s1')).toBeNull() }) it('reports an unattached host rather than a rejection when nothing can be sent', async () => { @@ -91,7 +94,7 @@ describe('structured mailbox pointer host', () => { value: { submission: { dispatchState } } }) ) - hostRef.current = { send } + hostRef.current = { send, waitForSendSettlement: async () => undefined } await expect( createStructuredMailboxPointerHost().send({ sessionId: 's1', @@ -107,6 +110,28 @@ describe('structured mailbox pointer host', () => { expect(send.mock.calls[0]![1]!.retryUnknown).toBeUndefined() }) + it('consumes mail once an accepted nudge is delivered while the worker starts (W10)', async () => { + hostRef.current = { + send: async () => ({ + ok: true, + value: { clientMessageId: 'op1', submission: { dispatchState: 'pending' } } + }), + waitForSendSettlement: async () => ({ + value: { clientMessageId: 'op1', submission: { dispatchState: 'accepted' } } + }) + } + await expect( + createStructuredMailboxPointerHost().send({ + sessionId: 's1', + dispatchId: 'd1', + operationId: 'op1', + expectedRuntimeFence: 1, + payloadFingerprint: 'fp', + body: { kind: 'message', role: 'user', blocks: [] } + } as never) + ).resolves.toEqual({ kind: 'sent', state: 'accepted' }) + }) + it('scopes direct peer mail to the session when there is no dispatch to scope to', async () => { // Direct mail is addressed to the worker's own handle, so there may be no dispatch at all. // The ledger is keyed on (callerKey, operationId): a key derived from the session keeps that diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts index b722952df92..ecfdff0dff5 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts @@ -6,9 +6,15 @@ * the send and reports what the host said. */ +import { ORCHESTRATION_READINESS_TIMEOUT_MS } from '../../../shared/orchestration-timing-budgets' +import { agentSessionSendSubmission } from '../../../shared/agent-session-wire' import { AGENT_SESSION_NOT_ATTACHED } from '../../native-chat/agent-session-wire/structured-agent-session-mutation-admission' import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' -import type { StructuredMailboxPointerHost } from './structured-mailbox-pointer-delivery' +import type { + StructuredMailboxPointerHost, + StructuredPointerGateFacts +} from './structured-mailbox-pointer-delivery' +import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types' import { structuredSessionGateFacts, type StructuredSessionGateFacts @@ -38,15 +44,29 @@ export function structuredSessionPointerCallerKey(sessionId: string): string { * idle-with-history is the normal steady state of a working agent. Shared so the pointer lane and * group addressing cannot disagree about it. */ -export function readStructuredSessionGateFacts( +export async function readStructuredSessionGateFacts( sessionId: string -): StructuredSessionGateFacts | null { +): Promise { + const snapshot = await readSessionJournal(sessionId) + return snapshot ? structuredSessionGateFacts(snapshot.items) : null +} + +/** The pointer lane's gate: the shared idle facts, plus what each recorded send settled as. */ +async function readPointerGateFacts(sessionId: string): Promise { + const snapshot = await readSessionJournal(sessionId) + return snapshot + ? { ...structuredSessionGateFacts(snapshot.items), submissions: snapshot.submissions } + : null +} + +async function readSessionJournal(sessionId: string): Promise { const host = getStructuredAgentSessionHost() if (!host) { return null } try { - return structuredSessionGateFacts(host.journalSnapshot(sessionId).items) + // Opens a conversation the idle sweep closed; that starts no agent. + return await host.journalSnapshot(sessionId) } catch (error) { // Not attached is a retain reason, not a failure; anything else is still unreadable. if ((error as Error)?.message !== AGENT_SESSION_NOT_ATTACHED.code) { @@ -59,7 +79,7 @@ export function readStructuredSessionGateFacts( export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHost { return { readGateFacts(sessionId) { - return readStructuredSessionGateFacts(sessionId) + return readPointerGateFacts(sessionId) }, currentFence(sessionId) { @@ -94,8 +114,22 @@ export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHo ? { kind: 'unattached' } : { kind: 'sent', state: 'rejected' } } - // `pending` is not yet an acknowledgement; only `accepted` may consume mail. - const state = result.value.submission.dispatchState + // `pending` is not yet an acknowledgement; only `accepted` may consume mail. Accepted is not + // delivered, so wait out a start; a wait that runs out parks for the next journal edge. + const answered = agentSessionSendSubmission(result.value) + const submission = + answered?.dispatchState === 'pending' + ? (agentSessionSendSubmission( + ( + await host + .waitForSendSettlement(input.sessionId, result.value.clientMessageId, { + budgetMs: ORCHESTRATION_READINESS_TIMEOUT_MS + }) + .catch(() => undefined) + )?.value + ) ?? answered) + : answered + const state = submission?.dispatchState return { kind: 'sent', state: state === 'accepted' ? 'accepted' : state === 'rejected' ? 'rejected' : 'unknown' diff --git a/src/main/runtime/orchestration/structured-pointer-operation-id.test.ts b/src/main/runtime/orchestration/structured-pointer-operation-id.test.ts index 35e75c865bc..fe891ef48ba 100644 --- a/src/main/runtime/orchestration/structured-pointer-operation-id.test.ts +++ b/src/main/runtime/orchestration/structured-pointer-operation-id.test.ts @@ -1,9 +1,14 @@ import { describe, expect, it } from 'vitest' -import { AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS } from '../../../shared/agent-session-host-authority' +import { + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, + AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS +} from '../../../shared/agent-session-host-authority' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { + decideStructuredPointerAttempt, mintAgentSessionOperationId, - resolveStructuredPointerOperation + resolveStructuredPointerOperation, + type StructuredPointerSubmission } from './structured-pointer-operation-id' const OPERATION_ID_PATTERN = /^\d{13}-[0-9a-f]{32}$/ @@ -12,6 +17,27 @@ function body(text: string): AgentJournalMessageItem { return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } } +/** The id this batch resolves to, as this process sends it; unrecorded unless `submissions` say. */ +function resolveId( + args: Omit< + Parameters[0], + 'submissions' | 'sentByThisProcess' + > & { submissions?: StructuredPointerSubmission[] } +): { + operationId: string + payloadFingerprint: string +} { + const resolved = resolveStructuredPointerOperation({ + ...args, + submissions: args.submissions ?? [], + sentByThisProcess: args.db.getStructuredPointerOperation(args.mailboxHandle)?.operation_id + }) + if (resolved.kind !== 'send') { + throw new Error(`expected a send, got ${resolved.kind}`) + } + return resolved +} + function fakeDb() { const rows = new Map() return { @@ -30,7 +56,7 @@ describe('structured pointer operation id', () => { it('reuses one id for the same batch', () => { const db = fakeDb() - const first = resolveStructuredPointerOperation({ + const first = resolveId({ db, mailboxHandle: 'dispatch:d1', sessionId: 's1', @@ -38,7 +64,7 @@ describe('structured pointer operation id', () => { messageIds: ['m1', 'm2'], now: 1_000 }) - const second = resolveStructuredPointerOperation({ + const second = resolveId({ db, mailboxHandle: 'dispatch:d1', sessionId: 's1', @@ -52,7 +78,7 @@ describe('structured pointer operation id', () => { it('re-mints when the batch grows', () => { const db = fakeDb() - const first = resolveStructuredPointerOperation({ + const first = resolveId({ db, mailboxHandle: 'dispatch:d1', sessionId: 's1', @@ -60,7 +86,7 @@ describe('structured pointer operation id', () => { messageIds: ['m1', 'm2'], now: 1_000 }) - const grown = resolveStructuredPointerOperation({ + const grown = resolveId({ db, mailboxHandle: 'dispatch:d1', sessionId: 's1', @@ -72,8 +98,9 @@ describe('structured pointer operation id', () => { }) it('never re-mints an ambiguous batch after the host replay window expires', () => { + // The host recorded the send `unknown`: whether the nudge landed is still open, however old. const db = fakeDb() - const first = resolveStructuredPointerOperation({ + const first = resolveId({ db, mailboxHandle: 'dispatch:d1', sessionId: 's1', @@ -81,12 +108,15 @@ describe('structured pointer operation id', () => { messageIds: ['m1', 'm2'], now: 1_000 }) - const aged = resolveStructuredPointerOperation({ + const aged = resolveId({ db, mailboxHandle: 'dispatch:d1', sessionId: 's1', body: body('2 messages'), messageIds: ['m1', 'm2'], + submissions: [ + { clientMessageId: first.operationId, dispatchState: 'unknown', submittedAt: 1_000 } + ], now: 1_000 + AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS + 1 }) expect(aged.operationId).toBe(first.operationId) @@ -98,7 +128,7 @@ describe('structured pointer operation id', () => { // its ledger answer — `accepted`, with no turn sent — and the lane then marks the NEW mail // delivered. The worker is never told, and the mail is gone. const db = fakeDb() - const first = resolveStructuredPointerOperation({ + const first = resolveId({ db, mailboxHandle: 'dispatch:d1', sessionId: 's1', @@ -106,7 +136,7 @@ describe('structured pointer operation id', () => { messageIds: ['m1', 'm2'], now: 1_000 }) - const different = resolveStructuredPointerOperation({ + const different = resolveId({ db, mailboxHandle: 'dispatch:d1', sessionId: 's1', @@ -120,7 +150,7 @@ describe('structured pointer operation id', () => { it('re-mints when a retained batch is reordered or partly consumed', () => { const db = fakeDb() - const first = resolveStructuredPointerOperation({ + const first = resolveId({ db, mailboxHandle: 'dispatch:d1', sessionId: 's1', @@ -128,7 +158,7 @@ describe('structured pointer operation id', () => { messageIds: ['m1', 'm2'], now: 1_000 }) - const shifted = resolveStructuredPointerOperation({ + const shifted = resolveId({ db, mailboxHandle: 'dispatch:d1', sessionId: 's1', @@ -141,7 +171,7 @@ describe('structured pointer operation id', () => { it('re-mints when the mailbox moves to a different session', () => { const db = fakeDb() - const first = resolveStructuredPointerOperation({ + const first = resolveId({ db, mailboxHandle: 'dispatch:d1', sessionId: 's1', @@ -149,7 +179,7 @@ describe('structured pointer operation id', () => { messageIds: ['m1', 'm2'], now: 1_000 }) - const moved = resolveStructuredPointerOperation({ + const moved = resolveId({ db, mailboxHandle: 'dispatch:d1', sessionId: 's2', @@ -160,3 +190,99 @@ describe('structured pointer operation id', () => { expect(moved.operationId).not.toBe(first.operationId) }) }) + +describe('what a pointer attempt does with its operation row', () => { + const row = { + mailbox_handle: 'run:r1', + session_id: 's1', + operation_id: 'op1', + batch_fingerprint: 'batch-1', + minted_at_ms: 2_000 + } + + function decide( + submissions: StructuredPointerSubmission[], + overrides: { + batchFingerprint?: string + sessionId?: string + mintedByThisProcess?: boolean + now?: number + } = {} + ) { + return decideStructuredPointerAttempt({ + row, + sessionId: overrides.sessionId ?? 's1', + batchFingerprint: overrides.batchFingerprint ?? 'batch-1', + submissions, + mintedByThisProcess: overrides.mintedByThisProcess ?? true, + now: overrides.now ?? 3_000 + }) + } + + const sent = (dispatchState: StructuredPointerSubmission['dispatchState']) => ({ + clientMessageId: 'op1', + dispatchState, + submittedAt: 2_000 + }) + const userTurn = (dispatchState: StructuredPointerSubmission['dispatchState'], at = 2_500) => ({ + clientMessageId: 'user-1', + dispatchState, + submittedAt: at + }) + + it('mints for a new batch, a new session, or no row at all', () => { + expect(decide([], { batchFingerprint: 'batch-2' })).toBe('mint') + expect(decide([], { sessionId: 's2' })).toBe('mint') + expect( + decideStructuredPointerAttempt({ + row: undefined, + sessionId: 's1', + batchFingerprint: 'batch-1', + submissions: [], + mintedByThisProcess: false, + now: 0 + }) + ).toBe('mint') + }) + + it('sends under the same id when the host never recorded it', () => { + expect(decide([])).toBe('reuse') + // A turn that ran before the row was minted is no news. + expect(decide([userTurn('accepted', 1_500)])).toBe('reuse') + }) + + it('stamps a send that ran, and parks one still in flight', () => { + expect(decide([sent('accepted')])).toBe('stamp') + expect(decide([sent('pending')])).toBe('park') + }) + + it.each([ + ['in doubt', sent('unknown')], + ['refused', sent('rejected')] + ])('replays a send that was %s instead of starting the agent again', (_label, failed) => { + expect(decide([failed])).toBe('reuse') + // A later send that has not run yet is no evidence the agent works again. + expect(decide([failed, userTurn('pending')])).toBe('reuse') + // Age never re-mints a recorded send. + expect(decide([failed], { now: 2_000 + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + 1 })).toBe( + 'reuse' + ) + }) + + it('mints once the agent ran a turn after the row was minted, recorded send or not', () => { + expect(decide([sent('unknown'), userTurn('accepted')])).toBe('mint') + expect(decide([sent('rejected'), userTurn('accepted')])).toBe('mint') + // A rewind dropped the row's own send from the journal. + expect(decide([userTurn('accepted')])).toBe('mint') + }) + + it('mints a row an earlier process left behind, recorded send or not', () => { + expect(decide([sent('unknown')], { mintedByThisProcess: false })).toBe('mint') + expect(decide([], { mintedByThisProcess: false })).toBe('mint') + }) + + it('mints an unrecorded send once the host would refuse it as too old to admit', () => { + expect(decide([], { now: 2_000 + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS })).toBe('reuse') + expect(decide([], { now: 2_000 + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + 1 })).toBe('mint') + }) +}) diff --git a/src/main/runtime/orchestration/structured-pointer-operation-id.ts b/src/main/runtime/orchestration/structured-pointer-operation-id.ts index 0f258ba325f..66710b3b4f4 100644 --- a/src/main/runtime/orchestration/structured-pointer-operation-id.ts +++ b/src/main/runtime/orchestration/structured-pointer-operation-id.ts @@ -4,8 +4,11 @@ * Orchestration's own `msg_` ids do not match the host's `^\d{13}-[0-9a-f]{32}$` shape and are * refused before the first send, so the id is minted here instead. It is durable and reused across * retries, because the id IS the send's idempotency key: a fresh id for the same nudge would land - * as a second turn. It is re-minted only when the send is genuinely a different call — a different - * batch of mail, or a different session. Age cannot resolve delivery ambiguity. + * as a second turn, and the host replays a recorded id's verdict without reaching the provider, so + * a retry after a failed send starts nothing. It is re-minted only when the send is genuinely a + * different call: a different batch of mail or session, or one the journal shows is owed again + * (see `decideStructuredPointerAttempt`). Age never re-mints a send the host recorded: its verdict + * is the only evidence of whether the nudge landed. * * Reuse is keyed on the MESSAGE IDS in the batch, never on the pointer body: the body names only * how many messages are waiting, so two unrelated same-size batches share a fingerprint. Reusing a @@ -14,9 +17,67 @@ */ import { createHash, randomBytes } from 'node:crypto' -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalMessageItem, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-session-host-authority' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { OrchestrationDb } from './db' +import type { StructuredPointerOperationRow } from './db/messages/structured-pointer-operation-store' + +/** What the pointer lane reads off a session's journal for its own sends. */ +export type StructuredPointerSubmission = Pick< + AgentJournalSubmission, + 'clientMessageId' | 'dispatchState' | 'submittedAt' +> + +/** + * What to do with a mailbox's pointer, given its operation row and the session's recorded sends. + * + * - `stamp`: the row's send ran; the batch is pointed. + * - `park`: the row's send is still in flight; its settlement is the next edge. + * - `mint`: a new send. The batch or session changed; the agent ran a turn after the row was + * minted; an earlier process minted it, so its attempt died with that process; or the host never + * recorded it and would now refuse it as too old to admit. + * - `reuse`: resend under the row's id. Unrecorded, it is a first delivery; recorded as failed, the + * host replays that verdict and starts nothing, so a provider that dies on every turn is not + * restarted by every status edge, and a user's Stop stays stopped. + */ +export type StructuredPointerAttempt = 'mint' | 'reuse' | 'stamp' | 'park' + +export function decideStructuredPointerAttempt(input: { + row: StructuredPointerOperationRow | undefined + sessionId: string + batchFingerprint: string + /** The session's recorded sends; a rewind may have dropped the row's. */ + submissions: readonly StructuredPointerSubmission[] + /** Whether this process minted the row's id. */ + mintedByThisProcess: boolean + now: number +}): StructuredPointerAttempt { + const { row, submissions } = input + if ( + !row || + row.session_id !== input.sessionId || + row.batch_fingerprint !== input.batchFingerprint + ) { + return 'mint' + } + const sent = submissions.find((entry) => entry.clientMessageId === row.operation_id) + if (sent?.dispatchState === 'accepted') { + return 'stamp' + } + if (sent?.dispatchState === 'pending') { + return 'park' + } + const ranSince = submissions.some( + (entry) => entry.dispatchState === 'accepted' && entry.submittedAt > row.minted_at_ms + ) + const tooOldToAdmit = + !sent && input.now - row.minted_at_ms > AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + return ranSince || !input.mintedByThisProcess || tooOldToAdmit ? 'mint' : 'reuse' +} export function mintAgentSessionOperationId(now: number): string { return `${String(now).padStart(13, '0')}-${randomBytes(16).toString('hex')}` @@ -43,6 +104,11 @@ export function structuredPointerPayloadFingerprint( }) } +export type StructuredPointerOperation = + | { kind: 'send'; operationId: string; payloadFingerprint: string } + | { kind: 'stamp' } + | { kind: 'park' } + export function resolveStructuredPointerOperation(args: { db: OrchestrationDb mailboxHandle: string @@ -50,18 +116,28 @@ export function resolveStructuredPointerOperation(args: { body: AgentJournalMessageItem /** The rows this nudge stands for; batch identity, not the body, decides reuse. */ messageIds: readonly string[] + submissions: readonly StructuredPointerSubmission[] + /** The operation id this process last sent for this mailbox, if any. */ + sentByThisProcess: string | undefined now?: number -}): { operationId: string; payloadFingerprint: string } { +}): StructuredPointerOperation { const now = args.now ?? Date.now() const payloadFingerprint = structuredPointerPayloadFingerprint(args.sessionId, args.body) const batchFingerprint = structuredPointerBatchFingerprint(args.sessionId, args.messageIds) const stored = args.db.getStructuredPointerOperation(args.mailboxHandle) - if ( - stored && - stored.session_id === args.sessionId && - stored.batch_fingerprint === batchFingerprint - ) { - return { operationId: stored.operation_id, payloadFingerprint } + const attempt = decideStructuredPointerAttempt({ + row: stored, + sessionId: args.sessionId, + batchFingerprint, + submissions: args.submissions, + mintedByThisProcess: stored?.operation_id === args.sentByThisProcess, + now + }) + if (attempt === 'stamp' || attempt === 'park') { + return { kind: attempt } + } + if (attempt === 'reuse' && stored) { + return { kind: 'send', operationId: stored.operation_id, payloadFingerprint } } const operationId = mintAgentSessionOperationId(now) args.db.putStructuredPointerOperation({ @@ -69,7 +145,11 @@ export function resolveStructuredPointerOperation(args: { session_id: args.sessionId, operation_id: operationId, batch_fingerprint: batchFingerprint, - minted_at_ms: now + // On the journal's clock too, so a backward clock step cannot date an earlier turn after it. + minted_at_ms: args.submissions.reduce( + (latest, entry) => Math.max(latest, entry.submittedAt), + now + ) }) - return { operationId, payloadFingerprint } + return { kind: 'send', operationId, payloadFingerprint } } diff --git a/src/main/runtime/orchestration/structured-session-lineage.ts b/src/main/runtime/orchestration/structured-session-lineage.ts new file mode 100644 index 00000000000..8cf6e94f12d --- /dev/null +++ b/src/main/runtime/orchestration/structured-session-lineage.ts @@ -0,0 +1,44 @@ +/** + * A structured session's `/clear` lineage, read off the durable session records. `/clear` continues + * a chat in a new session; the committed clear on the old record names the session that replaced it. + * Derived from the records every time; nothing is rewritten at a clear. + */ + +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' + +export type AgentSessionRecordReader = { + getRecord: (sessionId: string) => AgentSessionRecord | null + listRecords: () => AgentSessionRecord[] + /** Absent on a store that predates tab visibility; every session then counts as open. */ + getVisibleSessionTabIndex?: () => { present: boolean; sessionIds: string[] } +} + +/** Null until the agent-session host is installed; callers that must see records ensure it first. */ +export function readAgentSessionRecordStore(): AgentSessionRecordReader | null { + return getStructuredAgentSessionHost()?.deps.store ?? null +} + +/** The session a committed `/clear` continued this one in, if any. */ +export function clearedInto(record: AgentSessionRecord): string | null { + const command = record.conversationCommand + return command?.command === 'clear' && command.phase === 'committed' + ? (command.replacementSessionId ?? null) + : null +} + +/** The session running the lineage now; null when the chain names a session with no record. */ +export function lineageLiveSession( + store: AgentSessionRecordReader, + sessionId: string +): AgentSessionRecord | null { + let live = store.getRecord(sessionId) + const later = new Set([sessionId]) + let next = live ? clearedInto(live) : null + while (live && next && !later.has(next)) { + later.add(next) + live = store.getRecord(next) + next = live ? clearedInto(live) : null + } + return live +} diff --git a/src/main/runtime/orchestration/structured-session-mail-address.ts b/src/main/runtime/orchestration/structured-session-mail-address.ts new file mode 100644 index 00000000000..19a2a56a0ee --- /dev/null +++ b/src/main/runtime/orchestration/structured-session-mail-address.ts @@ -0,0 +1,93 @@ +/** + * A structured agent session as a mail address: `session:`, the Orca-minted id every agent is + * told is its public address. Recipient routing and pointer delivery both read these rules off the + * durable session record, so the two can never disagree about which sessions mail can reach. + * + * The address names a conversation, not one session of it: any session of a `/clear` lineage names + * the lineage root's address (`canonicalOrcaSessionId`), and mail reaches the lineage's live session. + * + * A released lease does not end a session: the host stops an idle chat's agent, and the send that + * points its mail starts it again. For mail, a conversation has ended only when its chat was closed. + */ + +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { isOrcaSessionId, type OrcaSessionId } from '../../../shared/orca-session-address' +import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration-session-caller-codes' +import { structuredWorkerHostScope } from '../structured-worker-identity' +import type { OrchestrationDb } from './db' +import { OrchestrationError } from './orchestration-error' +import { resolveOrcaSessionParty, type OrchestrationSessionParty } from './orchestration-party' +import { lineageLiveSession, type AgentSessionRecordReader } from './structured-session-lineage' + +export type OrcaAgentSessionLookup = + | { kind: 'found'; record: AgentSessionRecord } + /** The id is a provider's own session id, which rotates on `/clear`; this names the Orca id. */ + | { kind: 'provider-id'; orcaSessionId: string } + | { kind: 'unknown' } + +export function lookupOrcaAgentSession( + store: AgentSessionRecordReader, + id: string +): OrcaAgentSessionLookup { + const record = store.getRecord(id) + if (record) { + return { kind: 'found', record } + } + const owner = store + .listRecords() + .find((candidate) => + candidate.providerHandleChain.some(({ handle }) => + handle.provider === 'claude' ? handle.sessionId === id : handle.threadId === id + ) + ) + return owner ? { kind: 'provider-id', orcaSessionId: owner.sessionId } : { kind: 'unknown' } +} + +export type StructuredSessionMailReach = + /** `session` is the conversation's live session, the one its mail reaches now. */ + | { kind: 'reachable'; session: AgentSessionRecord } + | { kind: 'other-host' } + | { kind: 'ended'; reason: 'closed' | 'worker-identity-lost' | 'continuation-missing' } + +/** Whether mail to `record`'s conversation can reach the session that runs it now. */ +export function structuredSessionMailReach( + store: AgentSessionRecordReader, + record: AgentSessionRecord, + db: OrchestrationDb | null | undefined +): StructuredSessionMailReach { + const live = lineageLiveSession(store, record.sessionId) + if (!live) { + return { kind: 'ended', reason: 'continuation-missing' } + } + if (!structuredWorkerHostScope(live.location)) { + return { kind: 'other-host' } + } + if (isOrcaSessionId(live.sessionId) && !addressableSessionParty(live.sessionId, db)) { + // Why: it can no longer act (the caller resolver refuses it), so mail to it could never be read. + return { kind: 'ended', reason: 'worker-identity-lost' } + } + const visible = store.getVisibleSessionTabIndex?.() + if (visible?.present && !visible.sessionIds.includes(live.sessionId)) { + // Why: reviving a chat the user closed would run turns nobody can see; its mail waits instead. + return { kind: 'ended', reason: 'closed' } + } + return { kind: 'reachable', session: live } +} + +/** + * The party a session resolves to, or null for a worker whose identity this host lost: the party + * resolver refuses it in every role, so it can never read mail and none is owed to it. + */ +export function addressableSessionParty( + sessionId: OrcaSessionId, + db: OrchestrationDb | null | undefined +): OrchestrationSessionParty | null { + try { + return resolveOrcaSessionParty(sessionId, db) + } catch (error) { + if (error instanceof OrchestrationError && error.code === CODES.notLive) { + return null + } + throw error + } +} diff --git a/src/main/runtime/orchestration/structured-session-mail-target.test.ts b/src/main/runtime/orchestration/structured-session-mail-target.test.ts new file mode 100644 index 00000000000..5ed9c1ce26f --- /dev/null +++ b/src/main/runtime/orchestration/structured-session-mail-target.test.ts @@ -0,0 +1,531 @@ +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + getAppEnvironment, + hasAppEnvironment, + setAppEnvironment, + type AppEnvironment +} from '../../../shared/app-environment' +import type { AgentSessionLease, AgentSessionRecord } from '../../../shared/agent-session-record' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../shared/agent-session-record.test-fixture' +import { formatOrcaSessionAddress, type OrcaSessionId } from '../../../shared/orca-session-address' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' + +const hostRef: { current: unknown } = { current: null } + +vi.mock('../../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) + +const { OrcaRuntimeWithGetPtyRecordForPaneKey } = + await import('../orca-runtime-get-pty-record-for-pane-key') +const { OrchestrationDb } = await import('./db') +const { resolveOrcaSessionParty } = await import('./orchestration-party') +const { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerIdentities, + structuredWorkerProcessIncarnation +} = await import('../structured-worker-identity') + +const CHAT = testOrcaSessionId('4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37') +const CHAT_ADDRESS = formatOrcaSessionAddress(CHAT) + +/** The real methods through the real prototype chain; a re-declared copy would pin nothing. */ +class MailTargetProbe extends OrcaRuntimeWithGetPtyRecordForPaneKey { + target(mailboxHandle: string): unknown { + return this.resolveStructuredMailboxTarget(mailboxHandle) + } +} + +type Store = { + records: Map + visible: { present: boolean; sessionIds: string[] } +} + +function chatRecord( + lease: Partial = {}, + extra: Partial = {} +): AgentSessionRecord { + return { + ...agentSessionRecordFixture( + agentSessionLeaseFixture({ sessionId: CHAT, runtimeKind: 'native', ...lease }) + ), + ...extra + } +} + +function installStore(record: AgentSessionRecord | null, visible = true): Store { + const store: Store = { + records: new Map(record ? [[record.sessionId, record]] : []), + visible: { present: true, sessionIds: visible && record ? [record.sessionId] : [] } + } + hostRef.current = { + deps: { + store: { + getRecord: (sessionId: string) => store.records.get(sessionId) ?? null, + listRecords: () => [...store.records.values()], + getVisibleSessionTabIndex: () => store.visible + } + } + } + return store +} + +let db: InstanceType + +function probe(extra: Record = {}): MailTargetProbe { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a prototype-only probe; every field the methods read is assigned below. + return Object.assign(Object.create(MailTargetProbe.prototype), { + _orchestrationDb: db, + ptysById: new Map(), + ...extra + }) as MailTargetProbe +} + +function chatCoordinatedRun(): string { + return db.createRun({ + objective: 'o', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorOrcaSessionId: CHAT + }).id +} + +beforeEach(() => { + db = new OrchestrationDb(':memory:') + hostRef.current = null +}) + +afterEach(() => { + db.close() +}) + +describe('a Run whose coordinator is a chat (an Orca session id, no handle)', () => { + it('delivers its mailbox to that session', () => { + // The defect this pins: the resolver read only `coordinator_handle`, which a chat never has, so + // neither lane claimed the Run mailbox and a worker's result never reached the chat. + installStore(chatRecord()) + const runId = chatCoordinatedRun() + expect(probe().target(`run:${runId}`)).toEqual({ sessionId: CHAT, dispatchId: null }) + }) + + it('still delivers once the host has evicted the chat, so the delivery can wake it', () => { + installStore(chatRecord({ claimStatus: 'released', ownerProcess: null })) + const runId = chatCoordinatedRun() + expect(probe().target(`run:${runId}`)).toEqual({ sessionId: CHAT, dispatchId: null }) + }) + + it('does not deliver to a chat that was closed, cleared into no known session, or runs on another host', () => { + const runId = chatCoordinatedRun() + installStore(chatRecord(), false) + expect(probe().target(`run:${runId}`)).toBeNull() + + installStore( + chatRecord( + {}, + { + conversationCommand: { + command: 'clear', + state: 'completed', + replacementSessionId: '7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64', + operationId: 'op', + callerKey: 'caller', + phase: 'committed' + } + } + ) + ) + expect(probe().target(`run:${runId}`)).toBeNull() + + const remote = chatRecord() + installStore({ ...remote, location: { ...remote.location, executionHostId: 'ssh:box' } }) + expect(probe().target(`run:${runId}`)).toBeNull() + }) + + it('does not deliver to an Orca session id written at an earlier generation of the Run', () => { + // An older binary's rebind or unbind bumps the generation and leaves the id behind. + installStore(chatRecord()) + const runId = chatCoordinatedRun() + db.db + .prepare('UPDATE runs SET consumer_generation = consumer_generation + 1 WHERE id = ?') + .run(runId) + expect(probe().target(`run:${runId}`)).toBeNull() + }) + + it('ignores an Orca session id left beside a PTY handle; the handle owns the Run', () => { + installStore(chatRecord()) + const runId = db.createRun({ + objective: 'o', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_c:11111111-1111-4111-8111-111111111111', + coordinatorOrcaSessionId: CHAT + }).id + expect(probe().target(`run:${runId}`)).toBeNull() + }) +}) + +describe('a session addressed directly', () => { + it('owns its `session:` mailbox', () => { + installStore(chatRecord()) + expect(probe().target(CHAT_ADDRESS)).toEqual({ sessionId: CHAT, dispatchId: null }) + }) + + it('claims nothing for a malformed session address', () => { + installStore(chatRecord()) + expect(probe().target('session:term_abc')).toBeNull() + }) +}) + +describe('the idle edge of a structured session', () => { + it('re-derives and delivers the mailboxes the session owns, and nothing while it works', () => { + installStore(chatRecord()) + const runId = chatCoordinatedRun() + db.insertMessage({ + from: 'term_worker', + to: `run:${runId}`, + subject: 'done', + runId, + type: 'status' + }) + const delivered: string[] = [] + const runtime = probe({ + deliverPendingMessagesForHandle: (handle: string) => delivered.push(handle), + notifyStructuredSessionJournalActivity: vi.fn(), + cancelMessageWaiters: vi.fn() + }) + runtime.onStructuredSessionStatusForMail({ sessionId: CHAT, status: 'working' }) + expect(delivered).toEqual([]) + runtime.onStructuredSessionStatusForMail({ sessionId: CHAT, status: 'idle' }) + expect(delivered).toEqual([`run:${runId}`]) + }) + + it('points direct mail at a session that coordinates nothing', () => { + installStore(chatRecord()) + db.insertMessage({ from: 'term_peer', to: CHAT_ADDRESS, subject: 'hi', type: 'status' }) + const delivered: string[] = [] + probe({ + deliverPendingMessagesForHandle: (handle: string) => delivered.push(handle), + notifyStructuredSessionJournalActivity: vi.fn(), + cancelMessageWaiters: vi.fn() + }).onStructuredSessionStatusForMail({ sessionId: CHAT, status: 'idle' }) + expect(delivered).toEqual([CHAT_ADDRESS]) + }) +}) + +describe('the idle edge after a restart, before any orchestration call', () => { + let userData: string + let previousEnvironment: AppEnvironment | null + + beforeEach(() => { + userData = mkdtempSync(join(tmpdir(), 'orca-idle-edge-db-')) + previousEnvironment = hasAppEnvironment() ? getAppEnvironment() : null + setAppEnvironment({ + getPath: () => userData, + getAppPath: () => userData, + getVersion: () => '0.0.0-test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) + }) + + afterEach(() => { + if (previousEnvironment) { + setAppEnvironment(previousEnvironment) + } + rmSync(userData, { recursive: true, force: true }) + }) + + /** A runtime whose database has not been opened in this process yet. */ + function restarted(delivered: string[]): MailTargetProbe { + return probe({ + _orchestrationDb: null, + ensureOrchestrationFederationRelay: vi.fn(), + scheduleRestoredMessageRepoints: vi.fn(), + deliverPendingMessagesForHandle: (handle: string) => delivered.push(handle), + notifyStructuredSessionJournalActivity: vi.fn() + }) + } + + it('opens an existing orchestration database itself, so mail stored before the restart is redriven', () => { + // The strand this pins: the edge read the raw database field, null until the first + // orchestration RPC opened it, so a restarted chat's idle edges silently redrove nothing. + installStore(chatRecord()) + const stored = new OrchestrationDb(join(userData, 'orchestration.db')) + const runId = stored.createRun({ + objective: 'o', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorOrcaSessionId: CHAT + }).id + stored.close() + const delivered: string[] = [] + const runtime = restarted(delivered) + + runtime.onStructuredSessionStatusForMail({ sessionId: CHAT, status: 'idle' }) + + expect(delivered).toEqual([`run:${runId}`]) + runtime.getOrchestrationDb().close() + }) + + it('creates no database for a profile that never orchestrated, and says nothing', () => { + installStore(chatRecord()) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const delivered: string[] = [] + + restarted(delivered).onStructuredSessionStatusForMail({ sessionId: CHAT, status: 'idle' }) + + expect(delivered).toEqual([]) + expect(existsSync(join(userData, 'orchestration.db'))).toBe(false) + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + }) + + it('says so when the database cannot be opened, instead of skipping silently', () => { + installStore(chatRecord()) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const deliver = vi.fn() + probe({ + _orchestrationDb: null, + getExistingOrchestrationDb: () => { + throw new Error('userData unavailable') + }, + deliverPendingMessagesForHandle: deliver, + notifyStructuredSessionJournalActivity: vi.fn() + }).onStructuredSessionStatusForMail({ sessionId: CHAT, status: 'idle' }) + expect(deliver).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith( + '[orchestration] skipped a structured session mail edge: no database', + { sessionId: CHAT, error: 'userData unavailable' } + ) + warn.mockRestore() + }) +}) + +describe('a coordinator chat continued by /clear', () => { + const MIDDLE = testOrcaSessionId('clear-fedcba9876543210fedcba9876543210fedcba98') + const SUCCESSOR = testOrcaSessionId('clear-0123456789abcdef0123456789abcdef01234567') + + function sessionRecord(sessionId: string, clearedInto?: string): AgentSessionRecord { + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ + sessionId, + runtimeKind: 'native', + ...(clearedInto ? { claimStatus: 'released' as const, ownerProcess: null } : {}) + }) + ) + return clearedInto + ? { + ...record, + conversationCommand: { + command: 'clear', + state: 'completed', + replacementSessionId: clearedInto, + operationId: `op-${sessionId}`, + callerKey: 'caller', + phase: 'committed' + } + } + : record + } + + /** CHAT cleared into each of `chain` in turn; the last one is live and its tab is open. */ + function installLineage(...chain: string[]): void { + const lineage = [CHAT, ...chain] + const store = installStore(null) + lineage.forEach((sessionId, index) => + store.records.set(sessionId, sessionRecord(sessionId, lineage[index + 1])) + ) + store.visible.sessionIds.push(lineage.at(-1)!) + } + + function idleEdge(sessionId: string): string[] { + const delivered: string[] = [] + probe({ + deliverPendingMessagesForHandle: (handle: string) => delivered.push(handle), + notifyStructuredSessionJournalActivity: vi.fn() + }).onStructuredSessionStatusForMail({ sessionId, status: 'idle' }) + return delivered + } + + function runCreatedBy(sessionId: OrcaSessionId): string { + return db.createRun({ + objective: 'o', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorOrcaSessionId: resolveOrcaSessionParty(sessionId, db).orcaSessionId + }).id + } + + it('stores a Dispatch assignee by the lineage root of the session its incarnation names', () => { + installLineage(SUCCESSOR) + const dispatch = db.createDispatchContext({ + taskId: db.createTask({ runId: chatCoordinatedRun(), spec: 'work' }).id, + assigneeHandle: mintStructuredWorkerHandle(), + assigneePaneKey: mintStructuredWorkerPaneKey(SUCCESSOR), + processIncarnation: structuredWorkerProcessIncarnation(SUCCESSOR), + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + expect(db.getDispatchContextById(dispatch.id)?.assignee_orca_session_id).toBe(CHAT) + }) + + it("never unbinds the successor's own Run", () => { + // The strand this pins: the successor's own run-create, then its idle edge rebinding the + // predecessor's Run through an exclusive bind, which unbound the Run the successor created. + installLineage(SUCCESSOR) + chatCoordinatedRun() + const ownRun = runCreatedBy(SUCCESSOR) + const generation = db.getRunRaw(ownRun)!.consumer_generation + + expect(idleEdge(SUCCESSOR)).toContain(`run:${ownRun}`) + idleEdge(SUCCESSOR) + + const successor = resolveOrcaSessionParty(SUCCESSOR, db) + expect(db.getRunRaw(ownRun)).toMatchObject({ + coordinator_orca_session_id: successor.orcaSessionId, + consumer_generation: generation + }) + expect(db.getCurrentRunForCoordinator(successor)?.id).toBe(ownRun) + }) + + it('keeps a Run bound, unrewritten, across a chain of clears, and delivers it to the live end', () => { + installLineage(MIDDLE) + const runId = chatCoordinatedRun() + const generation = db.getRunRaw(runId)!.consumer_generation + idleEdge(MIDDLE) + installLineage(MIDDLE, SUCCESSOR) + expect(idleEdge(SUCCESSOR)).toContain(`run:${runId}`) + + expect(db.getRunRaw(runId)).toMatchObject({ + coordinator_orca_session_id: CHAT, + consumer_generation: generation + }) + for (const member of [CHAT, MIDDLE, SUCCESSOR]) { + expect(db.getCurrentRunForCoordinator(resolveOrcaSessionParty(member, db))?.id).toBe(runId) + } + expect(probe().target(`run:${runId}`)).toEqual({ sessionId: SUCCESSOR, dispatchId: null }) + }) + + it('keeps the Run a middle session created bound after the next clear', () => { + // The chain strand: adopting each predecessor's Run in turn unbound all but the last adopted. + installLineage(MIDDLE) + runCreatedBy(CHAT) + const middleRun = runCreatedBy(MIDDLE) + const generation = db.getRunRaw(middleRun)!.consumer_generation + installLineage(MIDDLE, SUCCESSOR) + idleEdge(SUCCESSOR) + + const successor = resolveOrcaSessionParty(SUCCESSOR, db) + expect(db.getRunRaw(middleRun)).toMatchObject({ + coordinator_orca_session_id: successor.orcaSessionId, + consumer_generation: generation + }) + expect(db.getCurrentRunForCoordinator(successor)?.id).toBe(middleRun) + }) + + it('reaches the live session through any spelling of the conversation, and stores one', () => { + installLineage(MIDDLE, SUCCESSOR) + for (const member of [CHAT, MIDDLE, SUCCESSOR]) { + expect(resolveOrcaSessionParty(member, db)).toMatchObject({ + orcaSessionId: CHAT, + address: CHAT_ADDRESS + }) + expect(probe().target(`session:${member}`)).toEqual({ + sessionId: SUCCESSOR, + dispatchId: null + }) + } + const direct = db.insertMessage({ + from: 'term_peer', + to: CHAT_ADDRESS, + subject: 'hi', + type: 'status' + }) + expect(idleEdge(SUCCESSOR)).toEqual([CHAT_ADDRESS]) + expect(db.getMessageById(direct.id)).toMatchObject({ to_handle: CHAT_ADDRESS, read: 0 }) + }) +}) + +describe('a structured worker continued by /clear', () => { + const WORKER = testOrcaSessionId('9c2e4a61-3f7b-4d8e-b105-6a2d8e4f1c93') + const WORKER_SUCCESSOR = testOrcaSessionId('clear-a1b2c3d4e5f60718293a4b5c6d7e8f9012345678') + + afterEach(() => { + structuredWorkerIdentities.clear() + }) + + /** A worker minted for WORKER, whose conversation `/clear` continued in WORKER_SUCCESSOR. */ + function clearedWorker(): { handle: string; paneKey: string } { + const store = installStore(null) + const minted = agentSessionRecordFixture( + agentSessionLeaseFixture({ sessionId: WORKER, runtimeKind: 'native' }) + ) + store.records.set(WORKER, { + ...minted, + conversationCommand: { + command: 'clear', + state: 'completed', + replacementSessionId: WORKER_SUCCESSOR, + operationId: 'op-worker', + callerKey: 'caller', + phase: 'committed' + } + }) + store.records.set( + WORKER_SUCCESSOR, + agentSessionRecordFixture( + agentSessionLeaseFixture({ sessionId: WORKER_SUCCESSOR, runtimeKind: 'native' }) + ) + ) + const identity = structuredWorkerIdentities.register({ + handle: mintStructuredWorkerHandle(), + sessionId: WORKER, + agent: 'codex', + paneKey: mintStructuredWorkerPaneKey(WORKER), + processIncarnation: structuredWorkerProcessIncarnation(WORKER), + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } + }) + return { handle: identity.handle, paneKey: identity.paneKey } + } + + it("delivers mail at the worker's handle to the live successor, as a terminal keeps its handle", () => { + // The strand this pins: the handle resolved to the session minted for it, which `/clear` + // replaced, so the worker's own mail was pointed at a session that no longer runs its turns. + const { handle } = clearedWorker() + expect(probe().target(handle)).toEqual({ sessionId: WORKER_SUCCESSOR, dispatchId: null }) + }) + + it("delivers the worker's dispatch mailbox and a Run it coordinates to the live successor", () => { + const { handle, paneKey } = clearedWorker() + const dispatch = db.createDispatchContext({ + taskId: db.createTask({ runId: chatCoordinatedRun(), spec: 'work' }).id, + assigneeHandle: handle, + assigneePaneKey: paneKey, + processIncarnation: structuredWorkerProcessIncarnation(WORKER), + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + expect(probe().target(`dispatch:${dispatch.id}`)).toEqual({ + sessionId: WORKER_SUCCESSOR, + dispatchId: dispatch.id + }) + const workerRun = db.createRun({ + objective: 'o', + coordinatorHandle: handle, + coordinatorPaneKey: paneKey + }).id + expect(probe().target(`run:${workerRun}`)).toEqual({ + sessionId: WORKER_SUCCESSOR, + dispatchId: null + }) + }) +}) diff --git a/src/main/runtime/orchestration/structured-session-mail-target.ts b/src/main/runtime/orchestration/structured-session-mail-target.ts new file mode 100644 index 00000000000..e71c08c512e --- /dev/null +++ b/src/main/runtime/orchestration/structured-session-mail-target.ts @@ -0,0 +1,128 @@ +/** + * Where a mailbox owned by a structured session is delivered: a chat that coordinates a Run + * (`run:` with no coordinator handle), a session addressed directly at `session:`, and the + * live session behind a structured worker's handle. The session is resolved here, never a pane, and + * takes the pointer as a session turn. + */ + +import { + ORCA_SESSION_ADDRESS_PREFIX, + isOrcaSessionId, + parseOrcaSessionAddress, + type OrcaSessionId +} from '../../../shared/orca-session-address' +import type { OrchestrationDb } from './db' +import { currentRunCoordinatorOrcaSessionId } from './db/runs/run-coordinator-orca-session' +import { structuredWorkerHostScope } from '../structured-worker-identity' +import type { StructuredPointerTarget } from './structured-mailbox-pointer-delivery' +import { + addressableSessionParty, + structuredSessionMailReach +} from './structured-session-mail-address' +import { + lineageLiveSession, + readAgentSessionRecordStore, + type AgentSessionRecordReader +} from './structured-session-lineage' +import type { RunRow } from './types' + +/** + * The session a Run's coordinator binding names when that binding has no handle. A structured + * worker coordinates by its own handle and resolves through it, so only a handle-less binding names + * a session here, and only by an Orca session id that still counts (see + * `currentRunCoordinatorOrcaSessionId`). + */ +export function handleLessCoordinatorSessionId( + run: Pick< + RunRow, + | 'coordinator_handle' + | 'coordinator_orca_session_id' + | 'coordinator_orca_session_id_generation' + | 'consumer_generation' + > +): OrcaSessionId | null { + if (run.coordinator_handle !== null) { + return null + } + return currentRunCoordinatorOrcaSessionId(run) +} + +/** + * The structured-lane target for `sessionId`'s conversation: its live session, whichever session of + * the lineage was named; null when mail cannot reach it here. + */ +export function structuredSessionMailTarget( + sessionId: string, + db: OrchestrationDb | null | undefined, + store: AgentSessionRecordReader | null = readAgentSessionRecordStore() +): StructuredPointerTarget | null { + const record = store?.getRecord(sessionId) + const reach = store && record ? structuredSessionMailReach(store, record, db) : null + return reach?.kind === 'reachable' + ? { sessionId: reach.session.sessionId, dispatchId: null } + : null +} + +/** + * The session a structured worker's mail reaches: the one minted for it, or that session's live + * `/clear` successor, which carries on as the worker the way a terminal keeps its handle. + */ +export function structuredWorkerMailSessionId( + mintedSessionId: string, + store: AgentSessionRecordReader | null = readAgentSessionRecordStore() +): string | null { + const live = store ? lineageLiveSession(store, mintedSessionId) : null + return live && structuredWorkerHostScope(live.location) ? live.sessionId : null +} + +/** + * The target of a `session:` mailbox; `undefined` when the handle is not a session address at + * all, so other address forms keep their own resolution. + */ +export function structuredSessionAddressTarget( + mailboxHandle: string, + db: OrchestrationDb | null | undefined +): StructuredPointerTarget | null | undefined { + if (!mailboxHandle.startsWith(ORCA_SESSION_ADDRESS_PREFIX)) { + return undefined + } + const sessionId = parseOrcaSessionAddress(mailboxHandle) + return sessionId ? structuredSessionMailTarget(sessionId, db) : null +} + +/** + * Every mailbox a session reads for itself: the Runs it coordinates and its own direct mail. + * Re-derived from the database on each idle edge rather than remembered, so mail that arrived + * while the session could not take it (mid-turn, closed) is found again. + */ +export function structuredSessionOwnedMailboxes(sessionId: string, db: OrchestrationDb): string[] { + const party = isOrcaSessionId(sessionId) ? addressableSessionParty(sessionId, db) : null + if (!party) { + return [] + } + const mailboxes = db.runsBoundToCoordinator(party).map((run) => `run:${run.id}`) + if (db.getUnreadDirectMessageTypes(party.address).length > 0) { + mailboxes.push(party.address) + } + return mailboxes +} + +/** The mailboxes a session's idle edge re-derives, opening an existing database if nothing has + * yet: after a restart this edge is what redrives mail stored before it. No database file means + * no mail, so `openDb` answers null and nothing is created. */ +export function structuredSessionIdleEdgeMailboxes( + sessionId: string, + openDb: () => OrchestrationDb | null +): string[] { + let db: OrchestrationDb | null + try { + db = openDb() + } catch (error) { + console.warn('[orchestration] skipped a structured session mail edge: no database', { + sessionId, + error: error instanceof Error ? error.message : String(error) + }) + return [] + } + return db ? structuredSessionOwnedMailboxes(sessionId, db) : [] +} diff --git a/src/main/runtime/orchestration/structured-session-pointer-delivery.ts b/src/main/runtime/orchestration/structured-session-pointer-delivery.ts index 4071d17a073..4e838e313f8 100644 --- a/src/main/runtime/orchestration/structured-session-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-session-pointer-delivery.ts @@ -68,7 +68,8 @@ export function structuredSessionGateFacts( * them. Neither refuses the frame: Codex COALESCES a mid-turn `turn/start` into * the running turn -- measured on codex-cli 0.147.0, 0.150.1 and 0.153.4, none * of which refuse it and none of which fire a second `turn/started` -- and - * Claude queues it behind the turn. Both therefore + * Claude folds it into the running turn (or runs it as the next turn when the + * turn ends first). Both therefore * fold the nudge into work already in flight, where it reads as part of the * running turn rather than a new instruction. Waiting for the turn to settle is * the one contract that holds for both, and it preserves orchestration's @@ -98,7 +99,7 @@ export function decideStructuredSessionPointerDelivery(input: { * adapters cannot tell them apart — so it must retain. Treating it as delivered * would drop mail whenever a child died mid-send. */ -export function structuredDispatchDelivered(state: StructuredDispatchState): boolean { +export function structuredDispatchDelivered(state: StructuredDispatchState): state is 'accepted' { return state === 'accepted' } diff --git a/src/main/runtime/orchestration/structured-worker-direct-mailbox-target.test.ts b/src/main/runtime/orchestration/structured-worker-direct-mailbox-target.test.ts index 4fff2d387d0..cc32156cc87 100644 --- a/src/main/runtime/orchestration/structured-worker-direct-mailbox-target.test.ts +++ b/src/main/runtime/orchestration/structured-worker-direct-mailbox-target.test.ts @@ -93,7 +93,7 @@ describe('the mailbox target for direct peer mail to a structured worker', () => // claimed the mailbox — the PTY lane refuses a structured handle outright and this resolver // answered only `dispatch:` addresses. The worker never reacted and the peer waiting on a // reply hung, with nothing logged. A dispatch says nothing about whether delivery is safe; - // the idle gate and the lease fence do, and both still run downstream. + // the idle gate and the writer lease do, and both still run downstream. const handle = registerWorker() installRecord({ runtimeKind: 'native', claimStatus: 'live' }) expect(probe(undefined).instance.probeResolveTarget(handle)).toEqual({ diff --git a/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts b/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts index 93fa58fd6b5..43b7fe2be45 100644 --- a/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts +++ b/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts @@ -50,11 +50,14 @@ function installHost(options: { items?: AgentJournalRenderItem[] lease?: { runtimeKind: string; claimStatus: string } hasSession?: boolean + tabListed?: boolean }): void { const lease = options.lease ?? { runtimeKind: 'native', claimStatus: 'live' } hostRef.current = { deps: { store: { + // No committed /clear: each session is its own lineage's root. + listRecords: () => [], getRecord: (sessionId: string) => ({ sessionId, @@ -65,21 +68,45 @@ function installHost(options: { } }, hasSession: () => options.hasSession ?? true, - journalSnapshot: () => ({ items: options.items ?? [idleTurn()] }) + getPersistedVisibleSessionTabIndex: () => ({ + present: true, + sessionIds: options.tabListed ? [SESSION_ID] : [] + }), + journalSnapshot: async () => ({ items: options.items ?? [idleTurn()] }) } } +/** The durable worker-terminal rows group addressing enumerates. */ +const rows: { + terminal_handle: string + pane_key: string + process_incarnation: string + worktree_id: string + host_scope: string +}[] = [] + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: group addressing reads only this one query off the database. +const workerDb = { listWorkerTerminalResourcesByIncarnationPrefix: () => rows } as never + function registerWorker(worktreeId = 'wt_1'): string { const handle = mintStructuredWorkerHandle() + const paneKey = mintStructuredWorkerPaneKey(SESSION_ID) structuredWorkerIdentities.register({ handle, sessionId: SESSION_ID, agent: 'codex', - paneKey: mintStructuredWorkerPaneKey(SESSION_ID), + paneKey, processIncarnation: structuredWorkerProcessIncarnation(SESSION_ID), worktreeId, hostScope: { kind: 'local', hostId: 'local' } }) + rows.push({ + terminal_handle: handle, + pane_key: paneKey, + process_incarnation: structuredWorkerProcessIncarnation(SESSION_ID), + worktree_id: worktreeId, + host_scope: JSON.stringify({ kind: 'local', hostId: 'local' }) + }) return handle } @@ -88,22 +115,28 @@ const PTY_TERMINAL = { handle: 'term_a', worktreeId: 'wt_1', agentIdentity: 'cla describe('group addressing and structured workers', () => { beforeEach(() => { structuredWorkerIdentities.clear() + rows.length = 0 hostRef.current = null }) it('enumerates a live structured worker as a candidate', () => { const handle = registerWorker() installHost({}) - expect(listAddressableStructuredWorkers()).toEqual([ + expect(listAddressableStructuredWorkers(workerDb)).toEqual([ { handle, worktreeId: 'wt_1', agentIdentity: 'codex' } ]) }) - it('leaves out a worker whose session is not proven live', () => { - // Addressing a settled worker would store mail no lane will ever deliver. - registerWorker() - installHost({ lease: { runtimeKind: 'native', claimStatus: 'live' }, hasSession: false }) - expect(listAddressableStructuredWorkers()).toEqual([]) + it('keeps a worker at rest and leaves out a retired one', () => { + // At rest: its agent was stopped while idle, its chat tab still lists it, and mail starts it. + const handle = registerWorker() + installHost({ lease: { runtimeKind: 'native', claimStatus: 'released' }, tabListed: true }) + expect(listAddressableStructuredWorkers(workerDb)).toEqual([ + { handle, worktreeId: 'wt_1', agentIdentity: 'codex' } + ]) + // Retired: released with its tab gone. Addressing it would store mail no lane delivers. + installHost({ lease: { runtimeKind: 'native', claimStatus: 'released' }, tabListed: false }) + expect(listAddressableStructuredWorkers(workerDb)).toEqual([]) }) it('reaches a structured worker through @all', () => { @@ -112,14 +145,14 @@ describe('group addressing and structured workers', () => { // machinery never ran and the sender got exit 0 with a receipt naming only who did resolve. const handle = registerWorker() installHost({}) - const recipients = [PTY_TERMINAL, ...listAddressableStructuredWorkers()] + const recipients = [PTY_TERMINAL, ...listAddressableStructuredWorkers(workerDb)] expect(resolveGroupAddress('@all', 'term_sender', recipients, () => 'idle')).toContain(handle) }) it('reaches a structured worker through @worktree: and @codex, but not @claude', () => { const handle = registerWorker('wt_2') installHost({}) - const recipients = [PTY_TERMINAL, ...listAddressableStructuredWorkers()] + const recipients = [PTY_TERMINAL, ...listAddressableStructuredWorkers(workerDb)] expect(resolveGroupAddress('@worktree:wt_2', 'term_sender', recipients, () => 'idle')).toEqual([ handle ]) @@ -129,20 +162,20 @@ describe('group addressing and structured workers', () => { ]) }) - it('reads @idle status off the FULL timeline, never a bounded tail', () => { + it('reads @idle status off the FULL timeline, never a bounded tail', async () => { // The same trap that already cost this branch once: settlement tombstones the lifecycle item // rather than rewriting it, so a long tool-calling turn pushes it arbitrarily far from the // tail and any page-sized read reports a BUSY worker as idle — then `@idle` broadcasts into a - // running turn, which Codex refuses outright and Claude queues behind. + // running turn, which Codex refuses outright and Claude folds into it. registerWorker() installHost({ items: [runningTurn(), ...transcript(500)] }) - expect(structuredWorkerAgentStatus(SESSION_ID)).toBe('working') + expect(await structuredWorkerAgentStatus(SESSION_ID)).toBe('working') }) - it('answers idle only when no turn is running and no human is awaited', () => { + it('answers idle only when no turn is running and no human is awaited', async () => { registerWorker() installHost({ items: [idleTurn()] }) - expect(structuredWorkerAgentStatus(SESSION_ID)).toBe('idle') + expect(await structuredWorkerAgentStatus(SESSION_ID)).toBe('idle') installHost({ items: [ { @@ -156,26 +189,27 @@ describe('group addressing and structured workers', () => { } as unknown as AgentJournalRenderItem ] }) - expect(structuredWorkerAgentStatus(SESSION_ID)).toBe('attention') + expect(await structuredWorkerAgentStatus(SESSION_ID)).toBe('attention') }) - it('answers null rather than idle when the session cannot be read', () => { + it('answers null rather than idle when the session cannot be read', async () => { // Unknown must never read as idle, or `@idle` wakes a worker mid-turn. hostRef.current = null - expect(structuredWorkerAgentStatus(SESSION_ID)).toBeNull() + expect(await structuredWorkerAgentStatus(SESSION_ID)).toBeNull() }) }) describe('sendGroupMessage actually composes structured workers in', () => { beforeEach(() => { structuredWorkerIdentities.clear() + rows.length = 0 hostRef.current = null }) /** * Drives the real `sendGroupMessage`, not `resolveGroupAddress`. * - * The suite above hand-composed `[PTY_TERMINAL, ...listAddressableStructuredWorkers()]` itself, + * The suite above hand-composed `[PTY_TERMINAL, ...listAddressableStructuredWorkers(workerDb)]` itself, * so deleting the composition at the call site left it green — the exact regression the fix * describes could come straight back. This test owns that seam. */ @@ -186,8 +220,10 @@ describe('sendGroupMessage actually composes structured workers in', () => { const db = { getLegacyAdoptedRunMailboxOwner: () => null, getCurrentRunForPane: () => undefined, + getCurrentRunForCoordinator: () => undefined, getActiveDispatchMailboxOwners: () => [], getRunMailboxOwnerIdsForHandle: () => [], + listWorkerTerminalResourcesByIncarnationPrefix: () => rows, insertMessages: (rows: { to: string }[]) => { inserted.push(...rows) return rows.map((row, index) => ({ id: `m${index}`, to_handle: row.to, type: 'status' })) @@ -197,7 +233,7 @@ describe('sendGroupMessage actually composes structured workers in', () => { // No PTY terminals at all: if the call site does not compose structured workers in, the // group resolves empty and this throws instead of delivering. listTerminals: async () => ({ terminals: [] }), - getAgentStatusForHandle: () => 'idle', + getAgentStatusForHandle: async () => 'idle', getLiveTerminalPaneKey: () => structuredWorkerIdentities.get(handle)!.paneKey, notifyMessageArrived: () => {} } @@ -227,9 +263,11 @@ describe('sendGroupMessage actually composes structured workers in', () => { const db = { getLegacyAdoptedRunMailboxOwner: () => null, getCurrentRunForPane: () => undefined, + getCurrentRunForCoordinator: () => undefined, getActiveDispatchForIdentity: () => ({ run_id: 'run_1' }), getActiveDispatchMailboxOwners: () => [], getRunMailboxOwnerIdsForHandle: () => [], + listWorkerTerminalResourcesByIncarnationPrefix: () => rows, listWorkerTerminalResources: () => [ { dispatchId: 'ctx_structured', @@ -257,17 +295,24 @@ describe('sendGroupMessage actually composes structured workers in', () => { listTerminals: async () => ({ terminals: [{ handle: 'term_claude', worktreeId: 'wt_1', agentIdentity: 'claude' }] }), - getAgentStatusForHandle: () => 'idle', + getAgentStatusForHandle: async () => 'idle', getLiveTerminalPaneKey: () => null, getOrchestrationDb: () => db, notifyMessageArrived: () => {} } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub cast is unchanged; the gate flags it only because this diff adds the sender field inside its span. await sendGroupMessage({ params: { subject: 's', body: 'b', type: 'status', priority: 'normal' }, runtime: runtime as never, db: db as never, from: 'term_sender', groupAddress: '@codex', + sender: { + address: 'term_sender', + terminalHandle: 'term_sender', + paneKey: null, + orcaSessionId: null + }, senderPaneKey: undefined, senderRunId: 'run_1', explicitRunId: undefined, diff --git a/src/main/runtime/orchestration/structured-worker-group-addressing.ts b/src/main/runtime/orchestration/structured-worker-group-addressing.ts index 168bd870118..3a6161f8187 100644 --- a/src/main/runtime/orchestration/structured-worker-group-addressing.ts +++ b/src/main/runtime/orchestration/structured-worker-group-addressing.ts @@ -16,8 +16,13 @@ */ import type { TuiAgent } from '../../../shared/tui-agent' -import { observeStructuredWorker, structuredWorkerAgent } from '../structured-worker-authority' -import { structuredWorkerIdentities } from '../structured-worker-identity' +import { structuredWorkerAgent } from '../structured-worker-authority' +import { structuredWorkerAddressable } from '../structured-worker-custody' +import { + STRUCTURED_WORKER_INCARNATION_PREFIX, + structuredWorkerIdentityFromRow +} from '../structured-worker-identity' +import type { OrchestrationDb } from './db' import { readStructuredSessionGateFacts } from './structured-mailbox-pointer-host' /** The only facts group addressing reads off a recipient. */ @@ -29,15 +34,29 @@ export type OrchestrationAddressableAgent = { } /** - * Live structured workers of this runtime, as group-address candidates. + * Structured workers this runtime owns, as group-address candidates. * - * Liveness-gated on the same observation the rest of the structured surface uses: a settled or - * handed-off worker is not a recipient, and addressing one would store mail no lane will deliver. + * Read from the durable worker-terminal rows, which outlive a settled dispatch and a restart, and + * gated on ownership and on the orchestration not having released it — the same answer direct mail + * routes on — never on liveness: a worker at rest is a recipient, and the mail starts it. A retired + * or released one is not. */ -export function listAddressableStructuredWorkers(): OrchestrationAddressableAgent[] { - return structuredWorkerIdentities - .list() - .filter((identity) => observeStructuredWorker(identity).status === 'live') +export function listAddressableStructuredWorkers( + db: OrchestrationDb | null +): OrchestrationAddressableAgent[] { + const seen = new Set() + return ( + db?.listWorkerTerminalResourcesByIncarnationPrefix(STRUCTURED_WORKER_INCARNATION_PREFIX) ?? [] + ) + .flatMap((row) => { + const identity = structuredWorkerIdentityFromRow(row) + // Newest row first: a session is one recipient, under its latest handle. + if (!identity || seen.has(identity.sessionId)) { + return [] + } + seen.add(identity.sessionId) + return structuredWorkerAddressable(db, identity.sessionId, row) === true ? [identity] : [] + }) .map((identity) => ({ handle: identity.handle, worktreeId: identity.worktreeId, @@ -49,11 +68,11 @@ export function listAddressableStructuredWorkers(): OrchestrationAddressableAgen * A structured worker's agent status, in the vocabulary `@idle` already matches on. * * Null when the session cannot be read: unknown must not read as idle, or a broadcast to `@idle` - * would wake a worker mid-turn — which Codex coalesces into the running turn and Claude queues - * behind it. + * would wake a worker mid-turn — which Codex coalesces into the running turn and Claude folds + * into it. */ -export function structuredWorkerAgentStatus(sessionId: string): string | null { - const facts = readStructuredSessionGateFacts(sessionId) +export async function structuredWorkerAgentStatus(sessionId: string): Promise { + const facts = await readStructuredSessionGateFacts(sessionId) if (!facts) { return null } diff --git a/src/main/runtime/orchestration/structured-worker-journal-page.ts b/src/main/runtime/orchestration/structured-worker-journal-page.ts index 35676aaf52c..8d260339fa6 100644 --- a/src/main/runtime/orchestration/structured-worker-journal-page.ts +++ b/src/main/runtime/orchestration/structured-worker-journal-page.ts @@ -16,14 +16,17 @@ export type StructuredJournalPage = { hasOlder: boolean } -/** The newest page of a session's journal, or null when this runtime cannot read it. */ -export function readStructuredJournalPage(sessionId: string): StructuredJournalPage | null { +/** The newest page of a session's journal, or null when this runtime cannot read it. A closed + * conversation is opened for the read; that starts no agent. */ +export async function readStructuredJournalPage( + sessionId: string +): Promise { const host = getStructuredAgentSessionHost() if (!host) { return null } try { - const result = host.history({ + const result = await host.history({ sessionId, direction: 'tail', limit: STRUCTURED_JOURNAL_PAGE_LIMIT diff --git a/src/main/runtime/orchestration/types.ts b/src/main/runtime/orchestration/types.ts index d00ea480c7d..cd2b12a7da2 100644 --- a/src/main/runtime/orchestration/types.ts +++ b/src/main/runtime/orchestration/types.ts @@ -1,4 +1,5 @@ import type { TerminalExitCause } from '../../../shared/terminal-exit-cause' +import type { OrcaSessionId } from '../../../shared/orca-session-address' export const MESSAGE_TYPES = [ 'status', 'dispatch', @@ -47,7 +48,7 @@ export type RunRow = { coordinator_handle: string | null coordinator_pane_key: string | null /** Bare Orca session id the coordinator is addressed by, when it has one (today only structured sessions); a `/clear`ed chat's lineage root. */ - coordinator_orca_session_id: string | null + coordinator_orca_session_id: OrcaSessionId | null /** The consumer_generation the id was written at; see currentRunCoordinatorOrcaSessionId. */ coordinator_orca_session_id_generation: number | null consumer_generation: number @@ -283,7 +284,7 @@ export type DispatchContextRow = { assignee_handle: string | null assignee_pane_key: string | null /** Bare Orca session id the assignee is addressed by, when it has one (today only structured sessions); a `/clear`ed chat's lineage root. */ - assignee_orca_session_id: string | null + assignee_orca_session_id: OrcaSessionId | null capability_hash: string | null process_incarnation: string | null capability_revoked_at: string | null @@ -294,7 +295,7 @@ export type DispatchContextRow = { creator_handle: string | null creator_pane_key: string | null /** Bare Orca session id the creator is addressed by, when it has one (today only structured sessions); a `/clear`ed chat's lineage root. */ - creator_orca_session_id: string | null + creator_orca_session_id: OrcaSessionId | null host_scope: string | null status: DispatchStatus failure_count: number diff --git a/src/main/runtime/orchestration/worker-output-archive.ts b/src/main/runtime/orchestration/worker-output-archive.ts index d7e894cf54c..ddae515a7f2 100644 --- a/src/main/runtime/orchestration/worker-output-archive.ts +++ b/src/main/runtime/orchestration/worker-output-archive.ts @@ -91,7 +91,7 @@ export async function captureWorkerOutputArchive(args: { structuredWorker?: StructuredWorkerIdentity | null }): Promise { if (args.structuredWorker) { - const content = captureStructuredWorkerArchive( + const content = await captureStructuredWorkerArchive( args.structuredWorker, structuredWorkerAgent(args.structuredWorker) ) diff --git a/src/main/runtime/orchestration/worker-terminal-process-liveness.test.ts b/src/main/runtime/orchestration/worker-terminal-process-liveness.test.ts new file mode 100644 index 00000000000..e2bc2cc7d63 --- /dev/null +++ b/src/main/runtime/orchestration/worker-terminal-process-liveness.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { matchesProcessIncarnation } from './worker-terminal-process-liveness' + +describe('matchesProcessIncarnation', () => { + it.each([ + { + name: 'an exact ptyId:incarnation match', + ptyId: 'pty-1', + incarnationId: 'incarnation-1', + processIncarnation: 'pty-1:incarnation-1', + expected: true + }, + { + name: 'a Windows repo::C:\\path@@1 ptyId whose incarnation matches', + ptyId: 'repo::C:\\path@@1', + incarnationId: 'incarnation-win', + processIncarnation: 'repo::C:\\path@@1:incarnation-win', + expected: true + }, + { + name: 'a colon-bearing relay incarnationId that a lastIndexOf split would mangle', + ptyId: 'relay-pty', + incarnationId: 'relay:conn-3:incarnation-9', + processIncarnation: 'relay-pty:relay:conn-3:incarnation-9', + expected: true + }, + { + name: 'a whitespace-dirty incarnationId (lost contact is never a match)', + ptyId: 'pty-1', + incarnationId: ' incarnation-1', + processIncarnation: 'pty-1: incarnation-1', + expected: false + }, + { + name: 'an empty-string incarnationId', + ptyId: 'pty-1', + incarnationId: '', + processIncarnation: 'pty-1:', + expected: false + }, + { + name: 'an absent (null) incarnationId', + ptyId: 'pty-1', + incarnationId: null, + processIncarnation: 'pty-1:incarnation-1', + expected: false + }, + { + name: 'an absent (undefined) incarnationId', + ptyId: 'pty-1', + incarnationId: undefined, + processIncarnation: 'pty-1:incarnation-1', + expected: false + }, + { + name: 'a prefix-decoy ptyId (@@1) against a longer live pty (@@10)', + ptyId: 'repo::C:\\path@@1', + incarnationId: 'inc-1', + processIncarnation: 'repo::C:\\path@@10:inc-1', + expected: false + } + ])('returns $expected for $name', ({ ptyId, incarnationId, processIncarnation, expected }) => { + expect(matchesProcessIncarnation(ptyId, incarnationId, processIncarnation)).toBe(expected) + }) + + it('rejects a partial prefix that is not colon-delimited', () => { + // 'pty-10' is not the pty 'pty-1'; the `${ptyId}:` fence keeps them distinct. + expect(matchesProcessIncarnation('pty-1', 'inc', 'pty-10:inc')).toBe(false) + }) +}) diff --git a/src/main/runtime/orchestration/worker-terminal-process-liveness.ts b/src/main/runtime/orchestration/worker-terminal-process-liveness.ts index 72c5ce07666..4e652d6286a 100644 --- a/src/main/runtime/orchestration/worker-terminal-process-liveness.ts +++ b/src/main/runtime/orchestration/worker-terminal-process-liveness.ts @@ -5,6 +5,24 @@ import type { PtyProcessInfo } from '../../providers/pty-process-info' export type { WorkerTerminalHostScope } from '../../../shared/worker-terminal-host-scope' export { parseWorkerTerminalHostScope } from '../../../shared/worker-terminal-host-scope' +/** + * Does `processIncarnation` name exactly this pty's live incarnation? Requires exact + * `${ptyId}:${incarnationId}` equality, so it is immune to colons on either side (relay/SSH + * ptyIds, colon-bearing relay incarnationIds). A pty with no (or a whitespace-dirty) + * incarnationId can never match — the exact-incarnation fence stays intact. + */ +export function matchesProcessIncarnation( + ptyId: string, + incarnationId: string | null | undefined, + processIncarnation: string +): boolean { + if (!incarnationId || incarnationId !== incarnationId.trim()) { + return false + } + return `${ptyId}:${incarnationId}` === processIncarnation +} + +/** Classify a recorded incarnation against live sessions: live on exact match, unverifiable when a candidate pty has a dirty or absent incarnationId (lost contact is never a death certificate), else exited. */ export function classifyWorkerTerminalProcessIncarnation( processIncarnation: string, sessions: readonly PtyProcessInfo[] @@ -13,13 +31,9 @@ export function classifyWorkerTerminalProcessIncarnation( processIncarnation.startsWith(`${session.id}:`) ) if ( - possibleMatches.some((session) => { - const incarnationId = session.incarnationId - if (!incarnationId || incarnationId !== incarnationId.trim()) { - return false - } - return `${session.id}:${incarnationId}` === processIncarnation - }) + possibleMatches.some((session) => + matchesProcessIncarnation(session.id, session.incarnationId, processIncarnation) + ) ) { return 'live' } diff --git a/src/main/runtime/orchestration/worker-transcript-payload.test.ts b/src/main/runtime/orchestration/worker-transcript-payload.test.ts index c843fe1cfa7..d0b4ce23127 100644 --- a/src/main/runtime/orchestration/worker-transcript-payload.test.ts +++ b/src/main/runtime/orchestration/worker-transcript-payload.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' import { MAX_CODEX_SUBAGENTS_PER_GROUP } from '../../codex/codex-structured-journal-limits' +import { projectStructuredItemsToNativeChat } from '../../../shared/structured-agent-session-projection' import { boundWorkerTranscriptMessages, + boundWorkerTranscriptTail, redactWorkerTerminalLines } from './worker-transcript-payload' @@ -177,6 +179,28 @@ describe('worker transcript wire bounds', () => { expect(result).toMatchObject({ limited: false, warnings: [] }) }) + it("serves a structured worker's journal rows without their list position", () => { + const [message] = projectStructuredItemsToNativeChat([ + { + itemId: 'reply', + revision: 1, + sequence: 7, + sequenceIndex: 1, + observedAt: 1, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'done' }] } + } + ]) + // Anti-vacuous: the projection itself does position the row. + expect(message?.journalPosition).toEqual({ sequence: 7, index: 1 }) + for (const served of [ + boundWorkerTranscriptMessages([message!]).messages, + boundWorkerTranscriptTail([message!], 262_144).messages + ]) { + expect(served).toHaveLength(1) + expect(served[0]).not.toHaveProperty('journalPosition') + } + }) + it('keeps two roster ids sharing a 512-char prefix distinct', () => { // The id is the roster key: a plain prefix clip would merge the two children. const head = 'a'.repeat(512) @@ -275,3 +299,48 @@ describe('worker transcript wire bounds', () => { ) }) }) + +describe("worker transcript wire bounds — a subagent's line names its agent and nothing more", () => { + it('serves the producing agent id, bounded like the roster key, and drops provenance', () => { + const longId = `task-${'x'.repeat(2_000)}` + const result = boundWorkerTranscriptMessages([ + { + id: 'child-line', + role: 'assistant', + timestamp: null, + source: 'transcript', + blocks: [{ type: 'text', text: 'The PR is CLEAN.' }], + agentId: longId, + parentAgentId: 'task-parent', + providerParentRef: 'toolu_provider_call', + producerKind: 'agent', + attempt: 2 + }, + { + id: 'roster', + role: 'system', + timestamp: null, + source: 'transcript', + blocks: [ + { + type: 'subagent-group', + groupId: 'group-1', + agents: [{ id: longId, label: 'review the PR', state: 'working' }] + } + ] + } + ]) + const [child, roster] = result.messages + expect(child).not.toHaveProperty('providerParentRef') + expect(child).not.toHaveProperty('parentAgentId') + expect(child).not.toHaveProperty('producerKind') + expect(child).not.toHaveProperty('attempt') + // The line's agent id and the roster entry that names it bound to the same key, + // so the reader can still put a name to the line. + const rosterBlock = roster?.blocks[0] + expect(rosterBlock?.type).toBe('subagent-group') + const entryId = rosterBlock?.type === 'subagent-group' ? rosterBlock.agents[0]?.id : undefined + expect(child?.agentId).toBeDefined() + expect(child?.agentId).toBe(entryId) + }) +}) diff --git a/src/main/runtime/orchestration/worker-transcript-payload.ts b/src/main/runtime/orchestration/worker-transcript-payload.ts index b4365e259c2..b62687bd813 100644 --- a/src/main/runtime/orchestration/worker-transcript-payload.ts +++ b/src/main/runtime/orchestration/worker-transcript-payload.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto' import type { NativeChatBlock, NativeChatMessage } from '../../../shared/native-chat-types' +import { agentJournalItemSubagentId } from '../../../shared/agent-session-journal-producer' import { boundSubagentEntryId } from '../../native-chat/subagent-entry-id-bounds' import { boundWorkerTranscriptActivityBlock } from './worker-transcript-activity-block-bounds' @@ -108,10 +109,25 @@ function boundMessage( if (blocks.length < message.blocks.length) { markClipped(state, 'Some transcript blocks were omitted from oversized messages.') } + // The journal position only orders a live list; a worker read is already in order. + // Of the producer linkage only the agent's id is served, so a reader can say whose + // line it is; the rest is provenance. + const { + journalPosition: _journalPosition, + agentId: _agentId, + parentAgentId: _parentAgentId, + providerParentRef: _providerParentRef, + producerKind: _producerKind, + attempt: _attempt, + ...served + } = message + const subagentId = agentJournalItemSubagentId(message) return { - ...message, + ...served, id: boundIdentifier(message.id, transcriptPath, state), ...(message.turnId ? { turnId: boundIdentifier(message.turnId, transcriptPath, state) } : {}), + // Same key as the roster entry that names it, so it is bounded the same way. + ...(subagentId === null ? {} : { agentId: boundEntryId(subagentId, state) }), blocks: blocks.map((block) => boundBlock(block, state)) } } diff --git a/src/main/runtime/paired-close-retirement-proof-publication-order.test.ts b/src/main/runtime/paired-close-retirement-proof-publication-order.test.ts index 640c54a753c..35fb16d55ea 100644 --- a/src/main/runtime/paired-close-retirement-proof-publication-order.test.ts +++ b/src/main/runtime/paired-close-retirement-proof-publication-order.test.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' import type { @@ -88,14 +89,17 @@ function createHost(): { } { let session = makePersistedSession() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the store stub carries the four members this publication-order suite drives; the rest of Store is unreached. - const runtime = new OrcaRuntimeService({ - getRepos: () => [LIVE_REPO], - getWorkspaceSession: () => session, - setWorkspaceSession: (next: WorkspaceSessionState) => { - session = next - }, - flushOrThrow: vi.fn() - } as never) + const runtime = new OrcaRuntimeService( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This runtime fixture supplies the persistence and graph methods exercised by the test. + withDurableRuntimeStore({ + getRepos: () => [LIVE_REPO], + getWorkspaceSession: () => session, + setWorkspaceSession: (next: WorkspaceSessionState) => { + session = next + }, + flushOrThrow: vi.fn() + }) as never + ) runtime.attachWindow(1) runtime.syncWindowGraph(1, { tabs: [ @@ -159,7 +163,7 @@ describe('retirement proof publication vs. renderer republication order', () => it('publishes the proof when the exit lands before the renderer drops the surface', async () => { const { runtime, handle } = createHost() - runtime.onPtyExit('pty-left', 0, 'incarnation-a') + await runtime.onPtyExit('pty-left', 0, 'incarnation-a') republishWithoutTheSurface(runtime) const published = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) @@ -174,7 +178,7 @@ describe('retirement proof publication vs. renderer republication order', () => retirePersistedSurface() republishWithoutTheSurface(runtime) - runtime.onPtyExit('pty-left', 0, 'incarnation-a') + await runtime.onPtyExit('pty-left', 0, 'incarnation-a') const published = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) expect(published.tabs).toEqual([]) @@ -185,7 +189,7 @@ describe('retirement proof publication vs. renderer republication order', () => // Why a subscriber and not just the stored snapshot: a mirror only ever sees frames. A proof // that lands in state without a frame to carry it is the same silence from the client's side. - it('fans the proof out to a paired subscriber, not just into stored state', () => { + it('fans the proof out to a paired subscriber, not just into stored state', async () => { const { runtime, handle, retirePersistedSurface } = createHost() const frames: RuntimeMobileSessionTabsResult[] = [] const unsubscribe = runtime.onMobileSessionTabsChanged( @@ -196,7 +200,7 @@ describe('retirement proof publication vs. renderer republication order', () => try { retirePersistedSurface() republishWithoutTheSurface(runtime) - runtime.onPtyExit('pty-left', 0, 'incarnation-a') + await runtime.onPtyExit('pty-left', 0, 'incarnation-a') } finally { unsubscribe() } diff --git a/src/main/runtime/pty-inventory-partial-relay-liveness.test.ts b/src/main/runtime/pty-inventory-partial-relay-liveness.test.ts index 22dc70e42c0..cf5618ab2ff 100644 --- a/src/main/runtime/pty-inventory-partial-relay-liveness.test.ts +++ b/src/main/runtime/pty-inventory-partial-relay-liveness.test.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' import { describe, expect, it } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' import { makePaneKey } from '../../shared/stable-pane-id' @@ -50,7 +51,8 @@ function createRuntime(options: { sessions?: unknown[]; vouchesForRetainedPty?: calls: ListCall[] } { const meta: Record> = { [WORKSPACE]: { hostId: 'local' } } - const store = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This runtime fixture supplies the persistence and graph methods exercised by the test. + const store = withDurableRuntimeStore({ getRepos: () => [REPO], getRepo: (id: string) => (id === REPO_ID ? REPO : undefined), getAllWorktreeMeta: () => meta, @@ -62,7 +64,7 @@ function createRuntime(options: { sessions?: unknown[]; vouchesForRetainedPty?: getWorkspaceSession: () => getDefaultWorkspaceSession(), setWorkspaceSession: () => {}, flushOrThrow: () => {} - } as never + }) as never const calls: ListCall[] = [] const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ diff --git a/src/main/runtime/pty-shell-ownership-mirror.test.ts b/src/main/runtime/pty-shell-ownership-mirror.test.ts index 20468a194cd..b0525940a86 100644 --- a/src/main/runtime/pty-shell-ownership-mirror.test.ts +++ b/src/main/runtime/pty-shell-ownership-mirror.test.ts @@ -16,6 +16,24 @@ describe('PtyShellOwnershipMirror', () => { expect(mirror.owner).toBe('shell') }) + it('asks at the real D after refuted stray Ds, in lockstep with the host barrier', async () => { + let hostGrounded = false + const confirm = vi.fn(async () => hostGrounded) + const mirror = new PtyShellOwnershipMirror(confirm) + mirror.scan('\x1b]133;C\x07\x1b[>1u\x1b[?1049hAGENT') + + for (let index = 1; index <= 5; index += 1) { + mirror.scan('\x1b]133;C\x07nested\x1b]133;D;0\x07') + await vi.waitFor(() => expect(confirm).toHaveBeenCalledTimes(index)) + await mirror.settle() + } + hostGrounded = true + mirror.scan('\x1b]133;D;137\x07') + + await vi.waitFor(() => expect(mirror.owner).toBe('shell')) + expect(confirm).toHaveBeenCalledTimes(6) + }) + it('does not arm from a seed on the normal buffer', async () => { const confirm = vi.fn(async () => true) const mirror = new PtyShellOwnershipMirror(confirm) diff --git a/src/main/runtime/pty-shell-ownership-mirror.ts b/src/main/runtime/pty-shell-ownership-mirror.ts index 5c54465be75..45a7d8e8422 100644 --- a/src/main/runtime/pty-shell-ownership-mirror.ts +++ b/src/main/runtime/pty-shell-ownership-mirror.ts @@ -42,6 +42,11 @@ export class PtyShellOwnershipMirror { } } + /** Reset Terminal: the ground for this mirror's view of mode ownership, already scanned. */ + groundInputModes(): string { + return this.scanner.groundProcessBoundary() + } + get owner(): TerminalOwner | undefined { return this.scanner.owner } diff --git a/src/main/runtime/qoder-terminal-readiness.ts b/src/main/runtime/qoder-terminal-readiness.ts new file mode 100644 index 00000000000..781da3341c0 --- /dev/null +++ b/src/main/runtime/qoder-terminal-readiness.ts @@ -0,0 +1,11 @@ +// Captured Qoder 1.1.64 paints its idle OSC title even while the trust menu owns input. +export function isQoderComposerReady(screenLines: readonly string[] | null): boolean { + if (!screenLines) { + return false + } + const screen = screenLines.join('\n').toLowerCase() + return ( + screen.includes('type your message or @path/to/file') && + !screen.includes("don't trust and exit") + ) +} diff --git a/src/main/runtime/qoder-transcripts.test.ts b/src/main/runtime/qoder-transcripts.test.ts new file mode 100644 index 00000000000..89c5881382a --- /dev/null +++ b/src/main/runtime/qoder-transcripts.test.ts @@ -0,0 +1,50 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { createTranscriptPane } from './agent-transcript-pane-test-harness' +import { extractLastOscTitle } from '../../shared/osc-title-extraction' +import { getAgentLabel, normalizeTerminalTitle } from '../../shared/agent-detection' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +describe('captured Qoder 1.1.64 startup', () => { + it.each(['qoder-trust-dialog', 'qoder-no-account', 'qoder-ready'])( + 'preserves Qoder identity in %s', + async (fixture) => { + const data = readFileSync(join(__dirname, '__fixtures__', `${fixture}.txt`), 'utf8') + // The recorder's shutdown clears the OSC title; inspect the live capture before that reset. + const title = extractLastOscTitle( + data.replaceAll(`${String.fromCharCode(27)}]0;${String.fromCharCode(7)}`, '') + ) + expect(title).toContain(' | Ready') + expect(getAgentLabel(normalizeTerminalTitle(title ?? ''))).toBe('Qoder CLI') + const { runtime, handle } = await createTranscriptPane({ + paneTitle: title ?? '', + foregroundProcess: 'qodercli-1.1.64', + launchAgent: 'qoder', + data, + size: { cols: 100, rows: 32 } + }) + const shown = await runtime.showTerminal(handle) + expect(shown.agentIdentity).toBe('qoder') + if (fixture === 'qoder-trust-dialog') { + const readiness = await runtime + .waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 600 }) + .catch(() => null) + expect(readiness?.satisfied ?? false).toBe(false) + } + if (fixture === 'qoder-ready') { + const readiness = await runtime.waitForTerminal(handle, { + condition: 'tui-idle', + timeoutMs: 1500 + }) + expect(readiness.satisfied).toBe(true) + } + } + ) +}) diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 0eae5f48bb3..1ec15933782 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -10,6 +10,7 @@ import type { } from '../../../shared/mobile-relay-credential-contract' import type { RuntimeCapability } from '../../../shared/protocol-version' import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence' +import type { OrchestrationSessionCaller } from '../orchestration/orchestration-caller-identity' export type PairingRpcContext = { getEndpoints(params: PairingGetEndpointsParams): Promise @@ -100,6 +101,8 @@ export type RpcContext = { replayedMutationReceipt?: unknown // Why: Run-scoped handlers must compare declared handles with request attestation. orchestrationCompatibilityEvidence?: OrchestrationCompatibilityEvidence + // Why: resolved once at the dispatch entry from the caller's Orca session id; the session wins. + orchestrationCaller?: OrchestrationSessionCaller // Why: only the compatibility authority router can set this trusted scope; user params cannot bypass Run consumer binding. legacyCoordinatorRunId?: string legacyCoordinatorAuthority?: LegacyCoordinatorAuthorityProof @@ -228,6 +231,11 @@ export function eraseRpcMethods( return methods as readonly RpcAnyMethod[] } +// Unsubscribes that must not retire a registration created after their dispatch began. +export function isRegistrationFencedUnsubscribe(method: string): boolean { + return method === 'terminal.unsubscribe' || method === 'session.tabs.unsubscribe' +} + export function isStreamingMethod(method: RpcAnyMethod): method is RpcStreamingMethod { return 'stream' in method && method.stream === true } diff --git a/src/main/runtime/rpc/dispatcher-unary-method-invocation.ts b/src/main/runtime/rpc/dispatcher-unary-method-invocation.ts index 60d9728f152..d8bf3e25b53 100644 --- a/src/main/runtime/rpc/dispatcher-unary-method-invocation.ts +++ b/src/main/runtime/rpc/dispatcher-unary-method-invocation.ts @@ -82,7 +82,8 @@ export async function invokeDispatcherUnaryMethod({ request, effectiveParams, invoke, - legacyCoordinator?.mutationCallerFingerprint ?? authenticatedCallerFingerprint + legacyCoordinator?.mutationCallerFingerprint ?? authenticatedCallerFingerprint, + context.orchestrationCaller?.orcaSessionId ) recordRuntimeFeatureInteraction(runtime, request.method, result, undefined, request.params) return result diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 6ed2f1fd9eb..83cd0e30f9c 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -1,5 +1,6 @@ import { buildRegistry, + isRegistrationFencedUnsubscribe, isStreamingMethod, type RpcAnyMethodDeclaration, type RpcEnvelopeMeta, @@ -23,6 +24,11 @@ import { mapDispatcherError } from './dispatcher-error-response' import { parseRpcRequestParams } from './dispatcher-request-parsing' import { RpcStreamingDispatcher } from './rpc-streaming-dispatcher' import { invokeDispatcherUnaryMethod } from './dispatcher-unary-method-invocation' +import { + needsOrchestrationCallerResolution, + resolveOrchestrationSessionCaller, + type ResolvedOrchestrationRequest +} from './orchestration-session-caller' export type DispatcherOptions = { runtime: OrcaRuntimeService @@ -69,7 +75,15 @@ export class RpcDispatcher { return migrationFence } - const parsedParams = parseRpcRequestParams(request, method, meta) + let resolved: ResolvedOrchestrationRequest = { request } + if (needsOrchestrationCallerResolution(request)) { + try { + resolved = await resolveOrchestrationSessionCaller(this.runtime, request, options) + } catch (error) { + return mapDispatcherError(request, meta, error) + } + } + const parsedParams = parseRpcRequestParams(resolved.request, method, meta) if (parsedParams.error) { return parsedParams.error } @@ -89,24 +103,25 @@ export class RpcDispatcher { try { const result = await invokeDispatcherUnaryMethod({ runtime: this.runtime, - request, + request: resolved.request, method, params: parsedParams.value, context: { runtime: this.runtime, signal: options?.signal, connectionId: options?.connectionId, - subscriptionRegistrationVersion: - request.method === 'terminal.unsubscribe' - ? this.runtime.getSubscriptionRegistrationVersion() - : undefined, + // Session tabs always need this fence. COMPAT(terminal request-addressed unsubscribe): terminal only for phones without `requestId`. + subscriptionRegistrationVersion: isRegistrationFencedUnsubscribe(request.method) + ? this.runtime.getSubscriptionRegistrationVersion() + : undefined, requestId: request.id, clientId: options?.clientId, clientKind: options?.clientKind, clientCapabilities: options?.clientCapabilities, updateClientCapabilities: options?.updateClientCapabilities, orchestrationCapability: request.orchestrationCapability, - authenticatedCallerFingerprint: options?.authenticatedCallerFingerprint + authenticatedCallerFingerprint: options?.authenticatedCallerFingerprint, + orchestrationCaller: resolved.caller }, orchestrationMutations: this.orchestrationMutations, legacyOrchestration: this.legacyOrchestration diff --git a/src/main/runtime/rpc/errors.test.ts b/src/main/runtime/rpc/errors.test.ts index 02ae6a335f2..624557c1f7f 100644 --- a/src/main/runtime/rpc/errors.test.ts +++ b/src/main/runtime/rpc/errors.test.ts @@ -15,6 +15,11 @@ import { nestedWorkerDepthExceededMessage } from '../../../shared/nested-worker-depth' import { OrchestrationError } from '../orchestration/orchestration-error' +import { + AgentSessionRefusalError, + agentSessionRefusalError, + refuseUnclassified +} from '../../../shared/agent-session-wire-refusals' class LineageError extends Error { code = 'LINEAGE_PARENT_NOT_FOUND' @@ -297,3 +302,63 @@ describe('structured worker dispatch preamble errors', () => { }) }) }) + +describe('thrown agent-session refusals', () => { + const meta = { runtimeId: 'runtime-1' } + + // Released clients classify a thrown refusal by its wire code and message; both must read + // exactly as the bare `Error(code)` this replaced. + it.each([ + [ + 'agent_session_ownership_unknown', + 'agent_session_ownership_unknown', + agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'noLiveOwner' }) + ], + [ + 'structured_agent_session_unsupported', + 'runtime_error', + agentSessionRefusalError('structured_agent_session_unsupported', { reason: 'hostDisabled' }) + ], + [ + 'agent_session_checkpoint_stale', + 'agent_session_checkpoint_stale', + agentSessionRefusalError('agent_session_checkpoint_stale', { + reason: 'fenceStale', + currentFence: 4 + }) + ] + ] as const)( + 'keeps %s on the wire as it was, and adds its details in data', + (code, wire, error) => { + const before = mapRuntimeError('req_1', meta, new Error(code)) + const after = mapRuntimeError('req_1', meta, error) + expect(after.error.code).toBe(before.error.code) + expect(after.error.code).toBe(wire) + expect(after.error.message).toBe(before.error.message) + expect(after.error.message).toBe(code) + expect(after.error.data).toEqual({ refusal: { code, details: error.refusal.details } }) + expect(error.refusal.details?.reason).toBeDefined() + } + ) + + it('carries no details in data when the refusal named none', () => { + const response = mapRuntimeError( + 'req_1', + meta, + new AgentSessionRefusalError( + refuseUnclassified('agent_session_conflict', 'Another process claims this session.') + ) + ) + expect(response.error).toEqual({ + code: 'agent_session_conflict', + message: 'agent_session_conflict', + data: { refusal: { code: 'agent_session_conflict' } } + }) + }) + + it('exposes no code property another passthrough could claim', () => { + expect( + 'code' in agentSessionRefusalError('agent_session_conflict', { reason: 'claimConflicted' }) + ).toBe(false) + }) +}) diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index c2402d87849..6d122c02d45 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -2,7 +2,13 @@ // runtime/browser error allowlists define the contract the CLI relies on to // format human-facing messages. Centralizing this mapping keeps the allowlist // auditable in one place instead of spread across per-method branches. +import { + agentSessionRefusalReference, + isAgentSessionRefusalError, + type AgentSessionRefusalError +} from '../../../shared/agent-session-wire-refusals' import type { RpcEnvelopeMeta, RpcFailure, RpcSuccess } from './core' +import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES } from '../../../shared/orchestration-session-caller-codes' import { computerUseErrorRecoveryData } from '../../../shared/computer-use-error-recovery' import { COMPUTER_ERROR_CODES } from '../../../shared/runtime-types' import { LINEAR_ERROR_CODES } from '../../../shared/linear/agent-access' @@ -153,11 +159,15 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ SKILL_INSTALL_RPC_ERROR_CODE, // Why: an owner conflict is a distinct client decision (reload the host, re-adopt, // stop offering the action) — flattened to runtime_error it can only be guessed at. - ...Object.values(AUTOMATION_OWNER_CONFLICT_CODES) + ...Object.values(AUTOMATION_OWNER_CONFLICT_CODES), + ...Object.values(ORCHESTRATION_SESSION_CALLER_ERROR_CODES) ]) export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknown): RpcFailure { const message = error instanceof Error ? error.message : String(error) + if (isAgentSessionRefusalError(error)) { + return agentSessionRefusalErrorResponse(id, meta, error) + } if ( error instanceof Error && 'code' in error && @@ -228,6 +238,27 @@ export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknow return errorResponse(id, meta, 'runtime_error', message) } +/** + * A thrown agent-session refusal, mapped before any `'code' in error` passthrough so no other + * subsystem's code set can claim it. Wire code and message are exactly what the bare `Error(code)` + * it replaced produced — released clients classify both — and the refusal's details ride only in + * `data`, which they ignore. + */ +function agentSessionRefusalErrorResponse( + id: string, + meta: RpcEnvelopeMeta, + error: AgentSessionRefusalError +): RpcFailure { + const { code } = error.refusal + return errorResponse( + id, + meta, + RUNTIME_PASSTHROUGH_CODES.has(code) ? code : 'runtime_error', + code, + { refusal: agentSessionRefusalReference(error.refusal) } + ) +} + export const computerErrorData = computerUseErrorRecoveryData // Why: browser errors carry a structured .code property (BrowserError from diff --git a/src/main/runtime/rpc/methods/agent-hooks.ts b/src/main/runtime/rpc/methods/agent-hooks.ts index 4d9f4a7706c..59273cf8715 100644 --- a/src/main/runtime/rpc/methods/agent-hooks.ts +++ b/src/main/runtime/rpc/methods/agent-hooks.ts @@ -1,3 +1,4 @@ +import { isAgentStatusHooksEnabledForAgent } from '../../../../shared/agent-status-hooks-setting' import { prepareManagedWslCodexHomeBeforeShellLaunch } from '../../../codex/managed-wsl-home-shell-preflight' import { defineMethod } from '../core' import { PrepareCodexForWslPaneParams } from '../../../../shared/rpc-contract/agent-hooks-params' @@ -10,15 +11,13 @@ export const AGENT_HOOK_METHODS = [ if (clientKind !== undefined) { throw new Error('Codex hook preparation is only available to the local Orca CLI.') } - const settings = runtime.getClientSettings() return await prepareManagedWslCodexHomeBeforeShellLaunch({ env: { CODEX_HOME: params.codexHome, ORCA_CODEX_HOME: params.orcaCodexHome, WSL_DISTRO_NAME: params.wslDistro }, - hooksEnabled: - settings.agentStatusHooksEnabled && !settings.disabledTuiAgents.includes('codex') + hooksEnabled: isAgentStatusHooksEnabledForAgent(runtime.getClientSettings(), 'codex') }) } }) diff --git a/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts b/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts new file mode 100644 index 00000000000..8ef0f2c904d --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts @@ -0,0 +1,181 @@ +/** + * A launch from a paired client moves that client's view to the new tab and nobody else's: the view + * intent belongs to the connection that asked. In-process callers and workspace-creating launches + * keep today's behaviour. + */ + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentSessionRecordStore } from '../../agent-session-record-store' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import type { RpcContext } from '../core' +import { + CAPABLE_CLIENT, + STRUCTURED_PREFERENCE, + methodNamed, + rpcContext, + runtimeStub, + type AgentLaunchRuntimeStub +} from './agent-launch.test-fixture' + +const createStructuredSession = vi.hoisted(() => vi.fn()) +vi.mock('./structured-agent-session-create', () => ({ + createStructuredAgentSessionForWorktree: createStructuredSession +})) + +const { AGENT_LAUNCH_METHODS } = await import('./agent-launch') +const AGENT_LAUNCH = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launch') +const AGENT_LAUNCH_REPLAY = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launchReplay') + +const TAB_ID = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' +const LEAF_ID = '3f2504e0-4f89-41d3-9a0c-0305e82c3301' +const PANE_KEY = `${TAB_ID}:${LEAF_ID}` +const EXISTING_LAUNCH = { agent: 'claude', target: { kind: 'existing', worktree: 'id:wt-7' } } +const CREATE_LAUNCH = { + agent: 'claude', + target: { kind: 'create-worktree', create: { repo: 'id:repo-1', name: 'task' } } +} +const CALLER = 'device-1' + +function selectionRuntime(options: Parameters[0]) { + return Object.assign(runtimeStub(options), { + selectCreatedMobileSessionTabForClient: vi.fn(() => true) + }) +} + +async function launch( + params: unknown, + runtime: AgentLaunchRuntimeStub, + context: Partial = CAPABLE_CLIENT +) { + return AGENT_LAUNCH.handler(AGENT_LAUNCH.params.parse(params), rpcContext(runtime, context)) +} + +function chatActivation(): unknown { + return createStructuredSession.mock.calls[0]?.[0]?.activate +} + +beforeEach(() => { + createStructuredSession + .mockReset() + .mockResolvedValue({ ok: true, value: { sessionId: 'sess-1' } }) +}) + +describe('a paired client launching into an existing workspace', () => { + it("selects the new terminal as that client's tab only", async () => { + const runtime = selectionRuntime({ settings: {}, terminalPaneKey: PANE_KEY }) + + await launch(EXISTING_LAUNCH, runtime) + + expect(runtime.selectCreatedMobileSessionTabForClient).toHaveBeenCalledExactlyOnceWith( + 'wt-7', + expect.objectContaining({ tabId: TAB_ID, leafId: LEAF_ID }), + CALLER + ) + }) + + it('publishes the chat without activating it for everyone, then selects it by session for the caller', async () => { + const runtime = selectionRuntime({ settings: STRUCTURED_PREFERENCE }) + + const result = await launch(EXISTING_LAUNCH, runtime) + + expect(result.outcome.kind).toBe('structured') + expect(chatActivation()).toBe(false) + expect(runtime.selectCreatedMobileSessionTabForClient).toHaveBeenCalledExactlyOnceWith( + 'wt-7', + { sessionId: 'sess-1' }, + CALLER + ) + }) + + it('still reports the launch when selecting its tab fails', async () => { + const runtime = selectionRuntime({ settings: {}, terminalPaneKey: PANE_KEY }) + runtime.selectCreatedMobileSessionTabForClient.mockImplementationOnce(() => { + throw new Error('selection store unavailable') + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const result = await launch(EXISTING_LAUNCH, runtime) + + expect(result.outcome).toMatchObject({ kind: 'terminal', handle: 'term_1' }) + warn.mockRestore() + }) + + it('selects nothing when the runtime reported no pane for the terminal', async () => { + const runtime = selectionRuntime({ settings: {} }) + + await launch(EXISTING_LAUNCH, runtime) + + expect(runtime.selectCreatedMobileSessionTabForClient).not.toHaveBeenCalled() + }) +}) + +describe('launches that keep the host-wide behaviour', () => { + it('an in-process caller activates the chat and selects nothing per client', async () => { + const runtime = selectionRuntime({ settings: STRUCTURED_PREFERENCE }) + + await launch(EXISTING_LAUNCH, runtime, {}) + + expect(chatActivation()).toBe(true) + expect(runtime.selectCreatedMobileSessionTabForClient).not.toHaveBeenCalled() + }) + + it("the host's own desktop window, a runtime client with no paired device, still activates the chat", async () => { + const runtime = selectionRuntime({ settings: STRUCTURED_PREFERENCE }) + + await launch(EXISTING_LAUNCH, runtime, { + ...CAPABLE_CLIENT, + clientKind: 'runtime', + pairedDeviceId: undefined + }) + + expect(chatActivation()).toBe(true) + expect(runtime.selectCreatedMobileSessionTabForClient).not.toHaveBeenCalled() + }) + + it('a workspace-creating launch from a paired client keeps the create navigation', async () => { + const runtime = selectionRuntime({ settings: STRUCTURED_PREFERENCE }) + + await launch(CREATE_LAUNCH, runtime) + + expect(chatActivation()).toBe(true) + expect(runtime.selectCreatedMobileSessionTabForClient).not.toHaveBeenCalled() + }) +}) + +describe('a replayed launch', () => { + // The ledger admits against `Date.now()`, so the id must be dated now. + const OPERATION_ID = `${Date.now()}-000000000000000000000000000000dd` + let directory: string + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-caller-')) + const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `deps.store` is the only member `agent.launch` reads, and a member it omits throws on call. + setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) + }) + + afterEach(async () => { + setStructuredAgentSessionHost(null) + await rm(directory, { recursive: true, force: true }) + }) + + it('answers from the record without moving the caller again', async () => { + const params = AGENT_LAUNCH_REPLAY.params.parse({ + ...EXISTING_LAUNCH, + operationId: OPERATION_ID + }) + const first = selectionRuntime({ settings: {}, terminalPaneKey: PANE_KEY }) + await AGENT_LAUNCH_REPLAY.handler(params, rpcContext(first, CAPABLE_CLIENT)) + expect(first.selectCreatedMobileSessionTabForClient).toHaveBeenCalledOnce() + + const replay = selectionRuntime({ settings: {}, terminalPaneKey: PANE_KEY }) + await AGENT_LAUNCH_REPLAY.handler(params, rpcContext(replay, CAPABLE_CLIENT)) + + expect(replay.createTerminal).not.toHaveBeenCalled() + expect(replay.selectCreatedMobileSessionTabForClient).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts b/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts new file mode 100644 index 00000000000..b5fe4fe31f4 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts @@ -0,0 +1,57 @@ +/** + * Which view a launch moves: the requesting connection's, never the host's or another client's. + * + * A paired client (a phone, or a desktop client of a remote server) that launches into an existing + * workspace gets the new tab as its own selection, recorded the way `session.tabs.createTerminal` + * selects the tab it creates for its caller. In-process callers keep today's behaviour, and a + * launch that creates its workspace keeps `worktree.create`'s navigation, whose host activation is + * what runs the new workspace's setup. + */ + +import type { AgentLaunchResult, AgentLaunchTarget } from '../../../../shared/agent-launch-intent' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcContext } from '../core' + +/** The paired client whose view this launch should move, or null when it moves the host's. */ +export function agentLaunchCallerNavigationId( + target: AgentLaunchTarget, + context: Pick +): string | null { + if (target.kind !== 'existing' || context.clientKind === undefined) { + return null + } + return context.pairedDeviceId?.trim() || null +} + +/** Bookkeeping, never a gate: the agent already runs, so a failure here only leaves the view as it was. */ +export function selectAgentLaunchTabForCaller( + runtime: Pick, + result: AgentLaunchResult, + clientNavigationId: string +): void { + const { outcome } = result + // Found by pane or session, never by a predicted tab id. + const surface = + outcome.kind === 'terminal' + ? outcome.paneKey + ? parsePaneKey(outcome.paneKey) + : null + : { sessionId: outcome.sessionId } + if (!surface) { + return + } + try { + if ( + !runtime.selectCreatedMobileSessionTabForClient( + result.worktreeId, + surface, + clientNavigationId + ) + ) { + console.warn('[agent-launch] the launch ran; its tab was not published to select') + } + } catch (error) { + console.warn('[agent-launch] the launch ran; selecting its tab for the caller did not', error) + } +} diff --git a/src/main/runtime/rpc/methods/agent-launch-failure-code.ts b/src/main/runtime/rpc/methods/agent-launch-failure-code.ts new file mode 100644 index 00000000000..eac21c7c00e --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-failure-code.ts @@ -0,0 +1,57 @@ +import type { AgentLaunchTarget } from '../../../../shared/agent-launch-intent' +import { + WorktreeCreateCollisionError, + WORKTREE_CREATE_COLLISION_CODE +} from '../../../../shared/new-workspace/worktree-create-collision' +import { + AgentLaunchPaneAlreadyLiveError, + AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE +} from '../../../../shared/agent-launch-pane-already-live' +import { + AgentLaunchSessionAlreadyExistsError, + AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE +} from '../../../../shared/agent-launch-session-already-exists' +import type { TerminalSpawnDispatch } from '../../../agent-launch/agent-launch-not-started' + +/** Long enough for every code this path raises, with room for one a later guard adds. */ +const LAUNCH_FAILURE_CODE_MAX_LENGTH = 128 + +/** + * This path raises its refusals as the thrown code, the way the method's own guards do — and the + * recorded code is what a replay answers with, so it is worth keeping. + * + * Bounded because a code is an identifier but `error.message` is free text: an errno sentence + * carrying an absolute path arrives here as one, and it would be written into a ledger file that is + * re-serialized whole on every subsequent operation. Bounded on the way IN only. A length check in + * `isAgentSessionOperationRow` would reject rows this same build wrote, and one rejected row costs + * the entire store. + */ +export function agentLaunchFailureCode(error: unknown): string { + const code = error instanceof Error ? error.message : '' + return code.length > 0 ? code.slice(0, LAUNCH_FAILURE_CODE_MAX_LENGTH) : 'agent_launch_failed' +} + +/** + * Only a typed refusal raised before anything was created proves the claimed launch had no effects. + * A live reserved pane or an existing reserved session proves it only for an existing workspace; on + * create-worktree the workspace already exists by the time the surface is refused. + */ +export function launchFailureWithoutEffectsCode( + error: unknown, + targetKind: AgentLaunchTarget['kind'], + terminalSpawn: TerminalSpawnDispatch +): string | null { + if (error instanceof WorktreeCreateCollisionError) { + return WORKTREE_CREATE_COLLISION_CODE + } + if (error instanceof AgentLaunchPaneAlreadyLiveError && targetKind === 'existing') { + return AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE + } + if (error instanceof AgentLaunchSessionAlreadyExistsError && targetKind === 'existing') { + return AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE + } + if (terminalSpawn.failedBeforeDispatch(error) && targetKind === 'existing') { + return agentLaunchFailureCode(error) + } + return null +} diff --git a/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts b/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts index 5551c4de354..1df2c1f7433 100644 --- a/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts @@ -64,7 +64,7 @@ describe('agent.launch with the real floating workspace resolver', () => { expect(structuredHost).not.toHaveBeenCalled() expect(createTerminal).toHaveBeenCalledExactlyOnceWith( `id:${FLOATING_TERMINAL_WORKTREE_ID}`, - { startupAgent: 'claude' } + { startupAgent: 'claude', onPtySpawnDispatched: expect.any(Function) } ) expect(result).toMatchObject({ worktreeId: FLOATING_TERMINAL_WORKTREE_ID, diff --git a/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts b/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts new file mode 100644 index 00000000000..6b7adb8dbfc --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts @@ -0,0 +1,171 @@ +/** + * A terminal launch that fails before its spawn is requested — no launch command, runtime + * unavailable — created nothing, so a named operation settles as failed with its real cause. + * Once the request has left, a failure proves nothing and the outcome stays unknown. + */ + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentSessionRecordStore } from '../../agent-session-record-store' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { RpcDispatcher } from '../dispatcher' +import { methodNamed, runtimeStub, type AgentLaunchRuntimeStub } from './agent-launch.test-fixture' + +vi.mock('./structured-agent-session-create', () => ({ + createStructuredAgentSessionForWorktree: async () => ({ + ok: true, + value: { sessionId: 'sess-1' } + }) +})) + +const { AGENT_LAUNCH_METHODS } = await import('./agent-launch') +const AGENT_LAUNCH_REPLAY = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launchReplay') + +const EXISTING_LAUNCH = { agent: 'claude', target: { kind: 'existing', worktree: 'id:wt-7' } } +const CREATE_LAUNCH = { + agent: 'claude', + target: { kind: 'create-worktree', create: { repo: 'id:repo-1', name: 'task' } } +} +const NO_LAUNCH_COMMAND = 'Could not build launch command for claude.' +type Launch = typeof EXISTING_LAUNCH | typeof CREATE_LAUNCH + +/** The create throws; `afterDispatch` says whether the spawn request had already left. */ +function failingCreate(runtime: AgentLaunchRuntimeStub, error: Error, afterDispatch: boolean) { + runtime.createTerminal.mockImplementation( + async (_selector: string, options?: Record) => { + const dispatched = options?.onPtySpawnDispatched + if (afterDispatch && typeof dispatched === 'function') { + dispatched() + } + throw error + } + ) +} + +describe('a launch whose terminal fails', () => { + // The ledger admits against `Date.now()`, so the ids must be dated now. + const OPERATION_ID = `${Date.now()}-000000000000000000000000000000cc` + const OTHER_OPERATION_ID = `${Date.now()}-000000000000000000000000000000dd` + let directory: string + let store: AgentSessionRecordStore + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-prestart-')) + store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `deps.store` is the only member `agent.launch` reads, and a member it omits throws on call. + setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) + }) + + afterEach(async () => { + setStructuredAgentSessionHost(null) + await rm(directory, { recursive: true, force: true }) + }) + + function outcomeOf(operationId: string) { + return store.listOperationRows().find((row) => row.operationId === operationId)?.outcome + } + + async function replay( + runtime: AgentLaunchRuntimeStub, + launch: Launch, + operationId: string = OPERATION_ID + ) { + const dispatcher = new RpcDispatcher({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture implements every runtime method reached by agent.launch and dispatcher metadata. + runtime: { ...runtime, getRuntimeId: () => 'runtime-1' } as unknown as OrcaRuntimeService, + methods: AGENT_LAUNCH_METHODS + }) + return dispatcher.dispatch({ + id: 'request-1', + authToken: 'token', + method: 'agent.launchReplay', + params: AGENT_LAUNCH_REPLAY.params.parse({ ...launch, operationId }) + }) + } + + it('reports a failure before the spawn request with its real cause and records it', async () => { + const runtime = runtimeStub({ settings: {} }) + failingCreate(runtime, new Error(NO_LAUNCH_COMMAND), false) + + const response = await replay(runtime, EXISTING_LAUNCH) + + expect(response).toMatchObject({ ok: false, error: { message: NO_LAUNCH_COMMAND } }) + expect(outcomeOf(OPERATION_ID)).toMatchObject({ status: 'failed', code: NO_LAUNCH_COMMAND }) + }) + + it('keeps a stable runtime code such as runtime_unavailable', async () => { + const runtime = runtimeStub({ settings: {} }) + failingCreate(runtime, new Error('runtime_unavailable'), false) + + const response = await replay(runtime, EXISTING_LAUNCH) + + expect(response).toMatchObject({ ok: false, error: { code: 'runtime_unavailable' } }) + }) + + it('answers a retry of the same operation from the record instead of launching again', async () => { + const first = runtimeStub({ settings: {} }) + failingCreate(first, new Error(NO_LAUNCH_COMMAND), false) + await replay(first, EXISTING_LAUNCH) + + const retry = runtimeStub({ settings: {} }) + const response = await replay(retry, EXISTING_LAUNCH) + + expect(retry.createTerminal).not.toHaveBeenCalled() + expect(response).toMatchObject({ ok: false, error: { message: NO_LAUNCH_COMMAND } }) + }) + + it('stays unknown when the failure came after the spawn request left', async () => { + // An SSH or daemon spawn whose reply was lost may still have started an agent. + const runtime = runtimeStub({ settings: {} }) + failingCreate(runtime, new Error('ssh_channel_closed'), true) + + const response = await replay(runtime, EXISTING_LAUNCH) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'agent_session_operation_unknown' } + }) + expect(outcomeOf(OPERATION_ID)?.status).toBe('unknown') + }) + + it('stays unknown when another launch saw the same error before its own spawn request', async () => { + // A failed pane spawn rejects one error into the spawner and into a create waiting on that pane. + const shared = new Error('ssh_channel_closed') + const waiting = runtimeStub({ settings: {} }) + failingCreate(waiting, shared, false) + await replay(waiting, EXISTING_LAUNCH, OTHER_OPERATION_ID) + expect(outcomeOf(OTHER_OPERATION_ID)?.status).toBe('failed') + + const spawner = runtimeStub({ settings: {} }) + failingCreate(spawner, shared, true) + const response = await replay(spawner, EXISTING_LAUNCH) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'agent_session_operation_unknown' } + }) + expect(outcomeOf(OPERATION_ID)?.status).toBe('unknown') + }) + + it('stays unknown for a launch that created its workspace first', async () => { + const runtime = runtimeStub({ settings: {} }) + // No startup terminal came back, so the launch builds its own in the new workspace. + runtime.createManagedWorktree.mockResolvedValueOnce({ + worktree: { id: 'wt-new' }, + startupTerminal: undefined + }) + failingCreate(runtime, new Error(NO_LAUNCH_COMMAND), false) + + const response = await replay(runtime, CREATE_LAUNCH) + + expect(runtime.createTerminal).toHaveBeenCalledTimes(1) + expect(response).toMatchObject({ + ok: false, + error: { code: 'agent_session_operation_unknown' } + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts index 3d6981ed4a1..336cd4c212b 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts @@ -585,7 +585,8 @@ describe('the inner attach reserves under its own id', () => { }) expect(forwarded).toEqual({ decision: 'refused', - code: 'agent_session_operation_conflict' + code: 'agent_session_operation_conflict', + details: { reason: 'operationIdReused' } }) const derived = deriveAgentLaunchChildOperationId(OPERATION_ID) diff --git a/src/main/runtime/rpc/methods/agent-launch-replay.ts b/src/main/runtime/rpc/methods/agent-launch-replay.ts index 223d4a56c9e..ec9269f4efe 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.ts @@ -11,8 +11,8 @@ * resolving the caller's worktree selector, because a selector resolution is a live precondition * and a replay must not be able to fail on one: an operation that already ran has an answer, and * re-deciding it against today's world is how a recorded success becomes a fresh refusal the client - * then retries as a second effect. `admitAgentSessionMutation` puts the ledger ahead of the lease - * and the fence for that same reason. + * then retries as a second effect. `admitAgentSessionMutation` puts the ledger ahead of the writer + * lease for that same reason. */ import { deriveAgentLaunchChildOperationId } from '../../../../shared/agent-launch-operation' diff --git a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts index 0037d6fc35e..11762137ba2 100644 --- a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts +++ b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts @@ -63,9 +63,9 @@ export async function commitStructuredAgentSessionLaunchPrompt(args: { // Settlement can fail after the journal append. Re-read the authoritative row before asking // the caller to resend, otherwise a retry creates a duplicate turn. try { - const committed = args.host - .journalSnapshot(args.sessionId) - .submissions.find((submission) => submission.clientMessageId === clientMessageId) + const committed = (await args.host.journalSnapshot(args.sessionId)).submissions.find( + (submission) => submission.clientMessageId === clientMessageId + ) if (committed) { return clientMessageId } diff --git a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts index 412d947d441..3f184cd50b5 100644 --- a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts +++ b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts @@ -4,8 +4,8 @@ * Both halves are deliberately the plain, user-facing forms: a structured session created for the * worktree exactly as `agentSession.create` creates one, and a terminal agent created exactly as a * new agent tab is. Orchestration's own factories are NOT reusable here — a worker's session - * carries a dispatch hold, a mailbox and a background tab that a launch the user asked for must - * not take — which is why the executor injects this rather than branching. + * carries a redrive subscription, a mailbox and a background tab that a launch the user asked for + * must not take — which is why the executor injects this rather than branching. * * Delivering the launch text is here for the same reason: it is the wire-shaped half. Each surface * takes it differently — a structured session commits it to a transcript, a terminal agent takes it @@ -14,10 +14,6 @@ */ import { randomUUID } from 'node:crypto' -import { tuiAgentToAgentKind } from '../../../../shared/agent-kind' -import { launchSourceSchema } from '../../../../shared/telemetry-property-schemas' -import type { TuiAgent } from '../../../../shared/tui-agent' -import type { TerminalCreateOptions } from '../../runtime-terminal-contracts' import { narrowStructuredLaunchSeedOptions } from '../../../../shared/native-chat-session-option-defaults' import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation' import { structuredAgentSessionTabId } from '../../../../shared/structured-agent-session-projection' @@ -36,12 +32,19 @@ import { AgentLaunchSessionAlreadyExistsError } from '../../../../shared/agent-l import { createStructuredAgentSessionId } from '../../../../shared/structured-agent-session-create' import { toAgentLaunchPreferences } from '../../../../shared/agent-launch-preferences' import { paneIdentity } from '../../runtime-terminal-pane-identity' +import { + trackTerminalSpawnDispatch, + type TerminalSpawnDispatch +} from '../../../agent-launch/agent-launch-not-started' /** Replay-safe launches keep the nested attach in the same stable caller namespace as the launch. */ export function agentLaunchSurfaceFactory( context: RpcContext, attachOperationId?: string, - operationCallerKey?: string + operationCallerKey?: string, + // False when the launch selects the chat for its paired caller instead of for everyone. + activateChat = true, + terminalSpawn: TerminalSpawnDispatch = trackTerminalSpawnDispatch() ): AgentLaunchSurfaceFactory { return { createStructuredSession: async ({ @@ -77,7 +80,7 @@ export function agentLaunchSurfaceFactory( ...(seeded ? { options: seeded } : {}), ...(tabId ? { tabId } : {}), // The user asked for this chat, so it takes the surface — unlike a dispatched worker. - activate: true + activate: activateChat }) if (!created.ok) { // The caller named this session, so a taken id is its answer, not an opaque refusal; and not @@ -118,7 +121,7 @@ export function agentLaunchSurfaceFactory( options }) => { const launchPreferences = toAgentLaunchPreferences(options) - const terminal = await context.runtime.createTerminal(`id:${worktreeId}`, { + const created = context.runtime.createTerminal(`id:${worktreeId}`, { // The agent id is not a shell command — `cursor` is the desktop app, its CLI is // `cursor-agent` — so the runtime builds the configured launcher. startupAgent: agent, @@ -131,8 +134,10 @@ export function agentLaunchSurfaceFactory( ...(launchPreferences ? { launchPreferences } : {}), // A live reserved pane would be attached, not launched into, so the runtime refuses it. ...(paneKey ? { ...paneIdentity(paneKey), requireFreshPane: true } : {}), - ...agentLaunchTelemetry(agent, launchSource) + ...(launchSource ? { launchSource } : {}), + onPtySpawnDispatched: terminalSpawn.onPtySpawnDispatched }) + const terminal = await created.catch(terminalSpawn.rethrow) return { handle: terminal.handle, // The runtime already minted this pane and baked it into the PTY's env and its own reveal; @@ -150,34 +155,6 @@ export function agentLaunchSurfaceFactory( } } -/** - * The `agent_started` triple, of which only `launch_source` came from the caller. - * - * `agent_kind` and `request_kind` are derived rather than accepted — the host already knows both, - * and a value it derives is a value a caller cannot misreport. `request_kind` is always `new` - * because a launch reusing a terminal returns before any surface is created. - * - * An unrecognized `launch_source` drops the telemetry and starts the agent anyway. The wire keeps - * the arm set open so an older host cannot refuse a newer client's launch over a label, which is - * only honoured if the refusal does not reappear here: attribution is bookkeeping, and bookkeeping - * must not gate the user's launch. - */ -function agentLaunchTelemetry( - agent: TuiAgent, - launchSource: string | undefined -): Pick { - const parsed = launchSourceSchema.safeParse(launchSource) - return parsed.success - ? { - telemetry: { - agent_kind: tuiAgentToAgentKind(agent), - launch_source: parsed.data, - request_kind: 'new' - } - } - : {} -} - function requireInstalledHost(): StructuredAgentSessionHost { const host = getStructuredAgentSessionHost() if (!host) { diff --git a/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts b/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts index 41081daeb99..ac66912e747 100644 --- a/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts +++ b/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts @@ -66,6 +66,7 @@ export async function deliverTerminalAgentLaunchPrompt(args: { return false } const sent = await args.runtime.sendTerminalAgentPrompt(args.handle, args.text, { + inputKind: 'launch', // Paired: together these take the queued path, which settles an unobserved turn start into // an `input_accepted` receipt rather than raising it. Without the id the write is verified // strictly and a slow first turn throws. diff --git a/src/main/runtime/rpc/methods/agent-launch.test.ts b/src/main/runtime/rpc/methods/agent-launch.test.ts index f44ddb4978b..75045cf74c4 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test.ts @@ -331,6 +331,23 @@ describe('the worktree factory', () => { }) }) + it("attributes a create by the launch's own source, never a copy inside the create payload", async () => { + const createWithSource = { + ...CREATE_LAUNCH, + target: { + kind: 'create-worktree', + create: { ...CREATE_LAUNCH.target.create, launchSource: 'cli' } + } + } + const named = runtimeStub({ settings: {} }) + await launch({ ...createWithSource, launchSource: 'onboarding' }, named) + expect(createArgs(named)).toMatchObject({ startupLaunchSource: 'onboarding' }) + + const unnamed = runtimeStub({ settings: {} }) + await launch(createWithSource, unnamed) + expect(createArgs(unnamed)).not.toHaveProperty('startupLaunchSource') + }) + it('preserves an explicit no-arguments value for an agent-first worktree create', async () => { const runtime = runtimeStub({ settings: {} }) await launch({ ...CREATE_LAUNCH, agentArgs: null }, runtime) @@ -461,7 +478,10 @@ describe('the terminal factory', () => { const runtime = runtimeStub({ createSupport: { supported: false, reason: 'wsl' } }) const result = await launch(CREATE_LAUNCH, runtime) - expect(runtime.createTerminal).toHaveBeenCalledWith('id:wt-new', { startupAgent: 'claude' }) + expect(runtime.createTerminal).toHaveBeenCalledWith('id:wt-new', { + startupAgent: 'claude', + onPtySpawnDispatched: expect.any(Function) + }) expect(createStructuredSession).not.toHaveBeenCalled() expect(result.outcome).toEqual({ kind: 'terminal', handle: 'term_1' }) // Never a failed launch, and never a silent downgrade. @@ -481,7 +501,10 @@ describe('the terminal factory', () => { expect(runtime.showManagedTerminalWorkspace).not.toHaveBeenCalled() // Resolved to an id first: everything below re-prefixes it, so a raw selector reaches the // runtime as `id:id:wt-7`. - expect(runtime.createTerminal).toHaveBeenCalledWith('id:wt-7', { startupAgent: 'grok' }) + expect(runtime.createTerminal).toHaveBeenCalledWith('id:wt-7', { + startupAgent: 'grok', + onPtySpawnDispatched: expect.any(Function) + }) expect(result.worktreeId).toBe('wt-7') }) }) @@ -548,15 +571,14 @@ describe('launch inputs that cross the wire', () => { }) }) - it('derives agent_kind and request_kind, taking only launch_source from the caller', async () => { + it("hands the caller's launch_source to the runtime, which attributes the launch", async () => { const runtime = runtimeStub({ settings: {} }) await launch({ ...EXISTING_LAUNCH, launchSource: 'source_control_recovery' }, runtime) - expect(terminalOptions(runtime).telemetry).toEqual({ - agent_kind: 'claude-code', - launch_source: 'source_control_recovery', - request_kind: 'new' - }) + // The runtime derives agent_kind and request_kind from the agent it builds, so the handler + // forwards only the one member it cannot know, and never a prebuilt triple. + expect(terminalOptions(runtime)).toMatchObject({ launchSource: 'source_control_recovery' }) + expect(terminalOptions(runtime)).not.toHaveProperty('telemetry') }) it('starts the agent anyway when launch_source is one this build has never heard of', async () => { @@ -567,16 +589,16 @@ describe('launch inputs that cross the wire', () => { ) // The whole point of the open arm set: attribution is bookkeeping, and bookkeeping must never - // gate a user action. The row is dropped; the launch is not. + // gate a user action. The runtime records it as `unknown`; the launch still starts. expect(result.outcome).toEqual({ kind: 'terminal', handle: 'term_1' }) - expect(terminalOptions(runtime)).not.toHaveProperty('telemetry') + expect(terminalOptions(runtime)).toMatchObject({ launchSource: 'a_surface_added_later' }) }) - it('sends no telemetry at all when the caller named no launch source', async () => { + it('names no launch source when the caller named none', async () => { const runtime = runtimeStub({ settings: {} }) await launch(EXISTING_LAUNCH, runtime) - expect(terminalOptions(runtime)).not.toHaveProperty('telemetry') + expect(terminalOptions(runtime)).not.toHaveProperty('launchSource') }) it('keeps a structured preference when the cwd names the workspace root', async () => { diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index eee9f2a48c1..1c7448e5ca2 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -31,20 +31,24 @@ import { WorktreeCreateCollisionError, WORKTREE_CREATE_COLLISION_CODE } from '../../../../shared/new-workspace/worktree-create-collision' -import { - AgentLaunchPaneAlreadyLiveError, - AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE -} from '../../../../shared/agent-launch-pane-already-live' -import { - AgentLaunchSessionAlreadyExistsError, - AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE -} from '../../../../shared/agent-launch-session-already-exists' import { executeAgentLaunch } from '../../../agent-launch/agent-launch-executor' +import { + trackTerminalSpawnDispatch, + type TerminalSpawnDispatch +} from '../../../agent-launch/agent-launch-not-started' import type { OrcaRuntimeService } from '../../orca-runtime' import { defineMethod, type RpcContext } from '../core' import { admitAgentLaunchOperation, agentLaunchOperationCallerKey } from './agent-launch-replay' import { AgentLaunch, AgentLaunchReplay, type AgentLaunchParams } from './agent-launch-schemas' import { agentLaunchSurfaceFactory } from './agent-launch-surfaces' +import { + agentLaunchFailureCode, + launchFailureWithoutEffectsCode +} from './agent-launch-failure-code' +import { + agentLaunchCallerNavigationId, + selectAgentLaunchTabForCaller +} from './agent-launch-caller-selection' import { agentLaunchWorkspaceFactory } from './agent-launch-worktree-creation' /** @@ -138,18 +142,30 @@ async function resolveUnlaunchedIntent( return intent } -function runAgentLaunch( +async function runAgentLaunch( intent: AgentLaunchIntent, context: RpcContext, attachOperationId?: string, - operationCallerKey?: string + operationCallerKey?: string, + terminalSpawn?: TerminalSpawnDispatch ): Promise { - return executeAgentLaunch({ + const callerNavigationId = agentLaunchCallerNavigationId(intent.target, context) + const result = await executeAgentLaunch({ runtime: context.runtime, intent, - surfaces: agentLaunchSurfaceFactory(context, attachOperationId, operationCallerKey), + surfaces: agentLaunchSurfaceFactory( + context, + attachOperationId, + operationCallerKey, + callerNavigationId === null, + terminalSpawn + ), workspaces: agentLaunchWorkspaceFactory(context, intent.agent) }) + if (callerNavigationId !== null) { + selectAgentLaunchTabForCaller(context.runtime, result, callerNavigationId) + } + return result } /** @@ -184,52 +200,17 @@ function settleQuietly(settlement: Promise): Promise { }) } -/** Long enough for every code this path raises, with room for one a later guard adds. */ -const LAUNCH_FAILURE_CODE_MAX_LENGTH = 128 - -/** - * This path raises its refusals as the thrown code, the way the method's own guards do — and the - * recorded code is what a replay answers with, so it is worth keeping. - * - * Bounded because a code is an identifier but `error.message` is free text: an errno sentence - * carrying an absolute path arrives here as one, and it would be written into a ledger file that is - * re-serialized whole on every subsequent operation. Bounded on the way IN only. A length check in - * `isAgentSessionOperationRow` would reject rows this same build wrote, and one rejected row costs - * the entire store. - */ -function agentLaunchFailureCode(error: unknown): string { - const code = error instanceof Error ? error.message : '' - return code.length > 0 ? code.slice(0, LAUNCH_FAILURE_CODE_MAX_LENGTH) : 'agent_launch_failed' -} - -/** - * Only a typed refusal raised before anything was created proves the claimed launch had no effects. - * A live reserved pane or an existing reserved session proves it only for an existing workspace; on - * create-worktree the workspace already exists by the time the surface is refused. - */ -function launchFailureWithoutEffectsCode( - error: unknown, - targetKind: AgentLaunchTarget['kind'] -): string | null { - if (error instanceof WorktreeCreateCollisionError) { - return WORKTREE_CREATE_COLLISION_CODE - } - if (error instanceof AgentLaunchPaneAlreadyLiveError && targetKind === 'existing') { - return AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE - } - if (error instanceof AgentLaunchSessionAlreadyExistsError && targetKind === 'existing') { - return AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE - } - return null -} - type ActiveAgentLaunch = { fingerprint: string promise: Promise } class AgentLaunchExecutionError extends Error { - constructor(cause: unknown) { + constructor( + cause: unknown, + /** Decided once, by the launch that ran; a later reader cannot re-derive it from the error. */ + readonly failedWithoutEffects: boolean + ) { super('agent_session_operation_unknown', { cause }) } } @@ -268,15 +249,26 @@ async function executeReplaySafeAgentLaunch( await settleQuietly(admission.fail(agentLaunchFailureCode(error))) throw error } + const terminalSpawn = trackTerminalSpawnDispatch() let result: AgentLaunchResult try { - result = await runAgentLaunch(intent, context, admission.attachOperationId, admission.callerKey) + result = await runAgentLaunch( + intent, + context, + admission.attachOperationId, + admission.callerKey, + terminalSpawn + ) } catch (error) { - const failedWithoutEffects = launchFailureWithoutEffectsCode(error, intent.target.kind) + const failedWithoutEffects = launchFailureWithoutEffectsCode( + error, + intent.target.kind, + terminalSpawn + ) if (failedWithoutEffects) { await settleQuietly(admission.fail(failedWithoutEffects)) } - throw new AgentLaunchExecutionError(error) + throw new AgentLaunchExecutionError(error, failedWithoutEffects !== null) } // Settlement is bookkeeping; failure leaves the truthful `unknown` refusal for later retries. await settleQuietly(admission.settle(result)) @@ -327,7 +319,7 @@ export const AGENT_LAUNCH_METHODS = [ code: WORKTREE_CREATE_COLLISION_CODE }) } - if (launchFailureWithoutEffectsCode(error.cause, params.target.kind)) { + if (error.failedWithoutEffects) { throw error.cause } throw new Error('agent_session_operation_unknown', { cause: error.cause }) diff --git a/src/main/runtime/rpc/methods/native-chat-subscription-token.test.ts b/src/main/runtime/rpc/methods/native-chat-subscription-token.test.ts new file mode 100644 index 00000000000..e6b3aae471e --- /dev/null +++ b/src/main/runtime/rpc/methods/native-chat-subscription-token.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest' +import { RuntimeSubscriptionRegistry } from '../../runtime-subscription-registry' +import type { RpcContext } from '../core' + +vi.mock('../../../native-chat/transcript-watch', () => ({ + readNativeChatTranscriptTail: vi.fn(), + subscribeNativeChatTranscript: () => Promise.resolve({ unsubscribe: () => {}, watching: true }) +})) + +import { NATIVE_CHAT_METHODS } from './native-chat' + +type Handler = ( + params: unknown, + ctx: RpcContext, + emit: (value: unknown) => void +) => Promise + +function handler(name: string): Handler { + const method = NATIVE_CHAT_METHODS.find((candidate) => candidate.name === name) + if (!method) { + throw new Error(`${name} not registered`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: both methods take (params, ctx[, emit]); the test only calls them with that shape. + return method.handler as Handler +} + +/** One phone connection on a runtime whose subscriptions live in the real registry. */ +function phoneConnection(): { + subscribe: (subscriptionId: string) => Promise + unsubscribe: (subscriptionId: string) => Promise +} { + const registry = new RuntimeSubscriptionRegistry() + const context: RpcContext = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handlers under test only touch these three registry-backed members. + runtime: { + registerSubscriptionCleanup: registry.register.bind(registry), + cleanupSubscription: registry.cleanup.bind(registry), + cleanupSubscriptionsByPrefix: registry.cleanupByPrefix.bind(registry) + } as unknown as RpcContext['runtime'], + connectionId: 'phone-connection', + clientKind: 'mobile' + } + return { + subscribe: async (subscriptionId) => { + const emitted: unknown[] = [] + await handler('nativeChat.subscribe')( + { agent: 'claude', sessionId: 'session', subscriptionId }, + context, + (value) => emitted.push(value) + ) + return emitted + }, + unsubscribe: async (subscriptionId) => { + await handler('nativeChat.unsubscribe')({ subscriptionId }, context, () => {}) + } + } +} + +describe('nativeChat subscription tokens on one connection', () => { + it('keeps two feeds of the same chat apart when each has its own token', async () => { + const phone = phoneConnection() + const under = await phone.subscribe('claude:session:under') + const top = await phone.subscribe('claude:session:top') + expect(under).toEqual([]) + expect(top).toEqual([]) + + await phone.unsubscribe('claude:session:top') + expect(top).toEqual([{ type: 'end' }]) + expect(under).toEqual([]) + }) + + it('ends the older feed when a second one reuses its token', async () => { + const phone = phoneConnection() + const older = await phone.subscribe('claude:session') + const newer = await phone.subscribe('claude:session') + expect(older).toEqual([{ type: 'end' }]) + expect(newer).toEqual([]) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-caller-workspace.ts b/src/main/runtime/rpc/methods/orchestration-caller-workspace.ts index 556938eef23..a3bbe7f01a3 100644 --- a/src/main/runtime/rpc/methods/orchestration-caller-workspace.ts +++ b/src/main/runtime/rpc/methods/orchestration-caller-workspace.ts @@ -14,12 +14,18 @@ */ import type { OrcaRuntimeService } from '../../orca-runtime' +import type { OrchestrationSessionCaller } from '../../orchestration/orchestration-caller-identity' import { isStructuredWorkerHandle } from '../../structured-worker-identity' export async function resolveDispatchCallerWorktreeId( runtime: Pick, - callerHandle: string + callerHandle: string, + callerSession: OrchestrationSessionCaller | undefined ): Promise { + // A session caller's workspace is on its record, whichever owner (chat or terminal) holds it. + if (callerSession) { + return callerSession.workspaceId + } if (isStructuredWorkerHandle(callerHandle)) { const worktreeId = runtime.getOrchestrationDispatchAuthority?.(callerHandle)?.worktreeId ?? null if (worktreeId) { diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-abandon.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-abandon.test.ts index 983bd20ad95..cf607524674 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-abandon.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-abandon.test.ts @@ -7,13 +7,12 @@ const released: string[] = [] vi.mock('./orchestration-structured-worker-session', () => ({ releaseStructuredWorkerSession: (dispatchId: string) => released.push(dispatchId), createStructuredWorkerSession: vi.fn(), - sendStructuredWorkerPreamble: vi.fn(), - structuredWorkerHoldId: (dispatchId: string) => `orchestration:dispatch:${dispatchId}` + sendStructuredWorkerPreamble: vi.fn() })) const harness = createOrchestrationRpcHarness() -describe('workerAbandon settles the structured hold', () => { +describe('workerAbandon settles the structured worker session', () => { let state: OrchestrationRpcState beforeEach(() => { @@ -37,10 +36,10 @@ describe('workerAbandon settles the structured hold', () => { return started.dispatch.id } - it('releases the hold when the dispatch actually settles', async () => { + it('releases the worker session when the dispatch actually settles', async () => { const dispatchId = await startedDispatch() - // Without this, the resume-capable hold outlives settlement: the provider child can never be - // evicted and host crash recovery keeps respawning an abandoned worker. + // Without this, the redrive subscription outlives settlement and keeps nudging a session no + // dispatch owns. await harness.call('orchestration.workerAbandon', { dispatch: dispatchId }, state.ctx) expect(released).toEqual([dispatchId]) }) diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts index a0d0426509b..b669233c31f 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts @@ -47,6 +47,7 @@ function installHost(options: { close?: () => Promise setSessionTabVisibility?: () => Promise historyThrows?: boolean + tabListed?: boolean }) { const record = options.record === undefined @@ -63,6 +64,10 @@ function installHost(options: { let closed = false hostRef.current = { deps: { store: { getRecord: () => record } }, + getPersistedVisibleSessionTabIndex: () => ({ + present: true, + sessionIds: options.tabListed ? [IDENTITY.sessionId] : [] + }), hasSession: () => (closed ? false : (options.hasSession ?? true)), setSessionTabVisibility: options.setSessionTabVisibility ?? (async () => {}), close: @@ -84,7 +89,7 @@ describe('structured worker observation', () => { hostRef.current = null }) - it('is unverifiable, never exited, when the host is not installed', () => { + it('is unverifiable, never exited, when the host is not installed', async () => { // Not being able to look is not a death certificate. expect(observeStructuredWorker(IDENTITY)).toEqual({ status: 'unverifiable', @@ -92,12 +97,12 @@ describe('structured worker observation', () => { }) }) - it('is live when the host holds the session under a live native lease', () => { + it('is live when the host holds the session under a live native lease', async () => { installHost({}) expect(observeStructuredWorker(IDENTITY).status).toBe('live') }) - it('is exited only on a released lease with death evidence', () => { + it('is exited only on a released lease with death evidence', async () => { installHost({ claimStatus: 'released', deathEvidence: { kind: 'exit-observed', detail: 'x', observedAt: 1 } @@ -105,7 +110,7 @@ describe('structured worker observation', () => { expect(observeStructuredWorker(IDENTITY).status).toBe('exited') }) - it('is unverifiable when a terminal an older build recorded holds the lease', () => { + it('is unverifiable when a terminal an older build recorded holds the lease', async () => { installHost({ claimStatus: 'conflicted' }) expect(observeStructuredWorker(IDENTITY).status).toBe('unverifiable') }) @@ -127,22 +132,30 @@ describe('structured worker stop', () => { }) }) - it.each([ - { hasSession: false }, - { claimStatus: 'conflicted' }, - { claimStatus: 'released' }, - { record: null } - ])('retains without positive exit evidence: %j', async (options) => { - installHost({ ...options, close: async () => {} }) - const retireStructuredAgentSessionTabFromSnapshot = vi.fn() - const result = await stopStructuredWorker(IDENTITY, 'd1', { - forgetStructuredSessionMail: vi.fn(), - retireStructuredAgentSessionTabFromSnapshot + it('settles a released lease whose stop was unproven: nothing is left running to close', async () => { + // A release that could not prove its stop sent no signal and left the verdict `unverifiable`; + // closing the chat is the user's action, so that bookkeeping does not refuse it. + installHost({ claimStatus: 'released', close: async () => {} }) + await expect(stopStructuredWorker(IDENTITY, 'd1')).resolves.toEqual({ + stopped: true, + closeAttempted: true }) - expect(result).toMatchObject({ stopped: false, closeAttempted: true }) - expect(retireStructuredAgentSessionTabFromSnapshot).not.toHaveBeenCalled() }) + it.each([{ hasSession: false }, { claimStatus: 'conflicted' }, { record: null }])( + 'retains without positive exit evidence: %j', + async (options) => { + installHost({ ...options, close: async () => {} }) + const retireStructuredAgentSessionTabFromSnapshot = vi.fn() + const result = await stopStructuredWorker(IDENTITY, 'd1', { + forgetStructuredSessionMail: vi.fn(), + retireStructuredAgentSessionTabFromSnapshot + }) + expect(result).toMatchObject({ stopped: false, closeAttempted: true }) + expect(retireStructuredAgentSessionTabFromSnapshot).not.toHaveBeenCalled() + } + ) + it('retains when the close throws, and admits the close was issued', async () => { installHost({ close: async () => { @@ -191,9 +204,9 @@ describe('structured worker output', () => { hostRef.current = null }) - it('round-trips the journal through the archive and back out of a released read', () => { + it('round-trips the journal through the archive and back out of a released read', async () => { installHost({}) - const live = readStructuredWorkerJournal({ + const live = await readStructuredWorkerJournal({ identity: IDENTITY, dispatchId: 'd1', workerState: 'ready', @@ -201,7 +214,7 @@ describe('structured worker output', () => { agent: 'claude' }) expect(live.source).toBe('transcript') - const archive = captureStructuredWorkerArchive(IDENTITY, 'claude') + const archive = await captureStructuredWorkerArchive(IDENTITY, 'claude') hostRef.current = null const archived = readArchivedStructuredJournal({ dispatchId: 'd1', @@ -219,7 +232,7 @@ describe('structured worker output', () => { expect(archived.sourceIdentity).not.toBe(live.sourceIdentity) }) - it('redacts dispatch capabilities from the archived journal', () => { + it('redacts dispatch capabilities from the archived journal', async () => { installHost({ items: [ { @@ -233,13 +246,13 @@ describe('structured worker output', () => { } as unknown as AgentJournalRenderItem ] }) - const archive = captureStructuredWorkerArchive(IDENTITY, 'claude') + const archive = await captureStructuredWorkerArchive(IDENTITY, 'claude') expect(JSON.stringify(archive)).not.toContain('dcap_aaa') expect(JSON.stringify(archive)).toContain('[dispatch capability redacted]') }) - it('refuses to read a session the host no longer holds', () => { - expect(() => + it('refuses to read a session the host no longer holds', async () => { + await expect( readStructuredWorkerJournal({ identity: IDENTITY, dispatchId: 'd1', @@ -247,15 +260,15 @@ describe('structured worker output', () => { liveness: 'live', agent: 'claude' }) - ).toThrow(/not attached/) + ).rejects.toThrow(/not attached/) }) - it('reports an unverifiable worker as unknown, never as running', () => { + it('reports an unverifiable worker as unknown, never as running', async () => { // The `could not look, therefore it is alive` inversion. After a restart the runtime observes // `unverifiable` — no attached provider child in this generation — while the journal is still // readable, and a coordinator reading `running` waits on a worker that may already be gone. installHost({}) - const read = readStructuredWorkerJournal({ + const read = await readStructuredWorkerJournal({ identity: IDENTITY, dispatchId: 'd1', workerState: 'ready', @@ -266,9 +279,9 @@ describe('structured worker output', () => { expect(read.status.liveness).toBe('unverifiable') }) - it('carries each proven verdict through unchanged', () => { + it('carries each proven verdict through unchanged', async () => { installHost({}) - const live = readStructuredWorkerJournal({ + const live = await readStructuredWorkerJournal({ identity: IDENTITY, dispatchId: 'd1', workerState: 'ready', @@ -276,7 +289,7 @@ describe('structured worker output', () => { agent: 'claude' }) expect(live.status).toMatchObject({ terminal: 'running', liveness: 'live' }) - const exited = readStructuredWorkerJournal({ + const exited = await readStructuredWorkerJournal({ identity: IDENTITY, dispatchId: 'd1', workerState: 'succeeded', @@ -286,9 +299,9 @@ describe('structured worker output', () => { expect(exited.status).toMatchObject({ terminal: 'exited', liveness: 'exited' }) }) - it('states that a settled release is exited', () => { + it('states that a settled release is exited', async () => { installHost({}) - const archive = captureStructuredWorkerArchive(IDENTITY, 'claude') + const archive = await captureStructuredWorkerArchive(IDENTITY, 'claude') const archived = readArchivedStructuredJournal({ dispatchId: 'd1', workerState: 'succeeded', @@ -300,12 +313,12 @@ describe('structured worker output', () => { expect(archived.status).toMatchObject({ terminal: 'exited', liveness: 'exited' }) }) - it('never calls an unproven release exited', () => { + it('never calls an unproven release exited', async () => { // The archive is frozen BEFORE the close. `release_unknown` is the state that records a close // that did NOT land, and a coordinator reading `exited` there starts a replacement worker over // the same worktree while the original provider child may still be attached. installHost({}) - const archive = captureStructuredWorkerArchive(IDENTITY, 'claude') + const archive = await captureStructuredWorkerArchive(IDENTITY, 'claude') for (const releaseState of ['unknown', 'releasing'] as const) { const archived = readArchivedStructuredJournal({ dispatchId: 'd1', @@ -324,7 +337,7 @@ describe('structured worker output', () => { // `readArchivedWorkerOutput`, and the resource row it already holds is the only thing that // knows whether the close landed. installHost({}) - const archive = captureStructuredWorkerArchive(IDENTITY, 'claude') + const archive = await captureStructuredWorkerArchive(IDENTITY, 'claude') const db = { getWorkerTerminalArchive: () => ({ dispatch_id: 'd1', @@ -355,12 +368,12 @@ describe('structured worker output', () => { }) }) - it('refuses a cursor once the tail window has slid past it', () => { + it('refuses a cursor once the tail window has slid past it', async () => { // The cursor is an index into the bounded tail, and `sourceIdentity` was constant for the // worker's life, so a coordinator paging a growing journal resumed at the newest items and // skipped the middle without a word. installHost({ items: ITEMS }) - const first = readStructuredWorkerJournal({ + const first = await readStructuredWorkerJournal({ identity: IDENTITY, dispatchId: 'd1', workerState: 'ready', @@ -376,7 +389,7 @@ describe('structured worker output', () => { } as unknown as AgentJournalRenderItem ] }) - expect(() => + await expect( readStructuredWorkerJournal({ identity: IDENTITY, dispatchId: 'd1', @@ -385,7 +398,7 @@ describe('structured worker output', () => { agent: 'claude', cursor: first.cursor }) - ).toThrow(/source changed/i) + ).rejects.toThrow(/source changed/i) }) }) @@ -394,7 +407,7 @@ describe('archiving a structured worker whose journal cannot be read', () => { hostRef.current = null }) - it('settles with an empty, warned archive once the session is PROVEN gone', () => { + it('settles with an empty, warned archive once the session is PROVEN gone', async () => { // Closing the worker's chat tab is a routine user action: it evicts the child and detaches the // journal permanently. Throwing archive_failed there wedged release on evidence that could // never arrive, leaving worker-abandon as the only way out. @@ -403,7 +416,7 @@ describe('archiving a structured worker whose journal cannot be read', () => { claimStatus: 'released', deathEvidence: { kind: 'exit-observed', detail: 'surface released', observedAt: 1 } }) - const archive = captureStructuredWorkerArchive(IDENTITY, 'claude') + const archive = await captureStructuredWorkerArchive(IDENTITY, 'claude') expect(archive.messages).toEqual([]) expect(archive.processIncarnation).toBe(IDENTITY.processIncarnation) expect(archive.warnings).toContain( @@ -411,12 +424,24 @@ describe('archiving a structured worker whose journal cannot be read', () => { ) }) - it('still retains when the journal is unreadable but nothing proves the child is gone', () => { - installHost({ historyThrows: true }) - expect(() => captureStructuredWorkerArchive(IDENTITY, 'claude')).toThrow(/retained/) + it('retains a worker at rest: released, but its chat tab still lists it', async () => { + // Released is not retired now — the idle sweep releases a quiet worker's lease — so an owned + // worker whose journal cannot be read keeps its evidence for a later archive. + installHost({ + historyThrows: true, + claimStatus: 'released', + deathEvidence: { kind: 'exit-observed', detail: 'idle stop', observedAt: 1 }, + tabListed: true + }) + await expect(captureStructuredWorkerArchive(IDENTITY, 'claude')).rejects.toThrow(/retained/) }) - it('still retains when there is no host to look with', () => { - expect(() => captureStructuredWorkerArchive(IDENTITY, 'claude')).toThrow(/retained/) + it('still retains when the journal is unreadable but nothing proves the child is gone', async () => { + installHost({ historyThrows: true }) + await expect(captureStructuredWorkerArchive(IDENTITY, 'claude')).rejects.toThrow(/retained/) + }) + + it('still retains when there is no host to look with', async () => { + await expect(captureStructuredWorkerArchive(IDENTITY, 'claude')).rejects.toThrow(/retained/) }) }) diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts index 13d3ce1dbce..bec15537c49 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts @@ -40,12 +40,13 @@ import { structuredWorkerTerminalState, type StructuredWorkerObservation } from '../../structured-worker-authority' +import { structuredWorkerOwned } from '../../structured-worker-custody' import type { StructuredWorkerIdentity } from '../../structured-worker-identity' import type { WorkerTerminalReleaseState } from '../../orchestration/worker-terminal-ownership' import { releaseStructuredWorkerSession } from './orchestration-structured-worker-session' import { closeStructuredAgentSessionChild } from '../../structured-agent-session-close' -export { observeStructuredWorker, type StructuredWorkerObservation } +export { observeStructuredWorker, structuredWorkerOwned, type StructuredWorkerObservation } /** The structured worker behind a dispatch, or null when a PTY worker owns it. */ export function resolveStructuredWorkerForDispatch( @@ -83,13 +84,13 @@ export async function stopStructuredWorker( return closeStructuredAgentSessionChild(identity.sessionId, { ...(runtime ? { runtime } : {}), // Between the close and the proof, never after: an unsettled close returns early, and a - // surviving hold keeps the provider child un-evictable for the life of the app. + // surviving redrive subscription keeps nudging a session no dispatch owns. afterClose: () => releaseStructuredWorkerSession(dispatchId, runtime) }) } /** The structured half of `worker-read`, or null when a PTY worker owns the dispatch. */ -export function readStructuredWorkerOutput(args: { +export async function readStructuredWorkerOutput(args: { db: OrchestrationDb dispatchId: string workerState: string @@ -98,7 +99,7 @@ export function readStructuredWorkerOutput(args: { source?: 'auto' | 'transcript' | 'terminal' cursor?: string | number limit?: number -}): OrchestrationWorkerReadTranscriptResult | null { +}): Promise { const identity = resolveStructuredWorkerForDispatch(args.db, args.dispatchId) if (!identity) { return null @@ -123,7 +124,7 @@ export function readStructuredWorkerOutput(args: { } /** Journal page in the shape `worker-read --source transcript` already serves. */ -export function readStructuredWorkerJournal(args: { +export async function readStructuredWorkerJournal(args: { identity: StructuredWorkerIdentity dispatchId: string workerState: string @@ -131,8 +132,8 @@ export function readStructuredWorkerJournal(args: { agent: AgentType cursor?: string | number limit?: number -}): OrchestrationWorkerReadTranscriptResult { - const page = readStructuredJournalPage(args.identity.sessionId) +}): Promise { + const page = await readStructuredJournalPage(args.identity.sessionId) if (!page) { throw new OrchestrationError( 'transcript_required', @@ -213,11 +214,13 @@ function structuredJournalPrefixIdentity(args: { } /** Freezes the journal before the session is closed, so a released worker is still readable. */ -export function captureStructuredWorkerArchive( +export async function captureStructuredWorkerArchive( identity: StructuredWorkerIdentity, agent: AgentType -): WorkerStructuredJournalArchive { - const page = readStructuredJournalPage(identity.sessionId) +): Promise { + // Opens a conversation at rest or one the idle sweep closed, so a resting worker's journal is + // still preserved. + const page = await readStructuredJournalPage(identity.sessionId) if (page) { return buildStructuredJournalArchive({ agent, @@ -232,10 +235,9 @@ export function captureStructuredWorkerArchive( // the worker's chat tab is a routine user action that does exactly that, so throwing there wedges // release on evidence that can never come and leaves `worker-abandon` as the only exit. // - // `exited` is the only verdict that qualifies: it needs a released lease WITH death evidence. - // `unverifiable` — no host installed, a lease handed to a TUI owner — means we could not look, - // and retaining is still right. - if (observeStructuredWorker(identity).status !== 'exited') { + // Only a worker this runtime no longer owns qualifies — released with its chat tab gone. An owned + // one, running or at rest, keeps its journal, and so does one we could not look at. + if (structuredWorkerOwned(identity.sessionId) !== false) { throw new OrchestrationError( 'archive_failed', 'Output could not be preserved for this structured worker; the session was retained.' diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts index 493f9d092c3..c3c69dc15fd 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts @@ -1,5 +1,8 @@ +import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { dispatchWriteFailureReason } from '../../../../shared/structured-agent-session-dispatch-rejection' +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import { getAppEnvironment } from '../../../../shared/app-environment' +import { DISPATCH_REJECTED_WRITE_FAILED } from '../../../../shared/structured-agent-session-dispatch-rejection' const hostRef: { current: unknown } = { current: null } const createSpy = vi.fn() @@ -14,37 +17,34 @@ vi.mock('./structured-agent-session-create', () => ({ const { createStructuredWorkerSession, releaseStructuredWorkerSession, - sendStructuredWorkerPreamble, - structuredWorkerHoldId + sendStructuredWorkerPreamble } = await import('./orchestration-structured-worker-session') const { isUnknownWorkerStartOutcome } = await import('./orchestration/worker/worker-topology') const { structuredWorkerIdentities } = await import('../../structured-worker-identity') -const { structuredWorkerChildIdentityEnv } = - await import('../../structured-worker-child-identity-env') +const { structuredSessionChildIdentityEnv } = + await import('../../structured-session-child-identity-env') -function installHost() { - const hold = vi.fn(async () => {}) - const release = vi.fn() +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a host stub carrying only the members the worker start reaches. +function installHost(location = { executionHostId: 'local', wslDistro: null as string | null }) { const dispose = vi.fn() + const subscribe = vi.fn(async () => dispose) hostRef.current = { setSessionTabVisibility: async () => {}, close: async () => {}, deps: { store: { getRecord: () => ({ - location: { executionHostId: 'local', wslDistro: null }, + location, lease: { runtimeFence: 2, runtimeKind: 'native', claimStatus: 'live' } }) } }, - hold, - release, - subscribe: () => dispose + subscribe } - return { hold, release, dispose } + return { subscribe, dispose } } -describe('structured worker session hold', () => { +describe('structured worker session', () => { beforeEach(() => { structuredWorkerIdentities.clear() createSpy.mockReset() @@ -54,8 +54,8 @@ describe('structured worker session hold', () => { })) }) - it('takes a resume-capable hold at start and releases it only on settlement', async () => { - const { hold, release, dispose } = installHost() + it('binds only a redrive subscription at start, and settlement drops it', async () => { + const { subscribe, dispose } = installHost() const created = await createStructuredWorkerSession({ runtime: { ensureStructuredAgentSessionHost: async () => {} } as never, worktreeId: 'wt_1', @@ -63,18 +63,19 @@ describe('structured worker session hold', () => { dispatchId: 'd1', onJournalActivity: () => {} }) - // Without the hold, the release clock evicts the provider child 15s after a user closes the - // worker's chat tab, killing an idle worker mid-dispatch. - expect(hold).toHaveBeenCalledWith(created.identity.sessionId, structuredWorkerHoldId('d1')) - expect(release).not.toHaveBeenCalled() + // No hold: while the dispatch is open the idle sweep reads it from the orchestration database. + expect(hostRef.current).not.toHaveProperty('hold') + expect(subscribe).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: created.identity.sessionId }) + ) + expect(dispose).not.toHaveBeenCalled() releaseStructuredWorkerSession('d1') - expect(release).toHaveBeenCalledWith(created.identity.sessionId, structuredWorkerHoldId('d1')) expect(dispose).toHaveBeenCalledTimes(1) expect(structuredWorkerIdentities.get(created.identity.handle)).toBeNull() - // A second settlement is a no-op rather than a second release of the same holder. + // A second settlement is a no-op. releaseStructuredWorkerSession('d1') - expect(release).toHaveBeenCalledTimes(1) + expect(dispose).toHaveBeenCalledTimes(1) }) it('registers the identity BEFORE the session is created, so the child gets the handle', async () => { @@ -83,7 +84,7 @@ describe('structured worker session hold', () => { createSpy.mockImplementation(async (args: { envelope: { sessionId: string } }) => { // `attach` is what spawns the provider child, and the child's env is read from the registry // at spawn time. Registering afterwards ships a worker with no ORCA_TERMINAL_HANDLE. - envAtSpawn = structuredWorkerChildIdentityEnv(args.envelope.sessionId, {}) + envAtSpawn = structuredSessionChildIdentityEnv(args.envelope.sessionId, {}) return { ok: true, value: { sessionId: args.envelope.sessionId } } }) const created = await createStructuredWorkerSession({ @@ -94,14 +95,22 @@ describe('structured worker session hold', () => { onJournalActivity: () => {} }) expect(envAtSpawn?.ORCA_TERMINAL_HANDLE).toBe(created.identity.handle) - expect(envAtSpawn?.ORCA_CLI_COMMAND).toBe('orca') + // This app's own launcher by absolute path, so a login shell's profile cannot swap in a global. + expect(envAtSpawn?.ORCA_CLI_COMMAND).toBe( + join( + getAppEnvironment().getPath('userData'), + 'cli', + 'bin', + process.platform === 'win32' ? 'orca-dev.cmd' : 'orca-dev' + ) + ) expect(envAtSpawn?.ORCA_PANE_KEY).toBeUndefined() releaseStructuredWorkerSession('d_spawn') }) it('forgets the identity and discards the session when the start fails', async () => { - const { hold } = installHost() - hold.mockRejectedValueOnce(new Error('hold refused')) + // A session that resolves outside the local host is not a worker this runtime can own. + installHost({ executionHostId: 'local', wslDistro: 'Ubuntu' }) const closed: string[] = [] ;(hostRef.current as { close: (id: string) => Promise }).close = async (id) => { closed.push(id) @@ -114,7 +123,7 @@ describe('structured worker session hold', () => { dispatchId: 'd_fail', onJournalActivity: () => {} }) - ).rejects.toThrow('hold refused') + ).rejects.toThrow(/local execution host outside WSL/) // Neither a live provider child nor a registry entry may outlive the failed start. expect(closed).toHaveLength(1) expect(structuredWorkerIdentities.getBySessionId(closed[0]!)).toBeNull() @@ -128,7 +137,7 @@ describe('structured worker session hold', () => { } // `commit` answers this after `attach` SUCCEEDED and only the tab publish failed, so the // provider child is live. Reading it as "refused, nothing created" strands that child with no - // hold and no binding, and nothing else in the runtime ever retires it. + // binding, and nothing else in the runtime ever retires it. createSpy.mockImplementation(async () => ({ ok: false, refusal: { @@ -224,20 +233,69 @@ describe('structured worker session hold', () => { }) describe('structured worker dispatch preamble', () => { - function hostWithSubmission(submission: Record) { + type PreambleHost = Parameters[0]['host'] + type Settled = Pick + + function submissionOf(settled: Settled): AgentJournalSubmission { return { - deps: { store: { getRecord: () => ({ lease: { runtimeFence: 7 } }) } }, - send: async () => ({ ok: true, value: { clientMessageId: 'c1', submission } }) - } as never + clientMessageId: 'c1', + fence: 7, + payloadFingerprint: 'fingerprint', + providerItemId: null, + submittedAt: 1, + resolvedAt: null, + ...settled + } } - const send = (host: never) => + function hostWithSubmission(submission: Settled, delivered?: Settled): PreambleHost { + return { + deps: { store: { getRecord: () => ({ lease: { runtimeFence: 7 } }) } }, + send: async () => ({ + ok: true, + replayed: false, + fence: 7, + cursor: { epoch: 'epoch-1', sequence: 1 }, + value: { clientMessageId: 'c1', submission: submissionOf(submission) } + }), + // What the submission settled as while the worker's agent started; undefined when the + // start outlasted the wait. + waitForSendSettlement: async () => + delivered + ? { + cursor: { epoch: 'epoch-1', sequence: 2 }, + value: { clientMessageId: 'c1', submission: submissionOf(delivered) } + } + : undefined + } + } + + const send = (host: PreambleHost) => sendStructuredWorkerPreamble({ host, sessionId: 's1', dispatchId: 'd1', preamble: 'spec' }) it('reports the preamble delivered only on an accepted submission', async () => { await expect( send(hostWithSubmission({ dispatchState: 'accepted', reason: null })) - ).resolves.toBeUndefined() + ).resolves.toBe('accepted') + }) + + it('waits for an accepted preamble to be delivered, and reports that delivery (W10)', async () => { + await expect( + send( + hostWithSubmission( + { dispatchState: 'pending', reason: null }, + { dispatchState: 'accepted', reason: null } + ) + ) + ).resolves.toBe('accepted') + }) + + it('reports a preamble still held for an agent that outlasted the wait, without failing the start (W10)', async () => { + // Held, not lost: the host delivers it when the agent starts. Throwing here tore the worker + // down, which rejected the preamble the start was about to deliver. + await expect( + send(hostWithSubmission({ dispatchState: 'pending', reason: null })) + ).resolves.toBe('pending') }) it('never claims delivery for a submission the provider never acknowledged', async () => { @@ -245,15 +303,13 @@ describe('structured worker dispatch preamble', () => { // into `unknown`, and `performSend` still returns ok. Reporting that as `dispatch_input: // accepted` marks the worker ready with no task, and the coordinator blocks in // `check --wait --types worker_done` until it times out. - for (const dispatchState of ['unknown', 'pending'] as const) { - const error = await send( - hostWithSubmission({ dispatchState, reason: 'provider child exited' }) - ).catch((thrown: unknown) => thrown) - expect((error as { code?: string }).code).toBe('operation_unknown') - // The wiring, not just the throw: this is the code that makes the start receipt - // `outcome_unknown` with the worker-show / worker-abandon recovery commands. - expect(isUnknownWorkerStartOutcome(error, 'dispatch_input')).toBe(true) - } + const error = await send( + hostWithSubmission({ dispatchState: 'unknown', reason: 'provider child exited' }) + ).catch((thrown: unknown) => thrown) + expect((error as { code?: string }).code).toBe('operation_unknown') + // The wiring, not just the throw: this is the code that makes the start receipt + // `outcome_unknown` with the worker-show / worker-abandon recovery commands. + expect(isUnknownWorkerStartOutcome(error, 'dispatch_input')).toBe(true) }) it('keeps a rejected preamble a proven failure under a code of its own', async () => { @@ -268,13 +324,33 @@ describe('structured worker dispatch preamble', () => { expect(isUnknownWorkerStartOutcome(error, 'dispatch_input')).toBe(false) }) + it('ends the message with one period whether the reason is a sentence or a marker', async () => { + const sentence = await send( + hostWithSubmission({ + dispatchState: 'rejected', + reason: 'The provider stopped before this message was sent.' + }) + ).catch((thrown: unknown) => thrown) + expect(sentence).toMatchObject({ + message: + 'The dispatch preamble was not delivered: The provider stopped before this message was sent.' + }) + const marker = await send( + hostWithSubmission({ dispatchState: 'unknown', reason: 'provider child exited' }) + ).catch((thrown: unknown) => thrown) + expect(marker).toMatchObject({ + message: + 'The dispatch preamble was submitted but not acknowledged (unknown): provider child exited.' + }) + }) + it('reports a refused transport write as undelivered, never as unknown', async () => { // The state a provably-unwritten frame now settles. Nothing reached the provider, // so there is no running turn for a coordinator to go and look at. const error = await send( hostWithSubmission({ dispatchState: 'rejected', - reason: dispatchWriteFailureReason(new Error('broken pipe')) + reason: DISPATCH_REJECTED_WRITE_FAILED }) ).catch((thrown: unknown) => thrown) expect((error as { code?: string }).code).toBe('dispatch_preamble_undelivered') diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts index 5bde461a059..f5684f1ba1b 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -1,19 +1,21 @@ /** - * Starting, holding and retiring a worker that IS a structured agent session. + * Starting and retiring a worker that IS a structured agent session. * * Three things make this different from the PTY worker path, and all three live here: * * - The session is created directly as structured, so readiness is the attach returning ok. There * is no boot-to-idle gap to wait on and no `tui-idle` edge to read. - * - A structured session's provider child is evicted 15s after its last HOLDER leaves, and holds - * come only from bound surfaces. A dispatched worker parked on mail is exactly that state, so - * the dispatch takes its own resume-capable hold and keeps it until the worker settles. + * - Nothing here keeps its agent running. The idle sweep leaves it running while its dispatch is + * open, reading that from the orchestration database; once the dispatch settles the agent rests + * like any chat's, and the next mail starts it. * - The dispatch preamble is a turn, not keystrokes. */ import { randomUUID } from 'node:crypto' import { isDefinitiveAgentSessionCreateRefusal } from '../../../../shared/agent-session-definitive-refusal' import type { AgentJournalMessageItem } from '../../../../shared/agent-session-journal-types' +import { ORCHESTRATION_READINESS_TIMEOUT_MS } from '../../../../shared/orchestration-timing-budgets' +import { agentSessionSendSubmission } from '../../../../shared/agent-session-wire' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import type { OrcaRuntimeService } from '../../orca-runtime' @@ -38,23 +40,16 @@ import { createStructuredAgentSessionForWorktree } from './structured-agent-sess type StructuredWorkerBinding = { sessionId: string handle: string - holderId: string disposeSubscription: () => void } const bindingsByDispatchId = new Map() -export function structuredWorkerHoldId(dispatchId: string): string { - return `orchestration:dispatch:${dispatchId}` -} - /** - * Drops the dispatch's hold, its redrive subscription and its parked mail; the release clock takes - * it from here. + * Drops the dispatch's redrive subscription, its registry entry and its parked mail. * - * EVERY settlement has to reach this — stop, release AND abandon. A surviving hold does not just - * leak: it keeps the provider child un-evictable for the life of the app, and makes host crash - * recovery respawn a child for a worker that was settled long ago. + * EVERY settlement has to reach this — stop, release AND abandon. A surviving subscription keeps + * nudging a session no dispatch owns. */ export function releaseStructuredWorkerSession( dispatchId: string, @@ -68,11 +63,6 @@ export function releaseStructuredWorkerSession( binding.disposeSubscription() structuredWorkerIdentities.forget(binding.handle) runtime?.forgetStructuredSessionMail?.(binding.sessionId) - try { - getStructuredAgentSessionHost()?.release(binding.sessionId, binding.holderId) - } catch (error) { - console.warn('[orchestration] structured worker hold release failed', dispatchId, error) - } } export async function createStructuredWorkerSession(args: { @@ -92,7 +82,7 @@ export async function createStructuredWorkerSession(args: { // resolves to whatever single leaf sits in the worktree — by default the COORDINATOR's pane. // // The scope is provisionally local; the record's own location is asserted local below, and a - // session that resolves anywhere else never reaches a hold. + // session that resolves anywhere else is discarded. const identity = structuredWorkerIdentities.register({ handle: mintStructuredWorkerHandle(), sessionId, @@ -141,13 +131,10 @@ export async function createStructuredWorkerSession(args: { 'A structured worker must run on the local execution host outside WSL.' ) } - const holderId = structuredWorkerHoldId(args.dispatchId) - await host.hold(sessionId, holderId) - const disposeSubscription = subscribeForRedrive(host, sessionId, args.onJournalActivity) + const disposeSubscription = await subscribeForRedrive(host, sessionId, args.onJournalActivity) bindingsByDispatchId.set(args.dispatchId, { sessionId, handle: identity.handle, - holderId, disposeSubscription }) return { identity, host } @@ -167,8 +154,8 @@ export async function createStructuredWorkerSession(args: { * * `ok` is not the test. `commit` answers `agent_session_operation_unknown` when `attach` SUCCEEDED * and only the tab publish failed, and a throw out of the commit half is past `attach` too — the - * pre-commit half never throws, it refuses. Both leave a live provider child that took no hold and - * has no binding, so nothing else in the runtime will ever retire it. Only a DEFINITIVE refusal + * pre-commit half never throws, it refuses. Both leave a session with a published tab and no + * binding, so nothing else in the runtime will ever retire it. Only a DEFINITIVE refusal * proves there is nothing to discard; everything else gets the best-effort close. */ function structuredCreateMayHaveCommitted( @@ -213,13 +200,27 @@ export async function discardStructuredWorkerSession( retireSettledStructuredWorkerTab(sessionId, runtime) } -/** Delivers the dispatch preamble as the worker's first turn. */ +/** A submission reason as a clause: host sentences end in a period, legacy markers do not. */ +function reasonClause(reason: string | null | undefined): string { + return (reason ?? 'no reason given').replace(/[.\s]+$/, '') +} + +/** What a preamble send reads of the host. */ +type StructuredWorkerPreambleHost = Pick< + StructuredAgentSessionHost, + 'send' | 'waitForSendSettlement' +> & { + deps: { store: { getRecord: (sessionId: string) => { lease: { runtimeFence: number } } | null } } +} + +/** Delivers the dispatch preamble as the worker's first turn. `pending`: the worker's agent had + * not taken it within the wait; the host still holds it for that agent, and never re-sends it. */ export async function sendStructuredWorkerPreamble(args: { - host: StructuredAgentSessionHost + host: StructuredWorkerPreambleHost sessionId: string dispatchId: string preamble: string -}): Promise { +}): Promise<'accepted' | 'pending'> { const body: AgentJournalMessageItem = { kind: 'message', role: 'user', @@ -244,11 +245,24 @@ export async function sendStructuredWorkerPreamble(args: { if (!result.ok) { throw new Error(`The dispatch preamble was refused: ${result.refusal.message}`) } - const submission = result.value.submission - if (submission.dispatchState === 'accepted') { - return + // Accepted is not delivered: the worker's agent may still be starting. + const answered = agentSessionSendSubmission(result.value) + const submission = + answered?.dispatchState === 'pending' + ? (agentSessionSendSubmission( + ( + await args.host + .waitForSendSettlement(args.sessionId, result.value.clientMessageId, { + budgetMs: ORCHESTRATION_READINESS_TIMEOUT_MS + }) + .catch(() => undefined) + )?.value + ) ?? answered) + : answered + if (submission?.dispatchState === 'accepted' || submission?.dispatchState === 'pending') { + return submission.dispatchState } - if (submission.dispatchState === 'rejected') { + if (submission?.dispatchState === 'rejected') { // A rejection is a verdict, not a mystery: the preamble provably did not happen. // `dispatch_preamble_undelivered` says exactly that, and says it as a code rather // than as prose, so a coordinator can tell "we could not send it" apart from @@ -256,7 +270,9 @@ export async function sendStructuredWorkerPreamble(args: { // pending receipt; only this one lets the caller retry knowing nothing landed. throw new OrchestrationError( 'dispatch_preamble_undelivered', - `The dispatch preamble was not delivered: ${submission.reason ?? 'no reason given'}.` + `The dispatch preamble was not delivered${ + submission.rejection ? ` (${submission.rejection.kind})` : '' + }: ${reasonClause(submission.reason)}.` ) } // Only `accepted` is an acknowledgement — the same rule the mail lane already applies. A thrown @@ -265,7 +281,7 @@ export async function sendStructuredWorkerPreamble(args: { // `outcome_unknown` receipt whose nextCommands send the coordinator to look. throw new OrchestrationError( 'operation_unknown', - `The dispatch preamble was submitted but not acknowledged (${submission.dispatchState}): ${submission.reason ?? 'no reason given'}.` + `The dispatch preamble was submitted but not acknowledged (${submission?.dispatchState ?? 'unknown'}): ${reasonClause(submission?.reason)}.` ) } @@ -304,17 +320,17 @@ const REDRIVE_MAX_WAIT_MS = 2_000 * idle worker — that is `deliverForHandle`, called when the message is enqueued and untouched * here. This is only the retry for mail already parked because the worker was busy. */ -function subscribeForRedrive( +async function subscribeForRedrive( host: StructuredAgentSessionHost, sessionId: string, onJournalActivity: (sessionId: string) => void -): () => void { +): Promise<() => void> { const coalescer = createKeyedTrailingEdgeCoalescer(onJournalActivity, { flushMs: REDRIVE_FLUSH_MS, maxWaitMs: REDRIVE_MAX_WAIT_MS }) try { - const unsubscribe = host.subscribe({ + const unsubscribe = await host.subscribe({ id: `orchestration:redrive:${sessionId}`, sessionId, emit: (event) => { @@ -324,7 +340,7 @@ function subscribeForRedrive( } }) // Disposal drops the pending timer rather than flushing it: every settlement reaches here, and - // a redrive that fires after the hold is gone would nudge a session no dispatch owns. + // a redrive that fires after it would nudge a session no dispatch owns. return () => { coalescer.dispose() unsubscribe() diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts index 1f29dfb4904..98a843fb695 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts @@ -136,7 +136,7 @@ describe('a structured worker-start that fails after the session exists', () => db, run: { id: 'run_1' } as never, existingTask: { id: 't1', spec: 'do the thing' } as never, - coordinatorPane: null, + coordinator: null, orchestrationMutation: undefined }) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts index aa8c7c58574..9531e0f468b 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts @@ -12,7 +12,6 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import { OrcaRuntimeService } from '../../orca-runtime' import { OrchestrationDb } from '../../orchestration/db' import { @@ -29,6 +28,12 @@ const STRUCTURED_HANDLE = 'structworker_worker' const TERMINAL_HANDLE = 'term_worker' const structuredPreambles: string[] = [] +// The session host the code under test reads; a structural fake, so no host type is claimed. +const hostRef = vi.hoisted((): { current: unknown } => ({ current: null })) + +vi.mock('../../../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) vi.mock('./orchestration/worker/worker-topology', async (importOriginal) => ({ ...(await importOriginal>()), @@ -42,6 +47,7 @@ vi.mock('./orchestration-structured-worker-session', async (importOriginal) => ( ...(await importOriginal>()), sendStructuredWorkerPreamble: async (args: { preamble: string }) => { structuredPreambles.push(args.preamble) + return 'accepted' }, releaseStructuredWorkerSession: () => {}, discardStructuredWorkerSession: async () => {} @@ -69,7 +75,7 @@ function installStructuredCoordinator(handle: string, sessionId: string): string worktreeId: WORKTREE, hostScope: { kind: 'local', hostId: 'local' } }) - setStructuredAgentSessionHost({ + hostRef.current = { hasSession: () => true, deps: { store: { @@ -82,10 +88,12 @@ function installStructuredCoordinator(handle: string, sessionId: string): string deathEvidence: null, runtimeFence: 1 } - }) + }), + // No committed /clear: each session is its own lineage's root. + listRecords: () => [] } } - } as never) + } return paneKey } @@ -149,7 +157,7 @@ describe('a worker cannot tell which mode it is running in', () => { afterEach(() => { db.close() - setStructuredAgentSessionHost(null) + hostRef.current = null structuredWorkerIdentities.clear() vi.restoreAllMocks() }) @@ -253,10 +261,14 @@ describe('a worker cannot tell which mode it is running in', () => { source: 'terminal' }) - expect(read).toThrow(/has no terminal output/) + const refusal = await read().then( + () => '', + (error: unknown) => (error instanceof Error ? error.message : String(error)) + ) + expect(refusal).toMatch(/has no terminal output/) // The refusal names a source that works instead of naming the worker's kind. - expect(read).toThrow(/--source auto or --source transcript/) - expect(read).not.toThrow(/structured/i) + expect(refusal).toMatch(/--source auto or --source transcript/) + expect(refusal).not.toMatch(/structured/i) }) it('never claims a structured worker was checked for a human-answerable prompt', async () => { diff --git a/src/main/runtime/rpc/methods/orchestration-worker-release-incarnation-fallback.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-release-incarnation-fallback.test.ts new file mode 100644 index 00000000000..e839c2e017d --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-release-incarnation-fallback.test.ts @@ -0,0 +1,534 @@ +import { afterEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import { ORCHESTRATION_METHODS } from './orchestration' +import { eraseRpcMethods, type RpcContext } from '../core' +import { OrchestrationDb } from '../../orchestration/db' +import { OrcaRuntimeService } from '../../orca-runtime' +import { completeWorkerTerminalRelease } from './orchestration/worker/worker-release-completion' + +describe('orchestration worker release incarnation fallback', () => { + let db: OrchestrationDb + let dbOpen = false + let runtime: OrcaRuntimeService + let ctx: RpcContext + let activeRunId: string + let inspectProcessLiveness: MockInstance< + OrcaRuntimeService['inspectTerminalProcessIncarnationLiveness'] + > + + const coordinatorPaneKey = 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + const workerPaneKey = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + + /** Fresh in-memory db and a fully-stubbed runtime for one worker-release scenario. */ + function setup(): void { + db = new OrchestrationDb(':memory:') + dbOpen = true + runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + inspectProcessLiveness = vi + .spyOn(runtime, 'inspectTerminalProcessIncarnationLiveness') + .mockResolvedValue('live') + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_coord' + ? coordinatorPaneKey + : handle === 'term_worker' || handle === 'term_reminted' + ? workerPaneKey + : null + ) + vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockImplementation((handle) => + handle === 'term_worker' || handle === 'term_reminted' ? 'runtime_test:term_worker:1' : null + ) + vi.spyOn(runtime, 'getOrchestrationDispatchAuthority').mockImplementation((handle) => + handle === 'term_worker' || handle === 'term_reminted' + ? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test fixture is deliberately shaped to exercise the private/runtime boundary. + ({ + terminalHandle: handle, + paneKey: workerPaneKey, + processIncarnation: 'runtime_test:term_worker:1', + hostScope: { kind: 'local', hostId: 'local' } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + } as never) + : null + ) + vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) + vi.spyOn(runtime, 'showTerminal').mockImplementation( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + async (handle) => ({ handle, worktreeId: 'repo::worktree', status: 'running' }) as never + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + vi.spyOn(runtime, 'showManagedTerminalWorkspace').mockResolvedValue({ + id: 'repo::worktree' + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + } as never) + vi.spyOn(runtime, 'createTerminal').mockResolvedValue({ + handle: 'term_worker', + worktreeId: 'repo::worktree', + title: 'worker' + }) + vi.spyOn(runtime, 'waitForTerminal').mockResolvedValue({ + handle: 'term_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca') + vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({ + handle: 'term_worker', + accepted: true, + bytesWritten: 1 + }) + vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) + vi.spyOn(runtime, 'getExactWorkerProviderSession').mockReturnValue(null) + vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ + handle: 'term_worker', + status: 'running', + tail: ['worker output line 1', 'worker output line 2'], + truncated: false, + nextCursor: '2' + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + vi.spyOn(runtime, 'closeTerminal').mockResolvedValue({ + handle: 'term_worker', + tabId: 'tab-worker', + ptyKilled: true + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + } as never) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + activeRunId = db.createRun({ + objective: 'Release test Run', + coordinatorHandle: 'term_coord', + coordinatorPaneKey + }).id + ctx = { runtime } + } + + afterEach(() => { + if (dbOpen) { + dbOpen = false + db.close() + } + vi.restoreAllMocks() + }) + + /** Look up a registered orchestration RPC method by name. */ + function findMethod(name: string) { + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find((m) => m.name === name) + if (!method) { + throw new Error(`Method not found: ${name}`) + } + return method + } + + /** Parse a method's params and invoke its handler against the shared ctx. */ + async function call(name: string, params: Record) { + const method = findMethod(name) + const parsed = method.params ? method.params.parse(params) : undefined + return method.handler(parsed, ctx) + } + + /** Start a ready worker on a fresh task off the coordinator terminal. */ + async function startWorker(options: { terminal?: string } = {}): Promise<{ + taskId: string + dispatchId: string + }> { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const task = db.createTask({ spec: 'release fixture task', runId: activeRunId }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const result = (await call('orchestration.workerStart', { + task: task.id, + from: 'term_coord', + ...(options.terminal ? { terminal: options.terminal } : { agent: 'codex' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + })) as { dispatchId: string; state: string } + expect(result.state).toBe('ready') + return { taskId: task.id, dispatchId: result.dispatchId } + } + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + /** Record the worker's report as settled — the precondition for release. */ + function settle(taskId: string, dispatchId: string, outcome: 'succeeded' | 'failed'): void { + const settlement = db.settleWorkerReport({ + taskId, + dispatchId, + outcome, + result: `worker ${outcome}` + }) + expect(settlement.action).toBe('settled') + } + + /** Start a worker and settle its report, the state a release acts on. */ + async function startSettledWorker( + outcome: 'succeeded' | 'failed' = 'succeeded', + options: { terminal?: string } = {} + ): Promise<{ taskId: string; dispatchId: string }> { + const worker = await startWorker(options) + settle(worker.taskId, worker.dispatchId, outcome) + return worker + } + + it('closes a live worker terminal whose durable handle no longer resolves but whose process incarnation still matches', async () => { + setup() + const { dispatchId } = await startSettledWorker() + // The durable handle stops resolving (renderer graph epoch bump / handle invalidation)... + vi.mocked(runtime.showTerminal).mockImplementation(async (handle) => + handle === 'term_worker' + ? Promise.reject(new Error('terminal_handle_stale')) + : // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test fixture is deliberately shaped to exercise the private/runtime boundary. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + ({ handle, worktreeId: 'repo::worktree', status: 'running' } as never) + ) + // ...but the recorded process incarnation still names a live PTY, re-minted to a fresh handle. + const resolveByIncarnation = vi.fn().mockReturnValue('term_reminted') + vi.spyOn(runtime, 'resolveTerminalHandleByProcessIncarnation').mockImplementation( + resolveByIncarnation + ) + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + processAction: string + } + + expect(receipt).toMatchObject({ state: 'released', processAction: 'closed_agent_terminal' }) + expect(resolveByIncarnation).toHaveBeenCalledWith( + 'runtime_test:term_worker:1', + JSON.stringify({ kind: 'local', hostId: 'local' }) + ) + // The close targeted exactly the re-minted live handle for that PTY, never the stale one. + expect(runtime.closeTerminal).toHaveBeenCalledTimes(1) + expect(runtime.closeTerminal).toHaveBeenCalledWith('term_reminted') + expect(db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + ownership_state: 'released', + release_state: 'released' + }) + }) + + it('stays release_unknown and closes nothing when the recorded incarnation no longer matches a live pty', async () => { + setup() + const { dispatchId } = await startSettledWorker() + vi.mocked(runtime.showTerminal).mockRejectedValue(new Error('terminal_handle_stale')) + // A reused ptyId now belongs to a different process: the incarnation mismatch refuses a close. + const resolveByIncarnation = vi.fn().mockReturnValue(null) + vi.spyOn(runtime, 'resolveTerminalHandleByProcessIncarnation').mockImplementation( + resolveByIncarnation + ) + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + } + + expect(receipt.state).toBe('release_unknown') + expect(resolveByIncarnation).toHaveBeenCalledWith( + 'runtime_test:term_worker:1', + JSON.stringify({ kind: 'local', hostId: 'local' }) + ) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).toBe('unknown') + }) + + it('does not plain-settle an exited missing worker when the archive was never committed', async () => { + setup() + const { dispatchId } = await startSettledWorker() + vi.mocked(runtime.showTerminal).mockRejectedValue(new Error('terminal_handle_stale')) + const resolveByIncarnation = vi.fn().mockReturnValue(null) + vi.spyOn(runtime, 'resolveTerminalHandleByProcessIncarnation').mockImplementation( + resolveByIncarnation + ) + inspectProcessLiveness.mockResolvedValue('exited') + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + processAction: string + } + + expect(receipt).toMatchObject({ state: 'release_unknown', processAction: 'none' }) + expect(inspectProcessLiveness).toHaveBeenCalled() + expect(runtime.closeTerminal).not.toHaveBeenCalled() + expect(db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + ownership_state: 'owned', + release_state: 'unknown' + }) + }) + + it('settles released without a close when exited missing and an archive is already committed', async () => { + setup() + const { dispatchId } = await startSettledWorker() + const requested = db.requestWorkerTerminalRelease(dispatchId) + if (requested.disposition !== 'requested') { + throw new Error(`expected requested, got ${requested.disposition}`) + } + db.commitWorkerTerminalArchiveForRelease({ + dispatchId, + resourceId: requested.resource.id, + kind: 'terminal_tail', + content: JSON.stringify({ lines: [] }), + archiveSource: 'terminal', + archiveStatus: 'empty' + }) + vi.mocked(runtime.showTerminal).mockRejectedValue(new Error('terminal_handle_stale')) + const resolveByIncarnation = vi.fn().mockReturnValue(null) + vi.spyOn(runtime, 'resolveTerminalHandleByProcessIncarnation').mockImplementation( + resolveByIncarnation + ) + inspectProcessLiveness.mockResolvedValue('exited') + + const receipt = await completeWorkerTerminalRelease({ + runtime, + db, + dispatchId, + resource: db.getWorkerTerminalResource(requested.resource.id)!, + mode: 'interactive' + }) + + expect(receipt).toMatchObject({ state: 'released', processAction: 'none' }) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + expect(db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + ownership_state: 'released', + release_state: 'released' + }) + }) + + it('reaches settleDead before the lease check when a gone worker is exited with no live authority', async () => { + // Without a committed archive settleDead retains; lease must not run first and force + // retained/identity_unproven. Disposition is release_unknown (interactive) after the retain. + setup() + const { dispatchId } = await startSettledWorker() + vi.mocked(runtime.showTerminal).mockRejectedValue(new Error('terminal_handle_stale')) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const resolveByIncarnation = vi.fn().mockReturnValue(null) + vi.spyOn(runtime, 'resolveTerminalHandleByProcessIncarnation').mockImplementation( + resolveByIncarnation + ) + vi.mocked(runtime.getOrchestrationDispatchAuthority).mockReturnValue(null) + inspectProcessLiveness.mockResolvedValue('exited') + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + processAction: string + } + + expect(receipt).toMatchObject({ state: 'release_unknown', processAction: 'none' }) + expect(receipt.state).not.toBe('retained') + expect(inspectProcessLiveness).toHaveBeenCalled() + expect(runtime.closeTerminal).not.toHaveBeenCalled() + }) + + it('concedes release_unknown before the lease check when a gone worker has no live authority and liveness is unproven', async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + setup() + const { dispatchId } = await startSettledWorker() + vi.mocked(runtime.showTerminal).mockRejectedValue(new Error('terminal_handle_stale')) + const resolveByIncarnation = vi.fn().mockReturnValue(null) + vi.spyOn(runtime, 'resolveTerminalHandleByProcessIncarnation').mockImplementation( + resolveByIncarnation + ) + vi.mocked(runtime.getOrchestrationDispatchAuthority).mockReturnValue(null) + // Liveness is unresolvable/not-exited: the process may have been re-homed, so concede rather + // than retain or guess at a live process. + inspectProcessLiveness.mockResolvedValue('live') + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + } + + expect(receipt.state).toBe('release_unknown') + expect(receipt.state).not.toBe('retained') + expect(runtime.closeTerminal).not.toHaveBeenCalled() + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).toBe('unknown') + }) + + it('workerStop closes a live worker via the reminted handle when the durable handle is stale', async () => { + setup() + const { dispatchId } = await startWorker() + // The durable handle stops resolving, but the recorded incarnation still names a live PTY. + vi.mocked(runtime.showTerminal).mockImplementation(async (handle) => + handle === 'term_worker' + ? Promise.reject(new Error('terminal_handle_stale')) + : // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test fixture is deliberately shaped to exercise the private/runtime boundary. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + ({ handle, worktreeId: 'repo::worktree', status: 'running' } as never) + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const resolveByIncarnation = vi.fn().mockReturnValue('term_reminted') + vi.spyOn(runtime, 'resolveTerminalHandleByProcessIncarnation').mockImplementation( + resolveByIncarnation + ) + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const receipt = (await call('orchestration.workerStop', { dispatch: dispatchId })) as { + processAction: string + } + + expect(receipt.processAction).toBe('closed_agent_terminal') + // The kill targeted exactly the reminted live handle, never the stale durable one — closing + // the stale handle would throw terminal_handle_stale and leak the PTY. + expect(runtime.closeTerminal).toHaveBeenCalledTimes(1) + expect(runtime.closeTerminal).toHaveBeenCalledWith('term_reminted') + }) + + it('workerRead reads a live worker via the reminted handle when the durable handle is stale', async () => { + setup() + const { dispatchId } = await startWorker() + vi.mocked(runtime.showTerminal).mockImplementation( + async (handle) => + handle === 'term_worker' + ? Promise.reject(new Error('terminal_handle_stale')) + : // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test fixture is deliberately shaped to exercise the private/runtime boundary. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + ({ handle, worktreeId: 'repo::worktree', status: 'running' } as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + ) + const resolveByIncarnation = vi.fn().mockReturnValue('term_reminted') + vi.spyOn(runtime, 'resolveTerminalHandleByProcessIncarnation').mockImplementation( + resolveByIncarnation + ) + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const output = (await call('orchestration.workerRead', { dispatch: dispatchId })) as { + terminal?: { tail: string[] } + } + + // Both the exact-session probe and the terminal read addressed the reminted handle. + expect(runtime.getExactWorkerProviderSession).toHaveBeenCalledWith( + 'term_reminted', + expect.any(Number) + ) + expect(runtime.readTerminal).toHaveBeenCalledWith('term_reminted', expect.anything()) + expect(output.terminal?.tail).toEqual(['worker output line 1', 'worker output line 2']) + }) + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + it('recovery-mode: defers when exited missing has no archive rather than plain-settling', async () => { + // Proof of death still runs settleDead first; when it retains (no archive), recovery must + // stay release_pending — not plain-settle, not unknown. + setup() + const { dispatchId } = await startSettledWorker() + vi.mocked(runtime.showTerminal).mockRejectedValue(new Error('terminal_handle_stale')) + const resolveByIncarnation = vi.fn().mockReturnValue(null) + vi.spyOn(runtime, 'resolveTerminalHandleByProcessIncarnation').mockImplementation( + resolveByIncarnation + ) + inspectProcessLiveness.mockResolvedValue('exited') + const requested = db.requestWorkerTerminalRelease(dispatchId) + if (requested.disposition !== 'requested') { + throw new Error(`expected a requested release, got ${requested.disposition}`) + } + + const receipt = await completeWorkerTerminalRelease({ + runtime, + db, + dispatchId, + resource: requested.resource, + mode: 'recovery' + }) + + expect(receipt).toMatchObject({ state: 'release_pending', processAction: 'none' }) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + expect(db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + ownership_state: 'owned', + release_state: 'requested' + }) + }) + + it('recovery-mode: settles released before the defer when exited missing already has an archive', async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + setup() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const { dispatchId } = await startSettledWorker() + vi.mocked(runtime.showTerminal).mockRejectedValue(new Error('terminal_handle_stale')) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const resolveByIncarnation = vi.fn().mockReturnValue(null) + vi.spyOn(runtime, 'resolveTerminalHandleByProcessIncarnation').mockImplementation( + resolveByIncarnation + ) + inspectProcessLiveness.mockResolvedValue('exited') + const requested = db.requestWorkerTerminalRelease(dispatchId) + if (requested.disposition !== 'requested') { + throw new Error(`expected a requested release, got ${requested.disposition}`) + } + db.commitWorkerTerminalArchiveForRelease({ + dispatchId, + resourceId: requested.resource.id, + kind: 'terminal_tail', + content: JSON.stringify({ lines: [] }), + archiveSource: 'terminal', + archiveStatus: 'empty' + }) + + const receipt = await completeWorkerTerminalRelease({ + runtime, + db, + dispatchId, + resource: db.getWorkerTerminalResource(requested.resource.id)!, + mode: 'recovery' + }) + + expect(receipt).toMatchObject({ state: 'released', processAction: 'none' }) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + expect(db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + ownership_state: 'released', + release_state: 'released' + }) + }) + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + it('recovery-mode: defers release_pending when liveness is unverifiable rather than provably exited', async () => { + setup() + const { dispatchId } = await startSettledWorker() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + vi.mocked(runtime.showTerminal).mockRejectedValue(new Error('terminal_handle_stale')) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const resolveByIncarnation = vi.fn().mockReturnValue(null) + vi.spyOn(runtime, 'resolveTerminalHandleByProcessIncarnation').mockImplementation( + resolveByIncarnation + ) + // Not a death certificate: inventory may still be incomplete, so recovery must defer. + inspectProcessLiveness.mockResolvedValue('unverifiable') + const requested = db.requestWorkerTerminalRelease(dispatchId) + if (requested.disposition !== 'requested') { + throw new Error(`expected a requested release, got ${requested.disposition}`) + } + + const receipt = await completeWorkerTerminalRelease({ + runtime, + db, + dispatchId, + resource: requested.resource, + mode: 'recovery' + }) + + expect(receipt.state).toBe('release_pending') + expect(runtime.closeTerminal).not.toHaveBeenCalled() + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).not.toBe('released') + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-release-reap-fixed.func.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-release-reap-fixed.func.test.ts new file mode 100644 index 00000000000..579f37eaf9e --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-release-reap-fixed.func.test.ts @@ -0,0 +1,358 @@ +// PRB-0219 / upstream #18737 — FUNCTIONAL (integration) reproduction of the steady-state +// worker-release reap LEAK. +// +// This is the "functional/integration" tier of the PRB-0219 test strategy. It wires the REAL +// orchestration RPC surface (orchestration.workerStart / workerRelease / workerList), the REAL +// OrchestrationDb, and the REAL release completion + observation modules against a fake runtime +// that faithfully models the two identity planes involved in the bug: +// +// * VOLATILE, epoch-fenced handle table — `handleTable` keyed by terminal handle, each entry +// stamped with the `rendererGraphEpoch` at which it was issued. `showTerminal` resolves a +// handle ONLY while its stamped epoch matches the current epoch (mirrors getLiveLeafForHandle +// throwing 'terminal_handle_stale' on a rendererGraphEpoch bump). A relay reconnect / renderer +// remount on headless serve bumps the epoch. +// * DURABLE, incarnation-addressed process table — `ptysById` keyed by the ptyId embedded in the +// worker's persisted process_incarnation (`${ptyId}:${incarnationId}`). The pty stays LIVE +// across an epoch bump; nothing about the graph epoch kills the process. +// +// The bug (mechanism): when the epoch bumps, the durable db handle stops resolving through +// showTerminal WHILE THE PTY IS STILL ALIVE. inspectWorkerTerminal swallows the throw and reports +// `missing`; completeWorkerTerminalRelease then commits `release_unknown` and returns WITHOUT ever +// calling runtime.closeTerminal — so the process/PTY leaks. On mtl-02 those orphans accumulate in +// the orca-serve@factory cgroup until TasksMax=4096 is hit and Bun/omp abort() on EAGAIN. +// +// The observable leak signal asserted here: state 'release_unknown' + processAction 'none' + +// closeTerminal NEVER called + the pty STILL alive in ptysById + worker-list terminalState stuck +// on 'release_unknown' (never 'released'). + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ORCHESTRATION_METHODS } from './orchestration' +import { eraseRpcMethods, type RpcContext } from '../core' +import { OrchestrationDb } from '../../orchestration/db' +import { OrcaRuntimeService } from '../../orca-runtime' + +describe('PRB-0219 worker-release reap FIX (functional verification)', () => { + let db: OrchestrationDb + let dbOpen = false + let runtime: OrcaRuntimeService + let ctx: RpcContext + let activeRunId: string + + const coordinatorPaneKey = 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + const workerPaneKey = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + + // Dispatch/automation ptyId shape: `${repoId}::${worktreePath}@@${suffix}` (executionHostId:null). + const PTY_ID = 'repo-7f3a::/data/wt/factory-task-1@@a1b2c3d4' + const INCARNATION_ID = 1 + const PROCESS_INCARNATION = `${PTY_ID}:${INCARNATION_ID}` + + // ---- fake runtime process/handle planes (module-level so spies can mutate them) ---- + let ptysById: Map + let handleTable: Map + let rendererGraphEpoch: number + let closedPtyIds: string[] + + /** Resolve a handle to its live pty only while its stamped graph epoch is current. */ + function resolveHandleToLivePty( + handle: string + ): { ptyId: string; pty: { incarnationId: number; alive: boolean } } | null { + const entry = handleTable.get(handle) + if (!entry) { + return null + } + if (entry.epoch !== rendererGraphEpoch) { + // rendererGraphEpoch fence: the durable handle no longer resolves to a live leaf. + return null + } + const pty = ptysById.get(entry.ptyId) + if (!pty || !pty.alive) { + return null + } + return { ptyId: entry.ptyId, pty } + } + + /** Wire the real RPC surface and db against the two-plane fake runtime. */ + function setup(): void { + ptysById = new Map([ + ['coord-pty', { incarnationId: 1, alive: true }], + [PTY_ID, { incarnationId: INCARNATION_ID, alive: true }] + ]) + handleTable = new Map([ + ['term_coord', { ptyId: 'coord-pty', epoch: 0 }], + ['term_worker', { ptyId: PTY_ID, epoch: 0 }] + ]) + rendererGraphEpoch = 0 + closedPtyIds = [] + + db = new OrchestrationDb(':memory:') + dbOpen = true + runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + + // Incarnation-addressed liveness probe: reads the DURABLE plane, so it stays 'live' across the + // epoch bump (the process really is still running). Matches the real asymmetry. + vi.spyOn(runtime, 'inspectTerminalProcessIncarnationLiveness').mockImplementation( + async (incarnation: string) => { + const idx = incarnation.lastIndexOf(':') + const ptyId = incarnation.slice(0, idx) + const pty = ptysById.get(ptyId) + return pty?.alive ? 'live' : 'exited' + } + ) + + // The incarnation-addressed re-resolution primitive the fix adds. Present here as a spy so the + // same harness proves BOTH tiers: pre-fix completion never calls it (leak); post-fix completion + // calls it to remint a live handle (reap). Fence: EXACT incarnationId match only. + vi.spyOn(runtime, 'resolveTerminalHandleByProcessIncarnation').mockImplementation( + (incarnation: string): string | null => { + const idx = incarnation.lastIndexOf(':') + const ptyId = incarnation.slice(0, idx) + const inc = Number(incarnation.slice(idx + 1)) + const pty = ptysById.get(ptyId) + if (!pty || !pty.alive) { + return null + } + if (pty.incarnationId !== inc) { + // Fence: a reused ptyId with a different incarnation must NOT resolve. + return null + } + // Remint a live handle at the current graph epoch. + handleTable.set('term_reminted', { ptyId, epoch: rendererGraphEpoch }) + return 'term_reminted' + } + ) + + // Identity plane (durable) — answers for the original AND any reminted handle. Independent of + // the graph epoch, exactly like the real getTerminal* accessors that read dispatch authority. + const knownWorkerHandle = (handle: string): boolean => + handle === 'term_worker' || handle === 'term_reminted' + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_coord' + ? coordinatorPaneKey + : knownWorkerHandle(handle) + ? workerPaneKey + : null + ) + vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockImplementation((handle) => + knownWorkerHandle(handle) ? PROCESS_INCARNATION : null + ) + vi.spyOn(runtime, 'getOrchestrationDispatchAuthority').mockImplementation((handle) => + knownWorkerHandle(handle) + ? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test fixture is deliberately shaped to exercise the private/runtime boundary. + ({ + terminalHandle: handle, + paneKey: workerPaneKey, + processIncarnation: PROCESS_INCARNATION, + hostScope: { kind: 'local', hostId: 'local' } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + } as never) + : null + ) + vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) + + // Volatile handle resolution — epoch-fenced. Throws 'terminal_handle_stale' once the epoch + // moves past the epoch at which the handle was issued. + vi.spyOn(runtime, 'showTerminal').mockImplementation(async (handle) => { + if (!resolveHandleToLivePty(handle)) { + throw new Error('terminal_handle_stale') + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + return { handle, worktreeId: 'repo::worktree', status: 'running' } as never + }) + + // The reap: closing a handle kills exactly the pty it resolves to. + vi.spyOn(runtime, 'closeTerminal').mockImplementation(async (handle) => { + const live = resolveHandleToLivePty(handle) + if (!live) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + return { handle, tabId: null, ptyKilled: false } as never + } + live.pty.alive = false + ptysById.delete(live.ptyId) + closedPtyIds.push(live.ptyId) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + return { handle, tabId: `tab:${live.ptyId}`, ptyKilled: true } as never + }) + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // Remaining runtime surface required to start + settle a worker (mirrors the unit harness). + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + vi.spyOn(runtime, 'showManagedTerminalWorkspace').mockResolvedValue({ + id: 'repo::worktree' + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + } as never) + vi.spyOn(runtime, 'createTerminal').mockResolvedValue({ + handle: 'term_worker', + worktreeId: 'repo::worktree', + title: 'worker' + }) + vi.spyOn(runtime, 'waitForTerminal').mockResolvedValue({ + handle: 'term_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca') + vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({ + handle: 'term_worker', + accepted: true, + bytesWritten: 1 + }) + vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) + vi.spyOn(runtime, 'getExactWorkerProviderSession').mockReturnValue(null) + vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ + handle: 'term_worker', + status: 'running', + tail: ['worker output line 1', 'worker output line 2'], + truncated: false, + nextCursor: '2' + }) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + + activeRunId = db.createRun({ + objective: 'PRB-0219 reap leak fixture', + coordinatorHandle: 'term_coord', + coordinatorPaneKey + }).id + ctx = { runtime } + } + + afterEach(() => { + if (dbOpen) { + dbOpen = false + db.close() + } + vi.restoreAllMocks() + }) + + /** Look up a registered orchestration RPC method by name. */ + function findMethod(name: string) { + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find((m) => m.name === name) + if (!method) { + throw new Error(`Method not found: ${name}`) + } + return method + } + + /** Parse a method's params and invoke its handler against the shared ctx. */ + async function call(name: string, params: Record) { + const method = findMethod(name) + const parsed = method.params ? method.params.parse(params) : undefined + return method.handler(parsed, ctx) + } + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + /** Start a worker and settle its report, the state a release acts on. */ + async function startSettledWorker(): Promise<{ taskId: string; dispatchId: string }> { + const task = db.createTask({ spec: 'reap-leak fixture task', runId: activeRunId }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const result = (await call('orchestration.workerStart', { + task: task.id, + from: 'term_coord', + agent: 'codex' + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + })) as { dispatchId: string; state: string } + expect(result.state).toBe('ready') + const settlement = db.settleWorkerReport({ + taskId: task.id, + dispatchId: result.dispatchId, + outcome: 'succeeded', + result: 'worker succeeded' + }) + expect(settlement.action).toBe('settled') + return { taskId: task.id, dispatchId: result.dispatchId } + } + + /** The terminalState workerList projects for a dispatch, or null. */ + async function workerTerminalState(dispatchId: string): Promise { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const listed = (await call('orchestration.workerList', { run: activeRunId })) as { + workers: { dispatchId: string; terminalState: string | null }[] + } + return listed.workers.find((w) => w.dispatchId === dispatchId)?.terminalState ?? null + } + + it('REAP (fixed): a rendererGraphEpoch bump strands the durable handle, but the incarnation fallback remints a live handle and reaps the process', async () => { + setup() + const { dispatchId } = await startSettledWorker() + const resource = db.getWorkerTerminalResourceByOwner(dispatchId) + expect(resource?.process_incarnation).toBe(PROCESS_INCARNATION) + + // Relay reconnect / renderer remount bumps the graph epoch: the durable handle goes stale + // while the PTY stays alive. + rendererGraphEpoch = 1 + await expect(runtime.showTerminal('term_worker')).rejects.toThrow('terminal_handle_stale') + expect(ptysById.get(PTY_ID)?.alive).toBe(true) + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + processAction: string + } + + // The fix: inspectWorkerTerminal re-resolved the live PTY by process incarnation, reminted a + // handle, and completion closed THAT handle — the process is actually reaped. + expect(receipt.state).toBe('released') + expect(receipt.processAction).toBe('closed_agent_terminal') + expect(runtime.closeTerminal).toHaveBeenCalledWith('term_reminted') + expect(closedPtyIds).toEqual([PTY_ID]) + expect(ptysById.has(PTY_ID)).toBe(false) + expect(await workerTerminalState(dispatchId)).toBe('released') + }) + + it('CONTROL: with the graph epoch intact the same release reaps exactly that PTY', async () => { + setup() + const { dispatchId } = await startSettledWorker() + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + processAction: string + } + + expect(receipt.state).toBe('released') + expect(receipt.processAction).toBe('closed_agent_terminal') + expect(closedPtyIds).toEqual([PTY_ID]) + expect(ptysById.has(PTY_ID)).toBe(false) + expect(await workerTerminalState(dispatchId)).toBe('released') + }) + + it('FENCE (fixed): a reused ptyId carrying a different incarnation must NOT remint or close — stays release_unknown', async () => { + setup() + const { dispatchId } = await startSettledWorker() + + // The graph epoch bumps AND the ptyId has been reused by a different process (incarnation 2), + // while the worker's recorded incarnation is still 1. The exact-incarnation fence must refuse. + rendererGraphEpoch = 1 + const reused = ptysById.get(PTY_ID) + if (reused) { + reused.incarnationId = 2 + } + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + processAction: string + } + + expect(receipt.state).toBe('release_unknown') + expect(receipt.processAction).toBe('none') + expect(runtime.closeTerminal).not.toHaveBeenCalled() + // The other lane's live process is left untouched (never reaped by an over-broad match). + expect(ptysById.get(PTY_ID)?.alive).toBe(true) + expect(await workerTerminalState(dispatchId)).toBe('release_unknown') + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts index 52cf3579016..a2beea9ea05 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts @@ -30,7 +30,7 @@ vi.mock('./orchestration/federation/federated-worker-start', () => ({ })) vi.mock('./orchestration-structured-worker-session', async (importOriginal) => ({ ...(await importOriginal>()), - sendStructuredWorkerPreamble: async () => {}, + sendStructuredWorkerPreamble: async () => 'accepted', releaseStructuredWorkerSession: () => {}, discardStructuredWorkerSession: async () => {} })) diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start.ts index a7c59b7064b..957b4a6dde7 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start.ts @@ -1,3 +1,4 @@ +import type { OrchestrationSessionCaller } from '../../../../orchestration/orchestration-caller-identity' import { isTuiAgent } from '../../../../../../shared/tui-agent-config' import type { RuntimeStatus } from '../../../../../../shared/runtime-types' import { @@ -45,6 +46,8 @@ export async function startFederatedWorker(args: { method: string payloadHash: string } + /** The coordinator's resolved session, when it is one; recorded as the Dispatch creator. */ + callerSession?: OrchestrationSessionCaller }): Promise { const { params, runtime, db, task, runId, orchestrationMutation } = args if (!isWorkerStartTimeoutWithinTimerLimit(params.timeoutMs)) { @@ -117,7 +120,7 @@ export async function startFederatedWorker(args: { const setupDecision = createsWorktree ? (params.setup ?? 'run') : 'not_applicable' const started = db.createStartingWorkerDispatch({ - creator: resolveDispatchCreator(runtime, params.from), + creator: resolveDispatchCreator(runtime, params.from, args.callerSession), maxDepth: runtime.getNestedWorkerMaxDepth(), taskId: task?.id, taskSpec: params.spec, diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts index 6f7a1bba077..067f583e37b 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts @@ -114,6 +114,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [ observeSetupCompletion: true, createdWithAgent: agent as TuiAgent, startupAgent: agent as TuiAgent, + startupLaunchSource: 'orchestration', ...(launch.preferences ? { startupLaunchPreferences: launch.preferences } : {}), activate: false, lineage: { noParent: true } @@ -185,6 +186,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [ // Why: agent ids are not shell commands (`cursor` is the desktop app, // its CLI is `cursor-agent`); resolve through the TUI agent config. startupAgent: agent as TuiAgent, + launchSource: 'orchestration', ...(launch.preferences ? { launchPreferences: launch.preferences } : {}), title: `worker-${params.taskId}`, presentation: 'background' diff --git a/src/main/runtime/rpc/methods/orchestration/gates/gates.ts b/src/main/runtime/rpc/methods/orchestration/gates/gates.ts index 29be91b4324..f0736db916c 100644 --- a/src/main/runtime/rpc/methods/orchestration/gates/gates.ts +++ b/src/main/runtime/rpc/methods/orchestration/gates/gates.ts @@ -84,7 +84,10 @@ export const ORCHESTRATION_GATE_METHODS = [ defineMethod({ name: 'orchestration.gateCreate', params: GateCreateParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { + handler: ( + params, + { orchestrationCompatibilityEvidence, orchestrationCaller, runtime, legacyCoordinatorRunId } + ) => { const db = runtime.getOrchestrationDb() let options: string[] | undefined if (params.options) { @@ -107,7 +110,8 @@ export const ORCHESTRATION_GATE_METHODS = [ callerTerminalHandle: params.from, requireCurrentConsumer: true, legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence + callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller }) if (task.run_id !== run.id) { throw taskNotFoundError(`Task ${params.task} was not found in Run ${run.id}.`, { @@ -127,7 +131,10 @@ export const ORCHESTRATION_GATE_METHODS = [ defineMethod({ name: 'orchestration.gateResolve', params: GateResolveParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { + handler: ( + params, + { orchestrationCompatibilityEvidence, orchestrationCaller, runtime, legacyCoordinatorRunId } + ) => { const db = runtime.getOrchestrationDb() const existing = db.getGate(params.id) if (!existing) { @@ -138,7 +145,8 @@ export const ORCHESTRATION_GATE_METHODS = [ callerTerminalHandle: params.from, requireCurrentConsumer: true, legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence + callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller }) // Why: a gate outside the caller's Run is indistinguishable from a missing one, so probing cannot map foreign Runs. if (existing.run_id !== run.id) { @@ -155,7 +163,10 @@ export const ORCHESTRATION_GATE_METHODS = [ defineMethod({ name: 'orchestration.gateList', params: GateListParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { + handler: ( + params, + { orchestrationCompatibilityEvidence, orchestrationCaller, runtime, legacyCoordinatorRunId } + ) => { const db = runtime.getOrchestrationDb() const explicitRun = params.run ? db.getRun(params.run) : undefined // Why: same read posture as taskList — an explicitly named Run is inspectable, an unnamed one means the caller's own. @@ -167,7 +178,8 @@ export const ORCHESTRATION_GATE_METHODS = [ callerTerminalHandle: params.from, requireCurrentConsumer: params.run === undefined, legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence + callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller }) const gates = db .listGates({ diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts index ef191250a8b..9b49e7a07a1 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts @@ -5,6 +5,11 @@ import { isGroupAddress } from '../../../../orchestration/groups' import { AskParams } from '../schemas' import { rejectFederatedExplicitTarget } from '../routing' import { askRemoteRunHome } from './ask-remote' +import { + mailboxAddressOf, + runCoordinatorKey +} from '../../../../orchestration/orchestration-caller-identity' +import { resolveOrchestrationParty } from '../../../../orchestration/orchestration-party' export const ORCHESTRATION_ASK_METHODS = [ defineMethod({ @@ -87,7 +92,12 @@ export const ORCHESTRATION_ASK_METHODS = [ `Dispatch ${activeDispatch.id} belongs to Run ${run.id}, not ${params.run}.` ) } - if (params.to && params.to !== `run:${run.id}` && params.to !== run.coordinator_handle) { + if ( + params.to && + params.to !== `run:${run.id}` && + resolveOrchestrationParty(params.to, db).address !== + mailboxAddressOf(runCoordinatorKey(run)) + ) { throw new OrchestrationError( 'dispatch_run_mismatch', `ask from Dispatch ${activeDispatch.id} must target its owning Run ${run.id}.` diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts index 20ac25e6512..b9aa437e179 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts @@ -6,6 +6,8 @@ import { checkRunMailbox } from './check-run' import { checkWorkerMailbox } from './check-worker' import { checkDirectMailbox } from './check-direct' import { orchestrationSkillRecoveryData } from '../../../../../../shared/orchestration-rpc-contract' +import { hasRunBindingKey } from '../../../../orchestration/orchestration-caller-identity' +import { orchestrationCallerIdentity } from '../runs/run-scope' import { callerHoldsDispatchPane, dispatchFenced, @@ -20,6 +22,7 @@ export const ORCHESTRATION_CHECK_METHODS = [ params, { orchestrationCompatibilityEvidence, + orchestrationCaller, runtime, signal, legacyCoordinatorRunId, @@ -31,9 +34,14 @@ export const ORCHESTRATION_CHECK_METHODS = [ const handle = params.terminal ?? 'unknown' const typeFilter = parseMessageTypes(params.types) - // Why: a live runtime handle is authoritative; pane metadata is only the restart fallback. - const paneKey = runtime.getTerminalPaneKey(handle) ?? params.terminalPaneKey - const boundRun = paneKey ? db.getCurrentRunForPane(paneKey) : undefined + const caller = orchestrationCallerIdentity(runtime, { + handle, + session: orchestrationCaller, + // Why: a live runtime handle is authoritative; pane metadata is only the restart fallback. + paneKey: runtime.getTerminalPaneKey(handle) ?? params.terminalPaneKey + }) + const paneKey = caller.paneKey ?? undefined + const boundRun = hasRunBindingKey(caller) ? db.getCurrentRunForCoordinator(caller) : undefined if (params.run || boundRun) { return checkRunMailbox({ params, @@ -41,6 +49,7 @@ export const ORCHESTRATION_CHECK_METHODS = [ db, handle, paneKey, + callerSession: orchestrationCaller, typeFilter, signal, legacyCoordinatorRunId, @@ -80,7 +89,8 @@ export const ORCHESTRATION_CHECK_METHODS = [ typeFilter, signal, activeDispatch, - remoteAttachment + remoteAttachment, + recordMutationReceipt }) } const consumingCheck = params.peek !== true && params.all !== true && params.unread !== false @@ -93,7 +103,7 @@ export const ORCHESTRATION_CHECK_METHODS = [ } // Why: a consuming check on a handle with no live pane and no Dispatch can never see // Run mail, so an empty inbox would read as "nothing yet" instead of a stale caller. - if (!paneKey && consumingCheck) { + if (!hasRunBindingKey(caller) && consumingCheck) { throw new OrchestrationError( 'stable_pane_required', `Terminal ${handle} has no live pane bound to a Run, so this inbox can never receive Run mail. Rebind this terminal with orchestration run-use, or read the Run mailbox with --run .`, diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/check-run-pending-mail.ts b/src/main/runtime/rpc/methods/orchestration/messaging/check-run-pending-mail.ts new file mode 100644 index 00000000000..5d453616f35 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/messaging/check-run-pending-mail.ts @@ -0,0 +1,140 @@ +import type { MessageType, OrchestrationDb, RunRow } from '../../../../orchestration/db' +import type { OrcaRuntimeService } from '../../../../orca-runtime' +import { interruptedAcknowledgedCheck } from '../routing' +import { checkWorkerMailbox } from './check-worker' +import { currentDispatchAssigneeRun } from './recipient-routing' +import { callerHoldsDispatchPane, dispatchFenced } from './dispatch-mailbox-fence' +import { orchestrationCallerIdentity } from '../runs/run-scope' +import type { OrchestrationSessionCaller } from '../../../../orchestration/orchestration-caller-identity' +import type { CheckParams } from '../schemas' +import type { z } from 'zod' + +export async function checkRunPendingMail(args: { + params: z.infer + runtime: OrcaRuntimeService + db: OrchestrationDb + run: RunRow + handle: string + paneKey: string | undefined + callerSession: OrchestrationSessionCaller | undefined + typeFilter: MessageType[] | undefined + signal: AbortSignal | undefined + revalidateConsumer: () => void + recordMutationReceipt: ((receipt: unknown) => void) | undefined +}): Promise<{ acknowledged?: string; result?: unknown }> { + const { + params, + runtime, + db, + run, + handle, + paneKey, + typeFilter, + signal, + revalidateConsumer, + recordMutationReceipt + } = args + const generation = run.consumer_generation + const address = `run:${run.id}` + // Drain pre-bind mail through its original Dispatch owner, never by changing its Run. + const residual = !params.run ? db.getActiveDispatchForIdentity(handle, paneKey) : undefined + const caller = orchestrationCallerIdentity(runtime, { + handle, + paneKey, + session: args.callerSession + }) + const residualAck = + params.ack && + residual && + db.getDeliveryRaw(params.ack)?.mailbox_handle === `dispatch:${residual.id}` + ? params.ack + : undefined + const acknowledgeRun = () => + params.ack + ? db.acknowledgeRunDelivery({ + runId: run.id, + consumerGeneration: generation, + deliveryId: params.ack + }) + : undefined + let acknowledged: { delivery: { id: string } } | undefined = residualAck + ? undefined + : acknowledgeRun() + const recordAcknowledged = () => { + if (acknowledged) { + recordMutationReceipt?.( + interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'outcome_unknown') + ) + } + } + recordAcknowledged() + if ( + residual && + residual.run_id !== run.id && + (residual.assignee_orca_session_id === null || + residual.assignee_orca_session_id === caller.orcaSessionId) && + callerHoldsDispatchPane(residual, paneKey) && + currentDispatchAssigneeRun(runtime, db, residual)?.id === run.id && + (residualAck || !db.hasOutstandingMailboxDelivery(address)) + ) { + const result = await checkWorkerMailbox({ + params: { ...params, ack: residualAck, wait: false }, + runtime, + db, + handle, + paneKey, + typeFilter, + signal, + activeDispatch: residual, + remoteAttachment: undefined, + wakeTypes: params.wait ? typeFilter : undefined, + // Accept the original owner's ack without creating a batch ahead of Run replay. + deferDelivery: () => db.hasOutstandingMailboxDelivery(address), + revalidateConsumer: () => { + revalidateConsumer() + const current = db.getActiveDispatchForIdentity(handle, paneKey) + if ( + !current || + current.id !== residual.id || + currentDispatchAssigneeRun(runtime, db, current)?.id !== run.id + ) { + throw dispatchFenced() + } + }, + recordMutationReceipt + }) + if (result?.acknowledged) { + acknowledged = { delivery: { id: result.acknowledged } } + } + recordAcknowledged() + try { + revalidateConsumer() + } catch (error) { + if (acknowledged) { + return { + acknowledged: acknowledged.delivery.id, + result: interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'consumer_fenced') + } + } + throw error + } + const inspectingHistory = + params.all === true || (params.unread === false && params.peek !== true) + if ( + result && + result.count > 0 && + (!inspectingHistory || db.getUnreadMessages(`dispatch:${residual.id}`).length > 0) + ) { + return { + acknowledged: acknowledged?.delivery.id, + result: { ...result, acknowledged: acknowledged?.delivery.id ?? null } + } + } + } + + // A supplied Delivery outside this caller's current Dispatch must still fail acknowledgment. + if (params.ack && !acknowledged) { + acknowledgeRun() + } + return { acknowledged: acknowledged?.delivery.id } +} diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/check-run.ts b/src/main/runtime/rpc/methods/orchestration/messaging/check-run.ts index 6db89cf4a20..c48141cf7dd 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/check-run.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/check-run.ts @@ -9,6 +9,8 @@ import { routeAllMailboxPages } from '../schemas' import { resolveRunScope } from '../runs/run-scope' import type { CheckParams } from '../schemas' import type { z } from 'zod' +import type { OrchestrationSessionCaller } from '../../../../orchestration/orchestration-caller-identity' +import { checkRunPendingMail } from './check-run-pending-mail' type CheckParamsInput = z.infer @@ -18,6 +20,7 @@ export async function checkRunMailbox(args: { db: OrchestrationDb handle: string paneKey: string | undefined + callerSession: OrchestrationSessionCaller | undefined typeFilter: MessageType[] | undefined signal: AbortSignal | undefined legacyCoordinatorRunId: string | undefined @@ -31,12 +34,12 @@ export async function checkRunMailbox(args: { db, handle, paneKey, + callerSession, typeFilter, signal, legacyCoordinatorRunId, revalidateLegacyCoordinator, - orchestrationCompatibilityEvidence, - recordMutationReceipt + orchestrationCompatibilityEvidence } = args const routeDirectSnapshot = async ( runId: string, @@ -52,6 +55,7 @@ export async function checkRunMailbox(args: { runId: params.run, callerTerminalHandle: handle, callerPaneKey: paneKey, + callerSession, requireCurrentConsumer: true, legacyCoordinatorRunId, callerEvidence: orchestrationCompatibilityEvidence @@ -68,40 +72,45 @@ export async function checkRunMailbox(args: { db.routeUnreadDirectMessagesToRunMailbox(run.id, coordinatorHandle, throughSequence) ) } - revalidateLegacyCoordinator?.() - const currentRun = resolveRunScope(runtime, { - runId: run.id, - callerTerminalHandle: handle, - callerPaneKey: paneKey, - requireCurrentConsumer: true, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }) - if (currentRun.consumer_generation !== generation) { - throw new OrchestrationError( - 'consumer_fenced', - 'This mailbox consumer was replaced while routing pending mail.' - ) + const revalidateConsumer = (): void => { + revalidateLegacyCoordinator?.() + const currentRun = resolveRunScope(runtime, { + runId: run.id, + callerTerminalHandle: handle, + callerPaneKey: paneKey, + callerSession, + requireCurrentConsumer: true, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + if (currentRun.consumer_generation !== generation) { + throw new OrchestrationError( + 'consumer_fenced', + 'This mailbox consumer was replaced while routing pending mail.' + ) + } } + revalidateConsumer() - const acknowledged = params.ack - ? db.acknowledgeRunDelivery({ - runId: run.id, - consumerGeneration: generation, - deliveryId: params.ack - }) - : undefined - if (acknowledged) { - recordMutationReceipt?.( - interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'outcome_unknown') - ) + const pending = await checkRunPendingMail({ ...args, run, revalidateConsumer }) + try { + revalidateConsumer() + } catch (error) { + if (pending.acknowledged) { + return interruptedAcknowledgedCheck(run.id, pending.acknowledged, 'consumer_fenced') + } + throw error } + if (pending.result) { + return pending.result + } + const acknowledged = pending.acknowledged if (params.all || (params.unread === false && !params.peek)) { const messages = db.getRunMailboxHistory(run.id, 100, typeFilter) const result = { messages: exposeMessages(messages), count: messages.length, - acknowledged: acknowledged?.delivery.id ?? null + acknowledged: acknowledged ?? null } if (params.format || params.inject) { return { @@ -117,7 +126,7 @@ export async function checkRunMailbox(args: { runId: run.id, messages: exposeMessages(messages), count: messages.length, - acknowledged: acknowledged?.delivery.id ?? null, + acknowledged: acknowledged ?? null, ...(params.format || params.inject ? { formatted: messages.map(formatMessageBanner).join('\n\n') } : {}) @@ -137,7 +146,7 @@ export async function checkRunMailbox(args: { messages: exposeMessages(current.messages), count: current.messages.length, replayed: current.replayed, - acknowledged: acknowledged?.delivery.id ?? null, + acknowledged: acknowledged ?? null, timedOut: false, cancelled: false, connectionLost: false, @@ -155,7 +164,7 @@ export async function checkRunMailbox(args: { deliveryId: null, messages: [], count: 0, - acknowledged: acknowledged?.delivery.id ?? null, + acknowledged: acknowledged ?? null, timedOut: false, cancelled: false, connectionLost: false @@ -169,17 +178,17 @@ export async function checkRunMailbox(args: { exclusive: true }) try { - revalidateLegacyCoordinator?.() + revalidateConsumer() } catch (error) { if (!acknowledged) { throw error } - return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'consumer_fenced') + return interruptedAcknowledgedCheck(run.id, acknowledged, 'consumer_fenced') } const latestRun = db.getRun(run.id) if (!latestRun || latestRun.consumer_generation !== generation) { if (acknowledged) { - return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'consumer_fenced') + return interruptedAcknowledgedCheck(run.id, acknowledged, 'consumer_fenced') } throw new OrchestrationError( 'consumer_fenced', @@ -188,7 +197,7 @@ export async function checkRunMailbox(args: { } if (waitResult === 'waiter_exists') { if (acknowledged) { - return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'waiter_exists') + return interruptedAcknowledgedCheck(run.id, acknowledged, 'waiter_exists') } throw new OrchestrationError( 'waiter_exists', @@ -204,7 +213,7 @@ export async function checkRunMailbox(args: { deliveryId: null, messages: [], count: 0, - acknowledged: acknowledged?.delivery.id ?? null, + acknowledged: acknowledged ?? null, timedOut: true, cancelled: false, connectionLost: false @@ -224,7 +233,7 @@ export async function checkRunMailbox(args: { deliveryId: null, messages: [], count: 0, - acknowledged: acknowledged?.delivery.id ?? null, + acknowledged: acknowledged ?? null, timedOut: false, cancelled: true, connectionLost: signal?.aborted === true @@ -241,7 +250,7 @@ export async function checkRunMailbox(args: { messages: exposeMessages(current?.messages ?? []), count: current?.messages.length ?? 0, replayed: current?.replayed ?? false, - acknowledged: acknowledged?.delivery.id ?? null, + acknowledged: acknowledged ?? null, timedOut: false, cancelled: false, connectionLost: false, diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/check-worker.ts b/src/main/runtime/rpc/methods/orchestration/messaging/check-worker.ts index fc9fa6b04e9..7616f3e5cc1 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/check-worker.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/check-worker.ts @@ -5,6 +5,8 @@ import { formatMessageBanner } from '../../../../orchestration/formatter' import { exposeMessages } from './mailbox-message-receipt' import { routeAllMailboxPages } from '../schemas' import { asDispatchFence, callerHoldsDispatchPane, dispatchFenced } from './dispatch-mailbox-fence' +import { interruptedAcknowledgedCheck } from '../routing' +import { currentDispatchAssigneeRun } from './recipient-routing' import type { CheckParams } from '../schemas' import type { z } from 'zod' @@ -24,7 +26,11 @@ export async function checkWorkerMailbox(args: { signal: AbortSignal | undefined activeDispatch: ActiveDispatch | undefined remoteAttachment: RemoteAttachment | undefined -}): Promise { + wakeTypes?: MessageType[] + revalidateConsumer?: () => void + deferDelivery?: () => boolean + recordMutationReceipt?: (receipt: unknown) => void +}) { const { params, runtime, @@ -54,6 +60,7 @@ export async function checkWorkerMailbox(args: { } const deliveryRunId = workerMailbox.runId db.requireRun(deliveryRunId) + const mailboxIdentity = { runId: deliveryRunId, dispatchId: workerMailbox.dispatchId } const address = `dispatch:${workerMailbox.dispatchId}` // Why: a federated worker host has no dispatch_contexts row, so its generation lives on the // remote_dispatch_attachments row instead. @@ -165,6 +172,7 @@ export async function checkWorkerMailbox(args: { } } await revalidateWorkerMailbox() + args.revalidateConsumer?.() let acknowledged try { acknowledged = params.ack @@ -179,9 +187,17 @@ export async function checkWorkerMailbox(args: { } catch (error) { throw asDispatchFence(error) } + if (acknowledged) { + args.recordMutationReceipt?.( + interruptedAcknowledgedCheck(deliveryRunId, acknowledged.delivery.id, 'outcome_unknown') + ) + } const showAll = params.all === true || (params.unread === false && params.peek !== true) const readPeek = () => db.getUnreadMessages(address, typeFilter) const readDelivery = (wakeTypes?: MessageType[]) => { + if (args.deferDelivery?.()) { + return undefined + } try { return db.getOrCreateMailboxDelivery({ runId: deliveryRunId, @@ -197,8 +213,7 @@ export async function checkWorkerMailbox(args: { if (showAll) { const messages = db.getAllMessagesForHandle(address, 100, typeFilter) return { - ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), - dispatchId: workerMailbox.dispatchId, + ...mailboxIdentity, messages: exposeMessages(messages), count: messages.length, acknowledged: acknowledged?.delivery.id ?? null, @@ -211,8 +226,7 @@ export async function checkWorkerMailbox(args: { const messages = readPeek() if (messages.length > 0 || !params.wait) { return { - ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), - dispatchId: workerMailbox.dispatchId, + ...mailboxIdentity, messages: exposeMessages(messages), count: messages.length, acknowledged: acknowledged?.delivery.id ?? null, @@ -222,11 +236,10 @@ export async function checkWorkerMailbox(args: { } } } else { - const current = readDelivery(params.wait ? typeFilter : undefined) + const current = readDelivery(params.wait ? typeFilter : args.wakeTypes) if (current || !params.wait) { return { - ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), - dispatchId: workerMailbox.dispatchId, + ...mailboxIdentity, deliveryId: current?.delivery.id ?? null, messages: exposeMessages(current?.messages ?? []), count: current?.messages.length ?? 0, @@ -241,19 +254,22 @@ export async function checkWorkerMailbox(args: { } } } - const waitResult = await runtime.waitForMessage(address, { - typeFilter: typeFilter as string[] | undefined, - timeoutMs: params.timeoutMs ?? undefined, - signal - }) + // Binding can happen during recovery, before run-create/run-use can cancel this wait. + const waitResult = + activeDispatch && currentDispatchAssigneeRun(runtime, db, activeDispatch) + ? 'cancelled' + : await runtime.waitForMessage(address, { + typeFilter: typeFilter as string[] | undefined, + timeoutMs: params.timeoutMs ?? undefined, + signal + }) await revalidateWorkerMailbox() if (readCurrentGeneration() !== workerMailbox.generation) { throw dispatchFenced() } if (waitResult === 'timed_out' || waitResult === 'cancelled') { return { - ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), - dispatchId: workerMailbox.dispatchId, + ...mailboxIdentity, messages: [], count: 0, acknowledged: acknowledged?.delivery.id ?? null, @@ -265,8 +281,7 @@ export async function checkWorkerMailbox(args: { if (params.peek) { const arrived = readPeek() return { - ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), - dispatchId: workerMailbox.dispatchId, + ...mailboxIdentity, messages: exposeMessages(arrived), count: arrived.length, acknowledged: acknowledged?.delivery.id ?? null, @@ -277,8 +292,7 @@ export async function checkWorkerMailbox(args: { } const arrived = readDelivery(typeFilter) return { - ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), - dispatchId: workerMailbox.dispatchId, + ...mailboxIdentity, deliveryId: arrived?.delivery.id ?? null, messages: exposeMessages(arrived?.messages ?? []), count: arrived?.messages.length ?? 0, diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/dispatch-recipient-identity.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/dispatch-recipient-identity.test.ts new file mode 100644 index 00000000000..4ab4b6555a0 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/messaging/dispatch-recipient-identity.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { testOrcaSessionId } from '../../../../../../shared/orca-session-address-test-fixture' +import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' +import { createOrchestrationRpcHarness } from '../rpc-test-harness' + +const PANE = 'tab_lead:22222222-2222-4222-9222-222222222222' +const OTHER = 'tab_other:33333333-3333-4333-8333-333333333333' +const SESSION = testOrcaSessionId('4bd46b4a-035b-41dd-a122-a9c29122ff11') + +describe.each([false, true])('Dispatch recipient identity (settled=%s)', (settled) => { + const h = createOrchestrationRpcHarness() + let state: ReturnType + let dispatch: ReturnType + + beforeEach(() => { + state = h.setup() + const task = state.db.createTask({ spec: 'lead' }) + dispatch = createRootDispatch(state.db, task.id, 'term_lead', PANE) + if (settled) { + state.db.completeDispatch(dispatch.id) + } + vi.spyOn(state.runtime, 'getLiveTerminalPaneKey').mockImplementation((handle) => + handle === 'term_lead' ? PANE : h.coordinatorPaneKey + ) + }) + afterEach(() => h.cleanup()) + + function send() { + return h.call( + 'orchestration.send', + { from: 'term_coord', to: `dispatch:${dispatch.id}`, subject: 'follow up' }, + state.ctx + ) + } + function sessionRun() { + state.db.db + .prepare('UPDATE dispatch_contexts SET assignee_orca_session_id = ? WHERE id = ?') + .run(SESSION, dispatch.id) + return state.db.createRun({ + objective: 'session lead', + coordinatorHandle: 'term_lead', + coordinatorPaneKey: null, + coordinatorOrcaSessionId: SESSION + }) + } + async function expectRun(runId: string) { + if (settled) { + await expect(send()).rejects.toMatchObject({ + code: 'dispatch_inactive', + message: expect.stringContaining(`Send to run:${runId} instead`) + }) + expect(state.db.getInbox()).toEqual([]) + } else { + expect(await send()).toMatchObject({ message: { to_handle: `run:${runId}`, run_id: runId } }) + } + } + async function expectNoRedirect(unrelatedRun: string) { + if (settled) { + await expect(send()).rejects.toMatchObject({ + code: 'dispatch_inactive', + message: expect.not.stringContaining(unrelatedRun) + }) + expect(state.db.getInbox()).toEqual([]) + } else { + expect(await send()).toMatchObject({ + message: { to_handle: `dispatch:${dispatch.id}`, run_id: dispatch.run_id } + }) + } + } + + it('uses a durable session binding without a live pane', async () => { + const run = sessionRun() + vi.mocked(state.runtime.getLiveTerminalPaneKey).mockReturnValue(null) + await expectRun(run.id) + }) + + it('uses the recorded session instead of an unrelated Run now occupying the saved pane', async () => { + const run = sessionRun() + state.db.createRun({ + objective: 'new occupant', + coordinatorHandle: 'term_other', + coordinatorPaneKey: PANE + }) + await expectRun(run.id) + }) + + it('ignores an old session column left behind by an older binary rebind', async () => { + const run = sessionRun() + state.db.db + .prepare( + 'UPDATE runs SET coordinator_handle = ?, coordinator_pane_key = ?, consumer_generation = consumer_generation + 1 WHERE id = ?' + ) + .run('term_other', OTHER, run.id) + await expectNoRedirect(run.id) + }) + + it('does not follow a closed handle to another occupant of its old pane', async () => { + const run = state.db.createRun({ + objective: 'new occupant', + coordinatorHandle: 'term_other', + coordinatorPaneKey: PANE + }) + vi.mocked(state.runtime.getLiveTerminalPaneKey).mockReturnValue(null) + await expectNoRedirect(run.id) + }) + + it.each(['replacement:pty:2', null])( + 'does not redirect with a replaced or unverifiable process: %s', + async (process) => { + const run = state.db.createRun({ + objective: 'pane run', + coordinatorHandle: 'term_lead', + coordinatorPaneKey: PANE + }) + state.db.db + .prepare('UPDATE dispatch_contexts SET process_incarnation = ? WHERE id = ?') + .run('original:pty:1', dispatch.id) + vi.mocked(state.runtime.getTerminalProcessIncarnation).mockReturnValue(process) + await expectNoRedirect(run.id) + expect(state.db.getDispatchContextById(dispatch.id)?.status).toBe( + settled ? 'completed' : dispatch.status + ) + } + ) + + it('accepts a reminted tab half with the same pane leaf and process', async () => { + const run = state.db.createRun({ + objective: 'same pane', + coordinatorHandle: 'term_lead', + coordinatorPaneKey: PANE + }) + vi.mocked(state.runtime.getLiveTerminalPaneKey).mockReturnValue( + PANE.replace('tab_lead', 'tab_restored') + ) + await expectRun(run.id) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts index 51a9d9bc0c5..e079596f80d 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts @@ -4,13 +4,15 @@ import { OrchestrationError } from '../../../../orchestration/orchestration-erro import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../../../../shared/orchestration-rpc-contract' import { abbreviateOrchestrationTasks } from '../../../../../../shared/orchestration-task-summary' import { parseOrchestrationTaskDepsFlag } from '../../../../orchestration/task-deps-flag' -import { resolveRunScope } from '../runs/run-scope' +import { orchestrationCallerIdentity, resolveRunScope } from '../runs/run-scope' import { readMutationReplayNudge, stripMutationReplayNudge } from '../../../orchestration-mutation-executor' import { exposeMessage } from './mailbox-message-receipt' +import { resolveOrchestrationParty } from '../../../../orchestration/orchestration-party' import { recordReceiptBeforeNudge, replayMutationNudge } from './mutation-replay-nudge' +import { resolveReplyRecipient } from './recipient-routing' import { ReplyParams, InboxParams, @@ -27,6 +29,7 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ params, { orchestrationCompatibilityEvidence, + orchestrationCaller, runtime, legacyCoordinatorRunId, recordMutationReceipt, @@ -73,7 +76,8 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ callerTerminalHandle: params.from, requireCurrentConsumer: true, legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence + callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller }) const answered = db.answerQuestion({ messageId: question.message_id, @@ -110,15 +114,20 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ ) } + const recipient = resolveReplyRecipient({ + runtime, + db, + originalFrom: original.from_handle, + originalRunId: original.run_id + }) db.markAsRead([original.id]) - const reply = db.insertMessage({ from: params.from ?? original.to_handle, - to: original.from_handle, + to: recipient.to, subject: `Re: ${original.subject}`, body: params.body, threadId: original.thread_id ?? original.id, - runId: original.run_id + runId: recipient.runId }) const receipt = { message: exposeMessage(reply) } @@ -135,7 +144,10 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ const db = runtime.getOrchestrationDb() // Why: stale/unknown handles return empty rather than error — historical rows survive handle deletion (design doc §3.3). const messages = params.terminal - ? db.getAllMessagesForHandle(params.terminal, params.limit) + ? db.getAllMessagesForHandle( + resolveOrchestrationParty(params.terminal, db).address, + params.limit + ) : db.getInbox(params.limit) return { messages, count: messages.length } } @@ -144,7 +156,10 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ defineMethod({ name: 'orchestration.taskCreate', params: TaskCreateParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { + handler: ( + params, + { orchestrationCompatibilityEvidence, orchestrationCaller, runtime, legacyCoordinatorRunId } + ) => { const db = runtime.getOrchestrationDb() const deps = params.deps ? parseOrchestrationTaskDepsFlag(params.deps) : undefined const run = resolveRunScope(runtime, { @@ -152,10 +167,19 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ callerTerminalHandle: params.callerTerminalHandle, requireCurrentConsumer: true, legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence + callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller }) - const creatorAuthority = params.callerTerminalHandle - ? runtime.getOrchestrationDispatchAuthority(params.callerTerminalHandle) + // A handle-less session creates root Tasks: Task lineage is recorded by terminal only. + const creatorHandle = params.callerTerminalHandle + ? orchestrationCallerIdentity(runtime, { + handle: params.callerTerminalHandle, + session: orchestrationCaller, + paneKey: null + }).terminalHandle + : null + const creatorAuthority = creatorHandle + ? runtime.getOrchestrationDispatchAuthority(creatorHandle) : null const task = db.createTask({ spec: params.spec, @@ -163,7 +187,7 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ displayName: params.displayName, deps, parentId: params.parent, - createdByTerminalHandle: params.callerTerminalHandle, + createdByTerminalHandle: creatorHandle ?? undefined, ...(creatorAuthority?.paneKey && creatorAuthority.processIncarnation ? { createdByPaneKey: creatorAuthority.paneKey, @@ -180,7 +204,10 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ defineMethod({ name: 'orchestration.taskList', params: TaskListParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { + handler: ( + params, + { orchestrationCompatibilityEvidence, orchestrationCaller, runtime, legacyCoordinatorRunId } + ) => { const db = runtime.getOrchestrationDb() const explicitRun = params.run ? db.getRun(params.run) : undefined const run = @@ -191,7 +218,8 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ callerTerminalHandle: params.callerTerminalHandle, requireCurrentConsumer: params.run === undefined, legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence + callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller }) // Why: listTasksWithDispatch adds assignee_handle + dispatch_id (NULL for non-dispatched), so legacy-shape consumers are unaffected. const joined = db.listTasksWithDispatch({ @@ -218,14 +246,18 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ defineMethod({ name: 'orchestration.taskUpdate', params: TaskUpdateParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { + handler: ( + params, + { orchestrationCompatibilityEvidence, orchestrationCaller, runtime, legacyCoordinatorRunId } + ) => { const db = runtime.getOrchestrationDb() const run = resolveRunScope(runtime, { runId: params.run, callerTerminalHandle: params.callerTerminalHandle, requireCurrentConsumer: true, legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence + callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller }) const existing = db.getTask(params.id) if (!existing || existing.run_id !== run.id) { diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts b/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts index f2b5e939a65..8828c771876 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts @@ -2,6 +2,15 @@ import type { LegacyAdoptedMailboxOwner, OrchestrationDb } from '../../../../orc import { OrchestrationError } from '../../../../orchestration/orchestration-error' import type { DispatchContextRow, DispatchStatus } from '../../../../orchestration/types' import type { OrcaRuntimeService } from '../../../../orca-runtime' +import { resolveOrchestrationParty } from '../../../../orchestration/orchestration-party' +import { isEquivalentPaneKey } from '../../../../orchestration/db/pane-key-match' +import { CURRENT_CONTRACT_VERSION } from '../../../../orchestration/db/contract-constants' +import { readAgentSessionRecordStore } from '../../../../orchestration/structured-session-lineage' +import { + readSessionRecipient, + refuseUndeliverableSessionRecipient, + type SessionRecipientRefusal +} from './session-recipient' const ACTIVE_DISPATCH_STATUSES: readonly DispatchStatus[] = ['pending', 'dispatched'] @@ -12,20 +21,123 @@ const ACTIVE_DISPATCH_STATUSES: readonly DispatchStatus[] = ['pending', 'dispatc * mailbox, so accepting the message reports success for a delivery that cannot * happen. Federated targets keep their own liveness check. */ -export function assertDispatchMailboxDeliverable(db: OrchestrationDb, dispatchId: string): void { +export function assertDispatchMailboxDeliverable( + runtime: OrcaRuntimeService, + db: OrchestrationDb, + dispatchId: string +): void { const dispatch = db.getDispatchContextById(dispatchId) if (!dispatch || ACTIVE_DISPATCH_STATUSES.includes(dispatch.status)) { return } + const recipientRun = currentDispatchAssigneeRun(runtime, db, dispatch)?.id ?? dispatch.run_id throw new OrchestrationError( 'dispatch_inactive', - `Dispatch ${dispatchId} is ${dispatch.status}; its worker will never read that mailbox. Send to run:${dispatch.run_id} instead, or start a new Dispatch for follow-up work.` + `Dispatch ${dispatchId} is ${dispatch.status}; its worker will never read that mailbox. Send to run:${recipientRun} instead, or start a new Dispatch for follow-up work.` ) } +// A saved pane alone cannot identify its occupant after reuse. +export function currentDispatchAssigneeRun( + runtime: OrcaRuntimeService, + db: OrchestrationDb, + dispatch: DispatchContextRow +) { + if ( + dispatch.contract_version !== CURRENT_CONTRACT_VERSION || + db.getFederatedDispatch(dispatch.id) + ) { + return undefined + } + if (dispatch.assignee_orca_session_id !== null) { + return db.getCurrentRunForCoordinator({ + terminalHandle: dispatch.assignee_handle, + paneKey: null, + orcaSessionId: dispatch.assignee_orca_session_id + }) + } + if (dispatch.assignee_handle === null) { + return undefined + } + const paneKey = runtime.getLiveTerminalPaneKey(dispatch.assignee_handle) + if ( + !paneKey || + (dispatch.assignee_pane_key && !isEquivalentPaneKey(dispatch.assignee_pane_key, paneKey)) || + (dispatch.process_incarnation !== null && + runtime.getTerminalProcessIncarnation(dispatch.assignee_handle) !== + dispatch.process_incarnation) + ) { + return undefined + } + return db.getCurrentRunForPane(paneKey) +} + +// Nested coordinators receive new mail where their current Run check waits. +export function resolveRunBoundDispatchRecipient( + runtime: OrcaRuntimeService, + db: OrchestrationDb, + dispatchId: string, + explicitRunId?: string +): { to: string; runId: string; warning: SendRecipientWarning } | undefined { + const dispatch = db.getDispatchContextById(dispatchId) + if (!dispatch || !ACTIVE_DISPATCH_STATUSES.includes(dispatch.status)) { + return undefined + } + const boundRun = currentDispatchAssigneeRun(runtime, db, dispatch) + if (!boundRun || boundRun.id === dispatch.run_id) { + return undefined + } + const recipient = `dispatch:${dispatchId}` + const mismatch = runMismatch(recipient, boundRun.id, explicitRunId) + if (mismatch && !mismatch.ok) { + throw new OrchestrationError(mismatch.code, mismatch.message) + } + return { + to: `run:${boundRun.id}`, + runId: boundRun.id, + warning: { + code: 'recipient_run_bound_redirect', + recipient, + message: `${recipient} is assigned to a terminal that now coordinates Run ${boundRun.id}; queued for run:${boundRun.id}, the mailbox that terminal reads.` + } + } +} + +// Replies share send routing; unresolved historical senders keep their original address. +export function resolveReplyRecipient(params: { + runtime: OrcaRuntimeService + db: OrchestrationDb + originalFrom: string + originalRunId: string | undefined +}): { to: string; runId: string | undefined } { + const { runtime, db, originalFrom, originalRunId } = params + const unchanged = { to: originalFrom, runId: originalRunId } + if (originalFrom.startsWith('run:')) { + return { to: originalFrom, runId: originalFrom.slice('run:'.length) } + } + if (originalFrom.startsWith('dispatch:')) { + const dispatchId = originalFrom.slice('dispatch:'.length) + // Federation owns its own recipient and liveness checks. + if (db.getFederatedDispatch(dispatchId)) { + return unchanged + } + assertDispatchMailboxDeliverable(runtime, db, dispatchId) + const runBound = resolveRunBoundDispatchRecipient(runtime, db, dispatchId) + return runBound ?? unchanged + } + const recipient = resolveBareOrchestrationRecipient({ + runtime, + db, + handle: originalFrom, + senderRunId: originalRunId + }) + return recipient.ok ? { to: recipient.to, runId: recipient.runId ?? originalRunId } : unchanged +} + export type SendRecipientWarning = { code: | 'legacy_terminal_recipient' + | 'recipient_run_bound_redirect' | 'recipient_unreachable' | 'recipient_ambiguous' | 'recipient_run_mismatch' @@ -42,7 +154,11 @@ export type BareRecipientResolution = } | { ok: false - code: 'terminal_not_found' | 'recipient_ambiguous' | 'recipient_run_mismatch' + code: + | 'terminal_not_found' + | 'recipient_ambiguous' + | 'recipient_run_mismatch' + | SessionRecipientRefusal['code'] message: string warning: SendRecipientWarning } @@ -55,9 +171,25 @@ export function resolveBareOrchestrationRecipient(params: { explicitRunId?: string legacyAdoptedMailboxOwner?: LegacyAdoptedMailboxOwner | null }): BareRecipientResolution { - const { runtime, db, handle } = params - const paneKey = runtime.getLiveTerminalPaneKey(handle) ?? undefined - const boundRun = paneKey ? db.getCurrentRunForPane(paneKey) : undefined + const { runtime, db } = params + const sessionStore = readAgentSessionRecordStore() + const session = readSessionRecipient(params.handle, sessionStore) + if (session && 'code' in session) { + return refused(params.handle, session) + } + const party = resolveOrchestrationParty(session?.address ?? params.handle, db) + const handle = party.address + const paneKey = + party.terminalHandle === null + ? undefined + : (runtime.getLiveTerminalPaneKey(party.terminalHandle) ?? undefined) + // Why: a session-backed party's Run binding is durable, so it outranks whether its pane is live. + const boundRun = + party.orcaSessionId !== null + ? db.getCurrentRunForCoordinator(party) + : paneKey + ? db.getCurrentRunForPane(paneKey) + : undefined if (boundRun) { const mismatch = runMismatch(handle, boundRun.id, params.explicitRunId) return mismatch ?? { ok: true, to: `run:${boundRun.id}`, runId: boundRun.id } @@ -89,6 +221,13 @@ export function resolveBareOrchestrationRecipient(params: { return mismatch ?? { ok: true, to: `run:${selectedRunId}`, runId: selectedRunId } } + if (session) { + const refusal = refuseUndeliverableSessionRecipient(session, sessionStore, db) + return refusal + ? refused(params.handle, refusal) + : { ok: true, to: handle, runId: params.senderRunId } + } + if (paneKey) { return { ok: true, @@ -111,6 +250,15 @@ export function resolveBareOrchestrationRecipient(params: { } } +function refused(recipient: string, refusal: SessionRecipientRefusal): BareRecipientResolution { + return { + ok: false, + code: refusal.code, + message: refusal.message, + warning: { code: 'recipient_unreachable', recipient, message: refusal.message } + } +} + function selectDispatch( dispatches: DispatchContextRow[], explicitRunId: string | undefined diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-history.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-history.test.ts new file mode 100644 index 00000000000..fba01d0d455 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-history.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' +import { createOrchestrationRpcHarness } from '../rpc-test-harness' + +const LEAD = 'tab_lead:22222222-2222-4222-9222-222222222222' +function deliveryId(result: unknown): string { + if ( + typeof result === 'object' && + result && + 'deliveryId' in result && + typeof result.deliveryId === 'string' + ) { + return result.deliveryId + } + throw new Error('Expected a Delivery') +} + +describe('Run history after residual Dispatch acknowledgment', () => { + const h = createOrchestrationRpcHarness() + let state: ReturnType + beforeEach(() => { + state = h.setup() + vi.mocked(state.runtime.getTerminalPaneKey).mockImplementation((handle) => + handle === 'term_lead' ? LEAD : h.coordinatorPaneKey + ) + }) + afterEach(() => h.cleanup()) + it.each([ + { mode: { all: true }, acknowledgeTogether: false }, + { mode: { all: true }, acknowledgeTogether: true }, + { mode: { unread: false }, acknowledgeTogether: false }, + { mode: { unread: false }, acknowledgeTogether: true } + ])('shows Run history after old mail is read: %j', async ({ mode, acknowledgeTogether }) => { + const task = state.db.createTask({ spec: 'nested lead' }) + const dispatch = createRootDispatch(state.db, task.id, 'term_lead', LEAD) + const run = state.db.createRun({ + objective: 'child Run', + coordinatorHandle: 'term_lead', + coordinatorPaneKey: LEAD + }) + const old = state.db.insertMessage({ + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + runId: dispatch.run_id, + subject: 'old mail' + }) + const check = (params = {}) => + h.call('orchestration.check', { terminal: 'term_lead', ...params }, state.ctx) + expect(await check(mode)).toMatchObject({ runId: dispatch.run_id, messages: [{ id: old.id }] }) + expect(state.db.hasOutstandingMailboxDelivery(`dispatch:${dispatch.id}`)).toBe(false) + const delivery = deliveryId(await check()) + if (!acknowledgeTogether) { + await check({ ack: delivery }) + } + const fresh = state.db.insertMessage({ + from: 'term_child', + to: `run:${run.id}`, + runId: run.id, + subject: 'new mail' + }) + expect( + await check({ ...mode, ...(acknowledgeTogether ? { ack: delivery } : {}) }) + ).toMatchObject({ runId: run.id, messages: [{ id: fresh.id }] }) + expect(state.db.getMessageById(old.id)?.read).toBe(1) + expect(state.db.getMessageById(fresh.id)?.read).toBe(0) + expect(state.db.hasOutstandingMailboxDelivery(`dispatch:${dispatch.id}`)).toBe(false) + expect(state.db.hasOutstandingMailboxDelivery(`run:${run.id}`)).toBe(false) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-interleavings.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-interleavings.test.ts new file mode 100644 index 00000000000..4e92d7c35c1 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-interleavings.test.ts @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' +import { ORCHESTRATION_DELIVERY_BATCH_LIMIT } from '../../../../orchestration/db/messages/mailbox-routing-page' +import { createOrchestrationRpcHarness } from '../rpc-test-harness' + +const LEAD = 'tab_lead:22222222-2222-4222-9222-222222222222' +const OTHER = 'tab_other:33333333-3333-4333-8333-333333333333' + +function deliveryId(result: unknown): string { + if ( + typeof result === 'object' && + result !== null && + 'deliveryId' in result && + typeof result.deliveryId === 'string' + ) { + return result.deliveryId + } + throw new Error('Expected a Delivery') +} + +describe('Run binding during Dispatch checks', () => { + const h = createOrchestrationRpcHarness() + let state: ReturnType + let dispatch: ReturnType + + beforeEach(() => { + state = h.setup() + vi.mocked(state.runtime.getTerminalPaneKey).mockImplementation((handle) => + handle === 'term_lead' ? LEAD : handle === 'term_coord' ? h.coordinatorPaneKey : OTHER + ) + dispatch = createRootDispatch( + state.db, + state.db.createTask({ spec: 'lead' }).id, + 'term_lead', + LEAD + ) + }) + afterEach(() => { + state.runtime.cancelMessageWaiters(`dispatch:${dispatch.id}`) + h.cleanup() + vi.restoreAllMocks() + }) + + function check(params: Record = {}) { + return h.call('orchestration.check', { terminal: 'term_lead', ...params }, state.ctx) + } + function residual(subject: string) { + return state.db.insertMessage({ + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + runId: dispatch.run_id, + subject + }) + } + async function bind(method: 'runCreate' | 'runUse') { + const params = + method === 'runCreate' + ? { objective: 'child' } + : { + id: state.db.createRun({ + objective: 'adopt', + coordinatorHandle: 'term_other', + coordinatorPaneKey: OTHER + }).id + } + await h.call(`orchestration.${method}`, { from: 'term_lead', ...params }, state.ctx) + const run = state.db.getCurrentRunForPane(LEAD) + if (!run) { + throw new Error('Expected bound Run') + } + return run + } + + describe.each(['runCreate', 'runUse'] as const)('%s', (method) => { + it.each(['parked', 'recovering'] as const)( + 'cancels a %s Dispatch wait and preserves its ack', + async (phase) => { + const old = residual('before bind') + const ack = deliveryId(await check()) + const record = vi.fn() + state.ctx.recordMutationReceipt = record + const wait = vi.spyOn(state.runtime, 'waitForMessage') + const waiting = check({ ack, wait: true, timeoutMs: 500 }) + if (phase === 'parked') { + await vi.waitFor(() => + expect(wait).toHaveBeenCalledWith(`dispatch:${dispatch.id}`, expect.anything()) + ) + } + const run = await bind(method) + await h.call( + 'orchestration.send', + { + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'after bind' + }, + state.ctx + ) + expect(await waiting).toMatchObject({ acknowledged: ack, cancelled: true, timedOut: false }) + expect(record).toHaveBeenCalledWith(expect.objectContaining({ acknowledged: ack })) + expect(state.db.getMessageById(old.id)).toMatchObject({ run_id: dispatch.run_id, read: 1 }) + expect(await check()).toMatchObject({ + runId: run.id, + messages: [{ subject: 'after bind' }] + }) + if (phase === 'recovering') { + expect(wait).not.toHaveBeenCalled() + } + } + ) + }) + + it.each(['before', 'during recovery'] as const)( + 'replays Run delivery created %s residual acknowledgment', + async (phase) => { + const ids = Array.from( + { length: ORCHESTRATION_DELIVERY_BATCH_LIMIT + 1 }, + (_, i) => residual(`old ${i}`).id + ) + const last = ids.at(-1) + if (!last) { + throw new Error('Expected residual tail') + } + const ack = deliveryId(await check()) + const run = await bind('runCreate') + state.db.insertMessage({ + from: 'term_child', + to: `run:${run.id}`, + runId: run.id, + subject: 'Run batch' + }) + let runDelivery: string | undefined + const readRun = () => { + const result = state.db.getOrCreateRunDelivery({ + runId: run.id, + consumerGeneration: run.consumer_generation + }) + if (!result) { + throw new Error('Expected Run delivery') + } + runDelivery = result.delivery.id + } + if (phase === 'before') { + runDelivery = deliveryId(await check({ run: run.id })) + } else { + state.db.db.prepare('UPDATE messages SET to_handle = ? WHERE id = ?').run('term_lead', last) + const route = state.db.routeUnreadDirectMessagesToDispatchMailbox.bind(state.db) + vi.spyOn(state.db, 'routeUnreadDirectMessagesToDispatchMailbox').mockImplementationOnce( + (...args) => { + const result = route(...args) + readRun() + return result + } + ) + } + const result = await check({ ack }) + expect(result).toMatchObject({ + runId: run.id, + deliveryId: runDelivery, + acknowledged: ack, + replayed: true + }) + expect(state.db.hasOutstandingMailboxDelivery(`dispatch:${dispatch.id}`)).toBe(false) + for (const id of ids.slice(0, -1)) { + expect(state.db.getMessageById(id)).toMatchObject({ read: 1, run_id: dispatch.run_id }) + } + expect(state.db.getMessageById(last)).toMatchObject({ read: 0, run_id: dispatch.run_id }) + expect(await check({ ack })).toMatchObject({ deliveryId: runDelivery, replayed: true }) + const tail = await check({ ack: runDelivery }) + expect(tail).toMatchObject({ runId: dispatch.run_id, messages: [{ id: last }], count: 1 }) + expect(await check({ ack: deliveryId(tail) })).toMatchObject({ count: 0 }) + expect(state.db.getMessageById(last)?.read).toBe(1) + } + ) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-safety.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-safety.test.ts new file mode 100644 index 00000000000..f5f2c57575f --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-safety.test.ts @@ -0,0 +1,290 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' +import { createOrchestrationRpcHarness } from '../rpc-test-harness' + +const LEAD = 'tab_lead:22222222-2222-4222-9222-222222222222' +const OTHER = 'tab_other:33333333-3333-4333-8333-333333333333' + +function deliveryId(result: unknown): string { + if ( + typeof result === 'object' && + result && + 'deliveryId' in result && + typeof result.deliveryId === 'string' + ) { + return result.deliveryId + } + throw new Error('Expected a Delivery') +} + +describe('Run-bound lead mailbox boundaries', () => { + const h = createOrchestrationRpcHarness() + let state: ReturnType + let dispatch: ReturnType + let leadRun: ReturnType + + beforeEach(() => { + state = h.setup() + vi.mocked(state.runtime.getTerminalPaneKey).mockImplementation((handle) => + handle === 'term_lead' ? LEAD : handle === 'term_coord' ? h.coordinatorPaneKey : OTHER + ) + const task = state.db.createTask({ spec: 'nested lead' }) + dispatch = createRootDispatch(state.db, task.id, 'term_lead', LEAD) + leadRun = state.db.createRun({ + objective: 'child Run', + coordinatorHandle: 'term_lead', + coordinatorPaneKey: LEAD + }) + }) + afterEach(() => h.cleanup()) + + function check(params: Record = {}) { + return h.call('orchestration.check', { terminal: 'term_lead', ...params }, state.ctx) + } + function residual(subject: string, type: 'status' | 'question' = 'status') { + return state.db.insertMessage({ + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + runId: dispatch.run_id, + subject, + type + }) + } + function runMail(subject = 'Run report') { + return state.db.insertMessage({ + from: 'term_child', + to: `run:${leadRun.id}`, + runId: leadRun.id, + subject, + type: 'question' + }) + } + function handoff() { + state.db.bindRun({ + runId: leadRun.id, + coordinatorHandle: 'term_other', + coordinatorPaneKey: OTHER + }) + } + + it('recovers old raw-handle replies only from the active Dispatch Run', async () => { + const old = residual('old reply') + state.db.db.prepare('UPDATE messages SET to_handle = ? WHERE id = ?').run('term_lead', old.id) + const foreign = state.db.createRun({ + objective: 'unrelated', + coordinatorHandle: 'term_other', + coordinatorPaneKey: OTHER + }) + const privateMail = state.db.insertMessage({ + from: 'term_other', + to: 'term_lead', + runId: foreign.id, + subject: 'foreign' + }) + runMail() + const first = await check() + expect(first).toMatchObject({ runId: dispatch.run_id, messages: [{ id: old.id }] }) + expect(await check({ ack: deliveryId(first) })).toMatchObject({ + runId: leadRun.id, + messages: [{ subject: 'Run report' }] + }) + expect(state.db.getMessageById(privateMail.id)).toMatchObject({ read: 0, run_id: foreign.id }) + }) + + it('treats types as a wake condition and replays the entire residual FIFO batch', async () => { + residual('older status') + residual('decision needed', 'question') + runMail() + const first = await check({ wait: true, types: 'question' }) + expect(first).toMatchObject({ + messages: [{ subject: 'older status' }, { subject: 'decision needed' }] + }) + expect(await check({ wait: true, types: 'worker_done' })).toMatchObject({ + deliveryId: deliveryId(first), + replayed: true + }) + expect(await check({ ack: deliveryId(first) })).toMatchObject({ + runId: leadRun.id, + acknowledged: deliveryId(first), + messages: [{ subject: 'Run report' }] + }) + }) + + it('does not filter a non-waiting consuming residual check', async () => { + residual('status') + expect(await check({ types: 'question' })).toMatchObject({ messages: [{ subject: 'status' }] }) + }) + + it.each([{ peek: true }, { all: true }])( + 'filters residual inspection without consuming it: %j', + async (mode) => { + const status = residual('status') + residual('question', 'question') + expect(await check({ ...mode, types: 'question' })).toMatchObject({ + messages: [{ subject: 'question' }] + }) + expect(state.db.getMessageById(status.id)?.read).toBe(0) + expect(state.db.hasOutstandingMailboxDelivery(`dispatch:${dispatch.id}`)).toBe(false) + } + ) + + it('replays an outstanding Run batch before exposing older nonmatching residual mail', async () => { + residual('status') + runMail() + const first = await check({ wait: true, types: 'question' }) + expect(first).toMatchObject({ runId: leadRun.id }) + expect(await check()).toMatchObject({ deliveryId: deliveryId(first), replayed: true }) + expect(await check({ ack: deliveryId(first) })).toMatchObject({ + acknowledged: deliveryId(first), + messages: [{ subject: 'status' }] + }) + }) + + it('keeps explicit Run checks scoped and refuses the parent Run', async () => { + const pending = residual('parent mail') + runMail() + expect(await check({ run: leadRun.id })).toMatchObject({ + runId: leadRun.id, + messages: [{ subject: 'Run report' }] + }) + await expect(check({ run: dispatch.run_id })).rejects.toMatchObject({ code: 'consumer_fenced' }) + expect(state.db.getMessageById(pending.id)?.read).toBe(0) + }) + + it('fences a Run handoff during residual direct-mail recovery before delivery or ack', async () => { + const pending = residual('raw before bind') + state.db.db + .prepare('UPDATE messages SET to_handle = ? WHERE id = ?') + .run('term_lead', pending.id) + const route = state.db.routeUnreadDirectMessagesToDispatchMailbox.bind(state.db) + vi.spyOn(state.db, 'routeUnreadDirectMessagesToDispatchMailbox').mockImplementationOnce( + (...args) => { + const result = route(...args) + handoff() + return result + } + ) + await expect(check()).rejects.toMatchObject({ code: 'consumer_fenced' }) + expect(state.db.hasOutstandingMailboxDelivery(`dispatch:${dispatch.id}`)).toBe(false) + expect(state.db.getMessageById(pending.id)?.read).toBe(0) + }) + + it('records a residual acknowledgment before a Run handoff interrupts the following wait', async () => { + residual('ack me') + const first = await check() + const record = vi.fn() + state.ctx.recordMutationReceipt = record + vi.spyOn(state.runtime, 'waitForMessage').mockImplementationOnce(async () => { + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ acknowledged: deliveryId(first) }) + ) + handoff() + return 'cancelled' + }) + expect(await check({ ack: deliveryId(first), wait: true })).toMatchObject({ + acknowledged: deliveryId(first), + waitInterrupted: 'consumer_fenced', + messages: [] + }) + expect(state.db.getUnreadMessages(`dispatch:${dispatch.id}`)).toEqual([]) + }) + + it('retains the acknowledged receipt if the wait transport throws', async () => { + residual('ack me') + const first = await check() + const record = vi.fn() + state.ctx.recordMutationReceipt = record + vi.spyOn(state.runtime, 'waitForMessage').mockRejectedValueOnce( + new Error('connection interrupted') + ) + await expect(check({ ack: deliveryId(first), wait: true })).rejects.toThrow( + 'connection interrupted' + ) + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ acknowledged: deliveryId(first) }) + ) + expect(state.db.getUnreadMessages(`dispatch:${dispatch.id}`)).toEqual([]) + }) + + it('does not give a reused process the previous assignee mail', async () => { + state.db.mintDispatchCapability({ + dispatchId: dispatch.id, + paneKey: LEAD, + processIncarnation: 'old:pty:1' + }) + const pending = residual('old process mail') + runMail() + expect(await check()).toMatchObject({ + runId: leadRun.id, + messages: [{ subject: 'Run report' }] + }) + expect(state.db.getMessageById(pending.id)?.read).toBe(0) + expect(state.db.hasOutstandingMailboxDelivery(`dispatch:${dispatch.id}`)).toBe(false) + }) + + it('refuses an explicit sender Run that would silently redirect into another Run', async () => { + await expect( + h.call( + 'orchestration.send', + { + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + run: dispatch.run_id, + subject: 'wrong scope' + }, + state.ctx + ) + ).rejects.toMatchObject({ code: 'recipient_run_mismatch' }) + expect(state.db.getInbox()).toEqual([]) + }) + + it('wakes a parked Run check for Dispatch mail sent after binding', async () => { + const waiter = vi.spyOn(state.runtime, 'waitForMessage') + const waiting = check({ wait: true, timeoutMs: 1_000 }) + await vi.waitFor(() => + expect(waiter).toHaveBeenCalledWith(`run:${leadRun.id}`, expect.anything()) + ) + await h.call( + 'orchestration.send', + { from: 'term_coord', to: `dispatch:${dispatch.id}`, subject: 'wake up' }, + state.ctx + ) + expect(await waiting).toMatchObject({ timedOut: false, messages: [{ subject: 'wake up' }] }) + }) + + it('keeps a canonical Run reply in the recipient Run even across an original thread Run', async () => { + const note = state.db.insertMessage({ + from: `run:${leadRun.id}`, + to: `run:${dispatch.run_id}`, + runId: dispatch.run_id, + subject: 'report' + }) + expect( + await h.call( + 'orchestration.reply', + { from: 'term_coord', id: note.id, body: 'decision' }, + state.ctx + ) + ).toMatchObject({ message: { to_handle: `run:${leadRun.id}`, run_id: leadRun.id } }) + expect(await check()).toMatchObject({ messages: [{ subject: 'Re: report' }] }) + }) + + it('refuses replies to an inactive canonical Dispatch before reading or inserting mail', async () => { + const note = state.db.insertMessage({ + from: `dispatch:${dispatch.id}`, + to: `run:${dispatch.run_id}`, + runId: dispatch.run_id, + subject: 'old report' + }) + state.db.completeDispatch(dispatch.id) + await expect( + h.call( + 'orchestration.reply', + { from: 'term_coord', id: note.id, body: 'too late' }, + state.ctx + ) + ).rejects.toMatchObject({ code: 'dispatch_inactive' }) + expect(state.db.getMessageById(note.id)?.read).toBe(0) + expect(state.db.getInbox()).toHaveLength(1) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-recipient-mail.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-recipient-mail.test.ts new file mode 100644 index 00000000000..bb6c7041cee --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-recipient-mail.test.ts @@ -0,0 +1,189 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcContext } from '../../../core' +import type { OrchestrationDb, RunRow } from '../../../../orchestration/db' +import type { OrcaRuntimeService } from '../../../../orca-runtime' +import type { DispatchContextRow } from '../../../../orchestration/types' +import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' +import { createOrchestrationRpcHarness } from '../rpc-test-harness' + +// A lead is dispatched by a root coordinator and then coordinates its own Run from the same pane. +// That pane's `check` reads its own Run mailbox, so mail meant for it must land there. +describe('mail for a lead whose pane coordinates its own Run', () => { + const h = createOrchestrationRpcHarness() + const coordPane = 'tab_coord:11111111-1111-4111-8111-111111111111' + const leadPane = 'tab_lead:22222222-2222-4222-9222-222222222222' + let db: OrchestrationDb + let runtime: OrcaRuntimeService + let ctx: RpcContext + let rootRun: RunRow + let dispatch: DispatchContextRow + + afterEach(() => { + h.cleanup() + }) + + function setup(): void { + ;({ db, runtime, ctx } = h.setup(false)) + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_coord' ? coordPane : handle === 'term_lead' ? leadPane : null + ) + rootRun = db.createRun({ + objective: 'root', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: coordPane + }) + const task = db.createTask({ spec: 'lead the sub-project', runId: rootRun.id }) + dispatch = createRootDispatch(db, task.id, 'term_lead', leadPane) + } + + function bindLeadRun(): RunRow { + return db.createRun({ + objective: 'lead', + coordinatorHandle: 'term_lead', + coordinatorPaneKey: leadPane + }) + } + + async function call(name: string, params: Record) { + return h.call(name, params, ctx) + } + + async function leadInbox(params: Record = {}): Promise { + return call('orchestration.check', { terminal: 'term_lead', ...params }) + } + + function deliveryIdOf(result: unknown): string { + if ( + typeof result === 'object' && + result !== null && + 'deliveryId' in result && + typeof result.deliveryId === 'string' + ) { + return result.deliveryId + } + throw new Error('check returned no delivery') + } + + it('routes dispatch: mail to the Run the assignee pane now coordinates', async () => { + setup() + const leadRun = bindLeadRun() + + const result = await call('orchestration.send', { + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'Follow-up for the lead' + }) + + expect(result).toMatchObject({ + message: { to_handle: `run:${leadRun.id}`, run_id: leadRun.id }, + warnings: [{ code: 'recipient_run_bound_redirect' }] + }) + expect(await leadInbox()).toMatchObject({ messages: [{ subject: 'Follow-up for the lead' }] }) + }) + + it('still reads dispatch mail that arrived before the pane bound its own Run', async () => { + setup() + db.insertMessage({ + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'Sent before the lead bound a Run', + runId: rootRun.id + }) + const leadRun = bindLeadRun() + db.insertMessage({ + from: 'term_worker', + to: `run:${leadRun.id}`, + subject: 'Sub-worker report', + runId: leadRun.id + }) + + const first = await leadInbox() + expect(first).toMatchObject({ messages: [{ subject: 'Sent before the lead bound a Run' }] }) + + const second = await leadInbox({ ack: deliveryIdOf(first) }) + expect(second).toMatchObject({ messages: [{ subject: 'Sub-worker report' }] }) + }) + + it('keeps the --types wake condition when older Dispatch mail does not match it', async () => { + setup() + db.insertMessage({ + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'Older status note', + type: 'status', + runId: rootRun.id + }) + const leadRun = bindLeadRun() + db.insertMessage({ + from: 'term_worker', + to: `run:${leadRun.id}`, + subject: 'Sub-worker finished', + type: 'worker_done', + runId: leadRun.id + }) + + const woke = await leadInbox({ wait: true, types: 'worker_done', timeoutMs: 500 }) + + expect(woke).toMatchObject({ + runId: leadRun.id, + messages: [{ subject: 'Sub-worker finished' }] + }) + }) + + it('delivers a reply to a Run-bound sender and wakes its waiting check', async () => { + setup() + const leadRun = bindLeadRun() + const report = db.insertMessage({ + from: 'term_lead', + to: `run:${rootRun.id}`, + subject: 'Lead report', + runId: rootRun.id + }) + + const waiting = leadInbox({ wait: true, timeoutMs: 2_000 }) + const reply = await call('orchestration.reply', { + id: report.id, + from: 'term_coord', + body: 'Decision' + }) + + expect(reply).toMatchObject({ + message: { to_handle: `run:${leadRun.id}`, run_id: leadRun.id } + }) + expect(await waiting).toMatchObject({ + timedOut: false, + messages: [{ subject: 'Re: Lead report' }] + }) + }) + + it('keeps dispatch: mail on the Dispatch mailbox while the assignee has no Run', async () => { + setup() + + const result = await call('orchestration.send', { + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'Plain worker follow-up' + }) + + expect(result).toMatchObject({ message: { to_handle: `dispatch:${dispatch.id}` } }) + expect(result).not.toHaveProperty('warnings') + }) + + it('keeps a reply on the raw handle when the sender has no Run or live pane', async () => { + setup() + const note = db.insertMessage({ + from: 'term_offline', + to: `run:${rootRun.id}`, + subject: 'Offline note', + runId: rootRun.id + }) + + const reply = await call('orchestration.reply', { + id: note.id, + from: 'term_coord', + body: 'Ack' + }) + + expect(reply).toMatchObject({ message: { to_handle: 'term_offline', run_id: rootRun.id } }) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-group.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-group.test.ts index b076be06593..62d2b5da495 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-group.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-group.test.ts @@ -69,7 +69,7 @@ describe('orchestration.send group addresses', () => { return terminal ? `${terminal.tabId}:${terminal.leafId}` : null }) vi.spyOn(runtime, 'getAgentStatusForHandle').mockImplementation( - (handle: string) => agentStatuses?.[handle] ?? null + async (handle: string) => agentStatuses?.[handle] ?? null ) } diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-group.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-group.ts index d5ee366a40f..ebcff6c284e 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-group.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-group.ts @@ -13,6 +13,7 @@ import { exposeMessages } from './mailbox-message-receipt' import { recordReceiptBeforeNudge } from './mutation-replay-nudge' import type { BareRecipientResolution, SendRecipientWarning } from './recipient-routing' import type { SendParams } from '../schemas' +import type { OrchestrationCallerIdentity } from '../../../../orchestration/orchestration-caller-identity' import type { z } from 'zod' type SendParamsInput = z.infer @@ -94,6 +95,7 @@ export async function sendGroupMessage(args: { db: OrchestrationDb from: string groupAddress: string + sender: OrchestrationCallerIdentity senderPaneKey: string | undefined senderRunId: string | undefined explicitRunId: string | undefined @@ -108,6 +110,7 @@ export async function sendGroupMessage(args: { db, from, groupAddress, + sender, senderPaneKey, senderRunId, explicitRunId, @@ -117,7 +120,7 @@ export async function sendGroupMessage(args: { } = args // Audience follows the sender's binding, never a caller-supplied message Run or payload. function resolveAudienceRunId(): string { - const coordinated = senderPaneKey ? db.getCurrentRunForPane(senderPaneKey) : undefined + const coordinated = db.getCurrentRunForCoordinator(sender) const runId = coordinated?.id ?? db.getActiveDispatchForIdentity(from, senderPaneKey)?.run_id ?? @@ -145,7 +148,7 @@ export async function sendGroupMessage(args: { const { terminals } = await runtime.listTerminals(undefined, undefined, { includeVisualLayouts: false }) - agents = [...terminals, ...listAddressableStructuredWorkers()] + agents = [...terminals, ...listAddressableStructuredWorkers(db)] } // Revalidate after discovery before selecting recipients or writing mail. revalidateLegacyCoordinator?.() @@ -164,8 +167,23 @@ export async function sendGroupMessage(args: { agents, warnings: groupWarnings }) - const handles = resolveGroupAddress(groupAddress, from, candidates, (handle: string) => - runtime.getAgentStatusForHandle(handle) + // Read up front: a structured worker's status comes from its journal, which may need opening. + const statuses = + groupAddress.toLowerCase() === '@idle' + ? new Map( + await Promise.all( + candidates.map( + async (candidate) => + [candidate.handle, await runtime.getAgentStatusForHandle(candidate.handle)] as const + ) + ) + ) + : new Map() + const handles = resolveGroupAddress( + groupAddress, + from, + candidates, + (handle: string) => statuses.get(handle) ?? null ) if (handles.length === 0) { // Preserve the recovery addresses even when every worker was skipped. diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts index 7d1edb77602..58dcceb5598 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts @@ -7,6 +7,7 @@ import { resolveMessageRun } from '../routing' import { assertDispatchMailboxDeliverable, resolveBareOrchestrationRecipient, + resolveRunBoundDispatchRecipient, type SendRecipientWarning } from './recipient-routing' import { @@ -16,9 +17,11 @@ import { } from '../../../orchestration-mutation-executor' import { replayMutationNudge } from './mutation-replay-nudge' import { sendRemoteMessage } from './send-remote' +import { mayNameSession } from './session-recipient' import { sendPointToPointMessage } from './send-point-to-point' import { sendGroupMessage } from './send-group' import { sendFederatedControlMail } from './send-control-mail' +import { orchestrationCallerIdentity } from '../runs/run-scope' export const ORCHESTRATION_SEND_METHODS = [ defineMethod({ @@ -35,6 +38,7 @@ export const ORCHESTRATION_SEND_METHODS = [ recordMutationReceipt, markWorkerDoneMutationEffectFree, replayedMutationReceipt, + orchestrationCaller, signal } ) => { @@ -59,7 +63,12 @@ export const ORCHESTRATION_SEND_METHODS = [ ? orchestrationCompatibilityCallerAuthority : undefined // Why: attested hook identity survives graph remount; caller params never supply lifecycle authority. - const senderPaneKey = attestedCaller?.paneKey ?? runtime.getTerminalPaneKey(from) ?? undefined + const sender = orchestrationCallerIdentity(runtime, { + handle: from, + session: orchestrationCaller, + paneKey: attestedCaller?.paneKey ?? runtime.getTerminalPaneKey(from) + }) + const senderPaneKey = sender.paneKey ?? undefined const remoteAttachment = senderPaneKey ? db.findActiveRemoteAttachmentForPane(senderPaneKey) : undefined @@ -84,8 +93,7 @@ export const ORCHESTRATION_SEND_METHODS = [ params.to && isGroupAddress(params.to) && !params.to.toLowerCase().startsWith('@worktree:') // Run groups validate their own audience; message scope cannot select a parent Dispatch. const routing = resolveMessageRun(runtime, { - from, - senderPaneKey, + sender, to: params.to, runId: runGroup ? undefined : params.run, payload: runGroup ? undefined : params.payload @@ -125,6 +133,10 @@ export const ORCHESTRATION_SEND_METHODS = [ const sendWarnings: SendRecipientWarning[] = [] let messageRunId = routing.run?.id if (!isGroupAddress(to) && !to.startsWith('run:') && !to.startsWith('dispatch:')) { + if (mayNameSession(to)) { + // Recipient routing reads the session record store, which the host opens lazily. + await runtime.ensureStructuredAgentSessionHost().catch(() => undefined) + } const recipient = resolveBareOrchestrationRecipient({ runtime, db, @@ -156,7 +168,18 @@ export const ORCHESTRATION_SEND_METHODS = [ : undefined // Federated targets perform their own liveness check before relaying. if (addressedDispatchId && !federatedTarget) { - assertDispatchMailboxDeliverable(db, addressedDispatchId) + assertDispatchMailboxDeliverable(runtime, db, addressedDispatchId) + const runBound = resolveRunBoundDispatchRecipient( + runtime, + db, + addressedDispatchId, + params.run + ) + if (runBound) { + to = runBound.to + messageRunId = runBound.runId + sendWarnings.push(runBound.warning) + } } const federatedControl = sendFederatedControlMail({ params, @@ -199,6 +222,7 @@ export const ORCHESTRATION_SEND_METHODS = [ db, from, groupAddress: to, + sender, senderPaneKey, senderRunId: routing.run?.id, explicitRunId: params.run, diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts b/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts new file mode 100644 index 00000000000..a22975c0454 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts @@ -0,0 +1,118 @@ +/** + * An agent session named as a recipient: `session:`, or a bare Orca session id. Any session on + * this host can be addressed, not only one that coordinates a Run: an agent's id is its public + * address, and a user telling one agent to message another's id is a supported workflow. + * + * Mail that no Run or Dispatch owns is stored at the conversation's `session:` and pointed + * at its live session as a turn, so any session of a `/clear` lineage is a valid spelling. A + * released lease is not a refusal (the pointer's send starts its agent); a closed chat, another host, + * and an unknown id are, before anything is stored. + */ + +import { + ORCA_SESSION_ADDRESS_PREFIX, + formatOrcaSessionAddress, + isOrcaSessionId, + parseOrcaSessionAddress, + type OrcaSessionAddress, + type OrcaSessionId +} from '../../../../../../shared/orca-session-address' +// The caller codes, reused: each names the same fact about a session, whichever side of the mail it is on. +import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../../../../shared/orchestration-session-caller-codes' +import { + lookupOrcaAgentSession, + structuredSessionMailReach +} from '../../../../orchestration/structured-session-mail-address' +import type { AgentSessionRecordReader } from '../../../../orchestration/structured-session-lineage' +import type { OrchestrationDb } from '../../../../orchestration/db' + +/** `address` is the named session's own spelling; the mailbox mail lands in is its identity address. */ +export type SessionRecipient = { sessionId: OrcaSessionId; address: OrcaSessionAddress } + +export type SessionRecipientRefusal = { + code: (typeof CODES)[keyof typeof CODES] + message: string +} + +/** Whether a recipient may name a session, so the caller can install the session host first. */ +export function mayNameSession(recipient: string): boolean { + return recipient.startsWith(ORCA_SESSION_ADDRESS_PREFIX) || isOrcaSessionId(recipient) +} + +/** + * The session a recipient names. A bare string names a session only when it is an Orca session id + * this host has a record for; anything else stays a terminal handle, exactly as before. + */ +export function readSessionRecipient( + recipient: string, + store: AgentSessionRecordReader | null +): SessionRecipient | SessionRecipientRefusal | null { + if (recipient.startsWith(ORCA_SESSION_ADDRESS_PREFIX)) { + const sessionId = parseOrcaSessionAddress(recipient) + return sessionId + ? { sessionId, address: formatOrcaSessionAddress(sessionId) } + : { + code: CODES.unknown, + message: `${recipient} does not name an Orca agent session id. No message was sent.` + } + } + const sessionId = isOrcaSessionId(recipient) ? recipient : null + const found = sessionId && store ? lookupOrcaAgentSession(store, sessionId) : null + if (found?.kind === 'provider-id') { + return providerIdRefusal(recipient, found.orcaSessionId) + } + return sessionId && found?.kind === 'found' + ? { sessionId, address: formatOrcaSessionAddress(sessionId) } + : null +} + +/** Null when mail to this session can be stored and delivered here; otherwise why not. */ +export function refuseUndeliverableSessionRecipient( + recipient: SessionRecipient, + store: AgentSessionRecordReader | null, + db: OrchestrationDb +): SessionRecipientRefusal | null { + const { sessionId } = recipient + if (!store) { + return { + code: CODES.unknown, + message: `Agent session ${sessionId} cannot be verified: this Orca is not running its agent-session host. No message was sent.` + } + } + const found = lookupOrcaAgentSession(store, sessionId) + if (found.kind === 'provider-id') { + return providerIdRefusal(sessionId, found.orcaSessionId) + } + if (found.kind === 'unknown') { + return { + code: CODES.unknown, + message: `No Orca agent session ${sessionId} exists on this host. No message was sent.` + } + } + const reach = structuredSessionMailReach(store, found.record, db) + if (reach.kind === 'other-host') { + return { + code: CODES.hostBoundary, + message: `Agent session ${sessionId} runs on another host; mail reaches a session only on the host that runs it. Send from that host. No message was sent.` + } + } + if (reach.kind === 'ended') { + return { + code: CODES.notLive, + message: + reach.reason === 'continuation-missing' + ? `Agent session ${sessionId} was cleared, and this host has no record of the session that continues it. No message was sent.` + : reach.reason === 'worker-identity-lost' + ? `Agent session ${sessionId} is a structured worker whose worker identity this host no longer has, so it can never read that mail. No message was sent.` + : `Agent session ${sessionId} has ended: its chat was closed. No message was sent.` + } + } + return null +} + +function providerIdRefusal(id: string, orcaSessionId: string): SessionRecipientRefusal { + return { + code: CODES.providerId, + message: `${id} is the provider's own session id, which changes on /clear. This session's Orca address is ${ORCA_SESSION_ADDRESS_PREFIX}${orcaSessionId}; use that instead. No message was sent.` + } +} diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/settled-dispatch-mail.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/settled-dispatch-mail.test.ts index e91614061ea..ddffed46890 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/settled-dispatch-mail.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/settled-dispatch-mail.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { RpcContext } from '../../../core' import type { OrchestrationDb } from '../../../../orchestration/db' import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' @@ -58,6 +58,38 @@ describe('orchestration.send to a settled Dispatch mailbox', () => { ).rejects.toThrow(new RegExp(`run:${dispatch.run_id}`)) }) + it('names the Run a settled assignee now coordinates, not the sender Run', async () => { + setup() + const leadPane = 'tab_lead:22222222-2222-4222-9222-222222222222' + vi.spyOn(ctx.runtime, 'getLiveTerminalPaneKey').mockReturnValue(leadPane) + const task = db.createTask({ spec: 'lead that settled and kept coordinating' }) + const dispatch = createRootDispatch(db, task.id, 'term_lead', leadPane) + db.completeDispatch(dispatch.id) + const leadRun = db.createRun({ + objective: 'lead', + coordinatorHandle: 'term_lead', + coordinatorPaneKey: leadPane + }) + + const rejection = call('orchestration.send', { + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'One more thing' + }) + + await expect(rejection).rejects.toMatchObject({ code: 'dispatch_inactive' }) + await expect(rejection).rejects.toThrow(new RegExp(`run:${leadRun.id}`)) + await expect(rejection).rejects.not.toThrow(new RegExp(`run:${dispatch.run_id}`)) + expect(db.getInbox()).toEqual([]) + await expect( + call('orchestration.check', { terminal: 'term_coord', run: leadRun.id }) + ).rejects.toMatchObject({ code: 'consumer_fenced' }) + // Run addresses are already visible to callers; a hint grants no consuming authority. + expect(await call('orchestration.runShow', { id: leadRun.id })).toMatchObject({ + run: { id: leadRun.id } + }) + }) + it('does not write an undeliverable message row', async () => { setup() const task = db.createTask({ spec: 'worker that already reported' }) diff --git a/src/main/runtime/rpc/methods/orchestration/routing.ts b/src/main/runtime/rpc/methods/orchestration/routing.ts index 47001283895..5978eb60cd1 100644 --- a/src/main/runtime/rpc/methods/orchestration/routing.ts +++ b/src/main/runtime/rpc/methods/orchestration/routing.ts @@ -1,6 +1,7 @@ import type { MessageType } from '../../../orchestration/db' import type { RunRow } from '../../../orchestration/types' import type { OrcaRuntimeService } from '../../../orca-runtime' +import type { OrchestrationCallerIdentity } from '../../../orchestration/orchestration-caller-identity' import { MESSAGE_TYPES } from '../../../orchestration/types' import { OrchestrationError } from '../../../orchestration/orchestration-error' import { LEGACY_CONTRACT_VERSION } from '../../../orchestration/db' @@ -20,8 +21,8 @@ export function parseMessageTypes(rawTypes: string | undefined): MessageType[] | export function resolveMessageRun( runtime: OrcaRuntimeService, params: { - from?: string - senderPaneKey?: string + /** The sender as Run binding and Dispatch identity see it. */ + sender: OrchestrationCallerIdentity to?: string runId?: string payload?: string @@ -50,9 +51,7 @@ export function resolveMessageRun( const dispatch = dispatchId ? db.getDispatchContextById(dispatchId) - : params.from - ? db.getActiveDispatchForIdentity(params.from, params.senderPaneKey) - : undefined + : db.getActiveDispatchForIdentity(params.sender.address, params.sender.paneKey ?? undefined) if (params.to?.startsWith('dispatch:') && !dispatch) { throw new OrchestrationError( 'dispatch_not_found', @@ -63,9 +62,8 @@ export function resolveMessageRun( const resolvedRunId = params.runId ?? targetRunId ?? dispatch?.run_id let run = resolvedRunId ? db.getRun(resolvedRunId) : undefined - if (!run && params.from) { - const paneKey = params.senderPaneKey ?? runtime.getTerminalPaneKey(params.from) - run = paneKey ? db.getCurrentRunForPane(paneKey) : undefined + if (!run) { + run = db.getCurrentRunForCoordinator(params.sender) } if (resolvedRunId && (!run || run.legacy === 1)) { throw new OrchestrationError('run_not_found', `Run ${resolvedRunId} was not found.`) diff --git a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-creator.ts b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-creator.ts index 8cdbcb8da11..0ee7f7523e7 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-creator.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-creator.ts @@ -1,5 +1,7 @@ import type { DispatchCreator } from '../../../../orchestration/db/dispatch-depth' +import type { OrchestrationSessionCaller } from '../../../../orchestration/orchestration-caller-identity' import type { OrcaRuntimeService } from '../../../../orca-runtime' +import { orchestrationCallerIdentity } from './run-scope' /** * Identify a CLI caller for nesting-depth purposes. @@ -10,18 +12,35 @@ import type { OrcaRuntimeService } from '../../../../orca-runtime' */ export function resolveDispatchCreator( runtime: OrcaRuntimeService, - callerHandle: string | undefined + callerHandle: string | undefined, + callerSession: OrchestrationSessionCaller | undefined ): DispatchCreator { if (!callerHandle) { // No declared caller means no resolvable parent. Depth 0 is the same answer // the pre-existing Run-binding check already gives this case. return { kind: 'system' } } - const authority = runtime.getOrchestrationDispatchAuthority?.(callerHandle) + const caller = orchestrationCallerIdentity(runtime, { + handle: callerHandle, + session: callerSession, + paneKey: null + }) + if (caller.terminalHandle === null) { + // A handle-less session: its Orca session id is its whole identity. + return caller.orcaSessionId + ? { kind: 'session', orcaSessionId: caller.orcaSessionId } + : { kind: 'system' } + } + const authority = runtime.getOrchestrationDispatchAuthority?.(caller.terminalHandle) return { kind: 'terminal', - handle: callerHandle, - paneKey: authority?.paneKey ?? runtime.getTerminalPaneKey(callerHandle) ?? undefined, - processIncarnation: authority?.processIncarnation ?? undefined + handle: caller.terminalHandle, + paneKey: + authority?.paneKey ?? + caller.paneKey ?? + runtime.getTerminalPaneKey(caller.terminalHandle) ?? + undefined, + processIncarnation: authority?.processIncarnation ?? undefined, + ...(caller.orcaSessionId ? { orcaSessionId: caller.orcaSessionId } : {}) } } diff --git a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts index 75c83878335..843df33357a 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts @@ -12,6 +12,7 @@ import { } from '../../../../orchestration/task-dispatch-refusal' import { resolveRunScope } from './run-scope' import { DispatchParams, DispatchShowParams } from '../schemas' +import { resolveDispatchAssigneeParty } from '../../../../orchestration/orchestration-party' export const ORCHESTRATION_DISPATCH_METHODS = [ defineMethod({ @@ -21,6 +22,7 @@ export const ORCHESTRATION_DISPATCH_METHODS = [ params, { orchestrationCompatibilityEvidence, + orchestrationCaller, runtime, legacyCoordinatorRunId, revalidateLegacyCoordinator, @@ -37,7 +39,8 @@ export const ORCHESTRATION_DISPATCH_METHODS = [ callerTerminalHandle: params.from, requireCurrentConsumer: true, legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence + callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller }) if (task.run_id !== run.id) { throw taskNotFoundError(`Task ${task.id} was not found in Run ${run.id}.`, { @@ -45,12 +48,13 @@ export const ORCHESTRATION_DISPATCH_METHODS = [ runId: run.id }) } + const assignee = params.to ? resolveDispatchAssigneeParty(params.to, db).address : undefined // Why: dry-run previews the preamble without mutating state, so it skips the ready-status check and uses a placeholder dispatchId. if (params.dryRun) { const maxDepth = runtime.getNestedWorkerMaxDepth() const previewDepth = db.resolveChildDispatchDepth( - resolveDispatchCreator(runtime, params.from), + resolveDispatchCreator(runtime, params.from, orchestrationCaller), maxDepth ) const preamble = buildDispatchPreamble({ @@ -59,19 +63,17 @@ export const ORCHESTRATION_DISPATCH_METHODS = [ canDispatchSubWorkers: previewDepth < maxDepth, taskSpec: task.spec, coordinatorHandle: params.from ?? 'coordinator', - workerHandle: params.to ?? 'worker', + workerHandle: assignee ?? 'worker', devMode: params.devMode, - ...(params.to - ? { cliCommand: runtime.getTerminalOrchestrationCliCommand(params.to) } - : {}) + ...(assignee ? { cliCommand: runtime.getTerminalOrchestrationCliCommand(assignee) } : {}) }) return { dispatch: null, injected: false, dryRun: true, preamble } } - if (!params.to) { + if (!assignee) { throw new Error('Missing --to') } - const to = params.to + const to = assignee if (task.status !== 'ready') { throw taskNotStartableError( @@ -131,7 +133,7 @@ export const ORCHESTRATION_DISPATCH_METHODS = [ assigneePaneKey, launchTokenHash: dispatchAuthority?.launchTokenHash ?? undefined, processIncarnation, - creator: resolveDispatchCreator(runtime, params.from), + creator: resolveDispatchCreator(runtime, params.from, orchestrationCaller), maxDepth: runtime.getNestedWorkerMaxDepth() }) const dispatchCapability = params.inject diff --git a/src/main/runtime/rpc/methods/orchestration/runs/run-receipt.test.ts b/src/main/runtime/rpc/methods/orchestration/runs/run-receipt.test.ts index 49afdfe119a..93d6f7bdda9 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/run-receipt.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/run-receipt.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { exposeRun } from './run-receipt' import type { RunRow } from '../../../../orchestration/types' +import { testOrcaSessionId } from '../../../../../../shared/orca-session-address-test-fixture' // Why: typecheck cannot see the strip because the RPC return types are loose. const RUN_ROW: RunRow = { @@ -9,7 +10,7 @@ const RUN_ROW: RunRow = { home_database: '/tmp/orca/orchestration.db', coordinator_handle: 'term_coord', coordinator_pane_key: 'tab_coord:11111111-1111-4111-8111-111111111111', - coordinator_orca_session_id: '22222222-2222-4222-8222-222222222222', + coordinator_orca_session_id: testOrcaSessionId('22222222-2222-4222-8222-222222222222'), coordinator_orca_session_id_generation: 3, consumer_generation: 3, legacy: 0, diff --git a/src/main/runtime/rpc/methods/orchestration/runs/run-scope.ts b/src/main/runtime/rpc/methods/orchestration/runs/run-scope.ts index 6b066723315..0496c92806e 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/run-scope.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/run-scope.ts @@ -6,11 +6,19 @@ import type { OrcaRuntimeService, OrchestrationCompatibilityCallerAuthority } from '../../../../orca-runtime' +import { + hasRunBindingKey, + type OrchestrationCallerIdentity, + type OrchestrationSessionCaller +} from '../../../../orchestration/orchestration-caller-identity' +import { resolveDeclaredCallerParty } from '../../../../orchestration/orchestration-party' export type RunScopeParams = { runId?: string callerTerminalHandle?: string callerPaneKey?: string + /** Resolved at the dispatch entry; when set it is the caller, whatever the declared handle. */ + callerSession: OrchestrationSessionCaller | undefined requireCurrentConsumer: boolean legacyCoordinatorRunId?: string // Why: the caller's declared handle is a user param; this is the attested one to check it against. @@ -36,10 +44,30 @@ export function assertCallerHandleMatchesEvidence( } } +/** + * The caller as Run binding and mail routing see it. A session the dispatch entry resolved is the + * caller outright; otherwise it is the party the declared handle names, at the pane it resolved to. + */ +export function orchestrationCallerIdentity( + runtime: OrcaRuntimeService, + caller: { + handle: string + paneKey: string | null | undefined + session: OrchestrationSessionCaller | undefined + } +): OrchestrationCallerIdentity { + if (caller.session) { + return caller.session + } + const party = resolveDeclaredCallerParty(caller.handle, runtime.getOrchestrationDb()) + return { ...party, paneKey: caller.paneKey ?? null } +} + export type OrchestrationCallerParams = { callerTerminalHandle: string callerEvidence?: OrchestrationCompatibilityEvidence callerAuthority?: OrchestrationCompatibilityCallerAuthority + callerSession: OrchestrationSessionCaller | undefined /** Preserve legacy callers that treated a missing pane as an ordinary fence. */ requireStablePane?: boolean /** @@ -50,33 +78,42 @@ export type OrchestrationCallerParams = { evidenceAssertedByCaller?: boolean } -/** Resolve the caller's runtime pane and, by default, attest its declared handle. */ +/** Resolve the caller's identity and, by default, attest its declared handle. */ export function resolveOrchestrationCaller( runtime: OrcaRuntimeService, params: OrchestrationCallerParams & { requireStablePane: true } -): string +): OrchestrationCallerIdentity export function resolveOrchestrationCaller( runtime: OrcaRuntimeService, params: OrchestrationCallerParams -): string | null +): OrchestrationCallerIdentity | null export function resolveOrchestrationCaller( runtime: OrcaRuntimeService, params: OrchestrationCallerParams -): string | null { +): OrchestrationCallerIdentity | null { if (!params.evidenceAssertedByCaller) { assertCallerHandleMatchesEvidence(runtime, params.callerTerminalHandle, params.callerEvidence) } - const paneKey = - params.callerAuthority?.terminalHandle === params.callerTerminalHandle - ? params.callerAuthority.paneKey - : runtime.getTerminalPaneKey(params.callerTerminalHandle) - if (!paneKey && params.requireStablePane) { - throw new OrchestrationError( - 'stable_pane_required', - 'The coordinator terminal has no stable pane identity. Run this command inside a live Orca terminal.' - ) + const caller = orchestrationCallerIdentity(runtime, { + handle: params.callerTerminalHandle, + session: params.callerSession, + paneKey: + params.callerSession === undefined + ? params.callerAuthority?.terminalHandle === params.callerTerminalHandle + ? params.callerAuthority.paneKey + : runtime.getTerminalPaneKey(params.callerTerminalHandle) + : undefined + }) + if (!hasRunBindingKey(caller)) { + if (params.requireStablePane) { + throw new OrchestrationError( + 'stable_pane_required', + 'The coordinator terminal has no stable pane identity. Run this command inside a live Orca terminal.' + ) + } + return null } - return paneKey ?? null + return caller } // Why: task and gate mutations must share one Run-binding rule. @@ -101,14 +138,21 @@ export function resolveRunScope(runtime: OrcaRuntimeService, params: RunScopePar if (explicit && params.legacyCoordinatorRunId === explicit.id) { return explicit } - const paneKey = params.callerPaneKey ?? runtime.getTerminalPaneKey(params.callerTerminalHandle) - if (!paneKey) { + const caller = orchestrationCallerIdentity(runtime, { + handle: params.callerTerminalHandle, + session: params.callerSession, + paneKey: + params.callerSession === undefined + ? (params.callerPaneKey ?? runtime.getTerminalPaneKey(params.callerTerminalHandle)) + : undefined + }) + if (!hasRunBindingKey(caller)) { throw new OrchestrationError( 'stable_pane_required', 'The coordinator terminal has no stable pane identity.' ) } - const current = db.getCurrentRunForPane(paneKey) + const current = db.getCurrentRunForCoordinator(caller) if (!current) { if (explicit) { throw new OrchestrationError( diff --git a/src/main/runtime/rpc/methods/orchestration/runs/runs.ts b/src/main/runtime/rpc/methods/orchestration/runs/runs.ts index eab6eb2913e..88653af8ecd 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/runs.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/runs.ts @@ -2,6 +2,9 @@ import { defineMethod } from '../../../core' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { assertCallerHandleMatchesEvidence, resolveOrchestrationCaller } from './run-scope' import { exposeRun } from './run-receipt' +import type { OrcaRuntimeService } from '../../../../orca-runtime' +import type { OrchestrationCallerIdentity } from '../../../../orchestration/orchestration-caller-identity' +import { currentDispatchAssigneeRun } from '../messaging/recipient-routing' import { RunCreateParams, RunCurrentParams, @@ -10,24 +13,39 @@ import { RunUseParams } from '../../../../../../shared/rpc-contract/orchestration-runs-params' +function cancelBoundDispatchWaiters( + runtime: OrcaRuntimeService, + caller: OrchestrationCallerIdentity, + runId: string +): void { + const db = runtime.getOrchestrationDb() + const dispatch = db.getActiveDispatchForIdentity(caller.address, caller.paneKey ?? undefined) + if (dispatch && currentDispatchAssigneeRun(runtime, db, dispatch)?.id === runId) { + runtime.cancelMessageWaiters(`dispatch:${dispatch.id}`) + } +} + export const ORCHESTRATION_RUN_METHODS = [ defineMethod({ name: 'orchestration.runCreate', params: RunCreateParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime }) => { - const paneKey = resolveOrchestrationCaller(runtime, { + handler: (params, { orchestrationCompatibilityEvidence, orchestrationCaller, runtime }) => { + const caller = resolveOrchestrationCaller(runtime, { callerTerminalHandle: params.from, callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller, requireStablePane: true }) const db = runtime.getOrchestrationDb() - const priorRun = db.getCurrentRunForPane(paneKey) + const priorRun = db.getCurrentRunForCoordinator(caller) const run = db.createRun({ objective: params.objective, - coordinatorHandle: params.from, - coordinatorPaneKey: paneKey + coordinatorHandle: caller.terminalHandle, + coordinatorPaneKey: caller.paneKey, + coordinatorOrcaSessionId: caller.orcaSessionId }) runtime.cancelMessageWaiters(params.from) + cancelBoundDispatchWaiters(runtime, caller, run.id) if (priorRun) { runtime.cancelMessageWaiters(`run:${priorRun.id}`) } @@ -43,19 +61,22 @@ export const ORCHESTRATION_RUN_METHODS = [ runtime, legacyCoordinatorAuthority, orchestrationCompatibilityEvidence, - orchestrationCompatibilityCallerAuthority: callerAuthority + orchestrationCompatibilityCallerAuthority: callerAuthority, + orchestrationCaller } ) => { - const paneKey = resolveOrchestrationCaller(runtime, { + const caller = resolveOrchestrationCaller(runtime, { callerTerminalHandle: params.from, callerEvidence: orchestrationCompatibilityEvidence, callerAuthority, + callerSession: orchestrationCaller, requireStablePane: true, evidenceAssertedByCaller: true }) if ( params.takeoverLegacy && - (callerAuthority?.terminalHandle !== params.from || callerAuthority.paneKey !== paneKey) + (callerAuthority?.terminalHandle !== params.from || + callerAuthority.paneKey !== caller.paneKey) ) { throw new OrchestrationError( 'legacy_read_only', @@ -65,11 +86,12 @@ export const ORCHESTRATION_RUN_METHODS = [ } assertCallerHandleMatchesEvidence(runtime, params.from, orchestrationCompatibilityEvidence) const db = runtime.getOrchestrationDb() - const priorRun = db.getCurrentRunForPane(paneKey) + const priorRun = db.getCurrentRunForCoordinator(caller) const run = db.bindRun({ runId: params.id, - coordinatorHandle: params.from, - coordinatorPaneKey: paneKey, + coordinatorHandle: caller.terminalHandle, + coordinatorPaneKey: caller.paneKey, + coordinatorOrcaSessionId: caller.orcaSessionId, takeoverLegacy: params.takeoverLegacy, legacyCoordinatorAuthority }) @@ -80,6 +102,7 @@ export const ORCHESTRATION_RUN_METHODS = [ ) } runtime.cancelMessageWaiters(params.from) + cancelBoundDispatchWaiters(runtime, caller, run.id) runtime.cancelMessageWaiters(`run:${params.id}`) if (priorRun && priorRun.id !== params.id) { runtime.cancelMessageWaiters(`run:${priorRun.id}`) @@ -90,13 +113,14 @@ export const ORCHESTRATION_RUN_METHODS = [ defineMethod({ name: 'orchestration.runCurrent', params: RunCurrentParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime }) => { - const paneKey = resolveOrchestrationCaller(runtime, { + handler: (params, { orchestrationCompatibilityEvidence, orchestrationCaller, runtime }) => { + const caller = resolveOrchestrationCaller(runtime, { callerTerminalHandle: params.from, callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller, requireStablePane: true }) - const run = runtime.getOrchestrationDb().getCurrentRunForPane(paneKey) + const run = runtime.getOrchestrationDb().getCurrentRunForCoordinator(caller) return { run: run ? exposeRun(run) : null } } }), diff --git a/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts index cef9e7dd8c1..e939d8a05f7 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts @@ -1,4 +1,3 @@ -import { resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' const { ipcHandlers } = vi.hoisted(() => ({ @@ -31,10 +30,6 @@ vi.mock('@electron-toolkit/utils', () => ({ })) import type Database from '../../../../../sqlite/sync-database' -import { - scanSourceTree, - stripComments -} from '../../../../../../shared/source-scan/source-tree-scan' import type { AgentStatusIpcPayload } from '../../../../../../shared/agent-status-ipc-payload' import { toAgentStatusIpcPayload } from '../../../../../agent-hooks/server/server-status-identity' import type { EnrichedAgentHookEventPayload } from '../../../../../agent-hooks/server/server-types' @@ -47,119 +42,6 @@ import { OrcaRuntimeService } from '../../../../orca-runtime' import { ORCHESTRATION_WORKER_LIST_METHOD } from './worker-list-method' import { projectFleetWorkerPage } from './worker-observation' -/** - * Census of every production site in `src/main` that turns hook-server agent-status rows into - * something a consumer reads. - * - * Why a census and not a single seam test: the false-liveness bug (rework failure table L-1) was - * one such site publishing rows that carry a pane key and nothing else, into a consumer that - * matches on terminal identity. Fixing that site fixes nothing if a fifth one is added beside it, - * so the list is pinned and the identity-bearing paths are each driven end to end. - */ -type CensusRow = { - path: string - /** `produces` = mints payloads a consumer reads; `consumes` = reads them; `wiring` = neither. */ - kind: 'produces' | 'consumes' | 'wiring' - role: string -} - -const CENSUS: readonly CensusRow[] = [ - { - path: 'main/ipc/agent-hooks.ts', - kind: 'produces', - role: 'agentStatus:getSnapshot — renderer pull, enriched (driven below)' - }, - { - path: 'main/ipc/agent-status-ipc-boundary.ts', - kind: 'produces', - role: 'resolveAgentStatusBinding — the one identity lookup the pull and fleet paths share' - }, - { - path: 'main/runtime/agent-status-observed-pane-identity.ts', - kind: 'produces', - role: 'captures the identity a hook row was observed under (fleet-status-observed-identity)' - }, - { - path: 'main/runtime/orchestration-fleet-agent-status-snapshot.ts', - kind: 'produces', - role: 'readOrchestrationFleetAgentStatusSnapshot — the minted fleet evidence (driven below)' - }, - { - path: 'main/startup/main-window-agent-status.ts', - kind: 'produces', - role: 'agentStatus:set — renderer live push, enriched inline (driven below)' - }, - { - path: 'main/startup/main-process-runtime-service.ts', - kind: 'wiring', - role: 'binds the hook server snapshot into the runtime deps' - }, - { - path: 'main/orcad/orcad-entry.ts', - kind: 'wiring', - role: 'binds the same snapshot, OSC producer and structured sink into the headless orcad runtime deps' - }, - { - path: 'main/runtime/orca-runtime-state-fields.ts', - kind: 'wiring', - role: 'stores the snapshot deps on the runtime' - }, - { - path: 'main/runtime/orca-runtime-preserved-branch-cleanup.ts', - kind: 'wiring', - role: 'declares the snapshot dep fields' - }, - { - path: 'main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts', - kind: 'produces', - role: 'getOrchestrationFleetAgentStatusSnapshot — delegates to the checked snapshot module' - }, - { - path: 'main/runtime/orca-runtime-stop-requested-pty-ids.ts', - kind: 'wiring', - role: 'feeds the enriched fleet rows to the orchestration projection' - }, - { - path: 'main/runtime/runtime-agent-orchestration-projection.ts', - kind: 'consumes', - role: 'indexes rows by pane key to attach dispatch context' - }, - { - path: 'main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts', - kind: 'consumes', - role: 'worker-list fleet verdict (driven below)' - }, - { - path: 'main/runtime/rpc/methods/orchestration/worker/worker-observation.ts', - kind: 'consumes', - role: 'worker-show fleet verdict (driven below)' - }, - { - path: 'main/runtime/orca-runtime-get-worktree-ps.ts', - kind: 'consumes', - role: 'worktree.ps inline agent rows (driven below)' - }, - { - path: 'main/runtime/orca-runtime-get-terminal-interactive-wait.ts', - kind: 'consumes', - role: 'exact-worker provider session selection, matched on pane key' - }, - { - path: 'main/runtime/orca-runtime-serialize-agent-prompt-submission.ts', - kind: 'consumes', - role: 'prompt-submission serialization, matched on pane key' - }, - { - path: 'main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts', - kind: 'consumes', - role: 'mobile tab-group pruning and its live agent row, plus the pane identity accessors' - } -] - -/** The names a hook row travels under. A new producer has to use one of them to reach a consumer. */ -const PRODUCER_TOKENS = - /getAgentStatusSnapshot|getAgentProviderSessionSnapshot|enrichAgentStatusIpcPayload|mintAgentStatusFleetEvidence|resolveAgentStatusBinding|getOrchestrationFleetAgentStatusSnapshot|agentStatus:set/ - const PANE_KEY = 'tab-census:leaf-census' const TERMINAL_HANDLE = 'term_census' const PROCESS_INCARNATION = 'pty-census:inc-1' @@ -258,17 +140,7 @@ function censusStore() { } } -describe('agent status producer census', () => { - it('pins every production site that hands hook rows to a consumer', () => { - const root = resolve(import.meta.dirname, '../../../../../..') - const scanned = scanSourceTree(resolve(root, 'main')) - .filter((file) => PRODUCER_TOKENS.test(stripComments(file.source))) - .map((file) => `main/${file.relativePath}`) - .sort() - - expect(scanned).toEqual(CENSUS.map((row) => row.path).sort()) - }) - +describe('agent status identity across every producer and consumer path', () => { it('reads live on worker-list from a hook row that carries only a pane key', async () => { const db = new OrchestrationDb(':memory:') try { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts index 8459a9886e4..d265858b024 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts @@ -155,6 +155,7 @@ describe('orchestration RPC methods', () => { // the tab without scrolling the sidebar to the worker's workspace. expect(runtime.createTerminal).toHaveBeenCalledWith('id:repo::worktree', { startupAgent: 'codex', + launchSource: 'orchestration', title: `worker-${task.id}`, surfaceOwner: false }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts index ecb3270874e..bc2b9601ceb 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts @@ -5,6 +5,7 @@ import { dispatchPreambleSendOptions } from '../../../../orchestration/preamble' import { sendStructuredWorkerPreamble } from '../../orchestration-structured-worker-session' +import type { WorkerTurnStartObservation } from './worker-start-turn-observation' import type { createStructuredWorkerSessionForWorktree } from './worker-topology' type StructuredSession = Awaited> | null @@ -14,7 +15,8 @@ type StructuredSession = Awaited { +}): Promise<{ + prompt?: RuntimeTerminalSend['prompt'] + structuredTurnStart?: WorkerTurnStartObservation +}> { const { runtime, structuredSession, terminalHandle } = args const preamble = buildDispatchPreamble({ // Depth only. A worker is taught the same verbs whichever mode it runs in, so this must not @@ -45,19 +50,32 @@ export async function deliverWorkerDispatchPreamble(args: { cliCommand: runtime.getTerminalOrchestrationCliCommand(terminalHandle) }) if (structuredSession) { - await sendStructuredWorkerPreamble({ + const delivery = await sendStructuredWorkerPreamble({ host: structuredSession.host, sessionId: structuredSession.identity.sessionId, dispatchId: args.dispatchId, preamble }) - return undefined + return { + structuredTurnStart: + delivery === 'accepted' + ? { verdict: 'observed' } + : { + verdict: 'unobserved', + reason: + 'The dispatch preamble was accepted, but the agent had not started to take it. It ' + + 'is delivered when the agent starts; if the worker then reports, this Dispatch ' + + 'settles normally.' + } + } + } + return { + prompt: ( + await runtime.sendTerminalAgentPrompt( + terminalHandle, + preamble, + dispatchPreambleSendOptions(args.requestId) + ) + ).prompt } - return ( - await runtime.sendTerminalAgentPrompt( - terminalHandle, - preamble, - dispatchPreambleSendOptions(args.requestId) - ) - ).prompt } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/explicit-worker-terminal-validation.ts b/src/main/runtime/rpc/methods/orchestration/worker/explicit-worker-terminal-validation.ts index 6e20cc40a29..f9b9e195e21 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/explicit-worker-terminal-validation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/explicit-worker-terminal-validation.ts @@ -1,6 +1,7 @@ import type { OrcaRuntimeService } from '../../../../orca-runtime' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { isStructuredWorkerHandle } from '../../../../structured-worker-identity' +import type { OrchestrationCallerIdentity } from '../../../../orchestration/orchestration-caller-identity' /** * Admits a caller-supplied `--terminal` as this dispatch's worker pane. @@ -13,18 +14,22 @@ export async function assertExplicitWorkerTerminalUsable(args: { runtime: OrcaRuntimeService terminal: string from: string - coordinatorPane: string | null + coordinator: OrchestrationCallerIdentity | null resolvedWorktreeId: string | undefined }): Promise { - const { runtime, terminal, from, coordinatorPane, resolvedWorktreeId } = args + const { runtime, terminal, from, coordinator, resolvedWorktreeId } = args const explicitTerminal = await runtime.showTerminal(terminal) const targetPane = runtime.getTerminalPaneKey(terminal) - const callerPane = coordinatorPane ?? runtime.getTerminalPaneKey(from) + const callerPane = coordinator?.paneKey ?? runtime.getTerminalPaneKey(from) // A structured coordinator has no terminal to show, so its own identity is the raw handle plus - // the pane key; showing `from` unconditionally would throw for exactly those callers. - const coordinatorHandle = isStructuredWorkerHandle(from) - ? from - : (await runtime.showTerminal(from)).handle + // the pane key; showing `from` unconditionally would throw for exactly those callers. A + // handle-less session has no terminal at all, so its address is its identity. + const coordinatorHandle = + coordinator?.terminalHandle === null + ? coordinator.address + : isStructuredWorkerHandle(from) + ? from + : (await runtime.showTerminal(from)).handle if ( explicitTerminal.handle === coordinatorHandle || (targetPane !== null && targetPane === callerPane) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/failed-worker-start-teardown.ts b/src/main/runtime/rpc/methods/orchestration/worker/failed-worker-start-teardown.ts index 250b639fc60..758bc3397cb 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/failed-worker-start-teardown.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/failed-worker-start-teardown.ts @@ -8,10 +8,10 @@ import type { createStructuredWorkerSessionForWorktree } from './worker-topology /** * Undoes what a start created before it failed. * - * A start that never reached ready leaves no settlement to release the hold later, and its session - * was already published as a chat tab — without the discard, a failed start strands a dead chat tab - * that the durable restore index republishes on every app launch. Both halves are best-effort by - * construction, so neither can replace the real error. + * A start that never reached ready leaves no settlement to release its binding later, and its + * session was already published as a chat tab — without the discard, a failed start strands a dead + * chat tab that the durable restore index republishes on every app launch. Both halves are + * best-effort by construction, so neither can replace the real error. * * A created PTY terminal is deliberately NOT torn down: its custody row was written at creation, so * `worker-release` on the failed Dispatch owns that cleanup and the coordinator decides when. diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index f8cd1033c97..d6b265c41a9 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -2,6 +2,10 @@ import type { OrcaRuntimeService } from '../../../../orca-runtime' import { describeTerminalWaitBlockedReason } from '../../../../../../shared/terminal-wait-blocked-reason-legacy-alias' import type { OrchestrationDb } from '../../../../orchestration/db' import type { RunRow, TaskRow } from '../../../../orchestration/types' +import type { + OrchestrationCallerIdentity, + OrchestrationSessionCaller +} from '../../../../orchestration/orchestration-caller-identity' import { resolveDispatchCreator } from '../runs/dispatch-creator' import { resolveDispatchCallerWorktreeId } from '../../orchestration-caller-workspace' import { @@ -38,18 +42,25 @@ export async function startLocalWorker(args: { runtime: OrcaRuntimeService db: OrchestrationDb run: RunRow - coordinatorPane: string | null + coordinator: OrchestrationCallerIdentity | null + callerSession?: OrchestrationSessionCaller existingTask?: TaskRow orchestrationMutation?: WorkerStartMutation /** Settings-driven; the executing host still gets to refuse below. */ mode: WorkerStartModeReceipt }): Promise { - const { params, runtime, db, run, coordinatorPane, existingTask, orchestrationMutation } = args + const { params, runtime, db, run, coordinator, callerSession, existingTask } = args + const { orchestrationMutation } = args + const coordinatorPane = coordinator?.paneKey ?? null const requestedWorktree = params.worktree ?? 'current' const createsWorktree = requestedWorktree === 'new-child' || requestedWorktree === 'new-top-level' const { agent, launch } = prepareLocalWorkerStart({ params, createsWorktree, runtime }) - const coordinatorWorktreeId = await resolveDispatchCallerWorktreeId(runtime, params.from) + const coordinatorWorktreeId = await resolveDispatchCallerWorktreeId( + runtime, + params.from, + callerSession + ) const creationWorktree = createsWorktree ? await runtime.showManagedWorktree(`id:${coordinatorWorktreeId}`) : undefined @@ -70,7 +81,7 @@ export async function startLocalWorker(args: { runtime, terminal: params.terminal, from: params.from, - coordinatorPane, + coordinator, resolvedWorktreeId: resolvedWorktree?.id }) } @@ -95,7 +106,7 @@ export async function startLocalWorker(args: { : 'existing_worktree' } const started = db.createStartingWorkerDispatch({ - creator: resolveDispatchCreator(runtime, params.from), + creator: resolveDispatchCreator(runtime, params.from, callerSession), maxDepth: runtime.getNestedWorkerMaxDepth(), taskId: existingTask?.id, taskSpec: params.spec, @@ -103,10 +114,12 @@ export async function startLocalWorker(args: { taskDeps: parseTaskDeps(params.deps), taskParentId: params.parent, taskRunId: run.id, - taskCreatedByTerminalHandle: params.from, + // A handle-less session creates root Tasks: Task lineage is recorded by terminal only. + taskCreatedByTerminalHandle: coordinator?.terminalHandle ?? undefined, taskCreatedByPaneKey: coordinatorPane ?? undefined, - taskCreatedByProcessIncarnation: - runtime.getTerminalProcessIncarnation(params.from) ?? undefined, + taskCreatedByProcessIncarnation: coordinator?.terminalHandle + ? (runtime.getTerminalProcessIncarnation(coordinator.terminalHandle) ?? undefined) + : undefined, taskCreatedByRunGeneration: run.consumer_generation, retryOf: params.retryOf, startOptions, @@ -175,10 +188,17 @@ export async function startLocalWorker(args: { effects, timeoutMs: params.timeoutMs ?? 60_000 }) - : await runtime.waitForTerminal(terminalHandle, { - condition: 'tui-idle', - timeoutMs: params.timeoutMs ?? 60_000 - }) + : // ZCode emits SessionStart only after input; its first dispatch must wait for the composer. + agent === 'zcode' && !params.terminal + ? await runtime.waitForFreshWorkerComposer( + terminalHandle, + agent, + params.timeoutMs ?? 60_000 + ) + : await runtime.waitForTerminal(terminalHandle, { + condition: 'tui-idle', + timeoutMs: params.timeoutMs ?? 60_000 + }) if (wait) { persistWorkerSetupWaitOutcome({ ...setupStage, wait }) if (!wait.satisfied) { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/structured-worker-release-stop.ts b/src/main/runtime/rpc/methods/orchestration/worker/structured-worker-release-stop.ts index 4a32147d1a6..91e524e4bf1 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/structured-worker-release-stop.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/structured-worker-release-stop.ts @@ -4,15 +4,15 @@ import type { WorkerTerminalResourceRow } from '../../../../orchestration/worker import { stopStructuredWorker } from '../../orchestration-structured-worker-lifecycle' import type { StructuredWorkerIdentity } from '../../../../structured-worker-identity' import { archiveSummary } from './worker-terminal-resource-presentation' -import type { WorkerReleaseReceipt } from './worker-release-completion' +import { releaseUnknownRecovery, type WorkerReleaseReceipt } from './worker-release-completion' /** * The close half of a release for a worker that IS a structured session. * * Separate from the PTY close for the same reason the delivery lane is: there is no terminal to * close and no exit to observe, so the host's own settlement is the only proof available. Only a - * proven close may settle; an unproven one reports `release_unknown` and stays retryable under the - * same request id. + * proven close may settle; an unproven one reports `release_unknown` and stays retryable, but only + * under a fresh request id — replaying the prior one just returns this same receipt. */ export async function stopStructuredWorkerForRelease(args: { structured: StructuredWorkerIdentity @@ -36,7 +36,7 @@ export async function stopStructuredWorkerForRelease(args: { processAction: stop.closeAttempted ? 'closed_agent_terminal' : 'none', archive: { source: args.archiveSource, status: args.archiveStatus }, lastError: unknown.release_error ?? stop.reason, - recovery: `Inspect with: orca orchestration worker-show --dispatch ${dispatchId} --json — then repeat worker-release with the same --retry-request.` + recovery: releaseUnknownRecovery(dispatchId) } } const settled = db.settleWorkerTerminalRelease(resource.id) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts index 5e8babf3c5e..a9211532626 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts @@ -143,7 +143,10 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS = [ `Worker Dispatch ${params.dispatch} no longer resolves to its exact process.` ) } - const structured = readStructuredWorkerOutput({ + // Read via the handle inspectWorkerTerminal proved live: the durable one, or a handle + // re-minted from the recorded incarnation after the durable handle went stale. + const liveHandle = observation.terminalHandle ?? terminalHandle + const structured = await readStructuredWorkerOutput({ db, dispatchId: params.dispatch, workerState: worker?.state ?? 'unsupervised', @@ -163,7 +166,7 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS = [ const output = await readExactWorkerOutput({ runtime, dispatchId: params.dispatch, - terminalHandle, + terminalHandle: liveHandle, workerState: worker?.state ?? 'unsupervised', terminalStatus: observation.status === 'exited' @@ -200,9 +203,8 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS = [ const abandoned = runtime.getOrchestrationDb().abandonWorkerDispatch(params.dispatch) if (abandoned.disposition === 'context_only') { if (!abandoned.alreadySettled) { - // Abandon settles the Dispatch, so it owes the same hold release stop and release do. - // A surviving hold pins the provider child for the life of the app and makes host crash - // recovery respawn a worker nobody is waiting on. + // Abandon settles the Dispatch, so it owes the same binding release stop and release do: + // a surviving redrive subscription keeps nudging a worker nobody is waiting on. releaseStructuredWorkerSession(params.dispatch, runtime) runtime.notifyMessageArrived(`dispatch:${params.dispatch}`, 'status') } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts index 83b3d020f4c..f1202c6e7cf 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts @@ -9,12 +9,14 @@ import { observeStructuredWorker, resolveStructuredWorkerForDispatch } from '../../orchestration-structured-worker-lifecycle' +import { structuredWorkerAddressable } from '../../../../structured-worker-custody' import type { DispatchContextRow, FederatedDispatchRow, WorkerDispatchRow } from '../../../../orchestration/types' +/** Observe a worker terminal, re-minting a live handle from the recorded process incarnation when the durable handle went stale, so a still-running worker is never reported missing and leaked. */ export async function inspectWorkerTerminal( runtime: OrcaRuntimeService, db: OrchestrationDb, @@ -27,12 +29,18 @@ export async function inspectWorkerTerminal( reason?: string /** Set only on a proven-exact worker parked on a prompt that needs a human. */ agentWait?: RuntimeTerminalInteractiveWait | null + /** Structured workers only: whether mail still reaches it — at rest included, since the mail + * starts it. Absent when ownership cannot be read. `status` stays the process verdict. */ + addressable?: boolean + /** The handle that actually resolved: the durable one, or a live handle re-minted from the + * recorded process incarnation after the durable handle went stale. Null when none resolved. */ + terminalHandle: string | null }> { const worker = db.getWorkerDispatch(dispatchId) const terminalHandle = worker?.agent_terminal_handle ?? db.getDispatchContextById(dispatchId)?.assignee_handle if (!terminalHandle) { - return { terminal: null, exact: false, status: 'unattached' } + return { terminal: null, exact: false, status: 'unattached', terminalHandle: null } } const structured = resolveStructuredWorkerForDispatch(db, dispatchId) if (structured) { @@ -49,24 +57,54 @@ export async function inspectWorkerTerminal( processIncarnation: structured.processIncarnation }) const observation = observeStructuredWorker(structured) + const addressable = structuredWorkerAddressable( + db, + structured.sessionId, + db.getWorkerTerminalResourceByHandle?.(structured.handle) + ) return { terminal: null, exact, status: exact ? observation.status : 'identity_changed', - ...(exact && observation.reason ? { reason: observation.reason } : {}) + ...(exact && observation.reason ? { reason: observation.reason } : {}), + ...(exact && addressable !== null ? { addressable } : {}), + terminalHandle: null } } - const terminal = await runtime.showTerminal(terminalHandle).catch(() => null) + let effectiveHandle = terminalHandle + let terminal = await runtime.showTerminal(effectiveHandle).catch(() => null) if (!terminal) { - return { terminal: null, exact: false, status: 'missing' } + // Why: the durable handle resolves nowhere after a renderer graph epoch bump or handle + // invalidation, yet the recorded process incarnation may still name a live PTY. Re-mint a + // live handle (incarnation-fenced) so worker-show and release act on the still-running + // process instead of reporting it missing — which would leak the agent process tree. + // (workerList is a pure DB projection and never calls inspectWorkerTerminal.) + const resource = db.getWorkerTerminalResourceByOwner(dispatchId) + const reminted = resource?.process_incarnation + ? runtime.resolveTerminalHandleByProcessIncarnation( + resource.process_incarnation, + resource.host_scope + ) + : null + if (reminted) { + const remintedTerminal = await runtime.showTerminal(reminted).catch(() => null) + if (remintedTerminal) { + effectiveHandle = reminted + terminal = remintedTerminal + } + } + } + if (!terminal) { + // The re-mint above failed, so no live handle resolved; report the durable handle unresolved. + return { terminal: null, exact: false, status: 'missing', terminalHandle: null } } const exact = db.isDispatchProcessCurrent({ dispatchId, - paneKey: runtime.getTerminalPaneKey(terminalHandle), - processIncarnation: runtime.getTerminalProcessIncarnation(terminalHandle) + paneKey: runtime.getTerminalPaneKey(effectiveHandle), + processIncarnation: runtime.getTerminalProcessIncarnation(effectiveHandle) }) if (!exact) { - return { terminal, exact, status: 'identity_changed' } + return { terminal, exact, status: 'identity_changed', terminalHandle: effectiveHandle } } // Why: the aggregate inventory only iterates registered providers, so a dropped // relay clears `connected` for every remote PTY at once. Lost contact is not a @@ -76,38 +114,48 @@ export async function inspectWorkerTerminal( // Exact-gated by the early return above: a replaced process's prompt would attribute another // lane's blocker to this worker. const agentWait = terminal.agentWait - const verdict = runtime.getTerminalLivenessVerdict?.(terminalHandle) ?? null + const verdict = runtime.getTerminalLivenessVerdict?.(effectiveHandle) ?? null if (verdict?.status === 'unverifiable') { - return { terminal, exact, status: 'unverifiable', reason: verdict.reason, agentWait } + return { + terminal, + exact, + status: 'unverifiable', + reason: verdict.reason, + agentWait, + terminalHandle: effectiveHandle + } } if (verdict?.status === 'live') { - return { terminal, exact, status: 'live', agentWait } + return { terminal, exact, status: 'live', agentWait, terminalHandle: effectiveHandle } } if (!verdict) { const dispatch = db.getDispatchContextById?.(dispatchId) const persistedHostScope = parseWorkerTerminalHostScope(dispatch?.host_scope ?? null) - const currentHostScope = runtime.getOrchestrationDispatchAuthority?.(terminalHandle)?.hostScope + const currentHostScope = runtime.getOrchestrationDispatchAuthority?.(effectiveHandle)?.hostScope if (persistedHostScope?.kind === 'ssh' || currentHostScope?.kind === 'ssh') { return { terminal, exact, status: 'unverifiable', reason: 'missing_liveness_verdict', - agentWait + agentWait, + terminalHandle: effectiveHandle } } return { terminal, exact, status: terminal.connected === false ? 'exited' : 'live', - agentWait + agentWait, + terminalHandle: effectiveHandle } } return { terminal, exact, status: 'exited', - agentWait + agentWait, + terminalHandle: effectiveHandle } } @@ -119,7 +167,8 @@ export function exposeObservation(observation: Awaited { console.warn( '[orchestration] structured host install failed before release', @@ -121,6 +120,9 @@ async function completeWorkerTerminalReleaseOnce( } } const observation = await inspectWorkerTerminal(runtime, db, dispatchId) + // The live handle to act on: the durable one, or a handle re-minted from the recorded process + // incarnation when the durable handle went stale (inspectWorkerTerminal proved it live). + const terminalHandle = observation.terminalHandle ?? resource.terminal_handle if (observation.status === 'identity_changed') { const retained = db.revertWorkerTerminalReleaseToRetained(resource.id, 'identity_unproven') return { @@ -132,33 +134,39 @@ async function completeWorkerTerminalReleaseOnce( } } if (observation.status === 'missing' || observation.status === 'unattached') { - if (args.mode === 'recovery') { - // A close can succeed before the process crashes, leaving `releasing` durable state while - // terminal inventory no longer resolves the handle. Only a positive host liveness verdict - // may settle that exact incarnation; contact loss remains pending/unverifiable. - if (resource.process_incarnation) { - const processLiveness = await runtime.inspectTerminalProcessIncarnationLiveness( - resource.process_incarnation, - resource.host_scope - ) - if (processLiveness === 'exited') { - const reconciled = db.settleDeadWorkerTerminalRelease({ - requestingDispatchId: dispatchId, - resourceId: resource.id, - processIncarnation: resource.process_incarnation - }) - if (reconciled.disposition === 'released') { - runtime.notifyMessageArrived(`dispatch:${dispatchId}`, 'status') - return { - dispatchId, - state: 'released', - processAction: 'closed_exited_terminal', - archive: archiveSummary(reconciled.resource) - } + // Re-resolution by process incarnation (inspectWorkerTerminal) already failed, so no live PTY + // carries this worker's exact incarnation. If that incarnation is provably gone, settle + // released BEFORE the recovery defer: proof of death outranks deferral, so a provably-exited + // worker never languishes in release_pending across recovery passes. + if (resource.process_incarnation) { + const processLiveness = await runtime.inspectTerminalProcessIncarnationLiveness( + resource.process_incarnation, + resource.host_scope + ) + if (processLiveness === 'exited') { + // Prefer incarnation-fenced settle (dispatch relation + process_incarnation CAS). + const reconciled = db.settleDeadWorkerTerminalRelease({ + requestingDispatchId: dispatchId, + resourceId: resource.id, + processIncarnation: resource.process_incarnation + }) + if (reconciled.disposition === 'released') { + runtime.notifyMessageArrived(`dispatch:${dispatchId}`, 'status') + return { + dispatchId, + state: 'released', + processAction: 'none', + archive: archiveSummary(reconciled.resource) } } + // settleDead retains when the archive is still mandatory and missing (e.g. requested but + // never committed). Do NOT plain-settle: that would discard output and break recovery's + // "archive is mandatory" invariant. Fall through to recovery pending / unknown instead. } - // Inventory may still be incomplete during startup/reconnect discovery; defer. + } + if (args.mode === 'recovery') { + // No death certificate yet: inventory may still be incomplete during startup/reconnect + // discovery, so defer instead of guessing. return { dispatchId, state: 'release_pending', @@ -184,7 +192,7 @@ async function completeWorkerTerminalReleaseOnce( } } - if (!workerTerminalLeaseIsCurrent(runtime, db, dispatchId, resource)) { + if (!workerTerminalLeaseIsCurrent(runtime, db, dispatchId, resource, terminalHandle)) { const retained = db.revertWorkerTerminalReleaseToRetained(resource.id, 'identity_unproven') return { dispatchId, @@ -203,7 +211,7 @@ async function completeWorkerTerminalReleaseOnce( const captured = await captureWorkerOutputArchive({ runtime, dispatchId, - terminalHandle: resource.terminal_handle, + terminalHandle, attachedAtMs: orchestrationTimestampToMs(worker.created_at), structuredWorker: structured }) @@ -231,7 +239,7 @@ async function completeWorkerTerminalReleaseOnce( archive: archiveSummary(releasing) } } - if (!workerTerminalLeaseIsCurrent(runtime, db, dispatchId, releasing)) { + if (!workerTerminalLeaseIsCurrent(runtime, db, dispatchId, releasing, terminalHandle)) { const retained = db.revertWorkerTerminalReleaseToRetained(resource.id, 'identity_unproven') return { dispatchId, @@ -254,7 +262,7 @@ async function completeWorkerTerminalReleaseOnce( archiveStatus }) } - const close = await runtime.closeTerminal(resource.terminal_handle) + const close = await runtime.closeTerminal(terminalHandle) if (!close.ptyKilled) { const reason = describeUnconfirmedAgentStop(close) const unknown = db.markWorkerTerminalReleaseUnknown(resource.id, reason) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts index dd4ce2522ea..d9798a47372 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts @@ -100,7 +100,7 @@ it.each(['unary', 'stream'])('mobile %s bytes do no orchestration database work' method.handler(method.params!.parse(params) as never, { runtime } as never) ).resolves.toMatchObject({ send: { accepted: true } }) } - expect(write).toHaveBeenCalledWith('pty-worker', 'x') + expect(write).toHaveBeenCalledWith('pty-worker', 'x', 'driving') expect(commit).toHaveBeenCalledTimes(1) expect(dbAccess).not.toHaveBeenCalled() expect(takeover).not.toHaveBeenCalled() diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-runtime-incarnation.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-runtime-incarnation.test.ts new file mode 100644 index 00000000000..4caac62f5da --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-runtime-incarnation.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { inspectWorkerTerminal } from './worker-observation' +import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' +import { makePaneKey } from '../../../../../../shared/stable-pane-id' + +const PTY_ID = 'runtime_test:term_worker' +const LEAF_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + +describe('worker release through runtime incarnation recovery', () => { + const harness = createOrchestrationWorkerReleaseHarness() + afterEach(() => harness.cleanup()) + + it.each(['release', 'stop', 'read', 'replacement', 'disconnected'] as const)( + '%s uses runtime identity and close paths', + async (scenario) => { + harness.setup() + const { dispatchId } = + scenario === 'stop' ? await harness.startWorker() : await harness.startSettledWorker() + const runtime = harness.runtime + vi.mocked(runtime.showTerminal).mockRestore() + vi.mocked(runtime.getTerminalPaneKey).mockRestore() + vi.mocked(runtime.getTerminalProcessIncarnation).mockRestore() + vi.mocked(runtime.getOrchestrationDispatchAuthority).mockRestore() + vi.mocked(runtime.readTerminal).mockRestore() + vi.mocked(runtime.closeTerminal).mockRestore() + const kill = vi.fn(() => true) + runtime.setPtyController({ write: () => true, kill, getForegroundProcess: async () => null }) + runtime.registerPty(PTY_ID, 'repo::worktree', null, { + tabId: 'tab_worker', + leafId: LEAF_ID, + incarnationId: scenario === 'replacement' ? '2' : '1' + }) + await expect(runtime.showTerminal('term_worker')).rejects.toThrow() + if (scenario === 'disconnected') { + vi.spyOn(runtime, 'getTerminalLivenessVerdict').mockReturnValue({ + status: 'unverifiable', + reason: 'transport unavailable' + }) + const observed = await inspectWorkerTerminal(runtime, harness.db, dispatchId) + expect(observed).toMatchObject({ exact: true, status: 'unverifiable' }) + expect(kill).not.toHaveBeenCalled() + return + } + if (scenario === 'stop') { + const receipt = await harness.call('orchestration.workerStop', { dispatch: dispatchId }) + expect(receipt).toMatchObject({ state: 'stopped', processAction: 'closed_agent_terminal' }) + expect(kill).toHaveBeenCalledExactlyOnceWith(PTY_ID) + return + } + if (scenario === 'read') { + const readTerminal = vi.spyOn(runtime, 'readTerminal') + const output = await harness.call('orchestration.workerRead', { dispatch: dispatchId }) + // Pins the read to the reminted terminal: the handle it reached must resolve to the + // registered pane and incarnation, which the stale durable handle never does. + const [[readHandle]] = readTerminal.mock.calls + expect(runtime.getTerminalPaneKey(readHandle)).toBe(makePaneKey('tab_worker', LEAF_ID)) + expect(runtime.getTerminalProcessIncarnation(readHandle)).toBe(`${PTY_ID}:1`) + expect(output).toMatchObject({ + source: 'terminal', + fallbackReason: 'session_not_reported' + }) + expect(kill).not.toHaveBeenCalled() + return + } + const receipt = await harness.call('orchestration.workerRelease', { dispatch: dispatchId }) + if (scenario === 'replacement') { + expect(receipt).toMatchObject({ state: 'release_unknown', processAction: 'none' }) + expect(kill).not.toHaveBeenCalled() + return + } + expect(receipt).toMatchObject({ state: 'released', processAction: 'closed_agent_terminal' }) + expect(kill).toHaveBeenCalledExactlyOnceWith(PTY_ID) + expect(harness.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + ownership_state: 'released', + release_state: 'released' + }) + } + ) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts index 593d097580f..526b09a6320 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts @@ -381,6 +381,7 @@ describe('orchestration worker-start prompt contract', () => { const { runtime, handle } = await createAgentPromptSubmissionRuntime(() => undefined, 'codex') runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) const pending = runtime.sendTerminalAgentPrompt(handle, 'queued prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'busy-swallowed', observationTimeoutMs: 0 diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts new file mode 100644 index 00000000000..e39a06110dc --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('../../../../orchestration/preamble', () => ({ buildDispatchPreamble: () => 'preamble' })) +vi.mock('./worker-topology', async (importOriginal) => ({ + ...(await importOriginal>()), + monitorWorkerSetup: () => {} +})) + +const { deliverAndSettleWorkerStartReadiness } = await import('./worker-start-readiness-settlement') + +function settle(delivered: 'accepted' | undefined) { + const db = { + getWorkerDispatch: () => ({ state: 'starting' }), + markWorkerStartUnknown: vi.fn(() => ({ + stage: 'turn_start_unobserved', + residual_resources: '[]' + })), + markWorkerDispatchReady: vi.fn(() => ({ state: 'ready', stage: 'ready' })) + } + const host = { + deps: { store: { getRecord: () => ({ lease: { runtimeFence: 1 } }) } }, + send: async () => ({ + ok: true, + value: { clientMessageId: 'c1', submission: { dispatchState: 'pending', reason: null } } + }), + // undefined: the worker's agent was still starting when the wait ran out. + waitForSendSettlement: async () => + delivered + ? { value: { clientMessageId: 'c1', submission: { dispatchState: delivered } } } + : undefined + } + const args = { + runtime: { + getNestedWorkerMaxDepth: () => 3, + getTerminalOrchestrationCliCommand: () => 'orca' + }, + db, + run: { id: 'run_1' }, + task: { id: 't1', spec: 'do the thing' }, + dispatchId: 'd1', + dispatchDepth: 0, + structuredSession: { host, identity: { sessionId: 's1' } }, + terminalHandle: 'structured_worker_1', + coordinatorHandle: 'term_c', + dispatchCapability: 'capability', + devMode: undefined, + requestId: 'r1', + agent: 'claude', + setupReceipt: {}, + launchReceipt: {}, + mode: {}, + timeoutMs: 60_000, + effects: [], + terminalRevealWarning: undefined, + onStage: () => {} + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fakes implement exactly the runtime, db and host members this settlement reaches. + const receipt = deliverAndSettleWorkerStartReadiness(args as never) + return { db, receipt } +} + +describe('a structured worker whose agent outlasts the preamble wait', () => { + it('parks as start-unknown instead of failing the start, and never names a screen to read', async () => { + const { db, receipt } = settle(undefined) + + // Resolving, not throwing, is what keeps the worker's session: a throw tears it down. + await expect(receipt).resolves.toMatchObject({ + state: 'outcome_unknown', + turnStart: 'unobserved', + nextCommands: [ + 'orca orchestration worker-show --dispatch d1 --json', + 'orca orchestration worker-abandon --dispatch d1 --json' + ] + }) + expect(db.markWorkerStartUnknown).toHaveBeenCalledWith( + 'd1', + 'turn_start_unobserved', + expect.stringContaining('delivered when the agent starts'), + expect.anything() + ) + expect(db.markWorkerDispatchReady).not.toHaveBeenCalled() + }) + + it('is ready once the agent took the preamble within the wait', async () => { + const { db, receipt } = settle('accepted') + + await expect(receipt).resolves.toMatchObject({ state: 'ready', turnStart: 'observed' }) + expect(db.markWorkerStartUnknown).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts index a3c57a357d2..7e9118daffb 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts @@ -47,7 +47,7 @@ export async function deliverAndSettleWorkerStartReadiness(args: { const { runtime, db, run, task, structuredSession, terminalHandle, effects } = args args.onStage('dispatch_input') - const promptDelivery = await deliverWorkerDispatchPreamble({ + const delivery = await deliverWorkerDispatchPreamble({ runtime, structuredSession, terminalHandle, @@ -71,11 +71,11 @@ export async function deliverAndSettleWorkerStartReadiness(args: { // The write above was accepted without waiting on provider hooks; now demand the positive // evidence the receipt claims is observable. A worker whose turn never starts must not be // reported ready — a wedged agent and a working one looked identical before this gate. - // A structured preamble send is acknowledged by the provider or throws, so it is already - // positive evidence. - const turnStart: WorkerTurnStartObservation = structuredSession - ? { verdict: 'observed' } - : await observeWorkerTurnStart({ runtime, terminalHandle, prompt: promptDelivery }) + // A structured preamble send is its own evidence: acknowledged, or still held for its agent. + const promptDelivery = delivery.prompt + const turnStart: WorkerTurnStartObservation = + delivery.structuredTurnStart ?? + (await observeWorkerTurnStart({ runtime, terminalHandle, prompt: promptDelivery })) const deliveredPrompt = turnStart.prompt ?? promptDelivery monitorWorkerSetup({ runtime, @@ -98,7 +98,7 @@ export async function deliverAndSettleWorkerStartReadiness(args: { id: terminalHandle, state: 'turn_unobserved' }) - const reason = describeUnobservedWorkerTurnStart(args.agent) + const reason = turnStart.reason ?? describeUnobservedWorkerTurnStart(args.agent) const worker = db.markWorkerStartUnknown( args.dispatchId, 'turn_start_unobserved', @@ -122,7 +122,8 @@ export async function deliverAndSettleWorkerStartReadiness(args: { residualResources: JSON.parse(worker.residual_resources) as unknown[], nextCommands: [ `orca orchestration worker-show --dispatch ${args.dispatchId} --json`, - `orca terminal read --terminal ${terminalHandle} --screen`, + // A structured worker has no screen to read. + ...(structuredSession ? [] : [`orca terminal read --terminal ${terminalHandle} --screen`]), `orca orchestration worker-abandon --dispatch ${args.dispatchId} --json` ], ...(args.terminalRevealWarning ? { warning: args.terminalRevealWarning } : {}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.ts index d1c9e59adfa..cc648ee7dde 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.ts @@ -19,6 +19,8 @@ export type WorkerTurnStartVerdict = 'observed' | 'permission' | 'unsupported' | export type WorkerTurnStartObservation = { verdict: WorkerTurnStartVerdict prompt?: RuntimeTerminalPromptDelivery + /** Why an `unobserved` start is unknown, when the default PTY wording does not fit. */ + reason?: string } function classifyPromptDelivery(prompt: RuntimeTerminalPromptDelivery): WorkerTurnStartVerdict { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts index 98d3c376f61..ee0e73c904f 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts @@ -141,6 +141,7 @@ export const ORCHESTRATION_WORKER_STOP_METHODS = [ }) } const observation = await inspectWorkerTerminal(runtime, db, params.dispatch) + const liveHandle = observation.terminalHandle ?? handle // The host exit can settle this stop while terminal inspection is awaiting inventory. if (db.getWorkerDispatch(params.dispatch)?.state === 'stopped') { runtime.notifyMessageArrived(`dispatch:${params.dispatch}`, 'status') @@ -201,7 +202,7 @@ export const ORCHESTRATION_WORKER_STOP_METHODS = [ } } const closed = await runtime - .closeTerminal(handle) + .closeTerminal(liveHandle) .then((close) => ({ close }) as const) .catch( (error: unknown) => diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-terminal-release-lease.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-terminal-release-lease.ts index 50a97c9de4b..cad0ceb4128 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-terminal-release-lease.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-terminal-release-lease.ts @@ -9,23 +9,27 @@ export function workerTerminalLeaseIsCurrent( runtime: OrcaRuntimeService, db: OrchestrationDb, dispatchId: string, - resource: WorkerTerminalResourceRow + resource: WorkerTerminalResourceRow, + // Live (possibly reminted) handle for runtime probes. Durable identity still compares + // worker.agent_terminal_handle to resource.terminal_handle; after beginGraphReload the durable + // string is absent from the handle table, so authority/pane/incarnation must use the live one. + liveTerminalHandle: string ): boolean { const worker = db.getWorkerDispatch(dispatchId) if (isStructuredWorkerHandle(resource.terminal_handle)) { return structuredWorkerTerminalLeaseIsCurrent(db, dispatchId, worker, resource) } - const authority = runtime.getOrchestrationDispatchAuthority(resource.terminal_handle) + const authority = runtime.getOrchestrationDispatchAuthority(liveTerminalHandle) // Exited PTYs retain identity and host evidence but no longer mint launch authority. return Boolean( worker?.agent_terminal_handle === resource.terminal_handle && (authority ? resource.host_scope === JSON.stringify(authority.hostScope) - : runtime.getTerminalLivenessVerdict(resource.terminal_handle)?.status === 'exited') && + : runtime.getTerminalLivenessVerdict(liveTerminalHandle)?.status === 'exited') && db.isDispatchProcessCurrent({ dispatchId, - paneKey: runtime.getTerminalPaneKey(resource.terminal_handle), - processIncarnation: runtime.getTerminalProcessIncarnation(resource.terminal_handle) + paneKey: runtime.getTerminalPaneKey(liveTerminalHandle), + processIncarnation: runtime.getTerminalProcessIncarnation(liveTerminalHandle) }) && !db.workerTerminalResourceHasIdentityConflict(resource.id) ) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-topology.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-topology.ts index b18eb9ee6c2..3e86dea3e28 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-topology.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-topology.ts @@ -69,6 +69,7 @@ export async function createExistingWorktreeWorkerTerminal(args: { // desktop app while its CLI is `cursor-agent`. Let the runtime build the // configured launcher instead of executing the raw id. startupAgent: args.agent, + launchSource: 'orchestration', ...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}), title: `worker-${args.taskId}`, // Why: dispatching a worker is background work; it must not pull the sidebar diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts index fe0c11e374d..cbdab8d1f94 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts @@ -61,6 +61,7 @@ export async function createWorkerWorktree(args: { ...(args.withAgentTerminal ? { startupAgent: args.agent, + startupLaunchSource: 'orchestration', ...(args.launchPreferences ? { startupLaunchPreferences: args.launchPreferences } : {}) } : {}), diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts index 5164ac0b22f..cda3354e816 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts @@ -140,6 +140,7 @@ describe('orchestration new-worktree workers', () => { expect(runtime.createManagedWorktree).toHaveBeenCalledWith( expect.objectContaining({ startupAgent: 'codex', + startupLaunchSource: 'orchestration', awaitTerminalProvisioning: true, observeSetupCompletion: true, lineage: expect.objectContaining({ noParent: true, parentWorktree: undefined }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers.ts index b1a1f40d45a..be4e40ab2b6 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers.ts @@ -20,7 +20,7 @@ export const ORCHESTRATION_WORKER_START_METHODS = [ params: WorkerStartParams, handler: async ( params, - { runtime, orchestrationMutation, orchestrationCompatibilityEvidence } + { runtime, orchestrationMutation, orchestrationCompatibilityEvidence, orchestrationCaller } ) => { if (!isWorkerStartTimeoutWithinTimerLimit(params.timeoutMs)) { throw new OrchestrationError( @@ -30,11 +30,12 @@ export const ORCHESTRATION_WORKER_START_METHODS = [ } const readinessTimeoutMs = resolveWorkerStartReadinessTimeoutMs(params.timeoutMs) const db = runtime.getOrchestrationDb() - const coordinatorPane = resolveOrchestrationCaller(runtime, { + const coordinator = resolveOrchestrationCaller(runtime, { callerTerminalHandle: params.from, - callerEvidence: orchestrationCompatibilityEvidence + callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller }) - const run = coordinatorPane ? db.getCurrentRunForPane(coordinatorPane) : undefined + const run = coordinator ? db.getCurrentRunForCoordinator(coordinator) : undefined if (!run || (params.run && params.run !== run.id)) { throw new OrchestrationError( 'consumer_fenced', @@ -62,7 +63,8 @@ export const ORCHESTRATION_WORKER_START_METHODS = [ db, runId: run.id, task: existingTask, - orchestrationMutation + orchestrationMutation, + callerSession: orchestrationCaller }) return receipt && typeof receipt === 'object' ? { ...receipt, mode } : receipt } @@ -71,7 +73,8 @@ export const ORCHESTRATION_WORKER_START_METHODS = [ runtime, db, run, - coordinatorPane, + coordinator, + callerSession: orchestrationCaller, existingTask, orchestrationMutation, mode diff --git a/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts new file mode 100644 index 00000000000..30d32c2ede8 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' + +describe('ZCode first dispatch readiness', () => { + const h = createOrchestrationWorkerReleaseHarness() + afterEach(() => h.cleanup()) + + it('waits for the new composer before delivering exactly one dispatch', async () => { + h.setup() + const gate = h.deferred() + vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockReturnValue(gate.promise) + const pending = h.startWorker({ agent: 'zcode' }) + await vi.waitFor(() => + expect(h.runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith( + 'term_worker', + 'zcode', + 60_000 + ) + ) + expect(h.runtime.waitForTerminal).not.toHaveBeenCalled() + expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + gate.resolve() + await pending + expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce() + }) + + it('keeps reused terminals on the normal idle wait', async () => { + h.setup() + vi.spyOn(h.runtime, 'waitForFreshWorkerComposer') + await h.startWorker({ terminal: 'term_worker' }) + expect(h.runtime.waitForFreshWorkerComposer).not.toHaveBeenCalled() + expect(h.runtime.waitForTerminal).toHaveBeenCalledWith( + 'term_worker', + expect.objectContaining({ condition: 'tui-idle' }) + ) + }) + + it('never delivers a task after a startup timeout', async () => { + h.setup() + vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockRejectedValue(new Error('timeout')) + await expect(h.startWorker({ agent: 'zcode' })).rejects.toThrow('Expected worker-start') + expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts b/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts index d62f50be595..95369d5830f 100644 --- a/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts @@ -99,6 +99,7 @@ export const SESSION_TAB_MUTATION_METHODS = [ tabId: params.tabId, root: params.root, expandedLeafId: params.expandedLeafId ?? null, + chatLeafId: params.chatLeafId, titlesByLeafId: params.titlesByLeafId }) } diff --git a/src/main/runtime/rpc/methods/session-tabs-inventory.ts b/src/main/runtime/rpc/methods/session-tabs-inventory.ts index d63868844a6..3ab06a298fc 100644 --- a/src/main/runtime/rpc/methods/session-tabs-inventory.ts +++ b/src/main/runtime/rpc/methods/session-tabs-inventory.ts @@ -6,6 +6,7 @@ import { projectSessionTabAgentStatus } from './session-tab-agent-status-project import { projectSessionTabBrowserPlacements } from './session-tab-browser-placement-projection' import { createSessionTabsRetirementProofDelta } from './session-tabs-retirement-proof-delta' import { isStructuredNativeChatEnabled } from './structured-agent-session-policy' +import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore' type SessionTabsInventory = { snapshots: RuntimeMobileSessionTabsResult[] @@ -241,6 +242,14 @@ export async function subscribeSessionTabsInventory( } let collected: Awaited> | undefined try { + // Why: restore after registering, so an unsubscribe or socket close while it runs still finds the stream. + const restoring = restoreStructuredTabsIfSupported(context) + if (restoring) { + await restoring + if (closed) { + return + } + } for (let attempt = 1; !collected; attempt += 1) { censusInvalidated = false const candidate = await collectSessionTabsInventory( diff --git a/src/main/runtime/rpc/methods/session-tabs-subscribe-admission.test.ts b/src/main/runtime/rpc/methods/session-tabs-subscribe-admission.test.ts new file mode 100644 index 00000000000..2652cdf12fd --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tabs-subscribe-admission.test.ts @@ -0,0 +1,342 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { RuntimeSubscriptionRegistry } from '../../runtime-subscription-registry' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import { SESSION_TAB_METHODS } from './session-tabs' +import { visibleSnapshot } from './session-tabs-snapshot.test-fixture' + +const CONNECTION = 'conn-1' +const KEY = (requestId: string): string => `session.tabs:${CONNECTION}:wt-1:${requestId}` +const ALL_KEY = (requestId: string): string => `session.tabs:${CONNECTION}:*:${requestId}` + +type Deferred = { promise: Promise; resolve: (value: T) => void; reject: (e: Error) => void } + +function deferred(): Deferred { + let resolve!: (value: T) => void + let reject!: (e: Error) => void + const promise = new Promise((settle, fail) => { + resolve = settle + reject = fail + }) + return { promise, resolve, reject } +} + +function makeHost(options: { structuredChat?: boolean } = {}) { + const registry = new RuntimeSubscriptionRegistry() + const stopListening = vi.fn() + const tabListeners: ((snapshot: RuntimeMobileSessionTabsResult, sequence: number) => void)[] = [] + const restore = vi.fn(() => Promise.resolve()) + const listMobileSessionTabs = vi.fn(async (): Promise => + visibleSnapshot() + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the session tab methods reach only these members; a missing one throws and fails the test. + const runtime = { + getRuntimeId: () => 'test-runtime', + getClientSettings: () => ({ + experimentalStructuredNativeChat: options.structuredChat === true + }), + restoreStructuredAgentSessionTabs: restore, + listMobileSessionTabs, + supportsAuthoritativeSessionTabsInventory: () => false, + listAllMobileSessionTabsWithChangeSequence: async () => ({ + snapshots: [visibleSnapshot()], + changeSequence: 0 + }), + onMobileSessionTabsChanged: vi.fn( + (listener: (snapshot: RuntimeMobileSessionTabsResult, sequence: number) => void) => { + tabListeners.push(listener) + return stopListening + } + ), + registerSubscriptionCleanup: registry.register.bind(registry), + cleanupSubscription: registry.cleanup.bind(registry), + cleanupSubscriptionsByPrefix: registry.cleanupByPrefix.bind(registry), + cleanupSubscriptionsForConnection: registry.cleanupForConnection.bind(registry), + getSubscriptionRegistrationVersion: registry.getRegistrationVersion.bind(registry) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + return { + runtime, + registry, + restore, + listMobileSessionTabs, + stopListening, + tabListeners, + // A foreign connection never owns the entry, so this probe reads presence without releasing it. + isRegistered: (id: string): boolean => !registry.cleanupIfOwnedByConnection(id, 'probe'), + dispatch: ( + request: RpcRequest, + messages: Frame[] = [], + extra: { signal?: AbortSignal } = {} + ): Promise => + dispatcher.dispatchStreaming(request, (message) => messages.push(JSON.parse(message)), { + connectionId: CONNECTION, + clientKind: 'mobile', + ...extra + }) + } +} + +function request(id: string, method: string, params?: unknown): RpcRequest { + return { id, authToken: 'tok', method, params } +} + +type Frame = { ok: boolean; result?: { type?: string } } + +function frames(messages: Frame[]): (string | undefined)[] { + return messages.map((reply) => (reply.ok ? reply.result?.type : 'error')) +} + +async function settle(): Promise { + for (let index = 0; index < 10; index += 1) { + await Promise.resolve() + } +} + +describe('session.tabs.subscribe registers when the request arrives', () => { + it.each([ + { stage: 'restore', unsubscribe: { worktree: 'id:wt-1', subscriptionId: 'sub-1' } }, + { stage: 'list', unsubscribe: { worktree: 'id:wt-1', subscriptionId: 'sub-1' } }, + { stage: 'list', unsubscribe: { worktree: 'id:wt-1' } } + ])( + 'an unsubscribe ($unsubscribe) during the $stage await ends the stream and leaves nothing', + async ({ stage, unsubscribe }) => { + const host = makeHost({ structuredChat: true }) + const gate = deferred() + const listing = deferred() + if (stage === 'restore') { + host.restore.mockReturnValueOnce(gate.promise) + } else { + host.listMobileSessionTabs.mockReturnValueOnce(listing.promise) + } + const messages: Frame[] = [] + const pending = host.dispatch( + request('sub-1', 'session.tabs.subscribe', { worktree: 'id:wt-1' }), + messages + ) + + await host.dispatch(request('unsub-1', 'session.tabs.unsubscribe', unsubscribe)) + gate.resolve() + listing.resolve(visibleSnapshot()) + await pending + await settle() + + expect(host.isRegistered(KEY('sub-1'))).toBe(false) + expect(host.runtime.onMobileSessionTabsChanged).not.toHaveBeenCalled() + expect(frames(messages)).toEqual(['end']) + } + ) + + it('a socket close during setup leaves no registration and no listener', async () => { + const host = makeHost() + const listing = deferred() + host.listMobileSessionTabs.mockReturnValueOnce(listing.promise) + const messages: Frame[] = [] + const pending = host.dispatch( + request('sub-1', 'session.tabs.subscribe', { worktree: 'id:wt-1' }), + messages + ) + + host.runtime.cleanupSubscriptionsForConnection(CONNECTION) + listing.resolve(visibleSnapshot()) + await pending + await settle() + + expect(host.isRegistered(KEY('sub-1'))).toBe(false) + expect(host.runtime.onMobileSessionTabsChanged).not.toHaveBeenCalled() + expect(frames(messages)).toEqual(['end']) + }) + + it('a request abort during setup ends the stream and leaves nothing', async () => { + const host = makeHost() + const listing = deferred() + host.listMobileSessionTabs.mockReturnValueOnce(listing.promise) + const controller = new AbortController() + const messages: Frame[] = [] + const pending = host.dispatch( + request('sub-1', 'session.tabs.subscribe', { worktree: 'id:wt-1' }), + messages, + { signal: controller.signal } + ) + + controller.abort() + listing.resolve(visibleSnapshot()) + await pending + await settle() + + expect(host.isRegistered(KEY('sub-1'))).toBe(false) + expect(host.runtime.onMobileSessionTabsChanged).not.toHaveBeenCalled() + expect(frames(messages)).toEqual(['end']) + }) + + it('a request whose socket already closed attaches nothing', async () => { + const host = makeHost() + const controller = new AbortController() + controller.abort() + + await host.dispatch(request('sub-1', 'session.tabs.subscribe', { worktree: 'id:wt-1' }), [], { + signal: controller.signal + }) + await settle() + + expect(host.isRegistered(KEY('sub-1'))).toBe(false) + expect(host.runtime.onMobileSessionTabsChanged).not.toHaveBeenCalled() + }) + + it('a stream released during setup reports no error when the await then rejects', async () => { + const host = makeHost() + const listing = deferred() + host.listMobileSessionTabs.mockReturnValueOnce(listing.promise) + const messages: Frame[] = [] + const pending = host.dispatch( + request('sub-1', 'session.tabs.subscribe', { worktree: 'id:wt-1' }), + messages + ) + + await host.dispatch( + request('unsub-1', 'session.tabs.unsubscribe', { + worktree: 'id:wt-1', + subscriptionId: 'sub-1' + }) + ) + listing.reject(new Error('list failed')) + await pending + + expect(frames(messages)).toEqual(['end']) + }) + + it('a failed setup reports only the error and leaves no registration', async () => { + const host = makeHost() + host.listMobileSessionTabs.mockRejectedValueOnce(new Error('list failed')) + const messages: Frame[] = [] + + await host.dispatch( + request('sub-1', 'session.tabs.subscribe', { worktree: 'id:wt-1' }), + messages + ) + await settle() + + expect(host.isRegistered(KEY('sub-1'))).toBe(false) + expect(frames(messages)).toEqual(['error']) + }) + + it('streams snapshot and updates, then ends once on unsubscribe', async () => { + const host = makeHost() + const messages: Frame[] = [] + + await host.dispatch( + request('sub-1', 'session.tabs.subscribe', { worktree: 'id:wt-1' }), + messages + ) + expect(host.isRegistered(KEY('sub-1'))).toBe(true) + host.tabListeners[0]?.({ ...visibleSnapshot(), snapshotVersion: 2 }, 1) + await host.dispatch( + request('unsub-1', 'session.tabs.unsubscribe', { + worktree: 'id:wt-1', + subscriptionId: 'sub-1' + }) + ) + host.runtime.cleanupSubscriptionsForConnection(CONNECTION) + await settle() + + expect(frames(messages)).toEqual(['snapshot', 'updated', 'end']) + expect(host.stopListening).toHaveBeenCalledTimes(1) + expect(host.isRegistered(KEY('sub-1'))).toBe(false) + }) + + it('keys a non-id selector by the resolved worktree once it is known', async () => { + const host = makeHost() + const messages: Frame[] = [] + + await host.dispatch( + request('sub-1', 'session.tabs.subscribe', { worktree: 'path:/repo/wt-1' }), + messages + ) + + expect(host.isRegistered(KEY('sub-1'))).toBe(true) + expect(frames(messages)).toEqual(['snapshot']) + }) + + it('a worktree-wide unsubscribe spares a subscribe that arrives while it resolves', async () => { + const host = makeHost() + const first: Frame[] = [] + const second: Frame[] = [] + await host.dispatch(request('sub-1', 'session.tabs.subscribe', { worktree: 'id:wt-1' }), first) + const listing = deferred() + host.listMobileSessionTabs.mockReturnValueOnce(listing.promise) + + // Old phones send no request id, so the host sweeps every stream for the worktree. + const unsubscribing = host.dispatch( + request('unsub-1', 'session.tabs.unsubscribe', { worktree: 'id:wt-1' }) + ) + await settle() + await host.dispatch(request('sub-2', 'session.tabs.subscribe', { worktree: 'id:wt-1' }), second) + listing.resolve(visibleSnapshot()) + await unsubscribing + await settle() + + expect(frames(first)).toEqual(['snapshot', 'end']) + expect(frames(second)).toEqual(['snapshot']) + expect(host.isRegistered(KEY('sub-1'))).toBe(false) + expect(host.isRegistered(KEY('sub-2'))).toBe(true) + }) + + it('a request-id unsubscribe ends only its own stream', async () => { + const host = makeHost() + const first: Frame[] = [] + const second: Frame[] = [] + + await host.dispatch(request('sub-1', 'session.tabs.subscribe', { worktree: 'id:wt-1' }), first) + await host.dispatch(request('sub-2', 'session.tabs.subscribe', { worktree: 'id:wt-1' }), second) + await host.dispatch( + request('unsub-1', 'session.tabs.unsubscribe', { + worktree: 'id:wt-1', + subscriptionId: 'sub-1' + }) + ) + await settle() + + expect(frames(first)).toEqual(['snapshot', 'end']) + expect(frames(second)).toEqual(['snapshot']) + expect(host.isRegistered(KEY('sub-1'))).toBe(false) + expect(host.isRegistered(KEY('sub-2'))).toBe(true) + expect(host.stopListening).toHaveBeenCalledTimes(1) + }) +}) + +describe('session.tabs.subscribeAll registers before restoring structured tabs', () => { + it.each([ + { + release: 'unsubscribeAll', + act: (host: ReturnType) => + host.dispatch( + request('unsub-1', 'session.tabs.unsubscribeAll', { subscriptionId: 'sub-1' }) + ) + }, + { + release: 'socket close', + act: async (host: ReturnType) => + host.runtime.cleanupSubscriptionsForConnection(CONNECTION) + } + ])('a $release during the restore leaves no stream behind', async ({ act }) => { + const host = makeHost({ structuredChat: true }) + const gate = deferred() + host.restore.mockReturnValueOnce(gate.promise) + const messages: Frame[] = [] + const pending = host.dispatch(request('sub-1', 'session.tabs.subscribeAll'), messages) + + await act(host) + gate.resolve() + await pending + await settle() + + expect(host.isRegistered(ALL_KEY('sub-1'))).toBe(false) + expect(frames(messages)).not.toContain('snapshots') + expect( + vi.mocked(host.runtime.onMobileSessionTabsChanged).mock.calls.length - + host.stopListening.mock.calls.length + ).toBe(0) + }) +}) diff --git a/src/main/runtime/rpc/methods/session-tabs-unsubscribe.test.ts b/src/main/runtime/rpc/methods/session-tabs-unsubscribe.test.ts index efd353a7c91..021224bd649 100644 --- a/src/main/runtime/rpc/methods/session-tabs-unsubscribe.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs-unsubscribe.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../../orca-runtime' +import { RuntimeSubscriptionRegistry } from '../../runtime-subscription-registry' import { RpcDispatcher } from '../dispatcher' import { SESSION_TAB_METHODS } from './session-tabs' @@ -61,12 +62,63 @@ describe('session tab unsubscribe RPC methods', () => { expect(cleanupSubscription).toHaveBeenCalledWith('session.tabs:conn-1:*:sub-all-1') expect(cleanupSubscriptionsByPrefix).not.toHaveBeenCalled() }) + + it('ends only the named stream when a newer one watches the same worktree', async () => { + const { dispatcher, ends } = await subscribeTwiceToOneWorktree() + + await dispatcher.dispatchStreaming( + request('session.tabs.unsubscribe', { worktree: 'id:wt-1', subscriptionId: 'sub-old' }), + vi.fn(), + { connectionId: 'conn-1' } + ) + + await vi.waitFor(() => expect(ends['sub-old']).toBe(1)) + expect(ends['sub-new']).toBe(0) + }) + + it('ends every stream for the worktree when the unsubscribe names no request', async () => { + const { dispatcher, ends } = await subscribeTwiceToOneWorktree() + + await dispatcher.dispatchStreaming( + request('session.tabs.unsubscribe', { worktree: 'id:wt-1' }), + vi.fn(), + { connectionId: 'conn-1' } + ) + + await vi.waitFor(() => expect(ends).toEqual({ 'sub-old': 1, 'sub-new': 1 })) + }) }) +async function subscribeTwiceToOneWorktree() { + const registry = new RuntimeSubscriptionRegistry() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Partial runtime backed by the real subscription registry; it supplies every member session.tabs subscribe/unsubscribe call. + const runtime = { + ...runtimeWithCleanup(registry.cleanup.bind(registry), registry.cleanupByPrefix.bind(registry)), + registerSubscriptionCleanup: registry.register.bind(registry), + getSubscriptionRegistrationVersion: registry.getRegistrationVersion.bind(registry), + onMobileSessionTabsChanged: () => () => {} + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const ends: Record = { 'sub-old': 0, 'sub-new': 0 } + for (const id of ['sub-old', 'sub-new']) { + await dispatcher.dispatchStreaming( + { ...request('session.tabs.subscribe', { worktree: 'id:wt-1' }), id }, + (message) => { + if (JSON.parse(message).result?.type === 'end') { + ends[id]! += 1 + } + }, + { connectionId: 'conn-1' } + ) + } + return { dispatcher, ends } +} + function runtimeWithCleanup( - cleanupSubscription: ReturnType, - cleanupSubscriptionsByPrefix = vi.fn() + cleanupSubscription: (id: string) => void, + cleanupSubscriptionsByPrefix: (prefix: string, throughVersion?: number) => void = vi.fn() ): OrcaRuntimeService { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the unsubscribe methods reach only these members; a missing one throws and fails the test. return { getRuntimeId: () => 'test-runtime', listMobileSessionTabs: vi.fn().mockResolvedValue({ @@ -79,7 +131,8 @@ function runtimeWithCleanup( tabs: [] }), cleanupSubscription, - cleanupSubscriptionsByPrefix + cleanupSubscriptionsByPrefix, + getSubscriptionRegistrationVersion: () => 0 } as unknown as OrcaRuntimeService } diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 40e1525f148..8d3d1af33bf 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -1,4 +1,5 @@ import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' +import { getExplicitWorktreeIdSelector } from '../../runtime-worktree-selection' import { defineMethod, defineStreamingMethod } from '../core' import { CreateTerminalTab, @@ -97,78 +98,115 @@ export const SESSION_TAB_METHODS = [ params: WorktreeTabSelector, handler: async ( params, - { runtime, connectionId, requestId, pairedDeviceId, clientKind, clientCapabilities }, + { runtime, connectionId, requestId, pairedDeviceId, clientKind, clientCapabilities, signal }, emit ) => { - let subscribedWorktree: string | null = null - let unsubscribe = (): void => {} - let closed = false - let initialized = false - await restoreStructuredTabsIfSupported({ runtime, clientKind, clientCapabilities }) - const initial = await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId) - if (closed) { - return - } - subscribedWorktree = initial.worktree - const cleanupPrefix = `session.tabs:${connectionId ?? 'local'}:${subscribedWorktree}` - const subscriptionId = requestId ? `${cleanupPrefix}:${requestId}` : cleanupPrefix - // Why: shared-control can carry multiple subscribers for one worktree on - // one socket; include the RPC id so one subscriber cannot evict another. - runtime.registerSubscriptionCleanup( - subscriptionId, - () => { - closed = true - unsubscribe() - if (initialized) { - emit({ type: 'end' }) - } - }, - connectionId - ) - if (closed) { - return - } - const withProofDelta = createSessionTabsRetirementProofDelta(clientCapabilities) - emit({ - type: 'snapshot', - ...withProofDelta( - projectSessionTabsForClient( - initial, - clientKind, - clientCapabilities, - isStructuredNativeChatEnabled(runtime) - ) - ) - }) - initialized = true - if (closed) { - return - } - - unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { - if (snapshot.worktree === subscribedWorktree) { - emit({ - type: 'updated', - ...withProofDelta( - projectSessionTabsForClient( - snapshot, - clientKind, - clientCapabilities, - isStructuredNativeChatEnabled(runtime) - ) - ) - }) + let subscriptionId: string | null = null + let released = false + let endOnRelease = true + let stopListening = (): void => {} + // Safe without a version check: any other release of this key runs our cleanup first, which latches `released`. + const release = (): void => { + if (!released && subscriptionId) { + runtime.cleanupSubscription(subscriptionId) } - }, pairedDeviceId) - if (closed) { - unsubscribe() + } + const register = (worktreeId: string): void => { + const cleanupPrefix = `session.tabs:${connectionId ?? 'local'}:${worktreeId}` + // Why: shared-control can carry multiple subscribers for one worktree on + // one socket; include the RPC id so one subscriber cannot evict another. + subscriptionId = requestId ? `${cleanupPrefix}:${requestId}` : cleanupPrefix + runtime.registerSubscriptionCleanup( + subscriptionId, + () => { + if (released) { + return + } + released = true + signal?.removeEventListener('abort', release) + stopListening() + if (endOnRelease) { + emit({ type: 'end' }) + } + }, + connectionId + ) + if (signal?.aborted) { + release() + } else { + signal?.addEventListener('abort', release, { once: true }) + } + } + // Why: register before any await so an unsubscribe or socket close during setup + // finds the stream; only an `id:` selector (every phone and web client) names it up front. + const explicitWorktreeId = getExplicitWorktreeIdSelector(params.worktree) + if (explicitWorktreeId) { + register(explicitWorktreeId) + } + try { + await restoreStructuredTabsIfSupported({ runtime, clientKind, clientCapabilities }) + if (released) { + return + } + const initial = await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId) + if (released) { + return + } + if (!subscriptionId) { + register(initial.worktree) + if (released) { + return + } + } + const subscribedWorktree = initial.worktree + const withProofDelta = createSessionTabsRetirementProofDelta(clientCapabilities) + emit({ + type: 'snapshot', + ...withProofDelta( + projectSessionTabsForClient( + initial, + clientKind, + clientCapabilities, + isStructuredNativeChatEnabled(runtime) + ) + ) + }) + if (released) { + return + } + stopListening = runtime.onMobileSessionTabsChanged((snapshot) => { + if (snapshot.worktree === subscribedWorktree) { + emit({ + type: 'updated', + ...withProofDelta( + projectSessionTabsForClient( + snapshot, + clientKind, + clientCapabilities, + isStructuredNativeChatEnabled(runtime) + ) + ) + }) + } + }, pairedDeviceId) + } catch (error) { + // A stream already ended by its release must not also report an error. + if (released) { + return + } + endOnRelease = false + release() + throw error } } }), defineMethod({ name: 'session.tabs.unsubscribe', params: SessionTabsUnsubscribe, - handler: async (params, { runtime, connectionId, pairedDeviceId }) => { + handler: async ( + params, + { runtime, connectionId, pairedDeviceId, subscriptionRegistrationVersion } + ) => { const snapshot = await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId) const connection = connectionId ?? 'local' if (params.subscriptionId) { @@ -179,17 +217,18 @@ export const SESSION_TAB_METHODS = [ } runtime.cleanupSubscription(`session.tabs:${connection}:${params.worktree}`) runtime.cleanupSubscription(`session.tabs:${connection}:${snapshot.worktree}`) - runtime.cleanupSubscriptionsByPrefix(`session.tabs:${connection}:${snapshot.worktree}:`) + // Why: subscribes register on arrival, so spare any that arrived after this unsubscribe. + runtime.cleanupSubscriptionsByPrefix( + `session.tabs:${connection}:${snapshot.worktree}:`, + subscriptionRegistrationVersion + ) return { unsubscribed: true } } }), defineStreamingMethod({ name: 'session.tabs.subscribeAll', params: null, - handler: async (_params, context, emit) => { - await restoreStructuredTabsIfSupported(context) - return subscribeSessionTabsInventory(context, emit) - } + handler: (_params, context, emit) => subscribeSessionTabsInventory(context, emit) }), defineMethod({ name: 'session.tabs.unsubscribeAll', diff --git a/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts index de62b6b5b52..554c0912ff0 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts @@ -32,16 +32,16 @@ describe('admission revoked while a session is still open', () => { it.each(CLEANUP_METHODS)( 'still serves $method after the host setting is turned off', - async ({ method, params, hostCall }) => { - const response = await call(method, params, STRUCTURED_CLIENT, SETTING_OFF) + async (entry) => { + const response = await call(entry.method, entry.params, STRUCTURED_CLIENT, SETTING_OFF) expect(response).toMatchObject({ ok: true }) - // `unsubscribe` retires runtime-owned subscriptions rather than calling the host, so its - // result payload is the observable effect. - if (hostCall === 'unsubscribe') { - expect(response).toMatchObject({ result: { unsubscribed: true } }) + // `unsubscribe` retires runtime-owned subscriptions and `release` is a no-op, so neither + // calls the host: the result payload is the observable effect. + if (entry.hostCall === null) { + expect(response).toMatchObject({ result: entry.result }) } else { - expect(hostCalls[hostCall]).toHaveBeenCalled() + expect(hostCalls[entry.hostCall]).toHaveBeenCalled() } } ) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts new file mode 100644 index 00000000000..63d24030c70 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -0,0 +1,506 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../../shared/agent-session-journal-types' +// A chat at rest, through the RPC surface a client actually calls: opening it starts nothing, what +// it can answer without an agent it answers, and the first send is what starts one. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { activeStructuredAgentSessionTurnId } from '../../../../shared/structured-agent-session-projection' +import { openJournalDatabase } from '../../../native-chat/agent-session-journal/journal-database' +import { + journalDatabaseFile, + journalDirectoryFor +} from '../../../native-chat/agent-session-journal/journal-paths' +import { + HOST_TEST_LOCATION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId +} from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { + createRestTestRig, + foundRestTestChat, + REST_TEST_CALLER as CALLER, + REST_TEST_SESSION as SESSION, + REST_TEST_THREAD, + restTestSend, + type RestTestRig +} from '../../../native-chat/agent-session-wire/structured-agent-session-rest-test-rig' +import * as providerSupport from '../../../native-chat/agent-session-wire/structured-agent-session-provider-support' +import { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcResponse } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { closeStructuredAgentSessionChild } from '../../structured-agent-session-close' +import { discardStructuredWorkerSession } from './orchestration-structured-worker-session' +import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' + +const CLIENT = { + clientId: 'device-1', + clientKind: 'runtime' as const, + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + connectionId: 'connection-1' +} + +let rig: RestTestRig +let dispatcher: RpcDispatcher +let requests = 0 + +async function call(method: string, params: unknown): Promise { + const replies: RpcResponse[] = [] + requests += 1 + await dispatcher.dispatchStreaming( + { id: `request-${requests}`, authToken: 'token', method, params }, + (raw) => replies.push(JSON.parse(raw) as RpcResponse), + CLIENT + ) + return replies +} + +/** A chat that once ran, reopened by a fresh host: nothing of it is in memory. */ +async function restingChat(): Promise { + await foundRestTestChat(rig) + await rig.store.replaceSessionOptions({ + sessionId: SESSION, + fence: rig.store.getRecord(SESSION)!.lease.runtimeFence, + options: { model: 'gpt-saved', effort: 'high' }, + now: rig.clock.now + }) + await rig.restart() + setStructuredAgentSessionHost(rig.host) + rig.adapter.acquire.mockClear() + rig.adapter.dispatch.mockClear() + rig.adapter.readOptions.mockClear() +} + +beforeEach(async () => { + requests = 0 + rig = await createRestTestRig({ idleSweep: { intervalMs: 3_600_000 } }) + setStructuredAgentSessionHost(rig.host) + const runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'getClientSettings').mockImplementation( + () => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the RPC gate reads only this one setting. + ({ experimentalStructuredNativeChat: true }) as ReturnType< + OrcaRuntimeService['getClientSettings'] + > + ) + dispatcher = new RpcDispatcher({ runtime, methods: STRUCTURED_AGENT_SESSION_METHODS }) +}) + +afterEach(async () => { + vi.restoreAllMocks() + setStructuredAgentSessionHost(null) + await rig.dispose() +}) + +describe('opening a chat at rest (P2-01)', () => { + it('reads, subscribes and answers everything without starting an agent', async () => { + await restingChat() + expect(rig.host.hasSession(SESSION)).toBe(false) + + const [history] = await call('agentSession.history', { sessionId: SESSION, direction: 'tail' }) + const frames = await call('agentSession.subscribe', { sessionId: SESSION }) + const [options] = await call('agentSession.options', { sessionId: SESSION }) + const [commands] = await call('agentSession.commands', { sessionId: SESSION }) + const [outline] = await call('agentSession.conversationOutline', { sessionId: SESSION }) + const [status] = await call('agentSession.handoffStatus', { sessionId: SESSION }) + const [held] = await call('agentSession.hold', { sessionId: SESSION, holderId: 'pane' }) + + expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(history).toMatchObject({ ok: true, result: { ok: true } }) + const snapshot = frames.find((frame) => frame.ok && 'result' in frame) + expect(snapshot).toMatchObject({ result: { type: 'snapshot', commands: null } }) + expect(options).toMatchObject({ + ok: true, + result: { current: { model: 'gpt-saved', effort: 'high' } } + }) + // Absent, never an empty list: the composer keeps its own menu. + expect(commands).toMatchObject({ ok: true, result: {} }) + expect(commands?.ok && 'result' in commands && commands.result).not.toHaveProperty( + 'commands', + [] + ) + expect(outline).toMatchObject({ ok: true }) + expect(status).toMatchObject({ ok: true }) + expect(held).toMatchObject({ ok: true, result: { held: true } }) + }) + + // Worktree activation asks this for every chat tab in the worktree. + it('answers the owner check from the record without opening the chat', async () => { + await restingChat() + + const [status] = await call('agentSession.handoffStatus', { sessionId: SESSION }) + + expect(status).toMatchObject({ ok: true, result: { owner: expect.any(String) } }) + expect(rig.host.hasSession(SESSION)).toBe(false) + }) + + it('starts the agent on the first send (P2-01)', async () => { + await restingChat() + const fence = rig.store.getRecord(SESSION)!.lease.runtimeFence + const sent = await rig.host.send(CALLER, restTestSend('wake up', fence)) + if (!sent.ok) { + throw new Error(`send refused: ${sent.refusal.code}`) + } + // Awaits the provider's answer itself rather than polling for it, however slow the start. + await rig.host.waitForSendSettlement(SESSION, sent.value.clientMessageId) + expect(rig.adapter.acquire).toHaveBeenCalledOnce() + expect(rig.adapter.dispatch).toHaveBeenCalledOnce() + }) +}) + +describe('the accessor', () => { + it('opens a closed conversation once, however many readers arrive together (P2-02)', async () => { + // Written by the provider alone, so nothing but the readers below ever opens it here. + const attached = await rig.host.attach(CALLER, hostTestAttachParams(null)) + expect(attached.ok).toBe(true) + rig.adapter.acquire.mock.calls + .at(-1)?.[0] + .events?.appendItem( + { provider: 'codex', threadId: REST_TEST_THREAD, turnId: 'turn-1', ordinal: 1 }, + hostTestMessage('from the provider'), + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await rig.host.flushStreamedEvents(SESSION) + await rig.restart() + setStructuredAgentSessionHost(rig.host) + rig.adapter.acquire.mockClear() + const open = vi.spyOn(rig.host.collaboratorsForTests().conversationDelivery, 'open') + await Promise.all([ + ...Array.from({ length: 5 }, () => + call('agentSession.history', { sessionId: SESSION, direction: 'tail' }) + ), + call('agentSession.subscribe', { sessionId: SESSION }) + ]) + const openedJournals = new Set( + await Promise.all(open.mock.results.map((result) => result.value)) + ) + expect(openedJournals.size).toBe(1) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + }) + + it('opens nothing once quit began, for a read that was already waiting on the lock', async () => { + await restingChat() + // The host's per-session queue, held so the read waits behind it. + const { serialize, lifetime } = rig.host.collaboratorsForTests() + let release = (): void => undefined + const held = new Promise((started) => { + void serialize(SESSION, () => { + started() + return new Promise((resolve) => (release = resolve)) + }) + }) + await held + const read = rig.host.history({ sessionId: SESSION, direction: 'tail' }) + + // Disposed as quit's first teardown step does. + lifetime.dispose() + release() + + await expect(read).rejects.toThrow() + expect(rig.host.hasSession(SESSION)).toBe(false) + }) + + it('refuses a read it cannot open with the reason, under the same code and message', async () => { + const [missing] = await call('agentSession.history', { + sessionId: 'session-never-created', + direction: 'tail' + }) + expect(missing).toMatchObject({ + ok: false, + error: { + code: 'agent_session_identity_required', + message: 'agent_session_identity_required', + data: { + refusal: { + code: 'agent_session_identity_required', + details: { reason: 'recordMissing' } + } + } + } + }) + + await restingChat() + vi.spyOn(providerSupport, 'adapterSupportsRecord').mockReturnValue(false) + const [unsupported] = await call('agentSession.history', { + sessionId: SESSION, + direction: 'tail' + }) + expect(unsupported).toMatchObject({ + ok: false, + error: { + // Not a passthrough code: released clients match the message, as before. + code: 'runtime_error', + message: 'structured_agent_session_unsupported', + data: { refusal: { details: { reason: 'hostUnsupported' } } } + } + }) + }) + + it('refuses a read whose journal will not open with the classified reason, never the storage text', async () => { + await restingChat() + const open = vi.spyOn(rig.host.collaboratorsForTests().conversationDelivery, 'open') + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const storagePath = '/Users/someone/.orca/journals/session-1/journal.sqlite' + const failWith = (error: Error): void => { + open.mockRejectedValue(error) + } + const failures = async (): Promise => + [ + ...(await call('agentSession.history', { sessionId: SESSION, direction: 'tail' })), + ...(await call('agentSession.subscribe', { sessionId: SESSION })), + ...(await call('agentSession.options', { sessionId: SESSION })) + ].filter((reply) => !reply.ok) + + failWith( + Object.assign(new Error(`file is not a database: ${storagePath}`), { + code: 'ERR_SQLITE_ERROR', + errcode: 26 + }) + ) + const corrupt = await failures() + failWith(Object.assign(new Error(`EACCES: permission denied, open '${storagePath}'`), {})) + const unavailable = await failures() + + for (const [replies, reason] of [ + [corrupt, 'journalCorrupt'], + [unavailable, 'journalUnavailable'] + ] as const) { + expect(replies).toHaveLength(3) + for (const reply of replies) { + expect(reply).toMatchObject({ + ok: false, + error: { + // Not a passthrough code: released clients read the message, which stays the code. + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { refusal: { code: 'agent_session_journal_unreadable', details: { reason } } } + } + }) + expect(JSON.stringify(reply)).not.toContain(storagePath) + } + } + }) + + it('logs a reader reconnecting to a journal that will not open once per failure', async () => { + await restingChat() + const open = vi.spyOn(rig.host.collaboratorsForTests().conversationDelivery, 'open') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const logged = (): unknown[] => + warn.mock.calls + .filter(([line]) => line === '[agent-session] opening the conversation for a read failed:') + .map(([, error]) => error) + const reconnect = async (): Promise => [ + ...(await call('agentSession.subscribe', { sessionId: SESSION })), + ...(await call('agentSession.history', { sessionId: SESSION, direction: 'tail' })) + ] + const denied = new Error('EACCES: permission denied') + const exhausted = new Error('EMFILE: too many open files') + + open.mockRejectedValue(denied) + for (let attempt = 0; attempt < 4; attempt += 1) { + // Every attempt is still refused with its reason; only the log is quiet. + expect((await reconnect()).filter((reply) => !reply.ok)).toHaveLength(2) + } + expect(logged()).toEqual([denied]) + open.mockRejectedValue(exhausted) + await reconnect() + await reconnect() + expect(logged()).toEqual([denied, exhausted]) + }) + + it('opens a corrupt journal through the recovering open and still accepts a send (P2-03)', async () => { + await foundRestTestChat(rig) + await rig.host.flushAllStreamedEvents() + const directory = journalDirectoryFor(rig.root, { + workspaceId: HOST_TEST_LOCATION.workspaceId, + sessionId: SESSION + }) + // A row that no longer parses: the recovering open keeps the readable prefix and rebuilds. + const opened = openJournalDatabase(journalDatabaseFile(directory)) + try { + opened.db.prepare('UPDATE journal_rows SET row_json = ? WHERE seq = ?').run('}{', 2) + } finally { + opened.db.close() + } + await rig.restart() + setStructuredAgentSessionHost(rig.host) + + const frames = await call('agentSession.subscribe', { sessionId: SESSION }) + expect(frames.some((frame) => !frame.ok)).toBe(false) + expect(frames.find((frame) => frame.ok)).toMatchObject({ result: { type: 'snapshot' } }) + const fence = rig.store.getRecord(SESSION)!.lease.runtimeFence + expect((await rig.host.send(CALLER, restTestSend('after the repair', fence))).ok).toBe(true) + }) + + it('subscribes an old mobile client that holds first even when no agent can start (P2-06)', async () => { + await restingChat() + rig.adapter.acquire.mockRejectedValue(new Error('auth expired')) + + const [held] = await call('agentSession.hold', { sessionId: SESSION, holderId: 'mobile' }) + const frames = await call('agentSession.subscribe', { sessionId: SESSION }) + expect(held).toMatchObject({ ok: true }) + expect(frames.find((frame) => frame.ok)).toMatchObject({ result: { type: 'snapshot' } }) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + }) +}) + +describe('options at rest', () => { + it('records a pick as intent and replays it at the next start (P2-16)', async () => { + await restingChat() + const fields = { key: 'model', value: 'gpt-picked' } + const [picked] = await call('agentSession.setOption', { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: rig.store.getRecord(SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.setOption', + sessionId: SESSION, + fields + }) + }, + ...fields + }) + expect(picked).toMatchObject({ ok: true, result: { ok: true } }) + expect(rig.store.getRecord(SESSION)?.options).toMatchObject({ model: 'gpt-picked' }) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + + const fence = rig.store.getRecord(SESSION)!.lease.runtimeFence + await rig.host.send(CALLER, restTestSend('use the new model', fence)) + await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledOnce()) + expect(rig.adapter.acquire.mock.calls[0]?.[0]).toMatchObject({ + options: expect.objectContaining({ model: 'gpt-picked' }) + }) + }) + + it('answers the provider-level features of a chat at rest (P2-17)', async () => { + await restingChat() + Object.assign(rig.host.deps.adapter, { + supportsThreadGoal: (_id: string, agent?: string) => agent === 'codex', + recordsContextUsage: (_id: string, agent?: string) => agent === 'claude', + rewindSupport: (_id: string, agent?: string) => + agent === 'codex' ? { supported: true } : { supported: false, reason: 'unsupported' } + }) + const [options] = await call('agentSession.options', { sessionId: SESSION }) + expect(options).toMatchObject({ + ok: true, + result: { threadGoal: { current: null }, rewind: { supported: true } } + }) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.readOptions).not.toHaveBeenCalled() + }) +}) + +describe('an agent exit', () => { + it('is shown, not respawned; the next send starts the agent (P2-18)', async () => { + await foundRestTestChat(rig) + const running = rig.host.collaboratorsForTests().sessions.get(SESSION)!.child! + await rig.host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + reason: 'killed', + cause: 'unexpected-exit', + fence: running.fence, + acquisitionGeneration: running.generation! + }) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(rig.adapter.acquire).toHaveBeenCalledOnce() + + const fence = rig.store.getRecord(SESSION)!.lease.runtimeFence + expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + await rig.host.send(CALLER, restTestSend('again', fence)) + await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledTimes(2)) + }) + + it('whose settlement write failed is settled by the next send, which is delivered', async () => { + await foundRestTestChat(rig) + const open = rig.host.collaboratorsForTests().sessions.get(SESSION)! + const running = open.child! + // A turn in flight, so the exit has something to settle. + rig.adapter.acquire.mock.calls + .at(-1)?.[0] + .events?.appendItem( + { provider: 'codex', threadId: REST_TEST_THREAD, turnId: 'working', ordinal: 50 }, + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await rig.host.flushStreamedEvents(SESSION) + // The exit's own write and the retry recording the exit queues are both refused. + vi.spyOn(open.journal, 'appendLifecycleBatch') + .mockRejectedValueOnce(new Error('disk full')) + .mockRejectedValueOnce(new Error('disk full')) + await rig.host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + reason: 'killed', + cause: 'unexpected-exit', + fence: running.fence, + acquisitionGeneration: running.generation! + }) + // Released with its death evidence, not latched: the next acquire settles from that evidence. + await vi.waitFor(() => + expect(rig.store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + deathEvidence: { kind: 'exit-observed' } + }) + ) + await rig.host.collaboratorsForTests().serialize(SESSION, async () => {}) + + const sent = await rig.host.send( + CALLER, + restTestSend('again', rig.store.getRecord(SESSION)!.lease.runtimeFence) + ) + + if (!sent.ok) { + throw new Error(`send refused: ${sent.refusal.code}`) + } + await rig.host.waitForSendSettlement(SESSION, sent.value.clientMessageId) + expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2) + expect( + activeStructuredAgentSessionTurnId((await rig.host.journalSnapshot(SESSION)).items) + ).not.toBe('working') + }) +}) + +describe('every close withdraws what is queued (P2-29)', () => { + it.each([ + ['the close RPC', async () => void (await call('agentSession.close', { sessionId: SESSION }))], + ['the runtime chat close', async () => void (await closeStructuredAgentSessionChild(SESSION))], + [ + 'a discarded worker', + () => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the discard reads only this runtime member. + discardStructuredWorkerSession(SESSION, { + retireStructuredAgentSessionTabFromSnapshot: () => undefined + } as never) + ] + ])('%s rejects a message accepted before any start, and starts nothing', async (_, close) => { + await restingChat() + // The delivery loop has not reached its first start yet. + const { loop } = rig.host.collaboratorsForTests().conversationDelivery + vi.spyOn(loop, 'wake').mockImplementation(() => undefined) + const reader: unknown[] = [] + await rig.host.subscribe({ + id: 'reader', + sessionId: SESSION, + emit: (event) => reader.push(event) + }) + const fence = rig.store.getRecord(SESSION)!.lease.runtimeFence + expect((await rig.host.send(CALLER, restTestSend('closed before it went', fence))).ok).toBe( + true + ) + + await close() + // A close's rejection is a sentence with its fact beside it, never a marker. + await vi.waitFor(() => + expect(JSON.stringify(reader)).toContain( + '"reason":"The chat closed before this message was sent.","submittedAt"' + ) + ) + expect(JSON.stringify(reader)).toContain('"rejection":{"kind":"chatClosed"}') + expect(rig.host.hasSession(SESSION)).toBe(false) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts index 2b62b00525e..8a9e2267a5a 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionBackgroundTaskState } from '../../../../shared/agent-session-wire' import { AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, @@ -63,7 +63,6 @@ describe('background-task stop capability at the RPC boundary', () => { it.each(['snapshot', 'batch', 'reset'] as const)( 'gates the %s stream without changing the provider state', async (type) => { - hostCalls.hold = vi.fn(async () => undefined) hostCalls.subscribe.mockImplementation((input: AgentSessionSubscribeInput) => { const base = { sessionId: SESSION, fence: 1, backgroundTasks: TASKS } if (type === 'batch') { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.ts b/src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.ts index 99bf60da727..08ecc212ec1 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.ts @@ -7,7 +7,7 @@ import { readAgentSessionConversationOutline } from '../../../native-chat/agent-session-wire/agent-session-conversation-outline' import { defineMethod } from '../core' -import { requireStructuredHost as requireHost } from './structured-agent-session-gate' +import { requireInstalledStructuredHost } from './structured-agent-session-gate' import { OptionsParams } from './structured-agent-session-schemas' export const STRUCTURED_AGENT_SESSION_CONVERSATION_OUTLINE_METHODS = [ @@ -15,6 +15,8 @@ export const STRUCTURED_AGENT_SESSION_CONVERSATION_OUTLINE_METHODS = [ name: 'agentSession.conversationOutline', params: OptionsParams, handler: async (params, ctx) => - readAgentSessionConversationOutline(requireHost(ctx).journalSnapshot(params.sessionId)) + readAgentSessionConversationOutline( + await (await requireInstalledStructuredHost(ctx)).journalSnapshot(params.sessionId) + ) }) ] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index ab52c2aa855..0c3b7b08713 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -12,6 +12,7 @@ * in. Both callers run the same two halves, so orchestration gets that guarantee too. */ +import { refuse } from '../../../../shared/agent-session-wire-refusals' import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' import type { AgentSessionAttachResult, @@ -43,7 +44,7 @@ export type PreparedStructuredAgentSessionCreate = { * intent, not a detail of it: without it a retry of "adopt this conversation" would replay as, or * conflict with, a blank create. `tabId` is covered so the declared digest spans the payload, but * replay keys on the attach fingerprint, so a retry naming another tab is answered with the one the - * record holds. The canonicalizer drops `undefined`, so plain creates keep the digest they had. + * chat's tab holds. The canonicalizer drops `undefined`, so plain creates keep the digest they had. */ export function structuredAgentSessionCreateIntentFingerprint(params: { envelope: AgentSessionMutationEnvelope @@ -79,8 +80,8 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: { * `--model`/`--effort` the dispatch asked for; a chat the user opened passes nothing and keeps * the saved selection. Narrowed by the caller, so `{}` never reaches the reservation. */ options?: Readonly> - /** The tab id the caller reserved for this chat, so its placement is recorded before the reply; - * absent records the id clients derive. Beside `options`, after the fingerprint, likewise. */ + /** The tab id the caller reserved for this chat, taken when its tab is published; absent, the tab + * gets the id clients derive. Beside `options`, after the fingerprint, likewise. */ tabId?: string }): Promise { // Adoption replay may need the record loaded from disk before source discovery can be skipped. @@ -131,24 +132,29 @@ export async function commitStructuredAgentSessionCreate(args: { if (!result.ok || !prepared.tab) { return result } + const surfaceTabId = prepared.attachParams.surfaceTabId try { await args.runtime.publishStructuredAgentSessionTab({ workspaceId: prepared.tab.workspaceId, sessionId: result.value.sessionId, agent: prepared.tab.agent, - activate: args.activate + activate: args.activate, + ...(surfaceTabId ? { tabId: surfaceTabId } : {}) }) } catch (error) { console.warn('[agent-session] create committed before tab publication failed', error) return { ok: false, - refusal: { - code: 'agent_session_operation_unknown', - message: 'The chat may have been created, but its tab could not be confirmed.' - } + refusal: refuse( + 'agent_session_operation_unknown', + { reason: 'tabUnconfirmed' }, + 'The chat may have been created, but its tab could not be confirmed.' + ) } } - return result + // Read after publishing, which is what gives the chat its tab. + const tabId = prepared.host.getSessionTabId?.(result.value.sessionId) + return tabId ? { ...result, value: { ...result.value, tabId } } : result } export async function createStructuredAgentSessionForWorktree(args: { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts index 72c52c888fd..a2876f284db 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts @@ -21,15 +21,18 @@ export const CLEANUP_METHODS = [ params: { envelope: envelope(), turnId: 'turn-1' }, hostCall: 'cancel' }, + // A no-op kept for older clients: it answers without reaching the host. { method: 'agentSession.release', params: { sessionId: SESSION, holderId: 'surface-1' }, - hostCall: 'release' + hostCall: null, + result: { released: true } }, { method: 'agentSession.unsubscribe', params: { sessionId: SESSION }, - hostCall: 'unsubscribe' + hostCall: null, + result: { unsubscribed: true } } ] as const @@ -53,6 +56,15 @@ export const ADMISSION_METHODS = [ }, { method: 'agentSession.ensure', params: attachParams() }, { method: 'agentSession.send', params: sendParams() }, + { + method: 'agentSession.queuedMessageSend', + params: { envelope: envelope(), messageId: 'queued-1' } + }, + { + method: 'agentSession.queuedMessageDelete', + params: { envelope: envelope(), messageId: 'queued-1' } + }, + { method: 'agentSession.queuedMessagesResume', params: { envelope: envelope() } }, { method: 'agentSession.rewind', params: { envelope: envelope(), itemId: 'chosen', expectedEpoch: 'epoch' } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts index 95e3e3d6eee..e4ff861d1ac 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts @@ -8,6 +8,7 @@ // for old mobile clients while structured chat is enabled so they receive a fallback row, and that // path constructs the host. `agentSession.*` stays refused either way, which is what this gate is for. +import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' @@ -27,7 +28,9 @@ export function supportsStructuredSessions(ctx: RpcContext): boolean { export function requireStructuredCapability(ctx: RpcContext): void { if (!supportsStructuredSessions(ctx)) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'clientCapabilityMissing' + }) } } @@ -35,7 +38,9 @@ export function requireStructuredHost(ctx: RpcContext): StructuredAgentSessionHo requireStructuredCapability(ctx) const host = getStructuredAgentSessionHost() if (!host) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostDisabled' + }) } return host } @@ -64,19 +69,22 @@ export function requireStructuredHost(ctx: RpcContext): StructuredAgentSessionHo */ export function requireStructuredCleanupHost(ctx: RpcContext): StructuredAgentSessionHost { if (!supportsStructuredAgentSessionCapability(ctx)) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'clientCapabilityMissing' + }) } const host = getStructuredAgentSessionHost() if (!host) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostDisabled' + }) } return host } -/** Builds the host for the calls that address a session by durable record rather than by live - * state: attach, which is the only way a session comes into being, plus hold and reveal, which - * each reach for a record on disk this process may not have opened yet. Every other method - * addresses a session that must already be attached, and correctly reports absent when none is. */ +/** Builds the host for a call that may be the first this process sees. Every session is addressed + * by its durable record — a read opens a conversation at rest — so each call that reaches for one + * may meet a host nothing has built yet. */ export async function ensureStructuredHostInstalled(ctx: RpcContext): Promise { // Gated first: a client that cannot read structured sessions must not be able // to make the host exist, which is an observable side effect of the surface. @@ -89,6 +97,14 @@ export async function ensureStructuredHostInstalled(ctx: RpcContext): Promise { + await ensureStructuredHostInstalled(ctx) + return requireStructuredHost(ctx) +} + /** Mirrors the existing agent-session host-authority derivation so one client * gets one operation namespace across both surfaces. */ export function structuredCallerFor(ctx: RpcContext): StructuredAgentSessionCaller { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts index c3099d843ce..140be5e1c39 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts @@ -1,8 +1,8 @@ -// The wire half of a session's lifetime: who takes a hold, and what happens when they vanish. +// `agentSession.hold` / `release` are kept answering for clients that still send them, and do +// nothing else: a view never starts or keeps an agent. // -// Run against the REAL subscription registry rather than a stub, because the backstop being tested -// IS that registry's connection sweep — a stubbed `registerSubscriptionCleanup` would prove that -// the handler called a function, which is not the claim. +// Run against the REAL subscription registry rather than a stub, because the claim includes that a +// connection closing runs nothing hold-related — a stubbed registry could not show that. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -24,9 +24,10 @@ import { OrcaRuntimeService } from '../../orca-runtime' import type { RpcResponse } from '../core' import { RpcDispatcher } from '../dispatcher' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' +import { agentSessionFailureFact } from '../../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' const CONNECTION = 'connection-1' -const GRACE_MS = 5 const CLIENT = { clientId: 'device-1', clientKind: 'runtime' as const, @@ -82,7 +83,12 @@ beforeEach(async () => { // acquire that throws leaves an unverifiable owner nothing may replace. releaseAcquisition: vi.fn(async () => true), closeSession, - dispatch: async () => ({ state: 'rejected', reason: 'unused' }), + dispatch: async () => ({ + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { + surface: 'rejection' + }) + }), cancelTurn: async () => ({ cancelled: false }), answerPrompt: async () => undefined, setOption: async () => undefined @@ -90,7 +96,6 @@ beforeEach(async () => { journalRoot: root, claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', - releaseGraceMs: GRACE_MS, now: () => NOW }) setStructuredAgentSessionHost(host) @@ -114,186 +119,85 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -describe('a client that holds a session', () => { - it('keeps the provider child while the hold stands', async () => { +describe('the hold surface, for clients that still call it', () => { + it('answers a hold without starting an agent or registering a cleanup', async () => { + await host.close(SESSION) + expect(host.hasSession(SESSION)).toBe(false) + const registered = vi.spyOn(runtime, 'registerOwnedSubscriptionCleanup') + const acquiresBefore = acquire.mock.calls.length + expect( await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) - ).toMatchObject({ ok: true }) + ).toMatchObject({ ok: true, result: { held: true } }) - await new Promise((resolve) => setTimeout(resolve, GRACE_MS * 20)) + expect(acquire.mock.calls.length).toBe(acquiresBefore) + expect(registered).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + }) + + it('answers a release without stopping anything, and a connection close runs nothing', async () => { + await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) + + expect( + await call('agentSession.release', { sessionId: SESSION, holderId: 'chat-1' }) + ).toMatchObject({ ok: true, result: { released: true } }) + runtime.cleanupSubscriptionsForConnection(CONNECTION) + await new Promise((resolve) => setTimeout(resolve, 20)) expect(closeSession).not.toHaveBeenCalled() expect(host.hasSession(SESSION)).toBe(true) }) - it('releases it when the client says so', async () => { - await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) - - expect( - await call('agentSession.release', { sessionId: SESSION, holderId: 'chat-1' }) - ).toMatchObject({ ok: true }) - - await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) - expect(closeSession).toHaveBeenCalledWith(SESSION) - }) - - it('releases its hold and cleanup after the setting is disabled', async () => { - const release = vi.spyOn(host, 'release') - await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) + it('refuses a hold once the setting is off, and still answers a release', async () => { structuredNativeChatEnabled = false + expect( + await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) + ).toMatchObject({ ok: false }) expect( await call('agentSession.release', { sessionId: SESSION, holderId: 'chat-1' }) - ).toMatchObject({ ok: true }) - const releaseCallsAfterRpc = release.mock.calls.length - runtime.cleanupSubscriptionsForConnection(CONNECTION) - - expect(releaseCallsAfterRpc).toBe(2) - expect(release).toHaveBeenCalledTimes(releaseCallsAfterRpc) - await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) - expect(closeSession).toHaveBeenCalledWith(SESSION) + ).toMatchObject({ ok: true, result: { released: true } }) + expect(closeSession).not.toHaveBeenCalled() }) - it('does not report success when no provider child can be acquired', async () => { - const response = await call('agentSession.hold', { - sessionId: 'session-missing', - holderId: 'chat-missing' - }) + it('answers a hold even when no agent could be started', async () => { + await host.close(SESSION) + acquire.mockRejectedValue(new Error('provider unavailable')) + const acquiresBefore = acquire.mock.calls.length - expect(response).toMatchObject({ - ok: false, - error: { code: 'agent_session_identity_required' } - }) - expect(host.isHeld('session-missing')).toBe(false) + expect( + await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) + ).toMatchObject({ ok: true, result: { held: true } }) + expect(acquire.mock.calls.length).toBe(acquiresBefore) }) }) -describe('a client that disappears without cleanup', () => { - it('shares one child with a same-ID replacement that arrives while the first hold resumes', async () => { +describe('a stream', () => { + it('reads a closed conversation without starting its agent', async () => { await host.close(SESSION) - await host.restoreReadableSessions() - closeSession.mockClear() - acquire.mockClear() - const entered = Promise.withResolvers() - const gate = Promise.withResolvers() - const spawnChild = acquire.getMockImplementation()! - acquire.mockImplementationOnce(async (input) => { - entered.resolve() - await gate.promise - return spawnChild(input) - }) - try { - const params = { sessionId: SESSION, holderId: 'same-chat' } - const first = call('agentSession.hold', params) - await entered.promise - // Re-registering the cleanup id released the first hold; the replacement waits its turn - // behind the first hold's attach and finds the child it made. - const replacement = call('agentSession.hold', params) - gate.resolve() + expect(host.hasSession(SESSION)).toBe(false) + const acquiresBefore = acquire.mock.calls.length + const frames: unknown[] = [] - expect(await first).toMatchObject({ ok: true }) - expect(await replacement).toMatchObject({ ok: true }) - expect(acquire).toHaveBeenCalledOnce() - expect(host.isHeld(SESSION)).toBe(true) - await new Promise((resolve) => setTimeout(resolve, GRACE_MS * 4)) - expect(closeSession).not.toHaveBeenCalled() - - runtime.cleanupSubscriptionsForConnection(CONNECTION) - await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) - expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION) - } finally { - gate.resolve() - } - }) - - it('lets a same-ID replacement make its own attempt when the first hold fails to acquire', async () => { - await host.close(SESSION) - await host.restoreReadableSessions() - closeSession.mockClear() - acquire.mockClear() - const entered = Promise.withResolvers() - const gate = Promise.withResolvers() - acquire.mockImplementationOnce(async () => { - entered.resolve() - await gate.promise - throw new Error('acquisition failed') - }) - try { - const params = { sessionId: SESSION, holderId: 'same-chat' } - const first = call('agentSession.hold', params) - await entered.promise - const replacement = call('agentSession.hold', params) - gate.resolve() - - expect(await first).toMatchObject({ ok: false }) - // One attempt per hold: the replacement's own succeeds, and the holder it re-took stands. - const replaced = await replacement - expect(replaced, JSON.stringify(replaced)).toMatchObject({ ok: true }) - expect(acquire).toHaveBeenCalledTimes(2) - expect(host.isHeld(SESSION)).toBe(true) - expect(closeSession).not.toHaveBeenCalled() - - runtime.cleanupSubscriptionsForConnection(CONNECTION) - await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) - expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION) - } finally { - gate.resolve() - } - }) - - it('still releases the session when its transport closes', async () => { - await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) - - runtime.cleanupSubscriptionsForConnection(CONNECTION) - - await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) - expect(closeSession).toHaveBeenCalledWith(SESSION) - }) - - it('does not release a hold another connection is still holding', async () => { - await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) await dispatcher.dispatchStreaming( { - id: 'request-other', + id: 'request-subscribe', authToken: 'token', - method: 'agentSession.hold', - params: { sessionId: SESSION, holderId: 'chat-1' } + method: 'agentSession.subscribe', + params: { sessionId: SESSION } }, - () => {}, - { ...CLIENT, clientId: 'device-2', connectionId: 'connection-2' } + (raw) => frames.push(JSON.parse(raw)), + CLIENT ) - runtime.cleanupSubscriptionsForConnection(CONNECTION) - await new Promise((resolve) => setTimeout(resolve, GRACE_MS * 20)) - - expect(closeSession).not.toHaveBeenCalled() - expect(host.hasSession(SESSION)).toBe(true) - }) - - it('does not let an old connection sweep release its same-document replacement', async () => { - await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) - await dispatcher.dispatchStreaming( - { - id: 'request-replacement', - authToken: 'token', - method: 'agentSession.hold', - params: { sessionId: SESSION, holderId: 'chat-1' } - }, - () => {}, - { ...CLIENT, connectionId: 'connection-2' } + expect(frames).toContainEqual( + expect.objectContaining({ result: expect.objectContaining({ type: 'snapshot' }) }) ) - - runtime.cleanupSubscriptionsForConnection(CONNECTION) - await new Promise((resolve) => setTimeout(resolve, GRACE_MS * 20)) - - expect(closeSession).not.toHaveBeenCalled() - expect(host.hasSession(SESSION)).toBe(true) - - runtime.cleanupSubscriptionsForConnection('connection-2') - await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) + expect(acquire.mock.calls.length).toBe(acquiresBefore) + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') }) - it('releases a desktop subscription when its renderer transport dies', async () => { + it('keeps nothing alive when its transport dies', async () => { const transport = new AbortController() await dispatcher.dispatchStreaming( { @@ -310,56 +214,12 @@ describe('a client that disappears without cleanup', () => { clientCapabilities: CLIENT.clientCapabilities } ) - expect(host.isHeld(SESSION)).toBe(true) transport.abort() + await new Promise((resolve) => setTimeout(resolve, 20)) - await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) - expect(closeSession).toHaveBeenCalledWith(SESSION) - }) - - it('unsubscribes and releases stream retention after the setting is disabled', async () => { - await dispatcher.dispatchStreaming( - { - id: 'stream-disabled-cleanup', - authToken: 'token', - method: 'agentSession.subscribe', - params: { sessionId: SESSION } - }, - () => {}, - CLIENT - ) - expect(host.isHeld(SESSION)).toBe(true) - structuredNativeChatEnabled = false - - expect( - await call('agentSession.unsubscribe', { - sessionId: SESSION, - subscriptionId: 'stream-disabled-cleanup' - }) - ).toMatchObject({ ok: true }) - - await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) - expect(closeSession).toHaveBeenCalledWith(SESSION) - }) - - it('does not let a stream alone resume a released session', async () => { - await host.close(SESSION) - expect(host.hasSession(SESSION)).toBe(false) - await host.restoreReadableSessions() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - - await dispatcher.dispatchStreaming( - { - id: 'request-subscribe', - authToken: 'token', - method: 'agentSession.subscribe', - params: { sessionId: SESSION } - }, - () => {}, - CLIENT - ) - - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + // The idle sweep, not a departing reader, is what stops an agent. + expect(closeSession).not.toHaveBeenCalled() + expect(host.hasSession(SESSION)).toBe(true) }) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.ts index 54753fcb889..e1975f1c725 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.ts @@ -1,64 +1,31 @@ -// `agentSession.hold` / `agentSession.release` — a surface saying it is bound to a session. +// `agentSession.hold` / `agentSession.release` — kept answering for clients that still send them. // -// The holder identity is scoped to the CONNECTION, not taken from the client verbatim: two clients -// are free to name their surfaces the same thing, and a hold that collides is one that a stranger -// can release. -// -// The registered cleanup is the backstop, and the ONLY thing that covers a client which vanishes — -// a paired client that disconnects mid-turn never gets to send its release. Registering it before taking -// the hold is deliberate: re-registering an id runs the previous cleanup synchronously, so the -// stale release lands before this hold rather than after it. +// A view no longer decides whether an agent runs: a send starts one, and the idle sweep stops it. +// So both are no-ops. `hold` still builds the host, because shipped mobile builds build it through +// `hold` before they subscribe. Delete both, and their allowlist entries, once +// MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION passes every client that still calls them. -import { defineMethod, type RpcContext } from '../core' +import { defineMethod } from '../core' import { - ensureStructuredHostInstalled, - requireStructuredCleanupHost, - requireStructuredHost + requireInstalledStructuredHost, + requireStructuredCleanupHost } from './structured-agent-session-gate' import { HoldParams } from './structured-agent-session-schemas' -const HOLD_CLEANUP_PREFIX = 'agentSession.hold' - -function holderKeyFor(ctx: RpcContext, holderId: string): string { - const client = ctx.clientId?.trim() || (ctx.clientKind ?? 'runtime') - return `${ctx.connectionId ?? 'local'}:${client}:${holderId}` -} - -function holdCleanupIdFor(sessionId: string, holderKey: string): string { - return `${HOLD_CLEANUP_PREFIX}:${holderKey}:${sessionId}` -} - export const STRUCTURED_AGENT_SESSION_HOLD_METHODS = [ defineMethod({ name: 'agentSession.hold', params: HoldParams, - handler: async (params, ctx) => { - await ensureStructuredHostInstalled(ctx) - const host = requireStructuredHost(ctx) - const holderKey = holderKeyFor(ctx, params.holderId) - const registration = ctx.runtime.registerOwnedSubscriptionCleanup( - holdCleanupIdFor(params.sessionId, holderKey), - () => host.release(params.sessionId, holderKey), - ctx.connectionId - ) - try { - await host.hold(params.sessionId, holderKey) - } catch (error) { - registration.releaseIfCurrent() - throw error - } + handler: async (_params, ctx) => { + await requireInstalledStructuredHost(ctx) return { held: true as const } } }), defineMethod({ name: 'agentSession.release', params: HoldParams, - handler: async (params, ctx) => { - const host = requireStructuredCleanupHost(ctx) - const holderKey = holderKeyFor(ctx, params.holderId) - host.release(params.sessionId, holderKey) - // Retires the backstop too; its release is a no-op against a holder already gone. - ctx.runtime.cleanupSubscription(holdCleanupIdFor(params.sessionId, holderKey)) + handler: async (_params, ctx) => { + requireStructuredCleanupHost(ctx) return { released: true as const } } }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts index b74b9cacfa6..19e93689577 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts @@ -1,22 +1,26 @@ // The options read surface: what a session reports about itself, and the // host's stored model catalog behind the picker. // -// `agentSession.options` serializes on the session because it asks the live -// provider what is selected. `agentSession.modelCatalog` deliberately does +// `agentSession.options` answers at rest from the record and the catalog, and +// asks the live provider only when one runs. `agentSession.modelCatalog` deliberately does // neither — answering from the host store is what lets a picker render while // an attach is still running. It is additive: an older host answers // `method_not_found` (or `forbidden` through the mobile allowlist gate), and // the client keeps its static seed. import { defineMethod } from '../core' -import { requireStructuredHost as requireHost } from './structured-agent-session-gate' +import { + requireInstalledStructuredHost, + requireStructuredHost as requireHost +} from './structured-agent-session-gate' import { ModelCatalogParams, OptionsParams } from './structured-agent-session-schemas' export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [ defineMethod({ name: 'agentSession.options', params: OptionsParams, - handler: async (params, ctx) => requireHost(ctx).readOptions(params.sessionId) + handler: async (params, ctx) => + (await requireInstalledStructuredHost(ctx)).readOptions(params.sessionId) }), defineMethod({ name: 'agentSession.modelCatalog', diff --git a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.ts b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.ts index 24d9fa2aec4..4038fe9e40d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.ts @@ -12,6 +12,9 @@ import { AGENT_SESSION_WIRE_REFUSAL_CODES, + agentSessionRefusalFromReference, + isAgentSessionRefusalError, + refuseUnclassified, type AgentSessionWireRefusal, type AgentSessionWireRefusalCode } from '../../../../shared/agent-session-wire' @@ -40,19 +43,25 @@ function wireRefusalCode(error: unknown): AgentSessionWireRefusalCode | null { return null } +const PRECOMMIT_REFUSAL_MESSAGE = 'Orca cannot open a structured agent chat for this workspace.' + function precommitRefusal(error: unknown): AgentSessionWireRefusal { + // A refusal the host raised keeps its situation; a bare code names none. + if (isAgentSessionRefusalError(error)) { + return agentSessionRefusalFromReference(error.refusal, PRECOMMIT_REFUSAL_MESSAGE) + } const code = wireRefusalCode(error) if (code) { - return { code, message: 'Orca cannot open a structured agent chat for this workspace.' } + return refuseUnclassified(code, PRECOMMIT_REFUSAL_MESSAGE) } const message = error instanceof Error ? error.message : String(error) // A code-less failure here is often a defect, not a policy answer; the refusal keeps the user - // moving, the log keeps the cause findable. + // moving, the log keeps the cause findable. Nothing names its situation, so it carries no reason. console.warn('[agent-session] create refused before it committed anything', error) - return { - code: UNCODED_PRECOMMIT_REFUSAL_CODE, - message: `Orca could not prepare a structured agent chat for this workspace: ${message}` - } + return refuseUnclassified( + UNCODED_PRECOMMIT_REFUSAL_CODE, + `Orca could not prepare a structured agent chat for this workspace: ${message}` + ) } /** diff --git a/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts b/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts new file mode 100644 index 00000000000..a4b68ccbcd3 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts @@ -0,0 +1,31 @@ +// The queued-message actions: Send-now and Delete on one card, and Resume on a +// paused queue. All gated on agent-session.queued-messages.v1; an older host +// lacks the methods entirely. + +import { defineMethod } from '../core' +import { + requireStructuredHost as requireHost, + structuredCallerFor as callerFor +} from './structured-agent-session-gate' +import { + QueuedMessageActionParams, + QueuedMessagesResumeParams +} from './structured-agent-session-schemas' + +export const STRUCTURED_AGENT_SESSION_QUEUED_METHODS = [ + defineMethod({ + name: 'agentSession.queuedMessageSend', + params: QueuedMessageActionParams, + handler: async (params, ctx) => requireHost(ctx).queuedMessageSend(callerFor(ctx), params) + }), + defineMethod({ + name: 'agentSession.queuedMessageDelete', + params: QueuedMessageActionParams, + handler: async (params, ctx) => requireHost(ctx).queuedMessageDelete(callerFor(ctx), params) + }), + defineMethod({ + name: 'agentSession.queuedMessagesResume', + params: QueuedMessagesResumeParams, + handler: async (params, ctx) => requireHost(ctx).queuedMessagesResume(callerFor(ctx), params) + }) +] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-respond-params.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-respond-params.test.ts new file mode 100644 index 00000000000..3eb7ad3db61 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-respond-params.test.ts @@ -0,0 +1,106 @@ +// Wire bounds for prompt responses: a decision id for an approval, structured answers for a question. + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + call, + clearStructuredHostStub, + envelope, + hostCalls, + installStructuredHostStub, + STRUCTURED_CLIENT +} from './structured-agent-session-rpc.test-fixture' + +beforeEach(() => { + installStructuredHostStub() +}) + +afterEach(() => { + clearStructuredHostStub() +}) + +describe('prompt response parameters', () => { + const rejects = async (method: string, params: unknown): Promise => { + const response = await call(method, params, STRUCTURED_CLIENT) + expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + } + + it('accepts the maximum fully encoded Claude choice group and retains a finite bound', async () => { + const maximumSelections = Array.from({ length: 4 }, (_, questionIndex) => ({ + questionId: `q${questionIndex + 1}`, + optionIds: Array.from( + { length: 4 }, + (_, optionIndex) => `q${questionIndex + 1}:choice-${optionIndex + 1}` + ) + })) + const optionId = `question-group:${encodeURIComponent(JSON.stringify(maximumSelections))}` + expect(optionId.length).toBe(610) + + const response = await call( + 'agentSession.respondToQuestion', + { + envelope: envelope(), + itemId: 'item-1', + expectedRevision: 1, + optionId + }, + STRUCTURED_CLIENT + ) + expect(response).toMatchObject({ ok: true }) + expect(hostCalls.respondToPrompt).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ optionId }) + ) + + await rejects('agentSession.respondToQuestion', { + envelope: envelope(), + itemId: 'item-1', + expectedRevision: 1, + optionId: 'x'.repeat(1025) + }) + }) + + it('takes a long typed answer as structured answers and bounds each field', async () => { + const answers = [ + { questionId: 'q1', optionIds: ['q1:choice-1'] }, + { questionId: 'q2', optionIds: [], other: 'Proceed with the replacement. '.repeat(100) } + ] + const response = await call( + 'agentSession.respondToQuestion', + { envelope: envelope(), itemId: 'item-1', expectedRevision: 1, answers }, + STRUCTURED_CLIENT + ) + expect(response).toMatchObject({ ok: true }) + expect(hostCalls.respondToPrompt).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ kind: 'question', answers }) + ) + + const base = { envelope: envelope(), itemId: 'item-1', expectedRevision: 1 } + await rejects('agentSession.respondToQuestion', base) + await rejects('agentSession.respondToQuestion', { ...base, optionId: 'q1:choice-1', answers }) + await rejects('agentSession.respondToQuestion', { + ...base, + answers: [{ questionId: 'q1', optionIds: [], other: 'x'.repeat(64 * 1024 + 1) }] + }) + await rejects('agentSession.respondToQuestion', { + ...base, + answers: [{ questionId: 'q1', optionIds: [], other: 'é'.repeat(40 * 1024) }] + }) + await rejects('agentSession.respondToApproval', { ...base, answers }) + await rejects('agentSession.respondToApproval', { ...base, optionId: 'x'.repeat(1025) }) + }) + + it('takes a question id exactly as the agent wrote it, including edge spaces', async () => { + const answers = [{ questionId: 'scope ', optionIds: [], other: 'mine' }] + const response = await call( + 'agentSession.respondToQuestion', + { envelope: envelope(), itemId: 'item-1', expectedRevision: 1, answers }, + STRUCTURED_CLIENT + ) + expect(response).toMatchObject({ ok: true }) + expect(hostCalls.respondToPrompt).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ kind: 'question', answers }) + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts index b3a70334f40..a48f897a005 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts @@ -64,20 +64,15 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ } }), defineMethod({ - // Reattach only, no send. No Orca surface calls it now — the desktop prompt's single action is - // resume-and-continue — but it is a PUBLISHED wire method, so dropping it is a wire removal an - // older or non-desktop client would meet as an unknown method. + // Reattach only, no send. Reattaching is nothing now — an agent starts only for work — so this + // answers that nothing was resumed. No Orca surface calls it, but it is a PUBLISHED wire + // method, so dropping it is a wire removal an older client would meet as an unknown method. name: 'agentSession.restartResume', params: RestartResumeParams, - handler: async (params, ctx) => { + handler: async (_params, ctx) => { await ensureStructuredHostInstalled(ctx) - const host = requireStructuredHost(ctx) - return { - results: await host.restartResume.resume( - params.sessionIds, - structuredCallerFor(ctx).callerKey - ) - } + requireStructuredHost(ctx) + return { results: [] } } }) ] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts index 47f30a5030d..903f8d440af 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts @@ -9,7 +9,11 @@ // reads its own record and answers with that record's workspace and provider, so a client knowing // only a session id cannot aim the publication somewhere else. -import { isAgentSessionWireRefusalCode } from '../../../../shared/agent-session-wire' +import { + agentSessionRefusalFromReference, + isAgentSessionRefusalError, + isAgentSessionWireRefusalCode +} from '../../../../shared/agent-session-wire' import type { StructuredAgentSessionReveal } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' import { refuseAgentSessionMutation } from '../../../native-chat/agent-session-wire/structured-agent-session-mutation-admission' import { defineMethod } from '../core' @@ -37,13 +41,14 @@ export const STRUCTURED_AGENT_SESSION_REVEAL_METHODS = [ if (!isAgentSessionWireRefusalCode(code)) { throw error } - return refuseAgentSessionMutation({ - code, - message: + return refuseAgentSessionMutation( + agentSessionRefusalFromReference( + isAgentSessionRefusalError(error) ? error.refusal : { code }, code === 'structured_agent_session_unsupported' ? 'This host cannot open that chat.' : 'This chat is no longer on this host.' - }) + ) + ) } await ctx.runtime.publishStructuredAgentSessionTab({ workspaceId: revealed.workspaceId, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index a9cf5cd7957..85d3e2a5b4b 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -191,6 +191,7 @@ export function hostStub(): StructuredAgentSessionHost { current: { model: 'gpt-live' } })), history: vi.fn(() => ({ ok: true, page: { items: [] } })), + sessionAgent: vi.fn(() => null), journalSnapshot: vi.fn((sessionId: string) => ({ sessionId, cursor: { epoch: 'epoch-a', sequence: 0 }, @@ -203,8 +204,7 @@ export function hostStub(): StructuredAgentSessionHost { subscribeStatus: vi.fn((subscriber: StructuredAgentSessionStatusSubscriber) => statusFeed().subscribe(subscriber) ), - unsubscribe: vi.fn(), - release: vi.fn() + unsubscribe: vi.fn() }) return hostCalls as unknown as StructuredAgentSessionHost } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index 9daf97d55af..5930fa1e80f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -16,7 +16,10 @@ export { ModelCatalogParams, MutationEnvelope, OptionsParams, + QueuedMessageActionParams, + QueuedMessagesResumeParams, RespondParams, + RespondToQuestionParams, RestartResumableParams, RestartResumeParams, RewindParams, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts new file mode 100644 index 00000000000..59bc914558e --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts @@ -0,0 +1,90 @@ +// Which clients get a send answered at acceptance, and which have their reply held until the +// message is handed over: a client that cannot show a rejection after `pending` must not see one. + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../../ipc/desktop-renderer-runtime-capabilities' +import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from '../../../native-chat/agent-session-wire/structured-agent-session-send-settlement' +import { + call, + clearStructuredHostStub, + hostCalls, + installStructuredHostStub, + SESSION, + sendParams, + STRUCTURED_CLIENT +} from './structured-agent-session-rpc.test-fixture' + +beforeEach(() => { + installStructuredHostStub() +}) + +afterEach(() => { + clearStructuredHostStub() +}) + +describe('agentSession.send reply timing', () => { + it('holds a pending reply until handover for a client that cannot show a later rejection', async () => { + hostCalls.send.mockResolvedValueOnce(pendingSendResult()) + hostCalls.waitForSendSettlement.mockResolvedValueOnce(undefined) + + await call('agentSession.send', sendParams(), STRUCTURED_CLIENT) + + expect(hostCalls.waitForSendSettlement).toHaveBeenCalledWith(SESSION, 'client-1', { + until: 'handed-over-or-behind-command', + budgetMs: STRUCTURED_AGENT_SESSION_START_WAIT_MS + }) + }) + + it("marks a client's send as the user's own, which alone lifts a Stop's queue pause", async () => { + hostCalls.send.mockResolvedValueOnce(pendingSendResult()) + await call('agentSession.send', sendParams(), STRUCTURED_CLIENT) + expect(hostCalls.send.mock.calls[0]?.[1]).toMatchObject({ userSend: true }) + }) + + it('answers at acceptance for the local desktop and paired desktop clients (W2)', async () => { + for (const clientCapabilities of [ + DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES, + // A paired desktop gains the structured surface as its own capability; the reply rule rides + // on the list it already sends. + [...ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + ]) { + hostCalls.send.mockResolvedValueOnce(pendingSendResult()) + const response = await call('agentSession.send', sendParams(), { + clientKind: 'runtime', + clientCapabilities: [...clientCapabilities] + }) + expect(response).toMatchObject({ + ok: true, + result: { value: { submission: { dispatchState: 'pending' } } } + }) + } + expect(hostCalls.waitForSendSettlement).not.toHaveBeenCalled() + }) +}) + +function pendingSendResult() { + return { + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-a', sequence: 1 }, + value: { + clientMessageId: 'client-1', + submission: { + clientMessageId: 'client-1', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending' as const, + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null, + handoverRecorded: true as const + } + } + } +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts index 8b948869990..47afaa8ac73 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts @@ -1,26 +1,48 @@ -import { AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import { agentSessionSendSubmission } from '../../../../shared/agent-session-wire' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from '../../../native-chat/agent-session-wire/structured-agent-session-send-settlement' import type { RpcContext } from '../core' import { requireStructuredHost, structuredCallerFor } from './structured-agent-session-gate' +/** + * A send answers once the host accepts it. A client that predates that answer cannot show a + * message rejected after it, so its reply is held until the message is handed over or rejected — + * or queued behind a running command such as `/compact`, which could outlast the client's own + * request timeout; one that predates pending replies at all waits, as before, for the provider's + * answer. + */ export async function sendStructuredAgentSessionForClient( params: Parameters[1], context: RpcContext ) { const host = requireStructuredHost(context) - const result = await host.send(structuredCallerFor(context), params) + // Only a client's own send lifts a Stop's queue pause; host-internal senders never do. + const result = await host.send(structuredCallerFor(context), { ...params, userSend: true }) + const capabilities = context.clientCapabilities ?? [] if ( !result.ok || - result.value.submission.dispatchState !== 'pending' || + // A queued answer only ever reaches a capable client, which renders it as-is. + agentSessionSendSubmission(result.value)?.dispatchState !== 'pending' || context.clientKind === undefined || - context.clientCapabilities?.includes(AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY) + capabilities.includes(AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY) ) { return result } + // The start that used to run before the reply now runs after acceptance, so both waits cover it. const settled = await host.waitForSendSettlement( params.envelope.sessionId, result.value.clientMessageId, - context.signal + { + until: capabilities.includes(AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY) + ? 'handed-over-or-behind-command' + : 'answered', + budgetMs: STRUCTURED_AGENT_SESSION_START_WAIT_MS, + ...(context.signal ? { signal: context.signal } : {}) + } ) return settled ? { ...result, ...settled } : result } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts index 0393cf216c8..473f6331372 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import type { AgentSessionHistoryPage, @@ -87,7 +87,6 @@ describe('turn item capability at the RPC boundary', () => { it.each(['snapshot', 'batch', 'reset'] as const)( 'downgrades the %s stream only for a legacy reader', async (type) => { - hostCalls.hold = vi.fn(async () => undefined) hostCalls.subscribe.mockImplementation((input: AgentSessionSubscribeInput) => { const base = { sessionId: SESSION, fence: 1 } if (type === 'batch') { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.ts b/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.ts index f0694811a9a..c6984552399 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.ts @@ -24,7 +24,10 @@ function readsTurnItems(ctx: TurnItemReader): boolean { ) } -function projectItems(items: AgentJournalRenderItem[]): AgentJournalRenderItem[] { +function projectItems( + items: AgentJournalRenderItem[], + sessionAgent: string | null +): AgentJournalRenderItem[] { if (!items.some((item) => item.body.kind === 'turn')) { return items } @@ -33,39 +36,45 @@ function projectItems(items: AgentJournalRenderItem[]): AgentJournalRenderItem[] return item } const { kind: _kind, ...turn } = item.body - return { ...item, body: legacyAgentJournalTurnStatusBody(turn, item.itemId) } + return { ...item, body: legacyAgentJournalTurnStatusBody(turn, item.itemId, sessionAgent) } }) } -function projectPage(page: AgentSessionHistoryPage): AgentSessionHistoryPage { - const items = projectItems(page.items) +function projectPage( + page: AgentSessionHistoryPage, + sessionAgent: string | null +): AgentSessionHistoryPage { + const items = projectItems(page.items, sessionAgent) return items === page.items ? page : { ...page, items } } +/** `sessionAgent` names the agent on a turn whose key does not, such as a command's. */ export function projectTurnItemHistory( result: AgentSessionHistoryResult, - ctx: TurnItemReader + ctx: TurnItemReader, + sessionAgent: string | null = null ): AgentSessionHistoryResult { if (readsTurnItems(ctx)) { return result } - const page = projectPage(result.page) + const page = projectPage(result.page, sessionAgent) return page === result.page ? result : { ...result, page } } export function projectTurnItemEvent( event: AgentSessionSubscribeEvent, - ctx: TurnItemReader + ctx: TurnItemReader, + sessionAgent: string | null = null ): AgentSessionSubscribeEvent { if (readsTurnItems(ctx)) { return event } if (event.type === 'batch') { - const items = projectItems(event.batch.items) + const items = projectItems(event.batch.items, sessionAgent) return items === event.batch.items ? event : { ...event, batch: { ...event.batch, items } } } if (event.type === 'snapshot' || event.type === 'reset') { - const page = projectPage(event.page) + const page = projectPage(event.page, sessionAgent) return page === event.page ? event : { ...event, page } } return event diff --git a/src/main/runtime/rpc/methods/structured-agent-session-unsubscribe.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-unsubscribe.test.ts new file mode 100644 index 00000000000..a31a8bc8d18 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-unsubscribe.test.ts @@ -0,0 +1,91 @@ +// A transcript stream ends when its client says so: the host drops that subscriber, so nothing is +// derived or sent for it any more, and a sibling stream of the same session keeps going. + +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { + createRestTestRig, + foundRestTestChat, + REST_TEST_CALLER as CALLER, + REST_TEST_SESSION as SESSION, + restTestSend, + type RestTestRig +} from '../../../native-chat/agent-session-wire/structured-agent-session-rest-test-rig' +import { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcResponse } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' + +const CLIENT = { + clientId: 'device-1', + clientKind: 'runtime' as const, + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + connectionId: 'connection-1' +} + +let rig: RestTestRig +let dispatcher: RpcDispatcher + +function subscriberCount(): number { + return rig.host.collaboratorsForTests().subscribers.subscriberCountForTests(SESSION) +} + +async function stream(id: string, frames: RpcResponse[]): Promise { + await dispatcher.dispatchStreaming( + { id, authToken: 'token', method: 'agentSession.subscribe', params: { sessionId: SESSION } }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dispatcher writes RpcResponse JSON. + (raw) => frames.push(JSON.parse(raw) as RpcResponse), + CLIENT + ) +} + +beforeEach(async () => { + rig = await createRestTestRig({ idleSweep: { intervalMs: 3_600_000 } }) + setStructuredAgentSessionHost(rig.host) + const runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'getClientSettings').mockImplementation( + () => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the RPC gate reads only this one setting. + ({ experimentalStructuredNativeChat: true }) as ReturnType< + OrcaRuntimeService['getClientSettings'] + > + ) + dispatcher = new RpcDispatcher({ runtime, methods: STRUCTURED_AGENT_SESSION_METHODS }) +}) + +afterEach(async () => { + setStructuredAgentSessionHost(null) + await rig.dispose() +}) + +it('drops the subscriber a client unsubscribes, and keeps its sibling (U-02)', async () => { + await foundRestTestChat(rig) + const ended: RpcResponse[] = [] + const kept: RpcResponse[] = [] + await stream('frame-a', ended) + await stream('frame-b', kept) + expect(subscriberCount()).toBe(2) + + const replies: RpcResponse[] = [] + await dispatcher.dispatchStreaming( + { + id: 'frame-c', + authToken: 'token', + method: 'agentSession.unsubscribe', + params: { sessionId: SESSION, subscriptionId: 'frame-a' } + }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dispatcher writes RpcResponse JSON. + (raw) => replies.push(JSON.parse(raw) as RpcResponse), + CLIENT + ) + expect(replies[0]).toMatchObject({ ok: true }) + expect(subscriberCount()).toBe(1) + + const before = ended.length + const fence = rig.store.getRecord(SESSION)!.lease.runtimeFence + await rig.host.send(CALLER, restTestSend('after the unsubscribe', fence)) + await vi.waitFor(() => expect(kept.length).toBeGreaterThan(1)) + // The ended stream's own `end` frame is all it gets. + expect(ended.slice(before).every((frame) => JSON.stringify(frame).includes('"end"'))).toBe(true) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 2d2760832ad..d9d430f08e3 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -4,6 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import { + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, + AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION, @@ -12,6 +14,7 @@ import { STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from '../../../native-chat/agent-session-wire/structured-agent-session-send-settlement' import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' import { ALL_RPC_METHODS } from './index' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' @@ -149,6 +152,10 @@ describe('capability gating', () => { it('advertises the capability without bumping the protocol version', () => { expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY) + // A client tells a host that accepts first, and admits a writer-free Stop before a turn, by it. + expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY) + // And a cancel naming no turn, which a host that only accepts first still refuses as invalid. + expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY) expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY) expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY) // Separate from the structured capability on purpose: a host can serve the rest of the @@ -167,7 +174,7 @@ describe('capability gating', () => { } // Bump deliberately: the whole agentSession.* surface is behind the structured capability, // so an additive method is invisible to old clients and needs no protocol bump. - expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(29) + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(32) }) it('hides the surface from a declared client that did not advertise it', async () => { @@ -252,11 +259,11 @@ describe('capability gating', () => { signal: controller.signal }) - expect(hostCalls.waitForSendSettlement).toHaveBeenCalledWith( - SESSION, - 'client-1', - controller.signal - ) + expect(hostCalls.waitForSendSettlement).toHaveBeenCalledWith(SESSION, 'client-1', { + until: 'answered', + budgetMs: STRUCTURED_AGENT_SESSION_START_WAIT_MS, + signal: controller.signal + }) expect(response).toMatchObject({ ok: true, result: { @@ -302,36 +309,6 @@ describe('capability gating', () => { }) }) - it('returns durable pending immediately to clients that understand admission', async () => { - hostCalls.send.mockResolvedValueOnce({ - ok: true, - replayed: false, - fence: 1, - cursor: { epoch: 'epoch-a', sequence: 1 }, - value: { - clientMessageId: 'client-1', - submission: { - clientMessageId: 'client-1', - fence: 1, - payloadFingerprint: 'fingerprint', - dispatchState: 'pending', - providerItemId: null, - reason: null, - submittedAt: 1, - resolvedAt: null - } - } - }) - - const response = await call('agentSession.send', sendParams(), STRUCTURED_CLIENT) - - expect(hostCalls.waitForSendSettlement).not.toHaveBeenCalled() - expect(response).toMatchObject({ - ok: true, - result: { value: { submission: { dispatchState: 'pending' } } } - }) - }) - it('requires the host structured-chat setting for mobile clients', async () => { const response = await call('agentSession.send', sendParams(), STRUCTURED_MOBILE_CLIENT, { getClientSettings: () => ({ experimentalStructuredNativeChat: false }) @@ -363,7 +340,9 @@ describe('capability gating', () => { ok: false, error: { message: expect.stringContaining('structured_agent_session_unsupported') } }) - expect(hostCalls[hostCall]).not.toHaveBeenCalled() + if (hostCall !== null) { + expect(hostCalls[hostCall]).not.toHaveBeenCalled() + } } ) @@ -783,41 +762,6 @@ describe('parameter validation', () => { }) }) - it('accepts the maximum fully encoded Claude choice group and retains a finite bound', async () => { - const maximumSelections = Array.from({ length: 4 }, (_, questionIndex) => ({ - questionId: `q${questionIndex + 1}`, - optionIds: Array.from( - { length: 4 }, - (_, optionIndex) => `q${questionIndex + 1}:choice-${optionIndex + 1}` - ) - })) - const optionId = `question-group:${encodeURIComponent(JSON.stringify(maximumSelections))}` - expect(optionId.length).toBe(610) - - const response = await call( - 'agentSession.respondToQuestion', - { - envelope: envelope(), - itemId: 'item-1', - expectedRevision: 1, - optionId - }, - STRUCTURED_CLIENT - ) - expect(response).toMatchObject({ ok: true }) - expect(hostCalls.respondToPrompt).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ optionId }) - ) - - await rejects('agentSession.respondToQuestion', { - envelope: envelope(), - itemId: 'item-1', - expectedRevision: 1, - optionId: 'x'.repeat(1025) - }) - }) - it('bounds a history page and validates its cursor', async () => { await rejects('agentSession.history', { sessionId: SESSION, diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 66d44d4b782..534d4967779 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -5,6 +5,7 @@ // not exist rather than receiving the journal or mutation surface. Session-tab // inventory may expose only a metadata placeholder for an incapable mobile client. +import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' import { agentSessionFingerprintConflict } from '../../../../shared/agent-session-mutation-envelope' import type { z } from 'zod' import { @@ -18,6 +19,7 @@ import { import { defineMethod, defineStreamingMethod, type RpcContext } from '../core' import { ensureStructuredHostInstalled as ensureHostInstalled, + requireInstalledStructuredHost as requireInstalledHost, requireStructuredCapability, requireStructuredCleanupHost, requireStructuredHost as requireHost, @@ -32,6 +34,7 @@ import { } from './structured-agent-session-create' import { STRUCTURED_AGENT_SESSION_HOLD_METHODS } from './structured-agent-session-hold' import { STRUCTURED_AGENT_SESSION_REVEAL_METHODS } from './structured-agent-session-reveal' +import { STRUCTURED_AGENT_SESSION_QUEUED_METHODS } from './structured-agent-session-queued-methods' import { STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS } from './structured-agent-session-restart-resume' import { resolveUncommittedStructuredCreate } from './structured-agent-session-precommit-refusal' import { @@ -56,6 +59,7 @@ import { HandoffStatusParams, OptionsParams, RespondParams, + RespondToQuestionParams, RewindParams, SendParams, SetOptionParams, @@ -74,7 +78,9 @@ async function resolveClientSuppliedAttach(params: z.infer, await ensureHostInstalled(ctx) const host = requireHost(ctx) if (!host.supportsCreate(params.location, params.agent)) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) } const { agent: _attachAgent, provider: _attachProvider, ...attachWithoutAgent } = params const attachParams = { @@ -129,7 +135,9 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ params: CreateSupportParams, handler: async (params, ctx) => { if (!supportsStructuredSessions(ctx)) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'clientCapabilityMissing' + }) } return ctx.runtime.getStructuredAgentSessionCreateSupport(params.worktree, params.agent) } @@ -140,7 +148,9 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ handler: async (params, ctx) => { requireStructuredCapability(ctx) if (params.envelope.expectedRuntimeFence !== null) { - throw new Error('agent_session_operation_invalid') + throw agentSessionRefusalError('agent_session_operation_invalid', { + reason: 'requestMalformed' + }) } // Everything up to `attach` is pre-commit, and answers with a refusal rather than a throw so // a client can tell "nothing was created" from "the outcome is unknown". @@ -197,6 +207,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ params: CancelParams, handler: async (params, ctx) => requireStructuredCleanupHost(ctx).cancel(callerFor(ctx), params) }), + ...STRUCTURED_AGENT_SESSION_QUEUED_METHODS, defineMethod({ // Releasing a chat view, not ending a conversation: the record and journal stay on disk so the // same session can be attached again. Only the provider child and the in-memory entry go. @@ -222,7 +233,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ }), defineMethod({ name: 'agentSession.respondToQuestion', - params: RespondParams, + params: RespondToQuestionParams, handler: async (params, ctx) => requireHost(ctx).respondToPrompt(callerFor(ctx), { ...params, kind: 'question' }) }), @@ -234,60 +245,55 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ defineMethod({ name: 'agentSession.handoffStatus', params: HandoffStatusParams, - handler: async (params, ctx) => requireHost(ctx).handoffStatus(params.sessionId) + handler: async (params, ctx) => + (await requireInstalledHost(ctx)).handoffStatus(params.sessionId) }), defineMethod({ name: 'agentSession.commands', params: OptionsParams, - handler: async (params, ctx) => requireHost(ctx).readCommands(params.sessionId) + handler: async (params, ctx) => (await requireInstalledHost(ctx)).readCommands(params.sessionId) }), defineMethod({ name: 'agentSession.history', params: HistoryParams, - handler: async (params, ctx) => - projectTurnItemHistory( - projectBackgroundTaskHistory(requireHost(ctx).history(params), ctx), - ctx + handler: async (params, ctx) => { + const host = await requireInstalledHost(ctx) + return projectTurnItemHistory( + projectBackgroundTaskHistory(await host.history(params), ctx), + ctx, + host.sessionAgent(params.sessionId) ) + } }), defineStreamingMethod({ name: 'agentSession.subscribe', params: SubscribeParams, handler: async (params, ctx, emit) => { - const host = requireHost(ctx) + const host = await requireInstalledHost(ctx) const subscriptionId = subscriptionIdFor(ctx, params.sessionId) - // A live stream is a surface too: it keeps a session from being evicted while it is read and - // releases that retention when the transport dies without a word. - // - // Retain-only: reading history must never be what starts a provider process. Current clients - // explicitly hold every open surface before subscribing. - const streamHolder = `subscription:${subscriptionId}` + // A stream reads; it never keeps an agent alive or starts one. let dispose = (): void => {} - const stream = bindStructuredAgentSessionStream(ctx, subscriptionId, () => { - dispose() - host.release(params.sessionId, streamHolder) - }) + const stream = bindStructuredAgentSessionStream(ctx, subscriptionId, () => dispose()) if (stream.isClosed()) { return } - // The host emits the opening snapshot (or the missed batch) synchronously - // inside open(), so nothing between here and there can interleave. - dispose = host.subscribe({ + // Resolves once the conversation is open and the opening snapshot (or the missed batch) is + // emitted; a close that raced the open disposes what it bound. + dispose = await host.subscribe({ id: subscriptionId, sessionId: params.sessionId, - emit: (event) => emit(projectTurnItemEvent(projectBackgroundTaskEvent(event, ctx), ctx)), + emit: (event) => + emit( + projectTurnItemEvent( + projectBackgroundTaskEvent(event, ctx), + ctx, + host.sessionAgent(params.sessionId) + ) + ), ...(params.cursor ? { cursor: params.cursor } : {}) }) if (stream.isClosed()) { dispose() - } else { - // Fire-and-forget, but never unhandled: a resume that refuses leaves the stream holding a - // readable session, which is exactly what the client sees anyway. - void host - .hold(params.sessionId, streamHolder, { resume: false }) - .catch((error: unknown) => - console.warn('[agent-session] stream hold failed', params.sessionId, error) - ) } } }), diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts new file mode 100644 index 00000000000..f76b3ae6cb7 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts @@ -0,0 +1,409 @@ +/** + * A chat tab's pointer to the conversation it shows, driven end to end: a real record store on disk, + * the real structured host, the real runtime, and the real RPC handlers. Only the provider is faked. + */ + +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' +import { + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' +import { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import { + HOST_TEST_LOCATION, + HOST_TEST_NOW, + HOST_TEST_SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { AgentSessionRecordStore } from '../../agent-session-record-store' +import { agentSessionStorePath } from '../../agent-session-record-store-file' +import { OrcaRuntimeService } from '../../orca-runtime' +import { RpcDispatcher } from '../dispatcher' +import type { RpcDispatchStreamingOptions } from '../dispatcher-stream-options' +import { SESSION_TAB_METHODS } from './session-tabs' +import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' +import { commitStructuredAgentSessionCreate } from './structured-agent-session-create' +import { closeStructuredAgentSessionChild } from '../../structured-agent-session-close' + +const WORKTREE = `id:${HOST_TEST_LOCATION.workspaceId}` +const SOURCE_TAB = `structured-agent-session-${HOST_TEST_SESSION}` +const caller = { callerKey: 'trusted-local:runtime' } + +let directory: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let runtime: OrcaRuntimeService +let dispatcher: RpcDispatcher +let acquisitions = 0 +let acquireFails = false +let closeSession: ReturnType Promise>> + +function providerAdapter(): StructuredAgentSessionAdapter { + return { + supportsLocation: (location) => + location.executionHostId === 'local' && location.wslDistro === null, + acquire: vi.fn(async (input) => { + if (acquireFails) { + throw new Error('provider failed to start') + } + acquisitions++ + return { + process: { + hostId: 'local', + pid: 4000 + acquisitions, + processStartTimeMs: HOST_TEST_NOW, + spawnToken: input.spawnToken + }, + link: { + linkId: `link-${acquisitions}`, + mintedAtFence: input.fence, + observedAt: HOST_TEST_NOW, + origin: 'created' as const, + handle: { + provider: 'codex' as const, + threadId: `00000000-0000-4000-8000-${String(acquisitions).padStart(12, '0')}` + } + } + } + }), + dispatch: vi.fn(async () => ({ state: 'admitted' as const })), + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: async () => {}, + setOption: async () => {}, + releaseAcquisition: async () => true, + closeSession, + readOptions: async () => ({ models: [], current: { model: 'test-model', effort: 'high' } }) + } +} + +async function openHost(): Promise { + store = await AgentSessionRecordStore.open({ + directory: join(directory, 'store'), + hostId: 'local' + }) + host = new StructuredAgentSessionHost({ + store, + adapter: providerAdapter(), + journalRoot: directory, + claimKeyId: 'key', + now: () => HOST_TEST_NOW, + mintSpawnToken: () => `spawn-${acquisitions}` + }) + setStructuredAgentSessionHost(host) +} + +type CallResponse = { + ok: boolean + result?: { ok?: boolean; value?: { replacementSessionId?: string } } +} + +async function call( + method: string, + params: unknown, + context: RpcDispatchStreamingOptions = {} +): Promise { + const response = await dispatcher.dispatch( + { id: 'request', authToken: 'token', method, params }, + context + ) + return JSON.parse(JSON.stringify(response)) +} + +async function createChat(sessionId: string, tabId?: string) { + return commitStructuredAgentSessionCreate({ + runtime, + caller, + activate: true, + prepared: { + host, + attachParams: hostTestAttachParams(null, { + envelope: { + sessionId, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: null, + payloadFingerprint: '' + }, + ...(tabId ? { surfaceTabId: tabId } : {}) + }), + tab: { workspaceId: HOST_TEST_LOCATION.workspaceId, agent: 'codex' } + } + }) +} + +function envelopeFor(method: string, sessionId: string, fields: Record) { + return { + sessionId, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(sessionId)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ method, sessionId, fields }) + } +} + +async function clear(sessionId: string): Promise { + const response = await call('agentSession.conversationCommand', { + command: 'clear', + envelope: envelopeFor('agentSession.conversationCommand', sessionId, { command: 'clear' }) + }) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + const replacement = response.result?.value?.replacementSessionId + expect(replacement).toBeDefined() + return replacement! +} + +async function send(sessionId: string, text: string) { + const body = hostTestMessage(text) + return call('agentSession.send', { + body, + envelope: envelopeFor('agentSession.send', sessionId, { body }) + }) +} + +async function snapshot() { + return runtime.listMobileSessionTabs(WORKTREE) +} + +beforeEach(async () => { + resetHostTestOperationIds() + acquisitions = 0 + acquireFails = false + closeSession = vi.fn(async () => true) + directory = await mkdtemp(join(tmpdir(), 'orca-chat-tab-table-')) + runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'getClientSettings').mockReturnValue( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the structured-chat policy reads only this one setting on these paths. + { experimentalStructuredNativeChat: true } as ReturnType< + OrcaRuntimeService['getClientSettings'] + > + ) + dispatcher = new RpcDispatcher({ + runtime, + methods: [...STRUCTURED_AGENT_SESSION_METHODS, ...SESSION_TAB_METHODS] + }) + await openHost() +}) + +afterEach(async () => { + await host?.flushAllStreamedEvents() + setStructuredAgentSessionHost(null) + await rm(directory, { recursive: true, force: true }) +}) + +describe('a chat tab across /clear', () => { + it('keeps sending through a second and third /clear, the tab following each replacement', async () => { + expect(await createChat(HOST_TEST_SESSION)).toMatchObject({ ok: true }) + expect(store.getSessionTabId(HOST_TEST_SESSION)).toBe(SOURCE_TAB) + let current = HOST_TEST_SESSION + // A pending send blocks /clear by design, so the clears run back to back and the chat sends after. + for (let round = 0; round < 3; round++) { + const replacement = await clear(current) + expect(store.getSessionTabId(replacement)).toBe(SOURCE_TAB) + expect(store.getSessionTabId(current)).toBeNull() + current = replacement + } + expect(await send(current, 'after three clears')).toMatchObject({ + ok: true, + result: { ok: true } + }) + const tabs = (await snapshot()).tabs + expect(tabs).toHaveLength(1) + expect(tabs[0]).toMatchObject({ type: 'agent-session', sessionId: current }) + expect(store.listVisibleSessionIds()).toEqual([current]) + }) + + it('reveals a cleared conversation in its own tab without activating the current chat', async () => { + await createChat(HOST_TEST_SESSION) + const replacement = await clear(HOST_TEST_SESSION) + + expect(await call('agentSession.reveal', { sessionId: HOST_TEST_SESSION })).toMatchObject({ + ok: true + }) + + const revealedTabId = store.getSessionTabId(HOST_TEST_SESSION) + expect(revealedTabId).not.toBeNull() + expect(revealedTabId).not.toBe(SOURCE_TAB) + expect(revealedTabId).not.toContain(':') + expect(store.getSessionTabId(replacement)).toBe(SOURCE_TAB) + const published = await snapshot() + expect(published.tabs.map((tab) => tab.id)).toEqual([ + `agent-session:${replacement}`, + `agent-session:${HOST_TEST_SESSION}` + ]) + expect(published.activeTabId).toBe(`agent-session:${HOST_TEST_SESSION}`) + }) + + it('closes the cleared conversation and leaves the current chat and its tab', async () => { + await createChat(HOST_TEST_SESSION) + const replacement = await clear(HOST_TEST_SESSION) + await call('agentSession.reveal', { sessionId: HOST_TEST_SESSION }) + + expect( + await call('session.tabs.close', { + worktree: WORKTREE, + tabId: `agent-session:${HOST_TEST_SESSION}`, + reason: 'user' + }) + ).toMatchObject({ ok: true }) + + expect(store.getSessionTabId(HOST_TEST_SESSION)).toBeNull() + expect(store.getSessionTabId(replacement)).toBe(SOURCE_TAB) + expect((await snapshot()).tabs.map((tab) => tab.id)).toEqual([`agent-session:${replacement}`]) + expect(await send(replacement, 'still here')).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it('puts a cleared chat back under the tab id it had when its close does not land', async () => { + await createChat(HOST_TEST_SESSION) + const replacement = await clear(HOST_TEST_SESSION) + closeSession.mockResolvedValue(false) + + const outcome = await closeStructuredAgentSessionChild(replacement) + expect(outcome).toMatchObject({ stopped: false }) + expect(store.getSessionTabId(replacement)).toBe(SOURCE_TAB) + closeSession.mockResolvedValue(true) + }) + + it('keeps the tab id and its pointer across a restart', async () => { + await createChat(HOST_TEST_SESSION) + const replacement = await clear(await clear(HOST_TEST_SESSION)) + await host.flushAllStreamedEvents() + + await openHost() + expect(store.getSessionTabId(replacement)).toBe(SOURCE_TAB) + expect(host.getPersistedVisibleSessionTabIndex()).toEqual({ + present: true, + sessionIds: [replacement] + }) + }) + + it('gives a reopened cleared conversation the same id when an older build drops the table', async () => { + await createChat(HOST_TEST_SESSION) + const first = await clear(HOST_TEST_SESSION) + await call('agentSession.reveal', { sessionId: HOST_TEST_SESSION }) + const current = await clear(first) + const reopenedTab = store.getSessionTabId(HOST_TEST_SESSION) + expect(reopenedTab).not.toBeNull() + await host.flushAllStreamedEvents() + + // An older build rewrites the file from what it read, which drops the table. + const file = agentSessionStorePath(join(directory, 'store')) + const raw = JSON.parse(await readFile(file, 'utf-8')) + expect(raw.sessionTabs).toHaveLength(2) + delete raw.sessionTabs + await writeFile(file, JSON.stringify(raw)) + + await openHost() + expect(store.getSessionTabId(current)).toBe(SOURCE_TAB) + expect(store.getSessionTabId(HOST_TEST_SESSION)).toBe(reopenedTab) + }) +}) + +describe('session tab mutations from other clients, unchanged by the table', () => { + it('reorders a group holding a chat for a paired client', async () => { + await createChat(HOST_TEST_SESSION) + await createChat('session-bravo') + const ids = (await snapshot()).tabs.map((tab) => tab.id) + const group = (await snapshot()).tabGroups![0]! + + expect( + await call( + 'session.tabs.move', + { + worktree: WORKTREE, + tabId: ids[1], + targetGroupId: group.id, + kind: 'reorder', + tabOrder: ids.toReversed() + }, + { + clientKind: 'runtime', + clientCapabilities: [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ] + } + ) + ).toMatchObject({ ok: true }) + expect((await snapshot()).tabGroups![0]!.tabOrder).toEqual(ids.toReversed()) + }) + + it('refuses an old mobile client closing a chat it may only view', async () => { + await createChat(HOST_TEST_SESSION) + + const response = await call( + 'session.tabs.close', + { worktree: WORKTREE, tabId: `agent-session:${HOST_TEST_SESSION}`, reason: 'user' }, + { clientKind: 'mobile', clientCapabilities: [] } + ) + + expect(response.ok).toBe(false) + expect(store.getSessionTabId(HOST_TEST_SESSION)).toBe(SOURCE_TAB) + expect((await snapshot()).tabs).toHaveLength(1) + }) +}) + +describe('a create that reserves its tab', () => { + it('answers with the reserved id and refuses a second chat under it', async () => { + expect(await createChat(HOST_TEST_SESSION, 'reserved-tab')).toMatchObject({ + ok: true, + value: { tabId: 'reserved-tab' } + }) + expect(await createChat('session-bravo', 'reserved-tab')).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_conflict' } + }) + expect(store.getSessionTabId(HOST_TEST_SESSION)).toBe('reserved-tab') + expect(store.getRecord('session-bravo')).toBeNull() + }) + + it('answers a create that reserved nothing with the id its tab was given', async () => { + expect(await createChat(HOST_TEST_SESSION)).toMatchObject({ + ok: true, + value: { tabId: SOURCE_TAB } + }) + }) + + it('restores no tab for a reserved create that stopped before its tab was published', async () => { + const attached = await host.attach( + caller, + hostTestAttachParams(null, { + envelope: { + sessionId: HOST_TEST_SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: null, + payloadFingerprint: '' + }, + surfaceTabId: 'reserved-tab' + }) + ) + expect(attached).toMatchObject({ ok: true }) + await host.flushAllStreamedEvents() + + await openHost() + expect(host.getPersistedVisibleSessionTabIndex().sessionIds).toEqual([]) + expect(await createChat('session-bravo', 'reserved-tab')).toMatchObject({ + ok: true, + value: { tabId: 'reserved-tab' } + }) + }) + + it('leaves no tab behind when the create fails, so nothing is restored and the id is free', async () => { + acquireFails = true + expect(await createChat(HOST_TEST_SESSION, 'reserved-tab')).toMatchObject({ ok: false }) + expect(store.getSessionTabId(HOST_TEST_SESSION)).toBeNull() + expect(store.listVisibleSessionIds()).toEqual([]) + + acquireFails = false + expect(await createChat('session-bravo', 'reserved-tab')).toMatchObject({ + ok: true, + value: { tabId: 'reserved-tab' } + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-session-tab-restore.ts b/src/main/runtime/rpc/methods/structured-session-tab-restore.ts index f9efa46d16d..6ec96241422 100644 --- a/src/main/runtime/rpc/methods/structured-session-tab-restore.ts +++ b/src/main/runtime/rpc/methods/structured-session-tab-restore.ts @@ -11,14 +11,16 @@ import { * project after a desktop restart — no chat and no prompt. The setting still gates it, because * with structured chat off there is nothing for any mobile client to reach. Restoring spawns no * provider child for a cleanly closed session. */ -export async function restoreStructuredTabsIfSupported( +export function restoreStructuredTabsIfSupported( context: Pick -): Promise { +): Promise | undefined { const shouldRestore = context.clientKind === 'mobile' ? isStructuredNativeChatEnabled(context.runtime) : supportsStructuredAgentSessions(context) if (shouldRestore && typeof context.runtime.restoreStructuredAgentSessionTabs === 'function') { - await context.runtime.restoreStructuredAgentSessionTabs() + return context.runtime.restoreStructuredAgentSessionTabs() } + // Nothing to restore: callers skip the await, so a stream's setup keeps its timing. + return undefined } diff --git a/src/main/runtime/rpc/methods/structured-worker-read-cursor.test.ts b/src/main/runtime/rpc/methods/structured-worker-read-cursor.test.ts index 1a97223bc39..b376f2c80d9 100644 --- a/src/main/runtime/rpc/methods/structured-worker-read-cursor.test.ts +++ b/src/main/runtime/rpc/methods/structured-worker-read-cursor.test.ts @@ -79,7 +79,7 @@ function read(cursor?: string, limit?: number) { }) } -function textsOf(result: ReturnType): string[] { +function textsOf(result: Awaited>): string[] { return result.transcript.messages.map((entry) => entry.blocks.map((block) => ('text' in block ? block.text : '')).join('') ) @@ -90,57 +90,57 @@ describe('the structured worker-read cursor over a mutating journal', () => { hostRef.current = null }) - it('refuses to resume when an already-delivered item was revised in place', () => { + it('refuses to resume when an already-delivered item was revised in place', async () => { // The `"hel"` / `"hello"` defect. The caller is handed a coalesced snapshot, resumes past it, // and the item is later revised at its original sequence — under the old anchor the resume was // accepted and that revision was never delivered to anyone. installJournal([message('i1', 'hel'), message('i2', 'second')]) - const first = read(undefined, 1) + const first = await read(undefined, 1) expect(textsOf(first)).toEqual(['hel']) installJournal([message('i1', 'hello world', 2), message('i2', 'second')]) - expect(() => read(first.cursor)).toThrow(/source changed/i) + await expect(read(first.cursor)).rejects.toThrow(/source changed/i) }) - it('refuses to resume when a resolved prompt inserts ahead of the caller position', () => { + it('refuses to resume when a resolved prompt inserts ahead of the caller position', async () => { // Duplication. A pending approval projects to null, so resolving it inserts a message in the // MIDDLE; the oldest item never moved, so the old anchor accepted a now-stale index and the // caller re-read content it already had. installJournal([message('i1', 'first'), approval('i2', false), message('i3', 'second')]) - const first = read(undefined, 2) + const first = await read(undefined, 2) expect(textsOf(first)).toEqual(['first', 'second']) installJournal([message('i1', 'first'), approval('i2', true, 2), message('i3', 'second')]) - expect(() => read(first.cursor)).toThrow(/source changed/i) + await expect(read(first.cursor)).rejects.toThrow(/source changed/i) }) - it('still resumes across a page boundary when only unread tail items change', () => { + it('still resumes across a page boundary when only unread tail items change', async () => { // The reason this is prefix-scoped and not whole-page: during an active turn the coalescer // revises the streaming item every 60ms. Fingerprinting the whole page would invalidate the // cursor continuously — a useless verb — while the worker is working. installJournal([message('i1', 'first'), message('i2', 'streaming')]) - const first = read(undefined, 1) + const first = await read(undefined, 1) expect(textsOf(first)).toEqual(['first']) installJournal([message('i1', 'first'), message('i2', 'streaming more', 7)]) - const second = read(first.cursor) + const second = await read(first.cursor) expect(textsOf(second)).toEqual(['streaming more']) }) - it('delivers every message exactly once when nothing below the cursor changes', () => { + it('delivers every message exactly once when nothing below the cursor changes', async () => { // The property the two refusals above protect: no omission, no duplication. installJournal([message('i1', 'a'), message('i2', 'b'), message('i3', 'c')]) - const first = read(undefined, 2) - const second = read(first.cursor, 2) + const first = await read(undefined, 2) + const second = await read(first.cursor, 2) expect([...textsOf(first), ...textsOf(second)]).toEqual(['a', 'b', 'c']) }) - it('still refuses when the window slides off the front', () => { + it('still refuses when the window slides off the front', async () => { // The case the old anchor DID catch, and which the prefix scoping must not lose: a slide // shifts every index. installJournal([message('i1', 'a'), message('i2', 'b')]) - const first = read(undefined, 1) + const first = await read(undefined, 1) installJournal([message('i2', 'b'), message('i3', 'c')]) - expect(() => read(first.cursor)).toThrow(/source changed/i) + await expect(read(first.cursor)).rejects.toThrow(/source changed/i) }) }) diff --git a/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts b/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts index e26788d5d46..59370934057 100644 --- a/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts +++ b/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts @@ -21,6 +21,7 @@ const METHOD_CASES: readonly (readonly [string, unknown, boolean])[] = [ ['terminal.agentStatus', { terminal: 'term' }, false], ['terminal.rename', { terminal: 'term', title: null }, false], ['terminal.clearBuffer', { terminal: 'term' }, false], + ['terminal.resetInputModes', { terminal: 'term' }, false], ['terminal.send', { terminal: 'term', text: 'x' }, false], ['terminal.wait', { terminal: 'term', for: 'exit' }, false], ['terminal.create', {}, false], @@ -67,11 +68,11 @@ async function invoke(name: string, params: unknown, runtime: Partial { it('preserves all method names, order, streaming flags, and parseable minimum inputs', () => { - expect(TERMINAL_METHODS).toHaveLength(35) + expect(TERMINAL_METHODS).toHaveLength(36) expect(TERMINAL_METHODS.map((method) => [method.name, 'stream' in method])).toEqual( METHOD_CASES.map(([name, _params, stream]) => [name, stream]) ) - expect(new Set(TERMINAL_METHODS.map((method) => method.name)).size).toBe(35) + expect(new Set(TERMINAL_METHODS.map((method) => method.name)).size).toBe(36) for (const [name, params] of METHOD_CASES) { expect(() => schemaFor(name).parse(params), name).not.toThrow() } diff --git a/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts b/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts index 7313eb82b94..59fd827f0ce 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts @@ -1,9 +1,5 @@ import { InvalidArgumentError } from '../../core' -import type { - DriverState, - OrcaRuntimeService, - SubscriptionRegistration -} from '../../../orca-runtime' +import type { DriverState, OrcaRuntimeService } from '../../../orca-runtime' import { TERMINAL_INPUT_MAX_BYTES, TERMINAL_INPUT_TOO_LARGE_ERROR, @@ -51,45 +47,6 @@ export function resolveMobileFloorClientId( export type TerminalStreamInputOutcome = 'delivered' | 'rejected' | 'failed' -export function watchSubscriptionLifetime( - runtime: OrcaRuntimeService, - ptyId: string, - signal: AbortSignal | undefined, - registration: SubscriptionRegistration -): () => void { - let unsubscribeExit: (() => void) | null = null - let removeAbort: (() => void) | null = null - let stopped = false - const stop = (): void => { - stopped = true - unsubscribeExit?.() - removeAbort?.() - } - const release = (): void => { - registration.releaseIfCurrent() - stop() - } - unsubscribeExit = runtime.subscribeToPtyExit(ptyId, release) - if (stopped) { - unsubscribeExit() - return stop - } - if (!signal) { - return stop - } - if (signal.aborted) { - release() - return stop - } - const onAbort = (): void => release() - removeAbort = () => signal.removeEventListener('abort', onAbort) - signal.addEventListener('abort', onAbort, { once: true }) - if (stopped) { - removeAbort() - } - return stop -} - export function isTerminalStreamInputRejection(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error) return message.includes('terminal_not_writable') || message.includes('terminal_handle_stale') @@ -109,10 +66,11 @@ export async function sendTerminalStreamInput( const floorClaim: MobileInputFloorClaimHolder = { current: null } try { if (!clientId) { - const result = await runtime.sendTerminal(args.terminal, action) + const result = await runtime.sendTerminal(args.terminal, action, { inputKind: 'driving' }) return result.accepted ? 'delivered' : 'rejected' } const result = await runtime.sendTerminal(args.terminal, action, { + inputKind: 'driving', reserveWrite: (writePtyId) => { const claim = runtime.beginMobileInputFloor(writePtyId, clientId) if (!claim) { diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts index ad4c7dd05da..a9bcf1af695 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts @@ -3,182 +3,115 @@ import type { TerminalOutputBatcher } from './terminal-output-batcher' import { isTerminalReadPayloadIncomplete } from './terminal-stream-replay' import { serializeBudgetedMobileSnapshot } from './terminal-snapshot-publication' import { updateViewportForClient } from './terminal-viewport-update' -import { watchSubscriptionLifetime } from './terminal-input-delivery' import type { TerminalSubscriptionArgs } from './terminal-legacy-subscription-types' import { allocateTerminalSubscriptionStreamId } from './terminal-subscription-stream-id' export async function runTerminalLeaseSubscription(args: TerminalSubscriptionArgs): Promise { - const { params, runtime, connectionId, signal, emit, ptyId, clientId } = args + const { registration, emit, ptyId, clientId } = args if (!clientId) { return } - let closed = false - let stopWatchingLifetime = (): void => {} let resolveStream = (): void => {} const streamClosed = new Promise((resolve) => { resolveStream = resolve }) - const subscriptionId = `${params.terminal}:${clientId}` + registration.setTeardown(resolveStream) // Why: chat needs the input-floor ack without registering a view subscriber or transporting duplicate PTY output. - const registration = runtime.registerOwnedSubscriptionCleanup( - subscriptionId, - () => { - stopWatchingLifetime() - closed = true - runtime.handleMobileUnsubscribe(ptyId, clientId) - emit({ type: 'end' }) - resolveStream() - }, - connectionId - ) - stopWatchingLifetime = watchSubscriptionLifetime(runtime, ptyId, signal, registration) - if (closed) { - // Why: an already-exited pty releases synchronously, so cleanup ran before this setup registers anything. + // A lease-only subscriber has no terminal view, so its cached viewport must never phone-fit the PTY. + await registration.addMobilePresence(ptyId, clientId, undefined) + if (registration.released) { return } - try { - // Why: a lease-only subscriber has no terminal view, so its cached viewport must never phone-fit the PTY. - await runtime.handleMobileSubscribe(ptyId, clientId, undefined) - if (closed || signal?.aborted) { - // Why: a disconnect can win the awaited subscribe and resurrect mobile presence after cleanup already released it. - // Unguarded on purpose: this must still fire when our own cleanup already ran. - // Safe only because lease-only passes no viewport and both !viewport paths in - // handleMobileSubscribeInternal return with no await, so no rebind can land - // first. Adding an await there — or passing a viewport here — makes a - // superseded handler delete the replacement's (ptyId, clientId) presence. - runtime.handleMobileUnsubscribe(ptyId, clientId) - if (!closed) { - registration.releaseIfCurrent() - } - return - } - emit({ type: 'subscribed', streamId: null, lines: [], truncated: false }) - await streamClosed - } catch (error) { - registration.releaseIfCurrent() - throw error - } + emit({ type: 'subscribed', streamId: null, lines: [], truncated: false }) + await streamClosed } export async function runTerminalJsonSubscription(args: TerminalSubscriptionArgs): Promise { - const { - params, - runtime, - connectionId, - signal, - emit, - ptyId, - clientId, - supportsDesktopViewportClaims - } = args + const { params, runtime, registration, emit, ptyId, clientId, supportsDesktopViewportClaims } = + args // Why: only unregister the width floor this subscription took (see the multiplex stream's registeredRemoteDesktopDriver note). let registeredRemoteDesktopDriver = false - - // Why: a hidden watcher and a visible pane can subscribe to one terminal, so key by client so neither stream evicts the other. - const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal const remoteDesktopSubscriptionKey = `json:${allocateTerminalSubscriptionStreamId()}` - let closed = false let outputBatcher: TerminalOutputBatcher | null = null let unsubscribeData = (): void => {} let unsubscribeFit = (): void => {} - let stopWatchingLifetime = (): void => {} let resolveStream = (): void => {} const streamClosed = new Promise((resolve) => { resolveStream = resolve }) - // Why: register before viewport/snapshot awaits so a socket close can't orphan the stream listeners or its remote-desktop width floor. - const registration = runtime.registerOwnedSubscriptionCleanup( - subscriptionId, - () => { - stopWatchingLifetime() - closed = true - outputBatcher?.flush() - outputBatcher?.dispose() - unsubscribeData() - unsubscribeFit() - if (registeredRemoteDesktopDriver && clientId) { - runtime.unregisterRemoteDesktopViewer(ptyId, remoteDesktopSubscriptionKey) - } - emit({ type: 'end' }) - resolveStream() - }, - connectionId - ) - stopWatchingLifetime = watchSubscriptionLifetime(runtime, ptyId, signal, registration) - if (closed) { - // Why: an already-exited pty releases synchronously, so cleanup ran before this setup registers anything. + registration.setTeardown(() => { + outputBatcher?.flush() + outputBatcher?.dispose() + unsubscribeData() + unsubscribeFit() + if (registeredRemoteDesktopDriver && clientId) { + runtime.unregisterRemoteDesktopViewer(ptyId, remoteDesktopSubscriptionKey) + } + resolveStream() + }) + if (clientId && params.client && params.viewport) { + registeredRemoteDesktopDriver = true + await updateViewportForClient( + runtime, + ptyId, + remoteDesktopSubscriptionKey, + params.client, + params.viewport, + 'desktop', + 'register', + !supportsDesktopViewportClaims + ) + } + if (registration.released) { return } - try { - if (clientId && params.client && params.viewport) { - registeredRemoteDesktopDriver = true - await updateViewportForClient( - runtime, - ptyId, - remoteDesktopSubscriptionKey, - params.client, - params.viewport, - 'desktop', - 'register', - !supportsDesktopViewportClaims - ) - } - if (closed || signal?.aborted) { - registration.releaseIfCurrent() - return - } - const read = await runtime.readTerminal(params.terminal) - const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, false) - if (closed || signal?.aborted) { - registration.releaseIfCurrent() - return - } - const size = runtime.getTerminalSize(ptyId) - const displayMode = runtime.getMobileDisplayMode(ptyId) - const seq = runtime.getLayout(ptyId)?.seq - emit({ - type: 'scrollback', - lines: read.tail, - truncated: isTerminalReadPayloadIncomplete(read), - serialized: serialized?.data, - oscLinks: serialized?.oscLinks, - cwd: serialized?.cwd, - // Why: an empty snapshot with no PTY size must still report the dims the fit - // will produce — dimless frames re-armed the mobile fit loop (STA-3337). - cols: serialized?.cols ?? size?.cols ?? params.viewport?.cols, - rows: serialized?.rows ?? size?.rows ?? params.viewport?.rows, - displayMode, - seq - }) - outputBatcher = createTerminalOutputBatcher((chunk) => { - emit({ type: 'data', chunk }) - }) - const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data) => { - outputBatcher?.push(data) - }) - // Why: the legacy JSON stream can feed a live xterm view, so register as a view subscriber; worst case is a withheld model reply, safer than a double reply. - const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) - unsubscribeData = () => { - releaseViewSubscriber() - unsubscribeStreamData() - } - unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => { - outputBatcher?.flush() - const mode = - event.mode === 'mobile-fit' - ? event.mode - : (runtime.getRemoteDesktopFitHold?.(ptyId, remoteDesktopSubscriptionKey).mode ?? - 'desktop-fit') - emit({ - type: 'fit-override-changed', - mode, - cols: event.cols, - rows: event.rows - }) - }) - await streamClosed - } catch (error) { - registration.releaseIfCurrent() - throw error + const read = await runtime.readTerminal(params.terminal) + const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, false) + if (registration.released) { + return } + const size = runtime.getTerminalSize(ptyId) + const displayMode = runtime.getMobileDisplayMode(ptyId) + const seq = runtime.getLayout(ptyId)?.seq + emit({ + type: 'scrollback', + lines: read.tail, + truncated: isTerminalReadPayloadIncomplete(read), + serialized: serialized?.data, + oscLinks: serialized?.oscLinks, + cwd: serialized?.cwd, + // Why: an empty snapshot with no PTY size must still report the dims the fit + // will produce — dimless frames re-armed the mobile fit loop (STA-3337). + cols: serialized?.cols ?? size?.cols ?? params.viewport?.cols, + rows: serialized?.rows ?? size?.rows ?? params.viewport?.rows, + displayMode, + seq + }) + outputBatcher = createTerminalOutputBatcher((chunk) => { + emit({ type: 'data', chunk }) + }) + const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data) => { + outputBatcher?.push(data) + }) + // Why: the legacy JSON stream can feed a live xterm view, so register as a view subscriber; worst case is a withheld model reply, safer than a double reply. + const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) + unsubscribeData = () => { + releaseViewSubscriber() + unsubscribeStreamData() + } + unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => { + outputBatcher?.flush() + const mode = + event.mode === 'mobile-fit' + ? event.mode + : (runtime.getRemoteDesktopFitHold?.(ptyId, remoteDesktopSubscriptionKey).mode ?? + 'desktop-fit') + emit({ + type: 'fit-override-changed', + mode, + cols: event.cols, + rows: event.rows + }) + }) + await streamClosed } diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-binary.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-binary.ts index 08639f16f48..35d40a2d948 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-binary.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-binary.ts @@ -13,7 +13,6 @@ import { import { TERMINAL_MULTIPLEX_PENDING_MAX_BYTES } from '../../../../../shared/terminal-multiplex-flow-control' import { measureTerminalStreamByteLength } from '../../terminal-stream-byte-length' import { createTerminalOutputBatcher, type TerminalOutputBatcher } from './terminal-output-batcher' -import { watchSubscriptionLifetime } from './terminal-input-delivery' import { trimPendingOutputToBudget } from './terminal-stream-replay' import { allocateTerminalSubscriptionStreamId } from './terminal-subscription-stream-id' import type { @@ -26,8 +25,7 @@ import { activateLegacyBinarySubscription } from './terminal-legacy-subscribe-li import { registerLegacyBinaryControlFrames } from './terminal-legacy-binary-control-frames' const TERMINAL_QUERY_REPLAY_MAX_CHARS = 16 * 1024 export async function runTerminalBinarySubscription(args: TerminalSubscriptionArgs): Promise { - const { params, runtime, connectionId, sendBinary, signal, emit, ptyId, clientId, isMobile } = - args + const { runtime, registration, sendBinary, ptyId, clientId, isMobile } = args if (!sendBinary) { throw new Error('binary_terminal_stream_required') } @@ -53,40 +51,28 @@ export async function runTerminalBinarySubscription(args: TerminalSubscriptionAr let unsubscribeFit = (): void => {} let unregisterBinaryHandler = (): void => {} let abortRendererMountWait = (): void => {} - let stopWatchingLifetime = (): void => {} let lateRendererReadyPromise: Promise | null = null let outputBatcher: TerminalOutputBatcher | null = null let resolveStream = (): void => {} const streamClosed = new Promise((resolve) => { resolveStream = resolve }) - // Why: register cleanup before any await so a mid-subscribe disconnect still removes mobile presence; client-scoped ids also allow parallel desktop subscribers. - const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal - const registration = runtime.registerOwnedSubscriptionCleanup( - subscriptionId, - () => { - stopWatchingLifetime() - outputBatcher?.flush() - outputBatcher?.dispose() - closed = true - unsubscribeData() - unsubscribeResize() - unsubscribeFit() - unregisterBinaryHandler() - abortRendererMountWait() - if (isMobile && clientId) { - runtime.handleMobileUnsubscribe(ptyId, clientId) - } else if (registeredRemoteDesktopDriver && clientId) { - runtime.unregisterRemoteDesktopViewer(ptyId, remoteDesktopSubscriptionKey) - } - emit({ type: 'end' }) - resolveStream() - }, - connectionId - ) - stopWatchingLifetime = watchSubscriptionLifetime(runtime, ptyId, signal, registration) - if (closed) { - // Why: an already-exited pty releases synchronously, so cleanup ran before this setup registers anything. + registration.setTeardown(() => { + outputBatcher?.flush() + outputBatcher?.dispose() + closed = true + unsubscribeData() + unsubscribeResize() + unsubscribeFit() + unregisterBinaryHandler() + abortRendererMountWait() + // Why: phone presence belongs to the registration; only a desktop viewer owns a width floor here. + if (!isMobile && registeredRemoteDesktopDriver && clientId) { + runtime.unregisterRemoteDesktopViewer(ptyId, remoteDesktopSubscriptionKey) + } + resolveStream() + }) + if (registration.released) { return } const sendFrame = ( @@ -268,19 +254,13 @@ export async function runTerminalBinarySubscription(args: TerminalSubscriptionAr registeredRemoteDesktopDriver = value }, displayMode: 'auto', - registration, streamClosed, sendFrame } - try { - await publishLegacyBinaryInitialSnapshot(args, state) - if (state.closed || signal?.aborted) { - return - } - activateLegacyBinarySubscription(args, state) - } catch (error) { - registration.releaseIfCurrent() - throw error + await publishLegacyBinaryInitialSnapshot(args, state) + if (registration.released) { + return } + activateLegacyBinarySubscription(args, state) await streamClosed } diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts index 7b9d45d144c..5202804c0d5 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts @@ -24,7 +24,7 @@ export async function publishLegacyBinaryInitialSnapshot( const { params, runtime, - signal, + registration, sendBinary, emit, ptyId, @@ -34,8 +34,9 @@ export async function publishLegacyBinaryInitialSnapshot( rendererMountRequestedBeforePty, serializerGenerationBeforeMobileFit } = args + const { signal } = registration if (isMobile && clientId) { - await runtime.handleMobileSubscribe(ptyId, clientId, params.viewport) + await registration.addMobilePresence(ptyId, clientId, params.viewport) } else if (clientId && params.viewport) { // Why: legacy subscribe records geometry without taking ownership; only an explicit activity/claim frame may suppress the host. state.registeredRemoteDesktopDriver = true @@ -60,20 +61,28 @@ export async function publishLegacyBinaryInitialSnapshot( if (state.closed) { return } - // Why: missing model state (not blank snapshot text) signals a never-attached PTY; a renderer-sourced snapshot already proves attachment, so skip the remount. + // Why: missing model state (not blank snapshot text) signals a never-attached PTY; any renderer answer proves a pane, and the answer is null when the host's flag is unset or stale after a pane closed over a live PTY, which falls back to the mount wait. + const needsRendererScreen = + missingHeadlessStateBeforeMobileFit && serialized?.source !== 'renderer' + let rendererReady = + needsRendererScreen && + (await runtime.serializeRendererTerminalBuffer(ptyId, { scrollbackRows: 0 })) !== null + if (state.closed) { + return + } const mountRequested = - missingHeadlessStateBeforeMobileFit && - serialized?.source !== 'renderer' && + needsRendererScreen && + !rendererReady && (rendererMountRequestedBeforePty || runtime.requestRendererTerminalTabMount(params.terminal)) - if (missingHeadlessStateBeforeMobileFit && mountRequested) { + if (mountRequested) { // Why: an idle legacy PTY emits no later byte, so wait for a settle proving this remount completed before replaying its screen. const mountWaitController = new AbortController() const abortMountWait = (): void => mountWaitController.abort() state.abortRendererMountWait = abortMountWait - if (signal?.aborted) { + if (signal.aborted) { abortMountWait() } else { - signal?.addEventListener('abort', abortMountWait, { once: true }) + signal.addEventListener('abort', abortMountWait, { once: true }) } const rendererReadyPromise = runtime .waitForRendererTerminalSerializer( @@ -84,7 +93,7 @@ export async function publishLegacyBinaryInitialSnapshot( ) .catch(() => false) const finishMountWait = (): void => { - signal?.removeEventListener('abort', abortMountWait) + signal.removeEventListener('abort', abortMountWait) if (state.abortRendererMountWait === abortMountWait) { state.abortRendererMountWait = () => {} } @@ -97,43 +106,49 @@ export async function publishLegacyBinaryInitialSnapshot( deadlineTimer.unref() } }) - const rendererReady = await Promise.race([rendererReadyPromise, initialDeadline]) + rendererReady = await Promise.race([rendererReadyPromise, initialDeadline]) if (deadlineTimer) { clearTimeout(deadlineTimer) } - if (state.closed || signal?.aborted) { + if (state.closed || signal.aborted) { return } - if (rendererReady) { - read = await runtime.readTerminal(params.terminal) - const stableRendererSnapshot = await serializeStableMobileRendererSnapshot( - runtime, - ptyId, - // The same frame, because this snapshot is published by the scrollback send below rather - // than by one of its own: the `resized` it used to name is a frame nothing here sends. - mobileSnapshotByteBudget(params.snapshotByteBudget, state.streamId, scrollbackFrame) - ) - if (state.closed) { - return - } - if (stableRendererSnapshot?.data.length) { - serialized = stableRendererSnapshot - const trailingOutput = state.pendingOutput.flatMap((item) => { - const output = getOutputAfterSnapshotSeq(item, stableRendererSnapshot.seq) - const seq = item.meta?.seq - return output && typeof seq === 'number' ? [{ data: output.data, seq }] : [] - }) - runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery( - ptyId, - stableRendererSnapshot, - trailingOutput - ) - } - } else { + if (!rendererReady) { // Why: a renderer can settle after the bounded initial response; keep observing so an idle PTY self-heals without bytes. state.lateRendererReadyPromise = rendererReadyPromise } } + if (rendererReady) { + read = await runtime.readTerminal(params.terminal) + const stableRendererSnapshot = await serializeStableMobileRendererSnapshot( + runtime, + ptyId, + // The same frame, because this snapshot is published by the scrollback send below rather + // than by one of its own: the `resized` it used to name is a frame nothing here sends. + mobileSnapshotByteBudget(params.snapshotByteBudget, state.streamId, scrollbackFrame) + ) + if (state.closed) { + return + } + // Why: a blank screen may be a parked pane that has not hydrated, and a seq-less screen has no + // seam against buffered output, so it is safe only when nothing is pending to replay twice. + if ( + stableRendererSnapshot?.data.length && + (typeof stableRendererSnapshot.seq === 'number' || state.pendingOutput.length === 0) + ) { + serialized = stableRendererSnapshot + const trailingOutput = state.pendingOutput.flatMap((item) => { + const output = getOutputAfterSnapshotSeq(item, stableRendererSnapshot.seq) + const seq = item.meta?.seq + return output && typeof seq === 'number' ? [{ data: output.data, seq }] : [] + }) + runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery( + ptyId, + stableRendererSnapshot, + trailingOutput + ) + } + } let initialOutputOverflowed = false if (state.pendingOutputOverflowed) { state.pendingOutput.splice(0) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscription-types.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscription-types.ts index d9eb624f0e1..595a65a5eeb 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscription-types.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscription-types.ts @@ -1,11 +1,11 @@ import type { z } from 'zod' import type { RpcContext } from '../../core' -import type { SubscriptionRegistration } from '../../../orca-runtime' import type { TerminalReplyQuerySequence } from '../../../../../shared/terminal-reply-query-scan' import type { TerminalOutputChunk } from './terminal-stream-types' import type { TerminalSubscribe } from './stream-schemas' import type { TerminalOutputBatcher } from './terminal-output-batcher' import type { TerminalStreamOpcode } from '../../../../../shared/terminal-stream-protocol' +import type { TerminalSubscriptionRegistration } from './terminal-subscription-registration' export type TerminalSubscribeParams = z.infer export type TerminalSubscriptionEmit = (result: unknown) => void @@ -13,10 +13,9 @@ export type TerminalSubscriptionEmit = (result: unknown) => void export type TerminalSubscriptionArgs = { params: TerminalSubscribeParams runtime: RpcContext['runtime'] - connectionId: RpcContext['connectionId'] + registration: TerminalSubscriptionRegistration sendBinary: RpcContext['sendBinary'] registerBinaryStreamHandler: RpcContext['registerBinaryStreamHandler'] - signal: RpcContext['signal'] emit: TerminalSubscriptionEmit ptyId: string clientId: string | undefined @@ -48,7 +47,6 @@ export type LegacyBinarySubscriptionState = { unsubscribeFit: () => void registeredRemoteDesktopDriver: boolean displayMode: string - readonly registration: SubscriptionRegistration readonly streamClosed: Promise readonly sendFrame: ( opcode: TerminalStreamOpcode, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts index 52c1063b0cc..68afc1e8d19 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts @@ -121,5 +121,12 @@ export const TERMINAL_QUERY_METHODS = [ handler: async (params, { runtime }) => ({ clear: await runtime.clearTerminalBuffer(params.terminal) }) + }), + defineMethod({ + name: 'terminal.resetInputModes', + params: TerminalHandle, + handler: async (params, { runtime }) => ({ + reset: await runtime.resetTerminalInputModes(params.terminal) + }) }) ] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts index 99c01d902a4..ca7ac0aeb75 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts @@ -210,6 +210,7 @@ export const TERMINAL_SEND_METHODS = [ try { result = useSettledAgentPrompt ? await runtime.sendTerminalAgentPrompt(params.terminal, params.text!, { + inputKind: 'driving', beforeWrite, signal, ...(orchestrationMutation @@ -234,6 +235,8 @@ export const TERMINAL_SEND_METHODS = [ { beforeWrite, signal, + // Why: a wire write carries no provenance beyond a client's own query reply. + inputKind: params.inputKind === 'query-reply' ? 'query-reply' : 'driving', ...(reserveWrite ? { reserveWrite } : {}), ...(params.inputKind !== 'query-reply' && mobileFloorClientId ? { afterWrite: () => commitMobileInputFloorClaim(mobileFloorClaim) } diff --git a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts index 7572d41496f..6fa2c936c0c 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts @@ -7,6 +7,7 @@ import { runTerminalLeaseSubscription } from './terminal-legacy-simple-subscriptions' import type { TerminalSubscriptionArgs } from './terminal-legacy-subscription-types' +import { registerTerminalSubscription } from './terminal-subscription-registration' export const TERMINAL_SUBSCRIBE_METHODS = [ // Streams live terminal output over WebSocket; mobile clients pass client+viewport for server-side auto-fit. @@ -15,81 +16,106 @@ export const TERMINAL_SUBSCRIBE_METHODS = [ params: TerminalSubscribe, handler: async ( params, - { runtime, connectionId, sendBinary, registerBinaryStreamHandler, signal }, + { runtime, connectionId, requestId, sendBinary, registerBinaryStreamHandler, signal }, emit ) => { - let leaf = runtime.resolveLeafForHandle(params.terminal) const isMobile = params.client?.type === 'mobile' + const useBinaryStream = params.capabilities?.terminalBinaryStream === 1 && Boolean(sendBinary) + // Why: validated before registering, so a request that can never stream can't evict the slot's live stream. + if (isMobile && !useBinaryStream) { + throw new Error('binary_terminal_stream_required') + } + if (signal?.aborted) { + return + } + let leaf = runtime.resolveLeafForHandle(params.terminal) const serializerGenerationBeforeAnyMount = isMobile ? (runtime.getRendererTerminalSerializerGenerationForHandle?.(params.terminal) ?? 0) : 0 let rendererMountRequestedBeforePty = false - const useBinaryStream = params.capabilities?.terminalBinaryStream === 1 && Boolean(sendBinary) - if (signal?.aborted) { - return - } - - if (!leaf?.ptyId && params.client) { - rendererMountRequestedBeforePty = runtime.requestRendererTerminalTabMount(params.terminal) - try { - const ptyId = await runtime.waitForLeafPtyId(params.terminal, 10_000, signal) - leaf = { ptyId } - } catch { - if (signal?.aborted) { + const clientId = params.client?.id + // Why: register before the pty wait so an unsubscribe, a same-slot replacement or a closed socket can end a pending stream. + // Client-scoped keys also let a hidden watcher and a visible pane subscribe to one terminal. + const registration = registerTerminalSubscription({ + runtime, + subscriptionId: clientId ? `${params.terminal}:${clientId}` : params.terminal, + connectionId, + requestId, + requestSignal: signal, + emit + }) + try { + if (!leaf?.ptyId && params.client) { + rendererMountRequestedBeforePty = runtime.requestRendererTerminalTabMount(params.terminal) + const ptyId = await runtime + .waitForLeafPtyId(params.terminal, 10_000, registration.signal) + .catch(() => null) + if (registration.released) { return } + leaf = { ptyId } + } + if (!leaf?.ptyId) { + const read = await runtime.readTerminal(params.terminal) + if (registration.released) { + return + } + emit({ + type: 'subscribed', + streamId: null, + lines: read.tail, + truncated: isTerminalReadPayloadIncomplete(read) + }) + return } - } - if (!leaf?.ptyId) { - const read = await runtime.readTerminal(params.terminal) - emit({ - type: 'subscribed', - streamId: null, - lines: read.tail, - truncated: isTerminalReadPayloadIncomplete(read) - }) - emit({ type: 'end' }) - return - } - if (isMobile && (!useBinaryStream || !sendBinary)) { - throw new Error('binary_terminal_stream_required') - } - const ptyId = leaf.ptyId - const clientId = params.client?.id - const missingHeadlessStateBeforeMobileFit = - isMobile && - (rendererMountRequestedBeforePty || runtime.hasHeadlessTerminalState?.(ptyId) === false) - const args: TerminalSubscriptionArgs = { - params, - runtime, - connectionId, - sendBinary, - registerBinaryStreamHandler, - signal, - emit, - ptyId, - clientId, - isMobile, - supportsDesktopViewportClaims: params.capabilities?.desktopViewportClaims === 1, - supportsWriteUnavailable: params.capabilities?.writeUnavailable === 1, - rendererMountRequestedBeforePty, - missingHeadlessStateBeforeMobileFit, - serializerGenerationBeforeMobileFit: missingHeadlessStateBeforeMobileFit - ? rendererMountRequestedBeforePty - ? serializerGenerationBeforeAnyMount - : runtime.getRendererTerminalSerializerGeneration(ptyId) - : 0 + const ptyId = leaf.ptyId + registration.releaseOnPtyExit(ptyId) + if (registration.released) { + return + } + const missingHeadlessStateBeforeMobileFit = + isMobile && + (rendererMountRequestedBeforePty || runtime.hasHeadlessTerminalState?.(ptyId) === false) + const args: TerminalSubscriptionArgs = { + params, + runtime, + registration, + sendBinary, + registerBinaryStreamHandler, + emit, + ptyId, + clientId, + isMobile, + supportsDesktopViewportClaims: params.capabilities?.desktopViewportClaims === 1, + supportsWriteUnavailable: params.capabilities?.writeUnavailable === 1, + rendererMountRequestedBeforePty, + missingHeadlessStateBeforeMobileFit, + serializerGenerationBeforeMobileFit: missingHeadlessStateBeforeMobileFit + ? rendererMountRequestedBeforePty + ? serializerGenerationBeforeAnyMount + : runtime.getRendererTerminalSerializerGeneration(ptyId) + : 0 + } + if (isMobile && params.capabilities?.mobileInputLeaseOnly === 1 && Boolean(clientId)) { + await runTerminalLeaseSubscription(args) + return + } + if (!useBinaryStream) { + await runTerminalJsonSubscription(args) + return + } + await runTerminalBinarySubscription(args) + } catch (error) { + // Why: a released stream already sent `end`; an error frame after it would contradict it. + if (registration.released) { + return + } + registration.releaseSilently() + throw error + } finally { + registration.release() } - if (isMobile && params.capabilities?.mobileInputLeaseOnly === 1 && Boolean(clientId)) { - await runTerminalLeaseSubscription(args) - return - } - if (!useBinaryStream) { - await runTerminalJsonSubscription(args) - return - } - await runTerminalBinarySubscription(args) } }) ] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-subscription-registration.test.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscription-registration.test.ts new file mode 100644 index 00000000000..1af456dfdd4 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscription-registration.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from 'vitest' +import { RuntimeSubscriptionRegistry } from '../../../runtime-subscription-registry' +import { registerTerminalSubscription } from './terminal-subscription-registration' + +const SUBSCRIPTION_ID = 'terminal-1:phone-1' + +function createRegistration( + requestSignal?: AbortSignal, + registry = new RuntimeSubscriptionRegistry() +) { + const runtime = { + registerOwnedSubscriptionCleanup: registry.registerOwned.bind(registry), + subscribeToPtyExit: vi.fn((_ptyId: string, _listener: () => void) => vi.fn()), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn() + } + const emit = vi.fn() + const registration = registerTerminalSubscription({ + runtime, + subscriptionId: SUBSCRIPTION_ID, + connectionId: 'conn-a', + requestId: 'req-1', + requestSignal, + emit + }) + return { registry, runtime, emit, registration } +} + +describe('terminal subscription registration', () => { + it('runs its teardown once when the teardown re-enters release', () => { + const { registry, emit, registration } = createRegistration() + const teardown = vi.fn(() => { + registration.release() + registry.cleanup(SUBSCRIPTION_ID) + }) + registration.setTeardown(teardown) + + registration.release() + + expect(teardown).toHaveBeenCalledOnce() + expect(emit.mock.calls).toEqual([[{ type: 'end' }]]) + expect(registration.signal.aborted).toBe(true) + }) + + it('runs a teardown set after release immediately', () => { + const { registration } = createRegistration() + registration.release() + const teardown = vi.fn() + + registration.setTeardown(teardown) + + expect(teardown).toHaveBeenCalledOnce() + }) + + it('is released on arrival, without taking the slot, when the request signal is already aborted', () => { + const registry = new RuntimeSubscriptionRegistry() + const liveCleanup = vi.fn() + registry.registerOwned(SUBSCRIPTION_ID, liveCleanup, 'conn-a') + const request = new AbortController() + request.abort() + const { emit, registration } = createRegistration(request.signal, registry) + + expect(registration.released).toBe(true) + expect(registration.signal.aborted).toBe(true) + expect(emit).not.toHaveBeenCalled() + expect(liveCleanup).not.toHaveBeenCalled() + expect(registry.cleanupIfOwnedByConnection(SUBSCRIPTION_ID, 'conn-a')).toBe(true) + expect(liveCleanup).toHaveBeenCalledOnce() + }) + + it('removes phone presence and aborts even when the teardown throws', async () => { + const { runtime, registration } = createRegistration() + void registration.addMobilePresence('pty-1', 'phone-1', undefined) + registration.setTeardown(() => { + throw new Error('flush_failed') + }) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + + registration.release() + + expect(runtime.handleMobileUnsubscribe).toHaveBeenCalledWith('pty-1', 'phone-1') + expect(registration.signal.aborted).toBe(true) + await vi.waitFor(() => expect(error).toHaveBeenCalled()) + error.mockRestore() + }) + + it('adds no presence after release', async () => { + const { runtime, registration } = createRegistration() + registration.release() + + await registration.addMobilePresence('pty-1', 'phone-1', undefined) + + expect(runtime.handleMobileSubscribe).not.toHaveBeenCalled() + expect(runtime.handleMobileUnsubscribe).not.toHaveBeenCalled() + }) + + it('releases silently without end', () => { + const { emit, registration } = createRegistration() + + registration.releaseSilently() + registration.release() + + expect(registration.released).toBe(true) + expect(emit).not.toHaveBeenCalled() + }) + + it('stops watching for pty exit when released, and releases on exit', () => { + const { runtime, emit, registration } = createRegistration() + let onExit = (): void => {} + const stopWatching = vi.fn() + runtime.subscribeToPtyExit.mockImplementation((_ptyId: string, listener: () => void) => { + onExit = listener + return stopWatching + }) + registration.releaseOnPtyExit('pty-1') + + onExit() + + expect(registration.released).toBe(true) + expect(stopWatching).toHaveBeenCalledOnce() + expect(emit.mock.calls).toEqual([[{ type: 'end' }]]) + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-subscription-registration.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscription-registration.ts new file mode 100644 index 00000000000..235088b2048 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscription-registration.ts @@ -0,0 +1,141 @@ +import type { OrcaRuntimeService } from '../../../orca-runtime' + +type RegistrationRuntime = Pick< + OrcaRuntimeService, + | 'registerOwnedSubscriptionCleanup' + | 'subscribeToPtyExit' + | 'handleMobileSubscribe' + | 'handleMobileUnsubscribe' +> + +/** One `terminal.subscribe` request, addressable from admission until it is released. */ +export type TerminalSubscriptionRegistration = { + readonly released: boolean + /** Aborted once release has torn everything down. */ + readonly signal: AbortSignal + /** Runs the stream's teardown on release, or now if the registration was already released. */ + setTeardown(teardown: () => void): void + releaseOnPtyExit(ptyId: string): void + addMobilePresence( + ptyId: string, + clientId: string, + viewport: { cols: number; rows: number } | undefined + ): Promise + /** Releases and ends the stream. */ + release(): void + /** Releases without `end`, for a handler about to fail the request instead. */ + releaseSilently(): void +} + +export function registerTerminalSubscription({ + runtime, + subscriptionId, + connectionId, + requestId, + requestSignal, + emit +}: { + runtime: RegistrationRuntime + subscriptionId: string + connectionId: string | undefined + /** With `connectionId`, lets `terminal.unsubscribe{requestId}` address this exact request. */ + requestId: string | undefined + requestSignal: AbortSignal | undefined + emit: (result: unknown) => void +}): TerminalSubscriptionRegistration { + const controller = new AbortController() + let released = false + let ended = false + let teardown: (() => void) | null = null + let stopWatchingExit = (): void => {} + let presence: { ptyId: string; clientId: string } | null = null + + const end = (): void => { + if (ended) { + return + } + ended = true + emit({ type: 'end' }) + } + const releaseOnce = (): void => { + // Why: the registry calls cleanup before recording it as in flight, so a re-entrant release must be a no-op. + if (released) { + return + } + released = true + requestSignal?.removeEventListener('abort', onRequestAbort) + try { + try { + stopWatchingExit() + teardown?.() + } finally { + // Why: the latch makes any retry a no-op, so a throwing teardown must not strand phone presence. + if (presence) { + runtime.handleMobileUnsubscribe(presence.ptyId, presence.clientId) + } + } + } finally { + controller.abort() + } + } + // Why: a closed socket hands out a pre-aborted signal; registering would evict the slot's live stream for a dead request. + const registryEntry = requestSignal?.aborted + ? null + : runtime.registerOwnedSubscriptionCleanup( + subscriptionId, + () => { + try { + releaseOnce() + } finally { + end() + } + }, + connectionId, + requestId + ) + // Why: route through the registry so the entry leaves with the release and a teardown error is contained there. + const release = (): void => registryEntry?.releaseIfCurrent() + const onRequestAbort = (): void => release() + + const registration: TerminalSubscriptionRegistration = { + get released() { + return released + }, + signal: controller.signal, + setTeardown(nextTeardown) { + if (released) { + nextTeardown() + return + } + teardown = nextTeardown + }, + releaseOnPtyExit(ptyId) { + const unsubscribe = runtime.subscribeToPtyExit(ptyId, release) + if (released) { + // Why: an already-exited pty releases synchronously, before the unsubscribe exists to stop. + unsubscribe() + return + } + stopWatchingExit = unsubscribe + }, + async addMobilePresence(ptyId, clientId, viewport) { + if (released) { + return + } + // Why: presence and the driver are set before the layout await, so a release during it must remove them. + presence = { ptyId, clientId } + await runtime.handleMobileSubscribe(ptyId, clientId, viewport) + }, + release, + releaseSilently() { + ended = true + release() + } + } + if (registryEntry) { + requestSignal?.addEventListener('abort', onRequestAbort, { once: true }) + } else { + releaseOnce() + } + return registration +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts index da1121c89df..b977c113668 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts @@ -83,6 +83,12 @@ export const TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS = [ name: 'terminal.unsubscribe', params: TerminalUnsubscribe, handler: async (params, { runtime, connectionId, subscriptionRegistrationVersion }) => { + if (params.requestId !== undefined) { + // Why: an unknown request already ended or never registered; falling back to the slot could end a newer stream. + runtime.releaseSubscriptionByRequest(connectionId, params.requestId) + return { unsubscribed: true } + } + // COMPAT(terminal request-addressed unsubscribe): slot path for phones that predate `requestId`. // Fence both socket replacement and a newer subscription on the same socket. let unsubscribed = runtime.cleanupSubscriptionIfOwnedByConnection( params.subscriptionId, diff --git a/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts b/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts index a8fe8ca00c7..5f077b9d204 100644 --- a/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts +++ b/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts @@ -9,6 +9,7 @@ import type { AssertNoMissingKeys, AssertNoMissingValues } from './ui-state-sche // strict schema is deliberate — but it must stay deliberate rather than // forgotten, which is what the parity assertion below enforces. type MainOwnedUIState = + | '_explorerDisplayRootMigrated' | 'trayMinimizeNoticeShown' | 'dashboardPopoutBounds' | '_expandedWorktreeCardPropertiesDefaulted' diff --git a/src/main/runtime/rpc/methods/worktree-create-args.ts b/src/main/runtime/rpc/methods/worktree-create-args.ts index 932659758bb..c05dc906f40 100644 --- a/src/main/runtime/rpc/methods/worktree-create-args.ts +++ b/src/main/runtime/rpc/methods/worktree-create-args.ts @@ -78,6 +78,7 @@ export function buildManagedWorktreeCreateArgs( : undefined, ...(params.startupAgent ? { startupAgent: params.startupAgent } : {}), ...(params.startupPrompt !== undefined ? { startupPrompt: params.startupPrompt } : {}), + ...(params.launchSource ? { startupLaunchSource: params.launchSource } : {}), startupDraft: params.startupDraft, lineage: { parentWorkspace: params.parentWorkspace, diff --git a/src/main/runtime/rpc/methods/worktree-create-launch-source.test.ts b/src/main/runtime/rpc/methods/worktree-create-launch-source.test.ts new file mode 100644 index 00000000000..2cfd6c3de92 --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-create-launch-source.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { WORKTREE_METHODS } from './worktree' + +const repo = { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 1, + kind: 'git' as const +} + +describe('worktree.create launch source', () => { + it('hands the surface that asked for a startup agent to the runtime', async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture implements every runtime method worktree.create reaches for a create with no provenance request. + const runtime = { + getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: (_repo: string, _id: string | undefined, run: () => Promise) => + run(), + showRepo: vi.fn().mockResolvedValue(repo), + createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + // An open label: a surface this host has never heard of must not refuse the create. + for (const launchSource of ['cli', 'a_surface_added_later']) { + const response = await dispatcher.dispatch({ + id: 'req-1', + authToken: 'tok', + method: 'worktree.create', + params: { repo: 'repo-1', name: 'agent-startup', startupAgent: 'codex', launchSource } + }) + expect(response).toMatchObject({ ok: true }) + expect(runtime.createManagedWorktree).toHaveBeenLastCalledWith( + expect.objectContaining({ startupAgent: 'codex', startupLaunchSource: launchSource }) + ) + } + }) +}) diff --git a/src/main/runtime/rpc/orchestration-mutation-executor.ts b/src/main/runtime/rpc/orchestration-mutation-executor.ts index e6ad7952d8c..010541357f5 100644 --- a/src/main/runtime/rpc/orchestration-mutation-executor.ts +++ b/src/main/runtime/rpc/orchestration-mutation-executor.ts @@ -4,6 +4,7 @@ import { isTerminalPromptMutation } from '../../../shared/orchestration-rpc-contract' import type { OrcaRuntimeService } from '../orca-runtime' +import type { OrcaSessionId } from '../../../shared/orca-session-address' import { OrchestrationError } from '../orchestration/orchestration-error' import type { RpcRequest } from './core' import { @@ -53,7 +54,9 @@ export class OrchestrationMutationExecutor { request: RpcRequest, params: unknown, invoke: (mutation?: DurableMutationInvocation) => unknown, - callerFingerprintOverride?: string + callerFingerprintOverride?: string, + /** The resolved session's Orca session id; it joins the payload so another caller cannot replay it. */ + callerOrcaSessionId?: OrcaSessionId ): Promise { const requestId = request.orchestrationRequestId if (!requestId || !isDurableMutation(request.method, params)) { @@ -61,7 +64,7 @@ export class OrchestrationMutationExecutor { } const callerFingerprint = callerFingerprintOverride ?? this.getLocalAuthenticatedCallerFingerprint() - const stableParams = replayStableCallerParams(this.runtime, params) + const stableParams = replayStableCallerParams(this.runtime, params, callerOrcaSessionId) const basePayloadHash = hashCanonical({ method: request.method, params: stableParams }) const key = `${callerFingerprint}:${requestId}` const db = this.runtime.getOrchestrationDb() diff --git a/src/main/runtime/rpc/orchestration-mutation-receipt.ts b/src/main/runtime/rpc/orchestration-mutation-receipt.ts index 25a3fa1c177..1d94723550b 100644 --- a/src/main/runtime/rpc/orchestration-mutation-receipt.ts +++ b/src/main/runtime/rpc/orchestration-mutation-receipt.ts @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto' import { isTerminalPromptMutation } from '../../../shared/orchestration-rpc-contract' import { parsePaneKey } from '../../../shared/stable-pane-id' import type { OrcaRuntimeService } from '../orca-runtime' +import type { OrcaSessionId } from '../../../shared/orca-session-address' export const EFFECT_FREE_WORKER_DONE_CHECKPOINT = JSON.stringify({ pending: { effectFree: 'worker_done' } @@ -13,12 +14,21 @@ export type MutationReplayNudge = | { kind: 'messages'; targets: { to: string; type: string }[] } | { kind: 'federation'; runId?: string } -export function replayStableCallerParams(runtime: OrcaRuntimeService, params: unknown): unknown { +const CALLER_ORCA_SESSION_ID_KEY = '__orcaCallerOrcaSessionId' + +export function replayStableCallerParams( + runtime: OrcaRuntimeService, + params: unknown, + callerOrcaSessionId?: OrcaSessionId +): unknown { if (!params || typeof params !== 'object' || Array.isArray(params)) { return params } const source = params as Record - const result = { ...source } + // Absent for terminal callers, so their payload hashes are unchanged. + const result: Record = callerOrcaSessionId + ? { ...source, [CALLER_ORCA_SESSION_ID_KEY]: callerOrcaSessionId } + : { ...source } delete result.waitSubmitMs for (const property of ['from', 'callerTerminalHandle', 'terminal'] as const) { const handle = source[property] diff --git a/src/main/runtime/rpc/orchestration-party-addressing.test.ts b/src/main/runtime/rpc/orchestration-party-addressing.test.ts new file mode 100644 index 00000000000..5c281583f0a --- /dev/null +++ b/src/main/runtime/rpc/orchestration-party-addressing.test.ts @@ -0,0 +1,321 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { formatOrcaSessionAddress } from '../../../shared/orca-session-address' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' +import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration-session-caller-codes' +import { ORCHESTRATION_TARGET_PARAM } from '../orchestration/orchestration-party' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerIdentities, + structuredWorkerProcessIncarnation +} from '../structured-worker-identity' +import { + ADDRESS_X, + ADDRESS_Y, + createSessionCallerHarness, + idOf, + orchestrationRequest, + resultOf, + SESSION_X, + SESSION_Y, + sessionRecord, + WORKER_HANDLE, + WORKER_PANE, + type SessionCallerHarness +} from './orchestration-session-caller-test-fixture' + +const hostRef = vi.hoisted((): { current: unknown } => ({ current: null })) +vi.mock('../../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) + +type Row = Record + +const SESSION_Z = testOrcaSessionId('3f9a1c7e-6b2d-4e85-a0c4-9d1e7b3f5a26') +const ADDRESS_Z = formatOrcaSessionAddress(SESSION_Z) + +/** Worker Y's two spellings; a chat has only its session address. */ +const handle = mintStructuredWorkerHandle() +const paneKey = mintStructuredWorkerPaneKey(SESSION_Y) +const WORKER_SPELLINGS = [ + ['its handle', handle], + ['its session address', ADDRESS_Y] +] as const + +let h: SessionCallerHarness + +beforeEach(() => { + h = createSessionCallerHarness(hostRef) + h.records.set(SESSION_Z, sessionRecord(SESSION_Z)) + structuredWorkerIdentities.register({ + handle, + sessionId: SESSION_Y, + agent: 'claude', + paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } + }) +}) + +afterEach(() => { + h.close() + vi.restoreAllMocks() +}) + +function call(sessionId: string | undefined, method: string, params: Row) { + return h.dispatch(orchestrationRequest(method, params, { sessionId })) +} + +async function as(sessionId: string | undefined, method: string, params: Row): Promise { + return resultOf(await call(sessionId, method, params)) +} + +function chatRun(sessionId = SESSION_X): Promise { + return as(sessionId, 'orchestration.runCreate', { objective: 'o' }).then(({ run }) => idOf(run)) +} + +/** Worker Y assigned a Dispatch in `runId`, as `worker-start` leaves it. */ +function assignWorker(runId: string): string { + return h.db.createDispatchContext({ + taskId: h.db.createTask({ runId, spec: 'work' }).id, + assigneeHandle: handle, + assigneePaneKey: paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), + creator: { kind: 'session', orcaSessionId: SESSION_X }, + maxDepth: Number.MAX_SAFE_INTEGER + }).id +} + +/** The PTY terminal assigned a Dispatch in a Run, so it may ask that Run's coordinator. */ +function assignTerminal(runId: string): void { + h.db.createDispatchContext({ + taskId: h.db.createTask({ runId, spec: 'sub' }).id, + assigneeHandle: WORKER_HANDLE, + assigneePaneKey: WORKER_PANE, + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) +} + +describe('every target param resolves both spellings of a party to one canonical address', () => { + it('covers exactly the target params the contract lists', () => { + expect(Object.keys(ORCHESTRATION_TARGET_PARAM).sort()).toEqual( + [ + 'orchestration.send', + 'orchestration.ask', + 'orchestration.dispatch', + 'orchestration.inbox' + ].sort() + ) + }) + + it.each(WORKER_SPELLINGS)('send: a worker at %s gets its Dispatch mailbox', async (_l, to) => { + const dispatchId = assignWorker(await chatRun()) + const { message } = await as(undefined, 'orchestration.send', { + from: WORKER_HANDLE, + to, + subject: 's' + }) + expect(message).toMatchObject({ to_handle: `dispatch:${dispatchId}` }) + }) + + it("send: a chat's session address is its own direct mailbox", async () => { + const { message } = await as(undefined, 'orchestration.send', { + from: WORKER_HANDLE, + to: ADDRESS_Z, + subject: 's' + }) + expect(message).toMatchObject({ to_handle: ADDRESS_Z }) + }) + + it.each(WORKER_SPELLINGS)('ask: a worker coordinator is asked at %s', async (_l, to) => { + assignWorker(await chatRun()) + const childRun = idOf((await as(SESSION_Y, 'orchestration.runCreate', { objective: 'c' })).run) + assignTerminal(childRun) + const asked = await as(undefined, 'orchestration.ask', { + from: WORKER_HANDLE, + to, + question: 'q', + timeoutMs: 0 + }) + expect(h.db.getQuestion(String(asked.messageId))).toMatchObject({ run_id: childRun }) + }) + + it("ask: a chat coordinator is asked at its session address, and not at a worker's", async () => { + const runId = await chatRun() + assignTerminal(runId) + const ask = (to: string) => + call(undefined, 'orchestration.ask', { from: WORKER_HANDLE, to, question: 'q', timeoutMs: 0 }) + + expect(await ask(ADDRESS_X)).toMatchObject({ ok: true }) + expect(await ask(ADDRESS_Y)).toMatchObject({ + ok: false, + error: { code: 'dispatch_run_mismatch' } + }) + }) + + it.each(WORKER_SPELLINGS)( + 'dispatch: a worker named by %s is the assignee, and its check reads the Dispatch', + async (_l, to) => { + const runId = await chatRun() + const task = h.db.createTask({ runId, spec: 'work' }) + + const { dispatch } = await as(SESSION_X, 'orchestration.dispatch', { task: task.id, to }) + + expect(dispatch).toMatchObject({ + assignee_handle: handle, + assignee_orca_session_id: SESSION_Y + }) + expect(await as(SESSION_Y, 'orchestration.check', { peek: true })).toMatchObject({ + dispatchId: idOf(dispatch) + }) + } + ) + + it('dispatch: refuses a chat assignee with no row written', async () => { + const runId = await chatRun() + const task = h.db.createTask({ runId, spec: 'work' }) + + const response = await call(SESSION_X, 'orchestration.dispatch', { + task: task.id, + to: ADDRESS_Z + }) + + expect(response).toMatchObject({ + ok: false, + error: { + code: CODES.chatNotDispatchable, + message: `Agent session ${SESSION_Z} is a chat, and a chat can't receive a dispatch yet. Start a worker with worker-start instead. No effects were applied.`, + data: { effectsApplied: false } + } + }) + expect(h.db.db.prepare('SELECT COUNT(*) AS n FROM dispatch_contexts').get()).toEqual({ n: 0 }) + expect(h.db.getTask(task.id)?.status).toBe('ready') + }) + + it.each(WORKER_SPELLINGS)('inbox: a worker named by %s lists its mail', async (_l, terminal) => { + const runId = await chatRun() + h.db.insertMessage({ from: 'term_x', to: handle, subject: 'direct', body: '', runId }) + + const { messages } = await as(undefined, 'orchestration.inbox', { terminal }) + + expect(messages).toEqual([expect.objectContaining({ subject: 'direct', to_handle: handle })]) + }) + + it("inbox: a chat's session address lists its direct mail", async () => { + await as(undefined, 'orchestration.send', { from: WORKER_HANDLE, to: ADDRESS_Z, subject: 'z' }) + const { messages } = await as(undefined, 'orchestration.inbox', { terminal: ADDRESS_Z }) + expect(messages).toEqual([expect.objectContaining({ subject: 'z' })]) + }) +}) + +describe('a caller declared by a session address, on a request with no session id', () => { + it.each(WORKER_SPELLINGS)( + 'sends as the worker named by %s, from its handle', + async (_l, from) => { + const { message } = await as(undefined, 'orchestration.send', { + from, + to: ADDRESS_Z, + subject: 's' + }) + expect(message).toMatchObject({ from_handle: handle }) + } + ) + + it('checks the worker named by its session address as if it had named its handle', async () => { + const dispatchId = assignWorker(await chatRun()) + await as(SESSION_X, 'orchestration.send', { to: handle, subject: 'work' }) + + const byAddress = await as(undefined, 'orchestration.check', { terminal: ADDRESS_Y, all: true }) + const byHandle = await as(undefined, 'orchestration.check', { terminal: handle, all: true }) + + expect(byAddress).toEqual(byHandle) + expect(byAddress).toMatchObject({ dispatchId, messages: [{ subject: 'work' }] }) + }) + + it("refuses a chat's session address, with no effects", async () => { + const response = await call(undefined, 'orchestration.send', { + from: ADDRESS_Z, + to: WORKER_HANDLE, + subject: 's' + }) + + expect(response).toMatchObject({ + ok: false, + error: { + code: CODES.chatNotDeclarable, + message: expect.stringContaining(`Agent session ${SESSION_Z} is a chat`), + data: { effectsApplied: false } + } + }) + expect(h.db.getInbox()).toEqual([]) + }) +}) + +describe('a structured worker that coordinates a child Run while assigned in its parent', () => { + it.each(WORKER_SPELLINGS)( + 'gets mail at %s in the child Run it reads, even with no live pane', + async (_l, to) => { + assignWorker(await chatRun()) + const childRun = idOf( + (await as(SESSION_Y, 'orchestration.runCreate', { objective: 'c' })).run + ) + // The harness answers no live pane for a structured handle, as for an evicted session. + expect(h.runtime.getLiveTerminalPaneKey(handle)).toBeNull() + + for (const sender of [SESSION_X, undefined]) { + const { message } = await as(sender, 'orchestration.send', { + ...(sender ? {} : { from: WORKER_HANDLE }), + to, + subject: `from ${sender ?? 'terminal'}` + }) + expect(message).toMatchObject({ to_handle: `run:${childRun}` }) + } + expect(await as(SESSION_Y, 'orchestration.check', { peek: true })).toMatchObject({ + runId: childRun, + count: 2 + }) + } + ) +}) + +describe('no writer stores a structured worker under its session address', () => { + it('keeps every to_handle and from_handle at the canonical address across every mail writer', async () => { + const runId = await chatRun() + const task = h.db.createTask({ runId, spec: 'work' }) + await as(SESSION_X, 'orchestration.dispatch', { task: task.id, to: ADDRESS_Y }) + assignTerminal(runId) + + for (const to of [ADDRESS_Y, handle]) { + await as(SESSION_X, 'orchestration.send', { to, subject: 'down' }) + } + const fromWorker = await as(SESSION_Y, 'orchestration.send', { + from: ADDRESS_Y, + to: ADDRESS_X, + subject: 'up' + }) + await as(undefined, 'orchestration.send', { from: ADDRESS_Y, to: WORKER_HANDLE, subject: 'u' }) + await as(SESSION_X, 'orchestration.reply', { + id: idOf(fromWorker.message), + body: 'noted' + }) + await as(SESSION_X, 'orchestration.send', { to: '@all', subject: 'everyone' }) + await as(undefined, 'orchestration.ask', { + from: WORKER_HANDLE, + to: ADDRESS_X, + question: 'q', + timeoutMs: 0 + }) + + const stored = h.db.db + .prepare( + `SELECT m.id FROM messages AS m JOIN dispatch_contexts AS d + ON d.assignee_orca_session_id IS NOT NULL + AND 'session:' || d.assignee_orca_session_id IN (m.to_handle, m.from_handle)` + ) + .all() + expect(h.db.getInbox(100).length).toBeGreaterThanOrEqual(7) + expect(stored).toEqual([]) + }) +}) diff --git a/src/main/runtime/rpc/orchestration-party-agreement.test.ts b/src/main/runtime/rpc/orchestration-party-agreement.test.ts new file mode 100644 index 00000000000..b0e0d302474 --- /dev/null +++ b/src/main/runtime/rpc/orchestration-party-agreement.test.ts @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { formatOrcaSessionAddress, type OrcaSessionId } from '../../../shared/orca-session-address' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' +import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration-session-caller-codes' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerIdentities, + structuredWorkerProcessIncarnation +} from '../structured-worker-identity' +import { + ADDRESS_X, + createSessionCallerHarness, + idOf, + orchestrationRequest, + resultOf, + SESSION_X, + SESSION_Y, + sessionRecord, + WORKER_HANDLE, + type SessionCallerHarness +} from './orchestration-session-caller-test-fixture' + +// A `/clear`ed member of X's lineage and of worker Y's, as a later lineage walk will map them. +const ALIAS_X = testOrcaSessionId('5c7e2a94-1d3b-4f68-b9a0-e4c2d6f81b37') +const ALIAS_Y = testOrcaSessionId('8a4d6f20-3e1c-4b79-a5d2-c0f7e9b3a164') + +const hostRef = vi.hoisted((): { current: unknown } => ({ current: null })) +vi.mock('../../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) +vi.mock('../orchestration/canonical-orca-session-id', () => ({ + canonicalOrcaSessionId: (id: string) => + id === '5c7e2a94-1d3b-4f68-b9a0-e4c2d6f81b37' + ? '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37' + : id === '8a4d6f20-3e1c-4b79-a5d2-c0f7e9b3a164' + ? '7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64' + : id +})) + +type Row = Record + +const handle = mintStructuredWorkerHandle() +const paneKey = mintStructuredWorkerPaneKey(SESSION_Y) +let h: SessionCallerHarness + +beforeEach(() => { + h = createSessionCallerHarness(hostRef) + h.records.set(ALIAS_X, sessionRecord(ALIAS_X)) + h.records.set(ALIAS_Y, sessionRecord(ALIAS_Y)) +}) + +afterEach(() => { + h.close() + vi.restoreAllMocks() +}) + +function call(sessionId: string | undefined, method: string, params: Row) { + return h.dispatch(orchestrationRequest(method, params, { sessionId })) +} + +async function as(sessionId: string | undefined, method: string, params: Row): Promise { + return resultOf(await call(sessionId, method, params)) +} + +function registerWorker(): void { + structuredWorkerIdentities.register({ + handle, + sessionId: SESSION_Y, + agent: 'claude', + paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } + }) +} + +/** A Dispatch naming Y as a structured worker, in a Run a terminal coordinates. */ +function recordWorkerDispatch(): void { + const run = h.db.createRun({ + objective: 'pty', + coordinatorHandle: 'term_c', + coordinatorPaneKey: 'tab_c:13131313-1313-4313-8313-131313131313' + }) + h.db.createDispatchContext({ + taskId: h.db.createTask({ runId: run.id, spec: 'work' }).id, + assigneeHandle: handle, + assigneePaneKey: paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) +} + +type PartyState = { name: string; sessionId: OrcaSessionId; setup: () => Promise | void } + +const PARTY_STATES: PartyState[] = [ + { name: 'a chat with no Run', sessionId: SESSION_X, setup: () => {} }, + { + name: 'a chat coordinating a Run', + sessionId: SESSION_X, + setup: async () => void (await as(SESSION_X, 'orchestration.runCreate', { objective: 'o' })) + }, + { + name: 'a registered worker with a Dispatch', + sessionId: SESSION_Y, + setup: () => { + registerWorker() + recordWorkerDispatch() + } + }, + { + name: 'a recorded worker whose identity is gone', + sessionId: SESSION_Y, + setup: recordWorkerDispatch + }, + { + name: 'a chat that runs on another host', + sessionId: SESSION_X, + setup: () => { + h.records.set(SESSION_X, sessionRecord(SESSION_X, { location: { executionHostId: 'ssh:b' } })) + } + } +] + +describe('the caller a session id resolves to and the recipient its address resolves to agree', () => { + it.each(PARTY_STATES.map((state) => [state.name, state] as const))('%s', async (_name, state) => { + await state.setup() + const sent = await call(undefined, 'orchestration.send', { + from: WORKER_HANDLE, + to: formatOrcaSessionAddress(state.sessionId), + subject: 'agree' + }) + const read = await call(state.sessionId, 'orchestration.check', { peek: true }) + + // Both refuse, or the caller reads exactly the mail sent to its address. + expect(sent.ok).toBe(read.ok) + if (read.ok) { + expect(resultOf(read)).toMatchObject({ messages: [{ subject: 'agree' }] }) + } + }) + + it('refuses a worker whose identity is gone with one code, as caller and as recipient', async () => { + recordWorkerDispatch() + const asCaller = await call(SESSION_Y, 'orchestration.runCurrent', {}) + const asRecipient = await call(undefined, 'orchestration.send', { + from: WORKER_HANDLE, + to: formatOrcaSessionAddress(SESSION_Y), + subject: 's' + }) + for (const response of [asCaller, asRecipient]) { + expect(response).toMatchObject({ ok: false, error: { code: CODES.notLive } }) + } + }) +}) + +describe('every session-to-party step goes through the canonical session id', () => { + it('binds a claimed alias as its canonical session', async () => { + const { run } = await as(ALIAS_X, 'orchestration.runCreate', { objective: 'o' }) + expect(h.db.getRunRaw(idOf(run))?.coordinator_orca_session_id).toBe(SESSION_X) + expect(await as(SESSION_X, 'orchestration.runCurrent', {})).toMatchObject({ + run: { id: idOf(run) } + }) + }) + + it("delivers mail to an alias's address at the canonical session's mailbox", async () => { + const { message } = await as(undefined, 'orchestration.send', { + from: WORKER_HANDLE, + to: formatOrcaSessionAddress(ALIAS_X), + subject: 's' + }) + expect(message).toMatchObject({ to_handle: ADDRESS_X }) + }) + + it("resolves a worker alias's address to the worker's handle", async () => { + registerWorker() + const { message } = await as(undefined, 'orchestration.send', { + from: formatOrcaSessionAddress(ALIAS_Y), + to: ADDRESS_X, + subject: 's' + }) + expect(message).toMatchObject({ from_handle: handle }) + }) + + it.each([formatOrcaSessionAddress(ALIAS_X), ALIAS_X])( + 'accepts the session declared as its alias %s', + async (declared) => { + const { run } = await as(SESSION_X, 'orchestration.runCreate', { + objective: 'o', + from: declared + }) + expect(run).toMatchObject({ coordinator_handle: null }) + } + ) + + it('refuses an alias of a chat declared on a request with no session id, naming the chat', async () => { + const response = await call(undefined, 'orchestration.send', { + from: formatOrcaSessionAddress(ALIAS_X), + to: WORKER_HANDLE, + subject: 's' + }) + expect(response).toMatchObject({ + ok: false, + error: { code: CODES.chatNotDeclarable, message: expect.stringContaining(SESSION_X) } + }) + }) +}) diff --git a/src/main/runtime/rpc/orchestration-session-caller-test-fixture.ts b/src/main/runtime/rpc/orchestration-session-caller-test-fixture.ts new file mode 100644 index 00000000000..396b0b2bec7 --- /dev/null +++ b/src/main/runtime/rpc/orchestration-session-caller-test-fixture.ts @@ -0,0 +1,167 @@ +import { vi } from 'vitest' +import type { AgentSessionLease, AgentSessionRecord } from '../../../shared/agent-session-record' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../shared/agent-session-record.test-fixture' +import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version' +import { formatOrcaSessionAddress } from '../../../shared/orca-session-address' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' +import { OrcaRuntimeService } from '../orca-runtime' +import { OrchestrationDb } from '../orchestration/db' +import { structuredWorkerIdentities } from '../structured-worker-identity' +import type { RpcRequest, RpcResponse } from './core' +import { RpcDispatcher } from './dispatcher' +import { ORCHESTRATION_METHODS } from './methods/orchestration' + +export const SESSION_X = testOrcaSessionId('4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37') +export const SESSION_Y = testOrcaSessionId('7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64') +export const ADDRESS_X = formatOrcaSessionAddress(SESSION_X) +export const ADDRESS_Y = formatOrcaSessionAddress(SESSION_Y) +export const PROVIDER_ID_X = 'c0ffee11-2233-4455-8677-8899aabbccdd' +export const WORKSPACE_X = 'repo_1::/work/tree-x' +export const WORKER_HANDLE = 'term_worker' +export const WORKER_PANE = 'tab_worker:77777777-7777-4777-8777-777777777777' + +export type SessionHostRef = { current: unknown } + +/** A live chat session record; the lease and location can be pushed into any state. */ +export function sessionRecord( + sessionId: string, + overrides: { + lease?: Partial + location?: Partial + providerId?: string + } = {} +): AgentSessionRecord { + const base = agentSessionRecordFixture( + agentSessionLeaseFixture({ sessionId, runtimeKind: 'native', ...overrides.lease }) + ) + return { + ...base, + location: { ...base.location, workspaceId: WORKSPACE_X, ...overrides.location }, + providerHandleChain: base.providerHandleChain.map((link) => ({ + ...link, + handle: { + provider: 'claude' as const, + sessionId: overrides.providerId ?? `provider-${sessionId}`, + leafUuid: null + } + })) + } +} + +export type SessionCallerHarness = { + runtime: OrcaRuntimeService + db: OrchestrationDb + dispatcher: RpcDispatcher + records: Map + /** Unary socket route: what the local CLI's Unix socket and Electron IPC reach. */ + dispatch: (request: RpcRequest) => Promise + /** Streaming dispatcher, optionally as a paired client on another host. */ + dispatchStreaming: (request: RpcRequest, pairedDeviceId?: string) => Promise + close: () => void +} + +export function createSessionCallerHarness(hostRef: SessionHostRef): SessionCallerHarness { + const db = new OrchestrationDb(':memory:') + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + vi.spyOn(runtime, 'ensureStructuredAgentSessionHost').mockResolvedValue() + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === WORKER_HANDLE ? WORKER_PANE : null + ) + vi.spyOn(runtime, 'getLiveTerminalPaneKey').mockImplementation((handle) => + runtime.getTerminalPaneKey(handle) + ) + const records = new Map([ + [SESSION_X, sessionRecord(SESSION_X, { providerId: PROVIDER_ID_X })], + [SESSION_Y, sessionRecord(SESSION_Y)] + ]) + hostRef.current = { + deps: { + store: { + getRecord: (sessionId: string) => records.get(sessionId) ?? null, + listRecords: () => [...records.values()] + } + } + } + structuredWorkerIdentities.clear() + const dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) + return { + runtime, + db, + dispatcher, + records, + dispatch: (request) => dispatcher.dispatch(request), + dispatchStreaming: async (request, pairedDeviceId) => { + const replies: string[] = [] + await dispatcher.dispatchStreaming( + request, + (reply) => replies.push(reply), + pairedDeviceId ? { pairedDeviceId } : {} + ) + const [reply] = replies + if (replies.length !== 1 || reply === undefined) { + throw new Error(`expected exactly one reply, got ${replies.length}`) + } + const parsed: unknown = JSON.parse(reply) + return parsed + }, + close: () => { + hostRef.current = null + structuredWorkerIdentities.clear() + db.close() + } + } +} + +let requestCounter = 0 + +/** An orchestration request as the CLI sends it, naming its caller by session id when given. */ +export function orchestrationRequest( + method: string, + params: Record, + options: { + sessionId?: string + requestId?: string + evidence?: OrchestrationCompatibilityEvidence + } = {} +): RpcRequest { + requestCounter += 1 + const requestId = options.requestId ?? `req-${requestCounter}` + const evidence = + options.sessionId === undefined + ? options.evidence + : { ...options.evidence, agentSessionId: options.sessionId } + return { + id: `rpc-${requestCounter}`, + authToken: 'test', + method, + params, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: requestId, + compatibilityInvocationId: requestId, + ...(evidence ? { orchestrationCompatibilityEvidence: evidence } : {}) + } +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function resultOf(response: unknown): Record { + if (!isRecord(response) || response.ok !== true || !isRecord(response.result)) { + throw new Error(`expected a successful response, got ${JSON.stringify(response)}`) + } + return response.result +} + +/** The `id` of a row a receipt carries. */ +export function idOf(row: unknown): string { + if (!isRecord(row) || typeof row.id !== 'string') { + throw new Error(`expected a row with an id, got ${JSON.stringify(row)}`) + } + return row.id +} diff --git a/src/main/runtime/rpc/orchestration-session-caller.test.ts b/src/main/runtime/rpc/orchestration-session-caller.test.ts new file mode 100644 index 00000000000..d6ab1ba3ccb --- /dev/null +++ b/src/main/runtime/rpc/orchestration-session-caller.test.ts @@ -0,0 +1,478 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ZodObject } from 'zod' +import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence' +import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration-session-caller-codes' +import { DeviceRegistry } from '../device-registry' +import { OrcaRuntimeRpcServer } from '../runtime-rpc' +import { buildRegistry } from './core' +import { ORCHESTRATION_METHODS } from './methods/orchestration' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerProcessIncarnation +} from '../structured-worker-identity' +import { + ADDRESS_X, + createSessionCallerHarness, + orchestrationRequest, + PROVIDER_ID_X, + idOf, + resultOf, + SESSION_X, + SESSION_Y, + sessionRecord, + type SessionCallerHarness +} from './orchestration-session-caller-test-fixture' +import { + needsOrchestrationCallerResolution, + ORCHESTRATION_CALLER_PARAM +} from './orchestration-session-caller' +import { ORCHESTRATION_TARGET_PARAM } from '../orchestration/orchestration-party' + +const hostRef = vi.hoisted((): { current: unknown } => ({ current: null })) +vi.mock('../../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) + +// Fields that can name a party: the caller in ORCHESTRATION_CALLER_PARAM, a target in ORCHESTRATION_TARGET_PARAM. +const PARTY_NAMING_FIELDS = ['to', 'from', 'terminal', 'callerTerminalHandle'] as const +// `method field` pairs with such a field that is neither, so never resolves as a party. +const NAMES_NO_RESOLVED_PARTY: Readonly> = { + 'orchestration.run from': 'retired; refused before any handler', + 'orchestration.runShow from': 'reads a Run by id; `from` is unused', + 'orchestration.dispatchShow from': '`from` only fills the preview preamble text', + 'orchestration.workerStart terminal': 'adopts an existing PTY pane, which a session never has', + 'orchestration.federationAttachStart terminal': 'names the remote worker terminal', + 'orchestration.workerTerminalUserInput terminal': 'names the worker terminal' +} + +/** One request per identity-consulting method, valid enough to reach the dispatcher entry. */ +const MINIMAL_PARAMS: Readonly>> = { + 'orchestration.runCreate': { objective: 'o' }, + 'orchestration.runUse': { id: 'run_missing' }, + 'orchestration.runCurrent': {}, + 'orchestration.check': {}, + 'orchestration.send': { subject: 's', to: 'term_worker' }, + 'orchestration.reply': { id: 'msg_missing', body: 'b' }, + 'orchestration.ask': { question: 'q', to: 'term_worker' }, + 'orchestration.dispatch': { task: 'task_missing', to: 'term_worker' }, + 'orchestration.gateCreate': { task: 'task_missing', question: 'q' }, + 'orchestration.gateResolve': { id: 'gate_missing', resolution: 'r' }, + 'orchestration.gateList': {}, + 'orchestration.taskCreate': { spec: 's' }, + 'orchestration.taskList': {}, + 'orchestration.taskUpdate': { id: 'task_missing', status: 'completed' }, + 'orchestration.workerStart': { spec: 's' } +} + +describe('orchestration session callers at the dispatch entry', () => { + let h: SessionCallerHarness + + beforeEach(() => { + h = createSessionCallerHarness(hostRef) + }) + + afterEach(() => { + h.close() + vi.restoreAllMocks() + }) + + it('classifies every party-naming field as the caller, a resolved target, or neither', () => { + const registry = buildRegistry(ORCHESTRATION_METHODS) + const partyNaming = [...registry.values()] + .flatMap((method) => { + const schema = method.params + return schema instanceof ZodObject + ? PARTY_NAMING_FIELDS.filter((field) => Object.hasOwn(schema.shape, field)).map( + (field) => `${method.name} ${field}` + ) + : [] + }) + .sort() + + // The population: 41 registered methods carrying 25 party-naming fields. + expect(registry.size).toBe(41) + expect(partyNaming).toHaveLength(25) + expect(partyNaming).toEqual( + [ + ...Object.entries(ORCHESTRATION_CALLER_PARAM).map( + ([method, field]) => `${method} ${field}` + ), + ...Object.entries(ORCHESTRATION_TARGET_PARAM).map( + ([method, field]) => `${method} ${field}` + ), + ...Object.keys(NAMES_NO_RESOLVED_PARTY) + ].sort() + ) + expect(Object.keys(MINIMAL_PARAMS).sort()).toEqual( + Object.keys(ORCHESTRATION_CALLER_PARAM).sort() + ) + }) + + it.each(Object.keys(ORCHESTRATION_CALLER_PARAM))( + 'refuses %s from a paired client before any effect, naming the host boundary', + async (method) => { + const response = await h.dispatchStreaming( + orchestrationRequest(method, MINIMAL_PARAMS[method] ?? {}, { sessionId: SESSION_X }), + 'paired-device-1' + ) + + expect(response).toMatchObject({ + ok: false, + error: { + code: CODES.hostBoundary, + message: expect.stringContaining('only on the host that runs that session'), + data: { effectsApplied: false } + } + }) + expect(h.db.listRuns().runs.filter((run) => run.legacy === 0)).toEqual([]) + } + ) + + it.each(Object.keys(ORCHESTRATION_CALLER_PARAM))( + 'resolves %s on the local route as the session, never as a terminal', + async (method) => { + const spy = vi.spyOn(h.runtime, 'verifyOrchestrationCompatibilityCaller') + const response = await h.dispatch( + orchestrationRequest(method, MINIMAL_PARAMS[method] ?? {}, { + sessionId: SESSION_X, + evidence: { + terminalHandle: 'term_other', + paneKey: 'tab_other:1:2', + launchToken: 'secret' + } + }) + ) + + // Whatever the method answers, it answered as the session: no host-boundary or session refusal, + // and terminal evidence on the same request never attested anyone. + if (!response.ok) { + expect(Object.values(CODES)).not.toContain(response.error.code) + expect(response.error.message).not.toContain('term_other') + } + for (const [evidence] of spy.mock.calls) { + expect(evidence?.terminalHandle).toBeUndefined() + } + } + ) + + it('admits the session on the real Unix-socket route and refuses it on the real paired route', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-session-caller-')) + const server = new OrcaRuntimeRpcServer({ + runtime: h.runtime, + userDataPath, + enableWebSocket: false + }) + server['deviceRegistry'] = new DeviceRegistry(userDataPath) + const device = server['deviceRegistry'].addDevice('laptop', 'runtime') + const request = orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o' }, + { + sessionId: SESSION_X + } + ) + + const replies: string[] = [] + await server['handleWebSocketMessage']( + JSON.stringify({ ...request, deviceToken: device.token }), + (reply) => replies.push(reply), + () => {}, + undefined, + undefined, + device.token + ) + expect(JSON.parse(replies[0] ?? '{}')).toMatchObject({ + ok: false, + error: { code: CODES.hostBoundary } + }) + + const local = await server['handleMessage']( + JSON.stringify({ ...request, authToken: server['authToken'] }) + ) + expect(local).toMatchObject({ ok: true, result: { run: { objective: 'o' } } }) + expect( + h.db.getCurrentRunForCoordinator({ + terminalHandle: null, + paneKey: null, + orcaSessionId: SESSION_X + }) + ).toMatchObject({ objective: 'o', coordinator_orca_session_id: SESSION_X }) + }) + + describe('refuses a session that cannot act, before any destructive or consuming lookup', () => { + function seedPendingMail(): { runId: string; messageId: string } { + const run = h.db.createRun({ + objective: 'x', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorOrcaSessionId: SESSION_X + }) + const message = h.db.insertMessage({ + from: 'term_worker', + to: ADDRESS_X, + subject: 'pending', + body: '', + runId: run.id + }) + return { runId: run.id, messageId: message.id } + } + + async function expectRefusedWithNoEffects( + sessionId: string, + code: string, + message: RegExp, + evidence?: OrchestrationCompatibilityEvidence + ): Promise { + const { messageId } = seedPendingMail() + for (const [method, params] of [ + ['orchestration.check', {}], + ['orchestration.reset', { messages: true }] + ] as const) { + const response = await h.dispatch( + orchestrationRequest(method, params, { sessionId, evidence }) + ) + expect(response, method).toMatchObject({ + ok: false, + error: { code, message: expect.stringMatching(message) } + }) + } + expect(h.db.getMessageById(messageId)).toMatchObject({ read: 0 }) + } + + it('an id that names no Orca session', async () => { + await expectRefusedWithNoEffects( + 'ffffffff-0000-4000-8000-000000000000', + CODES.unknown, + /No Orca agent session .* exists on this host/ + ) + }) + + it('a terminal handle presented as a session id', async () => { + await expectRefusedWithNoEffects('term_4f2c9a0b', CODES.unknown, /not an Orca session id/) + }) + + it("a provider's session id, with a hint naming the Orca id", async () => { + const response = await h.dispatch( + orchestrationRequest('orchestration.runCurrent', {}, { sessionId: PROVIDER_ID_X }) + ) + expect(response).toMatchObject({ + ok: false, + error: { + code: CODES.providerId, + message: expect.stringContaining(`This session's Orca id is ${SESSION_X}`), + data: { orcaSessionId: SESSION_X, effectsApplied: false } + } + }) + await expectRefusedWithNoEffects(PROVIDER_ID_X, CODES.providerId, /changes on \/clear/) + }) + + it('a released lease', async () => { + h.records.set(SESSION_X, sessionRecord(SESSION_X, { lease: { claimStatus: 'released' } })) + await expectRefusedWithNoEffects(SESSION_X, CODES.notLive, /is not running right now/) + }) + + it('a lease mid owner change', async () => { + h.records.set( + SESSION_X, + sessionRecord(SESSION_X, { + lease: { handoffStage: 'new-owner-proving', handoffOperationId: 'op' } + }) + ) + await expectRefusedWithNoEffects(SESSION_X, CODES.notLive, /is changing owners/) + }) + + it('a lease the host has not reconciled since restart', async () => { + h.records.set(SESSION_X, sessionRecord(SESSION_X, { lease: { unreconciled: true } })) + await expectRefusedWithNoEffects(SESSION_X, CODES.notLive, /no live owner/) + }) + + it('a session whose record says it runs on another host', async () => { + h.records.set( + SESSION_X, + sessionRecord(SESSION_X, { location: { executionHostId: 'ssh:devbox' } }) + ) + await expectRefusedWithNoEffects(SESSION_X, CODES.hostBoundary, /runs on another host/) + }) + + it('a request from an SSH or WSL environment', async () => { + await expectRefusedWithNoEffects(SESSION_X, CODES.hostBoundary, /an SSH environment/, { + host: { kind: 'ssh', targetId: 't', connectionIncarnation: 'c', attachmentId: 'a' } + }) + await expectRefusedWithNoEffects(SESSION_X, CODES.hostBoundary, /a WSL environment/, { + host: { kind: 'wsl', hostId: 'local', distro: 'Ubuntu' } + }) + }) + + it('a structured worker session whose worker identity this host no longer has', async () => { + // A Dispatch recorded the session as a worker; no registry entry or custody row maps it now. + const run = h.db.createRun({ + objective: 'pty', + coordinatorHandle: 'term_c', + coordinatorPaneKey: 'tab_c:13131313-1313-4313-8313-131313131313' + }) + h.db.createDispatchContext({ + taskId: h.db.createTask({ runId: run.id, spec: 'work' }).id, + assigneeHandle: mintStructuredWorkerHandle(), + assigneePaneKey: mintStructuredWorkerPaneKey(SESSION_Y), + processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + + const response = await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o' }, + { sessionId: SESSION_Y } + ) + ) + + expect(response).toMatchObject({ + ok: false, + error: { code: CODES.notLive, message: expect.stringContaining('no longer has') } + }) + expect( + h.db.listRuns().runs.filter((row) => row.coordinator_orca_session_id !== null) + ).toEqual([]) + }) + + it('an agent-session host that cannot be brought up to verify it', async () => { + hostRef.current = null + await expectRefusedWithNoEffects(SESSION_X, CODES.notLive, /cannot be verified/) + }) + }) + + describe('a declared caller must name the session', () => { + it('refuses a declared terminal handle that is not the session', async () => { + const response = await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o', from: 'term_sibling' }, + { sessionId: SESSION_X } + ) + ) + expect(response).toMatchObject({ + ok: false, + error: { + code: 'consumer_fenced', + message: `This caller is agent session ${SESSION_X} and cannot act as term_sibling. No effects were applied.` + } + }) + expect(h.db.listRuns().runs.filter((run) => run.legacy === 0)).toEqual([]) + }) + + it("refuses a caller-supplied pane key on check, the restart fallback's substitute", async () => { + const response = await h.dispatch( + orchestrationRequest( + 'orchestration.check', + { terminalPaneKey: 'tab_worker:77777777-7777-4777-8777-777777777777' }, + { sessionId: SESSION_X } + ) + ) + expect(response).toMatchObject({ ok: false, error: { code: 'consumer_fenced' } }) + }) + + it('accepts a /clear successor naming the address it had before the clear, and no other', async () => { + // Y continued X after a /clear, so X's address is Y's: the lineage root names the chat. + h.records.set(SESSION_X, { + ...sessionRecord(SESSION_X), + conversationCommand: { + command: 'clear', + state: 'completed', + replacementSessionId: SESSION_Y, + operationId: 'op', + callerKey: 'caller', + phase: 'committed' + } + }) + const run = resultOf( + await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o', from: ADDRESS_X }, + { sessionId: SESSION_Y } + ) + ) + ).run + expect(h.db.getRunRaw(idOf(run))?.coordinator_orca_session_id).toBe(SESSION_X) + + const stranger = 'session:0b5e2d7c-9a41-4c3e-8f62-7d1a3e5b9c08' + const refused = await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o', from: stranger }, + { sessionId: SESSION_Y } + ) + ) + expect(refused).toMatchObject({ ok: false, error: { code: 'consumer_fenced' } }) + }) + + it.each(['orchestration.gateList', 'orchestration.taskList'])( + 'checks a caller %s names beside --run: the /clear root is the chat, a stranger is refused', + async (method) => { + h.records.set(SESSION_X, { + ...sessionRecord(SESSION_X), + conversationCommand: { + command: 'clear', + state: 'completed', + replacementSessionId: SESSION_Y, + operationId: 'op', + callerKey: 'caller', + phase: 'committed' + } + }) + const runId = idOf( + resultOf( + await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o' }, + { sessionId: SESSION_Y } + ) + ) + ).run + ) + const param = ORCHESTRATION_CALLER_PARAM[method]! + const listed = await h.dispatch( + orchestrationRequest(method, { run: runId, [param]: ADDRESS_X }, { sessionId: SESSION_Y }) + ) + expect(resultOf(listed)).toMatchObject({ runId }) + + const stranger = 'session:0b5e2d7c-9a41-4c3e-8f62-7d1a3e5b9c08' + const refused = await h.dispatch( + orchestrationRequest(method, { run: runId, [param]: stranger }, { sessionId: SESSION_Y }) + ) + expect(refused).toMatchObject({ ok: false, error: { code: 'consumer_fenced' } }) + } + ) + + it.each([ADDRESS_X, SESSION_X])('accepts the session named as %s', async (declared) => { + const run = resultOf( + await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o', from: declared }, + { sessionId: SESSION_X } + ) + ) + ).run + expect(run).toMatchObject({ objective: 'o', coordinator_handle: null }) + }) + }) + + it('leaves a terminal caller untouched: no claim, no normalization, no extra hop', async () => { + const request = orchestrationRequest('orchestration.runCreate', { + objective: 'o', + from: 'term_worker' + }) + expect(needsOrchestrationCallerResolution(request)).toBe(false) + + const run = resultOf(await h.dispatch(request)).run + expect(run).toMatchObject({ coordinator_handle: 'term_worker' }) + expect(h.db.getRunRaw(idOf(run))?.coordinator_orca_session_id).toBeNull() + }) +}) diff --git a/src/main/runtime/rpc/orchestration-session-caller.ts b/src/main/runtime/rpc/orchestration-session-caller.ts new file mode 100644 index 00000000000..0d2253481b4 --- /dev/null +++ b/src/main/runtime/rpc/orchestration-session-caller.ts @@ -0,0 +1,278 @@ +/** + * Resolves an orchestration caller that names itself by the Orca agent session id in its injected + * environment. Both dispatchers call this once, before params parse and the unary/streaming split, + * so it runs ahead of legacy compatibility, receipt lookup and every method. Before parsing, so a + * session caller need not name itself in a param that requires a caller. + * + * The id names the caller; nothing here is a credential. Every agent on this host runs as the same + * user, so the checks are about getting the identity right, not about keeping anyone out: + * - Same host only. A paired client, an SSH environment or a WSL shell is another host, where + * "same machine, same user" does not hold, so a session claim from one is refused. + * - The Orca id, never the provider's: that one rotates on `/clear`. + * - A live lease: released, mid owner change or unreconciled sessions cannot act. + * - The session wins over any declared caller: a declared handle must name this same session, and + * a structured worker's session id maps to the handle and pane it was minted. + * - A request with no session id that declares a `session:` caller gets the party it names: a + * worker's handle, or a refusal for a chat, whose address alone identifies nobody. + */ +import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { isOrcaSessionId, parseOrcaSessionAddress } from '../../../shared/orca-session-address' +import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration-session-caller-codes' +import type { OrcaRuntimeService } from '../orca-runtime' +import type { OrchestrationSessionCaller } from '../orchestration/orchestration-caller-identity' +import { OrchestrationError } from '../orchestration/orchestration-error' +import { canonicalOrcaSessionId } from '../orchestration/canonical-orca-session-id' +import type { OrchestrationDb } from '../orchestration/db' +import { + resolveDeclaredCallerParty, + resolveOrcaSessionParty, + resolveOrchestrationParty +} from '../orchestration/orchestration-party' +import { lookupOrcaAgentSession } from '../orchestration/structured-session-mail-address' +import { readAgentSessionRecordStore } from '../orchestration/structured-session-lineage' +import { structuredWorkerHostScope } from '../structured-worker-identity' +import type { RpcRequest } from './core' + +type CallerParam = 'from' | 'terminal' | 'callerTerminalHandle' + +/** + * Every method that consults caller identity, and the param it names its caller in. This list is + * the contract: a method that starts reading caller identity is added here with its own test. + * Methods not listed carry no caller identity for any caller; a session claim on them is still + * validated, then they run exactly as they do for a terminal caller. + */ +export const ORCHESTRATION_CALLER_PARAM: Readonly> = { + 'orchestration.runCreate': 'from', + 'orchestration.runUse': 'from', + 'orchestration.runCurrent': 'from', + 'orchestration.check': 'terminal', + 'orchestration.send': 'from', + 'orchestration.reply': 'from', + 'orchestration.ask': 'from', + 'orchestration.dispatch': 'from', + 'orchestration.gateCreate': 'from', + 'orchestration.gateResolve': 'from', + 'orchestration.gateList': 'from', + 'orchestration.taskCreate': 'callerTerminalHandle', + 'orchestration.taskList': 'callerTerminalHandle', + 'orchestration.taskUpdate': 'callerTerminalHandle', + 'orchestration.workerStart': 'from' +} + +export type OrchestrationRequestRoute = { + /** Set only by the paired WebSocket route: the request came from another host's client. */ + pairedDeviceId?: string +} + +export type ResolvedOrchestrationRequest = { + /** The request with its declared caller bound to the session and its evidence reduced to it. */ + request: RpcRequest + caller?: OrchestrationSessionCaller +} + +const NO_EFFECTS = { effectsApplied: false } as const + +/** + * Whether this request names its caller by a session id, or declares a `session:` address as its + * caller. Synchronous, so every other request, terminal callers included, takes no extra async hop. + */ +export function needsOrchestrationCallerResolution(request: RpcRequest): boolean { + if (!request.method.startsWith('orchestration.')) { + return false + } + return ( + request.orchestrationCompatibilityEvidence?.agentSessionId !== undefined || + declaredSessionAddress(request) !== undefined + ) +} + +function declaredSessionAddress(request: RpcRequest): string | undefined { + const name = ORCHESTRATION_CALLER_PARAM[request.method] + const params: unknown = request.params + if (!name || !params || typeof params !== 'object' || Array.isArray(params)) { + return undefined + } + const values: Record = { ...params } + const declared = values[name] + return typeof declared === 'string' && parseOrcaSessionAddress(declared) ? declared : undefined +} + +/** Only for a request `needsOrchestrationCallerResolution` accepts. Throws the refusal, if any. */ +export async function resolveOrchestrationSessionCaller( + runtime: OrcaRuntimeService, + request: RpcRequest, + route: OrchestrationRequestRoute | undefined +): Promise { + const evidence = request.orchestrationCompatibilityEvidence + if (evidence?.agentSessionId === undefined) { + return { request: bindDeclaredSessionAddress(runtime.getOrchestrationDb(), request) } + } + const claimed: unknown = evidence?.agentSessionId + if (route?.pairedDeviceId !== undefined) { + throw hostBoundary( + 'This request reached Orca from a paired client, and an agent session id identifies a caller only on the host that runs that session.' + ) + } + if (evidence?.host) { + throw hostBoundary( + `This command ran in ${evidence.host.kind === 'ssh' ? 'an SSH' : 'a WSL'} environment, and an agent session id identifies a caller only on the host that runs that session.` + ) + } + if (typeof claimed !== 'string' || !isOrcaSessionId(claimed)) { + throw new OrchestrationError( + CODES.unknown, + 'The caller named an agent session id that is not an Orca session id. No effects were applied.', + NO_EFFECTS + ) + } + const sessionId = claimed + const record = await readSessionRecord(runtime, sessionId) + assertSessionCanAct(sessionId, record) + const db = runtime.getOrchestrationDb() + const caller: OrchestrationSessionCaller = Object.freeze({ + ...resolveOrcaSessionParty(sessionId, db), + sessionId, + workspaceId: record.location.workspaceId + }) + return { + request: { + ...request, + params: bindDeclaredCaller(request.method, request.params, caller, db), + // Why: the session wins, so any terminal evidence on the same request never attests. + orchestrationCompatibilityEvidence: { agentSessionId: sessionId } + }, + caller + } +} + +async function readSessionRecord( + runtime: OrcaRuntimeService, + sessionId: string +): Promise { + let store: ReturnType + try { + await runtime.ensureStructuredAgentSessionHost() + store = readAgentSessionRecordStore() + } catch { + store = null + } + if (!store) { + throw new OrchestrationError( + CODES.notLive, + `Agent session ${sessionId} cannot be verified: this Orca is not running its agent-session host. No effects were applied.`, + NO_EFFECTS + ) + } + const found = lookupOrcaAgentSession(store, sessionId) + if (found.kind === 'found') { + return found.record + } + if (found.kind === 'provider-id') { + throw new OrchestrationError( + CODES.providerId, + `${sessionId} is the provider's own session id, which changes on /clear. This session's Orca id is ${found.orcaSessionId}; use that instead. No effects were applied.`, + { ...NO_EFFECTS, orcaSessionId: found.orcaSessionId } + ) + } + throw new OrchestrationError( + CODES.unknown, + `No Orca agent session ${sessionId} exists on this host. No effects were applied.`, + NO_EFFECTS + ) +} + +function assertSessionCanAct(sessionId: string, record: AgentSessionRecord): void { + if (!structuredWorkerHostScope(record.location)) { + throw hostBoundary( + `Agent session ${sessionId} runs on another host, and an agent session id identifies a caller only on the host that runs that session.` + ) + } + if (agentSessionLeaseAdmitsWriter(record.lease)) { + return + } + const { lease } = record + const reason = + lease.claimStatus === 'released' + ? // Why not "ended": a released lease is evicted and wakeable; only a running process may act. + 'is not running right now. A new message or user turn revives it; retry then.' + : lease.handoffStage !== null + ? 'is changing owners on this host. Retry when that finishes.' + : 'has no live owner on this host right now. Retry once it is running.' + throw new OrchestrationError( + CODES.notLive, + `Agent session ${sessionId} ${reason} No effects were applied.`, + NO_EFFECTS + ) +} + +/** A request with no session id that declares a `session:` caller: a worker's is its handle. */ +function bindDeclaredSessionAddress(db: OrchestrationDb, request: RpcRequest): RpcRequest { + const name = ORCHESTRATION_CALLER_PARAM[request.method] + const declared = declaredSessionAddress(request) + const params: unknown = request.params + if (!name || declared === undefined || !params || typeof params !== 'object') { + return request + } + const party = resolveDeclaredCallerParty(declared, db) + return { ...request, params: { ...params, [name]: party.address } } +} + +/** Whether a declared caller names this session: any spelling that resolves to its party. */ +function declaredNamesCaller( + declared: unknown, + caller: OrchestrationSessionCaller, + db: OrchestrationDb +): boolean { + if (typeof declared !== 'string') { + return false + } + if (isOrcaSessionId(declared)) { + return canonicalOrcaSessionId(declared) === caller.orcaSessionId + } + try { + return resolveOrchestrationParty(declared, db).address === caller.address + } catch { + return false + } +} + +/** The declared caller, if any, must name this session; it is then replaced by its address. */ +function bindDeclaredCaller( + method: string, + params: unknown, + caller: OrchestrationSessionCaller, + db: OrchestrationDb +): unknown { + const name = ORCHESTRATION_CALLER_PARAM[method] + if (!name || !params || typeof params !== 'object' || Array.isArray(params)) { + return params + } + const values: Record = { ...params } + const declared = values[name] + if (declared !== undefined && !declaredNamesCaller(declared, caller, db)) { + throw consumerFenced(caller, String(declared)) + } + // Why: check's restart fallback takes a pane key from the caller; a session has its own or none. + if (values.terminalPaneKey !== undefined && values.terminalPaneKey !== caller.paneKey) { + throw consumerFenced(caller, `pane ${String(values.terminalPaneKey)}`) + } + values[name] = caller.address + return values +} + +function consumerFenced(caller: OrchestrationSessionCaller, declared: string): OrchestrationError { + return new OrchestrationError( + 'consumer_fenced', + `This caller is agent session ${caller.sessionId} and cannot act as ${declared}. No effects were applied.`, + NO_EFFECTS + ) +} + +function hostBoundary(reason: string): OrchestrationError { + return new OrchestrationError( + CODES.hostBoundary, + `${reason} Run the command on that host. No effects were applied.`, + NO_EFFECTS + ) +} diff --git a/src/main/runtime/rpc/orchestration-session-coordinator.test.ts b/src/main/runtime/rpc/orchestration-session-coordinator.test.ts new file mode 100644 index 00000000000..a60c3a16916 --- /dev/null +++ b/src/main/runtime/rpc/orchestration-session-coordinator.test.ts @@ -0,0 +1,637 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerIdentities, + structuredWorkerProcessIncarnation +} from '../structured-worker-identity' +import { + ADDRESS_X, + ADDRESS_Y, + createSessionCallerHarness, + orchestrationRequest, + idOf, + resultOf, + SESSION_X, + SESSION_Y, + WORKER_HANDLE, + WORKER_PANE, + WORKSPACE_X, + type SessionCallerHarness +} from './orchestration-session-caller-test-fixture' + +const hostRef = vi.hoisted((): { current: unknown } => ({ current: null })) +vi.mock('../../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) + +type Row = Record + +describe('a structured chat coordinates through the same verbs as a terminal', () => { + let h: SessionCallerHarness + + beforeEach(() => { + h = createSessionCallerHarness(hostRef) + }) + + afterEach(() => { + h.close() + vi.restoreAllMocks() + }) + + async function as(sessionId: string | undefined, method: string, params: Row): Promise { + return resultOf(await h.dispatch(orchestrationRequest(method, params, { sessionId }))) + } + + async function runCreate(sessionId: string, objective = 'o'): Promise { + const { run } = await as(sessionId, 'orchestration.runCreate', { objective }) + return idOf(run) + } + + it('runs the supervised loop: run, task, dispatch, worker mail in, check, send, reply, gates', async () => { + const runId = await runCreate(SESSION_X) + expect(h.db.getRunRaw(runId)).toMatchObject({ + coordinator_handle: null, + coordinator_pane_key: null, + coordinator_orca_session_id: SESSION_X + }) + expect(await as(SESSION_X, 'orchestration.runCurrent', {})).toMatchObject({ + run: { id: runId } + }) + + const { task } = await as(SESSION_X, 'orchestration.taskCreate', { spec: 'do it' }) + const taskId = idOf(task) + expect(task).toMatchObject({ run_id: runId, created_by_terminal_handle: null }) + expect(await as(SESSION_X, 'orchestration.taskList', {})).toMatchObject({ runId, count: 1 }) + + const { dispatch } = await as(SESSION_X, 'orchestration.dispatch', { + task: taskId, + to: WORKER_HANDLE + }) + expect(dispatch).toMatchObject({ + assignee_handle: WORKER_HANDLE, + creator_handle: null, + creator_pane_key: null, + creator_orca_session_id: SESSION_X, + depth: 1 + }) + + // The worker writes to its coordinator's public address. + const { message: inbound } = await as(undefined, 'orchestration.send', { + from: WORKER_HANDLE, + to: ADDRESS_X, + subject: 'progress' + }) + expect(inbound).toMatchObject({ to_handle: `run:${runId}`, run_id: runId }) + + const checked = await as(SESSION_X, 'orchestration.check', {}) + expect(checked).toMatchObject({ runId, count: 1, messages: [{ subject: 'progress' }] }) + + const { message: outbound } = await as(SESSION_X, 'orchestration.send', { + to: WORKER_HANDLE, + subject: 'more' + }) + expect(outbound).toMatchObject({ + from_handle: ADDRESS_X, + to_handle: `dispatch:${idOf(dispatch)}`, + run_id: runId + }) + + const replied = await as(SESSION_X, 'orchestration.reply', { + id: idOf(inbound), + body: 'ack' + }) + expect(replied).toMatchObject({ + message: { + from_handle: ADDRESS_X, + to_handle: `dispatch:${idOf(dispatch)}`, + run_id: runId, + thread_id: idOf(inbound) + } + }) + expect(await as(undefined, 'orchestration.check', { terminal: WORKER_HANDLE })).toMatchObject({ + runId, + dispatchId: idOf(dispatch), + count: 2, + messages: [{ id: idOf(outbound) }, { id: idOf(replied.message) }] + }) + + const { gate } = await as(SESSION_X, 'orchestration.gateCreate', { + task: taskId, + question: 'ship?' + }) + expect(await as(SESSION_X, 'orchestration.gateList', {})).toMatchObject({ runId, count: 1 }) + expect( + await as(SESSION_X, 'orchestration.gateResolve', { + id: idOf(gate), + resolution: 'yes' + }) + ).toMatchObject({ gate: { status: 'resolved' } }) + + expect( + await as(SESSION_X, 'orchestration.taskUpdate', { id: taskId, status: 'completed' }) + ).toMatchObject({ task: { status: 'completed' } }) + }) + + it("files a session's mail to a plain terminal under its own Run", async () => { + const runId = await runCreate(SESSION_X) + const { message } = await as(SESSION_X, 'orchestration.send', { + to: WORKER_HANDLE, + subject: 'no dispatch here' + }) + expect(message).toMatchObject({ + from_handle: ADDRESS_X, + to_handle: WORKER_HANDLE, + run_id: runId + }) + }) + + it("addresses a group to the session's own Run", async () => { + await runCreate(SESSION_X) + const response = await h.dispatch( + orchestrationRequest( + 'orchestration.send', + { to: '@all', subject: 'everyone' }, + { + sessionId: SESSION_X + } + ) + ) + // The audience resolved to the session's Run; that Run simply has no workers yet. + expect(response).toMatchObject({ + ok: false, + error: { + code: 'terminal_not_found', + message: 'No recipients resolved for group address: @all' + } + }) + }) + + it('asks as a coordinator does: only a supervised worker may ask', async () => { + await runCreate(SESSION_X) + const response = await h.dispatch( + orchestrationRequest( + 'orchestration.ask', + { question: 'q', to: WORKER_HANDLE }, + { + sessionId: SESSION_X + } + ) + ) + expect(response).toMatchObject({ ok: false, error: { code: 'dispatch_inactive' } }) + }) + + it("lets its worker ask it at its session address, the one the worker's preamble names", async () => { + const runId = await runCreate(SESSION_X) + const taskId = idOf((await as(SESSION_X, 'orchestration.taskCreate', { spec: 'q' })).task) + await as(SESSION_X, 'orchestration.dispatch', { task: taskId, to: WORKER_HANDLE }) + + const asked = await as(undefined, 'orchestration.ask', { + from: WORKER_HANDLE, + to: ADDRESS_X, + question: 'which way?', + timeoutMs: 0 + }) + expect(asked).toMatchObject({ timedOut: true }) + expect(h.db.getQuestion(String(asked.messageId))).toMatchObject({ run_id: runId }) + }) + + it("places a worker-start in the session's own workspace", async () => { + const runId = await runCreate(SESSION_X) + vi.spyOn(h.runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) + const placed = vi + .spyOn(h.runtime, 'showManagedTerminalWorkspace') + .mockRejectedValue(new Error('placement reached')) + const response = await h.dispatch( + orchestrationRequest( + 'orchestration.workerStart', + { spec: 'work', run: runId, agent: 'claude' }, + { + sessionId: SESSION_X + } + ) + ) + expect(placed).toHaveBeenCalledWith(`id:${WORKSPACE_X}`) + expect(response).toMatchObject({ ok: false, error: { message: 'placement reached' } }) + }) + + it("never lets one chat's run-create unbind another chat's Run", async () => { + const xFirst = await runCreate(SESSION_X, 'x first') + const yRun = await runCreate(SESSION_Y, 'y') + const xSecond = await runCreate(SESSION_X, 'x second') + + expect(await as(SESSION_Y, 'orchestration.runCurrent', {})).toMatchObject({ + run: { id: yRun } + }) + expect(await as(SESSION_X, 'orchestration.runCurrent', {})).toMatchObject({ + run: { id: xSecond } + }) + expect(h.db.getRunRaw(xFirst)?.coordinator_orca_session_id).toBeNull() + }) + + it('takes over a bound Run like a terminal does, and fences the previous coordinator', async () => { + const runId = await runCreate(SESSION_X) + await as(undefined, 'orchestration.send', { + from: WORKER_HANDLE, + to: ADDRESS_X, + subject: 'before takeover', + run: runId + }) + + expect(await as(SESSION_Y, 'orchestration.runUse', { id: runId })).toMatchObject({ + run: { id: runId } + }) + + const previous = await h.dispatch( + orchestrationRequest('orchestration.check', { run: runId }, { sessionId: SESSION_X }) + ) + expect(previous).toMatchObject({ + ok: false, + error: { + code: 'consumer_fenced', + message: `This coordinator terminal is no longer bound to Run ${runId}.` + } + }) + expect(await as(SESSION_X, 'orchestration.runCurrent', {})).toMatchObject({ run: null }) + expect(await as(SESSION_Y, 'orchestration.check', {})).toMatchObject({ + runId, + messages: [{ subject: 'before takeover' }] + }) + }) + + it("wakes the previous coordinator's waiting check as fenced when another session takes over", async () => { + const runId = await runCreate(SESSION_X) + const waiter = vi.spyOn(h.runtime, 'waitForMessage') + const waiting = h.dispatch( + orchestrationRequest( + 'orchestration.check', + { run: runId, wait: true, timeoutMs: 5_000 }, + { + sessionId: SESSION_X + } + ) + ) + await vi.waitFor(() => expect(waiter).toHaveBeenCalledWith(`run:${runId}`, expect.anything())) + const deliver = vi.spyOn(h.db, 'getOrCreateRunDelivery') + const acknowledge = vi.spyOn(h.db, 'acknowledgeRunDelivery') + + await as(SESSION_Y, 'orchestration.runUse', { id: runId }) + + const fenced = await waiting + expect(fenced).toMatchObject({ ok: false, error: { code: 'consumer_fenced' } }) + expect(fenced).not.toHaveProperty('result') + + const { message } = await as(undefined, 'orchestration.send', { + from: WORKER_HANDLE, + to: ADDRESS_Y, + subject: 'after takeover', + run: runId + }) + expect( + await h.dispatch( + orchestrationRequest('orchestration.check', { run: runId }, { sessionId: SESSION_X }) + ) + ).toMatchObject({ ok: false, error: { code: 'consumer_fenced' } }) + expect(deliver).not.toHaveBeenCalled() + + const replacement = await as(SESSION_Y, 'orchestration.check', {}) + expect(replacement).toMatchObject({ + runId, + count: 1, + messages: [{ id: idOf(message), to_handle: `run:${runId}`, run_id: runId }] + }) + const ack = replacement.deliveryId + if (typeof ack !== 'string') { + throw new Error('Expected a Run Delivery') + } + expect( + await h.dispatch( + orchestrationRequest('orchestration.check', { run: runId, ack }, { sessionId: SESSION_X }) + ) + ).toMatchObject({ ok: false, error: { code: 'consumer_fenced' } }) + expect(acknowledge).not.toHaveBeenCalled() + expect(h.db.getDeliveryRaw(ack)?.acknowledged_at).toBeNull() + expect(await as(SESSION_Y, 'orchestration.check', {})).toMatchObject({ + deliveryId: ack, + replayed: true, + messages: [{ id: idOf(message) }] + }) + expect(await as(SESSION_Y, 'orchestration.check', { ack })).toMatchObject({ + acknowledged: ack, + count: 0 + }) + expect(h.db.getDeliveryRaw(ack)?.status).toBe('acknowledged') + }) + + it('stops counting a coordinator Orca session id once an older binary rebinds the Run to a terminal', async () => { + const runId = await runCreate(SESSION_X) + // An older binary's bindRun rewrites handle and pane and bumps the generation, never the Orca session id. + h.db.db + .prepare( + `UPDATE runs SET coordinator_handle = ?, coordinator_pane_key = ?, + consumer_generation = consumer_generation + 1 + WHERE id = ?` + ) + .run(WORKER_HANDLE, WORKER_PANE, runId) + + expect(await as(SESSION_X, 'orchestration.runCurrent', {})).toMatchObject({ run: null }) + expect(await as(undefined, 'orchestration.runCurrent', { from: WORKER_HANDLE })).toMatchObject({ + run: { id: runId } + }) + }) + + it('replays an idempotent retry from the same session and refuses it from another', async () => { + const first = await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o' }, + { + sessionId: SESSION_X, + requestId: 'retry-1' + } + ) + ) + const retry = await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o' }, + { + sessionId: SESSION_X, + requestId: 'retry-1' + } + ) + ) + const other = await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o' }, + { + sessionId: SESSION_Y, + requestId: 'retry-1' + } + ) + ) + + const firstRun = idOf(resultOf(first).run) + expect(resultOf(retry)).toMatchObject({ run: { id: firstRun }, mutation: { replayed: true } }) + expect(other).toMatchObject({ ok: false, error: { code: 'request_mismatch' } }) + expect(h.db.listRuns().runs.filter((run) => run.legacy === 0)).toHaveLength(1) + }) +}) + +describe('a session with no Run, and receipts that carry no caller param', () => { + let h: SessionCallerHarness + + beforeEach(() => { + h = createSessionCallerHarness(hostRef) + }) + + afterEach(() => { + h.close() + vi.restoreAllMocks() + }) + + it('reads its direct mailbox on a consuming check, as a terminal with a live pane does', async () => { + h.db.insertMessage({ from: WORKER_HANDLE, to: ADDRESS_X, subject: 'direct', body: '' }) + + const checked = resultOf( + await h.dispatch(orchestrationRequest('orchestration.check', {}, { sessionId: SESSION_X })) + ) + + expect(checked).toMatchObject({ messages: [{ subject: 'direct', to_handle: ADDRESS_X }] }) + }) + + it('binds a receipt to the session even when the method names no caller', async () => { + const reset = (sessionId: string) => + h.dispatch( + orchestrationRequest( + 'orchestration.reset', + { messages: true }, + { + sessionId, + requestId: 'reset-1' + } + ) + ) + + expect(await reset(SESSION_X)).toMatchObject({ ok: true }) + expect(await reset(SESSION_X)).toMatchObject({ + ok: true, + result: { mutation: { replayed: true } } + }) + expect(await reset(SESSION_Y)).toMatchObject({ + ok: false, + error: { code: 'request_mismatch' } + }) + }) +}) + +describe('a structured worker that names itself by session id', () => { + let h: SessionCallerHarness + const workerSession = SESSION_Y + const handle = mintStructuredWorkerHandle() + const paneKey = mintStructuredWorkerPaneKey(workerSession) + + beforeEach(() => { + h = createSessionCallerHarness(hostRef) + structuredWorkerIdentities.register({ + handle, + sessionId: workerSession, + agent: 'claude', + paneKey, + processIncarnation: structuredWorkerProcessIncarnation(workerSession), + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } + }) + }) + + afterEach(() => { + h.close() + vi.restoreAllMocks() + }) + + function dispatchToWorker(): { runId: string; dispatchId: string } { + const run = h.db.createRun({ + objective: 'pty coordinator', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:12121212-1212-4212-8212-121212121212' + }) + const dispatch = h.db.createDispatchContext({ + taskId: h.db.createTask({ runId: run.id, spec: 'work' }).id, + assigneeHandle: handle, + assigneePaneKey: paneKey, + processIncarnation: structuredWorkerProcessIncarnation(workerSession), + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + h.db.insertMessage({ + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'instructions', + body: '', + runId: run.id + }) + return { runId: run.id, dispatchId: dispatch.id } + } + + it("reads its own Dispatch mailbox: the session id wins and maps to the worker's handle", async () => { + const { dispatchId } = dispatchToWorker() + expect(h.db.getDispatchContextById(dispatchId)?.assignee_orca_session_id).toBe(SESSION_Y) + + const bySession = resultOf( + await h.dispatch( + orchestrationRequest('orchestration.check', { peek: true }, { sessionId: workerSession }) + ) + ) + expect(bySession).toMatchObject({ messages: [{ subject: 'instructions' }] }) + + const namedByHandle = resultOf( + await h.dispatch( + orchestrationRequest( + 'orchestration.check', + { peek: true, terminal: handle }, + { + sessionId: workerSession + } + ) + ) + ) + expect(namedByHandle).toEqual(bySession) + }) + + it.each([ + ['its handle', handle], + ['its session address', ADDRESS_Y], + ['its bare session id', workerSession] + ])('accepts itself declared as %s and binds by its handle', async (_label, declared) => { + const { run } = resultOf( + await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'declared', from: declared }, + { sessionId: workerSession } + ) + ) + ) + expect(h.db.getRunRaw(idOf(run))).toMatchObject({ + coordinator_handle: handle, + coordinator_orca_session_id: SESSION_Y + }) + }) + + it('sends mail at either of its spellings to its Dispatch once assigned in a Run it coordinated before', async () => { + const { run } = resultOf( + await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o' }, + { sessionId: workerSession } + ) + ) + ) + const runId = idOf(run) + // A chat takes the Run over; the worker's addresses stay remembered as a former coordinator's. + resultOf( + await h.dispatch( + orchestrationRequest('orchestration.runUse', { id: runId }, { sessionId: SESSION_X }) + ) + ) + expect(h.db.getRunMailboxOwnerIdsForHandle(ADDRESS_Y)).toEqual([runId]) + const dispatch = h.db.createDispatchContext({ + taskId: h.db.createTask({ runId, spec: 'work' }).id, + assigneeHandle: handle, + assigneePaneKey: paneKey, + processIncarnation: structuredWorkerProcessIncarnation(workerSession), + creator: { kind: 'session', orcaSessionId: SESSION_X }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + + // Both spellings name one party, so both land in the one mailbox it reads, not the Run's. + for (const to of [ADDRESS_Y, handle]) { + const { message } = resultOf( + await h.dispatch( + orchestrationRequest('orchestration.send', { to, subject: to }, { sessionId: SESSION_X }) + ) + ) + expect(message).toMatchObject({ to_handle: `dispatch:${dispatch.id}` }) + } + const read = resultOf( + await h.dispatch( + orchestrationRequest('orchestration.check', { peek: true }, { sessionId: workerSession }) + ) + ) + expect(read).toMatchObject({ + count: 2, + messages: expect.arrayContaining([ + expect.objectContaining({ subject: handle }), + expect.objectContaining({ subject: ADDRESS_Y }) + ]) + }) + }) + + it('coordinates with its handle, pane and Orca session id, one mailbox at both spellings', async () => { + const { run } = resultOf( + await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'nested' }, + { + sessionId: workerSession + } + ) + ) + ) + const runId = idOf(run) + + expect(h.db.getRunRaw(runId)).toMatchObject({ + coordinator_handle: handle, + coordinator_pane_key: paneKey, + coordinator_orca_session_id: SESSION_Y + }) + expect(h.db.getRunMailboxOwnerIdsForHandle(handle)).toEqual([runId]) + for (const to of [handle, ADDRESS_Y]) { + const { message } = resultOf( + await h.dispatch( + orchestrationRequest('orchestration.send', { from: WORKER_HANDLE, to, subject: to }) + ) + ) + expect(message).toMatchObject({ to_handle: `run:${runId}` }) + } + }) + + it.each([ + ['its handle', handle], + ['its session address', ADDRESS_Y] + ])('is asked by its own worker at %s', async (_label, to) => { + const { run } = resultOf( + await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o' }, + { sessionId: workerSession } + ) + ) + ) + const runId = idOf(run) + h.db.createDispatchContext({ + taskId: h.db.createTask({ runId, spec: 'sub' }).id, + assigneeHandle: WORKER_HANDLE, + assigneePaneKey: WORKER_PANE, + creator: { kind: 'terminal', handle, paneKey, orcaSessionId: workerSession }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + + const asked = resultOf( + await h.dispatch( + orchestrationRequest('orchestration.ask', { + from: WORKER_HANDLE, + to, + question: 'which way?', + timeoutMs: 0 + }) + ) + ) + expect(asked).toMatchObject({ timedOut: true }) + expect(h.db.getQuestion(String(asked.messageId))).toMatchObject({ run_id: runId }) + }) +}) diff --git a/src/main/runtime/rpc/orchestration-session-recipient.test.ts b/src/main/runtime/rpc/orchestration-session-recipient.test.ts new file mode 100644 index 00000000000..f8f2de266d4 --- /dev/null +++ b/src/main/runtime/rpc/orchestration-session-recipient.test.ts @@ -0,0 +1,332 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerIdentities, + structuredWorkerProcessIncarnation +} from '../structured-worker-identity' +import { + ADDRESS_X, + ADDRESS_Y, + createSessionCallerHarness, + idOf, + isRecord, + orchestrationRequest, + resultOf, + PROVIDER_ID_X, + SESSION_X, + SESSION_Y, + sessionRecord, + WORKER_HANDLE, + type SessionCallerHarness +} from './orchestration-session-caller-test-fixture' + +const hostRef = vi.hoisted((): { current: unknown } => ({ current: null })) +vi.mock('../../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) + +type Row = Record + +describe('a send addressed to an agent session', () => { + let h: SessionCallerHarness + let visible: string[] + + beforeEach(() => { + h = createSessionCallerHarness(hostRef) + visible = [SESSION_X, SESSION_Y] + hostRef.current = { + deps: { + store: { + getRecord: (sessionId: string) => h.records.get(sessionId) ?? null, + listRecords: () => [...h.records.values()], + getVisibleSessionTabIndex: () => ({ present: true, sessionIds: visible }) + } + } + } + }) + + afterEach(() => { + h.close() + vi.restoreAllMocks() + }) + + async function send(to: string): Promise { + const response: unknown = await h.dispatch( + orchestrationRequest('orchestration.send', { from: 'term_worker', to, subject: 'hello' }) + ) + if (!isRecord(response)) { + throw new Error('expected an RPC response object') + } + return response + } + + function errorMessage(response: Row): string { + return isRecord(response.error) ? String(response.error.message) : '' + } + + it('stores mail to a live session that coordinates nothing at its own address, and points it', async () => { + // The refusal this replaces: "Terminal session: has no live pane or durable Run/Dispatch + // mailbox." An agent's id is its public address, coordinator or not. + const deliver = vi.spyOn(h.runtime, 'deliverPendingMessagesForHandle') + const sent = await send(ADDRESS_X) + expect(sent).toMatchObject({ ok: true, result: { message: { to_handle: ADDRESS_X } } }) + await vi.waitFor(() => expect(deliver).toHaveBeenCalledWith(ADDRESS_X, expect.anything())) + }) + + it('accepts a bare Orca session id and normalizes it', async () => { + expect(await send(SESSION_X)).toMatchObject({ + ok: true, + result: { message: { to_handle: ADDRESS_X } } + }) + }) + + it('keeps routing a coordinating session to its Run mailbox', async () => { + const created = await h.dispatch( + orchestrationRequest('orchestration.runCreate', { objective: 'o' }, { sessionId: SESSION_X }) + ) + const runId = idOf(resultOf(created).run) + expect(await send(ADDRESS_X)).toMatchObject({ + ok: true, + result: { message: { to_handle: `run:${runId}` } } + }) + }) + + it.each([ + [ + 'an unknown session', + () => `session:0b0b0b0b-1111-4222-8333-444444444444`, + 'session_caller_unknown' + ], + ['a malformed session address', () => 'session:term_abc', 'session_caller_unknown'], + ['a provider id', () => `session:${PROVIDER_ID_X}`, 'session_caller_provider_id'], + ['a bare provider id', () => PROVIDER_ID_X, 'session_caller_provider_id'] + ])('refuses %s before storing anything', async (_label, to, code) => { + const sent = await send(to()) + expect(sent).toMatchObject({ ok: false, error: { code } }) + expect(h.db.getInbox(100)).toEqual([]) + }) + + it('refuses a session on another host', async () => { + h.records.set(SESSION_Y, sessionRecord(SESSION_Y, { location: { executionHostId: 'ssh:box' } })) + expect(await send(`session:${SESSION_Y}`)).toMatchObject({ + ok: false, + error: { code: 'session_caller_host_boundary' } + }) + expect(h.db.getInbox(100)).toEqual([]) + }) + + it('refuses a session whose chat was closed, naming why', async () => { + visible = [SESSION_X] + const sent = await send(`session:${SESSION_Y}`) + expect(sent).toMatchObject({ ok: false, error: { code: 'session_caller_not_live' } }) + expect(errorMessage(sent)).toContain('its chat was closed') + expect(h.db.getInbox(100)).toEqual([]) + }) + + it('refuses a structured worker whose worker identity is gone: nothing could ever read it', async () => { + // A Dispatch recorded the session as a worker; no registry entry or custody row maps it now. + const run = h.db.createRun({ + objective: 'pty', + coordinatorHandle: 'term_c', + coordinatorPaneKey: 'tab_c:13131313-1313-4313-8313-131313131313' + }) + h.db.createDispatchContext({ + taskId: h.db.createTask({ runId: run.id, spec: 'work' }).id, + assigneeHandle: mintStructuredWorkerHandle(), + assigneePaneKey: mintStructuredWorkerPaneKey(SESSION_Y), + processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + structuredWorkerIdentities.clear() + const sent = await send(`session:${SESSION_Y}`) + expect(sent).toMatchObject({ ok: false, error: { code: 'session_caller_not_live' } }) + expect(errorMessage(sent)).toContain('worker identity') + }) + + it('leaves a bare string that is no session a terminal handle, as before', async () => { + expect(await send('0b0b0b0b-1111-4222-8333-444444444444')).toMatchObject({ + ok: false, + error: { code: 'terminal_not_found' } + }) + }) +}) + +describe('a live structured worker addressed by its session id', () => { + let h: SessionCallerHarness + const handle = mintStructuredWorkerHandle() + const paneKey = mintStructuredWorkerPaneKey(SESSION_Y) + + beforeEach(() => { + h = createSessionCallerHarness(hostRef) + structuredWorkerIdentities.register({ + handle, + sessionId: SESSION_Y, + agent: 'claude', + paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } + }) + }) + + afterEach(() => { + h.close() + vi.restoreAllMocks() + }) + + async function sendTo(to: string): Promise { + return resultOf( + await h.dispatch( + orchestrationRequest('orchestration.send', { from: 'term_worker', to, subject: 'hello' }) + ) + ) + } + + async function flaglessCheck(): Promise { + return resultOf( + await h.dispatch( + orchestrationRequest('orchestration.check', { peek: true }, { sessionId: SESSION_Y }) + ) + ) + } + + it('lands in its Dispatch mailbox, which its flagless check reads', async () => { + // The defect this pins: the mail was stored at `session:`, pointed at the worker, and its + // `check` — which reads the worker's handle and Dispatch mailboxes — returned nothing. + const run = h.db.createRun({ + objective: 'pty coordinator', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:12121212-1212-4212-8212-121212121212' + }) + const dispatch = h.db.createDispatchContext({ + taskId: h.db.createTask({ runId: run.id, spec: 'work' }).id, + assigneeHandle: handle, + assigneePaneKey: paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + expect(await sendTo(`session:${SESSION_Y}`)).toMatchObject({ + message: { to_handle: `dispatch:${dispatch.id}` } + }) + expect(await flaglessCheck()).toMatchObject({ messages: [{ subject: 'hello' }] }) + }) + + it('lands in its own handle mailbox between Dispatches, which its flagless check reads', async () => { + expect(await sendTo(SESSION_Y)).toMatchObject({ message: { to_handle: handle } }) + expect(await flaglessCheck()).toMatchObject({ messages: [{ subject: 'hello' }] }) + }) +}) + +describe('mail sent to a session address reaches the mailbox that session reads', () => { + let h: SessionCallerHarness + + beforeEach(() => { + h = createSessionCallerHarness(hostRef) + }) + + afterEach(() => { + h.close() + vi.restoreAllMocks() + }) + + async function as(sessionId: string | undefined, method: string, params: Row): Promise { + return resultOf(await h.dispatch(orchestrationRequest(method, params, { sessionId }))) + } + + function sendFromTerminal(to: string): Promise { + return as(undefined, 'orchestration.send', { from: WORKER_HANDLE, to, subject: 'hello' }) + } + + it("files it under the chat's current Run, as a terminal coordinator's pane does", async () => { + await as(SESSION_X, 'orchestration.runCreate', { objective: 'first' }) + const current = idOf( + (await as(SESSION_X, 'orchestration.runCreate', { objective: 'next' })).run + ) + + const { message } = await sendFromTerminal(ADDRESS_X) + + expect(message).toMatchObject({ to_handle: `run:${current}`, run_id: current }) + expect(await as(SESSION_X, 'orchestration.check', {})).toMatchObject({ + runId: current, + messages: [{ subject: 'hello' }] + }) + }) + + it('delivers it to a chat with no Run, which reads its direct mailbox', async () => { + const { message } = await sendFromTerminal(ADDRESS_X) + + expect(message).toMatchObject({ to_handle: ADDRESS_X }) + expect(await as(SESSION_X, 'orchestration.check', {})).toMatchObject({ + messages: [{ subject: 'hello' }] + }) + }) + + it('reaches a chat with no Run after a restart, once the send has started the session host', async () => { + // After an app restart the agent-session host starts lazily; routing reads the session record + // synchronously, so the send must start the host before it resolves the recipient. + const store = hostRef.current + hostRef.current = null + vi.mocked(h.runtime.ensureStructuredAgentSessionHost).mockImplementation(async () => { + hostRef.current = store + }) + + const { message } = await sendFromTerminal(ADDRESS_X) + + expect(message).toMatchObject({ to_handle: ADDRESS_X }) + }) + + it('refuses an Orca session this host does not run, or has no record of', async () => { + h.records.set( + SESSION_X, + sessionRecord(SESSION_X, { location: { executionHostId: 'ssh:devbox' } }) + ) + h.records.delete(SESSION_Y) + + for (const [to, code] of [ + [ADDRESS_X, 'session_caller_host_boundary'], + [ADDRESS_Y, 'session_caller_unknown'] + ]) { + const response = await h.dispatch( + orchestrationRequest('orchestration.send', { from: WORKER_HANDLE, to, subject: 's' }) + ) + expect(response).toMatchObject({ ok: false, error: { code } }) + } + }) + + it("routes a structured worker's session address to the Dispatch it is working", async () => { + const handle = mintStructuredWorkerHandle() + const paneKey = mintStructuredWorkerPaneKey(SESSION_Y) + structuredWorkerIdentities.register({ + handle, + sessionId: SESSION_Y, + agent: 'claude', + paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } + }) + const runId = idOf((await as(SESSION_X, 'orchestration.runCreate', { objective: 'o' })).run) + const dispatch = h.db.createDispatchContext({ + taskId: h.db.createTask({ runId, spec: 'work' }).id, + assigneeHandle: handle, + assigneePaneKey: paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), + creator: { kind: 'session', orcaSessionId: SESSION_X }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + + const { message } = await as(SESSION_X, 'orchestration.send', { + to: ADDRESS_Y, + subject: 'to the worker' + }) + + expect(message).toMatchObject({ to_handle: `dispatch:${dispatch.id}`, run_id: runId }) + expect(await as(SESSION_Y, 'orchestration.check', { peek: true })).toMatchObject({ + dispatchId: dispatch.id, + messages: [{ subject: 'to the worker' }] + }) + }) +}) diff --git a/src/main/runtime/rpc/orchestration-session-run-binding.test.ts b/src/main/runtime/rpc/orchestration-session-run-binding.test.ts new file mode 100644 index 00000000000..0f6b65d6571 --- /dev/null +++ b/src/main/runtime/rpc/orchestration-session-run-binding.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerIdentities, + structuredWorkerProcessIncarnation +} from '../structured-worker-identity' +import { + createSessionCallerHarness, + orchestrationRequest, + resultOf, + idOf, + SESSION_X, + SESSION_Y, + type SessionCallerHarness +} from './orchestration-session-caller-test-fixture' + +const hostRef = vi.hoisted((): { current: unknown } => ({ current: null })) +vi.mock('../../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) + +function deliveryId(result: Record): string { + if (typeof result.deliveryId === 'string') { + return result.deliveryId + } + throw new Error('Expected a Delivery') +} + +describe('structured lead Run binding through the RPC dispatcher', () => { + let h: SessionCallerHarness + let dispatchId: string + + function call(sessionId: string, method: string, params: Record = {}) { + return h + .dispatch(orchestrationRequest(`orchestration.${method}`, params, { sessionId })) + .then(resultOf) + } + + beforeEach(async () => { + h = createSessionCallerHarness(hostRef) + const handle = mintStructuredWorkerHandle() + const paneKey = mintStructuredWorkerPaneKey(SESSION_Y) + structuredWorkerIdentities.register({ + handle, + sessionId: SESSION_Y, + agent: 'claude', + paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), + worktreeId: 'folder-workspace', + hostScope: { kind: 'local', hostId: 'local' } + }) + const runId = idOf((await call(SESSION_X, 'runCreate', { objective: 'parent' })).run) + dispatchId = h.db.createDispatchContext({ + taskId: h.db.createTask({ runId, spec: 'structured lead' }).id, + assigneeHandle: handle, + assigneePaneKey: paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), + creator: { kind: 'session', orcaSessionId: SESSION_X }, + maxDepth: Number.MAX_SAFE_INTEGER + }).id + }) + afterEach(() => { + h.runtime.cancelMessageWaiters(`dispatch:${dispatchId}`) + h.close() + vi.restoreAllMocks() + }) + + it.each(['runCreate', 'runUse'] as const)( + 'cancels a parked session Dispatch check on %s', + async (method) => { + const params = + method === 'runCreate' + ? { objective: 'child' } + : { + id: h.db.createRun({ + objective: 'unbound', + coordinatorHandle: null, + coordinatorPaneKey: null + }).id + } + const wait = vi.spyOn(h.runtime, 'waitForMessage') + const waiting = call(SESSION_Y, 'check', { wait: true, timeoutMs: 500 }) + await vi.waitFor(() => + expect(wait).toHaveBeenCalledWith(`dispatch:${dispatchId}`, expect.anything()) + ) + const child = idOf((await call(SESSION_Y, method, params)).run) + expect(await waiting).toMatchObject({ cancelled: true, timedOut: false }) + const sent = await call(SESSION_X, 'send', { + to: `dispatch:${dispatchId}`, + subject: 'after bind' + }) + expect(sent.message).toMatchObject({ to_handle: `run:${child}`, run_id: child }) + expect(await call(SESSION_Y, 'check')).toMatchObject({ + messages: [{ subject: 'after bind' }] + }) + expect(h.db.getInbox()).toHaveLength(1) + } + ) + + it.each(['cancel', 'throw'] as const)( + 'replays a pre-bind ack receipt after the following wait ends by %s', + async (ending) => { + await call(SESSION_X, 'send', { to: `dispatch:${dispatchId}`, subject: 'ack me' }) + const ack = deliveryId(await call(SESSION_Y, 'check')) + const acknowledge = vi.spyOn(h.db, 'acknowledgeMailboxDelivery') + const wait = vi.spyOn(h.runtime, 'waitForMessage').mockImplementationOnce(async () => { + await call(SESSION_Y, 'runCreate', { objective: 'child' }) + if (ending === 'throw') { + throw new Error('wait transport interrupted') + } + return 'cancelled' + }) + const request = orchestrationRequest( + 'orchestration.check', + { ack, wait: true, timeoutMs: 500 }, + { + sessionId: SESSION_Y, + requestId: `bind-during-ack-${ending}` + } + ) + const first = await h.dispatch(request) + if (ending === 'throw') { + expect(first).toMatchObject({ ok: false, error: { code: 'runtime_error' } }) + } else { + expect(resultOf(first)).toMatchObject({ acknowledged: ack, count: 0, cancelled: true }) + } + const replay = resultOf(await h.dispatch(request)) + expect(replay).toMatchObject({ acknowledged: ack, count: 0, mutation: { replayed: true } }) + if (ending === 'throw') { + expect(replay.waitInterrupted).toBe('outcome_unknown') + } + expect(acknowledge).toHaveBeenCalledTimes(1) + expect(wait).toHaveBeenCalledTimes(1) + expect(h.db.getDeliveryRaw(ack)?.status).toBe('acknowledged') + } + ) +}) diff --git a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts index 1428b72d188..6c2faad902e 100644 --- a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts +++ b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts @@ -1,4 +1,10 @@ -import { isStreamingMethod, type RpcEnvelopeMeta, type RpcRegistry, type RpcRequest } from './core' +import { + isRegistrationFencedUnsubscribe, + isStreamingMethod, + type RpcEnvelopeMeta, + type RpcRegistry, + type RpcRequest +} from './core' import { errorResponse, successResponse } from './errors' import type { OrcaRuntimeService } from '../orca-runtime' @@ -15,6 +21,11 @@ import { parseRpcRequestParams } from './dispatcher-request-parsing' import { routeDispatcherClientHostedBrowserRpc } from './dispatcher-client-browser-routing' import { needsLocalCallerFingerprint } from './dispatcher-caller-fingerprint' import { createDispatcherStreamingFeatureEmitter } from './dispatcher-streaming-feature-emitter' +import { + needsOrchestrationCallerResolution, + resolveOrchestrationSessionCaller, + type ResolvedOrchestrationRequest +} from './orchestration-session-caller' export type RpcStreamingDispatcherDependencies = { runtime: OrcaRuntimeService @@ -29,10 +40,11 @@ export class RpcStreamingDispatcher { // Why: streaming dispatch sends multiple responses through the reply callback instead of a Promise. async dispatch( - request: RpcRequest, + rawRequest: RpcRequest, reply: (response: string) => void, options?: RpcDispatchStreamingOptions ): Promise { + let request = rawRequest const { runtime, registry, orchestrationMutations, legacyOrchestration, meta } = this.dependencies const envelopeMeta = meta() @@ -57,23 +69,36 @@ export class RpcStreamingDispatcher { return } + // Why: before params parse and the unary/streaming split, so both branches see one caller. + let resolved: ResolvedOrchestrationRequest = { request } + if (needsOrchestrationCallerResolution(request)) { + try { + resolved = await resolveOrchestrationSessionCaller(runtime, request, options) + } catch (error) { + reply(JSON.stringify(mapDispatcherError(request, envelopeMeta, error))) + return + } + } + request = resolved.request + const orchestrationCaller = resolved.caller const parsedParams = parseRpcRequestParams(request, method, envelopeMeta) if (parsedParams.error) { reply(JSON.stringify(parsedParams.error)) return } + const params = parsedParams.value if (!isStreamingMethod(method)) { try { + // Session tabs always need this fence. COMPAT(terminal request-addressed unsubscribe): terminal only for phones without `requestId`. // Capture before middleware yields to a replacement subscribe on the same connection. - const subscriptionRegistrationVersion = - request.method === 'terminal.unsubscribe' - ? runtime.getSubscriptionRegistrationVersion() - : undefined + const subscriptionRegistrationVersion = isRegistrationFencedUnsubscribe(request.method) + ? runtime.getSubscriptionRegistrationVersion() + : undefined const clientHostedBrowser = await routeDispatcherClientHostedBrowserRpc( runtime, request.method, - parsedParams.value + params ) if (clientHostedBrowser.handled) { recordRuntimeFeatureInteraction( @@ -88,16 +113,12 @@ export class RpcStreamingDispatcher { ) return } - const compatibility = await legacyOrchestration.tryHandle( - request, - parsedParams.value, - options?.signal - ) + const compatibility = await legacyOrchestration.tryHandle(request, params, options?.signal) if (compatibility.handled) { reply(JSON.stringify(successResponse(request.id, envelopeMeta, compatibility.result))) return } - const effectiveParams = compatibility.params ?? parsedParams.value + const effectiveParams = compatibility.params ?? params const legacyCoordinator = legacyOrchestration.createCoordinatorInvocation( request, compatibility.legacyCoordinatorAuthority @@ -136,14 +157,16 @@ export class RpcStreamingDispatcher { revalidateLegacyCoordinator: legacyCoordinator?.revalidate, orchestrationCompatibilityCallerAuthority: compatibility.orchestrationCompatibilityCallerAuthority, - orchestrationCompatibilityEvidence: request.orchestrationCompatibilityEvidence + orchestrationCompatibilityEvidence: request.orchestrationCompatibilityEvidence, + orchestrationCaller }) } const result = await orchestrationMutations.run( request, effectiveParams, invoke, - legacyCoordinator?.mutationCallerFingerprint ?? authenticatedCallerFingerprint + legacyCoordinator?.mutationCallerFingerprint ?? authenticatedCallerFingerprint, + orchestrationCaller?.orcaSessionId ) recordRuntimeFeatureInteraction(runtime, request.method, result, undefined, request.params) reply(JSON.stringify(successResponse(request.id, envelopeMeta, result))) @@ -162,7 +185,7 @@ export class RpcStreamingDispatcher { try { const result = await method.handler( - parsedParams.value, + params, { runtime, signal: options?.signal, @@ -177,7 +200,8 @@ export class RpcStreamingDispatcher { pairing: options?.pairing, sendBinary: options?.sendBinary, registerBinaryStreamHandler: options?.registerBinaryStreamHandler, - registerBinaryMessageHandler: options?.registerBinaryMessageHandler + registerBinaryMessageHandler: options?.registerBinaryMessageHandler, + orchestrationCaller }, emit ) diff --git a/src/main/runtime/rpc/static-web-client-handler-stream.test.ts b/src/main/runtime/rpc/static-web-client-handler-stream.test.ts new file mode 100644 index 00000000000..91bd0f52053 --- /dev/null +++ b/src/main/runtime/rpc/static-web-client-handler-stream.test.ts @@ -0,0 +1,255 @@ +import { once } from 'node:events' +import type * as fs from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync, type ReadStream } from 'node:fs' +import type * as fsPromises from 'node:fs/promises' +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PassThrough } from 'node:stream' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { createStaticWebClientHandler } from './static-web-client-handler' + +const observed = vi.hoisted(() => { + const streams: ReadStream[] = [] + const state: { + statWait: Promise + statError?: Error + missingPath?: string + onSource?: () => void + } = { + statWait: Promise.resolve() + } + return { streams, state, statCalls: 0, completedStats: 0 } +}) +vi.mock('node:fs', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + createReadStream: (...args: Parameters) => { + const stream = original.createReadStream(observed.state.missingPath ?? args[0], args[1]) + observed.streams.push(stream) + observed.state.onSource?.() + return stream + } + } +}) +vi.mock('node:fs/promises', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + stat: async (...args: Parameters) => { + observed.statCalls++ + const value = await original.stat(...args) + await observed.state.statWait + observed.completedStats++ + if (observed.state.statError) { + throw observed.state.statError + } + return value + } + } +}) + +let root: string +const servers: Server[] = [] +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-static-web-stream-')) + mkdirSync(join(root, 'assets')) + writeFileSync(join(root, 'assets', 'fixture.js'), Buffer.alloc(1024 * 1024, 1)) + writeFileSync(join(root, 'web-index.html'), 'web') + observed.state.statWait = Promise.resolve() + observed.state.statError = undefined + observed.state.missingPath = undefined + observed.state.onSource = undefined + observed.statCalls = 0 + observed.completedStats = 0 +}) +afterEach(async () => { + await Promise.all( + servers.splice(0).map(async (server) => { + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + }) + ) + await Promise.all( + observed.streams.splice(0).map(async (stream) => { + if (!stream.closed) { + const closed = new Promise((resolve) => stream.once('close', resolve)) + stream.destroy() + await closed + } + }) + ) + rmSync(root, { recursive: true, force: true }) +}) + +function fakeResponse() { + return Object.assign(new PassThrough({ highWaterMark: 16 }), { + setHeader: vi.fn(), + statusCode: 0, + headersSent: false + }) +} +function fileDescriptor(stream: ReadStream): number | null { + if ('fd' in stream && (typeof stream.fd === 'number' || stream.fd === null)) { + return stream.fd + } + throw new Error('Missing file descriptor') +} +function request(response = fakeResponse()) { + const handler = createStaticWebClientHandler(root) + handler( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This handler reads only these method/url fields. + { method: 'GET', url: '/assets/fixture.js' } as IncomingMessage, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture implements the writable response surface used by this handler. + response as unknown as ServerResponse + ) + return response +} +async function latestSource(count: number): Promise { + await expect.poll(() => observed.streams.length).toBe(count) + const source = observed.streams.at(-1) + if (!source) { + throw new Error('Missing file stream') + } + return source +} +async function startServer(): Promise { + const server = createServer(createStaticWebClientHandler(root)) + servers.push(server) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('Missing HTTP address') + } + return `http://127.0.0.1:${address.port}` +} + +it('closes 25 abandoned file readers and removes only owned response listeners', async () => { + const responses: ReturnType[] = [] + const otherClose = vi.fn() + const otherError = vi.fn() + for (let index = 0; index < 25; index++) { + const response = fakeResponse() + response.on('close', otherClose) + response.on('error', otherError) + request(response) + responses.push(response) + const source = await latestSource(index + 1) + await expect.poll(() => source.isPaused()).toBe(true) + const closed = once(response, 'close') + response.destroy() + await closed + } + await expect.poll(() => observed.streams.filter((stream) => !stream.closed).length).toBe(0) + expect(observed.streams.every((stream) => fileDescriptor(stream) === null)).toBe(true) + expect(responses.every((response) => response.listeners('close').length === 1)).toBe(true) + expect(responses.every((response) => response.listeners('error').length === 1)).toBe(true) + expect(otherClose).toHaveBeenCalledTimes(25) + expect(otherError).not.toHaveBeenCalled() +}) + +it('does not stat or open assets for an already closed response', async () => { + const response = fakeResponse() + const closed = once(response, 'close') + response.destroy() + await closed + request(response) + expect(observed.statCalls).toBe(0) + expect(observed.streams).toHaveLength(0) + expect(response.setHeader).not.toHaveBeenCalled() +}) + +it.each(['success', 'failure'])( + 'does not revive a response closed during stat %s', + async (outcome) => { + let releaseStat = (): void => {} + observed.state.statWait = new Promise((resolve) => { + releaseStat = resolve + }) + if (outcome === 'failure') { + observed.state.statError = new Error('stat failed after abandonment') + } + const response = request() + expect(observed.statCalls).toBe(1) + const ended = vi.spyOn(response, 'end') + const closed = once(response, 'close') + response.destroy() + await closed + releaseStat() + await expect.poll(() => observed.completedStats).toBe(1) + expect(observed.streams).toHaveLength(0) + expect(response.setHeader).not.toHaveBeenCalled() + expect(ended).not.toHaveBeenCalled() + } +) + +it('closes a file that is still opening when its response is destroyed', async () => { + const response = fakeResponse() + observed.state.onSource = () => { + const source = observed.streams.at(-1) + expect(source && fileDescriptor(source)).toBe(null) + response.destroy() + } + request(response) + const source = await latestSource(1) + await expect.poll(() => source.closed).toBe(true) + expect(fileDescriptor(source)).toBe(null) + expect(response.listenerCount('close')).toBe(0) + expect(response.listenerCount('error')).toBe(0) +}) + +it('closes its file reader when the response errors', async () => { + const response = request() + const source = await latestSource(1) + await expect.poll(() => source.isPaused()).toBe(true) + response.destroy(new Error('downstream failed')) + await expect.poll(() => source.closed).toBe(true) + expect(fileDescriptor(source)).toBe(null) + expect(response.listenerCount('close')).toBe(0) + expect(response.listenerCount('error')).toBe(0) +}) + +it('disconnects a response when its source fails after headers', async () => { + const response = request() + const source = await latestSource(1) + response.headersSent = true + const closed = once(response, 'close') + source.destroy(new Error('read failed after headers')) + await closed + await expect.poll(() => source.closed).toBe(true) + expect(response.statusCode).toBe(200) + expect(response.destroyed).toBe(true) + expect(response.listenerCount('close')).toBe(0) + expect(response.listenerCount('error')).toBe(0) +}) + +it('preserves actual HTTP GET bytes, cache headers and HEAD without a reader', async () => { + const url = await startServer() + const head = await fetch(`${url}/assets/fixture.js`, { method: 'HEAD' }) + expect(head.status).toBe(200) + expect(head.headers.get('content-length')).toBe(String(1024 * 1024)) + expect(await head.text()).toBe('') + expect(observed.streams).toHaveLength(0) + const get = await fetch(`${url}/assets/fixture.js`) + expect(get.status).toBe(200) + expect(get.headers.get('cache-control')).toBe('public, max-age=31536000, immutable') + expect(get.headers.get('content-type')).toBe('text/javascript; charset=utf-8') + expect(Buffer.from(await get.arrayBuffer())).toEqual(Buffer.alloc(1024 * 1024, 1)) + await expect.poll(() => observed.streams.every((source) => source.closed)).toBe(true) + const index = await fetch(`${url}/`) + expect(index.headers.get('cache-control')).toBe('no-cache') + expect(await index.text()).toBe('web') +}) + +it('answers an actual file-open error with an empty 500 instead of the asset length', async () => { + observed.state.missingPath = join(root, 'missing-after-stat.js') + const url = await startServer() + const response = await fetch(`${url}/assets/fixture.js`) + expect(response.status).toBe(500) + expect(response.headers.get('content-length')).toBe('0') + expect(response.headers.get('cache-control')).toBe('no-store') + expect(await response.text()).toBe('') + await expect.poll(() => observed.streams.every((source) => source.closed)).toBe(true) +}) diff --git a/src/main/runtime/rpc/static-web-client-handler.ts b/src/main/runtime/rpc/static-web-client-handler.ts index 53ecd85c73f..3ec8ed83919 100644 --- a/src/main/runtime/rpc/static-web-client-handler.ts +++ b/src/main/runtime/rpc/static-web-client-handler.ts @@ -29,6 +29,9 @@ async function handleStaticRequest( request: IncomingMessage, response: ServerResponse ): Promise { + if (response.destroyed) { + return + } if (request.method !== 'GET' && request.method !== 'HEAD') { response.setHeader('Allow', 'GET, HEAD') writeHttpStatus(response, 405) @@ -59,6 +62,9 @@ async function handleStaticRequest( writeHttpStatus(response, 404) return } + if (response.destroyed) { + return + } if (!fileStat.isFile()) { writeHttpStatus(response, 404) return @@ -80,8 +86,22 @@ async function handleStaticRequest( } const stream = createReadStream(absolutePath) + const stopReading = (): void => { + stream.destroy() + } + response.once('close', stopReading) + response.once('error', stopReading) + stream.once('close', () => { + response.off('close', stopReading) + response.off('error', stopReading) + }) stream.on('error', () => { + if (response.destroyed) { + return + } if (!response.headersSent) { + response.setHeader('Content-Length', 0) + response.setHeader('Cache-Control', 'no-store') writeHttpStatus(response, 500) return } @@ -136,6 +156,9 @@ function isAllowedStaticWebPath(pathname: string): boolean { } function writeHttpStatus(response: ServerResponse, statusCode: number): void { + if (response.destroyed) { + return + } response.statusCode = statusCode response.end() } diff --git a/src/main/runtime/rpc/subscription-registry-test-double.ts b/src/main/runtime/rpc/subscription-registry-test-double.ts index d74e8bc329f..0ee0fc79960 100644 --- a/src/main/runtime/rpc/subscription-registry-test-double.ts +++ b/src/main/runtime/rpc/subscription-registry-test-double.ts @@ -1,14 +1,19 @@ import type { SubscriptionRegistration } from '../orca-runtime' type Cleanup = () => void | Promise -type Entry = { cleanup: Cleanup; version: number } +type Entry = { + cleanup: Cleanup + version: number + request?: { connectionId: string; requestId: string } +} export type SubscriptionRegistryDouble = { registerSubscriptionCleanup: (id: string, cleanup: Cleanup, connectionId?: string) => void registerOwnedSubscriptionCleanup: ( id: string, cleanup: Cleanup, - connectionId?: string + connectionId?: string, + requestId?: string ) => SubscriptionRegistration cleanupSubscription: (id: string) => void cleanupSubscriptionIfOwnedByConnection: ( @@ -17,14 +22,18 @@ export type SubscriptionRegistryDouble = { throughVersion?: number ) => boolean getSubscriptionRegistrationVersion: () => number + releaseSubscriptionByRequest: (connectionId: string | undefined, requestId: string) => void cleanupSubscriptionsForConnection: (connectionId: string) => void /** Test-only inspection; the runtime deliberately exposes no such accessor. */ peekCleanup: (id: string) => Cleanup | undefined + /** Test-only inspection of the request-address index size. */ + requestAddressCount: () => number } /** * Faithful double of the runtime subscription registry (`OrcaRuntimeService`, - * `registerSubscriptionCleanup` through `cleanupSubscriptionsForConnection`). + * `registerSubscriptionCleanup` through `cleanupSubscriptionsForConnection`, plus + * `releaseSubscriptionByRequest`). * * This mirrors production line-for-line, so it can drift. If you change * `registerSubscriptionCleanup`, `cleanupSubscriptionAndWait`, @@ -43,6 +52,20 @@ export function createSubscriptionRegistryDouble(): SubscriptionRegistryDouble { let registrationVersion = 0 const byConnection = new Map>() const connectionByEntry = new Map() + const byRequest = new Map() + const requestKey = (connectionId: string, requestId: string): string => + JSON.stringify([connectionId, requestId]) + + // Mirrors removeRequestIndex: compare-and-delete, so a reused request id keeps its newer owner. + const removeRequestIndex = (entry: Entry): void => { + if (!entry.request) { + return + } + const key = requestKey(entry.request.connectionId, entry.request.requestId) + if (byRequest.get(key)?.entry === entry) { + byRequest.delete(key) + } + } const removeIndex = (id: string): void => { const connectionId = connectionByEntry.get(id) @@ -84,6 +107,7 @@ export function createSubscriptionRegistryDouble(): SubscriptionRegistryDouble { } cleanups.delete(id) removeIndex(id) + removeRequestIndex(entry) }) .finally(() => { if (inFlight.get(id)?.promise === promise) { @@ -109,15 +133,21 @@ export function createSubscriptionRegistryDouble(): SubscriptionRegistryDouble { const registerSubscriptionCleanup = ( id: string, cleanup: Cleanup, - connectionId?: string + connectionId?: string, + requestId?: string ): Entry => { const existing = cleanups.get(id) if (existing) { removeIndex(id) + removeRequestIndex(existing) cleanupOwned(id, existing) } - const entry = { cleanup, version: ++registrationVersion } + const request = connectionId && requestId ? { connectionId, requestId } : undefined + const entry: Entry = { cleanup, version: ++registrationVersion, request } cleanups.set(id, entry) + if (request) { + byRequest.set(requestKey(request.connectionId, request.requestId), { id, entry }) + } if (!connectionId) { return entry } @@ -133,14 +163,23 @@ export function createSubscriptionRegistryDouble(): SubscriptionRegistryDouble { return { registerSubscriptionCleanup, - registerOwnedSubscriptionCleanup: (id, cleanup, connectionId) => { - const entry = registerSubscriptionCleanup(id, cleanup, connectionId) + registerOwnedSubscriptionCleanup: (id, cleanup, connectionId, requestId) => { + const entry = registerSubscriptionCleanup(id, cleanup, connectionId, requestId) return { releaseIfCurrent: () => cleanupOwned(id, entry) } }, cleanupSubscription, getSubscriptionRegistrationVersion: () => registrationVersion, + releaseSubscriptionByRequest: (connectionId, requestId) => { + if (!connectionId) { + return + } + const target = byRequest.get(requestKey(connectionId, requestId)) + if (target) { + cleanupOwned(target.id, target.entry) + } + }, cleanupSubscriptionIfOwnedByConnection: (id, connectionId, throughVersion) => { const entry = cleanups.get(id) // Mirrors the production early-out: an unregistered id is already gone, not refused. @@ -173,6 +212,7 @@ export function createSubscriptionRegistryDouble(): SubscriptionRegistryDouble { byConnection.delete(connectionId) } }, - peekCleanup: (id) => cleanups.get(id)?.cleanup + peekCleanup: (id) => cleanups.get(id)?.cleanup, + requestAddressCount: () => byRequest.size } } diff --git a/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts b/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts index 29254fbcf3a..824513dd999 100644 --- a/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts +++ b/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts @@ -45,6 +45,7 @@ describe('terminal agent prompt send RPC', () => { expect(response.ok).toBe(true) expect(runtime.isTerminalRunningSettledPromptAgent).toHaveBeenCalledWith('terminal-1') expect(sendTerminalAgentPrompt).toHaveBeenCalledWith('terminal-1', 'review this change', { + inputKind: 'driving', beforeWrite: undefined, signal: undefined }) @@ -81,7 +82,7 @@ describe('terminal agent prompt send RPC', () => { expect(sendTerminal).toHaveBeenCalledWith( 'terminal-1', { text: 'echo x', enter: true, interrupt: false }, - { beforeWrite: undefined, signal: undefined } + { inputKind: 'driving', beforeWrite: undefined, signal: undefined } ) expect(sendTerminalAgentPrompt).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts b/src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts index 3dc1f6b2918..f93b772994e 100644 --- a/src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts @@ -241,11 +241,15 @@ describe('terminal multiplex RPC', () => { )! ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'still interactive\r', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { + text: 'still interactive\r', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) handlers.get(16)?.( @@ -434,11 +438,15 @@ describe('terminal multiplex RPC', () => { )! ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-8', { - text: 'remote-still-interactive\r', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-8', + { + text: 'remote-still-interactive\r', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) const frameCountBeforeAck = binaryFrames.length diff --git a/src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts b/src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts index 03327859c62..57abea846ae 100644 --- a/src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts @@ -413,11 +413,15 @@ describe('terminal multiplex RPC', () => { )! ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'still interactive\r', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { + text: 'still interactive\r', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) binaryFrames.splice(0) diff --git a/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts b/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts index 45ff2d77b8c..562181b2752 100644 --- a/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts @@ -206,11 +206,15 @@ describe('terminal multiplex RPC', () => { ) ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'ls\r', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { + text: 'ls\r', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) const sentAfterSuccessfulClaim = vi.mocked(runtime.sendTerminal).mock.calls.length vi.mocked(runtime.updateRemoteDesktopViewer).mockResolvedValueOnce(false) @@ -248,11 +252,15 @@ describe('terminal multiplex RPC', () => { ) } await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenLastCalledWith('terminal-1', { - text: 'retry', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenLastCalledWith( + 'terminal-1', + { + text: 'retry', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) dataListenerRef.current?.('a') diff --git a/src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts b/src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts index b7fb20b2f73..ae2298a20dc 100644 --- a/src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts @@ -327,11 +327,15 @@ describe('terminal multiplex RPC', () => { ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'echo one\necho two\r\n', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { + text: 'echo one\necho two\r\n', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) runtime.cleanupSubscription('terminal-multiplex:conn-byte-preserving') @@ -403,11 +407,15 @@ describe('terminal multiplex RPC', () => { ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'printf a\nprintf b\r\n', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { + text: 'printf a\nprintf b\r\n', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) runtime.cleanupSubscription('terminal-1:desktop-1') diff --git a/src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts b/src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts index ef6087923bb..eecd7b03859 100644 --- a/src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts @@ -309,7 +309,11 @@ describe('terminal multiplex RPC', () => { expect(runtime.sendTerminal).toHaveBeenCalledWith( 'terminal-1', { text: 'x', enter: false, interrupt: false }, - { reserveWrite: expect.any(Function), afterWrite: expect.any(Function) } + { + inputKind: 'driving', + reserveWrite: expect.any(Function), + afterWrite: expect.any(Function) + } ) ) expect(beginMobileInputFloor.mock.invocationCallOrder[0]).toBeLessThan( diff --git a/src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts b/src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts index 778cd022068..a5b40b2f1eb 100644 --- a/src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts @@ -595,7 +595,11 @@ describe('terminal multiplex RPC', () => { // Widened gate: a desktop client must mount + await its late PTY, not skip // straight to the bare scrollback path the way it did under the mobile-only gate. expect(runtime.requestRendererTerminalTabMount).toHaveBeenCalledWith('terminal-1') - expect(runtime.waitForLeafPtyId).toHaveBeenCalledWith('terminal-1', 10_000, undefined) + expect(runtime.waitForLeafPtyId).toHaveBeenCalledWith( + 'terminal-1', + 10_000, + expect.any(AbortSignal) + ) expect(messages.map((msg) => JSON.parse(msg).result?.type)).toEqual(['subscribed', 'end']) }) }) diff --git a/src/main/runtime/rpc/terminal-multiplex-test-harness.ts b/src/main/runtime/rpc/terminal-multiplex-test-harness.ts index 13368bd680d..c85e962d198 100644 --- a/src/main/runtime/rpc/terminal-multiplex-test-harness.ts +++ b/src/main/runtime/rpc/terminal-multiplex-test-harness.ts @@ -16,10 +16,13 @@ export const SET_OUTPUT_PAUSED_OPCODE = 16 as TerminalStreamOpcode export const WRITE_UNAVAILABLE_OPCODE = 17 as TerminalStreamOpcode export function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { + // Why: every terminal subscription registers at admission, even one that never reaches a pty. + const registry = createSubscriptionRegistryDouble() const serializeAuthoritativeTerminalBuffer = overrides.serializeAuthoritativeTerminalBuffer ?? ((ptyId: string, opts?: { scrollbackRows?: number }) => overrides.serializeTerminalBuffer?.(ptyId, opts)) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This partial runtime supplies the terminal RPC methods these tests invoke. return { getRuntimeId: () => 'test-runtime', subscribeToPtyExit: vi.fn(() => vi.fn()), @@ -40,6 +43,7 @@ export function stubRuntime(overrides: Partial = {}): OrcaRu isRemoteDesktopViewerOwner: vi.fn().mockReturnValue(false), serializeAuthoritativeTerminalBuffer, getPtyOutputSequence: vi.fn().mockReturnValue(0), + registerOwnedSubscriptionCleanup: registry.registerOwnedSubscriptionCleanup, ...overrides } as OrcaRuntimeService } diff --git a/src/main/runtime/rpc/terminal-output-batching.test.ts b/src/main/runtime/rpc/terminal-output-batching.test.ts index e597f5e5b13..214a5a2814e 100644 --- a/src/main/runtime/rpc/terminal-output-batching.test.ts +++ b/src/main/runtime/rpc/terminal-output-batching.test.ts @@ -347,7 +347,11 @@ describe('terminal output batching', () => { expect(runtime.sendTerminal).toHaveBeenCalledWith( 'terminal-1', { text: 'ls\r', enter: false, interrupt: false }, - { reserveWrite: expect.any(Function), afterWrite: expect.any(Function) } + { + inputKind: 'driving', + reserveWrite: expect.any(Function), + afterWrite: expect.any(Function) + } ) ) expect(beginMobileInputFloor).toHaveBeenCalledWith('pty-1', 'mobile-1') diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index e9fb481aae0..9aa55eb823e 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -232,6 +232,7 @@ describe('terminal send RPC', () => { interrupt: false }, { + inputKind: 'driving', beforeWrite: undefined, reserveWrite: expect.any(Function), afterWrite: expect.any(Function) @@ -370,7 +371,7 @@ describe('terminal send RPC', () => { expect(runtime.sendTerminal).toHaveBeenCalledWith( 'terminal-1', { text: '\x1b[3;4R', enter: false, interrupt: false }, - { beforeWrite: undefined } + { beforeWrite: undefined, inputKind: 'query-reply' } ) expect(runtime.mobileTookFloor).not.toHaveBeenCalled() }) @@ -570,7 +571,7 @@ describe('terminal send RPC', () => { enter: false, interrupt: false }, - { beforeWrite: undefined } + { inputKind: 'driving', beforeWrite: undefined } ) }) @@ -653,7 +654,7 @@ describe('terminal send RPC', () => { enter: true, interrupt: false }, - { beforeWrite: expect.any(Function) } + { inputKind: 'driving', beforeWrite: expect.any(Function) } ) }) diff --git a/src/main/runtime/rpc/terminal-stream-byte-length.test.ts b/src/main/runtime/rpc/terminal-stream-byte-length.test.ts index 60c4c86fe44..1c0f08cf33f 100644 --- a/src/main/runtime/rpc/terminal-stream-byte-length.test.ts +++ b/src/main/runtime/rpc/terminal-stream-byte-length.test.ts @@ -5,7 +5,6 @@ import { terminalStreamByteLength, terminalStreamByteLengthExceeds } from './terminal-stream-byte-length' -import { TERMINAL_OUTPUT_BATCH_MAX_BYTES } from '../../../shared/terminal-multiplex-flow-control' // Copy of the pre-change implementation (shared/clipboard-text.ts // measureClipboardTextByteLength), kept here so equivalence is checked against the @@ -323,92 +322,6 @@ describe('terminal stream byte length equivalence with the legacy code-point sca } } } - expect(MIN_NATIVE_BYTE_LENGTH_CODE_UNITS).toBeGreaterThan(0) - }) -}) - -// Reproduces createTerminalOutputBatcher's byte accounting exactly, under both the -// legacy scan and the new measurement, and asserts IDENTICAL flush boundaries. This is -// what makes the partial-count behaviour provably unobservable at that call site. -function simulateBatcherFlushes( - chunks: string[], - measure: ( - data: string, - options: { stopAfterBytes?: number } - ) => { byteLength: number; exceededLimit: boolean } -): string[] { - const flushes: string[] = [] - let pending: string[] = [] - let bytes = 0 - const flush = (): void => { - if (pending.length === 0) { - return - } - flushes.push(pending.join('')) - pending = [] - bytes = 0 - } - for (const data of chunks) { - if (!data) { - continue - } - pending.push(data) - const remainingBudget = Math.max(1, TERMINAL_OUTPUT_BATCH_MAX_BYTES - bytes) - const measurement = measure(data, { stopAfterBytes: remainingBudget }) - bytes += measurement.byteLength - if (measurement.exceededLimit || bytes >= TERMINAL_OUTPUT_BATCH_MAX_BYTES) { - flush() - } - } - flush() - return flushes -} - -describe('terminal output batcher flush boundaries are unchanged', () => { - it( - 'produces identical flush boundaries over randomized multi-chunk runs', - { timeout: 60000 }, - () => { - const random = mulberry32(0x1337) - for (let run = 0; run < 300; run += 1) { - const chunks: string[] = [] - const chunkCount = 1 + Math.floor(random() * 40) - for (let index = 0; index < chunkCount; index += 1) { - // Sizes straddle the 64KiB batch cap so single chunks both fit and blow the budget. - // Sizes straddle the native-call floor too, so runs mix scan-branch and - // native-branch measurements inside one batcher's byte accounting. - const scale = random() - const maxUnits = - scale < 0.25 - ? MIN_NATIVE_BYTE_LENGTH_CODE_UNITS * 2 - : scale < 0.5 - ? 64 - : scale < 0.85 - ? 20000 - : 90000 - chunks.push(random() < 0.5 ? randomString(random, maxUnits) : rawUtf16(random, maxUnits)) - } - const legacyFlushes = simulateBatcherFlushes(chunks, legacyMeasure) - const actualFlushes = simulateBatcherFlushes(chunks, measureTerminalStreamByteLength) - if (legacyFlushes.length !== actualFlushes.length) { - throw new Error(`flush count diverged on run ${run}`) - } - for (let index = 0; index < legacyFlushes.length; index += 1) { - if (legacyFlushes[index] !== actualFlushes[index]) { - throw new Error(`flush ${index} diverged on run ${run}`) - } - } - } - expect(true).toBe(true) - } - ) - - it('exercises runs that actually cross the batch budget', () => { - const oversized = '\u{1f600}'.repeat(TERMINAL_OUTPUT_BATCH_MAX_BYTES) - const chunks = ['a'.repeat(10), oversized, 'b'.repeat(10), oversized, 'c'] - const legacyFlushes = simulateBatcherFlushes(chunks, legacyMeasure) - expect(legacyFlushes.length).toBeGreaterThan(1) - expect(simulateBatcherFlushes(chunks, measureTerminalStreamByteLength)).toEqual(legacyFlushes) }) }) @@ -419,23 +332,8 @@ describe('resync trim byte accounting for a snapshot-sliced chunk', () => { it('re-measures a sliced chunk in UTF-8 bytes, not UTF-16 code units', () => { const data = '\u{1f600}é走a' const sliced = data.slice(2) - expect(terminalStreamByteLength(sliced)).toBe(legacyByteLength(sliced)) // Guards the mutant: code-unit length would be 4 here, UTF-8 is 6. expect(terminalStreamByteLength(sliced)).toBe(6) expect(terminalStreamByteLength(sliced)).not.toBe(sliced.length) }) - - it('matches the legacy byte length for every suffix slice of multi-byte terminal text', () => { - const random = mulberry32(0x51ced) - for (let iteration = 0; iteration < 2000; iteration += 1) { - const data = iteration % 2 === 0 ? randomString(random, 24) : rawUtf16(random, 24) - for (let offset = 0; offset <= data.length; offset += 1) { - const sliced = data.slice(offset) - if (terminalStreamByteLength(sliced) !== legacyByteLength(sliced)) { - throw new Error(`sliced byte length diverged for ${JSON.stringify(data)} at ${offset}`) - } - } - } - expect(true).toBe(true) - }) }) diff --git a/src/main/runtime/rpc/terminal-subscribe-admission.test.ts b/src/main/runtime/rpc/terminal-subscribe-admission.test.ts new file mode 100644 index 00000000000..75df605d1c8 --- /dev/null +++ b/src/main/runtime/rpc/terminal-subscribe-admission.test.ts @@ -0,0 +1,670 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../orca-runtime' +import type { RpcRequest } from './core' +import { RpcDispatcher } from './dispatcher' +import { TERMINAL_METHODS } from './methods/terminal' +import { TerminalUnsubscribe } from '../../../shared/rpc-contract/terminal-viewport-schemas-params' +import { createSubscriptionRegistryDouble } from './subscription-registry-test-double' + +const PTY_ID = 'pty-1' +const SUBSCRIPTION_ID = 'terminal-1:phone-1' + +const binaryParams = { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + viewport: { cols: 40, rows: 20 }, + capabilities: { terminalBinaryStream: 1 } +} + +const leaseOnlyParams = { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1, mobileInputLeaseOnly: 1 } +} + +let nextRequestId = 0 +const subscribeRequest = (params: unknown): RpcRequest => ({ + id: `req-${++nextRequestId}`, + authToken: 'tok', + method: 'terminal.subscribe', + params +}) +const unsubscribeRequest = (): RpcRequest => ({ + id: `req-${++nextRequestId}`, + authToken: 'tok', + method: 'terminal.unsubscribe', + params: { subscriptionId: SUBSCRIPTION_ID } +}) + +/** What a phone that addresses its request sends; the slot fields stay for older hosts. */ +const phoneUnsubscribeParams = (requestId: string) => ({ + subscriptionId: SUBSCRIPTION_ID, + client: { id: 'phone-1' }, + requestId +}) +const requestUnsubscribe = (requestId: string): RpcRequest => ({ + id: `req-${++nextRequestId}`, + authToken: 'tok', + method: 'terminal.unsubscribe', + params: phoneUnsubscribeParams(requestId) +}) + +const phoneConnection = (connectionId: string, signal?: AbortSignal) => ({ + connectionId, + signal, + sendBinary: vi.fn(), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) +}) + +const resultTypes = (messages: string[]): unknown[] => + messages.map((message) => JSON.parse(message).result?.type) + +const flush = (ms = 20): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + +/** Resolves pending `waitForLeafPtyId` calls on demand and rejects them when their signal aborts, like the runtime. */ +function createPtyWaits() { + const waits: { resolve: (ptyId: string) => void }[] = [] + return { + waits, + waitForLeafPtyId: vi.fn( + (_handle: string, _timeoutMs?: number, signal?: AbortSignal) => + new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error('request_aborted')) + return + } + signal?.addEventListener('abort', () => reject(new Error('request_aborted')), { + once: true + }) + waits.push({ resolve }) + }) + ), + spawn: () => { + for (const wait of waits.splice(0)) { + wait.resolve(PTY_ID) + } + } + } +} + +function stubRuntime(overrides: Record = {}) { + const registry = createSubscriptionRegistryDouble() + const ptyWaits = createPtyWaits() + let ptyReady = false + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This partial runtime supplies the terminal RPC methods these tests invoke. + const runtime = { + getRuntimeId: () => 'test-runtime', + registerRemoteTerminalViewSubscriber: () => () => {}, + requestRendererTerminalTabMount: vi.fn(() => false), + resolveLeafForHandle: vi.fn(() => ({ ptyId: ptyReady ? PTY_ID : null })), + waitForLeafPtyId: ptyWaits.waitForLeafPtyId, + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn(() => vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: ['scrollback'], truncated: false }), + serializeTerminalBuffer: vi + .fn() + .mockResolvedValue({ data: 'snapshot', cols: 40, rows: 20, seq: 4 }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 40, rows: 20 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn(() => vi.fn()), + subscribeToFitOverrideChanges: vi.fn(() => vi.fn()), + subscribeToPtyExit: vi.fn(() => vi.fn()), + registerOwnedSubscriptionCleanup: registry.registerOwnedSubscriptionCleanup, + cleanupSubscriptionIfOwnedByConnection: registry.cleanupSubscriptionIfOwnedByConnection, + getSubscriptionRegistrationVersion: registry.getSubscriptionRegistrationVersion, + releaseSubscriptionByRequest: registry.releaseSubscriptionByRequest, + ...overrides + } as unknown as OrcaRuntimeService + const spawn = (): void => { + ptyReady = true + ptyWaits.spawn() + } + return { runtime, registry, ptyWaits, spawn } +} + +/** The real runtime, with only handle resolution, the pty wait, the scrollback read and tab mounting doubled. */ +function createRealRuntime() { + const runtime = new OrcaRuntimeService() + const sizes = new Map([[PTY_ID, { cols: 120, rows: 40 }]]) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + resize: (ptyId, cols, rows) => { + sizes.set(ptyId, { cols, rows }) + return true + }, + getSize: (ptyId) => sizes.get(ptyId) ?? null + }) + const ptyWaits = createPtyWaits() + let ptyReady = false + vi.spyOn(runtime, 'resolveLeafForHandle').mockImplementation(() => ({ + ptyId: ptyReady ? PTY_ID : null + })) + vi.spyOn(runtime, 'waitForLeafPtyId').mockImplementation(ptyWaits.waitForLeafPtyId) + vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ + handle: 'terminal-1', + status: 'running', + tail: [], + truncated: false, + nextCursor: null + }) + vi.spyOn(runtime, 'requestRendererTerminalTabMount').mockReturnValue(false) + const spawn = (): void => { + ptyReady = true + ptyWaits.spawn() + } + return { runtime, ptyWaits, spawn, sizes } +} + +describe('terminal.subscribe registers at admission', () => { + it('lets an unsubscribe end a subscribe that is still waiting for its pty', async () => { + const { runtime, registry, ptyWaits, spawn } = stubRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const messages: string[] = [] + const subscribe = dispatcher.dispatchStreaming( + subscribeRequest(binaryParams), + (message) => messages.push(message), + phoneConnection('conn-a') + ) + await vi.waitFor(() => expect(ptyWaits.waitForLeafPtyId).toHaveBeenCalled()) + + const replies: string[] = [] + await dispatcher.dispatchStreaming(unsubscribeRequest(), (reply) => replies.push(reply), { + connectionId: 'conn-a' + }) + spawn() + await flush() + + try { + expect(JSON.parse(replies[0]!).result).toEqual({ unsubscribed: true }) + expect(registry.peekCleanup(SUBSCRIPTION_ID)).toBeUndefined() + expect(runtime.handleMobileSubscribe).not.toHaveBeenCalled() + expect(resultTypes(messages)).toEqual(['end']) + } finally { + registry.cleanupSubscriptionsForConnection('conn-a') + await subscribe + } + }) + + it('leaves the desktop driver idle when the phone leaves before the pty is ready', async () => { + const { runtime, ptyWaits, spawn } = createRealRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const messages: string[] = [] + const subscribe = dispatcher.dispatchStreaming( + subscribeRequest(binaryParams), + (message) => messages.push(message), + phoneConnection('conn-a') + ) + await vi.waitFor(() => expect(ptyWaits.waitForLeafPtyId).toHaveBeenCalled()) + + await dispatcher.dispatchStreaming(unsubscribeRequest(), vi.fn(), { connectionId: 'conn-a' }) + spawn() + await flush() + + try { + expect(runtime.getDriver(PTY_ID).kind).toBe('idle') + expect(runtime.getTerminalFitOverride(PTY_ID)).toBeNull() + expect(resultTypes(messages)).toEqual(['end']) + } finally { + runtime.cleanupSubscriptionsForConnection('conn-a') + await subscribe + } + }) + + it('ends a pending subscribe replaced by the same slot without adding its presence', async () => { + const { runtime, registry, ptyWaits, spawn } = stubRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const firstMessages: string[] = [] + const first = dispatcher.dispatchStreaming( + subscribeRequest(binaryParams), + (message) => firstMessages.push(message), + phoneConnection('conn-a') + ) + await vi.waitFor(() => expect(ptyWaits.waitForLeafPtyId).toHaveBeenCalledTimes(1)) + + const second = dispatcher.dispatchStreaming( + subscribeRequest(binaryParams), + vi.fn(), + phoneConnection('conn-b') + ) + await vi.waitFor(() => expect(ptyWaits.waitForLeafPtyId).toHaveBeenCalledTimes(2)) + const replacement = registry.peekCleanup(SUBSCRIPTION_ID) + spawn() + await first + await vi.waitFor(() => expect(runtime.handleMobileSubscribe).toHaveBeenCalled()) + await flush() + + try { + expect(resultTypes(firstMessages)).toEqual(['end']) + // Only the replacement joins; the evicted request never adds presence behind it. + expect(runtime.handleMobileSubscribe).toHaveBeenCalledOnce() + expect(runtime.handleMobileUnsubscribe).not.toHaveBeenCalled() + expect(registry.peekCleanup(SUBSCRIPTION_ID)).toBe(replacement) + } finally { + registry.cleanupSubscriptionsForConnection('conn-b') + await second + } + }) + + it('leaves the desktop driver idle when the phone leaves during the phone-fit layout', async () => { + const { runtime, sizes } = createRealRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + let leaveDuringLayout = true + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + resize: (ptyId, cols, rows) => { + sizes.set(ptyId, { cols, rows }) + // The phone's unsubscribe lands while handleMobileSubscribe awaits its layout. + if (leaveDuringLayout) { + leaveDuringLayout = false + runtime.cleanupSubscriptionIfOwnedByConnection(SUBSCRIPTION_ID, 'conn-a') + } + return true + }, + getSize: (ptyId) => sizes.get(ptyId) ?? null + }) + vi.spyOn(runtime, 'resolveLeafForHandle').mockReturnValue({ ptyId: PTY_ID }) + const messages: string[] = [] + + const subscribe = dispatcher.dispatchStreaming( + subscribeRequest(binaryParams), + (message) => messages.push(message), + phoneConnection('conn-a') + ) + await vi.waitFor(() => expect(leaveDuringLayout).toBe(false)) + await subscribe + + await vi.waitFor(() => expect(runtime.getDriver(PTY_ID).kind).toBe('idle'), { + timeout: 2_000 + }) + expect(resultTypes(messages)).toEqual(['end']) + }) + + it('leaves no registration when the request aborts while the pty is pending', async () => { + const { runtime, registry, ptyWaits, spawn } = stubRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const request = new AbortController() + const messages: string[] = [] + const subscribe = dispatcher.dispatchStreaming( + subscribeRequest(binaryParams), + (message) => messages.push(message), + phoneConnection('conn-a', request.signal) + ) + await vi.waitFor(() => expect(ptyWaits.waitForLeafPtyId).toHaveBeenCalled()) + + request.abort() + spawn() + await subscribe + await flush() + + expect(registry.peekCleanup(SUBSCRIPTION_ID)).toBeUndefined() + expect(runtime.handleMobileSubscribe).not.toHaveBeenCalled() + expect(runtime.subscribeToTerminalData).not.toHaveBeenCalled() + expect(resultTypes(messages)).toEqual(['end']) + }) + + it('does not follow a released stream end with an error', async () => { + let rejectSubscribe = (_error: Error): void => {} + const { runtime, registry } = stubRuntime({ + resolveLeafForHandle: vi.fn(() => ({ ptyId: PTY_ID })), + handleMobileSubscribe: vi.fn( + () => + new Promise((_resolve, reject) => { + rejectSubscribe = reject + }) + ) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const messages: string[] = [] + const subscribe = dispatcher.dispatchStreaming( + subscribeRequest(leaseOnlyParams), + (message) => messages.push(message), + phoneConnection('conn-a') + ) + await vi.waitFor(() => expect(runtime.handleMobileSubscribe).toHaveBeenCalled()) + + registry.cleanupSubscriptionIfOwnedByConnection(SUBSCRIPTION_ID, 'conn-a') + rejectSubscribe(new Error('subscribe_failed')) + await subscribe + + expect(messages.map((message) => JSON.parse(message))).toEqual([ + expect.objectContaining({ ok: true, result: { type: 'end' } }) + ]) + }) + + it('fails without end when the stream errors before anything released it', async () => { + const { runtime, registry } = stubRuntime({ + resolveLeafForHandle: vi.fn(() => ({ ptyId: PTY_ID })), + handleMobileSubscribe: vi.fn().mockRejectedValue(new Error('subscribe_failed')) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const messages: string[] = [] + + await dispatcher.dispatchStreaming( + subscribeRequest(leaseOnlyParams), + (message) => messages.push(message), + phoneConnection('conn-a') + ) + + expect(messages.map((message) => JSON.parse(message).ok)).toEqual([false]) + expect(registry.peekCleanup(SUBSCRIPTION_ID)).toBeUndefined() + // Presence was added before the await, so the failed request still removes it. + expect(runtime.handleMobileUnsubscribe).toHaveBeenCalledWith(PTY_ID, 'phone-1') + }) +}) + +describe('terminal.subscribe characterization', () => { + it('answers a dead pty with a scrollback preview, then one end, and keeps no registration', async () => { + const { runtime, registry } = stubRuntime({ + waitForLeafPtyId: vi.fn().mockRejectedValue(new Error('Timed out waiting for PTY to spawn')) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const messages: string[] = [] + + await dispatcher.dispatchStreaming( + subscribeRequest(binaryParams), + (message) => messages.push(message), + phoneConnection('conn-a') + ) + + expect(runtime.waitForLeafPtyId).toHaveBeenCalled() + expect(messages.map((message) => JSON.parse(message).result)).toEqual([ + { type: 'subscribed', streamId: null, lines: ['scrollback'], truncated: false }, + { type: 'end' } + ]) + expect(registry.peekCleanup(SUBSCRIPTION_ID)).toBeUndefined() + }) + + it('stops listening to a long-lived IPC signal once the stream is released', async () => { + const { runtime, registry } = stubRuntime({ + resolveLeafForHandle: vi.fn(() => ({ ptyId: PTY_ID })) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + // IPC keeps one controller per subscription id and it outlives the dispatch. + const ipcSubscription = new AbortController() + const addAbort = vi.spyOn(ipcSubscription.signal, 'addEventListener') + const removeAbort = vi.spyOn(ipcSubscription.signal, 'removeEventListener') + const messages: string[] = [] + const subscribe = dispatcher.dispatchStreaming( + subscribeRequest(binaryParams), + (message) => messages.push(message), + { ...phoneConnection('ipc-a'), signal: ipcSubscription.signal } + ) + await vi.waitFor(() => expect(runtime.handleMobileSubscribe).toHaveBeenCalled()) + expect(addAbort).toHaveBeenCalledWith('abort', expect.any(Function), { once: true }) + + registry.cleanupSubscriptionIfOwnedByConnection(SUBSCRIPTION_ID, 'ipc-a') + await subscribe + expect(removeAbort).toHaveBeenCalledWith('abort', addAbort.mock.calls[0]![1]) + + ipcSubscription.abort() + await flush() + expect(runtime.handleMobileUnsubscribe).toHaveBeenCalledOnce() + expect(resultTypes(messages).filter((type) => type === 'end')).toHaveLength(1) + }) + + it('ends a stream when its IPC subscription is aborted', async () => { + const { runtime, registry } = stubRuntime({ + resolveLeafForHandle: vi.fn(() => ({ ptyId: PTY_ID })) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const ipcSubscription = new AbortController() + const messages: string[] = [] + const subscribe = dispatcher.dispatchStreaming( + subscribeRequest(binaryParams), + (message) => messages.push(message), + { ...phoneConnection('ipc-a'), signal: ipcSubscription.signal } + ) + await vi.waitFor(() => expect(runtime.handleMobileSubscribe).toHaveBeenCalled()) + + ipcSubscription.abort() + await subscribe + + expect(registry.peekCleanup(SUBSCRIPTION_ID)).toBeUndefined() + expect(runtime.handleMobileUnsubscribe).toHaveBeenCalledWith(PTY_ID, 'phone-1') + expect(resultTypes(messages).at(-1)).toBe('end') + }) + + it('rejects a phone without binary streaming before it can replace the live stream', async () => { + const { runtime, registry } = stubRuntime({ + waitForLeafPtyId: vi.fn().mockRejectedValue(new Error('Timed out waiting for PTY to spawn')) + }) + const live = vi.fn() + registry.registerOwnedSubscriptionCleanup(SUBSCRIPTION_ID, live, 'conn-a') + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const messages: string[] = [] + + // A pre-binary phone subscribing to a pty that never spawned: this used to get a preview and end. + await dispatcher.dispatchStreaming( + subscribeRequest({ terminal: 'terminal-1', client: { id: 'phone-1', type: 'mobile' } }), + (message) => messages.push(message), + { connectionId: 'conn-b' } + ) + + expect(JSON.parse(messages[0]!)).toMatchObject({ + ok: false, + error: { message: 'binary_terminal_stream_required' } + }) + expect(messages).toHaveLength(1) + expect(runtime.waitForLeafPtyId).not.toHaveBeenCalled() + expect(live).not.toHaveBeenCalled() + expect(registry.peekCleanup(SUBSCRIPTION_ID)).toBe(live) + }) +}) + +describe('terminal.unsubscribe addressed by request', () => { + const liveStub = () => stubRuntime({ resolveLeafForHandle: vi.fn(() => ({ ptyId: PTY_ID })) }) + + it('does not let a replaced request end the newer stream on the same slot', async () => { + const { runtime, registry } = liveStub() + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const older = subscribeRequest(binaryParams) + const first = dispatcher.dispatchStreaming(older, vi.fn(), phoneConnection('conn-a')) + await vi.waitFor(() => expect(runtime.handleMobileSubscribe).toHaveBeenCalledTimes(1)) + const newerMessages: string[] = [] + const second = dispatcher.dispatchStreaming( + subscribeRequest(binaryParams), + (message) => newerMessages.push(message), + phoneConnection('conn-a') + ) + await vi.waitFor(() => expect(runtime.handleMobileSubscribe).toHaveBeenCalledTimes(2)) + const replacement = registry.peekCleanup(SUBSCRIPTION_ID) + + // The phone disposes the older stream only after the newer one registered. + await dispatcher.dispatchStreaming(requestUnsubscribe(older.id), vi.fn(), { + connectionId: 'conn-a' + }) + await flush() + + try { + expect(registry.peekCleanup(SUBSCRIPTION_ID)).toBe(replacement) + expect(resultTypes(newerMessages)).not.toContain('end') + } finally { + registry.cleanupSubscriptionsForConnection('conn-a') + await Promise.all([first, second]) + } + }) + + it('ends a subscribe still waiting for its pty, addressed by its request id', async () => { + const { runtime, ptyWaits, spawn } = createRealRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const pending = subscribeRequest(binaryParams) + const messages: string[] = [] + const subscribe = dispatcher.dispatchStreaming( + pending, + (message) => messages.push(message), + phoneConnection('conn-a') + ) + await vi.waitFor(() => expect(ptyWaits.waitForLeafPtyId).toHaveBeenCalled()) + + await dispatcher.dispatchStreaming(requestUnsubscribe(pending.id), vi.fn(), { + connectionId: 'conn-a' + }) + spawn() + await flush() + + try { + expect(runtime.getDriver(PTY_ID).kind).toBe('idle') + expect(runtime.getTerminalFitOverride(PTY_ID)).toBeNull() + expect(resultTypes(messages)).toEqual(['end']) + } finally { + runtime.cleanupSubscriptionsForConnection('conn-a') + await subscribe + } + }) + + it.each([ + ['an unknown request', () => 'req-unknown'], + ['the unsubscribe itself', (unsubscribeId: string) => unsubscribeId], + ['a non-terminal stream', () => 'req-tabs'] + ])('treats %s as a no-op without touching the slot', async (_label, target) => { + const { runtime, registry } = liveStub() + const tabsCleanup = vi.fn() + registry.registerSubscriptionCleanup('session.tabs:conn-a:wt:req-tabs', tabsCleanup, 'conn-a') + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const messages: string[] = [] + const subscribe = dispatcher.dispatchStreaming( + subscribeRequest(binaryParams), + (message) => messages.push(message), + phoneConnection('conn-a') + ) + await vi.waitFor(() => expect(runtime.handleMobileSubscribe).toHaveBeenCalled()) + const live = registry.peekCleanup(SUBSCRIPTION_ID) + + const unsubscribeId = `req-${++nextRequestId}` + const replies: string[] = [] + await dispatcher.dispatchStreaming( + { + id: unsubscribeId, + authToken: 'tok', + method: 'terminal.unsubscribe', + params: phoneUnsubscribeParams(target(unsubscribeId)) + }, + (reply) => replies.push(reply), + { connectionId: 'conn-a' } + ) + await flush() + + try { + expect(JSON.parse(replies[0]!).result).toEqual({ unsubscribed: true }) + expect(registry.peekCleanup(SUBSCRIPTION_ID)).toBe(live) + expect(resultTypes(messages)).not.toContain('end') + expect(tabsCleanup).not.toHaveBeenCalled() + } finally { + registry.cleanupSubscriptionsForConnection('conn-a') + await subscribe + } + }) + + it('is a no-op on a socket without a connection id', async () => { + const { runtime, registry } = liveStub() + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const live = subscribeRequest(binaryParams) + const subscribe = dispatcher.dispatchStreaming(live, vi.fn(), phoneConnection('conn-a')) + await vi.waitFor(() => expect(runtime.handleMobileSubscribe).toHaveBeenCalled()) + const liveCleanup = registry.peekCleanup(SUBSCRIPTION_ID) + + await dispatcher.dispatchStreaming(requestUnsubscribe(live.id), vi.fn()) + await flush() + + try { + expect(registry.peekCleanup(SUBSCRIPTION_ID)).toBe(liveCleanup) + } finally { + registry.cleanupSubscriptionsForConnection('conn-a') + await subscribe + } + }) + + it('lets a host without the field strip it and end the slot, as before', async () => { + // The shape every earlier host validates `terminal.unsubscribe` with; it is not strict. + const legacySchema = TerminalUnsubscribe.omit({ requestId: true }) + const legacyParams = legacySchema.parse(phoneUnsubscribeParams('req-any')) + expect(legacyParams).toEqual({ subscriptionId: SUBSCRIPTION_ID, client: { id: 'phone-1' } }) + + const { runtime, registry } = liveStub() + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const messages: string[] = [] + const subscribe = dispatcher.dispatchStreaming( + subscribeRequest(binaryParams), + (message) => messages.push(message), + phoneConnection('conn-a') + ) + await vi.waitFor(() => expect(runtime.handleMobileSubscribe).toHaveBeenCalled()) + + await dispatcher.dispatchStreaming( + { + id: `req-${++nextRequestId}`, + authToken: 'tok', + method: 'terminal.unsubscribe', + params: legacyParams + }, + vi.fn(), + { connectionId: 'conn-a' } + ) + await subscribe + + expect(registry.peekCleanup(SUBSCRIPTION_ID)).toBeUndefined() + expect(resultTypes(messages).at(-1)).toBe('end') + }) + + it('never lets a back-to-back unsubscribe overtake the subscribe it names', async () => { + const { runtime, registry } = liveStub() + const register = vi.spyOn(runtime, 'registerOwnedSubscriptionCleanup') + const release = vi.spyOn(runtime, 'releaseSubscriptionByRequest') + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const subscribe = subscribeRequest(binaryParams) + const messages: string[] = [] + + const streaming = dispatcher.dispatchStreaming( + subscribe, + (message) => messages.push(message), + phoneConnection('conn-a') + ) + const unsubscribing = dispatcher.dispatchStreaming(requestUnsubscribe(subscribe.id), vi.fn(), { + connectionId: 'conn-a' + }) + await Promise.all([streaming, unsubscribing]) + await flush() + + expect(register.mock.invocationCallOrder[0]).toBeLessThan(release.mock.invocationCallOrder[0]!) + expect(registry.peekCleanup(SUBSCRIPTION_ID)).toBeUndefined() + expect(registry.requestAddressCount()).toBe(0) + expect(resultTypes(messages).filter((type) => type === 'end')).toHaveLength(1) + }) + + it('heals a half-open socket: the replay on a new socket is what the leave ends', async () => { + const { runtime, spawn } = createRealRuntime() + spawn() + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + // Replay after a reconnect resends the same request id on the new socket. + const replayed = subscribeRequest(binaryParams) + const halfOpenMessages: string[] = [] + const halfOpen = dispatcher.dispatchStreaming( + replayed, + (message) => halfOpenMessages.push(message), + phoneConnection('conn-1') + ) + await vi.waitFor(() => expect(runtime.getDriver(PTY_ID).kind).toBe('mobile')) + const replay = dispatcher.dispatchStreaming(replayed, vi.fn(), phoneConnection('conn-2')) + await halfOpen + expect(resultTypes(halfOpenMessages).at(-1)).toBe('end') + + await dispatcher.dispatchStreaming(requestUnsubscribe(replayed.id), vi.fn(), { + connectionId: 'conn-2' + }) + await replay + + await vi.waitFor(() => expect(runtime.getDriver(PTY_ID).kind).toBe('idle'), { + timeout: 2_000 + }) + // The half-open socket's eventual close finds nothing of its own left. + runtime.cleanupSubscriptionsForConnection('conn-1') + expect(runtime.getDriver(PTY_ID).kind).toBe('idle') + }) +}) diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index b6c53668f2d..a08458d8be5 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -13,8 +13,11 @@ import { } from '../../../shared/terminal-stream-protocol' function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { + const registry = createSubscriptionRegistryDouble() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This partial runtime supplies the terminal RPC methods these tests invoke. return { getRuntimeId: () => 'test-runtime', + registerOwnedSubscriptionCleanup: registry.registerOwnedSubscriptionCleanup, subscribeToPtyExit: vi.fn(() => vi.fn()), // Why: subscribe streams register as remote view subscribers for Phase-5 // query-authority suppression (terminal-query-authority.md). @@ -76,7 +79,8 @@ describe('terminal subscribe buffering', () => { expect(await outcomePromise).toBe('settled') expect(runtime.readTerminal).not.toHaveBeenCalled() - expect(messages).toEqual([]) + // The pending stream was registered, so its release ends it like any other. + expect(messages.map((msg) => JSON.parse(msg).result?.type)).toEqual(['end']) } finally { vi.useRealTimers() } @@ -302,7 +306,8 @@ describe('terminal subscribe buffering', () => { expect(runtime.registerOwnedSubscriptionCleanup).toHaveBeenCalledWith( 'terminal-1:desktop-1', expect.any(Function), - 'conn-legacy-json' + 'conn-legacy-json', + 'req-1' ) expect(registry.peekCleanup('terminal-1:desktop-1')).toBeUndefined() expect(runtime.waitForTerminal).not.toHaveBeenCalled() diff --git a/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts b/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts index c64747616cf..6dd443f1402 100644 --- a/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts @@ -11,6 +11,11 @@ import { RpcDispatcher } from './dispatcher' import { TERMINAL_METHODS } from './methods/terminal' import { createSubscriptionRegistryDouble } from './subscription-registry-test-double' +function asRuntime(double: Record): OrcaRuntimeService { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: terminal.subscribe reads only the members the double provides. + return double as unknown as OrcaRuntimeService +} + const request: RpcRequest = { id: 'req-1', authToken: 'tok', @@ -97,7 +102,7 @@ describe('terminal subscribe mount replay', () => { let generation = 1 let headlessPresent = false let serializeCalls = 0 - const runtime = { + const runtime = asRuntime({ getRuntimeId: () => 'test-runtime', subscribeToPtyExit: vi.fn(() => vi.fn()), resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), @@ -110,9 +115,9 @@ describe('terminal subscribe mount replay', () => { waitForRendererTerminalSerializer: vi.fn(async (_ptyId, afterGeneration) => { return generation > afterGeneration }), + // The phone fit's redraw creates suffix-only headless state. handleMobileSubscribe: vi.fn(async () => { headlessPresent = true - generation = 2 return true }), handleMobileUnsubscribe: vi.fn(), @@ -126,12 +131,15 @@ describe('terminal subscribe mount replay', () => { } return { data: 'raced idle prompt $ ', cols: 80, rows: 24, seq: 5 } }), - serializeRendererTerminalBuffer: vi.fn(async () => ({ - data: 'raced idle prompt $ ', - cols: 80, - rows: 24, - seq: 5 - })), + // Baseline race: the pane settles between the attachment answer and the wait, so the wait + // must still count that settle against the pre-fit generation. + serializeRendererTerminalBuffer: vi + .fn() + .mockImplementationOnce(async () => { + generation = 2 + return null + }) + .mockResolvedValue({ data: 'raced idle prompt $ ', cols: 80, rows: 24, seq: 5 }), getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), getMobileDisplayMode: vi.fn().mockReturnValue('auto'), getLayout: vi.fn().mockReturnValue({ seq: 1 }), @@ -142,7 +150,7 @@ describe('terminal subscribe mount replay', () => { registerOwnedSubscriptionCleanup: vi.fn(registry.registerOwnedSubscriptionCleanup), cleanupSubscription: vi.fn(registry.cleanupSubscription), waitForTerminal: vi.fn(() => new Promise(() => {})) - } as unknown as OrcaRuntimeService + }) const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) const dispatchPromise = dispatcher.dispatchStreaming(request, vi.fn(), { @@ -286,11 +294,8 @@ describe('terminal subscribe mount replay', () => { const registry = createSubscriptionRegistryDouble() let generation = 0 let headlessPresent = false - const requestRendererTerminalTabMount = vi.fn(() => { - generation = 1 - return true - }) - const runtime = { + const requestRendererTerminalTabMount = vi.fn(() => true) + const runtime = asRuntime({ getRuntimeId: () => 'test-runtime', subscribeToPtyExit: vi.fn(() => vi.fn()), resolveLeafForHandle: vi.fn().mockReturnValue(null), @@ -320,11 +325,15 @@ describe('terminal subscribe mount replay', () => { rows: 24, seq: 1 }), - serializeRendererTerminalBuffer: vi.fn().mockResolvedValue({ - data: 'late leaf prompt $ ', - cols: 80, - rows: 24 - }), + // Baseline race: the pre-PTY mount settles between the attachment answer and the wait, so the + // wait must still count that settle against the pre-mount generation. + serializeRendererTerminalBuffer: vi + .fn() + .mockImplementationOnce(async () => { + generation = 1 + return null + }) + .mockResolvedValue({ data: 'late leaf prompt $ ', cols: 80, rows: 24 }), getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), getMobileDisplayMode: vi.fn().mockReturnValue('auto'), getLayout: vi.fn().mockReturnValue({ seq: 1 }), @@ -335,7 +344,7 @@ describe('terminal subscribe mount replay', () => { registerOwnedSubscriptionCleanup: vi.fn(registry.registerOwnedSubscriptionCleanup), cleanupSubscription: vi.fn(registry.cleanupSubscription), waitForTerminal: vi.fn(() => new Promise(() => {})) - } as unknown as OrcaRuntimeService + }) const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) const dispatchPromise = dispatcher.dispatchStreaming(request, vi.fn(), { diff --git a/src/main/runtime/rpc/terminal-subscribe-mounted-pane.test.ts b/src/main/runtime/rpc/terminal-subscribe-mounted-pane.test.ts new file mode 100644 index 00000000000..56720ff5d48 --- /dev/null +++ b/src/main/runtime/rpc/terminal-subscribe-mounted-pane.test.ts @@ -0,0 +1,370 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' +import type { OrcaRuntimeService } from '../orca-runtime' +import type { RpcRequest } from './core' +import { RpcDispatcher } from './dispatcher' +import { TERMINAL_METHODS } from './methods/terminal' +import { createSubscriptionRegistryDouble } from './subscription-registry-test-double' + +const request: RpcRequest = { + id: 'req-1', + authToken: 'tok', + method: 'terminal.subscribe', + params: { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + } +} + +function asRuntime(double: Record): OrcaRuntimeService { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: terminal.subscribe reads only the members the double provides. + return double as unknown as OrcaRuntimeService +} + +type PaneDouble = { + /** What the desktop renderer's serializer answers; null when no pane is registered. */ + rendererScreen: () => string | null + /** How long each renderer serialize takes; the IPC answers null after 750 ms when no pane replies. */ + rendererAnswerDelayMs?: number + /** PTY output high-water; a pane printing continuously advances it on every read. */ + outputSequence?: () => number + /** Renderer-ordered seq; null when the pane does not order output itself. */ + rendererSeq?: number | null + /** What the preference order serves before any renderer probe. */ + chosenScreen?: string + /** Live output that arrives while the subscription is still buffering. */ + pendingOutput?: string + /** Output offset at the end of the pending chunk. */ + pendingOutputSeq?: number + waitForRendererTerminalSerializer: OrcaRuntimeService['waitForRendererTerminalSerializer'] +} + +function subscribeMobile(pane: PaneDouble) { + const binaryFrames: Uint8Array[] = [] + const registry = createSubscriptionRegistryDouble() + let emitData: (data: string, meta: { seq: number; rawLength: number }) => void = () => {} + const runtime = { + getRuntimeId: () => 'test-runtime', + subscribeToPtyExit: vi.fn(() => vi.fn()), + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + // A daemon PTY reattached after a desktop restart has no headless model yet. + hasHeadlessTerminalState: vi.fn(() => false), + requestRendererTerminalTabMount: vi.fn(() => true), + getRendererTerminalSerializerGenerationForHandle: vi.fn(() => 1), + getRendererTerminalSerializerGeneration: vi.fn(() => 1), + getPtyOutputSequence: vi.fn(pane.outputSequence ?? (() => 4)), + replaceHeadlessTerminalFromRendererSnapshotForRecovery: vi.fn(), + waitForRendererTerminalSerializer: vi.fn(pane.waitForRendererTerminalSerializer), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn((_ptyId: string, listener: typeof emitData) => { + emitData = listener + return vi.fn() + }), + registerRemoteTerminalViewSubscriber: vi.fn(() => vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + // The restored provider snapshot wins the preference order over the live renderer. + serializeTerminalBuffer: vi.fn(async () => { + if (pane.pendingOutput) { + emitData(pane.pendingOutput, { + seq: pane.pendingOutputSeq ?? 3, + rawLength: pane.pendingOutput.length + }) + } + return { + data: pane.chosenScreen ?? 'restored provider history', + cols: 80, + rows: 24, + seq: 2 + } + }), + serializeRendererTerminalBuffer: vi.fn(async () => { + if (pane.rendererAnswerDelayMs) { + await new Promise((resolve) => setTimeout(resolve, pane.rendererAnswerDelayMs)) + } + const screen = pane.rendererScreen() + const seq = pane.rendererSeq === undefined ? 4 : pane.rendererSeq + return screen === null + ? null + : { + data: screen, + cols: 80, + rows: 24, + ...(seq === null ? {} : { seq }), + source: 'renderer' as const + } + }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn(registry.registerSubscriptionCleanup), + registerOwnedSubscriptionCleanup: vi.fn(registry.registerOwnedSubscriptionCleanup), + cleanupSubscription: vi.fn(registry.cleanupSubscription), + waitForTerminal: vi.fn(() => new Promise(() => {})) + } + const dispatcher = new RpcDispatcher({ runtime: asRuntime(runtime), methods: TERMINAL_METHODS }) + const done = dispatcher.dispatchStreaming(request, vi.fn(), { + connectionId: 'conn-phone', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + }) + const snapshotText = (): string => + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + const outputText = (): string => + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + const close = async (): Promise => { + runtime.cleanupSubscription('terminal-1:phone-1') + await done + } + return { runtime, snapshotText, outputText, close } +} + +describe('terminal subscribe for a pane the desktop already has mounted', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('adopts the live renderer screen over the chosen snapshot without waiting out the mount deadline', async () => { + // The renderer drops a mount request for a mounted tab, so no newer serializer settle ever arrives. + const subscription = subscribeMobile({ + chosenScreen: 'suffix-only redraw', + rendererScreen: () => 'live desktop prompt $ ', + waitForRendererTerminalSerializer: (_ptyId, _after, _timeout, signal) => + new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(false), { once: true }) + }) + }) + + await vi.advanceTimersByTimeAsync(100) + + expect(subscription.snapshotText()).toContain('live desktop prompt $ ') + expect(subscription.snapshotText()).not.toContain('suffix-only redraw') + expect(subscription.runtime.requestRendererTerminalTabMount).not.toHaveBeenCalled() + expect(subscription.runtime.waitForRendererTerminalSerializer).not.toHaveBeenCalled() + expect( + subscription.runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery + ).toHaveBeenCalledWith('pty-1', expect.objectContaining({ data: 'live desktop prompt $ ' }), []) + await subscription.close() + }) + + it('adopts a renderer-ordered screen even while output is pending', async () => { + // The screen's seq (4) is an exact seam inside the pending chunk (offsets 3..5), so only `z` replays. + const subscription = subscribeMobile({ + rendererScreen: () => 'ordered desktop prompt $ ', + pendingOutput: 'Xz', + pendingOutputSeq: 5, + waitForRendererTerminalSerializer: (_ptyId, _after, _timeout, signal) => + new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(false), { once: true }) + }) + }) + + await vi.advanceTimersByTimeAsync(100) + + expect(subscription.runtime.requestRendererTerminalTabMount).not.toHaveBeenCalled() + expect(subscription.snapshotText()).toContain('ordered desktop prompt $ ') + expect(subscription.snapshotText()).not.toContain('restored provider history') + expect( + subscription.runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery + ).toHaveBeenCalledWith( + 'pty-1', + expect.objectContaining({ data: 'ordered desktop prompt $ ', seq: 4 }), + [{ data: 'z', seq: 5 }] + ) + expect(subscription.outputText()).toBe('z') + await subscription.close() + }) + + it('answers at once for a mounted pane whose screen is still empty', async () => { + // A fresh shell that has printed nothing: the serializer is registered but its screen is blank. + const subscription = subscribeMobile({ + rendererScreen: () => '', + waitForRendererTerminalSerializer: (_ptyId, _after, _timeout, signal) => + new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(false), { once: true }) + }) + }) + + await vi.advanceTimersByTimeAsync(100) + + expect(subscription.runtime.requestRendererTerminalTabMount).not.toHaveBeenCalled() + // A blank renderer must not erase history the chosen snapshot already carries. + expect(subscription.snapshotText()).toContain('restored provider history') + expect( + subscription.runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery + ).not.toHaveBeenCalled() + await subscription.close() + }) + + it('answers at once for a mounted pane whose output never settles', async () => { + // A desktop agent printing continuously: every renderer serialize races a new byte. + let sequence = 4 + const subscription = subscribeMobile({ + rendererScreen: () => `agent frame ${sequence}`, + outputSequence: () => (sequence += 1), + waitForRendererTerminalSerializer: (_ptyId, _after, _timeout, signal) => + new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(false), { once: true }) + }) + }) + + await vi.advanceTimersByTimeAsync(100) + + expect(subscription.runtime.requestRendererTerminalTabMount).not.toHaveBeenCalled() + expect(subscription.runtime.waitForRendererTerminalSerializer).not.toHaveBeenCalled() + // An unsettled screen has no exact seam against buffered output, so the chosen snapshot goes out. + expect(subscription.snapshotText()).toContain('restored provider history') + expect( + subscription.runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery + ).not.toHaveBeenCalled() + await subscription.close() + }) + + it('keeps the chosen snapshot for a seq-less mounted screen while output is pending', async () => { + // Without a seq, the buffered chunk would replay on top of a screen that already holds it. + const subscription = subscribeMobile({ + rendererScreen: () => 'unordered desktop prompt $ ', + rendererSeq: null, + pendingOutput: 'pending byte', + waitForRendererTerminalSerializer: (_ptyId, _after, _timeout, signal) => + new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(false), { once: true }) + }) + }) + + await vi.advanceTimersByTimeAsync(100) + + expect(subscription.runtime.requestRendererTerminalTabMount).not.toHaveBeenCalled() + expect(subscription.snapshotText()).toContain('restored provider history') + expect(subscription.snapshotText()).not.toContain('unordered desktop prompt $ ') + expect( + subscription.runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery + ).not.toHaveBeenCalled() + await subscription.close() + }) + + it('adopts a seq-less mounted screen when no output is pending', async () => { + // Right after a deferred cold restore the pane is not renderer-ordered yet; nothing can replay twice. + const subscription = subscribeMobile({ + rendererScreen: () => 'unordered desktop prompt $ ', + rendererSeq: null, + waitForRendererTerminalSerializer: (_ptyId, _after, _timeout, signal) => + new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(false), { once: true }) + }) + }) + + await vi.advanceTimersByTimeAsync(100) + + expect(subscription.runtime.requestRendererTerminalTabMount).not.toHaveBeenCalled() + expect(subscription.snapshotText()).toContain('unordered desktop prompt $ ') + expect( + subscription.runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery + ).toHaveBeenCalledWith( + 'pty-1', + expect.objectContaining({ data: 'unordered desktop prompt $ ' }), + [] + ) + await subscription.close() + }) + + it('still requests the mount and waits for its settle when no pane is registered', async () => { + let mounted = false + const subscription = subscribeMobile({ + rendererScreen: () => (mounted ? 'mounted idle prompt $ ' : null), + waitForRendererTerminalSerializer: (_ptyId, afterGeneration) => { + expect(afterGeneration).toBe(1) + return new Promise((resolve) => { + setTimeout(() => { + mounted = true + resolve(true) + }, 500) + }) + } + }) + + await vi.advanceTimersByTimeAsync(100) + expect(subscription.runtime.requestRendererTerminalTabMount).toHaveBeenCalledWith('terminal-1') + expect(subscription.snapshotText()).toBe('') + // No pane is registered, so nothing may spend a terminal read (which can reach the provider) before the settle. + expect(subscription.runtime.readTerminal).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(400) + expect(subscription.snapshotText()).toContain('mounted idle prompt $ ') + expect(subscription.snapshotText()).not.toContain('restored provider history') + await subscription.close() + }) + + it('keeps the chosen snapshot after the mount wait when a seq-less screen meets pending output', async () => { + let mounted = false + const subscription = subscribeMobile({ + rendererScreen: () => (mounted ? 'unordered mounted prompt $ ' : null), + rendererSeq: null, + pendingOutput: 'pending byte', + waitForRendererTerminalSerializer: () => + new Promise((resolve) => { + setTimeout(() => { + mounted = true + resolve(true) + }, 500) + }) + }) + + await vi.advanceTimersByTimeAsync(500) + + expect(subscription.runtime.requestRendererTerminalTabMount).toHaveBeenCalledWith('terminal-1') + expect(subscription.snapshotText()).toContain('restored provider history') + expect( + subscription.runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery + ).not.toHaveBeenCalled() + await subscription.close() + }) + + it('falls back to the mount wait within one serialize when the flag outlives its pane', async () => { + // Nothing clears the flag when a tab closes over a live PTY; the IPC then answers null at its + // 750 ms deadline while a busy PTY moves output under every attempt. + let sequence = 4 + const subscription = subscribeMobile({ + rendererScreen: () => null, + rendererAnswerDelayMs: 750, + outputSequence: () => (sequence += 1), + waitForRendererTerminalSerializer: (_ptyId, _after, _timeout, signal) => + new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(false), { once: true }) + }) + }) + + await vi.advanceTimersByTimeAsync(750) + expect(subscription.runtime.requestRendererTerminalTabMount).toHaveBeenCalledWith('terminal-1') + await vi.advanceTimersByTimeAsync(2_999) + expect(subscription.snapshotText()).toBe('') + + // Worst case: one serialize plus the 3 s mount deadline. + await vi.advanceTimersByTimeAsync(1) + expect(subscription.snapshotText()).toContain('restored provider history') + expect(subscription.runtime.serializeRendererTerminalBuffer).toHaveBeenCalledTimes(1) + await subscription.close() + }) +}) diff --git a/src/main/runtime/rpc/terminal-subscribe-ownership.test.ts b/src/main/runtime/rpc/terminal-subscribe-ownership.test.ts index d59287639b6..fefe8efb28d 100644 --- a/src/main/runtime/rpc/terminal-subscribe-ownership.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-ownership.test.ts @@ -119,7 +119,7 @@ describe('terminal.subscribe teardown ownership', () => { expect(registry.peekCleanup(SUBSCRIPTION_ID)).toBeUndefined() }) - it('disposes an already-exited PTY observer before binding socket abort', async () => { + it('disposes an already-exited PTY observer and the request-abort listener', async () => { const registry = createSubscriptionRegistryDouble() const unsubscribeExit = vi.fn() const runtime = stubRuntime(registry, [], { @@ -131,12 +131,14 @@ describe('terminal.subscribe teardown ownership', () => { const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) const conn = new AbortController() const addAbort = vi.spyOn(conn.signal, 'addEventListener') + const removeAbort = vi.spyOn(conn.signal, 'removeEventListener') const options = streamOptions('conn-a', conn.signal) await dispatcher.dispatchStreaming(makeRequest(binaryParams), vi.fn(), options) expect(unsubscribeExit).toHaveBeenCalledOnce() - expect(addAbort).not.toHaveBeenCalled() + expect(addAbort).toHaveBeenCalledOnce() + expect(removeAbort).toHaveBeenCalledWith('abort', addAbort.mock.calls[0]![1]) }) // Why: the synchronous release runs cleanup before setup, so anything registered after it never gets torn down. diff --git a/src/main/runtime/rpc/ws-transport.test.ts b/src/main/runtime/rpc/ws-transport.test.ts index f1ac250bdc3..45798a89528 100644 --- a/src/main/runtime/rpc/ws-transport.test.ts +++ b/src/main/runtime/rpc/ws-transport.test.ts @@ -88,13 +88,6 @@ describe('WebSocketTransport', () => { }) } - it('starts and stops cleanly', async () => { - const { transport } = await createTransport() - - await transport.start() - await transport.stop() - }) - it('arms heartbeat only while accepted connections exist', async () => { const { transport } = await createTransport() await transport.start() diff --git a/src/main/runtime/runtime-automation-controller.ts b/src/main/runtime/runtime-automation-controller.ts index d3ac55df438..20d3725c34e 100644 --- a/src/main/runtime/runtime-automation-controller.ts +++ b/src/main/runtime/runtime-automation-controller.ts @@ -113,7 +113,7 @@ export class RuntimeAutomationController { if (input.reuseSession && target.workspaceMode !== 'existing') { throw new Error('Session reuse requires an existing workspace target.') } - return this.store.createAutomation( + const automation = this.store.createAutomation( { creationKey: input.creationKey, name: input.name, @@ -138,6 +138,8 @@ export class RuntimeAutomationController { ? { destination: destination ?? input.destination } : undefined ) + await this.store.flushPendingOrThrowAsync?.({ drainToStableGeneration: false }) + return automation } async update( @@ -179,18 +181,21 @@ export class RuntimeAutomationController { if (!targetChanged && patch.reuseSession && current.workspaceMode !== 'existing') { throw new Error('Session reuse requires an existing workspace target.') } - return this.store.updateAutomation(id, patch, options) + const automation = this.store.updateAutomation(id, patch, options) + await this.store.flushPendingOrThrowAsync?.({ drainToStableGeneration: false }) + return automation } - delete( + async delete( id: string, expectedOwner?: AutomationOwnerPrecondition - ): { removed: boolean; id: string } { + ): Promise<{ removed: boolean; id: string }> { if (!this.store?.deleteAutomation) { throw new Error('runtime_unavailable') } this.show(id) this.store.deleteAutomation(id, expectedOwner ? { expectedOwner } : undefined) + await this.store.flushPendingOrThrowAsync?.({ drainToStableGeneration: false }) return { removed: true, id } } diff --git a/src/main/runtime/runtime-client-settings-linear-team-projection.test.ts b/src/main/runtime/runtime-client-settings-linear-team-projection.test.ts new file mode 100644 index 00000000000..c26eecb3d95 --- /dev/null +++ b/src/main/runtime/runtime-client-settings-linear-team-projection.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { RuntimeClientSettingsController } from './runtime-client-settings' +import { createGlobalSettingsFixture } from '../../shared/global-settings-test-fixture' +import type { GlobalSettings } from '../../shared/global-settings-types' + +// Why: the projection is what paired clients render page.tasks from, and a +// non-array in the host store crashed that page in 1.4.207 (0a2b6e7f). +function projectionOf(settings: Partial) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: get() reads nothing but store.getSettings(); every other RuntimeStore member is unreachable from that path. + return new RuntimeClientSettingsController({ getSettings: () => settings } as never).get() +} + +function hostSettings(overrides: Record): Partial { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the override deliberately carries the malformed on-disk shape the projection must tolerate. + return { + ...createGlobalSettingsFixture({ workspaceDir: '/w' }), + ...overrides + } as Partial +} + +describe('RuntimeClientSettingsController Linear team selection projection', () => { + it('publishes a saved team array unchanged', () => { + expect( + projectionOf(hostSettings({ defaultLinearTeamSelection: ['t1', 't2'] })) + .defaultLinearTeamSelection + ).toEqual(['t1', 't2']) + }) + + it('publishes sticky-all as null', () => { + expect( + projectionOf(hostSettings({ defaultLinearTeamSelection: null })).defaultLinearTeamSelection + ).toBeNull() + }) + + it('publishes only string team IDs from a malformed array', () => { + expect( + projectionOf(hostSettings({ defaultLinearTeamSelection: ['t1', 7, null, {}, 't2'] })) + .defaultLinearTeamSelection + ).toEqual(['t1', 't2']) + }) + + it('publishes null when the host store holds a string or an object', () => { + expect( + projectionOf(hostSettings({ defaultLinearTeamSelection: 't1' })).defaultLinearTeamSelection + ).toBeNull() + expect( + projectionOf(hostSettings({ defaultLinearTeamSelection: { 0: 't1' } })) + .defaultLinearTeamSelection + ).toBeNull() + }) +}) diff --git a/src/main/runtime/runtime-client-settings-source-control-projection.test.ts b/src/main/runtime/runtime-client-settings-source-control-projection.test.ts new file mode 100644 index 00000000000..c1868df74bd --- /dev/null +++ b/src/main/runtime/runtime-client-settings-source-control-projection.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { RuntimeClientSettingsController } from './runtime-client-settings' +import { createGlobalSettingsFixture } from '../../shared/global-settings-test-fixture' +import type { GlobalSettings } from '../../shared/global-settings-types' +import { getDefaultSourceControlAiSettings } from '../../shared/source-control-ai' + +// Why: `settings.get` is an explicit allowlist. A paired client's AI buttons resolve their agent +// from the saved per-action recipe, and a recipe missing here reads as "none saved" on the client. +function projectionOf(settings: Partial) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: get() reads nothing but store.getSettings(); every other RuntimeStore member is unreachable from that path. + return new RuntimeClientSettingsController({ getSettings: () => settings } as never).get() +} + +function hostSettings(sourceControlAi: GlobalSettings['sourceControlAi']): Partial { + return { ...createGlobalSettingsFixture({ workspaceDir: '/w' }), sourceControlAi } +} + +describe('RuntimeClientSettingsController source control launch recipes', () => { + it('publishes the recipe saved for a launch action', () => { + const projected = projectionOf( + hostSettings({ + ...getDefaultSourceControlAiSettings(), + actions: { fixChecks: { agentId: 'codex', agentArgs: '--fast' } } + }) + ) + expect(projected.sourceControlAi.actions.fixChecks).toMatchObject({ + agentId: 'codex', + agentArgs: '--fast' + }) + }) + + it('publishes a recipe saved under the legacy launch defaults key', () => { + const projected = projectionOf( + hostSettings({ + ...getDefaultSourceControlAiSettings(), + actions: undefined, + launchActionDefaults: { resolveConflicts: { agentId: 'claude' } } + }) + ) + expect(projected.sourceControlAi.actions.resolveConflicts).toMatchObject({ agentId: 'claude' }) + }) + + it('publishes launch actions only, not the text generation recipes', () => { + const projected = projectionOf(hostSettings(getDefaultSourceControlAiSettings())) + expect(Object.keys(projected.sourceControlAi.actions).sort()).toEqual([ + 'fixChecks', + 'fixCommitFailure', + 'fixPushFailure', + 'resolveComments', + 'resolveConflicts' + ]) + }) +}) diff --git a/src/main/runtime/runtime-client-settings.ts b/src/main/runtime/runtime-client-settings.ts index 262021da0e5..d29b9afea1e 100644 --- a/src/main/runtime/runtime-client-settings.ts +++ b/src/main/runtime/runtime-client-settings.ts @@ -9,6 +9,11 @@ import { type TerminalQuickCommandMutation } from '../../shared/terminal-quick-commands' import { haveSameDisabledTuiAgents } from '../../shared/tui-agent-selection' +import { normalizeSourceControlAiSettings } from '../../shared/source-control-ai' +import { + SOURCE_CONTROL_LAUNCH_ACTION_IDS, + type SourceControlAiActionDefaults +} from '../../shared/source-control-ai-actions' import type { GlobalSettings } from '../../shared/global-settings-types' import { applyNativeChatSessionOptionSettingsMutation } from '../../shared/native-chat-session-option-defaults' import type { NativeChatSessionOptionSettingsMutation } from '../../shared/native-chat-session-options' @@ -17,6 +22,7 @@ import type { ExecutionHostId } from '../../shared/execution-host' import type { TerminalQuickCommand } from '../../shared/terminal-quick-command-types' import { recordManagedHookInstallFailure } from '../agent-hooks/install-telemetry' import { applyAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { isAgentStatusHooksEnabledForAgent } from '../../shared/agent-status-hooks-setting' import type { RuntimeStore } from './runtime-store-contract' export type RuntimeClientSettings = Pick< @@ -49,8 +55,12 @@ export type RuntimeClientSettings = Pick< | 'machineName' > & { hostSettingOverrides: RuntimeHostDisplayLabelOverrides + sourceControlAi: RuntimeClientSourceControlAi } +/** The saved per-action launch recipes (agent, prompt template, agent args), already migrated. */ +export type RuntimeClientSourceControlAi = { actions: SourceControlAiActionDefaults } + /** Safe paired projection: host labels only; filesystem defaults stay host-private. */ export type RuntimeHostDisplayLabelOverrides = Partial< Record @@ -107,7 +117,10 @@ export class RuntimeClientSettingsController { defaultTaskViewPreset: settings.defaultTaskViewPreset ?? 'issues', visibleTaskProviders: settings.visibleTaskProviders ?? [...TASK_PROVIDERS], defaultRepoSelection: settings.defaultRepoSelection ?? null, - defaultLinearTeamSelection: settings.defaultLinearTeamSelection ?? null, + // Persisted settings can violate the paired client's string-array contract. + defaultLinearTeamSelection: Array.isArray(settings.defaultLinearTeamSelection) + ? settings.defaultLinearTeamSelection.filter((id): id is string => typeof id === 'string') + : null, githubProjects: settings.githubProjects, experimentalNewWorktreeCardStyle: settings.experimentalNewWorktreeCardStyle === true, // The three that decide whether a new agent tab -- and so an orchestration worker -- is a @@ -124,6 +137,9 @@ export class RuntimeClientSettingsController { worktreeVisibilityDefaults: settings.worktreeVisibilityDefaults ?? { external: 'hide' }, agentSkillSharingEnabled: isAgentSkillSharingEnabled(settings), machineName: settings.machineName ?? '', + // Why projected: a paired client's AI buttons start these actions' agents, and must honour + // the agent saved for each one as the desktop does. Absent on older hosts. + sourceControlAi: projectSourceControlLaunchRecipes(settings), hostSettingOverrides: Object.fromEntries( [ ...getHostDisplayLabelOverrides({ hostSettingOverrides: settings.hostSettingOverrides }) @@ -218,11 +234,7 @@ export class RuntimeClientSettingsController { onInstallError: recordManagedHookInstallFailure, shouldContinue: (agent) => { const current = this.store?.getSettings() - return ( - current !== undefined && - current.agentStatusHooksEnabled !== false && - !current.disabledTuiAgents?.includes(agent) - ) + return current !== undefined && isAgentStatusHooksEnabledForAgent(current, agent) } }) }) @@ -230,3 +242,20 @@ export class RuntimeClientSettingsController { return reconciliation } } + +function projectSourceControlLaunchRecipes( + settings: Partial> +): RuntimeClientSourceControlAi { + const { actions } = normalizeSourceControlAiSettings( + settings.sourceControlAi, + settings.commitMessageAi + ) + return { + actions: Object.fromEntries( + SOURCE_CONTROL_LAUNCH_ACTION_IDS.flatMap((actionId) => { + const recipe = actions?.[actionId] + return recipe ? [[actionId, recipe]] : [] + }) + ) + } +} diff --git a/src/main/runtime/runtime-durable-store-fixture.ts b/src/main/runtime/runtime-durable-store-fixture.ts new file mode 100644 index 00000000000..a5f2dc82793 --- /dev/null +++ b/src/main/runtime/runtime-durable-store-fixture.ts @@ -0,0 +1,40 @@ +import type { DurableProfileStateMutation } from '../persistence/loading-store/store-runtime-state' +import { profileStateWriterFailureOutcome } from '../persistence/profile-state/profile-state-writer-errors' + +/** Keep runtime fakes on the same reserved, durable-before-ack contract as Store. */ +export function withDurableRuntimeStore< + T extends { + flushOrThrow?: () => void + flushPendingOrThrowAsync?: (options?: { drainToStableGeneration?: boolean }) => Promise + } +>(store: T) { + let pending = Promise.resolve() + return Object.assign(store, { + runDurableMutation(mutate: () => DurableProfileStateMutation): Promise { + const write = pending.then(async () => { + const mutation = mutate() + if (mutation.persist === false) { + return mutation.value + } + try { + if (store.flushPendingOrThrowAsync) { + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + } else { + store.flushOrThrow?.() + } + } catch (error) { + if (profileStateWriterFailureOutcome(error) !== 'indeterminate') { + mutation.rollback?.() + } + throw error + } + return mutation.value + }) + pending = write.then( + () => {}, + () => {} + ) + return write + } + }) +} diff --git a/src/main/runtime/runtime-file-commands-search-runtime-files.ts b/src/main/runtime/runtime-file-commands-search-runtime-files.ts index fd79941c1ae..5a192c4e50d 100644 --- a/src/main/runtime/runtime-file-commands-search-runtime-files.ts +++ b/src/main/runtime/runtime-file-commands-search-runtime-files.ts @@ -13,6 +13,7 @@ import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from '../ipc/markdown-documents' +import { getLocalGitOptionsForRegisteredWorktree } from '../ipc/local-worktree-runtime-options' import { validatePathExistenceBatch, type PathExistenceResult @@ -82,7 +83,14 @@ export class RuntimeFileCommandsWithSearchRuntimeFiles extends RuntimeFileComman const relativePaths = await provider.listFiles(target.worktree.path) return markdownDocumentsFromRelativePaths(target.worktree.path, relativePaths) } - return listMarkdownDocuments(target.worktree.path) + return listMarkdownDocuments( + target.worktree.path, + getLocalGitOptionsForRegisteredWorktree( + this.host.requireStore(), + target.worktree.path, + target.worktree.path + ) + ) } async pathsExistRuntimeFiles( diff --git a/src/main/runtime/runtime-file-commands-write-file-explorer-file.ts b/src/main/runtime/runtime-file-commands-write-file-explorer-file.ts index 3336a86e7d7..92a356f987a 100644 --- a/src/main/runtime/runtime-file-commands-write-file-explorer-file.ts +++ b/src/main/runtime/runtime-file-commands-write-file-explorer-file.ts @@ -64,12 +64,12 @@ export class RuntimeFileCommandsWithWriteFileExplorerFile extends RuntimeFileCom expectedSshConnectionGeneration ) const provider = requireRuntimeFileProvider(target) - const content = Buffer.from(contentBase64, 'base64') if (provider) { await provider.writeFileBase64(target.path, contentBase64) return { ok: true } } + const content = Buffer.from(contentBase64, 'base64') const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) await mkdir(dirname(filePath), { recursive: true }) await writeFile(filePath, content, { flag: 'wx' }) @@ -93,12 +93,12 @@ export class RuntimeFileCommandsWithWriteFileExplorerFile extends RuntimeFileCom expectedSshConnectionGeneration ) const provider = requireRuntimeFileProvider(target) - const content = Buffer.from(contentBase64, 'base64') if (provider) { await provider.writeFileBase64Chunk(target.path, contentBase64, append) return { ok: true } } + const content = Buffer.from(contentBase64, 'base64') const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) await mkdir(dirname(filePath), { recursive: true }) await writeFile(filePath, content, { flag: append ? 'a' : 'wx' }) diff --git a/src/main/runtime/runtime-file-target-execution-host.test.ts b/src/main/runtime/runtime-file-target-execution-host.test.ts index d9770b182df..40729da528c 100644 --- a/src/main/runtime/runtime-file-target-execution-host.test.ts +++ b/src/main/runtime/runtime-file-target-execution-host.test.ts @@ -141,7 +141,7 @@ describe('runtime file target execution host', () => { await runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`) expect(m4air.listFiles).not.toHaveBeenCalled() - expect(mocks.listMarkdownDocuments).toHaveBeenCalledWith(REMOTE_PATH) + expect(mocks.listMarkdownDocuments).toHaveBeenCalledWith(REMOTE_PATH, {}) }) // A `runtime:` row's `connectionId` names a target in the *server's* namespace. Reading it here diff --git a/src/main/runtime/runtime-file-upload-decode.test.ts b/src/main/runtime/runtime-file-upload-decode.test.ts new file mode 100644 index 00000000000..a07ce9ded3e --- /dev/null +++ b/src/main/runtime/runtime-file-upload-decode.test.ts @@ -0,0 +1,236 @@ +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeFileCommandHost } from './runtime-file-command-host' +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' + +const mocks = vi.hoisted(() => ({ + resolvePath: vi.fn(), + expectation: vi.fn(), + provider: vi.fn(), + authorize: vi.fn(), + requireStore: vi.fn(), + mkdir: vi.fn(), + writeFile: vi.fn(), + writeBuffer: vi.fn() +})) + +vi.mock('./runtime-file-commands-read-file-explorer-preview', () => ({ + RuntimeFileCommandsWithReadFileExplorerPreview: class { + resolveFileExplorerPath = mocks.resolvePath + host = { requireStore: mocks.requireStore } + } +})) +vi.mock('./runtime-file-commands-mobile-file-list-limit', () => ({ + assertRuntimeFileMutationExpectation: mocks.expectation +})) +vi.mock('./runtime-file-command-target', () => ({ requireRuntimeFileProvider: mocks.provider })) +vi.mock('../ipc/filesystem-auth', () => ({ resolveAuthorizedPath: mocks.authorize })) +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + mkdir: mocks.mkdir, + writeFile: mocks.writeFile +})) + +import { RuntimeFileCommandsWithWriteFileExplorerFile } from './runtime-file-commands-write-file-explorer-file' +import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider' + +function unexpectedHostCall(): never { + throw new Error('Unexpected access beyond the isolated file command') +} + +const host: RuntimeFileCommandHost = { + getRuntimeId: unexpectedHostCall, + requireStore: unexpectedHostCall, + resolveWorktreeSelector: unexpectedHostCall, + resolveRuntimeFileTarget: unexpectedHostCall, + resolveRuntimeGitTarget: unexpectedHostCall, + openFile: unexpectedHostCall, + openDiff: unexpectedHostCall +} +const commands = new RuntimeFileCommandsWithWriteFileExplorerFile(host) +const destination = join('workspace', 'uploads', 'binary.dat') +const target = { executionHostId: 'local', path: destination } +let remote = false + +function sshProvider(): SshFilesystemProvider { + remote = true + mocks.resolvePath.mockResolvedValue({ ...target, executionHostId: 'ssh:target' }) + const mux = { onNotification: vi.fn(() => () => {}), request: unexpectedHostCall } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: construction only subscribes; writes use the injected raw transfer and any mux request fails. + return new SshFilesystemProvider('target', mux as unknown as SshChannelMultiplexer, undefined, { + writeBuffer: mocks.writeBuffer + }) +} + +beforeEach(() => { + vi.resetAllMocks() + remote = false + mocks.resolvePath.mockResolvedValue(target) + mocks.provider.mockReturnValue(null) + mocks.authorize.mockResolvedValue(destination) + mocks.requireStore.mockReturnValue({}) + mocks.mkdir.mockResolvedValue(undefined) + mocks.writeFile.mockResolvedValue(undefined) + mocks.writeBuffer.mockResolvedValue(undefined) +}) +afterEach(() => vi.restoreAllMocks()) + +describe.each(['whole', 'first', 'append'] as const)('runtime %s base64 write', (mode) => { + const write = (base64: string) => { + if (remote) { + return mode === 'whole' + ? commands.writeFileExplorerFileBase64( + 'folder:workspace', + 'uploads/binary.dat', + base64, + 7, + 'target', + 'ssh:target' + ) + : commands.writeFileExplorerFileBase64Chunk( + 'folder:workspace', + 'uploads/binary.dat', + base64, + mode === 'append', + 7, + 'target', + 'ssh:target' + ) + } + return mode === 'whole' + ? commands.writeFileExplorerFileBase64( + 'folder:workspace', + 'uploads/binary.dat', + base64, + undefined, + undefined, + 'local' + ) + : commands.writeFileExplorerFileBase64Chunk( + 'folder:workspace', + 'uploads/binary.dat', + base64, + mode === 'append', + undefined, + undefined, + 'local' + ) + } + + it.each(['', 'AAH+/w==', 'AA', '%%%'])( + 'preserves local decoding and flags for %j', + async (base64) => { + const expected = Buffer.from(base64, 'base64') + const decode = vi.spyOn(Buffer, 'from') + await expect(write(base64)).resolves.toEqual({ ok: true }) + expect( + decode.mock.calls.filter((args) => args.at(0) === base64 && args.at(1) === 'base64') + ).toHaveLength(1) + expect(mocks.expectation).toHaveBeenCalledWith('local', 'local', undefined, undefined) + expect(mocks.provider).toHaveBeenCalledWith(target) + expect(mocks.authorize).toHaveBeenCalledWith( + destination, + mocks.requireStore.mock.results[0].value + ) + expect(mocks.mkdir).toHaveBeenCalledWith(dirname(destination), { recursive: true }) + expect(mocks.writeFile).toHaveBeenCalledWith(destination, expected, { + flag: mode === 'append' ? 'a' : 'wx' + }) + expect(mocks.writeBuffer).not.toHaveBeenCalled() + expect(mocks.expectation.mock.invocationCallOrder[0]).toBeLessThan( + mocks.provider.mock.invocationCallOrder[0] + ) + expect(decode.mock.invocationCallOrder[0]).toBeLessThan( + mocks.authorize.mock.invocationCallOrder[0] + ) + } + ) + + it.each(['', 'AAH+/w==', 'AA', '%%%'])( + 'decodes once through the SSH provider for %j', + async (base64) => { + const expected = Buffer.from(base64, 'base64') + const provider = sshProvider() + mocks.provider.mockReturnValue(provider) + const decode = vi.spyOn(Buffer, 'from') + await expect(write(base64)).resolves.toEqual({ ok: true }) + expect( + decode.mock.calls.filter((args) => args.at(0) === base64 && args.at(1) === 'base64') + ).toHaveLength(1) + expect(mocks.expectation).toHaveBeenCalledWith('ssh:target', 'ssh:target', 'target', 7) + expect(mocks.writeBuffer).toHaveBeenCalledWith(destination, expected, { + append: mode === 'append', + exclusive: mode !== 'append' + }) + expect(mocks.authorize).not.toHaveBeenCalled() + expect(mocks.mkdir).not.toHaveBeenCalled() + expect(mocks.writeFile).not.toHaveBeenCalled() + provider.dispose() + } + ) + + it('avoids a second decoded slice for a full upload chunk', async () => { + mocks.provider.mockReturnValue(sshProvider()) + const bytes = Buffer.alloc(384 * 1024, 0xb7) + const base64 = bytes.toString('base64') + const decode = vi.spyOn(Buffer, 'from') + await write(base64) + expect( + decode.mock.calls.filter((args) => args.at(0) === base64 && args.at(1) === 'base64') + ).toHaveLength(1) + expect(mocks.writeBuffer.mock.calls[0][1]).toEqual(bytes) + }) + + it.each(['resolve', 'expectation', 'provider'] as const)( + 'preserves %s failure before decoding or writes', + async (stage) => { + const failure = new Error(stage) + if (stage === 'resolve') { + mocks.resolvePath.mockRejectedValue(failure) + } else if (stage === 'expectation') { + mocks.expectation.mockImplementation(() => { + throw failure + }) + } else { + mocks.provider.mockImplementation(() => { + throw failure + }) + } + const decode = vi.spyOn(Buffer, 'from') + await expect(write('AA==')).rejects.toBe(failure) + expect(decode).not.toHaveBeenCalled() + expect(mocks.authorize).not.toHaveBeenCalled() + expect(mocks.writeFile).not.toHaveBeenCalled() + expect(mocks.writeBuffer).not.toHaveBeenCalled() + if (stage === 'resolve') { + expect(mocks.expectation).not.toHaveBeenCalled() + } + if (stage !== 'provider') { + expect(mocks.provider).not.toHaveBeenCalled() + } + } + ) + + it.each(['authorize', 'mkdir', 'writeFile'] as const)( + 'preserves local %s failure without a remote fallback', + async (stage) => { + const failure = new Error(stage) + mocks[stage].mockRejectedValue(failure) + await expect(write('AA==')).rejects.toBe(failure) + expect(mocks.writeBuffer).not.toHaveBeenCalled() + if (stage !== 'writeFile') { + expect(mocks.writeFile).not.toHaveBeenCalled() + } + } + ) + + it('propagates SSH sink errors without a local fallback', async () => { + mocks.provider.mockReturnValue(sshProvider()) + const failure = new Error('SSH sink failed') + mocks.writeBuffer.mockRejectedValue(failure) + await expect(write('AA==')).rejects.toBe(failure) + expect(mocks.authorize).not.toHaveBeenCalled() + expect(mocks.mkdir).not.toHaveBeenCalled() + expect(mocks.writeFile).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/runtime-folder-worktree-create.test.ts b/src/main/runtime/runtime-folder-worktree-create.test.ts index 885e61010dd..217d958d96e 100644 --- a/src/main/runtime/runtime-folder-worktree-create.test.ts +++ b/src/main/runtime/runtime-folder-worktree-create.test.ts @@ -28,7 +28,6 @@ function createDeps() { store: store as unknown as CreateArgs['deps']['store'], ptySpawnAvailable: true, createTerminal, - markTrusted: vi.fn(), pasteDraft: vi.fn(), sendFollowup: vi.fn(), invalidateResolvedWorktrees: vi.fn(), diff --git a/src/main/runtime/runtime-folder-worktree-create.ts b/src/main/runtime/runtime-folder-worktree-create.ts index fd13253c488..1b3cd2a829e 100644 --- a/src/main/runtime/runtime-folder-worktree-create.ts +++ b/src/main/runtime/runtime-folder-worktree-create.ts @@ -26,7 +26,6 @@ type RuntimeFolderWorktreeCreateDeps = { selector: string, options: TerminalCreateOptions ) => Promise - markTrusted: (agent: TuiAgent, path: string) => Promise pasteDraft: (handle: string, draft: WorktreeStartupDraftPaste) => void sendFollowup: (handle: string, followup: WorktreeStartupFollowup) => void invalidateResolvedWorktrees: () => void @@ -131,10 +130,6 @@ export async function createRuntimeFolderWorktree(args: { let startupTerminal: CreateWorktreeResult['startupTerminal'] if (args.startup && deps.ptySpawnAvailable) { try { - const trustAgent = args.draftPaste?.agent ?? args.createdWithAgent - if (trustAgent) { - await deps.markTrusted(trustAgent, worktree.path) - } const terminal = await deps.createTerminal(`id:${worktree.id}`, { command: args.startup.command, ...(request.startupCwd ? { cwd: request.startupCwd } : {}), diff --git a/src/main/runtime/runtime-git-generation-admission.test.ts b/src/main/runtime/runtime-git-generation-admission.test.ts index 6631930082d..8e8391c10f3 100644 --- a/src/main/runtime/runtime-git-generation-admission.test.ts +++ b/src/main/runtime/runtime-git-generation-admission.test.ts @@ -1,3 +1,4 @@ +import { setImmediate as nextTurn } from 'node:timers/promises' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { GlobalSettings } from '../../shared/global-settings-types' import type { RuntimeGitCommandHost, RuntimeGitTarget } from './runtime-git-command-target' @@ -14,13 +15,19 @@ const mocks = vi.hoisted(() => ({ resolveHostedReviewBodyForGeneration: vi.fn() })) +const linkedLookup = vi.hoisted(() => ({ run: (): Promise => Promise.resolve(null) })) + vi.mock('../git/status', () => ({ getStagedCommitContext: mocks.getStagedCommitContext })) vi.mock('../git/runner', () => ({ gitExecFileAsync: mocks.gitExecFileAsync })) vi.mock('../text-generation/pull-request-context', () => ({ getPullRequestDraftContext: mocks.getPullRequestDraftContext })) vi.mock('../source-control/pull-request-linked-issue', () => ({ - loadPullRequestLinkedIssue: mocks.loadPullRequestLinkedIssue + loadPullRequestLinkedIssue: (...args: unknown[]) => { + mocks.loadPullRequestLinkedIssue(...args) + // A mock return observer would handle the rejection being tested. + return linkedLookup.run() + } })) vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: mocks.getSshGitProvider, @@ -77,7 +84,7 @@ describe('RuntimeGitGenerationCommands admission', () => { beforeEach(() => { vi.clearAllMocks() mocks.gitExecFileAsync.mockResolvedValue({ stdout: '', stderr: '' }) - mocks.loadPullRequestLinkedIssue.mockResolvedValue(null) + linkedLookup.run = () => Promise.resolve(null) mocks.resolveHostedReviewBodyForGeneration.mockImplementation(async ({ body }) => body) mocks.prepareLocalCommitMessageAgentEnv.mockResolvedValue({ ok: true, env: {} }) mocks.generateCommitMessageFromContext.mockResolvedValue({ success: true, message: 'feat' }) @@ -167,4 +174,103 @@ describe('RuntimeGitGenerationCommands admission', () => { expect.objectContaining({ connectionId: 'conn-1', localGitOptions: {} }) ) }) + + describe.each(['local', 'SSH'])('linked-issue lifetime on %s', (host) => { + function commands(): RuntimeGitGenerationCommands { + mocks.getSshGitProvider.mockReturnValue({ exec: vi.fn(), executeCommitMessagePlan: vi.fn() }) + return makeCommands( + makeTarget('/repo', { + executionHostId: host === 'SSH' ? 'ssh:conn-1' : 'local' + }) + ) + } + + const input = { base: 'main', title: '', body: '', draft: false, provider: 'gitlab' as const } + + it.each(['no changes', 'context error', 'template error'])( + 'observes a late lookup rejection after %s', + async (outcome) => { + const issue = Promise.withResolvers() + linkedLookup.run = () => issue.promise + const preparationError = new Error('preparation failed') + mocks.getPullRequestDraftContext.mockResolvedValue(pullRequestContext) + if (outcome === 'no changes') { + mocks.getPullRequestDraftContext.mockResolvedValue(null) + } else if (outcome === 'context error') { + mocks.getPullRequestDraftContext.mockRejectedValue(preparationError) + } else { + mocks.resolveHostedReviewBodyForGeneration.mockRejectedValue(preparationError) + } + const unhandled = vi.fn() + process.on('unhandledRejection', unhandled) + try { + await expect( + commands().generateRuntimePullRequestFields('id:wt-1', input, settingsOverride) + ).resolves.toEqual({ + success: false, + error: + outcome === 'no changes' + ? 'No branch changes to summarize.' + : preparationError.message + }) + expect(mocks.loadPullRequestLinkedIssue).toHaveBeenCalledOnce() + issue.reject(new Error('Timed out waiting for a GitLab operation slot.')) + await nextTurn() + expect(unhandled).not.toHaveBeenCalled() + expect(mocks.generatePullRequestFieldsFromContext).not.toHaveBeenCalled() + } finally { + issue.resolve(null) + void issue.promise.catch(() => undefined) + process.off('unhandledRejection', unhandled) + } + } + ) + + it('observes rejection during preparation and preserves the later caller error', async () => { + const issue = Promise.withResolvers() + const preparation = Promise.withResolvers() + const failure = new Error('lookup admission failed') + linkedLookup.run = () => issue.promise + mocks.getPullRequestDraftContext.mockReturnValue(preparation.promise) + const unhandled = vi.fn() + process.on('unhandledRejection', unhandled) + const result = commands() + .generateRuntimePullRequestFields('id:wt-1', input, settingsOverride) + .catch((error: unknown) => error) + try { + await nextTurn() + expect(mocks.getPullRequestDraftContext).toHaveBeenCalledOnce() + issue.reject(failure) + await nextTurn() + expect(unhandled).not.toHaveBeenCalled() + preparation.resolve(pullRequestContext) + expect(await result).toBe(failure) + expect(mocks.generatePullRequestFieldsFromContext).not.toHaveBeenCalled() + } finally { + issue.resolve(null) + preparation.resolve(pullRequestContext) + await result + process.off('unhandledRejection', unhandled) + } + }) + + it('still rejects the caller after successful preparation when the lookup fails', async () => { + const issue = Promise.withResolvers() + const failure = new Error('lookup failed') + linkedLookup.run = () => issue.promise + mocks.getPullRequestDraftContext.mockResolvedValue(pullRequestContext) + const result = commands() + .generateRuntimePullRequestFields('id:wt-1', input, settingsOverride) + .catch((error: unknown) => error) + try { + await nextTurn() + issue.reject(failure) + expect(await result).toBe(failure) + expect(mocks.generatePullRequestFieldsFromContext).not.toHaveBeenCalled() + } finally { + issue.resolve(null) + await result + } + }) + }) }) diff --git a/src/main/runtime/runtime-git-generation-commands.ts b/src/main/runtime/runtime-git-generation-commands.ts index bea10b2ef7d..988e2ee096f 100644 --- a/src/main/runtime/runtime-git-generation-commands.ts +++ b/src/main/runtime/runtime-git-generation-commands.ts @@ -182,6 +182,7 @@ export class RuntimeGitGenerationCommands { admissionTier: 'interactive' } }) + void linkedIssueDetailsPromise.catch(() => undefined) let context: Awaited> try { const currentBody = await resolveHostedReviewBodyForGeneration({ diff --git a/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts b/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts index df794567737..345e2c08377 100644 --- a/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts +++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts @@ -12,7 +12,8 @@ import type { LegacyWorkerRecoveryResolution } from './runtime-legacy-worker-terminal-recovery-types' import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity' -import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from './workspace-session-failed-write-rollback' +import { cloneWorkspaceSessionState } from '../persistence/restoring-sessions/session-owner-fields' +import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from '../persistence/restoring-sessions/workspace-session-write-rollback' export class RuntimeLegacyWorkerTerminalRecoveryPersistence { constructor( @@ -29,66 +30,68 @@ export class RuntimeLegacyWorkerTerminalRecoveryPersistence { resolutions: readonly LegacyWorkerRecoveryResolution[] ): Promise> { const store = this.getStore() - if ( - !store?.getWorkspaceSession || - !store.setWorkspaceSession || - (!store.flushPendingOrThrowAsync && !store.flushOrThrow) - ) { + if (!store?.getWorkspaceSession || !store.setWorkspaceSession || !store.runDurableMutation) { return new Set() } + const getWorkspaceSession = store.getWorkspaceSession.bind(store) + const setWorkspaceSession = store.setWorkspaceSession.bind(store) const originals = new Map() const staged = new Map() const dispatchIds = new Set() try { - for (const { candidate, resolution } of resolutions) { - const hostId = this.getHostId(candidate.worktreeId) - const session = hostId ? store.getWorkspaceSession(hostId) : null - if (!hostId || !session) { - continue + return await store.runDurableMutation(() => { + for (const { candidate, resolution } of resolutions) { + const hostId = this.getHostId(candidate.worktreeId) + const session = hostId ? getWorkspaceSession(hostId) : null + if (!hostId || !session) { + continue + } + originals.set(hostId, originals.get(hostId) ?? cloneWorkspaceSessionState(session)) + let next = + resolution === 'exited' + ? retireTerminalSurfaceFromPersistence(session, { + worktreeId: candidate.worktreeId, + parentTabId: candidate.tabId, + leafId: candidate.leafId, + ptyId: candidate.ptyId, + incarnationId: candidate.incarnationId + }) + : session + const record = next.sleepingAgentSessionsByPaneKey?.[candidate.paneKey] + if (record && runtimeWorktreeIdsEqual(record.worktreeId, candidate.worktreeId)) { + const sleeping = { ...next.sleepingAgentSessionsByPaneKey } + delete sleeping[candidate.paneKey] + next = { ...next, sleepingAgentSessionsByPaneKey: sleeping } + } + if (next !== session) { + setWorkspaceSession(next, hostId) + } + staged.set(hostId, cloneWorkspaceSessionState(getWorkspaceSession(hostId))) + dispatchIds.add(candidate.dispatchId) } - originals.set(hostId, originals.get(hostId) ?? session) - let next = - resolution === 'exited' - ? retireTerminalSurfaceFromPersistence(session, { - worktreeId: candidate.worktreeId, - parentTabId: candidate.tabId, - leafId: candidate.leafId, - ptyId: candidate.ptyId, - incarnationId: candidate.incarnationId - }) - : session - const record = next.sleepingAgentSessionsByPaneKey?.[candidate.paneKey] - if (record && runtimeWorktreeIdsEqual(record.worktreeId, candidate.worktreeId)) { - const sleeping = { ...next.sleepingAgentSessionsByPaneKey } - delete sleeping[candidate.paneKey] - next = { ...next, sleepingAgentSessionsByPaneKey: sleeping } + return { + value: dispatchIds, + persist: dispatchIds.size > 0, + rollback: () => { + for (const [hostId, original] of originals) { + const stagedSession = staged.get(hostId) + const current = getWorkspaceSession(hostId) + if (!stagedSession || !current) { + continue + } + const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite( + original, + stagedSession, + current + ) + if (rolledBack !== current) { + setWorkspaceSession(rolledBack, hostId) + } + } + } } - if (next !== session) { - store.setWorkspaceSession(next, hostId) - } - staged.set(hostId, store.getWorkspaceSession(hostId)) - dispatchIds.add(candidate.dispatchId) - } - if (dispatchIds.size > 0) { - await this.flush(store) - } - return dispatchIds + }) } catch (error) { - for (const [hostId, original] of originals) { - const stagedSession = staged.get(hostId) - const current = store.getWorkspaceSession(hostId) - if (!stagedSession || !current) { - continue - } - const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite( - original, - stagedSession, - current - ) - if (rolledBack !== current) { - store.setWorkspaceSession(rolledBack, hostId) - } - } console.warn('[orchestration] failed to persist legacy worker recovery batch', { dispatchIds: [...dispatchIds], error @@ -124,16 +127,4 @@ export class RuntimeLegacyWorkerTerminalRecoveryPersistence { return null } } - - private async flush(store: RuntimeStore): Promise { - if (store.flushPendingOrThrowAsync) { - await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - return - } - if (store.flushOrThrow) { - store.flushOrThrow() - return - } - throw new Error('workspace_session_persistence_unavailable') - } } diff --git a/src/main/runtime/runtime-local-git-worktree-create.ts b/src/main/runtime/runtime-local-git-worktree-create.ts index 36731ac3abb..ccefaa82255 100644 --- a/src/main/runtime/runtime-local-git-worktree-create.ts +++ b/src/main/runtime/runtime-local-git-worktree-create.ts @@ -152,6 +152,9 @@ export async function createRuntimeLocalGitWorktree(args: { }) : null // This path has no create-span recorder, so the miss reason is only observable on the IPC path. + if (preparedAttempt?.status === 'miss' && preparedAttempt.rearm) { + args.rearm.fire = preparedAttempt.rearm + } if (preparedAttempt?.status === 'hit') { addResult = preparedAttempt.result // Deferred, not fired: re-arming is a full `reset --hard`, and the caller still has diff --git a/src/main/runtime/runtime-local-worktree-create.test.ts b/src/main/runtime/runtime-local-worktree-create.test.ts index 4ad713111f5..c673da99640 100644 --- a/src/main/runtime/runtime-local-worktree-create.test.ts +++ b/src/main/runtime/runtime-local-worktree-create.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resolve } from 'node:path' import type { Store } from '../persistence' import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types' @@ -32,7 +33,7 @@ const mocks = vi.hoisted(() => ({ resolveInclude: vi.fn<() => Promise>(), copyPaths: vi.fn<() => Promise>(), created: { - path: '/worktrees/app', + path: '', head: 'abc123', branch: 'app', isBare: false, @@ -82,6 +83,8 @@ vi.mock('../ipc/worktree-symlinks', () => ({ import { createRuntimeLocalManagedWorktree } from './runtime-local-worktree-create' import type { PreparationRearmHolder } from '../worktree-create-preparation' +const worktreePath = resolve('/worktrees', 'app') + function createWorktree( request: Partial = {}, rearm: PreparationRearmHolder = { fire: () => {} } @@ -112,6 +115,7 @@ function createWorktree( beforeEach(() => { vi.resetAllMocks() + mocks.created.path = worktreePath mocks.routing.mockReturnValue({}) mocks.defaultBase.mockImplementation(async () => { expect(resolveGitAdmissionTier()).toBe('interactive') @@ -142,6 +146,36 @@ beforeEach(() => { }) describe('runtime prepared-worktree replenishment', () => { + it('keeps a failed claim reserved through the normal-add fallback', async () => { + mocks.consume.mockResolvedValue({ + status: 'miss', + reason: 'finalize_failed', + rearm: mocks.rearm + }) + const rearm: PreparationRearmHolder = { fire: () => {} } + await createWorktree({}, rearm) + + expect(mocks.add).toHaveBeenCalledOnce() + expect(mocks.rearm).not.toHaveBeenCalled() + rearm.fire() + expect(mocks.rearm).toHaveBeenCalledOnce() + }) + + it('keeps the failed claim release available when the fallback also fails', async () => { + mocks.consume.mockResolvedValue({ + status: 'miss', + reason: 'prepare_failed', + rearm: mocks.rearm + }) + mocks.add.mockRejectedValue(new Error('normal add failed')) + const rearm: PreparationRearmHolder = { fire: () => {} } + await expect(createWorktree({}, rearm)).rejects.toThrow('normal add failed') + + expect(mocks.rearm).not.toHaveBeenCalled() + rearm.fire() + expect(mocks.rearm).toHaveBeenCalledOnce() + }) + it('leaves the re-arm holder armed but unfired once probes and include copies finish', async () => { const rearm: PreparationRearmHolder = { fire: () => {} } let finishProbe!: (paths: string[]) => void @@ -207,8 +241,8 @@ describe('runtime create Git priority', () => { expect(mocks.remoteBase).toHaveBeenCalledWith('/repo', 'main', options) expect(mocks.hasBase).toHaveBeenCalledWith('/repo', 'main', options) expect(mocks.consume).toHaveBeenCalledWith(expect.objectContaining({ options })) - expect(mocks.pushTarget).toHaveBeenCalledWith('/worktrees/app', 'app', target, options) - expect(mocks.listing).toHaveBeenCalledWith('/repo', '/worktrees/app', 'app', options) + expect(mocks.pushTarget).toHaveBeenCalledWith(worktreePath, 'app', target, options) + expect(mocks.listing).toHaveBeenCalledWith('/repo', worktreePath, 'app', options) expect(mocks.resolveShared).toHaveBeenCalledWith('/repo', options) expect(mocks.resolveInclude).toHaveBeenCalledWith('/repo', options) } @@ -240,7 +274,7 @@ describe('runtime create Git priority', () => { } ) try { - await expect(createWorktree()).resolves.toHaveProperty('worktreePath', '/worktrees/app') + await expect(createWorktree()).resolves.toHaveProperty('worktreePath', worktreePath) expect(mocks.add).toHaveBeenCalledOnce() } finally { blocker.release() @@ -262,7 +296,7 @@ describe('runtime create Git priority', () => { expect(mocks.refresh).toHaveBeenCalledWith('/repo', base, options) expect(mocks.addSparse).toHaveBeenCalledWith( '/repo', - '/worktrees/app', + worktreePath, 'app', ['src'], 'origin/main', diff --git a/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts b/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts index 1e590f22973..a80351c6af1 100644 --- a/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts +++ b/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts @@ -41,7 +41,6 @@ function createPorts() { }) const ports: StartupArgs['ports'] = { canSpawn: true, - markTrusted: vi.fn(), createTerminal, pasteDraft: vi.fn(), sendFollowup: vi.fn(), diff --git a/src/main/runtime/runtime-local-worktree-terminal-startup.ts b/src/main/runtime/runtime-local-worktree-terminal-startup.ts index b3cb5e71850..e878eaa4b16 100644 --- a/src/main/runtime/runtime-local-worktree-terminal-startup.ts +++ b/src/main/runtime/runtime-local-worktree-terminal-startup.ts @@ -20,7 +20,6 @@ import type { type Ports = { canSpawn: boolean - markTrusted: (agent: TuiAgent, path: string) => Promise createTerminal: ( selector: string, options: TerminalCreateOptions @@ -94,10 +93,6 @@ export async function startRuntimeLocalWorktreeTerminals(args: { if (sequencedStartup && ports.canSpawn) { try { - const trustAgent = args.draftPaste?.agent ?? args.createdWithAgent - if (trustAgent) { - await ports.markTrusted(trustAgent, worktree.path) - } const terminal = await ports.createTerminal(`id:${worktree.id}`, { command: sequencedStartup.command, ...(request.startupCwd ? { cwd: request.startupCwd } : {}), diff --git a/src/main/runtime/runtime-managed-worktree-create-types.ts b/src/main/runtime/runtime-managed-worktree-create-types.ts index bdc8c2a0a12..9edd5b23836 100644 --- a/src/main/runtime/runtime-managed-worktree-create-types.ts +++ b/src/main/runtime/runtime-managed-worktree-create-types.ts @@ -54,6 +54,7 @@ export type RuntimeManagedWorktreeCreateArgs = { /** Per-launch inputs used when `startupAgent` is the created terminal surface. */ startupAgentArgs?: string | null startupCwd?: string + /** The surface behind a host-built startup agent (`startupAgent` or `startupDraft`). */ startupLaunchSource?: string /** A caller-minted `tabId:leafId` for the startup terminal's pane. */ startupPaneKey?: string diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index 9cdc365e1ba..fa87d839cd5 100644 --- a/src/main/runtime/runtime-notifier-contract.ts +++ b/src/main/runtime/runtime-notifier-contract.ts @@ -117,7 +117,10 @@ export type RuntimeNotifier = { baseVersion: string, content: string ): Promise - closeTerminal(tabId: string, paneRuntimeId?: number): void + /** Closes the whole tab. */ + closeTerminal(tabId: string): void + /** Drops one split pane main already closed; never closes its tab. */ + closeTerminalPane?(tabId: string, leafId: string): void closeTerminalTab?( tabId: string, options?: { localPtyTeardownOwnedExternally?: boolean; force?: boolean } diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index bb23548ae6f..60c1e18cf10 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -12,6 +12,7 @@ import type { ExecutionHostId } from '../../shared/execution-host' import type { PtyProviderBufferSnapshot, PtyProcessInfo, PtySpawnResult } from '../providers/types' import type { PtyProcessInspection } from '../providers/pty-process-inspection' import type { WriteSettlement } from '../../shared/pty-write-settlement' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' export type RuntimePtyController = { claimStablePaneCreate?(args: { @@ -93,9 +94,13 @@ export type RuntimePtyController = { stablePaneOwner?: { handle: string; tabId: string; leafId: string } agentSessionEnsure?: AgentSessionClaimedSpawnResult }> - write(ptyId: string, data: string): boolean + write(ptyId: string, data: string, inputKind: TerminalInputKind): boolean /** Three-valued settlement; local providers settle synchronously. */ - writeWithSettlement?(ptyId: string, data: string): WriteSettlement | Promise + writeWithSettlement?( + ptyId: string, + data: string, + inputKind: TerminalInputKind + ): WriteSettlement | Promise /** Attach-only adoption of a live local daemon session so its output streams * to main without a renderer pane; never creates, resizes, or focuses. * False on doubt (absent session, SSH-scoped id, non-daemon provider). */ @@ -106,7 +111,9 @@ export type RuntimePtyController = { ptyId: string, opts?: { keepHistory?: boolean; deadlineMs?: number } ): Promise - markReversibleStops?(ptyIds: readonly string[]): () => void + /** Durably records a kill order for an explicit close's unconfirmed stop, replayed when its SSH + * host reconnects. True only when an order was written; local PTYs have no later host to ask. */ + recordUnconfirmedStop?(ptyId: string): boolean getCwd?(ptyId: string): Promise getForegroundProcess(ptyId: string): Promise inspectProcess?( @@ -117,6 +124,7 @@ export type RuntimePtyController = { confirmShellForeground?(ptyId: string): Promise hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise + resetInputModes?(ptyId: string): Promise resize?(ptyId: string, cols: number, rows: number): boolean // Why: exact-id mobile polls should not enumerate every local and SSH PTY. hasPty?(ptyId: string): boolean | null diff --git a/src/main/runtime/runtime-registered-local-worktree-removal.ts b/src/main/runtime/runtime-registered-local-worktree-removal.ts index e6658b73ef2..011d30207fe 100644 --- a/src/main/runtime/runtime-registered-local-worktree-removal.ts +++ b/src/main/runtime/runtime-registered-local-worktree-removal.ts @@ -20,16 +20,9 @@ import { isOrphanCompatiblePreflightError, isOrphanedWorktreeError } from '../ipc/worktree-logic' -import { - getLocalWorktreePathAccess, - removeLocalWorktreePath, - toLocalWorktreeRuntimePath -} from '../local-worktree-filesystem' +import { cleanupLocalOrphanedWorktreeDirectory } from '../local-orphaned-worktree-cleanup' import { recoverLocalWindowsWorktreeRemoval } from '../local-worktree-removal-recovery' -import { - canSafelyRemoveOrphanedWorktreeDirectory, - findRegisteredDeletableWorktree -} from '../worktree-removal-safety' +import { findRegisteredDeletableWorktree } from '../worktree-removal-safety' import { CLIENT_REMOVAL_HOME } from '../worktree-removal-home-guard' import type { RuntimeStore } from './runtime-store-contract' import type { RuntimeWorktreeRemovalTarget } from './runtime-worktree-selection' @@ -160,7 +153,12 @@ export async function removeRuntimeRegisteredLocalWorktree(args: { removalResult = recovered completed = true } else if (isOrphanedWorktreeError(error)) { - await cleanupOrphanedDirectory(repo, canonicalPath, localOptions, args.closeWatchers) + await cleanupLocalOrphanedWorktreeDirectory( + repo.path, + canonicalPath, + localOptions, + args.closeWatchers + ) await gitExecFileAsync(['worktree', 'prune'], { cwd: repo.path, ...localOptions }).catch( () => {} ) @@ -190,29 +188,6 @@ export async function removeRuntimeRegisteredLocalWorktree(args: { } } -async function cleanupOrphanedDirectory( - repo: Repo, - path: string, - options: LocalProjectWorktreeGitOptions, - closeWatchers: (path: string) => Promise -): Promise { - const access = getLocalWorktreePathAccess(options) - if ( - await canSafelyRemoveOrphanedWorktreeDirectory( - toLocalWorktreeRuntimePath(path, options), - toLocalWorktreeRuntimePath(repo.path, options), - CLIENT_REMOVAL_HOME, - access.statPath, - access.readPath - ) - ) { - await closeWatchers(path) - await removeLocalWorktreePath(path, options).catch(() => {}) - } else { - console.warn(`[worktrees] Refusing recursive cleanup for unproven worktree directory: ${path}`) - } -} - async function cleanupPushTarget( args: Parameters[0] ): Promise { diff --git a/src/main/runtime/runtime-remote-managed-worktree-create.test.ts b/src/main/runtime/runtime-remote-managed-worktree-create.test.ts index 4dfa1ba2647..f642a07aeab 100644 --- a/src/main/runtime/runtime-remote-managed-worktree-create.test.ts +++ b/src/main/runtime/runtime-remote-managed-worktree-create.test.ts @@ -37,7 +37,6 @@ function createDeps() { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the create only forwards the store to the mocked request helper, which never reads it. store: {} as unknown as CreateParams[2]['store'], canSpawn: () => true, - markTrusted: vi.fn(), createTerminal, pasteDraft: vi.fn(), sendFollowup: vi.fn(), diff --git a/src/main/runtime/runtime-remote-managed-worktree-create.ts b/src/main/runtime/runtime-remote-managed-worktree-create.ts index 85ff97f10a0..8d5b4a60a3d 100644 --- a/src/main/runtime/runtime-remote-managed-worktree-create.ts +++ b/src/main/runtime/runtime-remote-managed-worktree-create.ts @@ -19,11 +19,6 @@ import { finishRuntimeRemoteWorktreeCreate } from './runtime-remote-worktree-cre type Dependencies = { store: RuntimeStore canSpawn(): boolean - markTrusted( - agent: NonNullable, - connectionId: string, - path: string - ): Promise createTerminal( selector: string, options: TerminalCreateOptions @@ -105,10 +100,6 @@ export async function createRuntimeRemoteManagedWorktree( if (sequencedStartup && deps.canSpawn()) { try { - const startupTrustAgent = args.startupDraftPaste?.agent ?? args.createdWithAgent - if (startupTrustAgent) { - await deps.markTrusted(startupTrustAgent, repo.connectionId!, result.worktree.path) - } const terminal = await deps.createTerminal(`path:${result.worktree.path}`, { command: sequencedStartup.command, ...(args.startupCwd ? { cwd: args.startupCwd } : {}), diff --git a/src/main/runtime/runtime-repository-hooks-commands.ts b/src/main/runtime/runtime-repository-hooks-commands.ts index f5518b8e59a..334def348d5 100644 --- a/src/main/runtime/runtime-repository-hooks-commands.ts +++ b/src/main/runtime/runtime-repository-hooks-commands.ts @@ -1,3 +1,4 @@ +import { getStoredRepoSshConnectionId } from '../repo-execution-host' import { createHash } from 'node:crypto' import { readFile } from 'node:fs/promises' import type { Repo } from '../../shared/repo-types' @@ -27,8 +28,9 @@ export class RuntimeRepositoryHooksCommands { async getRepoHooks(repoSelector: string) { const repo = await this.deps.resolveRepo(repoSelector) - if (repo.connectionId) { - const fsProvider = getSshFilesystemProvider(repo.connectionId) + const connectionId = getStoredRepoSshConnectionId(repo) + if (connectionId) { + const fsProvider = getSshFilesystemProvider(connectionId) if (!fsProvider) { return { hasHooksFile: false, @@ -73,8 +75,9 @@ export class RuntimeRepositoryHooksCommands { if (isFolderRepo(repo)) { return { status: 'ok' as const, hasHooks: false, hooks: null, mayNeedUpdate: false } } - if (repo.connectionId) { - const fsProvider = getSshFilesystemProvider(repo.connectionId) + const connectionId = getStoredRepoSshConnectionId(repo) + if (connectionId) { + const fsProvider = getSshFilesystemProvider(connectionId) if (!fsProvider) { return { status: 'error' as const, hasHooks: false, hooks: null, mayNeedUpdate: false } } @@ -115,8 +118,9 @@ export class RuntimeRepositoryHooksCommands { } return inspectSetupScriptImportCandidates(async (relativePath) => { const filePath = joinWorktreeRelativePath(repo.path, relativePath) - if (repo.connectionId) { - const fsProvider = getSshFilesystemProvider(repo.connectionId) + const connectionId = getStoredRepoSshConnectionId(repo) + if (connectionId) { + const fsProvider = getSshFilesystemProvider(connectionId) if (!fsProvider) { return null } diff --git a/src/main/runtime/runtime-repository-issue-command.ts b/src/main/runtime/runtime-repository-issue-command.ts index 84509b8b159..730dbe69e9d 100644 --- a/src/main/runtime/runtime-repository-issue-command.ts +++ b/src/main/runtime/runtime-repository-issue-command.ts @@ -1,3 +1,4 @@ +import { getStoredRepoSshConnectionId } from '../repo-execution-host' import type { GitRuntimeOptions } from '../git/git-runtime-options' import type { Repo } from '../../shared/repo-types' import { parseOrcaYaml } from '../hooks' @@ -31,11 +32,12 @@ export class RuntimeRepositoryIssueCommand { source: 'none' as const } } - if (!repo.connectionId) { + const connectionId = getStoredRepoSshConnectionId(repo) + if (!connectionId) { return readIssueCommand(repo.path) } const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') - const fsProvider = getSshFilesystemProvider(repo.connectionId) + const fsProvider = getSshFilesystemProvider(connectionId) if (!fsProvider) { return { localContent: null, @@ -66,12 +68,13 @@ export class RuntimeRepositoryIssueCommand { if (isFolderRepo(repo)) { return { ok: true } } - if (!repo.connectionId) { + const connectionId = getStoredRepoSshConnectionId(repo) + if (!connectionId) { await writeIssueCommand(repo.path, content, () => this.deps.getLocalGitArgs(repo)[0] ?? {}) return { ok: true } } const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') - const fsProvider = getSshFilesystemProvider(repo.connectionId) + const fsProvider = getSshFilesystemProvider(connectionId) if (!fsProvider) { return { ok: true } } @@ -85,7 +88,7 @@ export class RuntimeRepositoryIssueCommand { return { ok: true } } await fsProvider.createDir(joinWorktreeRelativePath(repo.path, '.orca')) - if (!(await isIssueCommandIgnoredByGit(repo.path, repo.connectionId))) { + if (!(await isIssueCommandIgnoredByGit(repo.path, connectionId))) { await ensureRemoteOrcaDirIgnored(fsProvider, repo.path) } await fsProvider.writeFile(issueCommandPath, `${trimmed}\n`) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts index ce67fb94aff..d544dc6720b 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts @@ -218,6 +218,9 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'agentSession.reveal', 'agentSession.send', 'agentSession.cancel', + 'agentSession.queuedMessageSend', + 'agentSession.queuedMessageDelete', + 'agentSession.queuedMessagesResume', 'agentSession.close', 'agentSession.respondToApproval', 'agentSession.respondToQuestion', @@ -230,6 +233,8 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'agentSession.history', 'agentSession.subscribe', 'agentSession.unsubscribe', + // No-ops on a current host; kept until MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION passes the + // mobile builds that still call them. 'agentSession.hold', 'agentSession.release', 'nativeChat.readSession', diff --git a/src/main/runtime/runtime-service-command-surface.ts b/src/main/runtime/runtime-service-command-surface.ts index 27a5ebdbdf9..82b3204da52 100644 --- a/src/main/runtime/runtime-service-command-surface.ts +++ b/src/main/runtime/runtime-service-command-surface.ts @@ -26,6 +26,7 @@ export type RuntimeServiceCommandSurface = { cleanupSubscriptionsForConnection: RuntimeSubscriptionRegistry['cleanupForConnection'] cleanupSubscriptionIfOwnedByConnection: RuntimeSubscriptionRegistry['cleanupIfOwnedByConnection'] getSubscriptionRegistrationVersion: RuntimeSubscriptionRegistry['getRegistrationVersion'] + releaseSubscriptionByRequest: RuntimeSubscriptionRegistry['releaseByRequest'] onNotificationDispatched: RuntimeMobileNotificationController['onDispatched'] getMobileNotificationListenerCount: RuntimeMobileNotificationController['getListenerCount'] dispatchMobileNotification: RuntimeMobileNotificationController['dispatch'] @@ -116,6 +117,7 @@ export function installRuntimeServiceCommandSurface( cleanupSubscriptionIfOwnedByConnection: subscriptions.cleanupIfOwnedByConnection.bind(subscriptions), getSubscriptionRegistrationVersion: subscriptions.getRegistrationVersion.bind(subscriptions), + releaseSubscriptionByRequest: subscriptions.releaseByRequest.bind(subscriptions), onNotificationDispatched: notifications.onDispatched.bind(notifications), getMobileNotificationListenerCount: notifications.getListenerCount.bind(notifications), dispatchMobileNotification: notifications.dispatch.bind(notifications), diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 6c1436395f1..43fe59b34e1 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -48,6 +48,7 @@ export type RuntimeStore = { getWorkspaceSession?: Store['getWorkspaceSession'] getWorkspaceSessionHostIds?: Store['getWorkspaceSessionHostIds'] setWorkspaceSession?: Store['setWorkspaceSession'] + runDurableMutation?: Store['runDurableMutation'] flushOrThrow?: Store['flushOrThrow'] flushPendingOrThrowAsync?: Store['flushPendingOrThrowAsync'] persistPtyBinding?: Store['persistPtyBinding'] @@ -124,6 +125,8 @@ export type RuntimeStore = { nativeChatInheritShellEnvironment?: GlobalSettings['nativeChatInheritShellEnvironment'] nativeChatShellEnvironmentVariables?: GlobalSettings['nativeChatShellEnvironmentVariables'] aiVaultSearch?: GlobalSettings['aiVaultSearch'] + sourceControlAi?: GlobalSettings['sourceControlAi'] + commitMessageAi?: GlobalSettings['commitMessageAi'] } // Why: narrow to `unknown` return so test mocks can return void without // a cast. The runtime never reads the return value — the persisted value diff --git a/src/main/runtime/runtime-subscription-registry.test.ts b/src/main/runtime/runtime-subscription-registry.test.ts index 093b855d483..0d3a3e3f4d8 100644 --- a/src/main/runtime/runtime-subscription-registry.test.ts +++ b/src/main/runtime/runtime-subscription-registry.test.ts @@ -123,3 +123,106 @@ describe('subscription registration versions', () => { expect(cleanup).toHaveBeenCalledTimes(2) }) }) + +describe('request-addressed release', () => { + const requestAddresses = (registry: RuntimeSubscriptionRegistry): number => + registry['subscriptionsByRequest'].size + // Never `cleanupAndWait` here: it would run the cleanup itself and hide a missed release. + const settle = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)) + + it('releases the registration a request created, and forgets the address', async () => { + const registry = new RuntimeSubscriptionRegistry() + const cleanup = vi.fn() + registry.registerOwned('terminal:slot', cleanup, 'conn-a', 'req-1') + expect(requestAddresses(registry)).toBe(1) + + registry.releaseByRequest('conn-a', 'req-1') + await settle() + + expect(cleanup).toHaveBeenCalledOnce() + expect(requestAddresses(registry)).toBe(0) + }) + + it('ignores the request a same-slot replacement superseded', async () => { + const registry = new RuntimeSubscriptionRegistry() + const replacement = vi.fn() + registry.registerOwned('terminal:slot', vi.fn(), 'conn-a', 'req-old') + registry.registerOwned('terminal:slot', replacement, 'conn-a', 'req-new') + expect(requestAddresses(registry)).toBe(1) + + registry.releaseByRequest('conn-a', 'req-old') + await settle() + expect(replacement).not.toHaveBeenCalled() + + registry.releaseByRequest('conn-a', 'req-new') + await settle() + expect(replacement).toHaveBeenCalledOnce() + }) + + it('does not grow across replace and release cycles', async () => { + const registry = new RuntimeSubscriptionRegistry() + const cleanup = vi.fn() + for (let i = 0; i < 50; i++) { + registry.registerOwned('terminal:slot', cleanup, 'conn-a', `req-${2 * i}`) + registry.registerOwned('terminal:slot', cleanup, 'conn-a', `req-${2 * i + 1}`) + expect(requestAddresses(registry)).toBe(1) + registry.releaseByRequest('conn-a', `req-${2 * i + 1}`) + await settle() + } + expect(cleanup).toHaveBeenCalledTimes(100) + expect(requestAddresses(registry)).toBe(0) + }) + + it('keeps the newer owner when a reused request id outlives the old release', async () => { + const registry = new RuntimeSubscriptionRegistry() + const gate = Promise.withResolvers() + const newer = vi.fn() + // IPC aborts a subscription and reuses its id before the old teardown settles. + const old = registry.registerOwned('terminal:old', () => gate.promise, 'ipc', 'sub-1') + old.releaseIfCurrent() + registry.registerOwned('terminal:new', newer, 'ipc', 'sub-1') + gate.resolve() + await settle() + + registry.releaseByRequest('ipc', 'sub-1') + await settle() + expect(newer).toHaveBeenCalledOnce() + expect(requestAddresses(registry)).toBe(0) + }) + + it('keeps the address of a registration whose cleanup failed, so a retry still reaches it', async () => { + const registry = new RuntimeSubscriptionRegistry() + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const cleanup = vi.fn().mockRejectedValueOnce(new Error('teardown failed')) + registry.registerOwned('terminal:slot', cleanup, 'conn-a', 'req-1') + + registry.releaseByRequest('conn-a', 'req-1') + await settle() + expect(cleanup).toHaveBeenCalledOnce() + expect(requestAddresses(registry)).toBe(1) + + registry.releaseByRequest('conn-a', 'req-1') + await settle() + expect(cleanup).toHaveBeenCalledTimes(2) + expect(requestAddresses(registry)).toBe(0) + consoleError.mockRestore() + }) + + it('addresses nothing without both a connection and a request id', async () => { + const registry = new RuntimeSubscriptionRegistry() + const unaddressed = vi.fn() + const addressed = vi.fn() + registry.registerOwned('terminal:no-conn', unaddressed, undefined, 'req-1') + registry.registerOwned('terminal:no-req', vi.fn(), 'conn-a') + registry.registerOwned('terminal:addressed', addressed, 'conn-a', 'req-1') + expect(requestAddresses(registry)).toBe(1) + + registry.releaseByRequest(undefined, 'req-1') + registry.releaseByRequest('conn-b', 'req-1') + await settle() + + expect(unaddressed).not.toHaveBeenCalled() + expect(addressed).not.toHaveBeenCalled() + await registry.cleanupAndWait('terminal:addressed') + }) +}) diff --git a/src/main/runtime/runtime-subscription-registry.ts b/src/main/runtime/runtime-subscription-registry.ts index 93e04619618..eaa41dba1af 100644 --- a/src/main/runtime/runtime-subscription-registry.ts +++ b/src/main/runtime/runtime-subscription-registry.ts @@ -1,6 +1,15 @@ type SubscriptionCleanup = () => void | Promise -type SubscriptionEntry = { cleanup: SubscriptionCleanup; version: number } +type RequestAddress = { connectionId: string; requestId: string } + +const requestKey = ({ connectionId, requestId }: RequestAddress): string => + JSON.stringify([connectionId, requestId]) + +type SubscriptionEntry = { + cleanup: SubscriptionCleanup + version: number + request?: RequestAddress +} export type SubscriptionRegistration = { releaseIfCurrent(): void @@ -14,19 +23,35 @@ export class RuntimeSubscriptionRegistry { >() private readonly subscriptionsByConnection = new Map>() private readonly connectionBySubscription = new Map() + private readonly subscriptionsByRequest = new Map< + string, + { subscriptionId: string; version: number } + >() private registrationVersion = 0 getRegistrationVersion(): number { return this.registrationVersion } - register(subscriptionId: string, cleanup: SubscriptionCleanup, connectionId?: string): void { + register( + subscriptionId: string, + cleanup: SubscriptionCleanup, + connectionId?: string, + requestId?: string + ): void { const existing = this.cleanups.get(subscriptionId) if (existing) { this.removeConnectionIndex(subscriptionId) + this.removeRequestIndex(existing) this.cleanup(subscriptionId) } - this.cleanups.set(subscriptionId, { cleanup, version: ++this.registrationVersion }) + const version = ++this.registrationVersion + const request = connectionId && requestId ? { connectionId, requestId } : undefined + this.cleanups.set(subscriptionId, { cleanup, version, request }) + if (request) { + // Why: IPC reuses a request id after aborting its previous subscription, so the newer one owns the address. + this.subscriptionsByRequest.set(requestKey(request), { subscriptionId, version }) + } if (!connectionId) { return } @@ -42,13 +67,26 @@ export class RuntimeSubscriptionRegistry { registerOwned( subscriptionId: string, cleanup: SubscriptionCleanup, - connectionId?: string + connectionId?: string, + requestId?: string ): SubscriptionRegistration { - this.register(subscriptionId, cleanup, connectionId) + this.register(subscriptionId, cleanup, connectionId, requestId) const version = this.registrationVersion return { releaseIfCurrent: () => this.cleanupOwned(subscriptionId, version) } } + /** Releases the registration a request created; an unknown, ended or replaced request is a no-op. */ + releaseByRequest(connectionId: string | undefined, requestId: string): void { + // Why: some non-phone sockets carry no connection id, and a bare request id is not unique across sockets. + if (!connectionId) { + return + } + const target = this.subscriptionsByRequest.get(requestKey({ connectionId, requestId })) + if (target) { + this.cleanupOwned(target.subscriptionId, target.version) + } + } + cleanupIfOwnedByConnection( subscriptionId: string, connectionId?: string, @@ -121,6 +159,7 @@ export class RuntimeSubscriptionRegistry { } this.cleanups.delete(subscriptionId) this.removeConnectionIndex(subscriptionId) + this.removeRequestIndex(entry) }) .finally(() => { if (this.cleanupPromises.get(subscriptionId)?.promise === promise) { @@ -131,8 +170,13 @@ export class RuntimeSubscriptionRegistry { return promise } - cleanupByPrefix(prefix: string): void { - const ids = Array.from(this.cleanups.keys()).filter((id) => id.startsWith(prefix)) + cleanupByPrefix(prefix: string, throughVersion?: number): void { + const ids = Array.from(this.cleanups.entries()) + .filter( + ([id, entry]) => + id.startsWith(prefix) && (throughVersion === undefined || entry.version <= throughVersion) + ) + .map(([id]) => id) for (const id of ids) { this.cleanup(id) } @@ -162,6 +206,17 @@ export class RuntimeSubscriptionRegistry { this.cleanup(subscriptionId) } + /** Compare-and-delete: a newer registration that reused the request id keeps its address. */ + private removeRequestIndex(entry: SubscriptionEntry): void { + if (!entry.request) { + return + } + const key = requestKey(entry.request) + if (this.subscriptionsByRequest.get(key)?.version === entry.version) { + this.subscriptionsByRequest.delete(key) + } + } + private removeConnectionIndex(subscriptionId: string): void { const connectionId = this.connectionBySubscription.get(subscriptionId) if (!connectionId) { diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 5ee2cc8e7fc..e08fffa273c 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -16,6 +16,7 @@ import type { TuiAgent } from '../../shared/tui-agent' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import type { RuntimeTerminalSend } from '../../shared/runtime-terminal-contracts' import type { RuntimeTerminalWriteOptions } from './runtime-terminal-writer' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' import type { RuntimePtyController } from './runtime-pty-controller-contract' import type { RuntimeAgentRowSnapshot } from './runtime-worktree-agent-rows' import type { WorkerTerminalHostScope } from './orchestration/worker-terminal-process-liveness' @@ -61,6 +62,9 @@ export type TerminalCreateOptions = { viewMode?: 'terminal' | 'chat' startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] telemetry?: WorktreeStartupLaunch['telemetry'] + /** The surface that asked for this `startupAgent` launch; the runtime attributes every one it + * builds, as `unknown` when this is absent or unrecognized. Ignored without `startupAgent`. */ + launchSource?: string title?: string focus?: boolean rendererBacked?: boolean @@ -79,6 +83,8 @@ export type TerminalCreateOptions = { agentSessionCreateOperationId?: string signal?: AbortSignal onPtySpawnCommitted?: () => void + /** Called before the spawn request leaves this process; a throw before it proves nothing spawned. */ + onPtySpawnDispatched?: () => void deferMobileSessionPublish?: boolean } @@ -201,7 +207,9 @@ export type RuntimeProviderSnapshotReadOptions = { } /** Agent-prompt writes add the correlation inputs a queued-acceptance receipt needs. */ -export type RuntimeAgentPromptWriteOptions = RuntimeTerminalWriteOptions & { +export type RuntimeAgentPromptWriteOptions = Omit & { + /** `launch` for the prompt an agent starts with; `driving` for any prompt sent to a running one. */ + inputKind: Exclude /** Raw prompt text for submit scheduling; not written, only used for line-aware delays. */ promptForSchedule?: string /** See buildAgentPromptPasteBytes. */ diff --git a/src/main/runtime/runtime-terminal-idle-polls.test.ts b/src/main/runtime/runtime-terminal-idle-polls.test.ts index 99622ce42df..5149c63da22 100644 --- a/src/main/runtime/runtime-terminal-idle-polls.test.ts +++ b/src/main/runtime/runtime-terminal-idle-polls.test.ts @@ -3,6 +3,8 @@ import { RuntimeTerminalIdlePolls } from './runtime-terminal-idle-polls' import type { TerminalWaiter } from './runtime-terminal-contracts' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import type { RuntimeTerminalWait } from '../../shared/runtime-types' +import type { TuiAgent } from '../../shared/tui-agent' +import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' const INTERVAL_MS = 2000 @@ -70,9 +72,12 @@ describe('RuntimeTerminalIdlePolls timer budget', () => { quiescenceMs: 1500, getTabTitle: () => null, getForegroundProcess: () => null, + hasCommandPainted: () => true, getAdoptedPtyIdleStatus: () => null, getPaneAgent: () => null, getFirstPartyAgentStatus: () => null, + readScreenLines: () => null, + readVisibleScreen: () => null, getLiveLeaf: (leaf) => leaf, resolve: (waiter, result) => resolved.push({ handle: waiter.handle, result }) }) @@ -105,9 +110,12 @@ describe('RuntimeTerminalIdlePolls timer budget', () => { quiescenceMs: 1500, getTabTitle: () => null, getForegroundProcess: () => null, + hasCommandPainted: () => true, getAdoptedPtyIdleStatus: () => null, getPaneAgent: () => null, getFirstPartyAgentStatus: () => null, + readScreenLines: () => null, + readVisibleScreen: () => null, getLiveLeaf: (leaf) => leaf, resolve: () => {} }) @@ -130,9 +138,12 @@ describe('RuntimeTerminalIdlePolls timer budget', () => { quiescenceMs: 1500, getTabTitle: () => null, getForegroundProcess: () => null, + hasCommandPainted: () => true, getAdoptedPtyIdleStatus: () => null, getPaneAgent: () => null, getFirstPartyAgentStatus: () => null, + readScreenLines: () => null, + readVisibleScreen: () => null, getLiveLeaf: (leaf) => leaf, resolve: () => {} }) @@ -160,9 +171,12 @@ describe('RuntimeTerminalIdlePolls timer budget', () => { new Promise((resolve) => { gates.push(resolve) }), + hasCommandPainted: () => true, getAdoptedPtyIdleStatus: () => null, getPaneAgent: () => null, getFirstPartyAgentStatus: () => null, + readScreenLines: () => null, + readVisibleScreen: () => null, getLiveLeaf: (leaf) => leaf, resolve: (waiter) => resolved.push(waiter.handle) }) @@ -185,3 +199,238 @@ describe('RuntimeTerminalIdlePolls timer budget', () => { expect(polls.activeTimerCount).toBe(0) }) }) + +describe('RuntimeTerminalIdlePolls rendered-screen blocked prompts', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + const TRUST_SCREEN = [ + 'Accessing workspace:', + '/repo/app', + 'Quick safety check: Is this a project you created or one you trust?', + '❯ No, exit', + ' Yes, I trust this folder', + 'Enter to confirm · Esc to cancel' + ].join('\n') + + function createPolls( + readVisibleScreen: (ptyId: string) => Promise | null, + resolved: RuntimeTerminalWait[], + foreground: string | null = 'claude', + agent: TuiAgent | null = null + ): RuntimeTerminalIdlePolls { + return new RuntimeTerminalIdlePolls({ + intervalMs: INTERVAL_MS, + quiescenceMs: 1500, + getTabTitle: () => null, + // Unknown agent + quiet pane: without the screen check this would settle idle. + getForegroundProcess: () => (foreground ? Promise.resolve(foreground) : null), + hasCommandPainted: () => true, + getAdoptedPtyIdleStatus: () => null, + getPaneAgent: () => agent, + getFirstPartyAgentStatus: () => null, + readScreenLines: () => null, + readVisibleScreen, + getLiveLeaf: (leaf) => leaf, + resolve: (_waiter, result) => resolved.push(result) + }) + } + + it('reports a dialog the tail lost but the screen still shows, ahead of a quiet-pane idle', async () => { + const resolved: RuntimeTerminalWait[] = [] + const polls = createPolls(() => Promise.resolve(TRUST_SCREEN), resolved) + polls.startPty(makeWaiter('pty'), makePty('pty-1', { lastOutputAt: Date.now() - 10_000 })) + polls.startLeaf(makeWaiter('leaf'), makeLeaf('tab-1', { lastOutputAt: Date.now() - 10_000 })) + + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + + expect(resolved).toHaveLength(2) + expect(resolved).toEqual([ + expect.objectContaining({ satisfied: false, blockedReason: 'agent-trust-workspace' }), + expect.objectContaining({ satisfied: false, blockedReason: 'agent-trust-workspace' }) + ]) + expect(polls.activeTimerCount).toBe(0) + }) + + it('does not read the screen of an agent whose title says it is working', async () => { + const resolved: RuntimeTerminalWait[] = [] + const reads: string[] = [] + const polls = createPolls((ptyId) => { + reads.push(ptyId) + return Promise.resolve(TRUST_SCREEN) + }, resolved) + polls.startPty(makeWaiter('pty'), makePty('pty-1', { lastAgentStatus: 'working' })) + polls.startLeaf(makeWaiter('leaf'), makeLeaf('tab-1', { lastAgentStatus: 'working' })) + + await vi.advanceTimersByTimeAsync(INTERVAL_MS * 3) + + expect(reads).toEqual([]) + expect(resolved).toEqual([]) + expect(polls.activeTimerCount).toBe(1) + }) + + it('does not resolve a waiter that was cancelled while its screen read was pending', async () => { + const resolved: RuntimeTerminalWait[] = [] + const finishRead = new Map void>() + const reads: string[] = [] + const polls = createPolls( + (ptyId) => { + reads.push(ptyId) + return new Promise((resolve) => { + finishRead.set(ptyId, resolve) + }) + }, + resolved, + null + ) + const waiter = makeWaiter('pty') + polls.startPty(waiter, makePty('pty-1')) + polls.startPty(makeWaiter('other'), makePty('pty-2')) + + await vi.advanceTimersByTimeAsync(INTERVAL_MS * 2) + // One read per waiter: the second sweep must not stack reads behind a pending one. + expect(reads).toEqual(['pty-1', 'pty-2']) + + waiter.cancelIdlePoll?.() + finishRead.get('pty-1')?.(TRUST_SCREEN) + await vi.advanceTimersByTimeAsync(0) + expect(resolved).toEqual([]) + }) + + // Why: a name-only title is the only rest signal these agents emit, but it cannot see a + // dialog the tail lost, so it settles only once the screen read comes back clear. + it.each(['grok', 'copilot', 'aider'] as const)( + "settles %s's name-only title only after its screen read", + async (agent) => { + const resolved: RuntimeTerminalWait[] = [] + const screens: ((screen: string) => void)[] = [] + const polls = createPolls( + () => new Promise((resolve) => screens.push(resolve)), + resolved, + null, + agent + ) + const pty = makePty('pty-1', { lastAgentStatus: 'idle', lastOscTitle: agent }) + polls.startPty(makeWaiter('pty'), pty, { kind: 'ready-weak' }) + + await vi.advanceTimersByTimeAsync(0) + expect(screens).toHaveLength(1) + expect(resolved).toEqual([]) + + screens[0](`${agent} ready for input`) + await vi.advanceTimersByTimeAsync(0) + expect(resolved).toEqual([expect.objectContaining({ satisfied: true })]) + } + ) + + it('reports a dialog on screen under a name-only title instead of settling ready', async () => { + const resolved: RuntimeTerminalWait[] = [] + const polls = createPolls(() => Promise.resolve(TRUST_SCREEN), resolved, null, 'grok') + const pty = makePty('pty-1', { lastAgentStatus: 'idle', lastOscTitle: 'grok' }) + polls.startPty(makeWaiter('pty'), pty, { kind: 'ready-weak' }) + + await vi.advanceTimersByTimeAsync(0) + expect(resolved).toEqual([ + expect.objectContaining({ satisfied: false, blockedReason: 'agent-trust-workspace' }) + ]) + }) + + it("lets an agent's own idle title outrank dialog wording on its screen", async () => { + const resolved: RuntimeTerminalWait[] = [] + const reads: string[] = [] + const polls = createPolls( + (ptyId) => { + reads.push(ptyId) + return Promise.resolve(TRUST_SCREEN) + }, + resolved, + null, + 'claude' + ) + const pty = makePty('pty-1', { lastAgentStatus: 'idle', lastOscTitle: '✳ Claude Code' }) + polls.startPty(makeWaiter('pty'), pty) + + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + expect(reads).toEqual([]) + expect(resolved).toEqual([expect.objectContaining({ satisfied: true })]) + }) +}) + +describe('RuntimeTerminalIdlePolls quiet foreground for a launched agent', () => { + const QUIESCENCE_MS = 1500 + + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + function createPolls( + agent: TuiAgent, + resolved: string[], + foregroundReads: string[] = [] + ): RuntimeTerminalIdlePolls { + return new RuntimeTerminalIdlePolls({ + intervalMs: INTERVAL_MS, + quiescenceMs: QUIESCENCE_MS, + getTabTitle: () => null, + getForegroundProcess: (ptyId) => { + foregroundReads.push(ptyId) + return Promise.resolve(TUI_AGENT_CONFIG[agent].expectedProcess) + }, + hasCommandPainted: () => true, + getAdoptedPtyIdleStatus: () => null, + getPaneAgent: () => agent, + getFirstPartyAgentStatus: () => null, + readScreenLines: () => null, + readVisibleScreen: () => null, + getLiveLeaf: (leaf) => leaf, + resolve: (waiter) => resolved.push(waiter.handle) + }) + } + + // Why: amp's title never classifies, so this lane is its only rest signal; closing it for + // every launched agent failed `worker start` at agent_readiness after 60s (STA-7440). + it('settles an agent with no other rest signal once it has painted and gone quiet', async () => { + const resolved: string[] = [] + const foregroundReads: string[] = [] + const polls = createPolls('amp', resolved, foregroundReads) + const pty = makePty('pty-amp') + const leaf = makeLeaf('tab-amp') + polls.startPty(makeWaiter('pty'), pty) + polls.startLeaf(makeWaiter('leaf'), leaf) + + // Booting: the agent owns the foreground but has painted nothing (#9976). + await vi.advanceTimersByTimeAsync(INTERVAL_MS * 5) + expect(resolved).toEqual([]) + + await vi.advanceTimersByTimeAsync(INTERVAL_MS / 2) + pty.lastOutputAt = Date.now() + leaf.lastOutputAt = Date.now() + // The next sweep lands inside the quiet window measured from that paint. + await vi.advanceTimersByTimeAsync(INTERVAL_MS / 2) + expect(resolved).toEqual([]) + // A pane that cannot settle yet costs no process inspection. + expect(foregroundReads).toEqual([]) + + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + expect(resolved).toEqual(['pty', 'leaf']) + expect(polls.activeTimerCount).toBe(0) + }) + + it('does not settle an agent that will announce rest itself on a quiet foreground', async () => { + const resolved: string[] = [] + const polls = createPolls('claude', resolved) + polls.startPty(makeWaiter('pty'), makePty('pty-claude', { lastOutputAt: Date.now() })) + + await vi.advanceTimersByTimeAsync(INTERVAL_MS * 5) + expect(resolved).toEqual([]) + }) +}) diff --git a/src/main/runtime/runtime-terminal-idle-polls.ts b/src/main/runtime/runtime-terminal-idle-polls.ts index 8960dc79d26..d63b59974ee 100644 --- a/src/main/runtime/runtime-terminal-idle-polls.ts +++ b/src/main/runtime/runtime-terminal-idle-polls.ts @@ -1,10 +1,9 @@ -import { isShellProcess, type AgentStatus } from '../../shared/agent-detection' -import type { RuntimeTerminalWait } from '../../shared/runtime-types' -import { - detectTerminalWaitBlockedReason, - isKnownReadyPromptPreview, - isMuseReadyPromptPreview -} from './terminal-wait-detection' +import { isShellProcess } from '../../shared/agent-detection' +import type { + RuntimeTerminalWait, + RuntimeTerminalWaitBlockedReason +} from '../../shared/runtime-types' +import { detectTerminalWaitBlockedReason } from './terminal-wait-detection' import { buildPtyTerminalWaitBlockedResult, buildPtyTerminalWaitResult, @@ -13,53 +12,71 @@ import { } from './terminal-wait-results' import { buildTerminalWaitText } from './terminal-wait-tail-state' import { - isTuiIdleSatisfied, - quietForegroundProcessProvesTuiIdle, - type FirstPartyAgentStatus + evaluateTuiIdle, + leafTuiIdleEvidence, + ptyTuiIdleEvidence, + type QuietForegroundLane, + type TuiIdleEvidenceSource, + type TuiIdleVerdict } from './tui-idle-evidence' -import type { TuiAgent } from '../../shared/tui-agent' /** - * Why null counts as quiet: a record with no output timestamp has produced nothing the - * RUNTIME OBSERVED since it was created. That is not the same as silence — the reachable - * case is a daemon-hosted pane whose bytes never reach the runtime, which may still be - * streaming. The trade is deliberate: "never settles" becomes "settles uncorroborated", + * Why null counts as quiet on an `open` lane: a record with no output timestamp has produced + * nothing the RUNTIME OBSERVED since it was created. That is not the same as silence — the + * reachable case is a daemon-hosted pane whose bytes never reach the runtime, which may still + * be streaming. The trade is deliberate: "never settles" becomes "settles uncorroborated", * the caller keeps its timeout, and delivery cannot reach this lane. Reading it as `0ms since output` * inverted that — `0 >= quiescenceMs` is false forever, so an adopted pane that never * emitted could not settle no matter how long the caller waited. + * Why not on `after-paint`: that pane runs a known agent, whose TUI must paint before it can + * take input, so until the command has painted it is still booting. The shell's prompt and + * echoed command line are not the agent's paint (see terminal-command-paint.ts). */ -function isQuietForQuiescence(lastOutputAt: number | null, quiescenceMs: number): boolean { - return lastOutputAt === null ? true : Date.now() - lastOutputAt >= quiescenceMs +function isQuietForQuiescence( + lastOutputAt: number | null, + quiescenceMs: number, + lane: QuietForegroundLane, + commandPainted: () => boolean +): boolean { + if (lane === 'after-paint' && !commandPainted()) { + return false + } + if (lastOutputAt === null) { + return lane === 'open' + } + return Date.now() - lastOutputAt >= quiescenceMs } import type { TerminalWaiter } from './runtime-terminal-contracts' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' -type RuntimeTerminalIdlePollDependencies = { +type RuntimeTerminalIdlePollDependencies = TuiIdleEvidenceSource & { intervalMs: number - quiescenceMs: number - getTabTitle(tabId: string): string | null getForegroundProcess(ptyId: string): Promise | null - getAdoptedPtyIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null - getPaneAgent(ptyId: string | null | undefined): TuiAgent | null - getFirstPartyAgentStatus(ptyId: string | null | undefined): FirstPartyAgentStatus - /** Re-read the record the waiter registered against; see `liveLeaf` below. */ + /** Whether the pane's running command has painted anything of its own. */ + hasCommandPainted(ptyId: string): boolean + /** The pane's rendered viewport, or null when the runtime holds no screen model for it. */ + readVisibleScreen(ptyId: string): Promise | null + /** Re-read the record the waiter registered against; see `sample` below. */ getLiveLeaf(leaf: RuntimeLeafRecord): RuntimeLeafRecord resolve(waiter: TerminalWaiter, result: RuntimeTerminalWait): void } -type IdlePollEntry = - | { - kind: 'leaf' - waiter: TerminalWaiter - leaf: RuntimeLeafRecord - foregroundPollInFlight: boolean - } - | { - kind: 'pty' - waiter: TerminalWaiter - pty: RuntimePtyWorktreeRecord - foregroundPollInFlight: boolean - } +type IdlePollEntry = { + waiter: TerminalWaiter + foregroundPollInFlight: boolean + screenReadInFlight: boolean +} & ({ kind: 'leaf'; leaf: RuntimeLeafRecord } | { kind: 'pty'; pty: RuntimePtyWorktreeRecord }) + +/** One reading of a waiter's pane, and the results it would settle with. */ +type IdlePollSample = { + verdict: TuiIdleVerdict + ptyId: string | null + ready(): RuntimeTerminalWait + blocked(reason: RuntimeTerminalWaitBlockedReason): RuntimeTerminalWait + isQuiet(lane: QuietForegroundLane): boolean +} + +const IDLE_ENTRY_FLAGS = { foregroundPollInFlight: false, screenReadInFlight: false } export class RuntimeTerminalIdlePolls { private readonly entries = new Set() @@ -67,12 +84,13 @@ export class RuntimeTerminalIdlePolls { constructor(private readonly deps: RuntimeTerminalIdlePollDependencies) {} - startLeaf(waiter: TerminalWaiter, leaf: RuntimeLeafRecord): void { - this.start({ kind: 'leaf', waiter, leaf, foregroundPollInFlight: false }) + /** `verdict` is what the caller just evaluated; weak ready is checked at once, not a sweep later. */ + startLeaf(waiter: TerminalWaiter, leaf: RuntimeLeafRecord, verdict?: TuiIdleVerdict): void { + this.start({ kind: 'leaf', waiter, leaf, ...IDLE_ENTRY_FLAGS }, verdict) } - startPty(waiter: TerminalWaiter, pty: RuntimePtyWorktreeRecord): void { - this.start({ kind: 'pty', waiter, pty, foregroundPollInFlight: false }) + startPty(waiter: TerminalWaiter, pty: RuntimePtyWorktreeRecord, verdict?: TuiIdleVerdict): void { + this.start({ kind: 'pty', waiter, pty, ...IDLE_ENTRY_FLAGS }, verdict) } /** Test/diagnostic seam: live sweep handles, which must stay at most one. */ @@ -80,7 +98,7 @@ export class RuntimeTerminalIdlePolls { return this.sweepTimer ? 1 : 0 } - private start(entry: IdlePollEntry): void { + private start(entry: IdlePollEntry, verdict: TuiIdleVerdict | undefined): void { this.entries.add(entry) entry.waiter.cancelIdlePoll = () => this.stop(entry) // Why one shared timer for every waiter: a per-waiter interval multiplied idle @@ -89,6 +107,11 @@ export class RuntimeTerminalIdlePolls { if (!this.sweepTimer) { this.sweepTimer = setInterval(() => this.sweep(), this.deps.intervalMs) } + // Why: the evidence is already in hand and only needs its screen read; a probe that + // times out under one interval (the automation start probe) would otherwise never see it. + if (verdict?.kind === 'ready-weak') { + void this.tick(entry) + } } private sweep(): void { @@ -97,69 +120,102 @@ export class RuntimeTerminalIdlePolls { // slow `ps` must never delay another waiter's checks, and a waiter registered by a // resolve inside this sweep must wait for the next tick, as a fresh interval would. for (const entry of Array.from(this.entries)) { - void (entry.kind === 'leaf' ? this.tickLeaf(entry) : this.tickPty(entry)) + void this.tick(entry) } } - private async tickLeaf(entry: IdlePollEntry & { kind: 'leaf' }): Promise { - if (!this.entries.has(entry)) { - return + private sample(entry: IdlePollEntry): IdlePollSample { + const { handle } = entry.waiter + if (entry.kind === 'pty') { + // Why no re-read here: `ptysById` has a single create-once `set` site, so PTY + // records are mutated in place rather than swapped, and a capture stays live. + const { pty } = entry + const readWaitText = () => + buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) + return { + verdict: evaluateTuiIdle(ptyTuiIdleEvidence(this.deps, pty, readWaitText)), + ptyId: pty.ptyId, + ready: () => buildPtyTerminalWaitResult(handle, 'tui-idle', pty), + blocked: (reason) => buildPtyTerminalWaitBlockedResult(handle, 'tui-idle', pty, reason), + isQuiet: (lane) => + isQuietForQuiescence(pty.lastOutputAt, this.deps.quiescenceMs, lane, () => + this.deps.hasCommandPainted(pty.ptyId) + ) + } } - const { waiter } = entry // Why re-read: `syncWindowGraph` rebuilds `this.leaves` with fresh objects on every // renderer publish, so the record captured at registration stops advancing. Its - // `lastOutputAt` freezes, the quiescence gate below then reads an ever-growing - // elapsed time, and the waiter settles while the pane is in fact still streaming. + // `lastOutputAt` freezes, the quiescence gate then reads an ever-growing elapsed + // time, and the waiter settles while the pane is in fact still streaming. const leaf = this.deps.getLiveLeaf(entry.leaf) - const agent = this.deps.getPaneAgent(leaf.ptyId) + const readWaitText = () => + buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview) + const live = () => this.deps.getLiveLeaf(entry.leaf) + return { + verdict: evaluateTuiIdle(leafTuiIdleEvidence(this.deps, leaf, readWaitText)), + ptyId: leaf.ptyId, + ready: () => buildTerminalWaitResult(handle, 'tui-idle', live()), + blocked: (reason) => buildTerminalWaitBlockedResult(handle, 'tui-idle', live(), reason), + isQuiet: (lane) => + isQuietForQuiescence( + live().lastOutputAt, + this.deps.quiescenceMs, + lane, + () => !leaf.ptyId || this.deps.hasCommandPainted(leaf.ptyId) + ) + } + } + + private async tick(entry: IdlePollEntry): Promise { + if (!this.entries.has(entry)) { + return + } let startedForegroundPoll = false try { - const waitText = buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview) - const blockedReason = detectTerminalWaitBlockedReason(waitText) - if (blockedReason) { - this.stop(entry) - this.deps.resolve( - waiter, - buildTerminalWaitBlockedResult(waiter.handle, 'tui-idle', leaf, blockedReason) - ) + const sample = this.sample(entry) + const { verdict, ptyId } = sample + if (verdict.kind === 'blocked') { + this.settle(entry, sample.blocked(verdict.reason)) return } - if ( - isTuiIdleSatisfied({ - record: leaf, - rendererTitle: leaf.paneTitle ?? this.deps.getTabTitle(leaf.tabId), - readPositiveBodyEvidence: () => isKnownReadyPromptPreview(waitText), - readMuseReadyBodyEvidence: () => isMuseReadyPromptPreview(waitText), - agent, - firstPartyStatus: this.deps.getFirstPartyAgentStatus(leaf.ptyId), - quiescenceMs: this.deps.quiescenceMs - }) - ) { - this.stop(entry) - this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf)) + // Why strong ready outranks the screen: the detector is not scoped to a region, so dialog + // wording anywhere on a finished agent's screen would otherwise read as blocked. + if (verdict.kind === 'ready-strong') { + this.settle(entry, sample.ready()) return } - if ( - leaf.lastAgentStatus === null && - quietForegroundProcessProvesTuiIdle(agent) && - leaf.ptyId && - !entry.foregroundPollInFlight - ) { - const foregroundRead = this.deps.getForegroundProcess(leaf.ptyId) + // Why no screen read while working: its output can quote dialog wording (a diff of this + // detector), and the dialogs only the screen shows are start-up ones, painted before any title. + if (verdict.kind === 'working' || entry.screenReadInFlight) { + return + } + const screenRead = ptyId ? this.readScreenBlockedReason(entry, ptyId) : null + // Why await only a real read: a pane with no screen model keeps its tick synchronous. + const screenBlockedReason = screenRead ? await screenRead : null + if (!this.entries.has(entry)) { + return + } + if (screenBlockedReason) { + this.settle(entry, sample.blocked(screenBlockedReason)) + return + } + if (verdict.kind === 'ready-weak') { + this.settle(entry, sample.ready()) + return + } + const lane = verdict.quietForeground + // Why quiet before the read too: a streaming or not-yet-painted pane cannot settle, so it + // must not pay a process inspection every tick for a whole turn. + if (lane !== 'closed' && ptyId && !entry.foregroundPollInFlight && sample.isQuiet(lane)) { + const foregroundRead = this.deps.getForegroundProcess(ptyId) if (!foregroundRead) { return } entry.foregroundPollInFlight = true startedForegroundPoll = true const foreground = await foregroundRead - const live = this.deps.getLiveLeaf(entry.leaf) - if ( - foreground && - !isShellProcess(foreground) && - isQuietForQuiescence(live.lastOutputAt, this.deps.quiescenceMs) - ) { - this.stop(entry) - this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', live)) + if (foreground && !isShellProcess(foreground) && sample.isQuiet(lane)) { + this.settle(entry, sample.ready()) } } } catch { @@ -171,70 +227,27 @@ export class RuntimeTerminalIdlePolls { } } - private async tickPty(entry: IdlePollEntry & { kind: 'pty' }): Promise { - if (!this.entries.has(entry)) { - return - } - const { waiter, pty } = entry - // Why no re-read here: `ptysById` has a single create-once `set` site, so PTY - // records are mutated in place rather than swapped, and a capture stays live. - const agent = this.deps.getPaneAgent(pty.ptyId) - let startedForegroundPoll = false - try { - const waitText = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) - const blockedReason = detectTerminalWaitBlockedReason(waitText) - if (blockedReason) { - this.stop(entry) - this.deps.resolve( - waiter, - buildPtyTerminalWaitBlockedResult(waiter.handle, 'tui-idle', pty, blockedReason) - ) - return - } - if ( - isTuiIdleSatisfied({ - record: pty, - readPositiveBodyEvidence: () => - this.deps.getAdoptedPtyIdleStatus(pty) === 'idle' || - isKnownReadyPromptPreview(waitText), - readMuseReadyBodyEvidence: () => isMuseReadyPromptPreview(waitText), - agent, - firstPartyStatus: this.deps.getFirstPartyAgentStatus(pty.ptyId), - quiescenceMs: this.deps.quiescenceMs - }) - ) { - this.stop(entry) - this.deps.resolve(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty)) - return - } - if ( - pty.lastAgentStatus === null && - quietForegroundProcessProvesTuiIdle(agent) && - !entry.foregroundPollInFlight - ) { - const foregroundRead = this.deps.getForegroundProcess(pty.ptyId) - if (!foregroundRead) { - return - } - entry.foregroundPollInFlight = true - startedForegroundPoll = true - const foreground = await foregroundRead - if ( - foreground && - !isShellProcess(foreground) && - isQuietForQuiescence(pty.lastOutputAt, this.deps.quiescenceMs) - ) { - this.stop(entry) - this.deps.resolve(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty)) - } - } - } catch { - // Transient process inspection errors do not retire the waiter. - } finally { - if (startedForegroundPoll) { - entry.foregroundPollInFlight = false - } + /** Why the screen too: a dialog that parks the cursor above its own options (Claude's + * workspace trust) loses those rows from the line tail; the rendered screen still has them. */ + private readScreenBlockedReason( + entry: IdlePollEntry, + ptyId: string + ): Promise | null { + const screenRead = this.deps.readVisibleScreen(ptyId) + if (!screenRead) { + return null } + entry.screenReadInFlight = true + return screenRead + .then((screen) => (screen ? detectTerminalWaitBlockedReason(screen) : null)) + .finally(() => { + entry.screenReadInFlight = false + }) + } + + private settle(entry: IdlePollEntry, result: RuntimeTerminalWait): void { + this.stop(entry) + this.deps.resolve(entry.waiter, result) } private stop(entry: IdlePollEntry): void { diff --git a/src/main/runtime/runtime-terminal-orphan-adoption.ts b/src/main/runtime/runtime-terminal-orphan-adoption.ts index 59fbf76b857..51e56d0b508 100644 --- a/src/main/runtime/runtime-terminal-orphan-adoption.ts +++ b/src/main/runtime/runtime-terminal-orphan-adoption.ts @@ -5,6 +5,7 @@ import type { RuntimeTerminalOrphanAdoptionResult } from '../../shared/runtime-types' import { makePaneKey } from '../../shared/stable-pane-id' +import { hasClosedTerminalTabRecord } from '../../shared/closed-terminal-tab-tombstones' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { terminalOrphanExecutionOwnersEqual } from './terminal-orphan-owner' import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types' @@ -13,7 +14,7 @@ import { buildRuntimeTerminalOrphanSession } from './runtime-terminal-orphan-ses import { validateRuntimeTerminalOrphanTopology } from './runtime-terminal-orphan-topology-validation' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity' -import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from './workspace-session-failed-write-rollback' +import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from '../persistence/restoring-sessions/workspace-session-write-rollback' type RuntimeTerminalOrphanAdoptionPorts = { getPty: (handle: string) => RuntimePtyWorktreeRecord | null @@ -185,7 +186,11 @@ export async function adoptRuntimeTerminalOrphansFromInventory(args: { ) { throw new Error('terminal_orphan_surface_occupied') } - if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { + // Why the close record too: it outlives a host restart, which the client's retirement proofs do not. + if ( + session.terminalSurfaceTombstonesByPaneKey?.[paneKey] || + hasClosedTerminalTabRecord(session.closedTerminalTabTombstonesByTabId, claim.tabId) + ) { throw new Error('terminal_orphan_surface_retired') } for (const snapshot of ports.getMobileSnapshots()) { diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index 82e5124bf40..d030b896da9 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -11,6 +11,7 @@ import type { HeadlessEmulator } from '../daemon/headless-emulator' import type { PtyProviderBufferSnapshot } from '../providers/types' import type { RetainedTailRedrawCursor } from './terminal-tail-redraw-buffer' import type { TerminalTailWaitState } from './terminal-wait-tail-state' +import type { TerminalCommandPaint } from './terminal-command-paint' import type { PtyShellOwnershipMirror } from './pty-shell-ownership-mirror' import type { TerminalExitCause } from '../../shared/terminal-exit-cause' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' @@ -87,6 +88,8 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & { title: string | null titleUpdatedAt: number | null lastOutputAt: number | null + /** See terminal-command-paint.ts; absent until the pane's first output, and again after a gap or a new process. */ + commandPaint?: TerminalCommandPaint } export type RuntimePtyTabCloseAuthority = { diff --git a/src/main/runtime/runtime-terminal-wait.ts b/src/main/runtime/runtime-terminal-wait.ts index 23d112c8e9f..240f5508581 100644 --- a/src/main/runtime/runtime-terminal-wait.ts +++ b/src/main/runtime/runtime-terminal-wait.ts @@ -3,11 +3,6 @@ import type { RuntimeTerminalWaitCondition } from '../../shared/runtime-types' import { hasAntigravityTerminalHeader } from './antigravity-terminal-readiness' -import { - detectTerminalWaitBlockedReason, - isKnownReadyPromptPreview, - isMuseReadyPromptPreview -} from './terminal-wait-detection' import { buildPtyTerminalWaitBlockedResult, buildPtyTerminalWaitResult, @@ -16,23 +11,23 @@ import { getTerminalState } from './terminal-wait-results' import { buildTerminalWaitText } from './terminal-wait-tail-state' -import { isTuiIdleSatisfied, type FirstPartyAgentStatus } from './tui-idle-evidence' +import { + evaluateTuiIdle, + leafTuiIdleEvidence, + ptyTuiIdleEvidence, + type TuiIdleEvidenceSource, + type TuiIdleVerdict +} from './tui-idle-evidence' import type { TuiAgent } from '../../shared/tui-agent' import type { TerminalWaiter } from './runtime-terminal-contracts' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' -import type { AgentStatus } from '../../shared/agent-detection' import type { RuntimeTerminalIdlePolls } from './runtime-terminal-idle-polls' import type { RuntimeTerminalWaiterRegistry } from './runtime-terminal-waiter-registry' -type RuntimeTerminalWaitDependencies = { +type RuntimeTerminalWaitDependencies = TuiIdleEvidenceSource & { defaultTimeoutMs: number getLivePty(handle: string): { pty: RuntimePtyWorktreeRecord } | null getLiveLeaf(handle: string): { leaf: RuntimeLeafRecord } - getAdoptedPtyIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null - getTabTitle(tabId: string): string | null - quiescenceMs: number - getPaneAgent(ptyId: string | null | undefined): TuiAgent | null - getFirstPartyAgentStatus(ptyId: string | null | undefined): FirstPartyAgentStatus startVisibleReadProbe( waiter: TerminalWaiter, waiterTimeoutMs: number, @@ -49,28 +44,12 @@ export class RuntimeTerminalWait { /** Why one helper per record kind: every satisfaction site must rank the same way, * or the immediate check and the poll disagree about the same pane. */ - private ptySatisfied(pty: RuntimePtyWorktreeRecord, waitText: string): boolean { - return isTuiIdleSatisfied({ - record: pty, - readPositiveBodyEvidence: () => - this.deps.getAdoptedPtyIdleStatus(pty) === 'idle' || isKnownReadyPromptPreview(waitText), - readMuseReadyBodyEvidence: () => isMuseReadyPromptPreview(waitText), - agent: this.deps.getPaneAgent(pty.ptyId), - firstPartyStatus: this.deps.getFirstPartyAgentStatus(pty.ptyId), - quiescenceMs: this.deps.quiescenceMs - }) + private evaluatePty(pty: RuntimePtyWorktreeRecord, waitText: string): TuiIdleVerdict { + return evaluateTuiIdle(ptyTuiIdleEvidence(this.deps, pty, () => waitText)) } - private leafSatisfied(leaf: RuntimeLeafRecord, waitText: string): boolean { - return isTuiIdleSatisfied({ - record: leaf, - rendererTitle: leaf.paneTitle ?? this.deps.getTabTitle(leaf.tabId), - readPositiveBodyEvidence: () => isKnownReadyPromptPreview(waitText), - readMuseReadyBodyEvidence: () => isMuseReadyPromptPreview(waitText), - agent: this.deps.getPaneAgent(leaf.ptyId), - firstPartyStatus: this.deps.getFirstPartyAgentStatus(leaf.ptyId), - quiescenceMs: this.deps.quiescenceMs - }) + private evaluateLeaf(leaf: RuntimeLeafRecord, waitText: string): TuiIdleVerdict { + return evaluateTuiIdle(leafTuiIdleEvidence(this.deps, leaf, () => waitText)) } async wait( @@ -92,11 +71,13 @@ export class RuntimeTerminalWait { pty.pty.tailPartialLine, pty.pty.preview ) - const ptyBlockedReason = detectTerminalWaitBlockedReason(ptyWaitText) - if (condition === 'tui-idle' && ptyBlockedReason) { - return buildPtyTerminalWaitBlockedResult(handle, condition, pty.pty, ptyBlockedReason) + // Why strong verdicts only, here and at every other synchronous site: weak evidence + // cannot see a dialog the tail lost, so it settles only on the poll, after a screen read. + const ptyVerdict = condition === 'tui-idle' ? this.evaluatePty(pty.pty, ptyWaitText) : null + if (ptyVerdict?.kind === 'blocked') { + return buildPtyTerminalWaitBlockedResult(handle, condition, pty.pty, ptyVerdict.reason) } - if (condition === 'tui-idle' && this.ptySatisfied(pty.pty, ptyWaitText)) { + if (ptyVerdict?.kind === 'ready-strong') { return buildPtyTerminalWaitResult(handle, condition, pty.pty) } return await new Promise((resolve, reject) => { @@ -138,16 +119,16 @@ export class RuntimeTerminalWait { live.pty.tailPartialLine, live.pty.preview ) - const blockedReason = detectTerminalWaitBlockedReason(livePtyWaitText) - if (blockedReason) { + const verdict = this.evaluatePty(live.pty, livePtyWaitText) + if (verdict.kind === 'blocked') { this.waiters.resolve( waiter, - buildPtyTerminalWaitBlockedResult(handle, condition, live.pty, blockedReason) + buildPtyTerminalWaitBlockedResult(handle, condition, live.pty, verdict.reason) ) - } else if (this.ptySatisfied(live.pty, livePtyWaitText)) { + } else if (verdict.kind === 'ready-strong') { this.waiters.resolve(waiter, buildPtyTerminalWaitResult(handle, condition, live.pty)) } else { - this.polls.startPty(waiter, live.pty) + this.polls.startPty(waiter, live.pty, verdict) const paneAgent = this.deps.getPaneAgent(live.pty.ptyId) if ( // AGY can retain a stale working/blocked status after a trust dialog was @@ -173,17 +154,15 @@ export class RuntimeTerminalWait { } const leafWaitText = buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview) - const leafBlockedReason = detectTerminalWaitBlockedReason(leafWaitText) - if (condition === 'tui-idle' && leafBlockedReason) { - return buildTerminalWaitBlockedResult(handle, condition, leaf, leafBlockedReason) + const leafVerdict = condition === 'tui-idle' ? this.evaluateLeaf(leaf, leafWaitText) : null + if (leafVerdict?.kind === 'blocked') { + return buildTerminalWaitBlockedResult(handle, condition, leaf, leafVerdict.reason) } - // Why: if the agent already transitioned to idle (or permission) before the - // waiter was registered, resolve immediately. This uses the same OSC title - // detection that powers the renderer's "Task complete" notifications. - // Why: only 'idle' satisfies tui-idle, not 'permission'. Permission means the + // Why: if the agent already announced rest before the waiter was registered, resolve + // immediately. Only 'idle' satisfies tui-idle, not 'permission'. Permission means the // agent is blocked on user approval, not finished with its task. - if (condition === 'tui-idle' && this.leafSatisfied(leaf, leafWaitText)) { + if (leafVerdict?.kind === 'ready-strong') { return buildTerminalWaitResult(handle, condition, leaf) } @@ -235,13 +214,13 @@ export class RuntimeTerminalWait { live.leaf.tailPartialLine, live.leaf.preview ) - const blockedReason = detectTerminalWaitBlockedReason(liveLeafWaitText) - if (blockedReason) { + const verdict = this.evaluateLeaf(live.leaf, liveLeafWaitText) + if (verdict.kind === 'blocked') { this.waiters.resolve( waiter, - buildTerminalWaitBlockedResult(handle, condition, live.leaf, blockedReason) + buildTerminalWaitBlockedResult(handle, condition, live.leaf, verdict.reason) ) - } else if (this.leafSatisfied(live.leaf, liveLeafWaitText)) { + } else if (verdict.kind === 'ready-strong') { // Why: don't clear lastAgentStatus here. It's a factual record of the // last detected OSC state, not a one-shot signal. Clearing it causes // subsequent tui-idle waiters to hang even though the agent is idle — @@ -251,7 +230,7 @@ export class RuntimeTerminalWait { // Why: renderer-synced previews can show a known ready prompt even // while the last OSC title is still "working"; keep polling the // preview/title until the waiter resolves or hits its timeout. - this.polls.startLeaf(waiter, live.leaf) + this.polls.startLeaf(waiter, live.leaf, verdict) const paneAgent = this.deps.getPaneAgent(live.leaf.ptyId) if ( (paneAgent === 'antigravity' || diff --git a/src/main/runtime/runtime-terminal-writer.ts b/src/main/runtime/runtime-terminal-writer.ts index 7ff6ba4a7f3..10e94f3747f 100644 --- a/src/main/runtime/runtime-terminal-writer.ts +++ b/src/main/runtime/runtime-terminal-writer.ts @@ -1,8 +1,10 @@ import { resolveAgentPromptSubmitDelayForAgent } from '../../shared/agent-prompt-injection' import type { TuiAgent } from '../../shared/tui-agent' import { iterateTerminalInputChunks } from '../../shared/terminal-input' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' export type RuntimeTerminalWriteOptions = { + inputKind: TerminalInputKind signal?: AbortSignal beforeWrite?: (ptyId: string) => void | Promise reserveWrite?: (ptyId: string) => void @@ -12,7 +14,7 @@ export type RuntimeTerminalWriteOptions = { export class RuntimeTerminalWriter { constructor( - private readonly write: (ptyId: string, data: string) => boolean, + private readonly write: (ptyId: string, data: string, inputKind: TerminalInputKind) => boolean, private readonly getWriteHostPlatform: (ptyId: string) => NodeJS.Platform = () => process.platform, private readonly getAgent: (ptyId: string) => TuiAgent | null = () => null @@ -22,7 +24,7 @@ export class RuntimeTerminalWriter { ptyId: string, action: { text?: string; enter?: boolean; interrupt?: boolean }, payload: string, - options: RuntimeTerminalWriteOptions = {} + options: RuntimeTerminalWriteOptions ): Promise { // Why: direct terminal.send can carry paste-sized text from RPC/mobile // clients; chunk text before PTY/ConPTY while preserving suffix separation. @@ -54,7 +56,7 @@ export class RuntimeTerminalWriter { throw error } options.reserveWrite?.(ptyId) - if (!this.write(ptyId, suffix)) { + if (!this.write(ptyId, suffix, options.inputKind)) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') } await options.afterWrite?.(ptyId) @@ -65,7 +67,7 @@ export class RuntimeTerminalWriter { } await options.beforeWrite?.(ptyId) options.reserveWrite?.(ptyId) - if (!this.write(ptyId, payload)) { + if (!this.write(ptyId, payload, options.inputKind)) { throw new Error('terminal_not_writable') } await options.afterWrite?.(ptyId) @@ -74,14 +76,14 @@ export class RuntimeTerminalWriter { async writeChunks( ptyId: string, text: string, - options: RuntimeTerminalWriteOptions = {} + options: RuntimeTerminalWriteOptions ): Promise { const chunks = iterateTerminalInputChunks(text) let chunk = chunks.next() while (!chunk.done) { await options.beforeWrite?.(ptyId) options.reserveWrite?.(ptyId) - if (!this.write(ptyId, chunk.value)) { + if (!this.write(ptyId, chunk.value, options.inputKind)) { throw new Error('terminal_not_writable') } await options.afterWrite?.(ptyId) diff --git a/src/main/runtime/runtime-workspace-session-controller.ts b/src/main/runtime/runtime-workspace-session-controller.ts index c7472b914d2..e45425b8dd3 100644 --- a/src/main/runtime/runtime-workspace-session-controller.ts +++ b/src/main/runtime/runtime-workspace-session-controller.ts @@ -107,6 +107,15 @@ export class RuntimeWorkspaceSessionController { return hostId ? (this.deps.getStore()?.getWorkspaceSession?.(hostId) ?? null) : null } + /** The session only when the worktree's own host partition owns it, not a rotated-owner fallback. */ + getOwnPartition(worktreeId: string): WorkspaceSessionState | null { + const store = this.deps.getStore() + const hostId = store ? this.getPreferredHostId(worktreeId, store) : null + return hostId && hostId === this.tryGetHostId(worktreeId) + ? (store?.getWorkspaceSession?.(hostId) ?? null) + : null + } + set(worktreeId: string, session: WorkspaceSessionState): void { this.deps.getStore()?.setWorkspaceSession?.(session, this.getHostId(worktreeId)) } diff --git a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts new file mode 100644 index 00000000000..f4baa90da20 --- /dev/null +++ b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts @@ -0,0 +1,225 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { agentSessionFailureFact } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../shared/agent-session-wire' +import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types' +import { AgentHookServer, _internals } from '../agent-hooks/server' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' +import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' +import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' +import { indexedStatusFeedSession } from '../native-chat/agent-session-wire/structured-agent-session-status-feed-test-session' +import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows' +import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) +})) + +// A request that failed reads as failed on every surface the host feeds: the journal's verdict +// travels the real feed, the status-store ingest and `worktree ps`, never just the projection. +const SESSION = 'verdict-session' +const WORKSPACE_ID = 'workspace-1' +const SUBJECT = makeStructuredAgentStatusSubject( + { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKSPACE_ID, + workspaceKind: 'git-worktree' + }, + SESSION +) +const TURN_IDENTITY = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-1', + ordinal: 0 +} as const + +let root: string +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + _internals.resetCachesForTests() + root = await mkdtemp(join(tmpdir(), 'orca-verdict-rows-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +async function openJournal(): Promise { + return journals.open({ + identity: { + sessionId: SESSION, + workspaceId: WORKSPACE_ID, + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: join(root, SESSION) + }) +} + +/** What the host's real feed publishes for this journal. */ +function publishedSummary(journal: AgentSessionJournal): AgentSessionStatusSummary { + const session = indexedStatusFeedSession({ journal }) + const feed = new StructuredAgentSessionStatusFeed({ + sessions: new Map([[SESSION, session]]), + getRecord: () => agentSessionRecordFixture(), + now: () => 1_000 + }) + const events: AgentSessionStatusEvent[] = [] + feed.subscribe({ id: 'list', emit: (event) => events.push(event) }) + const snapshot = events.find((event) => event.type === 'snapshot') + const summary = snapshot?.type === 'snapshot' ? snapshot.sessions[0] : undefined + if (!summary) { + throw new Error('the feed published no session') + } + return summary +} + +function ingest(summary: AgentSessionStatusSummary) { + const store = new AgentHookServer() + store.ingestStructuredStatus(summary, SUBJECT) + const hookSnapshots = store.getStatusSnapshot() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: attaching agent rows reads and writes only `worktreeId`, `status`, `hasHostSidebarActivity` and `agents`. + const row = { + worktreeId: WORKSPACE_ID, + status: 'inactive', + hasHostSidebarActivity: false, + agents: [] + } as unknown as RuntimeWorktreePsSummary + attachRuntimeWorktreeAgentRows({ + summaries: new Map([[WORKSPACE_ID, row]]), + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `getSummary` below resolves every row by id, so the path index is never read. + pathIndex: { byPath: new Map(), byRealPath: new Map() } as never, + missingWorktreeIds: new Set(), + workingTerminalEvidenceByWorktreeId: new Map(), + rowSources: collectRuntimeWorktreeAgentSources({ + mirroredWorktreeIdByTabId: new Map(), + connectedPtyEvidence: { + tabIds: new Set(), + paneKeys: new Set(), + ptyIdByTerminalHandle: new Map() + }, + hookSnapshots + }), + orchestrationByPaneKey: null, + getSummary: (map, _p, _m, id) => map.get(id) ?? null + }) + return { status: hookSnapshots[0], ps: row.agents[0] } +} + +describe('a request that failed reads as failed through the feed, the ingest and worktree ps', () => { + it('reads a chat whose only send the agent start refused as failed, not interrupted', async () => { + const journal = await openJournal() + await journal.appendSubmission({ + clientMessageId: 'first', + payloadFingerprint: 'fp', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + fence: 1, + handoverRecorded: true + }) + await journal.rejectQueuedSubmissions( + 1, + agentSessionFailureWords(agentSessionFailureFact('notSignedIn'), { + surface: 'rejection', + agentName: 'Claude' + }) + ) + + const summary = publishedSummary(journal) + expect(summary).toMatchObject({ status: 'idle', turnOutcome: 'failure', latestPrompt: 'hello' }) + const { status, ps } = ingest(summary) + expect(status).toMatchObject({ + state: 'done', + mainAgent: { state: 'done', outcome: 'failure' } + }) + expect(status?.interrupted).not.toBe(true) + expect(ps).toMatchObject({ + state: 'done', + mainAgent: { state: 'done', outcome: 'failure' }, + interrupted: false + }) + }) + + it('reads a cancelled structured turn as interrupted for readers that predate the verdict', async () => { + const journal = await openJournal() + await journal.appendItem( + TURN_IDENTITY, + { + kind: 'turn', + turnId: 'turn-1', + state: 'interrupted', + outcome: 'cancellation', + completedAt: 5 + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + + const { status, ps } = ingest(publishedSummary(journal)) + expect(status).toMatchObject({ state: 'done', interrupted: true }) + expect(ps).toMatchObject({ + mainAgent: { state: 'done', outcome: 'cancellation' }, + interrupted: true + }) + }) + + it('publishes a main agent that failed while its subagent runs, on the row that still works', async () => { + const journal = await openJournal() + await journal.appendItem( + TURN_IDENTITY, + { kind: 'turn', turnId: 'turn-1', state: 'completed', outcome: 'failure', completedAt: 5 }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const summary: AgentSessionStatusSummary = { + ...publishedSummary(journal), + backgroundTasks: [{ id: 'child-1', kind: 'agent', state: 'working' }] + } + + const { status, ps } = ingest(summary) + expect(status).toMatchObject({ + state: 'working', + mainAgent: { state: 'done', outcome: 'failure' } + }) + // The row carries the main agent's own clock, which dates the failure apart from the working row. + expect(ps).toMatchObject({ + state: 'working', + mainAgent: { + state: 'done', + outcome: 'failure', + stateStartedAt: status?.mainAgent?.stateStartedAt + }, + interrupted: false + }) + }) + + it('lists nothing for a chat whose only send the user withdrew', async () => { + const journal = await openJournal() + await journal.appendSubmission({ + clientMessageId: 'first', + payloadFingerprint: 'fp', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + fence: 1, + handoverRecorded: true + }) + await journal.rejectQueuedSubmissions( + 1, + agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + ) + + expect(publishedSummary(journal)).toMatchObject({ status: null }) + expect(ingest(publishedSummary(journal)).ps).toBeUndefined() + }) +}) diff --git a/src/main/runtime/runtime-worktree-agent-rows.ts b/src/main/runtime/runtime-worktree-agent-rows.ts index 20c17f9b01a..310b0f6bc52 100644 --- a/src/main/runtime/runtime-worktree-agent-rows.ts +++ b/src/main/runtime/runtime-worktree-agent-rows.ts @@ -60,6 +60,7 @@ export function attachRuntimeWorktreeAgentRows(args: { toolName: source.toolName, toolInput: source.toolInput, interrupted: source.interrupted, + ...(source.mainAgent ? { mainAgent: source.mainAgent } : {}), stateStartedAt: source.stateStartedAt, updatedAt: source.updatedAt, ...(source.structuredHost === 'owned' ? { structuredHostOwned: true as const } : {}) diff --git a/src/main/runtime/runtime-worktree-agent-source.ts b/src/main/runtime/runtime-worktree-agent-source.ts index 984f20e0955..fd98d7914b1 100644 --- a/src/main/runtime/runtime-worktree-agent-source.ts +++ b/src/main/runtime/runtime-worktree-agent-source.ts @@ -1,5 +1,6 @@ import type { StructuredHostStatus } from '../../shared/agent-hook-listener/listener-event' import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' +import type { AgentMainAgentStatus } from '../../shared/main-agent-status' export type RuntimeWorktreeAgentSource = { paneKey: string @@ -15,6 +16,7 @@ export type RuntimeWorktreeAgentSource = { toolName: string | null toolInput: string | null interrupted: boolean + mainAgent?: AgentMainAgentStatus stateStartedAt: number updatedAt: number /** Projected by the structured session host; `owned` rows stay fresh past the staleness window. */ diff --git a/src/main/runtime/runtime-worktree-agent-startup.test.ts b/src/main/runtime/runtime-worktree-agent-startup.test.ts index e901555fa07..5fa98186449 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.test.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.test.ts @@ -1,22 +1,12 @@ import { describe, expect, it, vi } from 'vitest' import type { Repo } from '../../shared/repo-types' +import { tuiAgentToAgentKind } from '../../shared/agent-kind' const mocks = vi.hoisted(() => ({ - markAntigravityWorkspaceTrusted: vi.fn(), - markCodexProjectTrusted: vi.fn(), - markCopilotFolderTrusted: vi.fn(), - markCursorWorkspaceTrusted: vi.fn(), detectRemoteAgents: vi.fn(), detectInstalledAgentsWithShellPathHydration: vi.fn() })) -vi.mock('../agent-trust-presets', () => ({ - markAntigravityWorkspaceTrusted: mocks.markAntigravityWorkspaceTrusted, - markCodexProjectTrusted: mocks.markCodexProjectTrusted, - markCopilotFolderTrusted: mocks.markCopilotFolderTrusted, - markCursorWorkspaceTrusted: mocks.markCursorWorkspaceTrusted -})) - vi.mock('../preflight/agent-detection', () => ({ detectRemoteAgents: mocks.detectRemoteAgents, detectInstalledAgentsWithShellPathHydration: mocks.detectInstalledAgentsWithShellPathHydration @@ -24,8 +14,7 @@ vi.mock('../preflight/agent-detection', () => ({ import { buildWorktreeStartupForAgent, - buildWorktreeStartupForDraft, - markLocalWorktreeTrusted + buildWorktreeStartupForDraft } from './runtime-worktree-agent-startup' function makeRepo(fields: Partial): Repo { @@ -101,6 +90,22 @@ describe('buildWorktreeStartupForAgent host resolution', () => { request_kind: 'new' }) }) + + it('attributes a startup agent whose caller named no surface as unknown', () => { + const result = buildWorktreeStartupForAgent({ + repo: makeRepo({}), + settings, + agent: 'claude', + getLaunchPlatform: () => 'linux', + toSessionOptions: () => undefined + }) + + expect(result.startup.telemetry).toEqual({ + agent_kind: 'claude-code', + launch_source: 'unknown', + request_kind: 'new' + }) + }) }) describe('buildWorktreeStartupForDraft agent detection', () => { @@ -134,56 +139,32 @@ describe('buildWorktreeStartupForDraft agent detection', () => { expect(mocks.detectRemoteAgents).not.toHaveBeenCalled() expect(result?.agent).toBe('claude') }) -}) -describe('markLocalWorktreeTrusted', () => { - it('waits for the Codex trust write before resolving', async () => { - let finish!: () => void - mocks.markCodexProjectTrusted.mockReturnValue( - new Promise((resolve) => { - finish = resolve + // The host picks and launches this agent itself, so it is attributed like any other it builds, + // whether the draft rides the launch command or is pasted once the agent is up. + it.each([ + ['claude', 'cli', 'cli', false], + ['claude', undefined, 'unknown', false], + ['claude-agent-teams', 'orchestration', 'orchestration', true], + ['claude-agent-teams', undefined, 'unknown', true] + ] as const)( + 'attributes a %s draft launch named %s as %s', + async (agent, launchSource, expected, pasted) => { + const result = await buildWorktreeStartupForDraft({ + repo: makeRepo({}), + settings, + draft: 'ship it', + requestedAgent: agent, + getLaunchPlatform: () => 'linux', + ...(launchSource ? { launchSource } : {}) }) - ) - let settled = false - const marking = markLocalWorktreeTrusted('codex', '/workspace/app').then(() => { - settled = true - }) - await Promise.resolve() - expect(settled).toBe(false) - finish() - await marking - expect(mocks.markCodexProjectTrusted).toHaveBeenCalledWith('/workspace/app') - }) - - it('contains a rejected Codex trust write', async () => { - mocks.markCodexProjectTrusted.mockRejectedValueOnce(new Error('write failed')) - - await expect(markLocalWorktreeTrusted('codex', '/workspace/app')).resolves.toBeUndefined() - }) - - /** - * Why this test exists: Orca has two trust dispatch chains — the renderer's - * preflightAgentTrust (via the agentTrust:markTrusted IPC) and this main-process - * one, which is the only path `orchestration worker-start` takes. Adding - * `preflightTrust: 'antigravity'` to TUI_AGENT_CONFIG clears the `!preset` guard - * here but matched none of the cursor/copilot/codex branches, so every supervised - * agy worker still failed at agent_readiness with 'agent-trust-workspace' while - * the renderer-side unit tests passed. Verified live: with the branch added, the - * worktree is appended to ~/.gemini/antigravity-cli/settings.json and the dispatch - * reaches worker_done. - */ - it('writes the agy workspace trust artifact on the orchestration path', async () => { - await markLocalWorktreeTrusted('antigravity', '/workspace/app') - - expect(mocks.markAntigravityWorkspaceTrusted).toHaveBeenCalledWith('/workspace/app') - }) - - it('contains a throwing agy trust write', async () => { - mocks.markAntigravityWorkspaceTrusted.mockImplementationOnce(() => { - throw new Error('write failed') - }) - - await expect(markLocalWorktreeTrusted('antigravity', '/workspace/app')).resolves.toBeUndefined() - }) + expect(result?.draftPaste !== undefined).toBe(pasted) + expect(result?.startup.telemetry).toEqual({ + agent_kind: tuiAgentToAgentKind(agent), + launch_source: expected, + request_kind: 'new' + }) + } + ) }) diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index f71a81521a7..a65f2527357 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -1,31 +1,28 @@ +import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' import type { AgentLaunchPreferences } from '../../shared/agent-session-host-authority' -import { tuiAgentToAgentKind } from '../../shared/agent-kind' import type { Repo } from '../../shared/repo-types' import type { TuiAgent } from '../../shared/tui-agent' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' -import { launchSourceSchema } from '../../shared/telemetry-property-schemas' import { repoIsRemote } from '../../shared/agent-launch-remote' import { getRepoSshConnectionId } from '../../shared/execution-host' -import { isTuiAgent, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import { isTuiAgent } from '../../shared/tui-agent-config' import { isTuiAgentEnabled, pickTuiAgent } from '../../shared/tui-agent-selection' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup' -import { - markAntigravityWorkspaceTrusted, - markCodexProjectTrusted, - markCopilotFolderTrusted, - markCursorWorkspaceTrusted -} from '../agent-trust-presets' import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents } from '../preflight/agent-detection' -import { markRemoteAgentWorkspaceTrusted } from '../remote-agent-trust-presets' import type { RuntimeStore } from './runtime-store-contract' export type WorktreeStartupDraftPaste = { agent: TuiAgent; content: string } export type WorktreeStartupFollowup = { expectedProcess: string; prompt: string } +/** A fresh agent the host builds always carries its `agent_started` record; dropping it fails to compile. */ +type AttributedWorktreeStartupLaunch = WorktreeStartupLaunch & { + telemetry: NonNullable +} + type StartupEnvironment = { repo: Repo settings: ReturnType @@ -40,7 +37,7 @@ export async function buildWorktreeStartupForDraft( environment: StartupEnvironment & { draft: string; requestedAgent?: TuiAgent } ): Promise<{ agent: TuiAgent - startup: WorktreeStartupLaunch + startup: AttributedWorktreeStartupLaunch draftPaste?: WorktreeStartupDraftPaste } | null> { const content = environment.draft.trim() @@ -83,6 +80,7 @@ export async function buildWorktreeStartupForDraft( isRemote: repoIsRemote(repo), ...(environment.agentArgs !== undefined ? { agentArgs: environment.agentArgs } : {}) }) + const telemetry = agentStartedTelemetry(agent, environment.launchSource) const draftPlan = buildAgentDraftLaunchPlan({ ...launchArgs, draft: content }) if (draftPlan) { return { @@ -93,7 +91,8 @@ export async function buildWorktreeStartupForDraft( ...(draftPlan.startupCommandDelivery ? { startupCommandDelivery: draftPlan.startupCommandDelivery } : {}), - ...(draftPlan.env ? { env: draftPlan.env } : {}) + ...(draftPlan.env ? { env: draftPlan.env } : {}), + telemetry } } } @@ -113,7 +112,8 @@ export async function buildWorktreeStartupForDraft( ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), - ...(startupPlan.env ? { env: startupPlan.env } : {}) + ...(startupPlan.env ? { env: startupPlan.env } : {}), + telemetry }, draftPaste: { agent, content } } @@ -128,7 +128,11 @@ export function buildWorktreeStartupForAgent( preferences?: AgentLaunchPreferences ) => Parameters[0]['sessionOptions'] | undefined } -): { agent: TuiAgent; startup: WorktreeStartupLaunch; followup?: WorktreeStartupFollowup } { +): { + agent: TuiAgent + startup: AttributedWorktreeStartupLaunch + followup?: WorktreeStartupFollowup +} { const { agent, repo, settings } = environment if (!isTuiAgentEnabled(agent, settings.disabledTuiAgents)) { throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') @@ -148,7 +152,6 @@ export function buildWorktreeStartupForAgent( if (!startupPlan) { throw new Error(`Could not build launch command for ${agent}.`) } - const telemetry = agentLaunchTelemetry(agent, environment.launchSource) return { agent, startup: { @@ -158,7 +161,7 @@ export function buildWorktreeStartupForAgent( ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), ...(startupPlan.env ? { env: startupPlan.env } : {}), - ...(telemetry ? { telemetry } : {}) + telemetry: agentStartedTelemetry(agent, environment.launchSource) }, ...(startupPlan.followupPrompt ? { @@ -170,57 +173,3 @@ export function buildWorktreeStartupForAgent( : {}) } } - -function agentLaunchTelemetry( - agent: TuiAgent, - launchSource: string | undefined -): WorktreeStartupLaunch['telemetry'] | undefined { - const parsed = launchSourceSchema.safeParse(launchSource) - return parsed.success - ? { - agent_kind: tuiAgentToAgentKind(agent), - launch_source: parsed.data, - request_kind: 'new' - } - : undefined -} - -export async function markLocalWorktreeTrusted( - agent: TuiAgent, - workspacePath: string -): Promise { - const preset = TUI_AGENT_CONFIG[agent].preflightTrust - if (!preset) { - return - } - try { - if (preset === 'cursor') { - markCursorWorkspaceTrusted(workspacePath) - } else if (preset === 'copilot') { - markCopilotFolderTrusted(workspacePath) - } else if (preset === 'codex') { - // Why: the Codex write queues behind any in-flight hook grant, so the agent must not launch until it lands. - await markCodexProjectTrusted(workspacePath) - } else if (preset === 'antigravity') { - markAntigravityWorkspaceTrusted(workspacePath) - } - } catch { - // Best-effort: the user can still accept the agent trust prompt manually. - } -} - -export async function markRemoteWorktreeTrusted( - agent: TuiAgent, - connectionId: string, - workspacePath: string -): Promise { - const preset = TUI_AGENT_CONFIG[agent].preflightTrust - if (!preset) { - return - } - try { - await markRemoteAgentWorkspaceTrusted({ preset, connectionId, workspacePath }) - } catch { - // Best-effort: the user can still accept the remote agent trust prompt manually. - } -} diff --git a/src/main/runtime/runtime-worktree-pty-agent-sources.ts b/src/main/runtime/runtime-worktree-pty-agent-sources.ts index 058d378df11..e03f458134f 100644 --- a/src/main/runtime/runtime-worktree-pty-agent-sources.ts +++ b/src/main/runtime/runtime-worktree-pty-agent-sources.ts @@ -4,6 +4,7 @@ import { type ParsedAgentStatusPayload } from '../../shared/agent-status-types' import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id' +import { agentVerdictFields } from '../../shared/agent-main-agent-verdict' import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source' @@ -47,7 +48,8 @@ export function collectRuntimeWorktreePtyAgentSources(args: { lastAssistantMessage: entry.lastAssistantMessage ?? null, toolName: entry.toolName ?? null, toolInput: entry.toolInput ?? null, - interrupted: entry.interrupted ?? false, + interrupted: false, + ...agentVerdictFields(entry), stateStartedAt: entry.stateStartedAt, // A replay advances delivery order, not the age of the evidence shown by worktree.ps. updatedAt: entry.evidenceObservedAt ?? entry.receivedAt, diff --git a/src/main/runtime/runtime-worktree-startup-readiness.test.ts b/src/main/runtime/runtime-worktree-startup-readiness.test.ts new file mode 100644 index 00000000000..e96a072f7ed --- /dev/null +++ b/src/main/runtime/runtime-worktree-startup-readiness.test.ts @@ -0,0 +1,71 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + waitForWorktreeStartupDraft, + type WorktreeStartupReadinessHost +} from './runtime-worktree-startup-readiness' + +describe('fresh worker composer readiness', () => { + afterEach(() => vi.useRealTimers()) + + function fixture(replay?: string) { + let listener = (_data: string): void => {} + const unsubscribe = vi.fn() + const host: WorktreeStartupReadinessHost = { + getPtyId: () => 'pty-1', + getForegroundProcess: async () => 'zcode', + subscribeToData: (_ptyId, onData) => { + listener = onData + return unsubscribe + }, + readRecentOutput: () => replay, + write: vi.fn() + } + return { host, emit: (data: string) => listener(data), unsubscribe } + } + + it('accepts the captured composer while the banner continues repainting', async () => { + vi.useFakeTimers() + const h = fixture() + const pending = waitForWorktreeStartupDraft(h.host, 'term-1', 'zcode', { + timeoutMs: 45_000, + requireComposerMarker: true + }) + const data = readFileSync(join(__dirname, '__fixtures__', 'zcode-composer-ready.txt'), 'utf8') + for (let offset = 0; offset < data.length; offset += 4096) { + h.emit(data.slice(offset, offset + 4096)) + } + await expect(pending).resolves.toBe('pty-1') + expect(h.unsubscribe).toHaveBeenCalledOnce() + expect(h.host.write).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + + it('cleans up the deadline when the composer was already captured', async () => { + vi.useFakeTimers() + const h = fixture('\x1b[?1049h╭') + await expect( + waitForWorktreeStartupDraft(h.host, 'term-1', 'zcode', { + timeoutMs: 45_000, + requireComposerMarker: true + }) + ).resolves.toBe('pty-1') + expect(h.unsubscribe).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + }) + + it('does not accept shell decoration or a square startup dialog', async () => { + vi.useFakeTimers() + const h = fixture('╭ shell\n\x1b[?1049h\x1b[?2004h┌ Sign in ┐') + const pending = waitForWorktreeStartupDraft(h.host, 'term-1', 'zcode', { + timeoutMs: 45_000, + requireComposerMarker: true + }) + await vi.advanceTimersByTimeAsync(44_999) + expect(h.unsubscribe).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + await expect(pending).resolves.toBeNull() + expect(h.unsubscribe).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/runtime/runtime-worktree-startup-readiness.ts b/src/main/runtime/runtime-worktree-startup-readiness.ts index 77b4e1315db..779f16ec6a4 100644 --- a/src/main/runtime/runtime-worktree-startup-readiness.ts +++ b/src/main/runtime/runtime-worktree-startup-readiness.ts @@ -4,6 +4,7 @@ import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-sca import { resolveDraftPasteReadyTimeoutMs } from '../../shared/draft-paste-ready-timeout' import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import type { TuiAgent } from '../../shared/tui-agent' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' import type { WorktreeStartupDraftPaste, WorktreeStartupFollowup @@ -19,7 +20,7 @@ export type WorktreeStartupReadinessHost = { hasChildProcesses?: (ptyId: string) => Promise subscribeToData: (ptyId: string, listener: (data: string) => void) => () => void readRecentOutput: (ptyId: string) => string | undefined - write: (ptyId: string, data: string) => void + write: (ptyId: string, data: string, inputKind: TerminalInputKind) => void } export function pasteWorktreeStartupDraftWhenReady( @@ -33,7 +34,7 @@ export function pasteWorktreeStartupDraftWhenReady( console.warn('[worktree-create] agent did not become ready for draft paste') return } - host.write(ptyId, `${BRACKETED_PASTE_BEGIN}${draft.content}${BRACKETED_PASTE_END}`) + host.write(ptyId, `${BRACKETED_PASTE_BEGIN}${draft.content}${BRACKETED_PASTE_END}`, 'launch') }) .catch((error) => console.warn('[worktree-create] failed to paste startup draft:', error)) } @@ -49,7 +50,7 @@ export function sendWorktreeStartupFollowupWhenReady( console.warn('[worktree-create] agent did not become ready for follow-up prompt') return } - host.write(ptyId, `${followup.prompt}\r`) + host.write(ptyId, `${followup.prompt}\r`, 'launch') }) .catch((error) => console.warn('[worktree-create] failed to send startup follow-up prompt:', error) @@ -89,7 +90,8 @@ export async function waitForWorktreeStartupFollowup( export function waitForWorktreeStartupDraft( host: WorktreeStartupReadinessHost, handle: string, - agent: TuiAgent + agent: TuiAgent, + options: { timeoutMs?: number; requireComposerMarker?: boolean } = {} ): Promise { const ptyId = host.getPtyId(handle) if (!ptyId) { @@ -118,11 +120,14 @@ export function waitForWorktreeStartupDraft( resolve(value) } const observe = (data: string): void => { + if (settled) { + return + } const result = scanner.observe(data) if (result.ready) { return finish(ptyId) } - if (result.armQuietTimer) { + if (result.armQuietTimer && !options.requireComposerMarker) { if (quietTimer) { clearTimeout(quietTimer) } @@ -130,10 +135,13 @@ export function waitForWorktreeStartupDraft( } } unsubscribe = host.subscribeToData(ptyId, observe) + hardTimer = setTimeout( + () => finish(null), + options.timeoutMs ?? resolveDraftPasteReadyTimeoutMs(agent) + ) const replay = host.readRecentOutput(ptyId) if (replay) { observe(replay) } - hardTimer = setTimeout(() => finish(null), resolveDraftPasteReadyTimeoutMs(agent)) }) } diff --git a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts index b1e43a9025d..5b081d1e5df 100644 --- a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts +++ b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources' import { mkdtemp, rm } from 'node:fs/promises' @@ -71,7 +72,7 @@ async function awaitingApproval() { await journal.appendItem( { ...IDENTITY, ordinal: 1 }, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'rm the branch' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( { ...IDENTITY, ordinal: 2 }, @@ -82,14 +83,14 @@ async function awaitingApproval() { options: [{ id: 'allow', label: 'Allow' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const sessions = new Map([ [ SESSION, { journal, - hasProviderChild: true, + child: { phase: 'ready' as const, generation: 'child-1', fence: 1 }, params: { location: { executionHostId: 'local' as const, diff --git a/src/main/runtime/startup-dialog-blocked-signals.ts b/src/main/runtime/startup-dialog-blocked-signals.ts new file mode 100644 index 00000000000..e964205a9cc --- /dev/null +++ b/src/main/runtime/startup-dialog-blocked-signals.ts @@ -0,0 +1,76 @@ +import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' + +// Why rows, from each dialog's first `·` on: codex-terminal-readiness.ts takes a `·` after the +// startup header for the live chat's footer, so each dialog must be matched by the time that `·` +// lands. Why not headings: Codex 0.157+ paints them by cell diff over its startup screen, so the +// text copy can lose letters and spaces (`updat available`); these rows are fixed literals. +// Update: `Update available · 0.157.1 → 0.158.0`, then `enter continue · esc skip`. +const CODEX_UPDATE_ROW_RE = /available\s*·\s*\d+\.\d+|enter\s*continue\s*·\s*esc\s*skip/g +// Why not `esc back`: Codex's mid-session pickers end `enter confirm · esc back`. +const CODEX_HOOKS_REVIEW_KEY_ROW_RE = /enter\s*confirm\s*·(?!\s*esc\s*back)/g +// Why both verbs: the retired-model notice says `continue`, the new-model announcement `confirm`. +const CODEX_MODEL_MIGRATION_KEY_ROW_RE = /enter\/esc\s*(?:continue|confirm)\s*·/g + +function lastMatchIndex(text: string, row: RegExp): number { + let index = -1 + for (const match of text.matchAll(row)) { + index = match.index + } + return index +} + +// Why the last match, and the legacy wording too: an answered dialog stays in the text copy, and +// builds before 0.157 wrote `Press enter to continue/confirm`. +function findDialogIndex( + normalized: string, + legacyHeading: string, + legacyKeys: string, + row: RegExp +): number { + const rowIndex = lastMatchIndex(normalized, row) + const headingIndex = normalized.lastIndexOf(legacyHeading) + const legacyIndex = + headingIndex !== -1 && normalized.includes(legacyKeys, headingIndex) ? headingIndex : -1 + return Math.max(rowIndex, legacyIndex) +} + +// Why together: each startup dialog owns Enter before the chat exists, so a brief typed into one +// answers it (Codex's update dialog defaults to `Update now`). +export function findStartupDialogBlockedSignals( + normalized: string +): { reason: RuntimeTerminalWaitBlockedReason; index: number }[] { + const candidates: { reason: RuntimeTerminalWaitBlockedReason; index: number }[] = [] + const updateIndex = findDialogIndex( + normalized, + 'update available', + 'press enter to continue', + CODEX_UPDATE_ROW_RE + ) + if (updateIndex !== -1) { + candidates.push({ reason: 'agent-update-prompt', index: updateIndex }) + } + const cwdIndex = normalized.lastIndexOf('choose working directory to') + if (cwdIndex !== -1 && normalized.includes('press enter to continue', cwdIndex)) { + candidates.push({ reason: 'agent-cwd-prompt', index: cwdIndex }) + } + const modelMigrationIndex = findDialogIndex( + normalized, + 'codex just got an upgrade', + 'press enter to continue', + CODEX_MODEL_MIGRATION_KEY_ROW_RE + ) + if (modelMigrationIndex !== -1) { + candidates.push({ reason: 'codex-model-migration-prompt', index: modelMigrationIndex }) + } + const hooksIndex = findDialogIndex( + normalized, + 'hooks need review', + 'press enter to confirm', + CODEX_HOOKS_REVIEW_KEY_ROW_RE + ) + if (hooksIndex !== -1) { + // Why neutral: this matcher never inspects the agent -- 'hooks need review' is not Codex-only wording. + candidates.push({ reason: 'agent-hooks-review-prompt', index: hooksIndex }) + } + return candidates +} diff --git a/src/main/runtime/structured-agent-account-home.ts b/src/main/runtime/structured-agent-account-home.ts index 6190ad3bd00..060fbea5b1f 100644 --- a/src/main/runtime/structured-agent-account-home.ts +++ b/src/main/runtime/structured-agent-account-home.ts @@ -38,23 +38,15 @@ export type StructuredCodexAccountHomeDeps = { * null → system-home mapping below, so the two paths cannot drift. */ resolveLaunchHome: - | ((input: { - workspacePath: string - launchEnv: NodeJS.ProcessEnv - }) => string | null | Promise) + | ((input: { launchEnv: NodeJS.ProcessEnv }) => string | null | Promise) | null - /** Empty for a record-less read; only launch preparation consumes it. */ - workspacePath: string } export async function resolveStructuredCodexAccountHomePath( deps: StructuredCodexAccountHomeDeps ): Promise { // A create has no process yet, so the current selection is what it must follow. - const resolvedHome = await deps.resolveLaunchHome?.({ - workspacePath: deps.workspacePath, - launchEnv: deps.launchEnv - }) + const resolvedHome = await deps.resolveLaunchHome?.({ launchEnv: deps.launchEnv }) const configuredHome = deps.launchEnv.CODEX_HOME return ( resolvedHome?.trim() || diff --git a/src/main/runtime/structured-agent-session-close.test.ts b/src/main/runtime/structured-agent-session-close.test.ts index 531ca0aa129..948bb01cce2 100644 --- a/src/main/runtime/structured-agent-session-close.test.ts +++ b/src/main/runtime/structured-agent-session-close.test.ts @@ -19,6 +19,7 @@ vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', ( const { closeStructuredAgentSessionChild } = await import('./structured-agent-session-close') const SESSION = 'session-1' +const TAB_ID = 'tab-of-session-1' function record(sessionId: string): AgentSessionRecord { return { @@ -85,14 +86,18 @@ function installHost(options: HostOptions = {}) { } }) hostRef.current = { - deps: { store: { getRecord: (id: string) => (id === SESSION ? entry : null) } }, - hasSession: (sessionId: string) => held.has(sessionId), - getPersistedVisibleSessionTabIndex: () => { - if (options.indexThrows) { - throw new Error('visible tab index unreadable') + deps: { + store: { + getRecord: (id: string) => (id === SESSION ? entry : null), + getSessionTabId: (id: string) => { + if (options.indexThrows) { + throw new Error('visible tab index unreadable') + } + return visible.has(id) ? TAB_ID : null + } } - return { present: true, sessionIds: [...visible] } }, + hasSession: (sessionId: string) => held.has(sessionId), setSessionTabVisibility, close } @@ -133,7 +138,7 @@ describe('closeStructuredAgentSessionChild tab-visibility rollback', () => { expect(host.visible.has(SESSION)).toBe(true) expect(host.setSessionTabVisibility.mock.calls).toEqual([ [SESSION, false], - [SESSION, true] + [SESSION, true, TAB_ID] ]) }) @@ -147,7 +152,7 @@ describe('closeStructuredAgentSessionChild tab-visibility rollback', () => { expect(host.visible.has(SESSION)).toBe(true) expect(host.setSessionTabVisibility.mock.calls).toEqual([ [SESSION, false], - [SESSION, true] + [SESSION, true, TAB_ID] ]) }) diff --git a/src/main/runtime/structured-agent-session-close.ts b/src/main/runtime/structured-agent-session-close.ts index f65d1204db9..668b7f21890 100644 --- a/src/main/runtime/structured-agent-session-close.ts +++ b/src/main/runtime/structured-agent-session-close.ts @@ -3,8 +3,8 @@ * * Extracted from `stopStructuredWorker` so that orchestration settlement and worktree teardown * close a session the SAME way rather than one of them inventing a shorter version. Everything - * dispatch-shaped — dropping the hold, the redrive subscription and the parked mail — stays with - * the caller that has a dispatch; this is only the child. + * dispatch-shaped — dropping the redrive subscription, the registry entry and the parked mail — + * stays with the caller that has a dispatch; this is only the child. * * `host.close` returns void and keeps a failed close indexed for retry, so the only settlement * evidence is the observation AFTER it: a session the host no longer holds and whose lease is no @@ -15,7 +15,10 @@ import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wi import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import type { OrcaRuntimeService } from './orca-runtime' import { retireSettledStructuredWorkerTab } from './structured-agent-session-tab-retirement' -import { observeStructuredWorker } from './structured-worker-authority' +import { + observeStructuredWorker, + structuredSessionCloseSettled +} from './structured-worker-authority' export type StructuredAgentSessionCloseOutcome = { stopped: boolean @@ -32,8 +35,8 @@ export type StructuredAgentSessionCloseOptions = { /** * Runs after the close is issued and BEFORE the proof is read. * - * Not after: an unsettled close returns early, so a dispatch that released its hold there would - * keep the child un-evictable for the life of the app. Every settlement has to reach it. + * Not after: an unsettled close returns early, so a dispatch released there would keep its + * redrive subscription nudging a session no dispatch owns. Every settlement has to reach it. */ afterClose?: () => void /** @@ -60,11 +63,11 @@ export async function closeStructuredAgentSessionChild( reason: 'The structured agent-session host is not installed; no session was closed.' } } - // Read BEFORE the hide, so a rollback puts the tab back exactly as it was. Restoring - // unconditionally would publish a tab for a session that was already hidden — a worker started - // without a chat tab, or one the user had closed — which is a new side effect, not an undo. + // Read BEFORE the hide, so a rollback puts the tab back exactly as it was, under the same id. + // Restoring unconditionally would publish a tab for a session that was already hidden — a worker + // started without a chat tab, or one the user had closed — which is a new side effect, not an undo. const restoreTabIfCloseFails = - options.restoreTabOnUnprovenClose !== false && readPersistedTabVisibility(host, sessionId) + options.restoreTabOnUnprovenClose !== false ? readPersistedTabId(host, sessionId) : null // Set only once the close is actually issued: `setSessionTabVisibility` throwing first leaves a // running child, and a receipt that still said `closed_agent_terminal` for it would be the // close-that-never-happened this flag exists to rule out. @@ -86,8 +89,8 @@ export async function closeStructuredAgentSessionChild( } } options.afterClose?.() - const observation = observeStructuredWorker({ sessionId }) - if (observation.status !== 'exited') { + if (!structuredSessionCloseSettled(sessionId)) { + const observation = observeStructuredWorker({ sessionId }) await restorePersistedTabVisibility(host, sessionId, restoreTabIfCloseFails) return { stopped: false, @@ -101,13 +104,13 @@ export async function closeStructuredAgentSessionChild( return { stopped: true, closeAttempted: true } } -function readPersistedTabVisibility(host: StructuredAgentSessionHost, sessionId: string): boolean { +function readPersistedTabId(host: StructuredAgentSessionHost, sessionId: string): string | null { try { - return host.getPersistedVisibleSessionTabIndex?.().sessionIds.includes(sessionId) ?? false + return host.deps.store.getSessionTabId(sessionId) } catch { // Unreadable index: claim nothing. A rollback that cannot prove the tab was visible must not // publish one, for the same reason the read exists at all. - return false + return null } } @@ -135,13 +138,13 @@ function readPersistedTabVisibility(host: StructuredAgentSessionHost, sessionId: async function restorePersistedTabVisibility( host: StructuredAgentSessionHost, sessionId: string, - restoreTab: boolean + tabId: string | null ): Promise { - if (!restoreTab || observeStructuredWorker({ sessionId }).status === 'exited') { + if (tabId === null || structuredSessionCloseSettled(sessionId)) { return } try { - await host.setSessionTabVisibility?.(sessionId, true) + await host.setSessionTabVisibility?.(sessionId, true, tabId) } catch (error) { console.warn( `[structured-session-close] could not restore the chat tab for ${sessionId} after a failed close`, diff --git a/src/main/runtime/structured-agent-session-codex-compact-exit.test.ts b/src/main/runtime/structured-agent-session-codex-compact-exit.test.ts new file mode 100644 index 00000000000..ee3876301df --- /dev/null +++ b/src/main/runtime/structured-agent-session-codex-compact-exit.test.ts @@ -0,0 +1,154 @@ +// A Codex child that exits, or that Stop ends, while `/compact` runs, observed the way the shipping +// adapter observes it: the app-server connection's own exit, not a hand-fed compaction result. + +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../ipc/desktop-renderer-runtime-capabilities' +import { structuredAgentSessionCommandTurn } from '../native-chat/agent-session-wire/structured-agent-session-command-turn' +import { ensureStructuredAgentSessionHost } from './structured-agent-session-runtime' +import { + openStructuredCodexRpcHarness, + SESSION, + type FakeCodexConnection, + type StructuredCodexRpcHarness +} from './structured-codex-session-rpc-test-harness' + +let harness: StructuredCodexRpcHarness + +beforeEach(async () => { + // The desktop's own client: a send answers at acceptance, so one queued behind the command + // returns while the command still runs. + harness = await openStructuredCodexRpcHarness(DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES) +}) + +afterEach(async () => { + await harness.dispose() +}) + +async function snapshot() { + return (await ensureStructuredAgentSessionHost(harness.hostConfig())).journalSnapshot(SESSION) +} + +/** The connections that received `method`, once per call. */ +function calls(method: string): FakeCodexConnection[] { + return harness.codex.connections.flatMap((connection) => + connection.calls.filter((entry) => entry.method === method).map(() => connection) + ) +} + +/** The child serving the thread; a model-list probe opens connections of its own. */ +function threadChild(): FakeCodexConnection { + const child = harness.codex.connections.findLast((connection) => + connection.calls.some( + (entry) => entry.method === 'thread/start' || entry.method === 'thread/resume' + ) + ) + if (!child) { + throw new Error('no codex child opened the thread') + } + return child +} + +async function startCompact(fence: number): Promise { + const params = { + command: 'compact', + envelope: harness.envelope('agentSession.conversationCommand', { command: 'compact' }, fence) + } + await harness.ok('agentSession.conversationCommand', params) + return params.envelope.clientOperationId +} + +async function send(text: string): Promise { + const body = { kind: 'message' as const, role: 'user' as const, blocks: [{ type: 'text', text }] } + await harness.ok('agentSession.send', { + envelope: harness.envelope('agentSession.send', { body }, null), + body + }) +} + +function exitChild(): FakeCodexConnection { + const child = threadChild() + child.handlers.onExit?.(new Error('codex app-server exited')) + return child +} + +it('delivers what waited behind the command once its child dies mid-command, with one exit row', async () => { + const created = await harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) + const command = await startCompact(created.fence) + await vi.waitFor(() => expect(calls('thread/compact/start')).toHaveLength(1)) + await send('after the exit') + + const dead = exitChild() + + await vi.waitFor(() => expect(calls('turn/start')).toHaveLength(1)) + expect(calls('turn/start')[0]).not.toBe(dead) + const settled = await snapshot() + const turn = settled.items.find( + (item) => item.itemId === structuredAgentSessionCommandTurn(command).itemId + ) + expect(readAgentJournalTurn(turn?.body)?.state).toBe('interrupted') + expect( + settled.items.filter((item) => item.body.kind === 'status' && item.body.tone === 'error') + ).toHaveLength(1) +}) + +it('runs a later command after Stop named the one whose child died', async () => { + const created = await harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) + const command = await startCompact(created.fence) + await vi.waitFor(() => expect(calls('thread/compact/start')).toHaveLength(1)) + + const dead = exitChild() + await vi.waitFor(async () => { + const turn = (await snapshot()).items.find( + (item) => item.itemId === structuredAgentSessionCommandTurn(command).itemId + ) + expect(readAgentJournalTurn(turn?.body)?.state).toBe('interrupted') + }) + const { turnId } = structuredAgentSessionCommandTurn(command) + await harness.ok('agentSession.cancel', { + envelope: harness.envelope('agentSession.cancel', { turnId }, null), + turnId + }) + + // Nothing the dead command left behind holds the queue or refuses the next one. + const later = await startCompact(created.fence) + await vi.waitFor(() => expect(calls('thread/compact/start')).toHaveLength(2)) + expect(calls('thread/compact/start')[1]).not.toBe(dead) + const turn = (await snapshot()).items.find( + (item) => item.itemId === structuredAgentSessionCommandTurn(later).itemId + ) + expect(readAgentJournalTurn(turn?.body)?.state).toBe('running') +}) + +it('ends a command Codex has not opened a turn for by stopping its child, and the next send gets a fresh one', async () => { + const created = await harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) + const command = await startCompact(created.fence) + await vi.waitFor(() => expect(calls('thread/compact/start')).toHaveLength(1)) + const first = threadChild() + const { itemId, turnId } = structuredAgentSessionCommandTurn(command) + + // No `turn/started` yet, so there is no provider turn to interrupt. + await expect( + harness.ok('agentSession.cancel', { + envelope: harness.envelope('agentSession.cancel', { turnId }, null), + turnId + }) + ).resolves.toMatchObject({ cancelled: true }) + + expect(first.closed).toBe(true) + expect(first.calls.some((entry) => entry.method === 'turn/interrupt')).toBe(false) + const turn = (await snapshot()).items.find((item) => item.itemId === itemId) + expect(readAgentJournalTurn(turn?.body)?.state).toBe('interrupted') + await send('after the stop') + await vi.waitFor(() => expect(calls('turn/start')).toHaveLength(1)) + expect(calls('turn/start')[0]).not.toBe(first) +}) diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts new file mode 100644 index 00000000000..c86f7c85242 --- /dev/null +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -0,0 +1,324 @@ +// A Codex send the turn it went into never took: the Stop that interrupts that turn +// withdraws it, and nothing reads as working after. A Stop sent before Codex opens that +// turn waits for it to open, since Codex refuses an interrupt until then. Driven through the shipped host, +// journal and Codex adapter; only the Codex child is fake, keeping Codex 0.157's +// turn bookkeeping. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + openCodexAppServerConnection +} from '../codex/codex-app-server-connection' +import { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake' +import { settledWithin } from '../codex/codex-structured-dispatch-test-support' +import { CODEX_STOP_TURN_OPEN_WAIT_MS } from '../codex/codex-structured-prompt-ownership' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types' +import { classifyDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection' +import { owesStructuredAgentSessionWork } from '../../shared/structured-agent-session-owed-work' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { CHILD_EVICTION_TIMEOUT_MS } from '../native-chat/agent-session-wire/structured-agent-session-host-teardown' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +// A Stop must never reach for real processes on this machine under a made-up pid. +vi.mock('../codex/codex-structured-turn-processes', () => ({ + captureCodexTurnProcesses: async () => null, + terminateCodexTurnProcesses: async () => true +})) + +const CALLER = { callerKey: 'codex-turn-end-test' } +const MODEL = { + model: 'gpt-test', + displayName: 'GPT Test', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + isDefault: true +} + +let root: string +let host: StructuredAgentSessionHost +let fence: number +let handlers: CodexAppServerConnectionHandlers | undefined +let answers: number +let interrupts: number +let childCloses: number +let turns: ReturnType +let operations = 0 + +/** The durable ledger stamps its own clock and refuses an id far from it. */ +const operationId = (): string => `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}` + +function envelope(method: string, fields: Record) { + return { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + if (!sent.ok) { + throw new Error(JSON.stringify(sent.refusal)) + } + return sent.value.clientMessageId +} + +async function stop(turnId?: string): Promise { + const stopped = await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', turnId === undefined ? {} : { turnId }), + ...(turnId === undefined ? {} : { turnId }) + }) + if (!stopped.ok) { + throw new Error(JSON.stringify(stopped.refusal)) + } +} + +async function settled(): Promise<{ + submissions: readonly AgentJournalSubmission[] + owesWork: boolean +}> { + await host.flushStreamedEvents(SESSION) + const snapshot = await host.journalSnapshot(SESSION) + return { + submissions: snapshot.submissions, + // The shared rule every surface reads "working" from. + owesWork: owesStructuredAgentSessionWork(snapshot.items, snapshot.submissions, fence) + } +} + +function verdictOf(submissions: readonly AgentJournalSubmission[], clientMessageId: string) { + const submission = submissions.find((entry) => entry.clientMessageId === clientMessageId) + return submission?.dispatchState === 'rejected' + ? classifyDispatchRejection(submission).category + : submission?.dispatchState +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-codex-turn-end-')) + answers = 0 + interrupts = 0 + childCloses = 0 + turns = codexTurnLifecycleFake( + THREAD, + () => (method, params) => handlers?.onNotification?.(method, params) + ) + const openConnection: typeof openCodexAppServerConnection = async ( + _launch, + connectionHandlers = {} + ) => { + handlers = connectionHandlers + const connection: CodexAppServerConnection = { + pid: 4321, + closed: false, + request: async (method, params) => { + if (method === 'thread/start' || method === 'thread/resume') { + return { thread: { id: THREAD } } + } + if (method === 'model/list') { + return { data: [MODEL], nextCursor: null } + } + if (method === 'turn/start') { + answers += 1 + return turns.routes['turn/start']() + } + if (method === 'turn/interrupt') { + interrupts += 1 + return turns.routes['turn/interrupt'](params) + } + return {} + }, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => { + childCloses += 1 + return true + } + } + return connection + } + host = await ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveCodexCommand: () => 'codex', + resolveEnvironment: async () => ({ PATH: process.env.PATH }), + openCodexConnection: openConnection, + readProcessStartTime: async () => 1_700_000_000_000 + }) + const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) + attachParams.envelope.clientOperationId = operationId() + const attached = await host.attach(CALLER, attachParams) + if (!attached.ok) { + throw new Error(JSON.stringify(attached.refusal)) + } + fence = attached.value.fence +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + await rm(root, { recursive: true, force: true }) +}) + +describe('a Codex send its turn ended without taking it', () => { + it('is withdrawn by a Stop before any echo, and nothing reads as working after', async () => { + const sent = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + + await stop('turn-1') + + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, sent)).not.toBe('pending') + ) + const after = await settled() + expect(verdictOf(after.submissions, sent)).toBe('withdrawn') + expect(after.owesWork).toBe(false) + + // Codex echoing it late changes nothing: a settled answer stands. + await host.settleLateDispatch({ + sessionId: SESSION, + clientMessageId: sent, + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 0 } + }) + expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') + }) + + it('withdraws a follow-up Codex steered into the turn a Stop ends, with no working latch', async () => { + const opening = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(opening) + const followUp = await send('and check the tests') + // Steered: Codex answers with the running turn, and fires no second turn/started. + await vi.waitFor(() => expect(answers).toBe(2)) + + await stop('turn-1') + + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, followUp)).not.toBe('pending') + ) + const after = await settled() + expect(verdictOf(after.submissions, opening)).toBe('accepted') + expect(verdictOf(after.submissions, followUp)).toBe('withdrawn') + expect(after.owesWork).toBe(false) + }) +}) + +describe('a Stop sent after Codex answered a cold send, before it opened the turn', () => { + it('waits for the turn to open, then stops it and withdraws the send', async () => { + const sent = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + + const stopping = stop() + // Without the wait, it would reach Codex now, which would refuse it, and be done. + expect(await settledWithin(stopping, 1_000)).toBe('held') + expect(interrupts).toBe(0) + turns.start() + await stopping + + expect(interrupts).toBe(1) + expect(turns.turnId).toBeNull() + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') + ) + expect((await settled()).owesWork).toBe(false) + }) +}) + +describe('a Stop in that window that the turn never opens for', () => { + async function statusRows(): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [item.body.text] : [] + ) + } + + async function waitingStop(): Promise<{ stopping: Promise }> { + await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + const stopping = stop() + expect(await settledWithin(stopping, 200)).toBe('held') + return { stopping } + } + + it('says Codex had no turn running when that turn ends first', async () => { + const { stopping } = await waitingStop() + + turns.end('interrupted') + await stopping + + expect(interrupts).toBe(0) + expect(await statusRows()).toContain('Codex had no turn running to stop.') + }) + + it('lets a chat closed behind it close within its bound and one eviction', async () => { + const { stopping } = await waitingStop() + + const closing = host.close(SESSION) + + expect( + await settledWithin(closing, CODEX_STOP_TURN_OPEN_WAIT_MS + CHILD_EVICTION_TIMEOUT_MS) + ).not.toBe('held') + expect(await settledWithin(stopping, 0)).not.toBe('held') + expect(childCloses).toBe(1) + expect(interrupts).toBe(0) + expect(await statusRows()).toContain('Codex had no turn running to stop.') + }) + + it('lets the app quit behind it within the eviction budget, and still close the child', async () => { + await waitingStop() + + expect( + await settledWithin(stopStructuredAgentSessionRuntime(), CHILD_EVICTION_TIMEOUT_MS) + ).not.toBe('held') + expect(childCloses).toBe(1) + }) +}) + +describe('a cold send with no Stop behind it', () => { + /** Well inside one eviction step's budget, and far inside the bound a Stop waits. */ + const PROMPTLY_MS = 1_000 + + async function answeredColdSend(): Promise { + await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + expect(turns.turnId).toBe('turn-1') + } + + it('never delays closing the chat', async () => { + await answeredColdSend() + + expect(await settledWithin(host.close(SESSION), PROMPTLY_MS)).not.toBe('held') + expect(childCloses).toBe(1) + }) + + it('never delays quitting the app', async () => { + await answeredColdSend() + + expect(await settledWithin(stopStructuredAgentSessionRuntime(), PROMPTLY_MS)).not.toBe('held') + expect(childCloses).toBe(1) + }) +}) diff --git a/src/main/runtime/structured-agent-session-integration-replay.test.ts b/src/main/runtime/structured-agent-session-integration-replay.test.ts index 4fa390be0fb..29dbdf59302 100644 --- a/src/main/runtime/structured-agent-session-integration-replay.test.ts +++ b/src/main/runtime/structured-agent-session-integration-replay.test.ts @@ -6,234 +6,27 @@ // that ship. The fake app-server answers the same JSON-RPC calls the real one // does and pushes the same notifications and blocking requests back. -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - CodexAppServerConnection, - CodexAppServerConnectionHandlers, - openCodexAppServerConnection -} from '../codex/codex-app-server-connection' import type { CodexStructuredSessionAdapter } from '../codex/codex-structured-session-adapter' -import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' -import { - AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY -} from '../../shared/protocol-version' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' -import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { journalDirectoryFor } from '../native-chat/agent-session-journal/journal-paths' import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' -import type { OrcaRuntimeService } from './orca-runtime' -import type { RpcRequest, RpcResponse } from './rpc/core' -import { RpcDispatcher } from './rpc/dispatcher' -import { STRUCTURED_AGENT_SESSION_METHODS } from './rpc/methods/structured-agent-session' import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { + openStructuredCodexRpcHarness, + SESSION, + THREAD, + TURN, + type StructuredCodexRpcHarness +} from './structured-codex-session-rpc-test-harness' const journals = createTrackedJournalOpener() - -const SESSION = 'session-integration-1' -const THREAD = 'thread-integration' -const TURN = 'turn-1' const WORKSPACE = 'workspace-1' -// The capability set the desktop renderer advertises. Without the pending-send -// one the host holds the reply until the send settles, which is a shim for -// clients too old to render a pending bubble — not what this suite models. -const CLIENT = { - clientId: 'device-a', - clientKind: 'runtime' as const, - clientCapabilities: [ - AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY - ] -} -// ─── the fake `codex app-server` ──────────────────────────────────────────── - -type CodexScript = { - connections: FakeConnection[] - openConnection: typeof openCodexAppServerConnection - live: () => FakeConnection - notify: (method: string, params: unknown) => void - ask: (id: number, method: string, params: unknown) => void -} - -// `closed` is readonly on the real connection; the fake flips it so the test can -// see the takeover reap the previous child. -type FakeConnection = Omit & { - closed: boolean - handlers: CodexAppServerConnectionHandlers - calls: { method: string; params?: Record }[] - replies: { id: number | string; result?: unknown; code?: number }[] - resumedThreadId: string | null - launch: Parameters[0] -} - -function fakeCodex(): CodexScript { - const connections: FakeConnection[] = [] - const openConnection = (async (launch, handlers = {}) => { - const connection: FakeConnection = { - launch, - handlers, - calls: [], - replies: [], - resumedThreadId: null, - pid: 4321, - closed: false, - request: async (method, params) => { - connection.calls.push({ method, params }) - if (method === 'thread/start') { - return { thread: { id: THREAD, path: '/rollouts/integration.jsonl' } } - } - if (method === 'thread/resume') { - connection.resumedThreadId = (params as { threadId: string }).threadId - return { thread: { id: connection.resumedThreadId } } - } - if (method === 'turn/start') { - return { turn: { id: TURN } } - } - if (method === 'model/list') { - return { - data: [ - { - model: 'gpt-live', - displayName: 'GPT Live', - hidden: false, - supportedReasoningEfforts: [ - { reasoningEffort: 'medium', description: 'Balanced' }, - { reasoningEffort: 'high', description: 'Deep reasoning' } - ], - defaultReasoningEffort: 'medium', - isDefault: true - } - ], - nextCursor: null - } - } - return {} - }, - notify: () => {}, - respond: (id, result) => connection.replies.push({ id, result }), - respondWithError: (id, code) => connection.replies.push({ id, code }), - close: async () => { - connection.closed = true - return true - } - } - connections.push(connection) - return connection - }) as typeof openCodexAppServerConnection - const live = (): FakeConnection => { - const connection = connections.at(-1) - if (!connection) { - throw new Error('no codex app-server has been opened') - } - return connection - } - return { - connections, - openConnection, - live, - notify: (method, params) => live().handlers.onNotification?.(method, params), - ask: (id, method, params) => live().handlers.onServerRequest?.({ id, method, params }) - } -} - -// ─── the RPC client ───────────────────────────────────────────────────────── - -let operations = 0 - -/** `<13-digit ms>-<32 hex>`, the only shape the durable ledger accepts. Real - * time, not a frozen constant: the runtime under test stamps the ledger with - * its own clock and refuses a future-dated id. */ -function operationId(): string { - operations += 1 - return `${Date.now()}-${operations.toString(16).padStart(32, '0')}` -} - -function envelope(method: string, fields: Record, fence: number | null) { - return { - sessionId: SESSION, - clientOperationId: operationId(), - expectedRuntimeFence: fence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }) - } -} - -function attachParams(fence: number | null) { - const params = { - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: WORKSPACE, - workspaceKind: 'git-worktree' as const - }, - provider: 'codex' as const, - agent: 'codex', - accountHome: { variable: 'CODEX_HOME' as const, path: '/home/dev/.codex' }, - runtimeKind: 'native' as const, - providerHandle: { kind: 'codex' as const, threadId: THREAD } - } - const envelope = { - sessionId: SESSION, - clientOperationId: operationId(), - expectedRuntimeFence: fence, - payloadFingerprint: '' - } - return { - ...params, - envelope: { - ...envelope, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.attach', - sessionId: SESSION, - fields: attachFingerprintFields({ ...params, envelope } as never) - }) - } - } -} - -function createIntentParams() { - const worktree = `id:${WORKSPACE}` - const fields = { worktree, agent: 'codex' } - return { envelope: envelope('agentSession.create', fields, null), ...fields } -} - -let codex: CodexScript -let root: string -let dispatcher: RpcDispatcher -let bootEnvironmentReads: number -let codexOverrideReads: number -let configuredCodexProfile: string - -/** Runs a one-shot method and returns its decoded reply. */ -async function call(method: string, params: unknown): Promise { - const replies: RpcResponse[] = [] - const request: RpcRequest = { id: `req-${operations}`, authToken: 'token', method, params } - await dispatcher.dispatchStreaming(request, (raw) => replies.push(JSON.parse(raw)), CLIENT) - const first = replies[0] - if (!first) { - throw new Error(`no reply for ${method}`) - } - return first -} - -/** Asserts success and unwraps the host's `{ok:true, value}` mutation result. */ -async function ok(method: string, params: unknown): Promise { - const response = await call(method, params) - expect(response, `${method} failed: ${JSON.stringify(response)}`).toMatchObject({ ok: true }) - const result = (response as { result: { ok: boolean; value?: T; refusal?: unknown } }).result - expect(result, `${method} refused: ${JSON.stringify(result.refusal)}`).toMatchObject({ ok: true }) - return result.value as T -} +let harness: StructuredCodexRpcHarness function textOf(item: AgentJournalRenderItem): string { const body = item.body @@ -243,69 +36,20 @@ function textOf(item: AgentJournalRenderItem): string { } beforeEach(async () => { - operations = 0 - root = await mkdtemp(join(tmpdir(), 'orca-structured-integration-')) - codex = fakeCodex() - bootEnvironmentReads = 0 - codexOverrideReads = 0 - configuredCodexProfile = 'configured' - const runtime = { - getRuntimeId: () => 'runtime-1', - getClientSettings: () => ({ experimentalStructuredNativeChat: true }), - getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), - resolveStructuredAgentSessionCreateIntent: async () => { - const { - envelope: _envelope, - providerHandle: _providerHandle, - ...resolved - } = attachParams(null) - return resolved - }, - publishStructuredAgentSessionTab: () => {}, - ensureStructuredAgentSessionHost: () => - ensureStructuredAgentSessionHost({ - stateDirectory: root, - hostId: 'local', - claimKeyId: 'key-1', - resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, - resolveCodexCommand: () => '/usr/local/bin/codex', - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), - resolveEnvironment: async () => { - bootEnvironmentReads += 1 - return { - PATH: '/shell/bin:/usr/bin', - EXAMPLE_GATEWAY_TOKEN: 'shell-exported', - CODEX_HOME: '/shell/home' - } - }, - resolveCodexOverrides: () => { - codexOverrideReads += 1 - return { CODEX_PROFILE: configuredCodexProfile } - }, - openCodexConnection: codex.openConnection, - readProcessStartTime: async () => 1_700_000_000_000 - }).then(() => undefined), - registerOwnedSubscriptionCleanup: vi.fn((_id: string, dispose: () => void) => { - return { - releaseIfCurrent: dispose - } - }) - } - dispatcher = new RpcDispatcher({ - runtime: runtime as unknown as OrcaRuntimeService, - methods: STRUCTURED_AGENT_SESSION_METHODS - }) + harness = await openStructuredCodexRpcHarness() }) afterEach(async () => { await journals.closeAll() - await stopStructuredAgentSessionRuntime() - await rm(root, { recursive: true, force: true }) + await harness.dispose() }) describe('a structured codex session over agentSession.*', () => { it('replays a durable image send without dispatching it twice', async () => { - const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) + const created = await harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) const path = '/tmp/orca-paste-image.png' const body = { kind: 'message' as const, @@ -313,28 +57,23 @@ describe('a structured codex session over agentSession.*', () => { blocks: [{ type: 'image-ref' as const, path }] } const params = { - envelope: envelope('agentSession.send', { body }, created.fence), + envelope: harness.envelope('agentSession.send', { body }, created.fence), body } - await ok('agentSession.send', params) - const replay = await call('agentSession.send', params) + const turnStarts = () => + harness.codex.live().calls.filter((entry) => entry.method === 'turn/start') + await harness.ok('agentSession.send', params) + // Accepted first; the delivery loop hands it over once. + await vi.waitFor(() => expect(turnStarts()).toHaveLength(1)) + const replay = await harness.call('agentSession.send', params) expect(replay).toMatchObject({ ok: true, result: { ok: true, replayed: true } }) - expect(codex.live().calls.filter((entry) => entry.method === 'turn/start')).toHaveLength(1) + expect(turnStarts()).toHaveLength(1) }) it('joins an acquired attach through journal bind before draining final rows', async () => { - const host = await ensureStructuredAgentSessionHost({ - stateDirectory: root, - hostId: 'local', - claimKeyId: 'key-1', - resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, - resolveCodexCommand: () => '/usr/local/bin/codex', - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), - openCodexConnection: codex.openConnection, - readProcessStartTime: async () => 1_700_000_000_000 - }) + const host = await ensureStructuredAgentSessionHost(harness.hostConfig()) const adapter = (host as unknown as { deps: { adapter: CodexStructuredSessionAdapter } }).deps .adapter const historyEntered = Promise.withResolvers() @@ -346,15 +85,18 @@ describe('a structured codex session over agentSession.*', () => { return originalHistoryFilePath(input) }) - const creating = ok<{ fence: number }>('agentSession.create', createIntentParams()) + const creating = harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) await historyEntered.promise - codex.notify('turn/started', { threadId: THREAD, turn: { id: TURN } }) - codex.notify('item/started', { + harness.codex.notify('turn/started', { threadId: THREAD, turn: { id: TURN } }) + harness.codex.notify('item/started', { threadId: THREAD, turnId: TURN, item: { type: 'agentMessage', id: 'item-bind-window', text: '' } }) - codex.notify('item/agentMessage/delta', { + harness.codex.notify('item/agentMessage/delta', { threadId: THREAD, turnId: TURN, itemId: 'item-bind-window', @@ -381,7 +123,7 @@ describe('a structured codex session over agentSession.*', () => { } const reopened = await journals.open({ identity, - journalDir: journalDirectoryFor(root, identity) + journalDir: journalDirectoryFor(harness.root, identity) }) expect(reopened.snapshot().items.map(textOf)).toContain('Buffered while the journal opens.') expect( diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 3f8adae4906..1b7136a996d 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -218,6 +218,11 @@ function createIntentParams() { } let codex: CodexScript +/** Accepted first; the delivery loop hands the send over as `turn/start` after the reply. */ +const handedOverAs = (params: Record) => + vi.waitFor(() => + expect(codex.live().calls.at(-1)).toMatchObject({ method: 'turn/start', params }) + ) let root: string let dispatcher: RpcDispatcher let bootEnvironmentReads: number @@ -284,10 +289,10 @@ function textOf(item: AgentJournalRenderItem): string { } /** The durable submission row, which settlement rewrites after the send returns. */ -function submissionOf(clientMessageId: string): AgentJournalSubmission | undefined { - return getStructuredAgentSessionHost() - ?.journalSnapshot(SESSION) - .submissions.find((entry) => entry.clientMessageId === clientMessageId) +async function submissionOf(clientMessageId: string): Promise { + return (await getStructuredAgentSessionHost()?.journalSnapshot(SESSION))?.submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) } async function historyPage( @@ -466,10 +471,7 @@ describe('a structured codex session over agentSession.*', () => { // send coalesced into a running turn is answered with that turn's id, so // which message landed where is knowable only from the echo. expect(sent.submission).toMatchObject({ dispatchState: 'pending', providerItemId: null }) - expect(codex.live().calls.at(-1)).toMatchObject({ - method: 'turn/start', - params: { threadId: THREAD, clientUserMessageId: sent.clientMessageId } - }) + await handedOverAs({ threadId: THREAD, clientUserMessageId: sent.clientMessageId }) codex.notify('turn/started', { turn: { id: TURN } }) // Codex echoes the message back carrying the `clientId` it was sent under, @@ -493,8 +495,8 @@ describe('a structured codex session over agentSession.*', () => { // The echo is the first item of this turn, so the settled key is ordinal 0 — // minted by the same `identityFor` a history replay computes with, rather // than guessed from the turn/start response. - await vi.waitFor(() => - expect(submissionOf(sent.clientMessageId)).toMatchObject({ + await vi.waitFor(async () => + expect(await submissionOf(sent.clientMessageId)).toMatchObject({ dispatchState: 'accepted', providerItemId: `codex:${THREAD}:${TURN}:0` }) @@ -557,14 +559,11 @@ describe('a structured codex session over agentSession.*', () => { // "delivery unconfirmed" — it carries no identity yet, because the response // to a coalesced send names the running turn rather than this message. expect(sent.submission).toMatchObject({ dispatchState: 'pending', providerItemId: null }) - expect(codex.live().calls.at(-1)).toMatchObject({ - method: 'turn/start', - params: { - threadId: THREAD, - clientUserMessageId: sent.clientMessageId, - model: 'gpt-live', - effort: 'high' - } + await handedOverAs({ + threadId: THREAD, + clientUserMessageId: sent.clientMessageId, + model: 'gpt-live', + effort: 'high' }) // ── stream ────────────────────────────────────────────────────────────── @@ -585,8 +584,8 @@ describe('a structured codex session over agentSession.*', () => { expect(itemsOf(stream).filter((item) => textOf(item) === 'list files')).toHaveLength(1) // Settled from the echo's own journal identity, so it is by construction the // key a replay recomputes for this row. - await vi.waitFor(() => - expect(submissionOf(sent.clientMessageId)).toMatchObject({ + await vi.waitFor(async () => + expect(await submissionOf(sent.clientMessageId)).toMatchObject({ dispatchState: 'accepted', providerItemId: `codex:${THREAD}:${TURN}:0` }) diff --git a/src/main/runtime/structured-agent-session-legacy-send-behind-command.test.ts b/src/main/runtime/structured-agent-session-legacy-send-behind-command.test.ts new file mode 100644 index 00000000000..fb820891ba5 --- /dev/null +++ b/src/main/runtime/structured-agent-session-legacy-send-behind-command.test.ts @@ -0,0 +1,106 @@ +// A client that predates accepted-send replies (every phone build) has its send reply held until +// handover. A message queued behind `/compact` is handed over only when the compaction ends, so +// that reply answers once the message waits behind the running command instead. + +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types' +import { ensureStructuredAgentSessionHost } from './structured-agent-session-runtime' +import { + openStructuredCodexRpcHarness, + SESSION, + THREAD, + type FakeCodexConnection, + type StructuredCodexRpcHarness +} from './structured-codex-session-rpc-test-harness' + +const COMPACTION_TURN = 'compaction-turn' +// Far under the phone's 15 s send timeout, and far over a handover that is already due. +const PROMPT_REPLY_MS = 2_000 + +let harness: StructuredCodexRpcHarness + +beforeEach(async () => { + // The harness's default client is the phone's: pending replies, but not accepted-send. + harness = await openStructuredCodexRpcHarness() +}) + +afterEach(async () => { + await harness.dispose() +}) + +function calls(method: string): FakeCodexConnection[] { + return harness.codex.connections.flatMap((connection) => + connection.calls.filter((entry) => entry.method === method).map(() => connection) + ) +} + +function send(text: string): Promise<{ submission: AgentJournalSubmission }> { + const body = { kind: 'message' as const, role: 'user' as const, blocks: [{ type: 'text', text }] } + return harness.ok('agentSession.send', { + envelope: harness.envelope('agentSession.send', { body }, null), + body + }) +} + +function promptly(reply: Promise): Promise { + return Promise.race([ + reply, + new Promise((_, reject) => + setTimeout(() => reject(new Error('the send reply waited out the command')), PROMPT_REPLY_MS) + ) + ]) +} + +it('answers a send queued behind /compact at once, and delivers it once after the compaction', async () => { + const created = await harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) + await harness.ok('agentSession.conversationCommand', { + command: 'compact', + envelope: harness.envelope( + 'agentSession.conversationCommand', + { command: 'compact' }, + created.fence + ) + }) + await vi.waitFor(() => expect(calls('thread/compact/start')).toHaveLength(1)) + + const { submission } = await promptly(send('after the compaction')) + + expect(submission).toMatchObject({ dispatchState: 'pending' }) + expect(submission.handedOverAt).toBeUndefined() + expect(calls('turn/start')).toHaveLength(0) + + harness.codex.notify('turn/started', { threadId: THREAD, turn: { id: COMPACTION_TURN } }) + harness.codex.notify('item/completed', { + threadId: THREAD, + turnId: COMPACTION_TURN, + item: { type: 'contextCompaction', id: 'compaction' } + }) + harness.codex.notify('turn/completed', { + threadId: THREAD, + turn: { id: COMPACTION_TURN, status: 'completed' } + }) + + await vi.waitFor(() => expect(calls('turn/start')).toHaveLength(1)) + const journal = await ( + await ensureStructuredAgentSessionHost(harness.hostConfig()) + ).journalSnapshot(SESSION) + expect( + journal.submissions.filter((entry) => entry.clientMessageId === submission.clientMessageId) + ).toEqual([expect.objectContaining({ handedOverAt: expect.any(Number) })]) + // Nothing re-sent it: the host delivered the one message once. + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(calls('turn/start')).toHaveLength(1) +}) + +it('still holds the reply of a send that no command is holding until it is handed over', async () => { + await harness.ok('agentSession.create', harness.createIntentParams()) + + // The first send starts the child; the reply waits out that start and the handover. + const { submission } = await promptly(send('first message')) + + expect(submission.handedOverAt).toEqual(expect.any(Number)) + await vi.waitFor(() => expect(calls('turn/start')).toHaveLength(1)) +}) diff --git a/src/main/runtime/structured-agent-session-owner-probe.ts b/src/main/runtime/structured-agent-session-owner-probe.ts index 47f92a08ca6..d42959bcd28 100644 --- a/src/main/runtime/structured-agent-session-owner-probe.ts +++ b/src/main/runtime/structured-agent-session-owner-probe.ts @@ -10,8 +10,7 @@ import { readEchoedAgentSessionSpawnToken } from './agent-session-spawn-token-re /** * The lease's only source of truth about a previous owner. Everything it cannot - * answer PID-reuse-safely reports `indeterminate`. An exact owner stays fenced in `recovering`; - * an ownerless, unattributable reservation enters `manual-recovery`. + * answer PID-reuse-safely reports `indeterminate`, and recovery resolution concludes from there. */ export function createStructuredAgentSessionOwnerProbe( hostId: string, @@ -21,9 +20,6 @@ export function createStructuredAgentSessionOwnerProbe( return async (record) => { const owner = record.lease.ownerProcess if (!owner) { - if (record.lease.processlessAt !== undefined && record.lease.processlessAt !== null) { - return { outcome: 'reservation-unused' } - } const spawnToken = record.lease.reservedSpawnToken if (spawnToken === null) { if (record.lease.claimStatus === 'reserved') { diff --git a/src/main/runtime/structured-agent-session-runtime-exit.test.ts b/src/main/runtime/structured-agent-session-runtime-exit.test.ts index 7a3736b9009..3da0c6f203d 100644 --- a/src/main/runtime/structured-agent-session-runtime-exit.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-exit.test.ts @@ -32,7 +32,7 @@ describe('structured session runtime provider-exit wiring', () => { } }) - it('reacquires through the production callback and accepts a distinct next message', async () => { + it('does not respawn after a provider exit, and the next message starts a new child', async () => { root = await mkdtemp(join(tmpdir(), 'orca-runtime-provider-exit-')) operations = 0 const connections: { @@ -98,16 +98,17 @@ describe('structured session runtime provider-exit wiring', () => { JSON.stringify({ refusal: attached.refusal, connections: connections.length }) ) } - await host.hold(SESSION, 'desktop-chat:1') - const exitedFence = host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0 const exited = connections[0] exited?.handlers.onExit?.(new Error('scripted provider exit')) - - await vi.waitFor(() => expect(connections).toHaveLength(2)) + await vi.waitFor(() => + expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + ) + // Nothing restarts the child on its own; the exit is shown and the chat waits for a send. + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(connections).toHaveLength(1) const recoveredFence = host.deps.store.getRecord(SESSION)?.lease.runtimeFence - expect(recoveredFence).toBeGreaterThan(exitedFence) if (recoveredFence === undefined) { - throw new Error('recovered lease omitted its fence') + throw new Error('released lease omitted its fence') } const body = hostTestMessage('continue with a distinct message') const envelope = { @@ -128,7 +129,9 @@ describe('structured session runtime provider-exit wiring', () => { await expect( host.send({ callerKey: 'runtime-test' }, { envelope, body }) ).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) - expect(turn).toBe(1) + // The send answers at acceptance; the delivery loop starts a new child and hands it over. + await vi.waitFor(() => expect(connections).toHaveLength(2)) + await vi.waitFor(() => expect(turn).toBe(1)) }) it('does not reacquire when the production exit callback comes from a requested close', async () => { @@ -199,7 +202,6 @@ describe('structured session runtime provider-exit wiring', () => { JSON.stringify({ refusal: attached.refusal, connections: connections.length }) ) } - await host.hold(SESSION, 'desktop-chat:requested-close') await stopStructuredAgentSessionRuntime() await new Promise((resolve) => setImmediate(resolve)) @@ -223,7 +225,7 @@ describe('structured session runtime provider-exit wiring', () => { readProcessStartTime: async () => 1_700_000_000_000 }) await restarted.restoreReadableSessions() - const history = restarted.history({ sessionId: SESSION, direction: 'tail' }) + const history = await restarted.history({ sessionId: SESSION, direction: 'tail' }) expect(history.ok && history.page.items.some((item) => item.body.kind === 'status')).toBe(false) expect(restarted.deps.store.getRecord(SESSION)?.providerHandleChain.at(-1)?.handle).toEqual({ provider: 'codex', @@ -231,7 +233,7 @@ describe('structured session runtime provider-exit wiring', () => { }) }) - it('waits for an in-flight recovery before tearing down the runtime', async () => { + it('waits for a start a send began before tearing down the runtime', async () => { root = await mkdtemp(join(tmpdir(), 'orca-runtime-recovery-shutdown-')) let releaseRecovery!: () => void const recoveryReleased = new Promise((resolve) => { @@ -300,8 +302,28 @@ describe('structured session runtime provider-exit wiring', () => { attachParams.envelope.clientOperationId = operationId() const attached = await host.attach({ callerKey: 'runtime-test' }, attachParams) expect(attached.ok).toBe(true) - await host.hold(SESSION, 'desktop-chat:shutdown-race') - connections[0]?.handlers.onExit?.(new Error('recovery is still opening')) + connections[0]?.handlers.onExit?.(new Error('the first child exited')) + await vi.waitFor(() => + expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + ) + const body = hostTestMessage('start again') + await host.send( + { callerKey: 'runtime-test' }, + { + envelope: { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + } + ) + // The send's start is still opening its connection when the quit begins. await vi.waitFor(() => expect(opens).toBe(2)) let stopped = false @@ -387,7 +409,6 @@ describe('structured session runtime provider-exit wiring', () => { expect(await host.attach({ callerKey: 'runtime-test' }, attachParams)).toMatchObject({ ok: true }) - await host.hold(SESSION, 'desktop-chat:backstop') await expect(stopStructuredAgentSessionRuntime()).rejects.toThrow() await new Promise((resolve) => setImmediate(resolve)) @@ -396,7 +417,7 @@ describe('structured session runtime provider-exit wiring', () => { expect(closeAttempts).toBeGreaterThanOrEqual(2) // The callback it delivered neither reacquired nor wrote a technical row. expect(connections).toHaveLength(1) - const history = host.history({ sessionId: SESSION, direction: 'tail' }) + const history = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(history.ok && history.page.items.some((item) => item.body.kind === 'status')).toBe(false) // The aborted eviction left the session reachable, so the next teardown is a real retry. diff --git a/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts b/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts index 602507ef4d7..91bf91738b5 100644 --- a/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts @@ -1,5 +1,5 @@ -// A Claude child proving its start must not wait on another session's exit recovery, and must -// not make that recovery, or the session's own serialized operations, wait on the CLI: the host +// A Claude child proving its start must not wait on another session's start, and must not make +// that start, or the session's own serialized operations, wait on the CLI: the host // records what the child proved from what the adapter already holds. import { afterEach, describe, expect, it, vi } from 'vitest' @@ -18,7 +18,7 @@ afterEach(async () => { }) describe('a Claude child proving its start', () => { - it("never holds another session's exit recovery, or its own close, on a CLI read", async () => { + it("never holds another session's start, or its own close, on a CLI read", async () => { claude.behave(STALLED, { stallsControlReads: true }) const host = await claude.install() @@ -56,23 +56,15 @@ describe('a Claude child proving its start', () => { await vi.waitFor(() => expect(closed).toBe(true)) }) - it("flips to ready while another session's exit recovery is still acquiring", async () => { + it("flips to ready while another session's start is still acquiring", async () => { const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(HEALTHY, null))).resolves.toMatchObject({ - ok: true - }) - await host.hold(HEALTHY, 'surface-1') - await waitForStructuredAgentSessionRecovery() - - // Its exit recovery reacquires, and that spawn never returns. The exit hands the lease back - // and the restart queued behind it reserves it again at once, so `released` is not a state a - // poll can count on seeing; `reserved` with the spawn hanging is what "still acquiring" is. + // This start never returns from its spawn, so its lease stays reserved with no child. claude.behave(HEALTHY, { spawnHangs: true }) - claude.child(HEALTHY).exit(new Error('claude stream-json exited (code 1): crashed')) + void host.attach(CALLER, claude.attachParams(HEALTHY, null)).catch(() => undefined) await vi.waitFor(() => expect(host.deps.store.getRecord(HEALTHY)?.lease.claimStatus).toBe('reserved') ) - expect(claude.children(HEALTHY)).toHaveLength(1) + const childrenWhileHung = claude.children(HEALTHY).length await expect(host.attach(CALLER, claude.attachParams(STALLED, null))).resolves.toMatchObject({ ok: true @@ -83,9 +75,9 @@ describe('a Claude child proving its start', () => { effort: 'high' }) ) - // The other recovery is still where it was: reserved, with no child yet. + // The other start is still where it was: reserved, with no new child. expect(host.deps.store.getRecord(HEALTHY)?.lease.claimStatus).toBe('reserved') - expect(claude.children(HEALTHY)).toHaveLength(1) + expect(claude.children(HEALTHY)).toHaveLength(childrenWhileHung) }) it('is drained by the runtime before teardown proceeds', async () => { diff --git a/src/main/runtime/structured-agent-session-runtime.test.ts b/src/main/runtime/structured-agent-session-runtime.test.ts index 6b612c73d46..9f4f4e2474d 100644 --- a/src/main/runtime/structured-agent-session-runtime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime.test.ts @@ -191,17 +191,6 @@ describe('structured agent-session owner probe', () => { expect(result.outcome).toBe('indeterminate') }) - - it('releases only a reservation carrying durable pre-spawn proof', async () => { - const probe = deadProbe() - const result = await createStructuredAgentSessionOwnerProbe( - HOST_ID, - probe - )(record(null, { processlessAt: 1_800_000_000_000, claimStatus: 'reserved' })) - - expect(probe).not.toHaveBeenCalled() - expect(result).toEqual({ outcome: 'reservation-unused' }) - }) }) describe('structured agent-session runtime install', () => { @@ -216,36 +205,6 @@ describe('structured agent-session runtime install', () => { vi.restoreAllMocks() }) - it('starts orphan reaping and reports failures without failing installation', async () => { - stateDirectory = await mkdtemp(join(tmpdir(), 'orca-structured-runtime-')) - const failure = new Error('scan failed') - const reapOrphanChildren = vi.fn(async () => { - throw failure - }) - const onError = vi.fn() - - await expect( - ensureStructuredAgentSessionHost({ - stateDirectory, - hostId: HOST_ID, - claimKeyId: 'key-1', - resolveWorkspacePath: async () => stateDirectory!, - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), - resolveEnvironment: async () => ({}), - reapOrphanChildren, - onError - }) - ).resolves.toBeDefined() - - await vi.waitFor(() => - expect(onError).toHaveBeenCalledWith({ - scope: 'agent-session-orphan-child-reaper', - error: failure - }) - ) - expect(reapOrphanChildren).toHaveBeenCalledWith({ store: expect.anything() }) - }) - it('holds stop until the model catalog has written its coalesced save', async () => { stateDirectory = await mkdtemp(join(tmpdir(), 'orca-structured-runtime-')) await ensureStructuredAgentSessionHost({ @@ -254,8 +213,7 @@ describe('structured agent-session runtime install', () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => stateDirectory!, resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), - resolveEnvironment: async () => ({}), - reapOrphanChildren: async () => [] + resolveEnvironment: async () => ({}) }) let finishWrite = (): void => {} vi.spyOn(agentModelCatalogStore, 'flushPersistence').mockReturnValue( @@ -276,33 +234,6 @@ describe('structured agent-session runtime install', () => { expect(stopped).toBe(true) }) - it('logs an orphan-reaper failure when no reporter is configured', async () => { - stateDirectory = await mkdtemp(join(tmpdir(), 'orca-structured-runtime-')) - const failure = new Error('scan failed') - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) - - await expect( - ensureStructuredAgentSessionHost({ - stateDirectory, - hostId: HOST_ID, - claimKeyId: 'key-1', - resolveWorkspacePath: async () => stateDirectory!, - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), - resolveEnvironment: async () => ({}), - reapOrphanChildren: async () => { - throw failure - } - }) - ).resolves.toBeDefined() - - await vi.waitFor(() => - expect(consoleError).toHaveBeenCalledWith( - '[structured-agent-session] orphan reaper failed', - failure - ) - ) - }) - it('does not infer Windows process identity support from an injected reader', async () => { stateDirectory = await mkdtemp(join(tmpdir(), 'orca-structured-runtime-')) const originalPlatform = process.platform @@ -366,7 +297,6 @@ describe('a teardown that fails is retried by the next stop', () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => directory!, resolveEnvironment: async () => ({}), - reapOrphanChildren: async () => [], resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) }) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index a51da3f1c3c..10db8ba3bda 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -38,7 +38,6 @@ import { } from '../native-chat/claude-structured-managed-account-support' import { AgentSessionRecordStore } from './agent-session-record-store' import { agentSessionStorePath } from './agent-session-record-store-file' -import { stopOrphanAgentSessionChildren } from './agent-session-orphan-child-reaper' import { createStructuredAgentSessionOwnerProbe, createStructuredAgentSessionOwnerProbes @@ -104,7 +103,8 @@ export type StructuredAgentSessionRuntimeDeps = { onSessionStatusChanged?: StructuredAgentSessionHostDeps['onSessionStatusChanged'] /** The agent-status store; see `StructuredAgentSessionHostDeps.statusSink`. */ statusSink?: StructuredAgentSessionHostDeps['statusSink'] - reapOrphanChildren?: typeof stopOrphanAgentSessionChildren + /** See `StructuredAgentSessionHostDeps.hasOpenDispatch`. */ + hasOpenDispatch?: StructuredAgentSessionHostDeps['hasOpenDispatch'] /** The account home a structured launch would pin right now, for catalog * reads with no session record. Absent disables the catalog surface. */ resolveAgentAccountHome?: RuntimeAgentAccountHomeResolver @@ -197,21 +197,6 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { - try { - if (deps.onError) { - deps.onError({ scope: 'agent-session-orphan-child-reaper', error }) - } else { - console.error('[structured-agent-session] orphan reaper failed', error) - } - } catch (reportingError) { - console.error( - '[structured-agent-session] orphan reaper error reporting failed', - reportingError - ) - } - }) let host: StructuredAgentSessionHost | null = null const lifecycle = createStructuredAgentSessionLifecycleDelivery({ handle: (event) => host?.handleAdapterEvent(event), @@ -230,6 +215,20 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { + void host + ?.releaseUnansweredDispatches({ + sessionId, + reason: DISPATCH_DOUBT_PROVIDER_IDLE + }) + .catch((error) => + deps.onError?.({ + scope: `structured-agent-session-unanswered-dispatch:${sessionId}`, + error + }) + ) + } const codex = new CodexStructuredSessionAdapter({ resolveLaunch: createCodexStructuredLaunchResolver({ store, @@ -245,20 +244,10 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise host?.publishBackgroundTaskState(sessionId, state), + onChildWorkEvidence: (sessionId, evidence) => + host?.publishChildWorkEvidence(sessionId, evidence), onDispatchSettledLate, - onPrimaryThreadStoppedRunning: ({ sessionId }) => { - void host - ?.releaseUnansweredDispatches({ - sessionId, - reason: DISPATCH_DOUBT_PROVIDER_IDLE - }) - .catch((error) => - deps.onError?.({ - scope: `structured-agent-session-unanswered-dispatch:${sessionId}`, - error - }) - ) - }, + onPrimaryThreadStoppedRunning: releaseUnansweredDispatches, onEvent: (event) => { if (event.type === 'ended' && 'cause' in event && event.cause === 'unexpected-exit') { lifecycle.deliver(event) @@ -287,6 +276,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise host?.publishChildWorkEvidence(sessionId, evidence), onDispatchSettledLate, + onSessionIdle: releaseUnansweredDispatches, ...(deps.openClaudeConnection ? { openClaudeConnection: deps.openClaudeConnection } : {}), ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), modelCatalog: agentModelCatalogStore @@ -312,6 +302,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise the NUL-separated environment block `/proc//environ` serves. */ +const fakeProc = vi.hoisted(() => ({ environs: new Map() })) + +vi.mock('node:fs/promises', async (importOriginal) => { + const fsp = await importOriginal() + const enoent = (path: string) => Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' }) + return { + ...fsp, + readdir: async (...args: Parameters) => + args[0] === '/proc' ? [...fakeProc.environs.keys()].map(String) : fsp.readdir(...args), + readFile: async (...args: Parameters) => { + const path = String(args[0]) + if (!path.startsWith('/proc/')) { + return fsp.readFile(...args) + } + const environ = fakeProc.environs.get(Number(path.split('/')[2])) + if (environ === undefined || !path.endsWith('/environ')) { + throw enoent(path) + } + return environ + } + } +}) + +const HOST_ID = 'local' +const NOW = 1_800_000_000_000 +const SESSION = 'session-alpha' +const MINTED_TOKEN = 'spawn-minted-by-this-store' + +function environ(token: string | null): string { + return ['PATH=/usr/bin', ...(token ? [`${CODEX_SPAWN_TOKEN_ENV}=${token}`] : [])].join('\0') +} + +function reserveRequest(): AgentSessionReserveRequest { + const operationId = `${NOW}-${'1'.padStart(32, '0')}` + return { + sessionId: SESSION, + location: { + executionHostId: HOST_ID, + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + expectedFence: null, + spawnToken: MINTED_TOKEN, + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'indeterminate', reason: 'no previous owner' }, + operation: { callerKey: 'client-1', operationId, fingerprint: 'fp-1' }, + now: NOW + } +} + +const originalPlatform = process.platform +let stateDirectory: string + +beforeEach(async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-spawn-token-descendants-')) + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + fakeProc.environs.clear() + vi.restoreAllMocks() + await rm(stateDirectory, { recursive: true, force: true }) +}) + +function openStore(): Promise { + return AgentSessionRecordStore.open({ + directory: join(stateDirectory, 'agent-sessions'), + hostId: HOST_ID + }) +} + +describe('a process that inherited a spawn token', () => { + it('is never signalled when the host installs and reconciles, even when this store minted the token', async () => { + // A chat ran and ended: its root exited and the lease was released, clearing the token. + const seed = await openStore() + const reserved = await seed.reserveOwner(reserveRequest()) + const fence = reserved.record.lease.runtimeFence + await seed.commitProcessIdentity({ + sessionId: SESSION, + fence, + process: { hostId: HOST_ID, pid: 4242, processStartTimeMs: NOW, spawnToken: MINTED_TOKEN }, + now: NOW + }) + await seed.proveOwner({ + sessionId: SESSION, + fence, + link: { + linkId: 'link-1', + handle: { provider: 'codex', threadId: 'thread-1' }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + }, + now: NOW + }) + const released = await seed.evictProvenDeadOwner({ + sessionId: SESSION, + expectedFence: fence, + probe: { outcome: 'pid-absent' }, + now: NOW + }) + expect(released.lease).toMatchObject({ claimStatus: 'released', reservedSpawnToken: null }) + + // The editor the agent opened detached into its own group and outlived the chat, still + // carrying the token; another descendant carries a token no store here ever minted. + fakeProc.environs.set(1, environ(null)) + fakeProc.environs.set(5151, environ(MINTED_TOKEN)) + fakeProc.environs.set(6161, environ('spawn-foreign')) + const kill = vi.spyOn(process, 'kill').mockImplementation(() => true) + + const host = await ensureStructuredAgentSessionHost({ + stateDirectory, + hostId: HOST_ID, + claimKeyId: 'key-1', + resolveWorkspacePath: async () => stateDirectory, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveEnvironment: async () => ({}) + }) + await host.reconcileRestartLeases() + // Install work that is not awaited would have run by now; nothing here waits on a timer. + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(kill.mock.calls.filter(([, signal]) => signal !== 0)).toEqual([]) + }) +}) + +describe('the reservation owner probe', () => { + async function reconcileReservation(): Promise { + const crashed = await openStore() + await crashed.reserveOwner(reserveRequest()) + // The restart after a crash between reservation and recorded identity. + const store = await openStore() + await store.reconcileOnRestart({ + probe: createStructuredAgentSessionOwnerProbe(HOST_ID), + now: NOW + }) + return store + } + + it('proves a reservation never spawned when the host scan finds no process carrying its token', async () => { + fakeProc.environs.set(1, environ(null)) + fakeProc.environs.set(6161, environ('spawn-foreign')) + + const store = await reconcileReservation() + + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + deathEvidence: { kind: 'pid-absent', detail: 'reservation never spawned' } + }) + }) + + it('claims no absence when the host scan finds a process carrying the token', async () => { + fakeProc.environs.set(5151, environ(MINTED_TOKEN)) + const kill = vi.spyOn(process, 'kill').mockImplementation(() => true) + + const store = await reconcileReservation() + + // Released without evidence, and the process carrying the token is left alone. + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + deathEvidence: null + }) + expect(kill.mock.calls.filter(([, signal]) => signal !== 0)).toEqual([]) + }) +}) diff --git a/src/main/runtime/structured-agent-session-support-probe.test.ts b/src/main/runtime/structured-agent-session-support-probe.test.ts index 07a2b671f0a..ea0ab3298d9 100644 --- a/src/main/runtime/structured-agent-session-support-probe.test.ts +++ b/src/main/runtime/structured-agent-session-support-probe.test.ts @@ -22,19 +22,15 @@ function setPlatform(platform: NodeJS.Platform): void { type InstallEffects = { storeOpened: boolean - reaperStarted: boolean } -/** Stands in for `install()` by performing the two effects it performs, so a probe that +/** Stands in for `install()` by performing the effect it performs, so a probe that * reinstalls the host is caught by what the install *does*, not by a call count alone. */ function stubStructuredHostInstall(runtime: OrcaRuntimeService): { effects: InstallEffects ensure: ReturnType } { - const effects: InstallEffects = { - storeOpened: false, - reaperStarted: false - } + const effects: InstallEffects = { storeOpened: false } // `supportsCreate` answers as the real Codex adapter would, so a probe that reinstalls the host // still returns the right answer and fails on the install effects alone. const host = { @@ -44,7 +40,6 @@ function stubStructuredHostInstall(runtime: OrcaRuntimeService): { } const ensure = vi.fn(async () => { effects.storeOpened = true - effects.reaperStarted = true setStructuredAgentSessionHost(host as never) }) vi.spyOn(runtime, 'ensureStructuredAgentSessionHost').mockImplementation(ensure) @@ -94,10 +89,7 @@ async function expectSupportWithoutInstall(input: { expect(answers).toEqual(Array(input.repetitions ?? 1).fill(input.expected)) expect(ensure).not.toHaveBeenCalled() - expect(effects).toEqual({ - storeOpened: false, - reaperStarted: false - }) + expect(effects).toEqual({ storeOpened: false }) expect(getStructuredAgentSessionHost()).toBeNull() } @@ -193,10 +185,7 @@ describe('structured agent-session create-support probe', () => { await runtime.prepareStructuredAgentSessionStartupRestoration() expect(ensure).toHaveBeenCalledTimes(1) - expect(effects).toEqual({ - storeOpened: true, - reaperStarted: true - }) + expect(effects).toEqual({ storeOpened: true }) expect( (getStructuredAgentSessionHost() as unknown as { reconcileRestartLeases: () => void }) .reconcileRestartLeases diff --git a/src/main/runtime/structured-chat-coordinator-fake-codex-fixture.ts b/src/main/runtime/structured-chat-coordinator-fake-codex-fixture.ts new file mode 100644 index 00000000000..68756963193 --- /dev/null +++ b/src/main/runtime/structured-chat-coordinator-fake-codex-fixture.ts @@ -0,0 +1,175 @@ +// A fake Codex app-server for the structured chat coordinator mail tests: it answers the JSON-RPC +// calls the adapter makes, and misbehaves on the knobs in `providerFaults`. + +import type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + openCodexAppServerConnection +} from '../codex/codex-app-server-connection' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' +import { isRecord } from './rpc/orchestration-session-caller-test-fixture' + +const WORKSPACE = 'workspace-1' + +export type FakeConnection = Omit & { + closed: boolean + handlers: CodexAppServerConnectionHandlers + threadId: string | null + turns: { clientUserMessageId: string; text: string }[] +} + +/** How the fake provider misbehaves; reset before each test. */ +export const providerFaults: { + dieBeforeEveryEcho: boolean + refuseTurnStarts: number + /** Refuses every start with this error while set. */ + refuseStart: (() => Error) | null + /** How long a start takes before it answers. */ + startDelayMs: number + /** Kills the app-server while it takes a turn: its exit and the failed call, in either order. */ + crashOnTurnStart: 'off' | 'exit-then-throw' | 'throw-then-exit' + starts: number + turnStarts: number +} = { + dieBeforeEveryEcho: false, + refuseTurnStarts: 0, + refuseStart: null, + startDelayMs: 0, + crashOnTurnStart: 'off', + starts: 0, + turnStarts: 0 +} + +export function fakeCodex() { + const connections: FakeConnection[] = [] + let turnCounter = 0 + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a fake answering the JSON-RPC calls the adapter makes, as the shipped integration test does. + const openConnection = (async (_launch, handlers = {}) => { + providerFaults.starts += 1 + if (providerFaults.startDelayMs) { + await new Promise((resolve) => setTimeout(resolve, providerFaults.startDelayMs)) + } + if (providerFaults.refuseStart) { + throw providerFaults.refuseStart() + } + const connection: FakeConnection = { + handlers, + threadId: null, + turns: [], + pid: 4321, + closed: false, + request: async (method, params) => { + const input = isRecord(params) ? params : {} + if (method === 'thread/start') { + connection.threadId = `thread-${connections.length}` + return { thread: { id: connection.threadId } } + } + if (method === 'thread/resume') { + connection.threadId = String(input.threadId) + return { thread: { id: connection.threadId } } + } + if (method === 'turn/start') { + providerFaults.turnStarts += 1 + if (providerFaults.crashOnTurnStart === 'exit-then-throw') { + connection.handlers.onExit?.(new Error('app-server crashed')) + throw new Error('connection closed') + } + if (providerFaults.crashOnTurnStart === 'throw-then-exit') { + setTimeout(() => connection.handlers.onExit?.(new Error('app-server crashed')), 0) + throw new Error('connection closed') + } + if (providerFaults.refuseTurnStarts > 0) { + providerFaults.refuseTurnStarts -= 1 + throw new Error('turn/start refused') + } + turnCounter += 1 + connection.turns.push({ + clientUserMessageId: String(input.clientUserMessageId), + text: JSON.stringify(input.input) + }) + if (providerFaults.dieBeforeEveryEcho) { + setTimeout(() => connection.handlers.onExit?.(new Error('provider died')), 0) + } + return { turn: { id: `turn-${turnCounter}` } } + } + if (method === 'model/list') { + return { + data: [ + { + model: 'gpt-live', + displayName: 'GPT Live', + hidden: false, + supportedReasoningEfforts: [{ reasoningEffort: 'medium', description: 'Balanced' }], + defaultReasoningEffort: 'medium', + isDefault: true + } + ], + nextCursor: null + } + } + return {} + }, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => { + connection.closed = true + return true + } + } + connections.push(connection) + return connection + }) as typeof openCodexAppServerConnection + return { connections, openConnection } +} + +let operations = 0 +export function operationId(): string { + operations += 1 + return `${Date.now()}-${operations.toString(16).padStart(32, '0')}` +} + +export function attachParams(sessionId: string) { + const params = { + location: { + executionHostId: 'local' as const, + wslDistro: null, + workspaceId: WORKSPACE, + workspaceKind: 'git-worktree' as const + }, + provider: 'codex' as const, + agent: 'codex' as const, + accountHome: { variable: 'CODEX_HOME' as const, path: '/home/dev/.codex' }, + runtimeKind: 'native' as const + } + const envelope = { + sessionId, + clientOperationId: operationId(), + expectedRuntimeFence: null, + payloadFingerprint: '' + } + return { + ...params, + envelope: { + ...envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId, + fields: attachFingerprintFields({ ...params, envelope }) + }) + } + } +} + +/** A healthy provider and a fresh id sequence, before each test. */ +export function resetProviderFaults(): void { + operations = 0 + providerFaults.dieBeforeEveryEcho = false + providerFaults.refuseTurnStarts = 0 + providerFaults.refuseStart = null + providerFaults.startDelayMs = 0 + providerFaults.crashOnTurnStart = 'off' + providerFaults.starts = 0 + providerFaults.turnStarts = 0 +} diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts new file mode 100644 index 00000000000..a36912fae95 --- /dev/null +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -0,0 +1,884 @@ +// A worker's result reaching the structured chat that coordinates it, end to end in one process. +// +// Real: the structured agent-session host, its record store, journal, lease and Codex adapter; the +// orchestration database, RPC dispatcher and methods; the runtime's pointer lanes. Fake: only the +// Codex app-server child, which answers the JSON-RPC calls the real one does. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../shared/agent-session-journal-item-key' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version' +import { + AgentSessionAcquisitionRefusal, + AgentSessionPreSpawnError +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' +import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../shared/agent-session-host-authority' +import { refuse } from '../../shared/agent-session-wire-refusals' +import { OrcaRuntimeService } from './orca-runtime' +import { OrchestrationDb } from './orchestration/db' +import { localOrchestrationCliCommand } from './orchestration/cli-command' +import { formatMessagePointer } from './orchestration/formatter' +import { currentRunCoordinatorOrcaSessionId } from './orchestration/db/runs/run-coordinator-orca-session' +import type { RpcRequest } from './rpc/core' +import { RpcDispatcher } from './rpc/dispatcher' +import { ORCHESTRATION_METHODS } from './rpc/methods/orchestration' +import { idOf, isRecord, resultOf } from './rpc/orchestration-session-caller-test-fixture' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' +import { + attachParams, + fakeCodex, + operationId, + providerFaults, + resetProviderFaults, + type FakeConnection +} from './structured-chat-coordinator-fake-codex-fixture' + +const COORDINATOR = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37' +const PEER_CHAT = '7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64' +const WORKER_PANE = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const WORKER_2_PANE = 'tab_worker2:cccccccc-cccc-4ccc-8ccc-cccccccccccc' + +let codex: ReturnType +let root: string +let runtime: OrcaRuntimeService +let db: OrchestrationDb +let host: StructuredAgentSessionHost +let dispatcher: RpcDispatcher +let requests = 0 + +function request( + method: string, + params: Record, + options: { sessionId?: string; capability?: string } = {} +): RpcRequest { + requests += 1 + return { + id: `rpc-${requests}`, + authToken: 'test', + method, + params, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: `req-${requests}`, + ...(options.sessionId + ? { orchestrationCompatibilityEvidence: { agentSessionId: options.sessionId } } + : {}), + ...(options.capability ? { orchestrationCapability: options.capability } : {}) + } +} + +async function call( + method: string, + params: Record, + options?: { sessionId?: string; capability?: string } +): Promise> { + const response = await dispatcher.dispatch(request(method, params, options)) + if (!response.ok) { + throw new Error(`${method} failed: ${JSON.stringify(response)}`) + } + return resultOf(response) +} + +async function openChat(sessionId: string): Promise { + const attached = await host.attach({ callerKey: 'test-surface' }, attachParams(sessionId)) + expect(attached, JSON.stringify(attached)).toMatchObject({ ok: true }) + await host.setSessionTabVisibility(sessionId, true) + threadBySession.set(sessionId, codex.connections.at(-1)!.threadId!) + return connectionFor(sessionId) +} + +const threadBySession = new Map() + +function connectionFor(sessionId: string): FakeConnection { + const connection = codex.connections.findLast( + (candidate) => candidate.threadId === threadBySession.get(sessionId) + ) + if (!connection) { + throw new Error(`no app-server for ${sessionId}`) + } + return connection +} + +/** Codex's own sequence for a turn: it starts, echoes the user message, and completes. */ +async function settleTurn(sessionId: string, turnIndex: number): Promise { + const connection = connectionFor(sessionId) + const turn = connection.turns[turnIndex]! + const turnId = `turn-${turnIndex + 1}` + const notify = (method: string, params: unknown) => + connection.handlers.onNotification?.(method, params) + notify('turn/started', { turn: { id: turnId } }) + notify('item/completed', { + item: { + type: 'userMessage', + id: `echo-${turn.clientUserMessageId}`, + clientId: turn.clientUserMessageId, + content: [{ type: 'text', text: 'pointer' }] + } + }) + notify('turn/completed', { turn: { id: turnId } }) + await host.flushStreamedEvents(sessionId) +} + +/** A user message typed into the chat, as the chat surface sends it. */ +function sendUserMessage(sessionId: string, text: string) { + const body = { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text }] + } + return host.send( + { callerKey: 'test-surface' }, + { + envelope: { + sessionId, + clientOperationId: operationId(), + expectedRuntimeFence: host.deps.store.getRecord(sessionId)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId, + fields: { body } + }) + }, + body + } + ) +} + +async function userTexts(sessionId: string): Promise { + return (await host.journalSnapshot(sessionId)).items.flatMap((item: AgentJournalRenderItem) => + item.body?.kind === 'message' && item.body.role === 'user' + ? item.body.blocks.map((block) => (block.type === 'text' ? block.text : '')) + : [] + ) +} + +/** A capability-backed terminal worker under the coordinator's Run, and its worker_done. */ +async function finishWorker( + taskId: string, + worker: { handle: string; paneKey: string } = { handle: 'term_worker', paneKey: WORKER_PANE } +): Promise { + const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId, + startOptions: {} + }) + const capability = db.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: worker.handle, + paneKey: worker.paneKey, + processIncarnation: `runtime_test:${worker.handle}:1`, + worktreeId: 'repo::worker', + effects: [], + setupState: 'not_applicable' + }) + db.markWorkerDispatchReady(started.dispatch.id) + await call( + 'orchestration.send', + { + from: worker.handle, + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId, dispatchId: started.dispatch.id, outcome: 'succeeded' }) + }, + { capability } + ) +} + +async function coordinatorRunAndTask(): Promise<{ runId: string; taskId: string }> { + const created = await call( + 'orchestration.runCreate', + { objective: 'ship' }, + { + sessionId: COORDINATOR + } + ) + const runId = idOf(created.run) + const task = await call( + 'orchestration.taskCreate', + { spec: 'build it' }, + { + sessionId: COORDINATOR + } + ) + return { runId, taskId: idOf(task.task) } +} + +/** `/clear` as the chat surface runs it: the conversation continues in a new session. */ +async function clearChat(sessionId: string): Promise { + const command = 'clear' as const + const cleared = await host.conversationCommand( + { callerKey: 'test-surface' }, + { + command, + envelope: { + sessionId, + clientOperationId: operationId(), + expectedRuntimeFence: host.deps.store.getRecord(sessionId)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.conversationCommand', + sessionId, + fields: { command } + }) + } + } + ) + const successor = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!successor) { + throw new Error(`clear failed: ${JSON.stringify(cleared)}`) + } + // The surface swaps the tab over to the session that continues the chat. + await host.setSessionTabVisibility(sessionId, false) + await host.setSessionTabVisibility(successor, true) + threadBySession.set(successor, codex.connections.at(-1)!.threadId!) + return successor +} + +beforeEach(async () => { + resetProviderFaults() + root = await mkdtemp(join(tmpdir(), 'orca-structured-coordinator-mail-')) + codex = fakeCodex() + db = new OrchestrationDb(':memory:') + runtime = startRuntime() + host = await ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, + resolveCodexCommand: () => '/usr/local/bin/codex', + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveEnvironment: async () => ({ PATH: '/usr/bin' }), + openCodexConnection: codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + // The same call the runtime's own host install makes on every status change. + onSessionStatusChanged: (summary) => runtime.onStructuredSessionStatusForMail(summary) + }) + dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) +}) + +/** The runtime over the shared database; a second call is what an Orca restart leaves behind. */ +function startRuntime(): OrcaRuntimeService { + const started = new OrcaRuntimeService() + started.setOrchestrationDb(db) + vi.spyOn(started, 'ensureStructuredAgentSessionHost').mockResolvedValue() + vi.spyOn(started, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_worker' ? WORKER_PANE : handle === 'term_worker_2' ? WORKER_2_PANE : null + ) + return started +} + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + db.close() + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +// Pointers are sent on asynchronous edges; the default 1s wait is too tight under a loaded parallel run. +const WAIT = { timeout: 10_000 } + +const POINTER = + /You have 1 orchestration message\. Run `orca(-dev)? orchestration check --run run_\w+`\./ + +/** The text the PTY lane types into a local terminal for this mailbox, byte for byte. */ +function ptyPointer(mailboxHandle: string): string { + return formatMessagePointer(1, mailboxHandle, localOrchestrationCliCommand()).trim() +} + +/** The text of a turn the fake provider received. */ +function turnText(turn: { text: string }): string { + const input: unknown = JSON.parse(turn.text) + return Array.isArray(input) + ? input.map((item: unknown) => (isRecord(item) ? String(item.text) : '')).join('') + : '' +} + +describe('a worker result reaches the structured chat that coordinates it', () => { + it('lands as a turn in the coordinator journal, and a flagless check returns the worker_done', async () => { + const chat = await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + + await finishWorker(taskId) + + // No user action: the result itself sends the chat a turn through the host's send. + await vi.waitFor(() => expect(chat.turns).toHaveLength(1), WAIT) + expect(turnText(chat.turns[0]!)).toBe(ptyPointer(`run:${runId}`)) + await settleTurn(COORDINATOR, 0) + expect(await userTexts(COORDINATOR)).toEqual([expect.stringMatching(POINTER)]) + + const checked = await call('orchestration.check', {}, { sessionId: COORDINATOR }) + expect(checked).toMatchObject({ + runId, + count: 1, + messages: [{ type: 'worker_done', from_handle: 'term_worker' }] + }) + }) + + it('does not send a second pointer when the delivery is retried', async () => { + const chat = await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + await finishWorker(taskId) + await vi.waitFor(() => expect(chat.turns).toHaveLength(1), WAIT) + + // A pending send is not an acknowledgement, so the mail is retained and retried on every edge + // until the host confirms it: before the echo, and again at the turn's idle edge. + runtime.deliverPendingMessagesForHandle(`run:${runId}`) + await settleTurn(COORDINATOR, 0) + runtime.deliverPendingMessagesForHandle(`run:${runId}`) + await vi.waitFor( + () => expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toEqual([]), + WAIT + ) + expect(chat.turns).toHaveLength(1) + expect(await userTexts(COORDINATOR)).toHaveLength(1) + }) + + /** Fires both edges and waits until every gate read they started has answered. */ + async function edgesAnswered(): Promise { + const reads = vi.spyOn(host, 'journalSnapshot') + runtime.onStructuredSessionStatusForMail({ sessionId: COORDINATOR, status: null }) + runtime.onStructuredSessionStatusForMail({ sessionId: COORDINATOR, status: 'idle' }) + await vi.waitFor(() => expect(reads).toHaveBeenCalled(), WAIT) + await Promise.all(reads.mock.results.map((read) => read.value)) + await new Promise((resolve) => setImmediate(resolve)) + reads.mockRestore() + } + + /** The operation ids the coordinator's journal recorded for its pointer turns. */ + async function pointerSends(): Promise { + const snapshot = await host.journalSnapshot(COORDINATOR) + return snapshot.submissions + .filter((submission) => + snapshot.items.some( + (item) => + item.itemId === agentJournalSubmissionKey(submission.clientMessageId) && + item.body?.kind === 'message' && + item.body.blocks.some((block) => block.type === 'text' && POINTER.test(block.text)) + ) + ) + .map((submission) => submission.clientMessageId) + } + + it('keeps a pointer whose provider died before the echo, and points it after the next turn that runs', async () => { + // A provider that dies before echoing never ran the pointer: the mail stays unpointed, and + // neither the death's own edge nor an idle one starts the provider again for it. + const chat = await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + await finishWorker(taskId) + await vi.waitFor(() => expect(chat.turns).toHaveLength(1), WAIT) + + chat.handlers.onExit?.(new Error('provider died before the echo')) + const before = codex.connections.length + await edgesAnswered() + await edgesAnswered() + expect(codex.connections.length).toBe(before) + expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toHaveLength(1) + + // The user's next message starts the agent; once its turn runs, the pointer follows it. + expect(await sendUserMessage(COORDINATOR, 'again')).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(codex.connections.length).toBe(before + 1), WAIT) + const revived = connectionFor(COORDINATOR) + await vi.waitFor(() => expect(revived.turns).toHaveLength(1), WAIT) + expect(revived.turns[0]!.text).toContain('again') + await settleTurn(COORDINATOR, 0) + await vi.waitFor(() => expect(revived.turns).toHaveLength(2), WAIT) + expect(revived.turns[1]!.text).toMatch(POINTER) + await settleTurn(COORDINATOR, 1) + await vi.waitFor( + () => expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toEqual([]), + WAIT + ) + }) + + it('does not restart a provider that dies before every echo, however many edges follow', async () => { + // What this pins: each death's own status edge used to re-point the mail, and that send started + // the provider again, about once a second for as long as the mail was unread. + await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + providerFaults.dieBeforeEveryEcho = true + const before = codex.connections.length + await finishWorker(taskId) + await vi.waitFor(() => expect(providerFaults.turnStarts).toBe(1), WAIT) + // A fixed window, not a poll: a respawn loop would restart it several times in it. + await new Promise((resolve) => setTimeout(resolve, 1_500)) + await edgesAnswered() + expect(codex.connections.length - before).toBe(0) + expect(providerFaults.turnStarts).toBe(1) + expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toHaveLength(1) + }) + + it.each(['exit-then-throw', 'throw-then-exit'] as const)( + 'does not restart a provider that crashed while taking the pointer turn (%s)', + async (crash) => { + // The crash settles the send `unknown` with the connection's own error, not as a provider + // exit; every status edge after it re-pointed the mail and started the provider again. + await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + providerFaults.crashOnTurnStart = crash + const before = providerFaults.starts + await finishWorker(taskId) + await vi.waitFor(() => expect(providerFaults.turnStarts).toBe(1), WAIT) + await new Promise((resolve) => setTimeout(resolve, 1_500)) + await edgesAnswered() + expect(providerFaults.starts - before).toBe(0) + expect(providerFaults.turnStarts).toBe(1) + expect(await pointerSends()).toHaveLength(1) + expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toHaveLength(1) + } + ) + + it('points the next result once after a transient death, then holds nothing', async () => { + await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + const second = await call( + 'orchestration.taskCreate', + { spec: 'more' }, + { sessionId: COORDINATOR } + ) + providerFaults.dieBeforeEveryEcho = true + await finishWorker(taskId) + await vi.waitFor(() => expect(providerFaults.turnStarts).toBe(1), WAIT) + await edgesAnswered() + // The death was transient. A new result is new mail: one pointer for both, one start. + providerFaults.dieBeforeEveryEcho = false + const before = providerFaults.starts + await finishWorker(idOf(second.task), { handle: 'term_worker_2', paneKey: WORKER_2_PANE }) + await vi.waitFor(() => expect(providerFaults.turnStarts).toBe(2), WAIT) + const revived = connectionFor(COORDINATOR) + expect(turnText(revived.turns.at(-1)!)).toBe( + formatMessagePointer(2, `run:${runId}`, localOrchestrationCliCommand()).trim() + ) + await settleTurn(COORDINATOR, revived.turns.length - 1) + await vi.waitFor( + () => expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toEqual([]), + WAIT + ) + expect(providerFaults.starts - before).toBe(1) + expect(providerFaults.turnStarts).toBe(2) + }) + + it('leaves a pointer the person stopped while its agent was starting stopped', async () => { + await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + await host.close(COORDINATOR) + providerFaults.startDelayMs = 400 + const before = providerFaults.starts + await finishWorker(taskId) + await vi.waitFor(() => expect(providerFaults.starts).toBe(before + 1), WAIT) + // The person presses Stop while the agent is still starting. + const cancelled = await host.cancel( + { callerKey: 'test-surface' }, + { + envelope: { + sessionId: COORDINATOR, + clientOperationId: operationId(), + expectedRuntimeFence: host.deps.store.getRecord(COORDINATOR)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: COORDINATOR, + fields: { turnId: 'turn-x' } + }) + }, + turnId: 'turn-x' + } + ) + expect(cancelled).toMatchObject({ ok: true }) + providerFaults.startDelayMs = 0 + // A fixed window, not a poll: a re-point would start the agent again in it. + await new Promise((resolve) => setTimeout(resolve, 1_500)) + expect(providerFaults.starts - before).toBe(1) + expect(await pointerSends()).toHaveLength(1) + await edgesAnswered() + expect(providerFaults.starts - before).toBe(1) + expect(providerFaults.turnStarts).toBe(0) + expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toHaveLength(1) + }) + + it('points a held pointer once more after Orca restarts, under a new id', async () => { + await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + providerFaults.dieBeforeEveryEcho = true + await finishWorker(taskId) + await vi.waitFor(() => expect(providerFaults.turnStarts).toBe(1), WAIT) + await edgesAnswered() + const [held] = await pointerSends() + + // The next process: a fresh runtime over the same database redrives restored mail. The + // provider still dies, so exactly one start proves it is pointed once, not in a loop. + runtime = startRuntime() + dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) + const before = providerFaults.starts + await vi.waitFor(() => expect(providerFaults.turnStarts).toBe(2), WAIT) + await new Promise((resolve) => setTimeout(resolve, 1_500)) + await edgesAnswered() + expect(providerFaults.starts - before).toBe(1) + expect(providerFaults.turnStarts).toBe(2) + const sends = await pointerSends() + expect(sends).toHaveLength(2) + expect(sends[0]).toBe(held) + expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toHaveLength(1) + }) + + /** Mail for a chat whose agent is stopped and whose every start is refused; resolves after it. */ + async function refusedStartsFor( + refusal: () => Error + ): Promise<{ runId: string; starts: number }> { + await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + await host.close(COORDINATOR) + providerFaults.refuseStart = refusal + const before = providerFaults.starts + await finishWorker(taskId) + await vi.waitFor(() => expect(providerFaults.starts).toBe(before + 1), WAIT) + await vi.waitFor( + () => expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toHaveLength(1), + WAIT + ) + // A fixed window, not a poll: a retry loop would start it several times in it. + await new Promise((resolve) => setTimeout(resolve, 1_500)) + return { runId, starts: providerFaults.starts - before } + } + + it.each([ + [ + 'one the person must fix', + () => new AgentSessionAcquisitionRefusal('no login', 'notSignedIn') + ], + [ + 'an account switch in progress', + () => + new AgentSessionPreSpawnError(new Error('switching accounts'), { + reason: 'accountSwitchInProgress' + }) + ] + ])( + 'holds mail after a start refused as %s until a turn runs, adding nothing on later edges', + async (_label, refusal) => { + const { runId, starts } = await refusedStartsFor(refusal) + expect(starts).toBe(1) + // Later edges replay the refusal: no start, and no new pointer and failure rows in the chat. + const before = providerFaults.starts + for (let edge = 0; edge < 5; edge += 1) { + runtime.onStructuredSessionStatusForMail({ sessionId: COORDINATOR, status: 'idle' }) + await new Promise((resolve) => setTimeout(resolve, 100)) + } + await edgesAnswered() + expect(providerFaults.starts).toBe(before) + expect(await pointerSends()).toHaveLength(1) + expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toHaveLength(1) + + // Fixed, the person's next message starts the agent, and the pointer follows its turn. + providerFaults.refuseStart = null + expect(await sendUserMessage(COORDINATOR, 'again')).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(providerFaults.starts).toBe(before + 1), WAIT) + const revived = connectionFor(COORDINATOR) + await vi.waitFor(() => expect(revived.turns.length).toBeGreaterThanOrEqual(1), WAIT) + expect(revived.turns[0]!.text).toContain('again') + await settleTurn(COORDINATOR, 0) + await vi.waitFor(() => expect(revived.turns).toHaveLength(2), WAIT) + expect(revived.turns[1]!.text).toMatch(POINTER) + } + ) + + it('points held mail after the next turn that runs, even once a rewind dropped its send', async () => { + const chat = await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + providerFaults.crashOnTurnStart = 'exit-then-throw' + await finishWorker(taskId) + await vi.waitFor(() => expect(providerFaults.turnStarts).toBe(1), WAIT) + await edgesAnswered() + // What a rewind's recovery does: the journal is rebuilt, and no send is on record any more. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the host keeps its conversations private; this is the one journal call rewind recovery makes. + const open = (host as unknown as { sessions: Map }) + .sessions + const fence = host.deps.store.getRecord(COORDINATOR)!.lease.runtimeFence + await open.get(COORDINATOR)!.journal.replaceEpochItems('handle_forked', fence, []) + expect(await pointerSends()).toEqual([]) + + providerFaults.crashOnTurnStart = 'off' + expect(await sendUserMessage(COORDINATOR, 'again')).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(providerFaults.turnStarts).toBe(2), WAIT) + const revived = connectionFor(COORDINATOR) + expect(revived).not.toBe(chat) + await settleTurn(COORDINATOR, revived.turns.length - 1) + await vi.waitFor(() => expect(turnText(revived.turns.at(-1)!)).toMatch(POINTER), WAIT) + await settleTurn(COORDINATOR, revived.turns.length - 1) + await vi.waitFor( + () => expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toEqual([]), + WAIT + ) + }) + + it('points again under a new id once a send the host never recorded is too old to admit', async () => { + const chat = await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + // The first pointer is refused before the host records it, so the journal holds no verdict. + const realSend = host.send + const refused = vi.spyOn(host, 'send').mockImplementationOnce(async () => ({ + ok: false as const, + refusal: refuse( + 'agent_session_operation_invalid', + { reason: 'conversationCommandInFlight' }, + 'busy' + ) + })) + refused.mockImplementation((caller, params) => realSend(caller, params)) + await finishWorker(taskId) + await vi.waitFor(() => expect(refused).toHaveBeenCalledTimes(1), WAIT) + const held = db.getStructuredPointerOperation(`run:${runId}`)?.operation_id + + // No edge for a day: the host would now refuse that id as expired, on every retry. + vi.useFakeTimers({ toFake: ['Date'] }) + try { + vi.setSystemTime(Date.now() + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + 60_000) + await edgesAnswered() + await new Promise((resolve) => setTimeout(resolve, 300)) + expect(chat.turns.map(turnText)).toEqual([ptyPointer(`run:${runId}`)]) + expect(db.getStructuredPointerOperation(`run:${runId}`)?.operation_id).not.toBe(held) + } finally { + vi.useRealTimers() + } + }) + + it('holds mail a refused turn left in doubt until the next result, then points it once', async () => { + // A failed turn/start cannot prove the turn never started, so the host records it `unknown` + // and a resend under its id replays that; new mail is a new send. + const chat = await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + const second = await call( + 'orchestration.taskCreate', + { spec: 'more' }, + { sessionId: COORDINATOR } + ) + providerFaults.refuseTurnStarts = 1 + const before = codex.connections.length + await finishWorker(taskId) + await vi.waitFor(() => expect(providerFaults.turnStarts).toBe(1), WAIT) + await edgesAnswered() + expect(chat.turns).toHaveLength(0) + + await finishWorker(idOf(second.task), { handle: 'term_worker_2', paneKey: WORKER_2_PANE }) + await vi.waitFor(() => expect(chat.turns).toHaveLength(1), WAIT) + expect(turnText(chat.turns[0]!)).toBe( + formatMessagePointer(2, `run:${runId}`, localOrchestrationCliCommand()).trim() + ) + expect(codex.connections.length).toBe(before) + }) + + it('points a coordinator whose agent is not running through the send alone, which starts it', async () => { + await openChat(COORDINATOR) + const { taskId } = await coordinatorRunAndTask() + // What the idle sweep leaves of a chat nobody is looking at: agent stopped, no map entry. + await host.close(COORDINATOR) + expect(host.hasSession(COORDINATOR)).toBe(false) + const before = codex.connections.length + + await finishWorker(taskId) + + // Nothing holds or wakes the session first: the pointer's accepted send starts its agent. + await vi.waitFor(() => expect(codex.connections.length).toBe(before + 1), WAIT) + const revived = connectionFor(COORDINATOR) + await vi.waitFor(() => expect(revived.turns).toHaveLength(1), WAIT) + expect(revived.turns[0]!.text).toMatch(POINTER) + await settleTurn(COORDINATOR, 0) + expect(await userTexts(COORDINATOR)).toEqual([expect.stringMatching(POINTER)]) + }) + + it('points mail at the idle edge when it arrived mid-turn', async () => { + const chat = await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + const first = await host.send( + { callerKey: 'test-surface' }, + { + envelope: { + sessionId: COORDINATOR, + clientOperationId: operationId(), + expectedRuntimeFence: host.deps.store.getRecord(COORDINATOR)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: COORDINATOR, + fields: { + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] } + } + }) + }, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] } + } + ) + expect(first).toMatchObject({ ok: true }) + // Accepted, then delivered: the provider sees the turn once the host hands it over. + await vi.waitFor(() => expect(chat.turns).toHaveLength(1), WAIT) + const notify = (method: string, params: unknown) => + chat.handlers.onNotification?.(method, params) + notify('turn/started', { turn: { id: 'turn-1' } }) + notify('item/completed', { + item: { + type: 'userMessage', + id: 'echo-go', + clientId: chat.turns[0]!.clientUserMessageId, + content: [{ type: 'text', text: 'go' }] + } + }) + await host.flushStreamedEvents(COORDINATOR) + + await finishWorker(taskId) + await new Promise((resolve) => setTimeout(resolve, 20)) + // The coordinator is mid-turn, so nothing is folded into that turn. + expect(chat.turns).toHaveLength(1) + + notify('turn/completed', { turn: { id: 'turn-1' } }) + await host.flushStreamedEvents(COORDINATOR) + await vi.waitFor(() => expect(chat.turns).toHaveLength(2), WAIT) + expect(chat.turns[1]!.text).toMatch(POINTER) + expect(chat.turns[1]!.text).toContain(runId) + }) +}) + +describe('a /clear keeps the chat its orchestration address', () => { + it("delivers the conversation's Run to the session that continues it, and acts as it", async () => { + await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + const generation = db.getRunRaw(runId)!.consumer_generation + const successor = await clearChat(COORDINATOR) + const next = connectionFor(successor) + + await expect( + call('orchestration.runCurrent', {}, { sessionId: successor }) + ).resolves.toMatchObject({ run: { id: runId } }) + await finishWorker(taskId) + await vi.waitFor(() => expect(next.turns).toHaveLength(1), WAIT) + expect(next.turns[0]!.text).toMatch(POINTER) + await settleTurn(successor, 0) + await expect(call('orchestration.check', {}, { sessionId: successor })).resolves.toMatchObject({ + runId, + count: 1, + messages: [{ type: 'worker_done' }] + }) + // Nothing was rewritten: the Run is bound exactly as the first session bound it. + expect(db.getRunRaw(runId)).toMatchObject({ + coordinator_orca_session_id: COORDINATOR, + consumer_generation: generation + }) + }) + + it("stores the successor's own Run under the conversation's address, through a chain of clears", async () => { + await openChat(COORDINATOR) + const middle = await clearChat(COORDINATOR) + const created = await call( + 'orchestration.runCreate', + { objective: 'next' }, + { sessionId: middle } + ) + const runId = idOf(created.run) + expect(db.getRunRaw(runId)!.coordinator_orca_session_id).toBe(COORDINATOR) + const successor = await clearChat(middle) + runtime.onStructuredSessionStatusForMail({ sessionId: successor, status: 'idle' }) + + await expect( + call('orchestration.runCurrent', {}, { sessionId: successor }) + ).resolves.toMatchObject({ run: { id: runId } }) + expect(db.getRunRaw(runId)!.coordinator_orca_session_id).toBe(COORDINATOR) + // What it sends carries the same address. + await openChat(PEER_CHAT) + await expect( + call( + 'orchestration.send', + { to: `session:${PEER_CHAT}`, subject: 'hi' }, + { sessionId: successor } + ) + ).resolves.toMatchObject({ message: { from_handle: `session:${COORDINATOR}` } }) + }) + + it("binds a Run a cleared chat creates or uses to the conversation's root, at the Run's current generation", async () => { + const root = COORDINATOR + const boundOrcaSessionId = (runId: string): string | null => + currentRunCoordinatorOrcaSessionId(db.getRunRaw(runId)!) + await openChat(COORDINATOR) + const middle = await clearChat(COORDINATOR) + const first = idOf( + (await call('orchestration.runCreate', { objective: 'first' }, { sessionId: middle })).run + ) + expect(db.getRunRaw(first)).toMatchObject({ + coordinator_orca_session_id: root, + coordinator_orca_session_id_generation: db.getRunRaw(first)!.consumer_generation + }) + expect(boundOrcaSessionId(first)).toBe(root) + const second = idOf( + (await call('orchestration.runCreate', { objective: 'second' }, { sessionId: middle })).run + ) + expect(boundOrcaSessionId(first)).toBeNull() + + const successor = await clearChat(middle) + await call('orchestration.runUse', { id: first }, { sessionId: successor }) + const rebound = db.getRunRaw(first)! + expect(rebound.coordinator_orca_session_id).toBe(root) + expect(rebound.coordinator_orca_session_id_generation).toBe(rebound.consumer_generation) + expect(boundOrcaSessionId(second)).toBeNull() + await expect( + call('orchestration.runCurrent', {}, { sessionId: successor }) + ).resolves.toMatchObject({ run: { id: first } }) + }) + + it('lands mail sent to any session of the conversation in the live one', async () => { + await openChat(PEER_CHAT) + const middle = await clearChat(PEER_CHAT) + const successor = await clearChat(middle) + const next = connectionFor(successor) + + for (const [index, spelling] of [PEER_CHAT, middle, successor].entries()) { + const sent = await call('orchestration.send', { + from: 'term_worker', + to: `session:${spelling}`, + subject: `ping ${index}` + }) + expect(sent).toMatchObject({ message: { to_handle: `session:${PEER_CHAT}` } }) + await vi.waitFor(() => expect(next.turns).toHaveLength(index + 1), WAIT) + await settleTurn(successor, index) + } + await expect(call('orchestration.check', {}, { sessionId: successor })).resolves.toMatchObject({ + count: 3 + }) + }) +}) + +describe('any live session is addressable by its id', () => { + it('lands mail sent to `session:` as a turn in that chat, which a flagless check reads', async () => { + const peer = await openChat(PEER_CHAT) + + const sent = await call('orchestration.send', { + from: 'term_worker', + to: `session:${PEER_CHAT}`, + subject: 'ping' + }) + expect(sent).toMatchObject({ message: { to_handle: `session:${PEER_CHAT}` } }) + + await vi.waitFor(() => expect(peer.turns).toHaveLength(1), WAIT) + // Direct mail is not in a Run, so the pointer names no `--run`. + expect(turnText(peer.turns[0]!)).toBe(ptyPointer(`session:${PEER_CHAT}`)) + await settleTurn(PEER_CHAT, 0) + const checked = await call('orchestration.check', {}, { sessionId: PEER_CHAT }) + expect(checked).toMatchObject({ count: 1, messages: [{ subject: 'ping' }] }) + }) + + it('refuses mail to a chat that was closed, before storing it', async () => { + await openChat(PEER_CHAT) + await host.setSessionTabVisibility(PEER_CHAT, false) + const response = await dispatcher.dispatch( + request('orchestration.send', { + from: 'term_worker', + to: `session:${PEER_CHAT}`, + subject: 'ping' + }) + ) + expect(response).toMatchObject({ ok: false, error: { code: 'session_caller_not_live' } }) + expect(db.getInbox(100)).toEqual([]) + }) +}) diff --git a/src/main/runtime/structured-claude-auth-policy-wiring.test.ts b/src/main/runtime/structured-claude-auth-policy-wiring.test.ts index f018dfd30da..cc2b5c3b164 100644 --- a/src/main/runtime/structured-claude-auth-policy-wiring.test.ts +++ b/src/main/runtime/structured-claude-auth-policy-wiring.test.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { join } from 'node:path' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -10,28 +9,11 @@ import { } from './structured-agent-session-runtime' /** - * The structured host's Claude auth policy has exactly one production wiring, and it - * lives in `orca-runtime-get-worktree-ps.ts` — a `@ts-nocheck` file, so neither the - * compiler nor a type test can see the field disappear. Deleting that wiring used to - * leave ~1000 tests green while every `ANTHROPIC_*` variable in the shell reached the - * child, because `stripAuthEnv` silently fell back to `false`. - * - * Two independent guards replace that silence, and this file pins both. + * The structured host's Claude auth policy has exactly one production wiring. Deleting it + * used to leave ~1000 tests green while every `ANTHROPIC_*` variable in the shell reached + * the child, because `stripAuthEnv` silently fell back to `false`. */ describe('structured Claude auth policy wiring', () => { - // The behavioural version of this assertion — importing the runtime class and - // capturing the installed deps — costs 35s of module transform for the whole - // OrcaRuntime chain (measured), so the wiring itself is pinned by source and the - // policy's meaning by claude-structured-auth-policy.test.ts. - it('passes a settings-derived Claude auth policy to the host installer', () => { - const source = readFileSync(join(__dirname, 'orca-runtime-get-worktree-ps.ts'), 'utf8') - - expect(source).toContain('claudeStructuredAuthPolicyForSettings') - expect(source).toMatch( - /resolveClaudeAuthPolicy:\s*\(\)\s*=>\s*\n?\s*claudeStructuredAuthPolicyForSettings\(/ - ) - }) - describe('installing without one', () => { let stateDirectory: string | null = null diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index b0cb51eb183..761a6835a55 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -40,6 +40,7 @@ export type StructuredClaudeRuntimeAdapterDeps = { state: AgentSessionBackgroundTaskState | null ) => void onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate'] + onSessionIdle?: ClaudeStructuredSessionAdapterDeps['onSessionIdle'] onChildWorkEvidence?: ClaudeStructuredSessionAdapterDeps['onChildWorkEvidence'] } @@ -60,12 +61,12 @@ export function structuredClaudeLifecycleEvent( type: 'ended', sessionId: event.sessionId, reason: event.reason, + ...(event.failure ? { failure: event.failure } : {}), cause: event.cause, fence: event.fence, acquisitionGeneration: event.acquisitionGeneration, - ...(event.settlementRetryRequired - ? { settlementRetryRequired: event.settlementRetryRequired } - : {}), + // The instant the translator ended the open turn at; the host reads the exit's turn by it. + ...(event.observedAt === undefined ? {} : { observedAt: event.observedAt }), ...(event.startupUnproven ? { startupUnproven: event.startupUnproven } : {}) } } @@ -132,6 +133,7 @@ export function createStructuredClaudeRuntimeAdapter( ? { onBackgroundTasksChanged: deps.onBackgroundTasksChanged } : {}), ...(deps.onDispatchSettledLate ? { onDispatchSettledLate: deps.onDispatchSettledLate } : {}), + ...(deps.onSessionIdle ? { onSessionIdle: deps.onSessionIdle } : {}), ...(deps.onChildWorkEvidence ? { onChildWorkEvidence: deps.onChildWorkEvidence } : {}), ...(deps.openClaudeConnection ? { openConnection: deps.openClaudeConnection } : {}), ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), diff --git a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts index af1bc9a4af7..07de5e58761 100644 --- a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts +++ b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts @@ -1,6 +1,7 @@ // A structured-session runtime whose Claude children are scripted: the production runtime, // adapter, record store and host, with only the CLI process replaced. +import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' import { mkdir, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -20,6 +21,10 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +/** The error a real CLI's exit reaches the adapter as: Orca's message, the stderr as a log detail. */ +export function scriptedClaudeExitError(diagnostic: string): Error { + return withProviderDiagnostic(new Error(diagnostic), providerDiagnostic(diagnostic, 'log')) +} export type ScriptedClaudeBehavior = { /** Initialize never answers; only the child's exit settles it. */ initHangs?: boolean @@ -37,6 +42,11 @@ export type ScriptedClaudeBehavior = { optionWritesHang?: boolean /** Startup's own settings read goes unanswered. */ startupSettingsReadHangs?: boolean + /** Every option write loses its answer while the CLI keeps running (not a refusal), so a + * start that restores one faults. */ + optionWritesFail?: boolean + /** The init frame names another provider session than the one launched. */ + initNamesForeignSession?: boolean } export type ScriptedClaudeChild = { @@ -86,7 +96,13 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { const never = (): Promise => new Promise(() => {}) const optionWrite = (subtype: string): Promise => { child.calls.push(subtype) - return control(subtype, () => (behavior.optionWritesHang ? never() : Promise.resolve())) + return control(subtype, () => + behavior.optionWritesFail + ? Promise.reject(new Error('Query closed before response received')) + : behavior.optionWritesHang + ? never() + : Promise.resolve() + ) } let settingsReads = 0 const child: ScriptedClaudeChild = { @@ -110,7 +126,7 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { handlers.onMessage?.({ type: 'system', subtype: 'init', - session_id: providerSessionId, + session_id: behavior.initNamesForeignSession ? 'foreign-session' : providerSessionId, model: 'claude-sonnet-5', apiKeySource: 'none' }) @@ -156,7 +172,7 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { setPermissionMode: () => optionWrite('set_permission_mode'), applyFlagSettings: () => optionWrite('apply_flag_settings'), interrupt: async () => undefined, - cancelAsyncMessage: async () => {}, + cancelAsyncMessage: async () => false, stopTask: async () => {}, send: async () => { child.calls.push('send') @@ -175,7 +191,7 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { } const exitAtSpawn = (child: ScriptedClaudeChild, diagnostic: string): void => { child.connection.closed = true - child.exit(new Error(diagnostic)) + child.exit(scriptedClaudeExitError(diagnostic)) } /** A pid whose process is gone has no start time to read. */ const readProcessStartTime = async (pid: number): Promise => { diff --git a/src/main/runtime/structured-codex-child-work-runtime.test.ts b/src/main/runtime/structured-codex-child-work-runtime.test.ts new file mode 100644 index 00000000000..d03ab6f1be5 --- /dev/null +++ b/src/main/runtime/structured-codex-child-work-runtime.test.ts @@ -0,0 +1,133 @@ +// The production runtime hands a Codex session's child work to the status sink, under the address +// the session's own row landed under, and ends it with the provider. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + openCodexAppServerConnection +} from '../codex/codex-app-server-connection' +import { + HOST_TEST_SESSION as SESSION, + hostTestAttachParams +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { StructuredAgentSessionStatusSink } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +const THREAD = 'thread-runtime-child-work' +const CHILD = 'thread-runtime-reviewer' +const ROUTES: Record = { + 'thread/start': { thread: { id: THREAD } }, + 'model/list': { + data: [ + { + model: 'gpt-test', + displayName: 'GPT Test', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + isDefault: true + } + ], + nextCursor: null + } +} + +describe('structured Codex child work through the production runtime', () => { + let root: string | null = null + + afterEach(async () => { + await stopStructuredAgentSessionRuntime() + if (root) { + await rm(root, { recursive: true, force: true }) + root = null + } + }) + + it("hands its subagents to the status sink under the session's own address", async () => { + root = await mkdtemp(join(tmpdir(), 'orca-runtime-codex-child-work-')) + const connections: CodexAppServerConnectionHandlers[] = [] + const openConnection: typeof openCodexAppServerConnection = async (_launch, handlers = {}) => { + connections.push(handlers) + const connection: CodexAppServerConnection = { + pid: 4321, + closed: false, + request: async (method) => (method in ROUTES ? ROUTES[method] : {}), + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => true + } + return connection + } + const childWork: Parameters< + NonNullable + >[] = [] + const host = await ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root!, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveCodexCommand: () => 'codex', + resolveEnvironment: async () => ({ PATH: process.env.PATH }), + openCodexConnection: openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + statusSink: { + publish: () => {}, + forget: () => {}, + publishChildWork: (...args) => childWork.push(args) + } + }) + const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) + attachParams.envelope.clientOperationId = `${Date.now()}-${'1'.padStart(32, '0')}` + const attached = await host.attach({ callerKey: 'runtime-test' }, attachParams) + expect(attached).toMatchObject({ ok: true }) + // Creating the session starts its child; nothing else has to keep it running. + expect(connections).toHaveLength(1) + const notify = (method: string, params: Record) => + connections[0]?.onNotification?.(method, params) + notify('turn/started', { threadId: THREAD, turn: { id: 'turn-1' } }) + notify('turn/started', { threadId: CHILD, turn: { id: 'child-turn-1' } }) + notify('item/started', { + threadId: THREAD, + turnId: 'turn-1', + item: { + type: 'subAgentActivity', + id: 'spawn-1', + kind: 'started', + agentThreadId: CHILD, + agentPath: '/root/review' + } + }) + const subject = expect.objectContaining({ kind: 'structured-session', sessionId: SESSION }) + expect(childWork).toEqual([ + [ + subject, + [ + expect.objectContaining({ + type: 'live', + child: expect.objectContaining({ + handle: { idKind: 'thread_id', id: CHILD, runId: 'child-turn-1' }, + description: 'review' + }) + }) + ], + 'codex' + ] + ]) + // The provider dies: its session's end is reported under the same address. + connections[0]?.onExit?.(new Error('scripted provider exit')) + expect(childWork.at(-1)).toEqual([ + subject, + [expect.objectContaining({ type: 'session-ended' })], + 'codex' + ]) + }) +}) diff --git a/src/main/runtime/structured-codex-session-rpc-test-harness.ts b/src/main/runtime/structured-codex-session-rpc-test-harness.ts new file mode 100644 index 00000000000..6b9024ba029 --- /dev/null +++ b/src/main/runtime/structured-codex-session-rpc-test-harness.ts @@ -0,0 +1,278 @@ +// One structured Codex session driven over `agentSession.*`, with nothing stubbed but the Codex +// child: the RPC dispatcher, schemas, record store, journal, lease, Codex adapter and translation +// are the ones that ship. The fake app-server answers the JSON-RPC calls the real one does, and a +// test pushes notifications, blocking requests and the child's exit back through its handlers. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, vi } from 'vitest' +import type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + openCodexAppServerConnection +} from '../codex/codex-app-server-connection' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + type RuntimeCapability +} from '../../shared/protocol-version' +import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' +import type { OrcaRuntimeService } from './orca-runtime' +import type { RpcRequest, RpcResponse } from './rpc/core' +import { RpcDispatcher } from './rpc/dispatcher' +import { STRUCTURED_AGENT_SESSION_METHODS } from './rpc/methods/structured-agent-session' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +export const SESSION = 'session-integration-1' +export const THREAD = 'thread-integration' +export const TURN = 'turn-1' +const WORKSPACE = 'workspace-1' +// Without the pending-send capability the host holds the reply until the send settles, which is a +// shim for clients too old to render a pending bubble — not what these suites model. +const DEFAULT_CLIENT_CAPABILITIES: readonly RuntimeCapability[] = [ + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +] + +// `closed` is readonly on the real connection; the fake flips it so a test can +// see a takeover reap the previous child. +export type FakeCodexConnection = Omit & { + closed: boolean + handlers: CodexAppServerConnectionHandlers + calls: { method: string; params?: Record }[] + replies: { id: number | string; result?: unknown; code?: number }[] + resumedThreadId: string | null + launch: Parameters[0] +} + +export type FakeCodex = { + connections: FakeCodexConnection[] + openConnection: typeof openCodexAppServerConnection + live: () => FakeCodexConnection + notify: (method: string, params: unknown) => void + ask: (id: number, method: string, params: unknown) => void +} + +function fakeCodex(): FakeCodex { + const connections: FakeCodexConnection[] = [] + const openConnection: typeof openCodexAppServerConnection = async (launch, handlers = {}) => { + const connection: FakeCodexConnection = { + launch, + handlers, + calls: [], + replies: [], + resumedThreadId: null, + pid: 4321, + closed: false, + request: async (method, params) => { + connection.calls.push({ method, params }) + if (method === 'thread/start') { + return { thread: { id: THREAD, path: '/rollouts/integration.jsonl' } } + } + if (method === 'thread/resume') { + connection.resumedThreadId = (params as { threadId: string }).threadId + return { thread: { id: connection.resumedThreadId } } + } + if (method === 'turn/start') { + return { turn: { id: TURN } } + } + if (method === 'model/list') { + return { + data: [ + { + model: 'gpt-live', + displayName: 'GPT Live', + hidden: false, + supportedReasoningEfforts: [ + { reasoningEffort: 'medium', description: 'Balanced' }, + { reasoningEffort: 'high', description: 'Deep reasoning' } + ], + defaultReasoningEffort: 'medium', + isDefault: true + } + ], + nextCursor: null + } + } + return {} + }, + notify: () => {}, + respond: (id, result) => connection.replies.push({ id, result }), + respondWithError: (id, code) => connection.replies.push({ id, code }), + close: async () => { + connection.closed = true + return true + } + } + connections.push(connection) + return connection + } + const live = (): FakeCodexConnection => { + const connection = connections.at(-1) + if (!connection) { + throw new Error('no codex app-server has been opened') + } + return connection + } + return { + connections, + openConnection, + live, + notify: (method, params) => live().handlers.onNotification?.(method, params), + ask: (id, method, params) => live().handlers.onServerRequest?.({ id, method, params }) + } +} + +function attachParams(operationId: () => string, fence: number | null) { + const params = { + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKSPACE, + workspaceKind: 'git-worktree' as const + }, + provider: 'codex' as const, + agent: 'codex', + accountHome: { variable: 'CODEX_HOME' as const, path: '/home/dev/.codex' }, + runtimeKind: 'native' as const, + providerHandle: { kind: 'codex' as const, threadId: THREAD } + } + const envelope = { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: '' + } + return { + ...params, + envelope: { + ...envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields({ ...params, envelope } as never) + }) + } + } +} + +export type StructuredCodexRpcHarness = { + codex: FakeCodex + root: string + hostConfig: () => Parameters[0] + envelope: ( + method: string, + fields: Record, + fence: number | null + ) => { sessionId: string; clientOperationId: string; expectedRuntimeFence: number | null } + createIntentParams: () => Record + /** Runs a one-shot method and returns its decoded reply. */ + call: (method: string, params: unknown) => Promise + /** Asserts success and unwraps the host's `{ok:true, value}` mutation result. */ + ok: (method: string, params: unknown) => Promise + dispose: () => Promise +} + +export async function openStructuredCodexRpcHarness( + clientCapabilities: readonly RuntimeCapability[] = DEFAULT_CLIENT_CAPABILITIES +): Promise { + const client = { clientId: 'device-a', clientKind: 'runtime' as const, clientCapabilities } + let operations = 0 + const root = await mkdtemp(join(tmpdir(), 'orca-structured-integration-')) + const codex = fakeCodex() + // `<13-digit ms>-<32 hex>`, the only shape the durable ledger accepts. Real time, not a frozen + // constant: the runtime under test stamps the ledger with its own clock and refuses a future id. + const operationId = (): string => { + operations += 1 + return `${Date.now()}-${operations.toString(16).padStart(32, '0')}` + } + const envelope: StructuredCodexRpcHarness['envelope'] = (method, fields, fence) => ({ + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + }) + const hostConfig = (): Parameters[0] => ({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, + resolveCodexCommand: () => '/usr/local/bin/codex', + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveEnvironment: async () => ({ + PATH: '/shell/bin:/usr/bin', + EXAMPLE_GATEWAY_TOKEN: 'shell-exported', + CODEX_HOME: '/shell/home' + }), + resolveCodexOverrides: () => ({ CODEX_PROFILE: 'configured' }), + openCodexConnection: codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000 + }) + const runtime = { + getRuntimeId: () => 'runtime-1', + getClientSettings: () => ({ experimentalStructuredNativeChat: true }), + getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), + resolveStructuredAgentSessionCreateIntent: async () => { + const { + envelope: _envelope, + providerHandle: _providerHandle, + ...resolved + } = attachParams(operationId, null) + return resolved + }, + publishStructuredAgentSessionTab: () => {}, + ensureStructuredAgentSessionHost: () => + ensureStructuredAgentSessionHost(hostConfig()).then(() => undefined), + registerOwnedSubscriptionCleanup: vi.fn((_id: string, dispose: () => void) => ({ + releaseIfCurrent: dispose + })) + } + const dispatcher = new RpcDispatcher({ + runtime: runtime as unknown as OrcaRuntimeService, + methods: STRUCTURED_AGENT_SESSION_METHODS + }) + const call: StructuredCodexRpcHarness['call'] = async (method, params) => { + const replies: RpcResponse[] = [] + const request: RpcRequest = { id: `req-${operations}`, authToken: 'token', method, params } + await dispatcher.dispatchStreaming(request, (raw) => replies.push(JSON.parse(raw)), client) + const first = replies[0] + if (!first) { + throw new Error(`no reply for ${method}`) + } + return first + } + return { + codex, + root, + hostConfig, + envelope, + createIntentParams: () => { + const worktree = `id:${WORKSPACE}` + const fields = { worktree, agent: 'codex' } + return { envelope: envelope('agentSession.create', fields, null), ...fields } + }, + call, + ok: async (method: string, params: unknown): Promise => { + const response = await call(method, params) + expect(response, `${method} failed: ${JSON.stringify(response)}`).toMatchObject({ ok: true }) + const result = (response as { result: { ok: boolean; value?: T; refusal?: unknown } }).result + expect(result, `${method} refused: ${JSON.stringify(result.refusal)}`).toMatchObject({ + ok: true + }) + return result.value as T + }, + dispose: async () => { + await stopStructuredAgentSessionRuntime() + await rm(root, { recursive: true, force: true }) + } + } +} diff --git a/src/main/runtime/structured-session-child-identity-env.test.ts b/src/main/runtime/structured-session-child-identity-env.test.ts new file mode 100644 index 00000000000..1289e979ad6 --- /dev/null +++ b/src/main/runtime/structured-session-child-identity-env.test.ts @@ -0,0 +1,193 @@ +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' + +const shim = vi.hoisted(() => ({ ensureLinuxTerminalOrcaCliShimDir: vi.fn() })) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => shim) + +import { structuredSessionChildIdentityEnv } from './structured-session-child-identity-env' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerHostScope, + structuredWorkerIdentities, + structuredWorkerProcessIncarnation +} from './structured-worker-identity' + +const SESSION_ID = 'f7a1c0de-1111-4222-8333-444455556666' +const USER_DATA = '/data/orca' +const RESOURCES = '/app/Resources' +const SHIM_DIR = join(USER_DATA, 'linux-orca-cli-shim') + +const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')! +const resourcesDescriptor = Object.getOwnPropertyDescriptor(process, 'resourcesPath') + +function pinPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) +} + +function registerWorker(): string { + const handle = mintStructuredWorkerHandle() + structuredWorkerIdentities.register({ + handle, + sessionId: SESSION_ID, + agent: 'claude', + paneKey: mintStructuredWorkerPaneKey(SESSION_ID), + processIncarnation: structuredWorkerProcessIncarnation(SESSION_ID), + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } + }) + return handle +} + +beforeEach(() => { + shim.ensureLinuxTerminalOrcaCliShimDir.mockReset() + shim.ensureLinuxTerminalOrcaCliShimDir.mockReturnValue(SHIM_DIR) + Object.defineProperty(process, 'resourcesPath', { configurable: true, value: RESOURCES }) +}) + +afterEach(() => { + structuredWorkerIdentities.clear() + Object.defineProperty(process, 'platform', platformDescriptor) + if (resourcesDescriptor) { + Object.defineProperty(process, 'resourcesPath', resourcesDescriptor) + } else { + Reflect.deleteProperty(process, 'resourcesPath') + } +}) + +describe('structuredSessionChildIdentityEnv', () => { + it("gives an ordinary chat session its own id and this app's CLI, and no terminal identity", () => { + // The id names the caller, so a bare `orca orchestration check` acts as this session instead of + // guessing a terminal — every guess landed on a sibling pane, and `check` consumed its mail. + pinPlatform('linux') + installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) + const childEnv = { PATH: '/usr/bin' } + const env = structuredSessionChildIdentityEnv(SESSION_ID, childEnv) + expect(env).toEqual({ + PATH: `${SHIM_DIR}:/usr/bin`, + ORCA_AGENT_SESSION_ID: SESSION_ID, + // For a CLI that predates the id, which refuses on it instead of guessing a sibling. + ORCA_STRUCTURED_SESSION: '1', + ORCA_CLI_COMMAND: join(SHIM_DIR, 'orca'), + // The instance that minted the id, so any current CLI dials it rather than the default. + ORCA_USER_DATA_PATH: USER_DATA + }) + // A chat names itself by its id alone: no handle, no pane key. + expect(env.ORCA_TERMINAL_HANDLE).toBeUndefined() + expect(env.ORCA_PANE_KEY).toBeUndefined() + expect(childEnv).toEqual({ PATH: '/usr/bin' }) + }) + + it('replaces an id inherited from an Orca launched inside another session', () => { + installFakeAppEnvironment({ isPackaged: () => false, getPath: () => USER_DATA }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { + ORCA_AGENT_SESSION_ID: 'a0b1c2d3-0000-4000-8000-00000000abcd', + PATH: '/usr/bin' + }) + expect(env.ORCA_AGENT_SESSION_ID).toBe(SESSION_ID) + }) + + it('gives a structured worker its id and keeps the handle it was minted', () => { + // For orchestration the id wins and the host maps it back to this handle, so the worker keeps + // one identity; the handle stays for the handle-based surfaces outside orchestration. + installFakeAppEnvironment({ isPackaged: () => false, getPath: () => USER_DATA }) + const handle = registerWorker() + const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) + expect(env.ORCA_AGENT_SESSION_ID).toBe(SESSION_ID) + expect(env.ORCA_TERMINAL_HANDLE).toBe(handle) + }) + + describe.each(['chat', 'worker'] as const)("reaches this app's CLI as a %s", (kind) => { + beforeEach(() => { + if (kind === 'worker') { + registerWorker() + } + }) + + it('on packaged Linux, through the bare-orca shim, named by absolute path', () => { + // Without this the child's first `orca orchestration check` execs GNOME Orca — the CLI + // installs as `orca-ide` on Linux (stablyai/orca#7904) — and the dispatch hangs to timeout. + pinPlatform('linux') + installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin:/bin' }) + expect(env.ORCA_CLI_COMMAND).toBe(join(SHIM_DIR, 'orca')) + expect(env.PATH).toBe(`${SHIM_DIR}:/usr/bin:/bin`) + }) + + it('on packaged macOS, through the bundled CLI dir', () => { + pinPlatform('darwin') + installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) + expect(env.PATH).toBe(`${join(RESOURCES, 'bin')}:/usr/bin`) + expect(env.ORCA_CLI_COMMAND).toBe(join(RESOURCES, 'bin', 'orca')) + }) + + it('on packaged Windows, through the bundled CLI dir under the env block spelling', () => { + pinPlatform('win32') + installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { Path: 'C:\\Windows' }) + expect(env.Path).toBe(`${join(RESOURCES, 'bin')};C:\\Windows`) + expect(env.PATH).toBeUndefined() + // The native launcher: `orca.cmd` refuses message bodies cmd.exe would mangle. + expect(env.ORCA_CLI_COMMAND).toBe(join(RESOURCES, 'bin', 'orca.exe')) + }) + + it('unpackaged, through the dev launcher dir', () => { + pinPlatform('darwin') + installFakeAppEnvironment({ isPackaged: () => false, getPath: () => USER_DATA }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) + expect(env.PATH).toBe(`${join(USER_DATA, 'cli', 'bin')}:/usr/bin`) + expect(env.ORCA_CLI_COMMAND).toBe(join(USER_DATA, 'cli', 'bin', 'orca-dev')) + }) + }) + + it('omits the CLI command when no launcher resolves, never naming a bare `orca`', () => { + // On packaged Linux the shim can fail to resolve (no bundled launcher, an unverified AppImage); + // a bare `orca` there is GNOME's screen reader, and an inherited value names another app's CLI. + pinPlatform('linux') + installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) + shim.ensureLinuxTerminalOrcaCliShimDir.mockReturnValue(null) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { + PATH: '/usr/bin', + ORCA_CLI_COMMAND: '/Applications/Other Orca.app/Contents/Resources/bin/orca', + ORCA_USER_DATA_PATH: '/data/other-orca' + }) + expect(env).not.toHaveProperty('ORCA_CLI_COMMAND') + expect(env.PATH).toBe('/usr/bin') + expect(env.ORCA_USER_DATA_PATH).toBe(USER_DATA) + expect(console.warn).toHaveBeenCalledOnce() + }) + + it('never puts a pane key in the child environment', () => { + // A pane key here flows into hook-emitted agent statuses and the attestation, agent-row and + // mobile-projection pipelines, all of which assume it names a live PTY leaf. + pinPlatform('linux') + installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) + registerWorker() + const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) + expect(env.ORCA_PANE_KEY).toBeUndefined() + expect(Object.keys(env).filter((key) => key.includes('PANE'))).toEqual([]) + }) + + it('never names the WSL-scoped launcher, because a structured worker cannot run in WSL', () => { + // `orca-ide` is the literal the PTY lane exports for WSL only. A structured session that + // resolves to a WSL distro is refused a host scope, so it never becomes a worker at all — + // which is why the bare-`orca` shim, not the literal, is the right fix on Linux. + expect( + structuredWorkerHostScope({ + executionHostId: 'local', + workspaceId: 'wt_1', + workspaceKind: 'git-worktree', + wslDistro: 'Ubuntu' + }) + ).toBeNull() + pinPlatform('linux') + installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) + registerWorker() + expect( + structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }).ORCA_CLI_COMMAND + ).not.toBe('orca-ide') + }) +}) diff --git a/src/main/runtime/structured-session-child-identity-env.ts b/src/main/runtime/structured-session-child-identity-env.ts new file mode 100644 index 00000000000..4d3e85c3414 --- /dev/null +++ b/src/main/runtime/structured-session-child-identity-env.ts @@ -0,0 +1,93 @@ +/** + * The orchestration identity — and the CLI reachability — a structured session's own child needs to + * speak for itself. Both providers' native launches (Claude, Codex) build their child env here. + * + * Every structured session carries `ORCA_AGENT_SESSION_ID`, the id Orca minted for it — never the + * provider's, which rotates on `/clear`. The CLI sends it as the caller, so a bare + * `orca orchestration check` acts as this session instead of guessing a terminal: with no pane of + * its own, every guess landed on a sibling, and a destructive `check` consumed that sibling's mail. + * Identity by session id assumes one machine and one user; crossing a host boundary (SSH, a paired + * peer) re-opens that decision, and the host refuses a session claim from across one. + * + * A dispatched structured worker also keeps the `structworker_` handle it was minted, for the + * handle-based surfaces outside orchestration; for orchestration the id wins and the host maps it + * back to that handle, so the worker keeps one identity. + * + * The PATH prepend below makes bare `orca` this app's CLI, the SAME function `buildPtyHostEnv` + * applies, rather than a second, drifting copy of the rule. Orca's Linux CLI installs as `orca-ide` + * so it never claims GNOME Orca's /usr/bin/orca (stablyai/orca#7904), and on packaged macOS/Windows + * the bundled launcher is reachable only from the app's own resources dir. + * + * `ORCA_CLI_COMMAND` names that same launcher by absolute path, because a provider can run each + * command in a login shell (Codex runs `zsh -lc`) whose profile rebuilds PATH and puts a global + * install ahead of this app's. A bare `orca` that reaches another install still acts as this + * session: any current CLI sends the injected id and dials the instance `ORCA_USER_DATA_PATH` pins + * below, and a CLI that predates the id refuses on the marker. When no launcher resolves the key + * is omitted rather than naming a bare `orca`: on Linux that is GNOME's screen reader, and an + * inherited value names another app. + * + * `ORCA_USER_DATA_PATH` pins this instance beside the identity, so any current CLI — the session's + * own or a global one — dials the Orca that minted the id instead of the production default. + * + * Deliberately NOT `ORCA_PANE_KEY`. Claude structured sessions run hooks, and a pane key in their + * environment starts flowing into hook-emitted agent-status payloads and the hook-attestation, + * agent-row and mobile-projection pipelines, every one of which assumes a pane key names a live + * PTY leaf. It would also open `selectExactWorkerProviderSession`, which is fail-closed today + * precisely because a structured session emits no hook agent status. + * + * `ORCA_STRUCTURED_SESSION` stays beside the id for a CLI that predates it — one reached through a + * global install when a shell rc resets PATH — which would otherwise guess a sibling's terminal; + * such a CLI refuses on the marker. A current CLI checks the id first, so the marker never makes a + * session with an id identity-less. + * + * The handle is read from the registry at spawn time, so an in-host recovery respawn re-bakes the + * SAME handle rather than a stale or fresh one. + */ + +import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' +import { ORCA_AGENT_SESSION_ID_ENV } from '../../shared/agent-session-caller-env' +import { ORCA_STRUCTURED_SESSION_ENV } from '../../shared/structured-session-marker' +import { prependOrcaCliDirToChildPath } from '../cli/orca-cli-child-path' +import { structuredWorkerIdentities } from './structured-worker-identity' + +export function structuredSessionChildIdentityEnv( + sessionId: string, + childEnv: Record +): Record { + const identity = structuredWorkerIdentities.getBySessionId(sessionId) + const env: Record = { + ...childEnv, + ...(identity ? { ORCA_TERMINAL_HANDLE: identity.handle } : {}), + [ORCA_AGENT_SESSION_ID_ENV]: sessionId, + [ORCA_STRUCTURED_SESSION_ENV]: '1' + } + applyThisAppCli(env) + return env +} + +/** + * A host with no app environment installed — a plain-Node fork, or a unit test — has no userData + * root to resolve, and inventing one would write a shim into the wrong directory. + */ +function applyThisAppCli(env: Record): void { + delete env.ORCA_CLI_COMMAND + if (!hasAppEnvironment()) { + return + } + const app = getAppEnvironment() + const userDataPath = app.getPath('userData') + const isPackaged = app.isPackaged() + env.ORCA_USER_DATA_PATH = userDataPath + const launcher = prependOrcaCliDirToChildPath(env, { + isPackaged, + userDataPath, + resourcesPath: process.resourcesPath ?? null + }) + if (launcher) { + env.ORCA_CLI_COMMAND = launcher + } else { + console.warn( + "[structured-session] This app's CLI launcher did not resolve; the session's child has no ORCA_CLI_COMMAND." + ) + } +} diff --git a/src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts b/src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts new file mode 100644 index 00000000000..c5a9eb769f1 --- /dev/null +++ b/src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts @@ -0,0 +1,34 @@ +/** + * The zsh arm of `structured-session-cli-login-shell.test.ts`: Codex's own shell on macOS. Runs in + * the real-shell lane, which installs zsh; the ordinary unit lane has none. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + createLoginShellHarness, + type LoginShellHarness +} from './structured-session-login-shell-test-harness' + +describe.runIf(process.platform !== 'win32')('a structured session in a zsh login shell', () => { + let harness: LoginShellHarness + + beforeEach(() => { + harness = createLoginShellHarness() + }) + + afterEach(() => { + harness.dispose() + }) + + it("resolves this app's CLI through ORCA_CLI_COMMAND in `zsh -lc`", async () => { + // Positive control: the profile really does put the global install first for a bare name. + expect(await harness.run({ program: '/bin/zsh', args: ['-lc', 'orca'] })).toBe('global') + expect(await harness.run({ program: '/bin/zsh', args: ['-lc', '"$ORCA_CLI_COMMAND"'] })).toBe( + 'app' + ) + }) + + it("keeps bare `orca` this app's CLI in a zsh that reads no login profile", async () => { + expect(await harness.run({ program: '/bin/zsh', args: ['-c', 'orca'] })).toBe('app') + }) +}) diff --git a/src/main/runtime/structured-session-cli-login-shell.test.ts b/src/main/runtime/structured-session-cli-login-shell.test.ts new file mode 100644 index 00000000000..2079dc44eb6 --- /dev/null +++ b/src/main/runtime/structured-session-cli-login-shell.test.ts @@ -0,0 +1,38 @@ +/** + * A provider can run every command in a login shell: Codex runs ` -lc `. The login + * profile rebuilds PATH — macOS's path_helper, a user's profile — so the directory Orca prepended + * ends up behind a global install, and bare `orca` becomes that install, possibly an older Orca. + * `ORCA_CLI_COMMAND` names this app's launcher by absolute path, which no startup file can reorder. + * The zsh arm lives in `structured-session-cli-login-shell.live-shell.test.ts`, in the real-shell + * lane that installs zsh. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + createLoginShellHarness, + type LoginShellHarness +} from './structured-session-login-shell-test-harness' + +describe.runIf(process.platform !== 'win32')('a structured session in a bash login shell', () => { + let harness: LoginShellHarness + + beforeEach(() => { + harness = createLoginShellHarness() + }) + + afterEach(() => { + harness.dispose() + }) + + it("resolves this app's CLI through ORCA_CLI_COMMAND in `bash -lc`", async () => { + // Positive control: the profile really does put the global install first for a bare name. + expect(await harness.run({ program: '/bin/bash', args: ['-lc', 'orca'] })).toBe('global') + expect(await harness.run({ program: '/bin/bash', args: ['-lc', '"$ORCA_CLI_COMMAND"'] })).toBe( + 'app' + ) + }) + + it("keeps bare `orca` this app's CLI in a shell that reads no login profile", async () => { + expect(await harness.run({ program: '/bin/bash', args: ['-c', 'orca'] })).toBe('app') + }) +}) diff --git a/src/main/runtime/structured-session-login-shell-test-harness.ts b/src/main/runtime/structured-session-login-shell-test-harness.ts new file mode 100644 index 00000000000..943dfe0f4d3 --- /dev/null +++ b/src/main/runtime/structured-session-login-shell-test-harness.ts @@ -0,0 +1,48 @@ +/** + * A structured session's child env beside a HOME whose login profiles put a stand-in global `orca` + * first, as `/usr/local/bin` often is. Shared by the bash suite (every lane) and the zsh suite + * (the real-shell lane, which installs zsh). + */ + +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' +import { runProcess } from '../../shared/child-process/run-process' +import { structuredSessionChildIdentityEnv } from './structured-session-child-identity-env' + +const SESSION_ID = 'f7a1c0de-1111-4222-8333-444455556666' + +export type LoginShellHarness = { + /** Runs a shell with the structured session's env and returns its stdout. */ + run: (spec: { program: string; args: [flag: '-lc' | '-c', script: string] }) => Promise + dispose: () => void +} + +function writeStub(path: string, says: string): void { + writeFileSync(path, `#!/bin/sh\nprintf '%s' '${says}'\n`) + chmodSync(path, 0o755) +} + +export function createLoginShellHarness(): LoginShellHarness { + const root = mkdtempSync(join(tmpdir(), 'orca-login-shell-cli-')) + const home = join(root, 'home') + const globalBin = join(root, 'global-bin') + const userData = join(root, 'user-data') + const appCliBin = join(userData, 'cli', 'bin') + for (const dir of [home, globalBin, appCliBin]) { + mkdirSync(dir, { recursive: true }) + } + writeStub(join(globalBin, 'orca'), 'global') + writeStub(join(appCliBin, 'orca'), 'app') + writeStub(join(appCliBin, 'orca-dev'), 'app') + const prependGlobal = `export PATH="${globalBin}:$PATH"\n` + writeFileSync(join(home, '.zprofile'), prependGlobal) + writeFileSync(join(home, '.bash_profile'), prependGlobal) + installFakeAppEnvironment({ isPackaged: () => false, getPath: () => userData }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { HOME: home, PATH: '/usr/bin:/bin' }) + return { + run: async (spec) => (await runProcess({ ...spec, env })).stdout, + dispose: () => rmSync(root, { recursive: true, force: true }) + } +} diff --git a/src/main/runtime/structured-session-mail-redrive-wiring.test.ts b/src/main/runtime/structured-session-mail-redrive-wiring.test.ts new file mode 100644 index 00000000000..d7779112cc6 --- /dev/null +++ b/src/main/runtime/structured-session-mail-redrive-wiring.test.ts @@ -0,0 +1,65 @@ +// The production wiring of the idle-edge mail redrive: the host the runtime installs must report +// every status change to the runtime's mail redrive. The integration test installs its own callback, +// so without this nothing pins the line that connects the two in the real app. + +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' +import type * as StructuredAgentSessionRuntime from './structured-agent-session-runtime' +import type { StructuredAgentSessionRuntimeDeps } from './structured-agent-session-runtime' + +const installed = vi.hoisted((): { deps: StructuredAgentSessionRuntimeDeps | null } => ({ + deps: null +})) + +vi.mock('./structured-agent-session-runtime', async (importOriginal) => ({ + ...(await importOriginal()), + ensureStructuredAgentSessionHost: vi.fn(async (deps: StructuredAgentSessionRuntimeDeps) => { + installed.deps = deps + return {} + }) +})) + +const { OrcaRuntimeService } = await import('./orca-runtime') +const { OrchestrationDb } = await import('./orchestration/db') + +describe("the runtime's own structured host install", () => { + it('reports every session status change to the mail redrive', async () => { + const runtime = new OrcaRuntimeService() + const redrive = vi + .spyOn(runtime, 'onStructuredSessionStatusForMail') + .mockImplementation(() => {}) + await runtime.ensureStructuredAgentSessionHost() + const summary: AgentSessionStatusSummary = { + sessionId: 'claude_1234abcd', + workspaceId: 'workspace-1', + agent: 'claude', + status: 'idle', + latestPrompt: '', + updatedAt: 0 + } + try { + installed.deps?.onSessionStatusChanged?.(summary, { replay: false }) + } catch { + // The same callback's rename half needs a store this bare runtime does not have. + } + expect(redrive).toHaveBeenCalledWith(summary) + }) + + it('logs a redrive the database fails, so the status callback goes on to the workspace rename', () => { + const runtime = new OrcaRuntimeService() + const closed = new OrchestrationDb(':memory:') + closed.close() + vi.spyOn(runtime, 'getExistingOrchestrationDb').mockReturnValue(closed) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(() => + runtime.onStructuredSessionStatusForMail({ + sessionId: '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37', + status: 'idle' + }) + ).not.toThrow() + expect(warn).toHaveBeenCalledWith( + '[orchestration] structured session mail redrive failed', + expect.objectContaining({ sessionId: '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37' }) + ) + }) +}) diff --git a/src/main/runtime/structured-session-worktree-teardown.test.ts b/src/main/runtime/structured-session-worktree-teardown.test.ts index 915fbd52986..f33d7889b31 100644 --- a/src/main/runtime/structured-session-worktree-teardown.test.ts +++ b/src/main/runtime/structured-session-worktree-teardown.test.ts @@ -62,8 +62,8 @@ function installHost(options: { /** * Sessions this host is not holding, so they observe `unverifiable` rather than `live`. * - * The everyday shape, not an edge case: the provider child belongs to the VISIBLE pane, so any - * chat outside the active workspace has already been evicted by the release clock. + * The everyday shape, not an edge case: the idle sweep has already put to rest any chat quiet for + * its window, on screen or not. */ detached?: Set /** @@ -89,7 +89,13 @@ function installHost(options: { } } hostRef.current = { - deps: { store: { listRecords: () => options.records, getRecord: () => null } }, + deps: { + store: { + listRecords: () => options.records, + getRecord: () => null, + getSessionTabId: (sessionId: string) => (visible.has(sessionId) ? `tab-${sessionId}` : null) + } + }, hasSession: (sessionId: string) => held.has(sessionId), getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [...visible] }), setSessionTabVisibility: async (sessionId: string, isVisible: boolean) => { diff --git a/src/main/runtime/structured-session-worktree-teardown.ts b/src/main/runtime/structured-session-worktree-teardown.ts index 46265a16111..3f8ab31b3ec 100644 --- a/src/main/runtime/structured-session-worktree-teardown.ts +++ b/src/main/runtime/structured-session-worktree-teardown.ts @@ -27,7 +27,10 @@ import { } from '../../shared/execution-host' import { STILL_LIVE_DETAIL_PREFIX } from '../../shared/worktree/removal' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import { observeStructuredWorker } from './structured-worker-authority' +import { + observeStructuredWorker, + structuredSessionCloseSettled +} from './structured-worker-authority' import { closeStructuredAgentSessionChild } from './structured-agent-session-close' import { retireSettledStructuredWorkerTab } from './structured-agent-session-tab-retirement' import type { WorktreePtyHostFence } from './worktree-pty-host-fence' @@ -86,12 +89,9 @@ export function structuredSessionTeardownHostId( * The workspace's structured sessions, split into what belongs to it and what is attached. * * MEMBERSHIP and LIVENESS answer different questions, and folding them into one list is what let - * a chat tab outlive its workspace. A provider child is scoped to a VISIBLE pane — the hold that - * keeps one is `enabled: isVisible && isWorktreeActive`, and dropping the last hold evicts the - * child after the release grace — so `live` really means "this chat is the visible pane in the - * active workspace, or was moments ago". Deleting a workspace from the sidebar while a different - * one is active makes every chat in the target non-live. Those are exactly the sessions a - * liveness-only list never saw. + * a chat tab outlive its workspace. A provider child runs from a send until the idle sweep rests + * it, so `live` only means "this chat's agent worked recently", and every chat at rest in the + * target is non-live. Those are exactly the sessions a liveness-only list never saw. */ export type StructuredSessionsForWorktree = { /** Every session bound to this workspace on the fenced host, attached or not. */ @@ -256,11 +256,10 @@ export async function closeStructuredSessionsForWorktree( } = {} ): Promise { const { runtime, mayRefuse } = options - // No `afterClose` for a dispatched worker: `host.close` drops the holds, so nothing keeps a - // provider child un-evictable, but the dispatch's redrive subscription and registry entry do - // survive until it settles by another verb. That is a bounded leak, not a hazard — and passing - // one here would mean resolving a dispatch id per session on a teardown path that must stay - // inside the sweep deadline. + // No `afterClose` for a dispatched worker: `host.close` stops the child, but the dispatch's + // redrive subscription and registry entry survive until it settles by another verb. That is a + // bounded leak, not a hazard — and passing one here would mean resolving a dispatch id per + // session on a teardown path that must stay inside the sweep deadline. for (const session of progress.sessions) { // Stops ISSUING new closes once the budget is spent; an in-flight one is left to finish, since // nothing here can cancel a provider round trip. Without this, one slow round trip starved @@ -279,7 +278,7 @@ export async function closeStructuredSessionsForWorktree( // Re-observed rather than reusing the close's own reason string: what the user is asked to // waive is the state AFTER the attempt, and a close that threw never reached an observation. const status = observeStructuredWorker({ sessionId: session.sessionId }).status - if (status === 'exited') { + if (status === 'exited' || structuredSessionCloseSettled(session.sessionId)) { // The re-read can PROVE the exit a failed close could not — it threw past its own // observation, or the record's death evidence landed after it read. Refusing on a child // that is demonstrably gone is the defect this sweep exists to remove, so take the proof @@ -307,7 +306,7 @@ export async function closeStructuredSessionsForWorktree( * The close path already does this for a session it CLOSED. This is the complement: the members * with no attached child, which the close list never contained and nothing else will hide. Their * durable reference survives every purge a removal already performs: the renderer drops `unifiedTabsByWorktree` and the main - * process drops the workspace metadata, and neither touches `visibleSessionIds`. Startup replays + * process drops the workspace metadata, and neither touches the chat tab table. Startup replays * that index, restores the session from it and republishes the tab, so the chat comes back at the * next launch pointing at a workspace that is gone. Worktree ids are path-derived and can be * recreated, so a later workspace at the same path inherits the tab — which is the hazard diff --git a/src/main/runtime/structured-worker-at-rest.test.ts b/src/main/runtime/structured-worker-at-rest.test.ts new file mode 100644 index 00000000000..28317e4da88 --- /dev/null +++ b/src/main/runtime/structured-worker-at-rest.test.ts @@ -0,0 +1,347 @@ +// A structured worker whose agent is at rest: its dispatch keeps it running while open, and once it +// rests it is still this runtime's worker — mail reaches it — until its chat tab is gone. Whether +// its process runs is a separate answer, and a close counts a released lease as done. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { OrchestrationDb } from './orchestration/db' + +const hostRef: { current: unknown } = { current: null } + +vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) + +const { observeStructuredWorker, resolveStructuredWorkerAuthority, structuredSessionCloseSettled } = + await import('./structured-worker-authority') +const { structuredWorkerOwesWork, structuredWorkerOwned } = + await import('./structured-worker-custody') +const { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerIdentities, + structuredWorkerProcessIncarnation +} = await import('./structured-worker-identity') +const { listAddressableStructuredWorkers } = + await import('./orchestration/structured-worker-group-addressing') +const { closeStructuredAgentSessionChild } = await import('./structured-agent-session-close') +const { resolveGroupAddress } = await import('./orchestration/groups') +const { createRestTestRig, foundRestTestChat, IDLE_MS, REST_TEST_SESSION, sweepTicks } = + await import('../native-chat/agent-session-wire/structured-agent-session-rest-test-rig') + +const SESSION = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' +const LOCAL = JSON.stringify({ kind: 'local', hostId: 'local' }) + +function record(lease: Partial): AgentSessionRecord { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial record; the ownership and liveness reads touch only lease, location and provider. + return { + sessionId: SESSION, + provider: 'codex', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'wt_1', + workspaceKind: 'git-worktree' + }, + lease: { claimStatus: 'live', deathEvidence: null, runtimeFence: 3, ...lease } + } as AgentSessionRecord +} + +function installHost(current: AgentSessionRecord | null, tabs: string[]) { + const host = { + deps: { store: { getRecord: () => current } }, + hasSession: () => false, + getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: tabs }), + setSessionTabVisibility: vi.fn(async (_id: string, visible: boolean) => { + tabs.splice(0, tabs.length, ...(visible ? [SESSION] : [])) + }), + close: vi.fn(async () => undefined) + } + hostRef.current = host + return host +} + +let paneKey = '' + +function registerWorker(): string { + const handle = mintStructuredWorkerHandle() + paneKey = mintStructuredWorkerPaneKey(SESSION) + structuredWorkerIdentities.register({ + handle, + sessionId: SESSION, + agent: 'codex', + paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION), + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } + }) + return handle +} + +beforeEach(() => { + structuredWorkerIdentities.clear() + hostRef.current = null +}) + +describe('an open dispatch keeps its worker running (P2-19 i)', () => { + let db: OrchestrationDb + beforeEach(() => { + db = new OrchestrationDb(':memory:') + }) + afterEach(() => db.close()) + + function dispatchWorker(hostScope = LOCAL) { + const task = db.createTask({ runId: 'run_legacy_local', spec: 'structured work' }) + const { dispatch } = db.createStartingWorkerDispatch({ + taskId: task.id, + startOptions: {}, + creator: { kind: 'system' }, + maxDepth: 9 + }) + db.prepareStartingWorkerAuthority({ + dispatchId: dispatch.id, + handle: mintStructuredWorkerHandle(), + paneKey: mintStructuredWorkerPaneKey(SESSION), + processIncarnation: structuredWorkerProcessIncarnation(SESSION), + worktreeId: 'wt_1', + effects: [], + setupState: 'not_configured', + hostScope, + terminalOwnership: 'created' + }) + return dispatch + } + + it('answers open while the dispatch starts, runs or stops, and closed once it settles', () => { + const dispatch = dispatchWorker() + expect(structuredWorkerOwesWork(db, record({}))).toBe(true) + db.markWorkerDispatchReady(dispatch.id) + expect(structuredWorkerOwesWork(db, record({}))).toBe(true) + db.beginWorkerStop(dispatch.id, 'epoch_home') + expect(structuredWorkerOwesWork(db, record({}))).toBe(true) + db.settleWorkerStop(dispatch.id) + expect(structuredWorkerOwesWork(db, record({}))).toBe(false) + }) + + // Custody is the list state coordinators see: `active` owes work, `reclaimable` does not. + it('lets a worker awaiting its coordinator rest, and keeps one whose stop is in doubt', () => { + const done = dispatchWorker() + db.markWorkerDispatchReady(done.id) + db.settleWorkerReport({ + taskId: done.task_id, + dispatchId: done.id, + outcome: 'succeeded', + result: 'done' + }) + expect(structuredWorkerOwesWork(db, record({}))).toBe(false) + + const doubted = dispatchWorker() + db.markWorkerDispatchReady(doubted.id) + db.beginWorkerStop(doubted.id, 'epoch_home') + db.markWorkerStopUnknown(doubted.id, 'the close was not proven') + expect(structuredWorkerOwesWork(db, record({}))).toBe(true) + }) + + it('keeps a worker at rest after its dispatch settles a group recipient, until its tab goes (P2-19 ii)', () => { + const dispatch = dispatchWorker() + db.markWorkerDispatchReady(dispatch.id) + db.beginWorkerStop(dispatch.id, 'epoch_home') + db.settleWorkerStop(dispatch.id) + // Settlement forgets the in-memory entry; only the durable row and the record remain. + structuredWorkerIdentities.clear() + const tabs = [SESSION] + installHost( + record({ + claimStatus: 'released', + deathEvidence: { kind: 'exit-observed', detail: 'stopped by the sweep', observedAt: 1 } + }), + tabs + ) + const handle = db.getWorkerTerminalResourceByOwner(dispatch.id)!.terminal_handle + const recipients = () => listAddressableStructuredWorkers(db) + + expect(recipients()).toEqual([{ handle, worktreeId: 'wt_1', agentIdentity: 'codex' }]) + expect(resolveGroupAddress('@codex', 'term_sender', recipients(), () => 'idle')).toEqual([ + handle + ]) + expect(resolveGroupAddress('@claude', 'term_sender', recipients(), () => 'idle')).toEqual([]) + // Direct mail resolves the same worker through the same ownership answer. + expect(resolveStructuredWorkerAuthority(handle, db)?.identity.handle).toBe(handle) + + tabs.length = 0 + expect(recipients()).toEqual([]) + expect(resolveStructuredWorkerAuthority(handle, db)).toBeNull() + }) + + it('stops routing to a worker its coordinator abandoned and then released, and keeps its tab', () => { + const dispatch = dispatchWorker() + db.markWorkerDispatchReady(dispatch.id) + structuredWorkerIdentities.clear() + const tabs = [SESSION] + installHost( + record({ + claimStatus: 'released', + deathEvidence: { kind: 'exit-observed', detail: 'stopped by the sweep', observedAt: 1 } + }), + tabs + ) + const resource = db.getWorkerTerminalResourceByOwner(dispatch.id)! + const handle = resource.terminal_handle + const recipients = () => listAddressableStructuredWorkers(db) + // At rest, its dispatch abandoned: still a recipient, as a terminal worker left running is. + db.abandonWorkerDispatch(dispatch.id) + const worktreeGroup = () => + resolveGroupAddress('@worktree:wt_1', 'term_sender', recipients(), () => 'idle') + expect(worktreeGroup()).toEqual([handle]) + expect(resolveStructuredWorkerAuthority(handle, db)).not.toBeNull() + + // The release finds the agent at rest, so it settles as released. + expect(db.requestWorkerTerminalRelease(dispatch.id)).toMatchObject({ disposition: 'retained' }) + expect( + db.settleDeadWorkerTerminalRelease({ + requestingDispatchId: dispatch.id, + resourceId: resource.id, + processIncarnation: resource.process_incarnation! + }) + ).toMatchObject({ disposition: 'released' }) + + expect(tabs).toEqual([SESSION]) + expect(worktreeGroup()).toEqual([]) + // Direct mail routes through the same answer. + expect(resolveStructuredWorkerAuthority(handle, db)).toBeNull() + }) + + it('reads only this host scope, and no database answers no', () => { + dispatchWorker(JSON.stringify({ kind: 'ssh', targetId: 'elsewhere' })) + expect(structuredWorkerOwesWork(db, record({}))).toBe(false) + expect(structuredWorkerOwesWork(null, record({}))).toBe(false) + }) +}) + +describe('ownership, not liveness (P2-19 ii-iv, P2-26)', () => { + it('owns a worker at rest while its tab is listed, and routes mail to it', () => { + const handle = registerWorker() + installHost( + record({ + claimStatus: 'released', + deathEvidence: { kind: 'exit-observed', detail: 'stopped', observedAt: 1 } + }), + [SESSION] + ) + + expect(structuredWorkerOwned(SESSION)).toBe(true) + expect(observeStructuredWorker({ sessionId: SESSION }).status).toBe('exited') + expect(resolveStructuredWorkerAuthority(handle, null)?.identity.paneKey).toBe(paneKey) + }) + + it('retires a released worker whose tab is gone', () => { + const handle = registerWorker() + installHost(record({ claimStatus: 'released' }), []) + + expect(structuredWorkerOwned(SESSION)).toBe(false) + expect(resolveStructuredWorkerAuthority(handle, null)).toBeNull() + }) + + it('keeps authority for a live session that has no tab', () => { + const handle = registerWorker() + installHost(record({ claimStatus: 'live' }), []) + + expect(structuredWorkerOwned(SESSION)).toBe(true) + expect(resolveStructuredWorkerAuthority(handle, null)).not.toBeNull() + }) + + it('cannot answer without a host', () => { + expect(structuredWorkerOwned(SESSION)).toBeNull() + }) +}) + +describe('a close whose stop could not be proven (P2-30)', () => { + it('closes on the first attempt, keeps the tab retired and leaves the verdict alone', async () => { + const released = record({ claimStatus: 'released', deathEvidence: null }) + const tabs = [SESSION] + const host = installHost(released, tabs) + expect(observeStructuredWorker({ sessionId: SESSION }).status).toBe('unverifiable') + + const outcome = await closeStructuredAgentSessionChild(SESSION) + expect(outcome).toMatchObject({ stopped: true, closeAttempted: true }) + expect(host.close).toHaveBeenCalledOnce() + expect(tabs).toEqual([]) + expect(host.setSessionTabVisibility).not.toHaveBeenCalledWith(SESSION, true) + expect(released.lease.deathEvidence).toBeNull() + expect(structuredSessionCloseSettled(SESSION)).toBe(true) + }) + + it('still refuses a close that left the lease live', () => { + installHost(record({ claimStatus: 'live' }), [SESSION]) + expect(structuredSessionCloseSettled(SESSION)).toBe(false) + }) +}) + +describe('a task dispatched into a worker whose own dispatch settled', () => { + it('keeps the worker running while that task is open, and lets it rest once the task settles', async () => { + const db = new OrchestrationDb(':memory:') + const rig = await createRestTestRig({ + hasOpenDispatch: (current) => structuredWorkerOwesWork(db, current) + }) + try { + hostRef.current = rig.host + await foundRestTestChat(rig) + const incarnation = structuredWorkerProcessIncarnation(REST_TEST_SESSION) + const handle = mintStructuredWorkerHandle() + const workerPane = mintStructuredWorkerPaneKey(REST_TEST_SESSION) + const first = db.createTask({ runId: 'run_legacy_local', spec: 'first task' }) + const { dispatch: started } = db.createStartingWorkerDispatch({ + taskId: first.id, + startOptions: {}, + creator: { kind: 'system' }, + maxDepth: 9 + }) + db.prepareStartingWorkerAuthority({ + dispatchId: started.id, + handle, + paneKey: workerPane, + processIncarnation: incarnation, + worktreeId: 'wt_1', + effects: [], + setupState: 'not_configured', + hostScope: LOCAL, + terminalOwnership: 'created' + }) + db.markWorkerDispatchReady(started.id) + expect( + db.settleWorkerReport({ + taskId: first.id, + dispatchId: started.id, + outcome: 'succeeded', + result: 'done' + }) + ).toMatchObject({ action: 'settled' }) + // The coordinator hands the same worker its next task: a dispatch with no worker row. + const second = db.createTask({ runId: 'run_legacy_local', spec: 'second task' }) + const handedOn = db.createDispatchContext({ + taskId: second.id, + assigneeHandle: handle, + assigneePaneKey: workerPane, + processIncarnation: incarnation, + creator: { kind: 'system' }, + maxDepth: 9 + }) + rig.clock.now += 2 * IDLE_MS + + await sweepTicks(12) + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + expect(observeStructuredWorker({ sessionId: REST_TEST_SESSION }).status).toBe('live') + + db.completeDispatch(handedOn.id) + rig.clock.now += IDLE_MS + 1 + await vi.waitFor(() => + expect(observeStructuredWorker({ sessionId: REST_TEST_SESSION }).status).toBe('exited') + ) + expect(rig.adapter.closeSession).toHaveBeenCalledWith(REST_TEST_SESSION) + } finally { + hostRef.current = null + await rig.dispose() + db.close() + } + }) +}) diff --git a/src/main/runtime/structured-worker-authority.ts b/src/main/runtime/structured-worker-authority.ts index c11119878f6..24fda6b1456 100644 --- a/src/main/runtime/structured-worker-authority.ts +++ b/src/main/runtime/structured-worker-authority.ts @@ -3,18 +3,22 @@ * * The registry holds the handle→session mapping for this process; the durable worker-terminal * resource row is what survives a restart, so a miss falls back to rehydrating from it. The - * durable agent-session record is the liveness half: a session whose claim is conflicted or - * released, or pinned to another execution host, is no longer this runtime's structured worker. + * durable agent-session record, the chat's tab and the orchestration's own resource row decide + * custody: see `structured-worker-custody`. + * Whether its provider process runs is a separate fact, `observeStructuredWorker`, and routing + * never reads it — an agent at rest still receives mail, which starts it. */ import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { OrcaSessionId } from '../../shared/orca-session-address' import type { RuntimeTerminalState } from '../../shared/runtime-types' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import type { OrchestrationDb } from './orchestration/db' +import { structuredWorkerAddressable } from './structured-worker-custody' import { isStructuredWorkerHandle, structuredWorkerIdentities, - structuredWorkerRecordIsCurrent, + structuredWorkerProcessIncarnation, type StructuredWorkerIdentity } from './structured-worker-identity' @@ -47,7 +51,41 @@ export function resolveStructuredWorkerIdentity( return row ? structuredWorkerIdentities.rehydrate(row) : null } -/** Identity plus a record that still proves this runtime owns the session. */ +/** The worker identity minted for a session, if that session is a structured worker. */ +export function resolveStructuredWorkerIdentityForSession( + sessionId: string, + db: OrchestrationDb | null | undefined +): StructuredWorkerIdentity | null { + const known = structuredWorkerIdentities.getBySessionId(sessionId) + if (known) { + return known + } + const row = db?.getWorkerTerminalResourceByProcessIncarnation?.( + structuredWorkerProcessIncarnation(sessionId) + ) + return row ? structuredWorkerIdentities.rehydrate(row) : null +} + +/** + * Whether this session was assigned a Dispatch as a structured worker. Such a session acts with its + * worker handle, so one whose handle is gone must not act handle-less, as a chat would. + */ +export function isRecordedStructuredWorkerSession( + sessionId: OrcaSessionId, + db: OrchestrationDb +): boolean { + return Boolean( + db.db + .prepare( + `SELECT 1 FROM dispatch_contexts + WHERE assignee_orca_session_id = ? AND process_incarnation = ? LIMIT 1` + ) + .get(sessionId, structuredWorkerProcessIncarnation(sessionId)) + ) +} + +/** Identity plus a record that still proves this runtime owns the session, for a worker its + * orchestration has not released. */ export function resolveStructuredWorkerAuthority( handle: string, db: OrchestrationDb | null | undefined @@ -57,7 +95,14 @@ export function resolveStructuredWorkerAuthority( return null } const record = readStructuredAgentSessionRecord(identity.sessionId) - return record && structuredWorkerRecordIsCurrent(record) ? { identity, record } : null + return record && + structuredWorkerAddressable( + db, + identity.sessionId, + db?.getWorkerTerminalResourceByHandle?.(identity.handle) + ) + ? { identity, record } + : null } /** @@ -80,6 +125,20 @@ export type StructuredWorkerObservation = { reason?: string } +/** + * Whether a close left nothing running: `exited`, or `unverifiable` on a released lease — a release + * whose stop could not be proven, which sent no signal and is left as it is. Closing a chat is the + * user's action, and bookkeeping about a process already released must not refuse it. + */ +export function structuredSessionCloseSettled(sessionId: string): boolean { + const status = observeStructuredWorker({ sessionId }).status + return ( + status === 'exited' || + (status === 'unverifiable' && + readStructuredAgentSessionRecord(sessionId)?.lease.claimStatus === 'released') + ) +} + /** * The observation as the terminal state every read result reports. * diff --git a/src/main/runtime/structured-worker-child-identity-env.test.ts b/src/main/runtime/structured-worker-child-identity-env.test.ts deleted file mode 100644 index e966dd55824..00000000000 --- a/src/main/runtime/structured-worker-child-identity-env.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' - -const shim = vi.hoisted(() => ({ ensureLinuxTerminalOrcaCliShimDir: vi.fn() })) -vi.mock('../cli/linux-terminal-orca-cli-shim', () => shim) - -import { structuredWorkerChildIdentityEnv } from './structured-worker-child-identity-env' -import { - mintStructuredWorkerHandle, - mintStructuredWorkerPaneKey, - structuredWorkerHostScope, - structuredWorkerIdentities, - structuredWorkerProcessIncarnation -} from './structured-worker-identity' - -const SESSION_ID = 'f7a1c0de-1111-4222-8333-444455556666' -const USER_DATA = '/data/orca' -const RESOURCES = '/app/Resources' -const SHIM_DIR = join(USER_DATA, 'linux-orca-cli-shim') - -const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')! -const resourcesDescriptor = Object.getOwnPropertyDescriptor(process, 'resourcesPath') - -function pinPlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { configurable: true, value: platform }) -} - -function registerWorker(): string { - const handle = mintStructuredWorkerHandle() - structuredWorkerIdentities.register({ - handle, - sessionId: SESSION_ID, - agent: 'claude', - paneKey: mintStructuredWorkerPaneKey(SESSION_ID), - processIncarnation: structuredWorkerProcessIncarnation(SESSION_ID), - worktreeId: 'wt_1', - hostScope: { kind: 'local', hostId: 'local' } - }) - return handle -} - -beforeEach(() => { - shim.ensureLinuxTerminalOrcaCliShimDir.mockReset() - shim.ensureLinuxTerminalOrcaCliShimDir.mockReturnValue(SHIM_DIR) - Object.defineProperty(process, 'resourcesPath', { configurable: true, value: RESOURCES }) -}) - -afterEach(() => { - structuredWorkerIdentities.clear() - Object.defineProperty(process, 'platform', platformDescriptor) - if (resourcesDescriptor) { - Object.defineProperty(process, 'resourcesPath', resourcesDescriptor) - } else { - Reflect.deleteProperty(process, 'resourcesPath') - } -}) - -describe('structuredWorkerChildIdentityEnv', () => { - it('marks an ordinary chat session as having NO identity, and grants it nothing', () => { - // The marker names nothing — no handle, no pane key, no session id, no token — so it cannot be - // replayed or impersonated, and it does not reach the hook, agent-row or mobile-projection - // pipelines a pane key would. Its only job is to let the CLI REFUSE instead of guessing: this - // session has no pane, so every implicit-terminal guess resolved to a sibling, and a - // destructive `check` then consumed that sibling's mail. - pinPlatform('linux') - installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) - const childEnv = { PATH: '/usr/bin' } - const env = structuredWorkerChildIdentityEnv(SESSION_ID, childEnv) - expect(env).toEqual({ PATH: '/usr/bin', ORCA_STRUCTURED_SESSION: '1' }) - expect(env.ORCA_TERMINAL_HANDLE).toBeUndefined() - expect(env.ORCA_PANE_KEY).toBeUndefined() - expect(env.ORCA_CLI_COMMAND).toBeUndefined() - // Still no CLI reachability granted, so packaged builds keep today's exposure. - expect(childEnv.PATH).toBe('/usr/bin') - expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled() - }) - - it('gives a packaged-Linux worker the bare-orca shim its ORCA_CLI_COMMAND assumes', () => { - // Without this the child's first `orca orchestration check` execs GNOME Orca — the CLI - // installs as `orca-ide` on Linux (stablyai/orca#7904) — and the dispatch hangs to timeout. - pinPlatform('linux') - installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) - const handle = registerWorker() - const env = structuredWorkerChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin:/bin' }) - expect(env.ORCA_TERMINAL_HANDLE).toBe(handle) - expect(env.ORCA_CLI_COMMAND).toBe('orca') - expect(env.PATH).toBe(`${SHIM_DIR}:/usr/bin:/bin`) - }) - - it('gives a packaged-macOS worker the bundled CLI dir', () => { - pinPlatform('darwin') - installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) - registerWorker() - const env = structuredWorkerChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) - expect(env.PATH).toBe(`${join(RESOURCES, 'bin')}:/usr/bin`) - }) - - it('gives a packaged-Windows worker the bundled CLI dir under the env block spelling', () => { - pinPlatform('win32') - installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) - registerWorker() - const env = structuredWorkerChildIdentityEnv(SESSION_ID, { Path: 'C:\\Windows' }) - expect(env.Path).toBe(`${join(RESOURCES, 'bin')};C:\\Windows`) - expect(env.PATH).toBeUndefined() - }) - - it('gives an unpackaged worker the dev launcher dir', () => { - pinPlatform('darwin') - installFakeAppEnvironment({ isPackaged: () => false, getPath: () => USER_DATA }) - registerWorker() - const env = structuredWorkerChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) - expect(env.PATH).toBe(`${join(USER_DATA, 'cli', 'bin')}:/usr/bin`) - }) - - it('never puts a pane key in the child environment', () => { - // A pane key here flows into hook-emitted agent statuses and the attestation, agent-row and - // mobile-projection pipelines, all of which assume it names a live PTY leaf. - pinPlatform('linux') - installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) - registerWorker() - const env = structuredWorkerChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) - expect(env.ORCA_PANE_KEY).toBeUndefined() - expect(Object.keys(env).filter((key) => key.includes('PANE'))).toEqual([]) - }) - - it('never names the WSL-scoped launcher, because a structured worker cannot run in WSL', () => { - // `orca-ide` is the literal the PTY lane exports for WSL only. A structured session that - // resolves to a WSL distro is refused a host scope, so it never becomes a worker at all — - // which is why the bare-`orca` shim, not the literal, is the right fix on Linux. - expect( - structuredWorkerHostScope({ - executionHostId: 'local', - workspaceId: 'wt_1', - workspaceKind: 'git-worktree', - wslDistro: 'Ubuntu' - }) - ).toBeNull() - pinPlatform('linux') - installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) - registerWorker() - expect( - structuredWorkerChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }).ORCA_CLI_COMMAND - ).not.toBe('orca-ide') - }) -}) diff --git a/src/main/runtime/structured-worker-child-identity-env.ts b/src/main/runtime/structured-worker-child-identity-env.ts deleted file mode 100644 index 6cf9f46e910..00000000000 --- a/src/main/runtime/structured-worker-child-identity-env.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * The orchestration identity — and the CLI reachability — a structured worker's own child needs - * to speak for itself. - * - * Without `ORCA_TERMINAL_HANDLE` the worker's Bash tool has nothing to pass as `--from`, and - * `resolveOrchestrationTerminalHandle` falls back to a cwd lookup that returns whichever leaf in - * the worktree comes first. Two attacks follow from that: a bare `check` reads and consumes a - * SIBLING's dispatch mailbox, and a bare `send --type worker_done` can settle a sibling's - * context-only dispatch, a tier that has no capability token to reject on. - * - * `ORCA_CLI_COMMAND: 'orca'` is honest ONLY because of the PATH prepend below. Orca's Linux CLI - * installs as `orca-ide` so it never claims GNOME Orca's /usr/bin/orca (stablyai/orca#7904), and - * on packaged macOS/Windows the bundled launcher is reachable only from the app's own resources - * dir. A PTY worker gets that treatment from `buildPtyHostEnv`; a structured worker has no PTY, - * so it applies the SAME function here rather than a second, drifting copy of the rule. - * - * Deliberately NOT `ORCA_PANE_KEY`. Claude structured sessions run hooks, and a pane key in their - * environment starts flowing into hook-emitted agent-status payloads and the hook-attestation, - * agent-row and mobile-projection pipelines, every one of which assumes a pane key names a live - * PTY leaf. It would also open `selectExactWorkerProviderSession`, which is fail-closed today - * precisely because a structured session emits no hook agent status. The CLI needs none of it once - * the handle is present. - * - * A session that is not a dispatched worker gets ONE variable, `ORCA_STRUCTURED_SESSION`, and it - * names nothing: no handle, no pane key, no session id, no token. Its only meaning is "this child - * is a structured session with no orchestration identity", which is what a verb needs in order to - * REFUSE rather than guess one. Because it names nothing it cannot be replayed, cannot impersonate, - * and cannot flow into the hook, agent-row or mobile-projection pipelines the way a pane key would - * — which is why it is a different decision from withholding `ORCA_PANE_KEY`, not a reversal of it. - * Without it, `check` fell through to the active-terminal guess and destructively consumed a - * SIBLING pane's oldest unread batch; `requireUnambiguous` only narrows that, because with exactly - * one terminal pane in the worktree the guess still resolves — to a sibling. - * - * The handle is read from the registry at spawn time, so an in-host recovery respawn re-bakes the - * SAME handle rather than a stale or fresh one. - */ - -import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' -import { prependOrcaCliDirToChildPath } from '../cli/orca-cli-child-path' -import { ORCA_STRUCTURED_SESSION_ENV } from '../../shared/structured-session-marker' -import { structuredWorkerIdentities } from './structured-worker-identity' - -export function structuredWorkerChildIdentityEnv( - sessionId: string, - childEnv: Record -): Record { - const identity = structuredWorkerIdentities.getBySessionId(sessionId) - if (!identity) { - return { ...childEnv, [ORCA_STRUCTURED_SESSION_ENV]: '1' } - } - const env: Record = { - ...childEnv, - ORCA_TERMINAL_HANDLE: identity.handle, - ORCA_CLI_COMMAND: 'orca' - } - applyOrcaCliPath(env) - return env -} - -/** - * A host with no app environment installed — a plain-Node fork, or a unit test — has no userData - * root to resolve, and inventing one would write a shim into the wrong directory. - */ -function applyOrcaCliPath(env: Record): void { - if (!hasAppEnvironment()) { - return - } - const app = getAppEnvironment() - prependOrcaCliDirToChildPath(env, { - isPackaged: app.isPackaged(), - userDataPath: app.getPath('userData'), - resourcesPath: process.resourcesPath ?? null - }) -} diff --git a/src/main/runtime/structured-worker-custody.ts b/src/main/runtime/structured-worker-custody.ts new file mode 100644 index 00000000000..54e42b24d80 --- /dev/null +++ b/src/main/runtime/structured-worker-custody.ts @@ -0,0 +1,133 @@ +/** + * Whether orchestration still holds a structured worker, the one answer every reader derives from. + * + * Custody is the orchestration's own worker-terminal state, the list state `worker-list` shows + * coordinators, never whether the worker's process runs: a worker at rest is still held, and mail + * starts it. Routing, group addressing and `worker-show` ask whether it is addressable; the idle + * sweep asks whether it still owes work. + * + * Two policy decisions live here and nowhere else: only `released` ends addressability (a release + * pending or in doubt still routes, as for a terminal worker), and a settled worker awaiting its + * coordinator's decision (`reclaimable`) owes no work, so it may rest. + */ + +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { OrchestrationDb } from './orchestration/db' +import type { WorkerDispatchState } from './orchestration/types' +import { + deriveWorkerTerminalListState, + type WorkerTerminalResourceRow +} from './orchestration/worker-terminal-ownership' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { + structuredWorkerHostScope, + structuredWorkerProcessIncarnation, + structuredWorkerRecordIsCurrent +} from './structured-worker-identity' + +/** + * Whether this runtime still owns the worker's session: routing, addressing and authority ask + * this, never whether its process runs. Null when the host is not installed, because reading the + * record store would install it — not being able to look is not an answer. + */ +export function structuredWorkerOwned(sessionId: string): boolean | null { + const host = getStructuredAgentSessionHost() + if (!host) { + return null + } + let record: AgentSessionRecord | null + try { + record = host.deps.store.getRecord(sessionId) + } catch { + record = null + } + return structuredWorkerRecordIsCurrent( + record, + record?.lease.claimStatus === 'released' && structuredWorkerTabListed(host, sessionId) + ) +} + +/** Retirement is the tab index: every path that ends a chat for good hides its tab. */ +function structuredWorkerTabListed( + host: NonNullable>, + sessionId: string +): boolean { + try { + return host.getPersistedVisibleSessionTabIndex?.().sessionIds.includes(sessionId) ?? false + } catch { + return false + } +} + +type CustodyRow = Pick< + WorkerTerminalResourceRow, + 'owner_dispatch_id' | 'terminal_handle' | 'ownership_state' | 'release_state' +> + +function ownerState( + db: OrchestrationDb | null | undefined, + row: CustodyRow +): WorkerDispatchState | undefined { + return ( + db?.getWorkerDispatch?.(row.owner_dispatch_id)?.state ?? + db?.getRemoteDispatchAttachment?.(row.owner_dispatch_id)?.state + ) +} + +/** + * The user still owns the chat and orchestration has not released the worker, as with a terminal + * worker whose terminal closed. Null when ownership cannot be read. A released worker's chat stays + * the user's; nothing routes to it. + */ +export function structuredWorkerAddressable( + db: OrchestrationDb | null | undefined, + sessionId: string, + row: CustodyRow | undefined +): boolean | null { + const owned = structuredWorkerOwned(sessionId) + if (owned === null) { + return null + } + // Release is read off the row alone, so an owner whose state is unreadable still answers. + const custody = row + ? deriveWorkerTerminalListState({ + workerState: ownerState(db, row) ?? 'unsupervised', + agentTerminalHandle: row.terminal_handle, + resource: row + }) + : null + return owned && custody !== 'released' +} + +/** + * Work orchestration still owes on this worker, read per sweep tick: any unsettled dispatch + * addressed to its incarnation, on a process this host owns a terminal for. That covers its own + * worker-start dispatch (whose context stays open while the worker is active, a stop in doubt + * included, because a supervised worker's context settles only with it) and any task later + * dispatched to it. A `reclaimable` worker's dispatch has settled, so it owes nothing. + */ +export function structuredWorkerOwesWork( + db: OrchestrationDb | null, + record: AgentSessionRecord +): boolean { + const hostScope = structuredWorkerHostScope(record.location) + if (!db || !hostScope) { + return false + } + const incarnation = structuredWorkerProcessIncarnation(record.sessionId) + const owned = db.db + .prepare( + `SELECT 1 FROM worker_terminal_resources + WHERE process_incarnation = ? AND host_scope IS ? AND ownership_state = 'owned' LIMIT 1` + ) + .get(incarnation, JSON.stringify(hostScope)) + return ( + owned !== undefined && + db.db + .prepare( + `SELECT 1 FROM dispatch_contexts + WHERE process_incarnation = ? AND status IN ('pending', 'dispatched') LIMIT 1` + ) + .get(incarnation) !== undefined + ) +} diff --git a/src/main/runtime/structured-worker-identity.test.ts b/src/main/runtime/structured-worker-identity.test.ts index 98989a85891..17ba897de47 100644 --- a/src/main/runtime/structured-worker-identity.test.ts +++ b/src/main/runtime/structured-worker-identity.test.ts @@ -5,7 +5,7 @@ import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' import { selectExactWorkerProviderSession } from './orchestration/worker-provider-session' -import { structuredWorkerChildIdentityEnv } from './structured-worker-child-identity-env' +import { structuredSessionChildIdentityEnv } from './structured-session-child-identity-env' import { StructuredWorkerIdentityRegistry, isStructuredWorkerHandle, @@ -176,18 +176,24 @@ describe('structured worker identity', () => { it('keeps a recovered session current across a fence bump', () => { // The host bumps the fence on its own transparent crash recovery; fencing identity on it // would wedge the SAME worker as identity_unproven forever. - expect(structuredWorkerRecordIsCurrent(record({ runtimeFence: 1 }))).toBe(true) - expect(structuredWorkerRecordIsCurrent(record({ runtimeFence: 9 }))).toBe(true) + expect(structuredWorkerRecordIsCurrent(record({ runtimeFence: 1 }), false)).toBe(true) + expect(structuredWorkerRecordIsCurrent(record({ runtimeFence: 9 }), false)).toBe(true) expect(structuredWorkerProcessIncarnation(SESSION_ID)).toBe( structuredWorkerProcessIncarnation(SESSION_ID) ) }) - it('refuses a conflicted or released session', () => { + it('refuses a conflicted session, and a released one whose chat tab is gone', () => { // A terminal owner an older build recorded loads conflicted; it is not this worker. - expect(structuredWorkerRecordIsCurrent(record({ claimStatus: 'conflicted' }))).toBe(false) - expect(structuredWorkerRecordIsCurrent(record({ claimStatus: 'released' }))).toBe(false) - expect(structuredWorkerRecordIsCurrent(null)).toBe(false) + expect(structuredWorkerRecordIsCurrent(record({ claimStatus: 'conflicted' }), true)).toBe(false) + expect(structuredWorkerRecordIsCurrent(record({ claimStatus: 'released' }), false)).toBe(false) + expect(structuredWorkerRecordIsCurrent(null, true)).toBe(false) + }) + + it('keeps a released session with a listed tab: that worker is at rest, not gone', () => { + expect(structuredWorkerRecordIsCurrent(record({ claimStatus: 'released' }), true)).toBe(true) + // A live lease is owned whether or not a tab shows it. + expect(structuredWorkerRecordIsCurrent(record({ claimStatus: 'live' }), false)).toBe(true) }) }) @@ -294,9 +300,9 @@ describe('structured workers stay outside the PTY-only fail-closed paths', () => hostScope: { kind: 'local', hostId: 'local' } }) try { - const env = structuredWorkerChildIdentityEnv(SESSION_ID, {}) - // Registered, so this is a populated env — not the empty one an unregistered session gets, - // which would satisfy the pane-key assertion for the wrong reason. + const env = structuredSessionChildIdentityEnv(SESSION_ID, {}) + // Registered, so the worker's handle is present; without it the pane-key assertion would pass + // for the wrong reason. expect(env.ORCA_TERMINAL_HANDLE).toBe(handle) expect(Object.keys(env)).not.toContain('ORCA_PANE_KEY') } finally { diff --git a/src/main/runtime/structured-worker-identity.ts b/src/main/runtime/structured-worker-identity.ts index 5be84c336ae..22a49e11abb 100644 --- a/src/main/runtime/structured-worker-identity.ts +++ b/src/main/runtime/structured-worker-identity.ts @@ -25,14 +25,20 @@ import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' +import { isOrcaSessionId, type OrcaSessionId } from '../../shared/orca-session-address' +import { + STRUCTURED_WORKER_HANDLE_PREFIX, + isStructuredWorkerHandle +} from '../../shared/structured-worker-handle' import { parseWorkerTerminalHostScope, type WorkerTerminalHostScope } from './orchestration/worker-terminal-process-liveness' -// Deliberately not `term_`: `issueHandle` revalidates the renderer graph epoch against the -// renderer-driven leaves map, so a main-minted `term_` leaf evaporates on the next window reload. -export const STRUCTURED_WORKER_HANDLE_PREFIX = 'structworker_' +export { + STRUCTURED_WORKER_HANDLE_PREFIX, + isStructuredWorkerHandle +} from '../../shared/structured-worker-handle' export const STRUCTURED_WORKER_INCARNATION_PREFIX = 'structured:' export type StructuredWorkerIdentity = { @@ -46,10 +52,6 @@ export type StructuredWorkerIdentity = { hostScope: WorkerTerminalHostScope } -export function isStructuredWorkerHandle(handle: string | null | undefined): boolean { - return typeof handle === 'string' && handle.startsWith(STRUCTURED_WORKER_HANDLE_PREFIX) -} - export function mintStructuredWorkerHandle(): string { return `${STRUCTURED_WORKER_HANDLE_PREFIX}${randomUUID()}` } @@ -57,12 +59,13 @@ export function mintStructuredWorkerHandle(): string { /** * A RANDOM leaf, minted once per worker and persisted with the rest of the identity. * - * Emphatically not `structuredAgentSessionPaneKey`, which is a sha256 of the session id. A pane - * key is an identity credential on its own: `orchestration.check` is identity-gated, not - * capability-gated, and accepts a caller-supplied `terminalPaneKey` that `getActiveDispatchForIdentity` - * matches by leaf suffix. A derivable pane key would therefore let anyone who learns a session id — - * which the tab id embeds in plain text — read and consume that worker's mailbox with no token. - * PTY pane keys are safe only because their leaf UUID is random; this one has to be too. + * Emphatically not `structuredAgentSessionPaneKey`, which is a sha256 of the session id. A request + * that names no session — a PTY agent's, or any on the paired-client route, which refuses session + * ids — identifies its caller by pane: `orchestration.check` accepts a caller-supplied + * `terminalPaneKey` that `getActiveDispatchForIdentity` matches by leaf suffix. A pane key derivable + * from the session id, which the tab id embeds in plain text, would let such a request read and + * consume this worker's mailbox. On the same-host socket route the session id itself names the + * worker with no token, by design; the pane key is no credential there and must not become one. * * Restart stability comes from persisting the minted key, not from re-deriving it. */ @@ -122,6 +125,14 @@ export function sessionIdFromStructuredWorkerIncarnation( return sessionId.length > 0 ? sessionId : null } +/** The Orca session id a `structured:` incarnation names; null for any other. */ +export function structuredWorkerOrcaSessionIdForIncarnation( + processIncarnation: string | null | undefined +): OrcaSessionId | null { + const sessionId = sessionIdFromStructuredWorkerIncarnation(processIncarnation) + return sessionId !== null && isOrcaSessionId(sessionId) ? sessionId : null +} + /** Structured sessions can only exist local and outside WSL; anything else is not our authority. */ export function structuredWorkerHostScope( location: AgentSessionExecutionLocation @@ -131,19 +142,62 @@ export function structuredWorkerHostScope( : null } -/** Whether the durable record still describes THIS worker under this host. */ +/** + * Whether the durable record still describes THIS worker under this host — ownership, not whether + * its process runs. A released lease is a worker at rest while its chat tab is listed; released + * with the tab gone is retired. `tabListed` is the persisted tab index's answer. + */ export function structuredWorkerRecordIsCurrent( - record: AgentSessionRecord | null | undefined + record: AgentSessionRecord | null | undefined, + tabListed: boolean ): boolean { return Boolean( record && // Why: a conflicted claim may name a terminal an older build recorded as owner, not this worker. record.lease.claimStatus !== 'conflicted' && - record.lease.claimStatus !== 'released' && + (record.lease.claimStatus !== 'released' || tabListed) && structuredWorkerHostScope(record.location) ) } +type StructuredWorkerResourceRow = { + terminal_handle: string + pane_key: string | null + process_incarnation: string | null + worktree_id: string | null + host_scope: string | null +} + +/** A worker's identity as its durable worker-terminal resource row records it, or null for a row + * that is not a structured worker's or whose pane key does not belong to its own session. */ +export function structuredWorkerIdentityFromRow( + row: StructuredWorkerResourceRow +): StructuredWorkerIdentity | null { + const sessionId = sessionIdFromStructuredWorkerIncarnation(row.process_incarnation) + const hostScope = parseWorkerTerminalHostScope(row.host_scope) + const paneKey = row.pane_key + if ( + !sessionId || + !hostScope || + !row.worktree_id || + !paneKey || + !isStructuredWorkerHandle(row.terminal_handle) || + !persistedStructuredWorkerPaneKeyIsValid(paneKey, sessionId) + ) { + return null + } + return { + handle: row.terminal_handle, + sessionId, + // The row does not carry the provider; callers that need it read the durable record. + agent: null, + paneKey, + processIncarnation: structuredWorkerProcessIncarnation(sessionId), + worktreeId: row.worktree_id, + hostScope + } +} + export class StructuredWorkerIdentityRegistry { private readonly byHandle = new Map() private readonly bySessionId = new Map() @@ -183,35 +237,9 @@ export class StructuredWorkerIdentityRegistry { * only place a structured worker's pane key and host scope outlive this process. A row whose * pane key does not belong to its own recorded session is refused rather than trusted. */ - rehydrate(row: { - terminal_handle: string - pane_key: string | null - process_incarnation: string | null - worktree_id: string | null - host_scope: string | null - }): StructuredWorkerIdentity | null { - const sessionId = sessionIdFromStructuredWorkerIncarnation(row.process_incarnation) - const hostScope = parseWorkerTerminalHostScope(row.host_scope) - if ( - !sessionId || - !hostScope || - !row.worktree_id || - !isStructuredWorkerHandle(row.terminal_handle) || - // The durable row bootstraps the registry after restart, so validate it before registration. - !persistedStructuredWorkerPaneKeyIsValid(row.pane_key, sessionId) - ) { - return null - } - return this.register({ - handle: row.terminal_handle, - sessionId, - // The row does not carry the provider; callers that need it read the durable record. - agent: null, - paneKey: row.pane_key, - processIncarnation: structuredWorkerProcessIncarnation(sessionId), - worktreeId: row.worktree_id, - hostScope - }) + rehydrate(row: StructuredWorkerResourceRow): StructuredWorkerIdentity | null { + const identity = structuredWorkerIdentityFromRow(row) + return identity ? this.register(identity) : null } clear(): void { diff --git a/src/main/runtime/structured-worker-mail-routing.test.ts b/src/main/runtime/structured-worker-mail-routing.test.ts index c426132b848..0413c7b6fa6 100644 --- a/src/main/runtime/structured-worker-mail-routing.test.ts +++ b/src/main/runtime/structured-worker-mail-routing.test.ts @@ -20,9 +20,16 @@ const prototype = OrcaRuntimeWithAdoptTerminalOrphansFromInventory.prototype const getLivePaneKey = prototype.getLiveTerminalPaneKey const resolveActiveTerminal = prototype.resolveActiveTerminal -function installRecord(lease: { runtimeKind: string; claimStatus: string } | null): void { +function installRecord( + lease: { runtimeKind: string; claimStatus: string } | null, + tabListed = false +): void { hostRef.current = lease ? { + getPersistedVisibleSessionTabIndex: () => ({ + present: true, + sessionIds: tabListed ? [SESSION_ID] : [] + }), deps: { store: { getRecord: () => ({ @@ -73,10 +80,19 @@ describe('bare-handle direct mail to a structured session', () => { ) }) - it('withholds the pane key when the session is not proven live', () => { - // The PTY branch is connected-gated so mail is never routed to a corpse; so is this one. + it('routes to a worker at rest, whose agent the mail starts', () => { + // Released by the idle sweep with its chat tab still listed: owned, so mail reaches it. const handle = registerWorker() - installRecord({ runtimeKind: 'native', claimStatus: 'reserved' }) + installRecord({ runtimeKind: 'native', claimStatus: 'released' }, true) + expect(getLivePaneKey.call(paneKeyStub, handle)).toBe( + structuredWorkerIdentities.get(handle)!.paneKey + ) + }) + + it('withholds the pane key from a retired worker: released, with its tab gone', () => { + // Mail is never routed to a worker this runtime no longer owns. + const handle = registerWorker() + installRecord({ runtimeKind: 'native', claimStatus: 'released' }, false) expect(getLivePaneKey.call(paneKeyStub, handle)).toBeNull() }) diff --git a/src/main/runtime/structured-worker-terminal-read.test.ts b/src/main/runtime/structured-worker-terminal-read.test.ts index 802b23733bc..e49f24ba1a1 100644 --- a/src/main/runtime/structured-worker-terminal-read.test.ts +++ b/src/main/runtime/structured-worker-terminal-read.test.ts @@ -75,22 +75,22 @@ describe('reading a structured worker through the terminal-read path', () => { hostRef.current = null }) - it('serves the journal as terminal lines, with no dispatch and no capability', () => { + it('serves the journal as terminal lines, with no dispatch and no capability', async () => { // The defect this pins: a peer has no dispatch id and no coordinator standing, so `worker-read` // is closed to it, and `terminal read` threw `terminal_handle_stale` for a perfectly live // worker. A peer could not see a structured agent's recent output at all. const handle = registerWorker() installHost({ items: [message('i1', 'first line\nsecond line'), message('i2', 'done')] }) - const read = readStructuredWorkerTerminal({ handle, db: null }) + const read = await readStructuredWorkerTerminal({ handle, db: null }) expect(read?.tail).toEqual(['[assistant] first line', 'second line', '[assistant] done']) expect(read?.status).toBe('running') expect(read?.truncated).toBe(false) }) - it('honours limit, and claims no cursor space it cannot honour', () => { + it('honours limit, and claims no cursor space it cannot honour', async () => { const handle = registerWorker() installHost({ items: [message('i1', 'a'), message('i2', 'b'), message('i3', 'c')] }) - const read = readStructuredWorkerTerminal({ handle, db: null, limit: 2 }) + const read = await readStructuredWorkerTerminal({ handle, db: null, limit: 2 }) expect(read?.tail).toEqual(['[assistant] b', '[assistant] c']) // No index is advertised: the next read re-projects a sliding window, so 0/length would name // positions that address different lines by then. @@ -99,7 +99,7 @@ describe('reading a structured worker through the terminal-read path', () => { expect(read?.latestCursor).toBeUndefined() }) - it('refuses a cursor read rather than silently misdelivering lines', () => { + it('refuses a cursor read rather than silently misdelivering lines', async () => { // The PTY cursor indexes an append-only completed-line buffer with a monotone count. This // window is a bounded tail re-projected every read, so a saved index addresses different lines // as the journal grows — and `truncated` could never fire to say so, because it tests @@ -107,14 +107,10 @@ describe('reading a structured worker through the terminal-read path', () => { // duplicated lines with `truncated:false`. const handle = registerWorker() installHost({ items: [message('i1', 'a')] }) - const refusal = (() => { - try { - readStructuredWorkerTerminal({ handle, db: null, cursor: 0 }) - return '' - } catch (error) { - return (error as Error).message - } - })() + const refusal = await readStructuredWorkerTerminal({ handle, db: null, cursor: 0 }).then( + () => '', + (error: unknown) => (error instanceof Error ? error.message : String(error)) + ) expect(refusal).toMatch(/not line-addressable/) // Tells the caller what DOES work here. Polling a bounded newest-last tail and diffing fails // safe — a harmless re-read — where a broken cursor fails unsafe, as a silent hole. @@ -125,35 +121,35 @@ describe('reading a structured worker through the terminal-read path', () => { expect(refusal).not.toContain('worker-read') }) - it('reports dropped history as truncated rather than pretending the page is whole', () => { + it('reports dropped history as truncated rather than pretending the page is whole', async () => { const handle = registerWorker() installHost({ items: [message('i1', 'tail only')], hasOlder: true }) - expect(readStructuredWorkerTerminal({ handle, db: null })?.truncated).toBe(true) + expect((await readStructuredWorkerTerminal({ handle, db: null }))?.truncated).toBe(true) }) - it('redacts dispatch capability tokens the same way the archive path does', () => { + it('redacts dispatch capability tokens the same way the archive path does', async () => { const handle = registerWorker() const token = `dcap_${'a'.repeat(32)}` installHost({ items: [message('i1', `token is ${token} here`)] }) - const tail = readStructuredWorkerTerminal({ handle, db: null })?.tail.join('\n') ?? '' + const tail = (await readStructuredWorkerTerminal({ handle, db: null }))?.tail.join('\n') ?? '' expect(tail).not.toContain(token) expect(tail).toContain('[dispatch capability redacted]') }) - it('refuses when the session is not attached rather than answering an empty tail', () => { + it('refuses when the session is not attached rather than answering an empty tail', async () => { // An empty tail is the claim "this worker has produced no output", which is a different and // false statement — and the one a caller cannot tell apart from a real silence. const handle = registerWorker() installHost({ items: 'unreadable' }) - expect(() => readStructuredWorkerTerminal({ handle, db: null })).toThrow( + await expect(readStructuredWorkerTerminal({ handle, db: null })).rejects.toThrow( 'agent_session_ownership_unknown' ) }) - it('reports a session it cannot verify as unknown, never as running', () => { + it('reports a session it cannot verify as unknown, never as running', async () => { const handle = registerWorker() installHost({ items: [message('i1', 'said something')], hasSession: false }) - expect(readStructuredWorkerTerminal({ handle, db: null })?.status).toBe('unknown') + expect((await readStructuredWorkerTerminal({ handle, db: null }))?.status).toBe('unknown') }) it('is what `terminal read` answers with, ahead of the PTY lookup', async () => { @@ -182,15 +178,15 @@ describe('reading a structured worker through the terminal-read path', () => { }) }) - it('leaves every handle that is not a live structured worker to the PTY path', () => { + it('leaves every handle that is not a live structured worker to the PTY path', async () => { const handle = registerWorker() installHost({ items: [message('i1', 'x')] }) - expect(readStructuredWorkerTerminal({ handle: 'term_abc', db: null })).toBeNull() + expect(await readStructuredWorkerTerminal({ handle: 'term_abc', db: null })).toBeNull() // A terminal owner an older build recorded loads conflicted: not this runtime's worker. installHost({ items: [message('i1', 'x')], lease: { runtimeKind: 'native', claimStatus: 'conflicted' } }) - expect(readStructuredWorkerTerminal({ handle, db: null })).toBeNull() + expect(await readStructuredWorkerTerminal({ handle, db: null })).toBeNull() }) }) diff --git a/src/main/runtime/structured-worker-terminal-read.ts b/src/main/runtime/structured-worker-terminal-read.ts index 9b4a118b8ae..60dd1126067 100644 --- a/src/main/runtime/structured-worker-terminal-read.ts +++ b/src/main/runtime/structured-worker-terminal-read.ts @@ -29,7 +29,7 @@ */ import type { RuntimeTerminalRead } from '../../shared/runtime-types' -import { formatWorkerTranscriptMessage } from '../../shared/worker-transcript-text' +import { formatWorkerTranscriptMessages } from '../../shared/worker-transcript-text' import { AGENT_SESSION_NOT_ATTACHED } from '../native-chat/agent-session-wire/structured-agent-session-mutation-admission' import type { OrchestrationDb } from './orchestration/db' import { boundStructuredJournalTail } from './orchestration/structured-worker-journal-archive' @@ -48,12 +48,12 @@ import { readTerminalTail } from './terminal-tail-read' * IS a structured worker never falls through: an unreadable journal refuses rather than answering * an empty tail, which a caller cannot tell from a worker that has said nothing. */ -export function readStructuredWorkerTerminal(args: { +export async function readStructuredWorkerTerminal(args: { handle: string db: OrchestrationDb | null cursor?: number limit?: number -}): RuntimeTerminalRead | null { +}): Promise { const identity = resolveStructuredWorkerAuthority(args.handle, args.db)?.identity if (!identity) { return null @@ -76,7 +76,7 @@ export function readStructuredWorkerTerminal(args: { 'A structured session has no durable line anchor to page from — nothing else does either.' ) } - const page = readStructuredJournalPage(identity.sessionId) + const page = await readStructuredJournalPage(identity.sessionId) if (!page) { // Honest refusal, and the same one the send lane reports: an empty tail would read as "this // worker has produced no output", which is a different and false claim. @@ -84,9 +84,7 @@ export function readStructuredWorkerTerminal(args: { } // Redacts dispatch capabilities and clips oversized blocks under the archive path's byte bound. const bounded = boundStructuredJournalTail(page.items) - const lines = bounded.messages.flatMap((message) => - formatWorkerTranscriptMessage(message).split('\n') - ) + const lines = formatWorkerTranscriptMessages(bounded.messages).flatMap((text) => text.split('\n')) const read = readTerminalTail({ handle: args.handle, status: structuredWorkerTerminalState(observeStructuredWorker(identity).status), diff --git a/src/main/runtime/terminal-chat-owner-persistence.test.ts b/src/main/runtime/terminal-chat-owner-persistence.test.ts new file mode 100644 index 00000000000..a489285d27e --- /dev/null +++ b/src/main/runtime/terminal-chat-owner-persistence.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime-test-mocks.spec' +import { + HEADLESS_LEAF_ID, + TEST_WORKTREE_ID, + makeRuntimeStoreWithWorkspaceSession, + makeWorkspaceSessionWithHeadlessTerminal +} from './orca-runtime-test-fixtures.spec' +import { UpdatePaneLayout } from '../../shared/rpc-contract/session-tabs-schemas-params' +import type { RuntimeStore } from './runtime-store-contract' + +describe('remote terminal chat ownership', () => { + it('preserves, changes, and explicitly clears the owner across the RPC and restart boundary', async () => { + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal() + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The shared fixture implements RuntimeStore; its annotation erases the Vitest mock call signatures. + const runtime = new OrcaRuntimeService(runtimeStore as RuntimeStore) + const update = (owner: string | null | undefined) => { + const params = UpdatePaneLayout.parse({ + worktree: `id:${TEST_WORKTREE_ID}`, + tabId: 'host-tab', + root: getSession().terminalLayoutsByTabId['host-tab']!.root, + expandedLeafId: null, + ...(owner !== undefined ? { chatLeafId: owner } : {}) + }) + return runtime.updateMobileSessionPaneLayout(params.worktree, { + ...params, + expandedLeafId: params.expandedLeafId ?? null + }) + } + await update(HEADLESS_LEAF_ID) + expect(getSession().terminalLayoutsByTabId['host-tab']?.chatLeafId).toBe(HEADLESS_LEAF_ID) + // An older client changing geometry must not clear the owner. + await update(undefined) + expect(getSession().terminalLayoutsByTabId['host-tab']?.chatLeafId).toBe(HEADLESS_LEAF_ID) + runtime['mobileSessionTabsByWorktree'].delete(TEST_WORKTREE_ID) + runtime['hydrateHeadlessMobileSessionTabsFromWorkspaceSession'](TEST_WORKTREE_ID) + const rehydrated = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + const surface = rehydrated.tabs.find( + (tab) => tab.type === 'terminal' && tab.parentTabId === 'host-tab' + ) + expect(surface?.type === 'terminal' && surface.parentLayout?.chatLeafId).toBe(HEADLESS_LEAF_ID) + await update(null) + expect(getSession().terminalLayoutsByTabId['host-tab']?.chatLeafId).toBeUndefined() + const cleared = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + expect( + cleared.tabs + .filter((tab) => tab.type === 'terminal') + .every((tab) => !tab.parentLayout?.chatLeafId) + ).toBe(true) + }) +}) diff --git a/src/main/runtime/terminal-close-observed-exit-test-fixture.ts b/src/main/runtime/terminal-close-observed-exit-test-fixture.ts index 468508f896d..81287bafc59 100644 --- a/src/main/runtime/terminal-close-observed-exit-test-fixture.ts +++ b/src/main/runtime/terminal-close-observed-exit-test-fixture.ts @@ -37,8 +37,7 @@ export async function runObservedExitSocketScenario(scenario: ObservedExitSocket rememberSyntheticKillExit: harness.session.rememberSyntheticKillExit, sendPtyExitToRenderer: harness.session.sendPtyExitToRenderer, finishPtyShutdown, - retiredRejectedPtyIds: new Map(), - reversibleStopOwnersByPtyId: new Map() + retiredRejectedPtyIds: new Map() } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: stop/kill read only these controller ports and optional store; spawn ports are unused. const deps = ports as unknown as PtyRuntimeControllerDeps @@ -103,8 +102,8 @@ const BINDING = { } class ObservedExitRuntime extends OrcaRuntimeService { - closeControl(): Promise { - return this.stopExplicitlyClosedTabPtys([CONTROL_PTY_ID], CONTROL_PTY_ID) + async closeControl(): Promise { + return (await this.stopExplicitlyClosedTabPtys([CONTROL_PTY_ID], CONTROL_PTY_ID)).stopped } } diff --git a/src/main/runtime/terminal-command-paint.test.ts b/src/main/runtime/terminal-command-paint.test.ts new file mode 100644 index 00000000000..54493da686f --- /dev/null +++ b/src/main/runtime/terminal-command-paint.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { + hasTerminalCommandPainted, + observeTerminalCommandPaint, + type TerminalCommandPaintRecord +} from './terminal-command-paint' +import { normalizeTerminalChunk } from './terminal-ansi-normalization' + +function observe(record: TerminalCommandPaintRecord, data: string): void { + observeTerminalCommandPaint(record, data, normalizeTerminalChunk(data).text) +} + +describe('terminal command paint', () => { + it('reads a pane with no command-start marker as painted', () => { + const record: TerminalCommandPaintRecord = {} + observe(record, '~/repo % ') + expect(hasTerminalCommandPainted(record)).toBe(true) + }) + + it('does not count the prompt, the echoed command, or a title set after the marker', () => { + const record: TerminalCommandPaintRecord = {} + observe(record, '\x1b]133;A\x07~/repo % amp\r\n\x1b]133;C\x07\x1b]0;amp\x07') + expect(hasTerminalCommandPainted(record)).toBe(false) + observe(record, '\x1b[?1049h\x1b[?25l\r\n') + expect(hasTerminalCommandPainted(record)).toBe(false) + observe(record, '\x1b[2;3H╭─ Amp') + expect(hasTerminalCommandPainted(record)).toBe(true) + }) + + it('counts a paint that arrives in the same chunk as the marker', () => { + const record: TerminalCommandPaintRecord = {} + observe(record, '\x1b]133;C\x1b\\\x1b[?1049hLIVE-TUI') + expect(hasTerminalCommandPainted(record)).toBe(true) + }) + + it('finds a marker split across chunks, and counts only output after it', () => { + const record: TerminalCommandPaintRecord = {} + observe(record, '~/repo % amp\r\n\x1b]13') + observe(record, '3;C\x07') + expect(hasTerminalCommandPainted(record)).toBe(false) + observe(record, '\x1b]133;C;cmdline_url=') + observe(record, 'amp\x07╭─ Amp') + expect(hasTerminalCommandPainted(record)).toBe(true) + }) + + it('starts over at the next command', () => { + const record: TerminalCommandPaintRecord = {} + observe(record, '\x1b]133;C\x07goose> ') + observe(record, '\x1b]133;D;0\x07\x1b]133;A\x07% goose\r\n\x1b]133;C\x07') + expect(hasTerminalCommandPainted(record)).toBe(false) + }) +}) diff --git a/src/main/runtime/terminal-command-paint.ts b/src/main/runtime/terminal-command-paint.ts new file mode 100644 index 00000000000..d415d52dae7 --- /dev/null +++ b/src/main/runtime/terminal-command-paint.ts @@ -0,0 +1,66 @@ +import { stripAnsiEscapeSequences } from '../../shared/ansi-escape-sequences' +import { createOsc133CommandFinishedScanner } from '../../shared/terminal-osc133-command-finished' +import { normalizeTerminalChunk } from './terminal-ansi-normalization' + +// eslint-disable-next-line no-control-regex -- control bytes are exactly what is not visible. +const VISIBLE_CHARACTER_RE = /[^\s\u0000-\u001f\u007f-\u009f]/ + +function hasVisibleText(normalizedText: string): boolean { + return VISIBLE_CHARACTER_RE.test(stripAnsiEscapeSequences(normalizedText)) +} + +/** + * Where the running command's own output begins. Orca's shell integration prints OSC 133;C + * after the prompt and the echoed command line, right before the command runs, so only + * visible output after it can be the command's. + */ +export class TerminalCommandPaint { + private awaitingPaint = false + private markerEndInChunk = -1 + private readonly scanner = createOsc133CommandFinishedScanner( + () => {}, + (endInChunk) => { + this.markerEndInChunk = endInChunk + } + ) + + observe(data: string, normalizedText: string): void { + this.markerEndInChunk = -1 + this.scanner.scan(data) + if (this.markerEndInChunk !== -1) { + // Why the rest of this chunk counts: a fast binary can paint in the same read as the marker. + this.awaitingPaint = !hasVisibleText( + normalizeTerminalChunk(data.slice(this.markerEndInChunk)).text + ) + return + } + if (this.awaitingPaint && hasVisibleText(normalizedText)) { + this.awaitingPaint = false + } + } + + hasPainted(): boolean { + return !this.awaitingPaint + } +} + +export type TerminalCommandPaintRecord = { commandPaint?: TerminalCommandPaint } + +/** `normalizedText` is the chunk as the tail buffer received it (pending escapes resolved). */ +export function observeTerminalCommandPaint( + record: TerminalCommandPaintRecord, + data: string, + normalizedText: string +): void { + record.commandPaint ??= new TerminalCommandPaint() + record.commandPaint.observe(data, normalizedText) +} + +/** + * Why no marker reads as painted: some launches get no command boundary (older fish, SSH relay + * zsh, shells Orca could not wrap, and Windows launches that pass the command in shell args), so + * any output is all the evidence there is. + */ +export function hasTerminalCommandPainted(record: TerminalCommandPaintRecord): boolean { + return record.commandPaint?.hasPainted() ?? true +} diff --git a/src/main/runtime/terminal-input-kind-unchecked-call-sites.test.ts b/src/main/runtime/terminal-input-kind-unchecked-call-sites.test.ts new file mode 100644 index 00000000000..e806e88320c --- /dev/null +++ b/src/main/runtime/terminal-input-kind-unchecked-call-sites.test.ts @@ -0,0 +1,80 @@ +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { scanSourceTree } from '../../shared/source-scan/source-tree-scan' +import { + findCallsMissingArgument, + type RequiredCallArgument +} from '../../shared/source-scan/call-argument-scan' + +/** + * Every PTY write names its input kind, and the compiler enforces that everywhere except the + * runtime files split out with `@ts-nocheck`. There a missing kind would compile and silently + * record nothing, so this scan is the ratchet for them. + */ +const MAIN_ROOT = resolve(__dirname, '..') + +const namesInputKind = (literal: string): boolean => /\binputKind\b|\.\.\./.test(literal) +const kindAt = (index: number, receiver?: RegExp): RequiredCallArgument => ({ + index, + ...(receiver ? { receiver } : {}), + acceptsObjectLiteral: namesInputKind +}) +const CONTROLLER = /[Cc]ontroller\??\s*$/ + +const KIND_ARGUMENT_BY_METHOD: Record = { + write: kindAt(2, CONTROLLER), + writeWithSettlement: kindAt(2, CONTROLLER), + sendTerminal: kindAt(2), + sendTerminalAgentPrompt: kindAt(2), + writeTerminalAction: kindAt(3), + writeTerminalInputChunks: kindAt(2), + writeTerminalAgentPrompt: kindAt(4), + writeAction: kindAt(3), + writeChunks: kindAt(2) +} + +// Why multiline: some unchecked files open with a lint directive before `@ts-nocheck`. +const uncheckedSources = scanSourceTree(MAIN_ROOT).filter((file) => + /^\/\/ @ts-nocheck\b/m.test(file.source) +) + +describe('PTY write call sites the compiler cannot check', () => { + it('scans the unchecked runtime files that write to a PTY', () => { + expect(uncheckedSources.map((file) => file.relativePath)).toEqual( + expect.arrayContaining([ + 'runtime/orca-runtime-deliver-pending-messages.ts', + 'runtime/orca-runtime-create-pty-headless-terminal-state.ts', + 'runtime/orca-runtime-write-terminal-agent-prompt.ts', + 'runtime/orca-runtime-sync-window-graph.ts' + ]) + ) + }) + + it('finds a write, prompt or send that leaves out its kind', () => { + const planted = [ + '', + 'this.ptyController?.write(ptyId, reply)', + "this.ptyController.write(ptyId, '\\r', 'launch')", + "await this.sendTerminal(handle, { text: 'a, b' }, { beforeWrite })", + 'await this.sendTerminalAgentPrompt(handle, prompt, { ...options })', + 'other.write(ptyId, data)', + 'const controller = this.ptyController; controller.write(ptyId, data)' + ].join('\n') + + expect(findCallsMissingArgument(planted, KIND_ARGUMENT_BY_METHOD)).toEqual([ + '2: .write(ptyId, reply)', + "4: .sendTerminal(handle, { text: 'a, b' }, { beforeWrite })", + '7: .write(ptyId, data)' + ]) + }) + + it('passes an input kind at every write', () => { + const missing = uncheckedSources.flatMap((file) => + findCallsMissingArgument(file.source, KIND_ARGUMENT_BY_METHOD).map( + (site) => `${file.relativePath}:${site}` + ) + ) + + expect(missing).toEqual([]) + }) +}) diff --git a/src/main/runtime/terminal-intentional-stop-exit.test.ts b/src/main/runtime/terminal-intentional-stop-exit.test.ts new file mode 100644 index 00000000000..0c3f996153f --- /dev/null +++ b/src/main/runtime/terminal-intentional-stop-exit.test.ts @@ -0,0 +1,288 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { BrowserWindow } from 'electron' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../shared/stable-pane-id' +import { Store } from '../persistence/loading-store/store' +import { ProfileStateSqliteAuthority } from '../persistence/profile-state/profile-state-sqlite-authority' +import { wirePtyIpcSession } from '../ipc/pty/delivery/wire-session' +import { SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS } from '../ipc/pty/delivery/visibility-state' +import { bindProviderListeners } from '../ipc/pty/provider/bind-listeners' +import { + stopRendererOwnedPty, + stopReplacedPanePty, + type PtyKillIpcDeps +} from '../ipc/pty/ipc/renderer-kill' +import { ptyIncarnationById, ptyOwnership } from '../ipc/pty/provider/ownership-state' +import { getLocalPtyProvider, setLocalPtyProvider } from '../ipc/pty/provider/registry' +import { createPtyIpcSession } from '../ipc/pty/session' +import type { IPtyProvider } from '../providers/types' +import { OrcaRuntimeService } from './orca-runtime' +import { + INCARNATION_ID, + LEAF_ID, + PTY_ID, + REPO_ID, + TAB_ID, + WORKTREE_ID, + WORKTREE_PATH, + makeSession +} from './__fixtures__/orca-runtime-terminal-close-continuity-state-fixture' +import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' + +const REPLACEMENT_PTY_ID = 'pty-close-continuity-replacement' +const REPLACEMENT_INCARNATION_ID = '77777777-7777-4777-8777-777777777777' +const LATER_INCARNATION_ID = '88888888-8888-4888-8888-888888888888' + +const directories: string[] = [] +const stores: Store[] = [] +const priorProvider = getLocalPtyProvider() +afterEach(() => { + vi.useRealTimers() + setLocalPtyProvider(priorProvider) + ptyOwnership.delete(PTY_ID) + ptyIncarnationById.delete(PTY_ID) + for (const store of stores.splice(0)) { + store.freezeWrites() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +/** A real store and runtime with one bound pane, and main's exit delivery to a renderer stub. + * `lateProviderExit`: the kill's reply overtakes the exit, so main synthesizes one and the + * provider's own exit arrives later through its listener. */ +function createHarness(opts: { lateProviderExit?: boolean; folder?: boolean } = {}) { + const directory = mkdtempSync(join(tmpdir(), 'orca-intentional-stop-')) + directories.push(directory) + const store = new Store({ + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: new ProfileStateSqliteAuthority( + join(directory, 'profile-state.db'), + 'intentional-stop' + ) + }) + stores.push(store) + store.addRepo({ + id: REPO_ID, + path: WORKTREE_PATH, + displayName: 'Fixture', + badgeColor: 'gray', + addedAt: 1, + // Why: a folder workspace resolves without git, which the sleep transaction needs. + ...(opts.folder ? { kind: 'folder' as const } : {}) + }) + store.setWorkspaceSession(advanceTerminalTopologyRevision(makeSession(), WORKTREE_ID)) + store.flushOrThrow() + const runtime = new OrcaRuntimeService(store) + runtime.registerPty(PTY_ID, WORKTREE_ID, null, { + tabId: TAB_ID, + leafId: LEAF_ID, + incarnationId: INCARNATION_ID + }) + ptyOwnership.set(PTY_ID, null) + ptyIncarnationById.set(PTY_ID, INCARNATION_ID) + let emitProviderExit: + | ((payload: { id: string; code: number; incarnationId?: string }) => void) + | undefined + const provider = { + onData: () => () => {}, + onExit: (listener: typeof emitProviderExit) => { + emitProviderExit = listener + return () => {} + } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the listeners bind only onData and onExit, and the renderer kill hands the provider to the shutdown port below. + setLocalPtyProvider(provider as unknown as IPtyProvider) + const rendererSend = vi.fn() + const window = { isDestroyed: () => false, webContents: { send: rendererSend } } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: exit delivery reads only isDestroyed and webContents.send. + const session = createPtyIpcSession({ mainWindow: window as unknown as BrowserWindow, runtime }) + wirePtyIpcSession(session) + bindProviderListeners(session) + const deps: PtyKillIpcDeps = { + store, + runtime, + getLocalPtyProviderStartupPromise: () => undefined, + // The provider's own exit, delivered the way its listener delivers it. + shutdownProviderAndDetectExit: async (_provider, id) => { + if (opts.lateProviderExit) { + return false + } + runtime.onPtyExit(id, 0, INCARNATION_ID, { providerExitObserved: true }) + session.sendPtyExitToRenderer({ id, code: 0, incarnationId: INCARNATION_ID }) + return true + }, + rememberSyntheticKillExit: session.rememberSyntheticKillExit, + sendPtyExitToRenderer: session.sendPtyExitToRenderer + } + return { + store, + runtime, + deps, + emitProviderExit: (incarnationId: string) => + emitProviderExit?.({ id: PTY_ID, code: 0, incarnationId }), + boundPtyId: () => + store.getWorkspaceSession().terminalLayoutsByTabId[TAB_ID]?.ptyIdsByLeafId?.[LEAF_ID] ?? null, + tabIds: () => (store.getWorkspaceSession().tabsByWorktree[WORKTREE_ID] ?? []).map((t) => t.id), + rendererExits: () => + rendererSend.mock.calls.filter(([channel]) => channel === 'pty:exit').map(([, p]) => p) + } +} + +describe('intentional stops keep the pane through the exit', () => { + it('retires the pane when an ordinary close ends the process', async () => { + const harness = createHarness() + + await stopRendererOwnedPty(harness.deps, { id: PTY_ID }) + + expect(harness.boundPtyId()).toBeNull() + expect(harness.rendererExits()).toEqual([ + { id: PTY_ID, code: 0, incarnationId: INCARNATION_ID } + ]) + }) + + it('keeps the tab and its wake binding when the renderer hibernates the pane', async () => { + const harness = createHarness() + + await stopRendererOwnedPty(harness.deps, { id: PTY_ID, keepHistory: true }) + + expect(harness.tabIds()).toEqual([TAB_ID]) + expect(harness.boundPtyId()).toBe(PTY_ID) + expect(harness.rendererExits()).toEqual([ + { id: PTY_ID, code: 0, incarnationId: INCARNATION_ID, preserveRendererBinding: true } + ]) + }) + + it('keeps a typed pane that a restart replaces, and binds the replacement', async () => { + const harness = createHarness() + harness.runtime.terminalRunFacts.recordSpawnCommit({ + id: PTY_ID, + incarnationId: INCARNATION_ID + }) + harness.runtime.terminalRunFacts.recordInput(PTY_ID, 'driving', 'ls\r') + + await stopReplacedPanePty(harness.deps, PTY_ID) + expect(harness.boundPtyId()).toBe(PTY_ID) + await harness.store.persistPtyBinding({ + worktreeId: WORKTREE_ID, + tabId: TAB_ID, + leafId: LEAF_ID, + ptyId: REPLACEMENT_PTY_ID, + incarnationId: REPLACEMENT_INCARNATION_ID, + origin: 'spawn' + }) + + expect(harness.tabIds()).toEqual([TAB_ID]) + expect(harness.boundPtyId()).toBe(REPLACEMENT_PTY_ID) + expect( + harness.store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[ + makePaneKey(TAB_ID, LEAF_ID) + ] + ).toBe(REPLACEMENT_INCARNATION_ID) + expect(harness.rendererExits()).toEqual([ + { id: PTY_ID, code: 0, incarnationId: INCARNATION_ID, replacedByRestart: true } + ]) + }) + + it('labels the exit for both a sleep and a restart that stop the same process', async () => { + const harness = createHarness() + const settleSleep = harness.runtime.intentionalPtyStops.mark( + PTY_ID, + 'reversible', + INCARNATION_ID + ) + + await stopReplacedPanePty(harness.deps, PTY_ID) + settleSleep(true) + + expect(harness.boundPtyId()).toBe(PTY_ID) + expect(harness.rendererExits()).toEqual([ + { + id: PTY_ID, + code: 0, + incarnationId: INCARNATION_ID, + preserveRendererBinding: true, + replacedByRestart: true + } + ]) + }) + + it('keeps the pane through the synthetic exit and the provider exit that follows it', async () => { + const harness = createHarness({ lateProviderExit: true }) + + await stopRendererOwnedPty(harness.deps, { id: PTY_ID, keepHistory: true }) + harness.emitProviderExit(INCARNATION_ID) + + expect(harness.tabIds()).toEqual([TAB_ID]) + expect(harness.boundPtyId()).toBe(PTY_ID) + expect(harness.rendererExits()).toEqual([ + { id: PTY_ID, code: -1, incarnationId: INCARNATION_ID, preserveRendererBinding: true } + ]) + }) + + it('never reads the exit of a later process on the same id as the stop', async () => { + const harness = createHarness({ lateProviderExit: true }) + await stopRendererOwnedPty(harness.deps, { id: PTY_ID, keepHistory: true }) + harness.runtime.registerPty(PTY_ID, WORKTREE_ID, null, { + tabId: TAB_ID, + leafId: LEAF_ID, + incarnationId: LATER_INCARNATION_ID + }) + ptyIncarnationById.set(PTY_ID, LATER_INCARNATION_ID) + await harness.store.persistPtyBinding({ + worktreeId: WORKTREE_ID, + tabId: TAB_ID, + leafId: LEAF_ID, + ptyId: PTY_ID, + incarnationId: LATER_INCARNATION_ID, + origin: 'reattach' + }) + + harness.emitProviderExit(LATER_INCARNATION_ID) + + // Why wait: an unstopped exit retires the pane through an async durable save. + await vi.waitFor(() => expect(harness.boundPtyId()).toBeNull()) + expect(harness.rendererExits().at(-1)).toEqual({ + id: PTY_ID, + code: 0, + incarnationId: LATER_INCARNATION_ID + }) + }) + + it('forgets the stop once the duplicate-exit window after it closes', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const harness = createHarness({ lateProviderExit: true }) + await stopRendererOwnedPty(harness.deps, { id: PTY_ID, keepHistory: true }) + + vi.advanceTimersByTime(SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS - 1) + expect(harness.runtime.intentionalPtyStops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([ + 'reversible' + ]) + vi.advanceTimersByTime(1) + + expect(harness.runtime.intentionalPtyStops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([]) + }) + + it('keeps the tab and its wake binding when the runtime puts the worktree to sleep', async () => { + const harness = createHarness({ folder: true }) + const inventories = [[{ id: PTY_ID, worktreeId: WORKTREE_ID, cwd: WORKTREE_PATH, title: 'a' }]] + harness.runtime.setPtyController({ + write: () => true, + kill: () => true, + stopAndWait: async (ptyId) => { + harness.runtime.onPtyExit(ptyId, -1, INCARNATION_ID, { providerExitObserved: true }) + return true + }, + getForegroundProcess: async () => null, + listProcesses: async () => inventories.shift() ?? [] + }) + + await harness.runtime.sleepTerminalsForWorktree(`id:${WORKTREE_ID}`) + + expect(harness.tabIds()).toEqual([TAB_ID]) + expect(harness.boundPtyId()).toBe(PTY_ID) + }) +}) diff --git a/src/main/runtime/terminal-intentional-stops.test.ts b/src/main/runtime/terminal-intentional-stops.test.ts new file mode 100644 index 00000000000..d37fc4147e8 --- /dev/null +++ b/src/main/runtime/terminal-intentional-stops.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS } from '../ipc/pty/delivery/visibility-state' +import { TerminalIntentionalStops } from './terminal-intentional-stops' + +afterEach(() => { + vi.useRealTimers() +}) + +describe('terminal intentional stops', () => { + it('keeps the mark while a second overlapping owner still holds it', () => { + const stops = new TerminalIntentionalStops() + const settleFirst = stops.mark('pty-1', 'reversible', 'inc-1') + const settleSecond = stops.mark('pty-1', 'reversible', 'inc-1') + + settleFirst(false) + + expect(stops.isReversibleStopInFlight('pty-1')).toBe(true) + expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['reversible']) + settleSecond(false) + expect(stops.claimExit('pty-1', 'inc-1')).toEqual([]) + }) + + it('still reads an exit that lands after the stop settled, until the window closes', () => { + vi.useFakeTimers() + const stops = new TerminalIntentionalStops() + const settle = stops.mark('pty-ssh', 'reversible', 'inc-1') + + settle(true) + vi.advanceTimersByTime(SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS - 1) + + expect(stops.isReversibleStopInFlight('pty-ssh')).toBe(false) + expect(stops.claimExit('pty-ssh', 'inc-1')).toEqual(['reversible']) + vi.advanceTimersByTime(1) + expect(stops.claimExit('pty-ssh', 'inc-1')).toEqual([]) + }) + + it('drops the mark at once when the stop fails', () => { + const stops = new TerminalIntentionalStops() + + stops.mark('pty-1', 'replaced', 'inc-1')(false) + + expect(stops.claimExit('pty-1', 'inc-1')).toEqual([]) + }) + + it('reads the synthetic exit and the provider exit of the same process alike', () => { + const stops = new TerminalIntentionalStops() + const settle = stops.mark('pty-1', 'replaced', null) + + expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['replaced']) + settle(true) + + expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['replaced']) + expect(stops.claimExit('pty-1', 'inc-2')).toEqual([]) + }) + + it('never marks the exit of another process that reuses the id', () => { + const stops = new TerminalIntentionalStops() + stops.mark('pty-1', 'reversible', 'inc-1') + + expect(stops.claimExit('pty-1', 'inc-2')).toEqual([]) + }) + + it('starts a new stop of the same id fresh once the prior one settled', () => { + const stops = new TerminalIntentionalStops() + stops.mark('pty-1', 'reversible', 'inc-1')(true) + + const settle = stops.mark('pty-1', 'replaced', 'inc-2') + + expect(stops.claimExit('pty-1', 'inc-1')).toEqual([]) + expect(stops.claimExit('pty-1', 'inc-2')).toEqual(['replaced']) + settle(false) + expect(stops.claimExit('pty-1', 'inc-2')).toEqual([]) + }) + + it('labels one exit with every kind of stop that overlapped on it', () => { + const stops = new TerminalIntentionalStops() + const settleSleep = stops.mark('pty-1', 'reversible', 'inc-1') + const settleRestart = stops.mark('pty-1', 'replaced', 'inc-1') + + expect(stops.isReversibleStopInFlight('pty-1')).toBe(true) + expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['reversible', 'replaced']) + + settleSleep(true) + expect(stops.isReversibleStopInFlight('pty-1')).toBe(false) + settleRestart(false) + expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['reversible']) + }) + + it('keeps a landed stop when a later stop of the same process fails', () => { + const stops = new TerminalIntentionalStops() + stops.mark('pty-1', 'reversible', 'inc-1')(true) + + stops.mark('pty-1', 'reversible', 'inc-1')(false) + + expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['reversible']) + }) + + it('lets a new process on the id supersede a landed stop no exit pinned', () => { + const stops = new TerminalIntentionalStops() + stops.mark('pty-unpinned', 'reversible', null)(true) + stops.mark('pty-pinned', 'reversible', 'inc-1')(true) + stops.mark('pty-in-flight', 'replaced', null) + + for (const ptyId of ['pty-unpinned', 'pty-pinned', 'pty-in-flight']) { + stops.noteSpawnCommit(ptyId) + } + + expect(stops.claimExit('pty-unpinned', null)).toEqual([]) + expect(stops.claimExit('pty-pinned', 'inc-1')).toEqual(['reversible']) + expect(stops.claimExit('pty-in-flight', null)).toEqual(['replaced']) + }) +}) diff --git a/src/main/runtime/terminal-intentional-stops.ts b/src/main/runtime/terminal-intentional-stops.ts new file mode 100644 index 00000000000..b89bc709e5f --- /dev/null +++ b/src/main/runtime/terminal-intentional-stops.ts @@ -0,0 +1,122 @@ +import { SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS } from '../ipc/pty/delivery/visibility-state' + +/** + * Why main stopped a PTY on purpose. Both kinds keep the pane's binding through the exit: + * - `reversible`: sleep or hibernation; the binding is the wake hint. + * - `replaced`: a restart stops the old process so a new one can take the pane. + */ +export type TerminalIntentionalStopKind = 'reversible' | 'replaced' + +type IntentionalStopOwners = { inFlight: number; stopped: boolean } + +type IntentionalStop = { + /** Null until known; the first exit that claims the stop pins it to that process. */ + incarnationId: string | null + /** Why per kind: overlapping stops of different kinds each hold their own label on the exit. */ + ownersByKind: Map + expiryTimer?: ReturnType +} + +const NO_INTENTIONAL_STOP: readonly TerminalIntentionalStopKind[] = [] + +function hasInFlightOwners(stop: IntentionalStop): boolean { + return [...stop.ownersByKind.values()].some((owners) => owners.inFlight > 0) +} + +/** The one register of PTY stops main made on purpose, read by every exit path. */ +export class TerminalIntentionalStops { + private readonly stopsByPtyId = new Map() + + /** Registers one owner's stop; call the result once with whether the stop landed. */ + mark( + ptyId: string, + kind: TerminalIntentionalStopKind, + incarnationId: string | null + ): (stopped: boolean) => void { + let stop = this.stopsByPtyId.get(ptyId) + if (stop && this.joins(stop, incarnationId)) { + clearTimeout(stop.expiryTimer) + stop.expiryTimer = undefined + stop.incarnationId ??= incarnationId + } else { + clearTimeout(stop?.expiryTimer) + stop = { incarnationId, ownersByKind: new Map() } + this.stopsByPtyId.set(ptyId, stop) + } + const owners = stop.ownersByKind.get(kind) ?? { inFlight: 0, stopped: false } + owners.inFlight += 1 + stop.ownersByKind.set(kind, owners) + const owned = stop + let settled = false + return (stopped) => { + if (settled || this.stopsByPtyId.get(ptyId) !== owned) { + return + } + settled = true + owners.stopped ||= stopped + owners.inFlight -= 1 + if (owners.inFlight === 0 && !owners.stopped) { + owned.ownersByKind.delete(kind) + } + this.settleIfIdle(ptyId, owned) + } + } + + /** The kinds of stop this exit ends; empty when the process was not stopped on purpose. */ + claimExit( + ptyId: string, + exitIncarnationId: string | null | undefined + ): readonly TerminalIntentionalStopKind[] { + const stop = this.stopsByPtyId.get(ptyId) + if (!stop) { + return NO_INTENTIONAL_STOP + } + if (stop.incarnationId && exitIncarnationId && stop.incarnationId !== exitIncarnationId) { + return NO_INTENTIONAL_STOP + } + stop.incarnationId ??= exitIncarnationId ?? null + return [...stop.ownersByKind.keys()] + } + + /** Whether a stop of this PTY that may still be undone is in flight. */ + isReversibleStopInFlight(ptyId: string): boolean { + return (this.stopsByPtyId.get(ptyId)?.ownersByKind.get('reversible')?.inFlight ?? 0) > 0 + } + + /** A process committed on this id. A landed stop's process is dead, so an entry no exit ever + * pinned could otherwise claim the new process's exit as the stop. */ + noteSpawnCommit(ptyId: string): void { + const stop = this.stopsByPtyId.get(ptyId) + if (stop && stop.incarnationId === null && !hasInFlightOwners(stop)) { + clearTimeout(stop.expiryTimer) + this.stopsByPtyId.delete(ptyId) + } + } + + // Why: a settled entry joins only its own known process, so an id reused by a process whose + // incarnation is not yet known never inherits the old stop. + private joins(stop: IntentionalStop, incarnationId: string | null): boolean { + if (stop.incarnationId !== null && stop.incarnationId === incarnationId) { + return true + } + return hasInFlightOwners(stop) && (stop.incarnationId === null || incarnationId === null) + } + + private settleIfIdle(ptyId: string, stop: IntentionalStop): void { + if (hasInFlightOwners(stop)) { + return + } + if (stop.ownersByKind.size === 0) { + this.stopsByPtyId.delete(ptyId) + return + } + // Why a window: an SSH exit can arrive after the stop settles, and a synthetic exit can be + // followed by the provider's own; both describe the same stopped process. + stop.expiryTimer = setTimeout(() => { + if (this.stopsByPtyId.get(ptyId) === stop) { + this.stopsByPtyId.delete(ptyId) + } + }, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS) + stop.expiryTimer.unref?.() + } +} diff --git a/src/main/runtime/terminal-interactive-wait-visibility.test.ts b/src/main/runtime/terminal-interactive-wait-visibility.test.ts index 3a6470b41f9..b79ea7a3270 100644 --- a/src/main/runtime/terminal-interactive-wait-visibility.test.ts +++ b/src/main/runtime/terminal-interactive-wait-visibility.test.ts @@ -92,9 +92,9 @@ describe('terminal interactive-wait visibility (STA-4513, STA-3714)', () => { await expect( assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} }) ).rejects.toThrow('terminal_guard_permission') - await expect(runtime.sendTerminalAgentPrompt(handle, 'coordinator preamble')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'coordinator preamble', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') }) it('lets a dispatch preamble through once the same lane is working', async () => { diff --git a/src/main/runtime/terminal-list-pending-pty-registration.test.ts b/src/main/runtime/terminal-list-pending-pty-registration.test.ts new file mode 100644 index 00000000000..62a5daf78e3 --- /dev/null +++ b/src/main/runtime/terminal-list-pending-pty-registration.test.ts @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + createInventoryRuntime, + deferred, + processRow, + PREDECESSOR, + PTY, + WORKTREE +} from './pty-inventory-lifecycle-fixture' + +const TAB = '40000000-0000-4000-8000-000000000001' +const LEAF = '40000000-0000-4000-8000-000000000002' + +afterEach(() => vi.restoreAllMocks()) + +describe('terminal listing while a spawn binding is being persisted', () => { + it.each([ + { host: 'local', ptyId: PTY, connectionId: null, phase: 'binding', incarnationId: PREDECESSOR }, + { + host: 'SSH', + ptyId: 'ssh:host-a@@pty2:pending-spawn:1', + connectionId: 'host-a', + phase: 'binding' + }, + { host: 'local', ptyId: PTY, connectionId: null, phase: 'provider' }, + { + host: 'SSH', + ptyId: 'ssh:host-a@@pty2:pending-spawn:1', + connectionId: 'host-a', + phase: 'provider' + } + ])( + 'does not authorize orphan adoption during $host $phase admission', + async ({ ptyId, connectionId, phase, incarnationId }) => { + const bindingPersisted = deferred() + const { runtime } = createInventoryRuntime(async () => [processRow(ptyId)]) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [{ tabId: TAB, worktreeId: WORKTREE, title: '', activeLeafId: LEAF, layout: null }], + leaves: [ + { + tabId: TAB, + worktreeId: WORKTREE, + leafId: LEAF, + paneRuntimeId: 1, + ptyId: null, + paneTitle: null, + title: '' + } + ] + }) + + const releaseSpawn = + phase === 'provider' ? await runtime.acquireWorktreeTerminalSpawn(WORKTREE) : undefined + if (phase === 'binding') { + runtime.beginPtyRegistration(ptyId, incarnationId) + } + // Spawn commit awaits durable binding persistence before publishing the runtime surface. + const commit = bindingPersisted.promise.then(() => { + runtime.registerPty(ptyId, WORKTREE, connectionId, { + tabId: TAB, + leafId: LEAF, + incarnationId: PREDECESSOR + }) + }) + try { + await expect( + runtime.listTerminals(`id:${WORKTREE}`, undefined, { + requireFreshPtyLiveness: true, + includeVisualLayouts: false + }) + ).rejects.toThrow('terminal_surface_ownership_unavailable') + expect(runtime.capture(ptyId).verdict).toMatchObject({ status: 'live' }) + } finally { + bindingPersisted.resolve() + try { + await commit + } finally { + releaseSpawn?.() + } + } + + const committed = await runtime.listTerminals(`id:${WORKTREE}`, undefined, { + requireFreshPtyLiveness: true, + includeVisualLayouts: false + }) + expect(committed.terminals).toEqual([ + expect.objectContaining({ + ptyId, + tabId: TAB, + leafId: LEAF, + connected: true, + incarnationId: PREDECESSOR, + orphaned: false + }) + ]) + } + ) + + it('continues reporting a committed surface while another spawn is pending', async () => { + const { runtime } = createInventoryRuntime(async () => [processRow()]) + runtime.register() + const releaseSpawn = await runtime.acquireWorktreeTerminalSpawn(WORKTREE) + try { + const listed = await runtime.listTerminals(`id:${WORKTREE}`) + expect(listed.terminals).toEqual([ + expect.objectContaining({ ptyId: PTY, connected: true, orphaned: false }) + ]) + } finally { + releaseSpawn() + } + }) + + it('does not block orphan recovery in an unrelated workspace', async () => { + const { runtime } = createInventoryRuntime(async () => [processRow()]) + const releaseSpawn = await runtime.acquireWorktreeTerminalSpawn('repo::/tmp/another-workspace') + try { + const listed = await runtime.listTerminals(`id:${WORKTREE}`) + expect(listed.terminals).toEqual([ + expect.objectContaining({ ptyId: PTY, connected: true, orphaned: true }) + ]) + } finally { + releaseSpawn() + } + }) + + it('permits orphan recovery once the competing spawn admission has ended', async () => { + const { runtime } = createInventoryRuntime(async () => [processRow()]) + const releaseSpawn = await runtime.acquireWorktreeTerminalSpawn(WORKTREE) + runtime.beginPtyRegistration(PTY, PREDECESSOR) + try { + await expect(runtime.listTerminals(`id:${WORKTREE}`)).rejects.toThrow( + 'terminal_surface_ownership_unavailable' + ) + } finally { + runtime.cancelPendingPtyRegistration(PTY, PREDECESSOR) + releaseSpawn() + } + const listed = await runtime.listTerminals(`id:${WORKTREE}`) + expect(listed.terminals).toEqual([ + expect.objectContaining({ ptyId: PTY, connected: true, orphaned: true }) + ]) + }) +}) diff --git a/src/main/runtime/terminal-query-responder.test.ts b/src/main/runtime/terminal-query-responder.test.ts index 9db883529a0..f479be9e47a 100644 --- a/src/main/runtime/terminal-query-responder.test.ts +++ b/src/main/runtime/terminal-query-responder.test.ts @@ -21,6 +21,7 @@ import { setTerminalViewAttributes } from './terminal-view-attribute-store' import type { TerminalViewAttributes, TerminalViewRgb } from '../../shared/terminal-view-attributes' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' const settingsState = { terminalMainSideEffectAuthority: true as boolean, @@ -58,9 +59,11 @@ type RendererBufferStub = { data: string; cols: number; rows: number } function createResponderRuntime(opts: { rendererBuffer?: RendererBufferStub } = {}) { const runtime = new OrcaRuntimeService(store) const replies: { ptyId: string; data: string }[] = [] + const inputKinds: TerminalInputKind[] = [] runtime.setPtyController({ - write: (ptyId, data) => { + write: (ptyId, data, inputKind) => { replies.push({ ptyId, data }) + inputKinds.push(inputKind) return true }, kill: () => true, @@ -74,7 +77,7 @@ function createResponderRuntime(opts: { rendererBuffer?: RendererBufferStub } = } : {}) }) - return { runtime, replies } + return { runtime, replies, inputKinds } } /** Awaits the per-PTY emulator writeChain so queued chunk links (and the @@ -146,7 +149,7 @@ describe('reply parity for hidden-dropped chunks', () => { ['kitty CSI ? u default flags', '\x1b[?u', ['\x1b[?0u']], ['kitty CSI ? u reports pushed flags', '\x1b[=5;1u\x1b[?u', ['\x1b[?5u']] ])('%s', async (_label, chunk, expectedReplies) => { - const { runtime, replies } = createResponderRuntime() + const { runtime, replies, inputKinds } = createResponderRuntime() markHiddenRendererPty('pty-q') runtime.onPtyData('pty-q', chunk, Date.now()) @@ -154,6 +157,8 @@ describe('reply parity for hidden-dropped chunks', () => { expect(replies.map((reply) => reply.data)).toEqual(expectedReplies) expect(replies.every((reply) => reply.ptyId === 'pty-q')).toBe(true) + // Why: a reply written as driving input would read the untouched run as typed. + expect(inputKinds).toEqual(expectedReplies.map(() => 'query-reply')) }) it.each([ diff --git a/src/main/runtime/terminal-retirement-async-durability.test.ts b/src/main/runtime/terminal-retirement-async-durability.test.ts new file mode 100644 index 00000000000..28d2f1603a9 --- /dev/null +++ b/src/main/runtime/terminal-retirement-async-durability.test.ts @@ -0,0 +1,127 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { + ACK_INCARNATION, + ACK_LEAF, + ACK_SECOND_LEAF, + ACK_TAB, + createAcknowledgedTabRetirementFixture +} from './acknowledged-terminal-tab-retirement-fixture' + +const fixtures: ReturnType[] = [] +afterEach(async () => { + for (const fixture of fixtures.splice(0)) { + await fixture.dispose() + } +}) +function fixture() { + const result = createAcknowledgedTabRetirementFixture(true) + fixtures.push(result) + return result +} + +it('withholds the acknowledged close until its host retirement is durable', async () => { + const f = fixture() + let acknowledged = false + const closing = f.close().then((result) => { + acknowledged = true + return result + }) + await f.entered.promise + const gate = f.authority.pause() + f.acknowledgement.resolve() + await gate.started.promise + expect(acknowledged).toBe(false) + gate.finish.resolve() + await expect(closing).resolves.toEqual({ closed: true }) + expect(f.hasTab()).toBe(false) +}) + +it('publishes physical-exit retirement before its durable write completes', async () => { + const f = fixture() + await f.store.flushPendingOrThrowAsync() + const published = vi.fn() + const unsubscribe = f.runtime.onMobileSessionTabsChanged(published) + const gate = f.authority.pause() + const exiting = f.runtime.onPtyExit('pty-a', 0, ACK_INCARNATION, { providerExitObserved: true }) + await gate.started.promise + // Why: a client re-activating the exited pane in this window must already find it retired. + expect(published).toHaveBeenCalled() + gate.finish.resolve() + await exiting + expect(published).toHaveBeenCalled() + expect( + f.store.getWorkspaceSession().terminalLayoutsByTabId[ACK_TAB].ptyIdsByLeafId?.[ACK_LEAF] + ).toBeUndefined() + unsubscribe() +}) + +it('publishes nothing when the exit write lands after a replacement is admitted', async () => { + const f = fixture() + await f.store.flushPendingOrThrowAsync() + const published = vi.fn() + const unsubscribe = f.runtime.onMobileSessionTabsChanged(published) + const gate = f.authority.pause() + const exiting = f.runtime.onPtyExit('pty-a', 0, ACK_INCARNATION, { providerExitObserved: true }) + await gate.started.promise + f.runtime.onPtySpawned('pty-a', 'new-incarnation') + published.mockClear() + gate.finish.resolve() + await exiting + expect(published).not.toHaveBeenCalled() + unsubscribe() +}) + +async function exitWithFailedDurableWrite(f: ReturnType): Promise { + await f.store.flushPendingOrThrowAsync() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + f.authority.failNextWrite() + await f.runtime.onPtyExit('pty-a', 0, ACK_INCARNATION, { providerExitObserved: true }) + expect(errorSpy).toHaveBeenCalledWith( + '[runtime] terminal retirement is not yet durable:', + expect.any(Error) + ) + errorSpy.mockRestore() + // The failed write left disk untouched, so a relaunch would still load the exited leaf. + expect(f.readDisk().workspaceSession.terminalLayoutsByTabId[ACK_TAB]?.ptyIdsByLeafId).toEqual({ + [ACK_LEAF]: 'pty-a', + [ACK_SECOND_LEAF]: 'pty-b' + }) +} + +it('persists a failed exit retirement with the next unrelated profile write', async () => { + const f = fixture() + await exitWithFailedDurableWrite(f) + f.store.addRepo({ + id: 'repo2', + path: '/tmp/other', + displayName: 'Other', + badgeColor: 'gray', + addedAt: 2 + }) + await f.store.flushPendingOrThrowAsync() + expect(f.readDisk().workspaceSession.terminalLayoutsByTabId[ACK_TAB]?.ptyIdsByLeafId).toEqual({ + [ACK_SECOND_LEAF]: 'pty-b' + }) +}) + +it('persists a failed exit retirement with the final quit flush', async () => { + const f = fixture() + await exitWithFailedDurableWrite(f) + await f.quit() + expect(f.readDisk().workspaceSession.terminalLayoutsByTabId[ACK_TAB]?.ptyIdsByLeafId).toEqual({ + [ACK_SECOND_LEAF]: 'pty-b' + }) +}) + +it('writes exits retired in the same tick once', async () => { + const f = fixture() + await f.store.flushPendingOrThrowAsync() + const writes = f.authority.captures.length + await Promise.all([ + f.runtime.onPtyExit('pty-a', 0, ACK_INCARNATION, { providerExitObserved: true }), + f.runtime.onPtyExit('pty-b', 0, undefined, { providerExitObserved: true }) + ]) + // Why: the first write already carries both retirements; the second has nothing left to fence. + expect(f.authority.captures.length - writes).toBe(1) + expect(f.readDisk().workspaceSession.terminalLayoutsByTabId[ACK_TAB]).toBeUndefined() +}) diff --git a/src/main/runtime/terminal-run-facts-input.test.ts b/src/main/runtime/terminal-run-facts-input.test.ts new file mode 100644 index 00000000000..a1f26d9b686 --- /dev/null +++ b/src/main/runtime/terminal-run-facts-input.test.ts @@ -0,0 +1,244 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' +import { writePtyFromRuntimeController } from '../ipc/pty/runtime/operations' +import { getLocalPtyProvider, setLocalPtyProvider } from '../ipc/pty/provider/registry' +import type { IPtyProvider } from '../providers/types' +import { settledWriteStub } from '../providers/settled-pty-write-stub' +import { OrcaRuntimeService } from './orca-runtime' +import type { RuntimePtyController } from './runtime-pty-controller-contract' +import { writeOrchestrationPointerWithSettlement } from './orchestration/mailbox-pointer-pty-write' +import { sendTerminalStreamInput } from './rpc/methods/terminal/terminal-input-delivery' +import { makeStore } from './runtime-rpc-worktree-store-fixtures' +import { + pasteWorktreeStartupDraftWhenReady, + sendWorktreeStartupFollowupWhenReady, + type WorktreeStartupReadinessHost +} from './runtime-worktree-startup-readiness' + +vi.mock('../git/worktree', () => { + const worktrees = [ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/run-facts', + isBare: false, + isMainWorktree: false + } + ] + return { + listWorktrees: vi.fn().mockResolvedValue(worktrees), + listWorktreesStrict: vi.fn().mockResolvedValue(worktrees) + } +}) + +const PTY_ID = 'pty-run-facts-input' +const priorProvider = getLocalPtyProvider() + +type FreshRun = { + runtime: OrcaRuntimeService + controller: RuntimePtyController + handle: string + /** The kind each provider write was sent with, and whether input was recorded by then. */ + writes: { data: string; inputRecorded: boolean }[] + kinds: TerminalInputKind[] + firstUserInputAt: () => number | null +} + +/** A fresh shell whose controller writes go through main's real write funnel to a fake provider. */ +async function createFreshRun(): Promise { + const runtime = new OrcaRuntimeService(makeStore() as never) + const firstUserInputAt = (): number | null => + runtime.terminalRunFacts.read(PTY_ID, null).firstUserInputAt + const writes: FreshRun['writes'] = [] + const kinds: TerminalInputKind[] = [] + const write = (_id: string, data: string): boolean => { + writes.push({ data, inputRecorded: firstUserInputAt() !== null }) + if (data.endsWith('\r')) { + runtime.onPtyData(PTY_ID, '\x1b]0;Codex working\x07', Date.now()) + } + return true + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the write funnel calls only write and writeWithSettlement on the provider. + setLocalPtyProvider({ + write, + writeWithSettlement: settledWriteStub(write) + } as unknown as IPtyProvider) + const controller: RuntimePtyController = { + spawn: async () => ({ id: PTY_ID }), + write: (id, data, inputKind) => { + kinds.push(inputKind) + return writePtyFromRuntimeController({ runtime }, id, data, inputKind) + }, + writeWithSettlement: (id, data, inputKind) => { + kinds.push(inputKind) + return writePtyFromRuntimeController({ runtime }, id, data, inputKind, { + waitForSettlement: true + }) + }, + kill: () => true, + getForegroundProcess: async () => null + } + runtime.setPtyController(controller) + const terminal = await runtime.createTerminal('path:/tmp/worktree-a', { launchAgent: 'aider' }) + runtime.terminalRunFacts.recordSpawnCommit({ id: PTY_ID }) + return { runtime, controller, handle: terminal.handle, writes, kinds, firstUserInputAt } +} + +afterEach(() => { + vi.useRealTimers() + setLocalPtyProvider(priorProvider) +}) + +describe('run facts: the controller write funnel', () => { + it('records terminal.send input before the write that could end the process', async () => { + const run = await createFreshRun() + + await run.runtime.sendTerminal( + run.handle, + { text: 'exit', enter: true }, + { inputKind: 'driving' } + ) + + expect(run.firstUserInputAt()).not.toBeNull() + expect(run.writes.map((write) => write.inputRecorded)).toEqual([true, true]) + }) + + it('records stream input from a client', async () => { + const run = await createFreshRun() + + await sendTerminalStreamInput(run.runtime, { + terminal: run.handle, + text: 'l', + client: undefined, + isMobile: false + }) + + expect(run.firstUserInputAt()).not.toBeNull() + }) + + it('records a dispatched agent prompt before its first write', async () => { + vi.useFakeTimers() + const run = await createFreshRun() + + const submission = run.runtime.sendTerminalAgentPrompt(run.handle, 'review this', { + inputKind: 'driving' + }) + await vi.runAllTimersAsync() + await submission.catch(() => undefined) + + expect(run.firstUserInputAt()).not.toBeNull() + expect(run.writes[0]?.inputRecorded).toBe(true) + }) + + it('reads a run launched with a prompt as untyped until someone drives it', async () => { + vi.useFakeTimers() + const run = await createFreshRun() + + const submission = run.runtime.sendTerminalAgentPrompt(run.handle, 'start here', { + inputKind: 'launch' + }) + await vi.runAllTimersAsync() + await submission.catch(() => undefined) + + expect(run.writes.length).toBeGreaterThan(0) + expect(run.firstUserInputAt()).toBeNull() + + await run.runtime.sendTerminal(run.handle, { text: 'y' }, { inputKind: 'driving' }) + expect(run.firstUserInputAt()).not.toBeNull() + }) + + it('records a mailbox pointer, which drives the running agent', async () => { + const run = await createFreshRun() + + await writeOrchestrationPointerWithSettlement({ + ptyId: PTY_ID, + data: 'You have 1 unread message.', + controller: run.controller + }) + + expect(run.kinds).toEqual(['driving']) + expect(run.firstUserInputAt()).not.toBeNull() + }) + + it('records nothing for a client query reply', async () => { + const run = await createFreshRun() + + await run.runtime.sendTerminal(run.handle, { text: 'y' }, { inputKind: 'query-reply' }) + + expect(run.firstUserInputAt()).toBeNull() + }) + + it.each([ + ['a terminal reply', '\x1b[3;4R'], + ['focus reports', '\x1b[I\x1b[O'], + ['the focus-in a desktop sends on reattaching a remote pane', '\x1b[I'] + ])('records nothing for stream input that is only %s', async (_label, text) => { + const run = await createFreshRun() + + await sendTerminalStreamInput(run.runtime, { + terminal: run.handle, + text, + client: undefined, + isMobile: false + }) + + expect(run.writes).toHaveLength(1) + expect(run.firstUserInputAt()).toBeNull() + }) + + it('records a reply mixed with a keystroke', async () => { + const run = await createFreshRun() + + await run.runtime.sendTerminal(run.handle, { text: '\x1b[3;4Rx' }, { inputKind: 'driving' }) + + expect(run.firstUserInputAt()).not.toBeNull() + }) + + it('records dashboard preview typing before the write that could end the process', async () => { + const run = await createFreshRun() + + await expect(run.runtime.writeTerminalPreviewInput(PTY_ID, 'exit\r')).resolves.toBe(true) + + expect(run.writes.map((write) => write.inputRecorded)).toEqual([true]) + }) + + it('records nothing for dashboard preview bytes that are only a reply or focus reports', async () => { + const run = await createFreshRun() + + await run.runtime.writeTerminalPreviewInput(PTY_ID, '\x1b[3;4R') + await run.runtime.writeTerminalPreviewInput(PTY_ID, '\x1b[O\x1b[I') + + expect(run.writes).toHaveLength(2) + expect(run.firstUserInputAt()).toBeNull() + }) +}) + +describe('run facts: a created worktree’s startup writes', () => { + function readinessHost(run: FreshRun): WorktreeStartupReadinessHost { + return { + getPtyId: () => PTY_ID, + getForegroundProcess: async () => 'aider', + subscribeToData: () => () => {}, + // Why: bracketed paste enabled, then quiet, is the default draft-ready signal. + readRecentOutput: () => '\x1b[?2004h', + write: (ptyId, data, inputKind) => run.controller.write(ptyId, data, inputKind) + } + } + + it('reads a run whose only input was its create-time draft and follow-up as untyped', async () => { + vi.useFakeTimers() + const run = await createFreshRun() + const host = readinessHost(run) + + pasteWorktreeStartupDraftWhenReady(host, run.handle, { agent: 'aider', content: 'plan it' }) + sendWorktreeStartupFollowupWhenReady(host, run.handle, { + expectedProcess: 'aider', + prompt: 'and ship it' + }) + await vi.runAllTimersAsync() + + expect(run.kinds).toEqual(['launch', 'launch']) + expect(run.writes).toHaveLength(2) + expect(run.firstUserInputAt()).toBeNull() + }) +}) diff --git a/src/main/runtime/terminal-run-facts.test.ts b/src/main/runtime/terminal-run-facts.test.ts new file mode 100644 index 00000000000..e1982ebc968 --- /dev/null +++ b/src/main/runtime/terminal-run-facts.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { TerminalRunFactsRegister } from './terminal-run-facts' + +describe('terminal run facts', () => { + it('reads a run main never saw committed as not fresh', () => { + expect(new TerminalRunFactsRegister().read('pty-1', 'inc-1')).toEqual({ + freshSpawn: false, + firstUserInputAt: null + }) + }) + + it('keeps the first user input across later input and a re-registration of the same process', () => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1' }) + facts.recordInput('pty-1', 'driving', 'ls\r', 100) + facts.recordInput('pty-1', 'driving', 'ls\r', 200) + + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1', isReattach: true }) + + expect(facts.read('pty-1', 'inc-1')).toEqual({ freshSpawn: true, firstUserInputAt: 100 }) + }) + + it('starts a new process clean', () => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1' }) + facts.recordInput('pty-1', 'driving', 'ls\r', 100) + + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-2' }, { tabId: 'source-tab' }) + + expect(facts.read('pty-1', 'inc-2')).toEqual({ freshSpawn: true, firstUserInputAt: null }) + expect(facts.read('pty-1', 'inc-1')).toEqual({ freshSpawn: false, firstUserInputAt: null }) + }) + + it('never reads a reattached process as fresh', () => { + const facts = new TerminalRunFactsRegister() + + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1', isReattach: true }) + + expect(facts.read('pty-1', 'inc-1').freshSpawn).toBe(false) + }) + + it('starts clean when a commit carries no incarnation to tell it from a new process', () => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: 'pty-1' }) + facts.recordInput('pty-1', 'driving', 'ls\r', 100) + + facts.recordSpawnCommit({ id: 'pty-1', isReattach: true }) + + expect(facts.read('pty-1', null)).toEqual({ freshSpawn: false, firstUserInputAt: null }) + }) + + it.each([ + ['a launch write', 'launch', 'echo startup\r'], + ['a query reply', 'query-reply', '\x1b[1;1R'], + ['driving bytes that are only a terminal reply', 'driving', '\x1b[1;1R'], + ['driving bytes that are only focus reports', 'driving', '\x1b[I\x1b[O'] + ] as const)('records nothing for %s', (_label, inputKind, data) => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1' }) + + facts.recordInput('pty-1', inputKind, data, 100) + + expect(facts.read('pty-1', 'inc-1').firstUserInputAt).toBeNull() + }) +}) diff --git a/src/main/runtime/terminal-run-facts.ts b/src/main/runtime/terminal-run-facts.ts new file mode 100644 index 00000000000..1fb3e411b6c --- /dev/null +++ b/src/main/runtime/terminal-run-facts.ts @@ -0,0 +1,90 @@ +import { + spawnCommitBindingOrigin, + type PtySpawnCommitOrigin +} from '../persistence/loading-store/pty-binding-span' +import { isTerminalQueryReply } from '../../shared/terminal-query-reply' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' + +export type TerminalRunFacts = { + /** This process was started for its pane, not reattached, adopted or cold-restored. */ + freshSpawn: boolean + /** When input first drove this process, from any client or driver; null if none has. */ + firstUserInputAt: number | null +} + +// Why: a paired client's xterm answers focus changes (CSI I / CSI O) through input that carries no +// provenance; the desktop renderer already excludes them via xterm's user-input signal. +// oxlint-disable-next-line no-control-regex -- focus reports are ESC-framed sequences by definition. +const TERMINAL_FOCUS_REPORTS_ONLY_RE = new RegExp('^(?:\\u001b\\[[IO])+$') + +/** Input with no provenance that no person typed: a whole terminal reply or only focus reports. */ +function isUntypedTerminalInput(payload: string): boolean { + return isTerminalQueryReply(payload) || TERMINAL_FOCUS_REPORTS_ONLY_RE.test(payload) +} + +export type TerminalSpawnCommit = Parameters[0] & { + id: string + incarnationId?: string + coldRestore?: object +} + +/** A cold restore starts a new process for a pane that had one, so it is never fresh. */ +type TerminalRunSpawnOrigin = PtySpawnCommitOrigin | 'cold-restore' + +type TerminalRunRecord = { + incarnationId: string | null + spawnOrigin: TerminalRunSpawnOrigin + firstUserInputAt: number | null +} + +/** Main's per-process facts about one PTY run, keyed by the incarnation they describe. */ +export class TerminalRunFactsRegister { + private readonly runsByPtyId = new Map() + + /** Once per process: a re-registration of the same incarnation keeps its facts. Without an + * incarnation a commit cannot be told from a new process, so it starts clean. */ + recordSpawnCommit(commit: TerminalSpawnCommit, expectedSourceBinding?: unknown): void { + const incarnationId = commit.incarnationId ?? null + if ( + incarnationId !== null && + this.runsByPtyId.get(commit.id)?.incarnationId === incarnationId + ) { + return + } + const origin = spawnCommitBindingOrigin(commit, expectedSourceBinding) + this.runsByPtyId.set(commit.id, { + incarnationId, + spawnOrigin: origin === 'spawn' && commit.coldRestore !== undefined ? 'cold-restore' : origin, + firstUserInputAt: null + }) + } + + /** The one record point both write funnels call just before the provider write, because input + * such as `exit` can end the process before the write returns. The payload check backs up a + * writer that labels a reply or focus report as driving. */ + recordInput(ptyId: string, inputKind: TerminalInputKind, data: string, now = Date.now()): void { + if (inputKind !== 'driving') { + return + } + const run = this.runsByPtyId.get(ptyId) + if (run && run.firstUserInputAt === null && !isUntypedTerminalInput(data)) { + run.firstUserInputAt = now + } + } + + /** A run main never saw committed reads as not fresh, which keeps today's close-on-exit. */ + read(ptyId: string, incarnationId: string | null | undefined): TerminalRunFacts { + const run = this.runsByPtyId.get(ptyId) + if (!run || (run.incarnationId && incarnationId && run.incarnationId !== incarnationId)) { + return { freshSpawn: false, firstUserInputAt: null } + } + return { + freshSpawn: run.spawnOrigin === 'spawn' || run.spawnOrigin === 'split', + firstUserInputAt: run.firstUserInputAt + } + } + + delete(ptyId: string): void { + this.runsByPtyId.delete(ptyId) + } +} diff --git a/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts b/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts index b7c5ed95068..a25ed9817fb 100644 --- a/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts +++ b/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts @@ -96,9 +96,9 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { const probe = vi.fn(async () => false) const { runtime, handle, write } = await makeRuntimeWithLeafHandle({ probePtyLiveness: probe }) - await expect(runtime.sendTerminal(handle, { text: 'ping' })).rejects.toThrow( - 'terminal_not_writable' - ) + await expect( + runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' }) + ).rejects.toThrow('terminal_not_writable') expect(probe).toHaveBeenCalledWith(STALE_PTY_ID) expect(write).not.toHaveBeenCalled() @@ -108,9 +108,9 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { const probe = vi.fn(async () => false) const { runtime, handle, write } = await makeRuntimeWithLeafHandle({ probePtyLiveness: probe }) - await expect(runtime.sendTerminalAgentPrompt(handle, 'do the thing')).rejects.toThrow( - 'terminal_not_writable' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'do the thing', { inputKind: 'driving' }) + ).rejects.toThrow('terminal_not_writable') expect(write).not.toHaveBeenCalled() }) @@ -120,12 +120,14 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { probePtyLiveness: async () => null }) - await expect(runtime.sendTerminal(handle, { text: 'ping' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ handle, accepted: true }) - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping') + expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving') }) it('treats a throwing probe as unknown and proceeds', async () => { @@ -135,11 +137,13 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { } }) - await expect(runtime.sendTerminal(handle, { text: 'ping' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ accepted: true }) - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping') + expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving') }) it('proceeds when the probe answers live (restored session before its pane remounts)', async () => { @@ -147,21 +151,25 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { probePtyLiveness: async () => true }) - await expect(runtime.sendTerminal(handle, { text: 'ping' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ accepted: true }) - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping') + expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving') }) it('proceeds unchanged when the controller exposes no probe', async () => { const { runtime, handle, write } = await makeRuntimeWithLeafHandle({}) - await expect(runtime.sendTerminal(handle, { text: 'ping' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ accepted: true }) - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping') + expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving') }) it('never probes when the provider synchronously knows the id (live pty)', async () => { @@ -171,24 +179,26 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { hasPty: (ptyId) => ptyId === STALE_PTY_ID }) - await expect(runtime.sendTerminal(handle, { text: 'ping' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ accepted: true }) expect(probe).not.toHaveBeenCalled() - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping') + expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving') }) it('reuses a proven-absent verdict across repeated sends instead of re-probing', async () => { const probe = vi.fn(async () => false) const { runtime, handle } = await makeRuntimeWithLeafHandle({ probePtyLiveness: probe }) - await expect(runtime.sendTerminal(handle, { text: 'a' })).rejects.toThrow( - 'terminal_not_writable' - ) - await expect(runtime.sendTerminal(handle, { text: 'b' })).rejects.toThrow( - 'terminal_not_writable' - ) + await expect( + runtime.sendTerminal(handle, { text: 'a' }, { inputKind: 'driving' }) + ).rejects.toThrow('terminal_not_writable') + await expect( + runtime.sendTerminal(handle, { text: 'b' }, { inputKind: 'driving' }) + ).rejects.toThrow('terminal_not_writable') expect(probe).toHaveBeenCalledTimes(1) }) @@ -201,17 +211,19 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { hasPty: (ptyId) => livePtyIds.has(ptyId) }) - await expect(runtime.sendTerminal(handle, { text: 'a' })).rejects.toThrow( - 'terminal_not_writable' - ) + await expect( + runtime.sendTerminal(handle, { text: 'a' }, { inputKind: 'driving' }) + ).rejects.toThrow('terminal_not_writable') // Same id recreated by a fresh spawn: provider knowledge must beat the verdict. livePtyIds.add(STALE_PTY_ID) - await expect(runtime.sendTerminal(handle, { text: 'b' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'b' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ accepted: true }) expect(probe).toHaveBeenCalledTimes(1) - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'b') + expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'b', 'driving') }) }) diff --git a/src/main/runtime/terminal-surface-close.test.ts b/src/main/runtime/terminal-surface-close.test.ts new file mode 100644 index 00000000000..e149958ff34 --- /dev/null +++ b/src/main/runtime/terminal-surface-close.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalLayoutSnapshot } from '../../shared/terminal-tab-types' +import { + resolveTerminalCloseTarget, + type TerminalCloseLayoutCopies +} from './terminal-surface-close' + +const PANE = { kind: 'pane', tabId: 'tab-1', leafId: 'leaf-a' } as const + +function layout(...leafIds: string[]): TerminalLayoutSnapshot { + const [first, second] = leafIds + return { + root: + first === undefined + ? null + : second === undefined + ? { type: 'leaf', leafId: first } + : { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: first }, + second: { type: 'leaf', leafId: second } + }, + activeLeafId: first ?? null, + expandedLeafId: null + } +} + +function copies(overrides: Partial): TerminalCloseLayoutCopies { + return { + rendererListsTab: false, + snapshotRows: [], + graphLeafIds: [], + sessionLayout: undefined, + ...overrides + } +} + +describe('resolveTerminalCloseTarget', () => { + it('never widens a pane close when the owner copy records no panes', () => { + // A renderer tab registered before its panes, with nothing published for it yet. + expect(resolveTerminalCloseTarget(PANE, copies({ rendererListsTab: true }))).toBe('absent') + // A main-owned tab whose layout was saved before its pane mounted, and no other copy. + expect(resolveTerminalCloseTarget(PANE, copies({ sessionLayout: layout() }))).toBe('absent') + }) + + it("closes the tab only when a copy positively shows this pane as the tab's one pane", () => { + const cases: [string, Partial][] = [ + ['renderer graph', { rendererListsTab: true, graphLeafIds: ['leaf-a'] }], + [ + 'renderer rows with no layout or graph panes', + { rendererListsTab: true, snapshotRows: [{ leafId: 'leaf-a' }] } + ], + ['main session layout', { sessionLayout: layout('leaf-a') }], + [ + 'rows published for a main-owned tab whose saved layout is empty', + { sessionLayout: layout(), snapshotRows: [{ leafId: 'leaf-a', parentLayout: layout() }] } + ] + ] + for (const [name, overrides] of cases) { + expect([name, resolveTerminalCloseTarget(PANE, copies(overrides))]).toEqual([ + name, + 'last-pane' + ]) + } + }) + + it('reads past an empty copy to one that records the split', () => { + expect( + resolveTerminalCloseTarget( + PANE, + copies({ + sessionLayout: layout(), + snapshotRows: [{ leafId: 'leaf-a', parentLayout: layout('leaf-a', 'leaf-b') }] + }) + ) + ).toBe('pane') + }) + + it('treats a pane the owner copy lacks as absent', () => { + expect( + resolveTerminalCloseTarget(PANE, copies({ rendererListsTab: true, graphLeafIds: ['leaf-b'] })) + ).toBe('absent') + }) +}) diff --git a/src/main/runtime/terminal-surface-close.ts b/src/main/runtime/terminal-surface-close.ts new file mode 100644 index 00000000000..0feee12402c --- /dev/null +++ b/src/main/runtime/terminal-surface-close.ts @@ -0,0 +1,245 @@ +import type { RuntimeSessionTabCloseReason } from '../../shared/runtime-session-contracts' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { + hasClosedTerminalTabRecord, + recordClosedTerminalTabTombstone +} from '../../shared/closed-terminal-tab-tombstones' +import type { + TerminalLayoutSnapshot, + TerminalPaneLayoutNode +} from '../../shared/terminal-tab-types' +import type { TerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target' +import { + closeTerminalTabInWorkspaceSession, + type WorkspaceSessionTerminalTabCloseResult +} from '../../shared/workspace-session-terminal-tab-close' +import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement' +import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' +import type { DurableProfileStateMutation } from '../persistence/loading-store/store-runtime-state' +import type { ExecutionHostId } from '../../shared/execution-host' + +/** Where a pane close lands: its own removal, its tab's last pane, or a pane the copy lacks. */ +export type PaneCloseResolution = 'pane' | 'last-pane' | 'absent' + +export function collectTerminalLayoutLeafIds( + node: TerminalPaneLayoutNode | null | undefined +): string[] { + if (!node) { + return [] + } + if (node.type === 'leaf') { + return [node.leafId] + } + return [...collectTerminalLayoutLeafIds(node.first), ...collectTerminalLayoutLeafIds(node.second)] +} + +/** + * Resolves a pane close against the panes one copy records. Only a copy that positively shows this + * pane as its tab's one pane widens the close; a copy recording no panes knows nothing about it. + */ +export function resolvePaneClose( + leafIds: readonly string[] | null, + leafId: string +): PaneCloseResolution { + if (!leafIds?.includes(leafId)) { + return 'absent' + } + return leafIds.length === 1 ? 'last-pane' : 'pane' +} + +/** Every copy of a tab's panes main can read when it resolves a close it started. */ +export type TerminalCloseLayoutCopies = { + /** The desktop renderer lists the tab, so it owns the tab's panes. */ + rendererListsTab: boolean + /** The tab's rows in the published session snapshot. */ + snapshotRows: readonly { leafId: string; parentLayout?: TerminalLayoutSnapshot }[] + /** The tab's panes in the renderer-published runtime graph. */ + graphLeafIds: readonly string[] + sessionLayout: TerminalLayoutSnapshot | undefined +} + +/** Reads the panes from whoever owns the tab's layout; `null` means no copy records any. */ +function readLayoutOwnerLeafIds(copies: TerminalCloseLayoutCopies): readonly string[] | null { + const published = + copies.snapshotRows + .map((row) => collectTerminalLayoutLeafIds(row.parentLayout?.root)) + .find((leafIds) => leafIds.length > 0) ?? [] + const rows = copies.snapshotRows.map((row) => row.leafId) + // Why: main's saved layout lacks a renderer split whose PTY binding has not committed yet; + // the published layout outranks the graph, which relay recovery can leave with stale panes. + const ownerCopies = copies.rendererListsTab + ? [published, copies.graphLeafIds, rows] + : [collectTerminalLayoutLeafIds(copies.sessionLayout?.root), published, rows] + // Why: a layout saved before its pane mounted (or a graph before its panes register) is empty. + return ownerCopies.find((leafIds) => leafIds.length > 0) ?? null +} + +/** + * The only place main turns a pane close into its tab's close: `last-pane` sends callers down + * the tab path (and its renderer pin guard); anything else closes that pane or nothing. + * D1 (main as the single layout writer) collapses the owner read to main's session layout alone. + */ +export function resolveTerminalCloseTarget( + target: TerminalSurfaceCloseTarget, + copies: TerminalCloseLayoutCopies +): PaneCloseResolution | 'tab' { + return target.kind === 'tab' + ? 'tab' + : resolvePaneClose(readLayoutOwnerLeafIds(copies), target.leafId) +} + +export type TerminalSurfaceCloseResult = WorkspaceSessionTerminalTabCloseResult & { + resolution: PaneCloseResolution | 'tab' +} + +/** + * The membership half of every explicit terminal close: removes a tab, or one pane of a split + * tab, and advances the repo's topology revision so a stale renderer save cannot restore it. + * A pane close only ever removes that pane; the resolution says why anything else was a no-op. + * Every tab close is recorded in the session it was removed from, the owning host's partition. + */ +export function closeTerminalSurfaceInWorkspaceSession( + session: WorkspaceSessionState, + worktreeId: string, + target: TerminalSurfaceCloseTarget, + options: { + force?: boolean + paneIncarnationId?: string + reason: RuntimeSessionTabCloseReason + now?: number + } +): TerminalSurfaceCloseResult { + if (target.kind === 'pane') { + const layout = session.terminalLayoutsByTabId[target.tabId] + const resolution = resolvePaneClose( + layout ? collectTerminalLayoutLeafIds(layout.root) : null, + target.leafId + ) + // Why: the exit may already have retired this pane, leaving only live siblings to lose. + if (!layout || resolution !== 'pane') { + return { session, ptyIdsToKill: [], closed: false, pinned: false, resolution } + } + // The pane's own binding is passed so the retirement's stale-binding fence always admits it; + // the incarnation seen when the close was asked refuses a pane restarted since. + const retired = retireTerminalSurfaceFromPersistence(session, { + worktreeId, + parentTabId: target.tabId, + leafId: target.leafId, + ptyId: layout.ptyIdsByLeafId?.[target.leafId] ?? '', + ...(options.paneIncarnationId ? { incarnationId: options.paneIncarnationId } : {}) + }) + return { + session: retired, + ptyIdsToKill: [], + closed: retired !== session, + pinned: false, + resolution + } + } + const result = closeTerminalTabInWorkspaceSession(session, worktreeId, target.tabId, { + force: options.force + }) + if (result.pinned) { + return { ...result, resolution: 'tab' } + } + // Why a tab this session never listed is still recorded: its spawn may commit later and graft it. + const recorded: WorkspaceSessionState = { + ...result.session, + closedTerminalTabTombstonesByTabId: recordClosedTerminalTabTombstone( + result.session.closedTerminalTabTombstonesByTabId, + target.tabId, + { worktreeId, reason: options.reason }, + options.now ?? Date.now() + ) + } + return { + ...result, + session: result.closed ? advanceTerminalTopologyRevision(recorded, worktreeId) : recorded, + resolution: 'tab' + } +} + +/** How one close commits; `reason` is recorded only when the close resolves to the whole tab. */ +export type TerminalSurfaceCloseOptions = { + allowMissing?: boolean + force?: boolean + reason?: RuntimeSessionTabCloseReason + /** The desktop renderer's own close: its layout owner already removed the tab. Goes away with + * D1, once main owns the terminal layout. */ + closedByLayoutOwner?: boolean +} + +/** The desktop renderer's close intent as its IPC delivers it; main alone passes 'pty-exit'. */ +export type RendererTerminalClose = { + worktreeId: string + target: TerminalSurfaceCloseTarget + reason?: 'user' | 'cleanup' +} + +/** What one close's durable mutation reads and writes, resolved when the writer admits it. */ +export type TerminalSurfaceCloseCommit = { + worktreeId: string + target: TerminalSurfaceCloseTarget + options: TerminalSurfaceCloseOptions + /** The session as it was when the close was asked, before the writer admitted it. */ + requestedSession: WorkspaceSessionState | null | undefined + /** The tab's owner identity still matches the one the close was asked against. */ + ownerMatches: () => boolean + hostId: () => ExecutionHostId + getSession: (hostId: ExecutionHostId) => WorkspaceSessionState | null | undefined + setSession: (session: WorkspaceSessionState, hostId: ExecutionHostId) => void + onClosed: (ptyIdsToKill: string[]) => void +} + +/** Builds the close's durable mutation: a refusal persists nothing and is its value. */ +export function terminalSurfaceCloseMutation( + commit: TerminalSurfaceCloseCommit +): () => DurableProfileStateMutation { + const { target } = commit + // Why: a pane is fenced by its own binding, so a sibling split during the write cannot refuse it. + const paneIncarnationId = + target.kind === 'pane' + ? commit.requestedSession?.terminalPtyIncarnationsByPaneKey?.[ + `${target.tabId}:${target.leafId}` + ] + : undefined + return () => { + if (!commit.ownerMatches()) { + return { value: new Error('terminal_pane_owner_changed'), persist: false } + } + const hostId = commit.hostId() + const session = commit.getSession(hostId) + if (!session) { + return { value: new Error('workspace_session_unavailable'), persist: false } + } + const result = closeTerminalSurfaceInWorkspaceSession(session, commit.worktreeId, target, { + force: commit.options.force, + paneIncarnationId, + reason: commit.options.reason ?? 'user' + }) + if (result.pinned) { + return { value: new Error('terminal_tab_pinned'), persist: false } + } + if (!result.closed) { + // Why: a tab this session never listed still records its close, so its late spawn is refused; + // an existing record (an echo of that close) needs no second write. + if ( + commit.options.allowMissing && + target.kind === 'tab' && + !hasClosedTerminalTabRecord(session.closedTerminalTabTombstonesByTabId, target.tabId) + ) { + commit.setSession(result.session, hostId) + return { value: undefined } + } + return { + value: commit.options.allowMissing ? undefined : new Error('tab_not_found'), + persist: false + } + } + commit.setSession(result.session, hostId) + commit.onClosed(result.ptyIdsToKill) + // Why no rollback: bookkeeping must not undo a user's close or skip its kill; a failed write + // keeps the removal dirty in memory, so the next write persists it. + return { value: undefined } + } +} diff --git a/src/main/runtime/terminal-wait-detection.ts b/src/main/runtime/terminal-wait-detection.ts index e2b22b10427..2618a50279c 100644 --- a/src/main/runtime/terminal-wait-detection.ts +++ b/src/main/runtime/terminal-wait-detection.ts @@ -1,3 +1,4 @@ +import { isQoderComposerReady } from './qoder-terminal-readiness' import { memoizeTitleClassification } from '../../shared/terminal-title-classification-memo' import { detectAgentStatusFromTitle, @@ -5,7 +6,15 @@ import { type AgentStatus } from '../../shared/agent-detection' import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' +import type { TuiAgent } from '../../shared/tui-agent' import { findAntigravityReadyPromptIndex } from './antigravity-terminal-readiness' +import { + findCodexHeaderIndex, + findCodexScreenReadyPromptIndex, + isCodexComposerReadyScreen, + isCodexProvisionalStartupText +} from './codex-terminal-readiness' +import { findStartupDialogBlockedSignals } from './startup-dialog-blocked-signals' import { startOfLastLines, startOfLastNonBlankLines } from './terminal-wait-tail-window' const EXPLICIT_IDLE_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i @@ -43,22 +52,72 @@ export const detectExplicitIdleStatusFromTitle: (title: string) => AgentStatus | export function isKnownReadyPromptPreview(preview: string): boolean { const normalized = preview.toLowerCase() - const readyIndex = findKnownReadyPromptIndex(normalized) - if (readyIndex === null) { - return false - } - const blockedSignal = findTerminalWaitBlockedSignal(normalized) - if (blockedSignal !== null && blockedSignal.index > readyIndex) { - return false - } - return true + return isReadyPromptUnblocked(normalized, findKnownReadyPromptIndex(normalized)) } -// Why separate from isKnownReadyPromptPreview: that one settles tier 1 immediately, while -// a Muse ready screen only proves the TUI is up — the ranking holds it to quiescence. -export function isMuseReadyPromptPreview(preview: string): boolean { +/** + * The ready-prompt text rules for a pane about to take input. Unlike isKnownReadyPromptPreview + * (agent presence), Codex's provisional startup header does not count: 0.157 discards input typed + * behind it while its daemon starts. + */ +export function isKnownReadyPromptSettled(preview: string): boolean { const normalized = preview.toLowerCase() - const readyIndex = findMuseReadyPromptIndex(normalized) + return ( + isReadyPromptUnblocked(normalized, findKnownReadyPromptIndex(normalized)) && + !isCodexProvisionalStartupText(normalized) + ) +} + +/** + * Tier 1 body evidence for every tui-idle site. `readScreenLines` yields the live emulator's + * visible grid, or null when the runtime has no trustworthy one. + * + * Why the screen: Codex repaints its header by cell diff (`ESC[5;3Hdir ESC[5;7Hctory:`), which + * only a grid reassembles — the line-folded wait text reads `dirctory:` forever. + * Why it can only add readiness: a grid out of step with the PTY (size mismatch, resize + * mid-paint) garbles the header, so the text rules keep every verdict they give today. + */ +export function isKnownReadyPromptBody( + waitText: string, + agent: TuiAgent | null, + readScreenLines: () => readonly string[] | null +): boolean { + if (agent === 'qoder') { + return isQoderComposerReady(readScreenLines()) + } + if (isKnownReadyPromptSettled(waitText)) { + return true + } + // Why the agent gate: another agent's screen can merely mention "OpenAI Codex". + if (agent !== null && agent !== 'codex') { + return false + } + const screen = readScreen(readScreenLines) + return screen !== null && isReadyPromptUnblocked(screen, findCodexScreenReadyPromptIndex(screen)) +} + +/** + * Tier 1b body evidence: a ready screen from an agent with no title rest signal. Unlike tier 1 + * it only proves the TUI is up, so the ranking holds it to quiescence. + * Why codex panes only: a `cat`ed transcript or pager in an unknown pane can show the composer. + */ +export function isQuietReadyScreenBody( + waitText: string, + agent: TuiAgent | null, + readScreenLines: () => readonly string[] | null +): boolean { + if (agent === 'codex') { + const screen = readScreen(readScreenLines) + return screen !== null && isCodexComposerReadyScreen(screen) + } + return (agent === null || agent === 'muse') && isMuseReadyPromptPreview(waitText) +} + +function readScreen(readScreenLines: () => readonly string[] | null): string | null { + return readScreenLines()?.join('\n').toLowerCase() ?? null +} + +function isReadyPromptUnblocked(normalized: string, readyIndex: number | null): boolean { if (readyIndex === null) { return false } @@ -66,6 +125,11 @@ export function isMuseReadyPromptPreview(preview: string): boolean { return blockedSignal === null || blockedSignal.index <= readyIndex } +export function isMuseReadyPromptPreview(preview: string): boolean { + const normalized = preview.toLowerCase() + return isReadyPromptUnblocked(normalized, findMuseReadyPromptIndex(normalized)) +} + export function detectTerminalWaitBlockedReason( preview: string ): RuntimeTerminalWaitBlockedReason | null { @@ -91,6 +155,7 @@ export function findActionableTerminalWaitBlockedSignal( function findDismissedStartupModalIndex(normalized: string): number | null { const indexes = [ findCodexReadyPromptIndex(normalized), + findCodexHeaderIndex(normalized), findAntigravityReadyPromptIndex(normalized), findCursorActivePromptIndex(normalized), findMuseReadyPromptIndex(normalized) @@ -151,7 +216,7 @@ function findCodexReadyPromptIndex(normalized: string): number | null { } export const TERMINAL_WAIT_BLOCKED_SENTINEL_RE = - /update available|choose working directory to|codex just got an upgrade|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust|permission required|requires permission|allow once|allow always|run this command\?/i + /update available|choose working directory to|codex just got an upgrade|available\s*·|esc\s*skip|enter\s*confirm\s*·|enter\/esc\s*(?:continue|confirm)|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust|permission required|requires permission|allow once|allow always|run this command\?/i // Why text at all: cursor-agent has no approval hook, so the key-bound menu is the only authority. const CURSOR_APPROVAL_CHOICE_MARKERS = [ @@ -220,27 +285,7 @@ function findTerminalWaitBlockedSignal( function findBlockedSignalInLiveWindow( normalized: string ): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null { - const candidates: { reason: RuntimeTerminalWaitBlockedReason; index: number }[] = [] - const updateIndex = normalized.lastIndexOf('update available') - if (updateIndex !== -1 && normalized.includes('press enter to continue', updateIndex)) { - candidates.push({ reason: 'agent-update-prompt', index: updateIndex }) - } - const cwdIndex = normalized.lastIndexOf('choose working directory to') - if (cwdIndex !== -1 && normalized.includes('press enter to continue', cwdIndex)) { - candidates.push({ reason: 'agent-cwd-prompt', index: cwdIndex }) - } - const modelMigrationIndex = normalized.lastIndexOf('codex just got an upgrade') - if ( - modelMigrationIndex !== -1 && - normalized.includes('press enter to continue', modelMigrationIndex) - ) { - candidates.push({ reason: 'codex-model-migration-prompt', index: modelMigrationIndex }) - } - const hooksIndex = normalized.lastIndexOf('hooks need review') - if (hooksIndex !== -1 && normalized.includes('press enter to confirm', hooksIndex)) { - // Why neutral: this matcher never inspects the agent -- 'hooks need review' is not Codex-only wording. - candidates.push({ reason: 'agent-hooks-review-prompt', index: hooksIndex }) - } + const candidates = findStartupDialogBlockedSignals(normalized) const trustIndex = Math.max( normalized.lastIndexOf('do you trust'), normalized.lastIndexOf('trust this'), diff --git a/src/main/runtime/terminal-wait-name-only-idle.test.ts b/src/main/runtime/terminal-wait-name-only-idle.test.ts index 95cfa54be56..cf4648bf353 100644 --- a/src/main/runtime/terminal-wait-name-only-idle.test.ts +++ b/src/main/runtime/terminal-wait-name-only-idle.test.ts @@ -51,12 +51,15 @@ function createWait(options: { getAdoptedPtyIdleStatus: () => options.adoptedIdleStatus ?? null, getPaneAgent: () => options.agent ?? null, getFirstPartyAgentStatus: () => options.firstPartyStatus ?? null, + readScreenLines: () => null, + readVisibleScreen: () => null, quiescenceMs: QUIESCENCE_MS } const polls = new RuntimeTerminalIdlePolls({ ...shared, intervalMs: POLL_INTERVAL_MS, getForegroundProcess: () => Promise.resolve(options.foreground ?? null), + hasCommandPainted: () => true, getLiveLeaf: (leaf) => options.liveLeaf?.() ?? leaf, resolve: (waiter, result) => waiters.resolve(waiter, result) }) diff --git a/src/main/runtime/tui-idle-command-paint.test.ts b/src/main/runtime/tui-idle-command-paint.test.ts new file mode 100644 index 00000000000..fda31e06ebb --- /dev/null +++ b/src/main/runtime/tui-idle-command-paint.test.ts @@ -0,0 +1,57 @@ +/** + * An agent with no rest signal of its own (amp) settles only on a quiet foreground, and only + * once it has painted. The shell's prompt and the echoed launch command land first, so the + * runtime must tell them apart from the agent's paint by the shell's command-start marker. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' + +const POLL_INTERVAL_MS = 2_000 + +describe('tui-idle on an agent with no rest signal', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('waits through a silent boot after the shell echoed the command, then settles on the paint', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'amp', + launchAgent: 'amp', + data: '' + }) + vi.useFakeTimers() + const write = (chunk: string) => runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, chunk, Date.now()) + write('\x1b]133;A\x07~/repo % amp\r\n\x1b]133;C\x07\x1b]0;amp\x07') + const settled = vi.fn() + const wait = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 }) + wait.then(settled, () => {}) + + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 5) + expect(settled).not.toHaveBeenCalled() + + write('\x1b[?1049h\x1b[H╭─ Amp ─╮\r\n│ > │') + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 3) + await expect(wait).resolves.toMatchObject({ satisfied: true }) + }) + + it('does not finish a command-start marker cut off by dropped output', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'amp', + launchAgent: 'amp', + data: '' + }) + vi.useFakeTimers() + const write = (chunk: string) => runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, chunk, Date.now()) + // Fish's marker carries the command line, so a cut-off one can terminate on unrelated output. + write('~/repo % amp\r\n\x1b]133;C;cmdline_url=am') + runtime.notePtyDataGap(TRANSCRIPT_PANE_PTY_ID, 4096) + write('╭─ Amp ─╮\r\n│ > │\x1b]0;amp\x07') + const wait = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 }) + + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 3) + await expect(wait).resolves.toMatchObject({ satisfied: true }) + }) +}) diff --git a/src/main/runtime/tui-idle-evidence.test.ts b/src/main/runtime/tui-idle-evidence.test.ts index d4c9adfa6fb..ee8e908c61b 100644 --- a/src/main/runtime/tui-idle-evidence.test.ts +++ b/src/main/runtime/tui-idle-evidence.test.ts @@ -1,9 +1,17 @@ import { describe, expect, it } from 'vitest' +import { detectAgentStatusFromTitle } from '../../shared/agent-title-status' +import { getSyntheticAgentTerminalTitle } from '../../shared/synthetic-agent-title' +import { isTuiAgent, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import { getTuiAgentRestSignal } from '../../shared/tui-agent-rest-signal' +import { isKnownReadyPromptBody } from './terminal-wait-detection' import { - hasQuietMuseReadyPrompt, - isTuiIdleSatisfied, - type TuiIdleEvidenceRecord, - type TuiIdleSatisfactionInput + evaluateTuiIdle, + hasFreshDoneFirstPartyStatus, + hasQuietReadyScreen, + isTuiIdleReadyVerdict, + nameOnlyIdleNeedsCorroboration, + type TuiIdleEvaluationInput, + type TuiIdleEvidenceRecord } from './tui-idle-evidence' const QUIESCENCE_MS = 3000 @@ -17,11 +25,12 @@ function record(overrides: Partial = {}): TuiIdleEvidence } } -function input(overrides: Partial = {}): TuiIdleSatisfactionInput { +function input(overrides: Partial = {}): TuiIdleEvaluationInput { return { record: record(), + readTailBlockedReason: () => null, readPositiveBodyEvidence: () => false, - readMuseReadyBodyEvidence: () => true, + readQuietReadyBodyEvidence: () => true, agent: 'muse', firstPartyStatus: null, quiescenceMs: QUIESCENCE_MS, @@ -29,50 +38,238 @@ function input(overrides: Partial = {}): TuiIdleSatisf } } -describe('hasQuietMuseReadyPrompt', () => { +describe('hasQuietReadyScreen', () => { it('settles a Muse ready screen once the stream has gone quiet', () => { - expect(hasQuietMuseReadyPrompt(record(), 'muse', () => true, QUIESCENCE_MS)).toBe(true) + expect(hasQuietReadyScreen(record(), 'muse', () => true, QUIESCENCE_MS)).toBe(true) }) it('refuses while the pane is still streaming', () => { expect( - hasQuietMuseReadyPrompt( - record({ lastOutputAt: Date.now() }), - 'muse', - () => true, - QUIESCENCE_MS - ) + hasQuietReadyScreen(record({ lastOutputAt: Date.now() }), 'muse', () => true, QUIESCENCE_MS) ).toBe(false) }) it('refuses without an output clock, like the tier-3 lane', () => { expect( - hasQuietMuseReadyPrompt(record({ lastOutputAt: null }), 'muse', () => true, QUIESCENCE_MS) + hasQuietReadyScreen(record({ lastOutputAt: null }), 'muse', () => true, QUIESCENCE_MS) ).toBe(false) }) it('refuses without a ready screen', () => { - expect(hasQuietMuseReadyPrompt(record(), 'muse', () => false, QUIESCENCE_MS)).toBe(false) + expect(hasQuietReadyScreen(record(), 'muse', () => false, QUIESCENCE_MS)).toBe(false) }) it('covers adopted panes that carry no launch metadata', () => { - expect(hasQuietMuseReadyPrompt(record(), null, () => true, QUIESCENCE_MS)).toBe(true) - expect(hasQuietMuseReadyPrompt(record(), undefined, () => true, QUIESCENCE_MS)).toBe(true) + expect(hasQuietReadyScreen(record(), null, () => true, QUIESCENCE_MS)).toBe(true) + expect(hasQuietReadyScreen(record(), undefined, () => true, QUIESCENCE_MS)).toBe(true) }) - it('refuses another agent quoting Muse in its scrollback', () => { - expect(hasQuietMuseReadyPrompt(record(), 'codex', () => true, QUIESCENCE_MS)).toBe(false) + it('covers Codex, whose title carries no rest signal once idle', () => { + expect(hasQuietReadyScreen(record(), 'codex', () => true, QUIESCENCE_MS)).toBe(true) + }) + + it('refuses another agent quoting Muse or Codex in its scrollback', () => { + expect(hasQuietReadyScreen(record(), 'claude', () => true, QUIESCENCE_MS)).toBe(false) }) }) -describe('isTuiIdleSatisfied muse lane', () => { +describe('evaluateTuiIdle muse lane', () => { it('settles a quiet Muse pane with no title signal at all', () => { - expect(isTuiIdleSatisfied(input())).toBe(true) + expect(evaluateTuiIdle(input())).toEqual({ kind: 'ready-strong' }) }) it('lets a fresh first-party working status veto the Muse body', () => { expect( - isTuiIdleSatisfied(input({ firstPartyStatus: { state: 'working', updatedAt: Date.now() } })) + evaluateTuiIdle(input({ firstPartyStatus: { state: 'working', updatedAt: Date.now() } })) + ).toEqual({ kind: 'working' }) + }) +}) + +describe('evaluateTuiIdle ranking', () => { + const noMuse = { readQuietReadyBodyEvidence: () => false } + + it('ranks a blocking prompt in the tail above an explicit idle title', () => { + const verdict = evaluateTuiIdle( + input({ + ...noMuse, + agent: 'claude', + record: record({ lastAgentStatus: 'idle', lastOscTitle: '✳ Claude Code' }), + readTailBlockedReason: () => 'agent-trust-workspace' + }) + ) + expect(verdict).toEqual({ kind: 'blocked', reason: 'agent-trust-workspace' }) + }) + + it("calls an agent's own idle title strong", () => { + const verdict = evaluateTuiIdle( + input({ + ...noMuse, + agent: 'claude', + record: record({ lastAgentStatus: 'idle', lastOscTitle: '✳ Claude Code' }) + }) + ) + expect(verdict).toEqual({ kind: 'ready-strong' }) + }) + + it('calls a name-only title weak, even for an agent it is the only rest signal of', () => { + const verdict = evaluateTuiIdle( + input({ ...noMuse, agent: 'grok', record: record({ lastAgentStatus: 'idle' }) }) + ) + expect(verdict).toEqual({ kind: 'ready-weak' }) + }) + + it("holds Claude's bare name to the quiet window, as an agent that announces rest itself", () => { + const streaming = record({ + lastAgentStatus: 'idle', + lastOscTitle: 'claude', + lastOutputAt: Date.now() + }) + expect(evaluateTuiIdle(input({ ...noMuse, agent: 'claude', record: streaming }))).toEqual({ + kind: 'pending', + quietForeground: 'closed' + }) + const quiet = record({ lastAgentStatus: 'idle', lastOscTitle: 'claude' }) + expect(evaluateTuiIdle(input({ ...noMuse, agent: 'claude', record: quiet }))).toEqual({ + kind: 'ready-weak' + }) + }) + + it('reads working, which suppresses the screen read, from a working title', () => { + const verdict = evaluateTuiIdle( + input({ ...noMuse, agent: 'claude', record: record({ lastAgentStatus: 'working' }) }) + ) + expect(verdict).toEqual({ kind: 'working' }) + }) + + it('keeps a first-party blocked status pending, so the screen is still read', () => { + const verdict = evaluateTuiIdle( + input({ + ...noMuse, + agent: null, + record: record({ lastAgentStatus: 'idle', lastOscTitle: 'claude' }), + firstPartyStatus: { state: 'blocked', updatedAt: Date.now() } + }) + ) + expect(verdict).toEqual({ kind: 'pending', quietForeground: 'closed' }) + }) + + it('leaves the quiet-foreground lane open for an unidentified pane with no title status', () => { + expect(evaluateTuiIdle(input({ ...noMuse, agent: null }))).toEqual({ + kind: 'pending', + quietForeground: 'open' + }) + }) + + it('closes the quiet-foreground lane for an agent with a stronger rest signal still to come', () => { + for (const agent of ['claude', 'codex', 'grok', 'dsh'] as const) { + expect(evaluateTuiIdle(input({ ...noMuse, agent }))).toEqual({ + kind: 'pending', + quietForeground: 'closed' + }) + } + }) + + // Why: a launched agent whose title Orca cannot classify has no other lane; closing this + // one for every known agent left `worker start` failing at agent_readiness (STA-7440). + it('keeps the quiet-foreground lane for an agent with no other rest signal, after it paints', () => { + for (const agent of ['amp', 'goose', 'crush', 'kimi', 'qwen-code', 'rovo', 'aug'] as const) { + expect(evaluateTuiIdle(input({ ...noMuse, agent }))).toEqual({ + kind: 'pending', + quietForeground: 'after-paint' + }) + } + }) + + it('closes the lane once any title has classified, so a title that does arrive outranks it', () => { + const verdict = evaluateTuiIdle( + input({ ...noMuse, agent: 'amp', record: record({ lastAgentStatus: 'permission' }) }) + ) + expect(verdict).toEqual({ kind: 'pending', quietForeground: 'closed' }) + }) +}) + +// Why: `none` reopens the quiet-foreground lane, which is only safe where no stronger lane +// could have settled the wait; the identity-keyed lanes must agree with the declared signal. +describe('rest signal agrees with the lanes that can settle a wait', () => { + it.each(Object.keys(TUI_AGENT_CONFIG).filter(isTuiAgent))('%s', (agent) => { + const signal = getTuiAgentRestSignal(agent) + const hookDone = hasFreshDoneFirstPartyStatus(agent, { state: 'done', updatedAt: Date.now() }) + expect(hookDone).toBe(signal === 'hook-done') + let screenRead = false + isKnownReadyPromptBody('', agent, () => { + screenRead = true + return null + }) + const quietScreenBody = hasQuietReadyScreen(record(), agent, () => true, QUIESCENCE_MS) + // Why not only ready-body: Codex keeps its stronger hook-driven title beside this lane. + if (quietScreenBody) { + expect(signal).not.toBe('none') + } + // Why a screen read also counts: Qoder's ready body is its composer, read by identity. + if (signal === 'ready-body') { + expect(quietScreenBody || screenRead).toBe(true) + } + if (signal !== 'none') { + return + } + expect({ + screenRead, + syntheticTitle: getSyntheticAgentTerminalTitle(agent, 'done'), + processTitle: detectAgentStatusFromTitle(TUI_AGENT_CONFIG[agent].expectedProcess) + }).toEqual({ screenRead: false, syntheticTitle: null, processTitle: null }) + }) +}) + +describe('nameOnlyIdleNeedsCorroboration', () => { + it('holds agents that announce rest with an explicit title, native or synthesized', () => { + expect(nameOnlyIdleNeedsCorroboration('claude')).toBe(true) + expect(nameOnlyIdleNeedsCorroboration('codex')).toBe(true) + }) + + it('exempts agents whose name is their only rest signal', () => { + expect(nameOnlyIdleNeedsCorroboration('grok')).toBe(false) + }) + + it("names an adopted pane's agent from a shell auto-title", () => { + expect(nameOnlyIdleNeedsCorroboration(null, 'claude')).toBe(true) + expect(nameOnlyIdleNeedsCorroboration(null, 'claude ~/p/repo')).toBe(true) + }) +}) + +describe('a DSH pane settles tui-idle on its own hook', () => { + const base = { + record: { lastAgentStatus: null, lastOutputAt: null, lastOscTitle: '\u2726 \u{1F40B} repo' }, + rendererTitle: undefined, + readPositiveBodyEvidence: () => false, + readQuietReadyBodyEvidence: () => false, + readTailBlockedReason: () => null, + agent: 'dsh' as const, + firstPartyStatus: { state: 'done' as const, updatedAt: Date.now() }, + quiescenceMs: 1_000 + } satisfies TuiIdleEvaluationInput + + const ready = (over: Partial = {}) => + isTuiIdleReadyVerdict(evaluateTuiIdle({ ...base, ...over })) + + it('settles on a fresh first-party done', () => { + // The regression: DSH's title carries no idle (its rest glyph is Gemini's working one), + // so every title-reading tier failed and `terminal wait --for tui-idle` ran to timeout + // against an already-ready composer. + expect(ready()).toBe(true) + }) + + it('does not settle while the same pane reports working', () => { + expect(ready({ firstPartyStatus: { state: 'working', updatedAt: Date.now() } })).toBe(false) + }) + + it('does not settle on a stale done', () => { + expect( + ready({ firstPartyStatus: { state: 'done', updatedAt: Date.now() - 31 * 60 * 1000 } }) ).toBe(false) + }) + + it('leaves other agents on the title lanes', () => { + // Scoped on purpose: an agent whose hooks report child turns can emit `done` mid-turn. + expect(ready({ agent: 'claude' })).toBe(false) }) }) diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts index 0ca43b15a6c..cb7dc13714b 100644 --- a/src/main/runtime/tui-idle-evidence.ts +++ b/src/main/runtime/tui-idle-evidence.ts @@ -1,10 +1,21 @@ import type { AgentStatus } from '../../shared/agent-detection' -import { isFreshNonDoneAgentStatus } from '../../shared/agent-status-freshness' +import { + AGENT_STATUS_STALE_AFTER_MS, + isFreshNonDoneAgentStatus +} from '../../shared/agent-status-freshness' import type { AgentStatusState } from '../../shared/agent-status-types' +import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' import { getSyntheticAgentTerminalTitle } from '../../shared/synthetic-agent-title' import { resolveExplicitTerminalTitleAgentType } from '../../shared/terminal-title-agent-type' import type { TuiAgent } from '../../shared/tui-agent' -import { detectExplicitIdleStatusFromTitle } from './terminal-wait-detection' +import { getTuiAgentRestSignal } from '../../shared/tui-agent-rest-signal' +import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' +import { + detectExplicitIdleStatusFromTitle, + detectTerminalWaitBlockedReason, + isKnownReadyPromptBody, + isQuietReadyScreenBody +} from './terminal-wait-detection' /** * Ranking the evidence that a `tui-idle` wait may settle on. @@ -15,15 +26,21 @@ import { detectExplicitIdleStatusFromTitle } from './terminal-wait-detection' * stale spinner (#1437) — so a busy Codex/Devin pane is routinely titled idle, and * accepting it satisfied a wait in ~0s mid-turn (#6011). * - * 1. POSITIVE — the agent states it is ready: an explicit idle marker in its own + * 0. BLOCKED — the tail shows a prompt waiting on the user. + * 1. STRONG READY — the agent states it is ready: an explicit idle marker in its own * title, or a known ready-prompt body. - * 1b. MUSE — Muse emits no title signal at all, so its ready-screen body stands in - * for the positive evidence, believed only once the stream has gone quiet. - * 2. VETO — a fresh first-party agent status (OSC 9999) saying working/blocked/ - * waiting. The agent's own account of itself outranks anything inferred. - * 3. ABSENCE — a name-only title, or a quiet non-shell foreground process. A last + * 1b. QUIET READY SCREEN — Muse and an idle Codex title no rest signal, so their + * ready-screen body stands in for the strong evidence, believed only once quiet. + * 2. WORKING — a fresh first-party agent status (OSC 9999) saying working/blocked/ + * waiting, or a working title. The agent's own account of itself outranks anything + * inferred. + * 3. WEAK READY — a name-only title, or a quiet non-shell foreground process. A last * resort, and only once sustained. * + * Why weak ready is a verdict class rather than a per-evidence flag: none of it can see a + * start-up dialog the line tail lost (Claude's workspace trust), so ONLY the poll may settle + * on it, after the rendered screen shows no blocker. Synchronous sites settle on tiers 0-1b. + * * Why derived here rather than stamped onto the record at write time: `syncWindowGraph` * rebuilds every leaf from an explicit field list, so a bespoke provenance field is * silently dropped on any renderer publish and the verdict silently flips. `lastOscTitle` @@ -55,19 +72,53 @@ export function hasExplicitIdleTitle( return false } +/** + * Tier 1, first-party: the agent's own hook says the turn ENDED. + * + * Why DSH needs its own lane: the other tiers all read the title, and DSH cannot carry idle + * there. Its rest prefix is `✦`, which is Gemini's WORKING glyph, so the title detector + * deliberately reports no status for a DSH pane at all (see agent-title-status.ts) — which + * left `tui-idle` with nothing to settle on, and a supervised worker waiting on a ready + * composer until its timeout. + * + * Why a hook `done` is trustworthy here where a title would not be: it is the agent's own + * account of its own turn, and `normalizeDshEvent` drops SubagentStart/SubagentStop, so a + * `done` row for a DSH pane is the LEAD's, never a child's finishing early. + * + * Scoped rather than general: for agents whose hooks do report child turns, a `done` row + * can arrive mid-turn, and settling on it is exactly the #6011 class this file exists to + * prevent. + */ +export function hasFreshDoneFirstPartyStatus( + agent: TuiAgent | null | undefined, + status: FirstPartyAgentStatus, + staleAfterMs = AGENT_STATUS_STALE_AFTER_MS +): boolean { + if (agent !== 'dsh' || status?.state !== 'done') { + return false + } + return Date.now() - status.updatedAt <= staleAfterMs +} + /** Tier 2: the agent's own status stream says this turn is still open. */ export function hasFreshWorkingFirstPartyStatus(status: FirstPartyAgentStatus): boolean { return isFreshNonDoneAgentStatus(status ?? undefined) } +/** Agents that paint their own explicit rest title (Claude's `✳`). Gemini's `◇` would qualify + * but is not yet held to it. */ +const NATIVE_EXPLICIT_IDLE_TITLE_AGENTS: ReadonlySet = new Set(['claude']) + /** - * Whether a name-only title from `agent` may be held to the tier-3 quiescence demand. + * Whether a name-only title from `agent` must be corroborated by a quiet stream. * - * Only for agents that go on to announce rest with an explicit title of their own (the - * hook-driven `Codex ready` / `Devin ready`). Grok, Copilot, Aider, Mimo, agy and - * OpenCode emit their NAME and nothing more at rest, so holding them to it leaves no - * settle signal at all: a real idle Grok pane repaints its banner about four times a - * second forever, so the stream never quiesces and the wait runs to timeout. + * Only for agents that announce rest with an explicit title of their own: the hook-driven + * `Codex ready` / `Devin ready`, or Claude's `✳`. For them the bare name is not a rest signal — + * a shell auto-title (`claude`, `claude ~/repo`) names the agent from the moment it starts, over + * a start-up dialog or a busy turn alike. Grok, Copilot, Aider, Mimo, agy and OpenCode emit + * their NAME and nothing more at rest, so holding them to it leaves no settle signal at all: + * a real idle Grok pane repaints its banner about four times a second forever, so the stream + * never quiesces and the wait runs to timeout. */ export function nameOnlyIdleNeedsCorroboration( agent: TuiAgent | null | undefined, @@ -76,7 +127,11 @@ export function nameOnlyIdleNeedsCorroboration( // Why the title fallback: an adopted pane carries no launch metadata, but its // name-only title is exactly the thing that names the agent. const resolved = agent ?? (title ? resolveExplicitTerminalTitleAgentType(title) : null) - return getSyntheticAgentTerminalTitle(resolved, 'done') !== null + return ( + resolved !== null && + (NATIVE_EXPLICIT_IDLE_TITLE_AGENTS.has(resolved) || + getSyntheticAgentTerminalTitle(resolved, 'done') !== null) + ) } /** Tier 3: a title-derived idle, usable only once the stream has also gone quiet. */ @@ -103,17 +158,28 @@ export function hasSustainedTitleIdle( } /** - * Tier 3, cold start: Orca launched a known agent on this PTY, so a quiet non-shell - * foreground process is an agent still booting, not one sitting at its prompt. Resolving - * on it is what let `dispatch --inject` write into a TUI that had not yet attached its - * reader and silently lose the prompt (#9976). + * When a quiet non-shell foreground process may settle a pending wait. + * - `closed`: the agent has a stronger rest signal, so its silence is boot, not rest. + * Resolving on it let `dispatch --inject` write into a TUI that had not yet attached + * its reader and silently lose the prompt (#9976). + * - `after-paint`: Orca knows an agent runs here but it has no other rest signal, so this + * lane is its only one. A TUI that has painted nothing yet is still booting. + * - `open`: nothing is known about the pane, so a missing output clock also counts as quiet + * (see isQuietForQuiescence). */ -export function quietForegroundProcessProvesTuiIdle(agent: TuiAgent | null | undefined): boolean { - return !agent +export type QuietForegroundLane = 'closed' | 'after-paint' | 'open' + +function quietForegroundLane(agent: TuiAgent | null | undefined): QuietForegroundLane { + if (!agent) { + return 'open' + } + return getTuiAgentRestSignal(agent) === 'none' ? 'after-paint' : 'closed' } -export type TuiIdleSatisfactionInput = { +export type TuiIdleEvaluationInput = { record: TuiIdleEvidenceRecord + /** Tier 0: a blocking prompt in the line tail. */ + readTailBlockedReason: () => RuntimeTerminalWaitBlockedReason | null /** Renderer-synced pane/tab title, when one exists. */ rendererTitle?: string | null /** Tier 1 body evidence: a known ready prompt, or an adopted pane's explicit title. @@ -121,63 +187,174 @@ export type TuiIdleSatisfactionInput = { * (~11us and a multi-KB string on a full tail); the title check below usually answers * first, and then none of that has to happen at all. */ readPositiveBodyEvidence: () => boolean - /** Tier 1b body evidence: a Muse ready screen. Thunk for the same reason as above. */ - readMuseReadyBodyEvidence: () => boolean + /** Tier 1b body evidence: a Muse or Codex ready screen. Thunk for the same reason as above. */ + readQuietReadyBodyEvidence: () => boolean agent: TuiAgent | null | undefined firstPartyStatus: FirstPartyAgentStatus quiescenceMs: number } +export type TuiIdleVerdict = + | { kind: 'blocked'; reason: RuntimeTerminalWaitBlockedReason } + | { kind: 'ready-strong' } + /** Settles only on the poll, after a rendered-screen read finds no blocker. */ + | { kind: 'ready-weak' } + /** The agent says it is mid-turn: nothing may settle, and its screen is not read. */ + | { kind: 'working' } + | { kind: 'pending'; quietForeground: QuietForegroundLane } + +const READY_STRONG: TuiIdleVerdict = { kind: 'ready-strong' } +const READY_WEAK: TuiIdleVerdict = { kind: 'ready-weak' } +const WORKING: TuiIdleVerdict = { kind: 'working' } + +const QUIET_READY_SCREEN_AGENTS: ReadonlySet = new Set(['muse', 'codex']) + /** - * Tier 1b: a Muse ready screen in the body, believed only once the stream has gone quiet. + * Tier 1b: a ready screen in the body, believed only once the stream has gone quiet. * - * Muse is the one agent with no title signal at all — its OSC title is the bare cwd and - * never changes — so neither the explicit-idle nor the sustained-title lane can fire. - * The ready screen proves the TUI is up; the quiescence demand keeps a mid-turn - * streaming pane from satisfying, mirroring the codex tier-3 lane's - * positive-evidence-plus-quiet shape. Scoped to Muse and agent-unknown panes: another - * agent's scrollback quoting Muse must not settle its wait. + * Muse's OSC title is the bare cwd and never changes, and an idle Codex titles its pane with + * the cwd (plus a thread name) and no agent name, so neither the explicit-idle nor the + * sustained-title lane can fire. The ready screen proves the TUI is up; the quiescence + * demand keeps a mid-turn streaming pane from satisfying, mirroring the tier-3 lane's + * positive-evidence-plus-quiet shape. Scoped to those agents and agent-unknown panes (which + * read only Muse's screen): another agent's scrollback quoting them must not settle its wait. */ -export function hasQuietMuseReadyPrompt( +export function hasQuietReadyScreen( record: TuiIdleEvidenceRecord, agent: TuiAgent | null | undefined, readBodyEvidence: () => boolean, quiescenceMs: number ): boolean { - if (agent !== null && agent !== undefined && agent !== 'muse') { - return false - } - if (!readBodyEvidence()) { + if (agent && !QUIET_READY_SCREEN_AGENTS.has(agent)) { return false } // Why: same rule as the tier-3 lane — without an output clock there is no // corroboration available, so hold out instead of settling. - if (record.lastOutputAt === null) { + if (record.lastOutputAt === null || Date.now() - record.lastOutputAt < quiescenceMs) { return false } - return Date.now() - record.lastOutputAt >= quiescenceMs + // Why last: a streaming pane never pays for the screen projection. + return readBodyEvidence() } -/** The one place the tiers are combined; every satisfaction site routes here. */ -export function isTuiIdleSatisfied(input: TuiIdleSatisfactionInput): boolean { +/** The one place the tiers are combined; every settle site branches only on the verdict. */ +export function evaluateTuiIdle(input: TuiIdleEvaluationInput): TuiIdleVerdict { + const blockedReason = input.readTailBlockedReason() + if (blockedReason) { + return { kind: 'blocked', reason: blockedReason } + } + // Qoder publishes "Ready" before its trust dialog is dismissed; only its composer proves input is live. + if (input.agent === 'qoder') { + if ( + hasFreshWorkingFirstPartyStatus(input.firstPartyStatus) || + input.record.lastAgentStatus === 'working' + ) { + return WORKING + } + return input.readPositiveBodyEvidence() + ? READY_STRONG + : { kind: 'pending', quietForeground: 'closed' } + } // Why the title before the body: both are tier 1, so either settles, but the title is a // memoized lookup and the body is a fresh multi-KB scan. Same verdict, cheaper order. if (hasExplicitIdleTitle(input.record, input.rendererTitle) || input.readPositiveBodyEvidence()) { - return true + return READY_STRONG + } + // Why beside the title lane, not after the veto: both are tier 1, and a first-party `done` + // and a fresh `working` cannot both hold — the same row carries one state. + if (hasFreshDoneFirstPartyStatus(input.agent, input.firstPartyStatus)) { + return READY_STRONG } if (hasFreshWorkingFirstPartyStatus(input.firstPartyStatus)) { - return false + // Why blocked/waiting stays pending: the agent says it is waiting on the user, which is + // when a dialog is on screen, so the screen read must still run. + return input.firstPartyStatus?.state === 'working' + ? WORKING + : { kind: 'pending', quietForeground: 'closed' } } // Why after the veto: a first-party working account outranks inferred body evidence. + // Why before the working title: Codex can leave a stale spinner title after a turn, and a + // live spinner emits output every ~100 ms, so a spinning pane is never quiet here. if ( - hasQuietMuseReadyPrompt( + hasQuietReadyScreen( input.record, input.agent, - input.readMuseReadyBodyEvidence, + input.readQuietReadyBodyEvidence, input.quiescenceMs ) ) { - return true + return READY_STRONG + } + if (input.record.lastAgentStatus === 'working') { + return WORKING + } + if (hasSustainedTitleIdle(input.record, input.agent, input.quiescenceMs)) { + return READY_WEAK + } + return { + kind: 'pending', + quietForeground: + input.record.lastAgentStatus === null ? quietForegroundLane(input.agent) : 'closed' + } +} + +export function isTuiIdleReadyVerdict(verdict: TuiIdleVerdict): boolean { + return verdict.kind === 'ready-strong' || verdict.kind === 'ready-weak' +} + +/** The runtime state every tui-idle site reads its evidence from. */ +export type TuiIdleEvidenceSource = { + quiescenceMs: number + getTabTitle(tabId: string): string | null + getAdoptedPtyIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null + getPaneAgent(ptyId: string | null | undefined): TuiAgent | null + getFirstPartyAgentStatus(ptyId: string | null | undefined): FirstPartyAgentStatus + readScreenLines(ptyId: string | null | undefined): readonly string[] | null +} + +function lazyWaitText(readWaitText: () => string): () => string { + let waitText: string | null = null + return () => (waitText ??= readWaitText()) +} + +export function leafTuiIdleEvidence( + source: TuiIdleEvidenceSource, + leaf: RuntimeLeafRecord, + readWaitText: () => string +): TuiIdleEvaluationInput { + const waitText = lazyWaitText(readWaitText) + const agent = source.getPaneAgent(leaf.ptyId) + return { + record: leaf, + readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText()), + rendererTitle: leaf.paneTitle ?? source.getTabTitle(leaf.tabId), + readPositiveBodyEvidence: () => + isKnownReadyPromptBody(waitText(), agent, () => source.readScreenLines(leaf.ptyId)), + readQuietReadyBodyEvidence: () => + isQuietReadyScreenBody(waitText(), agent, () => source.readScreenLines(leaf.ptyId)), + agent, + firstPartyStatus: source.getFirstPartyAgentStatus(leaf.ptyId), + quiescenceMs: source.quiescenceMs + } +} + +export function ptyTuiIdleEvidence( + source: TuiIdleEvidenceSource, + pty: RuntimePtyWorktreeRecord, + readWaitText: () => string +): TuiIdleEvaluationInput { + const waitText = lazyWaitText(readWaitText) + const agent = source.getPaneAgent(pty.ptyId) + return { + record: pty, + readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText()), + readPositiveBodyEvidence: () => + (agent !== 'qoder' && source.getAdoptedPtyIdleStatus(pty) === 'idle') || + isKnownReadyPromptBody(waitText(), agent, () => source.readScreenLines(pty.ptyId)), + readQuietReadyBodyEvidence: () => + isQuietReadyScreenBody(waitText(), agent, () => source.readScreenLines(pty.ptyId)), + agent, + firstPartyStatus: source.getFirstPartyAgentStatus(pty.ptyId), + quiescenceMs: source.quiescenceMs } - return hasSustainedTitleIdle(input.record, input.agent, input.quiescenceMs) } diff --git a/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts b/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts index 0d265a0a5bb..562ab23ee3e 100644 --- a/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts +++ b/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts @@ -109,10 +109,8 @@ async function terminalWait( describe.skipIf(process.platform === 'win32')('tui-idle against a real agent pty', () => { it('does not satisfy while the real process streams under a name-only title', async () => { const { runtime, transcript, handle } = await startRealAgentPane('quiet', 60_000) - await new Promise((resolve) => setTimeout(resolve, 500)) - - // The OSC title really did reach the runtime as control bytes, not literal text. - expect(transcript.join('')).toContain(']0;Codex') + // Wait for real control bytes before measuring idle behavior. + await expect.poll(() => transcript.join(''), { timeout: 5_000 }).toContain(']0;Codex') const outcome = await terminalWait(runtime, handle, 8_000) expect(outcome.satisfied).toBe(false) diff --git a/src/main/runtime/tui-idle-weak-evidence-seam.test.ts b/src/main/runtime/tui-idle-weak-evidence-seam.test.ts new file mode 100644 index 00000000000..d608666fbdc --- /dev/null +++ b/src/main/runtime/tui-idle-weak-evidence-seam.test.ts @@ -0,0 +1,107 @@ +/** + * The seam a new weak-ready rank plugs into. Weak ready is a verdict class, not a per-evidence + * flag: no settle site may act on it before a rendered-screen read, whatever evidence produced + * it. The stand-in below fires over Claude's trust dialog itself, as a composer-ready signal + * such as bracketed paste would, and the dialog must still win. + */ + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' +import type * as TuiIdleEvidence from './tui-idle-evidence' + +const syntheticWeakEvidence = vi.hoisted(() => ({ fires: false })) + +vi.mock('./tui-idle-evidence', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + evaluateTuiIdle: ( + ...args: Parameters + ): TuiIdleEvidence.TuiIdleVerdict => { + const verdict = actual.evaluateTuiIdle(...args) + return syntheticWeakEvidence.fires && verdict.kind === 'pending' + ? { kind: 'ready-weak' } + : verdict + } + } +}) + +const POLL_INTERVAL_MS = 2_000 + +function readDialog(): { data: string; size: { cols: number; rows: number } } { + const base = join(__dirname, '__fixtures__', 'claude-dialog-trust-workspace') + const meta: { cols: number; rows: number } = JSON.parse(readFileSync(`${base}.meta.json`, 'utf8')) + return { data: readFileSync(`${base}.txt`, 'utf8'), size: { cols: meta.cols, rows: meta.rows } } +} + +async function createPane() { + const { data, size } = readDialog() + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'claude', + launchAgent: 'claude', + size, + data: '' + }) + vi.useFakeTimers() + const write = (chunk: string) => runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, chunk, Date.now()) + const paintDialog = () => { + const bytes = Buffer.from(data, 'utf8') + const decoder = new TextDecoder() + for (let offset = 0; offset < bytes.length; offset += 1024) { + write(decoder.decode(bytes.subarray(offset, offset + 1024), { stream: true })) + } + } + const wait = () => { + const settled = vi.fn() + const promise = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 }) + promise.then(settled, () => {}) + return { promise, settled } + } + return { write, paintDialog, wait } +} + +describe('a new weak-ready rank', () => { + afterEach(() => { + syntheticWeakEvidence.fires = false + vi.useRealTimers() + }) + + it('settles a pane with nothing on screen, so the stand-in reaches the settle sites', async () => { + const pane = await createPane() + pane.write('starting\r\n') + syntheticWeakEvidence.fires = true + const { promise } = pane.wait() + await vi.advanceTimersByTimeAsync(0) + await expect(promise).resolves.toMatchObject({ satisfied: true }) + }) + + it('reports the dialog to a wait registered after it painted', async () => { + const pane = await createPane() + pane.paintDialog() + syntheticWeakEvidence.fires = true + const { promise } = pane.wait() + await vi.advanceTimersByTimeAsync(0) + await expect(promise).resolves.toMatchObject({ + satisfied: false, + blockedReason: 'agent-trust-workspace' + }) + }) + + it('reports the dialog when a title change offers the evidence before it painted', async () => { + const pane = await createPane() + const { promise, settled } = pane.wait() + syntheticWeakEvidence.fires = true + pane.write('\x1b]0;claude ~/p/repo\x07') + await vi.advanceTimersByTimeAsync(0) + expect(settled).not.toHaveBeenCalled() + pane.paintDialog() + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS) + await expect(promise).resolves.toMatchObject({ + satisfied: false, + blockedReason: 'agent-trust-workspace' + }) + }) +}) diff --git a/src/main/runtime/windows-mobile-firewall.test.ts b/src/main/runtime/windows-mobile-firewall.test.ts index d561878a538..162f46e682b 100644 --- a/src/main/runtime/windows-mobile-firewall.test.ts +++ b/src/main/runtime/windows-mobile-firewall.test.ts @@ -67,6 +67,18 @@ describe('windows mobile firewall', () => { expect(script).toContain('$localPrefixLength = [int]$ip.PrefixLength') }) + it('doubles typographic single quotes in the executable literal', async () => { + const runPowerShell = vi.fn().mockResolvedValue('not json') + await inspectWindowsMobileFirewall( + 6768, + '192.168.0.108', + environment(runPowerShell, { executablePath: 'C:\\Users\\O\u2019Brien\\Orca\\Orca.exe' }) + ) + expect(runPowerShell.mock.calls[0]![0]).toContain( + "-Program 'C:\\Users\\O\u2019\u2019Brien\\Orca\\Orca.exe'" + ) + }) + it('treats an overlapping inbound Block rule as overriding a matching Allow rule', async () => { const runPowerShell = vi.fn().mockResolvedValue( JSON.stringify({ diff --git a/src/main/runtime/windows-mobile-firewall.ts b/src/main/runtime/windows-mobile-firewall.ts index c90a025cb14..5d17bf5f5a8 100644 --- a/src/main/runtime/windows-mobile-firewall.ts +++ b/src/main/runtime/windows-mobile-firewall.ts @@ -5,6 +5,7 @@ import type { WindowsMobileFirewallStatus, WindowsNetworkCategory } from '../../shared/windows-mobile-firewall' +import { quotePowerShellLiteral } from '../../shared/powershell-native-argument' import { hasSufficientWindowsFirewallRemoteScope } from './windows-firewall-remote-scope' const FIREWALL_RULE_NAME = 'Orca.MobilePairing' @@ -158,15 +159,11 @@ function parseNetworkCategory(value: string): WindowsNetworkCategory { return 'unknown' } -function quotePowerShell(value: string): string { - return `'${value.replaceAll("'", "''")}'` -} - function buildInspectionScript(port: number, executablePath: string, address?: string): string { const addressLookup = address ? ` try { - $ip = Get-NetIPAddress -IPAddress ${quotePowerShell(address)} -ErrorAction Stop | Select-Object -First 1 + $ip = Get-NetIPAddress -IPAddress ${quotePowerShellLiteral(address)} -ErrorAction Stop | Select-Object -First 1 $localAddress = [string]$ip.IPAddress $localPrefixLength = [int]$ip.PrefixLength $profile = Get-NetConnectionProfile -InterfaceIndex $ip.InterfaceIndex -ErrorAction Stop | Select-Object -First 1 @@ -180,7 +177,7 @@ try { return `$ErrorActionPreference = 'Stop' $matchingRuleScopes = @() $blockingRuleDetected = $false -$rules = @(Get-NetFirewallApplicationFilter -PolicyStore ActiveStore -Program ${quotePowerShell(executablePath)} -ErrorAction SilentlyContinue | Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' }) +$rules = @(Get-NetFirewallApplicationFilter -PolicyStore ActiveStore -Program ${quotePowerShellLiteral(executablePath)} -ErrorAction SilentlyContinue | Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' }) foreach ($rule in $rules) { $portFilter = $rule | Get-NetFirewallPortFilter $protocol = [string]$portFilter.Protocol @@ -215,7 +212,7 @@ function buildRepairScript(port: number, executablePath: string): string { // Removal deliberately ignores the Block rule's remote-address scope, // mirroring the fail-closed inspection (the phone address is unknown). return `$ErrorActionPreference = 'Stop' -$blockingRules = @(Get-NetFirewallApplicationFilter -Program ${quotePowerShell(executablePath)} -ErrorAction SilentlyContinue | Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' -and $_.Action -eq 'Block' }) +$blockingRules = @(Get-NetFirewallApplicationFilter -Program ${quotePowerShellLiteral(executablePath)} -ErrorAction SilentlyContinue | Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' -and $_.Action -eq 'Block' }) foreach ($rule in $blockingRules) { $portFilter = $rule | Get-NetFirewallPortFilter $protocol = [string]$portFilter.Protocol @@ -225,8 +222,8 @@ foreach ($rule in $blockingRules) { $rule | Remove-NetFirewallRule } } -Get-NetFirewallRule -Name ${quotePowerShell(FIREWALL_RULE_NAME)} -ErrorAction SilentlyContinue | Remove-NetFirewallRule -New-NetFirewallRule -Name ${quotePowerShell(FIREWALL_RULE_NAME)} -DisplayName ${quotePowerShell(FIREWALL_RULE_DISPLAY_NAME)} -Description 'Allows Orca Mobile to connect to this Orca desktop on private networks.' -Direction Inbound -Action Allow -Enabled True -Profile Private -Protocol TCP -LocalPort ${port} -Program ${quotePowerShell(executablePath)} -EdgeTraversalPolicy Block | Out-Null` +Get-NetFirewallRule -Name ${quotePowerShellLiteral(FIREWALL_RULE_NAME)} -ErrorAction SilentlyContinue | Remove-NetFirewallRule +New-NetFirewallRule -Name ${quotePowerShellLiteral(FIREWALL_RULE_NAME)} -DisplayName ${quotePowerShellLiteral(FIREWALL_RULE_DISPLAY_NAME)} -Description 'Allows Orca Mobile to connect to this Orca desktop on private networks.' -Direction Inbound -Action Allow -Enabled True -Profile Private -Protocol TCP -LocalPort ${port} -Program ${quotePowerShellLiteral(executablePath)} -EdgeTraversalPolicy Block | Out-Null` } // Why the elevated child keeps `-EncodedCommand` while the local runner does not: `Start-Process @@ -237,7 +234,7 @@ New-NetFirewallRule -Name ${quotePowerShell(FIREWALL_RULE_NAME)} -DisplayName ${ function buildElevationScript(powershellPath: string, encodedRepairScript: string): string { return `$ErrorActionPreference = 'Stop' try { - $process = Start-Process -FilePath ${quotePowerShell(powershellPath)} -ArgumentList @('-NoProfile', '-NonInteractive', '-EncodedCommand', '${encodedRepairScript}') -Verb RunAs -Wait -PassThru + $process = Start-Process -FilePath ${quotePowerShellLiteral(powershellPath)} -ArgumentList @('-NoProfile', '-NonInteractive', '-EncodedCommand', '${encodedRepairScript}') -Verb RunAs -Wait -PassThru [pscustomobject]@{ launched = $true; exitCode = $process.ExitCode } | ConvertTo-Json -Compress } catch { [pscustomobject]@{ launched = $false; nativeErrorCode = $_.Exception.NativeErrorCode } | ConvertTo-Json -Compress diff --git a/src/main/runtime/workspace-session-failed-write-rollback.ts b/src/main/runtime/workspace-session-failed-write-rollback.ts deleted file mode 100644 index 7234446cb9d..00000000000 --- a/src/main/runtime/workspace-session-failed-write-rollback.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { isDeepStrictEqual } from 'node:util' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' - -const MISSING = Symbol('missing') - -/** A JSON-shaped slot of persisted session state, or the absent-key sentinel. */ -type RollbackSlot = - | string - | number - | boolean - | null - | undefined - | typeof MISSING - | readonly RollbackSlot[] - | RollbackRecord - -type RollbackRecord = { readonly [key: string]: RollbackSlot } - -function isRecord(value: RollbackSlot): value is RollbackRecord { - return ( - value !== MISSING && - typeof value === 'object' && - value !== null && - !Array.isArray(value) && - Object.getPrototypeOf(value) === Object.prototype - ) -} - -function rollbackValue( - original: RollbackSlot, - staged: RollbackSlot, - current: RollbackSlot -): RollbackSlot { - if (isDeepStrictEqual(original, staged)) { - return current - } - if (isDeepStrictEqual(current, staged)) { - return original - } - if (!isRecord(original) || !isRecord(staged) || !isRecord(current)) { - return current - } - let changed = false - const next: Record = { ...current } - for (const key of new Set([ - ...Object.keys(original), - ...Object.keys(staged), - ...Object.keys(current) - ])) { - const value = rollbackValue( - Object.hasOwn(original, key) ? original[key] : MISSING, - Object.hasOwn(staged, key) ? staged[key] : MISSING, - Object.hasOwn(current, key) ? current[key] : MISSING - ) - if (value === MISSING) { - if (Object.hasOwn(next, key)) { - delete next[key] - changed = true - } - } else if (!Object.hasOwn(current, key) || !isDeepStrictEqual(current[key], value)) { - next[key] = value - changed = true - } - } - return changed ? next : current -} - -export function rollbackWorkspaceSessionAfterFailedAsyncWrite( - original: WorkspaceSessionState, - staged: WorkspaceSessionState, - current: WorkspaceSessionState -): WorkspaceSessionState { - return rollbackValue(original, staged, current) as WorkspaceSessionState -} diff --git a/src/main/runtime/workspace-session-terminal-membership-authority.ts b/src/main/runtime/workspace-session-terminal-membership-authority.ts index 76cbcdf4dad..7798ae7831c 100644 --- a/src/main/runtime/workspace-session-terminal-membership-authority.ts +++ b/src/main/runtime/workspace-session-terminal-membership-authority.ts @@ -70,7 +70,7 @@ function rebaseUnifiedTabs( (tab) => tab.contentType !== 'terminal' || terminalUnifiedTabMatches(tab, terminalTabIds) ) const representedTerminalIds = new Set( - result.filter((tab) => tab.contentType === 'terminal').flatMap((tab) => [tab.id, tab.entityId]) + result.flatMap((tab) => (tab.contentType === 'terminal' ? [tab.id, tab.entityId] : [])) ) for (const tab of current) { if ( diff --git a/src/main/runtime/worktree-terminal-mutation-lock.test.ts b/src/main/runtime/worktree-terminal-mutation-lock.test.ts index 822238034a8..b972bd02dec 100644 --- a/src/main/runtime/worktree-terminal-mutation-lock.test.ts +++ b/src/main/runtime/worktree-terminal-mutation-lock.test.ts @@ -16,9 +16,13 @@ describe('WorktreeTerminalMutationLock', () => { lock.acquire(KEY, 'shared') ]) expect(releases).toHaveLength(4) + expect(lock.hasActiveSpawns(KEY)).toBe(true) + expect(lock.hasActiveSpawns('other')).toBe(false) for (const release of releases) { + expect(lock.hasActiveSpawns(KEY)).toBe(true) release() } + expect(lock.hasActiveSpawns(KEY)).toBe(false) expect(lock.trackedKeyCount).toBe(0) }) diff --git a/src/main/runtime/worktree-terminal-mutation-lock.ts b/src/main/runtime/worktree-terminal-mutation-lock.ts index b0af98b2d3b..f76d8449826 100644 --- a/src/main/runtime/worktree-terminal-mutation-lock.ts +++ b/src/main/runtime/worktree-terminal-mutation-lock.ts @@ -35,6 +35,10 @@ export const WORKTREE_TERMINAL_SLEEP_TIMEOUT_ERROR = 'terminal_worktree_sleep_ti export class WorktreeTerminalMutationLock { private readonly entries = new Map() + hasActiveSpawns(key: string): boolean { + return (this.entries.get(key)?.activeSpawns ?? 0) > 0 + } + /** Why exposed: entry deletion is the only thing keeping this map from * becoming a per-worktree leak, so the tests assert on it directly. */ get trackedKeyCount(): number { diff --git a/src/main/runtime/zcode-readiness-transcript.test.ts b/src/main/runtime/zcode-readiness-transcript.test.ts index 2319915ab68..bd9909f89de 100644 --- a/src/main/runtime/zcode-readiness-transcript.test.ts +++ b/src/main/runtime/zcode-readiness-transcript.test.ts @@ -21,6 +21,18 @@ function readTranscript(): string { } describe('ZCode readiness from captured terminal bytes', () => { + it('accepts a fresh composer after the renderer adopts the terminal handle', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'worker-zcode', + foregroundProcess: 'zcode', + launchAgent: 'zcode', + data: '\x1b[?1049h╭' + }) + await expect( + runtime.waitForFreshWorkerComposer(handle, 'zcode', 1_000) + ).resolves.toBeUndefined() + }) + it('never emits an OSC title, so no title lane can settle its wait', () => { const data = readTranscript() expect(data).toContain(String.fromCharCode(27)) diff --git a/src/main/shell-wrapper-file-writer.ts b/src/main/shell-wrapper-file-writer.ts index 1e57a210bba..a30dff1503e 100644 --- a/src/main/shell-wrapper-file-writer.ts +++ b/src/main/shell-wrapper-file-writer.ts @@ -14,7 +14,8 @@ export type ShellWrapperFile = readonly [path: string, content: string] export function writeShellWrapperFiles( files: readonly ShellWrapperFile[], - logPrefix: string + logPrefix: string, + consequence = 'Shell will launch unwrapped' ): boolean { try { for (const [path, content] of files) { @@ -38,7 +39,7 @@ export function writeShellWrapperFiles( ? `${error.message} (${(error as NodeJS.ErrnoException).code || 'unknown'})` : String(error) console.error(`${logPrefix} Failed to write shell wrapper files: ${errorMessage}`) - console.error(`${logPrefix} Shell will launch unwrapped`) + console.error(`${logPrefix} ${consequence}`) return false } } diff --git a/src/main/skills/discovery-filter-sharing.test.ts b/src/main/skills/discovery-filter-sharing.test.ts index 14f755d0100..14fe9faf580 100644 --- a/src/main/skills/discovery-filter-sharing.test.ts +++ b/src/main/skills/discovery-filter-sharing.test.ts @@ -86,17 +86,20 @@ it('retains a newly requested name when its previously observed root becomes una const args = { homeDir: root, repos: [], includeCwd: false, sourceKinds: ['home' as const] } await repair.discoverSkills({ ...args, names: ['skill-0'] }) const original = SkillScanCoalescer.prototype.run - vi.spyOn(SkillScanCoalescer.prototype, 'run').mockImplementation( - function (this: SkillScanCoalescer, key, options, task) { - if ( - key === `home\0${join(root, '.agents', 'skills')}` || - key.startsWith(`home\0${join(root, '.agents', 'skills')}\0`) - ) { - return Promise.reject(new SkillScanShedError()) - } - return original.call(this, key, options, task) + vi.spyOn(SkillScanCoalescer.prototype, 'run').mockImplementation(function ( + this: SkillScanCoalescer, + key, + options, + task + ) { + if ( + key === `home\0${join(root, '.agents', 'skills')}` || + key.startsWith(`home\0${join(root, '.agents', 'skills')}\0`) + ) { + return Promise.reject(new SkillScanShedError()) } - ) + return original.call(this, key, options, task) + }) const next = await repair.discoverSkills({ ...args, names: ['skill-47'] }) expect(next.skills.map((skill) => skill.name)).toEqual(['skill-47']) expect(next.sources.find((source) => source.id === 'home-agents')?.skippedReason).toBe( diff --git a/src/main/source-control/hosted-review-branch-cache.ts b/src/main/source-control/hosted-review-branch-cache.ts index 0e6f207ab9c..7a9f02e50c8 100644 --- a/src/main/source-control/hosted-review-branch-cache.ts +++ b/src/main/source-control/hosted-review-branch-cache.ts @@ -13,6 +13,15 @@ import { settleDetachedLookup, settleLookup } from './hosted-review-unsettled-lookups' +import { + __resetHostedReviewInflightLookupsForTests, + expireOverdueInflight, + getInflightLookup, + releaseInflight, + retireInflightWithPrefix, + trackInflight, + type InflightToken +} from './hosted-review-inflight-lookups' import { __resetHostedReviewScopeGenerationsForTests, bumpScopeGeneration, @@ -29,7 +38,6 @@ import { ACTIVE_REFRESH_INTERVAL_MS, HOSTED_REVIEW_LOOKUP_DEADLINE_MS, MAX_BRANCH_MAP_ENTRIES, - MAX_INFLIGHT_LOOKUPS, NO_REVIEW_REFRESH_INTERVAL_MS } from './hosted-review-refresh-pacing' @@ -59,22 +67,7 @@ type CacheEntry = { startedAt: number } -declare const inflightTokenBrand: unique symbol - -/** Identity token for one lookup; only ever compared by reference. */ -type InflightToken = { readonly [inflightTokenBrand]?: never } - -type InflightRecord = { - /** Identity, so a detached lookup can only ever clear its own entry. */ - token: InflightToken - startedAt: number - promise: Promise - /** Releases the callers and unpins the branch; idempotent. */ - expire: () => void -} - const entries = new Map() -const inflight = new Map() // Why: NUL is the one byte a repo path or branch name cannot contain, so a // scope prefix cannot straddle a component boundary — invalidating `/a/b` must // not also flush the unrelated repo at `/a/b c`. @@ -158,53 +151,6 @@ function storeEntry(key: string, entry: CacheEntry): void { } } -/** Clears the key's in-flight record only if it is still this lookup's. */ -function releaseInflight(key: string, token: InflightToken): boolean { - if (inflight.get(key)?.token !== token) { - return false - } - inflight.delete(key) - return true -} - -/** - * Expires records that outlived the deadline without their timer firing. Main's - * timers are suspended across a system sleep, so wall-clock age — not - * `setTimeout` alone — is what actually bounds how long a branch stays pinned. - * - * The guarantee covers tracked records only: one the size cap evicted is no - * longer reachable here and falls back to its own suspended timer. - */ -function expireOverdueInflight(now: number): void { - let overdue: InflightRecord[] | undefined - for (const record of inflight.values()) { - if (now - record.startedAt >= HOSTED_REVIEW_LOOKUP_DEADLINE_MS) { - overdue ??= [] - overdue.push(record) - } - } - // Expire after the walk: each one deletes its own entry from the map. - for (const record of overdue ?? []) { - record.expire() - } -} - -function trackInflight(key: string, record: InflightRecord): void { - inflight.set(key, record) - while (inflight.size > MAX_INFLIGHT_LOOKUPS) { - const oldest = inflight.keys().next().value - if (oldest === undefined) { - break - } - // Why: drop the record without expiring it — its own deadline still - // releases its callers, and evicting is about memory, not about failing. - // It does forfeit the sweep's wall-clock release, so the cap must stay far - // above realistic concurrency: below it, sleep-suspended timers are all an - // evicted record's callers have left. - inflight.delete(oldest) - } -} - /** * Drops every cached answer for a repo. Called when Orca itself opens a review, * so the new one is visible immediately instead of after the no-review interval. @@ -221,13 +167,14 @@ export function invalidateHostedReviewBranchCache( entries.delete(key) } } + retireInflightWithPrefix(prefix) dropFailuresWithPrefix(prefix) } /** @internal - exposed for tests only */ export function __resetHostedReviewBranchCacheForTests(): void { entries.clear() - inflight.clear() + __resetHostedReviewInflightLookupsForTests() __resetHostedReviewLookupBackoffForTests() __resetHostedReviewActiveClaimsForTests() __resetUnsettledHostedReviewLookupsForTests() @@ -244,7 +191,7 @@ export function __resetHostedReviewBranchCacheForTests(): void { * was about to give the real one. */ function canAdoptDetachedAnswer(key: string, startedAt: number): boolean { - if (inflight.has(key)) { + if (getInflightLookup(key) !== undefined) { return false } const current = entries.get(key) @@ -341,7 +288,7 @@ function startLookup( // straggler and has to prove it still outranks what is there. const stored = generation === scopeGeneration(scope) && - (inflight.get(key)?.token === token || canAdoptDetachedAnswer(key, startedAt)) + (getInflightLookup(key)?.token === token || canAdoptDetachedAnswer(key, startedAt)) if (stored) { storeEntry(key, { review, fetchedAt: Date.now(), headOid, startedAt }) } @@ -360,7 +307,7 @@ function startLookup( } // Why: a record the size cap dropped has a live successor, and backing the // branch off would slow the retry that is already running. - if (inflight.get(key)?.token === token) { + if (getInflightLookup(key)?.token === token) { noteFailure(key) } // Why: the last good review beats an error card here just as it does on @@ -417,7 +364,7 @@ export async function withHostedReviewBranchCache( return cached.review } - const pending = inflight.get(key) + const pending = getInflightLookup(key) if (pending) { return pending.promise } diff --git a/src/main/source-control/hosted-review-execution-host.ts b/src/main/source-control/hosted-review-execution-host.ts index 1def4d60492..3f2ad49b303 100644 --- a/src/main/source-control/hosted-review-execution-host.ts +++ b/src/main/source-control/hosted-review-execution-host.ts @@ -1,10 +1,5 @@ -import { - getRepoExecutionHostId, - getSshTargetIdForExecutionHost, - LOCAL_EXECUTION_HOST_ID, - type ExecutionHostId -} from '../../shared/execution-host' -import type { Repo } from '../../shared/repo-types' +import type { ExecutionHostId } from '../../shared/execution-host' +export { getStoredRepoExecutionHostId as getRepoHostedReviewExecutionHostId } from '../repo-execution-host' import { ExecutionHostNotDispatchableError, resolveGitRouteForHost @@ -30,20 +25,3 @@ export function hostedReviewSshConnectionId(executionHostId: ExecutionHostId): s } return route.kind === 'ssh' ? route.connectionId : null } - -/** - * The host this process may run a hosted review on for a row in *its own* store. - * - * `getSshTargetIdForExecutionHost` and not `getRepoSshConnectionId`: a `runtime:` stamp on a row in - * this store is how a paired client addresses it, not a second machine holding the files. The - * runtime registration controller only adopts that stamp onto a row with no `connectionId` - * (`runtimeRepoMatchesExecutionHost` refuses to match an SSH row), so the checkout really is here - * and this keeps the review that has always been created for it. A row whose files sit on an SSH - * host keeps its own target — including one that carries only `executionHostId: ssh:…`. - */ -export function getRepoHostedReviewExecutionHostId( - repo: Pick -): ExecutionHostId { - const hostId = getRepoExecutionHostId(repo) - return getSshTargetIdForExecutionHost(hostId) ? hostId : LOCAL_EXECUTION_HOST_ID -} diff --git a/src/main/source-control/hosted-review-inflight-invalidation.test.ts b/src/main/source-control/hosted-review-inflight-invalidation.test.ts new file mode 100644 index 00000000000..6d8653ba76b --- /dev/null +++ b/src/main/source-control/hosted-review-inflight-invalidation.test.ts @@ -0,0 +1,271 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { HostedReviewInfo } from '../../shared/hosted-review' +import { + __resetHostedReviewBranchCacheForTests, + invalidateHostedReviewBranchCache, + withHostedReviewBranchCache +} from './hosted-review-branch-cache' +import { + HOSTED_REVIEW_LOOKUP_DEADLINE_MS, + MAX_UNSETTLED_LOOKUP_KEYS, + MAX_UNSETTLED_LOOKUPS_PER_KEY +} from './hosted-review-refresh-pacing' + +const identity = { + repoPath: '/repo', + executionHostId: 'ssh:host-a' as const, + branch: 'feature/review', + localGitExecOptions: { admissionTier: 'background' } +} +const options = { headOid: null } +const review: HostedReviewInfo = { + provider: 'gitlab', + number: 7, + title: 'Created review', + state: 'open', + url: 'https://git.example/team/repo/merge_requests/7', + status: 'neutral', + updatedAt: '', + mergeable: 'UNKNOWN' +} + +beforeEach(() => { + __resetHostedReviewBranchCacheForTests() + vi.useFakeTimers() + vi.setSystemTime(1_000_000) +}) + +afterEach(() => { + __resetHostedReviewBranchCacheForTests() + vi.useRealTimers() +}) + +describe('hosted review in-flight invalidation', () => { + it.each(['before', 'after'] as const)( + 'refreshes post-create readers when the old request finishes %s its replacement', + async (completionOrder) => { + const oldResponse = Promise.withResolvers() + const freshResponse = Promise.withResolvers() + const oldLookup = vi.fn(() => oldResponse.promise) + const freshLookup = vi.fn(() => freshResponse.promise) + const admitted = withHostedReviewBranchCache(identity, options, oldLookup) + expect( + await withHostedReviewBranchCache( + { ...identity, localGitExecOptions: { admissionTier: 'interactive' } }, + options, + async () => null + ) + ).toBeNull() + + invalidateHostedReviewBranchCache(identity.repoPath, identity.executionHostId) + const next = withHostedReviewBranchCache(identity, options, freshLookup) + const concurrent = withHostedReviewBranchCache(identity, options, freshLookup) + try { + expect(freshLookup).toHaveBeenCalledTimes(1) + if (completionOrder === 'before') { + oldResponse.resolve(null) + expect(await admitted).toBeNull() + } + freshResponse.resolve(review) + expect(await next).toEqual(review) + expect(await concurrent).toEqual(review) + if (completionOrder === 'after') { + oldResponse.resolve(null) + expect(await admitted).toBeNull() + } + expect(await withHostedReviewBranchCache(identity, options, freshLookup)).toEqual(review) + expect(oldLookup).toHaveBeenCalledTimes(1) + expect(freshLookup).toHaveBeenCalledTimes(1) + } finally { + oldResponse.resolve(null) + freshResponse.resolve(review) + await Promise.all([admitted, next, concurrent]) + } + } + ) + + it('does not make a post-create reader wait for a stale request deadline', async () => { + const oldResponse = Promise.withResolvers() + const admitted = withHostedReviewBranchCache(identity, options, () => oldResponse.promise) + const admittedResult = admitted.catch(() => null) + await vi.advanceTimersByTimeAsync(10_000) + invalidateHostedReviewBranchCache(identity.repoPath, identity.executionHostId) + const freshLookup = vi.fn(async () => review) + let settled = false + const next = withHostedReviewBranchCache(identity, options, freshLookup).then((value) => { + settled = true + return value + }) + try { + await vi.advanceTimersByTimeAsync(0) + expect(settled).toBe(true) + expect(await next).toEqual(review) + expect(freshLookup).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(HOSTED_REVIEW_LOOKUP_DEADLINE_MS - 10_000) + expect(await admittedResult).toEqual(review) + expect(await withHostedReviewBranchCache(identity, options, freshLookup)).toEqual(review) + expect(freshLookup).toHaveBeenCalledTimes(2) + } finally { + oldResponse.resolve(null) + await Promise.all([admittedResult, next]) + } + }) + + it('discards a retired answer that landed with no replacement to outrank it', async () => { + const oldResponse = Promise.withResolvers() + const admitted = withHostedReviewBranchCache(identity, options, () => oldResponse.promise) + invalidateHostedReviewBranchCache(identity.repoPath, identity.executionHostId) + // Nothing replaced it, so only the scope generation stands between this + // pre-invalidation "no review" and the key it no longer owns. Adopting it + // would read as fresh and short-circuit the lookup for the review Orca just + // opened. + oldResponse.resolve(null) + expect(await admitted).toBeNull() + const freshLookup = vi.fn(async () => review) + expect(await withHostedReviewBranchCache(identity, options, freshLookup)).toEqual(review) + expect(freshLookup).toHaveBeenCalledTimes(1) + }) + + it('keeps other hosts, paths and their pending promises isolated', async () => { + const others = [ + { ...identity, executionHostId: 'local' as const }, + { ...identity, executionHostId: 'ssh:host-b' as const }, + { ...identity, repoPath: '/repo-other' } + ].map((key) => { + const response = Promise.withResolvers() + const lookup = vi.fn(() => response.promise) + return { key, response, lookup, admitted: withHostedReviewBranchCache(key, options, lookup) } + }) + invalidateHostedReviewBranchCache(identity.repoPath, identity.executionHostId) + const readers = others.map(({ key, lookup }) => + withHostedReviewBranchCache(key, options, lookup) + ) + for (const other of others) { + expect(other.lookup).toHaveBeenCalledTimes(1) + other.response.resolve(review) + } + expect(await Promise.all(readers)).toEqual([review, review, review]) + await Promise.all(others.map((other) => other.admitted)) + }) + + it('sweeps invalidated readers after sleep without expiring their replacement', async () => { + const oldResponse = Promise.withResolvers() + const freshResponse = Promise.withResolvers() + const admitted = withHostedReviewBranchCache(identity, options, () => oldResponse.promise) + const admittedResult = admitted.catch((error: unknown) => error) + invalidateHostedReviewBranchCache(identity.repoPath, identity.executionHostId) + vi.setSystemTime(Date.now() + 90_000) + const freshLookup = vi.fn(() => freshResponse.promise) + const replacement = withHostedReviewBranchCache(identity, options, freshLookup).catch( + (error: unknown) => error + ) + vi.setSystemTime(Date.now() + 40_000) + const joined = withHostedReviewBranchCache(identity, options, freshLookup).catch( + (error: unknown) => error + ) + try { + await vi.advanceTimersByTimeAsync(0) + expect(await admittedResult).toMatchObject({ + message: expect.stringContaining('timed out') + }) + expect(freshLookup).toHaveBeenCalledTimes(1) + freshResponse.resolve(review) + expect(await replacement).toEqual(review) + expect(await joined).toEqual(review) + oldResponse.resolve(null) + expect(await withHostedReviewBranchCache(identity, options, freshLookup)).toEqual(review) + expect(freshLookup).toHaveBeenCalledTimes(1) + } finally { + oldResponse.resolve(null) + freshResponse.resolve(review) + await Promise.all([admittedResult, replacement, joined]) + } + }) + + it('keeps the two-unsettled-request cap across repeated invalidation', async () => { + const first = Promise.withResolvers() + const second = Promise.withResolvers() + const firstRead = withHostedReviewBranchCache(identity, options, () => first.promise) + invalidateHostedReviewBranchCache(identity.repoPath, identity.executionHostId) + const secondRead = withHostedReviewBranchCache(identity, options, () => second.promise) + invalidateHostedReviewBranchCache(identity.repoPath, identity.executionHostId) + const thirdLookup = vi.fn(async () => review) + const third = withHostedReviewBranchCache(identity, options, thirdLookup) + const thirdResult = third.catch((error: unknown) => error) + try { + await vi.advanceTimersByTimeAsync(0) + expect(thirdLookup).not.toHaveBeenCalled() + first.resolve(null) + second.resolve(null) + await Promise.all([firstRead, secondRead]) + expect(await thirdResult).toMatchObject({ + message: expect.stringContaining('never answered') + }) + expect(await withHostedReviewBranchCache(identity, options, thirdLookup)).toEqual(review) + expect(thirdLookup).toHaveBeenCalledTimes(1) + } finally { + first.resolve(null) + second.resolve(null) + await Promise.all([firstRead, secondRead, thirdResult]) + } + }) + + it('does not let an invalidated rejection penalize the replacement', async () => { + const oldResponse = Promise.withResolvers() + const freshResponse = Promise.withResolvers() + const admitted = withHostedReviewBranchCache(identity, options, () => oldResponse.promise) + const rejected = expect(admitted).rejects.toThrow('old lookup failed') + invalidateHostedReviewBranchCache(identity.repoPath, identity.executionHostId) + const fresh = withHostedReviewBranchCache(identity, options, () => freshResponse.promise) + oldResponse.reject(new Error('old lookup failed')) + await rejected + freshResponse.resolve(review) + expect(await fresh).toEqual(review) + await vi.advanceTimersByTimeAsync(60_001) + const next = vi.fn(async () => review) + expect(await withHostedReviewBranchCache(identity, options, next)).toEqual(review) + expect(next).toHaveBeenCalledTimes(1) + }) + + it('bounds retired owners by admission and sweeps all of them after sleep', async () => { + const requests: ReturnType>[] = [] + const readers: Promise[] = [] + for (let index = 0; index < MAX_UNSETTLED_LOOKUP_KEYS; index += 1) { + for (let attempt = 0; attempt < MAX_UNSETTLED_LOOKUPS_PER_KEY; attempt += 1) { + const response = Promise.withResolvers() + requests.push(response) + readers.push( + withHostedReviewBranchCache( + { ...identity, branch: `branch-${index}` }, + options, + () => response.promise + ).catch((error: unknown) => error) + ) + invalidateHostedReviewBranchCache(identity.repoPath, identity.executionHostId) + } + } + const freshLookup = vi.fn(async () => review) + try { + await expect(withHostedReviewBranchCache(identity, options, freshLookup)).rejects.toThrow( + 'Too many hosted review lookups are already in progress' + ) + expect(freshLookup).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(MAX_UNSETTLED_LOOKUP_KEYS * MAX_UNSETTLED_LOOKUPS_PER_KEY) + vi.setSystemTime(Date.now() + HOSTED_REVIEW_LOOKUP_DEADLINE_MS) + await expect(withHostedReviewBranchCache(identity, options, freshLookup)).rejects.toThrow() + expect(vi.getTimerCount()).toBe(0) + expect(await Promise.all(readers)).toEqual( + requests.map(() => + expect.objectContaining({ message: expect.stringContaining('timed out') }) + ) + ) + } finally { + for (const response of requests) { + response.resolve(null) + } + await Promise.all(readers) + } + expect(await withHostedReviewBranchCache(identity, options, freshLookup)).toEqual(review) + expect(freshLookup).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/source-control/hosted-review-inflight-lookups.test.ts b/src/main/source-control/hosted-review-inflight-lookups.test.ts new file mode 100644 index 00000000000..149de43c097 --- /dev/null +++ b/src/main/source-control/hosted-review-inflight-lookups.test.ts @@ -0,0 +1,44 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { + __resetHostedReviewInflightLookupsForTests, + expireOverdueInflight, + getInflightLookup, + releaseInflight, + retireInflightWithPrefix, + trackInflight +} from './hosted-review-inflight-lookups' +import { HOSTED_REVIEW_LOOKUP_DEADLINE_MS } from './hosted-review-refresh-pacing' + +afterEach(__resetHostedReviewInflightLookupsForTests) + +it.each(['completion', 'deadline'])( + 'releases a retired owner on %s while preserving its successor', + (outcome) => { + const oldToken = {} + const oldExpire = vi.fn(() => releaseInflight('repo\0branch', oldToken)) + trackInflight('repo\0branch', { + token: oldToken, + startedAt: 0, + promise: Promise.resolve(null), + expire: oldExpire + }) + retireInflightWithPrefix('repo\0') + const replacement = { + token: {}, + startedAt: HOSTED_REVIEW_LOOKUP_DEADLINE_MS, + promise: Promise.resolve(null), + expire: vi.fn() + } + trackInflight('repo\0branch', replacement) + if (outcome === 'completion') { + expect(releaseInflight('repo\0branch', oldToken)).toBe(false) + } + expireOverdueInflight(HOSTED_REVIEW_LOOKUP_DEADLINE_MS) + expect(oldExpire).toHaveBeenCalledTimes(outcome === 'deadline' ? 1 : 0) + expect(replacement.expire).not.toHaveBeenCalled() + expect(getInflightLookup('repo\0branch')).toBe(replacement) + expireOverdueInflight(HOSTED_REVIEW_LOOKUP_DEADLINE_MS + 1) + expect(oldExpire).toHaveBeenCalledTimes(outcome === 'deadline' ? 1 : 0) + expect(releaseInflight('repo\0branch', replacement.token)).toBe(true) + } +) diff --git a/src/main/source-control/hosted-review-inflight-lookups.ts b/src/main/source-control/hosted-review-inflight-lookups.ts new file mode 100644 index 00000000000..79d4f12ba13 --- /dev/null +++ b/src/main/source-control/hosted-review-inflight-lookups.ts @@ -0,0 +1,101 @@ +import type { HostedReviewInfo } from '../../shared/hosted-review' +import { + HOSTED_REVIEW_LOOKUP_DEADLINE_MS, + MAX_INFLIGHT_LOOKUPS +} from './hosted-review-refresh-pacing' + +declare const inflightTokenBrand: unique symbol + +/** Identity token for one lookup; only ever compared by reference. */ +export type InflightToken = { readonly [inflightTokenBrand]?: never } + +export type InflightRecord = { + /** Identity, so a detached lookup can only ever clear its own entry. */ + token: InflightToken + startedAt: number + promise: Promise + /** Releases the callers and unpins the branch; idempotent. */ + expire: () => void +} + +const inflight = new Map() +/** + * Owners an invalidation took off their key. They keep running — nothing here + * can cancel a lookup — but no new reader may join them, which is what stops a + * post-invalidation read from waiting out a stale request's deadline. + * + * Admission bounds these to two per key across at most 1,000 unsettled keys, so + * this map cannot outgrow the lookups already counted as in progress. + */ +const retired = new Map() + +export function getInflightLookup(key: string): InflightRecord | undefined { + return inflight.get(key) +} + +/** Clears only this owner's records; the return value identifies the current owner. */ +export function releaseInflight(key: string, token: InflightToken): boolean { + retired.delete(token) + if (inflight.get(key)?.token !== token) { + return false + } + inflight.delete(key) + return true +} + +/** Takes every owner under `prefix` off its key, without failing it. */ +export function retireInflightWithPrefix(prefix: string): void { + for (const [key, record] of inflight) { + if (key.startsWith(prefix)) { + retired.set(record.token, record) + inflight.delete(key) + } + } +} + +/** + * Expires records that outlived the deadline without their timer firing. Main's + * timers are suspended across a system sleep, so wall-clock age — not + * `setTimeout` alone — is what actually bounds how long a branch stays pinned. + * Retired owners are swept too: their readers are gone, but their own callers + * still need releasing. + * + * The guarantee covers tracked records only: one the size cap evicted is in + * neither map and falls back to its own suspended timer. + */ +export function expireOverdueInflight(now: number): void { + let overdue: InflightRecord[] | undefined + for (const records of [inflight, retired]) { + for (const record of records.values()) { + if (now - record.startedAt >= HOSTED_REVIEW_LOOKUP_DEADLINE_MS) { + overdue ??= [] + overdue.push(record) + } + } + } + for (const record of overdue ?? []) { + record.expire() + } +} + +export function trackInflight(key: string, record: InflightRecord): void { + inflight.set(key, record) + while (inflight.size > MAX_INFLIGHT_LOOKUPS) { + const oldest = inflight.keys().next().value + if (oldest === undefined) { + break + } + // Why: drop the record without expiring it — its own deadline still releases + // its callers, and evicting is about memory, not about failing. It does + // forfeit the sweep above, so the cap must stay far above realistic + // concurrency: below it, sleep-suspended timers are all an evicted record's + // callers have left. + inflight.delete(oldest) + } +} + +/** @internal - exposed for tests only */ +export function __resetHostedReviewInflightLookupsForTests(): void { + inflight.clear() + retired.clear() +} diff --git a/src/main/speech/stt-session-start.ts b/src/main/speech/stt-session-start.ts index 5377699b382..eb42418cd0b 100644 --- a/src/main/speech/stt-session-start.ts +++ b/src/main/speech/stt-session-start.ts @@ -138,7 +138,11 @@ async function startSttSession( isCurrent: () => state.worker === worker, onMessage: (event) => state.eventSink?.(event), onError: (error) => handleSttWorkerFailure(state, error), - onExit: () => handleSttWorkerFailure(state) + onExit: () => { + const stoppedSink = state.stopInFlight?.worker === worker ? null : state.eventSink + handleSttWorkerFailure(state) + stoppedSink?.({ type: 'stopped' }) + } }) initializeSttWorker(worker, { modelDir: state.modelManager.getModelDir(modelId), diff --git a/src/main/sqlite/bun-readonly-wal.test.ts b/src/main/sqlite/bun-readonly-wal.test.ts new file mode 100644 index 00000000000..f60482c8e0e --- /dev/null +++ b/src/main/sqlite/bun-readonly-wal.test.ts @@ -0,0 +1,180 @@ +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { initializeBunReadonlyWal } from './bun-readonly-wal' +import Database from './sync-database' + +vi.mock('node:fs', async (importOriginal) => { + const original = await importOriginal() + return { ...original, openSync: vi.fn(original.openSync) } +}) + +const directories: string[] = [] + +afterEach(async () => { + vi.restoreAllMocks() + const original = await vi.importActual('node:fs') + vi.mocked(fs.openSync).mockImplementation(original.openSync) + for (const directory of directories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture(wal = true): string { + const directory = fs.mkdtempSync(join(tmpdir(), 'orca-readonly-wal-')) + directories.push(directory) + const file = join(directory, 'state.db') + const db = new Database(file) + db.exec('CREATE TABLE state (id INTEGER PRIMARY KEY, value TEXT)') + if (wal) { + db.pragma('journal_mode=WAL') + } + db.close() + return file +} + +describe('readonly WAL initialization', () => { + it.each(['EACCES', 'EPERM', 'EROFS'])( + 'lets SQLite decide read access when WAL creation fails with %s', + async (code) => { + const file = fixture() + const { openSync: open } = await vi.importActual('node:fs') + vi.mocked(fs.openSync).mockImplementation((path, flags, ...rest) => { + if (path === `${file}-wal` && flags === 'wx') { + throw Object.assign(new Error('read-only directory'), { code }) + } + return open(path, flags, ...rest) + }) + expect(() => initializeBunReadonlyWal(file)).not.toThrow() + expect(fs.existsSync(`${file}-wal`)).toBe(false) + } + ) + + it('still reports unexpected WAL creation failures', async () => { + const file = fixture() + const { openSync: open } = await vi.importActual('node:fs') + vi.mocked(fs.openSync).mockImplementation((path, flags, ...rest) => { + if (path === `${file}-wal` && flags === 'wx') { + throw Object.assign(new Error('disk failure'), { code: 'EIO' }) + } + return open(path, flags, ...rest) + }) + expect(() => initializeBunReadonlyWal(file)).toThrow('disk failure') + }) + + it('allows a clean WAL database to reopen without changing its bytes or admitting SQL writes', () => { + const file = fixture() + const bytes = fs.readFileSync(file) + initializeBunReadonlyWal(file) + if (process.platform !== 'win32') { + expect(fs.statSync(`${file}-wal`).mode & 0o777).toBe(0o600) + } + const reader = new Database(file, { readonly: true }) + try { + expect(reader.prepare('SELECT COUNT(*) AS count FROM state').get()).toEqual({ count: 0 }) + expect(() => reader.exec("INSERT INTO state VALUES(1,'write')")).toThrow() + } finally { + reader.close() + } + expect(fs.readFileSync(file)).toEqual(bytes) + }) + + it('leaves a concurrent writer’s newly published WAL intact', async () => { + const file = fixture() + const { openSync: open } = await vi.importActual('node:fs') + vi.mocked(fs.openSync).mockImplementation((path, flags, ...rest) => { + if (path === `${file}-wal` && flags === 'wx') { + fs.writeFileSync(path, 'concurrent writer evidence') + } + return open(path, flags, ...rest) + }) + initializeBunReadonlyWal(file) + expect(fs.readFileSync(`${file}-wal`, 'utf8')).toBe('concurrent writer evidence') + }) + + it('leaves a current writer’s WAL bytes intact', () => { + const file = fixture() + const writer = new Database(file) + try { + writer.exec("INSERT INTO state VALUES(1,'durable')") + const before = fs.readFileSync(`${file}-wal`) + initializeBunReadonlyWal(file) + expect(fs.readFileSync(`${file}-wal`)).toEqual(before) + const reader = new Database(file, { readonly: true }) + try { + expect(reader.prepare('SELECT value FROM state').get()).toEqual({ value: 'durable' }) + } finally { + reader.close() + } + } finally { + writer.close() + } + }) + + it.skipIf(process.platform === 'win32')('opens a cold WAL database through a symlink', () => { + const file = fixture() + const alias = join(directories[0]!, 'alias.db') + fs.symlinkSync(file, alias) + const reader = new Database(alias, { readonly: true }) + try { + expect(reader.prepare('SELECT COUNT(*) AS count FROM state').get()).toEqual({ count: 0 }) + expect(fs.existsSync(`${alias}-wal`)).toBe(false) + } finally { + reader.close() + } + }) + + it('does not add recovery evidence to a rollback-journal database or malformed file', () => { + const file = fixture(false) + initializeBunReadonlyWal(file) + expect(fs.existsSync(`${file}-wal`)).toBe(false) + fs.writeFileSync(file, 'not SQLite') + initializeBunReadonlyWal(file) + expect(fs.existsSync(`${file}-wal`)).toBe(false) + }) + + it.skipIf(process.platform !== 'darwin' || !process.versions.bun)( + 'preserves another connection’s exclusive lock while inspecting the database header', + async () => { + const file = fixture(false) + const writer = new Database(file) + const probe = () => + runProcess({ + program: process.execPath, + args: [ + '-e', + `const { Database } = require('bun:sqlite') + const db = new Database(process.argv[1], { readonly: true }) + try { db.prepare('SELECT * FROM state').all(); process.stdout.write('readable') } + catch (error) { process.stdout.write(error.code) } + finally { db.close(true) }`, + file + ], + timeoutMs: 5_000 + }) + try { + writer.exec("BEGIN EXCLUSIVE; INSERT INTO state VALUES(1,'uncommitted')") + expect(await probe()).toMatchObject({ code: 0, stdout: 'SQLITE_BUSY' }) + const reader = new Database(file, { readonly: true }) + reader.close() + expect(await probe()).toMatchObject({ code: 0, stdout: 'SQLITE_BUSY' }) + } finally { + writer.close() + } + } + ) + + it.skipIf(process.platform === 'win32')( + 'does not follow an existing dangling WAL symlink', + () => { + const file = fixture() + const outside = join(directories[0]!, 'unrelated') + fs.symlinkSync(outside, `${file}-wal`) + initializeBunReadonlyWal(file) + expect(fs.existsSync(outside)).toBe(false) + expect(fs.lstatSync(`${file}-wal`).isSymbolicLink()).toBe(true) + } + ) +}) diff --git a/src/main/sqlite/bun-readonly-wal.ts b/src/main/sqlite/bun-readonly-wal.ts new file mode 100644 index 00000000000..b7cbd39880a --- /dev/null +++ b/src/main/sqlite/bun-readonly-wal.ts @@ -0,0 +1,37 @@ +import { closeSync, existsSync, openSync, readSync } from 'node:fs' + +/** Match SQLite's WAL creation without permitting writes through the database connection. */ +export function initializeBunReadonlyWal(path: string): void { + const wal = `${path}-wal` + if (existsSync(wal) || !hasWalHeader(path)) { + return + } + try { + // Apple's SQLite requires an existing WAL; exclusive creation preserves every existing byte. + closeSync(openSync(wal, 'wx', 0o600)) + } catch (error) { + if ( + typeof error !== 'object' || + error === null || + !('code' in error) || + !['EEXIST', 'EACCES', 'EPERM', 'EROFS'].includes(String(error.code)) + ) { + throw error + } + } +} + +function hasWalHeader(path: string): boolean { + const file = openSync(path, 'r') + try { + const header = Buffer.alloc(20) + return ( + readSync(file, header, 0, header.length, 0) === header.length && + header.subarray(0, 16).toString() === 'SQLite format 3\0' && + header[18] === 2 && + header[19] === 2 + ) + } finally { + closeSync(file) + } +} diff --git a/src/main/sqlite/bun-sqlite-database.ts b/src/main/sqlite/bun-sqlite-database.ts new file mode 100644 index 00000000000..b9695e08781 --- /dev/null +++ b/src/main/sqlite/bun-sqlite-database.ts @@ -0,0 +1,129 @@ +import type { DatabaseSync } from 'node:sqlite' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { BunSqliteStatement, type BunStatement } from './bun-sqlite-statement' +import { initializeBunReadonlyWal } from './bun-readonly-wal' + +type BunDatabase = { + exec(sql: string): void + prepare(sql: string): BunStatement + readonly inTransaction: boolean + close(throwOnError: boolean): void + fileControl(command: number, value: Int32Array): number +} + +type BunSqlite = { + Database: new (path: string, flags: number) => BunDatabase +} + +const SQLITE_OPEN_READONLY = 0x01 +const SQLITE_OPEN_READWRITE = 0x02 +const SQLITE_OPEN_CREATE = 0x04 +const SQLITE_OPEN_URI = 0x40 +const SQLITE_FCNTL_PERSIST_WAL = 10 + +export function loadBunSqlite(): BunSqlite | undefined { + if (!process.versions.bun || typeof process.getBuiltinModule !== 'function') { + return undefined + } + const sqlite: unknown = process.getBuiltinModule('bun:sqlite') + return isBunSqlite(sqlite) ? sqlite : undefined +} + +function isBunSqlite(value: unknown): value is BunSqlite { + return ( + typeof value === 'object' && + value !== null && + 'Database' in value && + typeof value.Database === 'function' + ) +} + +export class BunSqliteDatabase { + private readonly database: BunDatabase + + constructor( + path: ConstructorParameters[0], + options: { readonly?: boolean; fileMustExist?: boolean; timeout?: number } = {} + ) { + const sqlite = loadBunSqlite() + if (!sqlite) { + throw new Error('SQLite is unavailable in this runtime') + } + const filename = + path instanceof URL ? fileURLToPath(path) : typeof path === 'string' ? path : path.toString() + const flags = options.readonly + ? SQLITE_OPEN_READONLY + : SQLITE_OPEN_READWRITE | (options.fileMustExist ? 0 : SQLITE_OPEN_CREATE) + this.database = new sqlite.Database( + filename === ':memory:' || filename === '' ? filename : sqliteFileUri(filename), + // URI parsing defaults differ between the platform SQLite libraries. + flags | SQLITE_OPEN_URI + ) + try { + if (process.platform === 'darwin' && filename !== ':memory:' && filename !== '') { + // Apple's default retains WAL files; match the other shipped SQLite drivers. + if (this.database.fileControl(SQLITE_FCNTL_PERSIST_WAL, new Int32Array(2)) !== 0) { + throw new Error('SQLite cannot configure WAL cleanup') + } + if (options.readonly) { + const statement = this.database.prepare('PRAGMA database_list') + try { + const path = statement.get()?.file + if (typeof path !== 'string' || path.length === 0) { + throw new Error('SQLite did not report its database filename') + } + // SQLite resolves symlinks before locating its sidecars. + initializeBunReadonlyWal(path) + } finally { + statement.finalize() + } + } + } + const timeout = options.timeout ?? 0 + if (!Number.isSafeInteger(timeout) || timeout < 0 || timeout > 2_147_483_647) { + throw new RangeError('SQLite busy timeout must be a nonnegative 32-bit integer') + } + this.database.exec(`PRAGMA foreign_keys = ON; PRAGMA busy_timeout = ${timeout}`) + } catch (error) { + this.database.close(true) + throw error + } + } + + exec(sql: string): void { + this.database.exec(sql) + } + + prepare(sql: string): BunSqliteStatement { + return new BunSqliteStatement(this.database.prepare(sql), () => this.database.prepare(sql)) + } + + get isTransaction(): boolean { + return this.database.inTransaction + } + + /** Logical snapshot; implicit rowids may change. The caller runs this in its backup worker. */ + backup(path: string): void { + const statement = this.database.prepare('VACUUM INTO ?') + try { + statement.run(sqliteFileUri(path)) + } finally { + statement.finalize() + } + } + + close(): void { + this.database.close(true) + } +} + +function sqliteFileUri(path: string): string { + const url = pathToFileURL(path) + if (url.hostname) { + const hostname = url.hostname + url.hostname = '' + // SQLite accepts UNC paths with an empty URI authority on Windows. + url.pathname = `//${hostname}${url.pathname}` + } + return url.href +} diff --git a/src/main/sqlite/bun-sqlite-statement.ts b/src/main/sqlite/bun-sqlite-statement.ts new file mode 100644 index 00000000000..01b8081721a --- /dev/null +++ b/src/main/sqlite/bun-sqlite-statement.ts @@ -0,0 +1,77 @@ +import type { SQLInputValue, StatementResultingChanges } from 'node:sqlite' +import { SqliteIntegerReader } from './sqlite-integer-reader' +import type { SqliteBindings, SqliteRow, SqliteStatement } from './sqlite-statement' + +type BunBindings = (SQLInputValue | SQLInputValue[])[] +const EMPTY_BINDINGS: BunBindings = [[]] + +export type BunStatement = { + all(...parameters: BunBindings): SqliteRow[] + get(...parameters: BunBindings): SqliteRow | null + run(...parameters: BunBindings): StatementResultingChanges + iterate(...parameters: BunBindings): IterableIterator + finalize(): void + safeIntegers(enabled: boolean): void + readonly paramsCount: number +} + +export class BunSqliteStatement implements SqliteStatement { + private readonly integers = new SqliteIntegerReader() + private readonly parameterCount: number + + constructor( + private readonly statement: BunStatement, + private readonly prepareIterator: () => BunStatement + ) { + statement.safeIntegers(true) + this.parameterCount = statement.paramsCount + } + + all(...parameters: SqliteBindings): SqliteRow[] { + const rows = this.statement.all(...this.bindings(parameters)) + for (const row of rows) { + this.integers.row(row) + } + return rows + } + + get(...parameters: SqliteBindings): SqliteRow | undefined { + const row = this.statement.get(...this.bindings(parameters)) + return row === null ? undefined : this.integers.row(row) + } + + run(...parameters: SqliteBindings): StatementResultingChanges { + return this.integers.result(this.statement.run(...this.bindings(parameters))) + } + + *iterate(...parameters: SqliteBindings): IterableIterator { + // Bun leaves interrupted iterators positioned on their last row and exposes no reset. + const statement = this.prepareIterator() + try { + statement.safeIntegers(true) + for (const row of statement.iterate(...this.bindings(parameters))) { + yield this.integers.row(row) + } + } finally { + statement.finalize() + } + } + + setReadBigInts(enabled: boolean): void { + this.integers.readBigInts = enabled + } + + private bindings(parameters: SqliteBindings): BunBindings { + if (parameters.some((value) => value === undefined)) { + throw new TypeError('Undefined cannot be bound to a SQLite parameter') + } + // No arguments would reuse the driver's previous bindings. + if (parameters.length === 0) { + return EMPTY_BINDINGS + } + if (parameters.length >= this.parameterCount) { + return parameters + } + return [...parameters, ...Array(this.parameterCount - parameters.length).fill(null)] + } +} diff --git a/src/main/sqlite/node-sqlite-statement.ts b/src/main/sqlite/node-sqlite-statement.ts new file mode 100644 index 00000000000..53a83108086 --- /dev/null +++ b/src/main/sqlite/node-sqlite-statement.ts @@ -0,0 +1,39 @@ +import type { StatementResultingChanges } from 'node:sqlite' +import { SqliteIntegerReader } from './sqlite-integer-reader' +import type { SqliteBindings, SqliteRow, SqliteStatement } from './sqlite-statement' + +export class NodeSqliteStatement implements SqliteStatement { + private readonly integers = new SqliteIntegerReader() + + constructor(private readonly statement: SqliteStatement) { + // Native number reads overflow their INT64_MIN guard on some builds and round insert rowids. + statement.setReadBigInts(true) + } + + all(...parameters: SqliteBindings): SqliteRow[] { + const rows = this.statement.all(...parameters) + for (const row of rows) { + this.integers.row(row) + } + return rows + } + + get(...parameters: SqliteBindings): SqliteRow | undefined { + const row = this.statement.get(...parameters) + return row === undefined ? undefined : this.integers.row(row) + } + + run(...parameters: SqliteBindings): StatementResultingChanges { + return this.integers.result(this.statement.run(...parameters)) + } + + *iterate(...parameters: SqliteBindings): IterableIterator { + for (const row of this.statement.iterate(...parameters)) { + yield this.integers.row(row) + } + } + + setReadBigInts(enabled: boolean): void { + this.integers.readBigInts = enabled + } +} diff --git a/src/main/sqlite/sqlite-integer-reader.ts b/src/main/sqlite/sqlite-integer-reader.ts new file mode 100644 index 00000000000..e3a5821bcda --- /dev/null +++ b/src/main/sqlite/sqlite-integer-reader.ts @@ -0,0 +1,40 @@ +import type { StatementResultingChanges } from 'node:sqlite' +import type { SqliteRow } from './sqlite-statement' + +export class SqliteIntegerReader { + readBigInts = false + + row(row: SqliteRow): SqliteRow { + if (!this.readBigInts) { + for (const key of Object.keys(row)) { + const value = row[key] + if (typeof value === 'bigint') { + row[key] = this.integer(value) + } + } + } + return row + } + + result(result: StatementResultingChanges): StatementResultingChanges { + return { + changes: this.integer(result.changes, false), + lastInsertRowid: this.integer(result.lastInsertRowid, false) + } + } + + private integer(value: number | bigint, requireSafeNumber = true): number | bigint { + if (this.readBigInts) { + return BigInt(value) + } + const number = Number(value) + if (!Number.isSafeInteger(number)) { + // Metadata conversion must not report failure after a write has committed. + if (!requireSafeNumber) { + return BigInt(value) + } + throw new RangeError('SQLite integer cannot be represented safely as a JavaScript number') + } + return number + } +} diff --git a/src/main/sqlite/sqlite-read-failure.test.ts b/src/main/sqlite/sqlite-read-failure.test.ts index 8bd43d54eaa..fdb3dc88365 100644 --- a/src/main/sqlite/sqlite-read-failure.test.ts +++ b/src/main/sqlite/sqlite-read-failure.test.ts @@ -10,13 +10,12 @@ import { classifySqliteReadFailure, isTransientSqliteContention } from './sqlite // with no usable -shm reports errcode 14 ("unable to open database file"). The // two need opposite responses, so the classifier must never conflate them. -let tempDirs: string[] = [] +const tempDirs: string[] = [] afterEach(() => { - for (const dir of tempDirs) { + for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }) } - tempDirs = [] }) function contendedDatabase(): { path: string; release: () => void } { @@ -40,18 +39,22 @@ function contendedDatabase(): { path: string; release: () => void } { describe('isTransientSqliteContention', () => { it('recognizes a real SQLITE_BUSY thrown by a read-only open', () => { const contended = contendedDatabase() + let reader: SyncDatabase | undefined let thrown: unknown try { - new SyncDatabase(contended.path, { readonly: true, timeout: 0 }) - .prepare('SELECT id FROM session') - .all() + reader = new SyncDatabase(contended.path, { readonly: true, timeout: 0 }) + reader.prepare('SELECT id FROM session').all() } catch (error) { thrown = error } finally { - contended.release() + try { + reader?.close() + } finally { + contended.release() + } } - expect((thrown as { errcode?: number }).errcode).toBe(5) + expect(thrown).toMatchObject({ [process.versions.bun ? 'errno' : 'errcode']: 5 }) expect(isTransientSqliteContention(thrown)).toBe(true) }) diff --git a/src/main/sqlite/sqlite-read-failure.ts b/src/main/sqlite/sqlite-read-failure.ts index f96176eee0a..6919bf9e9cb 100644 --- a/src/main/sqlite/sqlite-read-failure.ts +++ b/src/main/sqlite/sqlite-read-failure.ts @@ -6,20 +6,39 @@ /** Primary result codes; extended codes pack the primary code in the low byte. */ const SQLITE_BUSY = 5 const SQLITE_LOCKED = 6 +const SQLITE_CORRUPT = 11 const SQLITE_CANTOPEN = 14 +const SQLITE_NOTADB = 26 // Shared with the Codex index-heal pass, which only ever sees a relayed message // string (app-server RPC drops `errcode`), so message matching is not optional. const CONTENTION_MESSAGE = /SQLITE_(?:BUSY|LOCKED)|database (?:is )?(?:busy|locked)/i function primaryErrcode(error: unknown): number | null { - if (!error || typeof error !== 'object' || !('errcode' in error)) { + if (!error || typeof error !== 'object') { return null } - const errcode = (error as { errcode?: unknown }).errcode + const errcode = 'errcode' in error ? error.errcode : 'errno' in error ? error.errno : undefined return typeof errcode === 'number' && Number.isFinite(errcode) ? errcode & 0xff : null } +/** node:sqlite marks its errors `ERR_SQLITE_ERROR`; Bun names its class `SQLiteError`. */ +function isSqliteDriverError(error: unknown): boolean { + return ( + error instanceof Error && + (('code' in error && error.code === 'ERR_SQLITE_ERROR') || error.name === 'SQLiteError') + ) +} + +/** True only when SQLite itself reports the database damaged or not a database at all. */ +export function isSqliteCorruption(error: unknown): boolean { + if (!isSqliteDriverError(error)) { + return false + } + const errcode = primaryErrcode(error) + return errcode === SQLITE_CORRUPT || errcode === SQLITE_NOTADB +} + function errorText(error: unknown): string { return error instanceof Error ? error.message : String(error) } diff --git a/src/main/sqlite/sqlite-statement.ts b/src/main/sqlite/sqlite-statement.ts new file mode 100644 index 00000000000..32d1e183be0 --- /dev/null +++ b/src/main/sqlite/sqlite-statement.ts @@ -0,0 +1,12 @@ +import type { SQLInputValue, SQLOutputValue, StatementResultingChanges } from 'node:sqlite' + +export type SqliteBindings = SQLInputValue[] +export type SqliteRow = Record + +export type SqliteStatement = { + all(...parameters: SqliteBindings): SqliteRow[] + get(...parameters: SqliteBindings): SqliteRow | undefined + run(...parameters: SqliteBindings): StatementResultingChanges + iterate(...parameters: SqliteBindings): IterableIterator + setReadBigInts(enabled: boolean): void +} diff --git a/src/main/sqlite/sync-database-concurrent-backup.test.ts b/src/main/sqlite/sync-database-concurrent-backup.test.ts new file mode 100644 index 00000000000..94b6221e1d6 --- /dev/null +++ b/src/main/sqlite/sync-database-concurrent-backup.test.ts @@ -0,0 +1,86 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Worker } from 'node:worker_threads' +import { expect, it } from 'vitest' +import SyncDatabase from './sync-database' + +const WRITER_SOURCE = ` + const { parentPort, workerData } = require('node:worker_threads') + const Database = process.versions.bun + ? require('bun:sqlite').Database + : require('node:sqlite').DatabaseSync + const db = new Database(workerData.path) + db.exec('PRAGMA busy_timeout=5000; PRAGMA synchronous=FULL') + const count = new Int32Array(workerData.count) + let revision = 0 + function commit() { + revision += 1 + db.exec('BEGIN IMMEDIATE; UPDATE marker SET revision=' + revision + '; COMMIT') + Atomics.store(count, 0, revision) + if (revision === 1) { + parentPort.once('message', commit) + parentPort.postMessage('writing') + return + } + if (revision < 40) setTimeout(commit, 2) + else { db.close(true); parentPort.close() } + } + commit() +` + +it('backs up one complete revision while another thread commits to the WAL', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-sqlite-concurrent-backup-')) + const path = join(directory, 'source.db') + const target = join(directory, 'snapshot.db') + const source = new SyncDatabase(path) + let writer: Worker | undefined + let snapshot: SyncDatabase | undefined + try { + source.exec(` + PRAGMA journal_mode=WAL; + PRAGMA synchronous=FULL; + CREATE TABLE marker(name TEXT PRIMARY KEY, revision INTEGER NOT NULL); + INSERT INTO marker VALUES('first',0),('second',0); + CREATE TABLE payload(id INTEGER PRIMARY KEY, value BLOB NOT NULL); + WITH RECURSIVE rows(id) AS (VALUES(1) UNION ALL SELECT id+1 FROM rows WHERE id<1024) + INSERT INTO payload SELECT id, zeroblob(65536) FROM rows; + `) + const sharedCount = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT) + const count = new Int32Array(sharedCount) + writer = new Worker(WRITER_SOURCE, { eval: true, workerData: { path, count: sharedCount } }) + const firstCommit = new Promise((resolve, reject) => { + writer?.once('message', () => resolve()) + writer?.once('error', reject) + }) + const writerExit = new Promise((resolve, reject) => { + writer?.once('error', reject) + writer?.once('exit', (code) => + code === 0 ? resolve() : reject(new Error(`Writer exited ${code}`)) + ) + }) + // Attach failure handling before awaiting either event. + void writerExit.catch(() => {}) + await firstCommit + const before = Atomics.load(count, 0) + // Keep the writer alive until this thread is ready to start the backup. + writer.postMessage('continue') + await source.backup(target) + const after = Atomics.load(count, 0) + expect(after).toBeGreaterThan(before) + await writerExit + snapshot = new SyncDatabase(target, { readonly: true, fileMustExist: true }) + const rows = snapshot.prepare('SELECT revision FROM marker ORDER BY name').all() + expect(rows).toHaveLength(2) + expect(rows[0]).toEqual(rows[1]) + expect(rows[0]?.revision).toBeGreaterThanOrEqual(before) + expect(rows[0]?.revision).toBeLessThanOrEqual(Atomics.load(count, 0)) + expect(snapshot.prepare('SELECT count(*) AS count FROM payload').get()).toEqual({ count: 1024 }) + expect(snapshot.pragma('integrity_check', { simple: true })).toBe('ok') + } finally { + await writer?.terminate() + snapshot?.close() + source.close() + rmSync(directory, { recursive: true, force: true }) + } +}, 20_000) diff --git a/src/main/sqlite/sync-database-portability.test.ts b/src/main/sqlite/sync-database-portability.test.ts new file mode 100644 index 00000000000..fe8534eaf98 --- /dev/null +++ b/src/main/sqlite/sync-database-portability.test.ts @@ -0,0 +1,278 @@ +import { existsSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import SyncDatabase, { isSqliteAvailable } from './sync-database' + +const directories: string[] = [] +const databases: SyncDatabase[] = [] + +function fixture(): string { + const directory = mkdtempSync(join(tmpdir(), 'orca-sqlite-runtime-')) + directories.push(directory) + return directory +} + +function open(path: string): SyncDatabase { + const db = new SyncDatabase(path) + databases.push(db) + return db +} + +afterEach(() => { + for (const database of databases.splice(0)) { + try { + database.close() + } catch { + // A close-contract test already released this connection. + } + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('SQLite runtime contract', () => { + it('restarts an interrupted cached iterator without skipping its failed row', () => { + const db = open(':memory:') + db.exec('CREATE TABLE rows(value INTEGER); INSERT INTO rows VALUES(1),(2),(3)') + const statement = db.prepare('SELECT value FROM rows ORDER BY value') + for (let attempt = 0; attempt < 3; attempt++) { + expect(() => { + for (const row of statement.iterate()) { + expect(row.value).toBe(1) + throw new Error('invalid row') + } + }).toThrow('invalid row') + } + expect([...statement.iterate()]).toEqual([{ value: 1 }, { value: 2 }, { value: 3 }]) + db.exec('DROP TABLE rows') + }) + + it('admits the actual runtime driver', () => { + expect(isSqliteAvailable()).toBe(true) + }) + + it('enforces foreign keys by default', () => { + const db = open(':memory:') + db.exec( + 'CREATE TABLE parents(id INTEGER PRIMARY KEY); CREATE TABLE children(parent INTEGER REFERENCES parents(id))' + ) + expect(() => db.prepare('INSERT INTO children VALUES(?)').run(1)).toThrow() + expect(db.prepare('SELECT * FROM children').all()).toEqual([]) + db.prepare('INSERT INTO parents VALUES(?)').run(1) + expect(db.prepare('INSERT INTO children VALUES(?)').run(1).changes).toBe(1) + expect(() => db.prepare('DELETE FROM parents WHERE id = ?').run(1)).toThrow() + }) + + it('returns safe integers as numbers without changing other SQLite values', () => { + const db = open(':memory:') + const row = db + .prepare('SELECT ? AS integer, ? AS real, ? AS text, ? AS blob, ? AS empty') + .get(Number.MAX_SAFE_INTEGER, 1.25, 'héllo', Buffer.from([0, 255, 17]), null) + expect(row).toEqual({ + integer: Number.MAX_SAFE_INTEGER, + real: 1.25, + text: 'héllo', + blob: expect.any(Uint8Array), + empty: null + }) + expect(row?.blob).toEqual(new Uint8Array([0, 255, 17])) + }) + + it('preserves 64-bit integers and rejects rounding in every reader by default', () => { + const db = open(':memory:') + const statement = db.prepare('SELECT ? AS integer') + for (const value of [ + -(1n << 63n), + -(1n << 63n) + 1n, + -(1n << 53n), + 1n << 53n, + (1n << 53n) + 1n, + (1n << 63n) - 1n + ]) { + expect(() => statement.get(value)).toThrow(RangeError) + expect(() => statement.all(value)).toThrow(RangeError) + expect(() => [...statement.iterate(value)]).toThrow(RangeError) + statement.setReadBigInts(true) + expect(statement.get(value)).toEqual({ integer: value }) + expect(statement.all(value)).toEqual([{ integer: value }]) + expect([...statement.iterate(value)]).toEqual([{ integer: value }]) + statement.setReadBigInts(false) + } + }) + + it('distinguishes large REAL values from INTEGER values in the same column', () => { + const db = open(':memory:') + db.exec('CREATE TABLE values_by_type(value); INSERT INTO values_by_type VALUES(1)') + const statement = db.prepare('SELECT value FROM values_by_type') + for (const readBigInts of [false, true, false]) { + statement.setReadBigInts(readBigInts) + db.exec('DELETE FROM values_by_type; INSERT INTO values_by_type VALUES(9007199254740991)') + const safe = readBigInts ? 9007199254740991n : Number.MAX_SAFE_INTEGER + expect(statement.get()).toEqual({ value: safe }) + db.exec( + 'DELETE FROM values_by_type; INSERT INTO values_by_type VALUES(CAST(-9223372036854775808 AS REAL))' + ) + const real = { value: Number(-(1n << 63n)) } + expect(statement.get()).toEqual(real) + expect(statement.all()).toEqual([real]) + expect([...statement.iterate()]).toEqual([real]) + db.exec('DELETE FROM values_by_type; INSERT INTO values_by_type VALUES(-9223372036854775808)') + if (readBigInts) { + expect(statement.get()).toEqual({ value: -(1n << 63n) }) + } else { + expect(() => statement.get()).toThrow(RangeError) + expect(() => statement.all()).toThrow(RangeError) + expect(() => [...statement.iterate()]).toThrow(RangeError) + } + } + }) + + it('rejects integers outside SQLite range before changing rows', () => { + const db = open(':memory:') + db.exec('CREATE TABLE items(value INTEGER)') + const insert = db.prepare('INSERT INTO items VALUES(?)') + for (const value of [-(1n << 63n) - 1n, 1n << 63n]) { + expect(() => insert.run(value)).toThrow() + } + expect(db.prepare('SELECT count(*) AS count FROM items').get()).toEqual({ count: 0 }) + }) + + it('clears old bindings and binds omitted positional values as null', () => { + const db = open(':memory:') + const statement = db.prepare('SELECT ? AS first, ? AS second') + expect(statement.get('old', 'secret')).toEqual({ first: 'old', second: 'secret' }) + expect(statement.get('new')).toEqual({ first: 'new', second: null }) + expect(statement.get()).toEqual({ first: null, second: null }) + expect(statement.all()).toEqual([{ first: null, second: null }]) + expect([...statement.iterate()]).toEqual([{ first: null, second: null }]) + expect(db.prepare('SELECT 1 WHERE 0').get()).toBeUndefined() + }) + + it('reports changes and explicit rowids using the requested integer mode', () => { + const db = open(':memory:') + db.exec('CREATE TABLE items(id INTEGER PRIMARY KEY, value TEXT)') + const insert = db.prepare('INSERT INTO items VALUES(?, ?)') + expect(insert.run(3, 'first')).toEqual({ changes: 1, lastInsertRowid: 3 }) + insert.setReadBigInts(true) + expect(insert.run(9007199254740993n, 'large')).toEqual({ + changes: 1n, + lastInsertRowid: 9007199254740993n + }) + }) + + it('preserves large write metadata without reporting a committed write as failed', () => { + const db = open(':memory:') + db.exec('CREATE TABLE items(id INTEGER PRIMARY KEY)') + const rowid = 9007199254740993n + expect(db.prepare('INSERT INTO items VALUES(?)').run(rowid)).toEqual({ + changes: 1, + lastInsertRowid: rowid + }) + expect(db.prepare('UPDATE items SET id=id').run()).toEqual({ + changes: 1, + lastInsertRowid: rowid + }) + const statement = db.prepare('SELECT id FROM items') + statement.setReadBigInts(true) + expect(statement.all()).toEqual([{ id: rowid }]) + }) + + it('rejects explicit undefined bindings before modifying rows', () => { + const db = open(':memory:') + db.exec('CREATE TABLE items(value TEXT)') + const insert = db.prepare('INSERT INTO items VALUES(?)') + // @ts-expect-error Exercise invalid input from untyped callers. + expect(() => insert.run(undefined)).toThrow() + expect(db.prepare('SELECT count(*) AS count FROM items').get()).toEqual({ count: 0 }) + const select = db.prepare('SELECT ? AS value') + // @ts-expect-error Exercise invalid input from untyped callers. + expect(() => select.get(undefined)).toThrow() + // @ts-expect-error Exercise invalid input from untyped callers. + expect(() => select.all(undefined)).toThrow() + // @ts-expect-error Exercise invalid input from untyped callers. + expect(() => [...select.iterate(undefined)]).toThrow() + }) + + it('releases statements and an unfinished iterator before filesystem retirement', () => { + const path = join(fixture(), 'database.db') + const db = open(path) + db.exec('CREATE TABLE items(id INTEGER PRIMARY KEY); INSERT INTO items VALUES(1),(2)') + db.exec("CREATE VIRTUAL TABLE search USING fts5(content); INSERT INTO search VALUES('needle')") + const statement = db.prepare('SELECT id FROM items ORDER BY id') + const search = db.prepare("SELECT content FROM search WHERE search MATCH 'needle'") + expect(search.get()).toEqual({ content: 'needle' }) + const iterator = statement.iterate() + expect(iterator.next().value).toEqual({ id: 1 }) + db.close() + expect(() => statement.get()).toThrow() + expect(() => search.get()).toThrow() + renameSync(path, `${path}.retired`) + const reopened = open(`${path}.retired`) + reopened.exec('DROP TABLE items') + }) + + it('opens literal filenames, URL paths and Buffer paths without treating them as data', () => { + const path = join(fixture(), "profile % # ' é.db") + const writer = open(path) + writer.exec('CREATE TABLE items(id INTEGER PRIMARY KEY); INSERT INTO items VALUES(9)') + for (const input of [pathToFileURL(path), Buffer.from(path)]) { + const reader = new SyncDatabase(input, { readonly: true, fileMustExist: true }) + databases.push(reader) + expect(reader.prepare('SELECT id FROM items').get()).toEqual({ id: 9 }) + } + expect(existsSync(path)).toBe(true) + }) + + it('refuses missing databases and changes through read-only connections', () => { + const path = join(fixture(), 'database.db') + for (const input of [path, pathToFileURL(path), Buffer.from(path)]) { + expect(() => new SyncDatabase(input, { fileMustExist: true })).toThrow() + expect(() => new SyncDatabase(input, { readonly: true, fileMustExist: true })).toThrow() + expect(existsSync(path)).toBe(false) + } + const writer = open(path) + writer.exec('CREATE TABLE items(id INTEGER PRIMARY KEY)') + const reader = new SyncDatabase(path, { readonly: true, timeout: 4321 }) + databases.push(reader) + expect(reader.pragma('busy_timeout', { simple: true })).toBe(4321) + expect(() => reader.exec('INSERT INTO items VALUES(1)')).toThrow() + expect(writer.pragma('busy_timeout', { simple: true })).toBe(0) + }) + + it('copies committed WAL data through a read-only source into privately precreated output', async () => { + const directory = fixture() + const path = join(directory, 'database.db') + const target = join(directory, "snapshot % # '.db") + const writer = open(path) + writer.exec( + 'PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; CREATE TABLE items(id INTEGER PRIMARY KEY, value TEXT)' + ) + writer.prepare('INSERT INTO items VALUES(?, ?)').run(19, 'committed in WAL') + expect(existsSync(`${path}-wal`)).toBe(true) + const source = new SyncDatabase(path, { readonly: true, fileMustExist: true }) + databases.push(source) + writeFileSync(target, '', { flag: 'wx', mode: 0o600 }) + await source.backup(target) + const snapshot = open(target) + expect(snapshot.prepare('SELECT id, value FROM items').get()).toEqual({ + id: 19, + value: 'committed in WAL' + }) + expect(snapshot.pragma('integrity_check', { simple: true })).toBe('ok') + }) + + it('refuses an active transaction before creating any backup destination', async () => { + const directory = fixture() + const db = open(join(directory, 'database.db')) + db.exec( + 'CREATE TABLE items(id INTEGER PRIMARY KEY); BEGIN IMMEDIATE; INSERT INTO items VALUES(1)' + ) + const target = join(directory, 'backup.db') + await expect(db.backup(target)).rejects.toThrow(/idle/) + expect(existsSync(target)).toBe(false) + db.exec('ROLLBACK') + }) +}) diff --git a/src/main/sqlite/sync-database.test.ts b/src/main/sqlite/sync-database.test.ts index 39cb443aeeb..4c937197a43 100644 --- a/src/main/sqlite/sync-database.test.ts +++ b/src/main/sqlite/sync-database.test.ts @@ -213,7 +213,7 @@ describe('SyncDatabase read-only opens under contention', () => { thrown = error } - expect((thrown as { errcode?: number }).errcode).toBe(5) + expect(thrown).toMatchObject({ [process.versions.bun ? 'errno' : 'errcode']: 5 }) expect((thrown as Error).message).toContain('database is locked') expect(Date.now() - startedAt).toBeLessThan(200) }) diff --git a/src/main/sqlite/sync-database.ts b/src/main/sqlite/sync-database.ts index 0bfa79e43f5..5f27b52329e 100644 --- a/src/main/sqlite/sync-database.ts +++ b/src/main/sqlite/sync-database.ts @@ -1,5 +1,8 @@ import { existsSync } from 'node:fs' -import type { DatabaseSync, StatementSync, SQLInputValue } from 'node:sqlite' +import type { backup, BackupOptions, DatabaseSync, SQLInputValue } from 'node:sqlite' +import { BunSqliteDatabase, loadBunSqlite } from './bun-sqlite-database' +import { NodeSqliteStatement } from './node-sqlite-statement' +import type { SqliteStatement } from './sqlite-statement' type SqlitePath = ConstructorParameters[0] @@ -13,7 +16,7 @@ type PragmaOptions = { simple?: boolean } -export type SqliteStatement = StatementSync +export type { SqliteStatement } from './sqlite-statement' // Why: dynamic `IN (?,?,…)` clauses mint a new SQL string per arity, so the cache must stay bounded. const STATEMENT_CACHE_LIMIT = 256 @@ -32,28 +35,60 @@ function loadDatabaseSync(): typeof DatabaseSync { if (typeof process.getBuiltinModule !== 'function') { throw new Error('node:sqlite is unavailable in this Node.js runtime') } - return (process.getBuiltinModule('node:sqlite') as { DatabaseSync: typeof DatabaseSync }) - .DatabaseSync + const sqlite: unknown = process.getBuiltinModule('node:sqlite') + if (!hasDatabaseSync(sqlite)) { + throw new Error('node:sqlite is unavailable in this Node.js runtime') + } + return sqlite.DatabaseSync +} + +function hasDatabaseSync(value: unknown): value is { DatabaseSync: typeof DatabaseSync } { + return ( + typeof value === 'object' && + value !== null && + 'DatabaseSync' in value && + typeof value.DatabaseSync === 'function' + ) +} + +function hasBackup(value: unknown): value is { backup: typeof backup } { + return ( + typeof value === 'object' && + value !== null && + 'backup' in value && + typeof value.backup === 'function' + ) +} + +export function isSqliteAvailable(): boolean { + try { + if (process.versions.bun) { + return loadBunSqlite() !== undefined + } + const sqlite: unknown = process.getBuiltinModule?.('node:sqlite') + return hasDatabaseSync(sqlite) && hasBackup(sqlite) + } catch { + return false + } } class SyncDatabase { - private readonly db: DatabaseSync - private readonly statementCache = new Map() + private readonly db: DatabaseSync | BunSqliteDatabase + private readonly statementCache = new Map() constructor(path: SqlitePath, options: SyncDatabaseOptions = {}) { - if ( - options.fileMustExist && - typeof path === 'string' && - path !== ':memory:' && - !existsSync(path) - ) { - throw new Error(`SQLite database does not exist: ${path}`) + if (options.fileMustExist && path !== ':memory:' && !existsSync(path)) { + throw new Error(`SQLite database does not exist: ${String(path)}`) + } + if (process.versions.bun) { + this.db = new BunSqliteDatabase(path, options) + } else { + const DatabaseSync = loadDatabaseSync() + this.db = new DatabaseSync(path, { + readOnly: options.readonly, + timeout: options.timeout + }) } - const DatabaseSync = loadDatabaseSync() - this.db = new DatabaseSync(path, { - readOnly: options.readonly, - timeout: options.timeout - }) } exec(sql: string): void { @@ -64,14 +99,17 @@ class SyncDatabase { this.db.exec(sql) } - prepare(sql: string): StatementSync { + prepare(sql: string): SqliteStatement { const cached = this.statementCache.get(sql) if (cached) { this.statementCache.delete(sql) this.statementCache.set(sql, cached) return cached } - const statement = this.db.prepare(sql) + const statement = + this.db instanceof BunSqliteDatabase + ? this.db.prepare(sql) + : new NodeSqliteStatement(this.db.prepare(sql)) if (isStatementCacheable(sql)) { if (this.statementCache.size >= STATEMENT_CACHE_LIMIT) { const oldest = this.statementCache.keys().next().value @@ -85,7 +123,7 @@ class SyncDatabase { } pragma(sql: string, options?: PragmaOptions): unknown { - const statement = this.db.prepare(`PRAGMA ${sql}`) + const statement = this.prepare(`PRAGMA ${sql}`) if (options?.simple) { const row = statement.get() if (!row) { @@ -100,6 +138,28 @@ class SyncDatabase { return this.db.isTransaction } + /** Keep the source open until completion; Bun's compact snapshot runs synchronously. */ + async backup(path: string, options?: BackupOptions): Promise { + if (this.db.isTransaction) { + throw new Error('SQLite backup requires an idle database connection') + } + if (this.db instanceof BunSqliteDatabase) { + if (options && Object.keys(options).length > 0) { + throw new Error('Incremental SQLite backup options are unavailable in this runtime') + } + this.db.backup(path) + return + } + const sqlite: unknown = + typeof process.getBuiltinModule === 'function' + ? process.getBuiltinModule('node:sqlite') + : undefined + if (!hasBackup(sqlite)) { + throw new Error('Asynchronous SQLite backup is unavailable in this Node.js runtime') + } + await sqlite.backup(this.db, path, options ?? {}) + } + close(): void { this.statementCache.clear() this.db.close() diff --git a/src/main/ssh-reattach-pane-cardinality.test.ts b/src/main/ssh-reattach-pane-cardinality.test.ts index d7179703360..889f792abd4 100644 --- a/src/main/ssh-reattach-pane-cardinality.test.ts +++ b/src/main/ssh-reattach-pane-cardinality.test.ts @@ -66,11 +66,11 @@ function sessionAfterClose() { } /** What the relay's reattach bind does per PTY — see `restoreReattachedPtyRuntime`. */ -function relayReattachBinds( +async function relayReattachBinds( store: ReturnType, args: { tabId: string; leafId: string; ptyId: string; incarnationId?: string } -): boolean | null { - return store.persistPtyBinding({ +): Promise { + return await store.persistPtyBinding({ worktreeId: WORKTREE, tabId: args.tabId, leafId: args.leafId, @@ -95,10 +95,10 @@ function relayReattachBinds( * production uses; a raw session write is reconciled back to the attached lease's PTY by binding * recovery, which would make the fixture disagree with the real flow. */ -function paneSpawnCommits( +async function paneSpawnCommits( store: ReturnType, args: { tabId: string; leafId: string; ptyId: string; leaseTabId?: string } -): void { +): Promise { store.upsertSshRemotePtyLease({ targetId: TARGET, ptyId: args.ptyId, @@ -107,7 +107,7 @@ function paneSpawnCommits( leafId: args.leafId, state: 'attached' }) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: WORKTREE, tabId: args.tabId, leafId: args.leafId, @@ -163,7 +163,7 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', () // The user closes the tab; the remote kill never lands, so the lease survives untouched. store.setWorkspaceSession(sessionAfterClose()) - const bound = relayReattachBinds(store, { + const bound = await relayReattachBinds(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-1', @@ -189,7 +189,7 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', () const resolvedTabId = findTerminalTabIdForLeaf(store.getWorkspaceSession(), TEST_LEAF_1) expect(resolvedTabId).toBe(OTHER_TAB) - const bound = relayReattachBinds(store, { + const bound = await relayReattachBinds(store, { tabId: resolvedTabId!, leafId: TEST_LEAF_1, ptyId: 'pty-2', @@ -208,7 +208,7 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', () it('still binds when the session is not yet authoritative for the worktree', async () => { const store = await createStore() - const bound = store.persistPtyBinding({ + const bound = await store.persistPtyBinding({ worktreeId: WORKTREE, tabId: TAB, leafId: TEST_LEAF_1, @@ -250,7 +250,7 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', () expect(store.getWorkspaceSession().terminalSurfaceTombstonesByPaneKey?.[paneKey]).toBeDefined() expect( - relayReattachBinds(store, { + await relayReattachBinds(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-1', @@ -267,7 +267,7 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', () const store = await createStore() store.setWorkspaceSession(sessionWithPane({ tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-1' })) - const bound = relayReattachBinds(store, { + const bound = await relayReattachBinds(store, { tabId: TAB, leafId: TEST_LEAF_2, ptyId: 'pty-2', @@ -292,9 +292,9 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', () const session = store.getWorkspaceSession() expect(session.terminalTopologyRevisionByRepoId?.repo1).toBeGreaterThan(0) expect(session.terminalSurfaceTombstonesByPaneKey ?? {}).toEqual({}) - expect(relayReattachBinds(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-1' })).toBe( - false - ) + expect( + await relayReattachBinds(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-1' }) + ).toBe(false) }) }) @@ -314,7 +314,7 @@ describe('STA-3077: one pane keeps at most one live remote lease', () => { const lease = { targetId: TARGET, worktreeId: WORKTREE, tabId: TAB, leafId: TEST_LEAF_1 } store.upsertSshRemotePtyLease({ ...lease, ptyId: 'pty-1', state: 'attached' }) - paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) + await paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) expect(liveLeasePtyIds(store)).toEqual(['pty-2']) }) @@ -327,7 +327,7 @@ describe('STA-3077: one pane keeps at most one live remote lease', () => { const lease = { targetId: TARGET, worktreeId: WORKTREE, tabId: TAB, leafId: TEST_LEAF_1 } store.upsertSshRemotePtyLease({ ...lease, ptyId: 'pty-1', state: 'attached' }) - paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) + await paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) const predecessor = store.getSshRemotePtyLeases(TARGET).find((entry) => entry.ptyId === 'pty-1') expect(predecessor?.state).toBe('expired') @@ -339,7 +339,7 @@ describe('STA-3077: one pane keeps at most one live remote lease', () => { store.setWorkspaceSession(sessionWithPane({ tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-0' })) for (let reconnect = 0; reconnect < 10; reconnect++) { - paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: `pty-${reconnect}` }) + await paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: `pty-${reconnect}` }) } expect(liveLeasePtyIds(store)).toEqual(['pty-9']) @@ -362,7 +362,7 @@ describe('STA-3077: one pane keeps at most one live remote lease', () => { // The successor's lease names the tab the pane sits in NOW; the predecessor's still names the // one it was written in. Only the leaf is common, so keying on the tab would stop the two // competing and leave both live — the cardinality growth. - paneSpawnCommits(store, { + await paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2', @@ -401,7 +401,7 @@ describe('STA-3077: one pane keeps at most one live remote lease', () => { const lease = { targetId: TARGET, worktreeId: WORKTREE, tabId: TAB, leafId: TEST_LEAF_1 } store.upsertSshRemotePtyLease({ ...lease, ptyId: 'pty-1', state: 'attached' }) - paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) + await paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) expect(liveLeasePtyIds(store).sort()).toEqual(['pty-2', 'sibling-pty']) }) @@ -461,7 +461,7 @@ describe('STA-3077: `expired` separates a superseded sibling from an orphan', () it('never bulk-reattaches a superseded sibling', async () => { const store = await storeWithPane('pty-1') - paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) + await paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) const predecessor = store.getSshRemotePtyLeases(TARGET).find((entry) => entry.ptyId === 'pty-1') expect(predecessor).toMatchObject({ state: 'expired', supersededBy: 'pty-2' }) @@ -474,7 +474,7 @@ describe('STA-3077: `expired` separates a superseded sibling from an orphan', () store.setWorkspaceSession(sessionWithPane({ tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-0' })) for (let reconnect = 0; reconnect < 10; reconnect++) { - paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: `pty-${reconnect}` }) + await paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: `pty-${reconnect}` }) } expect(bulkReattachPtyIds(store)).toEqual(['pty-9']) @@ -500,7 +500,7 @@ describe('STA-3077: `expired` separates a superseded sibling from an orphan', () store.markSshRemotePtyLease(TARGET, 'pty-1', 'expired') const orphanUpdatedAt = store.getSshRemotePtyLeases(TARGET)[0].updatedAt - paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) + await paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) const predecessor = store.getSshRemotePtyLeases(TARGET).find((entry) => entry.ptyId === 'pty-1') expect(predecessor).toMatchObject({ state: 'expired', supersededBy: 'pty-2' }) @@ -513,7 +513,7 @@ describe('STA-3077: `expired` separates a superseded sibling from an orphan', () // belongs to the lease that lost, never to whatever claims the id next. it('clears the supersession mark when the id is re-upserted as a live lease', async () => { const store = await storeWithPane('pty-1') - paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) + await paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) // A restarted relay hands `pty-1` to a new shell for a different pane. store.upsertSshRemotePtyLease({ diff --git a/src/main/ssh/__tests__/ssh-connection-test-client.ts b/src/main/ssh/__tests__/ssh-connection-test-client.ts index 5e57dea3936..46276f90c43 100644 --- a/src/main/ssh/__tests__/ssh-connection-test-client.ts +++ b/src/main/ssh/__tests__/ssh-connection-test-client.ts @@ -38,18 +38,32 @@ export const VALID_ED25519_HOST_KEY = Buffer.from( ) // Knobs tests assign to; grouped because imported bindings cannot be reassigned. -export const ssh2Mock = { - presentedHostKey: undefined as Buffer | undefined, +export const ssh2Mock: { + presentedHostKey: Buffer | undefined + lastHostKeyAccepted: boolean | undefined + connectBehavior: 'ready' | 'error' | 'pending' + connectErrorMessage: string + connectErrorCode: string + destroyErrorMessage: string + connectSequence: ('ready' | 'silent' | Error)[] + execBehavior: 'callback' | 'pending' + sftpBehavior: 'callback' | 'pending' + notifyClientCreated: (() => void) | undefined +} = { + presentedHostKey: undefined, /** What the verifier decided about the presented key on the most recent connect. */ - lastHostKeyAccepted: undefined as boolean | undefined, - connectBehavior: 'ready' as 'ready' | 'error' | 'pending', + lastHostKeyAccepted: undefined, + connectBehavior: 'ready', connectErrorMessage: '', connectErrorCode: '', destroyErrorMessage: '', - connectSequence: [] as ('ready' | Error)[], - execBehavior: 'callback' as 'callback' | 'pending', - sftpBehavior: 'callback' as 'callback' | 'pending', - notifyClientCreated: undefined as (() => void) | undefined + // Why 'silent': leaves the connect attempt pending (no ready/error emitted) + // so a test can drive auth events (e.g. keyboard-interactive prompts) + // through emitSshEvent itself. + connectSequence: [], + execBehavior: 'callback', + sftpBehavior: 'callback', + notifyClientCreated: undefined } export const emitSshEvent = (event: string, ...args: unknown[]): void => @@ -137,6 +151,9 @@ export function createSsh2Module(): Ssh2ModuleMock { this.emit('ready') return } + if (next === 'silent') { + return + } if (ssh2Mock.connectBehavior === 'pending') { const configValue = this.lastConnectConfig const readyTimeout = diff --git a/src/main/ssh/orcad-artifact-materializer.test.ts b/src/main/ssh/orcad-artifact-materializer.test.ts new file mode 100644 index 00000000000..beff61176ee --- /dev/null +++ b/src/main/ssh/orcad-artifact-materializer.test.ts @@ -0,0 +1,319 @@ +import { createHash } from 'node:crypto' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + ORCAD_BUILD_TARGET_FILENAME, + ORCAD_EMOJI_SHORTCODE_DATASET, + ORCAD_RIPGREP_ARTIFACTS, + ORCAD_TEMPLATE_MANIFEST_FILENAME, + ORCAD_TEMPLATE_TARGETS_DIR, + ORCAD_VERSION_FILENAME, + orcadArtifactFilenames, + orcadTemplateCommonFilenames +} from '../../shared/orcad-artifacts' +import type { OrcadBunTarget } from '../../shared/orcad-bun-runtime' +import { z } from 'zod' +import { readOrcadArtifactIdentity } from '../orcad/orcad-artifact-identity' +import { + assembleOrcadArtifact, + materializeOrcadArtifact, + resetOrcadArtifactMaterializationsForTests +} from './orcad-artifact-materializer' +import { materializeCachedOrcadBunRuntime } from './orcad-bun-runtime-materializer' +import type * as BunRuntimeMaterializer from './orcad-bun-runtime-materializer' + +vi.mock('./orcad-bun-runtime-materializer', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, materializeCachedOrcadBunRuntime: vi.fn() } +}) + +const TARGET = 'linux-x64-glibc' as const +const temporaryDirs: string[] = [] + +afterEach(() => { + resetOrcadArtifactMaterializationsForTests() + vi.clearAllMocks() + for (const dir of temporaryDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function sha256(path: string): string { + return createHash('sha256').update(readFileSync(path)).digest('hex') +} + +function write(path: string, contents: string): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, contents) +} + +function createTemplate(target: OrcadBunTarget = TARGET): { + root: string + templateDir: string + cacheRoot: string + runtimePath: string +} { + const root = mkdtempSync(join(tmpdir(), 'orcad-artifact-template-')) + temporaryDirs.push(root) + const templateDir = join(root, 'template') + const cacheRoot = join(root, 'cache') + const runtimePath = join(root, 'bun-runtime') + const common: Record = { + ...Object.fromEntries(orcadTemplateCommonFilenames().map((filename) => [filename, filename])), + 'orcad.js': 'orcad-entry', + 'daemon-entry.js': 'daemon-entry', + 'profile-state-writer-worker-entry.js': 'writer-entry', + 'profile-state-backup-worker-entry.js': 'backup-entry', + 'windows-bun-pty-gate-entry.js': 'pty-gate-entry', + 'parcel-watcher-process-entry.js': 'watcher-process', + 'node_modules/@parcel/watcher/index.js': 'watcher-wrapper', + [ORCAD_EMOJI_SHORTCODE_DATASET]: '{}' + } + for (const [filename, contents] of Object.entries(common)) { + write(join(templateDir, filename), contents) + } + const targetDir = join(templateDir, ORCAD_TEMPLATE_TARGETS_DIR, target) + write(join(targetDir, ORCAD_BUILD_TARGET_FILENAME), `${target}\n`) + write(join(targetDir, 'watcher.node'), 'native-watcher') + write(join(targetDir, 'agent-browser-linux-x64'), 'browser') + write(runtimePath, 'bun-executable') + if (target.startsWith('win32-')) { + write(join(templateDir, 'windows-process-tree.node'), 'process-table') + } + write( + join(templateDir, ORCAD_TEMPLATE_MANIFEST_FILENAME), + JSON.stringify({ + schemaVersion: 2, + commonSha256: Object.fromEntries( + Object.keys(common).map((filename) => [filename, sha256(join(templateDir, filename))]) + ), + targets: { + [target]: { + targetSha256: sha256(join(targetDir, ORCAD_BUILD_TARGET_FILENAME)), + watcherSha256: sha256(join(targetDir, 'watcher.node')), + browserName: 'agent-browser-linux-x64', + browserSha256: sha256(join(targetDir, 'agent-browser-linux-x64')) + } + } + }) + ) + return { root, templateDir, cacheRoot, runtimePath } +} + +describe('assembleOrcadArtifact', () => { + it.skipIf(process.platform === 'win32')( + 'restores executable search modes from a template copied without them', + async () => { + const fixture = createTemplate() + const filenames = ORCAD_RIPGREP_ARTIFACTS.filter((filename) => filename.endsWith('/rg')) + for (const filename of filenames) { + chmodSync(join(fixture.templateDir, filename), 0o644) + } + const artifactDir = await assembleOrcadArtifact({ ...fixture, target: TARGET }) + for (const filename of filenames) { + expect(statSync(join(artifactDir, filename)).mode & 0o777).toBe(0o755) + } + } + ) + + it('gives Windows executable naming a new immutable slot identity', async () => { + const target = 'win32-x64' as const + const fixture = createTemplate(target) + const artifactDir = await assembleOrcadArtifact({ ...fixture, target }) + expect(readFileSync(join(artifactDir, 'bun-runtime.exe'), 'utf8')).toBe('bun-executable') + expect(existsSync(join(artifactDir, 'bun-runtime'))).toBe(false) + const oldHash = createHash('sha256') + for (const filename of orcadArtifactFilenames(target)) { + oldHash.update(readFileSync(join(artifactDir, filename))) + } + oldHash.update('browser') + const oldVersion = `0.1.0+${oldHash.digest('hex').slice(0, 12)}` + const oldDir = join(fixture.cacheRoot, target, oldVersion) + write(join(oldDir, 'bun-runtime'), 'legacy-slot-must-stay-unchanged') + expect(artifactDir).not.toBe(oldDir) + await expect(assembleOrcadArtifact({ ...fixture, target })).resolves.toBe(artifactDir) + expect(readFileSync(join(oldDir, 'bun-runtime'), 'utf8')).toBe( + 'legacy-slot-must-stay-unchanged' + ) + }) + + it('assembles a complete content-addressed target directory', async () => { + const fixture = createTemplate() + const artifactDir = await assembleOrcadArtifact({ + templateDir: fixture.templateDir, + cacheRoot: fixture.cacheRoot, + target: TARGET, + runtimePath: fixture.runtimePath + }) + + const version = readFileSync(join(artifactDir, ORCAD_VERSION_FILENAME), 'utf8').trim() + expect(version).toMatch(/^0\.1\.0\+[a-f0-9]{12}$/u) + expect(await readOrcadArtifactIdentity(artifactDir)).toBe(version) + write(join(artifactDir, 'orcad.js'), 'changed-installed-bytes') + expect(await readOrcadArtifactIdentity(artifactDir)).not.toBe(version) + expect(artifactDir).toBe(join(fixture.cacheRoot, TARGET, version)) + expect(readFileSync(join(artifactDir, ORCAD_BUILD_TARGET_FILENAME), 'utf8').trim()).toBe(TARGET) + for (const filename of orcadArtifactFilenames()) { + expect(readFileSync(join(artifactDir, filename)).byteLength).toBeGreaterThan(0) + } + expect(readFileSync(join(artifactDir, 'agent-browser-linux-x64'), 'utf8')).toBe('browser') + }) + + it('rejects a packaged native file that does not match its manifest', async () => { + const fixture = createTemplate() + write( + join(fixture.templateDir, ORCAD_TEMPLATE_TARGETS_DIR, TARGET, 'watcher.node'), + 'corrupted' + ) + + await expect( + assembleOrcadArtifact({ + templateDir: fixture.templateDir, + cacheRoot: fixture.cacheRoot, + target: TARGET, + runtimePath: fixture.runtimePath + }) + ).rejects.toThrow('watcher checksum mismatch') + }) + + it('rejects a self-consistent target marker for a different native slot', async () => { + const fixture = createTemplate() + const targetPath = join( + fixture.templateDir, + ORCAD_TEMPLATE_TARGETS_DIR, + TARGET, + ORCAD_BUILD_TARGET_FILENAME + ) + write(targetPath, 'linux-x64-musl\n') + const manifestPath = join(fixture.templateDir, ORCAD_TEMPLATE_MANIFEST_FILENAME) + const manifest = z + .object({ + targets: z.record(z.string(), z.object({ targetSha256: z.string() }).passthrough()) + }) + .passthrough() + .parse(JSON.parse(readFileSync(manifestPath, 'utf8'))) + const target = manifest.targets[TARGET] + if (!target) { + throw new Error('Missing target fixture') + } + target.targetSha256 = sha256(targetPath) + write(manifestPath, JSON.stringify(manifest)) + + await expect( + assembleOrcadArtifact({ + templateDir: fixture.templateDir, + cacheRoot: fixture.cacheRoot, + target: TARGET, + runtimePath: fixture.runtimePath + }) + ).rejects.toThrow('target identity does not match') + }) + + it('repairs corrupt artifacts beside the old entry and reuses the repair', async () => { + const fixture = createTemplate() + const first = await assembleOrcadArtifact({ + templateDir: fixture.templateDir, + cacheRoot: fixture.cacheRoot, + target: TARGET, + runtimePath: fixture.runtimePath + }) + write(join(first, 'orcad.js'), 'corrupted-cache-entry') + + const repaired = await assembleOrcadArtifact({ ...fixture, target: TARGET }) + expect(repaired).not.toBe(first) + expect(readFileSync(join(repaired, 'orcad.js'))).toEqual( + readFileSync(join(fixture.templateDir, 'orcad.js')) + ) + expect(await assembleOrcadArtifact({ ...fixture, target: TARGET })).toBe(repaired) + expect(readFileSync(join(first, 'orcad.js'), 'utf8')).toBe('corrupted-cache-entry') + }) + + it('rejects an optional browser without a matching manifest checksum', async () => { + const fixture = createTemplate() + const manifestPath = join(fixture.templateDir, ORCAD_TEMPLATE_MANIFEST_FILENAME) + const manifest = z + .object({ targets: z.record(z.string(), z.record(z.string(), z.unknown())) }) + .passthrough() + .parse(JSON.parse(readFileSync(manifestPath, 'utf8'))) + delete manifest.targets[TARGET]?.browserSha256 + write(manifestPath, JSON.stringify(manifest)) + + await expect( + assembleOrcadArtifact({ + templateDir: fixture.templateDir, + cacheRoot: fixture.cacheRoot, + target: TARGET, + runtimePath: fixture.runtimePath + }) + ).rejects.toThrow('browserName and browserSha256') + }) + + it('rejects a manifest that omits a required common artifact checksum', async () => { + const fixture = createTemplate() + const manifestPath = join(fixture.templateDir, ORCAD_TEMPLATE_MANIFEST_FILENAME) + const manifest = z + .object({ commonSha256: z.record(z.string(), z.string()) }) + .passthrough() + .parse(JSON.parse(readFileSync(manifestPath, 'utf8'))) + delete manifest.commonSha256['orcad.js'] + write(manifestPath, JSON.stringify(manifest)) + + await expect( + assembleOrcadArtifact({ + templateDir: fixture.templateDir, + cacheRoot: fixture.cacheRoot, + target: TARGET, + runtimePath: fixture.runtimePath + }) + ).rejects.toThrow('manifest omits orcad.js') + }) +}) + +describe('materializeOrcadArtifact cancellation', () => { + it('detaches either cancelled caller without cancelling their shared cache fill', async () => { + const fixture = createTemplate() + let complete: (path: string) => void = () => {} + vi.mocked(materializeCachedOrcadBunRuntime).mockReturnValue( + new Promise((resolve) => { + complete = resolve + }) + ) + const first = new AbortController() + const second = new AbortController() + const one = materializeOrcadArtifact(TARGET, { ...fixture, signal: first.signal }) + const two = materializeOrcadArtifact(TARGET, { ...fixture, signal: second.signal }) + const three = materializeOrcadArtifact(TARGET, fixture) + const firstRejected = expect(one).rejects.toThrow('first cancelled') + const secondRejected = expect(two).rejects.toThrow('second cancelled') + await vi.waitFor(() => expect(materializeCachedOrcadBunRuntime).toHaveBeenCalledOnce()) + first.abort(new Error('first cancelled')) + second.abort(new Error('second cancelled')) + await Promise.all([firstRejected, secondRejected]) + complete(fixture.runtimePath) + const artifact = await three + expect(readFileSync(join(artifact, 'orcad.js'), 'utf8')).toBe('orcad-entry') + expect(materializeCachedOrcadBunRuntime).toHaveBeenCalledWith(TARGET, fixture.cacheRoot, { + fetcher: undefined + }) + }) + + it('refuses an already cancelled request before reading or fetching artifacts', async () => { + const controller = new AbortController() + controller.abort(new Error('cancelled')) + await expect(materializeOrcadArtifact(TARGET, { signal: controller.signal })).rejects.toThrow( + 'cancelled' + ) + expect(materializeCachedOrcadBunRuntime).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ssh/orcad-artifact-materializer.ts b/src/main/ssh/orcad-artifact-materializer.ts new file mode 100644 index 00000000000..2efe902b504 --- /dev/null +++ b/src/main/ssh/orcad-artifact-materializer.ts @@ -0,0 +1,291 @@ +import { createHash, randomUUID } from 'node:crypto' +import { createReadStream, existsSync } from 'node:fs' +import { chmod, copyFile, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { z } from 'zod' +import { getAppEnvironment } from '../../shared/app-environment' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { + ORCAD_BUILD_TARGET_FILENAME, + orcadBunRuntimeFilename, + orcadArtifactHashPrefix, + ORCAD_TEMPLATE_MANIFEST_FILENAME, + ORCAD_TEMPLATE_TARGETS_DIR, + ORCAD_VERSION, + ORCAD_VERSION_FILENAME, + ORCAD_RIPGREP_ARTIFACTS, + orcadArtifactFilenames, + orcadTemplateCommonFilenames +} from '../../shared/orcad-artifacts' +import type { OrcadBunTarget } from '../../shared/orcad-bun-runtime' +import { findOrcadCachePath } from './orcad-cache-path' +import { + fileSha256, + materializeCachedOrcadBunRuntime, + verifyFileSha256, + type OrcadBunRuntimeMaterializeOptions +} from './orcad-bun-runtime-materializer' + +const TemplateTargetSchema = z + .object({ + targetSha256: z.string().regex(/^[a-f0-9]{64}$/u), + watcherSha256: z.string().regex(/^[a-f0-9]{64}$/u), + browserName: z + .string() + .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u) + .optional(), + browserSha256: z + .string() + .regex(/^[a-f0-9]{64}$/u) + .optional() + }) + .refine((target) => Boolean(target.browserName) === Boolean(target.browserSha256), { + message: 'browserName and browserSha256 must either both be present or both be absent' + }) +const TemplateManifestSchema = z.object({ + schemaVersion: z.literal(2), + commonSha256: z.record(z.string(), z.string().regex(/^[a-f0-9]{64}$/u)), + targets: z.record(z.string(), TemplateTargetSchema) +}) + +type MaterializeOptions = OrcadBunRuntimeMaterializeOptions & { + templateDir?: string + cacheRoot?: string +} + +const materializations = new Map>() + +export async function materializeOrcadArtifact( + target: OrcadBunTarget, + options: MaterializeOptions = {} +): Promise { + options.signal?.throwIfAborted() + const templateDir = options.templateDir ?? resolveOrcadTemplateDir() + const cacheRoot = + options.cacheRoot ?? join(getAppEnvironment().getPath('userData'), 'orcad-artifacts') + const key = `${templateDir}\0${cacheRoot}\0${target}` + const existing = materializations.get(key) + if (existing) { + return waitForPromiseWithSignal(existing, options.signal) + } + // Cancellation detaches one caller; the bounded cache fill still serves other deployments. + const pending = materializeOrcadArtifactInner(target, templateDir, cacheRoot, { + fetcher: options.fetcher + }).finally(() => materializations.delete(key)) + materializations.set(key, pending) + return waitForPromiseWithSignal(pending, options.signal) +} + +async function materializeOrcadArtifactInner( + target: OrcadBunTarget, + templateDir: string, + cacheRoot: string, + options: MaterializeOptions +): Promise { + const manifest = await readTemplateManifest(templateDir) + await verifyTemplate(templateDir, target, manifest) + const runtimePath = await materializeCachedOrcadBunRuntime(target, cacheRoot, options) + return await assembleOrcadArtifact({ templateDir, cacheRoot, target, runtimePath, manifest }) +} + +export async function assembleOrcadArtifact(args: { + templateDir: string + cacheRoot: string + target: OrcadBunTarget + runtimePath: string + manifest?: z.infer +}): Promise { + const manifest = args.manifest ?? (await readTemplateManifest(args.templateDir)) + await verifyTemplate(args.templateDir, args.target, manifest) + const sources = artifactSources(args.templateDir, args.target, args.runtimePath, manifest) + const { fullVersion, sourceHashes } = await computeArtifactIdentity(sources, args.target) + const targetRoot = join(args.cacheRoot, args.target) + const cached = await findOrcadCachePath( + (attempt) => join(targetRoot, `${fullVersion}${attempt ? `.repair-${attempt}` : ''}`), + (path) => isCompleteArtifact(path, fullVersion, sources, sourceHashes) + ) + const targetDir = cached.path + if (cached.verified) { + return targetDir + } + await mkdir(targetRoot, { recursive: true }) + const stagingDir = join(targetRoot, `.staging-${process.pid}-${randomUUID()}`) + try { + for (const source of sources) { + const destination = join(stagingDir, source.filename) + await mkdir(dirname(destination), { recursive: true }) + await copyFile(source.path, destination) + if (source.executable && !args.target.startsWith('win32-')) { + await chmod(destination, 0o755) + } + } + await writeFile(join(stagingDir, ORCAD_VERSION_FILENAME), `${fullVersion}\n`, { mode: 0o600 }) + if (!(await isCompleteArtifact(stagingDir, fullVersion, sources, sourceHashes))) { + throw new Error('Orcad artifact sources changed while copying') + } + try { + await rename(stagingDir, targetDir) + } catch (error) { + if (!(await isCompleteArtifact(targetDir, fullVersion, sources, sourceHashes))) { + throw new Error(`Orcad artifact cache entry is unavailable or corrupted: ${targetDir}`, { + cause: error + }) + } + } + return targetDir + } finally { + await rm(stagingDir, { recursive: true, force: true }) + } +} + +function artifactSources( + templateDir: string, + target: OrcadBunTarget, + runtimePath: string, + manifest: z.infer +): { filename: string; path: string; executable?: boolean }[] { + const targetDir = join(templateDir, ORCAD_TEMPLATE_TARGETS_DIR, target) + const targetManifest = manifest.targets[target] + if (!targetManifest) { + throw new Error(`Packaged orcad template does not support ${target}`) + } + const required = orcadArtifactFilenames(target).map((filename) => ({ + filename, + path: + filename === orcadBunRuntimeFilename(target) + ? runtimePath + : filename === ORCAD_BUILD_TARGET_FILENAME + ? join(targetDir, ORCAD_BUILD_TARGET_FILENAME) + : filename.endsWith('watcher.node') + ? join(targetDir, 'watcher.node') + : join(templateDir, filename), + executable: + filename === orcadBunRuntimeFilename(target) || + ORCAD_RIPGREP_ARTIFACTS.some((artifact) => artifact === filename && artifact.endsWith('/rg')) + })) + if (!targetManifest.browserName) { + return required + } + return [ + ...required, + { + filename: targetManifest.browserName, + path: join(targetDir, targetManifest.browserName), + executable: true + } + ] +} + +async function computeArtifactIdentity( + sources: { filename: string; path: string }[], + target: OrcadBunTarget +): Promise<{ fullVersion: string; sourceHashes: Map }> { + const hash = createHash('sha256').update(orcadArtifactHashPrefix(target)) + const sourceHashes = new Map() + for (const source of sources) { + const sourceHash = createHash('sha256') + for await (const chunk of createReadStream(source.path)) { + hash.update(chunk) + sourceHash.update(chunk) + } + sourceHashes.set(source.filename, sourceHash.digest('hex')) + } + return { + fullVersion: `${ORCAD_VERSION}+${hash.digest('hex').slice(0, 12)}`, + sourceHashes + } +} + +async function isCompleteArtifact( + dir: string, + fullVersion: string, + sources: { filename: string }[], + sourceHashes: Map +): Promise { + try { + if ((await readFile(join(dir, ORCAD_VERSION_FILENAME), 'utf8')).trim() !== fullVersion) { + return false + } + for (const source of sources) { + if ((await fileSha256(join(dir, source.filename))) !== sourceHashes.get(source.filename)) { + return false + } + } + return true + } catch { + return false + } +} + +async function readTemplateManifest( + templateDir: string +): Promise> { + return TemplateManifestSchema.parse( + JSON.parse(await readFile(join(templateDir, ORCAD_TEMPLATE_MANIFEST_FILENAME), 'utf8')) + ) +} + +async function verifyTemplate( + templateDir: string, + target: OrcadBunTarget, + manifest: z.infer +): Promise { + const targetManifest = manifest.targets[target] + if (!targetManifest) { + throw new Error(`Packaged orcad template does not support ${target}`) + } + const commonFilenames = orcadTemplateCommonFilenames() + for (const filename of commonFilenames) { + const expected = manifest.commonSha256[filename] + if (!expected) { + throw new Error(`Packaged orcad template manifest omits ${filename}`) + } + await verifyFileSha256(join(templateDir, filename), expected, `orcad template ${filename}`) + } + const targetDir = join(templateDir, ORCAD_TEMPLATE_TARGETS_DIR, target) + const targetIdentityPath = join(targetDir, ORCAD_BUILD_TARGET_FILENAME) + await verifyFileSha256(targetIdentityPath, targetManifest.targetSha256, `${target} build target`) + if ((await readFile(targetIdentityPath, 'utf8')).trim() !== target) { + throw new Error(`Packaged orcad template target identity does not match ${target}`) + } + await verifyFileSha256( + join(targetDir, 'watcher.node'), + targetManifest.watcherSha256, + `${target} watcher` + ) + if (targetManifest.browserName && targetManifest.browserSha256) { + await verifyFileSha256( + join(targetDir, targetManifest.browserName), + targetManifest.browserSha256, + `${target} browser` + ) + } +} + +export function getOrcadTemplateCandidates(): string[] { + const candidates: string[] = [] + if (process.env.ORCA_ORCAD_TEMPLATE_PATH) { + candidates.push(process.env.ORCA_ORCAD_TEMPLATE_PATH) + } + if (process.resourcesPath) { + candidates.push(join(process.resourcesPath, 'orcad-template')) + } + const appPath = getAppEnvironment().getAppPath() + candidates.push( + join(appPath, 'out', 'orcad-template'), + join(appPath, 'resources', 'orcad-template') + ) + return [...new Set(candidates)] +} + +function resolveOrcadTemplateDir(): string { + const found = getOrcadTemplateCandidates().find((candidate) => existsSync(candidate)) + if (!found) { + throw new Error('The packaged orcad deployment template is missing') + } + return found +} + +export function resetOrcadArtifactMaterializationsForTests(): void { + materializations.clear() +} diff --git a/src/main/ssh/orcad-bun-runtime-materializer.test.ts b/src/main/ssh/orcad-bun-runtime-materializer.test.ts new file mode 100644 index 00000000000..de9ab5d939c --- /dev/null +++ b/src/main/ssh/orcad-bun-runtime-materializer.test.ts @@ -0,0 +1,299 @@ +import { createHash } from 'node:crypto' +import { access, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ORCAD_BUN_RUNTIME_FILENAME } from '../../shared/orcad-artifacts' +import { ORCAD_BUN_RELEASE_ASSETS, ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' +import { setMainHttpClient } from '../network/http-client' +import { runProcess } from '../../shared/child-process/run-process' +import { materializeCachedOrcadBunRuntime } from './orcad-bun-runtime-materializer' + +const extraction = vi.hoisted(() => ({ executable: new Uint8Array(), executableName: 'bun' })) + +vi.mock('../../shared/child-process/run-process', () => ({ + runProcess: vi.fn(async (spec: { args: string[] }) => { + const extracted = join(spec.args.at(-1)!, 'bun-linux-x64') + await mkdir(extracted, { recursive: true }) + await writeFile(join(extracted, extraction.executableName), extraction.executable) + return { code: 0, stdout: '', stderr: '' } + }) +})) + +const TARGET = 'linux-x64-glibc' as const +const originalAsset = { ...ORCAD_BUN_RELEASE_ASSETS[TARGET] } +let cacheRoot = '' + +function sha256(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex') +} + +function responseFetcher(body: Uint8Array, declaredLength = body.byteLength): typeof fetch { + return vi.fn( + async () => + new Response(Buffer.from(body), { + status: 200, + headers: { 'content-length': String(declaredLength) } + }) + ) +} + +beforeEach(async () => { + extraction.executableName = 'bun' + cacheRoot = await mkdtemp(join(tmpdir(), 'orca-bun-runtime-materializer-')) + Object.assign(ORCAD_BUN_RELEASE_ASSETS[TARGET], originalAsset) +}) + +afterEach(async () => { + vi.useRealTimers() + vi.unstubAllEnvs() + setMainHttpClient(null) + Object.assign(ORCAD_BUN_RELEASE_ASSETS[TARGET], originalAsset) + await rm(cacheRoot, { recursive: true, force: true }) +}) + +describe('materializeCachedOrcadBunRuntime', () => { + it('caches Windows PE files as .exe without renaming a legacy cache entry', async () => { + const target = 'win32-x64' as const + const savedAsset = { ...ORCAD_BUN_RELEASE_ASSETS[target] } + const archive = new TextEncoder().encode('windows archive') + const executable = new TextEncoder().encode('windows executable') + extraction.executable = executable + extraction.executableName = 'bun.exe' + Object.assign(ORCAD_BUN_RELEASE_ASSETS[target], { + sha256: sha256(archive), + executableSha256: sha256(executable) + }) + const runtimeDir = join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, target) + await mkdir(runtimeDir, { recursive: true }) + await writeFile(join(runtimeDir, 'bun-runtime'), 'legacy') + try { + const runtimePath = await materializeCachedOrcadBunRuntime(target, cacheRoot, { + fetcher: responseFetcher(archive) + }) + expect(runtimePath).toBe(join(runtimeDir, 'bun-runtime.exe')) + expect(await readFile(runtimePath)).toEqual(Buffer.from(executable)) + expect(await readFile(join(runtimeDir, 'bun-runtime'), 'utf8')).toBe('legacy') + } finally { + Object.assign(ORCAD_BUN_RELEASE_ASSETS[target], savedAsset) + } + }) + + it('reuses a checksum-valid cached runtime without fetching', async () => { + const runtime = new TextEncoder().encode('cached bun') + ORCAD_BUN_RELEASE_ASSETS[TARGET].executableSha256 = sha256(runtime) + const runtimeDir = join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET) + const runtimePath = join(runtimeDir, ORCAD_BUN_RUNTIME_FILENAME) + await mkdir(runtimeDir, { recursive: true }) + await writeFile(runtimePath, runtime) + const fetcher = vi.fn() + + await expect(materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher })).resolves.toBe( + runtimePath + ) + expect(fetcher).not.toHaveBeenCalled() + expect(await readFile(runtimePath)).toEqual(Buffer.from(runtime)) + }) + + it('downloads, verifies, extracts, and atomically caches the runtime', async () => { + const archive = new TextEncoder().encode('pinned archive') + const executable = new TextEncoder().encode('pinned bun executable') + extraction.executable = executable + Object.assign(ORCAD_BUN_RELEASE_ASSETS[TARGET], { + sha256: sha256(archive), + executableSha256: sha256(executable) + }) + const fetcher = responseFetcher(archive) + + const runtimePath = await materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher }) + + expect(await readFile(runtimePath)).toEqual(Buffer.from(executable)) + if (process.platform !== 'win32') { + expect((await stat(runtimePath)).mode & 0o111).toBe(0o111) + } + expect(fetcher).toHaveBeenCalledWith( + expect.stringContaining(`/bun-v${ORCAD_BUN_VERSION}/bun-linux-x64.zip`), + expect.objectContaining({ redirect: 'follow' }) + ) + expect((await readdir(join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET))).sort()).toEqual( + [ORCAD_BUN_RUNTIME_FILENAME] + ) + }) + + it('refuses an oversized declared archive before reading its body', async () => { + const fetcher = responseFetcher(new Uint8Array([1]), 200 * 1024 * 1024 + 1) + + await expect(materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher })).rejects.toThrow( + 'Bun download exceeded the archive size limit' + ) + await expect( + access(join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET, ORCAD_BUN_RUNTIME_FILENAME)) + ).rejects.toThrow() + expect(await readdir(join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET))).toEqual([]) + }) + + it('removes temporary data after an archive checksum mismatch', async () => { + const archive = new TextEncoder().encode('tampered archive') + const fetcher = responseFetcher(archive) + + await expect(materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher })).rejects.toThrow( + 'Bun archive checksum mismatch' + ) + expect(await readdir(join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET))).toEqual([]) + }) + + it('refuses an executable mismatch even when the archive matches its pin', async () => { + const archive = new TextEncoder().encode('pinned archive') + extraction.executable = new TextEncoder().encode('incorrect executable') + ORCAD_BUN_RELEASE_ASSETS[TARGET].sha256 = sha256(archive) + await expect( + materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher: responseFetcher(archive) }) + ).rejects.toThrow('Bun executable checksum mismatch') + expect(await readdir(join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET))).toEqual([]) + }) + + // Both cases spawn for real, so the assertion is against the errno Node actually reports: + // a missing program rejects asynchronously with ENOENT, while a program path whose parent is a + // regular file throws ENOTDIR synchronously out of `spawn` itself. + it.each([ + ['absent', (): string => join(cacheRoot, 'absent-extractor')], + ['unreachable through a file', (): string => join(cacheRoot, 'plain-file', 'unzip')] + ])('names the %s extractor and the override when it cannot be launched', async (_label, path) => { + const archive = new TextEncoder().encode('unextractable archive') + ORCAD_BUN_RELEASE_ASSETS[TARGET].sha256 = sha256(archive) + await writeFile(join(cacheRoot, 'plain-file'), 'not a directory') + const { runProcess: spawnForReal } = await vi.importActual<{ + runProcess: typeof runProcess + }>('../../shared/child-process/run-process') + vi.mocked(runProcess).mockImplementationOnce(spawnForReal) + vi.stubEnv('ORCA_UNZIP_BIN', path()) + + await expect( + materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher: responseFetcher(archive) }) + ).rejects.toThrow(/install unzip, or set ORCA_UNZIP_BIN/) + expect(await readdir(join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET))).toEqual([]) + }) + + it('cleans an aborted download before publishing any executable', async () => { + const controller = new AbortController() + controller.abort(new Error('deployment cancelled')) + await expect( + materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { + fetcher: responseFetcher(new Uint8Array([1])), + signal: controller.signal + }) + ).rejects.toThrow('deployment cancelled') + await expect(access(join(cacheRoot, 'bun'))).rejects.toThrow() + }) +}) + +it('publishes concurrent runtime downloads without removing or replacing the winning executable', async () => { + const archive = new TextEncoder().encode('pinned archive') + extraction.executable = new TextEncoder().encode('pinned runtime') + Object.assign(ORCAD_BUN_RELEASE_ASSETS[TARGET], { + sha256: sha256(archive), + executableSha256: sha256(extraction.executable) + }) + let finishSecond: (response: Response) => void = () => {} + const secondFetcher = vi.fn( + () => + new Promise((resolve) => { + finishSecond = resolve + }) + ) + const second = materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher: secondFetcher }) + await vi.waitFor(() => expect(secondFetcher).toHaveBeenCalledOnce()) + const firstPath = await materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { + fetcher: responseFetcher(archive) + }) + const firstIdentity = await stat(firstPath) + finishSecond(new Response(Buffer.from(archive))) + expect(await second).toBe(firstPath) + expect((await stat(firstPath)).ino).toBe(firstIdentity.ino) + expect(await readFile(firstPath)).toEqual(Buffer.from(extraction.executable)) +}) + +it('repairs a corrupt published runtime beside the old inode and reuses the repair', async () => { + const archive = new TextEncoder().encode('pinned archive') + extraction.executable = new TextEncoder().encode('pinned runtime') + Object.assign(ORCAD_BUN_RELEASE_ASSETS[TARGET], { + sha256: sha256(archive), + executableSha256: sha256(extraction.executable) + }) + const runtimeDir = join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET) + const runtimePath = join(runtimeDir, ORCAD_BUN_RUNTIME_FILENAME) + await mkdir(runtimeDir, { recursive: true }) + await writeFile(runtimePath, 'corrupt') + const fetcher = responseFetcher(archive) + const repaired = await materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher }) + expect(repaired).not.toBe(runtimePath) + expect(await readFile(repaired)).toEqual(Buffer.from(extraction.executable)) + expect(await materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher })).toBe(repaired) + expect(fetcher).toHaveBeenCalledOnce() + expect(await readFile(runtimePath, 'utf8')).toBe('corrupt') +}) + +it('uses the configured HTTP client for deployment downloads', async () => { + const archive = new TextEncoder().encode('proxy archive') + extraction.executable = new TextEncoder().encode('proxy runtime') + Object.assign(ORCAD_BUN_RELEASE_ASSETS[TARGET], { + sha256: sha256(archive), + executableSha256: sha256(extraction.executable) + }) + const fetcher = responseFetcher(archive) + setMainHttpClient({ fetch: fetcher, proxySession: () => null }) + await materializeCachedOrcadBunRuntime(TARGET, cacheRoot, {}) + expect(fetcher).toHaveBeenCalledOnce() +}) + +it('allows a progressing download to exceed two minutes', async () => { + vi.useFakeTimers() + const first = new TextEncoder().encode('first') + const second = new TextEncoder().encode('second') + extraction.executable = new TextEncoder().encode('slow runtime') + Object.assign(ORCAD_BUN_RELEASE_ASSETS[TARGET], { + sha256: sha256(Buffer.concat([first, second])), + executableSha256: sha256(extraction.executable) + }) + let stream: ReadableStreamDefaultController | undefined + let signal: AbortSignal | null | undefined + const fetcher = vi.fn(async (_url, options) => { + signal = options?.signal + return new Response( + new ReadableStream({ + start(controller) { + stream = controller + } + }) + ) + }) + const pending = materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher }) + await vi.waitFor(() => expect(fetcher).toHaveBeenCalledOnce()) + await vi.advanceTimersByTimeAsync(90_000) + stream!.enqueue(first) + await vi.waitFor(async () => { + const runtimeDir = join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET) + const temporary = (await readdir(runtimeDir)).find((entry) => entry.startsWith('.download-'))! + expect( + (await stat(join(runtimeDir, temporary, ORCAD_BUN_RELEASE_ASSETS[TARGET].filename))).size + ).toBe(first.length) + }) + await vi.advanceTimersByTimeAsync(90_000) + expect(signal?.aborted).toBe(false) + stream!.enqueue(second) + stream!.close() + expect(await readFile(await pending)).toEqual(Buffer.from(extraction.executable)) +}) + +it('aborts a stalled body and removes the unfinished download', async () => { + vi.useFakeTimers() + const cancel = vi.fn() + const fetcher = vi.fn(async () => new Response(new ReadableStream({ cancel }))) + const pending = materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher }) + const rejected = expect(pending).rejects.toThrow('Bun download stalled') + await vi.waitFor(() => expect(fetcher).toHaveBeenCalledOnce()) + await vi.advanceTimersByTimeAsync(120_000) + await rejected + expect(cancel).toHaveBeenCalledOnce() + expect(await readdir(join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET))).toEqual([]) +}) diff --git a/src/main/ssh/orcad-bun-runtime-materializer.ts b/src/main/ssh/orcad-bun-runtime-materializer.ts new file mode 100644 index 00000000000..e52a87534eb --- /dev/null +++ b/src/main/ssh/orcad-bun-runtime-materializer.ts @@ -0,0 +1,228 @@ +import { createHash, randomUUID } from 'node:crypto' +import { createReadStream, readdirSync } from 'node:fs' +import { chmod, link, mkdir, open, rm } from 'node:fs/promises' +import { basename, join } from 'node:path' +import { runProcess, type ProcessResult } from '../../shared/child-process/run-process' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { getZipExtractorCommand } from '../../shared/zip-extractor-command' +import { getMainHttpClient, type MainHttpClient } from '../network/http-client' +import { findOrcadCachePath } from './orcad-cache-path' +import { orcadBunRuntimeFilename } from '../../shared/orcad-artifacts' +import { + ORCAD_BUN_RELEASE_ASSETS, + ORCAD_BUN_VERSION, + orcadBunReleaseUrl, + type OrcadBunTarget +} from '../../shared/orcad-bun-runtime' + +const MAX_BUN_ARCHIVE_BYTES = 200 * 1024 * 1024 + +export type OrcadBunRuntimeMaterializeOptions = { + fetcher?: MainHttpClient['fetch'] + signal?: AbortSignal +} + +export async function materializeCachedOrcadBunRuntime( + target: OrcadBunTarget, + cacheRoot: string, + options: OrcadBunRuntimeMaterializeOptions +): Promise { + options.signal?.throwIfAborted() + const asset = ORCAD_BUN_RELEASE_ASSETS[target] + const runtimeDir = join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, target) + await mkdir(runtimeDir, { recursive: true }) + const runtime = await findOrcadCachePath( + (attempt) => + join(runtimeDir, `${attempt ? `repair-${attempt}-` : ''}${orcadBunRuntimeFilename(target)}`), + async (path) => (await fileSha256(path)) === asset.executableSha256 + ) + const runtimePath = runtime.path + if (runtime.verified) { + if (!target.startsWith('win32-')) { + await chmod(runtimePath, 0o755) + } + return runtimePath + } + const temporaryDir = join(runtimeDir, `.download-${process.pid}-${randomUUID()}`) + await mkdir(temporaryDir, { recursive: true }) + try { + const archivePath = join(temporaryDir, basename(asset.filename)) + await downloadVerifiedArchive( + orcadBunReleaseUrl(asset), + archivePath, + asset.sha256, + options.fetcher ?? getMainHttpClient().fetch, + options.signal + ) + options.signal?.throwIfAborted() + const extractedDir = join(temporaryDir, 'extracted') + await mkdir(extractedDir) + const result = await extractArchive(archivePath, extractedDir, options.signal) + options.signal?.throwIfAborted() + if (result.code !== 0) { + throw new Error(`Bun archive extraction failed: ${result.stderr || result.stdout}`) + } + const executable = findExtractedBun(extractedDir, target) + await verifyFileSha256(executable, asset.executableSha256, `${target} Bun executable`) + if (!target.startsWith('win32-')) { + await chmod(executable, 0o755) + } + options.signal?.throwIfAborted() + try { + await link(executable, runtimePath) + } catch (error) { + if ((await fileSha256(runtimePath)) !== asset.executableSha256) { + throw new Error(`Bun runtime cache entry is unavailable or corrupted: ${runtimePath}`, { + cause: error + }) + } + } + await verifyFileSha256(runtimePath, asset.executableSha256, `${target} cached Bun executable`) + return runtimePath + } finally { + await rm(temporaryDir, { recursive: true, force: true }) + } +} + +/** + * Why the extractor needs a message of its own: `unzip` is absent from a minimal POSIX install, + * and a bare `spawn unzip ENOENT` names neither the missing tool nor the override. A misconfigured + * `ORCA_UNZIP_BIN` whose parent is a file reports ENOTDIR instead, which is the same verdict. + * + * The errno is the program path's, not the caller's: `runProcess` leaves cwd unset, so the child + * inherits the parent's without resolving it. Measured on macOS, Linux and Windows — spawn still + * succeeds from a deleted cwd, even though `process.cwd()` itself throws ENOENT there. + */ +async function extractArchive( + archivePath: string, + extractDir: string, + signal?: AbortSignal +): Promise { + const command = getZipExtractorCommand(archivePath, extractDir) + try { + return await runProcess({ + program: command.file, + args: command.args, + timeoutMs: 120_000, + signal + }) + } catch (error) { + if (isDefinitiveAbsence(error)) { + throw new Error( + `Bun archive extraction could not run ${command.file}: install ${command.label}, ` + + 'or set ORCA_UNZIP_BIN to an unzip-compatible extractor.', + { cause: error } + ) + } + throw error + } +} + +async function downloadVerifiedArchive( + url: string, + destination: string, + expectedSha256: string, + fetcher: MainHttpClient['fetch'], + signal?: AbortSignal +): Promise { + const stall = new AbortController() + const downloadSignal = signal ? AbortSignal.any([signal, stall.signal]) : stall.signal + const stallTimer = setTimeout(() => stall.abort(new Error('Bun download stalled')), 120_000) + try { + const response = await fetcher(url, { redirect: 'follow', signal: downloadSignal }) + if (!response.ok || !response.body) { + await response.body?.cancel().catch(() => undefined) + throw new Error(`Bun download failed: ${response.status} ${response.statusText}`) + } + const declaredLength = Number(response.headers.get('content-length')) + if (Number.isFinite(declaredLength) && declaredLength > MAX_BUN_ARCHIVE_BYTES) { + await response.body.cancel().catch(() => undefined) + throw new Error('Bun download exceeded the archive size limit') + } + const handle = await open(destination, 'wx', 0o600).catch(async (error) => { + await response.body?.cancel().catch(() => undefined) + throw error + }) + const reader = response.body.getReader() + const hash = createHash('sha256') + let total = 0 + try { + for (;;) { + downloadSignal.throwIfAborted() + const chunk = await waitForPromiseWithSignal(reader.read(), downloadSignal) + if (chunk.done) { + break + } + if (chunk.value.byteLength > 0) { + stallTimer.refresh() + } + total += chunk.value.byteLength + if (total > MAX_BUN_ARCHIVE_BYTES) { + throw new Error('Bun download exceeded the archive size limit') + } + hash.update(chunk.value) + await writeAll(handle, chunk.value) + } + } catch (error) { + await reader.cancel().catch(() => undefined) + throw error + } finally { + await handle.close() + } + const actual = hash.digest('hex') + if (actual !== expectedSha256) { + throw new Error(`Bun archive checksum mismatch: expected ${expectedSha256}, got ${actual}`) + } + } finally { + clearTimeout(stallTimer) + } +} + +async function writeAll( + handle: Awaited>, + bytes: Uint8Array +): Promise { + let offset = 0 + while (offset < bytes.byteLength) { + const { bytesWritten } = await handle.write(bytes, offset, bytes.byteLength - offset) + if (bytesWritten === 0) { + throw new Error('Bun archive write made no progress') + } + offset += bytesWritten + } +} + +function findExtractedBun(root: string, target: OrcadBunTarget): string { + const expected = target.startsWith('win32-') ? 'bun.exe' : 'bun' + const entry = readdirSync(root, { recursive: true, withFileTypes: true }).find( + (candidate) => candidate.isFile() && candidate.name === expected + ) + if (!entry) { + throw new Error(`Downloaded Bun archive contained no ${expected}`) + } + return join(entry.parentPath, entry.name) +} + +export async function verifyFileSha256( + path: string, + expected: string, + label: string +): Promise { + const actual = await fileSha256(path) + if (actual !== expected) { + throw new Error(`${label} checksum mismatch: expected ${expected}, got ${actual ?? 'missing'}`) + } +} + +export async function fileSha256(path: string): Promise { + try { + const hash = createHash('sha256') + for await (const chunk of createReadStream(path)) { + hash.update(chunk) + } + return hash.digest('hex') + } catch { + return null + } +} diff --git a/src/main/ssh/orcad-cache-path.ts b/src/main/ssh/orcad-cache-path.ts new file mode 100644 index 00000000000..2b31f634642 --- /dev/null +++ b/src/main/ssh/orcad-cache-path.ts @@ -0,0 +1,22 @@ +import { lstat } from 'node:fs/promises' + +/** Recover beside corrupt entries; a published path may still belong to another reader. */ +export async function findOrcadCachePath( + candidate: (attempt: number) => string, + isValid: (path: string) => Promise +): Promise<{ path: string; verified: boolean }> { + for (let attempt = 0; ; attempt++) { + const path = candidate(attempt) + if (await isValid(path)) { + return { path, verified: true } + } + try { + await lstat(path) + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return { path, verified: false } + } + throw error + } + } +} diff --git a/src/main/ssh/orcad-deployment-target.test.ts b/src/main/ssh/orcad-deployment-target.test.ts new file mode 100644 index 00000000000..db5915e659c --- /dev/null +++ b/src/main/ssh/orcad-deployment-target.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { parseOrcadLinuxLibc, resolveOrcadDeploymentTarget } from './orcad-deployment-target' +import { SshConnection } from './ssh-connection' +import { createCallbacks, createTarget } from './ssh-connection-test-fixtures' +import { execCommand } from './ssh-relay-deploy-helpers' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn() })) +beforeEach(() => vi.mocked(execCommand).mockReset()) + +describe('deployment C library selection', () => { + it.each([ + ['glibc 2.31', 'linux-x64-glibc'], + ['musl', 'linux-x64-musl'] + ])('uses host fallback evidence %j when ldd is unavailable', async (evidence, target) => { + vi.mocked(execCommand).mockResolvedValueOnce('ldd: not found').mockResolvedValueOnce(evidence) + const conn = new SshConnection(createTarget(), createCallbacks()) + await expect( + resolveOrcadDeploymentTarget({ conn, host: getRemoteHostPlatform('linux-x64') }) + ).resolves.toBe(target) + expect(execCommand).toHaveBeenLastCalledWith( + conn, + expect.stringContaining('getconf GNU_LIBC_VERSION'), + expect.anything() + ) + }) + + it('never guesses when neither probe identifies the host library', async () => { + vi.mocked(execCommand).mockResolvedValue('') + await expect( + resolveOrcadDeploymentTarget({ + conn: new SshConnection(createTarget(), createCallbacks()), + host: getRemoteHostPlatform('linux-x64') + }) + ).rejects.toThrow('Could not identify') + }) + + it('checks connection ownership again before the fallback probe', async () => { + const conn = new SshConnection(createTarget(), createCallbacks()) + const generation = conn.getConnectGeneration() + const firstProbe = Promise.withResolvers() + vi.mocked(execCommand).mockReturnValueOnce(firstProbe.promise) + const exec = vi.fn(async (command: string) => { + if (conn.getConnectGeneration() !== generation) { + throw new Error('SSH connection changed during SQLite runtime setup.') + } + return execCommand(conn, command) + }) + const pending = resolveOrcadDeploymentTarget({ + conn, + host: getRemoteHostPlatform('linux-x64'), + exec + }) + expect(execCommand).toHaveBeenCalledOnce() + + await conn.disconnect() + firstProbe.resolve('ldd: not found') + + await expect(pending).rejects.toThrow('SSH connection changed') + expect(exec).toHaveBeenLastCalledWith(expect.stringContaining('getconf GNU_LIBC_VERSION')) + expect(execCommand).toHaveBeenCalledOnce() + }) + + it.each([ + ['ldd (Ubuntu GLIBC 2.31-0ubuntu9) 2.31', 'glibc'], + ['ldd (GNU libc) 2.28', 'glibc'], + ['musl libc (x86_64)\nVersion 1.2.5', 'musl'] + ])('recognizes %s', (output, expected) => { + expect(parseOrcadLinuxLibc(output)).toBe(expected) + }) + + it.each(['', 'ldd: command not found', 'Linux x86_64'])( + 'refuses unproven target %j', + (output) => { + expect(() => parseOrcadLinuxLibc(output)).toThrow('Could not identify') + } + ) +}) diff --git a/src/main/ssh/orcad-deployment-target.ts b/src/main/ssh/orcad-deployment-target.ts new file mode 100644 index 00000000000..9fd98fa8cfb --- /dev/null +++ b/src/main/ssh/orcad-deployment-target.ts @@ -0,0 +1,39 @@ +import type { OrcadBunTarget } from '../../shared/orcad-bun-runtime' +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import type { RemoteHostPlatform } from './ssh-remote-platform' + +export function parseOrcadLinuxLibc(output: string): 'glibc' | 'musl' { + if (/\bmusl\b/i.test(output)) { + return 'musl' + } + if (/\b(?:glibc|GNU libc|GNU C Library)\b/i.test(output)) { + return 'glibc' + } + throw new Error('Could not identify the host C library for the bundled Orca runtime') +} + +export async function resolveOrcadDeploymentTarget(options: { + conn: SshConnection + host: RemoteHostPlatform + signal?: AbortSignal + exec?: (command: string) => Promise +}): Promise { + const { host } = options + if (host.os !== 'linux') { + return `${host.os}-${host.arch}` + } + const exec = + options.exec ?? + ((command: string) => execCommand(options.conn, command, { signal: options.signal })) + let output = await exec('ldd --version 2>&1 || true') + try { + return `linux-${host.arch}-${parseOrcadLinuxLibc(output)}` + } catch { + output = await exec( + 'getconf GNU_LIBC_VERSION 2>/dev/null || ' + + 'for loader in /lib/ld-musl-*.so.1; do [ ! -e "$loader" ] || { echo musl; break; }; done' + ) + } + return `linux-${host.arch}-${parseOrcadLinuxLibc(output)}` +} diff --git a/src/main/ssh/orcad-remote-deploy-stop.ts b/src/main/ssh/orcad-remote-deploy-stop.ts new file mode 100644 index 00000000000..8a00a1dfa03 --- /dev/null +++ b/src/main/ssh/orcad-remote-deploy-stop.ts @@ -0,0 +1,68 @@ +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { ORCAD_INSTALL_MODEL } from './remote-install-model' +import { computeRemoteInstallDir } from './ssh-relay-versioned-install' +import { + parseOrcadStopOutcome, + stopOrcadCommand, + type OrcadStopOutcome +} from './orcad-remote-process-control' +import type { RemoteHostPlatform } from './ssh-remote-platform' +import { compareOrcadStateSnapshotCommand, orcadSnapshotIsUnchanged } from './orcad-state-snapshot' + +export type OrcadOutgoingStopOptions = { + conn: SshConnection + host: RemoteHostPlatform + remoteHome: string + nodePath: string + signal?: AbortSignal +} + +/** Stop the outgoing runtime and return its execution-host verdict. */ +export async function stopOutgoingOrcad( + options: OrcadOutgoingStopOptions, + outgoingVersion: string +): Promise { + const outgoingDir = computeRemoteInstallDir( + ORCAD_INSTALL_MODEL, + options.remoteHome, + outgoingVersion + ) + const output = await execCommand( + options.conn, + stopOrcadCommand(options.host, outgoingDir, { waitSeconds: 20, nodePath: options.nodePath }), + { + wrapCommand: options.host.commandDialect !== 'powershell', + signal: options.signal + } + ) + return parseOrcadStopOutcome(output) +} + +/** The caller must confirm candidate exit before inspecting its shared state. */ +export async function rejectedOrcadStateRecoveryRefusal( + options: OrcadOutgoingStopOptions & { userDataDir: string }, + incumbentVersion: string, + snapshotDir: string | undefined +): Promise { + const unchanged = snapshotDir + ? orcadSnapshotIsUnchanged( + await execCommand( + options.conn, + compareOrcadStateSnapshotCommand(options.host, options.userDataDir, snapshotDir), + { wrapCommand: options.host.commandDialect !== 'powershell', signal: options.signal } + ).catch(() => '') + ) + : false + if (unchanged) { + return undefined + } + // RPC was already exposed; a prelaunch census cannot authorize discarding candidate writes. + const retainedSnapshot = snapshotDir ? ` at ${snapshotDir}.` : ', which is unavailable.' + return ( + 'The candidate is stopped, but profile state changed or could not be verified. ' + + `orcad ${incumbentVersion} was not restarted against potentially incompatible state. ` + + 'Current state and daemon terminals are preserved; recovery requires a fresh host ' + + `terminal census before restoring the prelaunch snapshot${retainedSnapshot}` + ) +} diff --git a/src/main/ssh/orcad-remote-deploy.test.ts b/src/main/ssh/orcad-remote-deploy.test.ts index 1d9f87b009d..38b9fc951f9 100644 --- a/src/main/ssh/orcad-remote-deploy.test.ts +++ b/src/main/ssh/orcad-remote-deploy.test.ts @@ -1,4 +1,8 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn(), @@ -21,18 +25,23 @@ import { execCommand } from './ssh-relay-deploy-helpers' import { acquireInstallLock } from './ssh-relay-install-lock' import { uploadRelayDirectory, writeRelayFile } from './ssh-relay-install-transfers' import { deployOrcad, type OrcadDeployOptions } from './orcad-remote-deploy' +import { installOrcadBundle } from './orcad-remote-install' +import { + abandonInstall, + finalizeInstall, + isRemoteInstallComplete +} from './ssh-relay-versioned-install' import { emptyOrcadActivationRecord, withActivatedVersion } from './orcad-activation-record' import { getRemoteHostPlatform } from './ssh-remote-platform' -import { finalizeInstall } from './ssh-relay-versioned-install' import type { SshConnection } from './ssh-connection' const mockExec = vi.mocked(execCommand) -const NEW_VERSION = '0.2.0+bb01' +const NEW_VERSION = '0.2.0+bb0100000000' const OLD_VERSION = '0.1.0+aa01' vi.mock('./ssh-relay-versioned-install', async (importOriginal) => ({ ...(await importOriginal>()), - readLocalFullVersion: () => '0.2.0+bb01', + readLocalFullVersion: () => '0.2.0+bb0100000000', isRemoteInstallComplete: vi.fn().mockResolvedValue(false), finalizeInstall: vi.fn().mockResolvedValue(undefined), abandonInstall: vi.fn().mockResolvedValue(undefined) @@ -82,6 +91,11 @@ type HostScript = { /** Readiness content per version dir, keyed by the version in the path. */ readiness: Record log: string[] + preflightResult?: string + snapshotResult?: string + comparisonResult?: string + candidateStopResult?: string + readinessAtMs?: number } function scriptHost(script: HostScript): void { @@ -91,20 +105,42 @@ function scriptHost(script: HostScript): void { return script.activationRecord } if (text.includes('.orcad-readiness') && text.startsWith('cat ')) { + if (script.readinessAtMs !== undefined && Date.now() < script.readinessAtMs) { + return '' + } const version = Object.keys(script.readiness).find((v) => text.includes(v)) return version ? script.readiness[version] : '' } + if (text.includes('--orcad-profile-state-preflight')) { + script.log.push('preflight') + return ( + script.preflightResult ?? + JSON.stringify({ + type: 'orca_profile_state_ready', + nonce: text.match(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/)?.[0], + runtime: 'bun', + runtimeVersion: '1.4.2', + sqliteVersion: '3.51.0', + artifactVersion: NEW_VERSION, + revision: 1 + }) + ) + } if (text.includes('nohup')) { script.log.push(`launch:${text.includes(NEW_VERSION) ? NEW_VERSION : OLD_VERSION}`) return '9999' } if (text.includes('kill -TERM')) { script.log.push(`stop:${text.includes(NEW_VERSION) ? NEW_VERSION : OLD_VERSION}`) - return 'STOPPED' + return text.includes(NEW_VERSION) ? (script.candidateStopResult ?? 'STOPPED') : 'STOPPED' } if (text.includes('tar -C') && text.includes('-cf')) { script.log.push('snapshot') - return 'CAPTURED' + return script.snapshotResult ?? 'CAPTURED' + } + if (text.includes('verdict=UNCHANGED')) { + script.log.push('compare-state') + return script.comparisonResult ?? 'UNCHANGED' } return '' }) @@ -132,7 +168,105 @@ const ACTIVE_OLD = JSON.stringify( withActivatedVersion(emptyOrcadActivationRecord(), OLD_VERSION, null, new Date(0)) ) +describe('orcad install lock ownership', () => { + const remoteDir = `/home/u/.orca-remote/orcad-${NEW_VERSION}` + const install = (signal?: AbortSignal) => + installOrcadBundle( + { ...options({ signal }), localOrcadDir: '/local/out/orcad' }, + NEW_VERSION, + remoteDir + ) + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(isRemoteInstallComplete).mockReset().mockResolvedValue(false) + }) + + it('leaves another install lock alone when the initial probe is complete', async () => { + vi.mocked(isRemoteInstallComplete).mockResolvedValueOnce(true) + await install() + expect(acquireInstallLock).not.toHaveBeenCalled() + expect(abandonInstall).not.toHaveBeenCalled() + expect(uploadRelayDirectory).not.toHaveBeenCalled() + }) + + it('releases its lock when another installer completed while acquisition waited', async () => { + vi.mocked(isRemoteInstallComplete).mockResolvedValueOnce(false).mockResolvedValueOnce(true) + await install() + expect(acquireInstallLock).toHaveBeenCalledOnce() + expect(abandonInstall).toHaveBeenCalledOnce() + expect(abandonInstall).toHaveBeenCalledWith(expect.anything(), remoteDir, options().host) + expect(uploadRelayDirectory).not.toHaveBeenCalled() + expect(finalizeInstall).not.toHaveBeenCalled() + }) + + it('publishes a complete install before releasing its lock once', async () => { + await install() + expect(uploadRelayDirectory).toHaveBeenCalledOnce() + expect(finalizeInstall).toHaveBeenCalledWith(expect.anything(), remoteDir, options().host, { + signal: undefined, + releaseLock: false + }) + expect(abandonInstall).toHaveBeenCalledOnce() + expect(vi.mocked(finalizeInstall).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(abandonInstall).mock.invocationCallOrder[0] + ) + }) + + it('releases a failed upload without publishing it or replacing its error', async () => { + const error = new Error('upload interrupted') + vi.mocked(uploadRelayDirectory).mockRejectedValueOnce(error) + await expect(install()).rejects.toBe(error) + expect(abandonInstall).toHaveBeenCalledOnce() + expect(finalizeInstall).not.toHaveBeenCalled() + }) + + it('releases its lock without reusing an aborted operation signal', async () => { + const controller = new AbortController() + const error = new Error('deployment canceled') + vi.mocked(uploadRelayDirectory).mockImplementationOnce(async () => { + controller.abort(error) + controller.signal.throwIfAborted() + }) + await expect(install(controller.signal)).rejects.toBe(error) + expect(abandonInstall).toHaveBeenCalledOnce() + expect(abandonInstall).toHaveBeenCalledWith(expect.anything(), remoteDir, options().host) + expect(finalizeInstall).not.toHaveBeenCalled() + }) + + it('does not release a lock when acquisition failed', async () => { + const error = new Error('lock held by another client') + vi.mocked(acquireInstallLock).mockRejectedValueOnce(error) + await expect(install()).rejects.toBe(error) + expect(abandonInstall).not.toHaveBeenCalled() + expect(uploadRelayDirectory).not.toHaveBeenCalled() + }) +}) + describe('deployOrcad', () => { + it.each(['', '{"type":"orca_profile_state_ready","revision":0}'])( + 'leaves the incumbent and shared state alone when preflight returns %j', + async (preflightResult) => { + const script: HostScript = { + activationRecord: ACTIVE_OLD, + readiness: { [NEW_VERSION]: readyLine({}) }, + log: [], + preflightResult + } + scriptHost(script) + expect(await deployOrcad(options())).toMatchObject({ + outcome: 'installed-not-activated', + code: 'orcad_candidate_preflight_failed' + }) + expect(script.log).toEqual(['preflight']) + expect( + vi + .mocked(writeRelayFile) + .mock.calls.some(([, , path]) => path.includes('orcad-active.json')) + ).toBe(false) + } + ) + beforeEach(() => { vi.clearAllMocks() }) @@ -166,6 +300,7 @@ describe('deployOrcad', () => { ) expect(chmod).toBeGreaterThanOrEqual(0) expect(mockExec.mock.calls[chmod]?.[1]).toContain(`/ripgrep/${platform}/rg'`) + expect(mockExec.mock.calls[chmod]?.[1]).toContain("/bun-runtime'") expect(vi.mocked(uploadRelayDirectory).mock.invocationCallOrder[0]).toBeLessThan( mockExec.mock.invocationCallOrder[chmod] ) @@ -177,13 +312,17 @@ describe('deployOrcad', () => { it('does not run chmod on a Windows remote', async () => { scriptHost({ activationRecord: '', readiness: {}, log: [] }) - await deployOrcad( - options({ + await installOrcadBundle( + { + conn: options().conn, host: getRemoteHostPlatform('win32-x64'), - remoteHome: 'C:/Users/u', - census: { liveSessions: 1, startedSinceActivation: 0 } - }) + localOrcadDir: '/local/out/orcad' + }, + NEW_VERSION, + `C:/Users/u/.orca-remote/orcad-${NEW_VERSION}` ) + expect(uploadRelayDirectory).toHaveBeenCalledOnce() + expect(finalizeInstall).toHaveBeenCalledOnce() expect(mockExec.mock.calls.some(([, command]) => String(command).startsWith('chmod '))).toBe( false ) @@ -200,6 +339,73 @@ describe('deployOrcad', () => { expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() }) + it.skipIf(process.platform === 'win32').each([undefined, 'linux-x64', 'linux-musl-x64'])( + 'restores uploaded executable modes with optional browser %s', + async (browserTarget) => { + const directory = mkdtempSync(join(tmpdir(), 'orcad-install-modes-')) + const binaries = ['bun-runtime', 'ripgrep/linux-x64/rg'] + if (browserTarget) { + binaries.push(`agent-browser-${browserTarget}`) + } + try { + for (const filename of binaries) { + const path = join(directory, filename) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, 'uploaded executable') + chmodSync(path, 0o644) + } + mockExec.mockImplementation(async (_conn, command) => { + const result = await runProcess({ program: '/bin/sh', args: ['-c', command] }) + if (result.code !== 0) { + throw new Error(result.stderr) + } + return result.stdout + }) + await installOrcadBundle( + { + conn: options().conn, + host: getRemoteHostPlatform('linux-x64'), + localOrcadDir: directory + }, + NEW_VERSION, + directory + ) + expect(finalizeInstall).toHaveBeenCalledOnce() + for (const filename of binaries) { + expect(statSync(join(directory, filename)).mode & 0o777).toBe(0o755) + } + } finally { + mockExec.mockReset() + rmSync(directory, { recursive: true, force: true }) + } + } + ) + + it('allows startup time after a slow bundled preflight', async () => { + let elapsedMs = 0 + const clock = vi.spyOn(Date, 'now').mockImplementation(() => elapsedMs) + scriptHost({ + activationRecord: '', + readiness: { [NEW_VERSION]: readyLine({}) }, + readinessAtMs: 100_000, + log: [] + }) + try { + const result = await deployOrcad( + options({ + readinessTimeoutMs: undefined, + sleep: async () => { + elapsedMs += 50_000 + } + }) + ) + expect(result.outcome).toBe('installed-and-activated') + expect(elapsedMs).toBe(100_000) + } finally { + clock.mockRestore() + } + }) + it('activates a healthy candidate and records the outgoing version as the rollback target', async () => { const script: HostScript = { activationRecord: ACTIVE_OLD, @@ -218,7 +424,7 @@ describe('deployOrcad', () => { }) }) - it('snapshots the shared data root before the candidate ever runs', async () => { + it('stops the incumbent before snapshotting, so SQLite WAL files are quiescent', async () => { const script: HostScript = { activationRecord: ACTIVE_OLD, readiness: { [NEW_VERSION]: readyLine({}) }, @@ -228,6 +434,7 @@ describe('deployOrcad', () => { await deployOrcad(options()) expect(script.log.indexOf('snapshot')).toBeGreaterThan(-1) expect(script.log.indexOf('snapshot')).toBeLessThan(script.log.indexOf(`launch:${NEW_VERSION}`)) + expect(script.log.indexOf(`stop:${OLD_VERSION}`)).toBeLessThan(script.log.indexOf('snapshot')) }) it('installs but does not activate when terminals are running', async () => { @@ -278,10 +485,12 @@ describe('deployOrcad', () => { const result = await deployOrcad(options()) expect(result).toMatchObject({ outcome: 'installed-not-activated' }) expect(script.log).toEqual([ - 'snapshot', + 'preflight', `stop:${OLD_VERSION}`, + 'snapshot', `launch:${NEW_VERSION}`, `stop:${NEW_VERSION}`, + 'compare-state', `launch:${OLD_VERSION}` ]) expect(result.outcome === 'installed-not-activated' && result.reason).toContain( @@ -289,6 +498,81 @@ describe('deployOrcad', () => { ) }) + it.each(['CHANGED', 'UNKNOWN', ''])( + 'preserves rejected candidate state when comparison is %s', + async (comparisonResult) => { + const script: HostScript = { + activationRecord: ACTIVE_OLD, + readiness: { [NEW_VERSION]: readyLine({ selfTestOk: false }) }, + log: [], + comparisonResult + } + scriptHost(script) + + const result = await deployOrcad(options()) + + expect(result).toMatchObject({ outcome: 'installed-not-activated' }) + expect(script.log).toEqual([ + 'preflight', + `stop:${OLD_VERSION}`, + 'snapshot', + `launch:${NEW_VERSION}`, + `stop:${NEW_VERSION}`, + 'compare-state' + ]) + expect(result.outcome === 'installed-not-activated' && result.reason).toContain( + 'recovery requires a fresh host terminal census' + ) + expect(result.outcome === 'installed-not-activated' && result.reason).toContain( + '/home/u/.orca-remote/orcad-state-snapshots/' + ) + expect(mockExec.mock.calls.some(([, command]) => command.includes('echo RESTORED'))).toBe( + false + ) + } + ) + + it('restarts the incumbent when a quiescent snapshot cannot be captured', async () => { + const script: HostScript = { + activationRecord: ACTIVE_OLD, + readiness: { [OLD_VERSION]: readyLine({}) }, + log: [], + snapshotResult: 'tar: write failed' + } + scriptHost(script) + + await expect(deployOrcad(options())).rejects.toThrow('incumbent was stopped') + expect(script.log).toEqual([ + 'preflight', + `stop:${OLD_VERSION}`, + 'snapshot', + `launch:${OLD_VERSION}` + ]) + }) + + it.each(['NO_PID', 'STILL_RUNNING', 'SIGNAL_FAILED', ''])( + 'does not inspect or replace state without confirmed candidate exit: %s', + async (candidateStopResult) => { + const script: HostScript = { + activationRecord: ACTIVE_OLD, + readiness: { [NEW_VERSION]: readyLine({ selfTestOk: false }) }, + log: [], + candidateStopResult + } + scriptHost(script) + + await deployOrcad(options()) + + expect(script.log).toEqual([ + 'preflight', + `stop:${OLD_VERSION}`, + 'snapshot', + `launch:${NEW_VERSION}`, + `stop:${NEW_VERSION}` + ]) + } + ) + it('refuses to activate when a different build answered the port', async () => { const script: HostScript = { activationRecord: ACTIVE_OLD, diff --git a/src/main/ssh/orcad-remote-deploy.ts b/src/main/ssh/orcad-remote-deploy.ts index 88a4d923c84..f82b4014d57 100644 --- a/src/main/ssh/orcad-remote-deploy.ts +++ b/src/main/ssh/orcad-remote-deploy.ts @@ -1,30 +1,14 @@ /** - * Installing orcad on a host and, only if it proves itself, making it the active one. - * - * The install half is the relay's transaction, parameterized: the same per-version lock, - * staged SFTP write, `.install-complete` sentinel and stale-lock recovery, under - * `orcad-/` instead of `relay-/`. That is what §02 marks reusable. - * - * The activation half has no relay equivalent, because the relay has no notion of a version - * being *selected*. Bytes landing in a versioned directory neither picks a version nor rolls - * one back; the activation record does, and it is written only after the candidate publishes - * a health payload that survives `evaluateOrcadActivation`. A rejected candidate leaves the - * previous version running and its own bytes on disk — nothing is lost, and a retry costs no - * upload. + * Activate installed bytes only after the candidate proves healthy. A rejected candidate + * allows restarting the incumbent only when profile state is provably unchanged; otherwise + * preserve current state and the prelaunch snapshot for explicit recovery. */ import type { SshConnection } from './ssh-connection' +import { ORCAD_STARTUP_READINESS_TIMEOUT_MS } from '../../shared/orcad-profile-preflight' import { execCommand } from './ssh-relay-deploy-helpers' -import { shellEscape } from './ssh-connection-utils' import { ORCAD_INSTALL_MODEL } from './remote-install-model' -import { acquireInstallLock } from './ssh-relay-install-lock' -import { uploadRelayDirectory, writeRelayFile } from './ssh-relay-install-transfers' -import { - abandonInstall, - computeRemoteInstallDir, - finalizeInstall, - isRemoteInstallComplete, - readLocalFullVersion -} from './ssh-relay-versioned-install' +import { writeRelayFile } from './ssh-relay-install-transfers' +import { computeRemoteInstallDir, readLocalFullVersion } from './ssh-relay-versioned-install' import { RELAY_REMOTE_DIR } from './relay-protocol' import { ORCAD_STATE_SNAPSHOT_DIR, @@ -40,9 +24,9 @@ import { ORCAD_LOG_FILENAME, orcadLaunchCommand, parseOrcadReadinessOutput, - readOrcadReadinessCommand, - type OrcadLaunchSpec + readOrcadReadinessCommand } from './orcad-remote-launch' +import { rejectedOrcadStateRecoveryRefusal, stopOutgoingOrcad } from './orcad-remote-deploy-stop' import { captureOrcadStateSnapshotCommand, orcadSnapshotDirName, @@ -55,13 +39,18 @@ import { } from './orcad-remote-process-control' import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' import { computeLocalOrcadBuildHash } from './orcad-local-build-hash' +import { preflightInstalledOrcad } from './orcad-remote-preflight' +import { assertPosixOrcadHost } from './orcad-remote-host-support' +import { installOrcadBundle } from './orcad-remote-install' +import { materializeOrcadArtifact } from './orcad-artifact-materializer' +import { resolveOrcadDeploymentTarget } from './orcad-deployment-target' export type OrcadDeployOptions = { conn: SshConnection host: RemoteHostPlatform remoteHome: string - /** Local `out/orcad`, containing the artifacts and the `.version` marker. */ - localOrcadDir: string + /** An already assembled bundle; otherwise materialize the packaged template for this host. */ + localOrcadDir?: string nodePath: string userDataDir: string bindHost: string @@ -84,18 +73,13 @@ export type OrcadDeployResult = | { outcome: 'already-active'; fullVersion: string } | { outcome: 'installed-not-activated'; fullVersion: string; code: string; reason: string } -const DEFAULT_READINESS_TIMEOUT_MS = 90_000 const READINESS_POLL_MS = 500 const STOP_WAIT_SECONDS = 20 -function exec( - options: OrcadDeployOptions, - command: string, - signal = options.signal -): Promise { +function exec(options: OrcadDeployOptions, command: string): Promise { return execCommand(options.conn, command, { wrapCommand: options.host.commandDialect !== 'powershell', - signal + signal: options.signal }) } @@ -103,59 +87,14 @@ function baseDir(options: OrcadDeployOptions): string { return joinRemotePath(options.host, options.remoteHome, RELAY_REMOTE_DIR) } -/** Install the bytes under `orcad-/`, using the relay's install transaction. */ -async function installOrcadBundle( - options: OrcadDeployOptions, - fullVersion: string, - remoteDir: string -): Promise { - if ( - await isRemoteInstallComplete(options.conn, ORCAD_INSTALL_MODEL, remoteDir, options.host, { - signal: options.signal - }) - ) { - return - } - await acquireInstallLock(options.conn, remoteDir, options.host, { signal: options.signal }) - try { - // Re-probe under the lock: a sibling deploy may have finished while we waited. - if ( - await isRemoteInstallComplete(options.conn, ORCAD_INSTALL_MODEL, remoteDir, options.host, { - signal: options.signal - }) - ) { - return - } - await uploadRelayDirectory(options.conn, options.localOrcadDir, remoteDir, options.host, { - signal: options.signal - }) - const { host } = options - if (host.os !== 'win32') { - // SFTP creates uploaded files with 0644 even when the source binary is executable. - const binaryPath = joinRemotePath(host, remoteDir, 'ripgrep', host.relayPlatform, 'rg') - await exec(options, `chmod 755 ${shellEscape(binaryPath)}`) - } - await writeRelayFile( - options.conn, - options.host, - joinRemotePath(options.host, remoteDir, ORCAD_INSTALL_MODEL.versionFilename), - fullVersion, - { signal: options.signal } - ) - await finalizeInstall(options.conn, remoteDir, options.host, { signal: options.signal }) - } catch (error) { - // Leave a recoverable partial rather than a dir that probes complete. - await abandonInstall(options.conn, remoteDir, options.host) - throw error - } -} - async function captureSnapshot( options: OrcadDeployOptions, fullVersion: string, outgoingVersion: string | null, takenAt: Date ): Promise { + // The caller has already stopped the outgoing runtime. This is required once profile state + // includes SQLite: a tar of a live WAL, main database, and SHM file is not a SQLite backup. const dirName = orcadSnapshotDirName(fullVersion, takenAt.getTime()) const snapshotDir = joinRemotePath( options.host, @@ -176,9 +115,8 @@ async function captureSnapshot( 'way back. Refusing to activate.' ) } + // Empty profiles need no rollback snapshot. if (capture === 'empty') { - // Nothing on the host to lose: a first deployment. Rollback will correctly report that - // it has no snapshot, rather than restoring an archive of nothing over a populated root. return null } return { @@ -191,16 +129,20 @@ async function captureSnapshot( async function launchAndAwaitReadiness( options: OrcadDeployOptions, - spec: OrcadLaunchSpec + remoteInstallDir: string, + fullVersion: string ): Promise> { - await exec(options, orcadLaunchCommand(options.host, spec)) - const deadline = Date.now() + (options.readinessTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS) + await exec( + options, + orcadLaunchCommand(options.host, { ...options, remoteInstallDir, fullVersion }) + ) + const deadline = Date.now() + (options.readinessTimeoutMs ?? ORCAD_STARTUP_READINESS_TIMEOUT_MS) const sleep = options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))) let last = parseOrcadReadinessOutput('') while (Date.now() < deadline) { options.signal?.throwIfAborted() last = parseOrcadReadinessOutput( - await exec(options, readOrcadReadinessCommand(options.host, spec.remoteInstallDir)) + await exec(options, readOrcadReadinessCommand(options.host, remoteInstallDir)) ) if (last.state !== 'pending') { return last @@ -210,58 +152,61 @@ async function launchAndAwaitReadiness( return last } -/** - * Put the previous version back after a rejected candidate. - * - * Why this exists at all: activating means swapping which process owns the data root and the - * port, so the incumbent has to stop before the candidate can start. A gate that rejected - * and returned would leave the host with nothing running — a careful deploy causing the - * outage it was being careful about. The returned sentence goes into the caller's reason so - * the operator learns the host's actual state, not just why the candidate failed. - */ +/** Restart the incumbent only when the candidate left shared state unchanged. */ async function restoreIncumbent( options: OrcadDeployOptions, record: OrcadActivationRecord, - candidateDir: string + candidateDir?: string, + snapshot?: OrcadStateSnapshot | null ): Promise { - const stopped = parseOrcadStopOutcome( - await exec( - options, - stopOrcadCommand(options.host, candidateDir, { waitSeconds: STOP_WAIT_SECONDS }) + if (candidateDir) { + const stopped = parseOrcadStopOutcome( + await exec( + options, + stopOrcadCommand(options.host, candidateDir, { + waitSeconds: STOP_WAIT_SECONDS, + justLaunched: true + }) + ) ) - ) - if (!orcadStopFreedTheHost(stopped)) { - return `The candidate itself did not stop (${stopped}); the host may still be serving the rejected build.` + if (!orcadStopFreedTheHost(stopped)) { + return `The candidate itself did not stop (${stopped}); the host may still be serving the rejected build.` + } } if (!record.active) { return 'No previous version was active, so this host is now serving nothing.' } + if (candidateDir) { + const snapshotDir = snapshot + ? joinRemotePath(options.host, baseDir(options), ORCAD_STATE_SNAPSHOT_DIR, snapshot.dirName) + : undefined + const refusal = await rejectedOrcadStateRecoveryRefusal(options, record.active, snapshotDir) + if (refusal) { + return refusal + } + } const incumbentDir = computeRemoteInstallDir( ORCAD_INSTALL_MODEL, options.remoteHome, record.active ) - const parsed = await launchAndAwaitReadiness(options, { - remoteInstallDir: incumbentDir, - nodePath: options.nodePath, - fullVersion: record.active, - userDataDir: options.userDataDir, - bindHost: options.bindHost, - port: options.port - }) + const parsed = await launchAndAwaitReadiness(options, incumbentDir, record.active) return parsed.state === 'ready' ? `orcad ${record.active} was restarted and is serving again.` : `orcad ${record.active} was relaunched but has not published readiness; this host may be down.` } -/** - * Install, then activate only on a green cross-process health verdict. - * - * Every early return past the install leaves the bytes on disk and the previous version - * serving, which is why they all report `installed-not-activated` rather than throwing: a - * refusal to switch is a successful outcome of a deploy that was asked to be careful. - */ -export async function deployOrcad(options: OrcadDeployOptions): Promise { +/** Activate on a healthy verdict; retain changed candidate state for explicit recovery. */ +export async function deployOrcad(input: OrcadDeployOptions): Promise { + assertPosixOrcadHost(input.host) + const options = { + ...input, + localOrcadDir: + input.localOrcadDir ?? + (await materializeOrcadArtifact(await resolveOrcadDeploymentTarget(input), { + signal: input.signal + })) + } const now = options.now ?? ((): Date => new Date()) const fullVersion = readLocalFullVersion(options.localOrcadDir) const remoteDir = computeRemoteInstallDir(ORCAD_INSTALL_MODEL, options.remoteHome, fullVersion) @@ -287,52 +232,67 @@ export async function deployOrcad(options: OrcadDeployOptions): Promise + `The incumbent could not be restarted: ${ + restartError instanceof Error ? restartError.message : String(restartError) + }` + ) + throw new Error( + `${error instanceof Error ? error.message : String(error)} The incumbent was stopped ` + + `before snapshotting; ${restored}` + ) + } + } + + const parsed = await launchAndAwaitReadiness(options, remoteDir, fullVersion) const verdict = evaluateOrcadActivation(parsed.state === 'ready' ? parsed.readiness : null, { buildHash: computeLocalOrcadBuildHash(options.localOrcadDir), fullVersion }) if (verdict.decision === 'reject') { - const restored = await restoreIncumbent(options, record, remoteDir) + const restored = await restoreIncumbent(options, record, remoteDir, snapshot) return { outcome: 'installed-not-activated', fullVersion, diff --git a/src/main/ssh/orcad-remote-gc.test.ts b/src/main/ssh/orcad-remote-gc.test.ts index b25e3bb6b6d..9d365c631ef 100644 --- a/src/main/ssh/orcad-remote-gc.test.ts +++ b/src/main/ssh/orcad-remote-gc.test.ts @@ -35,7 +35,7 @@ const mockExec = vi.mocked(execCommand) */ function scriptHost(options: { listing: string[] - liveness?: Record + liveness?: Record removed: string[] }): void { mockExec.mockImplementation(async (_conn, command: string) => { @@ -50,7 +50,11 @@ function scriptHost(options: { } if (command.includes('.orcad-pid')) { const dir = Object.keys(options.liveness ?? {}).find((name) => command.includes(name)) - return dir ? (options.liveness?.[dir] ?? 'DEAD') : 'DEAD' + const result = dir ? (options.liveness?.[dir] ?? 'DEAD') : 'DEAD' + if (result instanceof Error) { + throw result + } + return result } if (command.startsWith('mv ')) { const match = command.match(/'([^']*)'/) @@ -152,4 +156,49 @@ describe('orcad GC', () => { }) expect(removed).toEqual(['orcad-0.0.9+dead']) }) + + it('stops before later versions when liveness probe termination is unconfirmed', async () => { + const removed: string[] = [] + const error = Object.assign(new Error('Liveness probe termination is unconfirmed'), { + sshChannelCloseConfirmed: false + }) + scriptHost({ + listing: ['orcad-0.1.0+bb0', 'orcad-0.0.9+dead'], + liveness: { 'orcad-0.1.0+bb0': error }, + removed + }) + + await expect( + gcOldOrcadVersions({ + conn, + host, + remoteHome: '/home/u', + currentDirAbsPath: '/home/u/.orca-remote/orcad-0.3.0+cc0', + record: emptyOrcadActivationRecord() + }) + ).rejects.toBe(error) + + expect(removed).toEqual([]) + expect(mockExec).toHaveBeenCalledTimes(4) + expect(mockExec.mock.calls.at(-1)?.[1]).toContain('.orcad-pid') + }) + + it('keeps a version after an ordinary probe failure and checks later versions', async () => { + const removed: string[] = [] + scriptHost({ + listing: ['orcad-0.1.0+bb0', 'orcad-0.0.9+dead'], + liveness: { 'orcad-0.1.0+bb0': new Error('Probe failed') }, + removed + }) + + await gcOldOrcadVersions({ + conn, + host, + remoteHome: '/home/u', + currentDirAbsPath: '/home/u/.orca-remote/orcad-0.3.0+cc0', + record: emptyOrcadActivationRecord() + }) + + expect(removed).toEqual(['orcad-0.0.9+dead']) + }) }) diff --git a/src/main/ssh/orcad-remote-gc.ts b/src/main/ssh/orcad-remote-gc.ts index a96b386fc1b..09997dc40d3 100644 --- a/src/main/ssh/orcad-remote-gc.ts +++ b/src/main/ssh/orcad-remote-gc.ts @@ -15,6 +15,7 @@ */ import type { SshConnection } from './ssh-connection' import { execCommand } from './ssh-relay-deploy-helpers' +import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' import { ORCAD_INSTALL_MODEL } from './remote-install-model' import { gcOldRemoteInstallVersions } from './ssh-relay-versioned-install' import { orcadGcPinnedDirNames, type OrcadActivationRecord } from './orcad-activation-record' @@ -63,7 +64,10 @@ export async function gcOldOrcadVersions(options: OrcadGcOptions): Promise } ) return orcadLivenessBlocksGc(parseOrcadLiveness(probe)) - } catch { + } catch (error) { + if (isUnconfirmedSshCommandTermination(error)) { + throw error + } // Why true: an unanswered probe is not evidence a tree is idle. Same rule the // relay's socket probe applies, for the same reason. return true diff --git a/src/main/ssh/orcad-remote-host-support.ts b/src/main/ssh/orcad-remote-host-support.ts index dfcb0f4b6e4..7ef8548ecdf 100644 --- a/src/main/ssh/orcad-remote-host-support.ts +++ b/src/main/ssh/orcad-remote-host-support.ts @@ -42,9 +42,17 @@ export const ORCAD_PID_FILENAME = '.orcad-pid' * A host without `ps` yields an empty state, which falls through to "alive" — the safe * direction for both callers. */ -export function posixProcessAliveShellFunction(): string { +export function posixProcessAliveShellFunction( + options: { refuseUnverifiable?: boolean } = {} +): string { + // Destructive lifecycle steps need explicit absence; permission failures cannot prove exit. + const probe = options.refuseUnverifiable + ? 'probe_error=$(LC_ALL=C kill -0 "$1" 2>&1) || { ' + + 'case "$probe_error" in *"No such process"*) return 1;; ' + + '*) echo UNKNOWN; exit 0;; esac; }; ' + : 'kill -0 "$1" 2>/dev/null || return 1; ' return ( - 'orcad_alive() { kill -0 "$1" 2>/dev/null || return 1; ' + + `orcad_alive() { ${probe}` + 'case "$(ps -o stat= -p "$1" 2>/dev/null)" in Z*) return 1;; esac; return 0; };' ) } diff --git a/src/main/ssh/orcad-remote-install-termination.test.ts b/src/main/ssh/orcad-remote-install-termination.test.ts new file mode 100644 index 00000000000..fbc0dcb156c --- /dev/null +++ b/src/main/ssh/orcad-remote-install-termination.test.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn() })) +vi.mock('./ssh-relay-install-lock', () => ({ + acquireInstallLock: vi.fn().mockResolvedValue(undefined), + RELAY_INSTALL_LOCK_NAME: '.install-lock' +})) +vi.mock('./ssh-relay-install-transfers', () => ({ + uploadRelayDirectory: vi.fn(), + writeRelayFile: vi.fn() +})) + +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { uploadRelayDirectory, writeRelayFile } from './ssh-relay-install-transfers' +import { installOrcadBundle } from './orcad-remote-install' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { decodeRemotePowerShellScript } from './ssh-remote-powershell' + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: All connection operations are mocked. +const conn = {} as SshConnection +const mockExec = vi.mocked(execCommand) +const mockUpload = vi.mocked(uploadRelayDirectory) +const mockWrite = vi.mocked(writeRelayFile) +const fullVersion = '0.2.0+abcdef123456' + +function issuedCommands(): string[] { + return mockExec.mock.calls.map(([, command]) => decodeRemotePowerShellScript(command)) +} + +beforeEach(() => { + vi.clearAllMocks() + mockExec.mockReset().mockResolvedValue('') + mockUpload.mockReset().mockResolvedValue(undefined) + mockWrite.mockReset().mockResolvedValue(undefined) +}) + +describe.each([ + { platform: 'linux-x64' as const, remoteDir: '/home/u/.orca-remote/orcad-version' }, + { platform: 'win32-x64' as const, remoteDir: 'C:/Users/u/.orca-remote/orcad-version' } +])('orcad install termination on $platform', ({ platform, remoteDir }) => { + const host = getRemoteHostPlatform(platform) + const completionCommandNumber = host.os === 'win32' ? 3 : 4 + const stages = [ + { stage: 'locked recheck', commands: 2 }, + { stage: 'upload', commands: 2 }, + { stage: 'version write', commands: completionCommandNumber - 1 }, + { stage: 'completion marker', commands: completionCommandNumber } + ] + const install = (signal?: AbortSignal): Promise => + installOrcadBundle( + { conn, host, localOrcadDir: '/local/orcad', signal }, + fullVersion, + remoteDir + ) + + function failStage(stage: string, commandNumber: number, error: Error): void { + if (stage === 'upload') { + mockUpload.mockRejectedValueOnce(error) + } else if (stage === 'version write') { + mockWrite.mockRejectedValueOnce(error) + } else { + let calls = 0 + mockExec.mockImplementation(async () => { + if (++calls === commandNumber) { + throw error + } + return '' + }) + } + } + + it.each(stages)('retains its lock after unconfirmed $stage', async ({ stage, commands }) => { + const error = Object.assign(new Error('SSH teardown not confirmed'), { + sshChannelCloseConfirmed: false + }) + failStage(stage, commands, error) + + await expect(install()).rejects.toBe(error) + + expect(mockExec).toHaveBeenCalledTimes(commands) + expect(mockUpload).toHaveBeenCalledTimes(stage === 'locked recheck' ? 0 : 1) + expect(mockWrite).toHaveBeenCalledTimes( + stage === 'locked recheck' || stage === 'upload' ? 0 : 1 + ) + expect(issuedCommands().some((command) => command.includes('.install-lock'))).toBe(false) + }) + + it.each(stages)( + 'still releases its lock after confirmed $stage failure', + async ({ stage, commands }) => { + const error = Object.assign(new Error('SSH command failed after closing'), { + sshChannelCloseConfirmed: true + }) + failStage(stage, commands, error) + + if (stage === 'locked recheck') { + await expect(install()).resolves.toBeUndefined() + expect(mockExec).toHaveBeenCalledTimes(completionCommandNumber + 1) + } else { + await expect(install()).rejects.toBe(error) + expect(mockExec).toHaveBeenCalledTimes(commands + 1) + } + expect(issuedCommands().at(-1)).toContain('.install-lock') + } + ) + + it('releases its lock once after successful completion', async () => { + await expect(install()).resolves.toBeUndefined() + + expect(mockExec).toHaveBeenCalledTimes(completionCommandNumber + 1) + expect(issuedCommands()[completionCommandNumber - 1]).toContain('.install-complete') + expect(issuedCommands().at(-1)).toContain('.install-lock') + expect(mockUpload).toHaveBeenCalledOnce() + expect(mockWrite).toHaveBeenCalledOnce() + }) + + it('preserves an uncertain locked recheck when the caller also aborts', async () => { + const controller = new AbortController() + const error = Object.assign(new Error('SSH teardown not confirmed'), { + sshChannelCloseConfirmed: false + }) + mockExec.mockResolvedValueOnce('').mockImplementationOnce(async () => { + controller.abort(new Error('deploy aborted')) + throw error + }) + + await expect(install(controller.signal)).rejects.toBe(error) + expect(mockExec).toHaveBeenCalledTimes(2) + expect(mockUpload).not.toHaveBeenCalled() + }) + + it('propagates an uncertain final lock release without retrying it', async () => { + const error = Object.assign(new Error('SSH teardown not confirmed'), { + sshChannelCloseConfirmed: false + }) + failStage('release', completionCommandNumber + 1, error) + + await expect(install()).rejects.toBe(error) + expect(mockExec).toHaveBeenCalledTimes(completionCommandNumber + 1) + expect(issuedCommands().at(-1)).toContain('.install-lock') + }) + + it.skipIf(host.os === 'win32')( + 'retains its lock after unconfirmed executable permissions', + async () => { + const error = Object.assign(new Error('SSH teardown not confirmed'), { + sshChannelCloseConfirmed: false + }) + failStage('permissions', 3, error) + + await expect(install()).rejects.toBe(error) + expect(mockExec).toHaveBeenCalledTimes(3) + expect(issuedCommands().at(-1)).toContain('chmod') + expect(mockWrite).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/main/ssh/orcad-remote-install.ts b/src/main/ssh/orcad-remote-install.ts new file mode 100644 index 00000000000..9866f666643 --- /dev/null +++ b/src/main/ssh/orcad-remote-install.ts @@ -0,0 +1,94 @@ +import { orcadBunRuntimeFilename } from '../../shared/orcad-artifacts' +import { orcadAgentBrowserNativeName } from '../../shared/orcad-agent-browser-name' +import { execCommand } from './ssh-relay-deploy-helpers' +import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' +import { shellEscape } from './ssh-connection-utils' +import type { SshConnection } from './ssh-connection' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { ORCAD_INSTALL_MODEL } from './remote-install-model' +import { acquireInstallLock } from './ssh-relay-install-lock' +import { uploadRelayDirectory, writeRelayFile } from './ssh-relay-install-transfers' +import { + abandonInstall, + finalizeInstall, + isRemoteInstallComplete +} from './ssh-relay-versioned-install' + +/** Install the bytes under `orcad-/`, using the relay's install transaction. */ +export async function installOrcadBundle( + options: { + conn: SshConnection + host: RemoteHostPlatform + localOrcadDir: string + signal?: AbortSignal + }, + fullVersion: string, + remoteDir: string +): Promise { + if ( + await isRemoteInstallComplete(options.conn, ORCAD_INSTALL_MODEL, remoteDir, options.host, { + signal: options.signal + }) + ) { + return + } + await acquireInstallLock(options.conn, remoteDir, options.host, { signal: options.signal }) + let preserveInstallLock = false + try { + // Re-probe under the lock: a sibling deploy may have finished while we waited. + if ( + await isRemoteInstallComplete(options.conn, ORCAD_INSTALL_MODEL, remoteDir, options.host, { + signal: options.signal + }) + ) { + return + } + await uploadRelayDirectory(options.conn, options.localOrcadDir, remoteDir, options.host, { + signal: options.signal + }) + if (options.host.os !== 'win32') { + await execCommand(options.conn, executablePermissionsCommand(options.host, remoteDir), { + wrapCommand: options.host.commandDialect !== 'powershell', + signal: options.signal + }) + } + await writeRelayFile( + options.conn, + options.host, + joinRemotePath(options.host, remoteDir, ORCAD_INSTALL_MODEL.versionFilename), + fullVersion, + { signal: options.signal } + ) + await finalizeInstall(options.conn, remoteDir, options.host, { + signal: options.signal, + releaseLock: false + }) + } catch (error) { + preserveInstallLock = isUnconfirmedSshCommandTermination(error) + throw error + } finally { + if (!preserveInstallLock) { + await abandonInstall(options.conn, remoteDir, options.host) + } + } +} + +function executablePermissionsCommand(host: RemoteHostPlatform, directory: string): string { + const required = [ + joinRemotePath(host, directory, orcadBunRuntimeFilename(host.os)), + joinRemotePath(host, directory, 'ripgrep', host.relayPlatform, 'rg') + ] + const browsers = new Set( + (['glibc', 'musl'] as const).map((libc) => + shellEscape( + joinRemotePath(host, directory, orcadAgentBrowserNativeName(host.os, host.arch, libc)) + ) + ) + ) + // SFTP drops executable modes; missing optional browser binaries remain a supported install. + return ( + `chmod 755 ${required.map(shellEscape).join(' ')} && ` + + `for executable in ${[...browsers].join(' ')}; do ` + + 'if [ -f "$executable" ]; then chmod 755 "$executable" || exit $?; fi; done' + ) +} diff --git a/src/main/ssh/orcad-remote-launch.test.ts b/src/main/ssh/orcad-remote-launch.test.ts index 068ae588f65..0be9ddc76c1 100644 --- a/src/main/ssh/orcad-remote-launch.test.ts +++ b/src/main/ssh/orcad-remote-launch.test.ts @@ -100,7 +100,10 @@ describe('liveness', () => { describe('stopping a running orcad', () => { it('sends SIGTERM and never SIGKILL', () => { - const command = stopOrcadCommand(posix, SPEC.remoteInstallDir, { waitSeconds: 20 }) + const command = stopOrcadCommand(posix, SPEC.remoteInstallDir, { + waitSeconds: 20, + nodePath: SPEC.nodePath + }) expect(command).toContain('kill -TERM') for (const kill of ['kill -9', 'kill -KILL', 'kill -SIGKILL', 'pkill']) { expect(command).not.toContain(kill) @@ -110,9 +113,10 @@ describe('stopping a running orcad', () => { it.each([ ['STOPPED', 'stopped', true], ['ALREADY_EXITED', 'already-exited', true], - ['NO_PID', 'no-pid', true], + ['NO_PID', 'no-pid', false], ['STILL_RUNNING', 'still-running', false], ['SIGNAL_FAILED', 'signal-failed', false], + ['UNKNOWN', 'unknown', false], ['', 'unknown', false] ])('parses %s and frees the host = %s', (output, expected, frees) => { expect(parseOrcadStopOutcome(output)).toBe(expected) diff --git a/src/main/ssh/orcad-remote-launch.ts b/src/main/ssh/orcad-remote-launch.ts index a76010ccbce..d6c9640ad33 100644 --- a/src/main/ssh/orcad-remote-launch.ts +++ b/src/main/ssh/orcad-remote-launch.ts @@ -20,6 +20,7 @@ import { posixProcessAliveShellFunction } from './orcad-remote-host-support' import type { ServeReadiness } from '../server/serve-readiness' +import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' /** Stdout of the launched candidate: exactly one `orca_server_ready` line, then nothing. */ export const ORCAD_READINESS_FILENAME = '.orcad-readiness' @@ -56,13 +57,15 @@ export function orcadLaunchCommand(host: RemoteHostPlatform, spec: OrcadLaunchSp const entry = shellEscape(joinRemotePath(host, spec.remoteInstallDir, 'orcad.js')) return [ `cd ${dir} &&`, + `${selectOrcadSlotRuntimeCommand(host, spec.remoteInstallDir, spec.nodePath)} &&`, // Why truncate: a re-launch into a dir that already holds a previous readiness line would // otherwise let the deploy activate on the OLD process's health payload. `: > ${readiness} &&`, 'umask 077 &&', `ORCA_VERSION=${shellEscape(spec.fullVersion)}`, `ORCA_USER_DATA=${shellEscape(spec.userDataDir)}`, - `nohup ${shellEscape(spec.nodePath)} ${entry}`, + // Keep $! equal to the runtime PID rather than a waiting shell's PID. + `exec nohup "$orcad_runtime" ${entry}`, `--json --bind ${shellEscape(spec.bindHost)} --port ${String(spec.port)}`, `> ${readiness} 2>> ${log} < /dev/null &`, `echo $! > ${pidFile} && cat ${pidFile}` diff --git a/src/main/ssh/orcad-remote-preflight.ts b/src/main/ssh/orcad-remote-preflight.ts new file mode 100644 index 00000000000..5bba386d461 --- /dev/null +++ b/src/main/ssh/orcad-remote-preflight.ts @@ -0,0 +1,45 @@ +import { randomUUID } from 'node:crypto' +import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' +import { orcadBunRuntimeFilename } from '../../shared/orcad-artifacts' +import { + ORCAD_PROFILE_PREFLIGHT_FLAG, + ORCAD_PROFILE_PREFLIGHT_TIMEOUT_MS, + parseOrcadProfilePreflight +} from '../../shared/orcad-profile-preflight' +import { assertPosixOrcadHost } from './orcad-remote-host-support' +import { execCommand } from './ssh-relay-deploy-helpers' +import { shellEscape } from './ssh-connection-utils' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import type { SshConnection } from './ssh-connection' + +export function orcadProfilePreflightCommand( + host: RemoteHostPlatform, + directory: string, + nonce: string +): string { + assertPosixOrcadHost(host) + return [ + 'ORCA_BACKGROUND_LAUNCH=1', + shellEscape(joinRemotePath(host, directory, orcadBunRuntimeFilename(host.os))), + shellEscape(joinRemotePath(host, directory, 'orcad.js')), + ORCAD_PROFILE_PREFLIGHT_FLAG, + shellEscape(nonce) + ].join(' ') +} + +/** Failure leaves the incumbent and its data untouched, including an unconfirmed SSH exit. */ +export async function preflightInstalledOrcad(options: { + conn: SshConnection + host: RemoteHostPlatform + remoteInstallDir: string + fullVersion: string + signal?: AbortSignal +}): Promise { + const nonce = randomUUID() + const output = await execCommand( + options.conn, + orcadProfilePreflightCommand(options.host, options.remoteInstallDir, nonce), + { signal: options.signal, timeoutMs: ORCAD_PROFILE_PREFLIGHT_TIMEOUT_MS } + ) + parseOrcadProfilePreflight(output, nonce, ORCAD_BUN_VERSION, options.fullVersion) +} diff --git a/src/main/ssh/orcad-remote-process-control.ts b/src/main/ssh/orcad-remote-process-control.ts index 6a9038ea3d6..33fd877ba41 100644 --- a/src/main/ssh/orcad-remote-process-control.ts +++ b/src/main/ssh/orcad-remote-process-control.ts @@ -1,15 +1,13 @@ /** * Stopping a running orcad on the host without taking its terminals with it. * - * `SIGKILL` is absent on purpose. orcad's own escalation contract (`orcad-entry.ts`) is - * SIGTERM, then a second SIGTERM meaning "your deadline elapsed, exit now"; a kill skips the - * teardown that releases the instance lock and disconnects — rather than shuts down — the - * terminal daemon. The daemon is detached and would survive a kill, but a stop that leaves - * the lock file behind makes the successor refuse to start with - * `orcad_data_root_shared`, so the update turns into an outage for no gain. + * SIGTERM starts one bounded durable shutdown. If it outlasts this wait, preserve the + * current owner; SIGKILL would skip flushing state and releasing the instance lock. */ import { shellEscape } from './ssh-connection-utils' import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { ORCAD_READINESS_FILENAME } from './orcad-remote-launch' +import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' import { assertPosixOrcadHost as assertPosixHost, ORCAD_PID_FILENAME, @@ -19,20 +17,38 @@ import { /** * Signal the orcad recorded in a version dir and wait for it to go. * - * `escalate` sends the second SIGTERM orcad reads as "exit immediately". Callers use it only - * after the first deadline elapses, so the two signals are never in the same command. + * `justLaunched` is only for this client's fixed exec launcher, including pre-readiness exits. + * Incumbents need their own readiness PID to corroborate the launcher's PID before any signal. */ export function stopOrcadCommand( host: RemoteHostPlatform, remoteInstallDir: string, - options: { waitSeconds: number } + options: { waitSeconds: number } & ( + | { justLaunched: true } + | { justLaunched?: false; nodePath: string } + ) ): string { assertPosixHost(host) const pidFile = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_PID_FILENAME)) + const readiness = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_READINESS_FILENAME)) + const readRuntimePid = [ + `const r = JSON.parse(require('node:fs').readFileSync(process.argv[1], 'utf8'));`, + `const pid = r?.type === 'orca_server_ready' ? r.health?.pid : null;`, + `if (!Number.isSafeInteger(pid) || pid <= 1) process.exit(1);`, + `process.stdout.write(String(pid));` + ].join(' ') return [ - posixProcessAliveShellFunction(), + posixProcessAliveShellFunction({ refuseUnverifiable: true }), `pid=$(cat ${pidFile} 2>/dev/null);`, 'case "$pid" in "" | *[!0-9]* ) echo NO_PID; exit 0;; esac;', + // Older launchers recorded a waiting shell, whose exit does not prove runtime exit. + ...(options.justLaunched + ? [] + : [ + `runtime_pid=$(${selectOrcadSlotRuntimeCommand(host, remoteInstallDir, options.nodePath)}; ` + + `"$orcad_runtime" -e ${shellEscape(readRuntimePid)} ${readiness} 2>/dev/null) || { echo UNKNOWN; exit 0; };`, + '[ "$pid" = "$runtime_pid" ] || { echo UNKNOWN; exit 0; };' + ]), 'orcad_alive "$pid" || { echo ALREADY_EXITED; exit 0; };', 'kill -TERM "$pid" 2>/dev/null || { echo SIGNAL_FAILED; exit 0; };', `i=0; while [ "$i" -lt ${options.waitSeconds} ]; do`, @@ -69,5 +85,5 @@ export function parseOrcadStopOutcome(output: string): OrcadStopOutcome { /** True when the port is free and a successor may bind. */ export function orcadStopFreedTheHost(outcome: OrcadStopOutcome): boolean { - return outcome === 'stopped' || outcome === 'already-exited' || outcome === 'no-pid' + return outcome === 'stopped' || outcome === 'already-exited' } diff --git a/src/main/ssh/orcad-remote-rollback.test.ts b/src/main/ssh/orcad-remote-rollback.test.ts index b9c74158a6d..4d2f9150a75 100644 --- a/src/main/ssh/orcad-remote-rollback.test.ts +++ b/src/main/ssh/orcad-remote-rollback.test.ts @@ -68,7 +68,10 @@ function readyLine(version: string): string { }) } -function scriptHost(log: string[], overrides: { restore?: string } = {}): void { +function scriptHost( + log: string[], + overrides: { restore?: string; readinessAtMs?: number } = {} +): void { mockExec.mockImplementation(async (_conn, command: string) => { const text = String(command) if (text.includes('state.tar') && text.includes('test -f') && !text.includes('tar -C')) { @@ -90,6 +93,9 @@ function scriptHost(log: string[], overrides: { restore?: string } = {}): void { return '9999' } if (text.startsWith('cat ') && text.includes('.orcad-readiness')) { + if (overrides.readinessAtMs !== undefined && Date.now() < overrides.readinessAtMs) { + return '' + } return readyLine(TARGET) } return '' @@ -130,6 +136,28 @@ describe('rollbackOrcad', () => { expect(log).toEqual([`stop:${ACTIVE}`, 'restore', `launch:${TARGET}`]) }) + it('allows rollback startup time after a slow bundled preflight', async () => { + let elapsedMs = 0 + const clock = vi.spyOn(Date, 'now').mockImplementation(() => elapsedMs) + const log: string[] = [] + scriptHost(log, { readinessAtMs: 100_000 }) + try { + const result = await rollbackOrcad( + options({ + readinessTimeoutMs: undefined, + sleep: async () => { + elapsedMs += 50_000 + } + }) + ) + expect(result.outcome).toBe('rolled-back') + expect(elapsedMs).toBe(100_000) + expect(log).toEqual([`stop:${ACTIVE}`, 'restore', `launch:${TARGET}`]) + } finally { + clock.mockRestore() + } + }) + it('refuses before touching anything when terminals started after activation', async () => { const log: string[] = [] scriptHost(log) diff --git a/src/main/ssh/orcad-remote-rollback.ts b/src/main/ssh/orcad-remote-rollback.ts index 03df9479e4a..412021c2f22 100644 --- a/src/main/ssh/orcad-remote-rollback.ts +++ b/src/main/ssh/orcad-remote-rollback.ts @@ -14,6 +14,7 @@ * failure this is meant to avoid, arrived at from the other side. */ import type { SshConnection } from './ssh-connection' +import { ORCAD_STARTUP_READINESS_TIMEOUT_MS } from '../../shared/orcad-profile-preflight' import { execCommand } from './ssh-relay-deploy-helpers' import { ORCAD_INSTALL_MODEL } from './remote-install-model' import { computeRemoteInstallDir } from './ssh-relay-versioned-install' @@ -71,7 +72,6 @@ export type OrcadRollbackResult = | { outcome: 'refused'; code: string; reason: string } | { outcome: 'failed'; code: string; reason: string } -const DEFAULT_READINESS_TIMEOUT_MS = 90_000 const READINESS_POLL_MS = 500 const STOP_WAIT_SECONDS = 20 @@ -142,7 +142,10 @@ export async function rollbackOrcad(options: OrcadRollbackOptions): Promise new Promise((r) => setTimeout(r, ms))) let parsed = parseOrcadReadinessOutput('') while (Date.now() < deadline && parsed.state === 'pending') { diff --git a/src/main/ssh/orcad-remote-runtime.test.ts b/src/main/ssh/orcad-remote-runtime.test.ts new file mode 100644 index 00000000000..637ab61e670 --- /dev/null +++ b/src/main/ssh/orcad-remote-runtime.test.ts @@ -0,0 +1,67 @@ +import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { runProcessSync } from '../../shared/child-process/run-process' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' +import { stopOrcadCommand } from './orcad-remote-process-control' + +const directories: string[] = [] +const host = getRemoteHostPlatform('linux-x64') + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture(): string { + const directory = mkdtempSync(join(tmpdir(), "orca 'quoted' $slot-")) + directories.push(directory) + return directory +} + +function launch(directory: string, nodePath: string) { + return runProcessSync({ + program: '/bin/sh', + args: [ + '-c', + `${selectOrcadSlotRuntimeCommand(host, directory, nodePath)}; ` + + '"$orcad_runtime" -e \'process.stdout.write("selected")\'' + ] + }) +} + +describe.skipIf(process.platform === 'win32')('POSIX slot runtime selection', () => { + it('returns an unverifiable stop result when a bundled runtime cannot execute', () => { + const directory = fixture() + writeFileSync(join(directory, '.build-target'), 'linux-x64-glibc') + writeFileSync(join(directory, '.orcad-pid'), String(process.pid)) + const result = runProcessSync({ + program: '/bin/sh', + args: [ + '-c', + stopOrcadCommand(host, directory, { waitSeconds: 1, nodePath: process.execPath }) + ] + }) + expect(result).toMatchObject({ code: 0, stdout: 'UNKNOWN\n' }) + }) + + it('uses the bundled executable when host Node does not exist', () => { + const directory = fixture() + writeFileSync(join(directory, '.build-target'), 'linux-x64-glibc') + symlinkSync(process.execPath, join(directory, 'bun-runtime')) + expect(launch(directory, '/missing-host-node')).toMatchObject({ code: 0, stdout: 'selected' }) + }) + + it('refuses an incomplete Bun slot before invoking a working host Node', () => { + const directory = fixture() + writeFileSync(join(directory, '.build-target'), 'linux-x64-glibc') + expect(launch(directory, process.execPath)).toMatchObject({ code: 78, stdout: '' }) + }) + + it('retains the original runtime for a legacy slot', () => { + expect(launch(fixture(), process.execPath)).toMatchObject({ code: 0, stdout: 'selected' }) + }) +}) diff --git a/src/main/ssh/orcad-remote-runtime.ts b/src/main/ssh/orcad-remote-runtime.ts new file mode 100644 index 00000000000..505965620a0 --- /dev/null +++ b/src/main/ssh/orcad-remote-runtime.ts @@ -0,0 +1,20 @@ +import { ORCAD_BUILD_TARGET_FILENAME, orcadBunRuntimeFilename } from '../../shared/orcad-artifacts' +import { assertPosixOrcadHost } from './orcad-remote-host-support' +import { shellEscape } from './ssh-connection-utils' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' + +/** Only legacy slots may use host Node; an incomplete Bun slot must not change runtimes. */ +export function selectOrcadSlotRuntimeCommand( + host: RemoteHostPlatform, + directory: string, + legacyNodePath: string +): string { + assertPosixOrcadHost(host) + const runtime = shellEscape(joinRemotePath(host, directory, orcadBunRuntimeFilename(host.os))) + const target = shellEscape(joinRemotePath(host, directory, ORCAD_BUILD_TARGET_FILENAME)) + return ( + `if [ -e ${target} ] || [ -e ${runtime} ]; then ` + + `[ -x ${runtime} ] || exit 78; orcad_runtime=${runtime}; ` + + `else orcad_runtime=${shellEscape(legacyNodePath)}; fi` + ) +} diff --git a/src/main/ssh/orcad-remote-shell-commands.integration.test.ts b/src/main/ssh/orcad-remote-shell-commands.integration.test.ts index f43f82949ee..4839fd7b2c2 100644 --- a/src/main/ssh/orcad-remote-shell-commands.integration.test.ts +++ b/src/main/ssh/orcad-remote-shell-commands.integration.test.ts @@ -6,20 +6,42 @@ * never matches, a `tar` invocation that silently captures nothing. These run the strings. */ import { execFileSync, spawn } from 'node:child_process' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + exportProfileStateJson, + importProfileStateJson +} from '../persistence/profile-state/profile-state-documents' +import { openProfileStateDatabase } from '../persistence/profile-state/profile-state-database' import { + orcadLaunchCommand, orcadLivenessProbeCommand, ORCAD_PID_FILENAME, - parseOrcadLiveness + ORCAD_READINESS_FILENAME, + parseOrcadLiveness, + parseOrcadReadinessOutput } from './orcad-remote-launch' -import { parseOrcadStopOutcome, stopOrcadCommand } from './orcad-remote-process-control' +import { + orcadStopFreedTheHost, + parseOrcadStopOutcome, + stopOrcadCommand +} from './orcad-remote-process-control' import { captureOrcadStateSnapshotCommand, + compareOrcadStateSnapshotCommand, newestStateMtimeCommand, + orcadSnapshotIsUnchanged, parseNewestStateMtimeSeconds, parseOrcadSnapshotCapture, parseOrcadSnapshotRestore, @@ -33,6 +55,7 @@ let root = '' let dataDir = '' let snapshotDir = '' let versionDir = '' +const launchedPids = new Set() function sh(command: string): string { return execFileSync('/bin/sh', ['-c', command], { encoding: 'utf8' }) @@ -52,10 +75,141 @@ beforeEach(() => { }) afterEach(() => { + for (const pid of launchedPids) { + try { + process.kill(pid, 'SIGKILL') + } catch { + // Successful stops have already removed their test processes. + } + } + launchedPids.clear() rmSync(root, { recursive: true, force: true }) }) +async function launchTestRuntime(legacyWrapper = false): Promise<{ + runtimePid: number + recordedPid: number + terminatedFile: string +}> { + const terminatedFile = join(versionDir, 'terminated') + writeFileSync( + join(versionDir, 'orcad.js'), + [ + `process.on('SIGTERM', () => {`, + ` require('node:fs').writeFileSync(${JSON.stringify(terminatedFile)}, 'terminated');`, + ` process.exit(0);`, + `});`, + `console.log(JSON.stringify({type: 'orca_server_ready', health: {pid: process.pid}}));`, + `setTimeout(() => process.exit(1), 10_000);` + ].join('\n') + ) + let command = orcadLaunchCommand(host, { + remoteInstallDir: versionDir, + nodePath: process.execPath, + fullVersion: '0.2.0+bb01', + userDataDir: dataDir, + bindHost: '127.0.0.1', + port: 0 + }) + if (legacyWrapper) { + // The trailing command retains the old macOS waiting-shell behavior on every POSIX shell. + command = command + .replace('exec nohup ', 'nohup ') + .replace('< /dev/null &', '< /dev/null && : &') + } + execFileSync('/bin/sh', ['-c', command], { stdio: 'ignore', timeout: 5_000 }) + const recordedPid = Number(readFileSync(join(versionDir, ORCAD_PID_FILENAME), 'utf8').trim()) + expect(recordedPid).toBeGreaterThan(1) + launchedPids.add(recordedPid) + const readRuntimePid = (): number => { + const parsed = parseOrcadReadinessOutput( + readFileSync(join(versionDir, ORCAD_READINESS_FILENAME), 'utf8') + ) + return parsed.state === 'ready' ? (parsed.readiness.health?.pid ?? 0) : 0 + } + await expect.poll(readRuntimePid, { timeout: 2_000, interval: 20 }).toBeGreaterThan(1) + const runtimePid = readRuntimePid() + launchedPids.add(runtimePid) + return { runtimePid, recordedPid, terminatedFile } +} + +function stopTestRuntime(justLaunched = false): ReturnType { + return parseOrcadStopOutcome( + execFileSync( + '/bin/sh', + [ + '-c', + stopOrcadCommand(host, versionDir, { + waitSeconds: 3, + ...(justLaunched ? { justLaunched: true as const } : { nodePath: process.execPath }) + }) + ], + { encoding: 'utf8', timeout: 5_000 } + ) + ) +} + describe('state snapshot commands, run for real', () => { + it('detects candidate SQLite migration without modifying current state or the snapshot', () => { + sh(captureOrcadStateSnapshotCommand(host, dataDir, snapshotDir)) + const archive = readFileSync(join(snapshotDir, 'state.tar')) + const compare = (): boolean => + orcadSnapshotIsUnchanged(sh(compareOrcadStateSnapshotCommand(host, dataDir, snapshotDir))) + expect(compare()).toBe(true) + writeFileSync(join(dataDir, 'daemon', 'daemon.sock.token'), 'live-daemon-after-launch') + expect(compare()).toBe(true) + + const databasePath = join(dataDir, 'profiles', 'p1', 'profile-state.db') + const candidate = openProfileStateDatabase(databasePath, 'p1') + try { + importProfileStateJson(candidate.db, '{"settings":{"theme":"dark"}}') + } finally { + candidate.db.close() + } + const databaseBytes = readFileSync(databasePath) + + expect(compare()).toBe(false) + expect(readFileSync(databasePath)).toEqual(databaseBytes) + expect(readFileSync(join(snapshotDir, 'state.tar'))).toEqual(archive) + expect(readFileSync(join(dataDir, 'daemon', 'daemon.sock.token'), 'utf8')).toBe( + 'live-daemon-after-launch' + ) + }) + + it('requires an intact comparison snapshot before an older build can restart', () => { + expect( + orcadSnapshotIsUnchanged(sh(compareOrcadStateSnapshotCommand(host, dataDir, snapshotDir))) + ).toBe(false) + sh(captureOrcadStateSnapshotCommand(host, dataDir, snapshotDir)) + writeFileSync(join(snapshotDir, 'state.tar'), 'not an archive') + expect( + orcadSnapshotIsUnchanged(sh(compareOrcadStateSnapshotCommand(host, dataDir, snapshotDir))) + ).toBe(false) + expect(readFileSync(join(dataDir, 'profiles', 'p1', 'orca-data.json'), 'utf8')).toBe( + '{"repos":"before"}' + ) + }) + + it('rejects symlinked profile state that a tar snapshot does not preserve', () => { + const external = join(root, 'external-profile') + mkdirSync(external) + writeFileSync(join(external, 'orca-data.json'), '{"before":true}') + const profile = join(dataDir, 'profiles', 'linked') + symlinkSync(external, profile) + + expect( + parseOrcadSnapshotCapture(sh(captureOrcadStateSnapshotCommand(host, dataDir, snapshotDir))) + ).toBe('failed') + + mkdirSync(snapshotDir, { recursive: true }) + execFileSync('tar', ['-C', dataDir, '-cf', join(snapshotDir, 'state.tar'), 'profiles']) + writeFileSync(join(external, 'orca-data.json'), '{"candidate":true}') + expect( + orcadSnapshotIsUnchanged(sh(compareOrcadStateSnapshotCommand(host, dataDir, snapshotDir))) + ).toBe(false) + expect(readFileSync(join(external, 'orca-data.json'), 'utf8')).toBe('{"candidate":true}') + }) + it('captures, then restores state the newer build overwrote', () => { expect( parseOrcadSnapshotCapture(sh(captureOrcadStateSnapshotCommand(host, dataDir, snapshotDir))) @@ -74,6 +228,49 @@ describe('state snapshot commands, run for real', () => { expect(() => readFileSync(join(dataDir, 'profiles', 'p1', 'new-build-only.json'))).toThrow() }) + it('round-trips a quiescent SQLite profile database with its WAL sidecars', () => { + const profileDirectory = join(dataDir, 'profiles', 'p1') + const databasePath = join(profileDirectory, 'profile-state.db') + const opened = openProfileStateDatabase(databasePath, 'p1') + try { + importProfileStateJson( + opened.db, + JSON.stringify({ settings: { theme: 'dark' }, snapshotMarker: 'before' }) + ) + // The connection remains open, so WAL/SHM are still part of the archive boundary while + // the generated command reads the now-quiescent files. + expect(existsSync(`${databasePath}-wal`)).toBe(true) + expect( + parseOrcadSnapshotCapture(sh(captureOrcadStateSnapshotCommand(host, dataDir, snapshotDir))) + ).toBe('captured') + } finally { + opened.db.close() + } + + const changed = openProfileStateDatabase(databasePath, 'p1') + try { + importProfileStateJson( + changed.db, + JSON.stringify({ settings: { theme: 'light' }, snapshotMarker: 'after' }) + ) + } finally { + changed.db.close() + } + expect( + parseOrcadSnapshotRestore(sh(restoreOrcadStateSnapshotCommand(host, dataDir, snapshotDir))) + ).toBe('restored') + + const restored = openProfileStateDatabase(databasePath, 'p1') + try { + expect(JSON.parse(exportProfileStateJson(restored.db))).toEqual({ + settings: { theme: 'dark' }, + snapshotMarker: 'before' + }) + } finally { + restored.db.close() + } + }) + it('leaves the live daemon runtime dir untouched through capture and restore', () => { sh(captureOrcadStateSnapshotCommand(host, dataDir, snapshotDir)) // The daemon is running across the rollback and rewrites its token; a restore that @@ -115,7 +312,13 @@ describe('state snapshot commands, run for real', () => { expect( parseOrcadSnapshotCapture(sh(captureOrcadStateSnapshotCommand(host, nasty, snapshotDir))) ).toBe('captured') + expect( + orcadSnapshotIsUnchanged(sh(compareOrcadStateSnapshotCommand(host, nasty, snapshotDir))) + ).toBe(true) writeFileSync(join(nasty, 'orca-profile-index.json'), '{"v":"changed"}') + expect( + orcadSnapshotIsUnchanged(sh(compareOrcadStateSnapshotCommand(host, nasty, snapshotDir))) + ).toBe(false) expect( parseOrcadSnapshotRestore(sh(restoreOrcadStateSnapshotCommand(host, nasty, snapshotDir))) ).toBe('restored') @@ -124,6 +327,85 @@ describe('state snapshot commands, run for real', () => { }) describe('liveness and stop commands, run for real', () => { + it('records the runtime PID and waits for that runtime to exit when stopped', async () => { + const { runtimePid, recordedPid, terminatedFile } = await launchTestRuntime() + expect(recordedPid).toBe(runtimePid) + expect(stopTestRuntime()).toBe('stopped') + expect(readFileSync(terminatedFile, 'utf8')).toBe('terminated') + // An unreaped zombie has exited even though kill -0 still succeeds. + expect(sh(`ps -o stat= -p ${runtimePid} || true`).trim()).toMatch(/^(?:Z.*)?$/) + expect(existsSync(join(versionDir, ORCAD_PID_FILENAME))).toBe(true) + expect(stopTestRuntime()).toBe('already-exited') + }) + + it('refuses a legacy wrapper PID both before and after its shell exits', async () => { + const { runtimePid, recordedPid, terminatedFile } = await launchTestRuntime(true) + expect(recordedPid).not.toBe(runtimePid) + const beforeWrapperExit = stopTestRuntime() + expect(beforeWrapperExit).toBe('unknown') + expect(orcadStopFreedTheHost(beforeWrapperExit)).toBe(false) + expect(() => process.kill(recordedPid, 0)).not.toThrow() + expect(() => process.kill(runtimePid, 0)).not.toThrow() + + process.kill(recordedPid, 'SIGTERM') + await expect + .poll(() => sh(`ps -o stat= -p ${recordedPid} || true`).trim(), { timeout: 2_000 }) + .toMatch(/^(?:Z.*)?$/) + const afterWrapperExit = stopTestRuntime() + expect(afterWrapperExit).toBe('unknown') + expect(orcadStopFreedTheHost(afterWrapperExit)).toBe(false) + expect(() => process.kill(runtimePid, 0)).not.toThrow() + expect(existsSync(terminatedFile)).toBe(false) + }) + + it.each(['missing', 'malformed', 'without-health'])( + 'refuses an incumbent with %s readiness proof', + async (proof) => { + const { runtimePid, terminatedFile } = await launchTestRuntime() + const readinessFile = join(versionDir, ORCAD_READINESS_FILENAME) + if (proof === 'missing') { + rmSync(readinessFile) + } else { + writeFileSync(readinessFile, proof === 'malformed' ? '{' : '{"type":"orca_server_ready"}') + } + expect(stopTestRuntime()).toBe('unknown') + expect(() => process.kill(runtimePid, 0)).not.toThrow() + expect(existsSync(terminatedFile)).toBe(false) + } + ) + + it('can stop a candidate just launched with exec without readiness', async () => { + const { runtimePid, recordedPid, terminatedFile } = await launchTestRuntime() + expect(recordedPid).toBe(runtimePid) + rmSync(join(versionDir, ORCAD_READINESS_FILENAME)) + expect(stopTestRuntime(true)).toBe('stopped') + expect(readFileSync(terminatedFile, 'utf8')).toBe('terminated') + expect(sh(`ps -o stat= -p ${runtimePid} || true`).trim()).toMatch(/^(?:Z.*)?$/) + }) + + it.each(['before', 'after'])('refuses a permission-denied probe %s SIGTERM', async (phase) => { + const { runtimePid, terminatedFile } = await launchTestRuntime() + const deniedProbe = [ + 'term_sent=0; kill() {', + 'if [ "$1" = -TERM ]; then term_sent=1; return 0; fi;', + `if [ '${phase}' = before ] || [ "$term_sent" = 1 ]; then`, + 'echo "kill: Operation not permitted" >&2; return 1; fi;', + 'command kill "$@"; };' + ].join(' ') + const outcome = parseOrcadStopOutcome( + sh( + `${deniedProbe} ${stopOrcadCommand(host, versionDir, { + waitSeconds: 1, + nodePath: process.execPath + })}` + ) + ) + expect(outcome).toBe('unknown') + expect(orcadStopFreedTheHost(outcome)).toBe(false) + expect(() => process.kill(runtimePid, 0)).not.toThrow() + expect(existsSync(terminatedFile)).toBe(false) + }) + it('reports UNKNOWN with no pid file, and DEAD for a pid that has exited', () => { expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('UNKNOWN') writeFileSync(join(versionDir, ORCAD_PID_FILENAME), 'not-a-pid') @@ -144,7 +426,9 @@ describe('liveness and stop commands, run for real', () => { child.once('exit', (_code, signal) => resolve(signal)) ) expect( - parseOrcadStopOutcome(sh(stopOrcadCommand(host, versionDir, { waitSeconds: 10 }))) + parseOrcadStopOutcome( + sh(stopOrcadCommand(host, versionDir, { waitSeconds: 10, justLaunched: true })) + ) ).toBe('stopped') expect(await exited).toBe('SIGTERM') expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('DEAD') @@ -166,7 +450,9 @@ describe('liveness and stop commands, run for real', () => { expect(sh(`kill -0 ${child.pid} 2>/dev/null && echo LIVE || echo DEAD`).trim()).toBe('LIVE') expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('DEAD') expect( - parseOrcadStopOutcome(sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1 }))) + parseOrcadStopOutcome( + sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1, justLaunched: true })) + ) ).toBe('already-exited') } finally { child.unref() @@ -176,14 +462,18 @@ describe('liveness and stop commands, run for real', () => { it('reports ALREADY_EXITED for a stale pid file rather than signalling a stranger', () => { const exited = Number(sh('sh -c "echo $$"').trim()) writeFileSync(join(versionDir, ORCAD_PID_FILENAME), String(exited)) - expect(parseOrcadStopOutcome(sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1 })))).toBe( - 'already-exited' - ) + expect( + parseOrcadStopOutcome( + sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1, justLaunched: true })) + ) + ).toBe('already-exited') }) it('reports NO_PID when the version dir was never launched', () => { - expect(parseOrcadStopOutcome(sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1 })))).toBe( - 'no-pid' - ) + expect( + parseOrcadStopOutcome( + sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1, nodePath: process.execPath })) + ) + ).toBe('no-pid') }) }) diff --git a/src/main/ssh/orcad-state-snapshot.test.ts b/src/main/ssh/orcad-state-snapshot.test.ts index 0c50dc55fdb..f660e95e5a4 100644 --- a/src/main/ssh/orcad-state-snapshot.test.ts +++ b/src/main/ssh/orcad-state-snapshot.test.ts @@ -4,6 +4,7 @@ import { ORCAD_SNAPSHOT_EXCLUDED, ORCAD_SNAPSHOT_MEMBERS, captureOrcadStateSnapshotCommand, + compareOrcadStateSnapshotCommand, newestStateMtimeCommand, orcadSnapshotDirName, parseNewestStateMtimeSeconds, @@ -93,6 +94,7 @@ describe('Windows hosts', () => { it.each([ ['capture', () => captureOrcadStateSnapshotCommand(windows, ROOT, SNAP)], ['restore', () => restoreOrcadStateSnapshotCommand(windows, ROOT, SNAP)], + ['compare', () => compareOrcadStateSnapshotCommand(windows, ROOT, SNAP)], ['mtime', () => newestStateMtimeCommand(windows, ROOT)] ])('refuses %s rather than emitting a POSIX command', (_label, build) => { expect(build).toThrow('orcad to a Windows host is not implemented') diff --git a/src/main/ssh/orcad-state-snapshot.ts b/src/main/ssh/orcad-state-snapshot.ts index 78ad8b47d2a..7d062275442 100644 --- a/src/main/ssh/orcad-state-snapshot.ts +++ b/src/main/ssh/orcad-state-snapshot.ts @@ -27,7 +27,9 @@ export const ORCAD_SNAPSHOT_MEMBERS = [ 'orca-profile-index.json', // Pre-profiles layout; still read as a migration source. 'orca-data.json', - 'profiles' + 'profiles', + // Cross-profile SQLite moves must survive an orcad rollback too. + 'profile-move-intents' ] as const /** Never captured and never restored — see the module comment. */ @@ -45,6 +47,10 @@ function assertPlainMemberName(member: string): string { return member } +function noSymlinkedStateCommand(path: string): string { + return `links=$(find ${path} -type l -print) && [ -z "$links" ]` +} + export function orcadSnapshotDirName(fullVersion: string, takenAtMs: number): string { // Why the version and the timestamp: two activations of one version (a re-deploy after a // rejected activation) must not overwrite each other's snapshot. @@ -72,8 +78,14 @@ export function captureOrcadStateSnapshotCommand( // Why the accumulated name is NOT quoted: `$members` is re-split by the shell before it // reaches tar, so a quoted name arrives as a literal `'profiles'` that tar cannot stat. // `assertPlainMemberName` is what makes leaving them bare safe. - (member) => - `[ -e ${root}/${shellEscape(member)} ] && members="$members ${assertPlainMemberName(member)}";` + (member) => { + const path = `${root}/${shellEscape(member)}` + return ( + `if [ -e ${path} ] || [ -L ${path} ]; then ` + + `${noSymlinkedStateCommand(path)} || { echo FAILED; exit 0; }; ` + + `members="$members ${assertPlainMemberName(member)}"; fi;` + ) + } ).join(' ') return [ `members=;`, @@ -145,6 +157,42 @@ export function parseOrcadSnapshotRestore(output: string): OrcadSnapshotRestore return value === 'MISSING' ? 'missing' : 'failed' } +/** Compare after stopping the candidate; a changed root cannot be handed to an older build. */ +export function compareOrcadStateSnapshotCommand( + host: RemoteHostPlatform, + userDataDir: string, + snapshotDir: string +): string { + assertPosixHost(host) + const root = shellEscape(userDataDir) + const dir = shellEscape(snapshotDir) + const archive = shellEscape(joinRemotePath(host, snapshotDir, 'state.tar')) + const comparisons = ORCAD_SNAPSHOT_MEMBERS.map((member) => { + const name = shellEscape(member) + return [ + `if [ -e ${root}/${name} ] || [ -L ${root}/${name} ]; then`, + `${noSymlinkedStateCommand(`${root}/${name}`)} || { echo UNKNOWN; exit 0; };`, + `diff -r ${root}/${name} "$comparison"/${name} >/dev/null 2>&1 || verdict=CHANGED;`, + `elif [ -e "$comparison"/${name} ] || [ -L "$comparison"/${name} ]; then verdict=CHANGED; fi;` + ].join(' ') + }).join(' ') + return [ + `test -d ${root} && test -r ${root} && test -x ${root} || { echo UNKNOWN; exit 0; };`, + `test -f ${archive} || { echo UNKNOWN; exit 0; };`, + `comparison=$(mktemp -d ${dir}/compare.XXXXXX) || { echo UNKNOWN; exit 0; };`, + `trap 'rm -rf "$comparison"' EXIT HUP INT TERM;`, + `tar -C "$comparison" -xf ${archive} || { echo UNKNOWN; exit 0; };`, + `${noSymlinkedStateCommand('"$comparison"')} || { echo UNKNOWN; exit 0; };`, + 'verdict=UNCHANGED;', + comparisons, + 'echo "$verdict"' + ].join(' ') +} + +export function orcadSnapshotIsUnchanged(output: string): boolean { + return output.trim().split('\n').pop()?.trim() === 'UNCHANGED' +} + /** * Has the shared store been written since `activatedAt`? * diff --git a/src/main/ssh/relay-bundle-paths.ts b/src/main/ssh/relay-bundle-paths.ts new file mode 100644 index 00000000000..d20dccb4cdb --- /dev/null +++ b/src/main/ssh/relay-bundle-paths.ts @@ -0,0 +1,18 @@ +import { join } from 'node:path' +import type { RelayPlatform } from './relay-protocol' + +export function relayBundleCandidates(platform: RelayPlatform, appPath: string): string[] { + return [ + ...new Set([ + ...(process.env.ORCA_RELAY_PATH ? [join(process.env.ORCA_RELAY_PATH, platform)] : []), + ...(process.resourcesPath + ? [ + join(process.resourcesPath, 'relay', platform), + join(process.resourcesPath, 'app.asar.unpacked', 'out', 'relay', platform) + ] + : []), + join(appPath, 'resources', 'relay', platform), + join(appPath, 'out', 'relay', platform) + ]) + ] +} diff --git a/src/main/ssh/relay-protocol-backpressure.test.ts b/src/main/ssh/relay-protocol-backpressure.test.ts deleted file mode 100644 index 45a869af16e..00000000000 --- a/src/main/ssh/relay-protocol-backpressure.test.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - FrameDecoder, - FrameDecoderContinuationError, - FRAME_DECODER_MAX_RETAINED_BYTES, - HEADER_LENGTH, - MAX_MESSAGE_SIZE, - MessageType, - encodeFrame, - type DecodedFrame -} from './relay-protocol' - -function createScheduler(): { - schedule: (callback: () => void) => number - cancel: (handle: unknown) => void - runNext: () => void - pending: () => number -} { - let nextHandle = 1 - const callbacks = new Map void>() - return { - schedule: (callback) => { - const handle = nextHandle++ - callbacks.set(handle, callback) - return handle - }, - cancel: (handle) => callbacks.delete(handle as number), - runNext: () => { - const entry = callbacks.entries().next().value as [number, () => void] | undefined - if (!entry) { - throw new Error('No decoder continuation scheduled') - } - callbacks.delete(entry[0]) - entry[1]() - }, - pending: () => callbacks.size - } -} - -function frame(id: number, payload = `${id}`): Buffer { - return encodeFrame(MessageType.Regular, id, 0, Buffer.from(payload)) -} - -describe('FrameDecoder bounded turns', () => { - it('retains at most one maximum frame plus one MiB of partial input', () => { - expect(FRAME_DECODER_MAX_RETAINED_BYTES).toBe(MAX_MESSAGE_SIZE + HEADER_LENGTH + 1024 * 1024) - const maximumFrame = encodeFrame(MessageType.Regular, 1, 0, Buffer.alloc(MAX_MESSAGE_SIZE)) - const acceptedError = vi.fn() - const accepted = new FrameDecoder(vi.fn(), acceptedError) - - accepted.feed(Buffer.concat([maximumFrame, Buffer.alloc(1024 * 1024)])) - - expect(acceptedError).not.toHaveBeenCalled() - expect(accepted.drain()).toHaveLength(1024 * 1024) - - const excessError = vi.fn() - const excess = new FrameDecoder(vi.fn(), excessError) - excess.feed(Buffer.concat([maximumFrame, Buffer.alloc(1024 * 1024 + 1)])) - - expect(excessError).toHaveBeenCalledWith( - expect.objectContaining({ message: expect.stringContaining('retained-input') }) - ) - expect(excess.drain()).toHaveLength(0) - }) - - it('fails closed when one delivered chunk exceeds retained-input capacity', () => { - const onError = vi.fn() - const decoder = new FrameDecoder(vi.fn(), onError) - - decoder.feed(Buffer.alloc(FRAME_DECODER_MAX_RETAINED_BYTES + 1)) - - expect(onError).toHaveBeenCalledWith( - expect.objectContaining({ message: expect.stringContaining('retained-input') }) - ) - expect(decoder.drain()).toHaveLength(0) - }) - - it('emits the first frame synchronously and preserves order through self-pause', () => { - const scheduler = createScheduler() - const seen: number[] = [] - let decoder: FrameDecoder - const pause = vi.fn(() => decoder.feed(frame(4))) - const resume = vi.fn() - decoder = new FrameDecoder((decoded) => seen.push(decoded.id), undefined, { - maxFramesPerTurn: 1, - schedule: scheduler.schedule, - cancelScheduled: scheduler.cancel, - pause, - resume - }) - - decoder.feed(Buffer.concat([frame(1), frame(2), frame(3)])) - - expect(seen).toEqual([1]) - expect(pause).toHaveBeenCalledTimes(1) - expect(scheduler.pending()).toBe(1) - - scheduler.runNext() - scheduler.runNext() - scheduler.runNext() - - expect(seen).toEqual([1, 2, 3, 4]) - expect(pause).toHaveBeenCalledTimes(1) - expect(resume).toHaveBeenCalledTimes(1) - expect(scheduler.pending()).toBe(0) - }) - - it('bounds decoded bytes and time independently from the frame count', () => { - const byteScheduler = createScheduler() - const byteSeen: number[] = [] - const first = frame(1, 'one') - const second = frame(2, 'two') - const byteDecoder = new FrameDecoder((decoded) => byteSeen.push(decoded.id), undefined, { - maxFramesPerTurn: 64, - maxBytesPerTurn: first.length, - schedule: byteScheduler.schedule, - cancelScheduled: byteScheduler.cancel - }) - - byteDecoder.feed(Buffer.concat([first, second])) - expect(byteSeen).toEqual([1]) - byteScheduler.runNext() - expect(byteSeen).toEqual([1, 2]) - - const timeScheduler = createScheduler() - const timeSeen: number[] = [] - let nowCalls = 0 - const timeDecoder = new FrameDecoder((decoded) => timeSeen.push(decoded.id), undefined, { - maxFramesPerTurn: 64, - maxBytesPerTurn: MAX_MESSAGE_SIZE + HEADER_LENGTH, - maxTurnMs: 4, - now: () => (nowCalls++ === 0 ? 0 : 5), - schedule: timeScheduler.schedule, - cancelScheduled: timeScheduler.cancel - }) - - timeDecoder.feed(Buffer.concat([frame(3), frame(4)])) - expect(timeSeen).toEqual([3]) - timeScheduler.runNext() - expect(timeSeen).toEqual([3, 4]) - }) - - it('releases its pause epoch when continuation scheduling throws', () => { - const pause = vi.fn() - const resume = vi.fn() - const decoder = new FrameDecoder(() => {}, undefined, { - maxFramesPerTurn: 1, - pause, - resume, - schedule: () => { - throw new Error('scheduler unavailable') - } - }) - - expect(() => decoder.feed(Buffer.concat([frame(1), frame(2)]))).toThrow('scheduler unavailable') - expect(pause).toHaveBeenCalledTimes(1) - expect(resume).toHaveBeenCalledTimes(1) - decoder.reset() - expect(resume).toHaveBeenCalledTimes(1) - }) - - it('contains a throwing continuation, resets residue, and reports one typed error', () => { - const scheduler = createScheduler() - const seen: number[] = [] - const onError = vi.fn() - const pause = vi.fn() - const resume = vi.fn() - const decoder = new FrameDecoder( - (decoded) => { - if (decoded.id === 2) { - throw new Error('frame owner failed') - } - seen.push(decoded.id) - }, - onError, - { - maxFramesPerTurn: 1, - schedule: scheduler.schedule, - cancelScheduled: scheduler.cancel, - pause, - resume - } - ) - - decoder.feed(Buffer.concat([frame(1), frame(2), frame(3)])) - expect(() => scheduler.runNext()).not.toThrow() - - expect(seen).toEqual([1]) - expect(onError).toHaveBeenCalledExactlyOnceWith(expect.any(FrameDecoderContinuationError)) - expect(onError.mock.calls[0]?.[0]).toMatchObject({ - name: 'FrameDecoderContinuationError', - cause: expect.objectContaining({ message: 'frame owner failed' }) - }) - expect(pause).toHaveBeenCalledTimes(1) - expect(resume).toHaveBeenCalledTimes(1) - expect(scheduler.pending()).toBe(0) - expect(decoder.drain()).toHaveLength(0) - - decoder.feed(frame(4)) - expect(seen).toEqual([1, 4]) - }) - - // feed() runs straight from a transport data handler. A frame owner that threw on the first - // turn used to escape feed() and reach uncaughtException. The continuation path was already - // contained; the synchronous path must match it. - it('contains a frame owner that throws on the synchronous turn and reports one typed error', () => { - const seen: number[] = [] - const onError = vi.fn() - const pause = vi.fn() - const resume = vi.fn() - const decoder = new FrameDecoder( - (decoded) => { - if (decoded.id === 2) { - throw new Error('frame owner failed') - } - seen.push(decoded.id) - }, - onError, - { pause, resume } - ) - - expect(() => decoder.feed(Buffer.concat([frame(1), frame(2), frame(3)]))).not.toThrow() - - expect(seen).toEqual([1]) - expect(onError).toHaveBeenCalledExactlyOnceWith(expect.any(FrameDecoderContinuationError)) - expect(onError.mock.calls[0]?.[0]).toMatchObject({ - cause: expect.objectContaining({ message: 'frame owner failed' }) - }) - expect(decoder.drain()).toHaveLength(0) - expect(pause).not.toHaveBeenCalled() - expect(resume).not.toHaveBeenCalled() - - decoder.feed(frame(4)) - expect(seen).toEqual([1, 4]) - }) - - it('keeps reads active for partial frames and incrementally discards oversized payloads', () => { - const errors: Error[] = [] - const seen: DecodedFrame[] = [] - const pause = vi.fn() - const decoder = new FrameDecoder( - (decoded) => seen.push(decoded), - (error) => errors.push(error), - { pause } - ) - const valid = frame(2, 'complete') - - decoder.feed(valid.subarray(0, HEADER_LENGTH + 2)) - expect(seen).toHaveLength(0) - expect(pause).not.toHaveBeenCalled() - decoder.feed(valid.subarray(HEADER_LENGTH + 2)) - expect(seen.map(({ id }) => id)).toEqual([2]) - - const oversizedHeader = Buffer.alloc(HEADER_LENGTH) - oversizedHeader[0] = MessageType.Regular - oversizedHeader.writeUInt32BE(3, 1) - oversizedHeader.writeUInt32BE(MAX_MESSAGE_SIZE + 1, 9) - decoder.feed(Buffer.concat([oversizedHeader, Buffer.alloc(32)])) - - expect(errors).toHaveLength(1) - expect(pause).not.toHaveBeenCalled() - decoder.reset() - decoder.feed(frame(4, 'after-reset')) - expect(seen.map(({ id }) => id)).toEqual([2, 4]) - }) - - it('drain and reset cancel continuation ownership without replaying residue', () => { - const scheduler = createScheduler() - const seen: number[] = [] - const resume = vi.fn() - const cancel = vi.fn(scheduler.cancel) - const decoder = new FrameDecoder((decoded) => seen.push(decoded.id), undefined, { - maxFramesPerTurn: 1, - schedule: scheduler.schedule, - cancelScheduled: cancel, - resume - }) - const second = frame(2, 'residue') - - decoder.feed(Buffer.concat([frame(1), second])) - const residue = decoder.drain() - - expect(seen).toEqual([1]) - expect(residue.equals(second)).toBe(true) - expect(cancel).toHaveBeenCalledTimes(1) - expect(resume).toHaveBeenCalledTimes(1) - expect(scheduler.pending()).toBe(0) - - decoder.feed(Buffer.concat([frame(3), frame(4)])) - decoder.reset() - expect(cancel).toHaveBeenCalledTimes(2) - expect(scheduler.pending()).toBe(0) - - decoder.feed(frame(5)) - expect(seen).toEqual([1, 3, 5]) - }) -}) diff --git a/src/main/ssh/relay-socket-path-limit.test.ts b/src/main/ssh/relay-socket-path-limit.test.ts index ce878765e8d..8d58fbb10e0 100644 --- a/src/main/ssh/relay-socket-path-limit.test.ts +++ b/src/main/ssh/relay-socket-path-limit.test.ts @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +vi.mock('./ssh-relay-opencode-runtime', () => ({ + ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') +})) vi.mock('./ssh-relay-ripgrep-install', () => ({ remoteRipgrepLayout: vi.fn().mockReturnValue(null), recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false), diff --git a/src/main/ssh/remote-install-gc-termination.test.ts b/src/main/ssh/remote-install-gc-termination.test.ts new file mode 100644 index 00000000000..73b50e7b6f1 --- /dev/null +++ b/src/main/ssh/remote-install-gc-termination.test.ts @@ -0,0 +1,183 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as DeployHelpers from './ssh-relay-deploy-helpers' + +vi.mock('./ssh-relay-deploy-helpers', async (importOriginal) => ({ + ...(await importOriginal()), + execCommand: vi.fn() +})) + +import type { SshConnection } from './ssh-connection' +import { gcOldRelayVersions } from './remote-install-gc' +import { execCommand } from './ssh-relay-deploy-helpers' +import { gcRelayNativeDepsCache } from './ssh-relay-native-deps-cache-gc' +import { gcRemoteRipgrepCache } from './ssh-relay-ripgrep-cache-gc' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: all connection access is replaced by execCommand's mock. +const conn = {} as SshConnection +const host = getRemoteHostPlatform('linux-x64') +const home = '/home/u' +const currentDir = `${home}/.orca-remote/relay-0.1.0+bbb` +const nativeKey = 'linux-x64-0123456789abcdef' +const mockExec = vi.mocked(execCommand) + +beforeEach(() => { + mockExec.mockReset() + mockExec.mockResolvedValue('') +}) + +function failAfter(replies: readonly string[], confirmed = false): Error { + for (const reply of replies) { + mockExec.mockResolvedValueOnce(reply) + } + const error = Object.assign(new Error('SSH command timed out'), { + sshChannelCloseConfirmed: confirmed + }) + mockExec.mockRejectedValueOnce(error) + return error +} + +function collectRelayVersions(): Promise { + return gcOldRelayVersions(conn, home, currentDir, host, { nativeDepsCacheKeys: [nativeKey] }) +} + +const versionSteps = [ + ['listing', 'relay-0.1.0+aaa\nrelay-0.1.0+ccc'], + ['install lock probe', 'OPEN'], + ['completion probe', 'COMPLETE'], + ['liveness probe', 'DEAD'], + ['claim acquisition', 'OK'], + ['claim owner write', ''], + ['claimed install lock probe', 'OPEN'], + ['claimed completion probe', 'COMPLETE'], + ['claimed liveness probe', 'DEAD'], + ['claim ownership probe', 'OWNED'], + ['rename', 'MOVED'], + ['claim release', 'RELEASED'], + ['tombstone deletion', ''] +] as const + +describe('version GC termination', () => { + it.each(versionSteps.map(([phase], index) => ({ phase, index })))( + 'stops all cleanup after unconfirmed $phase termination', + async ({ index }) => { + const error = failAfter(versionSteps.slice(0, index).map(([, reply]) => reply)) + + await expect(collectRelayVersions()).rejects.toBe(error) + + expect(mockExec).toHaveBeenCalledTimes(index + 1) + } + ) + + it.each([false, true])( + 'stops after an unconfirmed stale lock probe with claim held: %s', + async (claimed) => { + const replies = claimed + ? versionSteps.slice(0, 6).map(([, reply]) => String(reply)) + : [versionSteps[0][1]] + const error = failAfter([...replies, 'LOCKED']) + + await expect(collectRelayVersions()).rejects.toBe(error) + + expect(mockExec).toHaveBeenCalledTimes(replies.length + 2) + } + ) + + it('stops after unconfirmed abandoned tombstone deletion', async () => { + const error = failAfter([ + 'relay-0.1.0+aaa.gc-tombstone.1.1\nrelay-0.1.0+ccc.gc-tombstone.1.1\nrelay-0.1.0+ddd' + ]) + + await expect(collectRelayVersions()).rejects.toBe(error) + + expect(mockExec).toHaveBeenCalledTimes(2) + }) + + it('preserves an unconfirmed native cache cleanup error for its caller', async () => { + const error = failAfter(['']) + + await expect(collectRelayVersions()).rejects.toBe(error) + + expect(mockExec).toHaveBeenCalledTimes(2) + }) +}) + +const caches = [ + { + name: 'native dependencies', + collect: () => gcRelayNativeDepsCache(conn, host, home), + listing: `ENTRY ${nativeKey}\nENTRY linux-x64-fedcba9876543210\n__ORCA_NATIVE_CACHE__LIST_OK`, + references: '__ORCA_NATIVE_CACHE__REFS_OK' + }, + { + name: 'ripgrep', + collect: () => gcRemoteRipgrepCache(conn, host, home), + listing: + 'ENTRY 0123456789abcdef-linux-x64\nENTRY fedcba9876543210-linux-x64\n__ORCA_RG_CACHE__LIST_OK', + references: '__ORCA_RG_CACHE__REFS_OK' + } +] + +describe.each(caches)('$name cache GC termination', ({ collect, listing, references }) => { + const steps = [ + ['listing', listing], + ['reference scan', references], + ['rename', 'MOVED'], + ['reference recheck', references], + ['tombstone deletion', ''] + ] as const + + it.each(steps.map(([phase], index) => ({ phase, index })))( + 'preserves unconfirmed $phase termination and stops collection', + async ({ index, phase }) => { + const error = failAfter(steps.slice(0, index).map(([, reply]) => reply)) + + await expect(collect()).rejects.toBe(error) + + expect(mockExec).toHaveBeenCalledTimes(index + (phase === 'reference recheck' ? 2 : 1)) + if (phase === 'reference recheck') { + expect(mockExec.mock.calls.at(-1)?.[1]).toContain('[ ! -e ') + } + } + ) + + it.each(steps.map(([phase], index) => ({ phase, index })))( + 'keeps a confirmed $phase failure nonfatal', + async ({ index }) => { + failAfter( + steps.slice(0, index).map(([, reply]) => reply), + true + ) + + await expect(collect()).resolves.toBeUndefined() + } + ) + + it('stops when restoring a tombstone has unconfirmed termination', async () => { + const error = failAfter([listing, references, 'MOVED', 'unreadable references']) + + await expect(collect()).rejects.toBe(error) + + expect(mockExec).toHaveBeenCalledTimes(5) + }) + + it.each([false, true])( + 'does not retry a failed restoration after an unconfirmed recheck: %s', + async (confirmed) => { + const error = failAfter([listing, references, 'MOVED']) + mockExec.mockRejectedValueOnce( + Object.assign(new Error('restore failed'), { sshChannelCloseConfirmed: confirmed }) + ) + + await expect(collect()).rejects.toBe(error) + + expect(mockExec).toHaveBeenCalledTimes(5) + } + ) + + it('keeps a confirmed restore failure nonfatal', async () => { + failAfter([listing, references, 'MOVED', 'unreadable references'], true) + + await expect(collect()).resolves.toBeUndefined() + }) +}) diff --git a/src/main/ssh/remote-install-gc.ts b/src/main/ssh/remote-install-gc.ts index a76d119cf8a..986154e356e 100644 --- a/src/main/ssh/remote-install-gc.ts +++ b/src/main/ssh/remote-install-gc.ts @@ -95,7 +95,10 @@ export async function gcOldRemoteInstallVersions( host, listRemoteInstallBaseDirsCommand(host, baseDir, model) ) - } catch { + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } return } const entries = listing @@ -169,6 +172,9 @@ export async function gcOldRemoteInstallVersions( } removed.push(name) } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } console.warn( `[${model.id}] GC failed for ${dir}: ${err instanceof Error ? err.message : String(err)}` ) @@ -198,7 +204,10 @@ async function isCandidateSafeToRemove( let lockProbe: string try { lockProbe = await execHostCommand(conn, host, probeInstallLockExistsCommand(host, lockDir)) - } catch { + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } return false } const lockState = lockProbe.trim() @@ -224,7 +233,12 @@ async function isCandidateSafeToRemove( conn, host, probeFileExistsCommand(host, completePath) - ).catch(() => 'PARTIAL') + ).catch((err) => { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + return 'PARTIAL' + }) if (completeProbe.trim() !== 'COMPLETE') { // Crashed-install partial; leave for the next deploy to recover. return false @@ -263,7 +277,11 @@ export async function gcOldRelayVersions( if (options?.nativeDepsCacheKeys?.length) { await gcRelayNativeDepsCache(conn, host, remoteHome, { pinnedKeys: options.nativeDepsCacheKeys - }).catch(() => {}) + }).catch((err) => { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + }) } } @@ -294,7 +312,10 @@ async function hasLiveRelaySocket( ) const state = out.trim() return state !== 'DEAD' && state !== 'WAITING' - } catch { + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } // Why: an inconclusive liveness probe must never authorize deletion. return true } diff --git a/src/main/ssh/remote-install-model.test.ts b/src/main/ssh/remote-install-model.test.ts index a12b2e8342f..fa32ea81940 100644 --- a/src/main/ssh/remote-install-model.test.ts +++ b/src/main/ssh/remote-install-model.test.ts @@ -21,6 +21,18 @@ import { const RELAY_DIRS = ['relay-0.1.0+abcdef123456', 'relay-v0.1.0', 'relay-1.2.3'] const ORCAD_DIRS = ['orcad-0.1.0+abcdef123456', 'orcad-v0.1.0', 'orcad-1.2.3'] +it.each([false, true])( + 'requires the executable and both profile workers on Windows=%s', + (isWindows) => { + const artifacts = ORCAD_INSTALL_MODEL.requiredArtifacts(isWindows) + expect(artifacts).toContain(isWindows ? 'bun-runtime.exe' : 'bun-runtime') + expect(artifacts).not.toContain(isWindows ? 'bun-runtime' : 'bun-runtime.exe') + expect(artifacts).toContain('profile-state-writer-worker-entry.js') + expect(artifacts).toContain('profile-state-backup-worker-entry.js') + expect(artifacts.includes('windows-process-tree.node')).toBe(isWindows) + } +) + describe('remote install namespace', () => { it('names each model its own version dir', () => { expect(remoteInstallDirName(RELAY_INSTALL_MODEL, '0.1.0+aa')).toBe('relay-0.1.0+aa') @@ -30,7 +42,9 @@ describe('remote install namespace', () => { it('requires every shipped search binary in a completed standalone runtime install', () => { const required = ORCAD_INSTALL_MODEL.requiredArtifacts(false) expect(required).toEqual(expect.arrayContaining([...ORCAD_RIPGREP_ARTIFACTS])) - expect(ORCAD_INSTALL_MODEL.requiredArtifacts(true)).toEqual(required) + expect(ORCAD_INSTALL_MODEL.requiredArtifacts(true)).toEqual( + expect.arrayContaining([...ORCAD_RIPGREP_ARTIFACTS]) + ) }) it.skipIf(process.platform === 'win32')('rejects an install missing its search binary', () => { diff --git a/src/main/ssh/remote-install-model.ts b/src/main/ssh/remote-install-model.ts index 54be330a55f..e2a46fda547 100644 --- a/src/main/ssh/remote-install-model.ts +++ b/src/main/ssh/remote-install-model.ts @@ -53,9 +53,7 @@ export const ORCAD_INSTALL_MODEL: RemoteInstallModel = { nativeDepsPackageName: 'orca-orcad', versionFilename: ORCAD_VERSION_FILENAME, installCompleteFilename: ORCAD_INSTALL_COMPLETE_FILENAME, - // Why the parameter is ignored: orcad's forked children are the same three .js files on - // every host. The Windows-only console-list agent patch is a relay/node-pty concern. - requiredArtifacts: () => orcadArtifactFilenames() + requiredArtifacts: (isWindows) => orcadArtifactFilenames(isWindows ? 'win32' : '') } export const REMOTE_INSTALL_MODELS: readonly RemoteInstallModel[] = [ diff --git a/src/main/ssh/ssh-connection-auth-fallback.test.ts b/src/main/ssh/ssh-connection-auth-fallback.test.ts index 9e3760616c2..20c973e1b2c 100644 --- a/src/main/ssh/ssh-connection-auth-fallback.test.ts +++ b/src/main/ssh/ssh-connection-auth-fallback.test.ts @@ -179,6 +179,7 @@ describe('SshConnection', () => { 'target-1', 'password', 'example.com', + undefined, expect.any(AbortSignal) ) }) @@ -244,7 +245,7 @@ describe('SshConnection', () => { ], finish ) - for (let turn = 0; turn < 8 && finish.mock.calls.length === 0; turn += 1) { + for (let turn = 0; turn < 20 && finish.mock.calls.length === 0; turn += 1) { await Promise.resolve() } @@ -254,6 +255,7 @@ describe('SshConnection', () => { 'target-1', 'keyboard-interactive', 'Duo two-factor login\nSelect push or enter a passcode.\nOption:', + false, expect.any(AbortSignal) ) expect(onCredentialRequest).toHaveBeenNthCalledWith( @@ -261,6 +263,7 @@ describe('SshConnection', () => { 'target-1', 'keyboard-interactive', 'Duo two-factor login\nSelect push or enter a passcode.\nPasscode:', + false, expect.any(AbortSignal) ) expect(finish).toHaveBeenCalledWith(['1', '123456']) @@ -311,7 +314,7 @@ describe('SshConnection', () => { await vi.advanceTimersByTimeAsync(100_000) firstResponse.resolve('1') - for (let turn = 0; turn < 4 && onCredentialRequest.mock.calls.length < 2; turn += 1) { + for (let turn = 0; turn < 20 && onCredentialRequest.mock.calls.length < 2; turn += 1) { await Promise.resolve() } expect(onCredentialRequest).toHaveBeenCalledTimes(2) @@ -321,7 +324,7 @@ describe('SshConnection', () => { expect(finish).not.toHaveBeenCalled() secondResponse.resolve('123456') - for (let turn = 0; turn < 4 && finish.mock.calls.length === 0; turn += 1) { + for (let turn = 0; turn < 20 && finish.mock.calls.length === 0; turn += 1) { await Promise.resolve() } expect(finish).toHaveBeenCalledWith(['1', '123456']) @@ -337,7 +340,13 @@ describe('SshConnection', () => { ssh2Mock.connectBehavior = 'pending' let credentialSignal: AbortSignal | undefined const onCredentialRequest = vi.fn( - (_targetId: string, _kind: string, _detail: string, signal?: AbortSignal) => { + ( + _targetId: string, + _kind: string, + _detail: string, + _echo?: boolean, + signal?: AbortSignal + ) => { credentialSignal = signal const response = Promise.withResolvers() if (signal?.aborted) { @@ -405,6 +414,7 @@ describe('SshConnection', () => { 'target-1', 'passphrase', keyPath, + undefined, expect.any(AbortSignal) ) } finally { diff --git a/src/main/ssh/ssh-connection-gssapi-fallback.test.ts b/src/main/ssh/ssh-connection-gssapi-fallback.test.ts index 7dcc25410a8..8eeb6994a50 100644 --- a/src/main/ssh/ssh-connection-gssapi-fallback.test.ts +++ b/src/main/ssh/ssh-connection-gssapi-fallback.test.ts @@ -204,6 +204,7 @@ describe('SshConnection', () => { 'target-1', 'password', 'example.com', + undefined, expect.any(AbortSignal) ) }) diff --git a/src/main/ssh/ssh-connection-utils.test.ts b/src/main/ssh/ssh-connection-utils.test.ts index 53cdbf07dd6..00abeeb2052 100644 --- a/src/main/ssh/ssh-connection-utils.test.ts +++ b/src/main/ssh/ssh-connection-utils.test.ts @@ -514,6 +514,11 @@ describe('buildConnectConfig', () => { expect(config.keepaliveInterval).toBe(15_000) }) + it('enables keyboard-interactive auth so MFA challenges can be answered', () => { + const config = buildConnectConfig(makeTarget(), null) + expect(config.tryKeyboard).toBe(true) + }) + it('uses agent auth when no explicit key and SSH_AUTH_SOCK is set', () => { const config = buildConnectConfig(makeTarget(), null) expect(config.agent).toBe('/tmp/agent.sock') diff --git a/src/main/ssh/ssh-connection-utils.ts b/src/main/ssh/ssh-connection-utils.ts index 216e23e6203..48057b9c088 100644 --- a/src/main/ssh/ssh-connection-utils.ts +++ b/src/main/ssh/ssh-connection-utils.ts @@ -17,10 +17,13 @@ export type SshCredentialKind = 'passphrase' | 'password' | 'keyboard-interactiv export type SshConnectionCallbacks = { onStateChange: (targetId: string, state: SshConnectionState) => void + // Why: echo carries the server's RFC 4256 echo flag for keyboard-interactive + // prompts so the UI knows whether the typed response may be shown. onCredentialRequest?: ( targetId: string, kind: SshCredentialKind, detail: string, + echo?: boolean, signal?: AbortSignal ) => Promise } @@ -202,6 +205,9 @@ export function buildConnectConfig( username: effectiveUser, readyTimeout: CONNECT_TIMEOUT_MS, keepaliveInterval: 15_000, + // Why: lets servers deliver keyboard-interactive challenges (RFC 4256, + // commonly MFA after a partial password success). SshConnection answers + // them through the credential prompt callback. tryKeyboard: true } diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index a20167c2f4a..5535c5e7e8e 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { clientInstances, createSsh2Module, + emitSshEvent, eventHandlers, resetSshConnectionMocks, VALID_ED25519_HOST_KEY, @@ -277,4 +278,322 @@ describe('SshConnection', () => { await expect(conn.connect()).rejects.toThrow('Connection disposed') }) + + describe('keyboard-interactive MFA', () => { + it('completes password + keyboard-interactive MFA auth and forwards the prompt', async () => { + vi.stubEnv('SSH_AUTH_SOCK', '') + ssh2Mock.connectSequence = [ + new Error('All configured authentication methods failed'), + 'silent' + ] + const onCredentialRequest = vi.fn(async (_targetId: string, kind: string) => + kind === 'password' ? 'password-123' : '1' + ) + const conn = new SshConnection(createTarget(), createCallbacks({ onCredentialRequest })) + + const connectPromise = conn.connect() + await vi.waitFor(() => { + expect(eventHandlers.get('keyboard-interactive')?.size ?? 0).toBeGreaterThan(0) + expect(clientInstances).toHaveLength(2) + }) + const finish = vi.fn() + emitSshEvent( + 'keyboard-interactive', + '', + 'Choose a verification method:', + '', + [{ prompt: 'Passcode or option (1-2):', echo: true }], + finish + ) + await vi.waitFor(() => expect(finish).toHaveBeenCalledWith(['1'])) + emitSshEvent('ready') + await connectPromise + + expect(conn.getState().status).toBe('connected') + expect(clientInstances[1].lastConnectConfig).toMatchObject({ + tryKeyboard: true, + password: 'password-123' + }) + expect(onCredentialRequest).toHaveBeenCalledWith( + 'target-1', + 'password', + 'example.com', + undefined, + expect.any(AbortSignal) + ) + expect(onCredentialRequest).toHaveBeenCalledWith( + 'target-1', + 'keyboard-interactive', + 'Choose a verification method:\nPasscode or option (1-2):', + true, + expect.any(AbortSignal) + ) + }) + + it('auto-answers a keyboard-interactive password prompt with the cached password (echo=false)', async () => { + vi.stubEnv('SSH_AUTH_SOCK', '') + ssh2Mock.connectSequence = [ + new Error('All configured authentication methods failed'), + 'silent' + ] + const onCredentialRequest = vi.fn(async () => 'password-123') + const conn = new SshConnection(createTarget(), createCallbacks({ onCredentialRequest })) + + const connectPromise = conn.connect() + await vi.waitFor(() => { + expect(eventHandlers.get('keyboard-interactive')?.size ?? 0).toBeGreaterThan(0) + expect(clientInstances).toHaveLength(2) + }) + const finish = vi.fn() + emitSshEvent( + 'keyboard-interactive', + '', + '', + '', + [{ prompt: 'Password: ', echo: false }], + finish + ) + await vi.waitFor(() => expect(finish).toHaveBeenCalledWith(['password-123'])) + emitSshEvent('ready') + await connectPromise + + expect(conn.getState().status).toBe('connected') + expect(onCredentialRequest).toHaveBeenCalledTimes(1) + }) + + it('caches a password collected through a keyboard-interactive prompt, but never caches an OTP', async () => { + vi.stubEnv('SSH_AUTH_SOCK', '') + ssh2Mock.connectSequence = ['silent'] + const onCredentialRequest = vi.fn(async (_targetId: string, kind: string) => + kind === 'password' ? 'password-123' : '654321' + ) + const conn = new SshConnection(createTarget(), createCallbacks({ onCredentialRequest })) + + const connectPromise = conn.connect() + await vi.waitFor(() => + expect(eventHandlers.get('keyboard-interactive')?.size ?? 0).toBeGreaterThan(0) + ) + const finish = vi.fn() + emitSshEvent( + 'keyboard-interactive', + '', + '', + '', + [ + { prompt: 'Password: ', echo: false }, + { prompt: 'One-time password:', echo: false } + ], + finish + ) + await vi.waitFor(() => expect(finish).toHaveBeenCalledWith(['password-123', '654321'])) + emitSshEvent('ready') + await connectPromise + + expect(onCredentialRequest).toHaveBeenCalledWith( + 'target-1', + 'password', + 'example.com', + undefined, + expect.any(AbortSignal) + ) + expect(onCredentialRequest).toHaveBeenCalledWith( + 'target-1', + 'keyboard-interactive', + 'One-time password:', + false, + expect.any(AbortSignal) + ) + expect(conn.hasCachedCredential()).toBe(true) + + // Reconnect: the OTP prompt must be asked again (never auto-answered from a cache). + onCredentialRequest.mockClear() + ssh2Mock.connectSequence = ['silent'] + const privateConn = conn as unknown as { attemptConnect: () => Promise } + const reconnectPromise = privateConn.attemptConnect() + await vi.waitFor(() => + expect(eventHandlers.get('keyboard-interactive')?.size ?? 0).toBeGreaterThan(0) + ) + const finish2 = vi.fn() + emitSshEvent( + 'keyboard-interactive', + '', + '', + '', + [{ prompt: 'One-time password:', echo: false }], + finish2 + ) + await vi.waitFor(() => expect(finish2).toHaveBeenCalledWith(['654321'])) + // Why not answered from the password cache: this round only sends the + // OTP prompt (no password-looking prompt), so it must reach the user + // as a fresh 'keyboard-interactive' request every time. + expect(onCredentialRequest).toHaveBeenCalledWith( + 'target-1', + 'keyboard-interactive', + 'One-time password:', + false, + expect.any(AbortSignal) + ) + emitSshEvent('ready') + await reconnectPromise + }) + + it('re-prompts instead of replaying the cached password on a second keyboard-interactive round', async () => { + vi.stubEnv('SSH_AUTH_SOCK', '') + ssh2Mock.connectSequence = ['silent'] + const onCredentialRequest = vi.fn(async () => 'password-123') + const conn = new SshConnection(createTarget(), createCallbacks({ onCredentialRequest })) + + const connectPromise = conn.connect() + await vi.waitFor(() => + expect(eventHandlers.get('keyboard-interactive')?.size ?? 0).toBeGreaterThan(0) + ) + const finish = vi.fn() + emitSshEvent( + 'keyboard-interactive', + '', + '', + '', + [{ prompt: 'Password: ', echo: false }], + finish + ) + await vi.waitFor(() => expect(finish).toHaveBeenCalledWith(['password-123'])) + expect(onCredentialRequest).toHaveBeenCalledTimes(1) + + // The server rejected that password and opened a new round for it. Auto-answering from the + // cache again would replay the rejected value up to the round cap without asking the user. + onCredentialRequest.mockClear() + const finish2 = vi.fn() + emitSshEvent( + 'keyboard-interactive', + '', + '', + '', + [{ prompt: 'Password: ', echo: false }], + finish2 + ) + await vi.waitFor(() => expect(finish2).toHaveBeenCalledWith(['password-123'])) + expect(onCredentialRequest).toHaveBeenCalledWith( + 'target-1', + 'password', + 'example.com', + undefined, + expect.any(AbortSignal) + ) + + emitSshEvent('ready') + await connectPromise + }) + + it('does not fall back to the password prompt after a cancelled keyboard-interactive prompt', async () => { + vi.stubEnv('SSH_AUTH_SOCK', '') + ssh2Mock.connectSequence = ['silent'] + const onCredentialRequest = vi.fn(async () => null) + const conn = new SshConnection(createTarget(), createCallbacks({ onCredentialRequest })) + + const connectPromise = conn.connect() + connectPromise.catch(() => {}) + await vi.waitFor(() => + expect(eventHandlers.get('keyboard-interactive')?.size ?? 0).toBeGreaterThan(0) + ) + const finish = vi.fn() + emitSshEvent( + 'keyboard-interactive', + '', + '', + '', + [{ prompt: 'Duo passcode:', echo: false }], + finish + ) + await vi.waitFor(() => expect(finish).toHaveBeenCalledWith([])) + + // Why no server error event: a cancelled prompt now fails the attempt + // immediately instead of waiting on a server round-trip that will + // never come from a user decision. + await expect(connectPromise).rejects.toThrow('Keyboard-interactive authentication cancelled') + expect(onCredentialRequest).toHaveBeenCalledTimes(1) + expect(conn.getState().status).toBe('auth-failed') + }) + + it('does not restore the password cache when a prompt resolves after disconnect', async () => { + vi.stubEnv('SSH_AUTH_SOCK', '') + ssh2Mock.connectSequence = ['silent'] + let answer: (value: string) => void = () => {} + const onCredentialRequest = vi.fn( + () => + new Promise((resolve) => { + answer = resolve + }) + ) + const conn = new SshConnection(createTarget(), createCallbacks({ onCredentialRequest })) + const connecting = conn.connect() + connecting.catch(() => {}) + await vi.waitFor(() => expect(eventHandlers.has('keyboard-interactive')).toBe(true)) + const finish = vi.fn() + emitSshEvent( + 'keyboard-interactive', + '', + '', + '', + [{ prompt: 'Password:', echo: false }], + finish + ) + await vi.waitFor(() => expect(onCredentialRequest).toHaveBeenCalledOnce()) + await conn.disconnect() + answer('obsolete-password') + await vi.waitFor(() => expect(finish).toHaveBeenCalledWith([])) + expect(conn.hasCachedCredential()).toBe(false) + expect(conn.getState().status).toBe('disconnected') + await expect(connecting).rejects.toThrow() + }) + + it('does not treat a missing credential callback as user cancellation', async () => { + vi.stubEnv('SSH_AUTH_SOCK', '') + ssh2Mock.connectSequence = ['silent'] + const conn = new SshConnection(createTarget(), createCallbacks()) + const connecting = conn.connect() + connecting.catch(() => {}) + await vi.waitFor(() => expect(eventHandlers.has('keyboard-interactive')).toBe(true)) + const finish = vi.fn() + emitSshEvent('keyboard-interactive', '', '', '', [{ prompt: 'Code:', echo: false }], finish) + await vi.waitFor(() => expect(finish).toHaveBeenCalledWith([])) + emitSshEvent('error', new Error('All configured authentication methods failed')) + await expect(connecting).rejects.toThrow('All configured authentication methods failed') + conn.disconnect() + }) + + it('fails auth (not cancellation) when the server rejects an incorrect MFA answer', async () => { + vi.stubEnv('SSH_AUTH_SOCK', '') + ssh2Mock.connectSequence = [ + 'silent', + new Error('All configured authentication methods failed') + ] + const onCredentialRequest = vi.fn(async () => '000000') + const conn = new SshConnection(createTarget(), createCallbacks({ onCredentialRequest })) + + const connectPromise = conn.connect() + connectPromise.catch(() => {}) + await vi.waitFor(() => + expect(eventHandlers.get('keyboard-interactive')?.size ?? 0).toBeGreaterThan(0) + ) + const finish = vi.fn() + emitSshEvent( + 'keyboard-interactive', + '', + '', + '', + [{ prompt: 'Verification code:', echo: false }], + finish + ) + await vi.waitFor(() => expect(finish).toHaveBeenCalledWith(['000000'])) + // Why the second connectSequence entry is a plain error rather than + // another keyboard-interactive round: an incorrect OTP is a distinct + // case from cancellation — the user DID answer, the server rejected + // it — so this must not go through the keyboardInteractiveCancelled + // short-circuit at all. + emitSshEvent('error', new Error('All configured authentication methods failed')) + + await expect(connectPromise).rejects.toThrow('All configured authentication methods failed') + expect(conn.getState().status).toBe('auth-failed') + }) + }) }) diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index 98d383c5c92..448efe0ad38 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -45,6 +45,7 @@ import { type SshConnectionCallbacks, type SshCredentialKind } from './ssh-connection-utils' +import { collectKeyboardInteractiveResponses } from './ssh-keyboard-interactive' import { resolveEffectiveProxy, spawnProxyCommand } from './ssh-proxy-command' import { createHostKeyVerifier, @@ -114,7 +115,6 @@ const HOST_KEY_SOURCE_READ_TIMEOUT_MS = 5_000 const SSH_KEYBOARD_INTERACTIVE_MAX_ROUNDS = 8 const SSH_KEYBOARD_INTERACTIVE_READY_TIMEOUT_MS = SSH_CREDENTIAL_TIMEOUT_MS + 5_000 const SSH_KEYBOARD_INTERACTIVE_MAX_PROMPTS = 8 -const SSH_KEYBOARD_INTERACTIVE_TEXT_MAX = 4_096 // Upper bound on waiting for an aborted channel's open/close to settle before rejecting anyway. const ABORTED_CHANNEL_CLOSE_GRACE_MS = 5_000 @@ -206,6 +206,9 @@ export class SshConnection { private disposed = false private cachedPassphrase: string | null = null private cachedPassword: string | null = null + private keyboardInteractiveCancelled = false + // A rejected cached password must reach the user on the next round. + private keyboardInteractivePasswordState = { passwordAutoAnswered: false } private hostKeyFingerprint: string | undefined private connectGeneration = 0 @@ -224,6 +227,9 @@ export class SshConnection { getState(): SshConnectionState { return { ...this.state } } + getConnectGeneration(): number { + return this.connectGeneration + } getClient(): SshClient | null { return this.client } @@ -733,7 +739,8 @@ export class SshConnection { private async requestCredential( kind: SshCredentialKind, detail: string, - connectGeneration: number + connectGeneration: number, + echo?: boolean ): Promise { if (this.disposed || connectGeneration !== this.connectGeneration) { return undefined @@ -742,14 +749,16 @@ export class SshConnection { this.target.id, kind, detail, + echo, this.credentialAbortController.signal ) } private async answerKeyboardInteractive( + hostDetail: string, name: string, instructions: string, - prompts: readonly Prompt[], + prompts: Prompt[], connectGeneration: number, onPromptStart: () => void ): Promise { @@ -757,25 +766,36 @@ export class SshConnection { return null } const heading = [name.trim(), instructions.trim()].filter(Boolean).join('\n') - const responses: string[] = [] - for (const prompt of prompts) { - onPromptStart() - const promptText = prompt.prompt.trim() || 'Verification response' - const detail = [heading, promptText] - .filter(Boolean) - .join('\n') - .slice(0, SSH_KEYBOARD_INTERACTIVE_TEXT_MAX) - const response = await this.requestCredential( - 'keyboard-interactive', - detail, - connectGeneration - ) - if (response === null || response === undefined) { - return null - } - responses.push(response) - } - return this.disposed || connectGeneration !== this.connectGeneration ? null : responses + const isCurrent = (): boolean => !this.disposed && connectGeneration === this.connectGeneration + const responses = await collectKeyboardInteractiveResponses( + { + targetId: this.target.id, + hostDetail, + // Preserve a missing prompter as a capability gap, not a cancellation. + requestCredential: this.callbacks.onCredentialRequest + ? async (_targetId, kind, detail, echo) => + (await this.requestCredential(kind, detail, connectGeneration, echo)) ?? null + : undefined, + getCachedPassword: () => this.cachedPassword, + setCachedPassword: (value) => { + if (isCurrent()) { + this.cachedPassword = value + } + }, + markCancelled: () => { + // A stale prompt must not cancel the current attempt. + if (isCurrent()) { + this.keyboardInteractiveCancelled = true + } + }, + isCancelled: () => !isCurrent() || this.keyboardInteractiveCancelled, + state: this.keyboardInteractivePasswordState + }, + heading, + prompts, + onPromptStart + ) + return isCurrent() ? responses : null } private async attemptConnect(connectGeneration = ++this.connectGeneration): Promise { @@ -784,6 +804,8 @@ export class SshConnection { this.setState('connecting') this.proxyProcess?.kill() this.proxyProcess = null + this.keyboardInteractiveCancelled = false + this.keyboardInteractivePasswordState = { passwordAutoAnswered: false } const resolved = await resolveWithSshG(this.target.configHost || this.target.label).catch( () => null @@ -871,6 +893,13 @@ export class SshConnection { throw err } + // Cancellation must stop the credential and transport fallback chain. + if (this.keyboardInteractiveCancelled) { + this.proxyProcess?.kill() + this.proxyProcess = null + throw err + } + if (isSystemSshFallbackError(err)) { this.proxyProcess?.kill() this.proxyProcess = null @@ -918,6 +947,12 @@ export class SshConnection { this.proxyProcess = null throw keyErr } + // Key fallback must honor the same cancellation boundary. + if (this.keyboardInteractiveCancelled) { + this.proxyProcess?.kill() + this.proxyProcess = null + throw keyErr + } authError = keyErr const passphraseKeyPath = getPassphrasePrivateKeyPath(keyConfig) // Why: with GSSAPI enabled, let the reactive system-ssh probe try a Kerberos ticket before prompting for the passphrase; the prompt still runs if it fails. @@ -1526,10 +1561,33 @@ export class SshConnection { return } rearmStartupTimer(SSH_KEYBOARD_INTERACTIVE_READY_TIMEOUT_MS) - void this.answerKeyboardInteractive(name, instructions, prompts, connectGeneration, () => - rearmStartupTimer(SSH_KEYBOARD_INTERACTIVE_READY_TIMEOUT_MS) + void this.answerKeyboardInteractive( + config.host || this.target.label, + name, + instructions, + prompts, + connectGeneration, + () => rearmStartupTimer(SSH_KEYBOARD_INTERACTIVE_READY_TIMEOUT_MS) ).then( (responses) => { + // Settle cancellation immediately, but still answer ssh2’s pending callback. + if ( + responses === null && + (connectGeneration !== this.connectGeneration || this.keyboardInteractiveCancelled) + ) { + finish([]) + if (!settled) { + // Preserve the auth-failed state for explicit cancellation. + const cancelledError = Object.assign( + new Error( + `Keyboard-interactive authentication cancelled for ${this.target.label}` + ), + { level: 'client-authentication' } + ) + onStartupError(cancelledError) + } + return + } const attemptIsCurrent = !settled && !this.disposed && connectGeneration === this.connectGeneration finish(attemptIsCurrent ? (responses ?? []) : []) diff --git a/src/main/ssh/ssh-keyboard-interactive-wire.test.ts b/src/main/ssh/ssh-keyboard-interactive-wire.test.ts new file mode 100644 index 00000000000..5f45f27a52d --- /dev/null +++ b/src/main/ssh/ssh-keyboard-interactive-wire.test.ts @@ -0,0 +1,118 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Server, utils, type Connection } from 'ssh2' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SshConnection } from './ssh-connection' +import { createCallbacks, createResolvedConfig, createTarget } from './ssh-connection-test-fixtures' +import { initSshHostKeyStoreFile } from './ssh-host-key-store' +import { resolveWithSshG } from './ssh-config-parser' +import type * as SshConfigParser from './ssh-config-parser' + +vi.mock('./ssh-config-parser', async (importOriginal) => ({ + ...(await importOriginal()), + resolveWithSshG: vi.fn() +})) + +describe('keyboard-interactive over a real SSH socket', () => { + let profile: string + let server: Server + let conn: SshConnection | undefined + const sockets = new Set() + + beforeEach(() => { + profile = mkdtempSync(join(tmpdir(), 'orca-keyboard-wire-')) + initSshHostKeyStoreFile(join(profile, 'host-keys.json')) + vi.stubEnv('SSH_AUTH_SOCK', '') + vi.mocked(resolveWithSshG).mockResolvedValue( + createResolvedConfig({ + hostname: '127.0.0.1', + identitiesOnly: true, + identityAgent: 'none', + proxyUseFdpass: false, + strictHostKeyChecking: 'no' + }) + ) + }) + + afterEach(async () => { + await conn?.disconnect() + for (const socket of sockets) { + socket.end() + } + sockets.clear() + if (server) { + await new Promise((resolve) => server.close(() => resolve())) + } + vi.unstubAllEnvs() + rmSync(profile, { recursive: true, force: true }) + }) + + it.each([false, true])( + 'completes password then MFA with empty push response=%s', + async (empty) => { + const received: string[][] = [] + const methods: string[] = [] + server = new Server( + { hostKeys: [utils.generateKeyPairSync('ecdsa', { bits: 256 }).private] }, + (socket) => { + sockets.add(socket) + socket.on('error', () => {}) + socket.on('close', () => sockets.delete(socket)) + let passwordAccepted = false + socket.on('authentication', (context) => { + methods.push(context.method) + if (context.method === 'password' && context.password === 'fixture-password') { + passwordAccepted = true + context.reject(['keyboard-interactive'], true) + } else if (context.method === 'keyboard-interactive' && passwordAccepted) { + context.prompt( + [ + { + prompt: empty ? 'Press Enter for push:' : 'Passcode or option (1-2):', + echo: true + } + ], + 'MFA', + 'Choose verification:', + (answers) => { + received.push(answers) + if (answers[0] === (empty ? '' : '1')) { + context.accept() + } else { + context.reject(['keyboard-interactive']) + } + } + ) + } else { + context.reject(['password']) + } + }) + } + ) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('Fixture did not bind') + } + const onCredentialRequest = vi.fn(async (_id: string, kind: string) => + kind === 'password' ? 'fixture-password' : empty ? '' : '1' + ) + conn = new SshConnection( + createTarget({ host: '127.0.0.1', port: address.port }), + createCallbacks({ onCredentialRequest }) + ) + await conn.connect() + expect(conn.getState().status).toBe('connected') + expect(received).toEqual([[empty ? '' : '1']]) + expect(methods).toContain('password') + expect(onCredentialRequest).toHaveBeenLastCalledWith( + 'target-1', + 'keyboard-interactive', + `MFA\nChoose verification:\n${empty ? 'Press Enter for push:' : 'Passcode or option (1-2):'}`, + true, + expect.any(AbortSignal) + ) + } + ) +}) diff --git a/src/main/ssh/ssh-keyboard-interactive.test.ts b/src/main/ssh/ssh-keyboard-interactive.test.ts new file mode 100644 index 00000000000..0c426fbdb5f --- /dev/null +++ b/src/main/ssh/ssh-keyboard-interactive.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it, vi } from 'vitest' +import { + collectKeyboardInteractiveResponses, + formatKeyboardInteractivePromptDetail, + isKeyboardInteractivePasswordPrompt, + type KeyboardInteractiveSession +} from './ssh-keyboard-interactive' + +function createSession( + overrides: Partial = {} +): KeyboardInteractiveSession & { cancelled: { value: boolean } } { + const cancelled = { value: false } + return { + targetId: 'target-1', + hostDetail: 'example.com', + requestCredential: vi.fn(async () => 'answer'), + getCachedPassword: () => null, + setCachedPassword: vi.fn(), + markCancelled: () => { + cancelled.value = true + }, + isCancelled: () => cancelled.value, + state: { passwordAutoAnswered: false }, + cancelled, + ...overrides + } +} + +describe('isKeyboardInteractivePasswordPrompt', () => { + it('matches masked password prompts', () => { + expect(isKeyboardInteractivePasswordPrompt({ prompt: 'Password: ', echo: false })).toBe(true) + expect(isKeyboardInteractivePasswordPrompt({ prompt: "user@host's PASSWORD:" })).toBe(true) + }) + + it('rejects echoed prompts even when they mention a password', () => { + expect(isKeyboardInteractivePasswordPrompt({ prompt: 'Password: ', echo: true })).toBe(false) + }) + + it('rejects one-time password and OTP prompts', () => { + expect(isKeyboardInteractivePasswordPrompt({ prompt: 'One-time password:', echo: false })).toBe( + false + ) + expect(isKeyboardInteractivePasswordPrompt({ prompt: 'OTP password code:', echo: false })).toBe( + false + ) + }) + + it('rejects verification prompts that never mention a password', () => { + expect(isKeyboardInteractivePasswordPrompt({ prompt: 'Passcode:', echo: false })).toBe(false) + expect(isKeyboardInteractivePasswordPrompt({ prompt: 'Duo push sent', echo: false })).toBe( + false + ) + }) +}) + +describe('formatKeyboardInteractivePromptDetail', () => { + it('joins instructions and prompt on separate lines', () => { + expect(formatKeyboardInteractivePromptDetail('Pick an option:', 'Passcode: ')).toBe( + 'Pick an option:\nPasscode:' + ) + }) + + it('drops the missing half', () => { + expect(formatKeyboardInteractivePromptDetail('', 'Passcode: ')).toBe('Passcode:') + expect(formatKeyboardInteractivePromptDetail('Approve the push. ', '')).toBe( + 'Approve the push.' + ) + }) +}) + +describe('collectKeyboardInteractiveResponses', () => { + it('forwards verification prompts with instructions and the echo flag', async () => { + const requestCredential = vi.fn(async () => '1') + const session = createSession({ requestCredential }) + + const responses = await collectKeyboardInteractiveResponses(session, 'Choose an option:', [ + { prompt: 'Passcode or option (1-2):', echo: true } + ]) + + expect(responses).toEqual(['1']) + expect(requestCredential).toHaveBeenCalledWith( + 'target-1', + 'keyboard-interactive', + 'Choose an option:\nPasscode or option (1-2):', + true + ) + }) + + it('answers a password prompt from the cache without prompting', async () => { + const requestCredential = vi.fn(async () => 'unused') + const session = createSession({ + requestCredential, + getCachedPassword: () => 'password-123' + }) + + const responses = await collectKeyboardInteractiveResponses(session, '', [ + { prompt: 'Password: ', echo: false } + ]) + + expect(responses).toEqual(['password-123']) + expect(requestCredential).not.toHaveBeenCalled() + expect(session.state.passwordAutoAnswered).toBe(true) + }) + + it('re-prompts when the server rejects the auto-answered cached password', async () => { + const requestCredential = vi.fn(async () => 'corrected-password') + const setCachedPassword = vi.fn() + const session = createSession({ + requestCredential, + getCachedPassword: () => 'stale-password', + setCachedPassword, + state: { passwordAutoAnswered: true } + }) + + const responses = await collectKeyboardInteractiveResponses(session, '', [ + { prompt: 'Password: ', echo: false } + ]) + + expect(responses).toEqual(['corrected-password']) + expect(requestCredential).toHaveBeenCalledWith('target-1', 'password', 'example.com') + expect(setCachedPassword).toHaveBeenCalledWith('corrected-password') + }) + + it('collects and caches the password when nothing is cached yet', async () => { + const requestCredential = vi.fn(async () => 'password-123') + const setCachedPassword = vi.fn() + const session = createSession({ requestCredential, setCachedPassword }) + + const responses = await collectKeyboardInteractiveResponses(session, '', [ + { prompt: 'Password: ', echo: false }, + { prompt: 'Duo push approval', echo: false } + ]) + + expect(responses).toEqual(['password-123', 'password-123']) + expect(requestCredential).toHaveBeenNthCalledWith(1, 'target-1', 'password', 'example.com') + expect(requestCredential).toHaveBeenNthCalledWith( + 2, + 'target-1', + 'keyboard-interactive', + 'Duo push approval', + false + ) + expect(setCachedPassword).toHaveBeenCalledWith('password-123') + }) + + it('returns null and marks the session cancelled when the user dismisses a prompt', async () => { + const session = createSession({ requestCredential: vi.fn(async () => null) }) + + const responses = await collectKeyboardInteractiveResponses(session, '', [ + { prompt: 'Duo passcode:', echo: false } + ]) + + expect(responses).toBeNull() + expect(session.isCancelled()).toBe(true) + }) + + it('short-circuits every later round once cancelled', async () => { + const requestCredential = vi.fn(async () => 'answer') + const session = createSession({ requestCredential }) + session.markCancelled() + + const responses = await collectKeyboardInteractiveResponses(session, '', [ + { prompt: 'Duo passcode:', echo: false } + ]) + + expect(responses).toBeNull() + expect(requestCredential).not.toHaveBeenCalled() + }) + + it('returns null when no credential prompter is available', async () => { + const session = createSession({ requestCredential: undefined }) + + const responses = await collectKeyboardInteractiveResponses(session, '', [ + { prompt: 'Password: ', echo: false } + ]) + + expect(responses).toBeNull() + }) + + it('discards a password resolved after the attempt is cancelled', async () => { + let answer: (value: string) => void = () => {} + const requestCredential = vi + .fn(async () => 'code') + .mockImplementationOnce( + () => + new Promise((resolve) => { + answer = resolve + }) + ) + const setCachedPassword = vi.fn() + const session = createSession({ requestCredential, setCachedPassword }) + const collecting = collectKeyboardInteractiveResponses(session, '', [ + { prompt: 'Password:', echo: false }, + { prompt: 'Code:', echo: false } + ]) + session.markCancelled() + answer('obsolete-password') + expect(await collecting).toBeNull() + expect(setCachedPassword).not.toHaveBeenCalled() + expect(requestCredential).toHaveBeenCalledTimes(1) + }) + + it('accepts an empty response without caching it as a password', async () => { + const requestCredential = vi.fn(async () => '') + const setCachedPassword = vi.fn() + const session = createSession({ requestCredential, setCachedPassword }) + + const responses = await collectKeyboardInteractiveResponses(session, '', [ + { prompt: 'Password: ', echo: false } + ]) + + expect(responses).toEqual(['']) + expect(setCachedPassword).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ssh/ssh-keyboard-interactive.ts b/src/main/ssh/ssh-keyboard-interactive.ts new file mode 100644 index 00000000000..fae20287848 --- /dev/null +++ b/src/main/ssh/ssh-keyboard-interactive.ts @@ -0,0 +1,111 @@ +import type { Prompt } from 'ssh2' +import type { SshConnectionCallbacks } from './ssh-connection-utils' + +// Why: servers commonly reuse keyboard-interactive (RFC 4256) to collect the +// login password itself before issuing MFA challenges. Password-looking +// prompts route through the password credential flow (and its cache) so +// reconnects stay silent; every other prompt needs a fresh human answer. +const PASSWORD_PROMPT = /password/i +// Why: a one-time code answered with the cached login password would burn MFA +// attempts (and can lock the account) on every reconnect. +const ONE_TIME_PROMPT = /one.?time|otp/i +// Why: bounds a malicious/misbehaving server's prompt text before it reaches the credential dialog. +const PROMPT_DETAIL_MAX = 4_096 + +export function isKeyboardInteractivePasswordPrompt(prompt: Prompt): boolean { + return ( + prompt.echo !== true && + PASSWORD_PROMPT.test(prompt.prompt) && + !ONE_TIME_PROMPT.test(prompt.prompt) + ) +} + +export function formatKeyboardInteractivePromptDetail( + instructions: string, + promptText: string +): string { + const trimmedInstructions = instructions.trim() + const trimmedPrompt = promptText.trim() + const detail = + !trimmedInstructions || !trimmedPrompt + ? trimmedInstructions || trimmedPrompt + : `${trimmedInstructions}\n${trimmedPrompt}` + return detail.slice(0, PROMPT_DETAIL_MAX) +} + +export type KeyboardInteractiveSession = { + targetId: string + hostDetail: string + requestCredential: SshConnectionCallbacks['onCredentialRequest'] + getCachedPassword: () => string | null + setCachedPassword: (value: string) => void + markCancelled: () => void + isCancelled: () => boolean + // Why: one cached-password auto-answer per connection attempt — a second + // password prompt in the same attempt means the server rejected the cached + // value, so the user must be asked again instead of looping a bad password. + state: { passwordAutoAnswered: boolean } +} + +// Answers one keyboard-interactive round. Returns null when the user +// cancelled (or no prompter is available); callers respond with no answers so +// the server fails the round and the regular auth-error flow takes over. +export async function collectKeyboardInteractiveResponses( + session: KeyboardInteractiveSession, + instructions: string, + prompts: Prompt[], + onPromptStart?: () => void +): Promise { + if (session.isCancelled()) { + return null + } + // Why: a missing prompter is a capability gap, not a user decision — it + // must return null WITHOUT calling markCancelled(), or SshConnection would + // treat it as an explicit decline and skip its passphrase/password rungs. + if (!session.requestCredential) { + return null + } + const responses: string[] = [] + for (const prompt of prompts) { + onPromptStart?.() + const value = isKeyboardInteractivePasswordPrompt(prompt) + ? await answerPasswordPrompt(session) + : await session.requestCredential( + session.targetId, + 'keyboard-interactive', + formatKeyboardInteractivePromptDetail(instructions, prompt.prompt), + prompt.echo + ) + if (session.isCancelled()) { + return null + } + if (value == null) { + session.markCancelled() + return null + } + responses.push(value) + } + return responses +} + +async function answerPasswordPrompt( + session: KeyboardInteractiveSession +): Promise { + const cached = session.getCachedPassword() + if (cached != null && !session.state.passwordAutoAnswered) { + session.state.passwordAutoAnswered = true + return cached + } + const value = await session.requestCredential?.(session.targetId, 'password', session.hostDetail) + if (session.isCancelled()) { + return null + } + if (value == null) { + return value + } + session.state.passwordAutoAnswered = true + if (value) { + session.setCachedPassword(value) + } + return value +} diff --git a/src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts b/src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts index afb365c075c..7a503f51867 100644 --- a/src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts +++ b/src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts @@ -214,7 +214,7 @@ describe('sweepOrphanedRelayPtys', () => { clearBindingsForTarget: () => {}, clearBindingsForLeases: () => false, flush: () => {}, - flushDurableStateOrThrowAsync: async () => {} + runDurableMutation: async (mutate) => mutate().value } // The same pane re-leases under a new relay id; pty-1 is expired, never terminated. upsertSshRemotePtyLease(operations, { diff --git a/src/main/ssh/ssh-pending-pty-kill-replay.test.ts b/src/main/ssh/ssh-pending-pty-kill-replay.test.ts index 7f682c6c1b6..95296aa6434 100644 --- a/src/main/ssh/ssh-pending-pty-kill-replay.test.ts +++ b/src/main/ssh/ssh-pending-pty-kill-replay.test.ts @@ -134,6 +134,28 @@ describe('replayPendingSshPtyKills', () => { expect(terminated).toEqual(['pty-1']) }) + // An offline close across a relaunch never learned the incarnation; the epoch-scoped id is the fence. + it('replays an epoch-scoped stop that carries no incarnation', async () => { + const relayPtyId = 'pty2:epoch-a:4' + const { store, cleared, terminated } = createStoreStub([ + { ptyId: relayPtyId, intent: { requestedAt: NOW, attempts: 0 } } + ]) + const { provider, shutdown } = createProviderStub([{ relayPtyId, incarnationId: 'inc-live' }]) + await replayPendingSshPtyKills({ + targetId: TARGET, + store, + provider, + shouldContinue: () => true, + now: () => NOW + }) + expect(shutdown).toHaveBeenCalledWith(`ssh:ssh-1@@${relayPtyId}`, { + immediate: true, + expectedIncarnationId: undefined + }) + expect(cleared).toEqual([relayPtyId]) + expect(terminated).toEqual([relayPtyId]) + }) + // #16970: a redeployed relay renumbers from pty-1, so this id now names someone else's shell. it('refuses to kill a recycled relay id and expires the lease that named it', async () => { const { store, cleared, terminated, expired, recycled } = createStoreStub([ diff --git a/src/main/ssh/ssh-pty-consumer-recovery.ts b/src/main/ssh/ssh-pty-consumer-recovery.ts index db49a45c399..727f293b572 100644 --- a/src/main/ssh/ssh-pty-consumer-recovery.ts +++ b/src/main/ssh/ssh-pty-consumer-recovery.ts @@ -96,10 +96,7 @@ export async function removeSshPtyConsumerOwnerRecovery( clientInstanceId: string, store: Store ): Promise { - const persisted = store.getSshPtyConsumerRecovery(targetId) - if (persisted?.clientInstanceId === clientInstanceId) { - await store.removeSshPtyConsumerRecovery(targetId) - } + await store.removeSshPtyConsumerRecovery(targetId, clientInstanceId) } export function detachSshPtyConsumerRecovery(targetId: string, clientInstanceId: string): void { diff --git a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts index acda3bf0d69..be66463206a 100644 --- a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts +++ b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts @@ -45,6 +45,10 @@ vi.mock('./ssh-remote-node-resolution', () => ({ resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') })) +vi.mock('./ssh-relay-opencode-runtime', () => ({ + ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('not-needed') +})) + vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ ...(await importOriginal()), createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' diff --git a/src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts b/src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts index ef90e829f7b..4826f3c4dcc 100644 --- a/src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts +++ b/src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +vi.mock('./ssh-relay-opencode-runtime', () => ({ + ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') +})) vi.mock('./ssh-relay-ripgrep-install', () => ({ remoteRipgrepLayout: vi.fn().mockReturnValue(null), recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false), @@ -64,6 +67,7 @@ vi.mock('./ssh-relay-superseded-endpoints', () => ({ sweepSupersededRelayEndpoints: vi.fn().mockResolvedValue([]) })) import { sweepSupersededRelayEndpoints } from './ssh-relay-superseded-endpoints' +import { ensureRemoteOpenCodeRuntime } from './ssh-relay-opencode-runtime' import { gcOldRelayVersions } from './ssh-relay-versioned-install' import { deployAndLaunchRelay } from './ssh-relay-deploy' import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' @@ -198,10 +202,18 @@ describe('deployAndLaunchRelay honours the incumbent verdict', () => { onClose: vi.fn() }) vi.mocked(sweepSupersededRelayEndpoints).mockRejectedValueOnce(error) - await deployAndLaunchRelay(makeMockConnection()) + vi.mocked(ensureRemoteOpenCodeRuntime).mockResolvedValueOnce('not-needed') + const result = await deployAndLaunchRelay(makeMockConnection()) await vi.waitFor(() => expect(sweepSupersededRelayEndpoints).toHaveBeenCalledOnce()) await new Promise((resolve) => setImmediate(resolve)) expect(gcOldRelayVersions).toHaveBeenCalledTimes(expectedGcCalls) + const now = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 120_000) + try { + await result.prepareOpenCodeRuntime?.(new AbortController().signal) + expect(ensureRemoteOpenCodeRuntime).toHaveBeenCalledTimes(expectedGcCalls + 1) + } finally { + now.mockRestore() + } } ) }) diff --git a/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts b/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts index 467ddc5f788..26ba5a2d990 100644 --- a/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts +++ b/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts @@ -52,6 +52,9 @@ vi.mock('./ssh-remote-node-resolution', () => ({ // Why: the post-launch ripgrep install is fire-and-forget and would drain the queued exec mocks. // Why: the post-launch ripgrep cache GC is fire-and-forget and would drain the queued exec mocks. vi.mock('./ssh-relay-ripgrep-cache-gc', () => ({ gcRemoteRipgrepCache: vi.fn() })) +vi.mock('./ssh-relay-opencode-runtime', () => ({ + ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') +})) vi.mock('./ssh-relay-ripgrep-install', async (importOriginal) => ({ ...(await importOriginal()), ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue('present'), @@ -320,6 +323,70 @@ describe('deployAndLaunchRelay staged uploads', () => { expect(uploadStageRemovals[0]).toContain('/.orca-remote/.upload-stages/claim-0') }) + it.each(['lock acquisition', 'locked recheck'])( + 'preserves the stage after uncertain %s termination', + async (phase) => { + const conn = makeMockConnection() + const error = Object.assign(new Error('install command still running'), { + sshChannelCloseConfirmed: false + }) + vi.mocked(isRelayAlreadyInstalled).mockResolvedValueOnce(false) + if (phase === 'lock acquisition') { + vi.mocked(acquireInstallLock).mockRejectedValueOnce(error) + } else { + vi.mocked(isRelayAlreadyInstalled).mockRejectedValueOnce(error) + } + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + if (command.includes('uname')) { + return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' + } + if (command === 'echo $HOME') { + return '/home/user' + } + return stageCommandResponse(command) ?? '' + }) + conn.writeFile = vi.fn().mockResolvedValue(undefined) + conn.uploadDirectory = vi.fn().mockResolvedValue(undefined) + + await expect(deployAndLaunchRelay(conn)).rejects.toBe(error) + expect( + vi + .mocked(execCommand) + .mock.calls.some( + ([, command]) => + /\.sftp-namespace-[0-9a-f]{32}/u.test(command) && command.includes('rm -rf') + ) + ).toBe(false) + expect(conn.exec).not.toHaveBeenCalled() + } + ) + + it('stops before launch when owned-stage cleanup cannot confirm termination', async () => { + const conn = makeMockConnection() + const error = Object.assign(new Error('stage removal still running'), { + sshChannelCloseConfirmed: false + }) + vi.mocked(isRelayAlreadyInstalled).mockResolvedValueOnce(false).mockResolvedValueOnce(true) + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + if (/\.sftp-namespace-[0-9a-f]{32}/u.test(command) && command.includes('rm -rf')) { + throw error + } + if (command.includes('uname')) { + return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' + } + if (command === 'echo $HOME') { + return '/home/user' + } + return stageCommandResponse(command) ?? '' + }) + conn.writeFile = vi.fn().mockResolvedValue(undefined) + conn.uploadDirectory = vi.fn().mockResolvedValue(undefined) + + await expect(deployAndLaunchRelay(conn)).rejects.toBe(error) + expect(conn.exec).not.toHaveBeenCalled() + expect(ensureRemoteBundledRipgrep).not.toHaveBeenCalled() + }) + it('runs bounded fixed-path recovery before a fresh upload', async () => { const conn = makeMockConnection() const events: string[] = [] @@ -399,6 +466,7 @@ describe('deployAndLaunchRelay staged uploads', () => { await deployAndLaunchRelay(conn) } + await vi.waitFor(() => expect(events).toHaveLength(24)) expect(events).toEqual(Array.from({ length: 12 }, () => ['launch-ready', 'recover']).flat()) const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command) const recoveryCommands = commands.filter((command) => command.includes('deleting_old=')) @@ -575,12 +643,15 @@ describe('deployAndLaunchRelay staged uploads', () => { await deployAndLaunchRelay(conn) expect(conn.uploadDirectory).toHaveBeenCalledTimes(2) }) - // Why: a cold host's rg upload is a multi-MB transfer. While the cleanup sweep was chained - // behind it, stale upload stages and superseded version dirs sat on the remote for that whole - // duration. A never-settling install stands in for that transfer. - it('sweeps stale upload stages without waiting for the ripgrep upload', async () => { + it('returns the relay before ripgrep finishes but defers stale-stage cleanup', async () => { const conn = makeMockConnection() - vi.mocked(ensureRemoteBundledRipgrep).mockReturnValueOnce(new Promise(() => {})) + let finishUpload = (): void => {} + vi.mocked(ensureRemoteBundledRipgrep).mockImplementationOnce( + () => + new Promise((resolve) => { + finishUpload = () => resolve('present') + }) + ) let socketProbe = 0 vi.mocked(execCommand).mockImplementation((_conn, command) => { if (command.includes('uname')) { @@ -595,7 +666,15 @@ describe('deployAndLaunchRelay staged uploads', () => { return Promise.resolve('') }) - await deployAndLaunchRelay(conn) + const deployed = await deployAndLaunchRelay(conn) + expect(deployed.transport).toBeDefined() + await new Promise((resolve) => setImmediate(resolve)) + expect( + vi + .mocked(execCommand) + .mock.calls.some(([, command]) => command.includes(RELAY_UPLOAD_STAGE_POOL_NAME)) + ).toBe(false) + finishUpload() await vi.waitFor(() => { const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command) diff --git a/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts b/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts index 4c913234117..1a49ec11590 100644 --- a/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts +++ b/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts @@ -64,6 +64,9 @@ vi.mock('../ripgrep/bundled-ripgrep-path', () => ({ // Why: the fire-and-forget ripgrep install would drain the queued exec mocks. // Why: the post-launch ripgrep cache GC is fire-and-forget and would drain the queued exec mocks. vi.mock('./ssh-relay-ripgrep-cache-gc', () => ({ gcRemoteRipgrepCache: vi.fn() })) +vi.mock('./ssh-relay-opencode-runtime', () => ({ + ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') +})) vi.mock('./ssh-relay-ripgrep-install', async (importOriginal) => ({ ...(await importOriginal()), ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue('present'), @@ -310,6 +313,7 @@ describe('deployAndLaunchRelay on Windows remotes', () => { .mockResolvedValueOnce('') .mockResolvedValueOnce('READY') .mockResolvedValueOnce('') // persist active pipe A + .mockResolvedValueOnce('') // deferred stale-stage cleanup A .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe B .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') .mockResolvedValueOnce('C:\\Users\\me user') @@ -321,6 +325,7 @@ describe('deployAndLaunchRelay on Windows remotes', () => { .mockResolvedValueOnce('') // persist active pipe B await deployAndLaunchRelay(connA, undefined, 300, 'target-a') + await new Promise((resolve) => setImmediate(resolve)) await deployAndLaunchRelay(connB, undefined, 300, 'target-b') const markerPaths = mockExecCommand.mock.calls diff --git a/src/main/ssh/ssh-relay-deploy.test.ts b/src/main/ssh/ssh-relay-deploy.test.ts index f2fdaee4ed2..27a70f60fdb 100644 --- a/src/main/ssh/ssh-relay-deploy.test.ts +++ b/src/main/ssh/ssh-relay-deploy.test.ts @@ -62,6 +62,9 @@ vi.mock('../ripgrep/bundled-ripgrep-path', () => ({ // Why: the fire-and-forget ripgrep install would drain the queued exec mocks. // Why: the post-launch ripgrep cache GC is fire-and-forget and would drain the queued exec mocks. vi.mock('./ssh-relay-ripgrep-cache-gc', () => ({ gcRemoteRipgrepCache: vi.fn() })) +vi.mock('./ssh-relay-opencode-runtime', () => ({ + ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') +})) vi.mock('./ssh-relay-ripgrep-install', async (importOriginal) => ({ ...(await importOriginal()), ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue('present'), @@ -97,6 +100,7 @@ vi.mock('./ssh-connection-utils', () => ({ })) import { deployAndLaunchRelay } from './ssh-relay-deploy' +import { ensureRemoteOpenCodeRuntime } from './ssh-relay-opencode-runtime' import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' import { resolveRemoteNodePath } from './ssh-remote-node-resolution' import { isRelayAlreadyInstalled, gcOldRelayVersions } from './ssh-relay-versioned-install' @@ -105,6 +109,7 @@ import { ensureRemoteBundledRipgrep, recordRemoteRipgrepReference } from './ssh-relay-ripgrep-install' +import { gcRemoteRipgrepCache } from './ssh-relay-ripgrep-cache-gc' import * as DeployTiming from './ssh-relay-deploy-timing' import type { SshConnection } from './ssh-connection' import type * as SshRemoteNodeResolution from './ssh-remote-node-resolution' @@ -162,6 +167,7 @@ function detachedLaunchCommand(conn: SshConnection): string | undefined { describe('deployAndLaunchRelay', () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(ensureRemoteOpenCodeRuntime).mockReset().mockResolvedValue('ready') vi.mocked(execCommand).mockReset().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') vi.mocked(waitForSentinel).mockReset().mockResolvedValue({ write: vi.fn(), @@ -445,24 +451,35 @@ describe('deployAndLaunchRelay', () => { expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) }) - it('does not retry when a session-limit failure races with a real install-state failure', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { - reason: 4 - }) - const installError = new Error('permission denied while checking relay install') - vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce(sessionLimitError) - vi.mocked(isRelayAlreadyInstalled).mockRejectedValueOnce(installError) - mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe - mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME + it.each([false, true])( + 'does not retry after a sibling failure (unconfirmed abort: %s)', + async (unconfirmed) => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + const sessionLimitError = Object.assign( + new Error('(SSH) Channel open failure: open failed'), + { + reason: 4 + } + ) + const installError = unconfirmed + ? Object.assign(new Error('probe still running'), { + name: 'AbortError', + sshChannelCloseConfirmed: false + }) + : new Error('permission denied while checking relay install') + vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce(sessionLimitError) + vi.mocked(isRelayAlreadyInstalled).mockRejectedValueOnce(installError) + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe + mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME - await expect(deployAndLaunchRelay(conn)).rejects.toBe(installError) - expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(1) - expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) - }) + await expect(deployAndLaunchRelay(conn)).rejects.toBe(installError) + expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(1) + expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) + } + ) - it('does not retry until the surviving first-attempt probe settles', async () => { + it('lets the surviving probe finish before retrying a refused SSH session', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { @@ -471,13 +488,19 @@ describe('deployAndLaunchRelay', () => { mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe let releaseRemoteHome: (home: string) => void = () => {} let remoteHomeSettled = false - mockExecCommand.mockReturnValueOnce( - new Promise((resolve) => { - releaseRemoteHome = (home: string) => { - remoteHomeSettled = true - resolve(home) - } - }) + const cancelledProbe = Object.assign(new Error('system SSH probe cancellation unconfirmed'), { + name: 'AbortError', + sshChannelCloseConfirmed: false + }) + mockExecCommand.mockImplementationOnce( + (_conn, _command, options) => + new Promise((resolve, reject) => { + options?.signal?.addEventListener('abort', () => reject(cancelledProbe), { once: true }) + releaseRemoteHome = (home: string) => { + remoteHomeSettled = true + resolve(home) + } + }) ) vi.mocked(resolveRemoteNodePath).mockImplementationOnce(() => Promise.reject(sessionLimitError)) vi.mocked(resolveRemoteNodePath).mockImplementationOnce(() => { @@ -487,7 +510,7 @@ describe('deployAndLaunchRelay', () => { return Promise.resolve('/usr/bin/node') }) - const deployPromise = deployAndLaunchRelay(conn) + const deployPromise = deployAndLaunchRelay(conn).catch((error: unknown) => error) await vi.waitFor(() => expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1)) await new Promise((resolve) => setImmediate(resolve)) expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) @@ -497,7 +520,7 @@ describe('deployAndLaunchRelay', () => { queueLaunchNamespaceAndDeadSocketProbe() mockExecCommand.mockResolvedValueOnce('READY') // socket poll releaseRemoteHome('/home/user') - await deployPromise + await expect(deployPromise).resolves.toHaveProperty('transport') expect(resolveRemoteNodePath).toHaveBeenCalledTimes(2) }) @@ -525,11 +548,16 @@ describe('deployAndLaunchRelay', () => { expect(launchCommand).not.toContain('.pty-source-credit-policy') }) - it.each([false, true])( - 'waits for the ripgrep upload before cleanup (upload failure: %s)', - async (fails) => { + it.each([ + [false, false], + [false, true], + [true, false], + [true, true] + ])( + 'waits for ripgrep before cleanup (concurrent exec: %s, upload failure: %s)', + async (concurrent, fails) => { const conn = makeMockConnection() - vi.mocked(conn.canRunConcurrentExecCommands).mockReturnValue(false) + vi.mocked(conn.canRunConcurrentExecCommands).mockReturnValue(concurrent) queueFreshLinuxDeploy() let finishUpload = (): void => {} vi.mocked(ensureRemoteBundledRipgrep).mockImplementationOnce( @@ -544,11 +572,36 @@ describe('deployAndLaunchRelay', () => { await new Promise((resolve) => setImmediate(resolve)) expect(execCommand).toHaveBeenCalledTimes(execCount) expect(gcOldRelayVersions).not.toHaveBeenCalled() + expect(ensureRemoteOpenCodeRuntime).toHaveBeenCalledTimes(concurrent ? 1 : 0) finishUpload() await vi.waitFor(() => expect(gcOldRelayVersions).toHaveBeenCalledOnce()) } ) + it.each([false, true])( + 'waits for SQLite setup before cleanup (concurrent exec: %s)', + async (concurrent) => { + const conn = makeMockConnection() + vi.mocked(conn.canRunConcurrentExecCommands).mockReturnValue(concurrent) + queueFreshLinuxDeploy() + let finishSetup!: () => void + vi.mocked(ensureRemoteOpenCodeRuntime).mockImplementationOnce( + () => + new Promise((resolve) => { + finishSetup = () => resolve('failed') + }) + ) + await deployAndLaunchRelay(conn) + await vi.waitFor(() => expect(ensureRemoteOpenCodeRuntime).toHaveBeenCalledOnce()) + const execCount = vi.mocked(execCommand).mock.calls.length + await new Promise((resolve) => setImmediate(resolve)) + expect(execCommand).toHaveBeenCalledTimes(execCount) + expect(gcOldRelayVersions).not.toHaveBeenCalled() + finishSetup() + await vi.waitFor(() => expect(gcOldRelayVersions).toHaveBeenCalledOnce()) + } + ) + it('does not launch or upload an unprotected binary when recording its reference fails', async () => { const conn = makeMockConnection() queueFreshLinuxDeploy() @@ -558,6 +611,84 @@ describe('deployAndLaunchRelay', () => { expect(ensureRemoteBundledRipgrep).not.toHaveBeenCalled() }) + it.each([false, true])( + 'skips cleanup after unconfirmed SQLite teardown (concurrent exec: %s)', + async (concurrent) => { + const conn = makeMockConnection() + vi.mocked(conn.canRunConcurrentExecCommands).mockReturnValue(concurrent) + queueFreshLinuxDeploy() + vi.mocked(ensureRemoteOpenCodeRuntime).mockResolvedValueOnce('teardown-unconfirmed') + await deployAndLaunchRelay(conn) + await vi.waitFor(() => expect(ensureRemoteOpenCodeRuntime).toHaveBeenCalledOnce()) + const execCount = vi.mocked(execCommand).mock.calls.length + await new Promise((resolve) => setImmediate(resolve)) + expect(execCommand).toHaveBeenCalledTimes(execCount) + expect(gcOldRelayVersions).not.toHaveBeenCalled() + expect(gcRemoteRipgrepCache).not.toHaveBeenCalled() + } + ) + + it('does not launch after an unconfirmed ripgrep reference write', async () => { + const conn = makeMockConnection() + queueFreshLinuxDeploy() + const error = Object.assign(new Error('reference write still running'), { + sshChannelCloseConfirmed: false + }) + vi.mocked(recordRemoteRipgrepReference).mockRejectedValueOnce(error) + + await expect(deployAndLaunchRelay(conn)).rejects.toBe(error) + expect(detachedLaunchCommand(conn)).toBeUndefined() + expect(ensureRemoteBundledRipgrep).not.toHaveBeenCalled() + }) + + it.each([false, true])( + 'blocks cleanup and runtime retry after uncertain ripgrep teardown (concurrent exec: %s)', + async (concurrent) => { + const conn = makeMockConnection() + vi.mocked(conn.canRunConcurrentExecCommands).mockReturnValue(concurrent) + queueFreshLinuxDeploy() + vi.mocked(ensureRemoteBundledRipgrep).mockRejectedValueOnce( + Object.assign(new Error('upload still running'), { sshChannelCloseConfirmed: false }) + ) + vi.mocked(ensureRemoteOpenCodeRuntime).mockResolvedValue('failed') + const deployed = await deployAndLaunchRelay(conn) + await new Promise((resolve) => setImmediate(resolve)) + expect(gcOldRelayVersions).not.toHaveBeenCalled() + expect(gcRemoteRipgrepCache).not.toHaveBeenCalled() + const execCount = vi.mocked(execCommand).mock.calls.length + const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 60_001) + try { + await deployed.prepareOpenCodeRuntime?.(new AbortController().signal) + expect(ensureRemoteOpenCodeRuntime).toHaveBeenCalledTimes(concurrent ? 1 : 0) + expect(execCommand).toHaveBeenCalledTimes(execCount) + } finally { + clock.mockRestore() + } + } + ) + + it.each([false, true])( + 'gates later cache cleanup and runtime retry on GC termination (confirmed: %s)', + async (confirmed) => { + const conn = makeMockConnection() + queueFreshLinuxDeploy() + vi.mocked(ensureRemoteOpenCodeRuntime).mockResolvedValueOnce('failed') + vi.mocked(gcOldRelayVersions).mockRejectedValueOnce( + Object.assign(new Error('GC interrupted'), { sshChannelCloseConfirmed: confirmed }) + ) + const deployed = await deployAndLaunchRelay(conn) + await vi.waitFor(() => expect(gcOldRelayVersions).toHaveBeenCalledOnce()) + expect(gcRemoteRipgrepCache).not.toHaveBeenCalled() + const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 60_001) + try { + await deployed.prepareOpenCodeRuntime?.(new AbortController().signal) + expect(ensureRemoteOpenCodeRuntime).toHaveBeenCalledTimes(confirmed ? 2 : 1) + } finally { + clock.mockRestore() + } + } + ) + it('allows an unlimited SSH disconnect grace window', async () => { const conn = makeMockConnection() queueFreshLinuxDeploy() diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index aab5e6c39d3..4424adc0c01 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -1,7 +1,7 @@ -import { join } from 'node:path' /* eslint-disable max-lines -- Why: one cohesive contract (version detect, install-locked deploy, native-deps probe, launch, GC); splitting risks install/GC drift. */ import { existsSync } from 'node:fs' import { app } from 'electron' +import { relayBundleCandidates } from './relay-bundle-paths' import type { SshConnection } from './ssh-connection' import { RELAY_REMOTE_DIR, type RelayPlatform } from './relay-protocol' import type { MultiplexerTransport } from './ssh-channel-multiplexer' @@ -30,6 +30,11 @@ import { recordRemoteRipgrepReference } from './ssh-relay-ripgrep-install' import { gcRemoteRipgrepCache } from './ssh-relay-ripgrep-cache-gc' +import { ensureRemoteOpenCodeRuntime } from './ssh-relay-opencode-runtime' +import { + createRemoteOpenCodeRuntimeRetry, + type RemoteOpenCodeRuntimePreparation +} from './ssh-relay-opencode-runtime-retry' import { readLocalFullVersion, computeRemoteRelayDir, @@ -131,6 +136,7 @@ export type RelayDeployResult = { nodePath?: string sockPath?: string credentialFile?: string + prepareOpenCodeRuntime?: RemoteOpenCodeRuntimePreparation } class RelayDirectoryGcConflictError extends Error { @@ -322,8 +328,17 @@ async function resolveRelayBootstrapState( signal?.throwIfAborted() return { ...installState, nodePath } } catch (err) { - abortController.abort() + // Let an admitted probe finish before retrying a refused parallel session. + if (!isSshSessionLimitError(err)) { + abortController.abort() + } const settled = await Promise.allSettled([installStatePromise, nodePathPromise]) + const unconfirmed = settled.find( + (result) => result.status === 'rejected' && isUnconfirmedSshCommandTermination(result.reason) + ) + if (unconfirmed?.status === 'rejected') { + throw unconfirmed.reason + } signal?.throwIfAborted() if (!isSshSessionLimitError(err)) { throw err @@ -477,32 +492,18 @@ async function deployAndLaunchRelayAttempt( onProgress?.('Uploading relay...') console.log('[ssh-relay] Uploading relay...') try { - try { - await uploadRelay( - conn, - platform, - uploadStagePayloadDir, - fullVersion, - hostPlatform, - deploySignal, - { rootDir: uploadStage.slotDir, namespace: uploadStageSftpNamespace } - ) - } catch (err) { - if (isUnconfirmedSshCommandTermination(err)) { - uploadStageCleanupAllowed = false - } - throw err - } + await uploadRelay( + conn, + platform, + uploadStagePayloadDir, + fullVersion, + hostPlatform, + deploySignal, + { rootDir: uploadStage.slotDir, namespace: uploadStageSftpNamespace } + ) - try { - await acquireInstallLock(conn, remoteRelayDir, hostPlatform, { signal: deploySignal }) - ownsInstallLock = true - } catch (err) { - if (isUnconfirmedSshCommandTermination(err)) { - ownsInstallLock = true - } - throw err - } + await acquireInstallLock(conn, remoteRelayDir, hostPlatform, { signal: deploySignal }) + ownsInstallLock = true try { // Re-probe after acquiring the lock — a sibling installer may have finished while we waited. if ( @@ -517,26 +518,19 @@ async function deployAndLaunchRelayAttempt( homeRelativeRelayDir, deploySignal ) - try { - const promotion = await execHostCommand( - conn, + const promotion = await execHostCommand( + conn, + hostPlatform, + promoteOwnedRelayUploadStageCommand( hostPlatform, - promoteOwnedRelayUploadStageCommand( - hostPlatform, - uploadStage, - uploadStageOwner, - remoteRelayDir - ), - { signal: deploySignal } - ) - if (!relayUploadStagePromotionConfirmed(uploadStageOwner, promotion)) { - throw new Error('Relay upload stage ownership was lost before promotion') - } - } catch (err) { - if (isUnconfirmedSshCommandTermination(err)) { - uploadStageCleanupAllowed = false - } - throw err + uploadStage, + uploadStageOwner, + remoteRelayDir + ), + { signal: deploySignal } + ) + if (!relayUploadStagePromotionConfirmed(uploadStageOwner, promotion)) { + throw new Error('Relay upload stage ownership was lost before promotion') } console.log('[ssh-relay] Upload complete') @@ -568,13 +562,20 @@ async function deployAndLaunchRelayAttempt( } throw err } + } catch (error) { + uploadStageCleanupAllowed = !isUnconfirmedSshCommandTermination(error) + throw error } finally { if (uploadStageCleanupAllowed) { await execHostCommand( conn, hostPlatform, cleanupOwnedRelayUploadStageCommand(hostPlatform, uploadStage, uploadStageOwner) - ).catch(() => {}) + ).catch((error) => { + if (isUnconfirmedSshCommandTermination(error)) { + throw error + } + }) } } } @@ -588,95 +589,135 @@ async function deployAndLaunchRelayAttempt( remoteRelayDir, ripgrepLayout.entryName )) - let launched: Awaited> - let launchLivenessObserved = false + deploySignal?.throwIfAborted() + onProgress?.('Starting relay...') + console.log('[ssh-relay] Launching relay...') + // A failed launch retains its fences until stale recovery can establish liveness. + const launched = await launchRelay( + conn, + remoteRelayDir, + hostPlatform, + nodePath, + graceTimeSeconds, + relayInstanceId, + deploySignal, + ripgrepReferenced ? ripgrepLayout.binaryPath : undefined + ) + let launchCleanupSettled = true try { - deploySignal?.throwIfAborted() - onProgress?.('Starting relay...') - console.log('[ssh-relay] Launching relay...') - launched = await launchRelay( - conn, - remoteRelayDir, - hostPlatform, - nodePath, - graceTimeSeconds, - relayInstanceId, - deploySignal, - ripgrepReferenced ? ripgrepLayout.binaryPath : undefined - ) - launchLivenessObserved = true - } finally { - // Why: older clients understand only the install lock; if launch never goes live, keep it so their GC can't race a caller waiting behind this owner. - if (ownsInstallLock && launchLivenessObserved) { + if (ownsInstallLock) { await abandonInstall(conn, remoteRelayDir, hostPlatform) } - // The detached start may outlive a timed-out SSH command; keep the fence on failed launch until stale recovery proves the handoff ended. - if (launchGcClaimToken && launchLivenessObserved) { + if (launchGcClaimToken) { await releaseRelayGcClaimWithRetry(conn, remoteRelayDir, launchGcClaimToken, hostPlatform) } + } catch (error) { + // Keep the connected transport, but stop optional commands after uncertain fence release. + launchCleanupSettled = !isUnconfirmedSshCommandTermination(error) + console.warn('[ssh-relay] Launch fence release failed:', error) } console.log('[ssh-relay] Relay started successfully') // Keep background commands serial for SSH transports that allow only one exec at a time. const ripgrepEntry = ripgrepLayout?.entryName const ripgrepInstall = ( - ripgrepReferenced + ripgrepReferenced && launchCleanupSettled ? ensureRemoteBundledRipgrep(conn, hostPlatform, remoteHome, { signal: deploySignal }) : Promise.resolve() - ).catch(() => {}) - const cleanupReady = conn.canRunConcurrentExecCommands() ? Promise.resolve() : ripgrepInstall + ).then( + () => launchCleanupSettled, + (error) => !isUnconfirmedSshCommandTermination(error) + ) + const runtimeInstall = ( + conn.canRunConcurrentExecCommands() ? Promise.resolve(launchCleanupSettled) : ripgrepInstall + ) + .then((ripgrepSettled) => + ripgrepSettled + ? ensureRemoteOpenCodeRuntime(conn, hostPlatform, remoteHome, { + nodePath: launched.nodePath, + relayDir: remoteRelayDir, + signal: deploySignal + }) + : ('teardown-unconfirmed' as const) + ) + .catch(() => 'teardown-unconfirmed' as const) - void cleanupReady - .then(() => + const cleanupReady = Promise.all([ripgrepInstall, runtimeInstall]).then( + ([ripgrepSettled, runtimeOutcome]) => + ripgrepSettled && runtimeOutcome !== 'teardown-unconfirmed' + ) + const backgroundCleanup = cleanupReady.then((ready) => { + if (!ready) { + return false + } + return ( execHostCommand( conn, hostPlatform, recoverOneStaleRelayUploadStageCommand(hostPlatform, uploadStagePoolDir) ) - ) - .catch(() => {}) - // Why before GC: a superseded relay pins its version dir via the live-socket probe, so the - // sweep has to settle first or GC keeps every orphan's tree forever. - .then(() => - sweepSupersededRelayEndpoints(conn, hostPlatform, { - remoteHome, - currentRelayDir: remoteRelayDir, - sockName: relaySocketNameForInstanceId(relayInstanceId), - // Set only when this launch relocated past sun_path; the sweep must not reap - // the socket the transport it just handed back is talking to. - ...(launched.sockPath.startsWith(SHORT_RELAY_SOCKET_DIR_PREFIX) - ? { - currentShortSocketDir: launched.sockPath.slice(0, launched.sockPath.lastIndexOf('/')) - } - : {}), - nodePath: launched.nodePath - }) - ) - .catch((error) => { - if (error instanceof RelayProbeCleanupUnconfirmedError) { - throw error - } - }) - .then(() => - gcOldRelayVersions(conn, remoteHome, remoteRelayDir, hostPlatform, { - windowsNodePath: launched.nodePath, - windowsSockNames: [relaySocketNameForInstanceId(relayInstanceId)], - // Why pin rather than rely on the symlink alone: a deploy that fell back to a - // per-directory install has no reference to show, and its key must still survive. - nativeDepsCacheKeys: [ - resolveRelayNativeDepsCacheKey({ - platform, - localRelayDir, - deps: RELAY_NATIVE_DEPS + .catch((error) => { + if (isUnconfirmedSshCommandTermination(error)) { + throw error + } + }) + // Why before GC: a superseded relay pins its version dir via the live-socket probe, so the + // sweep has to settle first or GC keeps every orphan's tree forever. + .then(() => + sweepSupersededRelayEndpoints(conn, hostPlatform, { + remoteHome, + currentRelayDir: remoteRelayDir, + sockName: relaySocketNameForInstanceId(relayInstanceId), + // Set only when this launch relocated past sun_path; the sweep must not reap + // the socket the transport it just handed back is talking to. + ...(launched.sockPath.startsWith(SHORT_RELAY_SOCKET_DIR_PREFIX) + ? { + currentShortSocketDir: launched.sockPath.slice( + 0, + launched.sockPath.lastIndexOf('/') + ) + } + : {}), + nodePath: launched.nodePath }) - ].filter((key): key is string => key !== null) - }) + ) + .catch((error) => { + if ( + error instanceof RelayProbeCleanupUnconfirmedError || + isUnconfirmedSshCommandTermination(error) + ) { + throw error + } + }) + .then(() => + gcOldRelayVersions(conn, remoteHome, remoteRelayDir, hostPlatform, { + windowsNodePath: launched.nodePath, + windowsSockNames: [relaySocketNameForInstanceId(relayInstanceId)], + // Why pin rather than rely on the symlink alone: a deploy that fell back to a + // per-directory install has no reference to show, and its key must still survive. + nativeDepsCacheKeys: [ + resolveRelayNativeDepsCacheKey({ + platform, + localRelayDir, + deps: RELAY_NATIVE_DEPS + }) + ].filter((key): key is string => key !== null) + }) + ) + // Why after the version GC and not beside it: that pass is what removes the relay directories + // holding the references, so running second is what lets a superseded build become collectable + // in the same connect rather than the next one. + .then(() => + gcRemoteRipgrepCache(conn, hostPlatform, remoteHome, { pinnedEntry: ripgrepEntry }) + ) + .then(() => true) + .catch( + (error) => + !(error instanceof RelayProbeCleanupUnconfirmedError) && + !isUnconfirmedSshCommandTermination(error) + ) ) - // Why after the version GC and not beside it: that pass is what removes the relay directories - // holding the references, so running second is what lets a superseded build become collectable - // in the same connect rather than the next one. - .then(() => gcRemoteRipgrepCache(conn, hostPlatform, remoteHome, { pinnedEntry: ripgrepEntry })) - .catch(() => {}) + }) return { transport: launched.transport, @@ -687,7 +728,17 @@ async function deployAndLaunchRelayAttempt( remoteRelayDir, nodePath: launched.nodePath, sockPath: launched.sockPath, - credentialFile: launched.credentialFile + credentialFile: launched.credentialFile, + prepareOpenCodeRuntime: createRemoteOpenCodeRuntimeRetry( + runtimeInstall, + backgroundCleanup, + (signal) => + ensureRemoteOpenCodeRuntime(conn, hostPlatform, remoteHome, { + nodePath: launched.nodePath, + relayDir: remoteRelayDir, + signal + }) + ) } } @@ -964,7 +1015,9 @@ async function repairInstalledNativeDeps( signal }) } catch (err) { - await abandonInstall(conn, remoteDir, hostPlatform) + if (!isUnconfirmedSshCommandTermination(err)) { + await abandonInstall(conn, remoteDir, hostPlatform) + } throw err } if (!stillInstalled) { @@ -1111,7 +1164,9 @@ async function acquireRelayLaunchGcFence( // Why: a caller without the install lock still needs its own durable fence; never borrow another connection's lock through launch. return token } catch (err) { - await releaseRelayGcClaimWithRetry(conn, remoteDir, token, hostPlatform) + if (!isUnconfirmedSshCommandTermination(err)) { + await releaseRelayGcClaimWithRetry(conn, remoteDir, token, hostPlatform) + } throw err } } @@ -1685,24 +1740,7 @@ function getLocalRelayPath(platform: RelayPlatform): string | null { } export function getLocalRelayCandidates(platform: RelayPlatform): string[] { - const candidates: string[] = [] - if (process.env.ORCA_RELAY_PATH) { - candidates.push(join(process.env.ORCA_RELAY_PATH, platform)) - } - - // Why: electron-builder copies extraResources next to the app bundle, but app.getAppPath() points at app.asar in packaged builds. - if (process.resourcesPath) { - candidates.push(join(process.resourcesPath, 'relay', platform)) - candidates.push(join(process.resourcesPath, 'app.asar.unpacked', 'out', 'relay', platform)) - } - - const appPath = app.getAppPath() - candidates.push( - join(appPath, 'resources', 'relay', platform), - join(appPath, 'out', 'relay', platform) - ) - - return [...new Set(candidates)] + return relayBundleCandidates(platform, app.getAppPath()) } async function launchRelay( diff --git a/src/main/ssh/ssh-relay-gc-claim-termination.test.ts b/src/main/ssh/ssh-relay-gc-claim-termination.test.ts new file mode 100644 index 00000000000..d460e3e94b9 --- /dev/null +++ b/src/main/ssh/ssh-relay-gc-claim-termination.test.ts @@ -0,0 +1,216 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn() })) + +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { + isRelayGcClaimOwned, + releaseRelayGcClaim, + releaseRelayGcClaimWithRetry, + tryAcquireRelayGcClaim, + waitForRelayGcClaimRelease +} from './ssh-relay-gc-claim' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mocked execCommand never reads the connection. +const conn = {} as SshConnection +const mockExec = vi.mocked(execCommand) +const token = 'claim-owner' + +function unconfirmedTermination(): Error { + return Object.assign(new Error('SSH teardown not confirmed'), { + sshChannelCloseConfirmed: false + }) +} + +function confirmedFailure(): Error { + return Object.assign(new Error('SSH command failed after closing'), { + sshChannelCloseConfirmed: true + }) +} + +beforeEach(() => { + mockExec.mockReset() + mockExec.mockResolvedValue('OPEN') +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe.each([ + { platform: 'linux-x64' as const, remoteDir: '/relay/version' }, + { platform: 'win32-x64' as const, remoteDir: 'C:/relay/version' } +])('relay GC claim termination on $platform', ({ platform, remoteDir }) => { + const host = getRemoteHostPlatform(platform) + + it.each([ + { stage: 'claim creation', replies: [] }, + { stage: 'stale claim takeover', replies: ['BUSY'] }, + { stage: 'new claim owner write', replies: ['OK'] }, + { stage: 'recovered claim owner write', replies: ['BUSY', 'OK'] } + ])('preserves uncertainty during $stage without releasing or retrying', async ({ replies }) => { + const error = unconfirmedTermination() + for (const reply of replies) { + mockExec.mockResolvedValueOnce(reply) + } + mockExec.mockRejectedValueOnce(error) + + await expect(tryAcquireRelayGcClaim(conn, remoteDir, host)).rejects.toBe(error) + + expect(mockExec).toHaveBeenCalledTimes(replies.length + 1) + }) + + it('preserves acquisition uncertainty when the caller also aborts', async () => { + const controller = new AbortController() + const error = unconfirmedTermination() + mockExec.mockImplementationOnce(async () => { + controller.abort(new Error('deploy aborted')) + throw error + }) + + await expect(tryAcquireRelayGcClaim(conn, remoteDir, host, controller.signal)).rejects.toBe( + error + ) + expect(mockExec).toHaveBeenCalledTimes(1) + }) + + it('preserves ownership probe uncertainty', async () => { + const error = unconfirmedTermination() + mockExec.mockRejectedValueOnce(error) + + await expect(isRelayGcClaimOwned(conn, remoteDir, token, host)).rejects.toBe(error) + expect(mockExec).toHaveBeenCalledTimes(1) + }) + + it('preserves release uncertainty', async () => { + const error = unconfirmedTermination() + mockExec.mockRejectedValueOnce(error) + + await expect(releaseRelayGcClaim(conn, remoteDir, token, host)).rejects.toBe(error) + expect(mockExec).toHaveBeenCalledTimes(1) + }) + + it.each([0, 1])( + 'stops release retries after %i confirmed failures then uncertainty', + async (n) => { + const error = unconfirmedTermination() + for (let attempt = 0; attempt < n; attempt++) { + mockExec.mockRejectedValueOnce(confirmedFailure()) + } + mockExec.mockRejectedValueOnce(error) + + await expect(releaseRelayGcClaimWithRetry(conn, remoteDir, token, host)).rejects.toBe(error) + expect(mockExec).toHaveBeenCalledTimes(n + 1) + } + ) + + it.each([ + { stage: 'claim probe', replies: [] }, + { stage: 'stale claim takeover', replies: ['LOCKED'] }, + { stage: 'owner write', replies: ['LOCKED', 'OK'] }, + { stage: 'release', replies: ['LOCKED', 'OK', ''] } + ])('stops the claim waiter after an unconfirmed $stage', async ({ replies }) => { + const error = unconfirmedTermination() + for (const reply of replies) { + mockExec.mockResolvedValueOnce(reply) + } + mockExec.mockRejectedValueOnce(error) + + await expect(waitForRelayGcClaimRelease(conn, remoteDir, host)).rejects.toBe(error) + expect(mockExec).toHaveBeenCalledTimes(replies.length + 1) + }) + + it('preserves claim probe uncertainty when the caller also aborts', async () => { + const controller = new AbortController() + const error = unconfirmedTermination() + mockExec.mockImplementationOnce(async () => { + controller.abort(new Error('deploy aborted')) + throw error + }) + + await expect(waitForRelayGcClaimRelease(conn, remoteDir, host, controller.signal)).rejects.toBe( + error + ) + expect(mockExec).toHaveBeenCalledTimes(1) + }) + + it('stops when cleanup after a confirmed owner write failure is unconfirmed', async () => { + const error = unconfirmedTermination() + mockExec + .mockResolvedValueOnce('OK') + .mockRejectedValueOnce(confirmedFailure()) + .mockRejectedValueOnce(error) + + await expect(tryAcquireRelayGcClaim(conn, remoteDir, host)).rejects.toBe(error) + expect(mockExec).toHaveBeenCalledTimes(3) + }) + + it('keeps a confirmed acquisition failure as contention', async () => { + mockExec.mockRejectedValueOnce(confirmedFailure()) + + await expect(tryAcquireRelayGcClaim(conn, remoteDir, host)).resolves.toBeNull() + expect(mockExec).toHaveBeenCalledTimes(1) + }) + + it('conditionally releases after a confirmed owner write failure', async () => { + mockExec + .mockResolvedValueOnce('OK') + .mockRejectedValueOnce(confirmedFailure()) + .mockResolvedValueOnce('RELEASED') + + await expect(tryAcquireRelayGcClaim(conn, remoteDir, host)).resolves.toBeNull() + expect(mockExec).toHaveBeenCalledTimes(3) + }) + + it('keeps a confirmed ownership probe failure as lost ownership', async () => { + mockExec.mockRejectedValueOnce(confirmedFailure()) + + await expect(isRelayGcClaimOwned(conn, remoteDir, token, host)).resolves.toBe(false) + expect(mockExec).toHaveBeenCalledTimes(1) + }) + + it('retains bounded retries for confirmed release failures', async () => { + mockExec.mockRejectedValue(confirmedFailure()) + + await expect(releaseRelayGcClaimWithRetry(conn, remoteDir, token, host)).resolves.toBe( + 'unknown' + ) + expect(mockExec).toHaveBeenCalledTimes(3) + }) + + it('retries confirmed release failures until release succeeds', async () => { + mockExec.mockRejectedValueOnce(confirmedFailure()).mockResolvedValueOnce('RELEASED') + + await expect(releaseRelayGcClaimWithRetry(conn, remoteDir, token, host)).resolves.toBe( + 'released' + ) + expect(mockExec).toHaveBeenCalledTimes(2) + }) + + it('still recovers a claim after a confirmed probe failure', async () => { + mockExec + .mockRejectedValueOnce(confirmedFailure()) + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('RELEASED') + + await expect(waitForRelayGcClaimRelease(conn, remoteDir, host)).resolves.toBeUndefined() + expect(mockExec).toHaveBeenCalledTimes(4) + }) + + it('still polls after a confirmed stale takeover failure', async () => { + vi.useFakeTimers() + mockExec + .mockResolvedValueOnce('LOCKED') + .mockRejectedValueOnce(confirmedFailure()) + .mockResolvedValueOnce('OPEN') + + const waiting = waitForRelayGcClaimRelease(conn, remoteDir, host) + await vi.advanceTimersByTimeAsync(1_000) + + await expect(waiting).resolves.toBeUndefined() + expect(mockExec).toHaveBeenCalledTimes(3) + }) +}) diff --git a/src/main/ssh/ssh-relay-gc-claim.ts b/src/main/ssh/ssh-relay-gc-claim.ts index d0c8cc1221f..3ce807fcabb 100644 --- a/src/main/ssh/ssh-relay-gc-claim.ts +++ b/src/main/ssh/ssh-relay-gc-claim.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto' import type { SshConnection } from './ssh-connection' import { shellEscape } from './ssh-connection-utils' import { execCommand } from './ssh-relay-deploy-helpers' +import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' import { probeInstallLockExistsCommand, tryCreateInstallLockCommand, @@ -91,7 +92,10 @@ export async function tryAcquireRelayGcClaim( return null } return writeRelayGcClaimOwner(conn, remoteRelayDir, host, signal) - } catch { + } catch (error) { + if (isUnconfirmedSshCommandTermination(error)) { + throw error + } signal?.throwIfAborted() return null } @@ -114,7 +118,10 @@ async function writeRelayGcClaimOwner( try { await execHostCommand(conn, host, command, signal) return token - } catch { + } catch (error) { + if (isUnconfirmedSshCommandTermination(error)) { + throw error + } // Why: the write may have succeeded remotely before SSH lost its reply. // A conditional release removes only the claim generation with our token. await releaseRelayGcClaim(conn, remoteRelayDir, token, host) @@ -134,7 +141,12 @@ export async function isRelayGcClaimOwned( `if ((Get-Content -LiteralPath ${powerShellLiteral(ownerPath)} -Raw -ErrorAction SilentlyContinue) -ceq ${powerShellLiteral(token)}) { 'OWNED' } else { 'LOST' }` ) : `test "$(cat ${shellEscape(ownerPath)} 2>/dev/null)" = ${shellEscape(token)} && echo OWNED || echo LOST` - const output = await execHostCommand(conn, host, command).catch(() => 'LOST') + const output = await execHostCommand(conn, host, command).catch((error) => { + if (isUnconfirmedSshCommandTermination(error)) { + throw error + } + return 'LOST' + }) return output.trim() === 'OWNED' } @@ -162,7 +174,12 @@ export async function releaseRelayGcClaim( `else ${removeRemoteTreeCommand(host, claimPath)} 2>/dev/null;`, `if test -e ${shellEscape(claimPath)}; then echo UNKNOWN; else echo RELEASED; fi; fi` ].join(' ') - const output = await execHostCommand(conn, host, command).catch(() => 'UNKNOWN') + const output = await execHostCommand(conn, host, command).catch((error) => { + if (isUnconfirmedSshCommandTermination(error)) { + throw error + } + return 'UNKNOWN' + }) switch (output.trim()) { case 'RELEASED': return 'released' @@ -193,7 +210,12 @@ export async function waitForRelayGcClaimRelease( signal?: AbortSignal ): Promise { while (true) { - const claimed = await isRelayGcClaimed(conn, remoteRelayDir, host, signal).catch(() => true) + const claimed = await isRelayGcClaimed(conn, remoteRelayDir, host, signal).catch((error) => { + if (isUnconfirmedSshCommandTermination(error)) { + throw error + } + return true + }) signal?.throwIfAborted() if (!claimed) { return @@ -204,7 +226,12 @@ export async function waitForRelayGcClaimRelease( host, tryStealInstallLockCommand(host, claimPath, RELAY_GC_CLAIM_STALE_SECONDS), signal - ).catch(() => 'BUSY') + ).catch((error) => { + if (isUnconfirmedSshCommandTermination(error)) { + throw error + } + return 'BUSY' + }) signal?.throwIfAborted() if (recovered.trim().endsWith('OK')) { const token = await writeRelayGcClaimOwner(conn, remoteRelayDir, host, signal) diff --git a/src/main/ssh/ssh-relay-gc-retry.test.ts b/src/main/ssh/ssh-relay-gc-retry.test.ts index ae70ff68fa6..18d99a926a9 100644 --- a/src/main/ssh/ssh-relay-gc-retry.test.ts +++ b/src/main/ssh/ssh-relay-gc-retry.test.ts @@ -19,7 +19,10 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({ onData: vi.fn(), onClose: vi.fn() }), - isUnconfirmedSshCommandTermination: () => false, + isUnconfirmedSshCommandTermination: (error: unknown) => + error instanceof Error && + 'sshChannelCloseConfirmed' in error && + error.sshChannelCloseConfirmed === false, execCommand: vi.fn() })) vi.mock('./ssh-remote-node-resolution', () => ({ @@ -244,6 +247,72 @@ describe('relay GC deploy retry', () => { expect(releaseRelayGcClaimWithRetry).not.toHaveBeenCalled() }) + it.each(['acquired', 'busy'] as const)( + 'keeps the connected relay but stops setup after uncertain %s fence release', + async (lockResult) => { + const conn = makeConnection() + const error = Object.assign(new Error('release still running'), { + sshChannelCloseConfirmed: false + }) + vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce(lockResult) + const release = + lockResult === 'acquired' + ? vi.mocked(abandonInstall) + : vi.mocked(releaseRelayGcClaimWithRetry) + release.mockRejectedValueOnce(error) + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + if (command.includes('__ORCA_REMOTE_PLATFORM__')) { + return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' + } + if (command === 'echo $HOME') { + return '/home/user' + } + if (command.includes('node-pty')) { + return 'ORCA-NATIVE-DEPS-OK' + } + if (command.includes('var s=require("net").connect')) { + return 'READY' + } + return command.includes('test -S') ? 'DEAD' : '' + }) + + const deployed = await deployAndLaunchRelay(conn) + expect(deployed.transport).toBeDefined() + expect(release).toHaveBeenCalledOnce() + const execCount = vi.mocked(execCommand).mock.calls.length + await deployed.prepareOpenCodeRuntime?.(new AbortController().signal) + await new Promise((resolve) => setImmediate(resolve)) + expect(execCommand).toHaveBeenCalledTimes(execCount) + } + ) + + it.each(['acquired', 'busy'] as const)( + 'preserves the %s fence after an unconfirmed installed-state recheck', + async (lockResult) => { + const conn = makeConnection() + const error = Object.assign(new Error('recheck still running'), { + sshChannelCloseConfirmed: false + }) + vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce(lockResult) + vi.mocked(isRelayAlreadyInstalled).mockResolvedValueOnce(true).mockRejectedValueOnce(error) + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + if (command.includes('__ORCA_REMOTE_PLATFORM__')) { + return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' + } + if (command === 'echo $HOME') { + return '/home/user' + } + return 'ORCA-NATIVE-DEPS-OK' + }) + + await expect(deployAndLaunchRelay(conn)).rejects.toBe(error) + expect(abandonInstall).not.toHaveBeenCalled() + expect(releaseRelayGcClaimWithRetry).not.toHaveBeenCalled() + expect(waitForRelayGcClaimRelease).not.toHaveBeenCalled() + expect(conn.exec).not.toHaveBeenCalled() + } + ) + it('keeps retrying repeated launch-claim contention within the deploy bound', async () => { const conn = makeConnection() vi.mocked(tryAcquireRelayRepairLock).mockResolvedValue('busy') diff --git a/src/main/ssh/ssh-relay-gc-tombstone.ts b/src/main/ssh/ssh-relay-gc-tombstone.ts index 95684d4d20e..cc117981ead 100644 --- a/src/main/ssh/ssh-relay-gc-tombstone.ts +++ b/src/main/ssh/ssh-relay-gc-tombstone.ts @@ -1,5 +1,6 @@ import type { SshConnection } from './ssh-connection' import { execCommand } from './ssh-relay-deploy-helpers' +import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' import { removeRemoteTreeCommand } from './ssh-remote-commands' import { getRemoteHostPlatform, @@ -25,6 +26,9 @@ export async function cleanupRelayGcTombstones( await execCommand(conn, removeRemoteTreeCommand(host, tombstone), { wrapCommand: !isWindowsRemoteHost(host) }).catch((err) => { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } console.warn( `[ssh-relay] GC failed to remove tombstone ${tombstone}: ${err instanceof Error ? err.message : String(err)}` ) diff --git a/src/main/ssh/ssh-relay-install-lock-termination.test.ts b/src/main/ssh/ssh-relay-install-lock-termination.test.ts new file mode 100644 index 00000000000..de4c57a4977 --- /dev/null +++ b/src/main/ssh/ssh-relay-install-lock-termination.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn() })) + +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { acquireInstallLock, isRelayInstallLockStale } from './ssh-relay-install-lock' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mocked execCommand never reads the connection. +const conn = {} as SshConnection +const mockExec = vi.mocked(execCommand) +const nextAcquisition = ['OPEN', '', 'OK', 'OPEN'] + +const lockFailureStages = [ + { + stage: 'claim probe after acquisition', + replies: ['OPEN', '', 'OK'], + recovery: ['', 'OK', 'OPEN'] + }, + { + stage: 'release after acquisition', + replies: ['OPEN', '', 'OK', 'LOCKED'], + recovery: ['OK', 'OPEN'] + }, + { + stage: 'stale lock takeover', + replies: ['OPEN', '', 'BUSY'], + recovery: nextAcquisition + }, + { + stage: 'claim probe after stale takeover', + replies: ['OPEN', '', 'BUSY', 'OK'], + recovery: ['', ...nextAcquisition] + }, + { + stage: 'release after stale takeover', + replies: ['OPEN', '', 'BUSY', 'OK', 'LOCKED'], + recovery: nextAcquisition + } +] + +beforeEach(() => { + mockExec.mockReset() + mockExec.mockResolvedValue('OPEN') + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'info').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +describe.each([ + { platform: 'linux-x64' as const, remoteDir: '/relay/version' }, + { platform: 'win32-x64' as const, remoteDir: 'C:/relay/version' } +])('install lock termination on $platform', ({ platform, remoteDir }) => { + const host = getRemoteHostPlatform(platform) + + it.each(lockFailureStages)('stops after unconfirmed $stage', async ({ replies }) => { + const error = Object.assign(new Error('SSH teardown not confirmed'), { + sshChannelCloseConfirmed: false + }) + for (const reply of replies) { + mockExec.mockResolvedValueOnce(reply) + } + mockExec.mockRejectedValueOnce(error) + + await expect(acquireInstallLock(conn, remoteDir, host)).rejects.toBe(error) + expect(mockExec).toHaveBeenCalledTimes(replies.length + 1) + }) + + it.each(lockFailureStages)( + 'keeps recovery after confirmed $stage failure', + async ({ replies, recovery }) => { + vi.useFakeTimers() + for (const reply of replies) { + mockExec.mockResolvedValueOnce(reply) + } + mockExec.mockRejectedValueOnce( + Object.assign(new Error('SSH command failed after closing'), { + sshChannelCloseConfirmed: true + }) + ) + for (const reply of recovery) { + mockExec.mockResolvedValueOnce(reply) + } + + const acquiring = acquireInstallLock(conn, remoteDir, host) + await vi.advanceTimersByTimeAsync(1_000) + + await expect(acquiring).resolves.toBeUndefined() + expect(mockExec).toHaveBeenCalledTimes(replies.length + recovery.length + 1) + } + ) + + it('preserves takeover uncertainty when the caller also aborts', async () => { + const controller = new AbortController() + const error = Object.assign(new Error('SSH teardown not confirmed'), { + sshChannelCloseConfirmed: false + }) + mockExec + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('BUSY') + .mockImplementationOnce(async () => { + controller.abort(new Error('deploy aborted')) + throw error + }) + + await expect( + acquireInstallLock(conn, remoteDir, host, { signal: controller.signal }) + ).rejects.toBe(error) + expect(mockExec).toHaveBeenCalledTimes(4) + }) + + it('preserves uncertainty from the stale lock age probe', async () => { + const error = Object.assign(new Error('SSH teardown not confirmed'), { + sshChannelCloseConfirmed: false + }) + mockExec.mockRejectedValueOnce(error) + + await expect(isRelayInstallLockStale(conn, remoteDir, host)).rejects.toBe(error) + expect(mockExec).toHaveBeenCalledTimes(1) + }) + + it('keeps a confirmed stale age probe failure as not stale', async () => { + mockExec.mockRejectedValueOnce( + Object.assign(new Error('SSH command failed after closing'), { + sshChannelCloseConfirmed: true + }) + ) + + await expect(isRelayInstallLockStale(conn, remoteDir, host)).resolves.toBe(false) + expect(mockExec).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/ssh/ssh-relay-install-lock.ts b/src/main/ssh/ssh-relay-install-lock.ts index 7d5b26fdfae..3831851cbe9 100644 --- a/src/main/ssh/ssh-relay-install-lock.ts +++ b/src/main/ssh/ssh-relay-install-lock.ts @@ -1,6 +1,7 @@ import type { SshConnection } from './ssh-connection' -import { execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' +import { execCommand } from './ssh-relay-deploy-helpers' import { RELAY_DEPLOY_TIMEOUT_MS } from './ssh-relay-deploy-timing' +import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' import { isRelayGcClaimed, waitForRelayGcClaimRelease } from './ssh-relay-gc-claim' import { acquireInstallLockParentCommand, @@ -54,7 +55,10 @@ export async function isRelayInstallLockStale( const out = await execHostCommand(conn, host, lockAgeSecondsCommand(host, lockDir)) const ageSec = Number.parseInt(out.trim(), 10) return Number.isFinite(ageSec) && ageSec >= 0 && ageSec * 1000 > INSTALL_LOCK_STALE_MS - } catch { + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } return false } } @@ -95,11 +99,20 @@ export async function acquireInstallLock( remoteRelayDir, host, options?.signal - ).catch(() => true) + ).catch((err) => { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + return true + }) if (!claimedAfterAcquire && !options?.signal?.aborted) { return } - await execHostCommand(conn, host, removeRemoteTreeCommand(host, lockDir)).catch(() => {}) + await execHostCommand(conn, host, removeRemoteTreeCommand(host, lockDir)).catch((err) => { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + }) options?.signal?.throwIfAborted() } } catch (err) { @@ -119,7 +132,12 @@ export async function acquireInstallLock( host, tryStealInstallLockCommand(host, lockDir, INSTALL_LOCK_STALE_SECONDS), { signal: options?.signal } - ).catch(() => 'BUSY') + ).catch((err) => { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + return 'BUSY' + }) options?.signal?.throwIfAborted() if (steal.trim().endsWith('OK')) { const reason = steal.trim().endsWith('REBOOT_OK') ? 'previous-boot' : 'stale' @@ -129,11 +147,20 @@ export async function acquireInstallLock( remoteRelayDir, host, options?.signal - ).catch(() => true) + ).catch((err) => { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + return true + }) if (!claimedAfterSteal && !options?.signal?.aborted) { return } - await execHostCommand(conn, host, removeRemoteTreeCommand(host, lockDir)).catch(() => {}) + await execHostCommand(conn, host, removeRemoteTreeCommand(host, lockDir)).catch((err) => { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + }) options?.signal?.throwIfAborted() } } diff --git a/src/main/ssh/ssh-relay-native-deps-cache-commands.ts b/src/main/ssh/ssh-relay-native-deps-cache-commands.ts index 52d09de1b19..f2ce32a2222 100644 --- a/src/main/ssh/ssh-relay-native-deps-cache-commands.ts +++ b/src/main/ssh/ssh-relay-native-deps-cache-commands.ts @@ -10,6 +10,7 @@ import { shellEscape } from './ssh-connection-utils' import { RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME, RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX, + LEGACY_RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX, relayNativeDepsCacheBaseDir, relayNativeDepsCacheEntryDir, relayNativeDepsCacheNodeModulesPath, @@ -34,9 +35,6 @@ export const RELAY_NATIVE_CACHE_REFS_ERR = '__ORCA_NATIVE_CACHE__REFS_ERR' */ const CACHE_TAKEOVER_MINUTES = 120 -/** A crashed GC pass leaves a tombstone; it drains once no in-flight pass could still own it. */ -const CACHE_TOMBSTONE_SWEEP_MINUTES = 30 - /** Bounds every listing, matching `MAX_RELAY_GC_LISTING_ENTRIES`' role for version dirs. */ export const MAX_RELAY_NATIVE_CACHE_LISTING_ENTRIES = 64 @@ -157,7 +155,7 @@ export function promoteRelayNativeDepsCacheCommand(paths: RelayNativeDepsCachePa ].join('\n') } -/** Complete entries only; an incomplete one belongs to an installer, not to GC. */ +/** Complete entries and tombstones; deletion requires a separate reference scan. */ export function listRelayNativeDepsCacheEntriesCommand( host: RemoteHostPlatform, remoteHome: string @@ -166,13 +164,12 @@ export function listRelayNativeDepsCacheEntriesCommand( return [ `base=${shellEscape(base)}`, `[ -d "$base" ] || { printf '%s\\n' ${RELAY_NATIVE_CACHE_LIST_OK}; exit 0; }`, - `find "$base" -maxdepth 1 -name ${shellEscape(`${RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX}*`)} -mmin +${CACHE_TOMBSTONE_SWEEP_MINUTES} -exec rm -rf {} + 2>/dev/null || true`, 'n=0', - 'for d in "$base"/*/; do', + `for d in "$base"/*/ "$base"/${RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX}*/ "$base"/${LEGACY_RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX}*/; do`, ' [ -d "$d" ] || continue', - ` [ -f "$d${RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME}" ] || continue`, ' name=${d%/}', ' name=${name##*/}', + ` case "$name" in ${RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX}*|${LEGACY_RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX}*) ;; *) [ -f "$d${RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME}" ] || continue ;; esac`, ` printf 'ENTRY %s\\n' "$name"`, ' n=$((n+1))', ` if [ "$n" -ge ${MAX_RELAY_NATIVE_CACHE_LISTING_ENTRIES} ]; then break; fi`, @@ -196,8 +193,12 @@ export function listRelayNativeDepsCacheReferencesCommand( return [ `root=${shellEscape(root)}`, `[ -d "$root" ] || { printf '%s\\n' ${RELAY_NATIVE_CACHE_REFS_OK}; exit 0; }`, + `[ -r "$root" ] && [ -x "$root" ] && ls -A "$root" >/dev/null 2>&1 || { printf '%s\\n' ${RELAY_NATIVE_CACHE_REFS_ERR}; exit 0; }`, 'n=0', - 'for d in "$root"/*/node_modules; do', + 'for d in "$root"/*/; do', + ' [ -d "$d" ] || continue', + ` [ -r "$d" ] && [ -x "$d" ] || { printf '%s\\n' ${RELAY_NATIVE_CACHE_REFS_ERR}; exit 0; }`, + ' d="${d}node_modules"', ' [ -L "$d" ] || continue', ' t=$(readlink "$d" 2>/dev/null) || t=""', ` if [ -z "$t" ]; then printf '%s\\n' ${RELAY_NATIVE_CACHE_REFS_ERR}; exit 0; fi`, @@ -208,3 +209,24 @@ export function listRelayNativeDepsCacheReferencesCommand( `printf '%s\\n' ${RELAY_NATIVE_CACHE_REFS_OK}` ].join('\n') } + +/** A missing completion marker means deletion began; leave that tree for later cleanup. */ +export function restoreRelayNativeDepsCacheTombstoneCommand( + tombstone: string, + entryDir: string +): string { + const source = shellEscape(tombstone) + const destination = shellEscape(entryDir) + return `if [ -f ${source}/${RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME} ] && [ ! -e ${destination} ] && [ ! -L ${destination} ]; then mv ${source} ${destination} && echo MOVED; else echo BUSY; fi` +} + +/** A symlinked tombstone is never followed, so only its own marker can be dropped. */ +export function dropRelayNativeDepsCacheCompletionMarkerCommand(tombstone: string): string { + const source = shellEscape(tombstone) + return `{ [ -L ${source} ] || rm -f ${source}/${RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME}; }` +} + +/** Drops the completion marker first so an interrupted deletion can never be restored as complete. */ +export function removeRelayNativeDepsCacheTombstoneCommand(tombstone: string): string { + return `${dropRelayNativeDepsCacheCompletionMarkerCommand(tombstone)} && rm -rf ${shellEscape(tombstone)}` +} diff --git a/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts b/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts index ffc859df223..8d7a1988a97 100644 --- a/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts @@ -35,6 +35,9 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn() })) +vi.mock('./ssh-relay-opencode-runtime', () => ({ + ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') +})) vi.mock('./ssh-relay-ripgrep-install', () => ({ remoteRipgrepLayout: vi.fn().mockReturnValue(null), recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false), diff --git a/src/main/ssh/ssh-relay-native-deps-cache-gc-real.test.ts b/src/main/ssh/ssh-relay-native-deps-cache-gc-real.test.ts new file mode 100644 index 00000000000..e04d4bcf6bd --- /dev/null +++ b/src/main/ssh/ssh-relay-native-deps-cache-gc-real.test.ts @@ -0,0 +1,415 @@ +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + readlinkSync, + renameSync, + rmSync, + symlinkSync, + utimesSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { runProcessSync } from '../../shared/child-process/run-process' + +const { execMock } = vi.hoisted(() => ({ execMock: vi.fn() })) +vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: execMock })) + +import type { SshConnection } from './ssh-connection' +import { shellEscape } from './ssh-connection-utils' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { gcRelayNativeDepsCache } from './ssh-relay-native-deps-cache-gc' +import { listRelayNativeDepsCacheEntriesCommand } from './ssh-relay-native-deps-cache-commands' +import { + relayNativeDepsCacheBaseDir, + relayNativeDepsCacheEntryDir, + LEGACY_RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX, + RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX +} from './ssh-relay-native-deps-cache' + +const host = getRemoteHostPlatform('linux-x64') +const key = 'linux-x64-0123456789abcdef' +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: execCommand is replaced by a local shell for every connection access. +const conn = {} as SshConnection +const shells = ['/bin/sh', '/bin/dash'].filter(existsSync) + +describe.runIf(process.platform !== 'win32').each(shells)( + 'native cache GC recovery (%s)', + (shell) => { + let home: string + let cache: string + let entry: string + let relay: string + + function runShell(command: string): string { + const result = runProcessSync({ program: shell, args: ['-c', command], timeoutMs: 15_000 }) + if (result.code !== 0) { + throw new Error(result.stderr || `Shell exited ${result.code}`) + } + return result.stdout + } + + function tombstone( + timestamp = Date.now() - 60 * 60_000, + prefix = RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX + ): string { + const path = join(cache, `${prefix}${key}.123.${timestamp}`) + renameSync(entry, path) + return path + } + + function referenceEntry(): void { + symlinkSync(join(entry, 'node_modules'), join(relay, 'node_modules')) + } + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'orca native gc ')) + cache = relayNativeDepsCacheBaseDir(host, home) + entry = relayNativeDepsCacheEntryDir(host, home, key) + relay = join(home, '.orca-remote', 'relay-0.1.0+abc') + mkdirSync(relay, { recursive: true }) + mkdirSync(join(entry, 'node_modules'), { recursive: true }) + writeFileSync(join(entry, 'node_modules', 'addon.node'), 'original') + writeFileSync(join(entry, '.deps-complete'), '') + utimesSync(entry, new Date(0), new Date(0)) + execMock + .mockReset() + .mockImplementation(async (_conn: unknown, command: string) => runShell(command)) + }) + + afterEach(() => { + chmodSync(join(home, '.orca-remote'), 0o755) + chmodSync(relay, 0o755) + rmSync(home, { recursive: true, force: true }) + }) + + function tombstones(): string[] { + return readdirSync(cache).filter((name) => + name.startsWith(RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX) + ) + } + + it('lists an ancient-mtime tombstone without deleting it and restores its live reference', async () => { + referenceEntry() + const path = tombstone() + + expect(runShell(listRelayNativeDepsCacheEntriesCommand(host, home))).toContain( + `ENTRY ${RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX}` + ) + expect(existsSync(join(path, 'node_modules', 'addon.node'))).toBe(true) + + await gcRelayNativeDepsCache(conn, host, home) + + expect(readFileSync(join(relay, 'node_modules', 'addon.node'), 'utf8')).toBe('original') + expect(readdirSync(cache)).toEqual([key]) + }) + + async function runLaterPass(): Promise { + const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 31 * 60_000) + try { + await gcRelayNativeDepsCache(conn, host, home) + } finally { + clock.mockRestore() + } + } + + it('claims a referenced legacy tombstone out of old sweeps but never restores it', async () => { + referenceEntry() + const path = tombstone( + Date.now() - 60 * 60_000, + LEGACY_RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX + ) + + await gcRelayNativeDepsCache(conn, host, home) + + expect(existsSync(path)).toBe(false) + const [claimed] = tombstones() + expect(existsSync(join(cache, claimed, 'node_modules', 'addon.node'))).toBe(true) + expect(existsSync(join(cache, claimed, '.deps-complete'))).toBe(false) + + await runLaterPass() + + expect(existsSync(entry)).toBe(false) + expect(tombstones()).toHaveLength(1) + }) + + it('keeps a legacy tree unrestorable when its claim is interrupted after unmarking', async () => { + referenceEntry() + const path = tombstone( + Date.now() - 60 * 60_000, + LEGACY_RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX + ) + execMock.mockImplementation(async (_conn: unknown, command: string) => { + if (command.startsWith(`mv ${shellEscape(path)}`)) { + throw new Error('claim interrupted') + } + return runShell(command) + }) + + await gcRelayNativeDepsCache(conn, host, home) + + expect(existsSync(join(path, 'node_modules', 'addon.node'))).toBe(true) + expect(existsSync(join(path, '.deps-complete'))).toBe(false) + + execMock.mockImplementation(async (_conn: unknown, command: string) => runShell(command)) + await runLaterPass() + await runLaterPass() + + expect(existsSync(entry)).toBe(false) + }) + + it('collects an unreferenced legacy tombstone', async () => { + tombstone(Date.now() - 60 * 60_000, LEGACY_RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX) + + await gcRelayNativeDepsCache(conn, host, home) + + expect(readdirSync(cache)).toEqual([]) + }) + + it('lets exactly one of two passes claim the same abandoned tombstone', async () => { + const path = tombstone() + let releaseClaim!: () => void + const claimHeld = new Promise((resolve) => { + releaseClaim = resolve + }) + let heldPassReachedClaim!: () => void + const reachedClaim = new Promise((resolve) => { + heldPassReachedClaim = resolve + }) + let held = false + execMock.mockImplementation(async (_conn: unknown, command: string) => { + if (!held && command.startsWith(`mv ${shellEscape(path)}`)) { + held = true + heldPassReachedClaim() + await claimHeld + } + return runShell(command) + }) + + // The held pass sees the tree as unreferenced; the other pins its key and restores it. + const heldPass = gcRelayNativeDepsCache(conn, host, home) + await reachedClaim + await gcRelayNativeDepsCache(conn, host, home, { pinnedKeys: [key] }) + releaseClaim() + await heldPass + + expect(readdirSync(cache)).toEqual([key]) + expect(readFileSync(join(entry, 'node_modules', 'addon.node'), 'utf8')).toBe('original') + expect(execMock.mock.calls.some(([, command]) => String(command).includes('rm -rf'))).toBe( + false + ) + }) + + it('does not let another pass resurrect a tombstone while its owner deletes it', async () => { + tombstone() + let otherPass: Promise | undefined + execMock.mockImplementation(async (_conn: unknown, command: string) => { + if (!otherPass && command.includes('rm -rf')) { + // Simulate the owner's deletion being midway: marker and one file already gone. + const [owned] = tombstones() + rmSync(join(cache, owned, '.deps-complete')) + rmSync(join(cache, owned, 'node_modules', 'addon.node')) + referenceEntry() + // A later pass on another client sees the owned name as stale and wants the key back. + const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 31 * 60_000) + try { + otherPass = gcRelayNativeDepsCache(conn, host, home, { pinnedKeys: [key] }) + await otherPass + } finally { + clock.mockRestore() + } + } + return runShell(command) + }) + + await gcRelayNativeDepsCache(conn, host, home) + + expect(otherPass).toBeDefined() + expect(existsSync(entry)).toBe(false) + expect( + tombstones().filter((name) => existsSync(join(cache, name, '.deps-complete'))) + ).toEqual([]) + }) + + it('never restores a tree whose deletion was interrupted, even once its name is stale', async () => { + tombstone() + const interrupted = new Error('rm interrupted') + execMock.mockImplementation(async (_conn: unknown, command: string) => { + if (command.includes('rm -rf')) { + runShell(command.slice(0, command.indexOf(' && '))) + const [owned] = tombstones() + rmSync(join(cache, owned, 'node_modules', 'addon.node')) + throw interrupted + } + return runShell(command) + }) + await gcRelayNativeDepsCache(conn, host, home) + const [owned] = tombstones() + expect(existsSync(join(cache, owned, 'node_modules'))).toBe(true) + + referenceEntry() + execMock.mockImplementation(async (_conn: unknown, command: string) => runShell(command)) + await runLaterPass() + + expect(existsSync(entry)).toBe(false) + expect(tombstones()).toHaveLength(1) + expect(existsSync(join(cache, tombstones()[0], '.deps-complete'))).toBe(false) + }) + + it('preserves a referenced tombstone when the clock moves backward during the reference scan', async () => { + referenceEntry() + const now = Date.now() + const path = tombstone(now - 30 * 60_000 - 1) + const clock = vi.spyOn(Date, 'now').mockReturnValue(now) + execMock.mockImplementation(async (_conn: unknown, command: string) => { + if (command.includes('readlink')) { + clock.mockReturnValue(now - 60_000) + } + return runShell(command) + }) + try { + await gcRelayNativeDepsCache(conn, host, home) + expect(readFileSync(join(relay, 'node_modules', 'addon.node'), 'utf8')).toBe('original') + expect(existsSync(path)).toBe(false) + } finally { + clock.mockRestore() + } + }) + + it('keeps new tombstones outside an old client mtime-only deletion sweep', () => { + const path = tombstone() + const legacy = join(cache, `${LEGACY_RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX}${key}.123.1`) + mkdirSync(legacy) + utimesSync(legacy, new Date(0), new Date(0)) + + runShell( + `find ${shellEscape(cache)} -maxdepth 1 -name '.gc-tombstone.*' -mmin +30 -exec rm -rf {} +` + ) + + expect(existsSync(join(path, 'node_modules', 'addon.node'))).toBe(true) + expect(existsSync(legacy)).toBe(false) + }) + + it.skipIf(process.getuid?.() === 0).each(['root', 'relay'])( + 'preserves tombstones when the %s listing is unreadable', + async (scope) => { + const path = tombstone() + referenceEntry() + chmodSync(scope === 'root' ? join(home, '.orca-remote') : relay, 0o111) + + await gcRelayNativeDepsCache(conn, host, home) + + expect(existsSync(join(path, 'node_modules', 'addon.node'))).toBe(true) + expect(execMock.mock.calls.some(([, command]) => String(command).includes('rm -rf'))).toBe( + false + ) + } + ) + + it('preserves tombstones when a reference cannot be attributed', async () => { + const path = tombstone() + symlinkSync('../unknown/node_modules', join(relay, 'node_modules')) + + await gcRelayNativeDepsCache(conn, host, home) + + expect(existsSync(join(path, 'node_modules', 'addon.node'))).toBe(true) + expect(execMock).toHaveBeenCalledTimes(2) + }) + + it('collects a stale tombstone only after two complete reference scans', async () => { + const path = tombstone() + + await gcRelayNativeDepsCache(conn, host, home) + + expect(existsSync(path)).toBe(false) + expect( + execMock.mock.calls.filter(([, command]) => String(command).includes('readlink')) + ).toHaveLength(2) + }) + + it('collects an interrupted tombstone deletion after its completion marker is gone', async () => { + const path = tombstone() + rmSync(join(path, '.deps-complete')) + + await gcRelayNativeDepsCache(conn, host, home) + + expect(existsSync(path)).toBe(false) + }) + + it('leaves incomplete ordinary entries owned by installers alone', async () => { + rmSync(join(entry, '.deps-complete')) + + await gcRelayNativeDepsCache(conn, host, home) + + expect(readFileSync(join(entry, 'node_modules', 'addon.node'), 'utf8')).toBe('original') + expect(execMock).toHaveBeenCalledTimes(1) + }) + + it.each([() => Date.now(), () => Date.now() + 60 * 60_000, () => Number.MAX_SAFE_INTEGER + 1])( + 'keeps tombstones with a recent, future, or unsafe name timestamp despite ancient mtime', + async (timestamp) => { + const path = tombstone(timestamp()) + + await gcRelayNativeDepsCache(conn, host, home) + + expect(existsSync(join(path, 'node_modules', 'addon.node'))).toBe(true) + expect(execMock).toHaveBeenCalledTimes(1) + } + ) + + it('restores a link created during rename when the recheck termination is unconfirmed', async () => { + const error = Object.assign(new Error('read-only recheck timed out'), { + sshChannelCloseConfirmed: false + }) + let scans = 0 + execMock.mockImplementation(async (_conn: unknown, command: string) => { + if (command.includes('readlink') && ++scans === 2) { + throw error + } + const output = runShell(command) + if (command.startsWith('mv ')) { + referenceEntry() + } + return output + }) + + await expect(gcRelayNativeDepsCache(conn, host, home)).rejects.toBe(error) + + expect(readFileSync(join(relay, 'node_modules', 'addon.node'), 'utf8')).toBe('original') + expect(readdirSync(cache)).toEqual([key]) + expect(execMock).toHaveBeenCalledTimes(5) + }) + + it.each(['directory', 'dangling symlink'])( + 'does not overwrite or nest inside a recreated %s', + async (kind) => { + referenceEntry() + const path = tombstone() + if (kind === 'directory') { + mkdirSync(entry) + writeFileSync(join(entry, 'replacement'), 'new') + } else { + symlinkSync(join(cache, 'missing'), entry) + } + + await gcRelayNativeDepsCache(conn, host, home) + + expect(existsSync(path)).toBe(false) + const [claimed] = tombstones() + expect(readFileSync(join(cache, claimed, 'node_modules', 'addon.node'), 'utf8')).toBe( + 'original' + ) + if (kind === 'directory') { + expect(readdirSync(entry)).toEqual(['replacement']) + } else { + expect(readlinkSync(entry)).toBe(join(cache, 'missing')) + } + } + ) + } +) diff --git a/src/main/ssh/ssh-relay-native-deps-cache-gc.ts b/src/main/ssh/ssh-relay-native-deps-cache-gc.ts index e2349ad1bbf..0febd709cd6 100644 --- a/src/main/ssh/ssh-relay-native-deps-cache-gc.ts +++ b/src/main/ssh/ssh-relay-native-deps-cache-gc.ts @@ -14,23 +14,29 @@ * linked the entry between the first listing and the rename shows up in the recheck, and its tree * is moved back. */ +import { randomInt } from 'node:crypto' import type { SshConnection } from './ssh-connection' import { execCommand } from './ssh-relay-deploy-helpers' +import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' import { isRelayNativeDepsCacheEntryName, relayNativeDepsCacheBaseDir, relayNativeDepsCacheNodeModulesPath, supportsRelayNativeDepsCache, - RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX + RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX, + LEGACY_RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX } from './ssh-relay-native-deps-cache' import { listRelayNativeDepsCacheEntriesCommand, listRelayNativeDepsCacheReferencesCommand, MAX_RELAY_NATIVE_CACHE_LISTING_ENTRIES, RELAY_NATIVE_CACHE_LIST_OK, - RELAY_NATIVE_CACHE_REFS_OK + RELAY_NATIVE_CACHE_REFS_OK, + dropRelayNativeDepsCacheCompletionMarkerCommand, + removeRelayNativeDepsCacheTombstoneCommand, + restoreRelayNativeDepsCacheTombstoneCommand } from './ssh-relay-native-deps-cache-commands' -import { moveRemoteTreeCommand, removeRemoteTreeCommand } from './ssh-remote-commands' +import { moveRemoteTreeCommand } from './ssh-remote-commands' import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' type ReferenceScan = @@ -38,12 +44,34 @@ type ReferenceScan = /** Anything this client could not fully account for. No entry may be deleted on it. */ | { readable: false } +function staleTombstoneKey(name: string): string | null { + const prefix = [ + RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX, + LEGACY_RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX + ].find((value) => name.startsWith(value)) + if (!prefix) { + return null + } + const match = /^(.*)\.(\d+)\.(\d+)$/.exec(name.slice(prefix.length)) + if ( + !match || + !isRelayNativeDepsCacheEntryName(match[1]) || + !Number.isSafeInteger(Number(match[3])) || + Date.now() - Number(match[3]) < 30 * 60_000 + ) { + return null + } + return match[1] +} + function execHostCommand( conn: SshConnection, host: RemoteHostPlatform, command: string ): Promise { - return execCommand(conn, command, { wrapCommand: host.commandDialect !== 'powershell' }) + return execCommand(conn, command, { + wrapCommand: host.commandDialect !== 'powershell' + }) } /** @@ -64,12 +92,15 @@ export async function gcRelayNativeDepsCache( return } const base = relayNativeDepsCacheBaseDir(host, remoteHome) - let entries: string[] + let entries: CacheEntry[] try { entries = parseCacheEntryListing( await execHostCommand(conn, host, listRelayNativeDepsCacheEntriesCommand(host, remoteHome)) ) - } catch { + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } return } if (entries.length === 0) { @@ -80,10 +111,14 @@ export async function gcRelayNativeDepsCache( return } const pinned = new Set(options?.pinnedKeys ?? []) - const candidates = entries.filter((key) => !scan.referencedKeys.has(key) && !pinned.has(key)) const removed: string[] = [] - for (const key of candidates) { - if (await removeUnreferencedCacheEntry(conn, host, remoteHome, base, key)) { + for (const { name, key } of entries) { + const keep = scan.referencedKeys.has(key) || pinned.has(key) + const collected = + name === key + ? !keep && (await removeUnreferencedCacheEntry(conn, host, remoteHome, base, key)) + : await recoverAbandonedTombstone(conn, host, remoteHome, base, name, key, keep) + if (collected) { removed.push(key) } } @@ -94,6 +129,33 @@ export async function gcRelayNativeDepsCache( } } +// Why random rather than pid + clock: passes on different clients must never rename into one path. +function ownedTombstonePath(host: RemoteHostPlatform, base: string, key: string): string { + const name = `${RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX}${key}.${randomInt(1, 2 ** 47)}.${Date.now()}` + return joinRemotePath(host, base, name) +} + +async function moveTree( + conn: SshConnection, + host: RemoteHostPlatform, + source: string, + destination: string +): Promise { + try { + const moved = await execHostCommand( + conn, + host, + moveRemoteTreeCommand(host, source, destination) + ) + return moved.trim() === 'MOVED' + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + return false + } +} + async function removeUnreferencedCacheEntry( conn: SshConnection, host: RemoteHostPlatform, @@ -102,47 +164,104 @@ async function removeUnreferencedCacheEntry( key: string ): Promise { const entryDir = joinRemotePath(host, base, key) - const tombstone = joinRemotePath( - host, - base, - `${RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX}${key}.${process.pid}.${Date.now()}` - ) - try { - const moved = await execHostCommand( - conn, - host, - moveRemoteTreeCommand(host, entryDir, tombstone) - ) - if (moved.trim() !== 'MOVED') { - return false - } - } catch { + const tombstone = ownedTombstonePath(host, base, key) + if (!(await moveTree(conn, host, entryDir, tombstone))) { return false } + return collectOwnedTombstone(conn, host, remoteHome, key, tombstone, entryDir) +} + +/** + * Another pass may list the same abandoned tombstone, so only the pass whose rename wins may + * restore or delete it; the fresh name keeps every other pass off it for the stale window. + */ +async function recoverAbandonedTombstone( + conn: SshConnection, + host: RemoteHostPlatform, + remoteHome: string, + base: string, + name: string, + key: string, + keep: boolean +): Promise { + const source = joinRemotePath(host, base, name) + // Why unmark legacy trees before claiming them: an old client's mtime sweep may have left a + // partial tree with its marker, and the marker is all a later pass checks before restoring. + if ( + name.startsWith(LEGACY_RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX) && + !(await runOrDecline(conn, host, dropRelayNativeDepsCacheCompletionMarkerCommand(source))) + ) { + return false + } + const tombstone = ownedTombstonePath(host, base, key) + if (!(await moveTree(conn, host, source, tombstone))) { + return false + } + const entryDir = joinRemotePath(host, base, key) + if (keep) { + await restoreCacheEntry(conn, host, tombstone, entryDir) + return false + } + return collectOwnedTombstone(conn, host, remoteHome, key, tombstone, entryDir) +} + +async function collectOwnedTombstone( + conn: SshConnection, + host: RemoteHostPlatform, + remoteHome: string, + key: string, + tombstone: string, + entryDir: string +): Promise { // Why recheck under the rename: a deploy that read `.deps-complete` before it moved can still // be creating its symlink. Its reference now names a path that no longer exists, so restoring // the tree is the only outcome that leaves that relay with working native deps. - let recheck: ReferenceScan - try { - recheck = await scanCacheReferences(conn, host, remoteHome) - } catch { - recheck = { readable: false } - } + const recheck = await scanCacheReferences(conn, host, remoteHome).catch(async (err: unknown) => { + // A read-only scan cannot conflict with restoring this pass's renamed tree. + await restoreCacheEntry(conn, host, tombstone, entryDir).catch(() => {}) + throw err + }) if (!recheck.readable || recheck.referencedKeys.has(key)) { - await execHostCommand(conn, host, moveRemoteTreeCommand(host, tombstone, entryDir)).catch( - () => {} - ) + await restoreCacheEntry(conn, host, tombstone, entryDir) return false } + // A failed removal is retried by a later pass after its own recheck. + return runOrDecline(conn, host, removeRelayNativeDepsCacheTombstoneCommand(tombstone)) +} + +async function runOrDecline( + conn: SshConnection, + host: RemoteHostPlatform, + command: string +): Promise { try { - await execHostCommand(conn, host, removeRemoteTreeCommand(host, tombstone)) + await execHostCommand(conn, host, command) return true - } catch { - // The sweep in the entry listing drains a tombstone this pass could not remove. + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } return false } } +async function restoreCacheEntry( + conn: SshConnection, + host: RemoteHostPlatform, + tombstone: string, + entryDir: string +): Promise { + await execHostCommand( + conn, + host, + restoreRelayNativeDepsCacheTombstoneCommand(tombstone, entryDir) + ).catch((err) => { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + }) +} + async function scanCacheReferences( conn: SshConnection, host: RemoteHostPlatform, @@ -155,7 +274,10 @@ async function scanCacheReferences( host, listRelayNativeDepsCacheReferencesCommand(host, remoteHome) ) - } catch { + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } return { readable: false } } const lines = output.split(/\r?\n/).map((line) => line.trim()) @@ -219,12 +341,14 @@ function attributeReference( : { kind: 'unattributable' } } -function parseCacheEntryListing(output: string): string[] { +type CacheEntry = { name: string; key: string } + +function parseCacheEntryListing(output: string): CacheEntry[] { const lines = output.split(/\r?\n/).map((line) => line.trim()) if (!lines.includes(RELAY_NATIVE_CACHE_LIST_OK)) { return [] } - const entries: string[] = [] + const entries: CacheEntry[] = [] for (const line of lines) { if (!line.startsWith('ENTRY ')) { continue @@ -232,11 +356,9 @@ function parseCacheEntryListing(output: string): string[] { const name = line.slice('ENTRY '.length) // Why re-validate a name the host produced: it is about to be interpolated into `mv` and // `rm -rf`. Only names this client could itself have minted are eligible. - if ( - isRelayNativeDepsCacheEntryName(name) && - entries.length < MAX_RELAY_NATIVE_CACHE_LISTING_ENTRIES - ) { - entries.push(name) + const key = isRelayNativeDepsCacheEntryName(name) ? name : staleTombstoneKey(name) + if (key && entries.length < MAX_RELAY_NATIVE_CACHE_LISTING_ENTRIES) { + entries.push({ name, key }) } } return entries diff --git a/src/main/ssh/ssh-relay-native-deps-cache.test.ts b/src/main/ssh/ssh-relay-native-deps-cache.test.ts index bfe0c481983..7fee587b3ec 100644 --- a/src/main/ssh/ssh-relay-native-deps-cache.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-cache.test.ts @@ -168,7 +168,7 @@ describe('gcRelayNativeDepsCache', () => { const last = mockExec.mock.calls.at(-1)?.[1] ?? '' expect(last).toContain('rm -rf') - expect(last).toContain('.gc-tombstone.') + expect(last).toContain('.native-gc-') }) it('keeps an entry a live relay depends on', async () => { @@ -179,7 +179,7 @@ describe('gcRelayNativeDepsCache', () => { await gcRelayNativeDepsCache(conn, POSIX, HOME) expect(mockExec).toHaveBeenCalledTimes(2) - expect(mockExec.mock.calls.some(([, c]) => c.startsWith('rm -rf'))).toBe(false) + expect(mockExec.mock.calls.some(([, c]) => c.includes('rm -rf'))).toBe(false) }) it('keeps every entry when the reference listing never answers', async () => { @@ -219,7 +219,7 @@ describe('gcRelayNativeDepsCache', () => { await gcRelayNativeDepsCache(conn, POSIX, HOME) - expect(mockExec.mock.calls.some(([, c]) => c.startsWith('rm -rf'))).toBe(true) + expect(mockExec.mock.calls.some(([, c]) => c.includes('rm -rf'))).toBe(true) }) it('restores the tree when a deploy links the entry after the tombstone rename', async () => { @@ -235,7 +235,7 @@ describe('gcRelayNativeDepsCache', () => { const last = mockExec.mock.calls.at(-1)?.[1] ?? '' expect(last).toContain('mv ') expect(last).toContain(relayNativeDepsCacheEntryDir(POSIX, HOME, KEY)) - expect(mockExec.mock.calls.some(([, c]) => c.startsWith('rm -rf'))).toBe(false) + expect(mockExec.mock.calls.some(([, c]) => c.includes('rm -rf'))).toBe(false) }) it('restores the tree when the recheck itself cannot answer', async () => { @@ -248,7 +248,7 @@ describe('gcRelayNativeDepsCache', () => { await gcRelayNativeDepsCache(conn, POSIX, HOME) - expect(mockExec.mock.calls.some(([, c]) => c.startsWith('rm -rf'))).toBe(false) + expect(mockExec.mock.calls.some(([, c]) => c.includes('rm -rf'))).toBe(false) }) it('never removes a pinned key', async () => { diff --git a/src/main/ssh/ssh-relay-native-deps-cache.ts b/src/main/ssh/ssh-relay-native-deps-cache.ts index 8400a467a91..79f04221c01 100644 --- a/src/main/ssh/ssh-relay-native-deps-cache.ts +++ b/src/main/ssh/ssh-relay-native-deps-cache.ts @@ -41,8 +41,9 @@ export const RELAY_NATIVE_DEPS_CACHE_DIR_NAME = 'native' /** Written last. Its presence is the only thing that makes an entry linkable. */ export const RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME = '.deps-complete' -/** Hidden so the entry listing skips it, and swept by age so a crashed pass drains. */ -export const RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX = '.gc-tombstone.' +/** Older clients sweep the legacy prefix by mtime without checking references. */ +export const RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX = '.native-gc-' +export const LEGACY_RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX = '.gc-tombstone.' /** * Bump when the remote install starts mutating the installed tree in a way the hashed inputs diff --git a/src/main/ssh/ssh-relay-native-deps-install.test.ts b/src/main/ssh/ssh-relay-native-deps-install.test.ts index df15135963a..5c1e99eb80d 100644 --- a/src/main/ssh/ssh-relay-native-deps-install.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install.test.ts @@ -46,6 +46,9 @@ vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ // Why: the post-launch ripgrep install would consume this file's queued exec mocks. // Why: the post-launch ripgrep cache GC is fire-and-forget and would drain the queued exec mocks. vi.mock('./ssh-relay-ripgrep-cache-gc', () => ({ gcRemoteRipgrepCache: vi.fn() })) +vi.mock('./ssh-relay-opencode-runtime', () => ({ + ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') +})) vi.mock('./ssh-relay-ripgrep-install', async (importOriginal) => ({ ...(await importOriginal()), ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue('present'), diff --git a/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts b/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts index 395aed06f95..69c7ae1d2a5 100644 --- a/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts @@ -35,6 +35,9 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn() })) +vi.mock('./ssh-relay-opencode-runtime', () => ({ + ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') +})) vi.mock('./ssh-relay-ripgrep-install', () => ({ remoteRipgrepLayout: vi.fn().mockReturnValue(null), recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false), diff --git a/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts b/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts index 1f8fd69b8dd..7eda53e684d 100644 --- a/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts +++ b/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts @@ -6,6 +6,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' +vi.mock('./ssh-relay-opencode-runtime', () => ({ + ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') +})) vi.mock('./ssh-relay-ripgrep-install', () => ({ remoteRipgrepLayout: vi.fn().mockReturnValue(null), recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false), @@ -80,7 +83,7 @@ vi.mock('./ssh-connection-utils', () => ({ import { deployAndLaunchRelay } from './ssh-relay-deploy' import { execCommand } from './ssh-relay-deploy-helpers' import { parseUnameToRelayPlatform } from './relay-protocol' -import { isRelayAlreadyInstalled } from './ssh-relay-versioned-install' +import { gcOldRelayVersions, isRelayAlreadyInstalled } from './ssh-relay-versioned-install' import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' import { makeMockConnection, @@ -221,6 +224,7 @@ describe('spawn-time node-pty repair through the locked deploy path', () => { feed(repairSucceedsResponses()) const deploys = { count: 0 } await recover(conn, deploys) + await vi.waitFor(() => expect(gcOldRelayVersions).toHaveBeenCalledOnce()) vi.mocked(execCommand).mockReset().mockResolvedValue('') const second = await recover(conn, deploys) diff --git a/src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts b/src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts new file mode 100644 index 00000000000..72b5254c322 --- /dev/null +++ b/src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts @@ -0,0 +1,274 @@ +import { createHash } from 'node:crypto' +import { mkdir, mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { decodeRemotePowerShellScript } from './ssh-remote-powershell' +import { + parseOpenCodeRuntimeResult, + probeOpenCodeRuntimeCacheCommand, + probeOpenCodeNodeSqliteCommand, + promoteOpenCodeRuntimeCommand, + publishOpenCodeRuntimeReferenceCommand +} from './ssh-relay-opencode-runtime-commands' +import { + cleanupOwnedRelayUploadStageCommand, + parseReservedRelayUploadStage, + recoverOneStaleRelayUploadStageCommand, + reserveRelayUploadStageCommand +} from './ssh-relay-upload-stage-commands' + +const host = getRemoteHostPlatform('linux-x64') +const nodePath = process.execPath +const directories: string[] = [] +const expectedHash = createHash('sha256').update('verified runtime').digest('hex') +const markerName = '.sftp-namespace-0123456789abcdef0123456789abcdef' + +afterEach(async () => { + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })) + ) +}) + +async function directory(): Promise { + const result = await mkdtemp(join(tmpdir(), "orca-runtime spaces ' $-")) + directories.push(result) + return result +} + +async function command(text: string, environment: NodeJS.ProcessEnv = {}) { + return runProcess({ + program: 'sh', + args: ['-c', text], + timeoutMs: 10_000, + env: { ...process.env, OPENCODE_DB: '', XDG_DATA_HOME: '', ...environment } + }) +} + +async function reserveStage(root: string) { + const pool = join(root, '.upload-stages') + const result = await command(reserveRelayUploadStageCommand(host, pool, markerName)) + expect(result.code, result.stderr).toBe(0) + return parseReservedRelayUploadStage(host, pool, markerName, result.stdout) +} + +describe.skipIf(process.platform === 'win32')('host-owned SQLite setup commands', () => { + it('runs an actual SQLite read and identifies the executable', async () => { + const home = await directory() + const data = join(home, '.local', 'share', 'opencode') + await mkdir(data, { recursive: true }) + await writeFile(join(data, 'opencode.db'), '') + const result = await command(probeOpenCodeNodeSqliteCommand(host, nodePath, home)) + expect(result.code).toBe(0) + expect(parseOpenCodeRuntimeResult(result.stdout)).toEqual({ + status: 'ready', + executable: nodePath + }) + }) + + it('stages, verifies by bytes, promotes and atomically publishes under quoted paths', async () => { + const root = await directory() + const stage = await reserveStage(root) + const stageDir = stage.slotDir + const executable = join(root, expectedHash, 'bun') + const prepared = await command( + probeOpenCodeRuntimeCacheCommand({ + host, + nodePath, + executable, + expectedHash, + reference: join(root, 'runtime.json') + }) + ) + expect(parseOpenCodeRuntimeResult(prepared.stdout).status).toBe('missing') + expect((await stat(join(stageDir, markerName))).isFile()).toBe(true) + const stagedBinary = join(stageDir, 'payload', 'bun') + await writeFile(stagedBinary, 'verified runtime') + const promoted = await command( + promoteOpenCodeRuntimeCommand({ + host, + nodePath, + stagedBinary, + executable, + expectedHash, + repairToken: 'repair' + }) + ) + expect(parseOpenCodeRuntimeResult(promoted.stdout)).toEqual({ status: 'ready', executable }) + expect(await readFile(executable, 'utf8')).toBe('verified runtime') + const reference = join(root, 'opencode-sqlite-runtime.json') + await writeFile(reference, '{"old":true}') + const stagedReference = join(stageDir, 'payload', 'ref.json') + await writeFile(stagedReference, JSON.stringify({ protocol: 1, executable })) + const published = await command( + publishOpenCodeRuntimeReferenceCommand({ + host, + nodePath, + stagedReference, + reference, + token: 'one' + }) + ) + expect(parseOpenCodeRuntimeResult(published.stdout).status).toBe('published') + expect(JSON.parse(await readFile(reference, 'utf8'))).toEqual({ protocol: 1, executable }) + await command(cleanupOwnedRelayUploadStageCommand(host, stage, markerName)) + await expect(stat(stageDir)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await readFile(executable, 'utf8')).toBe('verified runtime') + }) + + it('refuses equal-sized corrupt uploads instead of accepting a size match', async () => { + const root = await directory() + const stagedBinary = join(root, 'source') + const executable = join(root, 'installed', 'bun') + await writeFile(stagedBinary, 'corrupt! runtime') + expect((await stat(stagedBinary)).size).toBe(Buffer.byteLength('verified runtime')) + const result = await command( + promoteOpenCodeRuntimeCommand({ + host, + nodePath, + stagedBinary, + executable, + expectedHash, + repairToken: 'one' + }) + ) + expect(result.code).not.toBe(0) + expect(result.stderr).toContain('checksum mismatch') + await expect(stat(executable)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('preserves an existing corrupt binary and reuses its verified repair reference', async () => { + const root = await directory() + const executable = join(root, expectedHash, 'bun') + await mkdir(join(root, expectedHash)) + await writeFile(executable, 'old binary still owned by another process') + const stagedBinary = join(root, 'source') + await writeFile(stagedBinary, 'verified runtime') + const promoted = await command( + promoteOpenCodeRuntimeCommand({ + host, + nodePath, + stagedBinary, + executable, + expectedHash, + repairToken: 'two' + }) + ) + const repaired = join(root, expectedHash, 'repair-two', 'bun') + expect(parseOpenCodeRuntimeResult(promoted.stdout).executable).toBe(repaired) + expect(await readFile(executable, 'utf8')).toBe('old binary still owned by another process') + const reference = join(root, 'runtime.json') + await writeFile(reference, JSON.stringify({ protocol: 1, executable: repaired })) + const prepared = await command( + probeOpenCodeRuntimeCacheCommand({ + host, + nodePath, + executable, + expectedHash, + reference + }) + ) + expect(parseOpenCodeRuntimeResult(prepared.stdout)).toEqual({ + status: 'ready', + executable: repaired + }) + }) + + it('defers an empty host, honors database overrides, and ignores in-memory databases', async () => { + const home = await directory() + const probe = probeOpenCodeNodeSqliteCommand(host, nodePath, home) + expect(parseOpenCodeRuntimeResult((await command(probe)).stdout).status).toBe('not-needed') + const xdg = join(home, 'other data') + await mkdir(join(xdg, 'opencode'), { recursive: true }) + await writeFile(join(xdg, 'opencode', 'opencode-team.db'), '') + const environment = { XDG_DATA_HOME: xdg, OPENCODE_DB: 'opencode-team.db' } + expect(parseOpenCodeRuntimeResult((await command(probe, environment)).stdout).status).toBe( + 'ready' + ) + expect( + parseOpenCodeRuntimeResult( + (await command(probe, { ...environment, OPENCODE_DB: ':memory:' })).stdout + ).status + ).toBe('not-needed') + }) + + it('reclaims an abandoned binary through the shared pool while preserving fresh uploads', async () => { + const root = await directory() + const abandoned = await reserveStage(root) + await writeFile(join(abandoned.slotDir, 'payload', 'bun'), 'partial upload') + const fresh = await reserveStage(root) + await writeFile(join(fresh.slotDir, 'payload', 'bun'), 'active upload') + const old = new Date(Date.now() - 3_600_000) + await utimes(join(abandoned.slotDir, '.orca-upload-owner'), old, old) + const recovered = await command(recoverOneStaleRelayUploadStageCommand(host, abandoned.poolDir)) + expect(recovered.code, recovered.stderr).toBe(0) + await expect(stat(abandoned.slotDir)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await readFile(join(fresh.slotDir, 'payload', 'bun'), 'utf8')).toBe('active upload') + }) + + it('falls back to an atomic unique rename when the host filesystem rejects hard links', async () => { + const root = await directory() + const source = join(root, 'source') + await writeFile(source, 'verified runtime') + const preload = join(root, 'disable-hardlinks.cjs') + await writeFile( + preload, + "require('node:fs').promises.link=async()=>{throw Object.assign(Error('unsupported'),{code:'EPERM'})}" + ) + const executable = join(root, expectedHash, 'bun') + const result = await command( + promoteOpenCodeRuntimeCommand({ + host, + nodePath, + stagedBinary: source, + executable, + expectedHash, + repairToken: 'fallback' + }), + { NODE_OPTIONS: `--require ${JSON.stringify(preload)}` } + ) + expect(result.code, result.stderr).toBe(0) + const repaired = join(root, expectedHash, 'repair-fallback', 'bun') + expect(parseOpenCodeRuntimeResult(result.stdout)).toEqual({ + status: 'ready', + executable: repaired + }) + expect(await readFile(repaired, 'utf8')).toBe('verified runtime') + await expect(stat(source)).rejects.toMatchObject({ code: 'ENOENT' }) + }) +}) + +it('carries Windows JavaScript and path arguments through the established PowerShell encoder', () => { + const windows = getRemoteHostPlatform('win32-x64') + const command = promoteOpenCodeRuntimeCommand({ + host: windows, + nodePath: "C:/Program Files/O'Brien/node.exe", + stagedBinary: 'C:/Users/a & b/.upload/bun.exe', + executable: 'C:/Users/a & b/cache/bun.exe', + expectedHash, + repairToken: 'one' + }) + const decoded = decodeRemotePowerShellScript(command) + expect(decoded).toContain("& 'C:/Program Files/O''Brien/node.exe'") + expect(decoded).toContain('createHash') + expect(decoded).toContain('C:/Users/a & b/.upload/bun.exe') + expect(command).not.toContain('-ExecutionPolicy') +}) + +it('rejects missing or malformed host confirmations', () => { + expect(() => parseOpenCodeRuntimeResult('login banner')).toThrow('did not confirm') + expect(() => + parseOpenCodeRuntimeResult('ORCA_VAULT_SQLITE:{"status":"ready","executable":"node"}') + ).toThrow('invalid executable') +}) + +it('accepts an absolute Windows UNC executable path', () => { + const executable = String.raw`\\server\profile\vault-sqlite\bun.exe` + expect( + parseOpenCodeRuntimeResult( + `ORCA_VAULT_SQLITE:${JSON.stringify({ status: 'ready', executable })}` + ) + ).toEqual({ status: 'ready', executable }) +}) diff --git a/src/main/ssh/ssh-relay-opencode-runtime-commands.ts b/src/main/ssh/ssh-relay-opencode-runtime-commands.ts new file mode 100644 index 00000000000..14900f15e5b --- /dev/null +++ b/src/main/ssh/ssh-relay-opencode-runtime-commands.ts @@ -0,0 +1,151 @@ +import { shellEscape } from './ssh-connection-utils' +import { posix, win32 } from 'node:path' +import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' +import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' + +export const OPENCODE_RUNTIME_RESULT = 'ORCA_VAULT_SQLITE:' + +function nodeCommand( + host: RemoteHostPlatform, + nodePath: string, + script: string, + args: string[] +): string { + if (isWindowsRemoteHost(host)) { + return powerShellCommand( + `& ${powerShellLiteral(nodePath)} -e ${powerShellNativeArg(script)} -- ${args.map(powerShellNativeArg).join(' ')}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }` + ) + } + return `${shellEscape(nodePath)} -e ${shellEscape(script)} -- ${args.map(shellEscape).join(' ')}` +} + +const SEND = `const send=(value)=>console.log(${JSON.stringify(OPENCODE_RUNTIME_RESULT)}+JSON.stringify(value));` +const HASH = `const fs=require('node:fs');const fsp=fs.promises;const path=require('node:path'); +async function hash(file){try{const digest=require('node:crypto').createHash('sha256');for await(const chunk of fs.createReadStream(file))digest.update(chunk);return digest.digest('hex')}catch(error){if(error.code==='ENOENT')return null;throw error}} +` + +export function probeOpenCodeNodeSqliteCommand( + host: RemoteHostPlatform, + nodePath: string, + homeDirectory: string +): string { + return nodeCommand( + host, + nodePath, + `${SEND} +const fs=require('node:fs/promises');const path=require('node:path'); +(async()=>{const data=path.join(process.env.XDG_DATA_HOME?.trim()||path.join(process.argv[1],'.local','share'),'opencode'); +const override=process.env.OPENCODE_DB?.trim();let present=false; +try{if(override&&override!==':memory:'){present=(await fs.stat(path.isAbsolute(override)?override:path.join(data,override))).isFile()} +else if(!override){const directory=await fs.opendir(data);for await(const entry of directory){if(entry.isFile()&&/^opencode(?:-[A-Za-z0-9_.-]+)?\\.db$/.test(entry.name)){present=true;break}}}} +catch(error){if(error.code!=='ENOENT'&&error.code!=='ENOTDIR')throw error} +if(!present){send({status:'not-needed'});return} +let db;try{db=new(require('node:sqlite').DatabaseSync)(':memory:'); +if(db.prepare('SELECT 1 AS ready').get().ready!==1)throw Error('SQLite read failed'); +send({status:'ready',executable:process.execPath})}catch{send({status:'unsupported'})}finally{if(db)db.close()} +})().catch(error=>{console.error(error.message);process.exitCode=1})`, + [homeDirectory] + ) +} + +export function probeOpenCodeRuntimeCacheCommand(args: { + host: RemoteHostPlatform + nodePath: string + executable: string + expectedHash: string + reference: string +}): string { + return nodeCommand( + args.host, + args.nodePath, + `${HASH}${SEND} +(async()=>{const [executable,expected,reference]=process.argv.slice(1); +let candidate=executable;let digest=candidate?await hash(candidate):null; +if(candidate&&digest!==expected){try{const ref=JSON.parse(await fsp.readFile(reference,'utf8')); +const relative=path.relative(path.dirname(executable),ref.executable); +if(ref.protocol===1&&relative&&!relative.startsWith('..'+path.sep)&&relative!=='..'&&!path.isAbsolute(relative)){candidate=ref.executable;digest=await hash(candidate)}}catch{}} +if(candidate&&digest===expected){if(process.platform!=='win32')await fsp.chmod(candidate,448);send({status:'ready',executable:candidate});return} +send({status:'missing'})})().catch(error=>{console.error(error.message);process.exitCode=1})`, + [args.executable, args.expectedHash, args.reference] + ) +} + +export function promoteOpenCodeRuntimeCommand(args: { + host: RemoteHostPlatform + nodePath: string + stagedBinary: string + executable: string + expectedHash: string + repairToken: string +}): string { + return nodeCommand( + args.host, + args.nodePath, + `${HASH}${SEND} +(async()=>{const [source,destination,expected,token]=process.argv.slice(1); +if(await hash(source)!==expected)throw Error('Uploaded SQLite runtime checksum mismatch'); +let executable=destination;const existing=await hash(destination); +if(existing!==expected){ +if(existing!==null)executable=path.join(path.dirname(destination),'repair-'+token,path.basename(destination)); +await fsp.mkdir(path.dirname(executable),{recursive:true,mode:448}); +if(process.platform!=='win32')await fsp.chmod(source,448); +try{await fsp.link(source,executable)}catch(error){if(await hash(executable)!==expected){ +if(!['EPERM','EOPNOTSUPP','ENOTSUP','ENOSYS','EXDEV'].includes(error.code))throw error; +executable=path.join(path.dirname(destination),'repair-'+token,path.basename(destination)); +await fsp.mkdir(path.dirname(executable),{recursive:true,mode:448});await fsp.rename(source,executable) +}} +} +send({status:'ready',executable})})().catch(error=>{console.error(error.message);process.exitCode=1})`, + [args.stagedBinary, args.executable, args.expectedHash, args.repairToken] + ) +} + +export function publishOpenCodeRuntimeReferenceCommand(args: { + host: RemoteHostPlatform + nodePath: string + stagedReference: string + reference: string + token: string +}): string { + return nodeCommand( + args.host, + args.nodePath, + `${SEND} +const fs=require('node:fs/promises');const path=require('node:path'); +(async()=>{const [source,destination,token]=process.argv.slice(1);const temporary=destination+'.upload-'+token; +try{await fs.copyFile(source,temporary,require('node:fs').constants.COPYFILE_EXCL); +await fs.rename(temporary,destination);send({status:'published'})} +finally{await fs.rm(temporary,{force:true})}})().catch(error=>{console.error(error.message);process.exitCode=1})`, + [args.stagedReference, args.reference, args.token] + ) +} + +export function parseOpenCodeRuntimeResult(output: string): { + status: string + executable?: string +} { + const line = output.split(/\r?\n/).findLast((entry) => entry.startsWith(OPENCODE_RUNTIME_RESULT)) + if (!line) { + throw new Error('The host did not confirm SQLite runtime setup.') + } + const result: unknown = JSON.parse(line.slice(OPENCODE_RUNTIME_RESULT.length)) + if ( + typeof result !== 'object' || + result === null || + !('status' in result) || + typeof result.status !== 'string' + ) { + throw new Error('Invalid SQLite runtime setup result.') + } + if ('executable' in result) { + if ( + typeof result.executable !== 'string' || + !(posix.isAbsolute(result.executable) || win32.isAbsolute(result.executable)) || + /[\0\r\n]/.test(result.executable) + ) { + throw new Error('SQLite runtime setup returned an invalid executable path.') + } + return { status: result.status, executable: result.executable } + } + return { status: result.status } +} diff --git a/src/main/ssh/ssh-relay-opencode-runtime-retry.test.ts b/src/main/ssh/ssh-relay-opencode-runtime-retry.test.ts new file mode 100644 index 00000000000..be8fde1fc5e --- /dev/null +++ b/src/main/ssh/ssh-relay-opencode-runtime-retry.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createRemoteOpenCodeRuntimeRetry } from './ssh-relay-opencode-runtime-retry' + +afterEach(() => vi.useRealTimers()) + +describe('scan-triggered SSH OpenCode runtime preparation', () => { + it('rechecks an empty host after the cooldown, coalesces scans, and stops once ready', async () => { + vi.useFakeTimers() + const retry = vi.fn().mockResolvedValue('ready') + const prepare = createRemoteOpenCodeRuntimeRetry( + Promise.resolve('not-needed'), + Promise.resolve(true), + retry + ) + const signal = new AbortController().signal + await prepare(signal) + expect(retry).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(60_000) + await Promise.all([prepare(signal), prepare(signal), prepare(signal)]) + expect(retry).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(60_000) + await prepare(signal) + expect(retry).toHaveBeenCalledOnce() + }) + + it('waits for initial background cleanup before a scan can retry', async () => { + vi.useFakeTimers() + let finish!: () => void + const cleanup = new Promise((resolve) => { + finish = () => resolve(true) + }) + const retry = vi.fn().mockResolvedValue('ready') + const prepare = createRemoteOpenCodeRuntimeRetry(Promise.resolve('failed'), cleanup, retry) + await prepare(new AbortController().signal) + await vi.advanceTimersByTimeAsync(30_000) + const pending = prepare(new AbortController().signal) + await Promise.resolve() + expect(retry).not.toHaveBeenCalled() + finish() + await pending + expect(retry).toHaveBeenCalledOnce() + }) + + it('cancels a queued retry on session teardown without starting a remote command', async () => { + vi.useFakeTimers() + const retry = vi.fn() + const prepare = createRemoteOpenCodeRuntimeRetry( + Promise.resolve('not-needed'), + new Promise(() => {}), + retry + ) + const controller = new AbortController() + await prepare(controller.signal) + await vi.advanceTimersByTimeAsync(60_000) + const pending = prepare(controller.signal) + controller.abort() + await pending + await prepare(controller.signal) + expect(retry).not.toHaveBeenCalled() + }) + + it('never retries an unconfirmed remote teardown', async () => { + vi.useFakeTimers() + const retry = vi.fn() + const prepare = createRemoteOpenCodeRuntimeRetry( + Promise.resolve('teardown-unconfirmed'), + Promise.resolve(true), + retry + ) + await vi.advanceTimersByTimeAsync(3600_000) + await prepare(new AbortController().signal) + expect(retry).not.toHaveBeenCalled() + }) + + it('never retries after deployment cleanup reports an unconfirmed command', async () => { + vi.useFakeTimers() + const retry = vi.fn() + const prepare = createRemoteOpenCodeRuntimeRetry( + Promise.resolve('not-needed'), + Promise.resolve(false), + retry + ) + await prepare(new AbortController().signal) + await vi.advanceTimersByTimeAsync(60_000) + await prepare(new AbortController().signal) + await vi.advanceTimersByTimeAsync(3600_000) + await prepare(new AbortController().signal) + expect(retry).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ssh/ssh-relay-opencode-runtime-retry.ts b/src/main/ssh/ssh-relay-opencode-runtime-retry.ts new file mode 100644 index 00000000000..103da40a6cd --- /dev/null +++ b/src/main/ssh/ssh-relay-opencode-runtime-retry.ts @@ -0,0 +1,42 @@ +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import type { RemoteOpenCodeRuntimeOutcome } from './ssh-relay-opencode-runtime' + +export type RemoteOpenCodeRuntimePreparation = (signal: AbortSignal) => Promise + +export function createRemoteOpenCodeRuntimeRetry( + initialSetup: Promise, + backgroundCleanup: Promise, + retry: (signal: AbortSignal) => Promise +): RemoteOpenCodeRuntimePreparation { + let nextAttempt = Infinity + let pending: Promise | undefined + const remember = (outcome: RemoteOpenCodeRuntimeOutcome): void => { + nextAttempt = + outcome === 'ready' || outcome === 'teardown-unconfirmed' + ? Infinity + : Date.now() + (outcome === 'not-needed' ? 60_000 : 30_000) + } + const initialized = initialSetup.then(remember).catch(() => {}) + return (signal) => { + if (signal.aborted) { + return Promise.resolve() + } + pending ??= (async () => { + await waitForPromiseWithSignal(initialized, signal) + if (Date.now() < nextAttempt) { + return + } + if (!(await waitForPromiseWithSignal(backgroundCleanup, signal))) { + nextAttempt = Infinity + return + } + signal.throwIfAborted() + remember(await retry(signal)) + })() + .catch(() => {}) + .finally(() => { + pending = undefined + }) + return pending + } +} diff --git a/src/main/ssh/ssh-relay-opencode-runtime.test.ts b/src/main/ssh/ssh-relay-opencode-runtime.test.ts new file mode 100644 index 00000000000..ce3da563b97 --- /dev/null +++ b/src/main/ssh/ssh-relay-opencode-runtime.test.ts @@ -0,0 +1,412 @@ +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + exec: vi.fn(), + upload: vi.fn(), + write: vi.fn(), + materialize: vi.fn(), + target: vi.fn(), + warm: false, + checksumError: false, + cleanupError: false, + reservationError: false +})) +vi.mock('./ssh-relay-deploy-helpers', () => ({ + execCommand: mocks.exec, + isUnconfirmedSshCommandTermination: (error: unknown) => + error instanceof Error && + 'sshChannelCloseConfirmed' in error && + error.sshChannelCloseConfirmed === false +})) +vi.mock('./ssh-relay-install-transfers', () => ({ + uploadRelayDirectory: mocks.upload, + writeRelayFile: mocks.write +})) +vi.mock('./orcad-bun-runtime-materializer', () => ({ + materializeCachedOrcadBunRuntime: mocks.materialize +})) +vi.mock('./orcad-deployment-target', () => ({ resolveOrcadDeploymentTarget: mocks.target })) + +import type { SshConnection } from './ssh-connection' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { ORCAD_BUN_RELEASE_ASSETS } from '../../shared/orcad-bun-runtime' +import { ensureRemoteOpenCodeRuntime } from './ssh-relay-opencode-runtime' +import { OPENCODE_RUNTIME_RESULT } from './ssh-relay-opencode-runtime-commands' + +const host = getRemoteHostPlatform('linux-x64') +const remoteHome = '/home/ada' +const relayDir = `${remoteHome}/.orca-remote/relay-build` +const binary = `${remoteHome}/.orca-remote/vault-sqlite/${ORCAD_BUN_RELEASE_ASSETS['linux-x64-glibc'].executableSha256}/bun` +let cacheRoot: string +let runtime: string +const frame = (status: string, executable?: string) => + `${OPENCODE_RUNTIME_RESULT}${JSON.stringify({ status, executable })}\n` +const options = () => ({ nodePath: '/usr/bin/node', relayDir, cacheRoot }) + +function hostCommandResult(command: string): string { + if (command.includes('staging quota is full')) { + if (mocks.reservationError) { + throw new Error('staging quota is full') + } + return `__ORCA_UPLOAD_STAGE_SLOT__${command.match(/\.sftp-namespace-[0-9a-f]{32}/)?.[0]}:slot-0` + } + if (command.includes('SELECT 1 AS ready')) { + return frame('unsupported') + } + if (command.includes('checksum mismatch')) { + if (mocks.checksumError) { + throw new Error('Uploaded SQLite runtime checksum mismatch') + } + return frame('ready', binary) + } + if (command.includes('published')) { + return frame('published') + } + if (command.includes('status:')) { + return mocks.warm ? frame('ready', binary) : frame('missing') + } + if (mocks.cleanupError && command.includes('claim_identity') && !command.includes('old=')) { + throw Object.assign(new Error('Cleanup teardown is unconfirmed'), { + sshChannelCloseConfirmed: false + }) + } + return '' +} + +function connection(system = false): SshConnection { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Setup reads only the mocked transport flag and connection generation; remote I/O is mocked. + return { + usesSystemSshTransport: () => system, + getConnectGeneration: () => 1 + } as unknown as SshConnection +} + +beforeEach(async () => { + vi.resetAllMocks() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + cacheRoot = await mkdtemp(join(tmpdir(), 'orca-vault-runtime-')) + runtime = join(cacheRoot, 'repair-1-orcad-bun') + await writeFile(runtime, 'verified runtime') + mocks.materialize.mockResolvedValue(runtime) + mocks.target.mockResolvedValue('linux-x64-glibc') + mocks.warm = false + mocks.checksumError = false + mocks.cleanupError = false + mocks.reservationError = false + mocks.exec.mockImplementation(async (_conn, command: string) => hostCommandResult(command)) +}) + +afterEach(async () => { + vi.useRealTimers() + vi.restoreAllMocks() + await rm(cacheRoot, { recursive: true, force: true }) +}) + +describe('SSH OpenCode runtime setup', () => { + it('publishes a capable existing Node without materializing or uploading Bun', async () => { + mocks.exec.mockResolvedValueOnce(frame('ready', '/opt/node 24/bin/node')) + expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe( + 'ready' + ) + expect(mocks.target).not.toHaveBeenCalled() + expect(mocks.materialize).not.toHaveBeenCalled() + expect(mocks.upload).not.toHaveBeenCalled() + expect(JSON.parse(mocks.write.mock.calls[0][3])).toEqual({ + protocol: 1, + executable: '/opt/node 24/bin/node' + }) + }) + + it('uses the verified materializer cache after Node 18 fails the actual read probe', async () => { + mocks.upload.mockImplementation(async (_conn, localDir: string) => { + expect(await readdir(localDir)).toEqual(['bun']) + expect(await readFile(join(localDir, 'bun'), 'utf8')).toBe('verified runtime') + }) + expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe( + 'ready' + ) + expect(mocks.target).toHaveBeenCalledWith( + expect.objectContaining({ host, signal: expect.any(AbortSignal) }) + ) + expect(mocks.materialize).toHaveBeenCalledWith('linux-x64-glibc', cacheRoot, { + signal: expect.any(AbortSignal) + }) + expect(await readdir(cacheRoot)).toEqual(['repair-1-orcad-bun']) + expect(JSON.parse(mocks.write.mock.calls[0][3])).toEqual({ protocol: 1, executable: binary }) + }) + + it('reuses a remotely verified binary without downloading it again', async () => { + mocks.warm = true + expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe( + 'ready' + ) + expect(mocks.materialize).not.toHaveBeenCalled() + expect(mocks.upload).not.toHaveBeenCalled() + }) + + it('uses one staging namespace for binary uploads and atomic reference writes', async () => { + await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options()) + const uploadOptions = mocks.upload.mock.calls[0][4] + const writeOptions = mocks.write.mock.calls[0][4] + expect(uploadOptions.sftpNamespace.homeRelativeNamespaceRoot).toMatch( + /^\.orca-remote\/\.upload-stages\/slot-0$/ + ) + expect(uploadOptions.sftpNamespace.shellProbePath).toBe( + writeOptions.sftpNamespace.shellProbePath + ) + expect(writeOptions.sftpNamespace.homeRelativePath).toBe( + `${uploadOptions.sftpNamespace.homeRelativePath}/opencode-sqlite-runtime.json` + ) + }) + + it('skips namespace probing on system SSH', async () => { + await ensureRemoteOpenCodeRuntime(connection(true), host, remoteHome, options()) + expect(mocks.upload.mock.calls[0][4].sftpNamespace).toBeUndefined() + expect(mocks.write.mock.calls[0][4].sftpNamespace).toBeUndefined() + }) + + it('coalesces repeated setup for one execution connection and directory', async () => { + const conn = connection() + const result = await Promise.all([ + ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options()), + ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options()) + ]) + expect(result).toEqual(['ready', 'ready']) + expect(mocks.upload).toHaveBeenCalledOnce() + }) + + it('coalesces the bounded target cache fill across hosts', async () => { + let finish!: (path: string) => void + mocks.materialize.mockReturnValue( + new Promise((resolve) => { + finish = resolve + }) + ) + const first = ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options()) + const second = ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options()) + await vi.waitFor(() => expect(mocks.materialize).toHaveBeenCalledOnce()) + finish(runtime) + expect(await Promise.all([first, second])).toEqual(['ready', 'ready']) + expect(mocks.upload).toHaveBeenCalledTimes(2) + }) + + it('never publishes a reference after a failed remote checksum', async () => { + mocks.checksumError = true + expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe( + 'failed' + ) + expect(mocks.write).not.toHaveBeenCalled() + }) + + it('aborts an upload without publishing or running further host commands', async () => { + const controller = new AbortController() + mocks.upload.mockImplementation(async (_conn, _local, _remote, _host, transfer) => { + controller.abort() + transfer.signal.throwIfAborted() + }) + expect( + await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, { + ...options(), + signal: controller.signal + }) + ).toBe('teardown-unconfirmed') + expect(mocks.write).not.toHaveBeenCalled() + expect(mocks.exec).toHaveBeenCalledTimes(4) + }) + + it('bounds even an unresponsive setup operation at 180 seconds', async () => { + vi.useFakeTimers() + mocks.exec.mockReturnValue(new Promise(() => {})) + const conn = connection() + const result = ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options()) + await vi.advanceTimersByTimeAsync(180_000) + expect(await result).toBe('teardown-unconfirmed') + expect(mocks.exec.mock.calls[0][2].signal.aborted).toBe(true) + expect(mocks.materialize).not.toHaveBeenCalled() + expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe( + 'teardown-unconfirmed' + ) + expect(mocks.exec).toHaveBeenCalledOnce() + }) + + it('admits setup on a new connection generation after an unconfirmed teardown', async () => { + const conn = connection() + const generation = vi.spyOn(conn, 'getConnectGeneration') + mocks.exec.mockRejectedValueOnce( + Object.assign(new Error('Channel teardown is unconfirmed'), { + sshChannelCloseConfirmed: false + }) + ) + expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe( + 'teardown-unconfirmed' + ) + expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe( + 'teardown-unconfirmed' + ) + expect(mocks.exec).toHaveBeenCalledOnce() + generation.mockReturnValue(2) + mocks.exec.mockResolvedValueOnce(frame('ready', '/usr/bin/node')) + expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe('ready') + expect(mocks.write).toHaveBeenCalledOnce() + }) + + it.each(['ready', 'not-needed'] as const)( + 'refuses a late %s result from a superseded setup', + async (status) => { + const conn = connection() + const generation = vi.spyOn(conn, 'getConnectGeneration') + mocks.exec.mockImplementationOnce(async () => { + generation.mockReturnValue(2) + return frame(status, '/usr/bin/node') + }) + expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe('failed') + expect(mocks.exec).toHaveBeenCalledOnce() + expect(mocks.write).not.toHaveBeenCalled() + } + ) + + it.each(['publication', 'cleanup'] as const)( + 'refuses completed setup when its generation changes during %s', + async (stage) => { + const conn = connection() + const generation = vi.spyOn(conn, 'getConnectGeneration') + const started = Promise.withResolvers() + const finish = Promise.withResolvers() + mocks.exec.mockImplementation(async (_conn, command: string) => { + if (command.includes('SELECT 1 AS ready')) { + return frame('ready', '/usr/bin/node') + } + const selected = + stage === 'publication' + ? command.includes('published') + : command.includes('claim_identity') && !command.includes('old=') + if (selected) { + started.resolve() + return finish.promise + } + return hostCommandResult(command) + }) + const pending = ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options()) + await started.promise + generation.mockReturnValue(2) + finish.resolve(stage === 'publication' ? frame('published') : '') + + expect(await pending).toBe('failed') + expect(mocks.exec).toHaveBeenCalledTimes(stage === 'publication' ? 4 : 5) + expect(mocks.write).toHaveBeenCalledOnce() + } + ) + + it('keeps a newer setup registered when the superseded setup finishes late', async () => { + const conn = connection() + const generation = vi.spyOn(conn, 'getConnectGeneration') + const oldProbe = Promise.withResolvers() + const currentProbe = Promise.withResolvers() + mocks.exec.mockReturnValueOnce(oldProbe.promise).mockReturnValueOnce(currentProbe.promise) + const oldSetup = ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options()) + generation.mockReturnValue(2) + const currentSetup = ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options()) + oldProbe.resolve(frame('not-needed')) + expect(await oldSetup).toBe('failed') + + const joined = ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options()) + expect(mocks.exec).toHaveBeenCalledTimes(2) + currentProbe.resolve(frame('not-needed')) + expect(await Promise.all([currentSetup, joined])).toEqual(['not-needed', 'not-needed']) + }) + + it.each(['deadline', 'caller'] as const)( + 'allows retry after local download cancellation by %s without reserving a stage', + async (cause) => { + const controller = new AbortController() + let finish!: (path: string) => void + mocks.materialize.mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve + }) + ) + if (cause === 'deadline') { + vi.useFakeTimers() + } + const conn = connection() + const pending = ensureRemoteOpenCodeRuntime(conn, host, remoteHome, { + ...options(), + signal: controller.signal + }) + await vi.waitFor(() => expect(mocks.materialize).toHaveBeenCalledOnce()) + expect(mocks.exec).toHaveBeenCalledTimes(2) + if (cause === 'deadline') { + await vi.advanceTimersByTimeAsync(180_000) + } else { + controller.abort() + } + expect(await pending).toBe('failed') + finish(runtime) + vi.useRealTimers() + await new Promise((resolve) => setImmediate(resolve)) + expect(mocks.exec).toHaveBeenCalledTimes(2) + expect(mocks.upload).not.toHaveBeenCalled() + expect(mocks.write).not.toHaveBeenCalled() + expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe('ready') + expect(mocks.upload).toHaveBeenCalledOnce() + } + ) + + it('retains the upload stage when a failed transfer may still be running', async () => { + mocks.upload.mockRejectedValue( + Object.assign(new Error('Upload teardown is unconfirmed'), { + sshChannelCloseConfirmed: false + }) + ) + const conn = connection() + expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe( + 'teardown-unconfirmed' + ) + expect(mocks.exec).toHaveBeenCalledTimes(4) + expect(mocks.write).not.toHaveBeenCalled() + expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe( + 'teardown-unconfirmed' + ) + expect(mocks.exec).toHaveBeenCalledTimes(4) + }) + + it('reports an unconfirmed stage cleanup to the deployment command queue', async () => { + mocks.exec.mockResolvedValueOnce(frame('ready', '/usr/bin/node')) + mocks.cleanupError = true + expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe( + 'teardown-unconfirmed' + ) + expect(mocks.exec).toHaveBeenCalledTimes(5) + }) + + it('skips installation without data and retries when a database appears on the same connection', async () => { + const conn = connection() + mocks.exec.mockResolvedValueOnce(frame('not-needed')) + expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe('not-needed') + expect(mocks.exec).toHaveBeenCalledOnce() + expect(mocks.materialize).not.toHaveBeenCalled() + expect(mocks.upload).not.toHaveBeenCalled() + expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe('ready') + expect(mocks.upload).toHaveBeenCalledOnce() + }) + + it('fails optionally without uploading when all bounded stages are occupied', async () => { + mocks.reservationError = true + expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe( + 'failed' + ) + expect(mocks.exec).toHaveBeenCalledTimes(4) + expect(mocks.upload).not.toHaveBeenCalled() + }) + + it('does not mistake an unanswered Node probe for an old runtime', async () => { + mocks.exec.mockResolvedValue('login banner only') + expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe( + 'failed' + ) + expect(mocks.materialize).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ssh/ssh-relay-opencode-runtime.ts b/src/main/ssh/ssh-relay-opencode-runtime.ts new file mode 100644 index 00000000000..83657e8b7f2 --- /dev/null +++ b/src/main/ssh/ssh-relay-opencode-runtime.ts @@ -0,0 +1,298 @@ +import { randomBytes } from 'node:crypto' +import { copyFile, link, mkdtemp, rm } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { getAppEnvironment } from '../../shared/app-environment' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { ORCAD_BUN_RELEASE_ASSETS, type OrcadBunTarget } from '../../shared/orcad-bun-runtime' +import type { SshConnection } from './ssh-connection' +import { resolveOrcadDeploymentTarget } from './orcad-deployment-target' +import { materializeCachedOrcadBunRuntime } from './orcad-bun-runtime-materializer' +import { execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' +import { uploadRelayDirectory, writeRelayFile } from './ssh-relay-install-transfers' +import { + createRelayUploadStageNamespace, + relayUploadStageSftpNamespaceMapping +} from './ssh-relay-install-namespace' +import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { RELAY_REMOTE_DIR } from './relay-protocol' +import { createRelayInstallMarkerFileName } from './ssh-relay-install-marker' +import { + cleanupOwnedRelayUploadStageCommand, + parseReservedRelayUploadStage, + recoverOneStaleRelayUploadStageCommand, + reserveRelayUploadStageCommand, + RELAY_UPLOAD_STAGE_POOL_NAME +} from './ssh-relay-upload-stage-commands' +import { + parseOpenCodeRuntimeResult, + probeOpenCodeRuntimeCacheCommand, + probeOpenCodeNodeSqliteCommand, + promoteOpenCodeRuntimeCommand, + publishOpenCodeRuntimeReferenceCommand +} from './ssh-relay-opencode-runtime-commands' + +const SETUP_TIMEOUT_MS = 180_000 +export type RemoteOpenCodeRuntimeOutcome = + | 'ready' + | 'not-needed' + | 'failed' + | 'teardown-unconfirmed' +const installations = new WeakMap< + SshConnection, + { generation: number; byDirectory: Map> } +>() +const downloads = new Map>() + +type SetupOptions = { + nodePath: string + relayDir: string + signal?: AbortSignal + cacheRoot?: string +} +type RemoteOperation = (operation: () => Promise) => Promise + +/** Optional companion setup; the host's relay and terminals never depend on it. */ +export function ensureRemoteOpenCodeRuntime( + conn: SshConnection, + host: RemoteHostPlatform, + remoteHome: string, + options: SetupOptions +): Promise { + const generation = conn.getConnectGeneration() + let current = installations.get(conn) + if (current?.generation !== generation) { + current = { generation, byDirectory: new Map() } + installations.set(conn, current) + } + const { byDirectory } = current + const active = byDirectory.get(options.relayDir) + if (active) { + return waitForPromiseWithSignal(active, options.signal).catch(() => 'teardown-unconfirmed') + } + const timeout = new AbortController() + const timer = setTimeout( + () => timeout.abort(new Error('SSH SQLite runtime setup timed out.')), + SETUP_TIMEOUT_MS + ) + timer.unref() + const signal = options.signal ? AbortSignal.any([options.signal, timeout.signal]) : timeout.signal + let remotePending = false + let remoteUnconfirmed = false + const assertCurrentGeneration = (): void => { + if (conn.getConnectGeneration() !== generation) { + throw new Error('SSH connection changed during SQLite runtime setup.') + } + } + const remote: RemoteOperation = async (operation) => { + signal.throwIfAborted() + assertCurrentGeneration() + remotePending = true + try { + const result = await operation() + assertCurrentGeneration() + return result + } catch (error) { + remoteUnconfirmed ||= signal.aborted || isUnconfirmedSshCommandTermination(error) + throw error + } finally { + remotePending = false + } + } + const pending = waitForPromiseWithSignal( + install(conn, host, remoteHome, options, signal, remote), + signal + ) + .then((outcome) => { + assertCurrentGeneration() + return outcome + }) + .catch((error: unknown) => { + console.warn( + '[ssh-relay] OpenCode history runtime setup did not finish:', + error instanceof Error ? error.message : String(error) + ) + return remotePending || remoteUnconfirmed || isUnconfirmedSshCommandTermination(error) + ? ('teardown-unconfirmed' as const) + : ('failed' as const) + }) + .then((outcome) => { + clearTimeout(timer) + // An unresolved channel must not admit another installer on this connection. + if (outcome !== 'teardown-unconfirmed') { + byDirectory.delete(options.relayDir) + } + return outcome + }) + byDirectory.set(options.relayDir, pending) + return pending +} + +async function install( + conn: SshConnection, + host: RemoteHostPlatform, + remoteHome: string, + options: SetupOptions, + signal: AbortSignal, + remote: RemoteOperation +): Promise { + const exec = async (command: string): Promise => { + signal.throwIfAborted() + const output = await remote(() => + execCommand(conn, command, { + signal, + wrapCommand: !isWindowsRemoteHost(host) + }) + ) + signal.throwIfAborted() + return output + } + const node = parseOpenCodeRuntimeResult( + await exec(probeOpenCodeNodeSqliteCommand(host, options.nodePath, remoteHome)) + ) + if (node.status === 'not-needed') { + return 'not-needed' + } + if (node.status !== 'ready' && node.status !== 'unsupported') { + throw new Error('The host did not complete its SQLite read probe.') + } + let executable = node.executable + let target: OrcadBunTarget | undefined + let localRuntime: string | undefined + if (node.status === 'unsupported') { + target = await resolveOrcadDeploymentTarget({ conn, host, signal, exec }) + const expectedHash = ORCAD_BUN_RELEASE_ASSETS[target].executableSha256 + executable = joinRemotePath( + host, + remoteHome, + RELAY_REMOTE_DIR, + 'vault-sqlite', + expectedHash, + isWindowsRemoteHost(host) ? 'bun.exe' : 'bun' + ) + const cached = parseOpenCodeRuntimeResult( + await exec( + probeOpenCodeRuntimeCacheCommand({ + host, + nodePath: options.nodePath, + executable, + expectedHash, + reference: joinRemotePath(host, options.relayDir, 'opencode-sqlite-runtime.json') + }) + ) + ) + if (cached.status === 'ready' && cached.executable) { + executable = cached.executable + } else if (cached.status === 'missing') { + const cacheRoot = + options.cacheRoot ?? join(getAppEnvironment().getPath('userData'), 'orcad-artifacts') + localRuntime = await cachedRuntime(target, cacheRoot, signal) + signal.throwIfAborted() + } else { + throw new Error('The host did not confirm its SQLite runtime cache.') + } + } + if (!executable) { + throw new Error('The host did not identify its SQLite executable.') + } + const token = randomBytes(12).toString('hex') + const relativePool = `${RELAY_REMOTE_DIR}/${RELAY_UPLOAD_STAGE_POOL_NAME}` + const poolDir = joinRemotePath(host, remoteHome, relativePool) + const owner = createRelayInstallMarkerFileName() + await exec(recoverOneStaleRelayUploadStageCommand(host, poolDir)) + const stage = parseReservedRelayUploadStage( + host, + poolDir, + owner, + await exec(reserveRelayUploadStageCommand(host, poolDir, owner)) + ) + const stageDir = stage.slotDir + const namespace = createRelayUploadStageNamespace(`${relativePool}/${stage.slotName}`, owner) + const mapping = (file?: string) => + !isWindowsRemoteHost(host) && conn.usesSystemSshTransport?.() !== true + ? relayUploadStageSftpNamespaceMapping(namespace, host, stageDir, file) + : undefined + let cleanupAllowed = true + try { + if (target && localRuntime) { + const localStage = await mkdtemp(join(dirname(localRuntime), '.vault-upload-')) + try { + const binaryName = isWindowsRemoteHost(host) ? 'bun.exe' : 'bun' + const localBinary = join(localStage, binaryName) + await link(localRuntime, localBinary).catch(() => copyFile(localRuntime, localBinary)) + signal.throwIfAborted() + await remote(() => + uploadRelayDirectory(conn, localStage, joinRemotePath(host, stageDir, 'payload'), host, { + signal, + sftpNamespace: mapping() + }) + ) + const promoted = parseOpenCodeRuntimeResult( + await exec( + promoteOpenCodeRuntimeCommand({ + host, + nodePath: options.nodePath, + stagedBinary: joinRemotePath(host, stageDir, 'payload', binaryName), + executable, + expectedHash: ORCAD_BUN_RELEASE_ASSETS[target].executableSha256, + repairToken: token + }) + ) + ) + if (promoted.status !== 'ready' || !promoted.executable) { + throw new Error('The host did not verify the uploaded SQLite runtime.') + } + executable = promoted.executable + } finally { + await rm(localStage, { recursive: true, force: true }).catch(() => {}) + } + } + const referenceName = 'opencode-sqlite-runtime.json' + const stagedReference = joinRemotePath(host, stageDir, 'payload', referenceName) + signal.throwIfAborted() + await remote(() => + writeRelayFile(conn, host, stagedReference, JSON.stringify({ protocol: 1, executable }), { + signal, + sftpNamespace: mapping(referenceName) + }) + ) + const published = parseOpenCodeRuntimeResult( + await exec( + publishOpenCodeRuntimeReferenceCommand({ + host, + nodePath: options.nodePath, + stagedReference, + reference: joinRemotePath(host, options.relayDir, referenceName), + token + }) + ) + ) + return published.status === 'published' ? 'ready' : 'failed' + } catch (error) { + cleanupAllowed = !isUnconfirmedSshCommandTermination(error) + throw error + } finally { + if (cleanupAllowed && !signal.aborted) { + await exec(cleanupOwnedRelayUploadStageCommand(host, stage, owner)).catch((error) => { + if (isUnconfirmedSshCommandTermination(error)) { + throw error + } + }) + } + } +} + +function cachedRuntime( + target: OrcadBunTarget, + cacheRoot: string, + signal: AbortSignal +): Promise { + const key = `${cacheRoot}\0${target}` + let pending = downloads.get(key) + if (!pending) { + pending = materializeCachedOrcadBunRuntime(target, cacheRoot, { + signal: AbortSignal.timeout(SETUP_TIMEOUT_MS) + }).finally(() => downloads.delete(key)) + downloads.set(key, pending) + } + return waitForPromiseWithSignal(pending, signal) +} diff --git a/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts b/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts index 5b2a42d87c7..f7fd4d65794 100644 --- a/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts +++ b/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' +vi.mock('./ssh-relay-opencode-runtime', () => ({ + ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') +})) vi.mock('./ssh-relay-ripgrep-install', () => ({ remoteRipgrepLayout: vi.fn().mockReturnValue(null), recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false), diff --git a/src/main/ssh/ssh-relay-repair-lock-termination.test.ts b/src/main/ssh/ssh-relay-repair-lock-termination.test.ts new file mode 100644 index 00000000000..db73b92e239 --- /dev/null +++ b/src/main/ssh/ssh-relay-repair-lock-termination.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn() })) + +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mocked execCommand never reads the connection. +const conn = {} as SshConnection +const mockExec = vi.mocked(execCommand) +const freshLockReplies = ['OPEN', 'LOCKED', '0'] +const contentionReplies = ['OPEN', '', 'BUSY', 'BUSY'] + +const failureStages = [ + { stage: 'initial claim probe', replies: [], recovery: [], result: 'error' }, + { stage: 'parent creation', replies: ['OPEN'], recovery: freshLockReplies, result: 'busy' }, + { stage: 'lock creation', replies: ['OPEN', ''], recovery: freshLockReplies, result: 'busy' }, + { + stage: 'stale takeover', + replies: ['OPEN', '', 'BUSY'], + recovery: freshLockReplies, + result: 'busy' + }, + { + stage: 'claim probe after creation', + replies: ['OPEN', '', 'OK'], + recovery: [''], + result: 'error' + }, + { + stage: 'release after creation', + replies: ['OPEN', '', 'OK', 'LOCKED'], + recovery: [], + result: 'gc' + }, + { + stage: 'claim probe after takeover', + replies: ['OPEN', '', 'BUSY', 'OK'], + recovery: [''], + result: 'error' + }, + { + stage: 'release after takeover', + replies: ['OPEN', '', 'BUSY', 'OK', 'LOCKED'], + recovery: [], + result: 'gc' + }, + { stage: 'contention claim probe', replies: contentionReplies, recovery: [], result: 'error' }, + { + stage: 'contention lock probe', + replies: [...contentionReplies, 'OPEN'], + recovery: [], + result: 'error' + }, + { + stage: 'contention lock age probe', + replies: [...contentionReplies, 'OPEN', 'LOCKED'], + recovery: [], + result: 'error' + } +] + +beforeEach(() => { + mockExec.mockReset() + mockExec.mockResolvedValue('OPEN') + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe.each([ + { platform: 'linux-x64' as const, remoteDir: '/relay/version' }, + { platform: 'win32-x64' as const, remoteDir: 'C:/relay/version' } +])('repair lock termination on $platform', ({ platform, remoteDir }) => { + const host = getRemoteHostPlatform(platform) + + describe.each([false, true])('with caller aborted = %s', (aborted) => { + it.each(failureStages)('stops after unconfirmed $stage', async ({ replies }) => { + const controller = new AbortController() + const error = Object.assign(new Error('SSH teardown not confirmed'), { + sshChannelCloseConfirmed: false + }) + for (const reply of replies) { + mockExec.mockResolvedValueOnce(reply) + } + mockExec.mockImplementationOnce(async () => { + if (aborted) { + controller.abort(new Error('deploy aborted')) + } + throw error + }) + + await expect( + tryAcquireRelayRepairLock(conn, remoteDir, host, { signal: controller.signal }) + ).rejects.toBe(error) + expect(mockExec).toHaveBeenCalledTimes(replies.length + 1) + }) + }) + + it.each(failureStages)( + 'keeps recovery after confirmed $stage failure', + async ({ replies, recovery, result }) => { + for (const reply of replies) { + mockExec.mockResolvedValueOnce(reply) + } + mockExec.mockRejectedValueOnce( + Object.assign(new Error('SSH command failed after closing'), { + sshChannelCloseConfirmed: true + }) + ) + for (const reply of recovery) { + mockExec.mockResolvedValueOnce(reply) + } + + await expect(tryAcquireRelayRepairLock(conn, remoteDir, host)).resolves.toBe(result) + expect(mockExec).toHaveBeenCalledTimes(replies.length + recovery.length + 1) + } + ) + + it('stops when a contention probe after a confirmed acquisition failure is unconfirmed', async () => { + const error = Object.assign(new Error('SSH teardown not confirmed'), { + sshChannelCloseConfirmed: false + }) + mockExec + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('') + .mockRejectedValueOnce(new Error('lock creation failed')) + .mockRejectedValueOnce(error) + + await expect(tryAcquireRelayRepairLock(conn, remoteDir, host)).rejects.toBe(error) + expect(mockExec).toHaveBeenCalledTimes(4) + }) +}) diff --git a/src/main/ssh/ssh-relay-repair-lock.ts b/src/main/ssh/ssh-relay-repair-lock.ts index 01d58e390e1..1e2b8b7a9d5 100644 --- a/src/main/ssh/ssh-relay-repair-lock.ts +++ b/src/main/ssh/ssh-relay-repair-lock.ts @@ -1,5 +1,6 @@ import type { SshConnection } from './ssh-connection' import { execCommand } from './ssh-relay-deploy-helpers' +import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' import { acquireInstallLockParentCommand, lockAgeSecondsCommand, @@ -49,7 +50,12 @@ export async function tryAcquireRelayRepairLock( remoteRelayDir, host, options?.signal - ).catch(() => undefined) + ).catch((error) => { + if (isUnconfirmedSshCommandTermination(error)) { + throw error + } + return undefined + }) options?.signal?.throwIfAborted() if (gcClaimedBeforeAcquire === true) { return 'gc' @@ -83,7 +89,10 @@ export async function tryAcquireRelayRepairLock( return finishRepairLockAcquire(conn, remoteRelayDir, lockDir, host, options?.signal) } return classifyRepairLockContention(conn, remoteRelayDir, lockDir, host, options?.signal) - } catch { + } catch (error) { + if (isUnconfirmedSshCommandTermination(error)) { + throw error + } options?.signal?.throwIfAborted() return classifyRepairLockContention(conn, remoteRelayDir, lockDir, host, options?.signal) } @@ -96,9 +105,12 @@ async function classifyRepairLockContention( host: RemoteHostPlatform, signal?: AbortSignal ): Promise { - const gcClaimed = await isRelayGcClaimed(conn, remoteRelayDir, host, signal).catch( - () => undefined - ) + const gcClaimed = await isRelayGcClaimed(conn, remoteRelayDir, host, signal).catch((error) => { + if (isUnconfirmedSshCommandTermination(error)) { + throw error + } + return undefined + }) signal?.throwIfAborted() if (gcClaimed === true) { return 'gc' @@ -112,7 +124,12 @@ async function classifyRepairLockContention( host, probeInstallLockExistsCommand(host, lockDir), signal - ).catch(() => '') + ).catch((error) => { + if (isUnconfirmedSshCommandTermination(error)) { + throw error + } + return '' + }) signal?.throwIfAborted() if (lockProbe.trim() !== 'LOCKED') { return 'error' @@ -122,7 +139,12 @@ async function classifyRepairLockContention( host, lockAgeSecondsCommand(host, lockDir), signal - ).catch(() => '') + ).catch((error) => { + if (isUnconfirmedSshCommandTermination(error)) { + throw error + } + return '' + }) signal?.throwIfAborted() const ageSeconds = Number.parseInt(ageOutput.trim(), 10) // Why: GC may remove stale locks, so only a positively observed fresh lock @@ -139,15 +161,22 @@ async function finishRepairLockAcquire( host: RemoteHostPlatform, signal?: AbortSignal ): Promise { - const gcClaimed = await isRelayGcClaimed(conn, remoteRelayDir, host, signal).catch( - () => undefined - ) + const gcClaimed = await isRelayGcClaimed(conn, remoteRelayDir, host, signal).catch((error) => { + if (isUnconfirmedSshCommandTermination(error)) { + throw error + } + return undefined + }) if (gcClaimed === false && !signal?.aborted) { return 'acquired' } // Why: GC may win its stable sibling claim while this command creates the // in-tree lock. Back out before npm can mutate a directory being renamed. - await execHostCommand(conn, host, removeRemoteTreeCommand(host, lockDir)).catch(() => {}) + await execHostCommand(conn, host, removeRemoteTreeCommand(host, lockDir)).catch((error) => { + if (isUnconfirmedSshCommandTermination(error)) { + throw error + } + }) signal?.throwIfAborted() return gcClaimed ? 'gc' : 'error' } diff --git a/src/main/ssh/ssh-relay-ripgrep-cache-gc-commands.ts b/src/main/ssh/ssh-relay-ripgrep-cache-gc-commands.ts index 509ba9a0ebd..9e0358fa4ca 100644 --- a/src/main/ssh/ssh-relay-ripgrep-cache-gc-commands.ts +++ b/src/main/ssh/ssh-relay-ripgrep-cache-gc-commands.ts @@ -97,17 +97,3 @@ export function listReferencesCommand(host: RemoteHostPlatform, remoteHome: stri `printf '%s\\n' ${REFS_OK}` ].join('\n') } - -// A concurrent installer may already have recreated the original directory. -export function restoreEntryCommand( - host: RemoteHostPlatform, - source: string, - destination: string -): string { - if (isWindowsRemoteHost(host)) { - return powerShellCommand( - `if (-not (Test-Path -LiteralPath ${powerShellLiteral(destination)})) { Move-Item -LiteralPath ${powerShellLiteral(source)} -Destination ${powerShellLiteral(destination)} -ErrorAction Stop; 'MOVED' } else { 'BUSY' }` - ) - } - return `if [ ! -e ${shellEscape(destination)} ]; then mv ${shellEscape(source)} ${shellEscape(destination)} && echo MOVED; else echo BUSY; fi` -} diff --git a/src/main/ssh/ssh-relay-ripgrep-cache-gc-real.test.ts b/src/main/ssh/ssh-relay-ripgrep-cache-gc-real.test.ts index 8b3e657e523..f29a9c52638 100644 --- a/src/main/ssh/ssh-relay-ripgrep-cache-gc-real.test.ts +++ b/src/main/ssh/ssh-relay-ripgrep-cache-gc-real.test.ts @@ -5,6 +5,7 @@ import { mkdirSync, mkdtempSync, readdirSync, + renameSync, rmSync, writeFileSync } from 'node:fs' @@ -112,6 +113,75 @@ describe('ripgrep cache shell transactions', () => { expect(readdirSync(cache)).toEqual([FIRST]) }) + it('restores an abandoned tombstone that a relay still references', async () => { + await recordRemoteRipgrepReference(conn, host, relay, FIRST) + await recordRemoteRipgrepReference(conn, host, relay, SECOND) + const tombstone = `.rg-gc-${SECOND}.123.${Date.now() - 60 * 60_000}` + renameSync(join(cache, SECOND), join(cache, tombstone)) + + await gcRemoteRipgrepCache(conn, host, home) + + expect(readdirSync(cache).sort()).toEqual([FIRST, SECOND]) + expect(existsSync(join(cache, SECOND, 'rg'))).toBe(true) + }) + + it('preserves a referenced tombstone when the clock moves backward during the reference scan', async () => { + await recordRemoteRipgrepReference(conn, host, relay, FIRST) + await recordRemoteRipgrepReference(conn, host, relay, SECOND) + const now = Date.now() + const tombstone = `.rg-gc-${SECOND}.123.${now - 30 * 60_000 - 1}` + renameSync(join(cache, SECOND), join(cache, tombstone)) + const clock = vi.spyOn(Date, 'now').mockReturnValue(now) + let calls = 0 + execMock.mockImplementation(async (_conn: unknown, command: string) => { + if (++calls === 2) { + clock.mockReturnValue(now - 60_000) + } + return runShell(command) + }) + try { + await gcRemoteRipgrepCache(conn, host, home) + expect(readdirSync(cache).sort()).toEqual([FIRST, SECOND]) + expect(existsSync(join(cache, SECOND, 'rg'))).toBe(true) + } finally { + clock.mockRestore() + } + }) + + it('lets exactly one of two passes claim the same abandoned tombstone', async () => { + await recordRemoteRipgrepReference(conn, host, relay, FIRST) + const tombstone = `.rg-gc-${SECOND}.123.${Date.now() - 60 * 60_000}` + renameSync(join(cache, SECOND), join(cache, tombstone)) + let releaseClaim!: () => void + const claimHeld = new Promise((resolve) => { + releaseClaim = resolve + }) + let heldPassReachedClaim!: () => void + const reachedClaim = new Promise((resolve) => { + heldPassReachedClaim = resolve + }) + let held = false + execMock.mockImplementation(async (_conn: unknown, command: string) => { + const script = process.platform === 'win32' ? decodeRemotePowerShellScript(command) : command + if (!held && script.includes(tombstone)) { + held = true + heldPassReachedClaim() + await claimHeld + } + return runShell(command) + }) + + // The held pass sees SECOND as unreferenced; the other pins it and restores it. + const heldPass = gcRemoteRipgrepCache(conn, host, home) + await reachedClaim + await gcRemoteRipgrepCache(conn, host, home, { pinnedEntry: SECOND }) + releaseClaim() + await heldPass + + expect(readdirSync(cache).sort()).toEqual([FIRST, SECOND]) + expect(existsSync(join(cache, SECOND, 'rg'))).toBe(true) + }) + it('does not nest a tombstone inside a directory recreated by a concurrent installer', async () => { await recordRemoteRipgrepReference(conn, host, relay, FIRST) let raced = false diff --git a/src/main/ssh/ssh-relay-ripgrep-cache-gc.ts b/src/main/ssh/ssh-relay-ripgrep-cache-gc.ts index 18cca2c4f5f..0f479548499 100644 --- a/src/main/ssh/ssh-relay-ripgrep-cache-gc.ts +++ b/src/main/ssh/ssh-relay-ripgrep-cache-gc.ts @@ -6,15 +6,20 @@ import { MAX_LISTING_ENTRIES, cacheDir, listEntriesCommand, - listReferencesCommand, - restoreEntryCommand + listReferencesCommand } from './ssh-relay-ripgrep-cache-gc-commands' // Relay installation references protect binaries until version GC removes their owners. // Unknown references block deletion; tombstones are rechecked before removal. +import { randomInt } from 'node:crypto' import type { SshConnection } from './ssh-connection' import { execCommand } from './ssh-relay-deploy-helpers' +import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' import { BUNDLED_RIPGREP_PLATFORMS } from '../../shared/bundled-ripgrep' -import { moveRemoteTreeCommand, removeRemoteTreeCommand } from './ssh-remote-commands' +import { + moveRemoteTreeCommand, + removeRemoteTreeCommand, + restoreRemoteTreeCommand +} from './ssh-remote-commands' import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' function entryNamePattern(): RegExp { @@ -32,24 +37,33 @@ function staleTombstoneEntry(name: string): string | null { return null } const match = /^(.*)\.(\d+)\.(\d+)$/.exec(name.slice(TOMBSTONE_PREFIX.length)) - if (!match || !ENTRY_NAME.test(match[1]) || Date.now() - Number(match[3]) < 30 * 60_000) { + if ( + !match || + !ENTRY_NAME.test(match[1]) || + !Number.isSafeInteger(Number(match[3])) || + Date.now() - Number(match[3]) < 30 * 60_000 + ) { return null } return match[1] } function exec(conn: SshConnection, host: RemoteHostPlatform, command: string): Promise { - return execCommand(conn, command, { wrapCommand: !isWindowsRemoteHost(host) }) + return execCommand(conn, command, { + wrapCommand: !isWindowsRemoteHost(host) + }) } type ReferenceScan = { readable: true; referenced: Set } | { readable: false } -function parseEntries(output: string): string[] { +type CacheEntry = { name: string; entry: string } + +function parseEntries(output: string): CacheEntry[] { const lines = output.split(/\r?\n/).map((line) => line.trim()) if (!lines.includes(LIST_OK)) { return [] } - const entries: string[] = [] + const entries: CacheEntry[] = [] for (const line of lines) { if (!line.startsWith('ENTRY ')) { continue @@ -57,11 +71,9 @@ function parseEntries(output: string): string[] { const name = line.slice('ENTRY '.length) // Why re-validate a name the host produced: it is about to be interpolated into `mv` and // `rm -rf`. Only names this client could itself have minted are eligible. - if ( - (ENTRY_NAME.test(name) || staleTombstoneEntry(name)) && - entries.length < MAX_LISTING_ENTRIES - ) { - entries.push(name) + const entry = ENTRY_NAME.test(name) ? name : staleTombstoneEntry(name) + if (entry && entries.length < MAX_LISTING_ENTRIES) { + entries.push({ name, entry }) } } return entries @@ -75,7 +87,10 @@ async function scanReferences( let output: string try { output = await exec(conn, host, listReferencesCommand(host, remoteHome)) - } catch { + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } return { readable: false } } const lines = output.split(/\r?\n/).map((line) => line.trim()) @@ -99,7 +114,7 @@ async function scanReferences( return { readable: true, referenced } } -/** Collect ripgrep builds that no relay installation references. Never throws. */ +/** Collect unreferenced ripgrep builds; unconfirmed termination stops the caller's cleanup. */ export async function gcRemoteRipgrepCache( conn: SshConnection, host: RemoteHostPlatform, @@ -116,28 +131,51 @@ export async function gcRemoteRipgrepCache( return } const removed: string[] = [] - for (const name of entries) { - const entry = staleTombstoneEntry(name) ?? name - if (scan.referenced.has(entry) || entry === options.pinnedEntry) { - continue - } - if ( - await removeUnreferencedEntry( - conn, - host, - remoteHome, - entry, - name === entry ? undefined : name - ) - ) { + for (const { name, entry } of entries) { + const keep = scan.referenced.has(entry) || entry === options.pinnedEntry + const collected = + name === entry + ? !keep && (await removeUnreferencedEntry(conn, host, remoteHome, entry)) + : await recoverAbandonedTombstone(conn, host, remoteHome, name, entry, keep) + if (collected) { removed.push(entry) } } if (removed.length > 0) { console.log(`[ssh-relay] ripgrep cache GC: removed ${removed.length}: ${removed.join(', ')}`) } - } catch { - /* Never fails a deploy; the next connect tries again. */ + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + // Confirmed cleanup failures are optional; the next connect tries again. + } +} + +// Why random rather than pid + clock: passes on different clients must never rename into one path. +function ownedTombstonePath(host: RemoteHostPlatform, base: string, entry: string): string { + return joinRemotePath( + host, + base, + `${TOMBSTONE_PREFIX}${entry}.${randomInt(1, 2 ** 47)}.${Date.now()}` + ) +} + +async function moveTree( + conn: SshConnection, + host: RemoteHostPlatform, + source: string, + destination: string +): Promise { + try { + return ( + (await exec(conn, host, moveRemoteTreeCommand(host, source, destination))).trim() === 'MOVED' + ) + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + return false } } @@ -145,39 +183,80 @@ async function removeUnreferencedEntry( conn: SshConnection, host: RemoteHostPlatform, remoteHome: string, - entry: string, - abandonedTombstone?: string + entry: string ): Promise { const base = cacheDir(host, remoteHome) const entryDir = joinRemotePath(host, base, entry) - const tombstone = joinRemotePath( - host, - base, - abandonedTombstone ?? `${TOMBSTONE_PREFIX}${entry}.${process.pid}.${Date.now()}` - ) - try { - if ( - !abandonedTombstone && - (await exec(conn, host, moveRemoteTreeCommand(host, entryDir, tombstone))).trim() !== 'MOVED' - ) { - return false - } - } catch { + const tombstone = ownedTombstonePath(host, base, entry) + if (!(await moveTree(conn, host, entryDir, tombstone))) { return false } + return collectOwnedTombstone(conn, host, remoteHome, entry, tombstone, entryDir) +} + +// Only the pass whose rename wins may restore or delete a tombstone other passes can also list. +async function recoverAbandonedTombstone( + conn: SshConnection, + host: RemoteHostPlatform, + remoteHome: string, + name: string, + entry: string, + keep: boolean +): Promise { + const base = cacheDir(host, remoteHome) + const tombstone = ownedTombstonePath(host, base, entry) + if (!(await moveTree(conn, host, joinRemotePath(host, base, name), tombstone))) { + return false + } + const entryDir = joinRemotePath(host, base, entry) + if (keep) { + await restoreCacheEntry(conn, host, tombstone, entryDir) + return false + } + return collectOwnedTombstone(conn, host, remoteHome, entry, tombstone, entryDir) +} + +async function collectOwnedTombstone( + conn: SshConnection, + host: RemoteHostPlatform, + remoteHome: string, + entry: string, + tombstone: string, + entryDir: string +): Promise { // Why recheck under the rename: a deploy that read this entry as present can still be writing // its marker. Its reference now names a path that no longer exists, so restoring the tree is // the only outcome that leaves that relay with a working ripgrep. - const recheck = await scanReferences(conn, host, remoteHome) + const recheck = await scanReferences(conn, host, remoteHome).catch(async (err: unknown) => { + // A read-only scan cannot conflict with restoring this pass's renamed tree. + await restoreCacheEntry(conn, host, tombstone, entryDir).catch(() => {}) + throw err + }) if (!recheck.readable || recheck.referenced.has(entry)) { - await exec(conn, host, restoreEntryCommand(host, tombstone, entryDir)).catch(() => {}) + await restoreCacheEntry(conn, host, tombstone, entryDir) return false } try { await exec(conn, host, removeRemoteTreeCommand(host, tombstone)) return true - } catch { + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } // A later pass retries after verifying references again. return false } } + +async function restoreCacheEntry( + conn: SshConnection, + host: RemoteHostPlatform, + tombstone: string, + entryDir: string +): Promise { + await exec(conn, host, restoreRemoteTreeCommand(host, tombstone, entryDir)).catch((err) => { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + }) +} diff --git a/src/main/ssh/ssh-relay-ripgrep-install.test.ts b/src/main/ssh/ssh-relay-ripgrep-install.test.ts index dc41c281db2..990e0ec0d54 100644 --- a/src/main/ssh/ssh-relay-ripgrep-install.test.ts +++ b/src/main/ssh/ssh-relay-ripgrep-install.test.ts @@ -30,7 +30,7 @@ vi.mock('../ripgrep/bundled-ripgrep-path', () => ({ })) import type { SshConnection } from './ssh-connection' -import { getRemoteHostPlatform } from './ssh-remote-platform' +import { getRemoteHostPlatform, joinRemotePath } from './ssh-remote-platform' import { decodeRemotePowerShellScript } from './ssh-remote-powershell' import { ensureRemoteBundledRipgrep, remoteRipgrepLayout } from './ssh-relay-ripgrep-install' @@ -176,6 +176,85 @@ describe('ensureRemoteBundledRipgrep', () => { expect(execScripts()[1]).toMatch(/^rm -rf '\/home\/me\/\.orca-remote\/ripgrep\/\.upload-/) }) + describe.each([ + { platform: 'Linux', host: LINUX, home: '/home/me' }, + { platform: 'Windows', host: WINDOWS, home: 'C:/Users/me user' } + ])('interrupted install on $platform', ({ host, home }) => { + it.each([ + ['reference', 1, 0], + ['probe', 1, 0], + ['upload', 1, 1], + ['promotion', 2, 1], + ['cleanup', 2, 1] + ] as const)( + 'preserves unconfirmed %s termination and starts no further operation', + async (phase, commands, uploads) => { + const error = Object.assign(new Error('Remote termination is unconfirmed'), { + sshChannelCloseConfirmed: false + }) + if (phase === 'reference' || phase === 'probe') { + execCommandMock.mockRejectedValueOnce(error) + } else { + execCommandMock.mockResolvedValueOnce('ORCA-RG-STAGED\n') + if (phase === 'upload') { + uploadRelayDirectoryMock.mockRejectedValueOnce(error) + } else { + execCommandMock.mockRejectedValueOnce(error) + if (phase === 'cleanup') { + uploadRelayDirectoryMock.mockRejectedValueOnce(new Error('Upload failed')) + } + } + } + + await expect( + ensureRemoteBundledRipgrep(connection(), host, home, { + relayDir: + phase === 'reference' + ? joinRemotePath(host, home, '.orca-remote', 'relay-1.2.3') + : undefined + }) + ).rejects.toBe(error) + + expect(execCommandMock).toHaveBeenCalledTimes(commands) + expect(uploadRelayDirectoryMock).toHaveBeenCalledTimes(uploads) + } + ) + + it.each(['upload', 'promotion'] as const)( + 'removes the stage after confirmed %s termination and remains nonfatal', + async (phase) => { + const error = Object.assign(new Error('Remote operation stopped'), { + sshChannelCloseConfirmed: true + }) + execCommandMock.mockResolvedValueOnce('ORCA-RG-STAGED\n') + if (phase === 'upload') { + uploadRelayDirectoryMock.mockRejectedValueOnce(error) + } else { + execCommandMock.mockRejectedValueOnce(error) + } + execCommandMock.mockResolvedValueOnce('') + + await expect(ensureRemoteBundledRipgrep(connection(), host, home)).resolves.toBe('failed') + + expect(execCommandMock).toHaveBeenCalledTimes(phase === 'upload' ? 2 : 3) + expect(execScripts().at(-1)).toContain( + host === WINDOWS ? 'Remove-Item -LiteralPath' : 'rm -rf' + ) + } + ) + + it('keeps an ordinary stage cleanup failure nonfatal', async () => { + execCommandMock + .mockResolvedValueOnce('ORCA-RG-STAGED\n') + .mockRejectedValueOnce(new Error('Cleanup failed')) + uploadRelayDirectoryMock.mockRejectedValueOnce(new Error('Upload failed')) + + await expect(ensureRemoteBundledRipgrep(connection(), host, home)).resolves.toBe('failed') + + expect(execCommandMock).toHaveBeenCalledTimes(2) + }) + }) + it('reports a failed size verification', async () => { execCommandMock .mockResolvedValueOnce('ORCA-RG-STAGED\n') diff --git a/src/main/ssh/ssh-relay-ripgrep-install.ts b/src/main/ssh/ssh-relay-ripgrep-install.ts index 02992ac811b..333a21801bb 100644 --- a/src/main/ssh/ssh-relay-ripgrep-install.ts +++ b/src/main/ssh/ssh-relay-ripgrep-install.ts @@ -6,8 +6,8 @@ * * Uploads land in a private `.upload-` stage and are renamed into place only after a size * check, so an interrupted or concurrent deploy never leaves a truncated binary at the final path. - * Every failure is reported, never thrown: the relay falls back to PATH `rg` and its git/readdir - * chain, which is exactly what it did before this binary existed. + * Ordinary failures use the relay's PATH `rg` and git/readdir fallback; unconfirmed remote stops + * propagate so deployment cannot start more work while the previous operation may still run. */ import { randomBytes } from 'node:crypto' import { statSync } from 'node:fs' @@ -15,6 +15,7 @@ import { dirname } from 'node:path' import type { SshConnection } from './ssh-connection' import { RELAY_REMOTE_DIR } from './relay-protocol' import { execCommand } from './ssh-relay-deploy-helpers' +import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' import { uploadRelayDirectory } from './ssh-relay-install-transfers' import { createRelayUploadStageNamespace, @@ -52,7 +53,7 @@ export function remoteRipgrepRefFileName(entryName: string): string { return `${REMOTE_RIPGREP_REF_PREFIX}${entryName}` } -/** Record which ripgrep build a relay directory runs against. Best-effort: never fails a deploy. */ +/** Record the relay's ripgrep build; only an unconfirmed remote stop rejects. */ export async function recordRemoteRipgrepReference( conn: SshConnection, host: RemoteHostPlatform, @@ -73,6 +74,9 @@ export async function recordRemoteRipgrepReference( ) return true } catch (error) { + if (isUnconfirmedSshCommandTermination(error)) { + throw error + } console.warn( '[ssh-relay] Could not record the ripgrep reference; skipping the bundled binary:', error instanceof Error ? error.message : String(error) @@ -117,7 +121,7 @@ export function remoteRipgrepLayout( } } -/** Make sure the host has Orca's ripgrep at `remoteRipgrepLayout().binaryPath`; never throws. */ +/** Ensure the host has Orca's ripgrep; only an unconfirmed remote stop rejects. */ export async function ensureRemoteBundledRipgrep( conn: SshConnection, host: RemoteHostPlatform, @@ -150,6 +154,9 @@ async function installOrReport( } return await installRemoteRipgrep(conn, host, layout, localBinary, options.signal) } catch (error) { + if (isUnconfirmedSshCommandTermination(error)) { + throw error + } console.warn( '[ssh-relay] Bundled ripgrep install failed; the relay will use rg from PATH:', error instanceof Error ? error.message : String(error) @@ -184,6 +191,7 @@ async function installRemoteRipgrep( } let promoted = false + let cleanupAllowed = true try { await uploadRelayDirectory( conn, @@ -204,12 +212,19 @@ async function installRemoteRipgrep( } console.log(`[ssh-relay] Installed bundled ripgrep at ${layout.binaryPath} (${size} bytes)`) return 'installed' + } catch (error) { + cleanupAllowed = !isUnconfirmedSshCommandTermination(error) + throw error } finally { - if (!promoted) { + if (!promoted && cleanupAllowed) { // Why best-effort: the next deploy's probe also sweeps stale stages. await execCommand(conn, removeRemoteTreeCommand(host, stageDir), { wrapCommand: !isWindowsRemoteHost(host) - }).catch(() => {}) + }).catch((error) => { + if (isUnconfirmedSshCommandTermination(error)) { + throw error + } + }) } } } diff --git a/src/main/ssh/ssh-relay-session-data-delivery.test.ts b/src/main/ssh/ssh-relay-session-data-delivery.test.ts index e5683c0949a..da528f7f95f 100644 --- a/src/main/ssh/ssh-relay-session-data-delivery.test.ts +++ b/src/main/ssh/ssh-relay-session-data-delivery.test.ts @@ -344,7 +344,8 @@ describe('SshRelaySession data delivery', () => { }) ) session.dispose() - expect(mockStore.removeSshPtyConsumerRecovery).toHaveBeenCalledWith(targetId) + const removeRecovery = mockStore.removeSshPtyConsumerRecovery + expect(removeRecovery).toHaveBeenCalledWith(targetId, 'persisted-client') }) it('voids checkpoints for a fresh claim without a second owner request', async () => { diff --git a/src/main/ssh/ssh-relay-session-managed-hooks.test.ts b/src/main/ssh/ssh-relay-session-managed-hooks.test.ts index 67351e08102..da10d8841a1 100644 --- a/src/main/ssh/ssh-relay-session-managed-hooks.test.ts +++ b/src/main/ssh/ssh-relay-session-managed-hooks.test.ts @@ -3,6 +3,8 @@ import { AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, AGENT_HOOK_INSTALL_PLUGINS_METHOD } from '../../shared/agent-hook-relay' +import { getDefaultSettings } from '../../shared/constants' +import type { Store } from '../persistence' import { SshRelaySession } from './ssh-relay-session' import type { SshConnection } from './ssh-connection' import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' @@ -159,4 +161,45 @@ describe('SshRelaySession managed hooks', () => { }) ) }) + it('refreshes OpenCode sources on settings changes and releases its subscription', async () => { + muxRequestMock.mockResolvedValue({ agents: [] }) + const { mockStore, mockConn, mockPortForward, getMainWindow } = createMockDeps() + const settings = getDefaultSettings('/synthetic-home') + settings.disabledTuiAgents = ['opencode'] + mockStore.getSettings = () => settings + let listener: Parameters[0] | undefined + const cleanup = vi.fn(() => { + listener = undefined + }) + mockStore.onSettingsChanged = (callback) => { + listener = callback + return cleanup + } + const session = new SshRelaySession( + 'target-settings', + getMainWindow, + mockStore, + mockPortForward + ) + await session.establish(mockConn) + const lastSources = () => + muxRequestMock.mock.calls.findLast( + ([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD + )?.[1] + expect(lastSources()).toMatchObject({ + opencodePluginSource: '', + opencode2PluginSource: expect.stringContaining('/hook/opencode2') + }) + settings.disabledTuiAgents = ['opencode2'] + listener?.({ disabledTuiAgents: settings.disabledTuiAgents }, settings) + expect(lastSources()).toMatchObject({ + opencodePluginSource: expect.stringContaining('/hook/opencode'), + opencode2PluginSource: '' + }) + settings.agentStatusHooksEnabled = false + listener?.({ agentStatusHooksEnabled: false }, settings) + expect(lastSources()).toMatchObject({ opencodePluginSource: '', opencode2PluginSource: '' }) + session.dispose() + expect(cleanup).toHaveBeenCalledOnce() + }) }) diff --git a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts b/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts index 6760a27821c..ce223ae0b9c 100644 --- a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts +++ b/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts @@ -3,7 +3,11 @@ import { randomUUID } from 'node:crypto' import type * as NodeCrypto from 'node:crypto' import { SshRelaySession } from './ssh-relay-session' import { runRemoteOrcaCli } from './ssh-remote-orca-cli' -import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' +import { + createMockDeps, + mockDeploySuccess, + recordedPtyBindings +} from './ssh-relay-session-test-fixtures' import { getDefaultWorkspaceSession } from '../../shared/constants' import type { SshRemotePtyLease } from '../../shared/ssh-types' @@ -439,7 +443,7 @@ describe('SshRelaySession reconnect incarnation ordering', () => { incarnationId }) expect(runtime.onPtySpawned).not.toHaveBeenCalled() - expect(mockStore.persistPtyBinding).toHaveBeenCalledWith({ + expect(recordedPtyBindings(mockStore)).toContainEqual({ worktreeId: 'worktree-1', tabId: 'tab-1', leafId: INCARNATION_LEAF_ID, @@ -454,13 +458,21 @@ describe('SshRelaySession reconnect incarnation ordering', () => { }) it.each([ - { relay: 'current', incarnationId: 'inc-1', tombstonePartition: 'local' }, - { relay: 'current', incarnationId: 'inc-1', tombstonePartition: 'host' }, - { relay: 'legacy', incarnationId: undefined, tombstonePartition: 'local' }, - { relay: 'legacy', incarnationId: undefined, tombstonePartition: 'host' } + { relay: 'current', incarnationId: 'inc-1', tombstonePartition: 'local', retiredBy: 'surface' }, + { relay: 'current', incarnationId: 'inc-1', tombstonePartition: 'host', retiredBy: 'surface' }, + { + relay: 'legacy', + incarnationId: undefined, + tombstonePartition: 'local', + retiredBy: 'surface' + }, + { relay: 'legacy', incarnationId: undefined, tombstonePartition: 'host', retiredBy: 'surface' }, + // A closed tab whose pane is in no tab: the close record is the backstop. + { relay: 'current', incarnationId: 'inc-1', tombstonePartition: 'local', retiredBy: 'close' }, + { relay: 'current', incarnationId: 'inc-1', tombstonePartition: 'host', retiredBy: 'close' } ])( - 'suppresses a $tombstonePartition-partition retired surface from a $relay relay', - async ({ incarnationId, tombstonePartition }) => { + 'suppresses a $tombstonePartition-partition $retiredBy retirement from a $relay relay', + async ({ incarnationId, tombstonePartition, retiredBy }) => { const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() const attachForReconnect = vi.fn().mockResolvedValue({ ...(incarnationId ? { incarnationId } : {}), @@ -510,10 +522,16 @@ describe('SshRelaySession reconnect incarnation ordering', () => { } } } + const closedTab: ReturnType = { + ...getDefaultWorkspaceSession(), + closedTerminalTabTombstonesByTabId: { [tabId]: { closedAt: Date.now(), worktreeId } } + } vi.mocked(mockStore.getWorkspaceSession).mockImplementation((hostId) => - (hostId ? 'host' : 'local') === tombstonePartition - ? sessionWithTombstone - : getDefaultWorkspaceSession() + (hostId ? 'host' : 'local') !== tombstonePartition + ? getDefaultWorkspaceSession() + : retiredBy === 'close' + ? closedTab + : sessionWithTombstone ) const runtime = { registerPty: vi.fn(), onPtySpawned: vi.fn() } const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) @@ -643,7 +661,7 @@ describe('SshRelaySession reconnect incarnation ordering', () => { leafId: INCARNATION_LEAF_ID, incarnationId }) - expect(mockStore.persistPtyBinding).toHaveBeenCalledWith( + expect(recordedPtyBindings(mockStore)).toContainEqual( expect.objectContaining({ tabId: movedTabId, ptyId: APP_PTY_ID, incarnationId }) ) expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( @@ -763,7 +781,7 @@ describe('SshRelaySession reconnect incarnation ordering', () => { incarnationId: currentIncarnationId }) expect(setPtyOwnership).toHaveBeenCalledWith(APP_PTY_ID, 'target-1') - expect(mockStore.persistPtyBinding).toHaveBeenCalledWith( + expect(recordedPtyBindings(mockStore)).toContainEqual( expect.objectContaining({ ptyId: APP_PTY_ID, incarnationId: currentIncarnationId }) ) expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:replay', { diff --git a/src/main/ssh/ssh-relay-session-recovery-races.test.ts b/src/main/ssh/ssh-relay-session-recovery-races.test.ts index 26dd798ac5a..f4600bb23e6 100644 --- a/src/main/ssh/ssh-relay-session-recovery-races.test.ts +++ b/src/main/ssh/ssh-relay-session-recovery-races.test.ts @@ -140,17 +140,21 @@ describe('SshRelaySession recovery race fencing', () => { mockDeploySuccess() }) - function emitSourceFrame(args: { - targetId: string - token: string - clientGeneration: number - ownerGeneration: number - sourceStartSu: number - sourceEndSu: number - }): void { - ptyDataHandlerRef.current?.({ + function emitSourceFrame( + args: { + targetId: string + token: string + clientGeneration: number + ownerGeneration: number + sourceStartSu: number + sourceEndSu: number + data?: string + }, + sink = ptyDataHandlerRef.current + ): void { + sink?.({ id: `ssh:${args.targetId}@@pty-1`, - data: 'late', + data: args.data ?? 'late', providerGeneration: 23, ptyIncarnation: 'incarnation-1', sequenceChars: args.sourceEndSu - args.sourceStartSu, @@ -227,6 +231,66 @@ describe('SshRelaySession recovery race fencing', () => { return { session, deps } } + it('keeps source output contiguous while the recovered pane binding waits for disk', async () => { + const targetId = 'recovery-binding-disk-wait' + const { session, deps } = await prepareRecovery(targetId) + const binding = Promise.withResolvers() + vi.mocked(deps.mockStore.getSshRemotePtyLeases).mockReturnValue([ + { + targetId, + ptyId: 'pty-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + state: 'detached', + createdAt: 1, + updatedAt: 1 + } + ]) + vi.mocked(deps.mockStore.persistPtyBinding).mockReturnValue(binding.promise) + const emit = (sourceStartSu: number, sink = ptyDataHandlerRef.current): void => + emitSourceFrame( + { + targetId, + token: 'new-token', + clientGeneration: 2, + ownerGeneration: 2, + sourceStartSu, + sourceEndSu: sourceStartSu + 4 + }, + sink + ) + let recoverySink: typeof ptyDataHandlerRef.current + const recoveryActivationLease = { commit: vi.fn(), retire: vi.fn() } + attachForReconnectMock.mockResolvedValue({ + incarnationId: 'incarnation-1', + sourceRecovery: pendingRecovery(8), + sourceActivationLease: { + commit: vi.fn(), + rollback: vi.fn(async () => true), + transferToRecovery: (sink: (payload: unknown) => void) => { + recoverySink = sink + emit(4, sink) + completeRecovery({ id: 'pty-1', ...pendingRecovery(8) }) + return recoveryActivationLease + } + } + }) + + const reconnect = session.reconnect(deps.mockConn) + await vi.waitFor(() => expect(deps.mockStore.persistPtyBinding).toHaveBeenCalledOnce()) + emit(8, recoverySink) + expect(recoveryActivationLease.commit).not.toHaveBeenCalled() + binding.resolve(true) + await reconnect + emit(12) + + expect( + acceptOutputDataMock.mock.calls.map(([payload]) => payload.source.sourceStartSu) + ).toEqual([4, 8, 12]) + expect(recoveryActivationLease.commit).toHaveBeenCalledOnce() + }) + it('publishes held recovery data before an exact exit without waiting for completion', async () => { const targetId = 'exit-with-complete-private-body' const { session, deps } = await prepareRecovery(targetId) @@ -235,22 +299,18 @@ describe('SshRelaySession recovery race fencing', () => { commit: vi.fn(), rollback: vi.fn(async () => true), transferToRecovery: vi.fn((sink: (payload: unknown) => void) => { - sink({ - id: `ssh:${targetId}@@pty-1`, - data: 'held', - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - sequenceChars: 4, - source: { - relayPtyId: 'pty-1', - spanId: 'new-token:4:8', + emitSourceFrame( + { + targetId, + token: 'new-token', clientGeneration: 2, ownerGeneration: 2, - deliveryToken: 'new-token', sourceStartSu: 4, - sourceEndSu: 8 - } - }) + sourceEndSu: 8, + data: 'held' + }, + sink + ) return recoveryActivationLease }) } @@ -301,22 +361,18 @@ describe('SshRelaySession recovery race fencing', () => { commit: vi.fn(), rollback: vi.fn(async () => true), transferToRecovery: vi.fn((sink: (payload: unknown) => void) => { - sink({ - id: `ssh:${targetId}@@pty-1`, - data: 'partial', - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - sequenceChars: 2, - source: { - relayPtyId: 'pty-1', - spanId: 'new-token:4:6', + emitSourceFrame( + { + targetId, + token: 'new-token', clientGeneration: 2, ownerGeneration: 2, - deliveryToken: 'new-token', sourceStartSu: 4, - sourceEndSu: 6 - } - }) + sourceEndSu: 6, + data: 'partial' + }, + sink + ) return recoveryActivationLease }) } @@ -501,38 +557,23 @@ describe('SshRelaySession recovery race fencing', () => { commit: vi.fn(), rollback: vi.fn(), transferToRecovery: vi.fn((sink: (payload: unknown) => void) => { - sink({ - id: `ssh:${targetId}@@pty-1`, - data: 'x'.repeat(2 * 1024 * 1024 + 1), - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - sequenceChars: 4, - source: { - relayPtyId: 'pty-1', - spanId: 'new-token:4:8', - clientGeneration: 2, - ownerGeneration: 2, - deliveryToken: 'new-token', - sourceStartSu: 4, - sourceEndSu: 8 - } - }) - sink({ - id: `ssh:${targetId}@@pty-1`, - data: 'later', - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - sequenceChars: 4, - source: { - relayPtyId: 'pty-1', - spanId: 'new-token:8:12', - clientGeneration: 2, - ownerGeneration: 2, - deliveryToken: 'new-token', - sourceStartSu: 8, - sourceEndSu: 12 - } - }) + for (const [data, sourceStartSu] of [ + ['x'.repeat(2 * 1024 * 1024 + 1), 4], + ['later', 8] + ] as const) { + emitSourceFrame( + { + targetId, + token: 'new-token', + clientGeneration: 2, + ownerGeneration: 2, + sourceStartSu, + sourceEndSu: sourceStartSu + 4, + data + }, + sink + ) + } return recoveryActivationLease }) } diff --git a/src/main/ssh/ssh-relay-session-test-fixtures.ts b/src/main/ssh/ssh-relay-session-test-fixtures.ts index ba0782b3a20..c186e922e93 100644 --- a/src/main/ssh/ssh-relay-session-test-fixtures.ts +++ b/src/main/ssh/ssh-relay-session-test-fixtures.ts @@ -1,6 +1,7 @@ import { vi, type Mock } from 'vitest' import type { BrowserWindow } from 'electron' import type { SshConnection } from './ssh-connection' +import type { PersistPtyBindingArgs } from '../persistence/loading-store/pty-binding-persistence' import type { Store } from '../persistence' import type { SshPortForwardManager } from './ssh-port-forward' import { deployAndLaunchRelay } from './ssh-relay-deploy' @@ -13,8 +14,16 @@ type SshRelaySessionTestDeps = { mockWindow: BrowserWindow } +const persistedBindings = new WeakMap() + +export function recordedPtyBindings(store: Store): readonly PersistPtyBindingArgs[] { + return persistedBindings.get(store) ?? [] +} + export function createMockDeps(): SshRelaySessionTestDeps { + const bindings: PersistPtyBindingArgs[] = [] const mockConn = {} as SshConnection + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The relay fixture implements the Store methods exercised by session establishment and teardown. const mockStore = { getRepos: vi.fn().mockReturnValue([]), getSshPtyConsumerRecovery: vi.fn().mockReturnValue(null), @@ -33,8 +42,16 @@ export function createMockDeps(): SshRelaySessionTestDeps { recordSshRemotePtyKillIntent: vi.fn(), clearSshRemotePtyKillIntent: vi.fn(), noteSshRemotePtyKillReplayAttempt: vi.fn(), - persistPtyBinding: vi.fn() + persistPtyBinding: vi.fn(async (input: Parameters[0]) => { + const binding = typeof input === 'function' ? input() : input + if (!binding) { + return false + } + bindings.push(binding) + return true + }) } as unknown as Store + persistedBindings.set(mockStore, bindings) const mockPortForward = { removeAllForwards: vi.fn() } as unknown as SshPortForwardManager diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index dc9ff181b2f..5b48337f0ab 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -206,6 +206,27 @@ describe('SshRelaySession', () => { expect(registerSshGitProvider).toHaveBeenCalledWith('target-1', expect.anything()) }) + it('rechecks OpenCode preparation from scans and aborts it when the relay session disconnects', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + const prepareOpenCodeRuntime = vi.fn().mockResolvedValue(undefined) + vi.mocked(deployAndLaunchRelay).mockResolvedValueOnce({ + transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, + platform: 'linux-x64', + prepareOpenCodeRuntime + }) + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + await session.establish(mockConn) + await session.requestAiVaultSessionList({}) + await session.requestSessionSearch('sessionSearch.search', {}) + expect(prepareOpenCodeRuntime).toHaveBeenCalledTimes(2) + const signal = prepareOpenCodeRuntime.mock.calls[0][0] + expect(signal.aborted).toBe(false) + await session.dispose() + expect(signal.aborted).toBe(true) + await expect(session.requestAiVaultSessionList({})).rejects.toThrow('not ready') + expect(prepareOpenCodeRuntime).toHaveBeenCalledTimes(2) + }) + it('continues provider registration when the relay managed-hook request fails', async () => { process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' muxRequestMock.mockImplementation(async (method: string) => { diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 19e102b08ed..e7911691261 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto' import type { BrowserWindow } from 'electron' import { deployAndLaunchRelay } from './ssh-relay-deploy' +import type { RemoteOpenCodeRuntimePreparation } from './ssh-relay-opencode-runtime-retry' import { execCommand } from './ssh-relay-deploy-helpers' import { writeStringsViaSftp } from './sftp-upload' import { isRelayVersionMismatchError } from './ssh-relay-version-mismatch-error' @@ -22,6 +23,7 @@ import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider' import { isMethodNotFoundError } from './ssh-filesystem-stream-reader' import { SshGitProvider } from '../providers/ssh-git-provider' +import { selectOpenCodePluginSources } from '../agent-hooks/opencode-plugin-settings' import { agentHookServer } from '../agent-hooks/server' import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { @@ -117,6 +119,7 @@ import { } from '../../shared/ssh-ai-vault-relay' import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id' import { isValidTerminalTabId } from '../../shared/terminal-tab-id' +import { hasClosedTerminalTabRecord } from '../../shared/closed-terminal-tab-tombstones' import { openSshPtyConsumerSession, type OpenSshPtyConsumerSessionOptions, @@ -317,6 +320,7 @@ export class SshRelaySession { private muxDisposeCleanup: (() => void) | null = null // Why: hold the notification-handler disposer so teardownProviders can release it on reconnect/shutdown (symmetric with muxDisposeCleanup). private muxNotificationCleanup: (() => void) | null = null + private pluginSettingsCleanup: (() => void) | null = null // Why: onStateChange never fires when the relay channel closes but SSH stays up; this callback lets ssh.ts drive relay-level reconnect. private _onRelayLost: ((targetId: string) => void) | null = null // Why: a version mismatch or a blocked owner admission is terminal, so it needs a separate callback @@ -329,6 +333,10 @@ export class SshRelaySession { private lastGraceTimeSeconds: number | undefined = undefined private hostPlatform: RemoteHostPlatform | null = null private remoteCliBridgeEnv: RemoteCliBridgeEnv | null = null + private openCodeRuntimePreparation: { + run: RemoteOpenCodeRuntimePreparation + controller: AbortController + } | null = null private aiVaultListMethodSupported: boolean | null = null private aiVaultTitleMethodSupported: boolean | null = null private pendingPtyReattaches = new Map() @@ -460,6 +468,7 @@ export class SshRelaySession { if (!mux || mux.isDisposed() || this._state !== 'ready') { throw new Error('SSH relay is not ready') } + this.prepareOpenCodeRuntimeForScan() return mux.request(method, params, { timeoutMs: 15_000 }) } @@ -474,6 +483,7 @@ export class SshRelaySession { if (!mux || mux.isDisposed() || this._state !== 'ready') { throw new Error('SSH relay is not ready') } + this.prepareOpenCodeRuntimeForScan() try { const result = await mux.request(SSH_AI_VAULT_LIST_SESSIONS_METHOD, params, { signal: options.signal, @@ -549,7 +559,8 @@ export class SshRelaySession { nodePath, sockPath, credentialFile, - hostPlatform + hostPlatform, + prepareOpenCodeRuntime } = await deployAndLaunchRelay(conn, undefined, graceTimeSeconds, this.targetId) this.hostPlatform = hostPlatform ?? null this.remoteCliBridgeEnv = @@ -574,6 +585,9 @@ export class SshRelaySession { } const mux = new SshChannelMultiplexer(transport) + this.openCodeRuntimePreparation = prepareOpenCodeRuntime + ? { run: prepareOpenCodeRuntime, controller: new AbortController() } + : null this.mux = mux const isAttemptCurrent = (): boolean => this.mux === mux && !this.isDisposed() const shouldContinue = (): boolean => isAttemptCurrent() && !mux.isDisposed() @@ -704,7 +718,8 @@ export class SshRelaySession { nodePath, sockPath, credentialFile, - hostPlatform + hostPlatform, + prepareOpenCodeRuntime } = await deployAndLaunchRelay(conn, undefined, graceTimeSeconds, this.targetId) this.hostPlatform = hostPlatform ?? null this.remoteCliBridgeEnv = @@ -729,6 +744,9 @@ export class SshRelaySession { } const mux = new SshChannelMultiplexer(transport) + this.openCodeRuntimePreparation = prepareOpenCodeRuntime + ? { run: prepareOpenCodeRuntime, controller: new AbortController() } + : null this.mux = mux const isAttemptCurrent = (): boolean => @@ -1072,6 +1090,13 @@ export class SshRelaySession { return false } + this.pluginSettingsCleanup?.() + this.pluginSettingsCleanup = + this.store.onSettingsChanged?.((updates) => { + if ('disabledTuiAgents' in updates || 'agentStatusHooksEnabled' in updates) { + void this.installPluginsOnRelay(mux) + } + }) ?? null await this.installPluginsOnRelay(mux) if (shouldContinue && !shouldContinue()) { return false @@ -1524,17 +1549,28 @@ export class SshRelaySession { // Why: ship plugin/extension source from Orca so agent-event changes don't force a relay redeploy — the relay is versioned independently. Best-effort: failure only costs agent status on this host. private async installPluginsOnRelay(mux: SshChannelMultiplexer): Promise { - if (!isRemoteAgentHooksEnabled() || !this.areAgentStatusHooksEnabled()) { + if (!isRemoteAgentHooksEnabled()) { return } try { - await mux.request(AGENT_HOOK_INSTALL_PLUGINS_METHOD, { - opencodePluginSource: openCodeInternals.getOpenCodePluginSource(), - opencode2PluginSource: openCodeInternals.getOpenCode2PluginSource(), - piExtensionSource: getPiAgentStatusExtensionSource('pi'), - ompExtensionSource: getPiAgentStatusExtensionSource('omp'), - primeAgentExtensionSource: getPiAgentStatusExtensionSource('prime-agent') - }) + const hooksEnabled = this.areAgentStatusHooksEnabled() + await mux.request( + AGENT_HOOK_INSTALL_PLUGINS_METHOD, + selectOpenCodePluginSources( + { + opencodePluginSource: openCodeInternals.getOpenCodePluginSource(), + opencode2PluginSource: openCodeInternals.getOpenCode2PluginSource(), + ...(hooksEnabled + ? { + piExtensionSource: getPiAgentStatusExtensionSource('pi'), + ompExtensionSource: getPiAgentStatusExtensionSource('omp'), + primeAgentExtensionSource: getPiAgentStatusExtensionSource('prime-agent') + } + : {}) + }, + this.store.getSettings?.() ?? null + ) + ) } catch (err) { // Why: -32601 = older relay without the handler; CONNECTION_LOST/DISPOSED = routine mid-flight teardown — swallow both. const code = (err as { code?: unknown })?.code @@ -1646,11 +1682,22 @@ export class SshRelaySession { }) } + private prepareOpenCodeRuntimeForScan(): void { + const preparation = this.openCodeRuntimePreparation + if (preparation) { + void preparation.run(preparation.controller.signal) + } + } + private teardownProviders( reason: 'shutdown' | 'connection_lost', outputGenerationReason: string = reason ): void { + this.openCodeRuntimePreparation?.controller.abort() + this.openCodeRuntimePreparation = null this.releaseRelayLossWatcher() + this.pluginSettingsCleanup?.() + this.pluginSettingsCleanup = null this.muxNotificationCleanup?.() this.muxNotificationCleanup = null for (const cleanup of this.ptyRecoveryNotificationCleanups) { @@ -1799,10 +1846,6 @@ export class SshRelaySession { } const pending = this.pendingPtyReattaches.get(payload.id) if (pending && this.activePtyConsumerOwner()?.outputFlowControl) { - if (pending.livePassthrough) { - void this.acceptPtyData(payload).catch(() => {}) - return - } this.quarantineReattachData(pending, payload) return } @@ -2074,6 +2117,12 @@ export class SshRelaySession { } private quarantineReattachData(pending: PendingPtyReattach, payload: SshPtyDataPayload): void { + if (pending.livePassthrough) { + if (this.ownsPtyRecoveryAttempt(payload.id, pending)) { + void this.acceptPtyData(payload).catch(() => {}) + } + return + } this.observePrivateRecoveryFrame(pending, payload) if (pending.restoreRequired) { return @@ -2575,11 +2624,15 @@ export class SshRelaySession { return } if (attachResult.incarnationId) { - const restoreResult = this.restoreReattachedPtyRuntime( + const restoreResult = await this.restoreReattachedPtyRuntime( appPtyId, attachResult.incarnationId, - activeLease + activeLease, + () => shouldContinue() && this.ownsPtyRecoveryAttempt(appPtyId, pendingReattach) ) + if (!shouldContinue() || !this.ownsPtyRecoveryAttempt(appPtyId, pendingReattach)) { + return + } if (restoreResult !== 'restored') { clearProviderPtyState(appPtyId) deletePtyOwnership(appPtyId) @@ -2700,9 +2753,19 @@ export class SshRelaySession { if (hasLiveCurrentBinding) { return false } - return [lease.tabId, ...currentTabIds] - .filter((tabId) => isValidTerminalTabId(tabId)) - .some(tombstoneMatches) + // Why only when no tab holds the leaf: a pane moved out of the tab before it closed lives on. + const leaseTabId = lease.tabId + const closedByRecord = + currentTabIds.length === 0 && + candidates.some((candidate) => + hasClosedTerminalTabRecord(candidate?.closedTerminalTabTombstonesByTabId, leaseTabId) + ) + return ( + closedByRecord || + [lease.tabId, ...currentTabIds] + .filter((tabId) => isValidTerminalTabId(tabId)) + .some(tombstoneMatches) + ) } private async suppressRetiredReattachedPty( @@ -2742,45 +2805,56 @@ export class SshRelaySession { deletePtyOwnership(appPtyId) } - private restoreReattachedPtyRuntime( + private async restoreReattachedPtyRuntime( appPtyId: string, incarnationId: string, - lease: SshPtyLease | undefined - ): ReattachedPtyRuntimeRestore { + lease: SshPtyLease | undefined, + shouldContinue: () => boolean + ): Promise { if (lease?.worktreeId && lease.tabId && lease.leafId) { - const session = this.store.getWorkspaceSession?.() - // The lease froze its tabId at write time; `detachTerminalPaneToTab` moves a live pane, so - // trusting it would fence this reattach to the tab the pane LEFT and refuse a pane that - // merely moved. Leaf is the identity, the tab is only where it currently sits. - // SSH spawns bind panes into `ssh:` while this reattach binds into `local`, so a - // fence that consulted only one partition would read "no pane" for a pane the other holds. - const hostSession = this.store.getWorkspaceSession?.(toSshExecutionHostId(this.targetId)) - const tabId = - findTerminalTabIdForLeaf(session, lease.leafId) ?? - findTerminalTabIdForLeaf(hostSession, lease.leafId) ?? - lease.tabId - // Absence of the pane only means "the user closed it" once the persisted membership - // speaks for this worktree. Before that it means the renderer has not published its - // layout yet, and refusing there drops a tab the user still has — the regression that - // reverted this fix twice. Losing a tab is worse than keeping a duplicate, so an - // unauthoritative session still gets the creating write. - // Authority is read from `local` because that is the partition this write lands in — it - // is local's absence we would be interpreting. But a pane the other partition still holds - // is not gone, so it keeps its creating write: refusing there would strand a live pane - // behind a binding reattach can no longer reach. - const mayCreate = - !hasHostAuthoritativeTerminalMembership(session, lease.worktreeId) || - findTerminalTabIdForLeaf(hostSession, lease.leafId) !== undefined - const bound = this.store.persistPtyBinding({ - worktreeId: lease.worktreeId, - tabId, - leafId: lease.leafId, - ptyId: appPtyId, - incarnationId, - ...(mayCreate ? {} : { mayCreate: false }), - mayReviveRetiredSurface: false, - origin: 'relay_reattach' + const { worktreeId, leafId, tabId: leaseTabId } = lease + let tabId = lease.tabId + const bound = await this.store.persistPtyBinding(() => { + if (!shouldContinue()) { + return null + } + const session = this.store.getWorkspaceSession?.() + // The lease froze its tabId at write time; `detachTerminalPaneToTab` moves a live pane, so + // trusting it would fence this reattach to the tab the pane LEFT and refuse a pane that + // merely moved. Leaf is the identity, the tab is only where it currently sits. + // SSH spawns bind panes into `ssh:` while this reattach binds into `local`, so a + // fence that consulted only one partition would read "no pane" for a pane the other holds. + const hostSession = this.store.getWorkspaceSession?.(toSshExecutionHostId(this.targetId)) + tabId = + findTerminalTabIdForLeaf(session, leafId) ?? + findTerminalTabIdForLeaf(hostSession, leafId) ?? + leaseTabId + // Absence of the pane only means "the user closed it" once the persisted membership + // speaks for this worktree. Before that it means the renderer has not published its + // layout yet, and refusing there drops a tab the user still has — the regression that + // reverted this fix twice. Losing a tab is worse than keeping a duplicate, so an + // unauthoritative session still gets the creating write. + // Authority is read from `local` because that is the partition this write lands in — it + // is local's absence we would be interpreting. But a pane the other partition still holds + // is not gone, so it keeps its creating write: refusing there would strand a live pane + // behind a binding reattach can no longer reach. + const mayCreate = + !hasHostAuthoritativeTerminalMembership(session, worktreeId) || + findTerminalTabIdForLeaf(hostSession, leafId) !== undefined + return { + worktreeId: worktreeId, + tabId, + leafId: leafId, + ptyId: appPtyId, + incarnationId, + ...(mayCreate ? {} : { mayCreate: false }), + mayReviveRetiredSurface: false, + origin: 'relay_reattach' as const + } }) + if (!shouldContinue()) { + return 'missing-surface' + } if (bound === false) { // Topology absence alone is not authority to kill a process, but neither refusal may // publish or replay into a missing pane. diff --git a/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts b/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts index 65459b8bcba..5bf0cc3ab53 100644 --- a/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts +++ b/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts @@ -7,6 +7,9 @@ import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' +vi.mock('./ssh-relay-opencode-runtime', () => ({ + ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') +})) vi.mock('./ssh-relay-ripgrep-install', () => ({ remoteRipgrepLayout: vi.fn().mockReturnValue(null), recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false), diff --git a/src/main/ssh/ssh-relay-superseded-endpoints.test.ts b/src/main/ssh/ssh-relay-superseded-endpoints.test.ts index dc091b67762..63d5ec29ca1 100644 --- a/src/main/ssh/ssh-relay-superseded-endpoints.test.ts +++ b/src/main/ssh/ssh-relay-superseded-endpoints.test.ts @@ -107,6 +107,60 @@ describe('classifySupersededRelay', () => { }) describe('sweepSupersededRelayEndpoints', () => { + it.each(['listing', 'reap', 'removal'] as const)( + 'stops after unconfirmed %s termination without examining another endpoint', + async (phase) => { + const secondSock = `${HOME}/.orca-remote/relay-0.1.0+cafebabe1234/${SOCK_NAME}` + const error = Object.assign(new Error('Remote termination is unconfirmed'), { + sshChannelCloseConfirmed: false + }) + if (phase !== 'listing') { + execCommand + .mockResolvedValueOnce(`${OLD_SOCK}\n${secondSock}\n`) + .mockResolvedValueOnce( + probe( + phase === 'reap' + ? ['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 2 0'] + : ['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof'] + ) + ) + } + execCommand.mockRejectedValueOnce(error) + + await expect(sweepSupersededRelayEndpoints(CONN, HOST, SWEEP)).rejects.toBe(error) + + expect(issuedCommands()).toHaveLength(phase === 'listing' ? 1 : 3) + } + ) + + it.each(['reap', 'removal'] as const)( + 'keeps an ordinary %s failure nonfatal and examines later endpoints', + async (phase) => { + const secondSock = `${HOME}/.orca-remote/relay-0.1.0+cafebabe1234/${SOCK_NAME}` + execCommand + .mockResolvedValueOnce(`${OLD_SOCK}\n${secondSock}\n`) + .mockResolvedValueOnce( + probe( + phase === 'reap' + ? ['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 2 0'] + : ['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof'] + ) + ) + .mockRejectedValueOnce(new Error('Operation failed')) + .mockResolvedValueOnce( + probe(['PRESENT=yes', 'LISTEN=unknown', 'HOLDERS_SOURCE=unavailable']) + ) + + const findings = await sweepSupersededRelayEndpoints(CONN, HOST, SWEEP) + + expect(findings.map((finding) => finding.outcome)).toEqual([ + phase === 'reap' ? 'reap-unconfirmed' : 'unverifiable', + 'unverifiable' + ]) + expect(issuedCommands()).toHaveLength(4) + } + ) + it('stops the sweep before cleanup when probe group termination is unconfirmed', async () => { execCommand .mockResolvedValueOnce(OLD_SOCK) diff --git a/src/main/ssh/ssh-relay-superseded-endpoints.ts b/src/main/ssh/ssh-relay-superseded-endpoints.ts index ade50943c32..c538988f11b 100644 --- a/src/main/ssh/ssh-relay-superseded-endpoints.ts +++ b/src/main/ssh/ssh-relay-superseded-endpoints.ts @@ -21,6 +21,7 @@ import { shellEscape } from './ssh-connection-utils' import { RELAY_REMOTE_DIR } from './relay-protocol' import { SHORT_RELAY_SOCKET_DIR_PREFIX } from './relay-socket-path-limit' import { execCommand } from './ssh-relay-deploy-helpers' +import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' import { describeRelayEndpointIncumbent, isReapableRelayHusk, @@ -138,6 +139,9 @@ export async function sweepSupersededRelayEndpoints( signal: options.signal }) } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } // Same reason the Windows arm logs: an abandoned pass and an empty host are the same return // value, and only the log tells them apart. console.warn( @@ -201,7 +205,10 @@ async function applySupersededRelayDecision( signal: options.signal }) return 'stale-endpoint-removed' - } catch { + } catch (error) { + if (isUnconfirmedSshCommandTermination(error)) { + throw error + } return 'unverifiable' } } diff --git a/src/main/ssh/ssh-relay-upload-stage-commands.test.ts b/src/main/ssh/ssh-relay-upload-stage-commands.test.ts index 8afb89228d0..0b5d0a74149 100644 --- a/src/main/ssh/ssh-relay-upload-stage-commands.test.ts +++ b/src/main/ssh/ssh-relay-upload-stage-commands.test.ts @@ -15,6 +15,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { getRemoteHostPlatform, type RemoteHostPlatform } from './ssh-remote-platform' +import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' import { cleanupOwnedRelayUploadStageCommand, parseReservedRelayUploadStage, @@ -73,17 +74,19 @@ function runCommand(host: RemoteHostPlatform, command: string, prefix = '') { return spawnSync('/bin/sh', ['-c', `${prefix}\n${command}`], { encoding: 'utf8' }) } -function createStage( - host: RemoteHostPlatform, +function createStage(host: RemoteHostPlatform, pool: string, index: number): string { + const reservation = runCommand(host, reserveRelayUploadStageCommand(host, pool, owner)) + expect(reservation.status, reservation.stderr).toBe(0) + return populateReservedStage(pool, index) +} + +function populateReservedStage( pool: string, index: number, - stageOwner = owner, + replacementOwner?: string, stale = false, state: 'slot' | 'claim' | 'delete' = 'slot' ): string { - const reservedOwner = /^\.sftp-namespace-[0-9a-f]{32}$/u.test(stageOwner) ? stageOwner : owner - const reservation = runCommand(host, reserveRelayUploadStageCommand(host, pool, reservedOwner)) - expect(reservation.status, reservation.stderr).toBe(0) const slot = join(pool, `slot-${index}`) const stage = join(pool, `${state}-${index}`) if (stage !== slot) { @@ -91,8 +94,8 @@ function createStage( renameSync(slot, stage) } const marker = join(stage, '.orca-upload-owner') - if (stageOwner !== reservedOwner) { - writeFileSync(marker, stageOwner) + if (replacementOwner !== undefined) { + writeFileSync(marker, replacementOwner) } writeFileSync(join(stage, 'payload', 'relay.js'), `relay-${index}`) if (stale) { @@ -102,16 +105,48 @@ function createStage( return stage } +function createStages(host: RemoteHostPlatform, pool: string, count: number): void { + if (host.commandDialect !== 'powershell' || count < 2) { + for (let index = 0; index < count; index += 1) { + createStage(host, pool, index) + } + return + } + const command = reserveRelayUploadStageCommand(host, pool, owner) + // Only fixture setup shares a process; each unmodified script gets a fresh local scope. + const batch = powerShellCommand( + [ + "$ErrorActionPreference = 'Stop'", + `$fixtureScript = ${powerShellLiteral(decodePowerShellCommand(command))}`, + '$reservation = [PowerShell]::Create()', + 'try {', + `foreach ($fixtureIndex in 1..${count}) {`, + '$reservation.Commands.Clear()', + '$reservation.Streams.Error.Clear()', + '$null = $reservation.AddScript($fixtureScript, $true)', + '$reservation.Invoke()', + 'if ($reservation.InvocationStateInfo.State -ne "Completed") { throw $reservation.InvocationStateInfo.Reason }', + '}', + '} finally { $reservation.Dispose() }' + ].join('\n') + ) + const result = runCommand(host, batch) + expect(result.status, result.stderr).toBe(0) + const reservations = result.stdout.trim().split(/\r?\n/u) + expect(reservations).toHaveLength(count) + for (const [index, output] of reservations.entries()) { + expect(parseReservedRelayUploadStage(host, pool, owner, output).slotName).toBe(`slot-${index}`) + populateReservedStage(pool, index) + } +} + afterEach(() => { for (const root of roots.splice(0)) { rmSync(root, { recursive: true, force: true }) } }) -// Why: every case spawns a real interpreter per command, and on non-Windows hosts the PowerShell -// path also forks `/usr/bin/stat` per file-identity lookup — the 8-entry reservation alone measured -// ~50s idle, nearly all of it process-spawn sys time, and the full suite multiplies that under CPU -// contention. Sized for spawn count, not the assertions, which run in microseconds. +// Allow for real interpreter startup and file-identity probes under full-suite contention. const SPAWNED_INTERPRETER_TIMEOUT_MS = 240_000 describe.each([ @@ -126,9 +161,7 @@ describe.each([ (_label, host) => { it.each([0, 1, 7, 8, 9])('bounds reservation with %i occupied entries', (count) => { const pool = createPool() - for (let index = 0; index < Math.min(count, RELAY_UPLOAD_STAGE_SLOT_COUNT); index += 1) { - createStage(host, pool, index) - } + createStages(host, pool, Math.min(count, RELAY_UPLOAD_STAGE_SLOT_COUNT)) if (count > RELAY_UPLOAD_STAGE_SLOT_COUNT) { mkdirSync(join(pool, 'foreign-extra')) } @@ -204,9 +237,9 @@ describe.each([ it('reclaims one stale owned stage but preserves fresh and foreign stages', () => { const pool = createPool() - createStage(host, pool, 0, owner, false) - createStage(host, pool, 1, '.foreign-owner', true) - createStage(host, pool, 2, owner, true) + createStages(host, pool, 3) + populateReservedStage(pool, 1, '.foreign-owner', true) + populateReservedStage(pool, 2, undefined, true) const result = runCommand(host, recoverOneStaleRelayUploadStageCommand(host, pool, 60)) @@ -218,8 +251,9 @@ describe.each([ it('drains fixed stale claim and delete states across repeated deployments', () => { const pool = createPool() - createStage(host, pool, 0, owner, true, 'claim') - createStage(host, pool, 1, owner, true, 'delete') + createStages(host, pool, 2) + populateReservedStage(pool, 0, undefined, true, 'claim') + populateReservedStage(pool, 1, undefined, true, 'delete') for (let attempt = 0; attempt < 2; attempt += 1) { const result = runCommand(host, recoverOneStaleRelayUploadStageCommand(host, pool, 60)) diff --git a/src/main/ssh/ssh-relay-versioned-install-termination.test.ts b/src/main/ssh/ssh-relay-versioned-install-termination.test.ts new file mode 100644 index 00000000000..7c550175133 --- /dev/null +++ b/src/main/ssh/ssh-relay-versioned-install-termination.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn() })) + +import type { SshConnection } from './ssh-connection' +import { REMOTE_INSTALL_MODELS } from './remote-install-model' +import { execCommand } from './ssh-relay-deploy-helpers' +import { + abandonInstall, + finalizeInstall, + isRemoteInstallComplete +} from './ssh-relay-versioned-install' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mocked execCommand never reads the connection. +const conn = {} as SshConnection +const mockExec = vi.mocked(execCommand) + +beforeEach(() => { + mockExec.mockReset() +}) + +describe.each([ + { platform: 'linux-x64' as const, remoteDir: '/relay/version' }, + { platform: 'win32-x64' as const, remoteDir: 'C:/relay/version' } +])('install-complete probe termination on $platform', ({ platform, remoteDir }) => { + const host = getRemoteHostPlatform(platform) + + it.each([abandonInstall, finalizeInstall])( + 'preserves uncertainty from lock release by %s', + async (release) => { + const error = Object.assign(new Error('release still running'), { + sshChannelCloseConfirmed: false + }) + mockExec.mockResolvedValue('') + if (release === finalizeInstall) { + mockExec.mockResolvedValueOnce('') + } + mockExec.mockRejectedValueOnce(error) + await expect(release(conn, remoteDir, host)).rejects.toBe(error) + } + ) + + it.each([abandonInstall, finalizeInstall])( + 'keeps confirmed release failures nonfatal in %s', + async (release) => { + if (release === finalizeInstall) { + mockExec.mockResolvedValueOnce('') + } + mockExec.mockRejectedValueOnce( + Object.assign(new Error('release failed'), { + sshChannelCloseConfirmed: true + }) + ) + await expect(release(conn, remoteDir, host)).resolves.toBeUndefined() + } + ) + + describe.each(REMOTE_INSTALL_MODELS)('$id installs', (model) => { + it.each([ + { aborted: false, rethrowSessionLimitErrors: false }, + { aborted: false, rethrowSessionLimitErrors: true }, + { aborted: true, rethrowSessionLimitErrors: false }, + { aborted: true, rethrowSessionLimitErrors: true } + ])( + 'preserves uncertainty with aborted=$aborted and strict=$rethrowSessionLimitErrors', + async ({ aborted, rethrowSessionLimitErrors }) => { + const controller = new AbortController() + const error = Object.assign(new Error('SSH teardown not confirmed'), { + sshChannelCloseConfirmed: false + }) + mockExec.mockImplementationOnce(async () => { + if (aborted) { + controller.abort(new Error('deploy aborted')) + } + throw error + }) + + await expect( + isRemoteInstallComplete(conn, model, remoteDir, host, { + signal: controller.signal, + rethrowSessionLimitErrors + }) + ).rejects.toBe(error) + expect(mockExec).toHaveBeenCalledTimes(1) + } + ) + + it('keeps a confirmed command failure as an incomplete install', async () => { + mockExec.mockRejectedValueOnce( + Object.assign(new Error('SSH command failed after closing'), { + sshChannelCloseConfirmed: true + }) + ) + + await expect(isRemoteInstallComplete(conn, model, remoteDir, host)).resolves.toBe(false) + }) + + it('keeps caller cancellation after a confirmed command failure', async () => { + const controller = new AbortController() + const abortError = new Error('deploy aborted') + mockExec.mockImplementationOnce(async () => { + controller.abort(abortError) + throw Object.assign(new Error('SSH command failed after closing'), { + sshChannelCloseConfirmed: true + }) + }) + + await expect( + isRemoteInstallComplete(conn, model, remoteDir, host, { signal: controller.signal }) + ).rejects.toBe(abortError) + }) + }) +}) diff --git a/src/main/ssh/ssh-relay-versioned-install.test.ts b/src/main/ssh/ssh-relay-versioned-install.test.ts index 482b0e8b113..1778ac1affc 100644 --- a/src/main/ssh/ssh-relay-versioned-install.test.ts +++ b/src/main/ssh/ssh-relay-versioned-install.test.ts @@ -1,11 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as DeployHelpers from './ssh-relay-deploy-helpers' vi.mock('fs', () => ({ existsSync: vi.fn(), readFileSync: vi.fn() })) -vi.mock('./ssh-relay-deploy-helpers', () => ({ +vi.mock('./ssh-relay-deploy-helpers', async (importOriginal) => ({ + ...(await importOriginal()), execCommand: vi.fn() })) @@ -909,7 +911,9 @@ describe('gcOldRelayVersions', () => { .mockResolvedValueOnce('OWNED') .mockRejectedValueOnce(unconfirmed) - await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + await expect( + gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + ).rejects.toBe(unconfirmed) const releaseCommands = mockExec.mock.calls .map(([, command]) => command) diff --git a/src/main/ssh/ssh-relay-versioned-install.ts b/src/main/ssh/ssh-relay-versioned-install.ts index 5bd111bdec1..ee62c3402b2 100644 --- a/src/main/ssh/ssh-relay-versioned-install.ts +++ b/src/main/ssh/ssh-relay-versioned-install.ts @@ -8,6 +8,7 @@ import { join } from 'node:path' import { existsSync, readFileSync } from 'node:fs' import type { SshConnection } from './ssh-connection' import { execCommand } from './ssh-relay-deploy-helpers' +import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' import { RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock' import { remoteInstallDirSegments } from './ssh-relay-install-namespace' import { RELAY_INSTALL_MODEL, type RemoteInstallModel } from './remote-install-model' @@ -133,6 +134,9 @@ export async function isRemoteInstallComplete( ) return probe.trim() === 'OK' } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } options?.signal?.throwIfAborted() if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { throw err @@ -160,7 +164,11 @@ export async function finalizeInstall( if (options?.releaseLock !== false) { await execHostCommand(conn, host, removeRemoteTreeCommand(host, lock), { signal: options?.signal - }).catch(() => {}) + }).catch((error) => { + if (isUnconfirmedSshCommandTermination(error)) { + throw error + } + }) } options?.signal?.throwIfAborted() } @@ -175,13 +183,17 @@ export async function abandonInstall( host: RemoteHostPlatform = DEFAULT_REMOTE_HOST ): Promise { const lock = joinRemotePath(host, remoteRelayDir, RELAY_INSTALL_LOCK_NAME) - await execHostCommand(conn, host, removeRemoteTreeCommand(host, lock)).catch(() => {}) + await execHostCommand(conn, host, removeRemoteTreeCommand(host, lock)).catch((error) => { + if (isUnconfirmedSshCommandTermination(error)) { + throw error + } + }) } /** * Garbage-collect old version directories: remove an idle, fully-installed, - * unlocked sibling version dir (never the current one). Best-effort — errors - * are swallowed so GC never blocks the user from connecting. + * unlocked sibling version dir (never the current one). Confirmed failures are + * best-effort; unconfirmed command termination stops later deployment commands. */ // Why re-exported rather than moved outright: deploy and the relay tests import the whole // versioned-install surface from here, and the split exists for file size, not to redraw an API. diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts index 53334e3cd12..811917cf715 100644 --- a/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts +++ b/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts @@ -129,6 +129,28 @@ describe('buildHostCliEnv', () => { expect(env.ORCA_CLI_COMMAND).toBe('orca') }) + it('never lets a remote command claim a local agent session', () => { + // The host's env carries a session id when Orca was launched inside a structured session; the + // remote shell's own is from another machine. Session identity is same-host only. + const env = buildHostCliEnv({ + hostEnv: { + ORCA_AGENT_SESSION_ID: 'f7a1c0de-1111-4222-8333-444455556666', + ORCA_STRUCTURED_SESSION: '1' + }, + remoteEnv: { + ORCA_TERMINAL_HANDLE: 'term_remote', + ORCA_AGENT_SESSION_ID: 'a0b1c2d3-0000-4000-8000-00000000abcd' + }, + userDataPath: '/host/user-data', + remoteCwd: '/srv/repo' + }) + + expect(env.ORCA_AGENT_SESSION_ID).toBeUndefined() + expect(env.ORCA_STRUCTURED_SESSION).toBeUndefined() + // The remote command still speaks as its own terminal. + expect(env.ORCA_TERMINAL_HANDLE).toBe('term_remote') + }) + it('namespaces identical remote artifact paths by stable SSH target', () => { const artifactInput = { sourceKey: '/srv/repo/report.html', diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.ts index d62a23d8e30..4490343504f 100644 --- a/src/main/ssh/ssh-remote-cli-host-passthrough.ts +++ b/src/main/ssh/ssh-remote-cli-host-passthrough.ts @@ -13,6 +13,8 @@ import { ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV, ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV } from '../../shared/orchestration-compatibility-evidence' +import { ORCA_AGENT_SESSION_ID_ENV } from '../../shared/agent-session-caller-env' +import { ORCA_STRUCTURED_SESSION_ENV } from '../../shared/structured-session-marker' import { REMOTE_ARTIFACT_INPUT_ENV, sshArtifactSourceKey, @@ -131,6 +133,10 @@ export function buildHostCliEnv(args: { delete env[ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV] delete env[ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV] delete env[REMOTE_ARTIFACT_INPUT_ENV] + // Why: a remote command must never claim a local agent session. The host's env carries one only + // when Orca was launched inside a session, and identity by session id is same-host only. + delete env[ORCA_AGENT_SESSION_ID_ENV] + delete env[ORCA_STRUCTURED_SESSION_ENV] if (args.runtimeAuthority) { env[ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV] = 'ssh' env[ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV] = args.runtimeAuthority.targetId diff --git a/src/main/ssh/ssh-remote-commands.test.ts b/src/main/ssh/ssh-remote-commands.test.ts index 363a87a645d..758d4739b0f 100644 --- a/src/main/ssh/ssh-remote-commands.test.ts +++ b/src/main/ssh/ssh-remote-commands.test.ts @@ -20,13 +20,11 @@ import { tryStealInstallLockCommand } from './ssh-relay-install-lock-commands' import { - commandInRemoteDirectory, commandWithNodePath, listRelayBaseDirsCommand, MAX_RELAY_GC_LISTING_ENTRIES, makeRemoteDirectoryCommand, moveRemoteTreeCommand, - promoteRemoteTreeContentsCommand, probeDirectoryExistsCommand, probeRelayInstalledCommand, readRemoteHomeCommand, @@ -229,29 +227,6 @@ describe('ssh remote command builders', () => { expect(windowsScript).toContain("'MOVED'") }) - it('enumerates Windows staging children before copying', () => { - const script = decodePowerShellCommand( - promoteRemoteTreeContentsCommand(windows, 'C:/Users/me/relay.upload-123', 'C:/Users/me/relay') - ) - expect(script).toContain('Get-ChildItem -LiteralPath') - expect(script).toContain(' -Force -ErrorAction Stop | Copy-Item -Destination') - expect(script).not.toContain('Copy-Item -LiteralPath') - expect(script).toContain('Remove-Item -LiteralPath') - expect(script).toContain("$ErrorActionPreference = 'Stop'") - expect(script).toContain('Copy-Item -Destination') - }) - - it('removes POSIX staging only after the copy succeeds', () => { - const command = promoteRemoteTreeContentsCommand( - posix, - '/home/u/relay.upload-123', - '/home/u/relay' - ) - expect(command).toContain("cp -a '/home/u/relay.upload-123'/. '/home/u/relay'/") - expect(command).toContain("&& rm -rf '/home/u/relay.upload-123'") - expect(command.indexOf('cp -a')).toBeLessThan(command.indexOf('rm -rf')) - }) - it('emits an explicit POSIX liveness result so GC can fail closed', () => { const command = relayLivenessProbeCommand(posix, '/home/u/.orca-remote/relay-0.1.0') @@ -659,9 +634,6 @@ describe('ssh remote command builders', () => { ) it('makes Windows remote directory changes fail before running scoped commands', () => { - const scopedCommand = decodePowerShellCommand( - commandInRemoteDirectory(windows, 'C:/Users/me/.orca-remote/relay-0.1.0', "'READY'") - ) const nodeScopedCommand = decodePowerShellCommand( commandWithNodePath( windows, @@ -671,9 +643,6 @@ describe('ssh remote command builders', () => { ) ) - expect(scopedCommand).toContain( - "Set-Location -ErrorAction Stop -LiteralPath 'C:/Users/me/.orca-remote/relay-0.1.0'" - ) expect(nodeScopedCommand).toContain( "Set-Location -ErrorAction Stop -LiteralPath 'C:/Users/me/.orca-remote/relay-0.1.0'" ) diff --git a/src/main/ssh/ssh-remote-commands.ts b/src/main/ssh/ssh-remote-commands.ts index fc3e965193e..11e39921fc2 100644 --- a/src/main/ssh/ssh-remote-commands.ts +++ b/src/main/ssh/ssh-remote-commands.ts @@ -72,17 +72,18 @@ export function moveRemoteTreeCommand( ) } -export function promoteRemoteTreeContentsCommand( +// A concurrent installer may already have recreated the original directory. +export function restoreRemoteTreeCommand( host: RemoteHostPlatform, - sourcePath: string, - destinationPath: string + source: string, + destination: string ): string { - if (!isWindowsRemoteHost(host)) { - return `cp -a ${shellEscape(sourcePath)}/. ${shellEscape(destinationPath)}/ && rm -rf ${shellEscape(sourcePath)}` + if (isWindowsRemoteHost(host)) { + return powerShellCommand( + `if (-not (Test-Path -LiteralPath ${powerShellLiteral(destination)})) { Move-Item -LiteralPath ${powerShellLiteral(source)} -Destination ${powerShellLiteral(destination)} -ErrorAction Stop; 'MOVED' } else { 'BUSY' }` + ) } - return powerShellCommand( - `$ErrorActionPreference = 'Stop'; Get-ChildItem -LiteralPath ${powerShellLiteral(sourcePath)} -Force -ErrorAction Stop | Copy-Item -Destination ${powerShellLiteral(destinationPath)} -Recurse -Force -ErrorAction Stop; Remove-Item -LiteralPath ${powerShellLiteral(sourcePath)} -Recurse -Force -ErrorAction Stop` - ) + return `if [ ! -e ${shellEscape(destination)} ] && [ ! -L ${shellEscape(destination)} ]; then mv ${shellEscape(source)} ${shellEscape(destination)} && echo MOVED; else echo BUSY; fi` } export function writeRemoteEmptyFileCommand(host: RemoteHostPlatform, remotePath: string): string { @@ -263,19 +264,6 @@ export function relayLivenessProbeCommand( ) } -export function commandInRemoteDirectory( - host: RemoteHostPlatform, - remoteDir: string, - command: string -): string { - if (!isWindowsRemoteHost(host)) { - return `cd ${shellEscape(remoteDir)} && ${command}` - } - return powerShellCommand( - `Set-Location -ErrorAction Stop -LiteralPath ${powerShellLiteral(remoteDir)}; ${command}` - ) -} - export function commandWithNodePath( host: RemoteHostPlatform, nodePath: string, diff --git a/src/main/ssh/ssh-remote-orca-cli.test.ts b/src/main/ssh/ssh-remote-orca-cli.test.ts index 276d9cad87c..637a0c55ce3 100644 --- a/src/main/ssh/ssh-remote-orca-cli.test.ts +++ b/src/main/ssh/ssh-remote-orca-cli.test.ts @@ -84,6 +84,7 @@ describe('runRemoteOrcaCli', () => { getActiveDispatchForIdentity: vi.fn(() => undefined), getActiveDispatchMailboxOwners: vi.fn(() => []), getCurrentRunForPane: vi.fn(() => undefined), + getCurrentRunForCoordinator: vi.fn(() => undefined), getRunMailboxOwnerIdsForHandle: vi.fn(() => []), findActiveRemoteAttachmentForPane: vi.fn(() => undefined) } @@ -584,7 +585,9 @@ describe('runRemoteOrcaCli', () => { ) expect(result.exitCode).toBe(0) - expect(db.getCurrentRunForPane).toHaveBeenCalledWith('tab_ssh:leaf_ssh') + expect(db.getCurrentRunForCoordinator).toHaveBeenCalledWith( + expect.objectContaining({ paneKey: 'tab_ssh:leaf_ssh' }) + ) expect(db.getActiveDispatchForIdentity).toHaveBeenCalledWith( 'term_stale_ssh', 'tab_ssh:leaf_ssh' @@ -608,7 +611,7 @@ describe('runRemoteOrcaCli', () => { ) expect(result.exitCode).toBe(0) - expect(db.getCurrentRunForPane).not.toHaveBeenCalled() + expect(db.getCurrentRunForCoordinator).not.toHaveBeenCalled() expect(db.getActiveDispatchForIdentity).toHaveBeenCalledWith('term_legacy_worker', undefined) }) diff --git a/src/main/ssh/ssh-system-fallback.test.ts b/src/main/ssh/ssh-system-fallback.test.ts index b477ad682ef..3212a44b6fb 100644 --- a/src/main/ssh/ssh-system-fallback.test.ts +++ b/src/main/ssh/ssh-system-fallback.test.ts @@ -880,14 +880,6 @@ describe('spawnSystemSsh', () => { vi.stubEnv('PATH', '') expect(() => spawnSystemSsh(createTarget())).toThrow('No system ssh binary found') }) - - it('returns a process wrapper with kill and onExit', () => { - const result = spawnSystemSsh(createTarget()) - - expect(result.pid).toBe(12345) - expect(typeof result.kill).toBe('function') - expect(typeof result.onExit).toBe('function') - }) }) describe('system SSH operation aborts', () => { diff --git a/src/main/startup/browser-process-user-agent-ordering.test.ts b/src/main/startup/browser-process-user-agent-ordering.test.ts index 5913890b5cf..ee14f1a8d3e 100644 --- a/src/main/startup/browser-process-user-agent-ordering.test.ts +++ b/src/main/startup/browser-process-user-agent-ordering.test.ts @@ -26,19 +26,36 @@ const mocks = vi.hoisted(() => { events.push('is-ready') return false }), + whenReady: vi.fn(() => Promise.resolve()), setName: vi.fn((name: string) => { events.push(`set-name:${name}`) }) } - return { app, events, userAgent: () => userAgent } + return { + app, + events, + userAgent: () => userAgent, + showErrorBox: vi.fn(), + backgroundLaunch: vi.fn(() => true), + admission: vi.fn(), + lock: vi.fn(() => true), + afterIdentity: vi.fn((): void => { + throw new Error('preflight-test-stop') + }), + recoverMoves: vi.fn() + } }) vi.mock('electron', () => ({ app: mocks.app, + dialog: { showErrorBox: mocks.showErrorBox }, ipcMain: {}, powerMonitor: {}, session: { defaultSession: {} } })) +vi.mock('../window/foreground-activation-policy', () => ({ + isBackgroundLaunch: mocks.backgroundLaunch +})) vi.mock('@electron-toolkit/utils', () => ({ is: { dev: true } })) vi.mock('./cli-launch-redirect', () => ({ maybeRedirectCliLaunch: () => ({ redirected: false, status: 0 }) @@ -83,10 +100,7 @@ vi.mock('./dev-instance-identity', () => ({ })) vi.mock('./renderer-heap-headroom') vi.mock('./startup-diagnostics', () => ({ - isStartupDiagnosticsEnabled: () => { - mocks.events.push('continued-after-browser-identity') - throw new Error('preflight-test-stop') - }, + isStartupDiagnosticsEnabled: () => false, logStartupDiagnostic: vi.fn() })) vi.mock('./event-loop-stall-probe') @@ -100,8 +114,11 @@ vi.mock('./serve-desktop-activation', () => ({ })) vi.mock('./single-instance-lock', () => ({ shouldBypassSingleInstanceLock: () => false, - shouldSkipSingleInstanceLock: () => true, - acquireSingleInstanceLock: vi.fn(), + shouldSkipSingleInstanceLock: () => false, + acquireSingleInstanceLock: () => { + mocks.events.push('single-instance-lock') + return mocks.lock() + }, logSingleInstanceLockBypass: vi.fn(), logSingleInstanceLockFailure: vi.fn(), SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE: 1 @@ -109,7 +126,12 @@ vi.mock('./single-instance-lock', () => ({ vi.mock('../../shared/app-environment', () => ({ setAppEnvironment: vi.fn() })) vi.mock('../host/electron-app-environment', () => ({ ElectronAppEnvironment: class {} })) vi.mock('../own-chromium-tree-kill-guard') -vi.mock('../../shared/secret-store') +vi.mock('../../shared/secret-store', () => ({ + setSecretStore: () => { + mocks.events.push('continued-after-browser-identity') + mocks.afterIdentity() + } +})) vi.mock('../host/electron-secret-store') vi.mock('../ipc/pty-host-bindings') vi.mock('../host/electron-runtime-desktop-surface') @@ -127,9 +149,24 @@ vi.mock('../persistence', () => ({ initDataPath: () => mocks.events.push('init-data-path'), getCanonicalUserDataPath: () => '/canonical-user-data' })) +vi.mock('../persistence/profile-state/profile-state-access', () => ({ + acquireProfileStateRuntimeAdmission: (root: string) => { + mocks.events.push(`admission:${root}`) + return mocks.admission() + } +})) vi.mock('../macos-press-and-hold-default') vi.mock('../ai-vault/session-parse-cache-persistence') -vi.mock('../orca-profiles/profile-index-store') +vi.mock('../orca-profiles/profile-index-store', () => ({ initOrcaProfilePaths: vi.fn() })) +vi.mock('../orca-profiles/profile-storage-paths', () => ({ + getProfileUserDataPath: () => '/canonical-user-data' +})) +vi.mock('../orca-profiles/profile-project-move-intent', () => ({ + recoverPendingProfileProjectMoves: mocks.recoverMoves +})) +vi.mock('../persistence/profile-state/profile-state-active-location', () => ({ + getActiveProfileStateLocation: () => ({ profileId: 'active-profile' }) +})) vi.mock('../stats/collector') vi.mock('../claude-usage/store') vi.mock('../codex-usage/store') @@ -163,23 +200,65 @@ vi.mock('../browser/browser-identity-mode-store', () => ({ })) describe('browser process user-agent startup ordering', () => { + it('explains admission refusal before a desktop launch exits', async () => { + const { runMainProcessPreflight } = await import('./main-process-preflight') + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + mocks.backgroundLaunch.mockReturnValueOnce(false) + mocks.admission.mockImplementationOnce(() => { + throw new Error('Stop Orca and orcad before retrying profile recovery') + }) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + expect( + runMainProcessPreflight({ focusExistingWindow: vi.fn(), requestDesktopActivation: vi.fn() }) + ).toBe(false) + expect(mocks.showErrorBox).toHaveBeenCalledWith( + 'Orca could not start', + expect.stringContaining('Stop Orca and orcad before retrying profile recovery') + ) + expect(mocks.app.isReady).not.toHaveBeenCalled() + } finally { + error.mockRestore() + platform.mockRestore() + mocks.showErrorBox.mockClear() + mocks.events.length = 0 + } + }) + + it('does not acquire profile admission for a duplicate launch', async () => { + const { runMainProcessPreflight } = await import('./main-process-preflight') + mocks.events.length = 0 + mocks.lock.mockReturnValueOnce(false) + expect( + runMainProcessPreflight({ focusExistingWindow: vi.fn(), requestDesktopActivation: vi.fn() }) + ).toBe(false) + expect(mocks.events).not.toContain('admission:/canonical-user-data') + expect(mocks.events).not.toContain('read-mode:/canonical-user-data') + expect(mocks.app.exit).toHaveBeenCalledWith(1) + mocks.events.length = 0 + }) + it('executes after the dev app name and before later preflight work', async () => { const { getBrowserProcessUserAgentIdentity } = await import('../browser/browser-process-user-agent') const { runMainProcessPreflight } = await import('./main-process-preflight') - expect(() => + expect( runMainProcessPreflight({ focusExistingWindow: vi.fn(), requestDesktopActivation: vi.fn() }) - ).toThrow('preflight-test-stop') + ).toBe(false) const nameIndex = mocks.events.indexOf('set-name:Orca Development') const modeIndex = mocks.events.indexOf('read-mode:/canonical-user-data') const writeIndex = mocks.events.indexOf('write-user-agent') const continuationIndex = mocks.events.indexOf('continued-after-browser-identity') expect(mocks.events.indexOf('init-data-path')).toBeLessThan(nameIndex) + expect(mocks.events.indexOf('init-data-path')).toBeLessThan( + mocks.events.indexOf('admission:/canonical-user-data') + ) + expect(mocks.events.indexOf('admission:/canonical-user-data')).toBeLessThan(modeIndex) expect(nameIndex).toBeLessThan(modeIndex) expect(modeIndex).toBeLessThan(writeIndex) expect(writeIndex).toBeLessThan(continuationIndex) @@ -191,4 +270,193 @@ describe('browser process user-agent startup ordering', () => { expect(mocks.userAgent()).not.toMatch(/Electron/) expect(mocks.userAgent()).not.toMatch(/Orca|Development/) }) + + it('exits without reading profile state or revealing a window when recovery holds admission', async () => { + const { runMainProcessPreflight } = await import('./main-process-preflight') + mocks.events.length = 0 + mocks.admission.mockImplementationOnce(() => { + throw new Error('Profile recovery is in progress') + }) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const focusExistingWindow = vi.fn() + const requestDesktopActivation = vi.fn() + try { + expect(runMainProcessPreflight({ focusExistingWindow, requestDesktopActivation })).toBe(false) + expect(mocks.app.exit).toHaveBeenCalledWith(1) + expect(mocks.events).toEqual([ + 'init-data-path', + 'set-name:Orca Development', + 'single-instance-lock', + 'admission:/canonical-user-data' + ]) + expect(focusExistingWindow).not.toHaveBeenCalled() + expect(requestDesktopActivation).not.toHaveBeenCalled() + expect(mocks.showErrorBox).not.toHaveBeenCalled() + } finally { + error.mockRestore() + } + }) +}) + +it('exits and releases admission after pending profile move recovery fails', async () => { + const { resetBrowserProcessUserAgentForTests } = + await import('../browser/browser-process-user-agent') + resetBrowserProcessUserAgentForTests() + const release = vi.fn() + mocks.admission.mockReturnValueOnce({ release }) + mocks.afterIdentity.mockImplementationOnce(() => undefined) + mocks.recoverMoves.mockImplementationOnce(() => { + throw new Error('unreadable move journal') + }) + const { runMainProcessPreflight } = await import('./main-process-preflight') + expect( + runMainProcessPreflight({ focusExistingWindow: vi.fn(), requestDesktopActivation: vi.fn() }) + ).toBe(false) + expect(mocks.recoverMoves).toHaveBeenCalledWith('/canonical-user-data', 'active-profile') + expect(release).toHaveBeenCalledOnce() + expect(mocks.app.exit).toHaveBeenCalledWith(1) +}) + +it('defers a Linux desktop startup failure until Electron is ready', async () => { + const { runMainProcessPreflight } = await import('./main-process-preflight') + const { resetBrowserProcessUserAgentForTests } = + await import('../browser/browser-process-user-agent') + resetBrowserProcessUserAgentForTests() + const release = vi.fn() + let resolveReady: (() => void) | undefined + const ready = new Promise((resolve) => { + resolveReady = resolve + }) + mocks.admission.mockReturnValueOnce({ release }) + mocks.afterIdentity.mockImplementationOnce(() => { + throw new Error('Linux pre-ready failure') + }) + mocks.app.whenReady.mockReturnValueOnce(ready) + mocks.app.exit.mockClear() + mocks.showErrorBox.mockClear() + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + mocks.backgroundLaunch.mockReturnValueOnce(false) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + expect( + runMainProcessPreflight({ focusExistingWindow: vi.fn(), requestDesktopActivation: vi.fn() }) + ).toBe(false) + expect(release).toHaveBeenCalledOnce() + expect(mocks.app.whenReady).toHaveBeenCalledOnce() + expect(mocks.showErrorBox).not.toHaveBeenCalled() + expect(mocks.app.exit).not.toHaveBeenCalled() + + resolveReady?.() + await ready + await Promise.resolve() + expect(mocks.showErrorBox).toHaveBeenCalledWith( + 'Orca could not start', + expect.stringContaining('Linux pre-ready failure') + ) + expect(mocks.app.exit).toHaveBeenCalledWith(1) + } finally { + error.mockRestore() + platform.mockRestore() + mocks.app.whenReady.mockClear() + } +}) + +it('exits after a Linux desktop readiness rejection without showing a dialog', async () => { + const { runMainProcessPreflight } = await import('./main-process-preflight') + const { resetBrowserProcessUserAgentForTests } = + await import('../browser/browser-process-user-agent') + resetBrowserProcessUserAgentForTests() + const release = vi.fn() + let rejectReady!: (error: Error) => void + const ready = new Promise((_resolve, reject) => { + rejectReady = reject + }) + mocks.admission.mockReturnValueOnce({ release }) + mocks.afterIdentity.mockImplementationOnce(() => { + throw new Error('Linux pre-ready failure') + }) + mocks.app.whenReady.mockReturnValueOnce(ready) + mocks.app.exit.mockClear() + mocks.showErrorBox.mockClear() + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + mocks.backgroundLaunch.mockReturnValueOnce(false) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + expect( + runMainProcessPreflight({ focusExistingWindow: vi.fn(), requestDesktopActivation: vi.fn() }) + ).toBe(false) + expect(release).toHaveBeenCalledOnce() + rejectReady(new Error('Electron readiness failed')) + await ready.catch(() => undefined) + await Promise.resolve() + expect(mocks.showErrorBox).not.toHaveBeenCalled() + expect(mocks.app.exit).toHaveBeenCalledWith(1) + } finally { + error.mockRestore() + platform.mockRestore() + mocks.app.whenReady.mockClear() + } +}) + +it('keeps Linux background startup failures console-only and immediate', async () => { + const { runMainProcessPreflight } = await import('./main-process-preflight') + const { resetBrowserProcessUserAgentForTests } = + await import('../browser/browser-process-user-agent') + resetBrowserProcessUserAgentForTests() + const release = vi.fn() + mocks.admission.mockReturnValueOnce({ release }) + mocks.afterIdentity.mockImplementationOnce(() => { + throw new Error('Linux background failure') + }) + mocks.app.whenReady.mockClear() + mocks.app.exit.mockClear() + mocks.showErrorBox.mockClear() + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + mocks.backgroundLaunch.mockReturnValueOnce(true) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + expect( + runMainProcessPreflight({ focusExistingWindow: vi.fn(), requestDesktopActivation: vi.fn() }) + ).toBe(false) + expect(release).toHaveBeenCalledOnce() + expect(mocks.app.whenReady).not.toHaveBeenCalled() + expect(mocks.showErrorBox).not.toHaveBeenCalled() + expect(mocks.app.exit).toHaveBeenCalledWith(1) + } finally { + error.mockRestore() + platform.mockRestore() + } +}) + +it('keeps Linux serve startup failures console-only and immediate', async () => { + const { runMainProcessPreflight } = await import('./main-process-preflight') + const { resetBrowserProcessUserAgentForTests } = + await import('../browser/browser-process-user-agent') + resetBrowserProcessUserAgentForTests() + const release = vi.fn() + const originalArgv = process.argv + process.argv = originalArgv.includes('--serve') ? [...originalArgv] : [...originalArgv, '--serve'] + mocks.admission.mockReturnValueOnce({ release }) + mocks.afterIdentity.mockImplementationOnce(() => { + throw new Error('Linux serve failure') + }) + mocks.app.whenReady.mockClear() + mocks.app.exit.mockClear() + mocks.showErrorBox.mockClear() + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + mocks.backgroundLaunch.mockReturnValueOnce(false) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + expect( + runMainProcessPreflight({ focusExistingWindow: vi.fn(), requestDesktopActivation: vi.fn() }) + ).toBe(false) + expect(release).toHaveBeenCalledOnce() + expect(mocks.app.whenReady).not.toHaveBeenCalled() + expect(mocks.showErrorBox).not.toHaveBeenCalled() + expect(mocks.app.exit).toHaveBeenCalledWith(1) + } finally { + error.mockRestore() + platform.mockRestore() + process.argv = originalArgv + } }) diff --git a/src/main/startup/cli-command-names.ts b/src/main/startup/cli-command-names.ts index 0c001b74f44..22d90947ced 100644 --- a/src/main/startup/cli-command-names.ts +++ b/src/main/startup/cli-command-names.ts @@ -48,6 +48,7 @@ export const CLI_COMMAND_NAMES = [ 'open-url', 'orchestration', 'pdf', + 'profile', 'project', 'reload', 'repo', diff --git a/src/main/startup/codex-launch-per-agent-hook-opt-out.test.ts b/src/main/startup/codex-launch-per-agent-hook-opt-out.test.ts new file mode 100644 index 00000000000..9191fb1666b --- /dev/null +++ b/src/main/startup/codex-launch-per-agent-hook-opt-out.test.ts @@ -0,0 +1,189 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { VerifiedCodexResumeSource } from '../codex/codex-session-resume-preparation' + +/** + * Turning Codex off in the per-agent hook settings removes Orca's Codex hook + * entry. Launch prep and session resume must read that same opt-out, or the + * next Codex launch writes the entry straight back. + */ +const SYSTEM_HOME = '/home/user/.codex' +const ACCOUNT_HOME = '/accounts/one/.codex' + +const mocks = vi.hoisted(() => { + const settings: Partial = {} + return { + settings, + prepareForCodexLaunchAsync: vi.fn(async (): Promise => null), + isHostSystemDefaultRealHomeSelected: vi.fn(() => false), + prepareRuntimeHomeForLaunch: vi.fn(async () => ({ state: 'ok' as const })), + installForLaunchPrep: vi.fn(async () => {}), + refreshRuntimeUserHooksForLaunchPrep: vi.fn(async () => {}), + ensureRealHomeCodexHookState: vi.fn(async () => 'installed' as const), + prepareCodexSessionResume: vi.fn() + } +}) + +vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp/orca-user-data') } })) +vi.mock('../agent-trust-presets', () => ({ markCodexProjectTrusted: vi.fn(async () => {}) })) +vi.mock('../codex/hook-service', () => ({ + codexHookService: { + prepareRuntimeHomeForLaunch: mocks.prepareRuntimeHomeForLaunch, + installForLaunchPrep: mocks.installForLaunchPrep, + refreshRuntimeUserHooksForLaunchPrep: mocks.refreshRuntimeUserHooksForLaunchPrep + } +})) +vi.mock('../codex/codex-real-home-hook-install', () => ({ + ensureRealHomeCodexHookState: mocks.ensureRealHomeCodexHookState +})) +// Why: the real predicate, without loading every agent's hook service. +vi.mock( + '../agent-hooks/managed-agent-hook-controls', + async () => await import('../../shared/agent-status-hooks-setting') +) +vi.mock('../wsl', () => ({ getDefaultWslDistro: () => 'Ubuntu' })) +vi.mock('../codex/codex-home-paths', () => ({ + getSystemCodexHomePath: () => SYSTEM_HOME, + getOrcaManagedCodexHomePath: () => '/managed/.codex' +})) +vi.mock('../codex/codex-session-resume-preparation', () => ({ + prepareCodexSessionResume: mocks.prepareCodexSessionResume +})) +vi.mock('../codex/codex-legacy-session-resume', () => ({ + prepareLegacySharedCodexSessionResume: vi.fn(async () => ({ useRealCodexHome: false })) +})) +vi.mock('./main-process-state', () => ({ + mainProcessState: { + codexRuntimeHome: { + prepareForCodexLaunchAsync: mocks.prepareForCodexLaunchAsync, + isHostSystemDefaultRealHomeSelected: mocks.isHostSystemDefaultRealHomeSelected, + isHostSystemDefaultRealHome: () => false, + getHostCodexHomePathsForSessionDiscovery: () => [], + resolveSelectedHostAccountCodexHomePathForResume: () => null + }, + store: { getSettings: () => mocks.settings } + } +})) + +import { prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation' +import { prepareCodexSessionResumeForLaunch } from './codex-session-resume-launch' + +const HOOK_SETTINGS: readonly { + name: string + settings: Partial + codexHooksOn: boolean +}[] = [ + { + name: 'Codex turned off per agent', + settings: { agentStatusHooksEnabled: true, disabledTuiAgents: ['codex'] }, + codexHooksOn: false + }, + { + name: 'every agent on', + settings: { agentStatusHooksEnabled: true, disabledTuiAgents: [] }, + codexHooksOn: true + }, + { + name: 'another agent turned off', + settings: { agentStatusHooksEnabled: true, disabledTuiAgents: ['claude'] }, + codexHooksOn: true + }, + { + name: 'the global switch off', + settings: { agentStatusHooksEnabled: false, disabledTuiAgents: [] }, + codexHooksOn: false + } +] + +function resumeFrom(homePath: string): Promise { + mocks.prepareCodexSessionResume.mockImplementation( + async (args: { + resolveVerifiedResumeHome: (source: VerifiedCodexResumeSource) => Promise + }) => { + const codexHomePath = await args.resolveVerifiedResumeHome({ + homePath, + transcriptPath: `${homePath}/sessions/abc.jsonl` + }) + return { outcome: 'resume' as const, codexHomePath, sessionId: 'abc' } + } + ) + return prepareCodexSessionResumeForLaunch({ + providerSession: { key: 'session_id', id: 'abc' }, + target: { runtime: 'host' } + }) +} + +describe('Codex launch prep honours the per-agent hook opt-out', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.isHostSystemDefaultRealHomeSelected.mockReturnValue(false) + mocks.prepareForCodexLaunchAsync.mockResolvedValue(null) + }) + + it.each(HOOK_SETTINGS)( + 'real ~/.codex launch with $name: hooks on = $codexHooksOn', + async ({ settings, codexHooksOn }) => { + mocks.settings = settings + mocks.isHostSystemDefaultRealHomeSelected.mockReturnValue(true) + + await expect(prepareCodexRuntimeHomeForLaunch()).resolves.toBeNull() + + expect(mocks.ensureRealHomeCodexHookState).toHaveBeenCalledTimes(1) + expect(mocks.ensureRealHomeCodexHookState).toHaveBeenCalledWith( + expect.objectContaining({ hooksEnabled: codexHooksOn }) + ) + expect(mocks.prepareRuntimeHomeForLaunch).not.toHaveBeenCalled() + } + ) + + it.each(HOOK_SETTINGS)( + 'managed account home launch with $name: hooks on = $codexHooksOn', + async ({ settings, codexHooksOn }) => { + mocks.settings = settings + mocks.prepareForCodexLaunchAsync.mockResolvedValue(ACCOUNT_HOME) + + await expect(prepareCodexRuntimeHomeForLaunch()).resolves.toBe(ACCOUNT_HOME) + + expect(mocks.ensureRealHomeCodexHookState).not.toHaveBeenCalled() + expect(mocks.prepareRuntimeHomeForLaunch).toHaveBeenCalledWith( + ACCOUNT_HOME, + undefined, + codexHooksOn + ) + } + ) + + it.each(HOOK_SETTINGS)( + 'resume into the real ~/.codex with $name: hooks on = $codexHooksOn', + async ({ settings, codexHooksOn }) => { + mocks.settings = settings + + await resumeFrom(SYSTEM_HOME) + + expect(mocks.ensureRealHomeCodexHookState).toHaveBeenCalledTimes(1) + expect(mocks.ensureRealHomeCodexHookState).toHaveBeenCalledWith( + expect.objectContaining({ hooksEnabled: codexHooksOn }) + ) + expect(mocks.installForLaunchPrep).not.toHaveBeenCalled() + expect(mocks.refreshRuntimeUserHooksForLaunchPrep).not.toHaveBeenCalled() + } + ) + + it.each(HOOK_SETTINGS)( + 'resume into a managed account home with $name: hooks on = $codexHooksOn', + async ({ settings, codexHooksOn }) => { + mocks.settings = settings + + await resumeFrom(ACCOUNT_HOME) + + expect(mocks.ensureRealHomeCodexHookState).not.toHaveBeenCalled() + if (codexHooksOn) { + expect(mocks.installForLaunchPrep).toHaveBeenCalledWith(ACCOUNT_HOME) + expect(mocks.refreshRuntimeUserHooksForLaunchPrep).not.toHaveBeenCalled() + } else { + expect(mocks.installForLaunchPrep).not.toHaveBeenCalled() + expect(mocks.refreshRuntimeUserHooksForLaunchPrep).toHaveBeenCalledWith(ACCOUNT_HOME) + } + } + ) +}) diff --git a/src/main/startup/codex-launch-preparation.ts b/src/main/startup/codex-launch-preparation.ts index 3b94c00b11d..06e61c18836 100644 --- a/src/main/startup/codex-launch-preparation.ts +++ b/src/main/startup/codex-launch-preparation.ts @@ -1,10 +1,9 @@ import { app } from 'electron' import type { CodexHomeLaunchContext } from '../ipc/pty' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' -import { markCodexProjectTrusted } from '../agent-trust-presets' import { codexHookService } from '../codex/hook-service' import { getDefaultWslDistro } from '../wsl' -import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { isAgentStatusHooksEnabledForAgent } from '../agent-hooks/managed-agent-hook-controls' import { ensureRealHomeCodexHookState } from '../codex/codex-real-home-hook-install' import { mainProcessState as state } from './main-process-state' @@ -17,18 +16,6 @@ export async function prepareCodexRuntimeHomeForLaunch( if (!runtimeHome) { throw new Error('Codex runtime home service is not initialized') } - if ( - target?.runtime !== 'wsl' && - launchContext?.launchAgent === 'codex' && - launchContext.workspacePath - ) { - try { - // Why: renderer quick-launch cannot await trust IPC before its PTY mounts; launch prep runs before every recognized Codex spawn. - await markCodexProjectTrusted(launchContext.workspacePath) - } catch (error) { - console.warn('[codex-project-trust] failed to pre-mark launch workspace:', error) - } - } const ensureRealHomeHooksIfSelected = async (): Promise => { if (target?.runtime === 'wsl' || !runtimeHome.isHostSystemDefaultRealHomeSelected(launchEnv)) { return false @@ -38,7 +25,7 @@ export async function prepareCodexRuntimeHomeForLaunch( // the pane spawns. An incapable grant flips the lane gate so the launch // below falls back to the managed home instead of a status-blind pane. await ensureRealHomeCodexHookState({ - hooksEnabled: isAgentStatusHooksEnabled(state.store?.getSettings()), + hooksEnabled: isAgentStatusHooksEnabledForAgent(state.store?.getSettings(), 'codex'), userDataPath: app.getPath('userData') }) return true @@ -70,7 +57,7 @@ export async function prepareCodexRuntimeHomeForLaunch( target?.runtime === 'wsl' ? { runtime: 'wsl' as const, wslDistro: target.wslDistro?.trim() || getDefaultWslDistro() } : target - const hooksEnabled = isAgentStatusHooksEnabled(state.store?.getSettings()) + const hooksEnabled = isAgentStatusHooksEnabledForAgent(state.store?.getSettings(), 'codex') try { // Why: honor the persisted off switch so post-startup launches can't reinstall removed hooks. const status = await codexHookService.prepareRuntimeHomeForLaunch( diff --git a/src/main/startup/codex-session-resume-daemon-guard.test.ts b/src/main/startup/codex-session-resume-daemon-guard.test.ts new file mode 100644 index 00000000000..4ac5b2fdabb --- /dev/null +++ b/src/main/startup/codex-session-resume-daemon-guard.test.ts @@ -0,0 +1,143 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { VerifiedCodexResumeSource } from '../codex/codex-session-resume-preparation' +import { + CODEX_DAEMON_OVERRIDE_MARKER, + codexDaemonSocketPathExceedsLimit +} from '../codex/codex-daemon-socket-path-guard' + +const mocks = vi.hoisted(() => ({ + hooksEnabled: false, + systemHomePath: '', + sharedHomePath: '', + installForLaunchPrep: vi.fn(), + refreshRuntimeUserHooksForLaunchPrep: vi.fn(), + ensureRealHomeCodexHookState: vi.fn(async () => {}), + prepareCodexSessionResume: vi.fn(), + prepareLegacySharedCodexSessionResume: vi.fn() +})) + +vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp/orca-user-data') } })) +vi.mock('../codex/hook-service', () => ({ + codexHookService: { + installForLaunchPrep: mocks.installForLaunchPrep, + refreshRuntimeUserHooksForLaunchPrep: mocks.refreshRuntimeUserHooksForLaunchPrep + } +})) +vi.mock('../codex/codex-real-home-hook-install', () => ({ + ensureRealHomeCodexHookState: mocks.ensureRealHomeCodexHookState +})) +vi.mock('../agent-hooks/managed-agent-hook-controls', () => ({ + isAgentStatusHooksEnabledForAgent: () => mocks.hooksEnabled +})) +vi.mock('../codex/codex-home-paths', async (importOriginal) => ({ + ...(await importOriginal()), + getSystemCodexHomePath: () => mocks.systemHomePath, + getOrcaManagedCodexHomePath: () => mocks.sharedHomePath +})) +vi.mock('../codex/codex-session-resume-preparation', () => ({ + prepareCodexSessionResume: mocks.prepareCodexSessionResume +})) +vi.mock('../codex/codex-legacy-session-resume', () => ({ + prepareLegacySharedCodexSessionResume: mocks.prepareLegacySharedCodexSessionResume +})) +vi.mock('./main-process-state', () => ({ + mainProcessState: { + codexRuntimeHome: { + isHostSystemDefaultRealHome: () => false, + getHostCodexHomePathsForSessionDiscovery: () => [], + resolveSelectedHostAccountCodexHomePathForResume: () => null + }, + store: { getSettings: () => ({}) } + } +})) + +import { prepareCodexSessionResumeForLaunch } from './codex-session-resume-launch' + +// Why: long enough that the daemon socket overflows sun_path on every host OS. +const LONG_SEGMENT = 'a'.repeat(60) + +describe('Codex session resume daemon socket guard', () => { + let root: string + let accountHome: string + let warn: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + root = mkdtempSync(join(tmpdir(), 'orca-resume-guard-')) + accountHome = join(root, 'codex-accounts', LONG_SEGMENT, 'home') + mocks.systemHomePath = join(root, 'system', LONG_SEGMENT, '.codex') + mocks.sharedHomePath = join(root, 'codex-runtime-home', 'home') + for (const home of [accountHome, mocks.systemHomePath, mocks.sharedHomePath]) { + mkdirSync(home, { recursive: true }) + } + mocks.hooksEnabled = false + mocks.prepareLegacySharedCodexSessionResume.mockResolvedValue({ useRealCodexHome: false }) + mocks.prepareCodexSessionResume.mockImplementation( + async (args: { + resolveVerifiedResumeHome: (source: VerifiedCodexResumeSource) => Promise + }) => ({ + outcome: 'resume' as const, + codexHomePath: await args.resolveVerifiedResumeHome({ + homePath: accountHome, + transcriptPath: join(accountHome, 'sessions', 'abc.jsonl') + }) + }) + ) + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warn.mockRestore() + rmSync(root, { recursive: true, force: true }) + }) + + function resume(): ReturnType { + return prepareCodexSessionResumeForLaunch({ + providerSession: { key: 'session_id', id: 'abc' }, + target: { runtime: 'host' } + }) + } + + it('guards the resumed account home when hook repair fails before mirroring config', async () => { + expect(codexDaemonSocketPathExceedsLimit(accountHome)).toBe(true) + writeFileSync(join(accountHome, 'config.toml'), 'model = "gpt-5"\n', 'utf-8') + mocks.hooksEnabled = true + mocks.installForLaunchPrep.mockRejectedValue(new Error('Could not parse Codex hooks.json')) + + const preparation = await resume() + + expect(preparation).toMatchObject({ outcome: 'resume', codexHomePath: accountHome }) + const config = readFileSync(join(accountHome, 'config.toml'), 'utf-8') + expect(config).toContain('model = "gpt-5"') + expect(config).toContain(`daemon_auto_start = false ${CODEX_DAEMON_OVERRIDE_MARKER}`) + }) + + it('guards the resumed account home when hooks are off and the refresh returns early', async () => { + mocks.refreshRuntimeUserHooksForLaunchPrep.mockResolvedValue({ + agent: 'codex', + state: 'error', + detail: 'Could not read system Codex hooks.json' + }) + + await resume() + + expect(mocks.refreshRuntimeUserHooksForLaunchPrep).toHaveBeenCalledWith(accountHome) + expect(readFileSync(join(accountHome, 'config.toml'), 'utf-8')).toContain( + `daemon_auto_start = false ${CODEX_DAEMON_OVERRIDE_MARKER}` + ) + }) + + it('never writes the guard into the real Codex home a migrated resume runs in', async () => { + expect(codexDaemonSocketPathExceedsLimit(mocks.systemHomePath)).toBe(true) + mocks.prepareLegacySharedCodexSessionResume.mockResolvedValue({ useRealCodexHome: true }) + + const preparation = await resume() + + expect(preparation).toMatchObject({ codexHomePath: mocks.systemHomePath }) + expect(mocks.ensureRealHomeCodexHookState).toHaveBeenCalledTimes(1) + expect(existsSync(join(mocks.systemHomePath, 'config.toml'))).toBe(false) + }) +}) diff --git a/src/main/startup/codex-session-resume-launch.ts b/src/main/startup/codex-session-resume-launch.ts index c537c4f75ef..a827ce2849f 100644 --- a/src/main/startup/codex-session-resume-launch.ts +++ b/src/main/startup/codex-session-resume-launch.ts @@ -7,8 +7,8 @@ import { prepareLegacySharedCodexSessionResume } from '../codex/codex-legacy-ses import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership' import { codexHookService } from '../codex/hook-service' import { ensureRealHomeCodexHookState } from '../codex/codex-real-home-hook-install' -import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' -import { markCodexProjectTrusted } from '../agent-trust-presets' +import { ensureCodexDaemonSocketGuard } from '../codex/codex-config-mirror' +import { isAgentStatusHooksEnabledForAgent } from '../agent-hooks/managed-agent-hook-controls' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { mainProcessState as state } from './main-process-state' @@ -17,7 +17,6 @@ export async function prepareCodexSessionResumeForLaunch(args: { providerSession: AgentProviderSessionMetadata target: CodexAccountSelectionTarget launchEnv?: NodeJS.ProcessEnv - workspacePath?: string }): Promise { const runtimeHome = state.codexRuntimeHome const store = state.store @@ -35,7 +34,7 @@ export async function prepareCodexSessionResumeForLaunch(args: { // readable alias wins. A throw here refuses the whole resume instead // (#STA-4422). const selectedAccountCodexHome = runtimeHome.resolveSelectedHostAccountCodexHomePathForResume() - // Why: a `fresh` outcome must skip migration, trust and hook repair entirely — there is + // Why: a `fresh` outcome must skip migration and hook repair entirely — there is // no verified origin home to prepare, so the PTY layer drops the resume argv (#10793). const preparation = await prepareCodexSessionResume({ sessionId: args.providerSession.id, @@ -80,17 +79,10 @@ export async function prepareCodexSessionResumeForLaunch(args: { ) } const resumeHome = migrated.useRealCodexHome ? systemHomePath : sessionSource.homePath - if (args.workspacePath) { - try { - await markCodexProjectTrusted(args.workspacePath) - } catch (error) { - console.warn('[codex-project-trust] failed to pre-mark resumed workspace:', error) - } - } const isSystemHome = normalizeRuntimePathForComparison(resumeHome) === normalizeRuntimePathForComparison(systemHomePath) - const hooksEnabled = isAgentStatusHooksEnabled(store.getSettings()) + const hooksEnabled = isAgentStatusHooksEnabledForAgent(store.getSettings(), 'codex') try { if (isSystemHome) { await ensureRealHomeCodexHookState({ @@ -106,6 +98,10 @@ export async function prepareCodexSessionResumeForLaunch(args: { // Why: hook repair is best-effort; session provenance must still win over the currently selected home. console.warn('[codex-hook-service] failed to prepare automatic resume home:', error) } + if (!isSystemHome) { + // Why: this pins the resumed pane's CODEX_HOME, and hook repair above can skip or fail before its config mirror applies the daemon guard. + ensureCodexDaemonSocketGuard(resumeHome) + } return resumeHome } }) diff --git a/src/main/startup/configure-process-profile-state.test.ts b/src/main/startup/configure-process-profile-state.test.ts new file mode 100644 index 00000000000..08db1989c61 --- /dev/null +++ b/src/main/startup/configure-process-profile-state.test.ts @@ -0,0 +1,46 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn(() => ''), + quit: vi.fn(), + exit: vi.fn(), + isPackaged: false, + disableHardwareAcceleration: vi.fn(), + commandLine: { appendSwitch: vi.fn(), getSwitchValue: vi.fn(() => '') } + } +})) + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('pre-ready profile-state recovery boundary', () => { + it('ignores a matching marker when SQLite is missing but an export remains', async () => { + const { shouldDisableHttp2ForElectronNetworking } = await import('./configure-process') + const { writeHttp1CompatibilityMarker } = await import('./http1-compatibility-marker') + const { profileStateJsonExportPath } = + await import('../persistence/profile-state/legacy-json/profile-state-export-path') + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-http1-profile-')) + temporaryDirectories.push(userDataPath) + const profileDirectory = join(userDataPath, 'profiles', 'profile-b') + mkdirSync(profileDirectory, { recursive: true }) + writeFileSync( + join(userDataPath, 'orca-profile-index.json'), + JSON.stringify({ activeProfileId: 'profile-b', profiles: [{ id: 'profile-b' }] }) + ) + const dataFile = join(profileDirectory, 'orca-data.json') + writeFileSync(dataFile, JSON.stringify({ settings: { electronHttp1CompatibilityMode: true } })) + writeHttp1CompatibilityMarker(userDataPath, true, 'profile-b') + writeFileSync(profileStateJsonExportPath(dataFile, 7), '{}') + + expect(shouldDisableHttp2ForElectronNetworking({ env: {}, userDataPath })).toBe(false) + }) +}) diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 7d7e2b0cc1a..70747c926f4 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -420,6 +420,22 @@ describe('configureElectronNetworkCompatibility', () => { return userDataPath } + function createProfileState( + userDataPath: string, + profileId: string, + settings: Record + ): string { + const profileDirectory = join(userDataPath, 'profiles', profileId) + mkdirSync(profileDirectory, { recursive: true }) + writeFileSync( + join(userDataPath, 'orca-profile-index.json'), + JSON.stringify({ activeProfileId: profileId, profiles: [{ id: profileId }] }), + 'utf-8' + ) + writeFileSync(join(profileDirectory, 'orca-data.json'), JSON.stringify({ settings }), 'utf-8') + return profileDirectory + } + afterEach(() => { for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }) @@ -504,6 +520,40 @@ describe('configureElectronNetworkCompatibility', () => { ).toBe(false) }) + it('scopes a profile marker to the active profile before trusting it', async () => { + const { shouldDisableHttp2ForElectronNetworking } = await import('./configure-process') + const { writeHttp1CompatibilityMarker } = await import('./http1-compatibility-marker') + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-http1-profile-')) + tempDirs.push(userDataPath) + createProfileState(userDataPath, 'profile-b', { electronHttp1CompatibilityMode: false }) + writeHttp1CompatibilityMarker(userDataPath, true, 'profile-a') + + expect(shouldDisableHttp2ForElectronNetworking({ env: {}, userDataPath })).toBe(false) + }) + + it('uses a matching profile marker even when the legacy JSON is stale', async () => { + const { shouldDisableHttp2ForElectronNetworking } = await import('./configure-process') + const { writeHttp1CompatibilityMarker } = await import('./http1-compatibility-marker') + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-http1-profile-')) + tempDirs.push(userDataPath) + createProfileState(userDataPath, 'profile-b', { electronHttp1CompatibilityMode: false }) + writeHttp1CompatibilityMarker(userDataPath, true, 'profile-b') + + expect(shouldDisableHttp2ForElectronNetworking({ env: {}, userDataPath })).toBe(true) + }) + + it('fails closed when a profile database exists without a trusted marker', async () => { + const { shouldDisableHttp2ForElectronNetworking } = await import('./configure-process') + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-http1-profile-')) + tempDirs.push(userDataPath) + const profileDirectory = createProfileState(userDataPath, 'profile-b', { + electronHttp1CompatibilityMode: true + }) + writeFileSync(join(profileDirectory, 'profile-state.db'), 'sqlite-present', 'utf-8') + + expect(shouldDisableHttp2ForElectronNetworking({ env: {}, userDataPath })).toBe(false) + }) + it('appends Electron disable-http2 before sessions are created', async () => { const { app } = await import('electron') const { configureElectronNetworkCompatibility } = await import('./configure-process') @@ -892,39 +942,6 @@ describe('safe graphics mode startup switches', () => { ) }) - // Why: the defect was the call site, not the switch — a win32 safe-graphics launch runs - // `if (!gpuFallbackActiveThisLaunch) enableMainProcessGpuFeatures()` and skips everything - // parked inside it, so only an unconditional call site reaches the users a GPU crash already hit. - it('calls the throttling opt-out outside the GPU-fallback gate in preflight', () => { - const mainSource = readFileSync(join(__dirname, 'main-process-preflight.ts'), 'utf8') - const gateStart = mainSource.indexOf('if (!state.gpuFallbackActiveThisLaunch) {') - expect(gateStart).toBeGreaterThanOrEqual(0) - const gateEnd = mainSource.indexOf('\n }', gateStart) - expect(gateEnd).toBeGreaterThan(gateStart) - - expect(mainSource.match(/\boptOutOfHiddenPageWakeUpThrottling\(\)/g)).toHaveLength(1) - expect(mainSource.slice(gateStart, gateEnd)).not.toContain('optOutOfHiddenPageWakeUpThrottling') - }) - - // Why: Chromium consumes the command line at ready, so this must stay in the pre-ready - // top-level block and never move into the whenReady callback, where appendSwitch is a silent - // no-op — the same invisible failure as parking it behind the GPU gate. - it('appends the throttling opt-out before app ready in preflight', () => { - const mainSource = readFileSync(join(__dirname, 'main-process-preflight.ts'), 'utf8') - const entrySource = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8') - const preflightEnd = mainSource.indexOf('\n return true') - const readyStart = entrySource.indexOf('void app.whenReady()') - const preflightCall = entrySource.indexOf('runMainProcessPreflight({') - expect(preflightEnd).toBeGreaterThan(0) - expect(readyStart).toBeGreaterThan(0) - expect(preflightCall).toBeGreaterThanOrEqual(0) - expect(preflightCall).toBeLessThan(readyStart) - - const callIndex = mainSource.indexOf('optOutOfHiddenPageWakeUpThrottling()') - expect(callIndex).toBeGreaterThan(0) - expect(callIndex).toBeLessThan(preflightEnd) - }) - // Why: Chromium enables IntensiveWakeUpThrottling on every desktop platform, so the opt-out // must never become reachable only through the GPU-feature path again. it('does not couple the throttling opt-out to GPU feature setup', async () => { diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index 13dbd70c2c3..95e1df91337 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -1,11 +1,16 @@ import { app } from 'electron' -import { existsSync, mkdirSync, readFileSync } from 'node:fs' +import { mkdirSync } from 'node:fs' import { homedir } from 'node:os' import { join, resolve } from 'node:path' import { getVersionManagerBinPaths } from '../codex-cli/command' import { getMainE2EConfig } from '../e2e-config' import { DISABLED_CHROMIUM_FEATURES } from './disabled-chromium-features' import { readHttp1CompatibilityMarker } from './http1-compatibility-marker' +import { + hasMissingProfileStateDatabaseWithRetainedExport, + readActiveProfileId, + readPersistedHttp1CompatibilityMode +} from './http1-compatibility-profile-state' const DEV_PARENT_SHUTDOWN_GRACE_MS = 3000 const HTTP1_COMPATIBILITY_ENV_VAR = 'ORCA_DISABLE_HTTP2' @@ -32,22 +37,6 @@ function parseBooleanEnvFlag(value: string | undefined): boolean | null { return null } -function readPersistedHttp1CompatibilityMode(userDataPath: string): boolean { - const dataFile = join(userDataPath, 'orca-data.json') - if (!existsSync(dataFile)) { - return false - } - - try { - const parsed = JSON.parse(readFileSync(dataFile, 'utf-8')) as { - settings?: { electronHttp1CompatibilityMode?: unknown } - } - return parsed.settings?.electronHttp1CompatibilityMode === true - } catch { - return false - } -} - export function shouldDisableHttp2ForElectronNetworking( options: NetworkCompatibilityOptions = {} ): boolean { @@ -56,11 +45,22 @@ export function shouldDisableHttp2ForElectronNetworking( return envValue } const userDataPath = options.userDataPath ?? app.getPath('userData') + const activeProfileId = readActiveProfileId(userDataPath) // Why the marker first: this runs before app.whenReady(), and the settings file is the multi-MB - // orca-data.json the Store parses again moments later. The marker is refreshed whenever settings - // change, so the full read only happens on a profile that has never written one. + // profile document the Store parses again moments later. The marker is refreshed whenever + // settings change; an untrusted SQLite profile fails closed rather than falling back to JSON. + if ( + activeProfileId !== undefined && + activeProfileId !== null && + hasMissingProfileStateDatabaseWithRetainedExport(userDataPath, activeProfileId) + ) { + return false + } return ( - readHttp1CompatibilityMarker(userDataPath) ?? readPersistedHttp1CompatibilityMode(userDataPath) + (activeProfileId === null + ? null + : readHttp1CompatibilityMarker(userDataPath, activeProfileId)) ?? + readPersistedHttp1CompatibilityMode(userDataPath) ) } diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index 5e4d4cfe428..40d20d73b03 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -104,6 +104,30 @@ describe('startup ordering', () => { expect(foundationSource.split('initializeBrowserClientHostId(')).toHaveLength(2) }) + it('fails closed with offline recovery guidance when profile state is unreadable', () => { + const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + + expect(entrySource).toContain('formatProfileStateStartupFailure') + expect(entrySource).toContain('formatProfileStateStartupFailure(error) ??') + expect(entrySource).toContain('presentProfileStateStartupRecoveryDialog') + expect(entrySource).toContain('!state.isServeMode && !isBackgroundLaunch()') + expect(entrySource).toContain( + "console.warn('[profile-state] Recovery dialog failed; exiting safely:'" + ) + expect(entrySource).toContain('app.exit(1)') + }) + + it('initializes telemetry before publishing profile-state authority selection', () => { + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-observers.ts'), + 'utf8' + ) + const telemetryInit = source.indexOf('initTelemetry(store)') + const authoritySelection = source.indexOf("track('profile_state_authority_selected'") + expect(telemetryInit).toBeGreaterThanOrEqual(0) + expect(authoritySelection).toBeGreaterThan(telemetryInit) + }) + it('requires daemon authority before restored-subagent liveness runs', () => { const source = readFileSync( join(process.cwd(), 'src/main/startup/main-process-pty-startup.ts'), diff --git a/src/main/startup/dev-education-suppression.test.ts b/src/main/startup/dev-education-suppression.test.ts index 534c64b3aa2..5e628920e51 100644 --- a/src/main/startup/dev-education-suppression.test.ts +++ b/src/main/startup/dev-education-suppression.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from 'vitest' +import { getDefaultUIState } from '../../shared/constants' import { getDefaultOnboardingState, - getDefaultUIState, ONBOARDING_FINAL_STEP, ONBOARDING_FLOW_VERSION -} from '../../shared/constants' +} from '../../shared/onboarding-defaults' import { CONTEXTUAL_TOUR_IDS } from '../../shared/contextual-tours' import { FEATURE_INTERACTION_IDS } from '../../shared/feature-interactions' import { FEATURE_TIP_IDS } from '../../shared/feature-tips' diff --git a/src/main/startup/dev-education-suppression.ts b/src/main/startup/dev-education-suppression.ts index bea97e7bbc8..1f6d1afe188 100644 --- a/src/main/startup/dev-education-suppression.ts +++ b/src/main/startup/dev-education-suppression.ts @@ -1,4 +1,4 @@ -import { ONBOARDING_FINAL_STEP, ONBOARDING_FLOW_VERSION } from '../../shared/constants' +import { ONBOARDING_FINAL_STEP, ONBOARDING_FLOW_VERSION } from '../../shared/onboarding-defaults' import { CONTEXTUAL_TOUR_IDS } from '../../shared/contextual-tours' import { FEATURE_INTERACTION_IDS, diff --git a/src/main/startup/gpu-lifecycle-install-dir-acl-guard.test.ts b/src/main/startup/gpu-lifecycle-install-dir-acl-guard.test.ts index e35fe401cdc..7c6e7dcaaec 100644 --- a/src/main/startup/gpu-lifecycle-install-dir-acl-guard.test.ts +++ b/src/main/startup/gpu-lifecycle-install-dir-acl-guard.test.ts @@ -84,7 +84,7 @@ function reportProbePoisoned(): { finishRepair: () => Promise } { release = resolve }) startWindowsInstallDirAclRepairIfPoisoned( - { status: 'ok', matchesPoisonSignature: true, wellKnownNameCheckReliable: true }, + { status: 'ok', matchesPoisonSignature: true }, { ...recoveryOptions(), runProcessFn: (async () => { @@ -115,7 +115,7 @@ async function reportProbePoisonedWithSettledRepair( userDataPath?: string ): Promise { startWindowsInstallDirAclRepairIfPoisoned( - { status: 'ok', matchesPoisonSignature: true, wellKnownNameCheckReliable: true }, + { status: 'ok', matchesPoisonSignature: true }, { ...recoveryOptions(userDataPath), runProcessFn: (async () => ({ @@ -268,7 +268,7 @@ describe('handleGpuChildCrash vs the install-dir ACL verdict', () => { // The reading lands poisoned: the claim was false, and engagement stays withheld. startWindowsInstallDirAclRepairIfPoisoned( - { status: 'ok', matchesPoisonSignature: true, wellKnownNameCheckReliable: true }, + { status: 'ok', matchesPoisonSignature: true }, recoveryOptions(userData.path) ) await decisive diff --git a/src/main/startup/headless-pty-hydration-ordering.test.ts b/src/main/startup/headless-pty-hydration-ordering.test.ts index 3b661dede99..c761ccf0409 100644 --- a/src/main/startup/headless-pty-hydration-ordering.test.ts +++ b/src/main/startup/headless-pty-hydration-ordering.test.ts @@ -40,7 +40,7 @@ describe('headless PTY registry hydration ordering', () => { it('hydrates orcad after Store and daemon readiness but before RPC and publication', () => { const source = readFileSync(join(process.cwd(), 'src/main/orcad/orcad-entry.ts'), 'utf8') - const store = source.indexOf('const store = new Store(') + const store = source.indexOf('createOrcadProfileStateStartup(runtimeUserDataPath)') const daemon = source.indexOf('await startOrcadDaemon()', store) const handlersAndHydration = source.indexOf('await registerHeadlessPtyRuntime(', daemon) const rpc = source.indexOf('await rpc.start()', handlersAndHydration) @@ -57,7 +57,7 @@ describe('headless PTY registry hydration ordering', () => { const source = readFileSync(join(process.cwd(), 'src/main/orcad/orcad-entry.ts'), 'utf8') const cleanup = source.indexOf('registerCleanup(async () => {') const hookStop = source.indexOf('agentHookServer.stop()', cleanup) - const store = source.indexOf('const store = new Store(') + const store = source.indexOf('createOrcadProfileStateStartup(runtimeUserDataPath)') const hookStart = source.indexOf('await agentHookServer.start(', store) const daemon = source.indexOf('await startOrcadDaemon()', hookStart) const hookEnv = source.indexOf('buildAgentHookPtyEnv:', daemon) diff --git a/src/main/startup/http1-compatibility-marker.ts b/src/main/startup/http1-compatibility-marker.ts index 9e85a83be66..88de78ab808 100644 --- a/src/main/startup/http1-compatibility-marker.ts +++ b/src/main/startup/http1-compatibility-marker.ts @@ -1,8 +1,12 @@ -import { readFileSync, writeFileSync } from 'node:fs' +import { readFileSync, rmSync } from 'node:fs' import { join } from 'node:path' +import { durableWriteTempPath, writeFileDurableSync } from '../durable-file-write' +import { bestEffortFsyncDirectorySync } from '../../shared/secure-file' /** * Cached copy of `settings.electronHttp1CompatibilityMode` for pre-`ready` startup. + * Version 2 carries the active profile ID so a profile switch cannot reuse the + * previous profile's network compatibility choice. * * Why a standalone file (not the Store): app.commandLine.appendSwitch('disable-http2') must run * before the first Electron session exists, which is before the settings Store is constructed. @@ -12,11 +16,13 @@ import { join } from 'node:path' */ export const HTTP1_COMPATIBILITY_MARKER_FILE = 'http1-compatibility.json' -const MARKER_SCHEME_VERSION = 1 +const LEGACY_MARKER_SCHEME_VERSION = 1 +const MARKER_SCHEME_VERSION = 2 type Http1CompatibilityMarker = { schemeVersion: number enabled: boolean + profileId?: string } function markerPath(userDataPath: string): string { @@ -24,12 +30,30 @@ function markerPath(userDataPath: string): string { } /** Returns null when the marker is missing or unreadable, so callers fall back to the settings file. */ -export function readHttp1CompatibilityMarker(userDataPath: string): boolean | null { +export function readHttp1CompatibilityMarker( + userDataPath: string, + expectedProfileId?: string +): boolean | null { try { const parsed = JSON.parse( readFileSync(markerPath(userDataPath), 'utf-8') ) as Partial - if (parsed.schemeVersion !== MARKER_SCHEME_VERSION || typeof parsed.enabled !== 'boolean') { + if (typeof parsed.enabled !== 'boolean') { + return null + } + if (parsed.schemeVersion === LEGACY_MARKER_SCHEME_VERSION) { + // A v1 marker predates profile-scoped state. It remains useful for a + // legacy install with no profile index, but cannot be trusted once the + // active profile is known. + return expectedProfileId === undefined ? parsed.enabled : null + } + if ( + parsed.schemeVersion !== MARKER_SCHEME_VERSION || + typeof parsed.profileId !== 'string' || + parsed.profileId.length === 0 || + expectedProfileId === undefined || + parsed.profileId !== expectedProfileId + ) { return null } return parsed.enabled @@ -38,14 +62,28 @@ export function readHttp1CompatibilityMarker(userDataPath: string): boolean | nu } } -export function writeHttp1CompatibilityMarker(userDataPath: string, enabled: boolean): void { - if (readHttp1CompatibilityMarker(userDataPath) === enabled) { +export function writeHttp1CompatibilityMarker( + userDataPath: string, + enabled: boolean, + profileId?: string +): void { + if (readHttp1CompatibilityMarker(userDataPath, profileId) === enabled) { return } - const marker: Http1CompatibilityMarker = { schemeVersion: MARKER_SCHEME_VERSION, enabled } + const marker: Http1CompatibilityMarker = + profileId === undefined + ? { schemeVersion: LEGACY_MARKER_SCHEME_VERSION, enabled } + : { schemeVersion: MARKER_SCHEME_VERSION, enabled, profileId } try { - writeFileSync(markerPath(userDataPath), JSON.stringify(marker)) + const targetPath = markerPath(userDataPath) + writeFileDurableSync(durableWriteTempPath(targetPath), targetPath, JSON.stringify(marker)) } catch { - // Best effort: a missing marker just costs the next launch the settings-file fallback. + // Best effort: a missing marker makes the next launch fail closed or read legacy JSON. } } + +/** Recovery must discard the old authority's cached setting before publishing JSON authority. */ +export function invalidateHttp1CompatibilityMarker(userDataPath: string): void { + rmSync(markerPath(userDataPath), { force: true }) + bestEffortFsyncDirectorySync(userDataPath) +} diff --git a/src/main/startup/http1-compatibility-profile-state.test.ts b/src/main/startup/http1-compatibility-profile-state.test.ts new file mode 100644 index 00000000000..d1c9b1b08a6 --- /dev/null +++ b/src/main/startup/http1-compatibility-profile-state.test.ts @@ -0,0 +1,141 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + hasMissingProfileStateDatabaseWithRetainedExport, + readActiveProfileId, + readPersistedHttp1CompatibilityMode +} from './http1-compatibility-profile-state' +import { profileStateJsonExportPath } from '../persistence/profile-state/legacy-json/profile-state-export-path' + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function createUserData(): string { + const directory = mkdtempSync(join(tmpdir(), 'orca-http1-profile-state-')) + temporaryDirectories.push(directory) + return directory +} + +function writeIndex(userDataPath: string, activeProfileId: string): void { + writeFileSync( + join(userDataPath, 'orca-profile-index.json'), + JSON.stringify({ + schemaVersion: 1, + activeProfileId, + profiles: [{ id: activeProfileId }] + }) + ) +} + +describe('pre-ready profile-state compatibility lookup', () => { + it('uses the legacy install-level JSON before a profile index exists', () => { + const userDataPath = createUserData() + writeFileSync( + join(userDataPath, 'orca-data.json'), + JSON.stringify({ settings: { electronHttp1CompatibilityMode: true } }) + ) + + expect(readActiveProfileId(userDataPath)).toBeUndefined() + expect(readPersistedHttp1CompatibilityMode(userDataPath)).toBe(true) + }) + + it('reads only the active profile JSON once the index is valid', () => { + const userDataPath = createUserData() + writeIndex(userDataPath, 'work') + const profileDirectory = join(userDataPath, 'profiles', 'work') + mkdirSync(profileDirectory, { recursive: true }) + writeFileSync( + join(userDataPath, 'orca-data.json'), + JSON.stringify({ settings: { electronHttp1CompatibilityMode: true } }) + ) + rmSync(join(userDataPath, 'orca-data.json')) + writeFileSync( + join(profileDirectory, 'orca-data.json'), + JSON.stringify({ settings: { electronHttp1CompatibilityMode: true } }) + ) + + expect(readActiveProfileId(userDataPath)).toBe('work') + expect(readPersistedHttp1CompatibilityMode(userDataPath)).toBe(true) + }) + + it.each(['-wal', '-shm', '-journal'])('fails closed when only SQLite %s remains', (suffix) => { + const userDataPath = createUserData() + writeIndex(userDataPath, 'work') + const profileDirectory = join(userDataPath, 'profiles', 'work') + mkdirSync(profileDirectory, { recursive: true }) + writeFileSync( + join(profileDirectory, 'orca-data.json'), + JSON.stringify({ settings: { electronHttp1CompatibilityMode: true } }) + ) + writeFileSync(join(profileDirectory, `profile-state.db${suffix}`), 'orphaned') + + expect(readPersistedHttp1CompatibilityMode(userDataPath)).toBe(false) + + writeFileSync(join(userDataPath, 'orca-profile-index.json'), '{ malformed') + writeFileSync( + join(userDataPath, 'orca-data.json'), + JSON.stringify({ settings: { electronHttp1CompatibilityMode: true } }) + ) + expect(readActiveProfileId(userDataPath)).toBe(null) + expect(readPersistedHttp1CompatibilityMode(userDataPath)).toBe(false) + }) + + it.each(['json-export', 'database-backup'])( + 'fails closed when SQLite is missing but a retained %s remains', + (artifact) => { + const userDataPath = createUserData() + writeIndex(userDataPath, 'work') + const profileDirectory = join(userDataPath, 'profiles', 'work') + mkdirSync(profileDirectory, { recursive: true }) + const dataFile = join(profileDirectory, 'orca-data.json') + writeFileSync( + dataFile, + JSON.stringify({ settings: { electronHttp1CompatibilityMode: true } }) + ) + writeFileSync( + artifact === 'json-export' + ? profileStateJsonExportPath(dataFile, 7) + : join( + profileDirectory, + 'profile-state.db.backup.1789999999999-00000000-0000-4000-8000-000000000000.db' + ), + readFileSync(dataFile) + ) + + expect(readPersistedHttp1CompatibilityMode(userDataPath)).toBe(false) + } + ) + + it.each(['json-export', 'database-backup'])( + 'detects a missing profile database with a retained %s', + (artifact) => { + const userDataPath = createUserData() + writeIndex(userDataPath, 'work') + const profileDirectory = join(userDataPath, 'profiles', 'work') + mkdirSync(profileDirectory, { recursive: true }) + const dataFile = join(profileDirectory, 'orca-data.json') + writeFileSync( + dataFile, + JSON.stringify({ settings: { electronHttp1CompatibilityMode: true } }) + ) + writeFileSync( + artifact === 'json-export' + ? profileStateJsonExportPath(dataFile, 7) + : join( + profileDirectory, + 'profile-state.db.backup.1789999999999-00000000-0000-4000-8000-000000000000.db' + ), + readFileSync(dataFile) + ) + + expect(hasMissingProfileStateDatabaseWithRetainedExport(userDataPath, 'work')).toBe(true) + } + ) +}) diff --git a/src/main/startup/http1-compatibility-profile-state.ts b/src/main/startup/http1-compatibility-profile-state.ts new file mode 100644 index 00000000000..f63ec01dc20 --- /dev/null +++ b/src/main/startup/http1-compatibility-profile-state.ts @@ -0,0 +1,122 @@ +import { existsSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { profileStateJsonExportPaths } from '../persistence/profile-state/legacy-json/profile-state-export-path' +import { profileStateDatabaseBackups } from '../persistence/profile-state/profile-state-backup-path' +import { hasProfileStateDatabaseFiles } from '../persistence/profile-state/profile-state-storage-classification' + +// Keep the pre-ready graph small; this stable ID mirrors DEFAULT_LOCAL_ORCA_PROFILE_ID. +const DEFAULT_LOCAL_PROFILE_ID = 'local-default' + +/** `null` means malformed index; `undefined` means a pre-profile legacy install. */ +export function readActiveProfileId(userDataPath: string): string | null | undefined { + const indexPath = join(userDataPath, 'orca-profile-index.json') + const candidates = [indexPath, `${indexPath}.bak`].filter(existsSync) + if (candidates.length === 0) { + return undefined + } + for (const candidate of candidates) { + try { + const parsed: unknown = JSON.parse(readFileSync(candidate, 'utf-8')) + if (!isRecord(parsed) || typeof parsed.activeProfileId !== 'string') { + continue + } + if ( + /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(parsed.activeProfileId) && + Array.isArray(parsed.profiles) && + parsed.profiles.some( + (profile) => isRecord(profile) && profile.id === parsed.activeProfileId + ) + ) { + return parsed.activeProfileId + } + } catch { + // A torn primary can still have a valid recovery index. + } + } + return null +} + +/** Read JSON only when no profile database is present; pre-ready cannot open SQLite safely. */ +export function readPersistedHttp1CompatibilityMode(userDataPath: string): boolean { + const activeProfileId = readActiveProfileId(userDataPath) + if (activeProfileId === null) { + // A malformed profile index leaves the active profile unknowable. + return false + } + + const profileDataFile = + activeProfileId === undefined + ? undefined + : join(userDataPath, 'profiles', activeProfileId, 'orca-data.json') + const profileDatabaseFile = + activeProfileId === undefined + ? undefined + : join(userDataPath, 'profiles', activeProfileId, 'profile-state.db') + // SQLite is authoritative once present; a missing marker therefore fails closed. + if (profileDatabaseFile !== undefined && hasProfileStateDatabaseFiles(profileDatabaseFile)) { + return false + } + + if ( + activeProfileId !== undefined && + activeProfileId !== DEFAULT_LOCAL_PROFILE_ID && + (profileDataFile === undefined || !existsSync(profileDataFile)) + ) { + // A known but unseeded non-default profile has default settings. The + // install-level legacy file belongs to another profile and must not leak. + return false + } + const dataFile = profileDataFile ?? join(userDataPath, 'orca-data.json') + // A retained migration export proves SQLite was established. Do not let the + // pre-ready path read a stale JSON mirror while recovery is required. + try { + if ( + profileStateJsonExportPaths(dataFile).length > 0 || + profileStateDatabaseBackups( + profileDatabaseFile ?? join(dirname(dataFile), 'profile-state.db') + ).length > 0 + ) { + return false + } + } catch { + return false + } + if (!existsSync(dataFile)) { + return false + } + + try { + const parsed: unknown = JSON.parse(readFileSync(dataFile, 'utf-8')) + if (!isRecord(parsed) || !isRecord(parsed.settings)) { + return false + } + return parsed.settings.electronHttp1CompatibilityMode === true + } catch { + return false + } +} + +/** Return whether a retained SQLite export makes pre-ready JSON/marker state untrusted. */ +export function hasMissingProfileStateDatabaseWithRetainedExport( + userDataPath: string, + profileId: string +): boolean { + const profileDirectory = join(userDataPath, 'profiles', profileId) + const databaseFile = join(profileDirectory, 'profile-state.db') + if (hasProfileStateDatabaseFiles(databaseFile)) { + return false + } + const dataFile = join(profileDirectory, 'orca-data.json') + try { + return ( + profileStateJsonExportPaths(dataFile).length > 0 || + profileStateDatabaseBackups(databaseFile).length > 0 + ) + } catch { + return true + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts index 7a677ed760e..42338910e2a 100644 --- a/src/main/startup/main-process-account-services.ts +++ b/src/main/startup/main-process-account-services.ts @@ -18,9 +18,9 @@ import { readMiniMaxApiKey } from '../minimax/minimax-api-key-store' import { createAccountRuntimeTargetSettingsSync } from '../rate-limits/account-runtime-target-sync' import { normalizeCodexRuntimeSelection } from '../codex-accounts/runtime-selection' import { normalizeClaudeRuntimeSelection } from '../claude-accounts/runtime-selection' -import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { agentHookServer } from '../agent-hooks/server' import { setSystemCodexHomeHookSweepSuppressed } from '../codex/hook-service' +import { shouldSuppressSystemCodexHomeHookSweep } from '../codex/codex-hook-legacy-cleanup' import { isRealHomeCodexHookLaneUsable } from '../codex/codex-real-home-hook-install' import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' import { browserManager } from '../browser/browser-manager' @@ -45,13 +45,13 @@ export function initializeMainProcessAccountServices(): void { state.codexRuntimeHome.setRealHomeLaneGate(() => isRealHomeCodexHookLaneUsable()) // Why: while the real-home lane owns ~/.codex/hooks.json, the legacy // system-home sweep inside managed installs would delete the entry the - // real-home installer just appended. Flag OFF, hooks off, or an incapable - // trust lane re-arms the sweep so downgrade, opt-out, and rollback converge. - setSystemCodexHomeHookSweepSuppressed( - () => - state.codexRuntimeHome !== null && - state.codexRuntimeHome.isHostSystemDefaultRealHome() && - isAgentStatusHooksEnabled(state.store?.getSettings()) + // real-home installer just appended. Flag OFF, hooks off (all or Codex), or an + // incapable trust lane re-arms the sweep so downgrade, opt-out, and rollback converge. + setSystemCodexHomeHookSweepSuppressed(() => + shouldSuppressSystemCodexHomeHookSweep({ + isHostSystemDefaultRealHome: state.codexRuntimeHome?.isHostSystemDefaultRealHome() === true, + settings: state.store?.getSettings() + }) ) state.codexSessionMigration = createCodexSessionMigrationScheduler({ isEligible: () => diff --git a/src/main/startup/main-process-observers.ts b/src/main/startup/main-process-observers.ts index b0ee60f88c2..9f7f0639aae 100644 --- a/src/main/startup/main-process-observers.ts +++ b/src/main/startup/main-process-observers.ts @@ -57,6 +57,16 @@ export function initializeMainProcessObservers(): void { } // Why: telemetry must init before any IPC handler/renderer can call track(); it's a no-op in dev and while TELEMETRY_ENABLED is false, so it's safe early. initTelemetry(store) + const profileStateStartup = state.profileStateStartup + if (profileStateStartup) { + track('profile_state_authority_selected', { + backend: profileStateStartup.backend, + classification: profileStateStartup.classification, + authority_mode: 'sqlite-established', + runtime: profileStateStartup.runtime, + migrated: profileStateStartup.migrated + }) + } // Why: the breadcrumb alone never leaves the machine — it rides crash reports, and a hang is not // a crash (the app is force-quit, so no report is ever generated). Without this the incidence // number the watchdog exists to produce would sit unread on the user's disk. Must run after diff --git a/src/main/startup/main-process-preflight-failure.ts b/src/main/startup/main-process-preflight-failure.ts new file mode 100644 index 00000000000..0fcd47935cb --- /dev/null +++ b/src/main/startup/main-process-preflight-failure.ts @@ -0,0 +1,41 @@ +import { app, dialog } from 'electron' +import { formatProfileStateStartupFailure } from '../persistence/profile-state/profile-state-startup-failure' +import { isBackgroundLaunch } from '../window/foreground-activation-policy' +import { mainProcessState as state } from './main-process-state' + +/** Ends a failed preflight without showing a Linux dialog before Electron is ready. */ +export function handleMainProcessPreflightFailure(error: unknown): void { + const message = + formatProfileStateStartupFailure(error) ?? + (error instanceof Error ? error.message : String(error)) + const shouldShowDialog = !state.isServeMode && !isBackgroundLaunch() + state.desktopActivationGate = null + const admission = state.profileStateAdmission + state.profileStateAdmission = undefined + try { + admission?.release() + } catch (releaseError) { + console.warn('[startup] Could not release profile state admission:', releaseError) + } + + const showDialogAndExit = (): void => { + try { + dialog.showErrorBox('Orca could not start', message) + } catch (dialogError) { + console.warn('[startup] Could not show startup failure:', dialogError) + } finally { + app.exit(1) + } + } + if (process.platform === 'linux' && shouldShowDialog) { + try { + void app.whenReady().then(showDialogAndExit, () => app.exit(1)) + } catch { + app.exit(1) + } + } else if (shouldShowDialog) { + showDialogAndExit() + } else { + app.exit(1) + } +} diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts index 98d41093c5f..100f1e21386 100644 --- a/src/main/startup/main-process-preflight.ts +++ b/src/main/startup/main-process-preflight.ts @@ -3,6 +3,7 @@ import { is } from '@electron-toolkit/utils' import os from 'node:os' import { join } from 'node:path' import { maybeRedirectCliLaunch } from './cli-launch-redirect' +import { runProfileStateRecoveryPreflight } from './profile-state-recovery-preflight' import { argvRequestsServeMode, normalizeServeModeArgv } from './serve-mode-argv' import { configureDevUserDataPath, @@ -67,6 +68,8 @@ import { initDataPath, getCanonicalUserDataPath } from '../persistence' import { applyMacPressAndHoldDefaultAtStartup } from '../macos-press-and-hold-default' import { initSessionParseCachePersistence } from '../ai-vault/session-parse-cache-persistence' import { initOrcaProfilePaths } from '../orca-profiles/profile-index-store' +import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' +import { recoverPendingProfileProjectMoves } from '../orca-profiles/profile-project-move-intent' import { initStatsPath } from '../stats/collector' import { initClaudeUsagePath } from '../claude-usage/store' import { initCodexUsagePath } from '../codex-usage/store' @@ -89,6 +92,9 @@ import { mainProcessState as state } from './main-process-state' import { initializeSyntheticTitleRuntime } from './synthetic-title-runtime' import { initializeBrowserProcessUserAgent } from '../browser/browser-process-user-agent' import { initializeBrowserIdentityModeStore } from '../browser/browser-identity-mode-store' +import { acquireProfileStateRuntimeAdmission } from '../persistence/profile-state/profile-state-access' +import { getActiveProfileStateLocation } from '../persistence/profile-state/profile-state-active-location' +import { handleMainProcessPreflightFailure } from './main-process-preflight-failure' export type MainProcessPreflightOptions = { focusExistingWindow: () => void @@ -97,6 +103,19 @@ export type MainProcessPreflightOptions = { /** Performs all module-scope work that must happen before Electron's ready event. */ export function runMainProcessPreflight(options: MainProcessPreflightOptions): boolean { + try { + return initializeMainProcessPreflight(options) + } catch (error) { + console.error('[startup] Preflight failed:', error) + handleMainProcessPreflightFailure(error) + return false + } +} + +function initializeMainProcessPreflight(options: MainProcessPreflightOptions): boolean { + if (runProfileStateRecoveryPreflight()) { + return false + } // Why: on Windows a CLI launch that lost ELECTRON_RUN_AS_NODE would boot the GUI and exit silently; redirect to node mode before the lock gate below. // The redirect runs before the serve-argv rewrite so it still matches on the launch argv verbatim. // Direct serve stays in-process so its signal handlers own all children. @@ -128,7 +147,9 @@ export function runMainProcessPreflight(options: MainProcessPreflightOptions): b ? state.devInstanceIdentity.appUserModelId : undefined state.desktopActivationGate = createServeDesktopActivationGate({ - initialState: state.isServeMode ? 'initializing' : 'ready', + // Why held for desktop too: an activation before the startup window exists would open a + // second main window and abort launch; runtime launch releases it once that window exists. + initialState: 'initializing', activateWindow: () => { // Why: an updater replacement must not resurrect the old app bundle. if (!isQuittingForUpdate()) { @@ -186,10 +207,6 @@ export function runMainProcessPreflight(options: MainProcessPreflightOptions): b if (state.devInstanceIdentity && shouldApplyPreReadyAppName(state.devInstanceIdentity)) { app.setName(state.devInstanceIdentity.appName) } - // Why: renderer and worker defaults are process-global and must be fixed before any session exists. - initializeBrowserProcessUserAgent( - initializeBrowserIdentityModeStore(getCanonicalUserDataPath()).appliedMode - ) state.startupDiagnosticsEnabled = isStartupDiagnosticsEnabled() if (state.startupDiagnosticsEnabled) { logStartupDiagnostic('before-single-instance-lock', { @@ -229,6 +246,11 @@ export function runMainProcessPreflight(options: MainProcessPreflightOptions): b app.exit(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE) return false } + state.profileStateAdmission = acquireProfileStateRuntimeAdmission(getCanonicalUserDataPath()) + // Renderer and worker defaults must be fixed before any session exists. + initializeBrowserProcessUserAgent( + initializeBrowserIdentityModeStore(getCanonicalUserDataPath()).appliedMode + ) // Why first in this block: the accessor throws until installed and everything below may read a // credential. The constructor does not touch `safeStorage` — it resolves lazily per call — so // installing here changes no timing, in particular not the pre-ready Keychain service-name @@ -282,6 +304,13 @@ export function runMainProcessPreflight(options: MainProcessPreflightOptions): b appVersion: app.getVersion() }) initOrcaProfilePaths() + // A crash can leave a cross-profile SQLite move between its two commits. Resolve + // that journal before any Store opens a profile, so no reader observes a half-move. + const profileUserDataPath = getProfileUserDataPath() + recoverPendingProfileProjectMoves( + profileUserDataPath, + getActiveProfileStateLocation(profileUserDataPath)?.profileId + ) // Why: same timing as initDataPath — capture userData before app.setName changes it. See persistence.ts:20-28. initStatsPath() initClaudeUsagePath() diff --git a/src/main/startup/main-process-pty-startup.ts b/src/main/startup/main-process-pty-startup.ts index 162faf66b2f..648db87a3b6 100644 --- a/src/main/startup/main-process-pty-startup.ts +++ b/src/main/startup/main-process-pty-startup.ts @@ -17,7 +17,10 @@ import { } from '../codex/codex-pane-account-registry' import { reconcileRetainedCodexHookHomes } from '../codex/retained-codex-hook-state' import { codexHookService } from '../codex/hook-service' -import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { + isAgentStatusHooksEnabled, + isAgentStatusHooksEnabledForAgent +} from '../agent-hooks/managed-agent-hook-controls' import { agentHookServer } from '../agent-hooks/server' import { indexPersistedPaneKeyPtyIds, @@ -146,9 +149,7 @@ export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServ if (hasRetainedManagedHostPane) { void reconcileRetainedCodexHookHomes({ hookService: codexHookService, - hooksEnabled: - isAgentStatusHooksEnabled(settings) && - settings?.disabledTuiAgents.includes('codex') !== true, + hooksEnabled: isAgentStatusHooksEnabledForAgent(settings, 'codex'), runtimeHomePaths: state.codexRuntimeHome.getRetainedHostCodexHookHomePaths(livePtyIds) }).catch((error) => console.warn('[codex-hook-service] retained Codex home reconcile failed:', error) diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts index 073791f6d3c..f104a3ec343 100644 --- a/src/main/startup/main-process-quit.ts +++ b/src/main/startup/main-process-quit.ts @@ -15,6 +15,7 @@ import { clearRuntimeMetadataIfOwned } from '../runtime/runtime-metadata' import { shutdownPairedRuntimeBrowserClientHosts } from '../browser/paired-runtime-browser-client-host-runtime' import { browserManager } from '../browser/browser-manager' import { stopCodexStateDbBackfillRecoveries } from '../codex/codex-state-db-backfill-recovery' +import { stopCodexAccountSessionBridges } from '../codex/codex-account-session-bridge' import { awaitPackedRefsLockRelease } from '../git/local-repo-ref-maintenance' import { settleTeardownWithinDeadline, settleWithinMs } from '../quit-teardown-deadline' import { quitTeardownStartGate } from '../quit-teardown-start-gate' @@ -134,6 +135,7 @@ function installWillQuitHandler(): void { state.pluginMarketplaceInstaller = null const pluginHostShutdown = state.pluginService?.dispose() ?? Promise.resolve() const codexBackfillRecoveryShutdown = stopCodexStateDbBackfillRecoveries() + stopCodexAccountSessionBridges() // Why before the stop: teardown stamps each working session's resume marker with why the app // went away, and an update install is a restart the user never chose. setStructuredAgentSessionTeardownTrigger(updateQuitInProgress ? 'update' : 'quit') @@ -195,12 +197,25 @@ function installWillQuitHandler(): void { browserManager.setBrowserGuestStateChangedListener(null) const emulatorShutdown = state.runtime?.getEmulatorBridge()?.destroyAllSessions() ?? Promise.resolve() - // Why immediately before store.flushAsync() with no await in between: beginSshShutdown() marks every + // Why immediately before the final store flush with no await in between: beginSshShutdown() marks every // active SSH lease detached in memory synchronously, and that flush is what persists it. const sshShutdown = beginSshShutdown() killAllPty() const watcherShutdown = shutdownWatchersOnce() - const storeFlush = state.store?.flushAsync() ?? Promise.resolve() + const finalStore = state.store + const storeFlush = (async () => { + if (!finalStore) { + return + } + try { + await finalStore.flushFinalOrThrowAsync({ exportJsonCompatibility: true }) + await finalStore.freezeWritesAsync() + state.profileStateAdmission?.release() + state.profileStateAdmission = undefined + } catch (error) { + console.error('[persistence] Failed to finalize profile state:', error) + } + })() // Why: usage-cache writes are queued off the main thread, so a quit right after setEnabled or a // scan completion would drop the final snapshot. Captured before any await; joins the barrier below. const usageCacheFlush = Promise.all([ diff --git a/src/main/startup/main-process-ready-activation-failure.test.ts b/src/main/startup/main-process-ready-activation-failure.test.ts new file mode 100644 index 00000000000..b3d265b3c25 --- /dev/null +++ b/src/main/startup/main-process-ready-activation-failure.test.ts @@ -0,0 +1,133 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { MainProcessRuntimeLaunchOptions } from './main-process-runtime-launch' + +const phases = vi.hoisted(() => ({ + foundation: vi.fn<() => Promise>(), + runtimeServices: vi.fn<() => Promise>(), + menu: vi.fn<() => Promise>(), + launch: vi.fn<(options: MainProcessRuntimeLaunchOptions) => Promise>() +})) + +vi.mock('./main-process-ready-foundation', () => ({ + initializeReadyFoundation: phases.foundation +})) +vi.mock('./main-process-ready-runtime', () => ({ + initializeReadyRuntimeServices: phases.runtimeServices +})) +vi.mock('./main-process-i18n-menu', () => ({ initializeMainProcessI18nAndMenu: phases.menu })) +vi.mock('./main-process-runtime-launch', () => ({ + initializeMainProcessRuntimeLaunch: phases.launch +})) + +const { initializeMainProcessReady } = await import('./main-process-ready') +const { mainProcessState: state } = await import('./main-process-state') +const { createServeDesktopActivationGate } = await import('./serve-desktop-activation') + +const activateWindow = vi.fn() +const launchOptions: MainProcessRuntimeLaunchOptions = { + openMainWindow: vi.fn(), + handleMacAppActivation: vi.fn() +} + +describe('desktop activation after ready-phase failures', () => { + beforeEach(() => { + vi.resetAllMocks() + phases.foundation.mockResolvedValue(undefined) + phases.runtimeServices.mockResolvedValue(undefined) + phases.menu.mockResolvedValue(undefined) + phases.launch.mockResolvedValue(undefined) + state.isServeMode = false + state.desktopActivationGate = createServeDesktopActivationGate({ + initialState: 'initializing', + activateWindow + }) + }) + + afterEach(() => { + state.desktopActivationGate = null + state.isServeMode = false + }) + + it.each(['foundation', 'runtimeServices'] as const)( + 'disables desktop activation after %s fails', + async (phase) => { + const error = new Error(`${phase} failed`) + phases[phase].mockRejectedValueOnce(error) + state.desktopActivationGate?.requestActivation() + + await expect(initializeMainProcessReady(launchOptions)).rejects.toBe(error) + + expect(activateWindow).not.toHaveBeenCalled() + expect(state.desktopActivationGate).toBeNull() + expect(phases.launch).not.toHaveBeenCalled() + state.desktopActivationGate?.requestActivation() + expect(activateWindow).not.toHaveBeenCalled() + } + ) + + it.each(['foundation', 'runtimeServices'] as const)( + 'disables serve promotion after %s fails', + async (phase) => { + const error = new Error(`${phase} failed`) + phases[phase].mockRejectedValueOnce(error) + state.isServeMode = true + state.desktopActivationGate?.requestActivation() + + await expect(initializeMainProcessReady(launchOptions)).rejects.toBe(error) + + expect(state.desktopActivationGate).toBeNull() + state.desktopActivationGate?.requestActivation() + expect(activateWindow).not.toHaveBeenCalled() + expect(phases.launch).not.toHaveBeenCalled() + } + ) + + it('keeps activations held when menu failure leaves window creation still pending', async () => { + const error = new Error('menu failed') + let finishLaunch = (): void => { + throw new Error('launch has not started') + } + phases.menu.mockRejectedValueOnce(error) + phases.launch.mockImplementationOnce( + (options) => + new Promise((resolve) => { + finishLaunch = () => { + options.openMainWindow() + resolve() + } + }) + ) + state.desktopActivationGate?.requestActivation() + + const ready = initializeMainProcessReady(launchOptions) + const rejected = expect(ready).rejects.toBe(error) + await vi.waitFor(() => expect(phases.launch).toHaveBeenCalledOnce()) + + expect(state.desktopActivationGate?.getState()).toBe('initializing') + expect(activateWindow).not.toHaveBeenCalled() + expect(launchOptions.openMainWindow).not.toHaveBeenCalled() + finishLaunch() + expect(launchOptions.openMainWindow).toHaveBeenCalledTimes(1) + expect(state.desktopActivationGate?.getState()).toBe('ready') + expect(activateWindow).toHaveBeenCalledTimes(1) + await rejected + expect(state.desktopActivationGate).toBeNull() + }) + + it('does not replay pending activations when window creation throws', async () => { + const error = new Error('window creation failed') + vi.mocked(launchOptions.openMainWindow).mockImplementationOnce(() => { + throw error + }) + phases.launch.mockImplementationOnce(async (options) => { + options.openMainWindow() + }) + state.desktopActivationGate?.requestActivation() + + await expect(initializeMainProcessReady(launchOptions)).rejects.toBe(error) + + expect(state.desktopActivationGate).toBeNull() + expect(activateWindow).not.toHaveBeenCalled() + expect(launchOptions.openMainWindow).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 3c0e01fe09e..238998bac49 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -11,7 +11,8 @@ import { } from '../hang-watchdog/hang-detection-marker' import { browserCertificateTrustController } from '../browser/browser-manager' import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store' -import { Store, getCanonicalUserDataPath } from '../persistence' +import { getCanonicalUserDataPath } from '../persistence' +import { createProfileStateStoreForStartup } from '../persistence/profile-state/profile-state-startup-authority' import { initializeBrowserClientHostId } from '../browser/browser-client-host-id' import { scheduleSecretProtectionGapReport } from '../host/deferred-secret-protection-report' import { initSshHostKeyStoreFile } from '../ssh/ssh-host-key-store' @@ -49,6 +50,7 @@ import { updateGpuAccelerationAboutPanel } from './gpu-lifecycle' import { reconcileManagedWslCliRegistrations } from '../cli/wsl-cli-registration-reconciliation' import { createWslCliReconciliationStartupBarrier } from './wsl-cli-reconciliation-startup-barrier' import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { reportProfileStateWriteFailure } from './profile-state-write-failure' export async function initializeReadyFoundation(): Promise { logStartupMilestone('app-ready') @@ -134,10 +136,21 @@ export async function initializeReadyFoundation(): Promise { // Why this early: the first window stamps the hosting id into its renderer's argv, so the durable // read has to have happened by then or the renderer and the browser-host lease disagree. initializeBrowserClientHostId(profile.profileDirectory) - const store = new Store({ + const profileState = await createProfileStateStoreForStartup({ dataFile: profile.dataFile, - storageAuthority: state.isServeMode ? 'runtime' : 'desktop' + databaseFile: profile.stateDatabaseFile, + profileId: profile.profile.id, + runtime: 'desktop', + storageAuthority: state.isServeMode ? 'runtime' : 'desktop', + onPersistenceFailure: reportProfileStateWriteFailure }) + state.profileStateStartup = { + backend: profileState.backend, + classification: profileState.classification, + runtime: 'desktop', + migrated: profileState.migrated + } + const store = profileState.store state.store = store // Why: create pending readiness before the guard can observe the default session. // Why parked on state instead of awaited here: Dock/Launchpad launches don't inherit shell @@ -197,7 +210,8 @@ export async function initializeReadyFoundation(): Promise { // Why: pre-`ready` startup reads this flag from a marker so it never has to parse orca-data.json. writeHttp1CompatibilityMarker( canonicalUserDataPath, - store.getSettings().electronHttp1CompatibilityMode === true + store.getSettings().electronHttp1CompatibilityMode === true, + profile.profile.id ) // Why: apply initial fallback WSL distro from store settings for global git/CLI calls. setDefaultWslDistroOverride(store.getSettings().terminalWindowsWslDistro ?? null) @@ -205,7 +219,8 @@ export async function initializeReadyFoundation(): Promise { if ('electronHttp1CompatibilityMode' in updates) { writeHttp1CompatibilityMarker( canonicalUserDataPath, - settings.electronHttp1CompatibilityMode === true + settings.electronHttp1CompatibilityMode === true, + profile.profile.id ) } if ('terminalWindowsWslDistro' in updates) { diff --git a/src/main/startup/main-process-ready-identity-write.test.ts b/src/main/startup/main-process-ready-identity-write.test.ts index 13011ccfb6b..7f7d490dc88 100644 --- a/src/main/startup/main-process-ready-identity-write.test.ts +++ b/src/main/startup/main-process-ready-identity-write.test.ts @@ -82,6 +82,16 @@ vi.mock('../persistence', () => ({ }, getCanonicalUserDataPath: () => mocks.userDataPath })) +vi.mock('../persistence/profile-state/profile-state-startup-authority', () => ({ + createProfileStateStoreForStartup: () => ({ + store: { + getSettings: () => ({}), + onSettingsChanged: () => {}, + getClaudeLivePtySessionIds: () => [], + getSshTargets: () => [] + } + }) +})) // The registry reads the canonical path from this module, not from '../persistence'. vi.mock('../persistence/loading-store/user-data-path', () => ({ getCanonicalUserDataPath: () => mocks.userDataPath diff --git a/src/main/startup/main-process-ready-persistence-cleanup.test.ts b/src/main/startup/main-process-ready-persistence-cleanup.test.ts new file mode 100644 index 00000000000..6deac0e8d51 --- /dev/null +++ b/src/main/startup/main-process-ready-persistence-cleanup.test.ts @@ -0,0 +1,130 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + createServeDesktopActivationGate, + type ServeDesktopActivationGate +} from './serve-desktop-activation' + +const { state, foundation, runtime, i18n, launch } = vi.hoisted(() => ({ + state: { + store: { freezeWritesAsync: vi.fn(async () => {}) }, + profileStateAdmission: initialAdmission(), + desktopActivationGate: initialActivationGate(), + mainProcessI18nReady: Promise.resolve() + }, + foundation: vi.fn(async () => {}), + runtime: vi.fn(async () => {}), + i18n: vi.fn(async () => {}), + launch: vi.fn(async () => {}) +})) + +function initialAdmission(): { release(): void } | undefined { + return undefined +} + +function initialActivationGate(): ServeDesktopActivationGate | null { + return null +} + +vi.mock('./main-process-state', () => ({ mainProcessState: state })) +vi.mock('./main-process-ready-foundation', () => ({ initializeReadyFoundation: foundation })) +vi.mock('./main-process-ready-runtime', () => ({ initializeReadyRuntimeServices: runtime })) +vi.mock('./main-process-i18n-menu', () => ({ initializeMainProcessI18nAndMenu: i18n })) +vi.mock('./main-process-runtime-launch', () => ({ initializeMainProcessRuntimeLaunch: launch })) + +import { initializeMainProcessReady } from './main-process-ready' + +const options = { + openMainWindow: (): never => { + throw new Error('Unexpected window creation in startup cleanup test') + }, + handleMacAppActivation: () => {} +} + +beforeEach(() => { + vi.clearAllMocks() + state.profileStateAdmission = { release: vi.fn() } + state.desktopActivationGate = null +}) + +describe('startup persistence lifetime', () => { + it('disables activations before a failed launch closes its profile writer', async () => { + const activateWindow = vi.fn() + state.desktopActivationGate = createServeDesktopActivationGate({ + initialState: 'ready', + activateWindow + }) + const failure = new Error('runtime startup failed') + launch.mockRejectedValueOnce(failure) + let finishFreeze = (): void => {} + state.store.freezeWritesAsync.mockImplementationOnce( + () => + new Promise((resolve) => { + finishFreeze = resolve + }) + ) + const rejected = expect(initializeMainProcessReady(options)).rejects.toBe(failure) + + await vi.waitFor(() => expect(state.store.freezeWritesAsync).toHaveBeenCalledOnce()) + + expect(state.desktopActivationGate).toBeNull() + state.desktopActivationGate?.requestActivation() + expect(activateWindow).not.toHaveBeenCalled() + finishFreeze() + await rejected + }) + + it('awaits writer release after a later startup phase fails', async () => { + const failure = new Error('runtime startup failed') + const admission = state.profileStateAdmission + runtime.mockRejectedValueOnce(failure) + let release = () => {} + state.store.freezeWritesAsync.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const ready = initializeMainProcessReady(options) + const rejected = expect(ready).rejects.toBe(failure) + await vi.waitFor(() => expect(state.store.freezeWritesAsync).toHaveBeenCalledOnce()) + expect(admission?.release).not.toHaveBeenCalled() + release() + await rejected + expect(admission?.release).toHaveBeenCalledOnce() + expect(state.profileStateAdmission).toBeUndefined() + expect(launch).not.toHaveBeenCalled() + }) + + it('joins concurrent startup branches before closing their Store', async () => { + const failure = new Error('translations failed') + i18n.mockRejectedValueOnce(failure) + let release = () => {} + launch.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const ready = initializeMainProcessReady(options) + const rejected = expect(ready).rejects.toBe(failure) + await vi.waitFor(() => expect(launch).toHaveBeenCalledOnce()) + expect(state.store.freezeWritesAsync).not.toHaveBeenCalled() + release() + await rejected + expect(state.store.freezeWritesAsync).toHaveBeenCalledOnce() + }) + + it('keeps the original startup failure when cleanup also fails', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + const failure = new Error('foundation failed') + foundation.mockRejectedValueOnce(failure) + state.store.freezeWritesAsync.mockRejectedValueOnce(new Error('close failed')) + try { + await expect(initializeMainProcessReady(options)).rejects.toBe(failure) + expect(log).toHaveBeenCalledOnce() + expect(state.profileStateAdmission?.release).not.toHaveBeenCalled() + } finally { + log.mockRestore() + } + }) +}) diff --git a/src/main/startup/main-process-ready.ts b/src/main/startup/main-process-ready.ts index 835c1f1dd13..7c27f73a465 100644 --- a/src/main/startup/main-process-ready.ts +++ b/src/main/startup/main-process-ready.ts @@ -2,6 +2,7 @@ import { initializeMainProcessI18nAndMenu } from './main-process-i18n-menu' import { mainProcessState as state } from './main-process-state' import { initializeReadyFoundation } from './main-process-ready-foundation' import { initializeReadyRuntimeServices } from './main-process-ready-runtime' +import { releaseDesktopActivationAfter } from './serve-desktop-activation' import { initializeMainProcessRuntimeLaunch, type MainProcessRuntimeLaunchOptions @@ -9,14 +10,44 @@ import { /** Runs the ready-phase composition in the same dependency order as the legacy entry point. */ export async function initializeMainProcessReady( - options: MainProcessRuntimeLaunchOptions + launchOptions: MainProcessRuntimeLaunchOptions ): Promise { - await initializeReadyFoundation() - await initializeReadyRuntimeServices() - // Why concurrent: window creation reads no translated string and no menu item, and both the - // native menu and the tray only become reachable once the window shows — so serializing them - // ahead of openMainWindow only delayed the renderer (8 ms in English, more for a lazy locale). - const i18nAndMenuReady = initializeMainProcessI18nAndMenu() - state.mainProcessI18nReady = i18nAndMenuReady.catch(() => {}) - await Promise.all([i18nAndMenuReady, initializeMainProcessRuntimeLaunch(options)]) + const options: MainProcessRuntimeLaunchOptions = { + ...launchOptions, + openMainWindow: releaseDesktopActivationAfter( + state.desktopActivationGate, + launchOptions.openMainWindow + ) + } + try { + await initializeReadyFoundation() + await initializeReadyRuntimeServices() + // Window creation can proceed while translations and the native menu initialize. + const i18nAndMenuReady = initializeMainProcessI18nAndMenu() + state.mainProcessI18nReady = i18nAndMenuReady.catch(() => {}) + // Join both branches before cleanup can close the profile writer. + const results = await Promise.allSettled([ + i18nAndMenuReady, + initializeMainProcessRuntimeLaunch(options) + ]) + for (const result of results) { + if (result.status === 'rejected') { + throw result.reason + } + } + } catch (error) { + // Startup now exits after failure; reopening would use a closing profile writer. + state.desktopActivationGate = null + try { + await state.store?.freezeWritesAsync() + state.profileStateAdmission?.release() + state.profileStateAdmission = undefined + } catch (closeError) { + console.error( + '[persistence] Failed to close profile persistence after startup failure:', + closeError + ) + } + throw error + } } diff --git a/src/main/startup/main-process-runtime-launch-activation.test.ts b/src/main/startup/main-process-runtime-launch-activation.test.ts new file mode 100644 index 00000000000..42813af450e --- /dev/null +++ b/src/main/startup/main-process-runtime-launch-activation.test.ts @@ -0,0 +1,232 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const electronApp = vi.hoisted(() => ({ + isPackaged: false, + on: vi.fn(), + getPath: vi.fn(() => '/tmp/orca-user-data'), + getVersion: vi.fn(() => '0.0.0-test'), + isReady: vi.fn(() => true), + focus: vi.fn() +})) +const launchHooks = vi.hoisted(() => ({ + duringInstallDirRepair: (): void => {}, + failBeforeWindow: false +})) + +vi.mock('electron', () => ({ app: electronApp, powerMonitor: { on: vi.fn() } })) +vi.mock('@electron-toolkit/utils', () => ({ is: { dev: false } })) +vi.mock('../orca-profiles/profile-cloud-auth-config', () => ({ + getOrcaCloudAuthConfig: () => ({ configured: false }) +})) +vi.mock('../orca-profiles/profile-storage-paths', () => ({ getProfileUserDataPath: vi.fn() })) +vi.mock('../persistence', () => ({ + getCanonicalUserDataPath: () => '/tmp/orca-user-data', + migrateMobilePairingDataToCanonicalUserDataPath: vi.fn() +})) +vi.mock('../runtime/runtime-rpc', () => ({ + OrcaRuntimeRpcServer: class { + start = vi.fn(async () => {}) + setOnUnpairedDeviceAuthFailure = vi.fn() + } +})) +vi.mock('../ipc/mobile', () => ({ registerMobileHandlers: vi.fn() })) +vi.mock('../ipc/pty', () => ({ + getLocalPtyProvider: vi.fn(), + registerHeadlessPtyRuntime: vi.fn() +})) +vi.mock('../providers/local-pty-provider', () => ({ LocalPtyProvider: class {} })) +vi.mock('../browser/offscreen-browser-backend', () => ({ OffscreenBrowserBackend: class {} })) +vi.mock('../browser/browser-manager', () => ({ browserManager: {} })) +vi.mock('./main-process-relay-status', () => ({ + getDesktopRelayStatus: vi.fn(), + publishDesktopRelayStatus: vi.fn() +})) +vi.mock('../runtime/relay/desktop-relay-service', () => ({ DesktopRelayService: class {} })) +vi.mock('./main-process-serve', () => ({ + getServeOptions: vi.fn(() => null), + getBundledWebClientRoot: vi.fn(() => null), + printServeReady: vi.fn() +})) +vi.mock('./main-process-pty-startup', () => ({ + bindTerminalRuntimeStartupServices: vi.fn(), + handleCodexHomePtySpawned: vi.fn(), + handlePtyExit: vi.fn(), + startTerminalRuntimeStartupServices: vi.fn(() => ({})) +})) +vi.mock('./codex-launch-preparation', () => ({ prepareCodexRuntimeHomeForLaunch: vi.fn() })) +vi.mock('./codex-session-resume-launch', () => ({ prepareCodexSessionResumeForLaunch: vi.fn() })) +vi.mock('./windows-install-dir-acl-recovery', () => ({ + // The awaited gap before the first window, where a second-instance launch can land. + repairKnownPoisonedInstallDirBeforeWindow: vi.fn(async () => { + launchHooks.duringInstallDirRepair() + if (launchHooks.failBeforeWindow) { + throw new Error('install-dir repair failed') + } + return 'not-marked' + }) +})) +vi.mock('./serve-signal-handlers', () => ({ registerServeSignalHandlers: vi.fn() })) +vi.mock('../runtime/runtime-rpc-startup-failure', () => ({ + recordRuntimeRpcStartFailure: vi.fn(), + showRuntimeRpcStartupFailureDialog: vi.fn() +})) +vi.mock('../cli/cli-installer', () => ({ CliInstaller: class {} })) +vi.mock('../cli/linux-bare-orca-dispatcher', () => ({ installLinuxBareOrcaDispatcher: vi.fn() })) +vi.mock('../terminal-history-deletion', () => ({ scheduleAllPendingHistoryTreeRemovals: vi.fn() })) +vi.mock('../ipc/startup-notification-registration', () => ({ + triggerStartupNotificationRegistration: vi.fn() +})) +vi.mock('./main-process-push-startup', () => ({ startDesktopPushService: vi.fn() })) +vi.mock('./startup-diagnostics', () => ({ logStartupMilestone: vi.fn() })) +vi.mock('../server/serve-stdout-boundary', () => ({ emitServeBrowserIdentityActionLine: vi.fn() })) +vi.mock('../browser/browser-identity-mode-store', () => ({ + getBrowserIdentityModeStatus: vi.fn() +})) +const showWindowWithoutStealingFocus = vi.hoisted(() => vi.fn()) +vi.mock('../window/foreground-activation-policy', () => ({ + isBackgroundLaunch: () => true, + isWindowlessLaunch: () => false, + showWindowWithoutStealingFocus +})) + +vi.mock('./main-process-ready-foundation', () => ({ + initializeReadyFoundation: vi.fn(async () => {}) +})) +vi.mock('./main-process-ready-runtime', () => ({ + initializeReadyRuntimeServices: vi.fn(async () => {}) +})) +vi.mock('./main-process-i18n-menu', () => ({ + initializeMainProcessI18nAndMenu: vi.fn(async () => {}) +})) + +const { initializeMainProcessReady } = await import('./main-process-ready') +const { mainProcessState: state } = await import('./main-process-state') +const { createServeDesktopActivationGate } = await import('./serve-desktop-activation') +const { focusExistingMainWindow } = await import('../window/focus-existing-window') + +type FakeWindow = { + id: number + webContents: { id: number } + isDestroyed: () => boolean + isMinimized: () => boolean + restore: () => void + once: () => void +} + +const originalPlatform = process.platform + +describe('desktop startup activation', () => { + let windows: FakeWindow[] + let ipcHandles: Set + let trustedRendererId: number | null + + // Mirrors openMainWindow's non-idempotent side effects that broke in the field. + function openMainWindow(): FakeWindow { + const id = windows.length + 1 + const window: FakeWindow = { + id, + webContents: { id }, + isDestroyed: () => false, + isMinimized: () => false, + restore: vi.fn(), + once: vi.fn() + } + windows.push(window) + trustedRendererId = id + if (ipcHandles.has('window:isMaximized')) { + throw new Error("Attempted to register a second handler for 'window:isMaximized'") + } + ipcHandles.add('window:isMaximized') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch only reads the fields FakeWindow provides. + state.mainWindow = window as unknown as NonNullable + return window + } + + beforeEach(() => { + windows = [] + showWindowWithoutStealingFocus.mockClear() + ipcHandles = new Set() + trustedRendererId = null + launchHooks.duringInstallDirRepair = () => {} + launchHooks.failBeforeWindow = false + state.mainWindow = null + state.isServeMode = false + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch only null-checks the runtime before the mocked RPC server takes it. + state.runtime = {} as NonNullable + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch only calls whenReady(). + state.windowsShellPathHydration = { + whenReady: () => Promise.resolve() + } as unknown as NonNullable + state.initialProxyApplicationReady = Promise.resolve() + // Built the way preflight builds it for a desktop launch. + state.desktopActivationGate = createServeDesktopActivationGate({ + initialState: 'initializing', + activateWindow: () => + focusExistingMainWindow({ + app: electronApp, + getWindow: () => state.mainWindow, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: focusExistingMainWindow only calls the FakeWindow methods. + openWindow: () => openMainWindow() as unknown as NonNullable + }) + }) + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }) + electronApp.isPackaged = false + }) + + it.each([ + ['darwin', false], + ['linux', false], + ['win32', true] + ] as const)( + 'focuses the startup window when a second instance lands before it exists (%s)', + async (platform, isPackaged) => { + Object.defineProperty(process, 'platform', { value: platform }) + electronApp.isPackaged = isPackaged + launchHooks.duringInstallDirRepair = () => state.desktopActivationGate?.requestActivation() + + await initializeMainProcessReady({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch only calls once() on the returned window. + openMainWindow: () => openMainWindow() as unknown as NonNullable, + handleMacAppActivation: vi.fn() + }) + + expect(windows).toHaveLength(1) + expect(trustedRendererId).toBe(windows[0].id) + expect(state.mainWindow).toBe(windows[0]) + expect(showWindowWithoutStealingFocus).toHaveBeenCalledWith(windows[0]) + expect(state.desktopActivationGate?.getState()).toBe('ready') + } + ) + + it('does not replay an activation when launch fails before the startup window', async () => { + launchHooks.duringInstallDirRepair = () => state.desktopActivationGate?.requestActivation() + launchHooks.failBeforeWindow = true + + await expect( + initializeMainProcessReady({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch only calls once() on the returned window. + openMainWindow: () => openMainWindow() as unknown as NonNullable, + handleMacAppActivation: vi.fn() + }) + ).rejects.toThrow('install-dir repair failed') + + expect(windows).toHaveLength(0) + expect(state.desktopActivationGate).toBeNull() + }) + + it('holds every launch mode behind the gate until startup settles it', () => { + const preflightSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) + expect(preflightSource).toContain("initialState: 'initializing',") + expect(preflightSource).not.toContain( + "initialState: state.isServeMode ? 'initializing' : 'ready'" + ) + }) +}) diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index 858c6b75786..b3df23e5861 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -208,7 +208,7 @@ async function launchServeMode( state.automations?.start() // Why: serve deletes worktrees too, and the history GC that normally drains delete tombstones is // armed from the main window — without this, a quit mid-removal leaks the tree until a desktop launch. - scheduleAllPendingHistoryTreeRemovals() + void scheduleAllPendingHistoryTreeRemovals() emitServeBrowserIdentityActionLine(getBrowserIdentityModeStatus()) await printServeReady(serveOptions) } diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 20ea8262c9a..a000c57760a 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -131,11 +131,8 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { runtimeHome: state.codexRuntimeHome, systemCodexHomePath: resolveHostCodexSessionSourceHome(store.getSettings()) }), - prepareCodexStructuredLaunch: ({ workspacePath, launchEnv }) => - prepareCodexRuntimeHomeForLaunch(undefined, launchEnv, { - launchAgent: 'codex', - workspacePath - }), + prepareCodexStructuredLaunch: ({ launchEnv }) => + prepareCodexRuntimeHomeForLaunch(undefined, launchEnv), // Why throw like prepare does: a null from an uninitialized service would // map to the system home and key a catalog read to the wrong account. resolveCodexStructuredLaunchHome: ({ launchEnv }) => { diff --git a/src/main/startup/main-process-state.ts b/src/main/startup/main-process-state.ts index 49f86136e89..f6e86423029 100644 --- a/src/main/startup/main-process-state.ts +++ b/src/main/startup/main-process-state.ts @@ -45,6 +45,23 @@ import { } from '../crash-reporting/gpu-crash-fallback-decision' import type { GpuCrashDiagnosticsRecorder } from '../crash-reporting/gpu-crash-diagnostics' import { createWebContentsTimedFlag } from './web-contents-timed-flag' +import type { ProfileStateStorageClassification } from '../persistence/profile-state/profile-state-storage-classification' +import type { ProfileStateRuntimeAdmission } from '../persistence/profile-state/profile-state-access' + +export type ProfileStateStartupMetadata = { + backend: 'sqlite' + classification: ProfileStateStorageClassification + runtime: 'desktop' | 'orcad' + migrated: boolean +} + +function createInitialProfileStateStartup(): ProfileStateStartupMetadata | null { + return null +} + +function createInitialProfileStateAdmission(): ProfileStateRuntimeAdmission | undefined { + return undefined +} /** Mutable composition-root state shared by startup, window, serve, and quit phases. */ export const mainProcessState = { @@ -52,6 +69,8 @@ export const mainProcessState = { /** Whether a manual app.quit() (Cmd+Q) is in progress; lets the close handler skip the running-process confirmation and go straight to close. */ isQuitting: false, store: null as Store | null, + profileStateStartup: createInitialProfileStateStartup(), + profileStateAdmission: createInitialProfileStateAdmission(), stats: null as StatsCollector | null, claudeUsage: null as ClaudeUsageStore | null, codexUsage: null as CodexUsageStore | null, diff --git a/src/main/startup/main-window-agent-status.ts b/src/main/startup/main-window-agent-status.ts index 5899b2fe6bd..38bb380d724 100644 --- a/src/main/startup/main-window-agent-status.ts +++ b/src/main/startup/main-window-agent-status.ts @@ -35,6 +35,7 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt receivedAt, evidenceObservedAt, stateStartedAt, + turnStartedAt, launchToken, providerSession, providerSessionOnly, @@ -89,6 +90,7 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt receivedAt, ...(evidenceObservedAt !== undefined ? { evidenceObservedAt } : {}), stateStartedAt, + ...(turnStartedAt !== undefined ? { turnStartedAt } : {}), ...(providerSession ? { providerSession } : {}), ...(promptInteractionKey ? { promptInteractionKey } : {}), ...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), diff --git a/src/main/startup/main-window-core-services.test.ts b/src/main/startup/main-window-core-services.test.ts new file mode 100644 index 00000000000..50d0bd49db5 --- /dev/null +++ b/src/main/startup/main-window-core-services.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + attachMainWindowServicesMock, + initTccPromptNoticeMock, + preserveAgentAuthBeforeRestartMock, + registerCoreHandlersMock, + state, + store +} = vi.hoisted(() => { + const store = { + writeLatestProfileStateJsonCompatibilityExportAsync: vi.fn(async () => {}), + writeLatestProfileStateJsonExportAsync: vi.fn(async () => {}), + getSettings: vi.fn(() => ({})) + } + return { + attachMainWindowServicesMock: vi.fn(), + initTccPromptNoticeMock: vi.fn(), + preserveAgentAuthBeforeRestartMock: vi.fn(() => Promise.resolve()), + registerCoreHandlersMock: vi.fn(), + state: { + store, + runtime: {}, + stats: {}, + claudeUsage: {}, + codexUsage: {}, + openCodeUsage: {}, + museUsage: {}, + codexAccounts: {}, + claudeAccounts: {}, + rateLimits: { attach: vi.fn(), start: vi.fn() }, + automations: { setWebContents: vi.fn(), start: vi.fn() }, + keybindings: {}, + codexRuntimeHome: {}, + claudeRuntimeAuth: { prepareForClaudeLaunch: vi.fn() }, + agentAwakeService: null, + crashReports: null, + pluginService: null, + pluginMarketplaceService: null, + pluginMarketplaceInstaller: null, + desktopRelayService: null, + isServeMode: false, + localPtyStartupReady: Promise.resolve(), + localPtyProviderStartupReady: Promise.resolve() + }, + store + } +}) + +vi.mock('../ipc/register-core-handlers/register-core-handlers', () => ({ + registerCoreHandlers: registerCoreHandlersMock +})) +vi.mock('../window/attach-main-window-services', () => ({ + attachMainWindowServices: attachMainWindowServicesMock +})) +vi.mock('../macos-tcc-prompt-notice', () => ({ initTccPromptNotice: initTccPromptNoticeMock })) +vi.mock('../updater', () => ({ resolveUpdateInstallMode: vi.fn(() => 'interactive') })) +vi.mock('./main-process-state', () => ({ mainProcessState: state })) +vi.mock('../agent-auth-restart-preservation', () => ({ + preserveAgentAuthBeforeRestart: preserveAgentAuthBeforeRestartMock +})) +vi.mock('../codex/codex-ai-vault-session-resume', () => ({ + prepareCodexAiVaultSessionResume: vi.fn() +})) +vi.mock('../codex/codex-session-source-home', () => ({ + resolveHostCodexSessionSourceHome: vi.fn() +})) +vi.mock('./main-process-pty-startup', () => ({ + emitPluginWorktreeLifecycle: vi.fn(), + handleCodexHomePtySpawned: vi.fn(), + handlePtyExit: vi.fn() +})) +vi.mock('./codex-launch-preparation', () => ({ prepareCodexRuntimeHomeForLaunch: vi.fn() })) +vi.mock('./codex-session-resume-launch', () => ({ prepareCodexSessionResumeForLaunch: vi.fn() })) +vi.mock('./main-window-lifecycle-flags', () => ({ isRecoveryReloadInFlight: vi.fn() })) + +const { attachMainWindowCoreServices } = await import('./main-window-core-services') + +describe('main window profile-state update preparation', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('publishes both recovery forms with one profile checkpoint before an update quit', async () => { + const window = { webContents: { id: 17 } } + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: mocked BrowserWindow only needs webContents for this composition-root wiring test. + attachMainWindowCoreServices(window as never, { + markExpectedRendererReload: vi.fn(), + recordRendererReload: vi.fn() + }) + + const options = attachMainWindowServicesMock.mock.calls[0]?.[5] + if ( + typeof options !== 'object' || + options === null || + !('onBeforeUpdateQuit' in options) || + typeof options.onBeforeUpdateQuit !== 'function' + ) { + throw new Error('Expected update quit cleanup to be wired') + } + + await options.onBeforeUpdateQuit() + + expect(preserveAgentAuthBeforeRestartMock).toHaveBeenCalledWith({ + codexRuntimeHome: state.codexRuntimeHome, + claudeRuntimeAuth: state.claudeRuntimeAuth, + store + }) + expect(store.writeLatestProfileStateJsonExportAsync).not.toHaveBeenCalled() + expect(store.writeLatestProfileStateJsonCompatibilityExportAsync).toHaveBeenCalledOnce() + expect(options).toHaveProperty('onBeforeUpdateQuitFailure', 'abort') + }) +}) diff --git a/src/main/startup/main-window-core-services.ts b/src/main/startup/main-window-core-services.ts index 8f9ef2118df..c292a4455c7 100644 --- a/src/main/startup/main-window-core-services.ts +++ b/src/main/startup/main-window-core-services.ts @@ -126,8 +126,11 @@ export function attachMainWindowCoreServices( isRecoveryReloadInFlight, onCodexHomePtySpawned: handleCodexHomePtySpawned, onPtyExit: handlePtyExit, - onBeforeUpdateQuit: () => - preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }), + onBeforeUpdateQuit: async () => { + await preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }) + await store.writeLatestProfileStateJsonCompatibilityExportAsync() + }, + onBeforeUpdateQuitFailure: 'abort', updateInstallMode: resolveUpdateInstallMode(state.isServeMode), onWorktreeLifecycle: emitPluginWorktreeLifecycle } diff --git a/src/main/startup/main-window-structured-status-filter.test.ts b/src/main/startup/main-window-structured-status-filter.test.ts index c7fe55da626..da26a593d00 100644 --- a/src/main/startup/main-window-structured-status-filter.test.ts +++ b/src/main/startup/main-window-structured-status-filter.test.ts @@ -94,3 +94,11 @@ it('forwards retirement acknowledgement only on live status delivery', () => { expect(sent[0].event).toHaveProperty('authorityRestartId', 'retirement-id') expect(sent[1].event).not.toHaveProperty('authorityRestartId') }) + +// The live push picks fields one by one; the host's turn start must be one of them. +it("forwards the host's turn start, and nothing when the host stamped none", () => { + hooks.listener!(statusPayload({ turnStartedAt: 1 })) + hooks.listener!(statusPayload({})) + expect(sent[0].event).toHaveProperty('turnStartedAt', 1) + expect(sent[1].event).not.toHaveProperty('turnStartedAt') +}) diff --git a/src/main/startup/pre-gone-crash-sampling-wiring.test.ts b/src/main/startup/pre-gone-crash-sampling-wiring.test.ts index 2a8e008c0b3..292645f874f 100644 --- a/src/main/startup/pre-gone-crash-sampling-wiring.test.ts +++ b/src/main/startup/pre-gone-crash-sampling-wiring.test.ts @@ -44,6 +44,8 @@ describe('pre-gone crash sampling startup wiring', () => { expect(readySource).toContain( "import { initializeReadyRuntimeServices } from './main-process-ready-runtime'" ) - expect(readySource).toContain('\n await initializeReadyRuntimeServices()') + expect(readySource).toContain( + 'try {\n await initializeReadyFoundation()\n await initializeReadyRuntimeServices()' + ) }) }) diff --git a/src/main/startup/profile-state-recovery-preflight.test.ts b/src/main/startup/profile-state-recovery-preflight.test.ts new file mode 100644 index 00000000000..0170025ff21 --- /dev/null +++ b/src/main/startup/profile-state-recovery-preflight.test.ts @@ -0,0 +1,342 @@ +import type * as NodeFs from 'node:fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + PROFILE_STATE_DESKTOP_RECOVERY_FLAG, + PROFILE_STATE_RECOVERY_FLAG, + PROFILE_STATE_RECOVERY_RESULT_PREFIX +} from '../../shared/profile-state-recovery-command' +import { acquireProfileStateRuntimeAdmission } from '../persistence/profile-state/profile-state-access' +import { profileStateJsonExportPath } from '../persistence/profile-state/legacy-json/profile-state-export-path' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath +} from '../persistence/profile-state/profile-state-backup-path' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from '../persistence/profile-state/profile-state-database' +import { + importProfileStateJson, + readProfileStateSnapshot +} from '../persistence/profile-state/profile-state-documents' +import { writeProfileStateDatabaseSnapshotAsync } from '../persistence/profile-state/profile-state-database-snapshot' +import * as marker from './http1-compatibility-marker' +import { + profileStateDesktopRecoveryArgs, + runProfileStateRecoveryPreflight +} from './profile-state-recovery-preflight' + +const mocks = vi.hoisted(() => ({ + setPath: vi.fn(), + requestSingleInstanceLock: vi.fn(), + on: vi.fn(), + exit: vi.fn(), + relaunch: vi.fn(), + whenReady: vi.fn(), + showMessageBox: vi.fn(), + background: vi.fn(), + output: vi.fn() +})) +vi.mock('electron', () => ({ + app: mocks, + dialog: { showMessageBox: mocks.showMessageBox } +})) +vi.mock('../window/foreground-activation-policy', () => ({ + applyBackgroundActivationPolicy: mocks.background +})) +vi.mock('node:fs', async (original) => { + const fs = await original() + return { + ...fs, + writeFileSync: (...args: Parameters) => { + if (args[0] === 1) { + mocks.output(args[1]) + return + } + return fs.writeFileSync(...args) + } + } +}) + +const roots: string[] = [] +beforeEach(() => { + vi.clearAllMocks() + mocks.requestSingleInstanceLock.mockReturnValue(true) + mocks.whenReady.mockResolvedValue(undefined) + mocks.showMessageBox.mockResolvedValue({ response: 0 }) + vi.stubEnv('ORCA_USER_DATA_PATH', '/stale/inherited/root') + vi.stubEnv('ORCA_BYPASS_SINGLE_INSTANCE_LOCK', '1') + vi.stubEnv('ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK', '0') +}) +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function fixture() { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-recovery-bridge-'))) + roots.push(root) + const profileId = 'bridge-profile' + const directory = join(root, 'profiles', profileId) + mkdirSync(directory, { recursive: true }) + writeFileSync( + join(root, 'orca-profile-index.json'), + JSON.stringify({ activeProfileId: profileId, profiles: [{ id: profileId }] }) + ) + const dataFile = join(directory, 'orca-data.json') + const databaseFile = join(directory, 'profile-state.db') + const exportFile = profileStateJsonExportPath(dataFile, 1) + const restored = { + settings: { electronHttp1CompatibilityMode: true }, + unknown: { sealed: 'unchanged', missing: null } + } + writeFileSync(dataFile, JSON.stringify({ old: true })) + writeFileSync(databaseFile, 'broken database') + writeFileSync(exportFile, JSON.stringify(restored)) + const argv = [ + 'Orca', + '--serve', + PROFILE_STATE_RECOVERY_FLAG, + JSON.stringify({ userDataPath: root, selector: { kind: 'json', revision: 1 } }) + ] + return { root, profileId, dataFile, databaseFile, exportFile, restored, argv } +} + +function response(): unknown { + const output: unknown = mocks.output.mock.calls[0]?.[0] + expect(typeof output).toBe('string') + if (typeof output !== 'string') { + throw new Error('Missing response') + } + return JSON.parse(output.slice(PROFILE_STATE_RECOVERY_RESULT_PREFIX.length)) +} + +describe('Electron recovery preflight', () => { + it('runs the recovery branch before CLI redirect or ordinary startup admission', () => { + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) + const start = source.indexOf('export function runMainProcessPreflight(') + const recovery = source.indexOf('if (runProfileStateRecoveryPreflight())', start) + const redirect = source.indexOf('const cliLaunchRedirect = maybeRedirectCliLaunch(', start) + const admission = source.indexOf('acquireProfileStateRuntimeAdmission(', start) + expect(start).toBeGreaterThanOrEqual(0) + expect(recovery).toBeGreaterThan(start) + expect(redirect).toBeGreaterThan(recovery) + expect(admission).toBeGreaterThan(redirect) + expect(source.slice(recovery, redirect)).toContain('return false') + }) + + it('leaves ordinary startup untouched', () => { + expect(runProfileStateRecoveryPreflight(['Orca', '--serve'])).toBe(false) + expect(mocks.background).not.toHaveBeenCalled() + expect(mocks.setPath).not.toHaveBeenCalled() + expect(mocks.exit).not.toHaveBeenCalled() + }) + + it.each(['json', 'current-json'] as const)( + 'restores %s under both locks despite ordinary singleton bypasses', + (kind) => { + const item = fixture() + if (kind === 'current-json') { + writeFileSync(item.dataFile, JSON.stringify(item.restored)) + item.argv[3] = JSON.stringify({ userDataPath: item.root, selector: { kind } }) + } + mocks.requestSingleInstanceLock.mockImplementation(() => { + expect(mocks.setPath).toHaveBeenCalledWith('userData', item.root) + expect(() => acquireProfileStateRuntimeAdmission(item.root)).toThrow() + expect(readFileSync(item.databaseFile, 'utf8')).toBe('broken database') + return true + }) + expect(runProfileStateRecoveryPreflight(item.argv)).toBe(true) + expect(process.env.ORCA_USER_DATA_PATH).toBe(item.root) + expect(process.env.ORCA_BACKGROUND_LAUNCH).toBe('1') + expect(mocks.background).toHaveBeenCalledOnce() + expect(mocks.requestSingleInstanceLock).toHaveBeenCalledOnce() + expect(response()).toMatchObject({ + ok: true, + result: { + storage: 'json', + revision: kind === 'json' ? 1 : null, + restoredPath: item.dataFile + } + }) + expect(JSON.parse(readFileSync(item.dataFile, 'utf8'))).toEqual(item.restored) + expect(existsSync(item.databaseFile)).toBe(false) + expect(existsSync(item.exportFile)).toBe(false) + expect(mocks.exit).toHaveBeenCalledWith(0) + const runtime = acquireProfileStateRuntimeAdmission(item.root) + runtime.release() + } + ) + + it('refuses an old native singleton owner without modifying authority or exports', () => { + const item = fixture() + mocks.requestSingleInstanceLock.mockReturnValue(false) + expect(runProfileStateRecoveryPreflight(item.argv)).toBe(true) + expect(response()).toMatchObject({ + ok: false, + code: 'runtime_error', + message: expect.stringContaining('Stop Orca') + }) + expect(readFileSync(item.databaseFile, 'utf8')).toBe('broken database') + expect(JSON.parse(readFileSync(item.dataFile, 'utf8'))).toEqual({ old: true }) + expect(existsSync(item.exportFile)).toBe(true) + expect(mocks.exit).toHaveBeenCalledWith(1) + const runtime = acquireProfileStateRuntimeAdmission(item.root) + runtime.release() + }) + + it('exits quietly when the CLI closes its response pipe after a successful restore', () => { + const item = fixture() + mocks.output.mockImplementationOnce(() => { + throw new Error('EPIPE') + }) + expect(() => runProfileStateRecoveryPreflight(item.argv)).not.toThrow() + expect(JSON.parse(readFileSync(item.dataFile, 'utf8'))).toEqual(item.restored) + expect(mocks.exit).toHaveBeenCalledWith(1) + const runtime = acquireProfileStateRuntimeAdmission(item.root) + runtime.release() + }) + + it('restores a real SQLite backup and retains root exclusion through marker publication', async () => { + const item = fixture() + rmSync(item.databaseFile) + const source = openProfileStateDatabase(item.databaseFile, item.profileId) + const backupId = createProfileStateDatabaseBackupId() + const backupFile = profileStateDatabaseBackupPath(item.databaseFile, backupId) + try { + importProfileStateJson(source.db, JSON.stringify(item.restored)) + await writeProfileStateDatabaseSnapshotAsync(source.db, backupFile) + importProfileStateJson(source.db, JSON.stringify({ newer: true }), { expectedRevision: 1 }) + } finally { + source.db.close() + } + const markerWrite = marker.writeHttp1CompatibilityMarker + const write = vi + .spyOn(marker, 'writeHttp1CompatibilityMarker') + .mockImplementation((...args) => { + expect(() => acquireProfileStateRuntimeAdmission(item.root)).toThrow() + expect(mocks.requestSingleInstanceLock).toHaveBeenCalledOnce() + markerWrite(...args) + }) + const argv = [ + 'Orca', + '--serve', + PROFILE_STATE_RECOVERY_FLAG, + JSON.stringify({ userDataPath: item.root, selector: { kind: 'sqlite', backupId } }) + ] + expect(runProfileStateRecoveryPreflight(argv)).toBe(true) + expect(response()).toMatchObject({ + ok: true, + result: { storage: 'sqlite', backupId, revision: 1 } + }) + expect(write).toHaveBeenCalledWith(item.root, true, item.profileId) + expect(existsSync(item.dataFile)).toBe(false) + expect(existsSync(backupFile)).toBe(true) + const restored = openProfileStateDatabaseReadOnly(item.databaseFile, item.profileId) + try { + expect(JSON.parse(readProfileStateSnapshot(restored.db).json)).toEqual(item.restored) + } finally { + restored.db.close() + } + expect(mocks.exit).toHaveBeenCalledWith(0) + }) + + it('refuses a participating Node runtime before asking for the Electron lock', () => { + const item = fixture() + const runtime = acquireProfileStateRuntimeAdmission(item.root) + try { + expect(runProfileStateRecoveryPreflight(item.argv)).toBe(true) + expect(response()).toMatchObject({ ok: false, code: 'runtime_error' }) + expect(mocks.requestSingleInstanceLock).not.toHaveBeenCalled() + expect(existsSync(item.exportFile)).toBe(true) + } finally { + runtime.release() + } + }) + + it.each([ + ['Orca', PROFILE_STATE_RECOVERY_FLAG, '{}'], + ['Orca', '--serve', PROFILE_STATE_RECOVERY_FLAG], + ['Orca', '--serve', PROFILE_STATE_RECOVERY_FLAG, '{}'], + [ + 'Orca', + '--serve', + PROFILE_STATE_RECOVERY_FLAG, + JSON.stringify({ userDataPath: 'relative', selector: { kind: 'json', revision: 1 } }) + ], + ['Orca', '--serve', PROFILE_STATE_RECOVERY_FLAG, '{}', PROFILE_STATE_RECOVERY_FLAG, '{}'] + ])('fails closed for malformed launch %j', (...argv) => { + expect(runProfileStateRecoveryPreflight(argv)).toBe(true) + expect(response()).toMatchObject({ ok: false }) + expect(mocks.setPath).not.toHaveBeenCalled() + expect(mocks.requestSingleInstanceLock).not.toHaveBeenCalled() + expect(mocks.exit).toHaveBeenCalledWith(1) + }) + + describe('desktop choice relaunch', () => { + function desktopArgv(item: ReturnType) { + writeFileSync(item.dataFile, JSON.stringify(item.restored)) + return [ + 'Orca', + ...profileStateDesktopRecoveryArgs(['Orca', '--inspect', 'orca://share/1'], { + userDataPath: item.root, + selector: { kind: 'current-json' } + }) + ] + } + + it('applies the choice without writing a CLI response, then relaunches ordinary startup', () => { + const item = fixture() + expect(runProfileStateRecoveryPreflight(desktopArgv(item))).toBe(true) + expect(JSON.parse(readFileSync(item.dataFile, 'utf8'))).toEqual(item.restored) + expect(existsSync(item.databaseFile)).toBe(false) + expect(mocks.output).not.toHaveBeenCalled() + expect(mocks.background).not.toHaveBeenCalled() + expect(mocks.relaunch).toHaveBeenCalledWith({ args: ['--inspect', 'orca://share/1'] }) + expect(mocks.exit).toHaveBeenCalledWith(0) + acquireProfileStateRuntimeAdmission(item.root).release() + }) + + it('reports a failed choice instead of relaunching', async () => { + const item = fixture() + mocks.requestSingleInstanceLock.mockReturnValue(false) + vi.spyOn(console, 'error').mockImplementation(() => {}) + expect(runProfileStateRecoveryPreflight(desktopArgv(item))).toBe(true) + await vi.waitFor(() => expect(mocks.exit).toHaveBeenCalledWith(1)) + expect(mocks.relaunch).not.toHaveBeenCalled() + expect(mocks.showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ detail: expect.stringContaining('Stop Orca') }) + ) + expect(readFileSync(item.databaseFile, 'utf8')).toBe('broken database') + }) + + it.each([ + ['Orca', PROFILE_STATE_DESKTOP_RECOVERY_FLAG, '{}'], + ['Orca', '--serve', PROFILE_STATE_DESKTOP_RECOVERY_FLAG, '{}'], + ['Orca', PROFILE_STATE_DESKTOP_RECOVERY_FLAG, '{}', PROFILE_STATE_RECOVERY_FLAG, '{}'] + ])('fails closed for malformed desktop launch %j', async (...argv) => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + expect(runProfileStateRecoveryPreflight(argv)).toBe(true) + await vi.waitFor(() => expect(mocks.exit).toHaveBeenCalledWith(1)) + expect(mocks.setPath).not.toHaveBeenCalled() + expect(mocks.relaunch).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/main/startup/profile-state-recovery-preflight.ts b/src/main/startup/profile-state-recovery-preflight.ts new file mode 100644 index 00000000000..7629f6241b9 --- /dev/null +++ b/src/main/startup/profile-state-recovery-preflight.ts @@ -0,0 +1,151 @@ +import { writeFileSync, realpathSync } from 'node:fs' +import { isAbsolute } from 'node:path' +import { app, dialog } from 'electron' +import { + PROFILE_STATE_DESKTOP_RECOVERY_FLAG, + PROFILE_STATE_RECOVERY_FLAG, + PROFILE_STATE_RECOVERY_RESULT_PREFIX, + ProfileStateRecoveryCommandError, + isProfileStateRecoveryCommandError, + profileStateRecoveryRequestSchema, + type ProfileStateRecoveryResponse +} from '../../shared/profile-state-recovery-command' +import { acquireProfileStateMaintenance } from '../persistence/profile-state/profile-state-access' +import { rollbackProfileState } from '../persistence/profile-state/profile-state-recovery-command' +import { applyBackgroundActivationPolicy } from '../window/foreground-activation-policy' +import { acquireSingleInstanceLock } from './single-instance-lock' + +/** The process owning both locks performs recovery before Electron can initialize a runtime. */ +export function runProfileStateRecoveryPreflight(argv: readonly string[] = process.argv): boolean { + if (argv.includes(PROFILE_STATE_DESKTOP_RECOVERY_FLAG)) { + runDesktopRecovery(argv) + return true + } + const index = argv.indexOf(PROFILE_STATE_RECOVERY_FLAG) + if (index === -1) { + return false + } + process.env.ORCA_BACKGROUND_LAUNCH = '1' + applyBackgroundActivationPolicy() + const response = + !argv.includes('--serve') || argv.lastIndexOf(PROFILE_STATE_RECOVERY_FLAG) !== index + ? invalidLaunch() + : runRecoveryRequest(argv[index + 1]) + let exitCode = response.ok ? 0 : 1 + try { + writeFileSync(1, `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}${JSON.stringify(response)}\n`) + } catch { + // The CLI may have exited while recovery held the locks; never open an Electron error dialog. + exitCode = 1 + } + app.exit(exitCode) + return true +} + +/** Build the relaunch argv that applies a startup-dialog choice before ordinary startup. */ +export function profileStateDesktopRecoveryArgs( + argv: readonly string[], + request: { userDataPath: string; selector: { kind: 'current-json' | 'current-sqlite' } } +): string[] { + return [ + ...stripRecoveryArgs(argv.slice(1)), + PROFILE_STATE_DESKTOP_RECOVERY_FLAG, + JSON.stringify(request) + ] +} + +function runDesktopRecovery(argv: readonly string[]): void { + const index = argv.indexOf(PROFILE_STATE_DESKTOP_RECOVERY_FLAG) + const response = + argv.lastIndexOf(PROFILE_STATE_DESKTOP_RECOVERY_FLAG) !== index || + argv.includes(PROFILE_STATE_RECOVERY_FLAG) || + argv.includes('--serve') + ? invalidLaunch() + : runRecoveryRequest(argv[index + 1]) + if (response.ok) { + // Why relaunch: ordinary startup must run in a process that never held maintenance. + app.relaunch({ args: stripRecoveryArgs(argv.slice(1)) }) + app.exit(0) + return + } + console.error(`[profile-state] Desktop recovery failed: ${response.message}`) + void app + .whenReady() + .then(() => + dialog.showMessageBox({ + type: 'error', + buttons: ['Quit'], + title: 'Orca profile state was not changed', + message: 'Orca could not apply the selected profile state.', + detail: `${response.message}\n\nReopen Orca to choose again.` + }) + ) + .catch((error: unknown) => console.warn('[profile-state] Recovery error dialog failed:', error)) + .finally(() => app.exit(1)) +} + +function runRecoveryRequest(payload: string | undefined): ProfileStateRecoveryResponse { + try { + let raw: unknown + try { + raw = JSON.parse(payload ?? '') + } catch { + raw = undefined + } + const parsed = profileStateRecoveryRequestSchema.safeParse(raw) + if (!parsed.success || !isAbsolute(parsed.data.userDataPath)) { + throw new ProfileStateRecoveryCommandError( + 'invalid_argument', + 'Invalid profile-state recovery request.' + ) + } + const userDataPath = realpathSync(parsed.data.userDataPath) + app.setPath('userData', userDataPath) + process.env.ORCA_USER_DATA_PATH = userDataPath + const maintenance = acquireProfileStateMaintenance(userDataPath) + try { + // Force Electron's lock even when ordinary dev or diagnostic launches would bypass it. + if (!acquireSingleInstanceLock(app, () => {})) { + throw new ProfileStateRecoveryCommandError( + 'runtime_error', + 'Stop Orca before profile-state rollback so no process can write the SQLite database.' + ) + } + return { + ok: true, + result: rollbackProfileState(userDataPath, parsed.data.selector, maintenance) + } + } finally { + maintenance.release() + } + } catch (error) { + return { + ok: false, + code: isProfileStateRecoveryCommandError(error) ? error.code : 'runtime_error', + message: error instanceof Error ? error.message : String(error) + } + } +} + +function invalidLaunch(): ProfileStateRecoveryResponse { + return { + ok: false, + code: 'invalid_argument', + message: 'Invalid profile-state recovery launch.' + } +} + +function stripRecoveryArgs(args: readonly string[]): string[] { + const kept: string[] = [] + for (let i = 0; i < args.length; i++) { + if ( + args[i] === PROFILE_STATE_DESKTOP_RECOVERY_FLAG || + args[i] === PROFILE_STATE_RECOVERY_FLAG + ) { + i++ + continue + } + kept.push(args[i]) + } + return kept +} diff --git a/src/main/startup/profile-state-write-failure.test.ts b/src/main/startup/profile-state-write-failure.test.ts new file mode 100644 index 00000000000..b434b3ca573 --- /dev/null +++ b/src/main/startup/profile-state-write-failure.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { reportProfileStateWriteFailure } from './profile-state-write-failure' + +const fixture = vi.hoisted(() => ({ + show: vi.fn(), + background: false, + state: { isServeMode: false } +})) +vi.mock('electron', () => ({ dialog: { showMessageBox: fixture.show } })) +vi.mock('../window/foreground-activation-policy', () => ({ + isBackgroundLaunch: () => fixture.background +})) +vi.mock('./main-process-state', () => ({ mainProcessState: fixture.state })) + +beforeEach(() => { + fixture.background = false + fixture.state.isServeMode = false + fixture.show.mockResolvedValue({ response: 0 }) + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) +afterEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() +}) + +it('tells desktop users saving stopped without silently restarting the writer', () => { + reportProfileStateWriteFailure(new Error('worker exited')) + expect(fixture.show).toHaveBeenCalledExactlyOnceWith({ + type: 'error', + title: 'Saving stopped', + message: 'Orca has stopped saving this profile.', + detail: 'Recent changes may not be saved. Restart Orca before continuing.', + buttons: ['OK'] + }) +}) + +it.each(['background', 'serve'])('keeps %s runs free of native dialogs', (mode) => { + fixture.background = mode === 'background' + fixture.state.isServeMode = mode === 'serve' + reportProfileStateWriteFailure(new Error('worker exited')) + expect(fixture.show).not.toHaveBeenCalled() + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('stopped saving'), + expect.any(Error) + ) +}) + +it('handles a failed dialog without an unhandled rejection', async () => { + fixture.show.mockRejectedValue(new Error('window system unavailable')) + reportProfileStateWriteFailure(new Error('worker exited')) + await vi.waitFor(() => expect(console.warn).toHaveBeenCalled()) +}) diff --git a/src/main/startup/profile-state-write-failure.ts b/src/main/startup/profile-state-write-failure.ts new file mode 100644 index 00000000000..81668513ba6 --- /dev/null +++ b/src/main/startup/profile-state-write-failure.ts @@ -0,0 +1,18 @@ +import { dialog } from 'electron' +import { isBackgroundLaunch } from '../window/foreground-activation-policy' +import { mainProcessState } from './main-process-state' + +/** Report a retired writer without treating unacknowledged state as safe to overwrite. */ +export function reportProfileStateWriteFailure(error: Error): void { + const message = 'Orca has stopped saving this profile.' + const detail = 'Recent changes may not be saved. Restart Orca before continuing.' + console.error(`[persistence] ${message} ${detail}`, error) + if (mainProcessState.isServeMode || isBackgroundLaunch()) { + return + } + void dialog + .showMessageBox({ type: 'error', title: 'Saving stopped', message, detail, buttons: ['OK'] }) + .catch((dialogError) => + console.warn('[persistence] Could not show saving failure:', dialogError) + ) +} diff --git a/src/main/startup/secure-dns-census.test.ts b/src/main/startup/secure-dns-census.test.ts index 6393ac3f3a4..72177fc04d4 100644 --- a/src/main/startup/secure-dns-census.test.ts +++ b/src/main/startup/secure-dns-census.test.ts @@ -33,14 +33,4 @@ describe('secure DNS census', () => { expect(offenders).toEqual([]) }) - - it('detects a DoH mode when one is present', () => { - // Why: the census reads real sources, so it passes vacuously today; prove the matcher on a known offender. - const offending = "app.configureHostResolver({ secureDnsMode: 'automatic' })" - const modes = [...offending.matchAll(/secureDnsMode\s*:\s*'([^']*)'/g)].map((match) => match[1]) - - expect(offending.includes('configureHostResolver')).toBe(true) - expect(modes).toEqual(['automatic']) - expect(modes.every((mode) => mode === 'off')).toBe(false) - }) }) diff --git a/src/main/startup/serve-desktop-activation.ts b/src/main/startup/serve-desktop-activation.ts index 2b7ad520891..0034c8859e0 100644 --- a/src/main/startup/serve-desktop-activation.ts +++ b/src/main/startup/serve-desktop-activation.ts @@ -27,6 +27,21 @@ export function settleServeDesktopActivation( gate.markReady() } +/** + * Wraps the desktop startup-window opener so activations queued since preflight are let through + * only once that window exists: they then focus it, where earlier they would open a duplicate. + */ +export function releaseDesktopActivationAfter( + gate: ServeDesktopActivationGate | null, + open: (...args: TArgs) => TResult +): (...args: TArgs) => TResult { + return (...args) => { + const result = open(...args) + gate?.markReady() + return result + } +} + export function createServeDesktopActivationGate(options: { initialState: 'initializing' | 'ready' activateWindow: () => void diff --git a/src/main/startup/startup-diagnostics.test.ts b/src/main/startup/startup-diagnostics.test.ts index dcf7a4237b3..4ba75269d6c 100644 --- a/src/main/startup/startup-diagnostics.test.ts +++ b/src/main/startup/startup-diagnostics.test.ts @@ -1,11 +1,24 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { isStartupDiagnosticsEnabled, logStartupDiagnostic, + logStartupMilestone, STARTUP_DIAGNOSTICS_ENV, writeStartupDiagnosticLine } from './startup-diagnostics' +afterEach(() => vi.unstubAllEnvs()) + +it('does not compute lazy milestone details unless diagnostics are enabled', () => { + vi.stubEnv(STARTUP_DIAGNOSTICS_ENV, '0') + const details = vi.fn(() => ({ bytes: 123 })) + logStartupMilestone('persistence-load-done', details) + expect(details).not.toHaveBeenCalled() + vi.stubEnv(STARTUP_DIAGNOSTICS_ENV, '1') + logStartupMilestone('persistence-load-done', details) + expect(details).toHaveBeenCalledOnce() +}) + describe('writeStartupDiagnosticLine', () => { it('writes directly to stderr fd 2 with a newline', () => { const write = vi.fn() diff --git a/src/main/startup/startup-diagnostics.ts b/src/main/startup/startup-diagnostics.ts index d5cd7718d88..dff09612711 100644 --- a/src/main/startup/startup-diagnostics.ts +++ b/src/main/startup/startup-diagnostics.ts @@ -32,8 +32,12 @@ export function logStartupDiagnostic( // Why: startup benchmarking needs in-process timestamps — harness-side stderr // arrival times include pipe buffering jitter. `t` is ms since process start. -export function logStartupMilestone(event: string, details: Record = {}): void { +export function logStartupMilestone( + event: string, + details: Record | (() => Record) = {} +): void { if (isStartupDiagnosticsEnabled()) { - logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details }) + const t = Math.round(performance.now()) + logStartupDiagnostic(event, { t, ...(typeof details === 'function' ? details() : details) }) } } diff --git a/src/main/startup/windows-install-dir-acl-probe.test.ts b/src/main/startup/windows-install-dir-acl-probe.test.ts index da63e35cd77..5e1d67b12d5 100644 --- a/src/main/startup/windows-install-dir-acl-probe.test.ts +++ b/src/main/startup/windows-install-dir-acl-probe.test.ts @@ -1,4 +1,7 @@ +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' import { join } from 'node:path' +import type { CrashReportBreadcrumbData } from '../../shared/crash-reporting' import { describe, expect, it, beforeEach, vi } from 'vitest' import { probeWindowsInstallDirAcl, @@ -6,13 +9,10 @@ import { WINDOWS_INSTALL_DIR_ACL_BREADCRUMB, type WindowsInstallDirAclProbeOptions } from './windows-install-dir-acl-probe' +import { isInstallDirAclPoisonVerdict } from './windows-install-dir-package-acl-repair' import { ALL_PACKAGES_ACE, - ENGLISH_BASELINE_ACES, fakeIcaclsSpawn, - FRENCH_BASELINE_ACES, - FRENCH_RESTRICTED_PACKAGES_ACE, - icaclsDacl, ORPHAN_PACKAGE_ACE, RESTRICTED_PACKAGES_ACE } from './windows-install-dir-acl.test-fixture' @@ -21,10 +21,6 @@ const INSTALL_DIR = 'C:\\Users\\neil\\AppData\\Local\\Programs\\orca' const ORPHAN = ORPHAN_PACKAGE_ACE const RESTRICTED_GRANT = RESTRICTED_PACKAGES_ACE -function dacl(target: string, firstAce: string, ...rest: string[]): string { - return icaclsDacl(target, [firstAce, ...rest]) -} - const fakeSpawn = fakeIcaclsSpawn function probe(options: WindowsInstallDirAclProbeOptions): Promise> { @@ -44,7 +40,7 @@ function probe(options: WindowsInstallDirAclProbeOptions): Promise> { - return probe({ spawnFn: fakeSpawn((target) => dacl(target, aces[0], ...aces.slice(1))).spawnFn }) + return probe({ spawnFn: fakeSpawn(() => aces).spawnFn }) } describe('probeWindowsInstallDirAcl', () => { @@ -54,7 +50,7 @@ describe('probeWindowsInstallDirAcl', () => { it('reports a clean DACL as unpoisoned', async () => { const data = await probe({ - spawnFn: fakeSpawn((target) => icaclsDacl(target, [], ENGLISH_BASELINE_ACES)).spawnFn + spawnFn: fakeSpawn(() => []).spawnFn }) expect(data.name).toBe(WINDOWS_INSTALL_DIR_ACL_BREADCRUMB) expect(data.status).toBe('ok') @@ -82,8 +78,8 @@ describe('probeWindowsInstallDirAcl', () => { // orphan alongside it launched clean on win32 10.0.26200 / Electron 43.4.1 — so // it is not the reproduced state, however useless -1 is to an LPAC token. it.each([ - ['the localized-safe name form', ALL_PACKAGES_ACE], - ['the raw SID form', 'S-1-15-2-1:(OI)(CI)(RX)'] + ['the AC alias', ALL_PACKAGES_ACE], + ['the raw SID form', '(A;OICI;0x1200a9;;;S-1-15-2-1)'] ])('clears the signature when only ALL APPLICATION PACKAGES grants (%s)', async (_l, ace) => { const data = await probeWith(ace, ORPHAN) expect(data.hasWellKnownPackageGrant).toBe(true) @@ -94,10 +90,10 @@ describe('probeWindowsInstallDirAcl', () => { // The reproduced remedy was an additive *grant*; an ACE that grants nothing on // the object cannot satisfy the orphan, so it must not clear the signature. it.each([ - ['deny', 'APPLICATION PACKAGE AUTHORITY\\ALL APPLICATION PACKAGES:(DENY)(OI)(CI)(F)'], - ['inherit-only', 'APPLICATION PACKAGE AUTHORITY\\ALL APPLICATION PACKAGES:(OI)(CI)(IO)(GR,GE)'], - ['raw-sid deny', 'S-1-15-2-2:(DENY)(F)'], - ['raw-sid inherit-only', 'S-1-15-2-1:(OI)(CI)(IO)(GR,GE)'] + ['deny', '(D;OICI;FA;;;AC)'], + ['inherit-only', '(A;OICIIO;GRGX;;;AC)'], + ['restricted deny', '(D;;FA;;;S-1-15-2-2)'], + ['restricted inherit-only', '(A;CIOIIO;GRGX;;;S-1-15-2-2)'] ])('does not let a %s well-known ACE satisfy an orphan', async (_label, ace) => { const data = await probeWith(ace, ORPHAN) expect(data.hasWellKnownPackageGrant).toBe(false) @@ -108,67 +104,131 @@ describe('probeWindowsInstallDirAcl', () => { it('does not let a grant on one target mask its absence on another', async () => { const data = await probe({ spawnFn: fakeSpawn((target) => - target.endsWith('ffmpeg.dll') - ? dacl(target, ORPHAN) - : dacl(target, RESTRICTED_GRANT, ORPHAN) + target.endsWith('ffmpeg.dll') ? [ORPHAN] : [RESTRICTED_GRANT, ORPHAN] ).spawnFn }) expect(data.hasWellKnownPackageGrant).toBe(true) expect(data.matchesPoisonSignature).toBe(true) }) - it('reports whether the well-known name check could be trusted', async () => { - const english = await probeWith(ORPHAN) - expect(english.wellKnownNameCheckReliable).toBe(true) - const localized = await new Promise>((resolve) => { - resetWindowsInstallDirAclProbeForTest() - probeWindowsInstallDirAcl({ - platform: 'win32', - installDir: INSTALL_DIR, - fileExists: () => false, - // fr-FR install that already carries the restricted grant: the name check - // cannot see it, so the signature is a false positive the flag must expose. - spawnFn: fakeSpawn((target) => - icaclsDacl(target, [ORPHAN, FRENCH_RESTRICTED_PACKAGES_ACE], FRENCH_BASELINE_ACES) - ).spawnFn, - recordBreadcrumb: (_name, d) => { - resolve(d as Record) - return undefined - } - }) + // zh-CN/ja-JP/ko-KR icacls keeps "NT AUTHORITY" English but translates the package + // names and summary. A tree the repair just fixed read as poisoned AND reliable there, + // re-arming the pre-window repair and blaming the install on every launch. + it('reads a repaired tree as clean when icacls translates package names', async () => { + const zhDisplay = (target: string): string => + [ + `${target} S-1-15-2-999-999-999:(OI)(CI)(RX)`, + ' APPLICATION PACKAGE AUTHORITY\\所有受限制的应用程序包:(OI)(CI)(RX)', + ' NT AUTHORITY\\SYSTEM:(I)(OI)(CI)(F)', + ' BUILTIN\\Administrators:(I)(OI)(CI)(F)', + '', + '已成功处理 1 个文件; 处理 0 个文件时失败' + ].join('\r\n') + let verdict: CrashReportBreadcrumbData = {} + const data = await probe({ + spawnFn: fakeSpawn(() => [ORPHAN, RESTRICTED_GRANT], zhDisplay).spawnFn, + onDone: (done) => (verdict = done) }) - expect(localized.matchesPoisonSignature).toBe(true) - expect(localized.wellKnownNameCheckReliable).toBe(false) + expect(isInstallDirAclPoisonVerdict(verdict)).toBe(false) + expect(data.status).toBe('ok') + expect(data.orphanPackageSidCount).toBe(1) + expect(data.hasRestrictedPackageGrant).toBe(true) }) it('matches the well-known SIDs exactly, not by prefix', async () => { - const data = await probeWith('S-1-15-2-1234567890:(OI)(CI)(RX)') + const data = await probeWith('(A;OICI;0x1200a9;;;S-1-15-2-1234567890)') expect(data.orphanPackageSidCount).toBe(1) expect(data.hasWellKnownPackageGrant).toBe(false) expect(data.matchesPoisonSignature).toBe(true) }) it('ignores capability SIDs, which are a different family and harmless', async () => { - const data = await probeWith('S-1-15-3-65536-599108337-2355189375-1353122160:(S,X)') + const data = await probeWith('(A;;0x100020;;;S-1-15-3-65536-599108337-2355189375-1353122160)') expect(data.orphanPackageSidCount).toBe(0) expect(data.matchesPoisonSignature).toBe(false) }) it('probes a content file, not just the directory object', async () => { - const fake = fakeSpawn((target) => dacl(target, ORPHAN)) + const fake = fakeSpawn(() => [ORPHAN]) await probe({ spawnFn: fake.spawnFn }) expect(fake.calls.map((c) => c.args[0])).toEqual([INSTALL_DIR, join(INSTALL_DIR, 'ffmpeg.dll')]) }) - it('never passes a recursive or write flag', async () => { - const fake = fakeSpawn((target) => dacl(target, ORPHAN)) + it('only saves the DACL to a temp file: no recursive or ACL-writing flag', async () => { + const fake = fakeSpawn(() => [ORPHAN]) await probe({ spawnFn: fake.spawnFn }) for (const call of fake.calls) { - expect(call.args).toHaveLength(1) + expect(call.args).toHaveLength(3) expect(call.args[0]).not.toMatch(/^\//) + expect(call.args[1]).toBe('/save') + expect(call.args[2].startsWith(tmpdir())).toBe(true) + expect(existsSync(call.args[2])).toBe(false) } }) + // Verbatim `icacls /save` / `icacls /save` output from win32 10.0.26200. + it.each([ + [ + 'a repaired module file', + 'ffmpeg.dll\r\nD:AI(A;;0x1200a9;;;S-1-15-2-2)(A;;0x1200a9;;;S-1-15-2-999-999-999)' + + '(A;ID;0x1200a9;;;S-1-15-2-999-999-999)(A;ID;FA;;;SY)(A;ID;FA;;;BA)' + + '(A;ID;FA;;;S-1-5-21-432636774-4279371817-3971399515-1001)\r\n', + false + ], + [ + 'a poisoned dir whose AC ACE denies', + 'scan21acl\r\nD:AI(D;;0x100116;;;AC)(A;OICI;0x1200a9;;;S-1-15-2-999-999-999)' + + '(A;OICIID;FA;;;SY)(A;OICIID;FA;;;BA)' + + '(A;OICIID;FA;;;S-1-5-21-432636774-4279371817-3971399515-1001)\r\n', + true + ] + ])('reads a real icacls /save capture: %s', async (_label, saved, poisoned) => { + const data = await probe({ + fileExists: () => false, + spawnFn: fakeSpawn(() => Buffer.from(saved, 'utf16le')).spawnFn + }) + expect(data.orphanPackageSids).toBe('S-1-15-2-999-999-999') + expect(data.matchesPoisonSignature).toBe(poisoned) + }) + + it('keeps a conditional DACL unreadable instead of hiding a later package grant', async () => { + const saved = + 'orca\r\nD:AI(A;OICI;0x1200a9;;;S-1-15-2-999-999-999)' + + '(XA;OICI;FA;;;WD;(@User.Department == "Finance"))' + + '(A;OICIID;0x1200a9;;;S-1-15-2-2)\r\n' + let verdict: CrashReportBreadcrumbData = {} + const data = await probe({ + fileExists: () => false, + spawnFn: fakeSpawn(() => Buffer.from(saved, 'utf16le')).spawnFn, + onDone: (done) => (verdict = done) + }) + expect(data).toMatchObject({ status: 'failed', reason: 'all-targets-unreadable' }) + expect(data.matchesPoisonSignature).toBeUndefined() + expect(isInstallDirAclPoisonVerdict(verdict)).toBe(false) + }) + + it.each([1, null])('ignores a saved DACL when icacls exits with %s', async (exitCode) => { + const fake = fakeSpawn(() => [ORPHAN], undefined, exitCode) + let verdict: CrashReportBreadcrumbData = {} + const data = await probe({ + fileExists: () => false, + spawnFn: fake.spawnFn, + onDone: (done) => (verdict = done) + }) + expect(data).toMatchObject({ status: 'failed', reason: 'all-targets-unreadable' }) + expect(isInstallDirAclPoisonVerdict(verdict)).toBe(false) + expect(fake.calls.every((call) => !existsSync(call.args[2]))).toBe(true) + }) + + it('does not report malformed saved output as a clean DACL', async () => { + const data = await probe({ + fileExists: () => false, + spawnFn: fakeSpawn(() => Buffer.from('orca\r\n', 'utf16le')).spawnFn + }) + expect(data).toMatchObject({ status: 'failed', reason: 'all-targets-unreadable' }) + expect(data.matchesPoisonSignature).toBeUndefined() + }) + it('records a failure instead of throwing when icacls cannot be read', async () => { const data = await probe({ spawnFn: fakeSpawn(() => null).spawnFn }) expect(data.status).toBe('failed') @@ -180,7 +240,7 @@ describe('probeWindowsInstallDirAcl', () => { ['darwin', { platform: 'darwin' as NodeJS.Platform }], ['serve mode', { platform: 'win32' as NodeJS.Platform, isServeMode: true }] ])('does no work on %s', async (_label, options) => { - const fake = fakeSpawn((target) => dacl(target, ORPHAN)) + const fake = fakeSpawn(() => [ORPHAN]) const record = vi.fn() const fileExists = vi.fn(() => true) probeWindowsInstallDirAcl({ @@ -197,7 +257,7 @@ describe('probeWindowsInstallDirAcl', () => { }) it('runs once per process', async () => { - const fake = fakeSpawn((target) => dacl(target, ORPHAN)) + const fake = fakeSpawn(() => [ORPHAN]) await probe({ spawnFn: fake.spawnFn }) const before = fake.calls.length probeWindowsInstallDirAcl({ platform: 'win32', installDir: INSTALL_DIR, spawnFn: fake.spawnFn }) diff --git a/src/main/startup/windows-install-dir-acl-probe.ts b/src/main/startup/windows-install-dir-acl-probe.ts index 25d1e581c9b..a717c87c241 100644 --- a/src/main/startup/windows-install-dir-acl-probe.ts +++ b/src/main/startup/windows-install-dir-acl-probe.ts @@ -1,5 +1,7 @@ import { spawn } from 'node:child_process' -import { existsSync } from 'node:fs' +import { randomUUID } from 'node:crypto' +import { existsSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { sanitizeCrashReportString, @@ -36,23 +38,16 @@ const MODULE_SHORTLIST = ['ffmpeg.dll', 'libGLESv2.dll', 'libEGL.dll', 'icudtl.d const PROBE_BUDGET_MS = 5_000 -/** Raw S-1-15-2-* means icacls could not resolve it — locale-independent. */ -const RAW_PACKAGE_SID = /\bS-1-15-2-[0-9-]+\b/i +// Why SDDL (`icacls /save`) rather than icacls's display: the display localizes +// the well-known package names — and on zh/ja/ko even keeps "NT AUTHORITY" English +// while doing so — so a repaired tree read as poisoned. SDDL prints SIDs on every locale. +const PACKAGE_SID = /^S-1-15-2-[0-9-]+$/i /** ALL RESTRICTED APPLICATION PACKAGES: the grant the reproduced remedy added. */ -const RESTRICTED_PACKAGES_SID = 's-1-15-2-2' -const WELL_KNOWN_PACKAGE_SIDS = new Set(['s-1-15-2-1', RESTRICTED_PACKAGES_SID]) -// icacls localizes these; the raw SID form is never printed for them. Orphan -// detection stays SID-form and locale-independent, but this check is not — so we -// report whether the output looked English at all, making a locale-induced -// false positive recognizable instead of silent. -const WELL_KNOWN_PACKAGE_NAMES = /ALL (RESTRICTED )?APPLICATION PACKAGES/i -const RESTRICTED_PACKAGE_NAME = /ALL RESTRICTED APPLICATION PACKAGES/i -// Not BUILTIN: fr-FR and es-ES print it verbatim while localizing the package -// names, so it is evidence of nothing. SYSTEM's ACE is on every install tree. -const ENGLISH_PRINCIPAL = /\b(NT AUTHORITY|APPLICATION PACKAGE AUTHORITY)\b/i -// Why: an ACE that denies, or only propagates to children, grants nothing on this -// object — so it cannot satisfy an orphan the way the reproduced fix did. -const NON_GRANTING_FLAGS = /\((?:DENY|IO)\)/i +const RESTRICTED_PACKAGES_SID = 'S-1-15-2-2' +/** `AC` is SDDL's alias for ALL APPLICATION PACKAGES (S-1-15-2-1). */ +const WELL_KNOWN_PACKAGE_SIDS = new Set(['AC', 'S-1-15-2-1', RESTRICTED_PACKAGES_SID]) +/** (type;flags;rights;object;inheritedObject;sid[;condition]) */ +const SDDL_ACE = /\(([A-Z]+);([A-Z]*);[^;()]*;[^;()]*;[^;()]*;([^;()]+)[;)]/gi export type WindowsInstallDirAclProbeOptions = { platform?: NodeJS.Platform @@ -69,80 +64,80 @@ type AclFacts = { orphanPackageSids: string[] hasWellKnownPackageGrant: boolean hasRestrictedPackageGrant: boolean - sawEnglishPrincipal: boolean } -function readDacl(spawnFn: typeof spawn, target: string, deadlineMs: number): Promise { - return new Promise((resolve) => { - // No flags: icacls with a bare path only reads. Never /T — a recursive walk on - // a real profile measured 62s and timed out (see windows-user-data-acl.ts). - const child = spawnFn(getIcaclsExePath(), [target], { - stdio: ['ignore', 'pipe', 'ignore'], +function readSavedDacl(spawnFn: typeof spawn, target: string, deadlineMs: number): Promise { + const saveFile = join(tmpdir(), `orca-install-acl-${randomUUID()}.txt`) + return new Promise((resolve) => { + // /save only reads the target (it writes the temp file). Never /T — a recursive + // walk on a real profile measured 62s and timed out (see windows-user-data-acl.ts). + const child = spawnFn(getIcaclsExePath(), [target, '/save', saveFile], { + stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true }) - let out = '' let settled = false - const settle = (value: string): void => { + const settle = (read: boolean): void => { if (settled) { return } settled = true clearTimeout(timer) - resolve(value) + let out = '' + try { + // An empty read parses as a clean DACL, so a failed /save must stay ''. + out = read ? readFileSync(saveFile, 'utf16le') : '' + } catch { + out = '' + } + try { + // A killed icacls may still hold the file; a leftover temp file is harmless. + rmSync(saveFile, { force: true }) + } catch { + // Nothing to do. + } + resolve(out) } const timer = setTimeout(() => { child.kill() - settle('') + settle(false) }, deadlineMs) timer.unref?.() - child.stdout?.on('data', (chunk: Buffer) => { - out += chunk.toString('utf-8') - }) - child.on('error', () => settle('')) - // 'close' not 'exit': exit can fire before stdout drains, and an empty read - // would parse as a clean DACL — a false negative in the only case we care about. - child.on('close', () => settle(out)) + child.on('error', () => settle(false)) + child.on('close', (code) => settle(code === 0)) }) } -function collectAclFacts(daclOutput: string): AclFacts { +function collectAclFacts(savedDacl: string): AclFacts | null { const orphanPackageSids: string[] = [] let hasWellKnownPackageGrant = false let hasRestrictedPackageGrant = false - let sawEnglishPrincipal = false - for (const line of daclOutput.split(/\r?\n/)) { - if (ENGLISH_PRINCIPAL.test(line)) { - sawEnglishPrincipal = true - } - // icacls glues the echoed path onto the first principal with no separator, so - // match the principal:(flags) tail rather than trying to split the line. - const ace = /([^\s:][^:]*):(\([^\s]*\))\s*$/.exec(line.trim()) - if (!ace) { + // A partial DACL can hide a later grant; unsupported ACEs must stay unreadable. + const dacl = /^D:[A-Z]*((?:\([^()]*\))*)(?:S:.*)?$/im.exec(savedDacl) + if (!dacl) { + return null + } + for (const [, type, flags, rawSid] of dacl[1].matchAll(SDDL_ACE)) { + const sid = rawSid.toUpperCase() + if (!WELL_KNOWN_PACKAGE_SIDS.has(sid)) { + if (PACKAGE_SID.test(sid)) { + orphanPackageSids.push(rawSid) + } continue } - const [, principal, flags] = ace - const rawSid = RAW_PACKAGE_SID.exec(principal)?.[0] - const sid = rawSid?.toLowerCase() - if (rawSid && !WELL_KNOWN_PACKAGE_SIDS.has(rawSid.toLowerCase())) { - orphanPackageSids.push(rawSid) - continue - } - const isWellKnown = sid !== undefined || WELL_KNOWN_PACKAGE_NAMES.test(principal) - if (!isWellKnown || NON_GRANTING_FLAGS.test(flags)) { + // Why: an ACE that denies, or only propagates to children (IO), grants nothing + // on this object — so it cannot satisfy an orphan the way the reproduced fix did. + const flagTokens: string[] = flags.toUpperCase().match(/../g) ?? [] + const inheritOnly = flagTokens.includes('IO') + if (type.toUpperCase() !== 'A' || inheritOnly) { continue } hasWellKnownPackageGrant = true // Reported, never the verdict: narrows which grant is present for triage. - if (sid === RESTRICTED_PACKAGES_SID || (!sid && RESTRICTED_PACKAGE_NAME.test(principal))) { + if (sid === RESTRICTED_PACKAGES_SID) { hasRestrictedPackageGrant = true } } - return { - orphanPackageSids, - hasWellKnownPackageGrant, - hasRestrictedPackageGrant, - sawEnglishPrincipal - } + return { orphanPackageSids, hasWellKnownPackageGrant, hasRestrictedPackageGrant } } function resolveTargets(installDir: string, fileExists: (path: string) => boolean): string[] { @@ -162,9 +157,9 @@ async function runProbe(options: WindowsInstallDirAclProbeOptions): Promise 0 ? await readDacl(spawnFn, target, remaining) : '') + outputs.push(remaining > 0 ? await readSavedDacl(spawnFn, target, remaining) : '') } - const facts = outputs.map(collectAclFacts) + const facts = outputs.map(collectAclFacts).filter((fact) => fact !== null) const orphans = [...new Set(facts.flatMap((f) => f.orphanPackageSids))] const hasWellKnownPackageGrant = facts.some((f) => f.hasWellKnownPackageGrant) const hasRestrictedPackageGrant = facts.some((f) => f.hasRestrictedPackageGrant) @@ -174,24 +169,22 @@ async function runProbe(options: WindowsInstallDirAclProbeOptions): Promise f.orphanPackageSids.length > 0 && !f.hasWellKnownPackageGrant ) - data = outputs.every((out) => out === '') - ? { status: 'failed', reason: 'all-targets-unreadable' } - : { - status: 'ok', - probedTargetCount: targets.length, - orphanPackageSidCount: orphans.length, - // Capped: correlating the same orphan across reports is what would - // identify the tool that left it, which is the point of recording it. - orphanPackageSids: sanitizeCrashReportString(orphans.slice(0, 3).join(','), 200), - // The verdict rides on this one: either well-known grant satisfies the orphan. - hasWellKnownPackageGrant, - // Diagnostic only — the -1-only shape launches clean on real hardware. - hasRestrictedPackageGrant, - // False positives are possible on a non-English Windows, where the - // well-known ACE resolves to a localized name this cannot match. - wellKnownNameCheckReliable: facts.some((f) => f.sawEnglishPrincipal), - matchesPoisonSignature: poisoned - } + data = + facts.length === 0 + ? { status: 'failed', reason: 'all-targets-unreadable' } + : { + status: 'ok', + probedTargetCount: targets.length, + orphanPackageSidCount: orphans.length, + // Capped: correlating the same orphan across reports is what would + // identify the tool that left it, which is the point of recording it. + orphanPackageSids: sanitizeCrashReportString(orphans.slice(0, 3).join(','), 200), + // The verdict rides on this one: either well-known grant satisfies the orphan. + hasWellKnownPackageGrant, + // Diagnostic only — the -1-only shape launches clean on real hardware. + hasRestrictedPackageGrant, + matchesPoisonSignature: poisoned + } } catch (error) { data = { status: 'failed', reason: sanitizeCrashReportString(`probe: ${String(error)}`, 200) } } diff --git a/src/main/startup/windows-install-dir-acl-recovery.test.ts b/src/main/startup/windows-install-dir-acl-recovery.test.ts index 7641cdc0700..23ca9de5062 100644 --- a/src/main/startup/windows-install-dir-acl-recovery.test.ts +++ b/src/main/startup/windows-install-dir-acl-recovery.test.ts @@ -32,9 +32,6 @@ import { import { ALL_PACKAGES_ACE, fakeIcaclsSpawn, - FRENCH_BASELINE_ACES, - FRENCH_RESTRICTED_PACKAGES_ACE, - icaclsDacl, ORPHAN_PACKAGE_ACE, RESTRICTED_PACKAGES_ACE } from './windows-install-dir-acl.test-fixture' @@ -49,7 +46,7 @@ type Runner = (spec: ProcessSpec) => Promise * which decides whether icacls ever runs. Only the two process seams are faked. */ function probeThenRecover( - dacl: (target: string) => string, + savedAces: (target: string) => string[], options: { failRepair?: boolean } = {} ): Promise { const specs: ProcessSpec[] = [] @@ -68,7 +65,7 @@ function probeThenRecover( platform: 'win32', installDir: INSTALL_DIR, fileExists: (path) => path.endsWith('ffmpeg.dll'), - spawnFn: fakeIcaclsSpawn(dacl).spawnFn, + spawnFn: fakeIcaclsSpawn(savedAces).spawnFn, recordBreadcrumb: () => undefined, onDone: (data) => { startWindowsInstallDirAclRepairIfPoisoned(data, { @@ -95,7 +92,7 @@ describe('startWindowsInstallDirAclRepairIfPoisoned', () => { }) it('repairs when the probe sees an orphan package ACE and no well-known grant', async () => { - const specs = await probeThenRecover((target) => icaclsDacl(target, [ORPHAN_PACKAGE_ACE])) + const specs = await probeThenRecover(() => [ORPHAN_PACKAGE_ACE]) expect(specs.map((spec) => spec.args?.[2])).toEqual([ '*S-1-15-2-2:(OI)(CI)(RX)', '*S-1-15-2-2:(RX)' @@ -106,27 +103,13 @@ describe('startWindowsInstallDirAclRepairIfPoisoned', () => { // win32 10.0.26200 / Electron 43.4.1, so it earns neither an ACL write nor the // accusing dialog copy. it('leaves an install whose package grant is ALL APPLICATION PACKAGES alone', async () => { - const specs = await probeThenRecover((target) => - icaclsDacl(target, [ORPHAN_PACKAGE_ACE, ALL_PACKAGES_ACE]) - ) + const specs = await probeThenRecover(() => [ORPHAN_PACKAGE_ACE, ALL_PACKAGES_ACE]) expect(specs).toHaveLength(0) expect(describeInstallDirAclPoison()).toBeNull() }) it('does not touch an install that already carries the restricted grant', async () => { - const specs = await probeThenRecover((target) => - icaclsDacl(target, [ORPHAN_PACKAGE_ACE, RESTRICTED_PACKAGES_ACE]) - ) - expect(specs).toHaveLength(0) - expect(describeInstallDirAclPoison()).toBeNull() - }) - - // A localized icacls prints the grant under a name the probe cannot match, so - // the signature is unproven: neither icacls nor the accusing dialog copy. - it('does not act on a signature from a non-English icacls', async () => { - const specs = await probeThenRecover((target) => - icaclsDacl(target, [ORPHAN_PACKAGE_ACE, FRENCH_RESTRICTED_PACKAGES_ACE], FRENCH_BASELINE_ACES) - ) + const specs = await probeThenRecover(() => [ORPHAN_PACKAGE_ACE, RESTRICTED_PACKAGES_ACE]) expect(specs).toHaveLength(0) expect(describeInstallDirAclPoison()).toBeNull() }) @@ -169,7 +152,7 @@ describe('describeInstallDirAclPoison', () => { }) it('offers the copyable commands, and drops them once the repair lands', async () => { - await probeThenRecover((target) => icaclsDacl(target, [ORPHAN_PACKAGE_ACE])) + await probeThenRecover(() => [ORPHAN_PACKAGE_ACE]) const repaired = describeInstallDirAclPoison() expect(repaired?.detail).toContain('Orca repaired the permissions') expect(repaired?.detail).not.toContain('Administrator Command Prompt') @@ -180,7 +163,7 @@ describe('describeInstallDirAclPoison', () => { }) it('walks a standard user through icacls when the repair could not write', async () => { - await probeThenRecover((target) => icaclsDacl(target, [ORPHAN_PACKAGE_ACE]), { + await probeThenRecover(() => [ORPHAN_PACKAGE_ACE], { failRepair: true }) const failed = describeInstallDirAclPoison() @@ -190,7 +173,7 @@ describe('describeInstallDirAclPoison', () => { it('reports the repair as in flight before icacls has answered', () => { startWindowsInstallDirAclRepairIfPoisoned( - { status: 'ok', matchesPoisonSignature: true, wellKnownNameCheckReliable: true }, + { status: 'ok', matchesPoisonSignature: true }, { platform: 'win32', installDir: INSTALL_DIR, @@ -206,8 +189,7 @@ describe('describeInstallDirAclPoison', () => { const POISON_VERDICT: CrashReportBreadcrumbData = { status: 'ok', - matchesPoisonSignature: true, - wellKnownNameCheckReliable: true + matchesPoisonSignature: true } const GPU_ENV = { appVersion: APP_VERSION, electronVersion: '43.4.1', platform: 'win32' } as const @@ -897,7 +879,7 @@ describe('the probe-pending grace window', () => { platform: 'win32' as const, installDir: INSTALL_DIR, fileExists: () => false, - spawnFn: fakeIcaclsSpawn((target) => icaclsDacl(target, [RESTRICTED_PACKAGES_ACE])).spawnFn, + spawnFn: fakeIcaclsSpawn(() => [RESTRICTED_PACKAGES_ACE]).spawnFn, recordBreadcrumb: () => undefined } let settleVerdict: () => void = () => undefined diff --git a/src/main/startup/windows-install-dir-acl.test-fixture.ts b/src/main/startup/windows-install-dir-acl.test-fixture.ts index ecbfec808f0..f28fe9c3d67 100644 --- a/src/main/startup/windows-install-dir-acl.test-fixture.ts +++ b/src/main/startup/windows-install-dir-acl.test-fixture.ts @@ -1,64 +1,69 @@ import { EventEmitter } from 'node:events' +import { writeFileSync } from 'node:fs' +import { basename } from 'node:path' import type { spawn } from 'node:child_process' -/** Shared icacls doubles for the install-dir DACL probe, repair, and recovery tests. */ +/** + * Shared icacls doubles for the install-dir DACL probe, repair, and recovery tests. + * ACEs are SDDL, the shape `icacls /save ` writes (captured on + * win32 10.0.26200: the leaf name, CRLF, then `D:AI(...)(...)`, UTF-16LE). + */ -export const ORPHAN_PACKAGE_ACE = 'S-1-15-2-999-999-999:(OI)(CI)(RX)' -export const RESTRICTED_PACKAGES_ACE = - 'APPLICATION PACKAGE AUTHORITY\\ALL RESTRICTED APPLICATION PACKAGES:(OI)(CI)(RX)' -/** The Program Files default: present on healthy installs, which launch clean. */ -export const ALL_PACKAGES_ACE = 'APPLICATION PACKAGE AUTHORITY\\ALL APPLICATION PACKAGES:(RX)' +export const ORPHAN_PACKAGE_ACE = '(A;OICI;0x1200a9;;;S-1-15-2-999-999-999)' +/** ALL RESTRICTED APPLICATION PACKAGES; SDDL has no alias for it. */ +export const RESTRICTED_PACKAGES_ACE = '(A;OICI;0x1200a9;;;S-1-15-2-2)' +/** ALL APPLICATION PACKAGES (`AC`), the Program Files default: healthy installs launch clean. */ +export const ALL_PACKAGES_ACE = '(A;;0x1200a9;;;AC)' -export const ENGLISH_BASELINE_ACES = [ - 'NT AUTHORITY\\SYSTEM:(I)(OI)(CI)(F)', - 'BUILTIN\\Administrators:(I)(OI)(CI)(F)', - 'awin\\neil:(I)(OI)(CI)(F)' +export const BASELINE_ACES = [ + '(A;OICIID;FA;;;SY)', + '(A;OICIID;FA;;;BA)', + '(A;OICIID;FA;;;S-1-5-21-432636774-4279371817-3971399515-1001)' ] -/** fr-FR icacls: no principal the English name check can recognize. */ -export const FRENCH_BASELINE_ACES = [ - 'AUTORITE NT\\Systeme:(I)(OI)(CI)(F)', - 'BUILTIN\\Administrateurs:(I)(OI)(CI)(F)' -] -export const FRENCH_RESTRICTED_PACKAGES_ACE = - "AUTORITE DE PACKAGE D'APPLICATION\\TOUS LES PACKAGES D'APPLICATION RESTREINTS:(RX)" -/** Real icacls shape: the echoed path is glued onto the first principal. */ -export function icaclsDacl( - target: string, - aces: string[], - baseline: string[] = ENGLISH_BASELINE_ACES -): string { - const [first, ...rest] = [...aces, ...baseline] - return [ - `${target} ${first}`, - ...rest.map((ace) => ` ${ace}`), - '', - 'Successfully processed 1 files' - ].join('\r\n') +/** The file `icacls /save` writes for a target carrying these ACEs. */ +export function icaclsSavedAcl(target: string, aces: string[]): Buffer { + const leaf = basename(target.replaceAll('\\', '/')) + return Buffer.from(`${leaf}\r\nD:AI${[...aces, ...BASELINE_ACES].join('')}\r\n`, 'utf16le') } -/** `null` output makes the spawn fail, as an unreadable target does. */ -export function fakeIcaclsSpawn(output: (target: string) => string | null): { +/** + * `saved` returns the target's extra ACEs, or the raw saved file; `null` makes the + * spawn fail, as an unreadable target does. `display` is what a bare + * `icacls ` prints. + */ +export function fakeIcaclsSpawn( + saved: (target: string) => string[] | Buffer | null, + display: (target: string) => string = () => '', + exitCode: number | null = 0 +): { spawnFn: typeof spawn calls: { file: string; args: string[] }[] } { const calls: { file: string; args: string[] }[] = [] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test double; the probe only touches kill/stdout/on of the child. const spawnFn = ((file: string, args: string[]) => { calls.push({ file, args }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: both fields are assigned on the next two lines. const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter kill: () => void } child.stdout = new EventEmitter() child.kill = () => undefined - const out = output(args[0]) + const [target, verb, saveFile] = args + const aces = saved(target) setImmediate(() => { - if (out === null) { + if (aces === null) { child.emit('error', new Error('ENOENT')) return } - child.stdout.emit('data', Buffer.from(out, 'utf-8')) - child.emit('close', 0) + if (verb === '/save') { + writeFileSync(saveFile, Buffer.isBuffer(aces) ? aces : icaclsSavedAcl(target, aces)) + } else { + child.stdout.emit('data', Buffer.from(display(target), 'utf-8')) + } + child.emit('close', exitCode) }) return child }) as unknown as typeof spawn diff --git a/src/main/startup/windows-install-dir-package-acl-repair.test.ts b/src/main/startup/windows-install-dir-package-acl-repair.test.ts index cc41388b811..e7222b016c0 100644 --- a/src/main/startup/windows-install-dir-package-acl-repair.test.ts +++ b/src/main/startup/windows-install-dir-package-acl-repair.test.ts @@ -312,8 +312,7 @@ describe('isInstallDirAclPoisonVerdict', () => { expect( isInstallDirAclPoisonVerdict({ status: 'ok', - matchesPoisonSignature: true, - wellKnownNameCheckReliable: true + matchesPoisonSignature: true }) ).toBe(true) expect( @@ -324,16 +323,4 @@ describe('isInstallDirAclPoisonVerdict', () => { ).toBe(false) expect(isInstallDirAclPoisonVerdict({ status: 'failed', reason: 'unreadable' })).toBe(false) }) - - // A localized icacls hides the well-known grants behind translated names, so the - // signature there is unproven: repairing and blaming the install would be wrong. - it('refuses a signature the probe could not name-check', () => { - expect( - isInstallDirAclPoisonVerdict({ - status: 'ok', - matchesPoisonSignature: true, - wellKnownNameCheckReliable: false - }) - ).toBe(false) - }) }) diff --git a/src/main/startup/windows-install-dir-package-acl-repair.ts b/src/main/startup/windows-install-dir-package-acl-repair.ts index 6bd6505a2cb..878f7c47411 100644 --- a/src/main/startup/windows-install-dir-package-acl-repair.ts +++ b/src/main/startup/windows-install-dir-package-acl-repair.ts @@ -103,13 +103,11 @@ const MAX_REPAIR_ATTEMPTS = 3 /** * The probe's verdict is the only trigger: an orphan package ACE with no - * well-known package grant to satisfy it. A localized icacls prints those grants - * under translated names the probe cannot match, so an unreliable name check is - * not evidence of poison — acting on it would spawn icacls and tell a user with a - * healthy install that their permissions are broken. + * well-known package grant to satisfy it. The probe reads SDDL, so the verdict + * holds on every Windows display language. */ export function isInstallDirAclPoisonVerdict(data: CrashReportBreadcrumbData): boolean { - return data.matchesPoisonSignature === true && data.wellKnownNameCheckReliable !== false + return data.matchesPoisonSignature === true } /** The commands to hand a user whose account cannot write the install ACL. */ diff --git a/src/main/telemetry/client.ts b/src/main/telemetry/client.ts index dc92cf1461d..50fb8cfaa3b 100644 --- a/src/main/telemetry/client.ts +++ b/src/main/telemetry/client.ts @@ -160,35 +160,47 @@ function waitForCaptureEnqueue(client: PostHog, event: EventName, uuid: string): }) } +/** Lets producers avoid preparing usage payloads when transmission is disabled. */ +export function isTelemetryEnabled(): boolean { + return ( + (testTransportEnabled || (IS_OFFICIAL_BUILD && TELEMETRY_ENABLED)) && + !shuttingDown && + posthog !== null && + commonProps !== null && + storeRef !== null && + resolveConsent(storeRef.getSettings()).effective === 'enabled' + ) +} + // No-op in contributor / non-official builds; only official stable/rc builds (CI-injected `ORCA_BUILD_IDENTITY` + `ORCA_POSTHOG_WRITE_KEY`) transmit. -export function track(name: N, props: EventProps): void { +export function track(name: N, props: EventProps): boolean { if (!testTransportEnabled && (!IS_OFFICIAL_BUILD || !TELEMETRY_ENABLED)) { - return + return false } // (1) Shutdown gate: late IPC arrivals must not enqueue against a flushing client. if (shuttingDown) { - return + return false } if (!posthog || !commonProps || !storeRef) { - return + return false } // (2) Burst cap before consent: the O(1) cap drops floods before the costly settings read, so a compromised opted-out renderer can't burn CPU. if (!consumeBurstToken(name)) { - return + return false } // (3) Consent resolve — reads live settings every call so it can't drift from persisted state / env-var precedence. const consent = resolveConsent(storeRef.getSettings()) if (consent.effective !== 'enabled') { - return + return false } // (4) Validator — single enforcement point for schema, enum, key set, and length caps. const result = validate(name, props) if (!result.ok) { - return + return false } // (5) Capture. `$process_person_profile: false` stops posthog-node creating a person per install_id (no init-time equivalent). @@ -201,6 +213,7 @@ export function track(name: N, props: EventProps): void $process_person_profile: false } }) + return true } export async function setOptIn(via: OptInVia, optedIn: boolean): Promise { diff --git a/src/main/telemetry/onboarding-cohort-classifier.test.ts b/src/main/telemetry/onboarding-cohort-classifier.test.ts index e74b0deb8d5..c6ea7c848ed 100644 --- a/src/main/telemetry/onboarding-cohort-classifier.test.ts +++ b/src/main/telemetry/onboarding-cohort-classifier.test.ts @@ -4,7 +4,7 @@ // session. See docs/onboarding-telemetry-extensions.md §2. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { ONBOARDING_FINAL_STEP } from '../../shared/constants' +import { ONBOARDING_FINAL_STEP } from '../../shared/onboarding-defaults' import type { GlobalSettings } from '../../shared/global-settings-types' import type { OnboardingState } from '../../shared/onboarding-state-types' import type { Store } from '../persistence' diff --git a/src/main/telemetry/onboarding-cohort-classifier.ts b/src/main/telemetry/onboarding-cohort-classifier.ts index 8b840dc0e17..ec368fbbb74 100644 --- a/src/main/telemetry/onboarding-cohort-classifier.ts +++ b/src/main/telemetry/onboarding-cohort-classifier.ts @@ -2,7 +2,7 @@ // Known limitation: upgrade_backfill's "completed" shape (persistence.ts:362-369) is also written by live completion, so an existing live-completer flips fresh_install→upgrade_backfill; dashboards forward-fill cohort from _started. TODO: a wasBackfilledByMigration sentinel would disambiguate. // Never throws: returns { cohort: undefined } on any read/uninit error, which the schema's .optional() cohort still validates. Mirrors sibling getCohortAtEmit's never-crash contract. -import { ONBOARDING_FINAL_STEP } from '../../shared/constants' +import { ONBOARDING_FINAL_STEP } from '../../shared/onboarding-defaults' import type { OnboardingCohort } from '../../shared/telemetry-events' import type { Store } from '../persistence' diff --git a/src/main/terminal-history-deletion.ts b/src/main/terminal-history-deletion.ts index 1fea1128ebe..97650a78ac3 100644 --- a/src/main/terminal-history-deletion.ts +++ b/src/main/terminal-history-deletion.ts @@ -1,5 +1,6 @@ import { basename, dirname, join } from 'node:path' -import { existsSync, mkdirSync, readdirSync, renameSync } from 'node:fs' +import { existsSync, mkdirSync, renameSync } from 'node:fs' +import { readdir } from 'node:fs/promises' import { removeHostTree } from './host-tree-removal' import { deleteFishHistoryFile, resolveFishHistoryDir } from './fish-history-session' import { readHistoryMeta } from './terminal-history' @@ -20,6 +21,112 @@ const historyTreeRemovalAttempts = new Map() const historyTreeRemovalRetryTimers = new Map>() const wslDistroByTombstone = new Map() +/** One root's undrained tombstone names from the last enumeration, plus its in-flight re-read. */ +type HistoryRemovalQueue = { names: string[]; refill: Promise | null } +// Why hold the names instead of re-reading per completion: one `readdir` already materialises every +// name, so keeping it until it runs out makes a backlog of N tombstones cost a handful of reads, not N. +const historyRemovalQueues = new Map() + +function historyRemovalQueueFor(historyRoot: string): HistoryRemovalQueue { + const existing = historyRemovalQueues.get(historyRoot) + if (existing) { + return existing + } + const queue: HistoryRemovalQueue = { names: [], refill: null } + historyRemovalQueues.set(historyRoot, queue) + return queue +} + +function historyTreeRemovalsAtCapacity(): boolean { + return ( + pendingHistoryTreeRemovals.size + historyTreeRemovalRetryTimers.size >= + MAX_PENDING_HISTORY_TREE_REMOVALS + ) +} + +function isHistoryTreeRemovalTracked(dir: string): boolean { + return pendingHistoryTreeRemovals.has(dir) || historyTreeRemovalRetryTimers.has(dir) +} + +/** Admit already-enumerated tombstones up to the admission cap; the rest stay queued for a later slot. */ +function admitQueuedHistoryTreeRemovals(historyRoot: string, queue: HistoryRemovalQueue): void { + const pendingRoot = getPendingDeleteRoot(historyRoot) + const wslDistro = wslDistroForHistoryRoot(historyRoot) + while (!historyTreeRemovalsAtCapacity()) { + const name = queue.names.pop() + if (name === undefined) { + return + } + // Safe to re-admit a name removed since enumeration: `removeHostTree` forces the rm. + scheduleHistoryTreeRemoval(join(pendingRoot, name), wslDistro) + } +} + +/** Re-read one root's tombstone directory, coalescing concurrent requests onto a single readdir. */ +function refillHistoryRemovalQueue(historyRoot: string, queue: HistoryRemovalQueue): Promise { + if (queue.refill) { + return queue.refill + } + const pendingRoot = getPendingDeleteRoot(historyRoot) + // Why snapshot rather than only test tracking when the read lands: a removal under way now can + // still show up in the read and also finish before it resolves, which would look admissible again. + const removalsUnderWay = new Set(pendingHistoryTreeRemovals.keys()) + queue.refill = readTombstoneNames(historyRoot).then((names) => { + // A teardown that dropped this queue must not resurrect its removals. + if (historyRemovalQueues.get(historyRoot) !== queue) { + return + } + queue.refill = null + try { + // Dropping names already handled keeps the read that confirms a drained directory from + // re-queueing work, so the tail of a backlog does not cost one read per completion. + queue.names = names.filter((name) => { + const dir = join(pendingRoot, name) + return !removalsUnderWay.has(dir) && !isHistoryTreeRemovalTracked(dir) + }) + admitQueuedHistoryTreeRemovals(historyRoot, queue) + } catch (err) { + // Non-fatal, and never a rejection: callers fire this off without awaiting it. + console.warn( + `[pty:history] Failed to queue pending history removals: ${err instanceof Error ? err.message : String(err)}` + ) + } + }) + return queue.refill +} + +async function readTombstoneNames(historyRoot: string): Promise { + try { + return await readdir(getPendingDeleteRoot(historyRoot)) + } catch (err) { + // An absent root is the normal steady state; anything else means tombstones may linger until a + // later completion re-reads, so it is worth a line. + const code = err && typeof err === 'object' && 'code' in err ? String(err.code) : '' + if (code !== 'ENOENT') { + console.warn( + `[pty:history] Failed to read pending history removals for ${historyRoot}: ${err instanceof Error ? err.message : String(err)}` + ) + } + return [] + } +} + +/** Reopen the admission window after a completion — from the queues, or one read once they empty. */ +function replenishHistoryTreeRemovals(historyRoot: string): void { + const queue = historyRemovalQueueFor(historyRoot) + admitQueuedHistoryTreeRemovals(historyRoot, queue) + // Why other roots too: a slot this root cannot fill is the only thing a sibling root displaced at + // the cap is waiting for, and nothing else would offer it one before the next startup. + for (const [otherRoot, otherQueue] of historyRemovalQueues) { + if (otherQueue !== queue) { + admitQueuedHistoryTreeRemovals(otherRoot, otherQueue) + } + } + if (queue.names.length === 0 && !historyTreeRemovalsAtCapacity()) { + void refillHistoryRemovalQueue(historyRoot, queue) + } +} + function wslDistroForHistoryRoot(historyRoot: string): string | undefined { return basename(dirname(historyRoot)) === 'terminal-history-wsl' ? basename(historyRoot) @@ -83,10 +190,7 @@ function scheduleHistoryTreeRemoval(dir: string, wslDistro?: string): void { return } // Leave excess tombstones on disk; admission is intentionally bounded. - if ( - pendingHistoryTreeRemovals.size + historyTreeRemovalRetryTimers.size >= - MAX_PENDING_HISTORY_TREE_REMOVALS - ) { + if (historyTreeRemovalsAtCapacity()) { return } // A rescan must not cancel a delayed retry for a real failure. @@ -130,7 +234,7 @@ function scheduleHistoryTreeRemoval(dir: string, wslDistro?: string): void { pendingHistoryTreeRemovals.delete(dir) } if (removalSucceeded) { - schedulePendingHistoryTreeRemovals(historyRootForTombstone(dir)) + replenishHistoryTreeRemovals(historyRootForTombstone(dir)) } }) pendingHistoryTreeRemovals.set(dir, removal) @@ -159,31 +263,26 @@ export function scheduleWorktreeHistoryTreeDeletion(dir: string, historyRoot: st return true } -/** Schedule tombstoned trees under one history root for async removal — the retry after a quit mid-rm. */ -export function schedulePendingHistoryTreeRemovals(historyRoot: string): void { - const pendingRoot = getPendingDeleteRoot(historyRoot) - if (!existsSync(pendingRoot)) { - return - } - try { - for (const entry of readdirSync(pendingRoot)) { - scheduleHistoryTreeRemoval(join(pendingRoot, entry), wslDistroForHistoryRoot(historyRoot)) - } - } catch { - // Non-fatal. - } +/** Schedule tombstoned trees under one history root for async removal — the retry after a quit mid-rm. + * Resolves once the enumeration landed; the removals it admitted keep draining in the background. */ +export function schedulePendingHistoryTreeRemovals(historyRoot: string): Promise { + return refillHistoryRemovalQueue(historyRoot, historyRemovalQueueFor(historyRoot)) } /** Schedule tombstoned trees under every history root, native and WSL. */ -export function scheduleAllPendingHistoryTreeRemovals(): void { - schedulePendingHistoryTreeRemovals(getHistoryRoot()) - for (const distroRoot of listWslHistoryRoots()) { - schedulePendingHistoryTreeRemovals(distroRoot) - } +export async function scheduleAllPendingHistoryTreeRemovals(): Promise { + await Promise.all([ + schedulePendingHistoryTreeRemovals(getHistoryRoot()), + ...listWslHistoryRoots().map((distroRoot) => schedulePendingHistoryTreeRemovals(distroRoot)) + ]) } -/** Drop every armed retry timer so a fixture teardown cannot resurrect a removal. Tests only. */ +/** Drop queued tombstone names and retries so fixture teardown cannot resurrect a removal. Tests only. */ export function cancelPendingHistoryTreeRemovalRetries(): void { + historyRemovalQueues.clear() + // Why also the in-flight map: a fixture that never settles a held removal would otherwise leave it + // for the next test's flush to wait on forever. + pendingHistoryTreeRemovals.clear() for (const timer of historyTreeRemovalRetryTimers.values()) { clearTimeout(timer) } @@ -195,11 +294,22 @@ export function cancelPendingHistoryTreeRemovalRetries(): void { /** Drain every history root's tombstones and await the in-flight removals. Tests only: production * schedules the same drain from startup GC and headless serve without ever blocking on it. */ export async function flushPendingWorktreeHistoryDeletions(): Promise { - scheduleAllPendingHistoryTreeRemovals() + await scheduleAllPendingHistoryTreeRemovals() // Why loop: awaiting one snapshot of the map would return with a removal scheduled mid-batch still - // in flight. Each pass settles its batch and drains whatever was added while it ran. - while (pendingHistoryTreeRemovals.size > 0) { - await Promise.all(pendingHistoryTreeRemovals.values()) + // in flight. Each pass admits every queued name the cap allows, then settles what is outstanding. + while (true) { + for (const [root, queue] of historyRemovalQueues) { + admitQueuedHistoryTreeRemovals(root, queue) + } + const outstanding = [ + ...pendingHistoryTreeRemovals.values(), + ...[...historyRemovalQueues.values()].flatMap((queue) => (queue.refill ? [queue.refill] : [])) + ] + if (outstanding.length === 0) { + // Nothing in flight and nothing admissible: only delayed retries can still make progress. + return + } + await Promise.all(outstanding) } } diff --git a/src/main/terminal-history-gc-fs-call-count.test.ts b/src/main/terminal-history-gc-fs-call-count.test.ts index d6c10b57951..4ed0cb816cd 100644 --- a/src/main/terminal-history-gc-fs-call-count.test.ts +++ b/src/main/terminal-history-gc-fs-call-count.test.ts @@ -167,15 +167,20 @@ describe('history GC filesystem request count', () => { await runHistoryGc(live) + const tombstoneLists = fsCalls.readdir.filter((p) => p.endsWith(PENDING_DELETE_DIR_NAME)) + const scanLists = fsCalls.readdir.filter((p) => !p.endsWith(PENDING_DELETE_DIR_NAME)) // The size estimation walked into every directory; the pass now only lists the root. - expect(fsCalls.readdir).toEqual([historyRoot]) + expect(scanLists).toEqual([historyRoot]) + // The tombstone drain (formerly a blocking readdirSync per completion) lists pending-delete a + // bounded number of times, never once per entry it removes. + expect(tombstoneLists.length).toBeLessThanOrEqual(12) // No stat on the entries themselves: the root dirent already carries the type. expect(statsOnEntries()).toEqual([]) // The one surviving stat per directory is readHistoryMetaAsync enforcing its size cap. expect(statsInsideEntries().sort()).toEqual( Array.from({ length: DIR_COUNT }, (_, i) => join(historyRoot, `wt-${i}`, 'meta.json')).sort() ) - expect(fsCalls.readdir.length + fsCalls.stat.length).toBe(1 + DIR_COUNT) + expect(scanLists.length + fsCalls.stat.length).toBe(1 + DIR_COUNT) const after = survivingDirs() expect(expected.size).toBe(DIR_COUNT / ORPHAN_EVERY) diff --git a/src/main/terminal-history-gc.ts b/src/main/terminal-history-gc.ts index f62ebab9ee5..7882ec963a4 100644 --- a/src/main/terminal-history-gc.ts +++ b/src/main/terminal-history-gc.ts @@ -148,7 +148,7 @@ async function executeHistoryGc(liveWorktreeIds: Set, signal: AbortSigna // Why: finish tombstones left by quit mid-rm before scanning live worktree hashes. // Safe ahead of the guard below: these entries were already condemned by a // completed GC, and leaving them renamed-but-present strands disk forever. - schedulePendingHistoryTreeRemovals(getHistoryRoot()) + await schedulePendingHistoryTreeRemovals(getHistoryRoot()) // Why refuse rather than treat every entry as orphaned: an empty live set is // what a store that fell back to default state looks like, and it cannot be // told apart from a user who genuinely has no worktrees — who also has no @@ -169,7 +169,7 @@ async function executeHistoryGc(liveWorktreeIds: Set, signal: AbortSigna if (signal.aborted) { break } - schedulePendingHistoryTreeRemovals(distroRoot) + await schedulePendingHistoryTreeRemovals(distroRoot) const r = await gcScanRoot(distroRoot, liveWorktreeIds, signal) wslTotals.totalDirs += r.totalDirs wslTotals.orphaned += r.orphaned diff --git a/src/main/terminal-history-tombstone-retry.test.ts b/src/main/terminal-history-tombstone-retry.test.ts index 1d7a606206a..cbfb10cc2b7 100644 --- a/src/main/terminal-history-tombstone-retry.test.ts +++ b/src/main/terminal-history-tombstone-retry.test.ts @@ -30,6 +30,16 @@ import { schedulePendingHistoryTreeRemovals } from './terminal-history-deletion' +// Captured before any fake clock replaces the global: refilling the removal queue reads the +// tombstone directory for real, and only a turn of the actual event loop can land that read. +const realSetImmediate = setImmediate + +async function settleTombstoneDirectoryReads(): Promise { + for (let index = 0; index < 10; index++) { + await new Promise((resolve) => realSetImmediate(resolve)) + } +} + /** A tombstone whose rm fails once used to sit on disk for the rest of the session — only the next * process start re-queued it. Prove the failure re-arms in-process, and that it stays bounded. */ describe('tombstoned history removal retries', () => { @@ -124,9 +134,11 @@ describe('tombstoned history removal retries', () => { expect(deleteWslFishHistoryFileMock).toHaveBeenCalledTimes(64) expect(releases).toHaveLength(64) - while (releases.length > 0) { + for (let pass = 0; removeHostTreeMock.mock.calls.length < 1_000; pass++) { + expect(pass).toBeLessThan(200) releases.splice(0).forEach((release) => release()) await vi.advanceTimersByTimeAsync(0) + await settleTombstoneDirectoryReads() } expect(removeHostTreeMock).toHaveBeenCalledTimes(1_000) expect(deleteWslFishHistoryFileMock).toHaveBeenCalledTimes(1_000) diff --git a/src/main/terminal-history-tombstone-scan-cost.test.ts b/src/main/terminal-history-tombstone-scan-cost.test.ts new file mode 100644 index 00000000000..002763750d6 --- /dev/null +++ b/src/main/terminal-history-tombstone-scan-cost.test.ts @@ -0,0 +1,280 @@ +import { mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' + +const reads = vi.hoisted(() => ({ count: 0, names: 0, failNext: 0 })) +const { removeHostTreeMock } = vi.hoisted(() => ({ + removeHostTreeMock: vi.fn<(dir: string) => Promise>() +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const fsp = await importOriginal() + return { + ...fsp, + readdir: async (...args: Parameters) => { + if (!String(args[0]).endsWith('.pending-delete')) { + return fsp.readdir(...args) + } + reads.count++ + if (reads.failNext > 0) { + reads.failNext-- + throw new Error('EACCES: permission denied, scandir') + } + const result = await fsp.readdir(...args) + reads.names += Array.isArray(result) ? result.length : 0 + return result + } + } +}) + +vi.mock('./host-tree-removal', () => ({ removeHostTree: removeHostTreeMock })) + +import { + cancelPendingHistoryTreeRemovalRetries, + flushPendingWorktreeHistoryDeletions, + HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS, + MAX_PENDING_HISTORY_TREE_REMOVALS, + schedulePendingHistoryTreeRemovals +} from './terminal-history-deletion' + +describe('terminal history tombstone scan cost', () => { + let userDataDir: string + + beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'orca-history-scan-cost-')) + installFakeAppEnvironment({ getPath: () => userDataDir }) + reads.count = 0 + reads.names = 0 + reads.failNext = 0 + removeHostTreeMock.mockReset() + }) + + afterEach(() => { + cancelPendingHistoryTreeRemovalRetries() + vi.useRealTimers() + rmSync(userDataDir, { recursive: true, force: true }) + }) + + function seedTombstones(root: string, count: number): void { + for (let index = 0; index < count; index++) { + mkdirSync(join(root, '.pending-delete', `old-session-${index}`), { + recursive: true + }) + } + } + + function holdRemovals(): (() => void)[] { + const releases: (() => void)[] = [] + removeHostTreeMock.mockImplementation( + (dir) => + new Promise((resolve) => + releases.push(() => { + rmSync(dir, { recursive: true, force: true }) + resolve() + }) + ) + ) + return releases + } + + /** Drain only the microtask queue, so no queued directory read can land yet. */ + async function settleRemovalPromises(): Promise { + for (let index = 0; index < 8; index++) { + await Promise.resolve() + } + } + + /** Let real filesystem reads land. Callers using fake timers must drive the clock instead. */ + async function waitFor(label: string, condition: () => boolean): Promise { + for (let index = 0; index < 500; index++) { + if (condition()) { + return + } + await new Promise((resolve) => setTimeout(resolve, 2)) + } + throw new Error(`Timed out waiting for ${label}`) + } + + it('drains 1024 tombstones without re-reading the directory per completion', async () => { + const root = join(userDataDir, 'terminal-history') + const pendingRoot = join(root, '.pending-delete') + seedTombstones(root, 1024) + const releases = holdRemovals() + + await schedulePendingHistoryTreeRemovals(root) + expect(reads.count).toBe(1) + expect(removeHostTreeMock).toHaveBeenCalledTimes(MAX_PENDING_HISTORY_TREE_REMOVALS) + while (releases.length > 0) { + expect(releases.length).toBeLessThanOrEqual(MAX_PENDING_HISTORY_TREE_REMOVALS) + releases.splice(0).forEach((release) => release()) + await settleRemovalPromises() + } + + expect(removeHostTreeMock).toHaveBeenCalledTimes(1024) + expect(readdirSync(pendingRoot)).toEqual([]) + // One read fills the queue; the handful after it only confirm the directory drained, so the + // count must stay flat in the backlog size rather than tracking completion batches. + expect(reads.count).toBeLessThanOrEqual(8) + expect(reads.names).toBeLessThanOrEqual(1024 + MAX_PENDING_HISTORY_TREE_REMOVALS) + }) + + it('picks up a tombstone created after the queued names were read', async () => { + const root = join(userDataDir, 'terminal-history') + const pendingRoot = join(root, '.pending-delete') + seedTombstones(root, 1) + const releases = holdRemovals() + + await schedulePendingHistoryTreeRemovals(root) + mkdirSync(join(pendingRoot, 'late-session')) + releases.splice(0).forEach((release) => release()) + + // The completion exhausted the queue, so its refill read discovers the late arrival. + await waitFor('late tombstone admission', () => removeHostTreeMock.mock.calls.length === 2) + releases.splice(0).forEach((release) => release()) + await flushPendingWorktreeHistoryDeletions() + expect(readdirSync(pendingRoot)).toEqual([]) + }) + + it('drains both roots within the shared admission cap without per-completion reads', async () => { + const nativeRoot = join(userDataDir, 'terminal-history') + const wslRoot = join(userDataDir, 'terminal-history-wsl', 'Ubuntu') + seedTombstones(nativeRoot, 160) + seedTombstones(wslRoot, 160) + let inFlight = 0 + let peakInFlight = 0 + removeHostTreeMock.mockImplementation(async (dir) => { + inFlight++ + peakInFlight = Math.max(peakInFlight, inFlight) + await new Promise((resolve) => setTimeout(resolve, 0)) + rmSync(dir, { recursive: true, force: true }) + inFlight-- + }) + + await flushPendingWorktreeHistoryDeletions() + + expect(removeHostTreeMock).toHaveBeenCalledTimes(320) + expect(peakInFlight).toBeLessThanOrEqual(MAX_PENDING_HISTORY_TREE_REMOVALS) + expect(readdirSync(join(nativeRoot, '.pending-delete'))).toEqual([]) + expect(readdirSync(join(wslRoot, '.pending-delete'))).toEqual([]) + // Two roots, so a couple of reads each rather than one per completion. + expect(reads.count).toBeLessThanOrEqual(12) + }) + + it('keeps draining after a directory read fails mid-drain', async () => { + const root = join(userDataDir, 'terminal-history') + const pendingRoot = join(root, '.pending-delete') + seedTombstones(root, MAX_PENDING_HISTORY_TREE_REMOVALS + 1) + const releases = holdRemovals() + + await schedulePendingHistoryTreeRemovals(root) + expect(removeHostTreeMock).toHaveBeenCalledTimes(MAX_PENDING_HISTORY_TREE_REMOVALS) + + // The refill these completions request is the one that fails. + reads.failNext = 1 + releases.splice(0).forEach((release) => release()) + await settleRemovalPromises() + // The queued 65th still came from memory, so the failed read cost no progress. + expect(removeHostTreeMock).toHaveBeenCalledTimes(MAX_PENDING_HISTORY_TREE_REMOVALS + 1) + + mkdirSync(join(pendingRoot, 'late-after-failed-read')) + releases.splice(0).forEach((release) => release()) + // A later completion must be able to read again rather than stay wedged on the failure. + await waitFor( + 'recovery after failed read', + () => removeHostTreeMock.mock.calls.length === MAX_PENDING_HISTORY_TREE_REMOVALS + 2 + ) + releases.splice(0).forEach((release) => release()) + await flushPendingWorktreeHistoryDeletions() + expect(readdirSync(pendingRoot)).toEqual([]) + }) + + it('drops an in-flight refill during fixture cleanup', async () => { + const root = join(userDataDir, 'terminal-history') + seedTombstones(root, MAX_PENDING_HISTORY_TREE_REMOVALS + 1) + const releases = holdRemovals() + + await schedulePendingHistoryTreeRemovals(root) + mkdirSync(join(root, '.pending-delete', 'late-session')) + releases.splice(0).forEach((release) => release()) + await settleRemovalPromises() + const admittedBeforeCleanup = removeHostTreeMock.mock.calls.length + + cancelPendingHistoryTreeRemovalRetries() + await waitFor('the dropped refill to land', () => reads.count >= 2) + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(removeHostTreeMock).toHaveBeenCalledTimes(admittedBeforeCleanup) + }) + + it('preserves delayed failure retries while successful removals replenish the queue', async () => { + // Fake timers must be armed before the failure so its retry lands on the test clock. + vi.useFakeTimers() + const root = join(userDataDir, 'terminal-history') + seedTombstones(root, 130) + const failedDir = join(root, '.pending-delete', 'old-session-0') + removeHostTreeMock.mockImplementation(async (dir) => { + if (dir === failedDir) { + throw new Error('EBUSY') + } + rmSync(dir, { recursive: true, force: true }) + }) + await flushPendingWorktreeHistoryDeletions() + expect(removeHostTreeMock).toHaveBeenCalledTimes(130) + expect(readdirSync(join(root, '.pending-delete'))).toEqual(['old-session-0']) + + await vi.advanceTimersByTimeAsync(HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS[0] - 1) + expect(removeHostTreeMock).toHaveBeenCalledTimes(130) + await vi.advanceTimersByTimeAsync(1) + expect(removeHostTreeMock).toHaveBeenCalledTimes(131) + await vi.advanceTimersByTimeAsync(HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS[1]) + expect(removeHostTreeMock).toHaveBeenCalledTimes(132) + expect(vi.getTimerCount()).toBe(0) + }) + + it('does not retry a tombstone past its attempt budget when another root frees slots', async () => { + vi.useFakeTimers() + const nativeRoot = join(userDataDir, 'terminal-history') + const wslRoot = join(userDataDir, 'terminal-history-wsl', 'Ubuntu') + seedTombstones(nativeRoot, 32) + seedTombstones(wslRoot, 130) + const releases: (() => void)[] = [] + const attemptsByDir = new Map() + removeHostTreeMock.mockImplementation((dir) => { + attemptsByDir.set(dir, (attemptsByDir.get(dir) ?? 0) + 1) + if (dir.startsWith(wslRoot)) { + return Promise.reject(new Error('EBUSY')) + } + return new Promise((resolve) => + releases.push(() => { + rmSync(dir, { recursive: true, force: true }) + resolve() + }) + ) + }) + await schedulePendingHistoryTreeRemovals(nativeRoot) + await schedulePendingHistoryTreeRemovals(wslRoot) + + // Every WSL tombstone that was admitted exhausts its retries while native removals stay in flight. + for (const delay of HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS) { + await vi.advanceTimersByTimeAsync(delay) + } + expect(vi.getTimerCount()).toBe(0) + const maxAttempts = 1 + HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS.length + const exhausted = [...attemptsByDir.entries()].filter(([, count]) => count === maxAttempts) + expect(exhausted.length).toBeGreaterThan(0) + + releases.splice(0).forEach((release) => release()) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(0) + // Freeing another root's slots may give untried tombstones a first attempt, but must never hand + // an exhausted one a fresh attempt. + for (const count of attemptsByDir.values()) { + expect(count).toBeLessThanOrEqual(maxAttempts) + } + for (const [dir] of exhausted) { + expect(attemptsByDir.get(dir)).toBe(maxAttempts) + } + }) +}) diff --git a/src/main/terminal-history.test.ts b/src/main/terminal-history.test.ts index 8dd25ea7ddf..c5ce990f32c 100644 --- a/src/main/terminal-history.test.ts +++ b/src/main/terminal-history.test.ts @@ -62,7 +62,9 @@ vi.mock('fs', () => ({ // transitive readFile/mkdir would otherwise resolve to undefined. vi.mock('node:fs/promises', async () => ({ ...(await vi.importActual('node:fs/promises')), - rm: rmAsyncMock + rm: rmAsyncMock, + // The tombstone drain lists pending-delete asynchronously; same fixture as the sync reads. + readdir: async (path: string) => readdirSyncMock(path) ?? [] })) const { parseWslPathMock, toLinuxPathMock } = vi.hoisted(() => ({ diff --git a/src/main/text-generation/command-environment-process.test.ts b/src/main/text-generation/command-environment-process.test.ts new file mode 100644 index 00000000000..bdc04daca3f --- /dev/null +++ b/src/main/text-generation/command-environment-process.test.ts @@ -0,0 +1,160 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterAll, describe, expect, it, vi } from 'vitest' +import { + withCodexHomeProcessLock, + resolveCodexHomeProcessLockKey +} from '../codex-cli/codex-home-process-lock' +import { spawnSourceControlAgent } from './source-control-agent-launch' +import { generateCommitMessage } from './source-control-text-generation-requests' +import { discoverModelsLocal } from './commit-message-model-discovery' +import { + discoverCommitMessageModelsLocal, + generateBranchNameFromContext, + generateCommitMessageFromContext, + generatePullRequestFieldsFromContext +} from './commit-message-text-generation' + +const folder = mkdtempSync(join(tmpdir(), 'orca-command-env-')) +const script = join(folder, 'agent.cjs') +const literal = 'spaces $HOME;$(echo unsafe) `echo unsafe` * = value' +writeFileSync( + script, + ` +let prompt = '' +process.stdin.on('data', chunk => prompt += chunk) +process.stdin.on('end', () => { + if (process.env.ORCA_COMMAND_VALUE !== ${JSON.stringify(literal)}) { + console.error('command environment missing or expanded') + process.exitCode = 3 + return + } + if (process.argv.includes('debug')) console.log(JSON.stringify({models:[{slug:'gpt-5.5',display_name:'GPT-5.5'}]})) + else if (process.argv.includes('models')) console.log('anthropic/claude-sonnet-4') + else if (prompt.includes('branch name')) console.log('honor-command-environment') + else if (prompt.includes('JSON')) console.log(JSON.stringify({base:'main',title:'Honor command environment',body:'Passed environment.',draft:true})) + else console.log('Honor command environment') +}) +` +) +afterAll(() => rmSync(folder, { recursive: true, force: true })) + +// Forward slashes remain valid Windows paths and avoid POSIX command-template escaping. +const override = `ORCA_COMMAND_VALUE='${literal}' "${process.execPath.replaceAll('\\', '/')}" "${script.replaceAll('\\', '/')}"` +const target = { + kind: 'local', + cwd: folder, + env: { ...process.env, ORCA_COMMAND_VALUE: 'base' } +} as const +const params = { agentId: 'custom', model: '', customAgentCommand: override } as const + +describe('environment-prefixed commands with real child processes', () => { + it.each(['generation', 'discovery'] as const)( + 'queues Codex %s on the overridden home before spawning', + async (operation) => { + const home = join(folder, operation).replaceAll('\\', '/') + let release!: () => void + const held = withCodexHomeProcessLock( + resolveCodexHomeProcessLockKey(home), + () => + new Promise((resolve) => { + release = resolve + }) + ) + await Promise.resolve() + const spawnAgent = vi.fn(spawnSourceControlAgent) + const command = `CODEX_HOME="${home}" ${override}` + const pending = + operation === 'generation' + ? generateCommitMessage({ + context: { branch: 'main', stagedSummary: 'M README.md', stagedPatch: '+test' }, + params: { agentId: 'codex', model: 'gpt-5.5', agentCommandOverride: command }, + target, + spawnAgent + }) + : discoverModelsLocal({ + agentId: 'codex', + env: target.env, + agentCommandOverride: command, + options: { cwd: folder }, + backslash: process.platform === 'win32' ? 'literal' : 'escape', + spawnAgent + }) + try { + await new Promise((resolve) => setImmediate(resolve)) + expect(spawnAgent).not.toHaveBeenCalled() + } finally { + release() + await held + } + await expect(pending).resolves.toMatchObject( + operation === 'discovery' ? { success: true, catalogOrigin: 'probe' } : { success: true } + ) + expect(spawnAgent).toHaveBeenCalledWith( + expect.objectContaining({ env: expect.objectContaining({ CODEX_HOME: home }) }) + ) + } + ) + + it('generates commits from a custom command in a plain folder', async () => { + await expect( + generateCommitMessageFromContext( + { branch: 'main', stagedSummary: 'M README.md', stagedPatch: '+test' }, + params, + target + ) + ).resolves.toMatchObject({ success: true, message: 'Honor command environment' }) + }) + + it('generates commits from a preset override', async () => { + await expect( + generateCommitMessageFromContext( + { branch: 'main', stagedSummary: 'M README.md', stagedPatch: '+test' }, + { agentId: 'claude', model: 'sonnet', agentCommandOverride: override }, + target + ) + ).resolves.toMatchObject({ success: true, message: 'Honor command environment' }) + }) + + it('generates branch names', async () => { + await expect( + generateBranchNameFromContext({ firstPrompt: 'Honor command environment' }, params, target) + ).resolves.toMatchObject({ success: true, slug: 'honor-command-environment' }) + }) + + it.each(['github', 'gitlab'] as const)('generates %s review fields', async (provider) => { + await expect( + generatePullRequestFieldsFromContext( + { + branch: 'test', + base: 'main', + branchChangedByPreparation: false, + currentTitle: '', + currentBody: '', + currentDraft: true, + commitSummary: 'Change', + changeSummary: 'M README.md', + patch: '+test', + provider + }, + params, + target + ) + ).resolves.toMatchObject({ + success: true, + fields: { + title: 'Honor command environment', + body: 'Passed environment.', + base: 'main', + draft: true + } + }) + }) + + it('discovers models through the same override environment', async () => { + await expect( + discoverCommitMessageModelsLocal('opencode', target.env, override, { cwd: folder }) + ).resolves.toMatchObject({ success: true, models: [{ id: 'anthropic/claude-sonnet-4' }] }) + }) +}) diff --git a/src/main/text-generation/command-environment-wsl.test.ts b/src/main/text-generation/command-environment-wsl.test.ts new file mode 100644 index 00000000000..e7408a2e059 --- /dev/null +++ b/src/main/text-generation/command-environment-wsl.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { wslAwareSpawn } from '../git/runner' +import { spawnSourceControlAgent } from './source-control-agent-launch' +import { withPlatform } from './commit-message-text-generation-test-harness' +import { resolveCodexHomeProcessLockKeyForSpawnEnv } from '../codex-cli/codex-home-process-lock' + +vi.mock('../git/runner', () => ({ wslAwareSpawn: vi.fn() })) +afterEach(() => vi.unstubAllEnvs()) + +describe('command environment on WSL', () => { + it('applies literal assignments in the guest after the login shell, without changing launcher PATH', () => { + withPlatform('win32', () => { + spawnSourceControlAgent({ + binary: 'claude', + args: ['-p'], + cwd: '\\\\wsl$\\Ubuntu\\repo', + wslDistro: 'Ubuntu', + env: undefined, + commandEnv: { PATH: '/guest/bin', VALUE: '$HOME;$(echo unsafe)' }, + stdinMode: 'pipe', + useCwdForNative: true + }) + }) + expect(wslAwareSpawn).toHaveBeenCalledWith( + '/usr/bin/env', + ['PATH=/guest/bin', 'VALUE=$HOME;$(echo unsafe)', 'claude', '-p'], + expect.objectContaining({ wslDistro: 'Ubuntu', windowsHide: true, useWslLoginShell: true }) + ) + const options = vi.mocked(wslAwareSpawn).mock.calls[0][2] + expect(options.env?.PATH).not.toBe('/guest/bin') + expect(options.env?.VALUE).toBeUndefined() + }) + + it('locks an explicitly assigned guest Codex home even when it matches the host value', () => { + vi.stubEnv('CODEX_HOME', '/same/home') + const explicit = resolveCodexHomeProcessLockKeyForSpawnEnv( + { CODEX_HOME: '/same/home' }, + 'Ubuntu', + { CODEX_HOME: '/same/home' } + ) + const managed = resolveCodexHomeProcessLockKeyForSpawnEnv( + { CODEX_HOME: '/other/home' }, + 'Ubuntu' + ) + expect(explicit).toContain('/same/home') + expect(explicit).not.toContain('.orca-default-codex-home') + expect(explicit).not.toBe(managed) + }) +}) diff --git a/src/main/text-generation/commit-message-model-discovery-policy.ts b/src/main/text-generation/commit-message-model-discovery-policy.ts index 88b8e1680e1..fa2b8869a37 100644 --- a/src/main/text-generation/commit-message-model-discovery-policy.ts +++ b/src/main/text-generation/commit-message-model-discovery-policy.ts @@ -61,8 +61,8 @@ export function finalizeModelDiscoveryOutput( // should keep its configured provider even when discovery lists concrete models. const defaultModelId = spec.defaultModelId === 'default' || models.some((model) => model.id === spec.defaultModelId) - ? spec.defaultModelId - : models[0].id + ? spec.defaultModelId + : models[0].id return staticModelDiscoveryResult(spec, models, defaultModelId, 'probe') } @@ -85,7 +85,8 @@ export function planModelDiscovery( binary: command.binary, args: [...command.prefixArgs, ...modelDiscovery.args], stdinPayload: modelDiscovery.stdinPayload ?? null, - label: spec.label + label: spec.label, + ...(command.env ? { env: command.env } : {}) } } } diff --git a/src/main/text-generation/commit-message-model-discovery.ts b/src/main/text-generation/commit-message-model-discovery.ts index ec6be0f12bb..80dbbb205a3 100644 --- a/src/main/text-generation/commit-message-model-discovery.ts +++ b/src/main/text-generation/commit-message-model-discovery.ts @@ -1,3 +1,4 @@ +import { mergeCommandEnvironment } from '../../shared/command-environment' import type { CommandTemplateBackslash } from '../../shared/commit-message-prompt' import type { CommitMessagePlan } from '../../shared/commit-message-plan' import { getAgentModelProbeSpec } from '../../shared/agent-model-probe-spec' @@ -46,6 +47,16 @@ export async function discoverModelsLocal(input: { return staticModelDiscoveryResult(spec) } + const planned = planModelDiscovery(spec, input.agentCommandOverride, input.backslash) + if (!planned.ok) { + return { success: false, error: planned.error } + } + const env = mergeCommandEnvironment( + input.env, + planned.plan.env, + input.options.wslDistro ? 'linux' : process.platform + ) + const startDiscovery = (): LocalProcessExecution => { let markProcessClosed!: () => void const processClosed = new Promise((resolve) => { @@ -53,18 +64,13 @@ export async function discoverModelsLocal(input: { }) const result = new Promise((resolve) => { let child: SpawnedSourceControlAgentProcess - const planned = planModelDiscovery(spec, input.agentCommandOverride, input.backslash) - if (!planned.ok) { - markProcessClosed() - resolve({ success: false, error: planned.error }) - return - } try { child = input.spawnAgent({ binary: planned.plan.binary, args: planned.plan.args, cwd: input.options.cwd, - env: input.env, + env: input.options.wslDistro ? input.env : env, + commandEnv: planned.plan.env, wslDistro: input.options.wslDistro, stdinMode: planned.plan.stdinPayload === null ? 'ignore' : 'pipe', useCwdForNative: false @@ -172,7 +178,7 @@ export async function discoverModelsLocal(input: { } return input.agentId === 'codex' ? runCodexProcessWithHomeLock( - resolveCodexHomeProcessLockKeyForSpawnEnv(input.env, input.options.wslDistro), + resolveCodexHomeProcessLockKeyForSpawnEnv(env, input.options.wslDistro, planned.plan.env), startDiscovery ) : startDiscovery().result diff --git a/src/main/text-generation/source-control-agent-launch.ts b/src/main/text-generation/source-control-agent-launch.ts index 8587497d318..709c0481390 100644 --- a/src/main/text-generation/source-control-agent-launch.ts +++ b/src/main/text-generation/source-control-agent-launch.ts @@ -39,15 +39,23 @@ function buildWslLauncherEnv(explicitEnv: NodeJS.ProcessEnv | undefined): NodeJS export const spawnSourceControlAgent: SpawnSourceControlAgent = (input) => { const spawnEnv = input.env ?? process.env if (process.platform === 'win32' && input.wslDistro) { - // Same contract as spawnProcess: stdout/stderr are piped; stdin matches stdinMode. - return wslAwareSpawn(input.binary, input.args, { - cwd: input.cwd, - env: buildWslLauncherEnv(input.env), - stdio: [input.stdinMode, 'pipe', 'pipe'], - windowsHide: true, - wslDistro: input.wslDistro, - useWslLoginShell: true - }) as SpawnedSourceControlAgentProcess + // Apply assignments in the guest after its login shell, not to the Windows launcher. + const assignments = Object.entries(input.commandEnv ?? {}).map( + ([key, value]) => `${key}=${value}` + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: WSL spawn pipes both output streams and supplies the configured stdin stream. + return wslAwareSpawn( + assignments.length ? '/usr/bin/env' : input.binary, + assignments.length ? [...assignments, input.binary, ...input.args] : input.args, + { + cwd: input.cwd, + env: buildWslLauncherEnv(input.env), + stdio: [input.stdinMode, 'pipe', 'pipe'], + windowsHide: true, + wslDistro: input.wslDistro, + useWslLoginShell: true + } + ) as SpawnedSourceControlAgentProcess } const resolvedBinary = process.platform === 'win32' diff --git a/src/main/text-generation/source-control-local-generation.ts b/src/main/text-generation/source-control-local-generation.ts index f388fd1b03a..d918b1ef487 100644 --- a/src/main/text-generation/source-control-local-generation.ts +++ b/src/main/text-generation/source-control-local-generation.ts @@ -1,3 +1,4 @@ +import { mergeCommandEnvironment } from '../../shared/command-environment' import type { CommitMessagePlan } from '../../shared/commit-message-plan' import { resolveCodexHomeProcessLockKeyForSpawnEnv, @@ -25,13 +26,21 @@ export function runLocalPlanForAgent(input: { operation: TextGenerationOperation spawnAgent: SpawnSourceControlAgent }): Promise { + const target = { + ...input.target, + env: mergeCommandEnvironment( + input.target.env, + input.plan.env, + input.target.wslDistro ? 'linux' : process.platform + ) + } const start = ( holdHomeLockUntilExit = false ): LocalProcessExecution => runLocalSourceControlPlan({ plan: input.plan, cwd: input.target.cwd, - env: input.target.env, + env: input.target.wslDistro ? input.target.env : target.env, emptyResultName: input.emptyResultName, operation: input.operation, wslDistro: input.target.wslDistro, @@ -41,13 +50,14 @@ export function runLocalPlanForAgent(input: { if (input.agentId !== 'codex') { return start().result } - return runCodexLocalPlanUnderHomeLock(start, input.target, input.operation) + return runCodexLocalPlanUnderHomeLock(start, target, input.operation, input.plan.env) } function runCodexLocalPlanUnderHomeLock( start: (holdHomeLockUntilExit: boolean) => LocalProcessExecution, target: LocalGenerationTarget, - operation: TextGenerationOperation + operation: TextGenerationOperation, + commandEnv?: Record ): Promise { const laneKey = localGenerationLaneKey(operation, target.cwd) let canceledWhileQueued = false @@ -69,7 +79,7 @@ function runCodexLocalPlanUnderHomeLock( } setLocalGenerationCancelToken(laneKey, queuedCancel) void withCodexHomeProcessLock( - resolveCodexHomeProcessLockKeyForSpawnEnv(target.env, target.wslDistro), + resolveCodexHomeProcessLockKeyForSpawnEnv(target.env, target.wslDistro, commandEnv), async () => { if (canceledWhileQueued) { publishResult({ success: false, error: 'Generation canceled.', canceled: true }) diff --git a/src/main/text-generation/source-control-local-process.ts b/src/main/text-generation/source-control-local-process.ts index 3f046494f0f..da1a2f9a139 100644 --- a/src/main/text-generation/source-control-local-process.ts +++ b/src/main/text-generation/source-control-local-process.ts @@ -66,6 +66,7 @@ export function runLocalSourceControlPlan(input: { args: plan.args, cwd, env: input.env, + commandEnv: plan.env, wslDistro: input.wslDistro, stdinMode: 'pipe', useCwdForNative: true diff --git a/src/main/text-generation/source-control-text-generation-types.ts b/src/main/text-generation/source-control-text-generation-types.ts index 2a8f2c9b8c6..a7b74c35d3a 100644 --- a/src/main/text-generation/source-control-text-generation-types.ts +++ b/src/main/text-generation/source-control-text-generation-types.ts @@ -88,6 +88,8 @@ export type SpawnSourceControlAgent = (input: { cwd?: string env?: NodeJS.ProcessEnv wslDistro?: string + // WSL applies these in the guest; native callers already merge them into env. + commandEnv?: Record stdinMode: 'ignore' | 'pipe' useCwdForNative: boolean }) => SpawnedSourceControlAgentProcess diff --git a/src/main/updater-test-harness.leaked-timers.test.ts b/src/main/updater-test-harness.leaked-timers.test.ts deleted file mode 100644 index 6857852c0e2..00000000000 --- a/src/main/updater-test-harness.leaked-timers.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { setTimeout as sleep } from 'node:timers/promises' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Mock } from 'vitest' -import { loadUpdaterModule, warmUpdaterModule } from './updater-test-module-loader' - -const { autoUpdaterMock, fetchNewerReleaseTagsMock, moduleFactories, resetUpdaterMocks } = - await vi.hoisted(async () => (await import('./updater-test-harness')).createUpdaterMocks()) - -vi.mock('electron', () => moduleFactories.electron()) -vi.mock('electron-updater', () => moduleFactories.electronUpdater()) -vi.mock('./electron-updater-loader', () => moduleFactories.electronUpdaterLoader()) -vi.mock('@electron-toolkit/utils', () => moduleFactories.electronToolkitUtils()) -vi.mock('./ipc/pty', () => moduleFactories.ipcPty()) -vi.mock('./linux-update-package-type', () => moduleFactories.linuxUpdatePackageType()) -vi.mock('./updater-lifecycle-diagnostics', () => moduleFactories.updaterLifecycleDiagnostics()) -vi.mock('./updater-changelog', () => moduleFactories.updaterChangelog()) -vi.mock('./updater-nudge', () => moduleFactories.updaterNudge()) -vi.mock('./update-install-exit-watchdog', () => moduleFactories.updateInstallExitWatchdog()) -vi.mock('./updater-prerelease-feed', () => moduleFactories.updaterPrereleaseFeed()) -vi.mock('./local-builds/local-build-switch', () => moduleFactories.localBuildSwitch()) -vi.mock('./local-builds/local-build-feed-server', () => moduleFactories.localBuildFeedServer()) - -const SILENT_SETTLE_DELAY_MS = 1_000 -const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 - -warmUpdaterModule() - -describe('updater test harness real-timer tracking', () => { - beforeEach(() => { - resetUpdaterMocks() - }) - - it('cancels a real timer armed before the reset', async () => { - let fired = false - setTimeout(() => { - fired = true - }, 20) - - resetUpdaterMocks() - - await sleep(150) - expect(fired).toBe(false) - }) - - it('still arms real timers after the reset', async () => { - let fired = false - setTimeout(() => { - fired = true - }, 10) - - await sleep(150) - expect(fired).toBe(true) - }) - - it('hands back the untouched Node timeout handle', () => { - const handle = setTimeout(() => {}, 60_000) - - // Why: production code calls unref() on these; the tracker must not swap in a plain id. - expect(typeof handle.unref).toBe('function') - handle.unref() - clearTimeout(handle) - }) -}) - -// Why: this pair reproduces the cross-test leak, so it depends on running in file order — the first -// test arms the timer and the second one is the later test it used to fire into. -describe('abandoned updater instance', () => { - let leakedStatusSend: Mock = vi.fn() - - beforeEach(() => { - resetUpdaterMocks() - }) - - it('leaves a silent-settle timer armed when its module instance is abandoned', async () => { - let resolveCheck: (value: unknown) => void = () => {} - autoUpdaterMock.checkForUpdates.mockImplementation(() => { - // Why: the checking status is what makes the silent settle publish 'not-available' later. - autoUpdaterMock.emit('checking-for-update') - return new Promise((resolve) => { - resolveCheck = resolve - }) - }) - leakedStatusSend = vi.fn() - - const { setupAutoUpdater } = await loadUpdaterModule() - - setupAutoUpdater({ webContents: { send: leakedStatusSend } } as never, { - getLastUpdateCheckAt: () => Date.now() - 25 * 60 * 60 * 1000 - }) - - await vi.waitFor(() => { - expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) - }) - - // Why: resolving without a terminal event is what arms the 1s silent settle; do it here so the - // full delay is still ahead of us and the timer is guaranteed to be pending when this test ends. - resolveCheck(undefined) - await sleep(0) - - expect(leakedStatusSend).not.toHaveBeenCalledWith( - 'updater:status', - expect.objectContaining({ state: 'not-available' }) - ) - }) - - it('never reaches the shared spies once the next test owns the clock', async () => { - vi.useFakeTimers() - - // Why: real time past the settle delay — the abandoned instance would settle here and re-arm its - // 24h auto check on this test's fake clock at its epoch. - await sleep(SILENT_SETTLE_DELAY_MS + 300) - await vi.advanceTimersByTimeAsync(AUTO_UPDATE_CHECK_INTERVAL_MS) - - expect(leakedStatusSend).not.toHaveBeenCalledWith( - 'updater:status', - expect.objectContaining({ state: 'not-available' }) - ) - // Why: the generation fence stops at the autoUpdater spies; this one sits past it on the - // pinDefaultReleaseFeed chain that the stale instance's re-armed background check still reached. - expect(fetchNewerReleaseTagsMock).not.toHaveBeenCalled() - expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/updater.feed-attempt-lifetime.test.ts b/src/main/updater.feed-attempt-lifetime.test.ts new file mode 100644 index 00000000000..3df242dfa13 --- /dev/null +++ b/src/main/updater.feed-attempt-lifetime.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { loadUpdaterModule, warmUpdaterModule } from './updater-test-module-loader' + +const { autoUpdaterMock, fetchNewerReleaseTagsMock, moduleFactories, resetUpdaterMocks } = + await vi.hoisted(async () => (await import('./updater-test-harness')).createUpdaterMocks()) + +vi.mock('electron', () => moduleFactories.electron()) +vi.mock('electron-updater', () => moduleFactories.electronUpdater()) +vi.mock('./electron-updater-loader', () => moduleFactories.electronUpdaterLoader()) +vi.mock('@electron-toolkit/utils', () => moduleFactories.electronToolkitUtils()) +vi.mock('./ipc/pty', () => moduleFactories.ipcPty()) +vi.mock('./linux-update-package-type', () => moduleFactories.linuxUpdatePackageType()) +vi.mock('./updater-lifecycle-diagnostics', () => moduleFactories.updaterLifecycleDiagnostics()) +vi.mock('./updater-changelog', () => moduleFactories.updaterChangelog()) +vi.mock('./updater-nudge', () => moduleFactories.updaterNudge()) +vi.mock('./update-install-exit-watchdog', () => moduleFactories.updateInstallExitWatchdog()) +vi.mock('./updater-prerelease-feed', () => moduleFactories.updaterPrereleaseFeed()) +vi.mock('./local-builds/local-build-switch', () => moduleFactories.localBuildSwitch()) +vi.mock('./local-builds/local-build-feed-server', () => moduleFactories.localBuildFeedServer()) + +warmUpdaterModule() + +type FeedResult = + | { tags: string[]; state: 'ready' | 'no-newer' } + | { tags: string[]; state: 'not-ready'; lastGoodTag: string } + +const expiredResults: FeedResult[] = [ + { tags: ['v2.0.0'], state: 'ready' }, + { tags: [], state: 'no-newer' }, + { tags: [], state: 'not-ready', lastGoodTag: 'v1.9.0' } +] + +function holdFirstPreflight(): (value: FeedResult) => void { + let finish = (_value: FeedResult): void => {} + fetchNewerReleaseTagsMock.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + return (value) => finish(value) +} + +async function setup() { + const updater = await loadUpdaterModule() + const mainWindow = { webContents: { send: vi.fn() } } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only webContents.send is used; all native Electron APIs are mocked. + updater.setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + return updater +} + +describe('updater feed preflight ownership', () => { + beforeEach(() => { + resetUpdaterMocks() + vi.useFakeTimers() + autoUpdaterMock.checkForUpdates.mockImplementation(() => new Promise(() => {})) + }) + + it.each(expiredResults)('does not repin after a timed-out $state result', async (result) => { + const finish = holdFirstPreflight() + const updater = await setup() + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(45_000) + const settledStatus = updater.getUpdateStatus() + expect(settledStatus.state).toBe('error') + autoUpdaterMock.setFeedURL.mockClear() + + finish(result) + await vi.advanceTimersByTimeAsync(0) + + expect(autoUpdaterMock.setFeedURL).not.toHaveBeenCalled() + expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + expect(updater.getUpdateStatus()).toBe(settledStatus) + }) + + it.each(['manual', 'background'] as const)( + 'preserves a newer feed after an older %s preflight resolves', + async (kind) => { + const finishOld = holdFirstPreflight() + fetchNewerReleaseTagsMock.mockResolvedValueOnce({ tags: ['v3.0.0'], state: 'ready' }) + const updater = await setup() + if (kind === 'manual') { + updater.checkForUpdatesFromMenu() + } else { + updater.checkForUpdates() + } + await vi.advanceTimersByTimeAsync(45_000) + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/download/v3.0.0' + }) + autoUpdaterMock.setFeedURL.mockClear() + + finishOld({ tags: ['v2.0.0'], state: 'ready' }) + await vi.advanceTimersByTimeAsync(0) + + expect(autoUpdaterMock.setFeedURL).not.toHaveBeenCalled() + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + } + ) + + it.each([ + { + result: { tags: ['v3.0.0'], state: 'ready' }, + url: 'https://github.com/stablyai/orca/releases/download/v3.0.0' + }, + { + result: { tags: [], state: 'no-newer' }, + url: 'https://github.com/stablyai/orca/releases/latest/download' + } + ])('keeps the active $result.state feed choice', async ({ result, url }) => { + fetchNewerReleaseTagsMock.mockResolvedValueOnce(result) + const updater = await setup() + autoUpdaterMock.setFeedURL.mockClear() + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.setFeedURL).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ provider: 'generic', url }) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/updater.quit-and-install.test.ts b/src/main/updater.quit-and-install.test.ts index 0080db58991..d5ba376e02e 100644 --- a/src/main/updater.quit-and-install.test.ts +++ b/src/main/updater.quit-and-install.test.ts @@ -183,6 +183,98 @@ describe('updater', () => { ) }) + it('aborts native install when required pre-quit cleanup fails', async () => { + vi.useFakeTimers() + + const onBeforeQuit = vi.fn().mockRejectedValue(new Error('profile state export failed')) + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + const { setupAutoUpdater, quitAndInstall, isQuittingForUpdate } = await loadUpdaterModule() + + setupAutoUpdater(mainWindow as never, { + onBeforeQuit, + onBeforeQuitFailure: 'abort' + }) + quitAndInstall() + + await vi.advanceTimersByTimeAsync(100) + + expect(onBeforeQuit).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(killAllPtyMock).not.toHaveBeenCalled() + expect(isQuittingForUpdate()).toBe(false) + expect(sendMock).toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ + state: 'error', + message: expect.stringContaining('Could not restart to install the update') + }) + ) + }) + + it('keeps optional pre-quit cleanup fail-and-continue behavior by default', async () => { + vi.useFakeTimers() + + const onBeforeQuit = vi.fn().mockRejectedValue(new Error('optional cleanup failed')) + const mainWindow = { webContents: { send: vi.fn() } } + const { setupAutoUpdater, quitAndInstall } = await loadUpdaterModule() + + setupAutoUpdater(mainWindow as never, { onBeforeQuit }) + quitAndInstall() + + await vi.advanceTimersByTimeAsync(100) + + expect(onBeforeQuit).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + expect(killAllPtyMock).toHaveBeenCalledTimes(1) + }) + + it('allows a required profile export to finish beyond the optional cleanup budget', async () => { + vi.useFakeTimers() + const onBeforeQuit = vi.fn(() => new Promise((resolve) => setTimeout(resolve, 5_000))) + const mainWindow = { webContents: { send: vi.fn() } } + const { setupAutoUpdater, quitAndInstall } = await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: updater only reads the mocked webContents.send in this lifecycle test. + setupAutoUpdater(mainWindow as never, { onBeforeQuit, onBeforeQuitFailure: 'abort' }) + quitAndInstall() + await vi.advanceTimersByTimeAsync(2_600) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(2_500) + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledOnce() + }) + + it('aborts native install when required pre-quit cleanup times out', async () => { + vi.useFakeTimers() + + const onBeforeQuit = vi.fn(() => new Promise(() => {})) + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + const { setupAutoUpdater, quitAndInstall, isQuittingForUpdate } = await loadUpdaterModule() + + setupAutoUpdater(mainWindow as never, { + onBeforeQuit, + onBeforeQuitFailure: 'abort' + }) + quitAndInstall() + + await vi.advanceTimersByTimeAsync(100) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(90_000) + + expect(onBeforeQuit).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(killAllPtyMock).not.toHaveBeenCalled() + expect(isQuittingForUpdate()).toBe(false) + expect(sendMock).toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ + state: 'error', + message: expect.stringContaining('Could not restart to install the update') + }) + ) + }) + it('ignores duplicate quitAndInstall requests while the shared delay is pending', async () => { vi.useFakeTimers() diff --git a/src/main/updater.ts b/src/main/updater.ts index cc9f0fc2c98..95c457b82c3 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -12,12 +12,12 @@ import type { import type { ReleaseBuild, ReleaseChannel } from '../shared/release-channel' import type { ReleaseBuildListOptions } from './updater-release-build-cache' import { UpdaterSetup, type UpdaterSetupOptions } from './updater/updater-setup' -import type { UpdateInstallMode } from './updater/updater-state' +import type { PreQuitCleanupFailureMode, UpdateInstallMode } from './updater/updater-state' // Keep one service instance so all public API calls share updater state and event listeners. const updater = new UpdaterSetup() -export type { UpdateInstallMode, UpdaterSetupOptions } +export type { PreQuitCleanupFailureMode, UpdateInstallMode, UpdaterSetupOptions } export function resolveUpdateInstallMode(isServeMode: boolean): UpdateInstallMode { return updater.resolveUpdateInstallMode(isServeMode) diff --git a/src/main/updater/updater-install-support.ts b/src/main/updater/updater-install-support.ts index 175362dad05..2da273e152e 100644 --- a/src/main/updater/updater-install-support.ts +++ b/src/main/updater/updater-install-support.ts @@ -10,7 +10,7 @@ import { disarmUpdateInstallExitWatchdog } from '../update-install-exit-watchdog import { resetMacInstallState } from '../updater-mac-install' import type { LinuxPackageInstallRecovery, UpdateStatus } from '../../shared/update-status-types' import { compareVersions } from '../updater-fallback' -import { PRE_QUIT_CLEANUP_TIMEOUT_MS } from './updater-state' +import { PRE_QUIT_CLEANUP_TIMEOUT_MS, REQUIRED_PRE_QUIT_CLEANUP_TIMEOUT_MS } from './updater-state' import { UpdaterCheckState } from './updater-check-state' export abstract class UpdaterInstallSupport extends UpdaterCheckState { @@ -137,6 +137,10 @@ export abstract class UpdaterInstallSupport extends UpdaterCheckState { return } + const timeoutMs = + this.onBeforeQuitFailure === 'abort' + ? REQUIRED_PRE_QUIT_CLEANUP_TIMEOUT_MS + : PRE_QUIT_CLEANUP_TIMEOUT_MS let timeout: ReturnType | null = null const cleanup = Promise.resolve() .then(() => this.onBeforeQuitCleanup?.()) @@ -146,29 +150,42 @@ export abstract class UpdaterInstallSupport extends UpdaterCheckState { { errorType: error instanceof Error ? error.name : typeof error }, { level: 'warn', - message: 'Pre-quit cleanup failed; continuing update install' + message: + this.onBeforeQuitFailure === 'abort' + ? 'Pre-quit cleanup failed; aborting update install' + : 'Pre-quit cleanup failed; continuing update install' } ) + if (this.onBeforeQuitFailure === 'abort') { + throw error + } }) const timeoutResult = new Promise<'timeout'>((resolve) => { - timeout = setTimeout(() => resolve('timeout'), PRE_QUIT_CLEANUP_TIMEOUT_MS) + timeout = setTimeout(() => resolve('timeout'), timeoutMs) }) - const result = await Promise.race([cleanup.then(() => 'done' as const), timeoutResult]) - if (result === 'timeout') { - recordUpdaterLifecycle( - 'pre_quit_cleanup_timeout', - { timeoutMs: PRE_QUIT_CLEANUP_TIMEOUT_MS }, - { - level: 'warn', - message: `Pre-quit cleanup exceeded ${PRE_QUIT_CLEANUP_TIMEOUT_MS}ms; continuing update install` + try { + const result = await Promise.race([cleanup.then(() => 'done' as const), timeoutResult]) + if (result === 'timeout') { + recordUpdaterLifecycle( + 'pre_quit_cleanup_timeout', + { timeoutMs }, + { + level: 'warn', + message: + this.onBeforeQuitFailure === 'abort' + ? `Pre-quit cleanup exceeded ${timeoutMs}ms; aborting update install` + : `Pre-quit cleanup exceeded ${timeoutMs}ms; continuing update install` + } + ) + if (this.onBeforeQuitFailure === 'abort') { + throw new Error(`Pre-quit cleanup exceeded ${timeoutMs}ms before update install`) } - ) - return - } - - if (timeout) { - clearTimeout(timeout) + } + } finally { + if (timeout) { + clearTimeout(timeout) + } } } diff --git a/src/main/updater/updater-release-feed.ts b/src/main/updater/updater-release-feed.ts index 46a34eb49ed..a06af6e669b 100644 --- a/src/main/updater/updater-release-feed.ts +++ b/src/main/updater/updater-release-feed.ts @@ -114,6 +114,7 @@ export abstract class UpdaterReleaseFeed extends UpdaterInstallExecution { protected async pinDefaultReleaseFeed( variant: UpdateCheckVariant = 'default' ): Promise<'ready' | 'not-available'> { + const attemptId = this.activeUpdateCheckAttemptId const autoUpdater = this.getAutoUpdater() // Why: the latest/download redirect can move between check and download, so pin the concrete tag (prerelease users resolve any channel, stable only stable). const currentVersion = app.getVersion() @@ -128,6 +129,10 @@ export abstract class UpdaterReleaseFeed extends UpdaterInstallExecution { ...(isPerfCheck ? { releaseFilter: 'perf' as const } : {}) } ) + // A timed-out preflight must not overwrite a newer check's feed. + if (attemptId === null || !this.isActiveUpdateCheckAttempt(attemptId)) { + return 'not-available' + } const newerTag = releaseTagsResult.tags[0] ?? null const fallbackTag = includePrerelease ? (releaseTagsResult.tags[1] ?? null) : null this.pendingPrereleaseFallback = diff --git a/src/main/updater/updater-setup.ts b/src/main/updater/updater-setup.ts index 8ebbc49b169..2b43964d5c0 100644 --- a/src/main/updater/updater-setup.ts +++ b/src/main/updater/updater-setup.ts @@ -20,7 +20,7 @@ import { getServeUpdateHandoffFailure } from '../serve-update-handoff' import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' import { AUTO_UPDATE_CHECK_INTERVAL_MS } from './updater-state' import { UpdaterDownloadInstall } from './updater-download-install' -import type { UpdateInstallMode } from './updater-state' +import type { PreQuitCleanupFailureMode, UpdateInstallMode } from './updater-state' export type UpdaterSetupOptions = { getLastUpdateCheckAt?: () => number | null @@ -32,6 +32,7 @@ export type UpdaterSetupOptions = { setDismissedUpdateNudgeId?: (id: string | null) => void getReleaseChannelOverride?: () => ReleaseChannel | null installMode?: UpdateInstallMode + onBeforeQuitFailure?: PreQuitCleanupFailureMode } /** Initializes electron-updater and attaches lifecycle/event bridges. */ @@ -113,6 +114,7 @@ export class UpdaterSetup extends UpdaterDownloadInstall { setupAutoUpdater(mainWindow: BrowserWindow, opts?: UpdaterSetupOptions): void { this.mainWindowRef = mainWindow this.onBeforeQuitCleanup = opts?.onBeforeQuit ?? null + this.onBeforeQuitFailure = opts?.onBeforeQuitFailure ?? 'continue' this.persistLastUpdateCheckAt = opts?.setLastUpdateCheckAt ?? null this._getLastUpdateCheckAt = opts?.getLastUpdateCheckAt ?? null this._getPendingUpdateNudgeId = opts?.getPendingUpdateNudgeId ?? null diff --git a/src/main/updater/updater-state.ts b/src/main/updater/updater-state.ts index d15ce42520c..32177e3183c 100644 --- a/src/main/updater/updater-state.ts +++ b/src/main/updater/updater-state.ts @@ -13,6 +13,8 @@ export const NUDGE_POLL_INTERVAL_MS = 30 * 60 * 1000 export const NUDGE_ACTIVATION_COOLDOWN_MS = 5 * 60 * 1000 export const QUIT_AND_INSTALL_DELAY_MS = 100 export const PRE_QUIT_CLEANUP_TIMEOUT_MS = 2_500 +// Required profile exports may each wait for a bounded writer request. +export const REQUIRED_PRE_QUIT_CLEANUP_TIMEOUT_MS = 90_000 export const UPDATE_CHECK_SILENT_SETTLE_DELAY_MS = 1_000 export const UPDATE_CHECK_STALL_TIMEOUT_MS = 45_000 @@ -24,6 +26,7 @@ export type UpdateInstallMode = | 'interactive' | 'supervised-headless-serve' | 'unsupported-headless-serve' +export type PreQuitCleanupFailureMode = 'continue' | 'abort' // Why: expected preflight outcomes need typed context so UI routing never depends on matching error text. export class ReleaseFeedPreflightError extends Error { @@ -42,6 +45,7 @@ export abstract class UpdaterState { protected currentStatus: UpdateStatus = { state: 'idle' } protected userInitiatedCheck = false protected onBeforeQuitCleanup: (() => void | Promise) | null = null + protected onBeforeQuitFailure: PreQuitCleanupFailureMode = 'continue' protected autoUpdaterInitialized = false // Why: modifier-clicking "Check for Updates" targets prerelease manifests; the feed still pins a concrete tag so cancelled prereleases without manifests are skipped. protected includePrereleaseActive = false diff --git a/src/main/usage/agent-token-usage-reporter.test.ts b/src/main/usage/agent-token-usage-reporter.test.ts new file mode 100644 index 00000000000..8ecf487838c --- /dev/null +++ b/src/main/usage/agent-token-usage-reporter.test.ts @@ -0,0 +1,168 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { agentTokenUsageSchema } from '../../shared/telemetry-agent-token-usage-schema' +import { _enableTransportForTests, _setShuttingDownForTests } from '../telemetry/client' +import { + cleanupTelemetryClientTest, + setupTelemetryClientTest, + type TelemetryClientTestState +} from '../telemetry/client-test-harness' +import { resetBurstCapsForSession } from '../telemetry/burst-cap' +import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer' +import { AgentTokenUsageReporter } from './agent-token-usage-reporter' + +const ID = '00000000-0000-4000-8000-000000000001' +const row = { + providerSessionId: 'private-provider-session', + input_tokens: 100, + output_tokens: 20, + cached_input_tokens: 50, + cache_write_input_tokens: 10 +} +let directory: string +let file: string +let telemetry: TelemetryClientTestState +let reporters: AgentTokenUsageReporter[] +const identity = vi.fn(async (ids: readonly string[]) => ids.map(() => ID)) +function reporter(): AgentTokenUsageReporter { + const instance = new AgentTokenUsageReporter(file, 'claude', identity) + reporters.push(instance) + return instance +} +function captures() { + return telemetry.mock.capture.mock.calls.map(([message]) => message.properties) +} +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'orca-token-usage-')) + file = join(directory, 'tokens.json') + telemetry = setupTelemetryClientTest() + reporters = [] + identity.mockReset().mockImplementation(async (ids) => ids.map(() => ID)) +}) +afterEach(async () => { + await Promise.all(reporters.map((instance) => instance.flush())) + cleanupTelemetryClientTest(telemetry.envStash) + vi.restoreAllMocks() + rmSync(directory, { recursive: true, force: true }) +}) + +describe('agent token usage', () => { + it('persists before capture, omits provider IDs, and retains revisions across restarts', async () => { + telemetry.mock.capture.mockImplementation(() => { + expect(JSON.parse(readFileSync(file, 'utf8')).snapshots[0].revision).toBeGreaterThan(0) + }) + const original = reporter() + await original.report([row]) + await original.report([row]) + expect(captures()).toHaveLength(1) + expect(captures()[0]).toMatchObject({ + analytics_session_id: ID, + revision: 1, + input_tokens: 100 + }) + expect(JSON.stringify(captures())).not.toContain(row.providerSessionId) + await original.report([{ ...row, input_tokens: 150 }]) + expect(captures()[1]).toMatchObject({ revision: 2, input_tokens: 150 }) + await reporter().report([{ ...row, input_tokens: 150 }]) + expect(captures()[2]).toMatchObject({ revision: 2, input_tokens: 150 }) + await reporter().report([{ ...row, input_tokens: 90 }]) + expect(captures()[3]).toMatchObject({ revision: 3, input_tokens: 90 }) + }) + + it.each(['opt-out', 'pending', 'environment', 'build', 'shutdown'])( + 'does no identity or disk work for %s', + async (gate) => { + if (gate === 'opt-out' && telemetry.settings.telemetry) { + telemetry.settings.telemetry.optedIn = false + } + if (gate === 'pending' && telemetry.settings.telemetry) { + telemetry.settings.telemetry.optedIn = null + } + if (gate === 'environment') { + process.env.DO_NOT_TRACK = '1' + } + if (gate === 'build') { + _enableTransportForTests(false) + } + if (gate === 'shutdown') { + _setShuttingDownForTests(true) + } + await reporter().report([row]) + expect(identity).not.toHaveBeenCalled() + expect(existsSync(file)).toBe(false) + expect(captures()).toEqual([]) + } + ) + + it('rechecks consent after asynchronous identity persistence', async () => { + identity.mockImplementation(async (ids) => { + if (telemetry.settings.telemetry) { + telemetry.settings.telemetry.optedIn = false + } + return ids.map(() => ID) + }) + await reporter().report([row]) + expect(captures()).toEqual([]) + }) + + it('resolves every session identity in one batch', async () => { + const second = '00000000-0000-4000-8000-000000000002' + identity.mockImplementation(async (ids) => + ids.map((id) => (id === 'second-session' ? second : ID)) + ) + await reporter().report([ + row, + { ...row, providerSessionId: 'invalid-session', input_tokens: -1 }, + { ...row, providerSessionId: 'second-session', input_tokens: 7 } + ]) + expect(identity).toHaveBeenCalledOnce() + expect(identity).toHaveBeenCalledWith([row.providerSessionId, 'second-session']) + expect(captures()).toEqual([ + expect.objectContaining({ analytics_session_id: ID, input_tokens: 100 }), + expect.objectContaining({ analytics_session_id: second, input_tokens: 7 }) + ]) + }) + + it('does not publish a revision whose write failed and retries it', async () => { + const instance = reporter() + const write = vi + .spyOn(UsageCacheSnapshotWriter.prototype, 'write') + .mockRejectedValueOnce(new Error('disk full')) + await expect(instance.report([row])).rejects.toThrow('disk full') + expect(captures()).toEqual([]) + write.mockRestore() + await instance.report([row]) + expect(captures()[0]).toMatchObject({ revision: 1 }) + }) + + it('retries rate-limited snapshots without incrementing their revision', async () => { + const instance = reporter() + for (let count = 1; count <= 31; count++) { + await instance.report([{ ...row, input_tokens: count }]) + } + expect(captures()).toHaveLength(30) + resetBurstCapsForSession() + await instance.report([{ ...row, input_tokens: 31 }]) + expect(captures().at(-1)).toMatchObject({ revision: 31, input_tokens: 31 }) + }) + + it('rejects corrupt saved revisions without replacing them', async () => { + writeFileSync(file, '{broken') + await expect(reporter().report([row])).rejects.toThrow() + expect(readFileSync(file, 'utf8')).toBe('{broken') + expect(captures()).toEqual([]) + }) + + it('rejects invalid counts and unexpected content fields', async () => { + await reporter().report([{ ...row, input_tokens: -1 }]) + const payload = { ...row, analytics_session_id: ID, revision: 1, provider: 'claude' } + expect(agentTokenUsageSchema.safeParse(payload).success).toBe(false) + const { providerSessionId: _, ...valid } = payload + expect(agentTokenUsageSchema.safeParse(valid).success).toBe(true) + for (const field of ['prompt', 'path', 'model', 'activity_timestamp', 'estimated_cost']) { + expect(agentTokenUsageSchema.safeParse({ ...valid, [field]: 'private' }).success).toBe(false) + } + }) +}) diff --git a/src/main/usage/agent-token-usage-reporter.ts b/src/main/usage/agent-token-usage-reporter.ts new file mode 100644 index 00000000000..12322f4eb55 --- /dev/null +++ b/src/main/usage/agent-token-usage-reporter.ts @@ -0,0 +1,123 @@ +import { readFile } from 'node:fs/promises' +import { z } from 'zod' +import { + agentTokenCountsSchema, + agentTokenUsageSchema, + type AgentTokenUsage +} from '../../shared/telemetry-agent-token-usage-schema' +import { isTelemetryEnabled, track } from '../telemetry/client' +import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer' +import type { AgentTokenSession } from './agent-token-usage' + +const stateSchema = z + .object({ + schemaVersion: z.literal(1), + snapshots: z.array(agentTokenUsageSchema) + }) + .strict() + +/** Cumulative snapshots: consumers select the highest revision per provider/session, never sum retries. */ +export class AgentTokenUsageReporter { + private snapshots: Map | null = null + private readonly sent = new Map() + private readonly writer: UsageCacheSnapshotWriter + private pending: Promise = Promise.resolve() + + constructor( + private readonly file: string, + private readonly provider: AgentTokenUsage['provider'], + private readonly getSessionIds: (providerSessionIds: readonly string[]) => Promise + ) { + this.writer = new UsageCacheSnapshotWriter('[agent-token-usage]', () => file) + } + + report(sessions: AgentTokenSession[]): Promise { + const operation = this.pending.then(() => this.publish(sessions)) + this.pending = operation.catch(() => {}) + return operation + } + + async flush(): Promise { + await this.pending + await this.writer.flush() + } + + private async publish(sessions: AgentTokenSession[]): Promise { + if (!isTelemetryEnabled()) { + return + } + const rows = sessions.flatMap(({ providerSessionId, ...counts }) => { + const parsed = agentTokenCountsSchema.safeParse(counts) + return parsed.success ? [{ providerSessionId, counts: parsed.data }] : [] + }) + const ids = await this.getSessionIds(rows.map((row) => row.providerSessionId)) + if (!isTelemetryEnabled()) { + return + } + const previous = this.snapshots ?? (await this.load()) + const next = new Map(previous) + let changed = false + for (const [index, { counts }] of rows.entries()) { + const id = ids[index] + const existing = next.get(id) + if ( + existing && + existing.input_tokens === counts.input_tokens && + existing.output_tokens === counts.output_tokens && + existing.cached_input_tokens === counts.cached_input_tokens && + existing.cache_write_input_tokens === counts.cache_write_input_tokens + ) { + continue + } + next.set( + id, + agentTokenUsageSchema.parse({ + ...counts, + provider: this.provider, + analytics_session_id: id, + revision: (existing?.revision ?? 0) + 1 + }) + ) + changed = true + } + // Persist revisions before capture so a restart can only retry the same snapshot or a newer one. + if (changed) { + await this.writer.write(() => + JSON.stringify({ schemaVersion: 1, snapshots: [...next.values()] }) + ) + } + this.snapshots = next + for (const snapshot of next.values()) { + if (this.sent.get(snapshot.analytics_session_id) === snapshot.revision) { + continue + } + if (!track('agent_token_usage', snapshot)) { + break + } + this.sent.set(snapshot.analytics_session_id, snapshot.revision) + } + } + + private async load(): Promise> { + let content: string + try { + content = await readFile(this.file, 'utf8') + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + return new Map() + } + throw error + } + const state = stateSchema.parse(JSON.parse(content)) + const snapshots = new Map( + state.snapshots.map((snapshot) => [snapshot.analytics_session_id, snapshot]) + ) + if ( + snapshots.size !== state.snapshots.length || + state.snapshots.some((snapshot) => snapshot.provider !== this.provider) + ) { + throw new Error('Invalid agent token usage state') + } + return snapshots + } +} diff --git a/src/main/usage/agent-token-usage.test.ts b/src/main/usage/agent-token-usage.test.ts new file mode 100644 index 00000000000..e18c825212a --- /dev/null +++ b/src/main/usage/agent-token-usage.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest' +import type { ClaudeUsageSession } from '../claude-usage/types' +import type { CodexUsageSession } from '../codex-usage/types' +import type { OpenCodeUsageSession } from '../opencode-usage/types' +import { claudeTokenSessions, codexOpenCodeTokenSessions } from './agent-token-usage' + +const claudeLocation = { + locationKey: '/private/path', + projectLabel: 'private-repo', + repoId: 'repo', + worktreeId: 'folder', + turnCount: 1, + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 30, + cacheWriteTokens: 10, + cacheWrite1hTokens: 5 +} +const claude: ClaudeUsageSession = { + sessionId: 'session', + firstTimestamp: 'private-start', + lastTimestamp: 'private-end', + model: 'private-model', + lastCwd: '/private/path', + lastGitBranch: 'private-branch', + primaryWorktreeId: 'folder', + primaryRepoId: 'repo', + turnCount: 2, + totalInputTokens: 200, + totalOutputTokens: 40, + totalCacheReadTokens: 60, + totalCacheWriteTokens: 20, + totalCacheWrite1hTokens: 10, + locationBreakdown: [claudeLocation, { ...claudeLocation, worktreeId: null }] +} +const codexLocation = { + locationKey: '/private/path', + projectLabel: 'private-repo', + repoId: 'repo', + worktreeId: 'folder', + eventCount: 1, + inputTokens: 100, + cachedInputTokens: 30, + outputTokens: 20, + reasoningOutputTokens: 5, + totalTokens: 120, + hasInferredPricing: false, + longContextInputTokens: 0, + longContextCachedInputTokens: 0, + longContextOutputTokens: 0, + estimatedCostUsd: 1 +} +const codex: CodexUsageSession = { + sessionId: 'session', + firstTimestamp: 'private-start', + lastTimestamp: 'private-end', + primaryModel: 'private-model', + hasMixedModels: false, + primaryProjectLabel: 'private-repo', + hasMixedLocations: true, + primaryWorktreeId: 'folder', + primaryRepoId: 'repo', + eventCount: 2, + totalInputTokens: 200, + totalCachedInputTokens: 60, + totalOutputTokens: 40, + totalReasoningOutputTokens: 10, + totalTokens: 240, + hasInferredPricing: false, + longContextInputTokens: 0, + longContextCachedInputTokens: 0, + longContextOutputTokens: 0, + locationBreakdown: [codexLocation, { ...codexLocation, worktreeId: null }], + modelBreakdown: [], + locationModelBreakdown: [] +} +const opencode: OpenCodeUsageSession = { + ...codex, + estimatedCostUsd: 2, + locationBreakdown: [codexLocation, { ...codexLocation, worktreeId: null }], + modelBreakdown: [], + locationModelBreakdown: [] +} + +describe('Orca token projections', () => { + it('counts Claude cache writes once including the 1-hour subset and excludes outside usage', () => { + expect(claudeTokenSessions([claude])).toEqual([ + { + providerSessionId: 'session', + input_tokens: 100, + output_tokens: 20, + cached_input_tokens: 30, + cache_write_input_tokens: 10 + } + ]) + }) + it.each([codex, opencode])('separates cache reads from inclusive input counts', (session) => { + expect(codexOpenCodeTokenSessions([session])).toEqual([ + { + providerSessionId: 'session', + input_tokens: 70, + output_tokens: 20, + cached_input_tokens: 30, + cache_write_input_tokens: 0 + } + ]) + }) + it('does not fall back to unscoped session totals when attribution is missing', () => { + expect(claudeTokenSessions([{ ...claude, locationBreakdown: [] }])).toEqual([]) + expect(codexOpenCodeTokenSessions([{ ...codex, locationBreakdown: [] }])).toEqual([]) + expect( + codexOpenCodeTokenSessions([ + { ...codex, locationBreakdown: [{ ...codexLocation, worktreeId: null }] } + ]) + ).toEqual([]) + }) +}) diff --git a/src/main/usage/agent-token-usage.ts b/src/main/usage/agent-token-usage.ts new file mode 100644 index 00000000000..36cada6cf01 --- /dev/null +++ b/src/main/usage/agent-token-usage.ts @@ -0,0 +1,48 @@ +import type { AgentTokenCounts } from '../../shared/telemetry-agent-token-usage-schema' +import type { ClaudeUsageSession } from '../claude-usage/types' +import type { CodexUsageSession } from '../codex-usage/types' +import type { OpenCodeUsageSession } from '../opencode-usage/types' + +export type AgentTokenSession = AgentTokenCounts & { providerSessionId: string } + +export function claudeTokenSessions(sessions: ClaudeUsageSession[]): AgentTokenSession[] { + return sessions.flatMap((session) => { + const locations = session.locationBreakdown.filter((entry) => entry.worktreeId !== null) + if (locations.length === 0) { + return [] + } + return [ + { + providerSessionId: session.sessionId, + input_tokens: locations.reduce((sum, entry) => sum + entry.inputTokens, 0), + output_tokens: locations.reduce((sum, entry) => sum + entry.outputTokens, 0), + cached_input_tokens: locations.reduce((sum, entry) => sum + entry.cacheReadTokens, 0), + cache_write_input_tokens: locations.reduce((sum, entry) => sum + entry.cacheWriteTokens, 0) + } + ] + }) +} + +export function codexOpenCodeTokenSessions( + sessions: (CodexUsageSession | OpenCodeUsageSession)[] +): AgentTokenSession[] { + return sessions.flatMap((session) => { + const locations = session.locationBreakdown.filter((entry) => entry.worktreeId !== null) + if (locations.length === 0) { + return [] + } + return [ + { + providerSessionId: session.sessionId, + // Codex/OpenCode include cache hits in input; Claude records them separately. + input_tokens: locations.reduce( + (sum, entry) => sum + entry.inputTokens - entry.cachedInputTokens, + 0 + ), + output_tokens: locations.reduce((sum, entry) => sum + entry.outputTokens, 0), + cached_input_tokens: locations.reduce((sum, entry) => sum + entry.cachedInputTokens, 0), + cache_write_input_tokens: 0 + } + ] + }) +} diff --git a/src/main/usage/analytics-session-id-store.test.ts b/src/main/usage/analytics-session-id-store.test.ts new file mode 100644 index 00000000000..02cd747104a --- /dev/null +++ b/src/main/usage/analytics-session-id-store.test.ts @@ -0,0 +1,218 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import type * as FsPromises from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer' +import { AnalyticsSessionIdStore } from './analytics-session-id-store' + +type WriteGate = { entered: (() => void) | null; wait: Promise | null; fail: boolean } + +const { writeGate } = vi.hoisted((): { writeGate: WriteGate } => ({ + writeGate: { + entered: null, + wait: null, + fail: false + } +})) +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises') + return { + ...actual, + open: async (...args: Parameters) => { + if (args[1] === 'w') { + writeGate.entered?.() + if (writeGate.wait) { + await writeGate.wait + } + if (writeGate.fail) { + throw new Error('simulated disk failure') + } + } + return actual.open(...args) + } + } +}) + +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ +let directory: string +let file: string +let stores: AnalyticsSessionIdStore[] +/** Registers every instance for flush-before-cleanup, including simulated restarts. */ +function createStore(path = file): AnalyticsSessionIdStore { + const store = new AnalyticsSessionIdStore(path) + stores.push(store) + return store +} +async function idFor(store: AnalyticsSessionIdStore, key: string) { + const [id] = await store.getOrCreate([key]) + return id +} +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'orca-analytics-session-id-')) + file = join(directory, 'identities.json') + stores = [] + writeGate.entered = null + writeGate.wait = null + writeGate.fail = false +}) +afterEach(async () => { + await Promise.all(stores.map((store) => store.flush())) + rmSync(directory, { recursive: true, force: true }) + vi.restoreAllMocks() +}) + +describe('AnalyticsSessionIdStore', () => { + it('is lazy and persists a random ID before returning it', async () => { + const store = createStore() + expect(existsSync(file)).toBe(false) + const id = await idFor(store, 'provider-session-123') + expect(id).toMatch(UUID_V4) + expect(id).not.toContain('provider-session') + expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual({ + schemaVersion: 1, + entries: [['provider-session-123', id]] + }) + expect(await idFor(store, 'provider-session-123')).toBe(id) + }) + + it('persists a batch with one durable write and returns IDs in input order', async () => { + const store = createStore() + const [known] = await store.getOrCreate(['known']) + const write = vi.spyOn(UsageCacheSnapshotWriter.prototype, 'write') + const ids = await store.getOrCreate(['a', 'known', 'b', 'a']) + expect(write).toHaveBeenCalledOnce() + expect(ids[1]).toBe(known) + expect(ids[3]).toBe(ids[0]) + expect(new Set(ids).size).toBe(3) + expect(await createStore().getOrCreate(['b', 'a', 'known'])).toEqual([ids[2], ids[0], known]) + await store.getOrCreate(['known', 'a', 'b']) + expect(write).toHaveBeenCalledOnce() + }) + + it('restores the ID after a restart or session resume', async () => { + const original = createStore() + const id = await idFor(original, 'resumed-session') + await original.flush() + expect(await idFor(createStore(), 'resumed-session')).toBe(id) + }) + + it('handles concurrent requests for the same and different sessions without lost writes', async () => { + const store = createStore() + const ids = await Promise.all( + Array.from({ length: 20 }, (_, index) => idFor(store, `session-${index % 10}`)) + ) + expect(new Set(ids).size).toBe(10) + expect(ids.slice(0, 10)).toEqual(ids.slice(10)) + const restored = createStore() + for (let index = 0; index < 10; index++) { + expect(await idFor(restored, `session-${index}`)).toBe(ids[index]) + } + }) + + it('isolates identical provider session IDs across provider and host/profile files', async () => { + const ids = await Promise.all([ + idFor(createStore(join(directory, 'host-a', 'claude.json')), 'same-session'), + idFor(createStore(join(directory, 'host-a', 'codex.json')), 'same-session'), + idFor(createStore(join(directory, 'host-b', 'claude.json')), 'same-session') + ]) + expect(new Set(ids).size).toBe(3) + }) + + it.each(['', ' ', 'x'.repeat(1025)])( + 'rejects a batch with an invalid key without minting any ID', + async (key) => { + await expect(createStore().getOrCreate(['valid', key])).rejects.toThrow( + 'Invalid provider session ID' + ) + expect(existsSync(file)).toBe(false) + } + ) + + it('handles prototype names and delimiter characters as ordinary opaque keys', async () => { + const store = createStore() + const keys = ['__proto__', 'constructor', 'a::b/c', 'a/b::c'] + const ids = await Promise.all(keys.map((key) => idFor(store, key))) + expect(new Set(ids).size).toBe(keys.length) + const restored = createStore() + expect(await Promise.all(keys.map((key) => idFor(restored, key)))).toEqual(ids) + }) + + it.each([ + '{broken', + JSON.stringify({ schemaVersion: 2, entries: [] }), + JSON.stringify({ schemaVersion: 1, entries: [['session', 'provider-id']] }), + JSON.stringify({ schemaVersion: 1, entries: [], extra: 'secret' }) + ])('fails closed on invalid persisted state without overwriting it', async (content) => { + writeFileSync(file, content) + await expect(idFor(createStore(), 'new-session')).rejects.toThrow( + 'Invalid analytics session identity file' + ) + expect(readFileSync(file, 'utf8')).toBe(content) + }) + + it('rejects conflicting provider keys and analytics IDs', async () => { + const first = '00000000-0000-4000-8000-000000000001' + const second = '00000000-0000-4000-8000-000000000002' + for (const entries of [ + [ + ['a', first], + ['a', second] + ], + [ + ['a', first], + ['b', first] + ] + ]) { + writeFileSync(file, JSON.stringify({ schemaVersion: 1, entries })) + await expect(idFor(createStore(), 'new-session')).rejects.toThrow( + 'Duplicate analytics session identity' + ) + } + }) + + it('keeps all callers and shutdown waiting until the ID write completes', async () => { + let release!: () => void + let entered!: () => void + writeGate.wait = new Promise((resolve) => { + release = resolve + }) + const writing = new Promise((resolve) => { + entered = resolve + }) + writeGate.entered = entered + const store = createStore() + let resolved = false + let flushed = false + const first = idFor(store, 'session').then((id) => { + resolved = true + return id + }) + await writing + const second = idFor(store, 'session') + const flush = store.flush().then(() => { + flushed = true + }) + try { + await Promise.resolve() + expect(resolved).toBe(false) + expect(flushed).toBe(false) + } finally { + release() + } + expect(await first).toBe(await second) + await flush + expect(flushed).toBe(true) + }) + + it('does not return an unpersisted ID on write failure and allows retry', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const store = createStore() + writeGate.fail = true + await expect(idFor(store, 'session')).rejects.toThrow('simulated disk failure') + expect(existsSync(file)).toBe(false) + writeGate.fail = false + const id = await idFor(store, 'session') + expect(await idFor(createStore(), 'session')).toBe(id) + }) +}) diff --git a/src/main/usage/analytics-session-id-store.ts b/src/main/usage/analytics-session-id-store.ts new file mode 100644 index 00000000000..1624732b131 --- /dev/null +++ b/src/main/usage/analytics-session-id-store.ts @@ -0,0 +1,94 @@ +import { randomUUID } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { z } from 'zod' +import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer' + +const providerSessionIdSchema = z + .string() + .min(1) + .max(1024) + .refine((id) => id.trim().length > 0) +const analyticsSessionIdSchema = z.uuidv4().brand<'AnalyticsSessionId'>() +export type AnalyticsSessionId = z.infer +const identityFileSchema = z + .object({ + schemaVersion: z.literal(1), + entries: z.array(z.tuple([providerSessionIdSchema, analyticsSessionIdSchema])) + }) + .strict() + +/** One owner per file, scoped to a provider's usage store on its execution host. */ +export class AnalyticsSessionIdStore { + private identities: Map | null = null + private pending: Promise = Promise.resolve() + private writer: UsageCacheSnapshotWriter | null = null + + constructor(private readonly file: string) {} + + /** IDs in input order; resolves only after persistence succeeds, so an uploaded ID survives a restart. */ + async getOrCreate(providerSessionIds: readonly string[]): Promise { + if (providerSessionIds.some((id) => !providerSessionIdSchema.safeParse(id).success)) { + throw new Error('Invalid provider session ID') + } + if (providerSessionIds.length === 0) { + return [] + } + // Serialize reads as well as writes so no caller sees an ID before it is durable. + const operation = this.pending.then(async () => { + const identities = this.identities ?? (await this.load()) + this.identities = identities + const updated = new Map(identities) + const ids = providerSessionIds.map((providerSessionId) => { + const id = updated.get(providerSessionId) ?? analyticsSessionIdSchema.parse(randomUUID()) + updated.set(providerSessionId, id) + return id + }) + // One durable write per batch: a first scan of N sessions must not rewrite the file N times. + if (updated.size > identities.size) { + this.writer ??= new UsageCacheSnapshotWriter('[analytics-session-id]', () => this.file) + await this.writer.write(() => JSON.stringify({ schemaVersion: 1, entries: [...updated] })) + this.identities = updated + } + return ids + }) + this.pending = operation.then( + () => {}, + () => {} + ) + return operation + } + + /** Includes queued lookups that have not reached the durable writer yet. */ + async flush(): Promise { + await this.pending + await this.writer?.flush() + } + + private async load(): Promise> { + let content: string + try { + content = await readFile(this.file, 'utf8') + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + return new Map() + } + throw error + } + let raw: unknown + try { + raw = JSON.parse(content) + } catch { + throw new Error('Invalid analytics session identity file') + } + const parsed = identityFileSchema.safeParse(raw) + if (!parsed.success) { + throw new Error('Invalid analytics session identity file') + } + const identities = new Map(parsed.data.entries) + const analyticsIds = new Set(parsed.data.entries.map(([, id]) => id)) + if (identities.size !== parsed.data.entries.length || analyticsIds.size !== identities.size) { + throw new Error('Duplicate analytics session identity') + } + return identities + } +} diff --git a/src/main/usage/usage-provider-store-lifecycle.test.ts b/src/main/usage/usage-provider-store-lifecycle.test.ts index 02a5571363d..cef33bf6d89 100644 --- a/src/main/usage/usage-provider-store-lifecycle.test.ts +++ b/src/main/usage/usage-provider-store-lifecycle.test.ts @@ -1,3 +1,7 @@ +import { + setupTelemetryClientTest, + cleanupTelemetryClientTest +} from '../telemetry/client-test-harness' import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import type * as FsPromises from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -106,6 +110,17 @@ class TestUsageStore extends UsageProviderStoreLifecycle< getAllWorktreeMeta: () => ({}) }, { + tokenUsage: { + provider: 'claude', + selectSessions: (state) => + state.sessions.map((session) => ({ + providerSessionId: session.id, + input_tokens: 10, + output_tokens: 2, + cached_input_tokens: 3, + cache_write_input_tokens: 1 + })) + }, logTag: '[test-usage]', resolveCacheFile: () => cacheFile, createDefaultState: makeState, @@ -156,6 +171,77 @@ describe('UsageProviderStoreLifecycle', () => { vi.restoreAllMocks() }) + it('reports enabled scans and preserves revisions when the usage cache is rebuilt', async () => { + const telemetry = setupTelemetryClientTest() + try { + const cacheFile = join(tempDirectory, 'provider.json') + scan.mockResolvedValue({ ...emptyScanResult(), sessions: [{ id: 'provider-session' }] }) + const original = createStore(cacheFile) + await original.refresh(true) + expect(telemetry.mock.capture).not.toHaveBeenCalled() + await original.setEnabled(true) + await original.refresh(true) + const first = telemetry.mock.capture.mock.calls[0]?.[0] + expect(first).toMatchObject({ + event: 'agent_token_usage', + properties: { revision: 1, input_tokens: 10 } + }) + await original.flush() + rmSync(cacheFile) + const rebuilt = createStore(cacheFile) + await rebuilt.setEnabled(true) + await rebuilt.refresh(true) + expect(telemetry.mock.capture.mock.calls[1]?.[0]).toEqual(first) + } finally { + cleanupTelemetryClientTest(telemetry.envStash) + } + }) + + it('persists identities for a whole first scan with one write', async () => { + const telemetry = setupTelemetryClientTest() + try { + const sessions = Array.from({ length: 20 }, (_, index) => ({ id: `session-${index}` })) + scan.mockResolvedValue({ ...emptyScanResult(), sessions }) + const store = createStore(join(tempDirectory, 'provider.json')) + await store.setEnabled(true) + writeProbe.opens = 0 + await store.refresh(true) + // Usage cache, identity file, and token-usage snapshot: one write each. + expect(writeProbe.opens).toBe(3) + expect(telemetry.mock.capture).toHaveBeenCalledTimes(sessions.length) + } finally { + cleanupTelemetryClientTest(telemetry.envStash) + } + }) + + it('keeps analytics identity separate from usage cache rebuilds and never puts it in snapshots', async () => { + const cacheFile = join(tempDirectory, 'provider.json') + const identityFile = join(tempDirectory, 'provider-analytics-session-ids.json') + const original = createStore(cacheFile) + expect(existsSync(identityFile)).toBe(false) + const [id] = await original.getAnalyticsSessionIds(['provider-session']) + expect(existsSync(identityFile)).toBe(true) + await original.setEnabled(true) + await original.refresh(true) + await original.flush() + expect(JSON.stringify(original.getState())).not.toContain(id) + expect(readFileSync(cacheFile, 'utf8')).not.toContain(id) + rmSync(cacheFile) + const rebuilt = createStore(cacheFile) + expect(await rebuilt.getAnalyticsSessionIds(['provider-session'])).toEqual([id]) + expect(await createStore().getAnalyticsSessionIds(['provider-session'])).not.toEqual([id]) + }) + + it('flush waits for queued analytics identity creation', async () => { + const store = createStore() + const identity = store.getAnalyticsSessionIds(['session']) + await store.flush() + const [id] = await identity + expect( + readFileSync(join(tempDirectory, 'usage-0-analytics-session-ids.json'), 'utf8') + ).toContain(id) + }) + it('skips disabled and fresh matching states', async () => { const store = createStore() diff --git a/src/main/usage/usage-provider-store-lifecycle.ts b/src/main/usage/usage-provider-store-lifecycle.ts index 850d9e61bda..550f79e3ce5 100644 --- a/src/main/usage/usage-provider-store-lifecycle.ts +++ b/src/main/usage/usage-provider-store-lifecycle.ts @@ -1,4 +1,10 @@ import { existsSync, readFileSync } from 'node:fs' +import { AgentTokenUsageReporter } from './agent-token-usage-reporter' +import type { AgentTokenSession } from './agent-token-usage' +import type { AgentTokenUsage } from '../../shared/telemetry-agent-token-usage-schema' +import { isTelemetryEnabled } from '../telemetry/client' +import { join, parse } from 'node:path' +import { AnalyticsSessionIdStore, type AnalyticsSessionId } from './analytics-session-id-store' import type { Store } from '../persistence' import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer' import { loadKnownUsageWorktreesByRepo } from '../usage-worktree-metadata' @@ -38,6 +44,10 @@ type UsageProviderStoreLifecycleConfig< sourceKey: SourceKey dataPresenceKey: DataPresenceKey jsonIndent?: number + tokenUsage?: { + provider: AgentTokenUsage['provider'] + selectSessions: (state: State) => AgentTokenSession[] + } scan: ( worktrees: UsageScanWorktreeRef[], previous: State[SourceKey] @@ -55,6 +65,8 @@ export abstract class UsageProviderStoreLifecycle< > { protected state: State private scanPromise: Promise | null = null + private tokenReporter: AgentTokenUsageReporter | null = null + private analyticsSessionIds: AnalyticsSessionIdStore | null = null private readonly writer: UsageCacheSnapshotWriter constructor( @@ -74,9 +86,21 @@ export abstract class UsageProviderStoreLifecycle< } as PublicUsageProviderScanState } + /** Local identity only; callers must use the usage store on the execution host. */ + getAnalyticsSessionIds(providerSessionIds: readonly string[]): Promise { + if (!this.analyticsSessionIds) { + const { dir, name } = parse(this.config.resolveCacheFile()) + this.analyticsSessionIds = new AnalyticsSessionIdStore( + join(dir, `${name}-analytics-session-ids.json`) + ) + } + return this.analyticsSessionIds.getOrCreate(providerSessionIds) + } + /** Await queued cache writes so quit does not drop the final snapshot. */ - flush(): Promise { - return this.writer.flush() + async flush(): Promise { + await this.tokenReporter?.flush() + await Promise.all([this.writer.flush(), this.analyticsSessionIds?.flush()]) } async setEnabled(enabled: boolean): Promise> { @@ -152,6 +176,7 @@ export abstract class UsageProviderStoreLifecycle< this.state.scanState.lastScanError = null // Persistence failures do not turn a successful source scan into a scan failure. await this.writeToDisk().catch(() => {}) + await this.reportTokenUsage() } catch (error) { this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error) await this.writeToDisk().catch(() => {}) @@ -163,6 +188,29 @@ export abstract class UsageProviderStoreLifecycle< await this.scanPromise } + private async reportTokenUsage(): Promise { + const config = this.config.tokenUsage + if (!config || !isTelemetryEnabled() || !this.state.scanState.enabled) { + return + } + try { + if (!this.tokenReporter) { + const { dir, name } = parse(this.config.resolveCacheFile()) + this.tokenReporter = new AgentTokenUsageReporter( + join(dir, `${name}-token-usage.json`), + config.provider, + (ids) => this.getAnalyticsSessionIds(ids) + ) + } + await this.tokenReporter.report(config.selectSessions(this.state)) + } catch { + // Reporting failures must not invalidate a successful local usage scan. + console.warn( + '[agent-token-usage] Could not report token usage; will retry after the next scan' + ) + } + } + private async getCurrentWorktreeFingerprint(): Promise { const repos = this.store.getRepos() return getUsageWorktreeFingerprint(loadKnownUsageWorktreesByRepo(this.store, repos)) diff --git a/src/main/usage/usage-scan-worker-client.test.ts b/src/main/usage/usage-scan-worker-client.test.ts index 7b34b5a80f4..09c6f378f00 100644 --- a/src/main/usage/usage-scan-worker-client.test.ts +++ b/src/main/usage/usage-scan-worker-client.test.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { join, sep } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { resolveWorkerThreadEntryPath } from '../worker-thread-entry-path' @@ -235,16 +234,4 @@ describe('usage scan worker entry path', () => { USAGE_SCAN_WORKER_ENTRY_FILENAME ]) }) - - // A rename in the build config would leave both branches pointing at a file - // that is never emitted, and only the packaged one fails silently. - it('names the entry the main build actually emits', () => { - const config = readFileSync( - join(import.meta.dirname, '..', '..', '..', 'electron.vite.config.ts'), - 'utf8' - ) - - expect(USAGE_SCAN_WORKER_ENTRY_FILENAME).toBe('usage-scan-worker-entry.js') - expect(config).toContain("'usage-scan-worker-entry': resolve(") - }) }) diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index ae83f5b7712..9e5c5493f09 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' +import type { RuntimeNotifier } from '../runtime/runtime-notifier-contract' const { onMock, @@ -154,7 +155,7 @@ type MainWindowStub = { type RuntimeStub = { attachWindow: MockFn - setNotifier: MockFn + setNotifier: ReturnType void>> markRendererReloading: MockFn markRendererReloadCancelled: MockFn markGraphReloadFailed: MockFn @@ -195,7 +196,7 @@ function createStore(): Store & { flushPendingAsync: MockFn } { function createRuntime(): RuntimeStub { return { attachWindow: vi.fn(), - setNotifier: vi.fn(), + setNotifier: vi.fn<(notifier: RuntimeNotifier | null) => void>(), markRendererReloading: vi.fn(), markRendererReloadCancelled: vi.fn(), markGraphReloadFailed: vi.fn(), @@ -290,8 +291,7 @@ describe('attachMainWindowServices', () => { await providerStartup.promise await Promise.resolve() - expect(hydrateLocalPtyRegistryAtBootMock).toHaveBeenCalledOnce() - expect(hydrateLocalPtyRegistryAtBootMock).toHaveBeenCalledWith(store) + expect(hydrateLocalPtyRegistryAtBootMock).toHaveBeenCalledExactlyOnceWith(store) }) it('passes injected update quit cleanup to the auto-updater', async () => { @@ -305,17 +305,23 @@ describe('attachMainWindowServices', () => { createRuntime() as never, undefined, undefined, - { onBeforeUpdateQuit, updateInstallMode: 'supervised-headless-serve' } + { + onBeforeUpdateQuit, + onBeforeUpdateQuitFailure: 'abort', + updateInstallMode: 'supervised-headless-serve' + } ) // Deferred to first paint — must not be configured at attach time. expect(setupAutoUpdaterMock).not.toHaveBeenCalled() await fireReadyToShow(mainWindow) expect(setupAutoUpdaterMock).toHaveBeenCalledTimes(1) - expect(setupAutoUpdaterMock).toHaveBeenCalledWith( - mainWindow, - expect.objectContaining({ installMode: 'supervised-headless-serve' }) - ) + const [updaterWindow, updaterOptions] = setupAutoUpdaterMock.mock.calls[0] + expect(updaterWindow).toBe(mainWindow) + expect(updaterOptions).toMatchObject({ + installMode: 'supervised-headless-serve', + onBeforeQuitFailure: 'abort' + }) await setupAutoUpdaterMock.mock.calls[0][1].onBeforeQuit() expect(onBeforeUpdateQuit).toHaveBeenCalledTimes(1) @@ -758,14 +764,9 @@ describe('attachMainWindowServices', () => { attachMainWindowServices(mainWindow as never, createStore(), runtime as never) expect(runtime.setNotifier).toHaveBeenCalledTimes(1) - const notifier = runtime.setNotifier.mock.calls[0][0] as { - worktreesChanged: (repoId: string) => void - reposChanged: () => void - activateWorktree: ( - repoId: string, - worktreeId: string, - setup?: { runnerScriptPath: string; envVars: Record } - ) => void + const notifier = runtime.setNotifier.mock.calls[0][0] + if (!notifier) { + throw new Error('Missing runtime notifier') } notifier.worktreesChanged('repo-1') diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 78496abc1fc..5a7aab6b145 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -24,7 +24,7 @@ import { registerRemoteWorkspaceHandlers } from '../ipc/remote-workspace' import { browserManager } from '../browser/browser-manager' import { hasSystemMediaAccess, requestSystemMediaAccess } from '../browser/browser-media-access' import type { OrcaRuntimeService, RuntimeWorktreeLifecycleEvent } from '../runtime/orca-runtime' -import type { UpdateInstallMode } from '../updater' +import type { PreQuitCleanupFailureMode, UpdateInstallMode } from '../updater' import { scheduleHistoryGc } from '../terminal-history-gc' import { hydrateLocalPtyRegistryAtBoot } from '../memory/hydrate-local-pty-registry' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' @@ -64,6 +64,7 @@ export function attachMainWindowServices( onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void onPtyExit?: (id: string, exitSequence: number) => void onBeforeUpdateQuit?: () => void | Promise + onBeforeUpdateQuitFailure?: PreQuitCleanupFailureMode updateInstallMode?: UpdateInstallMode onWorktreeLifecycle?: (event: RuntimeWorktreeLifecycleEvent) => void } diff --git a/src/main/window/clipboard-file-copy.test.ts b/src/main/window/clipboard-file-copy.test.ts index d1142705a92..dfc58eb7e1c 100644 --- a/src/main/window/clipboard-file-copy.test.ts +++ b/src/main/window/clipboard-file-copy.test.ts @@ -91,6 +91,17 @@ describe('writeFileToClipboard', () => { expect(args.join(' ')).toContain("Set-Clipboard -LiteralPath '/repo/o''brien.png'") }) + it('doubles typographic single quotes, which PowerShell also treats as delimiters', async () => { + const runCommand = vi.fn(async (_command: string, _args: string[]) => {}) + await writeFileToClipboard( + '/repo/o\u2019brien.png', + makeDeps({ platform: 'win32', runCommand }) + ) + expect(runCommand.mock.calls[0][1].join(' ')).toContain( + "Set-Clipboard -LiteralPath '/repo/o\u2019\u2019brien.png'" + ) + }) + it('reports a failure (never throws) when PowerShell rejects on Windows', async () => { const runCommand = vi.fn(async (_command: string, _args: string[]) => { throw new Error('powershell.exe not found') diff --git a/src/main/window/clipboard-file-copy.ts b/src/main/window/clipboard-file-copy.ts index 63f5594f279..bc09c27c19c 100644 --- a/src/main/window/clipboard-file-copy.ts +++ b/src/main/window/clipboard-file-copy.ts @@ -1,5 +1,6 @@ import { isAbsolute } from 'node:path' import { pathToFileURL } from 'node:url' +import { quotePowerShellLiteral } from '../../shared/powershell-native-argument' export type ClipboardFileResult = { ok: boolean; reason?: string } @@ -47,15 +48,14 @@ export async function writeFileToClipboard( if (deps.platform === 'win32') { // Set-Clipboard -LiteralPath populates CF_HDROP, which Explorer pastes as a - // file. Single-quote escaping for the PowerShell string literal. Guard the - // spawn so a missing/erroring PowerShell surfaces as a result, not a throw. - const escaped = clipboardPath.replace(/'/g, "''") + // file. Guard the spawn so a missing/erroring PowerShell surfaces as a + // result, not a throw. try { await deps.runCommand('powershell.exe', [ '-NoProfile', '-NonInteractive', '-Command', - `Set-Clipboard -LiteralPath '${escaped}'` + `Set-Clipboard -LiteralPath ${quotePowerShellLiteral(clipboardPath)}` ]) return { ok: true } } catch { diff --git a/src/main/window/history-gc-profile-worktree-ids.test.ts b/src/main/window/history-gc-profile-worktree-ids.test.ts index b3f0960eab9..3c01d0f2ea2 100644 --- a/src/main/window/history-gc-profile-worktree-ids.test.ts +++ b/src/main/window/history-gc-profile-worktree-ids.test.ts @@ -3,11 +3,14 @@ * segment, while the Store the GC consults holds one profile's ids. Without * these, switching profiles makes every other profile's history look orphaned. */ -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { folderWorkspaceKey } from '../../shared/workspace-scope' +import { importProfileStateJson } from '../persistence/profile-state/profile-state-documents' +import { openProfileStateDatabase } from '../persistence/profile-state/profile-state-database' +import { getOrcaProfileStateDatabaseFile } from '../orca-profiles/profile-storage-paths' import { getOtherProfileWorktreeIdsForHistoryGc } from './history-gc-profile-worktree-ids' const roots: string[] = [] @@ -103,6 +106,153 @@ describe('getOtherProfileWorktreeIdsForHistoryGc', () => { expect(getOtherProfileWorktreeIdsForHistoryGc(root).unreadableProfiles).toBe(1) }) + it('prefers the profile database over a stale JSON export', () => { + const root = userDataWithProfiles('active', [ + { id: 'active', state: {} }, + { + id: 'other', + state: { worktreeMeta: { 'repo::/json': {} }, folderWorkspaces: [] } + } + ]) + const database = openProfileStateDatabase( + getOrcaProfileStateDatabaseFile('other', root), + 'other' + ) + try { + importProfileStateJson( + database.db, + JSON.stringify({ + worktreeMeta: { 'repo::/database': {} }, + folderWorkspaces: [{ id: 'folder-database' }] + }) + ) + } finally { + database.db.close() + } + + expect(getOtherProfileWorktreeIdsForHistoryGc(root)).toEqual({ + unreadableProfiles: 0, + ids: new Set(['repo::/database', folderWorkspaceKey('folder-database')]) + }) + }) + + it('falls back to JSON without creating a database', () => { + const root = userDataWithProfiles('active', [ + { id: 'active', state: {} }, + { id: 'other', state: { worktreeMeta: { 'repo::/json': {} } } } + ]) + const databaseFile = getOrcaProfileStateDatabaseFile('other', root) + + expect(getOtherProfileWorktreeIdsForHistoryGc(root)).toEqual({ + unreadableProfiles: 0, + ids: new Set(['repo::/json']) + }) + expect(existsSync(databaseFile)).toBe(false) + }) + + it.each([true, false])( + 'refuses history pruning when SQLite is missing but a retained export exists (JSON: %s)', + (hasJson) => { + const state = { worktreeMeta: { 'repo::/stale-json': {} } } + const root = userDataWithProfiles('active', [ + { id: 'active', state: {} }, + { id: 'other', ...(hasJson ? { state } : {}) } + ]) + const dataFile = join(root, 'profiles', 'other', 'orca-data.json') + const exportPath = `${dataFile}.sqlite-export.1.json` + const exportJson = JSON.stringify({ worktreeMeta: { 'repo::/export': {} } }) + writeFileSync(exportPath, exportJson) + + expect(getOtherProfileWorktreeIdsForHistoryGc(root)).toEqual({ + unreadableProfiles: 1, + ids: new Set() + }) + expect(existsSync(getOrcaProfileStateDatabaseFile('other', root))).toBe(false) + expect(existsSync(dataFile)).toBe(hasJson) + if (hasJson) { + expect(readFileSync(dataFile, 'utf8')).toBe(JSON.stringify(state)) + } + expect(readFileSync(exportPath, 'utf8')).toBe(exportJson) + } + ) + + it('reads SQLite-only profiles with retained exports without blocking history pruning', () => { + const root = userDataWithProfiles('active', [{ id: 'active', state: {} }, { id: 'other' }]) + const database = openProfileStateDatabase( + getOrcaProfileStateDatabaseFile('other', root), + 'other' + ) + try { + importProfileStateJson( + database.db, + JSON.stringify({ worktreeMeta: { 'repo::/database': {} } }) + ) + } finally { + database.db.close() + } + const dataFile = join(root, 'profiles', 'other', 'orca-data.json') + writeFileSync( + `${dataFile}.sqlite-export.1.json`, + JSON.stringify({ worktreeMeta: { 'repo::/stale-export': {} } }) + ) + + expect(getOtherProfileWorktreeIdsForHistoryGc(root)).toEqual({ + unreadableProfiles: 0, + ids: new Set(['repo::/database']) + }) + expect(existsSync(dataFile)).toBe(false) + }) + + it('does not fall back to JSON when an existing database is corrupt', () => { + const root = userDataWithProfiles('active', [ + { id: 'active', state: {} }, + { id: 'other', state: { worktreeMeta: { 'repo::/json': {} } } } + ]) + writeFileSync(getOrcaProfileStateDatabaseFile('other', root), 'not a sqlite database') + + expect(getOtherProfileWorktreeIdsForHistoryGc(root)).toEqual({ + unreadableProfiles: 1, + ids: new Set() + }) + }) + + it.each(['-wal', '-shm', '-journal'])( + 'does not fall back to JSON when only %s remains', + (suffix) => { + const root = userDataWithProfiles('active', [ + { id: 'active', state: {} }, + { id: 'other', state: { worktreeMeta: { 'repo::/json': {} } } } + ]) + writeFileSync( + `${getOrcaProfileStateDatabaseFile('other', root)}${suffix}`, + 'orphaned evidence' + ) + + expect(getOtherProfileWorktreeIdsForHistoryGc(root)).toEqual({ + unreadableProfiles: 1, + ids: new Set() + }) + } + ) + + it('refuses history pruning for a future profile database schema', () => { + const root = userDataWithProfiles('active', [ + { id: 'active', state: {} }, + { id: 'other', state: { worktreeMeta: { 'repo::/json': {} } } } + ]) + const database = openProfileStateDatabase( + getOrcaProfileStateDatabaseFile('other', root), + 'other' + ) + database.db.pragma('user_version = 99') + database.db.close() + + expect(getOtherProfileWorktreeIdsForHistoryGc(root)).toEqual({ + unreadableProfiles: 1, + ids: new Set() + }) + }) + // A single-profile install must not pay for this, and no index at all is the // pre-profiles layout rather than an error. it('is empty and complete when there is no profile index', () => { diff --git a/src/main/window/history-gc-profile-worktree-ids.ts b/src/main/window/history-gc-profile-worktree-ids.ts index 6f82b96fc37..07a115b0ad8 100644 --- a/src/main/window/history-gc-profile-worktree-ids.ts +++ b/src/main/window/history-gc-profile-worktree-ids.ts @@ -1,10 +1,13 @@ -import { readFileSync } from 'node:fs' +import { lstatSync, readFileSync } from 'node:fs' import { folderWorkspaceKey } from '../../shared/workspace-scope' import { getOrcaProfileDataFile, + getOrcaProfileStateDatabaseFile, getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { getOrcaProfileIndexPath, readProfileIndex } from '../orca-profiles/profile-index-store' +import { readProfileStateDomains } from '../persistence/profile-state/profile-state-domain-reader' +import { assertNoRetainedProfileStateExports } from '../persistence/profile-state/profile-state-recovery-required' /** * Worktree ids owned by Orca profiles OTHER than the running one. @@ -16,7 +19,7 @@ import { getOrcaProfileIndexPath, readProfileIndex } from '../orca-profiles/prof * switch every other profile's history looks orphaned, and the GC deletes shell * history those profiles are still using. * - * Reading their data files directly is deliberate: a Store per profile would + * Reading their persisted state directly is deliberate: a Store per profile would * run migrations and normalization against state another profile owns. Only the * two id-bearing collections are read, and any unreadable profile is skipped — * a profile whose ids cannot be established must widen the live set's @@ -37,7 +40,7 @@ export function getOtherProfileWorktreeIdsForHistoryGc(userDataPath = getProfile if (profile.id === index.activeProfileId) { continue } - const collected = readProfileWorktreeIds(getOrcaProfileDataFile(profile.id, userDataPath)) + const collected = readProfileWorktreeIds(profile.id, userDataPath) if (!collected) { unreadableProfiles += 1 continue @@ -49,7 +52,99 @@ export function getOtherProfileWorktreeIdsForHistoryGc(userDataPath = getProfile return { ids, unreadableProfiles } } -function readProfileWorktreeIds(dataFile: string): Set | null { +function readProfileWorktreeIds(profileId: string, userDataPath: string): Set | null { + const databaseFile = getOrcaProfileStateDatabaseFile(profileId, userDataPath) + // A present database is authoritative. In particular, do not fall back to a + // stale JSON export after corruption or a future schema, because that could + // make live history look orphaned and delete it. + const databasePresence = profileStateDatabasePresence(databaseFile) + if (databasePresence === 'present') { + return readProfileWorktreeIdsFromDatabase(databaseFile, profileId) + } + if (databasePresence === 'unreadable') { + return null + } + const dataFile = getOrcaProfileDataFile(profileId, userDataPath) + try { + assertNoRetainedProfileStateExports({ dataFile, databaseFile, profileId }) + } catch { + return null + } + return readProfileWorktreeIdsFromJson(dataFile) +} + +function profileStateDatabasePresence(path: string): 'absent' | 'present' | 'unreadable' { + let mainDatabasePresent = false + try { + lstatSync(path) + mainDatabasePresent = true + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + mainDatabasePresent = false + } else { + return 'unreadable' + } + } + if (mainDatabasePresent) { + return 'present' + } + for (const sidecar of [`${path}-wal`, `${path}-shm`, `${path}-journal`]) { + try { + lstatSync(sidecar) + return 'unreadable' + } catch (error) { + if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'ENOENT') { + return 'unreadable' + } + } + } + return 'absent' +} + +function readProfileWorktreeIdsFromDatabase( + databaseFile: string, + profileId: string +): Set | null { + const domains = readProfileStateDomains(databaseFile, profileId, [ + 'worktreeMeta', + 'folderWorkspaces' + ]) + if (domains.kind === 'unreadable') { + return null + } + + const ids = new Set() + const worktreeMeta = domains.values.get('worktreeMeta') + if (worktreeMeta !== undefined) { + if (worktreeMeta === null) { + // Explicit null is a valid legacy state value and means no metadata. + } else if (!isRecord(worktreeMeta)) { + return null + } else { + for (const id of Object.keys(worktreeMeta)) { + ids.add(id) + } + } + } + const folderWorkspaces = domains.values.get('folderWorkspaces') + if (folderWorkspaces !== undefined) { + if (folderWorkspaces === null) { + // Explicit null is a valid legacy state value and means no workspaces. + } else if (!Array.isArray(folderWorkspaces)) { + return null + } else { + for (const workspace of folderWorkspaces) { + const id = isRecord(workspace) ? workspace.id : undefined + if (typeof id === 'string' && id) { + ids.add(folderWorkspaceKey(id)) + } + } + } + } + return ids +} + +function readProfileWorktreeIdsFromJson(dataFile: string): Set | null { let parsed: unknown try { parsed = JSON.parse(readFileSync(dataFile, 'utf8')) @@ -78,3 +173,7 @@ function readProfileWorktreeIds(dataFile: string): Set | null { } return ids } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/window/main-window-updater.ts b/src/main/window/main-window-updater.ts index f0298ed115d..7cdcdd8cd73 100644 --- a/src/main/window/main-window-updater.ts +++ b/src/main/window/main-window-updater.ts @@ -16,6 +16,7 @@ import { quitAndInstall, setupAutoUpdater, showLinuxPackage, + type PreQuitCleanupFailureMode, type UpdateInstallMode } from '../updater' @@ -33,6 +34,7 @@ export function scheduleMainWindowAutoUpdaterSetup( store: Store, options?: { onBeforeUpdateQuit?: () => void | Promise + onBeforeUpdateQuitFailure?: PreQuitCleanupFailureMode updateInstallMode?: UpdateInstallMode } ): void { @@ -69,6 +71,7 @@ export function scheduleMainWindowAutoUpdaterSetup( store.updateUI({ dismissedUpdateNudgeId: id }) }, getReleaseChannelOverride: () => store.getUI().releaseChannelOverride ?? null, + onBeforeQuitFailure: options?.onBeforeUpdateQuitFailure, installMode: options?.updateInstallMode }) logStartupMilestone('updater-setup-done') diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index 78c5f2ec426..142389099c0 100644 --- a/src/main/window/runtime-window-lifecycle.ts +++ b/src/main/window/runtime-window-lifecycle.ts @@ -198,7 +198,8 @@ export function registerRuntimeWindowLifecycle( baseVersion, content }) as Promise, - closeTerminal: (tabId, paneRuntimeId) => send('ui:closeTerminal', { tabId, paneRuntimeId }), + closeTerminal: (tabId) => send('ui:closeTerminal', { kind: 'tab', tabId }), + closeTerminalPane: (tabId, leafId) => send('ui:closeTerminal', { kind: 'pane', tabId, leafId }), closeTerminalTab: (tabId, options) => requestTerminalTabCloseFromRenderer(mainWindow, tabId, options), sleepWorktree: (worktreeId) => send('ui:sleepWorktree', { worktreeId }), diff --git a/src/main/windows/windows-msys-job.win32.test.ts b/src/main/windows/windows-msys-job.win32.test.ts index e7e0bee950a..9bf5485102e 100644 --- a/src/main/windows/windows-msys-job.win32.test.ts +++ b/src/main/windows/windows-msys-job.win32.test.ts @@ -37,8 +37,12 @@ describeOnWindows('MSYS terminal job ownership', () => { }) let output = '' let childPid: number | undefined + let exit: { exitCode: number; signal?: number } | undefined + proc.onExit((event) => { + exit = event + }) proc.onData((chunk) => { - output += chunk + output = (output + chunk).slice(-32_768) const match = /MSYS_OWNED_CHILD=(\d+)/.exec(output) if (match) { childPid = Number(match[1]) @@ -48,7 +52,14 @@ describeOnWindows('MSYS terminal job ownership', () => { proc.write( `${quotePosixShell(process.execPath.replace(/\\/g, '/'))} ${quotePosixShell(script.replace(/\\/g, '/'))}\r` ) - await vi.waitFor(() => expect(childPid).toBeDefined(), { timeout: 15_000 }) + try { + await vi.waitFor(() => expect(childPid).toBeDefined(), { timeout: 15_000 }) + } catch (cause) { + throw new Error( + JSON.stringify({ shellPid: proc.pid, exit, jobPids: listPtyJobProcessIds(proc), output }), + { cause } + ) + } expect(isAlive(childPid!)).toBe(true) expect(listPtyJobProcessIds(proc)).toContain(childPid) expect(terminatePtyJob(proc)).toBe('terminated') diff --git a/src/main/windows/windows-process-creation-time.test.ts b/src/main/windows/windows-process-creation-time.test.ts new file mode 100644 index 00000000000..3a316613b2b --- /dev/null +++ b/src/main/windows/windows-process-creation-time.test.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { + __setWindowsProcessTreeLoaderForTests, + __setWindowsProcessTreeRequireForTests, + readWindowsProcessCreationTime +} from './windows-process-table' + +const platform = Object.getOwnPropertyDescriptor(process, 'platform') +const read = vi.fn<(pid: number) => number | undefined>() +const scan = vi.fn() + +beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + read.mockReset().mockReturnValue(1_700_000_000_000) + scan.mockReset() + __setWindowsProcessTreeLoaderForTests(() => ({ + ProcessDataFlag: { None: 0, CommandLine: 2, CreationTime: 4 }, + getAllProcesses: scan, + getProcessCreationTime: read + })) +}) + +afterEach(() => { + __setWindowsProcessTreeRequireForTests() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } +}) + +it('reads one PID afresh on each call without enumerating the table', () => { + expect(readWindowsProcessCreationTime(12)).toBe(1_700_000_000_000) + read.mockReturnValue(1_800_000_000_000) + expect(readWindowsProcessCreationTime(12)).toBe(1_800_000_000_000) + expect(read.mock.calls).toEqual([[12], [12]]) + expect(scan).not.toHaveBeenCalled() +}) + +it.each([undefined, 0, -1, Number.NaN, Infinity, 1.5, Number.MAX_SAFE_INTEGER + 1])( + 'keeps an unavailable or invalid native creation time unverifiable: %s', + (value) => { + read.mockReturnValue(value) + expect(readWindowsProcessCreationTime(12)).toBeNull() + } +) + +it.each([0, -1, Number.NaN, Infinity, 1.5, 0x100000000])('refuses an invalid PID %s', (pid) => { + expect(readWindowsProcessCreationTime(pid)).toBeNull() + expect(read).not.toHaveBeenCalled() +}) + +it('keeps denied or failed native queries unverifiable', () => { + read.mockImplementation(() => { + throw Object.assign(new Error('access denied'), { code: 'EPERM' }) + }) + expect(readWindowsProcessCreationTime(12)).toBeNull() +}) + +it('does not invoke the reader off Windows', () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + expect(readWindowsProcessCreationTime(12)).toBeNull() + expect(read).not.toHaveBeenCalled() +}) + +it.each([true, false])('does not scan or fork when native capability is missing: %s', (loaded) => { + __setWindowsProcessTreeLoaderForTests(() => + loaded + ? { + ProcessDataFlag: { None: 0, CommandLine: 2, CreationTime: 4 }, + supportedProcessDataFlags: 7, + getAllProcesses: scan + } + : null + ) + expect(readWindowsProcessCreationTime(12)).toBeNull() + expect(scan).not.toHaveBeenCalled() +}) + +it('adapts the same identity getter from the staged relay addon', () => { + __setWindowsProcessTreeRequireForTests((specifier) => { + if (specifier === '@vscode/windows-process-tree') { + throw new Error('no package') + } + return { getProcessList: scan, getProcessCreationTime: read } + }) + expect(readWindowsProcessCreationTime(12)).toBe(1_700_000_000_000) + expect(read).toHaveBeenCalledWith(12) + expect(scan).not.toHaveBeenCalled() +}) diff --git a/src/main/windows/windows-process-table-native-addon.win32.test.ts b/src/main/windows/windows-process-table-native-addon.win32.test.ts index 1120d8173b4..64045b5b779 100644 --- a/src/main/windows/windows-process-table-native-addon.win32.test.ts +++ b/src/main/windows/windows-process-table-native-addon.win32.test.ts @@ -1,6 +1,7 @@ import { expect, it } from 'vitest' import { isWindowsProcessStartTimeAvailable, + readWindowsProcessCreationTime, readWindowsProcessTableFresh } from './windows-process-table' @@ -22,5 +23,7 @@ it.runIf(process.platform === 'win32')( expect(typeof self?.creationTimeMs).toBe('number') expect(self?.creationTimeMs).toBeGreaterThan(Date.parse('2020-01-01T00:00:00Z')) expect(self?.creationTimeMs).toBeLessThanOrEqual(Date.now()) + expect(readWindowsProcessCreationTime(process.pid)).toBe(self?.creationTimeMs) + expect(readWindowsProcessCreationTime(process.pid)).toBe(self?.creationTimeMs) } ) diff --git a/src/main/windows/windows-process-table.test.ts b/src/main/windows/windows-process-table.test.ts index 16c411ceb71..959a0092ff0 100644 --- a/src/main/windows/windows-process-table.test.ts +++ b/src/main/windows/windows-process-table.test.ts @@ -481,6 +481,29 @@ describe('PowerShell fallback when the native binding is absent', () => { expect(cimScan).not.toHaveBeenCalled() }) + it('says so in the log, once, rather than falling back silently', async () => { + // #16905 was this path running as the daemon's steady state with nothing to + // notice it. Absence is legitimate on a relay; being quiet about it is not. + const fallbackWarnings = (): number => + warn.mock.calls.filter((call) => + String(call[0]).includes('falling back to a powershell.exe CIM scan') + ).length + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + __setWindowsProcessTreeLoaderForTests(() => null) + + await readWindowsProcessTableFresh() + await readWindowsProcessTableFresh() + expect(fallbackWarnings()).toBe(1) + + // Re-injecting resets the reader, so the next process-equivalent warns again. + // Asserting only the first count passes even with that reset removed, as long + // as an earlier test in this file happened to trip the fallback first. + __setWindowsProcessTreeLoaderForTests(() => null) + await readWindowsProcessTableFresh() + expect(fallbackWarnings()).toBe(2) + warn.mockRestore() + }) + it('rejects a scan missing our own pid instead of reporting an idle machine', async () => { __setWindowsProcessTreeLoaderForTests(() => null) cimScan.mockResolvedValue([{ pid: 200, ppid: 4, name: 'claude.exe', command: 'claude' }]) diff --git a/src/main/windows/windows-process-table.ts b/src/main/windows/windows-process-table.ts index e3064560174..20f1ca000c2 100644 --- a/src/main/windows/windows-process-table.ts +++ b/src/main/windows/windows-process-table.ts @@ -82,6 +82,7 @@ type WindowsProcessTreeModule = { * prebuilt `.node` in place. */ supportedProcessDataFlags?: number + getProcessCreationTime?: (pid: number) => number | undefined getAllProcesses: ( callback: (processes: NativeProcessInfo[] | undefined) => void, flags?: number @@ -112,6 +113,7 @@ let requireNative: NativeRequire = requireFromMain * binding straight to the addon drops a duplicate rather than losing a guard. */ type WindowsProcessTreeAddon = { + getProcessCreationTime?: (pid: number) => number | undefined getProcessList: ( callback: (processes: NativeProcessInfo[] | undefined) => void, flags: number @@ -168,6 +170,13 @@ function stagedRelayAddonIsUnpatched(): boolean { } } +/** + * Once per process, for the main and relay processes whose console is real. The + * daemon's stderr is destroyed once it reports ready, so it logs this capability + * to daemonLog at startup instead (daemon-entry.ts). + */ +let warnedAboutCimFallback = false + let cachedModule: WindowsProcessTreeModule | null | undefined let moduleLoader: () => WindowsProcessTreeModule | null = loadWindowsProcessTree let cimScan: () => Promise = readWindowsProcessRowsWithCim @@ -177,6 +186,7 @@ function adaptAddon(addon: WindowsProcessTreeAddon): WindowsProcessTreeModule { return { ProcessDataFlag: PROCESS_DATA_FLAG, supportedProcessDataFlags: addon.supportedProcessDataFlags, + getProcessCreationTime: addon.getProcessCreationTime, getAllProcesses: (callback, flags) => addon.getProcessList(callback, flags ?? 0) } } @@ -293,6 +303,7 @@ let nativeReadGate: Promise = Promise.resolve() function resetNativeReaderState(): void { nativeReaderEpoch += 1 + warnedAboutCimFallback = false unreturnedReads.clear() // Chain, never replace. Dropping the old chain lets a waiter still holding it // run against a read queued on the new one -- two concurrent calls into one @@ -367,6 +378,12 @@ function readOneSnapshot(projection: ProcessRowProjection): Promise 0xffffffff) { + return null + } + try { + const value = moduleLoader()?.getProcessCreationTime?.(pid) + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : null + } catch { + return null + } +} + function resetSnapshotReaders(): void { identityReader.reset() detailedReader.reset() diff --git a/src/main/windows/windows-pty-job.ts b/src/main/windows/windows-pty-job.ts index 169db375f3b..814f2dbaa8a 100644 --- a/src/main/windows/windows-pty-job.ts +++ b/src/main/windows/windows-pty-job.ts @@ -1,4 +1,6 @@ import type { IPty } from 'node-pty' +import { canUseBunPty } from '../daemon/pty-subprocess/bun-pty-process-capabilities' +import { loadWindowsBunPtyJobNative } from '../daemon/pty-subprocess/windows-bun-pty-native' import { createRequire } from 'node:module' import { recordSelfInitiatedTreeKill } from '../crash-reporting/self-initiated-tree-kill-log' @@ -33,6 +35,19 @@ type ConptyNative = { assignCurrentProcessToJob: () => boolean } +type SelfOwnedPty = IPty & { + jobRootProcessIsWrapper?: true + shellProcessId?: number + terminateOwnedTree?: () => JobTerminationOutcome + listOwnedProcessIds?: () => readonly number[] | null +} + +/** The gate's pid never proves that its user shell remains alive. */ +export function ptyShellProcessId(proc: IPty): number | undefined { + const owned: SelfOwnedPty = proc + return owned.jobRootProcessIsWrapper ? owned.shellProcessId : proc.pid +} + let cachedNative: ConptyNative | null | undefined let nativeLoader: () => ConptyNative | null = loadConptyNative @@ -89,6 +104,23 @@ export type JobTerminationOutcome = 'terminated' | 'unavailable' * to be misread as "nothing to kill". */ export function terminatePtyJob(proc: IPty): JobTerminationOutcome { + const owned: SelfOwnedPty = proc + if (typeof owned.terminateOwnedTree === 'function') { + let outcome: JobTerminationOutcome + try { + outcome = owned.terminateOwnedTree() + } catch { + return 'unavailable' + } + if (outcome === 'terminated') { + recordSelfInitiatedTreeKill({ + pid: proc.pid, + site: 'windows-pty-job-teardown', + scope: 'win-pty-job' + }) + } + return outcome + } const target = ptyJobTarget(proc) const native = nativeLoader() if (!target || !native) { @@ -132,6 +164,14 @@ export function terminatePtyJob(proc: IPty): JobTerminationOutcome { * including children that detached from the console. */ export function listPtyJobProcessIds(proc: IPty): readonly number[] | null { + const owned: SelfOwnedPty = proc + if (typeof owned.listOwnedProcessIds === 'function') { + try { + return owned.listOwnedProcessIds() + } catch { + return null + } + } const target = ptyJobTarget(proc) const native = nativeLoader() if (!target || !native) { @@ -193,7 +233,7 @@ function assignHostProcessOnce(): boolean { /** Whether this build can own PTY trees with job objects at all. */ export function isPtyJobOwnershipAvailable(): boolean { - return nativeLoader() !== null + return canUseBunPty() ? loadWindowsBunPtyJobNative() !== null : nativeLoader() !== null } /** Test-only: substitute the native module (it is resolved via createRequire). */ diff --git a/src/main/windows/windows-pty-job.win32.test.ts b/src/main/windows/windows-pty-job.win32.test.ts index c5f408f7311..cb15463bff8 100644 --- a/src/main/windows/windows-pty-job.win32.test.ts +++ b/src/main/windows/windows-pty-job.win32.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { IPty } from 'node-pty' +import { runProcess } from '../../shared/child-process/run-process' import { isPtyJobOwnershipAvailable, listPtyJobProcessIds, @@ -102,6 +103,21 @@ describeOnWindows('ConPTY job ownership', () => { expect(isPtyJobOwnershipAvailable()).toBe(true) }) + it('keeps the native table intact while shell cleanup overlaps new terminals', async () => { + const result = await runProcess({ + program: process.execPath, + args: [join(process.cwd(), 'config', 'scripts', 'windows-pty-table-stress.cjs')], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 90_000 + }) + const status = result.code === null ? 'null' : `0x${(result.code >>> 0).toString(16)}` + expect( + result, + `Native host exited ${status} (${result.signal}); timedOut=${result.timedOut}\n${result.stdout}\n${result.stderr}` + ).toMatchObject({ code: 0, timedOut: false }) + expect(result.stdout).toContain('"phase":"complete"') + }, 100_000) + it('counts a detached grandchild as part of the pane tree', async () => { const { proc, grandchildPid } = await spawnShellWithDetachedGrandchild() diff --git a/src/main/worker-thread-entry-path.ts b/src/main/worker-thread-entry-path.ts index 9c9de40eb80..265c3048b3a 100644 --- a/src/main/worker-thread-entry-path.ts +++ b/src/main/worker-thread-entry-path.ts @@ -46,7 +46,10 @@ export function resolveWorkerThreadEntryPath( export function currentWorkerEntryLayout(moduleDir: string): WorkerEntryLayout { return { isPackaged: hasAppEnvironment() && getAppEnvironment().isPackaged(), - resourcesPath: process.resourcesPath, + resourcesPath: + 'resourcesPath' in process && typeof process.resourcesPath === 'string' + ? process.resourcesPath + : undefined, moduleDir } } diff --git a/src/main/worker-thread-request-queue.test.ts b/src/main/worker-thread-request-queue.test.ts index d7f29266acd..1c1616d8839 100644 --- a/src/main/worker-thread-request-queue.test.ts +++ b/src/main/worker-thread-request-queue.test.ts @@ -1,4 +1,5 @@ import type { Worker } from 'node:worker_threads' +import { getEventListeners } from 'node:events' import { afterEach, describe, expect, it, vi } from 'vitest' import { WorkerThreadRequestQueue } from './worker-thread-request-queue' @@ -103,6 +104,88 @@ describe('WorkerThreadRequestQueue', () => { vi.useRealTimers() }) + it('cancels queued requests without retiring active work and retires active cancellation', async () => { + const workers: FakeWorker[] = [] + const queue = makeQueue(workers) + const active = new AbortController() + const queued = new AbortController() + const first = settle( + queue.dispatch((id) => ({ id, label: 'active' }), TIMEOUT_MS, active.signal) + ) + const second = settle( + queue.dispatch((id) => ({ id, label: 'queued' }), TIMEOUT_MS, queued.signal) + ) + const third = send(queue, 'survivor') + queued.abort(new Error('queued cancelled')) + await expect(second).resolves.toMatchObject({ message: 'queued cancelled' }) + expect(workers[0].terminated).toBe(false) + active.abort(new Error('active cancelled')) + await expect(first).resolves.toMatchObject({ message: 'active cancelled' }) + expect(workers[0].terminated).toBe(true) + expect(workers).toHaveLength(2) + expect(labels(workers[1])).toEqual(['survivor']) + workers[0].respond() + workers[1].respond() + await expect(third).resolves.toMatchObject({ label: 'survivor' }) + queue.dispose() + }) + + it('never starts an already aborted request and rejects all work on disposal', async () => { + const workers: FakeWorker[] = [] + const queue = makeQueue(workers) + await expect( + queue.dispatch( + (id) => ({ id, label: 'aborted' }), + TIMEOUT_MS, + AbortSignal.abort(new Error('cancelled')) + ) + ).rejects.toThrow('cancelled') + expect(workers).toHaveLength(0) + const active = settle(send(queue, 'active')) + const queued = settle(send(queue, 'queued')) + queue.dispose() + await expect(active).resolves.toMatchObject({ message: 'Worker request queue disposed' }) + await expect(queued).resolves.toMatchObject({ message: 'Worker request queue disposed' }) + await expect(send(queue, 'later')).rejects.toThrow('disposed') + expect(workers[0].terminated).toBe(true) + }) + + it.each([false, true])( + 'does not respawn for queued calls sharing the cancelled active signal (survivor: %s)', + async (hasSurvivor) => { + const workers: FakeWorker[] = [] + const queue = makeQueue(workers) + const controller = new AbortController() + const reason = new Error('scan cancelled') + const pending = Array.from({ length: 8 }, (_, index) => + settle( + queue.dispatch( + (id) => ({ id, label: `cancelled-${index}` }), + TIMEOUT_MS, + controller.signal + ) + ) + ) + const survivor = hasSurvivor ? send(queue, 'survivor') : undefined + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(8) + + controller.abort(reason) + + expect(await Promise.all(pending)).toEqual(Array.from({ length: 8 }, () => reason)) + expect(workers[0].terminated).toBe(true) + expect(workers).toHaveLength(hasSurvivor ? 2 : 1) + expect(workers.flatMap(labels)).toEqual( + hasSurvivor ? ['cancelled-0', 'survivor'] : ['cancelled-0'] + ) + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) + if (survivor) { + workers[1].respond() + await expect(survivor).resolves.toMatchObject({ label: 'survivor' }) + } + queue.dispose() + } + ) + it('posts one request at a time and in the order it was dispatched', async () => { const workers: FakeWorker[] = [] const queue = makeQueue(workers) diff --git a/src/main/worker-thread-request-queue.ts b/src/main/worker-thread-request-queue.ts index 3c8f6d0a711..08c27e0eda4 100644 --- a/src/main/worker-thread-request-queue.ts +++ b/src/main/worker-thread-request-queue.ts @@ -43,6 +43,8 @@ type PendingCall = { resolve: (value: TResponse) => void reject: (error: Error) => void timer: NodeJS.Timeout | null + signal?: AbortSignal + cleanupAbort: () => void } export class WorkerThreadRequestQueue< @@ -53,6 +55,7 @@ export class WorkerThreadRequestQueue< private queue: PendingCall[] = [] private consecutiveDeaths = 0 private nextId = 1 + private disposed = false private readonly host: LazyWorkerThreadHost constructor(private readonly options: WorkerThreadRequestQueueOptions) { @@ -73,8 +76,20 @@ export class WorkerThreadRequestQueue< * @param timeoutMs - Deadline measured from dispatch, not from enqueue. * @returns The worker's response; rejects on timeout, crash, or an unspawnable worker. */ - dispatch(buildRequest: (id: number) => TRequest, timeoutMs: number): Promise { + dispatch( + buildRequest: (id: number) => TRequest, + timeoutMs: number, + signal?: AbortSignal + ): Promise { return new Promise((resolve, reject) => { + if (this.disposed) { + reject(new Error('Worker request queue disposed')) + return + } + if (signal?.aborted) { + reject(signal.reason ?? new Error('Worker request aborted')) + return + } // Built before the cap check so a rejection can name the dropped work; // the id it burns is only a correlation token, so a gap costs nothing. const request = buildRequest(this.nextId++) @@ -88,21 +103,57 @@ export class WorkerThreadRequestQueue< if (!this.active && this.queue.length === 0) { this.consecutiveDeaths = 0 } - this.queue.push({ + const call: PendingCall = { request, timeoutMs, resolve, reject, - timer: null - }) + timer: null, + signal, + cleanupAbort: () => signal?.removeEventListener('abort', abort) + } + const abort = (): void => { + if (this.active === call) { + this.host.destroy() + } else { + this.queue = this.queue.filter((queued) => queued !== call) + } + this.settle(call, () => reject(signal?.reason ?? new Error('Worker request aborted'))) + this.afterSettle() + } + signal?.addEventListener('abort', abort, { once: true }) + this.queue.push(call) this.pump() }) } + dispose(): void { + this.disposed = true + this.host.destroy() + const pending = this.active ? [this.active, ...this.queue] : this.queue + this.queue = [] + for (const call of pending) { + this.settle(call, () => call.reject(new Error('Worker request queue disposed'))) + } + } + private pump(): void { if (this.active || this.queue.length === 0) { return } + // A shared signal is already aborted before its remaining listeners run. + while (this.queue[0]?.signal?.aborted) { + const cancelled = this.queue.shift() + if (cancelled) { + this.settle(cancelled, () => + cancelled.reject(cancelled.signal?.reason ?? new Error('Worker request aborted')) + ) + } + } + if (this.queue.length === 0) { + this.host.scheduleIdleTeardown() + return + } const worker = this.host.ensure() if (!worker) { this.failQueuedAsUnavailable() @@ -115,7 +166,11 @@ export class WorkerThreadRequestQueue< this.active = call this.host.clearIdleTimer() this.armDeadline(call) - worker.postMessage(call.request) + try { + worker.postMessage(call.request) + } catch (error) { + this.onWorkerFault(error instanceof Error ? error : new Error(String(error))) + } } /** @@ -199,6 +254,7 @@ export class WorkerThreadRequestQueue< } private settle(call: PendingCall, run: () => void): void { + call.cleanupAbort() if (call.timer) { clearTimeout(call.timer) call.timer = null diff --git a/src/main/workspace-trust-test-fixtures.ts b/src/main/workspace-trust-test-fixtures.ts new file mode 100644 index 00000000000..273c1449329 --- /dev/null +++ b/src/main/workspace-trust-test-fixtures.ts @@ -0,0 +1,32 @@ +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import type { AgentTrustPreset } from './agent-trust-presets' + +/** Whether any trust entry for `preset` landed under `home` (Claude's config lives in the home). */ +export function workspaceTrustWritten(home: string, preset: AgentTrustPreset): boolean { + switch (preset) { + case 'claude': + return 'projects' in JSON.parse(readFileSync(join(home, '.claude.json'), 'utf-8')) + case 'codex': + return existsSync(join(home, '.codex', 'config.toml')) + case 'cursor': { + const projects = join(home, '.cursor', 'projects') + return existsSync(projects) && readdirSync(projects).length > 0 + } + case 'copilot': + return existsSync(join(home, '.copilot', 'config.json')) + case 'qoder': + return existsSync(join(home, '.qoder', 'settings.json')) + case 'antigravity': + return existsSync(join(home, '.gemini', 'antigravity-cli', 'settings.json')) + } +} + +/** A linked worktree at `worktree` whose main checkout is `mainCheckout`, as git lays it out. */ +export function linkGitWorktree(mainCheckout: string, worktree: string): void { + const gitDir = join(mainCheckout, '.git', 'worktrees', 'feature') + mkdirSync(gitDir, { recursive: true }) + mkdirSync(worktree, { recursive: true }) + writeFileSync(join(worktree, '.git'), `gitdir: ${gitDir}\n`) + writeFileSync(join(gitDir, 'gitdir'), join(worktree, '.git')) +} diff --git a/src/main/worktree-create-preparation-contention.test.ts b/src/main/worktree-create-preparation-contention.test.ts new file mode 100644 index 00000000000..9535d63cdee --- /dev/null +++ b/src/main/worktree-create-preparation-contention.test.ts @@ -0,0 +1,369 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Store } from './persistence' +import type { Repo } from '../shared/repo-types' +import type { AddWorktreeResult } from './git/worktree' + +const mocks = vi.hoisted(() => ({ + mkdir: vi.fn(), + listWorktreeGraph: vi.fn(), + prepare: vi.fn(), + finalize: vi.fn(), + discard: vi.fn(), + computeWorkspaceRootAsync: vi.fn(), + getWorktreeOptions: vi.fn(), + resolveBaseRef: vi.fn(), + measureDivergence: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ mkdir: mocks.mkdir })) +vi.mock('./git/worktree', () => ({ listWorktreeGraph: mocks.listWorktreeGraph })) +vi.mock('./git/worktree-create-preparation', () => ({ + prepareWorktreeCreateCheckout: mocks.prepare, + finalizePreparedWorktree: mocks.finalize, + discardPreparedWorktree: mocks.discard, + unlockPreparedWorktree: vi.fn() +})) +vi.mock('./git/worktree-base-ref-probe', () => ({ + resolveLocalWorktreeBaseRef: mocks.resolveBaseRef +})) +vi.mock('./git/worktree-base-divergence', () => ({ + measureRetargetDivergence: mocks.measureDivergence +})) +vi.mock('./project-runtime-git-options', () => ({ + getLocalProjectWorktreeGitOptions: mocks.getWorktreeOptions, + getWorktreeMirrorDistro: () => undefined +})) +vi.mock('./ipc/worktree-logic', () => ({ + computeWorkspaceRootAsync: mocks.computeWorkspaceRootAsync, + getWorktreePathSettings: () => ({ workspaceDir: '/workspace', nestWorkspaces: false }) +})) + +import { + _resetWorktreeCreatePreparationsForTests, + consumePreparedWorktreeCreate, + hasPendingWorktreeCreatePreparations, + prepareWorktreeCreateForRepo +} from './worktree-create-preparation' +import { + listPreparations, + releasePreparationClaim, + startPreparation, + takePreparation +} from './worktree-create-preparation-pool' + +const repo: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: 'blue', + addedAt: 0 +} +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The tested path reads only getSettings from Store. +const store = { getSettings: () => ({}) } as unknown as Store +const flushBackgroundWork = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)) + +function consume(baseBranch = 'origin/main') { + return consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/new-worktree', + branch: 'feature/new-worktree', + baseBranch + }) +} + +beforeEach(() => { + mocks.mkdir.mockReset().mockResolvedValue(undefined) + mocks.listWorktreeGraph.mockReset().mockResolvedValue([]) + mocks.prepare.mockReset().mockResolvedValue(undefined) + mocks.finalize.mockReset().mockResolvedValue({}) + mocks.discard.mockReset().mockResolvedValue(undefined) + mocks.computeWorkspaceRootAsync.mockReset().mockResolvedValue('/workspace') + mocks.getWorktreeOptions.mockReset().mockReturnValue({}) + mocks.resolveBaseRef + .mockReset() + .mockImplementation(async (_path: string, base: string) => + base === 'main' + ? 'refs/heads/main' + : base === 'other/main' + ? 'refs/remotes/other/main' + : 'refs/remotes/origin/main' + ) + mocks.measureDivergence.mockReset().mockResolvedValue('within') +}) + +afterEach(async () => { + await _resetWorktreeCreatePreparationsForTests() +}) + +describe('claimed worktree preparation', () => { + it('defers prefetch through checkout, finalization, and the remaining create work', async () => { + const checkout = Promise.withResolvers() + const checkoutStarted = Promise.withResolvers() + const finalize = Promise.withResolvers() + const finalizeStarted = Promise.withResolvers() + mocks.prepare.mockImplementationOnce(() => { + checkoutStarted.resolve() + return checkout.promise + }) + mocks.finalize.mockImplementationOnce(() => { + finalizeStarted.resolve() + return finalize.promise + }) + const preparation = prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await checkoutStarted.promise + const create = consume() + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + expect(mocks.prepare).toHaveBeenCalledTimes(1) + + checkout.resolve() + await preparation + await finalizeStarted.promise + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + expect(mocks.prepare).toHaveBeenCalledTimes(1) + + finalize.resolve({}) + const result = await create + expect(result.status).toBe('hit') + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + expect(mocks.prepare).toHaveBeenCalledTimes(1) + result.rearm?.() + await flushBackgroundWork() + expect(mocks.prepare).toHaveBeenCalledTimes(2) + }) + + it('coalesces mid-create prefetches and releases once at create completion', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const result = await consume() + expect(result.status).toBe('hit') + expect(hasPendingWorktreeCreatePreparations()).toBe(true) + + await Promise.all([ + prepareWorktreeCreateForRepo(store, repo, 'origin/main'), + prepareWorktreeCreateForRepo(store, repo, 'origin/main') + ]) + expect(mocks.prepare).toHaveBeenCalledTimes(1) + if (result.status === 'hit') { + result.rearm() + result.rearm() + } + await flushBackgroundWork() + expect(mocks.prepare).toHaveBeenCalledTimes(2) + expect(hasPendingWorktreeCreatePreparations()).toBe(true) + }) + + it('does not let two creates claim the same prepared checkout', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const [first, second] = await Promise.all([consume(), consume()]) + + expect([first.status, second.status].sort()).toEqual(['hit', 'miss']) + expect(mocks.finalize).toHaveBeenCalledOnce() + expect(hasPendingWorktreeCreatePreparations()).toBe(true) + first.rearm?.() + second.rearm?.() + expect(hasPendingWorktreeCreatePreparations()).toBe(false) + }) + + it('keeps an explicit prefetch ahead of an automatic replacement for the same key', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const entry = listPreparations()[0] + if (!entry) { + throw new Error('expected a prepared checkout') + } + const claim = takePreparation(entry) + const base = { + repoPath: repo.path, + workspaceRoot: '/workspace', + baseBranch: 'origin/main', + canonicalBase: 'refs/remotes/origin/main' + } + await startPreparation({ ...base, options: {} }, 'automatic') + await startPreparation({ ...base, options: { admissionTier: 'background' } }) + await startPreparation({ ...base, options: {} }, 'automatic') + + const released = releasePreparationClaim(claim) + expect(released.pendingPreparations).toEqual([ + { + kind: 'explicit', + args: { ...base, options: { admissionTier: 'background' } } + } + ]) + expect(releasePreparationClaim(claim)).toEqual({ released: false, pendingPreparations: [] }) + }) + + it('allows a fresh prefetch after an isolated create completes', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const result = await consume() + expect(result.status).toBe('hit') + if (result.status === 'hit') { + result.rearm() + } + expect(hasPendingWorktreeCreatePreparations()).toBe(false) + + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + expect(mocks.prepare).toHaveBeenCalledTimes(2) + }) + + it('does not repeat a burst replacement when release runs twice', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const first = await consume() + if (first.status === 'hit') { + first.rearm() + } + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const second = await consume() + expect(second.status).toBe('hit') + if (second.status === 'hit') { + second.rearm() + second.rearm() + } + await flushBackgroundWork() + expect(mocks.prepare).toHaveBeenCalledTimes(3) + }) + + it('reserves both the prepared and requested canonical bases on a retarget', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const result = await consume('main') + expect(result).toMatchObject({ status: 'hit', retargeted: true }) + + await prepareWorktreeCreateForRepo(store, repo, 'main') + expect(mocks.prepare).toHaveBeenCalledTimes(1) + if (result.status === 'hit') { + result.rearm() + } + await flushBackgroundWork() + expect(mocks.prepare).toHaveBeenCalledTimes(2) + expect(mocks.prepare.mock.calls[1]?.[2]).toBe('refs/heads/main') + }) + + it('preserves distinct prefetch bases while a retargeted create finishes', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const result = await consume('main') + expect(result.status).toBe('hit') + + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await prepareWorktreeCreateForRepo(store, repo, 'main') + expect(mocks.prepare).toHaveBeenCalledTimes(1) + if (result.status === 'hit') { + result.rearm() + } + await flushBackgroundWork() + expect(mocks.prepare).toHaveBeenCalledTimes(3) + }) + + it('passes a pending prefetch to another create claiming the same requested base', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await prepareWorktreeCreateForRepo(store, repo, 'other/main') + const first = await consume('main') + const second = await consume('main') + expect(first.status).toBe('hit') + expect(second.status).toBe('hit') + + await prepareWorktreeCreateForRepo(store, repo, 'main') + expect(mocks.prepare).toHaveBeenCalledTimes(2) + if (second.status === 'hit') { + second.rearm() + } + await flushBackgroundWork() + expect(mocks.prepare).toHaveBeenCalledTimes(2) + if (first.status === 'hit') { + first.rearm() + } + await flushBackgroundWork() + expect(mocks.prepare).toHaveBeenCalledTimes(3) + }) + + it('keeps a burst replacement when the remaining claim is isolated', async () => { + let timestamp = 1_000 + const now = vi.spyOn(Date, 'now').mockImplementation(() => timestamp++) + try { + await prepareWorktreeCreateForRepo(store, repo, 'other/main') + const seed = await consume('other/main') + expect(seed.status).toBe('hit') + seed.rearm?.() + + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await prepareWorktreeCreateForRepo(store, repo, 'other/main') + const burst = await consume('main') + const isolated = await consume('main') + expect(burst).toMatchObject({ status: 'hit', retargeted: true }) + expect(isolated).toMatchObject({ status: 'hit', retargeted: true }) + expect(mocks.prepare).toHaveBeenCalledTimes(3) + + burst.rearm?.() + await flushBackgroundWork() + expect(mocks.prepare).toHaveBeenCalledTimes(3) + isolated.rearm?.() + await flushBackgroundWork() + expect(mocks.prepare).toHaveBeenCalledTimes(4) + expect(mocks.prepare.mock.calls[3]?.[2]).toBe('refs/heads/main') + } finally { + now.mockRestore() + } + }) + + it('does not hold another repo, workspace root, or Git host behind the claim', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const result = await consume() + expect(result.status).toBe('hit') + + await prepareWorktreeCreateForRepo(store, { ...repo, path: '/other-repo' }, 'origin/main') + mocks.computeWorkspaceRootAsync.mockResolvedValueOnce('/other-workspace') + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + expect(mocks.prepare).toHaveBeenCalledTimes(4) + if (result.status === 'hit') { + result.rearm() + } + }) + + it('releases after failed finalization has discarded the claimed checkout', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + let failFinalization!: (error: Error) => void + mocks.finalize.mockReturnValueOnce( + new Promise((_resolve, reject) => { + failFinalization = reject + }) + ) + const create = consume() + await flushBackgroundWork() + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + expect(mocks.prepare).toHaveBeenCalledTimes(1) + + failFinalization(new Error('finalize failed')) + const result = await create + expect(result).toMatchObject({ status: 'miss', reason: 'finalize_failed' }) + expect(mocks.discard).toHaveBeenCalledTimes(1) + await flushBackgroundWork() + expect(mocks.prepare).toHaveBeenCalledTimes(1) + result.rearm?.() + await flushBackgroundWork() + expect(mocks.prepare).toHaveBeenCalledTimes(2) + }) + + it('holds an explicit prefetch through a claimed checkout failure', async () => { + let failPreparation!: (error: Error) => void + mocks.prepare.mockReturnValueOnce( + new Promise((_resolve, reject) => { + failPreparation = reject + }) + ) + const initialPreparation = prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const preparationFailure = initialPreparation.catch(() => {}) + await flushBackgroundWork() + const create = consume() + await flushBackgroundWork() + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + expect(mocks.prepare).toHaveBeenCalledTimes(1) + + failPreparation(new Error('checkout failed')) + const result = await create + await preparationFailure + expect(result).toMatchObject({ status: 'miss', reason: 'prepare_failed' }) + expect(mocks.prepare).toHaveBeenCalledTimes(1) + result.rearm?.() + await flushBackgroundWork() + expect(mocks.prepare).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/worktree-create-preparation-pool.ts b/src/main/worktree-create-preparation-pool.ts index 7a040373c57..a72972c9d20 100644 --- a/src/main/worktree-create-preparation-pool.ts +++ b/src/main/worktree-create-preparation-pool.ts @@ -51,7 +51,18 @@ export type StartPreparationArgs = { options: AddWorktreeOptions } +export type DeferredPreparation = { + args: StartPreparationArgs + kind: 'explicit' | 'automatic' +} + const preparations = new Map() +export type PreparationClaim = { + entry: PreparationEntry + requestedKey: string + pendingPreparations: Map +} +const claims = new Set() /** One repo on one Git host: the scope a stranded discard is retried under. */ function preparationHostKey(repoPathKey: string, wslDistro: string): string { @@ -60,7 +71,7 @@ function preparationHostKey(repoPathKey: string, wslDistro: string): string { /** A prepared checkout is a create that is either in flight or imminent. */ export function hasPendingPreparations(): boolean { - return preparations.size > 0 || hasPendingStalePreparationCleanup() + return preparations.size > 0 || claims.size > 0 || hasPendingStalePreparationCleanup() } function pathOps(path: string): Pick { @@ -147,12 +158,83 @@ export function findPreparation( /** Removes an entry from the pool so no other create can claim it. Callers must run this in the * same synchronous turn as the selection that produced `entry`. */ -export function takePreparation(entry: PreparationEntry): void { +export function takePreparation( + entry: PreparationEntry, + requestedCanonicalBase = entry.canonicalBase +): PreparationClaim { preparations.delete(entry.key) clearTimeout(entry.expiration) + const requestedKey = preparationEntryKey( + entry.repoPathKey, + entry.workspaceRootKey, + requestedCanonicalBase, + entry.wslDistro + ) + const claim = { entry, requestedKey, pendingPreparations: new Map() } + claims.add(claim) + return claim } -export function startPreparation(args: StartPreparationArgs): Promise { +function matchingClaim(args: StartPreparationArgs): PreparationClaim | undefined { + const key = preparationEntryKey( + preparationPathKey(args.repoPath), + preparationPathKey(args.workspaceRoot), + args.canonicalBase, + args.options.wslDistro ?? '' + ) + return [...claims] + .toReversed() + .find((claim) => claim.entry.key === key || claim.requestedKey === key) +} + +/** Preserve one request per canonical key, with explicit prefetch taking precedence. */ +function deferPreparationForClaim( + args: StartPreparationArgs, + kind: DeferredPreparation['kind'] +): boolean { + const matching = matchingClaim(args) + if (!matching) { + return false + } + const key = preparationEntryKey( + preparationPathKey(args.repoPath), + preparationPathKey(args.workspaceRoot), + args.canonicalBase, + args.options.wslDistro ?? '' + ) + if (kind === 'explicit' || !matching.pendingPreparations.has(key)) { + matching.pendingPreparations.set(key, { args, kind }) + } + return true +} + +/** A second release is inert, including after a test reset. */ +export function releasePreparationClaim(claim: PreparationClaim): { + released: boolean + pendingPreparations: DeferredPreparation[] +} { + if (!claims.delete(claim)) { + return { released: false, pendingPreparations: [] } + } + return { released: true, pendingPreparations: [...claim.pendingPreparations.values()] } +} + +export function startPreparation( + args: StartPreparationArgs, + kind: DeferredPreparation['kind'] = 'explicit' +): Promise { + const existing = findPreparation( + preparationPathKey(args.repoPath), + preparationPathKey(args.workspaceRoot), + args.canonicalBase, + args.options.wslDistro ?? '' + ) + if (existing) { + return existing.ready + } + if (deferPreparationForClaim(args, kind)) { + return Promise.resolve() + } return worktreePreparationGit.run(() => startBackgroundPreparation(args)) } @@ -227,6 +309,7 @@ function startBackgroundPreparation({ export async function _resetPreparationPoolForTests(): Promise { const entries = [...preparations.values()] preparations.clear() + claims.clear() await resetStalePreparationCleanupForTests() await Promise.all( entries.map(async (entry) => { diff --git a/src/main/worktree-create-preparation-timing.test.ts b/src/main/worktree-create-preparation-timing.test.ts new file mode 100644 index 00000000000..bd78f506201 --- /dev/null +++ b/src/main/worktree-create-preparation-timing.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AddWorktreeResult } from './git/worktree' + +const mocks = vi.hoisted(() => ({ + mkdir: vi.fn(), + prepare: vi.fn(), + finalize: vi.fn(), + discard: vi.fn(), + listWorktreeGraph: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ mkdir: mocks.mkdir })) +vi.mock('./git/worktree', () => ({ listWorktreeGraph: mocks.listWorktreeGraph })) +vi.mock('./git/worktree-create-preparation', () => ({ + prepareWorktreeCreateCheckout: mocks.prepare, + finalizePreparedWorktree: mocks.finalize, + discardPreparedWorktree: mocks.discard, + unlockPreparedWorktree: vi.fn() +})) +vi.mock('./git/worktree-base-ref-probe', () => ({ resolveLocalWorktreeBaseRef: vi.fn() })) +vi.mock('./git/worktree-base-divergence', () => ({ measureRetargetDivergence: vi.fn() })) +vi.mock('./project-runtime-git-options', () => ({ + getLocalProjectWorktreeGitOptions: vi.fn(), + getWorktreeMirrorDistro: vi.fn() +})) +vi.mock('./ipc/worktree-logic', () => ({ + computeWorkspaceRootAsync: vi.fn(), + getWorktreePathSettings: vi.fn() +})) + +import { + _resetWorktreeCreatePreparationsForTests, + consumePreparedWorktreeCreate +} from './worktree-create-preparation' +import { startPreparation } from './worktree-create-preparation-pool' +import { createWorktreeCreateTimingRecorder } from './worktree-create-timing' + +const request = { + repoPath: '/repo', + workspaceRoot: '/workspace', + worktreePath: '/workspace/feature', + branch: 'feature', + baseBranch: 'origin/main' +} + +function prepare() { + return startPreparation({ + repoPath: request.repoPath, + workspaceRoot: request.workspaceRoot, + baseBranch: request.baseBranch, + canonicalBase: 'refs/remotes/origin/main', + options: {} + }) +} + +beforeEach(() => { + mocks.mkdir.mockReset().mockResolvedValue(undefined) + mocks.prepare.mockReset().mockResolvedValue(undefined) + mocks.finalize.mockReset().mockResolvedValue({}) + mocks.discard.mockReset().mockResolvedValue(undefined) + mocks.listWorktreeGraph.mockReset().mockResolvedValue([]) +}) + +afterEach(async () => { + await _resetWorktreeCreatePreparationsForTests() + vi.restoreAllMocks() +}) + +describe('prepared checkout create timing', () => { + it('separates the remaining preparation wait from finalization', async () => { + const checkoutStarted = Promise.withResolvers() + const checkout = Promise.withResolvers() + const finalizeStarted = Promise.withResolvers() + const finalize = Promise.withResolvers() + mocks.prepare.mockImplementation(() => { + checkoutStarted.resolve() + return checkout.promise + }) + mocks.finalize.mockImplementation(() => { + finalizeStarted.resolve() + return finalize.promise + }) + const preparation = prepare() + await checkoutStarted.promise + + let now = 40 + const timing = createWorktreeCreateTimingRecorder(() => now) + const create = timing.time('git_worktree_add', () => + consumePreparedWorktreeCreate({ ...request, timing }) + ) + now = 150 + checkout.resolve() + await finalizeStarted.promise + now = 180 + finalize.resolve({}) + + expect(await create).toMatchObject({ status: 'hit', retargeted: false }) + await preparation + expect(timing.finish()).toEqual({ + totalDurationMs: 140, + phases: [ + { phase: 'prepared_checkout_wait', startedAtMs: 0, durationMs: 110 }, + { phase: 'prepared_checkout_finalize', startedAtMs: 110, durationMs: 30 }, + { phase: 'git_worktree_add', startedAtMs: 0, durationMs: 140 } + ] + }) + }) + + it('records the wait when preparation fails and the create must fall back', async () => { + const checkoutStarted = Promise.withResolvers() + const checkout = Promise.withResolvers() + mocks.prepare.mockImplementation(() => { + checkoutStarted.resolve() + return checkout.promise + }) + const preparation = Promise.allSettled([prepare()]) + await checkoutStarted.promise + let now = 0 + const timing = createWorktreeCreateTimingRecorder(() => now) + const create = consumePreparedWorktreeCreate({ ...request, timing }) + now = 250 + checkout.reject(new Error('checkout failed')) + + expect(await create).toEqual({ + status: 'miss', + reason: 'prepare_failed', + rearm: expect.any(Function) + }) + await preparation + expect(timing.finish().phases).toEqual([ + { phase: 'prepared_checkout_wait', startedAtMs: 0, durationMs: 250 } + ]) + expect(mocks.finalize).not.toHaveBeenCalled() + }) + + it('records a failed finalization before its fallback cleanup', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + await prepare() + let now = 0 + const timing = createWorktreeCreateTimingRecorder(() => now) + mocks.finalize.mockImplementation(async () => { + now = 80 + throw new Error('move failed') + }) + mocks.discard.mockImplementation(async () => { + now = 100 + }) + + expect(await consumePreparedWorktreeCreate({ ...request, timing })).toEqual({ + status: 'miss', + reason: 'finalize_failed', + rearm: expect.any(Function) + }) + expect(timing.finish().phases).toEqual([ + { phase: 'prepared_checkout_wait', startedAtMs: 0, durationMs: 0 }, + { phase: 'prepared_checkout_finalize', startedAtMs: 0, durationMs: 80 } + ]) + }) + + it('does not report a preparation wait when no checkout was armed', async () => { + const timing = createWorktreeCreateTimingRecorder(() => 0) + expect(await consumePreparedWorktreeCreate({ ...request, timing })).toEqual({ + status: 'miss', + reason: 'none_armed' + }) + expect(timing.finish().phases).toEqual([]) + }) +}) diff --git a/src/main/worktree-create-preparation.test.ts b/src/main/worktree-create-preparation.test.ts index 9b3291bc463..e8c2bb97bce 100644 --- a/src/main/worktree-create-preparation.test.ts +++ b/src/main/worktree-create-preparation.test.ts @@ -555,7 +555,7 @@ describe('worktree create preparation registry', () => { branch: 'feature/test', baseBranch: 'origin/main' }) - ).resolves.toEqual({ status: 'miss', reason: 'finalize_failed' }) + ).resolves.toMatchObject({ status: 'miss', reason: 'finalize_failed' }) expect(mocks.mkdir).toHaveBeenCalledWith('/workspace', { recursive: true }) expect(mocks.discard).toHaveBeenCalledTimes(1) }) @@ -637,9 +637,7 @@ describe('worktree create preparation registry', () => { expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1) }) - // `startPreparation` overwrites the map entry outright, so a thunk that armed over a prefetch - // would leave that prefetch's locked checkout on disk with nothing holding a reference to it. - it('skips the deferred re-arm when a prefetch armed the same key mid-create', async () => { + it('starts one explicit prefetch after the create completes', async () => { await prepareWorktreeCreateForRepo(store, repo, 'origin/main') await consumeOnce('first') await prepareWorktreeCreateForRepo(store, repo, 'origin/main') @@ -655,6 +653,7 @@ describe('worktree create preparation registry', () => { // The user reopens the composer while the create is still finishing. await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + expect(mocks.prepareCheckout).toHaveBeenCalledTimes(2) mocks.prepareCheckout.mockClear() if (attempt.status === 'hit') { @@ -662,7 +661,7 @@ describe('worktree create preparation registry', () => { } await flushBackgroundWork() - expect(mocks.prepareCheckout).not.toHaveBeenCalled() + expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1) }) it('does not re-arm when finalization failed', async () => { diff --git a/src/main/worktree-create-preparation.ts b/src/main/worktree-create-preparation.ts index be7ea0d5a36..3ac022910eb 100644 --- a/src/main/worktree-create-preparation.ts +++ b/src/main/worktree-create-preparation.ts @@ -12,11 +12,13 @@ import { resolveLocalWorktreeBaseRef } from './git/worktree-base-ref-probe' import { preparationPathKey, selectPreparationForCreate } from './worktree-create-preparation-claim' import { _resetPreparationPoolForTests, - findPreparation, hasPendingPreparations, listPreparations, + releasePreparationClaim, startPreparation, takePreparation, + type DeferredPreparation, + type PreparationClaim, type PreparationEntry } from './worktree-create-preparation-pool' import { @@ -33,6 +35,7 @@ import { resetPreparationConsumeHistoryForTests } from './worktree-create-preparation-burst' import { toHostFilesystemPath } from './host-tree-removal' +import type { WorktreeCreateTimingRecorder } from './worktree-create-timing' export { WORKTREE_CREATE_PREPARATION_LIMIT, @@ -56,7 +59,7 @@ export type PreparedWorktreeCreateAttempt = /** Run after materialization/startup completes, before returning the create result. */ rearm: () => void } - | { status: 'miss'; reason: PreparedCheckoutMissReason } + | { status: 'miss'; reason: PreparedCheckoutMissReason; rearm?: () => void } type ConsumePreparedWorktreeArgs = { repoPath: string @@ -66,6 +69,7 @@ type ConsumePreparedWorktreeArgs = { baseBranch: string refreshLocalBaseRef?: boolean options?: AddWorktreeOptions + timing?: Pick } function canonicalBaseRef( @@ -107,16 +111,6 @@ async function prepareWorktreeCreateInBackground( getWorktreePathSettings(repo, store.getSettings(), getWorktreeMirrorDistro(store, repo)) ) const canonicalBase = await canonicalBaseRef(repo.path, baseBranch, options) - const existing = findPreparation( - preparationPathKey(repo.path), - preparationPathKey(workspaceRoot), - canonicalBase, - options.wslDistro ?? '' - ) - if (existing) { - return existing.ready - } - return startPreparation({ repoPath: repo.path, workspaceRoot, @@ -127,8 +121,14 @@ async function prepareWorktreeCreateInBackground( } type ClaimedPreparation = - | { status: 'claimed'; entry: PreparationEntry; retargeted: boolean; canonicalBase: string } - | { status: 'miss'; reason: PreparedCheckoutMissReason } + | { + status: 'claimed' + entry: PreparationEntry + reservation: PreparationClaim + retargeted: boolean + canonicalBase: string + } + | { status: 'miss'; reason: PreparedCheckoutMissReason; rearm?: () => void } async function claimPreparedWorktree( args: ConsumePreparedWorktreeArgs, @@ -187,17 +187,34 @@ async function claimPreparedWorktree( } } const entry = selection.candidate - takePreparation(entry) + const reservation = takePreparation(entry, selection.canonicalBase) try { - await entry.ready + await (args.timing + ? args.timing.time('prepared_checkout_wait', () => entry.ready) + : entry.ready) return { status: 'claimed', entry, + reservation, retargeted: selection.kind === 'retarget', canonicalBase: selection.canonicalBase } } catch { - return { status: 'miss', reason: 'prepare_failed' } + return { status: 'miss', reason: 'prepare_failed', rearm: releaseClaimAfterCreate(reservation) } + } +} + +function startDeferredPreparation(preparation: DeferredPreparation): void { + void startPreparation(preparation.args, preparation.kind).catch(() => { + // A later create still has the normal add path if speculative preparation fails. + }) +} + +function releaseClaimAfterCreate(reservation: PreparationClaim): () => void { + return () => { + for (const preparation of releasePreparationClaim(reservation).pendingPreparations) { + startDeferredPreparation(preparation) + } } } @@ -209,37 +226,38 @@ async function claimPreparedWorktree( * Returns a thunk rather than launching: the replacement is a full `reset --hard`, which on a * large repo holds a general admission slot for tens of seconds. Started mid-create it competes * with the create's own git, so the caller runs it after materialization/startup completes. The burst - * bookkeeping still happens here — a prefetch that re-armed this key while we finalized would - * otherwise swallow the consume, and the next create would look isolated when it is really the - * middle of a burst. */ + * bookkeeping still happens here so a later create is recognized as part of a burst. An explicit + * prefetch during this create takes precedence over the burst replacement at release. */ function deferRearmPreparation( entry: PreparationEntry, + reservation: PreparationClaim, baseBranch: string, canonicalBase: string ): () => void { const continuesBurst = recordPreparationConsume(entry.key) - const alreadyArmed = (): boolean => - findPreparation(entry.repoPathKey, entry.workspaceRootKey, canonicalBase, entry.wslDistro) !== - undefined - if (!continuesBurst || alreadyArmed()) { - return () => {} - } return () => { - // Re-checked here, not only at consume time: `startPreparation` overwrites the map entry - // outright, so arming over a prefetch that landed during the create would strand its - // checkout on disk with no owner to discard it. - if (alreadyArmed()) { + const { released, pendingPreparations } = releasePreparationClaim(reservation) + if (!released) { return } - void startPreparation({ - repoPath: entry.repoPath, - workspaceRoot: entry.workspaceRoot, - baseBranch, - canonicalBase, - options: entry.options - }).catch(() => { - // Why: a warm-up failure is recovered by the normal add on the next create. - }) + const requestedBaseArmed = pendingPreparations.some( + (preparation) => preparation.args.canonicalBase === canonicalBase + ) + for (const preparation of pendingPreparations) { + startDeferredPreparation(preparation) + } + if (continuesBurst && !requestedBaseArmed) { + startDeferredPreparation({ + args: { + repoPath: entry.repoPath, + workspaceRoot: entry.workspaceRoot, + baseBranch, + canonicalBase, + options: entry.options + }, + kind: 'automatic' + }) + } } } @@ -249,9 +267,9 @@ export async function consumePreparedWorktreeCreate( const options = args.options ?? {} const claim = await claimPreparedWorktree(args, options) if (claim.status === 'miss') { - return { status: 'miss', reason: claim.reason } + return { status: 'miss', reason: claim.reason, ...(claim.rearm ? { rearm: claim.rearm } : {}) } } - const { entry } = claim + const { entry, reservation } = claim try { const parentDir = isWindowsAbsolutePathLike(args.worktreePath) ? win32.dirname(args.worktreePath) @@ -259,18 +277,22 @@ export async function consumePreparedWorktreeCreate( await mkdir(toHostFilesystemPath(parentDir), { recursive: true }) // Finalize resolves the requested base itself and resets the prepared checkout onto that // commit, so a retargeted claim is handed over at the requested commit or not at all. - const result = await finalizePreparedWorktree( - args.repoPath, - entry.preparedPath, - args.worktreePath, - args.branch, - args.baseBranch, - args.refreshLocalBaseRef, - options - ) + const finalize = (): Promise => + finalizePreparedWorktree( + args.repoPath, + entry.preparedPath, + args.worktreePath, + args.branch, + args.baseBranch, + args.refreshLocalBaseRef, + options + ) + const result = args.timing + ? await args.timing.time('prepared_checkout_finalize', finalize) + : await finalize() // Consuming the only prepared checkout leaves the next create cold. Re-arm for a user who is // creating in a burst; the TTL and the preparation limit still bound an unused replacement. - const rearm = deferRearmPreparation(entry, args.baseBranch, claim.canonicalBase) + const rearm = deferRearmPreparation(entry, reservation, args.baseBranch, claim.canonicalBase) return { status: 'hit', retargeted: claim.retargeted, result, rearm } } catch (error) { await discardPreparedWorktree(args.repoPath, entry.preparedPath, options).catch(() => {}) @@ -278,7 +300,11 @@ export async function consumePreparedWorktreeCreate( '[worktree-create] prepared checkout could not be finalized; using normal add', error ) - return { status: 'miss', reason: 'finalize_failed' } + return { + status: 'miss', + reason: 'finalize_failed', + rearm: releaseClaimAfterCreate(reservation) + } } } diff --git a/src/main/worktree-removal-close-records.test.ts b/src/main/worktree-removal-close-records.test.ts new file mode 100644 index 00000000000..9d2d2e32e71 --- /dev/null +++ b/src/main/worktree-removal-close-records.test.ts @@ -0,0 +1,225 @@ +import { closeTestStores, createSqliteTestStore } from './persistence-test-harness' +// Why this file exists: removing a workspace's session rows must take its close records with it, +// or they hold cap slots until the TTL and read as "emptied on purpose" for a new workspace at the +// same path. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { rmSync, mkdtempSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { getDefaultWorkspaceSession } from '../shared/constants' +import { toSshExecutionHostId } from '../shared/execution-host' +import { folderWorkspaceKey } from '../shared/workspace-scope' +import { + MAX_CLOSED_TERMINAL_TAB_TOMBSTONES, + recordClosedTerminalTabTombstone, + type ClosedTerminalTabTombstonesByTabId +} from '../shared/closed-terminal-tab-tombstones' + +const testState = { dir: '' } + +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.dir + }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (plaintext: string) => Buffer.from(plaintext, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8') + } +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn().mockReturnValue({}) })) + +async function createStore() { + vi.resetModules() + const { Store, initDataPath } = await import('./persistence') + initDataPath() + return createSqliteTestStore(Store, { dataFile: join(testState.dir, 'orca-data.json') }) +} + +const REMOVED = 'repo-a::/workspace/removed' +const KEPT = 'repo-a::/workspace/kept' +const OTHER_REPO = 'repo-b::/workspace/other' +const SSH_HOST = toSshExecutionHostId('target-1') + +function records( + entries: [tabId: string, worktreeId: string][], + now = Date.now() +): ClosedTerminalTabTombstonesByTabId { + return Object.fromEntries( + entries.map(([tabId, worktreeId]) => [ + tabId, + { closedAt: now, worktreeId, reason: 'user' as const } + ]) + ) +} + +function sessionWithRecords(map: ClosedTerminalTabTombstonesByTabId) { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [REMOVED]: [], [KEPT]: [] }, + closedTerminalTabTombstonesByTabId: map + } +} + +describe('close records on workspace removal', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) + }) + + afterEach(async () => { + await closeTestStores() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('drops only the removed worktree’s records', async () => { + const store = await createStore() + store.setWorktreeMeta(REMOVED, {}) + store.setWorkspaceSession( + sessionWithRecords( + records([ + ['closed-removed', REMOVED], + ['closed-kept', KEPT] + ]) + ) + ) + + store.removeWorktreeMeta(REMOVED) + + expect( + Object.keys(store.getWorkspaceSession().closedTerminalTabTombstonesByTabId ?? {}) + ).toEqual(['closed-kept']) + }) + + it('drops the records in the SSH host’s partition on a remote removal', async () => { + const store = await createStore() + store.setWorktreeMeta(REMOVED, { hostId: SSH_HOST }) + store.setWorkspaceSession( + sessionWithRecords( + records([ + ['closed-removed', REMOVED], + ['closed-kept', KEPT] + ]) + ), + SSH_HOST + ) + + store.removeWorktreeMeta(REMOVED, SSH_HOST) + + expect( + Object.keys(store.getWorkspaceSession(SSH_HOST).closedTerminalTabTombstonesByTabId ?? {}) + ).toEqual(['closed-kept']) + }) + + // Why: repo ids and paths repeat across hosts; the local owner of the same id is still live. + it('keeps a same-id worktree’s records on another host when one host’s copy is removed', async () => { + const store = await createStore() + const repo = { id: 'repo-a', displayName: 'A', badgeColor: 'gray', addedAt: 1 } + store.addRepo({ ...repo, path: '/workspace/repo-a' }) + store.addRepo({ + ...repo, + path: '/remote/repo-a', + connectionId: 'target-1', + executionHostId: SSH_HOST + }) + store.setWorktreeMeta(REMOVED, { hostId: SSH_HOST }) + store.setWorkspaceSession(sessionWithRecords(records([['local-tab', REMOVED]]))) + store.setWorkspaceSession(sessionWithRecords(records([['ssh-tab', REMOVED]])), SSH_HOST) + + store.removeWorktreeMeta(REMOVED, SSH_HOST) + + expect( + Object.keys(store.getWorkspaceSession().closedTerminalTabTombstonesByTabId ?? {}) + ).toEqual(['local-tab']) + expect(store.getWorkspaceSession(SSH_HOST).closedTerminalTabTombstonesByTabId).toEqual({}) + }) + + it('drops a removed folder workspace’s records from every partition', async () => { + const store = await createStore() + const group = store.createProjectGroup({ + name: 'Platform', + parentPath: '/workspace/platform', + createdFrom: 'folder-scan' + }) + const folder = store.createFolderWorkspace({ projectGroupId: group.id, name: 'Scratch' }) + const folderKey = folderWorkspaceKey(folder.id) + store.setWorkspaceSession( + sessionWithRecords( + records([ + ['closed-folder', folderKey], + ['closed-kept', KEPT] + ]) + ) + ) + store.setWorkspaceSession(sessionWithRecords(records([['ssh-folder', folderKey]])), SSH_HOST) + + expect(store.removeFolderWorkspace(folder.id)).toBe(true) + + expect( + Object.keys(store.getWorkspaceSession().closedTerminalTabTombstonesByTabId ?? {}) + ).toEqual(['closed-kept']) + expect(store.getWorkspaceSession(SSH_HOST).closedTerminalTabTombstonesByTabId).toEqual({}) + }) + + // Why the removed worktree has no meta here: records alone must still mark it as the repo's. + it('drops every worktree’s records when its project is removed, and keeps other projects’', async () => { + const store = await createStore() + store.addRepo({ + id: 'repo-a', + path: '/workspace/repo-a', + displayName: 'A', + badgeColor: 'gray', + addedAt: 1 + }) + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + closedTerminalTabTombstonesByTabId: records([ + ['closed-a', REMOVED], + ['closed-a2', KEPT], + ['closed-b', OTHER_REPO] + ]) + }) + + store.removeProject('repo-a') + + expect( + Object.keys(store.getWorkspaceSession().closedTerminalTabTombstonesByTabId ?? {}) + ).toEqual(['closed-b']) + }) + + // Why: dead records used to hold cap slots until their TTL, so fresh closes evicted a live one. + it('frees the removed worktree’s cap slots, so later closes keep a live worktree’s record', async () => { + const store = await createStore() + const now = Date.now() + const dead = Array.from( + { length: MAX_CLOSED_TERMINAL_TAB_TOMBSTONES - 1 }, + (_, index): [string, string] => [`dead-${index}`, REMOVED] + ) + store.setWorktreeMeta(REMOVED, {}) + store.setWorkspaceSession( + sessionWithRecords({ + ...records(dead, now), + ...records([['live-kept', KEPT]], now - 60_000) + }) + ) + + store.removeWorktreeMeta(REMOVED) + let map = store.getWorkspaceSession().closedTerminalTabTombstonesByTabId + for (let index = 0; index < 2; index += 1) { + map = recordClosedTerminalTabTombstone( + map, + `fresh-${index}`, + { worktreeId: KEPT }, + now + index + ) + } + + expect(map?.['live-kept']).toBeDefined() + }) +}) diff --git a/src/main/worktree-removal-safety.test.ts b/src/main/worktree-removal-safety.test.ts index 9aa9775de44..d32289a5fc7 100644 --- a/src/main/worktree-removal-safety.test.ts +++ b/src/main/worktree-removal-safety.test.ts @@ -6,7 +6,8 @@ import { canSafelyRemoveOrphanedWorktreeDirectory, findRegisteredDeletableWorktree, getRegisteredDeletableWorktree, - isDangerousWorktreeRemovalPath + isDangerousWorktreeRemovalPath, + isWorktreePathMissing } from './worktree-removal-safety' import { CLIENT_REMOVAL_HOME, executionHostRemovalHome } from './worktree-removal-home-guard' @@ -349,6 +350,16 @@ describe('canSafelyRemoveOrphanedWorktreeDirectory', () => { }) }) +describe('isWorktreePathMissing', () => { + it('does not treat an unreadable path as absent', async () => { + await expect( + isWorktreePathMissing('/workspaces/locked', async () => { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }) + }) + ).resolves.toBe(false) + }) +}) + describe('canCleanupUnregisteredOrcaLeftoverDirectory', () => { const repo = { path: '/repos/main' } const ownedMeta = { orcaCreatedAt: 1, orcaCreationSource: 'runtime' as const } diff --git a/src/main/worktree-removal-session-partition-fencing.test.ts b/src/main/worktree-removal-session-partition-fencing.test.ts index 89b7feafee6..03dc50a3dd5 100644 --- a/src/main/worktree-removal-session-partition-fencing.test.ts +++ b/src/main/worktree-removal-session-partition-fencing.test.ts @@ -1,3 +1,4 @@ +import { closeTestStores, createSqliteTestStore } from './persistence-test-harness' // Why this file exists: worktree removal writes to host session partitions, and the two hazards below // are only visible across a removal followed by a renderer session write — persistence.test.ts covers // removal and partitioning separately, so neither suite catches the interaction. @@ -34,7 +35,7 @@ async function createStore() { vi.resetModules() const { Store, initDataPath } = await import('./persistence') initDataPath() - return new Store() + return createSqliteTestStore(Store, { dataFile: join(testState.dir, 'orca-data.json') }) } const makeTerminalTab = (overrides: Partial = {}): TerminalTab => ({ @@ -58,7 +59,8 @@ describe('worktree removal across host session partitions', () => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) }) - afterEach(() => { + afterEach(async () => { + await closeTestStores() rmSync(testState.dir, { recursive: true, force: true }) }) diff --git a/src/main/wsl-home-cache.ts b/src/main/wsl-home-cache.ts index 368bf163102..c833b14bdae 100644 --- a/src/main/wsl-home-cache.ts +++ b/src/main/wsl-home-cache.ts @@ -1,19 +1,26 @@ const MAX_WSL_HOME_CACHE_ENTRIES = 64 const wslHomeCache = new Map() +// Why: WSL distro names are case-insensitive, and paths and callers spell them differently. +function cacheKey(distro: string): string { + return distro.toLowerCase() +} + export function getCachedWslHome(distro: string): string | undefined { - const home = wslHomeCache.get(distro) + const key = cacheKey(distro) + const home = wslHomeCache.get(key) if (home === undefined) { return undefined } - wslHomeCache.delete(distro) - wslHomeCache.set(distro, home) + wslHomeCache.delete(key) + wslHomeCache.set(key, home) return home } export function rememberWslHome(distro: string, home: string): string { - wslHomeCache.delete(distro) - wslHomeCache.set(distro, home) + const key = cacheKey(distro) + wslHomeCache.delete(key) + wslHomeCache.set(key, home) while (wslHomeCache.size > MAX_WSL_HOME_CACHE_ENTRIES) { const oldest = wslHomeCache.keys().next().value if (oldest === undefined) { @@ -25,7 +32,7 @@ export function rememberWslHome(distro: string, home: string): string { } export function hasCachedWslHome(distro: string): boolean { - return wslHomeCache.has(distro) + return wslHomeCache.has(cacheKey(distro)) } export function clearWslHomeCache(): void { diff --git a/src/main/wsl-managed-cli-path-restore.test.ts b/src/main/wsl-managed-cli-path-restore.test.ts new file mode 100644 index 00000000000..01d80390099 --- /dev/null +++ b/src/main/wsl-managed-cli-path-restore.test.ts @@ -0,0 +1,59 @@ +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { runProcessSync } from '../shared/child-process/run-process' +import { WSL_MANAGED_CLI_PATH_RESTORE } from './wsl-managed-cli-path-restore' + +const posix = process.platform !== 'win32' +const hasZsh = posix && runProcessSync({ program: 'sh', args: ['-c', 'command -v zsh'] }).code === 0 +const root = posix ? mkdtempSync(join(tmpdir(), 'orca managed cli restore ')) : '' +if (posix) { + writeFileSync(join(root, 'orca-dev'), '#!/bin/sh\n') + chmodSync(join(root, 'orca-dev'), 0o755) + writeFileSync(join(root, 'orca-ide'), '#!/bin/sh\n') + chmodSync(join(root, 'orca-ide'), 0o644) +} +afterAll(() => { + if (root) { + rmSync(root, { recursive: true, force: true }) + } +}) + +// Mirrors how each shell embeds the snippet: bash rcfile top level, zsh first-prompt hook. +const SHELLS = [ + { name: 'bash', enabled: posix, args: (body: string) => ['--norc', '--noprofile', '-uc', body] }, + { + name: 'zsh', + enabled: hasZsh, + args: (body: string) => ['-fuc', `__orca_hook() {\n emulate -L zsh\n${body}\n}\n__orca_hook`] + } +] as const + +function run( + shell: (typeof SHELLS)[number], + env: Record +): { code: number | null; stdout: string; stderr: string } { + return runProcessSync({ + program: shell.name, + args: shell.args(`${WSL_MANAGED_CLI_PATH_RESTORE}\nprintf '%s' "$PATH"`), + env: { PATH: '/usr/bin:/bin', ...env } + }) +} + +describe.each(SHELLS)('WSL_MANAGED_CLI_PATH_RESTORE in $name', (shell) => { + it.skipIf(!shell.enabled)('leads PATH with a directory holding an executable CLI', () => { + const result = run(shell, { ORCA_WSL_CLI_DIR: root, ORCA_CLI_COMMAND: 'orca-dev' }) + expect(result).toMatchObject({ code: 0, stdout: `${root}:/usr/bin:/bin`, stderr: '' }) + }) + + it.skipIf(!shell.enabled)('warns and keeps PATH when the CLI cannot run', () => { + const result = run(shell, { ORCA_WSL_CLI_DIR: root, ORCA_CLI_COMMAND: 'orca-ide' }) + expect(result).toMatchObject({ code: 0, stdout: '/usr/bin:/bin' }) + expect(result.stderr).toContain('Orca CLI unavailable') + }) + + it.skipIf(!shell.enabled)('does nothing without a managed directory', () => { + expect(run(shell, {})).toMatchObject({ code: 0, stdout: '/usr/bin:/bin', stderr: '' }) + }) +}) diff --git a/src/main/wsl-managed-cli-path-restore.ts b/src/main/wsl-managed-cli-path-restore.ts new file mode 100644 index 00000000000..d3091433a71 --- /dev/null +++ b/src/main/wsl-managed-cli-path-restore.ts @@ -0,0 +1,8 @@ +/** Leads PATH with the managed WSL CLI after startup files ran; an unusable CLI only warns. */ +export const WSL_MANAGED_CLI_PATH_RESTORE = `if [ -n "\${ORCA_WSL_CLI_DIR:-}" ]; then + if [ -x "$ORCA_WSL_CLI_DIR/\${ORCA_CLI_COMMAND:-}" ]; then + export PATH="$ORCA_WSL_CLI_DIR\${PATH:+:$PATH}" + else + printf 'Orca CLI unavailable: cannot run %s. Check WSL Windows-drive mount options.\\n' "$ORCA_WSL_CLI_DIR/\${ORCA_CLI_COMMAND:-}" >&2 + fi +fi` diff --git a/src/main/wsl-running-distro-cache.ts b/src/main/wsl-running-distro-cache.ts index 436fbe419cb..07a55a47bcd 100644 --- a/src/main/wsl-running-distro-cache.ts +++ b/src/main/wsl-running-distro-cache.ts @@ -8,7 +8,8 @@ import { wslDistroListRetryDelayMs } from './wsl-distro-retry' let cache: string[] | null = null let retryAfterMs = 0 let failureStreak = 0 -let inFlightProbe: Promise | null = null +type RunningDistroObservation = { distros: string[]; confirmed: boolean } +let inFlightProbe: Promise | null = null function armRetryAfterFailure(): void { const now = Date.now() @@ -28,24 +29,37 @@ function armRetryAfterFailure(): void { * back to the cache and arms backoff. Bounds wsl.exe spawns under both IPC fan-out and a * broken/degraded host. */ -export function resolveRunningWslDistros(probe: () => Promise): Promise { +export async function resolveRunningWslDistros( + probe: () => Promise, + options: { requireConfirmed?: boolean } = {} +): Promise { + const observation = await observeRunningWslDistros(probe) + if (options.requireConfirmed && !observation.confirmed) { + throw new Error('WSL running-distro discovery is unavailable. Retry when WSL is reachable.') + } + return observation.distros +} + +function observeRunningWslDistros( + probe: () => Promise +): Promise { if (inFlightProbe) { return inFlightProbe } if (Date.now() < retryAfterMs) { - return Promise.resolve(cache ?? []) + return Promise.resolve({ distros: cache ?? [], confirmed: false }) } const result = probe() .then((distros) => { cache = distros retryAfterMs = 0 failureStreak = 0 - return cache + return { distros, confirmed: true } }) .catch((error: unknown) => { armRetryAfterFailure() console.warn('[wsl] running-distro probe failed; falling back to last-known-good list', error) - return cache ?? [] + return { distros: cache ?? [], confirmed: false } }) .finally(() => { if (inFlightProbe === result) { diff --git a/src/main/wsl-running-distros.test.ts b/src/main/wsl-running-distros.test.ts index 049c2f2e379..4f80e5eb5c2 100644 --- a/src/main/wsl-running-distros.test.ts +++ b/src/main/wsl-running-distros.test.ts @@ -67,6 +67,65 @@ describe('running WSL distro discovery', () => { }) }) + it('rejects stale running state for launch admission through failure and backoff', async () => { + vi.useFakeTimers() + execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { + callback(null, 'Ubuntu\n') + }) + + await withPlatform('win32', async () => { + await expect(listRunningWslDistrosAsync()).resolves.toEqual(['Ubuntu']) + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callback(new Error('wsl unavailable'), '') + }) + const paths = ['\\\\wsl.localhost\\Ubuntu\\home\\ada'] + await expect( + filterPathsToRunningWslDistrosAsync(paths, { requireConfirmed: true }) + ).rejects.toThrow('discovery is unavailable') + await expect(listRunningWslDistrosAsync()).resolves.toEqual(['Ubuntu']) + await expect(listRunningWslDistrosAsync({ requireConfirmed: true })).rejects.toThrow( + 'discovery is unavailable' + ) + expect(execFileMock).toHaveBeenCalledTimes(2) + + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callback(null, '') + }) + await vi.advanceTimersByTimeAsync(15_001) + await expect( + filterPathsToRunningWslDistrosAsync(paths, { requireConfirmed: true }) + ).resolves.toEqual([]) + expect(execFileMock).toHaveBeenCalledTimes(3) + }) + }) + + it.each([true, false])( + 'shares the probe while preserving admission and observer failure policies (strict first: %s)', + async (strictFirst) => { + execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { + callback(null, 'Ubuntu\n') + }) + + await withPlatform('win32', async () => { + await listRunningWslDistrosAsync() + let finishProbe: ((error: Error | null, output: string) => void) | undefined + execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { + finishProbe = callback + }) + const first = listRunningWslDistrosAsync({ requireConfirmed: strictFirst }) + const second = listRunningWslDistrosAsync({ requireConfirmed: !strictFirst }) + const strict = strictFirst ? first : second + const observer = strictFirst ? second : first + const rejected = expect(strict).rejects.toThrow('discovery is unavailable') + expect(execFileMock).toHaveBeenCalledTimes(2) + finishProbe?.(new Error('wsl unavailable'), '') + await rejected + await expect(observer).resolves.toEqual(['Ubuntu']) + expect(execFileMock).toHaveBeenCalledTimes(2) + }) + } + ) + it('resolves homes only for the running distro set', async () => { execFileMock.mockImplementation((_command, args, _options, callback) => { callback(null, args.includes('--running') ? 'Ubuntu\n' : '/home/ada\n') @@ -139,6 +198,21 @@ describe('running WSL distro discovery', () => { }) }) + it('shares a confirmed fresh probe between discovery and launch admission', async () => { + execFileMock.mockImplementationOnce((_command, _args, _options, callback) => + callback(null, 'Ubuntu\n') + ) + await withPlatform('win32', async () => { + expect( + await Promise.all([ + listRunningWslDistrosAsync(), + listRunningWslDistrosAsync({ requireConfirmed: true }) + ]) + ).toEqual([['Ubuntu'], ['Ubuntu']]) + expect(execFileMock).toHaveBeenCalledOnce() + }) + }) + it('filters stopped-distro UNC paths while preserving host paths', async () => { execFileMock.mockImplementation((_command, _args, _options, callback) => { callback(null, 'Ubuntu\n') diff --git a/src/main/wsl-running-path-filter.ts b/src/main/wsl-running-path-filter.ts index 7384229757f..370e5b4783c 100644 --- a/src/main/wsl-running-path-filter.ts +++ b/src/main/wsl-running-path-filter.ts @@ -14,11 +14,12 @@ export function filterPathsToWslDistros( /** Keep host paths and WSL paths whose distro is running now. */ export async function filterPathsToRunningWslDistrosAsync( - paths: readonly string[] + paths: readonly string[], + options?: { requireConfirmed?: boolean } ): Promise { if (process.platform !== 'win32') { return [...paths] } - const runningDistros = paths.some(isWslUncPath) ? await listRunningWslDistrosAsync() : [] + const runningDistros = paths.some(isWslUncPath) ? await listRunningWslDistrosAsync(options) : [] return filterPathsToWslDistros(paths, runningDistros) } diff --git a/src/main/wsl.ts b/src/main/wsl.ts index be69dde401a..007b2f1c8bc 100644 --- a/src/main/wsl.ts +++ b/src/main/wsl.ts @@ -230,15 +230,19 @@ export async function listWslDistrosAsync(): Promise { /** Running user distros only — see `resolveRunningWslDistros` for the fallback/backoff and * single-flight contract shared by every caller. */ -export async function listRunningWslDistrosAsync(): Promise { +export async function listRunningWslDistrosAsync( + options: { requireConfirmed?: boolean } = {} +): Promise { if (process.platform !== 'win32') { return [] } - return resolveRunningWslDistros(() => - execFileUtf8('wsl.exe', ['--list', '--running', '--quiet'], { - ...process.env, - WSL_UTF8: '1' - }).then((output) => filterUserWslDistros(parseWslDistros(output))) + return resolveRunningWslDistros( + () => + execFileUtf8('wsl.exe', ['--list', '--running', '--quiet'], { + ...process.env, + WSL_UTF8: '1' + }).then((output) => filterUserWslDistros(parseWslDistros(output))), + options ) } diff --git a/src/main/zsh-startup-wrapper-builder.ts b/src/main/zsh-startup-wrapper-builder.ts index 27a1e0b7615..12a92b6bb45 100644 --- a/src/main/zsh-startup-wrapper-builder.ts +++ b/src/main/zsh-startup-wrapper-builder.ts @@ -27,6 +27,7 @@ * hook restores zsh option semantics for the body at call time. */ import { getPosixOmpShellWrapper } from './pty/omp-shell-wrapper' +import { WSL_MANAGED_CLI_PATH_RESTORE } from './wsl-managed-cli-path-restore' import { getPosixCodexShellLaunchPreflight } from './pty/codex-shell-launch-preflight' import { getZshShellReadyMarkerRegistrationBlock, @@ -38,14 +39,14 @@ import { /** Runtime values the hook re-exports after the user's own startup files ran. */ export type ZshWrapperRestoreSpec = { + /** The managed WSL CLI dir onto PATH — local wrappers only; a no-op outside WSL. */ + managedWslCli: boolean /** Orca's agent-teams shim dir back onto PATH. */ agentTeamsPath: boolean /** Remote CLI bin dir onto PATH — relay hosts only. */ remoteCliBinDir: boolean /** Orca's runtime CODEX_HOME. */ codexHome: boolean - /** The `codex()` wrapper that runs Orca's launch preflight. */ - codexLaunchPreflight: boolean } export type ZshStartupHookSpec = { @@ -124,8 +125,7 @@ function getOverlayRestoreBlocks(spec: ZshStartupHookSpec): (string | null)[] { MIMOCODE_HOME_RESTORE, spec.restores.remoteCliBinDir ? REMOTE_CLI_BIN_DIR_RESTORE : null, getPosixOmpShellWrapper(), - spec.restores.codexHome ? CODEX_HOME_RESTORE : null, - spec.restores.codexLaunchPreflight ? getPosixCodexShellLaunchPreflight() : null + spec.restores.codexHome ? CODEX_HOME_RESTORE : null ] } @@ -170,7 +170,10 @@ ${indentBlock(getZshShellReadyMarkerRegistrationBlock(spec.readyMarkerEscaped, t builtin typeset -g precmd_functions ${permanentPrecmd} ${joinBlocks([ + spec.restores.managedWslCli ? indentBlock(WSL_MANAGED_CLI_PATH_RESTORE, ' ') : null, featureGuard('overlay', getOverlayRestoreBlocks(spec)), + // Why outside the overlay guard: a system-default Codex home carries no overlay key. + indentBlock(getPosixCodexShellLaunchPreflight(), ' ').replace(/\n$/, ''), // Why no /etc/zshrc repair branch: ZDOTDIR was handed back before that file // ran, so the value it derives is the user's own path. #11044 is unreachable. ` if [[ -n "\${_orca_histfile:-}" ]]; then diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 26527b9a1e5..220b90c18a6 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2,12 +2,13 @@ import type { ClaudeAccountsApi, CodexAccountsApi, CodexConfigSyncApi, + CursorAccountsApi, GrokAccountsApi, MinimaxCredentialsApi } from './api/agent-account-api' import type { HooksApi } from './api/agent-hook-api' import type { SkillsApi } from './api/agent-skill-api' -import type { AgentAwakeApi, AgentStatusApi, AgentTrustApi } from './api/agent-status-api' +import type { AgentAwakeApi, AgentStatusApi } from './api/agent-status-api' import type { ClaudeUsageApi, CodexUsageApi, @@ -105,7 +106,6 @@ export type PreloadApi = { claudeAccounts: ClaudeAccountsApi cli: CliApi codexConfigSync: CodexConfigSyncApi - agentTrust: AgentTrustApi preflight: PreflightApi notifications: NotificationsApi onboarding: OnboardingApi @@ -143,6 +143,7 @@ export type PreloadApi = { rateLimits: RateLimitsApi minimaxCredentials: MinimaxCredentialsApi grokAccounts: GrokAccountsApi + cursorAccounts: CursorAccountsApi ssh: SshApi automations: AutomationsApi wsl: RuntimeApi['wsl'] diff --git a/src/preload/api/agent-account-api.ts b/src/preload/api/agent-account-api.ts index f041e41f8b7..e8d574c285b 100644 --- a/src/preload/api/agent-account-api.ts +++ b/src/preload/api/agent-account-api.ts @@ -3,7 +3,7 @@ import type { CodexRateLimitAccountsState } from '../../shared/managed-account-types' import type { CodexConfigSyncStatus } from '../../shared/codex-config-sync-types' -import type { GrokAccountStatus } from '../../shared/rate-limit-types' +import type { CursorAccountStatus, GrokAccountStatus } from '../../shared/rate-limit-types' export type CodexAccountsApi = { list: () => Promise @@ -62,6 +62,10 @@ export type GrokAccountsApi = { getStatus: () => Promise } +export type CursorAccountsApi = { + getStatus: () => Promise +} + export type MinimaxCredentialsApi = { // Why: cookie + API key each live in their own safeStorage file, so the // status separates them. 'configured' stays as the OR so existing callers diff --git a/src/preload/api/agent-status-api.ts b/src/preload/api/agent-status-api.ts index 7c538a990f1..909f0fd20fb 100644 --- a/src/preload/api/agent-status-api.ts +++ b/src/preload/api/agent-status-api.ts @@ -48,14 +48,6 @@ export type AgentStatusApi = { transferPaneAuthority: (args: { fromPaneKey: string; toPaneKey: string; ptyId?: string }) => void } -export type AgentTrustApi = { - markTrusted: (args: { - preset: 'cursor' | 'copilot' | 'codex' | 'antigravity' - workspacePath: string - connectionId?: string - }) => Promise -} - export type AgentAwakeApi = { getStatus: () => Promise onChanged: (callback: (status: ComputerAwakeStatus) => void) => () => void diff --git a/src/preload/api/agent-trust-bridge.ts b/src/preload/api/agent-trust-bridge.ts deleted file mode 100644 index a9afa9c363d..00000000000 --- a/src/preload/api/agent-trust-bridge.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ipcRenderer } from 'electron' -import type { PreloadApi } from '../api-types' - -export const agentTrustApi = { - markTrusted: (args: { - preset: 'cursor' | 'copilot' | 'codex' | 'antigravity' - workspacePath: string - connectionId?: string - }): Promise => ipcRenderer.invoke('agentTrust:markTrusted', args) -} satisfies PreloadApi['agentTrust'] diff --git a/src/preload/api/cursor-accounts-bridge.ts b/src/preload/api/cursor-accounts-bridge.ts new file mode 100644 index 00000000000..20f8d5d6a00 --- /dev/null +++ b/src/preload/api/cursor-accounts-bridge.ts @@ -0,0 +1,7 @@ +import { ipcRenderer } from 'electron' +import type { CursorAccountStatus } from '../../shared/rate-limit-types' +import type { PreloadApi } from '../api-types' + +export const cursorAccountsApi = { + getStatus: (): Promise => ipcRenderer.invoke('cursorAccounts:getStatus') +} satisfies PreloadApi['cursorAccounts'] diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index 2a8f0612905..d8b74cd9304 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -170,6 +170,8 @@ export type FilesystemApi = { listPythonEnvironments: (args: { filePath: string rootPath: string | null + /** False until the notebook is trusted: workspace envs are then listed without running them. */ + runWorkspaceInterpreters: boolean }) => Promise describePython: (args: { path: string }) => Promise startKernel: (args: { filePath: string; python: string }) => Promise diff --git a/src/preload/api/orca-profiles-bridge.ts b/src/preload/api/orca-profiles-bridge.ts index da58b2d9def..201b6be9799 100644 --- a/src/preload/api/orca-profiles-bridge.ts +++ b/src/preload/api/orca-profiles-bridge.ts @@ -1,6 +1,13 @@ import { ipcRenderer } from 'electron' import type { PreloadApi } from '../api-types' -import { ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL } from '../../shared/orca-profiles' +import { + ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL, + type OrcaProfileListResult, + type SwitchOrcaProfileResult, + type TransferOrcaProfileProjectResult +} from '../../shared/orca-profiles' +import { prepareAndInvokeAppRestart } from '../renderer-restart-wiring' +import { awaitBeforeUnloadCheckpoint } from '../preload-runtime-support' export const orcaProfilesApi = { list: () => ipcRenderer.invoke('orcaProfiles:list'), @@ -12,8 +19,30 @@ export const orcaProfilesApi = { }, createLocal: (args) => ipcRenderer.invoke('orcaProfiles:createLocal', args), createCloudLinked: (args) => ipcRenderer.invoke('orcaProfiles:createCloudLinked', args), - switchProfile: (args) => ipcRenderer.invoke('orcaProfiles:switch', args), - transferProject: (args) => ipcRenderer.invoke('orcaProfiles:transferProject', args), + switchProfile: (args) => + prepareAndInvokeAppRestart( + window, + (): Promise => ipcRenderer.invoke('orcaProfiles:switch', args), + awaitBeforeUnloadCheckpoint, + (result) => result.status === 'relaunching' + ), + transferProject: async (args) => { + const invoke = (): Promise => + ipcRenderer.invoke('orcaProfiles:transferProject', args) + if (args.mode !== 'move') { + return invoke() + } + const current: OrcaProfileListResult = await ipcRenderer.invoke('orcaProfiles:list') + if (args.sourceProfileId !== current.activeProfileId) { + return invoke() + } + return prepareAndInvokeAppRestart( + window, + invoke, + awaitBeforeUnloadCheckpoint, + (result) => result.status === 'transferred' && result.willRelaunch === true + ) + }, findProjectProfiles: (args) => ipcRenderer.invoke('orcaProfiles:findProjectProfiles', args), connectCurrent: () => ipcRenderer.invoke('orcaProfiles:connectCurrent'), refreshAuth: () => ipcRenderer.invoke('orcaProfiles:refreshAuth'), diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index ca273aee07d..eb45c4dba68 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -3,6 +3,7 @@ import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import type { PtyListedSession, PtySessionListScope } from '../../shared/pty-listed-session' import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics' @@ -75,14 +76,15 @@ export type PtyApi = { /** Host verdict on the shell-ready marker; absent when the execution host predates the field. */ shellReadyArmed?: boolean }> - write: (id: string, data: string) => void - writeAccepted: (id: string, data: string) => Promise + write: (id: string, data: string, inputKind: TerminalInputKind) => void + writeAccepted: (id: string, data: string, inputKind: TerminalInputKind) => Promise onWriteUnavailable?: (callback: (payload: { id: string }) => void) => () => void resize: (id: string, cols: number, rows: number) => void claimViewport: (id: string, cols: number, rows: number) => void reportGeometry: (id: string, cols: number, rows: number) => void signal: (id: string, signal: string) => void clearBuffer: (id: string) => void + resetInputModes: (id: string) => void kill: (id: string, opts?: { keepHistory?: boolean }) => Promise ackColdRestore: (id: string) => void ackData: (id: string, charCount: number, processedChars?: number) => void @@ -228,6 +230,7 @@ export type PtyApi = { }) => void ) => () => void onClearBufferRequest: (callback: (data: { ptyId: string }) => void) => () => void + onResetInputModesRequest: (callback: (data: { ptyId: string }) => void) => () => void sendSerializedBuffer: ( requestId: string, snapshot: { diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index e9b19dbe43a..1f8639c58be 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -1,6 +1,7 @@ import { ipcRenderer } from 'electron' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig @@ -71,11 +72,11 @@ export const ptySessionControlApi = { /** Host verdict on the shell-ready marker; absent when the execution host predates the field. */ shellReadyArmed?: boolean }> => ipcRenderer.invoke('pty:spawn', opts), - write: (id: string, data: string): void => { - ipcRenderer.send('pty:write', { id, data }) + write: (id: string, data: string, inputKind: TerminalInputKind): void => { + ipcRenderer.send('pty:write', { id, data, inputKind }) }, - writeAccepted: (id: string, data: string): Promise => - ipcRenderer.invoke('pty:writeAccepted', { id, data }), + writeAccepted: (id: string, data: string, inputKind: TerminalInputKind): Promise => + ipcRenderer.invoke('pty:writeAccepted', { id, data, inputKind }), onWriteUnavailable: (callback: (payload: { id: string }) => void): (() => void) => { const handler = (_event: Electron.IpcRendererEvent, payload: { id: string }): void => callback(payload) @@ -97,6 +98,9 @@ export const ptySessionControlApi = { clearBuffer: (id: string): void => { ipcRenderer.send('pty:clearBuffer', { id }) }, + resetInputModes: (id: string): void => { + ipcRenderer.send('pty:resetInputModes', { id }) + }, ackColdRestore: (id: string): void => { ipcRenderer.send('pty:ackColdRestore', { id }) }, diff --git a/src/preload/api/pty-bridge-stream-and-serialization.ts b/src/preload/api/pty-bridge-stream-and-serialization.ts index 49e2189fd53..557570c8eab 100644 --- a/src/preload/api/pty-bridge-stream-and-serialization.ts +++ b/src/preload/api/pty-bridge-stream-and-serialization.ts @@ -120,6 +120,11 @@ export const ptyStreamAndSerializationApi = { ipcRenderer.on('pty:clearBuffer:request', listener) return () => ipcRenderer.removeListener('pty:clearBuffer:request', listener) }, + onResetInputModesRequest: (callback: (data: { ptyId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { ptyId: string }) => callback(data) + ipcRenderer.on('pty:resetInputModes:request', listener) + return () => ipcRenderer.removeListener('pty:resetInputModes:request', listener) + }, sendSerializedBuffer: ( requestId: string, snapshot: { diff --git a/src/preload/api/session-bridge.ts b/src/preload/api/session-bridge.ts index 22d342cd2c1..537e15179dd 100644 --- a/src/preload/api/session-bridge.ts +++ b/src/preload/api/session-bridge.ts @@ -7,6 +7,7 @@ export const sessionApi = { listHostIds: () => ipcRenderer.invoke('session:list-host-ids'), set: (args, hostId) => ipcRenderer.invoke('session:set', args, hostId), patch: (args, hostId) => ipcRenderer.invoke('session:patch', args, hostId), + closeTerminalSurface: (args) => ipcRenderer.invoke('session:close-terminal-surface', args), flush: () => ipcRenderer.invoke('session:flush'), readTerminalScrollback: (args) => ipcRenderer.sendSync('session:read-terminal-scrollback-sync', args), diff --git a/src/preload/api/ssh-api.ts b/src/preload/api/ssh-api.ts index af198ad1080..5fc64c030e5 100644 --- a/src/preload/api/ssh-api.ts +++ b/src/preload/api/ssh-api.ts @@ -71,6 +71,7 @@ export type SshApi = { targetId: string kind: 'passphrase' | 'password' | 'keyboard-interactive' detail: string + echo?: boolean }) => void ) => () => void onCredentialResolved: (callback: (data: { requestId: string }) => void) => () => void diff --git a/src/preload/api/ssh-bridge.ts b/src/preload/api/ssh-bridge.ts index 03c10f9bc07..240da3c9508 100644 --- a/src/preload/api/ssh-bridge.ts +++ b/src/preload/api/ssh-bridge.ts @@ -158,6 +158,7 @@ export const sshApi = { targetId: string kind: 'passphrase' | 'password' | 'keyboard-interactive' detail: string + echo?: boolean }) => void ): (() => void) => { const listener = ( @@ -167,6 +168,7 @@ export const sshApi = { targetId: string kind: 'passphrase' | 'password' | 'keyboard-interactive' detail: string + echo?: boolean } ) => callback(data) ipcRenderer.on('ssh:credential-request', listener) diff --git a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts index 1738cb46d2a..80f2c2dd383 100644 --- a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts +++ b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts @@ -10,6 +10,7 @@ import { type RichMarkdownContextMenuTableTarget } from '../../shared/rich-markdown-context-menu' import type { NativeFileDropPayload } from '../../shared/native-file-drop' +import type { TerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target' import type { ClipboardImageThumbnail } from '../../shared/clipboard-image' import type { ReadClipboardTextOptions } from '../../shared/clipboard-text' import { subscribeNativeFileDrop } from '../preload-runtime-support' @@ -49,13 +50,9 @@ export const uiClipboardAndWindowControlsApi = { respondMobileMarkdownRequest: (response: RuntimeMobileMarkdownResponse): void => { ipcRenderer.send('ui:mobileMarkdownResponse', response) }, - onCloseTerminal: ( - callback: (data: { tabId: string; paneRuntimeId?: number }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { tabId: string; paneRuntimeId?: number } - ) => callback(data) + onCloseTerminal: (callback: (target: TerminalSurfaceCloseTarget) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, target: TerminalSurfaceCloseTarget) => + callback(target) ipcRenderer.on('ui:closeTerminal', listener) return () => ipcRenderer.removeListener('ui:closeTerminal', listener) }, diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index d20ce87718b..f6bb247bf91 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -1,6 +1,7 @@ import type { MarkdownDocument } from '../../shared/filesystem-entry-types' import type { PersistedUIState } from '../../shared/persisted-ui-state-types' import type { TuiAgent } from '../../shared/tui-agent' +import type { TerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target' import type { WorktreeDefaultTabsLaunch, WorktreeSetupLaunch, @@ -236,9 +237,7 @@ export type UiCommandEventApi = { ) => () => void onMobileMarkdownRequest: (callback: (request: RuntimeMobileMarkdownRequest) => void) => () => void respondMobileMarkdownRequest: (response: RuntimeMobileMarkdownResponse) => void - onCloseTerminal: ( - callback: (data: { tabId: string; paneRuntimeId?: number }) => void - ) => () => void + onCloseTerminal: (callback: (target: TerminalSurfaceCloseTarget) => void) => () => void onTerminalTabCloseRequest: (callback: (request: TerminalTabCloseRequest) => void) => () => void respondTerminalTabClose: (response: TerminalTabCloseResponse) => void onSleepWorktree: (callback: (data: { worktreeId: string }) => void) => () => void diff --git a/src/preload/api/workspace-session-api.ts b/src/preload/api/workspace-session-api.ts index 4c31b6e5db8..d621e5dc7db 100644 --- a/src/preload/api/workspace-session-api.ts +++ b/src/preload/api/workspace-session-api.ts @@ -4,6 +4,7 @@ import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import type { ExecutionHostId } from '../../shared/execution-host' +import type { TerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target' import type { RemoteWorkspaceChangedEvent, RemoteWorkspaceConnectedClient, @@ -19,6 +20,12 @@ export type WorkspaceSessionApi = { listHostIds: () => Promise set: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => Promise patch: (args: WorkspaceSessionPatch, hostId?: ExecutionHostId) => Promise + /** Commits a terminal tab or split-pane close into main's membership. */ + closeTerminalSurface: (args: { + worktreeId: string + target: TerminalSurfaceCloseTarget + reason?: 'user' | 'cleanup' + }) => Promise flush: () => Promise readTerminalScrollback: (args: { ref: string }) => string | null setSync: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => void diff --git a/src/preload/app-restart-checkpoint-routing.test.ts b/src/preload/app-restart-checkpoint-routing.test.ts index b68f55989f5..2c44b9a5b7f 100644 --- a/src/preload/app-restart-checkpoint-routing.test.ts +++ b/src/preload/app-restart-checkpoint-routing.test.ts @@ -124,4 +124,99 @@ describe('native preload destructive app actions', () => { expect(onKeyboardLayoutChanged).toHaveBeenCalledExactlyOnceWith(payload) expect(removeListener).toHaveBeenCalledWith(KEYBOARD_LAYOUT_CHANGED_CHANNEL, listener) }) + + it('awaits renderer durability before profile maintenance and preserves its result', async () => { + const api = await loadApi() + const started = vi.fn() + eventTarget.addEventListener(ORCA_APP_RESTART_STARTED_EVENT, started) + let finishCheckpoint = (_result: { ok: boolean }): void => {} + const checkpoint = new Promise((resolve) => { + finishCheckpoint = resolve + }) + const result = { status: 'relaunching' } + invoke.mockImplementation((channel: string) => + channel === 'app:await-before-unload-checkpoint' ? checkpoint : Promise.resolve(result) + ) + + const switching = api.orcaProfiles.switchProfile({ profileId: 'target' }) + await vi.waitFor(() => + expect(invoke).toHaveBeenCalledWith('app:await-before-unload-checkpoint') + ) + expect(started).toHaveBeenCalledOnce() + expect(invoke).not.toHaveBeenCalledWith('orcaProfiles:switch', expect.anything()) + finishCheckpoint({ ok: true }) + await expect(switching).resolves.toBe(result) + expect(invoke).toHaveBeenLastCalledWith('orcaProfiles:switch', { profileId: 'target' }) + }) + + it.each(['checkpoint-failed', 'switch-failed', 'already-active'])( + 'resets restart preparation when profile switching returns %s', + async (outcome) => { + const api = await loadApi() + const aborted = vi.fn() + eventTarget.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, aborted) + invoke.mockImplementation(async (channel: string) => { + if (channel === 'app:await-before-unload-checkpoint') { + return { ok: outcome !== 'checkpoint-failed' } + } + if (outcome === 'switch-failed') { + throw new Error('switch failed') + } + return { status: 'already-active' } + }) + const switching = api.orcaProfiles.switchProfile({ profileId: 'target' }) + await (outcome === 'already-active' + ? expect(switching).resolves.toEqual({ status: 'already-active' }) + : expect(switching).rejects.toThrow()) + expect(aborted).toHaveBeenCalledOnce() + if (outcome === 'checkpoint-failed') { + expect(invoke).not.toHaveBeenCalledWith('orcaProfiles:switch', expect.anything()) + } + } + ) + + it.each(['move', 'copy', 'inactive', 'duplicate', 'recovery'] as const)( + 'prepares only a potentially relaunching project transfer: %s', + async (outcome) => { + const api = await loadApi() + const started = vi.fn() + const aborted = vi.fn() + eventTarget.addEventListener(ORCA_APP_RESTART_STARTED_EVENT, started) + eventTarget.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, aborted) + const args = { + sourceProfileId: outcome === 'inactive' ? 'inactive' : 'active', + targetProfileId: 'target', + repoId: 'repo', + mode: outcome === 'copy' ? ('copy' as const) : ('move' as const) + } + const result = + outcome === 'duplicate' + ? { status: 'duplicate-target' } + : { status: 'transferred', willRelaunch: outcome === 'move' } + invoke.mockImplementation(async (channel: string) => { + if (channel === 'orcaProfiles:list') { + return { activeProfileId: 'active' } + } + if (channel === 'app:await-before-unload-checkpoint') { + return { ok: true } + } + if (outcome === 'recovery') { + const listener = on.mock.calls.find(([name]) => name === 'app:restart-committed')?.[1] + expect(listener).toBeTypeOf('function') + listener() + throw new Error('move requires recovery') + } + return result + }) + + const transfer = api.orcaProfiles.transferProject(args) + await (outcome === 'recovery' + ? expect(transfer).rejects.toThrow('move requires recovery') + : expect(transfer).resolves.toBe(result)) + const needsPreparation = outcome !== 'copy' && outcome !== 'inactive' + expect(started).toHaveBeenCalledTimes(needsPreparation ? 1 : 0) + expect(aborted).toHaveBeenCalledTimes(outcome === 'duplicate' ? 1 : 0) + expect(invoke).toHaveBeenLastCalledWith('orcaProfiles:transferProject', args) + } + ) }) diff --git a/src/preload/index.ts b/src/preload/index.ts index 3267b981775..42cf236c14d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -40,7 +40,6 @@ import { codexAccountsApi } from './api/codex-accounts-bridge' import { claudeAccountsApi } from './api/claude-accounts-bridge' import { cliApi } from './api/cli-bridge' import { codexConfigSyncApi } from './api/codex-config-sync-bridge' -import { agentTrustApi } from './api/agent-trust-bridge' import { preflightApi } from './api/preflight-bridge' import { notificationsApi } from './api/notifications-bridge' import { onboardingApi } from './api/onboarding-bridge' @@ -78,6 +77,7 @@ import { runtimeEnvironmentsApi } from './api/runtime-environments-bridge' import { rateLimitsApi } from './api/rate-limits-bridge' import { minimaxCredentialsApi } from './api/minimax-credentials-bridge' import { grokAccountsApi } from './api/grok-accounts-bridge' +import { cursorAccountsApi } from './api/cursor-accounts-bridge' import { sshApi } from './api/ssh-bridge' import { automationsApi } from './api/automations-bridge' import { e2eApi } from './api/e2e-bridge' @@ -139,7 +139,6 @@ const api = { claudeAccounts: claudeAccountsApi, cli: cliApi, codexConfigSync: codexConfigSyncApi, - agentTrust: agentTrustApi, preflight: preflightApi, notifications: notificationsApi, onboarding: onboardingApi, @@ -177,6 +176,7 @@ const api = { rateLimits: rateLimitsApi, minimaxCredentials: minimaxCredentialsApi, grokAccounts: grokAccountsApi, + cursorAccounts: cursorAccountsApi, ssh: sshApi, automations: automationsApi, e2e: e2eApi, diff --git a/src/preload/renderer-restart-wiring.test.ts b/src/preload/renderer-restart-wiring.test.ts index bd896109b57..ba97195a5f5 100644 --- a/src/preload/renderer-restart-wiring.test.ts +++ b/src/preload/renderer-restart-wiring.test.ts @@ -1,38 +1,140 @@ import { describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../shared/renderer-shutdown-events' import { ORCA_APP_RESTART_ABORTED_EVENT, + ORCA_APP_RESTART_STARTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT } from '../shared/updater-renderer-events' import { + prepareAndInvokeAppRestart, prepareAndInvokeUpdaterInstall, registerRendererRestartIpcRelays } from './renderer-restart-wiring' +function restartIpc(eventTarget: EventTarget) { + const ipcRenderer = Object.assign(new EventEmitter(), { + invoke: vi.fn(async () => {}), + postMessage: vi.fn(), + send: vi.fn(), + sendSync: vi.fn(), + sendToHost: vi.fn() + }) + const relay = { handleStatus: vi.fn(), abort: vi.fn() } + registerRendererRestartIpcRelays(ipcRenderer, eventTarget, relay) + return { ipcRenderer, ...relay } +} + describe('renderer restart wiring', () => { + it.each(['no-op', 'failure'] as const)( + 'keeps a committed restart prepared after a later %s', + async (outcome) => { + const eventTarget = new EventTarget() + const { ipcRenderer } = restartIpc(eventTarget) + const aborted = vi.fn() + const started = vi.fn() + const checkpoint = vi.fn(async () => {}) + eventTarget.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, aborted) + eventTarget.addEventListener(ORCA_APP_RESTART_STARTED_EVENT, started) + await prepareAndInvokeAppRestart( + eventTarget, + async () => { + ipcRenderer.emit('app:restart-committed') + return true + }, + checkpoint, + Boolean + ) + const subsequent = prepareAndInvokeAppRestart( + eventTarget, + async () => { + if (outcome === 'failure') { + throw new Error('already finalized') + } + return false + }, + checkpoint, + Boolean + ) + await (outcome === 'failure' + ? expect(subsequent).rejects.toThrow('already finalized') + : expect(subsequent).resolves.toBe(false)) + expect(checkpoint).toHaveBeenCalledOnce() + expect(started).toHaveBeenCalledOnce() + expect(aborted).not.toHaveBeenCalled() + } + ) + + it('refuses overlapping preparation without abandoning the accepted restart', async () => { + const eventTarget = new EventTarget() + const aborted = vi.fn() + eventTarget.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, aborted) + const checkpoint = Promise.withResolvers() + const invoke = vi.fn(async () => true) + const first = prepareAndInvokeAppRestart(eventTarget, invoke, () => checkpoint.promise) + const refused = vi.fn(async () => false) + await expect( + prepareAndInvokeAppRestart(eventTarget, refused, async () => {}, Boolean) + ).rejects.toThrow('already in progress') + expect(refused).not.toHaveBeenCalled() + expect(aborted).not.toHaveBeenCalled() + checkpoint.resolve() + await expect(first).resolves.toBe(true) + expect(invoke).toHaveBeenCalledOnce() + }) + + it('retains late commitment across an unrelated unload veto', async () => { + const eventTarget = new EventTarget() + const abandoned = vi.fn() + eventTarget.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, abandoned) + eventTarget.addEventListener(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, abandoned) + const { ipcRenderer } = restartIpc(eventTarget) + const checkpoint = vi.fn(async () => {}) + await prepareAndInvokeAppRestart(eventTarget, async () => true, checkpoint, Boolean) + ipcRenderer.emit('app:restart-committed') + await prepareAndInvokeAppRestart(eventTarget, async () => false, checkpoint, Boolean) + expect(checkpoint).toHaveBeenCalledOnce() + ipcRenderer.emit('window:unload-prevented') + await prepareAndInvokeAppRestart(eventTarget, async () => true, checkpoint, Boolean) + expect(checkpoint).toHaveBeenCalledOnce() + expect(abandoned).not.toHaveBeenCalled() + }) + + it('releases preparation ownership and its listener after checkpoint failure', async () => { + const eventTarget = new EventTarget() + const add = vi.spyOn(eventTarget, 'addEventListener') + const remove = vi.spyOn(eventTarget, 'removeEventListener') + await expect( + prepareAndInvokeAppRestart( + eventTarget, + async () => {}, + async () => { + throw new Error('checkpoint failed') + } + ) + ).rejects.toThrow('checkpoint failed') + await prepareAndInvokeAppRestart( + eventTarget, + async () => {}, + async () => {} + ) + expect(add.mock.calls).toHaveLength(2) + expect(remove.mock.calls).toEqual(add.mock.calls) + }) + it('relays updater status, aborted installs, and prevented unload events', () => { const eventTarget = new EventTarget() const unloadPrevented = vi.fn() const restartAborted = vi.fn() - const handleStatus = vi.fn() - const abort = vi.fn() - const listeners = new Map void>() - const ipcRenderer = { - on: vi.fn((channel: string, listener: (...args: unknown[]) => void) => { - listeners.set(channel, listener) - return ipcRenderer - }) - } as unknown as Parameters[0] + const { ipcRenderer, handleStatus, abort } = restartIpc(eventTarget) eventTarget.addEventListener(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, unloadPrevented) eventTarget.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, restartAborted) - - registerRendererRestartIpcRelays(ipcRenderer, eventTarget, { handleStatus, abort }) - listeners.get('updater:status')?.({}, { state: 'error', message: 'install failed' }) + ipcRenderer.emit('updater:status', {}, { state: 'error', message: 'install failed' }) // Why: main abandons an install without any status when its verdict outlived the cycle. - listeners.get('updater:quitAndInstallAborted')?.({}) - listeners.get('window:unload-prevented')?.({}) + ipcRenderer.emit('updater:quitAndInstallAborted') + ipcRenderer.emit('window:unload-prevented') - expect(ipcRenderer.on).toHaveBeenCalledTimes(3) + expect(ipcRenderer.eventNames()).toHaveLength(4) expect(handleStatus).toHaveBeenCalledWith({ state: 'error', message: 'install failed' }) expect(abort).toHaveBeenCalledTimes(1) expect(unloadPrevented).toHaveBeenCalledTimes(1) diff --git a/src/preload/renderer-restart-wiring.ts b/src/preload/renderer-restart-wiring.ts index 2dd0ad316aa..38ea1882b9f 100644 --- a/src/preload/renderer-restart-wiring.ts +++ b/src/preload/renderer-restart-wiring.ts @@ -12,6 +12,18 @@ import { ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT } from '../shared/updater-renderer-events' +type AppRestartState = { committed: boolean; pending: boolean } +const appRestartStates = new WeakMap() + +function appRestartState(eventTarget: EventTarget): AppRestartState { + let state = appRestartStates.get(eventTarget) + if (!state) { + state = { committed: false, pending: false } + appRestartStates.set(eventTarget, state) + } + return state +} + export function registerRendererRestartIpcRelays( ipcRenderer: Pick, eventTarget: EventTarget, @@ -24,7 +36,14 @@ export function registerRendererRestartIpcRelays( ipcRenderer.on('updater:quitAndInstallAborted', () => { relay.abort() }) + ipcRenderer.on('app:restart-committed', () => { + appRestartState(eventTarget).committed = true + }) ipcRenderer.on('window:unload-prevented', () => { + // A quit veto cannot reopen a profile whose maintenance has already committed. + if (appRestartState(eventTarget).committed) { + return + } eventTarget.dispatchEvent(new Event(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT)) eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) }) @@ -50,20 +69,39 @@ export async function prepareAndInvokeUpdaterInstall( } } -export async function prepareAndInvokeAppRestart( +export async function prepareAndInvokeAppRestart( eventTarget: EventTarget, - invoke: () => Promise, - awaitCheckpoint: () => Promise -): Promise { - await prepareRendererForAppRestart(eventTarget, { - startedEventName: ORCA_APP_RESTART_STARTED_EVENT, - abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT, - awaitCheckpoint - }) + invoke: () => Promise, + awaitCheckpoint: () => Promise, + willRestart: (result: T) => boolean = () => true +): Promise { + const state = appRestartState(eventTarget) + if (state.pending) { + throw new Error('App restart preparation is already in progress') + } + state.pending = true try { - await invoke() - } catch (error) { - eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) - throw error + if (!state.committed) { + await prepareRendererForAppRestart(eventTarget, { + startedEventName: ORCA_APP_RESTART_STARTED_EVENT, + abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT, + awaitCheckpoint + }) + } + try { + const result = await invoke() + if (!state.committed && !willRestart(result)) { + eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) + } + return result + } catch (error) { + // A failed profile move can require recovery after its writer has already closed. + if (!state.committed) { + eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) + } + throw error + } + } finally { + state.pending = false } } diff --git a/src/relay/agent-exec-handler.test.ts b/src/relay/agent-exec-handler.test.ts index 42a79469d42..87a2ef0403b 100644 --- a/src/relay/agent-exec-handler.test.ts +++ b/src/relay/agent-exec-handler.test.ts @@ -36,6 +36,7 @@ describe('AgentExecHandler', () => { }) afterEach(() => { + vi.unstubAllEnvs() for (const [key, value] of Object.entries(ambientGuardEnv)) { if (value !== undefined) { process.env[key] = value @@ -84,6 +85,7 @@ describe('AgentExecHandler', () => { }) it('merges caller-supplied provider environment into the spawned command environment', async () => { + vi.stubEnv('ORCA_EXEC_INHERITED', 'inherited-value') const child = createFakeChild() spawnMock.mockReturnValue(child as never) const handlers = createHandlers() @@ -112,7 +114,7 @@ describe('AgentExecHandler', () => { expect(spawnMock).toHaveBeenCalledWith('codex', ['exec'], { cwd: '/repo', env: expect.objectContaining({ - ...process.env, + ORCA_EXEC_INHERITED: 'inherited-value', CODEX_HOME: '/managed/codex-home', PATH: '/managed/bin' }), @@ -121,6 +123,59 @@ describe('AgentExecHandler', () => { }) }) + it('honors Windows override casing while replacing indexed Git configuration', async () => { + const child = createFakeChild() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every child event and stream used by the handler. + spawnMock.mockReturnValue(child as never) + const originalPlatform = process.platform + const originalPath = process.env.PATH + process.env.PATH = 'inherited-path' + process.env.GIT_CONFIG_COUNT = '2' + process.env.GIT_CONFIG_KEY_1 = 'stale.key' + process.env.GIT_CONFIG_VALUE_1 = 'stale-value' + try { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const handlers = createHandlers() + const pending = handlers.get('agent.execNonInteractive')!( + { + binary: 'agent.exe', + args: [], + env: { + Path: 'override-path', + token: 'override-token', + git_config_count: '1', + git_config_key_0: 'http.proxy', + git_config_value_0: 'proxy-value' + } + }, + requestContext() + ) + child.emit('close', 0) + await expect(pending).resolves.toMatchObject({ exitCode: 0 }) + const env = spawnMock.mock.calls[0][2]?.env + expect(env).toMatchObject({ + PATH: 'override-path', + TOKEN: 'override-token', + GIT_CONFIG_KEY_0: 'http.proxy', + GIT_CONFIG_VALUE_0: 'proxy-value' + }) + expect(env).not.toHaveProperty('Path') + expect(env).not.toHaveProperty('git_config_count') + expect(Object.values(env ?? {})).not.toContain('stale.key') + expect(Object.values(env ?? {})).not.toContain('stale-value') + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + if (originalPath === undefined) { + delete process.env.PATH + } else { + process.env.PATH = originalPath + } + delete process.env.GIT_CONFIG_COUNT + delete process.env.GIT_CONFIG_KEY_1 + delete process.env.GIT_CONFIG_VALUE_1 + } + }) + it('consumes an unattended marker and applies the full Git guard on the relay host', async () => { const child = createFakeChild() spawnMock.mockReturnValue(child as never) diff --git a/src/relay/agent-exec-handler.ts b/src/relay/agent-exec-handler.ts index a82791dcf45..152e8bd72fb 100644 --- a/src/relay/agent-exec-handler.ts +++ b/src/relay/agent-exec-handler.ts @@ -1,3 +1,4 @@ +import { mergeCommandEnvironment } from '../shared/command-environment' import { spawn, type ChildProcess } from 'node:child_process' import { existsSync } from 'node:fs' import { delimiter, join } from 'node:path' @@ -148,10 +149,17 @@ export class AgentExecHandler { params.env && typeof params.env === 'object' && !Array.isArray(params.env) ? (params.env as Record) : null - const spawnEnv = mergeGitConfigEnvProtocol(process.env, extraEnv ?? undefined) as Record< - string, - string - > + const baseEnv = mergeCommandEnvironment( + process.env, + extraEnv ? {} : undefined, + process.platform + ) + const overrides = mergeCommandEnvironment({}, extraEnv ?? undefined, process.platform) + const spawnEnv = Object.fromEntries( + Object.entries(mergeGitConfigEnvProtocol(baseEnv ?? process.env, overrides)).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) + ) // Why: this RPC has no interactive terminal, regardless of which wrapper // launches the agent or hook command. applyTerminalGitCredentialPromptGuard(spawnEnv, { diff --git a/src/relay/agent-hook-integration.test.ts b/src/relay/agent-hook-integration.test.ts index b5a0f1066e4..7e5b21365e1 100644 --- a/src/relay/agent-hook-integration.test.ts +++ b/src/relay/agent-hook-integration.test.ts @@ -168,6 +168,77 @@ describe('Integration: relay hook server → mux → AgentHookServer.ingestRemot expect(payload.agentType).toBe(agent) }) + it.each([0, 1, 2, 3])( + 'delivers Cursor form payloads with %i BOMs to the host-owned status store', + async (count) => { + const { port, token } = hookServer.getCoordinates() + for (const [hookEventName, state] of [ + ['beforeSubmitPrompt', 'working'], + ['stop', 'done'] + ]) { + const payload = Buffer.concat([ + ...Array.from({ length: count }, () => Buffer.from([0xef, 0xbb, 0xbf])), + Buffer.from( + JSON.stringify({ + hook_event_name: hookEventName, + prompt: 'Synthetic café 😀', + status: 'completed' + }) + ) + ]) + const response = await fetch(`http://127.0.0.1:${port}/hook/cursor`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Orca-Agent-Hook-Token': token + }, + body: new URLSearchParams({ + paneKey: `tab-7:${LEAF_7}`, + worktreeId: 'folder:synthetic-cursor', + payload: payload.toString('utf8') + }).toString() + }) + expect(response.status).toBe(204) + await expect + .poll(() => orcaServer.getStatusSnapshot()) + .toEqual([ + expect.objectContaining({ + paneKey: `tab-7:${LEAF_7}`, + worktreeId: 'folder:synthetic-cursor', + connectionId: 'conn-test', + state, + agentType: 'cursor', + prompt: 'Synthetic café 😀' + }) + ]) + } + } + ) + + it('acknowledges malformed Cursor form payloads without publishing status', async () => { + const { port, token } = hookServer.getCoordinates() + for (const payload of [ + '', + '\uFEFF\uFEFFnot json', + ' \uFEFF{}', + '{\uFEFF"hook_event_name":"stop"}', + '\uFEFF\uFEFF{"hook_event_name":"unknown"}' + ]) { + const response = await fetch(`http://127.0.0.1:${port}/hook/cursor`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Orca-Agent-Hook-Token': token + }, + body: new URLSearchParams({ paneKey: `tab-7:${LEAF_7}`, payload }).toString() + }) + expect(response.status).toBe(204) + } + await new Promise((resolve) => setImmediate(resolve)) + expect(hookServer.replayCachedPayloadsForPanes()).toBe(0) + expect(orcaServer.getStatusSnapshot()).toEqual([]) + }) + it('sheds an oversized assistant message through the production publication path', async () => { const events: { payload: { state: string; lastAssistantMessage?: string } }[] = [] orcaServer.setListener((event) => { diff --git a/src/relay/agent-workspace-trust-spawn-guard-failure.test.ts b/src/relay/agent-workspace-trust-spawn-guard-failure.test.ts new file mode 100644 index 00000000000..d207da0e07b --- /dev/null +++ b/src/relay/agent-workspace-trust-spawn-guard-failure.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('../shared/home-or-filesystem-root', () => ({ + isTooBroadToPreTrust: () => { + throw new Error('homedir unavailable') + } +})) + +import { applyRelayAgentWorkspaceTrust } from './agent-workspace-trust-spawn' + +describe('applyRelayAgentWorkspaceTrust when the breadth guard fails', () => { + it.each(['claude', 'copilot', 'qoder'] as const)( + 'skips %s trust and never fails the spawn', + async (agent) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + await expect( + applyRelayAgentWorkspaceTrust({ workspacePath: '/srv/wt' }, agent, {}, { wslShell: false }) + ).resolves.toBeUndefined() + expect(warn).toHaveBeenCalled() + warn.mockRestore() + } + ) +}) diff --git a/src/relay/agent-workspace-trust-spawn.test.ts b/src/relay/agent-workspace-trust-spawn.test.ts new file mode 100644 index 00000000000..ca83e5a0ee1 --- /dev/null +++ b/src/relay/agent-workspace-trust-spawn.test.ts @@ -0,0 +1,268 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import type * as Os from 'node:os' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentTrustPreset } from '../main/agent-trust-presets' +import type { TuiAgent } from '../shared/tui-agent' +import { linkGitWorktree, workspaceTrustWritten } from '../main/workspace-trust-test-fixtures' + +const state = vi.hoisted(() => ({ home: '' })) + +// Why: the non-Claude writers resolve their files from this process's home, as on a real relay. +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, homedir: () => state.home } +}) + +import { applyRelayAgentWorkspaceTrust } from './agent-workspace-trust-spawn' +import { buildSshPtySpawnRequest } from '../main/providers/ssh-pty-spawn-request' + +const HOST_SHELL = { wslShell: false } +const ORIGINAL_CLAUDE_CONFIG = '{"oauthAccount":{"x":1}}' +const AGENTS_WITH_A_RELAY_WRITER: [TuiAgent, AgentTrustPreset][] = [ + ['claude', 'claude'], + ['claude-agent-teams', 'claude'], + ['codex', 'codex'], + ['cursor', 'cursor'], + ['copilot', 'copilot'], + ['qoder', 'qoder'] +] +const AGENTS_THAT_INHERIT_TRUST = AGENTS_WITH_A_RELAY_WRITER.filter(([, preset]) => + ['claude', 'copilot', 'qoder'].includes(preset) +) +const ALL_PRESETS: AgentTrustPreset[] = [ + 'claude', + 'codex', + 'cursor', + 'copilot', + 'qoder', + 'antigravity' +] + +let root: string +let home: string +let workspace: string + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-relay-agent-trust-'))) + home = join(root, 'home', 'me') + mkdirSync(home, { recursive: true }) + state.home = home + writeFileSync(join(home, '.claude.json'), ORIGINAL_CLAUDE_CONFIG, { mode: 0o600 }) + workspace = join(root, 'wt') + mkdirSync(workspace) +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +function nothingWritten(): boolean { + return ALL_PRESETS.every((preset) => !workspaceTrustWritten(home, preset)) +} + +describe('applyRelayAgentWorkspaceTrust', () => { + it.each(AGENTS_WITH_A_RELAY_WRITER)( + "writes %s trust on the relay host's own disk", + async (agent, preset) => { + await applyRelayAgentWorkspaceTrust( + { workspacePath: workspace }, + agent, + { HOME: home }, + HOST_SHELL + ) + expect(workspaceTrustWritten(home, preset)).toBe(true) + } + ) + + it("grants Claude in the remote host's own config, named by the merged spawn env", async () => { + const configDir = join(root, 'cfg') + mkdirSync(configDir) + writeFileSync(join(configDir, '.claude.json'), ORIGINAL_CLAUDE_CONFIG, { mode: 0o600 }) + await applyRelayAgentWorkspaceTrust( + { workspacePath: workspace }, + 'claude', + { CLAUDE_CONFIG_DIR: configDir, HOME: home }, + HOST_SHELL + ) + expect(JSON.parse(readFileSync(join(configDir, '.claude.json'), 'utf-8'))).toEqual({ + oauthAccount: { x: 1 }, + projects: { [workspace]: { hasTrustDialogAccepted: true } } + }) + expect(readFileSync(join(home, '.claude.json'), 'utf-8')).toBe(ORIGINAL_CLAUDE_CONFIG) + }) + + it('never creates a Claude config file Claude has not written', async () => { + const emptyHome = join(root, 'empty-home') + mkdirSync(emptyHome) + await applyRelayAgentWorkspaceTrust( + { workspacePath: workspace }, + 'claude', + { HOME: emptyHome }, + HOST_SHELL + ) + expect(existsSync(join(emptyHome, '.claude.json'))).toBe(false) + }) + + it('writes Codex trust into the CODEX_HOME the spawn env names', async () => { + const codexHome = join(root, 'codex-home') + await applyRelayAgentWorkspaceTrust( + { workspacePath: workspace }, + 'codex', + { HOME: home, CODEX_HOME: codexHome }, + HOST_SHELL + ) + expect(readFileSync(join(codexHome, 'config.toml'), 'utf-8')).toContain( + `[projects."${workspace}"]` + ) + expect(workspaceTrustWritten(home, 'codex')).toBe(false) + }) + + it("trusts a worktree's main checkout for Codex, as a local launch does", async () => { + const mainCheckout = join(root, 'repo') + const worktree = join(root, 'worktrees', 'feature') + linkGitWorktree(mainCheckout, worktree) + await applyRelayAgentWorkspaceTrust( + { workspacePath: worktree }, + 'codex', + { HOME: home }, + HOST_SHELL + ) + const written = readFileSync(join(home, '.codex', 'config.toml'), 'utf-8') + expect(written).toContain(`[projects."${mainCheckout}"]`) + expect(written).not.toContain(`[projects."${worktree}"]`) + }) + + it('trusts a Codex worktree whose main checkout is the relay home, as a local launch does', async () => { + const worktree = join(root, 'worktrees', 'feature') + linkGitWorktree(home, worktree) + await applyRelayAgentWorkspaceTrust( + { workspacePath: worktree }, + 'codex', + { HOME: home }, + HOST_SHELL + ) + expect(readFileSync(join(home, '.codex', 'config.toml'), 'utf-8')).toContain( + `[projects."${home}"]` + ) + }) + + it.each([ + ['codex', 'codex'], + ['cursor', 'cursor'] + ] as const)( + 'trusts the relay home for %s, whose trust there covers only the home', + async (agent, preset) => { + await applyRelayAgentWorkspaceTrust( + { workspacePath: home }, + agent, + { HOME: home }, + HOST_SHELL + ) + expect(workspaceTrustWritten(home, preset)).toBe(true) + } + ) + + const tooBroad: [string, () => string][] = [ + ['the relay home', () => home], + ['a folder containing the relay home', () => join(root, 'home')], + [ + 'a symlink to the relay home', + () => { + symlinkSync(home, join(workspace, 'home-link'), 'junction') + return join(workspace, 'home-link') + } + ] + ] + describe.each(tooBroad)('for %s', (_label, arrange) => { + it.each(AGENTS_THAT_INHERIT_TRUST)('writes no %s trust', async (agent) => { + await applyRelayAgentWorkspaceTrust( + { workspacePath: arrange() }, + agent, + { HOME: home }, + HOST_SHELL + ) + expect(nothingWritten()).toBe(true) + }) + }) + + it.each(AGENTS_WITH_A_RELAY_WRITER)( + "never writes this host's files for %s started in a WSL guest", + async (agent) => { + await applyRelayAgentWorkspaceTrust( + { workspacePath: workspace }, + agent, + { HOME: home }, + { wslShell: true } + ) + expect(nothingWritten()).toBe(true) + } + ) + + it('leaves Antigravity to ask, since its writer is unverified on SSH hosts', async () => { + await applyRelayAgentWorkspaceTrust( + { workspacePath: workspace }, + 'antigravity', + { HOME: home }, + HOST_SHELL + ) + expect(workspaceTrustWritten(home, 'antigravity')).toBe(false) + }) + + it.each([ + ['no request', () => undefined, 'claude'], + ['a malformed request', () => ({ workspacePath: 42 }), 'claude'], + ['an earlier-shaped request', () => ({ worktreeRoot: workspace, trusted: true }), 'codex'], + ['an agent with no trust preset', () => ({ workspacePath: workspace }), 'gemini'], + ['no declared agent', () => ({ workspacePath: workspace }), undefined] + ] as const)('writes nothing, so the agent asks, for %s', async (_label, request, agent) => { + await applyRelayAgentWorkspaceTrust(request(), agent, { HOME: home }, HOST_SHELL) + expect(nothingWritten()).toBe(true) + }) + + it('never fails the spawn when a writer throws', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const codexHome = join(root, 'codex-home') + // Why: a config.toml that cannot be read as a file makes the Codex writer throw. + mkdirSync(join(codexHome, 'config.toml'), { recursive: true }) + await expect( + applyRelayAgentWorkspaceTrust( + { workspacePath: workspace }, + 'codex', + { HOME: home, CODEX_HOME: codexHome }, + HOST_SHELL + ) + ).resolves.toBeUndefined() + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) +}) + +describe('buildSshPtySpawnRequest', () => { + it('forwards the workspace to trust as an optional field an old relay ignores', () => { + const request = buildSshPtySpawnRequest({ + options: { + cols: 80, + rows: 24, + launchAgent: 'codex', + agentWorkspaceTrust: { workspacePath: '/w' } + }, + supportsCreateOperation: false + }) + expect(request.agentWorkspaceTrust).toEqual({ workspacePath: '/w' }) + expect(request.launchAgent).toBe('codex') + expect( + buildSshPtySpawnRequest({ options: { cols: 80, rows: 24 }, supportsCreateOperation: false }) + ).not.toHaveProperty('agentWorkspaceTrust') + }) +}) diff --git a/src/relay/agent-workspace-trust-spawn.ts b/src/relay/agent-workspace-trust-spawn.ts new file mode 100644 index 00000000000..51eaaa28485 --- /dev/null +++ b/src/relay/agent-workspace-trust-spawn.ts @@ -0,0 +1,47 @@ +import { existsSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { parseAgentWorkspaceTrustSpawnRequest } from '../shared/agent-workspace-trust-spawn-request' +import { TUI_AGENT_CONFIG } from '../shared/tui-agent-config' +import type { TuiAgent } from '../shared/tui-agent' +import { resolveClaudeGlobalConfigFile } from '../main/claude/claude-folder-trust-file' +import { SHORT_AGENT_TRUST_WRITE_DEADLINE_MS } from '../main/agent-trust-write-deadline' +import { applyWorkspaceTrustOnThisHost } from '../main/execution-host-workspace-trust' + +/** + * Why here: this host owns the files the agent reads, so each writer's lock, re-read, atomic + * rename and refusal rules act on local disk, with no round trip across the SSH link. + */ +export async function applyRelayAgentWorkspaceTrust( + rawRequest: unknown, + launchAgent: TuiAgent | undefined, + spawnEnv: Record, + launch: { wslShell: boolean } +): Promise { + const request = parseAgentWorkspaceTrustSpawnRequest(rawRequest) + const preset = launchAgent ? TUI_AGENT_CONFIG[launchAgent].preflightTrust : undefined + // Why: an agent inside a WSL guest reads the guest's files, not this Windows host's, and + // Antigravity's writer is unverified on SSH hosts, so it still asks there. + if (!request || !preset || preset === 'antigravity' || launch.wslShell) { + return + } + await applyWorkspaceTrustOnThisHost(preset, request.workspacePath, () => { + const keyStyle = process.platform === 'win32' ? 'win32' : 'posix' + const homeDir = (keyStyle === 'win32' ? spawnEnv.USERPROFILE : spawnEnv.HOME) || homedir() + return { + homes: [homeDir, homedir()], + claudeConfig: () => ({ + configFile: resolveClaudeGlobalConfigFile({ + env: spawnEnv, + homeDir, + style: keyStyle, + exists: existsSync + }), + keyStyle + }), + codexConfigFiles: () => [join(spawnEnv.CODEX_HOME || join(homeDir, '.codex'), 'config.toml')], + // Why: every write here is on the relay's own disk, so each preset gets the local budget. + deadlineMs: SHORT_AGENT_TRUST_WRITE_DEADLINE_MS + } + }) +} diff --git a/src/relay/ai-vault-handler.test.ts b/src/relay/ai-vault-handler.test.ts index eb90958d0a2..3951b58ea5f 100644 --- a/src/relay/ai-vault-handler.test.ts +++ b/src/relay/ai-vault-handler.test.ts @@ -393,7 +393,10 @@ function createTestService( return { listSessions: (params, signal) => scan({ - provider: createRelayAiVaultFilesystemProvider(), + provider: createRelayAiVaultFilesystemProvider({ + homeDirectory: remoteHome, + environment: {} + }), executionHostId: 'local', remoteHome, hostPlatform, diff --git a/src/relay/ai-vault-opencode-reader.test.ts b/src/relay/ai-vault-opencode-reader.test.ts new file mode 100644 index 00000000000..3d082205d5d --- /dev/null +++ b/src/relay/ai-vault-opencode-reader.test.ts @@ -0,0 +1,253 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AiVaultScanIssue } from '../shared/ai-vault-types' +import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform' +import { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner' +import { resetRemoteSessionParseCacheForTests } from '../main/ai-vault/remote-session-parse-cache' +import { listOpenCodeSqliteSessions } from '../main/ai-vault/session-scanner-opencode-sqlite-list' +import { listOpenCode2SqliteSessions } from '../main/ai-vault/session-scanner-opencode2-sqlite-list' +import { parseOpenCodeSqliteSession } from '../main/ai-vault/session-scanner-opencode-sqlite' +import { parseOpenCode2SqliteSession } from '../main/ai-vault/session-scanner-opencode2-sqlite' +import { writeOpenCodeSqliteDatabase } from '../main/ai-vault/session-scanner-opencode-sqlite-fixture' +import { createRelayAiVaultFilesystemProvider } from './ai-vault-service-filesystem' +import { + createRelayOpenCodeReader, + type RelayOpenCodeReaderOptions +} from './ai-vault-opencode-reader' + +const directories: string[] = [] +const disposables: { dispose(): void }[] = [] + +afterEach(async () => { + for (const disposable of disposables.splice(0)) { + disposable.dispose() + } + await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true }))) + resetRemoteSessionParseCacheForTests() +}) + +async function temporaryDirectory(): Promise { + const path = await mkdtemp(join(tmpdir(), 'orca-relay-opencode-')) + directories.push(path) + return path +} + +function factory() { + const reader = { list: vi.fn(async () => []), parse: vi.fn(async () => null), dispose: vi.fn() } + return { + reader, + create: vi.fn( + (_options: Parameters>[0]) => reader + ) + } +} + +describe('relay OpenCode reader', () => { + it('coalesces a persistent reader selected by the provisioned runtime reference', async () => { + const baseDir = await temporaryDirectory() + const executable = join(baseDir, 'runtime', 'bun') + await writeFile( + join(baseDir, 'opencode-sqlite-runtime.json'), + JSON.stringify({ protocol: 1, executable }) + ) + const { reader, create } = factory() + const probe = vi.fn(() => false) + const provider = createRelayAiVaultFilesystemProvider({ + baseDir, + readerFactory: create, + canReadSqlite: probe, + environment: { + HOME: baseDir, + NODE_OPTIONS: '--require=bad', + XDG_DATA_HOME: join(baseDir, 'data') + } + }) + disposables.push(provider) + const issues: AiVaultScanIssue[] = [] + const args = { dbPaths: [join(baseDir, 'opencode.db')], limit: 10, issues } + await Promise.all([provider.openCode?.list(args), provider.openCode?.list(args)]) + expect(issues).toEqual([]) + expect(create).toHaveBeenCalledTimes(1) + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ executable, args: [join(baseDir, 'opencode-sqlite-reader.cjs')] }) + ) + expect(create.mock.calls[0]?.[0]?.env?.NODE_OPTIONS).toBeUndefined() + expect(probe).not.toHaveBeenCalled() + provider.dispose() + expect(reader.dispose).toHaveBeenCalledTimes(1) + }) + + it('reports an unavailable reader once per source and retries after provisioning', async () => { + const baseDir = await temporaryDirectory() + const { create } = factory() + const reader = createRelayOpenCodeReader({ + baseDir, + readerFactory: create, + canReadSqlite: () => false + }) + disposables.push(reader) + const issues: AiVaultScanIssue[] = [] + const args = { + dbPaths: [join(baseDir, 'opencode.db'), join(baseDir, 'opencode-other.db')], + limit: 10, + issues + } + await reader.list(args) + expect(issues).toHaveLength(1) + expect(issues[0]).toMatchObject({ + agent: 'opencode', + kind: 'scope', + message: expect.stringContaining('waiting') + }) + expect(create).not.toHaveBeenCalled() + await writeFile( + join(baseDir, 'opencode-sqlite-runtime.json'), + JSON.stringify({ protocol: 1, executable: join(baseDir, 'bun') }) + ) + await reader.list({ ...args, issues: [] }) + expect(create).toHaveBeenCalledTimes(1) + }) + + it('uses the current runtime only after a successful SQLite read probe', async () => { + const baseDir = await temporaryDirectory() + const { create } = factory() + const reader = createRelayOpenCodeReader({ + baseDir, + readerFactory: create, + currentExecutable: process.execPath + }) + disposables.push(reader) + const issues: AiVaultScanIssue[] = [] + await reader.list({ dbPaths: [join(baseDir, 'opencode.db')], limit: 1, issues }) + expect(issues).toEqual([]) + expect(create).toHaveBeenCalledWith(expect.objectContaining({ executable: process.execPath })) + }) + + it('replaces the persistent reader after a repaired runtime reference changes', async () => { + const baseDir = await temporaryDirectory() + const referencePath = join(baseDir, 'opencode-sqlite-runtime.json') + await writeFile( + referencePath, + JSON.stringify({ protocol: 1, executable: join(baseDir, 'old-bun') }) + ) + const first = factory() + const second = factory() + const create = vi + .fn>() + .mockReturnValueOnce(first.reader) + .mockReturnValueOnce(second.reader) + const reader = createRelayOpenCodeReader({ baseDir, readerFactory: create }) + disposables.push(reader) + const args = { dbPaths: [join(baseDir, 'opencode.db')], limit: 1, issues: [] } + await reader.list(args) + await writeFile( + referencePath, + JSON.stringify({ protocol: 1, executable: join(baseDir, 'repaired-bun') }) + ) + await reader.list(args) + expect(first.reader.dispose).toHaveBeenCalledOnce() + expect(second.reader.list).toHaveBeenCalledOnce() + expect(create).toHaveBeenLastCalledWith( + expect.objectContaining({ executable: join(baseDir, 'repaired-bun') }) + ) + }) + + it.each([ + { protocol: 2, executable: '/runtime/bun' }, + { protocol: 1, executable: 'bun' }, + { protocol: 1, executable: '/runtime/bun\0bad' } + ])('refuses an invalid runtime reference: %j', async (reference) => { + const baseDir = await temporaryDirectory() + await writeFile(join(baseDir, 'opencode-sqlite-runtime.json'), JSON.stringify(reference)) + const { create } = factory() + const reader = createRelayOpenCodeReader({ + baseDir, + readerFactory: create, + canReadSqlite: () => true + }) + disposables.push(reader) + const issues: AiVaultScanIssue[] = [] + await reader.list({ dbPaths: ['opencode.db'], limit: 1, issues }) + expect(issues).toHaveLength(1) + expect(create).not.toHaveBeenCalled() + }) + + it('retains the host database overrides including relative and in-memory paths', async () => { + const baseDir = await temporaryDirectory() + const data = join(baseDir, 'data') + const reader = createRelayOpenCodeReader({ + baseDir, + environment: { XDG_DATA_HOME: data, OPENCODE_DB: 'opencode-team.db' } + }) + expect(reader.dataDirectory).toBe(join(data, 'opencode')) + expect(reader.databasePath).toBe(join(data, 'opencode', 'opencode-team.db')) + expect( + createRelayOpenCodeReader({ environment: { OPENCODE_DB: ':memory:' } }).databasePath + ).toBeNull() + }) + + it('does not start a child for empty or cancelled scans', async () => { + const baseDir = await temporaryDirectory() + const { create } = factory() + const reader = createRelayOpenCodeReader({ baseDir, readerFactory: create }) + disposables.push(reader) + await reader.list({ dbPaths: [], limit: 1, issues: [] }) + const controller = new AbortController() + controller.abort() + await expect( + reader.list({ dbPaths: ['opencode.db'], limit: 1, issues: [], signal: controller.signal }) + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(create).not.toHaveBeenCalled() + }) + + it('scans the host database and legacy JSON through the existing readers', async () => { + const baseDir = await temporaryDirectory() + const xdg = join(baseDir, 'data') + const dbPath = join(xdg, 'opencode', 'opencode-team.db') + writeOpenCodeSqliteDatabase(dbPath, [ + { id: 'migrated', turns: [{ role: 'user', parts: ['Database prompt'] }] } + ]) + const sessionDir = join(xdg, 'opencode', 'storage', 'session', 'project') + await mkdir(sessionDir, { recursive: true }) + await writeFile( + join(sessionDir, 'migrated.json'), + JSON.stringify({ id: 'migrated', title: 'Stale title' }) + ) + await writeFile( + join(sessionDir, 'legacy.json'), + JSON.stringify({ id: 'legacy', title: 'Legacy title', directory: '/project' }) + ) + const readerFactory: NonNullable = () => ({ + list: (args) => + args.agent === 'opencode2' + ? listOpenCode2SqliteSessions(args) + : listOpenCodeSqliteSessions({ ...args, agent: 'opencode' }), + parse: async (args) => + args.agent === 'opencode2' + ? parseOpenCode2SqliteSession(args) + : parseOpenCodeSqliteSession({ ...args, agent: 'opencode' }), + dispose() {} + }) + const provider = createRelayAiVaultFilesystemProvider({ + baseDir, + environment: { XDG_DATA_HOME: xdg, OPENCODE_DB: 'opencode-team.db' }, + readerFactory + }) + disposables.push(provider) + const result = await scanRemoteAiVaultSessions({ + provider, + remoteHome: baseDir, + executionHostId: 'ssh:actual', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + expect(result.issues).toEqual([]) + expect(result.sessions.map((entry) => entry.sessionId).sort()).toEqual(['legacy', 'migrated']) + expect(result.sessions.find((entry) => entry.sessionId === 'migrated')).toMatchObject({ + filePath: dbPath, + executionHostId: 'ssh:actual', + previewMessages: [expect.objectContaining({ text: 'Database prompt' })] + }) + }) +}) diff --git a/src/relay/ai-vault-opencode-reader.ts b/src/relay/ai-vault-opencode-reader.ts new file mode 100644 index 00000000000..d90f2e08cd1 --- /dev/null +++ b/src/relay/ai-vault-opencode-reader.ts @@ -0,0 +1,159 @@ +import { open } from 'node:fs/promises' +import { homedir } from 'node:os' +import { isAbsolute, join } from 'node:path' +import type { RemoteOpenCodeSessionReader } from '../main/ai-vault/remote-session-scanner-types' +import { throwIfAiVaultScanCancelled } from '../main/ai-vault/ai-vault-scan-cancellation' +import { isMissingRemoteSessionPathError } from '../main/ai-vault/remote-session-file-stat' +import { parseOpenCodeSessionFile } from '../main/ai-vault/session-scanner-opencode-parser' +import { createOpenCodeSqliteProcessClient } from '../main/ai-vault/session-scanner-opencode-sqlite-process-client' +import { buildRelayAiVaultServiceEnv } from '../main/ai-vault/session-scanner-service-env' +import { resolveOpenCodeDataDirectory } from '../main/opencode/opencode-data-directory' +import SyncDatabase from '../main/sqlite/sync-database' + +type Reader = Pick & { dispose(): void } + +export type RelayOpenCodeReaderOptions = { + baseDir?: string + environment?: NodeJS.ProcessEnv + homeDirectory?: string + currentExecutable?: string + readerFactory?: (options: Parameters[0]) => Reader + canReadSqlite?: () => boolean +} + +export function createRelayOpenCodeReader( + options: RelayOpenCodeReaderOptions = {} +): RemoteOpenCodeSessionReader & { dispose(): void } { + const environment = options.environment ?? process.env + const dataDirectory = resolveOpenCodeDataDirectory( + environment, + options.homeDirectory ?? homedir() + ) + const override = environment.OPENCODE_DB?.trim() + const baseDir = options.baseDir ?? __dirname + let reader: Reader | undefined + let executable: string | undefined + let pending: Promise | undefined + let disposed = false + const getReader = (refreshRuntime = false): Promise => { + if (disposed) { + return Promise.reject(new Error('OpenCode database reader was disposed.')) + } + if (reader && !refreshRuntime) { + return Promise.resolve(reader) + } + pending ??= (async () => { + const configured = await readRuntimeExecutable(join(baseDir, 'opencode-sqlite-runtime.json')) + if (!configured && !(options.canReadSqlite ?? canCurrentRuntimeReadSqlite)()) { + throw new Error('OpenCode history is waiting for its database reader on this host.') + } + if (disposed) { + throw new Error('OpenCode database reader was disposed.') + } + const nextExecutable = configured ?? options.currentExecutable ?? process.execPath + if (!reader || executable !== nextExecutable) { + reader?.dispose() + reader = (options.readerFactory ?? createOpenCodeSqliteProcessClient)({ + executable: nextExecutable, + args: [join(baseDir, 'opencode-sqlite-reader.cjs')], + env: buildRelayAiVaultServiceEnv(environment) + }) + executable = nextExecutable + } + return reader + })().finally(() => { + pending = undefined + }) + return pending + } + return { + dataDirectory, + ...(override + ? { + databasePath: + override === ':memory:' + ? null + : isAbsolute(override) + ? override + : join(dataDirectory, override) + } + : {}), + async list(args) { + throwIfAiVaultScanCancelled(args.signal) + if (args.dbPaths.length === 0) { + return [] + } + try { + return await (await getReader(true)).list(args) + } catch (error) { + throwIfAiVaultScanCancelled(args.signal) + args.issues.push({ + agent: args.agent ?? 'opencode', + kind: 'scope', + path: args.dbPaths[0] ?? dataDirectory, + message: error instanceof Error ? error.message : String(error) + }) + return [] + } + }, + async parse(args) { + throwIfAiVaultScanCancelled(args.signal) + return (await getReader()).parse(args) + }, + parseLegacy: parseOpenCodeSessionFile, + dispose() { + if (disposed) { + return + } + disposed = true + reader?.dispose() + } + } +} + +async function readRuntimeExecutable(path: string): Promise { + let file + try { + file = await open(path, 'r') + } catch (error) { + if (isMissingRemoteSessionPathError(error)) { + return undefined + } + throw error + } + try { + const bytes = Buffer.alloc(16_385) + const { bytesRead } = await file.read(bytes, 0, bytes.length, 0) + if (bytesRead > 16_384) { + throw new Error('OpenCode database runtime reference exceeds its size limit.') + } + const reference: unknown = JSON.parse(bytes.subarray(0, bytesRead).toString('utf8')) + if ( + typeof reference !== 'object' || + reference === null || + !('protocol' in reference) || + reference.protocol !== 1 || + !('executable' in reference) || + typeof reference.executable !== 'string' || + !isAbsolute(reference.executable) || + reference.executable.includes('\0') + ) { + throw new Error('OpenCode database runtime reference is invalid.') + } + return reference.executable + } finally { + await file.close() + } +} + +function canCurrentRuntimeReadSqlite(): boolean { + let db: SyncDatabase | undefined + try { + db = new SyncDatabase(':memory:') + return db.prepare('SELECT 1 AS ready').get()?.ready === 1 + } catch { + return false + } finally { + db?.close() + } +} diff --git a/src/relay/ai-vault-service-client-state.ts b/src/relay/ai-vault-service-client-state.ts index de623d19b07..11cab3763d7 100644 --- a/src/relay/ai-vault-service-client-state.ts +++ b/src/relay/ai-vault-service-client-state.ts @@ -28,6 +28,18 @@ export function relayAiVaultError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)) } +export function armRelayAiVaultCallTimeout( + call: RelayAiVaultServiceCall, + onExpired: (timeout: number) => void +): void { + const timeout = + call.request.operation === 'list' + ? RELAY_AI_VAULT_SCAN_TIMEOUT_MS + : RELAY_AI_VAULT_TITLE_TIMEOUT_MS + call.timer = setTimeout(() => onExpired(timeout), timeout) + call.timer.unref?.() +} + export function armRelayAiVaultCancellationTimeout( call: RelayAiVaultServiceCall, onExpired: () => void @@ -53,7 +65,8 @@ export function createRelayAiVaultServiceCall(args: { onAbort: null, settled: false, sent: false, - startRetried: false + startRetried: false, + startAttempt: 0 } } @@ -110,6 +123,7 @@ export type RelayAiVaultServiceCall = { /** Whether the sidecar received the request; an unsent call gets no reply. */ sent: boolean startRetried: boolean + startAttempt: number } export type RelayAiVaultServiceApi = { diff --git a/src/relay/ai-vault-service-client.ts b/src/relay/ai-vault-service-client.ts index c873c380506..9197bce1080 100644 --- a/src/relay/ai-vault-service-client.ts +++ b/src/relay/ai-vault-service-client.ts @@ -1,4 +1,5 @@ import type { ChildProcess } from 'node:child_process' +import { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters' import type { AiVaultListResult } from '../shared/ai-vault-types' import type { AiVaultSessionTitleRequest, @@ -9,8 +10,7 @@ import { RELAY_AI_VAULT_MAX_CALLS, RELAY_AI_VAULT_IDLE_TIMEOUT_MS, RELAY_AI_VAULT_READY_TIMEOUT_MS, - RELAY_AI_VAULT_SCAN_TIMEOUT_MS, - RELAY_AI_VAULT_TITLE_TIMEOUT_MS, + armRelayAiVaultCallTimeout, armRelayAiVaultCancellationTimeout, createRelayAiVaultServiceCall, relayAiVaultAbortError, @@ -37,7 +37,7 @@ import { relayLogLine } from './relay-diagnostic-log' export class RelayAiVaultServiceClient implements RelayAiVaultServiceApi { private child: ChildProcess | null = null - private ready: Promise | null = null + private ready: PromiseSettlementWaiters | null = null private readyReject: ((error: Error) => void) | null = null private readyTimer: NodeJS.Timeout | null = null private readonly active = new Map() @@ -74,6 +74,9 @@ export class RelayAiVaultServiceClient implements RelayAiVaultServiceApi { this.clearReadyTimer() this.restartPolicy.dispose() const error = new Error('Relay AI Vault service was disposed.') + this.readyReject?.(error) + this.readyReject = null + this.ready = null for (const call of [...this.active.values(), ...this.queue.splice(0)]) { settleRelayAiVaultServiceCall(call, error) } @@ -128,17 +131,22 @@ export class RelayAiVaultServiceClient implements RelayAiVaultServiceApi { } const call = this.queue.splice(index, 1)[0]! this.active.set(lane, call) - void this.ensureChild(call.forceStart).then( - (child) => this.sendCall(child, call), - (error: Error) => { - if (this.active.get(lane) !== call) { + const attempt = ++call.startAttempt + const readiness = this.ensureChild(call.forceStart) + void readiness + .wait({ + signal: call.signal, + createAbortError: relayAiVaultAbortError, + onFulfilled: (child) => this.sendCall(child, call) + }) + .catch((error: Error) => { + if (this.active.get(lane) !== call || call.startAttempt !== attempt) { return } this.active.delete(lane) this.retryStartOrSettle(call, error) this.pump() - } - ) + }) } this.scheduleIdleIfNeeded() } @@ -147,15 +155,9 @@ export class RelayAiVaultServiceClient implements RelayAiVaultServiceApi { if (this.active.get(call.lane) !== call || call.settled) { return } - const timeout = - call.request.operation === 'list' - ? RELAY_AI_VAULT_SCAN_TIMEOUT_MS - : RELAY_AI_VAULT_TITLE_TIMEOUT_MS - call.timer = setTimeout( - () => this.onFault(new Error(`Relay AI Vault service timed out after ${timeout}ms.`)), - timeout + armRelayAiVaultCallTimeout(call, (timeout) => + this.onFault(new Error(`Relay AI Vault service timed out after ${timeout}ms.`)) ) - call.timer.unref?.() call.sent = true child.send(call.request) } @@ -166,44 +168,46 @@ export class RelayAiVaultServiceClient implements RelayAiVaultServiceApi { } } - private ensureChild(forceStart: boolean): Promise { + private ensureChild(forceStart: boolean): PromiseSettlementWaiters { if (this.child && !this.ready) { - return Promise.resolve(this.child) + return new PromiseSettlementWaiters(Promise.resolve(this.child)) } if (this.ready) { return this.ready } const startError = this.restartPolicy.startError(forceStart) if (startError) { - return Promise.reject(startError) + return new PromiseSettlementWaiters(Promise.reject(startError)) } let child: ChildProcess try { child = this.options.processFactory() } catch (error) { - return Promise.reject(relayAiVaultError(error)) + return new PromiseSettlementWaiters(Promise.reject(relayAiVaultError(error))) } this.child = child - this.ready = new Promise((resolve, reject) => { - this.readyReject = reject - // Why: held on the instance so a crash before ready cannot leave the deadline - // armed, where it would later fault the healthy replacement sidecar. - this.readyTimer = setTimeout( - () => this.onFault(new Error('Relay AI Vault service did not become ready.')), - RELAY_AI_VAULT_READY_TIMEOUT_MS - ) - this.readyTimer.unref?.() - child.on('message', (message) => { - if (isRelayAiVaultServiceChildMessage(message) && message.type === 'ready') { - this.clearReadyTimer() - this.ready = null - this.readyReject = null - resolve(child) - return - } - this.onMessage(message) + this.ready = new PromiseSettlementWaiters( + new Promise((resolve, reject) => { + this.readyReject = reject + // Why: held on the instance so a crash before ready cannot leave the deadline + // armed, where it would later fault the healthy replacement sidecar. + this.readyTimer = setTimeout( + () => this.onFault(new Error('Relay AI Vault service did not become ready.')), + RELAY_AI_VAULT_READY_TIMEOUT_MS + ) + this.readyTimer.unref?.() + child.on('message', (message) => { + if (isRelayAiVaultServiceChildMessage(message) && message.type === 'ready') { + this.clearReadyTimer() + this.ready = null + this.readyReject = null + resolve(child) + return + } + this.onMessage(message) + }) }) - }) + ) child.on('error', (error) => this.onFault(error)) child.on('disconnect', () => this.onFault(new Error('Relay AI Vault service disconnected.'))) child.on('exit', (code) => this.onFault(new Error(`Relay AI Vault service exited (${code}).`))) diff --git a/src/relay/ai-vault-service-entry.ts b/src/relay/ai-vault-service-entry.ts index 9bd3ea35786..3f0a2b02aaf 100644 --- a/src/relay/ai-vault-service-entry.ts +++ b/src/relay/ai-vault-service-entry.ts @@ -19,7 +19,7 @@ if (!process.send) { const controllers = new Map() const cancelled = new Set() const pending = new Set() -const provider = createRelayAiVaultFilesystemProvider() +let provider: ReturnType | null = null let init: RelayAiVaultServiceInit | null = null let cacheLane = Promise.resolve() let interactiveLane = Promise.resolve() @@ -36,7 +36,7 @@ async function execute(request: RelayAiVaultServiceRequest): Promise { controller.abort() } try { - if (!init) { + if (!init || !provider) { throw new Error('Relay AI Vault service is not initialized.') } if (request.operation === 'titles') { @@ -79,6 +79,7 @@ async function shutdown(): Promise { controller.abort() } await Promise.allSettled([cacheLane, interactiveLane]) + provider?.dispose() process.disconnect?.() } @@ -89,6 +90,7 @@ process.on('message', (raw: RelayAiVaultServiceParentMessage) => { return } init = raw + provider = createRelayAiVaultFilesystemProvider({ homeDirectory: raw.remoteHome }) send({ type: 'ready', protocol: RELAY_AI_VAULT_SERVICE_PROTOCOL, pid: process.pid }) return } diff --git a/src/relay/ai-vault-service-filesystem.ts b/src/relay/ai-vault-service-filesystem.ts index fd5ff90a755..58b1b1f2d6e 100644 --- a/src/relay/ai-vault-service-filesystem.ts +++ b/src/relay/ai-vault-service-filesystem.ts @@ -2,9 +2,18 @@ import { readRelayTranscriptBytes } from './ai-vault-transcript-stream' import { lstat, readdir } from 'node:fs/promises' import type { RemoteSessionFilesystemProvider } from '../main/ai-vault/remote-session-scanner-types' import { readRelayFileContent } from './fs-handler-file-read' +import { + createRelayOpenCodeReader, + type RelayOpenCodeReaderOptions +} from './ai-vault-opencode-reader' -export function createRelayAiVaultFilesystemProvider(): RemoteSessionFilesystemProvider { +export function createRelayAiVaultFilesystemProvider( + options: RelayOpenCodeReaderOptions = {} +): RemoteSessionFilesystemProvider & { dispose(): void } { + const openCode = createRelayOpenCodeReader(options) return { + openCode, + dispose: () => openCode.dispose(), async readDir(dirPath) { const entries = await readdir(dirPath, { withFileTypes: true }) return entries.map((entry) => ({ diff --git a/src/relay/ai-vault-service-ready-retention.test.ts b/src/relay/ai-vault-service-ready-retention.test.ts new file mode 100644 index 00000000000..53f845a4e90 --- /dev/null +++ b/src/relay/ai-vault-service-ready-retention.test.ts @@ -0,0 +1,202 @@ +import { setImmediate } from 'node:timers/promises' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform' +import { + AiVaultServiceTestChild, + readyAiVaultServiceChild +} from '../main/ai-vault/session-scanner-service-test-child' +import type { AiVaultSessionTitleRequest } from '../shared/ai-vault-session-title' +import { RelayAiVaultServiceClient } from './ai-vault-service-client' + +function fixture() { + const child = new AiVaultServiceTestChild() + const processFactory = vi.fn(() => child.asChildProcess()) + const client = new RelayAiVaultServiceClient({ + processFactory, + init: { remoteHome: '/home/ada', hostPlatform: getRemoteHostPlatform('linux-x64') } + }) + return { client, child, processFactory } +} + +async function collect(): Promise { + if (!global.gc) { + throw new Error('This regression requires --expose-gc') + } + for (let turn = 0; turn < 3; turn++) { + await setImmediate() + global.gc() + } +} + +function titleRequests(): AiVaultSessionTitleRequest[] { + return [{ agent: 'claude', sessionId: 'session', transcriptPath: '/home/ada/session.jsonl' }] +} + +beforeEach(() => vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })) +afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() +}) + +describe('AI Vault readiness ownership', () => { + it('releases canceled request payloads while readiness remains pending', async () => { + const { client, child, processFactory } = fixture() + const canceled = await (async () => { + const refs: WeakRef[] = [] + for (let index = 0; index < 1000; index++) { + const requests = titleRequests() + refs.push(new WeakRef(requests)) + const controller = new AbortController() + const result = client.resolveSessionTitles(requests, controller.signal) + controller.abort() + const error = await result.catch((reason: unknown) => reason) + if (!(error instanceof Error) || error.name !== 'AbortError') { + throw new Error('Canceled title request did not reject with AbortError') + } + } + return refs + })() + await collect() + expect(canceled.filter((ref) => ref.deref() !== undefined)).toHaveLength(0) + expect(child.sent).toHaveLength(1) + expect(processFactory).toHaveBeenCalledOnce() + + const live = client.resolveSessionTitles(titleRequests()) + readyAiVaultServiceChild(child) + await Promise.resolve() + expect(child.sent.at(-1)).toMatchObject({ type: 'request', id: 1001, operation: 'titles' }) + child.emit('message', { type: 'result', id: 1001, operation: 'titles', value: { titles: [] } }) + await expect(live).resolves.toEqual({ titles: [] }) + const disposed = client.dispose() + child.emit('exit', 0) + await disposed + expect(vi.getTimerCount()).toBe(0) + }) + + it('releases uncanceled readiness payloads when the client is disposed', async () => { + const { client, child } = fixture() + const discarded = await (async () => { + const requests = titleRequests() + const ref = new WeakRef(requests) + const result = client.resolveSessionTitles(requests) + const rejection = expect(result).rejects.toThrow('disposed') + const disposed = client.dispose() + child.emit('exit', 0) + await disposed + await rejection + return ref + })() + await collect() + expect(discarded.deref() === undefined).toBe(true) + expect(child.sent).toEqual([expect.objectContaining({ type: 'init' }), { type: 'shutdown' }]) + expect(vi.getTimerCount()).toBe(0) + await expect(client.resolveSessionTitles([])).rejects.toThrow('disposed') + }) + + it('does not send a call canceled in the turn that readiness arrives', async () => { + const { client, child } = fixture() + const controller = new AbortController() + const canceled = client.resolveSessionTitles(titleRequests(), controller.signal) + readyAiVaultServiceChild(child) + controller.abort() + const live = client.resolveSessionTitles([]) + await expect(canceled).rejects.toMatchObject({ name: 'AbortError' }) + expect(child.sent).toEqual([ + expect.objectContaining({ type: 'init' }), + { type: 'request', id: 2, operation: 'titles', requests: [] } + ]) + child.emit('message', { type: 'result', id: 2, operation: 'titles', value: { titles: [] } }) + await expect(live).resolves.toEqual({ titles: [] }) + const disposed = client.dispose() + child.emit('exit', 0) + await disposed + }) +}) + +it('retries an unsent lane after spawn fails while the other lane starts a child', async () => { + const child = new AiVaultServiceTestChild() + const processFactory = vi.fn(() => child.asChildProcess()) + processFactory.mockImplementationOnce(() => { + throw new Error('temporary spawn failure') + }) + const client = new RelayAiVaultServiceClient({ + processFactory, + init: { remoteHome: '/home/ada', hostPlatform: getRemoteHostPlatform('linux-x64') } + }) + const list = client.listSessions({}) + const titles = client.resolveSessionTitles([]) + void list.catch(() => undefined) + void titles.catch(() => undefined) + try { + readyAiVaultServiceChild(child) + await vi.waitFor(() => { + expect(child.sent).toContainEqual({ type: 'request', id: 1, operation: 'list', params: {} }) + }) + expect(processFactory).toHaveBeenCalledTimes(2) + expect(child.sent).toContainEqual({ type: 'request', id: 1, operation: 'list', params: {} }) + expect(child.sent).toContainEqual({ type: 'request', id: 2, operation: 'titles', requests: [] }) + child.emit('message', { + type: 'result', + id: 1, + operation: 'list', + value: { sessions: [], issues: [], scannedAt: '2026-09-25T00:00:00.000Z' } + }) + child.emit('message', { type: 'result', id: 2, operation: 'titles', value: { titles: [] } }) + await expect(list).resolves.toMatchObject({ sessions: [] }) + await expect(titles).resolves.toEqual({ titles: [] }) + } finally { + const disposed = client.dispose() + child.emit('exit', 0) + await disposed + } +}) + +it('ignores an earlier readiness rejection after a replacement startup begins', async () => { + const first = new AiVaultServiceTestChild() + const replacement = new AiVaultServiceTestChild() + const processFactory = vi.fn(() => replacement.asChildProcess()) + processFactory.mockImplementationOnce(() => first.asChildProcess()) + const client = new RelayAiVaultServiceClient({ + processFactory, + init: { remoteHome: '/home/ada', hostPlatform: getRemoteHostPlatform('linux-x64') } + }) + const list = client.listSessions({}) + void list.catch(() => undefined) + try { + first.emit('exit', 1) + // Start the replacement before the old readiness rejection's microtasks run. + vi.advanceTimersByTime(250) + const titles = client.resolveSessionTitles([]) + void titles.catch(() => undefined) + readyAiVaultServiceChild(replacement) + await setImmediate() + + expect(processFactory).toHaveBeenCalledTimes(2) + expect(first.sent).toEqual([expect.objectContaining({ type: 'init' })]) + expect(replacement.sent).toEqual([ + expect.objectContaining({ type: 'init' }), + { type: 'request', id: 1, operation: 'list', params: {} }, + { type: 'request', id: 2, operation: 'titles', requests: [] } + ]) + replacement.emit('message', { + type: 'result', + id: 1, + operation: 'list', + value: { sessions: [], issues: [], scannedAt: '2026-09-25T00:00:00.000Z' } + }) + replacement.emit('message', { + type: 'result', + id: 2, + operation: 'titles', + value: { titles: [] } + }) + await expect(list).resolves.toMatchObject({ sessions: [] }) + await expect(titles).resolves.toEqual({ titles: [] }) + expect(processFactory).toHaveBeenCalledTimes(2) + } finally { + const disposed = client.dispose() + replacement.emit('exit', 0) + await disposed + } + expect(vi.getTimerCount()).toBe(0) +}) diff --git a/src/relay/freebuff-status-projection.test.ts b/src/relay/freebuff-status-projection.test.ts new file mode 100644 index 00000000000..b10214ef801 --- /dev/null +++ b/src/relay/freebuff-status-projection.test.ts @@ -0,0 +1,43 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { FreebuffStatusProjection } from './freebuff-status-projection' +import { createAgentStatusOscProcessor } from '../shared/agent-status-osc' + +const transcript = readFileSync( + join(import.meta.dirname, '../main/runtime/__fixtures__/freebuff-lifecycle.txt'), + 'utf8' +) + +describe('Freebuff relay status projection', () => { + it.each([1, 137, 4096, transcript.length])( + 'publishes host-observed states with %i-character PTY chunks', + (size) => { + const projection = new FreebuffStatusProjection(120, 40) + const parse = createAgentStatusOscProcessor() + const states: string[] = [] + try { + for (let index = 0; index < transcript.length; index += size) { + const raw = transcript.slice(index, index + size) + const projected = projection.project(raw) + // oxlint-disable-next-line no-control-regex -- Terminal protocol delimiters contain ESC and BEL. + expect(projected.replace(/\x1b\]9999;[^\x07]*\x07/g, '')).toBe(raw) + for (const payload of parse(projected).payloads) { + expect(payload.agentType).toBe('freebuff') + if (states.at(-1) !== payload.state) { + states.push(payload.state) + } + } + } + expect(states).toContain('working') + expect(states).toContain('waiting') + expect(states.at(-1)).toBe('done') + projection.resize(90, 30) + projection.dispose() + expect(projection.project('later output')).toBe('later output') + } finally { + projection.dispose() + } + } + ) +}) diff --git a/src/relay/freebuff-status-projection.ts b/src/relay/freebuff-status-projection.ts new file mode 100644 index 00000000000..6e8ea7d9de3 --- /dev/null +++ b/src/relay/freebuff-status-projection.ts @@ -0,0 +1,45 @@ +import { HeadlessEmulator } from '../main/daemon/headless-emulator' +import { + FreebuffScreenStatusTracker, + splitFreebuffScreenUpdates +} from '../shared/freebuff-screen-status' + +/** Emits the existing status OSC on the execution host, preserving the PTY's raw byte credit. */ +export class FreebuffStatusProjection { + private readonly emulator: HeadlessEmulator + private readonly tracker = new FreebuffScreenStatusTracker(true) + private disposed = false + + constructor(cols: number, rows: number) { + this.emulator = new HeadlessEmulator({ cols, rows, scrollback: 0 }) + } + + project(data: string): string { + if (this.disposed) { + return data + } + return splitFreebuffScreenUpdates(data, this.emulator.partialEscapeTailAnsi) + .map((chunk) => { + if (!this.emulator.writeSync(chunk)) { + return chunk + } + const status = this.tracker.observe(chunk, () => ({ + lines: this.emulator.getVisibleLines(), + alternate: this.emulator.isAlternateScreen + })) + return status ? `${chunk}\x1b]9999;${JSON.stringify(status)}\x07` : chunk + }) + .join('') + } + + resize(cols: number, rows: number): void { + if (!this.disposed) { + this.emulator.resize(cols, rows) + } + } + + dispose(): void { + this.disposed = true + this.emulator.dispose() + } +} diff --git a/src/relay/fs-handler-git-fallback.ts b/src/relay/fs-handler-git-fallback.ts index 6e1144cdf4f..3298f369c9e 100644 --- a/src/relay/fs-handler-git-fallback.ts +++ b/src/relay/fs-handler-git-fallback.ts @@ -6,10 +6,8 @@ * and git grep as universal fallbacks — git is always available since this is * a git-focused app. */ -import { SearchSubprocessLineAccumulator } from '../shared/search-subprocess-lines' import { spawn } from 'node:child_process' import { fileListingCancellationError } from '../shared/file-listing-cancellation' -import type { SearchOptions, SearchResult } from './fs-handler-utils' import { buildGitLsFilesArgsForQuickOpen, shouldExcludeQuickOpenRelPath, @@ -19,14 +17,6 @@ import { expandQuickOpenGitFileListing, parseQuickOpenGitLsFilesEntry } from '../shared/quick-open-readdir-walk' -import { - buildGitGrepArgs, - buildSubmatchRegex, - createAccumulator, - finalize, - ingestGitGrepLine, - SEARCH_TIMEOUT_MS -} from '../shared/text-search' import { buildRelayGitEnv } from './relay-command-env' /** @@ -266,81 +256,4 @@ export function listFilesWithGit( }) } -/** - * Text search using `git grep`. Fallback when rg is not installed. - */ -export function searchWithGitGrep( - rootPath: string, - query: string, - opts: SearchOptions -): Promise { - return new Promise((resolve) => { - const gitArgs = buildGitGrepArgs(query, opts) - const matchRegex = buildSubmatchRegex(query, opts) - const acc = createAccumulator() - const lines = new SearchSubprocessLineAccumulator(Number.MAX_SAFE_INTEGER) - let done = false - - const child = spawn('git', gitArgs, { - cwd: rootPath, - env: buildRelayGitEnv(), - stdio: ['ignore', 'pipe', 'pipe'] - }) - let killTimeout: ReturnType - - function resolveOnce(): void { - if (done) { - return - } - done = true - lines.clear() - clearTimeout(killTimeout) - // Why: child.kill() is advisory. If git ignores it, detach our - // closures so repeated relay searches do not retain old scans. - child.stdout!.off('data', handleStdoutData) - child.stderr!.off('data', handleStderrData) - child.off('error', handleError) - child.off('close', handleClose) - resolve(finalize(acc)) - } - - function processLine(line: string): void { - const verdict = ingestGitGrepLine(line, rootPath, matchRegex, acc, opts.maxResults) - if (verdict === 'stop') { - child.kill() - } - } - - function handleStdoutData(chunk: string): void { - lines.push(chunk, processLine) - } - - function handleStderrData(): void { - /* drain */ - } - - function handleError(): void { - resolveOnce() - } - - function handleClose(): void { - const tail = lines.finish() - if (tail !== null) { - processLine(tail) - } - resolveOnce() - } - - child.stdout!.setEncoding('utf-8') - child.stdout!.on('data', handleStdoutData) - child.stderr!.on('data', handleStderrData) - child.once('error', handleError) - child.once('close', handleClose) - - killTimeout = setTimeout(() => { - acc.truncated = true - child.kill() - resolveOnce() - }, SEARCH_TIMEOUT_MS) - }) -} +export { searchWithGitGrep } from './fs-handler-git-search' diff --git a/src/relay/fs-handler-git-search.ts b/src/relay/fs-handler-git-search.ts new file mode 100644 index 00000000000..c448c17d476 --- /dev/null +++ b/src/relay/fs-handler-git-search.ts @@ -0,0 +1,130 @@ +import { SearchSubprocessLineAccumulator } from '../shared/search-subprocess-lines' +import { spawnProcess } from '../shared/child-process/run-process' +import { abortSignalReason } from '../shared/abort-signal-reason' +import type { SearchOptions, SearchResult } from './fs-handler-utils' +import { + buildGitGrepArgs, + buildSubmatchRegex, + createAccumulator, + finalize, + ingestGitGrepLine, + SEARCH_TIMEOUT_MS +} from '../shared/text-search' +import { + absorbPendingRipgrepSpawnError, + killSpawnedRipgrepProcess +} from '../shared/ripgrep-process-availability' +import { buildRelayGitEnv } from './relay-command-env' + +/** + * Text search using `git grep`. Fallback when rg is not installed. + */ +export function searchWithGitGrep( + rootPath: string, + query: string, + opts: SearchOptions +): Promise { + const { signal } = opts + if (signal?.aborted) { + return Promise.reject(abortSignalReason(signal)) + } + return new Promise((resolve, reject) => { + const gitArgs = buildGitGrepArgs(query, opts) + const matchRegex = buildSubmatchRegex(query, opts) + const acc = createAccumulator() + const lines = new SearchSubprocessLineAccumulator(Number.MAX_SAFE_INTEGER) + let done = false + let processErrorObserved = false + + const child = spawnProcess({ + program: 'git', + args: gitArgs, + cwd: rootPath, + env: buildRelayGitEnv(), + stdio: ['ignore', 'pipe', 'pipe'] + }) + let killTimeout: ReturnType + + function settle(): boolean { + if (done) { + return false + } + done = true + signal?.removeEventListener('abort', onAbort) + lines.clear() + clearTimeout(killTimeout) + // Why: child.kill() is advisory. If git ignores it, detach our + // closures so repeated relay searches do not retain old scans. + child.stdout!.off('data', handleStdoutData) + child.stderr!.off('data', handleStderrData) + child.off('error', handleError) + child.off('close', handleClose) + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved: false + }) + return true + } + + function resolveOnce(): void { + if (settle()) { + resolve(finalize(acc)) + } + } + + function onAbort(): void { + if (signal && settle()) { + try { + killSpawnedRipgrepProcess(child) + } catch { + // A refused kill must still release the canceled request. + } + reject(abortSignalReason(signal)) + } + } + + function processLine(line: string): void { + const verdict = ingestGitGrepLine(line, rootPath, matchRegex, acc, opts.maxResults) + if (verdict === 'stop') { + child.kill() + } + } + + function handleStdoutData(chunk: string): void { + lines.push(chunk, processLine) + } + + function handleStderrData(): void { + /* drain */ + } + + function handleError(): void { + processErrorObserved = true + resolveOnce() + } + + function handleClose(): void { + const tail = lines.finish() + if (tail !== null) { + processLine(tail) + } + resolveOnce() + } + + child.stdout!.setEncoding('utf-8') + child.stdout!.on('data', handleStdoutData) + child.stderr!.on('data', handleStderrData) + child.once('error', handleError) + child.once('close', handleClose) + + killTimeout = setTimeout(() => { + acc.truncated = true + child.kill() + resolveOnce() + }, SEARCH_TIMEOUT_MS) + signal?.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted) { + onAbort() + } + }) +} diff --git a/src/relay/fs-handler-list-files-ignored.test.ts b/src/relay/fs-handler-list-files-ignored.test.ts index 3123d35241a..483afec1c4c 100644 --- a/src/relay/fs-handler-list-files-ignored.test.ts +++ b/src/relay/fs-handler-list-files-ignored.test.ts @@ -670,7 +670,12 @@ describe('relay quick open ignored file listing', () => { 'rg', ['--version'], // windowsHide: the probe must never flash a console window on Windows. - { env: buildRelayCommandEnv(), stdio: 'ignore', windowsHide: true } + expect.objectContaining({ + env: buildRelayCommandEnv(), + stdio: 'ignore', + windowsHide: true, + shell: false + }) ]) listProbe.emit('close', 0, null) await expect(listing).rejects.toThrow(`Search root is not reachable: ${missingRoot}`) diff --git a/src/relay/fs-handler-utils.ts b/src/relay/fs-handler-utils.ts index 8b93c6ac82e..a3b2234ced0 100644 --- a/src/relay/fs-handler-utils.ts +++ b/src/relay/fs-handler-utils.ts @@ -6,7 +6,8 @@ * so they are straightforward to test independently. */ import { SearchSubprocessLineAccumulator } from '../shared/search-subprocess-lines' -import { spawn } from 'node:child_process' +import { spawnProcess } from '../shared/child-process/run-process' +import { abortSignalReason } from '../shared/abort-signal-reason' import { open } from 'node:fs/promises' import { buildRgArgs, @@ -83,6 +84,7 @@ export type SearchOptions = { includePattern?: string excludePattern?: string maxResults: number + signal?: AbortSignal } export type SearchResult = SharedSearchResult @@ -104,6 +106,10 @@ export function searchWithRg( query: string, opts: SearchOptions ): Promise { + const { signal } = opts + if (signal?.aborted) { + return Promise.reject(abortSignalReason(signal)) + } return new Promise((resolve, reject) => { const rgArgs = buildRgArgs(query, rootPath, opts) const acc = createAccumulator() @@ -127,13 +133,14 @@ export function searchWithRg( // Why a second binding: the closures below capture it, and narrowing does not reach them. const command: string = resolvedRgCommand const env = buildRelayCommandEnv() - let child: ReturnType + let child: ReturnType try { - child = spawn(command, rgArgs, { + child = spawnProcess({ + program: command, + args: rgArgs, cwd: rootPath, env, - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true + stdio: ['ignore', 'pipe', 'pipe'] }) } catch { resolve(finalize(acc)) @@ -147,6 +154,7 @@ export function searchWithRg( return false } resolved = true + signal?.removeEventListener('abort', onAbort) lines.clear() clearTimeout(killTimeout) // Why: child.kill() is advisory over SSH; detach listeners if the @@ -162,6 +170,17 @@ export function searchWithRg( return true } + function onAbort(): void { + if (signal && settle()) { + try { + killSpawnedRipgrepProcess(child) + } catch { + // A refused kill must still release the canceled request. + } + reject(abortSignalReason(signal)) + } + } + function resolveOnce(): void { if (settle()) { resolve(finalize(acc)) @@ -172,8 +191,11 @@ export function searchWithRg( if (launchFailureCheck) { return } - launchFailureCheck = retryRipgrepOnPathAfterLaunchFailure(command, rootPath, error).then( - async (retryOnPath) => { + launchFailureCheck = retryRipgrepOnPathAfterLaunchFailure(command, rootPath, error) + .then(async (retryOnPath) => { + if (resolved) { + return + } if (retryOnPath) { // Why: a launch failure produced no output, so rerunning on PATH rg loses nothing. if (settle()) { @@ -184,19 +206,25 @@ export function searchWithRg( // Why not resolveOnce() on an unreachable root: an empty result reads as "no matches" // and the git/readdir chain never engages, because it only triggers on an unavailable // ripgrep. The workspace moving would otherwise look like a successful empty scan. + const failure = await classifyRipgrepLaunchFailure( + rootPath, + [command, pathRipgrepCommand()], + env, + signal + ) if (settle()) { reject( - (await classifyRipgrepLaunchFailure( - rootPath, - [command, pathRipgrepCommand()], - env - )) === 'cwd-unreachable' + failure === 'cwd-unreachable' ? ripgrepMissingCwdError(rootPath) : new RipgrepUnavailableError() ) } - } - ) + }) + .catch((error: unknown) => { + if (settle()) { + reject(error) + } + }) } function processLine(line: string): void { @@ -251,6 +279,10 @@ export function searchWithRg( killSpawnedRipgrepProcess(child) resolveOnce() }, SEARCH_TIMEOUT_MS) + signal?.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted) { + onAbort() + } }) } diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index 6286c71941e..36765721ef3 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -101,7 +101,7 @@ export class FsHandler { this.dispatcher.onRequest('fs.renameNoClobber', (p) => renameRelayPathNoClobber(p)) this.dispatcher.onRequest('fs.copy', (p) => copyRelayPath(p)) this.dispatcher.onRequest('fs.realpath', (p) => realpathRelayPath(p)) - this.dispatcher.onRequest('fs.search', (p) => this.search(p)) + this.dispatcher.onRequest('fs.search', (p, context) => this.search(p, context)) this.dispatcher.onRequest('fs.getCapabilities', async () => ({ quickOpenSearchVersion: 1, rangedReadVersion: 1, @@ -185,7 +185,7 @@ export class FsHandler { }) } - private async search(params: Record) { + private async search(params: Record, context?: RequestContext) { const query = params.query as string const rootPath = expandTilde(params.rootPath as string) const caseSensitive = params.caseSensitive as boolean | undefined @@ -204,7 +204,8 @@ export class FsHandler { useRegex, includePattern, excludePattern, - maxResults + maxResults, + signal: context?.signal } try { return await searchWithRg(rootPath, query, options) diff --git a/src/relay/fs-search-cancellation.test.ts b/src/relay/fs-search-cancellation.test.ts new file mode 100644 index 00000000000..329c74f07b4 --- /dev/null +++ b/src/relay/fs-search-cancellation.test.ts @@ -0,0 +1,239 @@ +import { ChildProcess } from 'node:child_process' +import type * as ChildProcessModule from 'node:child_process' +import type * as RipgrepAvailability from '../shared/ripgrep-process-availability' +import { getEventListeners } from 'node:events' +import { PassThrough } from 'node:stream' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { spawnMock, resolveCommand, retryOnPath, classifyFailure } = vi.hoisted(() => ({ + spawnMock: vi.fn(), + resolveCommand: vi.fn((): string | null => '/tools/rg'), + retryOnPath: vi.fn(async () => false), + classifyFailure: vi.fn( + async (): Promise<'cwd-unreachable' | 'ripgrep-unavailable'> => 'ripgrep-unavailable' + ) +})) +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal()), + spawn: spawnMock +})) +vi.mock('../shared/ripgrep-process-availability', async (importOriginal) => ({ + ...(await importOriginal()), + classifyRipgrepLaunchFailure: classifyFailure +})) +vi.mock('./relay-bundled-ripgrep', () => ({ + resolveRelayRipgrepCommand: resolveCommand, + pathRipgrepCommand: () => '/tools/rg', + retryRipgrepOnPathAfterLaunchFailure: retryOnPath +})) + +import { searchWithRg } from './fs-handler-utils' +import { searchWithGitGrep } from './fs-handler-git-fallback' +import { RelayDispatcher } from './dispatcher' +import { FsHandler } from './fs-handler' +import { RelayContext } from './context' +import { encodeJsonRpcFrame } from './protocol' + +function createProcess(spawned = true) { + const child = new ChildProcess() + child.stdout = new PassThrough() + child.stderr = new PassThrough() + Object.defineProperty(child, 'pid', { value: spawned ? 4321 : undefined }) + child.kill = vi.fn(() => true) + return child +} + +beforeEach(() => { + vi.useFakeTimers() + spawnMock.mockReset() + resolveCommand.mockReturnValue('/tools/rg') + retryOnPath.mockResolvedValue(false) + classifyFailure.mockResolvedValue('ripgrep-unavailable') +}) +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +describe.each([ + { name: 'ripgrep', search: searchWithRg }, + { name: 'git grep', search: searchWithGitGrep } +])('relay $name search cancellation', ({ search }) => { + it('does not start a child for an already canceled request', async () => { + const controller = new AbortController() + controller.abort() + await expect( + search('/remote/root', 'needle', { maxResults: 100, signal: controller.signal }) + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('releases a canceled search even when its child never reports an exit', async () => { + const child = createProcess() + spawnMock.mockReturnValue(child) + const controller = new AbortController() + const result = search('/remote/root', 'needle', { maxResults: 100, signal: controller.signal }) + const rejected = expect(result).rejects.toMatchObject({ name: 'AbortError' }) + child.stdout?.emit('data', 'unfinished output') + + controller.abort() + await rejected + + expect(child.kill).toHaveBeenCalledOnce() + expect(child.stdout?.listenerCount('data')).toBe(0) + expect(child.stderr?.listenerCount('data')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + expect(child.listenerCount('error')).toBe(0) + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) + expect(vi.getTimerCount()).toBe(0) + child.emit('close', 0, null) + expect(spawnMock).toHaveBeenCalledOnce() + }) + + it('removes the abort listener after normal completion', async () => { + const child = createProcess() + spawnMock.mockReturnValue(child) + const controller = new AbortController() + const result = search('/remote/root', 'needle', { maxResults: 100, signal: controller.signal }) + child.emit('close', 0, null) + await expect(result).resolves.toEqual({ files: [], totalMatches: 0, truncated: false }) + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) + controller.abort() + expect(child.kill).not.toHaveBeenCalled() + }) + + it('absorbs a queued spawn error after cancellation without signaling a missing PID', async () => { + const child = createProcess(false) + spawnMock.mockReturnValue(child) + const controller = new AbortController() + const result = search('/remote/root', 'needle', { maxResults: 100, signal: controller.signal }) + const rejected = expect(result).rejects.toMatchObject({ name: 'AbortError' }) + controller.abort() + await rejected + expect(child.kill).not.toHaveBeenCalled() + expect(() => child.emit('error', new Error('spawn ENOENT'))).not.toThrow() + expect(child.listenerCount('error')).toBe(0) + }) +}) + +it('does not retry on PATH after cancellation during launch-failure diagnosis', async () => { + let finishRetry: (retry: boolean) => void = () => undefined + retryOnPath.mockReturnValueOnce( + new Promise((resolve) => { + finishRetry = resolve + }) + ) + const child = createProcess(false) + spawnMock.mockReturnValue(child) + const controller = new AbortController() + const result = searchWithRg('/remote/root', 'needle', { + maxResults: 100, + signal: controller.signal + }) + const rejected = expect(result).rejects.toMatchObject({ name: 'AbortError' }) + child.emit('error', new Error('spawn ENOENT')) + controller.abort() + await rejected + finishRetry(true) + await Promise.resolve() + expect(spawnMock).toHaveBeenCalledOnce() +}) + +it('settles cancellation while launch-failure classification is still pending', async () => { + let finishClassification: (failure: 'ripgrep-unavailable') => void = () => undefined + classifyFailure.mockReturnValueOnce( + new Promise((resolve) => { + finishClassification = resolve + }) + ) + const child = createProcess(false) + spawnMock.mockReturnValue(child) + const controller = new AbortController() + const result = searchWithRg('/remote/root', 'needle', { + maxResults: 100, + signal: controller.signal + }) + const rejected = expect(result).rejects.toMatchObject({ name: 'AbortError' }) + child.emit('error', new Error('spawn ENOENT')) + await Promise.resolve() + controller.abort() + await rejected + finishClassification('ripgrep-unavailable') + await Promise.resolve() + expect(spawnMock).toHaveBeenCalledOnce() + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) + expect(vi.getTimerCount()).toBe(0) +}) + +describe.each(['ripgrep', 'git grep'])('relay dispatcher cancels %s', (backend) => { + it.each(['detach', 'rpc.cancel', 'dispose'])( + 'removes every search child and listener on %s', + async (cause) => { + if (backend === 'git grep') { + resolveCommand.mockReturnValue(null) + } + const children: ReturnType[] = [] + spawnMock.mockImplementation(() => { + const child = createProcess() + children.push(child) + return child + }) + const dispatcher = new RelayDispatcher(() => true) + const handler = new FsHandler(dispatcher, new RelayContext(), { + dispose: vi.fn(), + forgetRoot: vi.fn(), + subscribe: vi.fn() + }) + try { + const client = dispatcher.attachClient(() => true) + for (let id = 1; id <= 25; id++) { + dispatcher.feedClient( + client, + encodeJsonRpcFrame( + { + jsonrpc: '2.0', + id, + method: 'fs.search', + params: { rootPath: '/remote/root', query: 'needle' } + }, + id, + 0 + ) + ) + } + await Promise.resolve() + expect(children).toHaveLength(25) + if (cause === 'detach') { + dispatcher.detachClient(client) + } else if (cause === 'dispose') { + dispatcher.dispose() + } else { + for (let id = 1; id <= 25; id++) { + dispatcher.feedClient( + client, + encodeJsonRpcFrame( + { jsonrpc: '2.0', method: 'rpc.cancel', params: { id } }, + id + 25, + 0 + ) + ) + } + } + await Promise.resolve() + for (const child of children) { + expect(child.kill).toHaveBeenCalledOnce() + expect(child.stdout?.listenerCount('data')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + } + expect(spawnMock).toHaveBeenCalledTimes(25) + } finally { + handler.dispose() + dispatcher.dispose() + for (const child of children) { + child.emit('close', 0, null) + } + } + expect(vi.getTimerCount()).toBe(0) + } + ) +}) diff --git a/src/relay/fs-search-diagnosis-cancellation.test.ts b/src/relay/fs-search-diagnosis-cancellation.test.ts new file mode 100644 index 00000000000..e545c68485a --- /dev/null +++ b/src/relay/fs-search-diagnosis-cancellation.test.ts @@ -0,0 +1,190 @@ +import { ChildProcess } from 'node:child_process' +import type * as ChildProcessModule from 'node:child_process' +import type * as FsPromises from 'node:fs/promises' +import { PassThrough } from 'node:stream' +import { getEventListeners } from 'node:events' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const { spawnMock, statMock } = vi.hoisted(() => ({ spawnMock: vi.fn(), statMock: vi.fn() })) +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal()), + spawn: spawnMock +})) +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + stat: statMock, + access: vi.fn(async () => undefined) +})) +vi.mock('./relay-bundled-ripgrep', () => ({ + resolveRelayRipgrepCommand: () => '/tools/rg', + pathRipgrepCommand: () => '/tools/rg', + retryRipgrepOnPathAfterLaunchFailure: async () => false +})) +import { searchWithRg } from './fs-handler-utils' +import { classifyRipgrepLaunchFailure } from '../shared/ripgrep-process-availability' +import { searchWithGitGrep } from './fs-handler-git-search' + +function child(spawned: boolean) { + const result = new ChildProcess() + result.stdout = new PassThrough() + result.stderr = new PassThrough() + Object.defineProperty(result, 'pid', { value: spawned ? 4321 : undefined }) + result.kill = vi.fn(() => true) + return result +} +beforeEach(() => { + vi.useFakeTimers() + spawnMock.mockReset() + statMock.mockReset().mockResolvedValue({ isDirectory: () => false }) +}) +afterEach(() => vi.useRealTimers()) +it('does not start a version probe after canceled launch diagnosis resumes', async () => { + vi.useFakeTimers() + let completeStat: (value: { isDirectory: () => boolean }) => void = () => undefined + statMock.mockReturnValue( + new Promise((resolve) => { + completeStat = resolve + }) + ) + const failed = child(false) + const probe = child(true) + spawnMock.mockReturnValueOnce(failed).mockReturnValue(probe) + const controller = new AbortController() + const search = searchWithRg('/missing/root', 'needle', { + maxResults: 100, + signal: controller.signal + }) + const canceled = expect(search).rejects.toMatchObject({ name: 'AbortError' }) + failed.emit('error', new Error('spawn ENOENT')) + for (let tick = 0; tick < 5; tick += 1) { + await Promise.resolve() + } + expect(statMock).toHaveBeenCalledOnce() + controller.abort() + await canceled + completeStat({ isDirectory: () => false }) + for (let tick = 0; tick < 10; tick += 1) { + await Promise.resolve() + } + const observed = spawnMock.mock.calls.map((args) => [args[0], args[1]]) + probe.emit('close', 0) + expect(observed).toEqual([['/tools/rg', expect.not.arrayContaining(['--version'])]]) +}) + +for (const [name, search] of [ + ['rg', searchWithRg], + ['git', searchWithGitGrep] +] as const) { + it.each(['emit', 'throw'])( + `settles ${name} cancellation when kill fails via %s`, + async (mode) => { + const process = child(true) + process.kill = vi.fn(() => { + const error = Object.assign(new Error('kill EPERM'), { code: 'EPERM' }) + if (mode === 'throw') { + throw error + } + process.emit('error', error) + return false + }) + spawnMock.mockReturnValue(process) + const controller = new AbortController() + const result = search('/root', 'needle', { maxResults: 100, signal: controller.signal }) + const canceled = expect(result).rejects.toMatchObject({ name: 'AbortError' }) + expect(() => controller.abort()).not.toThrow() + await canceled + expect(process.kill).toHaveBeenCalledOnce() + expect(process.stdout?.listenerCount('data')).toBe(0) + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) + expect(vi.getTimerCount()).toBe(0) + } + ) +} + +it('terminates an active version probe and skips remaining candidates on abort', async () => { + const probe = child(true) + spawnMock.mockReturnValue(probe) + const controller = new AbortController() + const diagnosis = classifyRipgrepLaunchFailure( + '/missing/root', + ['/tools/rg', '/fallback/rg'], + {}, + controller.signal + ) + const canceled = expect(diagnosis).rejects.toMatchObject({ name: 'AbortError' }) + for (let tick = 0; tick < 5; tick += 1) { + await Promise.resolve() + } + expect(spawnMock).toHaveBeenCalledOnce() + controller.abort() + await canceled + expect(probe.kill).toHaveBeenCalledOnce() + expect(probe.listenerCount('close')).toBe(0) + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) + expect(vi.getTimerCount()).toBe(0) + probe.emit('close', 0) + expect(spawnMock).toHaveBeenCalledOnce() +}) + +it('preserves candidate fallback and cwd classification for a live caller', async () => { + const missing = child(true) + const available = child(true) + spawnMock.mockReturnValueOnce(missing).mockReturnValueOnce(available) + const controller = new AbortController() + const diagnosis = classifyRipgrepLaunchFailure( + '/missing/root', + ['/tools/rg', '/fallback/rg'], + {}, + controller.signal + ) + for (let tick = 0; tick < 5; tick += 1) { + await Promise.resolve() + } + missing.emit('close', 1) + for (let tick = 0; tick < 5; tick += 1) { + await Promise.resolve() + } + available.emit('close', 0) + await expect(diagnosis).resolves.toBe('cwd-unreachable') + expect(spawnMock.mock.calls.map(([program, args]) => [program, args])).toEqual([ + ['/tools/rg', ['--version']], + ['/fallback/rg', ['--version']] + ]) + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) + expect(vi.getTimerCount()).toBe(0) + expect(missing.kill).not.toHaveBeenCalled() + expect(available.kill).not.toHaveBeenCalled() +}) + +it.each(['emit', 'throw'])( + 'settles version-probe cancellation when kill fails via %s', + async (mode) => { + const probe = child(true) + probe.kill = vi.fn(() => { + const error = Object.assign(new Error('kill EPERM'), { code: 'EPERM' }) + if (mode === 'throw') { + throw error + } + probe.emit('error', error) + return false + }) + spawnMock.mockReturnValue(probe) + const controller = new AbortController() + const diagnosis = classifyRipgrepLaunchFailure( + '/missing/root', + ['/tools/rg', '/fallback/rg'], + {}, + controller.signal + ) + const canceled = expect(diagnosis).rejects.toMatchObject({ name: 'AbortError' }) + for (let tick = 0; tick < 5; tick += 1) { + await Promise.resolve() + } + expect(() => controller.abort()).not.toThrow() + await canceled + expect(probe.kill).toHaveBeenCalledOnce() + expect(spawnMock).toHaveBeenCalledOnce() + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) + expect(vi.getTimerCount()).toBe(0) + } +) diff --git a/src/relay/managed-hook-installer.ts b/src/relay/managed-hook-installer.ts index 3fa57dd5ed8..e88089f486a 100644 --- a/src/relay/managed-hook-installer.ts +++ b/src/relay/managed-hook-installer.ts @@ -6,7 +6,7 @@ import { import type { RelayDispatcher, RequestContext } from './dispatcher' import type { AgentHookTarget } from '../shared/agent-hook-types' import { isManagedAgentHookTarget } from '../shared/managed-agent-hook-targets' -import { parseClaudeCliVersion } from '../main/claude/claude-session-end-hook-capability' +import { parseClaudeCliVersion } from '../main/claude/claude-hook-event-versions' export type ManagedHookInstallSummary = { installers: number diff --git a/src/relay/omp-fresh-launch-environment.test.ts b/src/relay/omp-fresh-launch-environment.test.ts index 5f7cd57681f..8697368567f 100644 --- a/src/relay/omp-fresh-launch-environment.test.ts +++ b/src/relay/omp-fresh-launch-environment.test.ts @@ -91,7 +91,7 @@ it('prepares the execution host OMP config and status extension for a guarded la source.mockReturnValue(false) expect( await augment.mock.calls[1][0]({ id: 'other', shell: '/bin/bash', env: {}, command: 'codex' }) - ).toEqual({ ORCA_OPENCODE_AGENT: 'opencode' }) + ).toEqual({}) } finally { runtime.stop() dispatcher.dispose() diff --git a/src/relay/opencode-canonical-config.ts b/src/relay/opencode-canonical-config.ts index 4bcb9f3e151..38682d3f274 100644 --- a/src/relay/opencode-canonical-config.ts +++ b/src/relay/opencode-canonical-config.ts @@ -1,6 +1,7 @@ import { mkdirSync, unlinkSync, writeFileSync } from 'node:fs' import { isAbsolute, join, relative, resolve } from 'node:path' import { resolveOpenCodeConfigDirectory } from '../shared/opencode-config-directory' +import { isInstalledOpenCodePluginCurrent } from '../shared/opencode-installed-plugin' const RELAY_HOOKS_DIR = '.orca-relay' @@ -18,12 +19,14 @@ export function installOpenCodePluginInCanonicalConfig( agent === 'opencode2' ? 'orca-opencode2-status.js' : 'orca-opencode-status.js' const pluginPath = join(configDir, 'plugins', pluginFileName) mkdirSync(join(configDir, 'plugins'), { recursive: true }) - try { - unlinkSync(pluginPath) - } catch { - // The file may not exist on the first install. + if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) { + try { + unlinkSync(pluginPath) + } catch { + // The file may not exist on the first install. + } + writeFileSync(pluginPath, source) } - writeFileSync(pluginPath, source) return true } catch (err) { process.stderr.write( diff --git a/src/relay/opencode-hook-selection.test.ts b/src/relay/opencode-hook-selection.test.ts new file mode 100644 index 00000000000..336db284a47 --- /dev/null +++ b/src/relay/opencode-hook-selection.test.ts @@ -0,0 +1,188 @@ +import './mock-descendant-sweep' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beginPtyHandlerTest, endPtyHandlerTest } from './pty-handler-test-harness' +import { RelayAgentHookRuntime } from './relay-agent-hook-runtime' +import type { RelayDispatcher } from './dispatcher' +import { AGENT_HOOK_INSTALL_PLUGINS_METHOD } from '../shared/agent-hook-relay' + +const mocks = vi.hoisted(() => ({ + mockPtySpawn: vi.fn(), + mockCreateShellPromptReadinessProbe: vi.fn(), + mockPtyInstance: { + pid: process.pid, + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + clear: vi.fn(), + pause: vi.fn(), + resume: vi.fn() + } +})) +vi.mock('node-pty', () => ({ spawn: mocks.mockPtySpawn })) +vi.mock('../main/shell-prompt-readiness-probe', () => ({ + createShellPromptReadinessProbe: mocks.mockCreateShellPromptReadinessProbe +})) +vi.mock('../main/pty/posix-pty-process-groups', () => ({ + forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) +})) +vi.mock('./agent-hook-server', () => ({ + RelayAgentHookServer: class { + start = async () => {} + stop = () => {} + buildPtyEnv = () => ({ ORCA_AGENT_HOOK_PORT: '12345' }) + clearPaneState = () => {} + } +})) +let root: string +let harness: ReturnType +let runtime: RelayAgentHookRuntime +let custom: string +const plugin = (dir: string, agent: string) => join(dir, 'plugins', `orca-${agent}-status.js`) + +beforeEach(async () => { + root = mkdtempSync(join(tmpdir(), 'orca-relay-disabled-')) + custom = join(root, 'custom') + mkdirSync(custom) + writeFileSync(join(custom, 'opencode.json'), '{"model":"fixture"}') + vi.stubEnv('HOME', root) + vi.stubEnv('XDG_CONFIG_HOME', join(root, 'xdg')) + for (const key of [ + 'OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_SOURCE_CONFIG_DIR', + 'ORCA_OPENCODE_AGENT', + 'ZDOTDIR' + ]) { + vi.stubEnv(key, undefined) + } + harness = beginPtyHandlerTest(mocks) + runtime = new RelayAgentHookRuntime( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this harness implements the request registration used by the runtime; PTY and disk paths are real. + harness.dispatcher as unknown as RelayDispatcher, + harness.handler, + join(root, 'relay.sock') + ) + await runtime.start() +}) +afterEach(async () => { + runtime.stop() + await endPtyHandlerTest(harness.handler, harness.originalPlatform) + vi.unstubAllEnvs() + rmSync(root, { recursive: true, force: true }) +}) +async function install(v1: string, v2: string): Promise { + await harness.dispatcher.callRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, { + opencodePluginSource: v1, + opencode2PluginSource: v2 + }) +} +async function spawn(params: Record = {}): Promise> { + await harness.dispatcher.callRequest('pty.spawn', { + cwd: root, + shell: '/bin/bash', + cols: 80, + rows: 24, + command: 'echo fixture', + ...params + }) + return mocks.mockPtySpawn.mock.calls.at(-1)?.[2].env +} + +describe('relay OpenCode source selection on real fixture files', () => { + it('leaves a standalone relay without supplied sources unconfigured', async () => { + const env = await spawn() + expect(env.ORCA_OPENCODE_AGENT).toBeUndefined() + expect(existsSync(join(root, 'xdg', 'opencode'))).toBe(false) + }) + it.each([ + { v1: '// v1', v2: '// v2', selected: 'opencode' }, + { v1: '', v2: '// v2', selected: 'opencode2' }, + { v1: '// v1', v2: '', selected: 'opencode' }, + { v1: '', v2: '', selected: undefined } + ])('ordinary terminal with sources $v1 / $v2', async ({ v1, v2, selected }) => { + await install(v1, v2) + const env = await spawn() + expect(env.ORCA_OPENCODE_AGENT).toBe(selected) + for (const agent of ['opencode', 'opencode2']) { + expect(existsSync(plugin(join(root, 'xdg', 'opencode'), agent))).toBe(agent === selected) + } + }) + it.each(['opencode', 'opencode2'] as const)( + 'does not substitute another plugin for disabled explicit %s', + async (agent) => { + await install(agent === 'opencode' ? '' : '// v1', agent === 'opencode2' ? '' : '// v2') + for (const params of [{ command: `${agent} --session fixture` }, { launchAgent: agent }]) { + const env = await spawn(params) + expect(env.ORCA_OPENCODE_AGENT).toBeUndefined() + expect(existsSync(join(root, 'xdg', 'opencode'))).toBe(false) + } + } + ) + it('updates cached sources on one runtime and leaves old plugin files intact', async () => { + await install('// v1', '// v2') + await spawn() + const original = plugin(join(root, 'xdg', 'opencode'), 'opencode') + writeFileSync(original, '// sentinel') + await install('', '// v2') + expect((await spawn()).ORCA_OPENCODE_AGENT).toBe('opencode2') + expect(readFileSync(original, 'utf8')).toBe('// sentinel') + await install('// refreshed v1', '// v2') + expect((await spawn()).ORCA_OPENCODE_AGENT).toBe('opencode') + expect(readFileSync(original, 'utf8')).toBe('// refreshed v1') + }) + it('restores the real custom source when all OpenCode sources are revoked', async () => { + await install('// v1', '// v2') + const first = await spawn({ env: { OPENCODE_CONFIG_DIR: custom } }) + const path = plugin(first.OPENCODE_CONFIG_DIR, 'opencode') + expect(readFileSync(path, 'utf8')).toBe('// v1') + await install('', '') + const env = await spawn({ env: first }) + expect(env.OPENCODE_CONFIG_DIR).toBe(custom) + expect(env.ORCA_OPENCODE_AGENT).toBeUndefined() + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined() + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined() + expect(readFileSync(path, 'utf8')).toBe('// v1') + expect(env.ORCA_AGENT_HOOK_PORT).toBe('12345') + }) +}) + +it.each([true, false])( + 'preserves explicit config over inherited relay markers with hooks %s', + async (enabled) => { + await install(enabled ? '// v1' : '', '') + const stale = join(root, 'stale-source') + mkdirSync(stale) + writeFileSync(join(stale, 'opencode.json'), '{"model":"stale"}') + vi.stubEnv('ORCA_OPENCODE_CONFIG_DIR', join(root, 'old-overlay')) + vi.stubEnv('ORCA_OPENCODE_SOURCE_CONFIG_DIR', stale) + const env = await spawn({ env: { OPENCODE_CONFIG_DIR: custom } }) + expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'opencode.json'), 'utf8')).toBe( + '{"model":"fixture"}' + ) + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(enabled ? custom : undefined) + expect(existsSync(plugin(stale, 'opencode'))).toBe(false) + } +) + +it.each([true, false])( + 'restores legacy source-only overlay markers with hooks %s', + async (enabled) => { + await install('// v1', '') + const parent = await spawn({ env: { OPENCODE_CONFIG_DIR: custom } }) + delete parent.ORCA_OPENCODE_CONFIG_DIR + if (!enabled) { + await install('', '') + } + const env = await spawn({ env: parent }) + expect(env.OPENCODE_CONFIG_DIR).toEqual(enabled ? expect.any(String) : custom) + expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'opencode.json'), 'utf8')).toBe( + '{"model":"fixture"}' + ) + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(enabled ? custom : undefined) + } +) diff --git a/src/relay/plugin-overlay.test.ts b/src/relay/plugin-overlay.test.ts index 14feb631876..d4d38a31fba 100644 --- a/src/relay/plugin-overlay.test.ts +++ b/src/relay/plugin-overlay.test.ts @@ -1,12 +1,15 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { existsSync, + lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, + statSync, symlinkSync, + utimesSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -74,6 +77,50 @@ describe('PluginOverlayManager', () => { ).toBe('v2 plugin') }) + // Why: OpenCode 2 reloads a plugin whose file mtime changed, even with unchanged bytes. + it('leaves a current canonical plugin untouched and replaces a stale one', () => { + const env = { XDG_CONFIG_HOME: join(homeDir, 'xdg') } + const pluginPath = join(homeDir, 'xdg', 'opencode', 'plugins', 'orca-opencode2-status.js') + manager.setSources({ opencode2PluginSource: 'v2 plugin' }) + manager.installOpenCodePlugin('opencode2', env) + const past = new Date('2020-01-01T00:00:00Z') + utimesSync(pluginPath, past, past) + + expect(manager.installOpenCodePlugin('opencode2', env)).toBe(true) + expect(statSync(pluginPath).mtimeMs).toBe(past.getTime()) + + manager.setSources({ opencode2PluginSource: 'v2 plugin, next release' }) + expect(manager.installOpenCodePlugin('opencode2', env)).toBe(true) + expect(readFileSync(pluginPath, 'utf8')).toBe('v2 plugin, next release') + }) + + // Why: OpenCode 2 loads through a file-level symlink (dotfile managers) and stats its target. + it.skipIf(process.platform === 'win32')( + 'leaves a symlinked canonical plugin with current bytes untouched', + () => { + const env = { XDG_CONFIG_HOME: join(homeDir, 'xdg') } + const pluginsDir = join(homeDir, 'xdg', 'opencode', 'plugins') + const pluginPath = join(pluginsDir, 'orca-opencode2-status.js') + const targetPath = join(homeDir, 'dotfiles-orca-opencode2-status.js') + mkdirSync(pluginsDir, { recursive: true }) + writeFileSync(targetPath, 'v2 plugin') + symlinkSync(targetPath, pluginPath) + const past = new Date('2020-01-01T00:00:00Z') + utimesSync(targetPath, past, past) + manager.setSources({ opencode2PluginSource: 'v2 plugin' }) + + expect(manager.installOpenCodePlugin('opencode2', env)).toBe(true) + expect(lstatSync(pluginPath).isSymbolicLink()).toBe(true) + expect(statSync(targetPath).mtimeMs).toBe(past.getTime()) + + manager.setSources({ opencode2PluginSource: 'v2 plugin, next release' }) + expect(manager.installOpenCodePlugin('opencode2', env)).toBe(true) + expect(lstatSync(pluginPath).isFile()).toBe(true) + expect(readFileSync(pluginPath, 'utf8')).toBe('v2 plugin, next release') + expect(readFileSync(targetPath, 'utf8')).toBe('v2 plugin') + } + ) + it('mirrors a preexisting remote OpenCode config dir before adding Orca plugin', () => { const userConfigDir = join(homeDir, 'company-opencode') mkdirSync(join(userConfigDir, 'plugins'), { recursive: true }) diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts index 1c1c5200033..001309c1f93 100644 --- a/src/relay/plugin-overlay.ts +++ b/src/relay/plugin-overlay.ts @@ -79,7 +79,7 @@ function isUsableId(id: string): boolean { return typeof id === 'string' && id.length > 0 && id.length <= 1024 } export type PluginSources = { - /** Source body of `orca-opencode-status.js` to drop into /plugins/. */ + /** Empty string revokes future installs; omission preserves the cached source. */ opencodePluginSource?: string /** Source body of OpenCode 2's status plugin. */ opencode2PluginSource?: string @@ -158,7 +158,7 @@ export class PluginOverlayManager { } } hasOpenCodeSource(agent: 'opencode' | 'opencode2' = 'opencode'): boolean { - return (agent === 'opencode2' ? this.opencode2PluginSource : this.opencodePluginSource) !== null + return Boolean(agent === 'opencode2' ? this.opencode2PluginSource : this.opencodePluginSource) } hasPiSource(kind?: PiAgentKind): boolean { if (kind) { diff --git a/src/relay/preflight-handler.ts b/src/relay/preflight-handler.ts index b84166e76ba..1be1a63f9d4 100644 --- a/src/relay/preflight-handler.ts +++ b/src/relay/preflight-handler.ts @@ -39,10 +39,7 @@ const CONSERVATIVE_SYSTEM_SHELL_DIRS = new Set(['/bin', '/usr/bin']) const AGENT_PATH_PREFIX = '__ORCA_AGENT_PATH__' export class PreflightHandler { - private dispatcher: RelayDispatcher - - constructor(dispatcher: RelayDispatcher) { - this.dispatcher = dispatcher + constructor(private readonly dispatcher: RelayDispatcher) { this.registerHandlers() } @@ -53,9 +50,7 @@ export class PreflightHandler { ) } - // Why: the client sends the command list rather than importing TUI_AGENT_CONFIG - // on the relay side. This keeps the relay bundle minimal and makes the protocol - // self-describing — the relay doesn't need to know the agent catalog. + // Why: client-supplied commands keep the relay independent of the agent catalog. private async detectAgents(params: Record): Promise<{ agents: string[] versions?: Record @@ -66,9 +61,11 @@ export class PreflightHandler { } const probeCommands = [ ...new Set( - commands - .filter((command) => !isDetectionUnsupportedInRuntime(command, process.platform)) - .flatMap((command) => [command.cmd, ...(command.requiredCommands ?? [])]) + commands.flatMap((command) => + isDetectionUnsupportedInRuntime(command, process.platform) + ? [] + : [command.cmd, ...(command.requiredCommands ?? [])] + ) ) ] diff --git a/src/relay/pty-handler-agent-workspace-trust.test.ts b/src/relay/pty-handler-agent-workspace-trust.test.ts new file mode 100644 index 00000000000..3acdda6ab2e --- /dev/null +++ b/src/relay/pty-handler-agent-workspace-trust.test.ts @@ -0,0 +1,96 @@ +import './mock-descendant-sweep' +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe, mockApplyTrust } = + vi.hoisted(() => ({ + mockPtySpawn: vi.fn(), + mockCreateShellPromptReadinessProbe: vi.fn(), + mockApplyTrust: vi.fn(), + mockPtyInstance: { + pid: process.pid, + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + clear: vi.fn(), + pause: vi.fn(), + resume: vi.fn() + } + })) + +vi.mock('node-pty', () => ({ + spawn: mockPtySpawn +})) + +vi.mock('../main/pty/posix-pty-process-groups', () => ({ + forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) +})) + +vi.mock('../main/shell-prompt-readiness-probe', () => ({ + createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe +})) + +vi.mock('./agent-workspace-trust-spawn', () => ({ + applyRelayAgentWorkspaceTrust: mockApplyTrust +})) + +import type { PtyHandler } from './pty-handler' +import { beginPtyHandlerTest, endPtyHandlerTest } from './pty-handler-test-harness' +import type { MockDispatcher } from './pty-handler-test-harness' + +describe('relay pty.spawn agent workspace trust', () => { + let dispatcher: MockDispatcher + let handler: PtyHandler + let originalPlatform: PropertyDescriptor | undefined + let root: string + + beforeEach(() => { + ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({ + mockPtySpawn, + mockPtyInstance, + mockCreateShellPromptReadinessProbe + })) + mockApplyTrust.mockReset() + root = mkdtempSync(join(tmpdir(), 'orca-relay-trust-spawn-')) + }) + + afterEach(async () => { + await endPtyHandlerTest(handler, originalPlatform) + rmSync(root, { recursive: true, force: true }) + }) + + it("writes the launch's trust with its final env before the agent's process starts", async () => { + let finishTrust = (): void => {} + mockApplyTrust.mockReturnValue( + new Promise((resolve) => { + finishTrust = resolve + }) + ) + const request = { workspacePath: root } + + const spawned = dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + cwd: root, + launchAgent: 'codex', + env: { CODEX_HOME: '/remote/codex-home' }, + agentWorkspaceTrust: request + }) + await vi.advanceTimersByTimeAsync(0) + + expect(mockApplyTrust).toHaveBeenCalledWith( + request, + 'codex', + expect.objectContaining({ CODEX_HOME: '/remote/codex-home' }), + { wslShell: false } + ) + expect(mockPtySpawn).not.toHaveBeenCalled() + finishTrust() + await spawned + expect(mockPtySpawn).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/relay/pty-handler-attach-replay.test.ts b/src/relay/pty-handler-attach-replay.test.ts index 5ec55a6eb2a..571290e59cf 100644 --- a/src/relay/pty-handler-attach-replay.test.ts +++ b/src/relay/pty-handler-attach-replay.test.ts @@ -501,26 +501,10 @@ describe('PtyHandler', () => { }) describe('attachIdentityMismatches', () => { - it('rejects a paneKey collision across relay generations', () => { - // Old lease expects tab-a's pane; the reset relay's pty-1 belongs to tab-b. - expect( - attachIdentityMismatches({ paneKey: 'tab-a:0' }, { paneKey: 'tab-b:0', tabId: 'tab-b' }) - ).toBe(true) - }) - it('rejects a tabId collision when only tab identity is known', () => { expect(attachIdentityMismatches({ tabId: 'tab-a' }, { tabId: 'tab-b' })).toBe(true) }) - it('accepts a matching identity', () => { - expect( - attachIdentityMismatches( - { paneKey: 'tab-a:0', tabId: 'tab-a' }, - { paneKey: 'tab-a:0', tabId: 'tab-a' } - ) - ).toBe(false) - }) - it('stays permissive when the caller supplies no identity', () => { expect(attachIdentityMismatches({}, { paneKey: 'tab-a:0', tabId: 'tab-a' })).toBe(false) }) diff --git a/src/relay/pty-handler-output-streaming.test.ts b/src/relay/pty-handler-output-streaming.test.ts index af0a4cbdfaa..1030d98ca42 100644 --- a/src/relay/pty-handler-output-streaming.test.ts +++ b/src/relay/pty-handler-output-streaming.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' import './mock-descendant-sweep' import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { PTY_STARTUP_INGRESS_VERSION } from '../shared/pty-startup-ingress' @@ -150,6 +152,34 @@ describe('PtyHandler', () => { expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: PTY_1, data: 'hello world' }) }) + it('publishes Freebuff host status without charging synthetic OSC bytes to the PTY', async () => { + let onData: ((data: string) => void) | undefined + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + onData: vi.fn((callback: (data: string) => void) => { + onData = callback + }) + }) + await dispatcher.callRequest('pty.spawn', { launchAgent: 'freebuff', cols: 120, rows: 40 }) + const raw = readFileSync( + join(import.meta.dirname, '../main/runtime/__fixtures__/freebuff-trust.txt'), + 'utf8' + ) + onData!(raw) + vi.advanceTimersByTime(8) + expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { + id: PTY_1, + data: expect.stringContaining('"state":"blocked"'), + rawLength: raw.length, + seq: raw.length, + transformed: true + }) + dispatcher.notify.mockClear() + onData!('more output') + vi.advanceTimersByTime(8) + expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: PTY_1, data: 'more output' }) + }) + it('consumes capable startup queries before relay replay and fanout', async () => { let dataCallback: ((data: string) => void) | undefined const term = { diff --git a/src/relay/pty-handler-shell-recovery.test.ts b/src/relay/pty-handler-shell-recovery.test.ts new file mode 100644 index 00000000000..54497cd1763 --- /dev/null +++ b/src/relay/pty-handler-shell-recovery.test.ts @@ -0,0 +1,209 @@ +import './mock-descendant-sweep' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { PROCESS_BOUNDARY_GROUND } from '../shared/terminal-mode-reset-profiles' +import { RelayDispatcher, type RelayClientSessionIdentity } from './dispatcher' +import { encodeJsonRpcFrame, MessageType } from './protocol' +import { PtyHandler } from './pty-handler' +import { TEST_PTY_ID_MINT_EPOCH } from './pty-handler-test-harness' +import { RelayPtySourcePublication } from './relay-pty-source-publication' +import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' + +const { mockPtySpawn, mockConfirmShellForeground } = vi.hoisted(() => ({ + mockPtySpawn: vi.fn(), + mockConfirmShellForeground: vi.fn() +})) + +vi.mock('node-pty', () => ({ spawn: mockPtySpawn })) +vi.mock('../main/daemon/pty-subprocess/pty-shell-foreground-confirmation', () => ({ + confirmPtyShellForeground: mockConfirmShellForeground +})) + +const endpointIdentity: RelayClientSessionIdentity = { + principal: 'endpoint-principal', + authenticated: true, + allowSessionOwner: true, + authenticationKind: 'endpoint-credential' +} + +// A command that pushed kitty keyboard flags and died without popping them. +const DYING_COMMAND = '\x1b]133;C\x07\x1b[>1u' +const COMMAND_DONE = '\x1b]133;D;130\x07' +const PROMPT = '$ ' + +type Frame = { + method?: string + id?: number + params?: Record + result?: Record +} + +function requestFrame(id: number, method: string, params: Record): Buffer { + return encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0) +} + +function decode(buffer: Buffer): Frame | null { + if (buffer[0] !== MessageType.Regular) { + return null + } + const length = buffer.readUInt32BE(9) + return JSON.parse(buffer.subarray(13, 13 + length).toString('utf8')) +} + +describe.each([ + ['source-credit delivery', true], + ['legacy delivery', false] +])('PtyHandler shell recovery over %s', (_, sourceCredit) => { + let dispatcher: RelayDispatcher + let handler: PtyHandler + let writes: Buffer[] + let emitData: (data: string) => void + let emitExit: (event: { exitCode: number }) => void + let ptyId: string + let originalPlatform: PropertyDescriptor | undefined + + beforeEach(async () => { + vi.useFakeTimers() + originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + writes = [] + mockConfirmShellForeground.mockReset() + mockPtySpawn.mockReset() + mockPtySpawn.mockReturnValue({ + pid: process.pid, + onData: vi.fn((callback: (data: string) => void) => (emitData = callback)), + onExit: vi.fn((callback: (event: { exitCode: number }) => void) => (emitExit = callback)), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + clear: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + destroy: vi.fn() + }) + dispatcher = new RelayDispatcher( + (data, settle) => { + writes.push(Buffer.from(data)) + queueMicrotask(() => settle({ ok: true })) + return true + }, + { supportsWriteCallback: true, writableHighWaterMark: () => 0 }, + endpointIdentity + ) + handler = new PtyHandler(dispatcher, undefined, TEST_PTY_ID_MINT_EPOCH) + if (sourceCredit) { + let publication: RelayPtySourcePublication | undefined + const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a', undefined, (id) => + publication?.onCreditAvailable(id) + ) + publication = new RelayPtySourcePublication(dispatcher, adapter, (id) => + handler.handleSourcePublicationCapacity(id) + ) + handler.setSourcePublication(publication) + dispatcher.feed( + requestFrame(1, 'pty.openClient', { + protocolVersion: 1, + clientInstanceId: 'client-1', + requestedRole: 'session-owner', + capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 256 * 1024 } } + }) + ) + await vi.advanceTimersByTimeAsync(0) + } + dispatcher.feed(requestFrame(2, 'pty.spawn', {})) + await vi.advanceTimersByTimeAsync(0) + ptyId = String(response(2)?.id) + }) + + afterEach(async () => { + await handler.dispose({ waitForPhysicalExit: false }).catch(() => {}) + dispatcher.dispose() + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + vi.useRealTimers() + }) + + function response(id: number): Record | undefined { + return writes.map(decode).find((frame) => frame?.id === id)?.result + } + + function published(): string { + const frames = writes.map(decode).filter((frame) => frame?.method === 'pty.data') + if (sourceCredit) { + expect(frames.every((frame) => frame?.params?.sourceLengthSu !== undefined)).toBe(true) + } + return frames.map((frame) => frame?.params?.data).join('') + } + + async function replay(): Promise { + dispatcher.feed( + requestFrame(3, 'pty.attach', { + id: ptyId, + requireReplay: true, + suppressReplayNotification: true + }) + ) + await vi.advanceTimersByTimeAsync(0) + return String(response(3)?.replay ?? '') + } + + async function stream(...chunks: string[]): Promise { + for (const chunk of chunks) { + emitData(chunk) + } + await vi.advanceTimersByTimeAsync(50) + } + + it('grounds a dead command before the prompt, live and in replay alike', async () => { + mockConfirmShellForeground.mockResolvedValue(true) + await stream(DYING_COMMAND, `${COMMAND_DONE}${PROMPT}`) + + const expected = `${DYING_COMMAND}${COMMAND_DONE}${PROCESS_BOUNDARY_GROUND}${PROMPT}` + expect(mockConfirmShellForeground).toHaveBeenCalledOnce() + expect(published()).toBe(expected) + expect(await replay()).toBe(expected) + }) + + it('leaves the bytes unchanged when the shell does not own the foreground', async () => { + mockConfirmShellForeground.mockResolvedValue(false) + await stream(DYING_COMMAND, `${COMMAND_DONE}${PROMPT}`) + + const expected = `${DYING_COMMAND}${COMMAND_DONE}${PROMPT}` + expect(published()).toBe(expected) + expect(await replay()).toBe(expected) + }) + + it('delivers a dying app’s oversized final frame and the ground behind it', async () => { + mockConfirmShellForeground.mockResolvedValue(true) + const finalFrame = 'x'.repeat(20 * 1024) + await stream(DYING_COMMAND, `${finalFrame}${COMMAND_DONE}${PROMPT}`) + + const expected = `${DYING_COMMAND}${finalFrame}${COMMAND_DONE}${PROCESS_BOUNDARY_GROUND}${PROMPT}` + expect(published()).toBe(expected) + expect(await replay()).toBe(expected) + }) + + it('flushes the held prompt when the shell exits mid-proof', async () => { + mockConfirmShellForeground.mockReturnValue(new Promise(() => {})) + await stream(DYING_COMMAND, `${COMMAND_DONE}${PROMPT}`) + expect(published()).not.toContain(PROMPT) + + emitExit({ exitCode: 0 }) + await vi.advanceTimersByTimeAsync(50) + + expect(published()).toBe(`${DYING_COMMAND}${COMMAND_DONE}${PROMPT}`) + }) + + it('grounds replay, not the live stream, on Reset Terminal after an unhooked crash', async () => { + // Armed with no command end: nothing the host barrier could ground at. + const unhookedCrash = '\x1b[>1u\x1b[?1000h' + await stream(unhookedCrash, PROMPT) + + dispatcher.feed(requestFrame(4, 'pty.resetInputModes', { id: ptyId })) + await vi.advanceTimersByTimeAsync(50) + + // The client grounds its own view; a zero-raw span would not cross the credit window. + expect(published()).toBe(`${unhookedCrash}${PROMPT}`) + expect(await replay()).toBe(`${unhookedCrash}${PROMPT}${PROCESS_BOUNDARY_GROUND}`) + }) +}) diff --git a/src/relay/pty-handler-spawn-environment.test.ts b/src/relay/pty-handler-spawn-environment.test.ts index eff0f68f2fa..57f041c2b8f 100644 --- a/src/relay/pty-handler-spawn-environment.test.ts +++ b/src/relay/pty-handler-spawn-environment.test.ts @@ -336,6 +336,33 @@ describe('PtyHandler', () => { } ) + it.each(['process', 'client'])( + 'drops an ORCA_CODEX_LAUNCH_PREFLIGHT from the relay %s env', + async (source) => { + const inherited = '/opt/orca/bin/orca' + const previous = process.env.ORCA_CODEX_LAUNCH_PREFLIGHT + if (source === 'process') { + process.env.ORCA_CODEX_LAUNCH_PREFLIGHT = inherited + } + try { + await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + ...(source === 'client' ? { env: { ORCA_CODEX_LAUNCH_PREFLIGHT: inherited } } : {}) + }) + } finally { + if (previous === undefined) { + delete process.env.ORCA_CODEX_LAUNCH_PREFLIGHT + } else { + process.env.ORCA_CODEX_LAUNCH_PREFLIGHT = previous + } + } + + const spawnEnv = mockPtySpawn.mock.calls.at(-1)?.[2]?.env as Record + expect(spawnEnv.ORCA_CODEX_LAUNCH_PREFLIGHT).toBeUndefined() + } + ) + it('drops an ORCA_HISTFILE handed over in the client env', async () => { await dispatcher.callRequest('pty.spawn', { cols: 80, @@ -606,6 +633,54 @@ describe('PtyHandler', () => { expect(callArgs.env.ORCA_TAB_ID).toBe('tab-1') }) + it('mirrors pane identity onto its scrub-safe aliases for a remote spawn', async () => { + // Why the relay and not just the shared helper: a remote pane's env is built here, and an + // agent whose harness drops KEY/TOKEN names (DSH) has nothing to attribute its hooks to. + await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + env: { ORCA_PANE_KEY: 'tab-1:0', ORCA_AGENT_LAUNCH_TOKEN: 't' } + }) + + const callArgs = mockPtySpawn.mock.calls[0][2] as { env: Record } + expect(callArgs.env.ORCA_AGENT_PANE).toBe('tab-1:0') + expect(callArgs.env.ORCA_AGENT_LAUNCH).toBe('t') + expect(callArgs.env.ORCA_PANE_KEY).toBe('tab-1:0') + }) + + it("drops an alias the relay's own env carries when the spawn claims no identity", async () => { + // Why: the relay is itself startable from an Orca pane, so inheriting either name would + // attribute this pane's hooks to whichever row started the relay. + const previous = { + pane: process.env.ORCA_PANE_KEY, + launch: process.env.ORCA_AGENT_LAUNCH_TOKEN, + paneAlias: process.env.ORCA_AGENT_PANE + } + process.env.ORCA_PANE_KEY = 'relays-own-pane' + process.env.ORCA_AGENT_LAUNCH_TOKEN = 'relays-own-launch' + process.env.ORCA_AGENT_PANE = 'stale-alias' + try { + await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 }) + } finally { + for (const [key, value] of [ + ['ORCA_PANE_KEY', previous.pane], + ['ORCA_AGENT_LAUNCH_TOKEN', previous.launch], + ['ORCA_AGENT_PANE', previous.paneAlias] + ] as const) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } + + const callArgs = mockPtySpawn.mock.calls[0][2] as { env: Record } + expect(callArgs.env.ORCA_PANE_KEY).toBeUndefined() + expect(callArgs.env.ORCA_AGENT_PANE).toBeUndefined() + expect(callArgs.env.ORCA_AGENT_LAUNCH).toBeUndefined() + }) + it('passes PTY and explicit launch identity to env augmenters', async () => { const seenContexts: { id: string diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 2daa4311234..58a98785202 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -1,3 +1,5 @@ +import { FreebuffStatusProjection } from './freebuff-status-projection' +import { applyRelayAgentWorkspaceTrust } from './agent-workspace-trust-spawn' /* oxlint-disable max-lines */ import type { IPty } from 'node-pty' import { killWithDescendantSweep } from '../main/pty-descendant-termination' @@ -18,6 +20,7 @@ import { import { inspectPtyChildProcesses, processHasChildren } from './pty-child-process-inspection' import { getRelayShellLaunchConfig, isRelayWslShell } from './pty-shell-launch' import { RetiredPaneSurfaceRegistry } from './retired-pane-surfaces' +import { applyScrubSafeAgentEnvAliases } from '../shared/agent-hook-scrub-safe-env' import { addWslEnvKeys } from '../shared/wsl-env' import { ORCA_IMAGE_PROTOCOL_ENV, @@ -76,6 +79,8 @@ import { } from '../shared/pty-startup-ingress' import { resolvePtyOwnerBackend, type PtyOwnerBackend } from '../shared/pty-owner-backend' import { RecentPtyOutputBuffer } from '../main/runtime/recent-pty-output-buffer' +import { TerminalShellRecoveryBarrier } from '../main/daemon/terminal-shell-recovery-barrier' +import { confirmPtyShellForeground } from '../main/daemon/pty-subprocess/pty-shell-foreground-confirmation' import { resolveAgentForegroundProcessesBatch, resolveRemoteForegroundEvidence, @@ -202,6 +207,7 @@ function parseSourceRecoveryRequest(value: unknown): PtySourceRecoveryRequest | } type ManagedPty = { + freebuffStatus?: FreebuffStatusProjection id: string incarnationId: string pty: IPty @@ -246,6 +252,7 @@ type ManagedPty = { forceKillSent?: boolean gracefulKillSent?: boolean startupIngress?: PtyStartupIngress + recoveryBarrier?: TerminalShellRecoveryBarrier startupIngressIntent?: ReturnType ownerBackend: PtyOwnerBackend agentSessionOwners?: AgentSessionOwnerBinding[] @@ -839,6 +846,8 @@ export class PtyHandler { // pane to another worktree's history file — and wrapping a zsh pane that // nothing asked to wrap, since `history` is selected on its presence. delete result.ORCA_HISTFILE + // Why: the codex wrapper runs this path as hook prep, and a relay pane never gets one of its own. + delete result.ORCA_CODEX_LAUNCH_PREFLIGHT // Why: match local/daemon precedence so defaults/augmenters can't resurrect explicitly-removed values. for (const key of envToDelete) { delete result[key] @@ -850,6 +859,22 @@ export class PtyHandler { if (!result.TERM) { result.TERM = 'xterm-256color' } + // Why: the relay's own process env can carry pane identity (it is itself startable from + // an Orca pane), and unlike the local and daemon builders this one never dropped it. A + // spawn that specified no identity would then inherit someone else's, and every agent's + // hook would report against that pane. Drop it before mirroring, so an alias can only + // ever carry identity this spawn actually asked for. + for (const key of ['ORCA_PANE_KEY', 'ORCA_AGENT_LAUNCH_TOKEN'] as const) { + if (!rendererEnv || !Object.hasOwn(rendererEnv, key)) { + delete result[key] + } + } + // Why here and not only in the local/daemon builders: a remote pane's env is built HERE, + // and the client forwards only the canonical pane-identity names. An agent whose harness + // scrubs those names (DSH drops any env var whose name contains KEY or TOKEN) would find + // nothing to attribute its hooks to, so remote status would silently never appear even + // with the remote hook installed. + applyScrubSafeAgentEnvAliases(result) // Why last, not beside the scrubbers above: the relay runs those BEFORE envToDelete, // so an envToDelete of CONDA_PREFIX would otherwise re-create the broken pair. dropIncoherentCondaActivationEnv(result, process.platform) @@ -951,20 +976,29 @@ export class PtyHandler { this.notifyPoolListener(this.ptyPoolActiveListener, 'pty-pool-active') const emitIngressData = (emission: PtyIngressEmission): void => { const rawLength = emission.rawEndSeq - emission.rawStartSeq - this.appendReplayBuffer(managed, emission.data) + const data = managed.freebuffStatus?.project(emission.data) ?? emission.data + this.appendReplayBuffer(managed, data) this.enqueuePtyOutput( managed.id, - emission.data, - emission.transformed || rawLength !== emission.data.length + data, + emission.transformed || rawLength !== data.length ? { rawLength, seq: emission.rawEndSeq, transformed: true } : {} ) } - managed.startupIngress ??= new PtyStartupIngress({ + const isDead = (): boolean => managed.disposed === true + const recoveryBarrier = new TerminalShellRecoveryBarrier({ + confirmShellForeground: () => + confirmPtyShellForeground({ process: managed.pty, shellPath: managed.shellPath, isDead }), + release: emitIngressData, + isAlive: () => !isDead() + }) + managed.recoveryBarrier = recoveryBarrier + managed.startupIngress = new PtyStartupIngress({ ...(managed.startupIngressIntent ? { intent: managed.startupIngressIntent } : {}), ownerBackend: managed.ownerBackend, write: (data) => managed.pty.write(data), - onEmission: emitIngressData + onEmission: (emission) => recoveryBarrier.accept(emission) }) const startup = managed.startupCommand if (startup?.waitForShellReady) { @@ -1054,6 +1088,11 @@ export class PtyHandler { managed.startupCommand = undefined } managed.startupIngress?.drainAndClose() + // Why after drainAndClose: drained ingress bytes re-enter the barrier; a + // teardown mid-proof must still deliver the held prompt before exit. + managed.recoveryBarrier?.flushPending() + managed.recoveryBarrier?.dispose() + managed.freebuffStatus?.dispose() } private notifyExitListener(managed: ManagedPty): void { @@ -1082,6 +1121,7 @@ export class PtyHandler { this.dispatcher.onRequest('pty.getInitialCwd', (p) => this.getInitialCwd(p)) this.dispatcher.onRequest('pty.getSize', (p) => this.getSize(p)) this.dispatcher.onRequest('pty.clearBuffer', (p) => this.clearBuffer(p)) + this.dispatcher.onRequest('pty.resetInputModes', (p) => this.resetInputModes(p)) this.dispatcher.onRequest('pty.hasChildProcesses', (p) => this.hasChildProcesses(p)) this.dispatcher.onRequest('pty.getForegroundProcess', (p) => this.getForegroundProcess(p)) this.dispatcher.onRequest('pty.inspectProcess', (p) => this.inspectProcess(p)) @@ -1883,6 +1923,9 @@ export class PtyHandler { { id, paneKey, shell, command, launchAgent }, envToDelete ) + await applyRelayAgentWorkspaceTrust(params.agentWorkspaceTrust, launchAgent, spawnEnv, { + wslShell: isRelayWslShell(shell) + }) const worktreeId = typeof params.worktreeId === 'string' ? params.worktreeId : env?.ORCA_WORKTREE_ID const historyIsolationEnabled = params.historyIsolationEnabled === true @@ -1979,6 +2022,9 @@ export class PtyHandler { const ownerClientInstanceId = context === undefined ? null : (this.consumerIdentityResolver?.(context.clientId) ?? null) const managed: ManagedPty = { + ...(launchAgent === 'freebuff' + ? { freebuffStatus: new FreebuffStatusProjection(cols, rows) } + : {}), id, incarnationId: randomUUID(), pty: term, @@ -2225,6 +2271,7 @@ export class PtyHandler { // npm, where the patch is not applied. So the catch below stays. try { managed.pty.resize(cols, rows) + managed.freebuffStatus?.resize(cols, rows) } catch (err) { // A failed ioctl observed the handle, not the host's process table, so on // its own it is `unverifiable`. Re-probe: a now-absent pid retires the @@ -2646,6 +2693,15 @@ export class PtyHandler { } } + // Why the replay buffer and not the stream: a zero-raw span never crosses the + // credit window, and the client grounds its own view; reattach replays this. + private async resetInputModes(params: Record): Promise { + const managed = this.ptys.get(params.id as string) + if (managed?.recoveryBarrier && !managed.disposed) { + this.appendReplayBuffer(managed, managed.recoveryBarrier.groundInputModes()) + } + } + private async hasChildProcesses(params: Record): Promise { const id = params.id as string const managed = this.ptys.get(id) diff --git a/src/relay/pty-shell-overlay-wrappers.ts b/src/relay/pty-shell-overlay-wrappers.ts index 376d422f073..55251883661 100644 --- a/src/relay/pty-shell-overlay-wrappers.ts +++ b/src/relay/pty-shell-overlay-wrappers.ts @@ -1,6 +1,7 @@ import { readFileSync, statSync } from 'node:fs' import { join } from 'node:path' import { getPosixOmpShellWrapper } from '../main/pty/omp-shell-wrapper' +import { getPosixCodexShellLaunchPreflight } from '../main/pty/codex-shell-launch-preflight' import { BASH_FEATURE_CHANNEL_BLOCK, BASH_PROMPT_COMMAND_COMPOSITION_BLOCK, @@ -32,10 +33,10 @@ function getRelayZshWrapperSpec(): ZshStartupHookSpec { overlayRestoreComment: '# Why: remote startup files can re-export user defaults after relay spawn.', restores: { + managedWslCli: false, agentTeamsPath: false, remoteCliBinDir: true, - codexHome: false, - codexLaunchPreflight: false + codexHome: false } } } @@ -73,7 +74,7 @@ fi [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" [[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac ${getPosixOmpShellWrapper()} -${BASH_HISTFILE_RESTORE_BLOCK} +${getPosixCodexShellLaunchPreflight()}${BASH_HISTFILE_RESTORE_BLOCK} # Why: SSH bash sessions need the same command lifecycle markers as local # bash so agent rows stop showing "working" when the foreground command exits. __orca_initializing_wrapper=1 diff --git a/src/relay/relay-agent-hook-runtime.ts b/src/relay/relay-agent-hook-runtime.ts index 7ab4bf01162..bc1cef97137 100644 --- a/src/relay/relay-agent-hook-runtime.ts +++ b/src/relay/relay-agent-hook-runtime.ts @@ -19,8 +19,9 @@ import { isPiCompatibleAgentType } from '../shared/pi-agent-kind' import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing' -import { isOpenCode2LaunchCommand } from '../shared/opencode-launch-command' +import { selectOpenCodeHookAgent } from '../shared/opencode-launch-command' import { relayLogLine } from './relay-diagnostic-log' +import { restoreOrStripOverlayEnv } from '../shared/agent-overlay-env' import { registerManagedHookInstaller } from './managed-hook-installer' export class RelayAgentHookRuntime { @@ -85,12 +86,22 @@ export class RelayAgentHookRuntime { const env: Record = {} const overlayId = context.paneKey ?? context.id const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(context.env, context.command) - const opencodeAgent = - context.launchAgent === 'opencode2' || isOpenCode2LaunchCommand(launchCommandHint) - ? 'opencode2' - : 'opencode' - env.ORCA_OPENCODE_AGENT = opencodeAgent - if (this.pluginOverlay.hasOpenCodeSource(opencodeAgent)) { + const opencodeAgent = selectOpenCodeHookAgent(context.launchAgent, launchCommandHint, (agent) => + this.pluginOverlay.hasOpenCodeSource(agent) + ) + restoreOrStripOverlayEnv( + context.env, + { + primary: 'OPENCODE_CONFIG_DIR', + overlay: 'ORCA_OPENCODE_CONFIG_DIR', + source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR', + preserveExplicitPrimary: true + }, + {} + ) + delete context.env.ORCA_OPENCODE_AGENT + if (opencodeAgent) { + env.ORCA_OPENCODE_AGENT = opencodeAgent const sourceDir = resolveOpenCodeSourceConfigDir(context.env, context.shell) const inheritedRelayOverlay = sourceDir ? this.pluginOverlay.isRelayOverlayPath(sourceDir) diff --git a/src/relay/relay-watch-root-capacity-gate.ts b/src/relay/relay-watch-root-capacity-gate.ts index 4e430332be2..c275e303507 100644 --- a/src/relay/relay-watch-root-capacity-gate.ts +++ b/src/relay/relay-watch-root-capacity-gate.ts @@ -63,11 +63,19 @@ export class RelayWatchRootCapacityGate { this.waiting.add(rootKey) // Once, and never past the caller: a genuinely full cap must still reach the refusal that sends // the client dormant, and an unsubscribe that never settles must not park the request with it. - return (signal ? Promise.race([released, abortSignalSettled(signal)]) : released).finally( - () => { - this.waiting.delete(rootKey) + let onAbort = (): void => {} + const abandoned = new Promise((resolve) => { + onAbort = () => resolve() + if (signal?.aborted) { + resolve() + } else { + signal?.addEventListener('abort', onAbort, { once: true }) } - ) + }) + return (signal ? Promise.race([released, abandoned]) : released).finally(() => { + signal?.removeEventListener('abort', onAbort) + this.waiting.delete(rootKey) + }) } /** Setup roots that currently hold a slot — a parked capacity waiter holds none. */ @@ -75,12 +83,3 @@ export class RelayWatchRootCapacityGate { return [...this.setupRoots.keys()].filter((key) => key === rootKey || !this.waiting.has(key)) } } - -/** Resolves (never rejects) when the request is abandoned, so a race can drop out of a wait. */ -function abortSignalSettled(signal: AbortSignal): Promise { - return signal.aborted - ? Promise.resolve() - : new Promise((resolve) => - signal.addEventListener('abort', () => resolve(), { once: true }) - ) -} diff --git a/src/relay/relay-watch-root-capacity.test.ts b/src/relay/relay-watch-root-capacity.test.ts index de5a5fc2b4b..12f70234c1d 100644 --- a/src/relay/relay-watch-root-capacity.test.ts +++ b/src/relay/relay-watch-root-capacity.test.ts @@ -8,6 +8,7 @@ import type { RelayDispatcher } from './dispatcher' import { FsHandler } from './fs-handler' import { subscribeWithInProcessWatcher } from '../main/ipc/parcel-watcher-in-process-fallback' import { createMockDispatcher } from './relay-fs-test-dispatcher' +import { RelayWatchRootCapacityGate } from './relay-watch-root-capacity-gate' const { mockSubscribe } = vi.hoisted(() => ({ mockSubscribe: vi.fn() @@ -39,6 +40,50 @@ describe('relay watch-root capacity', () => { await fs.rm(tmpDir, { recursive: true, force: true }) }) + it('removes an abort listener after capacity teardown settles', async () => { + const activeRoots = new Map(Array.from({ length: 20 }, (_, index) => [`active-${index}`, {}])) + let resolveTeardown!: () => void + const teardown = new Promise((resolve) => { + resolveTeardown = resolve + }) + const gate = new RelayWatchRootCapacityGate(activeRoots, new Map(), () => ({ + rootPaths: () => ['retiring'], + settlePending: () => teardown + })) + const controller = new AbortController() + const addListener = vi.spyOn(controller.signal, 'addEventListener') + const removeListener = vi.spyOn(controller.signal, 'removeEventListener') + + const waiting = gate.release('new-root', controller.signal) + expect(waiting).toBeDefined() + expect(addListener).toHaveBeenCalledTimes(1) + resolveTeardown() + await waiting + + expect(removeListener).toHaveBeenCalledTimes(1) + }) + + it.each([false, true])( + 'resolves abandoned capacity waits (already aborted=%s)', + async (alreadyAborted) => { + const activeRoots = new Map(Array.from({ length: 20 }, (_, index) => [`active-${index}`, {}])) + const gate = new RelayWatchRootCapacityGate(activeRoots, new Map(), () => ({ + rootPaths: () => ['retiring'], + settlePending: () => new Promise(() => {}) + })) + const controller = new AbortController() + if (alreadyAborted) { + controller.abort() + } + const removeListener = vi.spyOn(controller.signal, 'removeEventListener') + const waiting = gate.release('new-root', controller.signal) + expect(waiting).toBeDefined() + controller.abort() + await expect(waiting).resolves.toBeUndefined() + expect(removeListener).toHaveBeenCalledTimes(1) + } + ) + it('blocks replacement watches behind physical unsubscribe and counts the pending slot', async () => { let resolveUnsubscribe: () => void = () => {} const unsubscribe = vi.fn( diff --git a/src/relay/relay-watcher-event-emitter.test.ts b/src/relay/relay-watcher-event-emitter.test.ts index 0f0991e5c01..82b10e2fdf3 100644 --- a/src/relay/relay-watcher-event-emitter.test.ts +++ b/src/relay/relay-watcher-event-emitter.test.ts @@ -1,5 +1,3 @@ -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' import { describe, expect, it, vi } from 'vitest' import type { WatcherProcessEvent } from '../main/ipc/parcel-watcher-process-protocol' import { RelayDispatcher } from './dispatcher' @@ -200,13 +198,6 @@ describe('relay watcher writer admission', () => { }) describe('relay watcher overflow suppression key', () => { - it('keeps the source free of NUL bytes so git and grep still see text', () => { - const source = readFileSync( - fileURLToPath(new URL('./relay-watcher-event-emitter.ts', import.meta.url)) - ) - expect(source.includes(0)).toBe(false) - }) - it('scopes the outstanding marker per client as well as per root', () => { const primary = createRecordingSink(65536) const secondary = createRecordingSink(65536) diff --git a/src/renderer/src/app-shell/use-app-session-persistence.ts b/src/renderer/src/app-shell/use-app-session-persistence.ts index e187ecb19d5..4d1e9987a09 100644 --- a/src/renderer/src/app-shell/use-app-session-persistence.ts +++ b/src/renderer/src/app-shell/use-app-session-persistence.ts @@ -4,6 +4,7 @@ import { isDirectSshRemoteWorkspaceApplyInProgress, onDirectSshRemoteWorkspaceApplyWindowClosed } from '../hooks/remote-workspace-snapshot-apply' +import { terminalLayoutNodeEqual } from '../lib/terminal-layout-equality' import { createSessionWriteSubscriber } from '../lib/session-write-subscriber' import { buildActiveViewUnloadPatch } from '../lib/active-view-persist' import { @@ -37,6 +38,8 @@ import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../../../shared/renderer-shutdown-events' import type { AppState } from '../store/types' +import type { DirectSshLayoutEdit } from '../store/terminals/terminal-state' +import type { RemoteWorkspaceObservedPatchResult } from '../../../shared/remote-workspace-types' import { applyRemoteWorkspacePushStatus } from '../hooks/remote-workspace-push-status' // Why: bound the resume-record loss window on a hard kill to ~1 min; capture skips unchanged records so per-tick cost is negligible. @@ -89,6 +92,26 @@ function remoteWorkspaceUploadAuthorityIsCurrent( ) } +function captureUploadedDirectSshLayoutEdits( + pendingLayoutEdits: AppState['pendingDirectSshLayoutEditsByTabId'], + targetId: string, + result: RemoteWorkspaceObservedPatchResult | undefined +): Record { + if (!result?.ok) { + return {} + } + return Object.fromEntries( + Object.entries(pendingLayoutEdits).flatMap(([tabId, entry]) => { + const uploaded = result.snapshot.session.terminalLayoutsByTabId[tabId] + return entry.targetId === targetId && + uploaded && + terminalLayoutNodeEqual(entry.root, uploaded.root) + ? [[tabId, entry]] + : [] + }) + ) +} + /** * Writes durable renderer session state to disk: the debounced per-host writer, the remote * workspace upload chain, and the synchronous shutdown checkpoint. @@ -108,6 +131,7 @@ export function useAppSessionPersistence(): void { const localWrite = patchWorkspaceSessionByHost(window.api.session, patch, state) void localWrite const uploadAuthorities = captureRemoteWorkspaceUploadAuthorities(state) + const pendingLayoutEdits = state.pendingDirectSshLayoutEditsByTabId if (uploadAuthorities.length > 0) { void (async () => { try { @@ -141,6 +165,11 @@ export function useAppSessionPersistence(): void { for (const { targetId, result } of results ?? []) { const authority = currentAuthorityByTargetId.get(targetId) if (authority && remoteWorkspaceUploadAuthorityIsCurrent(resultState, authority)) { + if (result?.ok) { + resultState.acknowledgeDirectSshLayoutEdits( + captureUploadedDirectSshLayoutEdits(pendingLayoutEdits, targetId, result) + ) + } applyRemoteWorkspacePushStatus(resultState, targetId, result, authority) } } diff --git a/src/renderer/src/app-shell/use-floating-workspace-panel.ts b/src/renderer/src/app-shell/use-floating-workspace-panel.ts index 49daaec55d8..7cd3763e401 100644 --- a/src/renderer/src/app-shell/use-floating-workspace-panel.ts +++ b/src/renderer/src/app-shell/use-floating-workspace-panel.ts @@ -1,4 +1,11 @@ -import { useCallback, useEffect, useRef, useState, type SetStateAction } from 'react' +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type SetStateAction +} from 'react' import { TOGGLE_FLOATING_TERMINAL_EVENT, requestFloatingTerminalOpenMaximized @@ -111,7 +118,8 @@ export function useFloatingWorkspacePanel() { setOpenWithFocus(true) }, [setOpenWithFocus]) - useEffect(() => { + // Why layout: enable-then-toggle callers dispatch a frame later, which a passive rebind can miss. + useLayoutEffect(() => { const toggleFloatingTerminal = (): void => { if (enabled) { setOpenWithFocus((current) => !current) diff --git a/src/renderer/src/app-shell/use-persisted-ui-writer.ts b/src/renderer/src/app-shell/use-persisted-ui-writer.ts index 19109c25eb2..168fb87f302 100644 --- a/src/renderer/src/app-shell/use-persisted-ui-writer.ts +++ b/src/renderer/src/app-shell/use-persisted-ui-writer.ts @@ -161,6 +161,7 @@ export function usePersistedUIWriter(): void { hideDetachedHeadWorkspaces: s.hideDetachedHeadWorkspaces, hideWorkspacesFromOtherDevices: s.hideWorkspacesFromOtherDevices, alwaysShowDefaultBranchWorkspace: s.alwaysShowDefaultBranchWorkspace, + explorerDisplayRootByWorktree: s.explorerDisplayRootByWorktree, showDotfilesByWorktree: s.showDotfilesByWorktree, filterRepoIds: s.filterRepoIds, // Why: dashboard auto-acks (fire on focus/visibility) and the in-memory ack cleanup diff --git a/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx b/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx index 314977a17aa..f5d404b4e10 100644 --- a/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx +++ b/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx @@ -6,16 +6,11 @@ // authority (main) = real updatePersistedUI/getPersistedUI merge // broadcast = controlled queue modeling the async ui:stateChanged IPC send // mobile client = mobile/src/worktree/workspace-view-settings mapping; the ui.set -// payload uses the shipping buildWorkspaceViewSettingsUpdate when -// exported, else the legacy whole-snapshot shape — the source-pin -// test asserts use-host-view-settings.ts matches whichever path is -// active, so the model stays tethered to shipping code. +// payload mirrors the shipping buildWorkspaceViewSettingsUpdate. // // Invariant: when two independently identified clients change DISJOINT workspace-view // fields concurrently or across stale-mirror windows, both changes survive and all // mirrors converge; no client may restore a stale sibling field. -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { StrictMode, act, createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -84,8 +79,7 @@ function createAuthority() { type Authority = ReturnType // Local model of mobile/src/worktree/workspace-view-settings.ts (the desktop test -// runner cannot transform Expo-configured sources). The source-pin test at the -// bottom fails if the shipping module stops matching this model. +// runner cannot transform Expo-configured sources). type MobileViewState = { groupMode: 'none' | 'workspaceStatus' | 'repo' | 'prStatus' sortMode: 'smart' | 'name' | 'recent' | 'repo' | 'manual' @@ -96,10 +90,6 @@ type MobileViewState = { collapsedGroups: string[] } -function mobileHasPatchOnlyBuilder(): boolean { - return readMobileViewSettingsSource().includes('export function buildWorkspaceViewSettingsUpdate') -} - /** Mirrors buildWorkspaceViewSettingsUpdate (candidate) — only touched fields. */ function patchOnlyUpdate( patch: Partial, @@ -127,18 +117,6 @@ function patchOnlyUpdate( return update } -/** Mirrors the pre-fix persistViewSettings payload — the full snapshot on every tap. */ -function legacyWholeSnapshotUpdate(next: MobileViewState): Partial { - return { - groupBy: next.groupMode === 'workspaceStatus' ? 'workspace-status' : 'repo', - sortBy: next.sortMode, - hideSleepingWorkspaces: next.hideSleeping, - hideDefaultBranchWorkspace: next.hideDefaultBranch, - filterRepoIds: next.filterRepoIds, - collapsedGroups: next.collapsedGroups - } -} - /** Model of the mobile host screen's view-settings client (persistViewSettings et al.). */ function createMobileClient(authority: Authority) { let view: MobileViewState = { @@ -171,28 +149,12 @@ function createMobileClient(authority: Authority) { /** A user tap: apply locally, then push through the shipping payload shape. */ tap(patch: Partial) { view = { ...view, ...patch } - const payload = mobileHasPatchOnlyBuilder() - ? patchOnlyUpdate(patch, view) - : legacyWholeSnapshotUpdate(view) + const payload = patchOnlyUpdate(patch, view) authority.set(omitPairingLocalUiFields(payload) as Partial) } } } -function readMobileViewSettingsHookSource(): string { - return readFileSync( - join(__dirname, '../../../../mobile/src/host-screen/use-host-view-settings.ts'), - 'utf-8' - ) -} - -function readMobileViewSettingsSource(): string { - return readFileSync( - join(__dirname, '../../../../mobile/src/worktree/workspace-view-settings.ts'), - 'utf-8' - ) -} - describe('workspace view preferences: cross-client persistence (STA-5781)', () => { let authority: Authority let store: StoreApi @@ -710,35 +672,4 @@ describe('workspace view preferences: cross-client persistence (STA-5781)', () = expect(authority.get().hideDefaultBranchWorkspace).toBe(false) expect(store.getState().hideDefaultBranchWorkspace).toBe(false) }) - - it('pins the modeled mobile ui.set payload to the shipping source', async () => { - const source = readMobileViewSettingsHookSource() - if (mobileHasPatchOnlyBuilder()) { - // Candidate: the persistence hook must push through the patch-only builder this model uses. - expect(source).toContain('buildWorkspaceViewSettingsUpdate(patch, next)') - const builderSource = readMobileViewSettingsSource() - for (const guard of [ - "if ('groupMode' in patch)", - "if ('sortMode' in patch)", - "if ('hideSleeping' in patch)", - "if ('hideDefaultBranch' in patch)", - "if ('filterRepoIds' in patch)", - "if ('collapsedGroups' in patch)" - ]) { - expect(builderSource).toContain(guard) - } - } else { - // Baseline: persistViewSettings pushes exactly this whole-snapshot payload. - for (const key of [ - 'groupBy: groupModeToDesktop(next.groupMode)', - 'sortBy: next.sortMode', - 'hideSleepingWorkspaces: next.hideSleeping', - 'hideDefaultBranchWorkspace: next.hideDefaultBranch', - 'filterRepoIds: next.filterRepoIds', - 'collapsedGroups: next.collapsedGroups' - ]) { - expect(source).toContain(key) - } - } - }) }) diff --git a/src/renderer/src/assets/diff-comment-draft-card-shadow-style.test.ts b/src/renderer/src/assets/diff-comment-draft-card-shadow-style.test.ts new file mode 100644 index 00000000000..3a28acf95c6 --- /dev/null +++ b/src/renderer/src/assets/diff-comment-draft-card-shadow-style.test.ts @@ -0,0 +1,39 @@ +import fs from 'node:fs' +import { describe, expect, it } from 'vitest' + +const mainCss = fs.readFileSync(new URL('./main.css', import.meta.url), 'utf8') + +function getCssRuleBody(selector: string): string { + const ruleMarker = mainCss.indexOf(`\n${selector} {`) + expect(ruleMarker).toBeGreaterThanOrEqual(0) + + const ruleStart = ruleMarker + 1 + const bodyStart = mainCss.indexOf('{', ruleStart) + 1 + const bodyEnd = mainCss.indexOf('}', bodyStart) + return mainCss.slice(bodyStart, bodyEnd) +} + +describe('diff comment draft card shadow', () => { + it('uses the documented shadow-xs tier instead of a hand-rolled fourth tier', () => { + const draftCard = getCssRuleBody('.orca-diff-comment-inline > .orca-diff-comment-draft-card') + + // STYLEGUIDE.md caps elevation at border / shadow-xs / shadow-floating — + // no invented per-component shadow values. + expect(draftCard).toContain('shadow-xs') + expect(draftCard).not.toMatch(/box-shadow:\s*\n?\s*0/) + expect(draftCard).not.toContain('rgba(0, 0, 0,') + }) + + it('keeps the dark override at shadow-xs, not a hand-rolled or missing shadow', () => { + const darkDraftCard = getCssRuleBody( + '.dark .orca-diff-comment-inline > .orca-diff-comment-draft-card' + ) + + // Same selector specificity as `.dark .orca-diff-comment-popover` (its + // ancestor via the shared draft-card component), which sits later in the + // file — dropping this rule lets that popover's much larger floating + // shadow win the cascade in dark mode instead of shadow-xs. + expect(darkDraftCard).toContain('shadow-xs') + expect(darkDraftCard).not.toContain('rgba(0, 0, 0,') + }) +}) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index e0d1791606b..2d999cc48db 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -409,7 +409,14 @@ [data-sonner-toaster] { font-family: var(--font-sans); - z-index: 40 !important; + z-index: var(--toaster-z-index, 40) !important; +} + +/* Keep errors above standard modal backdrops at the existing popover tier. + Why: overlays portal straight into body, and `>` plus a :has() on body alone (not in the toaster's + selector) keeps unrelated DOM changes from rescanning the page; the old form froze large diffs. */ +body:has(> [data-slot='dialog-overlay'], > [data-slot='sheet-overlay']) { + --toaster-z-index: 60; } [data-sonner-toaster] [data-sonner-toast][data-styled='true'] { @@ -719,7 +726,9 @@ mask-image: linear-gradient(to right, transparent, #000 1.5rem, #000 100%); } -.terminal-tab-strip--fade-end:not(.terminal-tab-strip--fade-start) { +/* Why: the status bar's usage cluster reuses the end fade so a clipped cluster never cuts a glyph. */ +.terminal-tab-strip--fade-end:not(.terminal-tab-strip--fade-start), +.status-bar-usage-cluster[data-overflowing='true'] { -webkit-mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 1.5rem), transparent); mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 1.5rem), transparent); } @@ -1619,6 +1628,31 @@ html.native-shell .app-layout { animation: compact-agent-expansion-reveal 180ms cubic-bezier(0.16, 1, 0.3, 1) both; } +/* Keep the disclosure in the timestamp slot without moving adjacent content. */ +.agent-child-disclosure-time { + visibility: hidden; +} + +@media (hover: hover) { + .agent-child-disclosure[data-expanded='false'] .agent-child-disclosure-time { + visibility: visible; + } + + .agent-child-disclosure[data-expanded='false'] [data-agent-child-disclosure-button] { + opacity: 0; + } + + .agent-disclosure-row:hover .agent-child-disclosure-time, + .agent-disclosure-row:focus-within .agent-child-disclosure-time { + visibility: hidden; + } + + .agent-disclosure-row:hover [data-agent-child-disclosure-button], + .agent-disclosure-row:focus-within [data-agent-child-disclosure-button] { + opacity: 1; + } +} + /* Why: the collapsed summary still uses pill spacing, while expanded compact agents read as a quiet tree inside the existing worktree card. */ .compact-agent-summary-panel { @@ -2858,6 +2892,39 @@ html.native-shell .app-layout { max-width: 420px; } +/* Why: embedded card in the diff flow, not a floating popup — use the + documented "subtle lift" tier (shadow-xs) rather than a hand-rolled shadow. + Restated under `.dark` (same value) so this still outranks the base + `.orca-diff-comment-popover` dark shadow at equal selector specificity. */ +.orca-diff-comment-inline > .orca-diff-comment-draft-card { + position: relative; + left: auto; + right: auto; + width: 100%; + max-width: 420px; + z-index: 1; + @apply shadow-xs; +} + +.dark .orca-diff-comment-inline > .orca-diff-comment-draft-card { + @apply shadow-xs; +} + +.orca-diff-comment-draft-margin { + width: 100%; + height: 100%; + display: flex; + justify-content: flex-end; +} + +.orca-diff-comment-draft-margin::after { + content: ''; + width: 3px; + height: 100%; + background: var(--primary); + border-radius: 1px; +} + /* Why: the saved note used to blend into the editor background because it reused the same muted/background mix the editor itself uses. Stay grayscale to match the rest of the UI, but lift the card off the editor diff --git a/src/renderer/src/components/AgentChildRowContent.tsx b/src/renderer/src/components/AgentChildRowContent.tsx new file mode 100644 index 00000000000..717b8438a0b --- /dev/null +++ b/src/renderer/src/components/AgentChildRowContent.tsx @@ -0,0 +1,58 @@ +import React from 'react' +import type { AgentChildRowModel } from '../../../shared/agent-child-row-model' +import { AgentStateDot } from '@/components/AgentStateDot' +import type { StateIndicatorTooltipSide } from '@/components/StateIndicatorTooltip' +import { agentChildRowText } from './agent-child-row-text' + +type AgentChildRowContentProps = { + row: AgentChildRowModel + now: number + /** Surface tone for the name and the detail; the words themselves are the row's. */ + leadClassName: string + trailClassName: string + separator: string + /** Overrides the dot's hover label; null suppresses it for an existing tooltip. */ + dotTitle?: string | null + tooltipSide?: StateIndicatorTooltipSide + /** Name the whole line on hover, for surfaces that truncate it tightly. */ + lineTitle?: boolean +} + +/** + * A child row's state dot, name and detail: the one piece the sidebar's child rows and the chat + * strip's rows both render, so the same child reads the same on both. + */ +export const AgentChildRowContent = React.memo(function AgentChildRowContent({ + row, + now, + leadClassName, + trailClassName, + separator, + dotTitle, + tooltipSide, + lineTitle = false +}: AgentChildRowContentProps): React.JSX.Element { + const { lead, trail } = agentChildRowText(row, now) + return ( + <> + + + {lead} + {trail ? ( + + {separator} + {trail} + + ) : null} + + + ) +}) diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts index 33642542d79..a767a61ccf4 100644 --- a/src/renderer/src/components/AgentStateDot.test.ts +++ b/src/renderer/src/components/AgentStateDot.test.ts @@ -47,15 +47,9 @@ describe('AgentStateDot', () => { it('renders working as a yellow spinner', () => { const markup = renderMarkup('working') - expect(markup).toContain('border-yellow-500') - expect(markup).toContain('border-t-transparent') - // Why: rotation must come from the compositor-driven CSS animation, not a - // JS clock writing per-element styles on the input thread (STA-3328). - expect(markup).toContain('agent-working-spinner') + // The spinner's own classes and animation contract belong to AgentWorkingSpinner.test.tsx; + // this pins only that 'working' reaches for it. expect(markup).toContain('data-agent-spinner') - // Why: under reduced motion the top border is filled so the static ring - // reads as a complete marker, not a broken partial spinner (#9515). - expect(markup).toContain('motion-reduce:border-t-yellow-500') }) it('renders monitoring as a static yellow heartbeat glyph', () => { diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx index 11f2bddc122..1a139b18d26 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx @@ -12,6 +12,7 @@ import { TooltipProvider } from './ui/tooltip' import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' import { consumeNativeChatResumeOnRestartDialogRequest, + getNativeChatResumeOnRestartDialogRequest, requestNativeChatResumeOnRestartDialog } from './native-chat-resume-on-restart-dialog' import { @@ -271,6 +272,103 @@ it('never re-offers a resumed chat when the status entry reopens the dialog', as expect(document.querySelectorAll('[role="checkbox"]')).toHaveLength(2) }) +// The resume outlives the dialog, as a skill update does: the status bar carries it while in flight. +it('closes on Resume and shows the resume in the status bar until the host answers', async () => { + const continued = Promise.withResolvers() + rpc.mockImplementation((_target, method) => + method === 'agentSession.restartResumable' + ? Promise.resolve({ sessions: offered }) + : continued.promise + ) + await mount( + <> + + + + ) + await act(async () => button('Resume 2 chats').click()) + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(button('Resuming 2 chats. Click to open details.').textContent).toBe('Resuming 2 chats') + // Counted once, as in flight, not also as still to resume. + expect(document.body.textContent).not.toContain('chats to resume') + + // Reopening mid-run shows the run, without a re-read that could race the host's answer. + const reads = rpc.mock.calls.length + await act(async () => button('Resuming 2 chats').click()) + expect(rpc.mock.calls.length).toBe(reads) + expect(button('Resuming…').disabled).toBe(true) + + await act(async () => + continued.resolve({ + resumed: offered.map(({ sessionId }) => ({ sessionId, outcome: 'resumed' })), + continued: offered.map(({ sessionId }) => ({ sessionId, outcome: 'continued' })), + sessions: [] + }) + ) + expect(document.body.textContent).not.toContain('Resuming') + expect(document.querySelector('[role="dialog"]')).toBeNull() + // Nothing is left to show, so the reopen request is retired rather than left to latch. + expect(getNativeChatResumeOnRestartDialogRequest()).toBe(false) + expect(toast).toHaveBeenCalledWith('Resumed 2 chats and asked them to continue') +}) + +// A dialog the user reopened mid-run is theirs: the run's answer must not close it over a chat +// they left out of the resume and can now act on. +it('keeps a dialog reopened mid-resume open over the chats still offered', async () => { + const third = { ...offered[1]!, sessionId: 'c', latestPrompt: 'Prompt c' } + const continued = Promise.withResolvers() + rpc.mockImplementation((_target, method) => + method === 'agentSession.restartResumable' + ? Promise.resolve({ sessions: [...offered, third] }) + : continued.promise + ) + await mount( + <> + + + + ) + await act(async () => checkbox(2).click()) + await act(async () => button('Resume 2 chats').click()) + expect(document.querySelector('[role="dialog"]')).toBeNull() + await act(async () => button('1 chat to resume').click()) + expect(document.querySelector('[role="dialog"]')).not.toBeNull() + // Mid-run the ticks say what is running, so the chat left out reads as left out. + const rowC = () => document.querySelector('[role="checkbox"][aria-label*="Prompt c"]') + expect(checkbox(0).getAttribute('data-state')).toBe('checked') + expect(checkbox(1).getAttribute('data-state')).toBe('checked') + expect(rowC()?.getAttribute('data-state')).toBe('unchecked') + + await act(async () => + continued.resolve({ + resumed: offered.map(({ sessionId }) => ({ sessionId, outcome: 'resumed' })), + continued: offered.map(({ sessionId }) => ({ sessionId, outcome: 'continued' })), + sessions: [third] + }) + ) + expect(rowC()?.getAttribute('data-state')).toBe('checked') + const dialog = document.querySelector('[role="dialog"]') + expect(dialog?.textContent).toContain('Prompt c') + expect(dialog?.textContent).not.toContain('Prompt a') + // The run is over, so the chat left out is actionable again, and this opening ticks it afresh. + expect(button('Dismiss all').disabled).toBe(false) + expect(checkbox(0).getAttribute('data-state')).toBe('checked') + expect(button('Resume 1 chat').disabled).toBe(false) +}) + +it('starts each opening from the default ticks, not the ones left at the last close', async () => { + rpc.mockResolvedValue({ sessions: offered }) + await mount() + await act(async () => checkbox(1).click()) + expect(button('Resume 1 chat')).toBeTruthy() + await act(async () => button('Close').click()) + expect(document.querySelector('[role="dialog"]')).toBeNull() + + await act(async () => requestNativeChatResumeOnRestartDialog()) + expect(checkbox(1).getAttribute('data-state')).toBe('checked') + expect(button('Resume 2 chats').disabled).toBe(false) +}) + // Resuming spends the host's claims, so the offer has to shrink with it. A count left standing over // chats the host already handed back sends the user to a status entry that re-reads, finds nothing, // and does nothing. @@ -336,6 +434,35 @@ it('resumes and continues once when the launch begins opted in', async () => { expect(document.querySelector('[role="dialog"]')).toBeNull() }) +// No dialog to watch, so the status bar is the only sign an automatic resume is running. +it('shows an opted-in launch resume in the status bar while it runs', async () => { + useAppStore.setState({ + settings: { + ...getDefaultSettings(''), + experimentalStructuredNativeChat: true, + nativeChatResumeWorkOnRestart: true + } + }) + const continued = Promise.withResolvers() + rpc.mockImplementation((_target, method) => + method === 'agentSession.restartResumable' + ? Promise.resolve({ sessions: offered }) + : continued.promise + ) + await mount() + expect(button('Resuming 2 chats').getAttribute('aria-label')).toBe( + 'Resuming 2 chats. Click to open details.' + ) + await act(async () => + continued.resolve({ + resumed: offered.map(({ sessionId }) => ({ sessionId, outcome: 'resumed' })), + continued: offered.map(({ sessionId }) => ({ sessionId, outcome: 'continued' })), + sessions: [] + }) + ) + expect(document.body.textContent).not.toContain('Resuming') +}) + // An opted-in launch reports the chats the host would not take, exactly as the button does. it('reports refused and newly ineligible chats on an opted-in launch', async () => { useAppStore.setState({ @@ -507,7 +634,7 @@ function dialogControls(): (string | null)[] { // The old toast said "1 chat could not be continued" and vanished. The chat now stays in the same // dialog — same title, checkboxes and footer — with its row saying what went wrong and what to do. -it('keeps a chat the resume could not carry on in the same dialog, with what to do', async () => { +it('lists a chat the resume could not carry on when the dialog reopens, with what to do', async () => { let remaining: unknown[] = [] rpc.mockImplementation(async (_target, method) => method === 'agentSession.restartResumable' @@ -525,6 +652,9 @@ it('keeps a chat the resume could not carry on in the same dialog, with what to ) await mount() await act(async () => button('Resume 2 chats').click()) + // Resume hands off to the status bar; its failure entry (or the toast's Show) reopens the list. + expect(document.querySelector('[role="dialog"]')).toBeNull() + await act(async () => requestNativeChatResumeOnRestartDialog()) const dialog = document.querySelector('[role="dialog"]') expect(dialog).not.toBeNull() // Unchanged chrome: the title, the preference box, and the two footer actions. @@ -594,6 +724,68 @@ it.each(['footer', 'row'] as const)( } ) +// A row action acts on its row, as Dismiss does: a retry that clears the last failure must not close +// the dialog over a chat still offered. +it('keeps the dialog open over the chats still offered after a row Retry succeeds', async () => { + let failed = [failure('b', 'agent_session_conflict')] + rpc.mockImplementation(async (_target, method) => + method === 'agentSession.restartResumable' + ? { sessions: [offered[0]], failed } + : ((failed = []), + { + resumed: [{ sessionId: 'b', outcome: 'resumed' }], + continued: [{ sessionId: 'b', outcome: 'continued' }], + sessions: [offered[0]], + failed + }) + ) + await mount() + await act(async () => button('Retry').click()) + const dialog = document.querySelector('[role="dialog"]') + expect(dialog?.textContent).toContain('Prompt a') + expect(dialog?.textContent).not.toContain('Prompt b') +}) + +// A dialog closed and reopened mid-retry is the user's again: the retry settling must not close it. +it('keeps a dialog reopened mid-retry open when the retry settles', async () => { + const continued = Promise.withResolvers() + rpc.mockImplementation((_target, method) => + method === 'agentSession.restartResumable' + ? Promise.resolve({ + sessions: [offered[0]], + failed: [failure('b', 'agent_session_conflict')] + }) + : continued.promise + ) + await mount( + <> + + + + ) + await act(async () => button('Retry').click()) + await act(async () => button('Close').click()) + expect(document.querySelector('[role="dialog"]')).toBeNull() + await act(async () => button('1 chat to resume').click()) + expect(document.querySelector('[role="dialog"]')).not.toBeNull() + // Only the retried row is running, so only it reads as ticked until the retry settles. + const tick = (prompt: string) => + document.querySelector(`[role="checkbox"][aria-label*="${prompt}"]`)?.getAttribute('data-state') + expect(tick('Prompt a')).toBe('unchecked') + expect(tick('Prompt b')).toBe('checked') + + await act(async () => + continued.resolve({ + resumed: [{ sessionId: 'b', outcome: 'resumed' }], + continued: [{ sessionId: 'b', outcome: 'continued' }], + sessions: [offered[0]], + failed: [] + }) + ) + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Prompt a') + expect(tick('Prompt a')).toBe('checked') +}) + // The user's case: the only row is a failure the host says a retry cannot fix. Ticking it could // only fail again, so the row's own action is the way on and the box cannot be ticked. it('keeps a failure the host marks unretryable out of Resume, even after a tick', async () => { diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx index 5cce3f2aa1a..315a19e3170 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx @@ -28,8 +28,8 @@ import { import { continueNativeChatRestartOffer, dismissNativeChatRestartOffer, - getNativeChatRestartOffer, - useNativeChatRestartOffer + useNativeChatRestartOffer, + useNativeChatRestartResuming } from './native-chat-resume-on-restart-store' /** @@ -42,9 +42,13 @@ import { * The "don't ask again" box removes the PROMPT, never a safety check — an opted-in launch calls * the same RPC, which re-derives the same predicate and staggers the same way. * + * Resume closes the dialog at once and the status-bar entry carries the run, then any chat it could + * not carry on. The run lives in the store, as a skill update's does, so the dialog is one view of it. + * * A chat an earlier resume could not carry on is listed too, as the same row plus what went wrong * and what to do; selecting it and resuming is a retry, unless the host says a retry cannot run. - * The dialog stays open while any remain, so the outcome is never left to a toast. + * Row actions (Retry, Dismiss) act on their row and leave the dialog open. It closes only on the + * user's own way out, or once the host confirms nothing is left; a resume settling never closes it. * * Closing is a SNOOZE, so looking around before deciding cannot remove the recovery. Dismiss all is * the explicit path that deletes the durable records. @@ -78,11 +82,22 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { ) const updateSettings = useAppStore((store) => store.updateSettings) const [dontAskAgain, setDontAskAgain] = useState(false) - const [busy, setBusy] = useState(false) + // The store's: the resume outlives this dialog, which can close or reopen mid-run. + const resuming = useNativeChatRestartResuming() + const busy = resuming.length > 0 /** The user's own ticks and unticks, over each row's default. Tracked as OVERRIDES rather than a * selection because the list is the host's and arrives — and shrinks — under an open dialog; a * stored selection would need seeding from an effect every time it changed. */ const [overrides, setOverrides] = useState>(() => new Map()) + // Each opening starts from the rows' defaults. This component never unmounts, so an untick made + // before a close would otherwise greet a reopen, e.g. as "Resume 0 chats" over what a run left. + const [openedWith, setOpenedWith] = useState(open) + if (openedWith !== open) { + setOpenedWith(open) + if (open) { + setOverrides(new Map()) + } + } /** Derived from the host's own list, so an action can never name a chat it did not list. */ const chosen = useMemo( () => @@ -99,6 +114,8 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { [rows, overrides, failureBySession] ) const selected = useMemo(() => new Set(chosen), [chosen]) + // Mid-run the ticks show what is running; this opening's own ticks may name chats left out of it. + const ticked = useMemo(() => (busy ? new Set(resuming) : selected), [busy, resuming, selected]) const toggleSelected = useCallback((sessionId: string, checked: boolean) => { setOverrides((current) => new Map(current).set(sessionId, checked)) @@ -111,19 +128,11 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { } }, [dontAskAgain, updateSettings]) + // Never closes the dialog: only the user's own ways out do, and the store once nothing is left. const resume = useCallback( async (sessionIds: string[]): Promise => { - setBusy(true) - try { - void persistPreference() - await continueNativeChatRestartOffer(sessionIds) - } finally { - setBusy(false) - // Stays open when a chat did not carry on: its row now says what to do about it. - if (getNativeChatRestartOffer().failed.length === 0) { - consumeNativeChatResumeOnRestartDialogRequest() - } - } + void persistPreference() + await continueNativeChatRestartOffer(sessionIds) }, [persistPreference] ) @@ -173,7 +182,7 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { { - if (!next && !busy) { + if (!next) { snooze() } }} @@ -217,7 +226,7 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { candidates={rows} listedAt={listedAt} busy={busy} - selected={selected} + selected={ticked} onToggle={toggleSelected} failureFor={(sessionId) => failureBySession.get(sessionId)} onFailureAction={(action, sessionId) => void actOnFailure(action, sessionId)} @@ -258,7 +267,11 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { variant="default" size="sm" disabled={busy || chosen.length === 0} - onClick={() => void resume(chosen)} + onClick={() => { + // Resume hands the run to the status bar. + consumeNativeChatResumeOnRestartDialogRequest() + void resume(chosen) + }} > {busy ? translate('auto.components.NativeChatResumeOnRestartModal.resuming', 'Resuming…') diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx index 6d143777e35..fa16bc7d247 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx @@ -69,7 +69,11 @@ vi.mock('@/components/new-workspace/SetProjectLocationDialog', () => ({ })) vi.mock('@/components/sparse/SparseCheckoutPresetSelect', () => ({ - default: () =>
+ default: ({ onEditingChange }: { onEditingChange?: (editing: boolean) => void }) => ( +
+ +
+ ) })) vi.mock('@/components/new-workspace/SmartWorkspaceNameField', () => ({ @@ -373,6 +377,19 @@ describe('NewWorkspaceComposerCard folder task source mode', () => { expect(advancedButton?.className).toContain('focus-visible:ring-inset') }) + it('shows only preset actions while editing and prevents hiding the draft', () => { + current = renderCard({ + advancedOpen: true, + sparseControlsEnabled: true, + canUseSparseCheckout: true + }) + const buttons = [...current.container.querySelectorAll('button')] + const button = (label: string) => buttons.find((node) => node.textContent?.includes(label)) + act(() => button('Begin preset')?.click()) + expect(button('Advanced')?.disabled).toBe(true) + expect(current.container.textContent).not.toContain('Create workspace') + }) + it('removes collapsed Advanced controls from the Tab order', () => { current = renderCard({ advancedOpen: false, branchesEnabled: true }) diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index b406fb0aeb8..5dac7dc8246 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -95,6 +95,7 @@ export default function NewWorkspaceComposerCard( const nameInputFocusFrameRef = React.useRef(null) const branchNameInputId = React.useId() const projectDescriptionId = React.useId() + const [sparseEditing, setSparseEditing] = React.useState(false) const [addRemoteHostMode, setAddRemoteHostMode] = React.useState(null) const [setLocationOption, setSetLocationOption] = React.useState( null @@ -287,6 +288,7 @@ export default function NewWorkspaceComposerCard(
-
- -
+ {!sparseEditing ? ( +
+ +
+ ) : null} {setLocationDialogMounted ? ( diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index 37d20b831e8..5d38cb0739c 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -73,6 +73,7 @@ function ComposerModalBody({ modalData: ComposerModalData onClose: () => void }): React.JSX.Element { + const dialogRef = useRef(null) const submitCancelledRef = useRef(false) const handleDismiss = useCallback(() => { submitCancelledRef.current = true @@ -83,7 +84,13 @@ function ComposerModalBody({ return ( !open && handleDismiss()}> { + if (dialogRef.current?.querySelector('[data-sparse-preset-editor]')) { + event.preventDefault() + } + }} onOpenAutoFocus={(event) => { // Why: Radix's FocusScope fires this once the dialog has mounted. // preventDefault stops it from focusing whatever first-tabbable it @@ -261,7 +268,7 @@ function QuickTabBody({ if (!shouldAllowComposerEnterSubmitTarget(target, composerRef.current)) { return } - if (createDisabled) { + if (createDisabled || composerRef.current?.hasAttribute('data-sparse-preset-editing')) { return } event.preventDefault() diff --git a/src/renderer/src/components/TerminalLegacyTerminalPanes.tsx b/src/renderer/src/components/TerminalLegacyTerminalPanes.tsx index f095b77e0e1..b054a05b717 100644 --- a/src/renderer/src/components/TerminalLegacyTerminalPanes.tsx +++ b/src/renderer/src/components/TerminalLegacyTerminalPanes.tsx @@ -1,5 +1,6 @@ import { createPortal } from 'react-dom' import TerminalPane from './terminal-pane/TerminalPane' +import { TerminalRestoringPlaceholder } from './terminal-pane/TerminalRestoringPlaceholder' import { findActivityTerminalPortal } from './activity/activity-terminal-portal' import { shouldMountBackgroundWorktreeTab } from './terminal/background-terminal-worktree-mount' import type { TerminalController } from './use-terminal-controller' @@ -59,51 +60,54 @@ export function TerminalLegacyTerminalPanes({ } aria-hidden={!isVisible} > - {(tabsByWorktree[workspace.id] ?? []) - .filter((tab) => - shouldMountBackgroundWorktreeTab( + {(tabsByWorktree[workspace.id] ?? []).map((tab) => { + const isActiveTerminalTab = + isVisible && tab.id === activeTabId && activeTabType === 'terminal' + if ( + !shouldMountBackgroundWorktreeTab( backgroundMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null, tab.id ) + ) { + // Why only the startup hold lands here visible: reveal admits every other + // visible deferred tab in the same render pass. + return isActiveTerminalTab ? : null + } + const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, { + worktreeId: workspace.id, + tabId: tab.id + }) + const isActivityPortalTab = activityTerminalPortal !== null + if ( + shouldColdParkTerminalPanes && + !isActivityPortalTab && + !evictionExemptTerminalTabIds.has(tab.id) + ) { + return null + } + const terminalPane = ( + handlePtyExit(tab.id, ptyId, exitCode)} + onCloseTab={() => handleCloseTab(tab.id)} + /> ) - .map((tab) => { - const activityTerminalPortal = findActivityTerminalPortal( - activityTerminalPortals, - { worktreeId: workspace.id, tabId: tab.id } + if (activityTerminalPortal) { + return createPortal( + terminalPane, + activityTerminalPortal.target, + `activity-terminal-${tab.id}` ) - const isActivityPortalTab = activityTerminalPortal !== null - const isActiveTerminalTab = - isVisible && tab.id === activeTabId && activeTabType === 'terminal' - if ( - shouldColdParkTerminalPanes && - !isActivityPortalTab && - !evictionExemptTerminalTabIds.has(tab.id) - ) { - return null - } - const terminalPane = ( - handlePtyExit(tab.id, ptyId, exitCode)} - onCloseTab={() => handleCloseTab(tab.id)} - /> - ) - if (activityTerminalPortal) { - return createPortal( - terminalPane, - activityTerminalPortal.target, - `activity-terminal-${tab.id}` - ) - } - return terminalPane - })} + } + return terminalPane + })}
) })} diff --git a/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx b/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx index 5c3145360f6..cfe381ccce9 100644 --- a/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx +++ b/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx @@ -1,5 +1,6 @@ import { useAnyBrowserGuestNeedsPaint } from './browser-pane/host-guest/browser-guest-paint-retention' import { WorktreeSplitSurface } from './TerminalWorktreeSplitSurface' +import { selectParkedEquivalentMountTabIds } from './terminal/startup-terminal-tab-hold' import type { TerminalController } from './use-terminal-controller' export function TerminalSplitWorkspaceSurfaces({ @@ -21,6 +22,7 @@ export function TerminalSplitWorkspaceSurfaces({ measurableBackgroundWorktreeIdsRef, mountedWorktreeIdsRef, renderedActiveWorktreeId, + startupTerminalTabHold, workspaceSurfaces } = controller // Why: this and TerminalSurface are both strict ancestors of every browser , so a @@ -69,9 +71,11 @@ export function TerminalSplitWorkspaceSurfaces({ backgroundMountTabIds={ backgroundMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null } - activationDeferredMountTabIds={ - activationDeferredMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null - } + activationDeferredMountTabIds={selectParkedEquivalentMountTabIds( + activationDeferredMountTabIdsByWorktreeRef.current.get(workspace.id), + startupTerminalTabHold, + workspace.id + )} /> ) })} diff --git a/src/renderer/src/components/WorktreeJumpPalette.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.test.tsx index 0881dd10d78..cfe5de95832 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.test.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.test.tsx @@ -421,6 +421,7 @@ describe('WorktreeJumpPalette', () => { // The first row names ITS OWN host — the wrong-host open is gone. await act(async () => fireEvent.click(rows[0]!)) expect(activateAndRevealWorktree).toHaveBeenLastCalledWith('shared', { + navigationIntent: 'user-open', executionHostId: 'local' }) }) @@ -443,6 +444,7 @@ describe('WorktreeJumpPalette', () => { await act(async () => fireEvent.click(rows[1]!)) expect(activateAndRevealWorktree).toHaveBeenLastCalledWith('shared', { + navigationIntent: 'user-open', executionHostId: 'ssh:box' }) }) @@ -487,6 +489,7 @@ describe('WorktreeJumpPalette', () => { await act(async () => fireEvent.click(hubARow!)) expect(activateAndRevealWorktree).toHaveBeenLastCalledWith('shared-runtime', { + navigationIntent: 'user-open', executionHostId: 'runtime:hub-a' }) }) diff --git a/src/renderer/src/components/activity/activity-clear-completed.test.ts b/src/renderer/src/components/activity/activity-clear-completed.test.ts index e0cee5aa9d8..79191ce3252 100644 --- a/src/renderer/src/components/activity/activity-clear-completed.test.ts +++ b/src/renderer/src/components/activity/activity-clear-completed.test.ts @@ -61,6 +61,7 @@ import { isClearableActivityThread, planClearCompletedActivity } from './activity-clear-completed' +import { activityThreadStatusId } from './activity-thread-presentation' function makeThread(paneKey: string, overrides: Partial = {}): AgentPaneThread { return { @@ -81,7 +82,7 @@ function makeThread(paneKey: string, overrides: Partial = {}): } } -function doneEvent(interrupted: boolean): ActivityEvent { +function doneEvent(interrupted: boolean, outcome?: 'failure'): ActivityEvent { return { id: 'evt', state: 'done', @@ -89,7 +90,16 @@ function doneEvent(interrupted: boolean): ActivityEvent { observedAt: 5_000, worktree: makeWorktree(), repo: null, - entry: { interrupted } as ActivityEvent['entry'], + entry: { + paneKey: 'evt-pane', + state: 'done', + prompt: '', + updatedAt: 5_000, + stateStartedAt: 5_000, + stateHistory: [], + interrupted, + ...(outcome ? { mainAgent: { state: 'done', outcome, stateStartedAt: 5_000 } } : {}) + }, tab: makeTab(), agentType: 'claude', agentAlive: false, @@ -102,6 +112,7 @@ const blockedThread = makeThread('t-blocked:1', { currentAgentState: 'blocked' } const waitingThread = makeThread('t-waiting:1', { currentAgentState: 'waiting' }) const doneThread = makeThread('t-done:1', { latestEvent: doneEvent(false) }) const interruptedThread = makeThread('t-interrupted:1', { latestEvent: doneEvent(true) }) +const failedThread = makeThread('t-failed:1', { latestEvent: doneEvent(false, 'failure') }) function makeRetained(paneKey: string): RetainedAgentEntry { return { @@ -125,10 +136,34 @@ describe('isClearableActivityThread', () => { it('clears only completed and interrupted threads', () => { expect(isClearableActivityThread(doneThread)).toBe(true) expect(isClearableActivityThread(interruptedThread)).toBe(true) + expect(activityThreadStatusId(failedThread)).toBe('failed') + expect(isClearableActivityThread(failedThread)).toBe(true) expect(isClearableActivityThread(workingThread)).toBe(false) expect(isClearableActivityThread(blockedThread)).toBe(false) expect(isClearableActivityThread(waitingThread)).toBe(false) }) + + it('reads a live thread whose main agent failed as failed, but keeps it while subagents run', () => { + const heldEntry = { + ...doneEvent(false).entry, + state: 'working' as const, + mainAgent: { state: 'done' as const, outcome: 'failure' as const, stateStartedAt: 5_000 } + } + const held = makeThread('t-held:1', { + currentAgentState: 'working', + currentAgentEntry: heldEntry + }) + expect(activityThreadStatusId(held)).toBe('failed') + expect(isClearableActivityThread(held)).toBe(false) + const succeeded = makeThread('t-ok:1', { + currentAgentState: 'working', + currentAgentEntry: { + ...heldEntry, + mainAgent: { state: 'done', outcome: 'success', stateStartedAt: 5_000 } + } + }) + expect(activityThreadStatusId(succeeded)).toBe('working') + }) }) describe('clearCompletedActivity', () => { diff --git a/src/renderer/src/components/activity/activity-clear-completed.ts b/src/renderer/src/components/activity/activity-clear-completed.ts index 2bb39fdff43..e365152c9bb 100644 --- a/src/renderer/src/components/activity/activity-clear-completed.ts +++ b/src/renderer/src/components/activity/activity-clear-completed.ts @@ -18,11 +18,15 @@ export type ClearCompletedActivityPlan = { clearedThreadCount: number } -/** A thread is clearable when it needs nothing from the user: completed or interrupted, +/** A thread is clearable when it needs nothing from the user: completed, failed or interrupted, * with no fresh live working/monitoring/blocked/waiting state. */ export function isClearableActivityThread(thread: AgentPaneThread): boolean { const id = activityThreadStatusId(thread) - return id === 'done' || id === 'interrupted' + // Why: a failed main agent reads failed while its subagents still run; that thread is still live. + if (thread.currentAgentState) { + return false + } + return id === 'done' || id === 'failed' || id === 'interrupted' } export function planClearCompletedActivity( diff --git a/src/renderer/src/components/activity/activity-pane-events.ts b/src/renderer/src/components/activity/activity-pane-events.ts index 6a147beac81..0ee287af187 100644 --- a/src/renderer/src/components/activity/activity-pane-events.ts +++ b/src/renderer/src/components/activity/activity-pane-events.ts @@ -27,7 +27,9 @@ function historyEntrySnapshot( toolName: undefined, toolInput: undefined, lastAssistantMessage: undefined, - interrupted: history.interrupted + interrupted: history.interrupted, + // The live row's main agent belongs to its current state, not to this snapshot. + mainAgent: history.mainAgent } } diff --git a/src/renderer/src/components/activity/activity-thread-actions.test.ts b/src/renderer/src/components/activity/activity-thread-actions.test.ts index 07f50bce92d..ce546f03697 100644 --- a/src/renderer/src/components/activity/activity-thread-actions.test.ts +++ b/src/renderer/src/components/activity/activity-thread-actions.test.ts @@ -1,4 +1,6 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' import { makeRepo, makeTab, makeWorktree } from './ActivityPrototypePage-test-fixtures' import type { AgentPaneThread } from './activity-thread-types' @@ -6,7 +8,9 @@ const mocks = vi.hoisted(() => ({ getState: vi.fn(), activateTabAndFocusPane: vi.fn(), activateStructuredAgentSessionTab: vi.fn(), - activateAndRevealWorkspace: vi.fn() + activateAndRevealWorkspace: vi.fn(), + isFloatingWorkspacePanelVisible: vi.fn(), + dispatchEvent: vi.fn() })) vi.mock('@/store', () => ({ useAppStore: { getState: mocks.getState } })) @@ -19,6 +23,9 @@ vi.mock('@/lib/structured-agent-session-tab-activation', () => ({ vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorkspace: mocks.activateAndRevealWorkspace })) +vi.mock('@/lib/floating-workspace-terminal-actions', () => ({ + isFloatingWorkspacePanelVisible: mocks.isFloatingWorkspacePanelVisible +})) import { createActivityThreadActions, hasActivityThreadWorkspace } from './activity-thread-actions' @@ -62,6 +69,8 @@ describe('activity thread host routing', () => { beforeEach(() => { vi.clearAllMocks() + vi.stubGlobal('window', { dispatchEvent: mocks.dispatchEvent }) + mocks.isFloatingWorkspacePanelVisible.mockReturnValue(false) mocks.activateStructuredAgentSessionTab.mockReturnValue(false) mocks.activateAndRevealWorkspace.mockReturnValue({ primaryTabId: null }) getKnownWorktreeById.mockReturnValue(thread.worktree) @@ -92,6 +101,76 @@ describe('activity thread host routing', () => { mocks.getState.mockImplementation(() => state) }) + afterEach(() => vi.unstubAllGlobals()) + + function makeFloatingThread(): AgentPaneThread { + return { + ...thread, + worktree: { ...thread.worktree, id: FLOATING_TERMINAL_WORKTREE_ID }, + repo: null, + tab: { ...thread.tab, worktreeId: FLOATING_TERMINAL_WORKTREE_ID } + } + } + + it.each([false, true])( + 'reveals the floating agent pane when the panel is open=%s without switching workspace', + (open) => { + const floatingThread = makeFloatingThread() + state.settings = { floatingTerminalEnabled: true } + state.tabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [floatingThread.tab] } + mocks.activateAndRevealWorkspace.mockReturnValue(false) + mocks.isFloatingWorkspacePanelVisible.mockReturnValue(open) + + makeActions().selectThread(floatingThread) + + expect(setSelectedPaneKey).toHaveBeenCalledWith(floatingThread.paneKey) + expect(mocks.activateAndRevealWorkspace).not.toHaveBeenCalled() + expect(setActiveWorktree).not.toHaveBeenCalled() + expect(mocks.dispatchEvent).toHaveBeenCalledTimes(open ? 0 : 1) + if (!open) { + expect(mocks.dispatchEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: TOGGLE_FLOATING_TERMINAL_EVENT }) + ) + } + expect(mocks.activateTabAndFocusPane).toHaveBeenCalledWith( + floatingThread.tab.id, + '11111111-1111-4111-8111-111111111111', + { flashFocusedPane: true, scrollToBottomIfOutputSinceLastView: true } + ) + } + ) + + it('enables a disabled floating workspace before revealing its agent pane', async () => { + const floatingThread = makeFloatingThread() + const updateSettings = vi.fn().mockResolvedValue(undefined) + const requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + callback(0) + return 1 + }) + vi.stubGlobal('requestAnimationFrame', requestAnimationFrame) + state.settings = { floatingTerminalEnabled: false } + state.updateSettings = updateSettings + state.tabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [floatingThread.tab] } + + makeActions().selectThread(floatingThread) + + expect(updateSettings).toHaveBeenCalledWith({ floatingTerminalEnabled: true }) + expect(mocks.dispatchEvent).not.toHaveBeenCalled() + expect(mocks.activateTabAndFocusPane).toHaveBeenCalled() + await vi.waitFor(() => expect(requestAnimationFrame).toHaveBeenCalledTimes(1)) + expect(mocks.dispatchEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: TOGGLE_FLOATING_TERMINAL_EVENT }) + ) + }) + + it('does not open the floating panel for a retained thread whose tab was closed', () => { + makeActions().selectThread(makeFloatingThread()) + + expect(mocks.activateAndRevealWorkspace).not.toHaveBeenCalled() + expect(mocks.dispatchEvent).not.toHaveBeenCalled() + expect(mocks.activateTabAndFocusPane).not.toHaveBeenCalled() + }) + it('routes the row click through the full activation sequence for the matching host', () => { makeActions().selectThread(thread) @@ -179,6 +258,7 @@ describe('activity thread host routing', () => { expect(acknowledgeAgents).toHaveBeenCalledWith([thread.paneKey]) expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith(thread.worktree.id, { + navigationIntent: 'user-open', executionHostId: REMOTE_HOST }) }) diff --git a/src/renderer/src/components/activity/activity-thread-actions.ts b/src/renderer/src/components/activity/activity-thread-actions.ts index 6e0d167a5a3..c2d99c23fc2 100644 --- a/src/renderer/src/components/activity/activity-thread-actions.ts +++ b/src/renderer/src/components/activity/activity-thread-actions.ts @@ -1,8 +1,11 @@ import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' +import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' +import { isFloatingWorkspacePanelVisible } from '@/lib/floating-workspace-terminal-actions' import { activateStructuredAgentSessionTab } from '@/lib/structured-agent-session-tab-activation' import { activateAndRevealWorkspace } from '@/lib/worktree-activation' import { jumpToWorktreeFromSidebar } from '@/lib/worktree-jump-navigation' import { useAppStore } from '@/store' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { getSettingsFocusedExecutionHostId, getWorktreeExecutionHostId, @@ -45,6 +48,25 @@ export function hasActivityThreadWorkspace( ) } +function toggleFloatingWorkspacePanelIfHidden(): void { + if (!isFloatingWorkspacePanelVisible()) { + window.dispatchEvent(new Event(TOGGLE_FLOATING_TERMINAL_EVENT)) + } +} + +// Why enable first: floating tabs outlive a feature disable, and the panel ignores the toggle +// while disabled, so a live floating agent row would otherwise be a silent no-op. +function revealFloatingWorkspacePanel(state: AppState): void { + if (state.settings?.floatingTerminalEnabled === true) { + toggleFloatingWorkspacePanelIfHidden() + return + } + void state.updateSettings({ floatingTerminalEnabled: true }).then(() => { + // Why deferred a frame: the panel only honors the toggle once the enabled flag has reached React. + requestAnimationFrame(toggleFloatingWorkspacePanelIfHidden) + }) +} + export function createActivityThreadActions({ getMarkAllReadThreads, acknowledgeAgents, @@ -75,6 +97,7 @@ export function createActivityThreadActions({ } const activateThreadTarget = (thread: AgentPaneThread): void => { + const isFloatingTerminal = thread.worktree.id === FLOATING_TERMINAL_WORKTREE_ID const executionHostId = getActivityThreadExecutionHostId( thread, getSettingsFocusedExecutionHostId(useAppStore.getState().settings) @@ -84,6 +107,7 @@ export function createActivityThreadActions({ // resumeSleepingAgentSessionsForWorktree/ensureWorktreeHasInitialTerminal run inside here. // Probing tab residency first is what made a remote row click a silent no-op (#16731). if ( + !isFloatingTerminal && activateAndRevealWorkspace(thread.worktree.id, { executionHostId, revealInSidebar: false, @@ -105,6 +129,10 @@ export function createActivityThreadActions({ // no pane to focus and focusing a sibling would be worse than focusing nothing. return } + // Floating tabs have no catalog workspace; reveal their panel without changing the main workspace. + if (isFloatingTerminal) { + revealFloatingWorkspacePanel(activated) + } activated.setActiveTabType('terminal', thread.worktree.id) const parsed = parsePaneKey(thread.paneKey) activateTabAndFocusPane( diff --git a/src/renderer/src/components/activity/activity-thread-grouping.ts b/src/renderer/src/components/activity/activity-thread-grouping.ts index cab4fbcf8c3..9bf99cc67cf 100644 --- a/src/renderer/src/components/activity/activity-thread-grouping.ts +++ b/src/renderer/src/components/activity/activity-thread-grouping.ts @@ -21,11 +21,11 @@ const ACTIVITY_STATUS_GROUP_RANK: Record = { waiting: 0, blocked: 1, permission: 2, - interrupted: 3, - working: 4, - monitoring: 5, - unverifiable: 6, - failed: 7, + failed: 3, + interrupted: 4, + working: 5, + monitoring: 6, + unverifiable: 7, done: 8, idle: 9 } diff --git a/src/renderer/src/components/activity/activity-thread-presentation.ts b/src/renderer/src/components/activity/activity-thread-presentation.ts index 5541b3da493..e38c802f867 100644 --- a/src/renderer/src/components/activity/activity-thread-presentation.ts +++ b/src/renderer/src/components/activity/activity-thread-presentation.ts @@ -2,6 +2,10 @@ import type { AgentDotState } from '@/components/AgentStateDot' import { formatAgentTypeLabel } from '@/lib/agent-status' import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text' import { showsAgentToolPreview } from '@/lib/agent-row-tool-preview' +import { + agentMainAgentVerdict, + agentVerdictDisplayMark +} from '../../../../shared/agent-main-agent-verdict' import { getActivityThreadTaskTitle, getActivityThreadWorkspaceTitle, @@ -68,7 +72,12 @@ export function agentTitle(event: ActivityEvent): string { return 'Agent working' } if (event.state === 'done') { - return event.entry.interrupted ? 'Agent interrupted' : 'Agent finished' + const verdict = agentMainAgentVerdict(event.entry) + return verdict === 'failure' + ? 'Agent failed' + : verdict === 'cancellation' + ? 'Agent interrupted' + : 'Agent finished' } return event.state === 'waiting' ? 'Agent waiting for input' : 'Agent needs input' } @@ -91,7 +100,12 @@ export function agentMeta(event: ActivityEvent): string { return `${agent} ${event.state}` } if (event.state === 'done') { - return event.entry.interrupted ? `${agent} interrupted` : `${agent} completed` + const verdict = agentMainAgentVerdict(event.entry) + return verdict === 'failure' + ? `${agent} failed` + : verdict === 'cancellation' + ? `${agent} interrupted` + : `${agent} completed` } return event.state === 'waiting' ? `${agent} waiting` : `${agent} blocked` } @@ -120,13 +134,18 @@ export function statusPreviewForEntry( export type ActivityThreadStatusId = AgentDotState /** Single classifier behind grouping, labels, and clear-completed; the only place the - * interrupted predicate is spelled. */ + * verdict predicate is spelled. */ export function activityThreadStatusId(thread: AgentPaneThread): ActivityThreadStatusId { + // Why: a failed main agent outranks the subagent work still holding its row live. + if (thread.currentAgentEntry && agentVerdictDisplayMark(thread.currentAgentEntry) === 'failed') { + return 'failed' + } const paneEntry = paneActivityEntry(thread) const state = threadCurrentState(thread) ?? 'done' - const interrupted = paneEntry ? paneEntry.interrupted : thread.latestEvent?.entry.interrupted - if (!thread.currentAgentState && state === 'done' && interrupted) { - return 'interrupted' + const verdictEntry = paneEntry ?? thread.latestEvent?.entry + const verdictDot = verdictEntry ? agentVerdictDisplayMark(verdictEntry) : null + if (!thread.currentAgentState && state === 'done' && verdictDot) { + return verdictDot } return state } @@ -149,7 +168,7 @@ function threadCurrentState( ) } -// Interrupted rows deliberately keep the done glyph (#2569). +// Interrupted rows deliberately keep the done glyph (#2569); a failure is a fault and does not. export function threadAgentState(thread: AgentPaneThread): AgentDotState { const id = activityThreadStatusId(thread) return id === 'interrupted' ? 'done' : id diff --git a/src/renderer/src/components/agent-child-row-parity.test.tsx b/src/renderer/src/components/agent-child-row-parity.test.tsx new file mode 100644 index 00000000000..c8e3b4bd190 --- /dev/null +++ b/src/renderer/src/components/agent-child-row-parity.test.tsx @@ -0,0 +1,631 @@ +/** @vitest-environment happy-dom */ +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + agentChildRowContextForParent, + type AgentChildRowContext +} from '../../../shared/agent-child-row-model' +import type { AgentSessionBackgroundTask } from '../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import type { AgentStatusEntry } from '../../../shared/agent-status-types' +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import DashboardAgentRow from '@/components/dashboard/DashboardAgentRow' +import { NativeChatBackgroundTasksStatus } from '@/components/native-chat/NativeChatBackgroundTasksStatus' +import { buildBackgroundTaskGroupsFromViews } from '@/components/native-chat/background-task-roster' +import { CompactAgentRow } from '@/components/sidebar/worktree-card-compact-agent-row' +import { buildSubagentChildRows } from '@/components/sidebar/worktree-subagent-child-rows' +import { TooltipProvider } from '@/components/ui/tooltip' + +vi.mock('@/components/dashboard/use-agent-row-conversation-name', () => ({ + useAgentRowConversationName: () => null +})) + +vi.mock('@/components/sidebar/CacheTimer', () => ({ + default: () => null, + usePromptCacheCountdownForPane: () => null +})) + +const NOW = 1_000_000 +const MINUTE = 60_000 + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(NOW) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +const tab: TerminalTab = { + id: 'parent-tab', + ptyId: null, + worktreeId: 'wt-1', + title: 'Parent', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 +} + +function view(id: string, overrides: Partial = {}): AgentChildWorkView { + return { + id, + providerId: `task-${id}`, + kind: 'agent', + description: 'Audit the parser', + agentType: 'general-purpose', + state: 'working', + membership: 'live', + firstObservedAt: NOW - 5 * MINUTE, + observedAt: NOW - 2 * MINUTE, + stoppable: true, + invocation: { invocationId: `spawn-${id}`, generation: 1 }, + ...overrides + } +} + +function settled( + outcome: NonNullable, + overrides: Partial = {} +): AgentChildWorkView { + return view('child', { + state: 'done', + membership: 'settled', + outcome, + settledAt: NOW - 3 * MINUTE, + ...overrides + }) +} + +const OWNED_SHELL = view('shell', { + kind: 'command', + description: 'npm run dev', + agentType: undefined, + state: 'monitoring', + parentChildWorkId: 'child' +}) + +function parentWith(children: AgentChildWorkView[], updatedAt = NOW): AgentStatusEntry { + return { + paneKey: 'parent-tab:leaf-1', + tabId: tab.id, + worktreeId: tab.worktreeId, + state: 'done', + prompt: 'parent prompt', + updatedAt, + stateStartedAt: NOW - 20 * MINUTE, + stateHistory: [], + children + } +} + +type RenderedRow = { dot: string; lead: string; trail: string } + +function readRow(root: Element, separator: string): RenderedRow { + const text = root.querySelector('span.truncate') + const [lead, trail] = [...(text?.children ?? [])].map((span) => span.textContent ?? '') + return { + dot: root.querySelector('[aria-label]')?.getAttribute('aria-label') ?? '', + lead: lead ?? '', + trail: trail?.startsWith(separator) ? trail.slice(separator.length) : (trail ?? '') + } +} + +function mount(markup: string): HTMLElement { + const container = document.createElement('div') + container.innerHTML = markup + return container +} + +function sidebarRows(parent: AgentStatusEntry, parentIsFresh = true): RenderedRow[] { + return buildSubagentChildRows({ parentEntry: parent, tab, parentIsFresh }).map((agent) => + readRow( + mount( + renderToStaticMarkup( + + {}} /> + + ) + ), + ' - ' + ) + ) +} + +function stripRows( + children: AgentChildWorkView[] | undefined, + childRowContext?: AgentChildRowContext, + tasks: AgentSessionBackgroundTask[] = [] +): RenderedRow[] { + const root = mount( + renderToStaticMarkup( + {}} + onStop={() => {}} + /> + ) + ) + return [...root.querySelectorAll('li')] + .filter((row) => row.querySelector(':scope > span.truncate')) + .map((row) => readRow(row, ' · ')) +} + +/** The full sidebar row: every dot label it carries, and its whole text. */ +function fullRow(parent: AgentStatusEntry): { labels: string[]; text: string } { + const [agent] = buildSubagentChildRows({ parentEntry: parent, tab, parentIsFresh: true }) + const root = mount( + renderToStaticMarkup( + + {}} + onDismiss={() => {}} + stateDotSize="sm" + hideExpand + /> + + ) + ) + return { + labels: [...root.querySelectorAll('[aria-label]')].map( + (element) => element.getAttribute('aria-label') ?? '' + ), + text: root.textContent ?? '' + } +} + +const SCENARIOS: [string, AgentChildWorkView[], RenderedRow][] = [ + [ + 'working, no known operation', + [view('child')], + { dot: 'Working', lead: 'Audit the parser', trail: 'general-purpose' } + ], + [ + 'working with a tool', + [ + view('child', { + operation: { toolName: 'Read', input: 'src/parser.ts', basis: 'open', observedAt: NOW } + }) + ], + { dot: 'Working', lead: 'Audit the parser', trail: 'Read: src/parser.ts' } + ], + [ + 'running a shell in the foreground', + [ + view('child', { + operation: { toolName: 'Bash', input: 'npm test', basis: 'open', observedAt: NOW } + }) + ], + { dot: 'Working', lead: 'Audit the parser', trail: 'Bash: npm test' } + ], + [ + 'finished, while a shell it launched still runs', + [settled('succeeded', { lastMessage: 'All green' }), OWNED_SHELL], + { + dot: 'Monitoring background tasks', + lead: 'Monitoring background tasks', + trail: 'Audit the parser' + } + ], + [ + 'waiting on an approval', + [ + view('child', { + state: 'waiting', + operation: { toolName: 'Edit', input: 'src/parser.ts', basis: 'open', observedAt: NOW } + }) + ], + { dot: 'Waiting for input', lead: 'Audit the parser', trail: 'Edit: src/parser.ts' } + ], + [ + 'blocked', + [view('child', { state: 'blocked', lastMessage: 'Rate limited, retrying' })], + { dot: 'Blocked', lead: 'Audit the parser', trail: 'Rate limited, retrying' } + ], + [ + 'finished', + [settled('succeeded', { lastMessage: 'Found 3 call sites' })], + { dot: 'Done', lead: 'Audit the parser', trail: 'Found 3 call sites' } + ], + [ + 'failed', + [settled('failed', { lastMessage: 'Exit code 1' })], + { dot: 'Failed', lead: 'Audit the parser', trail: 'Exit code 1' } + ], + [ + 'cancelled', + [settled('cancelled')], + { dot: 'Interrupted', lead: 'Audit the parser', trail: 'general-purpose' } + ], + [ + 'ended, outcome unknown', + [settled('unknown')], + { dot: 'Idle', lead: 'Audit the parser', trail: 'Ended' } + ], + [ + 'unverifiable', + [view('child', { state: 'unverifiable' })], + { dot: 'No recent update', lead: 'Audit the parser', trail: 'No update in 2m' } + ], + [ + 'parked, still live', + [view('child', { state: 'idle' })], + { dot: 'Idle', lead: 'Audit the parser', trail: 'general-purpose' } + ] +] + +/** What the full sidebar row, the CLI row's own layout, shows of the same detail. */ +const FULL_ROW_DETAIL: Record = { + 'working, no known operation': { shows: [] }, + 'working with a tool': { shows: ['Read', 'src/parser.ts'] }, + 'running a shell in the foreground': { shows: ['Bash', 'npm test'] }, + 'finished, while a shell it launched still runs': { shows: [], hides: ['All green'] }, + 'waiting on an approval': { shows: ['Edit', 'src/parser.ts'] }, + blocked: { shows: ['Rate limited, retrying'] }, + finished: { shows: ['Found 3 call sites'] }, + failed: { shows: ['Exit code 1'] }, + cancelled: { shows: [] }, + 'ended, outcome unknown': { shows: ['Ended'] }, + unverifiable: { shows: ['No update in 2m'] }, + 'parked, still live': { shows: [] } +} + +describe('a child reads the same in the sidebar and the chat strip', () => { + it.each(SCENARIOS)('%s', (name, children, expected) => { + const [sidebar] = sidebarRows(parentWith(children)) + const [strip] = stripRows(children) + expect(sidebar).toEqual(expected) + expect(strip).toEqual(expected) + const full = fullRow(parentWith(children)) + expect(full.labels).toContain(expected.dot) + expect(full.text).toContain(expected.lead === expected.dot ? expected.trail : expected.lead) + for (const text of FULL_ROW_DETAIL[name].shows) { + expect(full.text).toContain(text) + } + for (const text of FULL_ROW_DETAIL[name].hides ?? []) { + expect(full.text).not.toContain(text) + } + }) + + it('names an unlabeled child by the same state on every surface', () => { + const children = [ + settled('failed', { description: undefined, agentType: undefined, lastMessage: 'Exit 2' }) + ] + const [sidebar] = sidebarRows(parentWith(children)) + const [strip] = stripRows(children) + expect(sidebar.lead).toBe('Failed') + expect(strip.lead).toBe('Failed') + expect(fullRow(parentWith(children)).text).toContain('Failed') + }) + + it('shows the monitoring icon on the full sidebar row too', () => { + const [agent] = buildSubagentChildRows({ + parentEntry: parentWith([settled('succeeded'), OWNED_SHELL]), + tab, + parentIsFresh: true + }) + const root = mount( + renderToStaticMarkup( + + {}} + onDismiss={() => {}} + stateDotSize="sm" + hideExpand + /> + + ) + ) + expect(root.querySelector('[aria-label]')?.getAttribute('aria-label')).toBe( + 'Monitoring background tasks' + ) + // The finished child's last tool line is stale; neither row names it. + expect(root.textContent).not.toContain('Bash') + }) + + it('never shows a monitoring child tool text on either surface', () => { + const children = [ + settled('succeeded', { lastMessage: 'Bash: npm test finished' }), + { ...OWNED_SHELL, description: 'tail -f server.log' } + ] + const [sidebar] = sidebarRows(parentWith(children)) + const [strip] = stripRows(children) + for (const row of [sidebar, strip]) { + expect(row.dot).toBe('Monitoring background tasks') + expect(`${row.lead} ${row.trail}`).not.toContain('npm test') + } + }) +}) + +describe('a child running a shell in its turn', () => { + // The host records the running shell both as the child's operation and as a live command it owns. + const children = [ + view('child', { + operation: { toolName: 'Bash', input: 'npm run dev', basis: 'open', observedAt: NOW } + }), + { + ...OWNED_SHELL, + state: 'working' as const, + stoppable: false, + firstObservedAt: NOW - MINUTE + } + ] + const childRow: RenderedRow = { + dot: 'Working', + lead: 'Audit the parser', + trail: 'Bash: npm run dev' + } + + it('reads as the child working its shell, with the shell nested beneath it in the strip', () => { + const groups = buildBackgroundTaskGroupsFromViews(children) + expect(groups.map((group) => group.kind)).toEqual(['agent']) + expect(groups[0].tasks.map((entry) => entry.row.owned.map((owned) => owned.id))).toEqual([ + ['shell'] + ]) + expect(stripRows(children)).toEqual([ + childRow, + { dot: 'Working', lead: 'npm run dev', trail: '' } + ]) + }) + + it('shows the sidebar only the child, saying what the strip says of it', () => { + expect(sidebarRows(parentWith(children))).toEqual([childRow]) + }) +}) + +describe('one child reads the same from every shape a host publishes', () => { + it('names and details it identically from views, the subagents snapshot and the task roster', () => { + // A placeholder description falls through to the child's real label on every path. + const children = [view('child', { description: 'task', agentType: 'Explore' })] + const snapshotParent: AgentStatusEntry = { + ...parentWith([]), + children: undefined, + subagents: [ + { + id: 'child', + state: 'working', + startedAt: NOW - 5 * MINUTE, + description: 'task', + agentType: 'Explore' + } + ] + } + const roster: AgentSessionBackgroundTask[] = [ + { id: 'child', kind: 'agent', description: 'task', name: 'Explore', state: 'working' } + ] + const expected: RenderedRow = { dot: 'Working', lead: 'Explore', trail: '' } + expect(sidebarRows(parentWith(children))).toEqual([expected]) + expect(sidebarRows(snapshotParent)).toEqual([expected]) + expect(stripRows(children)).toEqual([expected]) + expect(stripRows(undefined, undefined, roster)).toEqual([expected]) + }) +}) + +describe('a mirrored parent and its children read one silence', () => { + const SKEW = 20 * MINUTE + // The host's clock runs 20 minutes ahead; this machine received its last word 3 minutes ago. + function mirroredParent(overrides: Partial): AgentStatusEntry { + return { + ...parentWith([]), + state: 'working', + updatedAt: NOW - 3 * MINUTE + SKEW, + mirroredEvidenceReceivedAt: NOW - 3 * MINUTE, + ...overrides + } + } + + it('times a snapshot child on the receipt clock the parent decays on', () => { + const parent = mirroredParent({ + children: undefined, + subagents: [ + { id: 'child', state: 'working', startedAt: NOW - 9 * MINUTE, description: 'Audit' } + ] + }) + expect(sidebarRows(parent, false)[0].trail).toBe('No update in 3m') + }) + + it('times a view child by its own host-clock age, never across machines', () => { + const children = [view('child', { observedAt: NOW - 5 * MINUTE + SKEW })] + const parent = mirroredParent({ children }) + expect(sidebarRows(parent, false)[0].trail).toBe('No update in 5m') + expect(stripRows(children, agentChildRowContextForParent(parent, false))[0].trail).toBe( + 'No update in 5m' + ) + const [agent] = buildSubagentChildRows({ parentEntry: parent, tab, parentIsFresh: false }) + const full = mount( + renderToStaticMarkup( + + {}} + onDismiss={() => {}} + stateDotSize="sm" + hideExpand + /> + + ) + ) + expect(full.textContent).toContain('No update in 5m') + }) +}) + +describe('a lost or stale parent reads the same on both surfaces', () => { + const children = [ + view('child', { + operation: { toolName: 'Bash', input: 'npm test', basis: 'open', observedAt: NOW } + }) + ] + const lost: RenderedRow = { + dot: 'No recent update', + lead: 'Audit the parser', + trail: 'No update in 2m' + } + + it('when the transport to the host is lost', () => { + const parent = { ...parentWith(children), subagentObservation: 'unverifiable' as const } + const [sidebar] = sidebarRows(parent) + const [strip] = stripRows(children, agentChildRowContextForParent(parent, true)) + expect(sidebar).toEqual(lost) + expect(strip).toEqual(lost) + }) + + it('when the parent row has gone stale', () => { + const parent = parentWith([settled('succeeded'), OWNED_SHELL], NOW - 40 * MINUTE) + const [sidebar] = sidebarRows(parent, false) + const [strip] = stripRows(parent.children ?? [], agentChildRowContextForParent(parent, false)) + const stale = { dot: 'No recent update', lead: 'Audit the parser', trail: 'No update in 2m' } + expect(sidebar).toEqual(stale) + expect(strip).toEqual(stale) + }) + + it('without a context the strip reports what the host last said', () => { + expect(stripRows(children)[0].dot).toBe('Working') + }) +}) + +describe('sibling child rows keep their own clocks', () => { + it('reads each sibling from its own evidence, not the parent clock', () => { + const parent = parentWith( + [ + view('busy', { description: 'Busy child', observedAt: NOW - 30_000 }), + view('quiet', { + description: 'Quiet child', + firstObservedAt: NOW - 15 * MINUTE, + observedAt: NOW - 10 * MINUTE + }) + ], + NOW - 40 * MINUTE + ) + const rows = buildSubagentChildRows({ parentEntry: parent, tab, parentIsFresh: false }) + expect(rows.map((row) => row.entry.evidenceObservedAt)).toEqual([ + NOW - 30_000, + NOW - 10 * MINUTE + ]) + expect(rows.map((row) => row.startedAt)).toEqual([NOW - 5 * MINUTE, NOW - 15 * MINUTE]) + expect(sidebarRows(parent, false).map((row) => row.trail)).toEqual([ + 'No update in 0m', + 'No update in 10m' + ]) + }) + + it('times a settled child from when it ended in the sidebar, and freezes its run in the strip', () => { + const [agent] = buildSubagentChildRows({ + parentEntry: parentWith([settled('succeeded')]), + tab, + parentIsFresh: true + }) + const text = mount( + renderToStaticMarkup( + + {}} /> + + ) + ).textContent + expect(text?.endsWith('3m')).toBe(true) + const strip = mount( + renderToStaticMarkup( + {}} + onStop={() => {}} + /> + ) + ) + // Ran from 5m ago until it settled 3m ago; a finished row never ticks. + expect(strip.querySelector('li')?.textContent).toMatch(/2m 0s$/) + }) +}) + +describe('the chat strip from views', () => { + it('nests a child-owned shell under its owner and stops by the provider id', () => { + const children = [ + view('child', { description: 'Dev server owner' }), + view('shell', { + kind: 'command', + description: 'npm run dev', + agentType: undefined, + state: 'monitoring', + parentChildWorkId: 'child' + }), + view('main-shell', { + kind: 'command', + description: 'tail -f log', + agentType: undefined, + state: 'monitoring' + }) + ] + const root = mount( + renderToStaticMarkup( + {}} + onStop={() => {}} + /> + ) + ) + const groups = [...root.querySelectorAll('ul[aria-label]')].map((list) => + list.getAttribute('aria-label') + ) + expect(groups).toEqual(['Agents', 'Shell']) + const nested = root.querySelector('ul[aria-label="Agents"] ul') + expect(nested?.textContent).toContain('npm run dev') + expect(root.querySelector('ul[aria-label="Shell"]')?.textContent).not.toContain('npm run dev') + const stopShell = root.querySelector('button[aria-label="Stop npm run dev"]') + expect(stopShell?.hasAttribute('disabled')).toBe(true) + }) +}) + +describe('one lifecycle word for a child, on the sidebar row and the strip header', () => { + it.each<[string, AgentChildWorkView[], string, string]>([ + ['failed', [settled('failed')], 'blocked', 'blocked'], + ['cancelled', [settled('cancelled')], 'idle', 'idle'], + ['ended, outcome unknown', [settled('unknown')], 'idle', 'idle'], + ['finished', [settled('succeeded')], 'done', 'done'], + ['monitoring its own shell', [settled('succeeded'), OWNED_SHELL], 'working', 'monitoring'], + ['waiting', [view('child', { state: 'waiting' })], 'waiting', 'waiting'] + ])('%s', (_name, children, sidebarState, headerState) => { + const [sidebar] = buildSubagentChildRows({ + parentEntry: parentWith(children), + tab, + parentIsFresh: true + }) + const [group] = buildBackgroundTaskGroupsFromViews(children) + // A CLI row carries monitoring as `working` plus its working mode; every other word is shared. + expect(sidebar.state).toBe(sidebarState) + expect(group.tasks[0].state).toBe(headerState) + }) +}) diff --git a/src/renderer/src/components/agent-child-row-text.ts b/src/renderer/src/components/agent-child-row-text.ts new file mode 100644 index 00000000000..96ef7d1d5c7 --- /dev/null +++ b/src/renderer/src/components/agent-child-row-text.ts @@ -0,0 +1,69 @@ +import type { AgentChildRowModel } from '../../../shared/agent-child-row-model' +import { formatAgentTypeLabel } from '../../../shared/agent-type-label' +import { agentStateLabel } from '@/components/AgentStateDot' +import { backgroundTaskStateReason } from '@/components/native-chat/background-task-roster' +import { translate } from '@/i18n/i18n' +import { agentNoUpdateLabel } from '@/lib/agent-row-decay-state' +import { formatAgentToolPreview } from '@/lib/agent-row-tool-preview' + +export type AgentChildRowText = { + /** Leads the line; kept when the row truncates. */ + lead: string + /** Follows the separator; '' when there is nothing more to say. */ + trail: string +} + +/** How long this child has been silent, on the reader's clock the model measured it on. */ +export function agentChildRowNoUpdateLabel(row: AgentChildRowModel, now: number): string { + return agentNoUpdateLabel({ updatedAt: row.recencyAt }, now) +} + +/** The words for a row's detail, reusing the phrasing a CLI agent row uses for the same fact. */ +export function agentChildRowDetailText(row: AgentChildRowModel, now: number): string { + const detail = row.detail + if (!detail) { + return '' + } + switch (detail.kind) { + case 'operation': + return formatAgentToolPreview( + { toolName: detail.toolName, toolInput: detail.input }, + 'working' + ) + case 'monitoring': + return agentStateLabel('monitoring') + case 'message': + return detail.text + case 'ended': + return translate('components.agentChildRow.ended', 'Ended') + case 'no-update': + return agentChildRowNoUpdateLabel(row, now) + case 'role': + return formatAgentTypeLabel(detail.agentType) + case 'reason': + return backgroundTaskStateReason(detail.state) ?? '' + } +} + +/** The line beneath a full-width row: what the child said, or that it ended; '' otherwise. */ +export function agentChildRowMessageLine(row: AgentChildRowModel): string { + if (row.detail?.kind === 'message') { + return row.detail.text + } + return row.detail?.kind === 'ended' ? translate('components.agentChildRow.ended', 'Ended') : '' +} + +/** The row's name, or its state when the child reported none. */ +export function agentChildRowName(row: AgentChildRowModel): string { + return row.name.trim() || agentStateLabel(row.displayState) +} + +export function agentChildRowText(row: AgentChildRowModel, now: number): AgentChildRowText { + const name = agentChildRowName(row) + const detail = agentChildRowDetailText(row, now) + // Why: a monitoring row leads with its state so truncation keeps passive distinct from active. + if (row.displayState === 'monitoring' && detail) { + return { lead: detail, trail: detail === name ? '' : name } + } + return { lead: name, trail: detail } +} diff --git a/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx b/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx index aa752c4b50f..1f7b2ebb391 100644 --- a/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx +++ b/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx @@ -134,7 +134,6 @@ export function AgentSessionContinuationDialog({ prompt, worktreeId: request.worktreeId, groupId: request.groupId, - workspacePath: request.workspacePath, initialCwd: request.initialCwd, launchSource: request.launchSource }) diff --git a/src/renderer/src/components/artifacts/ArtifactCollection.test.tsx b/src/renderer/src/components/artifacts/ArtifactCollection.test.tsx index 7a275441885..7639cf82f9d 100644 --- a/src/renderer/src/components/artifacts/ArtifactCollection.test.tsx +++ b/src/renderer/src/components/artifacts/ArtifactCollection.test.tsx @@ -185,11 +185,11 @@ describe('ArtifactCollection', () => { // Why: happy-dom has no layout, and the virtualizer sizes its window from the scroller's // offsetHeight — left at 0 it mounts no rows at all and the assertions below would be vacuous. function stubScrollerViewport(): void { - vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation( - function (this: HTMLElement) { - return this.classList.contains('overflow-auto') ? 600 : 53 - } - ) + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function ( + this: HTMLElement + ) { + return this.classList.contains('overflow-auto') ? 600 : 53 + }) } function announcedSetSizes(container: HTMLElement): string[] { diff --git a/src/renderer/src/components/artifacts/ArtifactPublishButton.test.tsx b/src/renderer/src/components/artifacts/ArtifactPublishButton.test.tsx index 77f64b0217c..2b79aa81d31 100644 --- a/src/renderer/src/components/artifacts/ArtifactPublishButton.test.tsx +++ b/src/renderer/src/components/artifacts/ArtifactPublishButton.test.tsx @@ -1,12 +1,28 @@ // @vitest-environment happy-dom import '@testing-library/jest-dom/vitest' -import type { ReactNode } from 'react' +import { createRef, type ReactNode } from 'react' import { cleanup, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ +type ArtifactPublishButtonMocks = { + connect: ReturnType + openSettingsPage: ReturnType + openSettingsTarget: ReturnType + getPublishedLink: ReturnType + copyLink: ReturnType + openLink: ReturnType + publish: ReturnType + openPopover: ((open: boolean) => void) | null + closePopover: ((event: Event) => void) | null + state: { + orcaProfileAuthStatus: Record + settings: { artifactSharingEnabled: boolean } + } +} + +const mocks = vi.hoisted(() => ({ connect: vi.fn(), openSettingsPage: vi.fn(), openSettingsTarget: vi.fn(), @@ -14,9 +30,10 @@ const mocks = vi.hoisted(() => ({ copyLink: vi.fn(), openLink: vi.fn(), publish: vi.fn(), - openPopover: null as ((open: boolean) => void) | null, + openPopover: null, + closePopover: null, state: { - orcaProfileAuthStatus: { configured: true, state: 'connected' } as Record, + orcaProfileAuthStatus: { configured: true, state: 'connected' }, settings: { artifactSharingEnabled: true } } })) @@ -42,7 +59,17 @@ vi.mock('@/components/ui/popover', () => ({ mocks.openPopover = onOpenChange ?? null return <>{children} }, - PopoverContent: ({ children }: { children: ReactNode }) =>
{children}
, + PopoverAnchor: ({ children }: { children?: ReactNode }) => <>{children}, + PopoverContent: ({ + children, + onCloseAutoFocus + }: { + children: ReactNode + onCloseAutoFocus?: (event: Event) => void + }) => { + mocks.closePopover = onCloseAutoFocus ?? null + return
{children}
+ }, PopoverTrigger: ({ children }: { children: ReactNode }) => ( mocks.openPopover?.(true)}>{children} ) @@ -81,6 +108,7 @@ describe('ArtifactPublishButton', () => { mocks.getPublishedLink.mockResolvedValue(null) mocks.copyLink.mockResolvedValue(true) mocks.openPopover = null + mocks.closePopover = null mocks.state.orcaProfileAuthStatus = { configured: true, state: 'connected' } mocks.state.settings = { artifactSharingEnabled: true } }) @@ -101,6 +129,31 @@ describe('ArtifactPublishButton', () => { expect(screen.getByRole('button', { name: 'Update shared content' })).toBeInTheDocument() }) + it('supports a controlled virtual anchor without rendering a second trigger', async () => { + const anchorRef = createRef() + render( + <> + + + + ) + + expect(screen.queryByRole('button', { name: 'Share as artifact' })).toBeNull() + expect(await screen.findByRole('button', { name: 'Generate link' })).toBeInTheDocument() + + const focus = vi.spyOn(anchorRef.current!, 'focus') + const closeEvent = new Event('close', { cancelable: true }) + mocks.closePopover?.(closeEvent) + expect(closeEvent.defaultPrevented).toBe(true) + expect(focus).toHaveBeenCalledWith({ preventScroll: true }) + }) + it('offers sign-in and blocks confirmation while signed out', async () => { const user = userEvent.setup() mocks.state.orcaProfileAuthStatus = { configured: true, state: 'local' } diff --git a/src/renderer/src/components/artifacts/ArtifactPublishButton.tsx b/src/renderer/src/components/artifacts/ArtifactPublishButton.tsx index bf6fb236003..a90c1c037a9 100644 --- a/src/renderer/src/components/artifacts/ArtifactPublishButton.tsx +++ b/src/renderer/src/components/artifacts/ArtifactPublishButton.tsx @@ -1,8 +1,8 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState, type RefObject } from 'react' import { ArrowRight, Loader2, Share2 } from 'lucide-react' import type { ArtifactWriteRequest } from '../../../../shared/artifacts' import { Button } from '@/components/ui/button' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' @@ -21,14 +21,26 @@ export function ArtifactPublishButton({ sourceKey, createRequest, className, - disabled + disabled, + anchorRef, + open: controlledOpen, + onOpenChange }: { sourceKey: string createRequest: () => Promise className?: string disabled?: boolean -}): React.JSX.Element { - const [open, setOpen] = useState(false) +} & ( + | { anchorRef?: never; open?: never; onOpenChange?: never } + | { + anchorRef?: RefObject + open: boolean + onOpenChange: (open: boolean) => void + } +)): React.JSX.Element { + const [ownOpen, setOwnOpen] = useState(false) + const open = controlledOpen ?? ownOpen + const setOpen = onOpenChange ?? setOwnOpen const [publishing, setPublishing] = useState(false) const [lookupRevision, setLookupRevision] = useState(0) const [linkLookup, setLinkLookup] = useState(null) @@ -111,25 +123,32 @@ export function ArtifactPublishButton({ ) return ( !busy && setOpen(nextOpen)}> - - - - - - - - {label} - - + {anchorRef ? ( + } + /> + ) : ( + + + + + + + + {label} + + + )} { + if (!anchorRef) { + return + } + event.preventDefault() + anchorRef.current?.focus({ preventScroll: true }) + }} >

diff --git a/src/renderer/src/components/artifacts/artifact-list-windowing.test.tsx b/src/renderer/src/components/artifacts/artifact-list-windowing.test.tsx index 4723af3d015..ca8a4a916c0 100644 --- a/src/renderer/src/components/artifacts/artifact-list-windowing.test.tsx +++ b/src/renderer/src/components/artifacts/artifact-list-windowing.test.tsx @@ -303,11 +303,11 @@ beforeEach(() => { root = createRoot(host) vi.stubGlobal('ResizeObserver', MockResizeObserver) - vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation( - function (this: HTMLElement) { - return elementHeight(this) - } - ) + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function ( + this: HTMLElement + ) { + return elementHeight(this) + }) vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) { const top = topsByElement.get(this) ?? 0 const height = elementHeight(this) diff --git a/src/renderer/src/components/automations/AutomationEditorPromptEditor.tsx b/src/renderer/src/components/automations/AutomationEditorPromptEditor.tsx index 69de2374d27..2772a6ae24d 100644 --- a/src/renderer/src/components/automations/AutomationEditorPromptEditor.tsx +++ b/src/renderer/src/components/automations/AutomationEditorPromptEditor.tsx @@ -5,7 +5,7 @@ import { installMonacoEditorFindShortcut } from '@/components/editor/editor-shor import { syncContentOnMount, syncContentUpdate } from '@/components/editor/monaco-content-sync' import { isMonacoFindWidgetOpen } from '@/components/editor/monaco-find-widget' import { computeEditorFontSize, resolveEditorFontFamily } from '@/lib/editor-font-zoom' -import { resolveDocumentTheme } from '@/lib/document-theme' +import { useDocumentDarkTheme } from '@/hooks/use-document-dark-theme' import '@/lib/monaco-setup' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' @@ -74,7 +74,7 @@ export function AutomationEditorPromptEditor({ const fontSize = computeEditorFontSize(settings?.terminalFontSize ?? 13, editorFontZoomLevel) const fontFamily = resolveEditorFontFamily(settings) - const isDark = resolveDocumentTheme(settings?.theme ?? 'system') + const isDark = useDocumentDarkTheme() const options = useMemo( () => buildAutomationPromptEditorOptions({ diff --git a/src/renderer/src/components/automations/AutomationPromptDisclosure.test.tsx b/src/renderer/src/components/automations/AutomationPromptDisclosure.test.tsx index 6ac693aea82..e47619cf81f 100644 --- a/src/renderer/src/components/automations/AutomationPromptDisclosure.test.tsx +++ b/src/renderer/src/components/automations/AutomationPromptDisclosure.test.tsx @@ -70,21 +70,21 @@ describe('AutomationPromptDisclosure', () => { beforeEach(async () => { await i18n.changeLanguage('en') vi.stubGlobal('ResizeObserver', PromptResizeObserver) - vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation( - function (this: HTMLElement) { - if (this.tagName !== 'P') { - return 0 - } - return this.classList.contains('line-clamp-4') - ? Math.min(promptNaturalHeight, 80) - : promptNaturalHeight + vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(function ( + this: HTMLElement + ) { + if (this.tagName !== 'P') { + return 0 } - ) - vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation( - function (this: HTMLElement) { - return this.tagName === 'P' ? promptNaturalHeight : 0 - } - ) + return this.classList.contains('line-clamp-4') + ? Math.min(promptNaturalHeight, 80) + : promptNaturalHeight + }) + vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(function ( + this: HTMLElement + ) { + return this.tagName === 'P' ? promptNaturalHeight : 0 + }) }) afterEach(async () => { diff --git a/src/renderer/src/components/automations/automation-list-search.test.ts b/src/renderer/src/components/automations/automation-list-search.test.ts index 5e9e880726f..3a9b3844164 100644 --- a/src/renderer/src/components/automations/automation-list-search.test.ts +++ b/src/renderer/src/components/automations/automation-list-search.test.ts @@ -8,15 +8,11 @@ import { AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES, AUTOMATION_LIST_SEARCH_WORKSPACE_MAX_CODE_UNITS, AUTOMATION_LIST_SEARCH_UNKNOWN_PROJECT, - automationListSearchFieldsMatch, automationListSearchIndexMatches, buildAutomationListSearchFingerprint, buildAutomationListSearchIndex, buildAutomationProjectSearchText, clampAutomationListSearchQueryInput, - filterByActiveAutomationListSearchQuery, - filterByAutomationListSearch, - filterByAutomationListSearchIndex, getActiveAutomationListSearchQuery, isAutomationListSearchQueryTooLarge, normalizeAutomationListSearchField, @@ -38,19 +34,6 @@ describe('automation-list-search', () => { expect(isAutomationListSearchQueryTooLarge(oversized)).toBe(true) expect(getActiveAutomationListSearchQuery(oversized)).toBeNull() expect(resolveAutomationListSearchQuery(oversized)).toEqual({ status: 'too_large' }) - expect( - automationListSearchFieldsMatch( - { name: 'Auto PR', project: 'orca', prompt: 'nudge' }, - oversized - ) - ).toBe(false) - - const items = [ - { id: '1', name: 'Auto PR', project: 'orca', prompt: 'nudge' }, - { id: '2', name: 'Nightly', project: 'mobile', prompt: 'ship' } - ] - // Why: oversized paste must leave the list unfiltered, not blank it. - expect(filterByAutomationListSearch(items, oversized, (item) => item)).toBe(items) }) it('rejects queries over the byte limit but under the code-unit limit', () => { @@ -64,8 +47,6 @@ describe('automation-list-search', () => { it('treats whitespace-only queries as inactive (no search work)', () => { expect(getActiveAutomationListSearchQuery(' \t ')).toBeNull() expect(resolveAutomationListSearchQuery(' ')).toEqual({ status: 'inactive' }) - const items = [{ name: 'A', project: 'p1', prompt: 'one' }] - expect(filterByAutomationListSearch(items, ' ', (item) => item)).toBe(items) }) it('clamps stored query input so multi-MB pastes are discarded', () => { @@ -128,21 +109,6 @@ describe('automation-list-search', () => { expect(AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS).toBe(2048) }) - it('matches workspace, agent, and host alongside name, project, and prompt', () => { - const fields = { - name: 'Auto PR assignment', - project: 'orca / main', - workspace: 'feature/login-retry', - agent: 'Claude Code', - host: 'build-box', - prompt: 'Assign reviewers for open PRs' - } - for (const query of ['assignment', 'ORCA', 'login-retry', 'claude', 'build-box', 'reviewers']) { - expect(automationListSearchFieldsMatch(fields, query)).toBe(true) - } - expect(automationListSearchFieldsMatch(fields, 'missing')).toBe(false) - }) - it('bounds every indexed field, so no axis grows with its source', () => { const index = buildAutomationListSearchIndex({ name: 'n'.repeat(10_000), @@ -192,53 +158,6 @@ describe('automation-list-search', () => { ) }) - it('matches name, project, or prompt', () => { - const fields = { - name: 'Auto PR assignment', - project: 'orca / main', - prompt: 'Assign reviewers for open PRs' - } - expect(automationListSearchFieldsMatch(fields, 'assignment')).toBe(true) - expect(automationListSearchFieldsMatch(fields, 'ORCA')).toBe(true) - expect(automationListSearchFieldsMatch(fields, 'reviewers')).toBe(true) - expect(automationListSearchFieldsMatch(fields, 'missing')).toBe(false) - }) - - it('filters by active query without re-resolving bounds', () => { - const items = [ - { id: '1', name: 'Auto Issue assignment', project: 'orca', prompt: 'triage issues' }, - { id: '2', name: 'Nightly deploy', project: 'mobile', prompt: 'ship apk' }, - { id: '3', name: 'PR nudge', project: 'orca', prompt: 'remind reviewers' } - ] - const indexes = items.map((item) => - buildAutomationListSearchIndex({ - name: item.name, - project: item.project, - prompt: item.prompt - }) - ) - expect( - filterByActiveAutomationListSearchQuery(items, indexes, 'apk').map((item) => item.id) - ).toEqual(['2']) - expect( - filterByAutomationListSearchIndex(items, indexes, 'orca').map((item) => item.id) - ).toEqual(['1', '3']) - expect(filterByAutomationListSearchIndex(items, indexes, ' ')).toBe(items) - expect( - filterByAutomationListSearchIndex( - items, - indexes, - 'a'.repeat(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES + 1) - ) - ).toBe(items) - // Why: a desynchronized index must leave the list unfiltered, not blank it. - expect( - filterByActiveAutomationListSearchQuery(items, indexes.slice(0, 1), 'apk').map( - (item) => item.id - ) - ).toEqual(['1', '2', '3']) - }) - it('builds a stable fingerprint from search sources only', () => { const sources = [ { name: 'A', project: 'p1', prompt: 'one' }, diff --git a/src/renderer/src/components/automations/automation-list-search.ts b/src/renderer/src/components/automations/automation-list-search.ts index 68df699c0c0..944fab58786 100644 --- a/src/renderer/src/components/automations/automation-list-search.ts +++ b/src/renderer/src/components/automations/automation-list-search.ts @@ -1,8 +1,3 @@ -import { - getActiveAutomationListSearchQuery, - resolveAutomationListSearchQuery -} from './automation-list-search-query' - export { AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES, clampAutomationListSearchQueryInput, @@ -111,81 +106,6 @@ export function automationListSearchIndexMatches( return false } -export function automationListSearchFieldsMatch( - fields: AutomationListSearchFields, - rawQuery: string -): boolean { - const resolved = resolveAutomationListSearchQuery(rawQuery) - if (resolved.status === 'too_large') { - return false - } - if (resolved.status === 'inactive') { - return true - } - return automationListSearchIndexMatches(buildAutomationListSearchIndex(fields), resolved.query) -} - -/** - * Filters with an already-resolved active query. Callers must pass null/skip - * when search is inactive or too large so this never runs "for free". - */ -export function filterByActiveAutomationListSearchQuery( - items: readonly T[], - indexes: readonly AutomationListSearchIndex[], - activeQuery: string -): T[] { - if (indexes.length !== items.length) { - return [...items] - } - const matches: T[] = [] - for (let i = 0; i < items.length; i += 1) { - const item = items[i] - const index = indexes[i] - if (item !== undefined && index && automationListSearchIndexMatches(index, activeQuery)) { - matches.push(item) - } - } - return matches -} - -/** - * Filters items by a prebuilt index. Empty and oversized queries leave the - * original array reference untouched so the list stays unfiltered and search - * work is skipped entirely. - */ -export function filterByAutomationListSearchIndex( - items: readonly T[], - indexes: readonly AutomationListSearchIndex[], - rawQuery: string -): readonly T[] { - const activeQuery = getActiveAutomationListSearchQuery(rawQuery) - if (activeQuery === null) { - return items - } - return filterByActiveAutomationListSearchQuery(items, indexes, activeQuery) -} - -/** Builds indexes then filters. Prefer prebuilt indexes when filtering often. */ -export function filterByAutomationListSearch( - items: readonly T[], - rawQuery: string, - getFields: (item: T) => AutomationListSearchFields -): readonly T[] { - const activeQuery = getActiveAutomationListSearchQuery(rawQuery) - if (activeQuery === null) { - return items - } - const matches: T[] = [] - for (const item of items) { - if ( - automationListSearchIndexMatches(buildAutomationListSearchIndex(getFields(item)), activeQuery) - ) { - matches.push(item) - } - } - return matches -} - /** * Content fingerprint for search-relevant fields only. Used so list refresh * ticks that replace arrays with equivalent search content do not rebuild diff --git a/src/renderer/src/components/automations/automation-orca-save-operations.ts b/src/renderer/src/components/automations/automation-orca-save-operations.ts index b12874d0036..914a6b47843 100644 --- a/src/renderer/src/components/automations/automation-orca-save-operations.ts +++ b/src/renderer/src/components/automations/automation-orca-save-operations.ts @@ -33,6 +33,7 @@ import type { AutomationHostTarget } from './automation-host-client' import type { AutomationAuthorityChangeReason } from './automation-host-invalidation' import type { AutomationSaveContext } from './automation-save-context' import { automationAuthorityCatalogKey } from './automation-host-catalog-types' +import { createBrowserUuid } from '@/lib/browser-uuid' export type AutomationMoveOperationContext = { automationDispatchContext: AutomationDispatchContext @@ -165,7 +166,7 @@ export async function moveAutomationToDestination( } const operationKey = `${source.id}:${target.entry.stableKey}` - const creationKey = context.moveCreationKeysRef.current.get(operationKey) ?? crypto.randomUUID() + const creationKey = context.moveCreationKeysRef.current.get(operationKey) ?? createBrowserUuid() context.moveCreationKeysRef.current.set(operationKey, creationKey) const created = toDispatchResult( await createAutomationAtDestination( diff --git a/src/renderer/src/components/automations/automation-scoped-list-client.test.ts b/src/renderer/src/components/automations/automation-scoped-list-client.test.ts index e07399319fb..43319886180 100644 --- a/src/renderer/src/components/automations/automation-scoped-list-client.test.ts +++ b/src/renderer/src/components/automations/automation-scoped-list-client.test.ts @@ -288,7 +288,7 @@ describe('owner-fenced mutations', () => { }) it('fences a desktop mutation over the local runtime target with the same precondition', async () => { - const { deleteAutomationForOwner, updateAutomationForOwner } = await client() + const { updateAutomationForOwner } = await client() callRuntimeRpc.mockResolvedValue({ automation: { id: 'a1' } }) await updateAutomationForOwner({ authority: DESKTOP, selector: { kind: 'self' } }, 'a1', { enabled: true @@ -300,7 +300,6 @@ describe('owner-fenced mutations', () => { expect.anything() ) expect(getRuntimeEnvironmentStatus).not.toHaveBeenCalled() - expect(typeof deleteAutomationForOwner).toBe('function') }) }) diff --git a/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx b/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx index b7c210c3818..ed8cc04c31f 100644 --- a/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx +++ b/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx @@ -4,7 +4,7 @@ * STA-3811: imports never touch the Google cookie family, so every import menu must disclose it * at the moment of decision. */ -import { act, type ReactNode } from 'react' +import { act, createRef, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import en from '@/i18n/locales/en.json' @@ -67,6 +67,12 @@ describe('cookie-import Google disclosure footer', () => { onImportFromFile={vi.fn()} viewportPresetId={null} onApplyViewportPreset={vi.fn()} + overflow={{ + triggerRef: createRef(), + tools: [], + deferUntilClose: vi.fn(), + onMenuCloseAutoFocus: vi.fn() + }} /> ) ], diff --git a/src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx b/src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx index 4eb4174211b..24524a10dc7 100644 --- a/src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx +++ b/src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx @@ -1,6 +1,4 @@ import React from 'react' -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' import { describe, expect, it, vi } from 'vitest' import { BrowserAnnotationSendMenuContent } from './BrowserAnnotationSendMenuContent' @@ -92,19 +90,4 @@ describe('BrowserAnnotationSendMenuContent', () => { onPromptDelivered }) }) - - it('is wired into both browser annotation send surfaces', () => { - const bannerSource = readFileSync( - fileURLToPath(new URL('../assemble-chrome/browser-page-chrome-banners.tsx', import.meta.url)), - 'utf8' - ) - const traySource = readFileSync( - fileURLToPath(new URL('./browser-page-annotation-tray.tsx', import.meta.url)), - 'utf8' - ) - const sendSurfaces = `${bannerSource}\n${traySource}` - - expect(sendSurfaces.match(/ - {translate('auto.components.browser.pane.BrowserImportHintButton.b24fef25be', 'Import')} + {compact + ? null + : translate( + 'auto.components.browser.pane.BrowserImportHintButton.b24fef25be', + 'Import' + )} diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.remote-link-routing.test.ts b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.remote-link-routing.test.ts deleted file mode 100644 index 856ac24c725..00000000000 --- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.remote-link-routing.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' - -describe('remote browser link routing', () => { - it('pins context-menu opens to the runtime that owns the pane', () => { - const source = readFileSync( - fileURLToPath(new URL('../stream-remote/remote-browser-page-pane.tsx', import.meta.url)), - 'utf8' - ) - const paneStart = source.indexOf('function RemoteBrowserPagePane') - const actionStart = source.indexOf('void openWorkspaceBrowserTab({', paneStart) - const actionEnd = source.indexOf('}).catch((error) => {', actionStart) - const openRequest = source.slice(actionStart, actionEnd) - - expect(openRequest).toContain('workspaceId: worktreeId') - expect(openRequest).toContain('expectedRuntimeEnvironmentId: runtimeEnvironmentId') - expect(openRequest).toContain("placementPreference: 'server'") - }) -}) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.render-ipc.test.ts b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.render-ipc.test.ts deleted file mode 100644 index 9e42075283c..00000000000 --- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.render-ipc.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' - -describe('BrowserPagePane render IPC boundary', () => { - it('derives toolbar URLs without querying the webview', () => { - const source = readFileSync( - fileURLToPath(new URL('./browser-page-pane.tsx', import.meta.url)), - 'utf8' - ) - const start = source.indexOf('const isBlankTab =', source.indexOf('function BrowserPagePane')) - const end = source.indexOf('useEffect(() => {', start) - const renderUrlDerivation = source.slice(start, end) - - expect(renderUrlDerivation).toContain('getLiveBrowserUrl(browserTab.id)') - expect(renderUrlDerivation).not.toContain('webviewRef') - expect(renderUrlDerivation).not.toContain('.getURL(') - }) -}) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserToolbarMenu.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserToolbarMenu.tsx index 026931d2554..c964a3fb6f3 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserToolbarMenu.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserToolbarMenu.tsx @@ -12,6 +12,7 @@ import { import { BrowserToolbarMenuDropdown } from './browser-toolbar-menu-dropdown' import { BrowserToolbarProfileDialogs } from './browser-toolbar-profile-dialogs' import { translate } from '@/i18n/i18n' +import type { BrowserChromeOverflowMenuProps } from './browser-chrome-folded-tools' type BrowserToolbarMenuProps = { currentProfileId: string | null @@ -20,6 +21,7 @@ type BrowserToolbarMenuProps = { viewportPresetId: BrowserViewportPresetId | null onDestroyWebview: () => void isActive: boolean + overflow: BrowserChromeOverflowMenuProps } export function BrowserToolbarMenu({ @@ -28,7 +30,8 @@ export function BrowserToolbarMenu({ browserPageId, viewportPresetId, onDestroyWebview, - isActive + isActive, + overflow }: BrowserToolbarMenuProps): React.JSX.Element { const browserSessionProfiles = useAppStore((s) => s.browserSessionProfiles) const detectedBrowsers = useAppStore((s) => s.detectedBrowsers) @@ -241,6 +244,7 @@ export function BrowserToolbarMenu({ onImportFromFile={() => void handleImportFromFile()} viewportPresetId={viewportPresetId} onApplyViewportPreset={applyViewportPreset} + overflow={overflow} /> void + disabled: boolean + grabShortcutLabel: string + annotationCount: number +} + +export function BrowserChromeElementToolButtons({ + tools, + showGrab, + showAnnotate, + showTourAnchors +}: { + tools: BrowserChromeElementTools + showGrab: boolean + showAnnotate: boolean + showTourAnchors: boolean +}): React.JSX.Element { + return ( + <> + {showGrab ? ( + + + + + + + + {translate( + 'auto.components.browser.pane.BrowserPane.acbe79fd01', + 'Grab page element ({{value0}})', + { value0: tools.grabShortcutLabel } + )} + + + ) : null} + + {showAnnotate ? ( + + + {/* Why: disabled buttons drop hover events, so the tooltip needs an enabled wrapper. */} + + + + + + {translate( + 'auto.components.browser.pane.BrowserPane.fc9be38f6f', + 'Annotate page element' + )} + + + ) : null} + + ) +} diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-folded-tools.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-folded-tools.test.tsx new file mode 100644 index 00000000000..0247f46509b --- /dev/null +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-folded-tools.test.tsx @@ -0,0 +1,107 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Share2 } from 'lucide-react' + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenuItem: ({ + children, + onSelect, + role, + 'aria-checked': ariaChecked + }: { + children: React.ReactNode + onSelect: () => void + role?: string + 'aria-checked'?: boolean + }) => ( + + ), + DropdownMenuSeparator: () =>
, + DropdownMenuShortcut: ({ children }: { children: React.ReactNode }) => {children} +})) + +import { BrowserChromeFoldedMenuItems } from './browser-chrome-folded-tools' + +afterEach(cleanup) + +describe('BrowserChromeFoldedMenuItems', () => { + it('runs ordinary actions directly', () => { + const onSelect = vi.fn() + render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Share' })) + expect(onSelect).toHaveBeenCalledOnce() + }) + + it('defers popover actions until the menu closes', () => { + const onSelect = vi.fn() + const deferUntilClose = vi.fn() + render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Share' })) + expect(onSelect).not.toHaveBeenCalled() + expect(deferUntilClose).toHaveBeenCalledWith(onSelect) + }) + + it('preserves active state for folded toggle tools', () => { + render( + + ) + + expect( + screen.getByRole('menuitemcheckbox', { name: 'Draw' }).getAttribute('aria-checked') + ).toBe('true') + }) + + it('gives toggle tools their own section, split from the actions by a divider', () => { + const { container } = render( + + ) + + // Rendered order: Draw, divider, Share, closing divider. + expect(Array.from(container.querySelectorAll('button, hr')).map((el) => el.tagName)).toEqual([ + 'BUTTON', + 'HR', + 'BUTTON', + 'HR' + ]) + }) +}) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-folded-tools.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-folded-tools.tsx new file mode 100644 index 00000000000..3b6c4fd55e0 --- /dev/null +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-folded-tools.tsx @@ -0,0 +1,106 @@ +import { Check } from 'lucide-react' +import type { LucideIcon } from 'lucide-react' +import { + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut +} from '@/components/ui/dropdown-menu' +import type { BrowserChromeFoldStage } from './use-browser-chrome-tool-fold' + +export type BrowserChromeFoldedTool = { + stage: BrowserChromeFoldStage + label: string + icon: LucideIcon + onSelect: () => void + disabled?: boolean + shortcut?: string + count?: number + /** Present for toggle tools, including false, so the menu preserves their pressed state. */ + active?: boolean + /** + * Run only after the menu has closed and skip its focus return. A tool that opens its own + * popover needs this: focus landing back on the ⋯ trigger would dismiss that popover at once. + */ + deferUntilMenuClose?: boolean +} + +export type BrowserChromeOverflowMenuProps = { + triggerRef: React.RefObject + tools: readonly BrowserChromeFoldedTool[] + deferUntilClose: (action: () => void) => void + onMenuCloseAutoFocus: (event: Event) => void +} + +function FoldedToolContent({ tool }: { tool: BrowserChromeFoldedTool }): React.JSX.Element { + const showActiveCheck = tool.active === true + const showTrailing = showActiveCheck || Boolean(tool.count) || Boolean(tool.shortcut) + return ( + <> + + {tool.label} + {showTrailing ? ( + // Why: a leading check column would push these icon rows out of line with the plain rows. + + {showActiveCheck ? : null} + {tool.count ? ( + + {tool.count} + + ) : tool.shortcut ? ( + {tool.shortcut} + ) : null} + + ) : null} + + ) +} + +function FoldedToolRow({ + tool, + deferUntilClose +}: { + tool: BrowserChromeFoldedTool + deferUntilClose: (action: () => void) => void +}): React.JSX.Element { + const onSelect = (): void => + tool.deferUntilMenuClose ? deferUntilClose(tool.onSelect) : tool.onSelect() + return ( + + + + ) +} + +/** The toolbar tools that no longer fit, rendered at the top of a surface's ⋯ menu. */ +export function BrowserChromeFoldedMenuItems({ + tools, + deferUntilClose +}: Pick): React.JSX.Element | null { + if (tools.length === 0) { + return null + } + // Why: the element/markup toggles fold first and carry on/off state, so they read as their own + // section ahead of the one-shot actions below rather than as one flat tool list. + const toggleTools = tools.filter((tool) => tool.active !== undefined) + const actionTools = tools.filter((tool) => tool.active === undefined) + return ( + <> + {toggleTools.map((tool) => ( + + ))} + {toggleTools.length > 0 && actionTools.length > 0 ? : null} + {actionTools.map((tool) => ( + + ))} + + + ) +} diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-toolbar.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-toolbar.test.tsx new file mode 100644 index 00000000000..5e014d3a8bf --- /dev/null +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-toolbar.test.tsx @@ -0,0 +1,158 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { useState } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { BrowserChromeFoldStage } from './use-browser-chrome-tool-fold' +import type { BrowserChromeFoldedTool } from './browser-chrome-folded-tools' + +const mocks = vi.hoisted(() => { + const stages: readonly BrowserChromeFoldStage[] = [] + return { folded: new Set(), stages } +}) + +vi.mock('./use-browser-chrome-tool-fold', () => ({ + BROWSER_CHROME_FOLD_ORDER: [ + 'import-label', + 'external', + 'devtools', + 'share', + 'import', + 'draw', + 'grab', + 'annotate' + ], + useBrowserChromeToolFold: (_rowRef: unknown, stages: readonly BrowserChromeFoldStage[]) => { + mocks.stages = stages + return mocks.folded + } +})) + +vi.mock('./browser-navigation-control-row', () => ({ + BrowserNavigationControlRow: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ) +})) + +vi.mock('../annotate/MarkupDrawButton', () => ({ + MarkupDrawButton: () => +})) + +vi.mock('./browser-chrome-element-tool-buttons', () => ({ + BrowserChromeElementToolButtons: () => null +})) + +import { BrowserChromeToolbar } from './browser-chrome-toolbar' + +const controls = { + canGoBack: false, + canGoForward: false, + loading: false, + goBack: vi.fn(), + goForward: vi.fn(), + reload: vi.fn(), + navigate: vi.fn() +} +const emptyOverflowMenu = (): null => null + +beforeEach(() => { + mocks.folded = new Set() + mocks.stages = [] +}) + +afterEach(cleanup) + +describe('BrowserChromeToolbar', () => { + it('does not duplicate an action already provided by the surface menu', () => { + mocks.folded = new Set(['devtools', 'external']) + let foldedTools: readonly BrowserChromeFoldedTool[] = [] + + render( + { + foldedTools = overflow.tools + return null + }} + /> + ) + + expect(foldedTools.map((tool) => tool.stage)).toEqual(['external']) + }) + + it('compacts the import hint before removing it', () => { + mocks.folded = new Set(['import-label']) + const importControl = vi.fn(() => null) + + render( + + ) + + expect(importControl).toHaveBeenCalledWith(true) + }) + + it('keeps the active tour control out of the fold sequence', () => { + render( + + ) + + expect(mocks.stages).not.toContain('grab') + expect(mocks.stages).toContain('annotate') + }) + + it('keeps the same share control mounted when it moves into overflow', () => { + function StatefulShare(): React.JSX.Element { + const [clicks, setClicks] = useState(0) + return + } + const props = { + controls, + addressSlot: null, + elementTools: null, + markup: { active: false, disabled: false, onToggle: vi.fn(), canShowDiscoveryHint: false }, + shareControl: () => , + viewSource: null, + openExternal: null, + overflowMenu: emptyOverflowMenu + } + const view = render() + fireEvent.click(screen.getByRole('button', { name: 'Share state 0' })) + + mocks.folded = new Set(['share']) + view.rerender() + + expect(screen.getByRole('button', { name: 'Share state 1' })).not.toBeNull() + }) +}) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-toolbar.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-toolbar.tsx index a314ed69215..59db797397d 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-toolbar.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-toolbar.tsx @@ -1,24 +1,35 @@ -import { cn } from '@/lib/utils' -import { Crosshair, ExternalLink, MessageSquarePlus, SquareCode } from 'lucide-react' +import { useRef, useState } from 'react' +import { + Crosshair, + ExternalLink, + MessageSquarePlus, + PenTool, + Share2, + SquareCode +} from 'lucide-react' +import type { LucideIcon } from 'lucide-react' import { Button } from '@/components/ui/button' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { BrowserNavigationControlRow, type BrowserNavigationControls } from './browser-navigation-control-row' import { MarkupDrawButton } from '../annotate/MarkupDrawButton' -import type { GrabIntent } from '../describe-page/browser-page-types' +import type { + BrowserChromeFoldedTool, + BrowserChromeOverflowMenuProps +} from './browser-chrome-folded-tools' +import { + BROWSER_CHROME_FOLD_ORDER, + useBrowserChromeToolFold, + type BrowserChromeFoldStage +} from './use-browser-chrome-tool-fold' +import { + BrowserChromeElementToolButtons, + type BrowserChromeElementTools +} from './browser-chrome-element-tool-buttons' -/** The in-guest element picker, driving both Grab (copy) and Annotate (comment). */ -export type BrowserChromeElementTools = { - /** The intent the picker is armed for right now, or null when it is idle. */ - activeIntent: GrabIntent | null - onStartIntent: (intent: GrabIntent) => void - disabled: boolean - grabShortcutLabel: string - annotationCount: number -} +export type { BrowserChromeElementTools } from './browser-chrome-element-tool-buttons' export type BrowserChromeMarkupTool = { active: boolean @@ -33,10 +44,40 @@ export type BrowserChromeMarkupTool = { canShowDiscoveryHint: boolean } +export type BrowserChromeShareControl = { + open: boolean + onOpenChange: (open: boolean) => void + anchorRef?: React.RefObject +} + export type BrowserChromeToolAction = { onSelect: () => void label: string disabled?: boolean + /** True when the surface's overflow menu already exposes this action. */ + alreadyInOverflowMenu?: boolean +} + +function BrowserChromeActionButton({ + action, + icon: Icon +}: { + action: BrowserChromeToolAction + icon: LucideIcon +}): React.JSX.Element { + return ( + + ) } /** @@ -59,148 +100,170 @@ export function BrowserChromeToolbar({ viewSource, openExternal, overflowMenu, - showTourAnchors = false + showTourAnchors = false, + pinnedStage }: { controls: BrowserNavigationControls addressSlot: React.ReactNode reloadControl?: React.ReactNode reloadLabel?: string /** Cookie import — a browsing session concept; null where there is no session to import into. */ - importControl?: React.ReactNode + importControl?: ((compact: boolean) => React.ReactNode) | null elementTools: BrowserChromeElementTools | null markup: BrowserChromeMarkupTool - shareControl?: React.ReactNode + shareControl?: (control: BrowserChromeShareControl) => React.ReactNode viewSource: BrowserChromeToolAction | null openExternal: BrowserChromeToolAction | null - overflowMenu?: React.ReactNode + overflowMenu: (props: BrowserChromeOverflowMenuProps) => React.ReactNode /** Only the browsing pane anchors the contextual tour; a second anchor would steal its steps. */ showTourAnchors?: boolean + /** Keeps the active contextual-tour control measurable while the remaining tools still fold. */ + pinnedStage?: BrowserChromeFoldStage }): React.JSX.Element { + const rowRef = useRef(null) + const overflowTriggerRef = useRef(null) + const present: Record = { + 'import-label': importControl != null, + import: importControl != null, + external: openExternal !== null, + devtools: viewSource !== null, + share: shareControl != null, + draw: true, + grab: elementTools !== null, + annotate: elementTools !== null + } + const stages = BROWSER_CHROME_FOLD_ORDER.filter( + (stage) => present[stage] && stage !== pinnedStage + ) + const folded = useBrowserChromeToolFold(rowRef, stages) + + const [sharePopoverOpen, setSharePopoverOpen] = useState(false) + const afterMenuCloseRef = useRef<(() => void) | null>(null) + + const foldedTools: BrowserChromeFoldedTool[] = [] + if (elementTools && folded.has('grab')) { + foldedTools.push({ + stage: 'grab', + label: translate('auto.components.browser.pane.BrowserPane.fdfc7fe0ef', 'Grab page element'), + icon: Crosshair, + onSelect: () => elementTools.onStartIntent('copy'), + disabled: elementTools.disabled, + shortcut: elementTools.grabShortcutLabel, + active: elementTools.activeIntent === 'copy' + }) + } + if (elementTools && folded.has('annotate')) { + foldedTools.push({ + stage: 'annotate', + label: translate( + 'auto.components.browser.pane.BrowserPane.fc9be38f6f', + 'Annotate page element' + ), + icon: MessageSquarePlus, + onSelect: () => elementTools.onStartIntent('annotate'), + disabled: elementTools.disabled, + count: elementTools.annotationCount, + active: elementTools.activeIntent === 'annotate' + }) + } + if (folded.has('draw')) { + foldedTools.push({ + stage: 'draw', + label: translate('auto.components.browser-pane.markup.drawButton', 'Draw on screenshot'), + icon: PenTool, + onSelect: markup.onToggle, + disabled: markup.disabled, + active: markup.active + }) + } + if (shareControl && folded.has('share')) { + foldedTools.push({ + stage: 'share', + label: translate( + 'auto.components.artifacts.ArtifactPublishButton.a4a49da6af', + 'Share as artifact' + ), + icon: Share2, + onSelect: () => setSharePopoverOpen(true), + deferUntilMenuClose: true + }) + } + if (viewSource && folded.has('devtools') && !viewSource.alreadyInOverflowMenu) { + foldedTools.push({ stage: 'devtools', icon: SquareCode, ...viewSource }) + } + if (openExternal && folded.has('external') && !openExternal.alreadyInOverflowMenu) { + foldedTools.push({ stage: 'external', icon: ExternalLink, ...openExternal }) + } + + const runAfterMenuClose = (action: () => void): void => { + afterMenuCloseRef.current = action + } + const onMenuCloseAutoFocus = (event: Event): void => { + const action = afterMenuCloseRef.current + if (!action) { + return + } + afterMenuCloseRef.current = null + event.preventDefault() + action() + } + const showFoldedAnnotationDot = folded.has('annotate') && (elementTools?.annotationCount ?? 0) > 0 + return ( - {importControl} + {folded.has('import') ? null : importControl?.(folded.has('import-label'))} {elementTools ? ( - <> - - - - - - - - {translate( - 'auto.components.browser.pane.BrowserPane.acbe79fd01', - 'Grab page element ({{value0}})', - { value0: elementTools.grabShortcutLabel } - )} - - - - - - {/* Why: disabled - - - - {translate( - 'auto.components.browser.pane.BrowserPane.fc9be38f6f', - 'Annotate page element' - )} - - - + ) : null} - + {folded.has('draw') ? null : ( + + )} - {shareControl} + {shareControl?.({ + open: sharePopoverOpen, + onOpenChange: setSharePopoverOpen, + anchorRef: folded.has('share') ? overflowTriggerRef : undefined + })} - {viewSource ? ( - + {viewSource && !folded.has('devtools') ? ( + ) : null} - {openExternal ? ( - + {openExternal && !folded.has('external') ? ( + ) : null} - {overflowMenu} + + {overflowMenu({ + triggerRef: overflowTriggerRef, + tools: foldedTools, + deferUntilClose: runAfterMenuClose, + onMenuCloseAutoFocus + })} + {/* Why: keeps pending annotations visible once the annotate button has folded into ⋯. */} + {showFoldedAnnotationDot ? ( + + ) : null} + ) } diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-navigation-control-row.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-navigation-control-row.tsx index 236251c64ef..999f890a37f 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-navigation-control-row.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-navigation-control-row.tsx @@ -36,6 +36,7 @@ export function BrowserNavigationControlRow({ reloadControl, reloadLabel, showTourAnchors = true, + rowRef, children }: { controls: BrowserNavigationControls @@ -46,10 +47,12 @@ export function BrowserNavigationControlRow({ reloadLabel?: string /** Off for surfaces the browsing tour does not cover — a second anchor would steal its steps. */ showTourAnchors?: boolean + rowRef?: React.Ref children?: React.ReactNode }): React.JSX.Element { return (
diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-context-menu.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-context-menu.tsx index fb1830bd41f..22f12bfea4e 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-context-menu.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-context-menu.tsx @@ -11,6 +11,7 @@ import { createPortal } from 'react-dom' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { normalizeExternalBrowserUrl } from '../../../../../shared/browser-url' +import { resolveBrowserSourceUnifiedTab } from '@/lib/browser-workspace-source-resolution' import type { BrowserPageContextMenuState } from '../describe-page/browser-page-types' // `focus:` rather than `focus-visible:` — items are only ever focused programmatically @@ -186,9 +187,18 @@ export function BrowserPageContextMenu({ role="menuitem" className={MENU_ITEM_CLASS} onClick={() => { + const sourceUnifiedTab = resolveBrowserSourceUnifiedTab( + useAppStore.getState(), + browserPageId, + worktreeId + ) createBrowserTab(worktreeId, contextMenu.linkUrl!, { title: contextMenu.linkUrl!, - activate: false + activate: false, + ...(sourceUnifiedTab ? { afterTabId: sourceUnifiedTab.id } : {}), + ...(sourceUnifiedTab?.executionHostId + ? { executionHostId: sourceUnifiedTab.executionHostId } + : {}) }) closeMenu() }} diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-toolbar.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-toolbar.tsx index 616cca726ec..f684e9a1b46 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-toolbar.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-toolbar.tsx @@ -1,6 +1,7 @@ import type { Dispatch, RefObject, SetStateAction } from 'react' import { ArtifactPublishButton } from '@/components/artifacts/ArtifactPublishButton' import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' import type { BrowserReloadTrigger } from '../navigate/browser-reload-action' import BrowserAddressBar from './BrowserAddressBar' import { BrowserChromeToolbar } from './browser-chrome-toolbar' @@ -102,9 +103,20 @@ export function BrowserPageToolbar({ currentBrowserUrl: string externalUrl: string | null }): React.JSX.Element { + const browserTourStep = useAppStore((state) => + state.activeContextualTourId === 'browser' ? state.activeContextualTourStepIndex : null + ) + const pinnedStage = + browserTourStep === 0 + ? ('grab' as const) + : browserTourStep === 1 + ? ('annotate' as const) + : undefined + return ( runReloadTrigger('hard-reload')} /> } - importControl={} + importControl={(compact) => ( + + )} elementTools={{ activeIntent: grab.state !== 'idle' ? grabIntent : null, onStartIntent: startGrabIntent, @@ -176,13 +190,16 @@ export function BrowserPageToolbar({ canShowDiscoveryHint: isActive }} shareControl={ - shareableArtifactFile ? ( - readBrowserHtmlArtifactRequest(currentBrowserUrl)} - /> - ) : null + shareableArtifactFile + ? (control) => { + const props = { + sourceKey: shareableArtifactFile.filePath, + className: 'h-7 w-7', + createRequest: () => readBrowserHtmlArtifactRequest(currentBrowserUrl) + } + return + } + : undefined } viewSource={{ onSelect: () => void window.api.browser.openDevTools({ browserPageId }), @@ -204,7 +221,7 @@ export function BrowserPageToolbar({ ), disabled: !externalUrl }} - overflowMenu={ + overflowMenu={(overflow) => ( destroyPersistentWebview(browserPageId)} isActive={isActive} + overflow={overflow} /> - } + )} /> ) } diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-toolbar-menu-dropdown.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-toolbar-menu-dropdown.tsx index 0d088a44edd..63098b9b2b4 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-toolbar-menu-dropdown.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-toolbar-menu-dropdown.tsx @@ -30,6 +30,10 @@ type DetectedBrowserEntry = { } import { BROWSER_VIEWPORT_PRESETS } from '../../../../../shared/browser-viewport-presets' import { translate } from '@/i18n/i18n' +import { + BrowserChromeFoldedMenuItems, + type BrowserChromeOverflowMenuProps +} from './browser-chrome-folded-tools' type BrowserToolbarMenuDropdownProps = { menuOpen: boolean @@ -45,6 +49,7 @@ type BrowserToolbarMenuDropdownProps = { onImportFromFile: () => void viewportPresetId: BrowserViewportPresetId | null onApplyViewportPreset: (nextId: BrowserViewportPresetId | null) => void + overflow: BrowserChromeOverflowMenuProps } export function BrowserToolbarMenuDropdown({ @@ -60,12 +65,14 @@ export function BrowserToolbarMenuDropdown({ onImportFromBrowser, onImportFromFile, viewportPresetId, - onApplyViewportPreset + onApplyViewportPreset, + overflow }: BrowserToolbarMenuDropdownProps): React.JSX.Element { return ( - + + {allProfiles.map((profile) => { const isSelectedProfile = profile.id === effectiveProfileId return ( diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-chrome-tool-fold.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-chrome-tool-fold.test.tsx new file mode 100644 index 00000000000..6edb0570a8c --- /dev/null +++ b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-chrome-tool-fold.test.tsx @@ -0,0 +1,159 @@ +// @vitest-environment happy-dom +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useRef } from 'react' +import { BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE } from './browser-chrome-address-slot' +import { + BROWSER_CHROME_ADDRESS_MIN_WIDTH_PX, + BROWSER_CHROME_FOLD_ORDER, + useBrowserChromeToolFold +} from './use-browser-chrome-tool-fold' + +const FIXED_WIDTH = 100 +const TOOL_WIDTH = 30 + +// A flex row where the address takes whatever the fixed controls and visible tools leave over. +const layout = { rowWidth: 600, visibleTools: BROWSER_CHROME_FOLD_ORDER.length } +let resizeCallbacks: (() => void)[] = [] + +function Host({ + onFolded, + stages = BROWSER_CHROME_FOLD_ORDER +}: { + onFolded: (count: number) => void + stages?: readonly (typeof BROWSER_CHROME_FOLD_ORDER)[number][] +}): React.JSX.Element { + const rowRef = useRef(null) + const folded = useBrowserChromeToolFold(rowRef, stages) + layout.visibleTools = stages.length - folded.size + onFolded(folded.size) + return ( +
+
+
+ ) +} + +function contentWidth(): number { + return FIXED_WIDTH + TOOL_WIDTH * layout.visibleTools +} + +beforeEach(() => { + resizeCallbacks = [] + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(callback: () => void) { + resizeCallbacks.push(callback) + } + observe(): void {} + disconnect(): void {} + } + ) + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => layout.rowWidth) + vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(() => + Math.max(layout.rowWidth, contentWidth()) + ) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => + DOMRect.fromRect({ width: Math.max(0, layout.rowWidth - contentWidth()) }) + ) +}) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +function resizeTo(width: number): void { + layout.rowWidth = width + act(() => resizeCallbacks.forEach((callback) => callback())) +} + +function expectedFolded(width: number): number { + const room = width - FIXED_WIDTH - BROWSER_CHROME_ADDRESS_MIN_WIDTH_PX + const fits = Math.max(0, Math.floor(room / TOOL_WIDTH)) + return Math.max(0, BROWSER_CHROME_FOLD_ORDER.length - fits) +} + +describe('useBrowserChromeToolFold', () => { + it('keeps every tool inline when the address has its minimum width', () => { + let folded = -1 + layout.rowWidth = 600 + render( (folded = count)} />) + expect(folded).toBe(0) + }) + + it('folds just enough tools to give the address its minimum width', () => { + let folded = -1 + layout.rowWidth = 300 + render( (folded = count)} />) + expect(folded).toBe(expectedFolded(300)) + expect(folded).toBeGreaterThan(0) + }) + + it('unfolds as the row widens and refolds as it narrows', () => { + let folded = -1 + layout.rowWidth = 600 + render( (folded = count)} />) + + resizeTo(260) + expect(folded).toBe(expectedFolded(260)) + + resizeTo(400) + expect(folded).toBe(expectedFolded(400)) + + resizeTo(700) + expect(folded).toBe(0) + }) + + it('folds everything but lets the address shrink once no tool is left', () => { + let folded = -1 + layout.rowWidth = 150 + render( (folded = count)} />) + expect(folded).toBe(BROWSER_CHROME_FOLD_ORDER.length) + }) + + it('leaves a hidden (zero-width) row alone', () => { + let folded = -1 + layout.rowWidth = 0 + render( (folded = count)} />) + expect(folded).toBe(0) + }) + + it('measures when a hidden row becomes visible', () => { + let folded = -1 + layout.rowWidth = 0 + render( (folded = count)} />) + + resizeTo(300) + expect(folded).toBe(expectedFolded(300)) + }) + + it('discards measurements when the available tools change', () => { + let folded = -1 + layout.rowWidth = 300 + const view = render( (folded = count)} />) + expect(folded).toBeGreaterThan(0) + + view.rerender( (folded = count)} stages={['grab', 'annotate']} />) + expect(folded).toBe(0) + + resizeTo(150) + expect(folded).toBe(2) + }) + + it('does not restore an obsolete fold level when a tool set returns', () => { + let folded = -1 + layout.rowWidth = 300 + const view = render( (folded = count)} />) + expect(folded).toBeGreaterThan(0) + + layout.rowWidth = 600 + view.rerender( (folded = count)} stages={['grab']} />) + expect(folded).toBe(0) + + view.rerender( (folded = count)} />) + expect(folded).toBe(0) + }) +}) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-chrome-tool-fold.ts b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-chrome-tool-fold.ts new file mode 100644 index 00000000000..02b1fa7e9ec --- /dev/null +++ b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-chrome-tool-fold.ts @@ -0,0 +1,115 @@ +import { useLayoutEffect, useRef, useState, type RefObject } from 'react' +import { BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE } from './browser-chrome-address-slot' + +/** + * One step of toolbar compaction. Steps apply in this order as the row narrows, so the least-used + * tools leave first and the page tools the chrome exists for (grab, annotate) leave last. + */ +export type BrowserChromeFoldStage = + | 'import-label' + | 'external' + | 'devtools' + | 'share' + | 'import' + | 'draw' + | 'grab' + | 'annotate' + +export const BROWSER_CHROME_FOLD_ORDER: readonly BrowserChromeFoldStage[] = [ + 'import-label', + 'external', + 'devtools', + 'share', + 'import', + 'draw', + 'grab', + 'annotate' +] + +/** Below this the URL stops being readable, so tools fold before the address shrinks further. */ +export const BROWSER_CHROME_ADDRESS_MIN_WIDTH_PX = 120 + +type RowMeasure = { address: number; overflow: number } + +function measureRow(row: HTMLElement | null): RowMeasure | null { + const slot = row?.querySelector(`[${BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE}]`) + // Why: a hidden pane measures zero; folding everything there would flash on reveal. + if (!row || !slot || row.clientWidth === 0) { + return null + } + return { + address: slot.getBoundingClientRect().width, + overflow: Math.max(0, row.scrollWidth - row.clientWidth) + } +} + +/** + * Folds `stages` one at a time until the address slot keeps its minimum width and the row stops + * overflowing, and unfolds as room returns. + * + * Why record the width each fold freed instead of re-measuring from zero on every resize: resetting + * would re-render the toolbar once per stage per resize frame. A fold is undone only when the + * address has that much slack, so an unfold can never immediately re-trigger its own fold. + */ +export function useBrowserChromeToolFold( + rowRef: RefObject, + stages: readonly BrowserChromeFoldStage[] +): ReadonlySet { + const stagesKey = stages.join(',') + const [foldedCount, setFoldedCount] = useState(0) + const level = Math.min(foldedCount, stages.length) + const freedRef = useRef<{ stagesKey: string; widths: number[] }>({ stagesKey, widths: [] }) + const pendingRef = useRef<(RowMeasure & { level: number }) | null>(null) + + const checkRef = useRef<() => void>(() => {}) + checkRef.current = () => { + if (freedRef.current.stagesKey !== stagesKey) { + freedRef.current = { stagesKey, widths: [] } + pendingRef.current = null + } + const measure = measureRow(rowRef.current) + if (!measure) { + return + } + const freed = freedRef.current.widths + const pending = pendingRef.current + if (pending && pending.level === level) { + freed[level - 1] = measure.address - pending.address + (pending.overflow - measure.overflow) + pendingRef.current = null + } + const shortfall = + Math.max(0, BROWSER_CHROME_ADDRESS_MIN_WIDTH_PX - measure.address) + measure.overflow + if (shortfall > 0.5 && level < stages.length) { + pendingRef.current = { ...measure, level: level + 1 } + setFoldedCount(level + 1) + return + } + const slack = measure.address - BROWSER_CHROME_ADDRESS_MIN_WIDTH_PX + if (shortfall <= 0.5 && level > 0 && slack >= (freed[level - 1] ?? 0)) { + setFoldedCount(level - 1) + } + } + + // Why layout effect: each fold step re-measures before paint, so a squeeze never flashes clipped. + useLayoutEffect(() => { + checkRef.current() + }, [level, stagesKey]) + + useLayoutEffect(() => { + const row = rowRef.current + const slot = row?.querySelector(`[${BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE}]`) + if (!row || typeof ResizeObserver === 'undefined') { + return + } + // Why both: the slot resizes when a sibling tool appears or hides, but stops resizing once it + // hits zero while the row keeps overflowing. + const observer = new ResizeObserver(() => checkRef.current()) + observer.observe(row) + if (slot) { + observer.observe(slot) + } + return () => observer.disconnect() + }, [rowRef]) + + return new Set(stages.slice(0, level)) +} diff --git a/src/renderer/src/components/browser-pane/browser-client-page-metadata-route-census.test.ts b/src/renderer/src/components/browser-pane/browser-client-page-metadata-route-census.test.ts deleted file mode 100644 index 6937de9e6e4..00000000000 --- a/src/renderer/src/components/browser-pane/browser-client-page-metadata-route-census.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' - -/** - * Page metadata has exactly one way out of this renderer: main, which owns the browser-host lease. - * - * Why a census and not a behavioural test: the failure this guards is silent. The runtime accepts - * page traffic only on the connection its lease attached on, so a publish sent as an ordinary - * runtime call is refused as a stale lease and simply never lands — the pane keeps working, the - * guest keeps navigating, and only the runtime's copy of the URL stays frozen at the create URL. - * Nothing in the rendered result changes, so only the route itself can be asserted. - */ -const ROUTE_PATH = fileURLToPath( - new URL('./browser-client-page-metadata-reporting.ts', import.meta.url) -) -const PANE_PATH = fileURLToPath(new URL('./ClientHostedBrowserPagePane.tsx', import.meta.url)) - -describe('client-hosted page metadata route', () => { - const route = readFileSync(ROUTE_PATH, 'utf8') - const pane = readFileSync(PANE_PATH, 'utf8') - - it('publishes through main and never through a plain runtime call', () => { - expect(occurrences(route, 'window.api.browser.publishClientPageMetadata')).toBe(1) - expect(occurrences(route, 'runtimeEnvironments.call')).toBe(0) - expect(occurrences(pane, 'runtimeEnvironments.call')).toBe(0) - }) - - it('reports a publish that did not land', () => { - expect(occurrences(route, 'onUnpublished:')).toBe(1) - }) - - // The pane must not build its own publisher around the route: a second construction site is a - // second chance to wire the transport wrongly, and it would not be covered by the counts above. - it('leaves the pane one way to start publishing', () => { - expect(occurrences(pane, 'createBrowserClientPageMetadataPublisher(')).toBe(0) - expect(occurrences(pane, 'startBrowserClientPageMetadataPublisher(')).toBe(1) - }) -}) - -function occurrences(source: string, needle: string): number { - return source.split(needle).length - 1 -} diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-guest-paint-retention.ts b/src/renderer/src/components/browser-pane/host-guest/browser-guest-paint-retention.ts index 0d1364c139b..941c527b38f 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-guest-paint-retention.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-guest-paint-retention.ts @@ -61,7 +61,6 @@ export function useBrowserGuestPaintRetention(browserPageIds: readonly string[]) // Why one exported predicate rather than the same OR-list at each site: a hand-rolled copy stays // green when a term is added — nothing typechecks a site that never names the new signal — and the // remote-viewer term reached the panes while four copies in Terminal.tsx still had three terms. -// browser-guest-retention-site-census.test.ts holds the sites to this function. export function browserPageNeedsPaintRetention(browserPageId: string): boolean { return ( isBrowserAutomationVisible(browserPageId) || diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-guest-retention-site-census.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-guest-retention-site-census.test.ts deleted file mode 100644 index 5d8c6028329..00000000000 --- a/src/renderer/src/components/browser-pane/host-guest/browser-guest-retention-site-census.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { readdirSync, readFileSync } from 'node:fs' -import { join, relative, sep } from 'node:path' -import { describe, expect, it } from 'vitest' - -// Guest retention is an OR over several independent signals, and every container from the app -// shell down to the guest has to agree on the list — Chromium stops painting inside a display:none -// subtree, so the *most* pinched ancestor wins. Adding a term (the remote-viewer signal, STA-4150) -// reached the panes while four hand-rolled copies still listed three: nothing typechecks a site -// that never names the new symbol. So the terms live behind one helper, and this census holds the -// sites to it — the classification is enforced here, not by review. - -const RENDERER_SRC = join(__dirname, '..', '..', '..') - -// The three term stores. -const RETENTION_TERM_STORES = [ - 'components/browser-pane/host-guest/browser-automation-visibility.ts', - 'lib/pane-manager/browser-mobile-driver-state.ts', - 'lib/pane-manager/browser-remote-viewer-state.ts' -] - -// ...plus the single module allowed to OR their terms together. -const RETENTION_TERM_OWNERS = [ - ...RETENTION_TERM_STORES, - 'components/browser-pane/host-guest/browser-guest-paint-retention.ts' -] - -// Every way a term store lets a caller read one term. A site that hand-rolls the OR-list from -// readers missing here would pass the census, so the last check holds this list to the exports. -const RETENTION_TERM_SYMBOLS = [ - 'isBrowserAutomationVisible', - 'useBrowserAutomationVisibilityForAny', - 'getBrowserAutomationVisiblePageIds', - 'onBrowserAutomationVisibilityChange', - 'isBrowserPageMobileDriven', - 'hasMobileDriverForAnyBrowserPage', - 'useBrowserMobileDriverForAny', - 'getBrowserMobileDrivenPageIds', - 'getDriverForBrowserPage', - 'useBrowserDriverForPage', - 'onBrowserDriverChange', - 'isBrowserPageRemotelyViewed', - 'hasRemoteViewerForAnyBrowserPage', - 'useBrowserRemoteViewerForAny', - 'getBrowserRemotelyViewedPageIds', - 'onBrowserRemoteViewerChange' -] - -const RETENTION_HELPER_SYMBOLS = [ - 'useAnyBrowserGuestNeedsPaint', - 'useBrowserGuestPaintRetention', - 'browserPageNeedsPaintRetention', - 'onBrowserGuestPaintRetentionChange', - 'browserTabsVetoGuestEviction' -] - -// Every place that decides whether a browser guest keeps painting, and the helper it must use. -const RETENTION_SITES = new Map([ - ['components/TerminalWorkbenchContainer.tsx', ['useAnyBrowserGuestNeedsPaint']], - // The two outermost workbench wrappers: strict ancestors of every guest, so the per-worktree - // surface hatch below cannot rescue a guest either one parked with `hidden`. - ['components/TerminalSurface.tsx', ['useAnyBrowserGuestNeedsPaint']], - ['components/TerminalSplitWorkspaceSurfaces.tsx', ['useAnyBrowserGuestNeedsPaint']], - ['components/TerminalWorktreeSplitSurface.tsx', ['useBrowserGuestPaintRetention']], - [ - 'components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx', - ['useBrowserGuestPaintRetention'] - ], - [ - 'components/browser-pane/host-guest/browser-guest-worktree-retention.ts', - ['browserPageNeedsPaintRetention'] - ], - [ - 'components/use-terminal-browser-retention.ts', - ['browserTabsVetoGuestEviction', 'onBrowserGuestPaintRetentionChange'] - ] -]) - -// The per-page threading hooks are the carve-out: they hand one boolean per page to -// browser-page-paintability's required fields, so a missing term IS a typecheck error there. They -// still travel as a set — a caller that reads two of the three is the same omission bug. -const PER_PAGE_RETENTION_HOOKS = [ - 'useBrowserAutomationVisiblePageIds', - 'useBrowserMobileDrivenPageIds', - 'useBrowserRemotelyViewedPageIds' -] - -// One production read of a term is not a retention decision: the pane reads *who* drives the active -// page to label the overlay and lock input. Naming the exception per file rather than dropping the -// symbol keeps every other file that reads it a census failure. -const NON_RETENTION_TERM_READERS = new Map([ - [ - 'components/browser-pane/assemble-chrome/browser-workspace-pane.tsx', - ['useBrowserDriverForPage'] - ] -]) - -// Writers, hydrators and the idle sentinel: they set or seed a term rather -// than read it, so naming one is not a retention decision. -const NON_READER_TERM_EXPORTS = [ - 'acquireBrowserAutomationVisibility', - 'releaseBrowserAutomationVisibility', - 'setDriverForBrowserPage', - 'hydrateBrowserDrivers', - 'IDLE_BROWSER_DRIVER', - 'setRemoteViewersForBrowserPage', - 'hydrateBrowserRemoteViewerPages' -] - -function productionSources(): Map { - const sources = new Map() - for (const entry of readdirSync(RENDERER_SRC, { recursive: true, withFileTypes: true })) { - if (!entry.isFile() || !/\.tsx?$/.test(entry.name) || entry.name.endsWith('.d.ts')) { - continue - } - if (/\.test\.tsx?$/.test(entry.name) || /test-(harness|rig|fixtures)/.test(entry.name)) { - continue - } - const filePath = join(entry.parentPath, entry.name) - sources.set( - relative(RENDERER_SRC, filePath).split(sep).join('/'), - readFileSync(filePath, 'utf8') - ) - } - return sources -} - -function namedSymbols(source: string, symbols: readonly string[]): string[] { - return symbols.filter((symbol) => new RegExp(`\\b${symbol}\\b`).test(source)) -} - -describe('browser guest retention site census', () => { - const sources = productionSources() - - it('keeps every individual retention term behind the shared helper', () => { - const offenders = [...sources] - .filter(([file]) => !RETENTION_TERM_OWNERS.includes(file)) - .map(([file, source]) => ({ - file, - terms: namedSymbols(source, RETENTION_TERM_SYMBOLS).filter( - (term) => !(NON_RETENTION_TERM_READERS.get(file) ?? []).includes(term) - ) - })) - .filter(({ terms }) => terms.length > 0) - .map(({ file, terms }) => `${file}: ${terms.join(', ')}`) - .sort() - - expect( - offenders, - 'A retention site that names terms one by one silently keeps its old list when a new signal ' + - 'is added. Call useBrowserGuestPaintRetention / browserPageNeedsPaintRetention instead, ' + - 'and register the site in RETENTION_SITES.' - ).toEqual([]) - }) - - it('registers every consumer of the shared retention helper', () => { - const consumers = [...sources] - .filter( - ([file]) => file !== 'components/browser-pane/host-guest/browser-guest-paint-retention.ts' - ) - .filter(([, source]) => namedSymbols(source, RETENTION_HELPER_SYMBOLS).length > 0) - .map(([file]) => file) - .sort() - - expect(consumers, 'New retention sites must be listed in RETENTION_SITES.').toEqual( - [...RETENTION_SITES.keys()].sort() - ) - }) - - it('keeps each registered site consulting retention through its helper', () => { - const missing: string[] = [] - for (const [file, requiredSymbols] of RETENTION_SITES) { - const source = sources.get(file) - expect(source, `${file} is registered as a retention site but no longer exists`).toBeDefined() - for (const symbol of requiredSymbols) { - if (!new RegExp(`\\b${symbol}\\b`).test(source ?? '')) { - missing.push(`${file}: ${symbol}`) - } - } - } - expect(missing, 'A registered retention site stopped consulting retention.').toEqual([]) - }) - - it('keeps the per-page threading hooks travelling as a full set', () => { - const partial = [...sources] - .filter(([file]) => !RETENTION_TERM_OWNERS.includes(file)) - .map(([file, source]) => ({ file, hooks: namedSymbols(source, PER_PAGE_RETENTION_HOOKS) })) - .filter(({ hooks }) => hooks.length > 0 && hooks.length < PER_PAGE_RETENTION_HOOKS.length) - .map(({ file, hooks }) => `${file}: only ${hooks.join(', ')}`) - .sort() - - expect( - partial, - 'A pane that threads some retention terms per page must thread all of them.' - ).toEqual([]) - }) - - // Keeps the symbol lists above from going stale: a term store that grows a new reader nobody - // classified would otherwise be a free way to hand-roll the OR-list. - it('classifies every value a term store exports', () => { - const classified = new Set([ - ...RETENTION_TERM_SYMBOLS, - ...PER_PAGE_RETENTION_HOOKS, - ...NON_READER_TERM_EXPORTS - ]) - const unclassified: string[] = [] - for (const file of RETENTION_TERM_STORES) { - const source = sources.get(file) - expect(source, `${file} is registered as a term store but no longer exists`).toBeDefined() - for (const [, name] of (source ?? '').matchAll(/^export (?:function|const|let) (\w+)/gm)) { - if (!classified.has(name)) { - unclassified.push(`${file}: ${name}`) - } - } - } - - expect( - unclassified, - 'A term store exports something this census does not classify. Add a reader to ' + - 'RETENTION_TERM_SYMBOLS (or PER_PAGE_RETENTION_HOOKS, for the per-page hooks typecheck ' + - 'already covers) so hand-rolled sites keep failing, and a writer to NON_READER_TERM_EXPORTS.' - ).toEqual([]) - }) -}) diff --git a/src/renderer/src/components/browser-pane/navigate/browser-address-bar-navigation.test.ts b/src/renderer/src/components/browser-pane/navigate/browser-address-bar-navigation.test.ts index a2f4cdc38cc..2faf06e4929 100644 --- a/src/renderer/src/components/browser-pane/navigate/browser-address-bar-navigation.test.ts +++ b/src/renderer/src/components/browser-pane/navigate/browser-address-bar-navigation.test.ts @@ -16,6 +16,17 @@ vi.mock('@/i18n/i18n', () => ({ import { resolveBrowserAddressBarSubmission } from './browser-address-bar-navigation' describe('resolveBrowserAddressBarSubmission', () => { + it.each([ + ['example.com:8443/docs', 'https://example.com:8443/docs'], + ['app.localhost:3000', 'http://app.localhost:3000/'] + ])('submits %s as a navigation for local and client-hosted panes', (input, url) => { + expect(resolveBrowserAddressBarSubmission(input)).toEqual({ status: 'navigate', url }) + expect(resolveBrowserAddressBarSubmission(input, { allowFileUrls: false })).toEqual({ + status: 'navigate', + url + }) + }) + it('falls back to the configured search engine instead of parsing a query as a host', () => { storeState.browserDefaultSearchEngine = 'google' expect(resolveBrowserAddressBarSubmission('google maps')).toEqual({ diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx index fb563f4c54b..f447b5432d1 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx @@ -5,6 +5,7 @@ import type { BrowserPage as BrowserPageState } from '../../../../../shared/brow import { runtimeEnvironmentSupportsCapability } from '@/runtime/runtime-rpc-client' import { convertBrowserPageToWorkspaceDoc } from '@/lib/file-preview' import { openWorkspaceBrowserTab } from '@/lib/workspace-browser-tab-open' +import { resolveBrowserSourceUnifiedTab } from '@/lib/browser-workspace-source-resolution' import { useBrowserPageChromeFocus } from '../assemble-chrome/use-browser-page-chrome-focus' import { useBrowserAddressBarEditSession } from '../assemble-chrome/use-browser-address-bar-edit-session' import { useElementGuestFocus } from '../assemble-chrome/browser-page-guest-focus' @@ -354,10 +355,17 @@ export function RemoteBrowserPagePane({ onOpenLinkInOrcaBrowser={() => { const linkUrl = contextMenu.linkUrl! setContextMenu(null) + const sourceUnifiedTab = resolveBrowserSourceUnifiedTab( + useAppStore.getState(), + browserTab.id, + worktreeId + ) void openWorkspaceBrowserTab({ workspaceId: worktreeId, url: linkUrl, + ...(sourceUnifiedTab ? { afterTabId: sourceUnifiedTab.id } : {}), focusOnCreate: false, + selectWorktree: false, intent: { kind: 'url' }, expectedRuntimeEnvironmentId: runtimeEnvironmentId, placementPreference: 'server' diff --git a/src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.host-connection.test.tsx b/src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.host-connection.test.tsx new file mode 100644 index 00000000000..26ee11c77ce --- /dev/null +++ b/src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.host-connection.test.tsx @@ -0,0 +1,225 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorktreeHostConnection } from '@/lib/worktree-host-connection-phase' +import type { SshConnectionStatus } from '../../../../shared/ssh-types' + +const mocks = vi.hoisted(() => { + const hostConnection: WorktreeHostConnection = { + phase: 'connecting', + targetId: 'target-a', + environmentId: null, + publishedStatus: 'connecting', + connectedEpoch: null + } + const route: { executionHostId: string | null } = { executionHostId: 'ssh:target-a' } + return { hostConnection, ...route, prepare: vi.fn() } +}) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getExecutionHostIdForWorktree: () => mocks.executionHostId +})) +vi.mock('@/lib/worktree-host-connection-phase', () => ({ + selectWorktreeHostConnectionPhase: () => mocks.hostConnection, + useWorktreeHostConnection: () => mocks.hostConnection +})) + +import { useSshWorkspaceBrowserRoute } from './use-ssh-workspace-browser-route' + +const READY_PARTITION = 'persist:orca-browser-v1-routed' + +const settle = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + +const STATUS_BY_PHASE: Record = { + local: null, + connecting: 'connecting', + connected: 'connected', + unavailable: 'disconnected', + unverifiable: null +} + +function setHost( + phase: WorktreeHostConnection['phase'], + connectionGeneration: number | null = null +): void { + mocks.hostConnection = { + phase, + targetId: 'target-a', + environmentId: null, + publishedStatus: STATUS_BY_PHASE[phase], + connectedEpoch: phase === 'connected' ? `target-a:${connectionGeneration}` : null + } +} + +describe('useSshWorkspaceBrowserRoute under a reconnecting SSH host', () => { + beforeEach(() => { + mocks.prepare.mockReset() + mocks.executionHostId = 'ssh:target-a' + Object.defineProperty(window, 'api', { + configurable: true, + value: { browser: { prepareSshWorkspacePartition: mocks.prepare } } + }) + }) + afterEach(() => cleanup()) + + it('waits while the host connects, then prepares exactly once when it connects', async () => { + setHost('connecting') + mocks.prepare.mockResolvedValue({ partition: READY_PARTITION }) + const { result, rerender } = renderHook(() => useSshWorkspaceBrowserRoute('wt-1', null)) + await settle() + expect(result.current.state).toEqual({ kind: 'preparing' }) + expect(mocks.prepare).not.toHaveBeenCalled() + + setHost('connected', 1) + rerender() + await settle() + expect(mocks.prepare).toHaveBeenCalledOnce() + expect(result.current.state).toEqual({ + kind: 'ready', + partition: READY_PARTITION, + targetId: 'target-a' + }) + }) + + it('re-derives an ssh-unavailable error without Retry once the host connects', async () => { + setHost('unavailable') + mocks.prepare.mockRejectedValueOnce(new Error('browser_local_route_ssh_unavailable')) + mocks.prepare.mockResolvedValueOnce({ partition: READY_PARTITION }) + const { result, rerender } = renderHook(() => useSshWorkspaceBrowserRoute('wt-1', null)) + await settle() + expect(result.current.state.kind).toBe('error') + + setHost('connecting') + rerender() + await settle() + // Why: a dial is a transient; the classified card stays until the host actually connects. + expect(result.current.state.kind).toBe('error') + expect(mocks.prepare).toHaveBeenCalledOnce() + + setHost('connected', 1) + rerender() + await settle() + expect(mocks.prepare).toHaveBeenCalledTimes(2) + expect(result.current.state.kind).toBe('ready') + }) + + it('re-prepares a failed route when the host reconnects under a new generation', async () => { + setHost('connected', 1) + mocks.prepare.mockRejectedValueOnce(new Error('browser_local_route_ssh_unavailable')) + mocks.prepare.mockResolvedValueOnce({ partition: READY_PARTITION }) + const { result, rerender } = renderHook(() => useSshWorkspaceBrowserRoute('wt-1', null)) + await settle() + expect(result.current.state.kind).toBe('error') + + setHost('connected', 2) + rerender() + await settle() + expect(mocks.prepare).toHaveBeenCalledTimes(2) + expect(result.current.state.kind).toBe('ready') + }) + + it('prepares once while connected and keeps a ready page mounted across a reconnect', async () => { + setHost('connected', 1) + mocks.prepare.mockResolvedValue({ partition: READY_PARTITION }) + const { result, rerender } = renderHook(() => useSshWorkspaceBrowserRoute('wt-1', null)) + await settle() + rerender() + rerender() + await settle() + expect(mocks.prepare).toHaveBeenCalledOnce() + + setHost('connecting', 1) + rerender() + await settle() + expect(result.current.state.kind).toBe('ready') + + setHost('connected', 2) + rerender() + await settle() + expect(mocks.prepare).toHaveBeenCalledOnce() + expect(result.current.state.kind).toBe('ready') + }) + + it('keeps a failed route on its card while another pane redials the host, then recovers on connect', async () => { + // The e2e sequence: the user disconnects, a browser tab classifies the dead host, and the + // workspace's terminal redials it. Dial transients must not swap the card for "preparing". + setHost('unavailable') + mocks.prepare.mockRejectedValueOnce(new Error('browser_local_route_ssh_unavailable')) + mocks.prepare.mockResolvedValueOnce({ partition: READY_PARTITION }) + const { result, rerender } = renderHook(() => useSshWorkspaceBrowserRoute('wt-1', null)) + await settle() + const card = result.current.state + expect(card).toMatchObject({ kind: 'error', errorKind: 'ssh-unavailable' }) + + for (const phase of ['connecting', 'unavailable', 'connecting', 'unavailable'] as const) { + setHost(phase) + rerender() + await settle() + expect(result.current.state).toEqual(card) + } + expect(mocks.prepare).toHaveBeenCalledOnce() + + setHost('connected', 2) + rerender() + await settle() + expect(mocks.prepare).toHaveBeenCalledTimes(2) + expect(result.current.state.kind).toBe('ready') + }) + + it('waits for the connect when Retry is pressed while the host dials', async () => { + setHost('unavailable') + mocks.prepare.mockRejectedValueOnce(new Error('browser_local_route_ssh_unavailable')) + mocks.prepare.mockResolvedValueOnce({ partition: READY_PARTITION }) + const { result, rerender } = renderHook(() => useSshWorkspaceBrowserRoute('wt-1', null)) + await settle() + expect(result.current.state.kind).toBe('error') + + setHost('connecting') + rerender() + await settle() + act(() => result.current.retry()) + await settle() + expect(result.current.state).toEqual({ kind: 'preparing' }) + expect(mocks.prepare).toHaveBeenCalledOnce() + + setHost('connected', 1) + rerender() + await settle() + expect(mocks.prepare).toHaveBeenCalledTimes(2) + expect(result.current.state.kind).toBe('ready') + }) + + it('waits for a dialing host when the route becomes routed mid-dial', async () => { + // Why: an unrouted route is not an answer for the target it just gained. + mocks.executionHostId = null + setHost('connecting') + mocks.prepare.mockResolvedValue({ partition: READY_PARTITION }) + const { result, rerender } = renderHook(() => useSshWorkspaceBrowserRoute('wt-1', null)) + await settle() + expect(result.current.state).toEqual({ kind: 'unrouted' }) + + mocks.executionHostId = 'ssh:target-a' + rerender() + await settle() + expect(result.current.state).toEqual({ kind: 'preparing' }) + expect(mocks.prepare).not.toHaveBeenCalled() + + setHost('connected', 1) + rerender() + await settle() + expect(mocks.prepare).toHaveBeenCalledOnce() + expect(result.current.state.kind).toBe('ready') + }) + + it('prepares normally for a host it cannot verify instead of waiting on it', async () => { + setHost('unverifiable') + mocks.prepare.mockResolvedValue({ partition: READY_PARTITION }) + const { result } = renderHook(() => useSshWorkspaceBrowserRoute('wt-1', null)) + await settle() + expect(mocks.prepare).toHaveBeenCalledOnce() + expect(result.current.state.kind).toBe('ready') + }) +}) diff --git a/src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.ts b/src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.ts index b7a1e432664..6af3275c14c 100644 --- a/src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.ts +++ b/src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { useAppStore } from '@/store' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { useWorktreeHostConnection } from '@/lib/worktree-host-connection-phase' import { resolveSshWorkspaceBrowserRouteEligibility } from '@/lib/ssh-workspace-browser-route-eligibility' export type SshWorkspaceBrowserRouteErrorKind = 'forwarding-blocked' | 'ssh-unavailable' | 'unknown' @@ -64,6 +65,24 @@ export function useSshWorkspaceBrowserRoute( const [state, setState] = useState( targetId ? { kind: 'preparing' } : { kind: 'unrouted' } ) + const hostConnection = useWorktreeHostConnection(worktreeId) + const routeHost = + targetId !== null && hostConnection.targetId === targetId ? hostConnection : null + // Why only an unsettled route waits: a dial is a transient, so it must not unmount a ready + // page or swap a failed route's card for "preparing". A connect (below) re-derives a failed one. + // Unrouted and another target's page are unsettled too: neither answers for this target. + const routeReady = state.kind === 'ready' && state.targetId === targetId + const awaitingHost = routeHost?.phase === 'connecting' && state.kind !== 'error' && !routeReady + const connectedHostEpoch = routeHost?.connectedEpoch ?? null + const [seenConnectedHostEpoch, setSeenConnectedHostEpoch] = useState(connectedHostEpoch) + if (connectedHostEpoch !== seenConnectedHostEpoch) { + setSeenConnectedHostEpoch(connectedHostEpoch) + // Why: a host that connects — or reconnects under a new generation — re-derives a route + // it failed or held, the same as Retry, but keeps the user's probe choice. + if (connectedHostEpoch !== null && !routeReady) { + setAttempt((current) => ({ ...current, count: current.count + 1 })) + } + } // Why: a persisted "Try anyway" means the user vouched for this host once // (e.g. PermitOpen allows their sites while the loopback probe is refused); @@ -74,8 +93,13 @@ export function useSshWorkspaceBrowserRoute( setState({ kind: 'unrouted' }) return } - let cancelled = false setState({ kind: 'preparing' }) + // Why: the SSH connection is still being established, so prepare could only fail with + // ssh-unavailable; the connected transition above restarts it. + if (awaitingHost) { + return + } + let cancelled = false window.api.browser .prepareSshWorkspacePartition({ targetId, @@ -100,7 +124,7 @@ export function useSshWorkspaceBrowserRoute( return () => { cancelled = true } - }, [targetId, browserProfileId, attempt, skipProbe]) + }, [targetId, browserProfileId, attempt, skipProbe, awaitingHost]) // Why (review P1-1): `state` lags one commit behind a targetId transition on // an already-mounted instance; returning stale 'unrouted' (or a stale @@ -109,13 +133,21 @@ export function useSshWorkspaceBrowserRoute( // routed/unrouted decision must be derived from targetId in-render. const effectiveState: SshWorkspaceBrowserRouteState = !targetId ? { kind: 'unrouted' } - : state.kind === 'unrouted' || (state.kind === 'ready' && state.targetId !== targetId) + : awaitingHost || + state.kind === 'unrouted' || + (state.kind === 'ready' && state.targetId !== targetId) ? { kind: 'preparing' } : state + const rederive = (nextSkipProbe: boolean): void => { + // Why: landing on preparing with the new attempt lets the effect's first run see a dialing + // host and wait, instead of starting a prepare that its own preparing write then cancels. + setState({ kind: 'preparing' }) + setAttempt((current) => ({ count: current.count + 1, skipProbe: nextSkipProbe })) + } return { state: effectiveState, targetId: sshTargetId, - retry: () => setAttempt((current) => ({ count: current.count + 1, skipProbe: false })), + retry: () => rederive(false), tryWithoutProbe: () => { // Persist the override so this host isn't re-nagged on every launch. if (sshTargetId && probeSkippedTargetIds?.includes(sshTargetId) !== true) { @@ -126,7 +158,7 @@ export function useSshWorkspaceBrowserRoute( ] }) } - setAttempt((current) => ({ count: current.count + 1, skipProbe: true })) + rederive(true) }, browseFromThisDevice: () => { if (!sshTargetId) { diff --git a/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx b/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx index aaee9c6243f..602581dc16d 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx +++ b/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx @@ -621,11 +621,11 @@ describe('HtmlDocPreview guest focus', () => { beforeEach(() => { focused = [] - focusSpy = vi - .spyOn(HTMLElement.prototype, 'focus') - .mockImplementation(function (this: HTMLElement) { - focused.push(this) - }) + focusSpy = vi.spyOn(HTMLElement.prototype, 'focus').mockImplementation(function ( + this: HTMLElement + ) { + focused.push(this) + }) container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) diff --git a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-overflow-menu.tsx b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-overflow-menu.tsx index 801d153b28b..29e35d8f87e 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-overflow-menu.tsx +++ b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-overflow-menu.tsx @@ -8,6 +8,10 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { translate } from '@/i18n/i18n' +import { + BrowserChromeFoldedMenuItems, + type BrowserChromeOverflowMenuProps +} from '../assemble-chrome/browser-chrome-folded-tools' /** * The preview's overflow menu. It carries the document actions rather than the browsing pane's @@ -18,7 +22,8 @@ export function DocPreviewOverflowMenu({ onHardReload, onOpenSource, onCopyPath, - onCopyRelativePath + onCopyRelativePath, + overflow }: { onReload: () => void onHardReload: () => void @@ -26,11 +31,13 @@ export function DocPreviewOverflowMenu({ onCopyPath: () => void /** Why it lives here: the preview hides the editor's path header, which was the only way to copy it. */ onCopyRelativePath: () => void + overflow: BrowserChromeOverflowMenuProps }): React.JSX.Element { return ( - + + {translate('auto.components.browser.pane.BrowserPane.0e080d820e', 'Reload')} diff --git a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-toolbar.tsx b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-toolbar.tsx index df8f9116ca2..871323a9aef 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-toolbar.tsx +++ b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-toolbar.tsx @@ -102,7 +102,8 @@ export function DocPreviewToolbar({ label: translate( 'auto.components.editor.HtmlDocPreview.openSourceControl', 'Open source file' - ) + ), + alreadyInOverflowMenu: true }} openExternal={{ onSelect: onOpenExternally, @@ -111,15 +112,16 @@ export function DocPreviewToolbar({ 'Open with default app' ) }} - overflowMenu={ + overflowMenu={(overflow) => ( - } + )} /> ) } diff --git a/src/renderer/src/components/confirmation-dialog-refresh-boundary.test.ts b/src/renderer/src/components/confirmation-dialog-refresh-boundary.test.ts index 952cb21e23d..b2f1db2de1f 100644 --- a/src/renderer/src/components/confirmation-dialog-refresh-boundary.test.ts +++ b/src/renderer/src/components/confirmation-dialog-refresh-boundary.test.ts @@ -1,33 +1,11 @@ // @vitest-environment happy-dom -import { createRequire } from 'node:module' import { createElement, type ReactNode } from 'react' import { renderHook } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import * as contextModule from '@/components/confirmation-dialog-context' -import * as providerModule from '@/components/confirmation-dialog' import { ConfirmationDialogProvider } from '@/components/confirmation-dialog' import { useConfirmationDialog } from '@/components/confirmation-dialog-context' -// react-refresh ships no types; take the one binding this needs. -const { isLikelyComponentType } = createRequire(import.meta.url)('react-refresh/runtime') as { - isLikelyComponentType: (value: unknown) => boolean -} - describe('confirmation dialog Fast Refresh boundary', () => { - it('exports nothing from the provider module that invalidates the refresh boundary', () => { - expect(Object.keys(providerModule)).toEqual(['ConfirmationDialogProvider']) - expect(isLikelyComponentType(providerModule.ConfirmationDialogProvider)).toBe(true) - }) - - it('keeps the context and hook in a component-free module', () => { - const components = Object.entries(contextModule) - .filter(([, value]) => isLikelyComponentType(value)) - .map(([name]) => name) - - expect(components).toEqual([]) - expect(typeof contextModule.useConfirmationDialog).toBe('function') - }) - it('resolves the hook against the context the provider publishes', () => { const wrapper = ({ children }: { children: ReactNode }) => createElement(ConfirmationDialogProvider, null, children) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx index f95815b2552..06e2cb38721 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx @@ -88,6 +88,7 @@ vi.mock('@xterm/xterm', () => ({ scrollToBottom = vi.fn() selectAll = vi.fn() getSelection = vi.fn(() => this.selectionText) + hasSelection = vi.fn(() => this.selectionText !== '') attachCustomKeyEventHandler = vi.fn((handler: (event: KeyboardEvent) => boolean) => { this.customKeyHandler = handler }) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.option-dead-key.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.option-dead-key.test.tsx index 62f5b5b2a1d..65e7026291b 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.option-dead-key.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.option-dead-key.test.tsx @@ -37,6 +37,7 @@ vi.mock('@xterm/xterm', () => ({ scrollToBottom = vi.fn() selectAll = vi.fn() getSelection = vi.fn(() => '') + hasSelection = vi.fn(() => false) attachCustomKeyEventHandler = vi.fn((handler: (event: KeyboardEvent) => boolean) => { this.customKeyHandler = handler }) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-geometry.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-geometry.test.tsx new file mode 100644 index 00000000000..8ddb8a8ae75 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-geometry.test.tsx @@ -0,0 +1,254 @@ +// @vitest-environment happy-dom + +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { ITerminalOptions } from '@xterm/xterm' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { TerminalPreviewConnectResult } from '../../../../shared/terminal-preview' +import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' +import { useAppStore } from '@/store' +import { AgentTerminalPreview } from './AgentTerminalPreview' + +type PreviewInstance = { + options: ITerminalOptions + container: HTMLElement | null + dispose: ReturnType +} +const harness = vi.hoisted(() => ({ instances: new Array() })) + +vi.mock('@xterm/xterm', () => ({ + Terminal: class { + cols = 80 + rows = 24 + buffer = { active: { cursorY: 14 } } + container: HTMLElement | null = null + screen = document.createElement('div') + write = vi.fn((_data: string, callback?: () => void) => callback?.()) + focus = vi.fn() + resize = vi.fn() + reset = vi.fn() + onData = vi.fn(() => ({ dispose: vi.fn() })) + dispose = vi.fn(() => this.screen.remove()) + constructor(public options: ITerminalOptions) { + harness.instances.push(this) + } + open(container: HTMLElement): void { + this.container = container + this.screen.className = 'xterm-screen' + Object.defineProperties(this.screen, { + offsetWidth: { + get: () => + this.cols * + (this.options.fontSize === 18 || + this.options.fontFamily?.includes('Fira Code') || + this.options.fontWeight === 900 || + this.options.fontWeightBold === 900 + ? 12 + : 10) + }, + offsetHeight: { get: () => this.rows * 16 * (this.options.lineHeight ?? 1) } + }) + const box = container.parentElement + if (!box) { + throw new Error('Missing preview box') + } + Object.defineProperties(box, { + clientWidth: { configurable: true, value: 600 }, + clientHeight: { configurable: true, value: 240 } + }) + container.append(this.screen) + } + } +})) +vi.mock('@/components/terminal-pane/terminal-user-input-signal', () => ({ + subscribeToTerminalUserInput: () => ({ dispose: vi.fn() }) +})) +vi.mock('@/components/terminal-pane/use-system-prefers-dark', () => ({ + useSystemPrefersDark: () => false +})) +vi.mock('@/lib/keyboard-layout/use-effective-mac-option-as-alt', () => ({ + useEffectiveMacOptionAsAlt: (value: string) => value +})) +vi.mock('./preview-terminal-ligatures', () => ({ syncPreviewTerminalLigatures: vi.fn() })) +vi.mock('./preview-terminal-compatibility', () => ({ + installPreviewTerminalCompatibility: () => vi.fn() +})) +vi.mock('./preview-terminal-ime-bridge', () => ({ + installPreviewImeBridge: () => ({ claimKeyEvent: () => false, dispose: vi.fn() }) +})) +vi.mock('./preview-terminal-key-handler', () => ({ + installPreviewTerminalKeyHandler: () => vi.fn() +})) +vi.mock('@/components/terminal-pane/terminal-native-copy-gutter', () => ({ + installTerminalNativeCopyGutterTrim: () => ({ dispose: vi.fn() }) +})) +vi.mock('./preview-terminal-app-menu-clipboard', () => ({ + installPreviewTerminalAppMenuClipboard: () => vi.fn() +})) +vi.mock('./preview-terminal-right-click-paste', () => ({ + installPreviewTerminalRightClickPaste: () => vi.fn() +})) + +const initial = useAppStore.getInitialState() +const connect = vi.fn() +const fit = vi.fn(async (_ptyId: string, cols: number, rows: number) => ({ cols, rows })) +const unsubscribe = vi.fn(async () => {}) +let settings: GlobalSettings +let originalApi: PropertyDescriptor | undefined + +beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + harness.instances.length = 0 + // No resize notification or later output: replay must perform its own fit and grid claim. + vi.stubGlobal( + 'ResizeObserver', + class { + observe = vi.fn() + disconnect = vi.fn() + } + ) + settings = createGlobalSettingsFixture({ + theme: 'dark', + activeRuntimeEnvironmentId: null, + terminalFontSize: 14, + terminalFontFamily: 'JetBrains Mono', + terminalFontWeight: 500, + terminalFontWeightBold: 700, + terminalLineHeight: 1, + terminalLigatures: 'off' + }) + connect + .mockReset() + .mockResolvedValue({ snapshot: { data: '', cols: 80, rows: 24, seq: 1 }, replay: [] }) + originalApi = Object.getOwnPropertyDescriptor(window, 'api') + Object.defineProperty(window, 'api', { + configurable: true, + value: { + settings: { + set: async (updates: Partial) => { + settings = structuredClone({ ...settings, ...updates }) + return settings + } + }, + terminalPreview: { connect, fit, unsubscribe, onData: () => vi.fn() } + } + }) + useAppStore.setState({ ...initial, settings }, true) +}) + +afterEach(() => { + cleanup() + for (const instance of harness.instances) { + expect(instance.dispose).toHaveBeenCalledOnce() + } + useAppStore.setState(initial, true) + if (originalApi) { + Object.defineProperty(window, 'api', originalApi) + } else { + Reflect.deleteProperty(window, 'api') + } + vi.unstubAllGlobals() + vi.useRealTimers() +}) + +async function settleGeometry(): Promise { + await act(async () => { + await vi.advanceTimersByTimeAsync(250) + }) +} + +it.each([ + { + updates: { terminalFontSize: 18 }, + cols: 50, + rows: 15, + scale: 'scale(0.625)', + anchor: 'top left' + }, + { + updates: { terminalFontFamily: 'Fira Code' }, + cols: 50, + rows: 15, + scale: 'scale(0.625)', + anchor: 'top left' + }, + { + updates: { terminalFontWeight: 900 }, + cols: 50, + rows: 15, + scale: 'scale(0.625)', + anchor: 'top left' + }, + { + updates: { terminalFontWeightBold: 900 }, + cols: 50, + rows: 15, + scale: 'scale(0.625)', + anchor: 'top left' + }, + { + updates: { terminalLineHeight: 1.5 }, + cols: 60, + rows: 10, + scale: 'scale(0.75)', + anchor: 'bottom left' + }, + { + updates: { terminalLigatures: 'on' }, + cols: 60, + rows: 15, + scale: 'scale(0.75)', + anchor: 'top left' + } +] satisfies { + updates: Partial + cols: number + rows: number + scale: string + anchor: string +}[])( + 'recreates the owner and fits the new geometry for $updates', + async ({ updates, cols, rows, scale, anchor }) => { + render() + await settleGeometry() + expect(fit).toHaveBeenLastCalledWith('pty-1', 60, 15) + await act(async () => useAppStore.getState().updateSettings(updates)) + await settleGeometry() + + expect(fit).toHaveBeenCalledTimes(2) + expect(fit).toHaveBeenLastCalledWith('pty-1', cols, rows) + expect(harness.instances).toHaveLength(2) + expect(connect).toHaveBeenCalledTimes(2) + expect(unsubscribe).toHaveBeenCalledExactlyOnceWith('pty-1') + expect(harness.instances[1]?.container?.style.transform).toBe(scale) + expect(harness.instances[1]?.container?.style.transformOrigin).toBe(anchor) + } +) + +it('ignores a pending connection retired by a metric change', async () => { + const gate = Promise.withResolvers() + connect.mockReturnValueOnce(gate.promise) + render() + await act(async () => useAppStore.getState().updateSettings({ terminalFontSize: 18 })) + await settleGeometry() + await act(async () => + gate.resolve({ snapshot: { data: '', cols: 80, rows: 24, seq: 1 }, replay: [] }) + ) + await settleGeometry() + expect(connect).toHaveBeenCalledTimes(2) + expect(harness.instances).toHaveLength(1) + expect(harness.instances[0]?.options.fontSize).toBe(18) + expect(fit).toHaveBeenCalledExactlyOnceWith('pty-1', 50, 15) +}) + +it('cancels an obsolete metric owner grid claim before the next change', async () => { + render() + await settleGeometry() + await act(async () => useAppStore.getState().updateSettings({ terminalFontSize: 18 })) + await act(async () => useAppStore.getState().updateSettings({ terminalLineHeight: 1.5 })) + await settleGeometry() + expect(harness.instances).toHaveLength(3) + expect(fit).toHaveBeenCalledTimes(2) + expect(fit).toHaveBeenLastCalledWith('pty-1', 50, 10) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx new file mode 100644 index 00000000000..84c0242e186 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx @@ -0,0 +1,292 @@ +// @vitest-environment happy-dom + +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ITerminalOptions } from '@xterm/xterm' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { TerminalPreviewConnectResult } from '../../../../shared/terminal-preview' +import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' +import { makeCustomTerminalThemeSelection } from '../../../../shared/terminal-custom-themes' +import { useAppStore } from '@/store' +import { AgentTerminalPreview } from './AgentTerminalPreview' + +type PreviewInstance = { + options: ITerminalOptions + dispose: ReturnType + reset: ReturnType +} + +const harness = vi.hoisted(() => ({ + instances: new Array(), + systemPrefersDark: false +})) + +vi.mock('@xterm/xterm', () => ({ + Terminal: class { + cols = 80 + rows = 24 + buffer = { active: { cursorY: 0 } } + options: ITerminalOptions + write = vi.fn((_data: string, callback?: () => void) => callback?.()) + open = vi.fn() + focus = vi.fn() + dispose = vi.fn() + resize = vi.fn() + reset = vi.fn() + onData = vi.fn(() => ({ dispose: vi.fn() })) + constructor(options: ITerminalOptions) { + this.options = options + harness.instances.push(this) + } + } +})) +vi.mock('@/components/terminal-pane/terminal-user-input-signal', () => ({ + subscribeToTerminalUserInput: () => ({ dispose: vi.fn() }) +})) +vi.mock('@/components/terminal-pane/use-system-prefers-dark', () => ({ + useSystemPrefersDark: () => harness.systemPrefersDark +})) +vi.mock('@/lib/keyboard-layout/use-effective-mac-option-as-alt', () => ({ + useEffectiveMacOptionAsAlt: (value: string) => value +})) +vi.mock('./preview-grid-claim', () => ({ + createPreviewGridClaim: () => ({ schedule: vi.fn(), dispose: vi.fn() }) +})) +vi.mock('./preview-terminal-box-fit', () => ({ + createPreviewBoxFit: () => ({ schedule: vi.fn() }) +})) +vi.mock('./preview-terminal-ligatures', () => ({ syncPreviewTerminalLigatures: vi.fn() })) +vi.mock('./preview-terminal-compatibility', () => ({ + installPreviewTerminalCompatibility: () => vi.fn() +})) +vi.mock('./preview-terminal-ime-bridge', () => ({ + installPreviewImeBridge: () => ({ claimKeyEvent: () => false, dispose: vi.fn() }) +})) +vi.mock('./preview-terminal-key-handler', () => ({ + installPreviewTerminalKeyHandler: () => vi.fn() +})) +vi.mock('@/components/terminal-pane/terminal-native-copy-gutter', () => ({ + installTerminalNativeCopyGutterTrim: () => ({ dispose: vi.fn() }) +})) +vi.mock('./preview-terminal-app-menu-clipboard', () => ({ + installPreviewTerminalAppMenuClipboard: () => vi.fn() +})) +vi.mock('./preview-terminal-right-click-paste', () => ({ + installPreviewTerminalRightClickPaste: () => vi.fn() +})) + +const initial = useAppStore.getInitialState() +const connect = vi.fn() +const unsubscribe = vi.fn(async () => {}) +const offData = vi.fn() +const onData = vi.fn((listener: Parameters[0]) => { + emitData = listener + return offData +}) +const connection: TerminalPreviewConnectResult = { + snapshot: { data: 'agent prompt', cols: 80, rows: 24, seq: 1 }, + replay: [] +} +let settings: GlobalSettings +let originalApi: PropertyDescriptor | undefined +let emitData: Parameters[0] | undefined + +async function updateSettings(updates: Partial): Promise { + await act(async () => useAppStore.getState().updateSettings(updates)) +} + +function terminal(index = 0): PreviewInstance { + const instance = harness.instances[index] + if (!instance) { + throw new Error(`Missing terminal ${index}`) + } + return instance +} + +beforeEach(() => { + vi.clearAllMocks() + harness.instances.length = 0 + harness.systemPrefersDark = false + emitData = undefined + settings = createGlobalSettingsFixture({ theme: 'dark', activeRuntimeEnvironmentId: null }) + connect.mockReset().mockResolvedValue(connection) + originalApi = Object.getOwnPropertyDescriptor(window, 'api') + Object.defineProperty(window, 'api', { + configurable: true, + value: { + settings: { + set: async (updates: Partial) => { + settings = structuredClone({ ...settings, ...updates }) + return settings + } + }, + terminalPreview: { + connect, + unsubscribe, + onData + } + } + }) + useAppStore.setState({ ...initial, settings }, true) +}) + +afterEach(() => { + cleanup() + for (const instance of harness.instances) { + expect(instance.dispose).toHaveBeenCalledOnce() + } + expect(offData).toHaveBeenCalledTimes(onData.mock.calls.length) + useAppStore.setState(initial, true) + if (originalApi) { + Object.defineProperty(window, 'api', originalApi) + } else { + Reflect.deleteProperty(window, 'api') + } +}) + +describe('preview terminal settings lifetime', () => { + it('keeps one preview connection across ten unrelated settings snapshots', async () => { + render() + await act(async () => {}) + const theme = terminal().options.theme + for (let index = 1; index <= 10; index += 1) { + await updateSettings({ editorAutoSaveDelayMs: 1000 + index }) + } + expect(connect).toHaveBeenCalledExactlyOnceWith('pty-1', { scrollbackRows: 24 }) + expect(harness.instances).toHaveLength(1) + expect(terminal().options.theme).toBe(theme) + expect(unsubscribe).not.toHaveBeenCalled() + }) + + it('updates cursor options on the existing terminal', async () => { + render() + await act(async () => {}) + await updateSettings({ + terminalCursorStyle: 'bar', + terminalCursorBlink: false + }) + expect(connect).toHaveBeenCalledOnce() + expect(terminal().options).toMatchObject({ + cursorStyle: 'bar', + cursorBlink: false + }) + }) + + it.each([ + { terminalThemeDark: 'Builtin Tango Light' }, + { terminalColorOverrides: { background: '#123456' } }, + { terminalBackgroundOpacity: 0.5 }, + { terminalCursorOpacity: 0.5 }, + { terminalMinimumContrastRatio: 7 } + ] satisfies Partial[])( + 'replaces the terminal when theme or contrast changes: %o', + async (updates) => { + render() + await act(async () => {}) + await updateSettings(updates) + expect(connect).toHaveBeenCalledTimes(2) + expect(unsubscribe).toHaveBeenCalledExactlyOnceWith('pty-1') + expect(terminal().dispose).toHaveBeenCalledOnce() + expect(harness.instances).toHaveLength(2) + if ('terminalMinimumContrastRatio' in updates) { + expect(terminal(1).options.minimumContrastRatio).toBe(7) + } else { + expect(terminal(1).options.theme).not.toEqual(terminal().options.theme) + } + } + ) + + it('keeps a cloned imported theme but reconnects when its selected colors change', async () => { + const custom = { + id: 'manual:preview', + name: 'Preview', + source: 'manual' as const, + mode: 'dark' as const, + terminal: { background: '#123456', foreground: '#eeeeee', red: '#ff0000' }, + importedAt: '2026-09-25' + } + await updateSettings({ + terminalCustomThemes: [custom], + terminalThemeDark: makeCustomTerminalThemeSelection(custom.id) + }) + render() + await act(async () => {}) + expect(terminal().options.theme?.background).toBe('#123456') + await updateSettings({ editorAutoSaveDelayMs: 1234 }) + expect(connect).toHaveBeenCalledOnce() + await updateSettings({ + terminalCustomThemes: [{ ...custom, terminal: { ...custom.terminal, background: '#654321' } }] + }) + expect(connect).toHaveBeenCalledTimes(2) + expect(terminal(1).options.theme?.background).toBe('#654321') + }) + + it('reconnects when the effective OS theme changes', async () => { + await updateSettings({ theme: 'system' }) + const view = render() + await act(async () => {}) + harness.systemPrefersDark = true + view.rerender() + await act(async () => {}) + expect(connect).toHaveBeenCalledTimes(2) + expect(terminal(1).options.theme).not.toEqual(terminal().options.theme) + }) + + it('uses current live options when an unrelated update arrives during connect', async () => { + const gate = Promise.withResolvers() + connect.mockReturnValueOnce(gate.promise) + render() + await updateSettings({ terminalCursorStyle: 'bar' }) + expect(connect).toHaveBeenCalledOnce() + await act(async () => gate.resolve(connection)) + expect(harness.instances).toHaveLength(1) + expect(terminal().options.cursorStyle).toBe('bar') + }) + + it('ignores an old pending connection after a relevant theme change', async () => { + const gate = Promise.withResolvers() + connect.mockReturnValueOnce(gate.promise) + render() + await updateSettings({ terminalMinimumContrastRatio: 7 }) + expect(connect).toHaveBeenCalledTimes(2) + expect(harness.instances).toHaveLength(1) + await act(async () => gate.resolve(connection)) + expect(harness.instances).toHaveLength(1) + expect(terminal().options.minimumContrastRatio).toBe(7) + }) + + it('does not install a terminal after unmounting a pending connection', async () => { + const gate = Promise.withResolvers() + connect.mockReturnValueOnce(gate.promise) + const view = render() + await updateSettings({ editorAutoSaveDelayMs: 1234 }) + view.unmount() + await act(async () => gate.resolve(connection)) + expect(harness.instances).toHaveLength(0) + expect(connect).toHaveBeenCalledOnce() + expect(unsubscribe).toHaveBeenCalledExactlyOnceWith('ssh:host@@pty-1') + }) + + it('keeps a hidden mounted preview connected through unrelated settings updates', async () => { + render( + + ) + await act(async () => {}) + await updateSettings({ editorAutoSaveDelayMs: 1234 }) + expect(connect).toHaveBeenCalledOnce() + expect(unsubscribe).not.toHaveBeenCalled() + }) + + it('still resyncs the same terminal after unrelated settings updates', async () => { + render() + await act(async () => {}) + await updateSettings({ editorAutoSaveDelayMs: 1234 }) + await act(async () => emitData?.({ type: 'resync', ptyId: 'pty-1' })) + expect(connect).toHaveBeenCalledTimes(2) + expect(harness.instances).toHaveLength(1) + expect(terminal().reset).toHaveBeenCalledOnce() + expect(unsubscribe).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx index 4a63a289551..d31f7293e76 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx @@ -80,6 +80,7 @@ vi.mock('@xterm/xterm', () => ({ scrollToBottom = vi.fn() selectAll = vi.fn() getSelection = vi.fn(() => this.selectionText) + hasSelection = vi.fn(() => this.selectionText !== '') attachCustomKeyEventHandler = vi.fn((handler: (event: KeyboardEvent) => boolean) => { this.customKeyHandler = handler }) @@ -213,7 +214,8 @@ describe('AgentTerminalPreview', () => { expect(input).toHaveBeenCalledTimes(1) expect(input).toHaveBeenCalledWith('pty-1', 'k') - act(() => terminal.writeCallbacks.shift()?.()) + // Why drain all: the connection's kitty restore write queues ahead of the live chunk. + act(() => terminal.writeCallbacks.splice(0).forEach((callback) => callback())) expect(ack).toHaveBeenCalledWith('pty-1', 4) }) @@ -271,6 +273,11 @@ describe('AgentTerminalPreview', () => { render() await waitFor(() => expect(imeHarness.forwarders).toHaveLength(1)) await waitFor(() => expect(imeHarness.forwarders[0]!.getKittyKeyboardFlags()).toBe(8)) + // The popout xterm gets the same flags, so its encoder agrees with the mirror. + expect(terminalHarness.instances[0]!.write).toHaveBeenCalledWith( + '\x1b[<99u\x1b[=8u', + expect.any(Function) + ) // Live output keeps advancing the same mirror the forwarder reads. act(() => { @@ -358,6 +365,37 @@ describe('AgentTerminalPreview', () => { expect(writeTerminalClipboardText).not.toHaveBeenCalled() }) + it('hands unselected Cmd+C to a kitty app and copies a selection instead', async () => { + platformState.value = 'darwin' + render() + await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) + const terminal = terminalHarness.instances[0]! + await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull()) + const cmdC = (type: string): KeyboardEvent => + new KeyboardEvent(type, { key: 'c', code: 'KeyC', metaKey: true, cancelable: true }) + + // A plain shell's unselected Cmd+C sends nothing. + expect(terminal.customKeyHandler!(cmdC('keydown'))).toBe(false) + expect(terminal.customKeyHandler!(cmdC('keyup'))).toBe(false) + + act(() => { + emitData?.({ type: 'data', ptyId: 'pty-1', data: '\x1b[>1u', bytes: 5 }) + }) + expect(terminal.customKeyHandler!(cmdC('keydown'))).toBe(true) + expect(terminal.customKeyHandler!(cmdC('keyup'))).toBe(true) + + // A highlight of blank cells copies no text but is still Orca's selection, as in the pane. + Object.assign(terminal, { hasSelection: () => true }) + expect(terminal.customKeyHandler!(cmdC('keydown'))).toBe(false) + expect(terminal.customKeyHandler!(cmdC('keyup'))).toBe(false) + expect(writeTerminalClipboardText).not.toHaveBeenCalled() + + terminal.selectionText = 'selected text' + expect(terminal.customKeyHandler!(cmdC('keydown'))).toBe(false) + expect(terminal.customKeyHandler!(cmdC('keyup'))).toBe(false) + expect(writeTerminalClipboardText).toHaveBeenCalledWith('selected text') + }) + it('selects all terminal text on Cmd+A and blocks xterm handling', async () => { platformState.value = 'darwin' render() diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx index f0360d2b795..875f13a96f4 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx @@ -1,5 +1,6 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { Terminal } from '@xterm/xterm' +import { Terminal, type ITheme } from '@xterm/xterm' +import { useShallow } from 'zustand/react/shallow' import '@xterm/xterm/css/xterm.css' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { subscribeToTerminalUserInput } from '@/components/terminal-pane/terminal-user-input-signal' @@ -10,7 +11,8 @@ import { replayPreviewConnectionSnapshot } from './preview-terminal-snapshot-rep import { useEffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/use-effective-mac-option-as-alt' import { buildPreviewAppearanceOptions, - buildPreviewTerminalOptions + buildPreviewTerminalOptions, + previewAdvertisesKittyKeyboard } from './preview-terminal-options' import { syncPreviewTerminalLigatures } from './preview-terminal-ligatures' import { installPreviewTerminalCompatibility } from './preview-terminal-compatibility' @@ -71,7 +73,7 @@ export function AgentTerminalPreview({ const settingsRef = useRef(settings) const macOptionAsAltRef = useRef(macOptionAsAlt) const terminalInputRef = useRef(terminalInput) - const { terminalTheme, terminalMode } = useMemo(() => { + const { terminalTheme: composedTheme, terminalMode } = useMemo(() => { if (!settings) { return { terminalTheme: null, terminalMode: 'dark' as const } } @@ -82,6 +84,10 @@ export function AgentTerminalPreview({ ) return { terminalTheme: theme, terminalMode: appearance.mode } }, [settings, systemPrefersDark]) + // Settings arrive as cloned snapshots; compare theme values before reconnecting. + const retainTheme = useShallow((theme: ITheme | null) => theme) + const terminalTheme = retainTheme(composedTheme) + const terminalMinimumContrastRatio = settings?.terminalMinimumContrastRatio // A null snapshot means no serializer knows this pty (it died or was never // spawned this session) — say so instead of painting a silent blank terminal. const [ptyGone, setPtyGone] = useState(false) @@ -96,6 +102,7 @@ export function AgentTerminalPreview({ terminalInputRef.current = terminalInput }, [settings, macOptionAsAlt, terminalInput]) + // Font changes retain the replay-driven fit, grid claim and input-owner reset. useEffect(() => { setPtyGone(false) const container = containerRef.current @@ -110,9 +117,13 @@ export function AgentTerminalPreview({ let disposeKeyHandler: (() => void) | null = null let disposeNativeCopyGutterTrim: (() => void) | null = null let disposeTerminalCompatibility: (() => void) | null = null + // Why one read: the xterm's advertisement and its mirror must never disagree. + const mountTerminalInput = terminalInputRef.current // Why: mirrors the pane's tracker — the policy needs the flags the TUI // negotiated, and this preview parses the same output stream the pane does. - const kittyKeyboardModes = new TerminalKittyKeyboardModeTracker() + const kittyKeyboardModes = new TerminalKittyKeyboardModeTracker({ + kittyKeyboard: previewAdvertisesKittyKeyboard(mountTerminalInput) + }) let refreshInFlight = false let refreshAgain = false let retryTimer: ReturnType | null = null @@ -217,22 +228,6 @@ export function AgentTerminalPreview({ }) } - const installNativeCopyGutterTrim = (): void => { - if (!terminal) { - return - } - disposeNativeCopyGutterTrim = installTerminalNativeCopyGutterTrim(terminal).dispose - } - - const installTerminalCompatibility = (): void => { - if (!terminal) { - return - } - disposeTerminalCompatibility = installPreviewTerminalCompatibility(terminal, { - getSettings: () => settingsRef.current - }) - } - const installInputRouting = (): void => { if (!terminal) { return @@ -264,7 +259,7 @@ export function AgentTerminalPreview({ terminal = new Terminal( buildPreviewTerminalOptions({ settings: settingsRef.current, - terminalInput: terminalInputRef.current, + terminalInput: mountTerminalInput, macOptionIsMeta: macOptionAsAltRef.current === 'true', theme: terminalTheme, themeMode: terminalMode, @@ -281,8 +276,10 @@ export function AgentTerminalPreview({ return } terminalRef.current = terminal - installTerminalCompatibility() - installNativeCopyGutterTrim() + disposeTerminalCompatibility = installPreviewTerminalCompatibility(terminal, { + getSettings: () => settingsRef.current + }) + disposeNativeCopyGutterTrim = installTerminalNativeCopyGutterTrim(terminal).dispose installInputRouting() installImeNativeTextBridge() installKeyHandler() @@ -412,7 +409,18 @@ export function AgentTerminalPreview({ terminal?.dispose() terminalRef.current = null } - }, [ptyId, terminalTheme, terminalMode]) + }, [ + ptyId, + terminalTheme, + terminalMode, + terminalMinimumContrastRatio, + settings?.terminalFontSize, + settings?.terminalFontFamily, + settings?.terminalFontWeight, + settings?.terminalFontWeightBold, + settings?.terminalLineHeight, + settings?.terminalLigatures + ]) // Why: appearance settings must land on the open terminal, and the OS input // source can flip Option-as-Alt with no settings change at all. A remount diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.ts index 1b263144a53..faaeb0048dc 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.ts @@ -14,6 +14,7 @@ import { type PreviewShortcutContext } from './preview-terminal-shortcuts' import { readTerminalClipboardSelection } from '@/components/terminal-pane/terminal-clipboard-selection-text' +import { isAppOwnedCopyChord } from '@/components/terminal-pane/xterm-bypass-policy' /** * Installs the preview terminal's ONE custom key handler (xterm allows a single @@ -110,6 +111,15 @@ export function installPreviewTerminalKeyHandler(args: { nativeOnlyShortcutTracker.prepareKeyDown(event) const keybindings = useAppStore.getState().keybindings if (keybindingMatchesAction('terminal.copySelection', event, platform, keybindings)) { + if ( + isAppOwnedCopyChord(event, { + isMac: platform === 'darwin', + hasSelection: terminal.hasSelection(), + kittyKeyboardFlags: args.getShortcutContext().getKittyKeyboardFlags() + }) + ) { + return true + } const selection = readTerminalClipboardSelection(terminal) if ( !selection && diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-options.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-options.ts index c27f085029d..3bf324e385d 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-options.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-options.ts @@ -45,6 +45,13 @@ export function buildPreviewAppearanceOptions( } } +// Why: local ConPTY CLIs read the advertisement but can't decode CSI-u (#2434); mirror the pane's withhold. +export function previewAdvertisesKittyKeyboard( + terminalInput: DashboardCardTerminalInput | null +): boolean { + return !terminalInput || terminalInput.kittyKeyboardAdvertised +} + /** * Full option set for the preview's xterm: the same defaults, user appearance, * and host compatibility flags a pane resolves, so the agent's TUI negotiates @@ -65,10 +72,9 @@ export function buildPreviewTerminalOptions(args: { ...(args.terminalInput?.localWindowsConpty ? buildLocalConptyTerminalOptions(args.terminalInput.osRelease) : {}), - // Why: local ConPTY CLIs read the advertisement but can't decode CSI-u (#2434); mirror the pane's withhold. - ...(args.terminalInput && !args.terminalInput.kittyKeyboardAdvertised - ? { vtExtensions: { kittyKeyboard: false } } - : {}) + ...(previewAdvertisesKittyKeyboard(args.terminalInput) + ? {} + : { vtExtensions: { kittyKeyboard: false } }) } return { ...buildDefaultTerminalOptions(), diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts index 3e288626259..f3b3ed4f800 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts @@ -110,7 +110,7 @@ describe('replayPreviewConnectionSnapshot', () => { expect(modes.snapshotFlags).toBe(0) }) - it('writes scrollback, frame, escape tail, then replay, in that order', () => { + it('writes scrollback, frame, kitty restore, escape tail, then replay, in that order', () => { const { written } = apply( { scrollbackAnsi: 'history', @@ -119,6 +119,6 @@ describe('replayPreviewConnectionSnapshot', () => { }, [{ data: 'tail', mode: 'live' }] ) - expect(written).toEqual(['history', 'frame', '\x1b[', 'tail']) + expect(written).toEqual(['history', 'frame', '\x1b[<99u', '\x1b[', 'tail']) }) }) diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.ts index 43d4333cc17..9e684353eb7 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.ts @@ -1,5 +1,6 @@ import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' import { parseTerminalKittyKeyboardFlags } from '../../../../shared/terminal-kitty-keyboard-flags' +import { buildKittyKeyboardRestore } from '../../../../shared/terminal-mode-reset-profiles' import type { TerminalPreviewReplayChunk, TerminalPreviewSnapshot @@ -7,7 +8,7 @@ import type { /** * Apply snapshot + buffered replay, restoring proven kitty flags after the - * snapshot scan (snapshot ANSI omits kitty pushes). Synchronous so no browser + * snapshot bytes (snapshot ANSI omits kitty pushes). Synchronous so no browser * event observes the temporary reset. */ export function replayPreviewConnectionSnapshot(args: { @@ -29,12 +30,11 @@ export function replayPreviewConnectionSnapshot(args: { if (snapshot.data) { args.write(snapshot.data, false) } + // Why as bytes: the popout xterm must parse the same restore its mirror scans. + args.write(buildKittyKeyboardRestore(provenFlags), false) if (snapshot.pendingEscapeTailAnsi) { args.write(snapshot.pendingEscapeTailAnsi, false) } - if (provenFlags !== undefined) { - kittyKeyboardModes.restoreSnapshotFlags(provenFlags) - } for (const chunk of args.replay) { args.write(chunk.data, chunk.mode === 'live') } diff --git a/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.test.tsx b/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.test.tsx new file mode 100644 index 00000000000..6d0994eb760 --- /dev/null +++ b/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.test.tsx @@ -0,0 +1,157 @@ +/** @vitest-environment happy-dom */ +import { fireEvent, render, screen, cleanup } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CompactAgentRow } from '../sidebar/worktree-card-compact-agent-row' +import { TooltipProvider } from '../ui/tooltip' +import DashboardAgentRow from './DashboardAgentRow' +import type { DashboardAgentRow as AgentRow } from './useDashboardData' +import { DashboardAgentChildDisclosure } from './DashboardAgentChildDisclosure' + +vi.mock('./use-agent-row-conversation-name', () => ({ useAgentRowConversationName: () => null })) +vi.mock('../sidebar/CacheTimer', () => ({ + default: () => null, + usePromptCacheCountdownForPane: () => null +})) + +afterEach(cleanup) + +const agent: AgentRow = { + paneKey: 'tab:leaf', + agentType: 'claude', + state: 'working', + startedAt: 60000, + entry: { + paneKey: 'tab:leaf', + state: 'working', + prompt: 'Review the change', + updatedAt: 60000, + stateStartedAt: 60000, + stateHistory: [] + }, + tab: { + id: 'tab', + ptyId: null, + worktreeId: 'workspace', + title: 'Agent', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +describe('agent child disclosure', () => { + it('toggles children without activating the surrounding agent or workspace', () => { + const activate = vi.fn() + const toggle = vi.fn() + const pointerDown = vi.fn() + const keyDown = vi.fn() + render( +
+ +
+ ) + const button = screen.getByRole('button', { name: 'Show 2 child agents' }) + fireEvent.pointerDown(button) + fireEvent.keyDown(button, { key: 'Enter' }) + fireEvent.keyDown(button, { key: ' ' }) + fireEvent.click(button) + expect(toggle).toHaveBeenCalledTimes(1) + expect(activate).not.toHaveBeenCalled() + expect(pointerDown).not.toHaveBeenCalled() + expect(keyDown).not.toHaveBeenCalled() + }) + + it('updates the accessible action and retains the timestamp when expanded', () => { + const toggle = vi.fn() + const { rerender } = render( + + ) + expect( + screen.getByRole('button', { name: 'Show 1 child agent' }).getAttribute('aria-expanded') + ).toBe('false') + rerender( + + ) + expect( + screen.getByRole('button', { name: 'Hide 1 child agent' }).getAttribute('aria-expanded') + ).toBe('true') + expect(screen.getByText('5m')).toBeTruthy() + }) + + it('does not offer a disclosure without children or a toggle action', () => { + const { rerender } = render( + + ) + expect(screen.queryByRole('button')).toBeNull() + rerender() + expect(screen.queryByRole('button')).toBeNull() + }) +}) + +describe.each(['compact', 'full'] as const)('%s row disclosure actions', (mode) => { + it.each([undefined, 'eligible'] as const)( + 'keeps child expansion separate from row actions in %s send mode', + (sendTargetStatus) => { + const onActivate = vi.fn(), + onSendTargetClick = vi.fn(), + onDismiss = vi.fn(), + onToggleChildAgents = vi.fn() + const props = { + agent, + now: 120000, + childAgentCount: 2, + childAgentsExpanded: false, + onToggleChildAgents, + onActivate, + sendTargetStatus, + onSendTargetClick + } + render( + + {mode === 'compact' ? ( + + ) : ( + + )} + + ) + fireEvent.click(screen.getByRole('button', { name: 'Show 2 child agents' })) + expect(onToggleChildAgents).toHaveBeenCalledTimes(1) + expect(onActivate).not.toHaveBeenCalled() + expect(onSendTargetClick).not.toHaveBeenCalled() + expect(onDismiss).not.toHaveBeenCalled() + fireEvent.click(screen.getByText('Review the change')) + if (sendTargetStatus) { + expect(onSendTargetClick).toHaveBeenCalledWith(agent.paneKey) + expect(onActivate).not.toHaveBeenCalled() + } else { + expect(onActivate).toHaveBeenCalledWith(agent.tab.id, agent.paneKey) + if (mode === 'full') { + fireEvent.click(screen.getByRole('button', { name: 'Dismiss agent' })) + expect(onDismiss).toHaveBeenCalledWith(agent.paneKey) + expect(onActivate).toHaveBeenCalledTimes(1) + } + } + } + ) +}) diff --git a/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.tsx b/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.tsx index fb8e45b4216..4e225a287dd 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.tsx @@ -1,18 +1,21 @@ import React, { useCallback } from 'react' -import { ChevronRight } from 'lucide-react' +import { ChevronDown } from 'lucide-react' import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' type Props = { childAgentCount?: number childAgentsExpanded: boolean onToggleChildAgents?: () => void + timestamp?: React.ReactNode } export function DashboardAgentChildDisclosure({ childAgentCount, childAgentsExpanded, - onToggleChildAgents + onToggleChildAgents, + timestamp }: Props) { const hasChildDisclosure = typeof childAgentCount === 'number' && @@ -40,29 +43,46 @@ export function DashboardAgentChildDisclosure({ } return ( - + aria-expanded={childAgentsExpanded} + > + + + ) } diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index fb80712e65f..426fe8b57aa 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -4,17 +4,21 @@ import { AgentStateDot, agentStateLabel, type AgentDotState } from '@/components import { AgentIcon } from '@/lib/agent-catalog' import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { DashboardAgentChildDisclosure } from './DashboardAgentChildDisclosure' import { DashboardAgentRowMessage } from './DashboardAgentRowMessage' import { DashboardAgentRowTrailingControls } from './DashboardAgentRowTrailingControls' import { DashboardAgentRowToolStep } from './DashboardAgentRowToolStep' import { showsAgentToolPreview } from '@/lib/agent-row-tool-preview' import { agentNoUpdateLabel, formatCompactDuration } from '@/lib/agent-row-decay-state' -import { agentRowDotState as asDotState } from '@/lib/agent-row-dot-state' +import { agentRowDisplayDotState, agentRowDotState as asDotState } from '@/lib/agent-row-dot-state' +import { agentVerdictDisplayMark } from '../../../../shared/agent-main-agent-verdict' import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData' import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text' import { useAgentRowConversationName } from './use-agent-row-conversation-name' import { lastEnteredDoneAt } from './agent-finished-timestamp' +import { + agentChildRowMessageLine, + agentChildRowNoUpdateLabel +} from '@/components/agent-child-row-text' function formatTimeAgo(ts: number, now: number): string { const delta = now - ts @@ -24,19 +28,24 @@ function formatTimeAgo(ts: number, now: number): string { return `${formatCompactDuration(delta)} ago` } +// A child row's silence is the model's, on the clock its compact row and the strip read. +function rowNoUpdateLabel(agent: DashboardAgentRowData, now: number): string { + return agent.childRow + ? agentChildRowNoUpdateLabel(agent.childRow, now) + : agentNoUpdateLabel(agent.entry, now) +} + function stateDotTooltipLabel( agent: DashboardAgentRowData, dotState: AgentDotState, now: number ): string { - if (agent.entry.interrupted === true) { + if (dotState === 'interrupted') { return 'Interrupted by user' } // Why: report the observation, not a verdict on the agent — the elapsed gap is what // lets the user apply context Orca has no way to know (a long build, a slow download). - return dotState === 'unverifiable' - ? agentNoUpdateLabel(agent.entry, now) - : agentStateLabel(dotState) + return dotState === 'unverifiable' ? rowNoUpdateLabel(agent, now) : agentStateLabel(dotState) } type Props = { @@ -55,12 +64,10 @@ type Props = { hideExpand?: boolean /** Reuse the row's hover tint to show the focused terminal pane's agent. */ isFocusedPane?: boolean - // Why: inline-card orchestration rows fold children under a leading chevron. + // Why: inline-card orchestration rows can fold their child agents. childAgentCount?: number childAgentsExpanded?: boolean onToggleChildAgents?: () => void - // Why: a top-level chevron hangs in the card gutter so the state dot keeps the column of chevron-less rows. - disclosureInGutter?: boolean // Why: chevron indentation replaces fixed-offset lineage connector art. hideLineageConnectors?: boolean // Why: send-popover target mode makes row clicks send/no-op instead of navigating. @@ -82,7 +89,6 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ childAgentCount, childAgentsExpanded = false, onToggleChildAgents, - disclosureInGutter = false, hideLineageConnectors = false, sendTargetStatus, sendTargetDisabledReason, @@ -130,7 +136,11 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ const conversationName = useAgentRowConversationName(agent) const prompt = conversationName ?? getAgentRowPrimaryText(agent.entry) // Why: prompt is '' when unknown, so fall back to the state label to keep the row labeled. - const displayLabel = prompt || agentStateLabel(asDotState(agent.state, agent.entry.workingMode)) + const displayLabel = + prompt || + agentStateLabel( + agent.childRow?.displayState ?? asDotState(agent.state, agent.entry.workingMode) + ) const model = agent.entry.model?.trim() ?? '' const isMonitoring = agent.state === 'working' && agent.entry.workingMode === 'monitoring' const isWorking = agent.state === 'working' && !isMonitoring @@ -140,8 +150,11 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ const showsTool = showsAgentToolPreview(agent.state) && !isMonitoring const toolName = showsTool ? (agent.entry.toolName?.trim() ?? '') : '' const toolInput = showsTool ? (agent.entry.toolInput?.trim() ?? '') : '' - const lastAssistantMessage = agent.entry.lastAssistantMessage?.trim() ?? '' - const isInterrupted = agent.entry.interrupted === true + // Why: a child row's message line is the model's, so a child that ended without an outcome says so. + const lastAssistantMessage = agent.childRow + ? agentChildRowMessageLine(agent.childRow) + : (agent.entry.lastAssistantMessage?.trim() ?? '') + const isInterrupted = agentVerdictDisplayMark(agent.entry) === 'interrupted' const lineage = agent.lineage const isLineageChild = lineage?.depth === 1 const lineageChildCount = lineage?.childCount ?? 0 @@ -152,14 +165,13 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ lineageChildCount === 1 ? 'agent' : 'agents' }` : [formatAgentTypeLabel(agent.agentType), model].filter(Boolean).join(' · ') - // Why: interrupted is a terminal outcome, so surface it in the leading state dot. - const dotState: AgentDotState = isInterrupted - ? 'interrupted' - : asDotState(agent.state, agent.entry.workingMode) + // Why: a stop or a failure is a terminal outcome, so surface it in the leading state dot; a + // failure does so even while subagents still run. + const dotState: AgentDotState = agentRowDisplayDotState(agent) const dotTooltipLabel = stateDotTooltipLabel(agent, dotState, now) // Why: the elapsed gap is the whole content of an `unverifiable` row, so it rides the // row's own timestamp slot rather than hiding in a hover tooltip. - const noUpdateLabel = dotState === 'unverifiable' ? agentNoUpdateLabel(agent.entry, now) : null + const noUpdateLabel = dotState === 'unverifiable' ? rowNoUpdateLabel(agent, now) : null // Why: always show the chevron so the row's right edge doesn't flicker as content grows/shrinks. @@ -183,8 +195,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ onClick={handleActivate} className={cn( // Why: named group scopes the X-reveal to this row, not every row in the card. - 'group/agent-row relative flex flex-col py-1', - hasChildDisclosure && disclosureInGutter ? '-ml-7' : '-ml-2', + 'agent-disclosure-row group/agent-row relative -ml-2 flex flex-col py-1', isLineageChild ? 'pl-5 pr-2' : 'px-2', // Why: hover wash stays softer than the enclosing card's highlight. 'cursor-pointer rounded-sm worktree-agent-row-hover', @@ -227,11 +238,6 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ ) : null}
- {/* Why: state dot sits in the leading gutter so the eye can scan one column for row state. */} @@ -286,6 +292,9 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ void paneKey: string relativeTimestamp: string | null expanded: boolean @@ -18,6 +22,9 @@ type DashboardAgentRowTrailingControlsProps = { } export function DashboardAgentRowTrailingControls({ + childAgentCount, + childAgentsExpanded = false, + onToggleChildAgents, paneKey, relativeTimestamp, expanded, @@ -64,8 +71,15 @@ export function DashboardAgentRowTrailingControls({ [onSendTargetClick, paneKey, sendTargetStatus] ) + const hasChildDisclosure = Boolean(childAgentCount && onToggleChildAgents) + return ( - + {(sendTargetStatus === 'eligible' || sendTargetStatus === 'sending') && ( )} - {!sendTargetStatus && hideDismiss && relativeTimestamp !== null && ( + {!hasChildDisclosure && !sendTargetStatus && hideDismiss && relativeTimestamp !== null && ( )} - {!sendTargetStatus && !hideDismiss && relativeTimestamp === null && ( + {!sendTargetStatus && !hideDismiss && (hasChildDisclosure || relativeTimestamp === null) && ( )} + {hasChildDisclosure && ( + + {relativeTimestamp} + + } + /> + )} {!hideExpand && (